diff --git a/changelog.d/8595-entry-outline-default.md b/changelog.d/8595-entry-outline-default.md new file mode 100644 index 0000000000..b332addeae --- /dev/null +++ b/changelog.d/8595-entry-outline-default.md @@ -0,0 +1 @@ +perf(codegen): finish structured module-entry outlining (#8595). Entry bodies with at least 1,000 top-level HIR statements or 4,000 estimated safepoints now split automatically into ordered functions capped at roughly 200 statements or 1,000 safepoints (`PERRY_OUTLINE_ENTRY=0` disables and `=1` forces the transform). Original declarations move unchanged, bindings that need cross-function storage are promoted to rooted module globals, and declaration/export/const/static-field/early-`process.env` scans reconstruct the logical source-order entry stream. Exports, Script `globalThis` reflection, and structured control flow are supported; top-level await and module-level TDZ preallocation remain fail-safe exclusions. This bounds per-function RS4GC fan-out, instruction selection, optimization, and register allocation without changing the requested optimization level. diff --git a/crates/perry-codegen/src/codegen/artifacts.rs b/crates/perry-codegen/src/codegen/artifacts.rs index a4d301c8df..550b0bad71 100644 --- a/crates/perry-codegen/src/codegen/artifacts.rs +++ b/crates/perry-codegen/src/codegen/artifacts.rs @@ -1831,7 +1831,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { // name (`const bar = function namedBar(){}` ⇒ `"namedBar"`). let mut named_inline_closure_ids: std::collections::HashSet = std::collections::HashSet::new(); - for stmt in &hir.init { + for stmt in super::entry_outline::logical_entry_stmts(hir) { if let perry_hir::Stmt::Let { name, init, .. } = stmt { if name.is_empty() || name.starts_with('_') { continue; diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index df23478c13..d478aae49b 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -122,7 +122,7 @@ fn emit_plugin_abi_shim(llmod: &mut LlModule, hir: &HirModule, module_prefix: &s /// (function(){ ... })()`), which is where the wrapped entry's top-level /// statements live. Assignments nested in conditionals or inner functions are /// deliberately skipped — those run conditionally/lazily, exactly as in Node. -fn collect_entry_env_literals(init: &[perry_hir::Stmt]) -> Vec<(String, String)> { +fn collect_entry_env_literals(hir: &HirModule) -> Vec<(String, String)> { use perry_hir::{Expr, Stmt}; fn record(expr: &Expr, out: &mut Vec<(String, String)>) { @@ -176,7 +176,9 @@ fn collect_entry_env_literals(init: &[perry_hir::Stmt]) -> Vec<(String, String)> } let mut out = Vec::new(); - scan(init, &mut out, 0); + for stmt in super::entry_outline::logical_entry_stmts(hir) { + scan(std::slice::from_ref(stmt), &mut out, 0); + } out } @@ -620,7 +622,7 @@ pub(super) fn compile_module_entry( // `collect_entry_env_literals`. The "NODE_ENV"/"production" string // handles are interned here and populated by the strings-init call // above (the entry body also references them, so they share slots). - for (name, value) in collect_entry_env_literals(&hir.init) { + for (name, value) in collect_entry_env_literals(hir) { let name_idx = strings.intern(&name); let value_idx = strings.intern(&value); let name_global = format!("@{}", strings.entry(name_idx).handle_global); diff --git a/crates/perry-codegen/src/codegen/entry_outline.rs b/crates/perry-codegen/src/codegen/entry_outline.rs index ac7aa8a42c..8d7e5279e0 100644 --- a/crates/perry-codegen/src/codegen/entry_outline.rs +++ b/crates/perry-codegen/src/codegen/entry_outline.rs @@ -1,4 +1,4 @@ -//! Module-entry outlining — analysis + gate (#8595, first increment). +//! Structured module-entry outlining (#8595). //! //! The module top level is lowered into a single LLVM function (`@main` / //! `perry_module_init`). For a large minified bundle that one function is @@ -7,18 +7,17 @@ //! (relocation fan-out, #8583), instruction selection (#4880), and register //! allocation. The fix is to outline the entry body into many small functions. //! -//! This module is the **analysis half only** — it computes how the entry body -//! WOULD chunk and which top-level `let`s cross a chunk boundary (and therefore -//! must be globalized so the chunks can share them), and reports it. It does -//! **not** transform anything yet: the transform is the correctness-critical -//! part (eval order, TDZ, hoisting, top-level await) and lands separately once -//! it can be validated end-to-end. The reusable pieces here — the chunk -//! boundary rule and the cross-chunk reference set — are exactly what that -//! transform will consume to decide chunk boundaries and drive globalization. +//! Oversized entry bodies are split at top-level statement boundaries into +//! ordinary HIR functions. The original statements move unchanged, and calls +//! to the chunks remain in the original order. Codegen's module-global pass +//! recognises declarations in these compiler-owned chunks as module bindings, +//! so a declaration still executes at its source position while references +//! from another chunk share the same rooted storage. //! -//! Nothing here changes codegen output. `PERRY_OUTLINE_ENTRY_REPORT=1` prints -//! the analysis; the transform gate `PERRY_OUTLINE_ENTRY` exists but is inert -//! until the transform lands. +//! Outlining is automatic only for very large bodies. `PERRY_OUTLINE_ENTRY=1` +//! forces it for testing and measurement; `=0` disables it. Top-level await +//! and a module-level TDZ preallocation remain fail-safe exclusions because a +//! raw module-global load cannot yet perform the checked TDZ-box read. use std::collections::HashSet; @@ -28,10 +27,26 @@ use crate::collectors::{collect_let_ids, collect_ref_ids_in_stmts}; /// Default target number of top-level statements per outlined chunk. Chosen so /// a chunk's live-root × safepoint product stays well under the RS4GC fan-out -/// regime (#8583); tuned with the transform, so it is only a reporting knob -/// today. Overridable with `PERRY_OUTLINE_ENTRY_CHUNK_STMTS`. +/// regime (#8583). The independent safepoint budget below can flush sooner. +/// Overridable with `PERRY_OUTLINE_ENTRY_CHUNK_STMTS`. const DEFAULT_CHUNK_STMTS: usize = 200; +/// Ordinary modules are deliberately left byte-for-byte unchanged. The +/// production pathology has tens of thousands of top-level HIR statements; +/// 1,000 is low enough to catch it while keeping normal source modules out. +const DEFAULT_AUTO_MIN_STMTS: usize = 1_000; + +/// Call-like expressions are the dominant source of pointer temporaries and +/// statepoints. A generated entry with fewer top-level statements can still be +/// pathological, so both automatic admission and chunk flushing have a +/// safepoint budget. +const DEFAULT_CHUNK_SAFEPOINTS: usize = 1_000; +const DEFAULT_AUTO_MIN_SAFEPOINTS: usize = 4_000; + +/// Compiler-owned name prefix used to distinguish outlined entry functions +/// from source functions when reconstructing the logical top-level stream. +const ENTRY_CHUNK_PREFIX: &str = "__perry_entry_chunk_"; + fn target_chunk_stmts() -> usize { std::env::var("PERRY_OUTLINE_ENTRY_CHUNK_STMTS") .ok() @@ -40,14 +55,28 @@ fn target_chunk_stmts() -> usize { .unwrap_or(DEFAULT_CHUNK_STMTS) } -/// Whether the entry-outlining TRANSFORM is enabled. Inert in this increment -/// (no transform exists yet); present so the transform can gate on it without a -/// second flag churn. `PERRY_OUTLINE_ENTRY=1`/`on`/`true` turns it on. -pub(crate) fn entry_outlining_enabled() -> bool { - matches!( - std::env::var("PERRY_OUTLINE_ENTRY").as_deref(), - Ok("1") | Ok("on") | Ok("true") - ) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OutlineMode { + Auto, + Forced, + Disabled, +} + +fn outline_mode_from_env(value: Option<&str>) -> OutlineMode { + match value { + Some("1" | "on" | "true") => OutlineMode::Forced, + Some("0" | "off" | "false") => OutlineMode::Disabled, + _ => OutlineMode::Auto, + } +} + +fn outline_mode() -> OutlineMode { + let value = std::env::var("PERRY_OUTLINE_ENTRY").ok(); + outline_mode_from_env(value.as_deref()) +} + +fn meets_automatic_size_threshold(stmt_count: usize, safepoint_count: usize) -> bool { + stmt_count >= DEFAULT_AUTO_MIN_STMTS || safepoint_count >= DEFAULT_AUTO_MIN_SAFEPOINTS } fn report_requested() -> bool { @@ -83,6 +112,144 @@ impl EntryOutlineAnalysis { } } +pub(crate) fn is_entry_chunk(function: &perry_hir::Function) -> bool { + function.name.starts_with(ENTRY_CHUNK_PREFIX) + && function.params.is_empty() + && matches!(function.return_type, perry_hir::types::Type::Void) + && !function.is_async + && !function.is_generator + && !function.is_exported +} + +/// Reconstruct the source-order module-entry statement stream after outlining. +/// +/// Several codegen analyses intentionally inspect module declarations rather +/// than ordinary function bodies (exported closure signatures, const folding, +/// static-field deduplication, and early `process.env` assignments). Replacing +/// a range with a chunk call must not hide those original statements from the +/// analyses. Non-chunk calls and all inline statements are returned unchanged. +pub fn logical_entry_stmts(hir: &HirModule) -> Vec<&perry_hir::Stmt> { + let chunks: std::collections::HashMap = hir + .functions + .iter() + .filter(|function| is_entry_chunk(function)) + .map(|function| (function.id, function)) + .collect(); + let mut logical = Vec::new(); + for stmt in &hir.init { + let chunk = match stmt { + perry_hir::Stmt::Expr(perry_hir::Expr::Call { callee, args, .. }) + if args.is_empty() => + { + match callee.as_ref() { + perry_hir::Expr::FuncRef(id) => chunks.get(id).copied(), + _ => None, + } + } + _ => None, + }; + if let Some(chunk) = chunk { + logical.extend(chunk.body.iter()); + } else { + logical.push(stmt); + } + } + logical +} + +/// Moved declarations whose storage crosses a generated-function boundary. +/// +/// A declaration used only inside its defining chunk remains a cheap local. +/// References from another chunk or an inline entry statement require a rooted +/// module global. Re-declarations split across chunks share storage too. +/// Module-level preallocated boxes are also promoted: the prealloc statement +/// remains in `hir.init`, so a function-local box would otherwise be a +/// different cell from the declaration moved into the chunk. +pub(crate) fn outlined_entry_global_let_ids(hir: &HirModule) -> HashSet { + let chunks: Vec<&perry_hir::Function> = hir + .functions + .iter() + .filter(|function| is_entry_chunk(function)) + .collect(); + let mut definer: std::collections::HashMap = std::collections::HashMap::new(); + let mut globals = HashSet::new(); + + // Keep this in lock-step with `module_globals_emit::collect_init_lets`: + // destructuring declarations can be wrapped in iterator-cleanup `Try` + // scaffolding while still representing module bindings. + fn record_definers( + stmts: &[perry_hir::Stmt], + function_id: u32, + definer: &mut std::collections::HashMap, + globals: &mut HashSet, + ) { + for stmt in stmts { + match stmt { + perry_hir::Stmt::Let { id, .. } => { + if definer + .insert(*id, function_id) + .is_some_and(|prior| prior != function_id) + { + globals.insert(*id); + } + } + perry_hir::Stmt::Try { + body, + catch, + finally, + } => { + record_definers(body, function_id, definer, globals); + if let Some(catch) = catch { + record_definers(&catch.body, function_id, definer, globals); + } + if let Some(finally) = finally { + record_definers(finally, function_id, definer, globals); + } + } + _ => {} + } + } + } + + for function in &chunks { + record_definers(&function.body, function.id, &mut definer, &mut globals); + } + + for function in &chunks { + let mut refs = HashSet::new(); + collect_ref_ids_in_stmts(&function.body, &mut refs); + for id in refs { + if definer + .get(&id) + .is_some_and(|defining_function| *defining_function != function.id) + { + globals.insert(id); + } + } + } + + let chunk_ids: HashSet = chunks.iter().map(|function| function.id).collect(); + for stmt in &hir.init { + match stmt { + perry_hir::Stmt::PreallocateBoxes(ids) => { + globals.extend(ids.iter().filter(|id| definer.contains_key(id)).copied()); + } + perry_hir::Stmt::Expr(perry_hir::Expr::Call { callee, args, .. }) + if args.is_empty() + && matches!(callee.as_ref(), perry_hir::Expr::FuncRef(id) if chunk_ids.contains(id)) => + { + // The compiler-owned call itself carries no module-local use. + } + _ => { + let mut refs = HashSet::new(); + collect_ref_ids_in_stmts(std::slice::from_ref(stmt), &mut refs); + globals.extend(refs.into_iter().filter(|id| definer.contains_key(id))); + } + } + } + globals +} + /// Chunk the top-level statement list into contiguous ranges of /// `target`-ish statements. Boundaries fall ONLY between top-level statements, /// never inside a compound statement, so a top-level `if`/`for`/`try` (and all @@ -92,10 +259,11 @@ fn chunk_ranges(total: usize, target: usize) -> Vec<(usize, usize)> { if total == 0 { return Vec::new(); } + let target = target.max(1); let mut ranges = Vec::new(); let mut start = 0; while start < total { - let end = (start + target).min(total); + let end = start.saturating_add(target).min(total); ranges.push((start, end)); start = end; } @@ -113,14 +281,19 @@ fn analyze_entry_outlining_with_target(hir: &HirModule, target: usize) -> EntryO let stmts = &hir.init; let total_stmts = stmts.len(); let ranges = chunk_ranges(total_stmts, target); - let chunk_count = ranges.len(); + let chunk_count = count_prospective_chunks(stmts, target); - // A top-level await splits the init across an async suspension; chunking - // across it is a distinct, harder transform, so such bodies are gated out - // initially. (Other gates — Script-scope `this`, generators — are added - // alongside the transform that needs them.) + // A top-level await splits init across an async suspension. A module-level + // TDZ preallocation needs checked global loads, which module globals do not + // provide yet. Both cases stay on the original lowering rather than + // accepting a semantic approximation. let gated_out = if hir.has_top_level_await { Some("top-level await") + } else if stmts + .iter() + .any(|stmt| matches!(stmt, perry_hir::Stmt::PreallocateTdzBoxes(_))) + { + Some("module-level TDZ preallocation") } else { None }; @@ -174,13 +347,11 @@ pub(crate) fn report_entry_outlining(hir: &HirModule) { return; } let a = analyze_entry_outlining(hir); - // When the transform is enabled it runs earlier (in the HIR phase, see - // `outline_entry_module`), so by the time codegen calls this the body is - // already rewritten — the numbers below then describe the post-transform - // init (hoisted declarations + chunk calls). With the transform off, they - // describe the original body, which is the useful measurement. - let transform = if entry_outlining_enabled() { - " (PERRY_OUTLINE_ENTRY set — transform already applied; figures are post-transform)" + // The transform runs in the HIR pipeline before codegen. Report clearly + // when these figures describe the compact call stream rather than source + // top-level statements. + let transform = if hir.functions.iter().any(is_entry_chunk) { + " (already outlined; figures describe the chunk-call stream)" } else { "" }; @@ -211,24 +382,33 @@ pub enum OutlineOutcome { Skipped(&'static str), } -/// Largest `FuncId` used anywhere in `hir` — over `functions`, -/// `script_global_functions`, `exported_functions`, and every nested closure -/// `func_id` in a top-level or function body. New chunk ids are minted strictly -/// above this so they can never collide with an existing function or closure. +/// Largest `FuncId` used anywhere in `hir`. New chunk ids are minted strictly +/// above it so generated functions cannot collide with a class member, nested +/// closure, or an id retained only in module metadata. fn max_func_id(hir: &HirModule) -> u32 { let mut max = 0u32; - for f in &hir.functions { - max = max.max(f.id); - } for (_, id) in &hir.script_global_functions { max = max.max(*id); } for (_, id) in &hir.exported_functions { max = max.max(*id); } - // Nested closures carry their own `func_id`; a new chunk id must clear - // those too. `collect_closures_in_stmts` walks stmts + exprs and yields - // every closure id — run it over the init body and every function body. + for id in hir + .async_step_closures + .iter() + .chain(hir.async_generator_funcs.iter()) + { + max = max.max(*id); + } + for id in hir + .closure_display_names + .keys() + .chain(hir.closure_source_text.keys()) + .chain(hir.gen_param_prologue_len.keys()) + { + max = max.max(*id); + } + let collect_max_closure = |stmts: &[perry_hir::Stmt], max: &mut u32| { let mut seen = std::collections::HashSet::new(); let mut out: Vec<(perry_hir::types::FuncId, perry_hir::Expr)> = Vec::new(); @@ -237,55 +417,155 @@ fn max_func_id(hir: &HirModule) -> u32 { *max = (*max).max(id); } }; + let collect_max_expr = |expr: &perry_hir::Expr, max: &mut u32| { + let mut seen = std::collections::HashSet::new(); + let mut out: Vec<(perry_hir::types::FuncId, perry_hir::Expr)> = Vec::new(); + crate::collectors::collect_closures_in_expr(expr, &mut seen, &mut out); + for (id, _) in out { + *max = (*max).max(id); + } + }; + let collect_function = |function: &perry_hir::Function, max: &mut u32| { + *max = (*max).max(function.id); + collect_max_closure(&function.body, max); + for param in &function.params { + if let Some(default) = ¶m.default { + collect_max_expr(default, max); + } + for decorator in ¶m.decorators { + for arg in &decorator.args { + collect_max_expr(arg, max); + } + } + } + for decorator in &function.decorators { + for arg in &decorator.args { + collect_max_expr(arg, max); + } + } + }; + collect_max_closure(&hir.init, &mut max); for f in &hir.functions { - collect_max_closure(&f.body, &mut max); + collect_function(f, &mut max); + } + for class in &hir.classes { + if let Some(constructor) = &class.constructor { + collect_function(constructor, &mut max); + } + for function in class + .methods + .iter() + .chain(class.static_methods.iter()) + .chain(class.getters.iter().map(|(_, function)| function)) + .chain(class.setters.iter().map(|(_, function)| function)) + .chain(class.computed_members.iter().map(|member| &member.function)) + { + collect_function(function, &mut max); + } + for member in &class.computed_members { + collect_max_expr(&member.key_expr, &mut max); + } + if let Some(expr) = &class.extends_expr { + collect_max_expr(expr, &mut max); + } + for field in class.fields.iter().chain(class.static_fields.iter()) { + if let Some(expr) = &field.key_expr { + collect_max_expr(expr, &mut max); + } + if let Some(expr) = &field.init { + collect_max_expr(expr, &mut max); + } + for decorator in &field.decorators { + for arg in &decorator.args { + collect_max_expr(arg, &mut max); + } + } + } + for decorator in &class.decorators { + for arg in &decorator.args { + collect_max_expr(arg, &mut max); + } + } + } + for global in &hir.globals { + if let Some(expr) = &global.init { + collect_max_expr(expr, &mut max); + } } max } /// A top-level statement the transform can safely relocate into a chunk -/// function without changing semantics or breaking an `hir.init` scan. -/// -/// Deliberately narrow for this first increment: a plain expression statement, -/// or a `let`/`const` binding a SINGLE local to an initializer (split into a -/// hoisted bare declaration plus a `LocalSet` in the chunk). Anything else — -/// destructuring, `var`, top-level control flow, class/enum/import decls — makes -/// the whole body ineligible (the transform bails and the entry compiles -/// unchanged). Extending this set (and the `hir.init` scans that must follow -/// statements into chunks) is the follow-up that reaches real bundles. +/// function without changing which function an abrupt `return` completes. +/// Structured control flow moves as one indivisible statement. A statement +/// containing `return` remains inline; `break`/`continue` stay within the same +/// compound statement and therefore retain their target. fn classify_top_level(stmt: &perry_hir::Stmt) -> Option { use perry_hir::Stmt; match stmt { - Stmt::Expr(_) => Some(TopLevelKind::Expr), - Stmt::Let { - id, init: Some(_), .. - } => Some(TopLevelKind::SimpleLet(*id)), - Stmt::Let { init: None, .. } => Some(TopLevelKind::BareLet), + Stmt::Let { .. } | Stmt::Expr(_) | Stmt::Throw(_) => Some(TopLevelKind::Relocatable), + Stmt::If { .. } + | Stmt::While { .. } + | Stmt::DoWhile { .. } + | Stmt::For { .. } + | Stmt::Labeled { .. } + | Stmt::Try { .. } + | Stmt::Switch { .. } + if !stmt_contains_return(stmt) => + { + Some(TopLevelKind::Relocatable) + } _ => None, } } enum TopLevelKind { - Expr, - SimpleLet(u32), - BareLet, + Relocatable, } -/// Module features whose codegen scans read `hir.init` directly and would -/// therefore miss statements relocated into chunks. Until each such scan is -/// taught to follow chunk calls, a module with any of them is ineligible. -fn has_init_scan_coupling(hir: &HirModule) -> Option<&'static str> { - if !hir.exports.is_empty() || !hir.exported_functions.is_empty() { - return Some("module has exports"); - } - if !hir.script_global_functions.is_empty() { - return Some("script-global function hoisting"); - } - if hir.references_global_this { - return Some("references globalThis"); +fn stmt_contains_return(stmt: &perry_hir::Stmt) -> bool { + use perry_hir::Stmt; + match stmt { + Stmt::Return(_) => true, + Stmt::If { + then_branch, + else_branch, + .. + } => { + then_branch.iter().any(stmt_contains_return) + || else_branch + .as_ref() + .is_some_and(|body| body.iter().any(stmt_contains_return)) + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => { + body.iter().any(stmt_contains_return) + } + Stmt::For { init, body, .. } => { + init.as_deref().is_some_and(stmt_contains_return) + || body.iter().any(stmt_contains_return) + } + Stmt::Labeled { body, .. } => stmt_contains_return(body), + Stmt::Try { + body, + catch, + finally, + } => { + body.iter().any(stmt_contains_return) + || catch + .as_ref() + .is_some_and(|clause| clause.body.iter().any(stmt_contains_return)) + || finally + .as_ref() + .is_some_and(|body| body.iter().any(stmt_contains_return)) + } + Stmt::Switch { cases, .. } => cases + .iter() + .any(|case| case.body.iter().any(stmt_contains_return)), + // A return inside an expression-owned closure completes that closure, + // not module init, so expression walkers are intentionally not used. + _ => false, } - None } /// How many chunk functions the interleaving would emit for `stmts` at @@ -295,31 +575,44 @@ fn has_init_scan_coupling(hir: &HirModule) -> Option<&'static str> { fn count_prospective_chunks(stmts: &[perry_hir::Stmt], target: usize) -> usize { let mut chunks = 0usize; let mut run = 0usize; - let flush = |run: &mut usize, chunks: &mut usize| { + let mut run_safepoints = 0usize; + let flush = |run: &mut usize, run_safepoints: &mut usize, chunks: &mut usize| { if *run > 0 { - *chunks += run.div_ceil(target.max(1)); + *chunks += 1; *run = 0; + *run_safepoints = 0; } }; for stmt in stmts { match classify_top_level(stmt) { - // A bare declaration is hoisted, not executed in a chunk. - Some(TopLevelKind::BareLet) => {} - Some(TopLevelKind::Expr) | Some(TopLevelKind::SimpleLet(_)) => run += 1, - None => flush(&mut run, &mut chunks), + Some(TopLevelKind::Relocatable) => { + run += 1; + run_safepoints = run_safepoints.saturating_add( + crate::collectors::count_safepoint_sites(std::slice::from_ref(stmt)), + ); + if run >= target.max(1) || run_safepoints >= DEFAULT_CHUNK_SAFEPOINTS { + flush(&mut run, &mut run_safepoints, &mut chunks); + } + } + None => flush(&mut run, &mut run_safepoints, &mut chunks), } } - flush(&mut run, &mut chunks); + flush(&mut run, &mut run_safepoints, &mut chunks); chunks } /// Attempt to outline `hir`'s entry body (#8595). Fail-safe: returns /// `Skipped(reason)` and leaves `hir` untouched unless the whole body is /// provably safe to relocate; callers proceed with the ordinary single-function -/// entry lowering in that case. Only runs when `PERRY_OUTLINE_ENTRY` is set. +/// entry lowering in that case. pub fn outline_entry_module(hir: &mut HirModule) -> OutlineOutcome { - if !entry_outlining_enabled() { - return OutlineOutcome::Skipped("PERRY_OUTLINE_ENTRY not set"); + let mode = outline_mode(); + if mode == OutlineMode::Disabled { + return OutlineOutcome::Skipped("PERRY_OUTLINE_ENTRY disabled"); + } + let safepoints = crate::collectors::count_safepoint_sites(&hir.init); + if mode == OutlineMode::Auto && !meets_automatic_size_threshold(hir.init.len(), safepoints) { + return OutlineOutcome::Skipped("below automatic outlining threshold"); } outline_entry_module_with_target(hir, target_chunk_stmts()) } @@ -333,36 +626,28 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli if !analysis.is_candidate() { return OutlineOutcome::Skipped("not a candidate (too small)"); } - // Coupling bail: some codegen scans read `hir.init` directly and would - // miss statements moved into chunks. Until each is taught to follow chunk - // calls, a module with one is ineligible. (Empirically, outlining exports / - // globalThis / process.env-literals produces correct output on toy entries, - // so these are candidates for relaxation once validated against the gap - // suite — see #8595.) - if let Some(reason) = has_init_scan_coupling(hir) { - return OutlineOutcome::Skipped(reason); - } - // Pre-scan: decide eligibility before mutating. Outlining is worthwhile // only if the interleaving would emit more than one chunk. - if count_prospective_chunks(&hir.init, target) <= 1 { + let prospective_chunks = count_prospective_chunks(&hir.init, target); + if prospective_chunks <= 1 { return OutlineOutcome::Skipped("would not split into multiple chunks"); } - let mut next_id = max_func_id(hir) + 1; + let max_id = max_func_id(hir); + if prospective_chunks > (u32::MAX - max_id) as usize { + return OutlineOutcome::Skipped("function id space exhausted"); + } + let mut next_id = max_id + 1; let module_name = hir.name.clone(); let original = std::mem::take(&mut hir.init); - // Hoisted bare declarations go to the FRONT of the new init so - // `emit_module_globals` still sees them as top-level `let`s and globalizes - // exactly those referenced across chunks (its existing escape rule). - let mut hoisted: Vec = Vec::new(); // The rewritten body: chunk calls interleaved with any statement that had // to stay inline, in original execution order. let mut new_body: Vec = Vec::new(); let mut chunk_fns: Vec = Vec::new(); // The current run of relocatable statements accumulating into a chunk. let mut run: Vec = Vec::new(); + let mut run_safepoints = 0usize; // Emit the accumulated run as a chunk function and append its call, unless // empty. `flush` is a closure over the mutable state via explicit params to @@ -378,18 +663,21 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli return; } let fn_id = *next_id; - *next_id += 1; + *next_id = (*next_id).saturating_add(1); let ci = chunk_fns.len(); chunk_fns.push(perry_hir::Function { id: fn_id, - name: format!("__perry_entry_chunk_{module_name}_{ci}"), + name: format!("{ENTRY_CHUNK_PREFIX}{module_name}_{ci}"), type_params: Vec::new(), params: Vec::new(), return_type: perry_hir::types::Type::Void, body: std::mem::take(run), is_async: false, is_generator: false, - is_strict: true, + // Entry lowering currently uses `is_strict_fn: false` even for an + // ESM. Match that lowering exactly; HIR already encodes the source + // strictness decisions that affect semantics. + is_strict: false, is_exported: false, captures: Vec::new(), decorators: Vec::new(), @@ -406,39 +694,7 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli for stmt in original { match classify_top_level(&stmt) { - Some(TopLevelKind::Expr) | Some(TopLevelKind::BareLet) => { - if let perry_hir::Stmt::Let { .. } = &stmt { - // A bare `let x;` is a pure declaration — hoist it (so it is - // globalized) and add nothing executable to the run. - hoisted.push(stmt); - } else { - run.push(stmt); - } - } - Some(TopLevelKind::SimpleLet(id)) => { - if let perry_hir::Stmt::Let { - id: lid, - name, - ty, - mutable, - init: Some(init), - } = stmt - { - hoisted.push(perry_hir::Stmt::Let { - id: lid, - name, - ty, - mutable, - init: None, - }); - run.push(perry_hir::Stmt::Expr(perry_hir::Expr::LocalSet( - id, - Box::new(init), - ))); - } else { - unreachable!("SimpleLet classification implies Let with init"); - } - } + Some(TopLevelKind::Relocatable) => run.push(stmt), None => { // A statement we cannot safely relocate (control flow, etc.): // end the current chunk run and keep this statement inline, at @@ -451,10 +707,16 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli &mut next_id, &module_name, ); + run_safepoints = 0; new_body.push(stmt); } } - if run.len() >= target { + if let Some(last) = run.last() { + run_safepoints = run_safepoints.saturating_add( + crate::collectors::count_safepoint_sites(std::slice::from_ref(last)), + ); + } + if run.len() >= target.max(1) || run_safepoints >= DEFAULT_CHUNK_SAFEPOINTS { flush( &mut run, &mut chunk_fns, @@ -462,6 +724,7 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli &mut next_id, &module_name, ); + run_safepoints = 0; } } flush( @@ -474,9 +737,7 @@ fn outline_entry_module_with_target(hir: &mut HirModule, target: usize) -> Outli let chunks = chunk_fns.len(); hir.functions.extend(chunk_fns); - let mut rebuilt = hoisted; - rebuilt.extend(new_body); - hir.init = rebuilt; + hir.init = new_body; OutlineOutcome::Outlined { chunks } } @@ -525,6 +786,47 @@ mod tests { assert_eq!(chunk_ranges(0, 3), Vec::<(usize, usize)>::new()); assert_eq!(chunk_ranges(3, 3), vec![(0, 3)]); assert_eq!(chunk_ranges(7, 3), vec![(0, 3), (3, 6), (6, 7)]); + assert_eq!(chunk_ranges(2, usize::MAX), vec![(0, 2)]); + } + + #[test] + fn safepoint_budget_can_split_before_the_statement_target() { + let allocation_heavy_stmt = || { + Stmt::Expr(Expr::Array( + (0..DEFAULT_CHUNK_SAFEPOINTS) + .map(|_| Expr::Array(vec![])) + .collect(), + )) + }; + let mut m = module_with_init(vec![allocation_heavy_stmt(), allocation_heavy_stmt()]); + assert_eq!( + count_prospective_chunks(&m.init, usize::MAX), + 2, + "each allocation-heavy statement should exhaust a chunk budget" + ); + assert_eq!( + outline_entry_module_with_target(&mut m, usize::MAX), + OutlineOutcome::Outlined { chunks: 2 } + ); + } + + #[test] + fn environment_mode_defaults_to_auto_and_has_explicit_overrides() { + assert_eq!(outline_mode_from_env(None), OutlineMode::Auto); + assert_eq!(outline_mode_from_env(Some("unexpected")), OutlineMode::Auto); + assert_eq!(outline_mode_from_env(Some("1")), OutlineMode::Forced); + assert_eq!(outline_mode_from_env(Some("on")), OutlineMode::Forced); + assert_eq!(outline_mode_from_env(Some("0")), OutlineMode::Disabled); + assert_eq!(outline_mode_from_env(Some("false")), OutlineMode::Disabled); + assert!(!meets_automatic_size_threshold( + DEFAULT_AUTO_MIN_STMTS - 1, + DEFAULT_AUTO_MIN_SAFEPOINTS - 1 + )); + assert!(meets_automatic_size_threshold(DEFAULT_AUTO_MIN_STMTS, 0)); + assert!(meets_automatic_size_threshold( + 1, + DEFAULT_AUTO_MIN_SAFEPOINTS + )); } #[test] @@ -583,8 +885,21 @@ mod tests { assert_eq!(a.gated_out, Some("top-level await")); assert!(!a.is_candidate(), "a gated-out body is never a candidate"); } + + #[test] + fn module_level_tdz_preallocation_gates_the_body_out() { + let m = module_with_init(vec![ + Stmt::PreallocateTdzBoxes(vec![0]), + Stmt::Expr(Expr::LocalGet(0)), + let_stmt(0, "x", Expr::Number(1.0)), + ]); + let a = analyze_entry_outlining_with_target(&m, 1); + assert_eq!(a.gated_out, Some("module-level TDZ preallocation")); + assert!(!a.is_candidate()); + } + #[test] - fn transform_splits_lets_and_emits_ordered_chunk_calls() { + fn transform_preserves_declarations_and_emits_ordered_chunk_calls() { // let x = 1 (chunk 0); read x + let y = 2 (chunk 1); read y (chunk 2) let mut m = module_with_init(vec![ let_stmt(0, "x", Expr::Number(1.0)), @@ -597,13 +912,9 @@ mod tests { assert_eq!(outcome, OutlineOutcome::Outlined { chunks: 2 }); // two chunk functions added assert_eq!(m.functions.len(), before_fns + 2); - // new init: hoisted bare decls for x and y, then two ordered chunk calls - let bare_lets = m - .init - .iter() - .filter(|s| matches!(s, Stmt::Let { init: None, .. })) - .count(); - assert_eq!(bare_lets, 2, "both lets hoisted as bare declarations"); + // The physical init is just the two ordered calls. The logical view + // reconstructs the unchanged declaration statements for codegen scans. + assert_eq!(m.init.len(), 2); let calls: Vec = m .init .iter() @@ -625,11 +936,61 @@ mod tests { m.functions[before_fns + 1].id, "call 1 targets chunk 1" ); - // chunk 0 holds `x = 1` (a LocalSet), no bare let + // Chunk 0 holds the original immutable declaration and initializer; + // it was not degraded into a mutable LocalSet assignment. let chunk0 = &m.functions[before_fns].body; - assert!(chunk0 - .iter() - .any(|s| matches!(s, Stmt::Expr(Expr::LocalSet(0, _))))); + assert!(chunk0.iter().any(|s| matches!( + s, + Stmt::Let { + id: 0, + init: Some(Expr::Number(1.0)), + mutable: false, + .. + } + ))); + let logical = logical_entry_stmts(&m); + assert_eq!(logical.len(), 4); + assert!(matches!(logical[0], Stmt::Let { id: 0, .. })); + assert!(matches!(logical[2], Stmt::Let { id: 1, .. })); + assert!( + outlined_entry_global_let_ids(&m).is_empty(), + "bindings confined to one chunk stay function-local" + ); + } + + #[test] + fn only_boundary_crossing_or_preallocated_bindings_become_globals() { + let mut crossing = module_with_init(vec![ + let_stmt(10, "shared", Expr::Number(1.0)), + Stmt::Expr(Expr::Number(0.0)), + Stmt::Expr(Expr::LocalGet(10)), + ]); + assert_eq!( + outline_entry_module_with_target(&mut crossing, 1), + OutlineOutcome::Outlined { chunks: 3 } + ); + assert_eq!( + outlined_entry_global_let_ids(&crossing), + HashSet::from([10]) + ); + + let mut preallocated = module_with_init(vec![ + Stmt::PreallocateBoxes(vec![20]), + Stmt::Try { + body: vec![let_stmt(20, "captured", Expr::Number(2.0))], + catch: None, + finally: None, + }, + Stmt::Expr(Expr::Number(0.0)), + ]); + assert_eq!( + outline_entry_module_with_target(&mut preallocated, 1), + OutlineOutcome::Outlined { chunks: 2 } + ); + assert_eq!( + outlined_entry_global_let_ids(&preallocated), + HashSet::from([20]) + ); } #[test] @@ -655,13 +1016,54 @@ mod tests { was_plain_async: false, was_unrolled: false, }); + m.classes.push(perry_hir::Class { + id: 1, + name: "C".into(), + type_params: vec![], + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: vec![], + constructor: None, + methods: vec![perry_hir::Function { + id: 12_000, + name: "method".into(), + type_params: vec![], + params: vec![], + return_type: Type::Void, + body: vec![], + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: vec![], + decorators: vec![], + was_plain_async: false, + was_unrolled: false, + }], + getters: vec![], + setters: vec![], + static_accessor_names: vec![], + static_accessor_fn_ids: vec![], + static_fields: vec![], + static_methods: vec![], + computed_members: vec![], + decorators: vec![], + is_exported: false, + aliases: vec![], + is_nested: false, + alloc_width_hint: 0, + specialized_from: None, + }); let base = m.functions.len(); let outcome = outline_entry_module_with_target(&mut m, 1); assert!(matches!(outcome, OutlineOutcome::Outlined { .. })); for f in &m.functions[base..] { assert!( - f.id > 9000, - "chunk id {} must clear the closure id 9000", + f.id > 12_000, + "chunk id {} must clear closure and class-member ids", f.id ); } @@ -669,16 +1071,12 @@ mod tests { #[test] fn transform_interleaves_chunks_around_a_must_stay_statement() { - // A top-level `if` cannot be relocated; the transform outlines the - // relocatable runs on either side of it and keeps the `if` inline, in - // order. target=1 so each relocatable statement is its own chunk. + // A top-level return cannot move into a helper because it completes + // module init. The transform outlines runs on either side and keeps the + // return inline, in order. target=1 maximizes chunking. let mut m = module_with_init(vec![ let_stmt(0, "x", Expr::Number(1.0)), // chunk - Stmt::If { - condition: Expr::Bool(true), - then_branch: vec![Stmt::Expr(Expr::LocalGet(0))], - else_branch: None, - }, // must-stay, inline + Stmt::Return(None), // must-stay, inline let_stmt(1, "y", Expr::Number(2.0)), // chunk Stmt::Expr(Expr::LocalGet(1)), // chunk ]); @@ -688,11 +1086,11 @@ mod tests { matches!(outcome, OutlineOutcome::Outlined { .. }), "runs around the if are outlined, not bailed: {outcome:?}" ); - let if_pos = m + let return_pos = m .init .iter() - .position(|s| matches!(s, Stmt::If { .. })) - .expect("the top-level if is kept inline"); + .position(|s| matches!(s, Stmt::Return(_))) + .expect("the top-level return is kept inline"); let call_positions: Vec = m .init .iter() @@ -707,12 +1105,12 @@ mod tests { }) .collect(); assert!( - call_positions.iter().any(|&i| i < if_pos), - "a chunk call precedes the if (the `x` run)" + call_positions.iter().any(|&i| i < return_pos), + "a chunk call precedes the return (the `x` run)" ); assert!( - call_positions.iter().any(|&i| i > if_pos), - "a chunk call follows the if (the `y` run)" + call_positions.iter().any(|&i| i > return_pos), + "a chunk call follows the return (the `y` run)" ); assert!( m.functions.len() > fns_before + 1, @@ -721,13 +1119,26 @@ mod tests { } #[test] - fn transform_bails_when_the_module_has_exports() { + fn structured_control_flow_moves_as_one_indivisible_statement() { + let structured = Stmt::If { + condition: Expr::Bool(true), + then_branch: vec![Stmt::Expr(Expr::Number(1.0))], + else_branch: None, + }; + let mut m = module_with_init(vec![structured, Stmt::Expr(Expr::Number(2.0))]); + let outcome = outline_entry_module_with_target(&mut m, 1); + assert_eq!(outcome, OutlineOutcome::Outlined { chunks: 2 }); + assert!(matches!(m.functions[0].body.as_slice(), [Stmt::If { .. }])); + } + + #[test] + fn exported_modules_are_eligible() { let mut m = module_with_init(vec![ let_stmt(0, "x", Expr::Number(1.0)), Stmt::Expr(Expr::LocalGet(0)), ]); m.exported_functions.push(("g".into(), 42)); let outcome = outline_entry_module_with_target(&mut m, 1); - assert_eq!(outcome, OutlineOutcome::Skipped("module has exports")); + assert_eq!(outcome, OutlineOutcome::Outlined { chunks: 2 }); } } diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index b05d24cae5..7c14e43216 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -580,6 +580,11 @@ pub(super) fn compile_function( let ic_base = llmod.ic_counter; let buffer_alias_base = llmod.buffer_alias_counter; let lf = llmod.define_function(&llvm_name, DOUBLE, params); + let entry_outline_chunk = super::entry_outline::is_entry_chunk(f); + // #8595: these functions exist specifically to bound backend work. Letting + // the ordinary or pre-statepoint inliner fold them back into module init + // would recreate the single giant function before RS4GC/ISel/regalloc. + lf.no_inline = entry_outline_chunk; if typed_public_trampoline.is_some() || guarded_public_plan.is_some() || spec_entry.is_some() @@ -620,7 +625,8 @@ pub(super) fn compile_function( // rewritten wrapper into its caller breaks GC-root coverage of the // step closure's iter capture, hanging async chains (issue #447). let specialized_entry = spec_entry.is_some(); - if !specialized_entry + if !entry_outline_chunk + && !specialized_entry && f.body.len() <= 8 && !f.is_async && !f.is_generator @@ -647,7 +653,8 @@ pub(super) fn compile_function( // as `hot_loop_callee` (before the entry block exists and before any // expression is lowered), for the same reason. lf.alloc_hot = cross_module.alloc_hot_functions.contains(&f.id); - if !specialized_entry + if !entry_outline_chunk + && !specialized_entry && !lf.force_inline && inline_hot_small_enabled() && (INLINE_HOT_SMALL_MIN..=inline_hot_small_size_cap()).contains(&f.body.len()) diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index d18666bbd4..acc8e7f11e 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -1409,6 +1409,12 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> } // macOS / darwin default }; progress.checkpoint("symbol tables and initial declarations"); + // #8595: after entry outlining, declaration-bearing statements live in + // compiler-owned chunk functions. Analyses that model the module's source + // environment use the reconstructed stream, not the compact call-only + // `hir.init`, so immutable initializer facts and TDZ/prealloc metadata are + // unchanged by the structural transform. + let logical_entry_stmts = entry_outline::logical_entry_stmts(hir); // Pre-scan hir.init for compile-time constant variables. These are // `declare const __platform__: number` / `declare const __plugins__: number` @@ -1416,7 +1422,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // uses these to constant-fold platform checks in `lower_if`, eliminating // dead branches that reference extern FFI functions absent on the target. let mut compile_time_constants: HashMap = HashMap::new(); - for s in &hir.init { + for s in logical_entry_stmts.iter().copied() { if let perry_hir::Stmt::Let { id, name, @@ -1445,14 +1451,14 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // ReferenceError on pre-declaration reads instead of folding to a value. { let mut prealloc_ids: std::collections::HashSet = std::collections::HashSet::new(); - for s in &hir.init { + for s in logical_entry_stmts.iter().copied() { if let perry_hir::Stmt::PreallocateBoxes(ids) | perry_hir::Stmt::PreallocateTdzBoxes(ids) = s { prealloc_ids.extend(ids.iter().copied()); } } - for s in &hir.init { + for s in logical_entry_stmts.iter().copied() { if let perry_hir::Stmt::Let { id, mutable: false, @@ -2227,7 +2233,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // in the module (LocalSet/Update/IndexSet/mutating methods). let mut map: std::collections::HashMap = std::collections::HashMap::new(); - for s in &hir.init { + for s in logical_entry_stmts.iter().copied() { if let perry_hir::Stmt::Let { id, init: Some(init), diff --git a/crates/perry-codegen/src/codegen/module_globals_emit.rs b/crates/perry-codegen/src/codegen/module_globals_emit.rs index 368a054156..905945538e 100644 --- a/crates/perry-codegen/src/codegen/module_globals_emit.rs +++ b/crates/perry-codegen/src/codegen/module_globals_emit.rs @@ -341,8 +341,12 @@ pub(crate) fn emit_module_globals( } } } + let logical_entry = super::entry_outline::logical_entry_stmts(hir); + let outlined_entry_globals = super::entry_outline::outlined_entry_global_let_ids(hir); let mut init_lets: Vec<&perry_hir::Stmt> = Vec::new(); - collect_init_lets(&hir.init, &mut init_lets); + for stmt in logical_entry { + collect_init_lets(std::slice::from_ref(stmt), &mut init_lets); + } // `Expr::New { class_name }` does not retain whether an unqualified name // came from the intrinsic or a same-named runtime binding. Mirror HIR's // `shadows_unqualified_global` categories here, plus the module-level HIR @@ -374,7 +378,10 @@ pub(crate) fn emit_module_globals( { module_global_proven_types.insert(*id, proven); } - if referenced_from_fn.contains(id) || exported_var_names.contains(name) { + if outlined_entry_globals.contains(id) + || referenced_from_fn.contains(id) + || exported_var_names.contains(name) + { // A `var` redeclared at module scope (`var x = …; … var x = …;`) // lowers to multiple `Stmt::Let` sharing the SAME id. The backing // global (and any exported getter) is keyed by that id, so emit it diff --git a/crates/perry-codegen/src/codegen/static_fields.rs b/crates/perry-codegen/src/codegen/static_fields.rs index 4bde689e5a..f32e4c62dc 100644 --- a/crates/perry-codegen/src/codegen/static_fields.rs +++ b/crates/perry-codegen/src/codegen/static_fields.rs @@ -306,16 +306,18 @@ pub(super) fn init_static_fields_late( // reassignment made between the class decl and end of module // init. Mirrors the static-block dedup below. The inline // lowering also registers the field in CLASS_DYNAMIC_PROPS. - let inline_initialized = hir.init.iter().any(|s| { - matches!( - s, - perry_hir::Stmt::Expr(perry_hir::Expr::StaticFieldSet { - class_name, - field_name, - .. - }) if *class_name == c.name && *field_name == sf.name - ) - }); + let inline_initialized = super::entry_outline::logical_entry_stmts(hir) + .into_iter() + .any(|s| { + matches!( + s, + perry_hir::Stmt::Expr(perry_hir::Expr::StaticFieldSet { + class_name, + field_name, + .. + }) if *class_name == c.name && *field_name == sf.name + ) + }); if inline_initialized { continue; } diff --git a/crates/perry-codegen/src/collectors/mod.rs b/crates/perry-codegen/src/collectors/mod.rs index 0ae01715ea..53a0f632e8 100644 --- a/crates/perry-codegen/src/collectors/mod.rs +++ b/crates/perry-codegen/src/collectors/mod.rs @@ -59,7 +59,7 @@ pub use clamp_detect::{detect_clamp3, detect_clamp_u8, returns_i32_identity_arg, // transitively expose through `pub(crate) use crate::collectors::*`. pub(crate) use byte_read_key::{collect_numeric_typed_locals, uint8array_get_reads_a_byte}; pub(crate) use class_accessors::{is_class_getter, is_class_setter}; -pub(crate) use closures::collect_closures_in_stmts; +pub(crate) use closures::{collect_closures_in_expr, collect_closures_in_stmts}; pub(crate) use escape_arrays::{const_index, MAX_SCALAR_OBJECT_FIELDS}; pub(crate) use escape_check::{check_escapes_in_stmts, find_new_candidates}; pub(crate) use escape_news::MAX_SCALAR_ARRAY_LEN; diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index 7dc62fcff2..993cfd2dfc 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -1942,9 +1942,17 @@ fn collect_module_finish( // #8595: outline an oversized module-entry body into per-chunk // functions so no single function carries the whole init (which is // pathological for RS4GC relocation fan-out, ISel, and regalloc alike). - // Self-gating and fail-safe: a no-op unless PERRY_OUTLINE_ENTRY is set, - // and it declines (leaving the body unchanged) unless the whole body is - // provably safe to relocate. See perry-codegen `codegen::entry_outline`. + // Automatic only for very large entries; PERRY_OUTLINE_ENTRY=1 forces + // the transform and =0 disables it. Fail-safe exclusions leave the + // original body untouched. See perry-codegen `codegen::entry_outline`. + progress.record(ProgressSnapshot { + stage: "transform-outline-entry", + module_path: Some(&canonical), + module_name: Some(&module_name), + visited: Some(visited.len()), + collected: Some(ctx.native_modules.len() + ctx.js_modules.len()), + ..Default::default() + }); match perry_codegen::codegen::entry_outline::outline_entry_module(&mut hir_module) { perry_codegen::codegen::entry_outline::OutlineOutcome::Outlined { chunks } => { log::debug!( @@ -1953,7 +1961,13 @@ fn collect_module_finish( chunks ); } - perry_codegen::codegen::entry_outline::OutlineOutcome::Skipped(_) => {} + perry_codegen::codegen::entry_outline::OutlineOutcome::Skipped(reason) => { + log::debug!( + "perry: entry body of '{}' not outlined: {}", + hir_module.name, + reason + ); + } } progress.record(ProgressSnapshot { stage: "transform-generators", diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 63bf6cb8e6..38eb33345c 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -1196,7 +1196,7 @@ pub fn run_with_parse_cache( // These are in exported_objects but not in functions, so they need param counts too let exported_set: std::collections::HashSet<&String> = hir_module.exported_objects.iter().collect(); - for stmt in &hir_module.init { + for stmt in perry_codegen::codegen::entry_outline::logical_entry_stmts(hir_module) { if let perry_hir::ir::Stmt::Let { name, init: Some(expr), diff --git a/crates/perry/tests/entry_outline_transform_8595.rs b/crates/perry/tests/entry_outline_transform_8595.rs index 39b342174c..7b60f51839 100644 --- a/crates/perry/tests/entry_outline_transform_8595.rs +++ b/crates/perry/tests/entry_outline_transform_8595.rs @@ -1,14 +1,13 @@ //! #8595 entry-outlining transform — end-to-end differential. //! -//! `PERRY_OUTLINE_ENTRY` rewrites an eligible module-entry body into per-chunk -//! functions, hoisting top-level `let` declarations so cross-chunk state is -//! globalized (via the existing `emit_module_globals` escape rule) and shared -//! across the chunks. This must not change observable behavior — including -//! under a relocating minor, since the cross-chunk objects now live in module -//! globals that a moving collection has to find and rewrite. +//! Oversized entries are outlined automatically; `PERRY_OUTLINE_ENTRY=1` +//! forces the transform on small differential fixtures. Original declarations +//! move unchanged into chunk functions, while module-global discovery gives +//! them shared rooted storage. This must not change observable behavior — +//! including under a relocating minor. //! //! The same program is compiled twice from identical source: -//! * `PERRY_OUTLINE_ENTRY` unset — the ordinary single-function entry; +//! * `PERRY_OUTLINE_ENTRY=0` — the ordinary single-function entry; //! * `PERRY_OUTLINE_ENTRY=1 PERRY_OUTLINE_ENTRY_CHUNK_STMTS=1` — maximum //! chunking, so every top-level statement is its own chunk function and the //! object lets `a`/`b`/`c` are genuinely defined in one chunk and read in @@ -24,14 +23,14 @@ fn perry_bin() -> PathBuf { PathBuf::from(env!("CARGO_BIN_EXE_perry")) } -/// Straight-line, no exports / no control flow / no top-level await, so it is -/// an eligible outlining candidate. `a`/`b`/`c` are heap objects defined in -/// separate chunks and read together in a later chunk. +/// Exported immutable bindings exercise the module-global/export scans that +/// used to gate outlining out entirely. `a`/`b`/`c` are heap objects defined +/// in separate chunks and read together in a later chunk. const SOURCE: &str = r#" let a = { v: 3 }; let b = { v: 4 }; let c = { v: 5 }; -let sum = a.v + b.v + c.v; +export const sum = a.v + b.v + c.v; console.log("sum:" + sum); "#; @@ -52,6 +51,7 @@ const GC_ENV_OVERRIDES: &[&str] = &[ "PERRY_OUTLINE_ENTRY", "PERRY_OUTLINE_ENTRY_CHUNK_STMTS", "PERRY_OUTLINE_ENTRY_REPORT", + "PERRY_OUTLINE_SCAN_8595", ]; fn compile(dir: &std::path::Path, name: &str, source: &str, outline: bool) -> (PathBuf, String) { @@ -72,6 +72,8 @@ fn compile(dir: &std::path::Path, name: &str, source: &str, outline: bool) -> (P cmd.env("PERRY_OUTLINE_ENTRY", "1") .env("PERRY_OUTLINE_ENTRY_CHUNK_STMTS", "1") .env("RUST_LOG", "debug"); + } else { + cmd.env("PERRY_OUTLINE_ENTRY", "0"); } let out = cmd.output().expect("run perry compile"); assert!( @@ -115,6 +117,11 @@ fn run_arms(binary: &std::path::Path, dir: &std::path::Path, label: &str, expect expected, "[{arm_label}] wrong output" ); + assert!( + run.stderr.is_empty(), + "[{arm_label}] unexpected stderr:\n{}", + String::from_utf8_lossy(&run.stderr) + ); } } @@ -139,29 +146,101 @@ fn outlined_entry_matches_the_single_function_entry_under_a_relocating_minor() { run_arms(&on_bin, dir.path(), "outlined", EXPECTED); } -/// A body with a top-level `if` between relocatable runs: the transform must -/// outline the runs and keep the `if` inline, in order — and the result must -/// still match the single-function build under a relocating minor. +/// Script-global function reflection and a `globalThis` read used to be a +/// conservative coupling bail. Structured control flow now moves as one chunk +/// statement, while the reflection still happens before user code. const INTERLEAVE_SOURCE: &str = r#" +function reflected() { return 7; } let a = { v: 10 }; let b = { v: 20 }; if (a.v < b.v) { console.log("less"); } let c = { v: 30 }; -console.log("total:" + (a.v + b.v + c.v)); +console.log("total:" + (a.v + b.v + c.v + globalThis.reflected())); "#; -const INTERLEAVE_EXPECTED: &str = "less\ntotal:60\n"; +const INTERLEAVE_EXPECTED: &str = "less\ntotal:67\n"; #[test] -fn outlining_interleaves_chunks_around_inline_control_flow() { +fn outlining_preserves_structured_control_flow_and_script_global_reflection() { let dir = tempfile::tempdir().expect("tempdir"); let (off_bin, _) = compile(dir.path(), "int_off", INTERLEAVE_SOURCE, false); let (on_bin, on_stderr) = compile(dir.path(), "int_on", INTERLEAVE_SOURCE, true); assert!( on_stderr.contains("outlined entry body of 'int_on.ts' into") && on_stderr.contains("chunk functions"), - "the interleaved body must still outline:\nstderr:\n{on_stderr}" + "the structured body must still outline:\nstderr:\n{on_stderr}" ); run_arms(&off_bin, dir.path(), "single-function", INTERLEAVE_EXPECTED); run_arms(&on_bin, dir.path(), "outlined", INTERLEAVE_EXPECTED); } + +/// `process.env` literals in the entry are applied before static dependencies +/// initialize. The early scan must follow chunk calls after outlining. +#[test] +fn outlining_keeps_early_process_env_assignment_visible_to_dependencies() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + dir.path().join("dep.ts"), + r#"export const observed = process.env.PERRY_OUTLINE_SCAN_8595 || "missing";"#, + ) + .expect("write dependency"); + let source = r#" +process.env.PERRY_OUTLINE_SCAN_8595 = "visible"; +import { observed } from "./dep"; +console.log(observed); +"#; + let (off_bin, _) = compile(dir.path(), "env_off", source, false); + let (on_bin, on_stderr) = compile(dir.path(), "env_on", source, true); + assert!( + on_stderr.contains("outlined entry body of 'env_on.ts' into"), + "the env fixture must outline:\nstderr:\n{on_stderr}" + ); + run_arms(&off_bin, dir.path(), "single-function", "visible\n"); + run_arms(&on_bin, dir.path(), "outlined", "visible\n"); +} + +#[test] +fn oversized_entry_outlines_automatically_without_an_environment_opt_in() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut source = String::new(); + for id in 0..1_001 { + source.push_str(&format!("const v{id} = {id};\n")); + } + source.push_str("console.log(v0 + v1000);\n"); + + let entry = dir.path().join("auto.ts"); + let output = dir.path().join("auto"); + std::fs::write(&entry, source).expect("write automatic outlining fixture"); + let mut cmd = Command::new(perry_bin()); + cmd.current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .env("RUST_LOG", "debug"); + for key in GC_ENV_OVERRIDES { + cmd.env_remove(key); + } + let compiled = cmd.output().expect("compile automatic outlining fixture"); + assert!( + compiled.status.success(), + "automatic outlining compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compiled.stdout), + String::from_utf8_lossy(&compiled.stderr) + ); + let stderr = String::from_utf8_lossy(&compiled.stderr); + assert!( + stderr.contains("outlined entry body of 'auto.ts' into 6 chunk functions"), + "the default 1,000-statement gate should emit six bounded chunks:\n{stderr}" + ); + + let run = Command::new(&output) + .current_dir(dir.path()) + .env_remove("PERRY_OUTLINE_SCAN_8595") + .output() + .expect("run automatic outlining fixture"); + assert!(run.status.success(), "automatic outlined binary failed"); + assert_eq!(String::from_utf8_lossy(&run.stdout), "1000\n"); + assert!(run.stderr.is_empty()); +}