What the machine does

A lookup that stops caring how wide an entry is

Buckets of eight entries aligned to a cache line read 1.20 lines a lookup when an entry is eight bytes and 19.25 when it is 128, because the bound was arithmetic about alignment and the arithmetic stops holding. Keeping one byte of each key's hash in a separate array and the entries in a parallel one reads 2.21 lines at every width from four bytes to sixty-four — and for a key the table does not hold, 2.05 against 31.98.

The bucket that fits a line measured a cuckoo table whose candidates are buckets of eight slots, each bucket aligned to one cache line. It reads at most two lines per lookup at any load it reaches, builds past 0.95, and misses less often than linear probing at every load — and it does all of that because eight eight-byte entries are sixty-four bytes, which is a cache line.

That is arithmetic about alignment, and the arithmetic has one term in it nobody chose. An entry is eight bytes because the table stores a 32-bit key and a 32-bit value. A table of records — a key, a pointer, a length, a timestamp — stores thirty-two bytes or more, and then a bucket of eight is four lines, the bound is gone, and the design that was the best worst case on that page is not.

The tag line pays for itself from 16 bytes an entry: the inline bucket's lines double with every doubling of the entry and the tag line's do not moveDistinct 64-byte lines a lookup reads against the entry's width in bytes, on logarithmic axes, for a cuckoo table of 8,192 slots at a load of 0.9 with buckets of 8, for keys the table holds. One layout keeps the entries inline in the bucket; the other keeps a byte of tag for each in a separate array and reads a full entry only where a tag matches. 4-byte entries (a bucket of 32 bytes): inline 1.203 lines, tagged 2.207, reading 1.020 entries of which 0.0202 were accidents. 8-byte entries (a bucket of 64 bytes): inline 1.203 lines, tagged 2.207, reading 1.020 entries of which 0.0202 were accidents. 16-byte entries (a bucket of 128 bytes): inline 2.406 lines, tagged 2.216, reading 1.020 entries of which 0.0202 were accidents. 32-byte entries (a bucket of 256 bytes): inline 4.813 lines, tagged 2.220, reading 1.020 entries of which 0.0202 were accidents. 64-byte entries (a bucket of 512 bytes): inline 9.625 lines, tagged 2.222, reading 1.020 entries of which 0.0202 were accidents. 128-byte entries (a bucket of 1024 bytes): inline 19.251 lines, tagged 3.242, reading 1.020 entries of which 0.0202 were accidents. A 64-byte line holds 16 entries of 4 bytes and 0.50 of 128.48163264128110entry width, bytescache lines a lookup readsentries inlinea line of tags in front8,192 slots, load 0.9, buckets of 8keys the table holds
Fig. 1 Distinct 64-byte cache lines a lookup reads, against the entry’s width in bytes, on logarithmic axes, for a cuckoo table of 8,192 slots at a load of 0.9 with buckets of eight, for keys the table holds. With the entries inline in the bucket: 1.203 lines at four and at eight bytes, 2.406 at sixteen, 4.813 at thirty-two, 9.625 at sixty-four and 19.251 at 128. With a byte of tag for each entry in a separate array: 2.207, 2.207, 2.216, 2.220, 2.222 and 3.242.

The second line on that plate is what this page is about, and the shape of it is the whole finding: it does not move. Between four-byte entries and sixty-four-byte entries an inline bucket’s cost multiplies by eight and a tagged bucket’s changes by fifteen thousandths.

The layout

Store, in a separate array, one byte taken from each key’s hash — a tag. The tag array is indexed exactly as the entry array is, so a bucket’s eight tags are eight contiguous bytes wherever the bucket is and however wide an entry is. Keep the entries themselves in a parallel array.

A lookup then reads its bucket’s eight tag bytes, compares all eight against the query key’s tag, and reads a full entry only at a position where the tag matched. A key the table holds is found by one tag match and one entry read: the tag line and one entry line, two lines, independent of the entry’s width. A key the table does not hold usually matches no tag at all, and then the entries are never touched.

The cost is the extra fetch. At four- and eight-byte entries an inline bucket is already one line, so the tag array adds a whole line to a lookup that did not need one — 2.21 against 1.20. That is the 84% penalty the plate shows at the left, and it is why the design is not simply better.

It is also why the tagged line sits at 2.22 rather than at 2.00. A lookup reads its first bucket’s tags and, on 20.3% of lookups at this load, has to read the second bucket’s too because the key was evicted there; and on 2.0% of lookups a tag matches by accident and a wasted entry line is read. Those two account for the 0.22 above two exactly, and both are measured separately below. Neither has anything to do with the entry’s width, which is why the line is flat.

And the rightmost point is the one place the tagged line moves at all. At 128-byte entries an entry no longer fits in one line, so an entry read costs two, and the tagged layout goes from 2.22 to 3.24. The design makes a lookup independent of the entry’s width only up to the width of a line, which is the same boundary the inline layout ran into, reached for a different reason and two doublings later.

Where it pays

The crossing is at sixteen bytes an entry, and it is not a property of the design. It is a property of the bucket.

The tag line pays for itself from 8 bytes an entry: the inline bucket's lines double with every doubling of the entry and the tag line's do not moveDistinct 64-byte lines a lookup reads against the entry's width in bytes, on logarithmic axes, for a cuckoo table of 8,192 slots at a load of 0.9 with buckets of 16, for keys the table holds. One layout keeps the entries inline in the bucket; the other keeps a byte of tag for each in a separate array and reads a full entry only where a tag matches. 4-byte entries (a bucket of 64 bytes): inline 1.111 lines, tagged 2.119, reading 1.038 entries of which 0.0380 were accidents. 8-byte entries (a bucket of 128 bytes): inline 2.222 lines, tagged 2.133, reading 1.038 entries of which 0.0380 were accidents. 16-byte entries (a bucket of 256 bytes): inline 4.444 lines, tagged 2.142, reading 1.038 entries of which 0.0380 were accidents. 32-byte entries (a bucket of 512 bytes): inline 8.889 lines, tagged 2.146, reading 1.038 entries of which 0.0380 were accidents. 64-byte entries (a bucket of 1024 bytes): inline 17.777 lines, tagged 2.149, reading 1.038 entries of which 0.0380 were accidents. 128-byte entries (a bucket of 2048 bytes): inline 35.555 lines, tagged 3.186, reading 1.038 entries of which 0.0380 were accidents. A 64-byte line holds 16 entries of 4 bytes and 0.50 of 128.48163264128110entry width, bytescache lines a lookup readsentries inlinea line of tags in front8,192 slots, load 0.9, buckets of 16keys the table holds
Fig. 2 The same measurement with buckets of sixteen slots rather than eight. Inline: 2.22 lines at eight-byte entries, 4.44 at sixteen, 8.89 at thirty-two, 17.78 at sixty-four. Tagged: 2.13, 2.14, 2.15 and 2.15 — and now below the inline line at every width drawn, because sixteen tags are still sixteen bytes and sixteen entries are never one line.

A bucket of bb entries of ww bytes spans bwbw bytes and therefore about bw/64bw/64 lines; bb tags span bb bytes and are one line for any bb up to sixty-four. So the tag line pays whenever bw>128bw > 128 or so — the inline bucket’s two candidates costing more than the tagged layout’s two tag lines plus an entry — and the crossing moves with the bucket exactly as that says: at buckets of four it is at thirty-two bytes, at eight it is at sixteen, and at sixteen there is no width drawn at which the inline layout wins.

That is worth stating as a design rule because it composes with the earlier page’s rule and partly replaces it. The bucket’s width was chosen to be a cache line. The tag line makes the bucket’s width free, so the bucket can be chosen for what it is actually good at — the load it can be filled to. The bucket that fits a line found that buckets of eight fill past a load of 0.95 where buckets of two stop near 0.85; the table on this page cannot be filled at all to 0.9 with buckets of two. With tags in front, a bucket of sixteen is available at no cost in lines, and it fills higher still.

The lookup that finds nothing

The plate above is for keys the table holds, which is the case a hash table is usually measured on and not the case that decides a system.

For a key the table does not hold, the tag line reads 2.05 lines at every entry width, and the inline bucket reads 2.00 at 4 bytes and 31.98 at 128Distinct 64-byte lines a lookup reads against the entry's width in bytes, on logarithmic axes, for a cuckoo table of 8,192 slots at a load of 0.9 with buckets of 8, for keys the table does not hold. One layout keeps the entries inline in the bucket; the other keeps a byte of tag for each in a separate array and reads a full entry only where a tag matches. 4-byte entries (a bucket of 32 bytes): inline 1.998 lines, tagged 2.047, reading 0.055 entries of which 0.0549 were accidents. 8-byte entries (a bucket of 64 bytes): inline 1.999 lines, tagged 2.047, reading 0.055 entries of which 0.0549 were accidents. 16-byte entries (a bucket of 128 bytes): inline 3.998 lines, tagged 2.048, reading 0.055 entries of which 0.0549 were accidents. 32-byte entries (a bucket of 256 bytes): inline 7.995 lines, tagged 2.048, reading 0.055 entries of which 0.0549 were accidents. 64-byte entries (a bucket of 512 bytes): inline 15.990 lines, tagged 2.048, reading 0.055 entries of which 0.0549 were accidents. 128-byte entries (a bucket of 1024 bytes): inline 31.980 lines, tagged 2.103, reading 0.055 entries of which 0.0549 were accidents. A 64-byte line holds 16 entries of 4 bytes and 0.50 of 128.4816326412810entry width, bytescache lines a lookup readsentries inlinea line of tags in front8,192 slots, load 0.9, buckets of 8keys the table does not hold
Fig. 3 The same sweep for keys the table does not hold. Inline: 2.00 lines at four and eight bytes, 4.00 at sixteen, 8.00 at thirty-two, 15.99 at sixty-four and 31.98 at 128 — both candidate buckets read in full, every time. Tagged: 2.047 at every width up to sixty-four and 2.103 at 128, because the tag bytes answer the question and the entries are read only on the 5.5% of lookups where a tag matched by accident.

For an absent key the inline bucket has no early exit: it has to read both candidate buckets to the end to know the key is in neither, so it pays 2bw/642bw/64 lines whatever else is true. The tagged layout pays two tag lines and nothing more, and the ratio at 128-byte entries is fifteen to one.

This is the case that matters because a lookup that finds nothing is most of the traffic in the systems these tables are built for. The filter that feeds the table is the arrangement that exists to avoid exactly this cost — a Bloom filter in front of a table, so that a key the table does not hold is rejected without touching it. A tag array is the same idea moved inside the table, and it is strictly better in one respect: it never says yes to a key the table does not hold without also finding out, in one further read, that it was wrong.

What an accident costs

A tag is one byte of a 32-bit hash, so two different keys share a tag once in 256 times, and every shared tag is an entry read that finds the wrong key.

A tag of 8 bits matches by accident on 2.0% of lookups and costs 3.1% of the table's bytes; four bits costs 1.6% and is wrong on 34%Accidental tag matches per lookup against the tag's width in bits, on logarithmic axes, for a table of 8,192 slots at a load of 0.9 with buckets of 8 and entries of 32 bytes. Every accident is an entry read that finds the wrong key. 4 bits: 0.3440 accidents a lookup, 2.499 lines for a key held and 2.988 for one not held, at 1.6% of the entries' bytes. 6 bits: 0.0810 accidents a lookup, 2.352 lines for a key held and 2.339 for one not held, at 2.3% of the entries' bytes. 8 bits: 0.0202 accidents a lookup, 2.220 lines for a key held and 2.048 for one not held, at 3.1% of the entries' bytes. 12 bits: 0.0011 accidents a lookup, 2.357 lines for a key held and 2.252 for one not held, at 4.7% of the entries' bytes. 16 bits: 0.0003 accidents a lookup, 2.203 lines for a key held and 1.997 for one not held, at 6.3% of the entries' bytes. A tag of twelve bits is one and a half bytes, so a bucket's tags straddle a line more often than an eight-bit or a sixteen-bit tag's do — which is why its lines do not fall between theirs.46812160.0010.010.1tag width, bitsaccidental matches a lookupaccidental matches8,192 slots, load 0.9, 32-byte entriesa wider tag costs bytes and buys accuracy
Fig. 4 Accidental tag matches per lookup against the tag’s width, on logarithmic axes, with each point plated with the tag array’s size as a share of the entries. Four bits: 0.344 accidents a lookup, 2.50 lines for a key held, 1.6% of the entries’ bytes. Six bits: 0.081 and 2.35. Eight bits: 0.0202 and 2.22, at 3.1%. Twelve bits: 0.0011 and 2.36. Sixteen bits: 0.0003 and 2.20, at 6.3%.

At eight bits an accident happens on 2.0% of lookups and costs one extra line when it does, so its contribution to the mean is two hundredths of a line against a total of 2.22 — under one per cent. At four bits it happens on 34% of lookups and the mean climbs to 2.50, which is a measurable loss for a saving of 1.5% of the table’s bytes. Eight bits is not a tuned optimum; it is the width at which the accidents stop mattering, and every wider tag buys almost nothing.

One point on that plate is out of order, and the reason is worth carrying. A twelve-bit tag reads 2.36 lines where an eight-bit tag reads 2.22 and a sixteen-bit tag 2.20, although it causes eighteen times fewer accidents than the eight-bit one. Twelve bits is one and a half bytes, so a bucket’s tags span twelve bytes at an offset that is not a multiple of anything, and they straddle a line boundary far more often than eight or sixteen bytes do. The accidents fell and the alignment got worse, and the second effect was larger. That is the same class of finding as the aligned bucket it began with — a bound that was arithmetic about alignment — arriving from the other direction.

Load, and what does not change with it

From a load of 0.5 to 0.95 the inline bucket goes from 4.04 lines to 5.22 and the tag line from 2.02 to 2.32Distinct 64-byte lines a lookup reads, against the table's load, for buckets of 8 entries of 32 bytes held inline and for the same table with a tag array in front, on logarithmic axes. Load 0.5: inline 4.044, tagged 2.020, with 0.0107 accidental tag matches a lookup. Load 0.6: inline 4.103, tagged 2.036, with 0.0122 accidental tag matches a lookup. Load 0.7: inline 4.218, tagged 2.066, with 0.0145 accidental tag matches a lookup. Load 0.8: inline 4.431, tagged 2.122, with 0.0162 accidental tag matches a lookup. Load 0.9: inline 4.813, tagged 2.220, with 0.0202 accidental tag matches a lookup. Load 0.95: inline 5.223, tagged 2.325, with 0.0227 accidental tag matches a lookup.0.50.60.70.80.90.95loadcache lines a lookup readsentries inlinea line of tags in front8,192 slots, 32-byte entriesboth read the same table
Fig. 5 Cache lines a lookup reads against the table’s load, for buckets of eight 32-byte entries, on logarithmic axes. Inline: 4.04 lines at a load of 0.5 rising to 5.22 at 0.95. Tagged: 2.02 rising to 2.32. Accidental tag matches rise from 0.0107 a lookup to 0.0227 over the same range.

Both layouts get slightly dearer as the table fills, and both for the same reason: a fuller table sends more lookups to their second candidate bucket. Neither shows anything like linear probing’s collapse — two probes are two misses measured a linear-probing table reading forty entries and missing 5.23 times per absent-key lookup at a load of 0.9, against a bucketed cuckoo table’s 1.87 — and the flatness is the property the cuckoo family was chosen for.

At a load of 0.9, buckets of 8 miss 1.87 times per absent-key lookup, reading 16.00 entries; linear probing misses 5.23, reading 40.12A table of 8,192 slots filled to each load and as many keys it does not hold looked up once each, the reads replayed through a cache of 64 lines of 8 slots. Linear probing: 1.13 read, 0.95 missed and at most 2 lines at 0.1; 1.30 read, 0.97 missed and at most 2 lines at 0.2; 1.52 read, 1.00 missed and at most 2 lines at 0.3; 1.88 read, 1.04 missed and at most 4 lines at 0.4; 2.13 read, 1.06 missed and at most 4 lines at 0.45; 2.51 read, 1.11 missed and at most 4 lines at 0.5; 3.82 read, 1.26 missed and at most 6 lines at 0.6; 6.37 read, 1.52 missed and at most 10 lines at 0.7; 12.34 read, 2.17 missed and at most 16 lines at 0.8; 40.12 read, 5.23 missed and at most 49 lines at 0.9. Cuckoo, buckets of 8: 16.00 read, 1.87 missed and at most 2 lines at 0.1; 16.00 read, 1.88 missed and at most 2 lines at 0.2; 16.00 read, 1.86 missed and at most 2 lines at 0.3; 16.00 read, 1.87 missed and at most 2 lines at 0.4; 16.00 read, 1.87 missed and at most 2 lines at 0.45; 16.00 read, 1.88 missed and at most 2 lines at 0.5; 16.00 read, 1.87 missed and at most 2 lines at 0.6; 16.00 read, 1.87 missed and at most 2 lines at 0.7; 16.00 read, 1.87 missed and at most 2 lines at 0.8; 16.00 read, 1.87 missed and at most 2 lines at 0.9.linear probingcuckoo, buckets of 80240.10.20.30.40.50.60.70.80.9load factorcache misses per lookup8,192 slots, 64 cache lines of 8a probe is an entry read; a miss is a line fetched
Fig. 6 Cache misses per lookup for a key the table does not hold, against load, for linear probing and for cuckoo buckets of eight eight-byte entries, through a cache of 64 lines. Linear probing misses 0.95 at a load of 0.1 and 5.23 at 0.9, reading 40.12 entries. The bucketed cuckoo table misses 1.87 at every load from 0.1 to 0.9, reading exactly 16.

The tag array does not change that flatness; it makes it hold for entries of any width. The bucketed table’s guarantee was “at most two lines”, proved by an alignment argument that a wider entry breaks. The tagged table’s guarantee is “two tag lines plus one entry line per tag match”, which has no width in it at all, and the widths on the first plate are what that sentence looks like measured.

The bound, restated without a width in it

It is worth writing the two guarantees side by side, because the difference between them is the point of the design and it is easy to lose in the plates.

The inline bucket’s guarantee. A lookup reads at most two buckets; a bucket of bb entries of ww bytes, aligned, spans bw/64\lceil bw/64 \rceil lines; so a lookup reads at most 2bw/642\lceil bw/64 \rceil lines. With b=8b = 8 and w=8w = 8 that is two, which is the number the earlier page was about. The guarantee is true for every bb and ww and is only useful when bw64bw \le 64.

The tagged bucket’s guarantee. A lookup reads at most two tag ranges of bb bytes each, which is at most two lines for b64b \le 64, plus one line for each tag that matched. Tags match once for the key itself and, for every other occupied slot examined, with probability 2t2^{-t} for a tag of tt bits. So the expected lines are at most 2+1+2bλ2t2 + 1 + 2b\lambda 2^{-t}, where λ\lambda is the load — and ww does not appear.

Putting the measured numbers into the second expression: b=8b = 8, λ=0.9\lambda = 0.9, t=8t = 8 gives 2bλ2t=0.0562b\lambda 2^{-t} = 0.056 accidental entry reads a lookup, against a measured 0.020. The account overstates because it charges both candidate buckets on every lookup and most lookups stop at the first; halving it gives 0.028, which is the right order. The expression is a bound rather than a fit, and the plate is what it looks like when the bound is not tight.

The worst case is worth naming too, because it is unbounded and the mean is not. A lookup whose tag matches every occupied slot in both buckets reads sixteen entries, which at 128-byte entries is thirty-two lines — worse than the inline layout’s worst case. It happens with probability 28×162^{-8 \times 16} on random keys and with probability one against an opponent who can choose keys, which is the same hazard the adversary who knows the seed is about, in a structure where the seed is a hash the caller cannot see. Nothing here measures it.

What is being traded

Three costs move together here and it is worth separating them.

Lines read. The quantity every plate above counts, and the one the design exists to hold flat.

Bytes held. A one-byte tag on an eight-byte entry is 12.5% more memory; on a 32-byte entry it is 3.1%. The design is therefore cheapest in space exactly where it is most useful in time, which is an unusually comfortable arrangement and is not a coincidence — both quantities are ratios against the entry’s width.

Instructions. Comparing eight tag bytes against one is a single vector comparison on any machine that has one, and comparing eight 32-byte entries is eight comparisons of thirty-two bytes. Nothing here counts instructions, and on a table that fits in cache the instruction count is what decides. The count is not the time is the standing caution, and it applies to this page more sharply than to most: a plate counting lines is a plate about a table that does not fit in cache, and the design being measured is a rearrangement whose other half — the vector comparison — this model cannot see.

The three do not trade against each other evenly, and the shape of the trade is the reason this design is common in practice and rare in textbooks. Bytes and instructions both improve as the entry widens: the tag’s share of memory falls, and comparing tags instead of entries saves more the wider an entry is. Lines improve too, and by the largest factor. A design whose three costs all move the same way with one parameter has no trade-off in that parameter at all — what it has is a threshold, and the threshold is where an entry stops fitting beside its neighbours in one line. The exchange rate nobody wrote down had to construct a conversion between comparisons and moved bytes before it could rank sorting algorithms by record size; here no conversion is needed, because past sixteen bytes every axis agrees.

What is not measured here

One cache, one line size. Sixty-four bytes, and lines counted rather than misses simulated. A lookup’s two tag lines and one entry line are three distinct lines whether or not any of them was resident, and on a table whose tag array fits in cache entirely — 8,192 bytes here — the tag reads are nearly free and the plate overstates the tagged layout’s cost. That is the case a real implementation aims for, and it is the reason the tag array is kept small.

No deletions and no updates. A tag is derived from the key, so an entry’s tag never changes while the entry lives. A table that supports deletion has to invalidate a tag, and the usual arrangement — a reserved tag value meaning empty — removes one of the 256 tags and costs nothing measurable. Neither is implemented.

One key distribution. Keys are drawn from a shift-register generator and hashed by multiply-shift, so tags are as near uniform as the hash makes them. A tag taken from the same bits of the hash that chose the bucket would be no tag at all — every key in a bucket would share it — and nothing on this page would detect that, because the tag here is taken from a different multiplier.

Present and absent, not a mix. Each plate looks up either only keys the table holds or only keys it does not. A real workload is a mix, and the tagged layout’s advantage scales with the share of lookups that find nothing: at 32-byte entries the two cases read 2.22 and 2.05 tagged lines against 4.81 and 8.00 inline, so the advantage is 2.2× on a hit and 3.9× on a miss and any mix falls between.

The second bucket, not a schedule. A lookup reads its second candidate bucket on 20.3% of hits at a load of 0.9 and on every miss, and nothing here tries to lower that. A table that biased its insertions towards each key’s first bucket would lower it — which is the arrangement the second choice measured for load rather than for locality — and would make the tagged layout’s 2.22 closer to 2.02.

Buckets of two never build. At a load of 0.9 a bucket of two fails, which is the earlier page’s threshold seen from the far side, so the crossing for that bucket is not drawn.

Still open: a tag that answers more than yes or no

Every tag on this page is a byte compared for equality, and the comparison has one bit of output per slot. The bytes could carry more. A tag whose bits are ordered — the top bits of the key’s hash rather than a scattering of them — would let a bucket’s tags be kept sorted, so that a lookup could stop at the first tag above its own and an insertion could find its place; and the same ordering makes a bucket’s tags a small index over the entries rather than a set of them.

The measurement that follows builds that variant and asks what the ordering costs and buys: how many tag comparisons a lookup makes when it can stop early against the eight it makes now, what the insertion pays to keep eight bytes in order, and whether an ordered tag is measurably worse at its first job — since the top bits of a hash are the bits the bucket index was taken from, and a tag correlated with the bucket is a tag that distinguishes fewer of the keys inside it.

Named alongside this one

Essays reaching for the same objects. Nobody chose these; they are what the concept index makes visible.

The objects this essay names

Each one links to every other essay that touches it.

AlignmentCacheCache lineCuckoo hashingDesign parameterFalse-positive rateFingerprintLocalityMemory layoutRecord sizeSpace time trade