VerifiedHAMT.SetWithoutValArray is a verified, bundled counterpart of
VerifiedHAMT.Set, with constant-time size. Its leaves store only keys and its
collision buckets only an Array α. There are no value fields or vals arrays.
The 32-way entries arrays, four-key promotion threshold, and maximum promotion
depth of seven are unchanged.
import VerifiedHAMT.SetWithoutValArray
open VerifiedHAMT
open scoped VerifiedHAMT.SetWithoutValArray
def s : SetWithoutValArray Nat := .ofList [7, 3, 7]
#eval s.size -- 2 (field access)
#eval (s.insert 7).size -- 2
#eval (s.insert 10).size -- 3
#eval decide (3 ∈ s) -- true
example (s : SetWithoutValArray Nat) (k q : Nat) :
q ∈ s.insert k ↔ q = k ∨ q ∈ s := by simp
example (s : SetWithoutValArray Nat) (k : Nat) :
((s.insert k).insert k).size = (s.insert k).size := by
simp [SetWithoutValArray.size_insert]The collection operations match Set.lean: empty sets, literals, insertion,
bulk construction, membership queries, decidable structural membership,
enumeration, and size. The public type bundles Valid, Unique, and
size_eq : size = Raw.keyCount toRaw.root; clients do not supply invariant
hypotheses. mem_toList, nodup_toList, and length_toList prove that
toList enumerates each element once and that its length is the cached size.
Its slot order matches VerifiedHAMT.Set.toList.
toRaw returns the keys-only SetWithoutValArray.Raw in constant time.
ofRaw requires proofs of routing and uniqueness and counts the keys in linear
time and memory, like VerifiedHAMT.Set.ofRaw. There is no zero-copy native-map
bridge or toMap/ofMap, since the node types differ.
The implementation adapts the previous VerifiedHAMT.InsertSized design:
- A
Raw.SizedRawholds the raw root and the whole set's count. The public set extends this exact container, adding only erased proof fields. Counts are not stored in every node. - Insertion carries the container down one hash route. At an empty slot it adds one; replacing a leaf leaves the count unchanged. At a collision bucket it compares the array lengths before and after the existing insertion scan.
- Promotion rebuilds use unsized insertion: the count change was already determined before rebuilding. This prevents counting reinserted keys twice.
- Parent slots are cleared before recursive calls, preserving exclusive
ownership and in-place updates where possible. Returning the runtime carrier
avoids a separate
(Bool, Node)result at every level. insertSizedhas a simple logical specification: ordinary insertion and anif contains ... then oldSize else oldSize + 1count. The kernel-checked@[csimp]theoremRaw.insertSized_eq_implreplaces it with the fused traversal in compiled code. No separate membership traversal runs.
| File | Contents |
|---|---|
Basic.lean |
Keys-only representation, raw tree, routing and uniqueness predicates |
Contains.lean |
Total lookup, soundness and completeness |
Insert.lean |
Unsized total insertion, cached hashes, promotion and slot clearing |
InsertProofs.lean |
Routing preservation and exact membership update |
Size.lean |
Structural key list, count, membership and absence of duplicates |
Unique.lean |
Uniqueness preservation and the key-count insertion law |
InsertSized.lean |
Reusable count carrier, fused insertion and compiler-rewrite proof |
Operations.lean |
Bundled public API and theorems corresponding to Set.lean |
All implementation functions are total. No library implementation uses
partial, unsafe, sorry, or implemented_by. The main axioms are checked:
only propext, Classical.choice, and Quot.sound occur. Public simp rules are
scoped to VerifiedHAMT.SetWithoutValArray; raw implementation rules are scoped
to its Raw namespace.
lake test checks proof examples, axiom dependencies, and 240,240 query
comparisons for the keys-only API. It checks every intermediate cached size
against a list model and VerifiedHAMT.Set, compares tree shapes and enumeration,
and checks every retained old version after subsequent insertions. Workloads
include distinct and repeated keys, default/identity/shared-prefix/constant/
high-bit hashes, and Name keys. Raw-level tests cover malformed short entries
arrays and the zero-promotion-level collision worker.
VerifiedHAMTTests/ReleaseIR.lean checks slot clearing before recursive insertion
for both sized and unsized workers and their Nat specializations.
VerifiedHAMTTests/SetWithoutValArrayIR.lean checks that insertion and lookup
compile identically to direct operations on Raw.SizedRaw, after proof erasure.
It also checks that size compiles identically to a plain field projection.
The benchmark runner rejects indirect calls in specialized round and query
loops.
Run from the repository root with no other benchmark or build running:
lake test
python3 Benchmarks/run_without_vals.py --runs 3 --samples 10 --target-ms 20
python3 Benchmarks/inspect_without_vals.pyThe 18 insertion and 22 lookup workloads cover construction, duplicates, extension, promotion, retained snapshots, hit rates, Nat and Name keys, and several hash distributions. Five backends run in rotating order:
| Prefix | Backend |
|---|---|
native |
Lean.PersistentHashSet |
raw |
Existing total PersistentHashMap α Unit operations without a size counter |
bundled |
VerifiedHAMT.Set, with cached size and erased proofs |
bare |
Unsized keys-only SetWithoutValArray.Raw |
keys |
Public SetWithoutValArray, with cached size and erased proofs |
bundled is the comparison with equivalent public semantics; bare isolates
size-maintenance cost on the keys-only representation. The keys/bare seeds
share one tree, and native/raw/bundled share another. Both trees are built in
identical order. Each insertion round borrows its seed, rotates the operation
order, and consumes later versions unless snapshots are retained. The timed
scope includes digest queries and releasing completed rounds. Lookup batches
reuse 8,192 independently validated queries. Ten samples let every backend run
first twice; calibration targets 20 ms for the fastest backend.
The runner saves all samples, process medians, source/binary hashes, compiler
checks, and environment metadata to the ignored
Benchmarks/results/without-vals-sized.json. Time ratios below one mean the
public keys-only set was faster. Aggregates are equal-weight geometric means
of per-workload median paired ratios. These local microbenchmarks do not use
CPU affinity or frequency control.
The memory probe counts unique reachable runtime objects using
lean_object_byte_size, including node/entry objects, array capacities, and
the size carriers of both bundled APIs. It also reports the bare keys-only
footprint, so the carrier cost is visible. Addresses are deduplicated across
snapshots, and all roots stay live throughout traversal. Key payloads, outer
history arrays, and allocator metadata/pages are excluded. These figures are
structural storage, not RSS or total allocation traffic. The unsafe diagnostic
FFI is confined to the benchmark executable.
The inspection script saves keys-only.ir.txt, keys-only.c.txt, benchmark.s,
and compiler.json under Benchmarks/results/without-vals-ir/. It checks the
generic implementation and the actual timed Nat/Name specializations. On the
pinned Lean 4.32.0 toolchain:
-
Wrapper/raw IR is identical for insertion, lookup, and size. The bundled routing, uniqueness, and count proofs add no runtime fields or wrapper work.
-
The size accessor is a field read with reference-count handling:
def wrappedSize (x_1 : @& obj) : tobj := let x_2 : tobj := proj[1] x_1; inc x_2; ret x_2 -
Public insertion calls the specialized
Raw.insertSizedRawdirectly after hashing. Both generic sized workers and the reachable Nat specializations have noProd.mkresult allocation or call tocontains,keyList, orkeyCount. The logical two-pass specification has been replaced by the proved fused implementation. -
Final IR lowers reuse to sharing tests and field writes. The emitted C keeps
lean_is_exclusivepaths for the incoming count carrier, entries node, and carrier returned by recursion in both generic workers and all four timed Nat/Name workers. Parent slots are cleared before recursive descent. These paths permit reuse when ownership is exclusive; retained snapshots still require copying shared objects. -
The four timed sized workers have no
lean_apply_*or closure allocation in C, and no indirect branch-and-link or closure/application calls in ARM64 assembly. Generic workers retain the expected typeclass equality callback, and generic promotion constructs a callback; the specialization result should not be generalized to every polymorphic caller. -
The timing function calls the batch once between the two clocks, with
Runtime.holdbefore the second clock. Assembly is generated with Lake's exact recorded flags, replacing-cwith-S.
These checks guard specific generated-code properties, not a universal runtime cost bound. They depend on compiler symbol conventions; review failures after a Lean upgrade. The assembly checks currently cover macOS ARM64.
The recorded run used Lean 4.32.0 Release and a native ARM64 benchmark executable. Three processes with ten samples each produced 1,200 validated five-backend samples. Source and binary hashes, plus the inspected benchmark C hash, are saved in the report. These measurements precede the inlining follow-up below.
Elapsed-time ratios, public keys-only set divided by each baseline:
| Baseline | Insertion, 18 workloads | Lookup, 22 workloads |
|---|---|---|
Native PersistentHashSet |
0.982 | 0.874 |
| Unsized unit-valued raw map | 0.970 | 0.998 |
Bundled VerifiedHAMT.Set |
0.915 | 0.999 |
| Unsized keys-only raw tree | 1.058 | 1.000 |
Relative to the comparable public Set, insertion took 8.5% less time in the
geometric mean; the individual process aggregates were 0.914–0.917. Lookup
was essentially unchanged (0.998–0.999). Maintaining cached size costs about
5.8% insertion time relative to the unsized keys-only tree, with unchanged
lookup time.
There are individual regressions. For 128 constant-hash keys, lookup ratios
against Set were 1.086, 1.058, and 1.024 at 0%, 50%, and 100% hit rates.
The all-miss regression occurred in all three processes (1.084–1.087).
Name all-hit lookup was 1.020. Thus the results support lower memory use and
competitive overall performance, but do not establish no performance loss
for every workload.
Reachable structural storage, including the cached-size carriers on both sides:
| Workload | Set bytes |
Keys-only bytes | Reduction |
|---|---|---|---|
| Default Nat, 32 keys | 1,088 | 832 | 23.5% |
| Default Nat, 4,096 keys | 428,096 | 395,328 | 7.7% |
| Default Nat, 65,536 keys | 5,310,528 | 3,213,376 | 39.5% |
| Default Nat, 1,048,576 keys | 35,719,232 | 27,330,624 | 23.5% |
| Shared-prefix Nat, 4,096 keys | 429,032 | 396,264 | 7.6% |
| Constant-hash Nat, 4,096 keys | 100,240 | 51,072 | 49.1% |
| Name, 16,384 keys | 995,904 | 740,840 | 25.6% |
| Default Nat, 4,096 + 512 retained insertions | 923,712 | 886,848 | 4.0% |
| Shared-prefix Nat, 4,096 + 512 retained insertions | 1,403,880 | 1,367,016 | 2.6% |
| Constant-hash Nat, 128 + 128 retained insertions | 1,438,608 | 842,112 | 41.5% |
In these runs cached size adds exactly 24 bytes per live set version over the
bare keys-only tree: one two-field carrier with a small immediate Nat count.
It adds no per-node counters. With 512 retained insertions plus the seed this
is 12,312 bytes, the same carrier cost as the existing bundled Set. Actual
savings depend on the mix of singleton leaves, collision buckets, array
capacities, and shared paths; the structural figures exclude key payloads and
allocator overhead as described above.
On the same Lean 4.32.0 toolchain, adding @[inline] to both
getCollisionNodeSize and mkCollisionNode removes their calls from generated
insertion code, but increases insertion time by 6.0% in the geometric mean.
The 128-key constant-hash build and duplicate-insertion workloads regress by
33.1% and 67.4%, respectively. Three baseline/candidate pairs, alternating
order, gave aggregate insertion ratios of 1.073, 1.051, and 1.065. Normalizing
against the unchanged bundled-Set control gives a similar aggregate, 1.059.
The emitted C shows the ownership issue. Without inlining the size accessor, the collision branch computes the old size before consuming the old node:
oldSize = array_size(oldKeys);
newNode = insertCollisionAux(oldNode, key);
newSize = getCollisionNodeSize(newNode);
Inlining the accessor exposes the new node's array projection and lets the compiler delay the old size read. The resulting reference-count operations keep the old array shared during insertion:
inc_ref(oldKeys);
newNode = insertCollisionAux(oldNode, key);
oldSize = array_size(oldKeys);
dec_ref(oldKeys);
newSize = array_size(newKeys);
This prevents in-place collision-array updates even when the original set was
exclusive. It occurs in both generic sized workers and all four Nat/Name
benchmark specializations. getCollisionNodeSize therefore has an explicit
@[noinline] boundary. The inspection script now checks that the old size read
precedes collision insertion and that no old-array alias is retained across
the call. The new check accepts the baseline and rejects the regressing code.
mkEmptyEntries and mkEmptyEntriesArray are shared closed values. Their
remaining calls in the generated benchmark C occur in initialization helpers,
not the hot insertion loops. Proposition-valued definitions and proof terms
are erased and do not benefit from runtime inlining.
The follow-up uses preserved baseline/candidate executables, three processes
per variant, ten samples per process, and a 10 ms calibration target. No builds
run during timing. Raw samples, source snapshots, binary/C hashes, and the
comparison script are saved under the ignored
Benchmarks/results/without-vals-inline/ directory; comparison.json records
the two-inline experiment. Lookup-worker C is unchanged after renaming local
variables, so small query timing shifts are not evidence of a lookup optimization.
A second comparison keeps the size accessor @[noinline] and inlines only
mkCollisionNode. This restores the old-size-before-insertion ordering and
passes the ownership check. Insertion time is 1.008 times the baseline, or
1.001 after normalizing against the bundled-Set control; individual process
pair aggregates are 1.011, 1.001, and 1.010. Lookup is unchanged (1.000).
There is no demonstrated speedup, so the constructor's inline annotation is
not retained. constructor-comparison.json records this experiment with the
same sampling protocol. The final source keeps only the protective noinline
on the size accessor, plus the explanatory comment on the shared empty node.
Final generated C for Basic, InsertSized, and the benchmark is byte-for-byte
identical to the baseline; final-code-comparison.json records the hashes.
| Change relative to the original Basic.lean | Insertion time ratio | Normalized against bundled Set |
|---|---|---|
| Inline both size accessor and collision constructor | 1.060 | 1.059 |
| Inline constructor, keep size accessor noinline | 1.008 | 1.001 |
Ratios below one indicate less time. These are comparisons with the same
keys-only implementation before the attribute changes, not with the unit-valued
VerifiedHAMT.Set; the Set backend is only a timing control in the last column.