From 0dc98d6f579d5b50e225ae885f73db1db1e501e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 30 Jul 2026 05:56:39 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(cli):=20--opt-report=20=E2=80=94=20sur?= =?UTF-8?q?face=20which=20values=20could=20not=20be=20statically=20typed,?= =?UTF-8?q?=20and=20why?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Perry's speed comes from proving static types and selecting unboxed representations. When a proof fails the value stays NaN-boxed and the fast paths silently do not fire — and until now the only way to find out WHICH values failed was to read LLVM IR. That blindness is not theoretical. #7034 measured the flagship representation on the object-heavy workload that motivates it and found Ptr promotes ZERO locals there; PERRY_PTR_SHAPE_LOCALS=0 vs default produced byte-identical output. A shipped representation was doing nothing on realistic code, and it was found by accident. Establishing that single fact took six compiler invocations, an IR dump, a symbol-level size A/B and a PERRY_REPSEL_DEBUG grep. perry compile batch.ts -o batch --opt-report now answers it in one command, and names the rule that denied each candidate. This is mostly surfacing, not new analysis: the collectors already know which rule rejected each candidate, they just `continue`. The change records (value, reason) at those sites, plus one aggregation layer and one renderer. Covered: Ptr locals and allocation sites (all five rules plus class admission, with the escape KIND discriminated — reassignment vs closure capture vs call argument vs return vs container element), canonical i32/u32/Str locals, and the specialized-ABI entry decision (the only param/return representation today). Reports wins as well as misses. Hotness is reported as two separate columns, loop depth AND whether the enclosing region is an iterating builtin's callback, because #7034 §8 measured that 208 of 247 guard sites in the motivating workload sit in callback bodies with zero loop depth. Collapsing them into one number would rank them last. Observational only: emitted LLVM IR is byte-identical with the flag on and off (verified on two programs). Off by default, gated by a OnceLock read of PERRY_OPT_REPORT before any reason string is formatted. Like --trace llvm it disables build/object cache reuse for its own run, since a cache hit skips codegen and there would be nothing to report; it is deliberately NOT in the object-cache key, because it changes no emitted byte. Values are reported by function + binding name. HIR keeps names through lowering but drops source positions, so there is no file:line yet; the LocalId -> Span side-table that would add it is #7036. --- benchmarks/app-patterns/kernels/batch.ts | 137 ++++ crates/perry-codegen/src/codegen/closure.rs | 8 + crates/perry-codegen/src/codegen/entry.rs | 10 + crates/perry-codegen/src/codegen/function.rs | 22 + crates/perry-codegen/src/codegen/method.rs | 10 + crates/perry-codegen/src/codegen/mod.rs | 37 + .../src/codegen/typed_abi_opt_report.rs | 98 +++ crates/perry-codegen/src/collectors/mod.rs | 1 + .../perry-codegen/src/collectors/ptr_shape.rs | 274 +++++-- .../collectors/ptr_shape_opt_report_tests.rs | 317 ++++++++ .../src/collectors/ptr_shape_report.rs | 653 +++++++++++++++ crates/perry-codegen/src/expr/slot_rep.rs | 15 + crates/perry-codegen/src/lib.rs | 1 + .../perry-codegen/src/opt_report/callbacks.rs | 151 ++++ crates/perry-codegen/src/opt_report/mod.rs | 753 ++++++++++++++++++ crates/perry-codegen/src/opt_report/render.rs | 427 ++++++++++ .../perry/src/commands/compile/build_cache.rs | 5 + .../src/commands/compile/run_pipeline.rs | 38 + crates/perry/src/commands/compile/types.rs | 30 + crates/perry/src/commands/dev.rs | 1 + crates/perry/src/commands/run/mod.rs | 1 + docs/src/cli/flags.md | 63 ++ 22 files changed, 2990 insertions(+), 62 deletions(-) create mode 100644 benchmarks/app-patterns/kernels/batch.ts create mode 100644 crates/perry-codegen/src/codegen/typed_abi_opt_report.rs create mode 100644 crates/perry-codegen/src/collectors/ptr_shape_opt_report_tests.rs create mode 100644 crates/perry-codegen/src/collectors/ptr_shape_report.rs create mode 100644 crates/perry-codegen/src/opt_report/callbacks.rs create mode 100644 crates/perry-codegen/src/opt_report/mod.rs create mode 100644 crates/perry-codegen/src/opt_report/render.rs diff --git a/benchmarks/app-patterns/kernels/batch.ts b/benchmarks/app-patterns/kernels/batch.ts new file mode 100644 index 0000000000..470eeade9b --- /dev/null +++ b/benchmarks/app-patterns/kernels/batch.ts @@ -0,0 +1,137 @@ +// Pattern: batch-process a stream of records — map to reshaped objects, +// sort by a derived key, group/reduce into summary rows. Models the +// object/property-heavy middle of a request handler, an ETL step, or a +// report builder: allocation-heavy, property-access-dominated, and every +// record *escapes* its producing scope (pushed, returned, or passed to a +// callback). +// +// This is the workload #7034 measured `Ptr` promotion against and +// found **zero** promoted locals. It is committed here so that +// `perry --opt-report` reproduces that finding in one command +// and names the rule that denied each candidate. Keep the escape shapes +// below intact — they are the point of the fixture, not incidental style. + +const N = 40_000; + +class Row { + id: number; + bucket: string; + weight: number; + score: number; + + constructor(id: number, bucket: string, weight: number) { + this.id = id; + this.bucket = bucket; + this.weight = weight; + this.score = 0; + } + + rescore(factor: number): number { + this.score = this.weight * factor + this.id % 7; + return this.score; + } +} + +interface Shaped { + key: string; + value: number; + tag: string; +} + +interface Summary { + bucket: string; + count: number; + total: number; + peak: number; +} + +const BUCKETS = ["alpha", "beta", "gamma", "delta"]; + +// Producer: every `new Row(...)` escapes into the array (rule 2 — the +// local is used as a call argument). +function buildRows(n: number): Row[] { + const rows: Row[] = []; + for (let i = 0; i < n; i++) { + const row = new Row(i, BUCKETS[i % 4], (i % 97) * 0.5); + rows.push(row); + } + return rows; +} + +// Reshape: the `.map(x => ({...}))` idiom — the record is produced in a +// closure body and returned, never bound to a local at all. +function shape(rows: Row[]): Shaped[] { + return rows.map((r) => ({ + key: r.bucket + ":" + r.id, + value: r.rescore(1.5), + tag: r.id % 2 === 0 ? "even" : "odd", + })); +} + +// Sort by a derived key — the comparator is a closure body invoked once +// per comparison, with no loop of its own. +function ranked(items: Shaped[]): Shaped[] { + const copy = items.slice(); + copy.sort((a, b) => { + if (a.value !== b.value) { + return b.value - a.value; + } + return a.key < b.key ? -1 : a.key > b.key ? 1 : 0; + }); + return copy; +} + +// Reduce into per-bucket summaries — the accumulator object is returned +// from the reducer (rule 2 — return disqualifies). +function summarize(rows: Row[]): Summary[] { + const byBucket: Summary[] = []; + for (let b = 0; b < BUCKETS.length; b++) { + const s: Summary = { + bucket: BUCKETS[b], + count: 0, + total: 0, + peak: 0, + }; + byBucket.push(s); + } + rows.reduce((acc: Summary[], r: Row) => { + const idx = BUCKETS.indexOf(r.bucket); + const slot = acc[idx]; + slot.count = slot.count + 1; + slot.total = slot.total + r.score; + if (r.score > slot.peak) { + slot.peak = r.score; + } + return acc; + }, byBucket); + return byBucket; +} + +// Fold the summaries into one totals record and hand it back — the single +// most common record-producing idiom in real TypeScript (#7034 §4), and +// another rule-2 denial: the accumulator is returned. +function totalsRow(summaries: Summary[]): Row { + const acc = new Row(0, "acc", 0); + for (let i = 0; i < summaries.length; i++) { + acc.weight = acc.weight + summaries[i].total; + acc.score = acc.score + summaries[i].peak; + } + return acc; +} + +const rows = buildRows(N); +const shaped = shape(rows); +const top = ranked(shaped); +const summaries = summarize(rows); + +let checksum = 0; +for (let i = 0; i < 16; i++) { + checksum = checksum + top[i].value; +} +for (let i = 0; i < summaries.length; i++) { + checksum = checksum + summaries[i].count + summaries[i].peak; +} +const totals = totalsRow(summaries); +checksum = checksum + totals.weight + totals.score; + +console.log("checksum: " + checksum.toFixed(4)); diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 23604f2ea0..504323ffd6 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -731,6 +731,14 @@ pub(super) fn compile_closure( .collect(); let flat_const_ids: std::collections::HashSet = cross_module.flat_const_arrays.keys().copied().collect(); + // `--opt-report` (#6952): closures are the position #7034 §8 found most + // of the guard sites in, so they get their own scope with the source + // function name when one is known. + let opt_report_name = func_names + .get(&func_id) + .cloned() + .unwrap_or_else(|| format!("closure#{func_id}")); + let _opt_report_scope = crate::opt_report::enter_closure(&opt_report_name, func_id); let native_facts = crate::collectors::collect_native_region_fact_graph( body, &[], diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index d848f2f09a..308e861001 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -649,6 +649,11 @@ pub(super) fn compile_module_entry( .chain(cross_module.returns_int_functions.iter()) .copied() .collect(); + // `--opt-report` (#6952) attribution scope; no-op when off. + let _opt_report_scope = crate::opt_report::enter_region( + "module_init", + crate::opt_report::RegionKind::ModuleInit, + ); let main_native_facts = crate::collectors::collect_native_region_fact_graph( &hir.init, &[], @@ -1273,6 +1278,11 @@ pub(super) fn compile_module_entry( .chain(cross_module.returns_int_functions.iter()) .copied() .collect(); + // `--opt-report` (#6952) attribution scope; no-op when off. + let _opt_report_scope = crate::opt_report::enter_region( + "module_init", + crate::opt_report::RegionKind::ModuleInit, + ); let init_native_facts = crate::collectors::collect_native_region_fact_graph( &hir.init, &[], diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index c72cd1ad8a..03014eba2a 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -592,6 +592,10 @@ pub(super) fn compile_function( .collect() }) .unwrap_or_default(); + // `--opt-report` (#6952): attribute every representation decision the + // collectors below make to this function. No-op when the report is off. + let _opt_report_scope = + crate::opt_report::enter_region(&f.name, crate::opt_report::RegionKind::Function); let native_facts = crate::collectors::collect_native_region_fact_graph_with_spec_lens( &f.body, &f.params, @@ -609,6 +613,24 @@ pub(super) fn compile_function( ); if let Some(plan) = spec_entry { + // `--opt-report` (#6952): the spec-ABI win, recorded at the same site + // as the PERRY_REPSEL_DEBUG line so the two cannot diverge. + if crate::opt_report::enabled() { + crate::opt_report::select( + crate::opt_report::Position::Param, + "(parameters + return)", + None, + crate::opt_report::Analysis::SpecAbi, + &plan + .reps + .iter() + .map(|r| r.label().to_string()) + .collect::>() + .join(","), + 0, + Some(format!("specialized entry for {}", f.name)), + ); + } if std::env::var("PERRY_REPSEL_DEBUG").as_deref() == Ok("1") { eprintln!( "repsel: spec entry '{}' tuple=[{}] [{}]", diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index 69672fb5ac..c5548c7318 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -370,6 +370,11 @@ pub(super) fn compile_method( .collect(); let flat_const_ids: std::collections::HashSet = cross_module.flat_const_arrays.keys().copied().collect(); + // `--opt-report` (#6952) attribution scope; no-op when off. + let _opt_report_scope = crate::opt_report::enter_region( + &format!("{}.{}", class.name, method.name), + crate::opt_report::RegionKind::Method, + ); let native_facts = crate::collectors::collect_native_region_fact_graph( &method.body, &[], @@ -1409,6 +1414,11 @@ pub(super) fn compile_static_method( .collect(); let flat_const_ids: std::collections::HashSet = cross_module.flat_const_arrays.keys().copied().collect(); + // `--opt-report` (#6952) attribution scope; no-op when off. + let _opt_report_scope = crate::opt_report::enter_region( + &format!("{}.{} (static)", class.name, f.name), + crate::opt_report::RegionKind::Method, + ); let native_facts = crate::collectors::collect_native_region_fact_graph( &f.body, &[], diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index df68023ec1..f5cda67240 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -58,6 +58,7 @@ mod opts; mod spec_abi; mod string_pool; mod typed_abi; +mod typed_abi_opt_report; pub(crate) use closure::emit_typed_string_capture_guard; pub use helpers::resolve_target_triple; @@ -127,6 +128,33 @@ fn record_typed_clone_rejection( if !should_record_typed_clone_rejection(reason) { return; } + let source_function = source_function.into(); + // `--opt-report` (#6952): surface the specialized-ABI (RFC Phase 2) + // decision, which is the only place params and returns get a + // representation today. The `typed_*_clone_decision` consumers are the + // older per-type clone mechanism and would report the same function up + // to four times, so they stay out of the report and keep going to the + // native-reps artifact only. + if consumer == "spec_abi_entry_decision" && crate::opt_report::enabled() { + let (why, tier, issue) = reason.opt_report_reason(); + crate::opt_report::deny_named( + &source_function, + crate::opt_report::RegionKind::Function, + crate::opt_report::Denial { + position: crate::opt_report::Position::Param, + name: "(parameters + return)", + local_id: None, + analysis: crate::opt_report::Analysis::SpecAbi, + rule: reason.as_str(), + reason: why, + tier, + issue, + loop_depth: 0, + detail: None, + byte_offset: None, + }, + ); + } records.push(crate::native_value::typed_clone_rejection_record( source_function, consumer, @@ -163,6 +191,15 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // set-per-module discipline as the outline gate above. helpers::set_jscvt_for_target(&triple); + // `--opt-report` (#6952): mark the closures that are iterating-builtin + // callbacks before any region is lowered, so their denials carry the + // per-element hotness column. No-op when the report is off. + crate::opt_report::scan_module(hir); + // Module-wide fallback attribution scope. Per-region scopes nest inside + // it and restore it on drop, so decisions taken outside any region (the + // specialized-ABI entry decision) still know their module. + let _opt_report_module_scope = crate::opt_report::enter_module(&hir.name); + let mut llmod = LlModule::new_with_fp_flags(&triple, fp_flags); // Null guard global: a zeroed i32 used as a safe dereference target // when a NaN-unboxed pointer is null/invalid. Prevents segfaults from diff --git a/crates/perry-codegen/src/codegen/typed_abi_opt_report.rs b/crates/perry-codegen/src/codegen/typed_abi_opt_report.rs new file mode 100644 index 0000000000..f60bf80a1c --- /dev/null +++ b/crates/perry-codegen/src/codegen/typed_abi_opt_report.rs @@ -0,0 +1,98 @@ +//! `--opt-report` (#6952) rendering for specialized-ABI rejections. +//! +//! Split out of `typed_abi.rs` for the 2000-line file-size gate. This is an +//! inherent impl on `TypedCloneRejectionReason`, so it can live in any module +//! of the crate; the variant list itself stays next to the decision logic. + +use super::typed_abi::TypedCloneRejectionReason; + +impl TypedCloneRejectionReason { + /// `--opt-report` (#6952) rendering of a **specialized-ABI entry** + /// rejection: a human expansion of the variant plus who can act on it. + /// + /// Extends this existing vocabulary rather than paralleling it — the + /// variant IS the rule, so the report cites `as_str()` as the rule name + /// and this as the explanation. Only the `Spec*` and shared-precondition + /// variants that the spec-ABI decision can actually produce are given + /// bespoke text; the rest fall through to a generic line. + pub(crate) fn opt_report_reason( + self, + ) -> (&'static str, crate::opt_report::Tier, Option<&'static str>) { + use crate::opt_report::Tier; + match self { + Self::SpecTupleUnproven => ( + "no call site proved a representation tuple for this \ + function's parameters, so every call uses the boxed ABI and \ + re-unboxes on entry. Either the arguments are genuinely \ + polymorphic, or their types are outside what the specialized \ + ABI covers today (i32/u32, f64, bool, string, typed-array \ + pointer) — object, array and union parameters are not \ + covered yet, so a correct annotation does not always help.", + Tier::CompilerLimitation, + None, + ), + Self::SpecBudgetExceeded => ( + "the module-wide specialized-entry budget \ + (PERRY_SPECIALIZED_ABI_MAX) was exhausted before this \ + function's turn; it routes to the boxed entry.", + Tier::CompilerLimitation, + None, + ), + Self::SpecTypedCloneOverlap => ( + "the function already carries a typed_abi clone family, which \ + is mutually exclusive with a specialized entry in this phase.", + Tier::CompilerLimitation, + None, + ), + Self::SpecReadsDynamicThis => ( + "the callee reads dynamic `this`, so direct call sites must \ + juggle the implicit-this slot and cannot take the raw ABI.", + Tier::CompilerLimitation, + None, + ), + Self::AsyncOrGenerator => ( + "async and generator bodies route their locals through shared \ + cells (the async-to-generator transform), which the \ + specialized ABI must not touch.", + Tier::CompilerLimitation, + None, + ), + Self::I64Specialized => ( + "the function is already i64-specialized, which takes \ + precedence over a representation-tuple entry.", + Tier::CompilerLimitation, + None, + ), + Self::Captures | Self::CapturesThis | Self::CapturesNewTarget => ( + "the function captures from an enclosing scope; captures are \ + passed through the closure environment, not the raw ABI.", + Tier::Fixable, + None, + ), + Self::ParamDefault => ( + "a parameter has a default value, so the callee cannot assume \ + a fixed argument count on the raw entry.", + Tier::Fixable, + None, + ), + Self::RestParam => ( + "a rest parameter makes the argument count dynamic, so there \ + is no fixed representation tuple.", + Tier::Fixable, + None, + ), + Self::ArgumentsObject => ( + "the body reads `arguments`, which requires the boxed \ + argument vector.", + Tier::Fixable, + None, + ), + _ => ( + "the specialized-ABI precondition named by the rule did not \ + hold; the function keeps the boxed entry ABI.", + Tier::CompilerLimitation, + None, + ), + } + } +} diff --git a/crates/perry-codegen/src/collectors/mod.rs b/crates/perry-codegen/src/collectors/mod.rs index ea5139af86..17f1af31ff 100644 --- a/crates/perry-codegen/src/collectors/mod.rs +++ b/crates/perry-codegen/src/collectors/mod.rs @@ -26,6 +26,7 @@ mod pointer_locals; mod proven_this; mod ptr_numarray; mod ptr_shape; +mod ptr_shape_report; mod refs; mod scalar_method_dispatch; mod scalar_methods; diff --git a/crates/perry-codegen/src/collectors/ptr_shape.rs b/crates/perry-codegen/src/collectors/ptr_shape.rs index 76af050c79..346985e32b 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape.rs @@ -88,7 +88,10 @@ use std::collections::{HashMap, HashSet}; use perry_hir::{Class, Expr, Stmt}; +use super::ptr_shape_report as report; +use super::ptr_shape_report::ShapeDenial; use super::ModuleDispatchFacts; +use crate::opt_report; /// `PERRY_PTR_SHAPE_LOCALS` gate. Enabled by default; `=0`/`off`/`false` /// disables shape-proven pointer-local selection (every access keeps today's @@ -156,7 +159,33 @@ pub(crate) fn expr_is_shape_barrier(expr: &Expr) -> bool { /// Compile-time visibility: one stderr line per shape-proven local, plus a /// process-wide running count. Only under `PERRY_REPSEL_DEBUG=1`. -fn note_ptr_shape_local(id: u32, fact: &PtrShapeLocal) { +/// +/// Also feeds the `--opt-report` win column (#6952) — the two mechanisms +/// share this one call site so a future proof cannot appear in one and not +/// the other. +fn note_ptr_shape_local( + id: u32, + fact: &PtrShapeLocal, + names: &HashMap, + depths: &HashMap, +) { + if opt_report::enabled() { + let fallback = format!(""); + let name = names.get(&id).map(String::as_str).unwrap_or(&fallback); + opt_report::select( + opt_report::Position::Local, + name, + Some(id), + opt_report::Analysis::PtrShape, + "Ptr", + depths.get(&id).copied().unwrap_or(0), + Some(format!( + "class {} ({} numeric field(s) proven)", + fact.class_name, + fact.numeric_fields.len() + )), + ); + } if !repsel_debug_enabled() { return; } @@ -169,6 +198,32 @@ fn note_ptr_shape_local(id: u32, fact: &PtrShapeLocal) { ); } +/// Record every candidate the collector will not even reach, on an +/// early-bail path (env gate off, or the rule-5 module-wide barrier). +/// +/// Runs only under `--opt-report`; it re-derives the candidate seeds purely +/// to name them, and returns nothing the collector consumes — the bail-out +/// itself is unchanged. +fn report_early_bail( + stmts: &[Stmt], + boxed_vars: &HashSet, + module_globals: &HashMap, + denial: ShapeDenial, +) { + if !opt_report::enabled() { + return; + } + let names = report::local_names(stmts); + let depths = report::loop_depths(stmts); + let seeds = report::candidate_seeds(stmts, boxed_vars, module_globals); + for (id, class_name) in &seeds { + report::deny_local(*id, &names, &depths, Some(class_name), denial); + } + for site in report::unbound_new_sites(stmts) { + report::deny_alloc_site(&site); + } +} + /// Entry point: collect the shape-proven pointer locals of one lowered region. /// /// `not_bigint_locals` feeds the numeric-field proof (a `Sub`/`Div`/bitwise @@ -181,9 +236,29 @@ pub(crate) fn collect_shape_proven_ptr_locals( module_dispatch: &ModuleDispatchFacts, not_bigint_locals: &HashSet, ) -> HashMap { - if !ptr_shape_locals_enabled() || module_dispatch.has_shape_barrier_sites() { + if !ptr_shape_locals_enabled() { + report_early_bail(stmts, boxed_vars, module_globals, report::GATE_DISABLED); + return HashMap::new(); + } + if module_dispatch.has_shape_barrier_sites() { + report_early_bail(stmts, boxed_vars, module_globals, report::MODULE_BARRIER); return HashMap::new(); } + // `--opt-report` (#6952): binding names and loop depths for the values + // this pass is about to accept or deny. Both walks are skipped entirely + // when the report is off. + let (names, depths) = if opt_report::enabled() { + (report::local_names(stmts), report::loop_depths(stmts)) + } else { + (HashMap::new(), HashMap::new()) + }; + if opt_report::enabled() { + // Allocations that are never bound to a local — rule 1 can never see + // them, and on real code they are the majority (#7034 §4). + for site in report::unbound_new_sites(stmts) { + report::deny_alloc_site(&site); + } + } // Pass 1: `Stmt::Let { init: New }` candidates, same seed as scalar // replacement (excludes boxed and module-global locals — which also // excludes async/generator bodies, whose locals are boxed by the @@ -195,7 +270,15 @@ pub(crate) fn collect_shape_proven_ptr_locals( } // Class-level admission BEFORE the use walk so the walk's chain-field // membership tests are meaningful. - candidates.retain(|_, class_name| chain_admissible(classes, class_name)); + candidates.retain(|id, class_name| { + match report::admission_cause(classes, class_name, &chain_classes(classes, class_name)) { + None => true, + Some(cause) => { + report::deny_local(*id, &names, &depths, Some(class_name), cause); + false + } + } + }); if candidates.is_empty() { return HashMap::new(); } @@ -241,6 +324,8 @@ pub(crate) fn collect_shape_proven_ptr_locals( method_calls: HashMap::new(), new_args: HashMap::new(), const_local_inits: HashMap::new(), + disq_reasons: HashMap::new(), + escape_ctx: report::ESC_BARE_REFERENCE, }; walk.walk_stmts(stmts); let UseWalk { @@ -250,11 +335,30 @@ pub(crate) fn collect_shape_proven_ptr_locals( method_calls, new_args, const_local_inits, + disq_reasons, .. } = walk; let mut out = HashMap::new(); + // `--opt-report`: one closure so every `continue` below has a matching + // one-line recording. Behaviour is unchanged — `deny` is a no-op when + // the report is off. + let deny = |id: &u32, class_name: &String, why: ShapeDenial| { + report::deny_local(*id, &names, &depths, Some(class_name), why); + }; 'cand: for (id, class_name) in &candidates { - if disqualified.contains(id) || let_counts.get(id).copied().unwrap_or(0) != 1 { + if disqualified.contains(id) { + deny( + id, + class_name, + disq_reasons + .get(id) + .copied() + .unwrap_or(report::ESC_BARE_REFERENCE), + ); + continue; + } + if let_counts.get(id).copied().unwrap_or(0) != 1 { + deny(id, class_name, report::MULTIPLE_LET); continue; } // Every alias of this root must itself be single-Let (a re-declared @@ -263,6 +367,7 @@ pub(crate) fn collect_shape_proven_ptr_locals( .iter() .any(|(m, r)| r == id && m != id && let_counts.get(m).copied().unwrap_or(0) != 1) { + deny(id, class_name, report::ALIAS_NOT_SINGLE_LET); continue; } let chain = chain_classes(classes, class_name); @@ -282,23 +387,28 @@ pub(crate) fn collect_shape_proven_ptr_locals( allow_this_in_store_values: false, }; if !analysis.ctor_chain_safe() { + deny(id, class_name, report::THIS_ESCAPE); continue; } let called = method_calls.get(id); if let Some(called) = called { if !module_dispatch.prototype_is_stable(classes, class_name) { + deny(id, class_name, report::UNSTABLE_PROTOTYPE); continue; } for m in called.keys() { if fields.contains(m.as_str()) { // A name that is both a field and a method is ambiguous // under own-property shadowing — bail. + deny(id, class_name, report::FIELD_METHOD_AMBIGUITY); continue 'cand; } let Some((owner, func)) = methods.get(m.as_str()) else { + deny(id, class_name, report::ESC_UNRESOLVED_METHOD); continue 'cand; }; if !analysis.method_safe(owner, func) { + deny(id, class_name, report::METHOD_THIS_ESCAPE); continue 'cand; } } @@ -327,7 +437,7 @@ pub(crate) fn collect_shape_proven_ptr_locals( class_name: class_name.clone(), numeric_fields, }; - note_ptr_shape_local(*id, &fact); + note_ptr_shape_local(*id, &fact, &names, &depths); // Aliases carry the same fact: they hold the same object, their slots // are equally shadow-bound, and access sites key on the local they // actually reference. @@ -422,38 +532,21 @@ pub(super) fn chain_classes<'a>( out } +/// Class-level admission. Delegates to +/// [`super::ptr_shape_report::admission_cause`], which enumerates the same +/// disqualifiers and additionally *names* the first one that fired so +/// `--opt-report` can report it. Keeping one implementation means the gate +/// and the explanation can never disagree. +/// +/// The disqualifiers are: an empty/unresolvable chain, a getter or setter, a +/// computed member or computed field key, a dynamic or lexically-shadowed +/// `extends`, an `extends` ClassId with no resolvable `extends_name`, a +/// native base, and (from the shipped scalar-replacement rejections) a +/// built-in Error base or an unmodeled base — the latter two install fields +/// or stamp their method surface as own properties at run time. pub(super) fn chain_admissible(classes: &HashMap, class_name: &str) -> bool { let chain = chain_classes(classes, class_name); - if chain.is_empty() { - return false; - } - for class in &chain { - if class.extends_expr.is_some() - || class.native_extends.is_some() - || class.heritage_lexically_shadowed - || !class.getters.is_empty() - || !class.setters.is_empty() - || !class.computed_members.is_empty() - || class.fields.iter().any(|f| f.key_expr.is_some()) - { - return false; - } - // `extends` (ClassId) without a resolvable `extends_name` means the - // parent is not statically walkable here. - if class.extends.is_some() && class.extends_name.is_none() { - return false; - } - } - // Reuse the shipped scalar-replacement chain rejections: built-in Error - // bases install fields at runtime; unmodeled/native bases stamp their - // method surface as own properties. - let class = chain[0]; - if super::this_as_value::class_chain_extends_builtin_error(class, classes) - || super::this_as_value::class_chain_has_unmodeled_base(class, classes) - { - return false; - } - true + super::ptr_shape_report::admission_cause(classes, class_name, &chain).is_none() } pub(super) fn chain_field_names(chain: &[&Class]) -> HashSet { @@ -509,6 +602,14 @@ struct UseWalk<'a> { /// re-declared id is poisoned to `None`). Lets the numeric-field proof /// chase one level through `const v = i * 0.5`-style temps. const_local_inits: HashMap>, + /// `--opt-report` (#6952): root candidate -> the FIRST use that + /// disqualified it. Purely observational — `disqualified` is the set the + /// proof consults; this only records why. + disq_reasons: HashMap, + /// The escape kind a bare `LocalGet` in the current position implies. + /// Parent arms narrow it (`return`, call argument, array element, …) so + /// the report can say *how* the object escaped, not just that it did. + escape_ctx: ShapeDenial, } impl<'a> UseWalk<'a> { @@ -517,12 +618,36 @@ impl<'a> UseWalk<'a> { self.roots.get(&id).copied() } - fn disq(&mut self, id: u32) { + fn disq(&mut self, id: u32, why: ShapeDenial) { if let Some(root) = self.tracked_root(id) { self.disqualified.insert(root); + self.note_reason(root, why); } } + /// Disqualify a root that has already been resolved. + fn disq_root(&mut self, root: u32, why: ShapeDenial) { + self.disqualified.insert(root); + self.note_reason(root, why); + } + + /// First reason wins: it is the use the developer will find first, and + /// later uses are usually consequences of the same escape. + fn note_reason(&mut self, root: u32, why: ShapeDenial) { + if !opt_report::enabled() { + return; + } + self.disq_reasons.entry(root).or_insert(why); + } + + /// Run `f` with the bare-reference escape kind narrowed to `why`. + fn with_ctx(&mut self, why: ShapeDenial, f: impl FnOnce(&mut Self)) { + let previous = self.escape_ctx; + self.escape_ctx = why; + f(self); + self.escape_ctx = previous; + } + fn candidate_chain_has_field(&self, root: u32, property: &str) -> bool { let Some(class_name) = self.candidates.get(&root) else { return false; @@ -553,7 +678,7 @@ impl<'a> UseWalk<'a> { } // A candidate whose Let init is not the New (var-redecl // seed) is not provenance-stable. - self.disq(*id); + self.disq(*id, report::LET_INIT_NOT_NEW); } else if !self.roots.contains_key(id) { // Plain local: remember single-Let const inits for the // numeric proof; poison re-declared ids. @@ -588,17 +713,18 @@ impl<'a> UseWalk<'a> { Some(Expr::LocalGet(src)) if self.tracked_root(*src) == Some(root) => { return; } - _ => self.disqualified.insert(root), - }; + _ => self.disq_root(root, report::ALIAS_NOT_SINGLE_LET), + } } if let Some(e) = init { self.walk_expr(e); } } - Stmt::Expr(e) | Stmt::Throw(e) => self.walk_expr(e), + Stmt::Expr(e) => self.walk_expr(e), + Stmt::Throw(e) => self.with_ctx(report::ESC_THROWN, |w| w.walk_expr(e)), Stmt::Return(opt) => { if let Some(e) = opt { - self.walk_expr(e); + self.with_ctx(report::ESC_RETURN, |w| w.walk_expr(e)); } } Stmt::If { @@ -677,7 +803,7 @@ impl<'a> UseWalk<'a> { if let Expr::LocalGet(id) = object.as_ref() { if let Some(root) = self.tracked_root(*id) { if !self.candidate_chain_has_field(root, property) { - self.disqualified.insert(root); + self.disq_root(root, report::ESC_UNDECLARED_PROPERTY); } return; } @@ -695,7 +821,7 @@ impl<'a> UseWalk<'a> { if let Expr::LocalGet(id) = object.as_ref() { if let Some(root) = self.tracked_root(*id) { if !self.candidate_chain_has_field(root, property) { - self.disqualified.insert(root); + self.disq_root(root, report::ESC_UNDECLARED_PROPERTY); } else { self.field_stores .entry(root) @@ -705,7 +831,7 @@ impl<'a> UseWalk<'a> { // The value walk is position-aware: a field read of the // same object is safe; a BARE reference to it (e.g. // `o.self = o`) hits the LocalGet arm and escapes. - self.walk_expr(value); + self.with_ctx(report::ESC_ELEMENT, |w| w.walk_expr(value)); return; } } @@ -718,7 +844,7 @@ impl<'a> UseWalk<'a> { if let Expr::LocalGet(id) = object.as_ref() { if let Some(root) = self.tracked_root(*id) { if !self.candidate_chain_has_field(root, property) { - self.disqualified.insert(root); + self.disq_root(root, report::ESC_UNDECLARED_PROPERTY); } else { self.field_stores .entry(root) @@ -743,14 +869,14 @@ impl<'a> UseWalk<'a> { if id == rid { if let Some(root) = self.tracked_root(*id) { if !self.candidate_chain_has_field(root, property) { - self.disqualified.insert(root); + self.disq_root(root, report::ESC_UNDECLARED_PROPERTY); } else { self.field_stores .entry(root) .or_default() .push((property.clone(), StoreValue::Direct(value))); } - self.walk_expr(value); + self.with_ctx(report::ESC_ELEMENT, |w| w.walk_expr(value)); return; } } @@ -774,7 +900,7 @@ impl<'a> UseWalk<'a> { let chain = chain_classes(self.classes, class_name); let resolvable = chain_method_map(&chain).contains_key(property); if !resolvable { - self.disqualified.insert(root); + self.disq_root(root, report::ESC_UNRESOLVED_METHOD); } else { self.method_calls .entry(root) @@ -786,7 +912,7 @@ impl<'a> UseWalk<'a> { for a in args { // Position-aware: `o.m(o.field)` is safe, // `o.m(o)` escapes via the LocalGet arm. - self.walk_expr(a); + self.with_ctx(report::ESC_CALL_ARGUMENT, |w| w.walk_expr(a)); } return; } @@ -794,7 +920,19 @@ impl<'a> UseWalk<'a> { } self.walk_expr(callee); for a in args { - self.walk_expr(a); + self.with_ctx(report::ESC_CALL_ARGUMENT, |w| w.walk_expr(a)); + } + } + // A tracked member passed to a constructor escapes as an argument. + Expr::New { args, .. } => { + for a in args { + self.with_ctx(report::ESC_CALL_ARGUMENT, |w| w.walk_expr(a)); + } + } + // Container literals: a tracked member becomes an element. + Expr::Array(items) => { + for i in items { + self.with_ctx(report::ESC_ELEMENT, |w| w.walk_expr(i)); } } // Barriers / hard escapes on a tracked member itself. @@ -803,7 +941,7 @@ impl<'a> UseWalk<'a> { Expr::PropertyGet { object, .. } | Expr::IndexGet { object, .. } => { if let Expr::LocalGet(id) = object.as_ref() { if self.tracked_root(*id).is_some() { - self.disq(*id); + self.disq(*id, report::ESC_DELETE); return; } } @@ -815,7 +953,7 @@ impl<'a> UseWalk<'a> { Expr::ObjectFreeze(t) | Expr::ObjectSeal(t) | Expr::ObjectPreventExtensions(t) => { if let Expr::LocalGet(id) = t.as_ref() { if self.tracked_root(*id).is_some() { - self.disq(*id); + self.disq(*id, report::ESC_FREEZE); return; } } @@ -823,14 +961,15 @@ impl<'a> UseWalk<'a> { } // Reassignment / bare reference / numeric update = escape. Expr::LocalSet(id, v) => { - self.disq(*id); + self.disq(*id, report::ESC_REASSIGNED); self.walk_expr(v); } Expr::LocalGet(id) => { - self.disq(*id); + let why = self.escape_ctx; + self.disq(*id, why); } Expr::Update { id, .. } => { - self.disq(*id); + self.disq(*id, report::ESC_REASSIGNED); } // Id-keyed variants the child walker cannot see. Expr::ArrayPush { array_id, .. } @@ -838,21 +977,25 @@ impl<'a> UseWalk<'a> { | Expr::ArrayUnshift { array_id, .. } | Expr::ArraySplice { array_id, .. } | Expr::ArrayCopyWithin { array_id, .. } => { - self.disq(*array_id); - perry_hir::walker::walk_expr_children(e, &mut |c| self.walk_expr(c)); + self.disq(*array_id, report::ESC_CONTAINER_MUTATOR); + self.with_ctx(report::ESC_ELEMENT, |w| { + perry_hir::walker::walk_expr_children(e, &mut |c| w.walk_expr(c)) + }); } Expr::ArrayPop(id) | Expr::ArrayShift(id) => { - self.disq(*id); + self.disq(*id, report::ESC_CONTAINER_MUTATOR); } Expr::SetAdd { set_id, .. } => { - self.disq(*set_id); - perry_hir::walker::walk_expr_children(e, &mut |c| self.walk_expr(c)); + self.disq(*set_id, report::ESC_CONTAINER_MUTATOR); + self.with_ctx(report::ESC_ELEMENT, |w| { + perry_hir::walker::walk_expr_children(e, &mut |c| w.walk_expr(c)) + }); } Expr::WithSet { fallback, .. } => { match fallback { perry_hir::WithSetFallback::Local(id) | perry_hir::WithSetFallback::SloppyImplicit(id) => { - self.disq(*id); + self.disq(*id, report::ESC_BARE_REFERENCE); } _ => {} } @@ -867,7 +1010,7 @@ impl<'a> UseWalk<'a> { .. } => { for c in captures.iter().chain(mutable_captures.iter()) { - self.disq(*c); + self.disq(*c, report::ESC_CLOSURE_CAPTURE); } self.walk_stmts(body); } @@ -1764,3 +1907,10 @@ fn expr_provably_not_bigint(e: &Expr, not_bigint_locals: &HashSet) -> bool // ToNumber(Symbol) THROWS, so the store never completes — throw behavior is // identical on the guarded and bare paths, and no non-number value can reach // the slot through these operators. + +/// `--opt-report` (#6952) end-to-end tests. Kept in a sibling file for the +/// file-size gate; still a child module, so `use super::*` reaches the +/// collector's private items. +#[cfg(test)] +#[path = "ptr_shape_opt_report_tests.rs"] +mod opt_report_tests; diff --git a/crates/perry-codegen/src/collectors/ptr_shape_opt_report_tests.rs b/crates/perry-codegen/src/collectors/ptr_shape_opt_report_tests.rs new file mode 100644 index 0000000000..6fbb1488fd --- /dev/null +++ b/crates/perry-codegen/src/collectors/ptr_shape_opt_report_tests.rs @@ -0,0 +1,317 @@ +//! `--opt-report` (#6952) end-to-end tests for the `Ptr` collector. +//! +//! Split out of `ptr_shape.rs` to stay under the 2000-line CI gate; declared +//! there with `#[path]` so it remains a child module and can reach the +//! collector's private items through `use super::*`. + +//! End-to-end tests for the `--opt-report` instrumentation (#6952). +//! +//! These run the real collector over hand-built HIR and assert BOTH +//! halves of the contract: +//! +//! 1. the report names the right value with the right rule, and +//! 2. **the collector's returned facts are unchanged** — the recording +//! must be observational. Assertion (2) is what stops a future edit +//! from "fixing" a report line by changing the proof. + +use super::*; +use crate::opt_report::{test_support::Session, Outcome, Position}; +use perry_hir::types::Type; +use perry_hir::{Class, ClassField, Expr, Stmt}; + +fn field(name: &str) -> ClassField { + ClassField { + name: name.to_string(), + key_expr: None, + ty: Type::Number, + init: None, + is_private: false, + is_readonly: false, + decorators: Vec::new(), + } +} + +fn class_with_fields(name: &str, fields: &[&str]) -> Class { + Class { + id: 0, + name: name.to_string(), + type_params: Vec::new(), + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: fields.iter().map(|f| field(f)).collect(), + constructor: None, + methods: Vec::new(), + getters: Vec::new(), + setters: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + computed_members: Vec::new(), + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + is_nested: false, + alloc_width_hint: 0, + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + } +} + +fn new_c() -> Expr { + Expr::New { + class_name: "C".to_string(), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + } +} + +fn let_c(id: u32, name: &str) -> Stmt { + Stmt::Let { + id, + name: name.to_string(), + ty: Type::Any, + mutable: false, + init: Some(new_c()), + } +} + +/// `o.x = 1` — a declared-field store, which rule 2 permits. +fn store_x(id: u32) -> Stmt { + Stmt::Expr(Expr::PropertySet { + object: Box::new(Expr::LocalGet(id)), + property: "x".to_string(), + value: Box::new(Expr::Number(1.0)), + }) +} + +/// A barrier-free fact set. `ModuleDispatchFacts::default()` is +/// deliberately fail-safe (every barrier ON), so using it here would make +/// every test in this module vacuously assert the rule-5 kill. +fn clean_dispatch() -> ModuleDispatchFacts { + super::super::collect_module_dispatch_facts(&perry_hir::Module::new("clean")) +} + +fn run(stmts: &[Stmt], classes: &HashMap) -> HashMap { + collect_shape_proven_ptr_locals( + stmts, + &HashSet::new(), + &HashMap::new(), + classes, + &clean_dispatch(), + &HashSet::new(), + ) +} + +/// A contained local is promoted AND reported as a win; an escaping one +/// is denied AND reported with rule 2 naming the return position. +#[test] +fn contained_local_wins_and_returned_local_is_denied_with_its_rule() { + let c = class_with_fields("C", &["x"]); + let mut classes = HashMap::new(); + classes.insert("C".to_string(), &c); + + let stmts = vec![ + let_c(1, "contained"), + store_x(1), + let_c(2, "escaped"), + store_x(2), + Stmt::Return(Some(Expr::LocalGet(2))), + ]; + + let session = Session::start(); + let facts = run(&stmts, &classes); + let entries = session.entries(); + + // (2) The proof itself is unchanged by the instrumentation. + assert!( + facts.contains_key(&1), + "the contained local must still be promoted" + ); + assert!( + !facts.contains_key(&2), + "the returned local must still be denied" + ); + + // (1) And the report says so, with the rule. + let win = entries + .iter() + .find(|e| e.name == "contained") + .expect("the promoted local must appear in the report"); + assert_eq!(win.outcome, Outcome::Selected); + assert_eq!(win.rep, "Ptr"); + assert_eq!(win.position, Position::Local); + + let miss = entries + .iter() + .find(|e| e.name == "escaped") + .expect("the denied local must appear in the report"); + assert_eq!(miss.outcome, Outcome::Denied); + assert_eq!(miss.rep, "Boxed"); + assert_eq!(miss.rule.as_deref(), Some("rule 2 (containment)")); + assert!( + miss.reason.as_deref().unwrap_or("").contains("returned"), + "the reason must name the RETURN escape, not a generic one: {:?}", + miss.reason + ); +} + +/// The escape kind must be discriminated, not collapsed into one bucket. +/// A closure capture and a call argument are different fixes. +#[test] +fn escape_kinds_are_discriminated() { + let c = class_with_fields("C", &["x"]); + let mut classes = HashMap::new(); + classes.insert("C".to_string(), &c); + + let captured = vec![ + let_c(1, "captured"), + Stmt::Expr(Expr::Closure { + func_id: 99, + params: Vec::new(), + return_type: Type::Any, + body: vec![store_x(1)], + captures: vec![1], + mutable_captures: Vec::new(), + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: true, + is_async: false, + is_generator: false, + is_strict: false, + }), + ]; + let session = Session::start(); + let facts = run(&captured, &classes); + let entries = session.entries(); + assert!(!facts.contains_key(&1), "a captured local must be denied"); + let e = entries.iter().find(|e| e.name == "captured").unwrap(); + assert!( + e.reason.as_deref().unwrap_or("").contains("captured"), + "closure capture must be reported as such: {:?}", + e.reason + ); + drop(session); + + let passed = vec![ + let_c(2, "passed"), + Stmt::Expr(Expr::Call { + callee: Box::new(Expr::LocalGet(50)), + args: vec![Expr::LocalGet(2)], + type_args: Vec::new(), + byte_offset: 0, + }), + ]; + let session = Session::start(); + let facts = run(&passed, &classes); + let entries = session.entries(); + assert!( + !facts.contains_key(&2), + "a local passed as an argument must be denied" + ); + let e = entries.iter().find(|e| e.name == "passed").unwrap(); + assert!( + e.reason + .as_deref() + .unwrap_or("") + .contains("passed as a call argument"), + "a call-argument escape must be reported as such, not as a bare \ + reference: {:?}", + e.reason + ); +} + +/// Rule 5 kills the whole module. The report must still enumerate what +/// *would* have been considered — otherwise a barrier-carrying module +/// produces a blank report and looks like it has no object code at all. +#[test] +fn module_barrier_still_enumerates_the_candidates_it_killed() { + let c = class_with_fields("C", &["x"]); + let mut classes = HashMap::new(); + classes.insert("C".to_string(), &c); + let stmts = vec![let_c(1, "victim"), store_x(1)]; + + // A module with a §5.2 barrier: `delete o.x` anywhere. + let mut barrier_module = perry_hir::Module::new("barrier"); + barrier_module.init = vec![Stmt::Expr(Expr::Delete(Box::new(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(7)), + property: "x".to_string(), + byte_offset: 0, + })))]; + let dispatch = super::super::collect_module_dispatch_facts(&barrier_module); + assert!( + dispatch.has_shape_barrier_sites(), + "fixture must actually trip the barrier, or this test is vacuous" + ); + + let session = Session::start(); + let facts = collect_shape_proven_ptr_locals( + &stmts, + &HashSet::new(), + &HashMap::new(), + &classes, + &dispatch, + &HashSet::new(), + ); + let entries = session.entries(); + + assert!(facts.is_empty(), "the barrier must still kill promotion"); + let e = entries + .iter() + .find(|e| e.name == "victim") + .expect("the killed candidate must still be named in the report"); + assert_eq!(e.rule.as_deref(), Some("rule 5 (module-wide barrier)")); +} + +/// The `.map(x => ({...}))` idiom: an allocation never bound to a local. +/// Rule 1 can never see it, so it must be reported as an allocation site +/// rather than silently omitted. +#[test] +fn unbound_allocation_sites_are_reported() { + let c = class_with_fields("C", &["x"]); + let mut classes = HashMap::new(); + classes.insert("C".to_string(), &c); + let stmts = vec![Stmt::Return(Some(new_c()))]; + + let session = Session::start(); + let facts = run(&stmts, &classes); + let entries = session.entries(); + + assert!(facts.is_empty()); + let e = entries + .iter() + .find(|e| e.position == Position::AllocSite) + .expect("an unbound `new` must be reported as an allocation site"); + assert_eq!(e.rule.as_deref(), Some("rule 1 (provenance)")); + assert_eq!(e.name, "new C(...)"); + assert!( + e.detail.as_deref().unwrap_or("").contains("return"), + "the allocation position must be named: {:?}", + e.detail + ); +} + +/// Nothing is recorded when the report is off — the zero-cost claim. +#[test] +fn nothing_is_recorded_when_the_report_is_off() { + let c = class_with_fields("C", &["x"]); + let mut classes = HashMap::new(); + classes.insert("C".to_string(), &c); + let stmts = vec![let_c(1, "escaped"), Stmt::Return(Some(Expr::LocalGet(1)))]; + + // Take the same lock the enabled sessions use — otherwise a + // concurrently-running enabled test would make this one flaky, since + // the gate and the sink are both process-global. + let session = Session::start_disabled(); + assert!(!crate::opt_report::enabled()); + let facts = run(&stmts, &classes); + assert!(facts.is_empty(), "the returned facts must be unchanged"); + assert!( + session.entries().is_empty(), + "the sink must stay empty when the report is off" + ); +} diff --git a/crates/perry-codegen/src/collectors/ptr_shape_report.rs b/crates/perry-codegen/src/collectors/ptr_shape_report.rs new file mode 100644 index 0000000000..0bf637d4e2 --- /dev/null +++ b/crates/perry-codegen/src/collectors/ptr_shape_report.rs @@ -0,0 +1,653 @@ +//! `--opt-report` (#6952) support for the `Ptr` collector. +//! +//! `collectors/ptr_shape.rs` already knows exactly which rule denied each +//! candidate — it just `continue`s. This module supplies the vocabulary and +//! the two auxiliary walks (binding names, loop depths, unbound allocation +//! sites) that let those `continue` sites record `(value, reason)` instead of +//! dropping the information on the floor. +//! +//! **Everything here runs only when [`crate::opt_report::enabled`] is true.** +//! The collector's returned facts are untouched either way: recording happens +//! *next to* the `continue`, never instead of it. +//! +//! ## On the actionability tiers +//! +//! The tier is a judgment about who can act, and it is stated here rather +//! than inferred at render time so it is reviewable in one place: +//! +//! - **`Fixable`** — the source could reasonably be written another way +//! (stop reassigning, don't capture in a closure, don't add ad-hoc +//! properties). The RFC's own examples. +//! - **`CompilerLimitation`** — the source is idiomatic TypeScript that +//! Perry cannot yet prove through. `return`, call arguments and container +//! elements are all in this bucket: #7034 §1/§4 established that the +//! proof needs to survive an escape, and that work is not written yet. +//! Telling a developer to stop returning records would be bad advice. +//! - **`InherentlyPolymorphic`** — genuinely dynamic; correctly boxed. + +use std::collections::{HashMap, HashSet}; + +use perry_hir::{Class, Expr, Stmt}; + +use crate::opt_report::{self, Analysis, Denial, Position, Tier}; + +/// A named denial: the rule as the collector numbers it, a human expansion, +/// the actionability tier, and the tracking issue when the fix is ours. +#[derive(Debug, Clone, Copy)] +pub(super) struct ShapeDenial { + pub rule: &'static str, + pub reason: &'static str, + pub tier: Tier, + pub issue: Option<&'static str>, +} + +const RULE1: &str = "rule 1 (provenance)"; +const RULE2: &str = "rule 2 (containment)"; +const RULE3: &str = "rule 3 (this-flow containment)"; +const RULE4: &str = "rule 4 (dispatch stability)"; +const RULE5: &str = "rule 5 (module-wide barrier)"; +const ADMISSION: &str = "class admission"; +const GATE: &str = "disabled by PERRY_PTR_SHAPE_LOCALS"; + +// ── Rule 1: provenance ───────────────────────────────────────────────────── + +pub(super) const UNBOUND_ALLOC: ShapeDenial = ShapeDenial { + rule: RULE1, + reason: "allocated in expression position and never bound to a `let`/`const`, \ + so the shape proof has no local to anchor to. This is the \ + `.map(x => ({...}))` / `return { ... }` idiom.", + tier: Tier::CompilerLimitation, + issue: Some("#7034 §4 (return-shape facts)"), +}; + +pub(super) const LET_INIT_NOT_NEW: ShapeDenial = ShapeDenial { + rule: RULE1, + reason: "the binding is re-declared with an initializer that is not the \ + original allocation, so its dynamic class is not fixed.", + tier: Tier::Fixable, + issue: None, +}; + +pub(super) const MULTIPLE_LET: ShapeDenial = ShapeDenial { + rule: RULE1, + reason: "bound by more than one `let`/`const` in this body; the proof \ + requires exactly one binding for the local's whole lifetime.", + tier: Tier::Fixable, + issue: None, +}; + +pub(super) const ALIAS_NOT_SINGLE_LET: ShapeDenial = ShapeDenial { + rule: RULE1, + reason: "an alias of this object is bound more than once, leaving a \ + second binding the proof does not cover.", + tier: Tier::Fixable, + issue: None, +}; + +// ── Class admission ──────────────────────────────────────────────────────── + +pub(super) const ADMIT_ACCESSOR: ShapeDenial = ShapeDenial { + rule: ADMISSION, + reason: "the class chain declares a getter or setter, so a field access \ + is a call, not a fixed-offset load.", + tier: Tier::Fixable, + issue: None, +}; + +pub(super) const ADMIT_COMPUTED: ShapeDenial = ShapeDenial { + rule: ADMISSION, + reason: "the class chain has a computed member or computed field key, so \ + its key set is not statically known.", + tier: Tier::Fixable, + issue: None, +}; + +pub(super) const ADMIT_DYNAMIC_BASE: ShapeDenial = ShapeDenial { + rule: ADMISSION, + reason: "the class chain extends a dynamic or lexically-shadowed \ + expression, so the parent shape is not statically walkable.", + tier: Tier::CompilerLimitation, + issue: None, +}; + +pub(super) const ADMIT_NATIVE_BASE: ShapeDenial = ShapeDenial { + rule: ADMISSION, + reason: "the class chain extends a native, unmodeled, or built-in Error \ + base, which installs its surface as own properties at run time.", + tier: Tier::CompilerLimitation, + issue: None, +}; + +pub(super) const ADMIT_UNRESOLVED: ShapeDenial = ShapeDenial { + rule: ADMISSION, + reason: "the allocated class is not resolvable in this module's class \ + table (imported, re-exported, or synthesised).", + tier: Tier::CompilerLimitation, + issue: None, +}; + +// ── Rule 2: containment ──────────────────────────────────────────────────── + +pub(super) const ESC_REASSIGNED: ShapeDenial = ShapeDenial { + rule: RULE2, + reason: "the binding is reassigned, so it does not hold one object of one \ + class for its whole lifetime.", + tier: Tier::Fixable, + issue: None, +}; + +pub(super) const ESC_CLOSURE_CAPTURE: ShapeDenial = ShapeDenial { + rule: RULE2, + reason: "captured by a closure; the capture machinery creates an alias \ + whose lifetime the proof cannot bound.", + tier: Tier::Fixable, + issue: None, +}; + +pub(super) const ESC_CALL_ARGUMENT: ShapeDenial = ShapeDenial { + rule: RULE2, + reason: "passed as a call argument. There is no mechanism yet by which a \ + shape fact at a call site becomes a fact about the callee's \ + parameter, so any argument position disqualifies.", + tier: Tier::CompilerLimitation, + issue: Some("#7034 §1 (argument positions via clone-and-route)"), +}; + +pub(super) const ESC_RETURN: ShapeDenial = ShapeDenial { + rule: RULE2, + reason: "returned from this function. Return positions do not carry a \ + shape fact yet, so returning a record forfeits its proof.", + tier: Tier::CompilerLimitation, + issue: Some("#7034 §4 (return-shape facts)"), +}; + +pub(super) const ESC_ELEMENT: ShapeDenial = ShapeDenial { + rule: RULE2, + reason: "stored into an array or object (element/property of a container). \ + Container slots do not carry a shape fact yet.", + tier: Tier::CompilerLimitation, + issue: Some("#7034 §3/§5 P4-P5 (elements and fields)"), +}; + +pub(super) const ESC_THROWN: ShapeDenial = ShapeDenial { + rule: RULE2, + reason: "thrown; the value escapes on an exceptional edge the proof does \ + not follow.", + tier: Tier::Fixable, + issue: None, +}; + +pub(super) const ESC_BARE_REFERENCE: ShapeDenial = ShapeDenial { + rule: RULE2, + reason: "referenced as a bare value somewhere other than a declared-field \ + access or a vetted method call, which creates an alias the escape \ + walk cannot bound.", + tier: Tier::Fixable, + issue: None, +}; + +pub(super) const ESC_UNDECLARED_PROPERTY: ShapeDenial = ShapeDenial { + rule: RULE2, + reason: "a property that is not a declared field of the class chain is \ + read or written on it, which would transition its shape.", + tier: Tier::Fixable, + issue: None, +}; + +pub(super) const ESC_UNRESOLVED_METHOD: ShapeDenial = ShapeDenial { + rule: RULE2, + reason: "a method that does not resolve on the declared class chain is \ + called on it.", + tier: Tier::Fixable, + issue: None, +}; + +pub(super) const ESC_DELETE: ShapeDenial = ShapeDenial { + rule: RULE2, + reason: "`delete` is applied to one of its properties, which changes its \ + shape at run time.", + tier: Tier::Fixable, + issue: None, +}; + +pub(super) const ESC_FREEZE: ShapeDenial = ShapeDenial { + rule: RULE2, + reason: "`Object.freeze`/`seal`/`preventExtensions` is applied to it, \ + which rewrites its descriptors.", + tier: Tier::Fixable, + issue: None, +}; + +pub(super) const ESC_CONTAINER_MUTATOR: ShapeDenial = ShapeDenial { + rule: RULE2, + reason: "used as the receiver of a container mutator (array push/pop/\ + splice, Set.add, …), which the proof treats as an escape.", + tier: Tier::Fixable, + issue: None, +}; + +// ── Rules 3-5 ────────────────────────────────────────────────────────────── + +pub(super) const THIS_ESCAPE: ShapeDenial = ShapeDenial { + rule: RULE3, + reason: "the constructor chain, a field initializer, or a called method \ + leaks `this` as a value (stores, passes, or captures it), \ + creating an alias the escape walk cannot see.", + tier: Tier::Fixable, + issue: None, +}; + +pub(super) const METHOD_THIS_ESCAPE: ShapeDenial = ShapeDenial { + rule: RULE3, + reason: "a method called on this local is not `this`-flow safe — its body \ + uses `this` as a value rather than only accessing declared \ + fields and calling vetted methods.", + tier: Tier::Fixable, + issue: None, +}; + +pub(super) const UNSTABLE_PROTOTYPE: ShapeDenial = ShapeDenial { + rule: RULE4, + reason: "the module writes through a `.prototype` object, so method \ + dispatch on this class is not statically stable.", + tier: Tier::Fixable, + issue: None, +}; + +pub(super) const FIELD_METHOD_AMBIGUITY: ShapeDenial = ShapeDenial { + rule: RULE4, + reason: "a called name is both a declared field and a method on the chain, \ + which is ambiguous under own-property shadowing.", + tier: Tier::Fixable, + issue: None, +}; + +pub(super) const MODULE_BARRIER: ShapeDenial = ShapeDenial { + rule: RULE5, + reason: "the module contains a shape barrier (`Object.defineProperty`, \ + `delete`, `setPrototypeOf`/`__proto__` write, `new Proxy`, or a \ + mutating `Reflect.*`). The first-increment rule disables ALL \ + Ptr promotion in the module regardless of the barrier's \ + target.", + tier: Tier::CompilerLimitation, + issue: Some("#7034 §2 (barrier narrowing)"), +}; + +pub(super) const GATE_DISABLED: ShapeDenial = ShapeDenial { + rule: GATE, + reason: "shape-proven pointer locals are switched off for this build \ + (`PERRY_PTR_SHAPE_LOCALS=0`).", + tier: Tier::CompilerLimitation, + issue: None, +}; + +// ── Recording ────────────────────────────────────────────────────────────── + +/// Record one denied `Ptr` candidate. +pub(super) fn deny_local( + id: u32, + names: &HashMap, + depths: &HashMap, + class_name: Option<&str>, + d: ShapeDenial, +) { + // Guard here, not just inside `opt_report::deny`: the arguments below + // allocate, and they are evaluated before the callee can early-return. + if !opt_report::enabled() { + return; + } + let fallback = format!(""); + let name = names.get(&id).map(String::as_str).unwrap_or(&fallback); + opt_report::deny(Denial { + position: Position::Local, + name, + local_id: Some(id), + analysis: Analysis::PtrShape, + rule: d.rule, + reason: d.reason, + tier: d.tier, + issue: d.issue, + loop_depth: depths.get(&id).copied().unwrap_or(0), + detail: class_name.map(|c| format!("candidate class: {c}")), + byte_offset: None, + }); +} + +/// Record an allocation site that never became a candidate (rule 1). +pub(super) fn deny_alloc_site(site: &NewSite) { + if !opt_report::enabled() { + return; + } + opt_report::deny(Denial { + position: Position::AllocSite, + name: &site.display, + local_id: None, + analysis: Analysis::PtrShape, + rule: UNBOUND_ALLOC.rule, + reason: UNBOUND_ALLOC.reason, + tier: UNBOUND_ALLOC.tier, + issue: UNBOUND_ALLOC.issue, + loop_depth: site.loop_depth, + detail: Some(format!("allocation position: {}", site.context)), + byte_offset: (site.byte_offset != 0).then_some(site.byte_offset), + }); +} + +// ── Auxiliary walks (only run under the report flag) ─────────────────────── + +/// `LocalId -> source binding name`, for every `Stmt::Let` in the region. +/// HIR keeps names through lowering; positions do not survive, which is why +/// the report is by function + name rather than `file:line`. +pub(super) fn local_names(stmts: &[Stmt]) -> HashMap { + let mut out = HashMap::new(); + walk_lets(stmts, 0, &mut |id, name, _depth| { + out.insert(id, name.to_string()); + }); + out +} + +/// `LocalId -> loop-nesting depth of its declaration`. A hotness *proxy*: it +/// cannot see that a closure body with depth 0 runs once per element, which +/// is why [`crate::opt_report::Entry::invoked_per_element`] is a separate +/// column rather than folded in here. +pub(super) fn loop_depths(stmts: &[Stmt]) -> HashMap { + let mut out = HashMap::new(); + walk_lets(stmts, 0, &mut |id, _name, depth| { + out.insert(id, depth); + }); + out +} + +fn walk_lets(stmts: &[Stmt], depth: u32, f: &mut impl FnMut(u32, &str, u32)) { + for s in stmts { + match s { + Stmt::Let { id, name, .. } => f(*id, name, depth), + Stmt::If { + then_branch, + else_branch, + .. + } => { + walk_lets(then_branch, depth, f); + if let Some(eb) = else_branch { + walk_lets(eb, depth, f); + } + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => { + walk_lets(body, depth + 1, f); + } + Stmt::For { init, body, .. } => { + if let Some(init) = init { + walk_lets(std::slice::from_ref(init.as_ref()), depth + 1, f); + } + walk_lets(body, depth + 1, f); + } + Stmt::Try { + body, + catch, + finally, + } => { + walk_lets(body, depth, f); + if let Some(c) = catch { + walk_lets(&c.body, depth, f); + } + if let Some(fin) = finally { + walk_lets(fin, depth, f); + } + } + Stmt::Switch { cases, .. } => { + for c in cases { + walk_lets(&c.body, depth, f); + } + } + Stmt::Labeled { body, .. } => walk_lets(std::slice::from_ref(body.as_ref()), depth, f), + _ => {} + } + } +} + +/// An object allocation that is not the initializer of a `Stmt::Let`. +pub(super) struct NewSite { + /// `new Row(...)` / `{ key, value }` — what the developer wrote. + pub display: String, + /// Where it sits: `return`, `call argument`, `array element`, … + pub context: &'static str, + pub loop_depth: u32, + /// Byte offset of the `new` expression in its module's source. `Expr::New` + /// is the one HIR node that already carries a source position (#5253, + /// captured for constructor TypeErrors), so allocation sites — which have + /// no binding name to report — can still be located. Ordinary locals have + /// no span; see the module doc. + pub byte_offset: u32, +} + +/// Allocation sites in this region that rule 1 can never see, because they +/// are never bound to a local. **Does not descend into closure bodies** — +/// those are lowered as their own regions and reported under their own +/// function name. +pub(super) fn unbound_new_sites(stmts: &[Stmt]) -> Vec { + let mut out = Vec::new(); + scan_stmts(stmts, 0, "statement", &mut out); + out +} + +fn scan_stmts(stmts: &[Stmt], depth: u32, ctx: &'static str, out: &mut Vec) { + for s in stmts { + match s { + // The Let init IS the provenance site rule 1 accepts; skip it and + // scan only its arguments. + Stmt::Let { + init: Some(Expr::New { args, .. }), + .. + } => { + for a in args { + scan_expr(a, depth, "constructor argument", out); + } + } + Stmt::Let { init, .. } => { + if let Some(e) = init { + scan_expr(e, depth, "initializer", out); + } + } + Stmt::Expr(e) => scan_expr(e, depth, ctx, out), + Stmt::Throw(e) => scan_expr(e, depth, "throw", out), + Stmt::Return(Some(e)) => scan_expr(e, depth, "return", out), + Stmt::Return(None) => {} + Stmt::If { + condition, + then_branch, + else_branch, + } => { + scan_expr(condition, depth, "condition", out); + scan_stmts(then_branch, depth, ctx, out); + if let Some(eb) = else_branch { + scan_stmts(eb, depth, ctx, out); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + scan_expr(condition, depth + 1, "condition", out); + scan_stmts(body, depth + 1, ctx, out); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(init) = init { + scan_stmts(std::slice::from_ref(init.as_ref()), depth + 1, ctx, out); + } + if let Some(c) = condition { + scan_expr(c, depth + 1, "condition", out); + } + if let Some(u) = update { + scan_expr(u, depth + 1, "loop update", out); + } + scan_stmts(body, depth + 1, ctx, out); + } + Stmt::Try { + body, + catch, + finally, + } => { + scan_stmts(body, depth, ctx, out); + if let Some(c) = catch { + scan_stmts(&c.body, depth, ctx, out); + } + if let Some(f) = finally { + scan_stmts(f, depth, ctx, out); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + scan_expr(discriminant, depth, "switch discriminant", out); + for c in cases { + if let Some(t) = &c.test { + scan_expr(t, depth, "case test", out); + } + scan_stmts(&c.body, depth, ctx, out); + } + } + Stmt::Labeled { body, .. } => { + scan_stmts(std::slice::from_ref(body.as_ref()), depth, ctx, out) + } + _ => {} + } + } +} + +fn scan_expr(e: &Expr, depth: u32, ctx: &'static str, out: &mut Vec) { + match e { + // A closure body is its own lowering region; it reports separately. + Expr::Closure { .. } => {} + Expr::New { + class_name, + args, + byte_offset, + .. + } => { + out.push(NewSite { + display: display_class(class_name), + context: ctx, + loop_depth: depth, + byte_offset: *byte_offset, + }); + for a in args { + scan_expr(a, depth, "constructor argument", out); + } + } + Expr::Call { callee, args, .. } => { + scan_expr(callee, depth, ctx, out); + for a in args { + scan_expr(a, depth, "call argument", out); + } + } + Expr::Array(items) => { + for i in items { + scan_expr(i, depth, "array element", out); + } + } + _ => perry_hir::walker::walk_expr_children(e, &mut |c| scan_expr(c, depth, ctx, out)), + } +} + +/// Anonymous object literals lower to `Expr::New { class_name: +/// "__AnonShape_…" }`; render them as the object literals the developer +/// actually wrote rather than leaking the synthetic class name. +fn display_class(class_name: &str) -> String { + if class_name.starts_with("__AnonShape") { + String::from("object literal { ... }") + } else { + format!("new {class_name}(...)") + } +} + +/// Why a class chain failed admission, or `None` when it is admissible. +/// Single source of truth for both the gate and the report — the collector's +/// `chain_admissible` is `cause(...).is_none()`. +pub(super) fn admission_cause( + classes: &HashMap, + class_name: &str, + chain: &[&Class], +) -> Option { + if chain.is_empty() { + return Some(if classes.contains_key(class_name) { + ADMIT_DYNAMIC_BASE + } else { + ADMIT_UNRESOLVED + }); + } + for class in chain { + if !class.getters.is_empty() || !class.setters.is_empty() { + return Some(ADMIT_ACCESSOR); + } + if !class.computed_members.is_empty() || class.fields.iter().any(|f| f.key_expr.is_some()) { + return Some(ADMIT_COMPUTED); + } + if class.extends_expr.is_some() + || class.heritage_lexically_shadowed + || (class.extends.is_some() && class.extends_name.is_none()) + { + return Some(ADMIT_DYNAMIC_BASE); + } + if class.native_extends.is_some() { + return Some(ADMIT_NATIVE_BASE); + } + } + let class = chain[0]; + if super::this_as_value::class_chain_extends_builtin_error(class, classes) + || super::this_as_value::class_chain_has_unmodeled_base(class, classes) + { + return Some(ADMIT_NATIVE_BASE); + } + None +} + +/// Enumerate the `Stmt::Let`-bound allocation candidates in a region without +/// running the proof — used on the early-bail paths (gate off, module +/// barrier) so the report can still name what *would* have been considered. +pub(super) fn candidate_seeds( + stmts: &[Stmt], + boxed_vars: &HashSet, + module_globals: &HashMap, +) -> HashMap { + let mut out = HashMap::new(); + super::find_new_candidates(stmts, boxed_vars, module_globals, &mut out); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn anon_shape_classes_render_as_object_literals() { + assert_eq!(display_class("__AnonShape_3"), "object literal { ... }"); + assert_eq!(display_class("Row"), "new Row(...)"); + } + + #[test] + fn every_compiler_limitation_that_has_a_fix_names_its_tracking_issue() { + // The CompilerLimitation tier is meant to double as a roadmap. The + // escape-position denials are the ones #7034 scoped, so they must + // carry an issue; the rest may legitimately have none. + for d in [ESC_CALL_ARGUMENT, ESC_RETURN, ESC_ELEMENT, UNBOUND_ALLOC] { + assert_eq!(d.tier, Tier::CompilerLimitation, "{}", d.rule); + assert!(d.issue.is_some(), "{} must name a tracking issue", d.rule); + } + } + + #[test] + fn fixable_denials_do_not_claim_a_compiler_issue() { + for d in [ + ESC_REASSIGNED, + ESC_CLOSURE_CAPTURE, + ESC_DELETE, + MULTIPLE_LET, + ] { + assert_eq!(d.tier, Tier::Fixable, "{}", d.rule); + assert!(d.issue.is_none(), "{} should not name an issue", d.rule); + } + } +} diff --git a/crates/perry-codegen/src/expr/slot_rep.rs b/crates/perry-codegen/src/expr/slot_rep.rs index 02229ccbdc..aac7e9349f 100644 --- a/crates/perry-codegen/src/expr/slot_rep.rs +++ b/crates/perry-codegen/src/expr/slot_rep.rs @@ -147,6 +147,21 @@ fn repsel_debug_enabled() -> bool { /// canonical representation (i32/u32/Str), /// plus a process-wide running count. Only under `PERRY_REPSEL_DEBUG=1`. 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. + if crate::opt_report::enabled() { + crate::opt_report::select_explicit( + &ctx.source_function, + crate::opt_report::RegionKind::Function, + crate::opt_report::Position::Local, + name, + Some(id), + crate::opt_report::Analysis::CanonicalSlot, + &format!("{rep:?}"), + ); + } if !repsel_debug_enabled() { return; } diff --git a/crates/perry-codegen/src/lib.rs b/crates/perry-codegen/src/lib.rs index 429f55ddb8..0f8ba5e185 100644 --- a/crates/perry-codegen/src/lib.rs +++ b/crates/perry-codegen/src/lib.rs @@ -21,6 +21,7 @@ pub mod module; pub mod nanbox; pub(crate) mod native_value; pub(crate) mod nm_install; +pub mod opt_report; pub mod runtime_decls; pub(crate) mod setjmp_abi; pub(crate) mod stmt; diff --git a/crates/perry-codegen/src/opt_report/callbacks.rs b/crates/perry-codegen/src/opt_report/callbacks.rs new file mode 100644 index 0000000000..9d4f5eaae0 --- /dev/null +++ b/crates/perry-codegen/src/opt_report/callbacks.rs @@ -0,0 +1,151 @@ +//! Which closures are an iterating builtin's callback (#6952, #7034 §8). +//! +//! Loop-nesting depth alone is a **wrong** hotness proxy for object-heavy +//! TypeScript. #7034 measured `batch.ts`: of 247 PIC/guard blocks, only 39 +//! sit inside an explicit loop region and **208 are in closure bodies** — +//! `map`/`sort`/`reduce` callbacks that have no loop of their own and are +//! invoked once per element. Ranking those at depth 0 buries exactly the +//! sites that matter. +//! +//! So the report carries the two facts in separate columns. This module +//! supplies the second one: a per-module scan that records which closure +//! `FuncId`s are passed directly to an iterating builtin, and which builtin. +//! It is a *static, syntactic* attribution — an indirection +//! (`const f = x => …; arr.map(f)`) is not resolved, so absence of the mark +//! is not evidence the body is cold. The report says so. + +use perry_hir::{Expr, Module, Stmt}; + +/// Scan a module and register every closure that is syntactically the +/// callback of an iterating builtin. Runs once per module, only under +/// `--opt-report`. +pub(crate) fn scan_module(hir: &Module) { + if !super::enabled() { + return; + } + scan_stmts(&hir.init); + for f in &hir.functions { + scan_stmts(&f.body); + } + for class in &hir.classes { + for m in &class.methods { + scan_stmts(&m.body); + } + if let Some(ctor) = &class.constructor { + scan_stmts(&ctor.body); + } + } +} + +fn scan_stmts(stmts: &[Stmt]) { + for s in stmts { + match s { + Stmt::Let { init, .. } => { + if let Some(e) = init { + scan_expr(e); + } + } + Stmt::Expr(e) | Stmt::Throw(e) => scan_expr(e), + Stmt::Return(opt) => { + if let Some(e) = opt { + scan_expr(e); + } + } + Stmt::If { + condition, + then_branch, + else_branch, + } => { + scan_expr(condition); + scan_stmts(then_branch); + if let Some(eb) = else_branch { + scan_stmts(eb); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + scan_expr(condition); + scan_stmts(body); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(init) = init { + scan_stmts(std::slice::from_ref(init.as_ref())); + } + if let Some(c) = condition { + scan_expr(c); + } + if let Some(u) = update { + scan_expr(u); + } + scan_stmts(body); + } + Stmt::Try { + body, + catch, + finally, + } => { + scan_stmts(body); + if let Some(c) = catch { + scan_stmts(&c.body); + } + if let Some(f) = finally { + scan_stmts(f); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + scan_expr(discriminant); + for c in cases { + if let Some(t) = &c.test { + scan_expr(t); + } + scan_stmts(&c.body); + } + } + Stmt::Labeled { body, .. } => scan_stmts(std::slice::from_ref(body.as_ref())), + _ => {} + } + } +} + +/// The iterating builtins whose callback runs once per element. `sort`'s +/// comparator runs O(n log n) times, which is even more per-element than the +/// rest; it is labelled the same way. +fn scan_expr(e: &Expr) { + scan_children(e); + let (callback, builtin) = match e { + Expr::ArrayForEach { callback, .. } => (callback, "Array.prototype.forEach"), + Expr::ArrayMap { callback, .. } => (callback, "Array.prototype.map"), + Expr::ArrayFilter { callback, .. } => (callback, "Array.prototype.filter"), + Expr::ArrayFind { callback, .. } => (callback, "Array.prototype.find"), + Expr::ArrayFindIndex { callback, .. } => (callback, "Array.prototype.findIndex"), + Expr::ArrayFindLast { callback, .. } => (callback, "Array.prototype.findLast"), + Expr::ArrayFindLastIndex { callback, .. } => (callback, "Array.prototype.findLastIndex"), + Expr::ArraySome { callback, .. } => (callback, "Array.prototype.some"), + Expr::ArrayEvery { callback, .. } => (callback, "Array.prototype.every"), + Expr::ArrayFlatMap { callback, .. } => (callback, "Array.prototype.flatMap"), + Expr::ArrayReduce { callback, .. } => (callback, "Array.prototype.reduce"), + Expr::ArrayReduceRight { callback, .. } => (callback, "Array.prototype.reduceRight"), + Expr::ArraySort { comparator, .. } => (comparator, "Array.prototype.sort"), + _ => return, + }; + if let Expr::Closure { func_id, .. } = callback.as_ref() { + super::note_per_element_callback(*func_id, builtin); + } +} + +/// Recurse into every sub-expression, including closure BODIES (which are +/// `Vec` and therefore invisible to `walk_expr_children`). A `map` +/// callback nested inside another callback must be marked too. +fn scan_children(e: &Expr) { + if let Expr::Closure { body, .. } = e { + scan_stmts(body); + } + perry_hir::walker::walk_expr_children(e, &mut |c| scan_expr(c)); +} diff --git a/crates/perry-codegen/src/opt_report/mod.rs b/crates/perry-codegen/src/opt_report/mod.rs new file mode 100644 index 0000000000..3157fdc7a3 --- /dev/null +++ b/crates/perry-codegen/src/opt_report/mod.rs @@ -0,0 +1,753 @@ +//! `--opt-report` (#6952): surface which values Perry could *not* statically +//! type, why, and whether the developer can do anything about it. +//! +//! Perry's speed comes from proving static types and selecting unboxed +//! representations (`docs/representation-selection-rfc.md`). When a proof +//! fails the value stays NaN-boxed and the fast paths silently do not fire. +//! Before this module the only way to find out *which* values failed was to +//! read LLVM IR — which is how #7034 discovered, by accident, that +//! `Ptr` promotion is **zero** on the object-heavy workload that +//! motivates it. This is the representation-selection analogue of LLVM's +//! `-Rpass-missed` optimization remarks. +//! +//! ## Shape +//! +//! - **Off by default.** [`enabled`] is a `OnceLock` over +//! `PERRY_OPT_REPORT`; every recording entry point early-returns when it is +//! false, *before* any string is formatted or allocated. The CLI promotes +//! `--opt-report` to that env var on the main thread before rayon spawns +//! the module workers, exactly like `--trace llvm` promotes `PERRY_SAVE_LL`. +//! - **Observational only.** Nothing in this module is read by codegen. The +//! collectors record *in addition to*, never *instead of*, the `continue` +//! that denied the candidate — the returned fact sets are bit-identical +//! with the report on and off, which the CLI's byte-identical-object test +//! asserts. +//! - **Attribution.** The representation collectors run on a bare `&[Stmt]` +//! with no idea which function they belong to, so each codegen caller +//! brackets its `collect_native_region_fact_graph` call with [`enter`], +//! which pushes a thread-local (module, function, region-kind) scope. Sites +//! that already hold an `FnCtx` (`expr/slot_rep.rs`) pass the names +//! explicitly instead. +//! - **Sink.** A process-global `Mutex>`. Module codegen is +//! in-process on rayon workers, so no on-disk artifact hand-off is needed +//! (unlike `--explain-lowering`, which predates this and re-reads JSON +//! sidecars). The CLI drains it with [`take_entries`] after codegen. +//! +//! ## What it can and cannot see +//! +//! v1 reports **function + variable name**, because HIR carries names but not +//! source spans (`Stmt::Let` is `{id, name, ty, mutable, init}`). A +//! `LocalId -> Span` side-table populated during AST→HIR lowering would add +//! `file:line` and source snippets; that is tracked separately and is +//! strictly additive to this output. + +mod callbacks; +mod render; + +pub(crate) use callbacks::scan_module; +pub use render::{render_json, render_text}; + +use std::cell::RefCell; +use std::sync::{Mutex, OnceLock}; + +/// `PERRY_OPT_REPORT` gate. Off unless the value is one of `1` / `text` / +/// `json` (`0`, `off`, `false`, empty, and unset are all off). +/// +/// Read exactly once per process. The CLI sets the var single-threaded before +/// module codegen spawns, so the `OnceLock` can never latch a stale `false`. +pub fn enabled() -> bool { + #[cfg(test)] + if test_support::forced() { + return true; + } + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| { + matches!( + std::env::var("PERRY_OPT_REPORT").as_deref(), + Ok("1") | Ok("text") | Ok("json") + ) + }) +} + +/// Turning the report on in a unit test cannot go through the env var: the +/// gate is a `OnceLock`, and cargo runs the crate's tests in one process, so +/// whichever test read it first would latch the answer for all of them. +/// Instead tests take a lock and flip an atomic, which also serialises them +/// against the process-global sink. +#[cfg(test)] +pub(crate) mod test_support { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Mutex, MutexGuard, OnceLock}; + + static FORCED: AtomicBool = AtomicBool::new(false); + static LOCK: OnceLock> = OnceLock::new(); + + pub(crate) fn forced() -> bool { + FORCED.load(Ordering::Relaxed) + } + + /// Enable the report and drain any leftover entries. Restores the gate on + /// drop, so a panicking test cannot leave it on for its neighbours. + pub(crate) struct Session { + _guard: MutexGuard<'static, ()>, + } + + impl Session { + pub(crate) fn start() -> Self { + let guard = LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|e| e.into_inner()); + FORCED.store(true, Ordering::Relaxed); + let _ = super::take_entries(); + Session { _guard: guard } + } + + /// Take the serialising lock WITHOUT enabling the report, so an + /// "off means nothing is recorded" test cannot race an enabled one. + pub(crate) fn start_disabled() -> Self { + let guard = LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|e| e.into_inner()); + FORCED.store(false, Ordering::Relaxed); + let _ = super::take_entries(); + Session { _guard: guard } + } + + pub(crate) fn entries(&self) -> Vec { + super::take_entries() + } + } + + impl Drop for Session { + fn drop(&mut self) { + let _ = super::take_entries(); + FORCED.store(false, Ordering::Relaxed); + } + } +} + +/// Which lowering region a value lives in. Doubles as the honest half of the +/// hotness signal: a `Closure` body has no loop of its own but is invoked once +/// per element when it is an iterating builtin's callback (see +/// [`Entry::invoked_per_element`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum RegionKind { + Function, + Method, + Closure, + ModuleInit, +} + +impl RegionKind { + pub fn as_str(self) -> &'static str { + match self { + RegionKind::Function => "function", + RegionKind::Method => "method", + RegionKind::Closure => "closure", + RegionKind::ModuleInit => "module-init", + } + } +} + +/// Where the value sits. v1 proves locals; `Param`/`Return`/`Field` exist so +/// the schema does not change when the collectors reach those positions +/// (RFC row 1 / #7034 §1, §3, §4). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Position { + Local, + Param, + Return, + Field, + /// An allocation site that is not bound to a local at all — the + /// `.map(x => ({...}))` idiom. Provenance (rule 1) can never see it. + AllocSite, + /// A whole-module fact (e.g. the §5.2 barrier kill). + Module, +} + +impl Position { + pub fn as_str(self) -> &'static str { + match self { + Position::Local => "local", + Position::Param => "param", + Position::Return => "return", + Position::Field => "field", + Position::AllocSite => "alloc-site", + Position::Module => "module", + } + } +} + +/// Which representation analysis produced this entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Analysis { + /// `collectors/ptr_shape.rs` — shape-proven object locals (RFC Phase 3b). + PtrShape, + /// `collectors/ptr_numarray.rs` — `number[]` locals (RFC Phase 4a.3). + PtrNumArray, + /// `expr/slot_rep.rs` — canonical i32/u32/Str locals (RFC Phase 1 / 3a). + CanonicalSlot, + /// `codegen/typed_abi.rs` — specialized-ABI / typed-clone entries + /// (RFC Phase 2). + SpecAbi, +} + +impl Analysis { + pub fn as_str(self) -> &'static str { + match self { + Analysis::PtrShape => "ptr-shape", + Analysis::PtrNumArray => "ptr-numarray", + Analysis::CanonicalSlot => "canonical-slot", + Analysis::SpecAbi => "spec-abi", + } + } + + /// The representation this analysis would have selected, for headline + /// lines like "Ptr: 0 of 4 candidates promoted". + pub fn target_rep(self) -> &'static str { + match self { + Analysis::PtrShape => "Ptr", + Analysis::PtrNumArray => "Ptr", + Analysis::CanonicalSlot => "I32/U32/Str", + Analysis::SpecAbi => "specialized ABI", + } + } + + /// The file whose rules produced the denial, cited in the report so the + /// rule numbers are checkable against source. + pub fn rule_source(self) -> &'static str { + match self { + Analysis::PtrShape => "collectors/ptr_shape.rs", + Analysis::PtrNumArray => "collectors/ptr_numarray.rs", + Analysis::CanonicalSlot => "expr/slot_rep.rs", + Analysis::SpecAbi => "codegen/typed_abi.rs", + } + } +} + +/// Actionability tier — what makes this a tool rather than a wall of text. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Tier { + /// The developer can change the source: stop reassigning, don't capture + /// it in a closure, keep the record contained. + Fixable, + /// Genuinely polymorphic at run time — correctly boxed, no action. Said + /// explicitly so nobody chases it. + InherentlyPolymorphic, + /// Perry's limitation, not the program's. This tier doubles as a roadmap + /// generator: `issue` names the tracking issue where one exists. + CompilerLimitation, +} + +impl Tier { + pub fn as_str(self) -> &'static str { + match self { + Tier::Fixable => "fixable", + Tier::InherentlyPolymorphic => "inherently-polymorphic", + Tier::CompilerLimitation => "compiler-limitation", + } + } + + pub fn heading(self) -> &'static str { + match self { + Tier::Fixable => "Fixable in your source", + Tier::InherentlyPolymorphic => "Inherently polymorphic (correctly boxed — no action)", + Tier::CompilerLimitation => "Perry limitation (not your code)", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum Outcome { + /// The proof succeeded — the value got an unboxed representation. + Selected, + /// The proof failed — the value stays `Boxed`. + Denied, +} + +/// One reported value. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct Entry { + pub module: String, + pub function: String, + pub region: RegionKind, + pub position: Position, + /// Source-level binding name. HIR keeps names through lowering; a value + /// with no name (an allocation site that never bound a local) gets a + /// synthetic description like `new Row(...)`. + pub name: String, + pub local_id: Option, + pub analysis: Analysis, + pub outcome: Outcome, + /// The representation actually selected — `Ptr`, `I32`, `Boxed`, … + pub rep: String, + /// The rule that denied it, in the collector's own numbering, e.g. + /// `rule 2 (containment)`. `None` on a win. + pub rule: Option, + /// Human-readable expansion of `rule`. + pub reason: Option, + pub tier: Option, + /// Tracking issue for a `CompilerLimitation`, e.g. `#6952`. + pub issue: Option, + /// Loop-nesting depth of the enclosing statement. **A proxy, not a + /// profile** — see [`Entry::invoked_per_element`]. + pub loop_depth: u32, + /// Set when this value's region is the callback of an iterating builtin + /// (`Array.prototype.map`, `sort`, `reduce`, …). Such bodies have + /// `loop_depth == 0` but run once per element, so loop depth alone + /// ranks them wrong — #7034 §8 found 208 of 247 guard sites in exactly + /// this position. Reported as its own column; never folded into + /// `loop_depth`. + pub invoked_per_element: Option, + /// Extra collector-specific context (class name, offending use site). + pub detail: Option, + /// Byte offset in the module source, when the HIR node happens to carry + /// one. Only `Expr::New` does today (#5253, captured for constructor + /// TypeErrors), so this is populated for allocation sites and `None` for + /// ordinary locals — HIR drops positions at lowering. + pub byte_offset: Option, +} + +impl Entry { + /// Ranking key: per-element callbacks first, then loop depth, then a + /// stable name order. Deliberately NOT "loop depth alone" — see + /// `invoked_per_element`. + fn rank(&self) -> (u8, std::cmp::Reverse, &str, &str) { + let hot = u8::from(self.invoked_per_element.is_none()); + ( + hot, + std::cmp::Reverse(self.loop_depth), + self.function.as_str(), + self.name.as_str(), + ) + } + + /// Identity for de-duplication. A function can be lowered more than once + /// (a boxed entry plus a typed clone), which would otherwise double-count + /// every denial in it. + fn dedup_key(&self) -> (String, String, String, Position, Analysis, Option) { + ( + self.module.clone(), + self.function.clone(), + self.name.clone(), + self.position, + self.analysis, + self.rule.clone(), + ) + } +} + +// ── Attribution scope ────────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +struct Scope { + module: String, + function: String, + region: RegionKind, + invoked_per_element: Option, +} + +thread_local! { + static SCOPE: RefCell> = const { RefCell::new(None) }; +} + +/// RAII scope guard. Restores the previous scope on drop so a nested region +/// (a constructor inlined into a function body, say) cannot leak attribution +/// into its parent. +pub struct ScopeGuard { + previous: Option, + /// `false` when the report is off — drop then does nothing at all. + active: bool, +} + +impl Drop for ScopeGuard { + fn drop(&mut self) { + if !self.active { + return; + } + let previous = self.previous.take(); + SCOPE.with(|s| *s.borrow_mut() = previous); + } +} + +/// Bracket a lowering region, inheriting the module name from the ambient +/// module scope opened by [`enter_module`] in `compile_module`. +/// +/// Region callers deliberately do NOT name their own module: `hir.name` and +/// `strings.module_prefix()` are different spellings of the same module +/// (`batch.ts` vs `batch_ts`), and having both in one report made it look +/// like two modules. +pub(crate) fn enter_region(function: &str, region: RegionKind) -> ScopeGuard { + if !enabled() { + return ScopeGuard { + previous: None, + active: false, + }; + } + let module = current_module(); + enter(&module, function, region) +} + +fn current_module() -> String { + SCOPE.with(|s| { + s.borrow() + .as_ref() + .map(|sc| sc.module.clone()) + .unwrap_or_else(|| String::from("")) + }) +} + +/// 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 { + if !enabled() { + return ScopeGuard { + previous: None, + active: false, + }; + } + let scope = Scope { + module: module.to_string(), + function: function.to_string(), + region, + invoked_per_element: None, + }; + let previous = SCOPE.with(|s| s.borrow_mut().replace(scope)); + ScopeGuard { + previous, + active: true, + } +} + +/// Like [`enter`], for a closure body. `func_id` resolves the per-element +/// callback role recorded by [`scan_module`] — the honest hotness column for +/// bodies that have no loop of their own (#7034 §8). +pub(crate) fn enter_closure(function: &str, func_id: u32) -> ScopeGuard { + if !enabled() { + return ScopeGuard { + previous: None, + active: false, + }; + } + let scope = Scope { + module: current_module(), + function: function.to_string(), + region: RegionKind::Closure, + invoked_per_element: per_element_role(Some(func_id)), + }; + let previous = SCOPE.with(|s| s.borrow_mut().replace(scope)); + ScopeGuard { + previous, + active: true, + } +} + +// ── Per-element callback registry ────────────────────────────────────────── + +/// Closure `FuncId` -> the iterating builtin it is the callback of. +/// Populated once per module by [`scan_module`]; read when that closure's +/// region opens its scope. Keyed by id rather than symbol name because the +/// same source arrow can be lowered under several symbols (typed clones). +static PER_ELEMENT: OnceLock>> = OnceLock::new(); + +fn per_element_map() -> &'static Mutex> { + PER_ELEMENT.get_or_init(|| Mutex::new(std::collections::HashMap::new())) +} + +/// Record that closure `func_id` is the callback of `builtin` +/// (`Array.prototype.map`, …). +pub(crate) fn note_per_element_callback(func_id: u32, builtin: &'static str) { + if !enabled() { + return; + } + if let Ok(mut map) = per_element_map().lock() { + map.insert(func_id, builtin); + } +} + +fn per_element_role(func_id: Option) -> Option { + let id = func_id?; + per_element_map() + .lock() + .ok() + .and_then(|m| m.get(&id).map(|s| (*s).to_string())) +} + +// ── Sink ─────────────────────────────────────────────────────────────────── + +static SINK: OnceLock>> = OnceLock::new(); + +fn sink() -> &'static Mutex> { + SINK.get_or_init(|| Mutex::new(Vec::new())) +} + +/// Push a fully-formed entry. Prefer [`deny`] / [`select`], which fill the +/// module/function from the active scope. +pub(crate) fn push(entry: Entry) { + if !enabled() { + return; + } + if let Ok(mut v) = sink().lock() { + v.push(entry); + } +} + +/// Everything a denial needs beyond the ambient scope. +pub(crate) struct Denial<'a> { + pub position: Position, + pub name: &'a str, + pub local_id: Option, + pub analysis: Analysis, + pub rule: &'a str, + pub reason: &'a str, + pub tier: Tier, + pub issue: Option<&'a str>, + pub loop_depth: u32, + pub detail: Option, + pub byte_offset: Option, +} + +/// Record a value that stayed `Boxed`, attributed to the active scope. +/// +/// Callers **must** gate the call on [`enabled`] when building `reason` / +/// `detail` costs anything: the arguments are evaluated before this function +/// can early-return. +pub(crate) fn deny(d: Denial<'_>) { + if !enabled() { + return; + } + let (module, function, region, per_element) = SCOPE.with(|s| match s.borrow().as_ref() { + Some(sc) => ( + sc.module.clone(), + sc.function.clone(), + sc.region, + sc.invoked_per_element.clone(), + ), + None => ( + String::from(""), + String::from(""), + RegionKind::Function, + None, + ), + }); + push(Entry { + module, + function, + region, + position: d.position, + name: d.name.to_string(), + local_id: d.local_id, + analysis: d.analysis, + outcome: Outcome::Denied, + rep: String::from("Boxed"), + rule: Some(d.rule.to_string()), + reason: Some(d.reason.to_string()), + tier: Some(d.tier), + issue: d.issue.map(str::to_string), + loop_depth: d.loop_depth, + invoked_per_element: per_element, + detail: d.detail, + byte_offset: d.byte_offset, + }); +} + +/// Record a denial for a named function in the ambient scope's **module**, +/// for sites that know their function but do not run inside a lowering +/// region — the specialized-ABI entry decision, which is taken in +/// `compile_module`'s own loop rather than during body lowering. +pub(crate) fn deny_named(function: &str, region: RegionKind, d: Denial<'_>) { + if !enabled() { + return; + } + push(Entry { + module: current_module(), + function: function.to_string(), + region, + position: d.position, + name: d.name.to_string(), + local_id: d.local_id, + analysis: d.analysis, + outcome: Outcome::Denied, + rep: String::from("Boxed"), + rule: Some(d.rule.to_string()), + reason: Some(d.reason.to_string()), + tier: Some(d.tier), + issue: d.issue.map(str::to_string), + loop_depth: d.loop_depth, + invoked_per_element: None, + detail: d.detail, + byte_offset: d.byte_offset, + }); +} + +/// Open a module-wide fallback scope. Region scopes nest inside it and +/// restore it on drop, so a site with no region of its own still knows which +/// module it is in. +pub(crate) fn enter_module(module: &str) -> ScopeGuard { + enter(module, "", RegionKind::ModuleInit) +} + +/// Record a value that *did* get an unboxed representation, attributed to the +/// active scope. Reporting wins matters: a report that only nags is less +/// useful, and less trusted, than one that shows the ratio. +pub(crate) fn select( + position: Position, + name: &str, + local_id: Option, + analysis: Analysis, + rep: &str, + loop_depth: u32, + detail: Option, +) { + if !enabled() { + return; + } + let (module, function, region, per_element) = SCOPE.with(|s| match s.borrow().as_ref() { + Some(sc) => ( + sc.module.clone(), + sc.function.clone(), + sc.region, + sc.invoked_per_element.clone(), + ), + None => ( + String::from(""), + String::from(""), + RegionKind::Function, + None, + ), + }); + push(Entry { + module, + function, + region, + position, + name: name.to_string(), + local_id, + analysis, + outcome: Outcome::Selected, + rep: rep.to_string(), + rule: None, + reason: None, + tier: None, + issue: None, + loop_depth, + invoked_per_element: per_element, + detail, + byte_offset: None, + }); +} + +/// Record a win from a site that already knows its own function and module +/// (an `FnCtx` holder), bypassing the thread-local scope. +pub(crate) fn select_explicit( + function: &str, + region: RegionKind, + position: Position, + name: &str, + local_id: Option, + analysis: Analysis, + rep: &str, +) { + if !enabled() { + return; + } + push(Entry { + module: current_module(), + function: function.to_string(), + region, + position, + name: name.to_string(), + local_id, + analysis, + outcome: Outcome::Selected, + rep: rep.to_string(), + rule: None, + reason: None, + tier: None, + issue: None, + loop_depth: 0, + invoked_per_element: None, + detail: None, + byte_offset: None, + }); +} + +/// Drain every recorded entry, de-duplicated and ranked. Called once by the +/// CLI after module codegen finishes. +pub fn take_entries() -> Vec { + let mut entries = match sink().lock() { + Ok(mut v) => std::mem::take(&mut *v), + Err(_) => Vec::new(), + }; + let mut seen = std::collections::HashSet::new(); + entries.retain(|e| seen.insert(e.dedup_key())); + entries.sort_by(|a, b| a.rank().cmp(&b.rank())); + entries +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(function: &str, loop_depth: u32, per_element: Option<&str>) -> Entry { + Entry { + module: "m".into(), + function: function.into(), + region: RegionKind::Function, + position: Position::Local, + name: "v".into(), + local_id: Some(1), + analysis: Analysis::PtrShape, + outcome: Outcome::Denied, + rep: "Boxed".into(), + rule: Some("rule 2 (containment)".into()), + reason: Some("escapes".into()), + tier: Some(Tier::Fixable), + issue: None, + loop_depth, + invoked_per_element: per_element.map(str::to_string), + detail: None, + byte_offset: None, + } + } + + /// #7034 §8: a loop-depth-only ranking puts iterating-builtin callbacks + /// (which have no loop of their own but run per element) at the bottom. + /// The rank must place them above a shallower explicit loop. + #[test] + fn per_element_callbacks_outrank_deeper_loops() { + let callback = entry("closure_3", 0, Some("Array.prototype.map")); + let loop_local = entry("aaa_outer", 2, None); + assert!( + callback.rank() < loop_local.rank(), + "a per-element callback must not sort below a plain loop-depth-2 local" + ); + } + + #[test] + fn deeper_loops_outrank_shallower_ones() { + assert!(entry("f", 3, None).rank() < entry("f", 1, None).rank()); + } + + #[test] + fn dedup_key_collapses_a_function_lowered_twice() { + let a = entry("f", 0, None); + let b = entry("f", 0, None); + assert_eq!(a.dedup_key(), b.dedup_key()); + } + + #[test] + fn dedup_key_separates_different_rules() { + let a = entry("f", 0, None); + let mut b = entry("f", 0, None); + b.rule = Some("rule 5 (module barrier)".into()); + assert_ne!(a.dedup_key(), b.dedup_key()); + } +} diff --git a/crates/perry-codegen/src/opt_report/render.rs b/crates/perry-codegen/src/opt_report/render.rs new file mode 100644 index 0000000000..2e23d0f8a3 --- /dev/null +++ b/crates/perry-codegen/src/opt_report/render.rs @@ -0,0 +1,427 @@ +//! Renderers for `--opt-report` (#6952): human-readable text and a stable +//! JSON schema. +//! +//! The text renderer's first job is the one thing #7034 asked for: state +//! plainly, in one command, how many candidates each representation promoted +//! — *including when the answer is zero* — and name the rule that denied each +//! one. The wins are reported alongside the misses on purpose: a report that +//! only nags is less useful, and less trusted, than one that shows the ratio. + +use std::collections::BTreeMap; +use std::fmt::Write as _; + +use super::{Analysis, Entry, Outcome, Tier}; + +/// Bump when a field is removed or its meaning changes. Additive fields do +/// not require a bump — consumers must ignore unknown keys. +pub const SCHEMA_VERSION: u32 = 1; + +/// How many denials to show per tier before collapsing the tail. The cold +/// tail is real information but it is not the actionable part. +const MAX_ROWS_PER_TIER: usize = 25; + +#[derive(Debug, Clone, Default, serde::Serialize)] +struct AnalysisTally { + selected: usize, + denied: usize, +} + +impl AnalysisTally { + fn candidates(&self) -> usize { + self.selected + self.denied + } +} + +fn tally(entries: &[Entry]) -> BTreeMap { + let mut out: BTreeMap = BTreeMap::new(); + for e in entries { + let slot = out.entry(e.analysis).or_default(); + match e.outcome { + Outcome::Selected => slot.selected += 1, + Outcome::Denied => slot.denied += 1, + } + } + out +} + +/// One-line hotness annotation. Loop depth and per-element invocation are +/// reported as *separate* facts — #7034 §8 measured that 208 of 247 guard +/// sites in the motivating workload sit in callback bodies with zero loop +/// depth, so collapsing them into one "hotness" number would rank them wrong. +fn hotness(e: &Entry) -> String { + match (&e.invoked_per_element, e.loop_depth) { + (Some(builtin), 0) => format!("per-element callback of {builtin}"), + (Some(builtin), d) => format!("per-element callback of {builtin}, loop depth {d}"), + (None, 0) => String::from("loop depth 0"), + (None, d) => format!("loop depth {d}"), + } +} + +/// Human-readable report. +pub fn render_text(entries: &[Entry]) -> String { + let mut out = String::new(); + let tallies = tally(entries); + + out.push_str("Perry optimization report (--opt-report)\n"); + out.push_str("========================================\n\n"); + + if entries.is_empty() { + out.push_str( + "No representation decisions were recorded.\n\n\ + This means codegen did not run for any module — most often an object-cache\n\ + hit. `--opt-report` disables the object and build caches for its own run, so\n\ + if you are seeing this, the build produced no native modules at all.\n", + ); + return out; + } + + // ── Summary: wins and misses side by side ────────────────────────────── + let total_selected: usize = tallies.values().map(|t| t.selected).sum(); + let total_denied: usize = tallies.values().map(|t| t.denied).sum(); + out.push_str("Representation summary\n"); + out.push_str("----------------------\n"); + for (analysis, t) in &tallies { + let pct = if t.candidates() == 0 { + 0 + } else { + t.selected * 100 / t.candidates() + }; + let _ = writeln!( + out, + " {:<16} {:>4} selected / {:>4} denied ({pct}% of {} candidates)", + analysis.target_rep(), + t.selected, + t.denied, + t.candidates(), + ); + } + let _ = writeln!( + out, + " {:<16} {total_selected} values unboxed, {total_denied} left Boxed", + "TOTAL", + ); + out.push('\n'); + + // ── Headline: call out any representation that promoted nothing ──────── + // This is the #7034 §0 finding, stated without having to read IR. + let mut zeros: Vec<(&Analysis, &AnalysisTally)> = tallies + .iter() + .filter(|(_, t)| t.selected == 0 && t.denied > 0) + .collect(); + zeros.sort_by_key(|(a, _)| **a); + for (analysis, t) in zeros { + let _ = writeln!( + out, + "*** {} promoted 0 of {} candidates in this build. ***", + analysis.target_rep(), + t.candidates(), + ); + let _ = writeln!( + out, + " Every candidate was denied; the rules are in {}.\n", + analysis.rule_source(), + ); + } + + // ── Denials, ranked, grouped by actionability tier ───────────────────── + let denials: Vec<&Entry> = entries + .iter() + .filter(|e| e.outcome == Outcome::Denied) + .collect(); + if denials.is_empty() { + out.push_str("No denied values — every proven-typeable value got an unboxed\n"); + out.push_str("representation in this build.\n\n"); + } else { + out.push_str("Denied values, hottest first\n"); + out.push_str("----------------------------\n"); + out.push_str( + "Hotness is a static proxy, not a profile. `per-element callback` means the\n\ + enclosing region is an iterating builtin's callback: it has no loop of its\n\ + own but runs once per element, so loop depth alone would rank it last.\n\n", + ); + } + + for tier in [ + Tier::Fixable, + Tier::CompilerLimitation, + Tier::InherentlyPolymorphic, + ] { + let rows: Vec<&&Entry> = denials + .iter() + .filter(|e| e.tier == Some(tier)) + .collect::>(); + if rows.is_empty() { + continue; + } + let _ = writeln!(out, "{} ({} value(s))", tier.heading(), rows.len()); + for e in rows.iter().take(MAX_ROWS_PER_TIER) { + let _ = writeln!( + out, + " {} :: {} `{}` [{}]", + e.module, + e.position.as_str(), + e.name, + hotness(e), + ); + let _ = writeln!( + out, + " in {} {} -> {}", + e.region.as_str(), + e.function, + e.rep, + ); + if let Some(rule) = &e.rule { + let _ = writeln!(out, " {} {rule}", e.analysis.as_str()); + } + if let Some(reason) = &e.reason { + let _ = writeln!(out, " {reason}"); + } + if let Some(detail) = &e.detail { + let _ = writeln!(out, " {detail}"); + } + if let Some(offset) = e.byte_offset { + let _ = writeln!(out, " source byte offset {offset}"); + } + if let Some(issue) = &e.issue { + let _ = writeln!(out, " tracking: {issue}"); + } + } + if rows.len() > MAX_ROWS_PER_TIER { + let _ = writeln!( + out, + " ... and {} more (use --opt-report=json for the full list)", + rows.len() - MAX_ROWS_PER_TIER, + ); + } + out.push('\n'); + } + + // ── Wins, compactly ──────────────────────────────────────────────────── + let wins: Vec<&Entry> = entries + .iter() + .filter(|e| e.outcome == Outcome::Selected) + .collect(); + if !wins.is_empty() { + let _ = writeln!(out, "Unboxed values ({})", wins.len()); + out.push_str("-----------------\n"); + for e in wins.iter().take(MAX_ROWS_PER_TIER) { + let _ = writeln!( + out, + " {} :: {} `{}` -> {} (in {} {})", + e.module, + e.position.as_str(), + e.name, + e.rep, + e.region.as_str(), + e.function, + ); + } + if wins.len() > MAX_ROWS_PER_TIER { + let _ = writeln!(out, " ... and {} more", wins.len() - MAX_ROWS_PER_TIER); + } + out.push('\n'); + } + + out.push_str( + "Values are reported by function and binding name: HIR keeps names through\n\ + lowering but not source spans, so there is no file:line yet.\n", + ); + out +} + +#[derive(Debug, serde::Serialize)] +struct JsonAnalysis<'a> { + analysis: &'a str, + target_rep: &'a str, + rule_source: &'a str, + selected: usize, + denied: usize, +} + +#[derive(Debug, serde::Serialize)] +struct JsonSummary<'a> { + selected: usize, + denied: usize, + by_analysis: Vec>, +} + +#[derive(Debug, serde::Serialize)] +struct JsonReport<'a> { + schema_version: u32, + summary: JsonSummary<'a>, + entries: &'a [Entry], +} + +/// Machine-readable report. The schema is stable enough for CI to diff two +/// builds and catch a representation regression (a `selected` count silently +/// going to zero — exactly what #7034 found by hand). +pub fn render_json(entries: &[Entry]) -> String { + let tallies = tally(entries); + let report = JsonReport { + schema_version: SCHEMA_VERSION, + summary: JsonSummary { + selected: tallies.values().map(|t| t.selected).sum(), + denied: tallies.values().map(|t| t.denied).sum(), + by_analysis: tallies + .iter() + .map(|(a, t)| JsonAnalysis { + analysis: a.as_str(), + target_rep: a.target_rep(), + rule_source: a.rule_source(), + selected: t.selected, + denied: t.denied, + }) + .collect(), + }, + entries, + }; + serde_json::to_string_pretty(&report).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::opt_report::{Position, RegionKind}; + + fn denied(analysis: Analysis, name: &str, rule: &str) -> Entry { + Entry { + module: "batch.ts".into(), + function: "buildRows".into(), + region: RegionKind::Function, + position: Position::Local, + name: name.into(), + local_id: Some(7), + analysis, + outcome: Outcome::Denied, + rep: "Boxed".into(), + rule: Some(rule.into()), + reason: Some("used as a call argument".into()), + tier: Some(Tier::Fixable), + issue: None, + loop_depth: 1, + invoked_per_element: None, + detail: None, + byte_offset: None, + } + } + + fn selected(analysis: Analysis, name: &str, rep: &str) -> Entry { + Entry { + module: "batch.ts".into(), + function: "containedTotals".into(), + region: RegionKind::Function, + position: Position::Local, + name: name.into(), + local_id: Some(9), + analysis, + outcome: Outcome::Selected, + rep: rep.into(), + rule: None, + reason: None, + tier: None, + issue: None, + loop_depth: 0, + invoked_per_element: None, + detail: None, + byte_offset: None, + } + } + + /// The #7034 §0 acceptance case: when a representation promotes nothing, + /// the text report must SAY so, not leave the reader to infer it from an + /// absent section. + #[test] + fn zero_promotion_is_stated_explicitly() { + let entries = vec![ + denied(Analysis::PtrShape, "row", "rule 2 (containment)"), + denied(Analysis::PtrShape, "s", "rule 2 (containment)"), + ]; + let text = render_text(&entries); + assert!( + text.contains("Ptr promoted 0 of 2 candidates"), + "report must state the zero-promotion headline; got:\n{text}" + ); + assert!( + text.contains("collectors/ptr_shape.rs"), + "report must cite the rule source; got:\n{text}" + ); + assert!( + text.contains("rule 2 (containment)"), + "report must name the denying rule; got:\n{text}" + ); + } + + /// The headline must NOT fire when the representation did promote + /// something — otherwise it is noise and gets ignored. + #[test] + fn zero_promotion_headline_is_absent_when_something_promoted() { + let entries = vec![ + denied(Analysis::PtrShape, "row", "rule 2 (containment)"), + selected(Analysis::PtrShape, "acc", "Ptr"), + ]; + let text = render_text(&entries); + assert!( + !text.contains("promoted 0 of"), + "headline must not fire when a candidate was promoted; got:\n{text}" + ); + assert!( + text.contains("1 selected / 1 denied"), + "summary must show the ratio; got:\n{text}" + ); + } + + /// Wins are reported, not just misses. + #[test] + fn wins_are_reported() { + let entries = vec![selected(Analysis::CanonicalSlot, "i", "I32")]; + let text = render_text(&entries); + assert!(text.contains("Unboxed values (1)"), "got:\n{text}"); + assert!(text.contains("`i` -> I32"), "got:\n{text}"); + assert!( + text.contains("1 values unboxed, 0 left Boxed"), + "got:\n{text}" + ); + } + + #[test] + fn empty_report_explains_itself() { + let text = render_text(&[]); + assert!(text.contains("No representation decisions were recorded")); + } + + #[test] + fn json_carries_schema_version_and_tallies() { + let entries = vec![ + denied(Analysis::PtrShape, "row", "rule 2 (containment)"), + selected(Analysis::CanonicalSlot, "i", "I32"), + ]; + let json: serde_json::Value = serde_json::from_str(&render_json(&entries)).unwrap(); + assert_eq!(json["schema_version"], SCHEMA_VERSION); + assert_eq!(json["summary"]["selected"], 1); + assert_eq!(json["summary"]["denied"], 1); + assert_eq!(json["entries"].as_array().unwrap().len(), 2); + let ptr_shape = json["summary"]["by_analysis"] + .as_array() + .unwrap() + .iter() + .find(|a| a["analysis"] == "ptr-shape") + .expect("ptr-shape tally present"); + assert_eq!(ptr_shape["selected"], 0); + assert_eq!(ptr_shape["denied"], 1); + assert_eq!(ptr_shape["target_rep"], "Ptr"); + } + + /// The per-element column must be visible in text — it is the honest + /// half of the hotness story (#7034 §8). + #[test] + fn per_element_context_is_rendered_separately_from_loop_depth() { + let mut e = denied(Analysis::PtrShape, "rec", "rule 1 (provenance)"); + e.loop_depth = 0; + e.invoked_per_element = Some("Array.prototype.map".into()); + let text = render_text(&[e]); + assert!( + text.contains("per-element callback of Array.prototype.map"), + "got:\n{text}" + ); + } +} diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index a3f7252e63..98e4d24d28 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -388,6 +388,11 @@ fn eligibility(args: &CompileArgs, project_root: &Path) -> Result<(), String> { if args.explain_lowering { return Err("explain-lowering".to_string()); } + // #6952: a cached build reuses the finished binary and never runs codegen, + // so the report would be empty. Same reasoning as explain-lowering above. + if args.opt_report.is_some() || std::env::var("PERRY_OPT_REPORT").is_ok() { + return Err("opt-report".to_string()); + } if args.verify_native_regions || args.emit_attest || args.emit_sandbox { return Err("sidecar-or-verify".to_string()); } diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 79295fa21a..68be249833 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -190,6 +190,32 @@ pub fn run_with_parse_cache( } } + // `--opt-report` (#6952): promote to the env var the codegen collectors + // read, single-threaded before rayon spawns the module workers — the same + // discipline as `--debug-symbols` and `--trace llvm` above. `PERRY_NO_CACHE` + // goes with it because the per-module object cache short-circuits codegen + // for unchanged modules, and a skipped `compile_module` records nothing: + // the report would come up empty on the second run of an unchanged file. + // The flag is deliberately NOT part of the object-cache key — it changes + // no emitted byte, it only forces codegen to actually run. + let opt_report_format = + args.opt_report + .or_else(|| match std::env::var("PERRY_OPT_REPORT").as_deref() { + Ok("json") => Some(OptReportFormat::Json), + Ok("1") | Ok("text") => Some(OptReportFormat::Text), + _ => None, + }); + if let Some(fmt) = opt_report_format { + std::env::set_var( + "PERRY_OPT_REPORT", + match fmt { + OptReportFormat::Json => "json", + OptReportFormat::Text => "text", + }, + ); + std::env::set_var("PERRY_NO_CACHE", "1"); + } + // Canonicalize the input path first so its `.parent()` is an absolute directory. // Without this, a bare filename like `perry demo.ts` produced `Path::new("").parent()` // → fallback `"."`, and the walk-up loops below (package.json + perry.toml discovery) @@ -4548,6 +4574,18 @@ pub fn run_with_parse_cache( explain_lowering.emit(format)?; } + // `--opt-report` (#6952). Drained once, after every module's codegen has + // finished, and written to stderr so it never contaminates a `--format + // json` stdout payload or a piped program output. + if let Some(fmt) = opt_report_format { + let entries = perry_codegen::opt_report::take_entries(); + let rendered = match fmt { + OptReportFormat::Json => perry_codegen::opt_report::render_json(&entries), + OptReportFormat::Text => perry_codegen::opt_report::render_text(&entries), + }; + eprintln!("{rendered}"); + } + // #835 + #846: fold the codegen-side FFI provenance registry into // ctx so the well-known flip and `needs_stdlib` decisions below see // the symbols codegen actually emitted, not just the modules the diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index 0723c87a1f..59910b5092 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -473,6 +473,36 @@ pub struct CompileArgs { /// dump. No effect unless `--trace hir` (or `--print-hir`) is set. #[arg(long, value_name = "NAME")] pub focus: Option, + + /// Report which values Perry could NOT statically type, why, and whether + /// you can do anything about it (#6952) — the representation-selection + /// analogue of LLVM's `-Rpass-missed` remarks. + /// + /// Perry's speed comes from proving static types and selecting unboxed + /// representations; when a proof fails the value stays NaN-boxed and the + /// fast paths silently do not fire. This prints, per value: its position + /// (local / param / return / allocation site), the representation it got, + /// the collector rule that denied it, an actionability tier, and a static + /// hotness proxy. Wins are reported too, so you can see the ratio. + /// + /// `--opt-report` prints human-readable text; `--opt-report=json` emits a + /// stable schema for tooling (CI can diff two builds to catch a silent + /// representation regression). Also settable via `PERRY_OPT_REPORT=1`. + /// + /// Observational only — emitted code is byte-identical with the flag on + /// and off. It does disable build/object cache reuse for its own run, so + /// that codegen actually executes and has something to report. + #[arg(long, value_enum, num_args = 0..=1, default_missing_value = "text")] + pub opt_report: Option, +} + +/// Output format for `--opt-report`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)] +pub enum OptReportFormat { + /// Human-readable, ranked, grouped by actionability tier. + Text, + /// Stable JSON schema for tooling. + Json, } /// Information about a JavaScript module that will be interpreted at runtime diff --git a/crates/perry/src/commands/dev.rs b/crates/perry/src/commands/dev.rs index 763d8830e4..62a92d78ff 100644 --- a/crates/perry/src/commands/dev.rs +++ b/crates/perry/src/commands/dev.rs @@ -313,6 +313,7 @@ fn build_once( verify_native_regions: false, disable_buffer_fast_path: false, explain_lowering: false, + opt_report: None, emit_attest: false, emit_sandbox: false, lockdown: false, diff --git a/crates/perry/src/commands/run/mod.rs b/crates/perry/src/commands/run/mod.rs index d0d43f98e8..6669976afc 100644 --- a/crates/perry/src/commands/run/mod.rs +++ b/crates/perry/src/commands/run/mod.rs @@ -225,6 +225,7 @@ pub fn run(args: RunArgs, format: OutputFormat, use_color: bool, verbose: u8) -> verify_native_regions: false, disable_buffer_fast_path: false, explain_lowering: false, + opt_report: None, emit_attest: false, emit_sandbox: false, lockdown: false, diff --git a/docs/src/cli/flags.md b/docs/src/cli/flags.md index 104614328c..482e5e2d02 100644 --- a/docs/src/cli/flags.md +++ b/docs/src/cli/flags.md @@ -101,6 +101,7 @@ accept either the `$perryfs/` virtual path or the embed-relative key. | `--no-link` | Produce `.o` object file only, skip linking | | `--no-codegen` | Skip the `package.json` `perry.codegen` build-time steps (also `PERRY_SKIP_CODEGEN=1`). See [Project Configuration](../getting-started/project-config.md) | | `--keep-intermediates` | Keep `.o` and `.asm` intermediate files | +| `--opt-report[=json]` | Report which values Perry could **not** statically type, why, and whether you can fix it. Text by default; `--opt-report=json` emits a stable schema for tooling. Also settable via `PERRY_OPT_REPORT=1` | The `--trace`/`--focus` pair localizes "compiled to the wrong thing" bugs: `perry compile foo.ts --trace hir,llvm --focus parseRow` dumps just the @@ -109,6 +110,67 @@ which stage corrupted it without scrolling a full-module dump. `--trace llvm` forces a full recompile (the object cache otherwise skips codegen for unchanged modules, leaving the trace dir empty). +### `--opt-report` — why a value stayed boxed + +Perry's speed comes from proving static types and selecting unboxed +representations (see the +[representation-selection RFC](https://github.com/PerryTS/perry/blob/main/docs/representation-selection-rfc.md)). +When a proof fails the value stays NaN-boxed and the fast paths silently do +not fire. `--opt-report` is the representation-selection analogue of LLVM's +`-Rpass-missed` remarks: it prints what was proven, what was not, and which +rule made the call. + +```console +$ perry compile batch.ts -o batch --opt-report +Representation summary +---------------------- + Ptr 0 selected / 4 denied (0% of 4 candidates) + I32/U32/Str 3 selected / 0 denied (100% of 3 candidates) + specialized ABI 0 selected / 3 denied (0% of 3 candidates) + TOTAL 3 values unboxed, 7 left Boxed + +*** Ptr promoted 0 of 4 candidates in this build. *** + Every candidate was denied; the rules are in collectors/ptr_shape.rs. +... + batch.ts :: local `acc` [loop depth 0] + in function totalsRow -> Boxed + ptr-shape rule 2 (containment) + returned from this function. Return positions do not carry a shape + fact yet, so returning a record forfeits its proof. +``` + +Each denied value carries: + +- **Position** — `local`, `param`, `return`, `field`, or `alloc-site` (an + object literal that is never bound to a local, the `.map(x => ({...}))` + idiom). +- **The rule that denied it**, in the collector's own numbering, so you can + check it against the named source file. +- **An actionability tier**: *Fixable in your source* (stop reassigning it, + don't capture it in a closure), *Inherently polymorphic* (correctly boxed — + no action), or *Perry limitation* (not your code; names the tracking issue + where one exists). +- **A hotness proxy.** Loop-nesting depth **and**, separately, whether the + enclosing region is an iterating builtin's callback. The two are reported + as distinct columns on purpose: a `map`/`sort`/`reduce` callback has no + loop of its own but runs once per element, so loop depth alone ranks it + last. Neither is a profile — they are static proxies. + +Wins are reported alongside the misses, so the ratio is visible rather than +just the complaints. + +**It is observational only** — emitted code is byte-identical with the flag +on and off. Like `--trace llvm`, it disables build and object cache reuse for +its own run, because a cache hit skips codegen entirely and there would be +nothing to report. The report goes to **stderr**, so it never mixes into a +`--format json` payload or your program's piped output. + +Values are identified by **function and binding name**: Perry's HIR keeps +names through lowering but drops source positions, so there is no `file:line` +yet. `--opt-report=json` carries the same data under `schema_version: 1`; +diffing two builds' JSON is a cheap CI check against a representation +silently regressing to zero. + ## Output Optimization | Flag | Description | @@ -164,6 +226,7 @@ shrink less, proportionally. | `PERRY_UPDATE_SERVER` | Custom update server URL | | `CI=true` | Auto-skip update checks (set by most CI systems) | | `RUST_LOG` | Debug logging level (`debug`, `info`, `trace`) | +| `PERRY_OPT_REPORT` | `1`/`text` or `json` — same as `--opt-report[=json]`, for driving the report from an environment where adding a flag is awkward | ## Configuration Files From 66b06328f565df45898001f579d38a633a193424 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 30 Jul 2026 05:56:39 +0200 Subject: [PATCH 2/2] docs(changelog): fragment for #7037 --- changelog.d/7037-opt-report.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/7037-opt-report.md diff --git a/changelog.d/7037-opt-report.md b/changelog.d/7037-opt-report.md new file mode 100644 index 0000000000..0ac27f9858 --- /dev/null +++ b/changelog.d/7037-opt-report.md @@ -0,0 +1,5 @@ +**`--opt-report`: see which values Perry could not statically type, and why (#6952).** Perry's speed comes from proving static types and selecting unboxed representations; when a proof fails the value stays NaN-boxed and the fast paths silently do not fire. Until now the only way to find out *which* values failed was to read LLVM IR — which is how #7034 discovered, by accident, that `Ptr` promotes **zero** locals on the object-heavy workload that motivates it. `perry compile app.ts --opt-report` now answers that in one command: per value it reports the position (local / param / return / allocation site), the representation it got, the collector rule that denied it in that collector's own numbering, an actionability tier (fixable in your source / inherently polymorphic / Perry limitation, with the tracking issue), and a static hotness proxy. Wins are reported alongside the misses so the ratio is visible. `--opt-report=json` emits a stable schema (`schema_version: 1`) that CI can diff between builds to catch a representation silently regressing to zero. + +Covers `Ptr` locals and allocation sites (all five rules plus class admission, with the escape *kind* discriminated — reassignment, closure capture, call argument, return and container element are five different fixes), canonical i32/u32/Str locals, and the specialized-ABI entry decision, which is the only place params and returns carry a representation today. Allocation sites that are never bound to a local — the `.map(x => ({...}))` idiom that provenance structurally cannot see, and the majority of records in real code — are reported too. Hotness is reported as loop depth **and** iterating-builtin-callback status in separate columns, never collapsed: #7034 measured that 208 of 247 guard sites in the motivating program sit in callback bodies with zero loop depth, so a loop-nesting proxy alone ranks exactly the hot sites last. + +Off by default and observational only: emitted LLVM IR is byte-identical with the flag on and off (verified on two programs), so it is deliberately not part of the object-cache key. Like `--trace llvm` it disables build/object cache reuse for its own run, because a cache hit skips codegen and there would be nothing to report. Values are identified by function and binding name — HIR keeps names through lowering but drops source positions; the `LocalId → Span` side-table that would add `file:line` and source snippets is #7036.