The Map is a Swiss table

This commit is contained in:
Joseph Ferano 2026-09-25 11:20:41 +07:00
commit 7b22f0ed98
11 changed files with 607 additions and 514 deletions

View File

@ -1271,10 +1271,13 @@ value an unsigned compare waves through.
lock the agent's listener thread may hold inside =dlopen=, so a program that
should die could hang. Unconditionally rather than only under =--dev=.
** TODO A Map's bounds check and the stale-container failure still die
Deliberate for the stale case — the region the container lived in was released and
there is no frame to go back to that would not read freed memory. The Map path was
left alone rather than converted half-way.
** DONE A Map's bounds check and the stale-container failure still die
CLOSED: [2026-09-25]
A Map has no bounds check: =get= and =map-remove= answer =None= for an absent key
and nothing on the map path indexes by a number, so there was nothing to convert.
The stale-container failure keeps dying — the region was released and there is no
frame to go back to that would not read freed memory. Rules out a signalled
condition on the stale path.
** DONE Six trap paths park instead of killing the session
CLOSED: [2026-09-18]
@ -1334,15 +1337,17 @@ docs/BUILT.md, "Three amendments to a frozen spec".
** DONE Map removal costs a backward-shift loop
Removal landed, with the loop the spec predicted as its cost. Deferring it was
what had kept the implementation free of tombstones and of Odin's
backward-shift loop; taking it is taking the loop.
backward-shift loop; taking it is taking the loop. Superseded by the Swiss table,
which removes by tombstone and moves nothing.
** NEXT The Map is slower than CPython's dict at a million entries
Decided 2026-09-25: build the Swiss-table layout, one metadata byte a slot. Measured against the current Map at small, game and large sizes before and after.
Keys, values and hashes are three separate runs, so a lookup that misses
everything costs three cache misses where a compact dict costs two. One byte of
metadata a slot — the Swiss-table arrangement — is the known answer and is not
built. The crossover is somewhere between ten thousand and a million and nobody
has found it.
** DONE The Map is slower than CPython's dict at a million entries
CLOSED: [2026-09-25]
The Map is a Swiss table: one control byte a slot, key and value side by side,
groups of eight probed as one 64-bit word, seven-eighths load, removal by
tombstone with a same-capacity rebuild to sweep them. Header, entry points and
iteration contract unchanged. Rules out Robin Hood, the backward shift and
separate key and value runs; SSE2 groups are not built. See docs/BUILT.md, "The
Map is a Swiss table".
** DONE dyn maps and interned keywords
CLOSED: [2026-09-20]

View File

@ -3088,6 +3088,10 @@ restriction under it.
### The three properties that were the point
**Superseded.** The table below is no longer a Robin Hood map: it is a Swiss table, and "The Map is a Swiss table"
further down says what replaced what and why. This section and the next are kept for the reasoning they record; the
hash-and-equality pair, the key restrictions and the header are unchanged.
Odin's header states them and they are why it was the thing to follow (`base/runtime/dynamic_map_internal.odin`).
- **Open-addressed Robin Hood hashing at a 75% load factor.** No buckets and no per-entry allocation: one block holds
@ -3104,14 +3108,11 @@ Odin's header states them and they are why it was the thing to follow (`base/run
### Two departures from Odin, both deliberate
**There are still no tombstones, now that removal exists.** A slot is empty or occupied and nothing else, and
`flan_map_remove` keeps it that way by shifting the run back over the hole rather than marking it. Odin does the
opposite, and this paragraph used to say otherwise: read out of
`base/runtime/dynamic_map_internal.odin`, `map_erase_dynamic` sets a tombstone bit and leaves the repair to the next
insert, which is why Odin's *insert* carries a backward-shift loop and its every lookup tests for a tombstone. The
trade is the usual one — erase is O(1) there and the shift is here, and the lookups, which outnumber the removals,
pay nothing. What `spec-memory.md` still defers is the rest of its sentence: move-aware lookup and owned entries. A
removed value is copied out, and nothing is dropped.
**Removal was a backward shift, and is now a tombstone.** While the table was Robin Hood, a slot was empty or occupied
and `flan_map_remove` shifted the run back over the hole. The Swiss table has a third control state, deleted, and
empties a slot outright only where no probe can have passed through it; see "The Map is a Swiss table". What
`spec-memory.md` still defers is unchanged: move-aware lookup and owned entries. A removed value is copied out, and
nothing is dropped.
**The header does not tag the capacity into the data pointer.** Odin stuffs `log2cap` into the low six bits because
its `Raw_Map` must be three words. This header already carries an allocator, a generation and an epoch, so the tagging
@ -3119,10 +3120,10 @@ would buy nothing, cost a mask on every access, and — the part that actually m
block being 64-byte aligned. Alignment is *requested*; an arena whose base is not cache-aligned now gives a slower map
rather than a wrong one.
The header is six words, 48 bytes, the same as a `Vec`'s and for the same reason: a layout that changes with a build
The header is five words, 40 bytes, the same as a `Vec`'s and for the same reason: a layout that changes with a build
flag can disagree across the reload boundary.
data len log2cap allocator gen epoch
data len log2cap allocator epoch
### The hash and equality pair, and why most key types do not get one
@ -3232,7 +3233,7 @@ Measured on this machine, `i64` to `i64`, against CPython 3.13's dict on the sam
left out. Both are waiting on memory there, and this layout waits longer: keys, values and hashes are three separate
runs, so a lookup that misses everything takes three cache misses where a compact dict takes two, and the hash run is a
full eight bytes a slot. Cell packing buys probe locality, which is a win while the hash run is resident and a loss
once nothing is. One byte of metadata a slot — the Swiss-table arrangement — is the known answer and is not built.
once nothing is. One byte of metadata a slot — the Swiss-table arrangement — is the known answer, and the next section is it being built.
The path from 35 ns to 18 ns (the cache-resident floor, at 500 entries) was **profiled, and the first two guesses were
both wrong**: the per-slot cell division and the block-size divisions were each replaced first and neither moved the
@ -3243,10 +3244,80 @@ twice per lookup; and the seed stopped being a five-multiply avalanche on the cr
again immediately after. `64/size` is a table, which is Odin's `Map_Cell_Info` by another route — Odin precomputes it
per type because the probe loop must not divide, and here the sizes arrive as ordinary arguments.
What remains at 18 ns is the type erasure itself: a non-inlinable call into the runtime and two non-inlinable indirect
What remained at 18 ns was the type erasure itself: a non-inlinable call into the runtime and two non-inlinable indirect
calls to the pair. That is the trade `spec-memory.md` chose deliberately — "It is type-erased on purpose… No generics
are involved, and none are needed" — and monomorphisation is what would buy it back, at the cost the spec declined.
### The Map is a Swiss table
The Robin Hood table above lost to CPython's dict at a million entries: three separate runs and an eight-byte hash a
slot. It is replaced by a Swiss table, and the header, every entry point's signature, the hash-and-equality pair and
the iteration contract are unchanged, so the change is confined to `runtime/flan_rt.c`. The layout and the removal rule
are described there; what follows is what the code cannot say.
- **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.
- **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.
- **Key and value share a slot** because a hit then costs two cache misses rather than three. The runtime is not told
alignments; the largest power of two dividing each size bounds them.
- **The head of the block holds three words** — growth left, slot stride, value offset — because the five-word header
is the emitter's layout too. The stride is stored rather than recomputed because recomputing it per call cost about a
tenth of a cache-resident lookup.
- **The 25/32 threshold** for sweeping deleted slots at the same capacity rather than doubling, and the rule for
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
allocator budget that a doubling would exceed.
#### Measured
`i64` to `i64`; keys present are `2i`, absent `2i + 1`. *insert* fills a fresh map with no `reserve`, to two million
inserts; *hit* and *miss* are four million lookups cycling the keys; *remove* removes every key of a filled map, to two
million. Each pass counts its wrong answers: a map passed by value to a filling function takes its grows in the
callee's copy of the header, and a benchmark written that way measures an empty map. CPython is Python 3.13.9,
`dict.get` and `dict.pop`.
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
suites — and a minimum measures the program rather than the neighbours. Both Flan binaries are `-O2`. The magnitudes
carry that load: across three nine-run passes the Robin Hood column moved by as much as half (a 100k hit read 51, 83
and 35 ns). What held is the ordering — the Swiss table ahead of the Robin Hood table in every cell of every pass,
and ahead of CPython in every cell — and that is the claim to reproduce. Nanoseconds per operation:
| Entries | Op | Robin Hood | Swiss | CPython dict |
|---|---|---|---|---|
| 16 | insert | 74.5 | 30.5 | 46.3 |
| | hit | 20.0 | 9.8 | 83.1 |
| | miss | 16.1 | 8.0 | 100.5 |
| | remove | 23.3 | 15.4 | 69.0 |
| 1k | insert | 80.9 | 30.0 | 57.4 |
| | hit | 22.2 | 9.7 | 124.6 |
| | miss | 15.9 | 7.8 | 128.3 |
| | remove | 24.5 | 14.8 | 91.9 |
| 10k | insert | 112.4 | 45.5 | 85.5 |
| | hit | 29.6 | 10.5 | 134.9 |
| | miss | 26.5 | 8.5 | 125.3 |
| | remove | 33.2 | 17.6 | 99.7 |
| 100k | insert | 204.4 | 47.6 | 123.6 |
| | hit | 35.3 | 16.1 | 133.6 |
| | miss | 30.4 | 14.4 | 128.4 |
| | remove | 40.6 | 22.9 | 98.4 |
| 300k | insert | 199.4 | 73.0 | 128.7 |
| | hit | 69.9 | 25.9 | 118.0 |
| | miss | 39.8 | 10.9 | 138.2 |
| | remove | 116.8 | 36.9 | 94.3 |
| 1M | insert | 454.2 | 138.5 | 151.5 |
| | hit | 147.1 | 78.4 | 120.3 |
| | miss | 88.2 | 14.6 | 125.2 |
| | remove | 152.0 | 74.5 | 91.5 |
**The crossover.** Against CPython the Robin Hood table lost on insert at every size, on remove from between 100k and
300k, and on hits between 300k and 1M. The Swiss table is ahead of both in every cell. CPython hashes a small integer
to itself, so this key pattern walks its table in order and the margin at a million is narrower than it looks.
Control bytes over separate key and value runs were measured and not kept: level with the final layout to 100k, behind
from 300k (100 ns a hit against 78 at a million).
## Unions, and the tag they carry
`defdata` parsed and its shape was checked long before this; naming the type (`check.ml:312`) and constructing a
@ -4488,10 +4559,8 @@ function.
```
**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
occupied and the position is the whole of the state. What a cursor does *not* survive is a removal taken while it is
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
hold: a slot's control byte says whether it is full, and the position is the whole of the state. A removal moves
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
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
@ -4501,11 +4570,10 @@ that is written through on the way out.
**It is the one map entry point that carries neither a hash nor an equality function.** Walking asks nothing about a
key. The two sizes are still there, because the runtime is type-erased and the block geometry is computed from them.
**The layout, restated, because it is the thing to get wrong here.** `data` is *one* allocation laid out
keys | values | hashes | scratch, each run cell-packed to a cache line — the arrangement the Valgrind lane described
while explaining why a probe overrun is not observable. A key is reached through `flan_cell_at` and never as
`ks + i * ksize`. The hashes are the exception `flan_map_clone` already relies on: an 8-byte element packs 8 to a
64-byte cell with nothing left over, so `g.hs[i]` is the right index and a flat one.
**The layout, restated, because it is the thing to get wrong here.** `data` is *one* allocation: a three-word head,
the control bytes, then the slots, each slot a key and its value side by side at the stride the head records. A slot is
full when its control byte has the top bit set, and a key is reached as `slots + i * stride`, never as
`ks + i * ksize`.
**Order is block order**, which is the hash's order and not the insertion's, and it changes when the map grows.
`programs/map-iter.flan` is therefore written entirely in sums, counts and lengths — every claim in it is order-free,
@ -5732,10 +5800,10 @@ IR (which is also why `--no-bounds-checks` never reached it), so both grew a tra
`Emit`'s `Rt` arm guards those two symbols and no others — they are the only ones in that family that can transfer;
everything else there is arithmetic over a container header.
**A `Map`'s bounds and a `Vec`'s stale-allocator check still die.** `flan_vec_stale_fail` is a different kind of
**A `Vec`'s stale-allocator check still dies, and so does a `Map`'s.** `flan_vec_stale_fail` is a different kind of
failure — the region a container lived in was released, and there is no frame to go back to that would not read freed
memory — and the map path was left alone rather than converted half-way. Written down here so it is a known edge
rather than a discovery.
memory. A `Map` has no bounds check to convert: `get` and `map-remove` answer `None` for an absent key and nothing on
the map path indexes by a number, so the stale check is the only map failure that dies.
## An address answers with a type, and nothing grew a tag word

View File

@ -7629,7 +7629,7 @@ and named_call ?(qualified = false) ctx ~want loc name args =
let attempt, note =
match target.Tast.ty with
(* 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
nth put. *)
| Types.Map (k, v) ->

View File

@ -334,7 +334,7 @@ let rec ll (t : Types.t) =
the Vec's address — so the shape is here only so that a slot, a struct
field and a copy in the IR are the right number of bytes. *)
| Types.Vec _ -> "%vec"
(* data + len + log2cap + allocator + gen + epoch. Six words, exactly as the
(* data + len + log2cap + allocator + epoch. Five words, as many as the
Vec's, and read here for exactly the same reason: nothing in this file
touches a field of one — every operation is a runtime call taking the
map's address — so the shape exists only so that a slot, a struct field
@ -4379,7 +4379,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
; type-erased and every operation is handed size and align at its call site.
%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,
; handed the two sizes and a hash/equality pair at each call site.
%map = type { ptr, i64, i64, ptr, i64 }

File diff suppressed because it is too large Load Diff

View File

@ -20,7 +20,7 @@ facility (see plan.org, "Managed classes").
| `[n T]` | n contiguous `T` | copies | no (inline) | — |
| `[T]` | ptr + len | copies the *view* | no | — |
| `(Vec T)` | ptr + len + cap | **moves** | yes | stored |
| `(Map K V)` | open-addressed, flat key/value arrays | **moves** | yes | stored |
| `(Map K V)` | open-addressed, one control byte a slot, key and value side by side | **moves** | yes | stored |
- `[n T]` is a value. It lives wherever it is declared, copies on assignment and
on pass-by-value, and is what `defconst colors [4 u32] ...` and

View File

@ -82,7 +82,7 @@
(print chars) (print " ") (print xs) (print " ") (print ys) (println ""))
(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
;; doublings past the minimum.
(defn after-growth [] ()

View File

@ -1,17 +1,26 @@
;;;; (map-remove m k) — the operation a Map has been missing.
;;;;
;;;; Removal is the one map operation that can break the *other* ones: Robin
;;;; Hood lookups stop at the first empty slot, so a hole punched in the middle
;;;; of a probe run hides every entry after it, and the entries it hides are
;;;; found by no test that only removes and asks about what it removed. So the
;;;; rows below are mostly about the survivors.
;;;; Removal is the one map operation that can break the *other* ones: a
;;;; lookup stops at the first group of slots holding an empty one, so a slot
;;;; emptied in the middle of a probe sequence hides every entry placed past
;;;; it, and the entries it hides are found by no test that only removes and
;;;; asks about what it removed. So the rows below are mostly about the
;;;; survivors.
;;;;
;;;; Odin marks a tombstone and repairs on the next insert; this shifts the run
;;;; back and stays tombstone-free, which is the arrangement the rest of the
;;;; file already assumed. A version that punched the hole and left it passes
;;;; row 1 and fails row 3.
;;;; A removed slot is marked deleted, and emptied outright only where no probe
;;;; can have passed through it. A version that emptied every removed slot
;;;; passes row 1 and fails row 3; a version whose deleted slots were never
;;;; swept grows without end under row 7.
(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
;; (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
@ -41,9 +50,8 @@
;; (3) The survivors, which is the row that matters. 2000 entries is eight
;; grows, so the runs are long and interleaved; removing the even keys and
;; then asking after every odd one is asking whether any probe run was cut.
;; A backward shift that stopped one slot early loses entries here and
;; nowhere else.
;; then asking after every odd one is asking whether any probe sequence was
;; cut.
(let [m (map-new i32 i64)]
(dotimes [i 2000] (put m i (* (i64 i) 3)))
(let [taken 0]
@ -90,9 +98,7 @@
(print (length s)) (println "") ; 1
(free s))
;; (5) Iteration after removals answers exactly the survivors. The cursor is
;; started fresh — a cursor held *across* a removal is invalidated by the
;; shift, the same way a put that grows invalidates one.
;; (5) Iteration after removals answers exactly the survivors.
(let [m (map-new i32 i32)]
(dotimes [i 100] (put m i i))
(dotimes [i 100] (if (= 0 (% i 3)) (map-remove m i)))
@ -118,4 +124,64 @@
(match (get t 299) (Some v) (do (print v) (println "")) None (println "?")) ; 598
(print (has-key? t 298)) (println ""))) ; false
(free-all ar))
;; (7) Churn at a steady size, checked against a plain array. Keys are drawn
;; from 512 and the map holds about two thirds of them, so removals leave
;; deleted slots that inserts must reuse and rebuilds must sweep. Every get is
;; checked against the array, and at the end so is the length and a full walk.
;;
;; 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)
bad 0
live 0]
(dotimes [i 512] (set (at churn-ref i) (i64 -1)))
(dotimes [step 200000]
(set x (+ (* x (u64 6364136223846793005)) (u64 1442695040888963407)))
(let [k (i32 (% (>> x 33) (u64 512)))
op (i32 (% (>> x 20) (u64 4)))]
(if (< op 2)
(do (if (< (at churn-ref k) 0) (set live (+ live 1)))
(put m k (i64 step))
(set (at churn-ref k) (i64 step)))
(if (= op 2)
(match (map-remove m k)
(Some v) (do (if (not (= v (at churn-ref k))) (set bad (+ bad 1)))
(set (at churn-ref k) (i64 -1))
(set live (- live 1)))
None (if (>= (at churn-ref k) 0) (set bad (+ bad 1))))
(match (get m k)
(Some v) (if (not (= v (at churn-ref k))) (set bad (+ bad 1)))
None (if (>= (at churn-ref k) 0) (set bad (+ bad 1))))))))
(print bad) (println "") ; 0
(print (= (length m) (i64 live))) (println "") ; true
(let [cur (i64 0) k 0 v (i64 0) seen 0]
(while (map-next m (addr cur) (addr k) (addr v))
(set seen (+ seen 1))
(if (not (= v (at churn-ref k))) (set bad (+ bad 1))))
(print (= seen live)) (println "") ; true
(print bad) (println "")) ; 0
;; Removing while walking visits every survivor once: nothing moves.
(let [cur (i64 0) k 0 v (i64 0) seen 0]
(while (map-next m (addr cur) (addr k) (addr v))
(set seen (+ seen 1))
(map-remove m k))
(print (= seen live)) (println "") ; true
(print (length m)) (println "")) ; 0
(free m)))
(print churn-refusals) (println "") ; 0
0)

View File

@ -1,7 +1,7 @@
;;;; (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
;;;; cache-line cell packing. Every claim below is one a plausible wrong
;;;; An open-addressed Swiss table: one control byte a slot, key and value side
;;;; by side. Every claim below is one a plausible wrong
;;;; version gets wrong, and the numbers differ per failure so a single wrong
;;;; answer names its own cause.
(defstruct Cell [x i32 y i32])

View File

@ -3968,8 +3968,7 @@ level "1"
(* ── (Map K V), spec-memory.md step 4 ──────────────────────────
Odin's map: open-addressed Robin Hood hashing at a 75% load factor with
cache-line cell packing. maps.flan is seven claims, each one a plausible
An open-addressed Swiss table, one control byte a slot. maps.flan is seven claims, each one a plausible
wrong version gets wrong, and the numbers differ per failure.
The two worth naming, because nothing else in the suite would catch
@ -4008,9 +4007,9 @@ level "1"
outputs "map iteration" "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
probe stops at the first empty slot, so a hole left in the middle of a
run hides every entry past it — and the hidden ones are exactly what a
(* Removal, which is the operation that can break the others. A probe
stops at the first group holding an empty slot, so a slot emptied in
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
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
@ -4021,7 +4020,8 @@ level "1"
into the wrong half of it. *)
let map_remove_out =
"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\n0\n"
in
outputs "map removal" "programs/map-remove.flan" map_remove_out;
outputs ~opt:"-O0" "map removal, -O0" "programs/map-remove.flan"

View File

@ -275,10 +275,11 @@ let corpus =
down: [check_at] is behind the flag and so is *half* of [check_slice] — the
[hi <= len] compare — while its [lo <= hi] stays, because that one is the
claim that a slice's length word is a count and not a bounds check at all.
A Vec's and a Map's bounds checks are not behind it either: they live inside
flan_vec_at and the map probe in flan_rt.c, are plain C, and run in every
build. So the flag lowers the guard on indexing a fixed array or a slice,
and on nothing else. These are the programs where that distinction reaches
A Vec's bounds check is not behind it either: it lives inside flan_vec_at,
is plain C, and runs in every build. A Map has no bounds check to lower —
its probe is masked to the capacity and an absent key is None. So the flag
lowers the guard on indexing a fixed array or a slice, and on nothing
else. These are the programs where that distinction reaches
heap storage. *)
let unchecked_subset =
[ "programs/allocators.flan", [];