6.1 KiB
SIMD 与运行时派发
双耳通路里最重的那几段循环(QMF 分析/合成、混合分析的低频拼接、混合域路径渲染、 ROOM 的 FFT、球谐对齐)都有一份运行时分派的向量实现:同一份二进制里装多套 ISA 代码, 启动时问一次 CPU,然后选最宽的那套跑。输出逐字节不变——这是硬约束,不是目标。
JOC_SIMD=auto|scalar|sse2|avx2|avx512|neon 强制某一层(验收用)
JOC_SIMD_LOG=1 打印每个 kernel 实际生效的 ISA
为什么必须"按编译单元分 ISA"
MSVC 没有 __attribute__((target("avx2"))) 这类函数级多版本能力,一个 .cpp 只能有
一个 /arch。所以每个 ISA 一个编译单元,各自带自己的 /arch:AVX2 / /arch:AVX512
(GCC/Clang 是 -mavx2 / -mavx512f),由 dispatch.cpp 在运行时填函数表。
基线单元(含派发器本身、CPU 探测、标量参考实现)不带任何 /arch,它们只使用
x86-64 架构保证的 SSE2。
历史坑:早先的 JOC_ENABLE_AVX2 是全局的,一旦打开,连"给老 CPU 用"的基线路径
都带 AVX2 指令。现在这个选项只决定是否把 AVX2 单元编进二进制。
目录布局
一个扁平目录,ISA 写在文件名里,不写进子目录——这是 FLAC 的做法
(src/libFLAC/lpc.c 旁边就是 lpc_intrin_sse2.c / lpc_intrin_avx2.c /
lpc_intrin_neon.c,CPU 探测单独放在 cpu.c)。
src/simd/
simd.h 唯一契约头:Isa / Kernel / Kernels / 维度常量
cpu_probe.{h,cpp} "这台机器能不能跑 ISA X":CPUID+XGETBV / __builtin_cpu_supports / getauxval
dispatch.cpp 策略:JOC_SIMD 解析、回退阶梯、填函数表、日志
kernels_scalar.cpp 参考实现(Isa::scalar,永远编译、永远可选中)
kernels_intrin_avx2.cpp /arch:AVX2 -mavx2
kernels_intrin_avx512.cpp /arch:AVX512 -mavx512f
kernels_intrin_neon.cpp AArch64 默认 -march=armv8-a+simd
simd.h 的头注释里有一份同样的模块地图,改布局时两处一起改。
模块放在 src/simd/ 而不是 src/dsp/simd/:这些 kernel 被 foundation/、hrtf/、
binaural/ 三个模块共用,是横切的一层,不是 DSP 的子模块(更何况 src/dsp/ 里除了
simd/ 空无一物)。
三条规则
- 只有
kernels_intrin_*.cpp拿更宽的编译开关。CMakeLists.txt用set_source_files_properties逐个点名,其余目标一律留在架构保证的 ISA 上。 任何不属于这个命名却带了宽指令的单元都会被devtools/vec/isa_audit.ps1判失败。 - ISA 单元里不许有动态初始化。 它们和基线代码链进同一个镜像,全局构造函数会在
派发器看 CPU 之前就跑宽指令。常量表没问题(落在
.rdata)。 - 逐位一致靠的是 lane 的划分,不是 ISA。 lane 里只能放互相独立的输出;单个输出
的舍入序列、乘加分离(绝不用 FMA)、求和顺序都保持
kernels_scalar.cpp原样。 只改变 double 存放顺序的布局改造(基函数表转秩小序、抽头表按项序、蝶形因子表 按级连续化、ROOM 谱平面改频带主序)是允许的。
运行时怎么选
dispatch.cpp 先解析 JOC_SIMD(强制一个本机不支持的层会打印诊断并回退,而不是假装
选中然后崩),再走阶梯 avx512 → avx2 → sse2 → neon,每个 kernel 单独挑"已编进本
二进制 且 本机可跑"的最宽实现,挑不到就落到标量参考实现。所以 AVX-512 单元在
不支持它的 CPU 上只是不被选中,不影响启动。
x86 的探测要 CPUID 与 XGETBV 同时成立:CPUID 说明硅片有这个能力,XCR0 说明操作 系统会保存对应寄存器状态,缺一个就不能用(否则上下文切换会踩坏别的线程)。AArch64 不需要探测,ASIMD 是架构基线。
sse2 是一个有意保留的档位但没有单独的单元:128 位 SSE2 寄存器就是标量 double
已经在用的寄存器,SSE2 加宽不了双精度运算,手写只会多出搬运指令,所以它选中的是基线
单元。
效果
30 s 参考用例,同一二进制只切 JOC_SIMD(DSP 阶段 t_render_dsp):
| 用例 | scalar |
auto |
DSP 加速 | 端到端墙钟 |
|---|---|---|---|---|
| 双耳 Rosella | 1.256 s | 0.640 s | 1.96× | 1.690 → 0.941 s |
| 双耳 SOFA | 1.560 s | 0.654 s | 2.39× | 1.859 → 0.849 s |
| 扬声器 5.1 / 9.1.6 / ADM | — | — | 1.00× | 0 回归(不走这些 kernel) |
单看被向量化的循环,红利更大:合成基函数 5.89×、13 抽头低频拼接 4.50×、 SOFA QMF 合成 4.27×、128 点 FFT 2.24×。整条流水线到不了 8×,是因为相当一部分时间在 参数设置、直通拷贝、写盘这些没有独立工作项的代码上,以及 SOFA 每次采样的 33 M 次 sin/cos 按逐位契约不能向量化。
验证
$env:JOC_SIMD_LOG='1' # 本机每个 kernel 实际选中的 ISA
$env:JOC_SIMD='scalar' # 强制参考路径:哈希必须一动不动
pwsh -NoProfile -File devtools\vec\isa_audit.ps1 # 反汇编全部 .obj:0 个无守卫的宽指令
pwsh -NoProfile -File devtools\vec\sha_matrix.ps1 # 5 档 × 2 渲染,逐字节比对参考摘要
cmd /c devtools\vec\build_kernel_probe.bat # kernel 级逐字节摘要(8 个 kernel)
加一个新的 ISA
- 写
kernels_intrin_<isa>.cpp,实现能实现的槽位,其余留nullptr——派发器会逐 kernel 回退(AVX-512 单元里的qmf_synthesis_basis就是这么处理的)。 - 在
CMakeLists.txt里加进JOC_SIMD_SOURCES、JOC_SIMD_HAVE_<ISA>=1和它的编译 开关,并在simd.h/dispatch.cpp里补上Isa、isa_rank、isa_compiled、isa_supported与JOC_SIMD名字表。 - 验证:kernel 摘要必须与标量逐字节相同,参考渲染在每个档位上的 SHA-256 都不能变。