diff --git a/changelog.d/8586-rs4gc-budget-assert.md b/changelog.d/8586-rs4gc-budget-assert.md new file mode 100644 index 0000000000..60f98a6a92 --- /dev/null +++ b/changelog.d/8586-rs4gc-budget-assert.md @@ -0,0 +1,5 @@ +### Changed + +- `PERRY_LL_PREOPT_OPTNONE_INSTRS` is removed (#8583). It stamped `optnone` before `rewrite-statepoints-for-gc`, which makes the pass manager skip `mem2reg`/`sccp` while RS4GC still runs, so a demoted function's root allocas were never promoted and the collector never saw them. The cap defaulted to 0, so no shipped build was affected; a test now pins that an `optnone` function loses every root under the rewrite. +- `PERRY_LL_RS4GC_MAX_INSTRS` (default 1.5 Mi): after `rewrite-statepoints-for-gc`, a function whose body exceeds the per-function budget fails its codegen unit with the function's name and its sizes before and after the rewrite, instead of entering an optimizer pipeline that is super-linear on statepoint relocation fan-out and would not finish. This is an assertion, not a fallback — no function is ever demoted and the requested optimization level applies to every function. `` raises it, `warn:` only warns, `0` disables. Both caches key on it. +- `PERRY_CODEGEN_UNIT_TIMINGS` now reports, per codegen unit, the widest function by estimated IR before LLVM starts, and after compile the instruction totals and widest function before and after RS4GC, the growth factor, and rewrite/optimize/emit times. diff --git a/crates/perry-codegen/src/inprocess.rs b/crates/perry-codegen/src/inprocess.rs index 972a48eb42..7e5ee5ce47 100644 --- a/crates/perry-codegen/src/inprocess.rs +++ b/crates/perry-codegen/src/inprocess.rs @@ -21,7 +21,6 @@ use std::ffi::CString; use std::sync::Once; use anyhow::{anyhow, Result}; -use inkwell::attributes::{Attribute, AttributeLoc}; use inkwell::context::Context; use inkwell::memory_buffer::MemoryBuffer; use inkwell::passes::PassBuilderOptions; @@ -205,6 +204,7 @@ pub fn compile_ll_to_object_inprocess( &mllvm, emit_asm, native_roots, + None, ) } @@ -314,6 +314,24 @@ pub(crate) fn optimize_and_emit_module( effective_target: &str, clang_style_args: &[String], native_roots: bool, +) -> Result> { + optimize_and_emit_module_with_stats( + module, + effective_target, + clang_style_args, + native_roots, + None, + ) +} + +/// [`optimize_and_emit_module`] that also fills `stats` (sizes before and +/// after RS4GC, widest functions, phase times) for the per-unit report. +pub(crate) fn optimize_and_emit_module_with_stats( + module: &inkwell::module::Module<'_>, + effective_target: &str, + clang_style_args: &[String], + native_roots: bool, + stats: Option<&mut UnitCodegenStats>, ) -> Result> { let (opt, mcpu_native, explicit_cpu, mllvm, emit_asm) = interpret_plan_args(clang_style_args)?; optimize_and_emit( @@ -325,92 +343,191 @@ pub(crate) fn optimize_and_emit_module( &mllvm, emit_asm, native_roots, + stats, ) } -/// Optional pre-optimization escape hatch for unusually large generated -/// functions. -/// -/// Dense generated bundles often contain one parser/table initializer that is -/// large enough to make the `-O1+` middle-end super-linear, alongside hundreds -/// of ordinary functions that benefit substantially from `-Os`. Routing the -/// whole codegen unit to `-O0` keeps compilation bounded but also bloats every -/// ordinary sibling. When this cap is non-zero, only functions above it are -/// stamped `optnone`+`noinline` before the module pipeline runs. This makes -/// `PERRY_LL_SIZE_OPT=1` a practical hybrid mode instead of an all-or-nothing -/// gamble on the largest function in each unit. -/// -/// Disabled by default while the threshold is calibrated across the bundle -/// corpus. `PERRY_LL_PREOPT_OPTNONE_INSTRS=N` enables it; `0` disables it. -const DEFAULT_PREOPT_OPTNONE_INSTRS: usize = 0; - -fn preopt_optnone_instr_cap() -> usize { - std::env::var("PERRY_LL_PREOPT_OPTNONE_INSTRS") - .ok() - .and_then(|s| s.trim().parse::().ok()) - .unwrap_or(DEFAULT_PREOPT_OPTNONE_INSTRS) -} - -fn stamp_function_optnone(function: inkwell::values::FunctionValue<'_>) { - let context = function.get_type().get_context(); - let optnone_kind = Attribute::get_named_enum_kind_id("optnone"); - let noinline_kind = Attribute::get_named_enum_kind_id("noinline"); - // `alwaysinline` and `noinline` are verifier-incompatible. Generated - // functions do not normally carry it, but the opt-in must remain safe for - // imported/generated IR that does. - function.remove_enum_attribute( - AttributeLoc::Function, - Attribute::get_named_enum_kind_id("alwaysinline"), - ); - function.remove_enum_attribute( - AttributeLoc::Function, - Attribute::get_named_enum_kind_id("inlinehint"), - ); - function.add_attribute( - AttributeLoc::Function, - context.create_enum_attribute(optnone_kind, 0), - ); - function.add_attribute( - AttributeLoc::Function, - context.create_enum_attribute(noinline_kind, 0), - ); +/// Per-unit facts the backend learns while it works: instruction totals and +/// the widest function before and after `rewrite-statepoints-for-gc`, and the +/// time each phase took. `native_emit` prints one line per unit from these +/// under `PERRY_CODEGEN_UNIT_TIMINGS`, so a build that is stuck in LLVM names +/// the function it is stuck on instead of a unit number (#8583). +#[derive(Debug, Default, Clone)] +pub struct UnitCodegenStats { + pub functions: usize, + pub pre_rewrite_instructions: usize, + pub pre_rewrite_widest: Option<(String, usize)>, + pub post_rewrite_instructions: usize, + pub post_rewrite_widest: Option<(String, usize)>, + pub rewrite_secs: f64, + pub optimize_secs: f64, + pub emit_secs: f64, } -fn function_instruction_count(function: inkwell::values::FunctionValue<'_>, cap: usize) -> usize { +fn function_instruction_count(function: inkwell::values::FunctionValue<'_>) -> usize { let mut instrs = 0usize; - 'body: for bb in function.get_basic_blocks() { + for bb in function.get_basic_blocks() { let mut inst = bb.get_first_instruction(); while let Some(i) = inst { instrs += 1; - if instrs > cap { - break 'body; - } inst = i.get_next_instruction(); } } instrs } -/// Demote large functions before the ordinary optimization pipeline while -/// leaving every smaller sibling eligible for the unit's requested opt level. -fn demote_preoptimization_bloated_functions(module: &inkwell::module::Module<'_>, cap: usize) { - if cap == 0 { - return; +/// (defined functions, total instructions, widest function) for a module. +/// One linear walk through the C API; a few milliseconds per ordinary unit. +fn module_instruction_census( + module: &inkwell::module::Module<'_>, +) -> (usize, usize, Option<(String, usize)>) { + let mut functions = 0usize; + let mut total = 0usize; + let mut widest: Option<(String, usize)> = None; + let mut function = module.get_first_function(); + while let Some(f) = function { + if f.count_basic_blocks() > 0 { + functions += 1; + let n = function_instruction_count(f); + total += n; + if widest.as_ref().is_none_or(|(_, w)| n > *w) { + widest = Some((f.get_name().to_string_lossy().into_owned(), n)); + } + } + function = f.get_next_function(); + } + (functions, total, widest) +} + +/// 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. +/// +/// Calibrated between the two measured points of #8128 on the Next 16.3.0 +/// production bundle: the largest post-rewrite function that finished +/// comfortably at `-Os` was ~413k instructions, and the one that ran more +/// than 65 CPU-minutes without finishing was ~2.1M. 1.5 Mi sits between them +/// with margin on both sides. `PERRY_LL_RS4GC_MAX_INSTRS=` raises or +/// lowers it, `warn:` only warns, and `0`/`off` disables the check. +const DEFAULT_RS4GC_MAX_INSTRS: usize = 1_572_864; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RewriteBudget { + Off, + Error(usize), + Warn(usize), +} + +fn parse_rewrite_budget(value: Option<&str>) -> RewriteBudget { + match value.map(str::trim) { + None | Some("") => RewriteBudget::Error(DEFAULT_RS4GC_MAX_INSTRS), + Some("0") | Some("off") | Some("false") => RewriteBudget::Off, + Some(v) => { + if let Some(n) = v.strip_prefix("warn:") { + match n.trim().parse::() { + Ok(0) => RewriteBudget::Off, + Ok(n) => RewriteBudget::Warn(n), + Err(_) => RewriteBudget::Warn(DEFAULT_RS4GC_MAX_INSTRS), + } + } else { + match v.parse::() { + Ok(n) => RewriteBudget::Error(n), + Err(_) => RewriteBudget::Error(DEFAULT_RS4GC_MAX_INSTRS), + } + } + } } +} + +fn rs4gc_instruction_budget() -> RewriteBudget { + parse_rewrite_budget(std::env::var("PERRY_LL_RS4GC_MAX_INSTRS").ok().as_deref()) +} + +/// Every defined function whose post-rewrite body exceeds `cap`. +fn rs4gc_budget_violations( + module: &inkwell::module::Module<'_>, + cap: usize, +) -> Vec<(String, usize)> { + let mut over = Vec::new(); let mut function = module.get_first_function(); while let Some(f) = function { - if function_instruction_count(f, cap) > cap { - stamp_function_optnone(f); - eprintln!( - "perry: `{}` exceeds {} pre-optimization instructions; compiling only this \ - function unoptimized (optnone) while its siblings keep the module's size \ - optimization. Override with PERRY_LL_PREOPT_OPTNONE_INSTRS.", - f.get_name().to_string_lossy(), - cap, + if f.count_basic_blocks() > 0 { + let n = function_instruction_count(f); + if n > cap { + over.push((f.get_name().to_string_lossy().into_owned(), n)); + } + } + function = f.get_next_function(); + } + over +} + +fn rewrite_budget_message(name: &str, post: usize, cap: usize, pre: Option) -> String { + let before = pre + .map(|n| format!(" (it was {n} before the rewrite)")) + .unwrap_or_default(); + 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)." + ) +} + +/// Apply [`RewriteBudget`] to a rewritten module. `pre` gives each function's +/// pre-rewrite size for the message, when the caller took a census. +fn enforce_rs4gc_instruction_budget( + module: &inkwell::module::Module<'_>, + budget: RewriteBudget, + pre: &std::collections::HashMap, +) -> 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); + if over.is_empty() { + return Ok(()); + } + let messages: Vec = over + .iter() + .map(|(name, post)| rewrite_budget_message(name, *post, cap, pre.get(name).copied())) + .collect(); + if fatal { + return Err(anyhow!("{}", messages.join("\n"))); + } + for m in messages { + eprintln!("perry: warning: {m}"); + } + Ok(()) +} + +/// Per-function pre-rewrite sizes, for the budget message. Only the names +/// are retained, so this is a few bytes per function, not per instruction. +fn pre_rewrite_sizes( + module: &inkwell::module::Module<'_>, +) -> std::collections::HashMap { + let mut sizes = std::collections::HashMap::new(); + let mut function = module.get_first_function(); + while let Some(f) = function { + if f.count_basic_blocks() > 0 { + sizes.insert( + f.get_name().to_string_lossy().into_owned(), + function_instruction_count(f), ); } function = f.get_next_function(); } + sizes } fn optimize_and_emit( @@ -422,6 +539,7 @@ fn optimize_and_emit( mllvm: &[String], emit_asm: bool, native_roots: bool, + mut stats: Option<&mut UnitCodegenStats>, ) -> Result> { global_init(mllvm); announce(); @@ -473,12 +591,6 @@ fn optimize_and_emit( module.set_triple(&triple); module.set_data_layout(&tm.get_target_data().get_data_layout()); - // Opt-in hybrid size optimization for generated bundles: protect only the - // pathological bodies before entering the requested module pipeline. - if opt != '0' { - demote_preoptimization_bloated_functions(module, preopt_optnone_instr_cap()); - } - // RS4GC must run BEFORE the optimization pipeline, and — critically — in // this process, against this LLVM. // @@ -496,6 +608,23 @@ fn optimize_and_emit( // `try` is one — 26% of the gap suite (128 of 479 files) contains a `try`, // which the explicit bridge refuses outright (#7327/#7330). if native_roots { + // 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 pre_sizes = if budget == RewriteBudget::Off && stats.is_none() { + std::collections::HashMap::new() + } else { + pre_rewrite_sizes(module) + }; + if let Some(stats) = stats.as_deref_mut() { + stats.functions = pre_sizes.len(); + stats.pre_rewrite_instructions = pre_sizes.values().sum(); + stats.pre_rewrite_widest = pre_sizes + .iter() + .max_by_key(|(_, n)| **n) + .map(|(name, n)| (name.clone(), *n)); + } + let rewrite_started = std::time::Instant::now(); module .run_passes(STATEPOINT_REWRITE_PASSES, &tm, PassBuilderOptions::create()) .map_err(|e| { @@ -518,6 +647,14 @@ fn optimize_and_emit( e.to_string() ) })?; + if let Some(stats) = stats.as_deref_mut() { + stats.rewrite_secs = rewrite_started.elapsed().as_secs_f64(); + let (_, total, widest) = module_instruction_census(module); + 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)?; } let pipeline = match opt { @@ -528,18 +665,26 @@ fn optimize_and_emit( 'z' => "default", _ => "default", }; + let optimize_started = std::time::Instant::now(); module .run_passes(pipeline, &tm, PassBuilderOptions::create()) .map_err(|e| anyhow!("pass pipeline `{pipeline}` failed:\n{}", e.to_string()))?; + if let Some(stats) = stats.as_deref_mut() { + stats.optimize_secs = optimize_started.elapsed().as_secs_f64(); + } let kind = if emit_asm { FileType::Assembly } else { FileType::Object }; + let emit_started = std::time::Instant::now(); let obj = tm - .write_to_memory_buffer(&module, kind) + .write_to_memory_buffer(module, kind) .map_err(|e| anyhow!("{kind:?} emission failed:\n{}", e.to_string()))?; + if let Some(stats) = stats { + stats.emit_secs = emit_started.elapsed().as_secs_f64(); + } Ok(obj.as_slice().to_vec()) } @@ -608,44 +753,123 @@ mod tests { } #[test] - fn preoptimization_bloated_function_is_demoted_without_demoting_its_sibling() { + fn rewrite_budget_spellings() { + assert_eq!( + parse_rewrite_budget(None), + RewriteBudget::Error(DEFAULT_RS4GC_MAX_INSTRS) + ); + assert_eq!(parse_rewrite_budget(Some("0")), RewriteBudget::Off); + assert_eq!(parse_rewrite_budget(Some("off")), RewriteBudget::Off); + assert_eq!( + parse_rewrite_budget(Some(" 250000 ")), + RewriteBudget::Error(250_000) + ); + assert_eq!( + parse_rewrite_budget(Some("warn:4096")), + RewriteBudget::Warn(4096) + ); + assert_eq!(parse_rewrite_budget(Some("warn:0")), RewriteBudget::Off); + // Unparsable values keep the default rather than silently disabling. + assert_eq!( + parse_rewrite_budget(Some("lots")), + RewriteBudget::Error(DEFAULT_RS4GC_MAX_INSTRS) + ); + } + + /// Six gc values live across forty safepoints: ~60 instructions before + /// `rewrite-statepoints-for-gc`, a few hundred after (each statepoint + /// relocates every live value). A budget between the two is exceeded + /// only by the post-rewrite module — which is the property the + /// assertion exists for. Counting BEFORE the rewrite (the #8421 + /// replacement knob's mistake) would make `after` empty and fail here. + fn relocation_fanout_fixture() -> String { + let mut ir = String::from( + "declare i64 @may_collect()\n\n\ + define i64 @f(i64 %a0, i64 %a1, i64 %a2, i64 %a3, i64 %a4, i64 %a5) gc \"statepoint-example\" {\n\ + entry:\n", + ); + for i in 0..6 { + ir.push_str(&format!( + " %p{i} = inttoptr i64 %a{i} to ptr addrspace(1)\n" + )); + } + for c in 0..40 { + ir.push_str(&format!(" %c{c} = call i64 @may_collect()\n")); + } + for i in 0..6 { + ir.push_str(&format!( + " %b{i} = ptrtoint ptr addrspace(1) %p{i} to i64\n" + )); + } + ir.push_str( + " %s0 = add i64 %b0, %b1\n %s1 = add i64 %s0, %b2\n %s2 = add i64 %s1, %b3\n\ + \x20 %s3 = add i64 %s2, %b4\n %s4 = add i64 %s3, %b5\n %s5 = add i64 %s4, %c0\n\ + \x20 %s6 = add i64 %s5, %c39\n ret i64 %s6\n}\n", + ); + ir + } + + #[test] + fn rs4gc_budget_fires_only_on_the_rewritten_module() { global_init(&[]); + let target = "arm64-apple-darwin"; + let fixture = relocation_fanout_fixture(); + let rewritten = statepoint_rewritten_ir(&fixture, target, "fanout_budget") + .expect("fan-out fixture must run RS4GC"); + let context = Context::create(); - let ir = "define i64 @big(i64 %a) {\n\ - entry:\n\ - \x20 %x1 = add i64 %a, 1\n\ - \x20 %x2 = add i64 %x1, 1\n\ - \x20 %x3 = add i64 %x2, 1\n\ - \x20 %x4 = add i64 %x3, 1\n\ - \x20 %x5 = add i64 %x4, 1\n\ - \x20 ret i64 %x5\n\ - }\n\ - define i64 @small(i64 %a) {\n\ - entry:\n\ - \x20 %x1 = add i64 %a, 1\n\ - \x20 ret i64 %x1\n\ - }\n"; - let module = - parse_ir_text(&context, ir, "preopt_optnone_demotion").expect("fixture parses"); - demote_preoptimization_bloated_functions(&module, 4); - - let optnone_kind = Attribute::get_named_enum_kind_id("optnone"); - let big = module.get_function("big").expect("big exists"); - let small = module.get_function("small").expect("small exists"); + 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 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); assert!( - big.get_enum_attribute(AttributeLoc::Function, optnone_kind) - .is_some(), - "a function past the pre-optimization cap must be stamped optnone" + post_f > 3 * pre_f, + "fixture must grow under relocation fan-out (pre {pre_f}, post {post_f}):\n{rewritten}" ); + assert_eq!(post_total, post_f, "one defined function"); + let cap = pre_f + (post_f - pre_f) / 2; + assert!( - small - .get_enum_attribute(AttributeLoc::Function, optnone_kind) - .is_none(), - "an ordinary sibling must keep the module optimization pipeline" + rs4gc_budget_violations(&before, cap).is_empty(), + "the pre-rewrite module is under the budget by construction" ); - module - .verify() - .expect("optnone+noinline must remain verifier-valid"); + let over = rs4gc_budget_violations(&after, cap); + assert_eq!( + over.len(), + 1, + "exactly the rewritten body is over: {over:?}" + ); + 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 msg = format!("{err:#}"); + for needle in [ + "`f`", + &format!("to {post_f} instructions"), + &format!("it was {pre_f} before"), + &format!("budget is {cap}"), + "PERRY_LL_RS4GC_MAX_INSTRS", + "#8583", + ] { + assert!( + msg.contains(needle), + "message must carry {needle:?}:\n{msg}" + ); + } + assert!( + !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"); } fn constant_fold_order_fixture(folded: bool) -> String { diff --git a/crates/perry-codegen/src/native_emit.rs b/crates/perry-codegen/src/native_emit.rs index 284468bcdb..fd51c8a8b1 100644 --- a/crates/perry-codegen/src/native_emit.rs +++ b/crates/perry-codegen/src/native_emit.rs @@ -291,10 +291,11 @@ pub fn compile_module_units_native( 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); + let unit_timings = std::env::var("PERRY_CODEGEN_UNIT_TIMINGS").is_ok(); let show_progress = matches!( std::env::var("PERRY_CODEGEN_PROGRESS").as_deref(), Ok("1" | "all") - ) || std::env::var("PERRY_CODEGEN_UNIT_TIMINGS").is_ok(); + ) || unit_timings; let unit_total = parts.len(); // Root lowering was selected while the module was produced. Preserve that // exact backend choice across the worker boundary instead of re-reading @@ -322,13 +323,39 @@ pub fn compile_module_units_native( .map_err(|e| anyhow!("unit {i}: {e:#}"))?; debug_dump(&module, &format!("{module_prefix}.unit{i}")); let (effective_target, args) = crate::linker::native_plan_args(target, native_roots); - let unit_bytes = crate::inprocess::optimize_and_emit_module( + 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:#}"))?; + if unit_timings { + let widest = |w: &Option<(String, usize)>| { + w.as_ref() + .map(|(name, n)| format!("{name} {n}")) + .unwrap_or_else(|| "-".to_string()) + }; + let growth = if stats.pre_rewrite_instructions > 0 { + stats.post_rewrite_instructions as f64 / stats.pre_rewrite_instructions as f64 + } else { + 0.0 + }; + eprintln!( + "[perry] codegen: {module_prefix}: unit {}/{unit_total}: {} fns; pre-RS4GC {} instrs (widest {}); post-RS4GC {} instrs (x{growth:.1}; widest {}); rs4gc {:.1}s, opt {:.1}s, emit {:.1}s", + i + 1, + stats.functions, + stats.pre_rewrite_instructions, + widest(&stats.pre_rewrite_widest), + stats.post_rewrite_instructions, + widest(&stats.post_rewrite_widest), + stats.rewrite_secs, + stats.optimize_secs, + stats.emit_secs, + ); + } let obj = crate::linker::finish_native_emission(unit_bytes, &effective_target, &args) .map_err(|e| anyhow!("unit {i}: {e:#}"))?; log::debug!( @@ -420,6 +447,22 @@ pub fn compile_module_units_native( // 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() { + 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 + // unit's time and memory, and a stuck unit number alone does + // 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)", + i + 1, + part.funcs.len(), + part.funcs.iter().map(|f| f.estimated_ir_bytes()).sum::() as f64 / 1_048_576.0, + widest.name, + widest.estimated_ir_bytes() as f64 / 1_048_576.0 + ); + } + } let unit = freeze_unit(part, &external_declarations); if sender.send((i, unit)).is_err() { break; @@ -672,6 +715,46 @@ mod tests { } } + /// #8583: `optnone` stamped BEFORE `rewrite-statepoints-for-gc` is not a + /// compile-time escape hatch, it is a rooting bug. The new pass manager + /// skips `mem2reg`/`sccp` on an `optnone` function while RS4GC (a module + /// pass keyed on the `gc` attribute) still runs, so the root allocas are + /// never promoted and the collector never sees them: no `gc-live` operand + /// bundle, no relocation. This is why the pre-rewrite + /// `PERRY_LL_PREOPT_OPTNONE_INSTRS` knob was removed rather than + /// calibrated, and why any future size policy must run AFTER the rewrite. + #[test] + fn optnone_before_rs4gc_hides_every_root_from_the_collector() { + let _native = crate::codegen::helpers::NativeRootsPin::native(); + let module = precise_root_fixture(false); + let target = crate::codegen::default_target_triple(); + let text_ir = module.to_ir(); + assert!( + text_ir.contains("gc \"statepoint-example\" {"), + "fixture must carry the GC strategy:\n{text_ir}" + ); + // Control: the same fixture without optnone roots and relocates. + assert_dynamic_root_survives_rs4gc(&module, "optnone_control"); + + let demoted = text_ir.replace( + "gc \"statepoint-example\" {", + "optnone noinline gc \"statepoint-example\" {", + ); + let rewritten = + crate::inprocess::statepoint_rewritten_ir(&demoted, &target, "optnone_before_rs4gc") + .expect("optnone fixture must still run RS4GC"); + assert!( + !rewritten.contains("\"gc-live\"(ptr addrspace(1)"), + "an optnone function kept its roots visible to RS4GC, so the pre-rewrite \ + demotion would be sound after all and this test (and the knob's removal) \ + needs revisiting:\n{rewritten}" + ); + assert!( + rewritten.contains("= alloca ptr addrspace(1)"), + "the root allocas should survive unpromoted under optnone:\n{rewritten}" + ); + } + /// #8121, emission half. The sibling pair in `inprocess::tests` proves the /// LLVM mechanism (RS4GC breaks an unmarked inline-asm barrier, and /// `gc-leaf-function` stops it) using hand-written IR, so it would still diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index f185a15653..a62b88f331 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -39,10 +39,11 @@ const BUILD_CACHE_ENV_VARS: &[&str] = &[ "PERRY_WRITE_BARRIERS", "PERRY_SHADOW_STACK", "PERRY_RS4GC", - // Explicit hybrid size mode changes both the module optimization policy - // and which unusually large functions skip the middle-end. + // `-Os` vs `-O3` for every native module. "PERRY_LL_SIZE_OPT", - "PERRY_LL_PREOPT_OPTNONE_INSTRS", + // The post-RS4GC per-function instruction budget (#8583): a unit that one + // setting refuses must not be served from a build another accepted. + "PERRY_LL_RS4GC_MAX_INSTRS", "PERRY_GC_SAFEPOINT_ONLY", "PERRY_INLINE_SHADOW_SLOT", "PERRY_DISABLE_BUFFER_FAST_PATH", diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index e83860d767..5e682373c4 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -841,9 +841,11 @@ 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. h.field( - "env_ll_preopt_optnone_instrs", - env_var("PERRY_LL_PREOPT_OPTNONE_INSTRS") + "env_ll_rs4gc_max_instrs", + env_var("PERRY_LL_RS4GC_MAX_INSTRS") .as_deref() .unwrap_or(""), ); diff --git a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs index b21dc6654b..1ab35caa09 100644 --- a/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs +++ b/crates/perry/src/commands/compile/object_cache/object_cache_tests.rs @@ -621,7 +621,7 @@ fn key_changes_with_codegen_env_vars() { "PERRY_SHADOW_STACK", "PERRY_RS4GC", "PERRY_LL_SIZE_OPT", - "PERRY_LL_PREOPT_OPTNONE_INSTRS", + "PERRY_LL_RS4GC_MAX_INSTRS", "PERRY_GC_SAFEPOINT_ONLY", "PERRY_DISABLE_BUFFER_FAST_PATH", "PERRY_VERIFY_NATIVE_REGIONS",