Skip to content

【Zig 日报】Everyone Should Know SIMD #350

Description

@jiacai2050

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 循环,那就不值得。但如果你是在遍历数百、数千甚至数百万个字节,回报将是巨大的。

这就是基础知识。像 simdutfsimdjson 这样的项目将这一点推到了极致,它们使用了可能很难理解的 SIMD 技术。但你不需要编写那样复杂的算法也能从中受益。常见的应用场景要简单得多。


通用结构

常见的“每次处理 N 个值”的 SIMD 代码都遵循以下五个步骤:

  1. 广播你需要的所有常量,并初始化向量累加器(如果有的话)。
  2. 向量宽度为块大小,每次循环处理一块输入。
  3. 并行跨所有通道(lane)执行比较或算术运算。
  4. 根据需要**规约(Reduce)**或存储向量结果。
  5. 用**标量尾巴(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;

valuesthreshold 都是向量,因此这会映射为一个向量运算(一条字面意义上的向量 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 表示该值大于 0xF0 表示不大。我们对掩码取反(~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),又充当了收尾的尾巴。

这就是第五步。它就是普通的循环。


总结:通用结构

让我们把整个实现重新映射回五个步骤:

  1. @splat(0xF) 将比较值广播到每个通道中。
  2. while 循环每次加载 lanes 个值。
  3. values > threshold 并行比较每个通道。
  4. @reduce@bitCast@ctz 用于寻找第一个比较失败的地方。
  5. 原本的标量循环负责处理剩余数据以及不支持 SIMD 的 CPU。

第四步中的细节一开始需要花点时间去理解,但整体结构非常直观。而且第 1、2、3 和 5 步在完全不同的算法中往往看起来几乎完全一样。

每当你看到 for (byte in bytes) 时,这就是你要套用的结构。


为什么编译器不能自动做这件事?

有时它可以!编译器可以**自动向量化(auto-vectorize)**简单的循环,尤其是那些没有复杂控制流的常规算术循环。在手动编写 SIMD 之前,你应该始终开启优化编译标量版本,看看你的编译器能生成什么代码。

但编译器在能够自动向量化哪些内容方面受到严重限制,而且总的来说,它们在这方面非常糟糕。

几十年来,自动向量化一直是编译器研究的一个活跃领域,而最近的研究仍然从这样一个观察结果开始:生产环境中的编译器经常错过向量化机会。我不指望这个问题会很快消失。

更重要的是,当这个循环重要到让我关心 5 倍的速度提升时,我希望向量化是显式且可预测的。我不希望不相关的代码修改或编译器更新悄悄地把它改回标量循环。


每个人都应该了解 SIMD

每个开发人员都应该能够识别出使用 SIMD 的机会,最重要的是,不应该对 SIMD 感到恐惧。如果你看到一个热循环(hot loop)在扫描、比较、计数或转换大量连续数据,你应该能够想象出以“向量宽度为块”的方式来处理它。

这篇文章证明了这些常见情况遵循一个非常规律的模式,你很快就会习惯它。而且有了良好的语言支持,你不需要懂任何汇编语言或 CPU 特定的怪癖,就能轻松获得性能提升。

每个人都应该对 SIMD 有足够的了解,以便做到这一点。5


脚注

  1. simdutfsimdjson 这样非常令人印象深刻的项目,使用了极其复杂的 SIMD 技巧来实现它们的目标。但这并不是我所认为的“日常 SIMD”。↩
  2. C0 控制字符超出了 0xF 的范围。这是 Ghostty 针对这条特定代码路径所使用的截断值;ESC 和其他控制序列的处理在别处进行。↩
  3. 通用向量消除了 CPU 特定的语法,而不是 CPU 特定的代码生成。Zig 仍然会将这些操作降级(lowers)为针对目标启用的指令集。当 Ghostty 无法选择支持的向量宽度时,会回退到标量代码。↩
  4. 比较本身是一条向量运算。加载向量、规约结果以及定位失败的通道需要额外的指令。重要部分在于我们正在同时进行多次比较。↩
  5. 这篇文章基于我写过的一条 Lobsters 评论。↩

https://mitchellh.com/writing/everyone-should-know-simd

加入我们

Zig 中文社区是一个开放的组织,我们致力于推广 Zig 在中文群体中的使用,有多种方式可以参与进来:

  1. 供稿,分享自己使用 Zig 的心得
  2. 改进 ZigCC 组织下的开源项目
  3. 加入微信群Telegram 群组

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    日报daily report

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions