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:
- constant propagation — folding the operand load is not folding the
operand. Bodies read it back as
(local.get $t0), which keeps$rget16'sbr_tableopaque. Propagating the constant into its uses is what unlocks the next pass. - register-file folding —
$rget16/$rset16are abr_tableover 8 globals. Once the index is a constant, that becomes one global access. - dead flag elimination — flags here are eager:
$flags_subwrites the whole word andjzreads bit 6 back out. A flag word overwritten before anything reads it is pure waste.
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:
- fold
$ea. After operand propagation its index is a literal, so the call becomes arm K's expression with the displacement substituted.$eais reached by 612 of the 1581 handlers and its own comment inemit.jscalls it the hottest path in the VM. - fold
$ea32—base + 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. - 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. - fold the segment lookup. A handler asks for memory by segment index —
(call $rd16 (i32.const 3) off)— and$rd16resolves it through$sbase'sbr_tableon the way in, inside the helper. So a constant index buys nothing on its own: the base never becomes an expression anything outside$rd16can see.emit.jstherefore emits a base-taking twin of all six accessors ($rd16band friends) from the same builder, and this pass swaps to it. Thebr_tablegoes, and — the real point — the base becomes an ordinary(global.get $dsb)that step 5 can hoist. - 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:
- evaluate constant
i32arithmetic — innermost-first, with exact wasm semantics: wrap to 32 bits, shift counts mod 32,shr_uandshr_sdistinct. - propagate a body-local temporary that has exactly one definition, at paren
depth 0, whose value is a literal. Both conditions are load-bearing: a second
definition means a use might be seeing the other one, and a definition nested
inside an
ifarm is conditional. Uses before the definition are left alone — those read the local's zero-initialized value, and substituting there would invent a definition that does not exist.
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:
foldOperandsrefused every fused handler — the hottest shape in the corpus. See the section above; DTM2 and ACCIDENT were lost to it.- Traces with an exit that cannot fall through. A
ret, acall_far, ajmp_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.trimExitdrops such an exit in every arm at once. - No terminator at all.
straightLineProgramonly ended the arena when the trace ended in ajmp; 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. - Branch edges located by name regex.
args === 4plus a list of Jcc names misses every fused and traced twin (cmp_rm8_jz_thas five operands and a name no entry is a prefix of).TAKEN_ATis emit.js's own bookkeeping for this and cannot drift from the handler table. - The arms ran on a different machine. Only
STATEwas 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 —0xF246against0x7246— with nothing to say which field moved.MACHINE_STATEis now shared out ofemit.jsso 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.
The generated module wrote its own state accessors. All four remaining mismatches were this, and it is the one worth reading.
moduleWatbuilt aget_/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 plainglobal.set. So$esbstayed 0 in every generated arm, and every segmented access in a compiled trace addressed0 + offwhile the interpreter it was being compared against addressedbase + 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 throughes:[bx]— with$eaprovably 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 inemit.jsnow and used by both modules, and it exports the six segment bases so a comparison can see derived state at all.paddingwas decided on the bytes.guestBytesreads 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 toreadTrace's cap. CYCLE's 92.5% block is two ops ending inretand was being thrown away. Selection also skips padding whether or not--min-opswas 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
--ops-prefix=Nruns only the first N ops of a trace. A mismatch names a whole trace, which is not a lead; rerunning at N=1,2,3… names the first op whose arms disagree, which is. It is a debugging knob, not a measurement — a prefix is not the hot loop and its timings mean nothing.- An exact
memHash. It sampled every 97th byte. B-STEEL's arms disagreed about the byte at0x1180, which 97 does not land on, so the report printed identical memory beside a register that had just been loaded from it — and sent the investigation looking for a bug in the addressing. It hashes every byte of the first megabyte now, and a mismatch names the first differing address. - Announcing every relaxation.
--min-ops=6used to fall back to the hottest block silently, so a 1-op measurement read in the output exactly like the 6-op one that was asked for. Both fallbacks say so now.
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:
- The back edge is usually not the terminator. This VM compiles through
conditional branches, so for
dec cx; jnz topthe fall-through — the loop exit — is stitched in behind the branch and the block continues; the back edge is a side exit in the middle of the block. A terminator-only classifier scored DTM2 100% and B-STEEL 90.6% straight-line for this reason alone. Every op is scanned now, not just the last. - It still undercounts multi-block loops. Only the block holding the back
edge is marked; the other blocks of the same loop read as
straighteven though they run every iteration.
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:
- Where the loop fits in one block, micro-ops can pay under a plain block-granular install, with no guards and no deopt.
- Where the loop spans blocks — most of the corpus — a block-sized compilation unit cannot hoist anything across the back edge, and getting the micro-op value there requires compiling the whole loop as one unit. That is a region, with a side exit and an entry guard. Which is to say the argument against trace-shaped compilation in toyvm-trace-blocks.md applies to general tracing, not to loop-region compilation, and this is the measurement that separates them.
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:
- 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.
- 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.
- 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:
- The region walk cannot cross
call. CMA_SHRT and CONTAGIO show no loop region and are nonetheless 100% covered by four-to-seven-op blocks ending inret— they are loops whose back edge is in a caller, driving a callee that the profiler sees as a hot straight-line block. A call-free-loop matcher would not fire on them either, so the table and the matcher agree; but read "no region" as "the loop is not visible at this granularity", not "no loop". - DEMO5's zero-exit region is
jmp_spin, a one-op parking loop. That is the spin collapse's territory, already built, and not a compiler beneficiary.
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.
- A fused branch does not start with
j. The classifier tested/^j/.test(op.name), which missescmp_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 above0x1000000. TAKEN_ATindexes the guest ip, not the arena word. It is whatloop-match.jsmatches block ips against; the arena address sits one slot in front of it, and the tail isarenaTaken 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:
- The return-address push is observable guest memory and has to stay. Inlining removes the transfer and the region boundary, not the stack traffic. Programs read that word; several in this corpus are self-modifying and one of them is a depacker.
retconsults the shadow stack, and an inlinedretstill has to check that what it pops is the return address it was inlined against — a guest that rearranged the stack must still leave. That is one compare whose failure path is an exit the region would have had anyway.- Not every callee is a leaf. CORE-ADD, ASYLUM and BRW have zero leaf targets — their callees call further — so those need recursive inlining under a depth budget, or they stay declined. And "leaf" is not "small": CYCLE's is 288 ops and DREAM has one at 216, against B-STEEL's 4/14/4.
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:
- SP balance is a second obligation. No stores means the value at the
return slot is unchanged; it does not mean the
retreads that slot. A callee that leaves SP somewhere else pops a different word. For a callee whose only stack traffic is push/pop that is statically checkable by counting; anything that computes into SP keeps the guard. - A store-bearing callee is not automatically disqualified, it just needs a
real argument rather than a count. In real mode a store's segment is known per
op, and the demo shape is stores through
ESat0xA000against a stack inSS— no overlap, so no aliasing. That is a segment-base comparison at region entry, one check for the whole loop, and it is the same kind of check the shadow stack already makes onCS. Measuring it needs the mode→segment map and is not in this table.
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.
- 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
benchTierscall 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. straightLineProgramterminated tier 0 at anyjmp. The compiled arms are straight lines with nojmpto 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. Ajmpis 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:
- Not the clock.
--irq-every=1bturns interrupts off in both arms and the divergence survives, which is the switch's stated job: it separates "the region ran the wrong code" from "the region moved the clock". - Not the compiler's assumptions about the arena words.
--no-spin,--no-traced,--no-fuseand--keep-arena-operandseach produce the same wrong hash.
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:
- Read the whole ladder from separate invocations. A shell loop that prints
a program's verdict with
printfand no newline silently shows a blank for a run that declined or crashed, and a blank next to a label reads as the previous row's answer. Two rows of the first ACCIDENT ladder were declines, not results, and they inverted the conclusion. tools/toyvmcompiles throughlib/compile-wat.js, which another agent cut over to WATX mid-session (23ed9639,24b79256). Any region measurement taken across that boundary is two different compilers, so re-baseline rather than compare across it.
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:
- unlowered —
(br $out), the region hands$gip = 0x2d6aback and the host decodes the fall-through as an ordinary block; - lowered — the
(else )arm is empty and ops 14–33 run inline.
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%
- interpreter: clean. Zero
arms-disagreeand zeronondeterministicover 199 programs × 4 dispatch shells. Every shell computes the same thing. - micro-ops: clean. Zero
mismatch. The 19branchyare inconclusive by construction — the op list transfers control somewhere other than its own end, so tier 0 takes an edge the straight-line tiers fall through and the arms did not run the same program.no-samplesandpaddingmean no trace was lifted. - The 7
shells:timeoutare higher than the single one on the pre-cutover run, and that run was not sharing the box with a region census. Treat the count as a load artifact until it is reproduced on a quiet machine.
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:
--no-lowerdiverges identically (52101px). The new lowering is not the cause; CARRIE was already broken and the gate was simply declining it.--no-promote,--no-fold-ea,--no-inline-counters,--no-region-code-bitsand--onceall diverge identically too. No knob in the region build moves it.
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:
25 of 103installed regions carry 0.0% share, and that predates all of this. It is the same "no beneficiary" problem, already shipped. A minimum-share floor inpickRegionwould drop them; it would also cut the headline region count by a quarter, which is why it wants to be a measured decision rather than a constant somebody picks. 2026-09-02: that share was a profiler-attribution bug, not a property of the regions — the two biggest JIT wins in the twenty-program set (COMPOVRS +125%, CONTACT +119%) were among the "0.0%" rows. Fixed inpickRegion; the mechanism and the measured revisions are indocs/toyvm-bench-20.md§7. The 25-of-103 figure has to be re-taken withregion-why.jsbefore any floor is discussed.region-census.js's old%column was never a benchmark. It ran--reps=1, and at one rep the interleave-and-rotate inregion-jit.jsnever rotates: the baseline arm always runs first and the region arm always second, min-of-one, on whatever the box is doing. The 2026-08-31 census returned 93 of 93 negative at a median of −58% on a box at load 17–27; that number described the measurement, not the JIT. Fixed 2026-09-01, see below.
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
- Both clocks are
run-dos's slice clocks,guestSecsandguestCpuSecs, bracketing only the guest slices. The%is taken from CPU time: on a loaded box the process waits for a core, and the same guest work has been measured at identical user CPU and three times the wall. A process-wideprocess.cpuUsage()around the whole run was tried first and read DRAGON's region — zero extra handbacks, +8.7% ceiling — as 11% slower, because V8 compiles the region module on background threads and at a 150ms guest run that compile is the size of the work. Scoping to the slices is what turned that into the +2.8% above. - One rep prints
n/a, not a number.--reps=1cannot rotate, so the%(and the censusspeedcell) exists only at--reps=2or more. expectedis the load-free view. A region's whole-program win is capped by its sample share, and inside that share the body runs at the gate's in-isolation tier-3 ratio, soshare × (1 − 1/ratio)is the ceiling on what the timing can show; the handback delta is the JS round trips the region adds and is the usual reason a measurement lands under its ceiling. Neither input moves with load, so this line is comparable across census runs where the%is not.region-census.jsparses it intogate,ceilingand+hbcolumns besidespeed, and intoexpectedin the JSON.
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.