From fede447df04f41057a7018b19854ca2f83068895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 31 Jul 2026 07:42:29 +0200 Subject: [PATCH 1/3] =?UTF-8?q?perf(codegen):=20repsel=20=E2=80=94=20modul?= =?UTF-8?q?e-init=20/=20program-entry=20bodies=20select=20canonical=20i32/?= =?UTF-8?q?u32/Str=20(#7109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 (#6903) hard-coded `repsel_context_allows_canonical_{i32,str}: false` for both entry contexts on the premise that "top-level locals interleave with import/init machinery; the win lives in function bodies". Neither that commit nor Phase 3a (#6909), which copied the exclusion, records a hazard — it is a scoping decision, and the premise is false for the corpus Perry is measured on. An entry body is lowered by the same `stmt::lower_stmts_inner` as a function body into an ordinary straight-line LLVM function. Every entry-only property is already covered by a value-level rule (module globals, boxed captures, closure-referenced locals, flat-const row aliases) or is not a difference at all (entry allocas, the module-init shadow frame, in-frame `await` polling, id- refreshing init unroll, `@perry_global_*`-only entry emission). Full audit on `expr::MODULE_INIT_CONTEXT`. `Ptr` stays excluded in entry bodies, now on its own flag: Phase 5a reused the canonical-i32 gate, so lifting it would silently have enabled guard-free receiver access there as a side effect — and #6991 is a live rooting bug in exactly that position (a compiled receiver going stale across the globalThis-population collection, which runs around module init). The split keeps `Ptr` behaviour bit-identical everywhere and keeps `module_init_context` named as its unconsumed mechanism in `--opt-report`. Claude-Session: https://claude.ai/code/session_018ZFER8EEg8K7ez2n6oDrT9 --- crates/perry-codegen/src/codegen/closure.rs | 5 + crates/perry-codegen/src/codegen/entry.rs | 144 ++++++++++--------- crates/perry-codegen/src/codegen/function.rs | 5 + crates/perry-codegen/src/codegen/method.rs | 10 ++ crates/perry-codegen/src/expr/mod.rs | 50 +++++-- crates/perry-codegen/src/expr/slot_rep.rs | 61 +++++++- 6 files changed, 190 insertions(+), 85 deletions(-) diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index cf2c3498fb..8e8612baa1 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -947,6 +947,11 @@ pub(super) fn compile_closure( i32_counter_slots: HashMap::new(), local_slot_reps: HashMap::new(), repsel_context_allows_canonical_i32: repsel_allows, + // #7109: Phase 5a's Ptr context gate, split out of + // `repsel_context_allows_canonical_i32`. Ordinary bodies keep the + // exact pre-split value; only entry bodies diverge. + repsel_context_allows_ptr_shape: repsel_allows, + repsel_ptr_shape_context_denial: repsel_context_denial, repsel_context_denial, repsel_closure_ref_locals: repsel_closure_refs, repsel_context_allows_canonical_str: repsel_str_allows, diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 1c69733b3a..1c9e7b318e 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -669,25 +669,25 @@ pub(super) fn compile_module_entry( &cross_module.compile_time_constants, &cross_module.module_dispatch, ); - // #7106: the two screens the `Stmt::Let` denial consults, so the - // module-init denial count excludes locals a PERMITTING context would - // have rejected anyway (closure-captured; string-ineligible). Both sets - // are read exclusively behind the `repsel_context_allows_*` flags, - // which are `false` here, so populating them changes no emitted byte — - // and they are only collected when the report is on, so an ordinary - // build does no extra walking. - let (repsel_report_closure_refs, repsel_report_str_ineligible) = - if crate::opt_report::enabled() { - ( - crate::expr::collect_closure_referenced_locals(&hir.init), - crate::expr::collect_canonical_str_ineligible_locals(&hir.init), - ) - } else { - ( - std::collections::HashSet::new(), - std::collections::HashSet::new(), - ) - }; + // #7109: the program-entry body participates in canonical (i32/u32/Str) + // selection on exactly the per-value rules a function body uses. There + // is no structural context reason to deny — see + // `expr::MODULE_INIT_CONTEXT` for the audit — so the only remaining + // gates are the two bisection env knobs. + let repsel_allows = crate::expr::canonical_i32_locals_enabled(); + let repsel_str_allows = crate::expr::canonical_str_locals_enabled(); + // The two value-level screens the `Stmt::Let` site consults (#7106 + // collected them for the report only; now they are load-bearing). + let repsel_closure_refs = if repsel_allows || repsel_str_allows { + crate::expr::collect_closure_referenced_locals(&hir.init) + } else { + std::collections::HashSet::new() + }; + let repsel_str_ineligible = if repsel_str_allows { + crate::expr::collect_canonical_str_ineligible_locals(&hir.init) + } else { + std::collections::HashSet::new() + }; let mut init_local_types: HashMap = HashMap::new(); crate::boxed_vars::collect_let_types_in_stmts(&hir.init, &mut init_local_types); let mut ctx = FnCtx { @@ -790,22 +790,24 @@ pub(super) fn compile_module_entry( class_field_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), local_slot_reps: HashMap::new(), - // Representation-selection Phase 1: module-init contexts keep the - // boxed/parallel-shadow model (top-level locals interleave with - // import/init machinery; the win lives in function bodies). - // - // #7106: that exclusion used to be invisible. It is taken here, - // before any per-value rule runs, so a program whose whole hot loop - // sits at module top level recorded neither a selection nor a - // denial — the report simply showed no candidates, which reads - // identically to "this program has no values worth promoting". - // Naming the rule makes the `Stmt::Let` site emit one denial per - // would-be-eligible top-level local instead. - repsel_context_allows_canonical_i32: false, - repsel_context_denial: Some(crate::expr::MODULE_INIT_CONTEXT), - repsel_closure_ref_locals: repsel_report_closure_refs, - repsel_context_allows_canonical_str: false, - repsel_str_ineligible_locals: repsel_report_str_ineligible, + // #7109: this entry body selects canonical i32/u32/Str on the same + // per-value rules as a function body. Phase 1 (#6903) excluded it + // on the premise that "the win lives in function bodies"; 9 of the + // 17 suite benchmarks put their entire hot loop at module top + // level, so it does not. `expr::MODULE_INIT_CONTEXT` carries the + // audit of every entry-body property that made the exclusion look + // load-bearing. + repsel_context_allows_canonical_i32: repsel_allows, + repsel_context_denial: None, + // Ptr stays off here, on its own flag now: Phase 5a reused + // the canonical-i32 gate, and #6991 is a live rooting bug for a + // compiled receiver held across the globalThis-population + // collection — which runs around module init. + repsel_context_allows_ptr_shape: false, + repsel_ptr_shape_context_denial: Some(crate::expr::MODULE_INIT_CONTEXT), + repsel_closure_ref_locals: repsel_closure_refs, + repsel_context_allows_canonical_str: repsel_str_allows, + repsel_str_ineligible_locals: repsel_str_ineligible, spec_abi_functions: &cross_module.spec_abi_functions, spec_ta_bindings: &cross_module.spec_ta_bindings, spec_ta_ready: std::collections::HashSet::new(), @@ -1327,25 +1329,25 @@ pub(super) fn compile_module_entry( &cross_module.compile_time_constants, &cross_module.module_dispatch, ); - // #7106: the two screens the `Stmt::Let` denial consults, so the - // module-init denial count excludes locals a PERMITTING context would - // have rejected anyway (closure-captured; string-ineligible). Both sets - // are read exclusively behind the `repsel_context_allows_*` flags, - // which are `false` here, so populating them changes no emitted byte — - // and they are only collected when the report is on, so an ordinary - // build does no extra walking. - let (repsel_report_closure_refs, repsel_report_str_ineligible) = - if crate::opt_report::enabled() { - ( - crate::expr::collect_closure_referenced_locals(&hir.init), - crate::expr::collect_canonical_str_ineligible_locals(&hir.init), - ) - } else { - ( - std::collections::HashSet::new(), - std::collections::HashSet::new(), - ) - }; + // #7109: the module-init body participates in canonical (i32/u32/Str) + // selection on exactly the per-value rules a function body uses. There + // is no structural context reason to deny — see + // `expr::MODULE_INIT_CONTEXT` for the audit — so the only remaining + // gates are the two bisection env knobs. + let repsel_allows = crate::expr::canonical_i32_locals_enabled(); + let repsel_str_allows = crate::expr::canonical_str_locals_enabled(); + // The two value-level screens the `Stmt::Let` site consults (#7106 + // collected them for the report only; now they are load-bearing). + let repsel_closure_refs = if repsel_allows || repsel_str_allows { + crate::expr::collect_closure_referenced_locals(&hir.init) + } else { + std::collections::HashSet::new() + }; + let repsel_str_ineligible = if repsel_str_allows { + crate::expr::collect_canonical_str_ineligible_locals(&hir.init) + } else { + std::collections::HashSet::new() + }; let mut ctx = FnCtx { func: init_fn, module_slug: crate::expr::native_region_slug(strings.module_prefix()), @@ -1446,22 +1448,24 @@ pub(super) fn compile_module_entry( class_field_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), local_slot_reps: HashMap::new(), - // Representation-selection Phase 1: module-init contexts keep the - // boxed/parallel-shadow model (top-level locals interleave with - // import/init machinery; the win lives in function bodies). - // - // #7106: that exclusion used to be invisible. It is taken here, - // before any per-value rule runs, so a program whose whole hot loop - // sits at module top level recorded neither a selection nor a - // denial — the report simply showed no candidates, which reads - // identically to "this program has no values worth promoting". - // Naming the rule makes the `Stmt::Let` site emit one denial per - // would-be-eligible top-level local instead. - repsel_context_allows_canonical_i32: false, - repsel_context_denial: Some(crate::expr::MODULE_INIT_CONTEXT), - repsel_closure_ref_locals: repsel_report_closure_refs, - repsel_context_allows_canonical_str: false, - repsel_str_ineligible_locals: repsel_report_str_ineligible, + // #7109: this entry body selects canonical i32/u32/Str on the same + // per-value rules as a function body. Phase 1 (#6903) excluded it + // on the premise that "the win lives in function bodies"; 9 of the + // 17 suite benchmarks put their entire hot loop at module top + // level, so it does not. `expr::MODULE_INIT_CONTEXT` carries the + // audit of every entry-body property that made the exclusion look + // load-bearing. + repsel_context_allows_canonical_i32: repsel_allows, + repsel_context_denial: None, + // Ptr stays off here, on its own flag now: Phase 5a reused + // the canonical-i32 gate, and #6991 is a live rooting bug for a + // compiled receiver held across the globalThis-population + // collection — which runs around module init. + repsel_context_allows_ptr_shape: false, + repsel_ptr_shape_context_denial: Some(crate::expr::MODULE_INIT_CONTEXT), + repsel_closure_ref_locals: repsel_closure_refs, + repsel_context_allows_canonical_str: repsel_str_allows, + repsel_str_ineligible_locals: repsel_str_ineligible, spec_abi_functions: &cross_module.spec_abi_functions, spec_ta_bindings: &cross_module.spec_ta_bindings, spec_ta_ready: std::collections::HashSet::new(), diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 41e8fa999a..86743956ee 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -780,6 +780,11 @@ pub(super) fn compile_function( .collect(), i32_counter_slots: spec_i32_param_slots, repsel_context_allows_canonical_i32: repsel_allows, + // #7109: Phase 5a's Ptr context gate, split out of + // `repsel_context_allows_canonical_i32`. Ordinary bodies keep the + // exact pre-split value; only entry bodies diverge. + repsel_context_allows_ptr_shape: repsel_allows, + repsel_ptr_shape_context_denial: repsel_context_denial, repsel_context_denial, repsel_closure_ref_locals: repsel_closure_refs, repsel_context_allows_canonical_str: repsel_str_allows, diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index 2382794f74..6e618a351d 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -524,6 +524,11 @@ pub(super) fn compile_method( i32_counter_slots: HashMap::new(), local_slot_reps: HashMap::new(), repsel_context_allows_canonical_i32: repsel_allows, + // #7109: Phase 5a's Ptr context gate, split out of + // `repsel_context_allows_canonical_i32`. Ordinary bodies keep the + // exact pre-split value; only entry bodies diverge. + repsel_context_allows_ptr_shape: repsel_allows, + repsel_ptr_shape_context_denial: repsel_context_denial, repsel_context_denial, repsel_closure_ref_locals: repsel_closure_refs, repsel_context_allows_canonical_str: repsel_str_allows, @@ -1579,6 +1584,11 @@ pub(super) fn compile_static_method( i32_counter_slots: HashMap::new(), local_slot_reps: HashMap::new(), repsel_context_allows_canonical_i32: repsel_allows, + // #7109: Phase 5a's Ptr context gate, split out of + // `repsel_context_allows_canonical_i32`. Ordinary bodies keep the + // exact pre-split value; only entry bodies diverge. + repsel_context_allows_ptr_shape: repsel_allows, + repsel_ptr_shape_context_denial: repsel_context_denial, repsel_context_denial, repsel_closure_ref_locals: repsel_closure_refs, repsel_context_allows_canonical_str: repsel_str_allows, diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 747dcbc123..969aa96af3 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -798,12 +798,41 @@ pub(crate) struct FnCtx<'a> { pub local_slot_reps: std::collections::HashMap, /// Whether this function context permits canonical-i32 storage selection. - /// False for async / generator / `was_plain_async` bodies (the async-to- - /// generator transform boxes body locals into shared cells) and for module - /// init. Checked at the `Stmt::Let` eligibility site together with the - /// `PERRY_CANONICAL_I32_LOCALS` env gate. + /// False for async / generator / `was_plain_async` bodies — the async-to- + /// generator transform boxes body locals into shared cells, which the + /// canonical model must not touch. Checked at the `Stmt::Let` eligibility + /// site together with the `PERRY_CANONICAL_I32_LOCALS` env gate. + /// + /// #7109: module-init / program-entry bodies are no longer excluded. They + /// are ordinary straight-line synchronous bodies lowered by the same + /// `stmt::lower_stmts_inner` as a function body, so the same per-value + /// rules decide. See [`crate::expr::MODULE_INIT_CONTEXT`] for the audit and + /// for what stays excluded there. pub repsel_context_allows_canonical_i32: bool, + /// Whether this context permits codegen to ACT on a `Ptr` receiver + /// proof ([`FnCtx::ptr_shape_receiver_fact`]). + /// + /// Split out from `repsel_context_allows_canonical_i32` by #7109. Phase 5a + /// reused that flag, so lifting the module-init gate for canonical i32/Str + /// would silently have turned guard-free `Ptr` field access on in + /// entry bodies too — a different representation, with a live rooting bug + /// of its own (#6991: a compiled receiver goes stale across the + /// `globalThis`-population collection, which is exactly what runs around + /// module init). The two are now independent, and this one keeps the + /// pre-#7109 value everywhere: `false` in entry bodies, and elsewhere the + /// same "sync body AND `PERRY_CANONICAL_I32_LOCALS` on" condition it had + /// when it was the same field. Re-coupling it to `PERRY_PTR_SHAPE_LOCALS` + /// instead is #7115-adjacent follow-up work, deliberately not done here. + pub repsel_context_allows_ptr_shape: bool, + + /// Why this context forbids codegen from acting on a `Ptr` receiver + /// proof, for the `--opt-report` unconsumed-promotion record; `None` when it + /// permits it. Same split as `repsel_context_allows_ptr_shape` — before + /// #7109 this read `repsel_context_denial`, which no longer names a rule in + /// entry bodies because canonical selection is allowed there now. + pub repsel_ptr_shape_context_denial: Option<&'static str>, + /// Why this context forbids canonical (i32/u32/Str) selection, for /// `--opt-report` (#6952) and the promotion census (#7106); `None` when it /// permits it. @@ -847,9 +876,10 @@ pub(crate) struct FnCtx<'a> { /// Representation-selection Phase 3a: whether this function context /// permits canonical-Str selection. Mirrors - /// `repsel_context_allows_canonical_i32` (sync bodies only, no module - /// init) but gated on `PERRY_CANONICAL_STR_LOCALS` instead, so the two - /// phases can be A/B-tested independently. + /// `repsel_context_allows_canonical_i32` (sync bodies only; #7109 lifted + /// the module-init exclusion from both together) but gated on + /// `PERRY_CANONICAL_STR_LOCALS` instead, so the two phases can be + /// A/B-tested independently. pub repsel_context_allows_canonical_str: bool, /// Phase 3a eligibility pre-pass result @@ -1499,7 +1529,7 @@ impl<'a> FnCtx<'a> { &self, e: &perry_hir::Expr, ) -> Option<&crate::collectors::PtrShapeLocal> { - if !self.repsel_context_allows_canonical_i32 { + if !self.repsel_context_allows_ptr_shape { // #7106 follow-up: this early return is the whole of mechanism 2. // The fact EXISTS — `collect_shape_proven_ptr_locals` already ran // and already recorded a `select()` for it — and every access site @@ -1533,7 +1563,7 @@ impl<'a> FnCtx<'a> { } /// Record that a selected `Ptr` proof was dropped by the context - /// gate (`repsel_context_allows_canonical_i32 == false`). + /// gate (`repsel_context_allows_ptr_shape == false`). /// /// Deliberately silent when the context permits the representation and only /// the `PERRY_CANONICAL_I32_LOCALS` bisection knob turned it off: that arm @@ -1541,7 +1571,7 @@ impl<'a> FnCtx<'a> { /// class of entry the default build cannot emit (same rule as /// `slot_rep::body_context_denial`). fn report_ptr_shape_context_drop(&self, e: &perry_hir::Expr) { - let Some(rule) = self.repsel_context_denial else { + let Some(rule) = self.repsel_ptr_shape_context_denial else { return; }; let Some(fact) = self.ptr_shape_fact_ignoring_context(e) else { diff --git a/crates/perry-codegen/src/expr/slot_rep.rs b/crates/perry-codegen/src/expr/slot_rep.rs index 9fa0b73fb6..668adf4d63 100644 --- a/crates/perry-codegen/src/expr/slot_rep.rs +++ b/crates/perry-codegen/src/expr/slot_rep.rs @@ -114,12 +114,63 @@ pub(crate) enum SlotRep { Str, } -/// The `repsel_context_denial` rule for a module-init / program-entry body. +/// The context rule for a module-init / program-entry body. /// -/// Both entry contexts (`codegen/entry.rs`) exclude canonical selection -/// wholesale, so every top-level local stays boxed no matter what the per-value -/// rules would have said. Naming the rule is what turns that from an invisible -/// zero into a counted denial (#7106). +/// ## History +/// +/// Phase 1 (#6903) excluded both entry contexts (`codegen/entry.rs`) from +/// canonical selection wholesale, on the stated premise that "top-level locals +/// interleave with import/init machinery; the win lives in function bodies". +/// Phase 3a (#6909) copied the exclusion for `Str`. Neither commit records a +/// hazard — it is a scoping decision, and the premise is false for the corpus +/// Perry is measured on: 9 of the 17 `benchmarks/suite` workloads put their +/// entire hot loop at module top level, `08_string_concat`'s `result` being +/// Phase 3a's own motivating `+=` self-append shape (#7109). +/// +/// ## Why lifting it for canonical i32/u32/Str is sound +/// +/// An entry body is lowered by the same `stmt::lower_stmts_inner` as a function +/// body, into an ordinary straight-line LLVM function (`main` or +/// `__init`). Every property that made the exclusion look necessary is +/// already covered by a value-level rule or is not a difference at all: +/// +/// * **Module globals.** A top-level binding read from any function, method or +/// closure body — or exported — is backed by a `@perry_global_*` cell +/// (`codegen/module_globals_emit.rs`), and `!ctx.module_globals.contains_key` +/// has excluded those since Phase 1. +/// * **Block-scoped top-level lets that escape into a closure.** Those are not +/// globalized; they are boxed (`ctx.boxed_vars`) or land in +/// `repsel_closure_ref_locals` — two more pre-existing value-level rules. +/// * **The init prelude.** `mark_entry_init_boundary` splices post-init setup +/// after the GC/string-pool prelude; canonical slots are entry allocas with a +/// constant `store i32 0`, exactly like the boxed path's `TAG_UNDEFINED` +/// store, and both go through `entry_allocas_push_store`. +/// * **The module-init shadow frame.** `enable_module_init_shadow_frame` binds +/// only pointer-typed locals (`collect_pointer_typed_locals`). An `I32`/`U32` +/// local is a number and was never bound; a `Str` local does not move storage +/// at all, so its binding is untouched. +/// * **Top-level `await`.** Codegen lowers `Expr::Await` to an in-frame polling +/// loop — module init is never rewritten into a generator state machine, so +/// the async-to-generator hazard that `body_context_denial` guards does not +/// arise here. +/// * **Init unrolling.** `unroll_static_loops` refreshes local ids per copy, so +/// an unrolled init declares fresh bindings, same as an unrolled function. +/// * **Entry-only emission** (`emit_namespace_populator`, +/// `init_static_fields_*`, `emit_script_global_function_decls`, +/// `register_module_globals_as_gc_roots`) reads `@perry_global_*` cells and +/// never `ctx.locals`. +/// +/// ## What is still excluded, and why +/// +/// `Ptr` receiver proofs. Phase 5a reused +/// `repsel_context_allows_canonical_i32` as its context gate, so lifting that +/// flag would silently have enabled guard-free `this.field` / `obj.field` +/// lowering in entry bodies as a side effect of an unrelated phase. That is not +/// a representation this issue measured, and #6991 is an open rooting bug in +/// exactly that position: a compiled receiver goes stale across the +/// `globalThis`-population collection, which runs around module init. So the +/// flag is split (`repsel_context_allows_ptr_shape`) and entry bodies keep +/// `Ptr` off, still naming this rule in `--opt-report`. pub(crate) const MODULE_INIT_CONTEXT: &str = "module_init_context"; /// Why an ordinary body context forbids canonical (i32/u32/Str) selection, or From d9d98a7336103a91f4469d809a1e981ae2392b92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 31 Jul 2026 07:56:59 +0200 Subject: [PATCH 2/3] test(repsel): module-init liveness fixture + correctness net for #7109 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `fixture_module_init_canonical.ts`: the only census workload that declares no function, method or closure, so every canonical count it reports had to come from the module-init FnCtx. Measures 0/0/0 on the pre-#7109 compiler and 2/1/1 (i32/u32/Str) after, which is what makes the new LIVENESS_FLOORS falsifiable rather than decorative. - `test_gap_repsel_module_init_canonical.ts`: byte-for-byte node parity for the top-level population — i32 wrap, u32 above 2^31, escaping bindings (module global + closure capture), Str alias demote / SSO->heap / ToString coercion / non-ASCII, try-catch and switch-fallthrough skipped Lets, and GC pressure. - `note_canonical_local` took its `--opt-report` region kind from a hard-coded `RegionKind::Function`. That was invisible while module-init and closure bodies could not select at all; it now takes the ambient scope's region, the same source `consume()` uses and for the same stated reason. Claude-Session: https://claude.ai/code/session_018ZFER8EEg8K7ez2n6oDrT9 --- benchmarks/repsel_census/baseline.json | 24 ++++ .../fixtures/fixture_module_init_canonical.ts | 50 +++++++ crates/perry-codegen/src/expr/slot_rep.rs | 8 +- crates/perry-codegen/src/opt_report/mod.rs | 18 +++ .../compiler_output_harness/repsel_census.py | 19 ++- .../test_gap_repsel_module_init_canonical.ts | 132 ++++++++++++++++++ 6 files changed, 246 insertions(+), 5 deletions(-) create mode 100644 benchmarks/repsel_census/fixtures/fixture_module_init_canonical.ts create mode 100644 test-files/test_gap_repsel_module_init_canonical.ts diff --git a/benchmarks/repsel_census/baseline.json b/benchmarks/repsel_census/baseline.json index 50ad8d908d..2c71c55993 100644 --- a/benchmarks/repsel_census/baseline.json +++ b/benchmarks/repsel_census/baseline.json @@ -185,6 +185,30 @@ }, "unconsumed_mechanisms": {} }, + { + "name": "fixture_module_init_canonical", + "role": "liveness", + "source": "benchmarks/repsel_census/fixtures/fixture_module_init_canonical.ts", + "floors": { + "ptr-shape": 0, + "ptr-shape-consumed": 0, + "ptr-numarray": 0, + "canonical-i32": 0, + "canonical-u32": 0, + "canonical-str": 0, + "int-valued-ta": 0, + "spec-abi-entry": 0, + "spec-abi-taptr-slot": 0 + }, + "candidates": { + "ptr-shape": 0, + "ptr-numarray": 0, + "canonical-slot": 0, + "int-valued-ta": 0, + "spec-abi": 0 + }, + "unconsumed_mechanisms": {} + }, { "name": "batch", "role": "corpus", diff --git a/benchmarks/repsel_census/fixtures/fixture_module_init_canonical.ts b/benchmarks/repsel_census/fixtures/fixture_module_init_canonical.ts new file mode 100644 index 0000000000..a124e2971e --- /dev/null +++ b/benchmarks/repsel_census/fixtures/fixture_module_init_canonical.ts @@ -0,0 +1,50 @@ +// Liveness fixture for canonical selection in a MODULE-INIT body (#7109). +// +// `fixture_canonical_slots.ts` proves the three canonical reps are alive in +// function bodies. This one proves the same thing for the program-entry body, +// and it is deliberately the ONLY program in the corpus that can: it declares +// no function, no method and no closure, so every promotion it reports had to +// come from `codegen/entry.rs`'s FnCtx. +// +// Before #7109 both entry contexts hard-coded +// `repsel_context_allows_canonical_{i32,str}: false`, so this file's counts +// were 0/0/0 no matter what the per-value rules said. That is what makes the +// floors below falsifiable: revert the entry.rs gate and this fixture goes to +// zero on all three keys while every function-body fixture stays green. +// +// Requirements mirrored from the function-body fixture: no closure may capture +// a candidate (there are no closures), and no candidate may be a module global +// (nothing here is exported or read from a function, so nothing is globalized +// into `@perry_global_*`). + +// Canonical i32: an index-used loop counter and the index-used bound it is +// compared against. `data[i]` is what makes both index-used; a counter that +// never reaches an array index is rejected by `not_index_used_or_bounded`. +const LIMIT = 64; +const data: number[] = []; +for (let i = 0; i < LIMIT; i++) { + data[i] = i * 3; +} + +let checksum = 0; +for (let i = 0; i < LIMIT; i++) { + checksum = checksum + data[i]; +} + +// Canonical u32: every write is a top-level `>>> 0`, so the value stays +// observable as unsigned above 2^31 and the u32 bit pattern round-trips. +let mixed = (0x9e3779b9 ^ LIMIT) >>> 0; +for (let s = 0; s < 8; s++) { + mixed = (mixed ^ (mixed << 13)) >>> 0; + mixed = (mixed ^ (mixed >>> 17)) >>> 0; +} + +// Canonical Str: a string local whose every write is a string — Phase 3a's +// motivating `+=` self-append shape, which is what `benchmarks/suite/ +// 08_string_concat.ts` is and why it promoted nothing before #7109. +let text = "seed"; +for (let t = 0; t < LIMIT; t++) { + text = text + "x"; +} + +console.log("module_init_canonical:" + checksum + ":" + mixed + ":" + text.length); diff --git a/crates/perry-codegen/src/expr/slot_rep.rs b/crates/perry-codegen/src/expr/slot_rep.rs index 668adf4d63..d5dbe8c7cb 100644 --- a/crates/perry-codegen/src/expr/slot_rep.rs +++ b/crates/perry-codegen/src/expr/slot_rep.rs @@ -250,12 +250,14 @@ fn repsel_debug_enabled() -> bool { pub(crate) fn note_canonical_local(ctx: &FnCtx<'_>, id: u32, name: &str, rep: SlotRep) { // `--opt-report` (#6952) shares this one call site with PERRY_REPSEL_DEBUG // so a canonical local can never show up in one mechanism and not the - // other. `FnCtx` already knows the function and module, so no ambient - // scope is needed here. + // other. `FnCtx` already knows the function and module; the region KIND is + // taken from the ambient scope, because `FnCtx` does not carry it and a + // hard-coded `Function` would now mislabel every module-init selection + // (#7109) — the exact region whose promotions are the interesting ones. if crate::opt_report::enabled() { crate::opt_report::select_explicit( &ctx.source_function, - crate::opt_report::RegionKind::Function, + crate::opt_report::current_region(), crate::opt_report::Position::Local, name, Some(id), diff --git a/crates/perry-codegen/src/opt_report/mod.rs b/crates/perry-codegen/src/opt_report/mod.rs index f050dfc8b8..9b5a08442b 100644 --- a/crates/perry-codegen/src/opt_report/mod.rs +++ b/crates/perry-codegen/src/opt_report/mod.rs @@ -532,6 +532,24 @@ fn current_module() -> String { }) } +/// Kind of the lowering region currently being emitted, for the one +/// [`select_explicit`] caller that knows its function name but not its region +/// kind (`slot_rep::note_canonical_local`, which holds only an `FnCtx`). +/// +/// Same rule [`scope_or_unknown`] states for consumption: the region is taken +/// from the ambient scope rather than re-derived from a function name, which is +/// what makes `region == module-init` trustworthy. Before #7109 the caller +/// passed a hard-coded `Function`, which was invisible only because module-init +/// and closure bodies could not select a canonical rep at all. +pub(crate) fn current_region() -> RegionKind { + SCOPE.with(|s| { + s.borrow() + .as_ref() + .map(|sc| sc.region) + .unwrap_or(RegionKind::Function) + }) +} + /// Bracket a lowering region so collector denials inside it are attributed to /// `function`. No-op (and allocation-free) when the report is off. pub(crate) fn enter(module: &str, function: &str, region: RegionKind) -> ScopeGuard { diff --git a/scripts/compiler_output_harness/repsel_census.py b/scripts/compiler_output_harness/repsel_census.py index 8428370f93..9150935910 100644 --- a/scripts/compiler_output_harness/repsel_census.py +++ b/scripts/compiler_output_harness/repsel_census.py @@ -206,6 +206,19 @@ "fixture_loop_bounded_i32": {"canonical-i32": 3}, "fixture_int_valued_ta": {"int-valued-ta": 1}, "fixture_spec_abi_taptr": {"spec-abi-entry": 1, "spec-abi-taptr-slot": 1}, + # #7109. The same three reps as `fixture_canonical_slots`, but this fixture + # declares no function, method or closure at all, so every count it reports + # had to come from the module-init `FnCtx` in `codegen/entry.rs`. Before + # #7109 that context hard-coded `repsel_context_allows_canonical_{i32,str}: + # false` and the fixture measured 0/0/0 on all three keys — which is what + # makes these floors falsifiable rather than decorative: restoring the + # entry.rs gate takes exactly this fixture red while every function-body + # fixture stays green. + "fixture_module_init_canonical": { + "canonical-i32": 1, + "canonical-u32": 1, + "canonical-str": 1, + }, } #: Workloads allowed to produce **zero candidates** — no analysis considered any @@ -217,9 +230,11 @@ #: there is no rule to point at and nothing to argue with. Before #7106's #: follow-up, **8 of the 18 real workloads were in that state** — every one #: because its hot loop is at module top level, which `codegen/entry.rs` -#: excludes from canonical selection before any per-value rule runs. The +#: excluded from canonical selection before any per-value rule ran. The #: promotion counts were identical either way, which is exactly why the census -#: alone could not see it. +#: alone could not see it. #7109 removed that exclusion: those top-level values +#: are now selected rather than denied, and `canonical-i32` went from promoting +#: in 2 of 18 real workloads to 17 of 18. #: #: An entry here must name a program that genuinely has nothing to analyse. ZERO_CANDIDATE_ALLOWLIST: dict[str, str] = { diff --git a/test-files/test_gap_repsel_module_init_canonical.ts b/test-files/test_gap_repsel_module_init_canonical.ts new file mode 100644 index 0000000000..71971bf746 --- /dev/null +++ b/test-files/test_gap_repsel_module_init_canonical.ts @@ -0,0 +1,132 @@ +// Gap test: representation-selection in a MODULE-INIT body (#7109). +// +// Correctness net for lifting the entry-context gate. Every candidate here is +// declared at module top level, which is the population that could not select +// a canonical rep before #7109, so this file is where a wrong-value regression +// from that change would show up first. +// +// Obligations exercised (each has a distinct failure signature): +// 1. canonical i32/u32 storage is the ONLY storage — a value read back in a +// boxed context must materialize identically (sitofp / uitofp), including +// above 2^31 for u32. +// 2. a top-level binding that a function or closure reads is a module global +// or a boxed capture, never a canonical slot — reading it from both sides +// must agree. +// 3. canonical Str at top level: alias demote, SSO -> heap growth, `.length`, +// `===`, `charCodeAt`, non-ASCII bytes, and a non-string right-hand side +// (which must ToString-coerce, not silently drop). +// 4. control flow that skips a top-level `Stmt::Let` (try/catch, switch +// fallthrough) must read the same value Node reads. +// 5. GC: a top-level Str accumulator and an object graph must survive a +// collection triggered in the middle of module init. +// +// Run: node --experimental-strip-types test_gap_repsel_module_init_canonical.ts +// Also run with PERRY_CANONICAL_I32_LOCALS=0, PERRY_CANONICAL_STR_LOCALS=0 and +// PERRY_GC_FORCE_EVACUATE=1 — all four must agree byte-for-byte. + +// ── 1. canonical i32 / u32 at top level ─────────────────────────────────── +const LIMIT = 40; +const cells: number[] = []; +for (let i = 0; i < LIMIT; i++) { + cells[i] = (i * 7) | 0; +} +let total = 0; +for (let i = 0; i < LIMIT; i++) { + total = total + cells[i]; +} +console.log("i32:", LIMIT, cells[0], cells[LIMIT - 1], total); + +// The i32 slot is signed; reading it in a boxed context must sitofp, and the +// negative wrap must be observable exactly as Node computes it. +let wrap = 2147483647 | 0; +wrap = (wrap + 1) | 0; +console.log("i32-wrap:", wrap, wrap - 1, String(wrap)); + +// u32: every write is a top-level `>>> 0`, so values above 2^31 must read back +// unsigned (uitofp), not as the negative signed reinterpretation. +let mix = 0x9e3779b9 >>> 0; +for (let s = 0; s < 5; s++) { + mix = (mix ^ (mix << 13)) >>> 0; + mix = (mix ^ (mix >>> 17)) >>> 0; +} +console.log("u32:", mix, mix.toString(16), mix > 2147483647); + +// ── 2. top-level bindings that escape ───────────────────────────────────── +// `shared` is read from a function body, so it is backed by a module global and +// must NOT be canonical; `captured` is read from a closure. Both must agree +// with the module-init side. +let shared = 0; +for (let i = 0; i < LIMIT; i++) { + shared = (shared + i) | 0; +} +function readShared(): number { + return shared; +} +let captured = "cap"; +const readCaptured = (): string => captured + "!"; +captured = captured + "-more"; +console.log("escape:", shared, readShared(), captured, readCaptured()); + +// ── 3. canonical Str at top level ───────────────────────────────────────── +let text = ""; +for (let i = 0; i < 30; i++) { + text = text + "x"; +} +console.log("str:", text.length, text === "x".repeat(30), text.charCodeAt(0)); + +// Alias demote: `snapshot` shares the buffer, so the next `+=` must allocate +// fresh instead of mutating in place. +let grow = "ab".repeat(4); +const snapshot = grow; +grow += "Z"; +console.log("str-alias:", grow, snapshot, grow.length, snapshot.length); + +// Non-string right-hand side must ToString-coerce through the fast arm. +let coerced = "n"; +coerced += 42; +coerced += true; +coerced += [1, 2]; +console.log("str-coerce:", coerced, coerced.length); + +// Non-ASCII must stay byte-exact through the append/length/compare arms. +let uni = ""; +for (let i = 0; i < 4; i++) { + uni = uni + "héllo→"; +} +console.log("str-unicode:", uni.length, uni === "héllo→".repeat(4), uni.charCodeAt(1)); + +// ── 4. control flow that skips a top-level Let ──────────────────────────── +try { + const skipped = JSON.parse("{ not json"); + console.log("unreachable:", skipped); +} catch { + console.log("catch-ok"); +} + +let switched = 0; +switch (LIMIT % 3) { + case 0: + switched = 100; + break; + case 1: { + const inner = 7 | 0; + switched = (switched + inner) | 0; + break; + } + default: + switched = -1; +} +console.log("switch:", switched); + +// ── 5. GC pressure around top-level canonical values ────────────────────── +const graph: { id: number; label: string }[] = []; +let acc = ""; +for (let i = 0; i < 200; i++) { + graph.push({ id: i | 0, label: "n" + i }); + acc = acc + "-"; +} +let live = 0; +for (let i = 0; i < graph.length; i++) { + live = (live + graph[i].id) | 0; +} +console.log("gc:", graph.length, acc.length, live, graph[199].label, text.length); From b74f91943f57a30a665899030ba7f501bf14029f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 31 Jul 2026 08:14:08 +0200 Subject: [PATCH 3/3] test(repsel): ratchet census floors, register the gap file, refresh the census README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every floor moved up or stayed; none was lowered (asserted programmatically against the pre-change baseline). canonical-i32 goes from promoting in 2 of 18 real workloads to 17 of 18, canonical-str from 0 to 1 (08_string_concat, which is Phase 3a's own `+=` self-append shape). `test-parity/gc_repsel_corpus.txt` registers the new gap file — the matrix script exits 3 on an unregistered `test_gap_repsel_*` file, by design. Claude-Session: https://claude.ai/code/session_018ZFER8EEg8K7ez2n6oDrT9 --- benchmarks/repsel_census/README.md | 35 ++++++-- benchmarks/repsel_census/baseline.json | 60 ++++++------- changelog.d/7121-module-init-repsel.md | 85 +++++++++++++++++++ .../test_gap_repsel_module_init_canonical.ts | 13 ++- test-parity/gc_repsel_corpus.txt | 10 +++ 5 files changed, 162 insertions(+), 41 deletions(-) create mode 100644 changelog.d/7121-module-init-repsel.md diff --git a/benchmarks/repsel_census/README.md b/benchmarks/repsel_census/README.md index e30795e6d6..6144a4a2b3 100644 --- a/benchmarks/repsel_census/README.md +++ b/benchmarks/repsel_census/README.md @@ -50,12 +50,21 @@ Six proven, two applied. A promotion goes unconsumed three ways, and every one of them is recorded at the site where the proof is dropped: 1. **`module_init_context`** (#7109) — `codegen/entry.rs` sets - `repsel_context_allows_canonical_i32: false` for module-init and - program-entry bodies, and `FnCtx::ptr_shape_receiver_fact` returns `None` for - the whole body when that flag is clear. Every access site falls back to the - guarded diamond. -2. **`async_body` / `generator_body`** (#6328) — the same flag, cleared for a - different reason. + `repsel_context_allows_ptr_shape: false` for module-init and program-entry + bodies, and `FnCtx::ptr_shape_receiver_fact` returns `None` for the whole + body when that flag is clear. Every access site falls back to the guarded + diamond. + + That flag used to be `repsel_context_allows_canonical_i32`, shared with a + different representation. #7109 lifted the entry-body exclusion for + canonical i32/u32/Str and split the flag rather than dragging `Ptr` + along with it: #6991 is an open rooting bug for a compiled receiver held + across the `globalThis`-population collection, which runs around module + init. So this row is unchanged, and it is now the only representation the + rule names. +2. **`async_body` / `generator_body`** (#6328) — the canonical flag, cleared for + a different reason. `Ptr` reads the same rule name through its own + flag. 3. **`scalar_replaced`** (#7115) — `collectors/escape_news.rs` deleted the object outright. This one is the *better* outcome, not a defect; it is listed because "scalar-replaced" and "promoted but wasted" used to render @@ -140,9 +149,11 @@ Three separate mechanisms, in increasing order of paranoia: candidates" names nothing — and the two produce an identical census table. When #7104 landed, **8 of the 18 real workloads were in the second state**, every one because its hot loop is at module top level and - `codegen/entry.rs` excludes module-init contexts from canonical selection - before any per-value rule runs (#7109). Nothing in the census could have - told the difference; the follow-up records those as denials so it can. + `codegen/entry.rs` excluded module-init contexts from canonical selection + before any per-value rule ran (#7109). Nothing in the census could have told + the difference; the follow-up recorded those as denials so it could, and + #7109 then removed the exclusion — the same values are now selections, and + `canonical-i32` went from promoting in 2 of 18 real workloads to 17 of 18. Only `suite_01_startup` is allowlisted: it is a lone `console.log`, with no bindings for any analysis to consider. @@ -181,6 +192,12 @@ local in a loop moves it to the parallel-shadow model, adding a bounds check to `fixture_int_valued_ta` moves its locals to the ordinary integer-local path. Each file says which edits would silently take it to zero. +`fixture_module_init_canonical.ts` has one extra rule of its own: **it must +never grow a function, method or closure.** Its whole claim is that every count +it reports came from the module-init `FnCtx`; moving one loop into a helper +would make it a duplicate of `fixture_canonical_slots.ts` and leave the entry +context untested again. + If a fixture legitimately stops exercising its representation, change the fixture *and* say so in the PR. Lowering `LIVENESS_FLOORS` instead is how this gate would end up unable to fail. diff --git a/benchmarks/repsel_census/baseline.json b/benchmarks/repsel_census/baseline.json index 2c71c55993..c402822320 100644 --- a/benchmarks/repsel_census/baseline.json +++ b/benchmarks/repsel_census/baseline.json @@ -119,7 +119,7 @@ "ptr-shape": 0, "ptr-shape-consumed": 0, "ptr-numarray": 0, - "canonical-i32": 1, + "canonical-i32": 3, "canonical-u32": 0, "canonical-str": 0, "int-valued-ta": 1, @@ -144,7 +144,7 @@ "ptr-shape": 0, "ptr-shape-consumed": 0, "ptr-numarray": 0, - "canonical-i32": 2, + "canonical-i32": 3, "canonical-u32": 0, "canonical-str": 0, "int-valued-ta": 0, @@ -183,7 +183,8 @@ "int-valued-ta": 0, "spec-abi": 4 }, - "unconsumed_mechanisms": {} + "unconsumed_mechanisms": {}, + "consumption_sites": {} }, { "name": "fixture_module_init_canonical", @@ -192,22 +193,23 @@ "floors": { "ptr-shape": 0, "ptr-shape-consumed": 0, - "ptr-numarray": 0, - "canonical-i32": 0, - "canonical-u32": 0, - "canonical-str": 0, + "ptr-numarray": 1, + "canonical-i32": 3, + "canonical-u32": 1, + "canonical-str": 1, "int-valued-ta": 0, "spec-abi-entry": 0, "spec-abi-taptr-slot": 0 }, "candidates": { "ptr-shape": 0, - "ptr-numarray": 0, - "canonical-slot": 0, + "ptr-numarray": 1, + "canonical-slot": 5, "int-valued-ta": 0, "spec-abi": 0 }, - "unconsumed_mechanisms": {} + "unconsumed_mechanisms": {}, + "consumption_sites": {} }, { "name": "batch", @@ -217,7 +219,7 @@ "ptr-shape": 2, "ptr-shape-consumed": 1, "ptr-numarray": 0, - "canonical-i32": 3, + "canonical-i32": 5, "canonical-u32": 0, "canonical-str": 0, "int-valued-ta": 0, @@ -273,7 +275,7 @@ "ptr-shape": 0, "ptr-shape-consumed": 0, "ptr-numarray": 0, - "canonical-i32": 0, + "canonical-i32": 2, "canonical-u32": 0, "canonical-str": 0, "int-valued-ta": 0, @@ -298,7 +300,7 @@ "ptr-shape": 0, "ptr-shape-consumed": 0, "ptr-numarray": 1, - "canonical-i32": 0, + "canonical-i32": 2, "canonical-u32": 0, "canonical-str": 0, "int-valued-ta": 0, @@ -323,7 +325,7 @@ "ptr-shape": 0, "ptr-shape-consumed": 0, "ptr-numarray": 1, - "canonical-i32": 0, + "canonical-i32": 2, "canonical-u32": 0, "canonical-str": 0, "int-valued-ta": 0, @@ -348,7 +350,7 @@ "ptr-shape": 0, "ptr-shape-consumed": 0, "ptr-numarray": 0, - "canonical-i32": 0, + "canonical-i32": 1, "canonical-u32": 0, "canonical-str": 0, "int-valued-ta": 0, @@ -373,7 +375,7 @@ "ptr-shape": 0, "ptr-shape-consumed": 0, "ptr-numarray": 0, - "canonical-i32": 0, + "canonical-i32": 2, "canonical-u32": 0, "canonical-str": 0, "int-valued-ta": 0, @@ -398,7 +400,7 @@ "ptr-shape": 1, "ptr-shape-consumed": 0, "ptr-numarray": 0, - "canonical-i32": 0, + "canonical-i32": 2, "canonical-u32": 0, "canonical-str": 0, "int-valued-ta": 0, @@ -425,9 +427,9 @@ "ptr-shape": 0, "ptr-shape-consumed": 0, "ptr-numarray": 0, - "canonical-i32": 0, + "canonical-i32": 2, "canonical-u32": 0, - "canonical-str": 0, + "canonical-str": 1, "int-valued-ta": 0, "spec-abi-entry": 0, "spec-abi-taptr-slot": 0 @@ -450,7 +452,7 @@ "ptr-shape": 1, "ptr-shape-consumed": 0, "ptr-numarray": 0, - "canonical-i32": 0, + "canonical-i32": 2, "canonical-u32": 0, "canonical-str": 0, "int-valued-ta": 0, @@ -481,7 +483,7 @@ "ptr-shape": 0, "ptr-shape-consumed": 0, "ptr-numarray": 1, - "canonical-i32": 0, + "canonical-i32": 3, "canonical-u32": 0, "canonical-str": 0, "int-valued-ta": 0, @@ -506,7 +508,7 @@ "ptr-shape": 0, "ptr-shape-consumed": 0, "ptr-numarray": 0, - "canonical-i32": 0, + "canonical-i32": 3, "canonical-u32": 0, "canonical-str": 0, "int-valued-ta": 0, @@ -531,7 +533,7 @@ "ptr-shape": 1, "ptr-shape-consumed": 0, "ptr-numarray": 0, - "canonical-i32": 0, + "canonical-i32": 2, "canonical-u32": 0, "canonical-str": 0, "int-valued-ta": 0, @@ -558,7 +560,7 @@ "ptr-shape": 0, "ptr-shape-consumed": 0, "ptr-numarray": 0, - "canonical-i32": 0, + "canonical-i32": 2, "canonical-u32": 0, "canonical-str": 0, "int-valued-ta": 0, @@ -583,7 +585,7 @@ "ptr-shape": 0, "ptr-shape-consumed": 0, "ptr-numarray": 0, - "canonical-i32": 0, + "canonical-i32": 2, "canonical-u32": 0, "canonical-str": 0, "int-valued-ta": 0, @@ -608,7 +610,7 @@ "ptr-shape": 0, "ptr-shape-consumed": 0, "ptr-numarray": 0, - "canonical-i32": 0, + "canonical-i32": 6, "canonical-u32": 0, "canonical-str": 0, "int-valued-ta": 0, @@ -633,7 +635,7 @@ "ptr-shape": 0, "ptr-shape-consumed": 0, "ptr-numarray": 0, - "canonical-i32": 3, + "canonical-i32": 5, "canonical-u32": 0, "canonical-str": 0, "int-valued-ta": 0, @@ -658,7 +660,7 @@ "ptr-shape": 0, "ptr-shape-consumed": 0, "ptr-numarray": 1, - "canonical-i32": 0, + "canonical-i32": 4, "canonical-u32": 0, "canonical-str": 0, "int-valued-ta": 0, @@ -676,5 +678,5 @@ "consumption_sites": {} } ], - "generated_at": "2026-07-31T05:53:44.935156Z" + "generated_at": "2026-07-31T06:45:01.246943Z" } diff --git a/changelog.d/7121-module-init-repsel.md b/changelog.d/7121-module-init-repsel.md new file mode 100644 index 0000000000..b974a9670c --- /dev/null +++ b/changelog.d/7121-module-init-repsel.md @@ -0,0 +1,85 @@ +Module-init and program-entry bodies now take part in canonical +representation selection. Before this, `codegen/entry.rs` hard-coded +`repsel_context_allows_canonical_{i32,str}: false` for both entry contexts, so +**every** top-level local stayed boxed no matter what the per-value rules said +— and 9 of the 17 `benchmarks/suite` workloads put their entire hot loop at +module top level. + +## Why the exclusion existed + +Nothing recorded a hazard. Phase 1 (#6903) introduced it with the comment +*"top-level locals interleave with import/init machinery; the win lives in +function bodies"*, and Phase 3a (#6909) copied it for `Str`. It is a scoping +decision, and the premise is false for the corpus Perry is measured on: +`08_string_concat`'s `result` is a fully eligible canonical-`Str` local doing +`+=` self-append in a loop — Phase 3a's own motivating pattern — blocked by +nothing else. + +An entry body is lowered by the same `stmt::lower_stmts_inner` as a function +body, into an ordinary straight-line LLVM function. Every entry-only property +is already covered by a value-level rule or is not a difference at all: module +globals (`@perry_global_*`) and boxed closure captures each have their own +pre-existing exclusion; entry allocas, the module-init shadow frame (which +binds pointer-typed locals only), in-frame `await` polling, id-refreshing init +unroll, and the `@perry_global_*`-only entry emission are all unchanged. The +audit is on `expr::MODULE_INIT_CONTEXT`. + +`Ptr` stays excluded in entry bodies, now on its own flag +(`repsel_context_allows_ptr_shape`). Phase 5a reused the canonical-i32 gate, so +lifting it would silently have enabled guard-free receiver access there as a +side effect of an unrelated phase — and #6991 is a live rooting bug in exactly +that position, where a compiled receiver goes stale across the +`globalThis`-population collection that runs around module init. Behaviour for +`Ptr` is bit-identical everywhere, and `module_init_context` remains its +named unconsumed mechanism in `--opt-report`. + +## What it converts + +Wider sweep, 452 files (`test-files/test_gap_*.ts` + the app-pattern kernels), +canonical-slot verdicts: + +| verdict | `e7bc73bd6` | `4d3ddc9a3` (#7122) | this PR | +|---|---|---|---| +| selected `I32` | 131 | 168 | **305** | +| selected `Str` | 67 | 67 | **247** | +| selected `U32` | 2 | 2 | **6** | +| denied `module_init_context` | 289 | 321 | **0** | +| denied `not_index_used_or_bounded` | 201 | 121 | 121 | +| denied `closure_referenced` / `declared_bigint` | 14 / 5 | 14 / 5 | 14 / 5 | + +**All 321 `module_init_context` denials convert to selections, and the other +three denial populations are unchanged** — selected total 237 → 558, exactly ++321. No residue, no fourth mechanism. #7122 predicted that its loop-induction +rule proves 22 more locals than it can promote, 18 of them blocked only by this +issue; on this wider scope that subset is the 289 → 321 column, and it lands. + +Promotion census, 25 → 26 workloads: `canonical-i32` 17 → 48, `canonical-str` +1 → 3, `canonical-u32` 1 → 2; `canonical-i32` goes from promoting in 2 of 18 +real workloads to 17 of 18. `ptr-shape` / `ptr-shape-consumed` unchanged at +7 / 3. No floor was lowered; 20 were raised. The new liveness fixture +`fixture_module_init_canonical.ts` declares no function, method or closure at +all, so its counts can only come from the module-init `FnCtx`. + +## Selected is not emitted, and this is a good illustration + +Of 39 benchmark/fixture workloads compiled with both compilers, **10 produce a +different object**. The rest shrink the pre-optimization IR by one +`alloca double`, one `sitofp` and one `fptosi` per promoted local and then +optimize to a byte-identical object — under the parallel-shadow model every +`LocalGet` already read the i32 slot, so the double slot was dead and `-O3` was +already deleting it. + +The `Str` case is a real lowering change that survives `-O3`. +`08_string_concat`'s top-level `result = result + "x"` went from two +`js_get_string_pointer_unified` calls per iteration to Phase 3a's four-arm +dispatch whose hot arm derives both handles with a bare +`and i64 …, 0xFFFF_FFFF_FFFF`. Its shadow-slot bind/store count and +write-barrier count are unchanged (9 and 3 in both arms) — canonical `Str` is +tagged-at-rest and does not move storage. + +## Also + +`note_canonical_local` reported a hard-coded `RegionKind::Function` to +`--opt-report`. That was invisible while module-init bodies could not select at +all; it now takes the region from the ambient scope, the same source `consume()` +uses, so a module-init selection renders as `module-init`. diff --git a/test-files/test_gap_repsel_module_init_canonical.ts b/test-files/test_gap_repsel_module_init_canonical.ts index 71971bf746..d9051447c1 100644 --- a/test-files/test_gap_repsel_module_init_canonical.ts +++ b/test-files/test_gap_repsel_module_init_canonical.ts @@ -20,9 +20,16 @@ // 5. GC: a top-level Str accumulator and an object graph must survive a // collection triggered in the middle of module init. // -// Run: node --experimental-strip-types test_gap_repsel_module_init_canonical.ts -// Also run with PERRY_CANONICAL_I32_LOCALS=0, PERRY_CANONICAL_STR_LOCALS=0 and -// PERRY_GC_FORCE_EVACUATE=1 — all four must agree byte-for-byte. +// Run the oracle on the Node pinned in `.node-version` at the repo root (26.5.1 +// at the time of writing) — CI reads that file via `setup-node`, and Node patch +// releases change observable output, so a different local Node will not match: +// +// node --version # must equal "v$(tr -d 'v \n' < .node-version)" +// node --experimental-strip-types test_gap_repsel_module_init_canonical.ts +// +// Also run the compiled binary under PERRY_CANONICAL_I32_LOCALS=0, +// PERRY_CANONICAL_STR_LOCALS=0 and PERRY_GC_FORCE_EVACUATE=1 — all four must +// agree with the oracle byte-for-byte. // ── 1. canonical i32 / u32 at top level ─────────────────────────────────── const LIMIT = 40; diff --git a/test-parity/gc_repsel_corpus.txt b/test-parity/gc_repsel_corpus.txt index 49c1ef7be7..a9b205bb3d 100644 --- a/test-parity/gc_repsel_corpus.txt +++ b/test-parity/gc_repsel_corpus.txt @@ -106,6 +106,16 @@ test_gap_gc_string_literal_operand_rooting # `evac_minor` arms both bite. test_gap_repsel_scalar_replaced_locals +# --- Module-init / program-entry canonical selection (#7109) ----------------- +# Not a new representation: the SAME canonical i32/u32/Str reps, selected in the +# program-entry body, which `codegen/entry.rs` excluded wholesale until #7109. +# Registered because the GC contract differs by POSITION, not by rep — a +# top-level canonical `Str` keeps its shadow-slot binding in the one frame that +# also runs the `globalThis` population and every dependency's `__init`, and a +# top-level canonical i32 has no binding at all. Both need exercising against +# the evacuating arms in that frame, not only inside a callee. +test_gap_repsel_module_init_canonical + # --- The GC-live member ------------------------------------------------------ # Every file above performs ZERO collections (measured, #6950), which makes the # GC arms inert against them. This one holds each representation's local live