SIMD:复杂技术背后的简单通用模式,助开发者轻松提升性能!

SIMD:复杂技术背后的简单通用模式,助开发者轻松提升性能! Mitchell Hashimoto每个人都应了解 SIMD2026 年 7 月 22 日发布的文章指出单指令多数据SIMDSingle Instruction, Multiple Data向来以复杂著称。很多优秀软件工程师认为它过于复杂不值得学习或是针对高性能软件的小众优化技术在日常编程中没什么用。但实际上这种看法是错误的SIMD 很容易理解常见的“一次处理 N 个值”的 SIMD 代码用于加速简单的 for 循环时几乎总是遵循相同的通用模式。一旦掌握基础知识编写 SIMD 代码就和编写 for 循环一样简单。每个开发者至少都应该了解一些 SIMD 知识。本文以 Zig 语言为例其中的概念适用于任何编程语言不同编程语言对 SIMD 指令的支持有所不同希望未来会有更多编程语言引入这些通用概念。此外作者说明这篇文章完全是手写的没有使用 AI 辅助。目录背景什么是 SIMD通用模式实际示例步骤 1广播常量步骤 2一次处理一个向量步骤 3执行 SIMD 操作步骤 4归约向量结果步骤 5处理标量尾部回顾通用模式为什么编译器不能自动完成每个人都应该了解 SIMD背景什么是 SIMD如果你已经了解 SIMD可以跳过这部分。SIMD 允许 CPU 并行处理多个值例如CPU 可以用一条指令同时比较 4 个、8 个甚至更多字节而不是一次比较一个字节。如果代码中有如“for (byte in bytes) { /* ... */ }”等循环就有机会使用 SIMD它可以将这些循环转换为“for (8 byte chunk in bytes) { /* ... */ }”实现局部加速加速效果与并行度直接相关数据处理速度可以提高 4 倍、8 倍甚至更多。要让 SIMD 发挥作用需要定期处理足够多的字节如果处理的数据只有几个或几十个字节不值得使用 SIMD但如果要处理的数据有数百、数千甚至数百万字节使用 SIMD 会带来巨大的收益。像 simdutf 和 simdjson 这样的项目将 SIMD 技术发挥到了极致其使用的 SIMD 技术可能很难理解但要从 SIMD 中受益并不需要编写像它们那样复杂的算法常见的情况要简单得多。通用模式常见的“一次处理 N 个值”的 SIMD 代码通常遵循以下五个步骤1. 广播所需的常量并初始化向量累加器如果需要2. 一次处理一个向量宽度的数据块3. 并行对所有通道执行比较或算术运算4. 根据需要归约或存储向量结果5. 用标量尾部处理剩余元素标量尾部就是向量化之前的普通循环它只处理无法组成完整向量的剩余元素。随着不断实践会自然地将每个 for 循环分解为这五个步骤编写 SIMD 代码就会像编写标量循环一样自然。实际示例以 Ghostty 的实际示例来看有一个解码后的代码点切片要处理这些代码点直到遇到一个小于或等于 0xF 的值C0 控制字符目的是尽快找到下一段可打印字符的结束位置。标量循环只有一行代码“while (end cps.len and cps[end] 0xF) end 1;”它一次处理一个代码点很容易理解。通用向量版本的代码增加了 12 行没有使用特定于 CPU 的内联函数也没有注释。使用 ARM NEON包括 Apple Silicon这个循环的吞吐量最多可提高 4 倍使用 AVX2大多数现代 x86 CPU可提高 8 倍使用 AVX - 512一些英特尔 CPU 以及 AMD Zen 4 及更新的产品可提高 16 倍。在配备 AVX2 的英特尔台式机上从终端程序到最终终端状态的实际端到端吞吐量提升约为 5 倍。接下来将逐步解释这 12 行代码将其直接映射到前面提到的通用模式。步骤 1广播常量从前三行代码“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 代码不进行任何 SIMD 操作。Vector(lanes, u32) 创建向量类型如果 lanes 为 8那么 V 就是一个包含八个 u32 值的单一值CPU 可以并行处理这些值。最后splat(0xF) 将 0xF 复制或“广播”到每个通道结果是一个如“{ 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF }”的向量。这就是步骤 1准备向量类型并广播所需的常量有些算法还会在这里初始化向量累加器但这个算法不需要。步骤 2一次处理一个向量接下来的代码“while (end lanes cps.len) : (end lanes) { const values: V cps[end..][0..lanes].*;”如果 lanes 为 8只有当至少还剩下八个值时才会进入循环。在循环内部将这八个值加载到向量 values 中。每次循环结束时end lanes 会使指针向前移动八个值而不是一个。处理“完整”向量的要求很重要如果只剩下五个值就无法加载一个八通道的向量选择通过标量尾部来处理这种情况将在步骤 5 中详细解释。这就是步骤 2一次加载并处理一个向量宽度的数据块能看到通道数量带来的加速效果。步骤 3执行 SIMD 操作进行比较“const greater_than_threshold values threshold;”values 和 threshold 都是向量这对应一个向量操作一个实际的向量 CPU 指令。单个 操作会将 values 中的每个通道与 threshold 中对应的通道进行比较如果有八个通道这相当于将标量比较 cps[end] 0xF 执行八次但只需要一条 CPU 指令。结果是另一个向量每个通道对应一个布尔值例如“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 操作没有显式的内部循环 运算符会并行应用于每个通道。比较只是一个例子还可以是加法、乘法、求最小值、求最大值或向量类型支持的任何其他操作关键是代码的结构保持不变。步骤 4归约向量结果现在有了一个布尔值向量但原始循环需要知道第一个小于或等于 0xF 的值的位置。首先处理所有值都大于 0xF 的常见情况“if (reduce(.And, greater_than_threshold)) continue;”reduce(.And, ...) 使用 and 运算符将每个布尔值组合起来返回一个单一的布尔值。如果每个通道都是 true就继续处理下一个向量。如果有任何通道为 false需要找出具体是哪个通道“const mask: std.meta.Int(.unsigned, lanes) bitCast(greater_than_threshold); end ctz(~mask); break;”bitCast 将布尔值向量转换为一个整数每个通道对应一位1 表示该值大于 0xF0 表示不大于。对掩码取反使不满足比较条件的通道为 1然后 ctz 计算第一个不满足条件的通道之前的零位数量这个数量就是第一个不满足条件的通道的索引。将这个索引加到 end 上然后跳出循环因为已经找到了控制字符。例如“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即第一个控制字符。这就是步骤 4将向量结果归约为原始算法所需的形式这也是不同算法之间差异最大的步骤。步骤 5处理标量尾部向量循环结束后运行最初的标量循环“while (end cps.len and cps[end] 0xF) end 1;”如果输入长度不是向量宽度的整数倍这个循环将处理剩余的值这就是所谓的“标量尾部”。这个循环还可以处理 simd.lanes(u32) 返回 null 的 CPU在这种情况下将跳过所有 SIMD 代码标量循环将处理整个输入原始实现既是备用方案也是处理尾部的方案。这就是步骤 5它只是一个普通的循环。回顾通用模式将整个实现映射回五个步骤1. splat(0xF) 将比较值广播到每个通道2. while 循环一次加载 lanes 个值3. values threshold 并行比较每个通道4. reduce、bitCast 和 ctz 找到第一个不满足比较条件的通道5. 原始的标量循环处理剩余元素和不支持的 CPU。步骤 4 的细节一开始可能需要一些时间来理解但整体结构很简单而且步骤 1、2、3 和 5 在不同的算法中看起来几乎相同。每当看到 for (byte in bytes) 这样的循环时就可以将其映射到这个模式。为什么编译器不能自动完成有时候编译器可以对简单的循环进行自动向量化特别是没有复杂控制流的常规算术循环。在手动编写 SIMD 代码之前应该始终对标量版本进行优化编译看看编译器能生成什么样的代码。但编译器在自动向量化方面存在很大的局限性总体来说效果并不好。自动向量化是编译器研究的一个活跃领域已经持续了几十年最近的研究仍然指出生产环境中的编译器经常错过向量化的机会这个问题短期内不太可能得到解决。更重要的是当循环的性能提升很重要比如有 5 倍的加速效果时希望向量化是显式和可预测的不希望一个无关的代码更改或编译器更新悄悄地将其变回标量循环。每个人都应该了解 SIMD每个开发者都应该能够识别使用 SIMD 的机会最重要的是不要害怕 SIMD。如果看到一个热点循环在扫描、比较、计数或转换大量连续数据应该能够想象一次处理一个向量宽度的数据块。本文展示了这些常见情况遵循非常规律的模式很快就能适应。有了良好的语言支持不需要了解汇编或特定于 CPU 的特性就能轻松获得性能提升。每个人都应该了解足够的 SIMD 知识来实现这一点。脚注像 simdutf 和 simdjson 这样令人印象深刻的项目使用了极其复杂的 SIMD 技巧来实现目标但这并不是所说的“日常 SIMD”。C0 控制字符的范围不止 0xF这是 Ghostty 在这个特定代码路径中使用的截止值ESC 和其他控制序列的处理在其他地方进行。通用向量消除了特定于 CPU 的语法但并没有消除特定于 CPU 的代码生成。Zig 仍然会将这些操作转换为目标架构支持的指令集。当无法选择支持的向量宽度时Ghostty 会回退到标量代码。比较操作本身是一个向量操作但加载向量、归约结果和定位不满足条件的通道需要额外的指令。重要的是可以一次进行多个比较。本文基于在 Lobsters 上的一条评论。