From 2e1a37cc062ac19c312167c635b3e63f10334643 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 4 Sep 2026 22:19:45 +0200 Subject: [PATCH 1/2] fix(compile): bound and unblock extracted Bun bundles --- crates/perry-codegen/src/codegen/helpers.rs | 2 +- crates/perry-codegen/src/codegen/mod.rs | 12 + .../perry-codegen/src/expr/dyn_extern_i18n.rs | 9 +- crates/perry-codegen/src/inprocess.rs | 565 +++++++++++++++++- crates/perry-codegen/src/lib.rs | 8 +- crates/perry-codegen/src/lower_call/new.rs | 12 + crates/perry-codegen/src/module.rs | 87 ++- crates/perry-codegen/src/native_emit.rs | 56 +- .../tests/native_proof_regressions.rs | 87 +++ .../src/lower/expr_call/intrinsics.rs | 2 +- .../src/lower/expr_call/intrinsics/require.rs | 48 ++ crates/perry-hir/src/lower/expr_call/mod.rs | 12 +- crates/perry-hir/src/lower/tests.rs | 24 + .../perry/src/commands/compile/build_cache.rs | 4 + .../src/commands/compile/object_cache.rs | 9 + .../object_cache/object_cache_tests.rs | 1 + .../src/commands/compile/run_pipeline.rs | 46 +- 17 files changed, 919 insertions(+), 65 deletions(-) diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index 504b743f79..cd372b1f43 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -518,7 +518,7 @@ pub(crate) fn inline_hot_small_max_call_sites() -> u32 { /// (every function stays on native statepoints, the pre-#8583 behavior). const DEFAULT_ROOT_SPILL_RELOCATIONS: usize = 32_000_000; -fn root_spill_relocation_threshold() -> usize { +pub(crate) fn root_spill_relocation_threshold() -> usize { std::env::var("PERRY_ROOT_SPILL_RELOCATIONS") .ok() .and_then(|v| v.trim().parse::().ok()) diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index ba4899a9ef..6e83cfde6e 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -385,6 +385,18 @@ pub fn short_spread_method_capabilities(hir: &HirModule) -> Vec String { + let module_prefix = sanitize(module_name); + helpers::scoped_fn_name(&module_prefix, function_name) +} + /// Compile a Perry HIR module to an object file via LLVM IR. /// /// CRITICAL (#686): `hir` MUST be `&HirModule` (shared reference), never diff --git a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs index 7097bc3868..d193041602 100644 --- a/crates/perry-codegen/src/expr/dyn_extern_i18n.rs +++ b/crates/perry-codegen/src/expr/dyn_extern_i18n.rs @@ -489,8 +489,15 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { is_eval: _, } => { let _ = lower_expr(ctx, filename)?; + if ctx.block().is_terminated() { + return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); + } let options_val = if let Some(options) = options { - lower_expr(ctx, options)? + let value = lower_expr(ctx, options)?; + if ctx.block().is_terminated() { + return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); + } + value } else { double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)) }; diff --git a/crates/perry-codegen/src/inprocess.rs b/crates/perry-codegen/src/inprocess.rs index 5b83456dc5..e46596e66c 100644 --- a/crates/perry-codegen/src/inprocess.rs +++ b/crates/perry-codegen/src/inprocess.rs @@ -308,7 +308,8 @@ pub(crate) fn optimize_and_emit_module( } /// [`optimize_and_emit_module`] that also fills `stats` (sizes before and -/// after RS4GC, widest functions, phase times) for the per-unit report. +/// after RS4GC, widest functions, phase times, and any bounded-emission +/// fallback) for the per-unit report. pub(crate) fn optimize_and_emit_module_with_stats( module: &inkwell::module::Module<'_>, effective_target: &str, @@ -348,6 +349,9 @@ pub struct UnitCodegenStats { /// Functions stamped `"disable-tail-calls"` because their alloca-walk /// estimate exceeded [`DEFAULT_TRE_MAX_ALLOCA_WALK`] (#8883). pub tail_call_elim_skipped: Vec, + /// The widest function which made this unit use LLVM's bounded O0 machine + /// pipeline after completing the requested IR optimization pipeline. + pub fast_emit_fallback: Option, } fn function_instruction_count(function: inkwell::values::FunctionValue<'_>) -> usize { @@ -385,6 +389,133 @@ fn module_instruction_census( (functions, total, widest) } +/// Per-function instruction ceiling for LLVM's optimized machine pipeline. +/// +/// This budget is checked *after* the requested `default` IR pipeline has +/// completed. It changes neither JS lowering nor middle-end optimization; it +/// only asks the target machine to use its O0 instruction-selection, +/// live-interval and register-allocation pipeline for a unit containing an +/// extreme generated function. +/// +/// The threshold is bracketed by real arm64/LLVM 22 measurements. Machine-IR +/// expansion depends on CFG shape, so raw IR size is deliberately only a +/// conservative guard: one 161k-instruction function emitted normally in +/// ~19s, while a different 100,152-instruction Claude Code 2.1.259 function +/// grew past ~10 GiB RSS in the optimized machine pipeline. The same function +/// emitted through an O0 target machine in 6s. Another 277k-instruction async +/// state-machine function remained in LiveIntervals / register allocation for +/// more than 16 minutes at ~10 GiB RSS; its already-Os-optimized IR emitted +/// through an O0 target machine in 3.5s at ~550 MiB RSS. 100k is immediately +/// below the smallest observed pathological case. +/// +/// `PERRY_LL_FAST_EMIT_MAX_INSTRS=` raises or lowers the ceiling; `0` / +/// `off` disables the fallback. +const DEFAULT_FAST_EMIT_MAX_INSTRS: usize = 100_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FastEmitBudget { + Off, + Cap(usize), +} + +fn parse_fast_emit_budget(value: Option<&str>) -> FastEmitBudget { + match value.map(str::trim) { + None | Some("") => FastEmitBudget::Cap(DEFAULT_FAST_EMIT_MAX_INSTRS), + Some("0") | Some("off") | Some("false") => FastEmitBudget::Off, + Some(v) => match v.parse::() { + Ok(0) => FastEmitBudget::Off, + Ok(n) => FastEmitBudget::Cap(n), + Err(_) => FastEmitBudget::Cap(DEFAULT_FAST_EMIT_MAX_INSTRS), + }, + } +} + +fn fast_emit_budget() -> FastEmitBudget { + #[cfg(test)] + if let Some(budget) = TEST_FAST_EMIT_BUDGET.with(std::cell::Cell::get) { + return budget; + } + parse_fast_emit_budget( + std::env::var("PERRY_LL_FAST_EMIT_MAX_INSTRS") + .ok() + .as_deref(), + ) +} + +#[cfg(test)] +thread_local! { + static TEST_FAST_EMIT_BUDGET: std::cell::Cell> = const { + std::cell::Cell::new(None) + }; +} + +/// Thread-local budget seam; mutating the process environment would race the +/// other LLVM tests in this binary. +#[cfg(test)] +fn with_test_fast_emit_budget(cap: usize, run: impl FnOnce() -> T) -> T { + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + TEST_FAST_EMIT_BUDGET.with(|budget| budget.set(self.0)); + } + } + let old = TEST_FAST_EMIT_BUDGET.replace(Some(FastEmitBudget::Cap(cap))); + let _restore = Restore(old); + run() +} + +/// The extreme function which selected bounded machine-code emission. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FastEmitFallback { + pub name: String, + pub instructions: usize, + pub cap: usize, +} + +impl std::fmt::Display for FastEmitFallback { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "`{}` has {} instructions after IR optimization, above the optimized machine-pipeline \ + budget {}; keeping the requested IR optimization, then emitting this unit through \ + LLVM's O0 machine pipeline to bound instruction selection, live intervals and \ + register allocation. Override with PERRY_LL_FAST_EMIT_MAX_INSTRS= (raise) or \ + =0 (disable).", + self.name, self.instructions, self.cap + ) + } +} + +fn fast_emit_fallback( + module: &inkwell::module::Module<'_>, + budget: FastEmitBudget, +) -> Option { + let cap = match budget { + FastEmitBudget::Off => return None, + FastEmitBudget::Cap(cap) => cap, + }; + let mut widest: Option = None; + let mut function = module.get_first_function(); + while let Some(f) = function { + if f.count_basic_blocks() > 0 { + let instructions = function_instruction_count(f); + if instructions > cap + && widest + .as_ref() + .is_none_or(|current| instructions > current.instructions) + { + widest = Some(FastEmitFallback { + name: f.get_name().to_string_lossy().into_owned(), + instructions, + cap, + }); + } + } + function = f.get_next_function(); + } + widest +} + /// Instruction budget for ONE function after `rewrite-statepoints-for-gc`. /// /// This is the measured backstop for the estimate that keeps relocation @@ -409,15 +540,31 @@ enum RewriteBudget { /// 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) enum Rs4gcBudgetCause { + /// The constructed function is already large enough that RS4GC's own + /// liveness/rewrite walk may not finish. The estimate uses the roots and + /// non-leaf call sites LLVM will actually see, rather than another source + /// syntax approximation. + PreRewrite { + root_allocas: usize, + safepoints: usize, + estimated_relocations: usize, + }, + /// RS4GC finished, but its relocation fan-out made the rewritten body too + /// large for the normal optimization pipeline. + PostRewrite { post_instructions: usize }, +} + #[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. + /// The pre- or post-rewrite condition that requested the retry. + pub cause: Rs4gcBudgetCause, + /// Active limit for the cause's estimate. pub cap: usize, } @@ -547,6 +694,120 @@ fn rs4gc_functions(module: &inkwell::module::Module<'_>) -> std::collections::Ha names } +/// The two constructed-IR factors that bound RS4GC relocation fan-out. +/// +/// Count only allocas whose payload is a managed pointer and call sites which +/// are not explicitly marked as GC leaves. LLVM intrinsics are also leaves: +/// they cannot enter Perry's runtime or collect. This is deliberately the +/// same conservative model as the source-level spill estimate — each +/// safepoint can leave one additional pointer result live across later calls — +/// but it observes the calls codegen actually emitted. That closes estimator +/// holes where one source expression expands into several collecting helpers. +fn rs4gc_preflight_factors(function: inkwell::values::FunctionValue<'_>) -> (usize, usize) { + let mut root_allocas = 0usize; + let mut safepoints = 0usize; + for bb in function.get_basic_blocks() { + let mut inst = bb.get_first_instruction(); + while let Some(i) = inst { + match i.get_opcode() { + inkwell::values::InstructionOpcode::Alloca => { + if matches!( + i.get_allocated_type(), + Ok(inkwell::types::BasicTypeEnum::PointerType(ptr)) + if ptr.get_address_space() == inkwell::AddressSpace::from(1u16) + ) { + root_allocas += 1; + } + } + inkwell::values::InstructionOpcode::Call + | inkwell::values::InstructionOpcode::CallBr + | inkwell::values::InstructionOpcode::Invoke => { + // Call, invoke and callbr are all LLVM CallBase values, so + // the call-site attribute API is valid for each opcode. + let call = unsafe { inkwell::values::CallSiteValue::new(i.as_value_ref()) }; + let gc_leaf = call + .get_string_attribute( + inkwell::attributes::AttributeLoc::Function, + "gc-leaf-function", + ) + .is_some(); + let intrinsic = call + .get_called_fn_value() + .map_or(false, |callee| callee.get_intrinsic_id() != 0); + if !gc_leaf && !intrinsic { + safepoints += 1; + } + } + _ => {} + } + inst = i.get_next_instruction(); + } + } + (root_allocas, safepoints) +} + +/// Every RS4GC-participating function whose constructed IR predicts more +/// relocation work than the source-level spill budget permits. +fn rs4gc_preflight_violations( + module: &inkwell::module::Module<'_>, + cap: usize, + rewritten_functions: &std::collections::HashSet, +) -> Vec<(String, usize, usize, usize)> { + if cap == 0 { + return Vec::new(); + } + 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(); + if rewritten_functions.contains(&name) { + let (root_allocas, safepoints) = rs4gc_preflight_factors(f); + let live_roots = + crate::codegen::helpers::spill_live_root_count(root_allocas, safepoints); + let estimate = + crate::codegen::helpers::root_relocation_estimate(live_roots, safepoints); + if estimate > cap { + over.push((name, root_allocas, safepoints, estimate)); + } + } + } + function = f.get_next_function(); + } + over +} + +/// Stop before RS4GC itself enters its super-linear liveness/rewrite walk and +/// ask codegen to re-lower the named functions with precise shadow roots. +fn enforce_rs4gc_preflight_budget( + module: &inkwell::module::Module<'_>, + cap: usize, + pre: &std::collections::HashMap, + rewritten_functions: &std::collections::HashSet, +) -> Result<()> { + let violations: Vec = + rs4gc_preflight_violations(module, cap, rewritten_functions) + .into_iter() + .map( + |(name, root_allocas, safepoints, estimated_relocations)| Rs4gcBudgetViolation { + pre_instructions: pre.get(&name).copied(), + name, + cause: Rs4gcBudgetCause::PreRewrite { + root_allocas, + safepoints, + estimated_relocations, + }, + cap, + }, + ) + .collect(); + if violations.is_empty() { + Ok(()) + } else { + Err(anyhow::Error::new(Rs4gcBudgetExceeded { violations })) + } +} + /// Every RS4GC-participating function whose post-rewrite body exceeds `cap`. fn rs4gc_budget_violations( module: &inkwell::module::Module<'_>, @@ -569,23 +830,40 @@ fn rs4gc_budget_violations( } 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 `{}` 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 - ) + match &violation.cause { + Rs4gcBudgetCause::PreRewrite { + root_allocas, + safepoints, + estimated_relocations, + } => format!( + "before rewrite-statepoints-for-gc, `{}` has {root_allocas} managed-root allocas and \ + {safepoints} non-leaf call sites; accounting for call-result temporaries predicts \ + {estimated_relocations} relocations, above the pre-rewrite budget {}. RS4GC's own \ + liveness/rewrite walk is super-linear on fan-out of this size; {outcome} (#8583). \ + Override with PERRY_ROOT_SPILL_RELOCATIONS= (raise) or =0 (disable).", + violation.name, violation.cap + ), + Rs4gcBudgetCause::PostRewrite { post_instructions } => { + let before = violation + .pre_instructions + .map(|n| format!(" (it was {n} before the rewrite)")) + .unwrap_or_default(); + format!( + "rewrite-statepoints-for-gc grew `{}` to {post_instructions} \ + 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.cap + ) + } + } } /// Apply [`RewriteBudget`] to a rewritten module. `pre` gives each function's @@ -610,7 +888,7 @@ fn enforce_rs4gc_instruction_budget( .map(|(name, post_instructions)| Rs4gcBudgetViolation { pre_instructions: pre.get(&name).copied(), name, - post_instructions, + cause: Rs4gcBudgetCause::PostRewrite { post_instructions }, cap, }) .collect(); @@ -907,8 +1185,9 @@ 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 preflight_cap = crate::codegen::helpers::root_spill_relocation_threshold(); let rewritten_functions = rs4gc_functions(module); - let pre_sizes = if budget == RewriteBudget::Off && stats.is_none() { + let pre_sizes = if budget == RewriteBudget::Off && preflight_cap == 0 && stats.is_none() { std::collections::HashMap::new() } else { pre_rewrite_sizes(module) @@ -921,6 +1200,11 @@ fn optimize_and_emit( .max_by_key(|(_, n)| **n) .map(|(name, n)| (name.clone(), *n)); } + // The source-level estimate is intentionally cheap but can miss + // codegen expansion (one expression becoming many collecting helper + // calls). Check the actual constructed CallBase/root shape before + // asking RS4GC to perform the potentially super-linear rewrite. + enforce_rs4gc_preflight_budget(module, preflight_cap, &pre_sizes, &rewritten_functions)?; let rewrite_started = std::time::Instant::now(); module .run_passes(STATEPOINT_REWRITE_PASSES, &tm, PassBuilderOptions::create()) @@ -985,13 +1269,53 @@ fn optimize_and_emit( stats.optimize_secs = optimize_started.elapsed().as_secs_f64(); } + // The IR pipeline above has already done the requested optimization. For + // an extreme generated function, LLVM's optimized *machine* pipeline can + // still become super-linear in instruction selection / LiveIntervals / + // register allocation. Use an O0 target machine only for final emission + // of that unit; ordinary units keep `tm`, and the optimized IR is not + // rebuilt or demoted. + let fast_emit = if opt == '0' { + None + } else { + fast_emit_fallback(module, fast_emit_budget()) + }; + if let Some(fallback) = &fast_emit { + eprintln!("perry: {fallback}"); + } + if let Some(stats) = stats.as_deref_mut() { + stats.fast_emit_fallback = fast_emit.clone(); + } + let fast_tm = if fast_emit.is_some() { + Some( + target + .create_target_machine( + &triple, + &cpu, + &features, + OptimizationLevel::None, + RelocMode::PIC, + CodeModel::Default, + ) + .ok_or_else(|| { + anyhow!( + "failed to create bounded O0 emission TargetMachine for \ + `{effective_target}`" + ) + })?, + ) + } else { + None + }; + let emit_tm = fast_tm.as_ref().unwrap_or(&tm); + let kind = if emit_asm { FileType::Assembly } else { FileType::Object }; let emit_started = std::time::Instant::now(); - let obj = tm + let obj = emit_tm .write_to_memory_buffer(module, kind) .map_err(|e| anyhow!("{kind:?} emission failed:\n{}", e.to_string()))?; if let Some(stats) = stats { @@ -1105,6 +1429,97 @@ mod tests { ); } + /// The source-level estimate is only a fast first line of defence. This + /// fixture pins the constructed-IR backstop: managed-root allocas count, + /// ordinary calls count, explicit GC-leaf calls and LLVM intrinsics do + /// not, and only functions which will actually enter RS4GC are governed. + #[test] + fn rs4gc_preflight_uses_constructed_roots_and_non_leaf_calls() { + let fixture = r#" +declare i64 @may_collect() +declare i64 @leaf() +declare void @llvm.donothing() + +define i64 @hot() gc "statepoint-example" { +entry: + %root = alloca ptr addrspace(1) + %plain = alloca i64 + %a = call i64 @may_collect() + %b = call i64 @may_collect() + %c = call i64 @leaf() "gc-leaf-function" + call void @llvm.donothing() + %p = load ptr addrspace(1), ptr %root + %bits = ptrtoint ptr addrspace(1) %p to i64 + %sum = add i64 %a, %b + %sum2 = add i64 %sum, %c + %out = add i64 %sum2, %bits + ret i64 %out +} + +define i64 @shadow() { +entry: + %root = alloca ptr addrspace(1) + %a = call i64 @may_collect() + ret i64 %a +} +"#; + let context = Context::create(); + let module = parse_ir_text(&context, fixture, "preflight_fixture").expect("fixture parses"); + let hot = module.get_function("hot").expect("hot"); + assert_eq!( + rs4gc_preflight_factors(hot), + (1, 2), + "plain allocas, leaf calls and intrinsics do not add RS4GC work" + ); + + // (one constructed root + two possible call-result roots) x two + // safepoints = six estimated relocations. The boundary is exclusive. + let rewritten_functions = rs4gc_functions(&module); + assert_eq!( + rs4gc_preflight_violations(&module, 5, &rewritten_functions), + vec![("hot".to_string(), 1, 2, 6)] + ); + assert!(rs4gc_preflight_violations(&module, 6, &rewritten_functions).is_empty()); + assert!(rs4gc_preflight_violations(&module, 0, &rewritten_functions).is_empty()); + + let pre = pre_rewrite_sizes(&module); + let err = enforce_rs4gc_preflight_budget(&module, 5, &pre, &rewritten_functions) + .expect_err("the constructed shape 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, "hot"); + assert_eq!(retry[0].pre_instructions, pre.get("hot").copied()); + assert_eq!( + retry[0].cause, + Rs4gcBudgetCause::PreRewrite { + root_allocas: 1, + safepoints: 2, + estimated_relocations: 6, + } + ); + assert_eq!(retry[0].cap, 5); + let msg = format!("{err:#}"); + for needle in [ + "before rewrite-statepoints-for-gc", + "`hot`", + "1 managed-root allocas", + "2 non-leaf call sites", + "predicts 6 relocations", + "budget 5", + "PERRY_ROOT_SPILL_RELOCATIONS", + "re-lower", + ] { + assert!( + msg.contains(needle), + "message must carry {needle:?}:\n{msg}" + ); + } + + let no_rewritten_functions = std::collections::HashSet::new(); + enforce_rs4gc_preflight_budget(&module, 1, &pre, &no_rewritten_functions) + .expect("a shadow-root function is outside the preflight budget"); + } + /// 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 @@ -1185,7 +1600,12 @@ mod tests { 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].cause, + Rs4gcBudgetCause::PostRewrite { + post_instructions: post_f + } + ); assert_eq!(retry[0].cap, cap); let msg = format!("{err:#}"); for needle in [ @@ -1694,6 +2114,29 @@ entry: ); } + #[test] + fn fast_emit_budget_spellings() { + assert_eq!( + parse_fast_emit_budget(None), + FastEmitBudget::Cap(DEFAULT_FAST_EMIT_MAX_INSTRS) + ); + assert_eq!( + parse_fast_emit_budget(Some("")), + FastEmitBudget::Cap(DEFAULT_FAST_EMIT_MAX_INSTRS) + ); + assert_eq!(parse_fast_emit_budget(Some("0")), FastEmitBudget::Off); + assert_eq!(parse_fast_emit_budget(Some("off")), FastEmitBudget::Off); + assert_eq!(parse_fast_emit_budget(Some("false")), FastEmitBudget::Off); + assert_eq!( + parse_fast_emit_budget(Some(" 250000 ")), + FastEmitBudget::Cap(250_000) + ); + assert_eq!( + parse_fast_emit_budget(Some("lots")), + FastEmitBudget::Cap(DEFAULT_FAST_EMIT_MAX_INSTRS) + ); + } + /// Two functions: `wide` has 4 allocas across 9 instructions (estimate /// 36), `narrow` has one across 3 (estimate 3), and `decl` has no body. fn alloca_walk_fixture() -> &'static str { @@ -1788,6 +2231,42 @@ entry: ); } + /// Selection is per function, the boundary is inclusive, declarations do + /// not count, and the diagnostic names the widest violating function. + #[test] + fn fast_emit_budget_selects_only_above_the_boundary() { + let context = Context::create(); + let module = parse_ir_text(&context, alloca_walk_fixture(), "fast_emit_fixture") + .expect("fixture parses"); + assert!(fast_emit_fallback(&module, FastEmitBudget::Off).is_none()); + assert!(fast_emit_fallback(&module, FastEmitBudget::Cap(9)).is_none()); + + let fallback = fast_emit_fallback(&module, FastEmitBudget::Cap(8)) + .expect("wide is one instruction over the budget"); + assert_eq!( + fallback, + FastEmitFallback { + name: "wide".to_string(), + instructions: 9, + cap: 8, + } + ); + let message = fallback.to_string(); + for needle in [ + "`wide`", + "9 instructions", + "budget 8", + "requested IR optimization", + "O0 machine pipeline", + "PERRY_LL_FAST_EMIT_MAX_INSTRS", + ] { + assert!( + message.contains(needle), + "{needle:?} missing from:\n{message}" + ); + } + } + /// A self-recursive tail call that TailCallElim turns into a loop at /// the pinned LLVM: with no attribute the recursive `call` disappears, /// with `"disable-tail-calls"="true"` (exactly what the budget stamps) @@ -1886,4 +2365,52 @@ entry: assert!(stats.tail_call_elim_skipped.is_empty()); assert!(!has_disable_tail_calls(&module, "wide")); } + + /// A tiny test cap proves the shipped path records and successfully uses + /// the second, O0 target machine only after running the requested Os IR + /// pipeline. The production threshold is pinned by the parser test and + /// the real Claude-Code measurement in its constant's documentation. + #[test] + fn fast_emit_budget_is_applied_by_the_shipped_pipeline() { + global_init(&[]); + let target = crate::codegen::default_target_triple(); + let context = Context::create(); + let module = parse_ir_text(&context, alloca_walk_fixture(), "fast_emit_shipped") + .expect("fixture parses"); + let mut stats = UnitCodegenStats::default(); + let object = with_test_fast_emit_budget(1, || { + optimize_and_emit_module_with_stats( + &module, + &target, + &["-Os".into(), "-c".into()], + false, + Some(&mut stats), + ) + }) + .expect("the already-optimized module emits through the bounded target machine"); + assert!(!object.is_empty()); + let fallback = stats + .fast_emit_fallback + .expect("the shipped path must report the selected fallback"); + assert_eq!(fallback.name, "wide"); + assert!(fallback.instructions > fallback.cap); + assert_eq!(fallback.cap, 1); + + // A requested O0 compile already uses the bounded target machine; it + // neither needs nor reports a fallback. + let module = + parse_ir_text(&context, alloca_walk_fixture(), "fast_emit_o0").expect("fixture parses"); + let mut stats = UnitCodegenStats::default(); + with_test_fast_emit_budget(1, || { + optimize_and_emit_module_with_stats( + &module, + &target, + &["-O0".into(), "-c".into()], + false, + Some(&mut stats), + ) + }) + .expect("-O0 emits"); + assert!(stats.fast_emit_fallback.is_none()); + } } diff --git a/crates/perry-codegen/src/lib.rs b/crates/perry-codegen/src/lib.rs index 8186dcb47f..95d1454fdb 100644 --- a/crates/perry-codegen/src/lib.rs +++ b/crates/perry-codegen/src/lib.rs @@ -73,10 +73,10 @@ pub mod types; pub use codegen::{ compile_module, namespace_member_class_key, namespace_member_func_key, - namespace_member_var_key, resolve_target_triple, short_spread_method_capabilities, AppMetadata, - CompileOptions, ExportedObjectLiteralCapability, FpContractMode, ImportedClass, - ImportedObjectLiteral, ImportedObjectLiteralMethod, NamespaceEntry, NamespaceEntryKind, - ObjectLiteralMethodCandidate, ShortSpreadMethodCandidate, + namespace_member_var_key, resolve_target_triple, short_spread_method_capabilities, + user_function_symbol, AppMetadata, CompileOptions, ExportedObjectLiteralCapability, + FpContractMode, ImportedClass, ImportedObjectLiteral, ImportedObjectLiteralMethod, + NamespaceEntry, NamespaceEntryKind, ObjectLiteralMethodCandidate, ShortSpreadMethodCandidate, }; pub use collectors::CjsPreambleCensus; diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index fdc93a03c5..fade7668bc 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -511,6 +511,18 @@ fn lower_new_impl_inner<'a>( let mut lowered_args: Vec = Vec::with_capacity(args.len()); for a in args { let value = lower_constructor_arg(ctx, a)?; + // An argument can complete abruptly while still returning a sentinel + // value to the lowering API. The unresolved dynamic-Worker fallback + // is one such expression: it emits the runtime throw followed by + // `unreachable`. Do not root that sentinel or continue into instance + // allocation / constructor diamonds. `LlBlock` drops instructions + // appended after a terminator, while those diamonds create fresh + // blocks that would refer to the dropped registers (Claude Code's + // `{ worker: new Worker(dynamicPath), stamp: ... }` object literal was + // the reproducer). + if ctx.block().is_terminated() { + return Ok(double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED))); + } // `collects` is unconditionally true: the instance allocation below // always collects, so every argument is live across it. That is the // same answer the pre-migration code gave by consulting diff --git a/crates/perry-codegen/src/module.rs b/crates/perry-codegen/src/module.rs index 4839d3264e..74807711eb 100644 --- a/crates/perry-codegen/src/module.rs +++ b/crates/perry-codegen/src/module.rs @@ -702,8 +702,8 @@ impl LlModule { /// `external` *declarations* are replicated as-is; /// * the module's external `declare`s plus a synthesized `declare` for /// every locally-defined function the unit does NOT itself define, so - /// cross-unit calls resolve at link time (deduped by name, existing - /// declarations win); + /// cross-unit calls resolve at link time (deduped by name, local + /// definitions supply the authoritative signature); /// * each function rendered with external linkage forced (the lone /// `internal` init/wrapper is promoted so cross-unit calls bind); /// * the shared attribute groups + metadata (so `#N`/`!N` refs resolve). @@ -759,18 +759,19 @@ impl LlModule { let shared_strings: Vec = self.string_constants.clone(); let shared_globals: Vec = self.globals.clone(); - // name -> declare line. Existing module declarations (runtime, FFI, - // cross-module) take precedence; every locally-defined function without - // one gets a synthesized declare. Deduped by name so no unit emits a - // duplicate declaration. BTreeMap for deterministic unit output. + // name -> declare line. Start with module declarations (runtime, FFI, + // cross-module), then replace any entry that is also defined locally + // with a declaration synthesized from that definition. Import metadata + // can contain an earlier, less precise signature; the definition is what + // the whole-module renderer and LLVM see, so split units must agree with + // it too. Deduped by name so no unit emits a duplicate declaration. + // BTreeMap keeps unit output deterministic. let mut decl_by_name: BTreeMap<&str, String> = BTreeMap::new(); for (name, decl) in &self.declarations { decl_by_name.insert(name.as_str(), decl.clone()); } for f in &funcs { - decl_by_name - .entry(f.name.as_str()) - .or_insert_with(|| declare_line_for(f)); + decl_by_name.insert(f.name.as_str(), declare_line_for(f)); } // #7174 (real-app scaling): scan each bucket's functions first, then @@ -1769,6 +1770,74 @@ mod tests { assert!(ir.contains("define i32 @main")); } + #[test] + fn split_unit_declaration_uses_local_definition_signature() { + let mut m = LlModule::new("arm64-apple-macosx15.0.0"); + + // Import metadata may register a constructor before its source module + // is lowered, with a stale arity. Once this module defines the symbol, + // its definition is authoritative for callers placed in another unit. + m.declare_function("constructor", DOUBLE, &[DOUBLE]); + let constructor = m.define_function( + "constructor", + DOUBLE, + vec![ + (DOUBLE, "this_arg".into()), + (DOUBLE, "arg0".into()), + (DOUBLE, "arg1".into()), + ], + ); + constructor.create_block("entry").ret(DOUBLE, "this_arg"); + + let caller = m.define_function("caller", DOUBLE, vec![]); + let entry = caller.create_block("entry"); + let result = entry.call( + DOUBLE, + "constructor", + &[(DOUBLE, "0.0"), (DOUBLE, "1.0"), (DOUBLE, "2.0")], + ); + entry.ret(DOUBLE, &result); + + let units = m.render_codegen_units(2); + let caller_unit = units + .iter() + .find(|unit| unit.contains("define double @caller(")) + .expect("caller unit"); + assert!(caller_unit.contains("declare double @constructor(double, double, double)")); + assert!(!caller_unit.contains("declare double @constructor(double)")); + } + + #[test] + fn split_unit_declares_local_function_used_as_pointer_argument() { + let mut m = LlModule::new("arm64-apple-macosx15.0.0"); + m.declare_function("js_closure_alloc_singleton", I64, &[PTR]); + + let wrapper_name = "__perry_wrap_perry_fn_m___a"; + let wrapper = m.define_function( + wrapper_name, + DOUBLE, + vec![(I64, "%this_closure".into()), (DOUBLE, "%a0".into())], + ); + wrapper.create_block("entry").ret(DOUBLE, "%a0"); + + let init = m.define_function("m__init_body", VOID, vec![]); + let entry = init.create_block("entry"); + entry.call( + I64, + "js_closure_alloc_singleton", + &[(PTR, &format!("@{wrapper_name}"))], + ); + entry.ret_void(); + + let units = m.render_codegen_units(2); + let init_unit = units + .iter() + .find(|unit| unit.contains("define void @m__init_body(")) + .expect("init unit"); + assert!(!init_unit.contains(&format!("define double @{wrapper_name}("))); + assert!(init_unit.contains(&format!("declare double @{wrapper_name}(i64, double)"))); + } + #[test] fn string_constant_escapes_nonprintable() { let mut m = LlModule::new("arm64-apple-macosx15.0.0"); diff --git a/crates/perry-codegen/src/native_emit.rs b/crates/perry-codegen/src/native_emit.rs index 8edeb59bfa..85932894f8 100644 --- a/crates/perry-codegen/src/native_emit.rs +++ b/crates/perry-codegen/src/native_emit.rs @@ -169,7 +169,7 @@ struct FrozenUnit { function_count: usize, } -/// Apply a typed post-RS4GC budget request to the lowering-owned functions +/// Apply a typed pre- or 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. @@ -187,17 +187,37 @@ pub(crate) fn apply_budget_spill_retry<'a>( }; 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, - ); + match &violation.cause { + crate::inprocess::Rs4gcBudgetCause::PreRewrite { + root_allocas, + safepoints, + estimated_relocations, + } => eprintln!( + "perry: `{}` exceeded the pre-RS4GC relocation estimate ({} managed-root \ + allocas + {} non-leaf call-result temporaries across {} call sites = {} \ + estimated relocations; cap {}); retrying it with precise GC roots in a \ + shadow frame at the requested optimization level (#8583)", + violation.name, + root_allocas, + safepoints, + safepoints, + estimated_relocations, + violation.cap, + ), + crate::inprocess::Rs4gcBudgetCause::PostRewrite { post_instructions } => { + 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()), + post_instructions, + violation.cap, + ); + } + } } } let missing: Vec<&str> = violations @@ -209,7 +229,7 @@ pub(crate) fn apply_budget_spill_retry<'a>( Ok(()) } else { Err(anyhow!( - "post-RS4GC budget requested a shadow-frame retry for {}, but those \ + "RS4GC budget requested a shadow-frame retry for {}, but those \ functions were not available for a new lowering (or were already retried)", missing.join(", ") )) @@ -421,7 +441,7 @@ pub fn compile_module_units_native( let target_triple = llmod.target_triple.clone(); let owned_module = std::mem::replace(llmod, LlModule::new(target_triple)); // 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 + // are frozen. A pre- or 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 @@ -671,6 +691,14 @@ pub fn compile_module_units_native( } } } else { + if show_progress { + eprintln!( + "[perry] codegen: {module_prefix}: LLVM unit {}/{} failed after {:.1}s: {error:#}", + i + 1, + unit_total, + attempt_elapsed.as_secs_f64() + ); + } slots[i] = Some(out); } } else { diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index 4644c57f92..f50b7463a6 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -7346,6 +7346,93 @@ fn abrupt_captured_local_assignment_does_not_emit_orphan_write_barrier() { ); } +#[test] +fn abrupt_constructor_argument_stops_anonymous_object_construction() { + // Closed object literals are `new __AnonShape_*(field0, field1, ...)` by + // the time codegen sees them. Claude Code returns an object whose first + // field constructs an unresolved dynamic Worker and whose second field + // constructs an Int32Array. The Worker emits throw + unreachable, so the + // later field, allocation and constructor diamond must not be emitted: + // their definitions would be dropped from the terminated block while the + // newly-created blocks still used their registers. + let mut record = class( + 80, + "__AnonShape_abrupt_constructor_arg", + vec![ + class_field("worker", Type::Any), + class_field("stamp", Type::Any), + ], + ); + record.constructor = Some(Function { + id: 81, + name: "__AnonShape_abrupt_constructor_arg_constructor".to_string(), + type_params: Vec::new(), + params: vec![ + param(82, "worker", Type::Any), + param(83, "stamp", Type::Any), + ], + return_type: Type::Any, + body: vec![ + Stmt::Expr(Expr::PropertySet { + object: Box::new(Expr::This), + property: "worker".to_string(), + value: Box::new(local(82)), + }), + Stmt::Expr(Expr::PropertySet { + object: Box::new(Expr::This), + property: "stamp".to_string(), + value: Box::new(local(83)), + }), + ], + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }); + let module = module_with_classes_and_params( + "abrupt_anonymous_object_constructor_arg.ts", + vec![record], + vec![param(99, "filename", Type::Any)], + Type::Any, + vec![Stmt::Return(Some(Expr::New { + class_name: "__AnonShape_abrupt_constructor_arg".to_string(), + args: vec![ + Expr::WorkerNew { + paths: Vec::new(), + filename: Box::new(local(99)), + options: None, + is_eval: false, + }, + Expr::Array(Vec::new()), + ], + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + }))], + ); + let ir = String::from_utf8(compile_module(&module, empty_opts()).unwrap()).unwrap(); + let body = probe_body(&ir); + let throw = body + .find("call void @js_throw_error_with_code") + .expect("unresolved Worker construction should emit its runtime throw"); + let after_throw = &body[throw..]; + + assert!( + after_throw.contains("\n unreachable"), + "the dynamic Worker fallback must terminate the path:\n{after_throw}" + ); + assert!( + !after_throw.contains("js_array_alloc") + && !after_throw.contains("js_object_alloc") + && !after_throw.contains("ctor_prologue"), + "nothing after an abruptly-completing constructor argument may be lowered:\n{after_throw}" + ); +} + fn boxed_param_capture_module(name: &str) -> Module { module_with_classes_and_params( name, diff --git a/crates/perry-hir/src/lower/expr_call/intrinsics.rs b/crates/perry-hir/src/lower/expr_call/intrinsics.rs index c3d84758c6..617833c7ac 100644 --- a/crates/perry-hir/src/lower/expr_call/intrinsics.rs +++ b/crates/perry-hir/src/lower/expr_call/intrinsics.rs @@ -41,4 +41,4 @@ pub(super) use native_arena::{ }; pub(super) use native_scalars::validate_native_scalar_conversion_call; pub(super) use precompile_wasm::{try_embed_wasm, try_precompile}; -pub(super) use require::{try_dynamic_require, try_require_literal}; +pub(super) use require::{try_dynamic_require, try_import_meta_require, try_require_literal}; diff --git a/crates/perry-hir/src/lower/expr_call/intrinsics/require.rs b/crates/perry-hir/src/lower/expr_call/intrinsics/require.rs index 2e17563f2e..43b1e46e29 100644 --- a/crates/perry-hir/src/lower/expr_call/intrinsics/require.rs +++ b/crates/perry-hir/src/lower/expr_call/intrinsics/require.rs @@ -5,6 +5,54 @@ use swc_ecma_ast as ast; use super::super::super::{lower_expr, LoweringContext}; +/// Bun's module-scoped synchronous loader, `import.meta.require(specifier)`. +/// +/// Perry already represents a computed CommonJS `require(expr)` as a +/// synchronous `DynamicImport`: the module collector resolves its finite path +/// set and codegen returns the selected namespace directly instead of wrapping +/// it in a Promise. Reuse that path here. Treating `require` as an ordinary +/// unknown `import.meta` property would otherwise fold the callee to +/// `undefined` in `expr_member` and compile an unconditional +/// `TypeError: value is not a function`. +pub(crate) fn try_import_meta_require( + ctx: &mut LoweringContext, + call: &ast::CallExpr, +) -> Result> { + let ast::Callee::Expr(callee_expr) = &call.callee else { + return Ok(None); + }; + let ast::Expr::Member(member) = callee_expr.as_ref() else { + return Ok(None); + }; + let is_require = match &member.prop { + ast::MemberProp::Ident(property) => property.sym.as_ref() == "require", + ast::MemberProp::Computed(property) => matches!( + property.expr.as_ref(), + ast::Expr::Lit(ast::Lit::Str(value)) if value.value.as_str() == Some("require") + ), + ast::MemberProp::PrivateName(_) => false, + }; + if !is_require + || !matches!( + member.obj.as_ref(), + ast::Expr::MetaProp(meta) if meta.kind == ast::MetaPropKind::ImportMeta + ) + || call.args.len() != 1 + || call.args[0].spread.is_some() + { + return Ok(None); + } + + let arg = lower_expr(ctx, call.args[0].expr.as_ref())?; + Ok(Some(Expr::DynamicImport { + paths: Vec::new(), + arg: Box::new(arg), + byte_offset: call.span.lo.0, + deferred_error: None, + synchronous: true, + })) +} + /// Issue #668 / #5216: a string-literal `require("")` from user source. /// /// When `` statically resolves to a Perry-supported native/Node-builtin diff --git a/crates/perry-hir/src/lower/expr_call/mod.rs b/crates/perry-hir/src/lower/expr_call/mod.rs index d18f494dbf..752b4a4d98 100644 --- a/crates/perry-hir/src/lower/expr_call/mod.rs +++ b/crates/perry-hir/src/lower/expr_call/mod.rs @@ -87,10 +87,11 @@ use inline_array_methods::try_inline_array_methods; use intrinsics::{ check_eval_function_call, try_bare_regexp_call, try_builtin_prototype_method_apply_call, try_dynamic_require, try_embed_wasm, try_function_return_this, try_iife_call_rewrite, - try_iterator_from, try_namespace_static_method_apply_call_bind, try_native_arena_intrinsics, - try_native_arena_public_api, try_native_memory_public_api, try_native_module_method_apply_call, - try_pod_layout_constants, try_precompile, try_require_literal, - try_strict_eval_arguments_assignment, validate_native_scalar_conversion_call, + try_import_meta_require, try_iterator_from, try_namespace_static_method_apply_call_bind, + try_native_arena_intrinsics, try_native_arena_public_api, try_native_memory_public_api, + try_native_module_method_apply_call, try_pod_layout_constants, try_precompile, + try_require_literal, try_strict_eval_arguments_assignment, + validate_native_scalar_conversion_call, }; use local_array_methods::try_local_array_methods; use module_class_static::try_module_class_static; @@ -224,6 +225,9 @@ fn lower_call_inner(ctx: &mut LoweringContext, call: &ast::CallExpr) -> Result bool { !has_runtime_value } +/// Wrapper symbol used when a dynamic-import namespace materializes a local +/// function as a JavaScript value. Keep this routed through codegen's own +/// function mangler: module-name sanitization is intentionally not injective +/// for `$` and would point `$a` at `_a`'s symbol instead. +fn namespace_local_function_wrapper_symbol(module_name: &str, function_name: &str) -> String { + format!( + "__perry_wrap_{}", + perry_codegen::user_function_symbol(module_name, function_name) + ) +} + +#[cfg(test)] +mod namespace_local_function_symbol_tests { + use super::namespace_local_function_wrapper_symbol; + + #[test] + fn uses_injective_function_component_for_dynamic_namespace_entries() { + let dollar = namespace_local_function_wrapper_symbol("chunk.js", "$a"); + let underscore = namespace_local_function_wrapper_symbol("chunk.js", "_a"); + + assert_eq!(dollar, "__perry_wrap_perry_fn_chunk_js__u__24_a"); + assert_eq!(underscore, "__perry_wrap_perry_fn_chunk_js___a"); + assert_ne!(dollar, underscore); + } +} + // OpenCode's 0.5--1.0 MiB generated chunks routinely lower to 20--45 MiB of // LLVM input even with fewer than 1,000 HIR callables. Treat that observed // range as memory-heavy too: ordinary modules still use outer parallelism, @@ -2761,13 +2787,11 @@ pub fn run_with_parse_cache( .iter() .find(|f| f.name == fe.source_local) { - let scoped = format!( - "perry_fn_{}__{}", - sanitize_module_name(&target_hir.name), - sanitize_module_name(&func.name) - ); perry_codegen::NamespaceEntryKind::LocalFunction { - wrap_symbol: format!("__perry_wrap_{}", scoped), + wrap_symbol: namespace_local_function_wrapper_symbol( + &target_hir.name, + &func.name, + ), } } else if let Some(class) = target_hir .classes @@ -2802,13 +2826,11 @@ pub fn run_with_parse_cache( // ran during init → "Cannot read properties of undefined // (reading 'checks')"). Resolve to the ORIGIN function's // closure singleton instead, matching plain declarations. - let scoped = format!( - "perry_fn_{}__{}", - sanitize_module_name(&target_hir.name), - sanitize_module_name(&func.name) - ); perry_codegen::NamespaceEntryKind::LocalFunction { - wrap_symbol: format!("__perry_wrap_{}", scoped), + wrap_symbol: namespace_local_function_wrapper_symbol( + &target_hir.name, + &func.name, + ), } } else { // Best-effort: treat unknown locals as Var sourced From 454daac4f8fc667ab4bc85b7b5b36c8bae56ae28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 4 Sep 2026 22:34:36 +0200 Subject: [PATCH 2/2] feat(codegen): allow an explicit application LLVM opt level --- crates/perry-codegen/src/linker.rs | 35 +++++++++++++++---- crates/perry-codegen/src/linker_tests.rs | 17 +++++++++ .../perry/src/commands/compile/build_cache.rs | 3 ++ .../src/commands/compile/object_cache.rs | 4 +++ .../object_cache/object_cache_tests.rs | 1 + 5 files changed, 54 insertions(+), 6 deletions(-) diff --git a/crates/perry-codegen/src/linker.rs b/crates/perry-codegen/src/linker.rs index ff6804db34..a1dbc6b192 100644 --- a/crates/perry-codegen/src/linker.rs +++ b/crates/perry-codegen/src/linker.rs @@ -342,6 +342,31 @@ fn size_optimization_requested(value: Option<&str>) -> bool { } } +/// Select the LLVM optimization level for generated application modules. +/// +/// Normal builds retain Perry's measured `-Os` default (or the existing +/// `PERRY_LL_SIZE_OPT=0` opt-out to `-O3`). `PERRY_LL_OPT_LEVEL` is an explicit +/// diagnostic/build-through override for dependency bundles whose generated +/// functions are too large for a useful optimized build. It accepts the same +/// level spellings as clang and deliberately leaves an unrecognized value on +/// the normal default instead of silently disabling optimization. +fn application_opt_flag(explicit: Option<&str>, size_opt: Option<&str>) -> &'static str { + match explicit + .map(str::trim) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("0" | "o0") => "-O0", + Some("1" | "o1") => "-O1", + Some("2" | "o2") => "-O2", + Some("3" | "o3") => "-O3", + Some("s" | "os") => "-Os", + Some("z" | "oz") => "-Oz", + _ if size_optimization_requested(size_opt) => "-Os", + _ => "-O3", + } +} + fn build_clang_compile_plan( clang: PathBuf, ll_path: PathBuf, @@ -361,13 +386,11 @@ fn build_clang_compile_plan( // Perry defaults to SIZE-optimized native output: `-Os` measured no runtime // cost on the benchmark corpus (see `size_optimization_requested`), and it // materially shrinks dense generated bundles. `PERRY_LL_SIZE_OPT=0` restores - // `-O3`. There is no module-size-driven policy change. + // `-O3`; the explicit `PERRY_LL_OPT_LEVEL` override wins over both. There is + // no implicit module-size-driven policy change. let size_opt = env::var("PERRY_LL_SIZE_OPT").ok(); - let opt_flag = if size_optimization_requested(size_opt.as_deref()) { - "-Os" - } else { - "-O3" - }; + let explicit_opt = env::var("PERRY_LL_OPT_LEVEL").ok(); + let opt_flag = application_opt_flag(explicit_opt.as_deref(), size_opt.as_deref()); // Compacting the stack map means going through assembly, because that is // where LLVM prints the map's function addresses as symbol *names* — the diff --git a/crates/perry-codegen/src/linker_tests.rs b/crates/perry-codegen/src/linker_tests.rs index dfbd0a6209..06500f0201 100644 --- a/crates/perry-codegen/src/linker_tests.rs +++ b/crates/perry-codegen/src/linker_tests.rs @@ -223,6 +223,23 @@ fn size_optimization_is_on_unless_explicitly_disabled() { } } +#[test] +fn explicit_application_opt_level_overrides_the_size_default() { + for (spelling, expected) in [ + ("0", "-O0"), + ("o0", "-O0"), + ("1", "-O1"), + ("O2", "-O2"), + ("3", "-O3"), + ("s", "-Os"), + ("Oz", "-Oz"), + ] { + assert_eq!(application_opt_flag(Some(spelling), None), expected); + } + assert_eq!(application_opt_flag(Some("unknown"), None), "-Os"); + assert_eq!(application_opt_flag(Some("unknown"), Some("0")), "-O3"); +} + #[test] fn compile_plan_skips_native_tuning_for_explicit_target() { let plan = build_clang_compile_plan( diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index 6d7ec00dd3..384d2a88ee 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -45,6 +45,9 @@ const BUILD_CACHE_ENV_VARS: &[&str] = &[ "PERRY_RS4GC", // `-Os` vs `-O3` for every native module. "PERRY_LL_SIZE_OPT", + // Explicit application-module LLVM optimization level. This overrides the + // normal `PERRY_LL_SIZE_OPT` selection and changes every emitted object. + "PERRY_LL_OPT_LEVEL", // 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. diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index e3a1c480dc..03732e0ac6 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -1021,6 +1021,10 @@ fn compute_object_cache_key_with_env( "env_ll_size_opt", env_var("PERRY_LL_SIZE_OPT").as_deref().unwrap_or(""), ); + h.field( + "env_ll_opt_level", + env_var("PERRY_LL_OPT_LEVEL").as_deref().unwrap_or(""), + ); // #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( 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 58ffbaf1c6..afee7042af 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 @@ -731,6 +731,7 @@ fn key_changes_with_codegen_env_vars() { "PERRY_SHADOW_STACK", "PERRY_RS4GC", "PERRY_LL_SIZE_OPT", + "PERRY_LL_OPT_LEVEL", "PERRY_LL_RS4GC_MAX_INSTRS", "PERRY_LL_TRE_MAX_ALLOCA_WALK", "PERRY_LL_FAST_EMIT_MAX_INSTRS",