Show HN

How SoA layout made my WASM SIMD convolver 4.67× faster

A real-time convolution reverb in Rust/WebAssembly — and a story about why the first SIMD attempt was 21% slower than scalar.

The AudioWorklet frame budget is 2.67 ms (128 samples @ 48 kHz). A 3-second cathedral IR requires 288,000 complex MACs per frame. The sandbox below downloads two WASM builds — one scalar, one hand-written SIMD — runs 5,000 frames of each on your CPU, and shows the live speedup. On the author's M1 it clocks 0.066 ms/frame (40× realtime).

⚠ O(N²) Brute Force60 FPS
Acoustic Space
Frame Latency
4.6ms
GFLOPs/Frame
0.0
Frame Budget
Glitch ⚠

Try it: pick Cathedral (3.0s), switch to WASM FFT, click Measure A/B. Or hit Play Audio Demo— the click train is routed through the WASM convolver on an AudioWorklet thread; the wet/dry slider is live.

Three tries to get SIMD right

Attempt #1 · LLVM autovectorize · 0.99×

Added -C target-feature=+simd128 and hoped. Nothing happened. rustfft's opaque generics + the Complex32accumulator loop produce control flow LLVM refuses to vectorize. The wasm file's SIMD opcode count stayed near zero.

Attempt #2 · hand-written SIMD, AoS layout · 0.79× (regressed!)

Wrote the complex MAC by hand using std::arch::wasm32 intrinsics. The Complex32 arrays stayed interleaved [re, im, re, im, ...], so the MAC required three i32x4_shuffle ops per iteration to reshape lanes.

Result: 21% slower than scalar. V8's baseline (Liftoff) tier compiles v128.shuffle to a generic byte-permute that's ~3× the cost of a plain f32.mul. The shuffle overhead wiped out every bit of parallelization gain.

Attempt #3 · hand-written SIMD, SoA split-complex · 4.67×

Restructured the frequency-domain spectra as two parallel f32 arraysre[] and im[] — instead of interleaved Complex32. The complex MAC collapses into:

re_out += re_ir * re_in - im_ir * im_in
im_out += re_ir * im_in + im_ir * re_in

Four f32x4_mul + two f32x4_add + one f32x4_sub per 4 complex numbers. Zero shuffles. Bit-identical output to scalar (max diff across 100 frames = 0.0). Latency fell from 0.308 ms to 0.066 ms.

The lesson

The bottleneck wasn't the instruction set — it was data layout. Mike Acton's rule again: your data structure must match how the hardware eats data. AoS looks natural to humans; SoA looks natural to the vector unit. The arithmetic was the same either way.

Measured numbers (M1 · Chrome · Node)

Variantper-framerealtime% budget
Time-domain O(N²), theoretical4.6 ms0.58×172%
WASM FFT scalar (baseline)0.308 ms8.6×11.6%
WASM FFT + SIMD AoS (attempt #2)0.395 ms6.7×14.8%
WASM FFT + SIMD SoA (attempt #3)0.066 ms40.5×2.5%

IR: 3-second synthetic cathedral (144,000 samples @ 48 kHz). 1,125 partitions × 256 freq bins = 288,000 complex MACs per 128-sample frame. Warmup: 1,000 frames. Measured window: 5,000 frames.

Pipeline

AudioWorklet (real-time audio thread) → WASM Convolver (Rust + wasm-simd128) → Web Audio destination.

  • Partitioned overlap-save: FFT size = 256, block = 128, tail persisted between frames
  • IR and input history stored in SoA split-complex blocks, pre-allocated at init_convolver()
  • Zero heap allocation in process_frame(); persistent accum_re/accum_im/FFT scratch
  • wasm.memory is shared zero-copy with the worklet; JS writes to INPUT_BUFFER, reads OUTPUT_BUFFER
  • Both WASM builds exported by the same cargo build --target wasm32-unknown-unknown; the SoA path is guarded by #[cfg(target_arch = "wasm32")] with a scalar fallback used in Rust unit tests

All numbers above are measured, not theoretical — the sandbox downloads both WASMs and runs them with performance.now(). Your device may differ. If you see something weird or know a faster layout, please tell me in the thread.