#381

clear() costs the largest size the map ever had: 834 us at a 4M peak holding 16 entries, 0.5 us by emptying each entry's slot

Openmartinusopened 25d ago

The number

clear() costs as much as the largest size the map ever had, not what it holds. clear_metadata() writes every group's 24 byte header, and since that header shares a cache line with nearly every line of the 88 byte block, it touches the whole index.

Scratch harness, main (5.3.1) header, map<uint64_t, uint64_t> grown once to a peak, then clear() + 16 inserts in a loop, clang -O2, pinned to core 2, one run per cell (no notes entry yet):

peakclear() + refillerase each key + refill
10,0000.67 us0.16 us
100,0005.7 us0.17 us
1,000,00095 us0.21 us
4,000,000834 us0.49 us

At a 4M peak this is about 45 MB at 50 GB/s, memory bandwidth. A single memset of the array instead of the per-group loop is level or 3-7% slower on both compilers (it also writes the 64 bytes of value indices nobody reads), so the loop is not the problem; touching the whole index is.

The break-even depends on the hash and on the cache. Per entry, with 64 inserts then the map emptied, peak 10k (1,024 groups) and 1M (131k groups):

key1,024 groups: clear() / erase each131k groups: clear() / erase eachhash alone
16 B string33 / 33 ns1,545 / 40 ns1.6 ns
135 B string44 / 51 ns1,522 / 60 ns7.9 ns
1024 B string119 / 177 ns1,583 / 193 ns44 ns

Both tables above are hot: each loop inserted its entries and emptied the map right away, so their groups and keys were still in cache. The erases are independent, so the core overlaps them: these are throughput numbers, not latency. Timing only the emptying step of 64 entries (c.cpp), with a 256 MB buffer streamed through the cache before each timed step for the cold rows:

keypeakstateclear()erase eachper entry
uint64_t10khot590 ns331 ns5.2 ns
uint64_t10kcold3,891 ns2,211 ns35 ns
uint64_t4Mcold1,697 us3.4 us53 ns
135 B string10khot1,151 ns1,831 ns29 ns
135 B string10kcold7,737 ns8,935 ns140 ns
135 B string4Mcold2,147 us12.2 us190 ns
1 KB string10khot1,204 ns5,154 ns81 ns
1 KB string10kcold9,324 ns24,284 ns379 ns
1 KB string4Mcold2,103 us29.8 us466 ns

(10k is 1,024 groups, so 16 groups per entry.) A reset costs 0.6 ns per group hot, 3-9 ns cold; emptying one entry costs its hash plus its probe, 5-80 ns hot and 35-470 ns cold. Break-even, in groups per entry: hot about 16 for integers and 100 for 1 KB strings; cold 9-16 for integers, 19-46 for 135 B strings, 42-114 for 1 KB strings. At a 4M peak, cold, emptying each entry is still 500x faster for integers and 70x for 1 KB strings.

The public erase(key) reads the caller's key and the stored one; the in-map loop reads only the stored key and the group, and its entries are just as independent, so it should cost at or below the erase column. Real callers sit between hot and cold, depending on how much work separates the fill from the clear().

libstdc++ and boost reset every bucket too; absl frees the array above 127 slots and pays a regrow. The standard specifies clear() as linear in size().

What to build

In clear(), when size() * K < number of groups, empty each value's own slot instead of resetting every group: hash the key, find the slot holding that value index (compare indices, not keys), clear its fingerprint and decrement the counters of the full groups on the way, as erase does. No swap-and-repoint: m_values.clear() follows. Otherwise the full reset stays.

The threshold depends only on the map's own size() and group count, like growth does, not on caller data (#248). bucket_count() does not change, so "reserve() once, then clear() and refill" keeps its array.

Proposed starting point K = 32: inside the cold break-even range for strings, above it for integers. A wrong choice near the threshold is not cheap in relative terms: cold 1 KB keys at 32 groups per entry empty about 3.6x slower than a full reset, counting the emptying step alone. What bounds it is that emptying an entry costs about what inserting the same key did (same hash, same groups), so the loss stays proportional to work the caller already paid for. Far from the threshold the gain is 70-4000x.

How to measure it

  • Sweep K in {8, 16, 32, 64} x key {uint64_t, 16 B, 135 B, 1 KB string} x clang and gcc, at a peak inside the cache and one past it (10k and 4M), with the groups-per-entry ratio stepped across the threshold, each cell hot (clear right after the fill) and cold (caches evicted before the clear). Time the emptying step alone and the insert-then-clear loop, against main. Pick K from the cold and hot results together and state what each side gives up.
  • Check that a full table's clear() (the score's case: size() near capacity) does not move: scripts/ab/solo.sh and perwl.sh, since clear() is inlined into callers.
  • Entry in notes/index-design.md with the sweep and the chosen K.

Done means

  • clear() on a map holding fewer than one entry per K groups costs time proportional to size(), not to capacity.
  • A regression test: grow a map large, shrink to a few entries, clear(), then check through the public API that inserts, hits and misses (including keys whose class overflowed past home before the clear) behave as on a fresh map, and that every counter in the index is zero (index() bytes). Killed by a mutation that skips the counter decrement.
  • ASan+UBSan leg and the fuzz corpus pass; score unchanged within the layout band.

Activity

0

No activity yet.