Skip to content

Give the stack VM real f32x4 SIMD (#67) - #68

Merged
wtholliday merged 2 commits into
mainfrom
stack-vm-f32x4-simd
Aug 29, 2026
Merged

Give the stack VM real f32x4 SIMD (#67)#68
wtholliday merged 2 commits into
mainfrom
stack-vm-f32x4-simd

Conversation

@wtholliday

@wtholliday wtholliday commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

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 for acc = acc * k + one, which made f32x4 5× slower than not using SIMD at all on that backend.

Results

30M iterations of acc = acc * k + one, stack VM, M-series:

time loop body
before 1.58s 69 dispatches
after real vector ops 0.237s 15
after fusion 0.148s 10
the same math as four scalar f32s 0.267s 22

10.7× faster, and f32x4 is 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 *Store forms pop the destination address instead and push nothing, so an assignment, a var initializer, a return, or an expression-bodied lambda computes into its destination with no temp and no memory.copy 16.

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 (the issue's alignment note). Clang emits what the issue asked for:

_op_f32x4_mul_store:
    ldr     q8, [x28]
    ldr     q9, [x27]
    fmul.4s v8, v8, v9
    str     q8, [x26]

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_none signature, same musttail chain, 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) and F32x4Neg2(a, dst) — nothing touches either operand stack.
  • F32x4MulAddSet / F32x4MulSubSeta * b ± c in 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:

23: f32x4.muladd_set 1 3 5 1
_op_f32x4_muladd_set:
    ldr     q8,  [x25, x8]
    ldr     q9,  [x25, x8]
    ldr     q10, [x25, x9]
    fmla.4s v10, v9, v8
    str     q10, [x25, x8]

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 scalar fmadds — 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:

handler body time FP ops memory accesses
one fmul.4s + fadd.4s (as committed) 0.148s 2 3 × ldr q + 1 × str q
four scalar fmul + fadd 0.178s 8 6 × ldp + 2 × stp

Quadrupling 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 + c was contracting to a single fmla, rounding once where Cranelift and LLVM round twice. That made the stack VM return different numbers than the JIT for the same source:

        jit    vm     stack
fma     0 0    0 0    -41 0

Three things made this worth fixing rather than documenting:

  • The stack VM is the backend shipped on iOS (src/ffi.rs), so this is desktop-vs-device audio, not just a test concern.
  • The stack VM also disagreed with itself: a * b + c gave −41, while t = a * b; t + c gave 0.
  • It wasn't pinned. build.rs set no -ffp-contract flag, 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_f already diverged the same way on main (reachable via the accumulate shape (c + 0.0) + a * b). But this change would have widened the exposure from that narrow shape to a * 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.lyte fails 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 uses fmadd/fmsub by 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 today: vector ops via memory     0.256s   15 ops/iter   8.52 ns/iter
B register window, unfused         0.254s   15 ops/iter   8.48 ns/iter
D fused, operands in memory        0.134s   10 ops/iter   4.47 ns/iter
E fused, accumulator in window     0.123s   10 ops/iter   4.11 ns/iter

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..f3v0..v3 and d0..d3v4..v7 already spend all eight AAPCS64 FP argument registers (preserve_none extends 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 making f32x4 a 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

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
wtholliday force-pushed the stack-vm-f32x4-simd branch from d51cba9 to aa3588a Compare August 29, 2026 02:11
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
wtholliday force-pushed the stack-vm-f32x4-simd branch from aa3588a to 64a1b6d Compare August 29, 2026 02:54
@wtholliday
wtholliday merged commit c2afda0 into main Aug 29, 2026
8 checks passed
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.

Stack VM scalarizes f32x4: SIMD is slower than scalar code

1 participant