⚡ 一次编写,处处加速:Go 1.27 的可移植 SIMD,和它在我的服务器上跑出的数字
目录
- 先看两个数字:7.6 毫秒,和 3.6 毫秒
- 什么是 SIMD:让 CPU 一次算八个
- 为什么说「可移植」很难:每颗 CPU 的脾气都不一样
- Go 的解法:把向量藏进类型系统
- 同一台机器上,2.13 倍是怎么来的
- 仿真的代价:可移植性不是免费的
- 谁的代码能受益:从图像处理到数据库
- 结尾:一次编写,处处加速——和它没承诺的那部分
先看两个数字:7.6 毫秒,和 3.6 毫秒
昨天晚上我在服务器上跑了一段点积运算:两个 400 万元素的浮点数组,逐元素相乘再累加。
第一版是朴素 Go 循环,7.6 毫秒。
第二版只改了一处——把循环里的逐元素相乘换成 Go 1.27 新引入的可移植 SIMD 接口。同一台机器、同一份数据、同一个算法,跑完是 3.6 毫秒。
2.13 倍。 没有改算法,没有换硬件,没有写一行汇编。只是把「让 CPU 一次算一个」换成了「让 CPU 一次算八个」。
这不是什么新概念。SIMD(单指令多数据)从 90 年代就在桌面 CPU 里了,x86 的 MMX、SSE、AVX,ARM 的 NEON,都是它。但问题从来不是「CPU 支不支持」,而是「你的代码能不能用上」。
Go 语言一直有个老毛病:想用 SIMD,只能写汇编。而 Go 的哲学是「少即是多」,汇编这扇门把 99% 的程序员挡在了外面。直到 Go 1.26 和 1.27,官方才第一次给了实验性的 SIMD API。
什么是 SIMD:让 CPU 一次算八个
先把概念说清楚。
普通 CPU 指令是「标量」的:一条指令处理一个数据。a + b 就是把两个数字加起来。
SIMD 是「向量」的:一条指令同时处理一组数据。Intel 的 AVX-512 一条指令能同时算 16 个 32 位浮点数——a[0..15] + b[0..15] 一条指令搞定。
对某些负载来说,这是数量级的差距。图像处理(每个像素做同样的变换)、音频处理、密码学、数据库扫描、矩阵运算——凡是「对一大片同质数据做同样操作」的场景,SIMD 都是天然的加速器。
Go 官方的 Green Tea 垃圾回收器自己就在用 SIMD 加速内存操作。
但问题在于:怎么让普通程序员用上它。
为什么说「可移植」很难:每颗 CPU 的脾气都不一样
如果你只在一台机器上跑,写汇编就行。但 Go 的卖点是「一次编写,到处编译」——你的代码可能跑在 amd64 服务器上,也可能跑在 ARM 的树莓派上,还可能跑在未来的 RISC-V 上。
而每颗 CPU 的 SIMD 都不一样:
- wasm / PowerPC / s390x:只有一种固定 128 位向量
- amd64:128(AVX)、256(AVX2)、512(AVX512)三种
- loong64(龙芯):128 和 256 两种
- arm64:NEON 固定 128 位,SVE 可变 128-2048 位
- riscv64:向量长度 128 到 65536 位不等,运行时才知道
不光是宽度不同,连「怎么表示向量」都不同。有的平台向量长度编译时就知道,有的要程序启动时查询。有的支持可变移位距离,有的只支持统一移位。连「if-else 怎么实现」都不一样——有的用掩码指令,有的只能拆开。
这就是为什么写一个跨平台 SIMD 库这么难:你要么为每个平台各写一份汇编(然后维护地狱),要么放弃 SIMD(然后性能地狱)。
Go 的解法:把向量藏进类型系统
Go 1.27 的思路很有意思:把「向量」变成类型系统里的一等公民,然后只保留所有平台都支持的操作的交集。
// 加载两个向量
va := simd.LoadFloat32s(a[i:])
vb := simd.LoadFloat32s(b[i:])
// 一次乘加:va * vb + acc
acc = acc.Add(va.Mul(vb))
关键设计:
向量类型是
simd.Float32s这种复数形式——不是Float32x4或Float32x8,因为长度不该由你决定,而该由硬件决定。你的代码写的是「一个向量」,至于这个向量在 amd64 上是 4 个 float32(128 位)、在 AVX512 上是 16 个(512 位)——编译器和运行时替你决定。只保留所有平台都支持的操作。那些某个平台独有的指令(比如 carryless multiply,密码学常用但很多平台没有),就留缺口,用仿真补齐。
没有 SIMD 的平台自动仿真。代码永远能跑——只是慢。
这套设计模仿了 C++ 的 Highway 库,但把它融进了语言本身。
官方博客给了个绝妙的例子:假设你要用 Int8s.OnesCount()(统计每个字节里 1 的个数),但 Go 1.27 的 simd 包还没实现它。你不必等官方——可以用 ToArch() 跳到架构相关层自己写一个 amd64 的实现,再用仿真给其他平台兜底。接口留了口子,但默认路径是统一的。
同一台机器上,2.13 倍是怎么来的
理论说完了,来真的。
我下载了 Go 1.27.1,在一台支持 AVX2 和 AVX512 的机器上,写了两个点积实现:
- 纯 Go 循环:
for i := range a { acc += a[i] * b[i] } - simd 包:
LoadFloat32s+Mul+Add,用Store收尾
数据:400 万元素的 float32 数组(每份 16MB),固定随机种子,预热后跑 5 次取最好成绩。
n=4194304 (4M float32 = 16MB per slice)
pure-Go: 7.6 ms result=1046548.500000
simd-pkg: 3.6 ms result=1048411.812500
speedup: 2.13x
vector len: 4 elements (128 bits)
emulated: false
2.13 倍,没有写一行汇编。向量长度 4(128 位,AVX),emulated: false 说明走的是硬件 SIMD 而不是仿真。
注意一个细节:两个实现的结果不完全一样——1046548.5 vs 1048411.8。这不是 bug,是浮点累加顺序不同导致的:标量循环按顺序累加,SIMD 是先把向量内四个元素横向求和再加到一起。对 400 万个数来说,这个差异在正常浮点误差范围内——但如果你写的是「必须逐位可复现」的金融代码,这就是个需要知道的坑。
仿真的代价:可移植性不是免费的
前面说了,simd 包在无 SIMD 平台会自动仿真,「只是慢」。
慢多少?我实测了一下:用 GODEBUG=simd=0 强制走仿真路径——
pure-Go: 5.9 ms result=1046548.500000
simd-pkg: 37.7 ms result=1048411.812500
speedup: 0.16x
emulated: true
仿真比纯 Go 还慢 6 倍。 0.16 倍,不是 2.13 倍。
这不是 Go 的失败,是「可移植性」的物理代价:仿真层要为每个向量操作做一堆簿记工作(分配、检查、组装),这些开销比直接写标量循环还大。也就是说:
- 有 SIMD 的机器:simd 包 ≈ 2 倍加速
- 没有 SIMD 的机器:simd 包 ≈ 6 倍减速
所以「write-once, run-anywhere」是有条件的——你得先确认你的目标平台有 SIMD,或者你能接受没有 SIMD 平台的性能回退。 Go 的答案很诚实:接口是实验性的,GOEXPERIMENT=simd 才启用,而且官方明确说「如果你只针对特定架构,archsimd 更合适」。
这其实是个很好的设计取舍:默认路径追求的是「正确 + 可移植」,性能是「有硬件就白捡」的 bonus,不是承诺。
谁的代码能受益:从图像处理到数据库
SIMD 最适合的负载是「对一大片同质数据做同样操作」:
- 图像处理:每个像素做同样的滤镜/缩放/色彩变换
- 音频/视频编解码:大量样本的数学变换
- 数据库:扫描一列数据做过滤/聚合(列式存储的天作之合)
- 密码学:哈希、加密、校验和(carryless multiply 就是为此)
- AI 推理的量化层:矩阵乘法的内积运算
Go 生态里这些场景不少:图像库、嵌入式数据库、时序数据库、日志处理——都是 SIMD 的天然客户。以前它们要么写汇编(像 Green Tea GC 那样),要么用 cgo 调 C 库(引入 FFI 开销和部署复杂度)。现在有了纯 Go 的路径。
特别值得说的是龙芯。 Go 官方博客明确把 loong64 列在支持列表里:128 位和 256 位向量。也就是说,这套可移植 SIMD 接口在中国自主指令集 LoongArch 上同样成立——你不用为龙芯单独写汇编,同一份 simd.Float32s 代码,在 amd64 上是 AVX,在龙芯上是 LSX/LASX。对国产芯片的软件生态来说,这种「编译器替你适配」的路径比「每个架构手写优化」现实得多。
结尾:一次编写,处处加速——和它没承诺的那部分
回到开头那两个数字:7.6 毫秒 vs 3.6 毫秒。
2.13 倍不是奇迹,是「让 CPU 干它本来就能干的事」的正常回报。真正有意思的是 Go 花了十年才走到这一步——不是技术上做不到,是设计哲学上绕了个大弯:一个以「简单」为最高信条的语言,要怎么引入「不同平台行为不同」的复杂性?
Go 1.27 的答案是:把复杂性藏进类型系统,让程序员只写「一个向量」,让编译器替你回答「这个向量在这台机器上是多宽」。
它没承诺的也很明确:仿真不是免费午餐,接口是实验性的,GOEXPERIMENT=simd 是道显式的门。你要的是可移植的正确性,性能是有硬件就白捡的 bonus。
我的服务器已经跑上这段代码了。2.13 倍,够我这种规模的负载用了。
评论(0)
暂无评论,来写第一条吧~