The memory hierarchy and information retrieval
Two subjects that are catalogued apart and practised together. Computer memory is a stack of stores whose access costs differ by orders of magnitude and whose contents move between levels in fixed-size blocks. Information retrieval is the problem of naming the few records that answer a query out of the many that do not. The link between them is not an analogy: an index is a decision about which level of the hierarchy will answer the next question, and every time the hierarchy has changed, the index that wins has changed with it while the algorithms stayed where they were. C is where that decision is still written down, because C’s model of storage — an object is a region of bytes, a pointer is its address, an array is contiguous — is the model the hierarchy rewards, and because C says nothing at all about the hierarchy itself.
This page measures both halves on one machine. Four programs in
data/memory/ produce every number on it: a pointer chase that
finds the cache levels, a stride sweep that finds the cache line, a dictionary lookup written six ways over
one key set, and a Boolean conjunction written five ways over one pair of postings lists. They ship with the
site so the sweep can be rerun rather than believed. Where a measurement contradicts the tidy version of the
story — compression losing to raw bytes, branchless code losing to branchy code, the second
cliff turning out to be the translation buffer rather than a cache — the measurement is what is
printed.
Counts of what this page measures rather than of what it asserts. The latency ratio is
computed from the sweep below, on the default 4 KiB pages; the rest count the variants in
search.c and
postings.c.
Two articles, one cost model¶
Wikipedia files computer memory under hardware and information retrieval under library and information science. Read the two articles side by side and the same sentence keeps appearing in different vocabulary. The memory article says storage is organised in a hierarchy because fast storage is expensive and small; the retrieval article says an index exists because reading every document is unaffordable. Both are statements about a budget of accesses, and both are solved the same way: keep a small thing that tells you where the large thing is.
Put concretely, every classical retrieval structure is a hierarchy device.
| Retrieval structure | What it actually is | The level it is trying to reach |
|---|---|---|
| Inverted index | A map from term to the list of documents containing it | Replaces a scan of the whole collection with a read of two small lists |
| B-tree | A search tree whose node is exactly one block of the storage below it | One block read per level — a disk block in 1970, a cache line now |
| Skip pointers | Uncompressed headers over compressed runs | Lets a compressed list be traversed without decoding what it skips |
| Signature file / Bloom filter | A lossy summary that can only produce false positives | Keeps a resident filter small enough to answer before the index is touched |
| Hash table | A function from key to slot, with a collision rule | One probe, at the price of a table larger than the keys it holds |
| Quantised vector index | Approximate vectors plus a re-ranking pass | Shrinks bytes per vector until the candidate set fits a level that is fast enough |
Five retrieval structures and the level of the storage hierarchy each is built to reach. The middle column is the structure’s definition; the right column is the reason it was invented. Sources: Bayer and McCreight (1972) for the B-tree; Moffat and Zobel (1996) for skip pointers; Jégou, Douze and Schmid (2011) for product quantisation. Table: Programming Language Atlas.
The claim this page defends is narrower and more testable than “locality matters”. It is that the complexity class of a retrieval structure does not predict its cost, and the pair (bytes per item, access pattern) does. Two structures with identical asymptotics differ by 3.0× on the same keys below, and one representation beats another by 34× in one regime and loses to that same one by 89× in another. None of those numbers are visible in a complexity class, and all of them fall out of the hierarchy.
The rest of the page is in three movements: the hierarchy measured from C, the retrieval structures measured on the same machine, and the history that explains why the winning structure has changed roughly once a decade without the algorithms changing at all.
The hierarchy, measured¶
The hierarchy is usually presented as a table of numbers from a vendor’s manual. It does not have to be: five lines of C measure it directly. Fill an array with a single random permutation cycle, one element per cache line, and walk it. Because the address of each load is the value the previous load returned, the processor cannot start the next access until the last one has finished, and it cannot prefetch — there is no arithmetic pattern in the addresses to extrapolate. What the loop reports is load-to-use latency, and nothing else.
/* The whole measurement. `next` holds one permutation cycle over the working
set, one element per 64-byte line, so the address of the next load is the
value returned by the last one and nothing overlaps. */
for (long i = 0; i < steps; i++)
p = buf[p];
Sweeping the size of that array and plotting the time per load gives the levels directly. Each plateau is a level of storage; each rise between plateaus is a capacity being exceeded.
/sys/devices/system/cpu/cpu0/cache; latencies from data/memory/latency.c, median of three sweeps on 4 KiB pages — the size a mapping gets unless it asks for more, which is a distinction the page-size section below is entirely about.data/memory/latency.c, median of three paired sweeps.The plateaus are unambiguous and they line up with what the machine reports about itself. The flat
region up to 32 KiB is the L1 data cache, whose size /sys gives as exactly 32 KiB. The
step to roughly 4.2 ns from 48 KiB is L2. The long shelf either side of
4 MiB is the shared L3. Past that, every load is a round trip to DRAM.
The useful way to read those numbers is not in nanoseconds but in the work they displace. At 2.8 GHz, a load that hits L1 costs about 4 cycles; one that reaches DRAM costs around 361. A modern core retires up to four instructions per cycle, so a single miss to memory is worth on the order of a thousand instructions that could have run instead. This is the number that makes retrieval a memory problem: an index that saves a comparison and costs a miss has lost.
Working set is the size of the chased array. Lines is that size in 64-byte cache lines, which is also the number of distinct addresses the cycle visits.
| Working set | Lines | 4 KiB pages, ns | spread | 2 MiB pages, ns | spread | 4 KiB / 2 MiB |
|---|---|---|---|---|---|---|
| 4 KiB | 64 | 1.51 | ±0.10 | 1.50 | ±0.01 | 1.01× |
| 6 KiB | 96 | 1.55 | ±0.07 | 1.51 | ±0.02 | 1.03× |
| 8 KiB | 128 | 1.55 | ±0.08 | 1.50 | ±0.02 | 1.03× |
| 12 KiB | 192 | 1.54 | ±0.06 | 1.51 | ±0.03 | 1.02× |
| 16 KiB | 256 | 1.52 | ±0.01 | 1.52 | ±0.01 | 1.00× |
| 24 KiB | 384 | 1.52 | ±0.00 | 1.51 | ±0.01 | 1.01× |
| 32 KiB | 512 | 1.54 | ±0.04 | 1.52 | ±0.02 | 1.01× |
| 48 KiB | 768 | 4.25 | ±0.03 | 4.22 | ±0.03 | 1.01× |
| 64 KiB | 1,024 | 4.24 | ±0.04 | 4.25 | ±0.02 | 1.00× |
| 96 KiB | 1,536 | 4.26 | ±0.03 | 4.24 | ±0.02 | 1.00× |
| 128 KiB | 2,048 | 4.25 | ±0.04 | 4.23 | ±0.02 | 1.00× |
| 192 KiB | 3,072 | 4.27 | ±0.16 | 4.25 | ±0.01 | 1.00× |
| 256 KiB | 4,096 | 4.25 | ±0.24 | 4.24 | ±0.01 | 1.00× |
| 384 KiB | 6,144 | 5.28 | ±0.18 | 5.20 | ±0.03 | 1.02× |
| 512 KiB | 8,192 | 5.92 | ±1.18 | 5.73 | ±0.10 | 1.03× |
| 768 KiB | 12,288 | 8.67 | ±2.31 | 8.99 | ±1.29 | 0.96× |
| 1 MiB | 16,384 | 14.45 | ±0.36 | 14.95 | ±0.84 | 0.97× |
| 1 MiB | 24,576 | 22.67 | ±0.75 | 22.27 | ±1.40 | 1.02× |
| 2 MiB | 32,768 | 23.81 | ±0.61 | 23.54 | ±1.44 | 1.01× |
| 3 MiB | 49,152 | 24.00 | ±1.02 | 23.89 | ±1.88 | 1.00× |
| 4 MiB | 65,536 | 24.05 | ±1.02 | 23.79 | ±1.24 | 1.01× |
| 6 MiB | 98,304 | 25.61 | ±0.78 | 26.12 | ±3.19 | 0.98× |
| 8 MiB | 131,072 | 31.51 | ±4.19 | 28.24 | ±4.32 | 1.12× |
| 12 MiB | 196,608 | 107.06 | ±73.69 | 105.19 | ±47.54 | 1.02× |
| 16 MiB | 262,144 | 126.09 | ±25.57 | 105.85 | ±30.18 | 1.19× |
| 24 MiB | 393,216 | 128.99 | ±2.61 | 104.83 | ±24.02 | 1.23× |
| 32 MiB | 524,288 | 130.97 | ±3.18 | 106.72 | ±18.81 | 1.23× |
| 48 MiB | 786,432 | 131.08 | ±3.90 | 104.35 | ±14.02 | 1.26× |
| 64 MiB | 1,048,576 | 128.39 | ±9.88 | 104.55 | ±12.44 | 1.23× |
| 96 MiB | 1,572,864 | 125.32 | ±14.12 | 105.15 | ±9.24 | 1.19× |
| 128 MiB | 2,097,152 | 128.98 | ±9.20 | 105.26 | ±6.33 | 1.23× |
| 256 MiB | 4,194,304 | 137.61 | ±7.19 | 111.11 | ±3.77 | 1.24× |
| 512 MiB | 8,388,608 | 150.28 | ±1.94 | 115.90 | ±56.61 | 1.30× |
The complete sweep. Each figure is the median of three back-to-back runs, each of which already reported the minimum of five timed passes; the spread is the largest minus the smallest of those three medians. The last column is the cost of address translation, and it is discussed in its own section below. Source: data/memory/latency.c.
The unit of transfer is a line¶
C’s model of storage is byte-addressed: sizeof(char) is 1, a pointer names a byte, and
the standard is careful to say that an object is a region of bytes. No machine that runs C has moved a single
byte between memory and cache in decades. The unit of transfer is a cache line, 64 bytes on
every target this page touches, and it is fetched whole. Reading one byte and reading its 63 neighbours are
the same operation at the same price.
/sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size. Diagram: Programming Language Atlas.That has a directly measurable consequence: a loop that touches every element of an array and one that touches every sixteenth element read the same number of lines, and should therefore take a similar time, while doing sixteen times less arithmetic. Sweeping the stride over a 64 MiB array — far larger than any cache here, so every line must be fetched — tests exactly that.
A stride of 16 elements is exactly one 32-bit element per 64-byte line, which is the point where the two cost columns must coincide.
| Stride | Elements read | ns per element | ns per 64-byte line | Lines per element |
|---|---|---|---|---|
| 1 × 4 B = 4 B | 16,777,216 | 0.651 | 10.423 | several elements per line |
| 2 × 4 B = 8 B | 8,388,608 | 0.977 | 7.817 | several elements per line |
| 4 × 4 B = 16 B | 4,194,304 | 1.629 | 6.514 | several elements per line |
| 8 × 4 B = 32 B | 2,097,152 | 2.874 | 5.748 | several elements per line |
| 16 × 4 B = 64 B | 1,048,576 | 5.790 | 5.790 | one element per line |
| 32 × 4 B = 128 B | 524,288 | 9.003 | 9.003 | one line per element, and a gap |
| 64 × 4 B = 256 B | 262,144 | 9.539 | 9.539 | one line per element, and a gap |
A stride sweep over a 64 MiB array of 32-bit integers. The third column is what the loop costs per element it read; the fourth is that cost re-expressed per cache line, which is the unit actually moved. Source: data/memory/stride.c, minimum of seven passes.
The two columns say different things and both are worth reading. Per element, the cost rises 8.9× between stride 1 and stride 16 — the loop is doing a sixteenth of the arithmetic and getting slower, because the arithmetic was never the cost. Per line, the cost falls, from 10.4 ns to 5.8 ns, because at stride 1 that figure includes sixteen additions and at stride 16 it includes one. Past stride 16 there is nothing left to save and the cost rises again: at 128-byte stride the pair-line prefetch that was helping stops helping, and each line arrives on its own.
So much for the transfer. The other half of the cost is order, and it is bigger. Three loops over the same 64 MiB array, visiting the same one million lines, one line apart, differing only in the order of visits and in whether one visit’s address depends on the last one’s value:
data/memory/stride.c, minimum of seven passes.The gap between the first and last bar is 21×, on identical data doing identical work. But the middle bar is the one that carries the lesson. It is the same random order as the third, over the same addresses; the only difference is that its indices are read from an array rather than from the array being walked, so the processor can compute several addresses before any of them has returned and keep a dozen misses in flight at once. That costs 2.1× sequential, not 21×.
The line has a second consequence that this page does not measure and the atlas does. Two threads writing
to different objects in the same line contend for it as though they were writing to the same
object, which is why the synchronisation page can take a
four-counter structure from 21 ns to 1.4 ns per increment by adding _Alignas(64) and
changing nothing else. Same 64 bytes, same declaration-order lever, seen from the other side.
Which wall you hit: cache, or TLB¶
The two series in Figure 2 are the same program, over the same addresses, in the same order, with the
same cache hierarchy underneath. They differ in one system call. One maps its buffer with the default page
size and the other adds madvise(MADV_HUGEPAGE), which on this kernel — transparent huge
pages set to madvise — gets 2 MiB pages instead of 4 KiB ones.
/* The only difference between the two series. mmap rather than malloc so the
page size is this program's decision; MADV_HUGEPAGE is advice, so the run
reads /proc/self/smaps_rollup afterwards and prints what it actually got. */
size_t *buf = mmap(NULL, bytes, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (huge && madvise(buf, bytes, MADV_HUGEPAGE) != 0)
fprintf(stderr, "# MADV_HUGEPAGE refused\n");
Advice is not a guarantee, so the run reports what it was given rather than what it asked for. The
default run prints AnonHugePages: 0 kB and the huge-page run prints
AnonHugePages: 524288 kB, which is the entire 512 MiB mapping. That control is the reason
the comparison is worth making: without it, a silently refused madvise would look like a
result.
The effect is confined to one end of the sweep, and that is the whole point. Below 8 MiB the two curves are on top of each other: page size cannot matter while the working set fits in cache, because the translation of a resident line is already cached too. From 16 MiB up, the 4 KiB series sits a consistent 1.23× above the 2 MiB one, across nine sizes and a factor of thirty-two in working set.
Sizes below 16 MiB are omitted: the two series are within their own run-to-run spread of each other there, so a ratio would be reporting noise.
| Working set | 4 KiB pages, ns | 2 MiB pages, ns | Difference, ns | Ratio |
|---|---|---|---|---|
| 16 MiB | 126.09 | 105.85 | 20.2 | 1.19× |
| 24 MiB | 128.99 | 104.83 | 24.2 | 1.23× |
| 32 MiB | 130.97 | 106.72 | 24.2 | 1.23× |
| 48 MiB | 131.08 | 104.35 | 26.7 | 1.26× |
| 64 MiB | 128.39 | 104.55 | 23.8 | 1.23× |
| 96 MiB | 125.32 | 105.15 | 20.2 | 1.19× |
| 128 MiB | 128.98 | 105.26 | 23.7 | 1.23× |
| 256 MiB | 137.61 | 111.11 | 26.5 | 1.24× |
| 512 MiB | 150.28 | 115.90 | 34.4 | 1.30× |
The DRAM-resident part of the sweep, where the working set is far beyond any cache and the only variable left is address translation. Source: data/memory/latency.c.
That difference is not a cache effect. It is the page walk. Every load has to turn a virtual address into a physical one, and the translation lookaside buffer caches the results; when the buffer misses, the processor walks the page tables, which is itself a chain of dependent loads into memory. A second-level TLB of the size Intel documents for this microarchitecture — 1536 entries, shared between page sizes — reaches 6 MiB of address space with 4 KiB pages and 3 GiB with 2 MiB ones. The chase visits a new page on nearly every hop, so past 6 MiB the 4 KiB series is paying for a walk on essentially every load, and the 1.23× is what a walk costs when the tables themselves are in cache.
mmap, madvise and the page size are the operating
system’s vocabulary, not the language’s.
What C says about memory, and what it does not¶
C is the language this page is written in, and not by accident. The connection runs in both directions and each one is worth stating precisely, because the usual summary — “C is close to the metal” — is wrong in a way that matters here.
What the language gives you¶
C’s model of storage is the one the hierarchy rewards. An object is a region of data storage; it has a size in bytes, an alignment, and a representation that the program may inspect. Arrays are contiguous, so index arithmetic is address arithmetic. Structure members appear in declaration order. None of that is inevitable: in a language whose aggregate is a reference to a heap node, a programmer who wants two fields adjacent in memory has no way to ask for it. In C the layout is written down in the declaration.
| What C guarantees | Where | Why it matters to the hierarchy |
|---|---|---|
| An object is a region of bytes | §3.15, §6.2.6.1 — every object has a size in bytes and a representation | Layout is a thing the program can reason about at all, which is not true in a language whose values are references |
| Array elements are contiguous | §6.2.5, §6.5.6 — pointer arithmetic within an array object is defined | An array walk is a stride, and a stride is what the prefetcher recognises |
| Structure members are in declaration order | §6.7.2.1 — later members have higher addresses; padding may be inserted | Field order is a layout decision the programmer makes, whether or not they meant to make one |
alignas, alignof | §6.7.5, §7.15 — C11 as _Alignas, spelled alignas since C23 | A structure can be placed on a boundary the hardware cares about, without naming the hardware |
aligned_alloc | §7.24.3.1 — C11; the size must be a multiple of the alignment | The same control for allocated storage; the only portable way to get it |
| Flexible array member | §6.7.2.1 — C99; a trailing incomplete array in a structure | A header and its payload in one allocation, so reaching the payload does not chase a pointer |
restrict | §6.7.3.1 — C99; for this block, the object is reached only through this pointer | Lets a value stay in a register across a store, which removes loads rather than making them faster |
memcpy on object representations | §7.26.2.1 — the defined way to move or reinterpret bytes | Bulk movement the implementation is free to turn into whatever the hardware does best |
The parts of the C standard that make layout a programmable property. None of these mentions a cache, and all of them are about where bytes are. Source: ISO/IEC 9899:2024 (C23), clauses as cited.
Put those together and a cache-conscious data structure is ordinary C. The B-tree node measured further down is a declaration and a loop:
/* The node of the static B-tree measured further down. Sixteen 32-bit keys is
exactly 64 bytes, and alignas puts the array on a line boundary, so a visit
to a node is one line fetched and never two. Both halves of that sentence
are C -- and neither the 16 nor the 64 is: the standard has no name for a
cache line, so the number is a constant this program was told. */
#include <stdalign.h>
#define B 16 /* keys per node = 64 / sizeof(unsigned) */
alignas(64) unsigned btree[NBLOCKS * B];
/* Rank within one node: sixteen independent compares over one line, summed
rather than branched on, so nothing has to be predicted and the sixteen
loads all hit the line that the first one brought in. */
static unsigned rank(const unsigned *node, unsigned x)
{
unsigned r = 0;
for (unsigned i = 0; i < B; i++) r += node[i] < x;
return r;
}
What the language withholds¶
Everything about the hierarchy itself. The standard describes an abstract machine in which every access costs the same, which is to say it describes no cost model at all. There is no portable way to ask how large a cache line is, how many levels of cache exist, whether an address is resident, or whether a load will be prefetched — and the standard is right not to offer one, because a conforming implementation may target a machine with no cache at all.
| What C does not expose | Standard position | Where the answer actually comes from |
|---|---|---|
| The size of a cache line | Nothing in the standard names it | sysconf(_SC_LEVEL1_DCACHE_LINESIZE), /sys, or a constant |
| How many levels of cache exist | Nothing in the standard names them | Platform interfaces, or measurement — which is what this page did |
| Prefetching | No standard facility | __builtin_prefetch (GCC, Clang), _mm_prefetch (x86 intrinsics) |
| The page size, or huge pages | Outside the language | sysconf(_SC_PAGESIZE), mmap, madvise(MADV_HUGEPAGE) |
| Which node of memory an allocation is on | Outside the language | libnuma, mbind, first-touch policy |
| Whether a branch will be predicted | No standard facility | [[likely]] / [[unlikely]] (C23) are hints about which path, not about cost |
| What the emitted code will be | The as-if rule, §5.1.2.3 | Reading the assembly — the reason the dictionary sweep below has two compiler columns |
The hierarchy is entirely outside the language. Every row is something a cache-conscious program needs and every answer is an extension, a platform interface, or a measurement. Source: ISO/IEC 9899:2024, and the named compiler and POSIX interfaces.
_Atomic and the memory_order enumerators. It says nothing
about how long an access takes, though it certainly costs something: the atlas measures what, on the
synchronisation page. The one this page measures is a cost model
of ordinary loads, and the standard does not have one. volatile belongs to neither: it marks accesses that are themselves observable
behaviour, which is what a device register needs and what neither threading nor caching does.
So the position is exact, and it is the reason C keeps showing up in retrieval systems. C hands the programmer complete control over where bytes are and complete ignorance of what that costs. The control is what makes a cache-conscious index expressible; the ignorance is what makes measurement compulsory. Every number on this page exists because there was no way to look it up in the standard.
The atlas takes the layers under this in two other places: what the implementation is permitted to do with your code between the source and the instructions, on the C article, and where the language stops and the platform ABI begins, on the calling conventions page. For how other languages in the index answer the same question, the memory and runtime page classifies all of them.
The anatomy of an inverted index¶
An inverted index answers “which documents contain this term” by storing, for every term, the sorted list of documents that contain it. It is called inverted because the natural direction of a document collection is document → terms and this is the transpose. Everything a Boolean or ranked retrieval system does on top of it — conjunction, disjunction, phrase matching, scoring — is arithmetic over those sorted lists.
The two structures have opposite characters, and the rest of this page measures them separately for exactly that reason.
| Dictionary | Postings | |
|---|---|---|
| What it holds | One entry per distinct term | One entry per (term, document) pair |
| Size, roughly | Vocabulary-sized: millions of entries | Collection-sized: billions of entries |
| Access per query term | One lookup | A full or partial traversal of one list |
| Access pattern | Random — a probe whose address depends on the term | Sequential — or sequential with skips |
| What decides its cost | Whether it is resident, and how many dependent loads a lookup takes | How many bytes per posting, and whether the traversal can skip |
| Measured below | Six layouts of one key set | Five representations of one pair of lists |
The two halves of an inverted index, and why they need different measurements. The bottom row names the two sweeps that follow. Table: Programming Language Atlas.
The conjunction itself — the AND of two terms — is the oldest operation in the field and the one every implementation still spends its time in. Two sorted lists, one output: advance whichever cursor is behind, and emit when they agree. The algorithm has not changed since the 1960s. What has changed, several times, is how the integers are stored, and the diagram below is the reason the change was worth making.
data/memory/postings.c hold 128 postings, not four.Two design pressures pull in opposite directions here, and every representation measured below is a different resolution of them. Compressing the postings makes each list smaller, so more of it fits in a faster level of the hierarchy and fewer bytes cross the bus — but a compressed sequence has no random access, so it cannot be skipped, only decoded. Blocking restores the skip at the cost of a header per block and slightly worse compression. Which resolution wins is not an algorithmic question. It is a question about the machine, and the answer has changed with the machine roughly once a decade.
Finding the term: six layouts of one key set¶
The dictionary problem, reduced to its core: given n sorted 32-bit keys, is a given key present? Six implementations, one key set, one query stream, one machine. Every variant but the first is O(log n) or O(1), so complexity theory says five of the six should be indistinguishable and the sixth catastrophically worse. The measurements say something else.
| Variant | What it is | Complexity | Access pattern | What it is trading |
|---|---|---|---|---|
| scan | Sorted array, read from the front | O(n) | Sequential | The prefetcher’s best case, and the only variant here whose cost is linear |
| binary | Sorted array, halve the range | O(log n) | Random, dependent | One unpredictable branch per level; the next address is not known until the compare resolves |
| branchless | The same search, compare folded into the index | O(log n) | Random, dependent | No branch to mispredict — if the compiler agrees, which is the finding below |
| eytzinger | The same keys in breadth-first order | O(log n) | Random, dependent | The hot top of the tree is contiguous instead of spread over the whole array |
| btree | 17-way static B-tree, one 64-byte node per level | O(log17 n) | Random, dependent | One cache line per level instead of one line per comparison |
| hash | Open addressing, load factor 0.5 | O(1) expected | Random, one probe | One dependent load, at the price of a table twice the size of the keys |
The six variants. All six answer the identical query over the identical keys, and all six are checked against each other for every size before any of them is timed. Source: data/memory/search.c.
binary and branchless lie on top of each other and only the upper one is visible — under this compiler that coincidence is the result, and it is taken apart in the next figure. Source: data/memory/search.c, minimum of seven timed runs of 2^20 queries each.Read the right-hand end first, at 16 Mi keys — 64 MiB of them, well past every cache on this machine. Binary search costs 576 ns. The B-tree over the same keys costs 189 ns, which is 3.0× faster for the same answer, the same key count, the same complexity class and, at every size in this sweep, a structure of exactly the same size. Nothing about that gap is algorithmic. Binary search touches one cache line per comparison and uses four bytes of it, so it pays about 24 line fetches for 24 useful comparisons; the B-tree reads a whole line and uses all sixteen keys in it, so it pays six.
The breadth-first (Eytzinger) layout is the same idea applied more weakly, and it is the clearest illustration of what layout can and cannot buy. It is the identical binary search over the identical keys with only the order they are stored in changed, so that the nodes visited early are adjacent. The margin that buys is a median 3.9× and stays above two all the way from sixteen keys to 1,048,576, where it is still 2.5× — five orders of magnitude of key count for a change that touches no code. Then it decays, and by 16,777,216 keys it has all but gone — 1.03×, which is within the run-to-run spread of no difference at all. Once the structure is large enough that even the top of the tree is evicted between queries, adjacency has nothing left to buy, and the deep levels of a breadth-first array are scattered where the deep levels of a sorted one are at least locally ordered. Layout buys residency, and residency runs out.
The sign of that last comparison is the least stable number on this page: rerunning the sweep has put the breadth-first array a few per cent either side of the sorted one at 16 Mi keys. The decay reproduces; which of the two is fractionally ahead at the end of it does not, and no argument here rests on it.
The hash table is the flattest line on the chart, from 7.4 ns at sixteen keys to 27.8 ns at sixteen million, and it is worth being precise about why: not because O(1) beats O(log n), but because it makes exactly one dependent load where the trees make several. Its cost curve is the DRAM latency curve from the first section, shifted. It also pays for that with size — at load factor 0.5 the table is 8 bytes per key against 4 — which is why it leaves cache an octave before the sorted array does, and why it is the wrong structure the moment the query stops being exact-match.
Bytes per key is a property of the layout rather than a measurement: the arrays hold each key once, the hash table runs at load factor one half, and the B-tree would pad its last node at a size that is not a multiple of sixteen — none of these are, so it does not.
| Variant | Bytes per key | 16 keys | 1 Ki | 64 Ki | 1 Mi | 16 Mi | 16 Mi ÷ 16 |
|---|---|---|---|---|---|---|---|
| scan | 4 | 15.6 | 326.0 | 20357.5 | — | — | — |
| binary | 4 | 24.4 | 66.5 | 116.1 | 238.0 | 575.7 | 24× |
| branchless | 4 | 20.3 | 54.6 | 97.1 | 201.6 | 546.1 | 27× |
| eytzinger | 4 | 8.0 | 16.3 | 32.4 | 96.4 | 561.5 | 70× |
| btree | 4 | 6.4 | 21.2 | 30.1 | 57.4 | 189.0 | 30× |
| hash | 8 | 7.4 | 7.5 | 9.1 | 13.1 | 27.8 | 4× |
Five points off the sweep, in nanoseconds per query, with the growth from the smallest size to the largest in the final column. A structure whose cost is a complexity class would grow by the ratio of the logarithms, which is 6×; none of them does. Source: data/memory/search.c, GCC 13.3 at -O2.
A digression that turns out to be the point¶
The branchless variant is the same binary search with the comparison folded into the index
arithmetic instead of driving a jump. Under GCC it is not measurably different from the branchy version at
any size. Under Clang it is a different program.
/* The source that is supposed to be branchless. Whether it is depends
entirely on whether the compiler chooses a conditional move. */
while (len > 1) {
unsigned half = len >> 1;
base += (base[half - 1] < x) ? half : 0;
len -= half;
}
GCC 13.3 at -O2 emits one cmov in the entire program; Clang 18.1 emits four. The
consequence is not subtle.
data/memory/search.c, built at -O2 with GCC 13.3.0 and Clang 18.1.3.Clang’s branchless search is 3.8× faster than its branchy one at sixteen keys, where the whole array is in L1 and the only cost left is the misprediction the branchless form removes. It stays ahead up to 1,048,576 keys. At 2,097,152 keys it is 1.2× slower, and it stays slower for the rest of the sweep.
The crossover is not arbitrary. 1,048,576 keys is 4 MiB of array and 2,097,152 keys is 8 MiB, against a pointer chase that was still on its L3 plateau at 8 MiB and off it at the next size up. The rewrite stops paying just before the array stops fitting, which is what it should do: a binary search is only partly resident long before it is wholly evicted, since the top of the tree is visited on every query and the bottom on none twice. The mechanism is this: a mispredicted branch is a speculative load down the wrong half of the array, and while the working set is in cache that speculation is wasted work, but once it is in DRAM that load is, half the time, a prefetch of the half the search was about to want — and a prefetch is worth far more than a mispredict costs. Removing the branch removes the speculation, and out of cache the speculation was the only thing overlapping the misses.
Intersecting the lists: five representations¶
Same discipline as the dictionary sweep: five representations, one pair of postings lists, one answer, checked against each other before anything is timed. The collection is 8,388,608 documents, and the two sweeps below vary the two things that actually decide the outcome — how long the lists are, and how different their lengths are from each other.
| Representation | What it is | Bytes per posting | What it gives up |
|---|---|---|---|
| merge | Two uint32 arrays, one linear pass | 4 | Nothing. It is the baseline: the algorithm with no representation trick at all |
| gallop | The same arrays; the short list drives, each posting found by exponential search | 4 | Random access into the long list, in exchange for not reading most of it |
| vbyte | Gaps between docids under variable-byte coding, decoded end to end | 1.0–2.0 | All ability to skip — a compressed sequence has no random access |
| blocks | The same coding in 128-posting blocks, each with its last docid in a plain header | 1.0–2.0 | A header per block and slightly worse compression, to get the skip back |
| bitmap | One bit per document in the collection, AND-ed a word at a time | constant | Any dependence on list length: size is the collection, so a rare term costs the same as a common one |
The five representations. The bytes-per-posting column is a property of the encoding rather than a measurement; the varied figure is because a shorter list has larger gaps between its docids and so needs more bytes for each. Source: data/memory/postings.c.
data/memory/postings.c, minimum of five timed passes.Two results here, and the first one is unwelcome. Compression loses. At every equal length, the flat variable-byte encoding is slower than reading raw 32-bit docids — by 2.9× at the largest size — despite moving a quarter of the bytes. The reason is in the third section: the merge reads both lists in address order, which is the case the hardware prefetcher exists for, so the three bytes per posting it saves were nearly free to move anyway; and variable-byte decoding is a chain of dependent shifts and branches on the continuation bit, which is exactly the shape that does not overlap. Cutting the encoding into blocks makes it slower again at every size but the largest, because blocked gaps restart at each boundary and there is nothing here to skip. At 4 Mi postings the two cross and the blocked form becomes the quicker of the two, by 2% — the first sign of the effect the next sweep is entirely about.
The second result is the bitmap. Its cost is flat — about 492 µs, which is what it takes to read 2048 KiB of bits and popcount them — because a bitmap is sized by the collection and not by the term. That makes it 89× worse than a merge for a term appearing in 1,024 documents, and 34× better for one appearing in half of them. On this collection the crossing falls between 64 Ki and 256 Ki postings per list.
List lengths are the actual lengths generated rather than the requested powers of two: the generator stops when its cumulative gaps reach the end of the collection.
| Postings per list | merge | gallop | vbyte | blocks | bitmap | In both |
|---|---|---|---|---|---|---|
| 1 Ki | 5.5 | 5.5 | 7.0 | 8.6 | 492.2 | 0 |
| 4 Ki | 22.1 | 33.4 | 43.1 | 55.2 | 488.6 | 1 |
| 16 Ki | 90.5 | 154.4 | 217.9 | 274.6 | 536.0 | 38 |
| 64 Ki | 360.4 | 613.8 | 1,118.4 | 1,345.7 | 531.8 | 510 |
| 256 Ki | 1,427.0 | 2,460.7 | 2,925.2 | 3,560.4 | 529.6 | 8,258 |
| 1 Mi | 5,489.3 | 9,891.0 | 11,861.6 | 14,152.2 | 539.3 | 131,205 |
| 4 Mi | 17,721.4 | 34,187.9 | 50,512.6 | 49,459.2 | 521.1 | 2,097,281 |
Equal-length lists, microseconds per conjunction. Every representation returns the identical count, which is checked before the timing starts. Source: data/memory/postings.c.
data/memory/postings.c, minimum of five timed passes.Everything changes. Against a list of 4 Mi postings, a linear merge of raw docids takes 11.3 ms and galloping takes 190 µs: 60× faster, because it never reads most of the long list. The flat compressed encoding gets none of that — 0.92× the merge, which is to say no better — because it cannot skip: to know where the thousandth posting is, it must decode the nine hundred and ninety-nine before it.
The blocked encoding gets both. It costs 279 µs, within 1.5× of galloping over uncompressed data, while storing the list at 1.12 bytes per posting rather than 4. That is a 3.6× smaller index for a 47% slower query, and it is the trade every production search engine has taken since Moffat and Zobel described self-indexing inverted files in 1996. It is also 37× faster than the same compression without the headers, which is the entire contribution of that paper, reproduced here in one column.
| Longer list | merge | gallop | vbyte | blocks | bitmap | In both | Bytes/posting, blocked |
|---|---|---|---|---|---|---|---|
| 1 Ki | 5.7 | 5.6 | 7.7 | 9.7 | 540.0 | 0 | 2.10 |
| 4 Ki | 14.1 | 9.1 | 25.1 | 22.9 | 494.3 | 1 | 2.08 |
| 16 Ki | 46.5 | 19.7 | 82.5 | 67.4 | 489.2 | 2 | 1.99 |
| 64 Ki | 175.0 | 33.1 | 440.6 | 349.3 | 488.4 | 4 | 1.62 |
| 256 Ki | 698.8 | 50.7 | 648.7 | 214.6 | 492.8 | 37 | 1.12 |
| 1 Mi | 2,773.9 | 118.0 | 2,558.4 | 247.1 | 487.2 | 132 | 1.12 |
| 4 Mi | 11,337.6 | 189.9 | 10,418.8 | 278.6 | 492.2 | 517 | 1.12 |
Skewed lists, microseconds per conjunction, with the shorter list fixed at 1,024 postings. The first row is the same measurement as the first row of the previous table, run again from the same seeds. It is also the shortest and so the noisiest, and the two runs of it agree only to within 12%. That is the honest bound on an absolute time here, and the reason every claim on this page is a ratio between two things measured in the same run rather than a magnitude. Source: data/memory/postings.c.
Reading the crossovers¶
Collecting the two sweeps. Two of these bars are honest algorithm changes and are marked as such: a bitmap and a merge have different complexity, and so do a gallop and a merge. Every other bar is the same algorithm over the same data with the bytes somewhere else or touched in a different order, and those run from 1.2× to 37×. The point of putting them on one axis is that a reader cannot tell, from the sizes alone, which kind each one is.
data/memory/.The choice is made from three properties of the data, in this order.
- Footprint. Bytes per item times items. Compare it against the capacities in Figure 1: that comparison, and nothing else, tells you which level will be answering. A structure that halves its bytes per item does not become twice as fast, but it does move its crossing point an octave to the right.
- Dependency. Can the next address be computed before the current load returns? If yes, misses overlap and the structure runs at bandwidth; if no, they serialise and it runs at latency. That is the 10× between the second and third bars of Figure 4, and it is the reason a hash probe and a six-level tree descent are not comparable just because both are “random”.
- Skew. Only then, the shape of the query. Lists of similar length want a merge; lists of very different length want a skip. This is the only one of the three that is about the algorithm.
| If this is true of the data | Use | Measured margin here |
|---|---|---|
| The whole structure fits in L1 | It still matters, and less than it will. At 16 keys the five layouts already span 18 ns, against 548 ns at 16 Mi | 30× less spread than at the largest size measured |
| Exact-match lookup, structure larger than L3 | Hash table — one dependent load beats several, and its size penalty is already paid | 28 ns against 576 ns for binary search, 16 Mi keys |
| Ordered lookup, structure larger than L3 | Blocked search tree with a node the size of a cache line | 0.33× the cost of binary search over the same keys |
| Two lists of similar length, sparse | Linear merge over raw docids; compression costs more to decode than the bytes cost to move | 2.9× against flat variable-byte at the largest equal size |
| Two lists of similar length, dense | Bitmap; its cost is the collection and it stops depending on the term at all | 34× against a merge at half the collection per list |
| Lists of very different length | Skip — galloping over raw docids, or blocked compression with headers | 60× against a merge, 1 Ki against 4 Mi postings |
| The index will not fit in memory at all | Compress harder and accept the decode: the level below is four orders of magnitude away, not one | Not measured here; it is the regime the 1990s literature was written in |
A decision table derived from the two sweeps. The right-hand column is the measured margin at the size where the rule starts to hold, not a general claim: every one of these crossings moves with the cache sizes of the machine. Table: Programming Language Atlas, from data/memory/search.c and data/memory/postings.c.
Notice what the table does not contain. There is no row that says “use the structure with the better asymptotic bound”, because at no size measured here was that the right advice. And every threshold in it is a cache size, which is why the next four sections are history: the thresholds have moved before, and each time they moved, the field rewrote its indexes.
How memory got its levels¶
The hierarchy was not discovered by measurement; it was predicted. In 1946, before any of the machines below existed, Burks, Goldstine and von Neumann wrote that an ideal memory — large, fast and cheap — was not obtainable, and that the practical answer was a graded series of stores with the fastest and smallest closest to the arithmetic unit. Every level added since has been an instance of that argument, and each one was added because a gap opened between two existing levels that was large enough to be worth filling.
| When | What | What it was | Access time, order of magnitude |
|---|---|---|---|
| 1946 | The hierarchy is proposed before it is built | Burks, Goldstine and von Neumann argue that an ideal memory is impossible and that a graded series of stores is the answer | — |
| 1947 | Mercury delay line | A bit is an acoustic pulse in a tube of mercury, read as it comes round. EDVAC, EDSAC, UNIVAC I | hundreds of µs, and strictly in order |
| 1948 | Williams–Kilburn tube | Charge on the face of a cathode-ray tube; the first genuinely random-access digital store. The Manchester Baby runs the first stored program on one | tens of µs |
| 1953 | Magnetic core | Ferrite rings on a wire grid, after Forrester, Wang and Woo. Non-volatile, destructive read, and the dominant memory for twenty years | µs |
| 1956 | The disk arrives, and with it the seek | IBM 350 RAMAC: five million characters, and an access time three orders of magnitude worse than core | hundreds of ms |
| 1962 | Virtual memory | The Ferranti Atlas one-level store: the program addresses a space larger than the machine and the hardware translates. The TLB is the cache that makes this affordable | — |
| 1965 | The cache | Wilkes proposes a small fast “slave memory” holding recently used words. IBM ships one in the System/360 Model 85 in 1968 | — |
| 1966 | The DRAM cell | Dennard, at IBM: one transistor and one capacitor. Still the cell in every commodity memory made today | — |
| 1968 | The working set | Denning formalises locality of reference, which is the assumption every level of the hierarchy is betting on | — |
| 1970 | DRAM becomes a commodity | The Intel 1103, one kilobit; within two years the best-selling semiconductor memory in the world, and the end of core | hundreds of ns |
| 1980 | Flash | Masuoka at Toshiba; NOR announced 1984, NAND 1987. Eventually a level of its own between DRAM and disk | tens of µs |
| 1993 | Synchronous DRAM | Memory clocked with the bus rather than answering asynchronously; DDR follows in 2000 and doubles per generation on bandwidth, not latency | tens of ns |
| 1995 | The memory wall is named | Wulf and McKee observe that processor speed is improving far faster than memory latency and that the average access will come to dominate everything | — |
| 2013 | High-bandwidth memory | DRAM stacked on the package with a very wide bus. Bandwidth improves by an order of magnitude; latency does not | tens of ns |
| 2019 | Memory over a fabric | CXL: memory attached over PCIe and shared between hosts. A new level, slower than local DRAM and faster than storage | hundreds of ns |
The additions that changed the shape of the hierarchy rather than its speed. The fourth column is an order of magnitude for the technology as first shipped, not a specification. Sources: Burks, Goldstine and von Neumann (1946); Wilkes (1965); Dennard, US patent 3,387,286; Denning (1968); Wulf and McKee (1995); JEDEC standards for SDRAM, DDR and HBM; and the Wikipedia article Computer memory for the chronology.
Two features of that list matter more than the individual entries. The first is that the gap the hierarchy exists to hide has widened, not closed. In the era of core memory a fetch and an arithmetic operation cost roughly the same, which is why nobody built a cache: there was no gap to hide, and the dates bear that out — the cache is proposed in 1965 and shipped in 1968, a decade after semiconductor logic began outrunning the store it was attached to. On the machine measured here, a DRAM access costs 84 times an L1 hit, and an L1 hit is itself several cycles from the registers. Every level since has been added because the level below it got relatively further away.
The second is that bandwidth and latency stopped moving together around 1990. Every generation of SDRAM, DDR and HBM since has multiplied the bytes per second and left the time to first byte roughly where it was. That divergence is the single most important fact for retrieval, and it is why the measurements in this page keep separating “how many bytes” from “how many dependent accesses”: those two costs are now paid to different budgets.
How retrieval got its index¶
Information retrieval starts, by common agreement, with an essay about a machine that was never built. Vannevar Bush’s Memex, described in 1945, stores a library on microfilm and lets its reader mark associative trails through it — the point being that the difficulty is not storing documents but reaching them. Everything since is a way of paying for that reach.
| When | What | What happened | What it changed |
|---|---|---|---|
| 1945 | The Memex | Bush describes a desk that stores a library on microfilm and lets a reader build named associative trails through it. No machine, but the statement of the problem | Association, not enumeration |
| 1950 | The name | Mooers coins “information retrieval” for the problem of finding documents rather than computing answers | — |
| 1957 | Statistics instead of cataloguing | Luhn, at IBM, proposes deriving index terms from word frequencies in the text itself rather than from a human classifier | The index becomes computable |
| 1958 | Precision and recall | The Cranfield experiments under Cleverdon establish that retrieval systems are compared by measurement on a shared collection with known answers | Evaluation becomes a method |
| 1960 | Probabilistic indexing | Maron and Kuhns rank documents by the probability of relevance rather than partition them into matching and not | Ranking, not filtering |
| 1961 | SMART | Salton’s system at Harvard and then Cornell; the vector space model, term weighting, relevance feedback, and the first large body of comparative results | Documents as vectors |
| 1972 | The B-tree | Bayer and McCreight define a search tree whose node is one block of the storage below it, so a level of the tree costs one access of that storage | The index takes the shape of the hardware |
| 1972 | Inverse document frequency | Spärck Jones shows that a term’s value is inverse to how many documents contain it, which is still the weighting half of every lexical scorer | — |
| 1994 | BM25 | Robertson and colleagues’ Okapi weighting at TREC-3, still the baseline that learned systems are measured against thirty years later | — |
| 1994 | Managing Gigabytes | Witten, Moffat and Bell make index compression a mainstream engineering practice: d-gaps, Golomb and Elias coding, and the arithmetic of when they pay | Bytes are the budget |
| 1996 | Self-indexing inverted files | Moffat and Zobel add uncompressed skip headers over compressed runs, so a compressed list can be traversed without decoding what it skips | Compression and skipping stop being alternatives |
| 1998 | PageRank | Brin and Page rank by the structure of the link graph rather than the text alone; the query-independent half of the score can be precomputed | Scores move off the query path |
| 1999 | Lucene | Cutting’s open-source index; the block-and-skip layout of the 1996 paper becomes the default implementation for a generation | — |
| 2011 | Product quantisation | Jégou, Douze and Schmid compress vectors into a few bytes each so that a similarity search can be run over a resident approximation and re-ranked | Compression, again, for the same reason |
| 2016 | HNSW | Malkov and Yashunin’s navigable small-world graphs: approximate nearest neighbours by greedy descent through a layered graph | A pointer chase, with all that implies |
| 2019 | Neural ranking at scale | Transformer rankers displace hand-tuned features; sparse learned representations keep the inverted index and change what goes into it | — |
The results that changed what an index is, rather than how well one scores. Sources: Bush, As We May Think (1945); Luhn (1957); Cleverdon’s Cranfield reports; Salton (1961 onwards); Bayer and McCreight (1972); Spärck Jones (1972); Robertson et al. at TREC-3 (1994); Witten, Moffat and Bell (1994); Moffat and Zobel (1996); Brin and Page (1998); Jégou, Douze and Schmid (2011); Malkov and Yashunin (2016); and the Wikipedia article 'Information retrieval' for the chronology.
Three entries in that table are memory-hierarchy papers wearing retrieval clothes, and they are the ones whose results are reproduced in this page’s measurements. Bayer and McCreight’s B-tree is defined by matching the node to the block of the storage underneath — a disk block in 1970, and a cache line in the sweep above, which is the same design at three orders of magnitude smaller. Witten, Moffat and Bell’s compression is an argument about bytes crossing a bus. Moffat and Zobel’s skip headers exist because compression had destroyed random access and random access was worth more.
One history, told twice¶
Set the two timelines beside each other and they are not two timelines. Each generation of retrieval practice is a response to the shape of the hierarchy it was built on, and each change of practice follows a change in that shape by a few years. The algorithms barely move: a conjunction in 1965 and a conjunction in 2025 are the same merge of two sorted lists. What moves is the answer to “what is expensive here”.
| Era | Where the index lived | What one access cost | What the field did | What it was minimising |
|---|---|---|---|---|
| 1950s | Tape and cards | Seconds, and only in order | Sort, then scan in one pass | Batch retrieval: the query waits for the tape |
| 1960s–70s | Disk over core | Tens of ms to seek; µs in core | Inverted file; B-tree with a node the size of a disk block | Minimise seeks. A comparison is free next to an access |
| 1980s–90s | Disk over DRAM | About 10 ms to seek; about 100 ns in DRAM | Compress everything: d-gaps, Golomb, front coding | Minimise bytes. Every byte saved is disk time not spent |
| 2000s | DRAM, sharded across machines | About 60 ns local; a network hop away otherwise | Keep the index resident; shard it; skip within it | Minimise machines touched, then bytes |
| 2010s | DRAM behind three cache levels | About 1 ns in L1; about 100 ns in DRAM | Block-max WAND; SIMD codecs; cache-line-sized nodes | Minimise dependent misses. Bandwidth is plentiful, latency is not |
| 2020s | DRAM, HBM, and a fabric | Similar latency; an order of magnitude more bandwidth | Quantised vectors, graph indexes, re-ranking | Minimise bytes per item so the candidate set fits a fast level |
The retrieval design of each era, against the hierarchy it was designed for. The rightmost column is the same question every time and a different answer every time, and the answer is a property of the hardware rather than of the problem. Table: Programming Language Atlas.
Three episodes are worth stating as cause and effect, because in each one the field reversed a position it had held firmly.
Compression became compulsory, then stopped being. When the index lived on a disk, a byte not read was ten milliseconds not spent, and Managing Gigabytes could show compression paying for itself many times over. The equal-length sweep above shows the same variable-byte coding losing to raw 32-bit docids by 2.9× on a resident index, because a sequential read from DRAM is fast enough that three saved bytes are worth less than the dependent shifts it takes to decode them. Neither result is wrong. The storage under the argument changed.
Random access was destroyed, then rebuilt. Compression made a postings list unaddressable, which cost nothing while lists were read end to end and everything once queries began pairing a rare term with a common one. Skip headers were the answer in 1996 and are still the answer: the skewed sweep above puts the blocked encoding 37× ahead of the same compression without them.
The bottleneck moved from bytes to dependencies. Once the index is resident and the bus is wide, the cost of a query is no longer the volume it reads but the number of loads it must wait for one at a time. That is why the 2010s work is about branch-free decoding, batching, and structures whose next address is arithmetic rather than a loaded value — and why the largest single ratio in the third section of this page, 21×, separates two loops that touch identical addresses.
The wall since 1995, and the next one¶
In 1995 Wulf and McKee published a two-page note observing that processor speed was improving by roughly half again per year while DRAM latency improved by a few per cent, and that whatever the hit rate, the average access time would eventually be dominated by the misses. They called it hitting the memory wall and expected it within a decade.
What happened is more interesting than either the prediction or its dismissal. Single-thread speed stopped improving at the predicted rate around 2005, which slowed the divergence; and the industry spent the intervening thirty years buying its way around the latency with everything except lower latency: more cache levels, wider buses, deeper out-of-order windows, hardware prefetchers, simultaneous multithreading, and stacked memory. Every one of those is a way of overlapping misses rather than shortening them. The measurements on this page are what that looks like from the inside: the difference between a random access that can be overlapped and one that cannot is 10×, and it is larger than the difference between random and sequential.
For retrieval, three things follow, and all three are visible in the sweeps above.
- Bytes per item is the design variable. Not the complexity class, and not the instruction count. It decides which level holds the structure, and that decides everything else. Vector quantisation is the current instance: a 768-dimensional float vector is 3 KiB and a product-quantised one is tens of bytes, and the entire argument for the technique is which level of the hierarchy the candidate set then fits in.
- Dependent traversal is the thing to avoid. Graph-based approximate nearest neighbour search is a pointer chase, which is the 21× bar in Figure 4. The research response — batching queries, prefetching neighbour lists, packing adjacency into contiguous blocks — is the same response the B-tree was.
- New levels are still arriving. CXL-attached memory sits between local DRAM and storage, and is the first genuinely new level since flash. Whatever index design is currently optimal will be re-derived for it, which is what the previous section says has happened roughly once a decade since 1955.
The one prediction this page is willing to make is structural rather than technical. As long as the ratio between the fastest and slowest level keeps growing — it is 84× between L1 and DRAM on the machine measured here, before storage is considered at all — retrieval will keep being a memory-layout discipline that happens to be about documents.
How these numbers were produced¶
| What it was | |
|---|---|
| Processor | Intel Xeon, family 6 model 85 stepping 7 (Cascade Lake), 4 vCPU at 2.80 GHz, on a KVM guest |
| Caches | L1d 32 KiB and L1i 32 KiB per core; L2 1 MiB per core; L3 33 MiB shared. 64-byte lines at every level, from /sys/devices/system/cpu/cpu0/cache |
| Memory | 16 GiB. Transparent huge pages set to madvise, which is what makes the paired page-size sweep possible |
| Operating system | Linux 6.18, Ubuntu 24.04 userspace |
| Compilers | GCC 13.3.0 and Clang 18.1.3, both at -O2 with no -march, so the code is what a default build produces |
| Reported statistic | Minimum over the timed passes within a run; median over runs where a sweep was repeated. Noise on a shared machine only adds time, so the minimum is the least contaminated estimate |
| Controls | A timing loop with the subject removed, reported alongside; a correctness check that every variant returns the same answer before any of them is timed; and, for the page-size sweep, the kernel's own report of how many huge pages it actually granted |
The machine and the method. Every figure on this page came from this configuration; nothing is quoted from a vendor document or another paper. Source: this page’s own measurement runs.
The four programs are published with the site, and each one prints the table this page renders:
data/memory/latency.c— the pointer chase, in both page sizes. About ninety seconds per sweep.data/memory/stride.c— the stride sweep and the three access orders.data/memory/search.c— the six dictionary layouts. Run it under both compilers; the difference is a result.data/memory/postings.c— the five postings representations and the two sweeps. About five minutes.
cc -O2 -o latency latency.c && ./latency && ./latency huge
cc -O2 -o stride stride.c && ./stride
cc -O2 -o search search.c && ./search
cc -O2 -o postings postings.c && ./postings
What is not claimed¶
- That these are good absolute numbers. They are one virtualised guest with a shared last-level cache. The shapes — the plateaus, the crossings, the orderings — are what the page argues from, and every one of them was reproduced across runs.
- That the L3 boundary is where this machine puts it. It moved between sweeps by more than a factor of two, which is what a shared cache does, and the spread column says so.
- That the retrieval variants are state of the art. They are the textbook forms, written plainly, so that the comparison is between layouts rather than between optimisation efforts. A SIMD codec or a hand-tuned gallop would move the numbers and not the ordering.
- That any of this measures a real search engine. The collection is synthetic and the postings gaps are uniform where a real term’s are heavy-tailed. That choice keeps the bytes per posting stable so that what varies between rows is the thing being tested.
- That single-threaded numbers predict throughput. Nothing here is measured under concurrency, and the shared L3 is exactly what concurrency would contend for.
One further caution about reproduction. Run these on an idle machine, one at a time. Two of the sweeps in the making of this page were run concurrently by accident and had to be discarded: they share a last-level cache, so each was measuring the other.
Sources¶
- The two articles this page connects. Wikipedia, Computer memory and Information retrieval, used for the chronology and the naming of technologies and results in the two history sections. Available under CC BY-SA 4.0.
- The hierarchy. Burks, Goldstine and von Neumann, Preliminary Discussion of the Logical Design of an Electronic Computing Instrument (1946), for the hierarchy as a proposal. M. V. Wilkes, Slave Memories and Dynamic Storage Allocation, IEEE Transactions on Electronic Computers (1965), for the cache. R. H. Dennard, US patent 3,387,286 (filed 1967), for the one-transistor DRAM cell. P. J. Denning, The Working Set Model for Program Behavior, CACM (1968), for locality. Wm. A. Wulf and Sally A. McKee, Hitting the Memory Wall: Implications of the Obvious, ACM SIGARCH Computer Architecture News 23(1) (1995).
- Retrieval. Vannevar Bush, As We May Think, The Atlantic (1945). H. P. Luhn, A Statistical Approach to Mechanized Encoding and Searching of Literary Information, IBM Journal of Research and Development (1957). R. Bayer and E. McCreight, Organization and Maintenance of Large Ordered Indices, Acta Informatica (1972). K. Spärck Jones, A Statistical Interpretation of Term Specificity and its Application in Retrieval, Journal of Documentation (1972). G. Salton, A. Wong and C. S. Yang, A Vector Space Model for Automatic Indexing, CACM (1975). I. H. Witten, A. Moffat and T. C. Bell, Managing Gigabytes (1994). A. Moffat and J. Zobel, Self-Indexing Inverted Files for Fast Text Retrieval, ACM TOIS 14(4) (1996). H. Jégou, M. Douze and C. Schmid, Product Quantization for Nearest Neighbor Search, IEEE TPAMI (2011). Y. A. Malkov and D. A. Yashunin, Efficient and Robust Approximate Nearest Neighbor Search Using Hierarchical Navigable Small World Graphs (2016).
- The language. ISO/IEC 9899:2024 (C23) — §3.15 and §6.2.6.1
(objects and their representation), §6.2.5 and §6.5.6 (arrays and pointer arithmetic),
§6.7.2.1 (structure members, flexible array members), §6.7.3.1 (
restrict), §6.7.5 and §7.15 (alignment), §7.24.3.1 (aligned_alloc), §5.1.2.3 and §5.1.2.4 (the abstract machine and the multi-threaded memory model), §7.26.2.1 (memcpy). - The machine. Cache sizes and line sizes from
/sys/devices/system/cpu/cpu0/cacheon the machine described in the method section; processor identification from/proc/cpuinfo. The second-level TLB capacity quoted in the page-size section is Intel’s documented figure for this microarchitecture; the ratio attributed to it is measured here. - Everything numeric on this page. The four programs in
data/memory/, whose raw output is the input to every table and figure above. No number on this page was copied from a published benchmark.
Every figure whose caption cites a program in data/memory/ is generated by this
page’s builder from the measurement tables above, as is every ratio quoted in prose, so a rerun of a
sweep moves the figure, the table and the sentence together. The five figures whose captions call
themselves diagrams are the exceptions: their contents are chosen to make a mechanism visible in one
screen rather than measured, and each says so where it stands.