每个人都应该了解 SIMD
原文由 Mitchell Hashimoto 于 发布,订阅该博客
SIMD 给人的印象一直是复杂难懂。我遇到过很多非常优秀的软件工程师,他们认为 SIMD 过于复杂、不值得去学,或者觉得它只是一种面向极致性能软件的 niche 优化,在日常编程中派不上用场。
我认为这种看法是错的。SIMD 完全可以简单易懂1,而常见的那种“一次处理 N 个值”、用来给朴素 for 循环提速的 SIMD 代码,几乎都遵循着同样的套路。一旦掌握了基础,写 SIMD 就和写 for 循环一样简单。而当它不再简单时,往往就意味着现在还不是用它的时候,不如先跳过。
每个开发者至少都应该掌握到这个程度的 SIMD。
本文用 Zig 举例,但其中的内容是通用的,适用于任何编程语言。不同编程语言对 SIMD 指令的支持程度各不相同,我希望未来有更多语言能开放这些通用概念!
我实在讨厌现在每篇文章都要加这么一句,但还是想说明一下:本文完全由人工手写,没有使用任何 AI 辅助。
背景:什么是 SIMD?
如果你已经知道 SIMD 是什么,可以直接跳过本节。
SIMD 能让 CPU 并行处理多个值。例如,CPU 不必一次只比较一个字节,而是可以用一条指令同时比较 4 个、8 个甚至更多字节。
如果你的代码里出现过这样的循环:
for (byte in bytes) { /* ... */ }
for (character in string) { /* ... */ }
for (value in array) { /* ... */ }那就有机会用上 SIMD。SIMD 会把它们变成这样:
for (8 byte chunk in bytes) { /* ... */ }这会带来与并行度直接对应的局部加速:处理速度提升 4 倍、8 倍甚至更多。
要让这种优化真正划算,唯一的硬性要求就是你需要稳定地处理足够多的数据。如果这些 for 循环处理的数据永远只有几个或几十个字节,就不值得折腾。但如果要遍历的是几百、几千甚至上百万字节,收益就会非常可观。
以上就是基础。像 simdutf 和 simdjson 这样的项目把 SIMD 用到了极致,其中的技巧确实很难理解。但你并不需要写出那样的算法才能从 SIMD 中获益。常见场景要简单得多。
常见的固定套路
常见的“一次处理 N 个值”的 SIMD 代码都遵循同样的五个步骤:
- 广播所需的常量,并初始化向量累加器(如有需要)。
- 每次按一个向量宽度的块来遍历输入。
- 在所有通道上并行执行比较或算术运算。
- 按需归约或存储向量结果。
- 用标量收尾处理剩余元素。所谓标量收尾,其实就是向量化之前的普通循环,只不过它只处理填不满一个完整向量的那部分余数。
做得多了,你就会自然而然地把每个 for 循环都拆解成这五步,写 SIMD 也会变得和写标量循环一样自然。
一个真实的例子
我们来看一个来自 Ghostty 的真实例子。我们会先看标量实现,再看 SIMD 实现,然后把它对应回上面说的通用套路。
我有一段已解码的码点切片,需要一直消费,直到遇到小于等于 0xF 的值(即 C0 控制字符)。2 终端中绝大多数都是待打印的普通字符,所以我们会尝试把它们批量处理。因此,这个循环的作用就是尽快找到下一段可打印区间的末尾。
标量循环只有一行:
while (end < cps.len and cps[end] > 0xF) end += 1;它一次只处理一个码点,非常好理解。
下面是不依赖特定 CPU 内在函数3、也没有注释的通用向量版本。稍后我会详细解释。
if (simd.lanes(u32)) |lanes| {
const V = @Vector(lanes, u32);
const threshold: V = @splat(0xF);
while (end + lanes <= cps.len) : (end += lanes) {
const values: V = cps[end..][0..lanes].*;
const greater_than_threshold = values > threshold;
if (@reduce(.And, greater_than_threshold)) continue;
const mask: std.meta.Int(.unsigned, lanes) = @bitCast(greater_than_threshold);
end += @ctz(~mask);
break;
}
}
while (end < cps.len and cps[end] > 0xF) end += 1;多了 12 行代码。
这能将循环的吞吐量提升至多 4 倍(ARM NEON,包括 Apple Silicon)、8 倍(AVX2,绝大多数现代 x86 CPU)以及 16 倍(AVX-512,部分 Intel CPU 和 AMD Zen 4 及更新型号)。
在 AVX2 的 Intel 台式机上,从终端程序到最终终端状态的真实端到端吞吐中,实际加速大约是 5 倍。由于 SIMD 代码周围的其他开销,你总会损失一部分理想加速比,但……那仍然是 5 倍!
我知道,对于不熟悉这些概念的人来说,那 12 行代码看起来会非常陌生。所以接下来我们退一步,一步步地解释,并直接对应到前面提到的套路。
第一步:广播常量
我们先从前三行开始:
if (simd.lanes(u32)) |lanes| {
const V = @Vector(lanes, u32);
const threshold: V = @splat(0xF);simd.lanes(u32) 是 Ghostty 中的一个辅助函数,它会返回目标 CPU 一次能并行处理多少个 u32 值。这些单独的值被称为通道。在 ARM 上它返回 4,AVX2 上返回 8,AVX-512 上返回 16。如果目标平台没有我们想用的向量宽度,它会返回 null,我们就会跳过所有相关代码,完全不做 SIMD 处理。
@Vector(lanes, u32) 用于创建向量类型。如果 lanes 是 8,那么 V 就是一个包含八个 u32 的单一值,CPU 可以并行操作它们。依此类推。
最后,我们需要把每个值都和 0xF 比较。向量比较要求两边都是向量,所以 @splat(0xF) 会把 0xF 复制、也就是广播到每一个通道中。结果就是这样一个向量:
{ 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF }这就是第一步:准备向量类型并广播所需的常量。有些算法还会在这里初始化向量累加器,但这个算法不需要。
第二步:一次循环一个向量
接下来,我们一次循环处理一个完整的向量:
while (end + lanes <= cps.len) : (end += lanes) {
const values: V = cps[end..][0..lanes].*;如果 lanes 是 8,那么只有在剩余至少八个值时才会进入循环。在循环内部,我们把这八个值加载到向量 values 中。每次循环结束时,end += lanes 会一次性前进八个值,而不是一个。
要求向量是完整的这一点很重要。如果只剩下五个值,就无法加载一个八通道的向量。处理这种情况有各种技巧,但我们采用最简单的做法:通过标量收尾来处理,稍后在第五步中会讲到。
这就是第二步:每次按一个向量宽度的块来加载并遍历输入。在这里就能看到通道数量带来的加速!
第三步:执行 SIMD 运算
现在我们执行比较:
const greater_than_threshold = values > threshold;values 和 threshold 都是向量,所以这一操作会映射为一条向量运算(也就是一条实实在在的向量 CPU 指令)。这一个 > 会把 values 中每个通道的值与 threshold 中对应的通道逐一比较。如果有八个通道,这就相当于把标量比较 cps[end] > 0xF 执行了八次,只不过它用一条 CPU 指令就完成了。4
结果是另一个向量,每个通道对应一个布尔值。概念上,它看起来像这样:
values: { 0x41, 0x42, 0x43, 0x0A, 0x44, 0x45, 0x46, 0x47 }
threshold: { 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF }
greater_than_threshold: { true, true, true, false, true, true, true, true }这就是真正的 SIMD 操作。这里没有显式的内层循环。> 运算符会并行地作用于每一个通道。
比较只是一个例子。这里也可以是加法、乘法、取最小值、取最大值,或向量类型支持的任何其他运算。关键在于,代码的整体套路仍然是一样的。
第四步:归约向量结果
现在我们得到了一个布尔向量,但原始循环需要知道第一个小于等于 0xF 的值的位置。
首先,处理最常见的情况——所有值都大于 0xF:
if (@reduce(.And, greater_than_threshold)) continue;@reduce(.And, ...) 会用 and 把所有布尔值合并,返回单个布尔值。如果每个通道都是 true,我们就 continue,继续处理下一个向量。在我们的例子中,通道 3 是 false,所以 @reduce 返回 false,我们就会往下走,去精确定位是哪一个通道失败了。
如果有任何一个通道为 false,我们就需要精确定位是哪一个通道失败了:
const mask: std.meta.Int(.unsigned, lanes) = @bitCast(greater_than_threshold);
end += @ctz(~mask);
break;@bitCast 会把布尔向量转换成一个整数,每个通道对应一位。位为 1 表示该值大于 0xF,为 0 则表示不是。我们对掩码取反,让失败的比较变为 1,然后用 @ctz 统计第一个 1 之前有多少个 0。这个数量就是首个失败通道的索引。
我们把该索引加到 end 上并 break,因为已经找到了控制字符。
沿用第三步中的同一组值,可以看到每个通道上的这种转换:
values: { 0x41, 0x42, 0x43, 0x0A, 0x44, 0x45, 0x46, 0x47 }
greater_than_threshold: { true, true, true, false, true, true, true, true }
mask: { 1, 1, 1, 0, 1, 1, 1, 1 }
~mask: { 0, 0, 0, 1, 0, 0, 0, 0 }@ctz(~mask) 会数出第一个 1 之前的三个 0,因此返回 3。给 end 加上 3,就让它指向通道 3,也就是包含首个控制字符 0x0A 的位置。
这就是第四步:把向量结果归约为原始算法需要的形式。这也是不同算法之间差异最大的一步。求和可能会把向量累加器归约为单个数字;转换可能会把整个向量存入输出缓冲区;而我们的扫描则是把向量转成位掩码,以便定位具体的某一个通道。
第五步:用标量收尾完成剩余部分
向量循环结束后,我们再执行一开始的那个标量循环:
while (end < cps.len and cps[end] > 0xF) end += 1;如果输入长度不是向量宽度的整数倍,这个循环就会处理剩余的值。例如,八通道的向量循环会留下 0 到 7 个值给这个循环处理。这就是所谓的标量收尾。
这个循环同时也兼容那些 simd.lanes(u32) 返回 null 的 CPU。在这种情况下,我们会跳过所有 SIMD 代码,由标量循环处理全部输入。原始实现既是回退方案,也是收尾部分。
这就是第五步。它就是普通的循环。
回顾:常见的固定套路
我们把整个实现对应回这五个步骤:
@splat(0xF)把比较值广播到每个通道。while循环每次加载lanes个值。values > threshold并行比较每个通道。@reduce、@bitCast和@ctz定位首个失败的比较。- 原始的标量循环处理余数以及不支持相关 CPU 的情况。
第四步的细节一开始需要花些时间理解,但整体套路非常清晰。而且在完全不同的算法中,第一、二、三、五步往往看起来几乎一模一样。
每当你看到 for (byte in bytes) 这样的循环,就可以把它映射成这个套路。
为什么编译器不能自动做这些?
有时它确实可以!编译器能够自动向量化一些简单循环,尤其是那些没有复杂控制流的规则算术循环。在手动编写 SIMD 之前,你应该始终先用优化选项编译标量版本,看看编译器生成了什么。
但编译器能自动向量化的范围非常有限,总体上做得也很差。自动向量化已经是编译器领域研究了数十年的课题,而最近的研究仍然是从“生产级编译器经常错过向量化机会”这一观察出发的。我不认为这个问题会很快消失。
更重要的是,当这个循环重要到让我在乎 5 倍加速时,我希望向量化是显式且可预期的。我不想因为一次不相关的代码改动或编译器更新,就让它悄悄变回标量循环。
每个人都应该了解 SIMD
每个开发者都应该能识别出这样的机会,更重要的是,不应该对 SIMD 心存畏惧。如果你看到一个热点循环在扫描、比较、计数或转换大量连续数据,你就应该能想象出一次处理一个向量宽度的数据块的情形。
本文表明,这些常见情况都遵循着非常规律的模式,很快就能习惯。而且有了良好的语言支持,你不需要懂任何汇编或 CPU 相关的特殊细节,就能轻松获得提升。
每个人都应该掌握到能做到这种程度的 SIMD。5
脚注
像 simdutf 和 simdjson 这样非常出色的项目使用了极其复杂的 SIMD 技巧来实现目标。但我认为那不属于“日常 SIMD”的范畴。 ↩
C0 控制字符的范围超出了
0xF。这是 Ghostty 在这条特定代码路径中采用的截断值;ESC 及其他控制序列的处理在别处进行。 ↩通用向量消除的是面向特定 CPU 的语法,而不是面向特定 CPU 的代码生成。Zig 仍会将这些操作 lowering 到目标平台启用的指令集上。当无法选择受支持的向量宽度时,Ghostty 会回退到标量代码。 ↩
比较本身是一条向量操作。加载向量、归约结果以及定位失败的通道还需要额外的指令。关键在于我们一次性做了多次比较。 ↩
本文基于我写的一条 Lobsters 评论。 ↩
随机一篇博客
评论
登录后参与讨论