From 954cda6049f3d37178da373cdc000ac59103a582 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 22 Aug 2026 12:08:22 +0200 Subject: [PATCH] perf(codegen): module-entry outlining analysis + reporting (#8595) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First, safe increment toward outlining the oversized module-entry function (the root cause behind #8583's RS4GC fan-out and the #4880 ISel cost: the whole module top level compiles to one giant function). This ships the ANALYSIS half only — no transform, no codegen change — so the correctness-critical part (eval order, TDZ, hoisting, top-level await) can land and be validated separately. `codegen/entry_outline.rs`: - `chunk_ranges` — contiguous top-level statement ranges at a target size, splitting ONLY between top-level statements so a compound statement (and its control flow) stays whole within one chunk. - `analyze_entry_outlining` — chunk count + the cross-chunk `let` set (defined in one chunk, referenced from another), computed with the same `collect_let_ids` / `collect_ref_ids_in_stmts` collectors `emit_module_globals` uses. Those are exactly the bindings the future transform must globalize; the existing escape rule already globalizes any let referenced from a separate function body, so once chunks become functions they globalize for free. - gate `has_top_level_await` (chunking across an async suspension is a distinct harder transform), `PERRY_OUTLINE_ENTRY` (transform gate, inert today), `PERRY_OUTLINE_ENTRY_REPORT` (prints the analysis), `PERRY_OUTLINE_ENTRY_CHUNK_STMTS`. Called once per module from `compile_module`, behind the report flag — zero effect on output otherwise. Pure-core tests (explicit target, no process-global env) cover chunking, cross-chunk detection, own-chunk confinement, and the await gate. --- changelog.d/8595-entry-outline-analysis.md | 1 + .../src/codegen/entry_outline.rs | 285 ++++++++++++++++++ crates/perry-codegen/src/codegen/mod.rs | 4 + 3 files changed, 290 insertions(+) create mode 100644 changelog.d/8595-entry-outline-analysis.md create mode 100644 crates/perry-codegen/src/codegen/entry_outline.rs diff --git a/changelog.d/8595-entry-outline-analysis.md b/changelog.d/8595-entry-outline-analysis.md new file mode 100644 index 0000000000..f14f1caa80 --- /dev/null +++ b/changelog.d/8595-entry-outline-analysis.md @@ -0,0 +1 @@ +perf(codegen): add module-entry outlining analysis + reporting (#8595, first increment). The module top level is lowered into a single LLVM function; for a large minified bundle that one function is enormous (the Claude Code `cli.js` entry is ~68 MB of IR, ~13,170 GC-root slots), which is simultaneously pathological for `rewrite-statepoints-for-gc` (#8583), instruction selection (#4880), and register allocation. This adds `codegen/entry_outline.rs`: the chunk-boundary rule (contiguous top-level statement ranges, never splitting a compound statement) and the cross-chunk `let` set (the bindings a future transform must globalize so chunks share state), reported under `PERRY_OUTLINE_ENTRY_REPORT=1`. Analysis and gate only — no transform yet, no codegen change; the correctness-critical transform (eval order / TDZ / hoisting / top-level await) lands separately. diff --git a/crates/perry-codegen/src/codegen/entry_outline.rs b/crates/perry-codegen/src/codegen/entry_outline.rs new file mode 100644 index 0000000000..f18d2c9821 --- /dev/null +++ b/crates/perry-codegen/src/codegen/entry_outline.rs @@ -0,0 +1,285 @@ +//! Module-entry outlining — analysis + gate (#8595, first increment). +//! +//! The module top level is lowered into a single LLVM function (`@main` / +//! `perry_module_init`). For a large minified bundle that one function is +//! enormous (the Claude Code `cli.js` entry is ~68 MB of IR, ~13,170 GC-root +//! slots), which is simultaneously pathological for `rewrite-statepoints-for-gc` +//! (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. +//! +//! 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. + +use std::collections::HashSet; + +use perry_hir::Module as HirModule; + +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`. +const DEFAULT_CHUNK_STMTS: usize = 200; + +fn target_chunk_stmts() -> usize { + std::env::var("PERRY_OUTLINE_ENTRY_CHUNK_STMTS") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .filter(|&n| n > 0) + .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") + ) +} + +fn report_requested() -> bool { + matches!( + std::env::var("PERRY_OUTLINE_ENTRY_REPORT").as_deref(), + Ok("1") | Ok("on") | Ok("true") + ) +} + +/// Result of analysing whether/how a module entry body would outline. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct EntryOutlineAnalysis { + /// Number of top-level statements in `hir.init`. + pub total_stmts: usize, + /// Number of chunks the body would split into at the current target. + pub chunk_count: usize, + /// Top-level `let`s defined in one chunk and referenced from another — + /// the bindings the transform must globalize so chunks share state. (The + /// existing `emit_module_globals` escape rule already globalizes any let + /// referenced from a separate function body, so once chunks are functions + /// these are globalized for free; this counts them for reporting.) + pub cross_chunk_lets: usize, + /// `Some(reason)` if the transform would decline to outline this body even + /// when enabled — the body is not a safe candidate. + pub gated_out: Option<&'static str>, +} + +impl EntryOutlineAnalysis { + /// Whether this body is a candidate the transform would act on (large + /// enough to be worth splitting and not gated out). + pub fn is_candidate(&self) -> bool { + self.gated_out.is_none() && self.chunk_count > 1 + } +} + +/// 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 +/// its control flow) stays wholly within one chunk. Returns the half-open +/// `[start, end)` index ranges. +fn chunk_ranges(total: usize, target: usize) -> Vec<(usize, usize)> { + if total == 0 { + return Vec::new(); + } + let mut ranges = Vec::new(); + let mut start = 0; + while start < total { + let end = (start + target).min(total); + ranges.push((start, end)); + start = end; + } + ranges +} + +/// Analyse the entry body of `hir` for outlining, using the env-configured +/// chunk target. +pub(crate) fn analyze_entry_outlining(hir: &HirModule) -> EntryOutlineAnalysis { + analyze_entry_outlining_with_target(hir, target_chunk_stmts()) +} + +/// Pure analysis for an explicit chunk target — the testable core (no env). +fn analyze_entry_outlining_with_target(hir: &HirModule, target: usize) -> EntryOutlineAnalysis { + let stmts = &hir.init; + let total_stmts = stmts.len(); + let ranges = chunk_ranges(total_stmts, target); + let chunk_count = ranges.len(); + + // 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.) + let gated_out = if hir.has_top_level_await { + Some("top-level await") + } else { + None + }; + + // Cross-chunk lets: for each chunk collect the `let`s it DEFINES and the + // ids it REFERENCES (same collectors `emit_module_globals` uses). A let is + // cross-chunk if any chunk other than its definer references it. Only + // counted when there is more than one chunk — with one chunk nothing + // crosses. + let cross_chunk_lets = if chunk_count > 1 { + let mut defs: Vec> = Vec::with_capacity(chunk_count); + let mut refs: Vec> = Vec::with_capacity(chunk_count); + for &(start, end) in &ranges { + let slice = &stmts[start..end]; + let mut d = HashSet::new(); + collect_let_ids(slice, &mut d); + defs.push(d); + let mut r = HashSet::new(); + collect_ref_ids_in_stmts(slice, &mut r); + refs.push(r); + } + let mut crossing: HashSet = HashSet::new(); + for (ci, d) in defs.iter().enumerate() { + for &id in d { + let referenced_elsewhere = refs + .iter() + .enumerate() + .any(|(ri, r)| ri != ci && r.contains(&id)); + if referenced_elsewhere { + crossing.insert(id); + } + } + } + crossing.len() + } else { + 0 + }; + + EntryOutlineAnalysis { + total_stmts, + chunk_count, + cross_chunk_lets, + gated_out, + } +} + +/// Print the analysis when `PERRY_OUTLINE_ENTRY_REPORT` is set. No effect on +/// codegen. Called once per module from `compile_module`. +pub(crate) fn report_entry_outlining(hir: &HirModule) { + if !report_requested() { + return; + } + let a = analyze_entry_outlining(hir); + let transform = if entry_outlining_enabled() { + " (PERRY_OUTLINE_ENTRY set, but the transform is not implemented yet — analysis only)" + } else { + "" + }; + match a.gated_out { + Some(reason) => eprintln!( + "[perry] entry-outline: {}: {} top-level stmts; NOT a candidate ({}){}", + hir.name, a.total_stmts, reason, transform + ), + None => eprintln!( + "[perry] entry-outline: {}: {} top-level stmts → {} chunk(s) of ~{}, {} cross-chunk let(s) to globalize; candidate={}{}", + hir.name, + a.total_stmts, + a.chunk_count, + target_chunk_stmts(), + a.cross_chunk_lets, + a.is_candidate(), + transform + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use perry_hir::types::Type; + use perry_hir::{Expr, Module, Stmt}; + + fn let_stmt(id: u32, name: &str, init: Expr) -> Stmt { + Stmt::Let { + id, + name: name.to_string(), + ty: Type::Any, + mutable: false, + init: Some(init), + } + } + + fn module_with_init(init: Vec) -> Module { + let mut m = Module::new("test_mod"); + m.init = init; + m + } + + #[test] + fn chunk_ranges_split_contiguously() { + 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)]); + } + + #[test] + fn small_body_is_a_single_chunk_and_not_a_candidate() { + let m = module_with_init(vec![ + Stmt::Expr(Expr::Number(1.0)), + Stmt::Expr(Expr::Number(2.0)), + ]); + let a = analyze_entry_outlining_with_target(&m, 200); + assert_eq!(a.chunk_count, 1); + assert_eq!(a.cross_chunk_lets, 0); + assert!(!a.is_candidate()); + } + + #[test] + fn cross_chunk_let_is_counted() { + // chunk size 1: `let x = 1` in chunk 0, `x` read in chunk 1 -> crosses. + let m = module_with_init(vec![ + let_stmt(0, "x", Expr::Number(1.0)), + Stmt::Expr(Expr::LocalGet(0)), + ]); + let a = analyze_entry_outlining_with_target(&m, 1); + assert_eq!(a.chunk_count, 2); + assert_eq!( + a.cross_chunk_lets, 1, + "x is defined in chunk 0 and read in chunk 1" + ); + assert!(a.is_candidate()); + } + + #[test] + fn a_let_used_only_within_its_own_chunk_does_not_cross() { + // Two lets, chunk size 2: both defined+used inside their chunk -> none cross. + let m = module_with_init(vec![ + let_stmt(0, "x", Expr::Number(1.0)), + Stmt::Expr(Expr::LocalGet(0)), + let_stmt(1, "y", Expr::Number(2.0)), + Stmt::Expr(Expr::LocalGet(1)), + ]); + let a = analyze_entry_outlining_with_target(&m, 2); + assert_eq!(a.chunk_count, 2); + assert_eq!( + a.cross_chunk_lets, 0, + "x and y are each confined to their own chunk" + ); + } + + #[test] + fn top_level_await_gates_the_body_out() { + let mut m = module_with_init(vec![ + let_stmt(0, "x", Expr::Number(1.0)), + Stmt::Expr(Expr::LocalGet(0)), + ]); + m.has_top_level_await = true; + let a = analyze_entry_outlining_with_target(&m, 1); + assert_eq!(a.gated_out, Some("top-level await")); + assert!(!a.is_candidate(), "a gated-out body is never a candidate"); + } +} diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index e9c4c19adf..5ec539a8fe 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -183,6 +183,7 @@ mod ctor_arity; #[cfg(test)] mod emission_order_tests; mod entry; +mod entry_outline; mod func_registry; mod function; #[cfg(test)] @@ -348,6 +349,9 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // afresh for every module — including the `false` case, to clear any prior // module's decision on this thread. set_full_outline_ic(decide_full_outline_ic(module_callable_count(hir))); + // #8595: report the module-entry outlining analysis when asked. Pure + // diagnostic — no transform yet (see codegen/entry_outline.rs). + entry_outline::report_entry_outlining(hir); // FEAT_JSCVT decision is per-target (apple-arm64 only) — same // set-per-module discipline as the outline gate above. helpers::set_jscvt_for_target(&triple);