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.

84×Measured span, L1 hit to DRAM round trip, on 4 KiB pages
4Benchmark programs behind every number here
6Dictionary layouts measured over one key set
5Postings representations measured over one conjunction

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 structureWhat it actually isThe level it is trying to reach
Inverted indexA map from term to the list of documents containing itReplaces a scan of the whole collection with a read of two small lists
B-treeA search tree whose node is exactly one block of the storage below itOne block read per level — a disk block in 1970, a cache line now
Skip pointersUncompressed headers over compressed runsLets a compressed list be traversed without decoding what it skips
Signature file / Bloom filterA lossy summary that can only produce false positivesKeeps a resident filter small enough to answer before the index is touched
Hash tableA function from key to slot, with a collision ruleOne probe, at the price of a table larger than the keys it holds
Quantised vector indexApproximate vectors plus a re-ranking passShrinks 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.

Back to top

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.

On core registers and L1: the level a C local variable lives inOn chip L2 and the shared L3Off chip DRAM, and everything slower
0.1 ns1 ns10 ns100 ns1e3 nsRegister · 16, of 64 bits · 0.3 ns — not measured here: a register access is not a load. Listed for the scaleRegister16, of 64 bits0.3 nsL1 data cache · 32 KiB per core · 1.54 ns — the level a hot loop's working set has to fit inL1 data cache32 KiB per core1.54 nsL2 cache · 1 MiB per core · 4.25 ns — large enough for a small index, small enough that one big array evicts itL2 cache1 MiB per core4.25 nsL3 cache · 33 MiB, shared · 24.0 ns — shared, so its effective size depends on the neighboursL3 cache33 MiB, shared24.0 nsDRAM · 16 GiB · 128.99 ns — one round trip, with the row activation and the bus in itDRAM16 GiB128.99 nsrequest
Figure 1. The storage hierarchy of the machine these measurements were taken on, with the latency of each level as measured by the pointer chase. The bar axis is logarithmic because the levels are two orders of magnitude apart; each bar also carries its number. The register row is the one number here that is not measured — a register is not addressed and cannot be chased — and is shown to fix the top of the scale. Capacities are from /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.
1101001e38K24K64K192K512K1.5M4M12M32M96M512M4 KiB pages · 4K: 1.511.514 KiB pages · 6K: 1.551.554 KiB pages · 8K: 1.551.554 KiB pages · 12K: 1.541.544 KiB pages · 16K: 1.521.524 KiB pages · 24K: 1.521.524 KiB pages · 32K: 1.541.544 KiB pages · 48K: 4.254.254 KiB pages · 64K: 4.244.244 KiB pages · 96K: 4.264.264 KiB pages · 128K: 4.254.254 KiB pages · 192K: 4.274.274 KiB pages · 256K: 4.254.254 KiB pages · 384K: 5.285.284 KiB pages · 512K: 5.925.924 KiB pages · 768K: 8.678.674 KiB pages · 1M: 14.4514.454 KiB pages · 1.5M: 22.6722.674 KiB pages · 2M: 23.8123.814 KiB pages · 3M: 24.024.04 KiB pages · 4M: 24.0524.054 KiB pages · 6M: 25.6125.614 KiB pages · 8M: 31.5131.514 KiB pages · 12M: 107.06107.064 KiB pages · 16M: 126.09126.094 KiB pages · 24M: 128.99128.994 KiB pages · 32M: 130.97130.974 KiB pages · 48M: 131.08131.084 KiB pages · 64M: 128.39128.394 KiB pages · 96M: 125.32125.324 KiB pages · 128M: 128.98128.984 KiB pages · 256M: 137.61137.614 KiB pages · 512M: 150.28150.282 MiB pages · 4K: 1.51.52 MiB pages · 6K: 1.511.512 MiB pages · 8K: 1.51.52 MiB pages · 12K: 1.511.512 MiB pages · 16K: 1.521.522 MiB pages · 24K: 1.511.512 MiB pages · 32K: 1.521.522 MiB pages · 48K: 4.224.222 MiB pages · 64K: 4.254.252 MiB pages · 96K: 4.244.242 MiB pages · 128K: 4.234.232 MiB pages · 192K: 4.254.252 MiB pages · 256K: 4.244.242 MiB pages · 384K: 5.25.22 MiB pages · 512K: 5.735.732 MiB pages · 768K: 8.998.992 MiB pages · 1M: 14.9514.952 MiB pages · 1.5M: 22.2722.272 MiB pages · 2M: 23.5423.542 MiB pages · 3M: 23.8923.892 MiB pages · 4M: 23.7923.792 MiB pages · 6M: 26.1226.122 MiB pages · 8M: 28.2428.242 MiB pages · 12M: 105.19105.192 MiB pages · 16M: 105.85105.852 MiB pages · 24M: 104.83104.832 MiB pages · 32M: 106.72106.722 MiB pages · 48M: 104.35104.352 MiB pages · 64M: 104.55104.552 MiB pages · 96M: 105.15105.152 MiB pages · 128M: 105.26105.262 MiB pages · 256M: 111.11111.112 MiB pages · 512M: 115.9115.94 KiB pages2 MiB pagesns per load
Figure 2. The same sweep as a curve, on both axes logarithmic: working-set size against time for one dependent load. Flat means a level answered; a rise means its capacity was passed. The two series are the identical program over the identical addresses, differing only in the page size the mapping was given. Source: 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 setLines4 KiB pages, nsspread2 MiB pages, nsspread4 KiB / 2 MiB
4 KiB641.51±0.101.50±0.011.01×
6 KiB961.55±0.071.51±0.021.03×
8 KiB1281.55±0.081.50±0.021.03×
12 KiB1921.54±0.061.51±0.031.02×
16 KiB2561.52±0.011.52±0.011.00×
24 KiB3841.52±0.001.51±0.011.01×
32 KiB5121.54±0.041.52±0.021.01×
48 KiB7684.25±0.034.22±0.031.01×
64 KiB1,0244.24±0.044.25±0.021.00×
96 KiB1,5364.26±0.034.24±0.021.00×
128 KiB2,0484.25±0.044.23±0.021.00×
192 KiB3,0724.27±0.164.25±0.011.00×
256 KiB4,0964.25±0.244.24±0.011.00×
384 KiB6,1445.28±0.185.20±0.031.02×
512 KiB8,1925.92±1.185.73±0.101.03×
768 KiB12,2888.67±2.318.99±1.290.96×
1 MiB16,38414.45±0.3614.95±0.840.97×
1 MiB24,57622.67±0.7522.27±1.401.02×
2 MiB32,76823.81±0.6123.54±1.441.01×
3 MiB49,15224.00±1.0223.89±1.881.00×
4 MiB65,53624.05±1.0223.79±1.241.01×
6 MiB98,30425.61±0.7826.12±3.190.98×
8 MiB131,07231.51±4.1928.24±4.321.12×
12 MiB196,608107.06±73.69105.19±47.541.02×
16 MiB262,144126.09±25.57105.85±30.181.19×
24 MiB393,216128.99±2.61104.83±24.021.23×
32 MiB524,288130.97±3.18106.72±18.811.23×
48 MiB786,432131.08±3.90104.35±14.021.26×
64 MiB1,048,576128.39±9.88104.55±12.441.23×
96 MiB1,572,864125.32±14.12105.15±9.241.19×
128 MiB2,097,152128.98±9.20105.26±6.331.23×
256 MiB4,194,304137.61±7.19111.11±3.771.24×
512 MiB8,388,608150.28±1.94115.90±56.611.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.

One caveat, stated up front, because it recurs below. The L3 on this machine is shared — with three sibling vCPUs, and with whatever else the host is running. The plateau values reproduced across three back-to-back sweeps; the position of the edge between L3 and DRAM did not, which is why the spread column exists and why it is worth an order of magnitude at exactly one size. A figure of this shape is evidence about the machine it ran on. The shape is general; the numbers are local.

Back to top

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.

The program asks for one objectbyte 19the machine moves 64 bytesOne 64-byte cache lineall of it, always
Figure 3. What a C program asks for, and what the machine moves. The request is one object at one address; the transfer is the 64-byte line that contains it, and the cells arrive together rather than in sequence because they are one transfer. The line size is from /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.

StrideElements readns per elementns per 64-byte lineLines per element
1 × 4 B = 4 B16,777,2160.65110.423several elements per line
2 × 4 B = 8 B8,388,6080.9777.817several elements per line
4 × 4 B = 16 B4,194,3041.6296.514several elements per line
8 × 4 B = 32 B2,097,1522.8745.748several elements per line
16 × 4 B = 64 B1,048,5765.7905.790one element per line
32 × 4 B = 128 B524,2889.0039.003one line per element, and a gap
64 × 4 B = 256 B262,1449.5399.539one 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:

Sequential the next address is the previous one plus a constantRandom the next address is a value that has to be loaded firstNeither measured control, or a structure with no single pattern
1 ns10 ns100 ns1e3 nsSequential — 6.0 ns. one line after the next; the prefetcher has the address before the loop doesSequential6.0 nsRandom, independent — 12.7 ns. a permuted order read from an index array, so many misses are in flight at onceRandom, independent12.7 nsRandom, dependent — 125.9 ns. each address is the value at the last one, so nothing overlapsRandom, dependent125.9 ns
Figure 4. One million cache lines, three orders. All three loops touch the identical addresses in the identical 64 MiB array and do the identical arithmetic. The axis is logarithmic. Source: 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×.

It is not randomness that is expensive. It is dependency. A structure whose next address is the value at the current one — a linked list, a pointer-based tree, a hash chain — serialises its misses and pays for each of them in full. A structure that can compute several addresses at once — an implicit tree in an array, a batch of independent probes, a scan — overlaps them. This is why the retrieval structures further down separate into two groups that complexity analysis puts in the same class.

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.

Back to top

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 set4 KiB pages, ns2 MiB pages, nsDifference, nsRatio
16 MiB126.09105.8520.21.19×
24 MiB128.99104.8324.21.23×
32 MiB130.97106.7224.21.23×
48 MiB131.08104.3526.71.26×
64 MiB128.39104.5523.81.23×
96 MiB125.32105.1520.21.19×
128 MiB128.98105.2623.71.23×
256 MiB137.61111.1126.51.24×
512 MiB150.28115.9034.41.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.

An index large enough to matter is a translation problem before it is a cache problem. The curve has two cliffs and only the first one is a cache. A reader who knows the cache sizes and not the TLB reach will place the second cliff at 33 MiB, where the L3 ends, and be wrong by a factor of five — and the fix for the one they are actually hitting is a flag on the mapping, not a smaller structure. Every large retrieval index in production is mapped with huge pages for this reason, and none of it is expressible in C: mmap, madvise and the page size are the operating system’s vocabulary, not the language’s.

Back to top

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 guaranteesWhereWhy 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 representationLayout 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 definedAn 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 insertedField 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 C23A 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 alignmentThe 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 structureA 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 pointerLets 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 bytesBulk 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 exposeStandard positionWhere the answer actually comes from
The size of a cache lineNothing in the standard names itsysconf(_SC_LEVEL1_DCACHE_LINESIZE), /sys, or a constant
How many levels of cache existNothing in the standard names themPlatform interfaces, or measurement — which is what this page did
PrefetchingNo standard facility__builtin_prefetch (GCC, Clang), _mm_prefetch (x86 intrinsics)
The page size, or huge pagesOutside the languagesysconf(_SC_PAGESIZE), mmap, madvise(MADV_HUGEPAGE)
Which node of memory an allocation is onOutside the languagelibnuma, mbind, first-touch policy
Whether a branch will be predictedNo standard facility[[likely]] / [[unlikely]] (C23) are hints about which path, not about cost
What the emitted code will beThe as-if rule, §5.1.2.3Reading 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.

Two different things are called the C memory model, and they are not related. The one in §5.1.2.4, added in C11 and covered on the C page, is about ordering and visibility between threads: what one thread is guaranteed to see of another’s writes, expressed with _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.

Back to top

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.

Query terma stringone probeDictionaryterm → offsetone seekPostingssorted docidstwo listsConjunctionmerge or skiptop kRankingscore, sort, cut
Figure 5. The query path through an inverted index. The two lookups have almost nothing in common: the first is a small number of random probes into a structure that should be resident, the second is a large sequential read of a structure that cannot be. Diagram: Programming Language Atlas.

The two structures have opposite characters, and the rest of this page measures them separately for exactly that reason.

DictionaryPostings
What it holdsOne entry per distinct termOne entry per (term, document) pair
Size, roughlyVocabulary-sized: millions of entriesCollection-sized: billions of entries
Access per query termOne lookupA full or partial traversal of one list
Access patternRandom — a probe whose address depends on the termSequential — or sequential with skips
What decides its costWhether it is resident, and how many dependent loads a lookup takesHow many bytes per posting, and whether the traversal can skip
Measured belowSix layouts of one key setFive 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.

08162432404856docidquery term Aquery term Blast 14last 30skippedlast 47last 602 in both
Figure 6. A conjunction over two postings lists, with the long list cut into skippable blocks. Each block carries its largest docid in an uncompressed header, so a block whose largest docid is below the cursor cannot contain a match and its payload is never read. The green marks are the documents in both lists. A diagram, not a measurement: the docids are chosen to make the shape visible in one screen. The blocks in 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.

Back to top

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.

VariantWhat it isComplexityAccess patternWhat it is trading
scanSorted array, read from the frontO(n)SequentialThe prefetcher’s best case, and the only variant here whose cost is linear
binarySorted array, halve the rangeO(log n)Random, dependentOne unpredictable branch per level; the next address is not known until the compare resolves
branchlessThe same search, compare folded into the indexO(log n)Random, dependentNo branch to mispredict — if the compiler agrees, which is the finding below
eytzingerThe same keys in breadth-first orderO(log n)Random, dependentThe hot top of the tree is contiguous instead of spread over the whole array
btree17-way static B-tree, one 64-byte node per levelO(log17 n)Random, dependentOne cache line per level instead of one line per comparison
hashOpen addressing, load factor 0.5O(1) expectedRandom, one probeOne 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.

scan read the sorted array from the frontbinary halve the range, branching on each comparebranchless the same search with the compare in the arithmeticeytzinger the same keys stored breadth-firstbtree 17-way tree, one cache line per nodehash open addressing, one probe
1101001e31e41e52^62^92^122^152^182^212^24scan · 2^4: 15.615.6scan · 2^5: 2121scan · 2^6: 32.732.7scan · 2^7: 52.152.1scan · 2^8: 91.791.7scan · 2^9: 170.2170.2scan · 2^10: 326326scan · 2^11: 641.2641.2scan · 2^12: 1270.41270.4scan · 2^13: 2552.72552.7scan · 2^14: 5114.65114.6scan · 2^15: 10105.410105.4scan · 2^16: 20357.520357.5binary · 2^4: 24.4524.45binary · 2^5: 30.6630.66binary · 2^6: 39.5939.59binary · 2^7: 4747binary · 2^8: 53.9253.92binary · 2^9: 60.1360.13binary · 2^10: 66.4966.49binary · 2^11: 71.7671.76binary · 2^12: 77.8777.87binary · 2^13: 85.2785.27binary · 2^14: 96.6296.62binary · 2^15: 106.23106.23binary · 2^16: 116.09116.09binary · 2^17: 129.83129.83binary · 2^18: 150.04150.04binary · 2^19: 191.98191.98binary · 2^20: 238.01238.01binary · 2^21: 270.28270.28binary · 2^22: 318.72318.72binary · 2^23: 432.59432.59binary · 2^24: 575.69575.69branchless · 2^4: 20.2920.29branchless · 2^5: 24.8824.88branchless · 2^6: 30.2930.29branchless · 2^7: 37.1737.17branchless · 2^8: 43.7843.78branchless · 2^9: 49.2949.29branchless · 2^10: 54.6154.61branchless · 2^11: 59.3359.33branchless · 2^12: 65.7365.73branchless · 2^13: 68.9168.91branchless · 2^14: 80.6480.64branchless · 2^15: 88.788.7branchless · 2^16: 97.0797.07branchless · 2^17: 109.9109.9branchless · 2^18: 128.26128.26branchless · 2^19: 170.92170.92branchless · 2^20: 201.64201.64branchless · 2^21: 242.95242.95branchless · 2^22: 287.85287.85branchless · 2^23: 404.07404.07branchless · 2^24: 546.06546.06eytzinger · 2^4: 7.997.99eytzinger · 2^5: 8.618.61eytzinger · 2^6: 9.879.87eytzinger · 2^7: 11.4711.47eytzinger · 2^8: 12.9412.94eytzinger · 2^9: 14.6914.69eytzinger · 2^10: 16.3316.33eytzinger · 2^11: 17.8617.86eytzinger · 2^12: 19.6919.69eytzinger · 2^13: 21.721.7eytzinger · 2^14: 25.0625.06eytzinger · 2^15: 28.2928.29eytzinger · 2^16: 32.432.4eytzinger · 2^17: 38.7638.76eytzinger · 2^18: 51.851.8eytzinger · 2^19: 68.8468.84eytzinger · 2^20: 96.4296.42eytzinger · 2^21: 140.49140.49eytzinger · 2^22: 251.47251.47eytzinger · 2^23: 424.08424.08eytzinger · 2^24: 561.51561.51btree · 2^4: 6.376.37btree · 2^5: 15.0715.07btree · 2^6: 13.9913.99btree · 2^7: 13.213.2btree · 2^8: 12.7312.73btree · 2^9: 21.7221.72btree · 2^10: 21.2521.25btree · 2^11: 20.5520.55btree · 2^12: 20.0720.07btree · 2^13: 29.2629.26btree · 2^14: 30.530.5btree · 2^15: 29.6529.65btree · 2^16: 30.1130.11btree · 2^17: 39.9939.99btree · 2^18: 46.3946.39btree · 2^19: 47.3547.35btree · 2^20: 57.3857.38btree · 2^21: 79.3979.39btree · 2^22: 120.39120.39btree · 2^23: 152.28152.28btree · 2^24: 189.01189.01hash · 2^4: 7.447.44hash · 2^5: 7.477.47hash · 2^6: 7.467.46hash · 2^7: 7.417.41hash · 2^8: 7.467.46hash · 2^9: 7.457.45hash · 2^10: 7.457.45hash · 2^11: 7.497.49hash · 2^12: 7.517.51hash · 2^13: 8.078.07hash · 2^14: 8.398.39hash · 2^15: 8.278.27hash · 2^16: 9.059.05hash · 2^17: 9.699.69hash · 2^18: 11.1611.16hash · 2^19: 10.7910.79hash · 2^20: 13.1413.14hash · 2^21: 18.1618.16hash · 2^22: 20.7120.71hash · 2^23: 24.0124.01hash · 2^24: 27.7827.78scanbinaryeytzingerbranchlessbtreehashns per query
Figure 7. Nanoseconds per membership query against the number of keys, both axes logarithmic. Compiled with GCC 13.3. The scan stops at 65,536 keys: its shape is established and its value is off the top of the chart after that. 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.

VariantBytes per key16 keys1 Ki64 Ki1 Mi16 Mi16 Mi ÷ 16
scan415.6326.020357.5
binary424.466.5116.1238.0575.724×
branchless420.354.697.1201.6546.127×
eytzinger48.016.332.496.4561.570×
btree46.421.230.157.4189.030×
hash87.47.59.113.127.8

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.

1101001e32^62^92^122^152^182^212^24GCC binary · 2^4: 24.4524.45GCC binary · 2^5: 30.6630.66GCC binary · 2^6: 39.5939.59GCC binary · 2^7: 4747GCC binary · 2^8: 53.9253.92GCC binary · 2^9: 60.1360.13GCC binary · 2^10: 66.4966.49GCC binary · 2^11: 71.7671.76GCC binary · 2^12: 77.8777.87GCC binary · 2^13: 85.2785.27GCC binary · 2^14: 96.6296.62GCC binary · 2^15: 106.23106.23GCC binary · 2^16: 116.09116.09GCC binary · 2^17: 129.83129.83GCC binary · 2^18: 150.04150.04GCC binary · 2^19: 191.98191.98GCC binary · 2^20: 238.01238.01GCC binary · 2^21: 270.28270.28GCC binary · 2^22: 318.72318.72GCC binary · 2^23: 432.59432.59GCC binary · 2^24: 575.69575.69GCC branchless · 2^4: 20.2920.29GCC branchless · 2^5: 24.8824.88GCC branchless · 2^6: 30.2930.29GCC branchless · 2^7: 37.1737.17GCC branchless · 2^8: 43.7843.78GCC branchless · 2^9: 49.2949.29GCC branchless · 2^10: 54.6154.61GCC branchless · 2^11: 59.3359.33GCC branchless · 2^12: 65.7365.73GCC branchless · 2^13: 68.9168.91GCC branchless · 2^14: 80.6480.64GCC branchless · 2^15: 88.788.7GCC branchless · 2^16: 97.0797.07GCC branchless · 2^17: 109.9109.9GCC branchless · 2^18: 128.26128.26GCC branchless · 2^19: 170.92170.92GCC branchless · 2^20: 201.64201.64GCC branchless · 2^21: 242.95242.95GCC branchless · 2^22: 287.85287.85GCC branchless · 2^23: 404.07404.07GCC branchless · 2^24: 546.06546.06Clang binary · 2^4: 22.7222.72Clang binary · 2^5: 27.6727.67Clang binary · 2^6: 32.5632.56Clang binary · 2^7: 37.3937.39Clang binary · 2^8: 42.5842.58Clang binary · 2^9: 49.3849.38Clang binary · 2^10: 55.2855.28Clang binary · 2^11: 60.2960.29Clang binary · 2^12: 66.5466.54Clang binary · 2^13: 72.3572.35Clang binary · 2^14: 84.5684.56Clang binary · 2^15: 92.8492.84Clang binary · 2^16: 101.47101.47Clang binary · 2^17: 117.27117.27Clang binary · 2^18: 139.34139.34Clang binary · 2^19: 179.46179.46Clang binary · 2^20: 212.65212.65Clang binary · 2^21: 255.37255.37Clang binary · 2^22: 299.39299.39Clang binary · 2^23: 426.07426.07Clang binary · 2^24: 568.51568.51Clang branchless · 2^4: 5.915.91Clang branchless · 2^5: 7.287.28Clang branchless · 2^6: 99Clang branchless · 2^7: 10.4810.48Clang branchless · 2^8: 11.9911.99Clang branchless · 2^9: 13.6113.61Clang branchless · 2^10: 15.2315.23Clang branchless · 2^11: 17.5717.57Clang branchless · 2^12: 20.2220.22Clang branchless · 2^13: 22.7222.72Clang branchless · 2^14: 30.7130.71Clang branchless · 2^15: 35.8835.88Clang branchless · 2^16: 41.0841.08Clang branchless · 2^17: 52.8152.81Clang branchless · 2^18: 73.1373.13Clang branchless · 2^19: 124.07124.07Clang branchless · 2^20: 173.77173.77Clang branchless · 2^21: 298.8298.8Clang branchless · 2^22: 424.4424.4Clang branchless · 2^23: 678.08678.08Clang branchless · 2^24: 908.92908.92Clang branchlessGCC binaryClang binaryGCC branchlessns per query
Figure 8. Binary search and its branchless rewrite, under two compilers, on logarithmic axes. The source is byte-identical in all four series; only the compiler differs. Three of the four coincide and are drawn one over another: the rewrite changes nothing under GCC, and under Clang it changes everything. Source: 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.

“Branchless” is a property of the emitted code, not of the source. Three things had to be true for the fast version to appear: the source had to be written that way, the compiler had to agree, and the data had to fit in cache. C guarantees none of them. This is why the atlas measures rather than restates — the same argument the calling conventions page makes about argument passing, arriving here from the other direction.

Back to top

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.

RepresentationWhat it isBytes per postingWhat it gives up
mergeTwo uint32 arrays, one linear pass4Nothing. It is the baseline: the algorithm with no representation trick at all
gallopThe same arrays; the short list drives, each posting found by exponential search4Random access into the long list, in exchange for not reading most of it
vbyteGaps between docids under variable-byte coding, decoded end to end1.0–2.0All ability to skip — a compressed sequence has no random access
blocksThe same coding in 128-posting blocks, each with its last docid in a plain header1.0–2.0A header per block and slightly worse compression, to get the skip back
bitmapOne bit per document in the collection, AND-ed a word at a timeconstantAny 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.

merge linear pass over two raw uint32 arraysgallop the short list drives, by exponential searchvbyte d-gaps, variable-byte, decoded end to endblocks the same coding with skippable block headersbitmap one bit per document, AND-ed by the word
1101001e31e41e51 Ki4 Ki16 Ki64 Ki256 Ki1 Mi4 Mimerge · 1 Ki: 5.5465.546merge · 4 Ki: 22.07822.078merge · 16 Ki: 90.51490.514merge · 64 Ki: 360.409360.409merge · 256 Ki: 1426.9771426.977merge · 1 Mi: 5489.2675489.267merge · 4 Mi: 17721.40917721.409gallop · 1 Ki: 5.4615.461gallop · 4 Ki: 33.42133.421gallop · 16 Ki: 154.442154.442gallop · 64 Ki: 613.818613.818gallop · 256 Ki: 2460.7132460.713gallop · 1 Mi: 9890.989890.98gallop · 4 Mi: 34187.9234187.92vbyte · 1 Ki: 7.07.0vbyte · 4 Ki: 43.14343.143vbyte · 16 Ki: 217.928217.928vbyte · 64 Ki: 1118.421118.42vbyte · 256 Ki: 2925.2172925.217vbyte · 1 Mi: 11861.64511861.645vbyte · 4 Mi: 50512.55750512.557blocks · 1 Ki: 8.6488.648blocks · 4 Ki: 55.23955.239blocks · 16 Ki: 274.629274.629blocks · 64 Ki: 1345.6571345.657blocks · 256 Ki: 3560.4143560.414blocks · 1 Mi: 14152.17314152.173blocks · 4 Mi: 49459.2449459.24bitmap · 1 Ki: 492.185492.185bitmap · 4 Ki: 488.603488.603bitmap · 16 Ki: 535.983535.983bitmap · 64 Ki: 531.843531.843bitmap · 256 Ki: 529.64529.64bitmap · 1 Mi: 539.342539.342bitmap · 4 Mi: 521.124521.124vbyteblocksgallopmergebitmapµs per AND
Figure 9. Equal-length lists: microseconds per conjunction against the length of each list, both axes logarithmic. The bitmap line is flat because its size is the collection, not the list. Source: 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 listmergegallopvbyteblocksbitmapIn both
1 Ki5.55.57.08.6492.20
4 Ki22.133.443.155.2488.61
16 Ki90.5154.4217.9274.6536.038
64 Ki360.4613.81,118.41,345.7531.8510
256 Ki1,427.02,460.72,925.23,560.4529.68,258
1 Mi5,489.39,891.011,861.614,152.2539.3131,205
4 Mi17,721.434,187.950,512.649,459.2521.12,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.

1101001e31e41e51 Ki4 Ki16 Ki64 Ki256 Ki1 Mi4 Mimerge · 1 Ki: 5.6945.694merge · 4 Ki: 14.13814.138merge · 16 Ki: 46.49446.494merge · 64 Ki: 175.028175.028merge · 256 Ki: 698.794698.794merge · 1 Mi: 2773.8712773.871merge · 4 Mi: 11337.6411337.64gallop · 1 Ki: 5.6225.622gallop · 4 Ki: 9.1169.116gallop · 16 Ki: 19.66119.661gallop · 64 Ki: 33.14933.149gallop · 256 Ki: 50.71650.716gallop · 1 Mi: 118.038118.038gallop · 4 Mi: 189.94189.94vbyte · 1 Ki: 7.6717.671vbyte · 4 Ki: 25.10725.107vbyte · 16 Ki: 82.45782.457vbyte · 64 Ki: 440.55440.55vbyte · 256 Ki: 648.685648.685vbyte · 1 Mi: 2558.382558.38vbyte · 4 Mi: 10418.84410418.844blocks · 1 Ki: 9.6789.678blocks · 4 Ki: 22.87522.875blocks · 16 Ki: 67.40567.405blocks · 64 Ki: 349.307349.307blocks · 256 Ki: 214.58214.58blocks · 1 Mi: 247.146247.146blocks · 4 Mi: 278.588278.588bitmap · 1 Ki: 539.991539.991bitmap · 4 Ki: 494.323494.323bitmap · 16 Ki: 489.245489.245bitmap · 64 Ki: 488.428488.428bitmap · 256 Ki: 492.769492.769bitmap · 1 Mi: 487.241487.241bitmap · 4 Mi: 492.158492.158mergevbytebitmapblocksgallopµs per AND
Figure 10. Skewed lists: one list pinned at 1,024 postings, the other growing to four million. Same five representations, same axes. This is the regime an actual query lives in, because term frequencies are heavily skewed. Source: 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 listmergegallopvbyteblocksbitmapIn bothBytes/posting, blocked
1 Ki5.75.67.79.7540.002.10
4 Ki14.19.125.122.9494.312.08
16 Ki46.519.782.567.4489.221.99
64 Ki175.033.1440.6349.3488.441.62
256 Ki698.850.7648.7214.6492.8371.12
1 Mi2,773.9118.02,558.4247.1487.21321.12
4 Mi11,337.6189.910,418.8278.6492.25171.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.

No representation wins twice. The linear merge is best on equal lists and worst on skewed ones. The bitmap is worst by 89× at the top of the table and best by 34× at the bottom. Compression is a loss in one sweep and a near-free 3.6× size reduction in the other. A search engine therefore does not choose a representation; it chooses one per list, from the list’s own statistics, which is what Roaring bitmaps and Lucene’s per-block codec selection both are.

Back to top

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.

10×100×Merge a sparse list, or bitmap it — 88.75×. the same comparison at 1 Ki postings; the winner has swappedMerge a sparse list, or bitmap it88.75×Gallop a long list, or merge it — 59.69×. raw docids either way, 1 Ki against 4 Mi postingsGallop a long list, or merge it59.69×Skip a compressed list, or decode it — 37.4×. blocked versus flat variable-byte, 1 Ki against 4 Mi postingsSkip a compressed list, or decode it37.4×Bitmap a dense list, or merge it — 34.01×. two lists of 4 Mi postings in 8 Mi documentsBitmap a dense list, or merge it34.01×Read in order, or chase a pointer — 21.12×. one million cache lines, same addresses, same arithmeticRead in order, or chase a pointer21.12×Remove the branch, in cache — 3.84×. identical source, 16 keysRemove the branch, in cache3.84×One line per level, or per compare — 3.05×. static B-tree against binary search, 16 Mi keys, GCCOne line per level, or per compare3.05×Store a tree breadth-first, or sorted — 2.47×. identical keys, identical search, 1 Mi keys, GCCStore a tree breadth-first, or sorted2.47×Keep the branch, out of cache — 1.6×. identical source, 16 Mi keys: the same rewrite, now a lossKeep the branch, out of cache1.6×Map the index with huge pages — 1.23×. 512 MiB working set, one madvise call apartMap the index with huge pages1.23×
Figure 11. Every ratio on this page worth more than a few per cent, on a logarithmic axis. Each bar is one pair of measurements from the tables above, divided. Two of them — the two named as choices between a bitmap, a gallop and a merge — compare different complexity classes; every other bar is one algorithm against itself with its bytes moved. Computed by this page’s builder from the sweeps in data/memory/.

The choice is made from three properties of the data, in this order.

  1. 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.
  2. 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”.
  3. 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 dataUseMeasured margin here
The whole structure fits in L1It still matters, and less than it will. At 16 keys the five layouts already span 18 ns, against 548 ns at 16 Mi30× less spread than at the largest size measured
Exact-match lookup, structure larger than L3Hash table — one dependent load beats several, and its size penalty is already paid28 ns against 576 ns for binary search, 16 Mi keys
Ordered lookup, structure larger than L3Blocked search tree with a node the size of a cache line0.33× the cost of binary search over the same keys
Two lists of similar length, sparseLinear merge over raw docids; compression costs more to decode than the bytes cost to move2.9× against flat variable-byte at the largest equal size
Two lists of similar length, denseBitmap; its cost is the collection and it stops depending on the term at all34× against a merge at half the collection per list
Lists of very different lengthSkip — galloping over raw docids, or blocked compression with headers60× against a merge, 1 Ki against 4 Mi postings
The index will not fit in memory at allCompress harder and accept the decode: the level below is four orders of magnitude away, not oneNot 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.

Back to top

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.

1940195019601970198019902000201020202030Delay lines, CRT stores — serial or fragile, kilobitsDelay lines, CRT storesserial or fragile, kilobitsMagnetic core — random access, microsecondsMagnetic corerandom access, microsecondsSemiconductor DRAM — Dennard's one-transistor cell, stillSemiconductor DRAMDennard's one-transistor cell, stillCache between them — Wilkes 1965, shipped 1968Cache between themWilkes 1965, shipped 1968Virtual memory, paging — Atlas 1962; the TLB followsVirtual memory, pagingAtlas 1962; the TLB followsFlash, then NVMe — the level that displaced the diskFlash, then NVMethe level that displaced the diskStacked and pooled DRAM — HBM 2013, CXL 2019Stacked and pooled DRAMHBM 2013, CXL 2019
Figure 12. The technologies, as spans. Two things are worth reading off it: how briefly each pre-semiconductor technology lasted, and that nothing has replaced Dennard's DRAM cell in more than fifty years — the additions since have all been new levels rather than new main memory. Diagram: Programming Language Atlas, from the dates in the table below.
WhenWhatWhat it wasAccess time, order of magnitude
1946The hierarchy is proposed before it is builtBurks, Goldstine and von Neumann argue that an ideal memory is impossible and that a graded series of stores is the answer
1947Mercury delay lineA bit is an acoustic pulse in a tube of mercury, read as it comes round. EDVAC, EDSAC, UNIVAC Ihundreds of µs, and strictly in order
1948Williams–Kilburn tubeCharge on the face of a cathode-ray tube; the first genuinely random-access digital store. The Manchester Baby runs the first stored program on onetens of µs
1953Magnetic coreFerrite rings on a wire grid, after Forrester, Wang and Woo. Non-volatile, destructive read, and the dominant memory for twenty yearsµs
1956The disk arrives, and with it the seekIBM 350 RAMAC: five million characters, and an access time three orders of magnitude worse than corehundreds of ms
1962Virtual memoryThe 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
1965The cacheWilkes proposes a small fast “slave memory” holding recently used words. IBM ships one in the System/360 Model 85 in 1968
1966The DRAM cellDennard, at IBM: one transistor and one capacitor. Still the cell in every commodity memory made today
1968The working setDenning formalises locality of reference, which is the assumption every level of the hierarchy is betting on
1970DRAM becomes a commodityThe Intel 1103, one kilobit; within two years the best-selling semiconductor memory in the world, and the end of corehundreds of ns
1980FlashMasuoka at Toshiba; NOR announced 1984, NAND 1987. Eventually a level of its own between DRAM and disktens of µs
1993Synchronous DRAMMemory clocked with the bus rather than answering asynchronously; DDR follows in 2000 and doubles per generation on bandwidth, not latencytens of ns
1995The memory wall is namedWulf and McKee observe that processor speed is improving far faster than memory latency and that the average access will come to dominate everything
2013High-bandwidth memoryDRAM stacked on the package with a very wide bus. Bandwidth improves by an order of magnitude; latency does nottens of ns
2019Memory over a fabricCXL: memory attached over PCIe and shared between hosts. A new level, slower than local DRAM and faster than storagehundreds 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.

Back to top

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.

1940195019601970198019902000201020202030Manual cataloguing — the index is written by peopleManual cataloguingthe index is written by peopleStatistical indexing — term frequencies, vector spaceStatistical indexingterm frequencies, vector spaceCompressed inverted files — d-gaps, blocks, skipsCompressed inverted filesd-gaps, blocks, skipsLink and learned ranking — PageRank, then learning to rankLink and learned rankingPageRank, then learning to rankDense vector retrieval — quantise, search, re-rankDense vector retrievalquantise, search, re-rank
Figure 13. The eras of retrieval practice, as overlapping spans. Nothing here replaces what came before it: a system built today has a compressed inverted index from the 1990s, a scorer descended from 1972, and a vector index from the 2010s, all at once. Diagram: Programming Language Atlas, from the dates in the table below.
WhenWhatWhat happenedWhat it changed
1945The MemexBush 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 problemAssociation, not enumeration
1950The nameMooers coins “information retrieval” for the problem of finding documents rather than computing answers
1957Statistics instead of cataloguingLuhn, at IBM, proposes deriving index terms from word frequencies in the text itself rather than from a human classifierThe index becomes computable
1958Precision and recallThe Cranfield experiments under Cleverdon establish that retrieval systems are compared by measurement on a shared collection with known answersEvaluation becomes a method
1960Probabilistic indexingMaron and Kuhns rank documents by the probability of relevance rather than partition them into matching and notRanking, not filtering
1961SMARTSalton’s system at Harvard and then Cornell; the vector space model, term weighting, relevance feedback, and the first large body of comparative resultsDocuments as vectors
1972The B-treeBayer 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 storageThe index takes the shape of the hardware
1972Inverse document frequencySpä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
1994BM25Robertson and colleagues’ Okapi weighting at TREC-3, still the baseline that learned systems are measured against thirty years later
1994Managing GigabytesWitten, Moffat and Bell make index compression a mainstream engineering practice: d-gaps, Golomb and Elias coding, and the arithmetic of when they payBytes are the budget
1996Self-indexing inverted filesMoffat and Zobel add uncompressed skip headers over compressed runs, so a compressed list can be traversed without decoding what it skipsCompression and skipping stop being alternatives
1998PageRankBrin and Page rank by the structure of the link graph rather than the text alone; the query-independent half of the score can be precomputedScores move off the query path
1999LuceneCutting’s open-source index; the block-and-skip layout of the 1996 paper becomes the default implementation for a generation
2011Product quantisationJé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-rankedCompression, again, for the same reason
2016HNSWMalkov and Yashunin’s navigable small-world graphs: approximate nearest neighbours by greedy descent through a layered graphA pointer chase, with all that implies
2019Neural ranking at scaleTransformer 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.

The 1970 B-tree and the 2020s cache-conscious tree are one design. “Make the node the size of the block the level below moves in” produced a 4 KiB node against a disk and produces a 64-byte node against DRAM. The 3.0× margin measured in the dictionary sweep is Bayer and McCreight’s argument, run against a storage level that did not exist when they made it.

Back to top

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”.

EraWhere the index livedWhat one access costWhat the field didWhat it was minimising
1950sTape and cardsSeconds, and only in orderSort, then scan in one passBatch retrieval: the query waits for the tape
1960s–70sDisk over coreTens of ms to seek; µs in coreInverted file; B-tree with a node the size of a disk blockMinimise seeks. A comparison is free next to an access
1980s–90sDisk over DRAMAbout 10 ms to seek; about 100 ns in DRAMCompress everything: d-gaps, Golomb, front codingMinimise bytes. Every byte saved is disk time not spent
2000sDRAM, sharded across machinesAbout 60 ns local; a network hop away otherwiseKeep the index resident; shard it; skip within itMinimise machines touched, then bytes
2010sDRAM behind three cache levelsAbout 1 ns in L1; about 100 ns in DRAMBlock-max WAND; SIMD codecs; cache-line-sized nodesMinimise dependent misses. Bandwidth is plentiful, latency is not
2020sDRAM, HBM, and a fabricSimilar latency; an order of magnitude more bandwidthQuantised vectors, graph indexes, re-rankingMinimise 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 pattern generalises, and it is the reason this page exists. A retrieval structure encodes an assumption about the cost of an access. When the hierarchy changes, the assumption expires and the structure has to be rebuilt — even though nothing about the query, the collection or the algorithm has changed at all. Reading the two Wikipedia articles as one subject is not a rhetorical move; it is what lets you predict which structure is about to become wrong.

Back to top

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.

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.

Back to top

How these numbers were produced

What it was
ProcessorIntel Xeon, family 6 model 85 stepping 7 (Cascade Lake), 4 vCPU at 2.80 GHz, on a KVM guest
CachesL1d 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
Memory16 GiB. Transparent huge pages set to madvise, which is what makes the paired page-size sweep possible
Operating systemLinux 6.18, Ubuntu 24.04 userspace
CompilersGCC 13.3.0 and Clang 18.1.3, both at -O2 with no -march, so the code is what a default build produces
Reported statisticMinimum 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
ControlsA 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:

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

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.

Back to top

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/cache on 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.

Back to top