Skip to content

perf: keep the value stack's height in a register across tail-call handlers - #79

Open
matthargett wants to merge 5 commits into
explodingcamera:nextfrom
rebeckerspecialties:perf/stack-height-arg
Open

matthargett wants to merge 5 commits into
explodingcamera:nextfrom
rebeckerspecialties:perf/stack-height-arg

Conversation

@matthargett

@matthargett matthargett commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Stack: these five commits also come stacked on #74 as #81, where the two combine (see its tables):

  1. perf: borrow the executing function from its module instance #74: borrow the executing function from its module instance
  2. perf: keep the value stack's height in a register, stacked on cheaper calls #81: keep the value stack's height in a register, on top of perf: borrow the executing function from its module instance #74

This PR is the same five commits on next alone.


The stack-height half of #78, which I separate into five commits that I measured one by one. None of them adds a nightly feature, and each one builds and passes the tests on its own, so they can go in one at a time. The panic fix that was in an earlier version is on next as 9d810e1.

  1. Pass the tail-call handlers' Instruction in a register on arm64_32 (see below). Only what the convention passes in registers stays out of memory between two handlers.
  2. Keep each value stack's height outside its Vec. A lane's height is its own field over a Vec that only grows. Slots above the height are stale and written before they are read. This lets the handlers write the height back without unsafe. A function's operand-stack reservation is written along with its locals only when it's at most 64 slots. A larger one stays capacity, and each slot is written the first time a push reaches it, so a function whose deep branch rarely runs keeps no pages for it: 64 stores of a module whose untaken branch declares a 30,000-deep i64 stack keep next's footprint (35 KB each on macOS, 16–19 KB on glibc) instead of 273 KB and 250 KB. Only builds with debug assertions check indices against the height, and a new CI job runs the tests optimized with them. A push that reaches a new slot checks the capacity and then uses Vec::push, which makes no call; the nightly push_within_capacity would save about 1% of instructions per call on arm64.
  3. Hold the value stack in the executor while it runs. The executor takes the store's value stack when it's created and gives it back when it's dropped and around host calls, also when a host function catches a panic from a nested call (tested in tests/host_nested_panic.rs). Every handler receives the executor as a &mut, so a stack access needs one fewer load, and the compiler knows that no store to a slot or to memory changes a height (verified this in disassembly and with low-level CPU counters).
  4. Pass the 32-bit value stack's height through the tail-call handlers. A handler no longer loads the height its predecessor stored. The Unbudgeted handlers take six integer arguments and the Bounded ones five.
  5. Pass the tail-call handlers' arguments in six registers on Windows x64 (see below).

With nightly-tail-calls the series is faster on every chip I measured, from the Apple TV 4K's A10X (2017) to an M4 and an Intel laptop: −7.6% cycles per call on WasmBench on the A10X, −4.7% on the A12's and A13's efficiency cores, −20% on the Apple Watch's S8 (an earlier version), −12% to −40% on cargo bench-suite's execute benchmarks on an M4 (see #81), and −1% to −4% without a budget on Windows x64. Details below.

Apple devices

Change against next (9d810e1) with nightly-tail-calls, median of five interleaved launches per build. Energy is the kernel's estimate of the CPU energy each call used (task_power_info_v2). Commit 1 does not change the code on 64-bit arm64, and commit 5 only changes Windows.

iPhone SE (A13), efficiency cores iPhone XS Max (A12), efficiency cores Apple TV 4K (A10X)
commits 1–3, cycles −6.3% −4.6%
commits 1–3, energy per call −3.5% −5.0%
commit 4 on top of 3, cycles +1.6% −0.1%
commit 4 on top of 3, energy per call +2.3% +0.6%
all, cycles −4.7% (15/15 faster) −4.7% (15/15 faster) −7.6% (15/15 faster)
all, energy per call −1.2% −4.4%

The A10X was measured with all commits only. The iPhone 12 wasn't available for this version; on the earlier version (an older next, with push_within_capacity, built for the A10), all four commits were −5.2% cycles on it (A14). On the A10X (Apple TV 4K first gen) the kernel doesn't report energy or which cores ran the benchmark, which makes sense since it plugs into a wall :D I have an A10X iPad Pro, but that device didn't get updated past iPadOS 17 so the performance profiling is limited in different ways. So far I've found that what's good for A12 effiency cores maps well to A10X's high-performance (Hurricane) cores in terms of wallclock times.

On Apple Watch SE (S8, arm64_32), 13 benchmarks (the watch leaves out audio DSP and the GC trees due to peak memory usage constraints, something to optimize for later), measured on the earlier version; commit 1 hasn't changed since:

cycles wall time energy per call
commit 1 (extern "C-unwind") −13.2% (13/13 faster) −14.5%
commits 1–4 −19.7% (13/13 faster) −19.8% −7.5%

Between some launches the watch changed its performance state, which moves energy per cycle between about 46 and 73 pJ, too often to give commit 1's energy on its own. Profiling on the physical watch is extremely annoying, and very touchy (literally!), but I feel like I got enough on-device data for it to be defensible.

Commits 1–4 against next, per benchmark:

benchmark A13 cycles A13 energy A12 cycles A12 energy A10X cycles
crc32 (scalar) −8.8% −2.3% −7.5% −6.6% −11.2%
audio DSP −7.6% +0.0% −9.3% −8.5% −15.9%
convolution (scalar) −6.5% −2.3% −6.2% −9.1% −18.4%
bulk_memory (scalar) −5.7% −1.3% −4.2% −10.1% −6.4%
EH parser, exnref −5.7% −4.7% −4.6% −5.3% 0.0%
matmul relaxed-simd FMA −5.4% −1.1% −2.6% +3.7% −4.9%
tail-call FSM −4.5% −2.2% −4.4% +2.1% −2.1%
call_indirect −4.2% −2.5% −4.7% −7.7% −5.3%
fib(30) −4.1% −1.3% −3.9% −4.1% −3.9%
vtable_poly4 −4.1% −2.8% −3.9% −3.1% −3.3%
call_ref twin: call_indirect −4.1% −2.2% −3.1% −2.0% −5.2%
sieve (scalar) −3.7% +1.8% −8.3% −8.5% −12.0%
graphql-validation −2.9% +0.9% −1.7% −4.2% −10.1%
xmrsplayer −2.3% +2.5% −4.6% −2.4% −13.3%
GC binary trees −1.1% −0.6% −1.3% +1.0% −0.1%

Commit 4 pays where the height's store-to-load round trip costs: on the iPhone 12 it removed 88–97% of the memory-order flushes on every benchmark profiled, and on audio DSP back-end stalls fell from 14.5% to 3.0% of pipeline slots. On the iPhone SE and XS Max it's neutral to about 2% slower, while commits 1–3 save 6.3% and 4.6% there, so commits 1–3 also stand on their own.

The call-heavy rows are bound by the call path instead: on the iPhone 12's tail-call FSM, about a third of the samples were the Shared<WasmFunction> refcount updates on each call and return, which #74 removes.

With the default dispatch, the series no longer changes instructions per call (within 0.05% on all 15 benchmarks): next's own match loop got 1.8–5.7% cheaper since 017780e, which is what commits 2 and 3 used to give it.

Calling convention (commits 1 and 5)

  • x86-64 System V: the handlers keep the Rust ABI, which passes six integer arguments in registers, as many as the Unbudgeted handlers take with commit 4.
  • Windows x64 (commit 5): the Windows convention passes four, so every dispatch put the Instruction on the stack, and with commit 4 the height too. The handlers now use extern "sysv64-unwind" (stable), which passes six. The Instruction still goes through memory there, since rustc passes this enum in memory under the System V rules, which is also why Linux keeps the Rust ABI. extern "rust-preserve-none" would pass twelve on every OS, but it's a nightly feature (rust_preserve_none_cc), so it isn't in this PR. A new CI job runs the tests with tail calls on Windows.
  • arm64_32 (watchOS 11): the handlers are extern "C-unwind". The Rust ABI passes the 8-byte Instruction by reference there, so on next 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 (super cool!)
  • Everything else keeps the Rust ABI.

Windows x64

Change in cycles against next on a Surface Book 2 (Intel Core i7-8650U, a Kaby Lake core from around 2016), cargo bench-suite's fixtures with nightly-tail-calls, best of 12 interleaved runs pinned to one core:

no budget fuel budget time budget
argon2id −1.2% +7.0% +6.9%
compress −1.3% +1.0% +0.9%
decompress −2.7% +1.1% +0.7%
json −3.6% −0.1% −0.3%
nested_tinywasm −2.7% +4.0% +2.6%

Without commit 5, commits 1–4 are 1.5–8% slower than next on Windows without a budget, because the height spills to the stack. Commit 5 makes every fixture faster there. With a fuel or time budget there's room for improvement on argon2id and nested_tinywasm in this configuration; I'd prefer to leave that to another design and optimization pass rather than hold this PR for it.

@explodingcamera
explodingcamera self-requested a review October 1, 2026 16:09
explodingcamera added a commit that referenced this pull request Oct 3, 2026
originally reported in #79

Signed-off-by: Henry <mail@henrygressmann.de>
explodingcamera added a commit that referenced this pull request Oct 3, 2026
based on the fix in #79

Signed-off-by: Henry <mail@henrygressmann.de>
@explodingcamera

explodingcamera commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

I just pushed a fix for the function call panics from this to next. I do see a good performance improvement overall, and I’d definitely like to bring in more of these changes. I’m just a bit hesitant to add more nightly features right now, so I might shuffle the flags around to separate tail-call dispatch from a more general nightly feature.

I’ll probably do some more testing in this direction and pull the changes in incrementally rather than all at once.

@explodingcamera
explodingcamera removed their request for review October 3, 2026 14:32
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. 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.

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.

2 participants