Go 中平台无关的 SIMD

Go 1.27 推出可移植 simd 包,用 simd.Uint8s 等类型隐藏 AVX512、NEON 与 wasm 的向量差异。

中文
复制

David Chase 和 Junyang Shao 2026 年 9 月 24 日 Go 1.26 和 1.27 引入了用于单指令多数据(SIMD)操作的实验性 API。SIMD 是许多现代 CPU 的原生特性,让软件能够以极快的速度对数据向量执行统一操作,例如在一条指令中完成 8 对 float64 值的相加。它可以显著加速许多计算密集型任务,从密码学到数据处理再到 AI。事实上,Go 的 Green Tea 垃圾回收器 就利用 SIMD 来加速扫描内存中的存活对象。 在这些新的实验性 API 出现之前,从 Go 访问这一功能的唯一途径是编写 Go 汇编。只有对性能真正关键的计算内核才值得这么做,这意味着许多本可受益于 SIMD 的软件白白闲置了大量 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”代码,并在(尚)不支持 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 变体:SVE、SVE2 还是 SVE2.1? 不同的 SIMD 架构在处理向量掩码的方式上也各不相同。对于向量,跨向量的 if-then-else 可以用掩码实现:照常执行操作,但只在掩码为“真”的位置赋值(或加载、或存储)结果。有些 SIMD 变体不提供掩码;所有操作都作用于全部元素,“掩码”通过向量位掩码和向量布尔运算完成(wasm、AVX、AVX2、NEON)。有些提供专用的掩码寄存器,用一位控制对一个向量元素的操作(AVX512 和 RVV)。还有一些(SVE)为向量的每个字节分配一位,但由每个元素掩码位的最低有效位决定是否执行掩码操作。AVX2 也支持带掩码的加载和存储,但使用普通向量作为掩码,并由最高有效位决定操作是否执行。

变化的第三个来源在于操作本身。每种架构都提供了各自的一套重排向量元素的原语;有些要求输入为常量,另一些则支持变量输入。不同的 SIMD 架构所支持的加密相关操作各不相同。即便是基本算术运算,支持程度也可能存在差异;例如 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 returns the inner product of x and y.
func innerProduct(x, y []float32) float32 {
 var a simd.Float32s
 var i int
 for i = 0; i < len(x)-a.Len()+1; i += a.Len() {
 u := simd.LoadFloat32s(x[i : i+a.Len()])
 v := simd.LoadFloat32s(y[i : i+a.Len()])
 a = u.MulAdd(v, a)
 }
 if i < len(x) {
 u, _ := simd.LoadFloat32sPart(x[i:])
 v, _ := simd.LoadFloat32sPart(y[i:])
 a = u.MulAdd(v, a)
 }
 return sum(a)
}
// sum returns scalar sum of elements of x.
func sum(x simd.Float32s) float32 {
 s := make([]float32, x.Len())
 x.Store(s)
 var r float32
 for _, e := range s {
 r += e
 }
 return r
}

这个例子也展示了该包首个实验性版本的一个局限:由于没有一种通用的方式对向量中所有元素求和,Go 1.27 中的 simd 并不支持这一操作,不过 ReduceSum 会在下一个版本中出现,届时 sum 就可以直接替换为 simd.ReduceSum。

SIMD 比较会产生掩码值,掩码值对应于相应的向量元素宽度,因此对 Int8s 的比较会产生 Mask8s,依此类推;掩码值可用于选择和过滤向量。

截至 Go 1.27 支持的 simd 包操作

下表中,V 和 U 是向量类型,M 是掩码类型,E 是标量类型,W 是宽度。

包级加载 / 广播函数

函数Int8sInt16sInt32sInt64sUint8sUint16sUint32sUint64sFloat32sFloat64s
LoadV([]E) VYYYYYYYYYY
LoadVPart([]E) (V, int)YYYYYYYYYY
BroadcastV(E) VYYYYYYYYYY

存储 / 字符串操作

(x V).Method(...)Int8sInt16sInt32sInt64sUint8sUint16sUint32sUint64sFloat32sFloat64s
Store(s []E)YYYYYYYYYY
StorePart(s []E) intYYYYYYYYYY
String() stringYYYYYYYYYY

算术操作

(x V).Method(...) VInt8sInt16sInt32sInt64sUint8sUint16sUint32sUint64sFloat32sFloat64s
Abs() VYYYYY
Add(y V) VYYYYYYYYYY
AddSaturated(y V) VYYYY
Average(y V) VYY
Div(y V) VYY
IfElse(mask MaskWs, y V) VYYYYYYYYYY
Len() intYYYYYYYYYY
Masked(mask MaskWs) VYYYYYYYYYY
Max(y V) VYYYYYYYY
Min(y V) VYYYYYYYY
Mul(y V) VYYYYYYYY
MulAdd(y V, z V) VYY
Neg() VYYYYYY
Not() VYYYYYYYY
Or(y V) VYYYYYYYY
Sqrt() VYY
Sub(y V) VYYYYYYYYYY
SubSaturated(y V) VYYYY
Xor(y V) VYYYYYYYY

布尔与向量掩码操作

(x V).Method(...) VInt8sInt16sInt32sInt64sUint8sUint16sUint32sUint64sFloat32sFloat64s
And(y V) VYYYYYYYY
AndNot(y V) VYYYYYYYY
CarrylessMultiplyEven(y V) VY
CarrylessMultiplyOdd(y V) VY
IfElse(mask MaskWs, y V) VYYYYYYYYYY
Masked(mask MaskWs) VYYYYYYYYYY
Not() VYYYYYYYY
Or(y V) VYYYYYYYY
Xor(y V) VYYYYYYYY

比较操作

(x V).Method(...) MInt8sInt16sInt32sInt64sUint8sUint16sUint32sUint64sFloat32sFloat64s
Equal(y V) MaskWsYYYYYYYYYY
Greater(y V) MaskWsYYYYYYYYY
GreaterEqual(y V) MaskWsYYYYYYYYY
Less(y V) MaskWsYYYYYYYYY
LessEqual(y V) MaskWsYYYYYYYYY
NotEqual(y V) MaskWsYYYYYYYYYY

转换操作

(x V).Method(...) UInt8sInt16sInt32sInt64sUint8sUint16sUint32sUint64sFloat32sFloat64s
ConvertToFloatW() FloatWsY
ConvertToIntW() IntWsYYYYY
ConvertToUintW() UintWsYYYY
ToMask() (to MaskWs)YYYY

掩码方法

(m M).Method(...) M)Mask8sMask16sMask32sMask64s
And(y M) MYYYY
Or(y V) VYYYY
String() stringYYYY
ToIntWs() (to IntWs)YYYY

移位与循环移位操作

(x V).Method() VInt8sInt16sInt32sInt64sUint8sUint16sUint32sUint64sFloat32sFloat64s
RotateAllLeft(dist uint64) VYYYYYY
RotateAllRight(dist uint64) VYYYYYY
ShiftAllLeft(dist uint64) VYYYYYY
ShiftAllRight(dist uint64) VYYYYY

零开销重塑操作

(x V).Method(...) UInt8sInt16sInt32sInt64sUint8sUint16sUint32sUint64sFloat32sFloat64s
ToBits() UintWsYYYYYY
ReshapeToUint8s() Uint8sYYY
ReshapeToUint16s() Uint16sYYY
ReshapeToUint32s() Uint32sYYY
ReshapeToUint64s() Uint64sYYY
BitsToFloatW() FloatWsYY
BitsToIntW() IntWsYYYY

与平台特定代码之间的转换

有时会出现这样的情况:simd 包对某个应用的所有部分来说都过于受限,或者我们尚未为某项必需功能提供足够的模拟。为此,simd 包支持与架构特定 SIMD 之间的相互转换。simd 包中的每个向量类型都有一个转换方法 ToArch(),返回一个 any。这个 any 可以通过类型断言转换为某个平台特定的架构类型。要转换回来,使用 simd.FromArch 函数之一。对于可移植代码来说,这就带来了一项义务:必须为每个平台编写架构特定的代码,包括模拟实现。

下面是一个完整示例,针对一个当前缺失、但应当在 Go 1.28 中加入的方法/函数。假设你的算法需要 Int8s.OnesCount()(而 Go 1.27 中的 simd 缺少它)。与其为每个平台重写整个算法,不如只实现缺失的那个操作。

首先看 amd64,它缺少 AVX 和 AVX2 的指令,但不缺 AVX512 的:

//go:build goexperiment.simd && amd64
package simd_test
import (
 "simd"
 "simd/archsimd"
)
var popcnt4x16 = [16]int8{0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4}
var popcnt4x32 = [32]int8{
 0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4,
 0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4,
}
// OnesCount returns the number of one bits for each element.
func OnesCount(v simd.Int8s) simd.Int8s {
 switch x := v.ToArch().(type) {
 case archsimd.Int8x16:
 lut := archsimd.LoadInt8x16Array(&popcnt4x16)
 mask0f := archsimd.BroadcastInt8x16(0x0f)
 lo := x.And(mask0f)
 hi := x.ToBits().ReshapeToUint16s().ShiftAllRight(4).
 ReshapeToUint8s().BitsToInt8().And(mask0f)
 return simd.Int8sFromArch(lut.PermuteOrZero(lo).
 Add(lut.PermuteOrZero(hi)))
 case archsimd.Int8x32:
 lut := archsimd.LoadInt8x32Array(&popcnt4x32)
 mask0f := archsimd.BroadcastInt8x32(0x0f)
 lo := x.And(mask0f)
 hi := x.ToBits().ReshapeToUint16s().ShiftAllRight(4).
 ReshapeToUint8s().BitsToInt8().And(mask0f)
 return simd.Int8sFromArch(lut.PermuteOrZeroGrouped(lo).
 Add(lut.PermuteOrZeroGrouped(hi)))
 case archsimd.Int8x64:
 return simd.Int8sFromArch(x.OnesCount())
 default:
 // GODEBUG=simd=0 emulation
 return OnesCountEmulated(v)
 }
}

接口转换和类型 switch 看起来似乎很低效,但 simd 在编译器端的实现会对代码做特化,并把类型 switch 优化掉。

NEON 和 Wasm 都支持 Int8s.OnesCount(),所以它们的实现要简单得多,尽管仍然用到了 Int8s.ToArch 和 Int8sFromArch。

//go:build goexperiment.simd && (wasm || arm64)
package simd_test
import (
 "simd"
 "simd/archsimd"
)
// OnesCount returns the number of one bits for each element.
func OnesCount(v simd.Int8s) simd.Int8s {
 // TODO when SVE is added, this won't work
 switch x := v.ToArch().(type) {
 case archsimd.Int8x16:
 return simd.Int8sFromArch(x.OnesCount())
 default:
 // GODEBUG=simd=0 emulation
 return OnesCountEmulated(v)
 }
}

别忘了,有些人并没有硬件 SIMD 支持:

//go:build goexperiment.simd && !(amd64 || wasm || arm64)

package simd_test
import (
 "simd"
)
// OnesCount returns the number of one bits for each element.
func OnesCount(v simd.Int8s) simd.Int8s {
 return OnesCountEmulated(v)
}

最后,为了完成这个练习,再写一个独立的模拟函数,作为所有实现共享的回退方案:

//go:build goexperiment.simd
package simd_test
import (
 "simd"
)
// OnesCountEmulated returns the number of one bits for each element.
func OnesCountEmulated(v simd.Int8s) simd.Int8s {
 a := [2]uint64{}
 v.ToBits().ReshapeToUint64s().Store(a[:])
 a0, a1 := a[0], a[1]
 m1 := uint64(0x5555555555555555)
 m2 := uint64(0x3333333333333333)
 m4 := uint64(0x0f0f0f0f0f0f0f0f)
 a0 = (a0 & m1) + ((a0 >> 1) & m1)
 a1 = (a1 & m1) + ((a1 >> 1) & m1)
 a0 = (a0 & m2) + ((a0 >> 2) & m2)
 a1 = (a1 & m2) + ((a1 >> 2) & m2)
 a0 = (a0 & m4) + ((a0 >> 4) & m4)
 a1 = (a1 & m4) + ((a1 >> 4) & m4)
 a[0], a[1] = a0, a1
 return simd.LoadUint64s(a[:]).ReshapeToUint8s().BitsToInt8()
}

API 交集与方法模拟

simd 包提供的任何操作,都需要在大多数架构上以可接受的性能运行。第一步是:任何在所有平台上都受支持的操作,都可以轻松地在 simd 上得到支持。这类操作通常包括加载、存储、算术和比较(但不是所有比较!)。

对不同架构的 SIMD 方法做一次朴素交集,仍然会留下不少空缺。 这些空缺通过为各架构特定的 archsimd API 添加模拟来填补。 这些 API 中已经包含了许多简单的模拟,用来减轻 Go 程序员的负担; 有符号和无符号整数加法使用同一条指令, 但正如 Go 同时为 int 和 uint 支持 + 运算符一样, archsimd 包同时提供 Int8x16.Add(Int8x16) 和 Uint8x16.Add(Uint8x16), 尽管它们编译后是同一条指令。 现代编程语言也不指望程序员懂得如何用位操作实现浮点取负和绝对值, 所以 archsimd 在必要的地方实现了这一点——或者,看你从哪个角度理解,叫它“模拟”也行。

有些模拟只需要两三条指令。例如,某些架构只支持所有向量元素使用相同的移位距离,另一些则允许每个向量元素有不同的移位距离。为了在 simd 中支持标量移位,我们用向量移位来模拟标量移位。有些架构缺少某些无符号比较——这些不过是有符号比较再加上两次与常量的 XOR。

并非所有缺失的指令都这么简单。“无进位乘法”(carryless multiply)指令对密码学和 CRC 校验很重要,但并非总是得到支持。把它排除在 simd API 之外,会让一些重要算法无法使用。因此我们提供了模拟实现;由于它的一个重要用途是密码学,其运行时间不随输入变化。

另一些情况下,我们不实现“成对相加”(也叫“水平加法”)这类原语指令,而是在下一个版本中为 simd 包提供成对相加通常所服务的高层操作,即求和归约。这也有助于让用户免受向量长度依赖的影响;即便硬件提供了成对相加指令,归约的步数仍取决于向量长度。

支持所有平台这一约束——包括我们预计未来一年左右会出现在 archsimd 中的平台——迫使我们在向 simd 添加哪些方法上采取偏保守的做法。Riscv64、ppc64、s390x 和 loong64 都有各自的 SIMD 扩展。

GODEBUG 设置

在有部分硬件支持的平台上,可以用 GODEBUG 修改行为,以便更容易地在各种硬件配置下测试使用 simd 的代码。你可以在运行程序之前设置 GODEBUG 环境变量。

在 Go 1.27 中,SIMD 支持级别大致按向量长度划分:

  • GODEBUG=simd=0 表示即使硬件支持可用,SIMD 操作也使用模拟。
  • GODEBUG=simd=128 表示使用 128 位向量及其特性。如果这些特性不可用,立即 panic。
  • GODEBUG=simd=256 表示尽可能使用 256 位向量及其特性。
  • GODEBUG=simd=512 表示尽可能使用 512 位向量及其特性。
  • GODEBUG=simd=+128 表示即使部分特性不受支持,也使用 128 位向量及其特性。如果用到不受支持的指令,代码会 panic;但如果没用到,仍可能正常运行。Raspberry Pi 就是一个例子:它支持 NEON,但缺少 PMULL(无进位乘法)。
  • GODEBUG=simd=+256 表示即使部分特性不受支持,也使用 256 位向量及其特性。如果用到不受支持的指令,代码会 panic;但如果没用到,仍可能正常运行。Apple Silicon 的 amd64 模拟就是一个例子:它支持 AVX2,但不支持 VPCLMULQDQ(同样是无进位乘法)。
  • GODEBUG=simd=+512 表示即使部分特性不受支持,也使用 512 位向量。

实现细节

如果你在调试使用 simd 的代码,哪怕只是看一眼堆栈跟踪,都会注意到一些奇怪的额外类型和方法。原因在于,simd 既是一个包,又是一个内部实现包,同时还是编译器前端的一部分 AST 重写。

AST 重写会为那些提及 simd 类型的函数、变量和类型生成多份特化副本,其中的 simd 类型被替换为对 simd/internal/bridge 中按大小特化的类型的引用。每个桥接类型都定义为 archsimd 类型,但方法集受限。特化后的函数、变量和类型会带上形如 @simdNNN 的后缀,其中 NNN 要么是向量长度(128、256 或 512),要么是 0,表示模拟实现。函数如果在内部提及 simd,但签名中没有,就会被转换为包装函数:根据程序启动时检测到的 SIMD 级别进行分支,调用相应的特化版本。特化函数直接调用其他特化函数,没有分派开销(还可能被内联)。选择这种重写策略,是在代码重复和 SIMD 性能之间做的折中;开销被提升到必要的高度,以避免在 SIMD 计算内部出现分派,但不会更高。如果 SIMD 分派在某个计算中显得“位置太低”,只要无谓地提及一个 simd 类型就能把它往上提,如下例所示:

func BenchmarkVpsumdSIMD(b *testing.B) {
 // mention "simd" so the benchmark loop calls specialized vpsumd3 directly
 var _ simd.Uint64s
 var w, x, y, z uint64 = ... // magic constants omitted.
 var lo, hi uint64
 for b.Loop() {
 // vpsumd3 does simd stuff, but lacks a simd signature,
 // so that it can be compared with non-SIMD emulations.
 lo, hi = vpsumd3(w, x, y, z)
 }
 sinkLo, sinkHi = lo, hi
}

后续计划

我们计划很快发布一篇博客文章,更详细地介绍 archsimd。

对于 Go 1.28,我们打算为 archsimd 添加 SVE 支持,也希望把这一支持加到 simd 中。更重要的是,我们希望在 simd 包已支持的操作之外加入更多 SIMD 操作(例如 OnesCount、掩码操作、归约操作、向量重排操作)。Go 1.28 还会包含少量“特性变体”,以免让那些有硬件向量实现、只是缺少一两个操作的平台(比如 Raspberry Pi)一路降级到完全模拟。

上一篇:按大小特化的内存分配

博客索引

来源: The Go Blog← 返回首页