SIMD(单指令多数据)一直以来都以复杂著称。我遇到过许多非常优秀的软件工程师,他们认为SIMD太难学,或者只是一种专门用于追求极致性能软件的小众优化手段,在日常编程中没什么用。
我认为这种观点是错误的。SIMD 其实可以很简单,并且为了加速一个普通的 for 循环而编写的常见“每次处理 N 个值”的 SIMD 代码,几乎总是遵循相同的通用结构。一旦你掌握了基础知识,编写 SIMD 实际上就像写一个 for 循环一样简单。如果碰上不简单的时候,那通常是个好信号——说明你现在可以直接跳过它。
每个开发人员都至少应该掌握这种程度的 SIMD。
这篇文章以 Zig 语言为例,但它是一篇通用的文章,适用于任何编程语言。对 SIMD 指令的支持因编程语言而异,我希望未来能有更多的编程语言暴露出这些通用的概念!
我很讨厌现在每发一篇文章都得声明一次,但我还是要说明:这篇文章完全由人工撰写,没有借助任何 AI。
目录
- 背景:什么是 SIMD?
- 通用结构
- 一个真实的例子
- 第一步:广播常量
- 第二步:每次循环处理一个向量
- 第三步:执行 SIMD 运算
- 第四步:规约向量结果
- 第五步:用标量尾巴收尾
- 总结:通用结构
- 为什么编译器不能自动做这件事?
- 每个人都应该了解 SIMD
背景:什么是 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 技术。但你不需要编写那样复杂的算法也能从中受益。常见的应用场景要简单得多。
通用结构
常见的“每次处理 N 个值”的 SIMD 代码都遵循以下五个步骤:
- 广播你需要的所有常量,并初始化向量累加器(如果有的话)。
- 以向量宽度为块大小,每次循环处理一块输入。
- 并行跨所有通道(lane)执行比较或算术运算。
- 根据需要**规约(Reduce)**或存储向量结果。
- 用**标量尾巴(scalar tail)**处理剩余的元素。标量尾巴就是你向量化之前的那个普通循环,但它只处理那些装不满一个完整向量的剩余数据。
当你做得越来越多时,你会自然而然地将每个 for 循环分解为这五个步骤,编写 SIMD 就会变得和编写标量循环一样自然。
一个真实的例子
让我们看一个来自 Ghostty 项目的真实例子。我们将查看标量实现、SIMD 实现,然后把它映射回上面提到的通用结构中。
我有一个解码后的码点(codepoint)切片,我希望一直消费它,直到看到一个小于或等于 0xF(C0 控制字符)的值。2 终端大部分是需要打印的普通字符,所以我们尽量把它们批量处理。
因此,这个循环会尽最大可能快速找到下一个可打印字符运行段的结尾。
标量循环只有一行:
while (end < cps.len and cps[end] > 0xF) end += 1;
它一次处理一个码点。这很容易理解。
下面是没有 CPU 特定内联函数(intrinsics)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 行。
在 ARM NEON(包括 Apple Silicon)下,这可以使循环的吞吐量提升高达 4 倍;在 AVX2(大多数现代 x86 CPU)下提升 8 倍;在 AVX-512(部分 Intel CPU 以及 AMD Zen 4 及更新型号)下提升 16 倍。
在英特尔 AVX2 桌面端上,从终端程序到最终终端状态的真实世界端到端吞吐量显示,这大约带来了 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 值的数量。这些独立的值被称为通道(lanes)。在 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,我们只有在剩余至少 8 个值时才会进入循环。
在循环内部,我们将这 8 个值加载到向量 values 中。在每次循环结束时,end += lanes 会向前推进 8 个值,而不是 1 个。
对完整向量的要求非常重要。如果只剩 5 个值,我们就无法加载一个 8 通道的向量。有各种各样的技巧来处理这种情况,但我们采取最简单的方法:通过我们的标量尾巴来处理它们,我将在第五步中解释这一点。
这就是第二步:以向量宽度为块大小,加载并循环遍历输入。你在这里就能看到通道数量带来的速度提升!
第三步:执行 SIMD 运算
现在我们执行比较:
const greater_than_threshold = values > threshold;
values 和 threshold 都是向量,因此这会映射为一个向量运算(一条字面意义上的向量 CPU 指令)。其中的 > 会将 values 中的每个通道与 threshold 中对应的每个通道进行比较。如果有 8 个通道,这相当于执行了八次标量比较 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 表示不大。我们对掩码取反(~mask),这样比较失败的位置变成了 1,然后 @ctz(计算尾随零)会计算第一个失败位置之前有多少个零比特。这个数量就是第一个失败通道的索引。
我们将该索引加到 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 之前计算出三个零比特,因此它返回 3。
将 3 加到 end 上,使其指向通道 3,该通道包含 0x0A——即第一个控制字符。
这就是第四步:将向量结果规约(Reduce)为原始算法所需的形态。这也是不同算法之间差异最大的步骤。求和(Sum)可能会将一个向量累加器规约成单个数字;转换(Transform)可能会将整个向量存储到输出缓冲区中;而我们的扫描(Scan)则将向量转换为位掩码(bit mask),以便找到某个特定的通道。
第五步:用标量尾巴收尾
在向量循环之后,我们运行我们一开始的那个标准标量循环:
while (end < cps.len and cps[end] > 0xF) end += 1;
如果输入的长度不是向量宽度的整数倍,这段代码就会处理剩余的值。例如,一个 8 通道的向量循环会给这个循环留下 0 到 7 个值。这被称为标量尾巴(scalar tail)。
这个循环还负责处理 simd.lanes(u32) 返回 null 的那些 CPU。在这种情况下,我们会跳过所有的 SIMD 代码,由标量循环处理整个输入。最初的实现既充当了回退方案(fallback),又充当了收尾的尾巴。
这就是第五步。它就是普通的循环。
总结:通用结构
让我们把整个实现重新映射回五个步骤:
@splat(0xF) 将比较值广播到每个通道中。
while 循环每次加载 lanes 个值。
values > threshold 并行比较每个通道。
@reduce、@bitCast 和 @ctz 用于寻找第一个比较失败的地方。
- 原本的标量循环负责处理剩余数据以及不支持 SIMD 的 CPU。
第四步中的细节一开始需要花点时间去理解,但整体结构非常直观。而且第 1、2、3 和 5 步在完全不同的算法中往往看起来几乎完全一样。
每当你看到 for (byte in bytes) 时,这就是你要套用的结构。
为什么编译器不能自动做这件事?
有时它可以!编译器可以**自动向量化(auto-vectorize)**简单的循环,尤其是那些没有复杂控制流的常规算术循环。在手动编写 SIMD 之前,你应该始终开启优化编译标量版本,看看你的编译器能生成什么代码。
但编译器在能够自动向量化哪些内容方面受到严重限制,而且总的来说,它们在这方面非常糟糕。
几十年来,自动向量化一直是编译器研究的一个活跃领域,而最近的研究仍然从这样一个观察结果开始:生产环境中的编译器经常错过向量化机会。我不指望这个问题会很快消失。
更重要的是,当这个循环重要到让我关心 5 倍的速度提升时,我希望向量化是显式且可预测的。我不希望不相关的代码修改或编译器更新悄悄地把它改回标量循环。
每个人都应该了解 SIMD
每个开发人员都应该能够识别出使用 SIMD 的机会,最重要的是,不应该对 SIMD 感到恐惧。如果你看到一个热循环(hot loop)在扫描、比较、计数或转换大量连续数据,你应该能够想象出以“向量宽度为块”的方式来处理它。
这篇文章证明了这些常见情况遵循一个非常规律的模式,你很快就会习惯它。而且有了良好的语言支持,你不需要懂任何汇编语言或 CPU 特定的怪癖,就能轻松获得性能提升。
每个人都应该对 SIMD 有足够的了解,以便做到这一点。5
脚注
- 像
simdutf 和 simdjson 这样非常令人印象深刻的项目,使用了极其复杂的 SIMD 技巧来实现它们的目标。但这并不是我所认为的“日常 SIMD”。↩
- C0 控制字符超出了
0xF 的范围。这是 Ghostty 针对这条特定代码路径所使用的截断值;ESC 和其他控制序列的处理在别处进行。↩
- 通用向量消除了 CPU 特定的语法,而不是 CPU 特定的代码生成。Zig 仍然会将这些操作降级(lowers)为针对目标启用的指令集。当 Ghostty 无法选择支持的向量宽度时,会回退到标量代码。↩
- 比较本身是一条向量运算。加载向量、规约结果以及定位失败的通道需要额外的指令。重要部分在于我们正在同时进行多次比较。↩
- 这篇文章基于我写过的一条 Lobsters 评论。↩
https://mitchellh.com/writing/everyone-should-know-simd
加入我们
Zig 中文社区是一个开放的组织,我们致力于推广 Zig 在中文群体中的使用,有多种方式可以参与进来:
- 供稿,分享自己使用 Zig 的心得
- 改进 ZigCC 组织下的开源项目
- 加入微信群、Telegram 群组
SIMD(单指令多数据)一直以来都以复杂著称。我遇到过许多非常优秀的软件工程师,他们认为SIMD太难学,或者只是一种专门用于追求极致性能软件的小众优化手段,在日常编程中没什么用。
我认为这种观点是错误的。SIMD 其实可以很简单,并且为了加速一个普通的
for循环而编写的常见“每次处理 N 个值”的 SIMD 代码,几乎总是遵循相同的通用结构。一旦你掌握了基础知识,编写 SIMD 实际上就像写一个for循环一样简单。如果碰上不简单的时候,那通常是个好信号——说明你现在可以直接跳过它。每个开发人员都至少应该掌握这种程度的 SIMD。
这篇文章以 Zig 语言为例,但它是一篇通用的文章,适用于任何编程语言。对 SIMD 指令的支持因编程语言而异,我希望未来能有更多的编程语言暴露出这些通用的概念!
我很讨厌现在每发一篇文章都得声明一次,但我还是要说明:这篇文章完全由人工撰写,没有借助任何 AI。
目录
背景:什么是 SIMD?
如果你已经知道什么是 SIMD,请跳过这一节。
SIMD 允许 CPU 并行处理多个值。例如,CPU 不是每次比较一个字节,而是可以通过一条指令比较 4 个、8 个甚至更多的字节。
如果你在代码中见过这样的循环:
这就存在使用 SIMD 的机会。SIMD 可以将它们变成这样:
这将带来与并行度直接对应的局部加速:你处理数据的速度可以提升 4 倍、8 倍甚至更快。
这种优化能够带来回报的唯一真正前提是:你需要定期处理足够大量的字节。如果你只是在区区几个或几十个字节的数据上运行这些
for循环,那就不值得。但如果你是在遍历数百、数千甚至数百万个字节,回报将是巨大的。这就是基础知识。像
simdutf和simdjson这样的项目将这一点推到了极致,它们使用了可能很难理解的 SIMD 技术。但你不需要编写那样复杂的算法也能从中受益。常见的应用场景要简单得多。通用结构
常见的“每次处理 N 个值”的 SIMD 代码都遵循以下五个步骤:
当你做得越来越多时,你会自然而然地将每个
for循环分解为这五个步骤,编写 SIMD 就会变得和编写标量循环一样自然。一个真实的例子
让我们看一个来自 Ghostty 项目的真实例子。我们将查看标量实现、SIMD 实现,然后把它映射回上面提到的通用结构中。
我有一个解码后的码点(codepoint)切片,我希望一直消费它,直到看到一个小于或等于
0xF(C0 控制字符)的值。2 终端大部分是需要打印的普通字符,所以我们尽量把它们批量处理。因此,这个循环会尽最大可能快速找到下一个可打印字符运行段的结尾。
标量循环只有一行:
它一次处理一个码点。这很容易理解。
下面是没有 CPU 特定内联函数(intrinsics)3 也没有注释的通用向量版本。我稍后会详细解释它:
代码多了 12 行。
在 ARM NEON(包括 Apple Silicon)下,这可以使循环的吞吐量提升高达 4 倍;在 AVX2(大多数现代 x86 CPU)下提升 8 倍;在 AVX-512(部分 Intel CPU 以及 AMD Zen 4 及更新型号)下提升 16 倍。
在英特尔 AVX2 桌面端上,从终端程序到最终终端状态的真实世界端到端吞吐量显示,这大约带来了 5 倍的提速。
由于 SIMD 代码周围的其他开销,你总是无法达到理想的加速比,但是……这依然是 5 倍的提速!
好了,现在我理解这 12 行代码对于不熟悉这些概念的人来说会显得非常陌生。所以让我们回过头来,一步步地解释它,并将其直接映射到前面提到的结构中。
第一步:广播常量
让我们从前三行开始:
simd.lanes(u32)是 Ghostty 中的一个辅助函数,它返回目标 CPU 可以同时处理的u32值的数量。这些独立的值被称为通道(lanes)。在 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 }这就是第一步:准备向量类型并广播任何常量。某些算法在这里还会初始化一个向量累加器,但这个算法不需要。
第二步:每次循环处理一个向量
接下来,我们每次循环处理一个完整的向量:
如果
lanes是 8,我们只有在剩余至少 8 个值时才会进入循环。在循环内部,我们将这 8 个值加载到向量
values中。在每次循环结束时,end += lanes会向前推进 8 个值,而不是 1 个。对完整向量的要求非常重要。如果只剩 5 个值,我们就无法加载一个 8 通道的向量。有各种各样的技巧来处理这种情况,但我们采取最简单的方法:通过我们的标量尾巴来处理它们,我将在第五步中解释这一点。
这就是第二步:以向量宽度为块大小,加载并循环遍历输入。你在这里就能看到通道数量带来的速度提升!
第三步:执行 SIMD 运算
现在我们执行比较:
values和threshold都是向量,因此这会映射为一个向量运算(一条字面意义上的向量 CPU 指令)。其中的>会将values中的每个通道与threshold中对应的每个通道进行比较。如果有 8 个通道,这相当于执行了八次标量比较cps[end] > 0xF,但它在一条 CPU 指令中就完成了。4结果是另一个向量,每个通道包含一个布尔值。从概念上讲,它看起来像这样:
这就是实际的 SIMD 运算。这里没有显式的内部循环。
>运算符并行应用于每个通道。比较只是一个例子。这可以是加法、乘法、求最小值、求最大值,或向量类型支持的任何其他运算。关键在于代码仍然保持相同的结构。
第四步:规约向量结果
我们现在得到了一个布尔值向量,但原始循环需要知道第一个小于或等于
0xF的值的位置。首先,让我们处理常见情况:所有值都大于
0xF:@reduce(.And, ...)使用and操作组合每个布尔值,并返回单个布尔值。如果每个通道都是true,我们就continue并处理下一个向量。在我们的例子中,通道 3 是false,所以@reduce返回false,并继续向下执行,以找出到底是哪个通道失败了。如果任何通道为
false,那么我们需要找出具体是哪个通道失败了:@bitCast将布尔值向量转换为一个整数,每个通道对应一个比特位。1表示该值大于0xF,0表示不大。我们对掩码取反(~mask),这样比较失败的位置变成了1,然后@ctz(计算尾随零)会计算第一个失败位置之前有多少个零比特。这个数量就是第一个失败通道的索引。我们将该索引加到
end上,然后break,因为我们已经找到了控制字符。使用与第三步相同的数值,我们可以看到每个通道的这种转换过程:
@ctz(~mask)在第一个1之前计算出三个零比特,因此它返回3。将
3加到end上,使其指向通道 3,该通道包含0x0A——即第一个控制字符。这就是第四步:将向量结果规约(Reduce)为原始算法所需的形态。这也是不同算法之间差异最大的步骤。求和(Sum)可能会将一个向量累加器规约成单个数字;转换(Transform)可能会将整个向量存储到输出缓冲区中;而我们的扫描(Scan)则将向量转换为位掩码(bit mask),以便找到某个特定的通道。
第五步:用标量尾巴收尾
在向量循环之后,我们运行我们一开始的那个标准标量循环:
如果输入的长度不是向量宽度的整数倍,这段代码就会处理剩余的值。例如,一个 8 通道的向量循环会给这个循环留下 0 到 7 个值。这被称为标量尾巴(scalar tail)。
这个循环还负责处理
simd.lanes(u32)返回null的那些 CPU。在这种情况下,我们会跳过所有的 SIMD 代码,由标量循环处理整个输入。最初的实现既充当了回退方案(fallback),又充当了收尾的尾巴。这就是第五步。它就是普通的循环。
总结:通用结构
让我们把整个实现重新映射回五个步骤:
@splat(0xF)将比较值广播到每个通道中。while循环每次加载lanes个值。values > threshold并行比较每个通道。@reduce、@bitCast和@ctz用于寻找第一个比较失败的地方。第四步中的细节一开始需要花点时间去理解,但整体结构非常直观。而且第 1、2、3 和 5 步在完全不同的算法中往往看起来几乎完全一样。
每当你看到
for (byte in bytes)时,这就是你要套用的结构。为什么编译器不能自动做这件事?
有时它可以!编译器可以**自动向量化(auto-vectorize)**简单的循环,尤其是那些没有复杂控制流的常规算术循环。在手动编写 SIMD 之前,你应该始终开启优化编译标量版本,看看你的编译器能生成什么代码。
但编译器在能够自动向量化哪些内容方面受到严重限制,而且总的来说,它们在这方面非常糟糕。
几十年来,自动向量化一直是编译器研究的一个活跃领域,而最近的研究仍然从这样一个观察结果开始:生产环境中的编译器经常错过向量化机会。我不指望这个问题会很快消失。
更重要的是,当这个循环重要到让我关心 5 倍的速度提升时,我希望向量化是显式且可预测的。我不希望不相关的代码修改或编译器更新悄悄地把它改回标量循环。
每个人都应该了解 SIMD
每个开发人员都应该能够识别出使用 SIMD 的机会,最重要的是,不应该对 SIMD 感到恐惧。如果你看到一个热循环(hot loop)在扫描、比较、计数或转换大量连续数据,你应该能够想象出以“向量宽度为块”的方式来处理它。
这篇文章证明了这些常见情况遵循一个非常规律的模式,你很快就会习惯它。而且有了良好的语言支持,你不需要懂任何汇编语言或 CPU 特定的怪癖,就能轻松获得性能提升。
每个人都应该对 SIMD 有足够的了解,以便做到这一点。5
脚注
simdutf和simdjson这样非常令人印象深刻的项目,使用了极其复杂的 SIMD 技巧来实现它们的目标。但这并不是我所认为的“日常 SIMD”。↩0xF的范围。这是 Ghostty 针对这条特定代码路径所使用的截断值;ESC 和其他控制序列的处理在别处进行。↩加入我们
Zig 中文社区是一个开放的组织,我们致力于推广 Zig 在中文群体中的使用,有多种方式可以参与进来: