From 330b3cb5a3c41221cb8db3baa98b9ad2ed88829e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 22 Aug 2026 21:38:46 +0200 Subject: [PATCH 1/2] perf(codegen): raise root-spill default to the measured fan-out cliff (#8620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DEFAULT_ROOT_SPILL_RELOCATIONS` was 4,000,000, low enough to spill moderate-fan-out functions whose native RS4GC statepoints would have optimized fine. On an ~8M-relocation entry function, spilling to the shadow frame was *slower* than the fan-out it replaced (#8620), so the default paid shadow-frame overhead for nothing. Measured the RS4GC fan-out cliff with synthetic entry functions compiled at -Os with spilling OFF (`PERRY_ROOT_SPILL_RELOCATIONS=0`), timing the `@main` codegen unit: 8.0M -> ~325 s (finished) 16.0M -> ~235 s (finished) 32.0M -> ~511 s / 8.5 min (finished) 40.0M -> did not finish in 20 min 48.0M -> did not finish in 20 min Fan-out finishes in bounded time up to 32M and does not past 40M, so the default is raised to 32,000,000 — the largest estimate whose fan-out still finished. Below it fan-out is the cheaper lowering; above it fan-out risks not finishing and the shadow frame wins. The change is compile-time only (spilled `main` is run-once init) and is backstopped by the post-RS4GC instruction-budget assertion (#8586), which fails loudly rather than hanging if a function this estimate misses still fans out. Pins the new default in a unit test. Claude-Session: https://claude.ai/code/session_01HHAsEkP5A9Y5rGx6kprJ9j --- crates/perry-codegen/src/codegen/helpers.rs | 68 +++++++++++++++++++-- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index 88ccc1f869..839815aab0 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -367,14 +367,33 @@ pub(crate) fn inline_hot_small_max_call_sites() -> u32 { /// bundle's 68 MB entry body measured 795 root slots × ~106k safepoints ≈ 8.4e7 /// and grew 439k → 6.5M instructions under RS4GC; without RS4GC the same unit /// optimized at `-Os` in ~5s). Real functions sit orders of magnitude below -/// this: hundreds of call sites times tens of slots is ~1e4–1e5. The default -/// is set well under the measured pathological point and well over ordinary -/// code, and the post-RS4GC instruction-budget assertion (#8583, inprocess.rs) -/// backstops any function the estimate misses. +/// this: hundreds of call sites times tens of slots is ~1e4–1e5. +/// +/// The default (#8620) is measured, not guessed. Synthetic entry functions with +/// a controlled `slots × safepoints` estimate were compiled at `-Os` with +/// spilling OFF (pure RS4GC fan-out) and the `@main` codegen unit timed: +/// +/// | estimate | fan-out finish | +/// |---------:|---------------:| +/// | 8.0M | ~325 s | +/// | 16.0M | ~235 s | +/// | 32.0M | ~511 s (8.5m) | +/// | 40.0M | did not finish in 20 min | +/// | 48.0M | did not finish in 20 min | +/// +/// The fan-out cliff sits between 32M and 40M, so the default is the largest +/// estimate whose fan-out still finished in bounded time. Below it fan-out is +/// the cheaper lowering — spilling a moderate function costs more than the +/// fan-out it avoids (an ~8M function spilled in 303 s vs 180 s fanned out, +/// #8620) — and above it fan-out risks not finishing and the shadow frame wins. +/// The former 4M default fired on ~8M functions that fan out fine in minutes. +/// The post-RS4GC instruction-budget assertion (#8586, inprocess.rs) backstops +/// any function this estimate misses: it fails loudly rather than hanging, so +/// raising the threshold is safe. /// /// `PERRY_ROOT_SPILL_RELOCATIONS=` overrides it; `0` disables spilling /// (every function stays on native statepoints, the pre-#8583 behavior). -const DEFAULT_ROOT_SPILL_RELOCATIONS: usize = 4_000_000; +const DEFAULT_ROOT_SPILL_RELOCATIONS: usize = 32_000_000; fn root_spill_relocation_threshold() -> usize { std::env::var("PERRY_ROOT_SPILL_RELOCATIONS") @@ -390,6 +409,45 @@ pub(crate) fn root_relocation_estimate(slot_count: usize, safepoint_sites: usize slot_count.saturating_mul(safepoint_sites) } +#[cfg(test)] +mod root_spill_default_tests { + use super::{root_relocation_estimate, DEFAULT_ROOT_SPILL_RELOCATIONS}; + + /// #8620: the default is pinned to the measured RS4GC fan-out cliff — the + /// largest estimate whose fan-out finished in bounded time (32M finished in + /// ~8.5 min; 40M/48M did not finish in 20 min). Change it only with fresh + /// measurement. + #[test] + fn default_sits_at_the_measured_fan_out_cliff() { + assert_eq!(DEFAULT_ROOT_SPILL_RELOCATIONS, 32_000_000); + } + + /// The moderate case the old 4M default wrongly spilled (#8620): ~8M + /// relocations (4000 root slots × ~2001 safepoints) fans out in minutes, so + /// under the new default it stays on native statepoints. + #[test] + fn moderate_fan_out_stays_on_statepoints() { + let est = root_relocation_estimate(4000, 2001); + assert_eq!(est, 8_004_000); + assert!( + est <= DEFAULT_ROOT_SPILL_RELOCATIONS, + "moderate estimate {est} must not exceed the default (would spill)", + ); + } + + /// The genuinely-catastrophic case (Claude Code `cli.js` `@main`, + /// ~795 slots × ~106k safepoints ≈ 8.4e7, never finishes at `-Os`) must + /// still spill under the new default. + #[test] + fn catastrophic_fan_out_still_spills() { + let est = root_relocation_estimate(795, 106_000); + assert!( + est > DEFAULT_ROOT_SPILL_RELOCATIONS, + "catastrophic estimate {est} must exceed the default (should spill)", + ); + } +} + /// Decide whether `func` should spill its roots to the shadow frame, and if so /// mark it (BEFORE its `enable_*_shadow_frame` call) and report it. Only /// meaningful under native stack-map roots — the shadow frame is already the From 5f2842ddf2fd2b3d1f914d0e9404f43207cd898c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 22 Aug 2026 21:39:34 +0200 Subject: [PATCH 2/2] changelog: root-spill default threshold raise (#8623) Claude-Session: https://claude.ai/code/session_01HHAsEkP5A9Y5rGx6kprJ9j --- changelog.d/8623-root-spill-threshold.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/8623-root-spill-threshold.md diff --git a/changelog.d/8623-root-spill-threshold.md b/changelog.d/8623-root-spill-threshold.md new file mode 100644 index 0000000000..cd0cc43be7 --- /dev/null +++ b/changelog.d/8623-root-spill-threshold.md @@ -0,0 +1,3 @@ +### Changed + +- Native GC-root spilling default raised from 4,000,000 to 32,000,000 estimated statepoint relocations (#8620, #8589). The 4M default fired on moderate-fan-out functions whose native `rewrite-statepoints-for-gc` statepoints optimize fine — on an ~8M-relocation entry function, spilling to the shadow frame was measured *slower* than the fan-out it replaced (303 s spilled vs 180 s fanned out), paying shadow-frame overhead for nothing. Measured synthetic entry functions (`@main` codegen unit, `-Os`, spilling off) fan out in bounded time up to 32M (~8.5 min) and do not finish past 40M (> 20 min), so the default is set to the largest estimate whose fan-out still finished. This is compile-time only — spilling a run-once init entry does not affect the emitted binary's runtime — and is backstopped by the post-RS4GC instruction-budget assertion (`PERRY_LL_RS4GC_MAX_INSTRS`, #8586), which fails loudly rather than hanging if a function this estimate misses still fans out. `PERRY_ROOT_SPILL_RELOCATIONS=` still overrides it; `0` disables spilling.