SIMD in Go 1.27: sum a slice eight floats at a time
on this page
Until now, if you wanted your CPU to add eight numbers with one instruction in Go, you wrote assembly. Almost nobody did, so most Go programs leave a wide part of the processor idle.
Go 1.26 started changing that with an experimental simd/archsimd package for
amd64. Go 1.27 adds arm64 and wasm, and a portable simd package on top. Both
sit behind GOEXPERIMENT=simd, and the API is not stable. This post is the
smallest example that teaches the important parts.
The idea
SIMD means single instruction, multiple data. A scalar loop adds one pair of floats per step. A vector register holds several of them, and one instruction adds all the lanes at once.
Step 16 of 16. Scalar: 16 of 16 adds. SIMD at 256 bits, 8 lanes: 16 of 16 adds, finished in 2 steps.
How many lanes? That depends on the CPU: 128, 256 or 512 bits on amd64, 128 on
arm64 and wasm. The portable simd package hides this. You write simd.Float32s,
and the length is whatever the machine gives you.
The whole function
//go:build goexperiment.simdpackage vecimport "simd"func Sum(x []float32) float32 { var acc simd.Float32s var i int for i = 0; i < len(x)-acc.Len()+1; i += acc.Len() { acc = acc.Add(simd.LoadFloat32s(x[i : i+acc.Len()])) } if i < len(x) { tail, _ := simd.LoadFloat32sPart(x[i:]) acc = acc.Add(tail) } lanes := make([]float32, acc.Len()) acc.Store(lanes) var s float32 for _, v := range lanes { s += v } return s} Four details, each one there for a reason.
The loop bound. i < len(x)-acc.Len()+1 looks odd next to the natural i+acc.Len() <= len(x). The Go team's own advice is the odd version: the
addition in the natural one can overflow in theory, so the compiler cannot
prove the slice access is in range and keeps a bounds check in the hot loop.
The tail. If len(x) is not a multiple of the lane count, the last few
elements do not fill a vector. LoadFloat32sPart loads what is left and
zero-fills the rest. For a sum that is harmless, because adding zero changes
nothing. For a product it would not be.
The reduction. Collapsing the lanes into one number is the clumsy part. Go
1.27's simd has no horizontal sum, so we store the lanes and add them in a
plain loop. The release notes say ReduceSum is planned for the next version,
and then this block becomes one call.
The build tag. Without GOEXPERIMENT=simd, the package does not build. Put
the SIMD version behind the tag and keep a scalar twin behind !goexperiment.simd.
Same function name, same signature, and the rest of your code never knows.
The trap: this is not the same sum
Floating point addition is not associative. A scalar loop computes ((a+b)+c)+d…. The vector version adds lane by lane, then adds the lanes. The
operations happen in a different order, so the rounding differs.
Worse, the order depends on the lane count. On a 128-bit machine you get four partial sums. On a 512-bit machine, sixteen. The same binary logic can return slightly different results on different hardware.
So never test it with ==:
func TestSum(t *testing.T) { x := make([]float32, 1003) // not a multiple of any lane count for i := range x { x[i] = float32(i%7) * 0.1 } got, want := Sum(x), sumScalar(x) if math.Abs(float64(got-want)) > 1e-2*math.Abs(float64(want)) { t.Fatalf("got %v, want %v", got, want) }} The length 1003 is on purpose: it forces the tail path. And you can force each
vector width without owning the hardware, using GODEBUG:
GOEXPERIMENT=simd GODEBUG=simd=0 go test ./... # emulation, no SIMDGOEXPERIMENT=simd GODEBUG=simd=128 go test ./... # 128-bit vectorsGOEXPERIMENT=simd GODEBUG=simd=256 go test ./... # 256-bit vectors Run all three. If the tail is wrong, one of them will find it.
Should you use it?
I am not putting a speedup number here, because it would be misleading. Summing a large slice is often limited by memory bandwidth, not by the adds. A wider add does not help a CPU that is waiting for data. Measure on your own machine:
func BenchmarkSum(b *testing.B) { x := make([]float32, 1<<20) for b.Loop() { sink = Sum(x) }} Compare it with the scalar twin using benchstat. My rule: profile first, find
the loop that really dominates, and only then reach for SIMD. It pays when the
data is already in cache and the work per element is real: image filters, audio,
checksums, distance calculations.
It also costs something. The experiment flag, an API that can still change, and
a result that depends on lane count. For most services, the standard library is
the right answer today. For the one hot loop in your profile, it is finally an
option that does not start with .s.