One entry per milestone/brick: what landed, the numbers with their method lines, what was reverted and which kind of revert (measured-worse vs within-noise). Newest first.
Perf, RSS and the secure feature had all been recommended without numbers.
All three measured before cutting 0.4.0. Method throughout: deterministic
COUNTS wherever a count exists (callgrind instructions retired, RSS bytes), and
where a duration was unavoidable, ABBA interleaving with a null arm.
1. Did the seven fixes cost performance? No — and this one is exact. Wall/CPU time in Docker could not resolve it (null arm read 1.0253 on means: the environment's floor is ~2.5%, wider than any effect). So the verdict comes from instructions retired, which has no noise floor:
| kernel | pristine 0.3.2 | fixed | ratio |
|---|---|---|---|
| malloc-small (single-threaded, fully repeatable) | 229,924,005 | 229,921,893 | 0.99999 |
| larson-4t | 129.6–130.9 M | 129.7–130.9 M | ranges overlap |
| xmalloc-4p | 203.3 M | 203.3 M | 1.0000 |
2,112 instructions in 230 M. Expected: the fixes add predictable early returns on the adopt/retire COLD paths and touch the malloc/free fast path not at all.
The mimalloc arm, re-run directly (oracle rebuilt from the vendored submodule; all four arms LD_PRELOADed into the SAME neutral C churn binary, so the allocator is the only variable):
| arm | instructions retired | vs mimalloc |
|---|---|---|
| glibc | 160,220,039 | 1.6835 |
| mimalloc v2.4.5 | 95,170,830 | 1.0000 |
| rusty_alloc 0.3.2 (pristine) | 107,943,033 | 1.1342 |
| rusty_alloc 0.4.0 (fixed) | 107,943,063 | 1.1342 |
fixed / pristine = 1.00000 — thirty instructions in 108 million. That is the definitive answer to "did seven fixes cost anything": no, and it is a count, not an estimate.
A caveat this measurement adds, and it is not flattering. On this allocation-CHURN microbenchmark rusty_alloc is 13.4% behind mimalloc — whereas the README's headline arms (lua/perl/sqlite under LD_PRELOAD) read 0.99–1.01. Both are true and they do not contradict: real programs dilute allocator cost among everything else they do, while this workload is almost nothing but malloc/free. The honest reading is that parity is workload- dependent, and the "at parity" claim should be read as scoped to the three real programs it was measured on — not as a general property. It also remains 33% cheaper than glibc on the same workload.
A surprising number that was WRONG, kept as a warning. The first xmalloc
reading was 4.86×. Re-run three times per arm it is 203.3 M both ways: the
outlier was a one-off scheduling artifact under callgrind. Work parity was
confirmed independently (both arms print blocks=600000, same seed). Re-verify
a surprising number before acting on it.
2. RSS — one clear win, one open question.
The decommit fix, measured directly (reserve 512 MiB, commit, touch every page, decommit, read RSS):
| decommit impl | returned to OS | contents after |
|---|---|---|
pristine MADV_DONTNEED |
6.4% (27.4 of 427.9 MiB) | 165 — STALE, contract violated |
fixed mmap MAP_FIXED |
100.1% (457.8 of 457.3 MiB) | 0 — zeroed, contract honoured |
Soaks (daemon-shaped: thread waves that exit holding live blocks, forcing abandonment, against a bounded live set):
- purge ENABLED (
purge_delay = 0), 6 min: RSS flat at 9.4 MiB, slope −0.02 MiB/min, peak 14.8. Clean. - shipped default (
purge_delay = -1, purging opt-in), 25 min, 299 samples: ~650 MiB RSS against a ~175 MiB mean live set, drifting +1.45 ± 0.70 MiB/min (least-squares, 95% CI) over the full run. The drift DECELERATES — first half +2.42 ± 2.06, second half +1.19 ± 1.87, no longer distinguishable from zero — and RSS does not track the live set (corr = +0.034), so this is retention approaching a plateau, or a slow leak, and 25 minutes cannot separate those two. NOT claimed as settled. The naive two-endpoint slope this harness printed first (+2.69) is not a sound estimator on data with 307 MiB peak-to-peak oscillation; the regression above supersedes it.
Actionable consequence: long-lived services should set purge_delay >= 0
rather than rely on the opt-in default. That is the configuration with flat,
measured RSS.
3. secure — works, and costs 4–7%. Full suite green with
--features secure; stress_mt 30/30 in release. Cost, instructions retired:
| kernel | default | secure | ratio |
|---|---|---|---|
| malloc-small | 229,921,829 | 245,439,205 | 1.0675× |
| larson-4t | 130,150,515 | 136,816,187 | 1.0512× |
| xmalloc-4p | 203,333,568 | 211,720,930 | 1.0412× |
Throughput on the alloc-heaviest kernel: 84.0 → 73.6 Mops/s. A real but modest price for guard pages + encrypted free lists on anything facing untrusted input.
Also executed for the first time this round: wasm32-unknown-unknown via
bench/wasm-selftest.mjs in a Node VM — PASSED (linear memory grew
2.06 → 64.00 MiB). The platform table said "tested in a VM self-test" on faith;
it has now actually been run.
The open P0 from the entry below is fixed. It was not a weak-memory-ordering
bug, and it was not aarch64-specific — that hypothesis (recorded below, from
the fact that the same source passed 19/20 under Rosetta's TSO) was WRONG. It is
a family of three plain use-after-frees on the abandon → adopt → reuse path,
present on every platform. x86-64 survived them because the just-munmapped
region there usually stayed mapped; native aarch64 unmaps a 32 MiB segment for
real and faults on the next touch. The correction matters: these are latent
memory-safety bugs on x86-64 Linux and Windows too, and the x86-64 arm of the
gate below improves as well.
The shape, in one sentence: three functions can RELEASE the segment they were
handed, and each returned () — so every caller kept using the pointer.
span_from_segmentsused a segment thatadopt_segmenthad released.adopt_segmentfreessegwhen it arrives empty and an empty one is already cached, or when a Huge segment's block had already died — then the caller read(*aseg).used_pagesand calledspan_alloc(aseg, …). Proof before fixing: a probe that_exit(42)s when the just-adopted pointer is the one adoption freed fired in 19 of 20 runs, with SIGSEGV dropping to 0.adopt_segmentused a segment its ownretire_spanhad released. The tail's dead-large-span retire can empty the segment and release it; the very next line reads(*seg).used_pages. This was the residual crash — lldb put it inadopt_segmentitself, at that read.retire_spanfreed a segment it had failed to unlink.remove_segmentended in a baredebug_assert!(false, "segment not in heap list"), which is compiled out in release: the caller fell through andsegment_freed a segment still linked in another list, leaving a danglingh.segmentshead that later crashedthread_done's walk. A probe that_exit(43)s on the not-found branch fired in 4 of 30 runs.
Fix: put the outcome in the type, not in a comment. adopt_segment,
retire_span and remove_segment now return bool and are #[must_use] with
a message naming the consequence. That is what makes this class non-recurring —
adding #[must_use] immediately surfaced all four remaining retire_span call
sites for audit (all four proved terminal and are annotated as such). Callers
that legitimately ignore it now say why in one line.
One premise checked rather than assumed while fixing: releasing a segment cannot
strand queued pages, because used_pages counts CARVED spans and a page is only
queued while carved — so used_pages == 0 implies none of its pages are in a bin
queue. An earlier "park it instead of releasing" attempt was built on the
opposite assumption, measured no better (36/40), and was reverted rather than
kept as a belt-and-braces change.
Gate, all on aarch64-apple-darwin unless noted, exit-code classified:
| arm | stress_mt |
|---|---|
| pristine 0.3.2 | 0 / 20 |
| + the 4 platform fixes (entry below) | 17 / 30 ABBA |
| + this UAF family fix | 30 / 30 ABBA · 100/100 release soak · 40/40 debug |
| x86-64 (Rosetta), before | 19 / 20 |
| x86-64 (Rosetta), after | 30 / 30 |
cargo test --workspace is now fully green on aarch64-apple-darwin — no
failures, no ignores beyond the pre-existing doctest — and green on
x86_64-apple-darwin; wasm32 still builds. The #[global_allocator] smoke app
(Vec/String/HashMap/BTreeMap churn, 40 MiB allocations, cross-thread frees,
8-thread waves) passes 30/30.
stress_mt IS the regression test for this family: it failed 65–100% of runs
before and is now 100/100, so a reintroduction shows up immediately.
The README's platform table said aarch64 "compiles; never executed". It was
executed, on macOS 26 / Apple Silicon (16 KiB pages), rustc 1.95.0. Four
defects, two of them memory-safety class. Baseline 0.3.2 could not run a
realistic #[global_allocator] workload on this platform at all.
Method for every rate below: the built binary run N times by a script that classifies by EXIT CODE (0 pass / 101 panic / 134 SIGABRT / 139 SIGSEGV / 137 killed-at-timeout), arms interleaved ABBA so machine-load drift hits both equally, binaries fingerprinted by sha256 before each arm — a stale binary produced one bogus reading before that check was added.
P0 — thread_id() read the wrong register (memory-safety). The aarch64 arm
read tpidr_el0, which is the thread pointer on Linux/Android/BSD but NOT on
Darwin: Apple puts the thread pointer in tpidrRO_el0 and uses tpidr_el0 for
the CPU/cluster id. Measured directly: tpidr_el0 returned small non-pointer
values (0x1002, 0x2005…), took 5 distinct values within ONE thread over 3M
reads as it migrated cores, and 8 live threads produced only 5 distinct values
— distinct threads collided. thread_id() is the ownership identity behind
segment.thread_id, so a collision routes one thread's free down the owner
(unsynchronised) path into another thread's segment. Fixed to
tpidrro_el0 & !0b111 (Apple documents the low 3 bits as the CPU number — which
is exactly the observed drift). New standing gate tests/thread_identity.rs
asserts stability-within-thread and uniqueness-across-live-threads; verified to
FAIL against the old register before being accepted.
P1 — subprocess isolation silently lost at teardown. abandoned_push read
my_subproc() from a Rust thread_local! while running inside the
pthread_key_create destructor. Destruction order between a platform TLS
destructor and Rust's own TLS is unspecified everywhere, and is observably wrong
here: it read back 0, so a thread tagged into subproc N abandoned its segments
into the MAIN subproc. Probe: ABANDONED_COUNT was 1 (the abandon fired
correctly) but the segment sat on list 0, not list 1. Fixed by mirroring the tag
onto HeapBox::subproc — the box is alive for all of teardown because it is the
value passed to the destructor — and passing it into abandoned_push. Now
correct on every target rather than accidentally correct on one.
P2 — decommit never returned memory to the OS. MADV_DONTNEED is only
advisory for private anonymous memory on Darwin: it neither frees the physical
pages nor zeroes them, so purge was a no-op and RSS only ever grew (the
abandonment purge path is on by default, so this was live). It also violated the
documented "contents are lost" contract — caught by prim.rs reading back 42.
Nothing trusted that yet (free_is_zero is conservatively cleared on purge) so
it was not a disclosure bug, but it was a landmine. Fixed with a MAP_FIXED
anonymous re-map over our own range, which drops the physical pages and installs
zero-fill-on-demand ones, matching the Linux contract exactly.
P3 — process_info RSS wrong on Darwin, in both directions. current_rss
read /proc/self/statm (no procfs on macOS → 0), and ru_maxrss was scaled
×1024 as KiB when macOS/BSD report it in BYTES — a plausible-looking number
1024× too large, in the exact field the README lists as unmeasured. Both replaced
with one task_info(MACH_TASK_BASIC_INFO) call, which reports resident_size
and resident_size_max in bytes.
Result. Whole suite green on this platform except stress_mt. Realistic
#[global_allocator] workload (Vec/String/HashMap/BTreeMap churn, 40 MiB
allocations, cross-thread frees, 8-thread waves), ABBA-interleaved, n=20/arm:
| arm | pass |
|---|---|
| pristine 0.3.2 | 1 / 20 (19 × SIGSEGV, dies at the cross-thread-free stage) |
| with these four fixes | 20 / 20 |
[CLOSED by the entry above — and the diagnosis below was WRONG: it is a plain use-after-free family, present on every platform, not a weak-memory bug.]
STILL OPEN (P0-class, aarch64-native only). stress_mt's abandon → adopt →
reuse storm still crashes: release 7/20 pass, 11 × SIGSEGV, 1 × SIGABRT, 1 hang.
Consistent crash site Heap::span_from_segments, EXC_BAD_ACCESS on addresses
sharing a low offset with differing high bits (a walk off a stale/unmapped
segment), plus debug_assert!(false, "segment not in heap list") at heap.rs:524
and :977. Two experiments narrow it:
- Not the thread-pointer path. Forcing apple-aarch64 onto the safe
pthread_selfcached-TLS id leaves the rate unchanged (7/20 pass), so P0's fix is necessary but not sufficient — the remaining bug is elsewhere. - It passes under Rosetta. The same source built for
x86_64-apple-darwinpasses 19/20. Rosetta emulates x86 TSO, which is the signature of a WEAK-MEMORY-ORDERING bug in the lock-free abandon/adopt protocol — invisible on every platform tested so far (x86-64 Linux and Windows are both TSO), and only ever visible on genuinely weakly-ordered hardware. The alternative hypothesis, a 16 KiB-page geometry assumption, is NOT excluded: Rosetta also uses 4 KiB pages, so that variable moved too.
Note tests/loom_xthread.rs models the delayed-free/abandon PROTOCOL but not the
segment adopt path or the heap segment list, which is where the crash sits —
extending the model there is the obvious next probe.
Also fixed: a test that hardcoded a 4 KiB page. bins::known_size_classes
asserted good_size(65537) == 69632. Above MEDIUM_OBJ_SIZE_MAX good_size is
page-rounded, so the correct value is 81920 on a 16 KiB-page host. Split into a
property test deriving the expectation from os::page_size().
Continuing the M9 win. The probe had already priced prim::thread_id(); the
remaining per-free work was a pile of separate loads answering one question —
"is this a plain binned page I can just push onto?":
has_aligned, bin == BIN_HUGE, in_full, and the segment's kind (a u32
compared against two magics, with a two-arm match on BOTH the page lookup and
the block recovery).
Brick #3: one Page::flags byte (HAS_ALIGNED | SINGLE_BLOCK | IN_FULL | HUGE_SEGMENT). The free path now does ONE page resolution and ONE flags load;
the SegmentKind match is gone from the hot path entirely (a huge segment's
interior slices already offset back to slot 1, so page_of covers both kinds).
in_full/has_aligned bools were removed — the byte replaces them, so the
Page struct did not grow.
Work parity proven before any timing was read (§4): baseline and new binaries report byte-identical counters on the same workload — allocs 10 002 036 = frees, generic 604 676, pages_fresh 132, segments 1, extends 553. Both arms do exactly the same work, so the comparison is valid.
Gates: Windows all-features green, Linux 21 suites / 0 failures, clippy
-D warnings, fmt.
Harness defect fixed along the way: bench/pinvs.ps1 would not parse —
Windows PowerShell 5.1 reads a UTF-8 file as Windows-1252, and the em-dashes
in the comments turned into a parse error several lines later. THE timing
harness is now ASCII-only with a banner saying why. A harness that does not
run is a discipline that does not exist (§13).
Numbers (pinned, CPU time, ABBA, same workload, work-parity verified) — and the honest conclusion: THE CLOCK CANNOT RESOLVE THIS ON THIS BOX.
| run | arms | pairs | median B/A | min B/A | win rate | z |
|---|---|---|---|---|---|---|
| short | ~1 s | 21 | 0.908 (B faster) | 0.943 | 12/21 | +0.65 |
| long | ~7 s | 31 | 1.069 (B slower) | 1.129 | 11/31 | −1.62 |
The sign flipped. Neither run resolves (|z| < 2 both times), and the within-arm spread gives the game away: arm A's own median was 7234 ms against its own minimum of 5812 ms — a 24% swing inside a single arm, on a box running two VS Code instances, a browser and Task Manager (checked, per the go-find-the-process rule). A 5-10% effect is simply not measurable through 24% of noise, and a result that changes sign with arm length is not a result.
NULL ARM — the session's noise floor, measured, not assumed (§3). The SAME binary against ITSELF, 21 pairs, ~7 s arms, identical method:
A: median 7046.9 ms min 5703.1 ms
B: median 6828.1 ms min 5875.0 ms
ratio of medians 0.9690 | ratio of mins 1.0301 | 11/21 | z = 0.22
Identical code measured 3.1% "faster" by median and 3.0% "slower" by min — the two statistics disagree in SIGN on a null comparison. That is the resolution limit of this machine, and it retires the earlier readings on the spot: a floor of ±3% cannot adjudicate a 5-10% claim whose own two runs disagree by 16 percentage points (0.908 vs 1.069). The between-run conditions moved, not the code. Every number in the table above is hereby marked inadmissible; the null arm is why we run it before believing anything.
Decision: KEEP the bricks, label them below instrument resolution. This is the §15 rule, not a rationalisation: for effects the clock cannot resolve, the deterministic evidence is primary and the clock is confirmatory. The deterministic evidence here is strong and independent of this box:
thread_idmeasured 1.41 ns vs 0.25 ns cached, reproduced to within 0.02 ns on Windows AND Linux — a mechanism, not a reading.- Bricks #2 and #3 remove work by construction: a mask, a shift and two loads (brick #2) and three loads plus a two-arm match (brick #3) per free, with byte-identical counters proving the same work is performed either way.
- Nothing was added; the Page struct did not grow.
I shipped a segfault. FFAI bisected it in one pass: 0.3.0 clean 0/8, 0.3.1 crashing 6/8, identical build and workload, allocator version the only variable. Reproducible with five JPEG decodes, single-threaded.
collect(force) began reclaiming abandoned segments. But both teardown paths
call a forced collect:
init.rs:509 thread_done -> h.collect(true)
init.rs:597 heap_delete -> h.collect(true)
So a thread on its way out adopted every orphan from previously-dead threads
into the heap it was about to destroy, re-homing their pages onto a
DelayedList freed moments later. Use-after-free.
The decisive detail: more threads made it LESS frequent — 1 thread 5/6, 2→3/6, 4→4/6, 16→1/6. Backwards for a race, exactly right for "a dying thread swallows the orphan pool": with more live heaps, orphans get adopted by a LIVING thread first. Everything else follows — every crash needs a thread exit (60 detects on a preloaded image: 0/6), PNG is clean because of its thread lifecycle not its allocation shape, and buffer size is irrelevant (640x480 and 1920x1080 both 6/6).
collect_for_teardown() — forced collect that does not reclaim. Both
teardown sites use it; mi_collect(true) on a LIVE heap still reclaims.
tests/teardown_reclaim.rs passes 4/4 with the bug deliberately
reintroduced. It is not a guard, and it is labelled as such in its own header.
The missing ingredient is almost certainly CROSS-THREAD frees: every thread in
it frees its own blocks, so nothing remote is pushing onto the dying heap's
DelayedList — which is what makes a re-homed xheap a live target. The fix
is reasoned and matches every observation, but it is not test-proven.
FFAI's repro is the only thing that has reproduced this.
- A caret requirement made this automatic. FFAI had
rusty_alloc = "0.3.0", which silently resolved to 0.3.1 on publish, mid-session, under an already-validated lockfile. They shipped a segfaulting default for several commits without touching a version string. - Nothing gated
collect(force). Not one test called it, which is exactly why the earlier_force-is-ignored bug survived to be found by a reader rather than a gate. I fixed the ignored parameter and added no test for it. - The teardown call sites were never checked. I searched for who called
collectwhen diagnosing FFAI's report, saw both teardown paths, and still changed the shared implementation.
Recommendation: YANK 0.3.1.
FFAI reported two things against 0.3.0. Both were real.
1. Stale M2 comments. rusty_alloc_api/src/lib.rs:129 said
"(M2: one global locked heap)" and heap.rs:3 said "M2: ONE global heap
behind a lock" — describing an architecture removed in M4. Anyone reading
either would conclude free serialises on a global lock. Corrected.
2. collect ignored force — the signature was literally _force: bool.
So mi_collect(true) was a per-heap page sweep and nothing else: it never
reclaimed an abandoned segment. That is a large part of why a caller's "trim"
measures ~0% — there was nothing in the forced path to reclaim WITH. force
now adopts every orphan first, so the bin sweep retires their dead pages in the
same pass.
A forced collect should also return pages to the OS, so the first version
purged every free span. It crashed the test suite with an access violation
(0xC0000005). Cause: span_free purges only spans of
len >= MEDIUM_PAGE_SLICES, so purging smaller ones reaches spans whose reuse
path does not re-commit them — the M8 defect exactly (Windows MEM_DECOMMIT
faults on touch; Linux MADV_DONTNEED does not, which is why this class keeps
being Windows-first). Reverted. A forced purge needs the recommit path audited
before it can ship.
Kept: reclaim-on-force. Gates green — Windows tests + clippy, Linux GATE PASSED, churn 3/3, wasm, and speed unchanged (lua 0.9883, perl 1.0060, sqlite 1.0037).
FFAI, N=5, one program, trim the only variable:
| arm | RSS med | RSS min | RSS max | latency |
|---|---|---|---|---|
| mimalloc | 111.1 | 106.7 | 134.2 | 31.97 ms |
| rusty no-trim | 195.8 | 92.2 | 403.3 | 30.63 ms |
| rusty trim200 | 195.8 | 91.2 | 402.5 | 30.49 ms |
The finding is the SPREAD, not the median. Our MINIMUM (92.2) beats mimalloc's (106.7); our max is 3x theirs; spread 4.4x against their 1.26x. A retention-policy difference shifts a distribution — it does not stretch one. And 92->403 MB is roughly ten 32 MiB segments, so something timing-dependent decides how many the process holds. Latency is FINE (we are 1.4 ms faster).
Trim reclaiming 0.1 MB (0%) is not a sampling artifact: trim walks a heap's own free spans and cannot see the abandoned list at all, so it would read 0% at any N.
tests/abandon_rss.rs: 8 waves x 8 threads that allocate spans and exit.
25 segments abandoned; a following 2048-block allocation burst adopted 4.
At 32 MiB each that is the tail, and whether anything adopts is pure
scheduling — exactly the shape of a 4.4x run-to-run spread.
The option existed in the table by name only, defaulted to 0, and nothing
read it. So an orphan kept every page it had ever touched, resident,
indefinitely. segment::purge_free_spans now decommits a segment's free spans
at the last instant the dying thread still owns it. Deliberately NOT gated on
purge_delay: that governs a LIVE heap's spans, which are likely to be reused
shortly, whereas an orphan has no owner to reuse anything.
span_from_segments capped adoption at tries < 2, then took a fresh 32 MiB
segment while orphans sat unclaimed. Now each adopted segment is tried
immediately and the loop runs until the request is met or the list is empty
(cap 32, a stall guard rather than a reclaim budget).
Neither fix is shown to reduce RSS yet, because no probe here reproduces the
shape. The count probe measures orphans, not bytes; bench/churn.c has a
4.9 MiB working set (where we already use 7x LESS than mimalloc); the
long-lived-thread sweeps never abandon anything. Fix 2 also did not move the
orphan count, and the reason is instructive: adoption only triggers on a
segment MISS, so a thread with room in its own heap never reaches the
abandoned list however generous the cap.
Speed is unaffected — lua 0.9908, perl 1.0060, sqlite 1.0037, unchanged, and neither fix touches the fast path.
Validation must come from FFAI's workload, which is the only thing that has
reproduced the tail. The A/B is free: MIMALLOC_ABANDONED_PAGE_PURGE=0 vs 1
on the same binary, N=5, comparing max and spread rather than median.
External measurement (FFAI/Diana, their harness, their null arm): speed at exact parity (1.015x wall, CPU 1.000x), peak RSS 91.4 MB vs mimalloc's 77.5 MB = +17.9%, null arm 77.4 so the gap is real. First independent numbers the project has ever had, and they land on the gap the audit called most likely to surprise us — we have no RSS gate at all.
bench/rss.sh initially had NO NULL ARM. It returned 62.6, 62.8 and 51.6
MiB for the same binary — an 11 MiB swing that silently invalidated three
conclusions drawn from it. Cause: perl randomises its hash seed per process,
so every run allocated a different pattern. Pinning PERL_HASH_SEED=0 collapsed
the spread to 0.2 MiB.
Everything measured before that fix was retracted, including a confident "purging is the cause" claim. The rule this project already applies to time applies to memory: no null arm, no result.
| probe | result |
|---|---|
| single thread, perl cycling | rusty_alloc 51.8 vs mimalloc 52.9 — we are BETTER |
| thread sweep 1 -> 28 | RSS scales LINEARLY with threads for both; at 28, ours 122.8 vs 228.2 MiB |
| size sweep 32 KiB -> 4 MiB | −44% at every size |
| alignment 0 / 32 / 64 / 4096 B | −44% at every alignment |
Per-thread heap retention IS the dominant RSS term — confirmed
independently, and it matches FFAI's scaling.rs finding (28 heaps retaining
174 MiB against 26.3 MiB live). But on every synthetic form of that mechanism
we retain roughly HALF of mimalloc, so our retention is not yet implicated in
their +17.9%.
Nothing synthetic reproduced the gap. Untested, in order of suspicion:
- Mixed lifetimes — model weights held for the session while activations cycle. Every probe here frees everything each round, so none of them exercises fragmentation.
- Thread lifecycle churn — probes use long-lived concurrent threads; candle's pool may create/destroy, routing through abandonment/adoption.
- Rust
deallocpassing aLayout(size AND align) where Cfreepasses only a pointer.
Next step is not more guessing: run FFAI's own scaling.rs with
rusty_alloc as the arm. It already produced the 174-vs-26.3 number, so it
measures per-thread retention under Diana's real behaviour — exactly what these
probes failed to synthesise.
- Purging (
purge_delay: -1, off by default) recovers ~2 MiB of ~10 single-threaded and nothing multi-threaded (122.8 vs 122.4). It is worth enabling but it is not the gap. - Deferred retire — keeping emptied pages queued so the next round reuses the same memory instead of first-fitting elsewhere. Span re-carve churn is REAL (504 pages retired and re-carved per round, 3,050 carves for a ~530-page working set, segment count flat) but changing it moved RSS not at all. Reverted.
New probes, all reproducible: bench/rss.sh (null arm + pinned seed),
bench/rss-threads.{c,sh}, bench/rss-sizes.sh,
crates/rusty_alloc/tests/rss_churn.rs.
Fix: wait_no_remote_in_flight(seg) — spin until no page of the segment
has XFLAG_FREEING set — called on every path by which memory can reach an
arena.
The first attempt guarded only segment_free and Miri still failed,
identically. That refutation was the useful part: it proved the racing path
was elsewhere. huge_free recycles a huge segment through chunk_free_n
WITHOUT passing through segment_free, so guarding one choke point left the
real hole open. Both are guarded now.
Why a barrier is sufficient rather than an epoch scheme: before a remote sets
FREEING it has not yet pushed to the delayed list, so the owner cannot have
drained it, so used > 0 and no retire is possible. Every dangerous instant
therefore has FREEING observably set.
Verified: cargo +nightly miri test -p rusty_alloc (isolation ON, the whole
target) exits 0 — stress_mt::abandon_adopt_reuse_storm included, which is the
test that caught it.
LESSON — the one that matters most here: the audit's corpus/miri-gate.sh
ENUMERATED suites (alloc_core spans heaps secure prim) and therefore silently
omitted stress_mt, the only multi-threaded one. It then recorded "Miri clean"
on that basis. CI ran the whole target and found a use-after-free on the first
green-field run. Never let a gate enumerate what it should sweep.
Found by CI, minutes after publishing 0.1.0-alpha.1. Miri's data-race
detector on stress_mt::abandon_adopt_reuse_storm:
Undefined Behavior: Data race detected between
(1) atomic store page.rs:430 thread `abandon_adopt_reuse_storm`
(2) non-atomic write segment.rs:535 thread `unnamed-8`
at alloc57912+0x8000060
- (2) is
huge_allocscrubbing a recycled arena chunk:write_bytes(seg, 0, size_of::<Segment>())— which zeroes the whole header, including every page slot'sxthread_freeatomic. - (1) is
remote_free's restore-DELAYED loop doing acompare_exchange_weakon(*page).xthread_free— a page inside that very segment.
So a segment was released, recycled through the arena, and re-tenanted as a
huge allocation while another thread was still mid-remote_free on one of
its pages. That is a use-after-free, and the write that lands on it is a
memset of the whole header.
This is the same FAMILY as the M8 P0 (guard pages recycled while still
PROT_NONE): a segment reaching an arena while something still references it.
The four-state protocol has FREEING precisely to stop teardown racing a remote
free — page_set_flag spins it out — so the gap is a teardown path that
reaches segment_free WITHOUT passing that gate. Not yet localised.
Miri was never run against stress_mt. The audit added corpus/miri-gate.sh
with the suite list alloc_core spans heaps secure prim — the multi-threaded
suite was not in it, and the audit entry even recorded "Miri clean" on that
basis. CI runs cargo +nightly miri test -p rusty_alloc, which runs
everything, and caught it on the first green-field run. The lesson is exact:
a Miri gate that enumerates suites will silently omit the one that matters;
run the whole target.
0.1.0-alpha.1is published on crates.io with this defect. Recommendation: yank (cargo yank --version 0.1.0-alpha.1) for bothrusty_allocandrusty_alloc-api. Yanking blocks new dependents while leaving existing builds working; it is reversible.- Reproduce:
cargo +nightly miri test -p rusty_alloc --test stress_mt(isolation ON, i.e. no-Zmiri-disable-isolation). - Blast radius: multithreaded programs that abandon threads AND allocate huge blocks. Single-threaded use is unaffected.
- Fix will need the loom model that built the protocol, not a point patch.
- Clippy never ran on Linux.
c_longisi64on LP64 unix andi32on Windows, so fouras c_longcasts are "unnecessary" on Linux and load-bearing on Windows.corpus/linux-gates.shnow runs clippy too — running it only on Windows was a genuine hole. double_free.rscannot run under Miri —current_exe()needsreadlink, blocked by isolation, and Miri cannot spawn the child anyway. Now#[cfg_attr(miri, ignore)].- Stale oracle path in
ci.yml(out/mivs the OS-namespacedout/linux/mi), plus a wasm job that executes rather than only compiles.
The known limitation recorded in the audit is fixed. page_push_local did
(*page).used -= 1 with no guard, so freeing a block twice wrapped used to
u32::MAX: the page never retired and the same block sat on the free list
twice, so a later pair of malloc calls handed the SAME memory to two owners.
Release builds accepted this silently; upstream mimalloc does too.
Now detected via a sign test on the post-decrement value — legitimate used is
always far below i32::MAX, so a negative reading can only be the wrap — and
the process aborts. Proven by tests/double_free.rs, which re-executes its own
binary in a child so the abort can be observed rather than assumed.
It costs real performance and that was the deliberate call:
| perl | sqlite | |
|---|---|---|
| M16 (no detection) | 1.0021 | 1.0018 |
| with detection (shipped) | 1.0062 | 1.0037 |
~4 Ir per free. Two forms were tried; both cost the same, and the disassembly
confirms the ideal dec eax; js sequence — the cost is the load/store around
it, because reading used back after the store prevents LLVM from keeping the
whole thing as a single dec [mem]. Kept anyway: an allocator whose premise is
memory safety should not hand the same block to two owners to save 0.4%.
publish = falseon_ffi,_override,_bench,_wasm— harnesses, fixtures and native artifacts, not libraries. Onlyrusty_allocandrusty_alloc_apigo to crates.io.- LICENSE added (MIT), naming the dev-only vendored trees explicitly:
oracle/mimallocandcorpus/mimalloc-benchare outside every published package directory and never ship. - Per-crate READMEs for the two published crates;
cargo packageverified at 32 files / 311.9 KiB — no oracle, no corpus, no target. - A stray zero-byte file named U+F03A (an unprintable private-use character) was sitting at the repo root, an artifact of an earlier shell redirect. Removed before it could be committed.
- Version set to
0.1.0-alpha.1, with the reason in the manifest: the allocator is done, the evidence is not.
bench/wallclock.sh — pinned, ABBA-interleaved, N=31, medians AND minima, with
a null arm (the same allocator against itself) as the floor.
The first version was WRONG and said so loudly: every min came back 0.0 ms,
because /usr/bin/time reports at 10 ms granularity — on a 300 ms workload
that is ~3%, coarser than the ~0.5% effect being measured. Replaced with
microsecond EPOCHREALTIME and workloads scaled to >1 s so per-run fixed costs
fall below the noise. (A SECOND harness bug survived that: min was still
0.0 ms because the accumulators start empty and grow with " $x", so the list
had a leading space that sort -g ranked as zero. awk NF now drops it —
a whole statistic had been silently dead across two consecutive runs.)
The result, N=31, pinned, microsecond timer:
| arm | median ratio |
|---|---|
| null (rusty_alloc vs ITSELF) | 1.0117 |
| perl, ra vs mi | 1.0009 |
| sqlite, ra vs mi | 1.0091 |
The null arm is 1.17% — wider than either effect. The same allocator compared against itself differs by more than the difference we are trying to detect. The only conclusion this instrument supports is "at parity, below measurement resolution", and that is what the README says. The wall-clock debt carried since M9 is now paid in the only currency available: we ran it, and it says the question cannot be answered on this machine.
cargo package -p rusty_alloc succeeds (32 files, 312.6 KiB — no oracle, no
corpus, no target). rusty_alloc_api fails with "no matching package named
rusty_alloc" until the core is actually on crates.io — expected, not a
defect. Publish rusty_alloc first, then rusty_alloc_api.
free opened with push r15; push r14; push rbx and closed with the pops —
six instructions of callee-saved traffic on a fast path that uses none of it.
Three previous attempts failed:
#[inline]+ 5-arg cold split — worse.- 5-arg cold split alone — worse.
#[cold]onretire_emptied— worse, and it ADDED back a push.
All three attacked the same thing: where the code lives. The fourth attacked
what stays LIVE. Registers get saved because a value must survive a call, and
the only call on the fast path is the retire branch — which took
(seg, pg). seg is dead the moment page_of finishes; it was being kept
alive across the entire fast path purely to serve a branch taken on 1.6% of
frees. Passing only pg and re-deriving seg inside the cold function (one
mask) lets it die immediately:
- 25b20: push r15 / push r14 / push rbx ...
+ 25c60: test rdi,rdi <- no prologue at all
The fast path now fits entirely in caller-saved registers.
| perl | sqlite | lua | |
|---|---|---|---|
| after M15 | 1.0044 | 1.0029 | 0.9865 |
| after M16 | 1.0021 | 1.0018 | 0.9841 |
perl is 0.21% from parity, sqlite 0.18% — and both now beat glibc (0.822 / 0.995).
The transferable rule, which cost four attempts to learn: to remove a prologue, shorten LIVE RANGES, not function bodies. Splitting code out does nothing if the split still threads hot values through its signature — and both M13 and M16 landed only once the cold function's argument list was cut to the single value it could not re-derive.
Gates: Windows tests exit 0, clippy -D warnings exit 0, Linux GATE PASSED
(23 suites), churn 5/5 clean, Miri clean — alloc_core 11 passed, spans
and heaps 1 each, zero UB and zero leaks. That run specifically clears the
new segment_of(pg.cast()) derivation: pg points into (*seg).pages, so
masking it back to seg stays inside the same allocation and with_addr
preserves the provenance.
Worked the four sized levers. The malloc side paid, twice.
M14's re-split showed the deficit had gone even (free +5.1 Ir/op, malloc +5.1) while every recent brick had targeted free. Reading our malloc fast path against upstream's found a structural difference:
let p = self.direct[w];
if !p.is_null() { // <- upstream has NO such test
let b = page_pop(p);
if !b.is_null() { ... }
}Two tests — "is there a page?" then "did it yield a block?" — where mimalloc
has one. Its pages_free_direct slots never hold null; an empty slot points at
a shared empty page (_mi_page_empty) whose free list is permanently null,
so popping from it returns null and falls through to the generic path exactly
as an exhausted real page does. The two questions collapse into one.
Ported as Page::empty_sentinel() + page::EMPTY_PAGE, published by
update_direct whenever a bin's queue is empty. block_size/slice_count are
1-ish rather than 0 only so the debug_checks validator accepts it. Sound as a
shared immortal static because page_pop returns BEFORE its first store when
free is null — nothing ever writes it.
heap.rs:malloc fell 6.03 -> 4.03 Ir/op, exactly the two deleted
instructions.
| perl | sqlite | lua | |
|---|---|---|---|
| after M14 | 1.0060 | 1.0037 | 0.9883 |
| after M15 | 1.0044 | 1.0029 | 0.9865 |
sqlite is now 0.29% from parity — about 920K instructions of 317.8M.
#[inline] on alloc::free, retested because M13 moved the general path out of
line and the body is now much smaller — the exact condition that was blamed the
first two times. Still a loss: perl 1.0044 -> 1.0055, sqlite 1.0029 -> 1.0033.
Three attempts, three refutations; treat it as settled and do not try a
fourth time without a genuinely new mechanism.
| lever | before | now |
|---|---|---|
| malloc-side deficit | +5.1 Ir/op | +2.1 (M14 + M15) |
| free-side remainder | +5.1 Ir/op | +5.1 — now the larger half |
Page 80->64 |
~1 Ir/op | confirmed ~1: the follow-back multiply is already gone, only the forward idx*80 lea-chain remains. Large refactor, small prize — deprioritised. |
| aligned fast path (P2) | +21 Ir/op | unchanged; rare in the verdict workloads, ~0 whole-program |
Gates: Windows tests exit 0, clippy -D warnings exit 0, Linux GATE PASSED
(23 suites), churn 3/3 clean.
Post-M13 the remaining gap was split EVENLY — free +5.1 Ir/op, malloc +5.1 —
and malloc had barely been examined. The breakdown named the culprit
immediately: init.rs:malloc cost 4.00 Ir/op, purely locating the heap.
M10c's TLS slot resolved the address the obvious way: load the offset from the
GOT, read the thread pointer from fs:0, add, dereference. Four instructions.
But x86 does that addition in the addressing mode — fs:[reg] is a
segment-relative load, so the explicit fs:0 read and the add both vanish:
mov {t}, qword ptr [rip + __ra_tls_heap@GOTTPOFF]
mov {o}, qword ptr fs:[{t}]
M10c chose the four-instruction form deliberately, to keep the address
computation pure and CSE-able. That reasoning was wrong in practice: the
profile shows heap_box is called once per malloc, so there was never anything
to CSE — the optimisation paid for a benefit that could not occur.
| perl | sqlite | lua | |
|---|---|---|---|
| before | 1.0067 | 1.0041 | 0.9917 |
| after | 1.0060 | 1.0037 | 0.9883 |
Gates: Windows tests exit 0, clippy -D warnings exit 0, Linux GATE PASSED
(23 suites), churn 3/3 clean.
M12 left six instructions of callee-saved traffic (push r15/r14/rbx + pops)
at the top of free, on a fast path that needs none of it. A cold-split had
ALREADY been tried and measured worse, so the question was whether the idea was
wrong or the implementation was.
It was the implementation, and the signature was the whole difference. The
failed attempt threaded all five already-computed values
(seg, pg, p, flags, local) into the cold function — putting five registers of
argument setup ON THE HOT PATH to serve 1.6% of frees. The version that works
passes only p, which is already in the argument register, and re-derives
segment/page/flags inside. The ~10 instructions of re-derivation are paid on
1.6% of frees; the hot path pays nothing.
| perl | sqlite | batch_lifo | |
|---|---|---|---|
| before | 1.0077 | 1.0045 | 70.98 (+11.28) |
| 5-arg split (earlier) | 1.0106 | 1.0060 | — |
| 1-arg split (kept) | 1.0067 | 1.0041 | 69.98 (+10.28) |
push r15 is gone from the prologue (the remaining third push is stack
alignment, not a register save). batch_lifo is down to 1.172x from 1.256x
two bricks ago.
Also tried and REVERTED: #[cold] on Heap::retire_emptied. The theory was
sound — it is #[inline], it runs only when a page empties, and its tree
(retire_span -> span_free -> segment_free) is large, so it looked like the
reason the fast path was provisioned for so many registers. Measured: perl
1.0067 -> 1.0084, sqlite 1.0041 -> 1.0048, and free's prologue gained back
the push r15 the change was meant to remove. Reverted; baseline restored
bit-exactly (sqlite 318,176,956).
That is three attempts at these six instructions: two refuted, one kept. The transferable part is that "split the cold path out" is not one idea — its signature decides whether the cost lands on the hot path or the cold one.
Gates: Windows tests exit 0, clippy -D warnings exit 0, Linux GATE PASSED
(23 suites), churn 3/3 clean.
The previous entry located the biggest single item in our free path: Page is
80 bytes, not a power of two, so page_of's span follow-back scaled a slice
count by 80 — neg; lea; shl before the subtract, 11.02 Ir/op of pointer
arithmetic, 32% of our whole free cost.
The obvious fix was to shrink Page 80 -> 64, which needs a POINTER to
disappear and is a large, risky refactor. Reading upstream first found a much
cheaper route to the same instructions. mimalloc's field is documented as
"the slice_offset is the byte offset back to the first slice" and
mi_slice_first is a plain byte subtract. Ours stored SLICES and paid the
scale on every free. Storing bytes deletes the multiply without touching the
struct's size at all.
Verified in the shipped artifact, not assumed — the follow-back went 7 instructions -> 4:
- lea rcx,[rsi+rax*1] ; movzx ; neg rax ; lea rax,[rax+rax*4] ; shl rax,0x4 ; lea rbx,[rcx+rax*1] ; movzx
+ lea rbx,[rsi+rax*1] ; movzx ; sub rbx,rax ; movzx
Results — every workload improved, and sqlite now beats glibc too:
| workload | before | after | vs glibc |
|---|---|---|---|
| lua | 0.9926 | 0.9898 | 0.833 |
| perl | 1.0098 | 1.0077 | 0.827 |
| sqlite | 1.0056 | 1.0045 | 0.997 |
| batch_lifo | 73.98 (+14.28) | 70.98 (+11.28) | — |
| mixed | 150.18 (−7.59) | 146.18 (−11.60) | — |
3 Ir/op off batch_lifo — exactly the three deleted instructions. The
batch deficit is down from 1.256x to 1.189x.
The slice_offset range is now guarded by a const assert:
(SLICES_PER_SEGMENT-1) * size_of::<Page>() <= u16::MAX (40,880 of 65,535),
so a future Page growth fails the build rather than silently truncating.
Gates: Windows tests exit 0, Linux GATE PASSED (23 suites), churn 3/3 clean.
Still open on this path: the six instructions of callee-saved traffic
(push r15/r14/rbx + pops) at the top of free. Moving the general path out of
line to relieve it was tried and measured WORSE (see previous entry) — the
argument setup costs more than the saves. The Page 80->64 shrink also remains
available and is now worth less, since the follow-back multiply — its main
prize — is already gone.
Worked the docs/plans/opscan_v1.md plan. Net code change: none. Everything
proposed was refuted, and the refutations are the result.
P1 died on its own count, before a line was written. Generic-path entries
per 100,000 allocations: ours 1,566, mimalloc 1,562 (batch_lifo); on
aligned, 6,254 vs 6,250. We do NOT leave the fast path more often than
upstream, so the extend-policy change P1 proposed was wasted work that would
have traded RSS for nothing. This is the count-before-code rule paying for
itself for the second time this campaign.
Two follow-on bricks measured worse and were reverted. The count did show
our exported free making a real call into alloc::free (100,082 per 100,000)
where upstream's is one flat symbol, and alloc::malloc had #[inline] while
alloc::free did not — a tidy-looking asymmetry.
| batch_lifo | perl | sqlite | |
|---|---|---|---|
| baseline | 73.98 | 1.0100 | 1.0056 |
#[inline] + cold-split |
74.98 | 1.0105 | 1.0060 |
| cold-split ALONE | — | 1.0106 | 1.0060 |
The first brick changed TWO things at once; isolating the second run showed the cold-split was the harmful half, which refuted the register-pressure theory rather than leaving it plausible. Both reverted; baseline restored bit-exactly (sqlite 1.0056).
WHY WE LOSE ON A LIVE WORKING SET — answered. It is a fast-path COST
problem, not a slow-path FREQUENCY one (the counts above prove the frequency is
identical). Per-operation on batch_lifo: our free 34.1 Ir/op vs 25.0, our
malloc 21.0 vs 16.9. The disassembly names the biggest single contributor:
Page is 80 bytes, and 80 is not a power of two. Slice indexing emits a
lea/lea/shl chain for idx * 80 and again for the slice_offset
follow-back, measuring 11.02 Ir/op — 32% of our entire free cost. Upstream
pads mi_page_t deliberately, commented "improve page index calculation"; we
never did.
Next brick, sized but NOT built: shrink Page 80 -> 64 bytes. 128 is
impossible (512 x 128 = the whole 64 KiB slice, no room for the segment header).
Cutting 16 is the difficulty: block_size->u32 (−4), heap_tag->i16 (−2),
free_is_zero+purged into the flags byte (−2) gets 8; the other 8 needs a
POINTER to go, and next/prev are load-bearing for cross-segment page queues.
P2 aligned: mechanism found, not built. The plan's guess (we lack a
natural-fit fast path) was WRONG — we have one, and both sides fast-path 93.75%.
The real cost is that ours proves alignment via bins::good_size(size) and then
malloc(size) recomputes the same bin, where upstream tests the actual next
free block with one AND. Real, but posix_memalign is rare in the verdict
workloads so whole-program value is ~0. P3 usable: 32 vs 30 Ir, correctly
last, not attempted.
Gates: Windows tests exit 0, clippy -D warnings exit 0, Linux GATE PASSED (23
suites), perl 1.0098 / sqlite 1.0056 / lua 0.9926 — unchanged.
Built and ran a side-by-side per-operation comparison. Full method, table and
ranked plan: docs/plans/opscan_v1.md. Two things belong in the ledger.
A symbol-by-symbol diff is not possible against release mimalloc. It inlines
the whole allocator into three symbols (free, malloc,
mi_page_free_list_extend). There is no mi_free_block_local or
mi_segment_page_of to line up against ours. So the scan compares
operations, via one C driver run under each allocator by LD_PRELOAD.
Two of three estimators were disqualified, and the reasons are reusable.
Per-object attribution under-counted us ~4x, because callgrind_annotate
ELIDES the [object] suffix on continuation lines — mimalloc's three fat
symbols each keep it, our cost is spread over many file:function lines that
lose it. Caught by a SIGN disagreement with the attribution-free estimator.
The repaired version then reported our allocator at 115.79 Ir/op on an op where
the whole process spends 82.37 — impossible, rejected on arithmetic alone.
The admissible estimator is the one with no attribution step:
(Ir(2N) − Ir(N))/N on process totals. Deltas exact; ratios diluted toward 1
by the constant caller overhead, so read the delta column.
Result shape (ra−mi Ir/op, positive = we lose):
| we lose | we win | ||
|---|---|---|---|
| aligned | +21.4 | huge | −52,517 |
| batch_fifo | +14.3 | realloc | −97.9 |
| batch_lifo | +14.3 | big / large | −51.0 |
| usable | +2.0 | med / small | −32.5 / −29.0 |
We win the simple ops (one block in flight) and lose the ops with a live
working set. That is exactly why perl sits at 1.0099 while a ping-pong
microbenchmark flatters us: real programs look like batch/mixed, and the
microbenchmark where we look best is the least representative one. huge is
a structural win — mimalloc pays mmap/munmap per 2 MiB cycle, our arena serves
from cache.
Plan ranked P1 batch_* (most representative), P2 aligned, P3 usable, each
with the COUNT that must be taken before any code changes. Not yet executed.
Asked to validate wasm. Starting point: we did not compile for wasm at all —
cargo check --target wasm32-unknown-unknown gave 18 errors, because the prim
layer has arms for windows, unix and miri, and wasm is none of those.
A correction to the competitive premise. mimalloc ALREADY supports wasm: it
ships src/prim/wasi/prim.c built on __builtin_wasm_memory_grow, and its
readme lists WASM among supported platforms. So wasm is not a place we win by
default. The honest differentiator is narrower and still real: a pure-Rust
allocator needs no C toolchain, no emscripten, and targets
wasm32-unknown-unknown directly rather than only WASI.
crates/rusty_alloc/src/prim/wasm.rs — one linear memory that only grows.
Every consequence is a genuine semantic difference, documented in the module:
freeis a no-op. Linear memory cannot shrink, so nothing returns to the host and our own segment/page caches become load-bearing rather than an optimisation. (Upstream documents the same for wasi.)- Alignment costs a ONE-TIME pad.
memory.growyields 64 KiB alignment but a segment needs 32 MiB. We read the current end, growpad + size, and return the aligned base — and because a 32 MiB-aligned 32 MiB block leaves the end 32 MiB-aligned, only the FIRST segment ever pays. protectreturns an error rather than succeeding. wasm has no page protection, and a guard page that cannot trap would let asecurebuild claim a hardening it does not have.- No clock (
clock_nowis a counter — purge ordering survives, duration does not) and one thread (constant id, static TLS table, destructors never fire because there is no thread exit).
1. A 32-bit arithmetic overflow. Random::next_usize did (hi << 32) | lo
— a constant shift past the width when usize is 32 bits, which rustc rejects
outright. Now width-aware: two draws on 64-bit, one on 32-bit.
2. The default arena cost 1 GiB of REAL memory. ensure_default_arena
reserves 1 GiB, which on a native OS is a cheap virtual reservation committed
lazily. Wasm has no virtual reservation — memory.grow backs every byte
immediately — so the reservation was fully materialised before the first
malloc returned. And it bought nothing: wasm memory is never returned to the
host, so every segment is already permanently cached, which is exactly what the
arena was for. Now skipped on wasm via DEFAULT_ARENA_PAYS. Upstream reaches
for the same lever more mildly (arena.c divides the reserve by 4 "if virtual
reserve is not supported (for WASM for example)"); with grow-only memory,
skipping entirely is strictly better.
| linear memory | selftest | |
|---|---|---|
| with the default arena | 1056.06 MiB | 6.79 ms |
| without (shipped) | 64.00 MiB | 1.75 ms |
cargo test cannot execute wasm32-unknown-unknown, so proof of EXECUTION
comes from crates/rusty_alloc_wasm — a cdylib exporting ra_selftest —
instantiated under Node by bench/wasm-selftest.mjs, driven by
corpus/wasm-gate.ps1. Ten checks with distinct failure codes: cross-bin
patterns verified only after ALL allocations (so overlapping live blocks are
caught rather than overwritten), usable_size floor, zalloc zeroing, realloc
prefix preservation across a moving growth, a 600 KB span, 200 rounds x 32
blocks of churn (the check that matters most on wasm, since unbounded growth is
the failure mode when page recycling breaks), and word alignment. The same
self-test also runs natively under cargo test, so any failure that is not
wasm-specific is caught by the ordinary gates instead of only by the runner.
- 64 MiB for a trivial workload is coarse. It is one 32 MiB segment plus the
one-time alignment pad, and the 32 MiB segment granularity — inherited from
the mask-based
segment_ofaddressing — is simply large for wasm contexts where memory is the scarce resource. A wasm-tunedSEGMENT_SIZEis the obvious follow-up and is NOT done. secureguard pages are unavailable (no page protection), and wasm entropy is much weaker: no host RNG, a counter clock and a constant thread id leave the stack address and a global counter as the only varying seed inputs. Free-list encoding there is corruption detection, not exploit mitigation.- Single-threaded only. The atomics+threads proposal would need the
read-then-grow pair in
allocto take a lock, as upstream's wasi backend does aroundsbrk. - Not benchmarked against mimalloc on wasm. Correctness is proven; no performance claim is made.
Gates: Windows all-features exit 0, clippy -D warnings exit 0, Linux GATE
PASSED (23 suites), WASM GATE PASSED. Native performance unchanged — lua
0.9930, perl 1.0099, sqlite 1.0056.
A deliberate hunt for looping hazards and unsafe that no caller actually quarantines. The worst thing found was not in the allocator — it was in the harness that certifies it.
1. corpus/linux-gates.sh reported success on a BROKEN BUILD. It counted
test result: ok lines and grepped for FAILED|panicked. Compile errors print
neither word, so a build break yielded failures: 0. This is not hypothetical:
earlier the same day it printed ok-suites: 1 / failures: 0 while the tree did
not compile, and that was briefly read as a pass. Now checks cargo's exit code,
fails on any test failure, and fails if the suite count collapses below 15.
Verified by deliberately breaking the build — it correctly reported
GATE FAILED (cargo exit 101) where the old script said failures: 0.
2. The new corpus/miri-gate.sh shipped with the SAME bug, briefly. Piping
cargo miri into tail makes the pipeline's status tail's — always 0 — so
it printed "MIRI FAILED" and exited 0. Fixed with ${PIPESTATUS[0]}. Worth
recording precisely because it shows the failure mode is easy to re-create the
moment you stop looking for it.
3. init::done_slot could hang the whole process, forever. The winner of
the INIT CAS is the ONLY thread that ever publishes RAW; every other thread
spins in a bare loop waiting for it. TlsSlot::new(...).expect(...) on the
winner therefore turned a rare resource failure into a permanent process-wide
hang — and panicking there also unwinds into C callers, which is why the
release profile is panic=abort in the first place. Replaced with an explicit
std::process::abort(), identical in debug and release.
4. Heap::free_fast was dead code holding the only cross-checks. M11
inlined its body into alloc::free and left the original behind with no
callers. Its two debug_asserts were the ONLY places verifying that the flags
byte agrees with independent representations — HUGE_SEGMENT vs the segment's
kind tag, SINGLE_BLOCK vs bin == BIN_HUGE. Since M9b routes the entire
free on that one byte, a desync would silently send a huge or unqueued span
down the binned path. Deleted the dead pub unsafe fn (less unsafe surface)
and moved both checks to the live decision point.
5. Miri was in NO gate, and is not installed in WSL — despite having caught
two real defects in this project (the M4 registry and M7 arena base). Added
corpus/miri-gate.sh; run on Windows nightly: alloc_core 11 passed, spans,
heaps, secure, prim all clean, zero UB and zero leaks.
page_of's removed bounds check is sound. All eight call sites deriveseg = segment_of(p)immediately before the call, sop.addr() - seg.addr() == p.addr() & (SEGMENT_SIZE-1)andidx < 512by construction. The contract is discharged at every site.- The
cfg(debug_assertions)counter gating cannot change behaviour. A search for any comparison or branch reading astatsfield returns nothing — the counters are write-only in the allocator. - The 4-state xthread loops terminate. All are CAS-retry (lock-free) or a
bounded spin on the short FREEING window;
page_set_flagspinning out FREEING is the designed handshake, not a hazard. - The arena claim-and-verify loop terminates. On conflict
idx = c + 1can move BACKWARDS for n >= 3, butrunresets to 0, so re-triggering requires rescanning n free chunks and each conflict consumed a competitor's claim. Theoretical livelock only under adversarial single-chunk churn.
page_push_localdoesused -= 1with no underflow guard. A double free wrapsusedtou32::MAX, so the page never retires and corruption continues silently in release. Debug builds catch it — Rust's overflow check panics at the subtraction.page_oftrustsslice_offsetread from the pointer's own segment. A pointer that is not ours yields an arbitraryslot.sub(off). Upstream'smi_slice_firsthas the identical shape and release mimalloc likewise does not validate. This is the failure mode behind the jemalloc/redis mixed-allocator crashes recorded in M10c.- MIRI BLIND SPOT, and it covers the newest unsafe code. The x86-64 Linux
inline-asm paths —
init::thread_id'sfs:0read andinit::heap_tls's initial-exec slot — arecfg(not(miri)), so Miri exercises theirthread_local!fallbacks instead. The TLS fast path we actually ship has no Miri coverage at all; its only gates are hardware ones (bench/churn.sh, the corpus sweep). Any future change there must be hardware-gated, not Miri-gated.
Gates after the fixes: Windows all-features exit 0, clippy -D warnings exit 0,
Linux GATE PASSED (21 suites, exit 0), Miri clean, churn 3/3. Release
performance unchanged — lua 0.9929, perl 1.0100, sqlite 1.0056 — the new
assertions are debug-only.
Asked for one more win on perl and sqlite. The profile said free runs
600,567 times on perl at ~35 Ir/call against mimalloc's ~25, and that
the last structural difference from upstream's mi_free_block_local was that
ours touches the owning HEAP (xheap load -> box_of_xheap -> heap pointer, a
dependent load chain) while upstream's touches none — the PAGE owns
local_free.
Two ceiling probes, and the second one refuted the first's explanation.
| probe | perl | sqlite |
|---|---|---|
| baseline (heap chain + counter) | 1.0145 | 1.0079 |
| #1 drop chain AND counter | 1.0114 | 1.0064 |
| #2 keep counter via cheap TLS, chain only on retire | 1.0163 | 1.0087 |
Probe #2 came back WORSE than baseline. That inverted the diagnosis: the heap chain was never the cost — in the baseline ONE resolution served both the counter and the retire, so splitting it into a TLS read plus a later chain added work. The cost was the counter.
Which led to the finding that matters more than the brick. Upstream:
#if (MI_DEBUG>0)
#define MI_STAT 2
#else
#define MI_STAT 0 // <-- the release oracle has NO counters at all
#endifOur counters were unconditional. So every ratio this campaign has published
measured a counters-on rusty_alloc against a counters-off mimalloc. The
change is therefore not only an optimisation, it is a correction to the
comparison: hot-path counters now live behind #[cfg(debug_assertions)],
exactly upstream's rule, keyed off debug rather than a new feature flag so
there is no manifest plumbing and cargo test (a debug profile) keeps the
instrument that proves two binaries do identical work.
The free fast path is now push + decrement + one zero test, with the owning
heap resolved only when a page actually empties (retire_emptied).
Result:
| workload | before | after | vs glibc |
|---|---|---|---|
| lua | 0.9978 | 0.9927 | 0.837 |
| perl | 1.0145 | 1.0101 | 0.829 |
| sqlite | 1.0079 | 1.0056 | 0.998 |
perl is under 1% for the first time; sqlite now also beats glibc (0.9983).
The campaign, end to end:
| workload | start | now | vs glibc |
|---|---|---|---|
| lua | 1.0650 | 0.9927 | 0.837 |
| perl | 1.0703 | 1.0101 | 0.829 |
| sqlite | 1.0355 | 1.0056 | 0.998 |
Gates: Windows all-features exit 0, Linux 21 suites / 0 failures, clippy
-D warnings exit 0, plus bench/churn.sh 5/5 clean (640 threads).
- Never round-trip source through PowerShell.
Get-Content -Raw+Set-Content -Encoding utf8decoded the file as Windows-1252 and re-encoded it, turning every§ → —into mojibake acrossheap.rs. The identical trap is already recorded forpinvs.ps1; it applies to SOURCE too. Reversed with a CP1252 re-encode, but the rule is: use the editor, not a shell text round-trip. - A global regex replace hit the definitions it was meant to feed.
Rewriting
self.stats.allocs += 1->self.stat_alloc()also rewrote the body ofstat_allocitself, producing infinite recursion — caught as a Windows stack overflow (0xC00000FD) and clippy's "function cannot return without recursing". Write the accessor AFTER the sweep, or exclude it.
The item M10b sized and declined to build, built — by a different design than the one that was declined.
What was rejected, and why the rejection was right. A thread-pointer-keyed
side table: hash the TCB address into a global array of (tp, heap) pairs.
That is P0-class, because a TCB is recycled when a thread exits, so a stale
entry hands a NEW thread a DEAD thread's heap. Clearing it in thread_done
only helps if thread_done always runs — the exact assumption that produced
the M8 access violation.
What was built. A real ELF TLS symbol in .tbss, declared via
global_asm!, read with the initial-exec relocation:
mov {off}, qword ptr [rip + __ra_tls_heap@GOTTPOFF] ; linker-resolved, pure
; + readonly => CSE-able
slot = thread_id() + off ; thread_id() IS the fs base
Two instructions and a load, replacing a call __tls_get_addr into ld.so.
Verified in the shipped artifact, not assumed: readelf -r shows
R_X86_64_TPOFF64 against __ra_tls_heap (the M10 lesson — measure the
artifact you ship).
Why this design is sound where the keyed table is not. The storage IS the
thread's own TLS block. Every thread receives a fresh block initialised from
the all-zero .tbss image at creation, so a recycled TCB cannot expose a dead
thread's heap — the staleness question does not arise. Initial-exec's cost is a
LOAD-TIME constraint (it needs a static-TLS slot, so a very late dlopen could
fail to load us), which fails loudly at load rather than corrupting memory. It
is the same trade upstream ships as
__attribute__((tls_model("initial-exec"))). x86-64 Linux only; every other
target keeps thread_local! (Windows TLS has no __tls_get_addr to remove).
Result — we are at parity with mimalloc on lua:
| workload | before | after | note |
|---|---|---|---|
| lua | 1.0198 | 0.9978 | 4 runs: 0.9954 / 0.9977 / 0.9979 / 1.0002 |
| perl | 1.0281 | 1.0145 | 4 runs, deterministic |
| sqlite | 1.0144 | 1.0079 | bit-identical across runs |
The campaign, end to end:
| workload | session start | now | gap closed | vs glibc |
|---|---|---|---|---|
| lua | 1.0650 | 0.9978 | at/under parity | 0.844 |
| perl | 1.0703 | 1.0145 | 79% | 0.832 |
| sqlite | 1.0355 | 1.0079 | 78% | 1.001 |
Gates: Windows all-features (exit 0), Linux 21 suites / 0 failures, clippy
-D warnings, fmt. Plus a brick-specific hazard probe — 640 threads
(40 waves x 16), each writing and verifying a thread-unique byte pattern
across 200 alloc/free rounds, x5 runs, zero corruption. That probe targets
precisely what a broken per-thread heap slot would produce.
The M8 note claimed all 10 OSS programs run correctly on us. That over-claimed on redis, and this session's sweep exposed it. Measured, 8 startups per arm:
| preload | ok | crashed |
|---|---|---|
| none | 8 | 0 |
| mimalloc | 0 | 8 |
| rusty_alloc | 2 | 6 |
Cause: redis-server here is built against jemalloc (mem_allocator:jemalloc-5.3.0,
linked to libjemalloc.so.2) and reaches allocator symbols directly, so
LD_PRELOADing any replacement produces a mixed-allocator process — blocks
allocated by one and freed by the other. It is an unsupportable configuration
rather than a defect in either allocator, and the ORACLE fails it harder than
we do. Not attributable to this brick (the mimalloc arm contains none of our
code). The sweep should either drop redis or build it with
MALLOC=libc; leaving it in as a "pass" was the actual error.
Separately, imagemagick shows 4 distinct output hashes across 6 runs
including system-vs-system, i.e. its output is nondeterministic
independent of the allocator. The other 8 programs (jq, sqlite3, git, xz,
zstd, lua, perl, python3) agree byte-for-byte across all three arms.
With __tls_get_addr gone from the top, the per-function profile finally
allowed the decisive comparison — our allocator against mimalloc's, function
by function, on the deterministic perl workload:
| mimalloc | rusty_alloc (before these bricks) | |
|---|---|---|
| malloc side | 9.7 M | ~11.4 M (already at parity) |
| free side | 15.0 M | ~41 M (2.7x) |
| total allocator | 27.9 M (3.6%) | ~56 M (6.8%) |
That reframed the whole campaign: our malloc was never the problem. The
entire deficit lives in free, and two bricks came straight out of reading it.
Brick #4 — page_of without the bounds check. It resolves a block to its
page and is the allocator's hottest function (twice per free). It indexed
[Page; 512] with a runtime index, so LLVM emitted a bounds check it cannot
discharge — the bound is a property of the CALLER's contract (p lies inside a
32 MiB segment), not of the arithmetic. Replaced with add/sub on the base
pointer, same provenance, same address, invariant kept as a debug_assert.
This is the case rusty-unsafe-optimizations says to look for: not "sprinkle
get_unchecked", but one place where a provable invariant is invisible to
the compiler.
Brick #5 — stop resolving the page TWICE per free. alloc::free resolves
the page to route ownership, then handed only the SEGMENT to free_local_at,
which resolved the page again. M9 threaded the segment through and missed the
page. Threading it too deletes an entire page_of per free.
Brick #6 — the flags byte was already there; nothing tested it. SLOW_FREE
(HAS_ALIGNED|SINGLE_BLOCK|IN_FULL|HUGE_SEGMENT) had been defined in M9 and
never used. Meanwhile the free path re-derived, one load at a time, exactly
what those four bits already say: a SegmentKind match, a bin == BIN_HUGE
compare, an IN_FULL re-test, and an unalign guard. The bits are exhaustive
by construction — SINGLE_BLOCK is set at the same statement that sets
bin = BIN_HUGE, HUGE_SEGMENT at the same statement that builds a Huge
segment — so one test against the byte alloc::free had ALREADY loaded proves
all four. Clear byte routes to Heap::free_fast: push, decrement, one
empty-page test, and nothing else. This is upstream's
page->flags.full_aligned == 0 shape, reached from our own side.
Deterministic results (perl and sqlite are exact to 4-6 digits):
| workload | after TLS brick | after #4 | after #5 | after #6 |
|---|---|---|---|---|
| lua | 1.0536 | 1.0477 | 1.0402 | 1.0198 |
| perl | 1.0602 | 1.0547 | 1.0476 | 1.0281 |
| sqlite | 1.0305 | 1.0278 | 1.0244 | 1.0144 |
Session total — roughly two thirds of the gap to mimalloc, closed:
| workload | start | now | gap closed | vs glibc |
|---|---|---|---|---|
| lua | 1.0650 | 1.0198 | 70% | 0.860 |
| perl | 1.0703 | 1.0281 | 60% | 0.844 |
| sqlite | 1.0355 | 1.0144 | 59% | 1.007 |
Gates green throughout: Windows all-features, Linux 21 suites / 0 failures,
clippy -D warnings, fmt.
Where the remaining 21.9 M instructions (perl) now sit. The free path fell
from ~39 M to 23.4 M against mimalloc's 15.0 M, and free_local_at
vanished from the profile entirely (inlined). Accounting for what is left:
| ours | mimalloc | gap | |
|---|---|---|---|
| free path | 23.4 M | 15.0 M | 8.4 M |
| malloc path | 14.5 M | 12.8 M | 1.7 M |
__tls_get_addr |
7.3 M | 0 | 7.3 M |
| 17.4 M (of 21.9 M measured) |
This reprices the TLS item. It was 0.89% of the program when the gap was 4.76%; the program cost has not changed but the gap has, so those same 7.3 M instructions are now a third of everything still separating us from mimalloc — the single largest named item left.
It is NOT built, deliberately. Stable Rust cannot select initial-exec for a
cdylib's thread_local!, and the alternative — a thread-pointer-keyed cache —
carries a P0-class hazard rather than a bug-class one: a TCB is recycled when a
thread exits, so a stale slot hands a NEW thread a dead thread's heap. Clearing
the slot in thread_done closes it only if thread_done always runs, which is
exactly the assumption the M8 P0 punished us for making. A 0.9% win does not
buy that risk. The honest options are a nightly-gated build flag or a design
that makes the stale entry detectable rather than merely unlikely.
Six-whys descent on "why are we 6.5% of instructions behind mimalloc on small-object churn", using callgrind's PER-FUNCTION breakdown as a deterministic stage profiler.
D3 — which op? The profile named it immediately, and it was not one of
ours: __tls_get_addr, 12.97 M Ir (1.96% of the whole program) — more
than half the cost of our entire free (14.6 M).
D5 — the mechanism. The shipping artifact is a cdylib (LD_PRELOAD).
Rust's thread_local! in a shared library compiles to the general-dynamic TLS
model, so every access is a CALL into ld.so. mimalloc's _mi_thread_id()
is one register read. We were paying a linker round-trip per free for a value
that lives in a register.
D6 — and the instrument was lying to me. The M9 probe measured TLS at 0.25 ns and I built on that. It measured TLS inside an executable, where the model is local-exec — a register offset, no call. The artifact we ship is a shared library. Measure the artifact you ship, not a convenient stand-in. This is the third time in this project a probe measured the wrong context.
Ceiling first, then cost (bench/tls-ceiling.sh): rebuilt with
-Z tls-model=initial-exec → 2.00% of our instructions, 33% of the gap.
That sized the prize before a line of the fix was written.
The brick, on STABLE Rust: read the thread pointer directly —
fs:0 (x86-64 Linux), gs:0x30 (x86-64 Windows), tpidr_el0 (aarch64),
with the cached-TLS path kept for every other target. Exactly mimalloc's
mechanism. Soundness of id reuse is the same argument mimalloc relies on: a
dying thread abandons its segments (id stored as 0) before its TCB can be
recycled.
RESULT — deterministic, reproducible, gap closed by a sixth:
| workload | ra/mi before | ra/mi after | gap closed |
|---|---|---|---|
| lua | 1.0650 | 1.0536 | 17% |
| perl | 1.0703 | 1.0602 | 14% |
| sqlite | 1.0355 | 1.0305 | 14% |
__tls_get_addr no longer appears in the profile's top entries at all.
Windows all-features green, Linux 21 suites / 0 failures, clippy + fmt.
One brick tried and REVERTED (measured flat, not measured worse): the
in-place realloc path bumps a counter, which costs a TLS heap lookup on the
commonest realloc outcome. Removing it left perl at 1.0602 and sqlite at
1.0305 — unchanged to four digits — because in-place reallocs are rare in
these workloads. It cost a work-parity counter for an unmeasurable gain, so it
went back. Recorded as flat, not worse.
Instrument refinement: lua's per-process hash-seed randomisation makes its instruction count vary ~0.3% run to run; perl and sqlite are deterministic to 4-6 digits (sqlite repeated to within 209 instructions in 326 M). Use perl/sqlite for verdicts; treat lua as indicative.
Standing: ~5.4% of instructions behind mimalloc on small-object churn (from 6.5%), ~11% AHEAD of glibc. The remaining TLS prize (~1%) is the heap pointer itself, which needs either nightly's TLS-model flag or a thread-pointer-keyed lookup — both are M11 candidates, both now sizeable before they are built.
The null arm proved this box cannot adjudicate a 5-10% effect. Rather than
wait for a quiet machine, we changed INSTRUMENT: instructions retired
(callgrind) — a counter, deterministic, indifferent to an open IDE, a
browser or thermal drift. Same program, same input, same output in every arm;
the allocator is the only variable. bench/icount-arms.sh.
Instrument verified first (three runs of the same arm): ra 662.83 M / 662.78 M / 663.04 M — 0.04% spread, versus the clock's 24%. That is a usable instrument on a noisy box, and it is now the project's default A/B for allocator work.
The answer, finally free of noise:
| workload | ra instructions | vs mimalloc | vs glibc |
|---|---|---|---|
| lua (small-object churn) | 663.0 M | 1.065× | 0.900× |
| perl (hash/array churn) | 834.3 M | 1.070× | 0.878× |
| sqlite (bulk) | 328.1 M | 1.036× | 1.028× |
We execute 6.5-7.0% more instructions than mimalloc on small-object interpreter churn, and 3.6% more on sqlite — while executing 10-12% FEWER than glibc on the same interpreters. That is the shape the real-world sweep hinted at, now quantified to four digits and reproducible on demand.
So the M9 story is complete and honest: the mechanism was real and is fixed (the per-free OS call is gone), we are comfortably ahead of the system allocator, and we remain ~7% of instructions behind mimalloc on exactly the workload class that started this investigation. That residual is the M10 target, and for the first time it can be attacked brick-by-brick with an instrument that gives the same answer twice.
Standing debt, narrowed: a quiet-box wall-clock session is still owed before any time ratio is published — but no longer to know whether a change helps. Instruction count answers that today.
What is still owed, and it is the same debt as M9: a pinned session on a QUIET machine (no IDE, no browser) at N >= 31 to convert "removes work" into a standing speed number. Until that exists, rusty_alloc claims no speed ratio.
The question: the real-world sweep showed us winning on bulk workloads and losing on small-object interpreter churn (lua, perl — same shape as cfrac). Why?
The answer, measured not guessed (rabench freepath-probe, both OSes):
| component | Windows | Linux |
|---|---|---|
| loop floor | 0.24 ns | 0.22 ns |
prim::thread_id() — called on EVERY free |
1.41 ns | 1.39 ns |
const-init thread_local cache (candidate) |
0.25 ns | 0.23 ns |
| whole malloc+free pair, 48 B | 5.77 ns | 6.47 ns |
free must know the calling thread's id to route local-vs-remote. We were
calling the OS/libc every time — pthread_self through the PLT from a cdylib,
GetCurrentThreadId on Windows — for a value that never changes.
That single call was 18–20% of an entire malloc+free pair, and it lands
squarely on the workloads that do nothing but small alloc/free: interpreters.
The two platforms agreeing to within 0.02 ns is what makes this a mechanism
rather than a reading.
Second finding, free of charge: alloc::free resolves the segment and
page to route ownership, then free_local recomputed both — a mask, a
shift and two loads per free, for nothing.
Bricks landed:
init::thread_id()— const-initthread_localcache with a#[cold]first-call path; every hot site routed through it (~1.16 ns/free removed).free_local_at(seg, p)— the already-resolved segment threaded through instead of recomputed. Byte-identical behaviour; strictly less work, which is the counter-style argument the clock cannot dispute.
Gates: Windows all-features green, Linux 21 suites / 0 failures, clippy
-D warnings + fmt clean.
Performance verdict: NOT RESOLVED on this box, and I am not claiming one. Real-workload medians walked with N — lua ra/mi 0.751 at N=5 → 1.071 at N=15, perl 1.104 → 1.729 — the exact §16 failure mode (the estimator itself trends; the reference's own throughput moved 25% between sessions). The best-of-N floors are the only stable statistic here:
| workload | ra/mi (min-of-N) before | after |
|---|---|---|
| lua | 1.19× | ~1.00× |
| sqlite | 0.84× | ~0.98× |
| perl | 1.05× | ~1.26× (contradicts the median direction) |
lua moving to parity is consistent with the mechanism; perl moving the wrong way is not, and both arms slowed in absolute terms between sessions, so the box — not the code — is the likely author of that number. What is defensible today: the mechanism is identified, quantified identically on two platforms, and removed. The ratio needs a quiet machine at N ≥ 31 before it goes in any README.
Next (M9 continued): pinned quiet-box session for the standing ratio;
then the remaining fast-path candidates already visible in the probe —
the SegmentKind branch and unalign's two loads on every free, and the
generic-path rate (6% of allocs) which sets how often we leave the hot path.
The P0 is fixed. Root cause: guard pages were recycled while still
PROT_NONE. A guarded allocation protects the page after the object; when
that segment was released it went back to the arena with the protection
still applied, so the next tenant faulted on memory it legitimately owned.
Fix: lift protection (and restore commitment) before any segment can be
re-tenanted — Segment::guarded, handled in huge_free/segment_free.
How it was found — the method, not luck. Whole-suite runs faulted ~1/10,
every test passed alone, and my first hypothesis (abandon→adopt→arena churn)
was WRONG: a purpose-built MT storm (tests/stress_mt.rs, dying threads +
adopters + cross-thread frees + huge allocs) stayed clean over 10 runs. The
discriminator was per-binary bisection: only tests/secure.rs faulted
(1/6), yet each of its four tests passed alone 8/8 — so it was an INTERACTION.
That named the pair: the guarded-objects test creates PROT_NONE pages, and the
other tests recycle segments. The one earlier signal that had held all along —
"0/12 with arenas disabled" — then made sense: OS-released memory is unmapped,
so only the arena path resurrects a protected page. Lesson (ledgered):
when a defect needs several tests to appear, bisect by BINARY and then by
PAIR; a clean single-test run is evidence of interaction, not of health.
Verification after the fix: previously-faulting binary 12/12 clean;
--all-features 10/10 + 8/8; default 8/8; secure 12/12. Linux 21 suites/0.
clippy -D warnings + fmt clean.
REAL-WORLD SWEEP — 10 open-source programs on rusty_alloc via LD_PRELOAD
(corpus/realworld.sh, corpus/realworld-medians.sh): jq, sqlite3, python3,
git, xz, zstd, lua5.4, perl, ImageMagick, redis-server (+redis-benchmark).
- Correctness: 10/10 ran, and every deterministic workload produced a
BYTE-IDENTICAL output checksum under
ra,miand glibc. No crash, no hang, no wrong answer. (ImageMagick's PNG bytes differ run-to-run under every arm — embedded timestamps, not a defect.) redis-server serves its full benchmark under our allocator: SET/GET/LPUSH/LRANGE_300 all complete. This is the strongest correctness evidence the project has: real C programs, unmodified, on our allocator. - Performance (medians of 5 ABBA-interleaved reps, WSL2 dev-loop numbers — NOT standing claims): sqlite 0.90× of mi (we are ~10% faster); perl 1.21×; lua 1.66× (min-of-N: 1.19×) — we are slower on the interpreter workloads. Median-vs-min disagreement is large on this box, so the ratio needs a quiet machine and N≥31 before anyone acts on it.
- The pattern is consistent and actionable: we win on large/bulk-allocation workloads (sqlite, malloc-large) and lag on small-object-heavy interpreter churn (lua, perl — and the same shape as the cfrac regression). That points at the single-threaded small-malloc fast path, exactly where M4 measured us at 0.93× of glibc's tcache. That is the M9 perf target, and it is now backed by real workloads rather than kernels.
Still open: cfrac regression un-diagnosed (needs a quiet re-run first); the fast-path perf campaign; the v1 geomean gate.
Landed: random.rs — self-contained ChaCha8 CSPRNG, per-heap streams,
OS-seeded (BCryptGenRandom / /dev/urandom) with a documented fallback mix.
secure feature — encrypted free lists (enc = (next + key2) ^ key1, fresh
per-page keys, corrupt links caught by an alignment check on decode) routed
through block_next/block_set_next at every traversal.
Guarded objects — dedicated segment with a PROT_NONE trailing page, object
right-aligned against the guard so an overflow faults on the first byte past
it; sampling API (mi_heap_guarded_set_sample_rate / _size_bound) wired to
the option table. Purge/decommit of coalesced free spans (RSS lever) with
per-span purged state and recommit-on-reuse. debug_checks implemented
(our dmi): page-invariant and whole-segment span-tiling validators on the
hot paths.
Defects found and FIXED during M8 (each real, each caught by a gate):
- Purge without recommit — Windows
MEM_DECOMMIT'd spans were handed back out; the next touch faulted. LinuxMADV_DONTNEEDkeeps pages accessible, which is exactly why this was Windows-only. Fixed: purge insidespan_freeafter coalescing +span_recommiton reuse + full recommit before a segment returns to an arena (Segment::purged_any). - Visitor read encoded links raw —
visit_segment_blockswalked free lists with plain(*b).next, so undersecureit indexed a stack bitmap with garbage. Fixed (block_next + bounds-checked marking). - Multi-chunk arena claim race —
chunk_alloc_nscanned for a free run then set the bits; the lock-free single-chunk path could steal one in between, giving TWO segments the same address. Fixed: claim-and-verify with rollback and rescan. adopt_segmentmutated the span layout it was walking —span_freecoalesces, so the iteration could land mid-span and queue a bogus page. Fixed: never retire during the walk.
OPEN DEFECT — v1 CANNOT SHIP (P0): a rare access violation survives, in
the parallel test suite only. Measured after all four fixes: 1 in 10 runs
(--all-features), 1 in 5 (default), 0 in 12 with MIMALLOC_DISALLOW_ ARENA_ALLOC=1. Ruled OUT by experiment, not by argument: the secure
encoding (identity-encoding probe still crashed), purge (still crashes with
purging opt-in/off), option/env parsing (bypass probe still crashed), and
single-threaded execution (every test passes alone, 11/11). Not reproduced on
Linux (21 suites, 0 failures) — consistent with a Windows-only commit/protect
interaction OR with timing. Strongest remaining hypothesis: a segment is
returned to an arena (or reused) while another thread still reaches it —
i.e. used_pages accounting across abandon → adopt → segment_free.
Next probes: (a) make debug_checks assert used_pages against a live-page
recount at every segment transition; (b) an arena-chunk generation counter to
catch reuse-while-referenced; (c) rebuild the Windows suite under Application
Verifier / page-heap for an exact faulting address.
Purging ships OPT-IN (purge_delay default −1) — not because purge is the
cause (it isn't), but because it widens the state space while the defect is
open. Documented divergence from the oracle's default of 10.
Gates: clippy -D warnings + fmt clean; Linux 21 suites / 0 failures;
Windows all-features 5/5 clean in the last sweep but 1/5 AV in the default
build — that is the blocker, and it is reported as such.
Tier-A corpus (WSL2, arm-interleaved, /usr/bin/time): malloc-large ra 2.50/3.04 s vs mi 5.02/3.95 — still ahead; espresso ra 8.14/8.93 vs mi 9.11/6.60 (RSS 3456 KiB vs mi 10448); larson wall parity (7.03–7.09 vs 7.06–7.13); cfrac regressed to 8.65–12.87 s vs mi 6.24–6.32 — a real M8-era regression on the small-alloc path, not yet diagnosed (the box was also running the Windows stress concurrently, so this number needs a quiet re-run before it is acted on: measure-first discipline, not a fix-first reflex).
v1 gate status: API parity ✅ (~150 of ~157 functions), corpus runs ✅, hardening ✅, stability ❌ (open P0), perf gate not yet assessable — the geomean claim cannot be made while a corruption is open and cfrac is unexplained. M8 is therefore NOT complete; the remaining work is the defect hunt, then the perf campaign.
M6 landed: first-class heaps as separately-allocated HeapBoxes with
owner routing via the page's xheap back-pointer (container-of over the
box's offset-0 delayed list — free now finds the OWNING heap, correct with
many heaps per thread); huge segments tracked per-heap and unified with the
delayed protocol (remote huge frees ride the DELAYED path; abandonment/adopt
cover Huge kind); heap_new/_ex/_in_arena, delete (segments absorbed into
the backing heap via adopt — blocks stay valid), destroy (wholesale release,
NEVER-spinning teardown), set_default/get_backing; heap_* alloc family
(FFI: ~45 heap exports); visitors (mi_heap_visit_blocks with free-bitmap
block enumeration, mi_abandoned_visit_blocks under the list lock),
contains/check_owned, page_under_utilized; arenas v1 (segment-granular
chunk pools, used+dirty bitmaps, exclusive arenas, manage_os_memory,
arena_area, huge-page reserves as large-page arenas); subprocs
(per-subproc abandoned lists — isolation verified); page heap tags surviving
abandonment. Rust Heap type (delete-on-drop / destroy-on-drop).
M7 landed: the full 38-slot option table (ABI index-compatible,
MIMALLOC_*/RUSTY_ALLOC_* env parsing), registered hooks
(output/error/deferred-free — the heartbeat fires it), stats
(per-heap merged-on-read across the heap registry, process/thread prints,
mi_process_info via GetProcessTimes+K32/getrusage+statm), realpath/dupenv/
wcsdup/mbsdup, the C++ mi_new family (documented divergence: no
std::get_new_handler), the override crate (unix-only exports: malloc
family + posix + Itanium-mangled C++ operator new/delete incl. sized+aligned),
include/rusty_mimalloc.h, and Tier-A runner scripts.
THE GATE THAT MATTERS — real C programs on our allocator via LD_PRELOAD (WSL2, /usr/bin/time, arm-interleaved, 2 runs/arm):
- cfrac: ra 5.95/8.63 s vs mi 7.47/7.15 vs glibc 6.32/9.06 — parity with the oracle; RSS 3456 KiB vs mi 4312.
- espresso: user-time parity (ra 6.39–6.68 vs mi 6.44–7.03); RSS 3264 KiB vs mi 10448.
- larson (real 100-thread-class bench, 8 workers): wall parity (ra 7.04/7.09 vs mi 7.15/7.77); RSS ra ~90–100 MB vs mi ~77–83 MB (retention policy).
- malloc-large found a REAL defect: 3–4× slower than mi, sys-time-bound —
large/huge alloc-free cycles round-tripped the OS. TWO fixes, both
mimalloc's own shape: (1) lazily reserve the DEFAULT 1 GiB arena
(
arena_reserve) so segments recycle through chunks; (2) serve HUGE blocks from arenas too (contiguous multi-chunk claim under a small lock). Result: ra 2.24/2.66 s vs mi 4.45/4.18/5.68 — flipped to ~1.8× FASTER than the oracle. Sys time 15.7 s → 0.5 s. RSS +20% vs mi (recycled chunks stay committed — purge wiring is the RSS lever, still open).
Miri earned its keep AGAIN, same law twice: the arena stored its base as
usize — the 1 GiB region became unreachable-by-pointer and the default-arena
reservation "leaked". Reachability (and provenance) follow POINTERS: base is
now *mut u8 and chunk derivation uses .add(). That's the third time this
lesson fired (registry M4, arenas M7) — it is now a review checklist item.
Gates green: Windows full suite + Linux 19 suites/0 failures · the new
heaps gate (visitor counts exact, delete-migration contents verified, destroy,
exclusive-arena containment + recycled-chunk re-zeroing, subproc isolation,
options/env, stats/process_info) · miri clean (heaps gate included; subproc
section native-only — the mock's TLS dtors don't fire, documented) · G1/G2
unchanged and green · clippy -D warnings + fmt.
Known divergences (documented, tracked): arena chunks are
segment-granular (32 MiB; upstream is slice-granular); _commit=false
arena reserves still commit (eager model); NUMA recorded not enforced;
purge/decommit of free spans and arena chunks still pending (RSS);
mi_stats_merge is a no-op (merged-on-read); racy-by-design stats snapshot
(volatile read) pending an atomic-counters refactor; no std::get_new_handler.
Landed: the API-completeness milestone, part 1 — ~34 new functions.
aligned_atwith interior-pointer recovery (the one real architecture piece):(p+offset) % align == 0via three tiers — natural fit through the bins (64 KiB-aligned areas ⇒bsize % align == 0qualifies every block), oversize-and-adjust (interior pointer; page markedhas_aligned, free and usable_size recover the block start by block arithmetic — works for binned, large-span, and adopted pages alike), and exact placement in dedicated huge segments (offset-aware, slack only when the offset actually shifts the boundary).- Full §5.4 aligned family + §5.7 zero-preserving (
rezalloc/recalloc+ aligned/_at — resting on the invariant that a zalloc'd block is zero across its FULL usable extent, so moves zero exactly[old_usable, new_usable)), §5.5u*block-size-returning variants, §5.11 POSIX core (posix_memalign with EINVAL/ENOMEM, memalign, valloc/pvalloc, aligned_alloc, reallocarray/reallocarr, cfree via the segment map,_expand, malloc_size/usable_size/good_size, sized frees with debug verification),realloc_aligned(_at). - Harness: trace gen emits ~15% aligned allocations (16 B–64 KiB) through the
align_log2field the format carried since v0; the system arm allocates/ frees through matching Layouts; realloc is restricted to natural-alignment blocks (the C contract). New gates:aligned_at_offsets(all tiers),rezalloc_grows_zero,align_storm(randomized aligned churn + canaries, interior-free recovery hammered).
Gates green: Windows + Linux full suites (18 result rows) · G1 CLEAN on
1M-op traces WITH aligned ops on both arms, strict leak gate 527 465 == 527 465
· miri clean over the new paths (112 s, all 11 alloc_core tests interpreted) ·
loom untouched (protocol unchanged) · clippy -D warnings + fmt.
Notes: realloc does not preserve >8 alignment (per the C contract) —
gen/replay encode that; realloc_aligned_at exists for callers who need it.
Sized-free fast-path exploitation (skip the page walk) is an M8 brick; M5
verifies the size under debug only. mi_realpath/mi_dupenv_s/wide-char
helpers remain M7 (they are I/O, not allocation).
Landed: the global lock is GONE. Per-thread heaps in os-allocated HeapBoxes
reached through a const-init thread_local! pointer (the R1-validated 0.3 ns
path; !Drop key ⇒ no allocation on access, no bootstrap recursion — heap
storage comes from the prim layer). free routes by Segment::thread_id:
owner → local path; else the 4-state xthread protocol
(NORMAL/DELAYED/FREEING/NEVER packed into the page's atomic word with the list
head): full-queue and large pages sit DELAYED so remote frees nudge the owner's
delayed list (drained at heartbeat — that is what un-parks full pages whose
blocks died remotely); FREEING guards the heap deref against teardown; NEVER
covers abandonment. Thread exit (prim FLS/pthread destructor from M1): collect
→ retire → surviving pages to NEVER (spinning out FREEING) → drain delayed →
publish segments on the global abandoned list → release heap storage.
Allocation slow paths adopt abandoned segments before reserving OS memory.
Global heap registry (M7 stats walk + honest reachability). FFI: mi_collect,
mi_thread_init/done, mi_process_init/done, mi_thread_set_in_threadpool.
Protocol verified by loom BEFORE implementation (tests/loom_xthread.rs
is the spec): delayed-push vs abandon (the use-after-free the FREEING state
exists to prevent + block conservation), normal-push vs collect, park/unpark
vs remote. Preemption bound 2 locally; LOOM_EXTENDED=1 → bound 3 in CI.
Loom immediately enforced its own hygiene: spin loops need yield_now, CAS
protocols need a preemption bound and branch budget.
Defects caught by gates: miri flagged heap boxes/segments as leaks after
the static→TLS move — root cause: the registry stored pointers as usize,
and REACHABILITY FOLLOWS POINTERS, NOT INTEGERS (AtomicPtr fixed it; the same
rule keeps our own provenance honest).
Gates green: Windows full suite + Linux full suite (18 result rows incl.
the new mleak test: 4 threads exit with 2 000 live blocks; contents survive
abandonment, frees from main route via NEVER, main's churn adopts segments) ·
G1 1M-op realloc trace with counters IDENTICAL to the locked M3 run (perfect
cross-milestone work parity) · miri clean · clippy -D warnings + fmt.
MT kernels (in-process wall, quiet-box Windows): larson 8 threads 146 Mops/s with 780k cross-thread frees; xmalloc (100% remote frees) 51.8 Mops/s. Canary-checked throughout.
Measurement note: the first post-M4 malloc-small readings (Win 27.9, Linux 17.9) were taken while our own miri/loom/WSL gates saturated all 24 cores — both arms fell ~4× equally, ratios held (~2.3× Win). Discarded per the go-find-the-process rule; quiet-box numbers below.
Loom postscript: the 3-thread abandon model exceeded loom's exploration
budgets twice (spin heuristics). Fix was MODEL DECOMPOSITION, not bigger
budgets: the UAF invariant needs exactly ONE remote vs the abandoner —
exhaustively explored, unbounded, in 3.7 s (4/4 models green). The 2-remote
wide-space variant is the LOOM_EXTENDED=1 CI soak. Lesson: a protocol model
should be the smallest machine that can violate the invariant.
Quiet-box numbers (in-process wall, method lines printed; single-session, pinned ABBA still owed for standing claims):
- Windows malloc-small: ra 78.9 Mops/s (locked M3: 67.9 → lock removal +16%) vs system 36.8 (2.14×). larson 8T: ra 202 Mops/s standalone; xmalloc all-remote: 70.1 Mops/s standalone.
- Windows cross-arm MT ratios: NOT RESOLVED — the box degraded mid-session (identical runs spread 10–87 Mops/s; likely rust-analyzer storm after the manifest edit — the check-what-your-edit-woke-up corollary). ABBA pairs taken, ratios 0.77–2.64, no verdict quoted. Needs a pinned quiet session.
- Linux (WSL, observational, 2 interleaved rounds, consistent direction): malloc-small ra 71.7 vs glibc 76.9 (0.93× single-threaded — glibc's tcache edges the still-unoptimized fast path). larson 8T: ra 162.8/114.0 vs glibc 120.3/104.1 (ahead both rounds). xmalloc (100% cross-thread frees): ra 27.7/51.7 vs glibc 3.6/16.7 — 3–8× ahead, both rounds, the M4 protocol's designed win.
Deferred, tracked: TSan MT fuzz (needs -Zbuild-std wiring, CI follow-up);
pinned cross-arm MT session on a quiet box; single-threaded fast-path polish
(M8 — the 0.93× vs tcache gap); mi_stats_merge over the heap registry (M7);
no_std profile (post-v1, needs a TLS story without std).
Landed: span reclamation (per-segment first-fit free-span list with O(1)
left/right coalescing via first/last slot markers), page retire (empty pages
return their span; one page per queue stays warm), in-segment large pages
(64 KiB–16 MiB single-block spans, fresh-per-request, retire-on-free — span
reuse IS the recycle path), a one-empty-segment cache (without it every
large alloc/free cycle paid a 32 MiB OS round-trip), the segment map (1 bit
per 32 MiB window, 1 MiB BSS; mi_is_in_heap_region), the realloc family
(realloc in-place when still-fits-and-≥-half-used, reallocn, reallocf,
expand, GlobalAlloc::realloc), mi_strdup/mi_strndup, 8 new FFI exports,
Realloc in trace gen/replay with G1f prefix-preservation + G1g strict-leak
gates.
Two real bugs caught by our own gates before shipping:
- Bump-frontier give-back broke the zero invariant —
span_freemerged freed spans back into the virgin bump region, whose allocations reportfresh = true; recycled dirty memory then skipped zalloc's memset. Caught by the spans G1c test. Freed spans now never rejoin the frontier. - Freeing a segment's only page released the segment instantly, making free-then-alloc cycles reserve a fresh 32 MiB each time → the segment cache.
Instrument lesson: the recurring "exit 255 with all tests ok" ghost was the
harness, not the code — truncating cargo's output pipe (Select-Object -First)
breaks $LASTEXITCODE. Read exit codes from full pipes only.
Gates green (both OSes): core 8/8 + spans lifecycle (deterministic-counter
process: reuse without new segments, recycled-span re-zeroing, retire counts,
12 MiB coalesced fit, realloc in-place/move/shrink semantics, expand
never-moves, segment map yes/no) + selfhost 5/5 + G2 + G1 on a 1M-op trace
with realloc: 529 898 allocs == frees (strict leak gate), 9 952 spans
retired, 9 segments (3 freed — cache policy visible), 9 897 large + 1 050 huge
allocs, realloc 22 102 in-place / 77 899 moved · miri clean (coalescing
interpreted end-to-end) · clippy -D warnings + fmt.
Numbers (in-process wall, same seed, method lines printed; single-run — pinned ABBA still owed): malloc-small Windows ra 67.9 Mops/s vs system 28.1 (M2 binary: 39.7 vs 17.6 — box conditions drifted too; ratio ~2.4×), Linux ra 71.2 vs glibc 71.1 — parity reached with the lock still in place (M2: 57.1). Counters bit-identical across OSes (10 002 036 allocs = frees, generic 6.0%, 132 pages, 553 extends) — work parity + determinism hold. The M2→M3 speedup is plausibly retire keeping the hot page resident; treat as observed, not confirmed until a pinned same-binary A/B.
Deferred, tracked: purge/decommit of free spans (RSS story, M7 options);
in-place realloc for large spans via span growth (M5-ish, currently copies);
aligned realloc (M5); mi_realpath (M7); huge-segment map bits cover the whole
reservation (done) but Normal segments assume ≤ 2⁴⁸ VA (LA57 = false-negative).
Landed: the allocator exists. bins.rs (oracle-pinned geometry), page.rs
(three sharded free lists, lazy extension), segment.rs (32 MiB-aligned sliced
segments, eager commit, dedicated huge segments, ptr→page = mask + slice walk),
heap.rs (75 bin queues + full queue, direct table, malloc_generic heartbeat,
free with full-queue unpark), alloc.rs (global spin-locked heap — the M2
threading model, removed in M4), aligned-subset (natural-fit via bins, huge
fallback), GlobalAlloc impl, 12 mi_* FFI exports, G1 replayer + trace gen,
Tier-B malloc-small kernel, R1 tls-spike.
G2 earned its keep twice (differential vs oracle DLL/so, every size 1..=64 KiB, both OSes):
- mimalloc's default is MI_ALIGN2W: wsizes ≤ 8 round to EVEN word counts — bins 24/40/56 B don't exist (that's how 16-byte max_align_t is guaranteed). My from-paper formula had them. 49k mismatches → 0.
- The binned cutoff is 64 KiB (
MEDIUM_PAGE_SIZE/8), not 128 KiB (/4); above itgood_sizeis page-rounded.
Other defects caught by gates before they shipped: zalloc returned blocks
whose first word held the free-list link (upstream zeroes exactly that word —
now we do); test-vs-test races on the shared heap (fixed test design, kept the
stress value); Windows DLL dependency resolution needs absolute paths +
redirect preload; mixed-OS cmake caches corrupt both oracle builds →
OS-namespaced oracle/out/{win,linux} (scripts + docs updated).
Gates green: core 7/7 + lib 5/5 on Windows AND Linux · G1 replay CLEAN on a
1M-op synthetic trace (alignment/usable/zero/canary/overlap) on both arms ·
G2 PASS both OSes · selfhost 5/5 both OSes (rusty_alloc as the test
binary's real #[global_allocator]: HashMap/BTreeMap churn, Vec grow/shrink,
cross-thread frees, 40 MiB boxes) · miri clean over the whole core
(segments, mask trick via with_addr, free lists) · clippy -D warnings +
fmt clean · every unsafe block carries its SAFETY invariant.
First numbers (Tier-B malloc-small, in-process wall, method lines in
output; standing claims await pinned ABBA vs oracle arms):
- Windows: ra 39.7 Mops/s vs system 17.6 (2.26× ahead) — with the global lock.
- Linux: ra 57.1 vs glibc 71.1 (0.80×) — glibc's lock-free tcache vs our locked fast path; this gap IS the M4 work item, not an M2 regression.
- Counters bit-identical across OSes (allocs 10 002 036 = frees; generic 6.0%; 1 segment / 27 pages / 273 extends) — deterministic kernel, work-parity holds.
R1 RESOLVED (tls-spike, 100M accesses): thread_local! + const init =
0.32 ns/access ≈ bare atomic load (0.31) on Windows; 0.29 vs 0.26 on Linux.
OS-slot (FLS/pthread) path: 3.87 / 1.73 ns. M4 design: thread_local! const
for the heap pointer; prim TlsSlot only as the thread-exit destructor hook.
No nightly #[thread_local] needed.
Deferred, tracked: page retire/slice reclamation + realloc family (M3); lock removal + xthread activation (M4); free-list encoding stays off to match the oracle's release default (secure/debug feature, M8); rdtsc path profiler skeleton (first optimization session).
Landed: prim/ (Windows VirtualAlloc backend incl. the aligned reserve-release-
re-reserve race-retry dance, large-page attempt + fallback, FLS-based TLS destructor,
QPC clock; unix mmap backend with over-allocate-and-trim alignment, MADV_DONTNEED
decommit, MADV_FREE reset, pthread_key TLS; miri mock with alloc registry), os.rs
(cached config, page rounding, alloc_aligned, purge policy).
Gates:
- Windows native: 9/9 integration tests (32 MiB segment alignment, fresh-zero pages, reserve→commit→write→decommit→zero cycle, reset stays accessible, protect round-trip, 50-case size×alignment sweep, thread ids, clock scale, TLS dtor fires at thread exit with the stored value, NUMA ≥ 1).
- Linux native (WSL2 Ubuntu, rustup stable): same suite, 9/9.
- miri (mock backend): 2/2 — caught a real defect before it ran anywhere: the mock's
registry used a non-const
HashMap::newin a static (cfg(miri)-only code stable never compiled). Fixed withOnceLock. - clippy
-D warningsclean both targets; fmt clean;cargo checkgreen on x86_64-pc-windows-msvc and x86_64-unknown-linux-gnu.
Notes for M2: prim commit conservatively reports is_zero = false on both
platforms (range may span still-resident pages) — the page layer must track
per-page is_zero itself off fresh-mapping info, exactly as upstream does, or
calloc double-zeroes. Windows decommit needs recommit; Linux DONTNEED does not —
the purge accounting must carry needs_recommit per range.
Landed: workspace (5 crates), oracle submodule pinned @ v2.4.5 (cde3f7a0) with
3-arm build scripts (mi/dmi/smi, built OK with cmake+MSVC), mimalloc-bench submodule,
.ratrace trace format v0 (round-trip tested), bench/pinvs.ps1 compliant harness,
CI (fmt/clippy/check/test win+linux, cross-target, miri, oracle build), WSL2 doc.
Gates: 4/4 tests; clippy/fmt clean; first differential gate passed — oracle
mimalloc.dll and rusty_alloc_ffi.dll loaded in one process both report
mi_version() == 20405.
Environment note: WSL2 Ubuntu installed 2026-08-05 (24 cores). mimalloc-bench
needed unzip + dos2unix beyond the documented packages (shbench patch step) —
recorded in corpus/WSL2.md.
Tier-A gate closed (2026-08-05): full bench suite built in WSL2; bench.sh mi cfrac runs (6.90 s wall / 6.77 s user / 7.3 MB RSS / 746 page-reclaims).
Null-arm session, cfrac × mi (METHOD: WSL2 Ubuntu on /mnt/c, N=4 each arm):
- unpinned: 6.80–12.26 s wall — 1.80× spread; page-reclaims 743–750 (work parity holds, so it is scheduler migration, not the workload)
taskset -c 2 nice -n -5: 7.34–9.31 s — 1.27× spread; minima agree (~6.8–7.3 s)
Verdict: best-of-N minima are usable for absolute floors; paired A/B on this box needs pinning AND large N, and WSL2 numbers are dev-loop only — plan risk R5 confirmed empirically on day one. Every future WSL2 run states this method line.