Memory Hierarchy and CPU Cache

The numbers everything else is built on

Every design decision in a storage engine is a negotiation between layers of a hierarchy whose speeds differ by orders of magnitude.

Layer Typical latency Relative to L1 Typical size
CPU register ~0.3 ns — ~1 KB
L1 cache ~1 ns 1x 32–64 KB per core
L2 cache ~4 ns 4x 256 KB – 2 MB per core
L3 cache ~10–20 ns 10–20x 8–100+ MB shared
Main memory (DRAM) ~80–100 ns ~100x GBs
NVMe SSD random read ~10–100 µs ~10,000–100,000x TBs
Network round trip, same datacenter ~500 µs ~500,000x —
HDD random read (seek) ~5–10 ms ~5,000,000x TBs

The exact numbers vary by hardware; the ratios are what matter. A single cache miss to DRAM costs about as much as 100 L1 hits, and a single SSD read costs about as much as 1000 DRAM accesses. That is why an index structure is judged by how many cache misses or disk reads it takes per lookup, not by how many comparisons — the comparison count that CP complexity analysis cares about is close to free next to the memory accesses.

graph TD reg["Registers (~0.3 ns)"] --> l1["L1 (~1 ns)"] l1 --> l2["L2 (~4 ns)"] l2 --> l3["L3 (~15 ns)"] l3 --> dram["DRAM (~100 ns)"] dram --> ssd["NVMe SSD (~10-100 µs)"] ssd --> hdd["HDD (~5-10 ms)"]

Cache lines: memory moves in 64-byte chunks

The CPU never reads one byte from DRAM. It reads a cache line — 64 bytes on x86 and most ARM servers (128 bytes on Apple M-series). Reading a[0] brings a[0..15] (for int32) into L1; reading a[1] right after is then nearly free.

That single fact explains the most important performance rule in systems code: contiguous memory is fast, pointer chasing is slow.

Worked example: summing an array vs. a linked list

type node struct {
    v    int64
    next *node
}

func sumSlice(v []int64) (s int64) {
    for _, x := range v { // sequential: 8 values per 64-byte line, prefetched
        s += x
    }
    return
}

func sumList(n *node) (s int64) {
    for ; n != nil; n = n.next { // each node is a separate object: a pointer chase
        s += n.v
    }
    return
}

Both are O(n) with the same number of additions. Measured in Go on an Apple M1 Pro with 1 M elements (CPU and Cache Efficiency):

Layout Time per pass Relative
[]int64 0.43 ms 1x
Linked list, nodes allocated in order 1.37 ms 3.2x
Linked list, same nodes linked in shuffled order 28.4 ms 66x

The slice sum runs at memory bandwidth. The list is slower because each next pointer must be loaded before the CPU knows where the next node is, and once the nodes are scattered, every step is a cache miss. The CPU's hardware prefetcher recognizes sequential and strided access patterns and fetches lines before they are asked for; it cannot predict a pointer chase.

This is the reason:

  • []T beats []*T and linked structures in Go for anything iterated.
  • B+Trees have nodes the size of a disk page holding hundreds of keys (one miss brings in many keys) instead of binary nodes with two.
  • LSM SSTables store keys in sorted, densely packed blocks.
  • Skip lists are tolerated in memtables only because they enable lock-free concurrent reads; they are not cache-friendly.

Cache associativity, TLB, and huge pages

Caches are not fully flexible: an address can only be placed in a small set of slots (set-associative, e.g. 8-way or 12-way). Access patterns with a large power-of-two stride (every 4096 bytes, say) map to the same set and evict each other — a slowdown that looks mysterious until you know the cause.

Virtual addresses must also be translated to physical ones (see Virtual Memory and Paging). The TLB caches recent translations, but it has only a few thousand entries. With 4 KB pages, 1,536 TLB entries cover just 6 MB; a 50 GB block cache touched randomly misses the TLB on almost every access, each miss costing a page-table walk. Huge pages (2 MB) let each TLB entry cover 512x more memory, which is why databases often recommend enabling them for large buffer pools — and why many also recommend disabling Transparent Huge Pages, whose background compaction can cause latency spikes.

Writes and multiple cores: coherence and false sharing

Each core has its own L1/L2. When one core writes a cache line, every other core's copy of that line must be invalidated (the MESI coherence protocol). The line then bounces between cores' caches on every write — about as expensive as a DRAM access.

False sharing is when two threads write different variables that happen to sit on the same cache line:

type Counters struct {
    reads  atomic.Uint64 // written by goroutine A
    writes atomic.Uint64 // written by goroutine B — same cache line!
}

The two threads never touch each other's data, yet every increment invalidates the other core's line. The fix is to pad each hot variable onto its own line:

type Counters struct {
    reads  atomic.Uint64
    _      [120]byte // pad: 128 bytes covers both 64- and 128-byte lines
    writes atomic.Uint64
    _      [120]byte
}

Go has no alignment attribute; padding fields do the job. golang.org/x/sys/cpu.CacheLinePad is a portable padding type sized for the target architecture. Measured in Go with 8 goroutines each incrementing its own counter: 45x slower when the counters share lines than when each is padded (CPU and Cache Efficiency). Go's runtime pads its own per-P structures for the same reason.

Data layout: array of structs vs. struct of arrays

// Array of structs (AoS): one entry = key + value + metadata together.
type Entry struct {
    Key, Seq uint64
    Value    [48]byte
}
var entries []Entry // binary search touches a 64-byte entry per probe

// Struct of arrays (SoA): keys contiguous, separate from payload.
var keys   []uint64 // binary search touches only keys
var seqs   []uint64
var values [][48]byte

A binary search over entries loads a full 64-byte entry per probe but uses only 8 bytes of it. Over keys, the last several probes land in the same few lines. Measured in Go, 10 million entries, random lookups with sort.Search: 494 ns per lookup over entries, 284 ns over keys — 1.7x faster with the same number of comparisons. This is the same idea behind a B+Tree node storing its keys in one array and its child pointers in another, and behind columnar formats (Parquet, ClickHouse) storing each column contiguously.

Branch prediction

Modern CPUs guess which way a branch goes and execute ahead speculatively; a wrong guess costs ~15–20 cycles. Branches that follow data with no pattern (comparing random keys) mispredict about half the time. This is why tight inner loops — Bloom filter probing, binary search within a block — are sometimes written branch-free, and why sorted data is often faster to process than unsorted data for the same operations. Compilers can remove simple branches entirely: in Go, a conditional add compiled to a branch-free CSEL ran at the same speed on sorted and random data, while a slightly more complex loop body that kept its branch ran 3.2x slower on random data (CPU and Cache Efficiency).

How to measure, not guess

# Linux: count cache misses and branch mispredictions for a benchmark run
perf stat -e cycles,instructions,cache-misses,LLC-load-misses,branch-misses \
  ./kv.test -test.run='^$' -test.bench=Get

Instructions per cycle (IPC) is the single most useful summary: an IPC near 3–4 means the CPU is busy computing; an IPC below ~0.5 means it is mostly waiting on memory. See CPU and Cache Efficiency.

Common pitfall

Optimizing the comparison count of a data structure while ignoring its memory layout is the classic CP instinct that fails in systems code. A theoretically better structure that chases pointers — a balanced binary tree, a hash table with chaining — routinely loses to a "worse" structure over contiguous memory, such as a sorted array with binary search, or an open-addressing hash table. Before choosing a structure, count the cache lines touched per operation, and confirm with perf stat rather than with Big-O.