#News

Go 1.27 simd package: portable SIMD benchmarked on AVX2

The Go logo above the line "portable simd, benchmarked", with a Go 1.27 badge

Go 1.27 has a SIMD package that does not care which CPU it runs on, and on September 24, 2026 the Go team explained how it works. In Platform-independent SIMD in Go, David Chase and Junyang Shao describe the experimental simd package that shipped with Go 1.27 on August 19, 2026: a vector type with no fixed size, such as simd.Float32s, that runs as AVX, AVX2, AVX-512, NEON or WebAssembly SIMD depending on the machine the binary starts on, and as plain Go emulation where none of those exist.

The package is enabled with GOEXPERIMENT=simd at build time.

The post shows an inner product as its example and says nothing about how fast it runs. So we timed it.

On an AVX2 machine with Go 1.27.1, the post’s own loop written against the portable type ran as fast as the same loop written against the fixed-width archsimd type, and a second kernel, a byte count, ran about twelve to seventeen times faster than plain Go. The same binary at the 128-bit level, which a user’s CPU or a container’s CPU flags can select, ran the inner product tens of times slower than plain Go on a large input until we added one instruction.

Advertisements

One vector type, five instruction sets, one build flag

Go 1.26, released on February 10, 2026, introduced simd/archsimd, a package of fixed-size vector types such as Float32x8 that map to the registers of one architecture. The Go 1.26 release notes describe it as amd64 only and “intentionally architecture-specific and thus non-portable”, and promise “a high-level portable SIMD package in the future”.

Go 1.27 delivers that package. According to the Go 1.27 release notes, the release also revises the amd64 archsimd API and adds 128-bit NEON on arm64 and 128-bit SIMD on WebAssembly to it. Both sets of notes say of archsimd that “The API is not yet considered stable”; of the portable package, the 1.27 notes say only that it is experimental.

The portable package hides the differences between those targets, the post says, “by removing fixed-size vectors from the type system, and by only supporting those operations that are in the intersection of all the different platforms”, with emulation filling the gaps. The design is “loosely based on Highway for C++”.

Advertisements

The stated goal is code that “is as efficient as assembly language when the source code operations match the underlying hardware” and “is otherwise emulated as well as possible”. On a platform with no SIMD support at all, every operation is emulated, so the code always runs.

A simd vector is loaded from a slice, operated on with methods such as Add, MulAdd, Equal or Max, and stored back. Comparisons produce masks. What Go 1.27 lacks is any way to sum the lanes of a vector: the post’s example ends by storing the vector into a slice and adding the elements in a loop, and it notes that ReduceSum “will appear in the next release”.

The compiler generates one specialized copy of every function that mentions a simd type per vector width, named with a suffix such as @simd256, or @simd0 for emulation, and a wrapper that picks one when the program starts. All the copies are in the binary; the choice is made at run time, not at build time.

Advertisements

Without the build flag the package does not exist. On Go 1.27.1, a file that imports simd fails to build with “build constraints exclude all Go files” in the package directory.

Measured: the post’s inner product and a byte count, at every GODEBUG level

Tested with: Go 1.27.1 linux/amd64, released on September 1, 2026 with fixes to simd and simd/archsimd among other packages, downloaded from go.dev and checked against the published SHA-256; Linux 5.15 under WSL2; a 13th Gen Intel Core i5-13600KF, which has AVX2 and FMA but no AVX-512, so 256 bits is the widest vector it can run.

All measurements are from September 28, 2026 UTC. Nothing was run on arm64, on WebAssembly or on a CPU with AVX-512.

Advertisements

We wrote two kernels three ways each. The inner product of two float32 slices is the post’s example, copied as is; the plain Go version is a loop of multiply and add, and the archsimd version does the same loop on Float32x8, the 256-bit type, with the same single accumulator.

The byte count returns how many bytes of a slice equal a given value. The simd version compares a vector against a broadcast value, converts the mask to Int8s and accumulates it; a match is -1 in the mask, so the lanes are flushed every 127 vectors to stay inside an int8. This is the whole function, with the tail for slices that are not a multiple of the vector length:

Go
// CountSIMD counts the bytes of s equal to c with a comparison mask. A match
// is -1 in the mask converted to Int8s, so the per-lane counter is flushed
// every 127 vectors to stay inside an int8.
func CountSIMD(s []byte, c byte) int {
	needle := simd.BroadcastUint8s(c)
	var acc simd.Int8s
	total := 0
	lanes := make([]int8, acc.Len())
	flush := func() {
		acc.Store(lanes)
		for _, l := range lanes {
			total -= int(l)
		}
		acc = simd.Int8s{}
	}
	var i, k int
	for i = 0; i < len(s)-acc.Len()+1; i += acc.Len() {
		v := simd.LoadUint8s(s[i : i+acc.Len()])
		acc = acc.Add(v.Equal(needle).ToInt8s())
		k++
		if k == 127 {
			flush()
			k = 0
		}
	}
	if i < len(s) {
		v, _ := simd.LoadUint8sPart(s[i:])
		m := v.Equal(needle).ToInt8s()
		// lanes past the end were zero-filled; drop them when c == 0
		if c == 0 {
			var z [64]int8
			mm := m.ToBits()
			_ = mm
			m.Store(z[:m.Len()])
			for j := len(s) - i; j < m.Len(); j++ {
				z[j] = 0
			}
			m = simd.LoadInt8s(z[:m.Len()])
		}
		acc = acc.Add(m)
	}
	flush()
	return total
}
Go

A test compares each kernel with the plain Go one on sizes from 0 to 1048576 elements before any timing. To time them, each benchmark ran six times per level of the GODEBUG=simd variable the post documents, which chooses the vector width when the program starts:

Advertisements

Terminal (bash)
for g in "" "simd=128" "simd=0" "simd=+512"; do
  GODEBUG=$g GOEXPERIMENT=simd go test -run xxx -bench . -benchtime=1s -count=6 .
done
Bash

The empty setting is what this CPU picks on its own, 256 bits. The 128 setting forces 128-bit vectors and 0 forces emulation although the hardware supports SIMD.

The table shows the benchstat median of the six runs. The three cells marked unstable had spreads of 133, 75 and 82 percent around that median, against one to three percent everywhere else. The archsimd column does not change with the setting, because that code is compiled for Float32x8 only.

KernelElementsPlain Gosimd, 256 bitsarchsimd, 256 bitssimd, 128 bitssimd, emulated
Inner product1024403.4 ns195.0 ns196.3 ns6591.0 ns, unstable2089.5 ns
Inner product1048576443.9 µs117.7 µs119.4 µs12510.5 µs, unstable2135.3 µs
Byte count1024263.6 ns22.67 ns53.70 ns, unstable4243.50 ns
Byte count1048576266.8 µs16.08 µs74.67 µs4360.16 µs

Read the first two rows together. At 256 bits the portable package and the fixed-width archsimd version are within two percent of each other on both sizes, and both take under half the time of plain Go. On this kernel and this machine the post’s efficiency goal holds.

Advertisements

The inner product gains less than the byte count because the loop, taken from the post unchanged, has a single accumulator, so each fused multiply-add waits for the previous one.

The byte count shows what a mask-based kernel gets at 256 bits: about twelve times faster than plain Go on the small slice and about seventeen times on the large one.

The last column is the cost of the guarantee that the code always runs. With emulation forced, the inner product takes about five times longer than plain Go and the byte count about sixteen times longer. A program that lands there in a hot path is slower than if it had never used the package.

Advertisements

At 128 bits the post’s own loop ran slower than plain Go

The inner product at the 128-bit level came out at 6591.0 ns on 1024 elements with a spread of 133 percent, and at 12510.5 µs on the large slice with a spread of 75 percent. Go’s benchmark loop times a few iterations first and then picks a count to fill the second. In one of these runs it picked 1000000 iterations that then took 12567 ns each, over twelve seconds instead of one, so the loop got slower after the count was chosen.

A second run with eight repetitions gave 11.6 to 22.1 ms per call on the large slice, twenty-six to fifty times the 0.44 ms of plain Go and one hundred to nearly two hundred times the 117.7 µs of the 256-bit level. The byte count at the same level was stable, at 72.6 to 77.0 µs on the large slice, and only unstable on the small one, where it ranged from 47.9 to 106.9 ns.

The disassembly of the specialized DotSIMD@simd128 function shows five MOVUPS instructions in the legacy SSE encoding next to two VFMADD213PS instructions in the VEX encoding, and no VZEROUPPER. The 256-bit copy of the same function has one MOVUPS. These are the relevant lines, from go tool objdump:

Advertisements

Output
dot.go:20           0x5456f6   410f10cf     MOVUPS X15, X1
decls_amd64.go:3516 0x54570e   c4e261a8d1   VFMADD213PS X1, X3, X2
dot.go:20           0x545716   0f10ca       MOVUPS X2, X1
decls_amd64.go:32   0x5457b7   410f10df     MOVUPS X15, X3
decls_amd64.go:3516 0x5457e8   c4e261a8d1   VFMADD213PS X1, X3, X2
dot.go:30           0x5457ef   0f10d1       MOVUPS X1, X2
dot.go:30           0x54580d   0f10c1       MOVUPS X1, X0
Plaintext

The 0f10 bytes are the legacy opcode; c4 opens a VEX prefix. The archsimd package exports a function called ClearAVXUpperBits, whose documentation says it “is intended for transitioning from AVX to SSE, eliminating the performance penalties caused by false dependencies”, that the compiler may generate it automatically in the future, and that it assembles to VZEROUPPER. So we ran the 128-bit inner product again with a fixed count of 300 iterations, once as is and once with ClearAVXUpperBits called before each call.

On the large slice the loop as is took 16.0 to 19.5 ms per call, and with the call added it took 0.41 to 0.57 ms, against 0.44 to 0.46 ms for plain Go in the same run. On 1024 elements the two variants were the same, at 184.2 to 206.7 ns.

That is faster than the 256-bit level measured in the same fixed-count run, 247.9 to 257.7 ns, and close to its 195.0 ns median in the six-run matrix; across our runs the 256-bit level on this size ranged from 190.0 to 257.7 ns, so the small slice separates runs, not widths. At 256 bits on the large slice the extra call did not help: 115.3 to 125.9 µs without it and 120.8 to 129.8 µs with it.

Advertisements

Our read is that this is the transition penalty Intel CPUs pay when legacy SSE instructions run while the upper halves of the AVX registers are dirty, which fits the mixed encodings and the effect of the VZEROUPPER. We did not confirm the cause with the Go team, and we did not reproduce it on another CPU. On this machine, forcing the 128-bit level on the post’s own example is worse than not using SIMD at all on a large input, unless something clears the upper bits first.

GODEBUG=simd=512 panics where the post says “if possible”

The post lists GODEBUG=simd=512 as “use 512-bit vectors and their features, if possible”, and the +512 form as “use 512-bit vectors, even if some features are not supported”. On this CPU both forms panic at startup, before main runs, with “Requested GODEBUG=gosimd=512 is larger than the simd length (256) supported on this cpu”. The message calls the variable gosimd, a name the post does not use.

simd.VectorBitSize() returns 128 under emulation, which matches the package documentation, “In all cases, the vector length is at least 128 bits”, and simd.Emulated() is how a program tells the two cases apart. And go doc simd on Go 1.27.1 lists three known bugs at the end of the package documentation: “Calls won’t work, and there may be other bugs”, “SIMD-dependent var initializers don’t work” and “Modified names may appear in stack traces and debugging”.

Advertisements

What we make of it

The portable package does what the post claims on the one comparison that matters most to someone deciding whether to write archsimd code: at the native width, the same loop written against simd.Float32s ran as fast as the one written against Float32x8, with no build tag and no per-architecture file. For code that is compute-bound and fits the operation table in the post, that is the version to write first.

Three things keep it an experiment rather than a tool. The package is experimental by the release notes’ own word, and the archsimd API underneath it is declared unstable. The operation set has holes that show up on the first real kernel, starting with the missing reduction, which the post itself works around with a scalar loop.

The third is that the widths a program does not run on by default, 128 bits on an AVX2 machine and full emulation anywhere, are where the surprises were. Treat emulation as a correctness fallback, not a performance one. One of them is slower than plain Go by a factor of five to sixteen, and the other, on our machine, by a factor of twenty-six to fifty on a large input.

Advertisements

If you try it, measure at every level of GODEBUG=simd, the default included. The Hacker News thread on the post, at 408 points and 147 comments when we read it, has two reports from people who did.

One commenter built a WebAssembly palette-swap demo that runs the same mapping as plain Go, as the portable package and as wasm archsimd, and reports the portable version about 11 percent slower than archsimd and both about five times faster than plain Go, on wasm, where 128 bits is the only width. Another reports a speedup of around 30 percent on a foreground-estimation task, with no baseline to compare.

Go 1.28 is where the gaps are supposed to close, and its release is one our News section covers when it ships. The post says it is meant to bring ReduceSum, OnesCount, mask and shuffle operations, SVE support in archsimd and “feature variants”, so that a CPU missing one instruction is not pushed all the way down to emulation, and a separate post on archsimd is promised “soon”. Whether the 128-bit behavior we saw is a known issue is the question the sources could not answer.

Advertisements

Go 1.27 simd package: portable SIMD benchmarked on AVX2

Claude Opus 5.5 vs GPT-6 Sol and

Go 1.27 simd package: portable SIMD benchmarked on AVX2

Claude Sonnet 5.5 vs Opus 5.5: cost

Leave a comment

Your email address will not be published. Required fields are marked *