Go 1.27 SIMD 初探
前言
Go 1.27 正式加入了实验性的 simd 包。以前想在 Go 里用 SIMD,常见办法是写汇编、用 cgo,或者依赖某个已经做好平台适配的库。现在终于可以直接写 Go 代码了。
我对这个功能最感兴趣的,不是“语法上能不能写”,而是几个更实际的问题:编译器会生成什么指令?短 slice 值不值得向量化?ARM64 和 AMD64 上表现如何?fallback 到软件模拟之后又有多慢?
于是我写了 Add、SAXPY、Clamp 和 Dot 四组 kernel,在两台机器上分别跑了标量、硬件 SIMD 和软件模拟。
SIMD 到底做了什么
SIMD 是 Single Instruction, Multiple Data,一条指令同时处理多份数据。
普通标量代码使用一个 float32 寄存器,每次完成一次加法。128-bit SIMD 寄存器则可以切成 4 个 32-bit lane,一条向量加法让四个 lane 同时工作。若寄存器宽度是 256 bit,一次便可以容纳 8 个 float32。
lane 可以直译为"通道",它就是寄存器里的一个格子。一条向量指令下来,所有 lane 同步执行同一个操作:lane0 算 a0+b0,lane1 算 a1+b1,彼此不能各干各的。所以 lane 数就是一条指令一次能处理的数据份数——寄存器越宽,lane 越多。
flowchart TB
subgraph scalar["标量:4 次加法指令"]
direction LR
S0["a0 + b0"] --> S1["a1 + b1"] --> S2["a2 + b2"] --> S3["a3 + b3"]
end
subgraph vector["128-bit SIMD:1 次向量加法指令"]
direction LR
VA["寄存器 A<br/>a0 | a1 | a2 | a3"] --> OP["FADD<br/>4 个 lane 同步执行"]
VB["寄存器 B<br/>b0 | b1 | b2 | b3"] --> OP
OP --> VC["结果寄存器<br/>c0 | c1 | c2 | c3"]
end
这里的“同时”发生在一个 CPU core 内。所有 lane 共享同一条指令和控制流,它不是启动了四个 goroutine,也不是占用了四个 CPU core。向量寄存器像一排格子,指令规定每个格子执行相同操作。
| 机制 | 并行单位 | 控制流 | 典型用途 |
|---|---|---|---|
| 标量 | 一个数 | 一条 CPU 指令流 | 普通业务逻辑、短任务 |
| SIMD | 一个 core 内的多个 lane | lane 共享同一条指令 | 向量、图像、编解码、扫描 |
| goroutine | 可调度任务 | 各自有独立 Go 控制流 | 并发 I/O、多核任务并行 |
| GPU SIMT | warp 内的多个 thread | thread 有自己的索引,通常按 warp 执行 | 大规模数据并行 |
SIMD 最喜欢连续内存、相同操作、元素之间互不依赖。下面这些情况会削弱它的收益:
- 数据太少,dispatch 和收尾成本盖过了计算本身。
- 每个元素要走完全不同的分支,难以用 mask 或 Min/Max 表达。
- 数据不连续,需要 gather/scatter,而不是顺序 load/store。
- 计算量很小,速度最终卡在内存带宽,而不是 ALU。
- 结果之间存在依赖,例如求和必须在最后把各个 lane 再归约成一个数。
Go 1.27 的两层 SIMD API
Go 1.27 的 SIMD API 分成两层:
| 包 | 定位 | 特点 |
|---|---|---|
simd |
可移植 API | 向量宽度未知,同一份代码可运行在 ARM64、AMD64、Wasm 或软件模拟上 |
simd/archsimd |
平台专属 API | 明确使用 Float32x4、Float32x8 等固定宽度类型,接近硬件 intrinsic |
本文使用第一层。simd.Float32s 表示“一组 float32”,但组里有几个元素并不写死。程序可以用 simd.VectorBitSize() 查询当前向量宽度,也可以用 Float32s.Len() 得到 lane 数。
它仍然是实验功能,需要显式开启:
GOEXPERIMENT=simd go test ./...
实验性也意味着它不受 Go 1 兼容承诺保护。用来学习和验证很合适,生产代码还得给 API 变化留出空间。详细设计可以看 Go 1.27 Release Notes 和 simd 包文档。
一份源码,多个机器码版本
simd.Float32s 的宽度在源码里未知,但 CPU 指令要求宽度明确。Go 1.27 的做法不是在每次 Add 时临时判断,而是在编译期生成多个函数 clone,再由 dispatch 入口选择当前机器能运行的版本。
flowchart LR
SRC["Go 源码<br/>simd.Float32s"] --> COMPILER["Go 编译器"]
COMPILER --> E0["func@simd0<br/>软件模拟"]
COMPILER --> E128["func@simd128<br/>Neon / XMM"]
COMPILER --> E256["func@simd256<br/>YMM"]
COMPILER --> E512["func@simd512<br/>ZMM"]
CPU["CPU feature<br/>+ GODEBUG"] --> DISPATCH["dispatch 入口"]
E0 --> DISPATCH
E128 --> DISPATCH
E256 --> DISPATCH
E512 --> DISPATCH
DISPATCH --> RUN["本次进程实际使用的版本"]
这样热循环里不需要为每个向量重复做 CPU feature 判断。程序启动时确定宽度后,本次执行中的所有可移植 SIMD vector 都使用相同长度。GODEBUG=simd=0 可以选择软件模拟,GODEBUG=simd=128/256/512 可以请求特定宽度;请求超出硬件或完整特性集时,运行时会 panic。
从向量加法开始
标量版本没有什么特别的:
func AddScalar(dst, a, b []float32) {
n := len(dst)
if len(a) < n || len(b) < n {
panic("input slice shorter than destination")
}
for i := 0; i < n; i++ {
dst[i] = a[i] + b[i]
}
}
SIMD 版本一次 load 一个向量,完成向量加法,再 store 回去:
func AddSIMD(dst, a, b []float32) {
n := len(dst)
if len(a) < n || len(b) < n {
panic("input slice shorter than destination")
}
var zero simd.Float32s
width := zero.Len()
i := 0
for ; i+width <= n; i += width {
va := simd.LoadFloat32s(a[i : i+width])
vb := simd.LoadFloat32s(b[i : i+width])
va.Add(vb).Store(dst[i : i+width])
}
// 处理不足一个向量的尾部
for ; i < n; i++ {
dst[i] = a[i] + b[i]
}
}
这段循环可以画成下面这样:
flowchart TD
START["i = 0"] --> CHECK{"剩余元素 ≥ width?"}
CHECK -- 是 --> LOAD["从 a、b 各 load 一个向量"]
LOAD --> CALC["所有 lane 执行 Add"]
CALC --> STORE["store 到 dst"]
STORE --> NEXT["i += width"]
NEXT --> CHECK
CHECK -- 否 --> TAIL["标量 tail loop<br/>处理 0 到 width-1 个元素"]
TAIL --> END["完成"]
load、计算和 store 是三个不同环节。SIMD 只会直接加速中间的向量计算和循环控制;数据仍然要从 cache 或内存搬到寄存器,再写回去。Add 每个元素只做一次加法,却要读取 a、读取 b、写入 dst,所以数据规模足够大后,内存带宽比加法单元更容易先到极限。
tail 也不能省。假设 width 是 4,而 slice 有 10 个元素,前两个向量处理 [0:4] 和 [4:8],最后两个元素只能交给标量循环。另一种写法是 LoadFloat32sPart 配合 mask,但简单运算里标量 tail 通常更直观。
这里有个容易忽略的细节:传给 LoadFloat32s 和 Store 的是 a[i:i+width],不是 a[i:]。
我最初用了后者。指令确实生成了,但 M2 Pro 上 SIMD Add 反而比标量慢。反汇编后才发现,每四个元素旁边都夹着多组 slice bounds check。改成精确长度的 slice 后,编译器消掉了循环内的大部分检查,16384 个元素的耗时从约 7.1 µs 降到 3.1 µs。Go 自己的 simd 测试代码也使用了这种写法。
这大概是本文最有用的一条经验:写 SIMD 不只是把 + 换成 Add,还得看看生成的机器码。
分支、mask 与水平归约
SIMD 的 Add、Mul、Min、Max 默认都是 lane-wise 操作:第 0 个 lane 只和第 0 个 lane 运算,不会碰到旁边的值。这很好理解,但也带来两个问题。
一个是分支。Clamp 的标量代码会对每个元素执行 if v < low 和 if v > high:
func ClampScalar(dst, src []float32, low, high float32) {
n := len(dst)
if len(src) < n {
panic("input slice shorter than destination")
}
for i := 0; i < n; i++ {
v := src[i]
if v < low {
v = low
}
if v > high {
v = high
}
dst[i] = v
}
}
向量版本可以把它改写成两条没有控制流分叉的操作:
src: [-4.0 | -1.0 | 2.0 | 8.0]
Max(src, -2.0): [-2.0 | -1.0 | 2.0 | 8.0]
Min(..., 3.0): [-2.0 | -1.0 | 2.0 | 3.0]
写成代码就是这样,注意循环体里没有任何 if:
func ClampSIMD(dst, src []float32, low, high float32) {
n := len(dst)
if len(src) < n {
panic("input slice shorter than destination")
}
var zero simd.Float32s
width := zero.Len()
vlow := simd.BroadcastFloat32s(low) // 每个 lane 都是 low
vhigh := simd.BroadcastFloat32s(high) // 每个 lane 都是 high
i := 0
for ; i+width <= n; i += width {
v := simd.LoadFloat32s(src[i : i+width])
v.Max(vlow).Min(vhigh).Store(dst[i : i+width])
}
// 处理不足一个向量的尾部
for ; i < n; i++ {
dst[i] = min(max(src[i], low), high)
}
}
更复杂的条件可以先用 Greater、LessEqual 等方法生成 mask,再用 IfElse 为每个 lane 选择值。它仍然只有一条向量控制流,只是各 lane 根据 mask 保留不同结果:
over := v.Greater(vhigh) // 生成 mask:超过上界的 lane 为 true
v = vhigh.IfElse(over, v) // true 的 lane 取 high,其余 lane 保留 v
另一个问题是归约。Dot 的乘法很好向量化,但最终结果只有一个数。向量循环结束时,累加器实际上还是四个或八个部分和:
flowchart LR
P0["第 0 轮<br/>a0*b0 | a1*b1 | a2*b2 | a3*b3"] --> ACC["lane-wise 累加器<br/>s0 | s1 | s2 | s3"]
P1["第 1 轮<br/>a4*b4 | a5*b5 | a6*b6 | a7*b7"] --> ACC
ACC --> H["水平归约<br/>s0 + s1 + s2 + s3"]
H --> SUM["一个 Dot 结果"]
水平归约会产生固定开销和 lane 之间的依赖。本文先把向量 store 到栈上的小数组,再用标量完成最后几次加法。这也是为什么很短的 Dot 可能比纯标量还慢。
完整的 Dot 长这样。先给标量版本作对照:
func DotScalar(a, b []float32) float32 {
n := len(a)
if len(b) < n {
panic("input slices have different lengths")
}
sum := float32(0)
for i := 0; i < n; i++ {
sum += a[i] * b[i]
}
return sum
}
SIMD 版本:
func DotSIMD(a, b []float32) float32 {
n := len(a)
if len(b) < n {
panic("input slices have different lengths")
}
var acc simd.Float32s // 零值就是零向量,每个 lane 都是 0
width := acc.Len()
i := 0
for ; i+width <= n; i += width {
va := simd.LoadFloat32s(a[i : i+width])
vb := simd.LoadFloat32s(b[i : i+width])
acc = va.MulAdd(vb, acc) // lane-wise:acc[k] += a[i+k] * b[i+k]
}
// 水平归约:store 到栈上数组,标量求和
var buf [16]float32 // 512 bit 最多 16 个 float32 lane
acc.Store(buf[:width])
sum := float32(0)
for k := 0; k < width; k++ {
sum += buf[k]
}
// 处理不足一个向量的尾部
for ; i < n; i++ {
sum += a[i] * b[i]
}
return sum
}
累加循环里每个 lane 只管自己的部分和,互不打扰;真正的"跨 lane"动作只有归约那几下。
四类测试
我选了四种形态不同的 kernel:
| Workload | 计算 | 想观察什么 |
|---|---|---|
| Add | dst[i] = a[i] + b[i] |
简单计算,容易受内存带宽限制 |
| SAXPY | dst[i] = x[i] * scale + y[i] |
SIMD FMA |
| Clamp | 把数值限制到 [low, high] |
标量分支对比向量 Max/Min |
| Dot | sum += a[i] * b[i] |
向量累加和水平归约 |
这几个名字值得展开一下。SAXPY 来自 BLAS 库的经典函数名——Single-precision A·X Plus Y,单精度的"乘加"。它比 Add 多一次乘法,而"乘完再加"正好对应 CPU 的 FMA(Fused Multiply-Add)指令,一条指令完成乘法和加法。Clamp 是把每个数钳制在 [low, high] 区间里,小于下界取下界、大于上界取上界,标量写法是每个元素两个 if,它代表带分支的一类计算。Dot 是向量点积,所有乘积累加成一个标量结果,它代表"最后要归约成一个数"的一类计算。
这四个 kernel 覆盖了从"几乎纯搬数据"(Add)到"计算密集但有收尾开销"(Dot)的频谱,看它们各自的表现,就能知道 SIMD 在哪种任务上值得用。
Add、Clamp 和 Dot 的代码前面已经出现过,SAXPY 的完整实现如下。标量版本每次处理一个元素:
func SAXPYScalar(dst, x, y []float32, scale float32) {
n := len(dst)
if len(x) < n || len(y) < n {
panic("input slice shorter than destination")
}
for i := 0; i < n; i++ {
dst[i] = x[i]*scale + y[i]
}
}
SIMD 版本先把 scale broadcast 到所有 lane,再用 MulAdd 完成 x*scale+y:
func SAXPYSIMD(dst, x, y []float32, scale float32) {
n := len(dst)
if len(x) < n || len(y) < n {
panic("input slice shorter than destination")
}
var zero simd.Float32s
width := zero.Len()
vscale := simd.BroadcastFloat32s(scale)
i := 0
for ; i+width <= n; i += width {
vx := simd.LoadFloat32s(x[i : i+width])
vy := simd.LoadFloat32s(y[i : i+width])
vx.MulAdd(vscale, vy).Store(dst[i : i+width])
}
for ; i < n; i++ {
dst[i] = x[i]*scale + y[i]
}
}
每个 workload 测 16、256、16384 和 4194304 个 float32。这几个规模大致对应“函数调用开销占主导”“小数组”“缓存内计算”和“大数组”。
测试数据在计时前创建,数值固定但不全相同,避免 Clamp 的分支永远走同一条路:
var benchmarkSizes = []int{16, 256, 16 * 1024, 4 * 1024 * 1024}
func makeTestData(n int) ([]float32, []float32) {
a := make([]float32, n)
b := make([]float32, n)
for i := range n {
a[i] = float32(i%251-125) / 17
b[i] = float32(i%127-63) / 11
}
return a, b
}
以 Add 为例,benchmark 同时跑标量和 SIMD。SetBytes 中的 3*4 表示每个元素读取两个 float32、写入一个 float32,总共搬运 12 字节:
var benchmarkSinkSlice []float32
func BenchmarkAdd(b *testing.B) {
for _, n := range benchmarkSizes {
a, values := makeTestData(n)
dst := make([]float32, n)
b.Run(fmt.Sprintf("N=%d/Scalar", n), func(b *testing.B) {
b.ReportAllocs()
b.SetBytes(int64(n * 3 * 4))
b.ResetTimer()
for range b.N {
AddScalar(dst, a, values)
}
benchmarkSinkSlice = dst
})
b.Run(fmt.Sprintf("N=%d/SIMD", n), func(b *testing.B) {
b.ReportAllocs()
b.SetBytes(int64(n * 3 * 4))
b.ResetTimer()
for range b.N {
AddSIMD(dst, a, values)
}
benchmarkSinkSlice = dst
})
}
}
另外三个 benchmark 使用同样的结构,只替换循环里直接调用的函数。这里没有抽成 func() 参数,是为了避免间接调用影响 16 元素那组数据。
| Benchmark | Scalar 调用 | SIMD 调用 | SetBytes |
|---|---|---|---|
| SAXPY | SAXPYScalar(dst, a, values, 1.75) |
SAXPYSIMD(dst, a, values, 1.75) |
n * 3 * 4 |
| Clamp | ClampScalar(dst, a, -2.5, 3.5) |
ClampSIMD(dst, a, -2.5, 3.5) |
n * 2 * 4 |
| Dot | result = DotScalar(a, values) |
result = DotSIMD(a, values) |
n * 2 * 4 |
Dot 的返回值会写入 package 级 float32 sink,其他三组写入 slice sink,防止编译器把无外部可见结果的计算删除。
两边都使用 Go 1.27.1,benchmark 参数相同:
GOMAXPROCS=1 GOEXPERIMENT=simd \
go test -run '^$' -bench '^Benchmark' \
-benchmem -benchtime=300ms -count=8 -cpu=1
表中数据是 8 次结果的中位数。加速比定义为 Scalar ns/op / SIMD ns/op,大于 1 表示 SIMD 更快。输入和输出 slice 均在计时前创建,所有 kernel 都是 0 B/op、0 allocs/op。
测试机器如下:
| 机器 | 系统 | CPU | SIMD |
|---|---|---|---|
| MacBook Pro | macOS ARM64 | Apple M2 Pro,10 核 | 128-bit Neon,4 个 float32 lane |
| NUC8 | Linux AMD64 | Intel Core i5-8259U,4 核 8 线程 | AVX2/FMA;Go 默认选择 128 bit |
两台机器的绝对时间不能横向比较,重点是同一台机器上标量与 SIMD 的相对变化。
M2 Pro:Neon 128-bit
| Workload | N | Scalar ns/op | SIMD ns/op | 加速比 | GB/s(Scalar→SIMD) |
|---|---|---|---|---|---|
| Add | 16 | 7.29 | 6.96 | 1.05x | 26.32 → 27.57 |
| Add | 256 | 92.31 | 47.80 | 1.93x | 33.28 → 64.27 |
| Add | 16384 | 5311.5 | 3112.5 | 1.71x | 37.02 → 63.18 |
| Add | 4194304 | 1432306 | 810213 | 1.77x | 35.15 → 62.12 |
| SAXPY | 16 | 7.32 | 7.35 | 1.00x | 26.21 → 26.12 |
| SAXPY | 256 | 92.53 | 48.66 | 1.90x | 33.20 → 63.13 |
| SAXPY | 16384 | 5307.5 | 3063.5 | 1.73x | 37.04 → 64.19 |
| SAXPY | 4194304 | 1409838 | 779232 | 1.81x | 35.70 → 64.59 |
| Clamp | 16 | 8.69 | 5.60 | 1.55x | 14.73 → 22.85 |
| Clamp | 256 | 307.0 | 43.30 | 7.09x | 6.67 → 47.31 |
| Clamp | 16384 | 21072 | 2613.0 | 8.06x | 6.22 → 50.17 |
| Clamp | 4194304 | 5412193 | 689143 | 7.85x | 6.20 → 48.69 |
| Dot | 16 | 7.31 | 9.52 | 0.77x | 17.50 → 13.45 |
| Dot | 256 | 239.7 | 59.13 | 4.05x | 8.54 → 34.63 |
| Dot | 16384 | 20568 | 5184.0 | 3.97x | 6.37 → 25.28 |
| Dot | 4194304 | 5281060 | 1319625 | 4.00x | 6.35 → 25.43 |
Add 和 SAXPY 在中大数组上大约快 1.7~1.9 倍,没有达到 4 lane 的理论倍数,因为它们很快就受到 load/store 和内存带宽限制。
Clamp 的结果最显眼。标量版本每个元素都要判断上下界,SIMD 版本则是连续的 Max 和 Min,中大数组稳定在 8 倍左右。
Dot 展示了另一个边界:16 个元素时,SIMD 慢了 23%。初始化向量累加器、dispatch、store lane 和最终水平求和的固定成本,比省下来的乘加还多。到 256 个元素后,它才进入大约 4 倍加速的区间。
NUC8:默认 128-bit SIMD
| Workload | N | Scalar ns/op | SIMD ns/op | 加速比 | GB/s(Scalar→SIMD) |
|---|---|---|---|---|---|
| Add | 16 | 8.43 | 8.11 | 1.04x | 22.79 → 23.66 |
| Add | 256 | 108.4 | 63.67 | 1.70x | 28.34 → 48.25 |
| Add | 16384 | 18762 | 3898.0 | 4.81x | 10.48 → 50.43 |
| Add | 4194304 | 5762780 | 1708170 | 3.37x | 8.73 → 29.47 |
| SAXPY | 16 | 11.75 | 8.84 | 1.33x | 16.34 → 21.72 |
| SAXPY | 256 | 144.7 | 63.75 | 2.27x | 21.23 → 48.19 |
| SAXPY | 16384 | 39384 | 3859.0 | 10.21x | 4.99 → 50.95 |
| SAXPY | 4194304 | 10185086 | 1692378 | 6.02x | 4.94 → 29.74 |
| Clamp | 16 | 12.80 | 7.90 | 1.62x | 10.00 → 16.21 |
| Clamp | 256 | 252.0 | 57.88 | 4.35x | 8.13 → 35.39 |
| Clamp | 16384 | 17292 | 3442.0 | 5.02x | 7.58 → 38.08 |
| Clamp | 4194304 | 4984947 | 1361095 | 3.66x | 6.73 → 24.65 |
| Dot | 16 | 9.35 | 10.30 | 0.91x | 13.69 → 12.42 |
| Dot | 256 | 248.8 | 73.50 | 3.38x | 8.23 → 27.86 |
| Dot | 16384 | 21602 | 6823.0 | 3.17x | 6.07 → 19.21 |
| Dot | 4194304 | 5861174 | 2168584 | 2.70x | 5.72 → 15.47 |
规律和 M2 Pro 相似:16 个元素时收益很少,Dot 仍然倒退;规模变大后,四类计算都有明显加速。
加速比并不等于 lane 数。比如 128-bit SIMD 一次处理 4 个 float32,但 16384 元素的 SAXPY 达到了 10.21 倍。这还混合了循环控制、标量依赖链、FMA 指令吞吐以及缓存等因素,不能简单理解成“四个数一起算,所以固定快四倍”。
为什么 M2 Pro 的 Add 和 SAXPY 加速比更小
先看 4194304 元素的结果:
| Workload | M2 Pro 加速比 | NUC8 加速比 | M2 Pro SIMD GB/s | NUC8 SIMD GB/s |
|---|---|---|---|---|
| Add | 1.77x | 3.37x | 62.12 | 29.47 |
| SAXPY | 1.81x | 6.02x | 64.59 | 29.74 |
NUC8 的倍率更大,但 SIMD 后的绝对吞吐只有 M2 Pro 的一半左右。原因是加速比等于 Scalar 耗时 / SIMD 耗时:标量基线越慢,倍率越容易显得大。M2 Pro 的标量 Add 已经达到 35.15 GB/s,而 NUC8 只有 8.73 GB/s。M2 Pro 的起点更高,留给 SIMD 的相对提升空间自然更小。
这也不是向量宽度造成的。两台机器在 Go 默认策略下都使用 128-bit SIMD,也就是一次处理 4 个 float32。NUC8 虽然支持 AVX2,但可移植 simd 包因为完整指令集检查把默认宽度降到了 128 bit。因此这组默认测试是 Neon 4 lane 对 XMM 4 lane,不是 4 lane 对 8 lane。
SAXPY 还有一层编译器差异。我把相同的标量循环分别编译并查看汇编,M2 Pro 的 ARM64 后端生成了一条融合乘加:
FMADDS // scale*x + y
AMD64 标量版本则是两条指令:
MULSS // x * scale
ADDSS // + y
而 SIMD 版本显式调用 MulAdd,AMD64 会生成 VFMADD213PS。也就是说,M2 Pro 的标量基线已经享受了 FMA,SIMD 主要增加 lane 数;NUC8 则同时获得了向量化和融合乘加,这会进一步放大 SAXPY 的相对倍率。
NUC8 较老确实是背景因素,但不是一个可以单独成立的解释。它使用的 i5-8259U 发布于 2018 年,官方最大内存带宽为 37.5 GB/s;M2 Pro 是 2023 年平台,官方统一内存带宽为 200 GB/s(参见 Intel 规格 和 Apple 规格)。较老的微架构、移动平台功耗限制和内存系统会让 NUC8 的标量基线更弱,但这同时也会让“从较慢起点出发”的加速比变大。
另一个证据是 NUC8 的 256-bit 附加测试。16384 元素时,加宽向量还能继续提速;到了 4194304 元素,Add 和 SAXPY 从 128 bit 改成 256 bit 后没有更快。这说明大数组已经更接近 load/store 或内存带宽瓶颈,增加算术 lane 并不能等比例提高吞吐。
所以更准确的结论是:M2 Pro 的 Add 和 SAXPY 相对加速较小,主要因为标量基线已经很强、标量 SAXPY 已经使用 FMA,并且数据搬运更早成为瓶颈;NUC8 的平台年代会影响这些条件,却不能简单解释成“老 CPU 的 SIMD 更有效”。而且这个趋势只出现在 Add 和 SAXPY,大数组 Clamp、Dot 在 M2 Pro 上的相对加速反而更高。
明明支持 AVX2,为什么默认只有 128 bit
NUC8 的 CPU flags 包含 AVX2 和 FMA,硬件可以使用 256-bit YMM 寄存器。但 simd.VectorBitSize() 返回了 128。
原因藏在 Go 1.27 的 midway_amd64.go 中。可移植 simd API 希望同一向量宽度下的整套操作都能由硬件实现。这个 CPU 没有 VPCLMULQDQ,所以即便普通浮点加法支持 AVX2,Go 仍然保守地把全局宽度降到 128 bit。
GODEBUG=simd=256 会直接 panic,并提示可以用 GODEBUG=simd=+256 跳过特性检查。本文的四个 kernel 只使用 Add、FMA、Min 和 Max,这些指令都在该 CPU 的能力范围内,所以我额外跑了一组强制 256-bit 测试。
| Workload | 16384:128→256 ns/op | 256-bit 改善 | 4194304:128→256 ns/op | 256-bit 改善 |
|---|---|---|---|---|
| Add | 3898.0 → 2471.0 | 1.58x | 1708170 → 1754121 | 0.97x |
| SAXPY | 3859.0 → 2576.5 | 1.50x | 1692378 → 1833758 | 0.92x |
| Clamp | 3442.0 → 2136.5 | 1.61x | 1361095 → 1221690 | 1.11x |
| Dot | 6823.0 → 2593.5 | 2.63x | 2168584 → 1197430 | 1.81x |
16384 元素时,加宽向量通常还能带来 1.5 倍以上提升。到了 4194304 元素,Add 和 SAXPY 已经受内存带宽限制,YMM 再宽也没有数据喂进来,甚至因为测试波动略慢。Dot 的计算密度更高,256 bit 仍然有 1.81 倍收益。
这里的 + 是在告诉运行时“我知道自己在做什么”。如果代码后来调用了 CPU 缺失的 SIMD 操作,就可能出问题,不能把它当成通用启动参数。
软件模拟不是性能 fallback
设置 GODEBUG=simd=0 后,同一份 SIMD 代码会切换到 128-bit 软件模拟。它很适合验证 fallback 的正确性,但不适合追求性能。
下面取 16384 元素的数据,加速比仍然是“标量耗时除以 SIMD API 耗时”:
| 机器 | Add | SAXPY | Clamp | Dot |
|---|---|---|---|---|
| M2 Pro,软件模拟 | 0.07x | 0.07x | 0.18x | 0.43x |
| i5-8259U,软件模拟 | 0.21x | 0.38x | 0.10x | 0.26x |
换句话说,软件模拟比直接写标量循环慢了约 2.3~14 倍。它解决的是“所有架构都能运行”这个问题,而不是“所有架构都能更快”。如果应用真的会运行在无硬件 SIMD 的平台上,最好保留经过 benchmark 的标量实现,并根据 simd.Emulated() 选择路径。
看一眼机器码
Go 1.27 会为 SIMD-dependent 函数生成 dispatch 入口和不同宽度的 clone。用 go tool nm 可以看到类似名称:
AddSIMD
AddSIMD@simd0
AddSIMD@simd128
AddSIMD@simd256
M2 Pro 的 128-bit clone 中出现了 Neon 指令:
FADD V1.S4, V0.S4, V0.S4
VFMLA V1.S4, V2.S4, V3.S4
FMAX V2.S4, V4.S4, V4.S4
FMIN V3.S4, V4.S4, V4.S4
AMD64 的 128-bit clone 使用 XMM,256-bit clone 使用 YMM:
VADDPS 0(R11), X0, X0
VFMADD213PS 0(R11), X1, X2
VADDPS 0(R11), Y0, Y0
VFMADD213PS 0(R11), Y1, Y2
这一步很重要。benchmark 告诉我们程序快了多少,反汇编则确认快的确实是我们以为的那条路径。
浮点数和测试
Dot 和 SAXPY 会改变浮点运算的组合方式,还可能使用 FMA。它们与标量实现数学上等价,但不保证 bitwise equal。因此正确性测试使用相对误差,而不是直接比较 float32 的 bit pattern。
测试长度包含 0、1、3、4、5、7、8、9、15、16、17、31、32、33、255、256、257 和 4097,主要用来覆盖不同向量宽度下的 tail。完整测试命令是:
GOEXPERIMENT=simd go test ./...
总结
这轮实验可以归纳成几条 Tips:
- Go 1.27 的
simd已经能生成真正的 Neon、XMM 和 YMM 指令,同一份代码可以跨架构运行。 - 短任务不一定更快。16 元素 Dot 在两台机器上都倒退了,先 benchmark 再决定是否向量化。
- 精确写出
slice[i:i+width]很重要,它能帮助编译器消除 SIMD 热循环里的 bounds check。 - 分支和长依赖链通常比简单的内存拷贝型计算更能从 SIMD 获益,但最终仍会撞上内存带宽。
simd.VectorBitSize()返回的是 Go 为整套可移植 API 选择的宽度,不一定等于 CPU 的最大寄存器宽度。GODEBUG=simd=0是兼容性 fallback,不是性能 fallback。- API 仍是实验性的。现阶段适合封装在内部包里,不要把 SIMD 类型暴露到公共接口。
SIMD 的代码量并不多,真正费时间的是定义基线、控制变量、验证生成代码。好在 Go 1.27 已经把最难跨过去的那道门槛挪走了,剩下的就是老老实实测量。