让编写跨平台 SIMD 代码变得愉快

Lobsters Hottest 工具

摘要

作者详细介绍了 bx 库跨平台 SIMD 抽象的第三次迭代,倡导无类型方法和 SSA 风格编码,以简化不同 CPU 架构上的底层性能优化。

<p><a href="https://lobste.rs/s/bmfnws/making_cross_platform_simd_code_pleasant">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/05/11 13:03

# 让跨平台 SIMD 代码编写更加愉悦 来源:https://bkaradzic.github.io/posts/typeless-simd/ ## 介绍 编写快速、可移植的 SIMD(Single Instruction, Multiple Data)(https://en.wikipedia.org/wiki/Single_instruction,_multiple_data) 代码一直是个痛苦的过程 —— 冗长的内置函数 (https://en.wikipedia.org/wiki/Intrinsic_function)、平台差异,以及一个在每一步都与你作对的类型系统。经过三次主要迭代后,`bx` 库 (https://github.com/bkaradzic/bx#bx) 现在有了一个更令人愉悦的解决方案。 `bx` 库从其初始提交 (https://github.com/bkaradzic/bx/commit/4eb80393d11e4cfa65be28bc01057d51d99863a2#diff-5c4f7073eb8792c7b081e10bd217affa861267c6db904e4046d74ccf5ae99557) 开始就支持 SIMD,最初的形式是 `float4_t`。后来的一次重大更新 (https://github.com/bkaradzic/bx/commit/224e990ee4cbfdede21daa790970abebe141836c#diff-525cfe64c6f9487eba1592648cbeba17bdfd0d0182fc7f9388663e998e8a0345) 将 `float4_t` 重命名为 `simd128_t`,并为寄存器宽度类型添加了泛型支持。那个接口仍然过于依赖浮点数,并且缺乏合适的通道(lane)类型——这主要是因为当时 CPU 上常见的可用 SIMD 特性严重偏向浮点中心。 我现在进行了第三次尝试。本文介绍了 `bx` 中结果得到的跨平台无类型 SIMD 库背后的架构决策。如果你宁愿跳过大段文字,实现在这里 (https://github.com/bkaradzic/bx/blob/master/include/bx/simd_t.h)。 ## SIMD 代码应该如何编写? 在将代码 SIMD 化时,最重要的一件事是改变数据布局,以便 SIMD 处理器能够高效地访问数据。这部分本质上是跨平台的——唯一重要的是你所针对平台上最大的寄存器宽度。仅仅是做布局更改,在你触碰单个内置函数之前,往往也能提高标量性能。 你应该总是从一个纯 C/C++ 参考实现开始,并且该参考实现应该与 SIMD 版本保持同步。这对于单元测试有用,当特定于平台的代码做出一些令人惊讶的事情时用于调试,并作为说明每个操作实际应该做什么的活文档。对于大多数用例,参考实现加上一个跨平台 SIMD 实现就足够了。如果你有一个值得付出努力的热点内循环,你可以为特定的 CPU 添加第三个手工优化路径——但你不应该**从那里开始**。 优先使用静态单赋值(SSA)形式,意味着**每一行是一个 SIMD 操作,生成一个新的名为 `const` 的临时变量**。无重新赋值,无嵌套调用。实际上这意味着,而不是这样写: ``` 1 val = simd_or(val, simd_x32_srl(val, 1)); 2 val = simd_or(val, simd_x32_srl(val, 2)); 3 val = simd_or(val, simd_x32_srl(val, 4)); 4 // ... ``` 其中每一行都会遮蔽前一行的 `val` 含义,操作顺序隐藏在嵌套之中,任何指向它的调试器都会显示你沿途具有八个不同值的单一名称,我们应该这样写: ``` 1 const auto shr1 = simd_x32_srl(val, 1); 2 const auto or1 = simd_or(val, shr1); 3 const auto shr2 = simd_x32_srl(or1, 2); 4 const auto or2 = simd_or(or1, shr2); 5 const auto shr4 = simd_x32_srl(or2, 4); 6 const auto or4 = simd_or(or2, shr4); 7 // ... ``` 每个中间步骤都有一个名字,每个名字只出现在左侧一次,数据流从上到下阅读。优化器不在乎——无论哪种方式生成的指令都是相同的。 ## 为什么要无类型? 我认为强类型对编写 SIMD 代码有害。真实的 SIMD 代码不断混合整数和浮点运算:比较指令产生掩码,通过位运算将掩码应用到通道,无分支代码源自 `selb(mask, a, b)`。在同一寄存器的整数和浮点视图之间进行类型重解释是无法避免的。类型系统只会碍事。 SSE/AVX 内置函数是半类型的:只有几种类型(`__m128`/`__m128i`/`__m128d`),但指令仍在其名称中编码通道类型(`_mm_add_ps` 对比 `_mm_add_epi32`)。类型系统除了当你抓取错误的内置函数时给你编译错误外,一无所获。NEON 内置函数走得更远,使每个通道配置成为一个独立的类型(`float32x4_t`、`int32x4_t`、`uint8x16_t`、...),伴随着更多的强制转换。 只有 WASM SIMD 内置函数走了明智的道路并完全去掉了类型——指令编码了解释通道的方式;值只是一堆比特,硬件本来就是这样看待它的。硬件 SIMD 寄存器(XMM、YMM、NEON 寄存器)真的只是比特的集合。你在其上执行的指令决定了这些比特如何被解释。一旦你接受了这一点,就会发生两件事: - **类型重解释转换消失了。**不再需要 `_mm_castps_si128`,也不需要 `vreinterpretq_f32_u32`。同一个 `simd128_t` 流经 `f32_add`、`u32_cmpgt`、`selb`,然后回到 `f32_mul`,中间没有任何语法仪式。 - **代码读起来就像算法。**以前是噪音的类型重解释步骤根本就不存在了。 ## bx SIMD 命名约定 ``` simd[register-width][_]_(...) <> - not optional [] - optional register-width: 32, 64, 128, 256 (omitted for width-generic templates to operate on any available register width) lane-type: f - floating point i - signed integer u - unsigned integer x - typeless bitwise lane-type-width: 8, 16, 32, 64 +----+----+----+----+----+----+----+----+- ~ -+----+ | 00 | 01 | 02 | 03 | 04 | 05 | 06 | 07 | ~ | NN | bytes +----+----+----+----+----+----+----+----+- ~ -+----+ | register width 32, 64, 128, 256 | +----+----+----+----+----+----+----+----+- ~ -----+ | u8 | u8 | u8 | u8 | u8 | u8 | u8 | u8 | ~ ... | +----+----+----+----+----+----+----+----+- ~ -----+ | u16 | u16 | u16 | u16 | ~ ... | +---------+---------+---------+---------+- ~ -----+ | u32 | u32 | ~ ... | +-------------------+-------------------+- ~ -----+ | u64 | ~ ... | +---------------------------------------+- ~ -----+ ``` `simd32_f32_add` — 在带有浮点分量的 32 位 SIMD 寄存器上的算术加法,即单个浮点数。`simd128_f32_add` — 同上,但是在 128 位 SIMD 寄存器上,它包含 4 个浮点数。`simd_f32_add` — 宽度泛型实现;实际宽度取决于传入的 SIMD 类型。大多数代码应该以这种风格编写,因此它可以跨越所有宽度保持可移植性。 ## `simd32_t` 作为 SIMD 化的切入点 起初 `simd32_t` 看起来毫无意义——它是一个单独的 32 位通道,形状与普通 `uint32_t` 相同。这正是重点所在。它不是一个宽度,它是一个**约定**。当你拿着一段现有的纯 C/C++ 代码并开始将其 SIMD 化时,第一步是将局部变量重新声明为 `simd32_t`,并将操作重写为 `simd32_*` 调用。数据布局此时尚未改变。改变的是你现在是按通道而不是按数值来思考。分支变为掩码,掩码变为 `selb`。一旦代码以这种方式编写,从 32 位扩展到 128 位大部分只是查找替换——你将 `simd32_t` 替换为 `simd128_t`,将循环指向四元素步长,操作保持不变。 在很多情况下,重写为 `simd32_t` 是你需要做的**全部**工作——结果已经在任何宽度寄存器上完美运行,因为代码一开始就不关心宽度。这就是优势:你一步就能得到 SIMD 形态的代码。 ## ABI 考虑因素 对于原生向量类型(`__m128`、`__m256`、`float32x4_t`),值传递在所有地方都是快速路径。它们直接进入向量寄存器。引用传递强制进行存储到内存并通过指针加载的往返,这严格来说更差。 | ABI | __m128 / float32x4_t | __m256 | | :--- | :--- | :--- | | MSVC x64 (default) | XMM0-XMM3 (4 regs) | YMM0-YMM3 (4 regs) | | MSVC x64 (__vectorcall) | XMM0-XMM5 (6 regs) | YMM0-YMM5 (6 regs) | | GCC/Clang x64 (System V) | XMM0-XMM7 (8 regs) | YMM0-YMM7 (8 regs) | | ARM64 (AAPCS64) | V0-V7 (8 regs) | N/A | `__vectorcall` 在 MSVC 上是真正的胜利——6 个向量参数寄存器对比默认 Microsoft x64 ABI 的 4 个。System V 已经给你 8 个,不需要特殊的调用约定。 `_ref` 等价物(参考后端 POD 结构体)是不同的。`simd32_ref_t` 和 `simd64_ref_t` 适合单个通用寄存器(GPR),且值传递在所有地方都是最优的——与传递 `uint32_t` 或 `uint64_t` 成本相同。`simd128_ref_t` 是 MSVC 变得奇怪的地方:System V 将在两个 GPR 中传递它,但 MSVC 的默认 x64 ABI 会静默地将任何超过 8 字节的结构体转换为隐藏指针,这意味着隐式的栈复制加上间接寻址。`simd256_ref_t` 在每个 ABI 上都变成隐藏指针;按值或按 const-ref 编写,编译器做的事情都一样。 当发生内联时——对于 `_ref` 函数几乎总是如此,因为每一个都是 `inline BX_CONSTEXPR_FUNC` 或 `BX_SIMD_FORCE_INLINE`——这些都不重要;编译器将它们内联得消失无踪。仅当没有发生内联时,ABI 细节才开始咬人:函数指针、虚函数分发、翻译单元和 DLL 边界。 ## 结论 这一切单独来看都不是新颖的。数据导向的布局更改、参考实现、SSA 风格编码、无类型寄存器以及感知 ABI 的抽象都以某种形式出现在 SIMD 领域各处。新的 `bx` SIMD 层提供的是将所有这些部分组合成一个统一界面的能力,写起来感觉很好。通过使寄存器成为比特的集合,使命名约定可预测,并使 `simd32_t` 成为自然的切入点,该库消除了通常使 SIMD 代码变得痛苦的摩擦。你花时间思考算法,而不是与类型系统搏斗或记忆特定于平台的内置函数。同样的代码可以干净地从单个通道扩展到 256 位寄存器,在调试器中保持可读性,并且在各个 ABI 上表现良好而没有隐藏成本。 如果你曾经看着屏幕上充满了 `_mm_castps_si128` 调用而感到热情耗尽,不妨试试这种方法。实现已经合并并在 (https://github.com/bkaradzic/bx/blob/master/include/bx/simd_t.h)。将 `simd32_t` 放入标量热点路径,一旦逻辑清晰了就将其扩展为 `simd128_t`,然后观察同样的代码在你关心的每个平台上运行得更快。SIMD 不必晦涩难懂。它只是通道中的数据——现在库不再妨碍你,以便你能真正使用它。

相似文章

每个人都应该了解SIMD

Hacker News Top

Mitchell Hashimoto的一篇博文,认为SIMD(单指令多数据)比通常认为的要简单,并通过Zig示例演示了在循环中使用SIMD的常见模式。

Performance impact of Alignment

Lobsters Hottest

This technical blog post explains the performance impact of memory alignment in SIMD vectorization, covering architectures with strict alignment requirements, cacheline crossing, and the behavior of modern CPUs.

使用SIMD加速std::copy_if

Lobsters Hottest

一篇博文,分析和实现了在AMD Zen 4上使用AVX-512指令的SIMD加速版本的std::copy_if,并进行了性能分析和与编译器自动向量化的对比。

90年代SIMD技术:编程 Intel Pentium MMX

Hacker News Top

本文概述了Intel的Pentium MMX SIMD指令的历史,这些指令于1990年代引入,并解释了它们在将并行处理技术引入主流桌面CPU以支持多媒体应用方面的关键作用。