Skip to content
Closed
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
38 changes: 38 additions & 0 deletions changelog.d/8619-ta-view-param-number-by-construction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
A hot function that folds a typed-array **parameter** into an accumulator —
`function reduce(arr: Float64Array) { let s = 0.0; for (…) { let x = arr[i] + 1.0;
s = s + x; } }` — no longer keeps its numeric locals as NaN-boxed GC roots.

The spec-ABI already proves such a parameter (`TaPtr`) permanently holds one
specific numeric-kind, non-view typed array, and specializes the body so the
element read inlines. But the `number_by_construction` fixpoint
(`collectors/ptr_shape_numeric.rs`) only recognised a typed-array element read as
"a Number or `undefined`, never a pointer" for a **local** view with a
compiler-visible `TypedArrayNew` initializer — not for a proven `TaPtr`
parameter. So the fresh, read-derived `x` failed the numeric proof, which
cascaded to the loop-carried accumulator `s = s + x`. Both then kept a shadow
root slot with a per-write `js_write_barrier_root_nanbox`, and `s`'s update
lowered to the opaque `js_dynamic_string_or_number_add` call instead of an inline
`fadd`.

The fixpoint now also treats a read off a `spec_ta_lens` binding as
Number-or-`undefined`. `spec_ta_lens` is keyed exactly by `SpecParamRep::TaPtr`
parameters, and `collectors::spec_abi_sites` admits a `TaPtr` only for
`spec_ta_kind_is_numeric` kinds (the BigInt typed arrays — whose elements are
BigInt pointers — are never `TaPtr`), so `arr[numeric_index]` off one is provably
a Number in-bounds and `undefined` out of range, which `+`/`-` launders into a
genuine Number (`NaN` at worst). The `rec(index)` guard is retained — a
non-numeric key would read a property, which can be a pointer. Soundness rests on
the entry contract, not on the erased `Float64Array` annotation, so a reassigned
or unproven receiver is untouched.

Effect on a 200000×4096 `Float64Array` reduction passed by parameter: the
accumulator's per-iteration `js_dynamic_string_or_number_add` and root barrier
become a single `fadd` in a raw `double` slot — ~5× faster (measured 5.1–7.3s →
~1.0s), with byte-identical output to the rooted build under every moving-GC
configuration and to Node.

Does not yet cover a typed array read through a **module-global** binding (the
issue #8619 reproducer): on `main` that read is still a runtime call (module-
global read inlining, #8617, is unmerged), so its accumulator rooting is a
secondary cost there; extending the same proof to `module_global_proven_types`
is the natural follow-up once the read inlines.
88 changes: 88 additions & 0 deletions crates/perry-codegen/src/collectors/number_by_construction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,23 @@ pub(crate) fn collect_number_by_construction_locals(
if !enabled() {
return HashSet::new();
}
// #8619: spec-ABI `TaPtr` parameters are proven to permanently hold one
// specific NUMERIC-kind, non-view typed array — `spec_ta_lens` is keyed
// exactly by those params (its only source is `SpecParamRep::TaPtr`, which
// `collectors::spec_abi_sites` admits only for `spec_ta_kind_is_numeric`
// kinds; the BigInt kinds are never TaPtr). A read `arr[numeric_index]` off
// one is therefore a Number (in-bounds) or `undefined` (OOB), never a
// pointer/string, so the fixpoint may treat it like a compiler-visible
// local typed-view constructor on one side of `+` (where `undefined`
// becomes the Number NaN rather than selecting string concatenation).
let numeric_ta_views: HashSet<u32> = spec_ta_lens.keys().copied().collect();
let mut numeric = super::ptr_shape::collect_numeric_by_construction_locals_for_type_analysis(
stmts,
boxed_vars,
module_globals,
not_bigint_locals,
&HashMap::new(),
&numeric_ta_views,
);
numeric.extend(collect_number_at_read_after_undefined(
stmts,
Expand Down Expand Up @@ -545,4 +556,81 @@ mod tests {

assert!(!run(&stmts).contains(&N));
}

// #8619: a spec-ABI `TaPtr` parameter is proven to permanently hold one
// specific NUMERIC-kind, non-view typed array, so `arr[numeric_index]` is a
// Number (in-bounds) or `undefined` (OOB) — never a pointer. The
// number-by-construction fixpoint must therefore admit a fresh
// read-derived local `let x = arr[i] + 1.0` (whose value is a genuine
// Number, `NaN` at worst) and cascade to the loop-carried accumulator
// `s = s + x`, so both drop their GC-root slot and their arithmetic stays
// an inline `fadd` instead of `js_dynamic_string_or_number_add`.
fn ta_view_stmts(arr: u32, s_id: u32, x_id: u32) -> Vec<Stmt> {
vec![
Stmt::Let {
id: s_id,
name: "s".to_string(),
ty: HirType::Number,
mutable: true,
init: Some(Expr::Number(0.0)),
},
Stmt::Let {
id: x_id,
name: "x".to_string(),
ty: HirType::Number,
mutable: false,
init: Some(add(
Expr::IndexGet {
object: Box::new(Expr::LocalGet(arr)),
index: Box::new(Expr::Integer(0)),
},
Expr::Number(1.0),
)),
},
Stmt::Expr(Expr::LocalSet(
s_id,
Box::new(add(Expr::LocalGet(s_id), Expr::LocalGet(x_id))),
)),
]
}

fn run_fixpoint(stmts: &[Stmt], ta_views: &HashSet<u32>) -> HashSet<u32> {
crate::collectors::ptr_shape::collect_numeric_by_construction_locals_for_type_analysis(
stmts,
&HashSet::new(),
&HashMap::new(),
&HashSet::new(),
&HashMap::new(),
ta_views,
)
}

#[test]
fn spec_ta_param_view_admits_read_derived_number_locals() {
let (arr, s_id, x_id) = (10u32, 20u32, 21u32);
let stmts = ta_view_stmts(arr, s_id, x_id);

let with = run_fixpoint(&stmts, &HashSet::from([arr]));
assert!(
with.contains(&x_id),
"fresh `arr[i] + 1.0` local must be Number by construction"
);
assert!(
with.contains(&s_id),
"accumulator must cascade to Number by construction"
);
}

#[test]
fn ta_read_without_spec_proof_stays_dynamic() {
// Same body, but the receiver is NOT a spec-proven typed array: the read
// could be a string/property access on an arbitrary receiver, so neither
// local may be un-rooted.
let (arr, s_id, x_id) = (10u32, 20u32, 21u32);
let stmts = ta_view_stmts(arr, s_id, x_id);

let without = run_fixpoint(&stmts, &HashSet::new());
assert!(!without.contains(&x_id));
assert!(!without.contains(&s_id));
}
}
4 changes: 4 additions & 0 deletions crates/perry-codegen/src/collectors/ptr_shape.rs
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,10 @@ pub(crate) fn collect_shape_proven_ptr_locals_and_element_fields(
module_globals,
not_bigint_locals,
&const_local_inits,
// #8619: this is the `Ptr<Shape>` provenance pass (feeds `is_numeric_expr`),
// not the local rooting proof; it has no specialized `TaPtr` context, so
// no view binding is spec-proven here.
&HashSet::new(),
);
// A spec entry has validated these parameters before entering this body.
// Unlike a TypeScript annotation, that is runtime evidence, so derived
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -785,6 +785,7 @@ fn numeric_locals_of(stmts: &[Stmt]) -> HashSet<u32> {
&HashMap::new(),
&HashSet::new(),
&HashMap::new(),
&HashSet::new(),
)
}

Expand Down Expand Up @@ -942,6 +943,7 @@ fn non_numeric_writes_and_bindings_are_excluded() {
&HashMap::new(),
&HashSet::new(),
&HashMap::new(),
&HashSet::new(),
)
.contains(&7),
"a boxed local's write set is not this region's to enumerate"
Expand Down Expand Up @@ -976,6 +978,7 @@ fn update_value_resolves_via_not_bigint() {
&HashMap::new(),
&not_bigint,
&HashMap::new(),
&HashSet::new(),
);
assert!(with_fact.contains(&21));
let without_fact = numeric::collect_numeric_by_construction_locals(
Expand All @@ -984,6 +987,7 @@ fn update_value_resolves_via_not_bigint() {
&HashMap::new(),
&HashSet::new(),
&HashMap::new(),
&HashSet::new(),
);
assert!(
!without_fact.contains(&22),
Expand Down
36 changes: 36 additions & 0 deletions crates/perry-codegen/src/collectors/ptr_shape_numeric.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ pub(super) fn prove_numeric_fields(
const_local_inits: &HashMap<u32, Option<&Expr>>,
numeric_locals: &HashSet<u32>,
) -> HashSet<String> {
// #8619: the class-field numeric proof has no specialized-entry `TaPtr`
// context, so no view binding is spec-proven here. Passing empty keeps this
// proof bit-identical to before the local `TaPtr` extension.
let no_ta_views: HashSet<u32> = HashSet::new();
let mut numeric: HashSet<String> = HashSet::new();
for class in chain {
for field in &class.fields {
Expand Down Expand Up @@ -119,6 +123,7 @@ pub(super) fn prove_numeric_fields(
not_bigint_locals,
const_local_inits,
numeric_locals,
&no_ta_views,
0,
)
})
Expand All @@ -145,6 +150,7 @@ pub(super) fn prove_numeric_fields(
not_bigint_locals,
const_local_inits,
numeric_locals,
&no_ta_views,
0,
)
})
Expand Down Expand Up @@ -219,6 +225,7 @@ pub(super) fn prove_numeric_fields(
not_bigint_locals,
const_local_inits,
numeric_locals,
&no_ta_views,
0,
)
};
Expand All @@ -243,6 +250,7 @@ pub(super) fn prove_numeric_fields(
not_bigint_locals,
const_local_inits,
numeric_locals,
&no_ta_views,
0,
),
};
Expand Down Expand Up @@ -429,6 +437,9 @@ pub(in crate::collectors) fn collect_numeric_by_construction_locals<'a>(
module_globals: &HashMap<u32, String>,
not_bigint_locals: &HashSet<u32>,
const_local_inits: &HashMap<u32, Option<&'a Expr>>,
// #8619: view bindings proven to hold a numeric-kind typed array (spec-ABI
// `TaPtr` params). Empty for the `Ptr<Shape>` type-analysis caller.
numeric_ta_views: &HashSet<u32>,
) -> HashSet<u32> {
// ONE write walker for both fixpoints (`collect_not_bigint_locals` and
// this one) — see its doc for why sharing is load-bearing. `None` = a
Expand Down Expand Up @@ -471,6 +482,7 @@ pub(in crate::collectors) fn collect_numeric_by_construction_locals<'a>(
not_bigint_locals,
&stable_local_inits,
&numeric,
numeric_ta_views,
0,
),
})
Expand Down Expand Up @@ -505,6 +517,15 @@ pub(super) fn expr_numeric_by_construction(
not_bigint_locals: &HashSet<u32>,
const_local_inits: &HashMap<u32, Option<&Expr>>,
numeric_locals: &HashSet<u32>,
// #8619: view bindings PROVEN to permanently hold a numeric-kind typed
// array — a spec-ABI `TaPtr` parameter (the entry contract binds the raw
// header of a proven numeric non-view typed array). A read
// `view_id[numeric_index]` is then a Number (in-bounds) or `undefined`
// (OOB) by construction, never a pointer/string, which the Add rule below
// launders into a genuine Number. Empty on every path that is not a
// specialized-entry local proof (the class-field provers, the `Ptr<Shape>`
// pass).
numeric_ta_views: &HashSet<u32>,
depth: usize,
) -> bool {
if depth > 16 {
Expand All @@ -520,6 +541,7 @@ pub(super) fn expr_numeric_by_construction(
not_bigint_locals,
const_local_inits,
numeric_locals,
numeric_ta_views,
depth + 1,
)
};
Expand All @@ -537,6 +559,18 @@ pub(super) fn expr_numeric_by_construction(
let Expr::LocalGet(view_id) = object.as_ref() else {
return false;
};
// #8619: a spec-proven numeric typed-array binding (`TaPtr` parameter)
// has no compiler-visible `TypedArrayNew` init in this body, but its
// entry contract is a STRONGER proof than an inline constructor: the
// call-site pre-pass proved the argument is one specific numeric-kind,
// non-view typed array, never reassigned. So `view_id[numeric_index]`
// is a Number-or-`undefined` exactly as the local-constructor case
// below — never a pointer. The `rec(index)` guard is retained: a
// non-numeric key (symbol/string) would read a property, which can be a
// pointer.
if numeric_ta_views.contains(view_id) {
return rec(index);
}
let Some(Some(init)) = const_local_inits.get(view_id) else {
return false;
};
Expand Down Expand Up @@ -676,6 +710,7 @@ pub(super) fn expr_numeric_by_construction(
not_bigint_locals,
const_local_inits,
numeric_locals,
numeric_ta_views,
depth + 1,
)
}) == Some(true)
Expand All @@ -699,6 +734,7 @@ pub(super) fn expr_numeric_by_construction(
not_bigint_locals,
const_local_inits,
numeric_locals,
numeric_ta_views,
depth + 1,
);
}
Expand Down
Loading
Loading