# Go Perf Simd

> 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.

- Skill: `ctoth/go-perf-simd` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add ctoth/go-perf-simd`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ctoth/go-perf-simd/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: ctoth (https://skillmd.com/u/ctoth)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ctoth/go-perf-simd

---


# Go Performance: SIMD & Vectorization

SIMD (single-instruction-multiple-data) runs one operation across a vector of lanes — add sixteen `int8`s 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:

1. A CPU profile proves a **single tight loop** dominates runtime — not spread cost (Amdahl; `go-perf-methodology`).
2. 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.
3. 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.
4. 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](https://go.dev/doc/go1.26)). 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`](https://github.com/golang/go/blob/master/src/internal/bytealg/indexbyte_native.go)):

```go
//go:build 386 || (amd64 && !plan9) || s390x || arm || arm64 || /* … */ wasm

package bytealg

//go:noescape
func IndexByte(b []byte, c byte) int

//go:noescape
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`](https://github.com/golang/go/blob/master/src/internal/bytealg/indexbyte_amd64.s)):

```asm
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](https://pkg.go.dev/cmd/compile)). 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](https://go.dev/doc/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](https://github.com/mmcloughlin/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](https://go.dev/doc/go1.26)). 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`](https://pkg.go.dev/simd/archsimd); [proposal #73787](https://github.com/golang/go/issues/73787)).

Three things you **must** flag whenever you touch it:

1. **The API is unstable.** "The API is not yet considered stable" ([release notes](https://go.dev/doc/go1.26)) and the package is "not subject to the Go 1 compatibility promise" ([`simd/archsimd`](https://pkg.go.dev/simd/archsimd)). It was *already renamed once* from `simd` to `simd/archsimd` during development ([#76473](https://github.com/golang/go/issues/76473)) — expect more churn.
2. **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](https://go.dev/doc/go1.26)). 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.
3. **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:

```go
//go:build goexperiment.simd && amd64

import "simd/archsimd" // Go 1.26, GOEXPERIMENT=simd — UNSTABLE API

// Add b into a, 16 int8 lanes per iteration. Method/constructor names
// here are illustrative — [VERIFY] the exact surface against the
// installed toolchain's simd/archsimd, it is changing.
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++ { // scalar tail
		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](https://go.dev/doc/go1.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](https://go.dev/doc/go1.26); [#76473](https://github.com/golang/go/issues/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](https://go.dev/doc/go1.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](https://pkg.go.dev/cmd/compile)).
- **Don't ship asm without a pure-Go fallback** and a Go prototype for `go vet` ([Go asm](https://go.dev/doc/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:

[references/common-mistakes.md](references/common-mistakes.md)

Source provenance for every claim in this skill:

[references/sources.yaml](references/sources.yaml)

