Skip to content

arena: recycled-block pool — block round-trips through the allocator were the tree.ts RSS term (#7438) - #7449

Merged
proggeramlug merged 5 commits into
mainfrom
gc/7438-tree-rss
Aug 5, 2026
Merged

arena: recycled-block pool — block round-trips through the allocator were the tree.ts RSS term (#7438)#7449
proggeramlug merged 5 commits into
mainfrom
gc/7438-tree-rss

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Progress on #7438 (does not fully close it — see the floor analysis at the end). All numbers from the dedicated quiet bench host (Mac mini M1/8 GB, Spotlight off), same-session A/B, best-of-3.

What the investigation ruled out

  • The young-cap dial cannot close the RSS cell. Matrix on tree.ts: effective cap 64/32/16/8 MB → 235/221/226/168 MB peak RSS at 9.8/18.7/20.2/19.6 s wall. RSS barely responds until the wall has already doubled (smaller Eden ⇒ less time to die ⇒ more promotion).
  • Old-gen churn and longlived are innocent (post-gc: old-generation hole free list — swept holes become reusable capacity (#7437) #7443): whole-block reclaim frees 37.7 of 37.9 MB per full on tree; old in-use settles at ~1 MB (36.7 MB one-time early spike); the longlived arena holds ~1 MB — the earlier 84-block reading was a mislabeled DIAG line (longlived= counted every non-general block; relabeled here).

What was actually burning the memory

Block dealloc/realloc round-trips through the process allocator. Every promoted-then-dropped cohort released its old-gen blocks; the next cohort's promotions landed in fresh mimalloc segments, so the union of ever-dirtied pages grew with cumulative promotion volume, not the concurrent high-water. mimalloc's own accounting shows it directly on tree: peak commit 257.5 MiB scavenge-on vs 140.5 MiB scavenge-off for a ~35 MB live set.

Fix — recycled-block pool (arena/block.rs)

Released arena blocks enter a capped thread-local pool instead of round-tripping; the single block-reservation funnel (try_alloc_block) reuses them before minting fresh mappings. Pooled pages are MADV_FREE'd (the OS can take them under pressure; contents are undefined on reuse, which every consumer tolerates — blocks are bump-filled from offset 0 and re-registered by the adopting arena). No collection decision changes; thread teardown still frees for real; the forced-allocation-failure test hook keeps priority over the pool.

The 64 MB cap is measured, not guessed: no pool → 225 MB, 64 MB → 190 MB, 128 MB → 210 MB (an oversized pool holds resident MADV_FREE'd pages past the optimum).

Results (same session, same host)

pool before (#7443 build)
tree ON 9.85 s / 190 MB 9.96 s / 225 MB
tree OFF 9.96 s / 101 MB 9.89 s / 102 MB (untouched, as designed)
churn 4.23 s / 24 MB unchanged
cycles 1.09 s / 29 MB unchanged
retain 4.51 s / 425 MB unchanged
deeplist 1.74 s / 158 MB 166 MB
probe 12 heapUsed / RSS 59.9 MB / 191 MB unchanged

peak commit on tree ON: 257.5 → 225.8 MiB. Counters are byte-identical everywhere (the pool makes no collection decisions), so the freshly pinned #7446 baseline needs no re-pin.

Why #7438 stays open

The remaining ON−OFF gap (~88 MB) is now down to structure the pool cannot touch: the young cap's committed high-water (64 vs 16 MB — the OFF arm's scale never grows), the one-time early promotion spike before the survival lock settles, and the promoting design's double-residency of the live set during transitions. Closing the last stretch means reducing early promotion volume (ramping the tenuring lock in) — a separate, riskier change tracked in the issue.

Also included: the DIAG sweep line relabel (longlived=non_general=) that misled this investigation.

Summary by CodeRabbit

  • Performance

    • Improved memory efficiency by reusing released arena memory, reducing allocation overhead and peak memory usage.
    • Recycled memory is capped to prevent unbounded growth and is released when execution threads end.
  • Diagnostics

    • Updated garbage-collection reporting to more accurately describe non-general blocks.
  • Documentation

    • Added benchmark results and memory-usage comparisons for the recycling improvements.

proggeramlug pushed a commit that referenced this pull request Aug 5, 2026
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fd59779d-115d-4301-bbed-4d08c4921697

📥 Commits

Reviewing files that changed from the base of the PR and between 502c473 and 028a266.

📒 Files selected for processing (8)
  • changelog.d/7449-recycled-block-pool.md
  • crates/perry-codegen/src/codegen/clone_suffix_tests.rs
  • crates/perry-codegen/src/codegen/mod.rs
  • crates/perry-runtime/src/arena/block.rs
  • crates/perry-runtime/src/arena/mod.rs
  • crates/perry-runtime/src/arena/reset.rs
  • crates/perry-runtime/src/arena/tests.rs
  • crates/perry-runtime/src/gc/oldgen.rs

📝 Walkthrough

Walkthrough

The runtime adds a capped thread-local pool for recycled arena blocks, reuses exact-size pooled blocks, and updates reclamation paths and diagnostics. Tests cover reuse, accounting, and thread cleanup. The change also reformats and reorders an unrelated codegen test module.

Changes

Arena block pool

Layer / File(s) Summary
Pool implementation and allocation
crates/perry-runtime/src/arena/block.rs, crates/perry-runtime/src/arena/mod.rs
Adds a 64 MiB thread-local pool, optional MADV_FREE, crate-visible helpers, and exact-size block reuse with reset state.
Reclamation integration and validation
crates/perry-runtime/src/arena/reset.rs, crates/perry-runtime/src/arena/tests.rs, changelog.d/7449-recycled-block-pool.md
All listed reclamation paths try the pool before deallocation. Tests check reuse, accounting, offset reset, and thread cleanup.
Old-generation diagnostic labels
crates/perry-runtime/src/gc/oldgen.rs
Changes sweep diagnostics from longlived to non_general and includes survivor, long-lived, and old blocks in the description.

Code generation test cleanup

Layer / File(s) Summary
Clone-suffix test organization
crates/perry-codegen/src/codegen/clone_suffix_tests.rs, crates/perry-codegen/src/codegen/mod.rs
Reformats the final assertion and moves the test-only module declaration before production modules.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ArenaReclamation
  participant BlockPool
  participant try_alloc_block
  ArenaReclamation->>BlockPool: block_pool_put(block.data, block.size)
  BlockPool-->>ArenaReclamation: pooled or rejected
  try_alloc_block->>BlockPool: take(exact_size)
  BlockPool-->>try_alloc_block: pooled block or no match
  try_alloc_block-->>ArenaReclamation: ArenaBlock with reset offset
Loading

Possibly related PRs

  • PerryTS/perry#7050 — Both changes modify arena block allocation and deallocation in crates/perry-runtime/src/arena/block.rs.
  • PerryTS/perry#7443 — Both changes modify arena reset and reclamation paths, but use different mechanisms.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the recycled-block pool and its purpose of addressing allocator round-trip RSS growth.
Description check ✅ Passed The description is detailed and explains the motivation, implementation, measurements, issue status, and benchmark results.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch gc/7438-tree-rss

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-runtime/src/arena/block.rs`:
- Around line 81-84: Update the BLOCK_POOL storage to wrap each (data, size)
pair in an owning Drop type that deallocates the raw block using its original
layout during thread-local teardown. Keep BLOCK_POOL_BYTES as metadata and
ensure pooled entries are no longer merely cleared without deallocation. Add a
regression test that verifies retained pooled blocks are released when the
thread exits.
- Around line 104-109: Update the block recycling path around the Unix madvise
call to check the return value of MADV_FREE before pushing the block into
BLOCK_POOL. If MADV_FREE fails, do not recycle that block; otherwise preserve
the existing BLOCK_POOL and BLOCK_POOL_BYTES updates. Ensure the behavior
remains correct for non-page-aligned blocks created by try_alloc_block and the
matching deallocation path.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6cbd0a25-0ef4-47bd-a62a-68ae7deca620

📥 Commits

Reviewing files that changed from the base of the PR and between 502c473 and 5733045.

📒 Files selected for processing (8)
  • changelog.d/7449-recycled-block-pool.md
  • crates/perry-codegen/src/codegen/clone_suffix_tests.rs
  • crates/perry-codegen/src/codegen/mod.rs
  • crates/perry-runtime/src/arena/block.rs
  • crates/perry-runtime/src/arena/mod.rs
  • crates/perry-runtime/src/arena/reset.rs
  • crates/perry-runtime/src/arena/tests.rs
  • crates/perry-runtime/src/gc/oldgen.rs

Comment thread crates/perry-runtime/src/arena/block.rs
Comment thread crates/perry-runtime/src/arena/block.rs
Ralph Küpper added 5 commits August 5, 2026 16:00
…lived'

The count is n_blocks - general_n — survivors + longlived + old — but the
label said longlived, which reads as an 84 MB longlived-arena leak on a
workload whose longlived arena holds 1 MB (measured while diagnosing
#7438: the trace's per-region arena_bytes tells the truth, the DIAG line
did not).
…ipped (#7438)

Block dealloc/realloc round-trips through the process allocator were
the dominant term of tree.ts's scavenge-on peak RSS: each
promoted-then-dropped cohort released its old-gen blocks and the next
cohort's promotions landed in fresh allocator segments, so ever-dirtied
pages grew with cumulative promotion volume — peak commit 257.5 MiB vs
140.5 MiB scavenge-off for a ~35 MB live set, while a cap matrix showed
the young-cap dial barely moves RSS (64/32/16 MB caps -> 235/221/226 MB
peak RSS). Reclaimed blocks now enter a capped 64 MB thread-local pool
(MADV_FREE'd so the OS can take the pages under pressure) and the block
reservation funnel reuses them before minting fresh mappings. No
collection decision changes; thread teardown still frees for real.
tree.ts peak RSS: no pool 225 MB, 64 MB pool 190 MB, 128 MB pool 210 MB
- pooled pages are MADV_FREE'd but stay resident until the OS wants
them, so an oversized pool holds free pages past the optimum.
The thread-local held a bare Vec<(*mut u8, usize)>, so a thread exiting
with a non-empty pool ran the Vec's destructor — freeing the Vec's own
buffer and stranding every block it pointed at, up to
BLOCK_POOL_CAP_BYTES per thread. perry/thread's spawn/parallelMap give
each agent its own arena and GC, so repeated spawns leaked without bound
in the one change whose purpose is lowering RSS.

Ownership lives on the pool value rather than in a drain called from
Arena::drop: both are TLS destructors, their relative order is
unspecified, and LocalKey::with panics once its own destructor has run,
so a drain could be skipped exactly when it is needed.
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audited and merged, with one fix pushed onto the branch.

The pool leaked its blocks at thread exit. BLOCK_POOL was a thread_local! holding a bare Vec<(*mut u8, usize)>. When the TLS destructor ran it dropped the Vec — freeing the Vec's own buffer and stranding every block the raw pointers addressed, up to BLOCK_POOL_CAP_BYTES (64 MB) per exiting thread. perry/thread's spawn/parallelMap give each agent its own arena and GC, so repeated spawns leaked without bound — in the one change whose purpose is lowering RSS. Nothing anywhere drained it; Arena::drop frees the arena's own blocks and never sees the pool.

Fixed by giving the pool a BlockPool newtype whose Drop releases the blocks. I deliberately did not add a drain called from Arena::drop: both are TLS destructors, their relative order is unspecified, and LocalKey::with panics once its own destructor has run — so a drain could be skipped exactly when it is needed. Ownership on the pool's own value is order-independent by construction.

Added block_pool_is_per_thread_and_drops_with_its_thread, which spawns a thread, pools a block, and joins. Stating its limit honestly: the dealloc is cfg!(test)-skipped by the same #4665 convention as Arena::drop, so the test asserts the destructor runs and that pools are per-thread — it cannot observe the free. Its value is that a regression to a bare Vec, or to a cross-destructor drain, still has to keep this path alive.

What I verified of your work:

  • MADV_FREE contents-undefined is safe, and I checked it rather than taking the claim: the arena walkers bound at block.offset, never block.size (while self.offset < block.offset), so the undefined region past the bump pointer is never scanned. That matters because it is precisely the hazard gc: old-generation hole free list — swept holes become reusable capacity (#7437) #7443's own comments describe — stale arena bits misread as live from-space pointers.
  • Exact-size reuse (rposition(|&(_, s)| s == size)) keeps the Layout valid for the eventual dealloc, which a best-fit pool would have broken.
  • Cap overflow and the test hook correctly fall through to real dealloc.
  • 1705 lib tests pass, 0 failed; cargo fmt --check clean; both pool tests confirmed live by name rather than inferred from a green summary.

I also rebased the branch onto today's main (it carried #7443's pre-squash commits), and confirmed all four of your commits survived by subject before force-pushing.

Two notes on the writeup, both in your favour: ruling out the young-cap dial with an actual 4-point matrix, and catching that the longlived= DIAG line had been mislabelling every non-general block — that relabel is included here and is the kind of thing that silently misdirects the next investigation.

@proggeramlug
proggeramlug merged commit 91487ff into main Aug 5, 2026
7 of 10 checks passed
@proggeramlug
proggeramlug deleted the gc/7438-tree-rss branch August 5, 2026 14:04
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

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.

1 participant