Go 1.27 引入了实验性的平台无关 SIMD 包
TL;DR
Go 1.27 发布了一个实验性的、平台无关的 simd 包,它抽象了固定大小的向量类型,实现了“一次编写,到处运行”的向量代码。该代码在 AVX/AVX2/AVX512 (amd64)、NEON (arm64) 和 WebAssembly 上可达到接近汇编的性能,同时在不支持 SIMD 的 CPU 上会自动回退到快速的软件模拟实现。
为什么需要一个可移植的 SIMD 层?
不同的 CPU 系列以截然不同的方式暴露 SIMD 功能:
- 向量宽度可能是固定的(例如 wasm、PowerPC、s390x 上的 128 位)或可变的(RISC-V V 扩展、ARM SVE)。
- 掩码(Masking)语义各不相同:一些架构有专用的掩码寄存器(AVX-512、RVV),另一些使用向量布尔值(NEON、AVX2),还有一些完全没有掩码。
- 指令集不仅因系列而异,还因功能标志而异(AVX 与 AVX2 与 AVX-512,NEON 与 SVE 等)。
由于 Go 之前的 archsimd 包必须直接暴露这些差异,编写可移植的 SIMD 代码需要针对每个架构编写条件判断和手写汇编。新的 simd 包从类型系统中移除了固定大小的向量,仅实现了所有支持平台共有的操作交集,并通过高效的模拟来填补空白。
如何启用实验性 API
在构建或运行程序时设置环境变量 GOEXPERIMENT=simd,就像使用旧的 archsimd 实验一样。编译器会将对 simd 类型的调用重写为特定架构的实现,或者根据程序启动时检测到的硬件回退到模拟实现。
核心设计原则
- 交集优先的 API – 仅暴露所有目标平台共有的操作。缺失的原语通过映射到现有 SIMD 指令的模拟来提供。
- 接近汇编的性能 – 当源操作与硬件能力匹配时,生成的代码与手写汇编一样快。
- 优雅的回退 – 在没有 SIMD 支持的 CPU 上(或设置了
GODEBUG=simd=0时),相同的代码将使用纯 Go 实现运行。 - 可读性 – 类型命名为
simd.Uint8s、simd.Float32s等,方法读起来就像普通的 Go 方法,使代码对人类和大型语言模型都易于理解。
示例:向量化内积
func innerProduct(x, y []float32) float32 {
var acc simd.Float32s
var i int
for i = 0; i < len(x)-acc.Len()+1; i += acc.Len() {
u := simd.LoadFloat32s(x[i : i+acc.Len()])
v := simd.LoadFloat32s(y[i : i+acc.Len()])
acc = u.MulAdd(v, acc) // acc += u*v
}
if i < len(x) {
u, _ := simd.LoadFloat32sPart(x[i:])
v, _ := simd.LoadFloat32sPart(y[i:])
acc = u.MulAdd(v, acc)
}
return simd.ReduceSum(acc) // added in Go 1.28
}
该循环每次迭代处理 acc.Len() 个元素,其中 acc.Len() 是运行时检测到的向量宽度(128、256 或 512 位)。相同的源代码在现代 x86 上编译为 AVX-512,在 Apple Silicon 上编译为 NEON,或者在 SIMD 不可用时编译为纯 Go 循环。
支持的操作 (Go 1.27)
下表总结了当前可用的方法。V 表示向量类型,M 表示掩码类型,E 表示标量元素类型。
加载 / 广播
LoadV([]E) V– 将完整切片加载到向量中。LoadVPart([]E) (V, int)– 加载部分切片,返回实际加载的元素数量。BroadcastV(E) V– 将标量广播到所有通道。
存储 / 字符串
Store([]E)– 将向量写回切片。StorePart([]E) int– 存储尽可能多的通道。String() string– 人类可读的表示形式。
算术(精选)
Add,Sub,Mul,Div(仅限浮点数),Neg,Abs(仅限浮点数)。MulAdd– 浮点数的乘加运算。IfElse(mask, y)– 当mask为真时从y中选择元素。Masked(mask)– 将掩码为假的通道清零。
布尔与掩码
- 向量上的
And,Or,Xor,AndNot。 Mask类型 (Mask8s,Mask16s, …) 支持And,Or,String,ToIntWs。
比较
Equal,NotEqual,Greater,GreaterEqual,Less,LessEqual– 生成相同元素宽度的掩码。
转换与重塑
ConvertToFloatW,ConvertToIntW,ConvertToUintW。ToBits,ReshapeToUint*,BitsToFloatW,BitsToIntW– 零成本重解释。
移位与旋转(仅限整数向量)
- 针对 16、32 和 64 位通道的
ShiftAllLeft/Right,RotateAllLeft/Right。
注意: 第一个版本缺少通用归约 (
ReduceSum) 和一些以掩码为中心的功能;这些计划在 Go 1.28 中加入。
桥接到特定架构的代码
当所需的某些操作尚未包含在可移植 API 中时,开发者可以通过 ToArch() 和 FromArch 辅助函数回退到 archsimd 包。以下是 amd64 上缺失 Int8s.OnesCount 实现的示例:
//go:build goexperiment.simd && amd64
func OnesCount(v simd.Int8s) simd.Int8s {
switch x := v.ToArch().(type) {
case archsimd.Int8x16:
// Use a lookup-table trick for AVX/AVX2.
lut := archsimd.LoadInt8x16Array(&popcnt4x16)
mask := archsimd.BroadcastInt8x16(0x0f)
lo := x.And(mask)
hi := x.ToBits().ReshapeToUint16s().ShiftAllRight(4).
ReshapeToUint8s().BitsToInt8().And(mask)
return simd.Int8sFromArch(lut.PermuteOrZero(lo).Add(lut.PermuteOrZero(hi)))
default:
return OnesCountEmulated(v)
}
}
编译器会将类型 switch 专门化,因为二进制文件在编译时就知道目标平台存在哪些 archsimd 类型。
使用 GODEBUG 进行运行时控制
开发者可以强制指定向量宽度或进行全模拟以进行测试:
GODEBUG=simd=0– 始终使用模拟路径。GODEBUG=simd=128,256,512– 要求该宽度;如果不可用则 panic。GODEBUG=simd=+128(或+256,+512) – 即使缺少某些可选指令也允许该宽度;使用不支持的指令将在运行时 panic。
实现说明
编译器执行 AST 重写,为每个提到 simd 类型的函数生成特定大小的副本。这些副本位于 simd/internal/bridge 中,并被实例化为具体的 archsimd 类型(例如 Int8x16)。调度在程序启动时发生一次,之后热循环执行专门的版本,无需进一步的分支判断。添加对 simd 类型的虚拟引用(例如 var _ simd.Uint64s)可以将调度点提升到外层循环,确保使用专门的版本。
社区反应(精选 HN 评论)
"在这种情况下,可移植 SIMD 比非可移植 SIMD 慢约 11%,但两者都比非 SIMD 快约 5 倍。" – ImJasonH (wasm 上的基准测试)
"C++ 正在引入
std::simd;我非常支持用最少的内在函数编写向量代码。" – beached_whale
"没人要求这样做,但他们花时间把它做对了,并继续推动 Go 成为一种内存安全的高级系统语言。" – u8
"这为已经在运行多核的 Go 项目的底层性能打开了许多大门。很少有语言拥有标准库 SIMD 支持。" – qprofyeh
"接口转换和类型 switch 看起来效率低下,但编译器将它们专门化了;switch 在启动时解析,而不是在每次迭代时解析。" – vlovich123
下一步计划
- Go 1.28 将向
archsimd添加 SVE 支持,并最终添加到可移植的simd包中。 - 计划加入新的操作,如
OnesCount、更丰富的掩码工具、归约原语和向量洗牌。 - 功能变体标志将允许代码在仅缺少少量可选指令的硬件上运行(例如没有 PMULL 的 Raspberry Pi 的 NEON)。
- 即将发布关于
archsimd内部原理的专题博客文章。
总结
Go 的实验性 SIMD 层消除了历史上针对每个架构编写汇编的障碍,提供了一个简洁、与大小无关的 API,在广泛的 CPU 上提供接近原生的性能,同时保证在任何平台上都有功能性的回退。该设计反映了 C++ Highway 库的理念,并预示了 SVE 等未来的扩展,使 Go 成为高性能、跨平台工作负载更具吸引力的选择。
Sources
相关
- 项目
- 项目
- 项目
- 项目