CPU Caches
Why the same loop can run ~8× faster just by visiting memory in a different order: the cache hierarchy, 64-byte cache lines, locality, prefetching, coherence and false sharing — measured on a real machine, in Go and JavaScript.
The 30-second version
A modern CPU core ticks about 3 billion times a second, so one clock cycle is roughly 0.3 nanoseconds (a nanosecond, ns, is a billionth of a second). Fetching one value from RAM takes around 100 ns. That's about 300 cycles of waiting for a single read — time in which the core could have done hundreds of additions.
This gap is called the memory wall. Over the decades CPUs got far faster; RAM got bigger and cheaper, but not much quicker to answer. So chip makers put small, very fast memories right next to each core: the CPU caches.
Here are the rough numbers. Flip the toggle to scale them up a billion times, so that one L1 read takes one second:
Look at the gap between L1 and RAM: a 100× difference, and in the human-scaled version, one second versus almost two minutes. Every time your code reads something that isn't in a cache — a cache miss — the core mostly sits and waits.
What you'll learn
- How the caches are organised, and why memory moves in 64-byte cache lines (§02).
- Locality: why the order you visit data in can change speed by 7× (§03–§07), with real measurements in Go and JavaScript.
- What happens when several cores write to the same memory: coherence and false sharing (§08–§09).
- How this changes which data structures you pick (§10), the staff-level extras — branch prediction, NUMA, the TLB (§11) — and how to measure, and when to care (§12).
L1, L2, L3 and the 64-byte cache line
Three layers
When a core needs a value, it checks the closest cache first and works outward. The first one that has it answers — a hit. If none do, the value comes from RAM, and copies are left in the caches on the way back, in case it's needed again soon.
48 KB
32 KB
48 KB
32 KB
48 KB
32 KB
48 KB
32 KB
/sys/devices/system/cpu/cpu0/cache on a 4-core Intel Xeon virtual machine. Each core has its own L1 and L2; all four share one L3. Data moves between every layer in 64-byte cache lines. Smaller = closer = faster.These are the cache levels:
- L1 is split in two: L1d for data and L1i for instructions (your compiled code). It's tiny and answers in a few cycles.
- L2 is private to one core too, bigger and a few times slower.
- L3 is shared by every core on the chip. It's the last stop before RAM.
You can read your own machine's sizes. This is the real output from the machine that ran this page's benchmarks:
$ lscpu | grep -i cache
L1d cache: 192 KiB (4 instances)
L1i cache: 128 KiB (4 instances)
L2 cache: 8 MiB (4 instances)
L3 cache: 260 MiB (1 instance)
$ cd /sys/devices/system/cpu/cpu0/cache
$ for i in index*; do echo "L$(cat $i/level) $(cat $i/type) $(cat $i/size) ways=$(cat $i/ways_of_associativity) line=$(cat $i/coherency_line_size)"; done
L1 Data 48K ways=12 line=64
L1 Instruction 32K ways=8 line=64
L2 Unified 2048K ways=16 line=64
L3 Unified 266240K ways=20 line=64“4 instances” means one per core: 192 KB of L1d in total is 48 KB per core. The 260 MB L3 is what this cloud virtual machine reports; it's shared with other customers' machines, so the amount we really get is smaller (you'll see that in §07). On a Mac, sysctl -a | grep cachelinesize and sysctl hw.l1dcachesize give similar answers.
The cache line: memory moves in chunks
Caches don't store single bytes or single variables. They store cache lines: 64-byte blocks of memory that each start at an address that's a multiple of 64 (the last field above, line=64). Ask for one int64 (8 bytes) and the CPU fetches the whole 64-byte line around it — its 7 neighbours come along for free.
That's the library trip from §01: you bring back the whole chapter. Two consequences drive the rest of this topic:
- Reading neighbours is nearly free. If your next read is in the same line, it's an L1 hit. (§03)
- Cores share whole lines, not variables. If two cores write to different variables in the same line, the hardware still treats it as sharing. (Locks §07 showed one counter's line bouncing between cores; §08–§09 here explain exactly why it happens and how to measure it.)
What gets thrown out
Caches are small, so loading a new line usually means throwing an old one out — eviction. CPUs evict roughly the least recently used line. That's why data you use again soon tends to stay cached, and data you touched once, long ago, doesn't.
Locality: why order matters
Those two bets have names:
- Spatial locality — nearby in space. You read
a[0], thena[1], thena[2]… The 64-byte cache line (§02) already brought them in. - Temporal locality — nearby in time. You read the same thing again soon: a loop counter, a lookup table, a config. It's still in cache because it was used recently.
The classic: summing a grid
A 2D grid in Go — a slice of slices, [][]int64 — stores each row as one contiguous block of memory. That layout is called row-major: all of row 0, then all of row 1, and so on. Now sum it two ways:
func sumRows(g [][]int64) (s int64) {
for r := 0; r < len(g); r++ {
for c := 0; c < len(g[r]); c++ {
s += g[r][c] // ← next read is the neighbour: same cache line
}
}
return s
}
func sumCols(g [][]int64) (s int64) {
for c := 0; c < len(g[0]); c++ {
for r := 0; r < len(g); r++ {
s += g[r][c] // ← next read is one whole row (32 KB) away
}
}
return s
}Both functions do exactly the same 16.7 million additions on a 4096 × 4096 grid (128 MB). Only the order differs. Before reading the numbers, watch what each order does to a cache on a small grid:
Row order misses once per line and gets 7 hits for free. Column order lands on a different line every read, and by the time it comes back to a line, it's been evicted. Here's the real thing:
func BenchmarkSumRows(b *testing.B) {
for b.Loop() { // Go 1.24+: runs the body enough times to time it
sink = sumRows(grid) // store the result so the compiler can't delete the work (§12)
}
}
// BenchmarkSumCols is the same with sumCols.$ go test -bench 'Sum(Rows|Cols)'
BenchmarkSumRows-4 42 25250921 ns/op
BenchmarkSumCols-4 6 194562810 ns/op~25 ms vs ~195 ms on this machine: almost 8× slower, for the same additions on the same data. (-4 is the number of cores Go used; the middle column is how many times it ran the loop. Across several runs the column version ranged from ~165 to ~310 ms — this machine was busy.)
JavaScript has the same physics. A Float64Array is one flat block of numbers, so we store row r starting at index r * N:
const N = 4096;
const grid = new Float64Array(N * N); // one contiguous block, row-major
for (let i = 0; i < grid.length; i++) grid[i] = i % 10;
function sumRows() {
let s = 0;
for (let r = 0; r < N; r++)
for (let c = 0; c < N; c++) s += grid[r * N + c]; // ← neighbour
return s;
}
function sumCols() {
let s = 0;
for (let c = 0; c < N; c++)
for (let r = 0; r < N; r++) s += grid[r * N + c]; // ← 32 KB jump
return s;
}
for (const f of [sumRows, sumCols]) {
f(); // warm up, so V8 compiles it to fast code first
const t = performance.now();
f();
console.log(f.name, (performance.now() - t).toFixed(0), "ms");
}$ node grid.mjs
sumRows 45 ms
sumCols 161 msAbout 3.6× slower in Node 22 on this machine. The rule doesn't care about the language: make the inner loop walk memory in order.
Pointer chasing: slices vs linked lists
A linked list stores each value in its own small object, a node, together with a Pointer to the next node:
type Node struct {
Val int64
Next *Node // address of the next node, or nil at the end
}
func sumList(head *Node) (s int64) {
for n := head; n != nil; n = n.Next { // ← must load n before we know n.Next
s += n.Val
}
return s
}
func sumSlice(vals []int64) (s int64) {
for _, v := range vals { // ← next address is simply "+8 bytes"
s += v
}
return s
}The difference that matters is in the comments. For the slice, the CPU knows the next address in advance (it's just 8 bytes further), so the prefetcher — a circuit that loads lines ahead of you, more in §06 — can fetch lines before the loop gets there. For the list, the next address is inside the node you're still waiting for.
Misses can wait together — unless they depend on each other
To see why that matters, you need one fact about CPUs: a core doesn't stop at a cache miss. Thanks to out-of-order execution, it keeps going with later instructions that don't need the missing value. If those include more reads whose addresses it already knows, it sends those requests too. A modern core can have 10 or more RAM requests in flight at once, so their ~100 ns waits overlap. That's called memory-level parallelism.
Pointer chasing takes that away: the next address is only known when the current read arrives, so there's nothing else to send. Press play and compare the two lanes:
Following a chain like this is called pointer chasing, and it forces the misses to happen strictly one after another. Here's what that looks like in memory:
Measured
Four million int64s, summed three ways on this machine. “In order” means the nodes were allocated one after another, so they happen to sit next to each other in memory. “Shuffled” is the same list after its nodes were linked in random order — which is what a long-lived list looks like after many inserts and deletes:
Even the in-order list is ~10× slower than the slice: nodes are twice as big (value + pointer), and each step still has to wait for the previous load before it knows where to go next.
The everyday version: []*T
You rarely write linked lists, but you write this all the time:
users := []*User{} // each element: 8-byte address
for _, u := range users {
total += u.Balance // ← jump to wherever *u lives
}users := []User{} // elements stored inline
for i := range users {
total += users[i].Balance // ← next one is right after
}A []*User is a contiguous array of addresses; the User structs themselves live wherever they were allocated on the Heap. Walking it is a mild form of pointer chasing. A []User keeps the structs themselves side by side.
JavaScript arrays of objects are the same story: the array holds references, and each object lives on the heap. V8 often places objects allocated together near each other, so a freshly built array is usually fine — but there's no guarantee once the garbage collector has moved things around. For big numeric data, typed arrays (§05) give you real contiguity.
Data layout: struct of arrays, hot/cold fields, padding
Array of structs vs struct of arrays
The natural way to store a million particles in a game or simulation is a slice of structs:
// Array of structs (AoS): each element holds every field.
type Particle struct {
X, Y, Z float64
VX, VY, VZ float64
Mass float64
Alive bool
} // 7×8 + 1 = 57 bytes, padded to 64
var ps []Particle
// Struct of arrays (SoA): one slice per field.
type Particles struct {
X, Y, Z []float64
VX, VY, VZ []float64
Mass []float64
Alive []bool
}Both hold the same data. They differ in what sits next to what in memory — the topic of AoS vs SoA. Now a loop that only needs one field, say to compute the average X position:
var s float64
for i := range ps { s += ps[i].X } // AoS: X is 8 bytes out of every 64
for _, x := range p.X { s += x } // SoA (p is a Particles): nothing but XsToggle between the two layouts to see what each loop drags through the cache:
Particle is 64 bytes, so every element fills a whole line. To read 1 X the CPU must fetch all 8 fields — 7/8 of the memory traffic is wasted.The measured numbers on this machine (1,048,576 particles, sum of every X):
$ go test -bench 'SumX'
BenchmarkAoSSumX-4 373 3170259 ns/op
BenchmarkSoASumX-4 1880 612061 ns/opThat's ~3.2 ms vs ~0.61 ms (over several runs, AoS ranged 2.7–4.6 ms; SoA stayed at ~0.6 ms).
About 5× faster with SoA, because the loop moves 8 MB through the caches instead of 64 MB. The same thing in JavaScript: an array of objects versus one Float64Array per field.
const P = 1 << 20; // 1,048,576
const objs = Array.from({ length: P }, (_, i) =>
({ x: i, y: i, z: i, vx: i, vy: i, vz: i, mass: i }));
const xs = new Float64Array(P).map((_, i) => i); // just the x column
function sumObjs() { let s = 0; for (let i = 0; i < P; i++) s += objs[i].x; return s; }
function sumXs() { let s = 0; for (let i = 0; i < P; i++) s += xs[i]; return s; }
// warmed up, then averaged over 10 calls: sumObjs ~4.4–5.7 ms, sumXs ~1.0–1.1 ms (Node 22, this machine)This way of thinking — design the data around how the hot loops read it — is called data-oriented design. Game engines (entity-component systems), columnar databases (§10) and machine-learning libraries are built on it.
Field order and padding in Go
A smaller struct also means more per cache line. Go lays out fields in the order you write them, and adds padding so that each field starts at an address that's a multiple of its size (on 64-bit machines, an int64 must start on an 8-byte boundary):
type Bad struct {
Active bool // 1 byte
ID int64 // 8 bytes
Admin bool // 1 byte
Score int64 // 8 bytes
}
type Good struct {
ID int64
Score int64
Active bool
Admin bool
}
fmt.Println("Bad: ", unsafe.Sizeof(Bad{}))
fmt.Println("Good:", unsafe.Sizeof(Good{}))
fmt.Println("offset of Bad.ID:", unsafe.Offsetof(Bad{}.ID))$ go run sizes.go
Bad: 32
Good: 24
offset of Bad.ID: 8int64 must start at an address that's a multiple of 8, so after a 1-byte bool the compiler inserts 7 bytes of padding (striped). Grouping the small fields at the end lets them share one padded tail. Sizes are real unsafe.Sizeof output on amd64.The rule: order fields from largest to smallest and padding mostly disappears. A million Bads is 32 MB; a million Goods is 24 MB — 25% less memory to move. The fieldalignment analyzer (from golang.org/x/tools) can find such structs for you.
Don't reorder everything on sight, though: the order you write fields in is also documentation. Do it for structs you keep millions of, or that sit in a hot loop.
Prefetching: why big arrays can still be fast
Here's a puzzle from §03. The row-order sum read a 128 MB grid — way bigger than L1 or L2 — yet averaged about 1.5 ns per number. If every new cache line cost a full RAM trip (~100+ ns), that would be impossible: 100 ns ÷ 8 numbers per line is already 12 ns each.
Part of the answer is memory-level parallelism from §04: the loop's addresses are known in advance, so many misses wait at the same time. The other part is the hardware prefetcher. It watches the stream of misses, notices “line 1000, 1001, 1002…”, and starts requesting 1003, 1004, 1005 while your loop is still busy with 1002. The waiting overlaps with the work, so the loop is limited by memory bandwidth (how many bytes per second can flow) instead of latency (how long one read takes).
Measured: what stride costs
To see the prefetcher at work, read every k-th element of a 256 MB []int64 in a single pass, and divide the time by the number of reads:
var sink int64 // package-level, so the compiler can't drop the loop (§12)
func pass(big []int64, stride int) {
var s int64
for i := 0; i < len(big); i += stride { // stride is in elements; ×8 = bytes
s += big[i]
}
sink += s
}How to read it, left to right:
- 8 → 64 bytes: the cost per read grows because fewer reads share each line. At 8 bytes, 8 reads split one line's cost; at 64 bytes, every read pays for a whole line by itself.
- At 64 bytes every read needs a fresh line — yet it's only ~5 ns, not 100+. The prefetcher is loading ahead.
- Beyond ~256 bytes it plateaus around 10–17 ns. Each read now costs about one full line transfer, but many transfers are in flight at once (the addresses are known in advance, and the prefetcher helps too), so each read pays only a fraction of the ~100 ns RAM latency.
Random isn't the problem. Dependent is.
It's tempting to conclude “sequential = fast, random = slow”. The real dividing line is whether the CPU knows the next address before the current read finishes. Here are four ways of reading the same 256 MB array on this machine:
for i := range big { s += big[i] } // sequential
for i := 0; i < len(big); i += 8 { s += big[i] } // fixed stride, one read per line (the chart above)
for _, k := range idx { s += big[k] } // independent random: idx is a pre-shuffled list of positions
for i := 0; i < hops; i++ { p = big[p] } // dependent random: each value is the next positionSo a loop that gathers many unrelated positions from a big array is much less bad than you might fear — around 20 ns per miss here — while walking a linked list or a tree through RAM really does pay the full ~100–250 ns on every hop.
Hash maps sit in between. One lookup is several reads in a row — find the key's group, then compare keys, then read the value — and each step needs the previous one's result, plus a hard-to-predict branch or two. Measured on this machine: random lookups in a Go 1.24 map[int64]int64 with 8 million entries took ~80–100 ns each. Still cheaper than a full pointer chase per lookup, but ~4× the array gather.
The practical lesson is the same as §03, one level up: sequential is king. A plain loop over a slice is the fastest way to visit a lot of data, even data far bigger than the cache. And when access must be random, keep the reads independent — gather the positions first, then read — instead of chaining them through pointers.
Working set: the cache staircase
The memory a piece of code keeps touching is its working set. A loop over a 20 KB lookup table has a 20 KB working set: it fits in L1, so every read is ~1 ns. Grow the table to 20 MB and the very same code reads from L3 or RAM.
Measuring the staircase
To see each cache level's full latency, we need reads that can't be prefetched or overlapped (§04, §06) — otherwise several misses would wait together and hide it. So we make every read dependent on the previous one: build one random cycle through the array, where each 64-byte line stores the index of the next line to visit. Then just follow it:
// Setup (not shown): shuffle the line numbers 0..n-1, then make each line
// point to the next one in the shuffled order, and the last back to the first.
// buf[p*16] is the next line's index; 16 uint32s = 64 bytes = one line per hop.
p := uint32(0)
for i := 0; i < hops; i++ {
p = buf[p*16] // ← can't start until the previous load finishes
}This is pointer chasing (§04) on purpose: each address depends on the previous read, so we time one full miss at a time. Repeat for arrays from 4 KB to 512 MB and plot the time per hop:
The steps line up with the cache sizes from §02:
| Array size | ns per dependent random read | Where most reads are served |
|---|---|---|
| 4–32 KB | ~1.5 | L1 (48 KB) |
| 64 KB – 1 MB | ~5 | L2 (2 MB) |
| 2–16 MB | ~40–70 | L3 |
| 32 MB and up | ~130–310 | RAM |
(Best of 3 runs on this machine. The L2 step comes a little before 2 MB because the array shares L2 with everything else the program touches.)
These are worst-case, one-miss-at-a-time numbers: right for linked lists, trees and hash chains. Independent reads overlap their misses, so they pay much less — §06 measured ~21 ns per independent random read against ~240 ns per dependent one in the same 256 MB array. The positions of the steps are the same either way; only their height changes.
What this means for your code
- Small and hot beats big and clever. A 64 KB lookup table that fits in L2 can beat a “smarter” algorithm whose 50 MB of data lives in RAM.
- Shrink the element, grow the hit rate. Halving your struct size (§05) doubles how many fit in each cache level.
- Look at the cliff, not the average. A service whose hot data is 1.5 MB runs happily; let it grow to 3 MB and pointer-heavy operations can get ~8× slower overnight, with no code change.
Cache coherence: MESI in plain words
Every core has its own L1 and L2 (§02). So what happens when core 1 changes x while core 2 has an old copy of x in its cache? Without help, core 2 would keep reading the stale value forever.
The CPU prevents this in hardware with cache coherence: a protocol that tracks, for every line in every core's cache, who has a copy and whether it's been changed. The textbook version is MESI, named after the four states a line can be in, from one core's point of view:
I'm the only one
We all have it
Don't trust it
The rule that follows: a write needs exclusive ownership. To write a Shared line, a core sends an “invalidate” message to all the others and waits until they've dropped their copies. Step through it:
x = 0 lives in RAM, in some 64-byte cache line. Neither core has a copy yet, so both see the line as Invalid (I).Why this matters to you
Coherence is automatic and always correct; you never call it. But it has a cost model you can design around:
- Many readers, no writers: free. Every core keeps a Shared copy. That's why read-only data (a config loaded once, a lookup table) scales perfectly across cores.
- One writer, mostly alone: cheap. The line stays Modified in that core.
- Several cores writing the same line: expensive. Each switch of writer moves the line between cores — tens of nanoseconds instead of one. This is the cache-line ping-pong you met in Locks §07, and it's why a single hot atomic counter or mutex stops scaling.
The surprising part — that this happens even when the cores write different variables — is §09.
False sharing, measured
Locks §10 warned about this in one paragraph. Now that you know MESI (§08), here's the full picture, with numbers.
The setup
Four goroutines each count events into their own counter. No counter is shared, so there's no race and no lock. It should scale perfectly across 4 cores:
const workers = 4
const perWorker = 5_000_000
type Adjacent struct {
n [workers]atomic.Int64 // 4 × 8 bytes = 32 bytes: one cache line
}
func BenchmarkAdjacent(b *testing.B) {
for b.Loop() {
var c Adjacent
var wg sync.WaitGroup
for w := range workers {
wg.Add(1)
go func() {
defer wg.Done()
for range perWorker {
c.n[w].Add(1) // ← each goroutine touches only its own counter
}
}()
}
wg.Wait()
}
}All four counters sit inside one 64-byte line. Every Add needs that line in Modified state (§08), which steals it from whichever core had it. The cores spend most of their time waiting for the line to come back. Run the toy model:
c[1], core 2 needs the line in Modified state, which invalidates everyone else's copy (§08). So the cores take turns owning the line, and most of the time is spent moving it. They never touch each other's counter — the sharing is “false” — but the hardware only tracks whole lines.The fix: give each counter its own line
type paddedInt struct {
atomic.Int64 // the counter (8 bytes)
_ [56]byte // filler: 8 + 56 = 64 bytes, one cache line each
}
type Padded struct {
n [workers]paddedInt // c.n[w].Add(1) works exactly the same
}$ go test -bench . -count 3
BenchmarkAdjacent-4 4 271592000 ns/op
BenchmarkAdjacent-4 4 321379632 ns/op
BenchmarkAdjacent-4 4 302258801 ns/op
BenchmarkPadded-4 20 54321745 ns/op
BenchmarkPadded-4 24 56908313 ns/op
BenchmarkPadded-4 27 46028901 ns/op~300 ms vs ~54 ms: about 5.5× faster, from adding 224 bytes of nothing (4 × 56). And it gets worse the more cores fight. An earlier run on this machine with 1, 2 and 4 workers:
(Each worker always does 5 million adds, so with perfect scaling every bar would stay at ~34 ms. This machine was also running other jobs, so “padded” doesn't reach the ideal.)
Where it hides in real code
- Per-worker stats in an array or slice:
stats []WorkerStatswhere each worker updatesstats[i]. Small structs → several per line. - Sharded locks:
[16]struct{ mu sync.Mutex; m map[K]V }from Locks §10 — each shard is only 16 bytes, so 4 shards share each line. - A hot field next to a read-mostly field in one struct: a request counter that's incremented constantly sits next to a config pointer every goroutine reads. The reads now keep missing, because every increment invalidates their copies.
The other fix is not to share at all: let each goroutine count in a local variable and add it to the shared total once at the end — the “one counter per core” idea from Locks §07. A local lives in a register or on that goroutine's own stack, never next to another core's data.
JavaScript: only with real shared memory
Plain JavaScript can't false-share, because each worker thread has its own memory and messages are copied (Threads & Processes). It appears the moment you use a SharedArrayBuffer + Atomics:
const PAD = 16; // 16 × 4 bytes = 64 bytes
const shared = new Int32Array(new SharedArrayBuffer(4 * 4 * PAD));
// in worker number `id` (0–3):
Atomics.add(shared, id * PAD, 1); // ← counters 64 bytes apart: one line each
// Atomics.add(shared, id, 1); // ← all four in one line: false sharingSame physics, same fix: space the indices one cache line apart. (How to share the buffer with workers and use Atomics safely is in Locks §09.)
Choosing data structures with caches in mind
Small N: a slice can tie a map
A hash map lookup is O(1): hash the key, jump to a slot. A linear scan of a slice is O(n). So the map always wins? Here's a real benchmark looking up int keys on this machine (Go 1.24):
_, ok = m[key] // map[int]bool
ok = slices.Contains(keys, key) // linear scan of []int
_, ok = slices.BinarySearch(keys, key) // keys is sorted| Number of keys | map | linear scan | binary search |
|---|---|---|---|
| 8 | ~4 ns | ~4.4 ns | ~9 ns |
| 32 | ~6–9 ns | ~8.5 ns | ~9 ns |
| 128 | ~6 ns | ~28 ns | ~11 ns |
| 1024 | ~6–10 ns | ~230 ns | ~31 ns |
Two honest lessons:
- Up to a handful of keys, the slice ties. 8
ints are 64 bytes: one cache line. Scanning it costs about as much as hashing. If the data is tiny and mostly read, a sorted slice is simpler, uses less memory and iterates in a fixed order. - Go's map is very cache-friendly. Since Go 1.24, maps are “Swiss tables”, which keep keys in compact groups and check 8 slots at once. The linear scan loses badly by 128 keys. Don't replace maps with slices on a hunch — measure.
Why databases use B-trees, not binary trees
A binary search tree holding a million keys is about 20 levels deep. Every level is a separate node somewhere on the heap — 20 rounds of pointer chasing (§04), most of them cache misses.
A B-tree stores hundreds of sorted keys per node. The same million keys fit in a tree only 3 levels deep. Inside each node the keys are contiguous, so searching them reads neighbouring cache lines (or neighbouring bytes of one disk page) — cheap. You trade a few extra comparisons for far fewer jumps.
That's exactly why databases use them on disk (Indexes §03), and why in-memory B-tree libraries (like github.com/google/btree in Go) often beat red-black trees for big sorted sets.
Sequential beats random, all the way down
The same idea repeats at every level of the storage stack, with bigger numbers. Disks and SSDs, like caches, are much faster at Sequential vs random I/O than at jumping around:
- LSM trees turn random writes into sequential ones: buffer in memory, then write sorted files in one long stream. That's the whole design of LSM Trees.
- Columnar formats (Parquet, ClickHouse, DuckDB) are SoA (§05) for tables: columnar storage keeps each column contiguous, so
SELECT avg(price)streams one column through the cache and skips the rest.
Staff-level: branch prediction, the TLB and NUMA
ifs, the TLB caches address translations, and on big servers memory has an address and a location. Predictable code and data keep all three guesses right.Branch prediction: sorted data is faster data
A core works on many instructions at once, like an assembly line. When it reaches an if, it doesn't wait to find out which way it goes: branch prediction guesses, and the core keeps going down that path. Guess right: free. Guess wrong: throw away the half-done work and restart — roughly 15–20 cycles.
Predictors learn patterns. If the condition follows a pattern (“always true”, “false, then true from here on”), they're nearly always right. If it's random, they're wrong about half the time. The same loop over the same numbers, sorted vs not:
func countBig(v []int64) (s int64) {
for _, x := range v {
if x >= 128 { // ← random data: a coin flip the predictor can't learn
s += x
s ^= s << 1 // a bit of extra work (see the callout below)
}
}
return s
}About 5× faster, from sorting. The general lesson: grouping similar items (all the active ones, then the inactive ones) helps the branch predictor and the cache.
The TLB: a cache for addresses
Your program's addresses are virtual: the CPU translates each one to a physical address through the page table, page by page (pages are usually 4 KB). Doing that lookup on every access would be slow, so the CPU caches recent translations in the TLB — typically a few thousand entries, covering only a few MB of 4 KB pages.
So jumping around a big working set misses twice: once in the data cache, once in the TLB. That's part of why the staircase in §07 kept rising past ~16 MB. The fixes are the same as for caches — touch fewer pages, more sequentially — plus huge pages (2 MB instead of 4 KB, so each TLB entry covers 512× more). Linux can use them automatically (“transparent huge pages”) for big heaps. The full story of pages and translation is in Virtual Memory §04, and huge pages and NUMA from the OS side in Virtual Memory §11.
NUMA: memory has a location
On a laptop, all RAM is equally far from every core. Big servers often have two or more CPU sockets, each with its own RAM attached. A core reading memory attached to its own socket is fast (“local”); reading memory attached to the other socket goes over a link between the chips and is noticeably slower (“remote”, often ~1.5–2× the latency). That's NUMA.
$ lscpu | grep -i numa # the machine used for this page: one node, so no NUMA effects
NUMA node(s): 1
NUMA node0 CPU(s): 0-3On a multi-socket box, numactl --hardware shows the nodes and the relative distances between them. What changes in practice:
- Linux allocates memory on the node of the core that first touches it (“first touch”). Data initialised by one thread and then processed by threads on the other socket is remote for all of them.
- Databases and JVM-style runtimes have NUMA settings (interleave memory, pin threads per socket). Go and Node don't do anything NUMA-specific; the usual production answer is to run one process per socket, pinned with
numactl --cpunodebind=0 --membind=0 ./appor with container CPU sets, or simply to use smaller VMs that fit in one socket. - Cloud VMs larger than one socket expose NUMA too. If a 96-core VM scales worse than two 48-core ones, check
lscpu.
Pitfalls, measuring, and when to care
Five design pitfalls
Walking a grid in the wrong order
Inner loop over rows of a row-major grid: every read lands on a new cache line. Swap the loops (§03). Same for “for each field, for each record” over a []Struct.
[]*T for big collections of small structs
Each element is a pointer to chase (§04). Use []T, or keep an index into a []T instead of a pointer, when you loop over many of them.
Fat structs in hot loops
A loop that reads two fields drags the other twenty through the cache. Split hot from cold fields, or go struct-of-arrays (§05); order fields large → small to cut padding.
Per-goroutine data packed side by side
counts [N]int64 or stats []Stats updated by different goroutines: false sharing (§09). Count locally and merge once, or pad to a cache line — after a benchmark shows it.
Tuning with toy-sized data
A benchmark with 1,000 elements runs entirely in L1 and tells you nothing about production's 50 MB (§07). Benchmark at realistic sizes, and at a couple of sizes around the cache boundaries.
Three ways benchmarks lied while writing this page
All three happened for real while measuring the numbers in this topic.
The compiler deleted the work
The first stride benchmark (§06) reported 0.26 ns per read at every stride — even reading one value per 8 KB of a 256 MB array. Impossible. The sum was stored in a local variable that was never used afterwards, so the compiler's dead-code elimination removed the reads. Writing the result to a package-level sink variable gave the real curve (1.1 ns → ~16 ns). In Go 1.24+, for b.Loop() also keeps the compiler from optimizing away calls inside the loop body.
Memory that was never written
The first struct-of-arrays benchmark (§05) showed AoS and SoA almost equal (~0.70 vs ~0.63 ms). The slices came straight from make and were never written. The likely reason: Linux hands out fresh memory lazily, and until a page is first written, reading it can be served from one shared page of zeros (Virtual Memory §05) — so the 64 MB “array” behaved like 4 KB that was always cached. Filling the slices with values first gave the real ~5× gap. Always initialise benchmark data, ideally with realistic values.
A busy machine
This page was measured on a shared virtual machine running other jobs (load average ~5 on 4 cores). The column-sum time ranged from 165 to 310 ms between runs. Run with -count 10 and compare with benchstat (from golang.org/x/perf), which tells you whether a difference is bigger than the noise.
Finding the hot loop: pprof
The Go Profiler tells you where time goes, not why. Cache misses show up as surprising amounts of time on lines that do almost nothing — a simple load or add:
$ go test -bench SumCols -run '^$' -cpuprofile cpu.out
$ go tool pprof -list 'sumCols' cpu.out
ROUTINE ======================== cc.sumCols in …/cache_test.go
1.01s 1.01s (flat, cum) 27.30% of Total
. . 38: for c := 0; c < len(g[0]); c++ {
970ms 970ms 39: for r := 0; r < len(g); r++ {
40ms 40ms 40: s += g[r][c](Real output, path shortened. -run '^$' matches no test names, so only the benchmark runs. This profile came from an earlier version of §03's code in a file called cache_test.go, where sumCols starts on line 36.) Notice that the time lands on line 39, the loop header, not on the load in line 40. Sampling profilers often charge a slow instruction's cost to a neighbouring line (“skid”). Read pprof's line numbers as “around here”. In Node, node --cpu-prof app.js or Chrome DevTools' Performance panel gives the same kind of view for JavaScript.
Finding out why: perf stat
On Linux, perf reads the CPU's hardware counters, which count cache misses, branch misses and instructions per cycle directly:
$ perf stat -e cycles,instructions,cache-references,cache-misses,branch-misses ./appThe machine used for this page doesn't have perf (and many cloud VMs and containers don't expose hardware counters at all), so there's no real output to show. What to look at when you can run it: instructions per cycle (below ~1 on a hot loop usually means it's waiting for memory), the cache-miss rate, and branch-misses. perf c2c goes further and points at cache lines bouncing between cores — the tool for confirming false sharing (§09).
When it matters — and when it doesn't
Worth it
Maybe
Not worth it
The habits that are always free — iterate slices in order, prefer []T over []*T for small structs in big collections, keep hot structs small, don't share written data between goroutines — are worth doing by default. Everything else waits for a profile.
Check yourself
Predict the answer before you click. Q1 is covered in §03, Q2 in §09, Q3 in §04, Q4 in §05, Q5 in §07 and Q6 in §12.
grid is a 4096×4096 [][]int64 (128 MB). Both loops compute the same sum.
// A
for r := 0; r < 4096; r++ {
for c := 0; c < 4096; c++ { s += grid[r][c] }
}
// B
for c := 0; c < 4096; c++ {
for r := 0; r < 4096; r++ { s += grid[r][c] }
}Four goroutines each do c[i].Add(1) 5 million times on their own element of var c [4]atomic.Int64. What happens if you pad each counter to 64 bytes?
Summing 4 million int64s takes ~1.8 ms from a slice and ~750 ms from a linked list whose nodes were shuffled in memory. What's the main reason?
type A struct { ok bool; id int64; on bool }
type B struct { id int64; ok bool; on bool }You time dependent random reads (a pointer chase, as in §07) in an array as it grows from 4 KB to 512 MB on a CPU with a 48 KB L1 and 2 MB L2. Between which sizes do you expect the first big jump in ns/read?
This loop over a 256 MB slice reports 0.26 ns per element, whatever the stride. What happened?
var s int64
for i := 0; i < len(big); i += stride {
s += big[i]
}
// s is never used after thisCheat sheet
[]*T make misses wait one by one instead of together. Prefer []T and wide nodes (B-trees).-count + benchstat, check the assembly.| Symptom | Likely cause | Fix |
|---|---|---|
| Same loop, different order, very different speed | Traversal against the memory layout | Inner loop over contiguous memory (§03) |
Iterating a list/tree/[]*T is slow | Pointer chasing | []T, indices instead of pointers, B-trees (§04, §10) |
| Loop reads one field of big structs | Wasted bytes per cache line | Struct of arrays, hot/cold split (§05) |
| Fast in tests, slow in production | Working set outgrew a cache level | Benchmark at real sizes, shrink data (§07) |
| More goroutines → slower, no shared variable | False sharing | Per-goroutine locals; pad to 64 B (§09) |
if over random data is slow | Branch mispredictions | Sort or partition data; branch-free code (§11) |
| Big multi-socket server scales badly | NUMA remote memory | One process per socket, numactl (§11) |
| Tool | What it tells you |
|---|---|
lscpu, /sys/devices/system/cpu/cpu0/cache/ | Cache sizes and line size of this machine |
go test -bench . -count 10 + benchstat | Is the change faster, beyond the noise? |
go test -cpuprofile + go tool pprof -list | Which lines are hot (allow for skid) |
unsafe.Sizeof, fieldalignment | Struct size and padding |
go tool objdump -s Func | What the compiler really produced (branches, CMOVs) |
perf stat -e cache-misses,…, perf c2c | Hardware counters: cache misses, false sharing (Linux, where available) |
node --cpu-prof, Chrome DevTools Performance | Hot functions in JavaScript |