Go 语言中与平台无关的 SIMD
文章摘要
这是 Go 官方博客 2026 年 9 月 24 日的文章,作者是 Go 团队的 David Chase 和 Junyang Shao。Go 1.26 和 1.27 都带了实验性的 SIMD(单指令多数据)API。SIMD 是现代 CPU 的原生能力,一条指令就能对一整个向量做同样的运算,比如一次把 8 对 float64 相加,从密码学、数据处理到 AI 都能大幅受益,Go 自己的 Green Tea 垃圾回收器就用 SIMD 加速扫描存活对象。在此之前,Go 程序想用 SIMD 只能手写 Go 汇编,只有真正性能关键的计算内核才值得这么做,于是大量本可以受益的代码就让 CPU 的这部分能力闲着。
Go 1.26 先为 amd64 提供了 SIMD API,1.27 又加上了 arm64(NEON)和 wasm。这些 API 放在依赖架构的 simd/archsimd 包里。而 Go 1.27 更进一步,推出了一个实验性的、完全可移植、与平台和向量长度都无关的 simd 包,设计上大致参考了 Google 的 C++ 库 Highway。目标是”写一次”,在有 SIMD 的平台上接近汇编的性能,在还没有 SIMD 支持的平台上提供像样的模拟。目前支持 amd64 上的 AVX、AVX2、AVX512,arm64 上的 NEON,以及 wasm 的 SIMD 指令。
为什么需要一层抽象:各家 SIMD 差异太大。 文章先列举了差异的几个维度。向量长度:wasm、PowerPC、s390x 只有固定的 128 位;amd64 有 128、256、512 三档,loong64 有 128 和 256;riscv64 支持 128 到 65536 位之间任意 2 的幂长度;arm64 既有固定 128 位的 NEON,又有 128 到 2048 位可变的 SVE。同一架构的具体机器支持什么还得运行时检测:是 AVX、AVX2 还是 AVX512?是 NEON 还是 SVE?SVE 多宽?是 SVE、SVE2 还是 SVE2.1?掩码的处理方式也各不相同:wasm、AVX、AVX2、NEON 没有专门的掩码,靠向量位掩码加布尔运算实现;AVX512 和 RVV 有专门的掩码寄存器,每个元素一位;SVE 每个字节一位,但只看每个元素最低位;AVX2 的掩码读写又是用普通向量做掩码、看最高位。指令集本身也有缺口,例如 wasm 没有 64 位整数向量的比较。即便 archsimd 已经尽量统一,写多平台 SIMD 代码依然很折腾。
simd 包的设计。 新包的做法是把固定长度的向量从类型系统里拿掉,只提供所有平台都支持的运算(取交集),交集里的空缺再用其他 SIMD 指令高效地模拟出来。设计目标是:足以支撑很多适合向量化、但不绑定具体向量长度的数据处理算法;源码运算和硬件吻合时与汇编一样快;否则尽量模拟好;而且代码要好读好懂,文中特意加了一句”即便(或者说尤其是)代码最后由 LLM 来写”。在没有 SIMD 指令或 archsimd 尚未覆盖的平台上,所有运算都被模拟,所以用 simd 写的代码总能运行。使用时需要在构建时设置 GOEXPERIMENT=simd。
向量类型就是基础类型名首字母大写再加复数,比如 simd.Uint8s、simd.Float32s,从切片加载、存回切片。文章用一个内积函数做示例:循环里每次按 a.Len() 的步长加载两段 Float32s,用 MulAdd 累加,尾部不足一个向量的部分用 LoadFloat32sPart 处理,最后把向量存回切片求和。这个例子也暴露了第一版的一个局限:因为各平台没有统一的”横向求和”方式,1.27 里还没有 ReduceSum,下个版本才会加上。比较运算返回与元素宽度对应的掩码类型(Int8s 比较得到 Mask8s),可用于选择和过滤。文章还附了一张完整的运算支持表,涵盖加载与广播、算术(含饱和加减、乘加、开方等)、按位与掩码运算、无进位乘法、比较、类型转换、移位与循环移位,以及一组”零成本重塑”操作。
与平台专用代码互相转换。 如果 simd 包不够用,每种向量都有 ToArch() 方法返回一个 any,可以类型断言成平台专用类型,用 simd.<类型>FromArch 再转回来。文章以 1.27 缺少、计划在 1.28 加入的 Int8s.OnesCount()(逐元素数 1 的个数)为例,展示如何只补一个缺失的操作,而不必为每个平台重写整个算法:amd64 上 AVX512 有现成指令,AVX/AVX2 用 16 项查找表加 PermuteOrZero 拼出来;NEON 和 wasm 本来就有这条指令;没有硬件 SIMD 的平台走一个用位运算实现的通用模拟函数。看起来接口转换和类型 switch 会很慢,但编译器会做特化,把类型 switch 优化掉。
交集里的空洞怎么补。 凡是各平台都支持的运算(加载、存储、算术、大部分比较)直接纳入。剩下的空洞靠在各平台的 archsimd 里加模拟来填。很多模拟只需两三条指令,比如有的架构只支持所有元素同一个移位距离,有的支持每个元素不同距离;某些架构缺无符号比较,就用有符号比较加两次与常量异或实现。对密码学和 CRC 很重要的”无进位乘法”并非处处都有,团队为它写了模拟,而且因为常用于加密,模拟的运行时间不随输入变化。对于”成对相加”这类原语,下个版本会直接提供它通常服务的上层操作,也就是求和归约,这样用户也不必关心向量长度带来的归约步数差异。由于要兼顾预计一年内会进入 archsimd 的 riscv64、ppc64、s390x、loong64,团队在往 simd 里加方法时相当保守。
GODEBUG 开关。 为方便在不同硬件配置下测试,可以用 GODEBUG 调整 SIMD 级别:simd=0 强制模拟;simd=128/256/512 使用对应宽度及其特性,128 档在特性不全时直接 panic;带加号的 +128、+256、+512 表示即使部分特性缺失也照样用,碰到不支持的指令才 panic。典型例子是树莓派(有 NEON 但没有无进位乘法 PMULL),以及 Apple Silicon 上的 amd64 模拟(有 AVX2 但没有 VPCLMULQDQ)。
实现细节。 调试时会在栈回溯里看到奇怪的类型和方法,因为 simd 同时是一个包、一个内部实现包,以及编译器前端的一次 AST 改写。编译器会为所有提到 simd 类型的函数、变量和类型生成多份特化副本,副本名带上 @simdNNN 后缀(NNN 为 128、256、512,或 0 表示模拟)。签名里不含 simd 类型、只在内部用到的函数会变成一个包装函数,根据程序启动时检测到的 SIMD 级别分派到对应特化版本;特化函数之间直接互调,没有分派开销,还可能被内联。这是在代码膨胀和 SIMD 性能之间的折中:分派开销被提到必要的高度,但不再更高。如果分派位置太低,可以在外层函数里随便提一下 simd 类型把它往上提,文中的基准测试就用了 var _ simd.Uint64s 这个小技巧。
接下来。 团队会另写一篇文章详细介绍 archsimd。Go 1.28 计划给 archsimd 加上 SVE 支持,也希望带到 simd;还要补充 OnesCount、掩码操作、归约、向量重排等运算,并引入少量”特性变体”,让树莓派这类只缺一两条指令的平台不必整体降级到完全模拟。
HN 评论精华
本帖 414 分、约 150 条评论。讨论大致分三块:一是对 Go 加入 SIMD 的欢迎和实测数据;二是作者 David Chase(dr2chase)对实现细节的补充;三是一场被一句”Go 是内存安全的系统语言”引出来的、和 SIMD 关系不大的内存安全定义之争,占了相当大的篇幅。
欢迎与实测。 qprofyeh 说这为已经跑满多核的 Go 项目打开了优化底层性能的大门,内置标准库支持 SIMD 的语言并不多。pjmlp 列出了 Java、.NET、D、Zig、Julia、Swift、Rust 等已有类似能力的语言,但也说多数语言不鼓励手写 SIMD,因为”我们大多数人根本不知道怎么写好 SIMD 代码,我肯定不会”。stingraycharles 在一家大量使用 SIMD 的 C++ 时序数据库创业公司工作,他说除了极少数地方,他们基本都用现成的库,手写 SIMD 主要是库维护者的事。ImJasonH 做了一个在浏览器里用 wasm 给图片换色的基准页面:可移植 simd 比平台专用的 archsimd 慢约 11%,但两者都比不用 SIMD 快约 5 倍。karolist 在抠图前景估计上用了它,提速约 30%;sixdimensional 在纯 Go(CGO_ENABLED=0)实现的语音识别和语音合成模型里试用,也说提升可观。physicsguy 说过去用 Go 做高性能计算几乎总得链接 C/C++,这下解决了一个心头大患,但很多时候”自动向量化就够了”,这个包没有填这个空白;typical182 补充说 Go 编译器的自动向量化工作已经在推进,有一组 CL 正在评审,由一位外部贡献者主导,可能是其博士研究的一部分。
为什么拖了这么久。 u8 的评论得票最高:”这就是我爱 Go 的原因,没人要这个,但他们花时间把它做对了。”physicsguy 反驳说明明很多人在要。typical182 解释了来龙去脉:相关提案一直很受欢迎,但 Go 核心团队过去认为正确的做法是设计一个能经得起时间考验的高层跨平台 API,因为太复杂被一再推迟;后来改变思路,先做底层依赖架构的 archsimd,再做高层可移植的 simd,这种两层设计也给了高层 API 更多自由,需要的人可以随时下沉到底层。senderista 评价说分层 API 是解决易用性和性能矛盾的好办法。nonethewiser 借题发挥,说 Go 的风格本来就是”不给用户他们要的东西”,比如泛型拖了 13 年,有时这门语言更像是为维护者而写的;sa46 和 andrewstuart2 则回应,Go 一开始就对切片、数组、map 提供了内置的泛型,加上接口已经覆盖了大部分需求,迟迟不加用户泛型也是为了保住编译速度。
作者答疑。 vlovich123 不理解文中”类型 switch 会被优化掉”的说法,同一个二进制要跑在未知的 CPU 上,怎么做到?Scaevolus 引用文章解释:编译器为涉及 SIMD 的函数生成多个版本,把分派开销提到调用方。keel_dev 追问这样二进制体积会不会膨胀。dr2chase 亲自回答:只有涉及 SIMD 的函数和类型会被复制,倍数取决于平台,wasm 是 2 倍,amd64 是 4 倍,arm64 目前 2 倍、很快会变成 4 倍,loong64 将是 3 倍;而且一次运行中 N-1 份副本永远不会执行。模拟版本主要是给测试用的,他还自嘲”你信得过我们吗?我反正不信”。SVE 和 RVV 因为长度可伸缩,复制量可能更大;Go 用固定大小的栈帧,所以 SVE 大概会生成模拟、128、256、512 带掩码几个版本,再加上 NEON 和不带无进位乘法的 NEON(树莓派)。Am4TIfIsER0ppos 问有多少”函数”其实只编译成一条 movd,dr2chase 说所有标为”零成本”的方法都只编译成 movd 或更少;整数向量和掩码之间的转换在 wasm、NEON、AVX、AVX2 上零成本,在 AVX512 和 SVE 上要换表示;编译器后端还有模式匹配优化,比如在 AVX512 或 SVE 上一个运算后接 IfElse,很可能合并成一条指令。他坦言对目前的方法拆分方式也不完全满意,”但没有更好的主意,总得定下来”。
可伸缩向量是亮点。 mshockwave 说在他最近看过的各种可移植 SIMD 方案里(比如 Rust 的 Fearless SIMD),这是第一个让 SVE 和 RISC-V 向量扩展这类非固定长度向量更容易支持的。Highway 的作者 janwas 现身说,这个做法是 Highway 首创的,他们也给 Go 的 API 提过建议,很高兴看到这个决定。melodyogonna 持保留意见,认为大多数情况下还是应该为每个平台专门优化,可移植实现只作后备,可惜很少有语言有 Zig 那样的 comptime;mshockwave 回应说可以像 LLVM IR 的 vscale 那样把一个运行时才知道的伸缩因子纳入设计,编译器照样能围绕它做优化。burntcaramel 总结了三种可移植 SIMD 思路:WebAssembly 是固定 4 个 float32,Mojo 是编译期参数 N 个,Go 是”一个 float32 向量”,长度不进类型。beached_whale 说 C++ 也将有 std::simd,他会尽量少用内建函数,”写一次出 N 个错,而不是 N×M 个”。
跑题的内存安全之争。 u8 那句”Go 是内存安全的高级系统语言”引来 __s 一句”Go 的数据竞争不是内存安全的”,随后吵了几十楼。simonask 和 beltsazar 认为没有数据竞争自由就没有内存安全,Java 即使有数据竞争也仍是内存安全的,Go 不是;monocasa 指出 Go 的切片、接口这类多字结构在并发读写时会读到不一致状态,导致越界读写,很容易让程序段错误,只是由于运行时的各种随机性很难被真正利用。tptacek 坚持”内存安全”是安全界的专门术语,指不易受栈溢出、堆溢出、释放后使用、类型混淆这类内存破坏攻击,几十年来几乎没有针对纯 Go 程序的非刻意构造的内存破坏利用;typical182 引用前 Go 安全团队负责人的话,说他从没见过真实 Go 代码因数据竞争而可被利用。saagarjha 则不买账,说 tptacek 发帖多、做过安全,并不等于”业界公认”。这场争论最后谁也没说服谁。
还有些零星的话题。metaltyphoon 呼吁官方博客给代码加语法高亮,watermelon0 翻出 Rob Pike 的旧言”语法高亮是幼稚的,我小时候用彩色积木学算术,长大后就用单色数字了”,又引出一轮关于语法高亮的口水战。shevy-java 感叹 Rust 的势头好像盖过了 Go,adeptima 说自己两个都用,Go 的岗位反而更多,高负载电商、物流用 Go 更顺手,地理空间分析则更适合 Rust。