Files
JustOneCacophony/docs/simd.md
T
TheM14 42d6da6302
ci / ubuntu-latest (push) Failing after 13s
ci / macos-latest (push) Has been cancelled
ci / windows-latest (push) Has been cancelled
Initial pure C++ implementation
2026-09-25 04:17:30 +08:00

6.1 KiB
Raw Blame History

SIMD 与运行时派发

English · 返回 README

双耳通路里最重的那几段循环(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/ 空无一物)。

三条规则

  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 按逐位契约不能向量化。

验证

$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 都不能变。