A

SIMD in Go 1.27: sum a slice eight floats at a time

4 min read ·
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.

A scalar loop adds sixteen floats one per step. A 128-bit vector register holds four float32 lanes and needs four steps; 256 bits, eight lanes, two steps; 512 bits, sixteen lanes, one step.
register step 16/16

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.