108 lines
6.1 KiB
Markdown
108 lines
6.1 KiB
Markdown
# SIMD 与运行时派发
|
||
|
||
[English](simd.en.md) · [返回 README](../README.md)
|
||
|
||
双耳通路里最重的那几段循环(QMF 分析/合成、混合分析的低频拼接、混合域路径渲染、
|
||
ROOM 的 FFT、球谐对齐)都有一份运行时分派的向量实现:同一份二进制里装多套 ISA 代码,
|
||
启动时问一次 CPU,然后选最宽的那套跑。**输出逐字节不变**——这是硬约束,不是目标。
|
||
|
||
```text
|
||
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`)。
|
||
|
||
```text
|
||
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/` 空无一物)。
|
||
|
||
## 三条规则
|
||
|
||
1. **只有 `kernels_intrin_*.cpp` 拿更宽的编译开关。** `CMakeLists.txt` 用
|
||
`set_source_files_properties` 逐个点名,其余目标一律留在架构保证的 ISA 上。
|
||
任何不属于这个命名却带了宽指令的单元都会被 `devtools/vec/isa_audit.ps1` 判失败。
|
||
2. **ISA 单元里不许有动态初始化。** 它们和基线代码链进同一个镜像,全局构造函数会在
|
||
派发器看 CPU 之前就跑宽指令。常量表没问题(落在 `.rdata`)。
|
||
3. **逐位一致靠的是 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 按逐位契约不能向量化。
|
||
|
||
## 验证
|
||
|
||
```powershell
|
||
$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
|
||
|
||
1. 写 `kernels_intrin_<isa>.cpp`,实现能实现的槽位,其余留 `nullptr`——派发器会逐
|
||
kernel 回退(AVX-512 单元里的 `qmf_synthesis_basis` 就是这么处理的)。
|
||
2. 在 `CMakeLists.txt` 里加进 `JOC_SIMD_SOURCES`、`JOC_SIMD_HAVE_<ISA>=1` 和它的编译
|
||
开关,并在 `simd.h` / `dispatch.cpp` 里补上 `Isa`、`isa_rank`、`isa_compiled`、
|
||
`isa_supported` 与 `JOC_SIMD` 名字表。
|
||
3. 验证:kernel 摘要必须与标量逐字节相同,参考渲染在每个档位上的 SHA-256 都不能变。
|