Realistic x87 region representation benchmark
This is the finite-trip follow-up to
docs/x87-microregion-representation-bench.md. The first shootout measured the
representation ceiling but gave straight-line locals an effectively infinite
hot loop. tools/x87-realistic-region-bench.js adds the costs that decide
whether a real region should be installed.
What is measured
Five arms execute identical state transitions:
| arm | implementation |
|---|---|
handler |
production-shaped micro-PC/br_table dispatch, direct TOP/tag state, ring-memory stack, and production-style fpu get/set helpers |
dispatch |
compact micro-PC/br_table dispatch with eight named f64 locals and opcodes specialized to decode-time physical local IDs |
fused |
the same named-local dispatcher, but each balanced four-op semantic motif is one dispatch case |
region |
generated straight-line named-local region; decode-time stack renaming makes push/pop/FXCH mapping free inside the region |
memory |
generated straight-line control that keeps a local TOP but reads/writes the architectural ring directly |
Each invocation includes conservative entry/exit materialization of all eight f64 stack slots for the named-local arms. Region lengths are 4, 8, 16 and 32 x87 ops, with trip counts 1, 2, 4, 8, 16, 64 and 256. Eight live GPR-like locals, lazy-flag state, tags, address calculation, budget, and micro-PC remain live. Safepoint periods 1, 4, 8, 16, 32 and 64 are separate compiled modules.
The timed call is an internal Wasm wrapper, not thousands of JS-to-Wasm calls. It repeatedly invokes the finite region so entry/exit is charged once per region invocation. Runtime seeds vary each invocation. Observable f64 pops, GPRs, flags, tags, TOP, micro-PC and safepoint state are checksummed, and all five arms must match exactly before a timing is accepted.
The normalized motifs come from real PE instructions, while deliberately remaining balanced at the loop backedge:
- Alpha:
terran.exeat 0x413f55/0x41ff5c plus the measured TQI algebra island; normalized H190 load, H188 load, H189 add-pop, H188 store-pop. - Jazz Jackrabbit 2:
jazz2.exeat 0x414506 and 0x44b49e; normalized integer load, square root, multiply, store-pop. - Half-Life Uplink:
hldemo.exeat 0x406bf6 and 0x412d74; normalized load, multiply, add, store-pop.
These are real instruction motifs, not runtime-frequency-weighted traces. That limitation matters when predicting whole-app speed.
Real production-handler calibration
The tool also boots the full current emulator, writes actual x86 loops into a
loaded PE, and executes the real decoder, $next, $fpu_exec_*, and
H188/H189/H190. This Node-only calibration is kept separate from the small
cross-engine modules. Handler histograms prove that the loops really execute:
| probe | time/invocation | H188 | H189 | H190 |
|---|---|---|---|---|
| Alpha L4 × T1 | 0.630 us | 2 | 1 | 1 |
| Alpha L4 × T64 | 15.683 us | 128 | 64 | 64 |
| Alpha L32 × T64 | 108.987 us | 1024 | 512 | 512 |
| Jazz2 L4 × T64 | 17.272 us | 64 | 64 | 128 |
| Half-Life L4 × T64 | 17.485 us | 128 | 0 | 128 |
This validates the production baseline and trace classification. It is not used as a direct speedup denominator: a tiny standalone candidate module and the complete emulator have different optimization context. Only an embedded runtime prototype can measure that final ratio honestly.
Cross-engine result
Final command (2026-09-09, seven rotated warm rounds):
NODE_PATH=/path/to/repo/node_modules \
X87_REAL_FIXTURE=/path/to/repo/test/binaries/notepad.exe \
X87_REAL_ROUNDS=7 X87_REAL_JSON=/tmp/x87-realistic-final2.json \
node tools/x87-realistic-region-bench.js
The table is the median ratio across all three motifs and four region lengths. The denominator is the production-shaped handler module; lower is better.
| engine | trips | local dispatcher | fused dispatcher | straight region | direct memory |
|---|---|---|---|---|---|
| Node V8 | 1 | 0.974 | 0.396 | 0.362 | 0.325 |
| Node V8 | 4 | 0.922 | 0.327 | 0.265 | 0.267 |
| Node V8 | 64 | 0.920 | 0.300 | 0.227 | 0.257 |
| Node V8 | 256 | 0.918 | 0.293 | 0.222 | 0.246 |
| Chrome V8 | 1 | 0.941 | 0.471 | 0.412 | 0.357 |
| Chrome V8 | 4 | 0.882 | 0.357 | 0.333 | 0.294 |
| Chrome V8 | 64 | 0.857 | 0.294 | 0.286 | 0.286 |
| Chrome V8 | 256 | 0.882 | 0.333 | 0.286 | 0.286 |
| JSC shell | 1 | 1.240 | 0.688 | 0.612 | 0.514 |
| JSC shell | 4 | 1.133 | 0.487 | 0.376 | 0.442 |
| JSC shell | 64 | 1.130 | 0.411 | 0.329 | 0.432 |
| JSC shell | 256 | 1.131 | 0.404 | 0.311 | 0.429 |
The important correction to the first experiment is that a generic one-x87-op-per-dispatch named-local VM does not preserve the renamed-local win. It saves only about 8-14% in V8 and is 13-24% slower in JSC. Local stack representation alone does not pay for another unpredictable dispatch layer.
Semantic fusion does preserve a large share of the gain. Even at one trip it is below the handler baseline in the median, and at 64 trips it costs 0.30x in Node, 0.29x in Chrome and 0.41x in JSC. It still trails direct memory for short regions because both pay dispatch while only the local arm pays eight-slot entry/exit materialization.
For the fully generated region, named locals overtake direct ring memory at a median of roughly four trips in Node and JSC and eight trips in Chrome. At 64 trips the straight region is 0.227/0.286/0.329 of the handler-shaped baseline. That is the useful break-even result; “one trip beats handlers” is true but does not prove locals beat a simpler straight-line memory lowering.
Safepoints, size, and compilation
A focused Node run with 5,000 Alpha L16 × T64 invocations per sample found the straight region at 1.039/0.944/0.963/0.895/0.908/1.016 us for K=1/4/8/16/32/64. K=16 was best in that run; checking every op cost about 16%, while K=8 and K=32 were within 8% and 1% of K=16. Separate-module tiering makes smaller differences uncertain, so this supports a tunable K near 16 rather than a magic constant.
At K=16, the modules containing 12 functions (three motifs × four lengths) are:
| arm | Wasm bytes | project compile | first engine compile |
|---|---|---|---|
| handler | 18,221 | 9.0 ms | 0.3 ms |
| dispatch | 24,043 | 9.5 ms | 0.7 ms |
| fused | 28,500 | 13.5 ms | 0.5 ms |
| region | 27,726 | 17.4 ms | 0.2 ms |
| memory | 28,206 | 14.8 ms | 0.2 ms |
These are batch-module sizes, not per-region bytes. Compile timings are noisy first-compilation observations rather than a cache-resistant compile benchmark.
Safari WebDriver was attempted, but safaridriver --diagnose did not establish
a session and had to be terminated. The JavaScriptCore shell result is therefore
labelled JSC shell, not Safari.
Recommendation
Do not build a second generic per-op x87 dispatcher merely to host named locals. The next production experiment should recognize balanced regions, lower them straight-line with a decode-time eight-local stack map, and materialize TOP, tags, status and all live values at every side exit. Require an observed trip count of at least 4-8 before installing a local-stack region. Where a reusable semantic motif is proven across binaries, a fused dispatcher is a viable lower-cost intermediate step. Start with a safepoint interval near 16 and keep it adjustable.
Production bounded-region follow-up
The first production generalization deliberately uses a finite semantic emitter catalog instead of adding a second per-op VM. The decoder now proves and rewrites these address/width-parameterized families to H449:
FLD mem ; FSTP mem
FLD mem ; FADD|FMUL|FSUB|FSUBR|FDIV|FDIVR mem ; FSTP mem
FLD mem ; FCHS|FABS ; FSTP mem
FLD mem ; arithmetic mem ; arithmetic mem ; FSTP mem
The emitted handler keeps the expression value in one f64 local. Stack push,
unary stack mutation, intermediate fpu_get/fpu_set, and all interior
threaded dispatches disappear. It materializes the architectural x87 stack at
the final store/pop boundary. The matcher currently accepts independently
mixed f32/f64 operands and absolute or simple base+displacement addresses; SIB
address-generation records remain outside this first catalog.
A decode-only census of Jazz Jackrabbit 2 and Half-Life Uplink found 281
accepted balanced regions. The leading shapes included 38 fld;fstp, 14
fld;fchs;fstp, six existing four-op arithmetic pipelines, and ten three-op
memory-arithmetic regions. Address-shape filtering means these counts are an
opportunity ceiling, not a claim that every occurrence is emitted by H449.
Production-loop medians on Node/V8, 200,000 guest iterations and nine rotated rounds (2026-09-10), were:
| semantic family | scalar | H449 | speedup |
|---|---|---|---|
| typed copy | 17.52 ms | 13.86 ms | 1.26x |
| one memory arithmetic op | 20.88 ms | 14.13 ms | 1.48x |
| unary sign op | 20.29 ms | 13.13 ms | 1.55x |
| two memory arithmetic ops | 22.71 ms | 16.76 ms | 1.35x |
test/test-x87-pipeline4-fusion.js compares 11 scalar/fused cases, including
mixed widths, absolute and base addressing, reverse divide, both unary ops,
the complete FNSAVE image, GPR/lazy-flag state, and the resume instruction
immediately after each rewritten region. The result supports extending the
straight semantic catalog while retaining scalar fallback at every unproved
shape. SIB-aware address lowering and larger decode-time stack-renamed regions
remain the next separate steps.
Browser opt-in
The debug toolbar exposes an unchecked x87 fold switch. It enables both the
bounded generic catalog/island gate and the Alpha-specific affine gate for the
next launched emulator instance; ?debug&x87-fold provides the same setting
for scripted browser runs. The setting is copied to the slot-0 guest Worker and
to later CreateThread WASM instances. Toggling an already-decoded program is
not a clean comparison, so A/B runs must toggle and relaunch.
This switch does not make matching permissive. Decode-time semantic, stack, address-shape, and ordering proofs still decide whether each candidate is rewritten; every unproved or unsupported sequence retains the scalar x87 handlers. The default remains off until fixed-work application tests establish both correctness and a repeatable whole-program benefit across more than the current Quake II OpenGL candidate.
Targeted game-module census
A 2026-09-10 linear code-section sweep of 28 unpacked game/runtime modules
(excluding installer payloads and bundled system/DirectX DLLs) ranked these
3D-era candidates highest. balanced x87 ops counts instructions inside the
census tool's bounded, stack-balanced regions; it is an opportunity ceiling for
a future region compiler, not the narrower current H449 catalog match count.
| module | static x87 / decoded instructions | balanced x87 ops |
|---|---|---|
Quake II ref_soft.dll |
12.17% | 2,037 |
Deus Ex render.dll |
10.43% | 5,390 |
Half-Life hl.dll |
9.80% | 11,633 |
Half-Life hw.dll |
9.30% | 12,872 |
Half-Life sw.dll |
9.15% | 11,198 |
MechWarrior 3 mech3demo.exe |
8.45% | 23,247 |
Quake II ref_gl.dll |
6.98% | 1,833 |
Quake II quake2.exe |
5.81% | 2,764 |
Deus Ex engine.dll |
4.14% | 13,477 |
This is candidate selection, not performance evidence. Linear static density
does not weight executed blocks: Alpha's terran.exe measured only 0.62% and
Jazz2 0.99% here even though their measured movie/game windows concentrate much
more heavily in a few x87 paths. Quake II also demonstrates that topology
matters: the OpenGL renderer has lower file-wide density than ref_soft.dll,
but its hot x87 work sits in tight self-loops and therefore amortizes the
current island handler much better. Direct3D/OpenGL offload rasterization; guest
code still performs animation, transforms, clipping, lightmap construction,
and API argument preparation before calling the host renderer.