בס״ד
לכבוד הקדוש ברוך הוא — for the glory of the Holy One, blessed be He
A safe-by-construction, 100 % Rust memory toolkit: a drop-in
#[global_allocator]and a typed handle store over one verified segment substrate. Compiler-enforcedunsafeconfinement, no C / C++ libraries pulled in (nolibnuma, nomimalloc, nojemalloc, nosnmalloc/tcmalloc) — and ~12–35× faster thanmimallocon cached large alloc/free (0.3.0, single-host criterion — see Performance).
[dependencies]
sefer-alloc = { version = "0.3", features = ["production"] }Or via cargo:
cargo add sefer-alloc --features productionThe production feature is the recommended set for any long-running
multi-thread or async workload. It is shorthand for
alloc-global + alloc-xthread + alloc-decommit + fastbin + alloc-segment-directory + primordial-lazy-commit + class-aware-dirty — the drop-in
GlobalAlloc face, lock-free cross-thread free, OS page decommit, and
the per-thread fast-bin magazine. Without alloc-decommit the
SegmentTable's free-list still recycles freed large-segment slots
(large-alloc/free churn keeps working), but empty small segments cannot
be recycled until they are decommitted; long-running processes with
many small-segment carve/decay cycles will pin slots and eventually
hit the MAX_SEGMENTS cap (see ## Honest limitations below for the
exact number and the reasoning behind it).
For the bare no_std + alloc handle-store core, see
Two faces below; for the full feature matrix, see
Features matrix.
Drop-in #[global_allocator] — three lines, zero configuration. Every
Vec / Box / String / HashMap allocation in your process
(including those made by tokio, rayon, serde_json, etc.) goes
through sefer-alloc.
use sefer_alloc::SeferAlloc;
#[global_allocator]
static GLOBAL: SeferAlloc = SeferAlloc::new();
fn main() {
let v: Vec<u8> = (0..1024).map(|i| i as u8).collect();
println!("vector of {} bytes", v.len());
}SeferAlloc::new() uses defaults whose large-cache policy is tuned
for throughput (unbounded large-cache, 256 MiB headroom, 1 s decay
interval, 10 % decay rate, event-driven mode); the small-segment
pool default (pool_segments=4, pool_byte_cap=16 MiB) is deliberately
RSS-conservative — see
Tuning the small-segment pool
for a latency-oriented opt-in. For RSS-sensitive or container
deployments, see Configuration below.
The default large-object cache retains up to 256 MiB per materialized heap/shard, not 256 MiB for the whole process. Each thread that materializes its own heap (the normal case under
alloc-xthread/production— one heap per active thread) gets its own independent 256 MiB headroom floor. A server with 32 concurrently active threads/heaps that have each touched a large allocation can therefore retain on the order of 32 × 256 MiB of committed OS memory for those heaps' lifetime, not 256 MiB total.Idle alone does not reclaim any of it. A thread/process that goes quiet after a burst of large allocations does not shrink its retained large-object cache on its own — decay is inline and event-driven (it only runs from inside the large alloc/dealloc slow path, by design: this project never spawns a background thread), so with no further large-object traffic there is nothing to trigger a decay tick. This was measured directly: across 36 arms sweeping headroom × thread count, a 2-second idle window with zero allocation activity reclaimed exactly 0 KiB in every arm, including at the 256 MiB default. Only genuinely reclaims on (a) a thread exiting (the one unconditional path,
HeapCore::trim_for_recycle, which evicts the entire large cache), (b) enough subsequent large-alloc/dealloc traffic to drive further decay ticks, or (c) an explicitSeferAlloc::trim_current_thread()call — the caller-driven API this exact gap motivated (R31-10, task #474): call it once your own code knows a burst/phase has ended, and it reclaims immediately instead of waiting for either (a) or (b). Measured RSS win: 128.0 MiB during idle for a representative burst→trim→idle→burst sequence (docs/perf/R31_10_TRIM_CURRENT_THREAD_RSS_GATE.md), vs. 0 KiB for the same sequence without the trim call.
This is a real, measured trade-off, not a defect: the 256 MiB default genuinely buys a better cache hit rate for large-object churn AT A 64 MiB ROUNDED WORKING SET (see Named profiles below — that scoping matters: R31-1 found the 64 MiB / 256 MiB tie BREAKS once a burst genuinely exceeds 64 MiB). If a smaller per-heap floor fits your deployment better — long-running server with many threads, container with a tight memory limit — the shipped, one-line answer is a named profile:
use sefer_alloc::{SeferAlloc, Profile, LargeCachePolicy};
#[global_allocator]
static GLOBAL: SeferAlloc = SeferAlloc::with_profile(
Profile::new().large_cache(LargeCachePolicy::Trimmed64MiB),
);
LargeCachePolicy::Trimmed64MiB lowers the large-cache headroom to 64
MiB/heap — full hit-rate parity with the 256 MiB default at ~7× less
RSS per heap, but ONLY at a working set that rounds to 64 MiB or less;
beyond that boundary it costs the same real hit-rate loss
LargeCachePolicy::LowHeadroom (16 MiB/heap) discloses. See
Named profiles below for the
full comparison table, or hand-roll an exact floor via
LargeCacheConfig::headroom_bytes. Note also that
headroom_bytes (every value in this section) is a decay FLOOR, not an
admission ceiling — see that section for
LargeCacheConfig::budget_bytes if you need an actual
RSS cap.
Full measured methodology (36-arm subprocess-isolated sweep, the
238–241 MiB/heap post-drain floor at the 256 MiB default, and why idle
never triggers a decay tick) lives in
docs/perf/R29_13_LARGE_CACHE_RETENTION_GATE.md —
this section states the headline facts and the practical remedy; that
report is where the full raw numbers and methodology live.
R30-7 (task #456) shipped a single flat Profile enum bundling a
small-pool choice with a large-cache choice; R31-9 (task #473) reworked
it into a small builder over two independent axes — Profile is now
Profile::new().small_pool(SmallPoolPolicy::…).large_cache(LargeCachePolicy::…)
— because the two knobs are governed by unrelated evidence (R27-3/R27-4
for the small pool, R30-6/R31-1 for the large cache) and bundling them
meant a caller who wanted only one win had to accept the other axis's
trade as a package deal. Profile::new() (== Profile::DEFAULT) is
byte-identical to SeferAlloc::new(); each axis is an explicit, opt-in
alternative, independently settable.
use sefer_alloc::{SeferAlloc, Profile, SmallPoolPolicy, LargeCachePolicy};
#[global_allocator]
static GLOBAL: SeferAlloc = SeferAlloc::with_profile(
Profile::new()
.small_pool(SmallPoolPolicy::Throughput)
.large_cache(LargeCachePolicy::Trimmed64MiB),
);Small-pool axis (SmallPoolPolicy):
| Value | pool_segments, pool_byte_cap |
measured latency | measured cost |
|---|---|---|---|
SmallPoolPolicy::Default |
(4, 16 MiB) — the production default |
same as default | none — no additional retention above what every existing deployment already pays |
SmallPoolPolicy::Throughput |
(8, 32 MiB) |
~22 % lower elapsed time, 9→0 decommit syscalls/run — but ONLY measured on a single-threaded, single-shot 1024 B batch-120 churn-with-teardown workload (paired t=8.114, sign 19/20 — R27_4) |
discloses a cost: R27_3 §0) |
Large-cache axis (LargeCachePolicy) — every value is a decay FLOOR,
not an RSS bound (see budget_bytes above for an actual cap):
| Value | headroom_bytes |
measured hit-rate/RSS |
|---|---|---|
LargeCachePolicy::LowHeadroom |
16 MiB |
discloses a cost: 12.5-percentage-point large-cache hit-rate loss vs 64/256 MiB (87.5 % vs 100.0 %, exact at 1/8/32 threads — R30_6 §0.1) |
LargeCachePolicy::Trimmed64MiB |
64 MiB |
full 100.0 % hit-rate parity with the 256 MiB default at a 64 MiB rounded working set (~34–37 MiB/heap vs ~238–241 MiB/heap post-drain floor — R30_6 §0/§8, R29_13 §0) — but NOT beyond that boundary: R31_1 measured the tie BREAKING at 128 MiB/288 MiB bursts, paying the SAME 12.5-percentage-point loss as LowHeadroom |
LargeCachePolicy::Default |
256 MiB (production default, unchanged) |
baseline — the value SeferAlloc::new() already uses |
LargeCachePolicy::DiverseTurnover |
256 MiB (same floor as Default) |
requires the large-cache-extended Cargo feature to do anything — see below |
LargeCachePolicy::DiverseTurnover is a named, explicitly opt-in policy
(task #491) for a workload with genuinely diverse, repeatedly-reused
Large-object sizes (more than the base cache's 8 slots) — it is not a
blanket throughput default and is not in production. It requires
--features large-cache-extended (EXPERIMENTAL, opt-in) to have any effect:
without that feature compiled in, this policy value resolves byte-identical
to Default, because widening the large-segment free-cache from 8 to 40
slots happens at COMPILE time, which a Profile axis value cannot select at
runtime. Choosing this policy means choosing three measured things together,
not a free lunch on any one of them:
- Turnover win: hit rate 33.3 % → 100 % on a workload cycling through
more than 8 repeatedly-reused distinct Large sizes (paired n=20,
t = 127.776, sign 20/20, mean ~385.7 µs/op faster —R31_3§2). - Narrow-working-set cost (real, not free): on a working set that does
NOT need the wider cache, the widened O(40) scan bound measurably,
reproducibly costs more than the base 8-slot scan (real-process A/B at
N=1/2/4:
t = -11.6 / -7.8 / -13.5; scan-isolated microjudge: 5.01× ns/round, 8 vs 40 slots —R31_3§8). Small in absolute per-operation terms (roughly 100–500 ns per alloc+dealloc pair at these N) but real, not noise. - Per-heap, NOT process-wide, RSS retention: the 256 MiB budget bounds
retention to ~248 MiB PER HEAP (vs ~432 MiB/heap unbounded for
Default), scaling LINEARLY with concurrently-active heap count —AllocCoreis owner-only (neitherSendnorSync), so there is no cross-heap coordination. A thread-per-core server running many heaps under this policy multiplies the ceiling: e.g. 32 concurrently-active heaps ≈ 32 × 248 MiB ≈ 7.75 GiB of retained committed memory in the measured workload shape, with nothing bounding the process-wide total —R31_3§4. A process-wide shared budget was considered and deliberately NOT built (it would be a new cross-heap synchronization point on a path that has none today, and its own contention cost would need the same rigor of measurement as every other perf claim in this crate — real added scope with no standing evidence yet to justify building it speculatively). If you run many large-working-set heaps concurrently under this policy, compute your own worst case as(concurrently-active heap count) × (256 MiB)and setLargeCacheConfig::budget_bytesexplicitly per heap if that total is more than your deployment can afford.
Full evidence trail: docs/perf/OPEN_ITEMS.md item 30.
Every number above is cited, not invented — see the linked gate reports for
full methodology, raw logs, and honest scope caveats (every row is a
workload-shape-specific result, not a general "N % faster" claim).
The ~22 % small-pool latency win is measured on a single-threaded,
single-shot 1024 B teardown micro-benchmark — a follow-up check on a
more application-shaped scenario (8 concurrent request-handler threads,
mixed object sizes, continuous multi-round churn) found the win does
NOT reproduce as a statistically distinguishable effect at that scale
(t=-0.119, in the same rough noise band as a same-vs-same control's own
t=-1.039), and the mechanism fires identically in both arms in this
workload (decommit_calls_total = 40 in EVERY launch of BOTH the
default and throughput arms — bit-identical, not merely non-zero), so
this workload does not separate them on that dimension — see
R30_7_SERVER_SHAPED_THROUGHPUT_PROFILE_AB_GATE.md
§0.1. This comparison's own minimum detectable effect is ≈19 % of the
mean (≈131 ms of a ≈697 ms mean, crit(p<0.05)=2.101 × se), so treat the
~22 % figure as workload-shape-specific and this null as UNDERPOWERED —
it cannot rule out a real effect up to roughly 15-19 % at this workload's
scale, not a confirmed absence of one (see the same report's §0.2). A
follow-up sweep of the small-pool cap through 8/16/32 on the SAME
8-thread server-shaped workload
(R31_2) found the
mechanism delta stays ZERO at every cap up to 32 — a genuinely more
decisive null (~4-5 % minimum-detectable-effect) than R30-7's own — so
SmallPoolPolicy::Throughput's win does not currently have ANY
multi-threaded server-shaped evidence behind it; the small-pool win is
binary, not graduated (R27-5 §4.1): a heap either absorbs its peak
segment demand (zero decommits, full win) or it doesn't — there is no
partial win from an intermediate cap.
Dated correction (2026-07-30, Round 30 review response — see
docs/reviews/2026-07-30-r30-full-review.md§4 P1-2/P1-3). This paragraph originally read "even though the underlying pool-overflow mechanism is proven activated in that workload too," citing only thedefaultarm's non-zerodecommit_calls_totalas if that alone validated the null. The corrected text above states the fuller, more important finding instead: the mechanism activates IDENTICALLY in both arms (40 = 40, not merely "non-zero"), so this workload does not distinguish the two configs on the dimensionSmallPoolPolicy::Throughputexists to affect. The paragraph also originally omitted this comparison's own minimum detectable effect, now added.
For RSS-bounded servers, containers, or any deployment where you want
to cap how much memory the allocator holds onto, use
SeferAlloc::with_config(...). Every builder method is const fn,
so the config lives in a static initialiser and is resolved at
compile time — zero runtime overhead, no env vars, no parse errors.
use sefer_alloc::{SeferAlloc, LargeCacheConfig, LargeCacheMode};
const CONFIG: LargeCacheConfig = LargeCacheConfig::new()
.budget_bytes(512 * 1024 * 1024) // 512 MiB hard ceiling per shard
.headroom_bytes(64 * 1024 * 1024) // 64 MiB anti-thrash floor
.decay_interval_ms(200) // 200 ms between decay ticks
.decay_rate_percent(25) // 25 % of excess released per tick
.mode(LargeCacheMode::Lazy); // event-driven (no background thread)
#[global_allocator]
static GLOBAL: SeferAlloc = SeferAlloc::with_config(CONFIG);| Method | Default | What it does |
|---|---|---|
.budget_bytes(N) |
None (unbounded) |
Per-shard hard ceiling on total cached bytes. Set to your container's RSS limit. FIFO eviction fires before admitting a new span that would exceed the limit. 0 ⇒ cache disabled (nothing is cached). |
.headroom_bytes(N) |
256 MiB |
Anti-thrash floor — the decay step does NOT release bytes below this level. Higher headroom = more memory retained between ticks (less aggressive trimming). |
.decay_interval_ms(N) |
1000 ms |
Minimum wall-clock interval between consecutive decay ticks. A tick computes excess = cached − headroom and releases excess × rate back to the OS. |
.decay_rate_percent(N) |
10 % |
Fraction of the excess released per tick, integer percent in [1, 100] (clamped). 10 ⇒ release 10 % per tick (self-damping exponential decay); 100 ⇒ flush all excess in one tick. |
.mode(M) |
Lazy |
Decay trigger. Lazy — the only mode — event-driven: each large alloc/free checks if the interval has elapsed; if so, one decay step runs inline. No background thread, idle process pays nothing. LargeCacheMode is #[non_exhaustive], leaving room for a future background-scavenger mode as a non-breaking addition. |
The model is "allocate fast, release slowly": each tick removes a constant fraction of the current excess, so the cache approaches the headroom aggressively when far above it and gently when near it — self-damping, no oscillation. An idle process pays nothing (the tick is gated by the very next large alloc/free).
SeferAlloc::new() is equivalent to
SeferAlloc::with_config(LargeCacheConfig::DEFAULT). Want to set
values from env / CLI / a config file? Read them in your own code and
pass to the builder — the allocator is intentionally agnostic.
Full reference + a worked tokio server example + how to verify the
config is live: docs/INTEGRATION.md.
sefer-alloc also ships Region<T> — a typed handle store for
slot-storage use cases, independent of the SeferAlloc allocator below.
Region<T> is a thin typed membrane over a third-party
slotmap::SlotMap; it shares no
backing memory, no segment substrate, and no allocator invariants with
SeferAlloc. Generational handles instead of pointers; a stale handle
returns None, never UB. This API needs no features beyond the default:
use sefer_alloc::Region;
let mut region = Region::new();
let a = region.insert("alpha");
let b = region.insert("beta");
assert_eq!(region.get(a), Some(&"alpha"));
region.remove(a);
assert_eq!(region.get(a), None); // stale handle → None, never UB
assert_eq!(region.get(b), Some(&"beta")); // others stay validFor no_std + alloc targets, disable the std feature:
sefer-alloc = { version = "0.3", default-features = false }. The
default build is #![forbid(unsafe_code)] at the top; the only
unsafe comes from slotmap's core wrapped by a thin typed
membrane. No version-scoped audit record for slotmap is tracked by
this project (see crates/region/README.md "## Safety").
SeferAlloc (the #[global_allocator] below) is a separate, OS-backed
segment allocator: SEGMENT-aligned (4 MiB) OS-backed spans, self-hosted
metadata (no Vec / HashSet / std::alloc on any alloc path),
per-thread heaps, non-intrusive cross-thread free through a
per-segment MPSC ring. Region<T> above does not use any of this — it
is backed entirely by slotmap's own storage. See
docs/ARCHITECTURE.md for the 30-minute tour.
Under production, the crate becomes #![deny(unsafe_code)] and every
unsafe lives in eight named confined seams (alloc_core::{os, node} + global::{sefer_alloc, tls_heap, fallback} +
registry::{bootstrap, heap_slot, heap_registry}) — never in the
alloc-path body outside them. Each unsafe block carries a // SAFETY: proof; the
compiler enforces the confinement (a stray unsafe outside a named
seam is a hard error). Complete inventory:
Where unsafe lives.
Two things, both rare in the same crate.
Pure Rust, no C / C++ libraries pulled in. Every comparable allocator in
the ecosystem wraps a C or C++ codebase: mimalloc (C++), jemalloc
(C, via tikv-jemallocator), snmalloc (C++), tcmalloc (C++). The most
common NUMA crates wrap libnuma (C). sefer-alloc is 100 % Rust — it
calls into the OS directly (mmap / VirtualAlloc / mbind etc. — the
same syscalls every allocator uses), but it does not link a single C or
C++ library. The only C dependency anywhere in this repository is the
optional mimalloc dev-dependency used as a baseline in benchmarks; it
is never on a consumer's runtime path. If a Rust-only build matrix matters
to you (cross-compilation, audit perimeter, supply-chain surface),
sefer-alloc is one of the few production-track choices.
Safety claim is structural, not prose. Most Rust allocators have
unsafe smeared across their hot paths and ask auditors to trust the
narrative. sefer-alloc makes the claim compiler-enforced: the default
build is #![forbid(unsafe_code)] at the top;
the moment any allocator feature (experimental, alloc-core and
above) is on, the crate switches to #![deny(unsafe_code)] and the
confined seams lift it with #![allow(unsafe_code)] only inside named
files. The compiler enforces it — a stray unsafe outside a named seam is
a hard error in every configuration. The intelligence (placement, free
lists, page maps, segment registries, bin tables, alloc bitmaps, owner
stamping, recycle policy) lives in pure safe integer arithmetic; the hand
(OS aperture, intrusive free-list r/w, NUMA syscalls, the
unsafe impl GlobalAlloc trait obligation, the TLS-binding raw-pointer
handoff, the heap-slot table) is split across small audited files.
The workspace extraction improved the audit story further: the two
OS-unsafe sub-problems (virtual-memory aperture and NUMA syscalls) are now
independently-publishable crates (aligned-vmem and numa-shim), each with
a single responsibility, a small line count, and their own cargo test. An
auditor who wants to verify the OS-memory unsafe can read those two crates
in isolation — they do not have to navigate the full allocator codebase.
The complete inventory by feature is in Where unsafe lives below.
The performance is honest (numbers from a single Windows dev host with
criterion sample_size(10) — see Performance for the
disclaimer):
- On large alloc/free (
alloc_large/dealloc_large) sefer-alloc is ~12–35× faster thanmimalloc(4/16/64 MiB) via the OPT-E large-segment cache — a 4 MiB cycle is ~59 ns vs mimalloc's ~716 ns, and ~302× faster thanSystem(measured 2026-07-06, see docs/ALLOC_BENCH.md). Preconditions for these headline ratios: same-size reuse inside the decay window with a size factor ≤ 2× (the OPT-E cache holds 8 committed slots) — do not extrapolate them to mixed-size or cold-first-touch workloads, where the cache misses and the numbers regress to OS-round-trip cost. - On single-thread small-class churn (the reuse pattern) it beats
mimallocat 256 B and above on the realistic writing pattern (256 B 1.57×, 1024 B 8.59× faster; 16 B and 64 B are within-noise ties on this run) after the P0–P6 perf arcs, the round4 remediation batch, Round7–9 (directory + lazy-commit + Large fresh-zero-skip), and Round10–13 (class-aware dirty routing promoted toproductionin R13-9) (measured 2026-07-23 vianpm run bench:table, post-Round13, see docs/ALLOC_BENCH.md). The old 256 B churn loss was eliminated in P6 (Э6) — its cause was a stale per-heap key in the block body (not the M2 bitmap), now removed; M2 was strengthened in the process. On cold first-touch of tiny blocks the P3 bump-direct carve removed the tautological round-trip; the current measurement shows a 2.4–2.7× cold gap at 16 B/256 B and parity at 64 B (16 B 2.37×, 256 B 2.71× slower, 64 B 1.00× faster — a noisy single-host run, see the Warm bulk burst table below); the residual is honest per-block page-fault work, called out indocs/ALLOC_BENCH.md. - On realloc the 0.3.0 X-arc (OPT-G in-place Large growth) still wins, at
a more modest margin than first published:
realloc_grow_geometric(64 B→4 MiB) is ~1.8× faster thanmimalloc(~238 µs vs ~431 µs) and ~12× faster thanSystem(a path-activation-oracle re-verification found the chain's final 2 MiB→4 MiB grow exceeds the committed span and copies — see the R34-23 gate,docs/perf/R34_23_REALLOC_AND_VEC_GATE.md);realloc_grow_neighbour_pressure(formerlyrealloc_in_place_unfavorable; renamed for honesty — after OPT-G the neighbours no longer block sefer's in-place growth) is confirmed and even better than first published: ~3,350× faster (~400 ns vs ~1.34 ms) — every Large growth step that fits the committed 4 MiB span is a header update returning the same pointer. - On MT cross-thread (
malloc_macrolarson/mstress) it is competitive withmimalloc, leading at T≥2 (historical 0.2.0 shape).
The verification stack is also honest: 111 integration test files, 11 loom models, proptest differential against a reference model, miri with strict-provenance, ThreadSanitizer (×3 clean runs), Valgrind memcheck (clean), aarch64 (qemu), libFuzzer, soak / RSS / tokio-burn-in harnesses. The Verification evidence section spells out what each one actually proves.
Region<T>/Handle<T> and SeferAlloc share NO backing memory — corrected
2026-08-09 per the static release audit's F5
(docs/reviews/2026-08-09-sefer-region-static-release-audit.md); an earlier
version of this section drew both as reaching into the same HeapCore/
AllocCore/SegmentTable box via a shared Cartographer, which was false.
┌───────────────────┐ ┌────────────────────────┐
│ Region<T> │ │ SeferAlloc │
│ Handle<T> │ │ #[global_allocator] │
│ (safe membrane, │ │ (unsafe trait impl) │
│ wraps slotmap, │ │ │
│ own storage) │ │ │
└───────────────────┘ └──────────┬─────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ HeapCore (registry + stamp + xthread routing) │
│ AllocCore (single-thread alloc/dealloc/realloc) │
│ SegmentTable + page_map + bin_table + alloc_bitmap │
│ RemoteFreeRing (per-segment MPSC, non-intrusive) │
│ │
│ Hand (confined-unsafe seams): │
│ os:: mmap/VirtualAlloc, decommit/recommit │
│ node:: intrusive free-list pointer r/w │
│ numa:: mbind / VirtualAllocExNuma (opt-in) │
└─────────────────────────────────────────────────────┘
Only SeferAlloc reaches into the OS-backed segment substrate — via the safe
Cartographer plus the documented unsafe impl GlobalAlloc aperture. Region<T>
is a separate, independent typed API entirely backed by third-party
slotmap::SlotMap; it never touches the Cartographer, the segment substrate,
or the Hand. The Hand is always the same three modules within SeferAlloc —
there is no second copy of mmap somewhere else in the crate, and Region<T>
has no unsafe of its own at all (#![forbid(unsafe_code)]).
| Organ | Responsibility | Safety |
|---|---|---|
| Cartographer | All placement / free-list / page-map / segment-registry / bin-table / alloc-bitmap / decommit-policy / NUMA-preference logic. Pure integer arithmetic over indices and offsets. Never touches raw memory. | safe |
| Membrane | The typed APIs (Handle<T>, Region<T>, AllocCore::alloc, SeferAlloc::alloc). Total — cannot express UB at the type level. |
safe |
| Hand | The confined-unsafe seams that touch raw memory. Each is a single audited file; every unsafe { ... } block carries a // SAFETY: proof. |
confined |
The deliberate inversion: all the intelligence lives in the safe Cartographer, so the Hand stays mechanical and small. Verification is over a total Membrane and an integer algorithm, not a tangle of pointer math.
The workspace extracted ten building blocks. Each is a real crates.io crate
someone can cargo add on its own — they are not internal implementation
details but independently useful libraries:
sefer-alloc
├── sefer-region (crates/region) — typed handle store (Handle<T>/Region<T>)
├── aligned-vmem (crates/vmem) — OS virtual-memory aperture (feature: alloc-core)
├── numa-shim (crates/numa) — NUMA detection + binding (feature: numa-aware)
├── malloc-bench-rs (crates/malloc-bench) — portable GlobalAlloc bench harness (standalone, dev-only)
├── racy-ptr-cell (crates/racy-ptr-cell) — lazy CAS-published pointer cell (feature: alloc-core)
├── size-classes (crates/size-classes) — const-built size-class tables + lookup (feature: alloc-core)
├── tagged-index-stack (crates/tagged-index-stack) — ABA-tagged free-index stack (feature: alloc-global)
├── globalalloc-model (crates/globalalloc-model) — differential op-stream test harness (standalone, dev-only)
├── proc-memstat (crates/proc-memstat) — same-instant RSS / commit self-probe (standalone, dev-only)
└── proc-probe (crates/proc-probe) — RESULT key=value stdout protocol (standalone, dev-only)
malloc-bench-rs, globalalloc-model, proc-memstat, and proc-probe are
not in sefer-alloc's runtime dependency tree — they are dev-only / example
infra. The other six are pulled in under the feature gates noted above
(alloc-core, alloc-global, numa-aware).
(A former eleventh crate, ring-mpsc — a standalone bounded MPSC index ring
- DirtyRouter — was removed from the workspace: it had zero production
consumers, since the in-tree
RemoteFreeRing/HeapOverflowswap it was extracted for was investigated and found NO-GO, seedocs/crate_extraction/CRATE_P4_FOLLOWUP_NOGO.md.)
Per-crate status:
| crate | crates.io | docs.rs |
|---|---|---|
sefer-region |
||
aligned-vmem |
||
numa-shim |
||
malloc-bench-rs |
||
racy-ptr-cell |
||
size-classes |
||
tagged-index-stack |
||
globalalloc-model |
||
proc-memstat |
||
proc-probe |
The extraction improved the audit story, not just reorganised code.
An auditor who wants to verify the OS-memory unsafe no longer has to read
through a large general-purpose allocator crate — they can audit aligned-vmem
(~400 lines, sole purpose: OS aperture) and numa-shim (~300 lines, sole
purpose: NUMA syscalls) in complete isolation. Each has one responsibility,
one reason to have unsafe, and its own cargo test.
Source of truth: grep -rnE '^\s*#!?\[allow\(unsafe_code\)\]' src/ crates/
— two tiers in one command: #![...] matches are module-level seams
(tier 1, listed below); #[...] matches are item-scoped unsafe fn
declarations and their internal call-site blocks (tier 2, listed in the
table after the seam table). Both are comment-proof: ^\s*#!?\[ requires
the line to begin with the attribute, not a // prefix.
External publishable crates (each independently auditable):
| Crate | Path | Unsafe story |
|---|---|---|
aligned-vmem |
crates/vmem/ |
#![allow(unsafe_code)] — entire crate IS the OS aperture (mmap/VirtualAlloc/decommit); single responsibility, small, audit in isolation |
numa-shim |
crates/numa/ |
#![allow(unsafe_code)] — entire crate IS the NUMA syscall shim (mbind/VirtualAllocExNuma); single responsibility, small, audit in isolation |
malloc-bench-rs |
crates/malloc-bench/ |
#![allow(unsafe_code)] — confined to alloc_block/free_block/drain_mailbox helpers; every block carries // SAFETY: |
racy-ptr-cell |
crates/racy-ptr-cell/ |
#![allow(unsafe_code)] — single documented reason: unsafe impl Send/Sync for the AtomicPtr-backed cell + NonNull::new_unchecked; every site has # Safety / // SAFETY: |
globalalloc-model |
crates/globalalloc-model/ |
#![allow(unsafe_code)] — single documented reason: the unsafe trait RawAllocator (its impls must return valid pointers for the requested layout); every impl + call carries // SAFETY: |
proc-memstat |
crates/proc-memstat/ |
#![allow(unsafe_code)] — entire crate IS the OS-FFI self-probe (Windows K32GetProcessMemoryInfo, macOS task_info, Linux /proc); every block carries // SAFETY: |
sefer-region |
crates/region/ |
#![forbid(unsafe_code)] — zero own unsafe; slotmap's core owns the generational layout (no version-scoped audit record for slotmap is tracked by this project — see crates/region/README.md "## Safety") |
size-classes |
crates/size-classes/ |
#![forbid(unsafe_code)] — const-evaluated, no_std, zero-dependency; no raw pointers anywhere |
tagged-index-stack |
crates/tagged-index-stack/ |
#![forbid(unsafe_code)] — lock-free via a single packed AtomicUsize head word; ABA tag in the high bits, no raw-pointer derefs |
proc-probe |
crates/proc-probe/ |
#![forbid(unsafe_code)] — pure protocol + re-export crate; the OS FFI stays in proc-memstat |
Internal sefer-alloc seams — tier 1 (module-level) — any unsafe token
not covered by a tier-1 module OR a tier-2 item-level allow (see below) is a
hard compile error in every configuration:
| Module | What it owns | Loaded under |
|---|---|---|
src/alloc_core/os.rs |
Thin interop wrapper around aligned-vmem; delegates SEGMENT-aligned reservation and decommit/recommit |
alloc-core |
src/alloc_core/node.rs |
Intrusive free-list node r/w through raw pointers (the generalised "hand" discipline); also release_segment thin wrapper |
alloc-core |
src/alloc_core/numa.rs |
Thin interop wrapper around numa-shim; delegates NUMA-node query and segment binding |
numa-aware |
src/alloc_core/dirty_by_class.rs |
The lazily-materialised per-(segment, class) dirty-bit sidecar (PerClassDirty); dereferences the RacyPtrCell-published sidecar pointer |
class-aware-dirty |
src/alloc_core/large_cache_extended.rs |
The lazily-materialised large-cache extension sidecar (owner-only, no RacyPtrCell — no cross-thread publisher); reserves via alloc_core::sidecar::reserve, dereferences via sidecar::deref[_mut] |
large-cache-extended |
src/alloc_core/sidecar.rs |
R14-9 (task #294): the shared owner-only lazily-materialised sidecar primitive (reserve / reserve_zeroed_with / deref / deref_mut) used by os.rs's SegmentDirectory reservation and large_cache_extended.rs's LargeCacheExtension reservation |
alloc-core |
src/global/sefer_alloc.rs |
The unsafe impl GlobalAlloc alloc-face seam — the trait obligation + pointer handoff to the HeapCore (the registry-resident per-thread heap) |
alloc-global |
src/global/tls_heap.rs |
Raw-pointer TLS binding + AbandonGuard seam — the *mut HeapCore handoff under the single-writer invariant; unsafe fn recycle from the guard's drop (whole-slot reuse); and the bench-internals-gated unsafe fn dbg_restore_local_for_test test hook (R29-7, task #438) — covered by this module's tier-1 allow, with no separate item-level allow (so it adds no tier-2 site). |
alloc-global |
src/global/fallback.rs |
The primordial fallback heap — static mut MaybeUninit<HeapCore> + atomic-init state-machine + spinlock-guarded &mut handout (so the global allocator survives reentrant / early-init / teardown access) |
alloc-global |
src/registry/bootstrap.rs |
The primordial-segment carve / SegmentTable bootstrap seam — raw-pointer footprint carving of the metadata region under the atomic single-writer bootstrap protocol. | alloc-global |
src/registry/heap_slot.rs |
Sync/Send impls on HeapSlot under the atomic single-writer protocol; the slot's UnsafeCell hand-off |
alloc-global |
src/registry/heap_registry.rs |
The global heap slot-table — the *mut HeapCore pointer handoff out of a slot, used by every cross-thread routing decision |
alloc-global |
src/concurrent/hand.rs |
The legacy epoch-tier AtomicSlot<T> (older experimental concurrent tier; superseded by alloc-xthread for the global allocator path; deprecated) |
experimental |
Under the recommended production feature
(`alloc-global + alloc-xthread + alloc-decommit + fastbin + alloc-segment-directory
- primordial-lazy-commit + class-aware-dirty
) the active internal seams are **ten** —alloc_core::{os, node, sidecar, dirty_by_class}plusglobal::{sefer_alloc, tls_heap, fallback}plusregistry::{bootstrap, heap_slot, heap_registry}.alloc_core::sidecar(R14-9, task #294) is active becausealloc-globalpulls inalloc-core;alloc_core::dirty_by_classis active becauseproductionitself enablesclass-aware-dirty(R13-9, task #279).alloc-xthread,alloc-decommit,fastbin, andprimordial-lazy-committhemselves do **not** open newunsafe` seams — they extend existing safe code paths.
numa-aware adds one more internal seam (alloc_core::numa), which in turn
delegates to the independently-auditable numa-shim crate. experimental
opens the older research-tier concurrent seam (now deprecated); the production
build does not pull it in.
Internal sefer-alloc item-scoped allows — tier 2 (task #101 / R4-9).
Each is a single #[allow(unsafe_code)] on an unsafe fn declaration (or on
the unsafe {} block at its internal call site) inside a file that is
otherwise safe code. Unlike tier 1 (where unsafe is permitted anywhere in
the module), tier 2 confines unsafe to one function/block boundary with its
own # Safety doc — the contract (validity/size/alignment/lifetime/exclusivity
of a caller-supplied pointer) cannot be expressed in the type system and
cannot be checked at runtime, so it lives in the signature, not in prose.
| File | Sites | What they cover |
|---|---|---|
src/alloc_core/alloc_core.rs |
3 | dealloc / realloc — unsafe fn boundaries (caller-pointer contract); Drop::drop — internal call-site block into deref_large_cache_extension_mut (R14-1, task #286) |
src/alloc_core/alloc_core_core_diag.rs |
5 | dbg_stamp_segment_id / dbg_stamp_kind_byte (raw metadata write) + dbg_unregister / dbg_recycle — unsafe fn boundaries; dbg_rebuild_directory — internal call-site block into sidecar::deref_mut (R14-9, task #294) |
src/alloc_core/alloc_core_large_cache.rs |
5 | Internal call-site blocks into deref_large_cache_extension[_mut] in large_cache_slot_get / large_cache_slot_take / large_cache_find_free_slot / large_cache_slot_set / dbg_large_cache_extended_slot_sizes (R14-1, task #286 — the sidecar deref functions became unsafe fn item boundaries) |
src/alloc_core/alloc_core_small.rs |
6 | Internal call-site blocks: bump_gen (in pop_free) / init_gen_table_in_place (in reserve_small_segment), hardened path; maybe_materialize_directory / directory / directory_mut — internal call-site blocks into sidecar::deref[_mut] (R14-9, task #294); find_segment_with_free_impl — calls the unsafe fns os::read_directory_node_bucket / os::read_directory_class_words (R17-2, task #319) |
src/alloc_core/alloc_core_small_diag.rs |
5 | dbg_corrupt_freelist_head_next / dbg_drain_freelist_batch / dbg_alloc_bitmap_bytes_for / dbg_magazine_bitmap_bytes_for / dbg_payload_start_for — unsafe fn declarations |
src/alloc_core/alloc_core_small_magazine.rs |
1 | flush_class — unsafe fn boundary (caller-pointer contract) |
src/alloc_core/alloc_core_small_pool.rs |
6 | dbg_force_decommit_retain_for (R29-8, task #439, gated bench-internals) — unsafe fn boundary: decommits a caller-pointer's segment payload via decommit_empty_segment_impl with NO live_count check, so the live_count == 0 precondition lives in the # Safety contract, not the body; plus the R29-3 (task #434) segment-lifecycle-decomposition unsafe fn boundary dbg_decomp_decommit_payload (decommit a caller-supplied segment base's payload) — gated bench-internals, forwarding its # Safety contract verbatim to the HeapCore-level delegation of the same name in heap_core_diag.rs; plus the R31-6 (task #469) sibling unsafe fn boundary dbg_decomp_recommit_payload (recommit a caller-supplied segment base's payload — a real VirtualAlloc(MEM_COMMIT) on Windows, a documented no-op on Unix/miri — the counterpart examples/r29_3_decomposition_gate.rs's Measurement B re-fault loop was missing, which crashed that example on Windows); plus dbg_decomp_release — unsafe fn again as of R31-15 (task #486): R31-4's move-consuming ReservedSmallSegment handle closed unforgeability and double-release but NOT owner-binding (a handle reserved on one AllocCore could be released on a DIFFERENT AllocCore, both safe API calls, corrupting the wrong heap's pool/directory/SegmentTable state — a CONFIRMED P0 soundness defect), so the # Safety contract ("handle reserved on THIS SAME AllocCore, still live/unreleased") is back, layered with a release-build (non-debug_assert!) owner-id check as defence-in-depth (see src/alloc_core/reserved_small_segment.rs's module doc, "Owner-binding" section); plus two task #504 (F11 step 2) unsafe fn boundaries, dbg_decomp_win_commit_only (commits a caller-supplied segment base's [PAGE, SEGMENT) range — documented raw-pointer precondition) and dbg_decomp_win_release_only (releases a caller-supplied (reservation_ptr, reservation_len) pair — same double-release/wrong-reservation hazard class as dbg_decomp_release), both gated bench-internals, isolating VirtualAlloc(MEM_RESERVE) from VirtualAlloc(MEM_COMMIT) for the Windows-native decomposition gate. |
src/alloc_core/alloc_core_small_reclaim.rs |
3 | Internal gen_at call-site blocks (dealloc_routing + hardened pack_entry_hardened) + dbg_push_to_ring declaration |
src/alloc_core/bootstrap.rs |
1 | Internal call-site block for init_gen_table_in_place (primordial carve, hardened path) |
src/alloc_core/remote_free_ring.rs |
2 | over_test_buffer / init_test_buffer — raw R/W over a caller buffer |
src/alloc_core/segment_directory.rs |
2 | init_node_ids_raw (numa-aware and non-numa-aware variants) — unsafe fn boundary; writes the node_ids repair through core::ptr::addr_of_mut! without ever materialising a &mut SegmentDirectory over the not-yet-fully-valid sidecar (R17-1, task #318 — the reserve_zeroed_with fixup closure) |
src/alloc_core/segment_header_gen_table.rs |
3 | gen_at / bump_gen / init_gen_table_in_place — atomic view + write by caller base |
src/registry/heap_core_alloc.rs |
6 | Internal bump_gen call-site blocks in alloc / refill_magazine_slow / alloc_batch / alloc_small_zeroed_via_magazine / refill_magazine_slow_virgin (R13-3, virgin-zero-skip magazine plumbing) (hardened path) |
src/registry/heap_core_dealloc_batch.rs |
7 | dealloc_batch / dealloc_batch_small — unsafe fn boundaries (caller-pointer contract) + internal call-site blocks into scalar dealloc / AllocCore::flush_class (R11-4) |
src/registry/heap_core_diag.rs |
10 | dbg_push_to_ring / dbg_push_coarse_only_entry (R13-1, gated bench-internals) / dbg_dealloc_own_thread_with_base (R23-3, task #372, gated bench-internals) / dbg_flush_class_only (R28-1, task #430, gated bench-internals) / dbg_clear_magazine_on_hit (R29-10, task #441, gated bench-internals) — unsafe fn boundaries (delegation to the unsafe producer / documented raw-pointer contract) — plus the R29-3 dbg_decomp_decommit_payload/dbg_decomp_recommit_payload (R31-6, task #469) delegations (gated bench-internals); see the R24-6/R25-1 note below the table. (dbg_decomp_release's delegation is unsafe fn again as of R31-15/task #486 — forwards the identical # Safety contract; see the alloc_core_small_pool.rs row above for why.) Plus two task #504 (F11 step 2) delegations, dbg_decomp_win_commit_only/dbg_decomp_win_release_only (gated bench-internals), forwarding their identical # Safety contracts from the alloc_core_small_pool.rs originals. |
src/registry/heap_core_free.rs |
6 | dealloc-routing unsafe fn boundaries (caller-pointer contract) + internal call-site blocks into AllocCore::dealloc / AllocCore::flush_class + R17-4 Large-kind routing block in dealloc_own_thread_with_base (R32-3/task #494: realloc's move leg and try_promote_to_large now call the safe dealloc_own_thread[_with_base] bodies directly with their already-proven base instead of routing back through HeapCore::dealloc, which cost this file its one try_promote_to_large item-scoped site — the other, realloc's move leg, was never separately counted here: it was an inner unsafe {} block already covered by realloc's own unsafe fn boundary) |
src/registry/heap_core_tcache.rs |
1 | Internal call-site block for AllocCore::flush_class |
src/registry/heap_core_xthread.rs |
1 | Internal gen_at call-site block in dealloc_foreign_routing (hardened pack_entry_hardened path) |
That's the full list (both tiers): 19 tier-1 module-level seams (13 in
src/, 6 in crates/) plus 73 tier-2 item-scoped allows across 18
files. Everywhere else in the crate is forbidden / denied unsafe; an
unsafe token not covered by a tier-1 module or a tier-2 item-level allow is
a hard compile error in every configuration.
R24-6 (task #384) / R25-1 (task #395) / R28-1 (task #430) / R29-10 (task #441) note —
measurement-only unsafe fn dbg_* hooks in heap_core_diag.rs. All seven
of that file's unsafe fn entries above are #[doc(hidden)], exist ONLY to
let this crate's own benches/perf_gate_iai.rs / tests/ harnesses isolate
a specific perf-gate sub-cost or reconstruct a hard-to-reach test scenario,
and are never called from any production alloc path — none of the four
changes what SeferAlloc::alloc/dealloc actually does. They are not
equivalent on one axis a prior review flagged as worth distinguishing
precisely: whether the hook's own #[cfg] gate happens to be fully
satisfied by plain --features production alone.
dbg_dealloc_own_thread_with_base(R23-3, task #372) anddbg_push_coarse_only_entry(R13-1, task #271) were both reachable from a plainproductionbuild (their prior gates —alloc-global + fastbinandalloc-xthread + alloc-segment-directory + class-aware-dirtyrespectively — are each a subset ofproduction's feature list). Each has exactly ONE caller in the whole tree (benches/perf_gate_iai.rsfor the first,tests/class_aware_dirty_oom_latch.rsfor the second), so both are now additionally gated behind thebench-internalsfeature (see the feature table above) — a plain--features productionbuild no longer compiles either in.dbg_flush_class_only(R28-1, task #430) — added to isolateAllocCore::flush_class's own standalone Ir cost inside the magazine-overflow free path (seedocs/perf/ R28_1_FLUSH_CLASS_ISOLATION_GATE.md) — was madepub unsafe fn+bench-internals-gated (alloc-global + fastbin + bench-internals) from the moment it was created, per CLAUDE.md's benchmark-hook rule (the rule this exact R25-1 fix above prompted): it derives aflush_classcall from a caller-supplied raw-pointer slice with zero validation beyondflush_class's own per-block M2 guards, so it was never a candidate for a safepub fn. One caller (benches/perf_gate_iai.rs'sdealloc_flush_class_only_16barm).dbg_clear_magazine_on_hit(R29-10, task #441) — added to isolate the ALLOC-side magazine-hitclear_magazineblock's standalone Ir cost (seedocs/perf/R29_10_ALLOC_HIT_CLEAR_MAGAZINE_ISOLATION_GATE.md) — was madepub unsafe fn+bench-internals-gated (alloc-global + fastbin + bench-internals) from the moment it was created, per CLAUDE.md's benchmark-hook rule. Unlike its siblings it delegates to no single callable production function: it inlines the three-line production magazine-hit clear block byte-for-byte, but the COMBINATION of safe primitives derives an unchecked metadata write from a caller raw pointer (the exact R25-1 shape), so it carries a# Safetycontract onissuedrather than being a safepub fn. One caller (benches/perf_gate_iai.rs'salloc_clear_magazine_only_16barm).- (Historical — R27-10/task #428) a fourth hook,
dbg_overflow_bitmap_clear_pass(R24-2, task #380), once lived in this file. It was additionally a safepub fnthat derived a segment base from an unvalidated caller pointer and wrote allocator metadata through it — a genuine safe-code-reachable soundness hole R25-1 (task #395) fixed by making itpub unsafe fn+bench-internals-gated. The optimization region it measured (the magazine-overflow bitmap-clear pass) then racked up four consecutive NO-GOs (R24-3/R24-4/R25-3/R26-7), so R27-10 removed the hook and its single bench arm outright rather than keep anunsafe fnwhose only caller left a temporary magazine-state invariant broken on return (seedocs/reviews/2026-07-28-r26-readonly-review.mdP2). Git history preserves the reproducer. dbg_push_to_ring(HeapCore/AllocCore, R6-MS-4) is deliberately left as-is: itsalloc-xthreadgate is also aproductionsubset, but unlike the two above it is called from ~20 files across the entirealloc-xthreadtest suite (predates R23-3 by many rounds — the oldest entry in this file's tier-2 list). Moving it behind a new feature would touch every one of those files' gates for a documentation-precision concern, not a new regression — disproportionate for this round. This note is the resolution:dbg_push_to_ringis measurement/test-only and excluded from any "changes production behavior" claim, exactly like its siblings, even though it remains textually reachable under--features production(the#[allow(unsafe_code)]grep this section's count is built from is feature-gate-blind by construction, so gating a hook behind a new feature would not change the 62 figure above regardless — only whether it compiles into a given build).
Each segment is SEGMENT = 4 MiB of OS-backed, SEGMENT-aligned virtual
memory. The first metadata page hosts: a SegmentHeader (kind, magic, bump
cursor, owner state, NUMA node id, live-count); a page_map (one byte per
page, per-page descriptor); a BinTable (per-size-class free-list heads);
an AllocBitmap (1 bit per MIN_BLOCK slot, the O(1) double-free guard); a
RemoteFreeRing (the per-segment MPSC ring for cross-thread frees).
A self-hosted SegmentTable carved from the primordial segment indexes
every live segment by base pointer. It is append-only with NULL-slot
recycle under alloc-decommit (see docs/ARCHITECTURE.md §3)
and from 0.1.0 ships an open-addressing hash side-index for O(1)
contains_base at DBMS scale. There is no Vec / HashSet / std::alloc
on any alloc path — M5 reentrancy-freedom is upheld structurally.
A thread allocates from its own HeapCore's per-class magazine (tcache) via a
single pointer read; deallocates with a single pointer write through the node
seam. No lock, no atomic on the common case. Slow path: refill REFILL_BATCH = 31 blocks from the current segment (the constant is measured — see
commit 81fec54, bigger refills hurt locality).
Cross-thread free (opt-in alloc-xthread) does not dereference the
block: the freer pushes (offset | class) into the segment's
RemoteFreeRing (whose memory lives in metadata pages that are never
decommitted), and the owner reclaims lazily on its alloc-slow-path. The
freer stamps the class because the page_map is unreliable for mixed-class
pages produced by a shared bump cursor — the §13 race investigation
(docs/RACE_DRAIN_RECLAIM.md) traced this
through four iterations of "peeling" before identifying the true root.
When a small segment's live-count drops to zero AND it is not the current
carve target, payload pages are returned to the OS (madvise MADV_DONTNEED
/ VirtualFree MEM_DECOMMIT); the segment is reset to a clean blank,
re-committed on first reuse. No epoch reclamation (M11) is needed —
the four-point safety argument is recorded in
docs/PHASE35_DECOMMIT_DESIGN.md §1:
Variant-2 cross-thread free dissolves the only reason epoch was ever
considered.
OPT-E adds a small fixed-slot cache (LARGE_CACHE_SLOTS = 8 slots, no
fixed per-span size cap — governed instead by the configurable
LargeCacheConfig::budget_bytes, default unbounded) inside each AllocCore
that holds freed large-segment OS reservations and reuses them on the next
alloc_large of comparable size — without decommitting and
re-committing pages, so the hit path is a register + header rewrite
(~42 ns at 4 MiB instead of 254 µs).
The same hot path stamps SegmentHeader::node_id to the current thread's
NUMA node when numa-aware is on, and find_segment_with_free prefers
local-node segments with foreign-node fallback. The OS syscalls live in
src/alloc_core/numa.rs (Linux mbind via
syscall(2), no libnuma dependency; Windows VirtualAllocExNuma;
macOS / miri no-op). Honest caveat: a QEMU -numa topology verifies
correctness, not latency-asymmetry — that needs real 2-socket hardware
(AWS *.metal, Graviton, dual-socket dev box). See
docs/PHASE_NUMA_DESIGN.md.
sefer-alloc 0.3.x — small-class churn/cold tables re-measured 2026-07-23
post-Round13 (class-aware dirty routing promoted to production in R13-9,
on top of directory + lazy-commit + Large fresh-zero-skip from Round7–9);
large-alloc and realloc tables 2026-07-06 post-X-arc (criterion benches on a single Windows
dev host, SeferAlloc
called directly through its GlobalAlloc impl — apples-to-apples — vs
mimalloc 0.1 vs System). Per CLAUDE.md the project's bench profile is the quick
one — sample_size(10), short warm-up — and the host is noisy (±15–20 %), so
these are honest comparative measurements, not a rigorous statistical
suite. Trust the relative shape and the order of magnitude, not the exact
percentages; the rigorous, deterministic gate is the instruction-count
perf_gate_iai bench (#127/#128/#144) on Linux CI. Source-of-truth tables +
per-bench commentary live in
docs/ALLOC_BENCH.md; re-run
cargo bench --features production for your own numbers. Lower is better
(latency).
Normativity note (R14-10/task #295): every table below reports absolute
ns/opfor THREE allocators (SeferAlloc/mimalloc/System) measured back-to-back on one shared, uncontrolled Windows dev host. Independent reruns of the same tree have shown the absolute numbers for all three allocators drift together by up to ±60% between runs (host load, thermal/power-plan state, background processes — not a code change in any of the three allocators). Thevs mimalloc/vs Systemratio columns are the normative signal — they cancel most of that shared host-noise drift because all three allocators are measured in the same run under the same conditions. Do not read an absolutens/opcell across two different table refreshes (or against a number from a different day) as a regression/improvement signal by itself. The one source of normative ABSOLUTE numbers in this document is the iai gate (deterministic instruction counts, Valgrind-based, immune to host wall-clock noise) — seedocs/perf/IAI_BASELINE.mdand the "Honest verdict" section below.
A same-harness three-way run (published 0.2.1 vs the tree immediately
before the round6 wave vs current HEAD) separates the pre-round6 gains
from the round6 wave's own effect — full tables, methodology and caveats in
docs/perf/R6_CROSS_VERSION_BENCH.md.
Headline (vs-mimalloc ratio, host-drift-normalised):
- All the large wall-clock wins landed between 0.2.1 and pre-round6, not in
the wave:
realloc_growwent from copy-and-free (ms-scale, ~7× slower than mimalloc at 0.2.1) to in-place (µs-scale, ~30–1000× faster) via OPT-G; 256 B churn flipped from ~1.25× slower to ~1.6× faster and 1024 B churn rose to ~9–10× faster via Э6 — all before345fa9b. - The round6 wave itself is flat-to-slightly-better on throughput and
regresses no family beyond host noise (probable modest wins on 4 MiB
large-alloc/free and the 1024 B teardown/decommit diagnostic). This is by
design: round6 P0 work targeted OS commit charge (≈7.4× lower for the
first heap), cross-thread-free tail latency, and the SMALL_MAX
fragmentation cliff (opt-in
medium-classes) — axesbench:tabledoes not measure (see the R6-OPT-A judges). The wave delivered its targeted wins without costing throughput.
The 0.2.1 column carries the current harness ported onto the release tag, kept
as the local bench/0.2.1 branch so 0.2.1 stays re-measurable
(git worktree add ../sa-021 bench/0.2.1 && cd ../sa-021 && npm run bench:table).
A fresh same-harness run of published 0.2.1 vs current 0.3.0 (49046ef,
all Round7 landed), mimalloc/System as reference — full 7-group tables + the two
ratio columns (vs 0.2.1, vs mimalloc) in
docs/perf/R7_CROSS_VERSION_BENCH.md.
Headline (ns/op, lower is better; 0.3.0's improvement over 0.2.1):
| Workload (1024 B) | 0.2.1 | 0.3.0 | vs 0.2.1 | vs mimalloc |
|---|---|---|---|---|
| churn (reuse) | 45.4 | 21.4 | 2.12× faster | 10.7× faster |
| churn + write | 38.6 | 23.2 | 1.66× faster | 8.6× faster |
segment_decommit_cycle (ns/batch) |
405 980 | 1 277 | ~318× faster | 4.5× faster |
working_set_cycle (ns/batch) |
1 031 300 | 256 100 | 4.03× faster | — |
- The ~318× decommit-cycle win comes from retaining emptied segments instead
of releasing them to the OS: the Mechanism-2 small-segment hysteresis pool
(default 4 seg / 16 MiB, presets in
R7_POOL_CAP_PRESETS.md) + the OPT-E large-segment cache turn an empty→reuse cycle from aVirtualAlloc+MEM_COMMITsyscall storm into a cheap pool-pop over the existing reservation. - Separately, the chunked Registry (R6-OPT-P0-2,
e4b3e1d+8dc6fe8) cut the Windows first-alloc commit charge from ≈128 MiB to ≈6 MiB (~21.7×) — replacing a monolithic[HeapSlot; 4096]inline array (committed whole on first alloc) with 64 lazily-materialised 64-slot chunks. - 0.3.0 loses to mimalloc only on the cold path at small sizes (16–64 B, ~1.9–2.7× slower); churn at 64 B+ is a clean win (up to 10.7×).
alloc(N) + free round-trip served by the OPT-E large-cache
(alloc-decommit): the freed segment is parked in the LARGE_CACHE_SLOTS = 8
cache with pages kept committed, so the next alloc of a compatible size
returns it with no OS round-trip. This is the crate's flagship strength.
| Workload | SeferAlloc | mimalloc | System | vs mimalloc | vs System |
|---|---|---|---|---|---|
alloc(4 MiB) + free |
~58.6 ns | ~716 ns | ~17.7 µs | ~12.2× faster | ~302× faster |
alloc(16 MiB) + free |
~61.9 ns | ~1.13 µs | ~17.7 µs | ~13.5× faster | ~237× faster |
alloc(64 MiB) + free |
~60.8 ns | ~2.58 µs | ~18.8 µs | ~33× faster | ~258× faster |
(measured 2026-07-06 post-X-arc, see docs/ALLOC_BENCH.md;
the 16/64 MiB mimalloc/System absolute columns were not re-recorded in the
post-X-arc section — only SeferAlloc ns and the vs mimalloc/vs System
ratios were — so those two cells are carried from the pre-X-arc run.)
The cache is byte-budget'd (per-shard, default unbounded — set via
LargeCacheConfig::new().budget_bytes(n) in SeferAlloc::with_config to cap
it, where budget_bytes(0) disables caching), with lazy 10 %/sec exponential
decay back to live + headroom. There is no per-span size cap — a 30 GB
segment on a 64 GB box is cacheable now. The 0.3.0 span_usable fix (#134)
keeps this win without unbounded RSS amplification across cache reuse.
| Bench | SeferAlloc | mimalloc | System | Notes |
|---|---|---|---|---|
realloc_grow_geometric (64 B→4 MiB) |
~238 µs | ~431 µs | ~2.86 ms | ~1.8× faster than mimalloc; ~12× faster than System |
realloc_grow_neighbour_pressure |
~400 ns | ~1.34 ms | ~7.55 ms | ~3,350× faster than mimalloc; ~18,900× faster than System |
(Re-measured 2026-08-04 by the R34-23 gate
(docs/perf/R34_23_REALLOC_AND_VEC_GATE.md):
the realloc_grow_geometric row was previously published as "~9.7 µs / ~40×
faster than mimalloc", but a path-activation-oracle-equipped re-verification
found the 64 B → 4 MiB ×2 chain's final grow (2 MiB → 4 MiB) exceeds the Large
segment's committed span_usable by the header-offset bytes, forcing a 2 MiB
copy that dominates the ~238 µs timing — the published 9.7 µs was physically
impossible given that copy. The realloc_grow_neighbour_pressure row is
confirmed and even improved: sefer grows in-place (100% in-place oracle) while
mimalloc/System copy every step.)
(realloc_grow_neighbour_pressure was renamed from realloc_in_place_unfavorable
in the 2026-07-09 review: after OPT-G the live neighbours no longer prevent
sefer's in-place Large growth, so the bench measures sefer's header-update path
against the copy-and-free path mimalloc/System still take — not an adversarial
in-place case for sefer.)
(OPT-G grows a Large block in place whenever the new size fits the already-committed 4 MiB span — a header update returning the same pointer, zero copy. For the geometric chain this holds for the 256 KiB → 512 KiB → 1 MiB → 2 MiB grows; only the final 2 → 4 MiB grow exceeds the span and copies.)
Two patterns. Churn (steady-state over a live working set — each iteration
frees a pseudo-random slot and allocates a replacement) is the common shape of
real workloads and what the fastbin per-thread magazine
(docs/FASTBIN_DESIGN.md) targets. Warm bulk
burst (alloc-1024-then-free-1024; no reuse within one iteration's allocation
half, but warm across criterion iterations — labelled "Cold direct" in older
runs) is the historically documented worst-case where mimalloc's cheaper
first-touch carve led at tiny sizes.
The P0–P6 perf arc (below) attacked exactly these two fronts. On cold tiny
blocks the P3 bump-direct batched carve (Э1) removed the tautological
carve → BinTable → pop round-trip that made every virgin block pay ~40
metadata-touch instructions: it roughly halved the cold gap at P3 time
(16 B 2.6× → 1.60× slower, 64 B 2.0× → 1.15× slower) and brought cold 256 B
to parity at P3 time — though the current re-measurement puts the cold
tiny gap at 2.67× / 1.97× slower and cold 256 B at 1.52× slower on this
noisy host (see the Warm bulk burst column below and the iai gate for the
deterministic signal). On churn the one-branch resolver (Э2) + classify-once
(Э4) + lock-free hit counter (Э5) widened the tiny-block lead (16 B 1.26× →
1.63× faster, 64 B 1.23× → 1.69× faster); then Э6 (P6) eliminated the 256 B
churn loss entirely by moving the M2 double-free oracle out of the block body
and into hot metadata (see below). Ranges below span two runs on a noisy host;
the deterministic per-op proof is the iai gate (see below).
Churn is measured two ways. Non-writing (global_alloc_churn, the original
bench — blocks are never written; the artificial pattern where the old
stale-key slow path bit hardest) vs writing (global_alloc_churn_write,
new in P6.0 — each block is written after alloc; the realistic pattern,
because real code writes to the memory it allocates). The writing row is the
headline.
All three patterns below now have fully re-measured absolute ns/pair (every
allocator column, every size) — no stale carried figures. Reproduce with
npm run bench:table (scripts/bench-table.mjs),
the canonical wall-clock comparison script that always prints this exact shape
(ns/pair for these three sized groups, fixed bench set, vs-mimalloc ratio). It exists precisely so this table
is regenerated the same way each time instead of hand-assembled in different
units — an earlier ad-hoc table once read as a 20 ns → 40 ns "regression" that
was actually a µs-per-batch vs ns-per-op unit mixup.
Churn + write (bench_churn_alloc_write — same as churn but writes 16 B
after each alloc; the realistic pattern, real code writes to what it
allocates) — the headline:
| Size | SeferAlloc | mimalloc | System | Sefer vs mimalloc |
|---|---|---|---|---|
| 16 B | 24.2 ns | 22.7 ns | 144.4 ns | 1.07× slower |
| 64 B | 28.2 ns | 28.2 ns | 147.9 ns | 1.00× slower |
| 256 B | 24.1 ns | 37.8 ns | 166.1 ns | 1.57× faster |
| 1024 B | 27.1 ns | 232.4 ns | 184.9 ns | 8.59× faster |
Churn, non-writing (bench_churn_alloc, working-set reuse — 1 free + 1
alloc per pair; the artificial pattern where the old stale-key slow path bit
hardest, before Э6 removed it):
| Size | SeferAlloc | mimalloc | System | Sefer vs mimalloc |
|---|---|---|---|---|
| 16 B | 23.0 ns | 25.0 ns | 143.6 ns | 1.09× faster |
| 64 B | 22.4 ns | 26.9 ns | 143.3 ns | 1.20× faster |
| 256 B | 22.0 ns | 37.5 ns | 174.2 ns | 1.71× faster |
| 1024 B | 22.6 ns | 227.8 ns | 171.3 ns | 10.08× faster |
Warm bulk burst (bench_direct_alloc, alloc 1024 then free 1024 — 1 alloc
- 1 free per pair; no reuse WITHIN one iteration's allocation half, but criterion reuses freed cache/freelist/pool blocks ACROSS iterations, so this is warm, not truly cold/first-touch. Historically the worst case where mimalloc's cheaper carve led at tiny sizes):
| Size | SeferAlloc | mimalloc | System | Sefer vs mimalloc |
|---|---|---|---|---|
| 16 B | 70.9 ns | 30.0 ns | 173.1 ns | 2.37× slower |
| 64 B | 48.2 ns | 48.4 ns | 150.1 ns | 1.00× faster |
| 256 B | 84.5 ns | 31.2 ns | 283.4 ns | 2.71× slower |
| 1024 B | 66.0 ns | 83.5 ns | 361.6 ns | 1.26× faster |
(Measured 2026-07-23 via npm run bench:table, post-Round13 tree — Windows dev
host, criterion sample_size(10), same R5-R3 methodology as before
(TLS-heap reset between groups + rotated arm order per (group, size), see
docs/agent_reviews_round5/performance_review.md §4.1 items 2-3). This
single-host run carries the usual ±15-20% noise band; treat the relative
shape and order of magnitude as the signal, not the exact percentages — an
isolated single-run wall-clock delta on this host cannot distinguish "the
code changed" from "the host was busier this time"
(see docs/perf/R5_R2_CHURN_REGRESSION_PAIRED_AB.md, which needed 20
alternating paired repetitions to separate a real effect from this exact
class of noise). The deterministic tie-breaker for this exact churn path is
npm run iai: docs/perf/IAI_BASELINE.md's R5-R2b entry shows Ir for
churn_256b/small_churn_16b DROPPED 20.6% across the whole round4+round5
window (42,880 → 34,036), i.e. the hot path got strictly cheaper by the one
noise-free measure available — the wall-clock ratios above should be read
as "SeferAlloc vs mimalloc/System, this run", not as "SeferAlloc got slower
since the last table." vs System: ~4–7× faster across the board, same
shape as before.)
The 256 B churn loss is GONE (Э6, P6) — and M2 got stronger, not weaker.
Through P5 sefer-alloc trailed mimalloc at 256 B churn (~1.16–1.25× slower), and
the docs pinned that on "the M2 bitmap price". That framing was incomplete: the
real cost was a stale per-heap key stamped into the freed block's body
(word1) and read back as a magazine double-free filter — on a non-writing bench
the key survived the free and forced a slow-path scan plus a cold/conflict cache
line touch at the 256 B stride. Э6 removed the key entirely: the two exact
oracles (in-magazine scan + the BinTable is_free bitmap, both hot metadata)
now run unconditionally and the free path never touches the block body. On
the realistic writing pattern sefer-alloc now leads at every size (256 B
1.64× faster, 2026-07-10); the artificial non-writing pattern leads too
(256 B 2.12× faster). This is
not a trade for safety — M2 was strengthened: the pre-Э6
flushed-double-free-after-user-write hole is now closed (the oracle no longer
depends on block-body contents; tests/regression_magazine_oracles.rs test (c)
is RED pre-Э6, GREEN on Э6). Every P0–P6 speedup deleted a tautology, never a
guard.
Where we still trail — cold tiny blocks (16 B, 256 B), 2.4–2.7× behind
mimalloc (2026-07-23 post-Round13 re-measurement: 16 B 2.37×, 256 B 2.71×
slower; 64 B at parity (1.00× faster) and 1024 B 1.26× faster — see the Cold
direct table above). This is the
cold carve path (global_alloc, no reuse), unchanged by Э6 (which targets
only the churn free path). The residual is honest per-block work — page-map
writes and page faults on genuinely fresh pages, not ceremony — documented in
docs/perf/PERF_PLAN_beat_mimalloc_small_medium.md.
The alloc-runfreelist experimental feature (PERF-3) attempted to close
exactly this cold/recycle gap via a run-encoded freelist representation and
was honest-rejected — it regressed every one of the 11 iai benches
including the four cold/recycle targets, and the wall-clock judge confirmed
the regression direction and magnitude (+40 %/+43 % on the 16 B/64 B cold
storm); see
docs/perf/PERF3_RUN_FREELIST_EXPERIMENT.md.
The feature and its source (Ф1–Ф4: run_stack.rs + the gated branches in
alloc_core_small.rs / alloc_core_small_magazine.rs / alloc_core_small_pool.rs
/ segment_header.rs) have been removed entirely (R6-CQ-4); the experiment
record stays as institutional memory, so a reader does not need to wonder
whether work on this gap is silently ongoing.
The DETERMINISTIC counterpart to these noisy single-host wall-clock ratios is
the instruction-count perf_gate_iai gate (Valgrind, Linux-only CI): the P0
benches (cold_alloc_free_256x16b / _256x64b, churn_256b) plus the new
churn_write_256b bench (#150) exist to confirm the per-op Ir deltas of
Э1–Э6; their Ir baseline is captured on the first Linux perf-gate run.
Historical 0.2.0 numbers — the MT macro-benchmarks were NOT re-run for 0.3.0 this pass (the single-thread criterion tables above were); the crossover shape (mimalloc leads at T=1, SeferAlloc leads at T≥2) is expected to hold but the exact figures are not current-build. Aggregate million-ops/sec (op = one alloc + one free), T = 1 / 2 / 4 worker threads, unpinned.
Aggregate million-ops/sec (op = one alloc + one free), T = 1 / 2 / 4 worker threads, unpinned.
larson (server-churn, working-set + occasional cross-thread free):
| T | SeferAlloc | mimalloc | System | vs mimalloc |
|---|---|---|---|---|
| 1 | ~20.5 M | ~27.9 M | ~6.9 M | 1.36× slower |
| 2 | ~23.2 M | ~18.2 M | ~6.8 M | 1.28× faster |
| 4 | ~39.4 M | ~32.5 M | ~13.4 M | 1.21× faster |
mstress (rounds of fill → free-half → refill, with cross-thread):
| T | SeferAlloc | mimalloc | System | vs mimalloc |
|---|---|---|---|---|
| 1 | ~26.6 M | ~34.0 M | ~4.1 M | 1.28× slower |
| 2 | ~44.7 M | ~37.6 M | ~6.2 M | 1.19× faster |
| 4 | ~84.1 M | ~64.0 M | ~13.5 M | 1.31× faster |
SeferAlloc overtakes mimalloc at T ≥ 2 on both workloads (the
per-thread heap takes no shared lock; cross-thread frees route through
the lock-free Phase-10/12.6 remote path). Single-thread (T = 1) mimalloc
leads — see the verdict below.
Reconciliation note: the mstress rows above are historical 0.2.0 macro-bench numbers (this run's shape — the "faster at T ≥ 2" verdict).
docs/ALLOC_BENCH.md's Phase-13.4a mstress table shows an earlier snapshot where the T = 2 / T = 4 rows are within-noise parity vs mimalloc; the ratios differ because the two runs are different points in the 0.2.0 evolution, not different builds under the current tree. Both are labelled with their origin run.
alloc N → free N per criterion iteration — no working-set reuse WITHIN one
iteration's allocation half (every block in that half is a fresh carve), but
criterion re-runs the closure many times so freed blocks are reused ACROSS
iterations (warm). Historically the documented worst case for a per-thread
magazine; the P3 bump-direct batched carve (Э1) removed the tautological
carve → BinTable → pop round-trip that made every virgin block pay ~40
metadata-touch instructions. This is the same measurement as the "Warm bulk
burst" column of the Performance table above (current vs mimalloc ratios
reproduced here for the dedicated section; absolute ns/pair for every
allocator are now in the main Warm bulk burst table above, re-measured
2026-07-14 via npm run bench:table):
| Size | vs mimalloc (2026-07-23, post-Round13) | (pre-P3 was) |
|---|---|---|
| 16 B | 2.37× slower | 2.6× slower |
| 64 B | 1.00× faster | 2.0× slower |
| 256 B | 2.71× slower | 1.5× slower |
| 1024 B | 1.26× faster | 1.2× faster |
(Warm-bulk-burst vs mimalloc ratios measured 2026-07-23, post-Round13 tree, same
R5-R3 methodology fix (TLS-state isolation + arm-order rotation), see
docs/ALLOC_BENCH.md and the main Performance table
above — all absolute ns/op columns were re-recorded in this run; the
qualitative shape — small sizes trail, 1024 B leads — is broadly unchanged
since the 2026-07-20 run, within this host's usual noise band, though 64 B
moved from a 1.98× loss to parity this run (a single-host wall-clock swing,
not attributed to any Round13 code change — Round13's one production
addition, class-aware-dirty, is remote-drain-only and iai-confirmed to add
zero cost to this single-thread cold path; see
docs/perf/R13_9_CLASS_AWARE_DIRTY_PRODUCTION_GATE.md §1a/§3). The
"pre-P3 was" column is the pre-X-arc historical run, kept for the
before/after of the Э1 trajectory.)
The P3 carve removed the tautological round-trip, but the cold tiny gap
still sits at ~2.4–2.7× rather than the ~1.15–1.60× the earlier P3-era run
recorded — the residual is honest per-block work (page-map writes, page
faults on genuinely fresh pages) on a noisy single host, not ceremony, and
the deterministic signal is the iai gate below. Э6 (P6) does not touch
this cold carve path (it targets only the churn free path), so cold tiny
remains the one place mimalloc leads. The
old P7 alloc-side bulk-bypass was retired in P3 (bump-direct IS the ideal
bulk path, so the streak-detection heuristic no longer buys anything).
fastbin remains default-on in production; its M2 double-free guard is now
paid entirely in hot metadata (no block-body touch on free after Э6), so
256 B churn — previously a ~16 % loss — now leads mimalloc on the
realistic writing pattern (see the verdict below).
Reproduce with:
cargo bench --bench large_realloc --features "alloc-global alloc-decommit" -- large_alloc_free
cargo bench --bench global_alloc --features production -- global_alloc_churn
cargo bench --bench global_alloc --features production -- global_alloc_churn_write
cargo bench --bench global_alloc --features production -- "^global_alloc/"
cargo run --release --example malloc_macro --features "alloc-global alloc-xthread"- Where sefer-alloc wins big:
- Large alloc/free OPT-E: 12–33× faster than
mimalloc, ~237–302× faster thanSystem(measured 2026-07-06 post-X-arc, see docs/ALLOC_BENCH.md). The headline. - Real-world churn (the common shape) — leads at 256 B and above. On the
realistic writing pattern: 1.57× on
256 B, 8.59× on 1024 B (measured 2026-07-23 via
npm run bench:table, post-Round13, see docs/ALLOC_BENCH.md). 16 B and 64 B are within-noise ties this run (1.07× and 1.00× slower — see the Churn + write table above). The 256 B churn loss was eliminated in P6 (Э6) — the cause was a stale per-heap key in the block body, not the M2 bitmap; removing it also strengthened M2 (see below). - Warm bulk burst after P3 (Э1 bump-direct carve): the tautological round-trip is gone; the 2026-07-23 post-Round13 re-measurement shows a 2.37× cold gap at 16 B and 2.71× at 256 B (vs the 2.6× / 1.5× pre-P3 baseline on this noisy host), while 64 B sits at parity (1.00× faster, vs 1.98× slower the prior run — a single-host wall-clock swing, not a code-driven change) and cold 1024 B 1.26× faster.
- Realloc (
realloc_grow_geometric): ~1.8× faster thanmimalloc, ~12× faster thanSystem(re-verified by the R34-23 gate; the prior "~40×" figure was measured before a path-activation-oracle re-check found the chain's final grow exceeds the committed span and copies — seedocs/perf/R34_23_REALLOC_AND_VEC_GATE.md);realloc_grow_neighbour_pressure(formerlyrealloc_in_place_unfavorable) ~3,350× faster, confirmed and improved (post-X-arc OPT-G in-place Large growth). - MT macro at T ≥ 2: larson 1.22–1.38× faster, mstress ≈parity to 1.04× faster (measured 2026-07-06 post-R1/R2/R3, see docs/ALLOC_BENCH.md; the earlier "1.19–1.31× faster on mstress" was the 0.2.0 historical run — mstress is the noisier workload and the mimalloc column swung run-to-run this re-run).
- Large alloc/free OPT-E: 12–33× faster than
- Where it ties:
manual_realloc_sim(manualalloc+copy+deallocgeometric grow chain — NOT realVec, NOTGlobalAlloc::realloc; 1.07× faster than mimalloc as of the 2026-07-23 post-Round13 re-measurement — this bench has swung across parity in both directions across successive re-measurements on this host and should be read as within-noise, not a stable lead or loss in either direction); 16 B and 64 B churn (see above); MT mstress T = 2 within noise. - Where it now leads (was a loss through P5):
- 256 B churn: eliminated the loss in P6 (Э6). Was ~1.16–1.25× behind
mimalloc. The real cause was a stale per-heap key stamped in the block body
(word1) — not the M2 bitmap, as the P5 docs said — which on a non-writing
bench survived the free and forced a slow-path scan plus a cold cache-line
touch at the 256 B stride. Э6 moved the M2 oracle entirely into hot
metadata and stopped touching the block body; the free path is now cheaper
than mimalloc's (mimalloc writes
nextinto the block body on every free; we write nothing to it). On the realistic writing pattern we now lead 256 B by 1.57× (2026-07-23 post-Round13; non-writing 1.71×), and M2 was strengthened (the flushed-double-free-after-user-write hole is closed;tests/regression_magazine_oracles.rstest (c) is RED pre-Э6, GREEN on Э6).
- 256 B churn: eliminated the loss in P6 (Э6). Was ~1.16–1.25× behind
mimalloc. The real cause was a stale per-heap key stamped in the block body
(word1) — not the M2 bitmap, as the P5 docs said — which on a non-writing
bench survived the free and forced a slow-path scan plus a cold cache-line
touch at the 256 B stride. Э6 moved the M2 oracle entirely into hot
metadata and stopped touching the block body; the free path is now cheaper
than mimalloc's (mimalloc writes
- Where it loses:
- Cold tiny blocks (16 B, 256 B): 2.4–2.7× behind
mimalloc(2026-07-23 post-Round13 re-measurement: 16 B 2.37×, 256 B 2.71× slower; 64 B at parity this run). The P3 bump-direct carve removed the tautological round-trip but did not fully close the gap — what remains is honest per-block work (page-map writes, page faults on genuinely fresh pages), not ceremony. - Single-thread larson/mstress T = 1: 1.28–1.36× behind
mimalloc(historical 0.2.0 MT numbers, not re-run this pass). Structural cost of our safety machinery; the per-thread architecture means it does not compound — at T ≥ 2 sefer-alloc leads. Seedocs/FASTBIN_DESIGN.md§0. - Synthetic bulk (16–256 B alloc-1024-then-free-1024): 1.5–2.3× slower
(2026-07-20, last measured — not part of
npm run bench:table's tracked bench set, so not re-run in the 2026-07-23 post-Round13 pass above) — the magazine's design worst case (every free overflows, every alloc empties and refills). Documented trade-off; not a real-world pattern.
- Cold tiny blocks (16 B, 256 B): 2.4–2.7× behind
Every loss above is the price of a safety guarantee mimalloc does not
provide (double-free of LIVE/MAPPED memory = no-op, never UB, protected
by the pre-reuse off >= bump stale-free guard (#138); foreign pointer =
safe no-op; forbid(unsafe) by default at the top level with named
audited seams under production). One documented residual: the
ring↔magazine cross-thread double-free residual limit of M2 — a block whose
cross-thread free is still in-flight in a segment's RemoteFreeRing (not yet
drained) sets neither own-thread oracle (magazine slots scan nor BinTable
is_free bitmap). Two of its three legs are closed on plain production: the
in-magazine leg (X2 / #164) and the refill-window double-issue leg (R1, f23f7eb).
The third leg — re-issue-before-drain — remains an accepted residual under
plain production: pinned by the permanently-#[ignore]d
tests/regression_xthread_double_free_residual.rs (honestly red without
per-block generations — no distinguishing state exists), modelled by
tests/loom_magazine_ring_compose.rs. Under --features hardened the X7
per-granule generation guard (stamp the ring note with the block's generation at
remote-free time; drop it on drain if the generation has advanced) closes this
leg — pinned by the sibling residual_xthread_double_free_no_corruption_hardened
test in the same file — except for the 1/256 wrap: ≥256 re-issues of one
block without an intervening drain of the stale note collides with the current
generation mod 256, the accepted probabilistic residual-of-the-residual (design
plan §2.5 rejected doubling the ring footprint for a u64 gen; pinned by
tests/regression_gen_wrap_boundary.rs). Full account in
docs/DURABILITY.md (ledger entry + §"X7 per-granule
generation counter") and
docs/design/X7_GENERATIONAL_RING_PLAN.md.
On real workloads — churn, MT, large-alloc — we are net faster while keeping
those guarantees.
This is a verification-first build. Every claim above is backed by a tool,
a test file, and a reproducible command. 111 integration test files ship
in tests/ (100 conventional + 11 loom models — counted separately below);
5 example binaries in examples/; 9 benches in benches/
(global_alloc, heap_alloc, heap_async_pattern, heap_xthread,
large_realloc, locality, perf_gate_iai, pinned_write, sharded_write);
3 libFuzzer targets in fuzz/
(region_ops, global_alloc_ops, heap_core_ops).
| Tool | What it proves | Where in repo |
|---|---|---|
| Unit / integration tests | Construction, edge cases, end-to-end behaviour | tests/*.rs (111 files) |
proptest differential |
Op-stream agreement with a reference model (M1–M4) | tests/alloc_core_differential.rs, tests/differential.rs |
loom |
Cross-thread protocol agreement (Phase 12, Phase 10) — honest status per file (some model live paths, some are retained-with-honesty-notes on removed/dead paths) in each file's own doc comment | tests/loom_deferred_large.rs, loom_dirty_multi_segment.rs, loom_dirty_publish.rs, loom_epoch.rs, loom_heap_overflow.rs, loom_heap_overflow_drain_guard.rs, loom_magazine_ring_compose.rs, loom_overflow_first_retry.rs, loom_remote_ring.rs, loom_remote_ring_drain_guard.rs, loom_sharded.rs, loom_thread_free.rs, loom_xthread_protocol.rs (13 in-tree models), plus the extracted crates' real-type suites crates/racy-ptr-cell/tests/loom_racy_ptr_cell.rs, crates/tagged-index-stack/tests/loom_aba.rs (CRATE-P3/P7 — replacing the former in-tree loom_bootstrap_cas/loom_chunk_cas/loom_fallback_init/loom_overflow_sidecar_cas/loom_free_slots_aba shadow models) |
miri (strict-provenance) |
UAF, races at byte level, double-free, exposed-provenance casts | CI gate: region_invariants, decommit_miri_cycle, reclaim_offset_unit |
| Safe-surface stress (pure-safe API) | M1/M3 soundness: alloc never hands out aliasing pointers, so no purely-safe Box/Vec/Arc usage can trigger double-free/UAF |
tests/stress_safe_surface_no_aliasing.rs (6 threads × 1500 iters × 6 size classes; zero unsafe; 30+ runs) |
| ThreadSanitizer | Real cross-thread data races on a live binary | CI job + manual ×3 verified clean on race_repro, race_norecycle, global_alloc_mt, heap_cross_thread, decommit_stale_ring, decommit_soak |
Valgrind memcheck |
UAF, leaks, invalid reads at the process level | Manual: clean on all three cross-thread test binaries. Note: helgrind / DRD are inapplicable to lock-free atomic code (Valgrind doesn't model Rust atomics) — TSan is the right concurrency detector here. |
aarch64 via qemu-user |
Code-gen + relaxed-memory smoke on ARM | CI job + manual 13/13 tests pass. Honest caveat: TCG translation does not fully model ARM's weak-memory; real ARM hardware verification is a follow-up. |
| libFuzzer | Op-stream invariants under random input | fuzz/fuzz_targets/region_ops.rs, global_alloc_ops.rs, heap_core_ops.rs (fastbin magazine) |
| Soak harness | N-thread × hours stability | examples/soak_xthread.rs (32 / 64 / 128 workers) |
| tokio burn-in | Live #[global_allocator] under tokio multi-thread runtime |
examples/tokio_burn_in.rs |
| RSS probe | Memory recovery under asymmetric cross-thread pressure | examples/rss_probe.rs |
| Macro-bench | MT throughput vs mimalloc and System |
examples/malloc_macro.rs (larson + mstress) |
| Flamegraph profiling | Hot path identification per workload | docs/PROFILE_FLAMEGRAPHS.md (4 scenarios) |
Every CI job is wired (.github/workflows/ci.yml) and runs on every push:
test matrix on x86_64 + aarch64 (9 feature combinations), a windows-latest
production run, the workspace member crates' own suites, miri with
strict-provenance, ThreadSanitizer, an MSRV (1.88) check, clippy, rustfmt.
(libFuzzer has its own nightly/manual cadence — see fuzz/README.md — not a
per-push job.)
The full safety stack and the relationship between layers is documented in
docs/ARCHITECTURE.md §8 and
docs/INVARIANTS.md.
| Feature | Pulls in | What it enables | Default | When to use |
|---|---|---|---|---|
std |
— | SyncRegion, all std-gated tiers |
on | almost always |
alloc-core |
std |
The segment substrate (AllocCore) |
off | building on AllocCore directly |
alloc-xthread |
alloc-core |
Lock-free cross-thread free via RemoteFreeRing |
off | multi-thread allocator |
alloc-global |
alloc-core |
The SeferAlloc #[global_allocator] face |
off | process-wide allocator |
alloc-decommit |
alloc-core |
Return empty-segment payload pages to OS + SegmentTable slot-recycle |
off | long-running / DBMS workloads |
numa-aware |
alloc-core |
NUMA-node stamping + local-node preference (Linux mbind, Windows VirtualAllocExNuma) |
off | multi-socket NUMA hardware |
fastbin |
alloc-global + alloc-xthread |
Per-thread magazine (tcache) fast path — array-based per-class pop/push, M2 protected by hot-metadata oracles (no block-body touch) | off (on under production) |
server-churn / mixed-size multi-threaded workloads |
production |
alloc-global + alloc-xthread + alloc-decommit + fastbin + alloc-segment-directory + primordial-lazy-commit + class-aware-dirty |
The recommended combo for long-running multi-thread workloads. The fast default — no paid caller-misuse checks on the free hot path. | off | DBMS, async runtimes, anything that allocates over hours. |
alloc-stats |
— | Per-hit diagnostic counters: bumps stats().tcache_hits (magazine) and stats().large_cache_hits (large cache) on each hit. Default OFF and NOT in production — the per-hit increment is compiled out of the churn/large-cache hot paths, and without it those two stats() fields read 0 (all other stats() fields are unaffected). The counter storage lives in the shared registry slot, so toggling this never changes layout/ABI. |
off | you poll stats().tcache_hits / .large_cache_hits and want the real hit counts (add alongside production) |
hardened |
fastbin |
Paranoid deploys. Additive over production. Adds opt-in defence-in-depth against UNSAFE-CALLER misuse that costs cycles: currently the interior-pointer free guard on both own-thread free faces — the SeferAlloc magazine (HeapCore) and the AllocCore substrate (dealloc_small) — rejecting a free of a pointer that is not the block start (off % block_size != 0) as a detected no-op instead of a mis-indexed bitmap read → double-issue. The check is a modulo-per-free (a real division), so it is NOT on the production fast path. (Cross-thread frees are already guarded unconditionally by reclaim_offset.) X7 closure: under hardened, a per-granule generation counter also closes the re-issue-before-drain leg of the ring↔magazine cross-thread double-free residual (the third leg of M2, open under plain production) — the ring note is stamped with the block's generation and dropped on drain if it has advanced — except the 1/256 wrap (≥256 re-issues without an intervening drain collide mod 256), the accepted probabilistic residual-of-the-residual. See docs/DURABILITY.md (ledger entry + X7 §). |
off | untrusted / adversarial callers, forensic hardening |
experimental |
std + deps |
Lock-free LockFreeRegion / EpochRegion / ShardedRegion (legacy/deprecated; kept for backward compat and research baseline) |
off | RCU / epoch experiments only |
pinning |
experimental + core_affinity |
Thread-per-core pinning with core_affinity (PinnedRunner is NOT deprecated) |
off | shard == core workloads |
batch-api |
experimental + alloc-core |
Tcache-aware batch alloc/dealloc (SeferAlloc::alloc_batch/dealloc_batch). ⚠ No semver guarantees — signature/behavior may change or the feature may be removed in any release while it depends on experimental (R12-12) |
off | you have measured a real batch-size win for your workload and accept an unstable API |
bench-internals |
— | R24-6 / R25-1 / R29-3 / R29-7 / R29-8 / R29-10: gates the smallest set of unsafe fn dbg_* hooks (plus their safe siblings) that exist ONLY to let benches/perf_gate_iai.rs / integration tests isolate a perf-gate sub-cost or reconstruct a hard-to-reach test scenario, and whose own #[cfg] would otherwise be fully satisfied by production alone (HeapCore::dbg_dealloc_own_thread_with_base, HeapCore::dbg_push_coarse_only_entry, HeapCore::dbg_flush_class_only, HeapCore::dbg_clear_magazine_on_hit, all in heap_core_diag.rs; and tls_heap::dbg_restore_local_for_test + its safe twin dbg_mark_local_torn_for_test in global/tls_heap.rs, R29-7/task #438 — the first file outside heap_core_diag.rs; and AllocCore::dbg_force_decommit_retain_for in alloc_core/alloc_core_small_pool.rs, R29-8/task #439 — a safe-pub fn-reachable decommit of a segment payload with NO live_count check on a crate-root-public type, same R25-1 bug class; and the R29-3/task #434 segment-lifecycle-decomposition pair dbg_decomp_release / dbg_decomp_decommit_payload, each declared twice — once in AllocCore (alloc_core/alloc_core_small_pool.rs) and once as the HeapCore-level delegation of the same name in heap_core_diag.rs — plus their safe siblings dbg_decomp_full_cycle/dbg_decomp_os_roundtrip/dbg_decomp_reserve_and_keep/dbg_decomp_payload_range/dbg_decomp_page_size in both files). NOT in production — a plain --features production build of the library never compiles any of these hooks in. (An earlier such hook, dbg_overflow_bitmap_clear_pass, existed until R27-10/task #428 removed it — see the R24-6/R25-1 note below the tier-2 table.) Carries no code of its own. |
off | never, in application code — internal to this crate's own CI perf-gate and tests |
Trap — numa-aware silently no-ops small-segment-lazy-commit. The two
features compose without any compile error or runtime diagnostic, but
enabling both does NOT give you a lazily-committed NUMA-steered small
segment: alloc_core_small.rs's ordinary-segment reservation has two
mutually-exclusive #[cfg] arms (src/alloc_core/alloc_core_small.rs,
~lines 1889–1992) — the numa-aware arm calls
numa::reserve_aligned_on_node unconditionally and always reserves the
segment eagerly (this call never participates in the lazy-commit deferral
logic at all); only the not(numa-aware) arm checks
#[cfg(feature = "small-segment-lazy-commit")] and takes the
aligned_vmem::reserve_aligned_lazy path. So with numa-aware on,
small-segment-lazy-commit compiles in, costs nothing to enable, and
changes nothing observable — every ordinary small segment is reserved (and
fully committed) the eager way regardless of the lazy-commit flag. This is
the same undocumented-no-op shape as the pool_segments/pool_byte_cap
trap below (R27-1): both knobs silently agreeing to do nothing, with no
error to catch it. small-segment-lazy-commit's own promotion status is
tracked separately in docs/perf/OPEN_ITEMS.md item 26 (deferred, not
promoted into production); this trap applies whether or not that item is
ever promoted, as long as numa-aware is the arm compiled in.
production is the right starting point for almost any multi-thread or
async use of SeferAlloc. Without alloc-decommit, unregister /
free-list still runs unconditionally (freed large-segment slots recycle
normally), but empty small segments are pinned — their slots cannot be
recycled until they are decommitted; a long-running tokio server with
many small-segment carve/decay cycles will eventually hit the
MAX_SEGMENTS cap (see ## Honest limitations).
For embedded / no_std use, stay with the default std feature.
The alloc-decommit feature carries a per-thread large-segment free-cache.
Configuration is via the LargeCacheConfig const builder — all knobs are
set at compile time in a static initialiser; no environment reads, no
runtime parse errors.
| Builder method | Default | Meaning |
|---|---|---|
budget_bytes(n) |
None (unbounded) |
Per-shard ceiling on total cached bytes. 0 = cache disabled (every span released to the OS immediately). Unset = no admission limit; FIFO eviction fires only when this is set and the new span would exceed it. |
decay_rate_percent(n) |
10 (10 %/tick) |
Integer percent of excess = cached − headroom to release back to the OS per tick. Range [1, 100], clamped. |
decay_interval_ms(n) |
1000 (1 s) |
Minimum wall-clock ms between two consecutive decay ticks. A tick fires inline on the next large alloc/free after the interval elapsed. Idle processes pay nothing. |
headroom_bytes(n) |
256 MiB |
Floor below which the decay is a no-op (anti-thrashing pad). |
mode(m) |
LargeCacheMode::Lazy |
LargeCacheMode::Lazy is the default and only variant. The enum is #[non_exhaustive], reserved for a future background-scavenger mode as a non-breaking addition. |
The model is "allocate fast, release slowly": on a large free, the
span is admitted to the cache (subject to budget); on each subsequent large
op, the excess over headroom exponentially decays to the OS at the chosen
rate. Self-damping: aggressive far from target, gentle near target, no
oscillation. The default budget=None (unbounded) admits any span; if you
want a hard RSS ceiling (containers, mobile), add
.budget_bytes(512 * 1024 * 1024) to your config (or whatever fits).
The alloc-decommit feature also carries the empty-small-segment
hysteresis pool (Mechanism 2): when a small segment empties, the
allocator MAY retain it — still registered in the segment table, pages
still committed, per-class free lists still populated — so the next
allocation that would otherwise reserve a fresh segment pops a pooled
one with no OS syscall, no metadata re-init, and no page fault. Its
default (SmallSegmentPoolConfig::DEFAULT = pool_segments=4, pool_byte_cap=16 MiB) is deliberately RSS-conservative — it caps
both how many empty segments are retained (4) and how much committed
RSS the pool holds (16 MiB). Setting either knob to 0 disables the
pool entirely (immediate release of every empty small segment).
For latency-sensitive workloads that churn allocations across a segment
boundary, raise BOTH knobs together via SmallSegmentPoolConfig,
composed into LargeCacheConfig:
use sefer_alloc::{SeferAlloc, LargeCacheConfig, SmallSegmentPoolConfig};
const POOL: SmallSegmentPoolConfig = SmallSegmentPoolConfig::new()
.pool_segments(8)
.pool_byte_cap(32 * 1024 * 1024);
const CONFIG: LargeCacheConfig = LargeCacheConfig::new().pool(POOL);
#[global_allocator]
static GLOBAL: SeferAlloc = SeferAlloc::with_config(CONFIG);Measured benefit (stated narrowly — do not generalize beyond the
shape that was measured): on the 1024-byte allocate/free
churn-with-teardown workload at batch size 120, the paired
(8, 32 MiB) config ran with ~22 % lower elapsed time and
9 → 0 decommit syscalls per run versus the (4, 16 MiB) default,
measured natively on Windows on a single host (paired t = 8.114,
sign test 19/20). See
docs/perf/R27_4_REAL_DEFAULT_AB_GATE.md.
This is a workload-shape-specific result, not a general
"sefer-alloc is 22 % faster" claim.
Cost — given equal prominence to the benefit: the paired config
retains ~+8 MiB of committed RSS per materialised heap versus the
4 / 16 MiB default (scaling linearly to ~+255 MiB across 32
heaps), and this retained memory does NOT decay during pure idle
time — the small-pool decay is event-driven (no background thread), so
retention persists until further allocation pressure, an explicit drain,
or thread-exit. See
docs/perf/R27_3_POOL_RETENTION_GATE.md.
Trap — both knobs must move together. The effective runtime cap is
min(pool_segments, pool_byte_cap / SEGMENT) where SEGMENT = 4 MiB.
With the default pool_byte_cap = 16 MiB, pool_byte_cap / SEGMENT
already equals 4, so a lone .pool_segments(8) (without also raising
.pool_byte_cap(...)) resolves to min(8, 4) = 4 — a silent no-op
that changes nothing observable. Both knobs must be raised together, as
in the recipe above. This exact trap is encoded as a CI guard:
tests/small_segment_pool.rs::paired_knob_promotion_is_not_a_noop.
See Install above for the Cargo dependency. The repository ships several runnable examples that exercise the allocator under real workloads:
# Handle store / global allocator example
cargo run --example global_allocator --features alloc-global
# Multi-thread macro-benchmark (larson + mstress, T=1/2/4)
cargo run --release --example malloc_macro --features "alloc-global alloc-xthread"
# Tokio async burn-in (256 tasks × 10 s)
cargo run --release --example tokio_burn_in --features "alloc-global alloc-xthread"
# Stability soak (default: avail_par threads × 5 s)
cargo run --release --example soak_xthread --features "alloc-global alloc-xthread"
# Production-style RSS probe
cargo run --release --example rss_probe --features "alloc-global alloc-xthread alloc-decommit"| Doc | What it covers |
|---|---|
docs/INTEGRATION.md |
How to attach the allocator to a project + the LargeCacheConfig builder (budget / decay period / decay rate / headroom / mode) |
docs/ARCHITECTURE.md |
30-minute end-to-end technical tour |
docs/INVARIANTS.md |
The I1–I7 (Region) and M1–M8 (Malloc) invariants |
docs/DESIGN.md |
Cartographer / Membrane / Hand model for Region<T> |
docs/ALLOC_PLAN.md |
Detailed Phase 8+ allocator plan |
docs/PHASE35_DECOMMIT_DESIGN.md |
M6 decommit + why no epoch reclamation is needed |
docs/PHASE_NUMA_DESIGN.md |
NUMA-aware path design |
docs/CROSS_THREAD_STATE_MACHINES.md |
The cross-thread-free state-machine spec |
docs/DURABILITY.md |
Ultra-long-run counter inventory: every monotonic/wrapping cursor, its wrap arithmetic, verdict, and boundary test |
docs/RACE_DRAIN_RECLAIM.md |
The §13 / §14 race investigation (the four "peelings") |
docs/ALLOC_BENCH.md |
Full benchmark results, OPT-E numbers, honest verdicts |
docs/FASTBIN_DESIGN.md |
Per-thread tcache magazine design (P0–P6), full sweep, win/loss ledger, production decision |
docs/PROFILE_FLAMEGRAPHS.md |
Flamegraph profiling report (4 scenarios, 6 optimisation candidates) |
docs/HEAP_BENCH.md, docs/BENCHMARKS.md |
Per-tier bench writeups |
docs/PLAN.md, docs/ALLOC_PLAN_PHASE12-13.md |
Phase plans, dependency DAGs, risk registers |
docs/GLOSSARY.md |
Identifier glossary: decodes the ID families used in source comments (I1–I7, M1–M11, Phase/P/Ф codes, Э-series, OPT-A…H, X7, W/A/MUST/SEC items, task #NNN) |
docs/design/R30_7_TRIM_SCAVENGE_API_DESIGN.md |
Design + implemented (R31-10, task #474) explicit, caller-driven SeferAlloc::trim_current_thread() API — reclaims retention a burst-then-idle workload leaves behind, sidestepping the no-background-thread constraint R27-5's adaptive-pool-budget design could not solve; measured a real 128.0 MiB RSS win during idle in docs/perf/R31_10_TRIM_CURRENT_THREAD_RSS_GATE.md |
docs/perf/R30_7_SERVER_SHAPED_THROUGHPUT_PROFILE_AB_GATE.md |
Does the Profile::Throughput small-pool win hold in a multi-thread, mixed-size, continuous-cycle workload (not R27-4's single-thread teardown micro-benchmark)? Measured: no statistically distinguishable effect at this scale — the mechanism fires identically in both arms (decommit_calls_total=40 in both), so this workload does not separate them; the null is underpowered (MDE ≈19% of the mean), not a confirmed absence of effect (§0.1/§0.2, corrected 2026-07-30) |
- Single-thread small-class hot path is ~1.2–2× behind
mimalloc. The flamegraph atdocs/PROFILE_FLAMEGRAPHS.md §1shows where; OPT-C lazy stamp recovers ~1 %, the structural gap remains. - NUMA latency-speedup is not benchmarked on real hardware. QEMU
-numaverifies correctness, not asymmetry. Real measurement needs a 2-socket dev box / cloud.metalinstance — flagged indocs/PHASE_NUMA_DESIGN.md. - ARM weak-memory is partial coverage. aarch64 13/13 under
qemu-userproves code-gen + most race-conditions; TCG does not fully model ARM's weak memory. Verification on real ARM hardware (Graviton / Apple Silicon / Raspberry Pi) is a follow-up. - Valgrind helgrind / DRD are inapplicable. Both report thousands of
false positives on legitimate lock-free atomic load/store pairs (Valgrind
does not model Rust atomics).
ThreadSanitizeris the right concurrency detector for this codebase. Valgrindmemcheckis run and clean. - The large-cache has no fixed per-span size cap. The old
MAX_CACHED_LARGE_BYTES = 64 MiBceiling was removed (#90); admission is governed byLargeCacheConfig::budget_bytes(defaultNone— unbounded) and the fixedLARGE_CACHE_SLOTS = 8slot count, not by span size. A workload with sustained multi-GB large allocations is cacheable subject to the configured budget (or the process's available RSS, if unbounded). alloc-decommitis opt-in. Without it, unregister and the SegmentTable free-list still recycle freed large-segment slots unconditionally, but empty small segments cannot be recycled (they are recycled only when decommitted). Long-running processes with many small-segment carve/decay cycles will pin slots and eventually hit theMAX_SEGMENTScap. Use theproductionfeature alias to avoid this.- A hard cap on simultaneously-LIVE Large objects:
MAX_SEGMENTS - 1(4095), reproducible, not a soft degradation. Every Large allocation — regardless of feature combination, and independent ofalloc-decommit(which only recycles a slot once its object is freed, not while it stays alive) — consumes exactly oneSegmentTableslot (src/alloc_core/segment_table.rs). Slot 0 is permanently reserved for the primordial segment, so the usable ceiling isMAX_SEGMENTS - 1. Past it,alloc()returns null on every further request (graceful OOM, not a panic/abort inside this crate) until an existing Large object is freed; for aGlobalAllocconsumer that surfaces ashandle_alloc_error(process abort by default). Alloc/dealloc latency stays flat approaching the ceiling (no non-linear slowdown) — it is a binary wall, not degradation. R13-8 first measured and located this precisely at 1023 live objects whenMAX_SEGMENTSwas 1024 (docs/perf/R13_8_MEDIUM_WORKING_SET_JUDGE.md); R14-7 raisedMAX_SEGMENTS4× (to 4096) after confirming the raise is cheap on every axis measured — idle-process RSS unchanged, primordial segment metadata footprint +84 KiB inside a large fixed 4 MiB budget, and no scan-path degradation (theproduction-defaultalloc-segment-directorybounds the hot lookup path independent of table size; the only trueO(table size)walk is the one-timeDropteardown) — seedocs/perf/R14_7_EXPANDABLE_SEGMENT_TABLE_DESIGN.mdfor the follow-on design (an expandable/chained table, evaluated jointly against the cold-carve gap) if a workload still needs more than 4095 simultaneously-live Large objects.
1.88. The single-threaded core is plain safe Rust and will build on much older toolchains; we pin a known-good floor from day one. MSRV bumps are minor releases.
PRs welcome — please read CONTRIBUTING.md first. The
short version: this is a verification-first project, so a PR is expected
to come with tests + run the right verification layer for what it changes
(cargo test --features production minimum; miri / loom / TSan for
cross-thread; // SAFETY: for any new unsafe).
The codebase conventions are documented in
docs/ARCHITECTURE.md and CLAUDE.md (one
export per file; mod.rs only re-exports; tests live in tests/ not
inline; unsafe only in named seams). The compiler enforces the unsafe
discipline; the rest is convention.
Memory-safety bugs, soundness holes, and unsafe-contract violations
qualify as security issues. Please do not open public issues for these.
Use GitHub Security Advisories (private) per SECURITY.md.
Acknowledgement within 72 hours; coordinated disclosure standard.
This project adopts the Contributor Covenant 2.1.
Dual-licensed under either MIT or Apache-2.0,
at your option. Contributions are accepted under the same terms (per
CONTRIBUTING.md).