Hacker News 中文摘要

RSS订阅

Go 中的跨平台 SIMD -- Platform-Independent SIMD in Go

Go 1.26 和 1.27 引入了单指令多数据(SIMD)的实验性 API。SIMD 是现代 CPU 的原生特性,能在单条指令里对一组数据做统一运算,比如一次完成 8 对 float64 值的加法,可显著加速密码学、数据处理和 AI 等计算密集型任务。此前在 Go 里使用这一能力只能编写 Go 汇编,只对性能极其关键的场景才划算,Go 1.26 为 amd64 提供了 API,1.27 又加入 arm64(NEON)和 wasm,但各平台差异巨大。

正文

Go 1.26 和 1.27 包含了用于单指令多数据(SIMD)操作的实验性 API。SIMD 是许多现代 CPU 的原生特性,允许软件跨数据向量极快地执行统一操作,例如一条指令相加 8 对 float64 值,可大幅加速从加密、数据处理到 AI 的许多计算密集型任务。事实上,Go 的 Green Tea 垃圾回收器就利用 SIMD 加速扫描内存中的存活对象。

在这些新实验性 API 之前,从 Go 访问此功能的唯一方法是编写 Go 汇编。这只对真正性能关键的计算内核值得,意味着大量本可受益的软件让 CPU 许多算力闲置。

Go 1.26 为 amd64 引入了 SIMD API,Go 1.27 增加了 arm64(特别是 NEON)和 wasm 的 API。但 SIMD API 的一个基本挑战是平台间巨大差异,不仅是支持的操作,连向量表示法都不同。有些平台提供固定大小向量(通常 128 到 512 位),有些在构建时大小未知,必须程序启动时查询。为提供对这些平台广度的完整访问,相关 API 位于依赖架构的 archsimd 包中。

但 Go 1.27 超越这些架构相关 API,引入了实验性的、完全可移植且与平台和大小无关的 SIMD 接口,大致基于 C++ 的 Highway。目标是支持一次编写、接近汇编性能的代码,并在无 SIMD 支持的平台提供可靠的模拟。simd 包目前支持 amd64 上的 AVX、AVX2、AVX512,arm64 上的 NEON,以及 wasm 的 SIMD 指令。

动机:SIMD 架构间的差异

SIMD 架构在多个维度变化。有些提供单一固定向量大小(wasm、PowerPC、s390x 为 128 位);有些提供多个固定大小(amd64 有 128、256、512;loong64 有 128、256);Riscv64 支持 128 到 65536 位间未指定大小的向量(长度限 2 的幂);Arm64 支持一个固定大小(128 位 NEON)和一个可变大小(128-2048 位,仅 2 的幂,SVE)。在特定架构实例上,确定支持的大小需特性检查:amd64 是 AVX、AVX2 还是 AVX512?arm64 是 NEON 还是 SVE?若是 SVE,多大?哪个变体:SVE、SVE2 还是 SVE2.1?

不同架构处理向量掩码方式不同。跨向量的若-则-否则可用掩码实现,即只在不掩码为“真”处赋值(或加载存储)。有些架构不提供掩码,所有操作跨所有元素,“掩码”用位掩码和布尔操作完成(wasm、AVX、AVX2、NEON);有些提供特殊掩码寄存器,一位控制一个元素(AVX512、RVV);SVE 每字节一位,但每元素掩码最低位控制操作;AVX2 支持掩码加载存储,但用普通向量作掩码,最高位控制。

第三类差异是操作本身。各架构有自己重排原语,有些需常量输入,有些支持变量。加密相关操作支持不同。即使基础算术也有差异,如 wasm 缺乏 64 位整数向量比较。即便特定架构给定向量长度,指令支持也依赖须检查的特性。

尽管 Go 的 archsimd 包尽量跨架构统一,许多怪癖仍在,使多平台 SIMD 设计编写测试繁琐。虽可在 archsimd 让架构更相似,但不损效率前提下有限。

概述

新 simd 包从类型系统移除固定大小向量,只支持各平台操作交集,并用其他 SIMD 指令高效模拟交集缺口。目标操作集:1) 足以支持许多受益向量化的数据处理算法(但不绑定特定向量大小);2) 当源码操作匹配底层硬件时效率媲美汇编;3) 否则尽可能模拟;4) 易读易懂(即便由 LLM 写代码)。

缺乏 SIMD 或 archsimd 不支持的平台,所有操作被模拟,因此用 simd 包写的代码总能运行。

使用此实验包需设 GOEXPERIMENT=simd,如同使用 archsimd。

simd 向量类型是大写复数原始类型,如 simd.Uint8s 或 simd.Float32s。向量从切片加载和存回切片。文章举了 innerProduct 例子,用 simd.Float32s 加载 x 和 y 的切片段做 MulAdd 累积,余下部分用 LoadFloat32sPart 处理,最后求和。此例也显示首版限制:因无通用跨元素求和法,Go 1.27 的 simd 不支持,需用 Store 到切片再循环求和,下版将推 ReduceSum 替代。

SIMD 比较产生掩码值,对应向量元素宽度(如 Int8s 比较产生 Mask8s),掩码可用于选择和过滤向量。

Go 1.27 支持的 simd 包操作

在 Go 1.27 中,simd 包为各种整数和浮点向量类型(Int8s 到 Float64s)提供操作。包级函数支持所有类型的 Load、LoadPart 和 Broadcast。存储和字符串方法同样全类型支持 Store、StorePart 和 String。算术上,Add、Sub、Mul(64 位整数和 Uint64s 除外)、Div(仅浮点)、Min、Max(64 位整数和 Uint64s 除外)、Not、Or、Xor 等基本全支持;Abs 不支持 64 位整数和 Uint;MulAdd 仅浮点;CarrylessMultiply 仅在 Uint64s 有 Even/Odd 方法。比较操作支持 Equal、Greater 等,但无符号类型的 Greater 等仅从 16 位起有。转换操作允许不同位宽和浮点/整数间转换及 ToMask。掩码方法支持 And、Or、ToIntWs 等。移位和旋转仅支持 16 位及以上整数类型。零成本重塑操作允许在向量与其位表示 Uint 类型间转换。

与平台特定代码的过渡

可能 simd 包对某些应用不够,或缺少必要特性模拟。simd 包支持转向架构特定 SIMD。每个向量类型有 ToArch() 返回 any,可类型断言为特定平台类型;转回用 simd.FromArch。可移植代码需为各平台写特定代码含模拟。

文章给了完整例子:假设算法需 Int8s.OnesCount()(Go 1.27 缺),不必重写整个算法。amd64 上 AVX/AVX2 缺此指令但 AVX512 有,代码用 ToArch 分派,查表模拟或调原生;NEON 和 wasm 直接调原生;无 SIMD 硬件时调纯 Go 模拟函数 OnesCountEmulated(用位掩码技巧)。接口转换和类型开关看似低效,但编译器会特化代码并优化掉。

API 交集与方法模拟

simd 提供的操作需在多数架构跑得可接受。首先,到处支持的操作易加入。跨架构朴素交集仍有洞,通过向 archsimd 加模拟填补。archsimd 已有许多平凡模拟(如有符号无符号加提供不同方法但编译同指令,浮点取负绝对值必要时实现)。许多模拟只需 2-3 指令,如标量移位用向量移位模拟,无符号比较用有符号加两次 XOR 常量。并非都简单:无进位乘法(carryless multiply)对加密和 CRC 重要但不总支持,故提供模拟且运行时不因输入变(防时序攻击)。另有些如“add pairs”下版将提供高层 sum reduction 而非原语,也避免用户依赖向量长度。支持所有平台(含未来一年将出现的)的约束使加方法较保守,Riscv64、ppc64、s390x、loong64 各有 SIMD 扩展。

GODEBUG 设置

有硬件支持平台可用 GODEBUG 改行为测不同配置。Go 1.27 级别按向量长度分:

  • GODEBUG=simd=0:即使有硬件也模拟。
  • =128/256/512:用对应位宽及特性,无则立即 panic。
  • =+128/+256/+512:用对应位宽即使缺些特性,用到不支持指令才 panic(如树莓派 NEON 缺 PMULL,苹果硅 amd64 模拟 AVX2 缺 VPCLMULQDQ)。

实现细节

调试用 simd 的代码或看栈迹会发现怪异类型和办法。因 simd 既是包,也是内部实现包和编译器前端的 AST 重写。AST 重写生成提及 simd 类型的函数、变量、类型的多份特化副本,simd 类型被替换为 simd/internal/bridge 中定长专用类型(定义为 archsimd 类型但方法受限)。专用函数变量加 @simdNNN 后缀(NNN 为 128/256/512 或 0 表模拟)。内部用 simd 但签名没有的函数转成包装器,在程序启动检测到的 SIMD 级别分派调专用版。专用函数直调其他专用函数免开销。此策略权衡代码重复与性能,将开销提至必要高度避免计算内分发。若分发显“太低”,可随意提一下 simd 类型将其上移,如基准测试里声明 var _ simd.Uint64s 使循环直调专用版。

即将到来

计划很快发博文详述 archsimd。Go 1.28 拟加 SVE 到 archsimd 和 simd;加更多操作(OnesCount、掩码、归约、重排);并加少量“特性变体”,避免对有硬件向量但缺一两操作(如树莓派)的平台完全降级模拟。

评论

社区对Go实验性SIMD API整体持欢迎态度,认为它弥补了以往需依赖C/C++的短板,但少数声音指出真正瓶颈在互操作开销与自动向量化,并对文档中编译器优化表述存疑。

认为Go内置SIMD是积极尝试,可减少C/C++依赖并提升性能

  • 较多人表示以往为性能不得不链接C/C++,如今标准库支持打开了底层优化大门,已有项目获得约30%加速(较多人) > 几乎总得链接C/C++才能获得合适性能,这功能很棒(@physicsguy)

认为真正瓶颈在互操作开销而非裸性能,且新API未填补自动向量化差距

  • 少数人指出Go的问题实质是C/C++互操作开销,先前“减少30%开销”宣传对多数情况不实,且实验API未解决自动向量化通常已足够好的场景(少数人)

质疑编译器对类型开关的优化说明不清晰

  • 少数人困惑于同一二进制运行未知类型时编译器如何专门化并消除类型开关,认为表述模糊(少数人) > 同一Go二进制可能运行未知类型,如何优化掉类型开关令人费解(@vlovich123)

有价值的补充

  • 便携SIMD比非便携SIMD慢约11%,但两者均比非SIMD快约5倍,已有浏览器内换色基准测试可参考(@ImJasonH)
  • C++最新标准也引入std::simd,即便非最优也胜于标量操作,反映向量化趋势(@beached_whale)
  • 有用户在图像前景估计中应用该特性获30%提速,但算法本身尚有余地(@karolist)