Stack calls and region address reuse

Results and benchmark tooling integrated into main; no production runtime optimization merged by this integration. Existing experimental stack-span runtime 482f9a48, artifact SHA 750f03c063e4806502ed056f00a8968da35e8c271ef348bbdc5e4ab410d6ab28.

Actual interpreter: call-heavy microbenchmarks

Run on quiet remote fast-near-9tb-1 under the shared benchmark flock, Node20.11.1 / Intel i9-9900K. Tool: baseline worktree tools/bench-loops.js, copied to remote tools/bench-stack-calls.js. Nine alternating, order-rotated repetitions per arm; fresh nonoverlapping code addresses for every repetition.

Each sample executes one million actual x86 indirect calls through a table. Either one target or512 distinct targets; the latter visits targets in a permutation with stride73. Each target saves EBX/ESI/EDI, allocates16 bytes, spills EAX, adds one or16 memory operands to EAX, reads the spill, releases locals, restores registers and returns. The caller advances its table index and loop counter. This includes actual call/return, guest-memory accesses, register file operations and interpreter dispatch; no guest code is bypassed. The512-target sequence is periodic, not randomized or a real game trace.

Every sample verifies the accumulator, preserved registers, loop completion, ESP and final local spill, and compares state checksums between arms. Enabled samples must report exactly2,000,000 fast-span hits; disabled samples zero. Handler-count passes are separate from timed passes (diagnostics force fallback).

Median milliseconds; lower is better:

Targets Body loads/adds Ordinary handlers Stack span Reduction Null-control absolute delta
1 1 241.608 185.277 23.31% 0.0002%
512 1 283.777 227.495 19.83% 0.153%
1 16 490.958 430.509 12.31% 0.071%
512 16 534.272 474.628 11.16% 0.227%

Null control toggles unused rect_run, with stack candidate unchanged in both arms. It bounds positional/timing noise, not every possible JIT-layout effect. Raw results are versioned in bench-results/stack-reuse-20260921/: stack-calls-micro.txt, stack-calls-null.txt, stack-region-reuse-a.txt, and stack-region-reuse-b.txt. These are synthetic workload gains, not predicted app-level gains. They support stack optimization for genuinely call-heavy code; the small Diablo/AoE gains remain the app-level evidence.

Morrowind eligibility

Local retail executable only; no retail binary uploaded to remote. Static instruction-boundary census, non-ESP runs chunked to maximum4:

Run length PUSH spans POP spans
2 8298 3202
3 2455 2571
4 1170 1624

This is49,254 statically covered instructions, not an execution-weighted percentage. Existing notes'5.2% PUSH statistic is startup/load, not gameplay. Later phase-separated notes retract the earlier hotspot interpretation; gameplay attempts stopped at the character-name modal. No usable raw gameplay histogram was located. Morrowind therefore has static opportunity but unknown runtime benefit. Do not multiply static counts or the5.2% startup figure by microbenchmark speedups.

Region-level reuse prototype

Completed: a restricted synthetic WAT experiment, not production region lowering. Initial prototype timings rejected because translation counters were inside the timed functions and the region arm hoisted translations over the entire repetition loop. Revised comparison must time uninstrumented functions and separate per-region stack reuse, body-address reuse and whole-loop hoisting. Forced fallback modes are not alias/fault correctness coverage. No result from that initial prototype is quoted as a performance win.

Corrected tool: tools/stack-region-reuse-bench.js, commit ea3b17ae (supersedes974403e4). Uses vendored WATX compiler,4035-byte counter-free timing module and4042-byte instrumented module for logical translation counts. Two remote invocations, each nine rotated rounds with calibration/warmup and every sample >=30ms. State independently initialized before every arm/sample; checks expected accumulator, saved words, spill, ESP and checksum afterward. No explicit body-value caching in emitted WAT; the native JIT is still free to optimize it.

Median nanoseconds per synthetic region, first / second invocation:

Lowering Body1 Body16
Split per-access translation 16.135 /16.127 46.201 /46.263
Separate PUSH/POP spans 12.449 /12.455 42.261 /42.301
Stack reuse through saves/spill/restores 6.822 /6.832 36.410 /36.254
Body-address reuse only 18.490 /18.537 19.853 /19.881
Combined per-region reuse 9.681 /9.689 11.772 /11.764
Entire-loop hoisting (optimistic bound) 0.934 /0.934 3.211 /3.211

Interpretation: per-region combined reuse is about22% lower time than separate spans for body1,72% lower for body16. But for body1, stack-only reuse is better than the combination; extra body-span validation pays nothing when there is only one body address. This is evidence to specialize lowering by repeated access shape, not blindly combine every transform. Translation-count reduction alone is not a cost model: instrumented body1 stack-only and combined both make74 calls per37 regions but their timings differ.

Scope limits: fixed compile-time guest addresses, simplified direct mapping, no actual x86 decoding/dispatch/call-target working set, no sparse faults, self-modifying code or concurrency, and no mid-region state materialization. Forced fallback mode is checked at entry; it is not a test of real mid-region alias/fault/exit recovery. Logical translation counts come from a different instrumented module and do not describe native calls after JIT optimization. Loop-hoisted arm keeps addresses across all repetitions; its very low time must not be attributed to per-region reuse. These are mechanism experiments, not measured speedups of a production region compiler or an application.

Recommended next implementation

In a region IR, track stack pointer as base plus constant delta; expose translation/loads/stores separately for analysis, then fuse emission. Reuse one guarded stack range across saves/local spills/restores. Cache a body translation only when repeated accesses justify it. Flush architectural state and discard assumptions at unmodelled calls/exits; add actual alias/fault/ code-invalidation tests before production use. Keep split micro-ops out of the interpreter's dispatch stream unless their cost has been measured. Morrowind still needs a gameplay-weighted eligibility census before an app performance prediction. No production integration occurred in this experiment.

Reproduction

Call benchmarks require the experimental stack-span artifact identified above, not main's default WASM: set_bench_candidate and fast-hit exports are required. Set STACK_BENCH_WASM to its absolute path; this skips automatic rebuilding.

STACK_BENCH_WASM=/absolute/path/to/stack-span.wasm node tools/bench-loops.js \
  --shapes=stack_calls1_body1,stack_calls1_body16,stack_calls512_body1,stack_calls512_body16 \
  --toggle=stack_candidate --reps=9 --json
# Null control: same artifact/shapes, replace --toggle=stack_candidate with --toggle=rect_run.
node tools/stack-region-reuse-bench.js
# Repeat the region command for an independent invocation.

Run alone on a quiet machine; serialize remote benchmarks with the shared /tmp/wine-assembly-diablo-benchmark.lock. The standalone region tool compiles its own restricted WAT and does not load or modify the production emulator.