Connectionsblockingtilingb-treelocalityalgorithm designcomplexity

Cache-Aware Algorithms

Two algorithms with identical asymptotic complexity can differ by an order of magnitude in wall clock, because complexity analysis counts operations and hardware charges for data movement. Blocking, compact layouts and node sizes matched to the transfer granularity are all the same idea: arrange the work so that data pays its travel cost once.

▶ Run the labFollow the mechanism

Software view, hardware view

The gap between what you wrote and what the machine does is where this whole domain lives.

The question
Why can two algorithms with the same big-O differ by an order of magnitude in wall clock, and how do you design for the memory hierarchy rather than for the instruction count?
What you wrote
The algorithm is O(n log n), so its cost is n log n. A constant factor is an implementation detail, and the asymptotically better algorithm wins at scale.
What the hardware does
The machine charges per *cache line and page transferred*, not per operation. An algorithm that touches the same n elements in a different order can move ten times as much data through the hierarchy while executing the same number of instructions.
Complexity analysis assumes uniform-cost memory access, which stopped being true decades ago. Once the working set outgrows a cache level, the term that dominates runtime is the one the analysis does not model — and the fix is usually a reordering of the same work, not a better algorithm.
SourceCompilerInstructionsFront EndExecutionRegistersCachesMemoryI/OBehavior

The cost model complexity analysis uses is not the machine's

Big-O counts operations under an implicit assumption: every memory access costs the same. That assumption made sense when it was formalised and is now wrong by two orders of magnitude between a register and DRAM (see The Memory Hierarchy). What the hardware actually charges for is *transfers* — a cache line moved from L3, a page fetched from storage — and an algorithm's transfer count is not determined by its operation count.

This is why the external-memory or cache-oblivious literature exists: it re-does the analysis counting block transfers instead of operations, and gets different answers. You do not need that machinery to benefit from the idea. The practical version is one question asked of any hot loop: *how many times does each piece of data travel, and could it travel fewer times?*

Note what this does and does not say. It does not say complexity analysis is useless — an O(n²) algorithm will lose to an O(n log n) one at large n no matter how cache-friendly it is, and choosing the right algorithm is still the first and largest lever. It says that *among algorithms of the same complexity class*, and at the sizes real programs actually run at, data movement usually decides the winner.

Same asymptotic cost, different transfer counts
Structure or approachOperation countWhat the hardware is charged forWhere the difference comes from
Array scan vs linked-list scanBoth O(n)Array: one line per several elements. List: often one line per nodeContiguity and prefetchability — see Both Are O(n). One Is Far Slower.
Binary search tree vs B+ tree lookupBoth O(log n)BST: one line per level. B+ tree: one line per *many* keys per levelNode size matched to the transfer unit
Naive vs tiled matrix multiplyBoth O(n³) multipliesNaive re-fetches a row or column per pass; tiled fetches a block onceReuse while resident — see Matrix Tiling: Same Arithmetic, Ten Times Faster
Row-major vs column-major traversalBoth O(rows × cols)One walks lines; the other touches one element per lineStride versus layout — see Spatial Locality
Pointer-linked graph vs index-linked graphBoth O(V + E)Pointers scatter; indices into an array stay denseAllocation layout — see Pointer Chasing: The Address You Do Not Have Yet

Three techniques that are all the same technique

Blocking (tiling) restructures a loop nest so that a chunk of data is brought in once and fully used before being evicted. This is the technique behind tiled matrix multiply, blocked transposes and cache-friendly sorts. It changes no arithmetic; it changes residency.

Sizing nodes to the transfer granularity is why a B+ tree beats a binary search tree for on-disk and increasingly for in-memory indexes. A binary tree pays one expensive access per *comparison*; a B+ tree pays one per *node*, and a node holds many keys. The fan-out is chosen so a node matches the unit the layer below actually transfers — a page for storage, a line or small multiple for memory. The database domain derives this from the storage side in Why B+ Trees: Fanout, Not Big-O and Pages: The Unit of Everything; the hardware reason is identical.

Compact, dense layouts reduce the number of lines a traversal touches at all. Struct-of-arrays for field-selective work (Array of Structs, or Struct of Arrays?), indices instead of pointers, and removing padding (Padding: Why Your Struct Is Bigger Than Its Fields) all shrink the bytes moved per useful byte consumed.

A fourth idea worth knowing by name rather than by implementation: cache-oblivious layouts such as van Emde Boas achieve good behaviour at *every* level of the hierarchy simultaneously, without being tuned to any particular cache size. They matter because they show the goal is not "tune to L2" but "arrange for reuse at whatever scale the machine has".

  • Blocking — bring a chunk in once, finish with it, then move on. Same operations, fewer transfers.
  • Fan-out sizing — make the node match the transfer unit, so one expensive access yields many useful comparisons.
  • Dense layout — fewer bytes per element means fewer lines per traversal.
  • Cache-oblivious layout — recursive structure that gets reuse at every level without knowing any cache size.
  • The check that unifies them — count how many times each datum crosses a hierarchy boundary, and ask whether it needs to.

When this is worth doing, and when it is not

Cache-aware restructuring is not free. Tiling adds loop nests and a tile-size parameter that is machine-dependent. Struct-of-arrays fragments a conceptually cohesive object across arrays. Index-based graphs lose type safety that pointers gave you. All of these make code harder to read and harder to change, which is a real and recurring cost paid by every future maintainer.

The honest rule is the same one that governs all optimisation: do it where the measurement says it matters, which in practice is a small number of hot loops over large data. Applying it everywhere produces an unreadable codebase and a rounding-error speedup — the failure mode Data-Oriented Design, Without the Dogma warns about at length.

The good news is that the *analysis* is cheap even when the fix is not. Asking "how many times does this data move?" costs nothing, catches the worst offenders early, and often reveals that the answer is not a clever layout but a plainly better algorithm or simply doing the work in a different order.

Why transfer count dominates: relative cost of reaching a datum at each level — 1 unit ≈ one L1 accessSIMPLIFIED
Register×0.3
L1 hit×1
L2 hit×4
L3 hit×15
DRAM×60
Storage×5000
Ratios, not times. Absolute latencies depend on the processor, its clock, the memory it is attached to and what else is running — publishing them would be wrong everywhere except one machine. The bars are log-scaled, so each step is larger than it looks.
Registeralready in the core; no transfer at all
L1 hitthe baseline unit
L2 hitstill on-core on most designs
L3 hitshared, so contended by other cores
DRAMan order of magnitude past L1; this is the cliff
Storageflash; spinning media is far worse again

Key points

  • Complexity analysis counts operations and assumes uniform memory cost; hardware charges for transfers, so the two rank algorithms differently.
  • Among algorithms of the same complexity class, data movement usually decides which wins at real sizes.
  • Blocking, fan-out sizing and dense layouts are three expressions of one idea: make each datum pay its travel cost once.
  • A B+ tree beats a binary tree for the same reason a tiled multiply beats a naive one — the unit of work is matched to the unit of transfer.
  • The restructuring costs readability and adds machine-dependent parameters, so it belongs in measured hot paths, not everywhere.

Progressive depth

Overview

Two algorithms with the same big-O can have very different runtimes, because complexity counts operations while hardware charges for moving data. Arranging work so each piece of data is used fully while it is nearby is the single highest-leverage optimisation for large-data loops.

Practical

Three recurring techniques: block the loop so a chunk is reused while resident; size structure nodes to match the transfer unit, which is why B+ trees are wide; and make elements dense so more useful data arrives per line. Apply them in measured hot loops only — everywhere else they cost readability and buy nothing.

Advanced

The external-memory model re-does complexity analysis counting block transfers rather than operations, and produces genuinely different rankings. Cache-oblivious structures such as van Emde Boas layouts achieve near-optimal transfer counts at every level of the hierarchy simultaneously, without being parameterised by any cache size — which matters because a tuned constant is a portability hazard.

Internals

Transfer count is not the whole story: the hardware can overlap independent misses (Misses That Overlap Are Nearly Free), so an access pattern with many outstanding independent misses tolerates them far better than a dependent chain does. This is why a blocked traversal with predictable strides beats a pointer-linked one by more than the raw miss counts suggest — the prefetcher works on one and not the other, and the misses that remain are overlapped rather than serialised.

Loop Order & Locality

Change an input and watch which number moves — and which one refuses to.

The same matrix, three traversal orders
SIMULATED
for i { for j { a[i][j] } }88%
for j { for i { a[i][j] } }0%
tiled 8×888%

Identical arithmetic, identical element count, identical complexity. Only the order changed. Column-major traversal of row-major storage touches a new line on essentially every access; tiling restores the reuse by keeping a block resident while it is used.

Follow the mechanism

The path through the machine, hop by hop — and the conclusions it invites that are wrong.

  1. 1
    Algorithm → access sequence: the loop order and data structure together determine which addresses are touched, and in what order.
  2. 2
    Access sequence → line requests: the hardware translates that into cache-line requests; addresses within a line cost one transfer between them.
  3. 3
    Line requests → hierarchy traffic: each request that misses walks outward, and the working set decides at which level it stops (Working Set: Why Performance Falls Off a Cliff).
  4. 4
    Hierarchy traffic → stall cycles: misses that the CPU cannot overlap become stalls, which is where the wall-clock difference actually appears.
  5. 5
    Stall cycles → runtime: two algorithms with equal instruction counts diverge here, and no amount of instruction-level tuning closes the gap.
What people conclude from this — wrongly
  • "It is O(n log n), so it is fast enough" — complexity bounds the growth rate, not the constant, and the constant is where the hierarchy lives.
  • "The tiled version does less work" — it does exactly the same arithmetic; it moves less data.
  • "Cache-friendly code is always worth writing" — outside hot loops it buys nothing and costs clarity permanently.
  • "We tuned the tile size, so it is optimal" — optimal on the machine you tuned it on, and possibly nowhere else.

Consequences, controls and cost

What it causes
  • • A structure that is theoretically optimal can lose badly to a "worse" one that fits the hierarchy — the standard example being linked structures against arrays.
  • • Performance falls off a cliff at the input size where the working set stops fitting a level, rather than degrading smoothly.
  • • Tuning constants such as tile size are machine-specific, so a value tuned on one machine can be wrong on the next.
  • • Index and tree designs across databases converge on similar fan-outs, because they are all solving the same transfer-count problem.
What you can do
  • • Choose the right algorithm first — cache-friendliness never rescues an asymptotically worse choice at large n.
  • • Restructure loops to reuse data while it is resident (blocking) before attempting anything more exotic.
  • • Match structure granularity to transfer granularity: wide nodes, dense arrays, indices instead of pointers.
  • • Shrink the element: remove padding and split rarely-used fields out, so more useful data arrives per line.
  • • Measure the transfer count, not just the runtime, so you know whether you fixed the cause or got lucky.
How to see it
  • • Compare cache-miss counts per level before and after; a restructuring that helped should show fewer last-level misses for the same instruction count.
  • • Plot runtime against input size and look for the knee where the working set exceeds a cache level — the knee moves when the layout changes.
  • • Check instructions retired alongside runtime: if instructions are flat and runtime fell, you removed transfers rather than work.
  • • Run on more than one machine before trusting a tuned constant.
What it costs
  • • Blocked and tiled code is substantially harder to read and to modify than the loop nest it replaces.
  • • Tile sizes and fan-outs are machine-dependent parameters that silently become wrong as hardware changes.
  • • Dense layouts such as struct-of-arrays fragment objects and can complicate ownership, serialisation and debugging.
  • • The analysis effort is only repaid in genuinely hot code over genuinely large data.

Scope

§224 — what these claims are specific to.

What these claims are specific to
  • GENERALThe principle — hardware charges for transfers, not operations — holds on every machine with a memory hierarchy, which is all of them.
  • SIMPLIFIEDThe cost scale is relative and illustrative. Real ratios vary by a factor of several between machines, and the L3-to-DRAM gap in particular depends on memory configuration and contention.
  • PLATFORM-SPECIFICOptimal tile sizes and tree fan-outs depend on the specific cache sizes, line size and page size of the target machine; a value tuned for one is often wrong for another.

Misconceptions

Claim
“If two algorithms are both O(n), they will perform about the same.”
Reality
They can differ by an order of magnitude. Complexity classes ignore the constant, and on modern hardware the constant is dominated by how many cache lines and pages the access pattern moves. An array scan and a linked-list scan are the canonical demonstration.
Claim
“Cache optimisation means picking the right cache size to tune against.”
Reality
Tuning to a specific cache size is one approach and a brittle one. Cache-oblivious designs get good behaviour at every level without knowing any size, and the general goal is reuse-while-resident rather than a tuned constant.
Claim
“A B+ tree is a database thing; in memory a binary tree is fine.”
Reality
The B+ tree's advantage is transfer-count, not disk. In memory the transfer unit is a cache line rather than a page, so the optimal fan-out is smaller — but wide nodes still beat one comparison per expensive access, which is why in-memory indexes are also broad rather than binary.

Apply it

Where the rest of this lives

Programming Languages & Runtime Internals
Allocator placement and object layout

Whether a "dense array of objects" is actually dense in memory depends on the runtime: a language that boxes elements gives you an array of pointers, and the cache-aware layout you thought you wrote does not exist.