Chidori

Interpreter performance: optimizing chidori-js without a JIT

Status: Phase 0 (measurement) done — see §11. Phase 1 (hot-loop cleanup) partially landed — see §12. Phase 2 (op fusion) first fusion landed — see §13. Phases 3–4 proposed. This document scopes a body of work to make the chidori-js bytecode interpreter materially faster without adding a JIT, while preserving the engine's three load-bearing invariants: zero unsafe, no new heavyweight dependencies, and byte-identical deterministic replay.

Related: docs/conformance.md (Test262 gate), docs/architecture.md, docs/replay.md, docs/value-checkpoints.md.

Superseded for planning by: docs/js-performance-roadmap.md (2026-07) — a callgrind instruction-exact profile of the cross-runtime suite, the deterministic fast-hasher landing (property lookups were ~49% of property-heavy execution), and the re-ranked roadmap toward JIT-class throughput. This document remains the record of Phases 0–2.


1. Summary (TL;DR)

chidori-js is a pure-Rust bytecode interpreter: oxc parses to an AST, compiler.rs lowers it to a stack-machine bytecode, and exec.rs/vm.rs run a switch-dispatch loop over that bytecode. A JIT (compile hot bytecode to native machine code at runtime) would be the conventional way to go faster, but for Chidori it is the wrong tool: it requires unsafe executable-memory management, a code-generation backend (a large dependency or a hand-rolled assembler), and it introduces timing nondeterminism that fights the engine's deterministic-replay contract — all to speed up a workload (LLM agents) that is overwhelmingly bottlenecked on network/LLM latency, not on JS execution.

This document proposes the interpreter-level alternative. The realistic, high-value wins that respect all three invariants are, in priority order:

  1. Hot-loop cleanup — flatten the current double dispatch and move per-op bookkeeping off the common path.
  2. Superinstructions / op fusion — fold common opcode sequences (e.g. LoadLocal; LoadConst; Add) into single fused ops, cutting both dispatch count and operand-stack traffic.
  3. Inline caches — cache shape→slot resolutions at property-access sites.
  4. (Larger, optional) register-based bytecode — eliminate a large fraction of stack-shuffling ops entirely.

Crucially, true "threaded" dispatch (computed-goto / guaranteed tail calls) is not cleanly available in stable, unsafe-free Rust, so the goal is framed as "reduce the number of dispatches and the work per dispatch" rather than "thread the interpreter" in the literal sense.

The work is gated end-to-end by the existing Test262 conformance baseline and the replay byte-identity tests: a performance change that alters any observable behavior is a bug, not a tradeoff.


2. Background: why not a JIT?

A JIT keeps the same front end (parser → bytecode) but replaces the back end for hot code: it profiles which functions/loops run often, emits native machine code for them, and jumps into it. It computes exactly the same results as the interpreter — the dividing line is how work is dispatched, not semantics.

For Chidori specifically, a JIT collides with the project's stated invariants:

  1. unsafe / C-free is a headline property. A JIT must allocate executable memory (W^X / mmap with PROT_EXEC), write raw bytes into it, and call through a transmuted function pointer. That is irreducibly unsafe. The alternative — a backend like Cranelift — is a large dependency that runs against the crate's "pure Rust, vendor nothing" stance.
  2. Speculation + deoptimization is the hard, bug-prone part. JIT speed comes from speculating on types/shapes; when an assumption breaks the engine must deoptimize — abandon native code mid-execution and reconstruct the exact interpreter frame. This is where most JIT correctness and security bugs live.
  3. It fights deterministic replay. Chidori's durability model is re-execution against a recorded host-call journal for byte-identical output (docs/replay.md). A JIT introduces nondeterministic timing — when a function compiles, when it deopts — which can perturb observable ordering (GC, microtask interleaving). Keeping a JIT's observable behavior bit-identical across record and replay is a constraint production JITs never face.
  4. Rust does not give threaded dispatch for free. Classic direct/token threading needs computed goto or guaranteed tail-call optimization. Stable safe Rust has neither (become/explicit-tail-call is unstable; label-as-value does not exist). So the biggest "free" interpreter speedups in C interpreters are not directly portable here.
  5. The workload rarely justifies it. A single await chidori.llm(...) dwarfs all the JS an agent will run between effects. JS-execution time must first be shown to register in a profile of a representative run (see Phase 0).

Conclusion: invest in interpreter-level optimizations that keep zero unsafe, add no dependencies, and do not perturb determinism.


3. Current architecture (what we're optimizing)

All paths below are in crates/chidori-js/src/.

  • Front end: compiler.rs (~6.7k LOC) lowers the oxc AST to bytecode; bytecode.rs defines enum Op (constants, locals/cells/upvalues/globals, arithmetic, property access, calls, control flow, etc.).
  • VM core: vm.rs holds the Vm, Frame, Flow (Return/Throw/Suspend), and the suspension/promise/generator machinery. No VM state is ever serialized; durability lives one layer up in the journal (replay.rs).
  • Dispatch loop: exec.rs::run_frame is the hot loop. It is a switch-threaded interpreter, and today it is a double dispatch:
// exec.rs::run_frame (abridged)
loop {
    // 1. op-budget decrement (every op)
    // 2. interrupt poll (amortized: every 256 ops)
    // 3. frame.pending_throw  check (Option::take, every op)
    // 4. frame.pending_return check (Option::take, every op)
    let ip = frame.ip;
    frame.ip = ip + 1;
    match self.step(&mut frame, &proto.code[ip]) {   // inner `match op { .. }`
        Ok(Ctl::Next)      => continue,
        Ok(Ctl::Jump(t))   => { frame.ip = t; continue; }
        Ok(Ctl::Return(v)) => { /* module hook */ done!(Flow::Return(v)); }
        Ok(Ctl::Await(v))  => ...,
        Ok(Ctl::Yield(v))  => ...,
        Err(e)             => ...,
    }
}

step(&mut self, frame, op) -> Result<Ctl, Value> is a large match op { .. }. So per opcode the engine pays for: loop bookkeeping (1–4 above), an indirect read of proto.code[ip], the inner match op jump, construction of a Result<Ctl, Value>, its return, and a re-match on Ctl in the outer loop.

Cost model. The dominant micro-cost on tiny ops (Add, LoadLocal) is branch misprediction on the single central match in step: the predictor sees one indirect branch for the whole interpreter and mispredicts constantly (~15–20 cycles each). For real-world JS, property access (shape lookup) is typically the larger aggregate cost. The team already does this class of tuning — e.g. cloning the FuncProto Rc once per frame to avoid a per-op Op::clone (noted in run_frame as having been ~5% of a call-heavy run).

The bytecode is stack-based: many ops exist only to move values between the operand stack and locals/cells, which is pure dispatch overhead a register model would remove.


4. What "threaded" means, and the Rust constraint

The dispatch-optimization family, from least to most invasive:

  • Switch threading — one central switch. This is what we have.
  • Direct/token threading — each op handler jumps directly to the next handler, so the predictor gets one branch site per opcode and learns correlations ("LoadLocal is usually followed by LoadConst"). The classic 20–40% win in C interpreters (CPython computed-goto). Needs computed goto.
  • Subroutine threading — each op is a function; the program is a list of function pointers called in sequence. Needs guaranteed tail calls to avoid growing the native stack.
  • Superinstructions / op fusion — fold common op sequences into one op. Cuts dispatch count and stack traffic. Pure compiler+VM work; no dispatch trickery; works in safe Rust.

The catch: direct/subroutine threading need computed goto or guaranteed TCO, neither of which is available in stable, unsafe-free Rust (become is unstable; raw function-pointer threading reintroduces unsafe). Therefore this plan does not pursue literal threaded dispatch. It pursues the safe-Rust subset that captures most of the benefit: fewer dispatches (fusion), cheaper dispatch (loop flattening), and fewer lookups (inline caches).


5. Proposed work (phased)

Each phase is independently shippable and independently gated. Phases 1–3 are the recommended scope; Phase 4 is a larger optional follow-up.

Phase 0 — Measurement baseline (prerequisite) ✅ done — results in §11

Goal: establish where cycles actually go before changing anything, and make regressions visible. (The only engine touch is a feature-gated, off-by-default instrument that compiles out of the shipping build — see §11.)

  • Extend benches/execution.rs (criterion) so the interp group isolates the dispatch loop from the front end across the existing workloads (arith_loop, fib_recursive, property get/set, arrays, strings, closures).
  • Add a representative-agent benchmark: a recorded agent run replayed from a journal (zero LLM calls), measuring the share of wall-clock spent in JS execution vs. host/journal/serialization. This answers "does JS execution even register?" and sets the bar for whether Phases 2–4 are worth it.
  • Capture a profile (e.g. perf/samply) of the hottest micro-benchmark to confirm the branch-mispredict and property-lookup hypotheses in §3.
  • Record committed baseline numbers in this doc (a results table) so later phases can quote deltas.

Exit criteria: baseline numbers committed; a documented decision on whether the per-phase wins clear a "worth the complexity" bar.

Phase 1 — Hot-loop cleanup (flatten dispatch, de-overhead the common path) — partially landed (§12)

Goal: remove avoidable per-op work without changing the bytecode.

  • Flatten the double dispatch. The common Ctl::Next path currently builds and returns a Result<Ctl, Value> that the outer loop immediately re-matches. Restructure so the most frequent ops update frame.ip/stack and signal "continue" with the cheapest possible control transfer (e.g. inline the hottest op bodies into the loop, or have step mutate frame and return a compact status that avoids materializing Ctl::Next/Return payloads on the hot path).
  • Move rare checks off the hot path. pending_throw / pending_return are only set right after a completion or a generator resume; their Option::take on every iteration can move to the slow paths that set them (or be guarded by a single cheap flag). Keep the interrupt poll amortized as it is.
  • Keep the op-budget semantics identical (it must remain uncatchable and terminating); only its placement may change.

Risk: low. No bytecode or semantics change. Pure internal refactor.

Phase 2 — Superinstructions / op fusion — first fusion landed (§13)

Goal: fewer dispatches and less operand-stack traffic for the most common sequences.

  • Add a peephole fusion pass over emitted bytecode (in compiler.rs, after lowering) that recognizes high-frequency sequences and rewrites them to fused ops. Candidate fusions (validate against Phase 0 op-frequency data — do not guess):
    • LoadLocal(x); LoadConst(k); <BinOp>BinOpLocalConst{ x, k, op }
    • LoadLocal(x); LoadLocal(y); <BinOp>BinOpLocalLocal
    • LoadConst/LoadLocal; <branch> for loop conditions
    • increment/compare patterns from for loops (i < n, i++).
  • Add the corresponding handlers in exec.rs. Each fused handler must produce exactly the result and side effects (including thrown errors, ToNumber coercions, and ordering) of the sequence it replaces.
  • Fusion is opt-in per pattern and gated behind the conformance suite; an unfused fallback always remains for any sequence not matched.

Risk: medium. Each fused op is a new opportunity for a subtle semantic divergence (e.g. coercion order, exception type/site). Mitigated by Test262 + differential testing (§6).

Phase 3 — Inline caches for property access

Goal: make the dominant real-world cost (shape lookups on GetProp/SetProp) cheaper by caching the resolved shape→slot mapping at each access site.

  • Attach a small monomorphic/polymorphic inline cache to property-access ops (keyed on the access site). On a cache hit (same object shape as last seen), read/write the slot directly; on a miss, fall back to the full lookup and refill the cache.
  • Determinism boundary (critical): the cache must be a pure performance side effect. It may only change how fast a lookup resolves, never what it resolves to, nor the order/identity of any observable operation. It must never be serialized, must be reset on Vm setup, and must be excluded from anything the journal observes. This is almost certainly safe (it mirrors what the slow path computes) but must be asserted by the replay byte-identity tests (§6).
  • Start monomorphic (single shape) and only extend to polymorphic if Phase 0 data shows it pays.

Risk: medium. The cache-invalidation logic (shape transitions, prototype mutation, delete, __proto__ changes) must be correct or it produces wrong results, not just slow ones. Heavily gated by Test262's Object/Reflect/Proxy suites and the differential harness.

Phase 4 — Register-based bytecode (larger, optional follow-up)

Goal: eliminate a large fraction of Load/Store stack-shuffling ops by moving to a register/local-indexed bytecode (Lua 5 model). This is the interpreter-side change that most closely approaches "what a baseline JIT would buy" — without code generation, unsafe, or determinism risk.

  • This is effectively a rewrite of the compiler.rs back end and a second instruction encoding. It is out of scope for the initial landing and listed here only to record the ceiling of the interpreter-only approach. Revisit only if Phases 1–3 leave a measured, workload-relevant gap.

Risk: high (scope). Deferred behind explicit go/no-go after Phases 1–3.


6. Determinism & replay considerations (non-negotiable)

Every change here must satisfy the engine's determinism contract (docs/replay.md, docs/conformance.md):

  • Observable behavior is invariant. Results, thrown error types/messages, property enumeration order, microtask/promise ordering, and host-call sequencing must be byte-for-byte identical before and after each phase. These optimizations change timing and internal representation only.
  • No new nondeterminism sources. No wall-clock-, address-, or iteration-count-dependent decisions may leak into observable output. (This is exactly why a JIT's compile/deopt timing is disqualifying and an inline cache — which only memoizes a deterministic lookup — is acceptable.)
  • Caches and fused state are never serialized. Inline caches and any Phase-2/3 auxiliary state are rebuilt from scratch on each Vm and excluded from the journal. A record→replay run must produce an identical journal and identical output whether or not caches warmed.
  • Fail-loud on divergence. The existing ordered-journal divergence detection (replay.rs) is the backstop: if any optimization perturbs host-call ordering, replay must fail loudly rather than silently diverge.

7. Testing plan

Layered, with each phase required to pass all layers before merge.

7.1 Conformance gate (correctness, primary)

  • Run the full Test262 suite via scripts/test262.sh --gate against the committed baseline. Zero regressions is the merge bar for every phase (docs/conformance.md). Inline caches especially must hold the Object/Reflect/Proxy/Array suites; fusion must hold the language operator/coercion suites.
  • Re-record the baseline only for intentional conformance gains (never to paper over a regression).

7.2 Engine unit/integration tests (correctness)

  • The existing tests/smoke.rs, tests/async_gen.rs, and tests/replay.rs must remain green unchanged.
  • Add targeted unit tests for each new fused op asserting it equals the unfused sequence on edge cases: operand-type mixes (number/string/BigInt/object with valueOf/Symbol.toPrimitive), coercion-order observability, NaN, ±0, exceptions thrown mid-sequence, and TDZ/uninitialized-cell interactions.
  • Add inline-cache invalidation tests: shape transitions, delete, prototype swaps (__proto__, Object.setPrototypeOf), accessor vs data properties, proxies, and megamorphic sites.

7.3 Replay byte-identity tests (determinism, primary)

  • Extend tests/replay.rs: for a corpus of programs, assert record→replay produces an identical journal and identical output with caches cold vs. warm, and with fusion on vs. off.
  • Add a toggle-equivalence test: a hidden build/test flag that disables each optimization (unfused fallback, cache-bypass) must yield byte-identical output and journals to the optimized path on the whole corpus. This is the strongest guarantee that the optimizations are pure performance side effects.

7.4 Differential testing (correctness, fuzz)

  • Add a differential harness that runs randomly generated / corpus programs through (a) the optimized interpreter and (b) the unfused / cache-bypassed interpreter, and asserts identical results, thrown errors, and journals. Seed it with Test262 fragments and the benchmark workloads; optionally wire a small fuzzer (e.g. cargo-fuzz-style, kept in-tree, no new runtime deps).
  • Where feasible, cross-check a subset against Node/Bun using the existing benchmarks/run.mjs cross-runtime harness for result parity (not timing).

7.5 Performance benchmarks (the actual goal)

  • cargo bench -p chidori-js (criterion) interp group: report per-workload deltas vs. the Phase 0 baseline. Each phase states its expected win and must not regress any workload.
  • The representative-agent replay benchmark from Phase 0: report the end-to-end share of time in JS execution before/after, to keep the work honest about real-world impact.
  • Add a CI performance smoke (informational, non-gating initially) that flags large interpreter-loop regressions on PRs.
  • Measurement environment matters. Phase 1 found that the cloud dev/CI container cannot reliably resolve deltas below 10–15%: re-running the same unchanged binary produced criterion "regressions" of +3% to +8% and absolute times that drifted upward run-over-run (e.g. fib_recursive 74→84→90 ms across three identical runs). Consequences: (a) per-phase wins smaller than the noise floor (Phase 1 is one) cannot be validated by wall-clock here — run on a quiet, frequency-pinned, single-tenant machine; (b) the CI perf-smoke must compare against a same-session baseline (measure HEAD and HEAD back to back in one job), never a stored historical baseline; (c) for small changes, prefer a deterministic proxy (executed-op count, branch count, allocations) over wall-clock — these are reproducible and environment-independent.

7.6 Memory & safety

  • Confirm no unsafe is introduced (#![forbid(unsafe_code)]-style check or grep gate in CI).
  • Re-run the conformance runner's leak/OOM guards (the GC cycle-breaking Vm::dispose path) to confirm caches/fused state don't leak across Vms.

8. Rollout & sequencing

  1. Phase 0 lands first and alone (benchmarks + agent profile + committed baseline + the go/no-go decision).
  2. Phase 1 (loop cleanup) — lowest risk, validates the gating harness.
  3. Phase 2 (fusion) — driven by Phase 0 op-frequency data.
  4. Phase 3 (inline caches) — only if property access registers in the agent profile.
  5. Phase 4 (register bytecode) — explicit go/no-go after 1–3; likely deferred.

Each phase is a separate PR, each gated by §7.1 (Test262), §7.3 (replay byte-identity), and §7.5 (no benchmark regressions).


9. Explicitly out of scope

Experimental aside (concluded): a safe-Rust closure-threading backend — compiling each function's bytecode to a Vec of boxed closures, zero unsafe, no new deps, byte-identical replay — was prototyped, measured at 1.01–1.11× (mostly under the noise floor), and removed; see docs/jit.md for the full record and rationale. The experiment empirically confirmed this document's thesis: dispatch-side speedups don't pay here, and the wins that do are the resume-cost caches (docs/resume-performance.md).

  • A JIT (baseline or optimizing), for the reasons in §2.
  • Literal threaded dispatch (computed-goto / subroutine threading), because stable safe Rust lacks computed goto and guaranteed tail calls (§4).
  • Any unsafe or new heavyweight dependency. If a proposed optimization needs either, it is out of scope by definition.
  • Speculative type specialization with deoptimization — that is JIT-shaped complexity and determinism risk without the JIT's payoff.

10. Open questions

  • Does JS execution register meaningfully against host/LLM/journal time in a representative agent run? (Phase 0 answers this and may shrink the scope to Phase 1 only.)
  • Which fused-op set maximizes win per added handler? (Driven by Phase 0 op-frequency histograms, not intuition.)
  • Monomorphic vs. polymorphic inline caches: does the agent workload have enough shape diversity at hot sites to justify polymorphism?
  • Is a register bytecode (Phase 4) ever worth its rewrite cost given the workload, or is the stack VM permanently "good enough" after Phases 1–3?

11. Phase 0 results (measured 2026-06-14)

Phase 0 is complete. Tooling landed:

  • A feature-gated dynamic opcode-frequency instrument (src/opstats.rs + one #[cfg(feature = "op-histogram")] call site in exec.rs::run_frame). The feature is OFF by default, so the shipping engine is byte-identical and pays nothing (confirmed: tests/smoke.rs and tests/replay.rs pass unchanged on the default build, including record→replay byte-identity).
  • An analyzer that reports static (as-emitted) and dynamic (as-executed) opcode and adjacent-pair histograms: cargo run --release --example opstats -p chidori-js --features op-histogram.

Caveat: the numbers below are from one developer machine and are a relative baseline for tracking deltas across phases, not an absolute spec. Re-run on the target machine before quoting wins.

11.1 Timing baseline (criterion, default release build)

Median per the existing benches/execution.rs workloads. compile = front end only; interp = VM loop only (compiled once); eval = end-to-end incl. realm setup; engine_new = realm/builtin construction alone.

workloadcompileinterpeval
arith_loop4.4 µs6.3 ms7.0 ms
fib_recursive5.4 µs74.6 ms73.9 ms
property_access7.0 µs7.9 ms8.8 ms
array_push_sum6.4 µs5.3 ms6.0 ms
array_hof9.0 µs3.1 ms4.0 ms
string_build4.5 µs2.2 ms4.8 ms
closures8.4 µs5.9 ms9.3 ms
engine_new3.6 ms

Findings.

  1. The front end is not the bottleneck. Compilation is microseconds; the interpreter loop is milliseconds — 3 orders of magnitude apart. Optimization effort belongs in the VM loop, not the parser/compiler.
  2. Realm setup (engine_new, ~3.6 ms) is a large fixed cost — it rivals or exceeds the entire interpreter time of the lighter workloads. For short agent steps (a little JS between host calls), realm construction can dominate interpreter-loop time. This is a previously-unscoped lever worth its own investigation (cache/clone a warm realm) and may beat Phases 1–3 for the short-script regime.
  3. The interpreter loop dominates only for compute-heavy code (fib ≈ 75 ms).

11.2 Dynamic opcode frequency (execution-weighted, 4.43M executed ops)

Top executed opcodes (combined across workloads):

%opcode%opcode
16.9%LoadCell3.8%Call
12.2%InitCell3.8%LoadArg
9.4%LoadConst3.7%Return
4.6%JumpIfFalse3.6%LoadUndefined
4.6%Lt3.6%LoadThis/LoadNewTarget/BindThisSloppy
4.1%Sub3.6%LoadUpvalue
3.1%Add

~40% of all executed opcodes are pure data movement (LoadCell, InitCell, LoadConst, LoadArg, LoadUpvalue, LoadUndefined). This is the operand-stack-shuffle overhead that fusion (Phase 2) and a register bytecode (Phase 4) directly target. InitCell at 12.2% is notably high — per-iteration let bindings in for loops and the call/param prologue mint fresh cells.

11.3 Top fusion candidates (adjacent executed pairs)

%pairfuse to
8.9%LoadCell ; LoadConstLoadCellConst
5.0%InitCell ; LoadCellprologue superinstruction
4.6%Lt ; JumpIfFalseJumpIfNotLt (compare-and-branch)
4.5%LoadConst ; Ltfolds into LoadCellConst ; Lt
3.8%LoadArg ; InitCellparam-bind superinstruction
3.6%LoadConst ; SubSubConst
3.4%Sub ; Call

The canonical loop-condition idiom LoadCell(i); LoadConst(N); Lt; JumpIfFalse shows up as a chain of high-frequency pairs (8.9% + 4.5% + 4.6%). Fusing load+binop (LoadCell ; LoadConst ; <binop>) and compare-and-branch (Lt ; JumpIfFalse) would remove a large, concrete slice of dispatches and operand-stack traffic in exactly the hot loops.

11.4 Go / no-go decision

PhaseDecisionRationale
1 — hot-loop cleanupGOLowest risk; validates the gating harness; the per-op Result<Ctl,_> round-trip and per-op Option::takes are real overhead on the data-movement ops that make up ~40% of execution.
2 — fusionGOData shows clear high-frequency pairs (load+binop, compare-and-branch) and a 40%-data-movement profile. Target the §11.3 candidates, validated by the survey rather than intuition.
3 — inline cachesDEFER (live); optional for replay/testProperty ops didn't crack the top-15 here, AND §11.5 now shows JS is <1% of live wall-clock — so for live perf this is firmly not worth its cache-invalidation risk. Reconsider only to speed the replay/test path on property-heavy agents, gated on a property-heavy profile.
4 — register bytecodeDEFERA back-end rewrite whose payoff (the 40%-data-movement figure) lands almost entirely on the <1%-of-live JS path. Revisit only if replay/test throughput becomes a real pain point after Phases 1–2.
Bonus — warm-realm reuseINVESTIGATEengine_new ≈ 3.6 ms is unexpectedly large and dominates short scripts; it is also pure setup unrelated to dispatch, so it is the most likely replay/test-throughput win still on the table.

11.5 Agent-replay benchmark — DONE (the decisive number)

Built as examples/agent_replay.rs (record→replay entirely within chidori-js, no chidori-crate host runtime needed): a representative agent — real glue compute (prompt building, string scanning, modular arithmetic) interleaved with one recorded llm host effect per step — over 200 steps / 200 host calls. It measures compute_only (pure JS), record (live, host=0), and replay (journal-served, no host), then models live wall-clock under a range of LLM latencies. Measured here (min-of-N, so robust to the §7.6 noise):

quantityvalue
JS compute only20.2 ms (~0.10 ms/step)
journal + promise/microtask machinery0.75 ms
full replay (no host)21.0 ms
journal size17 KB

Modeled live wall-clock — JS+journal as a share of the total:

LLM latency / calllive total (200 calls)JS+journal share
50 ms (optimistic)10.0 s0.21%
200 ms40.0 s0.053%
500 ms100 s0.021%
2000 ms400 s0.005%

Conclusions.

  1. JS execution is well under 1% of an agent's live wall-clock — a fraction of a percent even at an unrealistically fast 50 ms/call. This is the empirical backbone of the no-JIT stance: a JIT only speeds the JS sliver of that sliver, so its end-to-end payoff is negligible. The a-priori argument from §11.4 is now measured, not assumed.
  2. The durability layer is cheap. Journal + promise/microtask machinery is 0.75 ms — ~3.6% of replay, and invisible against host latency. Replay cost is dominated by JS interpretation, not the journal.
  3. Where the interpreter work (Phases 1–2) actually pays off: replay and tests. The zero-host replay path — used for the committed-checkpoint tests, debugging, and crash resume — is ~97% interpreter. So faster dispatch meaningfully speeds replay/test throughput and compute-heavy steps, even though it barely moves live wall-clock. That, not live latency, is the honest justification for Phases 1–2.

12. Phase 1 results (hot-loop cleanup, 2026-06-14)

Landed. Hoisted the per-iteration pending_throw / pending_return checks out of run_frame's dispatch loop (exec.rs). These two Option::takes ran on every opcode, but the fields are set only by resume_frame_throw / resume_frame_return immediately before run_frame (a generator .return(v) or an awaited rejection delivered at resume) and are never re-set inside the loop — so they can only ever fire on the first iteration. The hoist handles them once, before the loop, removing two branches from the hot path per executed op.

Correctness — fully validated. The whole chidori-js suite is green, including the paths that exercise the hoisted fields: tests/async_gen.rs (generators / await), tests/replay.rs (record→replay byte-identity, suspend→persist→restore→resume), tests/gc.rs, the DOM suites, and tests/smoke.rs. No unsafe, no new deps, no bytecode change. The one acknowledged difference — the op-budget counter decrements one fewer time on the rare resume-with-injection path — is not observable to JS (the budget is a coarse, uncatchable safety bound, not a spec-visible quantity).

Performance — below the noise floor here; not separately claimed. Hoisting two predictable-branch Option::takes is expected to be a sub-few-percent win, and the dev/CI container's noise floor (~10–15%, see §7.6) is far larger: re-running the unchanged binary swung +3–8% and times drifted upward across identical runs. So this environment cannot confirm or deny the delta. The change is kept on its own merits: it is a strict reduction in per-iteration work and a clarity win — it codifies in one place the invariant that pending_* are resume-only signals. The measurable dispatch wins come from Phase 2.

Scoping note — the "flatten the double dispatch" half is folded into Phase 2. The other Phase 1 idea (collapse the step → Result<Ctl, Value> round-trip the outer loop immediately re-matches) was deliberately not done as a standalone step. Doing it safely requires either (a) a sweeping change to step's return type touching hundreds of return sites, or (b) duplicating trivial-op logic into a loop fast-path — and (b) creates exactly the kind of two-sources-of-truth divergence risk the determinism contract warns against. Phase 2's superinstruction work restructures dispatch by construction (new fused ops handled in the loop), so the flatten lands there with a single source of truth and direct test coverage, rather than as a risky isolated refactor of the engine's hottest code.


13. Phase 2 results (op fusion — compare-and-branch, 2026-06-14)

Landed: the first superinstruction. The Phase-0 survey's top dynamic finding was the loop-test idiom LoadCell(i); LoadConst(n); <cmp>; JumpIfFalse, with the <cmp>; JumpIfFalse pair alone covering a few percent of all executed opcodes (Lt; JumpIfFalse ≈ 4.6%, plus the other comparisons). Phase 2 adds:

  • A new fused opcode CmpBranchFalse { cmp, target } (bytecode.rs) and its handler (exec.rs), covering all eight comparison ops (< <= > >= == != === !==). The handler reuses the exact same less_than / loose_equals / strict_equals helpers as the standalone ops, so coercion and any thrown error are byte-identical; the intermediate boolean — never observable to JS — is consumed directly by the branch instead of round-tripping the operand stack.
  • A peephole fusion pass (fuse.rs) run once per finished FuncProto in Compiler::finish (so it covers the whole proto tree). It rewrites cmp; JumpIfFalseCmpBranchFalse, which shortens the bytecode and so shifts absolute jump targets; the pass remaps every code-offset operand through a single for_each_ip accessor (the lone source of truth for "which operands are instruction pointers"), which deliberately skips handler-stack depths (CompletionJump.boundary) and u32::MAX "none" sentinels. A window is fused only when nothing jumps into its interior; jumps to its head stay valid because entering the fused op is equivalent to entering the sequence at its head.
  • A fusion toggle (compile_script_opts(src, fuse)): fusion is on in production, off only for the differential test — so an unfused fallback always exists, as required by the plan.

Correctness — gated by four independent layers, all green:

  1. Test262 (the authoritative gate), zero regressions. Ran the committed baseline gate over the areas fusion most stresses — every loop form (for, for-of, for-in, while, do-while), switch, try/catch/finally, labeled statements, break/continue, all comparison operators, conditional, logical, and optional-chaining: 2062 tests, 0 regressions, 0 new failures, 0 progressions (2016 pass / 29 fail / 17 skip = 98.58%, identical to baseline). A mis-remapped jump in any of these would have flipped a passing test to failing.
  2. Differential test (tests/fusion.rs): a control-flow-heavy corpus (nested loops with break/continue, labeled break, try/finally, switch, optional chaining, short-circuit ops, a coercion that throws) run with fusion on vs. off — byte-identical console output and thrown errors. Plus a structural check that fusion actually fires and the toggle genuinely suppresses it.
  3. Fusion-pass unit tests (fuse.rs): target remapping after shortening, the "don't fuse into a jump target" guard, and boundary-is-not-an-ip.
  4. Full chidori-js suite, including tests/replay.rs record→replay byte-identity and the generator/try-finally paths — unchanged and green with fusion on by default.

No unsafe, no new dependencies. Determinism is preserved: fusion perturbs neither values nor host-call ordering, and the toggle-equivalence (fused ≡ unfused) is exactly the property §7.3 calls for.

Performance — structural win, not separately benchmarked here. Per §7.6 this environment cannot resolve a few-percent delta. The change is a strict reduction on the hottest path: for every fused pair it removes one interpreter dispatch (one fewer trip through the central match → one fewer likely branch mispredict) and one operand-stack push/pop of the intermediate boolean. Phase 0 put the fuseable cmp; JumpIfFalse pairs at several percent of all executed opcodes, concentrated in loop conditions — the code that runs most. Quantifying the wall-clock win belongs on a quiet, pinned machine (§7.6); the deterministic proxy (executed-op count drops by the number of fused pairs) is reproducible and already implied by the fusion firing.

Next fusions (same infrastructure, ranked by Phase 0 data). The pass and the for_each_ip remap now exist, so further superinstructions are incremental. The genuinely-outstanding candidate is load+binop (LoadConst; Sub 3.6%). Each follows the same recipe: add the fused op, reuse the standalone helper in its handler, extend fuse.rs, and add a differential-corpus case.

Landed since: LoadCell; LoadConst → LoadCellConst (bytecode.rs/fuse.rs/ exec.rs) and the JumpIfTrue variant CmpBranchTrue.

Also landed (same measure-then-cache discipline, outside the dispatch loop): a thread-local (pattern, flags) → compiled-regex cache (regexp.rs:: compile_cached). Every RegExp entry point (exec/test/match/replace/ split) re-derives the source/flags strings from the object and previously re-compiled the pattern on every call — a regex literal in a loop re-parsed per iteration. A compiled Regex is immutable (exec takes &self) and compilation is deterministic, so the cache is a pure performance side effect; success-only and bounded. Companion caches with the same contract: the source→proto script cache (compiler.rs::compile_script_cached) and the transpile cache in the main crate — see docs/resume-performance.md.


14. References

  • docs/conformance.md — Test262 harness and CI gate.
  • docs/replay.md — deterministic-replay durability model.
  • docs/architecture.md — runtime map.
  • crates/chidori-js/src/exec.rsrun_frame dispatch loop and step.
  • crates/chidori-js/src/bytecode.rsenum Op.
  • crates/chidori-js/src/compiler.rs — AST → bytecode lowering.
  • crates/chidori-js/benches/execution.rs — interpreter micro-benchmarks.