Status: working and on by default (§10). Inline loads and stores run for L1D
lines already in cache and fall back to the Rust callouts otherwise; the R5000
cache model got the same path in September 2026. j2 inline_mem [on|off]
switches it at runtime, and lockstep forces it off
(rules/jitv2/lockstep-must-disable-inline-memory.md). §7–§9 record the two
failed attempts, whose real cause was a Cranelift verifier error (§10), not the
emit strategy.
Since 2026-10-01, jitv2 always uses tcache; the old non-tcache inline
path below has been removed. R10000 uses the transparent memory window
without L1 tag/dirty probes. Aligned 8/16/32/64-bit accesses are inlined;
unaligned instructions still use interpreter memory helpers.
Original motivation: before inline memory, every guest access cost a C-ABI call
through core.read{8,16,32,64}_fn plus a jit_mem_exc check. No JIT reaches
QEMU-TCG / MAME-DRC class performance while that holds. nutlb and tcache
were built to make this possible; this is the payoff.
iris-bench run, non-tcache, nutlb on. Each row is a prefix of the next:
| stage | loads (464M) | stores (150M) |
|---|---|---|
| nutlb hit | 97.2% | 96.1% |
| + L1D tag hit | 81.8% | 75.4% |
| + dirty (stores) | — | 75.4% |
| inline-eligible | 81.8% | 75.4% |
Translation is not the constraint — the L1D tag is. nutlb delivers 97%, then the tag check costs another 15–21 points. The residual callouts are genuine cache misses, which must call out anyway to run the fill, so ~82%/75% is close to the ceiling for this structure without inlining fills too.
Two caveats before trusting these numbers for design decisions:
- Bare-metal. Live IRIX has consistently differed from this suite (see
docs/nutlb-design.md§9); re-measure there before tuning. - The store
dirtyrow shows zero additional drop, i.e. every tag-hit store was already dirty. Plausible for a benchmark that rewrites buffers, but if it holds on IRIX too then the clean→dirty callout is free and needn't be designed around.
Both variants share stages 1–2 and differ only in where the data lives.
1. nutlb probe VA -> phys miss -> callout
2. L1D tag match idx + 1 cmp miss -> callout (Rust runs the fill)
stores: line dirty? clean -> callout (Rust does the RMW)
3. tcache only: transparent(phys) clear -> callout (MMIO / unmapped)
4. data access base + swizzle
stores: mark dirty (non-tcache) + jitv2 gen bump
Non-tcache is the shorter path: a resident line is authoritative regardless of whether the region is mapped RAM, so no bitmap test is needed.
| tcache off | tcache on | |
|---|---|---|
| source | dc.data (the line holds the bytes) |
tc_base + phys (ppmem window) |
| store | write dc.data, set tag dirty |
write window, set tag dirty |
| bitmap test | not needed | required |
Both are correct in their own build. Under tcache the L1D holds tags but no data, so the tag check is not protecting read correctness — RAM is the right source either way. It is preserving cache-model fidelity: fills, LRU, and dirty state that the guest can observe through CACHE ops and writebacks. Worth stating explicitly, because it is what determines whether stage 2 could ever be dropped under tcache (it could, at the cost of that fidelity — do not, without deciding that deliberately).
Guest memory is stored word-swapped ("The Edge", HACKING.md). The two data
paths use different swizzles because their base units differ — bytes into
the ppmem window vs u64 chunks in the cache array:
| access | tcache (tc_base + phys, byte-indexed) |
non-tcache (dc.data, u64-indexed) |
|---|---|---|
| 64-bit | load u64, then rotate_left(32) |
data[masked >> 3], no rotate |
| 32-bit | plain u32 at off |
words[(masked >> 2) ^ 1] |
| 16-bit | halves[(off >> 1) ^ 1] |
halves[(masked >> 1) ^ 3] |
| 8-bit | bytes[off ^ 3] |
bytes[masked ^ 7] |
(masked = virt_addr & (DC_SIZE - 1); source: tc_read and dc_read in
mips_cache_v2.rs.)
Codegen needs two tables selected by the tcache cfg. A wrong constant produces byte-scrambled data only on sub-word accesses — it survives whetstone and dies somewhere unrelated later. Lockstep-test every (path × size) combination before trusting any of it.
Currently repr(Rust): measured as 16 B, ptag at 0, dirty at 9 — but none
of that is guaranteed. Compiled code will hardcode those offsets, so the type
needs the same treatment MipsCore already documents for itself: #[repr(C)]
plus offset_of! at codegen time, never a literal.
Compiled code reaches everything through core_ptr at fixed offsets, but the
cache state lives in C: Cache. Mirror what the fast path needs into
MipsCore, installed alongside the existing jitv2 hooks:
pub jit_dc_tags: *mut u8, // L1D tag array base
pub jit_dc_data: *mut u8, // L1D data array base (non-tcache)
pub jit_tc_base: *mut u8, // ppmem window base (tcache)
pub jit_tc_bitmap: *const u64,// mapped-region bitmap (tcache)
pub jit_gen_base: *mut u8, // jitv2 generation windowThese are raw pointers into boxed slices with stable addresses, so caching them
is safe — provided they are refreshed anywhere the backing allocation can
change: snapshot restore, cache resize/reconfigure, ppmem remap. Getting that
wrong is a dangling-pointer bug in compiled code, which will not be pleasant to
debug. A single install_jit_mem_ptrs() called from the same places as
install_jit_hooks is the safest shape.
DC_SIZE, DC_LINE, LINE_SHIFT, NUM_LINES_MASK are const-generic on the
cache type. Codegen must read them from the instantiated C (they differ
R4400 vs R5000), not hardcode R4400's.
- 1-way only (
!IS_R5K). R5000 is 2-way; the data address folds the way into the index (dc_data_addr), so inline needs way selection. R5000 keeps calling out.jit_probealready reports R5K as never-inlinable so the measured numbers stay honest. - Aligned only. Misaligned addresses fall back; the address-error path stays entirely in Rust.
- Stores only on already-dirty lines. The clean→dirty transition is a tag read-modify-write; leave it to Rust. First store to a line calls out, the rest inline. (§1 suggests this may cost nothing at all.)
- No inline fills. A tag miss calls out and lets Rust run the fill.
- jitv2 generation bump on every inline store. The fast path must not skip
the gen bump the Rust path would have done, or self-modifying code silently
stops invalidating compiled regions.
gen_ptris a pure shift (gen_base + (phys >> 9),GEN_RATIO = 512) so this is cheap — but it is not optional, and it is invisible until something breaks weirdly. - Watchpoints / breakpoints.
check_breakpoint::<PhysRead/PhysWrite>runs on the Rust path. Inline code skips it. Either gate the whole fast path off when breakpoints are active (simplest, and consistent withlightningalready trading debuggability for speed), or emit the check. developerundo buffer likewise records writes on the Rust path.- Lockstep is the acceptance test.
jitv2_lockstepcompares every JIT instruction against the interpreter inline; that is exactly the right net for swizzle and dirty-state bugs. Do not declare this working on benchmark throughput alone.
#[repr(C)]onL1DTag; pointer block +install_jit_mem_ptrs.- Loads only, 32-bit only, non-tcache,
!IS_R5K, behind a runtimej2toggle so it can be A/B'd live. Validate under lockstep. - Remaining load sizes, then stores (with gen bump + dirty rules).
- tcache variant (bitmap test + window swizzles).
- Re-measure on live IRIX; only then consider R5000 or inline fills.
Implemented exactly as §2 describes — per-access guard chain, fast block, slow
block, join with a block param — and measured on iris-bench run, one binary,
IRIS_NO_INLINE_MEM selecting the path at emit time:
| MIPS | DMIPS | accuracy | |
|---|---|---|---|
| inline OFF | 312.3 | 297.2 | 100% |
| inline ON | 34.3 | 46.4 | 100% |
100% accurate, ~9x slower, uniformly across kernels (codec/lz 9.69x).
Forcing proceed = false — so the fast block is emitted but never taken,
and every access runs the byte-identical old callout — still measured
34.1 MIPS. The checks, the swizzles, and the data access are therefore all
innocent. The cost is structural: creating the blocks at all.
Page-pool stats were identical either way (13/4096 pages, ~3400 emits), so this is not compile thrash or region rejection. It is the emitted code being slow.
rules/jitv2/jit-v2-design.md:25 names this exact failure as the reason the v1
JIT was abandoned:
Register pressure: 34 live guest values into 15 x86_64 registers with exception paths crossing block boundaries -> spill bugs. v2 answer: memory-resident registers (§5).
The guard adds 4-5 blocks per memory access. A region with 20 loads goes
from a handful of blocks to ~100, and every join is a point where Cranelift
must reconcile live values. v2's whole performance model is that guest
registers live in memory at fixed offsets off core_ptr and the surrounding
code stays in one straight-line block; carving that into a control-flow diamond
per access destroys it.
Ruled out: any design where the fast path is a branch around a callout inside the same Cranelift function. The block structure is the cost, so no amount of tuning the checks recovers it.
Not ruled out — the coverage numbers in §1 (81.8% of loads, 75.4% of stores are inline-eligible) are still real and still worth chasing. Candidate directions, none validated:
- Branchless, no blocks. Compute both a fast address and a fallback, select
with
selectrather thanbrif. Requires the slow path to be safe to always execute (or a safe dummy address), which the callout is not — so this probably means a different fallback shape, not a different branch shape. - Out-of-line stubs. Emit the guard as a call to a tiny hand-written stub rather than inline IR, keeping the main function single-block. Trades the call back in, but a leaf stub is far cheaper than the current wrapper.
- Hoist the guard out of the access. Check once per region/loop for a known page rather than per access — amortizes the block cost over many accesses. Needs the analyzer to prove the page cannot change.
- Bypass Cranelift for this. The measured ~10-14 instruction sequence is trivial machine code; the cost is entirely in expressing it as IR that Cranelift then has to schedule. Emitting it as raw bytes around the Cranelift-generated body is how DRC-style JITs typically do this.
Direction 4 is the most promising and the most work. Direction 3 is the cheapest to try. Direction 1 is likely a dead end for stores.
L1DTagis now#[repr(C)](needed by any of the above).Cache::jit_dc_tags_ptr/jit_dc_data_ptr/jit_dc_geometry,tcache_base_ptr, and theMipsCorepointer block +install_jit_mem_ptrs.JitDcGeometryplumbed throughJitv2->Codegen->EmitCtx.feature = "jitstats"and the §1 coverage numbers.Cache::jit_probe, which is the executable spec of the guard predicate.
All of it is prerequisite for every candidate above, and none of it costs anything when the emit is disabled.
§7's failure was block count, not instruction count. So keep the guard in
compiled machine code, but emit it once per module as its own Cranelift
function and have every access site call it. Access sites go back to a single
straight-line call — exactly the shape they have today — so the caller's
block structure, and therefore its register allocation, is unchanged from the
current (fast) baseline.
per access site: call helper_load32(core_ptr, vaddr) <- one instruction
helper_load32: guard chain (nutlb, tag, [bitmap]) in ITS OWN function
hit -> load + return
miss -> tail-call the existing read32_fn wrapper
- vs attempt 1: the guard's blocks live in one small function compiled once, not multiplied across every access site in the caller. Nothing crosses the caller's register allocation.
- vs today's callout: the helper is a leaf that returns without entering
read_data— no breakpoint check, notranslate_fnindirect call, no cache trait dispatch, no undo bookkeeping. Only the ~82%/75% of accesses that miss the guard pay the full wrapper.
The remaining cost is the call itself. That is what makes this worth measuring rather than assuming: if the win is entirely eaten by call overhead, the answer is direction 4 (raw machine code, bypassing Cranelift), not more tuning here.
The helper's address is stable for the module's lifetime, and a flush takes
everything with it. mega_flush discards every compiled function, so after
a flush there is no surviving caller that could reach a moved helper — the
dangling-pointer window simply does not exist. (This is unlike the pcp case,
where a pointer outlives the pool it indexes; do not import that hazard here.)
So the only requirement is ordering: emit the helpers into each new module
before the first region compiled against it, and rebuild them alongside
Codegen on new_with_shared_arena. Within a module, regions can call the
helper by its resolved address.
One helper per (size x load/store) = 8 functions, since SIZE and the
load/store split are both compile-time in the guard (different swizzle, and
stores need the dirty check). They are small; emitting 8 per module is cheap.
Calling convention. The helper needs core_ptr and vaddr, returns the
value plus a hit/miss indication. Cheapest is to have the miss path tail-call
the existing wrapper and return its result, so the caller cannot tell the
difference and no extra branch is needed at the call site — the jit_mem_exc
check the caller already emits covers both.
- Emit the 8 helpers into the module at construction; verify they appear and
that regions can call them (a counter, as in attempt 1 —
emitted=0was what caught that the geometry never reached the worker). - Loads only, 32-bit,
!IS_R5K, non-tcache. Measure against theIRIS_NO_INLINE_MEMbaseline on the same binary. - Only if it wins, extend to the other sizes, then stores, then tcache.
If step 2 does not clearly beat 312 MIPS, stop and go to direction 4 rather than tuning.
§7 blamed the 9x regression on per-access block count wrecking register allocation, citing the forced-slow-path bisect (fast block emitted, never taken, still 34 MIPS). That bisect was real but misinterpreted: the slowdown was never about blocks at all.
Attempt 2 built the guard as a single once-per-module helper function, so
access sites are one call_indirect with no extra blocks — and it was still
33.9 MIPS. Then, with the helper call fully disabled (emitted=0, every access
back on the original wrapper), still 33.9 MIPS. The regression survived
removing the thing §7 blamed.
build_load32_helper calls Module::finalize_definitions() to resolve the
helper's address. Doing that mid-session breaks the arena's deferred-sealing
model (paged_memory.rs: sealed_up_to, try_seal_ready, and the seal queue
that finalize_batch drives via patch_pending_publish). The observable
symptom is runaway recompilation: the func_id_counter heartbeat prints
"10000 / 20000 functions compiled into this Codegen's arena", which the
untouched baseline never prints at all. Regions stop staying published and are
recompiled endlessly; the ~9x is that churn, not any emitted instruction.
Proof, one binary, three env toggles:
| helper built? | helper called? | MIPS | recompile heartbeats |
|---|---|---|---|
| no | no | 304.2 | none |
| yes | no | 33.9 | 10000, 20000 |
| yes | yes | 33.9 | 10000, 20000 |
Building it is sufficient to cause the regression. Calling it changes nothing.
Whether the inline path is a win has not been measured yet. Both attempts
died before the fast path ever executed — attempt 2's runtime counters read
runtime_calls=0, so compiled code never reached the helper even when emission
was enabled. Every performance claim in §7 about "the emit strategy" should be
disregarded; only the §1 coverage numbers (81.8% loads / 75.4% stores
inline-eligible) survive, and those came from jit_probe, not from codegen.
Resolve the helper's address without finalize_definitions() mid-session.
Options, in order of preference:
- Emit the helper through the same path regions use —
define_function+ thefunc_ranges/ seal-queue reservation thatcompile_region_uncommitteddoes, thenfinalize_batch— so it participates in deferred sealing like everything else instead of forcing a global finalize. - Emit it lazily as part of the first region's batch, so its finalize is that batch's finalize.
- Declare it
Linkage::Importwith a fixed symbol and resolve viaJITBuilder::symbol, sidestepping in-module finalization entirely.
Only after one of those holds 300+ MIPS with runtime_calls > 0 is there a
number worth reporting for the fast path itself.
Three uextend(ptr_ty, x) where x was already i64 (and ptr_ty is i64 on
x86-64). Cranelift's verifier rejects a widening that isn't one:
VerifierError { context: "v14 = uextend.i64 v13",
message: "arg 0 (v13) with type i64 failed to satisfy type set ..." }
Every region containing a 32-bit load therefore failed define_function, got
declined, and fell back to the interpreter. That is the entire 9x. It was the
same three lines in attempt 1 and attempt 2 — the helper surfaced it only
because build_load32_helper printed the error, while region compiles swallow
it as an ordinary decline.
Retract §7 and §9's diagnoses. Block count did not matter. The once-per-module helper did not matter. The emitted code simply never ran.
Lesson for next time: emitted=N is an emit-time counter and proves nothing
about execution. Verify with a runtime counter or a compile-decline count
before drawing any performance conclusion.
| MIPS | DMIPS | accuracy | |
|---|---|---|---|
| baseline | 302.3 / 314.9 / 314.1 | 292.0 / 293.8 / 292.1 | 100% |
| inline loads | 338.3 / 329.4 / 333.6 | 371.1 / 370.8 / 375.7 | 100% |
+7% MIPS, +27% DMIPS, ranges non-overlapping, emitted=10396 declined=0.
This is with stores and the other three sizes still calling out — §1 says loads
alone are 81.8% inline-eligible, so most of the remaining headroom is stores.
The inline emit wins, but it duplicates the guard at every access site. A shared helper is still worth having — for code size, and to make the inline path switchable without recompiling. The design that avoids attempt 2's mistake:
Emit all helpers first, into their own page(s), and force-seal them
immediately. At module construction (and after every mega_flush, before any
region compiles), emit the full set — 4 load sizes x 4 store sizes, plus any
variants — into a page or two at the very start of the arena, then seal that
prefix with the existing forced path (patch_pending_publish(.., force=true) /
try_seal_ready_forced, as finalize_batch already does).
Why this is safe where attempt 2 was not:
- Attempt 2 called
Module::finalize_definitions()mid-session, which breaks the deferred-sealing model and triggered endless region recompiles (thefunc_id_counterheartbeat gives it away: baseline prints none, the broken build prints "10000 / 20000 functions compiled"). - Helpers emitted first occupy the arena's lowest range.
sealed_up_tois a contiguous watermark, so force-sealing that prefix is exactly the operation the arena is built for — and every region compiled afterwards sits above it and seals normally. - Their addresses are then fixed for the module's lifetime, and
mega_flushdiscards helpers and callers together, so no stale reference can survive.
Runtime switchable. With helpers always present, choosing inline-vs-callout
becomes a per-compile decision (emit a call helper or a call read32_fn),
so a j2 monitor command can flip it and force a flush — no rebuild needed for
A/B on live IRIX, which is where the numbers that matter come from.
Open question worth measuring: whether the shared helper is actually faster than the duplicated inline emit, or merely smaller. §10's win came from fully inlined guards; adding a call back in may give some of it up. Keep both paths selectable and measure.