Pricing a trace JIT before building one

tools/toyvm/trace-jit.js

The dispatch shootout asks how to make the interpreter's dispatch cheaper. This asks a different question: what is left once dispatch is gone entirely?

The plan being priced is the obvious one for this VM — run the threaded interpreter normally, and when a trace is hot, recompile the threaded code (not the x86) into a new wasm module with the operands baked in. The codegen for that already exists: emitSwitch in emit.js inlines every HANDLERS[i].body as a br_table arm, so a stitcher emits the same body strings concatenated in execution order. No new backend.

The thing worth knowing before writing it is how much of the win comes from stitching alone and how much needs a real optimizer, because those are very different projects.

The three tiers

tier what it is what it removes
0 the threaded interpreter, exactly as shipped
1 the trace's handler bodies stitched into one wasm function, operands folded to constants dispatch, the operand load, the $ip advance
2 three optimization passes over tier 1 constant propagation, register-file folding, dead flags
3 the trace lowered past its x86 shape the addressing-mode br_table, the rest of the register file, the registers themselves

Tier 1 is a text substitution. Every handler's operand preamble is generated by ops(n) and therefore has one exact shape, so folding it needs no per-handler work. Tier 2 needs the whole trace in view and cannot be done by a stitcher:

Tier 3: micro-ops that do not match x86

A threaded word is one x86-ish operation. That shape suits the decoder and defeats an optimizer, because it keeps three things inside a handler body where nothing can reach them: an addressing-mode br_table, a register-file br_table, and a flag word. Tier 3 lowers to micro-ops that deliberately do not match x86 — an address is an add, a register is a value — so those become ordinary expressions.

The passes compose in one order only, and the reason is the whole design:

  1. fold $ea. After operand propagation its index is a literal, so the call becomes arm K's expression with the displacement substituted. $ea is reached by 612 of the 1581 handlers and its own comment in emit.js calls it the hottest path in the VM.
  2. fold $ea32base + index*scale + disp, registers named. This is the one that matters in this corpus: all 17 of BRW's address computations are 386 form and not one 16-bit arm fires there. It is deliberately not wrapped to 64K, which is the point of the encoding.
  3. fold rget32/rset32/rset8, which are only reachable once (1) and (2) have stopped hiding them. On BRW: 17 addresses folded → 33 further register-file calls folded.
  4. fold the segment lookup. A handler asks for memory by segment index(call $rd16 (i32.const 3) off) — and $rd16 resolves it through $sbase's br_table on the way in, inside the helper. So a constant index buys nothing on its own: the base never becomes an expression anything outside $rd16 can see. emit.js therefore emits a base-taking twin of all six accessors ($rd16b and friends) from the same builder, and this pass swaps to it. The br_table goes, and — the real point — the base becomes an ordinary (global.get $dsb) that step 5 can hoist.
  5. promote guest registers into wasm locals, loaded once before the loop and stored once after — so the cost is paid per entry, not per iteration. That is why a loop is a better subject for this than a straight-line trace, and it is the thing this page's tier-2 section named as the single biggest item left on the table.

$ea's arms are what read $bx/$si/$bp/$di raw, so no register can leave the globals until the address computation stops going through the table that reads it behind the optimizer's back. That is the ordering constraint, not a preference.

Segment bases are promoted alongside the registers, and they are loop invariant rather than merely register-like: only a segment load writes one, $sset is not on the allow-list, so a body that could change a base has already declined the whole promotion.

Two accessor families, one source. $rd16 and $rd16b differ in exactly one line — how the linear address is formed — and are generated by one builder in emit.js rather than having one call the other. The obvious factoring, $rd8 = $rd8b (call $sbase seg), makes every interpreted access pay an extra call to buy something only the JIT uses. Two copies in the wasm, one copy in the source: the rule EA_ARMS is shared under, for the same reason — a drift here would be two disagreements about where a store went, one of them reachable only from compiled code. Checked by diffing run-dos.js output against the previous commit across all ten programs of the core set (mode X, text mode, both extenders, CGA, Sound Blaster): identical on every line but the timing ones.

Propagating an operand is not folding it

The segment fold declined on BRW's whole body — all 17 accesses reported as having a dynamic segment. They did not have one. Tier 2 had substituted the operand and stopped there, leaving

(local.set $t5 (i32.and (i32.shr_u (i32.const 3299129) (i32.const 4)) (i32.const 7)))
...
(call $rd32 (local.get $t5) ...)

— entirely constant, never evaluated, and read back through a local. Every pass in this tier asks "is this argument a literal", and neither half of that is.

So two more passes, both of which buy no instructions directly (V8 folds constant arithmetic itself) and exist only to make constants visible to the passes that ask:

The effect on BRW's 43-op body, all of it downstream:

before after
segment br_tables folded 0 of 17 17 of 17
further register-file calls folded 33 57
register promotion declined ($rset32 with a dynamic index) 6 values in locals, dsb among them

The $rset32 decline was itself the unevaluated constant: with the index folded it is a direct global, and the promoter stops seeing a dynamic register write.

--dump-wat=FILE is what found this. Every decline this tool reports is a fact about one expression, and the count never says which — "17 had a dynamic segment" and the line that made them so are one grep apart once the body is on disk.

The guard on step 4 is an allow-list, and that is the correctness story. A deny-list spelled $out does not match $port_out — the spin census already paid for that once — and here the failure mode is worse than a bad measurement. BRW's hot body is lodsb32 … stosb32, and a string helper that touches SI behind this pass's back does not make the loop slow, it makes it compute something else. So a call is safe only if it is named as touching no general register, and anything unrecognised declines the whole promotion. Inverted, it immediately caught two real cases: $rec_add32 (a false positive — it writes only the flag-record globals, now allowed) and $rset32 with a dynamic index (a true decline).

Faithfulness was established before anything was timed: with promotion declined, tier 3 is byte-identical to tier 2 on registers and on the hash of guest RAM.

Measured, with the caveats attached

daretro.exe, all four arms agreeing, box at load 8-10:

  tier 0  interpreter    22.38 ns   (baseline)
  tier 1  stitched       13.86 ns   1.62x
  tier 2  optimized      11.73 ns   1.91x
  tier 3  micro-ops       9.53 ns   2.35x

tier 2 -> 3 came out 1.16x and 1.23x across two interleaved runs. Absolute ns move between runs at that load; the min-of-7 interleaved ratio is what holds.

This is a lead, not a corpus number. The trace is 3 ops long, and finding even one benchable program took a scan — see the selection trap below.

Bench the core ten before the corpus

A 199-program sweep reports a geomean, and a geomean over the programs that happened to work hides how few that was. Running the core ten instead — the set picked so that no code path goes uncovered — asked a blunter question: how many of them can this tool measure at all?

One. And that one on a single-op trace. Every tier number this page quotes rested on daretro.exe alone. Seven separate causes, none of them in the compiler — every one was in the harness that was accusing it:

before now
benchable 1 9
unfoldable (fused handler) 2 0
mismatch 5 0
padding 3 0
no-samples 1 1

What moved, and what each was:

  1. foldOperands refused every fused handler — the hottest shape in the corpus. See the section above; DTM2 and ACCIDENT were lost to it.
  2. Traces with an exit that cannot fall through. A ret, a call_far, a jmp_m16: tier 0 follows it and goes on executing other blocks while tiers 1–3 have nothing to follow and re-run the body. DTM2's SP came out 20000 pops from the rest. trimExit drops such an exit in every arm at once.
  3. No terminator at all. straightLineProgram only ended the arena when the trace ended in a jmp; otherwise tier 0 ran off the end into unrelated code. The terminator is laid down first now, which also gives a trailing branch somewhere to fall through to — repointing it at the arena base instead made a self-loop that read as an 18.5x speedup, which is how it was found.
  4. Branch edges located by name regex. args === 4 plus a list of Jcc names misses every fused and traced twin (cmp_rm8_jz_t has five operands and a name no entry is a prefix of). TAKEN_AT is emit.js's own bookkeeping for this and cannot drift from the handler table.
  5. The arms ran on a different machine. Only STATE was copied across, so the generated modules sat at every default: 8086 reserved flag bits, a real-mode address mask, empty descriptor tables. That is one bit of the flags word — 0xF246 against 0x7246 — with nothing to say which field moved. MACHINE_STATE is now shared out of emit.js so both modules declare the same list, and is seeded before the guest state, because a segment setter resolves its shadow base through the descriptor tables.

DTM2 agreeing is worth more than the count: it is the first program with a fused branch in its trace to match tier 0, which is the positive check that the two-segment operand walk is right. The dumped body shows all five operands landing exactly where it predicts.

  1. The generated module wrote its own state accessors. All four remaining mismatches were this, and it is the one worth reading. moduleWat built a get_/set_ pair per guest global rather than sharing the interpreter's, and the two had drifted: the interpreter routes a segment write through $sset, which recomputes that segment's shadow base, and the copy was a plain global.set. So $esb stayed 0 in every generated arm, and every segmented access in a compiled trace addressed 0 + off while the interpreter it was being compared against addressed base + off.

    It presented as an addressing bug and is not one. B-STEEL's six-op VGA DAC loop came out differing only in ax — one byte, loaded through es:[bx] — with $ea provably pure, the packed operand provably right, and the memory provably identical. Nothing in the compiler was wrong; the two machines were different machines. stateAccessors() is emitted once in emit.js now and used by both modules, and it exports the six segment bases so a comparison can see derived state at all.

  2. padding was decided on the bytes. guestBytes reads memory as it stands at the end of the profiling run, while the block was compiled from whatever was there when the compiler reached it — so an overlay since swapped out, or a buffer since cleared, reads back as sixteen zeroes underneath real code. What actually marks an unwritten region is that it has no terminator: the decoder runs through it to readTrace's cap. CYCLE's 92.5% block is two ops ending in ret and was being thrown away. Selection also skips padding whether or not --min-ops was given, instead of declining a whole program because its hottest block is padding — RUNDEMO's top block is 26 ops of zeroes and there is real code below it.

Still open: one. DHADREN lands no samples in a known block.

Three tools that made the difference

One more caution the base columns taught: a fingerprint field that only some arms can report makes every comparison fail on formatting rather than on state. Adding esb=… to the row declared B-STEEL's arms to disagree while every register in it was equal.

Measured: the core ten, nine of them

There is a rendered version of this section at docs/toyvm-core10/, generated by node tools/toyvm/report-core10.js from the same jitTiers() the CLI calls — so it cannot drift from the code the way a hand-typed table does. Serve the repo with node tools/dev-server.js and open http://127.0.0.1:8080/docs/toyvm-core10/. Its raw measurements sit beside it in data.json, and --no-run re-renders the page from that file without re-benchmarking. The numbers below are one such run; expect the last digit to move with box load, which is exactly why the report shows two passes.

--bench --min-ops=6 --sample-from=0.5, default 7 interleaved reps, run twice as two independent passes so the spread is visible rather than asserted. Load averaged 5.8–6.3, so the absolute ns are not quotable; the ratios are, and the two passes are what says so.

program trace share 0→1 1→2 2→3 0→3 pass B
BRW 43 ops 8.2% 1.61 1.04 3.11 5.19x 4.94x
B-STEEL 6 ops 15.6% 2.43 1.37 1.06 3.53x 3.38x
CYCLE 7 ops 0.5% 1.95 1.23 1.10 2.64x 2.65x
ACCIDENT 9 ops 1.9% 1.83 1.14 1.01 2.11x 2.13x
DTM2 2 ops 51.3% 1.61 0.98 1.28 2.04x 2.05x
CMA_SHRT 7 ops 99.7% 1.14 0.99 1.77 1.99x 1.92x
RUNDEMO 7 ops 1.9% 1.48 1.01 1.19 1.76x 1.75x
CONTAGIO 15 ops 80.4% 1.12 1.12 1.36 1.69x 1.86x
DEMO5 1 op 100.0% 1.39 0.99 1.01 1.38x 1.38x
2.28x geomean 2.28x

Seven of the nine reproduce inside 2%. CONTAGIO (1.69/1.86) and BRW (5.19/4.94) are the two that move, and both move less than the gap to their neighbours.

Tier 2 is worth nothing. Register folding and dead-flag elimination came out at 0.98–1.42x with a median of 1.04x, and three programs are at or below 1.00. Everything the JIT earns is in tier 1 (removing the dispatch, the operand load and the $ip advance) and tier 3 (folding the address br_table, the wide register file, and promoting registers into wasm locals). That is a result about where to spend effort, and it points at tier 3.

The long trace wins biggest. BRW's 43-op body gets 3.11x from tier 3 alone; the two traces that get nothing from it (ACCIDENT 1.01x, DEMO5 1.01x) are 9 and 1 ops. More body is more to fold across, which is the argument for extending traces rather than optimizing short ones harder.

What this is not: a JIT number

The compile is not in any of it. The timing loop starts after the module is emitted, compiled and instantiated, so every ratio above prices compiled-code throughput. A JIT also has to pay for the compile and earn it back, and the tool now measures that too:

build break-even ops it runs in 15M pays back
CONTAGIO 16.6ms 1.72M ops 12.07M 7.0x
CMA_SHRT 19.5ms 2.21M 14.96M 6.8x
DTM2 16.3ms 1.41M 7.70M 5.5x
DEMO5 18.1ms 5.17M 15.00M 2.9x
B-STEEL 15.6ms 2.26M 2.35M 1.0x
BRW 18.7ms 1.75M 1.23M 0.70x
ACCIDENT 15.9ms 2.60M 0.28M 0.11x
RUNDEMO 16.8ms 3.65M 0.28M 0.08x
CYCLE 14.9ms 2.25M 0.08M 0.04x

BRW is the whole argument in one row, twice. It has the biggest speedup in the set at 5.2x, it is worth 7% to its program, and it does not repay its own compile over an entire 15M-dispatch run. A hit counter that fires a compile on that trace loses time. Four of the nine are in that position and they are exactly the low-share traces — no amount of extra speedup fixes them, because the trace does not execute enough for the speedup to be collected.

Read the absolute build cost as an upper bound rather than a property of the design: this path emits WAT text, runs it through the project's own JS compiler and hands the bytes to the engine, which is not what an in-VM implementation would do. What survives that caveat is the shape — break-even is around a million guest ops, which is the same order as how much a typical core-ten trace runs in a whole profiling window. That is uncomfortably close, and it is the strongest argument in this document for making the compile cheap before making the compiled code faster.

What this is not: a program speedup

Every number above prices one trace. Multiplying it out against that trace's share of samples gives the whole-program bound if the JIT compiled that trace and nothing else:

trace program bound
CMA_SHRT 1.99x 1.99x (99.7% of samples)
DEMO5 1.38x 1.38x
CONTAGIO 1.69x 1.49x
DTM2 2.04x 1.35x
B-STEEL 3.53x 1.13x
BRW 5.19x 1.07x (8.2% of samples)
ACCIDENT / RUNDEMO / CYCLE 2.11 / 1.76 / 2.64x 1.01 / 1.01 / 1.00x
2.28x geomean 1.24x geomean

BRW is the whole point in one row: the biggest trace win in the set is worth 7% to the program, because 92% of its samples are somewhere else. 2.28x on the trace is 1.24x on the program, and the difference is coverage, not code quality — which says the next work is compiling more traces, not compiling one of them better. That is what makes Option A (installing a compiled trace into a free $handlers slot at runtime) the thing to build next rather than another tier-3 pass.

Three of the nine sit on traces holding 0.5–1.9% of samples. They demonstrate that the compiler is correct on those shapes; they say nothing about those programs, and the share column is in the table so that cannot be misread.

The corpus sweep: what it settles and what it does not

sweep-dos.js --dir=/tmp/demos --variants=tailcall, all 199 programs.

It settles correctness. 15 programs were benchable and all 15 agree — tier 3 lands on the same registers and the same hash of guest RAM as tiers 0, 1 and 2 on every one. The 184 that declined did so for reasons that predate this tier: 114 padding, 27 mismatch, 22 no-samples, 17 unfoldable, 4 timeouts.

It settles the fold rates, which are counts and so are load-independent:

of the 15
traces with a memory access 8
segment lookups folded 8 of 8 — none stayed dynamic
a segment base promoted into a local 5 (dsb ×3, csb ×2, fsb ×1)
register promotion succeeded 10
declined: no register in the body 3
declined: $pop32 / $sset not on the allow-list 3

That every segment that appears folds is the result worth having: the pass has no decline population left in this corpus, and $sset — the one call that could invalidate a promoted base — is the thing the allow-list already refuses.

It settles nothing about speed. The run was --reps=1 --iters=4000 at load 12+, chosen to get the counts across 199 programs in an hour; its t23 column ranges from 0.14x to 1.83x, which is the box, not the compiler. Those numbers are deliberately not reproduced here. A tier-3 speed claim still needs a quiet machine, and the daretro.exe figures above remain the only timing in this section.

--min-ops and the padding trap

The hottest block is often a two-op poll or a lone ret, which prices the harness rather than the code, so --min-ops=N takes the hottest block with at least N ops. It must skip padding while doing so, and that is not a refinement: an unwritten region decodes into a very long straight run, so an op floor selects for padding. Raising it to 8 without the check turned 10 of 14 programs in a scan into padding declines that the default selection would never have hit.

Correctness is not optional

All three arms are seeded from one captured guest state and must land on identical registers and an identical hash of the first megabyte of guest RAM before any ratio is printed. An arm that computes something else is not faster.

Every branch is made to fall through in all three arms, so they execute one identical op sequence. This does not price side exits — a real trace JIT also pays to leave a trace, and this says nothing about that cost.

The numbers

On DRAGON.EXE's LZ bit-reader (71 ops, 86% of samples, no host calls):

ns per guest op (min of 7 interleaved reps, 20000 iterations each):
  tier 0  interpreter    25.31 ns   (baseline)
  tier 1  stitched       10.66 ns   2.37x
  tier 2  optimized       7.02 ns   3.60x

And across the corpus (sweep-dos.js, 23 distinct traces):

  tier 0 -> 1   1.83x     stitching alone
  tier 1 -> 2   1.76x     the optimizer on top
  tier 0 -> 2   3.22x     total

Stitching is most of the distance but not the project. A 1.76x still sitting on top of it after three passes is too large to leave in a stitcher-only design.

And 1.76x is a floor, not a ceiling. Tier 2 has no register allocation — guest registers still live in globals rather than in wasm locals across the trace. That is the single biggest thing left on the table, and it is what tier 3 above goes after.

One result that goes the other way: only 3 of DRAGON's 20 flag computations were dead. In a branch-dense bit-reader the flags are genuinely live, so eager flags cost less on this shape than the theory predicts. FLY.EXE is the one program where tier 2 came out slower than tier 1.

How it declines

Never a throw and never a silent zero. Each way a program can fail to be benchable is a named reason, and a declined program is a fact about the corpus:

reason meaning
no-samples no sample landed in a known block
padding the hot trace was compiled out of unwritten memory
unfoldable a handler body whose operand preamble drifted from ops()'s shape
trap an arm faulted while running
mismatch the arms disagreed on registers or memory

padding is the one that matters most. From the arena side, a trace compiled out of zeroed memory is indistinguishable from a hot loop — hundreds of ops, sampled constantly. 00 00 decodes as add [bx+si],al, so it looks like real work. The guest bytes are the only thing that tells them apart, and the largest speedup ever measured here (11.5x, cchop.exe) was decoded emptiness. 26 of 94 programs in the corpus decline this way.

Usage

node tools/toyvm/trace-jit.js DEMO.EXE                  # find + show the hot trace
node tools/toyvm/trace-jit.js DEMO.EXE --bench          # tier 0 vs 1 vs 2
node tools/toyvm/trace-jit.js DEMO.EXE --bench --json   # one JSON object
node tools/toyvm/trace-jit.js DEMO.EXE --sample-from=0.5   # profile only the tail
node tools/toyvm/trace-jit.js DEMO.EXE --min-ops=6         # skip 1-op blocks and padding
node tools/toyvm/trace-jit.js DEMO.EXE --bench --dump-wat=/tmp/t3.wat   # why it declined
node tools/toyvm/trace-jit.js DEMO.EXE --bench --ops-prefix=2           # bisect a mismatch

--sample-from exists because this corpus ships compressed and a profile from dispatch zero finds the depacker rather than the demo — see the shootout's §5.3.

Coverage: what the tiers can reach, and why --min-ops was lying to us

Every ratio above is a ratio on ONE block, and a ratio on one block is worth share of a program. So before asking how much faster a compiled block gets, ask how much of the program the compilable blocks add up to. --coverage=N answers that off the ranking that already exists — nothing is built and nothing is timed, so it costs one profiling pass:

node tools/toyvm/trace-jit.js DEMO.EXE --dispatches=12m --sample-from=0.5 \
  --min-ops=1 --coverage=100

Pass --min-ops=1 when you want the coverage number. This is the trap that produced a badly wrong reading of this whole area. --min-ops=6 exists so the bench does not time a one-op parking loop, and it is right for that job — but run the coverage curve through it and it discards every block under six ops, which on DRAGON means 5 blocks of 16 and a 4.9% ceiling. At --min-ops=1 the same program's ceiling is 74.9%, and its hottest block alone is 64.6%. The filter describes what the harness will consent to time, not what a compiler could reach, and reading one as the other made the JIT look structurally capped when it is not.

Coverage saturates fast. Across the twenty programs of bench-set-20.txt, the top block is typically a third to two thirds of all samples and the top five reach 67–70%; per-program ceilings run 31–100%, mean about 85%.

Where that coverage lives, by block size

This is the number that should drive tier work, because the tiers do not all want the same thing. Tier 1 stitching pays per op — one dispatch, one operand load and one $ip advance removed each — so a 2-op block is a fine customer. Tier 2/3 micro-ops need something to fold: an address computation, a register stream, a segment base used more than once. Mean share of samples, 18 programs that yielded samples, 12M dispatches, --sample-from=0.5:

block size share of samples reachable by
1 op 19% stitching buys nothing; this is a dispatch + a transfer
2–3 ops 37% stitching
4–7 ops 23% stitching, and micro-ops have a little to work with
8–15 ops 4% micro-ops
16+ ops 2% micro-ops

56% of program time is in blocks of three ops or fewer, and only 6% is in blocks of eight or more — which is precisely the population --min-ops=6 selects, and precisely where tier 2→3's 1.52x and the 2.83x ladder were measured. The tier ratios are not wrong; they were measured on the thin end of the distribution.

Size is the wrong axis for the micro-op question

The table above invites the conclusion "micro-ops are population-capped at 6%", and that conclusion does not follow. The micro-op passes do not pay in proportion to block size. They pay when something computed per op and per iteration — the segment base, the effective address, a register round-tripping through the register-file globals — becomes something computed once and carried. A 3-op block run a million times as a loop body is a better micro-op target than a 12-op block run once, and a size histogram cannot tell those apart.

The design consequence is the sharp one: the compilation unit has to contain the back edge. A block-granular install must spill promoted registers to the globals at every block exit, so for a 3-op loop body promoteRegs loads into locals and immediately spills, once per iteration, and buys nothing. The hoisting only pays if the compiled function contains the loop, which is what the main emulator's LUT_RUN/RECT_RUN/RLE_RUN folds do.

So --coverage also classifies each block by whether it contains a back edge:

class meaning
self a branch targets this block's own head — the loop is this one block
back a branch targets an earlier arena address — a loop closing inside this block
straight neither

Read the classifier's limits before reading its numbers, because getting this wrong twice is what produced the table:

Mean over the 18 programs that yielded samples: 31% of program time is in a block that contains its own back edge, and the distribution is strongly bimodal — six programs at 0%, four at ~100% (DEMO5, COMPOVRS, CONTACT, daretro), the rest scattered between.

That bimodality is the finding, and it is more useful than the mean:

And one caution that applies to every number in this section: hot means repeatedly executed, so a block with a large sample share is in a loop whether or not this classifier can see the edge. Coverage is close to an upper bound on loop residency already; the classifier is answering the narrower question of whether the loop fits in one compilation unit.

Three consequences, in order of how much they should change what gets built:

  1. Tier 1 stitching has the population unconditionally. It pays per op, needs nothing hoisted across a back edge, and reaches the whole 66–100% coverage. It is the part of the ladder that works at block granularity.
  2. The 1-op blocks are 19% and are a different problem entirely. A one-op block is a dispatch plus a block transfer, so there is no body to compile — it is exactly the shape the spin-loop collapse already targets, and DEMO5 is 100% one-op blocks. Look there, not at the compiler.
  3. Per-block install reaches most of a program; per-loop install is what makes the micro-ops worth anything. The top five blocks are already ~70%, so stitching needs no traces, guards or deopt. But only ~31% of time sits in a block that holds its own back edge, so for the rest, hoisting has to happen in a unit that spans blocks. Those are two different builds and should be costed as two, not merged into "the JIT".

Loop regions: what compiling a whole loop would actually cost

The section above ends at "per-loop install is what makes the micro-ops worth anything", which is a claim about a build nobody has costed. This costs it, and still without writing a compiler: the region walk is a static pass over arena words the profiler already has.

Entry is not the obstacle it is usually assumed to be. A region is installed at its head only, and every block it covers stays in the arena exactly as it was. Code that jumps into the middle of the loop dispatches ordinary blocks and never reaches the compiled version — it does not need to be stopped, because it cannot get in. So there is no entry guard, no on-stack replacement, and no requirement that the region have one predecessor.

Exits are the obstacle, and they scale with the number of edges leaving the region rather than with its size. Each one has to spill registers promoted into wasm locals back to the globals, materialise the flag records the dead-flag pass elided, and set $ip/$gip to wherever control is going. A dec cx / jnz loop has one such edge; a loop containing five calls has at least five more.

So the measurement is the exit count of the hot loops, not their number. --coverage=N now prints it: for each block holding a back edge it takes the natural loop (blocks reachable from the head that reach the head again), counts edges leaving it, and counts call/int inside it.

node tools/toyvm/trace-jit.js /tmp/demos/1994-a-addy_ii/ADDY_II.EXE \
  --dispatches=12m --sample-from=0.5 --min-ops=1 --coverage=100 --tiers=0

Over tools/toyvm/bench-set-20.txt, hottest region per program, sorted by what share of the program's samples the region holds:

program share blocks ops exits call/int
DTM2 100.0% 1 4 2 0
DEMO5 100.0% 1 1 0 0
COMPOVRS 100.0% 8 98 7 0
CONTACT 100.0% 19 167 23 13
daretro 99.7% 1 4 1 0
DSTNFO 87.7% 21 148 35 13
DREAM 68.1% 22 211 35 13
CYCLE 66.3% 1 3 2 0
DRAGON 64.6% 2 24 1 0
ADDY_II 64.6% 14 178 1 0
CORE-ADD 52.5% 15 144 13 6
B-STEEL 39.5% 17 75 23 11
ACCIDENT 17.0% 8 32 3 1
ASYLUM 16.7% 10 89 15 9
BRW 5.3% 11 191 26 6
RUNDEMO 1.7% 7 35 15 7

CMA_SHRT and CONTAGIO have no loop region at all (below); DHADREN and COPPER land no samples in a live block at all, which is the self-modifying decline this file already documents.

The distribution is bimodal and the split is call. Every call-free region in the table has 0–7 exits, and those are the ones holding 64–100% of their program. Every region containing a call has 13–35. There is no middle.

That makes the first cut obvious and small: compile call-free natural loops. ADDY_II is the shape that argument was made for — 14 blocks, 178 ops, one exit, 64.6% of the program — and DRAGON's 2 blocks / 24 ops / 1 exit is the same. A single exit means one writeback path, which is close to the cheapest version of this that could exist. The call-bearing half is where the OSR-shaped machinery would be needed, and it can be declined by the matcher without losing the programs above.

Two limits to read the table with:

Two instrument bugs this found, both in reading branch targets

Neither was in the VM; both were in this file's static reader, and both produced confident wrong numbers first.

  1. A fused branch does not start with j. The classifier tested /^j/.test(op.name), which misses cmp_mi16_jnz — so DRAGON's hot loop came back with zero exits when its only way out is exactly that op. The fix is structural rather than by name: an operand that holds the address of a known block head is a branch target. Guest ip operands cannot collide with that — they are 16-bit, the arena sits above 0x1000000.
  2. TAKEN_AT indexes the guest ip, not the arena word. It is what loop-match.js matches block ips against; the arena address sits one slot in front of it, and the tail is arenaTaken guestTaken [arenaFall] guestFall — four words for a plain conditional, three for a traced twin whose fall-through is stitched in behind it. This matters because an edge whose target the compile did not emit is written as arena address zero (the branch handlers read that as "hand back to the host"), and those handbacks are how most loops actually leave. Counting them needs the right slot.

Fixing (1) also moved the back-edge shares in the section above by a point or two — DRAGON's back went 65.0% → 66.4% — since the classifier was missing fused back edges too. The conclusions there are unchanged.

Would inlining the calls rescue the other half?

The table above declines every region containing a call, and that is half the corpus. The obvious repair is to inline the callee so the call stops being a region boundary. Whether that is available at all depends on something the census can answer: are these calls direct? A call_rel carries its operands as [arenaTarget][guestTarget][retIp][arenaRet] — the callee's arena address is already there at compile time, so inlining it is a static splice. A call_r16/call_m16 computes its target at runtime, so inlining one needs a speculated target plus a guard, which is the trace machinery this VM has spent several documents avoiding.

Same command, same twenty programs; hottest region per program:

program region exits direct sites distinct targets leaf targets (ops) indirect int
CONTACT 23 12 3 1 (8) 0 1
DSTNFO 35 13 6 2 (59, 171) 0 0
DREAM 35 13 6 4 (21, 21, 5, 216) 0 0
B-STEEL 23 11 5 3 (4, 14, 4) 0 0
ASYLUM 15 9 2 0 0 0
RUNDEMO 15 7 4 4 (9, 45, 54, 57) 0 0
CORE-ADD 13 6 1 0 0 0
BRW 26 6 1 0 0 0
CYCLE (3.0% region) 14 5 2 1 (288) 0 0
ACCIDENT (3.8% region) 4 0 0 0 0 2

Every call in every hot region is direct. Not a majority — all of them. The mechanism inlining needs is therefore available without speculation, without a guard on the target, and without any of the deopt machinery; the callee's arena address is a constant in the operand stream.

Three things that still cost something, none of them a blocker:

What inlining does not do is turn these into the one-exit shape the call-free half already has. DSTNFO's 35 exits lose at most its 13 call sites; the rest are ordinary branches leaving the loop. So the ordering stands: build the call-free case first, where ADDY_II is 178 ops behind a single exit, and treat direct-call inlining as the extension that brings B-STEEL-shaped regions (three leaves of 4, 14 and 4 ops) in behind it. The regions whose callees are large or non-leaf are a size decision, not a mechanism one, and can be declined by a budget without losing the mechanism.

The regex that made this table wrong the first time

/^call_(r|m)/ also matches call_rel. Every region duly reported exactly as many indirect sites as direct ones — 12/12, 13/13, 6/6, 11/11 — and the tidy 1:1 ratio is what gave it away: a real corpus does not do that. Anchored to the whole name (call_r16|call_r32|call_m16|call_m32|call_far_m|call_far_m32) the indirect count is zero everywhere. The first version of this section would have concluded that inlining was unavailable. Third branch-reading bug in this file, same shape as the other two: a name pattern standing in for a structural fact.

Can the guard be proved dead instead of executed?

An inlined ret still pops the guest stack and checks what it got, because the callee might have rewritten its own return address. The question is whether that check can be removed rather than merely made cheap — and the answer is a static property the census can already report: did the callee perform a general store at all?

The distinction that makes this tractable is one handler-effects.js already draws. A push is a stack effect; a mov [di],al is a memWrite. A push writes below the return slot by construction — SP has been decremented — so no amount of pushing can clobber the word the ret is about to read. Only a general store can, and only if its effective address lands there.

So --coverage reports each leaf callee as <ops>op/<stores>st:

program leaf callees (ops / general stores)
RUNDEMO 9/0, 45/0, 54/0, 57/11
CONTACT 8/0
CYCLE 288/0
DREAM 21/8, 21/8, 5/0, 216/20
B-STEEL 4/3, 14/4, 4/0
BRW 8/1
DSTNFO 59/5, 171/21

Store-free leaf callees are common, not exotic. Three of RUNDEMO's four, CONTACT's only one, CYCLE's 288-op one, one of DREAM's four and one of B-STEEL's three touch memory only through the stack. For those the return address provably still holds what the call pushed, the guard is dead code, and the inlined ret collapses to the SP adjustment — the callee becomes straight inline code with no exit and no materialization point.

Two caveats before that is a rule:

The payoff is not the compare. A load, a compare and a branch is a few instructions; if that were all a guard cost, it would not be worth removing. What a guard actually costs is that it is a materialization point: at every exit the compiled region must be able to write back promoted registers, elided flag records and the right $ip. Guards inside a loop body are what stop registers living in wasm locals across iterations, which is the entire value of the micro-op tiers. Removing a guard removes a constraint on the register allocator, not four instructions — which is why "did this callee store anything" is worth a static pass.

Is the cost dispatch, or memory?

Worth pinning down before designing around either, because "dispatch" is often used to mean three different things: the indirect-branch mechanism, the per-op tax (operand loads, $ip advance, register file through globals, the budget check), and the branch misprediction the mechanism suffers.

bench.js already has the two extreme shapes — alu touches no memory at all, mem touches it on every op — so the question is one command. Box at load 3.5, 6M dispatches, 5 interleaved reps, minima:

shape tailcall repl_tailcall switch
alu (no memory traffic) 8.78 ns/dispatch 8.58 (−2.2%) 8.33 (−5.1%)
mem (every op reads and writes) 9.40 ns/dispatch 9.80 (+4.2%) 8.82 (−6.2%)

Two things fall out.

Guest memory traffic is not the dominant cost — 7%. Adding a read, a read-modify and a write per iteration moves the per-op cost from 8.78ns to 9.40ns. With the caveat that matters: mem streams a 2KB window, so this prices L1-resident memory. A demo blitting a 64000-byte framebuffer will miss cache and this shape cannot see that; a framebuffer-sized shape is missing from the harness.

Nor is the dispatch mechanism — 2-6%. Swapping return_call_indirect for a replicated tail or a br_table moves these shapes by a few percent, in both directions. That is the same conclusion the corpus reached at +10.5% for repl_tailcall: real, worth having, not where the time is.

So the ~8.8ns floor is the per-op tax, and it is what stitching removes: tier 0 → 1 is 1.97x precisely because it deletes the operand load, the $ip advance and the transfer together, without touching the work the op does. For the micro-op region design this cuts both ways — putting registers in locals attacks the tax directly, but every micro-op added still costs ~8-9ns, so a lowering that emits three micro-ops per x86 op has to eliminate more than it adds. Op count remains the first measurement, before any timing.

Not separated by this experiment: branch misprediction, which is inside the 8.8ns and cannot be split out by shape. The A/B that would isolate it is a single-handler loop against a rotating mix at equal op count — the predictor sees one target in the first and many in the second. Not built.

One bug found while running it: bench.js parses --dispatches with a bare Number(), so the 12m suffix every other tool in this directory accepts silently yields NaN, and the run reports 0.0 ms and NaN ns/dispatch against every arm. And the mixed shape retires almost nothing (0.3ms for a 6M-dispatch budget, unresolved=2) — it stops early, so its numbers are not comparable with the other two.

Five engines, and what the machine code actually looks like

Every number above this line came out of node's V8. Two questions follow from that and neither can be answered from inside it: is the tier win a fact about our code generation or about V8's optimizer finishing the job, and does it survive on an engine that compiles differently.

tools/toyvm/engine-bench.js answers both. trace-jit.js --bundle=DIR writes what a different engine would need to run the identical arms — the four module binaries, the memory snapshot, the register and machine state, and the arena the interpreter arm executes — and the tool generates one runner script that every installed shell can eval, with its parameters baked in as literals (argv reaches a shell script differently in all five, and a mis-parsed argument would silently benchmark a default).

DRAGON.EXE's hottest block, 8 ops, 1M iterations, best of 3, box at load 1.6:

engine tier 0 tier 1 tier 2 tier 3 tier3 vs tier0
node (V8 24.x) 69ms 34ms 33ms 8ms 8.6x
node, --liftoff-only 102ms 58ms 58ms 23ms 4.4x
SpiderMonkey (Ion) 56ms 36ms 35ms 12ms 4.7x
SpiderMonkey, --wasm-compiler=baseline 78ms 53ms 52ms 17ms 4.6x
JavaScriptCore 66ms 26ms 28ms 8ms 8.3x
JavaScriptCore, --useOMGJIT=false 84ms 53ms 52ms 17ms 4.9x
d8 (V8 shell) 83ms 21ms 21ms 11ms 7.6x
d8, --liftoff-only 166ms 64ms 63ms 26ms 6.4x
bun (JSC) 66ms 25ms 23ms 7ms 9.4x

The tiers are not a V8 artifact. Every engine ranks them the same way and every engine, including the three baseline-only arms, pays less for tier 3 than for tier 0. Stitching alone (tier 0 → 1) is worth ~2x everywhere, which is the per-op tax and nothing to do with any optimizer.

But the ratio is engine-dependent by 2x, and the baseline arms say why. With the optimizing tier off, all three engines land in the same place: 4.4-4.9x. The spread at the top — 4.7x on SpiderMonkey against 9.4x on bun — is the optimizing compilers disagreeing about our generated code, not about the interpreter. SpiderMonkey is the outlier in both directions: its interpreter arm is the fastest of the five (56ms) and its compiled arm the slowest (12ms).

Shorter traces converge, as they should — CYCLE.EXE and ACCIDENT.EXE both park in a 4-op loop and score 1.2x (SM) to 2.5x (bun), and a 1-op block scores 1.06-1.77x. There is no fixed multiplier to quote; the tier win scales with how much per-op tax there is to delete.

The non-JIT control this does not have is a pure interpreter runtime like wasm3: these modules import a memory and three host functions, and wiring that through a CLI runtime is a harness, not a flag. --liftoff-only / --wasm-compiler=baseline / --useOMGJIT=false are the honest stand-ins.

What Ion makes of a compiled region

tools/wasm-native.js now takes --wat= (any module's source, for the name table) alongside --wasm=, and names direct-call targets from the segment table, so the region a JIT run installs can be read as machine code:

node tools/toyvm/region-jit.js <exe> --emit=/tmp/rj
node tools/wasm-native.js --wasm=/tmp/rj.wasm --wat=/tmp/rj.wat --func='$region_0'

daretro's region — 8 ops, +18% end to end — is 952 bytes of arm64 Ion. Two things in it are the next work, and neither was visible from a timing:

The promoted registers are not in registers. ax, dx, si and esb are lifted out of the globals into wasm locals for the length of the loop, which is what tier 3's promotion pass is for — and Ion spills all four to the frame ([x20,#44], #40, #36, #32) and reloads them around every call. So promotion currently buys a cheaper addressing mode (frame-relative instead of instance-relative), not registers.

Four calls survive per iteration$rd8b, $rec_add, $wr8b, $cxdec — and they are what forces those spills. $cxdec is the loop counter and $rec_add is a flag record; both are small enough to inline into the region, and doing so is the difference between a loop that keeps its state in registers and one that does not. That is a much larger lever than anything left in the op bodies themselves.

Read it for structure only: this is SpiderMonkey Ion, and the table above says Ion is the engine least happy with our generated code.

The region JIT at depth: a shorter budget was flattering it

The six-demo table in commit 776c20dd was measured at --dispatches=2m, which is 50-250ms of guest per arm. Re-run at 12M on an idle box (load 1.9, 3 reps, minima) the same regions look very different:

demo region 2M 12M
daretro.exe 8 ops @ 0x216 +11.5% +0.3%
DRAGON.EXE 56 ops @ 0x7d +7.4% +14.8%
CYCLE.EXE 4 ops +7.1% −3.0%
ADDY_II.EXE −1.9% −0.1%
DTM2.EXE −12.9% no self-loop region found
ACCIDENT.EXE 34 ops @ 0x2d41 +5.1% wrong frame

Two lessons, one of them uncomfortable.

A short run measures the region's share, not the region. A demo's hot loop is hot during its effect — a fade, a scroller, a plasma — and a 2M-dispatch run is often mostly that one effect. Ten times the budget walks into the rest of the program, the share collapses, and the same compiled loop with the same body is worth a tenth as much. Only DRAGON's region survives, and it is the one whose loop is the program's actual renderer. Amdahl was always in the formula; the short budget was hiding which side of it each demo sat on.

ACCIDENT.EXE is a correctness bug, not a slow region. At 12M the region run diverges: 135 interrupts against the baseline's 1567, a blank screen where the baseline drew 18447 pixels, and it takes 4x the wall clock to do it. It is reproducible and it is not in the parts this session added — --no-lower, --no-promote and the default all produce byte-identical runs (12003056 dispatches each), so the fault is in the shared region mechanism: the $steps charge, the exit protocol, or the successor list. It is the same open question as RUNDEMO's +82% with a differing frame. Until it is found, a region result is only a result when the frame matches at the budget it was measured at.

One hypothesis was tested and ruled out. A region is keyed by guest ip, so a program that rewrites the code there would get the old loop compiled over new instructions, and ACCIDENT does take one self-modify break in the baseline and none with the region. So compile.js now takes regionBytes: the guest bytes each block covered, read out of the compiler's own covered extents at build time and re-checked before every install, with the substitution declined if a byte has moved. That is a real hole closed — but it is not this one. The guard installs 41 bytes over ACCIDENT's single block and the divergence is unchanged.

Narrowing ACCIDENT: eight bisectors and what each one cleared

The wrong frame above is reproducible and budget-dependent, so the first thing built was --head=0xIP, which pins the region to one guest ip. Without it the pick is a function of the profiling budget, and sweeping the budget to find where a region first goes wrong silently changes which region is being measured — ACCIDENT's 34-op loop at 0x2d41 looked benign at 2.2M and catastrophic at 12M partly because the two runs had chosen different loops.

Pinned to 0x2d41, the divergence appears between 5M and 8M dispatches: the baseline's interrupt count jumps 88 → 1206 as the program enters a new phase, and the region run stays at ~110. It never gets there.

What has been cleared, each by a switch that is now in the tool:

bisector result
--trap (region body is unreachable) traps — so the region is entered
--succ-only (successor list, no region) identical — the extra decoded blocks are innocent
--no-succ (region, no successor list) unchanged — not the successors either way
--passes= + --no-promote (no optimization at all) unchanged
--no-spin --no-traced --no-cross-flags --no-dead-flags --no-fuse unchanged — the compiler's own assumptions about the arena words are not being violated
--irq-every=1b (no timer interrupts) unchanged — and it proved ints counts guest int instructions, so the region really is executing different code, not just drifting the clock
regionBytes guard (self-modified code) unchanged
--agree (trace-jit's snapshot bench over the region's own ops) ALL THREE MATCH — every register and every byte of guest memory agree between the interpreter and the compiled form
--once (no back edge: a straight-line block replacement) unchanged

So the ops are proven equivalent and the loop protocol is not the fault: a region that runs the same 34 ops exactly once per entry, with every optimization off, still diverges. What is left is what a region does at its edges — the state it is entered with, or what it publishes on the way out — for this block in particular. It is the only region in the corpus containing movsb and mov_sr_r, and it hands back at its own head 230 times, which no other region does.

One thing the --agree run settled on the way past: for this op mix tier 3 is 0.94x of tier 0 — the compiled form is slower than the threaded code. This region should never have been installed on merit, and a snapshot-bench gate in front of the installer would have declined it before correctness ever came up.

A related bug was found and fixed while looking: splitBranch took the textually last (if in a body rather than the last top-level one. A branch whose arms each contain an (if (every CONT resolve does) therefore failed its balance check, declined lowering, and fell back to the interpreter protocol — with a profiling-run arena address baked in as a constant. ACCIDENT's loop was exactly that shape. Lowering it changes the run (dispatches, handbacks and interrupt count all move) but does not fix the divergence, so the stale address was a second bug and not this one. The four demos whose frames match still match with it lowered.

The install gate, and the number that justified it evaporating

The paragraph above ends by proposing a snapshot-bench gate in front of the installer, on the strength of ACCIDENT's region benching at 0.94x of the interpreter. The gate is now in (region-jit.js, on by default, --no-gate to run anyway, --gate=RATIO to move the bar) — but that 0.94x was not real, and the correction matters more than the feature.

0.94x was a warm-up artifact. It was measured over the --agree default of 200 iterations. The same region over 4000 iterations is 2.19x and 2.38x on two consecutive runs, and 2.35–2.36x on every run since. Below roughly a thousand iterations the arms are still being tiered up by the host engine, so the ratio prices V8's compiler rather than the lowering. --gate-iters defaults to 4000 for that reason, and the printed line carries its own iteration count, because a gate verdict without one cannot be checked.

A mismatch only counts when both arms ran the same program. The bench runs an op list end to end: the interpreter arm walks arena words, so a branch word inside the list jumps, while every compiled arm was emitted as a straight line and falls through it. Where that branch is taken, the two arms are two different programs and the bench correctly reports MISMATCH — with all three compiled arms agreeing with each other and only arm 0 differing, which is the signature. The first version of the gate declined CYCLE, BRW and CMA_SHRT for exactly this, and CYCLE is frame-IDENTICAL end to end: a false positive, not a find. So the downgrade is on the disagreement, not on the region — an op list with an internal transfer turns a mismatch into INCONCLUSIVE, and leaves a pass alone (ACCIDENT's 0x2d41 has an internal branch that is never taken from this seed, agrees, and is judged on its ratio like anything else).

What it declines today: nothing. Across the core-10 set plus DRAGON and ADDY_II, every region that the gate can judge passes it (DRAGON 1.59x, ADDY_II 2.39x, ACCIDENT 2.36x) and every region it cannot judge is reported INCONCLUSIVE. That is the honest state: the gate is a regression check for lowering work, not a filter that is currently catching anything. It costs about 20ms on a 12M-dispatch run.

A second divergent region turned up while sweeping for it: CMA_SHRT, whose region is 100% of samples and whose frame differs — the same family as ACCIDENT and RUNDEMO, and a better bisect target than either, since nothing else in that run is competing for time.

Inlining the counter helpers, so CX can be promoted

$cxdec, $cx16 and their 32-bit twins are on promoteRegs' allow-list, but being on it costs CX: they read and write $cx behind the pass's back, so a body containing one has CX banned from promotion entirely — and CX is the induction variable of every counted loop and every REP in the corpus, the one register written on every iteration.

inlineCounters (tier 3, --passes=…,inline, on by default) expands the four helpers into the same expressions over the same globals, with a (block (result i32) …) holding the store for the two that have one. The point is not the call overhead — it is that (global.get $cx) in plain text is something the promotion pass can see and rewrite into a local.

It does what it is for, and that part is deterministic: on ACME-SNS the region body goes from 38 global.get $cx to 15 and gains 29 local.get $Lcx, and the promoted set gains CX on every region that contains a counter call (rage, ACME-SNS, IHANMUU, ADDY_II, ACCIDENT). Two calls per iteration disappear from the SpiderMonkey Ion disassembly (61 → 59 calls in $region_0).

No speedup is measurable on this box. The whole-run A/B on ADDY_II spans −0.0% to −5.7% within one arm at load 5–7, and the snapshot-bench ratio for the same region ranges 2.76–4.82x across three runs of the identical build. Both instruments are wider than any effect being looked for. The structural claim above is checkable and true; the timing question is open and needs a quiet machine.

There is a reason to expect the effect to be small where it was measured, too: ADDY_II's region has 7 in-body exits and ACME-SNS's has 18, and every exit re-runs the epilogue that stores each promoted local back to its global. Promoting one more register adds a store per exit against the loads and stores it saves per use. The regions where this should pay are the ones with few exits and a hot counter — which is a selection criterion, not a pass.

The wrong-frame bug, one third of it found

Four corpus programs installed a region and then computed something else (ACCIDENT, RUNDEMO, CMA_SHRT, CONTACT, plus rage). CMA_SHRT is now fixed, and the route there says as much as the fix.

The bench was lying twice, and both lies had to go first.

  1. compileWat's cache key did not include the ops. It keyed on tier name, block ip and pass set, all of which two different op lists from one block share, so the second benchTiers call in a process ran the first call's compiled module against the new list's tier-0 arm. A prefix walk therefore blamed whichever op it happened to look at second — op 1 starting from 1, op 2 starting from 2, op 3 starting from 3. The key now carries a hash of the ops.
  2. straightLineProgram terminated tier 0 at any jmp. The compiled arms are straight lines with no jmp to stop at, so for any op list with an internal jump the arms were guaranteed to diverge from the next op onward — which is every multi-block region in the corpus. A jmp is now redirected at its fall-through, exactly like a conditional branch already was; it still terminates when it is the last op, and it still never points backwards (the self-loop that once read as an 18.5x speedup on daretro).

With both fixed, --agree-bisect walks prefixes and names one op. On CMA_SHRT it clears ops 0–43 and stops at a call_rel32, which is a real transfer and outside what a straight-line bench can judge.

Then two real bugs, in the region compiler.

A stale arena on the shadow return stack. A call inside a region pushed $rpush(ret_guest, ret_arena) with the profiling run's arena address for its return point. The callee's ret popped it, matched the (correct) guest ip and set $ip to unrelated code. Arena operands are now stripped from the ops a region compiles — $rpush treats a zero arena as "no entry", so the return degrades to a block-cache resolve.

An offset compared as though it were an address. After a transfer it could not lower, the region carried on inside itself whenever $gip came out equal to the recorded fall-through. $gip is an offset. A far transfer to the same offset in a different selector passes that test, and the region then runs its next block's ops in the wrong segment — which is precisely what CMA_SHRT, a 32-bit protected-mode program, does. The region now leaves after any unlowered transfer and lets the host resolve $gip (--assume-fallthrough restores the old rule). It costs a handback where an unlowered transfer exists and nothing where none does: DRAGON and ADDY_II report identical dispatch and handback counts either way.

program before after
CMA_SHRT blank screen (0 px vs 19432) frame IDENTICAL, 19432 px
CONTACT 51014 px vs 61081 unchanged
ACCIDENT 0 px vs 18447 unchanged
rage 0 px both, hashes differ unchanged
ADDY_II / DRAGON / CYCLE / BRW identical identical

Zeroing GO's arena operand as well as $rpush's fixes CMA_SHRT too, by accident — CONT(0) fails, $slice_exit sets $halt, and the old test then exits — but it breaks DRAGON and ADDY_II, because an unlowered transfer sits inside the loop and the body keeps executing the ops after it. That is worth knowing before reaching for it again: the fix is to leave, not to poison the operand.

A compiled region's bytes were not marked as code

isa.CODE_BITMAP is the whole self-modify detector: $wr8 raises $smc only for a store into a byte the host marked compiled, and the host builds that bitmap from the covered ranges compile.js records as it decodes. The region-substitution branch never decoded the region's guest bytes — it writes the region's handler index as the block body and moves on — so those bytes were absent from covered, absent from the bitmap, and a guest store into them was invisible. compile.js now pushes each guard range into covered on that path.

It is a correctness argument with no demonstrated beneficiary. --no-region-code-bits is the A/B switch, threaded through dos-loop.js and run-dos.js, and every program that has a region in this corpus reports byte-identical results both ways: rage smc 27/4 and the same two frame hashes, ACCIDENT smc 1/0, CMA_SHRT smc 1634/1634 and IDENTICAL, ADDY_II smc 2/2 and IDENTICAL. So it does not explain ACCIDENT's blank frame, which was the hypothesis that produced it, and no program in the corpus writes into a region it is executing. It is kept because the hole is real and the fix is three lines, not because anything measured got better — and the flag is there so the next program that lands can be checked rather than assumed.

CONTACT: the divergence is the exit at the head, not the ops

CONTACT.EXE's region is a single 17-op block at 1d79:00cf carrying 99.7% of the samples — a generated inner loop (the static image is zeros there, so the program writes this code at runtime). It has been the corpus's second wrong frame since the CMA_SHRT fix. The bisect now names the edge exactly:

arm frame
--passes= (nothing lowered, nothing folded) IDENTICAL
--passes=constprop DIFFERS 51014 px vs 61629
--passes=constprop --no-lower IDENTICAL
--passes=constprop --no-lower --once DIFFERS, same wrong hash

constprop is not the culprit — it is the enabler. splitBranch needs the branch's operands folded to literals, so with no passes the dec ch / jnz is left unlowered and the region hands back there. What the last two rows isolate is sharper than that: --no-lower and --no-lower --once compile the same body, and differ only in whether the region takes its own back edge or leaves and comes back through $jlook. Looping inside is right; leaving and re-entering is wrong.

So the rule the evidence supports is: exiting the region with $gip equal to the head and re-entering through the block cache is not equivalent to the back edge. Every arm that ever does it produces the identical wrong frame (7dc0c28d, 51014 px), and every arm that never does it is byte-identical to the interpreter.

Two whole families are already excluded, each by its own control:

And --entries shows both arms leaving wasm at the same guest addresses in nearly the same counts (1d79:ae x10, 1e3c:7f x7, 1d79:b3 x6 in both), so the guest is not stuck — it runs the same surrounding code and paints a different picture. The snapshot bench cannot see any of this: --agree reports all three tiers matching over 400,000 iterations, because the thing that is wrong is the region's re-entry, which a straight-line snapshot never performs.

...and ACCIDENT is the same headline with a different tail

The same ladder on ACCIDENT.EXE (34-op region at 1bb5:2d41, 12M dispatches) splits one row differently:

arm CONTACT ACCIDENT
default DIFFERS DIFFERS
--no-lower IDENTICAL IDENTICAL
--once DIFFERS DIFFERS
--no-lower --once DIFFERS IDENTICAL

So the head-exit rule above is CONTACT's, not a corpus law: ACCIDENT is indifferent to the back edge and cares only about the lowering. What both programs agree on is the headline — --no-lower makes each of them frame-identical to the interpreter, and nothing else does. Branch lowering (splitBranch / splitJump and the act() that decides which arm falls through inline) is the one thing on the failure path for both, which is where the next pass over this should start.

Two process notes worth keeping, because both cost time here:

CORRECTION: the two ladders above were read through a broken shell loop

Both ladders in the previous two sections were driven by a for a in "--x --y" loop that passed $a unquoted. zsh does not word-split an unquoted parameter expansion. So every row whose arm had more than one flag reached region-jit.js as a single argv entry — "--passes=constprop --no-lower" — which matches no flag it knows, and the row silently reports the default arm's verdict under a label claiming otherwise. Single-flag rows were fine; every multi-flag row was fiction. Write ${=a}, or spell each arm out.

That is the second time a ladder here has been read wrong, after the blank-row printf problem recorded above, and both cost a conclusion. The rule that covers both: a bisector row is only evidence if the invocation that produced it is visible in the transcript.

Re-measured on the current tree, one invocation per arm:

ACCIDENT: it really is the lowering, and it is the fall-through arm

arm frame
--succ-only (region not installed) IDENTICAL
--no-lower IDENTICAL
--no-lower --once IDENTICAL
default DIFFERS 38c165c5 0px, ints 1567/148, smc 1/0
--once DIFFERS, same hash
--no-promote / --passes= / --passes=constprop / --no-fuse / --no-spin / --no-traced / --keep-arena-operands / --no-region-code-bits DIFFERS, same hash, every one

Every optimization is exonerated by its own switch, and the wrong frame is bit-identical across all of them, so nothing about what the ops compute is in play. --once fails and --no-lower --once passes, which pins it further: the back edge is not it either.

What is left between those two arms is one thing. ACCIDENT's region is a 34-op block whose op 13 is a loop, and the two arms differ only in what happens when that loop is not taken:

Continuing inline is what breaks it. The obvious repair — give that arm the CONT/$smc test the interpreter's transfer protocol would have applied — was implemented in a scratch worktree as --no-fallthrough-guard and A/B'd properly this time: both arms still DIFFER, so the missing slice-boundary test is not the mechanism. That theory is dead for the second time, now on a measurement that can be trusted.

CONTACT: not a lowering bug at all

The earlier section's headline (--no-lower fixes it) does not survive. On the current tree only ONE arm is identical:

arm frame
--succ-only (region not installed) IDENTICAL
--no-lower DIFFERS dc683527 61081px
--passes= DIFFERS dc683527
--passes= --no-promote --no-lower --once DIFFERS dc683527
default DIFFERS 7dc0c28d 51014px
--once / --no-promote / --assume-fallthrough DIFFERS 7dc0c28d

The fourth row is the one that matters. --passes= --no-promote --no-lower --once is the most degenerate region this file can build: the shipped handler bodies concatenated verbatim, operands unfolded, no registers in locals, the interpreter's own transfer protocol at every branch, no loop around it, entered and left exactly where the interpreter enters and leaves the block. It still diverges. So CONTACT is not a lowering bug and not an optimization bug — what is wrong is in the install/entry/exit protocol itself, which every configuration shares, and which --succ-only is the only arm to switch off.

Two supporting facts. --agree runs the interpreter and all three tiers over CONTACT's 17 ops from one seeded state and reports ALL THREE MATCH, so the bodies mean the same thing. And the unlowered arm lands on the baseline's exact pixel COUNT (61081) with a different hash, while the lowered arm lands on neither — two distinct wrong pictures, not one bug seen twice.

So the two programs are no longer one story: ACCIDENT is the branch lowering's inline fall-through; CONTACT is the region protocol. Fixing either one will not fix the other.

--dump, not --dump-wat

The body dump is --dump. --dump-wat is not a flag, it writes nothing, and it fails silently — so the /tmp/region-<exe>.wat left over from an earlier session reads as the current build's output. A diff of two arms taken that way showed them identical when they are not.

ACCIDENT, narrowed further (and one theory that nearly held)

Pin the region with --head=0x2d41 so the profiling budget stops choosing a different loop, and the divergence is there from the smallest budget that finds it at all:

--dispatches frame ints base/region
3m and below no self-loop region found
4m DIFFERS 73/71
6m DIFFERS 102/90
8m DIFFERS 1206/110
12m DIFFERS 1567/148

The earliest symptom is two missing interrupts out of 73, and it compounds from there: between 6M and 8M dispatches the baseline breaks out of this loop (its ints jump ten-fold as the loader starts making DOS calls) and the region arm never does.

Two facts about the region make that shape sensible. --agree runs the interpreter and all three tiers over its 34 ops from one seeded state and reports ALL THREE MATCH, so the bodies are not in question. And the block has no conditional exit of its own: op 13 is the loop, ops 14–33 are its fall-through, and op 34 is a jmp back to the head. It is an infinite loop that only something outside it can break — which is why an interrupt that does not arrive leaves the guest in it forever, and why smc 1/0 is a downstream reading rather than a cause (the region arm records zero self-modify breaks because its guest never reaches the code that patches itself).

That suggested interrupt pacing, and at 4M dispatches it looks exactly right:

arm (at --dispatches=4m) frame ints
default DIFFERS 73/71
--slice=2000 DIFFERS 75/72
--irq-every=8k IDENTICAL 304/304

--slice=2000 is the control that matters: --irq-every=8k caps the slice at 2000 dispatches as a side effect, so without it the row would only say "shorter slices help". It does not help. The interrupt rate does.

But it does not survive the full budget. At 12M no interrupt rate rescues it, and --irq-every=25k puts the region arm 5120 pixels into a frame the baseline draws 18447 of and leaves it stopped at c002:bbbf — an address the baseline never visits. So denser interrupts postpone the divergence rather than removing it, and pacing is at most half the story.

Still dead, both re-tested properly this time: --no-fallthrough-guard (give the lowered fall-through arm the CONT/$smc test the interpreter's transfer protocol applies to both edges) changes nothing, and --head-exit=slice (leave through $slice_exit instead of resolving $gip at the head) changes nothing.

CORRECTION 2: CONTACT is the lowering after all, and here is why it looked otherwise

The section above concluded that CONTACT's fault is in the region install protocol, because its degenerate region — verbatim handler bodies, unfolded operands, no promotion, interpreter transfer protocol, no loop — also diverged. That reading was wrong, and the mistake is worth more than the conclusion was.

Most of this corpus never terminates. A demo runs its effect until somebody presses a key, so a run ends when the dispatch budget does and the frame is a snapshot of an animation in progress. region-jit.js charges $steps in one lump per straight line rather than one per op — its own code says so — so the two arms stop a few instructions apart having done the same work. A few instructions apart in a plasma loop is a different picture.

CONTACT's baseline frame is a function of the budget and nothing else:

--dispatches baseline frame --no-lower region lowered region
8m b8852c82 61112px IDENTICAL 7dc0c28d 51014px
11m 0f74ca91 61036px IDENTICAL 7dc0c28d 51014px
11.5m 7468af39 61079px 7dc0c28d 51014px
12m 447d0738 61081px dc683527 61081px 7dc0c28d 51014px

The unlowered region tracks the interpreter frame for frame and misses only at 12M, on the same pixel count — that is the phase artifact, not a fault. The degenerate arm behaves the same way: at 11M, --passes= --no-promote --no-lower --once is IDENTICAL. Every "failure" of an unlowered arm in the previous section was measured at 12M and was this.

And the lowered arm gives itself away in the same table: 7dc0c28d at every budget from 3M to 12M. It is not drawing a different frame, it is drawing the same frame forever while the interpreter moves on. The guest stops making progress.

So both programs are one story after all, and it is the story the first correction told about ACCIDENT: branch lowering is the fault, in both. What the second correction got right was the process — the multi-flag ladder rows were never run — and what it got wrong was treating a single budget's frame hash as a verdict.

The rule that falls out, and it now lives in the tooling. For a program that does not terminate, frame equality at one dispatch budget is not a correctness test. tools/toyvm/region-census.js confirms every differing frame at three further budgets before reporting it, and refuses a confirmation whose baseline had not drawn anything yet (two black frames match trivially — that is what first cleared ACCIDENT, which is genuinely stuck at 0px against 18447). It reports phase for differed-then-agreed, and frozen for a region that drew the identical frame at every budget while the interpreter moved on.

Corpus census on the post-WATX tree

sweep-dos.js over all 199 programs, re-run after the WATX compiler cutover so nothing is compared across it:

outcomes: benched 146, no-samples 22, branchy 19, shells:timeout 7, padding 5
geomean over 175 programs that ran >=1M dispatches (baseline tailcall):
  repl_tailcall +8.4%, calls -4.5%, switch +5.1%

The region JIT's dominant bug: a block that ran past its own terminator

Everything above is about the micro-op backend (trace-jit.js tiers priced on a memory snapshot). The jit backend — a region compiled by region-jit.js and installed into a real whole-program run — had no corpus measurement at all until tools/toyvm/region-census.js (15986337, c25bc887). The first census over the 199-program DOS corpus, on the post-WATX tree:

no-loop 101, identical 54, no-samples 23, differs 9, frozen 6, phase 2, timeout 2, crash 2

That is 15 wrong frames out of 71 installed regions — a one-in-five defect rate, not a tail of edge cases.

Narrowing it

Re-running only those 15 with --args=--no-lower (which keeps every branch on the interpreter's transfer protocol instead of lowering it to br $again / br $out) gave identical 10, differs 4, phase 1. So 11 of 15 were the lowering, one bug and not eleven.

DADEMO2.EXE was the small reproducer: a 7-op region whose lowered form executed two ops that are not in the loop at all. Its --dump shows the shape — a loop op, then xor_rr32_nf, then jmp, where the guest loop ends at the loop.

Root cause

readTrace (trace-jit.js) ends a block on a name test:

/^(end|jmp|jcc|call|ret|int)/.test(h.name)

That regex misses loop, loop32, and every fused pair (dec_r8_jnz, the traced twins). A block terminated by one of those keeps reading, and what it reads next is the next decoded block's arena words, which it then reports as this block's own fall-through ops. While the region left the branch unlowered that tail was unreachable — the interpreter protocol jumped away before reaching it — so the bug was invisible. Lowering makes the tail inline, and it runs.

The fix (8bd1cd39)

Rather than re-deriving the terminator set by name (which is what created the problem), region-jit.js now truncates a chained trace at the first op whose recorded fall-through arena address is not the arena address of the op that follows it:

function fallArena(op) {
  const at = TAKEN_AT.get(op.fn);
  if (at === undefined) return null;
  if (op.args.length - (at - 1) !== 4) return null;   // 4 words = own fall-through block
  return op.args[at + 1];
}

This reads the operands the decoder actually emitted, so a new fused pair or a renamed handler cannot silently reopen the hole. A truncated chain is allowed to end on something other than jmp; an untruncated one still is not.

After

no-loop 83, identical 81, no-samples 23, differs 6, phase 3, crash 3

Installed regions went 71 → 90 (truncated chains close on their head where the over-long ones did not), identical went 54 → 81, and wrong frames went 15 → 6. frozen — a region drawing a byte-identical frame at every budget while the interpreter moved on — went to zero.

The six survivors are not this bug

program head base px jit px smc breaks (base/jit)
acme-sns.exe 0x80 98234 98234 40576 / 40568
CARRIE.EXE 0x986 64000 64000 2004 / 2004
BMGLP.EXE 0x235 40767 40868 54973 / 54650
COMPOVRS.EXE 0x338 63814 63814 1 / 1
UNTITLED.EXE 0xc9d 128000 128000 125203 / 125197
AUTUMN.EXE 0x87a2 0 0 455 / 464

Same pixel count, different hash, at three budgets each. The two with tens of thousands of self-modify breaks (acme-sns, BMGLP) point at region staleness rather than lowering: the install guard is computed once at compile time, and guardBytes silently continues past a block whose covered span it cannot find. That is the next thing to dig into.

Coverage, not correctness, is now the jit's limit: 106 of 199 programs get no region at all (no-loop 83 + no-samples 23). --why's decline histogram is the work list.

None of the speed columns in either census are quotable — both ran on a box at load 4+, and the fix also changed region shape, so the throughput picture has to be re-measured on a quiet machine.

Measuring phase instead of arguing about it

Four of the six survivors above were never defects. The check that cleared them is worth stating on its own, because "confirm at more budgets" — what region-census.js did — cannot decide the question and no amount of extra budgets makes it able to.

The interpreter stops dispatch-exact. A region charges $steps in one lump per straight line, so its last entry overshoots, and the two arms stop thousands of dispatches apart: 13087 apart on COMPOVRS.EXE, whose changed pixels were then a 320x5 band — about the 1400 writes that delta buys. The delta is not even signed consistently across budgets (-3228 at 4M, +13087 at 8M), so a budget where the arms happen to agree is luck, not evidence. Nor can the gap be closed by asking the interpreter for the region's exact dispatch count: it too stops only at a block boundary, and a request for 8054341 dispatches ran 8062431 of them.

2026-09-02: most of that gap was ours. dos-loop.js billed an exhausted slice as its quantum and dropped the overshoot, which is one block under the interpreter and one billed chunk in a region; it now bills budget - $steps, and the two arms stop within tens of dispatches of each other at the same guest state (--peek-ds). The check below still matters for what is left — the arms still stop at different instructions — but it is no longer deciding thousands of dispatches of drift. See toyvm-bench-20.md §9.

So region-jit.js measures the noise floor instead. On a differing frame it re-runs the interpreter at the region's dispatch count and counts the pixels the baseline moved by itself over that gap. That is how much picture this program repaints in the distance between the two stops:

COMPOVRS.EXE   baseline drifts 1816px over the 13087 dispatch gap;
               baseline vs region is 1131px   -> PHASE, not a defect
CARRIE.EXE     baseline drifts    4px over the    79 dispatch gap;
               baseline vs region is 52101px  -> BEYOND THE NOISE FLOOR

A difference at or under the baseline's own drift is phase; one three orders of magnitude above it is the region computing something else. It has its own exit code (6) so the census stops counting it as a bug. On the six survivors it cleared acme-sns, COMPOVRS, UNTITLED and AUTUMN — the last two differ only in interrupt count (125203/125197, 455/464) with a pixel-identical frame — and held CARRIE and BMGLP.

CARRIE.EXE: the region is right and the install is not

CARRIE.EXE is the sharper of the two. Its region is frame-IDENTICAL under --no-succ and wrong with the successor list supplied, while --succ-only (the list compiled, no region installed) is also frame-identical. So neither half is wrong on its own; the combination is.

--succ-take=N bisects the list. The ladder is not monotonic:

take=11 IDENTICAL   take=12 PHASE   take=13 IDENTICAL   take=14 IDENTICAL
take=15 47545px     take=17 52101px ... take=20 52101px

so successor 14 (0xa74, the taken edge of a cmp_mi8_jnz) is what tips it, and --why now prints that provenance. Compiling one more block changes no guest semantics at all — it changes the arena layout and which blocks exist — which makes this a layout dependence somewhere in the install, not a wrong op.

The obvious suspect was the one this file already names: a transfer that splitBranch could not lower keeps the interpreter's GO with a profiling-run arena address baked into it, and CARRIE's region has seven of them. Resolving that address live instead ($jlook of the $gip the GO has just published, layout-independent by construction; --keep-go-arena is the A/B) changed CARRIE's frame not at all. The theory is dead: after an unlowered transfer the region does (br $out), and the epilogue re-resolves $ip from $gip before anything reads it, so the stale constant really is inert on that path.

What remains unexplained is why more compiled blocks change the answer at all. CARRIE reports 1929 self-modify breaks in both arms, so the next place to look is the interaction between the region's install-time byte guard and the covered ranges guardBytes contributes — it silently skips a block whose span it cannot find, and a region whose code is only partly covered is invisible to the self-modify check that is supposed to retire it.

The region install had two layout dependences, and both are now gated

BMGLP.EXE and CARRIE.EXE failed the same way and for two different reasons. Both are frame-identical under --no-succ (region installed, successor list withheld) and under --succ-only (list supplied, no region), and wrong only with both — so neither half is wrong on its own. --succ-take=N bisects the list down to the single address that tips each one.

BMGLP: a never-taken edge, pre-compiled into ciphertext. Its culprit is successor 5, 0x281, the fall-through of a cmp_mi8_jnz — and --why reports it was NEVER DECODED in the profiling run. The successor list exists so the decoder can walk out of a block whose body it never decodes, and the comment on it claimed over-approximating was free. It is not free on a program that decrypts itself: BMGLP takes 51158 self-modify breaks, and pre-compiling an address whose bytes are not code yet made the run report 338 fewer breaks than the interpreter and draw a different picture. Two gates now: successors the profiling run never decoded are dropped (--succ-unseen restores them), and every remaining one carries the bytes it was decoded from so compile.js can skip any whose code has not been written yet, the same check the region's own guard already does. BMGLP is frame-identical with them in.

CARRIE: a region that could not lower its transfers. Its culprit is successor 14, 0xa74 — a block the profiling run did decode, whose bytes at install time do match. Compiling it changes no guest semantics at all, and the two arms agree on everything a run can be counted by: 3840 region entries each, 1929 self-modify breaks each, 6 interrupts each, 79 dispatches apart — and 52101 pixels different. (Which is its own proof, incidentally: 79 dispatches cannot repaint 52101 pixels, so no amount of phase can explain that frame.)

What CARRIE's region has that the working ones do not is eight transfers splitBranch could not lower. Such a transfer keeps the interpreter's GO protocol inside the region's loop, and a GO carries an arena address from the profiling run. Resolving that address live rather than trusting the constant ($jlook of the $gip the GO has just published) changes CARRIE not at all — the (br $out) after it re-resolves $ip anyway — so the stale constant is not itself the mechanism. But the dependence is real and it is confined to exactly these regions: a fully lowered region has no edge that survives to install time, and DRAGON and ADDY_II report identical dispatch and handback counts with and without their regions for that reason. So a region with an unlowered transfer is now declined (--allow-unlowered overrides, --no-lower is exempt as a bisector), and so is one whose blocks cannot all be byte-guarded.

What that costs, and what it buys

                    no-loop  identical  no-samples  differs  frozen  phase  declined  gated
after readTrace fix      83         81          23        6       0      3         -      3
+ phase noise floor      87         74          24        1       0      8         -      5
+ these two gates        87         19          24    >>0<<      0      2        64      3

Zero wrong frames in 199 programs — which is the bar — for 55 regions that had been measuring identical. They were not known correct: CARRIE measured identical at 6M dispatches and is wrong at 11M, so "identical at the census budget" was never proof. But the trade is steep and it names the next piece of work precisely: lowering the transfers splitBranch declines has 55 regions waiting on it, and --why's decline histogram is the list.

Lowering the declined transfers, and what it uncovered

The unlowered-transfer gate above named its own follow-up: 55 regions were declined for a transfer splitBranch could not turn into a br_if. So the first thing was to make the decline say why. splitBranch/splitJump now record a reason on every rejection path, buildRegion collects one line per unlowered op, and the gate prints the histogram:

    unlowered: cmp_ri16_jz_t: then arm publishes no resolvable $gip
    unlowered: cmp_ri8_jnz:   then arm publishes no resolvable $gip
    ...
    unlowered: ret:           neither arm publishes a resolvable $gip

Seven of CARRIE's eight were one shape, and the --dump showed it immediately. A traced twin does not write its taken ip as a literal — the operand words are hoisted into locals first, and the arm reads one back:

(local.set $t1 (i32.const 2470))
(local.set $t2 (i32.const 2444))
(if (i32.eqz (global.get $fr))
  (then (global.set $gip (local.get $t1)) ...
  (else (global.set $gip (i32.const 2444)) ...

gipOf() follows that one hop: find the last local.set of that local before the arm, take its literal, and refuse if anything reassigns it in between. The eighth was a ret, whose destination is computed and can never be a literal — but it does not need to be. splitExit() keeps the part of the body that publishes $gip, drops the trailing GO, and lets the region epilogue's $jlook resolve it, exactly as it already does for every other exit.

The gate had been masking CARRIE, not fixing it

With both lowerings in, CARRIE lowers 8 of 8, installs — and is still wrong at 11.04M dispatches, 52101 pixels against a 387-dispatch gap whose measured noise floor is 8. Two runs settled where the fault was not:

What does move it is the successor list — --no-succ is frame-identical and --succ-only (successors, no region) is frame-identical, so it is the pair.

--succ-drop, and the answer

--succ-take=N bisects by position and cannot separate "this address is the culprit" from "the Nth slot is", so --succ-drop=0xa74,0x9a4 was added to remove named addresses and hold every other one still. (The successor line also printed its withheld marks by index rather than membership, which sent one bisect the wrong way before it was fixed.)

Dropping each of CARRIE's twenty successors one at a time, then the tail as a group, gives a clean split:

successors installed frame
the 14 addresses below 0xa55 identical (4px, phase)
+ any one of 0xa55, 0xa5b, 0xa6e, 0xa72, 0xa74 47545–52101px
the 14, + 0xa7b only identical

Those five are not a random set. CARRIE's region is six blocks — 0x983, 0x986, 0x9c1, 0xa2b, 0xa55, 0xa74 — and the five breakers are its last two blocks plus the three fall-through addresses that sit in the gaps between its recorded extents. 0xa7b, the one address past the end of the region's bytes, is harmless.

A successor inside the region's own bytes is a second copy of code the region already owns. The region replaces the decode of those blocks; pre-compiling an address that lands in the same guest bytes puts an independent arena block over them, reached whenever an exit resolves there instead of handing back. So the successor list is now filtered against the hull of the region's guarded spans — the hull, not the spans, because three of the five breakers live in the gaps between them. --succ-inside restores the old behaviour for the A/B.

With that filter CARRIE installs one successor of twenty, costs 551 extra handbacks, and is frame-identical at 11.04M dispatches.

This is a gate with a measurement behind it, not a root cause: why a second arena copy of the region's own bytes computes a different picture is still open, and every build knob says it is not the region body. The honest statement is that the region owns its bytes and nothing else may compile them.

The hull is over the ABSORBED blocks, not the head

Scoping the filter to every guarded span cost a second program. acme-sns.exe is a one-block region of 138 bytes, and withholding its own interior edges moved it from 16px (phase) to a persistent 38px. The head block is not in the same position as the others: it is replaced one-for-one by the region entry, so the arena still owns an entry at that ip and its edges are ordinary. It is the blocks the region absorbed that the arena no longer has. So the hull is taken over the non-head spans, which leaves a single-block region untouched and still withholds all five of CARRIE's breakers.

acme-sns.exe: not certified, and not a lowering bug

That program is still not clean, and it is worth being precise about what it is. --agree reports ALL THREE MATCH — the ops lower correctly from a seeded snapshot, registers and every byte of guest memory. And yet across budgets from 3M to 12M dispatches the frame is persistently 6-18 pixels off in one 43x63 box, and the region arm reports about five FEWER self-modify breaks out of ~17600, every time.

Five fewer breaks is the tell. A break is the emulator invalidating what it believes is code, and a region replaces the decode of its blocks — so the walk that would have discovered and marked their neighbours never happens, and regionSucc stands in for it. The two arms therefore do not have the same idea of which bytes are code, and a frame difference that follows cannot be blamed on the body. region-jit.js now says so with its own exit code (7) and region-census.js reports it as smc-drift, listed in the bugs: line alongside differs because it is an unresolved divergence — just one whose cause is named and is not the lowering.

Where all three backends stand at 418a9607

interp    sweep-dos.js, 199 programs, 4 dispatch shells
          arms-disagree 0, nondeterministic 0
micro     same run, tiers 0/1/2/3
          mismatch 0; branchy 19 (inconclusive by construction, not defects)
          geomean over 141 distinct traces: tier 0 -> 3  3.13x
jit       region-census.js, 199 programs
          no-loop 87, identical 77, no-samples 24, phase 9, gated 2
          declined 0, differs 0

Seven programs — ASMINST, BYRON, DD, ANTARES, STHINTRO, CONDENZ, QUARTZ — did not finish the shells stage inside the sweep's 180s cap, so that first run left their agreement unmeasured behind a green summary. They are not broken, they are slow: ANTARES retires a dispatch in 102ns and STHINTRO in 388ns against a corpus norm near 17ns. Agreement is decided by comparing the four shells' end signatures and does not need the timing ladder, so re-running just those seven at --dispatches=2m --reps=1 closes the hole: all seven complete, zero disagreements.

acme-sns.exe was not a defect: the dispatch count is not a common clock

The smc-drift verdict above was the right instinct pointed at the wrong conclusion. --smc-diff runs both arms with the per-site self-modify census on and subtracts the two maps, which turns "5 breaks missing out of 17600" into two names:

  smc sites differing: 2 of 10
    -3  110:5467 patched its own next block  (baseline 12370, region 12367)
    -3  110:5484 patched its own next block  (baseline 12369, region 12366)

Two writers, each short by three, constant from 3M to 12M dispatches — not accumulating, and every other site identical. Both arms stop at the same cs:ip and paint the same number of pixels. That is not a region computing something else; it is a region three iterations behind in one self-patching loop.

Which is a measurement bug, and an obvious one in hindsight. A region collapses a whole loop into ONE dispatch. Two arms stopped at equal dispatch counts are therefore at different guest instants, and the distance between them is not jitRun.dispatched - baseRun.dispatched — so the phase probe was measuring the noise floor over the wrong distance and calling the residue a defect.

When the arms disagree on self-modify breaks, those breaks are the better clock: they are the program's own events, monotone in the budget. So the check now brackets on them — walk down from the full budget in doubling strides until the count drops below the region's, bisect the remainder, and compare the region against the two instants either side. Two details are load-bearing. The bisect needs enough steps to converge (ten halvings of a 4M range leave it on its starting point, reporting 10142 -> 10142 across a target of 10134 and calling the region wrong). And an exact rematch is often impossible, because breaks come in bursts — a decryptor patches a run of bytes and the count steps by nine — so the test is whether the region's frame lies inside a step the interpreter itself takes.

It does, and by the widest possible margin:

  4M   interpreter steps 10133 -> 10134 across the region's 10134 ... region is 0px from the nearer end
  8M   interpreter steps 25359 -> 25360 across the region's 25360 ... region is 0px
 10M   interpreter steps 32841 -> 32842 across the region's 32842 ... region is 0px

Zero pixels. The region draws exactly the picture the interpreter draws at the same break count. smc-drift (exit 7) survives as the fallback for a rematch that still fails, which is now a much stronger claim than it was.

Coverage: why 111 of 199 programs got no region, and the walk that fixed half of it

At a956de59 the region JIT was correct everywhere it applied and applied to 88 of 199 programs. region-census.js reports the shortfall as two verdicts, no-loop (87) and no-samples (24), and neither is actionable: "no self-loop region found" is the summary of a search that rejected every candidate it looked at, one rule at a time.

region-jit.js --why already prints each of those rejections. What did not exist was the aggregate. tools/toyvm/region-why.js runs the pick — and only the pick, via the new --pick-only, which returns straight after the report so nothing is built, installed, compared or timed — over the whole corpus, keeps the rejection lines from programs that ended with no region, normalizes each line to its rule (addresses and counts differ per program; the rule does not), and histograms by programs blocked rather than by occurrences. That last choice is the whole point: one program can reject two thousand candidates for a single reason and would otherwise drown out a rule that quietly blocks forty. no-samples is kept in its own bucket and never mixed in — it is a different failure (the profiler's samples landed in blocks that no longer exist, which is what a self-decrypting program does to its own arena) and no loosening of the pick rules reaches it.

The first histogram named the cause immediately: the walk was ending at a ret, a bad-handler or a call_far in program after program — at addresses that had no business being on a loop body's path at all.

chainFrom followed only the taken edge. A loop whose body contains a bail- out test — which is most loops — has its taken edge leaving the loop, so the walk marched down the bail-out path, away from the head, until it hit something it could not cross, and reported that as the reason. The rule the histogram was counting was real but was never the obstacle; the obstacle was that the walk never tried the other edge.

It is now a backtracking depth-first search: at each terminator it tries the taken edge first and the fall-through second, with an undo mark over ops, nexts, spans and heads so a failed branch leaves no residue, a seen key of ip@depth (the same block at a different inlined-call depth is a different state), and a maxVisits cap so a pathological CFG gives up rather than hangs. --no-backtrack restores the old single-path behaviour for A/B.

Measured over the corpus:

                     region   no-loop   no-samples
taken edge only          88        87           24
+ backtracking          103        72           24     (+15 programs)

And the census, which is the gate that matters — a region reached is worth nothing if it is a region that draws the wrong picture:

                     identical  no-loop  no-samples  phase  gated  differs  smc-drift
before                      75       87          24      8      3        0          0
after                       88       72          24      8      5        0          0

15 more programs get a region, 13 more are byte-identical to the interpreter, and zero wrong frames. This changes which region the picker selects in all 103 programs, not only the 15 new ones, which is why the full census was the acceptance test rather than a spot check on the new arrivals. The two timeout rows at the 180s cap are ASMINST.EXE and STHINTRO.EXE, the corpus's two slowest programs; re-run at --timeout=900 they come back phase 0px vs 0px and identical.

The rejection histogram over the remaining 72, by programs blocked, is now the work list:

  36  ret with no inlined call to return to
  17  block contains an op the walk will not cross   (int_imm)
  17  edge to ADDR is not a block head
  15  ends bad-handler, not jmp
  12  ends call_far, not jmp

The top two are the same shape of fix and splitExit() already has the machinery for it: an unmatched ret and an int_imm are both exits from the region, not reasons to reject it — publish $gip and let the epilogue's $jlook resolve the destination, exactly as a computed ret destination is handled today. The third is a depth/size limit (maxDepth 3, maxOps 400) rather than a shape the walk cannot express.

The next two coverage levers, built, measured, and removed

The histogram above named ret with no inlined call to return to as blocking 36 of the 72 remaining programs, and that has an obvious reading: the walk can follow a call DOWN and its ret back UP, but it cannot walk up out of the frame it STARTED in, because that return address is on the guest stack and is a runtime value. A hot block inside a subroutine — the loop living in the caller, the callee merely being where the samples land — dies exactly that way. The fix needs nothing new in the walk: root the candidate at a CALL SITE, and the call becomes an ordinary inlined edge whose ret matches a frame the walk pushed itself.

It moved coverage by one program, 103 → 104.

That result is worth more than the change was, because it says the histogram was lying. region-why.js was unioning the rules of every rejected candidate per program, which answers "did any candidate here meet rule X" — not "what blocked this program". Programs reject dozens of candidates; nearly all of them meet a ret somewhere. The fix is two-sided: region-jit.js now prints [depth N] with each rejection (ops accumulated when that path died) and reports the reason from the path that came CLOSEST TO CLOSING rather than the one tried last, and region-why.js keeps only the deepest line per program. The histogram then sums to the program count and cannot tell that story wrong:

   before (union)        after (per-program blocker)
   36  ret ...           14  ret with no inlined call to return to
   17  int_imm           13  walk revisited ADDR
   16  bad-handler       10  block contains an op the walk will not cross
   12  call_far           7  ADDR ends call_far, not jmp

walk revisited had been invisible at rank two. A revisit is a cycle, and a cycle is a loop — just not the one being walked; the trace is linear, so it cannot go round an inner loop and still close on the outer head. But that block is a loop head in its own right, and nothing offered it as a candidate: the candidate list is built from branch targets visible in the hot block's own ops, and this one is only discovered several blocks into the walk. Recording it and retrying there took coverage 104 → 111.

And then the census refused it. DRAIN.COM, previously no-loop, came back differs — 12681px vs 12749px. The body itself is not the problem (--agree: all three tiers match over 200 iterations) and no install knob moves it (--no-succ, --no-region-code-bits, --no-spin are all identical). Nor is it certifiable as phase: ints and smc are equal in every arm at every budget, so there is no guest event to rematch the clock on, and swept across 3M/4M/6M/8M/12M the region is sometimes ahead of the interpreter and sometimes behind while the baseline itself drifts 7638px over a comparable gap. It is simply a program this harness cannot certify either way.

The reason it should never have been asked to is the finding that matters:

region sample share, over the corpus's installed regions
  min 0%   p10 0%   p50 12.9%   p90 66%   max 100%      25 of 103 at 0.0%

A region's whole-program win is capped by its share, so a region at 0.0% of samples cannot help by construction and can only add risk — and DRAIN's is one of those: 0.0% share, 12 ops, and a body the gate measures at 3.34x that still exits on every iteration. Both new roots are fallbacks reached only after the ranked, sampled candidates have failed, so they bypass the hotness ranking entirely: of the 8 programs they added, essentially all arrived at 0.0% (25 → 35 zero-share regions).

So both were removed. Eight more programs that get a region they cannot benefit from, one of them uncertifiable, is not coverage — and the diagnostic work that found this out is what was kept: the depth-ranked rejection reason, the per-program blocker histogram, the share distribution, and --pass= for re-measuring coverage as an older pick behaved.

Two things for whoever picks this up next:

Measuring whether a region pays (2026-09-01)

Everything above ends at the same wall: 103 installed regions, and no number in the tree that says whether any of them is faster whole-program. The timing table now answers that two ways, one of which does not need a quiet box.

              dispatched  handbacks    wall ms     cpu ms
  baseline       6010806        553       72.6       77.5
  region         6010806        553       72.9       75.4   2.8% cpu  (-0.3% wall)
  expected    share 10.0% x (1 - 1/7.64x) = +8.7% ceiling, handbacks +0 vs baseline

What the first readings say, box at load 5–7, 6M dispatches, 3 reps:

program share gate ceiling +hb cpu %
DRAGON.EXE 10.0% 7.64x +8.7% +0 +2.8%
CARRIE.EXE 16.1% 3.63x +11.6% +139 +29.8%

DRAGON is coherent: a positive win under its ceiling with nothing to pay for. CARRIE is above its ceiling, which is impossible, and its baseline shows cpu ms 40% over wall ms — background V8 work landing inside the slices at a 70ms run. 6M dispatches is too short to time under load; the ceiling and handback columns are the ones to rank regions by until a quiet box (or a longer budget) confirms the %. That ranking is what the min-share floor above should be decided from: a region whose ceiling rounds to 0.0% cannot pay by construction, and the column now says so per program.

Two items checked off the loop list without a change (2026-09-02)

ret-terminated traces already install. The straight walk keeps the block that ends in an un-inlined ret and splitExit publishes its computed $gip; DREAM's 15-op, 3-block region at 0x997 ends exactly that way. The reject 0x...: straight, N ops, stops: ret with no inlined call line that made it look declined is the walk's own log of why the walk stopped, printed for every non-closing candidate whether or not it is then installed. The unreadable:ret = 168 figure came from loop-match.js --why, which is the static Design-A matcher, not this JIT.

daretro's 0.3% trace is a correct decline, but the profile behind it is odd and unexplained. 297 of 300 samples (487 of 500 with a jittered slice) sit on one 1-op block — the cmp_mi8_jz_spin twin, 238 entries, ~3M of the 12M dispatches — and every candidate region reads 0–5 samples. A slice-length jitter (deterministic LCG, ¼–1× the quantum, profiling runs only) was built on the theory that a fixed slice phase-locks to the wait; it moved nothing and was removed. Its frame is byte-identical under --irq-every=25000 and --dispatches-per-tick=50000, so whatever that spin waits for is neither the timer IRQ nor the BIOS tick word. Next step, if it matters: --trace-at the spin's ip with --trace-entry past the depacker and read what byte it compares — --trace-entry=N only prints the first N handbacks, which on daretro are all the depacker.