JS execution performance: the road toward JIT-class throughput
Status: profiling review complete (2026-07-02); the optimization push it scoped has landed on this branch — deterministic fast hasher, quick-wins batch, key-verified inline caches, superinstruction round 2, and the call-path slimming (cell pool +
Rc-sharedBytecodeFunction). Measured results in §6; the remaining roadmap (register bytecode, full cells→locals, shapes) is unchanged below. This document is the successor to the per-phase plan indocs/interpreter-optimization.md(Phases 0–2 landed there) and the retired closure-threading experiment indocs/jit.md. It re-scopes the goal from "make the interpreter less wasteful" to "close as much of the gap to JIT runtimes as the engine's invariants allow" — and it is grounded in a new callgrind (instruction-exact) profile of the cross-runtime benchmark suite rather than wall-clock on a noisy container.The three invariants are unchanged and non-negotiable: zero
unsafe, no new heavyweight dependencies, byte-identical deterministic replay (docs/replay.md). Every item below states how it satisfies them.
1. Where we are (measured 2026-07-02)
1.1 Cross-runtime gap
node crates/chidori-js/benchmarks/run.mjs (5 runs, median, execution-only =
subprocess wall-clock minus per-runtime startup), on an otherwise-idle dev
container (4 cores):
| workload | chidori | node (V8) | bun (JSC) | gap vs fastest |
|---|---|---|---|---|
| arith_loop | 342 ms | 2.2 ms | 4.0 ms | 155× |
| array_hof | 362 ms | 21 ms | 15 ms | 24× |
| array_push_sum | 590 ms | 14 ms | 18 ms | 41× |
| closures | 624 ms | 0.6 ms † | 4.0 ms | (†) |
| fib_recursive | 1.38 s | 7.0 ms | 6.9 ms | 199× |
| json_roundtrip | 223 ms | 38 ms | 26 ms | 8.5× |
| property_access | 904 ms | 0.6 ms † | 3.2 ms | (†) |
| sort | 1.73 s | 110 ms | 99 ms | 17× |
| string_build | 440 ms | 3.6 ms | 3.4 ms | 128× |
† A sub-millisecond "execution" time means V8's optimizer effectively deleted the workload (loop-invariant hoisting / dead-store elimination on a loop whose result folds), so those ratios measure the JIT's ability to not do the work, not engine speed doing it. Cross-checked directly: node's total process time for property_access (~37 ms) is below its own ~42 ms startup baseline. Read those rows as "unboundedly behind on eliminable loops."
Honest summary: chidori-js executes these workloads ~10–40× slower than JIT runtimes where the work is irreducible (json, sort, array traversal) and ~100–200× slower on tight numeric/call loops, which is exactly where speculative JITs shine. Startup is chidori's one clear win (~4 ms vs node's ~42 ms). For context: QuickJS — the reference "fast pure interpreter" — sits roughly 10–30× behind V8 on these same shapes; the realistic ceiling for an interpreter-only chidori-js is that band, and §4 covers what crossing it would actually take.
(Wall-clock methodology note: an earlier run of this table taken while a
cargo build was saturating the container's cores inflated node/bun times
3–15× and understated every gap. On shared hardware, cross-runtime ratios
are only meaningful from an idle machine — one more reason the roadmap's
load-bearing numbers are callgrind instruction counts, which contention
cannot touch.)
1.2 Where the instructions actually go (callgrind)
Wall-clock on this container has a ~10–15% noise floor
(interpreter-optimization.md §7.6), so the load-bearing numbers here are
callgrind instruction counts — deterministic, environment-independent,
and reproducible to the instruction. Totals are for one full workload run.
property_access (11.0 G instructions) — hashing IS the workload:
| share | where | what |
|---|---|---|
| 48.7% | SipHash (sip::Hasher::write 19.8% + hash_one 16.1%) + IndexMap::get_index_of (12.7%) | every o.a get/set SipHashes the key string and probes the property IndexMap |
| 17.7% | step | dispatch + op bodies |
| 6.0% | run_frame | loop bookkeeping |
| 4.9% | set_prop_mode | write-path walk |
| 4.2% | Vec::push_mut | operand-stack pushes |
fib_recursive (12.8 G) — the price of a call:
| share | where | what |
|---|---|---|
| 21.3% | step | dispatch + op bodies |
| 16.7% | malloc + _int_free + free | one Rc<RefCell<Value>> heap allocation per binding per call (make_frame), plus frame vec churn |
| 8.6% | run_frame | loop bookkeeping |
| ~7.8% | SipHash + IndexMap probe | resolving the global fib by string hash on every call |
| 4.9% | make_frame | frame setup |
| 2.8% + 1.9% + 1.3% | drop_in_place<Frame>, Rc::drop_slow, Value::clone | refcount churn |
| 1.3% | slice_contains | stable_cells Vec::contains on every InitCell |
| 1.1% | BytecodeFunction::clone | per-call clone |
arith_loop (3.2 G) — dispatch-bound, as Phase 0 predicted:
| share | where | what |
|---|---|---|
| 52.3% | step (37.7%) + run_frame (14.7%) | pure dispatch |
| 8.8% | Vec::push_mut | operand-stack traffic |
| 8.2% | bin_arith | the actual arithmetic |
| 5.9% | libm fmod | the workload's % operator |
| 5.4% | drop_in_place<Value> | stack-slot drops |
Three structural taxes explain nearly all of the gap:
- Hash-table property access — no shapes, no caches, and (until this change) a DoS-hardened hasher: ~49% of property-heavy execution.
- Every binding is a heap cell —
compiler.rsdocuments this as the v1 trade ("every source-level binding is a heap cell … an allocation per binding"): ~25% of call-heavy execution is allocator + refcount traffic.frame.localsandLoadLocal/StoreLocalexist in the VM but the compiler never emits them (num_locals: 0). - Stack-machine switch dispatch — >50% of tight-loop execution, the
known ceiling from
interpreter-optimization.md§4.
2. Landed with this review: deterministic fast hasher (src/fxhash.rs)
IndexMap's default hasher is RandomState/SipHash — a keyed, DoS-resistant
hash that is exactly wrong for an engine-internal property table. Replaced
with an in-tree implementation of the Fx hash function (rustc's own table
hasher; one rotate/xor/multiply per 8-byte chunk, ~30 lines, zero new
dependencies) for: object property maps (ObjectData::props), Map/Set/
WeakMap/WeakSet backing stores.
Measured (callgrind, deterministic):
| workload | before | after | Δ instructions |
|---|---|---|---|
| property_access | 10.98 G | 7.75 G | −29.4% |
| fib_recursive | 12.83 G | 12.24 G | −4.6% |
Wall-clock moved less than the instruction count on this container (property_access roughly −10%, sort and string_build directionally similar) — the remaining probe is more memory-bound, and the shared container's noise floor blurs the rest. The instruction count is the honest, reproducible metric here.
Why this is safe:
- Determinism: hashes decide only bucket placement inside
IndexMap; iteration order is insertion order and lookups are settled byEq. The hash function is unobservable to JS and to the replay journal. UnlikeRandomState(per-process random seed!), Fx is fully deterministic — bucket layouts are now identical across runs by construction, which is strictly more deterministic than before. - Security: SipHash's seed defends tables whose keys an adversary chooses
against collision-flooding. Property keys come from the agent program and
its data; the engine already bounds runaway execution with the uncatchable
op budget and the host interrupt/timeout. A flooding attack degrades a run
the same way
while(1)does, and the same defenses answer it. V8, JSC, and SpiderMonkey all make this same trade. - Gates: full crate suite (incl.
tests/replay.rsrecord→replay byte-identity) and the Test262 gate, green.
3. The interpreter roadmap, re-ranked by measured share
Ordered by (measured cost × implementation risk). Each item is a pure performance side effect: same results, same errors, same enumeration order, same journal — gated by Test262, the replay byte-identity suite, and the callgrind instruction-count proxy.
3.1 Property-key atoms with precomputed hashes (attacks the remaining ~24% of property-heavy)
After the hasher swap, get_index_of still re-hashes the key string and
byte-compares it on every access. Property names are known at compile time
(they sit in FuncProto.consts); the fix is a compile-time atom table:
- Intern property-name strings once per engine; a
PropertyKeythen carries(Rc<str>, precomputed u64 hash); equality tries pointer-equality first. GetProp/SetPropreference the atom directly from the const table — noJsStringclone, noPropertyKeyconstruction, no re-hash per access.- Determinism: an atom table is a cache keyed by string content; identical content → identical atom. Nothing address-dependent leaks: hashes are content-derived (Fx, unseeded).
Expected: kills most of the remaining 24% get_index_of + the per-access key
materialization visible in step. Low risk — no semantic surface at all.
3.2 Cells → locals: stop heap-allocating every binding (attacks ~25% of call-heavy)
The compiler's own header calls this the deferred v1 trade. The VM already
has pooled frame.locals: Vec<Value> and LoadLocal/StoreLocal ops —
unused. The work is compiler-side:
- Pre-pass per function body: collect names referenced by nested closures
(the
FnCtxcomment already reserves the concept). Bindings not captured — the overwhelming majority — lower tolocalsslots; captured ones stay cells. Frames containing directeval(orwith) keep everything in cells, as today. - Params stay cells only when a mapped
argumentsobject aliases them or a closure captures them. - Per-iteration
letsemantics: a fresh cell per iteration is only needed when the loop body captures the binding; otherwise a local slot is reused — same observable behavior, zero allocations.
Expected: removes the malloc/free + Rc/RefCell traffic that is ~25% of
fib-style execution and much of the closures workload's 49× gap; every
LoadCell (16.9% of all executed ops in the Phase-0 survey) that becomes
LoadLocal drops a pointer-chase + borrow-check + refcount-safe clone to an
indexed Vec read. This is the single biggest interpreter win available.
Medium risk (compiler rewrite of binding resolution), fully covered by
Test262's closure/TDZ/arguments suites + the differential harness.
3.3 Global inline cache (attacks fib's ~8% + every cross-function call)
LoadGlobal resolves fib by string hash on every recursive call. The
global object is one known object; give each LoadGlobal site a slot cache:
- Cache
(slot_index)validated by a global-object mutation counter (bump on any insert/delete/reconfigure of globals — not on value writes, which go through the slot). Hit → direct indexed read from theIndexMap; miss → today's path, then refill. - Determinism: deterministic by construction — the counter is engine state driven only by program behavior, identical across record/replay. (No pointer identity involved; if per-object identity is ever needed for object-property ICs, mint allocation-order object ids — also deterministic — rather than addresses.)
- Never serialized; rebuilt per
Vmlike the existing caches.
Expected: turns every global read (function references above all) from
hash+probe into counter check + Vec index. Low risk, small surface.
3.4 Superinstruction continuation + per-op precomputation (cheap, incremental)
The Phase-2 fusion infrastructure is landed and cheap to extend:
GetPropConst/SetPropConstops that carry the resolved atom (with §3.1) — removes the const-table indirection in the hottest property idiom.stable_cellsmembership: resolve at compile time into a per-cell flag on the op (InitCellvsInitCellStable) instead ofVec::containsper execution (1.3% of fib for a linear scan!).- Integer-
%fast path inarith: both operands integral f64 in safe range → compute directly instead of libmfmod(5.9% of arith_loop). - Loop-idiom fusions from the Phase-0 pair table still unmined:
LoadCell;LoadCell;<binop>, increment patterns (i = i + 1as a singleIncCell-class op).
3.5 Register bytecode (Phase 4 — now with a justified trigger)
Unchanged assessment from interpreter-optimization.md: the biggest
dispatch-side win (arith_loop is 52% dispatch + 8.8% stack pushes + 5.4%
stack drops), and the biggest rewrite. Two changes to its standing since:
- §3.2 (cells→locals) is a prerequisite done right: once bindings live in indexed frame slots, the distance to "ops address slots directly" (that is what a register VM is) shrinks substantially — much of the compiler-side analysis is shared.
- The decision input is now instruction-exact: re-run the callgrind proxy after 3.1–3.4; if dispatch+stack-shuffle still dominates compute-heavy workloads by >40%, the rewrite pays.
3.6 Frame diet (small, riskless)
Frame carries rarely-used fields (dispose_scopes, enumerators,
with_scope, eval_vars, completion machinery) inline; boxing the rare ones
(Option<Box<RareFrameState>>) shrinks per-call initialization and the
drop_in_place<Frame> cost (2.8% of fib). Likewise BytecodeFunction::clone
per call (1.1%) can become an Rc bump.
3.7 Out of scope here, tracked elsewhere
- Warm-realm reuse —
engine_new≈ 3.6 ms dominates short scripts; flagged INVESTIGATE ininterpreter-optimization.md§11.4 and partly addressed by the resume caches (docs/resume-performance.md). - Object-shape (hidden-class) layer + property ICs — the full V8-style
answer to property access. Deliberately sequenced after §3.1/§3.3: atoms
- global ICs capture most of the measured cost at a fraction of the risk
(shape transitions,
delete, proto mutation, dictionary-mode fallback are where engines grow their worst bugs). Revisit with fresh callgrind data; ifget_index_ofstill dominates property-heavy code, shapes are the next step and the determinism story is the same as ICs (shapes are content/insertion-order-derived, never serialized).
- global ICs capture most of the measured cost at a fraction of the risk
(shape transitions,
4. The JIT question, answered honestly
The ask: "if we could add a JIT without unsafe, it would be reasonable."
A native-code JIT without unsafe does not exist. Emitting machine code
at runtime requires mapping writable-then-executable memory and calling
through a synthesized function pointer — both outside safe Rust's semantics
by definition, no matter who writes the code. The options, ranked by how
close they get:
| option | unsafe? | deps | verdict |
|---|---|---|---|
Cranelift baseline JIT (cranelift-jit) | none in our crate (forbid(unsafe_code) stays); the unsafe lives in the dependency | pure Rust, no C — but a compiler backend (~large, slow to build, its own CVE surface) | The only real JIT path. See below. |
| JS → wasm → wasmtime/pulley | same delegation, more of it | wasmtime is a much larger dependency than Cranelift alone | Strictly worse than direct Cranelift for this use. |
| Closure-threading (safe Rust) | zero | zero | Built, measured 1.01–1.11×, retired (docs/jit.md). Dispatch was never the whole problem. |
| Basic-block closure compilation (safe Rust) | zero | zero | The unexplored safe headroom: compile straight-line blocks to single closures keeping intermediates in a scratch buffer, Ctl only at block boundaries. Est. 1.3–2× on dispatch-bound code, delicate mid-block throw semantics. Only worth revisiting after §3.2/§3.5, which shrink the same costs with less machinery. |
On determinism: the old blanket objection ("a JIT fights replay") is
narrower than interpreter-optimization.md §2 states, and the retired
experiment proved the pattern: a non-speculative baseline tier (no type
guards, no deopt) that compiles a function on its Nth activation (an op-
count trigger — deterministic engine state, not wall-clock) and reuses the
interpreter's own helpers computes byte-identical results with a byte-
identical journal, record and replay alike. Determinism is an engineering
constraint on JIT design, not a disqualifier.
The honest cost-benefit: a Cranelift tier is the "no heavyweight deps"
invariant traded away, plus the largest correctness surface this engine
would ever take on (frame reconstruction at OSR points, unwinding into JS
try/finally, the op-budget contract inside native code) — for a payoff that
lands almost entirely on compute-heavy replay/test throughput, because JS
is <1% of live agent wall-clock (interpreter-optimization.md §11.5).
Recommendation:
- Land §3.1–3.4 (atoms, cells→locals, global ICs, fusion batch). These are safe-Rust, dep-free, and attack the measured 25–50% shares. Re-profile. Expected composite: 3–6× on the benchmark suite, taking the gap on irreducible workloads from ~10–40× to roughly 3–10× and tight loops from ~100–200× to ~30–60× — the QuickJS band, i.e. parity with the best pure interpreters, with startup (~4 ms vs node's ~42 ms) already won.
- Decide register bytecode (§3.5) on the post-3.x callgrind numbers.
- Treat a Cranelift tier as a product decision, not an engineering
default: it becomes rational only if a workload emerges where JS compute
dominates wall-clock at scale (mass replay fleets, compute-heavy agent
steps) and the 5–10× interpreter band is demonstrably not enough. If
that day comes, the design constraints are already established: baseline-
only, non-speculative, deterministic activation-count tier-up, interpreter
helpers for every slow path,
unsafeconfined to the dependency, and the whole thing behind the same toggle-equivalence gate the closure-threading experiment used.
5. Gates (unchanged, per item)
Every roadmap item ships only when all four hold:
- Test262 gate (
scripts/test262.sh --gate): zero regressions. - Replay byte-identity (
tests/replay.rs): identical journal + output, caches cold vs. warm, optimization on vs. off. - Differential harness (
tests/fusion.rspattern): optimized vs. fallback path, byte-identical output and errors over the corpus. - Callgrind instruction-count proxy: the claimed share actually drops; wall-clock is reported but never load-bearing on shared hardware.
6. Results of the 2026-07-02 optimization push (landed)
Five commits on this branch implement §2, §3.1 (the cheap half), §3.3, §3.4,
§3.6, and an allocation-free approximation of §3.2. All of it is safe Rust,
zero new dependencies, and gated green on both crates' full suites (98 + 603
tests, including record→replay byte-identity, the fusion differential corpus,
and the new tests/ic.rs stale-hint corpus cross-checked against Node).
- Deterministic Fx hasher (§2) for property maps and Map/Set stores.
- Quick wins: O(1)
stable_cellsflags; integer fast path for%(libmfmodwas 5.9% of arith_loop);Rcpointer-equality fast path inJsString::eq. - Key-verified inline caches on
GetProp/SetProp/LoadGlobal(FuncProto::ic): a slot-index hint per op site, verified against the key stored at that slot on every use — a stale hint is a miss, never a wrong answer, so no invalidation protocol exists. Own-data-property reads/writes and global reads skip hashing entirely on hits. - Superinstruction round 2: the fusion pass matches variable-length
windows and runs to a fixed point. A whole
i < Nloop test (CmpCellConstBranchFalse), a statement-positioni++(IncCellStmt, the 6-op window),cell <op> constoperands (AddCellConst/ArithCellConst), and the per-iterationletcopy (LoadCellInit) are each ONE dispatch. The canonical counting loop: 21 → 12 dispatches per iteration. - Call-path slimming: binding cells are pooled (recycled only at
Rc::strong_count == 1— provably unreachable, so reuse is unobservable);FunctionInner::BytecodeholdsRc<BytecodeFunction>, making the per-call clone a refcount bump instead of one or two Vec allocations. - Cells→locals localization (§3.2, landed) —
localize.rsrewrites provably-uncaptured bindings to flatframe.localsslots at compile finish. Captured/stable/aliased cells keep their ORIGINAL indices (child protos' upvalue references stay valid, no patching); dynamic-resolution functions (directeval,with, materializedarguments) bail whole. Every superinstruction gained a local mirror, so localized loops keep their one-dispatch tests/updates. En route,mapped_param_cells— which pinned every sloppy function's parameters as stable heap cells — is now built only when the function can actually materializearguments;fib-style leaf calls now allocate zero cells. Gated by a capture-focused differential corpus (tests/localize.rs) across all four {fuse}×{localize} combinations.
Round 3 (same session) added four more commits:
- Rope strings —
s += chunkwas O(total²): string_build's profile was 96.5% memcpy.JsStringgained a rope arm: each+over well-formed operands is one O(1) node; bytes are copied exactly once on first observation..lengthand the size guard stay O(1) via stored totals; flatten and drop are both iterative. string_build: 5.00 G → 0.18 G instructions (27×) — and prompt-building via+=is the canonical agent pattern. - Dense-array element fast paths — every
a[i]converted the Number key to a heap-allocated string (grisu formatting!) and reparsed it.GetPropDynamic/SetPropDynamicand the array iteration builtins (map/filter/forEach/reduce, the array iterator) now access unshadowed in-bounds dense elements directly. array_push_sum −26%, array_hof −17%. - Prototype-level inline caches —
IcEntrycarries an independentproto_slot+ holder: a data property on the receiver's DIRECT prototype (thearr.push/ class-method pattern) is cached, verified by holder pointer identity (which doubles as realm isolation) and no-own-props shadowing. The own-hit path never touches the holder. - Owned-args calls — the interpreter's call ops move their pooled argument buffer into the callee frame instead of re-copying.
6.1 Instruction counts (callgrind — deterministic, the load-bearing metric)
Whole-workload totals, branch start → after rounds 1–3:
| workload | before | after | Δ |
|---|---|---|---|
| string_build | 5.00 G | 0.18 G | −96% |
| property_access | 10.98 G | 3.95 G | −64% |
| arith_loop | 3.17 G | 2.28 G | −28% |
| fib_recursive | 12.83 G | 8.59 G | −33% |
| array_push_sum | 5.04 G¹ | 3.73 G | −26%¹ |
| array_hof | 3.03 G¹ | 2.52 G | −17%¹ |
¹ vs. the post-round-2 measurement (no branch-start baseline was taken). The zero-host agent-replay example is instruction-identical before/after round 3 (11.05 G) — its cost is host-effect glue, not the paths these rounds touched.
malloc/free (16.7% of fib at branch start) and SipHash (48.7% of property_access) have left the profiles' top ranks entirely.
6.2 Wall-clock (idle container, 5-run median, execution-only)
Against the same-day pre-push idle-machine run (§1.1 methodology):
| workload | before | after | speedup |
|---|---|---|---|
| property_access | 904 ms | 310 ms | 2.9× |
| arith_loop | 342 ms | 199 ms | 1.7× |
| fib_recursive | 1.38 s | 900 ms | 1.5× |
| closures | 624 ms | 451 ms | 1.4× |
| array_push_sum | 590 ms | 436 ms | 1.4× |
| array_hof | 362 ms | 276 ms | 1.3× |
| sort | 1.73 s | 1.38 s | 1.3× |
| json_roundtrip | 223 ms | 183 ms | 1.2× |
| string_build | 440 ms | 382 ms | 1.2× |
Geometric mean ≈ 1.5× across the suite (2.9× where property access
dominates), on top of the hasher win the same day. The zero-host agent-replay
path (examples/agent_replay) went 21.0 → 18.5 ms (−12%) — it is
host-effect glue rather than tight loops, as §11.5 of the interpreter doc
predicts.
6.3 What's next (assessment after round 3 — now addressed by §6.4)
§3.2 (cells→locals) and the property/array cache work are landed. Hash
probing (get_index_of) no longer appears in any workload's top profile
ranks — the shapes question (§3.7) is answered for now. What the profiles
show after round 3:
- sort (14.1 G, the biggest remaining): ~25% pure call ceremony for the
tiny comparator —
Frameis 408 bytes and its construction, pool round-trips, and drop dominate each comparison. The lever is a Frame diet (box the rarely-used fields) and/or a leaf-call fast path that skips unused frame machinery. (→ landed as the frame POOL + direct-call path, §6.4.) - register bytecode (§3.5) remains the dispatch-side end-game for the arith/fib class (step + run_frame are >50% there).
- A discovered pre-existing conformance gap: sealed-array appends are not
rejected (
Object.seal(a); a[len] = vwrites). Tracked for a separate fix validated by Test262.
6.4 Round 4 (2026-07-02, follow-up session): the call-ceremony round
Callgrind confirmed §6.3's read: after round 3, ~30–40% of sort/fib-class
execution was per-call ceremony — frame construction (make_frame_owned
6.3%), frame drop (3.9%), buffer-pool round-trips (recycle_frame +
recycle_value_vec 7%), ~400-byte Frame moves (memcpy 2.8%), and the
layered call dispatch (~6%). This round attacks exactly that; all safe Rust,
zero new dependencies, byte-identical benchmark checksums.
-
Frame pool — frames are now recycled WHOLE as
Box<Frame>(Vm::frame_pool): a synchronously-finished frame is scrubbed (every value-bearing field cleared, so the pool never extends a value's lifetime — the cell pool's discipline) and parked; the next call re-initializes fields in place. The operand-stack/locals/cells/args buffer capacities stay attached to the frame across calls (the per-calltake_value_vec/recycle_value_vecround-trips for three buffers are gone),run_frametakes the box (a pointer move, not a ~400-byte memcpy), and a suspension keeps its box (noBox::newper await/yield). A pooled frame'sfuncslot holds a shared placeholder (Vm::dummy_bf) so noBytecodeFunctionoutlives its frame. -
Direct call path (
Op::Call/Op::CallMethodless→Vm::call_direct): the callee is peeked on the operand stack; a plain (sync, non-generator, non-class-ctor) bytecode function skips the generic dispatch layers and its arguments MOVE straight from the caller's operand stack into the pooled callee frame — no intermediate pooledVec, no second copy, and the popped function value is reused asfunc_objwithout refcount traffic. Everything else (native, bound, proxy, async, generator, not-callable) takes the generic path unchanged. The genericcall_valuevecitself was collapsed to a single object borrow (callable check + dispatch extraction were two). -
Interpreter-loop slimming — the per-op budget/interrupt checks hoisted behind one
countingbool sampled at frame entry (both are only ever installed before execution starts); the common case pays one predicted branch instead of twoOptionloads throughselfper op. -
merge_sortscratch buffer — the recursion allocated TWO freshVecclones per node (O(n log n) allocations + refcount churn). Now one scratch buffer for the whole sort; the left run MOVES into it and merges back in place (zeroValueclones). Identical recursion structure and stable-merge order, so the comparator sees the exact same call sequence. -
Sort loop fast paths —
Array.prototype.sort's snapshot loop now uses the existinghas_get_elemdense fast path (round 3) instead of per-indexHasProperty/Getwith a heap-allocated string key, and write-back overwrites existing dense elements in place under the same gate asOp::SetPropDynamic(props.is_empty()+ in-bounds non-hole). The undefined-partition pass moves values instead of cloning. -
func_objgating — the per-callRcround-trip is skipped unlessproto.uses_arguments(its only consumer isarguments.callee).
6.4.1 Instruction counts (callgrind, whole workload, deterministic)
| workload | round-3 baseline | after | Δ |
|---|---|---|---|
| sort | 14.09 G | 11.58 G | −17.8% |
| fib_recursive | 8.59 G | 7.45 G | −13.3% |
| closures | 4.47 G | 4.00 G | −10.4% |
| array_hof | 2.52 G | 2.38 G | −5.4% |
| property_access | 3.95 G | 3.85 G | −2.6% |
| arith_loop | 2.28 G | 2.23 G | −2.5% |
| array_push_sum | 3.73 G | 3.67 G | −1.7% |
| string_build | 0.18 G | 0.18 G | 0% |
Benchmark RESULT checksums are byte-identical before/after every change. Wall-clock (5-run median, same shared container, indicative only per §1.1): sort 1.09 s → 0.93 s, fib 696 → 572 ms, closures 350 → 311 ms. An incidental robustness gain: heap-boxed frames raised the native-stack recursion ceiling from ~1160 to ~1460 JS frames (see the known issue below).
6.4.2 Robustness follow-ups (landed with this round)
Both known issues flagged during this round's review are fixed:
- Deep recursion now throws instead of aborting the process. The
pre-existing failure:
max_call_depth(2000) exceeded what the default 8 MB native stack supports, sorec(1500)-style recursion killed the process with an uncatchable native stack overflow (main aborted at ~1160 frames; the frame pool had already raised that to ~1460). Root cause:step's single ~190-arm match carried a 4 KB stack frame (LLVM's imperfect stack coloring unions the arms' locals), so every JS call cost ~5.5 KB of native stack. Fix, two-sided:stepis split: hot ops stay inline (~2.7 KB frame), everything else delegates to an#[inline(never)] step_cold. Each op has exactly ONE implementation. A plain JS→JS call's native footprint is now ~3 KB, so the 2000-frame guard fires (catchable RangeError) comfortably inside 8 MB. Probe-verified:rec(1900)returns, unbounded recursion throwsRangeError, spread-call recursion included. Callgrind cost of the split: ≈0.5% geomean (fib +1.1% worst) — the price of the abort→throw conversion.- The chidori server/CLI ran agent JS on
tokio::spawn_blockingthreads with tokio's 2 MiB default stacks (abort at ~350 frames!). All tokio runtimes are now built viascheduler::new_tokio_runtime()with 16 MiB threads (JS_THREAD_STACK_BYTES, matching the branch worker threads' existing choice). - Remaining sharp edge (accepted): recursion THROUGH
direct eval(perform_direct_evalholds its own 4 KB frame) stacks ~10 KB/frame and can still hit the native limit before the depth guard on small stacks. Pathological; unchanged from before.
- Sealed-array appends are rejected.
Object.seal(a); a[a.len] = v(andpush/unshift, in-bounds hole writes,preventExtensions-only receivers) wrote through the dense-array Set path, which never consultedextensiblewhen CREATING an element.ordinary_define_ownnow rejects creation on a non-extensible dense array — silently in sloppy mode, TypeError in strict — matching Node/spec exactly. One Test262 test flips to passing (baseline refreshed).
6.5 Typed loop kernels (landed): unboxed register execution for numeric loops
The answer to "can we selectively fast-path compute-heavy loops in a
limited context" — a deterministic, safe-Rust, zero-dependency middle tier
between the interpreter and a real JIT. Design informed directly by the
retired closure-threading experiment (docs/jit.md): removing dispatch
alone bought 1.01–1.11×, so this tier removes the boxing instead — the
Value clone/match/drop and operand-stack traffic around every add and
compare.
How it works (kernel.rs, Op::LoopKernel, Vm::run_kernel_op):
- At compile finish (after localize + fuse), back-edges identify loop
regions. A region qualifies only if every op is on a numeric allowlist —
loads/stores of localized
frame.localsslots,Numberconstants, arithmetic/comparisons/branches and their fused forms; anything else (calls, property access, cells, TDZ init, try handlers, suspension) disqualifies it entirely. The region's stack bytecode is translated by abstract interpretation into a flat register program over unboxedf64s (canonical stack slots become registers; compare ops must feed branches — a materialized boolean disqualifies). The loop-header op is REPLACED byOp::LoopKernel(indices/jump targets everywhere are untouched); the original header op is preserved as the kernel'sfallback. - At runtime the kernel enters only when (a) no op budget is installed
(per-op accounting stays exact — the conformance runner's budgeted runs
execute fully generically) and (b) every mapped local currently holds a
Number. JS numeric ops are CLOSED over numbers, so after the guard no non-number can appear mid-kernel — there is no deopt map because there is nothing to deopt. A failed guard executes the fallback op and the generic interpreter takes that iteration; the kernel retries at the next back-edge arrival (so alet x;warming to a number on iteration 1 enters the kernel from iteration 2). Loop exits write the registers back and resume the interpreter at the exit ip. The cooperative interrupt flag is polled on kernel back-edges at the interpreter's cadence. - Arithmetic calls the SAME
number_arith_raw/js_mod/to_int32helpers as the interpreter's Number×Number fast paths — results are bit-identical by construction (NaN, -0, shift masking,%sign,>>>).
Kernel v2 (same branch): dense-array access + nested loops. The
translator's virtual stack became TYPED — number registers vs. array-base
entries (a two-phase walk first discovers which locals are used as bases) —
which generalizes the tier to the s += a[i] class:
a[i]reads,a[i] = vin-place writes, anda.lengthtranslate toLoadElem/StoreElem/LoadLenkernel ops. Each access RE-CHECKS its full fast-path condition at runtime (unshadowed dense array, integral in-bounds index, non-holeNumberelement — the same conditions asOp::SetPropDynamic's existing fast path) and otherwise bails precisely: registers write back and the generic interpreter resumes AT the access op, its operand stack reconstructed from a per-kernel shape table (numbers from registers, bases from the pinned object slots). A bail is a slow iteration, never a wrong answer — holes that read through the prototype, accessor elements, frozen/sealed arrays, string elements, float/negative/OOB indices, and mid-loop growth all take the exact spec path (differentially pinned in the corpus and cross-checked against Node).- Base locals are pinned at entry (stores to them reject at translation),
aliased bases work (per-access borrows), and
a[b[i]]nests. - An inner loop's
Op::LoopKernelheader translates as its preserved fallback op, so nested numeric loops collapse into ONE outer kernel (the per-iterationlet jreset — a deadundefinedstore — is elided when provably re-stored before any read within the block).
Measured (callgrind, whole workload, deterministic):
| workload | before | after | Δ |
|---|---|---|---|
| arith_loop | 2.23 G | 0.48 G | −78% (4.6×) |
| array_sum (new workload) | 10.67 G | 3.76 G | −65% (2.8×) |
| array_push_sum | 3.67 G | 2.72 G | −26% (its sum loop kernels) |
| sort / fib / closures / property / string / json | — | — | unchanged |
Wall-clock arith_loop ~245 ms → ~44 ms (5.6×); a mixed array/nested
workload (dot products + 2D walk) 686 ms → 193 ms (3.6×). The gap to V8 on
pure numeric loops drops from ~100× to ~20×. fib (calls), sort
(comparator calls), and property/string workloads are untouched by design —
kernels only fire where the loop body is local numerics and dense-array
element access.
Gates: the differential corpus (70+ programs) + 300-case deterministic
fuzz (tests/kernels.rs) require byte-identical behavior kernels-on vs
kernels-off across break/continue/labels, NaN/-0/precision edges, guard
bails, late entry, nested loops, array holes/accessors/freeze/aliasing/
growth/reassignment, and op-budget interaction; structural tests pin the
canonical, array, and nested loops to actually kernelize; full suites +
Test262 gate green (kernels are additionally OFF under the runner's op
budget, so conformance runs the generic path — the corpus carries
kernel-specific coverage). The pass is disabled under the op-histogram
feature (it would hide per-op counts).
Kernel v3 (same branch): Math.* intrinsics + in-body const/let.
- The compiler's Math method-call pattern (
LoadGlobal("Math"); Dup; GetProp(name); Swap; args…; Call(n)) translates to direct kernel ops forabs floor ceil round trunc sign sqrt fround(unary) andmin max pow imul(binary, exact-arity only). Every kind calls the SAME core function its builtin uses (builtins::numbers), so results are bit-identical — includingMath.round's half-up negatives,min/maxNaN-poisoning and ±0 ordering, andimul's int32 wrap. The entry guard identity-checks the globalMathbinding (a plain data property holding the canonical object — accessors/replacements decline) and each used method (methods are writable; a monkeypatchedMath.maxmakes the kernel decline and the patch runs generically, observably).Math.PI- class value constants are non-writable AND non-configurable on the canonical object, so with the object identity guarded they fold to literal constants at translation. Unsupported methods/arities (hypot,log, variadicmax) reject the region as before. - In-body
const x = …/let y = …emit a TDZ-init op (InitLocalTdz) that previously rejected the region — the single biggest eligibility hole in practice. It is now ELIDED under the same proof as the deadundefinedstore: the local must be re-stored before any read, branch, or branch target; a genuine conditional-TDZ-read region stays generic so the ReferenceError comes from the spec path (pinned in the corpus).
Measured on a Math-heavy loop (clamp + imul hash over 2M iterations, the
DSP/aggregation shape): 2058 ms → 199 ms (10.3×), node at 57 ms — the
gap on this class drops from ~36× to ~3.5×. Benchmark-suite counts are
otherwise unchanged (checksums identical); the corpus grew Math edge cases
(NaN/±0 ordering, half-up rounding, monkeypatching, wholesale Math
replacement, accessor-on-globalThis, patch-between-activations) plus
in-body-declaration cases, and the fuzz generator now routes values
through Math intrinsics.
Kernel v4 (same branch): dense appends, materialized booleans, and captured loop bounds.
- Appends & hole fills:
StoreElemnow performs an exact one-past-the-end append (a[a.length] = v,arr.push-free building) and in-bounds hole fills — both CREATE a property, so they additionally require the array extensible and under the dense-storage bound; otherwise they bail to the generic path, which owns the sloppy-silent / strict-TypeError / RangeError semantics. This unlocks the fill-by-append andnew Array(n)fill idioms. - Booleans as first-class kernel values: the virtual stack and the
local map are statically TYPED. A stored comparison (
const hi = x > 5),!x,true/falseliterals, and loop-carried flags become Bool registers holding exactly 0.0/1.0; the guard requiresValue::Booland write-back restores it —typeof oknever sees a number. Coercing consumers (arithmetic, conditions, Math args) read the raw register — identical toToNumber/ToBooleanon a boolean — while array indices/elements REFUSE bools (a[true]is the property"true"), and strict (in)equality between statically mixed bool/number operands folds to its constant (the genericstrict_equalsnever compares across types). Local types are discovered to a FIXPOINT (a boolean store types the local; the next translation run reloads it as Bool); genuinely mixed-type locals keep the loop generic. - Captured loop bounds: a read-only-in-region UPVALUE (
const Ncaptured from the enclosing scope — the classic module-level bound) snapshots into a register at entry, guardedNumber. Sound because kernel regions contain no calls: nothing can write the cell mid-activation. In-region upvalue writes still reject.
Measured: array_sum drops further, 3.74 G → 2.03 G instructions (−81%
total from its 10.67 G pre-kernel baseline — the new Array(N) fill loop
was bailing per-iteration on holes). A run-scanning workload (append-build
- boolean-flag scan, 500k elements ×4) goes 2065 ms → 204 ms (10.1×). Other suite counts unchanged (±layout noise), checksums byte-identical.
Kernel v5 (same branch): FUNCTION kernels — frameless tiny callees.
The sort/HOF profile is ~55% comparator-call ceremony (frame init/recycle,
operand-stack moves, Value clones/drops) around an 8-op body. A function
whose ENTIRE body is on the kernel allowlist now also compiles to a register
program (FuncProto::fn_kernel), and the call paths (call_direct,
call_bytecode_vec, the slice-based call_bytecode) execute it FRAMELESS:
no frame, no operand stack, no pool traffic — arguments load straight into
registers and Return yields the result value (KOp::Ret, typed
Number/Bool).
- Entry guard, per call: every consumed argument present and a
Number(a missing/extra/string argument declines THAT call, generically); captured upvalues holdNumbers (read-only snapshots — no calls can run inside a kernelized body, so nothing writes a cell mid-execution); no op budget; no trace sink (it must see an enter/exit per call);Mathcanonicals verified as in v3. Callers apply the depth guard before the hook, so the max-call-depth RangeError fires identically on both paths. - fn-mode translation rules on top of the loop allowlist:
LoadArgreads become guarded argument registers; locals are pure register scratch — there is no guard to type them, so every read must be DOMINATED by a real store, tracked as a sortedinitset merged under the same must-match rule as the virtual-stack shape (a genuine TDZ read or use-before-init rejects; the generic path owns the error). Element access anda.lengthreject outright (a bail needs a frame to resume into). The declared-function prologue (this/new.targetmaterialized into locals) translates via an OPAQUE stack entry whose only legal consumer is a store to a never-read local — arrows and declared functions both kernelize. Bodies with loops work (the interiorOp::LoopKerneltranslates as its fallback, and kernel back-edges poll the interrupt flag as usual).
Measured (callgrind, RESULT checksums byte-identical across the suite):
sort 11.56 G → 6.21 G (−46%), closures 4.03 G → 2.31 G (−43%) (its
callbacks capture numeric upvalues — the guarded-snapshot rule covers them),
array_hof 2.38 G → 1.72 G (−28%), arith_loop/array_sum −3.4% each;
fib_recursive +0.07% (the fn_kernel.is_some() probe on a never-eligible
callee — ~1 instruction per call), property_access unchanged. Wall-clock
sort ~1.4 s → ~0.6 s on the dev box.
Gates: corpus grew ~20 function-kernel programs (comparators incl.
boolean-returning and ternary, map/filter/reduce/every/findIndex callbacks,
upvalue capture with number and string cells, missing/extra/non-number
arguments, monkeypatched Math, −0/NaN pins, .call/.apply entry,
recursion staying generic, loops inside kernelized bodies); structural pins
require the canonical tiny functions to carry fn_kernel and
frame-dependent bodies (arguments, property access, calls, allocation,
implicit-undefined return) to NEVER carry one; the op-budget test now also
covers a call-heavy program (function kernels are OFF under a budget, like
loop kernels).
Kernel v6 (same branch): SELF-RECURSIVE function kernels.
fib-class functions are pure scalar bodies whose only off-allowlist ops are
the recursive call sites. LoadGlobal of the function's OWN name now
translates (fn mode) to a speculative "self" entry, and the plain-call
pattern over it fuses to KOp::SelfCall: the executor
(Vm::run_fn_kernel_rec) runs the whole recursion as stacked REGISTER
WINDOWS over one grown Vec<f64> with an explicit (return-pc, dst,
window) stack — zero frames, zero Values, zero operand stacks for the
entire call tree.
- Guard (on top of v5's): the global the callee resolves through must
be a plain data property holding the VERY closure being invoked (pointer
identity) — a rebound/shadowed/accessor'd name declines and the generic
LoadGlobalobservably resolves whatever the program set up. Checked once per top-level entry: nothing inside a kernel can write globals. - Depth fidelity: self-calls track depth against the interpreter's
limit (
call_depth + window count); an overflow ABANDONS the activation and returns "guard declined" — sound because function kernels are pure (registers only) — so the caller's generic rerun recurses to the same depth and raises the exact spec RangeError from the exact frame. Interrupts poll on self-calls and back-edges as usual. - Static safety rails: every self-call must supply every argument
index the body consumes (a short call would need the generic
undefinedparameter), and a recursive kernel must return NUMBERS only (a boolean would land in a caller register statically typed Num, diverging undertypeof/strict-eq). Argument expressions must be statically-Number registers. Mutual recursion and named-expression self-reference (a lexical binding, not a global) stay generic.
Measured: fib_recursive 7.51 G → 0.81 G instructions (−89%, 9.3×);
wall-clock fib(30) ~78 ms vs node 22's ~110 ms total on the same box — the
first workload where chidori beats node outright. Every other suite count
is at layout-noise level (±0.15%), checksums byte-identical. Corpus grew
fib/gcd/Ackermann (nested self-call arguments), rebinding-mid-program,
boolean-return and mutual-recursion negatives, per-call declines, and a
dedicated depth-overflow differential (max_call_depth = 64 on a big-stack
thread) pinning the abandon-and-rerun RangeError path.
Kernel v7 (same branch): named-property access on pinned objects.
The property_access shape — a monomorphic o.a = i; o.b = o.a + 1; …
get/set loop over a plain object — was pure interpreter tax (44% step,
27% Value clone/drop/push). Op::GetProp/Op::SetProp over an
oslot-pinned base now translate to KOp::LoadProp/KOp::StoreProp, with
a resolution model STRONGER than an inline cache: each (base, key) class
resolves ONCE at kernel entry to a raw property-map slot index, and then
runs with zero per-access checks and no bail path. That is sound
because nothing inside a kernel region can restructure a property map —
no calls, no property creation or deletion (delete rejects the region;
a creating store never translates) — and the only in-kernel property
writes are StoreProp's in-place Number overwrites, so both the slot
index and the loaded-value Number-ness are activation invariants.
- Entry conditions per class (mirroring
Op::SetProp's interpreter fast path): the base holds anInternal::Ordinaryobject (exotic receivers — Proxy, module namespace, typed arrays,Date&c. — decline), the property exists as an OWN data property, holds aNumberwhere the region loads it, and is writable where the region stores it. Any miss declines the ACTIVATION into the generic fallback iteration (accessors fire, frozen objects fail silently/throw, prototype reads walk the chain — all observably, on the spec path), and the kernel re-tries at the next back-edge (late entry covers the create-then-loop idiom). - Aliased bases stay coherent (every access reads/writes the object's
real storage); slots re-resolve on every activation, so shape changes
BETWEEN activations are fine.
o.lengthkeeps the arrayLoadLenpath.
Measured: property_access 3.84 G → 0.69 G instructions (−82%, 5.5×),
checksums byte-identical across the suite. The fatter kernel dispatch
loop costs arith_loop/array_sum ~+2.5% (register pressure), dwarfed by
the win. Corpus grew getter/setter observation counts, frozen and
non-writable stores (sloppy + strict), prototype reads, aliasing,
create-in-loop late entry, delete-in-loop rejection, between-activation
shape changes, non-Ordinary receivers, and −0/NaN pins.
Kernel v8 (same branch): pinned-closure calls inside loop kernels.
The closures shape — for (…) s = f(s) - 4 over a tiny capturing callback
— spent ~2300 instructions per iteration on generic dispatch and call
ceremony around ~15 instructions of work (the callee itself already ran
frameless via its v5 function kernel). A call of an OBJECT-TYPED LOCAL
under a plain undefined this now translates to KOp::CallKernel: the
callee local is pinned exactly like an array base (in-region stores to it
reject), and the callee's function-kernel register program runs INLINE on
a dedicated window above the caller's registers.
- One guard per activation covers everything
run_fn_kernelchecks per call: plain bytecode function, has a (non-recursive, Number-returning) fn kernel, every consumed argument index below the smallest argc any site supplies, canonical Math, Number upvalues — the upvalue snapshot loads into the window ONCE (callee code never writes upvalue registers, and no calls can run between iterations). Loop calls happen at a constant depth, so one depth check stands in for the per-call guard; an active trace sink declines (it must see an enter/exit per call). Argument registers must be statically NUMBER (they copy raw into the callee's guarded-Number arg registers). - Per call: copy argc registers, run the callee window (its
back-edges poll the interrupt through the caller's counter), copy the
Retregister out. No frames, noValues, no operand stacks. - Codegen isolation: the register loop is MONOMORPHIZED on a const
CALLEESflag — kernels without closure calls compile the arm and its state out entirely. (The naive single loop cost arith_loop/array_sum/ property_access 7–10% in register spills; the split restored all three to their prior counts.)
Measured: closures 2.32 G → 0.57 G instructions (−76%; −86% from the 4.03 G session start), other suite counts within ±1%, checksums byte-identical. Corpus grew multi-callee regions, non-number upvalues, mid-loop and between-activation callee reassignment, boolean-returning / kernel-less / native / recursive callees (all decline observably), short/extra argument counts, monkeypatched Math, −0 pins, and callee results flowing into property/element stores.
6.5.1 What's next (new baseline)
- Remaining kernel candidates:
String.prototype.charCodeAt-class reads, loop bounds via own-frame CELLS (captured accumulators), typed-array element access (a natural fit — elements are statically numeric), and argument-typed ARRAY parameters for function kernels ((a, i) => a[i]needs arg object slots + a bail-free access story). - Kernel-tier extensions with clear shapes: MUTUAL recursion (guard a
small set of global bindings instead of one), self-calls through local/
captured bindings (
const f = n => … f(…)), and boolean-returning recursion (type the result register Bool). - step dispatch remains the wall for non-kernel code:
step+run_frameare 25–43% of call-heavy workloads. Register bytecode (§3.5) is the remaining structural lever, and kernels shrink its risk: the translator's typed-stack machinery is exactly the analysis a register allocator needs.
Re-run the callgrind sweep before choosing; the noise-floor and idle-machine caveats in §1.1 stand.
6.6 JSON round-trip (landed): single-buffer stringify + parser fast paths
json_roundtrip's profile was ~30% raw allocator traffic and ~12% Rust
format! machinery: the serializer built a fresh String per LEAF, a
Vec<String> + join + format! wrap per tree LEVEL, and allocated an
Rc<str> property key per member [[Get]]; the parser built every string
char-by-char and the number formatter ran grisu for every integer.
Changes (builtins/numbers.rs, vm.rs) — no spec-visible effect moves:
the [[Get]] order, toJSON/replacer calls, and proxy traps are untouched,
and a 25-case differential battery (escapes, indent modes, replacer
allowlists/omission, boxed primitives, toJSON, surrogate escapes,
control-char rejection, circular detection, −0/1e21/2^53-class numbers)
is byte-identical to node 22:
- One output buffer for the whole tree:
json_stringifyappends and returns emitted/omitted; an omitted object member TRUNCATES its written"key":prefix back off (its side effects already ran, exactly as the spec orders). Separators are direct pushes; the pretty-print strings are all empty in compact mode. - Run-based escaping (
json_quote_into): only",\and control bytes escape — all single ASCII bytes — so maximal clean runs copy as slices, multi-byte UTF-8 included wholesale. JsStringkeys end-to-end: member keys stayRc(clone = refcount bump) through the key list, the member [[Get]], toJSON/replacer arguments, and quoting — no per-member allocation.- Small-integer number formatting (
push_number_string): integral |n| ≤ 2^53 — exact, and its plain decimal digits ARE the shortest round-trip form — formats straight into the buffer; larger/fractional values keep the spec grisu path (String(2**60)-class values differ!). - Parser no-escape fast path: a string body without escapes/control bytes is ONE slice copy; the escape-aware loop only runs from the first backslash.
Measured: json_roundtrip 2.07 G → 1.20 G instructions (−42%), RESULT checksums byte-identical across the suite. Remaining costs are parse-side object building (property-map hashing) and the interpreter reads around the loop — shape-cache territory, out of scope here.
6.7 String code-unit reads (landed 2026-07-10): cached length + O(1) ASCII indexing
A gap found while adding bench coverage, not by callgrind — no workload
exercised it (the new string_scan closes that): the charCodeAt-class
accessors (charCodeAt/charAt/codePointAt/at) materialized the ENTIRE
string as a fresh Vec<u16> per call (units_this → to_utf16_vec), and
.length on a plain Utf8 string re-scanned it per read — so the canonical
tokenizer loop for (i = 0; i < s.length; i++) s.charCodeAt(i) was O(n²)
with n full-string allocations.
Two changes, no new dependencies, no semantic surface:
Repr::Utf8carries a lazily-cached UTF-16 unit count (Cell<u32>, computed on firstlen_utf16;from_code_unitsrecords it for free)..lengthis O(1) after first read, and — since unit count == byte count iff the string is pure ASCII —code_unit_atindexes bytes directly on ASCII strings (theRopearm already stores totals, so an ASCII rope gets the same O(1) path over its flattened bytes). Construction stays O(1); the bytecode-constant load path (from_rc_str) pays nothing.- The four accessor builtins read through
code_unit_atinstead of materializing code units.
Measured: string_scan (8 KB ASCII string, 12 scan rounds + a charAt pass)
1.50 s → 50 ms wall (30×); heap churn for the criterion variant
20.16 MiB / 166 k allocs → 167 KiB / 2.4 k allocs. RESULT byte-identical
to Node. Non-ASCII strings keep the O(i) iterator path per read — a rope
index or WTF-8 offset table remains available headroom if a workload ever
needs it, and kernel candidate (a) (§6.5.1) now has its O(1) accessor
prerequisite.
6.8 Typed-array kernel bases (landed 2026-07-10): §6.5.1 candidate (c)
The loop-kernel translator always accepted t[i] / t[i] = v / t.length
over a pinned object base — the RUNTIME arms just only recognized dense
Internal::Array, so a typed-array loop kernelized and then bailed on every
access. The arms now also take numeric typed arrays:
LoadElem/StoreElem: a valid-index integer-indexed [[Get]]/[[Set]] reads/writes element storage directly — own props and the prototype chain are never consulted, so no props/proto re-check is needed (unlike dense arrays). The register already holds the ToNumber'd store value, andtyped_array::encode/decodeare the SAME per-kind conversions the builtin path uses (f32 rounding, ToInt32-class wrapping, u8 clamping) — bit-identical by construction. OOB (incl. detached / shrunk views) bails: the generic path owns undefined-absorption and silent-store semantics. BigInt-element kinds always bail (elements aren't Numbers).LoadLen: typed-array.lengthresolves through a prototype ACCESSOR, so the activation entry guard (kernel_ta_len_ok, gated by the newKernel::loads_lenflag) identity-checks that any typed-array LoadLen base still resolves to the pinned canonical%TypedArray%.prototypegetter (Realm::ta_length_getter) with no own shadow. Sound for the whole activation: nothing inside a kernel can add props, change protos, or resize/detach a buffer (all require calls).
Measured: the typed_array workload (Float64Array sum/dot/transform +
Int32Array bit-mix) 637 ms → 37 ms wall (17×) — from ~45× behind Node to
roughly parity. Gates: the kernels differential corpus grew 17 typed-array
programs (per-kind store semantics incl. clamping/wrapping/f32 rounding,
OOB reads and writes, aliased same-buffer and cross-kind views, offset
subarrays, own-length shadow, patched prototype getter, null proto,
BigInt kinds, resizable-buffer length tracking, mixed dense+typed regions);
full suites + Test262 gate green.
6.9 Kernel v9 (landed 2026-07-10): the recursion shapes — mutual,
boolean-returning, and captured-binding self-reference (§6.5.1 e/f/g)
The three shapes v6's self-recursion tier declined, all landed in one
generalization. KOp::SelfCall gained a callee selector, Kernel:: self_global became a KernelRec descriptor (self_refs + partner
globals), and the windowed executor became MULTI-FUNCTION:
- Boolean returns (g): translation runs under an assumed static return
type (Number first, then Boolean) —
SelfCalldst registers are typed by the assumption and everyRetmust agree, soisEven-class predicates kernelize while mixed-type recursions stay generic. A top-level boolean result materializesValue::Bool(typeof/strict-eq exact). - Captured-binding self-reference (f):
LoadUpvaluein callee position (the compiler'sLoadUpvalue; LoadUndefined(this); args…; Callpattern) speculates SELF; the entry guard requires the cell to hold the very closure being invoked.const gcd = (a, b) => … gcd(…)and named function expressions kernelize; a reboundletor a helper closure in the cell declines observably. Mis-speculation only costs translation — the shapes it could hit were never kernelizable. - Mutual recursion (e):
LoadGlobalof a non-self name in fn mode becomes a speculative partner reference. The entry guard resolves the whole call FAMILY once per activation (transitively, bounded at 8): every name a plain data global holding a plain sync bytecode closure with a function kernel, every member's self-references intact, every member'sRettype matching the entry kernel's, every call site supplying the RESOLVED callee's consumed arguments, canonical Math and Number upvalues per member. Execution stacks per-member register windows over one buffer; the current member's tables are hoisted so same-kernel recursion (fib-class) pays nothing new — fib's count is unchanged. Nothing inside a kernel writes globals or cells, so entry resolution holds for the activation; rebinding a partner BETWEEN calls declines and the patched binding is observed generically.
Measured (idle machine, interleaved A/B medians): mutual_recursion (isEven/isOdd over 20k outer calls + const-arrow gcd) 1.106 s → 0.160 s (6.9×); fib_recursive / closures / sort unchanged. RESULT byte-identical. Remaining headroom: the per-activation family resolution allocates its tables per OUTER call (~40k entries here) — an entry cache or Vm-pooled scratch would shave the shallow-recursion case further. (→ landed as the family cache + grow-only register buffer, §6.11.)
Gates: the kernels differential corpus grew 16 recursion programs (boolean/mutual/const-binding families, typeof/strict-eq observation, partner and self rebinding declines, non-kernelizable family members, short-arg call sites at translation and at entry, −0 pins, Math inside recursion, captured numeric upvalues) and the depth-overflow differential now covers the mutual path; structural pins updated (boolean/mutual/ captured-binding recursion MUST kernelize, parameter-recursion must not); full suites + Test262 gate green.
6.10 Register bytecode (landed 2026-07-10): §3.5, scoped as a tier
The remaining structural lever from §3.5, built the way this engine builds
tiers rather than as the big-bang rewrite the original phase plan imagined:
a compile-finish pass (reg.rs) translates a WHOLE eligible function body
from the final stack bytecode into a register program
(FuncProto::reg: Option<Rc<RegProto>>), and Vm::run_frame — the single
funnel every call, construct, accessor, and module/script evaluation goes
through — executes it via run_reg_frame instead of the stack loop. No
operand stack, no per-value push/pop traffic, and one dispatch where the
stack machine pays two or three: LoadLocal a; LoadLocal b; Add; StoreLocal c
is a single Add { dst: c, a, b }.
Translation model (mirrors the kernel tiers' discipline, over boxed values):
- Same helpers, same semantics. Every register op calls the SAME
Vmhelper as its stack twin. The inline-cache fast paths (GetProp/SetProp/LoadGlobal) and the dense-array element fast paths were extracted into sharedVmmethods (ic_get_prop,ic_set_prop,ic_load_global,elem_get,elem_set, …) that BOTH interpreters call, so each op keeps exactly one implementation and the tiers cannot drift. Register programs carry their ownIcEntrytable with the identical key-verified protocol. - Registers are
frame.locals. Slots0..num_localsare the localized bindings (same TDZ marker, same semantics); slots above are the canonical homes of the stack machine's operand depths (canon(d) = num_locals + d). A register frame is an ordinary pooledFrame— GC tracing,arguments, cells, upvalues, and the frame pool all work unchanged. - Lazy operands. A virtual stack of entries abstract-interprets the
stack code at compile time.
LoadLocal/LoadConst/LoadThis&c. emit NOTHING — consuming ops read the local register or akonstoperand directly; everything is flushed to canonical form at control-flow edges, and any op that writes a local first flushes live lazy references to it (locals are provably unaliasable — that is what localization established). A forward dataflow (intersection at joins, to a fixpoint) proves most local reads can never see the TDZ marker, so they skip the check the stack interpreter pays on everyLoadLocal; unprovable reads emit an explicitTdzCheck. TheDup; GetProp(name); Swapmethod-call prologue fuses to one property op (plus at most one move). Fused stack superinstructions map 1:1 onto fused register ops (CmpBrK,IncLocal,AddCellK, …), so non-kernel loops keep their one-dispatch tests and updates. - Whole-function eligibility, tier ordering. Any op outside the
translated subset declines the function:
with/direct-eval scope ops,super/private class elements, dispose scopes (using), suspension ops, andOp::LoopKernel— a loop-kernelized function keeps the stack tier because its unboxed kernels are far faster than boxed register ops. The call paths still tryfn_kernelfirst, so the tier order per activation is: function kernel (unboxed, frameless) → register program (boxed, framed) → stack loop. Await-freeasyncbodies translate (they contain no suspension ops and run to completion in one shot); a realawaitdeclines the body. - Try/catch/finally translate (round 2, same day) — and with them
for-of loops and array destructuring, whose IteratorClose-on-abrupt-exit
protocol rides the handler machinery.
ROp::PushTryHandlerrecords the CANONICAL operand depth after a full flush (so the surviving entries — a for-of loop's iterator — sit where every unwind expects them); the register-mode completion walk (reg_do_completion) mirrorsdo_completionexactly, with "truncate the operand stack" becoming "clear the canonical registers above the handler's depth" (value- lifetime parity) and the exception landing incanon(depth)— the register the catch label's seeded state reads it from. Catch/finally labels are seeded in the pre-emission dataflow (catch atdepth + 1) with a conservative all-maybe TDZ set, since a throw can occur anywhere in the guarded region.Op::CompletionJump/Op::EndFinally(break/ continue/return crossing finallys) translate 1:1; the delegation arms cannot occur (generators never translate). A jump target reachable ONLY through parked completions (awhile(true)whose sole exits break through afinally) has no seeded state and declines the function. - Determinism. The register program is a pure function of the source,
compiled at compile finish. It polls the cooperative interrupt flag at
the stack loop's cadence.
Like the kernel tiers, the register tier is OFF whenever an op budget is installed— superseded by §6.11: the tier now carries EXACT per-op stack-unit costs and stays on under a budget, so the production server's defaultCHIDORI_JS_OP_BUDGET=5e9runs get the register tier too. The kernel tiers still decline under a budget. - Native stack discipline.
run_reg_framekeeps the hot ops inline and delegates everything rare to an#[inline(never)]rstep_cold, the same split (and reason) asstep/step_cold; the depth-overflow differential pins that deep recursion through register frames raises the same catchable RangeError at the same depth.
Gates: a 110-program differential corpus + 300-case deterministic fuzz
(tests/reg.rs) require byte-identical output and errors reg-on vs reg-off
across the virtual-stack shuffles (ternaries, logical operators, optional
chains, method calls, compound assignment), TDZ edges, IC hit/miss/exotic
paths, dense-element semantics, every call shape, closures/per-iteration
capture, arguments aliasing, mixed-tier throws, the full handler-
machinery surface (rethrow, nested finallys, return-in-finally overriding,
break/continue crossing finally regions, iterator close on break/throw
with throwing/suppressed return()), and the decline boundaries; structural pins require the canonical shapes to translate and
the excluded shapes to decline; the op-budget test additionally asserts the
budget drains to the exact same count on both compiles. Full suites for
both crates (118 + 761 tests, incl. record→replay byte-identity) and the
Test262 gate: green. The pass is disabled under the op-histogram feature
(register execution would hide per-op counts).
6.10.1 Measured (callgrind instruction counts, whole workload)
Reg-on vs reg-off, same commit, release build; RESULT checksums byte-identical on every workload:
| workload | reg off | reg on | Δ instructions |
|---|---|---|---|
| string_scan | 521.4 M | 368.3 M | −29.4% |
| sort | 2.64 G | 2.07 G | −21.5% |
| mixed_helpers (new) | 5.98 G | 5.18 G | −13.5% |
| string_build | 183.3 M | 160.1 M | −12.7% |
| json_roundtrip | 1.25 G | 1.20 G | −4.3% |
| arith / fib / closures / property / array_hof / array_push_sum | — | — | ±0.003% |
The unchanged rows are exactly the kernel-owned workloads: their hot
functions decline register translation by design (the +0.003%-class
deltas are the one-branch proto.reg probe per call, the same cost class
as the fn_kernel probe). The wins land where the boxed interpreter
itself carries the run — top-level glue around native/kernel calls (sort's
build-and-invoke loop), string-scan/build loops, and mixed_helpers,
which is all glue by construction. Geomean over the five
interpreter-carried workloads: −17% instructions.
Wall-clock (idle container, 5-run median, execution-only):
| workload | reg off | reg on | speedup |
|---|---|---|---|
| string_scan | 58 ms | 42 ms | 1.38× |
| sort | 295 ms | 243 ms | 1.21× |
| mixed_helpers | 724 ms | 620 ms | 1.17× |
| string_build | 28 ms | 26 ms | 1.08× |
| json_roundtrip | 154 ms | 147 ms | 1.05× |
| kernel-owned workloads | — | — | 1.0× (noise) |
Handler-machinery spot check (round 2): an ad-hoc for-of/try/destructuring
glue script (Object.entries iteration, destructuring in a helper called
per entry, try/catch around a throwing path, try/finally in the loop body —
all shapes round 1 declined) measures 518.9 M vs 646.6 M instructions
(−19.8%) and 84 ms vs 103 ms wall (1.23×) reg-on vs reg-off, RESULT
byte-identical. The committed suite's rows are unchanged (no committed
workload uses try/for-of in hot code).
mixed_helpers is a new workload added with this round: small helper
functions over objects and strings (property traffic across calls, string
building, for-in, ternary classification) — the shape of real agent glue
where every function declines the kernel tiers and the interpreter itself
carries the run. It is the register tier's home turf; the kernel-owned
numeric workloads are untouched by design (their functions decline
translation), and the suite's RESULT checksums are byte-identical reg-on
vs reg-off across the board.
6.10.2 What's next (post-register baseline)
Try/finally support— landed same day (see above): for-of, array destructuring, and try-wrapped code now translate.- Register-mode loop-kernel embedding — let a function carry BOTH kernels and a register program by teaching kernel bail-out to resume at a register pc instead of a stack ip.
Budget-compatible fast tiers— the register half landed (§6.11): budgeted runs keep the register tier with EXACT drain. The kernel tiers (whose fused unboxed regions have no 1:1 stack-op correspondence at bail boundaries) still decline under a budget; mapping them stays open.- Re-run the callgrind sweep before choosing; §1.1's caveats stand.
6.11 Recursion-entry + for-in fast paths; the register tier joins budgeted runs (landed 2026-07-11)
Four items from a fresh callgrind review of the two workloads the previous rounds left richest — mutual_recursion (1.43 G, ~22% per-activation overhead) and mixed_helpers (4.16 G, ~14% for-in machinery) — plus the op-budget follow-up §6.10.2 tracked. All safe Rust, zero new dependencies.
-
Grow-only kernel register buffer.
Vm::kernel_regswas cleared and re-zeroed per activation, so a 300-deepisEvenrecursion re-zero-filled every window on each of 40k outer calls —Vec::resize+ memset were 11% of mutual_recursion. The buffer now keeps its high-water length across activations and is never re-zeroed: stalef64s are unreadable by construction (every register is loaded at entry or store-before-read by translation proof — the same proofs the kernels already rely on). -
Recursion-family cache (
exec::RecFamily,Vm::rec_families). v9's per-activation family resolution allocated ~8 table Vecs and re-scanned every member's kernel code (Ret-type + argc cross-checks) per OUTER call. The resolved family is now cached keyed by the entry closure's identity; a hit re-verifies only the dynamic facts — each recorded global SLOT still holds its member (key-verified indexed read, no hashing — insertion slots are stable under value writes and appends), upvalue self-cells intact,Mathcanonical — and re-snapshots upvalue values. Any failure drops the entry and re-resolves from scratch (exactly the uncached path), so hit vs miss is unobservable. The windowed executor's (return-pc, dst, window) stack is Vm-pooled, and the family's tables are read through the cache during execution. Bounded (8 entries, MRU); holding member closures across activations is the same retention class as the prototype-holder ICs. -
for-in fast path (
Vm::for_in_keys_fast). Everyfor (k in o)built a shadowingHashSetand per-chain-levelown_keyskey-clone Vecs — so a 5-key plain object also inserted Object.prototype's ~12 non-enumerable keys into a set that then rehashed and dropped, 60k times (~14% of mixed_helpers + a large share of its allocator traffic). Now: an ORDINARY receiver's own enumerable string keys come straight off its property map (index-likes sorted first, own keys unique by construction — no dedup needed for one level), and the prototype chain is walked with an allocation-free "contributes any enumerable string key?" scan. If nothing contributes (every standard prototype), shadowing is irrelevant and the walk is done; a proxy, an exotic index source, or any enumerable proto key falls back to the generic path unchanged. Gated by an 18-case differential vs Node (index ordering, shadowing incl. non-enumerable shadows, null proto, mid-programObject.prototypeinjection, arrays/holes/strings, enumerable accessors, frozen objects, array-valued protos). -
The register tier joins budgeted runs (
RegProto::costs). The tier previously declined whenever an op budget was installed — so the production server'sCHIDORI_JS_OP_BUDGET=5e9kept every agent run on the stack tier, and §6.10's wins never reached production. Each register op now carries the EXACT number of final stack-bytecode ops it stands for, attributed during translation: elided pure loads accumulate into the next ANCHOR op on their path (between a pure stack op and the next anchor nothing observable runs, so charging there is exact); a fall-through edge into a join sinks stranded units into a pureROp::Charge; the fused method-call window anchors at itsGetProp(unit 2 of 3) so a getter still runs — with the same remaining budget — when the stack tier would have died exactly at the trailingSwap. The charge is hard-checked before each op: the budget-exceeded throw fires exactly where the stack loop's per-op check would have, no op the stack tier would not have reached can run, and every control op costs ≥ 1 so termination stays guaranteed. Unbudgeted runs pay one predicted branch per op (the stack loop's owncountingprotocol).En route this EXPOSED a pre-existing register-tier crash that budgeted runs had masked: a zero-arg call/construct whose argument window starts one past every touched register (
new/method-call at virtual-stack top, the Test262S13.2.2_A10shape) panicked slicinglocals[at..at]out of range — the process-killing kind of bug the conformance gate exists to catch, and it only surfaced because the gate (which installs a 50M budget) now exercises the register tier at all. Fixed with explicit empty-window guards; the shape is pinned in the reg corpus.
6.11.1 Measured (callgrind, whole workload, deterministic)
Baseline = the branch point, same toolchain; RESULT checksums byte-identical across the suite and cross-checked against node/bun by the benchmark harness:
| workload | before | after | Δ |
|---|---|---|---|
| mixed_helpers | 4.157 G | 2.953 G | −29.0% |
| mutual_recursion | 1.434 G | 1.097 G | −23.5% |
| json_roundtrip | 1.193 G | 1.191 G | −0.2% |
| sort | 2.045 G | 2.051 G | +0.3% ¹ |
| string_scan | 353.8 M | 354.9 M | +0.3% ¹ |
| fib_recursive | 673.8 M | 676.5 M | +0.4% ² |
| closures / arith_loop / property_access / array_sum / typed_array / array_hof / array_push_sum / string_build | — | — | unchanged (±0.1%) |
¹ The register loop's counting branch — the price of budget
eligibility, paid only by reg-carried workloads.
² One instruction per self-call reading window sizes through the cached
family instead of hoisted locals.
Wall-clock (idle container, indicative per §1.1): mutual_recursion 150 → 113 ms, mixed_helpers 587 → 427 ms.
Gates: full suites for both crates green; kernels + reg differential corpora green (reg corpus grew the zero-arg-window pin); the op-budget test now genuinely exercises the register tier, joined by TWO new budget differentials — a full 0..=total budget sweep on a side-effecting program (every budget value must produce identical console output, completion, and remaining budget on both compiles) and boundary probes across the whole reg corpus; the 18-case for-in differential vs Node; Test262 gate green — now running the register tier under the runner's budget for the first time, i.e. 40k+ conformance tests newly executing the tier.
6.12 Masked kernel windows, kernel const-prop + fusion round 2, and
object-literal templates (landed 2026-07-11)
A fresh callgrind review of the post-§6.11 profiles found three structural costs, attacked in one round. All safe Rust, zero new dependencies, RESULT checksums byte-identical across the suite (cross-checked against node/bun by the benchmark harness).
- Masked fixed register windows (
bytecode::KWIN = 32). Every kernel executor indexed its register file throughVec<f64>slice indexing — and the resulting bounds checks were 12–17% of every kernel-owned workload (they never predict away: the panic branch sits in the hot dispatch loop). Registers now live in fixed[f64; KWIN]WINDOWS indexed asregs[(r as usize) & KWIN_MASK]— translation caps every kernel at KWIN registers (observed max: 10) so the mask is an identity on valid indices that PROVES the access in-bounds; the compiler drops the check and the panic branch entirely, zerounsafe. Loop kernels run one window (pinned-callee windows at KWIN strides above it, re-split perCallKernel); plain function kernels run one stack-allocated window (no pool traffic at all); the recursive executor strides windows at KWIN with a member-level/window-level loop split so same-kernel calls re-derive only the window view, never the member tables. - Kernel const-propagation + fusion round 2 (
const_prop_kops, three new superinstructions). The loop translator materialized constants that fed two-register ops ((i*7919) % 10007ranConst+ArithwhereArithKexists), so cleanup gained a forward const-prop pass (same block discipline ascopy_prop_kops):Add/Arith/BrCmpoperands that are known constants rewrite to their*Kimmediate forms (mirroring the comparison when the constant is on the left; only commutative kinds swapArithoperands),Mov-of-constant becomesConst, and deadConstdefs die in DCE (extended pastMovs). Fusion gained the shapes the array_sum dump showed dominating:LenBrCmp(thei < a.lengthheader test — LoadLen + BrCmp in one dispatch, with the LoadLen entry-guard scan taught to see the fused form),LoadElemAdd(s += a[i]),LoadElemArith(the dot-product second load feeding its multiply), andArithKAdd(s += a <op> Kafter const-prop). The canonicals += a[i]loop: 8 dispatches per iteration → 5. En route, thei.fract() == 0.0integrality probe in every element fast path (kernel and interpreter) became a cast round-trip (dense_index) —fractlowers to a libmtruncCALL on baseline x86-64, ~3% of array-heavy workloads. - Object-literal templates (
Op::NewObjectTpl,FuncProto::obj_tpls). mixed_helpers spent ~12% (inclusive) indefine_own_property+IndexMap::insert_full: every{ id: …, name: …, … }evaluation re-hashed and re-inserted every key through the full spec descriptor path — on a fresh ordinary extensible object, where none of that machinery can observe anything or fail. The compiler now detects all-static-data-key literals (≥2 distinct plain keys; computed keys, accessors, methods, spread,__proto__, and duplicates keep the generic path), pre-builds the property map ONCE at compile time, and instantiation CLONES it (bucket layout included — no hashing, no growth-rehash, no descriptor validation) and writes the evaluated values into slots0..N. Value expressions still evaluate in exact source order (static keys have no evaluation step, and the object cannot be observed mid-literal — no binding exists and every template-eligible property is plain data). Both tiers execute it (ROp::NewObjectTplmirrorsNewArray's canonical window; budget cost is its exact 1 stack unit); kernel regions decline it as before. A side benefit: all objects from one site share one insertion layout, so the key-verified property ICs hit consistently. - Smaller wins along the way:
op_addgained a String⊕Number fast path (both operands already primitive: format the number's digits straight into the once-allocated result buffer viapush_number_string— the exact digitsto_js_stringproduces — skipping two intermediate allocations per+; restricted below the rope threshold so big accumulators keep the O(1) rope path);JSON.parseinterns object keys across parses (Vm-level bounded cache — repeated same-shaped documents reuse oneRc<str>per key) and pre-sizes each object's property map (one table allocation instead of the 0→3→7 growth);js_mod's integer fast path also dropped its libmtrunccalls.
6.12.1 Measured (callgrind, whole workload, deterministic)
Baseline = the branch point, same toolchain (rustc 1.97), same container; RESULT checksums byte-identical on every workload:
| workload | before | after | Δ |
|---|---|---|---|
| array_sum | 1.948 G | 1.522 G | −21.8% |
| mixed_helpers | 2.953 G | 2.324 G | −21.3% |
| string_build | 118.1 M | 94.0 M | −20.4% |
| typed_array | 521.0 M | 429.4 M | −17.6% |
| property_access | 247.4 M | 204.4 M | −17.4% |
| arith_loop | 382.3 M | 322.3 M | −15.7% |
| array_push_sum | 282.0 M | 240.0 M | −14.9% |
| closures | 378.4 M | 326.4 M | −13.7% |
| mutual_recursion | 1.097 G | 958.9 M | −12.6% |
| array_hof | 340.2 M | 327.4 M | −3.8% |
| sort | 2.051 G | 1.992 G | −2.9% |
| string_scan | 355.3 M | 351.4 M | −1.1% |
| json_roundtrip | 1.191 G | 1.186 G | −0.4% |
| fib_recursive | 676.5 M | 695.3 M | +2.8% ¹ |
Geomean across the suite: −12% instructions; −15 to −22% on the kernel-owned and object/string-glue workloads the round targeted.
¹ fib runs the recursive windowed executor, where the window-loop restructure costs a few instructions per call/return crossing against the masking savings inside the (tiny, ~10-op) activations — the member/window loop split recovered most of an initial +19%; the residual is the price the other recursion shapes' wins ride on (mutual_recursion −12.6%).
Wall-clock under the RECOMMENDED (PGO) build — interleaved best-of-7 A/B, both sides PGO'd on the same corpus, same idle container (plain-release wall on two workloads swung the OTHER way purely from fat-LTO code layout; cachegrind showed the new binary better on every simulated metric while plain-release wall read slower — exactly the layout sensitivity that made PGO the recommended benchmark build, and why callgrind stays the load-bearing gate):
| workload | PGO before | PGO after | Δ |
|---|---|---|---|
| array_sum | 201 ms | 138 ms | −31% |
| property_access | 29 ms | 20 ms | −31% |
| typed_array | 56 ms | 42 ms | −25% |
| arith_loop | 38 ms | 29 ms | −24% |
| mixed_helpers | 307 ms | 240 ms | −22% |
| mutual_recursion | 93 ms | 80 ms | −14% |
| array_push_sum / closures | 38 / 32 ms | 33 / 28 ms | −13% |
| string_build | 18 ms | 16 ms | −11% |
| string_scan / array_hof / sort / json | — | — | 0 to −5% |
| fib_recursive | 58 ms | 62 ms | +7% |
6.12.2 What the profiles say now
- json_roundtrip barely moved (−0.4%): its cost is diffuse allocator
traffic (~20% malloc/free) across parse-side ObjectData/array/string
construction and teardown — per-key and per-table tweaks are rounding
errors against one
Rc<RefCell<ObjectData>>+ map + value allocations per node. The structural answer is an ObjectData pool or arena (needs a recycle point for Rc-dropped objects) — tracked as the next allocation-side lever. - mixed_helpers (the "real agent glue" proxy) is now dominated by the
register tier's own dispatch (~15%),
Valuedrop glue (~8%), and the remaining allocator share (~10%) — the object-literal define path and the string+machinery have left the top ranks. Further movement needs either reg-tier superinstructions or a smallerValue. - sort / array_hof remain comparator-call-ceremony bound around the
prepared-kernel paths; string_scan is
charCodeAtloop glue the register tier already carries.
Gates: full suites for both crates green (chidori-js 129 incl. the new
obj_tpl.rs template structural+behavior tests and kernels structural pins
for LenBrCmp/LoadElemAdd/K-form const-prop; record→replay byte-identity
unchanged); kernels + reg differential corpora and 300-case fuzzes green —
the kernels corpus caught a real bug during development (the fused
LenBrCmp escaped the typed-array length-shadow entry guard scan, fixed
by teaching the guard the fused form); Test262 gate green (0 regressions,
99.11%); clippy clean across the workspace.
6.13 Pinned-string kernels (charCodeAt/length) + allocation diet
(landed 2026-07-11, follow-up round)
Round 2 on the §6.12 baseline: the biggest remaining per-workload gap with a
clear shape was string_scan (~6× behind bun — the tokenizer idiom
for (i = 0; i < s.length; i++) s.charCodeAt(i), §6.5.1 candidate (a),
unlocked by §6.7's O(1) ASCII accessors), plus a first pass at the
allocation costs that dominate json_roundtrip.
- Pinned STRING bases in loop kernels (
Kernel::sslots,KOp::StrLen,KOp::CharCodeAt). A local used ass.charCodeAt(i)/s.lengthinside a kernel region is discovered (fixpoint, like array bases; string discovery WINS over a same-local array-base guess) and PINNED: the entry guard requires a FLAT ASCII string — flattening a rope-built accumulator once, cached in the rope node — and, forcharCodeAt, the canonicalString.prototype.charCodeAtresolution (pinned at install likeArray.prototype.push; a monkeypatched method runs generically, observably). Both ops are then TOTAL — no bail path exists: strings are immutable and the local is pinned, soStrLenis an activation constant, andCharCodeAtcomputes ToIntegerOrInfinity (NaN→0, truncate-toward-zero via saturating casts — no libm) and the out-of-range NaN exactly as the builtin does. Non-ASCII/WTF-8 receivers decline the activation into the generic loop. - Allocation diet:
Internal::Promise/Internal::ModuleNamespacecarried the enum's largest payloads inline (104/72 bytes) — boxing the two cold variants shrinksInternal104→64 and everyObjectData184→144 bytes (~20% less memory traffic per object allocation, json_roundtrip's dominant cost). JSON string values now parse straight from the source slice into theRcbacking (one allocation instead of the String round-trip's two). - Not taken: a
Vec<[f64; KWIN]>window pool for the recursive executor measured WORSE on instructions than §6.12's slice+try_intowindows (fib 695→713 M) and was reverted — fib's residual +2.8% stands as the accepted price of the recursion tier's masked windows. An ObjectData pool/arena (the real json lever) needs a recycle point for Rc-dropped objects — a GC-design change, still tracked.
6.13.1 Measured (callgrind, whole workload; wall best-of-5 plain release)
| workload | §6.12 baseline | after | Δ instructions | wall |
|---|---|---|---|---|
| string_scan | 351.4 M | 145.2 M | −58.7% | 46 → 21 ms |
| json_roundtrip | 1.186 G | 1.176 G | −0.8% | 165 → 144 ms |
| mixed_helpers | 2.324 G | 2.318 G | −0.3% | 315 → 273 ms |
| fib_recursive | 695.3 M | 683.2 M | −1.7% | — |
| string_build / others | — | — | unchanged | — |
The wall movement beyond the instruction deltas on json/mixed is the allocation diet (smaller objects, one less copy per parsed string) — cache effects callgrind cannot see; plain-release layout noise caveats (§6.12) apply, but the direction is consistent across repeated interleaved runs.
Gates: kernels differential corpus grew 9 pinned-string programs
(rope-built receivers, ToIntegerOrInfinity edges incl. NaN/fractional/
negative/OOB indices, non-ASCII and astral receivers declining to generic,
monkeypatched charCodeAt observed, mid-program reassignment re-guarded,
results feeding arithmetic/booleans) plus a structural pin
(char_code_loops_get_kernels); a 7-case differential vs Node 22
byte-identical; full suites green for both crates; Test262 gate green
(0 regressions); clippy clean.
6.14 Cell-writing function kernels (landed 2026-07-11): the PRNG idiom
Motivated by the CPython benchmark column: on the sort workload chidori
trailed every other runtime, and phase isolation showed the sort itself was
already at Bun parity (§6.5's prepared f64 comparator) — the deficit was the
LCG fill loop, 300k calls to
function rnd() { seed = (seed * 1103515245 + 12345) >>> 0; return seed; }
at ~370 ns each. rnd declined the function-kernel tier for exactly one op:
StoreUpvalueChecked (function kernels were pure — "nothing inside a kernel
can write globals or cells"). A function that mutates captured state through
a cell is the PRNG/accumulator/counter idiom, common in exactly the glue
code the fn-kernel tier targets.
- Translation (
kernel.rs): fn mode now acceptsStoreUpvalue/StoreUpvalueCheckedof a NUMBER — the value moves into the upvalue's register (later loads read the same register), and the kernel records the write inKernel::uv_writes((register, upvalue)pairs). Written registers join the always-live set so the completion flush's source survives DCE. The Checked form's TDZ test is statically satisfied: the entry guard already requires the cell to hold a Number (a TDZ'd cell declines to the generic path, which raises the spec ReferenceError). Non-Number stores (boolean/undefined — the flush would change the cell's observable type) and loop-mode upvalue stores keep declining. - Execution (
run_fn_kernel): written cells buffer in registers for the whole frameless call — nothing else can run mid-kernel — and flush back asValue::Numberon BOTH completions (return and the interrupt unwind, mirroring the loop kernels' local write-back), so the cell holds exactly the generic write-through path's state; a conditionally-skipped store flushes the entry snapshot back (same value, unobservable). A belt-and-braces entry check declines if a written cell aliases another captured cell. Both helpers live out of line so write-free kernels' hot paths are untouched. - Tier boundaries: non-empty
uv_writesdeclines the recursion tier (its family resolution and cache treat cells as activation constants — checked at translation for self-recursion and atbuild_rec_familyfor non-recursive helpers pulled into a family), the prepared-callback paths (sort/HOF: they snapshot upvalues once per run), and the loop-kernel pinned-callee guard (one snapshot per activation). Those shapes run generically, exactly as before.
6.14.1 Measured (callgrind, whole workload; wall interleaved 6-run median)
| workload | before | after | Δ instructions | wall |
|---|---|---|---|---|
| sort | 1.992 G | 1.554 G | −22.0% | 232 → 179 ms |
| sort fill phase only | 1.107 G | 657 M | −40.6% | 166 → 85 ms |
300k bare rnd() calls | 976 M | 526 M | −46.1% | 118 → 69 ms |
| fib_recursive | 683.2 M | 694.0 M | +1.6% | 65 → 64 ms |
| mutual_recursion | 940.2 M | 958.8 M | +2.0% | 79 → 81 ms |
| array_hof / others | — | — | ±0.1% | — |
Per-call cost of the PRNG shape drops ~370 → ~210 ns (the remaining cost is
the generic caller loop: LoadGlobal; Call dispatch around the frameless
body). On the cross-runtime suite the sort row goes from slowest-of-four to
ahead of Node and CPython (~1.5× Bun, from 2.0×). The fib/mutual instruction
upticks are the §6.13-class fat-LTO layout shift (their wall is flat, and
neither workload executes any new code on its hot path); the residual
stands as accepted noise, same as §6.13's window-pool revert.
What's next here: the fill loop itself still runs generic bytecode — a
loop kernel can't contain a call to a GLOBAL callee (pinned callees are
local-held only, and cell-writing callees decline the pinned path). Extending
KOp::CallKernel to entry-guarded global callees with per-call flush would
kernelize the entire fill loop (the inlined-LCG equivalent measured 26 ms
total, another ~3× on this phase). Landed as §6.15.
Gates: kernels differential corpus grew 16 cell-writing programs (PRNG,
shared cells across two kernels, conditional/write-only/self-assign stores,
-0/NaN round-trips, boolean/undefined stores declining, call-time TDZ
throwing generically, cell-writing comparator/HOF-callback/self-recursion
declining their tiers, the fill idiom) plus a structural pin
(cell_writing_functions_get_fn_kernels); benchmark suite cross-checks
green (all four runtimes agree on every workload); full suites green;
clippy clean.
6.15 Global pinned callees, cell-writing callee flush, and the
default-sort key precompute (landed 2026-07-11, follow-up round)
The two follow-ups §6.14 queued: kernelize the sort workload's fill loop
END TO END, and fix the latent no-comparator Array.prototype.sort cliff
(868 ms vs Bun's 129 ms on a 6×50k numeric sort — per-comparison ToString).
- Pinned GLOBAL callees in loop kernels ([
KCalleeSrc]).LoadGlobalof a non-Mathname in a loop region now speculates a pinned callee ([VE::GlobalFn]) — its only legal consumer is aCallunder anundefinedthis, fusing to the existingKOp::CallKernel, so a mis-speculated non-callee global just fails translation (exactly today's decline). The activation entry guard resolves the name the wayLoadGlobal's fast path does — an own DATA property of the global object holding a kernel-carrying closure — and declines accessor/proto-inherited/missing bindings to the generic loop (which then runs the getter per iteration / throws the ReferenceError exactly per spec). The binding is an activation constant: nothing on the kernel allowlist can rebind a global, and aKPropstore aimed at the same global-object property can't pass its holds-a-Number entry check while this guard sees a closure. - Cell-writing callees in the pinned path. §6.14's
uv_writeskernels are now admitted by theCallKernelguard: the callee window still snapshots upvalues once per activation, the written register IS the cell's live value across calls (nothing else can touch it mid-loop), and the window flushes written cells back after EVERY call — so a mid-loop bail (a store target going string-tainted) resumes the generic loop against current cell state, and the interrupt unwind keeps the generic-prefix contract. Cross-window aliasing (a written cell captured by another callee window or snapshot by the caller kernel's own upvalue registers) declines the activation — the differential corpus pins the caller-reads-the-cell shape staying generic. - Default-sort ToString precompute (
sort_default_primitive, independent of the kernel tiers). The spec's default SortCompare stringifies both operands per comparison — ~2·n·log n allocating conversions. ToString of a Number/String/Bool/Null/BigInt is pure, so an all-primitive snapshot now precomputes the n keys once and sorts an index permutation with the IDENTICAL stable take-left merge — same output element-for-element, no observable difference (objects keep the exact per-comparison path: their toString/valueOf call order and count are user-visible; a Symbol element still throws from the comparison). All-ASCII keys (number-to-string output always is) compare as bytes instead of UTF-16 code units.
6.15.1 Measured (callgrind, whole workload; wall interleaved 7-run median)
| workload | §6.14 baseline | after | Δ instructions | wall |
|---|---|---|---|---|
| sort (benchmark workload) | 1.554 G | 1.144 G | −26.3% | 175 → 125 ms |
| sort fill phase only | 669.1 M | 259.8 M | −61.2% | 85 → 33 ms |
| default-sort probe (6×50k, no comparator) | 9.900 G | 1.618 G | −83.7% | 838 → 184 ms |
| fib_recursive | 694.0 M | 694.0 M | ±0.0% | 64 → 66 ms |
Cumulative on the sort benchmark row since §6.13: 1.992 G → 1.144 G instructions (−42.6%), 232 → 125 ms — on the cross-runtime suite chidori now posts the FASTEST execution-only sort of all four runtimes (Node ~1.6×, Bun ~1.5×, CPython ~1.0–1.3× behind on the same box). The no-comparator cliff drops from 6.7× behind Bun to ~1.4×. fib is bit-identical this round (the §6.14 layout shift did not compound).
Gates: kernels differential corpus grew 12 global-callee programs
(args/two-callees/two-sites-min-argc, under-supplied args declining,
rebinding between activations, rebound-to-non-function throwing, accessor
global counting its per-iteration getter calls, cell-writing callee with
post-loop reads, mid-loop bail with a live cell, caller-reads-cell alias
decline) plus a structural pin (global_callee_loops_get_kernels); the
default sort gained a smoke pin (default_sort_precomputed_keys:
string-order numerics, mixed primitives, -0/NaN, stability, object and
Symbol elements, UTF-16 astral order, toSorted) all verified against Node
per case; benchmark suite cross-checks green on all four runtimes; full
suites green; clippy and rustfmt clean.
6.16 Glue-code round: typeof-dispatch fusion, compile-scoped string
interning, String+String add, for-in buffer pool (landed 2026-07-12)
mixed_helpers ran ~2.3× slower than CPython (~255 vs ~112 ms execution) —
by design the workload where every function declines the kernel tiers and
the register interpreter carries everything. Phase isolation split its
~255 ms into normalize/for-in ~120 ms, render/string-build ~66 ms, object
allocation ~41 ms, buckets/classify ~38 ms; the profile showed no cliff,
just the aggregate per-iteration tax (~38.6 k instructions each): dispatch
~30%, Value drops + malloc ~13%, string equality ~4.5%, map hashing ~5%.
This round took every bounded lever; the rest is the tracked
arena/GC-design work.
ROp::TypeofBr— the dispatch idiomtypeof v === "number"fuses at register-translation time into a direct type-tag test: no typeof string materialized, noValueclone/drop, no string compare, no const load. Build-time conditions: an equality compare (===/!==/==/!=) K-folded against a string literal that IS one of the eight typeof names, whose operand is the Temp defined by the immediately precedingUnary{Typeof}(code.last()— any interleaving materialization would have emitted a later op). The fused-away Unary becomes aChargecarrying its already-assigned units, so budgeted runs drain at exactly the stack tier's points — the first cut deferred those units across the branch edge and the reg budget-sweep differential caught it precisely (per-path charge divergence). Literals outside the eight names (typeof x === "Number") keep the generic compare.- Compile-scoped string interning (
Compiler::str_intern): oneJsStringallocation per distinct string across a whole compilation — const pools AND object-template keys — so a for-in key cloned out of an instantiated template pointer-matchesk === "id"literals and property-name consts in any function of the program. The eight typeof names route to the process-widenames.rstable instead, giving unfused/stack-tiertypeofcompares the pointer fast path too. Compile-scoped, so nothing is retained across programs. op_addString+String fast path: both operands already primitive — ToPrimitive and ToString are identities — so concatenate directly under the same length cap; skipped are two identity-clone ToPrimitive calls and two ToString round-trips per join (label + ":" + vpays it twice).- Pooled for-in key buffers (
Vm::forin_pool): a glue loop for-inning a fresh object per iteration reused one buffer allocation instead of a malloc/free per loop entry; parked cleared atForInPop(unwound frames just miss the pool).
Evaluated and REJECTED: a for-in slot-carry for rec[k] re-hashing —
the measured cost is ~1.7% of the row and soundness needs a generation
counter on every ObjectData, undoing §6.13's size diet; the arena/GC
recycle point remains the real allocation lever (drops + malloc still
~12% after this round).
6.16.1 Measured (callgrind, whole workload; wall interleaved medians)
| workload | before | after | Δ instructions | wall |
|---|---|---|---|---|
| mixed_helpers | 2.319 G | 1.996 G | −13.9% | 260 → 226 ms |
| normalize/for-in phase probe | 1.504 G | ~1.26 G | −16% | 166 → 142 ms |
| fib_recursive / sort / others | — | — | ±0.001% | unchanged |
The row stays ~2× CPython (~227 vs ~116 ms execution on the same box) — CPython's dict iteration, type dispatch, and string concat are its own C-tuned native gait, and parity for chidori on this shape needs the ObjectData arena or a baseline-compile step, both tracked.
Gates: reg differential corpus grew 7 typeof-dispatch programs (every
tag × every value kind, negated/loose/literal-on-the-left forms, a
non-typeof-name literal, non-branch consumers, the LoadGlobalTypeof path)
plus a structural pin (typeof_branches_fuse, positive and negative);
the reg budget sweep runs over the new corpus entries (and caught the
first cost-model cut); full suites green; benchmark cross-checks green on
all four runtimes; clippy and rustfmt clean.
7. References
docs/interpreter-optimization.md— Phases 0–2 (measurement, hot-loop cleanup, fusion), the noise-floor protocol, and the agent-replay "<1% of live wall-clock" result.docs/jit.md— the retired closure-threading experiment.docs/resume-performance.md— the resume-cost caches (transpile/proto/regexp) that motivated "measure, then cache".docs/replay.md— the determinism contract every item here is gated against.crates/chidori-js/benchmarks/— the cross-runtime harness used for §1.1.