Skip to content

diag(gc): attribute a copying minor's root scan per scanner (#7915) - #7931

Merged
proggeramlug merged 1 commit into
mainfrom
gc/7915-box-root-scan
Aug 12, 2026
Merged

diag(gc): attribute a copying minor's root scan per scanner (#7915)#7931
proggeramlug merged 1 commit into
mainfrom
gc/7915-box-root-scan

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Closes #7915 (as far as it can be closed — see "What this does not do").

What #7915 asked, and what the measurement says

asyncpipe runs a 134 ms minor collection that copied zero objects and zero
bytes … the work is per registered root, not per copied object: 82 registered
runtime mutable root scanners over 218 455 pointer roots.

Both halves turn out to be artefacts of which counter was read. Reproduced on
origin/main, asyncpipe at 240 batches, PERRY_GC_TRACE=1 PERRY_GC_DIAG=1:

1. The "zero objects" minor handled 200 201 of them.

[gc-copy-minor] ran … survival_permille=895
  copied_objects=0 copied_bytes=0
  promoted_objects=200201 promoted_bytes=15860160 …

copied_objects counts survivor-space copies only. On a promoting cycle it is
structurally zero, which is the same shape as CLAUDE.md's "a gate must assert
its subject was live" — a counter that cannot be non-zero on the path being
measured. 101 ms / 200 201 objects is 505 ns/object, not a fixed per-cycle cost.

2. The root population is one registry, not 82 scanners.

This PR adds the per-scanner attribution that shows it. crate::box::scan_box_roots_mut
is 92 % of the pointer roots and 89.5 % of all root-scan time; the other 81
scanners are 8.8 % between them. The suspected culprit, the promise registry,
visits 12 slots and costs 4–43 µs.

3. …and that 89.5 % is not root-scan overhead, it is the object trace.

A cheap slot visit costs ~4 ns (intern_table_mutable_root_scanner: 16 384
slots, 1 144 pointer roots, 68 µs). The box scanner costs 98.8 ns/slot because
essentially every box slot is a from-space pointer, so visiting it in
CopyingMark mode evacuates the object it names — 93 380 of minor #1's 156 236
evacuations are driven straight out of box roots. 98.8 ns/pointer-root is the
same price as a measured full evacuation (123 ns/object).

So the fixed per-root overhead the issue is aimed at is about
4 ns × 265 000 ≈ 1 ms of a 65–101 ms pause. Generational filtering, dirty
tracking of the registries and cheaper registry representations all attack that
1 ms. I built the third one (slab-allocated box cells, so the scan streams
instead of chasing ~121 500 scattered 8-byte mallocs in hash order, twice per cycle) and it
measured +0.03 % instructions / −1.8 % cycles / no RSS change / pause
indistinguishable over 7 runs
, so it is not in this PR.

What is in this PR

Instruments and one lint fix — no behaviour change.

  • [gc-scanner-profile], per registered scanner: wall time and
    slots/pointer-roots/rewrites, sorted by time, once per copying minor, under
    the existing PERRY_GC_DIAG (no new knob — CLAUDE.md's GC knob
    kill-policy). Registration sites carry their own path as the name via
    stringify! in gc_init's reg_scanner!/reg_budgeted_scanner! macros, so
    a name cannot drift from the list it describes. The aggregate
    root_sources.runtime_mutable_scanners counter sums all 82 scanners and all
    passes into one number — enough to see that a cost is per-root, not enough to
    say which registry holds the roots, which is exactly how gc: asyncpipe collects at 1200-1650 ns/object, including a 122 ms minor that handled zero objects #7915 came to read as
    a property of "the registries".

  • Counters for the three unobserved gc_safepoint_moving_minor entry
    guards.
    Only budgeted was counted, so a precise safepoint that returned
    without collecting had one observable explanation and three invisible ones.
    [gc-incremental] now also prints
    safepoints_blocked(in_alloc=… unsafe_zone=… root_lock=…). This is what makes
    "an active setjmp/try region suppresses the copying minor" (perf(runtime): promise resolution with an object pays a 78.5% thenable-probe tax (~9% of asyncpipe) #7910) a
    testable claim rather than an argument about the guard list.

  • scripts/gc_runtime_root_holders.py learns the two registration macros.
    They expand to gc_register_* calls but the gate matches the call name, so
    introducing them dropped its registered-scanner count from 122 to 24 and every
    holder reached only from gc_init would have read as uncovered. Its
    MIN_REGISTERED floor caught it, which is what that floor is for.

Where the cost actually is (and it is not the collector)

asyncpipe's young_survival_permille is 695 and 895, against 0–4
for every other churn-shaped program in the corpus — and it is a churn program
by construction: each batch builds 200 Reqs / 200 Oks / 200 strings, folds
them into six numbers, and drops the batch.

A one-env-var probe (temporary, not in this PR) makes scan_box_roots_mut skip
marking while still rewriting:

control box roots do not mark
minor #1 young_survival_permille 695 24
minor #1 copied_objects 156 236 6 634 (−95.8 %)
minor #1 freed_bytes 5 456 696 17 451 568 (3.2×)
minor #2 200 201 objects promoted never happens
exit 0, correct output 134 (SIGABRT)

asyncpipe's real live young set is ~6 600 objects. The other 96 % is alive
only because a completed async activation's boxed locals are still GC roots

the transform boxes every body local of every async function and nothing ever
frees or clears one, so every local of all 48 000 completed activations is a
permanent root. At 6 634 live objects a minor costs ~0.8 ms instead of 62 ms.

The SIGABRT is the control, not a failure: not marking is unsound, which is
precisely why the fix is clearing an activation's boxes at its terminal state
rather than not marking them. That is a codegen change in the async transform
and wants its own issue; this PR contributes the evidence that it is the lever.
Full write-up: gc-handoff/ROOTS-NOTES.md §7–8.

Validation

  • cargo test --release -p perry-runtime --lib (RUST_TEST_THREADS=1),
    GC/copying + box suites: 127 passed, 0 failed.
  • All 19 corpus programs byte-identical to node, exit 0.
  • cargo fmt --all -- --check, scripts/check_file_size.sh,
    scripts/gc_runtime_root_holders.py (+ --self-test),
    scripts/addr_class_inventory.py (+ --self-test).

Summary by CodeRabbit

  • New Features

    • Added optional garbage-collection diagnostics through PERRY_GC_DIAG.
    • Reports per-scanner timing, root statistics, rewrite activity, and aggregate analysis.
    • Added visibility into moving safepoints blocked by allocation, unsafe zones, and root locks.
  • Documentation

    • Documented GC root-scan attribution findings, including box-root and promise-registry costs.
    • Improved scanner registration coverage checks for diagnostics tooling.

The aggregate `root_sources.runtime_mutable_scanners` counter sums all
82 registered scanners and every pass into one number, which is enough
to see that a cost is per-root and not enough to say WHICH registry
holds the roots. #7915 was written on that gap: "82 scanners over
218,455 pointer roots" is arithmetically true, and the attribution says
it is one scanner -- `crate::box::scan_box_roots_mut` is 92% of the
pointer roots and 89.5% of root-scan time, while the suspected promise
registry visits 12 slots.

`[gc-scanner-profile]` rides the existing PERRY_GC_DIAG knob (no new
knob) and reports, per scanner, wall time and slots/pointer-roots/
rewrites. Registration sites carry their own path as the name via
`stringify!` in two `gc_init` macros, so a name cannot drift from the
list it describes.

Also adds counters for the three `gc_safepoint_moving_minor` entry
guards that had none -- a precise safepoint that returned without
collecting had one observable explanation and three invisible ones --
and teaches `gc_runtime_root_holders.py` the new registration macros,
which its MIN_REGISTERED floor caught immediately.

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

coderabbitai Bot commented Aug 12, 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: 57caabec-8963-495f-890a-2ef0b696deba

📥 Commits

Reviewing files that changed from the base of the PR and between 14e291f and c248708.

📒 Files selected for processing (9)
  • changelog.d/7915-root-scan-attribution.md
  • crates/perry-runtime/src/gc/copying.rs
  • crates/perry-runtime/src/gc/instruments.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/policy.rs
  • crates/perry-runtime/src/gc/roots.rs
  • crates/perry-runtime/src/gc/scanner_profile.rs
  • crates/perry-runtime/src/gc/tests/copying.rs
  • scripts/gc_runtime_root_holders.py

📝 Walkthrough

Walkthrough

GC root scanners now retain registration names and expose per-scanner copying-minor timing and root statistics. Moving safepoint diagnostics now count allocation, unsafe-zone, and root-lock blocks. Scanner-holder lint recognizes the new registration macros.

Changes

GC diagnostics

Layer / File(s) Summary
Named scanner registration
crates/perry-runtime/src/gc/roots.rs, crates/perry-runtime/src/gc/mod.rs, crates/perry-runtime/src/gc/tests/copying.rs
Scanner entries retain registration names. Runtime registrations use named wrappers while preserving budgets, sources, and feature gates.
Copying-minor scanner profiling
crates/perry-runtime/src/gc/scanner_profile.rs, crates/perry-runtime/src/gc/copying.rs
Scanner calls record timing and statistics deltas. Diagnostic reports are sorted, emitted, and reset.
Moving safepoint guard diagnostics
crates/perry-runtime/src/gc/instruments.rs, crates/perry-runtime/src/gc/policy.rs, crates/perry-runtime/src/gc/mod.rs
Diagnostics count safepoints blocked by allocation, unsafe zones, and root locks.
Scanner coverage and findings
scripts/gc_runtime_root_holders.py, changelog.d/7915-root-scan-attribution.md
Coverage checks recognize named registration macros. The changelog documents scanner attribution and guard counters.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested reviewers: jdalton, thehypnoo

✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch gc/7915-box-root-scan

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.

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.

gc: asyncpipe collects at 1200-1650 ns/object, including a 122 ms minor that handled zero objects

1 participant