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/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. 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