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

  1. 把长 IR 切成 128-sample 的小块(cathedral 有 1,125 个 partition)
  2. 每块独立 FFT → 频域复数乘法 → IFFT
  3. 重叠相加,拼出完整卷积

频域乘法才是真正的热路径——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 baseline0.308ms8.6× RT11.6%
SoA SIMD1280.066ms40.5× RT2.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 帧卷积, 给出实测延迟和加速比。

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

延迟数字来自浏览器实测(scalar wasm + simd wasm 各 5000 帧), 不是理论推导。基准算力因设备而异,但 SoA vs AoS 的复杂度差异是数学事实。


本系列上一篇:128位向量暴力美学:WASM-SIMD 重构音频核心

更早一篇:逃离V8的引力:WASM零拷贝音频引擎

← 返回博客列表