What the machine does

The slack an insertion can reach

A cuckoo table whose entries are packed by tag rank holds half the bytes of a slotted one at half load, and pays by shifting entries on every insertion. It was proposed to keep a few spare entries at the end of each group's region. Charged the copies a full region costs, that slack helps only with the copies: at groups of 16 buckets an insertion still moves over a thousand bytes, against 36 for slots. The same slack divided among the buckets, with an insertion borrowing the nearest free entry, moves 347 — and the group size, which multiplied every earlier cost, stops mattering.

Entries found by the rank of their tag took a bucketed cuckoo table whose tags are kept sorted and noticed that the position a tag is found at is an address. It dropped the table’s fixed slots and packed each bucket’s entries in tag order, found by rank. The table has 16,384 slots in buckets of eight, with 32-byte entries. A slotted table at half load spends half its entry array on nothing and holds 66.1 bytes a key; the packed one held 36.1. The price was data movement. An insertion into a packed bucket shifts every entry after its rank, and with entries of several buckets packed into one region, every entry of the later buckets too. With a region for every bucket, an insertion at half load moved 99 bytes where a slotted one moved 36.

That page charged no reallocation: a region was assumed to have room. Its closing section proposed a layout between the two. Pack each group’s entries, but leave a few spare entries at the end of the group’s region, so that an insertion “shifts only the entries after its rank in its own bucket and borrows a slot from the shared slack”. A region that runs out is copied into a larger one, and that copy is charged. The prediction was that a few spare entries a group, for groups of four to sixteen buckets, would keep most of the packed layout’s saving while bringing the bytes an insertion moves back within a small factor of the slotted layout’s.

The measurement builds that layout and charges its copies. It keeps the saving and does not bring the bytes down. The proposal’s own sentence contains the reason: a slot at the end of the region is not one an insertion into an earlier bucket can borrow without moving everything in between.

Slack at the end saves only the copies

Three packed layouts are built beside the slotted one, on exactly the earlier page’s table: the same keys, hashes, cuckoo kicks and sorted tags. Each group of buckets owns one region of entries, and a region with no free entry left is copied into one larger by a stated number of spare entries, the copy charged at 32 bytes an entry. The layouts differ in where the free entries sit.

  • Slack at the end. Entries are packed in bucket order and the free ones follow the last. An insertion shifts every entry after its position in the region.
  • Slack spread, region rewritten. Each bucket owns a run of entries with free space at its end, and an insertion shifts only the entries after its rank in its own run. A bucket whose run is full makes the whole region rewrite itself with the free entries spread evenly again.
  • Slack spread, nearest gap borrowed. The same runs, but a bucket whose run is full takes a free entry from the nearest bucket that has one, shifting the entries in between by one place. Only a group with no free entry anywhere is copied into a larger region.
At a load of 0.5, groups of 16 buckets: slack at the end of the region brings an insertion from 2,832 bytes moved to 1,073 and no lower, because it only saves the copies; spread among the buckets and borrowed from the nearest gap, 16 spare entries bring it to 347 — against 36 for slotsBytes moved by an insertion at a load of 0.5 — shifted, written and copied — against the spare entries each group of 16 buckets keeps, 32-byte entries, reallocation charged. Slack at the end: 1 spare 2,832, 2 spare 1,912, 4 spare 1,459, 8 spare 1,225, 16 spare 1,109, 32 spare 1,073. Slack spread, region rewritten: 1 spare 1,934, 2 spare 1,881, 4 spare 1,755, 8 spare 1,523, 16 spare 1,087, 32 spare 882. Slack spread, nearest gap borrowed: 1 spare 1,934, 2 spare 1,426, 4 spare 1,100, 8 spare 755, 16 spare 347, 32 spare 306. The slotted layout moves 36. Both axes are logarithmic.1248163210010³spare entries a group of 16 buckets keepsbytes moved an insertionslack at the endslack spread, region rewrittenslack spread, nearest gapborrowedslotsload 0.5, groups of 16dashed: the slotted layout
Fig. 1 Bytes an insertion moves at a load of 0.5 — shifted, written and copied — against the spare entries each group of 16 buckets keeps. Slack at the end: 2,832 with one spare entry, 1,459 with four, 1,109 with sixteen, 1,073 with thirty-two. Slack spread, region rewritten: 1,934, 1,755, 1,087, 882. Slack spread, nearest gap borrowed: 1,934, 1,100, 347, 306. The slotted layout moves 36.

At the end of the region, sixteen spare entries bring an insertion from 2,832 bytes moved to 1,109, and thirty-two to 1,073. They never bring it below a thousand. Past a handful of spare entries the curve is flat. The slack is doing one thing: making copies rarer. A region with one free entry is copied at almost every insertion into its group: reaching half load, 8,192 keys, the table copies a region 8,064 times, and each copy moves the group’s whole contents. With thirty-two free entries a region is copied once for every thirty-two insertions into it — 191 copies to half load — and copying falls from 65% of the bytes moved to 5%. The flat part of the curve is what is left when copying has been paid down to nothing, and it is not small.

What remains is shifting, and slack at the end cannot touch it. An insertion into the first bucket of a group of sixteen, at half load, shifts the entries of the fifteen buckets after it: about sixty entries, 1,900 bytes. Averaged over where insertions land, that is the thousand bytes the curve levels at. Counted without reallocation, as the earlier page counted, the same shifting comes to 996 bytes an insertion for groups of sixteen, and the slack’s whole effect is to bring the copies down to nearly nothing on top of it.

The proposal’s sentence assumed that an insertion would shift only its own bucket and “borrow a slot from the shared slack”. The two halves are incompatible when the slack is at the end. The free slot is after the last bucket, and the only way to bring it to an earlier bucket is to move every entry between.

Where the bytes go

What the slack is buying: at the end of a region, copies are 65% of the bytes moved with one spare entry and 5% with 32 — the rest is shifting, which slack there cannot touch; rewriting the region whenever a bucket fills makes copies 84% of the bill even with 32The share of all bytes moved, while the table was filled to a load of 0.5, that were copies of a whole region, against the spare entries each group of 16 buckets keeps. Slack at the end: 1 spare 65%, 2 spare 48%, 4 spare 31%, 8 spare 19%, 16 spare 10%, 32 spare 5%. Slack spread, region rewritten: 1 spare 94%, 2 spare 94%, 4 spare 93%, 8 spare 92%, 16 spare 89%, 32 spare 84%. Slack spread, nearest gap borrowed: 1 spare 94%, 2 spare 63%, 4 spare 41%, 8 spare 29%, 16 spare 31%, 32 spare 21%. The horizontal axis is logarithmic.12481632spare entries a group of 16 buckets keepsshare of bytes moved that were copies0%25%50%75%100%slack at the endslack spread, region rewrittenslack spread, nearest gapborrowedfilled to 0.5, groups of 16reallocation charged
Fig. 2 The share of all bytes moved that were copies of a whole region, while the table was filled to half load, against the spare entries a group of 16 buckets keeps. Slack at the end: 65% with one spare entry, 31% with four, 10% with sixteen, 5% with thirty-two. Slack spread, region rewritten: 94%, 93%, 89% and 84%. Slack spread, nearest gap borrowed: 94%, 41%, 31% and 21%.

The three layouts spend their bytes on different things, and the plate says which fix each one needs. Slack at the end spends almost nothing on copies once it has a few spare entries, and almost everything on shifts. Rewriting the region whenever a bucket’s run fills spends 84% of its bytes on copies even with thirty-two spare entries. Its shifts are small, but its runs fill often, each bucket’s share of the spare being two entries, and every fill rewrites sixteen buckets’ worth of entries.

The nearest-gap layout sits between. Its shifts are short because the nearest free entry is usually a bucket or two away, and its copies are rare because a whole group has to fill before one happens. With sixteen spare entries a group, one a bucket, a fifth to a third of its bytes are copies and the rest are short shifts, and the total is a third of the slack-at-the-end layout’s.

The rewrite layout fails for a reason worth stating, because it looks like the obvious repair. Spreading the slack evenly gives each bucket one free entry when the group keeps sixteen, and a cuckoo insertion lands in one of two buckets chosen by hash. So the first bucket in a group to receive two insertions since the last rebalance fills its run, and a group of sixteen buckets reaches that point after a handful of insertions — the birthday problem at the scale of a group. Each time, all sixteen buckets’ entries are rewritten to hand the full bucket one more slot. The nearest-gap layout hands it the slot from a neighbour instead, and the neighbour’s entries are the only ones that move.

This is the arrangement the packed memory array uses for keeping a sorted array with fast insertion: gaps spread through the array, an insertion moving elements only as far as the nearest gap, and a rebalance only when a region runs out. The difference here is the scale. The regions are a group of buckets, a few hundred entries, rather than a whole array.

Bytes an insertion moves as the table fills

With 16 spare entries a group of 16 buckets, the nearest-gap layout moves 347 bytes an insertion at half load and 1,005 at 0.9; the same slack at the end moves 1,109 and 6,786; slots move 36 and 89Bytes moved by an insertion at each load, 32-byte entries, groups of 16 buckets with 16 spare entries, reallocation charged. Slack at the end: 0.1 138, 0.2 386, 0.3 625, 0.4 852, 0.5 1,109, 0.6 1,532, 0.7 2,211, 0.8 3,301, 0.9 6,786, 0.95 18,160. Slack spread, nearest gap borrowed: 0.1 56, 0.2 131, 0.3 209, 0.4 286, 0.5 347, 0.6 415, 0.7 536, 0.8 696, 0.9 1,005, 0.95 1,670. Slots: 0.1 33, 0.2 34, 0.3 34, 0.4 35, 0.5 36, 0.6 39, 0.7 45, 0.8 56, 0.9 89, 0.95 202.0510150.2000.4000.6000.800loadthousands of bytes moved an insertionslack at the endslack spread, nearest gapborrowedslotsgroups of 16, 16 spare32-byte entries
Fig. 3 Bytes an insertion moves at each load, groups of 16 buckets with 16 spare entries. Slack spread, nearest gap borrowed: 347 at half load, 1,005 at 0.9. Slack at the end: 1,109 and 6,786. Slots: 36 and 89.

As the table fills, every layout moves more, and the gap between them widens. At half load the nearest-gap layout moves 347 bytes an insertion against the slack-at-the-end layout’s 1,109, a third. At 0.9 it moves 1,005 against 6,786, a seventh. Near the top the regions are full and the cuckoo kicks multiply the insertions, so the slack-at-the-end layout pays its long shifts several times over. The nearest-gap layout’s shifts are bounded by the distance to a free entry, and that distance grows only as the group’s free entries run out.

The slotted layout’s own growth separates the two causes. It shifts nothing but tag bytes, so its rise from 36 bytes an insertion at half load to 89 at 0.9 is almost entirely the cuckoo kicks: each kick is another insertion into another bucket, and near full load an arriving key displaces others more often. That factor of 2.5 is the part of the growth every layout inherits. The nearest-gap layout grows by 2.9 times over the same range, little more than the kicks alone. The slack-at-the-end layout grows by 6.1 times, because each of the extra insertions the kicks cause also pays the long shift, and the shifts grow with the load too.

The prediction’s first half holds: the saving survives. At half load the nearest-gap layout with one spare entry a bucket holds 38.3 bytes a key, against the slotted layout’s 66.1 and the earlier page’s unreallocated packing’s 36.1. The spare entries cost two bytes a key and the per-bucket run lengths about a byte a bucket. The second half does not: 347 bytes moved against the slotted layout’s 36 is nearly ten times, not “a small factor”. At 0.9 it is eleven times, 1,005 against 89, while the packed layout’s space advantage has almost vanished: 35.6 bytes a key against 36.7.

Every layout on one plate

The group size and the amount of slack are two dials, and each point they set is a pair: bytes held a key and bytes an insertion moves.

Every layout at half load, bytes held a key against bytes moved an insertion: slots hold 66.1 and move 36; the packed layouts hold 34 to 42 and move from 153 (a region a bucket, 2 spare) to 4,399, and the nearest-gap points sit left of the slack-at-the-end ones at every group sizeAt a load of 0.5, 32-byte entries, reallocation charged: bytes held a key against bytes moved an insertion, for groups of 1, 4, 16, 64 buckets keeping a quarter, a half, one and two spare entries a bucket. Groups of 1: slack at the end 1 spare 36.2 held, 211 moved; 2 spare 40.1 held, 153 moved; slack spread, nearest gap borrowed 1 spare 36.5 held, 211 moved; 2 spare 40.3 held, 153 moved. Groups of 4: slack at the end 1 spare 34.6 held, 740 moved; 2 spare 35.6 held, 512 moved; 4 spare 37.7 held, 401 moved; 8 spare 41.8 held, 345 moved; slack spread, nearest gap borrowed 1 spare 34.9 held, 555 moved; 2 spare 35.9 held, 416 moved; 4 spare 37.9 held, 269 moved; 8 spare 42.0 held, 197 moved. Groups of 16: slack at the end 4 spare 35.1 held, 1,459 moved; 8 spare 36.0 held, 1,225 moved; 16 spare 38.0 held, 1,109 moved; 32 spare 42.1 held, 1,073 moved; slack spread, nearest gap borrowed 4 spare 35.3 held, 1,100 moved; 8 spare 36.3 held, 755 moved; 16 spare 38.3 held, 347 moved; 32 spare 42.4 held, 306 moved. Groups of 64: slack at the end 16 spare 35.2 held, 4,399 moved; 32 spare 36.2 held, 4,160 moved; 64 spare 38.2 held, 4,036 moved; 128 spare 42.2 held, 4,006 moved; slack spread, nearest gap borrowed 16 spare 35.5 held, 3,157 moved; 32 spare 36.4 held, 2,112 moved; 64 spare 38.4 held, 495 moved; 128 spare 42.4 held, 554 moved. Slots: 66.1 held, 36 moved. The horizontal axis is logarithmic.1001,000bytes moved an insertionbytes held a key3540506070slotsG 1G 4G 16G 64slack spread, nearestgap borrowedslack at the endload 0.5, 32-byte entrieslabels: buckets a group
Fig. 4 At half load, bytes held a key against bytes moved an insertion, for groups of 1, 4, 16 and 64 buckets keeping a quarter, a half, one and two spare entries a bucket. A region a bucket, one spare entry: 36.2 held, 211 moved; two spare: 40.1 and 153. Groups of 16 with 16 spare, nearest gap: 38.3 and 347; at the end: 38.0 and 1,109. Groups of 64 with 64 spare, nearest gap: 38.4 and 495; at the end: 38.2 and 4,036. Slots: 66.1 held, 36 moved.

The plate has two findings. The first is that reallocation doubles the cheapest packed layout’s cost. The earlier page’s best, a region a bucket, moved 99 bytes an insertion at half load with no reallocation charged. Given one spare entry and charged its copies, the same layout moves 211, and with two spare entries, 153. For a region of one bucket, the copy on overflow is the dominant cost, and the slack the proposal wanted is worth having there — as a reallocation policy, not as a shift policy. The exchange rate is visible between the two points: a second spare entry in every bucket’s region costs 3.9 bytes a key held and saves 58 bytes an insertion moved.

The second is that with the nearest gap borrowed, grouping costs little. At one spare entry a bucket, groups of 4, 16 and 64 buckets move 269, 347 and 495 bytes an insertion against a region a bucket’s 211 — at most 2.3 times — where slack at the end for the same groups moves up to 19 times as much. Grouping saves pointers, eight bytes a group instead of a bucket, which at half load is up to two bytes a key. At 32-byte entries that is a small share of what a key holds; for smaller entries it would be the larger part of the packed layout’s overhead, and there the ability to group without paying for it in shifts matters.

What grouping costs, by where the slack is

With one spare entry a bucket, slack at the end moves 39.3 times as many bytes an insertion at 0.9 for groups of 64 buckets as for a region a bucket, because every insertion shifts the rest of its group; borrowing the nearest gap keeps the move near a bucket's own entries at any group sizeBytes moved an insertion against the buckets in a group, each group keeping one spare entry a bucket, reallocation charged, 32-byte entries. Slack at the end at 0.5: 1 bucket 211, 4 buckets 401, 16 buckets 1,109, 64 buckets 4,036. Slack spread, nearest gap borrowed at 0.5: 1 bucket 211, 4 buckets 269, 16 buckets 347, 64 buckets 495. Slack at the end at 0.9: 1 bucket 662, 4 buckets 1,877, 16 buckets 6,786, 64 buckets 26,000. Slack spread, nearest gap borrowed at 0.9: 1 bucket 662, 4 buckets 784, 16 buckets 1,005, 64 buckets 1,602. Dashed lines are the load of 0.9. Both axes are logarithmic.14166410³10⁴buckets a groupbytes moved an insertionat the end, load 0.5nearest gap, load 0.5at the end, load 0.9nearest gap, load 0.9one spare a bucketdashed: load 0.9
Fig. 5 Bytes an insertion moves against the buckets in a group, each group keeping one spare entry a bucket. Slack at the end, half load: 211, 401, 1,109 and 4,036 for groups of 1, 4, 16 and 64. Nearest gap: 211, 269, 347, 495. At 0.9 (dashed), slack at the end: 662, 1,877, 6,786, 26,000; nearest gap: 662, 784, 1,005, 1,602.

With the slack at the end, bytes moved grow in proportion to the group: 39 times as many for groups of 64 as for a region a bucket at 0.9. Every insertion shifts the rest of its group, and the rest of the group grows with it. With the nearest gap borrowed, a group of 64 moves 2.4 times what a region a bucket does at 0.9 and 2.3 times at half load. The distance to the nearest free entry grows much more slowly than the group, because one spare entry a bucket leaves a free entry within a bucket or two almost everywhere.

A tag that answers more than yes sorted a bucket’s tags so that a lookup could stop early, and the earlier page turned that order into an address. What this page adds is that the order has a cost, and that the cost is set by the distance from an insertion to the nearest free place, not by how much free space there is. The bucket that fits a line sized a bucket to a cache line so that a lookup costs one line. A packed layout that keeps its free entries spread gives an insertion a comparable guarantee: it touches the bucket it lands in and, usually, one neighbour.

That guarantee is what makes the shifts cheap in the sense a machine charges. Where insertion sort actually wins found that the shifting insertion sort does costs almost nothing on small arrays, because the elements it moves are adjacent and already in cache. A shift within one bucket’s run and into its neighbour’s is the same kind of move: a few hundred contiguous bytes, one or two cache lines. The slack-at-the-end layout’s shifts are the other kind. A group of sixty-four buckets at 0.9 holds about 460 entries, fifteen kilobytes, and an insertion near its start moves most of them — a cost that the cliff where the data stops fitting would price by whether the region still fits in the cache at all.

What was measured and what was not

The lookup’s layout is the earlier pages’. A lookup that stops caring how wide an entry is separated the tags from the entries so that a lookup reads the tag array’s line and then one entry, and every layout here keeps that separation. A spread layout adds a byte a bucket of run length beside the tags. Two probes are two misses is the reminder that a cuckoo lookup’s second bucket is a second line, and nothing here changes that.

Bytes, not time. Every cost is bytes moved, as on the earlier page. A shift of a few hundred bytes is a memmove within a cache line or two; a copy of a region is an allocation and a larger memmove. They are counted in the same unit here and priced differently by a machine. The count is not the time is the standing warning.

One table, one seed, one entry size. The table has 16,384 slots and 32-byte entries throughout, built once from one seed. Near full load the number of cuckoo kicks an insertion causes varies most between seeds, so the figures at 0.9 and above are the least certain on these plates, and the ratios between layouts are more trustworthy there than the bytes themselves. Smaller entries make the pointers a larger share of the space, which favours grouping, and make every shift cheaper in bytes, which favours packing.

Regions grow and never shrink. Deletions leave free entries behind and no region is ever made smaller. A table that shrinks and grows would pay copies in both directions, and a workload of mixed inserts and deletes was not measured.

Lookups were not charged. A packed bucket’s entry is found from its group’s pointer, the counts of earlier buckets, and its rank; with runs, from the run lengths instead of the counts. Both are a few bytes of arithmetic in the same line as the tags, and neither was measured as time.

Still open: runs sized by where the insertions land

Every spread layout here divides the group’s spare evenly among its buckets. But a cuckoo table’s buckets do not fill evenly. A bucket that is some keys’ first choice and others’ second fills faster, and after a kick the displaced key lands in its other bucket, which may be anywhere. The nearest-gap layout pays when a full bucket’s neighbours are full too, and those are the stretches an even division serves worst.

A run could instead be sized by its bucket’s recent history. A bucket that has overflowed its run gets a larger share at the next rebalance, and one that has lost entries a smaller one. That is the adaptive packed memory array’s refinement of the even one, applied at the scale of a group of buckets. The measurement that follows gives each bucket’s run a share of the group’s spare in proportion to its insertions since the last rebalance and compares that with the even division at the same spare. The prediction is that it helps most near full load, where the even division’s shifts grow fastest, and helps little at half load, where almost every bucket has a free entry within reach anyway. The number to watch is whether it brings the moves at 0.9 back towards the half-load figure. That would say how much of the growth with load is the cuckoo kicks’ own cost and how much is slack in the wrong place.

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.

Cuckoo hashingData movementDesign parameterHash tableHonest limitLoad factorLocalityMeasured countRankSpace accounting