Skip to content

gc: price the idle reclaim by swept bytes, not old-gen occupancy (#9589) - #9643

Closed
proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:fix/9589-reducer-productivity
Closed

gc: price the idle reclaim by swept bytes, not old-gen occupancy (#9589)#9643
proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:fix/9589-reducer-productivity

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

The idle-time reclaim that landed for #9589 switches itself off on the workload it was built for. This is the pricing bug that does it, found by measuring the reducer on the compiled claude-code TUI.

What the subject showed

A real session, 279 collections behind it, both attempts landing in the idle stretch after a paste:

[gc-idle-reclaim] start attempt=1 external_collections=279 backoff_shift=0 old_in_use=93608664
[gc-idle-reclaim] done  old_in_use=93608664->93606360 reclaimed_old=2304 freed=2370304 productive=false backoff_shift=1
[gc-idle-reclaim] start attempt=2 external_collections=281 backoff_shift=1 old_in_use=93731232
[gc-idle-reclaim] done  old_in_use=93731232->93736616 reclaimed_old=0    freed=509784  productive=false backoff_shift=2
[gc] blocks: non_general=143 (130 live)      <- identical across both cycles
[gc-old-free] reusable_bytes=49986912 -> 50095240

Two fulls, both scored unproductive, activity requirement doubled twice inside a minute of idle. An earlier 300 s run scored four fulls unproductive that had freed 8.7, 5.7, 8.7 and 11.7 MB.

Why

arena::old_gen_in_use_bytes is the sum of the live old blocks' bump offsets. A non-moving sweep hands dead objects to the old-gen free list; the block keeps its offset. Only a whole-block release moves that number, and 130 blocks stayed live through both cycles. So the reducer was pricing a mark-sweep by a meter a mark-sweep cannot move.

The unit tests passed because their litter (litter_old_gen_with_dead_promises) fills blocks that end up entirely dead, so offsets do drop there. The test could only pass — a shape the production workload never has.

The change

Price the full by the freed-bytes count the cycle already reports to note_cycle_completed. The occupancy delta stays in the counters and on the diag line as the whole-block-release signal it actually is, next to a new reusable= field — the old-gen free-list residue, 50 MB on that session, which only compaction can return to the OS.

Two new tests price a completion against live occupancy, so no delta is available: one asserts a sweep that freed the bar is productive (this fails on the old pricing, left: 0 right: 1), one asserts a byte under the bar still backs off.

perry-runtime 3053 passed / 0 failed with --test-threads=1; gc::tests::idle_reclaim 11/11.

What this does not fix

The 50 MB on that free list stays there. Returning it needs compaction, and the reducer's cycle cannot compact by construction: evacuation_policy_allowed = !low_pause_non_moving, low_pause_non_moving = progress_kind.is_budgeted(), and old-page defrag only ever runs as part of a moving minor — GcCycleState::new_full takes no page selection at all. Idle-time compaction is a separate change that re-opens #7917 (defrag went opt-in pending a fragmentation stress corpus that still does not exist) and carries a pause at a moment the user can end with a keystroke. Filing it separately with this evidence.

Fixes the reducer's half of #9589.

https://claude.ai/code/session_011qE5TRRJFzqxN44K34AnG2

Summary by CodeRabbit

  • Bug Fixes

    • Improved idle-time garbage collection so productive cleanup cycles are recognized based on the memory actually freed.
    • Prevented productive cycles from incorrectly triggering backoff when old-generation occupancy remains unchanged.
    • Updated garbage-collection diagnostics to report the pricing threshold and reusable memory.
  • Tests

    • Added coverage for productive cycles meeting the reclamation threshold.
    • Added coverage confirming cycles below the threshold still trigger backoff.

…ryTS#9589)

The reducer scored a full productive only when `old_gen_in_use_bytes`
dropped. That number is the sum of the live old blocks' bump offsets, and
a non-moving sweep cannot lower it: dead objects go back to the old-gen
free list and the block keeps its offset, so only a whole-block release
moves it. The unit tests passed because their litter fills blocks that
end up entirely dead; every real workload does not.

Measured on the compiled claude-code TUI, in a session with 279
collections behind it: two reducer fulls freed 2.37 MB and 0.51 MB while
occupancy stood at 93.6 MB with 50 MB already on the old-gen free list,
both scored unproductive, and the activity requirement had doubled twice
inside a minute of idle. The reducer was switching itself off on exactly
the workload it was built for.

Price the full by the freed-bytes count the cycle already reports. The
occupancy delta stays in the counters and on the diag line as the
whole-block-release signal it actually is, next to a new `reusable=`
field: the free-list residue that only compaction can return to the OS,
which a budgeted (non-moving) cycle cannot do.

Claude-Session: https://claude.ai/code/session_011qE5TRRJFzqxN44K34AnG2
@coderabbitai

coderabbitai Bot commented Sep 3, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 49cacec6-d068-40e0-9fbc-8727807516b4

📥 Commits

Reviewing files that changed from the base of the PR and between 4415089 and a0f111c.

📒 Files selected for processing (3)
  • changelog.d/9589-idle-reclaim-productivity.md
  • crates/perry-runtime/src/gc/idle_reclaim.rs
  • crates/perry-runtime/src/gc/tests/idle_reclaim.rs

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

Idle reclaim now prices full GC cycles by bytes freed during sweeping instead of old-generation occupancy changes. Diagnostics report reusable free-list bytes. Tests cover productive and below-threshold cycles.

Changes

Idle reclaim productivity

Layer / File(s) Summary
Price cycles by swept bytes
crates/perry-runtime/src/gc/idle_reclaim.rs, changelog.d/9589-idle-reclaim-productivity.md
note_cycle_completed uses freed_bytes for productivity pricing. Occupancy deltas remain available for whole-block release tracking. Diagnostics report reusable= bytes. Test support can pin starting old-generation occupancy. The changelog documents the change.
Validate productivity thresholds
crates/perry-runtime/src/gc/tests/idle_reclaim.rs
Tests verify that cycles at the productivity bar reset backoff and cycles below the bar still back off.

Estimated code review effort: 2 (Simple) | ~15 minutes

Merge Risk: ⚪ Minimal · up to a0f11

Idle GC now recognizes productive non-moving sweeps based on bytes freed, preventing unnecessary reclaim backoff while retaining occupancy diagnostics. Threshold behavior is covered, with no current merge-readiness risk identified.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely states the primary change: pricing idle reclaim by swept bytes instead of old-generation occupancy.
Description check ✅ Passed The description provides a detailed summary, motivation, production evidence, implementation details, related issue reference, test results, and scope limitations. It does not use all template heading…
Docstring Coverage ✅ Passed Docstring coverage is 85.71% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 7 functions across 2 files. (1 skipped: 1 u…
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.
Full details: Description check

Explanation

The description provides a detailed summary, motivation, production evidence, implementation details, related issue reference, test results, and scope limitations. It does not use all template headings or include the checklist, but the required change information is substantially complete.

Full details: Docstring Coverage

Explanation

Docstring coverage is 85.71% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 7 functions across 2 files. (1 skipped: 1 unsupported.)

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed via merge train #9650 (rebase-merge, authorship preserved).

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