2.67ms里的声学炼金术:WASM实时卷积混响
TL;DR - 性能数据摘要
本文讨论:如何用 Rust/WASM + SIMD 在 2.67ms 帧预算内实现专业级卷积混响。
核心数据:
- 3秒教堂脉冲响应 @ 48kHz = 144,000 采样点
- 时域卷积:36.9M FLOPs > 2.67ms 预算(失败)
- 频域分区卷积(SoA-SIMD):0.066ms / 4.67× 加速
关键洞察:性能提升不在指令集(SIMD),而在数据布局(SoA vs AoS)。
复现环境:Chrome 120+, WASM SIMD128, 分区 Overlap-Add, 256 分区
English Abstract (Show HN)
A 3-second cathedral IR at 48kHz means 144,000 samples × 128 per frame = 36.9M FLOPs for time-domain convolution — blowing past the 2.67ms AudioWorklet frame budget. I built a partitioned overlap-add FFT convolver in Rust/WASM with hand-written SIMD128 on SoA (Structure-of-Arrays) layout. The key insight: performance is in the data layout, not the instruction set. Naive AoS SIMD with v128.shuffle was slower than scalar (0.79×). SoA split-complex MAC with zero shuffles achieved 4.67× speedup (0.066ms per frame). The embedded sandbox lets you benchmark scalar vs SIMD in your browser.
144,000 采样点的脉冲响应,O(N²) 时域卷积需要 36.9M FLOPs,直接刺穿 2.67ms 帧预算。 我用 Rust/WASM 做了一个分区 Overlap-Add 快速卷积引擎,手写 SoA-SIMD128, 跑出了 0.066ms / 4.67× 加速。而加速的秘密不在指令集,在数据布局。
时域卷积的物理诅咒
什么是混响?一句话:混响就是卷积。 你把一段干声(Dry Signal)和房间的脉冲响应(Impulse Response)做卷积, 就得到了带有空间感的湿声(Wet Signal)。
一个 3 秒尾音的"史诗大教堂",脉冲响应有 144,000 个采样点(48kHz × 3s)。 AudioWorklet 每帧给你 128 个采样。时域卷积的运算量:
FLOPs = 2 × 128 × 144,000 = 36,864,000
约 36.9M 次浮点运算
在 8 GFLOPS 基准算力下 → 4.6ms
4.6ms 刺穿 2.67ms 帧预算。这不是优化问题,是 O(N²) 的物理诅咒—— IR 越长,计算量以平方级增长,没有任何微优化能救你。
频域炼金:Overlap-Add 快速卷积
卷积定理:时域的卷积 = 频域的乘法。 FFT 把 O(N²) 降维到 O(N log N)。但 3 秒 IR 意味着 144,000 个采样点, 一次巨型 FFT 不现实。答案是分区 Overlap-Add:
- 把长 IR 切成 128-sample 的小块(cathedral 有 1,125 个 partition)
- 每块独立 FFT → 频域复数乘法 → IFFT
- 重叠相加,拼出完整卷积
频域乘法才是真正的热路径——1,125 个 partition × 256 个频点 = 288,000 次复数 MAC/帧。 这就是 SIMD 的战场。
4.67× 加速的秘密:数据布局,不是指令集
我天真地以为加个 +simd128 target-feature 就能白嫖加速。 结果三档尝试,只有第三档才真正打穿。
尝试一:LLM Autovectorization → 0.99×
给 rustc 加 -C target-feature=+simd128,祈祷 LLVM 自动向量化。 结果:0.99× 基本等于白忙。rustfft 的 FFT butterfly 循环对 LLVM 来说太不透明, 根本不敢 vectorize。而我自己写的复数 MAC 循环,LLVM 也没能自动优化。
尝试二:手写 SIMD + AoS shuffle → 0.79× 更慢!
用 v128 手写复数 MAC,数据布局是传统的 interleaved Complex32——[re₀, im₀, re₁, im₁, ...]。复数乘法 (a+bi)(c+di) 需要用i32x4_shuffle 重排实部虚部。结果:
⚠ v128.shuffle 是 V8 baseline tier 最慢的 SIMD 操作之一
3 次 shuffle + 3 次 mul + 2 次 add = 10+ 条 SIMD 指令 / 2 个复数
比纯标量 8 条 wasm op 还慢
反向加速 21%。SIMD 不是万能药——选错数据布局,SIMD 比标量还慢。
尝试三:手写 SIMD + SoA 分离 → 4.67× ✓
核心洞察:把 Complex32 的 interleaved 布局拆成两个平行数组——re[] 和 im[]。复数 MAC 变成:
re_out += re_ir × re_in − im_ir × im_in
im_out += re_ir × im_in + im_ir × re_in
纯 f32x4_mul + f32x4_add + f32x4_sub,零 shuffle。一次 v128 处理 4 个 f32(1 个复数对), 6 条 SIMD 指令完成 8 个乘加。Cathedral 3s IR 实测:
| 版本 | 每帧延迟 | 实时倍率 | 帧预算占比 |
|---|---|---|---|
| Scalar baseline | 0.308ms | 8.6× RT | 11.6% |
| SoA SIMD128 | 0.066ms | 40.5× RT | 2.5% |
4.67× 加速,逐样本零误差。性能瓶颈从来不在 SIMD 指令集—— 在数据布局。interleaved 的 Complex32 是给人看的,SoA 才是给机器吃的。
SoA 分离复数:为什么零 shuffle 这么重要
传统的复数乘法把实部虚部交错存储:[re₀, im₀, re₁, im₁]。 一个 v128 寄存器装 4 个 f32 = 2 个复数。乘法之前要先把实部抽出来、虚部抽出来, 这就是 v128.shuffle 的来源——3 次 shuffle 才能完成一轮 MAC。
SoA 把实部和虚部分成两个独立数组:re = [re₀, re₁, re₂, re₃],im = [im₀, im₁, im₂, im₃]。一个 v128 装的都是同类型的 f32, 直接 f32x4_mul 一次算 4 组——不需要任何重排。
对 Cathedral 3s IR 来说,1,125 个 partition × 256 个频点, 每帧 288,000 次复数 MAC。SoA 布局下每次 MAC 只需 6 条 SIMD 指令, 288k × 6 ≈ 1.7M 条 SIMD 指令/帧。而 AoS shuffle 版需要 288k × 10 ≈ 2.9M 条,且 shuffle 的延迟远高于 mul/add。
这就是"零 shuffle"的物理意义:不是指令少了,是每条指令的吞吐量更高。
用 FLOPs 定律处决落后算法
下面的沙盒里,你可以在自己的浏览器里实测 scalar vs SIMD 的性能对比。 切到 "WASM FFT" Tab,点 "Measure A/B"——V8 会在你的 CPU 上跑 5000 帧卷积, 给出实测延迟和加速比。
延迟数字来自浏览器实测(scalar wasm + simd wasm 各 5000 帧), 不是理论推导。基准算力因设备而异,但 SoA vs AoS 的复杂度差异是数学事实。
本系列上一篇:128位向量暴力美学:WASM-SIMD 重构音频核心
更早一篇:逃离V8的引力:WASM零拷贝音频引擎