perf(codegen): make the RS4GC root-spill estimate see allocating literals + call-result temporaries (#8583) - #8633
Conversation
… 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
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
Included review availability: Your plan provides up to 8 included reviews per hour; 6 remain after this review. 📝 WalkthroughWalkthroughRS4GC 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. ChangesSafepoint-aware root spill estimation
Estimated code review effort: 2 (Simple) | ~10 minutes Merge Risk: ⚪ Minimal · up to 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)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
…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>
|
Landed on 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 One thing I fixed on the landing branch: your change moves the production formula to Nice find on the |
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
@mainmechanism — a hugeroots × safepointsproduct super-linear inrewrite-statepoints-for-gc— but the shadow-frame spill estimate never fired for it, so it stayed on native statepoints instead of spilling.Root cause
__33499is a giant constant nested array literal (a bundle data table): it lowers to 11,104js_array_from_valuesallocations (one per sub-array) and ~20k GC safepoints. The spill estimateslot_count × count_safepoint_sites(body)under-counted both terms by ~100×:count_safepoint_sitescounted onlyCall/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 (noExpr::Callat all) looked safepoint-free.slot_countwas 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: countExpr::{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-codegenrun 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
Documentation