Superinstructions in the toy VM's threaded code

The shape of the problem

Threaded code pays for every operation twice: once to do the work, and once to get to it. $next is five steps — charge a step, test the halt flag, load the handler index, advance the thread pointer, indirect-call — and tools/wasm-native.js puts the compiled form at 193 instructions with a frame setup, a stack-limit check and an interrupt check at the top of each one. The handler body underneath is frequently smaller than that.

The classic answer is a superinstruction: take two ops that occur adjacently, generate one handler that is both bodies, and pay the dispatch once. The question is only which two, and that is a measurement rather than a guess.

Which pair, and how much of the corpus it is

Handler-pair census over the ten programs in bench-set-core10.txt, 8M dispatches each, counts exact (they do not depend on box load):

node tools/toyvm/run-dos.js <exe> --dispatches=8m --handler-hist=999 --handler-pairs=999

An ALU op immediately followed by a Jcc is 10,323,039 of 80,000,000 dispatches — 12.9%. It is also concentrated: 204 distinct pairs exist, but

pairs share of the fusable work
top 10 63.9%
top 20 77.7%
top 40 90.2%
top 80 99.1%

and two pairs alone (cmp_ri8 -> jz, cmp_rm8 -> jz) are 6.0% of everything the corpus runs.

The other number that came out of the same census is the one that ruled the alternatives out. Summing the block-terminating handlers against all handler entries gives 3.25 dispatches per block transfer on DTM2. That kills any design that adds an op per block: a block-head "charge the whole block's steps at once" op, which would have removed the per-op step decrement, costs one dispatch per 3.25 to save 3.25 decrements, and a dispatch is not remotely as cheap as a decrement. It is also why fusing inside the block is worth doing at all — at 3.25 ops per block, nearly every op is next to a branch.

The fusion is body concatenation, and that is why it is safe

Every handler reads its operands with ops(n), which loads them at offsets off $ip and then advances $ip past them. So two bodies in sequence read two operand lists in sequence, correctly, with no re-layout, no operand renumbering and no new code:

h(`${alu}_j${cc}`, a.args + j.args, `${a.body} ...charge... ${j.body}`);

The arena loses exactly one word — the Jcc's opcode — and the compiler's whole edit is to overwrite the first op's index and splice that word out.

genFusedBranches in emit.js builds these for the census's top twelve first ops crossed with all sixteen conditions: 192 handlers, taking the table from 728 to 920. Twelve is where the census's tail stops paying; they are 88.5% of all fusable pairs.

What is not fused, and how that is enforced. A fused first half must run to its end every time: anything that can hand back, fault or return early would leave $ip parked between the two operand lists, and the resume would read the branch's operands as an instruction. popf is the case that matters — it is 19th on the census but ends the block when it raises TF. The generator asserts on it rather than trusting this paragraph: a first op whose body mentions $halt, (return), $fault or $jlook is a build error.

Step parity, and why it makes the corpus diff a real check

A fused pair is one dispatch where there were two, so it would naturally charge one step where two were charged. That would be a retiming: $steps decides where a slice ends, a slice boundary is where interrupts are injected, and the guest would take its interrupts at different instructions. Every frame in the corpus would legitimately change, and the run would be un-verifiable — exactly the situation that hid the COMPOVRS divergence in toyvm-decoder-in-wasm.md for a whole afternoon.

So the fused handler charges the step its removed dispatch used to, inline:

(global.set $steps (i32.sub (global.get $steps) (i32.const 1)))

One sub against a saved dispatch, and in exchange a fused run and an unfused run must be bit-identical. That is not a nice-to-have; it converts the entire 146-program corpus into a regression test for the transformation, at the level of the frame hash rather than "it still runs".

--no-fuse is the A/B partner.

Measured

Fusion is on by default. All figures below are from the same build.

Dispatch reduction (exact, load-independent). DTM2, 8M billed dispatches: handler entries fall from 7,994,552 to 6,032,180 — 24.5% fewer trips through $next — over 25 distinct fused handlers, led by cmp_rm8_jz at 16.4% of what remains and sbb_ri16_jb at 11.8%. DTM2 is branch-dense and well above the 12.9% corpus average; it is the upper end, not the typical case.

Equivalence. --no-fuse against the default over the whole demo corpus, 8M dispatches each, comparing handbacks, interrupts, traces, the frame hash and the stopping cs:ip. Over 177 programs: 102 bit-identical, 71 differing only in the arena footprint — which shrinks by a word per fused block, DTM2 30KB → 29KB — and 4 differing additionally in compile and recycle counts (CONTAGIO, AQUAPHOB, COUNTDWN, ZOKDTPLN). All four are programs that recycle the arena, and a smaller footprint moves the recycle boundary, so a slightly different set of blocks gets recompiled. Their frame hashes, pixel counts, interrupt counts and stopping cs:ip are identical, which is the part step parity was for. No program's output changed.

Throughput. tools/bench-dos.js now A/Bs a compiler switch the way it A/Bs a dispatch shell (--variants=tailcall,tailcall+nofuse) — arms interleaved rep by rep with the starting arm rotated, minimum of five, and the frame hash and dispatch count checked to agree across arms before a ratio is printed. Core ten, 20M dispatches, box at load 8 either side, per-arm spread 1–12%:

program fused unfused fused is
DTM2 8.69 ns/disp 10.81 +24.4%
CYCLE 8.44 10.27 +21.7%
B-STEEL 10.60 11.98 +13.0%
RUNDEMO 12.16 13.11 +7.8%
ACCIDENT 12.61 13.43 +6.5%
DHADREN 13.50 14.31 +6.0%
CMA_SHRT 15.19 15.79 +4.0%
DEMO5 10.08 10.25 +1.7%
BRW 13.63 13.77 +1.0%
CONTAGIO 27.74 27.79 +0.2%

Geomean +8.3%, and fused won on 10 of 10. Since the two arms retire the same number of billed dispatches by construction, ns/dispatch is directly comparable and this ratio is the whole effect.

CONTAGIO's 0.2% is the result that confirms the model rather than the one that disappoints it: it is the compile-bound program of the set (913,084 handbacks over 20M dispatches, 27.7 ns/dispatch against DTM2's 8.7), so almost none of its wall clock is dispatch and there is nothing here for a dispatch optimisation to take. It is the program the decoder work was for.

What this does not do

It removes a dispatch. It did not remove the flag write — that is toyvm-dead-flags.md, and a fused pair turns out to be the enabler for it rather than a beneficiary: it reads the record its own half just made and never the flags it was entered with, which is what makes the arithmetic in front of it provably dead. Within the pair itself, the fused body still computes and stores $flags in its first half and reads a bit back out of it in its second, because a later adc, sbb, pushf or second Jcc may read those flags and nothing here proves they are dead. Eliding that is the lazy-flag change, and a fused pair is the natural place to do it — the producer and the consumer are now inside one handler, where a liveness question that is hard across a dispatch becomes local. That is the next thing worth pricing.

Priced, in toyvm-lazy-flags.md: deferring the flags is a wash on its own, and the reason is this page. Fusion and laziness are substitutes for the compare/branch pair rather than complements — eager computes six flags and the branch reads one bit, lazy stores six globals and the branch computes one bit, and the work comes out the same.

The third option is the one that pays, and it is now in. A fused handler knows its flag producer at generation time, so its branch does not need a general getter that asks $fop which rule is pending: cmp_ri8_jz reads ZF as an i32.eqz of the recorded result, with no dispatch and no flag word, while the record keeps the other five flags available to a later reader. +1.7% by minimum, ahead on 9 of 10 programs, 177/177 corpus-identical.

It also does not touch the block transfer, which at 3.25 ops per block is a large share of the remaining cost. The trace-JIT answer there is to compile through a conditional branch, emitting the taken edge as a side exit and letting the not-taken path fall through inline, so the common direction pays no transfer at all. That is in, and it is written up in toyvm-trace-blocks.md — including why it keeps the same dispatch count, and so keeps the corpus diff usable, when tracing through an unconditional jmp would not.

And the census that justified fusion turned out to have a second answer in it. Read the pair table for what is followed by itself 100% of the time and the top row stops being a fusion candidate and starts being a program waiting: one op branching to its own head, 4-58% of the dispatches in four of the core ten. Those are not fused, they are not run at all — toyvm-spin-loops.md.

The one that did not work is worth reading too. A handler reaches a guest register through a br_table on an index the compiler itself wrote into the arena, for 36-72% of the dispatches in that same set, and eight twins per handler pin it to a literal. It is correct — 199/199 of the corpus is bit-identical with it on — and it makes the VM no faster, which is the whole of toyvm-reg-specialization.md: a negative result, the four noisy measurements that had to be thrown away before it was legible, and why it ships off.