Skip to content

VM startup performance fixes, -O3 Android release, and two upstream backports - #13

Merged
Flewp merged 9 commits into
250829098.0.15-discordfrom
flewp/hermes-opt-candidate
Nov 5, 2026
Merged

Flewp merged 9 commits into
250829098.0.15-discordfrom
flewp/hermes-opt-candidate

Conversation

@Flewp

@Flewp Flewp commented Oct 28, 2026 •

Copy link
Copy Markdown

Summary

Startup and runtime performance fixes for the 250829098.0.15 line, plus two upstream correctness backports. No bytecode-format or JSI changes, so existing hermesc output and RN integration are unaffected.

VM performance (AJ Palkovic):

  • Dictionary re-class limit. Deleting or redefining a property on a dictionary switched it to no-cache mode from the second change on. React Native's lazy globals redefine the global object 13 times at startup, so every read of Object, Array, Math and similar missed the property cache. Allows up to 15 cacheable re-classings first.
  • String → SymbolID cache. obj[key] with a non-interned key string re-hashed it on every access. Adds a 256-entry direct-mapped cache, cleared at GC start and before symbols are freed.
  • Object spread clone. {...src} into a fresh object shares the source's hidden class and copies slots in bulk, when the source is a plain object of default-flag, string-keyed data properties with the same prototype.
  • JSON.parse fast paths. Reuses existing add-transitions for keys, appends array elements directly, and lexes ASCII input in place.
  • Build knobs. The Android release CMake build type, heap mode and extra compile/link flags become configurable.

Defaults:

  • The Android release AAR builds with CMAKE_BUILD_TYPE=Release (-O3) instead of MinSizeRel (-Os), overridable with -PhermesReleaseCmakeBuildType. iOS is unchanged (the Apple script still uses MinSizeRel).

Hardening (follow-up to review):

  • Weak-reference read barrier on string → symbol cache hits.
  • static_assert tying the re-class limit to its 4-bit counter.
  • Removes a dead pinned local in parseArray.

Upstream backports (cherry-pick -x):

Base: 3e1261f2a7bc50ffe52f8354868e78e4e74bb0c8 (250829098.0.15-discord-4).

Testing

Local, macOS, Release builds of base and candidate:

  • check-hermes (lit): the candidate fails nothing that base passes. The one failure on both, hermes/truncate-long-stack.js, is environmental: its final CHECK-NEXT: end matches the substring "end" inside the checkout path on the at global (...) line.
  • Differential runs, base vs candidate, byte-identical output:
    • 20,000 generated JSON.parse inputs, including error messages;
    • JSON fast-path edge cases;
    • spread edge cases;
    • dictionary re-class;
    • string-cache GC stress;
    • an object-model fuzzer: 3,000 generated sources spread and Object.assigned (accessors, symbols, index-like keys, non-enumerable, frozen/sealed/non-extensible, dictionary-mode and null/foreign prototypes), 40 rounds of global-object redefinition and deletion, and JSON-derived computed keys with deletes across forced GCs.
  • ASan + UBSan + handle-sanitizer Debug build (slow-debug asserts on). No sanitizer reports or assertion failures:
    • At -gc-sanitize-handles=1 (heap moved on every allocation): dictionary re-class and JSON fast-path cases.
    • At 0.05: spread clone and the object-model fuzzer.
    • At 0.002 with four random seeds each: the 20k-input JSON fuzz and the string-cache GC stress. The JSON fuzz output is byte-identical to the base Release build for every seed.

CI on this branch covers test262 and lit on macOS, Linux x64, Linux arm64 and ARMv7. The forced-JIT macOS job fails on the base branch as well.

Deferred review notes

  1. The spread fast path walks the source class with forEachPropertyWhile, which may rebuild a property map that a later add-transition moved away. Repeated {...state, x} on the same class could allocate more than the slow path. Worth measuring, and worth caching an "all default flags" bit on the class.
  2. The ASCII JSON lexer duplicates the UTF-16 scanner. Templating it over the code-unit type would keep one implementation.
  3. JSON.parse encodes each value before knowing the fast path applies. On boxed-double builds this can allocate an unused BoxedDouble on the slow path. Reordering needs care, because encoding before taking raw pointers is what fixed the 32-bit GC hazard.
  4. Three new declarations were inserted between existing doc comments and their declarations (HiddenClass.h, JSObject.h).
  5. The new paths have differential tests but no lit tests yet.

ajpalkovic and others added 9 commits October 28, 2026 09:49
CMake: HERMES_EXTRA_COMPILE_FLAGS / HERMES_EXTRA_LINK_FLAGS cache variables applied to every target (used for PGO). android/build.gradle.kts: release builds honor -PhermesReleaseCmakeBuildType (default MinSizeRel), -PhermesHeapHvMode and -PhermesExtraCmakeArgs='-Da=b|-Dc=d'.
Deleting or redefining a property of a dictionary object switched it to no-cache mode from the second change on, so every property-cache lookup on it missed. React Native's lazy globals redefine the global object 13 times at startup, so every read of Object, Array, Math, BigInt, HermesInternal... missed (~325k slow lookups per cold start). Give it a new cacheable class up to 15 times (4 spare ClassFlags bits) before falling back.
obj[key] / key in obj / hasOwnProperty with a non-interned key string (e.g. an id taken from JSON) re-hashed the string and probed the identifier table every time. A 256-entry direct-mapped cache keyed by the string's address, cleared at every GC start and before symbols are freed. 68% hit rate on device.
For {...src} into a fresh object, when src is a plain object whose properties all have default flags and string keys (no accessors, not a dictionary, same prototype), share src's hidden class and copy the slots in bulk. Observably identical to CopyDataProperties. 2.6-4x faster on the host.
Follow an existing hidden-class add transition for object keys (an existing transition proves the key is absent), append array elements directly and set length once, and lex ASCII input in place instead of widening it to UTF-16 (integers of up to 15 digits skip strtod). Encodes the value before taking raw class/object pointers: encoding can allocate, and holding them across it was a GC hazard that crashed 32-bit ARM.
…leted entries" (meta#2114)

Summary:
`IdentifierHashTable` capacity grows without bound when its' nonEmpty entries count is nearing the capacity. However, this also happens when most of the entries are already invalid.

This PR makes the table rehash in place when the table is dominated by invalid entries (more than 75% entries are invalid).

~30-line standalone script, no dependencies — run with the `hermes` CLI and watch process RSS:

```js
const sink = {};
let interned = 0;
for (let round = 0; round < 240; round++) {
  for (let j = 0; j < 200000; j++) {
    const s = "unique_key_" + interned++ + "_suffix_padding";
    if (s in sink) throw new Error("unreachable");
    const garbage = { a: j, b: [j, j + 1], c: "g" + (j & 1023) }; // drive GC
    if (garbage.a === -1) print(garbage.b, garbage.c);
  }
  const st = HermesInternal.getInstrumentedStats();
  print(round, st.js_heapSize ?? st.heapSize, st.js_numGCs ?? st.numGCs);
}
```

48M unique keys, macOS host CLI built from the same source, RSS sampled at 0.5 s:

| binary | RSS peak | RSS final | JS heap (self-reported) |
|---|---|---|---|
| before | 223 MB | 159 MB | 12.6 MB flat, 3908 GCs |
| after | 68 MB | **36 MB** | 12.6 MB flat, 3916 GCs |

Real-world instance: a React Native app animating interpolated color strings (a library probes a color-keyword dictionary with each animated `hsl(...)` string ≈ 4k unique interned strings/s) grew from 81 MB to 333 MB over 6 hours through seven table doublings (8→16→…→256 MB tables).S

Pull Request resolved: meta#2114

Test Plan: See description

Reviewed By: lavenzg

Differential Revision: D113932368

Pulled By: tmikov

fbshipit-source-id: 05f51c60fe1c4bd17637f264e84bfadc10784c49
(cherry picked from commit 859499d)
… elements (meta#2239)

Summary:
Fixes meta#2238.

`HBCISel::generateAllocArrayInst` caps the `NewArray` size hint at `UINT16_MAX`, so a large array literal gets storage for 65,535 elements (indices 0–65534). `generateDefineOwnInDenseArrayInst` still emitted the unchecked `DefineOwnInDenseArrayL` for `index == UINT16_MAX`, which stores one slot past the `ArrayStorage` cell into the header of the next heap cell. In release builds that is either silent data loss (`a[65535]` ends up `undefined`) or a later crash when the clobbered cell is used; debug builds assert in `unsafeSetExistingElementAt`.

This changes the condition to `index < UINT16_MAX`, so element 65535 takes the growing `DefineOwnByIndexL` path like elements 65536+ already do. The emitted bytecode for a 24 MB React Native bundle is otherwise identical.

Pull Request resolved: meta#2239

Test Plan:
- New lit test `test/hermes/array-literal-over-65535-elements.js` builds a 65,537-element literal through `eval` and checks `a.length, a[65534], a[65535], a[65536]`. Before: `65537 1 undefined 1`. After: `65537 1 1 1`.
- Same literal precompiled with `hermesc -O -emit-binary` and run on an unmodified VM: `DefineOwnByIndexL r3, r2, 65535` is emitted and the program prints `65537 1 1 1`.
- A debug `hermes` build no longer hits `Assertion failed: (index >= self->beginIndex_ && index < self->getEndIndex() && "array index out of range")` on the repro.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed By: avp

Differential Revision: D124887906

Pulled By: lavenzg

fbshipit-source-id: b3ea4634de0dda8b4975453dce8411c7d1832246
(cherry picked from commit eefe5da)
(cherry picked from commit 2e1397b)
Release builds use CMAKE_BUILD_TYPE=Release unless -PhermesReleaseCmakeBuildType
overrides it, instead of upstream's MinSizeRel (-Os). Trades library size for
faster interpreter and VM code on Android.
Apply the weak-reference read barrier on string-to-symbol cache hits, as
every other read of a symbol from a weak table does, so a hit during
concurrent marking keeps the symbol alive instead of relying on the cache
being cleared at the start of every marking cycle.

Tie the dictionary re-class limit to the width of its 4-bit counter with a
static_assert, so raising the limit past 15 fails to compile instead of
wrapping the counter.

Remove an unused pinned local from JSON.parse's array path.
@Flewp
Flewp marked this pull request as ready for review November 5, 2026 19:09
@Flewp
Flewp merged commit 8a8a04f into 250829098.0.15-discord Nov 5, 2026
30 of 32 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants