Skip to content

fix(heap): stranded large spans after adoption; perf: segment scrub, large cache (2.2.4) - #47

Merged
Ttimmahlax merged 2 commits into
mainfrom
perf/veins-and-retention
Oct 8, 2026
Merged

Ttimmahlax merged 2 commits into
mainfrom
perf/veins-and-retention

Conversation

@Ttimmahlax

Copy link
Copy Markdown
Contributor

Intended release: 2.2.4.

Fixes (consumer report, docs/plans/huge-free-retention.md)

  • adopt_segment retired one dead large span per adopted segment and left the rest carved for the life of the process. A model loaded on a thread that exited and dropped on another kept ~0.6 of its size resident, whatever purge_delay said (probe: 1.62 -> 1.01 models). Adoption also stopped re-counting and releasing the empty segment retire_span had just parked.
  • With purging on, a freed huge block (> 32 MiB) is purged before its chunks rejoin the arena (exit RSS 159 MB -> 7 MB in the probe).

Performance (callgrind, memset counted as real instructions; vs 2.2.3)

probe 2.2.3 this PR
64 MiB malloc + free 8,959 824 (-90.8 %)
2 MiB malloc + free 293.05 138.06 (-52.9 %)
thread life 19,387 16,988 (-12.4 %)
Rust thread-pool workload 3,191,866 ~2.7 M (-15 %)

Every opscan op unchanged or better (big 92 -> 91, mixed 92.70 -> 92.04). perl/sqlite allocator Ir flat; Endless Sky start-up output identical, allocator Ir flat. wasm +361 B gz (baseline moved deliberately).

Validated locally: workspace --all-features 177/177, corpus/linux-gates.sh 42 suites, embedded matrix 15/15, miri gate, semgrep, mimalloc-bench sweep 19/19, churn 5/5, real-world programs byte-identical across ra/mi/sys, alloc-eval checks on 8 game traces, downstream corpus (spacedb x3, rusty_alloc_default, rusty_zstd; rusty_maplibre is the known 2.0.0 break).

Refutations recorded in docs/opps.md. Unsafe census 973 -> 977, each site in UNSAFE.md.

🤖 Generated with Claude Code

tim-almond-house and others added 2 commits October 7, 2026 17:56
…large cache

Fixes (docs/plans/huge-free-retention.md, a consumer report):
- adopt_segment retired ONE dead large span per adopted segment and left
  the rest carved forever (a large span is never queued, so no collect
  reaches it). A model loaded on a thread that then exited, and dropped on
  another, kept ~0.6 of its size resident whatever purge_delay said. Now
  every dead span is retired; the walk resumes at the merged span.
  Adoption also no longer re-counts and releases the empty segment that
  retire_span had just parked.
- huge_free purges a dead huge block (> 32 MiB) before its chunks rejoin
  the arena when purging is on. It stayed resident: the consumer's
  unexplained ~150 MB was Whisper small's 152 MiB embedding.

Performance (callgrind; memset counted as real instructions):
- scrub_recycled: a recycled chunk zeroes the owner table and the slots
  read before a carve, not the whole 45 KB header; slots are zeroed at
  their first carve. Huge segments skip 511 strided slot writes and
  wait_no_remote_in_flight scans slot 1 only. 64 MiB malloc+free
  8,959 -> 824 Ir; thread life -12.4 %; Rust thread-pool workload -15.0 %.
- Per-heap large-span cache (<= 4 MiB, purging off, lone frees only, so a
  teardown run never leaves a span to be refilled out of order): 2 MiB
  malloc/free 293 -> 138 Ir.
- Multi-chunk arena claim: one bitmap load per word, a one-word run claimed
  with one masked fetch_or. find_arena replaces three copies of the scan.

Every opscan op unchanged or better; perl/sqlite/Endless Sky allocator Ir
flat. Refuted and recorded in docs/opps.md: segment_map RMW batching (+15),
masked chunk release (+9), flush-on-miss in large_alloc (+3 on every
generic op).

Validated: workspace --all-features, linux-gates (42 suites), embedded
matrix 15/15, miri gate, semgrep, mimalloc-bench sweep 19/19, churn,
real-world programs byte-identical across ra/mi/sys, alloc-eval checks on
8 game traces, downstream corpus (spacedb x3, rusty_alloc_default,
rusty_zstd). Unsafe census 973 -> 977 (UNSAFE.md); wasm baseline
20321 -> 20873 B gz (deliberate). corpus/linux-gates.sh no longer
hard-codes a /mnt/c path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The heartbeat in Heap::malloc_generic_body called options::deferred_free
while malloc_generic's `&mut self` was live and protected. A hook that
allocates (the openheimer test's) or frees re-entered the same heap through
a raw pointer under that borrow: undefined behaviour under Stacked Borrows.
Miri has reported it on every main run since 2.2.3, which kept the CI badge
red.

The heartbeat now only marks the hook DUE (Heap::deferred_due, set when
options::deferred_registered() peeks a hook). Every allocation entry in
alloc.rs fires a due hook BEFORE forming &mut Heap (fire_deferred_if_due);
malloc_slow keeps both arms in tail position, jumping to the cold
malloc_slow_deferred when due. Upstream fires the hook at the start of the
generic path; here it runs at the start of the next entry, within the
hook's "called occasionally, with a heartbeat" contract.

Cost, measured: big 91 -> 94, mixed 92.04 -> 94.26 Ir/op; perl/sqlite
allocator Ir +0.16 % / +0.22 %. The first shape (a call inside
malloc_slow) gave it a frame and cost big +9; refuted, recorded.

Miri: tests/openheimer.rs now passes, 37/37 (it aborted at the first UB
before, so the suites after it had not run under Miri in CI since 2.2.3).
Unsafe census 977 -> 981 (UNSAFE.md).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Ttimmahlax
Ttimmahlax merged commit 4b27d47 into main Oct 8, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants