# JIT M2 progress report
M2 closes the largest remaining gap between the Tier 1 lowering that already
existed and the closed opcode policy that advertised it. Before M2, every
comparison except `<`, every bitwise and shift operator, `%`, the unary
numeric operators, most constant pushes, tail calls, `null`, and the empty
string literal were rejected, so ordinary functions such as the designated
`quickjs-int-arith` kernel, the bit-operation kernel, object-shape loops, and
any `return f(x)` never entered native code at all. M2 advertises those
opcodes in Tier 1, lowers them in Tier 2 with exact deoptimization, adds the
two runtime helpers needed for exact `%` and unary slow paths, and removes a
per-call maintenance pass from the native entry path.
## Performance
Lower latency is better. The results below are medians from 30 interleaved
fresh processes after five discarded warmup processes, with ten one-second
throughput windows per mode. Every mode produced the same checksum for every
workload, and no automatic sample recorded a fallback, retry, or
deoptimization. The raw evidence is `benchmarks/results/m2-core-opcodes.json`
and the generated report is `benchmarks/results/m2-core-opcodes.md`.
| quickjs-int-arith | 6.831 ms | 2.645 ms (2.58x) | 4.185 ms (1.63x) | 3.892 ms (1.76x) | 340 | 0/0/0 |
| quickjs-bitops | 1.335 ms | 437.6 us (3.05x) | 209.2 us (6.38x) | 209.1 us (6.39x) | 352 | 0/0/0 |
| numeric | 796.8 us | 215.1 us (3.70x) | 27.1 us (29.38x) | 27.3 us (29.20x) | 351 | 0/0/0 |
| scalar-loop | 798.6 us | 213.6 us (3.74x) | 26.6 us (29.98x) | 27.1 us (29.42x) | 343 | 0/0/0 |
| fibonacci-iterative | 36.130 ms | 7.566 ms (4.78x) | 883.5 us (40.89x) | 880.2 us (41.05x) | 332 | 0/0/0 |
| call-heavy | 1.893 ms | 1.077 ms (1.76x) | 316.8 us (5.97x) | 312.6 us (6.05x) | 113968 | 0/0/0 |
| float64-dense | 3.887 ms | 1.629 ms (2.39x) | 1.632 ms (2.38x) | 361.5 us (10.75x) | 360 | 0/0/0 |
| arrays-typed | 5.185 ms | 13.343 ms (0.39x) | 3.864 ms (1.34x) | 2.731 ms (1.90x) | 679 | 0/0/0 |
| property-heavy | 1.444 ms | 11.077 ms (0.13x) | 24.807 ms (0.06x) | 24.734 ms (0.06x) | 333 | 0/0/0 |
| quickjs-fibonacci | 1.314 ms | 369.3 us (3.56x) | 372.5 us (3.53x) | 369.9 us (3.55x) | 0 | 0/0/0 |
| fibonacci-recursive | 12.019 ms | 130.011 ms (0.09x) | 12.118 ms (0.99x) | N/A (no native entry) | 330 | 0/0/0 |
| collections | 2.065 ms | N/A (no native entry) | N/A (no native entry) | N/A (no native entry) | 0 | 0/0/0 |
| strings-json | 3.020 ms | 10.975 ms (0.28x) | 4.097 ms (0.74x) | 2.355 ms (1.28x) | 0 | 0/0/0 |
| calls-closures | 3.561 ms | N/A (no native entry) | N/A (no native entry) | N/A (no native entry) | 0 | 0/0/0 |
| adversarial | 1.123 ms | 1.965 ms (0.57x) | 1.270 ms (0.88x) | 1.043 ms (1.08x) | 0 | 0/0/0 |
| strings-regexp | 19.343 ms | 32.320 ms (0.60x) | 22.482 ms (0.86x) | 19.714 ms (0.98x) | 0 | 0/0/0 |
| objects-polymorphic | 6.658 ms | 172.024 ms (0.04x) | 39.979 ms (0.17x) | 6.718 ms (0.99x) | 0 | 0/0/0 |
| calls-recursion-closures | 7.376 ms | 103.266 ms (0.07x) | 41.011 ms (0.18x) | N/A (no native entry) | 2160700 | 0/0/0 |
| json-codec | 78.730 ms | 216.117 ms (0.36x) | 102.792 ms (0.77x) | 78.447 ms (1.00x) | 0 | 0/0/0 |
| map-set-bigint | 15.209 ms | N/A (no native entry) | N/A (no native entry) | N/A (no native entry) | 0 | 0/0/0 |
| exceptions-promises-async | 1.962 ms | N/A (no native entry) | N/A (no native entry) | N/A (no native entry) | 0 | 0/0/0 |
Compared with the tracked M0 and M1 evidence:
- `quickjs-int-arith` and `quickjs-bitops` never entered native code before
M2 (`sar`, `push_i16`, `tail_call`, `shl`, `shr`, `xor` were rejected);
automatic mode now runs them 1.8x and 6.4x faster than the interpreter.
The int-arith Tier 2 artifact is still slower than its Tier 1 artifact,
because the outer loop calls `Math.max` and `String` through the generic
call bridge; that bridge is the next target.
- The focused Int32 loops match or exceed the M0 forced-Tier-2 results
(`fibonacci-iterative` 41.1x against 44.8x at M0, numeric/scalar 29.3x
against 32x) while now carrying an exact overflow exit on every increment
the range proof cannot discharge; the M0 code wrapped at `INT32_MAX`.
- `call-heavy` automatic is 6.0x against 7.6x at M1. The Tier 2 body now
guards the operand tags of comparisons and additions whose operands are
call results, which costs about 70 us per 2,000 calls; removing that guard
needs representation tracking for call results.
- `float64-dense` automatic is 10.8x (7.3x at M0), `arrays-typed` 1.9x
(2.1x at M1, within run-to-run variation of this workload).
- `calls-recursion-closures` and `fibonacci-recursive` stay at interpreter
speed in automatic mode: with M2's opcodes they became eligible, forced
tiers show the generic call bridge losing 5x to the interpreter, and the
new admission rule keeps call-only functions out of native code.
- `property-heavy` remains unprofitable in every native tier (0.13x Tier 1,
0.06x Tier 2). Before M2 the forced Tier 2 sample aborted on the leaked
reference, so this is the first complete measurement; the per-access
shape-guard and materialization helpers dominate, and automatic mode
cannot demote it because the cost model has no interpreter timing.
- Strings, JSON, collections, closures, and async workers remain
interpreter-only or near parity, as at M1.
Two kernels show a bimodal sample distribution (about 27 us and 52 us for
the scalar loops) with identical native-entry counters; the slower samples
land on the CPU's efficiency cores in `powersave` mode. Medians are reported.
The generated report's acceptance gates move from one PASS at M1 to four:
a designated kernel with a lower confidence bound at or above 10x
(`fibonacci-iterative`, 41.1x..42.1x), every strict sample entering its
required native tier, automatic mode using the production profitability
policy, and identical checksums. The compute geometric-mean gate (3.3x..3.4x
against 5x), the startup/reload/tail-latency regression gate, and the
gpui-shell gate remain FAIL/INCONCLUSIVE, as they were at M0 and M1.
## Implementation progress
Tier 1 now advertises 55 additional opcodes:
- constants and stack shape: `push_minus1`, `push_4`..`push_7`, `push_i16`,
`push_const`, `null`, `push_empty_string`, `goto`, `nip`, `insert2`,
`insert3`, `perm3`, `get_loc`/`put_loc`/`set_loc` (16-bit), `get_loc0_loc1`,
`put_arg*`, `set_arg*`;
- comparisons: `lte`, `gt`, `gte`, `eq`, `neq`, `strict_eq`, `strict_neq`,
with a both-numeric native path and the existing `COMPARE_SLOW` helper for
every other operand class;
- arithmetic: `mod`, `xor`, `shl`, `sar`, `shr`, `neg`, `inc`, `dec`,
`post_dec`, `not`, `lnot`, `inc_loc`, `dec_loc`, `add_loc`;
- `is_undefined`, `is_null`, `tail_call`, and `tail_call_method`.
The QuickJS JIT ABI gains two append-only helpers (minor version 19):
`BINARY_ARITH_SLOW` and `UNARY_ARITH_SLOW`, which run the interpreter's own
`js_binary_arith_slow`, `js_binary_logic_slow`, `js_shr_slow`,
`js_unary_arith_slow`, and `js_not_slow` paths on frame slots with the same
ownership contract as `ADD_SLOW`. Tier 1 lowers `%` with QuickJS's own Int32
fast path (non-negative dividend, positive divisor) and sends every other
shape through the helper; `-x`, `++x`, `--x`, and `~x` keep their native
numeric lowering and use the unary helper for non-numeric operands, so
observable coercions, BigInt arithmetic, and throwing `valueOf` stay exact
while the function remains native.
Auditing the existing native arithmetic found that Tier 1 reinterpreted
operands of unknown domain (globals, elements, constants, property values,
call results) as numbers without a runtime check. Natively lowered numeric
operations now carry a use-site tag guard that deoptimizes to the exact
interpreter state at that instruction; property loads and call results are
tracked as unknown instead of forcing the whole function to retry before
entry. The `set_loc*`/`set_arg*` keep forms also released their previous
primitive slot before duplicating an owned value.
Tier 2 classifies and lowers the same opcode set: unboxed Int32 shifts with
QuickJS's `& 31` masking, `>>>` renormalizing to Float64 when the result does
not fit Int32, `%` on the guarded Int32 fast path, native equality for any
mix of Int32, Float64, Bool, `undefined`, and `null`, unary negation with the
`-0` and `INT32_MIN` cases, local increment/decrement/add, the full stack
shuffle family with owner-preserving provenance, `is_undefined`/`is_null`,
and tail calls expanded into the existing guarded call followed by a return.
Strings, objects, symbols, BigInt, and loose bool/number coercions
deoptimize by design. Global loads (`get_var`) and method receivers
(`get_field2`) are now reachable from Tier 2 through the runtime helpers, so
kernels that call `Math.max` or `String(...)` are no longer rejected as a
whole.
Benchmarking the widened opcode set exposed two pre-existing Tier 2
ownership defects that also reproduce on the M1 baseline. The guarded
property access and the generic call bridge materialize every borrowed
argument/local alias on the operand stack as an interpreter owner for their
exception paths, but released only their own operands afterwards, so a
receiver alias kept below the operands (`p.x = p.x + 1`, the property-heavy
benchmark shape) leaked one reference per access until runtime teardown
aborted. Stores into interpreter-owned argument and local buffers also
spilled borrowed heap aliases without transferring ownership, which
underflowed a reference count on the next deoptimization. Both paths now
release the materialized aliases before continuing, and frame stores carry
exact deoptimization sites: proven scalar sources are stored directly,
proven heap aliases fail closed to Tier 1, and unproven sources check the
tag at run time and deoptimize before the spill. CI-gated production
regressions cover both shapes.
Widening the opcode set also changed which functions automatic tiering
admits. A function that only calls through the generic call bridge
(recursion, forwarding wrappers, tail calls) has no loop to amortize that
bridge, and the cost model has no interpreter timing to notice that both
native tiers lose; such functions now stay in the interpreter and are
counted in `generic_call_rejections`. In the raw-i32 loop shape the
comparison and increment lowering no longer repeat tag guards that the entry
and header guards already prove, deoptimization blocks are marked cold, and a
counted-loop increment carries a range proof: inside a natural loop whose
header ends in `k < X; if_false -> exit` and whose only write to `k` is the
increment itself, `k + 1` cannot overflow and its exit is dropped. Every
other increment keeps the exact overflow exit; the M1 code omitted that exit
unconditionally and wrapped at `INT32_MAX`.
Both tiers also stop enabling Cranelift's IR verifier, a development aid
whose superlinear passes dominated large-function compile time: a
12,000-instruction Tier 1 function compiles in 0.65 s instead of 7.0 s, and
the compile cost is now linear in function size. Generated code is
unchanged; the compiler's own frame-state and stack-map proofs remain.
Faster compilation exposed a feedback gap: only the interpreter's `OP_return`
recorded return types, so a first invocation that entered baseline code
through OSR and returned natively left the call signature incomplete and
Tier 2 admission waited forever. Native `DONE` exits now record the return
type as well.
The production backend no longer runs its full maintenance pass on every
native entry, hot probe, and exit. Maintenance still runs when a worker
completion or code-cache reclamation is pending, when any feedback lattice
entry changed, when an artifact was installed, and at least every 64 ticks;
the clock still advances on every tick so backoff windows are unchanged.
The entry pin also stops copying the artifact's deoptimization table on every
call. This reduces the generic native call path from roughly 7.8 us to 0.6 us
per call in forced Tier 1 and from roughly 0.9 us to 0.5 us in automatic mode
on the development machine; the interpreter's own call cost is about 50 ns,
so generic native calls remain a follow-up.
## Correctness
The full runtime test command passes:
```sh
cargo fmt --all -- --check
cargo clippy --all --all-targets --features full-async,bindgen -- -D warnings
cargo test -p quickjs-jit-sys --test jit_patch
cargo test -p quickjs-jit-runtime --features compiler,test-support --tests
```
Coverage added in M2:
- one manifest case per newly advertised opcode, executed at its native pc
with helper evidence, GC stress for helper-backed forms, and tag-edge
inputs (NaN, `-0`, `INT32_MIN`/`INT32_MAX`, Int32/Float64 mixes,
`Symbol.toPrimitive` ordering, BigInt, throwing coercions);
- differential semantics tests for tail calls (including exceptions and GC),
comparisons, shifts, `%`, unary operators, the empty string literal,
`is_undefined`/`is_null`, and the use-site deoptimization of non-numeric
operands;
- Tier 2 CLIF evidence for the bit-operation loop, `srem`, native `fcmp`
equality without helper calls, and tail-call expansion, plus synthetic
frame execution of every new Tier 2 lowering with exact deoptimization
resume pcs;
- production deoptimization tests for `-4 % 2`, `x % 0`, `INT32_MIN % -1`,
`1 << 33`, `-1 >>> 0`, `~1.5`, string equality, `null == undefined`,
`NaN !== NaN`, and `-(0)`;
- helper-level tests for both new ABI helpers, including foreign-opcode
rejection with untouched operands.
## Known limitations and post-M2 work
1. Generic native call overhead
A native-to-native call through the generic `CALL` helper still costs
about ten times an interpreter call because entry acquisition, hot
probing, timing, and exit accounting run as Rust callbacks per call.
Caching the entry handle on the C side and batching hot probes is the next
step for call-heavy and recursive code; automatic mode currently demotes
such functions instead of running them natively.
2. Native frame depth
Native frames are larger than interpreter frames, so deep recursion
reaches "Maximum call stack size exceeded" earlier under a forced tier.
3. Aliased heap locals in Tier 2
Deoptimization maps carry identity recipes only, so a local that holds a
borrowed heap-reference alias cannot yet be materialized at an exit;
such functions stay on Tier 1. Owning duplication recipes would lift this.
4. Remaining coverage
`nop`, `nip1`, `dup1`..`dup3`, `swap2`, `rot*`, `perm4`, `perm5`, and
`insert4` stay rejected because QuickJS only emits them for constructs
that are still unsupported (`for-in`, destructuring lvalues, `super`).
`typeof`, closures (`fclosure`, var-refs), `for-of`, exceptions, and async
remain the next milestones; object literals and `array_from` are still
Tier 1 only.
## M2 merge gate
M2 is suitable for merge when the PR's required CI checks are green. The
limitations above remain explicit follow-up work and are not represented as
passing project-wide performance gates.