Repository navigation
v0.9.0
0.9.0 is a breaking release. Under Cargo's 0.x rule, ^0.8 does not match 0.9.0, so a dependent upgrades deliberately. The Rust API loses five items and changes one signature to unsafe, and the C header marks six string-map navigation functions deprecated. The concurrent Sync* wrappers no longer reach undefined behaviour from safe code on either width (#1086), and that fix costs instructions on the shared paths, and writer throughput on the 32-bit SyncExpanseMap32. The costs are listed under Disclosed costs.
Breaking changes
Checked against git diff v0.8.2 f4d1da2e -- .github/public-api/ include/.
SlotTag::Tombstoneis removed (#1180). Nothing produced or consumed it. The byte0xFEnow decodes asSlotTag::RawWord. The binary image format is unchanged (EXPANSE_FORMAT_VERSIONstays 2).
Migration: delete matches onSlotTag::Tombstone.SlotTagis#[non_exhaustive], so a wildcard arm already covers it.ExpanseMap::range_filteredandExpanseMap::scan_filteredare removed (#1181). They decoded bits 63:40 of every value as blob-map metadata without checking the tag. On a plain map that is part of the caller's value, and on a blob map's index it is inline payload bytes. No C symbol or binding called them.
Migration: on a plain map, usemap.range(r).filter(..). For blob metadata, useExpanseBlobMap::scan_filtered, which checks the tag.slot::filter_slots_rangeandslot::filter_slots_predicateread metadata only fromArenaMetaslots (#1179). The signatures are unchanged but results change: any other slot reads as metadata0, as the rustdoc already said. Before, an inline slot reported its payload bytes as metadata.
Migration: none, unless you depended on the old results. A range that excludes 0 no longer matches inline or raw slots.with_locked_mutis replaced bywith_exclusiveonSyncExpanseStrMapandSyncExpanseBytesMap(#1196, Refs #1188). The old method gave its closure a&mutmap while optimistic readers ran, so a store through a returned slot raced them.with_exclusivepasses aStrExclusive/BytesExclusivehandle withget,contains_key,insert,remove,update,lenandis_empty. The operations in one section still apply as one step.
Migration: rewritew.with_locked_mut(|m| { … m.insert(k, v); … })asw.with_exclusive(|x| { … x.insert(k, v); … }). Code that needs a slot pointer or the whole map has no replacement yet; the operation gaps are tracked in #1194. Read-only scans move towith_locked, because the string map's cursors now take&self(see below).occ::Collector::retireis anunsafe fn(#1173, Refs #1145). It frees a pointer the caller passes in, so safe code could free a dangling pointer. Its# Safetysection states the contract.
Migration: wrap each call inunsafe { }and document why the pointer meets the contract. No binding, bench or example called it.- Four unused
occ32functions are removed (#1198):node_validate,node_version_begin,node_version_endandtry_node_sample. Nothing in the workspace called them.
Migration: none inside this workspace. External callers must drop them. occ_statsgainsStat::BranchSplitDemoteU(index 48, #1204).NAMESis now[&str; 49]andsnapshot()returns[u64; 49].
Migration: code that names the array length 48 must use 49. This only applies with theocc-statsfeature.Statis#[non_exhaustive].BRANCHU_TO_B_DOWNis now 160, not 191 (#1221). The type is unchanged, so the API snapshot does not move, but the value and the node form it selects do. After removals, a branch keeps itsBranchUform down to 161 digits, where it used to demote to aBranchBat 191. This applies on plain trees as well as shared ones. The memory cost is under Disclosed costs.
Migration: none needed for correctness. Code that reads the constant sees the new value.- C: six string-map navigation functions are deprecated (#1159):
expanse_strmap_first,_last,_next_at_or_after,_next_after,_prev_at_or_beforeand_prev_before.include/expanse.hnow tags themEXPANSE_DEPRECATED(deprecatedon GCC/Clang,__declspec(deprecated)on MSVC). A build with-Werror(or-Werror=deprecated-declarations, or/WX) now fails at each call.
Migration: call the_exvariants (expanse_strmap_first_ex, …), which were already in 0.8.2. They returnexpanse_str_nav_status, which separates truncation from a missing key. The header has no macro to turn the attribute off. - C:
EXPANSE_NONNULL(1)onexpanse_map_insert,expanse_bytesmap_insertandexpanse_strmap_insert(#1159). The compiler may now warn when the handle argument is a literalNULL, and with-Werrorthat warning fails the build.
Migration: do not pass a NULL handle to these three functions.
Two other changes do not break source code:
ExpanseStrMap::first,last,next_after,next_at_or_after,prev_at_or_before,prev_before,cursorandcursor_at_or_afternow take&selfinstead of&mut self(#1196, #1159). Existing calls still compile.StrCursoris nowUnwindSafe.- Several C functions now take
consthandles (#1159):expanse_strmap_slot,expanse_bytesmap_slot, and the string-map navigation functions with their_exvariants. Callers compile unchanged. Code that stores these functions in function-pointer variables typed with the old signatures must update those types.
Added
compact()on the 64-bitExpanseSetandExpanseMap(#1199). It rebuilds the surviving keys into a new allocator, which returns the slab pages that live nodes keep pinned after bulk deletes.shrink_to_fit()cannot return those pages, because it never moves a node. Every node moves, socompact()invalidates every value pointer. The transient peak is the old tree, the new tree and a key buffer. On a tree shared through a collector it does nothing. Its pre-registered result isINTERMEDIATE:- The retained-bytes gate is met on all 42 cells. Held bytes relative to a fresh build are 0.5770–0.9982 (measured: Apple M1,
c953ea80; workload: example_remove_retention;docs/benchmarks/remove_retention/results/compact_retention.json). - The no-side-effect instruction gate is not met: three
from_sorted_iterarms moved down, by 1.6% to 7.2%, and the gate counts either direction. - The
Sync*wrappers, the 32-bit engine, the other container types and the C ABI do not havecompact().
- The retained-bytes gate is met on all 42 cells. Held bytes relative to a fresh build are 0.5770–0.9982 (measured: Apple M1,
- Public optimistic ordered reads on the 64-bit map readers (#1172, Refs #900).
first,last,next_at_or_after,next_after,prev_at_or_beforeandprev_beforeare now public onMapReader,OwnedMapReaderandDetachedMapReader. Each call is linearizable on its own. A sequence of calls is not a snapshot. The C ABI already exported these reads. Thesync32ordered reads stay#[doc(hidden)]. ExpanseStrMap::cursor_prefixandStrPrefixCursor(#1151, Refs #1096). The cursor walks exactly the keys that start with a prefix.ExpanseStrMap::contains_keyandget_slot_ptr;ExpanseBytesMap::get_slot_ptrandtry_for_each(#1159).mem_held()/shrink_to_fit(), extended across the remaining types (Refs #1135):- the 32-bit
ExpanseMap32/ExpanseSet32, their C symbols in the 32-bit library, andWasmExpanseMap32/WasmExpanseSet32(#1146); - all five concurrent wrappers:
SyncExpanseMap,SyncExpanseSetandSyncExpanseStrMap(#1148),SyncExpanseBytesMapandSyncExpanseBlobMap(#1153). On a wrapper,mem_held()counts the collector's freelists and its blocks still in their grace period.shrink_to_fit(&self)releases the freelists without excluding readers or writers. - the C ABI and the C++, Java, .NET, Go, Python, Node and PHP bindings for the concurrent map and set (#1153).
- the 32-bit
- 12 new C symbols in
include/expanse.h. The 64-bitstdlibrary now exports 170 symbols, up from 158 (docs/COMPAT.md):expanse_sync_map_mem_held,expanse_sync_map_shrink_to_fit,expanse_sync_set_mem_heldandexpanse_sync_set_shrink_to_fit(#1153);expanse_map_contains,expanse_bytesmap_contains,expanse_bytesmap_for_each,expanse_strmap_containsandexpanse_blob_map_contains(#1159);expanse_set_validate,expanse_map_validateandexpanse_map_validate_explain(#1159). These three are 64-bit only.- The 32-bit library now exports 67 symbols, up from 62: the four
expanse_{map,set}_{mem_held,shrink_to_fit}symbols (#1146) andexpanse_map_contains(#1159). - The new symbols are wrapped in the Java, .NET, Python, Node and Go bindings (#1159). The C++ header gains
validate(),bytesmap::for_each,blob_map::get_into, and exception propagation out of callbacks.
JUDY_STRICT_MACROS(#1159). Define it before includingJudy.hto expand theJ1*/JL*/JSL*/JHS*convenience macros asdo { … } while (0). The default expansion is unchanged, for drop-in compatibility.occ_stats::Stat::BranchSplitDemoteU(#1204). It counts shared removals that fall back instead of dropping aBranchUbelow its demotion floor.
Fixed
-
The
Sync*wrappers no longer reach undefined behaviour from safe code, on 64-bit or 32-bit (Refs #1086, present since v0.6.0). Two classes were closed:- optimistic readers racing a writer's plain stores;
&T/&mutaliasing over the engine.
The 64-bit fixes:
- word-atomic root-leaf loads and stores on the map and set (#1133) and on the string map's sub-maps (#1138, #1157);
- bitmap reads through
shared_bitmapin the ordered walks (#1163); - atomic shared accesses to every word a reader loads: edges, branch headers, subarray pointers, value words and packed leaf keys. Covered writers now reach published nodes only through raw pointers (#1175).
with_exclusivein place ofwith_locked_mut, and compile-time checks that each walk is used in the right sharing mode (#1196).
The 32-bit fixes, for
sync32:- readers stop borrowing the engine and read through a published slot table (#1198);
- branch nodes are read and written through raw pointers and word stores (#1203);
- the shared writer copies a leaf before editing it (#1209).
Every entry in the Miri undefined-behaviour census (
.github/miri-ub-sites.json) is now recordedclean. A clean entry is an observation over the census's seed range, not a proof. Deterministic checks that every optimistic-reader answer is validated came with #1219 (Refs #1189). Fail-then-pass records for each class-2 fix came with #1196 and #1220 (Refs #1190). -
Optimistic removals no longer leave a
BranchUbelow its demotion floor (#1204, Refs #1079). Before,validate()could reject a tree shared throughSyncExpanseMap,SyncExpanseSet,SyncExpanseBytesMapor aSyncExpanseStrMapsub-map after concurrent removals. A removal that would reach the floor now falls back (BranchSplitDemoteU). The follow-up in #1221 widened the U↔B band (see Performance). -
SyncExpanseStrMapfrees sub-maps that optimistic removes drain through aBranchU(#1184, Refs #1182). Before, such a node stayed linked untilclearor drop. -
Dropping a
Sync*wrapper returns its tree while the writer thread lives (#1206). The wrapper drained the collector and only then dropped the tree, whose nodes went back into the collector's bins. Each writer thread's cached reader held that collector alive until the thread's next cache miss or its exit. In the PR's measurement, a dropped 10⁶-key random-order map left 10,232,856 B allocated on a live thread (#1206). The tree is now dropped first. -
SyncExpanseStrMapascending-order inserts are no longer O(N) each (#1167, Refs #1162). The deferred insert paths refolded the whole sub-map population after every fallback. At N = 20,000 sorted UUIDs, a load folded 460,996 edges before the fix and 0 after (#1167,sync::fold_edgestest counter). -
JudyLGet,JudySLGetandJudyHSGetno longer mutate the array on a lookup (#1159). They now use a non-mutating slot lookup.ExpanseBytesMap::get_slot_ptrprojects the slot pointer through raw pointers, as Stacked and Tree Borrows require. -
occ::Collector::retireisunsafe(#1173, Refs #1145); see Breaking changes. -
The blob-metadata filters check the slot tag (#1179, #1181); see Breaking changes.
-
sync::tests::stale_locator_window_during_compact_fallbackno longer hangs on a many-core x86 host (#1224). The test now paces its compactor; it is a blob-map compaction test and does not exercise the U↔B band. -
Tooling fixes in range:
- #1166 (
perf_report.pyresolves Ins / Op per harness and arm), #1177 (perf_counterswithholds a zero event only when it was co-scheduled), #1217 (lint reads every lane filter); - #1223:
search_booleandrops its materialized results after each timed repetition (benchmark only); - #1225: bare-metal bench output fails closed on zero-count, empty and stale files;
- #1226: a kernel-released host lock, and a guard that refuses a busy host; #1229: the pinned CPUs run the
performancegovernor for each run; #1230, #1232 and #1234 harden the bench runners (an authorized checkout SHA, a dedicated service account, and a valgrind floor of 3.24.0); - #1228:
disciplinepinned by commit.
- #1166 (
Performance
Figures are instruction counts, Callgrind or wasmtime fuel, unless marked wall clock. Every figure names its host and commits. The release candidate is f4d1da2e; #1235 lands after it and is covered under Disclosed costs.
Instruction counts
Callgrind, v0.8.2 against the release candidate (measured: dual Xeon E5-2697 v4 x86_64 dev host, iai-callgrind 0.16.1 and valgrind 3.24.0 in rust:1.98, v0.8.2 710f1098 against f4d1da2e, both run unfiltered; a local run, not a CI run; workload: core_instructions).
- 133 arms exist in both builds: 102 plain arms and 31
sync_*arms. 41 arms exist only at the candidate. 50 of the 133 are exactly equal. - No arm's measured body changed between the two commits. The setup of the six
strmap_prefix_scan,strmap_prefix_seekandstrmap_cursor_scanarms gained a read-onlycursor_prefixcheck; they are marked "setup changed".
| Arms | >0.1% up | >0.1% down | >0.5% up | >0.5% down | >5% up | >5% down |
|---|---|---|---|---|---|---|
| all 133 | 31 | 33 | 28 | 24 | 16 | 7 |
| plain 102 | 4 | 30 | 2 | 22 | 0 | 6 |
sync_* 31 |
27 | 3 | 26 | 2 | 16 | 1 |
Plain (unshared) containers. 98 of the 102 plain arms are at or below v0.8.2, or within 0.1% of it. The four above 0.1%:
| Arm | N | v0.8.2 Ins/op | RC Ins/op | Δ | Moved by |
|---|---|---|---|---|---|
blobmap32_scan/ipv4_routes |
2k | 172.9 | 174.9 | +1.15% | #1203 |
strmap_prefix_seek/paths (setup changed) |
64 | 2,494.4 | 2,510.4 | +0.64% | before f70af2e2 |
map32_remove/random |
2k | 671.5 | 673.0 | +0.22% | before f70af2e2; #1203 −0.172%, #1209 +0.067% |
set32_remove/random |
2k | 556.2 | 557.2 | +0.17% | before f70af2e2; #1203 −0.120%, #1209 +0.031% |
The largest plain reductions:
| Arm | N | v0.8.2 Ins/op | RC Ins/op | Δ |
|---|---|---|---|---|
strmap_cursor_scan/paths_dense (setup changed) |
50k | 311.0 | 277.0 | −10.92% |
strmap_prefix_scan/paths_dense (setup changed) |
12.5k | 352.2 | 318.3 | −9.63% |
set_clone/sequential |
50k | 56.0 | 52.0 | −7.19% |
strmap_cursor_scan/paths (setup changed) |
50k | 204.1 | 190.0 | −6.93% |
map32_insert/sensor_timestamps |
10k | 502.8 | 470.6 | −6.39% |
strmap_prefix_scan/paths (setup changed) |
12.5k | 244.5 | 230.8 | −5.61% |
map_oscillate/random |
50k | 80.1 | 76.1 | −4.99% |
set_insert/sequential |
50k | 67.7 | 64.4 | −4.79% |
strmap_insert/routes |
50k | 892.2 | 868.5 | −2.66% |
set_clone/random |
50k | 310.2 | 302.5 | −2.50% |
Twelve more plain arms fell by 0.5% to 2.0%, among them strmap_clear_refill/routes −2.00%, set_insert/clustered −1.83% and strmap_churn/routes −1.73%. set_clone fell with #1199's builder change.
Shared (Sync*) containers. 27 of the 31 sync_* arms are above v0.8.2 by more than 0.1%, and 16 of them by more than 5%; this is the accepted cost of the #1086 fix (see Disclosed costs). The largest are sync_strmap_churn_short/short +12.64% (4,164.9 → 4,691.5 Ins/op), sync_strmap_insert_short/short +11.20% and sync_set_insert/random +8.78%. sync_map_prev_locked/random is −0.01% and three of the four leaf arms are flat or lower (sync_map_get/leaf −10.43%); sync_set_churn/leaf is +0.17%.
What moved after f70af2e2 (same host and image; f70af2e2 against 68d787f8 (#1203), 152b96f9 (#1209), 0a8b924f (#1219, #1220) and f4d1da2e (#1221, #1223)). 37 of the 173 arms common to all five builds moved. Beyond those already listed:
sync_map_drain_floor/top+6.905% andsync_set_drain_floor/top+6.623%, both at #1221. Their bench code did not change; #1221 moved the floor the drain crosses (see Disclosed costs).sync_map_write_twin/sequential−10.905% andsync_map_count_after_write/sequential−1.787% at #1221.map32_insert/sensor_timestamps−6.458% (#1203 −5.451%, #1209 −1.065%);set32_insert/sensor_timestamps−0.085% (#1203 +1.080%, #1209 −1.153%).- Every other movement is within ±0.33%. #1219/#1220 changed only test code, and #1223 only the
search_booleanbench, which is not in this suite.
Wasm fuel, v0.8.2 against the release candidate (measured: same dev host, scripts/wasm_fuel.py as CI runs it, wasmtime 48.0.0, wasm32 on rustc 1.98.0, wasm64 on nightly-2026-09-03, N = 10,000, 710f1098 against f4d1da2e; workload: wasm_fuel). All 30 arms exist at both commits on each width, and every checksum matches.
- wasm64: every arm is within −0.11% to +0.25% of v0.8.2. After
f70af2e2it moved only at #1221:map_remove/random−0.351% andset_remove/random−0.332%. - wasm32: −3.32% to +0.73%. Five arms are above v0.8.2 by more than 0.1%:
map_get/sequential+0.73%,map_get/random+0.63%,map_remove/clustered+0.52%,map_get/clustered+0.48% andmap_remove/sequential+0.30%. Themap_getrise is all #1203. Eight arms fell by more than 0.5%, led bymap_insert/random−3.32%,/clustered−2.78% and/sequential−1.97%; the insert reductions are split between #1203 and #1209. The iterate, range andset_containsarms are unchanged.
C ABI against stock libjudy, Callgrind (measured: i9-12900F reference host, v0.8.2 710f1098 against f70af2e2, run 36265221866; workload: capi_vs_stock). None of the 34 Expanse arms regressed. JudyLGet retires 4.3% to 10.8% fewer instructions (judyl_get_expanse −4.32% to −6.92%, judyl_get_expanse_dl −6.72% to −10.77%), because a lookup no longer maintains the insert-path cache (#1159). Every other Expanse arm is unchanged or lower by at most 0.95% (Judy1Set, JudySLIns, JudySLGet and JudyL churn). Expanse executes fewer instructions than stock libjudy on every paired arm.
Wall clock
All wall-clock figures come from the i9-12900F reference host, pinned to 0-15, with BCa 95% intervals (2,000 resamples). A run is admitted only if its artifact records the load inside every timed window; in every admitted run the largest foreign load is at most 0.13 busy core-equivalents. Suites whose runs carry no in-run load record are left out (#1214). Each comparison with v0.8.2 below is one run on each side, so the intervals are within-run and do not bound the spread between runs; a movement is named only when it repeats across runs.
SyncExpanseBytesMap writer scaling (workload: core_concurrency; 50% read / 50% write, 16 threads, total M ops/s, 18 rounds per run):
| Build | Run | M ops/s |
|---|---|---|
v0.8.2 710f1098 |
36302483787 | 33.97 [29.01, 37.44] |
after #1204, 5dac3cee |
36307778215 | 11.14 [11.07, 11.22] |
after #1221, 5fdb77f3 |
36328184138 | 40.26 [39.54, 40.89] |
release candidate f4d1da2e |
36333096580 | 40.39 |
- #1204 made the one-digit
BranchU↔BranchBband reachable on shared trees. A hash trie's level-7 branches sit near 200 digits, derived as 256 × (1 − e^(−390/256)). With a one-digit band, many of those branches becameBranchB, and their removals fell back to the exclusive path. - At
5dac3cee, theocc-statsfallback census recorded 134,513 fallbacks in 4,856,055 writes, against 173 in 14,967,304 at its parent (#1221; dev host, not the reference host). - #1221 widened the band to 32 digits (
BRANCHU_TO_B_DOWN= 160). The #1204 loss holds in three consecutive reference runs (5dac3cee, andf70af2e2runs 36285145713 and 36294918740 at 11.17 [11.09, 11.25] and 11.22 [11.12, 11.31]); the recovery holds in two (5fdb77f3andf4d1da2e). The ratio to v0.8.2 at5fdb77f3is 1.185 [1.071, 1.362].
concurrency suite, selected cells (measured: i9-12900F reference host, 5fdb77f3 run 36328184138 against v0.8.2 710f1098 run 36302483787; workload: core_concurrency; 18 rounds, T threads, total M ops/s; the harness is unchanged between the two commits):
| Engine | Workload | T | 5fdb77f3 |
v0.8.2 | Ratio to v0.8.2 |
|---|---|---|---|---|---|
| SyncExpanseMap | 100% read | 16 | 333.02 [331.36, 334.63] | 338.52 | 0.984 [0.978, 0.990] |
| SyncExpanseMap | 50 / 50 | 16 | 88.20 [83.18, 93.84] | 85.32 | 1.034 [0.942, 1.136] |
| SyncExpanseSet | 100% read | 16 | 396.03 [327.79, 448.65] | 529.03 | 0.749 [0.616, 0.852] |
| SyncExpanseSet | 50 / 50 | 16 | 216.75 [215.24, 218.65] | 209.44 | 1.035 [0.990, 1.137] |
| SyncExpanseBlobMap | 100% read | 16 | 282.06 [281.23, 282.66] | 288.67 | 0.977 [0.972, 0.981] |
| SyncExpanseBlobMap | 50 / 50 | 16 | 6.97 [5.32, 7.98] | 7.27 | 0.959 [0.747, 1.173] |
| SyncExpanseStrMap | 100% read | 16 | 78.74 [78.62, 78.92] | 77.72 | 1.013 [1.011, 1.016] |
| SyncExpanseStrMap | 50 / 50 | 16 | 52.79 [52.13, 53.19] | 53.32 | 0.990 [0.978, 0.999] |
| SyncExpanseBytesMap | 100% read | 16 | 115.10 [109.40, 118.08] | 125.83 | 0.915 [0.869, 0.939] |
| SyncExpanseBytesMap | 50 / 50 | 16 | 40.26 [39.54, 40.89] | 33.97 | 1.185 [1.071, 1.362] |
SyncExpanseMap32 is covered under Disclosed costs.
Movements against v0.8.2 that repeat across runs (the eight post-v0.8.2 concurrency runs on the reference host: the five bisection runs 36303535010 to 36307778215, the two f70af2e2 runs and run 36328184138; workload: core_concurrency):
SyncExpanseBytesMap100% read, every T: 6% to 9% below v0.8.2 at5fdb77f3(0.915 [0.869, 0.939] to 0.938 [0.936, 0.941]), and below in every run fromfcebe54bon. The bisection places it on one commit, #1175 (atomic shared accesses, Refs #1086), whose Callgrind cost was accepted when it merged and whose wall clock was unmeasured. This is the first wall-clock figure for it.SyncExpanseSet100% read, T = 4 and 16: below in the three latest runs, from 0.749 [0.616, 0.852] to 0.977; the size is unstable and earlier runs flip sign. Unattributed.SyncExpanseMap100% read, T = 1 and 4: 0.969 to 0.994 in all eight runs (T = 4 in seven), already at86adbf15, so before #1175. Unattributed.Mutex<ExpanseStrMap>50/50, T = 4: 0.908 to 0.935 in six of eight runs and 0.995 to 1.004 in the other two. Unattributed.- Gains:
SyncExpanseSet50/50 T = 1, +1.6% to +4.1% in all eight runs. The competitor armRwLock<BTreeMap>50/50 T = 16 also moved +0.7% to +4.3% in all eight, which sets a floor on the between-run spread of this suite.
The ycsb suite has no v0.8.2 record, so its cells are not compared with v0.8.2 here.
Loss list: cells where Expanse trails a competitor, with the interval excluding 1. Ratios are Expanse / competitor throughput, except patricia_comparison, which is Expanse ns/op over the competitor's (> 1 means Expanse is slower). All are measured on the i9-12900F reference host at f70af2e2, before #1221, except concurrency, which is at 5fdb77f3.
ycsb(runs 36267550245 and 36308858713, both agree; workload E, range scans, vs BTreeMap):ExpanseBlobMapsparse keys 0.389–0.825 in all six size × order cells;ExpanseMapsparse keys 0.487–0.700 at 100k and 10M;ExpanseBlobMapdense keys 0.604–0.781 in four cells.hashbrown_ycsb(run 36275691729): Expanse vs hashbrown trails on every workload compared (A, B, C, D, F; n = 500k; both orders), 0.373–0.540.rocksdb(run 36276140654) vs VectorRep:fillrandom0.023 [0.022, 0.024],prefixscanBatch 0.198 [0.193, 0.200],prefixscanIterator 0.283 [0.276, 0.287],seekrandom0.933 [0.927, 0.942]. Expanse leads SkipListRep on all five operations.patricia_comparison(run 36273455259): 23 scan cells (full_traversalvs qp_trie 1.07–2.77;prefix_scanonprefixed_pathvs qp_trie 1.35–4.66, fast_radix_trie 1.18–1.91 and patricia_tree 1.16–1.67), and 16 string-lookup cells with longprefixed_pathkeys vs fast_radix_trie (1.12–1.75) and qp_trie (1.01–2.36). Tracked in #1096.ycsb_concurrent(run 36286579298): OLC vs DashMap trails in all 32 cells, 0.15–0.91, lowest at Ac and Fc T = 8 (0.153, 0.151); vs SkipMap at Ac T = 8 (0.455), Fc T = 4 (0.905) and Fc T = 8 (0.447); vsRwLock<BTreeMap>at Fc T = 8, 0.956 [0.882, 0.995]; vs Mutex at T = 1 in Ac (0.832), C (0.900) and Fc (0.682). Pre-registered gate family DG6_peak_8_over_4is REFUTED: [0.9757, 0.9903].concurrency(run 36328184138):SyncExpanseStrMapvs DashMap every cell (0.453–0.627) andSyncExpanseBytesMapvs DashMap every cell (0.442–0.888); the BlobMap, StrMap and BytesMap wrappers vs their ownMutex<…>twins at T = 1 (0.446–0.899);SyncExpanseBlobMapvs SkipMap at 50/50 T = 16, 0.402 [0.312, 0.460].SyncExpanseBlobMap50/50 scales negatively, C(16) = 0.40 [0.32, 0.45], as it did at v0.8.2.- Memory (deterministic censuses, no interval):
art_memory(run 36273216814) at N = 1k, Expanse / ART bytes 1.72× sequential and clustered, 1.93× sparse_stride, 3.39× zipfian;hashbrown_memory_alloc(run 36276027025) random keys, Expanse 58.13 B/key vs BTreeMap 28.90 and hashbrown 34.83 at 1k, down to 30.59 vs 27.24 and 22.28 at 100k.
Disclosed costs and known limitations
- Shared-path instructions. Closing #1086 made every word a reader shares an atomic access. The packed-key leaf search on shared trees is scalar, because stable Rust has no 128-bit atomic to feed the SIMD search. Against v0.8.2 at the release candidate (the dev-host Callgrind run above):
- 26 of the 27 tree-size
sync_*arms are +2.26% to +12.64%, andsync_map_prev_locked/randomis −0.01%. - The root-leaf arms are −10.43% to +0.17%.
- This cost was accepted when #1175 merged, with an
allow-regression:line citing run 36150299210. #1201 recovered part of it (Refs #1191), and the figures include that recovery. - #1204 added a pre-lock count on
BranchUnull stores. #1221 narrowed that count to tag words and stops it past the floor.sync_map_write_twin/sequentialstill retires 2,172,094 instructions against 1,161,694 before #1204 (runs 36256715324 and 36308860629). That arm does not exist at v0.8.2.
- 26 of the 27 tree-size
sync_map_drain_floor/topandsync_set_drain_floor/topare +6.23% and +6.16% (#1221, run 36308860629, approved byallow-regression:; +6.905% and +6.623% on the dev-host run above). With the floor at 160, 39 of the arm's 200 removals take the optimistic null store instead of the exclusive fallback.SyncExpanseBytesMap100% read is 6% to 9% slower than v0.8.2 in wall clock (see Wall clock), attributed to #1175 by bisection. This is the accepted cost of the undefined-behaviour fix.SyncExpanseMap32loses throughput to the undefined-behaviour fix.SyncExpanseMap32is the 32-bit engine's concurrent map; the publicSync*aliases select it only on 32-bit targets. Its fixes (Refs #1187) cost, on the i9-12900F reference host (workload: core_concurrency):- #1203 (branch nodes through raw pointers and word stores): reads −7% to −10% at every write rate (
f70af2e2run 36294918740 against68d787f8run 36334348048). - #1209 (copy-on-write leaves in the shared writer): the full-duty writer falls from 11.62 to 2.49 M writes/s at one reader (
f70af2e2run 36294918740 against152b96f9run 36335596077). Reads in the 1M-writes/s cells fall 47% to 64%, and at 16 readers the writer no longer keeps up with a 1M/s target (0.881 M writes/s atf4d1da2e, run 36333096580). - #1235 recovers part of it. An overwrite with the value already held opens no bracket, and a bitmap leaf's value overwrite is one atomic word store in place. Linear leaves stay copy-on-write. On the i9-12900F reference host (measured:
1e23a1a1, runs 36355351868 and 36356515520, two runs; againstf70af2e2run 36294918740; workload: core_concurrency, armsync32), in M ops/s:- full-duty writer, one reader: 3.88 and 3.99 M writes/s, against 11.62 before #1209 and 2.54 without #1235 (0.33×–0.34× of
f70af2e2); - reads with a writer at 1M writes/s: one reader 28.6 in both runs (0.75× of
f70af2e2's 37.9; 18.6 without #1235); 16 readers 220.5 and 218.2 (0.69×; 108.8 without #1235); the writer again meets its 1M/s target at 16 readers (0.99, against 0.88 without #1235); - lighter write rates: 100k writes/s at 16 readers 400.6 and 404.4 (0.87×); 10k writes/s at 16 readers 431.1 and 426.0 (0.91×, the #1203 per-read cost).
- full-duty writer, one reader: 3.88 and 3.99 M writes/s, against 11.62 before #1209 and 2.54 without #1235 (0.33×–0.34× of
- A stalled reader now makes the writer fail sooner. With one reader parked mid-walk, the writer completed 347,663 mutations before
ReclaimBacklogatf70af2e2, 18,951 atf4d1da2eand 36,180 with #1235 (stalled_reader_backlog_census, #1235), because each leaf edit that changes a leaf's key set still allocates a node and retires one. This is a behaviour change for embedded users: sizenode_capfor the mutations a stalled reader can let through, or keep reader sections short. - On device (STM32H747I-DISCO, M4 at 200 MHz, DWT cycles, minimum of 5 passes, two code placements, #1209): the saturated writer's mutation rate fell 1.8% and 2.8%; the worst-case interrupt-handler read moved 2,111 → 2,112 and 2,092 → 2,124 cycles; the M4
.textgrew by 2,236 bytes. #1198 had raised meansync32read cycles by 2.5% to 6.7%. The publishedsync32device tables have not been re-measured. - Tracked for 0.9.x in #1233: word-atomic in-place leaf edits for key-set changes, which would restore both the writer rate and the stalled-reader tolerance.
- The 64-bit
Sync*wrappers are unaffected by #1203, #1209 and #1235: every 64-bit cell of the reference-hostconcurrencysuite was 0.95–1.00 of its value before them (measured:f4d1da2erun 36333096580 againstf70af2e2run 36294918740), apart fromSyncExpanseBytesMap50/50, which is the #1221 gain.
- #1203 (branch nodes through raw pointers and word stores): reads −7% to −10% at every write rate (
- The U↔B band is 32 digits. After removals, a branch that holds 161–191 digits keeps its
BranchUform (4,160 B), where it used to become aBranchB. At 161 digits, aBranchBtakes at least 128 + 161 × 16 = 2,704 B. That puts the extra memory at no more than 1,456 B per such branch, derived from the node sizes indocs/ARCHITECTURE.md§10 with subarray rounding ignored, which can only shrink the gap. No measured density cell reaches that state.scripts/condense_bounds.pystill models the old 191 floor for the concluded remove-retention spike (#1221). - The U↔B crossing is expensive. On a shared tree, each crossing of the band falls back to an exclusive section that quiesces the writers. A branch whose digit count oscillates across the band still pays that cost. The wider band makes such oscillation less likely; it does not remove the cost. No wall-clock cell measures it yet (#1208). A header child count that would replace the demotion scans is tracked in #1202.
Deferred to 0.9.x
- #1233: word-atomic in-place leaf edits in
SyncExpanseMap32, to restore its writer throughput and stalled-reader tolerance. - #1202: a
BranchUchild count in the node header, to replace the demotion scans (a measured ablation). - #1208: wall-clock cells for a shared
BranchUcrossing its demotion floor. - #1194: the
Sync*operation gaps, including thosewith_locked_mut's removal left. - #1142: a validated batch cursor, with bounds, on the concurrent map readers.
- #1144: concurrent count, rank and select.
- #1096: the string-map prefix scan, still slower than the three radix-trie crates in every
patricia_comparisonscan cell. - #1214: in-run load snapshots for the wall-clock suites that record none; until then those suites are left out of the wall-clock summary.
What's Changed
🚀 Features
- feat(32bit,capi,wasm): mem_held and shrink_to_fit on the 32-bit engine (Refs #1135) by @orieg in #1146
- feat(sync): mem_held and shrink_to_fit on the concurrent wrappers (Refs #1135) by @orieg in #1148
- feat(strmap): cursor_prefix, a cursor bounded by a key prefix (Refs #1096) by @orieg in #1151
- feat(sync,capi,bindings): mem_held and shrink_to_fit across the concurrent wrappers by @orieg in #1153
- feat(sync): make the map readers' ordered reads public by @orieg in #1172
- feat(compact): explicit compact() for the 64-bit ExpanseSet and ExpanseMap by @orieg in #1199
🐛 Fixes
- fix(sync): word-atomic root-leaf loads and stores on the shared map and set (Refs #1086) by @orieg in #1133
- fix(strmap): atomic sub-map root words on the shared string map (Refs #1086) by @orieg in #1138
- fix(strmap): atomic root-leaf shifts in a string node's sub-map (Refs #1086) by @orieg in #1157
- fix(capi): resolve UB in lookups, fix inline blob view safety, and add 8 container methods with 100% ABI parity by @orieg in #1159
- fix(sync): the ordered walks read bitmaps through shared_bitmap by @orieg in #1163
- fix(perf-report): resolve Ins / Op counts per (harness, arm) by @orieg in #1166
- fix(strmap): no census re-sync on the deferred twin's insert paths by @orieg in #1167
- fix(occ): make Collector::retire an unsafe fn with a safety contract by @orieg in #1173
- fix(perf_counters): withhold an event that reads 0 only when co-scheduled (Refs #1096) by @orieg in #1177
- fix(slot)!: filter kernels read metadata only from ArenaMeta slots by @orieg in #1179
- fix: Judy-fidelity hygiene — derived demotion thresholds, remove SlotTag::Tombstone (breaking), scope §2.2 by @orieg in #1180
- fix(map)!: remove ExpanseMap's untagged hot-metadata filters by @orieg in #1181
- fix(strmap): prune sub-maps that optimistic removes drain through BranchU by @orieg in #1184
- fix(sync): atomic shared accesses and raw-pointer covered writers (Refs #1086) by @orieg in #1175
- fix(sync): guarded exclusive handle replaces with_locked_mut; compile-time walk modes; census coverage (Refs #1086) by @orieg in #1196
- fix(sync32): readers stop borrowing the engine; published slot table; census workloads (Refs #1187) by @orieg in #1198
- fix(sync): optimistic removals keep a BranchU above its demotion floor (Refs #1079) by @orieg in #1204
- fix(sync): drop the tree before draining the collector by @orieg in #1206
- fix(sync32): branch nodes through raw pointers and word stores; plain walks keep main's code (Refs #1187) by @orieg in #1203
- fix(sync32): copy-on-write leaves in the shared writer (Refs #1187) by @orieg in #1209
- fix(ci): lint reads every lane filter, and check_ci_filters enforces it by @orieg in #1217
- fix(sync): widen the BranchU -> BranchB band; restores SyncExpanseBytesMap writer scaling by @orieg in #1221
- fix(bench): drop search_boolean's materialized results after each timed repetition by @orieg in #1223
⚡ Performance
- perf(strmap): append a terminal chunk's key bytes in one store (Refs #1096) by @orieg in #1137
- perf(strmap): append a suffix entry's key bytes without memcpy (Refs #1096) by @orieg in #1154
- bench(instructions): count and ordered-scan arms for the concurrent map by @orieg in #1155
- bench(writer_scaling): count_locked cells for concurrent counts under a writer by @orieg in #1160
- bench(patricia): prefix scans time cursor_prefix; keep the unbounded walk as an arm (Refs #1096) by @orieg in #1156
- eval(patricia): counters for the 1M prefix scan's build-order gap (Refs #1096) by @orieg in #1168
- bench(remove_retention): step 0a census, condense bounds and pre-registration by @orieg in #1185
- bench(remove_retention): Callgrind arms for the subtree-condense spike by @orieg in #1192
- eval(remove_retention): phase 3 is negative, nothing promoted by @orieg in #1195
- bench(remove_retention): allocator census and rebuild arm for drained-tree retention by @orieg in #1197
- perf(set,map,leaf): cut #1175's plain-path and shared-writer instruction cost by @orieg in #1201
- bench(concurrency): AArch64 default vs +rcpc census and dispatch-only A/B by @orieg in #1205
- eval(concurrency): AArch64 +rcpc A/B result — no material effect by @orieg in #1207
- bench(wasm): re-baseline fuel counts at 152b96f; run lint on the wasm filter by @orieg in #1215
📦 Packaging & Distribution
🔧 CI & Tooling
- ci(nightly): shard the Miri UB-site census and report a timeout (Refs #1086) by @orieg in #1140
- config(discipline): recognize _self_test test entry points and Unity TEST_CASE macro by @orieg in #1147
- ci(census): record the second innermost frame of bytes_two_writers (Refs #1086) by @orieg in #1149
- ci(miri): split the Tier-1 Miri lane into two shards by @orieg in #1152
- ci(discipline): bump orieg/discipline action to v0.12.2 by @orieg in #1150
- ci(asan): keep the docs-sync test target out of the ASan core lane by @orieg in #1161
- ci: gate slow lanes behind a fast lane; drafts and main pushes run it alone; trim version axes; CodeQL/Python daily by @orieg in #1183
- ci(workflow): skip a push's slow lanes only when it lands a tree CI already passed by @orieg in #1186
📚 Documentation
- docs(benchmarking): re-measure the drain RSS at head (Refs #1135) by @orieg in #1139
- docs(sync): state where the concurrent map's pop0 fold runs by @orieg in #1164
- docs(man): mem_held and shrink_to_fit are on both widths for map and set by @orieg in #1165
- docs: correct README and ALGORITHMS.md against the code and committed artifacts by @orieg in #1170
- docs: correct COMPAT, DATABASE and the large-value and 32-bit design docs against the code by @orieg in #1171
- docs(architecture): correct technical inaccuracies against the code; gate section 10 citation symbols by @orieg in #1174
- docs(visualizer): correct node layouts, thresholds, kernels and read protocol by @orieg in #1176
- docs: re-derive the quiet-host libjudy table, correct occ/blobmap comments, pin drifted METHODOLOGY citations by @orieg in #1178
- docs: the Sync* undefined-behaviour classes are closed on both widths (Refs #1086) by @orieg in #1218
Other Changes
- test(miri): census workloads for the map's ordered reads by @orieg in #1158
- test(miri): census workload for the count fold under a reader by @orieg in #1169
- test(sync): check that every optimistic-reader answer is validated (Refs #1189) by @orieg in #1219
- test(census): fail-then-pass records for the unreached shared stores (Refs #1190) by @orieg in #1220
- fix(bench): fail closed on zero-count, empty and stale bare-metal output by @orieg in #1225
- fix(bench): a kernel-released host lock, and a guard that refuses a busy host by @orieg in #1226
- ci(discipline): pin v0.14.2 by commit; add agent hooks in observe mode by @orieg in #1228
- fix(bench): hold the pinned CPUs at the performance governor for each run by @orieg in #1229
- fix(test): pace the compactor in stale_locator_window_during_compact_fallback by @orieg in #1224
- fix(ci): pin bench checkouts to a SHA the comment authorized by @orieg in #1230
- fix(bench): run the self-hosted bench runners under a dedicated service account by @orieg in #1232
- ci(bench): refuse a Callgrind suite on valgrind older than 3.24.0 by @orieg in #1234
- ci(miri): split the Tier-1 lane into three shards; census the shards by @orieg in #1237
- bench(provenance): a load window around every timed case of the single-process suites by @orieg in #1227
- ci(callgrind): run the hosted Callgrind jobs under a pinned valgrind 3.27.1 by @orieg in #1236
- perf(sync32): recover part of the SyncExpanseMap32 writer cost of copy-on-write leaves (Refs #1187) by @orieg in #1235
Full Changelog: v0.8.2...v0.9.0