From 2f1e6c502c0c7a7d72e3ef8239d04c9a71021bfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 12 Aug 2026 12:59:14 +0200 Subject: [PATCH 1/2] perf(codegen): specialize typed-array helper integer args --- crates/perry-codegen/src/codegen/function.rs | 14 +- .../perry-codegen/src/collectors/hir_facts.rs | 19 +- .../src/collectors/integer_locals.rs | 24 ++- crates/perry-codegen/src/collectors/mod.rs | 2 +- .../src/collectors/spec_abi_sites.rs | 167 +++++++++++++----- .../src/collectors/spec_abi_sites/tests.rs | 88 ++++++++- .../perry-codegen/src/lower_call/func_ref.rs | 8 + .../spec_abi_typed_array_local_length.rs | 134 ++++++++++++++ ...7221_spec_abi_float64array_local_length.ts | 52 ++++++ 9 files changed, 452 insertions(+), 56 deletions(-) create mode 100644 crates/perry-codegen/tests/spec_abi_typed_array_local_length.rs create mode 100644 test-files/test_issue_7221_spec_abi_float64array_local_length.ts diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 23e3749024..97d4e09921 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -604,6 +604,17 @@ pub(super) fn compile_function( .collect() }) .unwrap_or_default(); + let spec_i32_params: HashSet = spec_entry + .map(|plan| { + f.params + .iter() + .zip(plan.reps.iter()) + .filter_map(|(p, rep)| { + matches!(rep, crate::collectors::SpecParamRep::I32).then_some(p.id) + }) + .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. // @@ -618,7 +629,7 @@ pub(super) fn compile_function( .return_shape_class(f.id) .is_some(), ); - let native_facts = crate::collectors::collect_native_region_fact_graph_with_spec_lens( + let native_facts = crate::collectors::collect_native_region_fact_graph_with_spec_params( &f.body, &f.params, &flat_const_ids, @@ -632,6 +643,7 @@ pub(super) fn compile_function( &cross_module.compile_time_constants, &cross_module.module_dispatch, &spec_ta_lens, + &spec_i32_params, ); if let Some(plan) = spec_entry { diff --git a/crates/perry-codegen/src/collectors/hir_facts.rs b/crates/perry-codegen/src/collectors/hir_facts.rs index 5e84255835..83ba7acfb3 100644 --- a/crates/perry-codegen/src/collectors/hir_facts.rs +++ b/crates/perry-codegen/src/collectors/hir_facts.rs @@ -409,6 +409,7 @@ pub(crate) fn collect_type_facts( compile_time_constants: &HashMap, module_dispatch: &super::ModuleDispatchFacts, spec_ta_lens: &HashMap, + spec_i32_params: &HashSet, ) -> TypeFacts { // #7700: which locals hold a NUMBER, so a `u8[k]` keyed on one is a byte // read rather than a property read. Computed once here because @@ -416,12 +417,13 @@ pub(crate) fn collect_type_facts( // above all the counter in `for (let i = …) sum += buf[i]`, have to be // walked for or the hottest buffer shape loses its i32 representation. let numeric_locals = super::collect_numeric_typed_locals(stmts, params, binding_types); - let mut integer_locals = super::integer_locals::collect_integer_locals( + let mut integer_locals = super::integer_locals::collect_integer_locals_with_seeds( stmts, flat_const_ids, clamp_fn_ids, arg_dependent_clamp_fn_ids, &numeric_locals, + spec_i32_params, ); // Native-i32 residency for integer-valued locals whose init/writes include a // possibly-out-of-bounds INT typed-array element read (bcryptjs `_encipher` @@ -679,7 +681,7 @@ pub(crate) fn collect_native_region_fact_graph( compile_time_constants: &HashMap, module_dispatch: &super::ModuleDispatchFacts, ) -> NativeRegionFactGraph { - collect_native_region_fact_graph_with_spec_lens( + collect_native_region_fact_graph_with_spec_params( stmts, params, flat_const_ids, @@ -692,15 +694,15 @@ pub(crate) fn collect_native_region_fact_graph( compile_time_constants, module_dispatch, &HashMap::new(), + &HashSet::new(), ) } -/// Variant carrying spec-ABI `TaPtr` parameter lengths (representation- -/// selection Phase 2): the call-site pre-pass proved these params hold -/// non-view typed arrays with these constant element counts, which unlocks -/// the wrap-i32 additive admission's in-bounds operand proof. +/// Variant carrying spec-ABI parameter facts (representation-selection Phase +/// 2): `TaPtr` lengths unlock in-bounds proofs, while raw-i32 params seed the +/// ordinary integer-local fixed point so derived index temps stay native. #[allow(clippy::too_many_arguments)] -pub(crate) fn collect_native_region_fact_graph_with_spec_lens( +pub(crate) fn collect_native_region_fact_graph_with_spec_params( stmts: &[Stmt], params: &[perry_hir::Param], flat_const_ids: &HashSet, @@ -713,6 +715,7 @@ pub(crate) fn collect_native_region_fact_graph_with_spec_lens( compile_time_constants: &HashMap, module_dispatch: &super::ModuleDispatchFacts, spec_ta_lens: &HashMap, + spec_i32_params: &HashSet, ) -> NativeRegionFactGraph { collect_type_facts( stmts, @@ -727,6 +730,7 @@ pub(crate) fn collect_native_region_fact_graph_with_spec_lens( compile_time_constants, module_dispatch, spec_ta_lens, + spec_i32_params, ) } @@ -753,6 +757,7 @@ pub(crate) fn collect_hir_facts( // conservative default keeps it that way if one ever could. &super::ModuleDispatchFacts::default(), &HashMap::new(), + &HashSet::new(), ) } diff --git a/crates/perry-codegen/src/collectors/integer_locals.rs b/crates/perry-codegen/src/collectors/integer_locals.rs index d4aa2ab953..fd7984373b 100644 --- a/crates/perry-codegen/src/collectors/integer_locals.rs +++ b/crates/perry-codegen/src/collectors/integer_locals.rs @@ -379,7 +379,29 @@ pub fn collect_integer_locals( // key is one of those, or is numeric by construction. numeric_locals: &HashSet, ) -> HashSet { - let mut candidates: HashSet = HashSet::new(); + collect_integer_locals_with_seeds( + stmts, + flat_const_ids, + clamp_fn_ids, + arg_dependent_clamp_fn_ids, + numeric_locals, + &HashSet::new(), + ) +} + +/// The ordinary integer-local proof with already-proven i32 locals seeded +/// into its transitive closure. Specialized-ABI raw-i32 parameters use this: +/// plan selection has already rejected reassigned/captured parameters, so the +/// seed is a construction proof rather than a declared-type assumption. +pub(crate) fn collect_integer_locals_with_seeds( + stmts: &[perry_hir::Stmt], + flat_const_ids: &HashSet, + clamp_fn_ids: &HashSet, + arg_dependent_clamp_fn_ids: &HashSet, + numeric_locals: &HashSet, + seed_locals: &HashSet, +) -> HashSet { + let mut candidates: HashSet = seed_locals.clone(); // Issue #50 bridge: pre-compute which locals are row-aliases of // flat-const 2D int arrays BEFORE collecting integer let ids, since diff --git a/crates/perry-codegen/src/collectors/mod.rs b/crates/perry-codegen/src/collectors/mod.rs index 4390749942..6caeb85903 100644 --- a/crates/perry-codegen/src/collectors/mod.rs +++ b/crates/perry-codegen/src/collectors/mod.rs @@ -60,7 +60,7 @@ pub(crate) use escape_arrays::{const_index, MAX_SCALAR_OBJECT_FIELDS}; pub(crate) use escape_check::{check_escapes_in_stmts, find_new_candidates}; pub(crate) use escape_news::MAX_SCALAR_ARRAY_LEN; pub(crate) use hir_facts::{ - collect_native_region_fact_graph, collect_native_region_fact_graph_with_spec_lens, + collect_native_region_fact_graph, collect_native_region_fact_graph_with_spec_params, NativeRegionFactGraph, }; pub(crate) use hot_callees::{collect_alloc_hot_functions, collect_hot_loop_callees}; diff --git a/crates/perry-codegen/src/collectors/spec_abi_sites.rs b/crates/perry-codegen/src/collectors/spec_abi_sites.rs index 676f73001b..33abfee266 100644 --- a/crates/perry-codegen/src/collectors/spec_abi_sites.rs +++ b/crates/perry-codegen/src/collectors/spec_abi_sites.rs @@ -11,18 +11,20 @@ //! - **`TaPtr { kind, const_len }`** — the arg is a local/module binding whose //! SINGLE, top-level `let`/`const` binding is `new Int32Array(x)` (or another //! numeric typed-array kind) in a **non-view construction form**: `x` absent, -//! an integer-literal length, or a local proven to be a plain array (its own -//! single binding is an array literal, never reassigned). The binding local is +//! an integer-literal length, an immutable local bound once to such a length, +//! or a local proven to be a plain array (its own single binding is an array +//! literal, never reassigned). The binding local is //! never reassigned anywhere in the module (`LocalSet`/`GlobalSet`/`Update`) //! and never referenced inside any closure body — so at every dominated call //! site the slot still holds that exact typed array, whose header address, //! element kind, and length are fixed for the program's lifetime (typed-array //! storage is non-movable: `gc/types.rs` marks `GC_TYPE_TYPED_ARRAY` / //! `GC_TYPE_BUFFER` `movable: false`, and a non-view typed array cannot be -//! detached). `const_len` is `Some` when the length is an integer literal or -//! the element count of an array-literal source whose uses provably cannot -//! change its length. -//! - **`I32`** — integer literal in i32 range. +//! detached). `const_len` is `Some` when the length is an integer literal +//! (directly or through the immutable local above), or the element count of +//! an array-literal source whose uses provably cannot change its length. +//! - **`I32`** — integer literal in i32 range, or a local whose complete +//! write set proves it stays in that range. //! - **`F64`** — any other numeric literal (a JS number's NaN-box IS its //! double bits, so raw-f64 passing is bit-identical). //! - anything else → **`Boxed`** (always sound; the public boxed entry remains @@ -513,25 +515,38 @@ fn array_literal_len(e: &Expr) -> Option { } } +/// A typed-array constructor length literal. Keep this deliberately aligned +/// with the direct-literal arm of [`judge_ctor_arg`]. +fn typed_array_length_literal(e: &Expr) -> Option { + match e { + Expr::Integer(n) if (0..=16_000_000).contains(n) => Some(*n), + Expr::Number(f) if f.is_finite() && f.fract() == 0.0 && *f >= 0.0 && *f <= 16_000_000.0 => { + Some(*f as i64) + } + _ => None, + } +} + /// Judge the `new TypedArray(arg)` constructor argument: `Some(const_len)` /// when the construction is provably NON-VIEW (arg is a length or a plain /// array — never an ArrayBuffer), `None` when the form is unproven. fn judge_ctor_arg( arg: Option<&Expr>, array_literal_locals: &HashMap>, + literal_length_locals: &HashMap, ) -> Option> { + if let Some(len) = arg.and_then(typed_array_length_literal) { + return Some(Some(len)); + } match arg { None => Some(Some(0)), - Some(Expr::Integer(n)) if (0..=16_000_000).contains(n) => Some(Some(*n)), - Some(Expr::Number(f)) - if f.is_finite() && f.fract() == 0.0 && *f >= 0.0 && *f <= 16_000_000.0 => - { - Some(Some(*f as i64)) - } // A local whose single binding is an array literal, never reassigned: // definitely a plain array (copy construction, non-view). The length // is constant only when no use of the source could have resized it. - Some(Expr::LocalGet(id)) => array_literal_locals.get(id).map(|len| *len), + Some(Expr::LocalGet(id)) => literal_length_locals + .get(id) + .map(|len| Some(*len)) + .or_else(|| array_literal_locals.get(id).copied()), _ => None, } } @@ -542,12 +557,26 @@ fn judge_ctor_arg( /// bodies are never descended (they run at unknown times). fn judge_sites_in_body( stmts: &[Stmt], + params: &[perry_hir::Param], ta_bindings: &HashMap, out: &mut HashMap>>, ) { + // Use the same transitive, all-writes range proof as canonical i32 slots. + // Empty auxiliary sets only under-approximate clamp/flat-array cases; they + // cannot admit a local the integer-local provenance judge would reject. + let binding_types = HashMap::new(); + let numeric_locals = super::collect_numeric_typed_locals(stmts, params, &binding_types); + let empty = HashSet::new(); + let integer_locals = super::integer_locals::collect_integer_locals( + stmts, + &empty, + &empty, + &empty, + &numeric_locals, + ); let mut ready: HashSet = HashSet::new(); for s in stmts { - judge_stmt(s, ta_bindings, &ready, out); + judge_stmt(s, ta_bindings, &integer_locals, &ready, out); if let Stmt::Let { id, .. } = s { if ta_bindings.contains_key(id) { ready.insert(*id); @@ -559,16 +588,17 @@ fn judge_sites_in_body( fn judge_stmt( s: &Stmt, ta: &HashMap, + integer_locals: &HashSet, ready: &HashSet, out: &mut HashMap>>, ) { match s { - Stmt::Let { init: Some(e), .. } => judge_expr(e, ta, ready, out), + Stmt::Let { init: Some(e), .. } => judge_expr(e, ta, integer_locals, ready, out), Stmt::Let { init: None, .. } => {} - Stmt::Expr(e) | Stmt::Throw(e) => judge_expr(e, ta, ready, out), + Stmt::Expr(e) | Stmt::Throw(e) => judge_expr(e, ta, integer_locals, ready, out), Stmt::Return(opt) => { if let Some(e) = opt { - judge_expr(e, ta, ready, out); + judge_expr(e, ta, integer_locals, ready, out); } } Stmt::If { @@ -576,20 +606,20 @@ fn judge_stmt( then_branch, else_branch, } => { - judge_expr(condition, ta, ready, out); + judge_expr(condition, ta, integer_locals, ready, out); for st in then_branch { - judge_stmt(st, ta, ready, out); + judge_stmt(st, ta, integer_locals, ready, out); } if let Some(eb) = else_branch { for st in eb { - judge_stmt(st, ta, ready, out); + judge_stmt(st, ta, integer_locals, ready, out); } } } Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { - judge_expr(condition, ta, ready, out); + judge_expr(condition, ta, integer_locals, ready, out); for st in body { - judge_stmt(st, ta, ready, out); + judge_stmt(st, ta, integer_locals, ready, out); } } Stmt::For { @@ -599,35 +629,35 @@ fn judge_stmt( body, } => { if let Some(i) = init { - judge_stmt(i, ta, ready, out); + judge_stmt(i, ta, integer_locals, ready, out); } if let Some(c) = condition { - judge_expr(c, ta, ready, out); + judge_expr(c, ta, integer_locals, ready, out); } if let Some(u) = update { - judge_expr(u, ta, ready, out); + judge_expr(u, ta, integer_locals, ready, out); } for st in body { - judge_stmt(st, ta, ready, out); + judge_stmt(st, ta, integer_locals, ready, out); } } - Stmt::Labeled { body, .. } => judge_stmt(body, ta, ready, out), + Stmt::Labeled { body, .. } => judge_stmt(body, ta, integer_locals, ready, out), Stmt::Try { body, catch, finally, } => { for st in body { - judge_stmt(st, ta, ready, out); + judge_stmt(st, ta, integer_locals, ready, out); } if let Some(c) = catch { for st in &c.body { - judge_stmt(st, ta, ready, out); + judge_stmt(st, ta, integer_locals, ready, out); } } if let Some(f) = finally { for st in f { - judge_stmt(st, ta, ready, out); + judge_stmt(st, ta, integer_locals, ready, out); } } } @@ -635,13 +665,13 @@ fn judge_stmt( discriminant, cases, } => { - judge_expr(discriminant, ta, ready, out); + judge_expr(discriminant, ta, integer_locals, ready, out); for case in cases { if let Some(t) = &case.test { - judge_expr(t, ta, ready, out); + judge_expr(t, ta, integer_locals, ready, out); } for st in &case.body { - judge_stmt(st, ta, ready, out); + judge_stmt(st, ta, integer_locals, ready, out); } } } @@ -656,19 +686,25 @@ fn judge_stmt( pub(crate) fn judge_arg( arg: &Expr, ta: &HashMap, + integer_locals: &HashSet, ready: &HashSet, ) -> SpecParamRep { match arg { Expr::Integer(n) if i32::try_from(*n).is_ok() => SpecParamRep::I32, Expr::Integer(_) => SpecParamRep::F64, Expr::Number(_) => SpecParamRep::F64, - Expr::LocalGet(id) => match ta.get(id) { - Some(binding) if ready.contains(id) => SpecParamRep::TaPtr { - kind: binding.kind, - const_len: binding.const_len, - }, - _ => SpecParamRep::Boxed, - }, + Expr::LocalGet(id) => { + if let Some(binding) = ta.get(id).filter(|_| ready.contains(id)) { + SpecParamRep::TaPtr { + kind: binding.kind, + const_len: binding.const_len, + } + } else if integer_locals.contains(id) { + SpecParamRep::I32 + } else { + SpecParamRep::Boxed + } + } _ => SpecParamRep::Boxed, } } @@ -676,12 +712,16 @@ pub(crate) fn judge_arg( fn judge_expr( e: &Expr, ta: &HashMap, + integer_locals: &HashSet, ready: &HashSet, out: &mut HashMap>>, ) { if let Expr::Call { callee, args, .. } = e { if let Expr::FuncRef(fid) = callee.as_ref() { - let judged: Vec = args.iter().map(|a| judge_arg(a, ta, ready)).collect(); + let judged: Vec = args + .iter() + .map(|a| judge_arg(a, ta, integer_locals, ready)) + .collect(); out.entry(*fid).or_default().push(judged); } } @@ -690,7 +730,9 @@ fn judge_expr( if matches!(e, Expr::Closure { .. }) { return; } - perry_hir::walker::walk_expr_children(e, &mut |c| judge_expr(c, ta, ready, out)); + perry_hir::walker::walk_expr_children(e, &mut |c| { + judge_expr(c, ta, integer_locals, ready, out) + }); } /// Run the whole pre-pass on a module. @@ -726,6 +768,37 @@ pub fn collect_spec_abi_facts(hir: &Module) -> SpecAbiModuleFacts { collect_array_literals(&f.body); } + // Immutable, single-binding constructor lengths. Resolving this one + // indirection keeps `new Float64Array(nodes)` equivalent to the literal + // form without treating an arbitrary local as a non-view constructor. + let mut literal_length_locals: HashMap = HashMap::new(); + let mut collect_literal_lengths = |stmts: &[Stmt]| { + for s in stmts { + if let Stmt::Let { + id, + mutable: false, + init: Some(e), + .. + } = s + { + if scan.let_counts.get(id).copied() == Some(1) + && !scan.writes.contains(id) + && !scan.closure_refs.contains(id) + && !scan.boxed_prealloc.contains(id) + && !scan.other_bindings.contains(id) + { + if let Some(len) = typed_array_length_literal(e) { + literal_length_locals.insert(*id, len); + } + } + } + } + }; + collect_literal_lengths(&hir.init); + for f in &hir.functions { + collect_literal_lengths(&f.body); + } + // Proven typed-array bindings: single TOP-LEVEL `let`/`const` bound to a // provably-non-view `TypedArrayNew` of a numeric kind, never reassigned, // never referenced by a closure. @@ -746,7 +819,11 @@ pub fn collect_spec_abi_facts(hir: &Module) -> SpecAbiModuleFacts { { continue; } - if let Some(const_len) = judge_ctor_arg(arg.as_deref(), &array_literal_locals) { + if let Some(const_len) = judge_ctor_arg( + arg.as_deref(), + &array_literal_locals, + &literal_length_locals, + ) { ta_bindings.insert( *id, SpecTaBinding { @@ -765,9 +842,9 @@ pub fn collect_spec_abi_facts(hir: &Module) -> SpecAbiModuleFacts { // Judge every direct call site in init + function bodies. let mut call_sites: HashMap>> = HashMap::new(); - judge_sites_in_body(&hir.init, &ta_bindings, &mut call_sites); + judge_sites_in_body(&hir.init, &[], &ta_bindings, &mut call_sites); for f in &hir.functions { - judge_sites_in_body(&f.body, &ta_bindings, &mut call_sites); + judge_sites_in_body(&f.body, &f.params, &ta_bindings, &mut call_sites); } SpecAbiModuleFacts { diff --git a/crates/perry-codegen/src/collectors/spec_abi_sites/tests.rs b/crates/perry-codegen/src/collectors/spec_abi_sites/tests.rs index ec3349ca61..2d89721996 100644 --- a/crates/perry-codegen/src/collectors/spec_abi_sites/tests.rs +++ b/crates/perry-codegen/src/collectors/spec_abi_sites/tests.rs @@ -1,6 +1,6 @@ use super::*; use perry_hir::types::Type; -use perry_hir::{Function, Module, TYPED_ARRAY_KIND_INT32}; +use perry_hir::{Function, Module, TYPED_ARRAY_KIND_FLOAT64, TYPED_ARRAY_KIND_INT32}; fn let_stmt(id: u32, mutable: bool, init: Expr) -> Stmt { Stmt::Let { @@ -85,6 +85,92 @@ fn literal_length_binding_and_dominated_site() { ); } +#[test] +fn literal_length_local_proves_typed_array_binding() { + // #7221: const nodes = 10_000; const values = new Float64Array(nodes); + // fill(values, nodes, dirty, frame). The constructor's numeric local is + // still a non-view length and all integer locals can use raw i32 params. + let m = module_with_init(vec![ + let_stmt(1, false, Expr::Integer(10_000)), + let_stmt( + 2, + false, + ta_new(TYPED_ARRAY_KIND_FLOAT64, Some(Expr::LocalGet(1))), + ), + let_stmt(3, false, Expr::Integer(1_000)), + let_stmt(4, true, Expr::Integer(0)), + Stmt::Expr(Expr::Update { + id: 4, + op: perry_hir::UpdateOp::Increment, + prefix: false, + }), + Stmt::Expr(call( + 7, + vec![ + Expr::LocalGet(2), + Expr::LocalGet(1), + Expr::LocalGet(3), + Expr::LocalGet(4), + ], + )), + ]); + let facts = collect_spec_abi_facts(&m); + assert_eq!( + facts.ta_bindings.get(&2).map(|binding| binding.const_len), + Some(Some(10_000)) + ); + assert_eq!( + facts.call_sites.get(&7).unwrap()[0], + vec![ + SpecParamRep::TaPtr { + kind: TYPED_ARRAY_KIND_FLOAT64, + const_len: Some(10_000) + }, + SpecParamRep::I32, + SpecParamRep::I32, + SpecParamRep::I32, + ] + ); +} + +#[test] +fn mutable_literal_length_local_does_not_prove_typed_array_binding() { + // A mutable length slot can stop being numeric before construction, so + // this provenance shortcut is deliberately limited to `const` bindings. + let m = module_with_init(vec![ + let_stmt(1, true, Expr::Integer(10_000)), + let_stmt( + 2, + false, + ta_new(TYPED_ARRAY_KIND_FLOAT64, Some(Expr::LocalGet(1))), + ), + Stmt::Expr(call(7, vec![Expr::LocalGet(2)])), + ]); + let facts = collect_spec_abi_facts(&m); + assert!(!facts.ta_bindings.contains_key(&2)); + assert_eq!( + facts.call_sites.get(&7).unwrap()[0], + vec![SpecParamRep::Boxed] + ); +} + +#[test] +fn local_with_non_integer_write_stays_boxed_at_spec_call() { + let m = module_with_init(vec![ + let_stmt(1, true, Expr::Integer(7)), + Stmt::Expr(Expr::LocalSet( + 1, + Box::new(Expr::String("not an integer".to_string())), + )), + Stmt::Expr(call(7, vec![Expr::LocalGet(1)])), + ]); + let facts = collect_spec_abi_facts(&m); + assert_eq!( + facts.call_sites.get(&7).unwrap()[0], + vec![SpecParamRep::Boxed] + ); +} + #[test] fn array_literal_source_counts_elements() { // var A = [1,2,3]; const P = new Int32Array(A); f(P) diff --git a/crates/perry-codegen/src/lower_call/func_ref.rs b/crates/perry-codegen/src/lower_call/func_ref.rs index 69107d35ac..07f7b99981 100644 --- a/crates/perry-codegen/src/lower_call/func_ref.rs +++ b/crates/perry-codegen/src/lower_call/func_ref.rs @@ -75,6 +75,7 @@ fn try_emit_spec_static_call( enum RawArg { Double(usize), I32Const(i64), + I32Value(usize), TaPtr(usize), } let mut raw_plan: Vec = Vec::with_capacity(args.len()); @@ -90,6 +91,9 @@ fn try_emit_spec_static_call( Expr::Integer(n) if i32::try_from(*n).is_ok() => { raw_plan.push(RawArg::I32Const(*n)) } + Expr::LocalGet(id) if ctx.integer_locals.contains(id) => { + raw_plan.push(RawArg::I32Value(i)) + } _ => return None, }, SpecParamRep::TaPtr { kind, const_len } => { @@ -122,6 +126,10 @@ fn try_emit_spec_static_call( match entry { RawArg::Double(i) => raw_args_storage.push((DOUBLE, lowered[*i].clone())), RawArg::I32Const(n) => raw_args_storage.push((I32, n.to_string())), + RawArg::I32Value(i) => { + let raw = ctx.block().fptosi(DOUBLE, &lowered[*i], I32); + raw_args_storage.push((I32, raw)); + } RawArg::TaPtr(i) => { let blk = ctx.block(); let bits = blk.bitcast_double_to_i64(&lowered[*i]); diff --git a/crates/perry-codegen/tests/spec_abi_typed_array_local_length.rs b/crates/perry-codegen/tests/spec_abi_typed_array_local_length.rs new file mode 100644 index 0000000000..f611cd3d71 --- /dev/null +++ b/crates/perry-codegen/tests/spec_abi_typed_array_local_length.rs @@ -0,0 +1,134 @@ +use perry_codegen::{compile_module, CompileOptions}; +use perry_hir::types::Type; +use perry_hir::{Expr, Function, Module, Param, Stmt, TYPED_ARRAY_KIND_FLOAT64}; + +fn param(id: u32, name: &str) -> Param { + Param { + id, + name: name.to_string(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + } +} + +fn function_ir_section<'a>(ir: &'a str, symbol: &str) -> &'a str { + let needle = format!("@{symbol}("); + let mut search_start = 0; + let start = loop { + let Some(relative) = ir[search_start..].find(&needle) else { + panic!("function `{symbol}` definition not found in IR:\n{ir}"); + }; + let symbol_pos = search_start + relative; + let line_start = ir[..symbol_pos].rfind('\n').map_or(0, |index| index + 1); + if ir[line_start..symbol_pos] + .trim_start() + .starts_with("define ") + { + break line_start; + } + search_start = symbol_pos + needle.len(); + }; + let rest = &ir[start..]; + let end = rest.find("\n}\n").map_or(rest.len(), |index| index + 3); + &rest[..end] +} + +#[test] +fn const_literal_length_local_routes_float64array_helper_to_raw_specialized_entry() { + let mut module = Module::new("spec_abi_typed_array_local_length.ts"); + module.functions.push(Function { + id: 7, + name: "fill".to_string(), + type_params: Vec::new(), + params: vec![param(100, "values"), param(101, "nodes")], + return_type: Type::Number, + body: vec![ + Stmt::Let { + id: 102, + name: "index".to_string(), + ty: Type::Number, + mutable: false, + init: Some(Expr::Binary { + op: perry_hir::BinaryOp::Sub, + left: Box::new(Expr::LocalGet(101)), + right: Box::new(Expr::Integer(10_000)), + }), + }, + Stmt::Expr(Expr::PutValueSet { + target: Box::new(Expr::LocalGet(100)), + key: Box::new(Expr::LocalGet(102)), + value: Box::new(Expr::Number(1.25)), + receiver: Box::new(Expr::LocalGet(100)), + strict: false, + }), + Stmt::Return(Some(Expr::Number(1.25))), + ], + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }); + module.init = vec![ + Stmt::Let { + id: 1, + name: "nodes".to_string(), + ty: Type::Number, + mutable: false, + init: Some(Expr::Integer(10_000)), + }, + Stmt::Let { + id: 2, + name: "values".to_string(), + ty: Type::Named("Float64Array".to_string()), + mutable: false, + init: Some(Expr::TypedArrayNew { + kind: TYPED_ARRAY_KIND_FLOAT64, + arg: Some(Box::new(Expr::LocalGet(1))), + }), + }, + Stmt::Expr(Expr::Call { + callee: Box::new(Expr::FuncRef(7)), + args: vec![Expr::LocalGet(2), Expr::LocalGet(1)], + type_args: Vec::new(), + byte_offset: 0, + }), + ]; + + let ir = String::from_utf8( + compile_module( + &module, + CompileOptions { + emit_ir_only: true, + ..CompileOptions::default() + }, + ) + .unwrap(), + ) + .unwrap(); + let symbol = "perry_fn_spec_abi_typed_array_local_length_ts__fill$spec_ta7x10000_i32"; + let specialized = function_ir_section(&ir, symbol); + + assert!( + specialized.contains("i32 %arg101"), + "the proven integer local must cross the specialized ABI as raw i32:\n{specialized}" + ); + assert!( + specialized.contains("store double"), + "specialized helper must emit a raw Float64Array store:\n{specialized}" + ); + assert!( + !specialized.contains("js_typed_array_index_set_dynamic"), + "specialized helper must not retain the boxed dynamic store:\n{specialized}" + ); + assert!( + ir.contains(&format!("call double @{symbol}(")), + "the proven call site must call the specialized entry directly:\n{ir}" + ); +} diff --git a/test-files/test_issue_7221_spec_abi_float64array_local_length.ts b/test-files/test_issue_7221_spec_abi_float64array_local_length.ts new file mode 100644 index 0000000000..393aaed0f8 --- /dev/null +++ b/test-files/test_issue_7221_spec_abi_float64array_local_length.ts @@ -0,0 +1,52 @@ +function fill7221( + values: Float64Array, + nodes: number, + dirty: number, + frame: number, + initialChecksum: number, +): number { + let checksum = initialChecksum; + for (let row = 0; row < dirty; row++) { + const index = nodes - dirty + row; + const value = frame * 0.001 + row * 0.01; + values[index] = value; + checksum = (checksum + index + value * 1000) % 4_294_967_296; + } + return checksum; +} + +const nodes7221 = 128; +const dirty7221 = 32; +const frames7221 = 100; + +const helperValues7221 = new Float64Array(nodes7221); +let helperChecksum7221 = 0; +for (let frame = 0; frame < frames7221; frame++) { + helperChecksum7221 = fill7221( + helperValues7221, + nodes7221, + dirty7221, + frame, + helperChecksum7221, + ); +} + +const inlineValues7221 = new Float64Array(nodes7221); +let inlineChecksum7221 = 0; +for (let frame = 0; frame < frames7221; frame++) { + for (let row = 0; row < dirty7221; row++) { + const index = nodes7221 - dirty7221 + row; + const value = frame * 0.001 + row * 0.01; + inlineValues7221[index] = value; + inlineChecksum7221 = + (inlineChecksum7221 + index + value * 1000) % 4_294_967_296; + } +} + +if (helperChecksum7221 !== inlineChecksum7221) { + throw new Error( + `helper/inline checksum mismatch: ${helperChecksum7221} !== ${inlineChecksum7221}`, + ); +} + +console.log("issue7221:", helperChecksum7221, helperValues7221[nodes7221 - 1]); From f70ea0b6b54aca6fc8e48e2051b7a6ebbc38f484 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 12 Aug 2026 13:05:22 +0200 Subject: [PATCH 2/2] docs(changelog): note typed-array spec ABI fix --- changelog.d/7951-float64array-spec-abi.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 changelog.d/7951-float64array-spec-abi.md diff --git a/changelog.d/7951-float64array-spec-abi.md b/changelog.d/7951-float64array-spec-abi.md new file mode 100644 index 0000000000..d27f719a32 --- /dev/null +++ b/changelog.d/7951-float64array-spec-abi.md @@ -0,0 +1,8 @@ +### Performance + +- **Monomorphic typed-array helpers now preserve raw arguments through their + specialized ABI (#7221).** An immutable numeric local used as a typed-array + constructor length now proves the same non-view shape as a direct literal, + and integer locals with complete write-set proofs cross direct calls as raw + `i32` values. This lets derived indices remain native inside the helper and + lowers `Float64Array` writes to direct stores instead of the dynamic setter.