Pinning the register a handler reaches for

The register file is reached through a jump table

A guest register lives in a wasm global. A handler gets at one by calling $rget16 / $rset8 / … with an index, and those helpers are a br_table over eight arms — the same shape $get_reg uses in src/03-registers.wat.

When the index is a literal, that whole apparatus folds: the engine inlines the helper and the br_table on a constant becomes one global.get. When the index comes out of the arena — (local.get $t0), loaded from the operand stream — it cannot fold anything. Every register access is a call and an indirect branch on a value the compiler is not allowed to know.

And it does know it. The index is a word this compiler wrote into the arena. It was a constant the whole time; it was just spelled as a load.

tools/toyvm/reg-index-census.js measures how much of a run that costs, by classifying every handler body and weighting it by --handler-hist entries:

program dispatches specializable index packed / multi-operand literal index only no register access
DHADREN 6,977,694 68.4% 11.4% 4.5% 15.6%
DTM2 5,757,220 72.0% 8.1% 6.1% 13.7%
ACCIDENT 5,590,841 53.8% 20.0% 5.2% 21.1%
CONTAGIO 7,020,122 47.0% 16.2% 3.9% 32.9%
CYCLE 6,603,659 46.9% 35.1% 3.7% 14.4%
CMA_SHRT 5,095,476 49.9% 1.1% 46.0% 3.0%
RUNDEMO 183,550 48.8% 2.1% 4.9% 44.2%
BRW 810,954 46.9% 24.0% 2.9% 26.1%
DEMO5 7,338,569 42.4% 13.7% 3.6% 40.3%
B-STEEL 7,281,257 36.4% 11.5% 11.6% 40.4%

What it does

Eight twins per handler, each the original body with the index expression replaced by a literal, and the compiler stores the one whose literal is the value the handler was going to compute.

The safety argument is one sentence. A twin is the original body with an expression replaced by the value that expression was going to evaluate to, so it cannot mean anything different. Same handler, same arity, same operand still sitting in the arena, same dispatch, same $steps. The corpus diff therefore stays a strict check rather than a smoke test — see Validation.

That leaves exactly one place for the risk to live: whether the JS that recomputes the index agrees with the WAT that used to compute it. So the index expression is not matched against a list of known shapes. It is resolved — backwards through the local that holds it, to the arena word it came from, through a four-operator subset (and / or / shl / shr_u against a constant). Anything outside that subset declines the handler, and declining is always safe.

Resolution is what makes the memory forms work at all. mov_rm16's index is (local.get $t6), and $t6 is not an operand — EA_SETUP_PRE computes it as (t0 >> 8) & 7. The resolver walks that back to operand 0 with the program [shr_u 8, and 7], which the compiler then runs over the same word. A fused body needs the same care for a different reason: it is two bodies concatenated, so it has two operand preambles and $t0 names a different arena word in each half. Definitions therefore carry a position and the resolver asks for the latest one before the use.

The table is hard-capped, so the set is a budget

--handler-hist's pair census is HIST_SLOTS² words of the VM's own linear memory — 16MB at 2048 slots, and MEM_PAGES is derived from it. Passing 2048 handlers quadruples a table every run allocates, whether or not anyone asked for a census. With 1581 handlers built, 467 entries are free: 58 handlers at eight twins each.

That constraint turned out not to bite, because the distribution is steep. Ranking every eligible handler by its mean share of dispatches across the core-ten set:

   1  mov_rm16                 4.34%   cumulative  4.3%
   2  cmp_ri8_jz               4.24%   cumulative  8.6%
   3  cmp_rm8_jnz_t            3.67%   cumulative 12.2%
   4  push_r16                 2.97%   cumulative 15.2%
   5  mov_rm8                  2.76%   cumulative 18.0%
  ...
  58  imul_r16_nf              0.15%   cumulative 46.6%   <-- budget
 245  (everything eligible)                        51.3%

58 handlers buy 46.6% of all dispatches; all 245 buy 51.3%. The tail is worth 4.7% between it, so the cap costs almost nothing. The list lives in tools/toyvm/reg-spec-set.js with the command that produced it, and a name in it that no longer exists in the handler table is a build error rather than a silent skip — a rename means the ranking was taken on a different table.

Where it sits in the pipeline

Last. Fusion, trace formation, spin collapse and the flag pass each swap a handler for a different one; this pins whichever handler ends up there, and nothing looks a handler up afterwards. That ordering is also why the set contains dec_r16_nf and not dec_r16: by the time this runs, the flagless twin is what is in the arena.

Measured: it does not pay, and it is off

This is a negative result. The transformation is correct, it removes the work it claims to remove, and it makes the VM no faster — the cleanest readings say slightly slower.

Two independent runs, tailcall against tailcall+regspec, 15 interleaved reps per arm, 8M dispatches, --cpu-time (box at load 11.7 and 13.5):

run DTM2 ACCIDENT geomean
1 −3.2% min / −2.6% paired −2.0% min / −1.8% paired −2.6% min / −2.2% paired
2 −2.4% min / −1.4% paired

Negative is the specialized arm being slower. The two runs agree on sign and roughly on size, which nothing earlier in the session did.

Getting to that took discarding four earlier measurements, and how they failed is the useful part. The first A/B (7 reps, 4M dispatches) said the unspecialized arm was 10.4% faster; a 21-rep run on the same program said +4.8% on min and −3.0% on paired — the two estimators disagreeing in sign on one program. Across the day the readings spanned −3.0% to +10.4%, and they collapsed toward zero exactly as the configurations got cleaner (more reps, more dispatches, fewer handbacks). A large number from a noisy configuration is not a large effect.

Two things made the last pair readable. --cpu-time, because wall clock at load 12 counts every other process on the box. And picking programs with few handbacks — DTM2 hands back 495 times in 8M dispatches, so almost nothing but the interpreter is being timed, while DEMO5 hands back 43,252 times and dilutes the signal with host work.

tools/toyvm/trace-jit.js was the other instrument tried and it cannot serve here: 60 of 60 programs decline as padding, and DTM2's real trace declines as unfoldable because tier 1 does not know the traced twins. The --passes= knob added for this (--passes=regfold prices register-file folding alone, against a tier 2 that normally runs three passes at once) is still the right instrument for the question and is left in place for a corpus where it applies.

Why it might be slower

The likeliest mechanism is the one this project already has a negative result about. $next dispatches through one indirect call, and its predictor sees the whole arena's traffic. Before, every mov_rm16 in a program was one target; now it is eight. The specialized handler is cheaper, and the branch that reaches it is less predictable — and the dispatch work already found that the mispredicted indirect call is the cost that matters and that removing work from around it does not necessarily help.

Note what this is not: both arms run the identical wasm module. Specialization is a choice about what the compiler writes into the arena, not a build option, so module size, function count and compile time are held equal by construction. Whatever the difference is, it is about which handlers execute.

So it is off by default, and so is the generation

--reg-spec turns it on; nothing turns it on by itself. The gate covers the generation of the twins as well as their use, which matters more than it sounds: 464 twins take the handler table from 1581 to 2045, and the ceiling is 2048 — because --handler-hist's pair census is HIST_SLOTS² words of the VM's own linear memory and MEM_PAGES is derived from it. Leaving three free entries would mean the next person to add an opcode fails the build, which is not a state to leave a table in on behalf of a change that buys nothing.

Validation

What is next