C++26 发布了一个无人要求的 SIMD 库
摘要
文章批评了 C++26 中的新 std::simd 库,认为它比标量循环慢,编译速度慢,并且被自动向量化器和 Google Highway 等替代库超越,质疑其在经过十年标准化过程后的价值。
<p><a href="https://lobste.rs/s/5wy2mq/c_26_shipped_simd_library_nobody_asked_for">评论</a></p>
查看缓存全文
缓存时间: 2026/05/14 16:33
# C++26 发布了一个没人要的 SIMD 库
来源:https://lucisqr.substack.com/p/c26-shipped-a-simd-library-nobody
C++26 带来了 `std::simd`(P1928),一个基于库的可移植 SIMD 抽象。它的卖点很诱人:一次编写 SIMD 代码,编译到 AVX2、AVX‑512、NEON、SVE。不再需要 `#ifdef __AVX512F__` 这样的 spaghetti 代码。不再需要内建函数。只需 `std::simd`,剩下的交给编译器。
NoNaeAbC 的一个讽刺性仓库(https://github.com/NoNaeAbC/std_simd)最近引起了广泛关注,它提出了“使用 std::simd 的 6 个理由”——每一个都验证了一个真实的缺陷。我复现了基准测试并深入挖掘。
**它编译慢 10 倍,运行比标量循环还慢,默认使用错误的向量宽度,并且无法表达真正 SIMD 代码中重要的操作。**
编译器自动向量化——`std::simd` 本该替代的东西——在每一项关键指标上都击败了它。
`std::simd` 的故事始于一个人:**Matthias Kretz**,德国达姆施塔特重离子研究中心(GSI Helmholtzzentrum für Schwerionenforschung)的研究员。大约 2009‑2010 年,Kretz 构建了 Vc 库(https://github.com/VcDevel/Vc)——用于高能物理模拟向量化的“可移植、零开销的 C++ 显式数据并行类型”。Vc 是一个严肃的项目:超过 5000 次提交,在 CERN 使用,是最早尝试干净 C++ SIMD 抽象的项目之一。思路是对的:通过类型系统表达并行性,而不是通过内建函数或新的控制结构。Kretz 随后将 Vc 的设计带到了 C++ 委员会。
**该提案经历了一段异常漫长的标准化过程。**
P0214(“数据并行向量类型与操作”)大约在 2016 年出现,经历了至少九个修订版本。它作为并行性 TS 2(ISO/IEC TS 19570:2018)的一部分发布——这是一种技术规范,是委员会表达“我们觉得这很有趣,但还没准备好承诺”的方式。GCC 11 在 2021 年通过 `<experimental/simd>` 提供了一个实验性实现,Kretz 在 VcDevel/std‑simd(https://github.com/VcDevel/std-simd)维护了一个独立版本。
然后来了 P1928,该提案将 `std::simd` 从实验性 TS 提升到 C++26 正式标准。有趣的地方从这里开始。提案以某种形式在委员会讨论了近十年,才被投票进入 C++26。在这十年里,**竞争格局在其脚下发生了巨大变化。**
GCC、Clang 和 MSVC 中的自动向量化器大幅改进。ISPC 证明了语言级别的 SIMD 能够比库级别的抽象生成更好的代码。ARM 发布了 SVE,这是一种可伸缩宽度的 SIMD ISA,从根本上挑战了固定宽度的抽象。而编译器对 `-march=native` 的支持已经成熟,标量循环可以常规地自动向量化到最宽的可用寄存器。
Kretz 最初的愿景——一次编写 SIMD 代码,到处编译——是一个值得追求的目标。2012 年的 Vc 库确实领先于时代。问题是,2026 年的 `std::simd` 是 2012 年的解决方案,但世界已经向前发展了。委员会花了十年打磨一种基于库的方法,而编译器自动解决了简单的情况,ISPC 则通过语言级别的支持解决了困难的情况。当 `std::simd` 从实验性走向标准时,它已经要跟那些做得更好的工具竞争——而这些工具已经取得了十年的先发优势。
在 `std::simd` 艰难地在委员会中推进的时候,开源生态系统并没有等待。几个库现在占据了 `std::simd` 设计的准确空间——而且它们做得更好,因为它们可以根据实际用户反馈进行迭代,而不是依赖委员会共识。
**Google Highway(https://github.com/google/highway)** 是最严肃的竞争对手。它自称是“性能可移植、长度无关的 SIMD,带运行时调度”。最后一点很重要:Highway 可以在运行时检测 CPU,并调度到最佳的可用 SIMD 实现——SSE4、AVX2、AVX‑512 或 NEON/SVE——无需重新编译。`std::simd` 根本没有运行时调度的故事。Highway 是长度无关的,意味着它可以自然地处理 ARM SVE 的可伸缩向量,而 `std::simd` 的固定宽度模型无法表达这一点。采用列表不言自明:Chromium、Firefox、JPEG XL (libjxl)、libaom (AV1 编解码器)、Jpegli、libvips。**当 Google 需要为生产级图像和视频编解码器提供可移植 SIMD 时,他们构建了 Highway——而不是 `std::simd`。**
不过 Highway 也不是没有问题。API 冗长而特立独行——一切通过标签分派的自由函数进行,比如 `hn::Mul(d, a, b)`,而不是通过运算符重载,这即使简单的算术也读起来像汇编伪代码。运行时调度机制要求你的代码围绕 `HWY_DYNAMIC_DISPATCH` 宏来组织,这会跨多个编译目标分割你的源代码。这是一个 Google 项目,具有 Google 级别的维护规模,但公共汽车因子是真实存在的——核心开发由一个小团队推动,如果 Google 的优先级发生变化(确实如此),库的未来就会变得不确定。而且,长度无关意味着你无法轻易表达依赖于在编译时知道向量大小的固定宽度算法,这在密码学和编解码器工作中很常见。
**SIMDe(SIMD Everywhere)(https://github.com/simd-everywhere/simde)** 采用了完全不同的方法。它不是抽象内建函数,而是提供可移植的 *实现*。你编写 `_mm256_shuffle_epi8()`,SIMDe 通过将其翻译为 NEON/SVE 等效实现,使其在 ARM 上也能工作。这意味着现有的内建函数代码无需重写就能获得可移植性。它涵盖了 `std::simd` 不涉及的跨通道操作、混洗和宽度特定的算术。哲学是实用的:开发者已经知道内建函数,所以让内建函数变得可移植,而不是发明新的抽象。
其反面是 **SIMDe 将你锁定在 Intel 的思维模式中。** 你的“可移植”代码仍然围绕 128 位和 256 位固定宽度操作来组织——无法原生表达可伸缩宽度的 SVE 算法。从 x86 内建函数到 ARM 等效实现的翻译并不总是一一对应;一些 `_mm256_*` 操作会分解成多个 NEON 指令,带来如果你编写了 ARM 原生代码就不会存在的开销。你还会继承 Intel API 的瑕疵——不一致的命名、隐式的宽度假设、巴洛克式的混洗语义。SIMDe 是让 x86 SIMD 代码在 ARM 上运行的出色迁移工具,但用 Intel 内建函数编写*新*的跨平台代码,指望 SIMDe 来翻译它们,那是反向解决可移植性问题。
**xsimd(https://github.com/xtensor-stack/xsimd)** 覆盖了 SSE 到 AVX‑512、NEON、SVE、WebAssembly SIMD、Power VSX 和 RISC‑V 向量。它是 xtensor 数值计算生态系统的 SIMD 后端,提供了与 `std::simd` 类似的批次类型,但迭代周期更快且架构覆盖更广。话虽如此,xsimd 和 EVE 一样,共享相同的库级别的优化器不透明性——编译器看到的是 `batch` 模板,而不是向量指令。该项目与 xtensor 生态系统紧密耦合,这意味着开发优先级跟踪数值计算用例,而非那些 SIMD 最重要的编解码器/图像/HFT 工作负载。**文档很薄弱,社区相比 Highway 很小,当出现问题时,你更多是阅读源代码而不是文档。**
**EVE(Expressive Vector Engine)(https://github.com/jfalcou/eve)** 值得特别关注,因为它的构建者是谁。Joel Falcou 是 C++ 委员会参与者,合著过关于 SIMD 和并行性的论文——他从内部见证了 `std::simd` 并构建了不同的东西。EVE 是他早期 Boost.SIMD 库(发表于 PACT 2012)的 C++20 从头重写,使用概念和现代模板技术。它覆盖了 SSE2 到 AVX‑512、NEON、ASIMD 和 SVE(固定寄存器大小)。但问题是:**EVE 同样遭受许多与 `std::simd` 相同的结构性问题。** 它仍然是一种基于库的方法,这意味着优化器不透明性问题依然存在——编译器看到的还是模板实例化,而不是 SIMD 原语。SVE 支持限于固定大小(128、256、512 位),而不是 SVE 全部意义的动态可伸缩向量。没有像 Highway 那样的运行时调度。Visual Studio 支持列为“待定”——这意味着最广泛使用的桌面操作系统上最广泛使用的 C++ 编译器无法编译它。项目自己的 README 称其为“首先是研究项目,其次是开源库”,并且尚未达到 1.0 版本,保留随时破坏 API 的权利。PowerPC 支持不完整。采用案例很少——没有能与 Highway 的 Chromium/Firefox/JPEG XL 名单相匹敌的主要生产用户。EVE 是设计更好的 `std::simd`,由了解委员会局限性的人构建,但更好的库抽象仍然是库抽象。根本问题——将 SIMD 包装在 C++ 模板中会损失优化器可见性——不关心你的概念有多优雅。
**Agner Fog 的向量类库(https://github.com/vectorclass/version2)** 十多年来一直是业界支柱——围绕内建函数的薄 C++ 包装,手动控制向量宽度,广泛用于科学计算。它早于 Vc 并且始终将可预测的代码生成置于抽象之上。**VCL 的弱势是其优势的镜像:它仅限 x86。** 没有 ARM,没有 NEON,没有 SVE,没有 WebAssembly。如果你的代码需要运行在 Apple Silicon、AWS Graviton 或 Android NDK 上,VCL 就是死胡同。它本质上也是一个单人项目——Agner Fog 维护它,当他停止时,开发就停止了。该库并不假装可移植,这很诚实,但意味着随着世界走向异构架构,VCL 解决的问题正在缩小。
然后还有 **ISPC**,正如我们稍后讨论的,它在语言层面而非库层面解决了问题——对于控制流密集的 SIMD 工作负载,它能生成比上述所有库都更好的代码。ISPC 根本不是一个 C++ 库——它是一个独立的编译器,有自己的语言语法,这意味着需要单独的构建步骤、单独的调试工具以及开发者的心理上下文切换。你不能在 ISPC 函数上进行模板化,不能在 ISPC 内核中使用 C++ 类,ISPC 和 C++ 之间的互操作边界是一个扁平的 C ABI。对于 95% 是 C++ 只有几个热点 SIMD 内核的项目来说,这种集成代价是合理的。对于需要在许多小函数中散布 SIMD 的项目,维护两种语言的开销很痛苦。
模式很清晰:每一个真正需要生产级可移植 SIMD 的主要项目都选择了第三方库或不同的语言。没有人等待 `std::simd`。当它在 C++26 中发布时,这些库将拥有十年的生产级实战测试、真实用户反馈和跨平台覆盖,这些是 `std::simd` 在一开始无法匹敌的。而最致命的证据可能是 EVE 本身——一个委员会成员看了看 `std::simd`,认为它不够好,然后构建了自己的库。即便如此,库方法仍然遇到了同样的墙。
引入 `<experimental/simd>` 会拉入深层嵌套的模板机制——`simd.h`、`simd_x86.h`、`simd_builtin.h` 及其同类。一个在 SIMD 向量上计算 `sin` 的平凡函数大约需要 2.2 秒来编译。等效的标量 for 循环?0.2 秒。**每个翻译单元就有 10 倍的编译时间惩罚**,而这是 GCC 14 中的实验性头文件,当前最成熟的实现。每个涉及 `std::simd` 的文件都要付出这个代价。在一个有数百个处理市场数据的翻译单元的交易系统中,这会累积成几分钟的浪费构建时间,而代码——正如我们将看到的——运行起来也更慢。
模板过重的实现也意味着错误消息极其糟糕。尝试在 `std::simd` 中使用 `where()` 表达式,你会得到 138 行模板实例化错误,引用像 `_SimdWrapper<_Float16, 8, void>` 和 `_VectorTraitsImpl` 这样的内部类型。你的源代码只有 6 行。一个语言级别的 SIMD 特性可以产生有针对性的诊断。**基于库的方法在出错时会泄露其整个实现。**
以下是令人尴尬的地方。使用 `-O3 -ffast-math -march=native`,一个标量 `sin` 循环会自动向量化并击败显式的 `std::simd` 版本:
表1(图片来源:https://substackcdn.com/image/fetch/$s_!MTs6!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7ffc39a3-196d-4628-8524-7cf69915c1e2_598x226.png)
编译器知道 `-fveclib=libmvec`,可以将标量数学调用路由到优化的 SIMD 实现。**`std::simd` 路径无法从相同的优化中受益,因为优化器无法看穿模板抽象层。**
这并不只是超越函数的一次性情况。考虑 `sqrt(x) * sqrt(x)` 并启用 `-ffast-math`。编译器将标量代码简化成仅仅是 `x`——整个函数体变成一条 `ret` 指令。`std::simd` 版本呢?它发出了实际的 `vsqrtps` + `vmulps`,因为优化器无法通过不透明的模板函数调用进行代数化简:
代码块1(图片来源:https://substackcdn.com/image/fetch/$s_!gvPy!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5c607e82-1e1f-4ee3-94ad-3bf85e530dca_1146x365.png)
任何需要推理数学属性的优化——常量折叠、强度折减、代数恒等式——都会受到库抽象的阻碍。编译器看到的是 `std::experimental::simd::operator*`,而不是“乘法”。这在热路径中至关重要。
这是最具后果的设计缺陷,它会在生产代码中悄无声息地破坏性能。`std::simd::size()` 返回“ABI 安全”的原生宽度。在具有 256 位寄存器(8 个 int)的 AVX2 机器上,它返回 4。在具有 512 位寄存器(16 个 int)的 AVX‑512 上,仍然返回 4。**默认的 `std::simd` 类型使用 128 位 SSE 宽度,无论硬件实际支持什么。** 与此同时,带有 `-march=native` 的标量 for 循环自动向量化到完整的机器宽度。
基准测试结果惨不忍睹:
表2(图片来源:https://substackcdn.com/image/fetch/$s_!3fz_!,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9578d0ac-48e0-4473-b669-d649e191375a_884x270.png)
去掉基线开销后,`std::simd` 版本大约需要 326ns,而标量循环大约需要 137ns。**“可移植 SIMD”代码比普通 for 循环慢 2.4 倍。** 而且 `std::simd` 版本需要大约 3 倍的源代码:手动循环分块、带对齐标签的显式加载/存储、用于尾部掩码的 `where()`,以及一个标量剩余循环。
你可以通过请求特定宽度来修复这个问题——`std::simd<float, 8>` 用于 AVX2——但那样你就硬编码了宽度,失去了作为全部卖点的可移植性。或者你可以使用 `std::native_simd`,但这映射到“原生 ABI”宽度,在大多数实现上仍然是 128 位。整个抽象都在与你作对。
当你看向 ARM 时,可移植性的故事变得更糟。在带有 SVE(可伸缩向量扩展)的 aarch64 上,标量 for 循环使用 SVE 谓词指令自动向量化——`whilelo`、`ld1w`、`st1w`、`incw`——这是现代 ARM 硬件上最高效的 SIMD 惯用法。`std::simd` 版本在 ARM 上编译正常,但发出的是固定宽度 128 位
相似文章
C++26:标准库强化
C++26 引入了标准化的库强化机制,用于在运行时捕获常见的未定义行为(如越界访问)。基于 Google 的生产经验,此举仅带来 0.30% 的性能开销,同时将段错误减少了 30%。
C++ 标准库在过去十五年间一直在自我撤步,证据公开
一份详细的目录,列出了从 C++11 到 C++26 期间被正式弃用、非正式不推荐或由于 ABI 约束实际上已损坏但无法修复的 C++ 标准库特性。文章指出,C++ 委员会推出一系列替代品来替换其自身特性的模式始终如一,其中包含一个基准测试,显示 Rust 和 C++ 标准库容器之间的 P99 延迟差异高达 58 倍。
使用SIMD加速std::copy_if
一篇博文,分析和实现了在AMD Zen 4上使用AVX-512指令的SIMD加速版本的std::copy_if,并进行了性能分析和与编译器自动向量化的对比。
让编写跨平台 SIMD 代码变得愉快
作者详细介绍了 bx 库跨平台 SIMD 抽象的第三次迭代,倡导无类型方法和 SSA 风格编码,以简化不同 CPU 架构上的底层性能优化。
# 重新审视位旋转:关于 gcc 单向旋转算法的惊人发现 在我[上一篇关于旋转的文章](https://blog.regehr.org/archives/1063)中,我发现了不同编译器识别旋转习语的能力存在差异。接下来我想看看实际生成的代码质量如何,结果发现了一些令人惊讶的东西。 ## 背景 旋转操作可以用多种方式表达。双向版本如下所示: ```c unsigned rot32(unsigned x, int n) { return (x << n) | (x >> (32 - n)); } ``` 这里 `n` 的范围是 1..31。通常情况下,编译器可以很好地处理这种形式——它非常标准,GCC、Clang 和其他编译器都能将其编译为单条旋转指令(例如 x86 上的 `rol`)。 单向版本则稍有不同: ```c unsigned rotl32(unsigned x, int n) { return (x << n) | (x >> (-n & 31)); } ``` 这里旋转量可以是 0..31 中的任意值。`-n & 31` 这个技巧可以在不引入未定义行为的情况下处理 `n=0` 的边界情况(当 `n=0` 时,`32-n` 会产生移位量为 32 的移位操作,而这在 C 语言中是未定义行为)。 ## 令人惊讶的发现 让我来看看 GCC 如何处理这些代码。对于标准的双向旋转,GCC 生成的代码如预期那样: ```asm rol %cl, %edi mov %edi, %eax ret ``` 完美。只有一条旋转指令。 但对于单向版本(使用 `-n & 31`),GCC 生成的代码却令人大跌眼镜: ```asm mov %edi, %eax mov %esi, %ecx roll %cl, %eax ret ``` 等等,这其实也不错。让我检查一下更复杂的情况…… 实际上,真正令人震惊的发现出现在某些特定版本的 GCC 中。当对单向旋转使用某些写法时,GCC 有时会生成**错误的代码**。 ## 具体问题 考虑以下代码: ```c #include <stdio.h> #include <stdlib.h> unsigned rotl32a(unsigned x, unsigned n) { return (x << n) | (x >> (-n & 31)); } unsigned rotl32b(unsigned x, unsigned n) { return (x << n) | (x >> (32 - n)); } ``` 这两个函数在 `n` 的范围是 1..31 时语义相同。但 `rotl32a` 在 `n=0` 时也能正确工作,而 `rotl32b` 在 `n=0` 时会产生未定义行为(右移 32 位)。 问题在于 GCC 对某些旋转习语的**优化过于激进**。GCC 内部会识别旋转模式,然后将其替换为旋转指令。然而,在识别 `-n & 31` 这种模式时,某些版本的 GCC 会错误地将其优化——生成的代码在 `n=0` 时返回错误结果,或者完全改变了运算的语义。 ## 测试方法 我编写了一个简单的测试程序来验证: ```c #include <stdio.h> #include <stdint.h> unsigned rotl32(unsigned x, unsigned n) { return (x << n) | (x >> (-n & 31)); } int main(void) { unsigned x = 0x12345678; for (unsigned i = 0; i < 32; i++) { printf("rotl32(0x%08x, %2u) = 0x%08x\n", x, i, rotl32(x, i)); } return 0; } ``` 在受影响版本的 GCC 上使用 `-O2` 编译并运行,会发现某些旋转量的结果是错误的。 ## 根本原因 深入研究后,问题出在 GCC 的**树级优化器**(tree-level optimizer)中。当 GCC 识别到旋转习语时,它会将其转换为内部的旋转树节点(`ROTATE` 或 `ROTATERT`)。 然而,GCC 在处理 `-n & 31` 时,会尝试简化这个表达式。在某些情况下,GCC 错误地假设 `n` 的范围,从而对旋转量进行了错误的变换。 具体来说,GCC 在执行以下变换时出现了问题: ``` (x << n) | (x >> (-n & 31)) ``` 转换为: ``` ROTATE(x, n) ``` 这个转换本身是正确的,但在后续的代码生成阶段,旋转量的处理可能出现偏差。 ## 影响范围 这个问题影响了: - 使用 `-n & 31` 技巧编写的"安全"旋转代码 - 在旋转量为 0 时的边界行为 - 使用受影响 GCC 版本(某些 4.x 和早期 5.x 版本)编译的代码 ## 解决方案 有几种解决方法: **方案一:显式处理零旋转** ```c unsigned rotl32(unsigned x, unsigned n) { if (n == 0) return x; return (x << n) | (x >> (32 - n)); } ``` **方案二:使用编译器内置函数**(如果可用) ```c // MSVC _rotl(x, n); // GCC/Clang(在某些平台上) __builtin_rotateleft32(x, n); ``` **方案三:使用 `__attribute__` 或 `#pragma` 禁用相关优化** **方案四:升级 GCC 版本** 较新版本的 GCC 已经修复了这个问题。 ## 更广泛的启示 这个发现揭示了几个重要问题: 1. **"安全"的代码并不总是安全的**:即使你精心编写了避免未定义行为的代码,编译器优化器仍然可能生成错误的代码。 2. **编译器 bug 确实存在**:我们往往过于信任编译器。像这样的 bug 提醒我们,对于关键代码,测试是必不可少的。 3. **旋转操作出奇地复杂**:看似简单的位操作在实现和优化时都存在微妙之处。 4. **模糊测试的价值**:这类 bug 很难通过代码审查发现,但通过随机测试相对容易发现。如果你的代码依赖旋转操作,请务必测试所有可能的旋转量,包括 0。 ## 结论 在大多数现代编译器版本中,`(x << n) | (x >> (-n & 31))` 这种写法可以被正确编译为单条旋转指令。但历史上确实存在编译器错误处理这种模式的情况。 对于安全关键的代码,建议: - 使用最新版本的编译器 - 对旋转函数进行全面测试 - 考虑使用 C++20 的 `std::rotl` 和 `std::rotr`(如果可用) 这个故事的寓意是:即使是看似简单的底层操作,也值得深入研究和验证。编译器是复杂的软件,偶尔也会出错。
Raymond Chen 探讨了 gcc libstdc++ 中针对随机访问迭代器的旋转算法,揭示其本质上与前向迭代器旋转算法相同,只是从不同角度来看待而已。本文是一个系列文章的一部分,该系列对比了不同编译器中旋转算法的实现方式。