Skip to content
Software Atlas

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.

⏱ ~45 min read14 sections8 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceYour CPU is much faster than your memory. Small, fast caches on the chip hide the difference — but only when your program reads memory in a way they can predict.

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:

The latency ladder
How long one read takes from each place. Bars are on a log scale: each step right is 10× longer. Click a row.
RAM (main memory) — like the library across town. ~300 cycles. The CPU can do hundreds of simple operations in the time one RAM read takes. This machine measured more for a dependent random read (one that can't overlap with others, §04), partly because those reads also missed in the address-translation cache (§11). (Measured on this machine, §07: ~200 ns.)
Typical round numbers for a modern server; real values vary by CPU, and every number here is approximate.

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

  1. How the caches are organised, and why memory moves in 64-byte cache lines (§02).
  2. Locality: why the order you visit data in can change speed by 7× (§03–§07), with real measurements in Go and JavaScript.
  3. What happens when several cores write to the same memory: coherence and false sharing (§08–§09).
  4. 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).
Foundations§02

L1, L2, L3 and the 64-byte cache line

In one sentenceEach core has its own small, fast caches (L1, L2); all cores share one big, slower one (L3). Data moves between them — and from RAM — in fixed 64-byte chunks called cache lines.

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.

The cache hierarchy of the machine these numbers were measured on
core 1
registers
L1d
48 KB
L1i
32 KB
L2 · 2 MB
core 2
registers
L1d
48 KB
L1i
32 KB
L2 · 2 MB
core 3
registers
L1d
48 KB
L1i
32 KB
L2 · 2 MB
core 4
registers
L1d
48 KB
L1i
32 KB
L2 · 2 MB
↕ 64-byte lines ↕
L3 · 260 MB (as reported by this VM) · shared by all cores
↕ memory bus ↕
RAM · 16 GB
Sizes from /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:

  1. Reading neighbours is nearly free. If your next read is in the same line, it's an L1 hit. (§03)
  2. 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.

Foundations§03

Locality: why order matters

In one sentenceCaches bet on two things: you'll soon use the bytes next to what you just read, and you'll soon use the same bytes again. Code that makes those bets come true runs fast.

Those two bets have names:

  • Spatial locality — nearby in space. You read a[0], then a[1], then a[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:

grid_test.go
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:

Same sum, two orders: watch the cache
A grid of 8×16 int64s (8 bytes each), stored row by row. One cache line = 8 numbers. The toy cache holds only 4 lines and evicts the least recently used one.
memory, row by rowoutlined = in cache
hitmiss (load a whole line)line in cachealready summed
reads
0/128
hit rate
—
hits
0
misses
0
cache (4 lines)
empty
empty
empty
empty
Press play. Reading row by row, the first read of each line is a miss — the CPU fetches all 8 numbers in that line at once — and the next 7 reads are free hits.
Illustrative: a real L1 holds hundreds of lines, but real grids are also far bigger — a 4096-wide row of int64s is 32 KB, so column order on a real 4096×4096 grid thrashes in exactly the same way.

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:

grid_test.go
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:

grid.mjs
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 ms

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

Practitioner§04

Pointer chasing: slices vs linked lists

In one sentenceA slice puts values side by side. A linked list scatters them and links them with pointers — and every pointer you follow can be a cache miss you have to wait for.

A linked list stores each value in its own small object, a node, together with a Pointer to the next node:

list.go
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:

Waiting in parallel vs waiting in line
Each bar is one read that misses all the caches and waits ~100 ns for RAM. Toy timeline: 600 ns in total.
independent reads: s += big[idx[i]]0 reads finished
The core already knows the next addresses, so it sends a new request every few ns without waiting for the earlier ones.
dependent reads: p = next[p]0 reads finished
The next address is inside the value still on its way, so each request can only start when the previous one lands.
time
0 ns
in flight (top)
1
requests waiting at once
finished
0 vs 0
—
Both lanes pay the same ~100 ns per read. The difference is that the top lane's waits overlap: about 10 requests are in flight at any moment. In the bottom lane, every wait happens alone. Measured on this machine (§06): ~21 ns per independent random read vs ~240 ns per dependent one in a 256 MB array.

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:

The same 7 numbers: in a slice vs in a linked list
[]int64 — contiguous0123456one 64-byte line = 8 values, fetched in one triplinked list — each node is wherever it happened to be allocated0123456each arrow can be a cache miss — and the next one can't start until this one finishes
Top: a slice is one block — the CPU reads 8 int64s per cache line, and the prefetcher (the circuit that loads lines ahead of you) sees the pattern and loads ahead. Bottom: list nodes live wherever the allocator put them. To find node 2 you must first finish loading node 1 to read its pointer — so these misses happen one after another, instead of overlapping like independent reads do.

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:

Sum 4 million int64s (Go, this machine, typical of 4 runs)
[]int64 slice~1.8 ms
linked list, in order~20 ms
linked list, shuffled~750 ms
Same values, same additions. The shuffled list is ~400× slower than the slice: each hop is a trip to RAM (~180 ns per node here), and unlike independent reads, the hops can't overlap.

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:

✕ Slice of pointers
users := []*User{}       // each element: 8-byte address
for _, u := range users {
    total += u.Balance   // ← jump to wherever *u lives
}
✓ Slice of values
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.

Practitioner§05

Data layout: struct of arrays, hot/cold fields, padding

In one sentenceEvery cache line you load should be full of bytes you're about to use. If a loop reads one field out of eight, lay the data out so that field's values sit together.

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:

particles.go
// 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 Xs

Toggle between the two layouts to see what each loop drags through the cache:

Summing every particle's X: what the CPU actually loads
Each row is one 64-byte cache line (eight 8-byte slots). Highlighted = bytes the loop uses. M = Mass, A = Alive (a 1-byte bool, padded to 8).
line 0
X0
Y0
Z0
VX0
VY0
VZ0
M0
A0
line 1
X1
Y1
Z1
VX1
VY1
VZ1
M1
A1
line 2
X2
Y2
Z2
VX2
VY2
VZ2
M2
A2
line 3
X3
Y3
Z3
VX3
VY3
VZ3
M3
A3
useful bytes / line
8 of 64
lines for 1M particles
1,048,576
measured (Go)
~3.2 ms
on this machine
Each 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/op

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

soa.mjs
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):

sizes.go
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: 8
Field order changes the size: 32 bytes vs 24 bytes
Bad: bool, int64, bool, int64 → 32 bytes
+0
Active
pad
+8
ID
+16
Admin
pad
+24
Score
Good: int64, int64, bool, bool → 24 bytes
+0
ID
+8
Score
+16
Active
Admin
pad
Each row is 8 bytes. An int64 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.

Practitioner§06

Prefetching: why big arrays can still be fast

In one sentenceIf your reads follow a predictable pattern, the CPU notices and starts fetching the next cache lines before you ask — so even a 256 MB array, far bigger than any cache, can be read at near-cache speed.

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:

stride/main.go
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
}
Measured: cost per read vs stride
One pass over a 256 MB []int64, reading every k-th element. Real Go measurements on this machine (median of 3 runs). Hover or tap a point.
051015201 cache line1 page (4 KB)81632641282565121K2K4K8Kstride (bytes between reads)ns per read
stride 64B → 4.88 ns/read. Every 8th int64 = exactly one read per cache line. Now every read pulls in a fresh line, yet it's still only ~5 ns — far below RAM's ~100 ns — because the prefetcher saw the pattern and requested lines ahead of time, and many requests are in flight at once.

How to read it, left to right:

  1. 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.
  2. At 64 bytes every read needs a fresh line — yet it's only ~5 ns, not 100+. The prefetcher is loading ahead.
  3. 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:

mlp/main.go (core loops)
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 position
ns per read, 256 MB []int64 (Go, this machine, 3 runs)
sequential~1.1 ns
stride 64 B~4.9 ns
independent random~21 ns
dependent random~240 ns
The independent random reads touch lines just as scattered as the dependent ones, but their addresses come from idx, so ~10 misses wait together. The dependent reads wait for each miss alone — the ~10× difference in steady-state rate that the §04 timeline shows (its finish counter ends lower, ~8.5×, because the first reads all take the full 100 ns).

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

Practitioner§07

Working set: the cache staircase

In one sentenceCode runs at the speed of the smallest cache its hot data fits in. Grow the data past a cache's size and every read suddenly costs the next level's price.

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:

ws/main.go (core loop)
// 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:

Measured: the cache staircase
Dependent random hops through an array of growing size. Each hop's address comes from the previous read, so nothing can be prefetched or overlapped: every miss is paid in full. Real Go measurements on this machine. Hover or tap a point.
L1L2L3RAM1310301003004K16K64K256K1M4M16M64M256Marray size (bytes, log scale)ns per dependent read (log scale)
array size
2MB
per dependent read
38.3 ns
mostly served by
L3
exactly L2's size — but L2 also holds other data, so it no longer fits
Band edges come from this machine's reported sizes (L1d 48 KB, L2 2 MB). The VM reports a 260 MB L3, but it's shared with other tenants and split into slices, so in practice the climb toward RAM speed starts much earlier. Above ~16 MB, address-translation misses (§11) add to the cost too.

The steps line up with the cache sizes from §02:

Array sizens per dependent random readWhere most reads are served
4–32 KB~1.5L1 (48 KB)
64 KB – 1 MB~5L2 (2 MB)
2–16 MB~40–70L3
32 MB and up~130–310RAM

(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.
Practitioner§08

Cache coherence: MESI in plain words

In one sentenceMany cores may read the same cache line at once. To write it, one core must first make every other copy invalid. Writes are where multi-core code pays.

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:

M · E

I'm the only one

Modified: only I have it, and I've changed it (RAM is out of date). Exclusive: only I have it, unchanged. Either way I can write it without asking anyone — L1 speed.
S

We all have it

Shared: other cores may have copies too. Everyone can read, at L1 speed. Nobody can write until the others give up their copies.
I

Don't trust it

Invalid: my copy is stale (or I never had one). The next read is a miss and fetches the line again — from RAM or straight from the core that has 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:

MESI: two cores, one cache line
Step through reads and writes and watch each core's copy change state.
core 1 · L1
line of xI
no usable copy
Invalid: stale or absent — don't use
start
core 2 · L1
line of xI
no usable copy
Invalid: stale or absent — don't use
L3 / RAM: x = 0line moves so far: 0
One variable 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).
1 / 8
Simplified: real CPUs use extended versions (Intel MESIF, AMD MOESI) and timings vary; the four core states and the ping-pong are the part that matters.

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.

Practitioner§09

False sharing, measured

In one sentenceThe hardware tracks ownership per cache line, not per variable. Two cores writing different variables in the same line still take turns — that's false sharing.

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:

counters_test.go
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:

Four goroutines, four counters, one cache line?
Each core only increments its own counter. Toy timing model: one increment = 1 tick, moving a line between cores = 4 ticks. Real hardware overlaps more, so the measured gap below is smaller (~5.5×) than this toy shows.
core 1
0
owns the shared line
core 2
0
doesn't own the line
core 3
0
doesn't own the line
core 4
0
doesn't own the line
memory (each box = one 64-byte cache line)
c[0]c[1]c[2]c[3]····
ticks
0
total increments
0
increments / tick
—
ideal would be 4
All four counters fit in one line. To write 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

counters_test.go
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:

5 million atomic adds per worker (Go, this machine)
1 worker~34 ms
2 workers, adjacent~150 ms
2 workers, padded~43 ms
4 workers, adjacent~360 ms
4 workers, padded~62 ms
With one worker there's nothing to share, so padding doesn't matter (~34 ms). With 4 workers, padded counters scale; adjacent ones get 10× slower than one worker alone.

(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 []WorkerStats where each worker updates stats[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:

counters.mjs
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 sharing

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

Practitioner§10

Choosing data structures with caches in mind

In one sentenceBig-O counts steps. Caches decide how much each step costs. For small inputs and for anything that walks data, the step cost often wins.

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):

lookup_test.go
_, 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 keysmaplinear scanbinary 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.

Pointer hops to find one key among 1,000,000 (counted, not timed)
binary tree (2-way)~20 hops
B-tree, 16 keys/node~5 hops
B-tree, 256 keys/node~3 hops
Each hop is a potential cache miss (in memory) or page read (on disk). Wide nodes turn 20 random jumps into 3.

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§11

Staff-level: branch prediction, the TLB and NUMA

In one sentenceCaches are one of several guesses the CPU makes about your program. Branch predictors guess your 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:

branch_test.go
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
}
countBig over 1M random values in 0–255 (Go, this machine)
unsorted~4.1 ms
sorted~0.81 ms
Identical values and work; only the order differs. Sorted: the branch is false for the first half and true for the second — trivially predictable.

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

On 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 ./app or 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.
Staff-level§12

Pitfalls, measuring, and when to care

In one sentenceCache effects are real but easy to mis-measure. Benchmark with realistic sizes, check the compiler didn't cheat, and only tune code a profile says is hot.

Five design pitfalls

1

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.

2

[]*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.

3

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.

4

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.

5

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.

6

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.

7

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.

8

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

The 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

Tight loops over lots of data: parsing, compression, serialization, analytics, image/audio processing, game loops, in-memory indexes and caches, hot counters shared by many goroutines. Here, layout changes give 2–10×.
?

Maybe

A Hot path handling thousands of requests per second, where a profile shows one data structure dominating. Measure before and after; keep the change only if it wins.
✗

Not worth it

Code that waits on a database, network or disk. One network round trip (~500 µs) is worth ~5,000 RAM misses. A typical web handler is bounded by I/O; fix the queries (N+1) first.

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.

Practitioner§13

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.

QUESTION 1 / 6
Which loop is faster?

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] }
}
QUESTION 2 / 6
Four goroutines, four counters

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?

QUESTION 3 / 6
Why is the linked list slow?

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?

QUESTION 4 / 6
Pick the smaller struct
type A struct { ok bool; id int64; on bool }
type B struct { id int64; ok bool; on bool }
QUESTION 5 / 6
Where's the cliff?

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?

QUESTION 6 / 6
The impossible benchmark

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 this
Answered 0 / 6
Foundations§14

Cheat sheet

🐢
RAM is far awayL1 ~1 ns, L2 ~4 ns, L3 ~15–40 ns, RAM ~100 ns (approximate). A miss to RAM costs hundreds of instructions' worth of time.
📦
64-byte linesMemory moves in cache lines (128 B on Apple M-series). Use every byte you pay for.
➡️
Walk in orderInner loop over contiguous memory. Row-major grid → column index in the inner loop.
🔮
Be predictableThe prefetcher streams sequential and fixed-stride access. Independent random reads overlap their misses (~20 ns here); dependent reads (pointer chasing) pay full latency (~240 ns).
🔗
Avoid pointer chasingLinked lists, trees and []*T make misses wait one by one instead of together. Prefer []T and wide nodes (B-trees).
🧱
Pack what's hotStruct of arrays for loops over one field; split hot/cold fields; order struct fields large → small.
📏
Mind the working setCode runs at the speed of the smallest cache its hot data fits in. Watch for cliffs as data grows.
🏓
Don't share writesWrites need line ownership (MESI). Shared counters and neighbouring per-core data ping-pong: count locally, or pad.
📐
Measure honestlyRealistic sizes, initialised data, results stored in a sink, -count + benchstat, check the assembly.
⚖️
Pick your battlesHot loops over lots of data: yes. Handlers waiting on I/O: fix the I/O first.
SymptomLikely causeFix
Same loop, different order, very different speedTraversal against the memory layoutInner loop over contiguous memory (§03)
Iterating a list/tree/[]*T is slowPointer chasing[]T, indices instead of pointers, B-trees (§04, §10)
Loop reads one field of big structsWasted bytes per cache lineStruct of arrays, hot/cold split (§05)
Fast in tests, slow in productionWorking set outgrew a cache levelBenchmark at real sizes, shrink data (§07)
More goroutines → slower, no shared variableFalse sharingPer-goroutine locals; pad to 64 B (§09)
if over random data is slowBranch mispredictionsSort or partition data; branch-free code (§11)
Big multi-socket server scales badlyNUMA remote memoryOne process per socket, numactl (§11)
ToolWhat it tells you
lscpu, /sys/devices/system/cpu/cpu0/cache/Cache sizes and line size of this machine
go test -bench . -count 10 + benchstatIs the change faster, beyond the noise?
go test -cpuprofile + go tool pprof -listWhich lines are hot (allow for skid)
unsafe.Sizeof, fieldalignmentStruct size and padding
go tool objdump -s FuncWhat the compiler really produced (branches, CMOVs)
perf stat -e cache-misses,…, perf c2cHardware counters: cache misses, false sharing (Linux, where available)
node --cpu-prof, Chrome DevTools PerformanceHot functions in JavaScript
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in OS & RuntimeSystem Calls
Read →