diff --git a/changelog.d/7623-static-pretenure-run-once-accumulators.md b/changelog.d/7623-static-pretenure-run-once-accumulators.md new file mode 100644 index 0000000000..59d99789b5 --- /dev/null +++ b/changelog.d/7623-static-pretenure-run-once-accumulators.md @@ -0,0 +1,20 @@ +**gc: pretenure-accumulator admission infrastructure (#7598, scope-reduced per audit)** + +Adds the static admission machinery for a future pretenurer, with no +allocator or codegen consumers: `collect_pretenure_accumulator_locals` +(accumulator `let` at loop depth 0, every push at depth ≥ 1, layered on the +all-pointer terms, refusal tests for the per-iteration and mixed-depth +shapes) and an explicit `region_runs_once` parameter on both fact-graph +builders — module main/init pass true, every function/method/closure region +false, with a graph-level test pinning both polarities. Only a run-once +region makes "declared outside every loop" a cohort-lifetime claim; a +function region's accumulator is re-entered per call (measured 6.6× slower +when pretenured). + +The originally proposed born-tenured allocation was removed after audit: +json_pipeline's minor-moved cohort is the runtime-allocated parse tree +(~113 MB), not codegen-visible literals (~1 MB live at minor time), and the +PR's measured win was a confound between arms that differed in whether +#7613's promote-on-first-copy seed fired. The deferred-page-registration +finding is extracted separately. Next routes for #7598: dynamic feedback or +allocation-context pretenure inside the JSON materialiser. diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index a8c9d721eb..eb3e36901e 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -802,6 +802,7 @@ pub(super) fn compile_closure( classes, &cross_module.compile_time_constants, &cross_module.module_dispatch, + false, ); // Representation-selection context gates (see codegen/function.rs). diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 54a1352105..631a7235a2 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -668,6 +668,7 @@ pub(super) fn compile_module_entry( classes, &cross_module.compile_time_constants, &cross_module.module_dispatch, + true, ); // #7109: the program-entry body participates in canonical (i32/u32/Str) // selection on exactly the per-value rules a function body uses. There @@ -1337,6 +1338,7 @@ pub(super) fn compile_module_entry( classes, &cross_module.compile_time_constants, &cross_module.module_dispatch, + true, ); // #7109: the module-init body participates in canonical (i32/u32/Str) // selection on exactly the per-value rules a function body uses. There diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 946fb7286a..5b43995b99 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -620,6 +620,7 @@ pub(super) fn compile_function( &cross_module.compile_time_constants, &cross_module.module_dispatch, &spec_ta_lens, + false, ); if let Some(plan) = spec_entry { diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index e109ab52d0..4ba7d549a3 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -388,6 +388,7 @@ pub(super) fn compile_method( classes, &cross_module.compile_time_constants, &cross_module.module_dispatch, + false, ); // Representation-selection context gates (see codegen/function.rs). @@ -1448,6 +1449,7 @@ pub(super) fn compile_static_method( classes, &cross_module.compile_time_constants, &cross_module.module_dispatch, + false, ); // Representation-selection context gates (see codegen/function.rs). diff --git a/crates/perry-codegen/src/collectors/all_pointer_arrays.rs b/crates/perry-codegen/src/collectors/all_pointer_arrays.rs index 46ea09de50..17b1f96400 100644 --- a/crates/perry-codegen/src/collectors/all_pointer_arrays.rs +++ b/crates/perry-codegen/src/collectors/all_pointer_arrays.rs @@ -194,6 +194,159 @@ pub(crate) fn collect_all_pointer_array_locals( /// distinct, so seeing it here is harmless — the kill walk above descends into /// closures too, and every use of such an id is inside the closure body it is /// scoped to. +/// #7598 — the subset of [`collect_all_pointer_array_locals`]-admitted locals +/// whose pushed objects should be born TENURED in old-gen. +/// +/// The all-pointer terms already prove the accumulator *shape* (one binding, +/// never rebound, every store a push of a fresh allocation, no captures / +/// boxes / globals). What they do not prove is *cohort lifetime*, and that is +/// the entire pretenuring bet: an object born old that dies young sits in +/// old-gen until a full reclaim. The discriminator is loop position: +/// +/// - the `let` must sit at **loop depth 0** — a per-iteration accumulator +/// (`for (…) { const keep = []; … keep.push(x) … }`) dies every iteration, +/// and pretenuring it floods old-gen with garbage at allocation rate; +/// - every push must sit at **depth ≥ 1** — the cohort accumulates across +/// iterations, so it is live for the remainder of the loop by construction. +/// +/// This is deliberately NOT a proof the array outlives the function; a +/// depth-0 accumulator that is dropped at function exit still pretenures, and +/// its cohort is then reclaimed by the proportional-band full cycles (#7596) +/// instead of dying free in the nursery. That trade is measured, not assumed — +/// see the adversarial arm in the PR. +pub(crate) fn collect_pretenure_accumulator_locals( + stmts: &[Stmt], + all_pointer_admitted: &HashSet, +) -> HashSet { + if all_pointer_admitted.is_empty() { + return HashSet::new(); + } + let mut decl_depth: HashMap = HashMap::new(); + let mut push_depths: HashMap> = HashMap::new(); + scan_depths(stmts, 0, &mut decl_depth, &mut push_depths); + all_pointer_admitted + .iter() + .copied() + .filter(|id| { + decl_depth.get(id) == Some(&0) + && push_depths + .get(id) + .is_some_and(|ds| !ds.is_empty() && ds.iter().all(|&d| d >= 1)) + }) + .collect() +} + +/// Depth-attributed scan: bindings and pushes recorded with their enclosing +/// real-loop count. Expressions directly attached to a statement (conditions, +/// initializers, the statement expression itself) are scanned deeply at that +/// statement's depth — a push nested inside a larger expression still counts, +/// at the depth of the statement carrying it. `for_each_expr` descends into +/// closure bodies too; a push on the same id from inside a closure records the +/// enclosing statement's depth, which is harmless — a captured id was already +/// refused by the all-pointer capture kill. +fn scan_depths( + stmts: &[Stmt], + depth: u32, + decl_depth: &mut HashMap, + push_depths: &mut HashMap>, +) { + let record = |expr: &Expr, at: u32, push_depths: &mut HashMap>| { + super::scalar_method_dispatch::for_each_expr(expr, &mut |e| { + if let Expr::ArrayPush { array_id, .. } = e { + push_depths.entry(*array_id).or_default().push(at); + } + }); + }; + for s in stmts { + match s { + Stmt::Let { id, init, .. } => { + if let Some(Expr::Array(_)) = init { + // First binding wins; a rebind was refused upstream. + decl_depth.entry(*id).or_insert(depth); + } + if let Some(init) = init { + record(init, depth, push_depths); + } + } + Stmt::Expr(expr) | Stmt::Throw(expr) | Stmt::Return(Some(expr)) => { + record(expr, depth, push_depths); + } + Stmt::If { + condition, + then_branch, + else_branch, + } => { + record(condition, depth, push_depths); + scan_depths(then_branch, depth, decl_depth, push_depths); + if let Some(eb) = else_branch { + scan_depths(eb, depth, decl_depth, push_depths); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { condition, body } => { + record(condition, depth + 1, push_depths); + scan_depths(body, depth + 1, decl_depth, push_depths); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(init) = init { + // The initializer runs once, outside the iteration. + scan_depths( + std::slice::from_ref(init.as_ref()), + depth, + decl_depth, + push_depths, + ); + } + if let Some(condition) = condition { + record(condition, depth + 1, push_depths); + } + if let Some(update) = update { + record(update, depth + 1, push_depths); + } + scan_depths(body, depth + 1, decl_depth, push_depths); + } + Stmt::Try { + body, + catch, + finally, + } => { + scan_depths(body, depth, decl_depth, push_depths); + if let Some(c) = catch { + scan_depths(&c.body, depth, decl_depth, push_depths); + } + if let Some(fin) = finally { + scan_depths(fin, depth, decl_depth, push_depths); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + record(discriminant, depth, push_depths); + for c in cases { + if let Some(test) = &c.test { + record(test, depth, push_depths); + } + scan_depths(&c.body, depth, decl_depth, push_depths); + } + } + Stmt::Labeled { body, .. } => { + scan_depths( + std::slice::from_ref(body.as_ref()), + depth, + decl_depth, + push_depths, + ); + } + _ => {} + } + } +} + fn walk_stmts<'a>(stmts: &'a [Stmt], f: &mut impl FnMut(&'a Stmt)) { for s in stmts { f(s); @@ -445,4 +598,88 @@ mod tests { ]; assert!(!collect(&stmts).contains(&1)); } + + // ---- #7598 pretenure-accumulator loop-position terms ------------------- + + fn while_loop(body: Vec) -> Stmt { + Stmt::While { + condition: Expr::Bool(true), + body, + } + } + + fn collect_pretenure(stmts: &[Stmt]) -> HashSet { + let admitted = collect(stmts); + collect_pretenure_accumulator_locals(stmts, &admitted) + } + + /// The target shape: `const out = []` outside every loop, pushes inside. + #[test] + fn pretenure_admits_the_outer_accumulator_inner_push_shape() { + let stmts = vec![ + let_array(1, vec![]), + while_loop(vec![push(1, object_literal())]), + ]; + assert!(collect_pretenure(&stmts).contains(&1)); + } + + /// The per-iteration accumulator dies every iteration; pretenuring it + /// would flood old-gen with garbage at allocation rate. This is + /// push_bench's exact shape and MUST stay refused. + #[test] + fn pretenure_refuses_a_loop_local_accumulator() { + let stmts = vec![while_loop(vec![ + let_array(1, vec![]), + push(1, object_literal()), + ])]; + assert!(!collect_pretenure(&stmts).contains(&1)); + } + + /// A one-shot push outside any loop has no cohort to speak of. + #[test] + fn pretenure_refuses_pushes_outside_loops() { + let stmts = vec![let_array(1, vec![]), push(1, object_literal())]; + assert!(!collect_pretenure(&stmts).contains(&1)); + } + + /// One depth-0 push alongside loop pushes: refused — every push must be + /// inside a loop for the cohort claim to hold. + #[test] + fn pretenure_refuses_mixed_depth_pushes() { + let stmts = vec![ + let_array(1, vec![]), + push(1, object_literal()), + while_loop(vec![push(1, object_literal())]), + ]; + assert!(!collect_pretenure(&stmts).contains(&1)); + } + + /// A push nested inside a larger expression still counts, at the depth of + /// the statement carrying it — the depth scan is not statement-position + /// only. + #[test] + fn pretenure_sees_a_push_nested_in_an_expression() { + let stmts = vec![ + let_array(1, vec![]), + while_loop(vec![Stmt::Expr(Expr::BooleanCoerce(Box::new( + Expr::ArrayPush { + array_id: 1, + value: Box::new(object_literal()), + }, + )))]), + ]; + assert!(collect_pretenure(&stmts).contains(&1)); + } + + /// The all-pointer terms remain a prerequisite: a local they refuse + /// (rebind) is never pretenured, whatever its loop position. + #[test] + fn pretenure_requires_all_pointer_admission() { + let stmts = vec![ + let_array(1, vec![]), + Stmt::Expr(Expr::LocalSet(1, Box::new(Expr::Array(vec![])))), + while_loop(vec![push(1, object_literal())]), + ]; + assert!(!collect_pretenure(&stmts).contains(&1)); + } } diff --git a/crates/perry-codegen/src/collectors/hir_facts.rs b/crates/perry-codegen/src/collectors/hir_facts.rs index 16d51b46be..da64c82340 100644 --- a/crates/perry-codegen/src/collectors/hir_facts.rs +++ b/crates/perry-codegen/src/collectors/hir_facts.rs @@ -97,6 +97,12 @@ pub(crate) struct ArrayFacts { /// why this fact governs *profitability* rather than the soundness of the /// elided per-store note (which the emitted header test owns). pub all_pointer_element_locals: HashSet, + /// #7598: the subset of `all_pointer_element_locals` whose pushed object + /// literals should be born TENURED in old-gen — an accumulator declared + /// outside every loop, filled only from inside one, so its cohort is live + /// for the remainder of the loop by construction. See + /// `collect_pretenure_accumulator_locals` for the loop-position terms. + pub pretenure_accumulator_locals: HashSet, } #[derive(Debug, Clone, Default)] @@ -261,6 +267,11 @@ impl TypeFacts { self.arrays.all_pointer_element_locals.contains(&local_id) } + /// #7598: object literals pushed into this local should be born tenured. + pub(crate) fn pretenure_accumulator(&self, local_id: u32) -> bool { + self.arrays.pretenure_accumulator_locals.contains(&local_id) + } + pub(crate) fn array_length_mutation_locals(&self) -> &HashSet { &self.effect.array_length_mutation_locals } @@ -486,6 +497,12 @@ pub(crate) fn collect_type_facts( boxed_vars, module_globals, ); + // #7598: the loop-position subset whose pushed literals are born tenured. + array_facts.pretenure_accumulator_locals = + super::all_pointer_arrays::collect_pretenure_accumulator_locals( + stmts, + &array_facts.all_pointer_element_locals, + ); let index_used_locals = super::index_uses::collect_index_used_locals(stmts); // Repsel Phase 1: under `PERRY_CANONICAL_I32_LOCALS` (default on), a // proven in-window const int-typed-array element load counts as a STRICT @@ -670,6 +687,7 @@ pub(crate) fn collect_native_region_fact_graph( classes: &HashMap, compile_time_constants: &HashMap, module_dispatch: &super::ModuleDispatchFacts, + region_runs_once: bool, ) -> NativeRegionFactGraph { collect_native_region_fact_graph_with_spec_lens( stmts, @@ -684,6 +702,7 @@ pub(crate) fn collect_native_region_fact_graph( compile_time_constants, module_dispatch, &HashMap::new(), + region_runs_once, ) } @@ -705,8 +724,9 @@ pub(crate) fn collect_native_region_fact_graph_with_spec_lens( compile_time_constants: &HashMap, module_dispatch: &super::ModuleDispatchFacts, spec_ta_lens: &HashMap, + region_runs_once: bool, ) -> NativeRegionFactGraph { - collect_type_facts( + let mut facts = collect_type_facts( stmts, params, flat_const_ids, @@ -719,7 +739,17 @@ pub(crate) fn collect_native_region_fact_graph_with_spec_lens( compile_time_constants, module_dispatch, spec_ta_lens, - ) + ); + // #7598: pretenure-accumulator admission additionally requires the REGION + // to run exactly once (module main/init). A function body's depth-0 + // accumulator is re-entered on every call and its cohort dies at return — + // measured 6.6x slower with 4x the RSS when pretenured (the adversarial + // arm in the PR). Only a run-once region makes "declared outside every + // loop" a cohort-lifetime proof. + if !region_runs_once { + facts.arrays.pretenure_accumulator_locals.clear(); + } + facts } // #854: thin wrapper over collect_type_facts, currently only exercised by this @@ -1406,6 +1436,7 @@ impl ArrayFactCollector { // Filled in by `collect_type_facts` — its own walk, with its // own admission terms, over the same statements. all_pointer_element_locals: HashSet::new(), + pretenure_accumulator_locals: HashSet::new(), }, EffectFacts { unknown_call_escape: self.unknown_call_escape, @@ -1806,6 +1837,50 @@ mod tests { } } + /// #7598: the run-once region gate is what makes `pretenure_accumulator` + /// a cohort-lifetime fact rather than a shape fact — a function region's + /// accumulator is re-entered per call and its cohort dies at return + /// (measured 6.6x slower when pretenured). The admission machinery is + /// retained for a future dynamic-feedback pretenurer (see #7623's audit: + /// json_pipeline's moved cohort is runtime-allocated, so codegen-visible + /// literals were the wrong target); this test keeps the graph-level fact + /// live and pins the gate's direction at both polarities. + #[test] + fn pretenure_accumulator_fact_requires_a_run_once_region() { + let accumulator = Stmt::Let { + id: 1, + name: "out".into(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Array(vec![])), + }; + let push_loop = Stmt::While { + condition: Expr::Bool(true), + body: vec![Stmt::Expr(Expr::ArrayPush { + array_id: 1, + value: Box::new(Expr::Object(vec![("v".to_string(), Expr::Integer(1))])), + })], + }; + let build = |region_runs_once: bool| { + collect_native_region_fact_graph( + &[accumulator.clone(), push_loop.clone()], + &[], + &HashSet::new(), + &HashSet::new(), + &HashSet::new(), + &HashSet::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &crate::collectors::ModuleDispatchFacts::default(), + region_runs_once, + ) + }; + assert!(build(true).pretenure_accumulator(1)); + assert!(!build(false).pretenure_accumulator(1)); + } + fn const_number_let(id: u32, init: Expr) -> Stmt { Stmt::Let { id, @@ -2005,6 +2080,7 @@ mod tests { &HashMap::new(), &constants, &crate::collectors::ModuleDispatchFacts::default(), + true, ); assert!(graph.known_noalias_buffer_locals().contains(&1)); @@ -2096,6 +2172,7 @@ mod tests { &HashMap::new(), &HashMap::new(), &crate::collectors::ModuleDispatchFacts::default(), + true, ); assert!(graph.integer_locals().contains(&1));