From d2b12121fc4470e9b3462e1b7ccb6cb40b93346b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 27 Aug 2026 21:40:51 +0200 Subject: [PATCH 1/6] perf(transform): bind this.field.push(v) to a local so the inline append lowering applies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit arr.push(v) on a local lowers to Expr::ArrayPush — an inline bump append whose live header test elides the per-store GC bookkeeping — but the same push through a class field is a NativeMethodCall{array, push_single} that lowers to js_array_push_guard + js_array_push_f64 + js_array_length with the layout note and the barrier out of line (7% of an ECS frame in one statement). The pass rewrites the statement form into let old = this.f; let t = old; t.push(v); if (t !== old) this.f = t; which is read for read and write for write what the native lowering did (field read once before the value, write-back only when the head moved), and the let locals are what codegen roots across the value's evaluation. Admitted only for a declared instance array field of the enclosing class with no accessor of that name, as a statement, one non-spread argument, in instance methods/getters/setters. Claude-Session: https://claude.ai/code/session_01FUvFrRNZyc5qknBiJbYbby --- .../src/closure_local_inline.rs | 2 +- .../src/field_push_local_bind.rs | 377 ++++++++++++++++++ crates/perry-transform/src/lib.rs | 2 + 3 files changed, 380 insertions(+), 1 deletion(-) create mode 100644 crates/perry-transform/src/field_push_local_bind.rs diff --git a/crates/perry-transform/src/closure_local_inline.rs b/crates/perry-transform/src/closure_local_inline.rs index ce6715916f..47ef3a8bb9 100644 --- a/crates/perry-transform/src/closure_local_inline.rs +++ b/crates/perry-transform/src/closure_local_inline.rs @@ -583,7 +583,7 @@ fn for_each_expr_in_stmt_mut(stmt: &mut Stmt, f: &mut dyn FnMut(&mut Expr)) { } } -fn nested_stmt_lists(s: &mut Stmt) -> Vec<&mut Vec> { +pub(crate) fn nested_stmt_lists(s: &mut Stmt) -> Vec<&mut Vec> { match s { Stmt::If { then_branch, diff --git a/crates/perry-transform/src/field_push_local_bind.rs b/crates/perry-transform/src/field_push_local_bind.rs new file mode 100644 index 0000000000..3c6cd548f6 --- /dev/null +++ b/crates/perry-transform/src/field_push_local_bind.rs @@ -0,0 +1,377 @@ +//! Bind `this.field.push(v)` to a local so the inline append lowering applies. +//! +//! `arr.push(v)` on a LOCAL array lowers to `Expr::ArrayPush { array_id }`, +//! which codegen turns into an inline bump append: a header test, one store, +//! and — when the value and the live header jointly prove it — none of the +//! per-store GC bookkeeping calls. The same push through a class field +//! (`this.commands.push(cmd)`) is a `NativeMethodCall { module: "array", +//! method: "push_single" }` and lowers to `js_array_push_guard` + +//! `js_array_push_f64` + `js_array_length`, with the runtime doing the layout +//! note and the barrier out of line: on an ECS command buffer that was 7% of +//! the frame, all in one statement. +//! +//! This pass rewrites the statement form +//! +//! ```text +//! this.f.push(v); +//! ``` +//! +//! into +//! +//! ```text +//! let __push_recv_old = this.f; +//! let __push_recv = __push_recv_old; +//! __push_recv.push(v); // Expr::ArrayPush +//! if (__push_recv !== __push_recv_old) this.f = __push_recv; +//! ``` +//! +//! which is, read for read and write for write, what the native lowering +//! already did: the field is read once before the value is evaluated, the +//! push targets that array, and the field is written back only when the +//! append re-allocated the head (`arr.push.wb` in +//! `lower_call/native/native_instance_branch.rs` compares the returned head +//! against the original for exactly this). A `let` local is what codegen +//! roots across the value's evaluation, so the head survives a collection +//! there; a synthetic codegen slot would not. +//! +//! Admission is deliberately narrow: +//! +//! * the receiver is `this.f` where `f` is an instance FIELD of the enclosing +//! class (declared in `Class::fields`) whose declared type is an array, +//! and no getter or setter of that name exists — a rewritten accessor +//! would turn one call into a read plus a write; +//! * the call is a statement (its length result is unused); +//! * exactly one non-spread argument (`push_single`); +//! * instance methods, getters and setters only — never a constructor, +//! where a field may not be initialised yet, and never a static method. +use perry_hir::types::LocalId; +use perry_hir::types::Type; +use perry_hir::{Class, CompareOp, Expr, Function, Module, Stmt}; + +use crate::closure_local_inline::nested_stmt_lists; + +pub fn run(module: &mut Module) { + let mut next_local_id = crate::generator::compute_max_local_id(module).saturating_add(1); + for c in &mut module.classes { + let fields: Vec<(String, Type)> = admissible_fields(c); + if fields.is_empty() { + continue; + } + for m in &mut c.methods { + run_function(m, &fields, &mut next_local_id); + } + for (_, g) in &mut c.getters { + run_function(g, &fields, &mut next_local_id); + } + for (_, s) in &mut c.setters { + run_function(s, &fields, &mut next_local_id); + } + } +} + +/// Instance fields with a declared array type that no accessor shadows. +fn admissible_fields(c: &Class) -> Vec<(String, Type)> { + c.fields + .iter() + .filter(|f| f.key_expr.is_none() && matches!(f.ty, Type::Array(_))) + .filter(|f| { + !c.getters.iter().any(|(name, _)| name == &f.name) + && !c.setters.iter().any(|(name, _)| name == &f.name) + }) + .map(|f| (f.name.clone(), f.ty.clone())) + .collect() +} + +fn run_function(f: &mut Function, fields: &[(String, Type)], next_local_id: &mut LocalId) { + if f.is_async || f.is_generator { + return; + } + process_stmts(&mut f.body, fields, next_local_id); +} + +fn process_stmts(stmts: &mut Vec, fields: &[(String, Type)], next_local_id: &mut LocalId) { + for s in stmts.iter_mut() { + for inner in nested_stmt_lists(s) { + process_stmts(inner, fields, next_local_id); + } + } + let mut i = 0; + while i < stmts.len() { + let Some((field, ty)) = field_push_candidate(&stmts[i], fields) else { + i += 1; + continue; + }; + let Stmt::Expr(Expr::NativeMethodCall { args, .. }) = stmts.remove(i) else { + unreachable!("candidate shape was just matched"); + }; + let value = args + .into_iter() + .next() + .expect("push_single carries one argument"); + let old_id = alloc_local(next_local_id); + let recv_id = alloc_local(next_local_id); + let field_get = || Expr::PropertyGet { + object: Box::new(Expr::This), + property: field.clone(), + byte_offset: 0, + }; + let rewritten = [ + Stmt::Let { + id: old_id, + name: "__push_recv_old".to_string(), + ty: ty.clone(), + mutable: false, + init: Some(field_get()), + }, + Stmt::Let { + id: recv_id, + name: "__push_recv".to_string(), + ty: ty.clone(), + mutable: true, + init: Some(Expr::LocalGet(old_id)), + }, + Stmt::Expr(Expr::ArrayPush { + array_id: recv_id, + value: Box::new(value), + }), + Stmt::If { + condition: Expr::Compare { + op: CompareOp::Ne, + left: Box::new(Expr::LocalGet(recv_id)), + right: Box::new(Expr::LocalGet(old_id)), + }, + then_branch: vec![Stmt::Expr(Expr::PropertySet { + object: Box::new(Expr::This), + property: field.clone(), + value: Box::new(Expr::LocalGet(recv_id)), + })], + else_branch: None, + }, + ]; + let n = rewritten.len(); + stmts.splice(i..i, rewritten); + i += n; + } +} + +fn alloc_local(next_id: &mut LocalId) -> LocalId { + let id = *next_id; + *next_id += 1; + id +} + +/// `this.f.push(v)` as a statement, for an admissible field `f`. +fn field_push_candidate(stmt: &Stmt, fields: &[(String, Type)]) -> Option<(String, Type)> { + let Stmt::Expr(Expr::NativeMethodCall { + module, + class_name: None, + object: Some(object), + method, + args, + }) = stmt + else { + return None; + }; + if module != "array" || method != "push_single" || args.len() != 1 { + return None; + } + let Expr::PropertyGet { + object: recv, + property, + .. + } = object.as_ref() + else { + return None; + }; + if !matches!(recv.as_ref(), Expr::This) { + return None; + } + fields + .iter() + .find(|(name, _)| name == property) + .map(|(name, ty)| (name.clone(), ty.clone())) +} + +#[cfg(test)] +mod tests { + use super::*; + use perry_hir::ClassField; + + fn push_stmt(field: &str) -> Stmt { + Stmt::Expr(Expr::NativeMethodCall { + module: "array".to_string(), + class_name: None, + object: Some(Box::new(Expr::PropertyGet { + object: Box::new(Expr::This), + property: field.to_string(), + byte_offset: 0, + })), + method: "push_single".to_string(), + args: vec![Expr::Number(1.0)], + }) + } + + fn method(body: Vec) -> Function { + Function { + id: 1, + name: "add".to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Void, + body, + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + } + } + + fn field(name: &str, ty: Type) -> ClassField { + ClassField { + name: name.to_string(), + key_expr: None, + ty, + init: None, + is_private: true, + is_readonly: false, + decorators: Vec::new(), + } + } + + fn class(fields: Vec, body: Vec) -> Class { + Class { + id: 1, + name: "Buffer".to_string(), + type_params: Vec::new(), + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields, + constructor: None, + methods: vec![method(body)], + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + computed_members: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + is_nested: false, + alloc_width_hint: 0, + specialized_from: None, + } + } + + fn module_with(class: Class) -> Module { + let mut m = Module::new("field_push_local_bind_test"); + m.classes.push(class); + m + } + + #[test] + fn a_field_push_statement_binds_a_local_and_writes_back_only_on_realloc() { + let mut m = module_with(class( + vec![field("items", Type::Array(Box::new(Type::Number)))], + vec![push_stmt("items")], + )); + run(&mut m); + let body = &m.classes[0].methods[0].body; + assert_eq!(body.len(), 4, "{body:?}"); + let (old_id, recv_id) = match (&body[0], &body[1]) { + ( + Stmt::Let { + id: old, + init: Some(Expr::PropertyGet { property, .. }), + .. + }, + Stmt::Let { + id: recv, + init: Some(Expr::LocalGet(from)), + mutable: true, + .. + }, + ) => { + assert_eq!(property, "items"); + assert_eq!(from, old); + (*old, *recv) + } + other => panic!("expected the two receiver lets, got {other:?}"), + }; + assert!( + matches!(&body[2], Stmt::Expr(Expr::ArrayPush { array_id, .. }) if *array_id == recv_id), + "the push must target the mutable receiver local: {:?}", + body[2] + ); + match &body[3] { + Stmt::If { + condition: + Expr::Compare { + op: CompareOp::Ne, + left, + right, + }, + then_branch, + else_branch: None, + } => { + assert!(matches!(left.as_ref(), Expr::LocalGet(id) if *id == recv_id)); + assert!(matches!(right.as_ref(), Expr::LocalGet(id) if *id == old_id)); + assert!(matches!( + &then_branch[..], + [Stmt::Expr(Expr::PropertySet { property, value, .. })] + if property == "items" + && matches!(value.as_ref(), Expr::LocalGet(id) if *id == recv_id) + )); + } + other => panic!("expected the guarded write-back, got {other:?}"), + } + } + + #[test] + fn an_accessor_of_the_same_name_or_a_non_array_field_is_left_alone() { + let mut with_getter = class( + vec![field("items", Type::Array(Box::new(Type::Number)))], + vec![push_stmt("items")], + ); + with_getter + .getters + .push(("items".to_string(), method(Vec::new()))); + let mut m = module_with(with_getter); + run(&mut m); + assert_eq!( + m.classes[0].methods[0].body.len(), + 1, + "a getter-shadowed field must not be rewritten" + ); + + let mut m = module_with(class( + vec![field("items", Type::Any)], + vec![push_stmt("items")], + )); + run(&mut m); + assert_eq!( + m.classes[0].methods[0].body.len(), + 1, + "an untyped field must not be rewritten" + ); + + let mut m = module_with(class( + vec![field("items", Type::Array(Box::new(Type::Number)))], + vec![push_stmt("other")], + )); + run(&mut m); + assert_eq!( + m.classes[0].methods[0].body.len(), + 1, + "an undeclared field must not be rewritten" + ); + } +} diff --git a/crates/perry-transform/src/lib.rs b/crates/perry-transform/src/lib.rs index 81e844437e..1fb590cc25 100644 --- a/crates/perry-transform/src/lib.rs +++ b/crates/perry-transform/src/lib.rs @@ -11,6 +11,7 @@ pub mod async_to_generator; pub mod closure; mod closure_local_inline; pub mod deforest; +mod field_push_local_bind; pub mod finally_inline; pub mod generator; pub mod i18n; @@ -52,5 +53,6 @@ pub fn post_inline_cleanups(module: &mut perry_hir::Module) { unroll_static_loops(module); aggregate_scalar::run(module); closure_local_inline::run(module); + field_push_local_bind::run(module); prop_cse::run(module); } From 0e13090b1771c9613f31979aa3e9f16009dda9f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 27 Aug 2026 22:06:18 +0200 Subject: [PATCH 2/6] perf(codegen): the tiny-method allocation kernel sees through a field-push expansion field_push_local_bind expands one this.f.push(v) statement into four, which pushed a command-buffer method that is exactly this.commands.push({...}) over the tiny-method budget: its literal fell back to the outlined js_object_alloc_class_inline_keys_stamped (+ per-object layout records), a 7.7% regression that ate the push's gain. The rule now counts each expansion as the one statement it came from; pinned by a test on the expanded shape. Claude-Session: https://claude.ai/code/session_01FUvFrRNZyc5qknBiJbYbby --- .../src/collectors/hot_callees.rs | 142 +++++++++++++++++- 1 file changed, 141 insertions(+), 1 deletion(-) diff --git a/crates/perry-codegen/src/collectors/hot_callees.rs b/crates/perry-codegen/src/collectors/hot_callees.rs index 572f524d9b..e55d91a9be 100644 --- a/crates/perry-codegen/src/collectors/hot_callees.rs +++ b/crates/perry-codegen/src/collectors/hot_callees.rs @@ -64,6 +64,29 @@ const INDIRECT_CLOSURE_ALLOC_SITE_BUDGET: u32 = 8; const TINY_METHOD_MAX_STMTS: usize = 2; const TINY_METHOD_ALLOC_SITE_BUDGET: u32 = 8; +/// The receiver-binding local `perry-transform`'s `field_push_local_bind` +/// pass introduces when it expands one `this.f.push(v)` statement into four +/// (`let __push_recv_old = this.f; let __push_recv = old; push; if (moved) +/// this.f = __push_recv`). For the tiny-method budget above that is still the +/// ONE statement the author wrote: the pass exists so the push takes the +/// inline append, and a command-buffer method that is exactly +/// `this.commands.push({ ... })` must not lose its allocation kernel to the +/// rewrite that made its push cheaper. Kept in sync by name with the pass +/// (`field_push_local_bind.rs`); the test below pins the shape. +const FIELD_PUSH_RECEIVER_OLD_NAME: &str = "__push_recv_old"; + +/// Statement count for the tiny-method rule: each field-push expansion +/// counts as the single statement it came from. +fn tiny_method_stmt_count(body: &[Stmt]) -> usize { + let expansions = body + .iter() + .filter( + |stmt| matches!(stmt, Stmt::Let { name, .. } if name == FIELD_PUSH_RECEIVER_OLD_NAME), + ) + .count(); + body.len().saturating_sub(3 * expansions) +} + /// Collect the set of `FuncId`s eligible for `inlinehint`: those with ≥1 direct /// call site inside a loop AND at most `max_call_sites` total direct call sites /// across the whole module (`init` + every function + every executable @@ -245,7 +268,7 @@ pub fn collect_alloc_hot_functions(hir: &Module) -> HashSet { let mut tiny_method_sites: HashMap = HashMap::new(); for class in &hir.classes { for method in &class.methods { - if method.body.len() > TINY_METHOD_MAX_STMTS { + if tiny_method_stmt_count(&method.body) > TINY_METHOD_MAX_STMTS { continue; } // Count into a scratch map because the ownership-aware walker also @@ -832,6 +855,123 @@ mod recursion_participant_tests { })) } + fn new_expr() -> Expr { + Expr::New { + class_name: "Command".to_string(), + args: Vec::new(), + cap_args_appended: 0, + type_args: Vec::new(), + byte_offset: 0, + } + } + + /// `this.commands.push({ ... })` after `field_push_local_bind` expanded it. + fn expanded_field_push(old_id: u32, recv_id: u32) -> Vec { + vec![ + Stmt::Let { + id: old_id, + name: FIELD_PUSH_RECEIVER_OLD_NAME.to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::PropertyGet { + object: Box::new(Expr::This), + property: "commands".to_string(), + byte_offset: 0, + }), + }, + Stmt::Let { + id: recv_id, + name: "__push_recv".to_string(), + ty: Type::Any, + mutable: true, + init: Some(Expr::LocalGet(old_id)), + }, + Stmt::Expr(Expr::ArrayPush { + array_id: recv_id, + value: Box::new(new_expr()), + }), + Stmt::If { + condition: Expr::Compare { + op: perry_hir::CompareOp::Ne, + left: Box::new(Expr::LocalGet(recv_id)), + right: Box::new(Expr::LocalGet(old_id)), + }, + then_branch: vec![Stmt::Expr(Expr::PropertySet { + object: Box::new(Expr::This), + property: "commands".to_string(), + value: Box::new(Expr::LocalGet(recv_id)), + })], + else_branch: None, + }, + ] + } + + fn class_with_method(method: Function) -> perry_hir::Class { + perry_hir::Class { + id: 1, + name: "CommandBuffer".to_string(), + type_params: Vec::new(), + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: Vec::new(), + constructor: None, + methods: vec![method], + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + computed_members: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + is_nested: false, + alloc_width_hint: 0, + specialized_from: None, + } + } + + /// Rule 4 must see through `field_push_local_bind`'s expansion: a method + /// that was `this.commands.push({ ... })` is still a tiny allocation + /// kernel after the pass rewrote its push, while four genuinely separate + /// statements still exceed the budget. + #[test] + fn tiny_method_rule_counts_a_field_push_expansion_as_one_statement() { + let mut module = Module::new("buffer.ts"); + module + .classes + .push(class_with_method(func(11, expanded_field_push(100, 101)))); + let mut plain = expanded_field_push(200, 201); + // Same four statements, but the first is an ordinary local: not an + // expansion, so the method is four statements long. + if let Stmt::Let { name, .. } = &mut plain[0] { + *name = "old".to_string(); + } + module.classes.push(class_with_method(func(12, plain))); + + assert_eq!( + tiny_method_stmt_count(&module.classes[0].methods[0].body), + 1 + ); + assert_eq!( + tiny_method_stmt_count(&module.classes[1].methods[0].body), + 4 + ); + let hot = collect_alloc_hot_functions(&module); + assert!( + hot.contains(&11), + "the expanded field push is still a tiny kernel: {hot:?}" + ); + assert!( + !hot.contains(&12), + "four unrelated statements are not: {hot:?}" + ); + } + #[test] fn self_call_cycle_pair_and_acyclic_chain_are_classified() { let mut module = Module::new("recursion.ts"); From c29049748ba45d5620e1d69c23dd99efe8e047eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 27 Aug 2026 22:06:18 +0200 Subject: [PATCH 3/6] perf(codegen): inline the f64 typed-argument guard and unbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit emit_typed_f64_guard is the exact js_typed_f64_arg_guard predicate (is_number || is_int32) in IR, mirroring the existing i32 lane; the guarded unbox is a select over the INT32 lane. Every public entry of a function with a boxed-double clone ran the guard as a cross-crate call per numeric parameter — a one-line ECS isComponentId(id) paid a call for a four instruction compare. Same predicate, same routing decision; the two typed dispatch sites that called the runtime symbol directly now share the helper. Claude-Session: https://claude.ai/code/session_01FUvFrRNZyc5qknBiJbYbby --- .../src/codegen/ordinary_param_guard_tests.rs | 9 ++-- .../src/codegen/spec_self_recursion_tests.rs | 10 +++- crates/perry-codegen/src/codegen/typed_abi.rs | 49 +++++++++++++++++-- .../src/lower_call/early_branches.rs | 21 ++++---- .../src/lower_call/method_override.rs | 6 +-- 5 files changed, 71 insertions(+), 24 deletions(-) diff --git a/crates/perry-codegen/src/codegen/ordinary_param_guard_tests.rs b/crates/perry-codegen/src/codegen/ordinary_param_guard_tests.rs index c0a6d53fe3..9ed7e9d4f7 100644 --- a/crates/perry-codegen/src/codegen/ordinary_param_guard_tests.rs +++ b/crates/perry-codegen/src/codegen/ordinary_param_guard_tests.rs @@ -672,10 +672,11 @@ fn nonsuspending_async_function_needs_no_direct_call_site_for_its_guarded_clone( .count(), 1 ); - assert_eq!( - public.matches("call i32 @js_typed_f64_arg_guard(").count(), - 1 - ); + // The Number leg is the inline `is_number || is_int32` predicate now + // (`emit_typed_f64_guard`): one band test against the Perry tag range, + // no runtime call. + assert!(!public.contains("call i32 @js_typed_f64_arg_guard(")); + assert_eq!(public.matches(", 32761").count(), 1, "{public}"); assert!(public.contains("$spec_b_b(")); assert!(public.contains("$generic(")); let specialized = function_ir(&ir, "renderAsync$spec_b_b("); diff --git a/crates/perry-codegen/src/codegen/spec_self_recursion_tests.rs b/crates/perry-codegen/src/codegen/spec_self_recursion_tests.rs index f1d7b59f94..acc92593c4 100644 --- a/crates/perry-codegen/src/codegen/spec_self_recursion_tests.rs +++ b/crates/perry-codegen/src/codegen/spec_self_recursion_tests.rs @@ -339,7 +339,10 @@ fn derived_recursive_number_argument_re_enters_the_guarded_clone() { // Keep both halves of the subject live: this must be the ordinary boxed // clone selected by the public Number guard, not the raw-i32 Tier-A path. - assert!(public.contains("call i32 @js_typed_f64_arg_guard(")); + assert!( + public.contains(", 32761") && !public.contains("call i32 @js_typed_f64_arg_guard("), + "the public Number guard is the inline band test:\n{public}" + ); assert!( clone.starts_with("define internal") && clone.contains("double @perry_fn_spec_self_recursion_ts__f$spec_b(double"), @@ -376,7 +379,10 @@ fn bigint_capable_recursive_argument_keeps_the_public_guard() { let public = function_ir(&ir, "@perry_fn_spec_self_recursion_bigint_ts__f("); let clone = function_ir(&ir, "$spec_b_b("); - assert!(public.contains("call i32 @js_typed_f64_arg_guard(")); + assert!( + public.contains(", 32761") && !public.contains("call i32 @js_typed_f64_arg_guard("), + "the public Number guard is the inline band test:\n{public}" + ); assert!( clone.starts_with("define internal") && clone.contains("double @perry_fn_spec_self_recursion_bigint_ts__f$spec_b_b(double"), diff --git a/crates/perry-codegen/src/codegen/typed_abi.rs b/crates/perry-codegen/src/codegen/typed_abi.rs index 41850e74d8..a9153644f4 100644 --- a/crates/perry-codegen/src/codegen/typed_abi.rs +++ b/crates/perry-codegen/src/codegen/typed_abi.rs @@ -242,6 +242,9 @@ pub(crate) fn emit_typed_arg_guard( rep: TypedParamRep, arg: &str, ) -> String { + if rep == TypedParamRep::F64 { + return emit_typed_f64_guard(blk, arg); + } let raw = blk.call( crate::types::I32, rep.guard_fn(), @@ -250,17 +253,53 @@ pub(crate) fn emit_typed_arg_guard( blk.icmp_ne(crate::types::I32, &raw, "0") } +/// Inline the exact contract of runtime `js_typed_f64_arg_guard` +/// (`JSValue::is_number || JSValue::is_int32`), the way +/// [`emit_typed_i32_guard_and_raw`] already does for the i32 lane. +/// +/// The public entry of every function with a boxed-double clone runs this +/// guard on each numeric parameter before dispatching; a one-line predicate +/// such as an ECS `isComponentId(id)` paid a cross-crate call per invocation +/// for a compare it could have done in four instructions. Same predicate, +/// same routing decision. +pub(crate) fn emit_typed_f64_guard(blk: &mut crate::block::LlBlock, arg: &str) -> String { + use crate::types::{I1, I64}; + let bits = blk.bitcast_double_to_i64(arg); + // JSValue::is_number: Perry-owned tags occupy the positive-qNaN top words + // 0x7ff9..=0x7fff; everything outside that band is a Number. + let top16 = blk.lshr(I64, &bits, "48"); + let below_tag_band = blk.icmp_ult(I64, &top16, crate::nanbox::SHORT_STRING_TAG_TOP16_I64); + let above_tag_band = blk.icmp_ugt(I64, &top16, crate::nanbox::STRING_TAG_TOP16_I64); + let is_plain_number = blk.or(I1, &below_tag_band, &above_tag_band); + // JSValue::is_int32: the INT32 tag with any payload. + let int32_identity_mask = crate::nanbox::i64_literal(!crate::nanbox::INT32_MASK); + let tagged_identity = blk.and(I64, &bits, &int32_identity_mask); + let is_int32 = blk.icmp_eq(I64, &tagged_identity, crate::nanbox::INT32_TAG_I64); + blk.or(I1, &is_plain_number, &is_int32) +} + +/// Inline `js_typed_f64_arg_to_raw` for a value the F64 guard admitted: an +/// INT32-tagged value converts its low lane, anything else is already the +/// double the clone wants. Only valid after [`emit_typed_f64_guard`] passed +/// (a tagged non-number would otherwise reach the clone as its raw bits). +pub(crate) fn emit_typed_f64_to_raw_guarded(blk: &mut crate::block::LlBlock, arg: &str) -> String { + use crate::types::{DOUBLE, I1, I32, I64}; + let bits = blk.bitcast_double_to_i64(arg); + let int32_identity_mask = crate::nanbox::i64_literal(!crate::nanbox::INT32_MASK); + let tagged_identity = blk.and(I64, &bits, &int32_identity_mask); + let is_int32 = blk.icmp_eq(I64, &tagged_identity, crate::nanbox::INT32_TAG_I64); + let low = blk.trunc(I64, &bits, I32); + let converted = blk.sitofp(I32, &low, DOUBLE); + blk.select(I1, &is_int32, DOUBLE, &converted, arg) +} + pub(crate) fn emit_typed_arg_to_raw( blk: &mut crate::block::LlBlock, rep: TypedParamRep, arg: &str, ) -> String { match rep { - TypedParamRep::F64 => blk.call( - crate::types::DOUBLE, - rep.unbox_fn(), - &[(crate::types::DOUBLE, arg)], - ), + TypedParamRep::F64 => emit_typed_f64_to_raw_guarded(blk, arg), TypedParamRep::I32 => blk.call( crate::types::I32, rep.unbox_fn(), diff --git a/crates/perry-codegen/src/lower_call/early_branches.rs b/crates/perry-codegen/src/lower_call/early_branches.rs index 479a86678d..98d1559f1e 100644 --- a/crates/perry-codegen/src/lower_call/early_branches.rs +++ b/crates/perry-codegen/src/lower_call/early_branches.rs @@ -967,10 +967,11 @@ pub fn try_lower_closure_typed_local_call( crate::codegen::generic_closure_body_name(&closure_fn); let mut typed_guard: Option = None; for (value, rep) in lowered_args.iter().zip(typed_param_reps.iter()) { - let raw = - ctx.block() - .call(I32, rep.guard_fn(), &[(DOUBLE, value.as_str())]); - let ok = ctx.block().icmp_ne(I32, &raw, "0"); + let ok = crate::codegen::emit_typed_arg_guard( + ctx.block(), + *rep, + value.as_str(), + ); typed_guard = Some(match typed_guard { Some(prev) => ctx.block().and(I1, &prev, &ok), None => ok, @@ -1005,11 +1006,13 @@ pub fn try_lower_closure_typed_local_call( Vec::with_capacity(lowered_args.len()); for (value, rep) in lowered_args.iter().zip(typed_param_reps.iter()) { typed_args_storage.push(match rep { - crate::codegen::TypedParamRep::F64 => ctx.block().call( - DOUBLE, - rep.unbox_fn(), - &[(DOUBLE, value.as_str())], - ), + crate::codegen::TypedParamRep::F64 => { + crate::codegen::emit_typed_arg_to_raw( + ctx.block(), + *rep, + value.as_str(), + ) + } crate::codegen::TypedParamRep::I32 => ctx.block().call( I32, rep.unbox_fn(), diff --git a/crates/perry-codegen/src/lower_call/method_override.rs b/crates/perry-codegen/src/lower_call/method_override.rs index fe09922c20..35038e647f 100644 --- a/crates/perry-codegen/src/lower_call/method_override.rs +++ b/crates/perry-codegen/src/lower_call/method_override.rs @@ -1273,8 +1273,7 @@ pub(super) fn emit_guarded_direct_method_call( .collect(); let mut guard: Option = None; for (value, rep) in formal_args.iter().zip(typed_param_reps.iter()) { - let raw = ctx.block().call(I32, rep.guard_fn(), &[(DOUBLE, *value)]); - let ok = ctx.block().icmp_ne(I32, &raw, "0"); + let ok = crate::codegen::emit_typed_arg_guard(ctx.block(), *rep, value); guard = Some(match guard { Some(prev) => ctx.block().and(I1, &prev, &ok), None => ok, @@ -1298,8 +1297,7 @@ pub(super) fn emit_guarded_direct_method_call( for (value, rep) in formal_args.iter().zip(typed_param_reps.iter()) { typed_args_storage.push(match rep { crate::codegen::TypedParamRep::F64 => { - ctx.block() - .call(DOUBLE, rep.unbox_fn(), &[(DOUBLE, *value)]) + crate::codegen::emit_typed_arg_to_raw(ctx.block(), *rep, value) } crate::codegen::TypedParamRep::I32 => { ctx.block().call(I32, rep.unbox_fn(), &[(DOUBLE, *value)]) From 96161d1d0dbdf574400e5f5d6982b35bb5f619e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 27 Aug 2026 22:06:18 +0200 Subject: [PATCH 4/6] perf(runtime): iteration helpers probe the typed-array/Buffer registries only for a non-array header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A GC_TYPE_ARRAY header is never a registered typed array, Buffer or native view (every registration carries its own object type), so the 13 receiver-dispatch probes in iter_methods.rs are gated on receiver_may_be_registered_exotic — one header byte — instead of two thread-local registry lookups per call. Claude-Session: https://claude.ai/code/session_01FUvFrRNZyc5qknBiJbYbby --- crates/perry-runtime/src/array/header.rs | 17 +++++++ .../perry-runtime/src/array/iter_methods.rs | 50 ++++++++++++++----- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/crates/perry-runtime/src/array/header.rs b/crates/perry-runtime/src/array/header.rs index 34ba3bd64b..1ef7639531 100644 --- a/crates/perry-runtime/src/array/header.rs +++ b/crates/perry-runtime/src/array/header.rs @@ -950,6 +950,23 @@ pub(crate) fn array_ptr_as_proxy(arr: *const ArrayHeader) -> Option { None } +/// May this receiver be a registered typed array, Buffer or native view? +/// +/// A `GC_TYPE_ARRAY` header never is — every registration carries its own +/// object type (`GC_TYPE_TYPED_ARRAY`, `GC_TYPE_NATIVE_TYPED_VIEW`, +/// `GC_TYPE_BUFFER`) — so the iteration helpers need not probe the +/// thread-local registries for one. Anything else, including a header this +/// cannot read, may be, and keeps the probes. +#[inline] +pub(crate) fn receiver_may_be_registered_exotic(arr: *const ArrayHeader) -> bool { + unsafe { + match array_gc_header(arr) { + Some(header) => (*header).obj_type != crate::gc::GC_TYPE_ARRAY, + None => true, + } + } +} + /// Normalize an Array.prototype method receiver into a real ArrayHeader. /// /// `Array.prototype..call(arrayLike, ...)` lets a *generic array-like diff --git a/crates/perry-runtime/src/array/iter_methods.rs b/crates/perry-runtime/src/array/iter_methods.rs index a3dc190a04..fc1888841b 100644 --- a/crates/perry-runtime/src/array/iter_methods.rs +++ b/crates/perry-runtime/src/array/iter_methods.rs @@ -254,7 +254,9 @@ pub extern "C" fn js_array_forEach(arr: *const ArrayHeader, callback: *const Clo if arr.is_null() { return; } - if crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() { + if super::header::receiver_may_be_registered_exotic(arr) + && crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() + { crate::typedarray::js_typed_array_for_each( arr as *const crate::typedarray::TypedArrayHeader, callback, @@ -323,7 +325,9 @@ pub extern "C" fn js_array_map( if arr.is_null() { return js_array_alloc(0); } - if crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() { + if super::header::receiver_may_be_registered_exotic(arr) + && crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() + { // Typed-array receiver: read elements per element-kind and return a // same-kind TypedArray (mirrors the sort/at/findLast delegation). return crate::typedarray::js_typed_array_map( @@ -515,7 +519,9 @@ pub extern "C" fn js_array_filter( if arr.is_null() { return js_array_alloc(0); } - if crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() { + if super::header::receiver_may_be_registered_exotic(arr) + && crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() + { return crate::typedarray::js_typed_array_filter( arr as *const crate::typedarray::TypedArrayHeader, callback, @@ -603,7 +609,9 @@ pub extern "C" fn js_array_find(arr: *const ArrayHeader, callback: *const Closur if arr.is_null() { return f64::from_bits(crate::value::TAG_UNDEFINED); } - if crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() { + if super::header::receiver_may_be_registered_exotic(arr) + && crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() + { return crate::typedarray::js_typed_array_find( arr as *const crate::typedarray::TypedArrayHeader, callback, @@ -663,7 +671,9 @@ pub extern "C" fn js_array_findIndex( if arr.is_null() { return -1; } - if crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() { + if super::header::receiver_may_be_registered_exotic(arr) + && crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() + { return crate::typedarray::js_typed_array_find_index( arr as *const crate::typedarray::TypedArrayHeader, callback, @@ -708,7 +718,9 @@ pub extern "C" fn js_array_find_last( if arr.is_null() { return f64::from_bits(crate::value::TAG_UNDEFINED); } - if crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() { + if super::header::receiver_may_be_registered_exotic(arr) + && crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() + { return crate::typedarray::js_typed_array_find_last( arr as *const crate::typedarray::TypedArrayHeader, callback, @@ -749,7 +761,9 @@ pub extern "C" fn js_array_find_last_index( if arr.is_null() { return -1; } - if crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() { + if super::header::receiver_may_be_registered_exotic(arr) + && crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() + { let r = crate::typedarray::js_typed_array_find_last_index( arr as *const crate::typedarray::TypedArrayHeader, callback, @@ -849,7 +863,9 @@ pub extern "C" fn js_array_some(arr: *const ArrayHeader, callback: *const Closur if arr.is_null() { return f64::from_bits(TAG_FALSE); } - if crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() { + if super::header::receiver_may_be_registered_exotic(arr) + && crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() + { return crate::typedarray::js_typed_array_some( arr as *const crate::typedarray::TypedArrayHeader, callback, @@ -909,8 +925,10 @@ pub extern "C" fn js_array_some_captureless( if arr.is_null() { return f64::from_bits(TAG_FALSE); } - if crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() - || crate::buffer::is_registered_buffer(arr as usize) + if super::header::receiver_may_be_registered_exotic(arr) + && crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() + || super::header::receiver_may_be_registered_exotic(arr) + && crate::buffer::is_registered_buffer(arr as usize) { let callback = crate::closure::js_closure_alloc_singleton(callback_func); return js_array_some(original_arr, callback); @@ -998,7 +1016,9 @@ pub extern "C" fn js_array_every(arr: *const ArrayHeader, callback: *const Closu if arr.is_null() { return f64::from_bits(TAG_TRUE); } - if crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() { + if super::header::receiver_may_be_registered_exotic(arr) + && crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() + { return crate::typedarray::js_typed_array_every( arr as *const crate::typedarray::TypedArrayHeader, callback, @@ -1146,7 +1166,9 @@ pub extern "C" fn js_array_reduce( // Typed-array receiver: read elements per element-kind (raw int/float // storage is NOT NaN-boxed f64, so the generic ArrayHeader path below would // read garbage). Issue #2799. - if crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() { + if super::header::receiver_may_be_registered_exotic(arr) + && crate::typedarray::lookup_typed_array_kind(arr as usize).is_some() + { return crate::typedarray::js_typed_array_reduce( arr as *const crate::typedarray::TypedArrayHeader, callback, @@ -1429,7 +1451,9 @@ pub extern "C" fn js_validate_array_map_callback(arr: i64, cb_boxed: f64) -> i64 if let Some(p) = resolve_callback_ptr(cb_boxed) { return p; } - let is_typed_array = crate::typedarray::lookup_typed_array_kind(arr as usize).is_some(); + let is_typed_array = + super::header::receiver_may_be_registered_exotic(arr as *const ArrayHeader) + && crate::typedarray::lookup_typed_array_kind(arr as usize).is_some(); let rendered = if is_typed_array { render_callback_plain(cb_boxed) } else { From 54c3e1c60e622faeb1efa4153c627da0a723dda4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 27 Aug 2026 22:43:16 +0200 Subject: [PATCH 5/6] changelog: #8897 ECS command-path round 3 Claude-Session: https://claude.ai/code/session_01FUvFrRNZyc5qknBiJbYbby --- changelog.d/8897-ecs-round3-field-push-inline-append.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 changelog.d/8897-ecs-round3-field-push-inline-append.md diff --git a/changelog.d/8897-ecs-round3-field-push-inline-append.md b/changelog.d/8897-ecs-round3-field-push-inline-append.md new file mode 100644 index 0000000000..e598400960 --- /dev/null +++ b/changelog.d/8897-ecs-round3-field-push-inline-append.md @@ -0,0 +1,9 @@ +Four more general mechanisms on the ECS command path (round 3 after #8885): +`this.field.push(v)` statements bind their receiver to a local so the push +takes the inline append instead of the runtime push (and the tiny-method +allocation kernel rule sees through that expansion); the f64 typed-argument +guard and unbox are inlined at every typed dispatch; the array iteration +helpers probe the typed-array/Buffer registries only for a non-array header. +On the upstream `codehz/ecs` "5k entities: 3 commands each + sync" row the +compiled benchmark went from 4.38 ms/op to 4.15 ms/op (−5.4%, paired runs on +an idle Mac mini; Node 26.5.1 is 1.76 ms/op). From e2afea469196b0fd9374cf1cbfbc8660aa39de3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Thu, 27 Aug 2026 23:47:02 +0200 Subject: [PATCH 6/6] perf(codegen): inline the f64 typed guard at the direct-call and scalar-method dispatch sites too; repin the native_proof_regressions markers The free-function direct call (func_ref.rs) and the scalar-replaced method dispatch (scalar_method.rs) still called js_typed_f64_arg_guard / js_typed_f64_arg_to_raw; they now share emit_typed_f64_guard / emit_typed_f64_to_raw_guarded with the public entries. The native_proof_regressions integration tests that pinned the runtime calls at typed-dispatch sites pin the inline markers (the SHORT_STRING band bound ', 32761' and the INT32-lane 'sitofp i32 %') instead; the Map/Set number-key and closure-capture unbox sites keep their runtime calls and their pins. Claude-Session: https://claude.ai/code/session_01FUvFrRNZyc5qknBiJbYbby --- crates/perry-codegen/src/codegen/mod.rs | 5 +- .../perry-codegen/src/lower_call/func_ref.rs | 8 +- .../src/lower_call/scalar_method.rs | 11 +-- .../tests/native_proof_regressions.rs | 80 ++++++++++--------- 4 files changed, 50 insertions(+), 54 deletions(-) diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index d3f25b64ad..3af1a6358a 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -250,8 +250,9 @@ pub(crate) use opts::{CrossModuleCtx, ImportedCtor}; pub(crate) use param_guard::scalar_descriptor_rep; pub(crate) use spec_abi::{spec_abi_enabled, spec_function_name, SpecDispatch, SpecFnPlan}; pub(crate) use typed_abi::{ - emit_typed_arg_guard, emit_typed_arg_to_raw, generic_closure_body_name, - generic_function_body_name, generic_method_body_name, nonnegative_index_fast_array_method_name, + emit_typed_arg_guard, emit_typed_arg_to_raw, emit_typed_f64_guard, + emit_typed_f64_to_raw_guarded, generic_closure_body_name, generic_function_body_name, + generic_method_body_name, nonnegative_index_fast_array_method_name, nonnegative_index_fast_array_params, nonnegative_index_method_name, typed_arg_is_guard_candidate, typed_f64_closure_name, typed_f64_function_name, typed_f64_method_name, typed_f64_receiver_method_info, typed_f64_receiver_method_name, diff --git a/crates/perry-codegen/src/lower_call/func_ref.rs b/crates/perry-codegen/src/lower_call/func_ref.rs index 71833e7fd1..d69e121e22 100644 --- a/crates/perry-codegen/src/lower_call/func_ref.rs +++ b/crates/perry-codegen/src/lower_call/func_ref.rs @@ -1336,10 +1336,7 @@ pub fn try_lower_func_ref_call( let generic_body_name = crate::codegen::generic_function_body_name(&fname); let mut guard: Option = None; for (value, rep) in lowered.iter().zip(typed_i1_param_reps.iter()) { - let raw = ctx - .block() - .call(I32, rep.guard_fn(), &[(DOUBLE, value.as_str())]); - let ok = ctx.block().icmp_ne(I32, &raw, "0"); + let ok = crate::codegen::emit_typed_arg_guard(ctx.block(), *rep, value.as_str()); guard = Some(match guard { Some(prev) => ctx.block().and(I1, &prev, &ok), None => ok, @@ -1362,8 +1359,7 @@ pub fn try_lower_func_ref_call( for (value, rep) in lowered.iter().zip(typed_i1_param_reps.iter()) { typed_args_storage.push(match rep { crate::codegen::TypedParamRep::F64 => { - ctx.block() - .call(DOUBLE, rep.unbox_fn(), &[(DOUBLE, value.as_str())]) + crate::codegen::emit_typed_arg_to_raw(ctx.block(), *rep, value.as_str()) } crate::codegen::TypedParamRep::I32 => { ctx.block() diff --git a/crates/perry-codegen/src/lower_call/scalar_method.rs b/crates/perry-codegen/src/lower_call/scalar_method.rs index 7629a8fbcc..1d3b703356 100644 --- a/crates/perry-codegen/src/lower_call/scalar_method.rs +++ b/crates/perry-codegen/src/lower_call/scalar_method.rs @@ -1145,10 +1145,7 @@ pub(super) fn try_lower_scalar_replaced_method_call( let guard_values = collect_guard_local_values(ctx, &arg_plans)?; let mut guard: Option = None; for (_, value) in &guard_values { - let raw = ctx - .block() - .call(I32, "js_typed_f64_arg_guard", &[(DOUBLE, value.as_str())]); - let ok = ctx.block().icmp_ne(I32, &raw, "0"); + let ok = crate::codegen::emit_typed_f64_guard(ctx.block(), value.as_str()); guard = Some(match guard { Some(prev) => ctx.block().and(I1, &prev, &ok), None => ok, @@ -1172,11 +1169,7 @@ pub(super) fn try_lower_scalar_replaced_method_call( for (id, value) in &guard_values { raw_locals.insert( *id, - ctx.block().call( - DOUBLE, - "js_typed_f64_arg_to_raw", - &[(DOUBLE, value.as_str())], - ), + crate::codegen::emit_typed_f64_to_raw_guarded(ctx.block(), value.as_str()), ); } let mut fast_args = Vec::with_capacity(args.len()); diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index 3d2477b0ad..0504316cfb 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -8,6 +8,12 @@ // `NativeRootsPin::native()` because their module-wide "no inbounds GEP" proxy // collides with the shadow lowering's inline slot addressing (see that file's // header). +// The typed-f64 guard/unbox are inline since `emit_typed_f64_guard`: a +// public entry proves `is_number || is_int32` with a band test whose +// SHORT_STRING top-16 bound (`, 32761`) is its signature, and unboxes an +// INT32 lane with `sitofp i32 %` behind a select. Assertions below pin those +// markers where they used to pin `call i32 @js_typed_f64_arg_guard(` and +// `call double @js_typed_f64_arg_to_raw`. use perry_codegen::testing::NativeRootsPin; use perry_codegen::{compile_module, AppMetadata, CompileOptions}; use perry_hir::types::{ObjectType, PropertyInfo, Type, TypeParam}; @@ -1173,7 +1179,10 @@ fn assert_only_guarded_generic_splits(ir: &str, case: &str) { let entry = function_ir_section(ir, base); assert!( entry.contains("call i32 @js_param_type_guard(") - || entry.contains("_arg_guard(double "), + || entry.contains("_arg_guard(double ") + // The f64 guard is the inline `is_number || is_int32` band test + // (`emit_typed_f64_guard`): its SHORT_STRING top-16 bound. + || entry.contains(", 32761"), "{case}: `{base}`'s public entry reaches a clone without guarding:\n{entry}" ); } @@ -3202,8 +3211,8 @@ fn map_number_key_set_get_has_delete_use_guarded_number_key_specialization() { let ir = compile_ir_for_module_with_opts(module, empty_opts()).unwrap(); let probe_ir = body_ir_section(&ir, "perry_fn_map_number_key_specialization_ts__probe"); assert!( - probe_ir.contains("call i32 @js_typed_f64_arg_guard") - && probe_ir.contains("call double @js_typed_f64_arg_to_raw"), + probe_ir.contains("call i32 @js_typed_f64_arg_guard(") + && probe_ir.contains("call double @js_typed_f64_arg_to_raw("), "Map specialization should guard then unbox the key to raw f64:\n{probe_ir}" ); for helper in [ @@ -3275,8 +3284,8 @@ fn map_number_key_string_value_set_uses_string_ref_until_slot() { "perry_fn_map_number_string_value_specialization_ts__probe", ); assert!( - probe_ir.contains("call i32 @js_typed_f64_arg_guard") - && probe_ir.contains("call double @js_typed_f64_arg_to_raw"), + probe_ir.contains("call i32 @js_typed_f64_arg_guard(") + && probe_ir.contains("call double @js_typed_f64_arg_to_raw("), "Map.set should keep the existing guarded numeric-key path:\n{probe_ir}" ); assert!( @@ -4902,8 +4911,8 @@ fn set_number_add_has_delete_use_guarded_number_specialization() { let ir = compile_ir_for_module_with_opts(module, empty_opts()).unwrap(); let probe_ir = body_ir_section(&ir, "perry_fn_set_number_specialization_ts__probe"); assert!( - probe_ir.contains("call i32 @js_typed_f64_arg_guard") - && probe_ir.contains("call double @js_typed_f64_arg_to_raw"), + probe_ir.contains("call i32 @js_typed_f64_arg_guard(") + && probe_ir.contains("call double @js_typed_f64_arg_to_raw("), "Set specialization should guard then unbox the value to raw f64:\n{probe_ir}" ); for helper in [ @@ -7588,7 +7597,7 @@ fn compiler_private_async_iter_result_annotated_numeric_payload_stays_generic() // per-call cost. The invariant is unchanged: the raw-f64 clone is only // reachable through the entry guard, with the generic body as fallback. assert!( - entry.contains("call i32 @js_typed_f64_arg_guard(") + entry.contains(", 32761") && !entry.contains("call i32 @js_param_type_guard(") && entry.contains(&format!("@{symbol}$generic(")), "the raw-f64 clone must be reachable only through the entry guard, with the generic body as fallback:\n{entry}" @@ -10591,8 +10600,8 @@ fn typed_f64_function_clone_emits_internal_clone_and_guarded_call() { ir.contains(&format!("define internal double @{generic_body}")), "{ir}" ); - assert!(ir.contains("call i32 @js_typed_f64_arg_guard"), "{ir}"); - assert!(ir.contains("call double @js_typed_f64_arg_to_raw"), "{ir}"); + assert!(ir.contains(", 32761"), "{ir}"); + assert!(ir.contains("sitofp i32 %"), "{ir}"); assert!(ir.contains(&format!("call double @{typed}")), "{ir}"); assert!( ir.contains(&format!("call double @{generic_body}(")), @@ -10616,8 +10625,7 @@ fn typed_f64_public_trampoline_dispatches_before_generic_body() { "typed function should keep a separate generic body:\n{ir}" ); assert!( - wrapper_ir.contains("call i32 @js_typed_f64_arg_guard") - && wrapper_ir.contains("call double @js_typed_f64_arg_to_raw"), + wrapper_ir.contains(", 32761") && wrapper_ir.contains("sitofp i32 %"), "public wrapper should guard and unbox numeric JSValue args:\n{wrapper_ir}" ); let typed_call = wrapper_ir @@ -10842,7 +10850,7 @@ fn typed_f64_function_clone_accepts_mixed_raw_signature_and_direct_call() { "typed clone body should avoid JSValue traffic on the hot path:\n{typed_ir}" ); assert!( - wrapper_ir.contains("call i32 @js_typed_f64_arg_guard") + wrapper_ir.contains(", 32761") && wrapper_ir.contains("call i32 @js_typed_i32_arg_guard") && wrapper_ir.contains("call i32 @js_typed_i1_arg_guard") && wrapper_ir.contains(&format!("call double @{typed}(double %")) @@ -11334,8 +11342,8 @@ fn typed_i1_numeric_predicate_function_uses_f64_params_and_public_wrapper() { "numeric predicate body should stay in native f64/i1 SSA:\n{typed_ir}" ); assert!( - wrapper_ir.contains("call i32 @js_typed_f64_arg_guard") - && wrapper_ir.contains("call double @js_typed_f64_arg_to_raw") + wrapper_ir.contains(", 32761") + && wrapper_ir.contains("sitofp i32 %") && wrapper_ir.contains(&format!("call i1 @{typed}(double ")), "public wrapper should guard/unbox f64 args before the i1 clone:\n{wrapper_ir}" ); @@ -11344,8 +11352,8 @@ fn typed_i1_numeric_predicate_function_uses_f64_params_and_public_wrapper() { "public wrapper should retain a generic JSValue fallback:\n{wrapper_ir}" ); assert!( - caller_ir.contains("call i32 @js_typed_f64_arg_guard") - && caller_ir.contains("call double @js_typed_f64_arg_to_raw") + caller_ir.contains(", 32761") + && caller_ir.contains("sitofp i32 %") && caller_ir.contains(&format!("call i1 @{typed}(double ")), "direct FuncRef lowering should use the mixed-signature typed-i1 clone after f64 guards:\n{caller_ir}" ); @@ -12116,15 +12124,15 @@ fn typed_i1_numeric_predicate_method_uses_f64_params_and_guarded_direct_call() { "numeric predicate method body should stay in native f64/i1 SSA:\n{typed_ir}" ); assert!( - wrapper_ir.contains("call i32 @js_typed_f64_arg_guard") - && wrapper_ir.contains("call double @js_typed_f64_arg_to_raw") + wrapper_ir.contains(", 32761") + && wrapper_ir.contains("sitofp i32 %") && wrapper_ir.contains(&format!("call i1 @{typed}(double ")), "public method wrapper should guard/unbox f64 args before the i1 clone:\n{wrapper_ir}" ); assert!( contains_inline_direct_method_shape_guard(caller_ir) - && caller_ir.contains("call i32 @js_typed_f64_arg_guard") - && caller_ir.contains("call double @js_typed_f64_arg_to_raw") + && caller_ir.contains(", 32761") + && caller_ir.contains("sitofp i32 %") && caller_ir.contains(&format!("call i1 @{typed}(double ")), "exact direct method call should use the mixed-signature typed-i1 clone after f64 guards:\n{caller_ir}" ); @@ -12238,8 +12246,8 @@ fn typed_f64_method_clone_emits_internal_clone_and_guarded_direct_call() { "generic method ABI body must remain emitted separately:\n{ir}" ); assert!(contains_inline_direct_method_shape_guard(&ir), "{ir}"); - assert!(ir.contains("call i32 @js_typed_f64_arg_guard"), "{ir}"); - assert!(ir.contains("call double @js_typed_f64_arg_to_raw"), "{ir}"); + assert!(ir.contains(", 32761"), "{ir}"); + assert!(ir.contains("sitofp i32 %"), "{ir}"); assert!( ir.contains(&format!("call double @{typed}(double ")), "typed direct call should target the clone:\n{ir}" @@ -12275,8 +12283,7 @@ fn typed_f64_method_public_trampoline_dispatches_before_generic_body() { let wrapper_ir = function_ir_section(&ir, public); assert!( - wrapper_ir.contains("call i32 @js_typed_f64_arg_guard") - && wrapper_ir.contains("call double @js_typed_f64_arg_to_raw"), + wrapper_ir.contains(", 32761") && wrapper_ir.contains("sitofp i32 %"), "public method wrapper should guard and unbox numeric JSValue args:\n{wrapper_ir}" ); let typed_call = wrapper_ir @@ -12601,8 +12608,7 @@ fn typed_f64_closure_clone_emits_internal_clone_and_guarded_direct_call() { "{ir}" ); assert!( - wrapper_ir.contains("call i32 @js_typed_f64_arg_guard") - && wrapper_ir.contains("call double @js_typed_f64_arg_to_raw"), + wrapper_ir.contains(", 32761") && wrapper_ir.contains("sitofp i32 %"), "public closure wrapper should guard and unbox numeric JSValue args:\n{wrapper_ir}" ); assert!( @@ -12669,13 +12675,13 @@ fn typed_f64_closure_clone_accepts_immutable_numeric_capture() { ); assert!( wrapper_ir.contains("call i64 @js_closure_get_capture_bits(i64 %this_closure, i32 0)") - && wrapper_ir.contains("call i32 @js_typed_f64_arg_guard"), + && wrapper_ir.contains(", 32761"), "public typed-f64 wrapper must validate capture bits before entering the raw clone:\n{wrapper_ir}" ); assert!( caller_ir.contains("closure_direct.typed_f64") && caller_ir.contains("call i64 @js_closure_get_capture_bits") - && caller_ir.contains("call i32 @js_typed_f64_arg_guard") + && caller_ir.contains(", 32761") && caller_ir.contains(&format!("call double @{generic_body}(i64 ")), "direct typed-f64 calls must guard captures and retain their generic branch:\n{caller_ir}" ); @@ -13016,8 +13022,8 @@ fn typed_i1_numeric_predicate_closure_uses_f64_params_and_guarded_direct_call() "numeric-predicate typed closure clone should use f64 params and i1 return:\n{ir}" ); assert!( - wrapper_ir.contains("call i32 @js_typed_f64_arg_guard") - && wrapper_ir.contains("call double @js_typed_f64_arg_to_raw") + wrapper_ir.contains(", 32761") + && wrapper_ir.contains("sitofp i32 %") && wrapper_ir.contains(&format!("call i1 @{typed}(i64 %this_closure")), "public closure wrapper should guard/unbox numeric JSValue args and call the typed clone:\n{wrapper_ir}" ); @@ -13027,8 +13033,8 @@ fn typed_i1_numeric_predicate_closure_uses_f64_params_and_guarded_direct_call() ); assert!( ir.contains(&format!("call i1 @{typed}(i64 ")) - && ir.contains("call i32 @js_typed_f64_arg_guard") - && ir.contains("call double @js_typed_f64_arg_to_raw"), + && ir.contains(", 32761") + && ir.contains("sitofp i32 %"), "direct local closure call should guard/unbox numeric args and call the typed clone:\n{ir}" ); assert!( @@ -13777,8 +13783,8 @@ fn scalar_method_boolean_predicate_guards_public_numeric_arguments() { assert!( ir.contains("scalar_method_arg_guard.fast") && ir.contains("scalar_method_arg_guard.fallback") - && ir.contains("call i32 @js_typed_f64_arg_guard") - && ir.contains("call double @js_typed_f64_arg_to_raw"), + && ir.contains(", 32761") + && ir.contains("sitofp i32 %"), "{case} public numeric arg should guard/unbox before scalar inline:\n{ir}" ); assert!( @@ -13859,8 +13865,8 @@ fn scalar_method_boolean_predicate_guards_public_numeric_argument_expressions() assert!( ir.contains("scalar_method_arg_guard.fast") && ir.contains("scalar_method_arg_guard.fallback") - && ir.matches("call i32 @js_typed_f64_arg_guard").count() >= 2 - && ir.matches("call double @js_typed_f64_arg_to_raw").count() >= 2 + && ir.matches(", 32761").count() >= 2 + && ir.matches("sitofp i32 %").count() >= 2 && ir.contains("fmul double") && ir.contains("fadd double"), "public numeric arg expression should guard/unbox locals and rebuild arithmetic as raw f64 before scalar inline:\n{ir}"