C++26 正式引入了 std::simd(P1928),一个标榜为"写一次 SIMD 代码,编译到所有平台"的便携式 SIMD 抽象库。听起来很美好——不需要再写 #ifdef AVX512F 的条件编译,不需要手写 intrinsics,只需要 std::simd<float>,编译器会自动生成最优的 SIMD 指令。然而,一位来自低延迟交易社区的开发者 Henrique Bucher 在仔细测试后发现,std::simd 实际上比它要替代的方案更慢、更难用,而且错过了真正重要的 90% 的 SIMD 场景。
std::simd 的故事要从 2009 年说起。当时 Matthias Kretz 在德国重离子研究中心(GSI)工作时,为高能物理模拟开发了 Vc 库,这是一个"零开销的显式数据并行 C++ 类型"库,用于向量化和 SIMD 编程。Vc 是一个严肃的项目:5000 多次提交,在 CERN 使用,是最早尝试通过类型系统而非 intrinsics 或控制结构来表达并行的尝试之一。Kretz 将 Vc 的设计提交给了 C++ 标准委员会,提案 P0214 在 2016 年左右出现,经过至少九次修订,2018 年作为并行技术规范 TS 2 的一部分发布——这是委员会的方式表达"我们认为这很有趣但还没准备好正式承诺"。2021 年 GCC 11 以 <experimental simd> 的形式提供了实验性实现。
然后是 P1928,将 std::simd 从实验 TS 提升到 C++26 标准本身。这提案在委员会讨论了近十年。但在这十年里,竞争格局发生了巨大变化。GCC、Clang 和 MSVC 的自动向量化器大幅改进。ISPC 证明了语言级 SIMD 可以生成比库级抽象更好的代码。ARM 推出了 SVE——一种可变宽度的 SIMD ISA,从根本上挑战了固定宽度抽象的假设。而且编译器对 -march=native 的支持已经成熟到标量循环通常可以自动向量化到最宽可用寄存器的程度。Kretz 最初的愿景是写一次 SIMD 代码,编译到所有平台——仍然是一个有价值的目标。Vc 库在 2012 年确实是真正领先的。但问题是 2026 年的 std::simd 是 2012 年的解决方案在世界已经向前发展之后才到达。
在 std::simd 在委员会中缓慢推进的同时,开源生态系统的其他库并没有坐以待毙。Google Highway 是最认真的竞争者,标榜自己为"性能可移植、长度无关的 SIMD,带运行时调度"。最后一点很重要:Highway 可以在运行时检测 CPU 并调度到最佳可用 SIMD 实现——SSE4、AVX2、AVX-512 或 NEON/SVE——无需重新编译。std::simd 根本没有运行时调度故事。Highway 是长度无关的,意味着它自然地支持 ARM SVE 的可扩展向量,而 std::simd 的固定宽度模型根本无法表达。使用 Highway 的项目列表很能说明问题:Chromium、Firefox、JPEG XL(libjxl)、libaom(AV1 编解码器)、Jpegli、libvips。当 Google 需要用于生产图像和视频编解码器的便携式 SIMD 时,他们构建了 Highway 而不是 std::simd。
编译时间是第一个问题。包含 <experimental simd> 会引入深层嵌套的模板机制——simd.h、simd_x86.h、simd_builtin.h 等等。一个计算 SIMD 向量上 sin 的trivial 函数需要约 2.2 秒编译。而等效的标量 for 循环只需要 0.2 秒。这是每个翻译单元 10 倍的编译时间惩罚。更糟糕的是,模板密集型的实现也意味着错误信息非常糟糕。尝试使用std::simd<std::float16_t>配合 where() 表达式,你会得到 138 行模板实例化错误,引用 _SimdWrapper<_Float16, 8, void>和_VectorTraitsImpl这样的内部类型。
然后是性能。测试结果令人尴尬。使用 -O3 -ffast-math -march=native,标量 sin 循环自动向量化后比显式 std::simd 版本更快。原因在于编译器知道 -fveclib=libmvec 并可以将标量数学调用路由到优化的 SIMD 实现。std::simd 路径没有从相同的优化中受益,因为优化器无法看穿模板抽象层。任何需要推理数学属性(常量折叠、强度归约、代数恒等式)的优化,都被库抽象阻碍了。
默认宽度是一个沉默的性能杀手。std::simd<int>::size() 返回"ABI 安全的"原生宽度。在 AVX2 机器(256 位寄存器,8 个 int)上返回 4。在 AVX-512(512 位寄存器,16 个 int)上仍然是 4。默认的 std::simd 类型无论硬件实际支持什么,都使用 128 位 SSE 宽度。而标量 for 循环配合 -march=native 自动向量化到完整机器宽度。基准测试结果是残酷的:std::simd 版本需要约 326 纳秒,而标量循环只需要约 137 纳秒。"便携式 SIMD"代码比普通 for 循环慢 2.4 倍。
在 ARM 上情况更糟。在带 SVE(可扩展向量扩展)的 aarch64 上,标量 for 循环使用 SVE 预测指令自动向量化——whilelo、ld1w、st1w、incw——这是现代 ARM 硬件上最高效的 SIMD 方式。std::simd 版本可以编译,但在 ARM 上发出固定宽度的 128 位 NEON 指令。生成的汇编代码大约长 3 倍,根本没有使用 SVE。讽刺的是:std::simd 的可移植性意味着它可以在任何地方编译,但不在任何地方优化。
std::simd 只支持垂直操作(每条通道的输出只依赖于同一通道的输入)。这是 SIMD 中最简单的部分,自动向量化已经处理得很好。真正的 SIMD 代码 dominated by跨通道操作——shuffle、permute、水平归约、字节级查找表——这些 std::simd 根本无法表达。考虑 ffmpeg 在其编解码器 DSP 内核中做的事情:_mm256_shuffle_epi8 用于像素格式转换、_mm_sad_epu8 用于运动估计、_mm256_permutevar8x32_epi32 用于通道去交错、_mm256_maddubs_epi16 用于定点乘累加。这些都没有 std::simd 等效项。Pack/unpack 操作、饱和算术、movemask——这些是图像处理、视频编解码器、字符串搜索和压缩算法的基本功。std::simd 一个都没提供。
作者提出了一个尖锐的问题:谁需要这个? intrinsics 程序员需要精确控制 shuffle 模式、通道宽度和指令选择,std::simd 没有给他们这些。写标量循环的应用程序员已经有自动向量化了,而且它产生的代码比 std::simd 更好,源代码复杂度更低。std::simd 占据了一个尴尬的中区——对需要 SIMD 的人来说太高了,对不需要的人来说太低了。这是一个在任何地方都可以编译但在任何地方都不优化的便携式抽象。C++ 委员会花费十年打磨一个库方法,而编译器已经自动解决了简单情况,ISPC 用语言级支持解决了困难情况。
来源:C++26 Shipped a SIMD Library Nobody Asked For (https://lucisqr.substack.com/p/c26-shipped-a-simd-library-nobody)