Skip to content

perf(gc): cut a copying minor's fixed cost (intern young log, skip empty array-tail tables, young-only prunes) - #11634

Merged
proggeramlug merged 5 commits into
mainfrom
perf/11549-minor-fixed-cost
Sep 28, 2026
Merged

proggeramlug merged 5 commits into
mainfrom
perf/11549-minor-fixed-cost

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Part of #11549

Cuts the fixed instruction cost of a copying minor, the part that does not depend on how much survives. This is direction 2 of #11549, the half #11612 was waiting for.

Where a minor's fixed cost went (measured first)

I put temporary instruction counters (perf_event_open, user instructions, not committed) around every phase of run_copied_minor_attempt and around each registered root scanner. Per copying minor on main:

item alloc loop dotenv/parse (4 MB nursery)
intern table scan, 8192 slots x 2 passes 401k 525k
array-tail transition tables scan, 2 x 8192 slots x 2 passes 167k 167k
array-tail prune, 2 x 8192 slots 98k 98k
built-in closure metadata full retain (dead-owner prune) small 275k
small-int / ASCII-char string caches, 384 slots x 2 passes 20k 21k
scanner loop overhead (~100 scanners x 2 passes), stack/shadow roots, everything else ~100k rest

Counters on the same runs: the intern table held 0 live entries on the alloc loop and ~820 on dotenv, of which ~33 (4%) were young. Both array-tail tables were empty on every workload measured: they are only filled by class X extends Array tail transitions. The small-int caches only ever hold longlived, pinned strings, so every minor visit there is a no-op. visit_tagged_raw_addr was hot because of these walks (it ran for every intern and array-tail slot), not because of its own cost.

What changed

  1. Intern table gets a young-entry log (string/intern.rs), the same gc/young_log.rs mechanism the transition cache, descriptors and shapes already use. Both writers note the slot before publishing. A minor visits only logged slots and re-logs the ones still young. A full collection walks the whole table and rebuilds the log. Rule 2 applies: under debug_assertions/tests the minor walk first re-derives the young slots from the table and panics if the log misses one.
  2. Array-tail tables are skipped while they have never been written (object/array_tail_transition.rs). An array_tail_occupied flag is set before the first publish by the only production writer. Nothing but test_clear ever returns a slot to EMPTY (pruning leaves tombstones), so "flag clear" means every slot is empty. Scan and prune return at once. Under debug/tests, the skip re-checks that both tables really are empty.
  3. Built-in closure metadata gets a young-only prune (builtin_closure_metadata.rs). Its young log already existed for the scan, but the dead-owner prune always ran a full retain over both maps. On a minor it now asks only about logged owners. A minor can only find a minor-collectible owner dead, and every such owner is logged.
  4. A minor skips the small-int / ASCII-char string caches (string/format.rs). Both writers publish only longlived, pinned strings. A minor can neither move nor free those, and does not trace through them. Full-scope passes still walk both caches. Under debug/tests the skip asserts that every entry is pinned and non-young.

Every skip is conservative. When the precondition cannot be shown, the old walk runs.

Results (perrymaster, perf stat -e instructions:u, --release builds, PERRY_NO_AUTO_OPTIMIZE=1, under /tmp/perry-bench-lock.d)

Fixed cost per minor on the alloc loop. Measured as the instruction difference between a 1 MB and a 16 MB nursery, divided by the difference in minor count (610 vs 67 minors): main 753k per minor, this branch after its first two changes 98k per minor (−87%). The last two changes do not touch this loop.

Per-iteration instructions. Two-N differential, median of 3. main = 2d1f1d7a9, pr = main + #11612, fix = this branch, pr+fix = both. Every run's output matched Node 26.5.1.

workload nursery main pr fix pr+fix
alloc loop 16 MB 469.2 469.2 463.6 463.6
alloc loop 4 MB 483.1 483.1 466.0 466.0
dotenv/parse 16 MB 2,874,228 2,004,579 2,870,116 2,002,873
dotenv/parse 4 MB 2,621,396 2,013,350 2,617,949 2,006,974
moment/parse_format 16 MB 2,381,253 1,922,163 2,313,907 1,915,001
moment/parse_format 4 MB 2,207,614 1,899,954 2,266,636 1,917,756
qs/parse_nested 16 MB 1,898,146 1,897,732 1,897,827 1,897,091
qs/parse_nested 4 MB 1,947,141 1,946,152 1,943,517 1,945,973
qs/stringify_nested 16 MB 5,932,635 5,915,121 5,963,317 5,916,671
qs/stringify_nested 4 MB 6,279,339 6,369,309 6,377,776 6,361,757
date-fns/format_add 16 MB 938,872 925,764 937,953 925,117
date-fns/format_add 4 MB 944,197 931,521 939,965 927,920
validator/batch 16 MB 19,802,598 20,282,725 20,060,554 19,618,629
validator/batch 4 MB 20,536,355 19,485,990 19,918,435 19,999,807

Noise: moment and validator vary by about ±1–2% run to run on an identical binary, even with setarch -R. I measured moment at 4 MB repeatedly: pr ranged 20.03–20.30 G total and pr+fix 19.99–20.47 G. So the moment and validator rows can't resolve differences below about 2%. The alloc, dotenv, qs and date-fns rows are stable.

The allocation-loop cost of a 4 MB nursery is gone. It was +3.0% (main 469.2 → 483.1). With this branch, 4 MB costs 466.0, which is below main's default of 469.2.

Does #11612 + a 4 MB nursery now meet "no compute regression, no RSS increase"? No, so the default is unchanged

pr+fix at a 4 MB nursery, compared with main at its default:

workload instructions peak RSS (median of 5, KB)
alloc loop −0.7% flat (RSS is noisy here)
dotenv/parse −30.2% 24,340 vs 31,272
moment/parse_format −19.5% 33,160 vs 40,348
date-fns/format_add −1.2% 24,320 vs 40,476
qs/parse_nested +2.5% 159,608 vs 192,512
qs/stringify_nested +7.2% 299,656 vs 473,004
validator/batch +1.0% (within noise) noisy

The qs rows still regress. That cost is not fixed per-minor cost: qs builds large nested structures, so a smaller nursery copies more survivors (qs/stringify goes from 24 to 105 minors). A global smaller nursery therefore still trades compute for RSS on survivor-heavy workloads, and no nursery default change is included here. Direction 2 still needs survival-aware young pacing, for example shrinking only while observed survival is low. Cutting fixed cost makes that pacing cheap but does not replace it. What remains of the fixed cost on dotenv, per minor: canonical-keys prune (~170k), symbol side tables (~155k), object-cache/canonical-keys scan (~180k), descriptors (~140k), stack/shadow roots (~150k).

Correctness

  • New tests in gc/tests/minor_fixed_cost.rs and builtin_closure_metadata.rs:
    • A young interned string reachable only through the intern table is evacuated, and its slot is rewritten through the logged walk. The walk row proves the walk was partial.
    • An old interned string is not visited (visited == 0).
    • Array-tail tables are skipped only while unarmed.
    • The young prune drops dead young owners and never touches old ones, even under an "everything is dead" predicate.
    • Real small-int cache entries satisfy the skip's precondition.
  • Sabotage tests (all go red on a missing write-site note):
    • An intern writer that publishes without arm_intern_young panics with young log for string.intern_table does not name ….
    • An array-tail publish without arming panics with … without arming the flag first.
    • A suppressed built-in-closure note is rejected by the young prune.
    • A young string written into the small-int cache fails the precondition check.
  • RUST_TEST_THREADS=1 cargo test --release -p perry-runtime --lib: 4714 passed, 0 failed (CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16).
  • Seeded schedule sweeps (PERRY_GC_SCHEDULE_SEED, rate 0.05, PERRY_GC_PROTECT_FROMSPACE=1, quarantine armed: [gc-fromspace-protect] retired_set=#N lines present), output compared against Node, on pr+fix:
  • PERRY_GC_VERIFY_EVACUATION=1 with seeds:
    • mixed fixture: 20/20 at the default nursery, 20/20 at 1 MB;
    • dotenv: 20/20;
    • moment: 17/20, and the 3 failures are the pre-existing seeds above.
  • Gap A/B vs main (--filter test_gap_ with gc, array, regex, string): identical on both arms. gc: 61 pass, the same 4 compile failures on both. array: 113 pass, the same 2 compile failures on both. regex: 14 pass, the same test_gap_regex_replace_dyn_regex_with_http compile failure on both. string: 56 pass.
  • scripts/gc_runtime_root_holders.py is OK on this branch and on main. The policy.rs flag reported earlier did not reproduce on either. scripts/thread_exit_address_globals.py is OK. File-size is OK. cargo fmt --check is OK.
  • scripts/run_lint_gates.sh with SKIP_COMPILE_GATES=1: 98 of 101 script gates pass, compile tier not run. The 3 failures:
    • "Public benchmark evidence freshness": known red on main.
    • "Type-check Windows": cargo xwin is not installed on this host.
    • "GC store-site inventory": red on this branch before the last commit, which adds the missing marker. I did not re-run the full lint gates after that commit; scripts/gc_store_site_inventory.py alone passes.

Not run

  • The full gap suite, the compile tier of the lint gates, and the Windows cargo xwin check.
  • cargo test for crates other than perry-runtime.
  • The macOS/arm64 path: every measurement here is Linux x86_64.

Summary by CodeRabbit

  • Performance
    • Reduced reported minor-GC fixed cost on allocation-bound loops from about 753,000 to 98,000 instructions.
    • Allocation-loop performance is now similar with a 4 MB nursery (466.0 instructions per iteration) and a 16 MB nursery (469.2).
    • The default nursery size is unchanged.

@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 454e55e9-5966-4200-8ffb-abc3f7bea601

📥 Commits

Reviewing files that changed from the base of the PR and between 295473c and 492c62b.

📒 Files selected for processing (11)
  • changelog.d/11634-gc-minor-fixed-cost.md
  • crates/perry-runtime/src/gc/dead_owner.rs
  • crates/perry-runtime/src/gc/tests/minor_fixed_cost.rs
  • crates/perry-runtime/src/gc/tests/mod.rs
  • crates/perry-runtime/src/object/array_tail_transition.rs
  • crates/perry-runtime/src/object/mod.rs
  • crates/perry-runtime/src/object/native_module.rs
  • crates/perry-runtime/src/object/native_module/callable_exports/builtin_closure_metadata.rs
  • crates/perry-runtime/src/string/format.rs
  • crates/perry-runtime/src/string/intern.rs
  • crates/perry-runtime/src/string/mod.rs

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 5 remain after this review.


📝 Walkthrough

Walkthrough

Minor-GC scans and pruning now use young-entry logs or an occupancy flag for selected runtime tables. Tests check those tracking rules and their completeness. The changelog reports a reduction in fixed-cost instructions and records allocation-loop measurements.

Changes

Minor GC table scans

Layer / File(s) Summary
Intern-table young-slot tracking
crates/perry-runtime/src/string/intern.rs, crates/perry-runtime/src/string/mod.rs, crates/perry-runtime/src/gc/tests/*, crates/perry-runtime/src/gc/tests/mod.rs, changelog.d/11634-gc-minor-fixed-cost.md
Intern writes log minor-relevant slots. Young scans visit logged slots, while full scans rebuild the log. Tests cover evacuation, old entries, and missing log entries. The changelog reports performance measurements and the described scan changes.
Array-tail table occupancy
crates/perry-runtime/src/object/mod.rs, crates/perry-runtime/src/object/array_tail_transition.rs, crates/perry-runtime/src/gc/tests/minor_fixed_cost.rs
Array-tail writers set an occupancy flag before publishing entries. Root scanning and pruning skip the tables when the flag is clear. Tests exercise the flag and verify detection of unarmed entries.
Small-string cache minor-scope checks
crates/perry-runtime/src/string/format.rs, crates/perry-runtime/src/string/mod.rs, crates/perry-runtime/src/gc/tests/minor_fixed_cost.rs
Young-scope visits skip both caches after debug and test builds check that entries are pinned and not minor-collectible. Tests cover valid cache entries and a young-entry violation.
Young-only closure metadata pruning
crates/perry-runtime/src/object/native_module/callable_exports/builtin_closure_metadata.rs, crates/perry-runtime/src/object/native_module.rs, crates/perry-runtime/src/gc/dead_owner.rs
The young-only prune examines logged owners, removes dead candidates from both metadata maps, and retains live minor-collectible candidates. Minor-cycle fan-out uses this prune. Tests check dead and live owners and incomplete logs.

Priority: ➖ Normal

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

Change: Refactor

Suggested reviewers: claude

Merge Risk: ⚪ Minimal · up to 492c6

No identified issue remains that should delay merging after normal checks.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 492c6

The changed collection paths rely on new tracking rules, but the inspected production writers record or constrain references before publication, and no reachable security bypass was identified. Some runtime behavior remains unverified without execution.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — If a skipped reference were reachable, its immediate exposure would be runtime memory integrity or process availability during collection. The inspected paths establish no new credential, tenant, network, or service authority.

Trust Boundaries and Controls

  • observed — The two inspected production intern writers log a minor-relevant slot before publishing its pointer. Young scanning repairs logged pointers and retains still-relevant slots; full scanning visits every slot.
  • observed — Closure-metadata scanning rekeys both maps after forwarding and re-logs surviving young owners. Its young prune removes only candidates the collector classifies as dead; the full prune examines both maps.
  • observed — The inspected production small-string-cache writers allocate long-lived strings, pin them, then publish them through the root-store operation. Young scans skip these caches; full scans continue to visit them.

Resilience and Maintainability Implications

  • inferred — Release-build correctness depends on preserving log-before-publication and cache-residency invariants when writers change; the inspected debug/test checks would not catch a later violation in release.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 74.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 50 functions across 10 files. (1 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly identifies the main change: reducing copying minor-GC fixed cost through targeted optimizations. It is specific and related to the changeset, although somewhat long.
Description check ✅ Passed The description is comprehensive. It explains the motivation, four code changes, measured results, correctness tests, known pre-existing failures, and checks that were not run. It does not use the tem…
Full details: Docstring Coverage

Explanation

Docstring coverage is 74.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 50 functions across 10 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

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
proggeramlug merged commit 560c85f into main Sep 28, 2026
58 of 60 checks passed
@proggeramlug
proggeramlug deleted the perf/11549-minor-fixed-cost branch September 28, 2026 16:33
proggeramlug pushed a commit that referenced this pull request Sep 28, 2026
Keep both sides of string/intern.rs: the atom table beside #11634's
young-only intern log. The atom table holds strong heap pointers, so it
gets its own young log (arm-before-publish in place(), cleared on rehash,
debug-asserted) and is scanned in both minor and full passes.
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