A Map's churn test fails a table that doubles instead of sweeping, and nothing recalled is cited as read

This commit is contained in:
Joseph Ferano 2026-09-25 11:18:12 +07:00
parent cbfac9f474
commit 840b3b215e
8 changed files with 89 additions and 99 deletions

View File

@ -3175,58 +3175,32 @@ are involved, and none are needed" — and monomorphisation is what would buy it
### The Map is a Swiss table ### The Map is a Swiss table
The Robin Hood table above lost to CPython's dict at a million entries, for the reason the last section gave: three The Robin Hood table above lost to CPython's dict at a million entries: three separate runs and an eight-byte hash a
separate runs and an eight-byte hash a slot. It is replaced by a Swiss table. The header, every entry point's slot. It is replaced by a Swiss table, and the header, every entry point's signature, the hash-and-equality pair and
signature, the hash-and-equality pair and the iteration contract are unchanged, so neither backend and neither the iteration contract are unchanged, so the change is confined to `runtime/flan_rt.c`. The layout and the removal rule
debugger description moved; the whole change is inside `runtime/flan_rt.c`. are described there; what follows is what the code cannot say.
**The block.** - **Empty is zero** — Zig's encoding (`lib/std/hash_map.zig`, `Metadata`: free 0, tombstone 1, a used bit on top) — so
a zeroed control run is an empty map.
[growth][stride][value offset][control: cap + 7 bytes] pad to 64 | slots - **Groups are eight bytes read as one integer**, not SSE2's sixteen: portable to every target the runtime compiles
for, and nothing measured asks for the wider group.
- **One control byte a slot.** `0x00` is empty, `0x01` deleted, and a full slot is `0x80` with the top seven bits of the - **Key and value share a slot** because a hit then costs two cache misses rather than three. The runtime is not told
hash below it — Zig's encoding (`lib/std/hash_map.zig`, `Metadata`), chosen because empty is zero: a zeroed control alignments; the largest power of two dividing each size bounds them.
run is an empty map, the property the old table's zero hash word had. The low bits of the hash choose the group, so - **The head of the block holds three words** — growth left, slot stride, value offset — because the five-word header
the tag and the position never share a bit. is the emitter's layout too. The stride is stored rather than recomputed because recomputing it per call cost about a
- **Groups of eight, as one 64-bit word.** Matching a tag, finding an empty slot and finding a free one are each a few
integer operations over the word — hashbrown's portable "generic" group, which needs no intrinsics and so compiles
for every target the runtime does. SSE2's sixteen-wide group is not built; nothing measured asks for it. The seven
bytes past the end mirror the first seven, so a group can be read starting at any slot. Groups are visited at
triangular offsets, which reach every group of a power-of-two table before repeating.
- **A key and its value side by side.** A hit reads the control group and then one slot — two cache misses where the
old table took three. The runtime is not told either alignment, and does not need to be: a size is a multiple of its
type's alignment, so the largest power of two dividing it (capped at 64) bounds the alignment. The value is placed at
the key's size rounded up to that bound, and the stride rounded up to the larger of the two bounds.
- **The head holds three words** because the header has no room for them: it is the emitter's layout as much as the
runtime's. The growth word is how many more entries may land in an *empty* slot before a rebuild; the stride and value
offset are computed once at allocation, because recomputing them from the two sizes on every call measured as about a
tenth of a cache-resident lookup. tenth of a cache-resident lookup.
- **Seven-eighths load.** A miss stops at the first group holding an empty slot rather than walking a run, and at seven - **The 25/32 threshold** for sweeping deleted slots at the same capacity rather than doubling, and the rule for
eighths every eight-slot group has one. emptying a removed slot outright, were written from memory of abseil's table, not read from its source; no abseil
clone is on this machine. `map-remove.flan` row 7 is what holds them: it churns a map at a steady size under an
**Removal is a tombstone, most of the time not.** A lookup stops at the first group with an empty slot, so emptying a allocator budget that a doubling would exceed.
slot could hide a key placed past it while it was full. The slot becomes deleted instead, unless every eight-slot
window containing it already holds an empty slot — then no probe can have passed through it, and it is emptied
outright. That is abseil's test (the empties just before and just after are fewer than eight slots apart), and in a map
that is not nearly full it is the common case. Nothing moves, so a cursor held across a removal stays valid; the
backward shift that removal used to cost is gone.
**Deleted slots count against growth**, since a probe cannot stop at one. When an insert would take an empty slot with
no growth left, the table is rebuilt: at the same capacity when the live entries fit in 25/32 of it (abseil's figure),
which only sweeps the deleted slots, and at double otherwise. `map-remove.flan` row 7 churns a map at a steady size for
200,000 operations against a plain array and holds it at 512 slots through 33 sweeps.
**The probe is inlined by force.** Called from five entry points, clang keeps it out of line at `-O2`, and the call
spills everything the loop held in registers.
#### Measured #### Measured
`i64` to `i64`. Keys present are `2i`, keys absent `2i + 1`. Per size: *insert* fills a fresh map from empty with no `i64` to `i64`; keys present are `2i`, absent `2i + 1`. *insert* fills a fresh map with no `reserve`, to two million
`reserve` and frees it, repeated to two million inserts; *hit* and *miss* are four million lookups cycling through the inserts; *hit* and *miss* are four million lookups cycling the keys; *remove* removes every key of a filled map, to two
keys; *remove* fills a map untimed and removes every key timed, repeated to two million. Every pass counts its wrong million. Each pass counts its wrong answers: a map passed by value to a filling function takes its grows in the
answers and the program prints them, because the first version of this benchmark passed a map by value to a filling callee's copy of the header, and a benchmark written that way measures an empty map. CPython is Python 3.13.9,
function — the grows landed in the callee's copy of the header, the caller's map stayed empty, and every lookup was a `dict.get` and `dict.pop`.
fast miss. The CPython side is the same workload in Python 3.13.9 (`dict.get`, `dict.pop`).
The figure is the **minimum of nine runs**, the two Flan binaries run alternately and the Python script in a separate The figure is the **minimum of nine runs**, the two Flan binaries run alternately and the Python script in a separate
nine-run pass, because this machine is shared — it carried a load average near 20 throughout, from other lanes' test nine-run pass, because this machine is shared — it carried a load average near 20 throughout, from other lanes' test
@ -3262,16 +3236,12 @@ and ahead of CPython in every cell — and that is the claim to reproduce. Nanos
| | miss | 88.2 | 14.6 | 125.2 | | | miss | 88.2 | 14.6 | 125.2 |
| | remove | 152.0 | 74.5 | 91.5 | | | remove | 152.0 | 74.5 | 91.5 |
**The crossover.** Against CPython, the Robin Hood table lost on insert at every size, on remove from somewhere between **The crossover.** Against CPython the Robin Hood table lost on insert at every size, on remove from between 100k and
100k and 300k, and on hits between 300k and 1M; misses it won throughout. The Swiss table is quicker than CPython at 300k, and on hits between 300k and 1M. The Swiss table is ahead of both in every cell. CPython hashes a small integer
every size and every operation measured, and quicker than the table it replaced at every one of the twenty-four. The to itself, so this key pattern walks its table in order and the margin at a million is narrower than it looks.
margin that is left at a million is insert (139 against 152) and remove (75 against 92), and it is narrower than it
looks: CPython hashes a small integer to itself, so this key pattern walks its table in order and the hardware
prefetcher does the rest, while every Flan key lands at a random slot.
A middle variant was measured and not kept: Swiss control bytes over separate key and value runs. It was within a few Control bytes over separate key and value runs were measured and not kept: level with the final layout to 100k, behind
nanoseconds of the final layout up to 100k and lost from 300k up (87 ns a hit against 26 at 300k, 100 against 78 at a from 300k (100 ns a hit against 78 at a million).
million), which is the third cache miss the interleaved slot removes.
## Unions, and the tag they carry ## Unions, and the tag they carry
@ -4514,10 +4484,8 @@ function.
``` ```
**The cursor is a slot index the caller owns, and there is no iterator struct** because there is nothing for one to **The cursor is a slot index the caller owns, and there is no iterator struct** because there is nothing for one to
hold. A map has no tombstones — removal shifts the run back instead of marking a hole — so a slot is either empty or hold: a slot's control byte says whether it is full, and the position is the whole of the state. A removal moves
occupied and the position is the whole of the state. What a cursor does *not* survive is a removal taken while it is nothing, so a cursor survives one; what it does not survive is a put that rebuilds the block. The cursor starts at 0, comes back one past the entry just answered, and is left
in flight: the shift moves entries to lower slots, and a cursor already past them steps over entries it has not
answered, the same bargain a put that grows already makes. The cursor starts at 0, comes back one past the entry just answered, and is left
at `cap` by the call that answers false, so a spent cursor keeps answering false rather than wrapping. at `cap` by the call that answers false, so a spent cursor keeps answering false rather than wrapping.
**Three out-pointers and not a returned pair**, because there are no tuples. An `(Option K)` would answer half an **Three out-pointers and not a returned pair**, because there are no tuples. An `(Option K)` would answer half an

View File

@ -7519,7 +7519,7 @@ and named_call ?(qualified = false) ctx ~want loc name args =
let attempt, note = let attempt, note =
match target.Tast.ty with match target.Tast.ty with
(* For a map the number is entries, not slots: the runtime sizes the (* For a map the number is entries, not slots: the runtime sizes the
block so that [n] still sits under the 75% load factor, which is block so that [n] still sits under the load factor, which is
the only reading of "room for n" that does not reallocate on the the only reading of "room for n" that does not reallocate on the
nth put. *) nth put. *)
| Types.Map (k, v) -> | Types.Map (k, v) ->

View File

@ -4134,7 +4134,7 @@ let header = {|; Generated by flan. The layout is C's: no object headers anywher
; (Vec T), spec-memory.md. The element type is nowhere in it: the runtime is ; (Vec T), spec-memory.md. The element type is nowhere in it: the runtime is
; type-erased and every operation is handed size and align at its call site. ; type-erased and every operation is handed size and align at its call site.
%vec = type { ptr, i64, i64, ptr, i64 } %vec = type { ptr, i64, i64, ptr, i64 }
; (Map K V), spec-memory.md — Odin's open-addressed Robin Hood map. Neither key ; (Map K V), spec-memory.md — an open-addressed Swiss table. Neither key
; nor value type appears in it, for the same reason: one type-erased runtime, ; nor value type appears in it, for the same reason: one type-erased runtime,
; handed the two sizes and a hash/equality pair at each call site. ; handed the two sizes and a hash/equality pair at each call site.
%map = type { ptr, i64, i64, ptr, i64 } %map = type { ptr, i64, i64, ptr, i64 }

View File

@ -1910,16 +1910,17 @@ int8_t flan_vec_clone(flan_vec *dst, flan_vec *src, flan_allocator *a,
* Header, five words, and the same five the emitter's %map and its debugger * Header, five words, and the same five the emitter's %map and its debugger
* description name: * description name:
* *
* data one allocation: growth word | control bytes | keys | values * data one allocation: a three-word head, the control bytes, then
* the slots, each a key and its value side by side
* len live entries * len live entries
* log2cap 0 until something is allocated; never below 3 after * log2cap 0 until something is allocated; never below 3 after
* allocator epoch as on a Vec, and checked the same way * allocator epoch as on a Vec, and checked the same way
* *
* The one number a Swiss table needs beyond the header — how many more * The one number a Swiss table needs beyond the header — how many more
* entries may land in empty slots before the table is rebuilt — lives in the * entries may land in empty slots before the table is rebuilt — lives in the
* first word of the block rather than in the header, because the header's * block's head rather than in the header, because the header's layout is the
* layout is the emitter's as much as this file's and a sixth word would move * emitter's as much as this file's and a sixth word would move every offset
* every offset after it. The slot geometry sits beside it; see "Block * after it. The slot stride and value offset sit beside it; see "Block
* geometry". * geometry".
* *
* Every entry point returns int8_t 1/0 for "did it fit", never reporting * Every entry point returns int8_t 1/0 for "did it fit", never reporting
@ -2131,9 +2132,8 @@ uint64_t flan_hash_combine(uint64_t acc, uint64_t h) {
/* ── Groups ─────────────────────────────────────────────────────────── /* ── Groups ───────────────────────────────────────────────────────────
* *
* Eight control bytes read as one 64-bit word, and every question about them * Eight control bytes read as one 64-bit word, and every question about them
* answered with a handful of integer operations over the whole word — the * answered with a handful of integer operations over the whole word, which
* portable arrangement hashbrown calls "generic", which needs no intrinsics * needs no intrinsics and so compiles for every target the runtime does. Each answer is a mask
* and so compiles for every target the runtime does. Each answer is a mask
* with 0x80 set in the byte of every slot that qualifies; a slot's index in * with 0x80 set in the byte of every slot that qualifies; a slot's index in
* the group is its byte's position, counted from the low end. */ * the group is its byte's position, counted from the low end. */
#define FLAN_LSB 0x0101010101010101ULL #define FLAN_LSB 0x0101010101010101ULL
@ -2518,7 +2518,7 @@ int8_t flan_map_init(flan_map *m, flan_allocator *a, int64_t ksize,
* An insert that would land in an empty slot with no growth left rebuilds * An insert that would land in an empty slot with no growth left rebuilds
* first. Deleted slots count against the growth, since each one is a slot a * first. Deleted slots count against the growth, since each one is a slot a
* probe cannot stop at; when they are most of what is using it up — the live * probe cannot stop at; when they are most of what is using it up — the live
* entries fit in 25/32 of the capacity, abseil's figure — the rebuild keeps * entries fit in 25/32 of the capacity — the rebuild keeps
* the capacity and only sweeps them out, so a map that churns at a steady size * the capacity and only sweeps them out, so a map that churns at a steady size
* does not double without end. */ * does not double without end. */
int8_t flan_map_put(flan_map *m, const void *key, const void *val, int8_t flan_map_put(flan_map *m, const void *key, const void *val,
@ -2605,10 +2605,10 @@ int8_t flan_map_has(flan_map *m, const void *key, int64_t ksize, int64_t vsize,
* DELETED instead — a slot a probe walks through and an insert may reuse — * DELETED instead — a slot a probe walks through and an insert may reuse —
* unless no probe can ever have walked through it: when every eight-slot * unless no probe can ever have walked through it: when every eight-slot
* window containing it already holds an empty slot, any probe that reached it * window containing it already holds an empty slot, any probe that reached it
* stopped in that window, and it can be emptied outright. That test is * stopped in that window, and it can be emptied outright. The test is that the
* abseil's (the empties just before and just after it are fewer than eight * empties just before and just after it are fewer than eight slots apart, and
* slots apart), and it is what keeps a map that is far from full from * it is what keeps a map that is far from full from accumulating deleted slots
* accumulating deleted slots at all. * at all.
* *
* Nothing moves. The old table shifted the rest of the run back one slot, so * Nothing moves. The old table shifted the rest of the run back one slot, so
* a cursor already past them stepped over entries; here an entry stays in its * a cursor already past them stepped over entries; here an entry stays in its

View File

@ -82,7 +82,7 @@
(print chars) (print " ") (print xs) (print " ") (print ys) (println "")) (print chars) (print " ") (print xs) (print " ") (print ys) (println ""))
(free m))) (free m)))
;; Growth past the 75% threshold rehashes into a new block, so this walks a map ;; Growth past the load factor rehashes into a new block, so this walks a map
;; whose layout is nothing like its insertion order and at a capacity several ;; whose layout is nothing like its insertion order and at a capacity several
;; doublings past the minimum. ;; doublings past the minimum.
(defn after-growth [] () (defn after-growth [] ()

View File

@ -13,6 +13,14 @@
;;;; swept grows without end under row 7. ;;;; swept grows without end under row 7.
(defstruct Cell [x i32 y i32]) (defstruct Cell [x i32 y i32])
;; Row 7's allocator and its count of refusals. Globals, because a handler
;; cannot see the locals of the function that established it.
(defonce churn-alloc Allocator)
(defonce churn-refusals i64)
;; Row 7's reference: a global array rather than a Vec, because a Vec would
;; take its block from the same process heap the budget is counting.
(defonce churn-ref [512 i64])
(defn main [] i32 (defn main [] i32
;; (1) The value comes back, the length drops, and the key is gone. An ;; (1) The value comes back, the length drops, and the key is gone. An
;; absent key is None and changes nothing — the same answer get gives, since ;; absent key is None and changes nothing — the same answer get gives, since
@ -118,38 +126,53 @@
(free-all ar)) (free-all ar))
;; (7) Churn at a steady size, checked against a plain array. Keys are drawn ;; (7) Churn at a steady size, checked against a plain array. Keys are drawn
;; from 512 and the map hovers around half of them, so removals leave deleted ;; from 512 and the map holds about two thirds of them, so removals leave
;; slots that inserts must reuse and rebuilds must sweep. Every get is checked ;; deleted slots that inserts must reuse and rebuilds must sweep. Every get is
;; against the array, and at the end so is the length and a full walk. ;; checked against the array, and at the end so is the length and a full walk.
(let [m (map-new i32 i64) ;;
ref (vec-new i64) ;; The process heap is given a budget of 20000 bytes, with nothing else of
;; this program's live on it. An i32 key and an i64
;; value make a 16-byte slot, so the 512-slot block is 8768 bytes and a
;; rebuild at that size holds two of them, 17536; a grow to 1024 slots holds
;; 8768 and 17472 at once and goes over. The entries never need more than 512
;; slots, so a refusal means deleted slots were used up and the table doubled
;; instead of sweeping them — which is what a version that never swept them
;; does. The handler lets it through, counts it, and the count is printed.
(set churn-alloc (heap-allocator))
(set-alloc-budget churn-alloc 20000)
(handler-bind
[(StorageExhausted [c]
(set churn-refusals (+ churn-refusals 1))
(set-alloc-budget churn-alloc (* 2 (alloc-budget churn-alloc)))
(invoke-restart 'retry))]
(let [m (map-new i32 i64 churn-alloc)
x (u64 12345) x (u64 12345)
bad 0 bad 0
live 0] live 0]
(dotimes [i 512] (push ref (i64 -1))) (dotimes [i 512] (set (at churn-ref i) (i64 -1)))
(dotimes [step 200000] (dotimes [step 200000]
(set x (+ (* x (u64 6364136223846793005)) (u64 1442695040888963407))) (set x (+ (* x (u64 6364136223846793005)) (u64 1442695040888963407)))
(let [k (i32 (% (>> x 33) (u64 512))) (let [k (i32 (% (>> x 33) (u64 512)))
op (i32 (% (>> x 20) (u64 4)))] op (i32 (% (>> x 20) (u64 4)))]
(if (< op 2) (if (< op 2)
(do (if (< (at ref k) 0) (set live (+ live 1))) (do (if (< (at churn-ref k) 0) (set live (+ live 1)))
(put m k (i64 step)) (put m k (i64 step))
(set (at ref k) (i64 step))) (set (at churn-ref k) (i64 step)))
(if (= op 2) (if (= op 2)
(match (map-remove m k) (match (map-remove m k)
(Some v) (do (if (not (= v (at ref k))) (set bad (+ bad 1))) (Some v) (do (if (not (= v (at churn-ref k))) (set bad (+ bad 1)))
(set (at ref k) (i64 -1)) (set (at churn-ref k) (i64 -1))
(set live (- live 1))) (set live (- live 1)))
None (if (>= (at ref k) 0) (set bad (+ bad 1)))) None (if (>= (at churn-ref k) 0) (set bad (+ bad 1))))
(match (get m k) (match (get m k)
(Some v) (if (not (= v (at ref k))) (set bad (+ bad 1))) (Some v) (if (not (= v (at churn-ref k))) (set bad (+ bad 1)))
None (if (>= (at ref k) 0) (set bad (+ bad 1)))))))) None (if (>= (at churn-ref k) 0) (set bad (+ bad 1))))))))
(print bad) (println "") ; 0 (print bad) (println "") ; 0
(print (= (length m) (i64 live))) (println "") ; true (print (= (length m) (i64 live))) (println "") ; true
(let [cur (i64 0) k 0 v (i64 0) seen 0] (let [cur (i64 0) k 0 v (i64 0) seen 0]
(while (map-next m (addr cur) (addr k) (addr v)) (while (map-next m (addr cur) (addr k) (addr v))
(set seen (+ seen 1)) (set seen (+ seen 1))
(if (not (= v (at ref k))) (set bad (+ bad 1)))) (if (not (= v (at churn-ref k))) (set bad (+ bad 1))))
(print (= seen live)) (println "") ; true (print (= seen live)) (println "") ; true
(print bad) (println "")) ; 0 (print bad) (println "")) ; 0
;; Removing while walking visits every survivor once: nothing moves. ;; Removing while walking visits every survivor once: nothing moves.
@ -159,6 +182,6 @@
(map-remove m k)) (map-remove m k))
(print (= seen live)) (println "") ; true (print (= seen live)) (println "") ; true
(print (length m)) (println "")) ; 0 (print (length m)) (println "")) ; 0
(free ref) (free m)))
(free m)) (print churn-refusals) (println "") ; 0
0) 0)

View File

@ -1,7 +1,7 @@
;;;; (Map K V) — spec-memory.md, step 4 of the container build order. ;;;; (Map K V) — spec-memory.md, step 4 of the container build order.
;;;; ;;;;
;;;; Odin's map: open-addressed Robin Hood hashing at a 75% load factor, with ;;;; An open-addressed Swiss table: one control byte a slot, key and value side
;;;; cache-line cell packing. Every claim below is one a plausible wrong ;;;; by side. Every claim below is one a plausible wrong
;;;; version gets wrong, and the numbers differ per failure so a single wrong ;;;; version gets wrong, and the numbers differ per failure so a single wrong
;;;; answer names its own cause. ;;;; answer names its own cause.
(defstruct Cell [x i32 y i32]) (defstruct Cell [x i32 y i32])

View File

@ -3831,8 +3831,7 @@ level "1"
(* ── (Map K V), spec-memory.md step 4 ────────────────────────── (* ── (Map K V), spec-memory.md step 4 ──────────────────────────
Odin's map: open-addressed Robin Hood hashing at a 75% load factor with An open-addressed Swiss table, one control byte a slot. maps.flan is seven claims, each one a plausible
cache-line cell packing. maps.flan is seven claims, each one a plausible
wrong version gets wrong, and the numbers differ per failure. wrong version gets wrong, and the numbers differ per failure.
The two worth naming, because nothing else in the suite would catch The two worth naming, because nothing else in the suite would catch
@ -3871,9 +3870,9 @@ level "1"
outputs "map iteration" "programs/map-iter.flan" map_iter_out; outputs "map iteration" "programs/map-iter.flan" map_iter_out;
outputs ~opt:"-O0" "map iteration, -O0" "programs/map-iter.flan" map_iter_out; outputs ~opt:"-O0" "map iteration, -O0" "programs/map-iter.flan" map_iter_out;
(* Removal, which is the operation that can break the others. A Robin Hood (* Removal, which is the operation that can break the others. A probe
probe stops at the first empty slot, so a hole left in the middle of a stops at the first group holding an empty slot, so a slot emptied in
run hides every entry past it — and the hidden ones are exactly what a the middle of a probe sequence hides every entry past it — and the hidden ones are exactly what a
test that only asks after what it removed never looks at. Hence the test that only asks after what it removed never looks at. Hence the
third row: 2000 entries, the even keys taken out, and then every odd one third row: 2000 entries, the even keys taken out, and then every odd one
asked for. A removal that punched the hole and left it answers row one asked for. A removal that punched the hole and left it answers row one
@ -3885,7 +3884,7 @@ level "1"
let map_remove_out = let map_remove_out =
"100\n1\nfalse\ngone\n1\n0\n77\n1\n1000\n1000\n0\n0\n2000\n\ "100\n1\nfalse\ngone\n1\n0\n77\n1\n1000\n1000\n0\n0\n2000\n\
709\nfalse\ntrue\n399\n1\ntrue\n1\n66\n0\n66\n150\n598\nfalse\n\ 709\nfalse\ntrue\n399\n1\ntrue\n1\n66\n0\n66\n150\n598\nfalse\n\
0\ntrue\ntrue\n0\ntrue\n0\n" 0\ntrue\ntrue\n0\ntrue\n0\n0\n"
in in
outputs "map removal" "programs/map-remove.flan" map_remove_out; outputs "map removal" "programs/map-remove.flan" map_remove_out;
outputs ~opt:"-O0" "map removal, -O0" "programs/map-remove.flan" outputs ~opt:"-O0" "map removal, -O0" "programs/map-remove.flan"