Skip to content

perf: keep the value stack's height in a register, stacked on cheaper calls - #81

Open
matthargett wants to merge 10 commits into
explodingcamera:nextfrom
rebeckerspecialties:perf/stack-height-on-cheaper-calls
Open

matthargett wants to merge 10 commits into
explodingcamera:nextfrom
rebeckerspecialties:perf/stack-height-on-cheaper-calls

Conversation

@matthargett

@matthargett matthargett commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Stack, in merge order: #74 → #81, this PR, which is #79's six commits on top of #74. #79 is the same six commits on next alone. All three share one table layout for side-by-side comparison.


#79's six commits on top of #74. #74 removes the call path's refcount updates and lookups; #79 removes the height's store-to-load round trip on every dispatch. Neither change depends on the other, and on most rows the two add up.

Results

i7-8650U A10X A12 A13 M4
argon2id +2.2% −4.6% −6.2% −5.7% −12.4%
compress −1.7% −11.9% −15.8% −14.1% −41.0%
decompress −5.7% −14.8% −14.3% −13.0% −39.9%
EH parser (exnref) −48.5% −52.4% −21.1% −21.1% −23.5%
GC binary trees −1.6% −1.7% −0.8% +1.0% −3.7%
CoreMark −6.1% −13.7% −12.3% −12.2% −30.8%

CoreMark is −15.5% averaged over the five chips. The EH parser gets #74's gain and the suite fixtures #79's; GC binary trees and argon2id on the i7-8650U stay within ±2.5%.

Change in cycles per call against next with nightly-tail-calls, median of five interleaved runs per build. i7-8650U: a Surface Book 2 (Kaby Lake, 2017) on Windows x64, pinned to one core, core cycles counted with VTune. A10X: Apple TV 4K (1st gen). A12, A13: iPhone XS Max and iPhone SE, performance cores. M4: performance cores. argon2id, compress and decompress are cargo bench-suite's fixtures, the EH parser and GC binary trees are WasmBench rows, and CoreMark runs with a clock that advances a fixed step per read, so every build runs the same iterations.

With the default (match-loop) dispatch, which #79 doesn't target, #74 carries the gain: CoreMark −11.3% on the A13's and −3.4% on the A14's performance cores with both PRs, against −12.7% and −7.0% with #74 alone.

Entering a function reserved and zero-filled all three value-stack lanes
even when the function never touches the 64- or 128-bit lane, which is
the common case. Skip a lane whose locals and operand-stack depth are
both zero.

A single-result return now moves the result down and truncates once,
instead of popping, truncating and pushing it back.
Every call and return between two functions cloned the callee's
`Shared<WasmFunction>` and dropped the previous one, two refcount updates
each way, and looked the function up in the store.

A module instance now keeps its own functions, which the store allocates
contiguously. The interpreter holds the instance for the whole run, so
the executor borrows the executing function and its module from it: a
call or return within the instance switches a reference, and a direct
call to one of the module's own functions skips the address table, the
host check and the owner check.

Execution that continues in another instance's function (a call through
an import, table or reference, a return, or an exception unwinding into
it) ends the run, and `InterpreterRuntime` resumes that frame with an
executor for its instance. Fuel and time budgets carry over, so a run
suspends at the same points as before.
The match loop spilled the executor's function and reloaded it, then the
instruction slice's pointer and length, on every step. The executing
function is borrowed from the module instance, not from the executor, so
the loop can hold its instructions in a local and reload them only when a
step switches functions.
The instance kept its own copy of the module's function list for the
executor to borrow from, which cost an allocation and a reference count
per function on every instantiation, and as many again on drop. Hold the
module instead, which costs one reference count per instance.
On arm64_32 (watchOS) the Rust ABI passes the 8-byte Instruction by
reference, so every dispatch stores it and the next handler loads it back.
The C convention passes it in a register, and `C-unwind` still lets a
host function's panic unwind.

Other targets keep the Rust ABI.
The height of a value-stack lane is its own field, over a Vec that holds
every slot the lane has reached. Slots above the height hold stale values
that are written before they are read again.

The tail-call handlers can then pass the height between them in a register
and write it back without unsafe code.

Entering a function writes its locals, and its operand-stack reservation
if that is at most 64 slots, so later entries at that height compare once.
A larger reservation stays capacity, and a push writes each slot the first
time it reaches it, so a function whose deep branch rarely runs keeps no
pages for it; once the locals have slots, later entries compare and go on.
With 64 stores of a module whose untaken branch declares a
30000-deep i64 stack, each store keeps next's footprint (35 KB on macOS,
16-19 KB on glibc) instead of 273 KB and 250 KB.

v128.any_true and i8x16.all_true test the vector with integer arithmetic:
LLVM scalarized their iterator forms once pushes gained that path.

Only builds with debug assertions check indices against the height. CI
also runs the tests optimized with them, keeping the release profile's
wrapping arithmetic.

Entering a function zeroes one or two locals with direct stores, and only
the ones in between with memset.
The executor takes the store's value stack when it is created and gives it
back when it is dropped, and around host calls, which read their arguments
from the store and may call back into wasm.

Every handler receives the executor as a `&mut`, so a stack access reached
through it needs one load fewer (the store pointer), and the compiler knows
that no store to a slot or to memory changes a stack's height, so it can
keep the heights in registers within a handler.
…lers

Each tail-call handler receives the height of the 32-bit value stack, which
most instructions push to or pop from, as its last argument. It writes the
height to the stack before its body and reads it back before dispatching,
and with the stack in the executor the compiler can keep it in a register
in between, as it does in most handlers. A handler no longer loads the
height its predecessor stored, a store-to-load round trip through memory
on every dispatch.

The Unbudgeted handlers now take six integer arguments and the Bounded ones
five, which stay in registers on arm64 (eight) and x86-64 System V (six).
Windows x64 passes four, so there the height goes on the stack along with
the Instruction, which already does without this change.
…ows x64

Windows' calling convention passes four integer arguments in registers, so
every dispatch stored the Unbudgeted handlers' last two and the next handler
loaded them back. On Windows x64 the handlers now use the System V
convention, which passes six, so the 32-bit height stays in a register. The
instruction still goes through the stack: under the System V rules this enum
is passed in memory. A CI job runs the tests with tail calls on Windows.
Windows x64 passes four integer arguments in registers. The bounded
handlers (fuel and time budgets) take four besides the 32-bit height, so
passing the height put a fifth argument on the stack under either
convention: the height under the Rust ABI, the instruction under System V,
which passes this enum in memory.

On Windows x64 the bounded handlers keep the Rust ABI and leave the height
out: they read it from the stack before dispatching, as before the height
was passed. The unbudgeted handlers keep the System V convention and the
height.
@matthargett
matthargett force-pushed the perf/stack-height-on-cheaper-calls branch from ccb5032 to 81c5b50 Compare October 17, 2026 04:59

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant