Give the stack VM real f32x4 SIMD (#67) - #68
Merged
Conversation
The stack VM was the only backend with no vector support for f32x4. Every vector op was lowered to four per-lane load/op/store trips through the float window, materialized into a fresh 16-byte temp, and copied back into the destination — 69 dispatches for `acc = acc * k + one`, which made f32x4 5x slower than writing the same math with four scalar f32s. Add fourteen f32x4 opcodes in two families. The plain forms name their destination frame slot in the immediate and push its address, for use in expression position. The `*Store` forms pop the destination address off the int window and push nothing, so an assignment, a `var` initializer, a `return`, or an expression-bodied lambda computes into its destination with no temp slot and no memory.copy 16. The handlers do the arithmetic on a `vector_size(16)` type, going through memcpy in both directions: alloc_memory rounds frame allocations to 8-byte slots, so a 16-byte f32x4 is only 8-byte aligned and the loads have to be unaligned. Clang emits `ldr q / ldr q / fmul.4s / str q`. Operands are read into registers before the store, so a destination that aliases an operand (`v = v * v`) is fine. 30M iterations of `acc = acc * k + one` on the stack VM: 1.58s -> 0.237s, with the loop body down from 69 dispatches to 11. The same math written as four scalar f32s takes 0.30s, so f32x4 is now the faster way to write it rather than the slower one. The vector TOS window (item 4 of the issue) is left alone: it needs the f64 window shrunk or overlaid on the f32 one first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XSicxo3USpAaDFu8o74mdX
wtholliday
force-pushed
the
stack-vm-f32x4-simd
branch
from
August 29, 2026 02:11
d51cba9 to
aa3588a
Compare
The vector ops added in the previous commit pass f32x4 values by address,
so `acc = acc * k + one` cost six dispatches: two local.addr pushes and a
multiply into a temp slot, then two more pushes and an add. But the stack
VM is dispatch-bound — a micro-interpreter modelling its exact dispatch
(same 18-arg preserve_none signature, same musttail chain) puts a vector
load/store at well under the cost of reaching the next handler.
So when every operand and the destination is a 16-byte frame slot, drop
the addresses entirely and compute between the slots: F32x4{Add,Sub,Mul,
Div}3 and F32x4Neg2 take their operands and destination as immediates and
touch neither operand stack. Codegen resolves an f32x4 expression tree to
slots, spilling any operand that isn't already a local into a temporary,
so nested subtrees still work. Everything admitted is a pure read of a
frame slot, which is what makes it safe to evaluate operands in any order
and to let the destination alias any of them.
On top of that, `a * b + c` (either operand order) and `a * b - c` fold
into F32x4MulAddSet / F32x4MulSubSet.
30M iterations of `acc = acc * k + one`: 0.237s -> 0.148s, the loop body
down from 15 dispatches to 10, with the whole vector computation in one
instruction. Against the 1.58s this started at, 10.7x. The same math
written as four scalar f32s takes 0.267s, so f32x4 is now the faster way
to write it by a factor of 1.8. No movement on the scalar benchmarks.
Also pin -ffp-contract=off on the interpreter. Left to clang's default,
`a * b + c` contracts to a single fmla, rounding once where Cranelift
rounds twice, so the stack VM returned different numbers than the JIT for
the same source — and the stack VM is the backend shipped on iOS, so that
is desktop-vs-device audio, not just a test concern. The stack VM also
disagreed with itself: `a * b + c` diverged while `t = a * b; t + c` did
not. Whether it happened at all depended on the host compiler's default,
so the same source could behave differently depending on who built it.
Backend agreement is worth more than the last ulp here: the cost is ~6% on
f32x4 multiply-accumulate (0.140s -> 0.148s) and nothing measurable
elsewhere, and tests/cases/simd/f32x4_fma_rounding.lyte fails without the
flag.
Scalar f32 is a separate, pre-existing story and is left alone: both the
LLVM backend and vm_arm64.S (which uses fmadd/fmsub by hand) still
contract `(c + 0.0) + a * b`, where Cranelift and the register VM do not.
The new test covers only f32x4, where all five backends agree, so it needs
no skip directives.
This is also why the vector TOS register window stays unbuilt. In the same
model, a register window without fusion is worth 0.8%, and with fusion a
further 8% — against making f32x4 a window value throughout codegen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XSicxo3USpAaDFu8o74mdX
wtholliday
force-pushed
the
stack-vm-f32x4-simd
branch
from
August 29, 2026 02:54
aa3588a to
64a1b6d
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #67.
The stack VM was the only backend with no vector support for
f32x4. Every vector op was scalarized into four per-lane load/op/store trips through the float window, materialized into a fresh 16-byte temp, and copied back — 69 dispatches foracc = acc * k + one, which madef32x45× slower than not using SIMD at all on that backend.Results
30M iterations of
acc = acc * k + one, stack VM, M-series:f32s10.7× faster, and
f32x4is now 1.8× faster than hand-scalarizing rather than 5× slower — the thing the issue asked for.Almost all of that is the collapse in dispatch count rather than the vector instructions themselves — see How much of this is actually the SIMD? below.
No movement on the scalar benchmarks (FFT 0.840 → 0.841, SK LPF 0.134 → 0.137). Full suite green on jit / vm / stack / asm.
First commit — vector ops (issue items 1–3)
Fourteen opcodes in two families. The plain forms name a destination frame slot in the immediate and push its address, for expression position; the
*Storeforms pop the destination address instead and push nothing, so an assignment, avarinitializer, areturn, or an expression-bodied lambda computes into its destination with no temp and nomemory.copy 16.Handlers do the arithmetic on a
vector_size(16)type, going throughmemcpyin both directions —alloc_memoryrounds frame allocations to 8-byte slots, so a 16-bytef32x4is only 8-byte aligned and the loads have to be unaligned (the issue's alignment note). Clang emits what the issue asked for:Operands are read into registers before the store, so a destination aliasing an operand (
v = v * v) is fine.Second commit — fusion
The stack VM is dispatch-bound: a micro-interpreter modelling its exact dispatch (same 18-argument
preserve_nonesignature, samemusttailchain, same 32-byte instructions) puts a vector load/store well under the cost of reaching the next handler. So when every operand and the destination is a frame slot, drop the addresses and compute between the slots:F32x4{Add,Sub,Mul,Div}3(a, b, dst)andF32x4Neg2(a, dst)— nothing touches either operand stack.F32x4MulAddSet/F32x4MulSubSet—a * b ± cin one op, matched in either operand order.Codegen resolves an f32x4 expression tree to slots, spilling any operand that isn't already a local into a temporary, so nested subtrees still work. Everything the slot form admits is a pure read of a frame slot, which is what makes it safe to evaluate operands in any order and to let the destination alias any of them.
The whole benchmark loop body becomes one instruction:
How much of this is actually the SIMD?
Barely any of it, and that's worth being straight about. I swapped
op_f32x4_muladd_set's body for four scalarfmadds — with a zero-instruction"+w"asm barrier to stop SLP merging them back into a vector — and re-ran the same program on the real VM:fmul.4s+fadd.4s(as committed)ldr q+ 1 ×str qfmul+faddldp+ 2 ×stpQuadrupling the FP ops and doubling the memory accesses costs 1.0 ns out of 4.9 ns per iteration. The core absorbs most of it in parallel with the dispatch chain's dependent loads — the handler body is not the critical path.
So the 1.58s → 0.148s is mostly the dispatch collapse, not the vector arithmetic. The old path wasn't slow because it used scalar math; it was slow because it spent 69 dispatches doing it. The vector instructions are worth 20% of the remaining time; the other 10× is getting the work into one dispatch.
That also says where the remaining headroom is: the loop is 10 dispatches and only one does vector work; the rest are the counter, the compare and the branch. More work per dispatch is the lever, not better per-op vector codegen.
FP contraction, found in review and now pinned off
A code review caught that
a * b + cwas contracting to a singlefmla, rounding once where Cranelift and LLVM round twice. That made the stack VM return different numbers than the JIT for the same source:Three things made this worth fixing rather than documenting:
src/ffi.rs), so this is desktop-vs-device audio, not just a test concern.a * b + cgave −41, whilet = a * b; t + cgave 0.build.rsset no-ffp-contractflag, so whether it happened depended on the host compiler's default — the same source could behave differently depending on who built it.This is a pre-existing bug, not a new one:
op_fused_get_get_fmul_fadd_falready diverged the same way onmain(reachable via the accumulate shape(c + 0.0) + a * b). But this change would have widened the exposure from that narrow shape toa * b + c, the most common expression in DSP code.So the interpreter now builds with
-ffp-contract=off. It costs ~6% on f32x4 multiply-accumulate (0.140s → 0.148s, reflected in the results above) and nothing measurable elsewhere — Biquad 0.119 → 0.119, FFT 0.841 → 0.838, SK LPF 0.137 → 0.135.tests/cases/simd/f32x4_fma_rounding.lytefails without the flag; I verified that by removing it.Scalar f32 is a separate, pre-existing story, left alone here: the LLVM backend and
vm_arm64.S(which usesfmadd/fmsubby hand) both still contract(c + 0.0) + a * b, where Cranelift and the register VM do not. Worth its own issue. The test covers only f32x4, where all five backends agree, so it needs no skip directives.Item 4 (vector TOS window) deliberately not built
The issue lists it as optional and larger. I measured it rather than guessing. In the same micro-interpreter, calibrated against the real VM (0.256s modelled vs 0.237s actual):
A register window is worth 0.8% on its own and a further 8% on top of fusion — removing 5 dispatches accounted for 4.05 ns of the 4.05 ns delta. Against that:
f0..f3→v0..v3andd0..d3→v4..v7already spend all eight AAPCS64 FP argument registers (preserve_noneextends the integer arg list, not the FP one — a 10-float signature spills args 9 and 10 to the stack), so it needs the f64 window shrunk or the window overlaid on the f32 one. The overlay is only free if all 71 f-window handlers are rewritten full-width — the mechanical lane-0 translation costs +2 instructions on every f32 op, because clang won't infer that scalar FP already zeroes bits [127:32]. Plus makingf32x4a window value throughout codegen. Not worth 8%.Tests
tests/cases/simd/f32x4_store_forms.lyte— aliasing destinations, computed constructor lanes, sret returns, expression-bodied lambdas.tests/cases/simd/f32x4_three_address.lyte— multiply-accumulate in both operand orders, multiply-subtract, destinations aliasing the accumulator and a multiplicand, nested subtrees, whole-vector copies.tests/cases/stack_ir/f32x4_vector_ops.lyte— locks in the lowering shape so it can't silently regress to the scalarized path.tests/cases/simd/f32x4_fma_rounding.lyte— guards backend agreement on FP contraction.The runtime tests run on every backend, and all four agree.
🤖 Generated with Claude Code
https://claude.ai/code/session_01XSicxo3USpAaDFu8o74mdX