Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions changelog.d/7819-numarray-analysis-disabled-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
**`--opt-report` now names the module-wide `Ptr<NumArray>` kill instead of going silent** (#7112, whole-analysis half).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use the current PR key in the fragment filename.

The PR objective identifies this change as PR #7112, but this file is named 7819-numarray-analysis-disabled-report.md. Rename it to changelog.d/7112-numarray-analysis-disabled-report.md, unless the PR objective is incorrect.

As per coding guidelines: add one PR-keyed changelog.d/<PR>-<slug>.md fragment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@changelog.d/7819-numarray-analysis-disabled-report.md` at line 1, Rename the
changelog fragment to use the current PR key 7112, preserving the existing
numarray-analysis-disabled-report slug and content.

Source: Coding guidelines


`collect_num_array_locals` returns an empty map outright when the module has a shape-barrier site, a numarray prototype-index barrier, or an opaque prototype mutation — and when the analysis is switched off entirely. That happens **before** any array local becomes a candidate, so no per-value `deny()` runs and the report shows the module as having no array candidates at all.

That reads identically to "the analysis never looked at these values", which is precisely the ambiguity the report exists to remove — and it is why 8 of the census's 18 real workloads read "zero candidates".

The kill is now recorded once, naming which of the four causes fired:

```
<module> | rule 0 (analysis disabled)
| cause: an opaque (aliased) prototype mutation in this module
| tier: compiler-limitation | issue: #7112
```

Verified both directions on a two-module pair: a module containing `const p = Array.prototype; p[5] = 1` alongside a numeric array emits exactly one `rule 0` entry with that cause; the same module without the prototype write emits **zero**. So the entry means "the analysis stopped upstream", not "this module has arrays".

Two deliberate choices worth stating:

* It is attributed to a pseudo-name `<module>` with no `local_id`, rather than to some array local. A module-wide kill has no value to blame, and inventing one would put a denial on an array that was never examined — the same misattribution in the other direction.
* The four causes are now separate `if` arms rather than one `||` chain, purely so the report can say *which* barrier fired. The chain's alias-hole rationale (why `has_opaque_prototype_mutation` exists at all, given the direct-form kill above it) moves onto the arm that owns it rather than being left orphaned above a condition it no longer describes.

**The element-type pre-filter is covered too**, which is the case the issue leads with. `benchmarks/suite/11_prime_sieve.ts` reported **0** `ptr-numarray` candidates — reading as "this program has no array worth promoting", for a program whose 1,000,000-element array is in the hot loop indexed by a proven integer. It now reports:

```
<array local> | rule 0 (element type) | declared type: Array(Boolean) | tier: fixable
```

`Fixable`, not `CompilerLimitation`: the declared element type is the trust boundary every numeric fast path uses, so the program can move to `number[]` (or the analysis can grow a boolean slot representation). That is a different answer from "the compiler cannot express this".

Only ARRAY types are recorded. A non-array `Let` is not a rejected array candidate — it simply is not an array — and filing a denial for every `let x = 1` would drown the report in rows answering a question nobody asked. Verified on a control module: a `number[]` is reported `selected`, and the `const flag = 1` / `const label = "x"` beside it produce **zero** element-type rows.

Still untouched: the `Ptr<Shape>` side, whose provenance pass has the analogous structure.

`cargo test -p perry-codegen --lib` 851 passed / 0 failed; fmt and file-size clean.
130 changes: 115 additions & 15 deletions crates/perry-codegen/src/collectors/ptr_numarray.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,67 @@ pub(crate) fn expr_is_numarray_prototype_index_barrier(expr: &Expr) -> bool {
/// incremented, and "`Ptr<NumArray>`: 0 promoted" was indistinguishable from
/// "the instrument is dead". That is CLAUDE.md's failure mode (4): the gate
/// runs but its subject never did.
/// #7112: record that the WHOLE `Ptr<NumArray>` analysis was switched off for
/// this module, and by what.
///
/// Without this the report is silent, and silence at candidate-generation time
/// is indistinguishable from "considered and rejected" — the same ambiguity the
/// census was built to remove, one stage upstream.
///
/// Deliberately `Position::Local` with a module-scoped pseudo-name rather than
/// a real local: there is no value to attribute a module-wide kill to, and
/// inventing one would put a denial on an array that was never examined.
/// #7112: record an array local rejected by the ELEMENT-TYPE pre-filter, before
/// it ever became a candidate.
///
/// The pre-filter sits above `provenance()`, so nothing downstream sees the
/// local and no per-value `deny()` runs — the array vanishes from the report.
/// `Fixable` rather than `CompilerLimitation`: the declared element type is the
/// trust boundary every numeric fast path uses, so the program can move to
/// `number[]` (or the analysis can grow a boolean slot representation). That is
/// a different kind of answer from "the compiler cannot express this".
fn note_non_numeric_element_type(id: u32, ty: &HirType) {
if !opt_report::enabled() {
return;
}
opt_report::deny(opt_report::Denial {
position: opt_report::Position::Local,
name: "<array local>",
local_id: Some(id),
analysis: opt_report::Analysis::PtrNumArray,
rule: "rule 0 (element type)",
reason: "the declared element type is not `number` / `int32`, and the \
Ptr<NumArray> slot representation admits only numeric \
elements — so the local never reached provenance analysis.",
tier: opt_report::Tier::Fixable,
issue: Some("#7112"),
loop_depth: 0,
detail: Some(format!("declared type: {ty:?}")),
byte_offset: None,
});
}

fn note_analysis_disabled(cause: &str) {
if !opt_report::enabled() {
return;
}
opt_report::deny(opt_report::Denial {
position: opt_report::Position::Local,
name: "<module>",
local_id: None,
analysis: opt_report::Analysis::PtrNumArray,
rule: "rule 0 (analysis disabled)",
reason: "the Ptr<NumArray> analysis was switched off for this whole \
module before any array local became a candidate, so no \
per-value decision was made for any of them.",
tier: opt_report::Tier::CompilerLimitation,
issue: Some("#7112"),
loop_depth: 0,
detail: Some(format!("cause: {cause}")),
byte_offset: None,
});
}

fn note_num_array_local(
id: u32,
fact: &NumArrayLocal,
Expand Down Expand Up @@ -217,21 +278,41 @@ pub(crate) fn collect_num_array_locals(
compile_time_constants: &HashMap<u32, f64>,
integer_locals: &HashSet<u32>,
) -> HashMap<u32, NumArrayLocal> {
if !ptr_numarray_locals_enabled()
|| module_dispatch.has_shape_barrier_sites()
|| module_dispatch.has_numarray_prototype_index_barriers()
// An UNATTRIBUTABLE prototype reference anywhere in the module
// (`const p = Array.prototype; p[5] = …`, `x.constructor.prototype`,
// …). The direct-form kill above only sees a write whose receiver is
// syntactically `<expr>.prototype`; once the prototype object is
// aliased into a local, the write is an ordinary `IndexSet` on that
// local and is invisible to it. `note_prototype_holder` already flags
// every such NAMING site as opaque, so consuming that fact closes the
// alias hole — without it a polluted `Array.prototype[i]` could make a
// HOLE read observable while a guard-free `HolesOK` load (which cannot
// consult the runtime pollution byte) still returned the quiet NaN.
|| module_dispatch.has_opaque_prototype_mutation()
{
// #7112: a module-wide barrier kills EVERY array local at once, before any
// of them becomes a candidate — so nothing reaches a per-value `deny()` and
// the report shows the module as having no array candidates at all. That
// reads identically to "the analysis never looked", which is the ambiguity
// this report exists to remove, and it is why 8 of the census's 18 real
// workloads report zero candidates.
//
// Record the kill itself, once, naming which barrier fired. It is a
// MODULE-level fact rather than a value-level one, so there is no local to
// attribute it to — that is the point: the reader learns the analysis
// stopped upstream rather than that every array was individually rejected.
if !ptr_numarray_locals_enabled() {
note_analysis_disabled("PERRY_PTR_NUMARRAY_LOCALS=0");
return HashMap::new();
}
if module_dispatch.has_shape_barrier_sites() {
note_analysis_disabled("a shape-barrier site in this module");
return HashMap::new();
}
if module_dispatch.has_numarray_prototype_index_barriers() {
note_analysis_disabled("a numarray prototype-index barrier in this module");
return HashMap::new();
}
// An UNATTRIBUTABLE prototype reference anywhere in the module
// (`const p = Array.prototype; p[5] = …`, `x.constructor.prototype`, …).
// The direct-form kill above only sees a write whose receiver is
// syntactically `<expr>.prototype`; once the prototype object is aliased
// into a local, the write is an ordinary `IndexSet` on that local and is
// invisible to it. `note_prototype_holder` already flags every such NAMING
// site as opaque, so consuming that fact closes the alias hole — without it
// a polluted `Array.prototype[i]` could make a HOLE read observable while a
// guard-free `HolesOK` load (which cannot consult the runtime pollution
// byte) still returned the quiet NaN.
if module_dispatch.has_opaque_prototype_mutation() {
note_analysis_disabled("an opaque (aliased) prototype mutation in this module");
return HashMap::new();
}
// Pass 1: single-`Let` provenance candidates.
Expand Down Expand Up @@ -361,6 +442,25 @@ impl<'a> UseWalk<'a> {
_ => false,
};
if !numeric_array_ty {
// #7112: this verdict is CORRECT but the report used to
// throw it away. `benchmarks/suite/11_prime_sieve.ts`
// declares `const sieve: boolean[]` and reports 0
// `ptr-numarray` candidates — which reads as "this program
// has no array worth promoting", for a program whose
// 1,000,000-element array is in the hot loop indexed by a
// proven integer. The real answer, "declared `boolean[]`
// and this analysis admits only numeric element types", is
// a good answer that nothing in the tooling would tell you.
//
// Only ARRAY types are recorded: a non-array `Let` is not a
// rejected array candidate, it simply is not an array, and
// filing a denial for every `let x = 1` would drown the
// report in rows answering a question nobody asked.
let is_array_ty = matches!(ty, HirType::Array(_))
|| matches!(ty, HirType::Generic { base, .. } if base == "Array");
if is_array_ty {
note_non_numeric_element_type(*id, ty);
}
return;
}
if let Some(init) = init {
Expand Down
Loading