Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/8633-spill-inline-aware-estimate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
fix(codegen): the RS4GC root-spill estimate (#8583) now counts allocating object/array literals as GC safepoints and counts call-result pointer temporaries as roots, so a minified constant data table — a giant array-of-arrays literal that lowers to thousands of `js_array_from_values` allocations (the Claude Code bundle's `__33499`: 11,104 of them, ~20k safepoints) — spills to the shadow frame like the module entry does instead of staying on native statepoints and making `rewrite-statepoints-for-gc` fan out for hours. Previously `count_safepoint_sites` saw only `Call`/`New`-family expressions (a data table has none) and the root term counted only named pointer locals (not the ~one live temporary per call result), so the estimate under-counted by ~100x and the function was never spilled. Over-approximation biased toward spilling; a function needs ~2000+ allocating operations to cross the default threshold, so only genuinely huge (usually module-init) functions are affected.
26 changes: 21 additions & 5 deletions crates/perry-codegen/src/codegen/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -468,16 +468,32 @@ pub(super) fn maybe_spill_roots_to_shadow_frame(
return;
}
let sites = crate::collectors::count_safepoint_sites(body);
let estimate = root_relocation_estimate(slot_count, sites);
// #8583 (unit-4 / `__33499` of the Claude Code bundle): `slot_count` is the
// shadow-slot map size — the count of *named* pointer-typed locals — but
// that is NOT the root population RS4GC relocates. A call-heavy minified
// closure produces one pointer-typed *temporary* per call result (the
// constructed IR carries ~one `alloca ptr addrspace(1)` per call), and each
// is live across the later safepoints; those temporaries dominate the true
// root count yet are invisible to `collect_pointer_typed_locals`. `__33499`
// measured ~20.3k named-and-anonymous pointer roots × ~20.3k safepoints, but
// its `slot_count` alone was ~100x smaller, so `slot_count × sites` fell
// under the threshold, the function stayed on statepoints, and RS4GC then
// fanned out for >3 h / ~30 GiB (never reaching the #8586 post-rewrite
// budget assertion, which only fires *after* the rewrite it never finishes).
// Count each safepoint as contributing ~one live pointer temporary. This is
// an over-approximation biased toward spilling — the intended direction (a
// false-positive shadow frame is cheap; a missed fan-out is not).
let live_roots = slot_count.saturating_add(sites);
let estimate = root_relocation_estimate(live_roots, sites);
if estimate <= threshold {
return;
}
func.request_shadow_frame_spill();
eprintln!(
"perry: `{fn_name}` keeps its {slot_count} GC roots in a shadow frame instead of \
statepoints: an estimated {estimate} relocations ({slot_count} roots × {sites} \
safepoints) would make rewrite-statepoints-for-gc fan-out super-linear in the \
optimizer (> {threshold}). The function is still compiled at the requested \
"perry: `{fn_name}` keeps its {live_roots} GC roots (incl. call-result temporaries) in a \
shadow frame instead of statepoints: an estimated {estimate} relocations ({live_roots} \
roots × {sites} safepoints) would make rewrite-statepoints-for-gc fan-out super-linear in \
the optimizer (> {threshold}). The function is still compiled at the requested \
optimization level; only its GC-root representation changes, and its roots stay \
precise (#8583). Override with PERRY_ROOT_SPILL_RELOCATIONS."
);
Expand Down
41 changes: 41 additions & 0 deletions crates/perry-codegen/src/collectors/safepoint_sites.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,21 @@ fn is_safepoint(e: &Expr) -> bool {
| Expr::Await(_)
| Expr::Yield { .. }
| Expr::AsyncFirstCall { .. }
// #8583 (unit-4 / `__33499`): object and array literals allocate via
// a runtime call (`js_array_from_values`, `js_object_*`) that can
// collect, so RS4GC inserts a statepoint at each. A minified data
// table is a single giant array-of-arrays — `__33499` lowered to
// 11,104 `js_array_from_values` calls, none of which is an `Expr::Call`,
// so the pre-fix count saw almost no safepoints, the function was not
// spilled, and RS4GC then fanned out for >3 h. Counting these keeps
// the estimate an over-approximation biased toward spilling (the safe
// direction; a hoisted/constant literal that emits no call only costs
// a cheap shadow frame).
| Expr::Object(_)
| Expr::ObjectSpread { .. }
| Expr::ObjectAssign { .. }
| Expr::Array(_)
| Expr::ArraySpread(_)
)
}

Expand Down Expand Up @@ -216,4 +231,30 @@ mod tests {
let nested = call(vec![call(vec![]), call(vec![])]);
assert_eq!(count_safepoint_sites(&[Stmt::Expr(nested)]), 3);
}

#[test]
fn array_and_object_literals_are_safepoints() {
// #8583: allocating literals lower to a collecting runtime call
// (`js_array_from_values` / `js_object_*`) and must count. A minified
// data table is a giant array-of-arrays with no `Expr::Call` at all —
// the pre-fix count saw zero safepoints and the function was not spilled.
let inner = |a, b| Expr::Array(vec![Expr::Number(a as f64), Expr::Number(b as f64)]);
// [[..],[..],[..]] — one outer Array + three inner Arrays = 4 safepoints.
let table = Expr::Array(vec![inner(1, 2), inner(3, 4), inner(5, 6)]);
assert_eq!(count_safepoint_sites(&[Stmt::Expr(table)]), 4);

// An object literal is also an allocating safepoint.
let obj = Expr::Object(vec![("k".to_string(), Expr::Number(1.0))]);
assert_eq!(count_safepoint_sites(&[Stmt::Expr(obj)]), 1);
}

#[test]
fn nested_array_literals_recurse() {
// Deeply nested constant arrays count every allocating level — the
// `__33499` shape (11,104 `js_array_from_values` from one literal).
let leaf = || Expr::Array(vec![Expr::Number(0.0)]);
let rows: Vec<Expr> = (0..10).map(|_| leaf()).collect();
// 1 outer + 10 inner = 11.
assert_eq!(count_safepoint_sites(&[Stmt::Expr(Expr::Array(rows))]), 11);
}
}
Loading