sefer-alloc 0.2.0

A safe-by-construction, 100% Rust memory toolkit (no C/C++ libraries — no libnuma/mimalloc/jemalloc/snmalloc/tcmalloc): a single-threaded handle store (Region<T>) and a drop-in #[global_allocator] (SeferAlloc) over one verified segment substrate, with #![forbid(unsafe_code)] at the top.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
# `SeferMalloc` — benchmark & honest verdict (Phase 11)

`SeferMalloc` (feature `alloc-global`) is the **malloc face** of `sefer-alloc`:
an `unsafe impl GlobalAlloc` over the per-thread segment heap (Phase 8 segment
substrate + Phase 9 intrusive free-list hot path + Phase 10 cross-thread free
under `alloc-xthread`). One substrate, two faces — the typed `Handle` face and
this raw `*mut u8` drop-in face.

This is the honest measurement the campaign promised: *"as fast as the best, and
safe."* Stated plainly, win or lose.

## What was measured — single-threaded churn

`benches/global_alloc.rs` (quick criterion profile: `sample_size(10)`, 150 ms
warm-up, 600 ms measurement). All three allocators are driven **through their
`GlobalAlloc` API directly** in one binary — a true apples-to-apples comparison
of the alloc/dealloc hot path (`SeferMalloc` is not installed as the bench
binary's global allocator; it is called directly, exactly like `mimalloc` and
`System`).

- **direct churn:** `OPS = 1024` alloc+dealloc pairs of a fixed layout.
- **`Vec_push`:** a realistic growing-vector pattern (repeated alloc + realloc +
  copy as a 512-element `i64` vector doubles its capacity).

Host: Windows 10, dev machine. Numbers are medians; the quick profile is for
*relative standing*, not precision. **Re-measured at Phase 13.4a** (see the
evolution note below) — these are current numbers, not the stale Phase-11 ones.

| workload          | SeferMalloc | mimalloc | System (Win) |
| ----------------- | ----------: | -------: | -----------: |
| 16 B churn        |     ~24.5 µs |  ~11.0 µs |     ~110 µs |
| 64 B churn        |     ~30.6 µs |  ~20.2 µs |     ~144 µs |
| 256 B churn       |     ~28.7 µs |  ~22.8 µs |     ~132 µs |
| 1024 B churn      | **~30.5 µs** |  ~34.8 µs |     ~116 µs |
| `Vec` push/grow   |  **~496 ns** |  ~515 ns |     ~543 ns |

### Evolution of the single-thread numbers (why they moved since Phase 11)

The Phase-11 table claimed 16 B churn at ~21.5 µs. That number predated two
substrate changes: (a) the **registry inversion** of Phase 12.5 (raw-pointer TLS
→ a process-global heap registry with a never-null fallback; the per-call
`current_for_alloc()` now does a registry hop), and (b) the **Phase 13.4a**
double-free guard rework (an O(1) bitmap that replaced an accidental O(N²)
scan — a *correctness/perf fix*, not a regression). Net, steady-state 16 B churn
is now ~22–25 µs (the TLS/registry hop is a small constant added per call). The
1024 B and `Vec_push` standings — where `SeferMalloc` is at or ahead of
mimalloc — are unchanged in character. The old 21.5 µs figure is retired; the
table above is the honest current state.

### Bulk-vs-churn: what the "churn" row above actually measures

The table above uses `bench_direct_alloc` (`alloc 1024 → free 1024`). Strictly
speaking that is a **bulk** pattern, not a steady-state churn. Real workloads
interleave allocs and frees over a bounded working set. The bulk pattern is
the documented **worst case** for the per-thread magazine (`fastbin`,
default-on in `production`): every free overflows the magazine, every alloc
empties it. The 16 B/64 B/256 B bulk regression (~1.8–2.9× slower than
`mimalloc`) is the magazine's design trade-off, kept as a regression guard
rather than a representative workload.

A true steady-state churn microbench (`bench_churn_alloc`, added in P0 of
the `fastbin` project — `benches/global_alloc.rs`, group `global_alloc_churn`)
maintains a working set of 256 live blocks and frees/replaces one at random
per iteration. On that workload `SeferMalloc` is competitive-to-strongly-ahead:

| size | SeferMalloc | mimalloc | vs mimalloc |
| ---- | ----------: | -------: | -----------: |
|   16 B |  ~21.8 µs |  ~36.9 µs | **1.7× faster** |
|   64 B |  ~22.3 µs |  ~37.2 µs | **1.7× faster** |
|  256 B |  ~21.9 µs |  ~22.1 µs | parity |
| 1024 B |  ~21.9 µs |   ~159 µs | **7.3× faster** |

See [`FASTBIN_DESIGN.md`](FASTBIN_DESIGN.md) for the design and the full
P0 → P6 measurement history (sweep, win/loss ledger, production
decision).

## What was measured — multi-threaded macro-benchmark (Phase 13.7)

`examples/malloc_macro.rs` (run:
`cargo run --release --example malloc_macro --features "alloc-global alloc-xthread"`).
This is a **standalone harness**, NOT a criterion micro-loop: criterion's
per-iter model mis-measures MT work (thread-spawn inside the timed closure
dominates). Threads are pre-spawned, aligned on a `Barrier`, then a fixed op
budget (400 k steps/thread) is run and the steady-state region is timed with
`Instant::elapsed`. PRNG is a dependency-free xorshift with fixed per-thread
seeds (reproducible; no `rand` crate added).

Two workloads, both reporting **aggregate ops/sec** (an op = one alloc+free
pair), sweeping T = 1, 2, 4 threads:

- **larson** (server-churn): each thread keeps a ~768-slot working set; each
  step frees a random slot and re-allocates a random small-skewed size
  (16..512 B, ~3% up to 8 KiB). Every 16th step a block is **handed to another
  thread and freed there** (cross-thread free, routed through the Phase-10/12.6
  remote path under `alloc-xthread`).
- **mstress**: rounds of "fill 512 mixed blocks → free half in random order
  (~1/8 cross-thread) → refill → free all".

Cross-thread handoff is leak/UAF-free by construction: a handed-off block is
moved out of the producer's slot before being sent over an `mpsc` channel; the
consumer is its sole owner and frees it exactly once; each worker drains its
mailbox before joining. Every block is freed exactly once by exactly one thread.

Numbers are **million ops/sec** (higher is better), Windows 10 dev machine,
representative run (run-to-run ±10%; the *ordering* is stable):

**larson**

| T | SeferMalloc | mimalloc | System |
| -: | ----------: | -------: | -----: |
| 1 |   ~21 M     |  ~27 M   | ~6.6 M |
| 2 | **~25 M**   |  ~19 M   | ~7.2 M |
| 4 | **~40 M**   |  ~32 M   | ~14 M  |

**mstress**

| T | SeferMalloc | mimalloc | System |
| -: | ----------: | -------: | -----: |
| 1 |   ~25 M     |  ~33 M   | ~4.5 M |
| 2 |   ~43 M     |  ~43 M   | ~6.1 M |
| 4 |   ~65 M     |  ~65 M   | ~9.5 M |

**RSS:** not measured here. There is no portable, dependency-free peak-RSS probe
across Windows/Linux/macOS without pulling in platform syscalls, so the harness
honestly reports N/A rather than inventing a number. (RSS comparison is a
hardening-gate item — `SeferMalloc` does not yet decommit empty segments to the
OS, feature `alloc-decommit`/#35, so a fair RSS comparison must wait for that.)

### Honest reading of the MT numbers

- **Single thread, mimalloc leads** (both larson and mstress): ~25 % faster on
  larson, ~30 % on mstress. Same story as the single-thread churn table — the
  per-call `classify` + TLS/registry hop is a constant mimalloc's hand-tuned
  inlining shaves.
- **At T = 2 and T = 4, `SeferMalloc` catches and on larson *passes* mimalloc.**
  On larson it is ahead at both 2 and 4 threads (the per-thread heap means the
  fast path takes no shared lock; the cross-thread frees route through the
  per-segment remote path without contending the producer). On mstress the two
  are within noise of each other at 2 and 4 threads.
- **`SeferMalloc` scales cleanly with thread count** — larson goes ~21→25→40 M
  (1→2→4 T), mstress ~25→43→65 M. mimalloc's larson number actually *dips* at
  T = 2 on this host (~27→19 M) before recovering at T = 4; `SeferMalloc` does
  not show that dip. System is flat and ~3–7× behind throughout.
- This is the payoff of the per-thread-heap architecture: the single-thread
  constant is a real gap, but it does **not** compound under contention, so by a
  handful of threads the safe-by-construction allocator is competitive-to-ahead.

### Heap == core pinning (Phase 13.6) — honest verdict: no win on this host

A heap is bound to its thread via TLS (`current_for_alloc`), so pinning the
**worker thread** to a fixed core keeps that heap's segments warm in one core's
cache without the allocator having to change anything — the per-thread-heap
analogue of the Phase-7c sharded thread-per-core topology. The macro-bench grew
an opt-in **pinned mode** (feature `pinning`; reuses the Phase-7c
`core_affinity` organ through `PinnedRunner::pin_current_thread_to_core` /
`available_cores` — no new dependency, no `unsafe`). Run:

```
cargo run --release --example malloc_macro \
  --features "alloc-global alloc-xthread pinning"
```

It runs the **whole sweep twice in one process** (unpinned baseline, then
pinned) so the two modes see the same warm machine state. Without the `pinning`
feature the example is byte-for-byte the pre-13.6 single (unpinned) sweep.

Measured on this host (**Windows 10, 16 logical cores**, 1/2/4 workers,
representative of two consecutive runs; M ops/sec, higher better):

**larson — SeferMalloc**

| T | unpinned | pinned |
| -: | -------: | -----: |
| 1 |   ~19 M  | ~20 M  |
| 2 | **~24 M**| ~21 M  |
| 4 | **~42 M**| ~32 M  |

**mstress — SeferMalloc**

| T | unpinned | pinned |
| -: | -------: | -----: |
| 1 |   ~25 M  | ~23 M  |
| 2 | **~41 M**| ~27 M  |
| 4 | **~63 M**| ~48 M  |

**Pinning does NOT help here — it hurts, the more so as T grows** (the same
trend holds for mimalloc and System in the pinned tables). This is the expected
outcome on a **16-core box running only 1–4 workers**: the OS scheduler has
plenty of idle cores and places the few hot threads well on its own. Forcing
worker *i* onto a fixed core id removes that freedom — round-robin from core 0
on an SMT machine can co-schedule two workers on the two hyperthreads of one
physical core (sharing its L1/L2), and pinning also blocks the scheduler from
migrating a worker off a core that the OS or another process is using. The
"keep the heap cache-warm" benefit a pinned topology *can* give (many workers,
core-count-saturated, cache-hot per-thread working sets) is simply not the
regime a 16-core dev box with ≤4 workers is in.

**Decision (honest keep-or-document):** the pinned mode is **kept as an opt-in
tool**, not made the default — exactly because the measurement says it does not
help *on this machine*. It is the right primitive to reach for on a
**core-saturated** deployment (workers ≈ cores, thread-per-core executors like
glommio/monoio, NUMA boxes) where keeping a heap's segments resident on one
core's cache and avoiding cross-NUMA migration genuinely pays — but that regime
must be measured on the target host, not assumed. On this 16-core Windows dev
machine with few workers, leaving the OS scheduler in charge (the default,
unpinned path) is the faster choice. No numbers were tuned to flatter the
feature; the slowdown is reported as found.

## Honest verdict

**The architecture delivers: `SeferMalloc` is competitive with `mimalloc` and
consistently far faster than the Windows system allocator — while being safe by
construction** (the intelligence is pure safe integer arithmetic; `unsafe` lives
only in the two audited seams + the `GlobalAlloc` impl).

1. **Competitive with mimalloc, and it wins on real patterns.** On small
   fixed-size churn (16–256 B) `SeferMalloc` is ~1.4–2.2× behind mimalloc — the
   gap is the per-call `classify` + TLS/registry hop that mimalloc shaves with
   hand-tuned inlining. But at **1024 B it is faster than mimalloc**, and on the
   realistic **`Vec` push/grow** pattern (the allocation shape real programs
   actually produce) it **beats both mimalloc and System**. The single-writer
   per-thread free-list pop is the same shape as the best allocators, so the
   remaining gap is a small constant, not an order of magnitude.
1b. **Multi-threaded: the single-thread gap does NOT compound (Phase 13.7).**
   The new MT macro-bench (`examples/malloc_macro.rs`, larson + mstress, 1/2/4
   threads, with cross-thread free) shows mimalloc ahead at T = 1, but at
   **T = 2 and T = 4 `SeferMalloc` catches it — and on larson passes it** (e.g.
   ~40 M vs ~32 M ops/sec at 4 threads). `SeferMalloc` scales monotonically with
   thread count; the per-thread heap takes no shared lock on the fast path, so
   contention does not magnify the per-call constant. System trails ~3–7×
   throughout. RSS is not yet measured (no decommit; see the macro-bench section).
2. **Dramatically faster than the OS allocator.** Across every size, `SeferMalloc`
   is **~2.5–5× faster than the Windows system allocator** — the practical win
   for a Windows service that currently pays the system-allocator tax.
3. This is a huge improvement over the Phase-9 `Heap` micro-bench (which showed
   ~7–12× behind mimalloc); measuring the steady-state hot path through the
   `GlobalAlloc` face shows the true standing.

## NOT yet production-trusted — the remaining hardening gate

Per `ALLOC_PLAN.md` §5 P11 / §8, production trust is earned only after the full
hardening gate. What works today and what remains:

**Works and is verified:**
- The `GlobalAlloc` face serves correct, aligned, non-overlapping, reusable
  memory — proven by `tests/global_alloc.rs` (direct-API, pattern write/read-back,
  no-overlap, realloc-prefix, 20 k churn) and the Phase 8/9 differential proptests
  + miri.
- It runs as a real `#[global_allocator]` for a single-threaded application
  workload end-to-end — `examples/global_allocator.rs` sums a 100 k-element `Vec`
  and fills a 10 k-entry `HashMap` entirely through `SeferMalloc`.
- Reentrancy-freedom (M5) and no-panic on the alloc path: the substrate's panic
  sites were hardened (the `alloc_small` `.expect`, `SegmentTable::register` and
  `Segment::reserve` now return gracefully; the TLS binding has a no-panic
  `with_heap_try` variant).

**Remaining (NOT done — do not deploy as a process-wide allocator yet):**
- **Robust process-wide `#[global_allocator]` under hostile runtimes.**
  RESOLVED in Phase 12.5 (the shard-model turn). The raw-pointer TLS + global
  heap registry + never-null fallback heap make the face reentrancy-safe (M5)
  and never-null (M10) under the multithreaded libtest harness — parallel test
  threads, panic hooks, thread teardown. The headline MT gate
  (`tests/global_alloc_mt.rs`) installs `SeferMalloc` as the
  `#[global_allocator]` and runs `Vec`/`String`/`HashMap`/`Box` churn across
  threads that spawn AND exit mid-allocation, plus a cross-thread free channel
  test (under `alloc-xthread`), all green. This removes the "abort under
  reentrant/teardown access" caveat. **Caveat that REMAINS:** the heavy
  hardening gate (fuzz / aarch64 weak-memory CI / TSan) has not been run, so
  "production-trusted on every target" is still pending that gate (§4 of
  `ALLOC_PLAN_PHASE12-13.md`).
- Cross-thread free requires the `alloc-xthread` feature. Phase 12.5 ships the
  shard model (a heap is a shard; thread death releases the slot, the HeapCore
  stays whole for the next claimant); **Phase 12.6 makes cross-thread free
  RECLAIM** — the freer pushes the block's `offset | class` into a non-intrusive
  per-segment ring; the owner reclaims it into the `BinTable` lazily on its
  alloc-slow-path (`find_segment_with_free`). See `RACE_DRAIN_RECLAIM.md`
  §13/§14: the true root was that `page_map`'s per-page class is unreliable for
  the mixed-class pages a shared bump cursor produces, so cross-thread reclaim
  must carry the class from the freer's `Layout` (not derive it). The
  Phase-12.5 discard-leak is **gone**: cross-thread-freed blocks are reused; RSS
  is bounded (only an in-flight ring's worth may sit un-reclaimed until the next
  slow-path drain). Verified on Windows + Linux (ThreadSanitizer-clean — it was
  a class-derivation logic bug, not a data race). M6 decommit (return empty
  segments to the OS) + M11 remain deferred behind a future `alloc-decommit`
  flag (#35). The single-thread `Heap` path fully reuses.
- The heavy correctness gate — `cargo-fuzz` (CPU-hours over adversarial
  alloc/free/realloc streams), **aarch64** multi-arch CI (the weak memory model
  x86 hides), and **ThreadSanitizer** under stress — needs CI / non-Windows
  hosts and has not been run.

## Bottom line

`SeferMalloc` proves the thesis: a safe-by-construction allocator can be
*competitive with mimalloc* (and beat it on realistic patterns) while being
*much faster than the OS allocator*. As of Phase 12.5 it is a **working,
multithreaded drop-in `#[global_allocator]`** — the reentrancy/teardown abort
that blocked MT use is closed, and the headline MT gate runs green with thread
churn + cross-thread free, and (Phase 12.6) cross-thread-freed blocks are now
**reclaimed** (no discard-leak). One honest remainder: the heavy hardening gate
(fuzz / aarch64 / TSan-under-stress) is still pending, so "production-trusted on
every target" is not yet claimed — though the cross-thread reclaim path is now
ThreadSanitizer-verified on Linux. For a process-wide allocator right now,
`SeferMalloc` is a viable MT choice; the remaining gap to `mimalloc` is the
multi-arch / CPU-hours hardening gate, not a known correctness or RSS issue.

---

## NUMA — opt-in locality steering (Phase B–E of #58)

### What was added

Feature flag `numa-aware = ["alloc-core"]`, **default OFF**.  Enabling it
steers new segment reservations to the NUMA node of the calling thread:

- **Linux**: `mbind(2)` with `MPOL_PREFERRED` — called after `mmap`, before
  first page access, so page-faults bring physical pages from the preferred
  node.
- **Windows**: `VirtualAllocExNuma` replaces `VirtualAlloc` — the preferred
  node is passed at reservation time (Windows has no `mbind` equivalent for
  already-reserved ranges).
- **macOS / miri**: no-op — Apple Silicon is UMA; there is no public NUMA
  syscall.  `current_node()` returns `NO_NODE` (`u32::MAX`), `bind_segment`
  is a no-op compile-time constant.

Without the flag the build is **byte-for-byte unchanged** — no new code
executes, no layout shifts (the `node_id: u32` field in `SegmentHeader` is
always compiled in to keep the struct layout stable across feature configs, but
is initialised to `NO_NODE` and never read without `numa-aware`).

### Integration points

| Location | Change |
|---|---|
| `src/alloc_core/numa.rs` | Confined-`unsafe` OS seam: `current_node()`, `bind_segment()`, `reserve_aligned_on_node()` |
| `src/alloc_core/segment_header.rs` | `node_id: u32` field (+4 bytes; still ≪ PAGE) |
| `src/alloc_core/alloc_core.rs` | `reserve_small_segment` stamps `node_id`; `find_segment_with_free` prefers same-node segments; `alloc_large` steers large segments |

### What is verified

| Test file | What it checks |
|---|---|
| `tests/numa_seam.rs` | `current_node()` returns `NO_NODE` or `< 64`; `bind_segment` with `NO_NODE` / zero len is a no-op; `reserve_aligned_on_node` returns a SEGMENT-aligned pointer |
| `tests/numa_segment_id.rs` | Small and large `AllocCore` allocations carry `node_id == current_node()` at allocation time |
| `tests/numa_alloc.rs` | Integration: same-thread segments share `node_id`; cross-thread free across potential node boundaries does not panic or corrupt; two-thread consistency of `stamped == observed` |

`tests/numa_alloc.rs` is **ENV-guarded** (`SEFER_NUMA_TEST=1`) — without the
variable every test body returns immediately (passes on CI single-NUMA
machines).  To execute the full test:

```sh
SEFER_NUMA_TEST=1 \
  cargo test \
    --features "alloc-core alloc-global alloc-xthread alloc-decommit numa-aware" \
    --test numa_alloc
```

Run inside a QEMU VM with `-numa node,...` or on a kernel booted with
`numa=fake=N` to exercise a real multi-node topology.

### What is NOT verified — honest N/A

**Latency-reduction numbers are not in this table.** The benefit of local-node
page allocation is a *memory-access latency* reduction — measurable only when
the workload's data fits in a NUMA node's local DRAM and accesses to remote
DRAM are the bottleneck.  That regime requires **real multi-socket hardware**:

- AWS `c5n.metal` / `i3.metal` (Xeon, 2 physical sockets)
- AWS `r6g.metal` (Graviton 2, multiple NUMA domains)
- Dual-socket development box

QEMU `-numa` / `numa=fake` verify correctness of the `mbind` / stamping path
but cannot reproduce the physical latency asymmetry (all "nodes" share the same
physical socket and DRAM controllers).  Until measurements on real hardware are
available the NUMA latency column is **N/A**.

### Synergy with the `pinning` feature

`numa-aware` alone is **best-effort**: if the OS migrates a thread to a
different NUMA node after its initial segment was reserved, subsequent accesses
to that segment will be cross-node.  The allocator uses strategy (a) — ignore
migration, steer only NEW reservations — as the MVP policy.

Combining `numa-aware + pinning` makes the locality **deterministic**: the
`pinning` feature (via `core_affinity`) pins each worker thread to a specific
core for the duration of its run.  A pinned thread on core *k* always resides
on the same NUMA node, so every new segment it reserves is local.

```sh
cargo run --release --example malloc_macro \
  --features "alloc-global alloc-xthread pinning numa-aware"
```

Without `pinning`, `numa-aware` helps under workloads with low thread
migration (e.g. long-lived worker threads, DBMS executors) but is not a
guarantee.  With `pinning`, locality is guaranteed for the lifetime of the
pinned run.

---

## Large-cache (OPT-E) — `alloc-decommit` required

### What was added

Feature-gated on `alloc-decommit`, `AllocCore` holds a small fixed-size
free-cache for large segments (`LARGE_CACHE_SLOTS = 2`). When a large
allocation is freed, instead of releasing the OS reservation immediately
the segment is deposited into the cache (reservation stays live, pages
stay committed — no decommit on deposit, so no recommit is needed on hit).
The next `alloc_large` of a compatible size
(`needed <= cached_size <= needed * 2`) hits the cache, skipping the OS
mmap/VirtualAlloc entirely.

**Admission policy (per-shard byte budget, #90–#95):** the original
per-span cap `MAX_CACHED_LARGE_BYTES = 64 MiB` was removed in #90 — it
prevented caching large spans on machines that have the headroom. The
cache is now byte-budget'd per shard (default unbounded — clients
override with `SEFER_LARGE_CACHE_BUDGET=…` supporting `K`/`M`/`G`
suffixes). Lazy 10 %/sec exponential decay back to `live + headroom`
(headroom default 256 MiB, env-overridable) keeps the cache bounded
without a background thread. FIFO eviction on budget overflow.

The OS reservation is released either on the next `Drop` of `AllocCore`
(if the cached segment is never reused) or when the decay/budget logic
evicts the slot.

### Numbers — `benches/large_realloc.rs`, `large_alloc_free` group

Run: `cargo bench --bench large_realloc --features "alloc-global alloc-decommit" -- large_alloc_free`

Host: Windows 10, dev machine. Numbers are medians from criterion `sample_size(10)`.

**Before OPT-E** (`--features alloc-global`, no cache):

| size  | SeferMalloc | mimalloc  | System    |
| ----- | ----------: | --------: | --------: |
| 4 MiB |   ~237 µs   |  ~753 ns  |  ~18.7 µs |
| 16 MiB|   ~657 µs   |  ~851 ns  |  ~17.5 µs |
| 64 MiB|  ~1.97 ms   |  ~2.0 µs  |  ~18.3 µs |

**After OPT-E + byte-budget admission (current state, post-#90/#94/#95):**
`--features "alloc-global alloc-decommit"`:

| size  | SeferMalloc (cache hit) | mimalloc | System   | vs mimalloc | speedup vs before |
| ----- | -----------------------: | -------: | -------: | ----------: | ----------------: |
| 4 MiB |                **~46 ns** | ~743 ns  | ~17.5 µs |  **~16× faster** |        **~5,200×** |
| 16 MiB|                **~46 ns** | ~861 ns  | ~14.6 µs |  **~19× faster** |       **~14,300×** |
| 64 MiB|                **~63 ns** | ~2.43 µs | ~16.9 µs |  **~39× faster** |       **~31,300×** |

At all three sizes the cache eliminates the OS round-trip entirely. The
cached path is: scan 2 slots (O(1)), call `table.register` (O(1) for the
recycled NULL slot), write a 96-byte `SegmentHeader` struct, return a
pointer. No syscall, no page-table work.

**64 MiB is now cached** (it was not under the original per-span cap;
#90 removed the cap). The per-shard byte budget admits any single span
as long as the budget allows it; clients who want a hard cap can set
`SEFER_LARGE_CACHE_BUDGET`. See [`ALLOC_PLAN_PHASE12-13.md`] /
checkpoint notes for the redesign rationale.

### Why pages are kept committed (no decommit on deposit)

An earlier version decommitted the payload pages on cache deposit
(`VirtualFree(MEM_DECOMMIT)`) and recommitted on cache hit
(`VirtualAlloc(MEM_COMMIT)`). On Windows, committing 8 MiB of pages costs
~50 µs regardless of the warm/cold state — essentially the same as a full
mmap round-trip. Removing the decommit/recommit pair dropped the 4 MiB hit
from ~50 µs to ~45 ns: a 1,100× additional improvement.

Trade-off: cached segments hold their pages committed between uses, increasing
RSS by `usable_size` per cached slot (max 2 × 64 MiB = 128 MiB with current
constants). For workloads that alloc/free large blocks infrequently, the
`alloc-decommit` feature without OPT-E (or a future time-based eviction) is
preferable. OPT-E is optimal for workloads with repeated large-allocation churn
at the same size class.

### Known limitation — `MPOL_PREFERRED` not `MPOL_BIND`

The current Linux implementation uses `MPOL_PREFERRED` (mode 1): the kernel
*prefers* to allocate physical pages from the requested node but falls back to
any available node under memory pressure.  This avoids OOM-abort on saturated
NUMA nodes at the cost of occasional cross-node pages.

If strict binding (`MPOL_BIND`, mode 2 — pages must come from the requested
node or OOM) is needed, switching is a one-line change in `numa.rs` and is a
planned follow-up, not part of this phase.

### RSS impact

NUMA steering does not increase or decrease the number of segments reserved —
it only influences *which physical pages* back those segments.  The RSS profile
is unchanged.  The `alloc-decommit` feature (Phase 35) remains the correct
lever for RSS reduction.