diff --git a/changelog.d/8679-rs4gc-budget-spill-retry.md b/changelog.d/8679-rs4gc-budget-spill-retry.md new file mode 100644 index 0000000000..d2dbba098d --- /dev/null +++ b/changelog.d/8679-rs4gc-budget-spill-retry.md @@ -0,0 +1,4 @@ +Fixed large native functions whose statepoint rewrite crossed the LLVM +instruction budget but fell below the root-spill estimate threshold. Perry now +re-lowers only those functions with precise shadow-frame roots and retries the +same optimization pipeline instead of refusing the whole codegen unit. diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index 5567aace3f..a4ac5a6658 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -387,9 +387,10 @@ pub(crate) fn inline_hot_small_max_call_sites() -> u32 { /// 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. +/// The post-RS4GC instruction budget (#8586/#8679, inprocess.rs) backstops any +/// function this estimate misses: it re-lowers that function onto a precise +/// shadow frame and retries before LLVM's optimizer can hang, so raising the +/// estimate threshold is safe. /// /// `PERRY_ROOT_SPILL_RELOCATIONS=` overrides it; `0` disables spilling /// (every function stays on native statepoints, the pre-#8583 behavior). diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index ec2993ba9e..af2c873b23 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -3353,28 +3353,30 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> progress.phase(3, "object ready; releasing generated IR"); return result; } - let units = llmod.render_codegen_units(n_units); - log::debug!( - "perry-codegen: split '{}' into {} codegen units", - hir.name, - units.len() - ); - // #7154: dump the units. The comment above used to claim `PERRY_SAVE_LL` - // took the single-text path — it never did; this `return` fires before - // the `PERRY_SAVE_LL` write below. So `--trace llvm` silently emitted - // NOTHING for any module past `MIN_CALLABLES_TO_SPLIT`, i.e. exactly the - // largest modules, which is where a static IR audit - // (`scripts/gc_root_dominance_check.py`) most needs to look — a corpus - // that quietly omits its biggest members makes a clean verdict - // meaningless. One file per unit, not one concatenation: the units are - // already materialized here, so this adds no peak. - if let Ok(save_dir) = std::env::var("PERRY_SAVE_LL") { - for (i, unit) in units.iter().enumerate() { - let filename = format!("{}/{}.unit{}.ll", save_dir, module_prefix, i); - let _ = std::fs::write(&filename, unit); + loop { + let units = llmod.render_codegen_units(n_units); + log::debug!( + "perry-codegen: split '{}' into {} codegen units", + hir.name, + units.len() + ); + // #7154: dump the units. The comment above used to claim + // `PERRY_SAVE_LL` took the single-text path — it never did; this + // return fires before the write below. One file per unit, not one + // concatenation: the units are already materialized here, so this + // adds no peak. + if let Ok(save_dir) = std::env::var("PERRY_SAVE_LL") { + for (i, unit) in units.iter().enumerate() { + let filename = format!("{}/{}.unit{}.ll", save_dir, module_prefix, i); + let _ = std::fs::write(&filename, unit); + } + } + match crate::linker::compile_units_to_object(&units, opts.target.as_deref()) { + Ok(object) => return Ok(object), + Err(error) if apply_rs4gc_budget_retry(&mut llmod, &error)? => continue, + Err(error) => return Err(error), } } - return crate::linker::compile_units_to_object(&units, opts.target.as_deref()); } // exp/llvm-inprocess Phase 2: `PERRY_LLVM_INPROCESS=native` constructs @@ -3382,29 +3384,60 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // textual); `=diff` builds both arms and diffs them. Unit-split and // emit_ir_only paths above stay textual (they fall into the in-process // *transport* under these values, so no clang subprocess either way). - if let Some(result) = try_native_construction(&llmod, opts.target.as_deref(), &module_prefix) { + if let Some(result) = + try_native_construction(&mut llmod, opts.target.as_deref(), &module_prefix) + { return result; } - let ll_text = llmod.to_ir(); - log::debug!( - "perry-codegen: emitted {} bytes of LLVM IR for '{}' ({} interned strings)", - ll_text.len(), - hir.name, - strings.len() - ); - // Save .ll files when PERRY_SAVE_LL= is set - if let Ok(save_dir) = std::env::var("PERRY_SAVE_LL") { - let filename = format!("{}/{}.ll", save_dir, module_prefix); - let _ = std::fs::write(&filename, &ll_text); - } - if opts.emit_ir_only { - Ok(ll_text.into_bytes()) - } else { - crate::linker::compile_ll_to_object(&ll_text, opts.target.as_deref()) + loop { + let ll_text = llmod.to_ir(); + log::debug!( + "perry-codegen: emitted {} bytes of LLVM IR for '{}' ({} interned strings)", + ll_text.len(), + hir.name, + strings.len() + ); + // Save .ll files when PERRY_SAVE_LL= is set + if let Ok(save_dir) = std::env::var("PERRY_SAVE_LL") { + let filename = format!("{}/{}.ll", save_dir, module_prefix); + let _ = std::fs::write(&filename, &ll_text); + } + if opts.emit_ir_only { + return Ok(ll_text.into_bytes()); + } + match crate::linker::compile_ll_to_object(&ll_text, opts.target.as_deref()) { + Ok(object) => return Ok(object), + Err(error) if apply_rs4gc_budget_retry(&mut llmod, &error)? => continue, + Err(error) => return Err(error), + } } } +/// Consume the typed post-RS4GC budget signal on text-transport paths. The +/// native constructors have the same loop closer to their LLVM modules; text +/// compilation returns through `linker`, so its retry belongs at the last +/// point where the lowering-owned `LlModule` is still available. +#[cfg(feature = "llvm-inprocess")] +fn apply_rs4gc_budget_retry( + llmod: &mut crate::module::LlModule, + error: &anyhow::Error, +) -> Result { + let Some(violations) = crate::inprocess::rs4gc_budget_retry(error) else { + return Ok(false); + }; + crate::native_emit::apply_budget_spill_retry(llmod.functions_mut(), &violations)?; + Ok(true) +} + +#[cfg(not(feature = "llvm-inprocess"))] +fn apply_rs4gc_budget_retry( + _llmod: &mut crate::module::LlModule, + _error: &anyhow::Error, +) -> Result { + Ok(false) +} + /// exp/llvm-inprocess: unit-split twin of [`try_native_construction`]. #[cfg(feature = "llvm-inprocess")] fn try_native_units( @@ -3444,7 +3477,7 @@ fn try_native_units( /// in-process mode is requested, so the flag can never silently no-op. #[cfg(feature = "llvm-inprocess")] fn try_native_construction( - llmod: &crate::module::LlModule, + llmod: &mut crate::module::LlModule, target: Option<&str>, module_prefix: &str, ) -> Option>> { @@ -3471,7 +3504,7 @@ fn try_native_construction( #[cfg(not(feature = "llvm-inprocess"))] fn try_native_construction( - _llmod: &crate::module::LlModule, + _llmod: &mut crate::module::LlModule, _target: Option<&str>, _module_prefix: &str, ) -> Option>> { diff --git a/crates/perry-codegen/src/function.rs b/crates/perry-codegen/src/function.rs index 3807c36395..39d2f54186 100644 --- a/crates/perry-codegen/src/function.rs +++ b/crates/perry-codegen/src/function.rs @@ -302,12 +302,35 @@ impl LlFunction { /// into every caller's hot loop. Skip the frame entirely; the /// to_ir() rewrite pass keys off `shadow_frame_slot.is_some()`, /// so no matching pop is emitted either. - /// #8583: route this function's precise roots through the heap shadow - /// frame instead of native statepoints. Must be called BEFORE - /// `enable_shadow_frame` / `enable_post_init_shadow_frame` so the frame is - /// built in shadow form. No effect once a frame has been emitted. - pub fn request_shadow_frame_spill(&mut self) { + /// #8583/#8679: route this function's precise roots through the heap + /// shadow frame instead of native statepoints. + /// + /// The estimate-driven path calls this before `enable_shadow_frame`, while + /// the post-RS4GC budget retry calls it after lowering is complete. In the + /// latter case the native-root path deliberately retained the original + /// `js_shadow_slot_bind` calls until final rendering, so converting the + /// recorded stack-map request back into a shadow-frame push is a complete + /// re-lowering: final rendering keeps those binds, adds the matching pops, + /// and drops the GC strategy so RS4GC skips the function on retry. + /// + /// Returns `true` only when this call changed the lowering. A retry driver + /// uses that to reject an impossible second retry instead of looping. + pub fn request_shadow_frame_spill(&mut self) -> bool { + if self.force_shadow_frame { + return false; + } self.force_shadow_frame = true; + self.stack_map_requested = false; + if self.shadow_frame_requested + && self.shadow_frame_slot.is_none() + && self.stack_map_slot_count != 0 + { + self.emit_shadow_frame_push( + self.stack_map_slot_count, + self.shadow_frame_post_init_region, + ); + } + true } /// Whether this function spills its roots to the shadow frame (#8583). @@ -1363,6 +1386,53 @@ mod define_header_tests { ); } + /// #8679's budget is learned only after RS4GC, so the durable fallback + /// necessarily asks an already-lowered function to change root lowering. + /// This pins that late request to the same complete shadow-frame shape as + /// the estimate-driven early request, including balanced return pops. + #[test] + fn a_post_lowering_spill_request_rebuilds_the_shadow_frame() { + use crate::codegen::helpers::NativeRootsPin; + use crate::types::{I64, PTR}; + const STRATEGY: &str = "gc \"statepoint-example\""; + + let _native = NativeRootsPin::native(); + let mut function = LlFunction::new("late_spill", crate::types::VOID, vec![]); + function.enable_post_init_shadow_frame(0); + let idx = function + .reserve_shadow_slot() + .expect("native lowering reserves a precise-root slot"); + let root = function.alloca_entry(I64); + function.entry_allocas_push_store(I64, "0", &root); + function.entry_setup_call_void( + "js_shadow_slot_bind", + &[(crate::types::I32, &idx.to_string()), (PTR, &root)], + ); + function.mark_entry_init_boundary(); + let entry = function.create_block("entry"); + let _ = entry.call(I64, "may_collect", &[]); + entry.ret_void(); + + let native_ir = function.to_ir(); + assert!(native_ir.contains(STRATEGY)); + assert!(!native_ir.contains("@js_shadow_frame_enter")); + assert!(!native_ir.contains("@js_shadow_slot_bind")); + + assert!( + function.request_shadow_frame_spill(), + "the first late request must change the lowering" + ); + assert!( + !function.request_shadow_frame_spill(), + "a repeated request must report that no retry progress is possible" + ); + let shadow_ir = function.to_ir(); + assert!(!shadow_ir.contains(STRATEGY), "{shadow_ir}"); + assert!(shadow_ir.contains("call ptr @js_shadow_frame_enter(i32 1)")); + assert!(shadow_ir.contains("call void @js_shadow_slot_bind(i32 0")); + assert!(shadow_ir.contains("call void @js_shadow_frame_pop(i64")); + } + /// `force_external` drops only the linkage keyword. The codegen-unit path /// depends on that and on nothing else changing. #[test] diff --git a/crates/perry-codegen/src/inprocess.rs b/crates/perry-codegen/src/inprocess.rs index 513dd8c21b..aa1ffbc654 100644 --- a/crates/perry-codegen/src/inprocess.rs +++ b/crates/perry-codegen/src/inprocess.rs @@ -27,6 +27,7 @@ use inkwell::passes::PassBuilderOptions; use inkwell::targets::{ CodeModel, FileType, InitializationConfig, RelocMode, Target, TargetMachine, TargetTriple, }; +use inkwell::values::AsValueRef; use inkwell::OptimizationLevel; use crate::linker::STATEPOINT_REWRITE_PASSES; @@ -383,12 +384,10 @@ fn module_instruction_census( /// Instruction budget for ONE function after `rewrite-statepoints-for-gc`. /// -/// This is an assertion about the estimate that keeps relocation fan-out out -/// of LLVM's input (#8583), not an optimization policy: a function past it is -/// refused loudly, never demoted. The #8421 contract — every function is -/// optimized at the level the plan asked for — stays intact; what this adds -/// is that an estimator miss fails in seconds with the function's name and -/// sizes instead of hanging the build for hours. +/// This is the measured backstop for the estimate that keeps relocation +/// fan-out out of LLVM's optimizer input (#8583). A function past it is sent +/// back to codegen for a shadow-frame spill and then compiled again at the +/// requested optimization level (#8679); it is never demoted or refused. /// /// Calibrated between the two measured points of #8128 on the Next 16.3.0 /// production bundle: the largest post-rewrite function that finished @@ -405,6 +404,52 @@ enum RewriteBudget { Warn(usize), } +/// One function that must be re-lowered onto a shadow frame before LLVM can +/// safely optimize its codegen unit. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Rs4gcBudgetViolation { + /// LLVM symbol of the function to spill. + pub name: String, + /// Instruction count before RS4GC, when the caller requested a census. + pub pre_instructions: Option, + /// Instruction count after RS4GC and before the optimizer. + pub post_instructions: usize, + /// Active per-function instruction limit. + pub cap: usize, +} + +/// Typed backend signal consumed by the codegen retry loops. Keeping this as +/// an error lets every existing LLVM API stop before the super-linear +/// optimizer, while the type (preserved through `anyhow` contexts) prevents +/// callers from scraping a diagnostic string for function names. +#[derive(Debug)] +struct Rs4gcBudgetExceeded { + violations: Vec, +} + +impl std::fmt::Display for Rs4gcBudgetExceeded { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for (index, violation) in self.violations.iter().enumerate() { + if index != 0 { + writeln!(f)?; + } + write!(f, "{}", rewrite_budget_message(violation, true))?; + } + Ok(()) + } +} + +impl std::error::Error for Rs4gcBudgetExceeded {} + +/// Recover an RS4GC spill request through any diagnostic contexts added by +/// the native or text transport layers. +pub(crate) fn rs4gc_budget_retry(error: &anyhow::Error) -> Option> { + error + .chain() + .find_map(|cause| cause.downcast_ref::()) + .map(|request| request.violations.clone()) +} + fn parse_rewrite_budget(value: Option<&str>) -> RewriteBudget { match value.map(str::trim) { None | Some("") => RewriteBudget::Error(DEFAULT_RS4GC_MAX_INSTRS), @@ -427,21 +472,92 @@ fn parse_rewrite_budget(value: Option<&str>) -> RewriteBudget { } fn rs4gc_instruction_budget() -> RewriteBudget { + #[cfg(test)] + if let Some(budget) = TEST_RS4GC_BUDGET.with(std::cell::Cell::get) { + return budget; + } parse_rewrite_budget(std::env::var("PERRY_LL_RS4GC_MAX_INSTRS").ok().as_deref()) } -/// Every defined function whose post-rewrite body exceeds `cap`. +#[cfg(test)] +thread_local! { + static TEST_RS4GC_BUDGET: std::cell::Cell> = const { + std::cell::Cell::new(None) + }; +} + +/// Thread-local budget seam for native-construction tests. Unlike mutating +/// `PERRY_LL_RS4GC_MAX_INSTRS`, this cannot make concurrently-running LLVM +/// tests spuriously spill or fail. +#[cfg(test)] +pub(crate) fn with_test_rs4gc_budget(cap: usize, run: impl FnOnce() -> T) -> T { + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + TEST_RS4GC_BUDGET.set(self.0); + } + } + let old = TEST_RS4GC_BUDGET.replace(Some(RewriteBudget::Error(cap))); + let _restore = Restore(old); + run() +} + +#[cfg(test)] +/// Return the producer thread's test-only error budget for worker inheritance. +pub(crate) fn test_rs4gc_budget_cap() -> Option { + TEST_RS4GC_BUDGET.with(|budget| match budget.get() { + Some(RewriteBudget::Error(cap)) => Some(cap), + _ => None, + }) +} + +#[cfg(test)] +/// Install the producer's test budget around one worker-thread backend call. +pub(crate) fn with_inherited_test_rs4gc_budget( + cap: Option, + run: impl FnOnce() -> T, +) -> T { + match cap { + Some(cap) => with_test_rs4gc_budget(cap, run), + None => run(), + } +} + +/// Names of functions that actually entered RS4GC. A shadow-spilled function +/// still lives in a native-roots module, but carries no GC strategy and must +/// not trip the retry budget a second time merely because its ordinary body +/// is large. +fn rs4gc_functions(module: &inkwell::module::Module<'_>) -> std::collections::HashSet { + let mut names = std::collections::HashSet::new(); + let mut function = module.get_first_function(); + while let Some(f) = function { + if f.count_basic_blocks() > 0 { + let gc = unsafe { llvm_sys::core::LLVMGetGC(f.as_value_ref()) }; + if !gc.is_null() + && unsafe { std::ffi::CStr::from_ptr(gc) }.to_bytes() == b"statepoint-example" + { + names.insert(f.get_name().to_string_lossy().into_owned()); + } + } + function = f.get_next_function(); + } + names +} + +/// Every RS4GC-participating function whose post-rewrite body exceeds `cap`. fn rs4gc_budget_violations( module: &inkwell::module::Module<'_>, cap: usize, + rewritten_functions: &std::collections::HashSet, ) -> Vec<(String, usize)> { let mut over = Vec::new(); let mut function = module.get_first_function(); while let Some(f) = function { if f.count_basic_blocks() > 0 { + let name = f.get_name().to_string_lossy().into_owned(); let n = function_instruction_count(f); - if n > cap { - over.push((f.get_name().to_string_lossy().into_owned(), n)); + if n > cap && rewritten_functions.contains(&name) { + over.push((name, n)); } } function = f.get_next_function(); @@ -449,18 +565,23 @@ fn rs4gc_budget_violations( over } -fn rewrite_budget_message(name: &str, post: usize, cap: usize, pre: Option) -> String { - let before = pre +fn rewrite_budget_message(violation: &Rs4gcBudgetViolation, retry: bool) -> String { + let before = violation + .pre_instructions .map(|n| format!(" (it was {n} before the rewrite)")) .unwrap_or_default(); + let outcome = if retry { + "Perry will re-lower this function with precise roots in a shadow frame, then retry the \ + unit at the requested optimization level" + } else { + "the warning-only budget override leaves the function for LLVM to optimize" + }; format!( - "rewrite-statepoints-for-gc grew `{name}` to {post} instructions{before}; the \ - per-function budget is {cap}. LLVM's optimizer is super-linear on statepoint \ - relocation fan-out of this size and the compile would not finish in practical \ - time, so the unit is refused instead of being left to hang. Perry does not lower \ - the optimization level for it: the fix is to keep this function's GC roots out \ - of the relocation set or to split it (#8583). Override with \ - PERRY_LL_RS4GC_MAX_INSTRS= (raise), =warn: (warn only) or =0 (disable)." + "rewrite-statepoints-for-gc grew `{}` to {} instructions{before}; the \ + per-function budget is {}. LLVM's optimizer is super-linear on statepoint \ + relocation fan-out of this size; {outcome} (#8679). Override with \ + PERRY_LL_RS4GC_MAX_INSTRS= (raise), =warn: (warn only) or =0 (disable).", + violation.name, violation.post_instructions, violation.cap ) } @@ -470,25 +591,34 @@ fn enforce_rs4gc_instruction_budget( module: &inkwell::module::Module<'_>, budget: RewriteBudget, pre: &std::collections::HashMap, + rewritten_functions: &std::collections::HashSet, ) -> Result<()> { let (cap, fatal) = match budget { RewriteBudget::Off => return Ok(()), RewriteBudget::Error(cap) => (cap, true), RewriteBudget::Warn(cap) => (cap, false), }; - let over = rs4gc_budget_violations(module, cap); + let over = rs4gc_budget_violations(module, cap, rewritten_functions); if over.is_empty() { return Ok(()); } - let messages: Vec = over - .iter() - .map(|(name, post)| rewrite_budget_message(name, *post, cap, pre.get(name).copied())) + let violations: Vec = over + .into_iter() + .map(|(name, post_instructions)| Rs4gcBudgetViolation { + pre_instructions: pre.get(&name).copied(), + name, + post_instructions, + cap, + }) .collect(); if fatal { - return Err(anyhow!("{}", messages.join("\n"))); + return Err(anyhow::Error::new(Rs4gcBudgetExceeded { violations })); } - for m in messages { - eprintln!("perry: warning: {m}"); + for violation in &violations { + eprintln!( + "perry: warning: {}", + rewrite_budget_message(violation, false) + ); } Ok(()) } @@ -593,6 +723,7 @@ fn optimize_and_emit( // Sizes before the rewrite: the budget message below names them, and // the per-unit report compares them with the post-rewrite census. let budget = rs4gc_instruction_budget(); + let rewritten_functions = rs4gc_functions(module); let pre_sizes = if budget == RewriteBudget::Off && stats.is_none() { std::collections::HashMap::new() } else { @@ -635,8 +766,11 @@ fn optimize_and_emit( stats.post_rewrite_instructions = total; stats.post_rewrite_widest = widest; } - // The relocation-fan-out assertion (#8583): refuse, never demote. - enforce_rs4gc_instruction_budget(module, budget, &pre_sizes)?; + // The relocation-fan-out backstop (#8583/#8679): stop before the + // super-linear optimizer and ask codegen to retry the named functions + // with precise shadow-frame roots. The retry keeps this same pipeline + // and optimization level; only the GC-root representation changes. + enforce_rs4gc_instruction_budget(module, budget, &pre_sizes, &rewritten_functions)?; } let pipeline = match opt { @@ -820,6 +954,7 @@ mod tests { let before = parse_ir_text(&context, &fixture, "fanout_before").expect("fixture parses"); let after = parse_ir_text(&context, &rewritten, "fanout_after").expect("rewritten parses"); let pre = pre_rewrite_sizes(&before); + let rewritten_functions = rs4gc_functions(&before); let pre_f = pre["f"]; let (_, post_total, post_widest) = module_instruction_census(&after); let post_f = post_widest.as_ref().map(|(_, n)| *n).unwrap_or(0); @@ -831,10 +966,10 @@ mod tests { let cap = pre_f + (post_f - pre_f) / 2; assert!( - rs4gc_budget_violations(&before, cap).is_empty(), + rs4gc_budget_violations(&before, cap, &rewritten_functions).is_empty(), "the pre-rewrite module is under the budget by construction" ); - let over = rs4gc_budget_violations(&after, cap); + let over = rs4gc_budget_violations(&after, cap, &rewritten_functions); assert_eq!( over.len(), 1, @@ -843,8 +978,19 @@ mod tests { assert_eq!(over[0].0, "f"); assert_eq!(over[0].1, post_f); - let err = enforce_rs4gc_instruction_budget(&after, RewriteBudget::Error(cap), &pre) - .expect_err("the default spelling refuses the unit"); + let err = enforce_rs4gc_instruction_budget( + &after, + RewriteBudget::Error(cap), + &pre, + &rewritten_functions, + ) + .expect_err("the default spelling requests a spill retry"); + let retry = rs4gc_budget_retry(&err).expect("the request stays typed"); + assert_eq!(retry.len(), 1); + assert_eq!(retry[0].name, "f"); + assert_eq!(retry[0].pre_instructions, Some(pre_f)); + assert_eq!(retry[0].post_instructions, post_f); + assert_eq!(retry[0].cap, cap); let msg = format!("{err:#}"); for needle in [ "`f`", @@ -852,7 +998,8 @@ mod tests { &format!("it was {pre_f} before"), &format!("budget is {cap}"), "PERRY_LL_RS4GC_MAX_INSTRS", - "#8583", + "re-lower", + "#8679", ] { assert!( msg.contains(needle), @@ -863,12 +1010,35 @@ mod tests { !msg.contains("optnone"), "the budget is an assertion, never a demotion:\n{msg}" ); - enforce_rs4gc_instruction_budget(&after, RewriteBudget::Warn(cap), &pre) - .expect("warn spelling does not refuse"); - enforce_rs4gc_instruction_budget(&after, RewriteBudget::Off, &pre) - .expect("off spelling does not refuse"); - enforce_rs4gc_instruction_budget(&after, RewriteBudget::Error(post_f), &pre) - .expect("a budget at the exact size is not exceeded"); + enforce_rs4gc_instruction_budget( + &after, + RewriteBudget::Warn(cap), + &pre, + &rewritten_functions, + ) + .expect("warn spelling does not retry"); + enforce_rs4gc_instruction_budget(&after, RewriteBudget::Off, &pre, &rewritten_functions) + .expect("off spelling does not retry"); + enforce_rs4gc_instruction_budget( + &after, + RewriteBudget::Error(post_f), + &pre, + &rewritten_functions, + ) + .expect("a budget at the exact size is not exceeded"); + + // The retry removes the function's GC strategy. Its ordinary shadow + // body may itself exceed a deliberately tiny test cap, but it must not + // request the same spill forever: only functions that entered RS4GC + // are governed by this relocation-fan-out budget. + let no_rewritten_functions = std::collections::HashSet::new(); + enforce_rs4gc_instruction_budget( + &after, + RewriteBudget::Error(cap), + &pre, + &no_rewritten_functions, + ) + .expect("a shadow-spilled function is outside the RS4GC budget"); } fn constant_fold_order_fixture(folded: bool) -> String { diff --git a/crates/perry-codegen/src/linker.rs b/crates/perry-codegen/src/linker.rs index a90ba9ff46..ff6804db34 100644 --- a/crates/perry-codegen/src/linker.rs +++ b/crates/perry-codegen/src/linker.rs @@ -841,14 +841,27 @@ fn compile_ll_inprocess_in( Ok(bytes) } Err(e) => { - let error = anyhow!( + // Preserve typed backend errors through this diagnostic layer. + // In particular, #8679's codegen caller must be able to recover + // an `Rs4gcBudgetExceeded` and re-lower the named functions; a + // freshly formatted anyhow string would turn that retry request + // back into the old hard refusal. + let error = e.context(format!( "in-process LLVM compile failed (PERRY_LLVM_INPROCESS).\n\ - requested -target: {}\n\ - \n\ - {}", - plan.effective_target, - e - ); + requested -target: {}", + plan.effective_target + )); + if crate::inprocess::rs4gc_budget_retry(&error).is_some() { + // This is expected control flow, not a failed compile: the + // lowering owner will rebuild the named functions. Do not + // consume the process-wide "retain the first LLVM failure" + // slot or leave an intermediate behind unless the user + // explicitly requested all IR via PERRY_LLVM_KEEP_IR. + if !policy.keep { + let _ = fs::remove_dir_all(&paths.scratch_dir); + } + return Err(error); + } Err(failed_scratch.finish_with_ir(error, ll_text)) } } diff --git a/crates/perry-codegen/src/native_emit.rs b/crates/perry-codegen/src/native_emit.rs index 2010ee7090..9258884027 100644 --- a/crates/perry-codegen/src/native_emit.rs +++ b/crates/perry-codegen/src/native_emit.rs @@ -34,7 +34,7 @@ //! the remaining per-LINE formatting; the `instructions=` counter logged per //! module is that migration's ratchet. -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, Context as _, Result}; use inkwell::context::Context; use inkwell::module::Module; @@ -166,8 +166,55 @@ struct FrozenUnit { function_count: usize, } +/// Apply a typed post-RS4GC budget request to the lowering-owned functions +/// that produced a module/unit. The request is expected to make progress for +/// every named function; otherwise retrying would either preserve the refusal +/// or loop forever, so fail with the original names and counts instead. +pub(crate) fn apply_budget_spill_retry<'a>( + funcs: impl IntoIterator, + violations: &[crate::inprocess::Rs4gcBudgetViolation], +) -> Result<()> { + let mut changed = std::collections::HashSet::new(); + for function in funcs { + let Some(violation) = violations + .iter() + .find(|violation| function.name == violation.name) + else { + continue; + }; + if function.request_shadow_frame_spill() { + changed.insert(violation.name.clone()); + eprintln!( + "perry: `{}` exceeded the post-RS4GC instruction budget ({} -> {} \ + instructions; cap {}); retrying it with precise GC roots in a shadow \ + frame at the requested optimization level (#8679)", + violation.name, + violation + .pre_instructions + .map_or_else(|| "unknown".to_string(), |n| n.to_string()), + violation.post_instructions, + violation.cap, + ); + } + } + let missing: Vec<&str> = violations + .iter() + .filter(|violation| !changed.contains(&violation.name)) + .map(|violation| violation.name.as_str()) + .collect(); + if missing.is_empty() { + Ok(()) + } else { + Err(anyhow!( + "post-RS4GC budget requested a shadow-frame retry for {}, but those \ + functions were not available for a new lowering (or were already retried)", + missing.join(", ") + )) + } +} + fn freeze_unit( - part: crate::module::OwnedCodegenUnitPart, + part: &crate::module::OwnedCodegenUnitPart, external_declarations: &[(String, String)], ) -> Result { let crate::module::OwnedCodegenUnitPart { pre, post, funcs } = part; @@ -201,11 +248,11 @@ fn freeze_unit( // no inkwell builders. Let LLVM's in-process assembly parser build // only these exceptional functions; all ordinary bodies remain on // the typed C-API path and never become text. - skeleton.push_str(&crate::module::render_fn_external(&f)); + skeleton.push_str(&crate::module::render_fn_external(f)); skeleton.push('\n'); continue; } - skeleton.push_str(&crate::module::declare_line_for(&f)); + skeleton.push_str(&crate::module::declare_line_for(f)); skeleton.push('\n'); let mut items = Vec::new(); if f.stack_map_requested() { @@ -233,7 +280,7 @@ fn freeze_unit( } functions.push(FrozenFunction { name: f.name.clone(), - header: synth_define_header(&f, true), + header: synth_define_header(f, true), items, }); } @@ -362,7 +409,15 @@ pub fn compile_module_units_native( .collect(); let target_triple = llmod.target_triple.clone(); let owned_module = std::mem::replace(llmod, LlModule::new(target_triple)); - let parts = owned_module.into_codegen_unit_parts(n); + // Keep at most a bounded window of lowering-owned units alive after they + // are frozen. A post-RS4GC budget miss needs that source graph exactly + // once so the named functions can switch root lowering and be frozen + // again; successful units are still dropped immediately (#8679). + let mut parts: Vec> = owned_module + .into_codegen_unit_parts(n) + .into_iter() + .map(Some) + .collect(); let unit_timings = std::env::var("PERRY_CODEGEN_UNIT_TIMINGS").is_ok(); let show_progress = matches!( std::env::var("PERRY_CODEGEN_PROGRESS").as_deref(), @@ -385,25 +440,34 @@ pub fn compile_module_units_native( // `LlModule::skeleton_ir`; cross-unit declarations with their actual // signatures already live in each part's filtered `pre`. let llvm_started = std::time::Instant::now(); + #[cfg(test)] + let test_budget = crate::inprocess::test_rs4gc_budget_cap(); let compile_one = |i: usize, unit: &FrozenUnit| -> Result> { let started = std::time::Instant::now(); let context = Context::create(); let module = crate::inprocess::parse_ir_text(&context, &unit.skeleton, "perry_native_module") - .map_err(|e| anyhow!("unit {i} skeleton: {e:#}"))?; + .with_context(|| format!("unit {i} skeleton"))?; let (t, r) = stream_frozen_functions(&context, &module, &unit.functions) - .map_err(|e| anyhow!("unit {i}: {e:#}"))?; + .with_context(|| format!("unit {i}"))?; debug_dump(&module, &format!("{module_prefix}.unit{i}")); let (effective_target, args) = crate::linker::native_plan_args(target, native_roots); let mut stats = crate::inprocess::UnitCodegenStats::default(); - let unit_bytes = crate::inprocess::optimize_and_emit_module_with_stats( - &module, - &effective_target, - &args, - native_roots, - unit_timings.then_some(&mut stats), - ) - .map_err(|e| anyhow!("unit {i}: {e:#}"))?; + let stats_out = unit_timings.then_some(&mut stats); + let optimize = || { + crate::inprocess::optimize_and_emit_module_with_stats( + &module, + &effective_target, + &args, + native_roots, + stats_out, + ) + }; + #[cfg(test)] + let optimized = crate::inprocess::with_inherited_test_rs4gc_budget(test_budget, optimize); + #[cfg(not(test))] + let optimized = optimize(); + let unit_bytes = optimized.with_context(|| format!("unit {i}"))?; if unit_timings { let widest = |w: &Option<(String, usize)>| { w.as_ref() @@ -429,7 +493,7 @@ pub fn compile_module_units_native( ); } let obj = crate::linker::finish_native_emission(unit_bytes, &effective_target, &args) - .map_err(|e| anyhow!("unit {i}: {e:#}"))?; + .with_context(|| format!("unit {i}"))?; log::debug!( "perry-codegen: native unit {i}: {} fns, {t} typed + {r} raw insts, {:.3}s", unit.function_count, @@ -447,6 +511,7 @@ pub fn compile_module_units_native( if show_progress { let estimated_mib: f64 = parts .iter() + .flatten() .map(|part| { (part.pre.len() + part.post.len() @@ -462,21 +527,23 @@ pub fn compile_module_units_native( "[perry] codegen: {module_prefix}: freeze/LLVM pipeline started: {unit_total} units, {jobs} workers, ~{estimated_mib:.1} MiB estimated IR" ); } - let completed = std::sync::atomic::AtomicUsize::new(0); let frozen = std::sync::atomic::AtomicUsize::new(0); - let slots: Vec>>>> = (0..parts.len()) - .map(|_| std::sync::Mutex::new(None)) - .collect(); - // The producer alone touches lowering-owned LlFunction/Rc state. Each - // completed owned payload immediately enters a bounded queue, letting LLVM - // consume it while the producer freezes later units. Previously all units - // were frozen into a Vec first: full Claude waited ~5 minutes before LLVM - // started and retained both graphs at peak RSS. + let mut slots: Vec>>> = (0..parts.len()).map(|_| None).collect(); + // The producer alone touches lowering-owned LlFunction/Rc state. Workers + // return their result through a second channel; on a typed budget request + // the producer can mutate that still-local graph, freeze it again, and + // resubmit it. The in-flight window stays bounded so this retry ability + // does not restore the old whole-bundle retention peak. let (sender, receiver) = std::sync::mpsc::sync_channel::<(usize, Result)>(jobs.max(1)); + let (result_sender, result_receiver) = + std::sync::mpsc::channel::<(usize, std::time::Duration, Result>)>(); let receiver = std::sync::Mutex::new(receiver); std::thread::scope(|scope| { for worker_index in 0..jobs { + let result_sender = result_sender.clone(); + let receiver = &receiver; + let compile_one = &compile_one; // LLVM recursion depth scales with function size, and a post-RS4GC // relocation-fan-out function reaches millions of instructions // (#8082) — Rust's default 2 MiB worker stack SIGBUSes on the @@ -485,40 +552,29 @@ pub fn compile_module_units_native( std::thread::Builder::new() .name(format!("perry-llvm-unit-{worker_index}")) .stack_size(64 * 1024 * 1024) - .spawn_scoped(scope, || loop { - let received = receiver - .lock() - .expect("native freeze queue poisoned") - .recv(); - let Ok((i, frozen_unit)) = received else { break }; - let unit_started = std::time::Instant::now(); - let out = frozen_unit.and_then(|unit| compile_one(i, &unit)); - let done = completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; - if show_progress { - let elapsed = llvm_started.elapsed().as_secs_f64(); - let eta = if done < unit_total { - elapsed / done as f64 * (unit_total - done) as f64 - } else { - 0.0 + .spawn_scoped(scope, move || loop { + let received = receiver + .lock() + .expect("native freeze queue poisoned") + .recv(); + let Ok((i, frozen_unit)) = received else { + break; }; - eprintln!( - "[perry] codegen: {module_prefix}: LLVM unit {}/{} finished ({:.1}s; {} complete; elapsed {:.1} min; ETA ~{:.1} min)", - i + 1, unit_total, unit_started.elapsed().as_secs_f64(), done, - elapsed / 60.0, eta / 60.0 - ); - } - *slots[i].lock().expect("native codegen-unit slot poisoned") = Some(out); + let unit_started = std::time::Instant::now(); + let out = frozen_unit.and_then(|unit| compile_one(i, &unit)); + if result_sender + .send((i, unit_started.elapsed(), out)) + .is_err() + { + break; + } }) .expect("spawn LLVM unit worker"); } + drop(result_sender); let freeze_started = std::time::Instant::now(); let report_step = (unit_total / 20).max(1); - // Consume each part as soon as its owned worker payload has been - // produced. Keeping `parts` alive through the scoped worker join held - // every unit's large pre/post strings until all LLVM work completed; - // dropping that multi-gigabyte graph afterwards added a several-minute - // single-threaded destructor tail on the full Claude Code bundle. - for (i, part) in parts.into_iter().enumerate() { + let enqueue = |i: usize, part: &crate::module::OwnedCodegenUnitPart, retry: bool| -> bool { if unit_timings { // Name the widest body before LLVM ever sees it: the one // irreducible function in a bundle is the one that sets the @@ -526,7 +582,8 @@ pub fn compile_module_units_native( // not say which (#8583). if let Some(widest) = part.funcs.iter().max_by_key(|f| f.estimated_ir_bytes()) { eprintln!( - "[perry] codegen: {module_prefix}: unit {}/{unit_total}: {} fns, ~{:.1} MiB estimated IR, widest {} (~{:.1} MiB)", + "[perry] codegen: {module_prefix}: {}unit {}/{unit_total}: {} fns, ~{:.1} MiB estimated IR, widest {} (~{:.1} MiB)", + if retry { "retry " } else { "" }, i + 1, part.funcs.len(), part.funcs.iter().map(|f| f.estimated_ir_bytes()).sum::() as f64 / 1_048_576.0, @@ -537,7 +594,10 @@ pub fn compile_module_units_native( } let unit = freeze_unit(part, &external_declarations); if sender.send((i, unit)).is_err() { - break; + return false; + } + if retry { + return true; } let done = frozen.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; if show_progress && (done == unit_total || done % report_step == 0) { @@ -550,18 +610,102 @@ pub fn compile_module_units_native( eta ); } + true + }; + + // One source unit per worker. Retrying requires retaining that source + // until LLVM answers, but there is no reason to retain a second queued + // source per worker too; freezing the next unit after one completes is + // only a small producer step and keeps the extra peak tightly bounded. + let max_in_flight = jobs.clamp(1, unit_total); + let mut next = 0usize; + let mut in_flight = 0usize; + while next < max_in_flight { + let part = parts[next] + .as_ref() + .expect("an undispatched native unit still owns its lowering graph"); + if !enqueue(next, part, false) { + break; + } + next += 1; + in_flight += 1; + } + + let mut done = 0usize; + while done < unit_total && in_flight != 0 { + let Ok((i, attempt_elapsed, out)) = result_receiver.recv() else { + break; + }; + if let Err(error) = &out { + if let Some(violations) = crate::inprocess::rs4gc_budget_retry(error) { + let retry = parts[i] + .as_mut() + .expect("a retryable native unit keeps its lowering graph"); + match apply_budget_spill_retry(retry.funcs.iter_mut(), &violations) { + Ok(()) if enqueue(i, retry, true) => continue, + Ok(()) => { + slots[i] = Some(Err(anyhow!( + "native codegen retry queue closed for unit {}/{}", + i + 1, + unit_total + ))); + } + Err(retry_error) => { + slots[i] = Some(Err(retry_error.context(format!( + "native codegen unit {}/{} could not honor its RS4GC budget retry: \ + {error:#}", + i + 1, + unit_total + )))); + } + } + } else { + slots[i] = Some(out); + } + } else { + slots[i] = Some(out); + } + + // A final result no longer needs its Rc/RefCell lowering graph. + // Drop it now, not after every unit and LLVM worker has finished. + parts[i].take(); + done += 1; + in_flight -= 1; + if show_progress { + let elapsed = llvm_started.elapsed().as_secs_f64(); + let eta = if done < unit_total { + elapsed / done as f64 * (unit_total - done) as f64 + } else { + 0.0 + }; + eprintln!( + "[perry] codegen: {module_prefix}: LLVM unit {}/{} finished ({:.1}s; {} complete; elapsed {:.1} min; ETA ~{:.1} min)", + i + 1, + unit_total, + attempt_elapsed.as_secs_f64(), + done, + elapsed / 60.0, + eta / 60.0 + ); + } + + if next < unit_total { + let part = parts[next] + .as_ref() + .expect("an undispatched native unit still owns its lowering graph"); + if enqueue(next, part, false) { + next += 1; + in_flight += 1; + } + } } drop(sender); }); let mut objs = Vec::with_capacity(unit_total); for (i, slot) in slots.into_iter().enumerate() { objs.push( - slot.into_inner() - .expect("native codegen-unit slot poisoned") - .expect("every native codegen unit is compiled") - .map_err(|e| { - anyhow!("native codegen unit {}/{} failed: {e:#}", i + 1, unit_total) - })?, + slot.expect("every native codegen unit is compiled") + .with_context(|| format!("native codegen unit {}/{} failed", i + 1, unit_total))?, ); } let merge_started = std::time::Instant::now(); @@ -597,8 +741,18 @@ pub fn compile_module_units_diff( target: Option<&str>, module_prefix: &str, ) -> Result> { - let units = llmod.render_codegen_units(n); - let bytes_text = crate::linker::compile_units_to_object(&units, target)?; + let (bytes_text, text_unit_count) = loop { + let units = llmod.render_codegen_units(n); + match crate::linker::compile_units_to_object(&units, target) { + Ok(bytes) => break (bytes, units.len()), + Err(error) => { + let Some(violations) = crate::inprocess::rs4gc_budget_retry(&error) else { + return Err(error); + }; + apply_budget_spill_retry(llmod.functions_mut(), &violations)?; + } + } + }; match compile_module_units_native(llmod, n, target, module_prefix) { Err(e) => { eprintln!("perry: [ir-diff] native unit construction FAILED (text arm used): {e:#}"); @@ -607,9 +761,9 @@ pub fn compile_module_units_diff( if bytes_text == bytes_native { eprintln!( "perry: [ir-diff] OK — native and text unit arms emit byte-identical merged \ - objects ({} bytes, {} units)", + objects ({} bytes, {} units)", bytes_text.len(), - units.len() + text_unit_count ); } else { eprintln!( @@ -630,27 +784,36 @@ fn plan_for(target: Option<&str>, native_roots: bool) -> (String, Vec) { } pub fn compile_module_native( - llmod: &LlModule, + llmod: &mut LlModule, target: Option<&str>, module_prefix: &str, ) -> Result> { - let context = Context::create(); - let module = build_native_module(&context, llmod)?; - debug_dump(&module, module_prefix); let native_roots = crate::codegen::helpers::native_stack_roots_enabled(); let (effective_target, args) = plan_for(target, native_roots); - // #7982: under the statepoint backends the plan asks for `-S`, so this - // returns assembler TEXT. It must go through the compact-map rewrite and - // the assembler before it can be called an object — the textual path has - // always done this, the native path silently did not, and the link died - // with `ld: unknown file type`. - let bytes = crate::inprocess::optimize_and_emit_module( - &module, - &effective_target, - &args, - native_roots, - )?; - crate::linker::finish_native_emission(bytes, &effective_target, &args) + loop { + let context = Context::create(); + let module = build_native_module(&context, llmod)?; + debug_dump(&module, module_prefix); + // #7982: under the statepoint backends the plan asks for `-S`, so this + // returns assembler TEXT. It must go through the compact-map rewrite + // and the assembler before it can be called an object. + match crate::inprocess::optimize_and_emit_module( + &module, + &effective_target, + &args, + native_roots, + ) { + Ok(bytes) => { + return crate::linker::finish_native_emission(bytes, &effective_target, &args); + } + Err(error) => { + let Some(violations) = crate::inprocess::rs4gc_budget_retry(&error) else { + return Err(error); + }; + apply_budget_spill_retry(llmod.functions_mut(), &violations)?; + } + } + } } /// The debug view under native construction: `PERRY_SAVE_LL=` (which @@ -933,7 +1096,7 @@ mod tests { #[test] fn native_construction_lowers_precise_roots_before_rs4gc() { let _native = crate::codegen::helpers::NativeRootsPin::native(); - let module = precise_root_fixture(false); + let mut module = precise_root_fixture(false); let text_ir = module.to_ir(); assert!( @@ -948,7 +1111,7 @@ mod tests { let text = crate::linker::compile_ll_to_object(&text_ir, None) .expect("trusted text arm emits an object"); - let native = compile_module_native(&module, None, "native_root_diff_fixture") + let native = compile_module_native(&mut module, None, "native_root_diff_fixture") .expect("direct native arm emits an object"); assert_eq!( native, text, @@ -957,6 +1120,70 @@ mod tests { ); } + /// #8679: a real backend budget miss must come back through the native + /// constructor, mutate the lowering-owned function, rebuild the module, + /// and finish emission. The one-instruction cap guarantees that the first + /// RS4GC arm trips without constructing a million-instruction fixture; + /// the successful result and retained shadow IR prove this is a retry, + /// not the former hard refusal or a disabled budget. + #[test] + fn post_rs4gc_budget_retries_with_a_shadow_frame() { + let _native = crate::codegen::helpers::NativeRootsPin::native(); + let mut module = precise_root_fixture(false); + let before = module + .deduped_function_refs() + .into_iter() + .find(|function| function.name == "native_root_diff_fixture") + .expect("fixture function exists before the retry") + .to_ir(); + assert!(before.contains("gc \"statepoint-example\""), "{before}"); + assert!(!before.contains("@js_shadow_frame_enter"), "{before}"); + + let object = crate::inprocess::with_test_rs4gc_budget(1, || { + compile_module_native(&mut module, None, "rs4gc_budget_retry_fixture") + }) + .expect("a post-RS4GC budget miss must spill and retry successfully"); + assert!(!object.is_empty()); + + let retried = module + .deduped_function_refs() + .into_iter() + .find(|function| function.name == "native_root_diff_fixture") + .expect("fixture function survives the retry"); + assert!(retried.spills_roots_to_shadow_frame()); + let after = retried.to_ir(); + assert!(!after.contains("gc \"statepoint-example\""), "{after}"); + assert!(after.contains("@js_shadow_frame_enter"), "{after}"); + assert!(after.contains("@js_shadow_slot_bind"), "{after}"); + assert!(after.contains("@js_shadow_frame_pop"), "{after}"); + } + + /// The reported Claude bundle takes the split-unit worker path. Its retry + /// source must stay on the producer thread (the `LlFunction` graph is not + /// `Send`) while LLVM reports the typed violation from a worker. A compact + /// map would prove the worker silently missed the test cap and kept the + /// statepoint lowering; no map proves the successful object came from the + /// resubmitted shadow-frame unit. + #[test] + fn split_unit_budget_retry_returns_a_shadow_rooted_object() { + let _native = crate::codegen::helpers::NativeRootsPin::native(); + let mut module = precise_root_fixture(true); + let before = module.render_codegen_units(2); + assert!( + before + .iter() + .any(|unit| unit.contains("gc \"statepoint-example\"")), + "fixture must initially send a mapped function through RS4GC" + ); + + let object = crate::inprocess::with_test_rs4gc_budget(1, || { + compile_module_units_native(&mut module, 2, None, "rs4gc_split_budget_retry_fixture") + }) + .expect("a worker budget miss must be re-lowered and resubmitted"); + assert!(!object.is_empty()); + assert_no_compact_gc_map(&object, "budget-retried split native"); + } + #[test] fn split_native_construction_lowers_precise_roots_before_rs4gc() { let _native = crate::codegen::helpers::NativeRootsPin::native(); @@ -1001,12 +1228,13 @@ mod tests { fn native_and_text_arms_agree_on_an_elf_target() { const ELF_TRIPLE: &str = "x86_64-unknown-linux-gnu"; let _native = crate::codegen::helpers::NativeRootsPin::native(); - let module = precise_root_fixture_for(ELF_TRIPLE, false); + let mut module = precise_root_fixture_for(ELF_TRIPLE, false); let text = crate::linker::compile_ll_to_object(&module.to_ir(), Some(ELF_TRIPLE)) .expect("trusted text arm emits an ELF object"); - let native = compile_module_native(&module, Some(ELF_TRIPLE), "native_root_elf_fixture") - .expect("direct native arm emits an ELF object"); + let native = + compile_module_native(&mut module, Some(ELF_TRIPLE), "native_root_elf_fixture") + .expect("direct native arm emits an ELF object"); assert_eq!( &text[..4], @@ -1095,6 +1323,24 @@ mod tests { /// Returns the text arm's object (the trusted reference) so a diff run is /// safe for real builds while surfacing every divergence. pub fn compile_module_diff( + llmod: &mut LlModule, + target: Option<&str>, + module_prefix: &str, +) -> Result> { + loop { + match compile_module_diff_once(llmod, target, module_prefix) { + Ok(bytes) => return Ok(bytes), + Err(error) => { + let Some(violations) = crate::inprocess::rs4gc_budget_retry(&error) else { + return Err(error); + }; + apply_budget_spill_retry(llmod.functions_mut(), &violations)?; + } + } + } +} + +fn compile_module_diff_once( llmod: &LlModule, target: Option<&str>, module_prefix: &str, diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index 725ecfae31..877bf8eea3 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -41,8 +41,9 @@ const BUILD_CACHE_ENV_VARS: &[&str] = &[ "PERRY_RS4GC", // `-Os` vs `-O3` for every native module. "PERRY_LL_SIZE_OPT", - // The post-RS4GC per-function instruction budget (#8583): a unit that one - // setting refuses must not be served from a build another accepted. + // The post-RS4GC per-function instruction budget (#8583/#8679): a function + // one setting re-lowers must not be served from a build another kept on + // statepoints. "PERRY_LL_RS4GC_MAX_INSTRS", // #8583: the relocation estimate above which a function spills its GC roots // to a shadow frame. It changes which functions carry statepoints, so it diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 6648cb5c74..8fdc1e1b62 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -897,8 +897,8 @@ fn compute_object_cache_key_with_env( "env_ll_size_opt", env_var("PERRY_LL_SIZE_OPT").as_deref().unwrap_or(""), ); - // #8583: the post-RS4GC instruction budget decides whether a unit is - // refused; two settings must never share a cached object. + // #8583/#8679: the post-RS4GC instruction budget decides whether functions + // are re-lowered onto shadow frames; two settings must never share an object. h.field( "env_ll_rs4gc_max_instrs", env_var("PERRY_LL_RS4GC_MAX_INSTRS")