Skip to content

perf(codegen): make the RS4GC root-spill estimate see allocating literals + call-result temporaries (#8583) - #8633

Closed
proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:perf/8583-spill-inline-aware
Closed

perf(codegen): make the RS4GC root-spill estimate see allocating literals + call-result temporaries (#8583)#8633
proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:perf/8583-spill-inline-aware

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Problem (#8583)

With the fan-out fix (#8585/#8586/#8589/#8593) shipped, the Claude Code 2.1.112 bundle still does not compile to completion: unit 4 hangs 3+ hours on one closure, perry_closure_cli_2_1_112_js__33499. Every other unit finishes; RS4GC on this one fans out (401s + 29.75 GiB RSS in a standalone replay before timeout).

It is the exact @main mechanism — a huge roots × safepoints product super-linear in rewrite-statepoints-for-gc — but the shadow-frame spill estimate never fired for it, so it stayed on native statepoints instead of spilling.

Root cause

__33499 is a giant constant nested array literal (a bundle data table): it lowers to 11,104 js_array_from_values allocations (one per sub-array) and ~20k GC safepoints. The spill estimate slot_count × count_safepoint_sites(body) under-counted both terms by ~100×:

  1. count_safepoint_sites counted only Call/New-family expressions — not allocating object/array literals, even though each lowers to a collecting runtime call that RS4GC gives a statepoint. So a data table (no Expr::Call at all) looked safepoint-free.
  2. slot_count was the shadow-slot map size = named pointer locals only, not the ~one pointer temporary each call result leaves live across later safepoints — the roots RS4GC actually relocates.

The #8586 post-rewrite budget assertion cannot catch this: it is checked after the rewrite completes, and here the rewrite never finishes.

Fix

  • collectors/safepoint_sites.rs: count Expr::{Object,ObjectSpread,ObjectAssign,Array,ArraySpread} as safepoints.
  • codegen/helpers.rs: count call-result temporaries in the root term — live_roots = slot_count + sites.

Both are needed: literals are the missing safepoints, temporaries the missing roots. Over-approximation biased toward spilling, matching the estimate's stated design (a false-positive shadow frame is cheap; a missed fan-out is not). A function needs ~2000+ allocating operations to cross the 32M default, i.e. only genuinely huge (usually module-init) functions — negligible over-spill risk on hot code. Composes with #8623 (32M threshold): __33499's estimate is ~411M, well over 32M.

Verification

A faithful synthetic (a function returning a 3,000-row nested constant array) reproduces __33499's shape. With the fix it spills (3001 roots × 3001 safepoints = 9,006,001), compiles in 64s instead of not finishing, and produces byte-correct output (t[1500][2] = 44). Unit tests added for literal/nested-literal safepoint counting.

Still pending (this machine is disk-starved by concurrent builds): full cargo test -p perry-codegen run and the end-to-end cli.js acceptance compile (unit 4 completes + cc --version). Will attach results. A pre-RS4GC loud-guard (turn any missed fan-out into a fast error instead of a multi-hour wedge) is a sensible defense-in-depth follow-up, as is the real perf optimization: lowering large constant data-table literals to a static rodata descriptor + one bulk-materialization call instead of N per-element allocations.

https://claude.ai/code/session_01TwxRkALrR9HKSF1zKLSTAF

Summary by CodeRabbit

  • Bug Fixes

    • Improved garbage-collection root-spill estimation for functions containing object and array literals.
    • Accounts for additional allocation points and call-result pointers, reducing the risk of underestimating required spill space.
    • Updated spill diagnostics to report more accurate root counts and relocation estimates.
  • Documentation

    • Documented the updated estimation behavior and its default threshold for very large functions.

Ralph Küpper added 2 commits August 23, 2026 08:12
… the RS4GC root-spill estimate (PerryTS#8583)

A minified bundle data table compiles as one giant array-of-arrays literal:
the Claude Code 2.1.112 bundle's `__33499` lowered to 11,104 `js_array_from_values`
allocations (one per sub-array) and ~20k GC safepoints. The shadow-frame spill
estimate (`slot_count * safepoint_sites`, PerryTS#8583) missed it on two counts, so the
function stayed on native statepoints and `rewrite-statepoints-for-gc` fanned out
for >3h / ~30GiB — never reaching the post-rewrite budget assertion, which only
fires after a rewrite that here never finished:

1. `count_safepoint_sites` counted only Call/New-family expressions, not
   allocating object/array literals, which lower to a collecting runtime call
   (`js_array_from_values` / `js_object_*`) and each get an RS4GC statepoint.
2. `slot_count` counted only named pointer locals (the shadow-slot map), not the
   ~one pointer temporary each call result leaves live across later safepoints —
   the roots RS4GC actually relocates.

The estimate now counts allocating literals as safepoints and adds per-safepoint
temporaries to the root count, so a data-table-shaped function spills to the
shadow frame like the module entry does. Over-approximation biased toward
spilling (a false positive is a cheap shadow frame; a missed fan-out is not); a
function needs ~2000+ allocating ops to cross the default threshold, i.e. only
genuinely huge (usually module-init) functions. Verified on a 3,000-row
nested-array synthetic: estimate 3001x3001 -> spilled, compiled in 64s instead of
not finishing, byte-correct output.

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

coderabbitai Bot commented Aug 23, 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: Pro Plus

Run ID: 23b9e722-3453-4f0e-a31e-a3fc4f45c1f7

📥 Commits

Reviewing files that changed from the base of the PR and between 38dac3b and 7fbf93a.

📒 Files selected for processing (3)
  • changelog.d/8633-spill-inline-aware-estimate.md
  • crates/perry-codegen/src/codegen/helpers.rs
  • crates/perry-codegen/src/collectors/safepoint_sites.rs

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


📝 Walkthrough

Walkthrough

RS4GC root-spill estimation now counts allocating object and array literals, plus call-result pointer temporaries. Tests cover nested literal safepoints, and the changelog documents the updated spill behavior.

Changes

Safepoint-aware root spill estimation

Layer / File(s) Summary
Allocating literal safepoint detection
crates/perry-codegen/src/collectors/safepoint_sites.rs
Object and array literal variants are classified as safepoints. Tests cover object literals and recursively nested arrays.
Expanded root-spill estimation
crates/perry-codegen/src/codegen/helpers.rs, changelog.d/8633-spill-inline-aware-estimate.md
Root-spill estimation adds safepoint sites to named root slots and reports the expanded root count and relocation estimate. The changelog documents the resulting shadow-frame spilling behavior.

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

Merge Risk: ⚪ Minimal · up to 7fbf9

This change adjusts root-spill estimation for allocating literals and call-result temporaries; no actionable merge-blocking risk remains based on the supplied evidence.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the RS4GC root-spill estimate changes for allocating literals and call-result temporaries.
Description check ✅ Passed The description clearly explains the problem, root cause, implementation, linked issue, verification, and remaining test work.
Docstring Coverage ✅ Passed Docstring check was indeterminate for this PR — some files could not be analyzed in time. Not blocking.
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 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 added a commit that referenced this pull request Aug 23, 2026
…poraries) (#8634)

* perf(codegen): count allocating literals + call-result temporaries in the RS4GC root-spill estimate (#8583)

A minified bundle data table compiles as one giant array-of-arrays literal:
the Claude Code 2.1.112 bundle's `__33499` lowered to 11,104 `js_array_from_values`
allocations (one per sub-array) and ~20k GC safepoints. The shadow-frame spill
estimate (`slot_count * safepoint_sites`, #8583) missed it on two counts, so the
function stayed on native statepoints and `rewrite-statepoints-for-gc` fanned out
for >3h / ~30GiB — never reaching the post-rewrite budget assertion, which only
fires after a rewrite that here never finished:

1. `count_safepoint_sites` counted only Call/New-family expressions, not
   allocating object/array literals, which lower to a collecting runtime call
   (`js_array_from_values` / `js_object_*`) and each get an RS4GC statepoint.
2. `slot_count` counted only named pointer locals (the shadow-slot map), not the
   ~one pointer temporary each call result leaves live across later safepoints —
   the roots RS4GC actually relocates.

The estimate now counts allocating literals as safepoints and adds per-safepoint
temporaries to the root count, so a data-table-shaped function spills to the
shadow frame like the module entry does. Over-approximation biased toward
spilling (a false positive is a cheap shadow frame; a missed fan-out is not); a
function needs ~2000+ allocating ops to cross the default threshold, i.e. only
genuinely huge (usually module-init) functions. Verified on a 3,000-row
nested-array synthetic: estimate 3001x3001 -> spilled, compiled in 64s instead of
not finishing, byte-correct output.

Claude-Session: https://claude.ai/code/session_01TwxRkALrR9HKSF1zKLSTAF

* docs(changelog): fragment for #8633

Claude-Session: https://claude.ai/code/session_01TwxRkALrR9HKSF1zKLSTAF

* test(codegen): keep the spill-threshold tests on the production formula

#8633 changes the estimate to (slot_count + sites) x sites, but #8623's two
endpoint tests still called root_relocation_estimate(slot, sites) directly, so
they pinned a formula production no longer uses. Both still reached the right
verdict (moderate 8.0M vs 12.0M, catastrophic 84.3M vs 11.3B -- same side of
the 32M threshold either way), but they would no longer catch a regression in
the live_roots composition this PR introduces.

Extract spill_live_root_count() so production and the tests share one
definition, and route both endpoint tests through it.

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed on main in ad25921 via #8634.

Audit notes are in #8634. Summary: the change is sound — the two-axis under-count is real and the over-approximation is biased the right way. All nine ratchets plus cargo fmt pass, perry-codegen is 1182/0, and I asserted your two new safepoint tests by name rather than inferring them from the count.

One thing I fixed on the landing branch: your change moves the production formula to (slot_count + sites) × sites, but #8623's two endpoint tests still called root_relocation_estimate(slot, sites) directly — so they were pinning a formula production no longer uses (moderate 8.0M vs 12.0M, catastrophic 84.3M vs 11.3B). Both still land on the correct side of the 32M threshold so nothing was broken, but those tests exist to guard both ends of that threshold and would no longer have caught a regression in the live_roots composition you're introducing. I extracted spill_live_root_count() so production and the tests share one definition.

Nice find on the Expr::Array safepoint gap — a data table with no Expr::Call reading as safepoint-free is a good catch.

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