Skip to content

Reuse the collator across String.prototype.localeCompare calls - #2320

Open
AI091 wants to merge 1 commit into
meta:static_hfrom
AI091:fix-localecompare-reuse-collator
Open

AI091 wants to merge 1 commit into
meta:static_hfrom
AI091:fix-localecompare-reuse-collator

Conversation

@AI091

@AI091 AI091 commented Nov 13, 2026 •

Copy link
Copy Markdown

Summary

Fixes #867.

String.prototype.localeCompare creates a new platform collator on every call. On Android, that means a new com.facebook.hermes.intl.Collator through JNI every time, with locale resolution, an ICU collator clone and several finalizable objects. A sort calls localeCompare hundreds of thousands of times with the same arguments, so almost all of its time goes into building collators that are used once.

This change keeps the last collator localeCompare created in JSLibStorage, together with the normalized locales and options it was created from. The next call reuses it if:

  • the normalized locales and options are equal, and
  • the platform default locale has not changed since the collator was created.

Every observable read and conversion of the arguments (CanonicalizeLocaleList, the options getters) still happens on every call, before the cache lookup. A cache hit reuses a collator that was created successfully from the same normalized values, so the reuse is not observable from JavaScript.

The default-locale check is the new platform_intl::Collator::defaultLocaleChanged():

  • Android: when locale matching falls back to the default locale (no locales given, or none of them supported), LocaleMatcher now also returns the default it used, ULocale.getDefault(ULocale.Category.FORMAT) (Locale.getDefault() before API 24). The Java Collator keeps that value and compares it with the current default. Because the collator keeps the value resolution actually used, rather than reading the default a second time, another thread that changes the default during construction cannot leave a stale collator in the cache. A collator resolved from a requested locale does not depend on the default, so a default change does not replace it. An app that calls Locale.setDefault (in-app language switch), or a system language change in a running process, gets a new collator on the next call.
  • Apple: getDefaultLocale() reads the default once per process, so it returns false.

Two symptoms are reported in #867. The measurements below cover both on Android:

  1. localeCompare is generally over 10x slower than other engines. I did not benchmark other engines. On Android, localeCompare is about 1.6-1.8x slower than a reused Intl.Collator; before the change it was 12-93x slower.
  2. Rarely, a single call takes several seconds, reported on arm64. On an arm64 Android image this reproduces with the sort repro from the issue. The slowest call of each sort matches a garbage-collector wait on the JavaScript thread to the millisecond. For example, WaitForGcToComplete blocked Alloc on Background for 2.538s matches a 2,539 ms call. The JavaScript thread had to wait for ART's collector before it could allocate. The collators produce about 2 KB of Java garbage per call (about 950 MB per sort). On this image the concurrent collector does not keep up, so allocations block. With no locale argument, the x86_64 emulator (API 36) had no blocking collections, which fits "arm64 yes, x86_64 no" in the issue. With 'en' it had 8, totalling 134 ms.

Other Intl entry points create a platform object on every call in the same way. This PR does not change them:

  • Date.prototype.toLocaleString, toLocaleDateString and toLocaleTimeString (DateTimeFormat)
  • Number.prototype.toLocaleString (NumberFormat), which Array.prototype.toLocaleString reaches once per element
  • toLocaleUpperCase and toLocaleLowerCase on Android, which run the best-fit locale matcher on every call

Test Plan

New tests

  • test/hermes/intl/locale-compare.js:
    • Alternates locales and options (de, sv, caseFirst, numeric) between consecutive calls and repeats them, so each cache hit and miss is checked against known orders.
    • With a warm cache, checks that an options getter still runs on every call, that a throwing getter still throws, and that a getter calling localeCompare with other arguments does not disturb the outer call.
    • Checks that an invalid option still throws RangeError and leaves later calls correct.
  • HermesIntlAndroidTest.testLocaleCompareFromAsset runs the same file on Android.
  • HermesIntlAndroidTest.testLocaleCompareFollowsDefaultLocale changes the default locale between calls in the same runtime: 'ä'.localeCompare('z') must be 1 for sv-SE and -1 for de-DE. It also covers a change of only the FORMAT category default, and an unsupported explicit locale ('zz') that falls back to the default. It restores the general, FORMAT and DISPLAY defaults afterwards.

To check that the second test catches a stale cache, I built a variant whose defaultLocaleChanged() always returns false. The test failed with expected:<-1> but was:<1>.

Android Intl suite (./gradlew :intltest:connectedAndroidTest, API 36 x86_64 emulator)

  • All 10 HermesIntlAndroidTest tests pass, including the two new ones.
  • HermesIntlTest262 evaluates the same 467 files before and after the change, and the same 52 fail both times (pre-existing failures, mostly Collator/DateTimeFormat resolvedOptions details). No new failures.
  • The latest revision also ran on Firebase Test Lab's arm64 image (MediumPhone.arm, API 34): all 10 HermesIntlAndroidTest tests pass, and HermesIntlTest262 fails the same 52 of 467 files as static_h on that image.

Notes:

  • On static_h, android/intltest does not build with targetSdk 36: the manifest merge fails on androidx.test 1.1.1 / espresso 3.2.0 (android:exported needs to be explicitly specified). I bumped those test-only dependencies locally to run the suite (androidx.test.ext:junit 1.2.1, runner 1.6.2, core 1.6.1, espresso-core 3.6.1). That change is not in this PR. I can send it separately.
  • I have no macOS machine. The Apple change (defaultLocaleChanged() returns false) and the lit test on Apple are unbuilt and unrun. CI should cover them.

Measurements

The sort repro from #867: 30,000 random 8-letter strings, 474,595 comparisons, every call timed. Java collector counters (Debug.getRuntimeStat) were read around the sort. The test build was non-debuggable, with release native code. Results are per sort.

Firebase Test Lab arm64 virtual device (MediumPhone.arm, API 34, 192 MB Java heap), 3 rounds each:

before after
a.localeCompare(b), sort time 10.7, 20.6, 21.2 s 0.36, 0.41, 0.39 s
slowest single call 2,539, 4,962, 6,853 ms 3, 2, 9 ms
calls over 1 s 1, 9, 5 0, 0, 0
blocking Java collections (time) 26 (9.7 s), 23 (19.7 s), 25 (20.5 s) 0, 0, 0
Java allocation 952 MB 29 MB
a.localeCompare(b, 'en'), sort time not run 0.39, 0.41, 0.44 s
reference: new Intl.Collator().compare 0.23 s 0.22-0.29 s

Android emulator (x86_64, API 36, 192 MB Java heap):

before after
a.localeCompare(b), sort time 8.5, 7.8, 6.7, 7.6 s 0.88, 0.90, 0.88 s
Java allocation / collections about 1,025 MB / 830-900 32 MB / 47-56
a.localeCompare(b, 'en'), sort time 617.7 s (1 run) 1.03, 1.04, 0.95 s
a.localeCompare(b, 'en') Java allocation / collections 103.7 GB / 41,196 32 MB / 47-49
'en', 3,000 strings 37.9 s 0.10 s
reference: new Intl.Collator().compare 0.47-0.54 s 0.53-0.58 s

The Intl.Collator reference row is a code path this change does not touch. Its before/after ranges overlap across alternating runs on a shared host. With the change, localeCompare is still about 1.6-1.8x slower than a reused Intl.Collator. That difference is the per-call argument normalization and the default-locale check.

I did not run the patched engine on a physical phone (no device quota left). Separately, in a React Native 0.88 app on a physical Galaxy A03s and Galaxy A54, we saw the same WaitForGcToComplete blocked Alloc stalls (up to 22.8 s) in the system log. They went away when the app stopped calling localeCompare. Those traces come from that app and are not part of these measurements.

The change also applies cleanly to 260318099.0.0-stable.

@meta-cla meta-cla Bot added the CLA Signed Do not delete this pull request or issue due to inactivity. label Nov 13, 2026
@AI091 AI091 mentioned this pull request Nov 13, 2026
1 task done
@AI091
AI091 force-pushed the fix-localecompare-reuse-collator branch from 3460541 to 067baa1 Compare November 13, 2026 16:17
@AI091
AI091 force-pushed the fix-localecompare-reuse-collator branch from 067baa1 to 440355a Compare November 13, 2026 22:25
@AI091
AI091 force-pushed the fix-localecompare-reuse-collator branch from 440355a to 03e9799 Compare November 13, 2026 22:30
@AI091
AI091 marked this pull request as ready for review November 13, 2026 22:32

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed Do not delete this pull request or issue due to inactivity.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

localeCompare extremely slow sometimes

1 participant