Semantic expression fusion benchmark brief
Question
Can a stack or register interpreter profitably lower short arithmetic sequences to expression dataflow, instead of dispatching one handler per guest opcode?
The transformation is:
guest stack/register operations semantic expression
FLD a STORE d
FMUL b |
FADD c ADD
FSTP d / \
MUL LOAD c
/ \
LOAD a LOAD b
The exact arm must retain the source program's evaluation order. It may remove temporary stack movement and intermediate architectural-state writes, but it must not reassociate arithmetic or introduce an FMA.
Representations to compare
Use one common seeded state and checksum every observable result.
- Opcode handlers: one indirect dispatch per guest operation; architectural stack/register state is read and written by each handler.
- Micro-op VM: enter once, then one
switch/br_tablecase per semantic operation; values may use locals, but there is still per-operation dispatch. - Fused descriptor: one dispatch selects a parameterized expression shape; the complete formula runs straight-line using locals.
- Straight region: a specialized straight-line function for the expression, with architectural state materialized only at entry, exit and side exits.
- Direct-memory control: straight-line expression, but temporary stack slots stay in interpreter memory. This isolates the value of locals from the value of eliminating dispatch.
Primitive vocabulary
Leaves: load_f32/f64, load_i16/i32/i64, constant, existing_stack_slot
Unary: neg, abs, sqrt
Binary: add, sub, mul, div
Outputs: store_f32/f64, integer conversion/store, write_stack_slot
Address: base + index*scale + displacement
Stack actions such as push, pop, duplicate and swap should become decode-time reference renaming where possible. They should not be runtime micro-ops in the fused and straight-region arms.
x86 integer expression lowering
The same representation applies to ordinary x86 integer code, and its core
arithmetic is simpler because Wasm i32 wrapping matches 32-bit x86 addition,
subtraction and multiplication:
mov eax,[x]
imul eax,3 STORE32 z
add eax,[y] |
xor eax,mask XOR
mov [z],eax / \
ADD mask
/ \
MUL LOAD32 y
/ \
LOAD32 x 3
The expression IR should distinguish a value from the flags produced by that value. Flags may remain lazy when nothing observes them:
v1 = add32(a,b) ; wrapping value
f1 = flags_add32(a,b,v1) ; materialize only if consumed
Examples:
add eax,ebx; add eax,ecx; mov [p],eax
-> store32(p, add32(add32(eax,ebx),ecx))
add eax,ebx; adc edx,0
-> t = add_with_carry32(eax,ebx,0)
eax = t.value
edx = add32(edx,t.carry)
cmp eax,limit; jl target
-> branch(signed_lt32(eax,limit), target, fallthrough)
Start with full-register i32 operations. Treat AL/AH/AX writes as barriers
until the IR explicitly models insert/extract operations, because AH aliases
bits 8-15 rather than a separate register. Likewise, do not initially fuse
across PUSHFD, LAHF, SETcc, ADC, SBB, rotates-through-carry or a branch
unless the region carries the required flag value to that consumer.
Memory accesses remain an ordered side-effect list even when arithmetic becomes a tree:
r = load32(a) # access 0
s = load32(b) # access 1
store32(c,r+s) # access 2
Do not move the store ahead of either load unless alias analysis proves it safe.
Preserve original fault order in exact mode. DIV/IDIV require explicit zero and
quotient-overflow checks matching x86 traps; shifts require x86's masked count
and exact flag rules. These are later primitives, not ordinary Wasm / or >>.
Useful integer benchmark arms should include:
flag-dead arithmetic chain
arithmetic followed by Jcc
ADC/SBB carry chain
base+index address-update loop
partial-register near miss
possibly-aliasing load/store near miss
DIV zero/overflow side exits
Exact integer fusion should be bit- and state-identical. There is little reason for an integer "fast math" mode: reassociation is safe only when both value and all intermediate flag observations are proven irrelevant. That is an optimizer proof, not an application tolerance setting.
Workloads
Test at least these exact-order shapes at lengths 4, 8, 16 and 32 semantic operations, with 1, 2, 4, 8, 16, 64 and 256 repetitions per entry:
pipeline: out = ((a * b) + c)
tree: out = (a * b) + (c * d)
deep stack: out = ((a + b) * (c - d)) / e
mixed integer: out = ((x * 3) + y) ^ mask
mixed address: out[i] = in[i] * scale + bias
Include register/local pressure comparable to the host interpreter: eight live integer registers, flags, stack TOP/tags, instruction/budget state and address temporaries. A tiny formula with nothing else live gives a misleading ceiling.
Exact and relaxed modes
exact:
preserve source operation order
preserve wrapping integer behavior
preserve flag/status results at every observable boundary
no reassociation and no implicit FMA
relaxed floating-point (separate opt-in arm):
permit reassociation of add/multiply trees
permit FMA
permit independent-lane SIMD
Never compare relaxed output by exact bits alone. Report maximum absolute error, maximum ULP error, image PSNR/hash-distance for pixel kernels, and whether any comparison/branch outcome changes. Exact mode must remain bit/state identical.
Safepoints and side exits
Charge entry/exit materialization in the timed region. Compare safepoint periods
K = 1, 4, 8, 16, 32, 64. A side exit must materialize all live architectural
values before returning to the scalar interpreter.
Initially treat these as barriers:
status/control-word observation or mutation
unknown calls
memory alias not represented by the expression
branch merge with incompatible stack shape
unsupported conversion or transcendental operation
Measurements
For every engine and workload report:
ns per semantic guest operation
ratio to opcode-handler baseline
break-even repetition count
generated Wasm bytes
projection/compile/instantiate time
branch or dispatch count when available
entry/exit materialization cost
checksum or exact-state equality
Run Node/V8, browser Chrome/V8 and JavaScriptCore. Label a JSC shell result as JSC, not Safari. Rotate arm order and use multiple warm median samples.
Expected hypothesis
The local-stack micro-op VM is expected to provide only a small, engine-sensitive
gain because it retains one unpredictable branch per operation. Fused descriptors
and straight regions should win when they turn several guest operations into one
predictable straight-line host expression. Previous Wine-Assembly experiments
found straight local regions crossing direct-memory regions after roughly 4-8
repetitions and a useful safepoint neighborhood around K=16; ToyVM should test
those values independently rather than treating them as constants.
Wine-Assembly calibration (2026-09-10)
The production prototype now supplies five opt-in lowering arms:
H449 straight FLD mem; arithmetic mem; arithmetic mem; FSTP mem
H450 tree FLD mem; FLD mem; arithmetic-pop; FSTP mem
H451 island arbitrary contiguous H188/H189/H190 stream, canonical helpers
H452 affine-a FILD; FILD; FLD ST0; MUL; FXCH; ADD; MUL; FXCH; MUL
H453 affine-b ADD; FSUBP; ADD mem; FXCH; ADD mem
H452/H453 are the first semantic-compiler arm rather than another dispatch
fold. The decoder recognizes instruction semantics and address shapes, tracks
the x87 stack symbolically, and emits a straight-line Wasm expression.
FLD ST0 becomes another reference to the same local and FXCH becomes a
compile-time rename, so neither operation executes at runtime. Architectural
stack state is materialized at ordered memory boundaries and at region exit;
the original memory/fault order and arithmetic association are retained.
On Node/V8, 200,000 real decoded guest iterations with nine rotated rounds gave:
shape scalar median fused median local speedup
pipeline 23.50 ms 16.68 ms 1.41x
tree 26.50 ms 18.13 ms 1.46x
Alpha island 53.71 ms 40.21 ms 1.34x
With the actual Alpha affine sequence as one iteration, a later three-arm run of 200,000 iterations and nine rotated rounds measured:
scalar opcode handlers 147.77 ms 1.00x
H451 canonical-helper island 105.13 ms 1.41x
H452/H453 semantic compiler 69.71 ms 2.12x scalar, 1.51x H451
The differential test compares output bytes, the complete FNSAVE image, GPRs and lazy flags across all three arms. It also puts observable integer instructions immediately after both compiled regions, guarding the threaded instruction-pointer resumption boundary that a NOP could not test.
The Alpha Centauri movie's actual hot x87 sequence is not either four-op leaf. It is two longer islands separated by integer work. H451 executes 412,060 times over the fixed movie window and removes 2,472,360 threaded handler dispatches: 57,814,507 total handlers become 55,342,147, a 4.28% count reduction. All 20 anchored 640x480 frame hashes remain identical.
That did not produce a resolvable whole-movie gain on the loaded benchmark host: the fixed-frame ratio was 0.982x while the fixed-wall arm happened to produce four extra frames, a contradictory result inside machine noise. The profile explains the ceiling: x87 is about 5.7% of the handler stream and the integer Smacker decoder dominates. Treat H451 as evidence that generic micro-op islands are viable and exact, not as evidence that they solve Alpha's frame rate.
The first direct semantic experiment is now implemented. In a separate real Alpha histogram window, H452 and H453 each ran 206,720 times. Each pair lowers 14 x87 instructions to two semantic handlers, removing 12 dispatches per pair, or 2,480,640 dispatches in that sample, while also eliminating the H451 inner micro-op branch/helper work. A no-histogram browser run completed with all 20 anchored 640x480 frame hashes identical to the scalar oracle. Host load varied from 28 to 124 during these runs, so their wall-time/FPS values are explicitly not performance evidence; the rotated in-process microbenchmark is the usable local speed measurement.
The remaining high-value step is to generalize the recognizer from these two affine templates into a bounded expression-region builder: symbolic stack and register SSA, ordered memory nodes, exact barriers/side exits, then a small catalog of straight-line emitters. The same IR can cover flag-dead integer Smacker expressions, which dominate Alpha's profile and therefore have a much higher whole-app ceiling than further x87-only tuning.