# Go Perf Data Oriented Layout

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

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

---


# 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](https://www.lysator.liu.se/c/pikestyle.html)

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](https://github.com/dgryski/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](https://go.dev/doc/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](https://github.com/dgryski/go-perfbook)). Iterating *one* field over many records → you need the next **X**, so pack the X's together.

```go
// AoS: 48 bytes/elem. A one-field sum still pulls every cold field
// (Y, Z, velocities) through cache — wasted bandwidth.
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
}

// SoA: X is packed contiguously. The loop streams one tight []float64,
// touches the minimum cache lines, and autovectorizes cleanly (→ go-perf-simd).
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**.

```go
type Account struct {
	Balance int64
	ID      int64 // pointer-free → []Account is a noscan span
}

func totalPtr(as []*Account) int64 { // scattered heap objects + GC scans every *Account
	var t int64
	for _, a := range as {
		t += a.Balance
	}
	return t
}

func totalVal(as []Account) int64 { // contiguous, prefetch-friendly, noscan
	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](https://go.dev/doc/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](https://go.dev/doc/gc-guide)).

```go
// Pointer tree: each child is a heap object and a scanned pointer; random-access misses.
type NodeP struct {
	Val         int64
	Left, Right *NodeP
}

// Index tree: all nodes in one noscan []Node; -1 is the nil sentinel.
type Node struct {
	Val         int64
	Left, Right int32 // index into the backing slice; -1 == none
}

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](https://go.dev/doc/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`).

```go
// Slab: int32 handles, not *Node. Dense, noscan, freed en masse by reslicing.
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] } // reuse capacity, no GC work
```

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](https://github.com/dgryski/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](https://github.com/dgryski/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](https://go.dev/doc/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](https://go.dev/doc/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](https://github.com/dgryski/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:

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

Source provenance for every claim in this skill:

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

