| name | go-perf-simd |
| description | Guides SIMD / vectorization in Go. The compiler barely autovectorizes, so SIMD means hand-written Plan 9 assembly (//go:noescape stubs, GOARCH .s files, or Avo) as stdlib does for bytes.IndexByte, or the experimental simd/archsimd package (Go 1.26, GOEXPERIMENT=simd, amd64, unstable API). Covers when it pays off — data-parallel hot loops, after algorithm/BCE/SoA — and its costs: assembly bypasses bounds checks and the race detector. Fires on "use SIMD in Go", "vectorize this loop", "AVX in Go", "the simd package". Routes to go-perf-compiler-intrinsics and go-perf-data-oriented-layout. |
| allowed-tools | Read, Glob, Grep |
| compatibility | Claude Code, Codex CLI, Gemini CLI |
Go Performance: SIMD & Vectorization
SIMD (single-instruction-multiple-data) runs one operation across a vector of lanes — add sixteen int8s in one instruction. It is the last lever in the performance toolbox, not the first. The measure-first gate (is this even the bottleneck?) belongs to go-perf-methodology; read it first. This skill owns the SIMD-specific reality: Go's compiler will not vectorize for you, the historical answer is hand-written assembly, and the new simd/archsimd package is experimental.
All Go below builds clean under gofmt/go vet; assembly is Go's Plan 9 dialect.
1. The Honest Framing — Most Code Should Never Reach Here
Go's compiler does only limited autovectorization — unlike GCC/LLVM at -O2/-O3, it will not turn a scalar for loop into AVX automatically in the general case. So in Go, "use SIMD" has historically meant writing the vector instructions by hand in assembly. That is a large, permanent cost (§5), so the bar is high. Exhaust the cheaper levers, in order, first:
| Lever (do these first) | Why it usually wins | Route to |
|---|
| A better algorithm / less work | dwarfs any constant-factor SIMD gain | go-perf-methodology |
| Struct-of-arrays (SoA) layout | makes the loop vectorizable at all and cache-friendly | go-perf-data-oriented-layout |
| Bounds-check elimination | removes per-iteration branches; precondition for tight loops | go-perf-bounds-check-elimination |
math/bits intrinsics + GOAMD64 | the compiler already lowers these to single CPU instructions | go-perf-compiler-intrinsics |
| Hand SIMD (this skill) | only when a profiled data-parallel hot loop still dominates | — |
math/bits functions (OnesCount, LeadingZeros, …) are already compiled to single instructions, and GOAMD64=v3 unlocks newer instruction sets without assembly — try those before vectorizing by hand (go-perf-compiler-intrinsics). Knuth's order stands: algorithm and data layout first, unsafe/assembly last.
2. When SIMD Is Actually Worth It
Reach for hand SIMD only when all of these hold:
- A CPU profile proves a single tight loop dominates runtime — not spread cost (Amdahl;
go-perf-methodology).
- The work is data-parallel: the same operation over a large contiguous array (image pixels, audio samples, vector dot-products, byte scanning, checksums), with no loop-carried dependency between iterations.
- The data is laid out SoA / contiguous so lanes load in one instruction (
go-perf-data-oriented-layout) — vectorizing an array-of-structs that strides past cold fields buys little.
- The array is large enough to amortize the fixed cost of vector setup, tail handling, and the function call into the asm stub. Tiny slices lose to scalar code.
If the loop is short, branchy, pointer-chasing, or rarely hot, SIMD is net-negative once you price in §5. And always re-measure with benchstat afterward (go-perf-methodology): a vectorized kernel that wins cache-hot in a microbenchmark can lose on cold production data, so confirm the gain on a representative workload before keeping the assembly.
3. Why Go ≠ GCC/LLVM Here
GCC and LLVM have mature auto-vectorizers; Go's gc compiler deliberately does not. This is a known, intentional gap — the experimental simd/archsimd package exists precisely because there was no portable, supported way to access vector instructions from Go source before it (Go 1.26 release notes). Until that package matures, the stdlib itself reaches for hand-written assembly (§4), which is the strongest evidence of the gap.
The practical consequence: do not assume a scalar loop becomes vector code at build time the way it might under clang -O3. Go has no -O2/-O3 switch, and the gc compiler will emit scalar instructions for an ordinary for loop. If you need vector throughput, you must explicitly choose one of the two routes below — there is no "just turn on optimization" path.
4. The Historical Path — Plan 9 Assembly + //go:noescape Stubs
The established pattern (used throughout the stdlib) is: a Go stub declaration with no body, an implementation in a GOARCH-specific .s file, a //go:build constraint selecting the architecture, and a //go:noescape pragma. bytes.IndexByte is the canonical example — it dispatches to internal/bytealg, whose Go side declares the stub (indexbyte_native.go):
package bytealg
func IndexByte(b []byte, c byte) int
func IndexByteString(s string, c byte) int
and the amd64 implementation is real SSE2 SIMD — a 16-byte vector compare per iteration (indexbyte_amd64.s):
MOVOU (DI), X1 // load 16 bytes into the X1 vector register
PCMPEQB X0, X1 // compare all 16 bytes against the target byte at once
PMOVMSKB X1, DX // gather the 16 match bits into a scalar register
Two load-bearing rules from this pattern:
//go:noescape is a promise you must keep. It "must be followed by a function declaration without a body … It specifies that the function does not allow any of the pointers passed as arguments to escape into the heap or into the values returned" (cmd/compile directives). The compiler trusts it during escape analysis — if your assembly actually leaks a pointer, you get silent memory corruption, not a compile error.
- Always give the asm a Go prototype. "Assembly functions should always be given Go prototypes, both to provide pointer information for the arguments and results and to let
go vet check that the offsets … are correct" (Go asm). Keep a pure-Go fallback (stdlib ships countGeneric) for unsupported arches and for testing.
5. The Cost — Why This Is the Last Resort
Hand-written assembly pays for speed with everything else:
- It bypasses bounds checks. The assembler does no
[i] range checking — an off-by-one reads or writes out of bounds with no panic (go-perf-bounds-check-elimination covers the safe scalar path).
- It is invisible to the race detector.
-race instruments compiled Go, not .s files, so a data race inside an asm routine goes undetected.
- It is per-architecture and non-portable. Each
GOARCH needs its own .s file (amd64 / arm64 / ppc64 / s390x …); the build tags above show the maintenance surface.
- Available instructions are gated by
GOAMD64. AVX2/AVX-512 paths only run on CPUs the binary targets; you must feature-detect or pick a baseline GOAMD64 level (route the level choice to go-perf-compiler-intrinsics).
- It is hard to read, review, and refactor. Plan 9 syntax, manual register allocation, and hand-rolled loop tails make every change risky.
There is no -race shim and no bounds-checked vector load to fall back on; correctness is entirely manual once you cross into the .s file.
Avo softens the authoring cost: it lets you "Generate x86 Assembly with Go" and "makes high-performance Go assembly easier to write, review and maintain" by writing a Go program that emits the .s file, with automatic register allocation and argument handling (Avo). It does not remove the portability, bounds-check, or race-detector costs — the output is still per-arch assembly.
6. The Experimental simd/archsimd Package (Go 1.26) — Version-Sensitive
VERSION CARE. Everything in this section is Go 1.26, experimental, opt-in, API-unstable, amd64-only. Do not use it in code that must be stable or portable.
Go 1.26 adds a low-level SIMD package, import path simd/archsimd, "which can be enabled by setting the environment variable GOEXPERIMENT=simd at build time. It is currently available on the amd64 architecture" and "supports 128-bit, 256-bit, and 512-bit vector types" (Go 1.26 release notes). The vector types are structs corresponding to hardware registers — e.g. Int8x16, Float64x8, Float32x4 — with methods like Int8x16.Add, plus Load/Store, Add/Sub/Mul, and And/Or/Xor (simd/archsimd; proposal #73787).
Three things you must flag whenever you touch it:
- The API is unstable. "The API is not yet considered stable" (release notes) and the package is "not subject to the Go 1 compatibility promise" (
simd/archsimd). It was already renamed once from simd to simd/archsimd during development (#76473) — expect more churn.
- It is amd64-only and intentionally non-portable. "The API [is] intentionally architecture-specific and thus non-portable. In addition, we plan to develop a high-level portable SIMD package in the future" (release notes). A portable package does not exist yet as of the 1.26 release. [VERIFY] any claim about other architectures or a portable package — they are stated as future plans, not shipped.
- It only exists under the experiment. Without
GOEXPERIMENT=simd the package is absent; the build won't see it. This means CI, tooling, and teammates must all opt in.
The usage shape is load → vector-op → store over a contiguous slice, with a scalar tail for the remainder (vector width rarely divides the length). Conceptually:
import "simd/archsimd"
func addInto(a, b []int8) {
i := 0
for ; i+16 <= len(a); i += 16 {
va := archsimd.LoadInt8x16(&a[i])
vb := archsimd.LoadInt8x16(&b[i])
va.Add(vb).Store(&a[i])
}
for ; i < len(a); i++ {
a[i] += b[i]
}
}
What it buys: vector intrinsics from Go source (no .s file), so you keep go vet, readability, and — unlike hand asm — the compiler's bounds-check and escape machinery around the Go that surrounds the intrinsics. What it does not buy: stability, portability, or a free pass on the §2 "is it worth it" gate. Build it with the experiment explicitly: GOEXPERIMENT=simd go build ./... (Go 1.26).
7. Routing to Related Skills
This plugin (go-perf-* depth):
go-perf-methodology — the measure-first gate; prove the loop dominates before vectorizing.
go-perf-compiler-intrinsics — math/bits (already single-instruction) and GOAMD64/GOARM64 levels; try these before hand SIMD.
go-perf-data-oriented-layout — struct-of-arrays / contiguous layout that makes a loop vectorizable at all.
go-perf-bounds-check-elimination — the safe scalar tight-loop path; do this first.
go-perf-pprof-profiling — confirm the hot loop with a CPU profile.
Parent / sibling marketplace (go-* correctness):
go-strings-bytes-runes — bytes/strings correctness behind the SIMD-accelerated stdlib funcs.
go-race-and-memory-model — why bypassing -race in asm is dangerous.
8. Don't
- Don't reach for SIMD before profiling. Vectorizing a loop the profile never flagged is wasted, risky effort ([methodology] gate). Algorithm and layout win bigger.
- Don't treat
simd/archsimd as stable. Its API "is not yet considered stable," is outside the Go 1 promise, and was already renamed once (Go 1.26; #76473).
- Don't assume SIMD in Go is cross-platform. The experiment is amd64-only; hand asm needs one
.s per GOARCH (Go 1.26).
- Don't put
//go:noescape on asm that actually leaks a pointer. The compiler trusts it; a false promise is silent memory corruption (cmd/compile).
- Don't ship asm without a pure-Go fallback and a Go prototype for
go vet (Go asm).
- Don't forget assembly bypasses bounds checks and
-race — every memory and concurrency bug is on you.
- Don't hand-write what an intrinsic already does —
math/bits and a higher GOAMD64 level cover many "I need a CPU instruction" cases with zero assembly (go-perf-compiler-intrinsics).
9. Reference Files
SIMD/assembly anti-patterns in LLM-generated Go, each with wrong/right contrast and citations:
${CLAUDE_SKILL_DIR}/references/common-mistakes.md
Source provenance for every claim in this skill:
${CLAUDE_SKILL_DIR}/references/sources.yaml