JA EN
LearnData Structures
·★ MEMBER·11 min read

Cache-Friendly Code — Why Two O(n) Loops Can Differ by 10×

Two implementations with identical complexity can differ by an order of magnitude, because the CPU never fetches one value — it fetches a 64-byte block. Locality, cache lines, arrays versus linked lists, AoS versus SoA, loop order and false sharing, from zero assumed background to checking it yourself with perf.

ModalitytextTaskalgorithm

An analogy: the library won't fetch a single book

Picture a library with closed stacks. You request one book, and the librarian wheels out the whole shelf section it came from. If your next request happens to sit on that same cart, you get it instantly. If it doesn't, that's another trip to the stacks.

In this library, the wait is set by the number of trips, not the number of books. Reading 100 books in shelf order takes a handful of trips; reading the same 100 in lottery order takes 100. Same book count, wildly different afternoon.

That is exactly the relationship between a CPU and memory. The CPU never pulls a single number out of RAM. It always pulls a fixed-size block — 64 bytes on most machines — and parks it on a small shelf nearby, the cache. Algorithm textbooks count how many books you read. Wall-clock time is decided by how many trips you make.

Why fetch in blocks — locality as a bet

Blocks exist because, as The Memory Wall from Scratch works out from the physics, fetching one lone value from far away is brutally expensive. Almost all of that cost is the trip itself; dragging the neighbours back along with it adds almost nothing. So the hardware always takes the neighbours.

Whether those neighbours turn out to be useful is a bet on how programs behave. The bet rests on two kinds of locality:

The hardware then raises the bet. A circuit called the prefetcher detects regularity — "this code is reading sequentially", "this code is jumping by a fixed step" — and pulls in blocks nobody has asked for yet. Code that sweeps regularly laid-out data is fast because that guess keeps landing. Code whose next address is unknowable until you read memory itself — anything that chases pointers — gets no prefetching at all, by construction.

The mechanism: the cache line is the unit

The smallest thing a cache handles is a cache line: 64 bytes on x86-64 and most Arm cores, 128 bytes on Apple silicon. Read one 4-byte int and the entire 64-byte line containing it lands in cache. The 15 neighbours come along for free.

When you sweep a contiguous array from the front, the fraction of accesses that miss (that need a trip) is a plain division.

m=sBm = \frac{s}{B}
(1)

ss is the size of one element in bytes, BB is the cache line size in bytes, and mm is the fraction of accesses that miss. In words: if 4-byte elements ride in 64-byte blocks, only one access in 16 causes a trip. The other 15 are served off the shelf.

Perceived time follows directly from mm.

T=thit+m×tmissT = t_{\text{hit}} + m \times t_{\text{miss}}
(2)

thitt_{\text{hit}} is the time when the value was already on the shelf, tmisst_{\text{miss}} is the extra time a trip costs, and TT is the average time per access. In words: average time = the usual time + probability of being wrong × the penalty for being wrong. The penalty is what makes this interesting: an L1 hit costs a few cycles, while going all the way to DRAM costs on the order of hundreds of cycles — two orders of magnitude apart (exact figures vary by machine).

So an implementation with m=1/16m = 1/16 and one with m=1m = 1 can differ by a large factor in runtime while executing the same number of instructions. And because the penalty grows the further out you go, the gap can appear discontinuously: two implementations run neck and neck until the working set outgrows a cache level, and then one of them falls off a cliff. O(n) versus O(n) — the fight is decided by the constant factor. That is what this article is about.

FIG 1Complexity analysis only compares the shape of the curve. Flip the y-axis to log and you can see it: a constant factor merely shifts a line up or down without changing its shape. An implementation that is 10× slower is still called O(n)

What this figure leaves out is the whole subject of this article. Every curve here has a coefficient of 1; you never see several lines of the same shape stacked at different heights. Complexity notation was built to compare growth as the input gets large, so the coefficient is discarded up front. And that coefficient is usually set not by the algorithm but by how the code touches memory.

The rest of this piece walks through the four things that build that coefficient: how data is laid out (array versus linked list), how records are stored (AoS versus SoA), the order of your loops, and threads writing to the same line — false sharing.

Every data structure table says a linked list inserts and deletes in the middle in O(1), while an array needs O(n). The table in [Choosing a Data Structure](/en/a/data-structures-tour/) is not lying, but it has a missing column: trips to memory per element.

What's behind this

§

Members-only from here

371 walkthroughs, 26 textbook chapters, 48 student units and 6 close readings — all included for $4.99/mo, with three new explainers every day. Cancel any time; access runs to the end of the period.

Already a member? Sign in to keep reading

Comments

Sign in to comment