Replicated dispatch (repl_tailcall) for the main emulator

Status: proposal, not started. Nobody owns this yet — claim it on messageboard.txt before touching src/*.wat or lib/compile-wat.js.

Where the number comes from: toyvm-dispatch-shootout.md. That work compared four interpreter dispatch shells in the toy VM (tools/toyvm/), on real DOS demos rather than a synthetic loop. This document is the argument for carrying one of its four results across into the main wine-assembly emulator, and the list of ways that carry could fail.


1. The main emulator is already the shootout's baseline arm

This is the fact that makes the result transferable rather than merely suggestive. The shootout's tailcall arm is: one shared $next, one return_call_indirect site, every handler ends by tail-calling $next.

The main emulator is exactly that construction:

So the two systems differ in ISA, handler count and handler size, but not in dispatch shape. The shootout's other three arms (calls, switch, repl_tailcall) are all describable as edits to this one.

2. What repl_tailcall is

Give every handler its own copy of the dispatch tail instead of tail-calling one shared $next. Nothing else changes: same handler bodies, same table, same threaded-code format, same $steps accounting.

  tailcall                            repl_tailcall
  --------                            -------------
  $th_add_r_i32:                      $th_add_r_i32:
     ...body...                          ...body...
     return_call $next                   steps--; if (<=0) { resume_ip=ip; return }
                                         fn = [ip]; op = [ip+4]; ip += 8
  $next:                                 return_call_indirect fn(op)
     steps--; ...
     fn = [ip]; op = [ip+4]; ip += 8   $th_sub_r_i32:
     return_call_indirect fn(op)          ...body...
                                         steps--; if (<=0) { resume_ip=ip; return }
                                         fn = [ip]; op = [ip+4]; ip += 8
                                         return_call_indirect fn(op)

Why it is supposed to be faster. One shared dispatch site gives the CPU's indirect-branch predictor a single history slot for every opcode transition in the program. Replicating it gives each predecessor opcode its own slot, and in a real instruction stream the next opcode correlates strongly with the current one — push follows push, a compare is followed by a jcc. The shared site throws that correlation away.

Measured in the toy VM: geomean +10.6% over ten DOS programs, ahead on 10 of 10; and +10.5% over the 50 programs of a 94-program sweep that run at least 1M dispatches, with the pixels/no-pixels split at +11.3% / +10.0%. Three independent sweeps gave +9.7%, +9.9%, +10.5%. It is the most stable result in that document — and the one shell the earlier synthetic microbenchmark never tested.

3. Why the transformation is sound here

A handler is already entered by tail call, so replacing its (return_call $next) with a copy of $next's body preserves both exits exactly:

$next itself must stay: $run calls it directly (non-tail) on the resume path, and that call is what makes the whole chain return into $run.

4. A second, independent reason to expect a win

V8's wasm inlining budget already refuses to inline $next (and $g2w, $get_reg) at the hottest call sites — measured at roughly 8% of CPU, with --wasm-inlining-min-budget as the thermometer. See the project_v8_wasm_inlining_budget note.

Replication is exactly that inlining, performed in the source where the engine's growth-factor budget cannot decline it. The two arguments are independent — one is about the branch predictor, one is about the compiler's budget — and they point the same way.

There is a third, and it is the most concrete of the three. tools/wasm-native.js was pointed at this exact function and recorded that $next compiles to 193 native instructions, and opens every dispatch with a frame setup, a stack-limit check and an interrupt check. Those three are the price of $next being a separate function at all. Replication does not optimize them — it deletes them, because there is no longer a function being entered. That is a per-dispatch cost independent of anything the branch predictor does.

5. What could go wrong, and why it must be measured rather than assumed

$next is much fatter here than in the toy VM. The main emulator's version carries, in this order:

  1. the $steps decrement and the $resume_ip escape,
  2. a $fn >= 443 bounds check with a cache-recovery path (0xCAC4BAD0 log, $clear_cache, return),
  3. a $handler_hist_enabled branch,
  4. the load/advance/return_call_indirect.

Replicating all four 406 times trades branch-predictor pressure for instruction cache pressure — the opposite direction from the thing being bought. The toy VM's $next is a fraction of this, so its +10.6% was measured on a thinner tail than a naive port would produce.

But the fat is separable, and that is the whole design. Replicate the fast path; leave the slow paths behind a cold call. Part by part:

part of $next disposition what stays in the replicated tail
1. steps--, resume_ip escape inline ~3 instructions. It cannot be outlined: it is a return, not a call, and the return has to happen in the handler's own frame to land back in $run. Outlining the body would still leave the branch, so there is nothing to win.
2. fn >= 443 recovery split the compare and branch (2 instructions). The 0xCAC4BAD0 log, $clear_cache and the return move into a cold $dispatch_bad. Same trick as above: the branch stays, the body leaves.
3. $handler_hist_enabled remove, not outline nothing. This is a global load plus a branch on every dispatch to serve a debug flag. Build the replicated tails without it and let a --handler-hist build fall back to the shared $next. Zero cost in the shipping build.
4. load / advance / return_call_indirect inline the point of the exercise.

That lands the tail at roughly two loads, an add, a store, three branches and the indirect call — close to the shape the +10.6% was measured on.

The trap in this plan is that the engine may inline the cold callee back in. If V8 decides $dispatch_bad fits its budget, it reappears in all 406 copies and the split silently did nothing. $clear_cache is probably large enough to be refused, but "probably" is not a measurement, and this repo has the tool that settles it: node tools/wasm-native.js --func='$th_add_r_i32' (and --top for a size census) shows the machine code the JIT actually produced. Check two things there before trusting any timing — that the replicated tail is small, and that the cold path stayed out of line. Note the tool is SpiderMonkey Ion, not V8 TurboFan: read it for structure, never for a cycle count attributed to Chrome.

Code growth is not monotonic, and the same document proves it. switch, the other code-growth arm, is bimodal: +40.1% on DSTNFO and −26.0% on COPPER, reproduced across two runs at different box loads. What code growth costs depends on whether that program's hot handlers still fit the engine's budgets, which is a per-program property. Do not assume a single sign.

+10.6% is ns/dispatch, not end-to-end. For calibration, the return_call_indirect change itself measured −22% on Caesar III, −15% on Diablo, −11% on Liquid War and about −2% on StarCraft — the last is host/GDI bound, not interpreter bound. Expect materially less than 10% on a real app, and expect it to vary by app.

Size. ~406 copies of a thin tail is on the order of 20 KB against a 977 KB module — about 2%, which matters for browser load time but not much. A fat tail replicated 406 times is a different conversation; see the staging plan.

6. Suggested plan

Do not hand-edit 406 sites. Make it a source transform in the WAT pipeline — lib/compile-wat.js or tools/concat-wat.js — that rewrites (return_call $next) into the inlined sequence. One build switch, trivially A/B-able, and it keeps the 406 handler definitions readable. A hand-applied version is unmaintainable and un-revertable.

Staged, cheapest experiment first:

  1. Replicate the thin tail only, per the split table in §5: inline the $steps escape, the bounds compare, the load/advance and the return_call_indirect; move the recovery body into a cold $dispatch_bad; drop the histogram branch and let --handler-hist builds use the shared $next. Verify with tools/wasm-native.js that the cold path really stayed out of line before timing anything.
  2. Top-N before all-406. Take the hot handler list from --handler-hist and replicate only those. That captures most of the predictor benefit at almost no icache cost. If the partial build wins and the full build does not, that difference is the answer about which of the two effects dominates — which a single all-or-nothing build cannot tell you.
  3. Then all 406, only if the partial build is positive.

7. How to measure it

Use the protocol that cleared the return_call_indirect change, not a fresh one:

Correctness gate — the same one the tail-call change passed:

8. What this does not affect

The no-tail-call fallback build (iOS Safari, and any engine without the tail call proposal) lowers return_call to call; return. Today that costs two nested frames per dispatch — the handler's and $next's. Replication makes it one. That build gets shallower, not deeper, so this is not a blocker there.

9. 2026-08-31 implementation attempt

Implemented in the isolated worktree /private/tmp/wa-repl-tailcall.

Validation run:

/opt/homebrew/bin/timeout -s KILL 60 node test/test-compile-wat-replicated-dispatch.js
/opt/homebrew/bin/timeout -s KILL 60 node tools/check-handler-count.js
/opt/homebrew/bin/timeout -s KILL 180 node tools/build-compile-wat.js --dispatch=shared --out=build/wine-assembly.shared.wasm --compat-out=build/wine-assembly.shared.compat.wasm
/opt/homebrew/bin/timeout -s KILL 180 node tools/build-compile-wat.js --dispatch=replicated --out=build/wine-assembly.repldispatch.wasm --compat-out=build/wine-assembly.repldispatch.compat.wasm

Artifact sizes:

artifact bytes
shared tail calls 976,543
replicated tail calls 1,004,118
shared compat 976,992
replicated compat 1,004,975

The clean detached source at df4793a6 did not build by itself because current src/13-exports.wat referenced DirectX display getter/setter helpers that were only present in active main-worktree WIP. For this isolated benchmark worktree, src/09a8-handlers-directx.wat carries small compatibility wrappers around the existing globals; that hunk is not part of the dispatch experiment.

Benchmark setup:

Diablo II:

node test/run.js --app=diablo2_demo --no-build --wasm=ARTIFACT \
  --batch-size=1000000 --max-batches=240 --max-seconds=60 \
  --quiet-api --quiet-blocks --no-close --repaint-every=10000
arm user CPU samples, seconds mean speedup
shared 17.02, 17.27, 17.22 17.170 1.00x
replicated 15.33, 15.06, 17.21 15.867 1.08x

Heroes III:

node test/run.js --app=heroes3_demo --screen=800x600 --no-build --wasm=ARTIFACT \
  --batch-size=200000 --thread-slices=1 --tick-ms-per-batch=100 \
  --max-batches=1000 --max-seconds=60 --quiet-api --quiet-blocks \
  --no-close --repaint-every=10000 --dx-surfaces
arm user CPU samples, seconds mean speedup
shared 2.80, 2.61, 2.09 2.500 1.00x
replicated 2.44, 2.41, 1.89 2.247 1.11x

This is a positive result for the two requested games, but it is not yet enough to flip the default build. The Diablo II third replicated sample was noisy, wall time was scheduler-sensitive under load, and Heroes III's startup sample is very short. Next useful step is the top-N build from handler histograms and at least one longer app-window sample with screenshot/trace parity.


Related