Verify behavior and measure performance

Go 1.27 SIMD: Vectorize a Go Loop, Then Measure Whether It Pays

Vectorize a float32 loop with Go 1.27 portable SIMD, test partial-vector tails, and measure whether it beats the scalar version.

By Niko Minadze5 min read

Editorial illustration for Go 1.27 SIMD: Vectorize a Go Loop, Then Measure Whether It Pays

If a numeric loop is hot in a CPU profile, Go 1.27 gives you a portable way to try explicit SIMD without writing separate vector-width implementations. Keep the scalar loop: it is both your correctness reference and the performance result SIMD has to beat. A faster-looking instruction sequence is not, by itself, a faster application.

What Go 1.27 adds

Go 1.27 introduces the experimental simd package. It is available across architectures, uses hardware SIMD where available, and provides emulation otherwise. Its vector types have unspecified size: portable code asks for the active width rather than assuming a fixed number of lanes. Code importing the package needs GOEXPERIMENT=simd at build time. The Go 1.27 release notes distinguish this portable API from simd/archsimd, whose experimental architecture-specific support began in Go 1.26. Go 1.27 revises its amd64 API and adds arm64 NEON and WebAssembly 128-bit SIMD support.

Start with a loop that profiling identifies as worth changing. Elementwise subtraction makes the mechanics easy to see, but it is not a promise of a speedup: short inputs, data movement, or an existing optimized implementation may dominate the result. Measure the input sizes your program actually processes.

Subtract full vectors, then handle the tail

These two functions modify a in place and assume a and b have equal lengths. Put them in a Go file in a module built with Go 1.27. The scalar function is deliberately uncomplicated:

package sub

import "simd"

func subScalar(a, b []float32) {
	for i := range a {
		a[i] -= b[i]
	}
}

func subSIMD(a, b []float32) {
	lanes := simd.VectorBitSize() / 32 // float32 has 32 bits

	for len(a) >= lanes {
		simd.LoadFloat32s(a).Sub(simd.LoadFloat32s(b)).Store(a)
		a = a[lanes:]
		b = b[lanes:]
	}

	if len(a) > 0 {
		x, n := simd.LoadFloat32sPart(a)
		y, _ := simd.LoadFloat32sPart(b)
		x.Sub(y).StorePart(a[:n])
	}
}

VectorBitSize() supplies the active vector width; dividing by 32 gives the number of float32 elements in a full vector. The loop consumes those full vectors. For the remaining elements, partial loads and a partial store avoid treating the tail as a full vector. This follows the subtraction pattern in the Go SIMD benchmark example. Portable vectors do not expose a fixed lane count to bake into the function.

Prove the tail works before timing it

A test should cover zero elements, lengths below one vector, exact-vector lengths, and every possible remainder. Deriving cases from the active width makes the test useful under different SIMD configurations. Add this in sub_test.go in the same package:

package sub

import (
	"fmt"
	"simd"
	"testing"
)

func input(n int) ([]float32, []float32) {
	a, b := make([]float32, n), make([]float32, n)
	for i := range a {
		a[i] = float32(i%23) + 2
		b[i] = float32(i%7) / 8
	}
	return a, b
}

func TestSubSIMD(t *testing.T) {
	lanes := simd.VectorBitSize() / 32
	lengths := make([]int, 0, 2*lanes+3)
	for n := 0; n <= 2*lanes+1; n++ {
		lengths = append(lengths, n)
	}
	lengths = append(lengths, 1001)

	for _, n := range lengths {
		t.Run(fmt.Sprint(n), func(t *testing.T) {
			want, b := input(n)
			got := append([]float32(nil), want...)
			subScalar(want, b)
			subSIMD(got, b)
			for i := range want {
				if got[i] != want[i] {
					t.Fatalf("index %d: SIMD %g, scalar %g", i, got[i], want[i])
				}
			}
		})
	}
}

The generated values are finite and identical for both paths; add representative data from your workload as well. In particular, retain cases that exercise the partial-load and partial-store path. Enable the experiment when testing:

GOEXPERIMENT=simd go test ./...

Benchmark the same work at useful sizes

Add a benchmark to sub_test.go. Each sub-benchmark starts with the same input pattern, uses an equally sized destination and source, and repeatedly performs one in-place subtraction. Replace or extend these sizes with the distribution seen in your application.

var benchmarkResult float32

func BenchmarkSub(b *testing.B) {
	for _, n := range []int{1, 16, 128, 4096, 65536} {
		for _, variant := range []struct {
			name string
			fn   func([]float32, []float32)
		}{
			{"scalar", subScalar},
			{"simd", subSIMD},
		} {
			b.Run(fmt.Sprintf("%s/%d", variant.name, n), func(b *testing.B) {
				a, src := input(n)
				b.ResetTimer()
				for i := 0; i < b.N; i++ {
					variant.fn(a, src)
				}
				b.StopTimer()
				benchmarkResult = a[0]
			})
		}
	}
}
GOEXPERIMENT=simd go test -run '^$' -bench . -count=5

This measures repeated in-place operations without including input construction in the timed loop; it does not measure copying fresh input for every call. Report the spread across runs, not just the fastest number. Record the CPU, Go version, input size, and GOEXPERIMENT setting alongside results. A published Go 1.27 SIMD comparison found substantial scalar-to-SIMD gains in its small-array workload, but its complete suite ran only once; tiny differences there were not established as repeatable.

For a portability check, force the emulated path and rerun the tests:

GODEBUG=simd=0 GOEXPERIMENT=simd go test ./...

As the SIMD experiment description explains, GODEBUG=simd=0 selects emulation even when hardware SIMD is available. Other GODEBUG=simd width settings can help check configurations, but neither an emulation timing nor a forced-width timing establishes the speed of your normal production workload.

Keep the vectorized path only if the improvement is repeatable at the sizes that matter enough to justify an experimental API, another implementation to maintain, and tail handling. If the portable operation set cannot express the kernel, simd/archsimd offers architecture-specific operations—but then platform implementations and CPU-feature considerations become part of the decision, rather than a free extension of this portable loop.

Comments

No comments yet.

Leave a comment

Your comment appears after approval. Plain text only; links stay as text. Line breaks and indentation are kept.

Up to 5,000 characters. No account or email needed.

All articles

Type at least two characters.

Press <kbd>Esc</kbd> to closeOpen the search page