Skip to content

JIT: Reconcile the docs and the syncAllFRTempExcept comment - #2165

Open
tmikov wants to merge 15 commits into
meta:static_hfrom
tmikov:export-D117588421
Open

tmikov wants to merge 15 commits into
meta:static_hfrom
tmikov:export-D117588421

Conversation

@tmikov

@tmikov tmikov commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary:
X-link: #2165

The spec's Delivered section said, as of milestone 6, that none of the
three maintainer findings were fixed and that the arm64
reify-arguments hole was left unfixed deliberately. All three were
fixed since, and doc/JIT.md's findings list already documents that in
detail. The historical record stays intact: each now-false present-tense
claim is date-scoped to milestone 6 with a pointer to doc/JIT.md,
rather than rewritten.

syncAllFRTempExcept's guard comment in both backends explained why
excluding the destination FR is unsound in a try, but not the
precondition its soundness rests on: callers must invoke the sync
before allocating or writing the instruction's destination register, so
the destination still holds its pre-instruction value when stored. That
precondition is now written down, character-for-character identical
between the arm64 and x86-64 files.

Differential Revision: D117588421

@meta-cla meta-cla Bot added the CLA Signed Do not delete this pull request or issue due to inactivity. label Sep 2, 2026
@meta-codesync

meta-codesync Bot commented Sep 2, 2026

Copy link
Copy Markdown

@tmikov has exported this pull request. If you are a Meta employee, you can view the originating Diff in D117588421.

tmikov and others added 15 commits October 29, 2026 18:25
Summary:
Two heap-value-mode build trees for the x86-64 JIT:

    cmake-build-x86jit-hv32   -DHERMESVM_HEAP_HV_MODE=HEAP_HV_PREFER32
    cmake-build-x86jit-boxed  -DHERMESVM_HEAP_HV_MODE=HEAP_HV_BOXED

On x86-64, PREFER32 turns on `HERMESVM_COMPRESSED_POINTERS`,
`HERMESVM_BOXED_DOUBLES` and `HERMESVM_CONTIGUOUS_HEAP` together, while
HEAP_HV_BOXED turns on only `HERMESVM_BOXED_DOUBLES` and so isolates
the boxed-double decode from pointer compression. Between them they
give the first compile, and the first execution, to every mode-guarded
branch this backend transcribed blind across milestones 3 and 4.

No heap-mode emitter fix was required: every mode-guarded branch
compiled and ran correctly on the first try. The commit also carries
three small x86-64 emitter changes that apply in all modes:
`loadConstStringInGpX` now declines instead of asserting when the
lookup-entry offset exceeds a disp32; a debug-only `rspDelta_` counter
enforces the stack-argument rsp contract from `callImpl()` (a multiple
of 16 at `callRuntime()`, zero at instruction boundaries and in
`leave()`); and `fastArrayLoad`'s `isInTry()` sync gets a comment.
`objects.js` is reflowed to 80 columns.

Two earlier notes turn out to be wrong, and coverage rather than code
corrects them. `loadParentNoTraps` was recorded as unreachable; it
compiles now that calls and property access have landed, so `super`
reaches the nullable compressed-pointer decode. And
`newObjectWithBufferSlow` is reached in both boxed builds, not only
HV32: its trigger is `SmallHermesValue::canInlineDouble()` rejecting a
literal's double, which is a question about value bits, not about
pointer compression.

Differential Revision: D117588404
Summary:
Port the 14 remaining scalar emitters from arm64, leaving 17 stubs:
exceptions, switches, iterators, arguments and for-in.

`typeOfIs` and `jmpTypeOfIs` are structurally verbatim ports, with
arm64's `cset` becoming `setcc` plus a zero-extension and the condition
codes mapped kEQ->kE, kLO->kB, kHI->kA, kLS->kBE. The
result-initializing `mov`s inside the object and function cases sit
between a comparison and the branch that reads it, so they have to stay
flag-clean `mov`s and not become `xor`s. The code says so.

`addS` has no inlined type check on either backend, since it is emitted
only where both operands are already proved to be strings. The emitter
says so, because the plan's text implies otherwise.

Also adds the milestone-5 implementation plan,
`doc/superpowers/plans/2026-08-24-x86-64-jit-exceptions.md`, which is
the plan referred to above.

Differential Revision: D117588405
Summary:
A function with an exception table finally compiles, now that `leave()`
no longer declines one. The SHJmpBuf/setjmp prologue has been in place
since milestone 1 and was unreachable until now.

Three x86-64 deltas, each commented at its site. arm64's `adr` and `br`
become a RIP-relative `lea` and `jmp rax`. The label-delta table is
explicitly 4-byte aligned, since it follows a variable-length `jmp` and
`_jit_find_catch_target` reads it through an `int32_t *`. And
`emit_sh_ljs_is_empty` was missing, so it joins its neighbours in
`JitEmitter-internal.h`.

The inherited `rspDelta_` scope obligation is resolved by folding
`bumpAllocAndUnpoison`'s ASan pushes into the counter rather than
documenting a split. They are balanced within the helper, and the 8
pushes total 64 bytes, a multiple of 16, so `callRuntime()`'s alignment
assert now actually checks that call site instead of passing
vacuously. The counter accounts for all transient `rsp` movement in the
backend.

`fastArrayLoad`'s `isInTry()` branch is live for the first time. Its
comment said "unreachable today" and now says what drives it.

Differential Revision: D117588406
Summary:
Port the last twelve emitter stubs, so `JitEmitter-stubs.cpp` is empty
and goes away. The only permanent decline left is `AsyncBreakCheck`,
and it lives in the shared driver exactly as it does for arm64.

`uintSwitchImm`'s jump table follows arm64's structure, 32-bit deltas
relative to the table's own address emitted right after the indirect
jump, with `lea`/`movsxd`/`add`/`jmp` in place of
`adr`/`ldr`/`add`/`br`. It branches on both `jne` and `jp` after
`emit_double_is_uint32()`, since `vucomisd` reports an unordered
compare as equal. Without the `jp`, a NaN switch operand would be
treated as the value 0 and, in a table whose `minVal` is 0, would run
the first case.

`reifyArgumentsImpl` widens the lazy register's recorded type after the
merge point, which arm64 does not. The register is an in/out operand
holding an object afterwards, while ISel's `LoadConstUndefined` left it
recorded as a non-pointer. The type asserts caught it through
`strictEqualImpl`'s raw-bit tier.

Differential Revision: D117588407
Summary:
The x86-64 backend has reached arm64's full opcode surface, and this
adds the standing test that pins it.

Grepping the x86-64 tree for `unsupported(` now finds two call sites,
and neither declines an opcode. One is a shift-count register check in
the two-register-temp path: the count must land in `rcx` for the x86
shift encoding, and a compare catches an allocator surprise instead of
silently reading a stale `cl`. The other is a disp32-range check on the
identifier lookup table offset, since `SymbolID`'s index is 28 bits and
the theoretical offset can exceed INT32_MAX even though no real program
gets close. Both decline an out-of-range encoding the same way arm64's
own defensive checks would.

The only real opcode decline anywhere in the tree is `AsyncBreakCheck`,
in the architecture-independent driver, shared verbatim with arm64.
There is no x86-64-specific unsupported list left at all.

It also adds `-Xjit-crash-on-error` to the differential RUN lines of the
existing x86-64 tests, since none of them declines any more.
`exceptions.js` now prints `e.message`, so the two derived-constructor
errors can be told apart, and pins the full threshold-mode compile
chain. `reifyArgumentsImpl` asserts that its dead global-register reload
never runs. The jmp_buf alignment comments are corrected, and the x86-64
section of `doc/JIT.md` is updated for milestone 5.

Differential Revision: D117588408
Summary:
test/jit was restricted to `jit-arch-arm64` because it was written
against that backend and had never been audited for arm64-only
assumptions. None of its 48 files checks backend-specific assembly; they
are behavioral or compile-status tests, and the one that matches dump
text (`getbyid-spec-exhausted.js`, the `// Read property cache` marker)
matches a comment both backends emit. So the audit is the whole job, and
it comes out clean. No file needed gating or adapting.

The directory is now skipped only when no `jit-arch-*` feature exists
at all, which keeps milestone 1's warning for an unrecognized
architecture.

Two files depend on a frame register getting a global, callee-saved
register, and x86-64 has three of those against arm64's sixteen, so
their allocations were checked against `-Xdump-jitcode=3` rather than
assumed. `reset-fr-global.js` still puts its loop counter in one and is
fully exercised. `new-object-parent-global-reg.js` does not, and is
reduced to a plain behavioral test there. Both now say so.

Differential Revision: D117588409
Summary:
Emitters that can throw call `syncAllFRTempExcept()` before the call
and exclude their own destination FR, assuming it is dead across the
call. Inside a try region that is wrong. Register allocation coalesces
a variable's phi with the destination, so the excluded FR is also the
one holding the value live into the catch handler. The prior value is
dropped rather than stored, and after the longjmp the handler reads the
frame slot, which still holds the zero fill `_sh_enter` wrote. So

    var y = "prior"; try { y = o.throwingGetter } catch { use(y) }

yields 0, not "prior".

The JIT asserts cannot catch this. The FR holds a well-formed but stale
`HermesValue`, so it is a lost store, not a type error.

The fix is deferred to the maintainer. It spans 54 exclusion sites
per backend, 108 across both, or else requires liveness to treat
exceptional edges as uses, and this branch owes arm64 byte-identical
emission. The repro goes in the shared test/jit directory marked
`XFAIL: *`, so it runs on both architectures and a fix announces itself
as an XPASS. `doc/JIT.md`'s open question is replaced by the answer.

It also brings `doc/JIT.md`'s milestone-6 paragraph and the
`test/jit/lit.local.cfg` header comment up to date with the earlier
un-gating of `test/jit`, which neither had reflected.

Differential Revision: D117588410
Summary:
x86-64 needs less canonicalization than arm64. Its `loadBits64InGp` is
an unconditional `mov reg, imm64`, with no `isCheapConst` split and no
RO-data fallback for GP constants, so the mechanism that makes two
arm64 runs of the same binary diverge is absent. Confirmed against real
dumps: every differing line is a `mov` of an 8-or-more-digit hex
immediate, already collapsed by the existing trailing
`s/0x[0-9A-Fa-f]{8,}/ADDR/g` rule.

The only addition deletes `.dq` lines, dropping x86-64's RO_DATA
payload the way the existing rule already drops arm64's `.xword` lines.

Differential Revision: D117588411
Summary:
The fifth build configuration, `cmake-build-x86jit-rel` (Release,
clang, `HERMESVM_ALLOW_JIT=2`, no ASan), is the first non-ASan
non-Debug x86-64 run in this port, so the first time NDEBUG strips
every assert, including any with load-bearing side effects. Nothing
diverged.

Perf sanity on that build, interpreter against `-Xjit=force`, 3 runs,
median: a hot numeric loop 2.25x, a scaled-up stress.js-shaped workload
1.61x, a property and IC heavy loop 1.45x. That clears the spec's "must
not lose to the interpreter" bar with no tuning.

The matrix goes in a new `doc/JITTesting.md` rather than into
`doc/JIT.md`, which is already 1180+ lines mixing architecture with a
findings appendix.

Differential Revision: D117588413
Summary:
The spec's Testing bullet was wrong: test/jit held no assembly
goldens. The real golden artifact is the per-arch jit-dump baseline
workflow, and un-gating was a gate-predicate change plus an arch-shape
audit. Corrected, and a new Delivered section records the milestones,
the deviations and the three findings left to the maintainer.

`doc/JITTesting.md` now notes that Release reaches 68/1/1 over a
different set of tests, that the arm64 leg is HV64 only, and when a dump
baseline may roll forward. `utils/jit/README.md` and `jit-dump.sh`
document that the trailing `ADDR` rule also hides genuine 64-bit
immediates. `try-catch-dest-reg.js` now XFAILs per architecture, so a
fix on one backend can un-XFAIL just that backend.

Differential Revision: D117588414
Summary:
The try-region destination-sync bug (both backends), the arm64
`reifyArguments` stale-type hole, and the `static_assert` message-form
sweep, all found during the x86-64 JIT port.

Differential Revision: D117588416
Summary:
The one-argument form `static_assert(cond && "msg")` ANDs the message
into the condition instead of using it as the diagnostic. All tag-helper
asserts in both backends move to `static_assert(cond, "msg")`.

The arm64 message for `HVTag_Str` was copy-pasted and claimed
"HV_TagObject must be -1". The x86-64 comment that tracked that
divergence goes away with it.

Closes dz 01a03bbd-4888.

Differential Revision: D117588417
Summary:
`reifyArgumentsImpl` replaces the lazy-arguments register's undefined
with a reified Arguments object on both the fast path, where it is
already an object, and the slow path, where it is freshly allocated.
arm64 never updated the register's recorded FR type to match. It stayed
`FRType::OtherNonPtr`, the type recorded when ISel initializes the
register with `LoadConstUndefined`, and nothing else in the emitted
code touched that bookkeeping.

The stale non-pointer claim propagates through `Mov`. Today its only
consumer is `strictEqualImpl`'s raw-bit tier, which compares an object's
bits correctly, since bit equality is object identity. So it produces no
wrong results, but the recorded type is false: `-Xjit-emit-type-asserts`
traps on it, and any future fast path that relies on `OtherNonPtr` more
heavily would inherit the hole.

x86-64 hit this and fixed it earlier under `-Xjit-emit-type-asserts`.
arm64 gets the same `frUpdateType` call at the structurally equivalent
point, right after the fast and slow paths join at `contLab` and before
the slow-path lambda is registered.

Closes dz 01a03bbd-4870.

Differential Revision: D117588418
Summary:
Every emitter that can throw calls `syncAllFRTempExcept()` before the
call and passes its own destination FR as the exception, on the
assumption that the destination is dead across the call. Inside a try
region that assumption is wrong. Register allocation may coalesce a
live variable's phi with the destination, so the excluded FR is also
the FR holding a value live into the catch handler. When the
instruction throws instead of writing its destination, the handler
reads that frame slot and finds the zero fill `_sh_enter` wrote on
entry.

    var y = "prior"; try { y = o.throwingGetter; } catch { use(y); }

printed 0 under `-Xjit=force` at -O, on arm64 and x86-64 alike, in all
three heap-value modes. The value is a well-formed `HermesValue`,
merely stale, so neither `-Xjit-emit-asserts` nor
`-Xjit-emit-type-asserts` could catch it: a lost store, not a bad tag.

Fixing it at the call sites would mean 54 exclusions per backend, 108
across both, each needing the same reasoning applied by hand. Instead
`syncAllFRTempExcept()` drops the exclusion itself when `isInTry()`.

That is sound because the exclusion was only ever an optimization.
Syncing an extra FR stores that FR's current value and nothing else,
and every call site issues the sync before allocating its destination
register, so the destination still holds its pre-instruction value at
sync time. Plain calls were never affected: `callImpl` already passes
`FR()` and syncs the whole frame.

The cost is one extra store per throwing instruction, and only in
functions containing a try. Those have no global registers, since
`RegisterAllocator::getRegClass` forces `RegClass::Other`, so the store
goes straight to the memory frame.

This deliberately changes emitted code on both backends, arm64
included. That backend's byte-identical-to-the-export-base constraint
was scoped to the port itself, not to a later cross-backend bug fix.
Both baselines were recaptured.

Closes dz 01a03bbd-02cf.

Differential Revision: D117588419
Summary:
X-link: meta#2165

The spec's Delivered section said, as of milestone 6, that none of the
three maintainer findings were fixed and that the arm64
reify-arguments hole was left unfixed deliberately. All three were
fixed since, and `doc/JIT.md`'s findings list already documents that in
detail. The historical record stays intact: each now-false present-tense
claim is date-scoped to milestone 6 with a pointer to `doc/JIT.md`,
rather than rewritten.

`syncAllFRTempExcept`'s guard comment in both backends explained why
excluding the destination FR is unsound in a try, but not the
precondition its soundness rests on: callers must invoke the sync
before allocating or writing the instruction's destination register, so
the destination still holds its pre-instruction value when stored. That
precondition is now written down, character-for-character identical
between the arm64 and x86-64 files.

Differential Revision: D117588421
@tmikov
tmikov force-pushed the export-D117588421 branch from eb0bdf3 to b9c251c Compare October 30, 2026 02:22

This branch was previously deployed

1 inactive (outdated) deployment
npm-publish — eb0bdf35 Deployed Sep 2, 2026 by tmikov via publish #1432
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. meta-exported

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant