Repository navigation
Conversation
1 task done
AI091
force-pushed
the
fix-localecompare-reuse-collator
branch
from
November 13, 2026 16:17
3460541 to
067baa1
Compare
2 of 3 tasks
AI091
force-pushed
the
fix-localecompare-reuse-collator
branch
from
November 13, 2026 22:25
067baa1 to
440355a
Compare
AI091
force-pushed
the
fix-localecompare-reuse-collator
branch
from
November 13, 2026 22:30
440355a to
03e9799
Compare
AI091
marked this pull request as ready for review
November 13, 2026 22:32
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #867.
String.prototype.localeComparecreates a new platform collator on every call. On Android, that means a newcom.facebook.hermes.intl.Collatorthrough JNI every time, with locale resolution, an ICU collator clone and several finalizable objects. A sort callslocaleComparehundreds 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
localeComparecreated inJSLibStorage, together with the normalized locales and options it was created from. The next call reuses it if: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():LocaleMatchernow also returns the default it used,ULocale.getDefault(ULocale.Category.FORMAT)(Locale.getDefault()before API 24). The JavaCollatorkeeps 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 callsLocale.setDefault(in-app language switch), or a system language change in a running process, gets a new collator on the next call.getDefaultLocale()reads the default once per process, so it returnsfalse.Two symptoms are reported in #867. The measurements below cover both on Android:
localeCompareis generally over 10x slower than other engines. I did not benchmark other engines. On Android,localeCompareis about 1.6-1.8x slower than a reusedIntl.Collator; before the change it was 12-93x slower.WaitForGcToComplete blocked Alloc on Background for 2.538smatches 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,toLocaleDateStringandtoLocaleTimeString(DateTimeFormat)Number.prototype.toLocaleString(NumberFormat), whichArray.prototype.toLocaleStringreaches once per elementtoLocaleUpperCaseandtoLocaleLowerCaseon Android, which run the best-fit locale matcher on every callTest Plan
New tests
test/hermes/intl/locale-compare.js:de,sv,caseFirst,numeric) between consecutive calls and repeats them, so each cache hit and miss is checked against known orders.localeComparewith other arguments does not disturb the outer call.RangeErrorand leaves later calls correct.HermesIntlAndroidTest.testLocaleCompareFromAssetruns the same file on Android.HermesIntlAndroidTest.testLocaleCompareFollowsDefaultLocalechanges the default locale between calls in the same runtime:'ä'.localeCompare('z')must be1forsv-SEand-1forde-DE. It also covers a change of only theFORMATcategory default, and an unsupported explicit locale ('zz') that falls back to the default. It restores the general,FORMATandDISPLAYdefaults afterwards.To check that the second test catches a stale cache, I built a variant whose
defaultLocaleChanged()always returnsfalse. The test failed withexpected:<-1> but was:<1>.Android Intl suite (
./gradlew :intltest:connectedAndroidTest, API 36 x86_64 emulator)HermesIntlAndroidTesttests pass, including the two new ones.HermesIntlTest262evaluates the same 467 files before and after the change, and the same 52 fail both times (pre-existing failures, mostlyCollator/DateTimeFormatresolvedOptionsdetails). No new failures.MediumPhone.arm, API 34): all 10HermesIntlAndroidTesttests pass, andHermesIntlTest262fails the same 52 of 467 files asstatic_hon that image.Notes:
static_h,android/intltestdoes 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.defaultLocaleChanged()returnsfalse) 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:a.localeCompare(b), sort timea.localeCompare(b, 'en'), sort timenew Intl.Collator().compareAndroid emulator (x86_64, API 36, 192 MB Java heap):
a.localeCompare(b), sort timea.localeCompare(b, 'en'), sort timea.localeCompare(b, 'en')Java allocation / collections'en', 3,000 stringsnew Intl.Collator().compareThe
Intl.Collatorreference 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,localeCompareis still about 1.6-1.8x slower than a reusedIntl.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 Allocstalls (up to 22.8 s) in the system log. They went away when the app stopped callinglocaleCompare. Those traces come from that app and are not part of these measurements.The change also applies cleanly to
260318099.0.0-stable.