| name | go-perf-data-oriented-layout |
| description | Guides data-oriented memory layout in Go — Struct-of-Arrays vs Array-of-Structs and when SoA wins (scan one field over many records, fewer cache lines), why []T beats []*T (a pointer slice scatters objects and adds GC scan work; []T is contiguous and noscan), replacing pointer-chasing lists/trees with int32 indices. Fires on cache-friendly layout, struct of arrays, slice of pointers is slow, data-oriented design. Routes field packing to go-perf-struct-layout, GC-scan to go-perf-allocator-internals, SIMD to go-perf-simd. |
| allowed-tools | Read, Glob, Grep |
| compatibility | Claude Code, Codex CLI, Gemini CLI |
Go Performance: Data-Oriented Layout
"Data dominates. If you've chosen the right data structures and organized things well, the algorithms will almost always be self-evident."
— Rob Pike, Notes on Programming in C
This skill owns one lever: lay data out so the hot loop touches the fewest cache lines and the GC scans the fewest pointers. It is the cache-and-access-pattern peer of go-perf-struct-layout (which owns field ordering and padding within one struct). Correctness of slices/maps routes to go-slices-and-maps; the should-I-even-bother gate routes to go-perf-methodology — these are big wins only in hot, data-parallel loops a profile has already proven dominant, and they trade type-safety and readability for speed.
All Go below builds clean under gofmt and go vet on Go 1.26.
1. The mental model: the cache, not the CPU, is the bottleneck
A modern core can do dozens of arithmetic ops in the time one DRAM miss costs. So the layout question is "how many cache lines does this loop drag in, and how predictably?" — not "how many instructions." go-perfbook puts the access-pattern rule plainly: "caches prefer the predictable access of scanning a slice to the effectively random access of chasing a pointer" (go-perfbook). Two consequences drive everything below:
- Contiguous + sequential lets the hardware prefetcher run ahead; scattered + pointer-chased stalls on every miss.
- Pointers cost the GC. "More pointers means more GC work, because at minimum the GC needs to visit all the pointers in the program" (GC Guide). Pointer-free data lands in noscan spans the collector never walks (depth →
go-perf-allocator-internals).
2. Struct-of-Arrays vs Array-of-Structs
When a loop reads one field across many elements, Array-of-Structs ([]Particle) drags every cold field of each element through cache; Struct-of-Arrays packs the hot field contiguously so the loop streams only what it uses. go-perfbook frames it as "SOA vs AOS layouts: row-major vs. column major" and asks the decisive question: "when you have an X, do you need another X or do you need a Y?" (go-perfbook). Iterating one field over many records → you need the next X, so pack the X's together.
type Particle struct {
X, Y, Z float64
VX, VY, VZ float64
}
func sumXAoS(ps []Particle) float64 {
var s float64
for i := range ps {
s += ps[i].X
}
return s
}
type Particles struct {
X, Y, Z []float64
VX, VY, VZ []float64
}
func sumXSoA(p Particles) float64 {
var s float64
for _, x := range p.X {
s += x
}
return s
}
Measured (Go 1.26, Ryzen 5950X, summing one field over 2²⁰ elements): for the 48-byte Particle above, SoA ran ~2.83 ms vs AoS ~3.5 ms (~1.2×). Widen the struct to 128 bytes (one hot field + 15 cold) and the gap opens to ~1.7× (AoS ~4.9 ms → SoA ~2.85 ms). The tell is in the shape: SoA's time is flat (~2.85 ms) regardless of struct width because it always streams the same packed array; AoS gets slower the more cold data each element carries. SoA wins exactly in proportion to the cold bytes you stop dragging — so it's worth it for wide records iterated field-wise, and pointless when the loop reads most fields of each element anyway.
3. []T vs []*T — a slice of pointers is two penalties
[]*Account stores contiguous headers, but each element is a separate heap object reached by an indirection. That is random access (cache misses on every dereference) and a slice full of pointers the GC must scan. []Account lays the values out contiguously, prefetches predictably, and — if Account is pointer-free — the whole backing array is noscan.
type Account struct {
Balance int64
ID int64
}
func totalPtr(as []*Account) int64 {
var t int64
for _, a := range as {
t += a.Balance
}
return t
}
func totalVal(as []Account) int64 {
var t int64
for i := range as {
t += as[i].Balance
}
return t
}
The GC Guide makes the scan cost explicit: "Pointer-free values are segregated from other values. As a result, it may be advantageous to eliminate pointers from data structures that do not strictly need them, as this reduces the cache pressure the GC exerts on the program" (GC Guide). Prefer []T of values; reach for []*T only when elements are large, individually mutated through shared aliases, or genuinely polymorphic.
4. Replace pointer-chasing structures with indices-into-a-slice
Linked lists and pointer trees are the worst case: every node is a separate heap allocation, every link is a GC-scanned pointer, and traversal is a chain of cache misses. The fix is indices as pointers — keep all nodes in one contiguous []Node and link them by int32 offset. The slice is pointer-free (noscan), dense, and prefetch-friendly. The GC Guide endorses exactly this: "the GC must interact with nearly every pointer it sees, so using indices into a[n] slice, for example, instead of pointers, can aid in reducing GC costs," and "data structures that rely on indices over pointer values, while less well-typed, may perform better" (GC Guide).
type NodeP struct {
Val int64
Left, Right *NodeP
}
type Node struct {
Val int64
Left, Right int32
}
type Tree struct{ nodes []Node }
func (t *Tree) add(val int64) int32 {
t.nodes = append(t.nodes, Node{Val: val, Left: -1, Right: -1})
return int32(len(t.nodes) - 1)
}
int32 handles also halve the link size versus 64-bit pointers, packing more nodes per cache line. Measured framing matters here — the same guide warns this "is only worth doing if it's clear that the object graph is complex and the GC is spending a lot of time marking and scanning" (GC Guide). Confirm with a heap profile / GC trace before trading away *T type-safety; route the judgment to go-perf-methodology.
5. Ring buffers and arena-style index slabs
The same idea generalizes to allocation-free reuse:
- Ring buffer over a fixed
[]T instead of container/list (which allocates a node and stores interface-boxed pointers per element). Contiguous, pointer-free, zero per-op allocation.
- Arena / slab: append nodes into one
[]T and hand out int32 handles instead of *T. The slab is dense and noscan; freeing a whole short-lived graph is one s.buf = s.buf[:0] that keeps the capacity — no per-node GC churn (slice reuse mechanics → go-perf-slices).
type Slab struct{ buf []Node }
func (s *Slab) alloc(n Node) int32 {
s.buf = append(s.buf, n)
return int32(len(s.buf) - 1)
}
func (s *Slab) get(h int32) *Node { return &s.buf[h] }
func (s *Slab) reset() { s.buf = s.buf[:0] }
One caveat the index model buys you: a handle stays valid across a growslice, whereas a *Node into s.buf dangles after the backing array moves — so don't hold *Node across an alloc.
6. Batch work to stay in cache
When several passes each touch the same large dataset, fuse them: do all the work on a block while it is resident in L1/L2 before moving on, instead of streaming the whole array once per pass. go-perfbook notes "iterating in batches over data structures can be much faster" (go-perfbook). The flip side is the lookup-table trap — precomputed tables can lose to recomputation: "It's very easy for lookup tables to be 'far away' in memory (and therefore expensive to access) making it faster to just recompute a value every time it's needed" (go-perfbook). A cache miss costs more than a handful of ALU ops, so a "fast" table that lives in cold memory can be slower than the arithmetic it replaced.
7. Don't
- Don't reorganize layout without a profile. These wins live in hot, data-parallel loops only; a cold path pays the readability cost for nothing (Amdahl) — gate it through
go-perf-methodology.
- Don't default to
[]*T for a collection of small values you iterate — it scatters the heap and adds GC scan work; use []T (GC Guide).
- Don't reach for SoA when the loop reads most fields of each element — you gain nothing and lose the natural struct grouping. SoA pays off in proportion to the cold bytes skipped (§2).
- Don't index-encode a pointer graph "to be fast" absent evidence the GC is mark/scan-bound — the guide gates it on a complex graph and measured GC time (GC Guide).
- Don't keep
*T into a slab/slice across an append — a growslice moves the backing array and dangles the pointer; hold the int32 handle instead (§5).
- Don't use
container/list/container/ring on a hot path — per-node allocation and interface boxing; a slice-backed ring buffer is contiguous and allocation-free (§5).
- Don't assume a precomputed lookup table is free — if it sits in cold memory, recomputation can win (go-perfbook).
8. Routing to Related Skills
This plugin (go-perf-* depth):
go-perf-struct-layout — field ordering, alignment, and padding within a single struct; fieldalignment; pointer-free structs.
go-perf-allocator-internals — noscan vs scan spans, size classes, and what makes a span GC-free; the allocator path behind "noscan."
go-perf-slices — growslice, make([]T, 0, n) prealloc, s = s[:0] reuse, slice-backed ring buffers as a throughput lever.
go-perf-simd — vectorizing the tight SoA loop SoA makes possible (GOEXPERIMENT=simd, 1.26).
go-perf-bounds-check-elimination — dropping the per-iteration bounds check in the hot index loop.
go-perf-false-sharing — when concurrent per-shard fields share a cache line (a different cache problem).
go-perf-methodology — the measure-first gate: is this loop actually the bottleneck, and did the relayout measurably help?
Parent / sibling marketplace (go-* correctness):
go-slices-and-maps — slice aliasing, append semantics, 3-index slices, Clone/Clip, nil maps — the correctness layer under the perf depth here.
9. Reference Files
Data-layout anti-patterns in LLM-generated Go, each with a 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