all_lessons/C++/08 · Data layout & the cachelesson 9 / 20

Data layout and mechanical sympathy: the cache

By Lesson 07 you can express ownership in the type — who frees a resource, and when, is now answered. This lesson answers a different question the high-level languages also hid: where, physically, does your data sit, and what does it cost to reach it? The new idea is mechanical sympathy — that the CPU is not a uniform-cost machine that treats every memory access the same. It has a layered memory system, and code that respects the layout of that system runs ten to a hundred times faster than code that ignores it, with identical big-O. This is the lesson that turns "where does the memory live?" from a correctness question into a performance one — and it is the reason a vector beats a list (Lesson 13).

The thesis, here
The cost model is not just allocations and copies — it is distance. The same instruction, load this byte, costs about 1 nanosecond if the byte is already in the L1 cache and about 100 nanoseconds if it must come from DRAM. C++ refuses to hide this: it lets you control exactly how your bytes are laid out, so you can keep the data the CPU wants next physically close to the data it has now. The transferable habit: for any data structure, ask not just "what's the big-O?" but "how does the CPU walk this in memory?"
Linear position
Prerequisite: Lesson 02 (an object is a typed region of storage with a known sizeof; the stack vs the heap) and Lesson 07 (ownership — so we can talk about a vector that owns a contiguous block vs a list of separately-owned nodes).
New capability: read a struct's true size including padding and shrink it by reordering fields; put rough latency numbers on each level of the memory hierarchy; explain why contiguous access is fast (cache lines + the prefetcher) and pointer-chasing is slow; and choose between array-of-structs and struct-of-arrays for a given access pattern.
The plan
Six moves. (1) Struct layout: alignment and padding, with a worked struct that shrinks from 24 to 16 bytes by reordering. (2) The memory hierarchy with concrete latency numbers. (3) The cache line — why reading one byte pulls in 64. (4) Why contiguous data beats pointer-chasing, and what the prefetcher does. (5) Array-of-structs vs struct-of-arrays, with a case where SoA wins. (6) The worked payoff: summing a contiguous array vs a linked list of the same length, and the cache-miss arithmetic — which is exactly why vector beats list.

1 · Struct layout: alignment and padding

In Lesson 02 you learned that every object is a typed region of storage with a size you can query with sizeof. A struct (a record type that groups several members) is laid out by placing its members in declaration order at increasing addresses. But the members are not packed tightly. Each type has an alignment requirement: a constraint that an object of that type must start at an address that is a multiple of some number (its alignment). On a typical 64-bit machine an int (4 bytes) must start at an address divisible by 4, a double or pointer (8 bytes) at one divisible by 8, a char (1 byte) anywhere. Hardware wants this because an aligned load fits in a single memory transaction; a misaligned one can take two.

To satisfy alignment, the compiler inserts unused bytes called padding between members (and at the end). Padding is dead weight — it inflates sizeof, wastes cache, and you usually never asked for it. The fix is free: reorder members from largest alignment to smallest, so each one falls naturally on its boundary with no gaps.

Worked example — shrink a struct by reordering
// As declared: char, double, int — alignment forces gaps.
struct Bad {
    char  a;   // offset 0,  1 byte
    // 7 bytes of PADDING here, so 'b' lands on an 8-aligned address
    double b;  // offset 8,  8 bytes
    int    c;  // offset 16, 4 bytes
    // 4 bytes of PADDING at the end, so an array of Bad keeps b aligned
};               // sizeof(Bad) == 24

// Reordered: largest alignment first. No internal gaps.
struct Good {
    double b;  // offset 0,  8 bytes
    int    c;  // offset 8,  4 bytes
    char   a;  // offset 12, 1 byte
    // 3 bytes of PADDING at the end -> total is a multiple of 8 (the
    //   largest member alignment), so arrays stay aligned.
};               // sizeof(Good) == 16
Same three fields, same data. Bad is 24 bytes; Good is 16 bytes — a 33% shrink, for free. In an array of a million of these, that is 24 MB vs 16 MB; the smaller one fits more elements per cache line (next sections) and is simply faster to walk. Why the trailing padding at all? Because in an array, element i+1 must also be aligned, so each element's size is rounded up to a multiple of its strictest member's alignment.

You can verify any of this: static_assert(sizeof(Good) == 16); compiles, static_assert(sizeof(Bad) == 24); compiles, and alignof(double) is 8. The rule to internalize: order struct members from largest to smallest alignment. It costs nothing and often shrinks the type.

2 · The memory hierarchy: not one memory, but five

A high-level language presents memory as a flat, uniform array of bytes — every access the same cost. That is a lie of convenience. Real hardware has a memory hierarchy: a stack of progressively larger and slower storage, each level a cache for the one below it. The CPU's registers (a few dozen named slots inside the core, where arithmetic actually happens) are fastest; then three levels of on-chip cache (L1, L2, L3 — small, fast SRAM holding recently-used data); then main memory, DRAM — large, cheap, and dramatically slower. Rough numbers for a modern desktop core:

Register
~0 ns
inside the core; the operands of an instruction. A handful of bytes.
L1 cache
~1 ns
~4 cycles. ~32 KB per core. The first place a load looks.
L2 cache
~4 ns
~256 KB–1 MB per core. Checked on an L1 miss.
L3 cache
~12 ns
several MB, shared across cores. Last stop before DRAM.
DRAM
~100 ns
gigabytes. ~100× slower than L1 — a full cache miss.

The headline: a load that hits L1 is roughly 100 times faster than one that misses all the way to DRAM. (These numbers vary by CPU; the ratios are the durable part.) Put it in human scale: if an L1 hit took one second, a DRAM access would take about a minute and a half. The CPU does not stand idle for that minute and a half if it can help it — but when it genuinely needs a value that is only in DRAM, it stalls, doing nothing useful while the bytes travel. Performance work is, to a first approximation, the work of avoiding those stalls. Big-O counts operations; mechanical sympathy counts where the operands were.

3 · The cache line: reading one byte pulls in 64

The cache does not move individual bytes. It moves cache lines — fixed-size blocks, almost universally 64 bytes, aligned to 64-byte boundaries. When you read a single char and it is not already cached, the CPU fetches the entire 64-byte line containing it from DRAM into L1. So one byte's worth of intent costs one line's worth of traffic — but the 63 neighbours come along for free, already in fast cache.

address: ... | line N (64 B) | line N+1 (64 B) | ... read a[0] --> miss, fetch whole line --> a[0..15] (if int) now in L1 read a[1] --> HIT (same line) ~1 ns read a[2] --> HIT (same line) ~1 ns ... read a[16] --> miss, fetch next line --> a[16..31] now in L1

This single fact explains nearly all of performance layout. If your next access is to a neighbouring byte, it is already in cache — a hit. If your next access jumps to a random faraway address, it is a fresh line fetch — a miss. So the question that decides speed is: does my access pattern stay within lines I've already pulled in? Sixteen ints (16 × 4 = 64 bytes) fit in one line: one miss buys you fifteen free hits. This is why the Good struct from §1 matters — a smaller struct packs more useful elements per line and wastes fewer bytes on padding that the CPU still has to drag through cache.

4 · Contiguous beats pointer-chasing — and the prefetcher

Contiguous data means the elements sit one after another in memory, no gaps — like the elements of an array or a std::vector's backing block. Walking it touches address p, then p+stride, then p+2·stride — a steady linear march. The hardware has a second weapon for exactly this pattern: the prefetcher, a unit that watches the stream of addresses, detects "this code is marching forward at a fixed stride," and fetches the next lines into cache before the program asks. With a working prefetcher, a linear scan of an array can run with almost no stalls at all — the data arrives just in time.

Pointer-chasing is the opposite. A linked structure — say a std::list — stores each element in its own separately-allocated node, and you reach the next element by following a pointer stored in the current one. The addresses are scattered across the heap (each node came from a separate allocation, Lesson 04). Three things go wrong at once: (1) every node is likely a fresh cache miss, because nodes are not neighbours; (2) the prefetcher cannot help — it cannot predict the next address, because that address is the data it hasn't fetched yet (you must load node N to learn where node N+1 is — a dependency chain); and (3) each node carries pointer overhead (16+ bytes of next/prev per element) that bloats the footprint and wastes cache lines. A linked list is a machine for generating cache misses.

The asymmetry that surprises people
An array and a linked list both support "visit every element" in O(n) operations. The big-O is identical. Yet on a million elements the array scan can be 10–50× faster, because every array access after a miss is a cheap hit (line locality + prefetch), while every list access tends to be a fresh ~100 ns DRAM miss with no prefetch. Big-O is silent about the constant factor that the memory hierarchy makes enormous. This is the entire reason Lesson 13 will tell you the list is almost always the wrong default.

5 · Array-of-Structs vs Struct-of-Arrays

When you store many records, you have a layout choice the language makes explicit. Array-of-Structs (AoS) is the obvious one: a single array where each element is a full record. Struct-of-Arrays (SoA) splits each field into its own contiguous array. Same data, transposed.

struct Particle { float x, y, z;  float vx, vy, vz;  float mass; };

// AoS: one array, fields of a record interleaved in memory.
std::vector<Particle> aos;        // ...x y z vx vy vz mass | x y z ...

// SoA: one array per field, each contiguous.
struct Particles {
    std::vector<float> x, y, z, vx, vy, vz, mass;
};
Particles soa;                    // x x x x ... | y y y y ... | ...

Which wins depends entirely on the access pattern. Suppose a step of physics needs to update only the positions from the velocities — it touches x,y,z,vx,vy,vz but never mass. With AoS, every cache line you pull in to get a particle's x also drags in that particle's mass — bytes you will not use this pass, wasting a slice of every 64-byte line. With SoA, the x array is pure positions, packed tight: every byte of every fetched line is data you will actually touch, so you get the maximum useful elements per line, and SIMD vector instructions (which want 4/8/16 contiguous floats) apply cleanly. Here SoA wins — often markedly — because it raises the fraction of each cache line that is useful.

AoS wins when
You process whole records at a time — touch most fields of one element before moving on (e.g. "render this particle"). Keeping a record's fields together is then exactly the locality you want.
SoA wins when
You sweep one or a few fields across all records (e.g. "advance every position"). Packing that field contiguously means no wasted line bytes and clean vectorization.

The deciding question is always the same cache-line question from §3: of the 64 bytes a fetch pulls in, how many will this loop actually use? AoS and SoA are two answers; you pick by access pattern, not by taste.

6 · Worked payoff — sum an array vs a linked list of the same N

Make the abstraction concrete with the simplest possible task: sum N = 1,000,000 integers, stored two ways.

long sum_vec(const std::vector<int>& v) {        // contiguous
    long s = 0;
    for (int x : v) s += x;                       // linear march, prefetchable
    return s;
}

long sum_list(const std::list<int>& lst) {        // pointer-chasing
    long s = 0;
    for (int x : lst) s += x;                      // follow next-pointer each step
    return s;
}

Now the cache-miss arithmetic. An int is 4 bytes, so a 64-byte line holds 16 of them.

vector misses
~62,500
N / 16 lines (1M / 16). One miss feeds 16 elements; the other 15 are hits, and the prefetcher hides most of even those 62,500 stalls.
list misses
~1,000,000
Each node is a separate heap allocation on its own line → roughly one ~100 ns DRAM miss per element, and the prefetcher can't help (next address is unknown until you load the node).
footprint
4 MB vs ≥24 MB
vector: 4 B × 1M = 4 MB packed. list node: 4 B data + two 8 B pointers + allocator rounding ≈ 24+ B each → 24+ MB scattered.

So the list does about 16× more cache-line fetches, and worse, none of them prefetch — the loop sits stalled ~100 ns at each node waiting to learn where the next node is. In practice the vector sum runs on the order of 10–50× faster, despite both loops being textbook O(n) with one add per element. The work is identical; the memory walk is not. This — plus the padding arithmetic of §1 — is the whole content of "mechanical sympathy," and it is precisely why Lesson 13 makes std::vector the default container and treats std::list as a special-case tool.

Preview — false sharing (Lesson 17)
The cache line returns as a concurrency bug. If two threads on two cores each write to different variables that happen to sit in the same 64-byte line, the hardware must keep their caches coherent — so every write by one core invalidates the line in the other's cache, ping-ponging it back and forth even though the threads never touch the same byte. This is false sharing: a silent slowdown caused purely by layout. We solve it (with the same 64-byte awareness from §3) in Lesson 17.

Common mistakes / failure modes

Reaching for std::list by reflex
"It's a list, lists are for sequences." No — its per-node allocation and pointer-chasing make it cache-hostile. Default to vector; use list only when you truly need O(1) splice/erase with stable element addresses (Lesson 13).
Declaring struct members in random order
Mixing char/int/double/pointers carelessly bloats the type with padding (the 24-vs-16 of §1). Order largest-alignment-first; check with static_assert(sizeof(T) == ...).
Optimizing big-O while ignoring layout
Two O(n) loops can differ 50×. The constant factor the hierarchy creates is real and often dominant. Profile for cache misses, not just operation counts.
Indexing a 2D array column-major in C++
C++ stores rows contiguously. Looping columns-then-rows strides across lines — a miss per access. Loop rows-then-columns so the inner loop marches contiguously and prefetches.

Checkpoint exercise

Try it
(1) Write the Bad and Good structs from §1 and add static_assert(sizeof(Bad) == 24); and static_assert(sizeof(Good) == 16); — predict whether each compiles, then check. Add a fourth member and predict the new size before compiling. (2) Write sum_vec and sum_list over 1,000,000 ints, time both (std::chrono), and predict the ratio from the §6 miss arithmetic before you run it. (3) If you have perf on Linux, run perf stat -e cache-misses ./a.out on each and confirm the list reports roughly 16× more misses. Reason about why the numbers came out as they did — that reasoning is the skill, not the numbers.

Where this points next

We now have the cost of reaching data. The next question is the cost of abstracting it: when you wrap data in a class with methods, do you pay for the wrapper? Lesson 09 makes the zero-overhead principle concrete at the call level — it shows that a non-virtual method is compiled into a plain function call with a hidden this pointer, no slower than a free function, so encapsulation in C++ is genuinely free. That sets up Lesson 10, where we meet the one form of abstraction you do pay for — the virtual call — whose cost, fittingly, includes the cache miss this lesson just taught you to fear.

Takeaway
The CPU is not a flat, uniform memory: it is a hierarchy where an L1 hit (~1 ns) is about 100× faster than a DRAM miss (~100 ns), and data moves in 64-byte cache lines, so reading one byte pulls in its 63 neighbours. This makes layout a first-class performance lever. A struct shrinks from 24 to 16 bytes just by ordering members largest-alignment-first, eliminating padding. Contiguous data (a vector) is fast because the prefetcher predicts a linear march and every miss buys ~16 free hits; pointer-chasing (a list) is slow because each node is a scattered, un-prefetchable, ~100 ns miss — so summing a million ints can be 10–50× faster in a vector at identical O(n). Choose AoS when you process whole records, SoA when you sweep one field across all of them, by asking how much of each fetched line you'll actually use. This is mechanical sympathy: match your access pattern to how the CPU walks memory — and it is exactly why vector beats list (Lesson 13) and why false sharing (Lesson 17) is a layout bug in disguise.

Interview prompts