lyyyuna 的小花园

动静中之动, by

RSS

Go 1.27 SIMD 初探

发表于 2026-09
用 Go 1.27 实验性的 simd 包写四种向量计算,并在 ARM64 Neon 与 AMD64 AVX2 上对比标量版本。

前言

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 最喜欢连续内存、相同操作、元素之间互不依赖。下面这些情况会削弱它的收益:

Go 1.27 的两层 SIMD API

Go 1.27 的 SIMD API 分成两层:

定位 特点
simd 可移植 API 向量宽度未知,同一份代码可运行在 ARM64、AMD64、Wasm 或软件模拟上
simd/archsimd 平台专属 API 明确使用 Float32x4Float32x8 等固定宽度类型,接近硬件 intrinsic

本文使用第一层。simd.Float32s 表示“一组 float32”,但组里有几个元素并不写死。程序可以用 simd.VectorBitSize() 查询当前向量宽度,也可以用 Float32s.Len() 得到 lane 数。

它仍然是实验功能,需要显式开启:

GOEXPERIMENT=simd go test ./...

实验性也意味着它不受 Go 1 兼容承诺保护。用来学习和验证很合适,生产代码还得给 API 变化留出空间。详细设计可以看 Go 1.27 Release Notessimd 包文档

一份源码,多个机器码版本

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 通常更直观。

这里有个容易忽略的细节:传给 LoadFloat32sStore 的是 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 < lowif 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)
    }
}

更复杂的条件可以先用 GreaterLessEqual 等方法生成 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/op0 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 版本则是连续的 MaxMin,中大数组稳定在 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:

  1. Go 1.27 的 simd 已经能生成真正的 Neon、XMM 和 YMM 指令,同一份代码可以跨架构运行。
  2. 短任务不一定更快。16 元素 Dot 在两台机器上都倒退了,先 benchmark 再决定是否向量化。
  3. 精确写出 slice[i:i+width] 很重要,它能帮助编译器消除 SIMD 热循环里的 bounds check。
  4. 分支和长依赖链通常比简单的内存拷贝型计算更能从 SIMD 获益,但最终仍会撞上内存带宽。
  5. simd.VectorBitSize() 返回的是 Go 为整套可移植 API 选择的宽度,不一定等于 CPU 的最大寄存器宽度。
  6. GODEBUG=simd=0 是兼容性 fallback,不是性能 fallback。
  7. API 仍是实验性的。现阶段适合封装在内部包里,不要把 SIMD 类型暴露到公共接口。

SIMD 的代码量并不多,真正费时间的是定义基线、控制变量、验证生成代码。好在 Go 1.27 已经把最难跨过去的那道门槛挪走了,剩下的就是老老实实测量。

lyyyuna 沪ICP备2025110782号-1