Interpreter Dispatch: What Has Actually Been Measured

ASCII TLDR:

$next costs ~23% of a Caesar III gameplay profile. Two generic attempts to make
a dispatch CHEAPER both measured exactly zero:
  - return_call_indirect (constant-stack chains)
  - fast path cut from 4 branches to 1
Identical 518,446,380 handler ops across all three builds, so the A/B was exact.
The cost is the mispredicted call_indirect target itself. You cannot make a
dispatch cheaper -- only make FEWER of them.

BUT: "fewer dispatches" is not automatically "faster". The AoE notes already
measured broad SIB fusion as a net LOSS -- op count fell, wall time rose,
because the fused handler was bigger and slower than the two it replaced.
So handler-op count is a proof of EQUAL WORK, not a proof of SPEED.
Every fusion needs both: an op-count delta AND a time measurement.

Companion docs: wasm-stack-threaded-code.md (the proposal this constrains), aoe-performance-optimization.md (the older experiment table), wasm-engine-support.md (which engines have the instructions these ideas need).

The two rejected generic changes

Measured 2026-08-23 in throwaway worktrees at 0fab7b7d, preserved on branches perf/next-tailcall-dispatch and perf/next-fastpath-branches.

variant what changed n min p25 median max
base 22 4.00 5.09 6.32 8.88
tailcall return_call_indirect in $next 22 3.90 5.06 5.59 8.56
nextcheck 4 dispatch branches → 1 22 3.99 5.12 5.48 8.83

CPU seconds, interleaved samples, Caesar III gameplay. Minima within 2.5% of each other against a per-variant noise band of 4.0–8.9s; the box ran at load 5–45 throughout, which is why the bands are that wide and why only the minima are quoted. No measurable win for either.

  1. return_call_indirect. Handlers already end in return_call $next, so chains were assumed to be pushing frames. They are not: a chain is one x86 basic block, and $steps = 1000 (src/13-exports.wat:179) is a backstop, not a typical depth. Needed opcode 0x13 added to lib/compile-wat.js, which already had return_call 0x12 plus a call;return fallback.
  2. Fast path 4 branches → 1. The fn >= 400 guard moved to $te at emit time, and the handler_hist_enabled check merged into the $steps test via a $next_slow path. Removing two of four branches changed nothing.

Caveat on the second one, if it is ever revived: dropping the runtime fn>=400 guard means post-emit thread-cache corruption traps instead of clearing the cache. Two paths can still reach that state — $run's unconditional arena reset under a nested pump (src/09c3-controls.wat:14964, src/09c9-winhelp-ui.wat:2186), and $te's non-recycling overflow backstop writing into the next thread's arena.

Conclusion: it is neither frame push/pop nor branch count. It is the indirect call — a call_indirect target that changes every op and mispredicts.

The trap: op count is not time

This is the most important thing on this page, and it was learned twice.

--handler-hist-thread=N gives a load-independent count of handler dispatches. That makes it the right primary metric on a busy box, and it is what proves two builds did the same work — the three variants above all ran exactly 518,446,380 ops, which is why a 2.5% timing spread could be called noise with confidence.

But it does not measure speed. From the AoE experiment table:

Revert broad SIB fused handlers. They reduced handler dispatch count, but larger/slower handlers lost more time than dispatch removal saved.

and

Revert br_table register helpers. WASM still needs nested block labels for br_table, and this workload was slower in Chrome. (+0.6%)

So a fusion that cuts op count by 5% can still be a regression. The fused handler must stay small — a fusion that adds branches to cover operand shapes can easily cost more than the dispatch it removed. Narrow, single-shape fusions are the ones worth having; "broad" fusions have already lost once.

Required evidence to keep a fusion: (a) op-count delta from --handler-hist-thread=0, (b) pixel identity via tools/png-diff.js, and (c) a timing measurement on a quiet box. (a) and (b) alone are not enough.

The workload — and why "run caesar3" is not it

Every number on this page comes from one command, the drive-into-the-city sequence test/test-caesar3-gameplay.js runs, with the histogram armed:

node test/run.js --app=caesar3_demo --screen=800x600 --batch-size=50000 \
  --max-batches=3400 --repaint-every=50 --handler-hist-thread=0 \
  --input='700:mousemove:400:300,760:mousedown:400:300,800:mouseup:400:300,1000:mousemove:400:172,1040:mousedown:400:172,1080:mouseup:400:172,1500:mousemove:548:320,1540:mousedown:548:320,1580:mouseup:548:320,2600:mousemove:613:502,2640:mousedown:613:502,2680:mouseup:613:502' \
  --png=out.png

Writing this down is not bookkeeping. --app=caesar3_demo --max-batches=3400 without the input script looks like the same experiment and is not: the game sits on its title screen and dispatches 16,664,328 ops against the gameplay run's ~509M, a 31x difference, and it makes 289 API calls against 23,704. A fusion aimed at the blitters would measure as nearly nothing there. Four separate agents were nearly sent down that path by a baseline command quoted without its input script; the command belongs next to the numbers it produced.

The clicks must be mousedown, a gap of batches, mouseup — Caesar samples the button once per frame, so run.js's click (both events in one batch) is invisible to it. --screen=800x600 matches the DirectDraw exclusive mode so cursor coordinates need no scaling.

It is not "mostly gameplay", and only ~4/5 of it is the interpreter

Two things worth knowing before reading any speedup claim on this page as a speedup of Caesar III.

The workload is not gameplay-dominated. CPU time is close to linear in --max-batches, so prefixes of the run give the phase split directly:

--max-batches user CPU phase reached
1 0.10s process start
700 2.35s title screen
1500 5.22s main menu
2700 8.55s mission briefing
3400 11.16s city view

Startup is ~1%; the rest is roughly title 21% / menu 25% / briefing 29% / city 23%. So a change that only helps the isometric city renderer is being graded on a run that is three-quarters not the city, and a change that helps the menu blitters gets credit that would not survive a longer session. This is the same trap as the missing input script, one level down: the command is right, but its name ("gameplay") oversells what it spends its time on.

~15% of it is CLI-only JS rasterization no dispatch change can touch. node --prof on the same command: 91.8% of ticks are JavaScript, and inside that, wasm accounts for 6350 ticks against 1457 in host JS — wasm is 81.3%. The top non-wasm entries are all lib/raster-canvas.js: drawImage (lib/raster-canvas.js:273) at 11.8% of total ticks, putImageData 2.0%, fillRect 1.4%. That path exists only in the headless CLI; the browser hands those to the real canvas. It is a fixed ~15% tax on every measurement taken this way, and it caps what any dispatch reduction can show: fuse-cmp-jcc's −8.43% op reduction has at most ~85% of the run to act on even before the ops it removes turn out to be the cheap ones.

Corrected 2026-08-23 — the earlier version of this paragraph named the wrong functions. It reported $gl32 (6.2%), $gl16 (5.2%), $gs8 (3.3%) and $th_store8_sib (3.0%) and concluded "memory accessors, not dispatch". Those names came from tools/func-index.js, which was counting 8 wrapped imports as definitions and so resolved every index 8 too low (fixed in 61dbb724; tools/wasm-func-name.js was right all along). Re-taken on the same command:

ticks % of total % of wasm function
1265 14.1% 26.2% $next
848 9.4% 17.6% $run
392 4.4% 8.1% $get_reg
358 4.0% 7.4% $set_reg
281 3.1% 5.8% $g2w
119 1.3% 2.5% $do_alu32
114 1.3% 2.4% $do_alu_sized

Wasm is 4823 of 8981 ticks (53.7%) in this run. The conclusion inverts. Dispatch and the run loop are the top two costs and together are 43.8% of all wasm time; the register file ($get_reg + $set_reg) is another 15.5%, and address translation 5.8%. No handler body is above 2.5%. The cost is not spread across what the handlers do — it is concentrated in getting to them and in reading and writing registers on the way.

This does not resurrect the four dispatch variants: their measured op reductions are still 5–8% against a noise floor of 41.9% on this box (below), and earlier work already measured both tail calls and branch-stripping inside $next at zero — fewer dispatches did not mean faster. It does say the target was right and the attempts were the wrong shape — the 26.2% is the mispredicted indirect call itself, which fewer or cheaper dispatch branches do not remove.

The 361 cap: fusions flatter themselves 2x

Fixed 2026-08-23. $HANDLER_HIST_COUNT is now 512, the matrix is 1MB at 0x04000000 (the unused gap under THREAD_CACHE_BASEnot 0x08000000, which is $VIRTUAL_BACKING_BASE, which is in turn why tools/wat-memory-map.js stops scanning at that address), and tools/check-handler-count.js now fails the build if the handler table outgrows either the matrix side or the 1024-slot per-handler array. Verified on heroes2_demo: the top two pairs in that workload, H389->H148 (6.22%) and H406->H389 (6.20%), were both invisible before the fix — the table read to choose fusions was missing its own largest entries. Any fusion-candidate list taken before this date should be re-taken. The rest of this section is what the bug was, kept because the 2x inflation is baked into numbers already recorded below.

$HANDLER_HIST_COUNT was 361 — it is the side of the dense count x count pair matrix, not the number of per-handler counters (HANDLER_HIST_COUNTS holds 1024). Every fused superinstruction is numbered above 361.

If the histogram total sums only count slots, a fused pair removes two dispatches from the printed total — the two originals — while the one replacement dispatch lands in an invisible slot. So the printed drop is exactly twice the real one.

test/run.js:3532 now sums get_handler_hist_slots() (all 1024) when the export exists, falling back to count when it does not. That export landed after 0fab7b7d, so any measurement taken against a build at or before that commit is inflated 2x and must be halved or re-run. Check for the export before trusting a fusion number, and treat a reduction that looks suspiciously close to 2 x (pair count) / total as the tell.

The true baseline is 520,284,956, not 518,446,380. Two agents derived it independently, from different experiments, and agreed to the digit; the 1,838,576 difference is handlers 361–399, which the old reader never counted. Use 520,284,956 for any new comparison. The 518,446,380 figure is kept in the tables on this page because the numbers recorded against it were taken with the same blind spot, and rescaling them after the fact would be inventing precision.

For the same reason the 518,446,380 baseline above is visible ops, not all ops: handlers such as $th_load32_sib (389) and $th_lea_sib_pair (390) already existed above the cap and were never in it. That does not weaken the three-way A/B — all three builds had the identical blind spot and the identical total — but it is not a whole-program op count.

Re-measured against main @ eb080a99

All four experiments were rebased onto current main and re-run with the fixed 512-wide histogram and the workload above, so these supersede every number further down this page. Baseline: 509,494,254 dispatches. (Not 518,446,380 and not 520,284,956 — both predate six commits of main, including ~460 lines of 07-decoder.wat and ten new fused handlers at ids 400-409, which is most of the difference. Three independent captures of the rebased baseline agreed to the digit.)

branch                          total          delta          op delta
main @ eb080a99             509,494,254            —             —
perf/fuse-cmp-jcc           466,521,428  -42,972,826        -8.43%
perf/fuse-sib-store         483,528,414  -25,965,840        -5.10%
perf/reg-specialised-...    509,494,254            0          0.00%
perf/reg-file-in-memory     509,494,254            0          0.00%

Every branch is pixel-identical to the baseline capture (0 of 480000 pixels differ) and passes test-x86-ops, test-shift-equivalence, test-wat-decoder-runaway, test-win16-exec and test-caesar3-gameplay.

The two zeros are not failures, they are the metric being the wrong instrument. Both register experiments move work inside a dispatch — specialising a handler per register, or moving the register file into linear memory — and remove no dispatches at all, so op count is structurally blind to them and can neither confirm nor refute either. Both are decidable only by a timing run on a quiet box; the register-file branch additionally carries +4.73% code size (860,196 → 900,879 bytes) into a loop that already thrashes icache.

The two fusions compose rather than compete: the Jcc fusion's three handlers kept their exact pre-rebase counts after main's own 404/407 fusions landed (different if arms of the same decoder decision — mod=3 register forms versus memory forms, mutually exclusive by construction), and every handler not involved in either fusion has a bit-identical count across all captures.

None of the four is merge-ready. Op count proves equal work, never speed, and this box sat at load 250-360 throughout, so no timing was taken.

Result: fusing compute_ea_sib into store32

Measured 2026-08-23, branch perf/fuse-sib-store. Re-measured after rebase: -25,965,840 of 509,494,254 = -5.10% — the same absolute reduction as below, against the corrected baseline. Handler id is now 410, and the handler was rewritten onto main's $sib_ea helper and reports itself to the SIB consumer histogram, matching the shape of main's 400-402. New handler $th_store32_sib, emitted by $emit_store32 for 32-bit non-absolute EAs; absolute and 16-bit segmented forms keep the generic two-dispatch encoding. The EA math is fully general (base/index/scale/disp, 0xF = absent) — no register specialisation. $emit_store32 has one call site (0x89 MOV r/m32, r32), so the blast radius is that one opcode form. It mirrors the load side, $th_load32_sib (389), which already existed.

printed total   518,446,380 -> 466,514,700   = -10.02%   <- DO NOT USE
real reduction   25,965,840 removed          = -5.01%    <- the honest number
true post-change ~492,480,540 (466,514,700 visible + 25,965,840 in H400)

The 2x gap is the 361 cap described above; the worktree branched at 0fab7b7d, before get_handler_hist_slots existed. The arithmetic closes to the unit: baseline SIB consumers 27,673,792 → 1,707,952 (store32 gone from that table entirely) leaves exactly 25,965,840, which is exactly the baseline top pair H149->H21; and 518,446,380 - 2 x 25,965,840 = 466,514,700, the observed total.

Pixel-identical: 0 of 480000 pixels differ, max channel delta 0. Build clean (handler table=401 elem entries=401 cache guard=401), test-x86-ops 69/69, test-shift-equivalence 29376 combos, test-win16-exec 58/58, test-caesar3-gameplay PASS with numbers identical to baseline.

The whole H149 <-> H21 <-> H345 triangle leaves the pair list. What remains of the SIB table is 1,707,952 EAs led by $th_mov_m32_i32 (692,183) and $th_movsx16 (~864k across three forms) — together ~0.3% of dispatches, so a second fused handler each would buy roughly 0.2%. Deliberately left generic.

Still needs a timing run per the rule above: -5.01% of dispatches is a work reduction, and the AoE revert is the standing warning that a bigger handler can give it back.

Result: fusing flag producers with the Jcc that reads them

Measured 2026-08-23, branch perf/fuse-cmp-jcc. Re-measured after rebase: 466,521,428 vs 509,494,254 = -42,972,826 = -8.43%, with handler ids 410-412. The delta equals the three fused handlers' hit counts summed, to the unit, and every uninvolved handler's count is bit-identical across the two runs — a stronger identity check than the sum arithmetic used below. Three fused handlers — $th_cmp_r_i32_jcc, $th_alu_r8_i8_jcc, $th_alu_r16_i16_jcc — plus decoder lookahead ($jcc_lookahead_cc) that folds a following Jcc into the producer at emit time.

printed total   518,446,380 -> 432,500,728   = -16.6%   <- DO NOT USE
real reduction   42,972,826 fused dispatches = -8.26%   <- the honest number
true totals     ~520,284,956 -> 477,312,130

Same 2x inflation as the SIB result, same cause (the worktree predates the HANDLER_HIST_COUNT fix). The <361 accounting fell by exactly 2 x the fused count, which independently proves the emulated instruction stream is unchanged: every fused dispatch replaced precisely two. All three named pairs (H154->H311, H10->H321, H207->H311) leave the top-pairs list.

Pixel-identical (0 of 480000, byte-identical files); test-x86-ops 80/80 (69 + 11 new directed fused-pair flag cases); shift-equivalence, decoder-runaway, win16-exec, caesar3-gameplay, notepad-dialogs 5/5 all green.

The fused handlers still publish the lazy-flag state. They call the same $exec_* body the unfused handler calls, so the two paths cannot drift. Skipping the flag store was considered and rejected: EFLAGS is architectural state, not a private channel to the next instruction (cmp + two branches, SETcc, ADC/SBB, PUSHF, a fault with EFLAGS live into an SEH handler are all ordinary), and the analysis is structurally unavailable anyway — the fused handler ends the block at the folded Jcc, and successors are decoded as independent blocks keyed by entry EIP, so no single-block reasoning can prove who reads the flags next. Fuse the dispatch, keep the flags.

Still needs a timing run. -8.26% of dispatches is a work reduction; the AoE revert remains the standing warning.

Result: register file in linear memory (built, unmeasured)

Branch perf/reg-file-in-memory. Re-measured after rebase: 509,494,254, identical to main to the digit, with per-handler histogram bodies identical line for line. The transform is re-run over the tree after every merge rather than merged by hand, which is safe because lib/compile-wat.js hard-fails on an unknown global (unlike an unknown function, which only warns) — a site the transform misses cannot compile. Verified with a negative control: (global.set $eax …) injected into 06-fpu.wat gives Error: compile-wat: unknown global: $eax.

All 12,813 direct global.get/set $eax..$edi sites plus the 325 $get_reg/$set_reg calls rewritten to i32.load/store offset=N (global.get $reg_base), per-thread partitioned at REGFILE_BASE + tid*64 (a full cache line, so no false sharing). Execution-identical: 518,446,380 dispatches unchanged, PNG byte-identical, plus five real worker-thread tests including Winamp's MP3 decoder — which runs guest x86 on workers and would have shredded instantly on a bad partition.

This measured distribution contradicts the static-count argument below. Of the ~328 indirect sites, 96% are in the interpreter inner loop; of the 12,734 direct sites, 93% are in API-handler code that runs once per Win32 call, and 7,816 of those are $esp (the stdcall arg-read/epilogue idiom) with 4,283 more $eax (return value) — dynamically near-free. So the tax lands on cold code and the win lands on hot code, which is the opposite of what the site counts suggest. Counterweights: code size +4.7% (856,950 → 897,541 bytes) on a loop that already thrashes icache, and global.get $reg_base is only CSE-able across call-free regions, which the interpreter does not have. Unmeasured for speed.

One caveat with no equivalent under globals: registers now live at a linear-memory address inside g2w's direct guest window, so a wild guest pointer can scribble on them. The guest stack and the GDI tables already carry that exposure, so it is consistent with the layout rather than new in kind — but it is strictly worse than per-instance globals.

Result: register-specialised hot handlers (built, unmeasurable by op count)

Branch perf/reg-specialised-handlers, ids 410-449. Forty handlers: eight register variants each of add_r_i32, sub_r_i32, cmp_r_i32, load32 and alu_r16_i16, so the hot ones read and write a fixed register instead of calling $get_reg/$set_reg. Op count is exactly unchanged (509,494,254) — the change moves traffic between handlers and removes no dispatches — and each family sums to its generic predecessor's count to within the print cutoff:

family            specialised     generic on main
add   410-417      29,470,685     H3   29,470,685
sub   418-425      12,400,769     H8   12,400,769
cmp   426-433      18,806,152     H10  18,806,717
load32 434-441     12,820,153     H20  12,820,376
alu16 442-449      26,573,690     H207 26,573,690

That removes 141.9M-195.1M dynamic get_reg/set_reg calls (the range is the CMP-vs-non-CMP split in the alu16 family, still unmeasured). Static call counts are unchanged — the win is reachability, not text. It composes with main's own run handlers (405/406/408), which take their bite first at the $emit_load32 tail: they had already absorbed 11% of load32 traffic (14,414,944 → 12,820,376) independently of this change, and there is no further loss from stacking.

The load32 family is the weakest of the five — 12.8M over eight handlers, with only five registers carrying real traffic (ecx 5.07M, edx 4.17M, eax 3.39M) — and is the block to trim if handler count matters.

Levers that remain

The 512-wide histogram moved the cut line, and the three candidates it exposed were all invisible under the old 361 cap or below the top-24 print slice:

  1. H344/H345 $th_load32_ro_base_{ebp,esi} — 21.2M + 30.8M = 52.0M dispatches, each still calling $set_reg for its destination. That is the largest single remaining set_reg source in the profile, larger than any family specialised so far. Full specialisation is 8 dst x N bases, but the pair data (H21->H345 23.9M, H345->H149 25.6M) says a handful of (dst, base) combinations carry nearly all of it.
  2. H154 $th_alu_r8_i8 — 17,236,577 (3.38%), routed through $get_reg8/$set_reg8, which are built on $get_reg/$set_reg. Bigger than both the sub (12.4M) and load32 (12.8M) families that were specialised.
  3. The 16-bit cluster H166 12.75M / H165 12.18M / H206 8.76M / H210 8.76M / H193 8.76M, all get_reg16/set_reg16 users and tightly chained (H193->H206->H165, H166->H442->H311).

The table below is the older ranking, kept for the pair data.

Ordered by measured promise, from the Caesar III gameplay histogram (518,446,380 dispatches):

top pairs (fusion candidates)
  H149 $th_compute_ea_sib -> H21  $th_store32                25,965,840  6.13%
  H345 $th_load32_ro_base_esi -> H149 $th_compute_ea_sib     25,623,874  6.05%
  H21  $th_store32 -> H345 $th_load32_ro_base_esi            23,936,067  5.65%
  H154 $th_alu_r8_i8 -> H311 $th_jcc_z                       12,812,297  3.02%
  H165 $th_mov_m16_r16_ro -> H3 $th_add_r_i32                11,976,227  2.83%
  H10  $th_cmp_r_i32 -> H321 $th_jcc_le                      11,960,026  2.82%
  H3   $th_add_r_i32 -> H8 $th_sub_r_i32                     10,856,017  2.56%
  H166 $th_mov_r16_m16_ro -> H207 $th_alu_r16_i16             9,051,072  2.14%
  H207 $th_alu_r16_i16 -> H311 $th_jcc_z                      9,051,072  2.14%

top SIB consumers (recorded 27,673,792, collisions 0)
  H21 $th_store32     op=0x0 [edi+edx*1+disp]  25,734,930  92.99%
  H76 $th_mov_m32_i32 op=0x0 [edx+eax*4+disp]     692,183   2.50%
  H81 $th_movsx16     op=0x2 [none+ecx*2+disp]    507,855   1.84%

top handlers
  H21  $th_store32            33,042,320  6.37%
  H345 $th_load32_ro_base_esi 30,822,447  5.95%
  H3   $th_add_r_i32          29,470,685  5.68%
  H149 $th_compute_ea_sib     27,673,792  5.34%
  H207 $th_alu_r16_i16        26,573,690  5.13%
  H311 $th_jcc_z              25,222,665  4.87%
  H344 $th_load32_ro_base_ebp 21,227,657  4.09%
  H43  $th_jmp                20,521,673  3.96%
  H10  $th_cmp_r_i32          18,806,717  3.63%
  H154 $th_alu_r8_i8          17,236,577  3.32%

Note how concentrated the SIB shape is: 93% of all SIB effective addresses in this workload are one form ([edi+edx*1+disp] feeding store32). That is the argument for a narrow fusion over the broad one AoE rejected.

get_reg/set_reg are ~10% of the profile. Making them cheaper means a memory-backed register file. The argument below is the prior that the built branch above refuted — it reasons from static site counts, and the dynamic distribution puts the tax on cold code and the win on hot code. Kept because the counterweights it names (the extra dependent load, the AoE br_table data point) are still real. There are 12,734 direct global.get/set $eax..$edi sites in src/ against only 230 call $get_reg + 98 call $set_reg. global.get $eax is one load from the instance struct; i32.load offset=N (global.get $reg_base) is two dependent loads. The ~328 indirect sites get cheaper and the ~12,734 direct ones get dearer, so the net sign is genuinely uncertain and probably negative. The AoE br_table register helpers row (+0.6%) is the closest prior data point and it lost.

Whatever such a register file looks like, it must be per-thread partitioned. src/01-header.wat:791 is (import "host" "memory" (memory 8192 8192 shared)) — worker threads are separate instances over one shared linear memory. Mutable globals are per-instance, which is exactly why registers work per-thread today. A register array at a fixed address would give every thread one register file. Follow the $THREAD_BASE = 0x05000000 + tid*0x400000 pattern, via a per-instance base global.

Timing: attempted four ways, resolved nothing (tools/ab-time.js)

All four experiments were blocked on "needs a timing run". Four passes were made over the five worktrees (main, jccfuse, sibfuse, regspec, regarray), each interleaved, each quoting minima. None of them resolved a difference, and the failures are worth recording because each one looked like a result.

pass metric result
1 (n=6, load 4-7) wall clock main alone spanned 9094-13410ms — a 47% spread on one binary, larger than any between-variant gap
2 (n=10, load 4-15) wall clock, minima jccfuse −0.09%, sibfuse +1.51%, regspec +7.74%, regarray −0.08% — all inside noise
3 (n=8, load 4-8) CPU time, fixed order jccfuse −6.6%, sibfuse −7.3%, regspec −7.1%, regarray −11.3%
4 (n=10, load 4-9) CPU time, rotated order jccfuse +3.3%, sibfuse +17.5%, regspec −0.9%, regarray −8.4%

Pass 3 is the instructive one. Every variant beat main by 6-11%, including regarray, whose dispatch count is bit-identical to main's — that is not four speedups, it is main always running in slot 1 of the round and paying for the previous round's tail. Interleaving removes drift between variants but not between positions; ab-time.js now rotates the order each round. Pass 4, same binaries, flips sibfuse from −7.3% to +17.5%.

The CPU-time switch also did not buy what it promised: /usr/bin/time user+sys tracked elapsed almost exactly (13240c/13899w), because SMT contention and frequency scaling are billed to the process. Measured noise floor (worst min→median spread) was 24.5% in pass 3 and 41.9% in pass 4, against effects of 5-8%.

Conclusion: this box cannot time these changes, and no amount of statistics fixes that — the box has run at load 4-30 with several agent sessions sweeping the corpus for the entire investigation. Do not merge any of the four on the strength of a timing run taken here. What would settle it: a quiet machine, or an in-process metric that is not wall-clock (a cycle counter around $run, or --cpu-prof self-time on the interpreter functions specifically, which is how opus5-rct got a defensible 64.5% figure for $invalidate_page).

Note also that the biggest measured interpreter win found during this work came from none of these: it was $invalidate_page's O(CACHE_SIZE) sweep per guest write, 64.5% of total CPU on RollerCoaster Tycoon, fixed by per-page version counters for a 5.4x wall-clock win with a bit-identical dispatch count (opus5-rct). A dispatch-count investigation is structurally blind to that class of bug — the cost sat entirely in the store path, not in a handler, and the histogram this page is built on only counts handlers.

Two corollaries from that work worth carrying here:

Method notes