每个人都应该了解 SIMD
Hacker News 摘要这篇由 Mitchell Hashimoto 撰写的文章主张,SIMD(单指令多数据)不应被视为只有高性能软件工程师才需要掌握的冷门技术。作者认为,只要掌握了基本模式,编写 SIMD 代码就像编写传统的 for 循环一样简单,且能显著提升代码性能。
什么是 SIMD
SIMD 允许处理器同时对多个数值进行并行操作。例如,处理器可以一次性比较 4 个、8 个甚至更多字节,而不是逐个比较。如果代码中存在遍历数组、字符串或字节流的循环,通常就有使用 SIMD 优化的空间。当处理数据量达到数百或数百万字节时,这种并行处理带来的加速效果非常显著。
SIMD 代码的通用模式
大多数将逐个处理转换为批量处理的 SIMD 代码都遵循以下五个步骤:
1. 广播常量:准备好需要的常量并初始化向量累加器。
2. 向量宽度循环:按照向量的宽度(一次处理的数值个数)进行输入数据循环。
3. 执行并行操作:在所有通道上并行执行比较或算术运算。
4. 归约或存储结果:根据需要处理向量运算后的结果。
5. 处理标量尾部:使用普通的标量循环处理剩余不足一个完整向量的数据。
实例分析:Ghostty 终端的优化
作者展示了他在终端模拟器项目 Ghostty 中的一个实际案例。该任务是扫描一组编码点,直到遇到小于或等于 0xF 的控制字符为止。
标量版本:
使用 while 循环逐个检查字符,直到发现目标字符或到达末尾。
SIMD 版本的实现细节:
• 第一步:准备向量与广播
首先确定处理器一次能处理多少个 u32 类型的值(称为通道)。在 ARM 架构上通常是 4 个,AVX2 是 8 个,AVX-512 是 16 个。使用 @splat(0xF) 函数将阈值 0xF 复制到向量的所有通道中,形成一个像 {0xF, 0xF, ...} 这样的对比向量。
• 第二步:按向量步长循环
如果每个向量有 8 个通道,循环就会一次读取 8 个值进入向量变量。循环计数器每次增加 8 而不是 1。如果剩余数据不足 8 个,则跳出此循环。
• 第三步:执行并行比较
使用一个大于号 > 即可同时比较向量中的所有通道。这仅用一条 CPU 指令就完成了 8 次标量比较。结果是一个布尔值向量。
• 第四步:归约结果
程序首先检查是否所有通道都满足条件。如果是,则直接继续下一次循环。如果有任何一个通道不满足(即发现了控制字符),则通过位转换 @bitCast 将布尔向量转为整数掩码,并利用 @ctz(计算末尾零个数)指令快速定位到第一个失效的具体位置。
• 第五步:标量尾部处理
在向量循环结束后,剩下的零星数据使用最初的标量循环处理。这保证了逻辑的完整性,同时也作为不支持 SIMD 处理器的降级方案。
性能提升与编译器限制
在实际测试中,这种优化在支持 AVX2 的英特尔桌面上带来了约 5 倍的端到端性能提升。虽然现代编译器拥有自动向量化功能,但它们通常只能处理非常简单的算术循环,且极易因为代码微调或编译器更新而失效。作者认为,对于关键的性能路径,应当显式地编写 SIMD 代码,以确保性能的可预测性。
结论
开发者不应畏惧 SIMD。只要能够识别出扫描、比较、计数或转换大块连续数据的场景,就可以尝试应用这种通用模式。通过现代编程语言(如 Zig)提供的泛型向量支持,开发者无需编写汇编代码或了解复杂的 CPU 特性,就能获得显著的性能增益。