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):
| peak | clear() + refill | erase each key + refill |
|---|---|---|
| 10,000 | 0.67 us | 0.16 us |
| 100,000 | 5.7 us | 0.17 us |
| 1,000,000 | 95 us | 0.21 us |
| 4,000,000 | 834 us | 0.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):
| key | 1,024 groups: clear() / erase each | 131k groups: clear() / erase each | hash alone |
|---|---|---|---|
| 16 B string | 33 / 33 ns | 1,545 / 40 ns | 1.6 ns |
| 135 B string | 44 / 51 ns | 1,522 / 60 ns | 7.9 ns |
| 1024 B string | 119 / 177 ns | 1,583 / 193 ns | 44 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:
| key | peak | state | clear() | erase each | per entry |
|---|---|---|---|---|---|
uint64_t | 10k | hot | 590 ns | 331 ns | 5.2 ns |
uint64_t | 10k | cold | 3,891 ns | 2,211 ns | 35 ns |
uint64_t | 4M | cold | 1,697 us | 3.4 us | 53 ns |
| 135 B string | 10k | hot | 1,151 ns | 1,831 ns | 29 ns |
| 135 B string | 10k | cold | 7,737 ns | 8,935 ns | 140 ns |
| 135 B string | 4M | cold | 2,147 us | 12.2 us | 190 ns |
| 1 KB string | 10k | hot | 1,204 ns | 5,154 ns | 81 ns |
| 1 KB string | 10k | cold | 9,324 ns | 24,284 ns | 379 ns |
| 1 KB string | 4M | cold | 2,103 us | 29.8 us | 466 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.shandperwl.sh, sinceclear()is inlined into callers. - Entry in
notes/index-design.mdwith the sweep and the chosen K.
Done means
clear()on a map holding fewer than one entry per K groups costs time proportional tosize(), 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
0No activity yet.