Show HN
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).
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.
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.
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.
Restructured the frequency-domain spectra as two parallel f32 arrays — re[] 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 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.
| Variant | per-frame | realtime | % budget |
|---|---|---|---|
| Time-domain O(N²), theoretical | 4.6 ms | 0.58× | 172% |
| WASM FFT scalar (baseline) | 0.308 ms | 8.6× | 11.6% |
| WASM FFT + SIMD AoS (attempt #2) | 0.395 ms | 6.7× | 14.8% |
| WASM FFT + SIMD SoA (attempt #3) | 0.066 ms | 40.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.
AudioWorklet (real-time audio thread) → WASM Convolver (Rust + wasm-simd128) → Web Audio destination.
init_convolver()process_frame(); persistent accum_re/accum_im/FFT scratchwasm.memory is shared zero-copy with the worklet; JS writes to INPUT_BUFFER, reads OUTPUT_BUFFERcargo build --target wasm32-unknown-unknown; the SoA path is guarded by #[cfg(target_arch = "wasm32")] with a scalar fallback used in Rust unit testsAll 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.