From 304c63de8b2fc5dceecbf1a9ca878798fd20fb4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 29 Aug 2026 23:02:22 +0200 Subject: [PATCH 1/5] =?UTF-8?q?wip:=20call=20devirt=20v2=20=E2=80=94=20pro?= =?UTF-8?q?be(fixed=20magic)=20+=20single-binding=20seeding=20+=20TDZ-safe?= =?UTF-8?q?=20guard-free?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/perry-codegen/src/codegen/artifacts.rs | 27 +++++ crates/perry-codegen/src/codegen/closure.rs | 36 ++++++ .../src/codegen/closure_collect.rs | 104 +++++++++++++++++ crates/perry-codegen/src/codegen/entry.rs | 2 + crates/perry-codegen/src/codegen/function.rs | 1 + crates/perry-codegen/src/codegen/helpers.rs | 6 + crates/perry-codegen/src/codegen/method.rs | 2 + crates/perry-codegen/src/collectors/mod.rs | 2 +- crates/perry-codegen/src/expr/mod.rs | 4 + .../src/lower_call/early_branches.rs | 106 +++++++++++++++--- crates/perry-codegen/src/target_layout.rs | 8 ++ 11 files changed, 283 insertions(+), 15 deletions(-) diff --git a/crates/perry-codegen/src/codegen/artifacts.rs b/crates/perry-codegen/src/codegen/artifacts.rs index 7930ad8ae2..b76ca5841f 100644 --- a/crates/perry-codegen/src/codegen/artifacts.rs +++ b/crates/perry-codegen/src/codegen/artifacts.rs @@ -96,6 +96,30 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { }; let module_reassigned_locals = crate::collectors::reassigned_locals_in_module(hir); + // #9071 follow-up: module-wide GUARD-FREE closure bindings. #7170 R1's + // `single_binding_closure_locals` is the strong fact: exactly one `Let`, + // never written at any depth in any body, never rebound by a parameter or + // catch clause — "a call through this binding names that closure" with + // `Expr::FuncRef` strength, which is why the function-body pipeline + // already devirtualizes (and LLVM then folds) these calls. Closure bodies + // get the same treatment through this map: seeded into the known-func_id + // path, and the identity guard skipped outright. + let closure_param_counts: std::collections::HashMap = closures + .iter() + .filter_map(|(func_id, expr)| match expr { + perry_hir::Expr::Closure { params, .. } => Some((*func_id, params.len())), + _ => None, + }) + .collect(); + let immutable_closure_bindings: std::collections::HashMap = + crate::collectors::spec_abi_sites::single_binding_closure_locals(hir) + .into_iter() + .filter_map(|(id, func_id)| { + closure_param_counts + .get(&func_id) + .map(|count| (id, (func_id, *count))) + }) + .collect(); progress.checkpoint("reassigned-local analysis"); let closure_started = Instant::now(); @@ -160,6 +184,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { module_boxed_vars, module_receiver_types, &module_reassigned_locals, + &immutable_closure_bindings, closure_rest_params, cross_module, false, @@ -186,6 +211,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { module_boxed_vars, module_receiver_types, &module_reassigned_locals, + &immutable_closure_bindings, closure_rest_params, cross_module, true, @@ -213,6 +239,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { module_boxed_vars, module_receiver_types, &module_reassigned_locals, + &immutable_closure_bindings, closure_rest_params, cross_module, true, diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index d5a143be12..d0c1c5d0c5 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -497,6 +497,9 @@ pub(super) fn compile_closure( // inherit module-wide receiver types, so their invalidation scope must be // module-wide too. module_reassigned_locals: &HashSet, + // Module-wide `immutable binding -> (closure func_id, param count)` facts; + // already filtered by the reassignment oracle at the collection site. + immutable_closure_bindings: &HashMap, closure_rest_params: &HashMap, cross_module: &CrossModuleCtx, trusted_box_captures: bool, @@ -1126,6 +1129,7 @@ pub(super) fn compile_closure( .compiler_private_async_i1_control_locals, closure_rest_params, local_closure_func_ids: HashMap::new(), + guard_free_closure_bindings: std::collections::HashSet::new(), local_closure_param_counts: HashMap::new(), resolved_arrow_callback_targets: HashMap::new(), resolved_versioned_loop_callback_targets: HashMap::new(), @@ -1313,6 +1317,38 @@ pub(super) fn compile_closure( // live parameter of every closure body, so capture-slot reads are direct. // Skipped for async bodies: entry SSA values do not survive the CPS // rewrite. + // #9071 follow-up: a captured or module-global binding that provably holds + // one specific same-module closure gets the body-local known-func_id + // treatment — the guarded direct path with compile-time typed-clone + // selection and a STATIC fast call — exactly as if its `Let` were in this + // body. Entry resolution below skips these ids: static beats indirect. + for id in ctx + .closure_captures + .keys() + .chain(ctx.module_globals.keys()) + .copied() + .collect::>() + { + if let Some((func_id, param_count)) = immutable_closure_bindings.get(&id) { + ctx.local_closure_func_ids.entry(id).or_insert(*func_id); + ctx.local_closure_param_counts + .entry(id) + .or_insert(*param_count); + // The single-binding fact holds module-wide, so the identity + // guard is unnecessary at these call sites — for CAPTURED + // bindings. A capture of a single-binding closure is boxed by + // construction when it can be read before its `Let` runs, and the + // boxed read throws the TDZ error before the dispatch arm is + // reached. A MODULE GLOBAL has no such protection: code running + // during module init can call through the binding while the cell + // still holds the TDZ sentinel, so globals keep the inline + // identity probe (whose magic check fails on the sentinel and + // falls back to the full dispatcher's correct error path). + if ctx.closure_captures.contains_key(&id) { + ctx.guard_free_closure_bindings.insert(id); + } + } + } if !is_async { let param_ids: std::collections::HashSet = params.iter().map(|p| p.id).collect(); super::helpers::emit_callee_binding_resolutions( diff --git a/crates/perry-codegen/src/codegen/closure_collect.rs b/crates/perry-codegen/src/codegen/closure_collect.rs index ec6a84fa4c..b7b636f48e 100644 --- a/crates/perry-codegen/src/codegen/closure_collect.rs +++ b/crates/perry-codegen/src/codegen/closure_collect.rs @@ -825,3 +825,107 @@ pub(crate) fn collect_module_closures(hir: &HirModule) -> ModuleClosures { closure_arrow_functions, } } + +/// Module-wide `immutable binding -> closure func_id` facts, for statically +/// devirtualizing calls THROUGH those bindings in other bodies. +/// +/// A `let f = ` with `mutable: false` pins the binding's +/// value identity for the whole program once the module-wide reassignment +/// oracle clears it (the caller intersects with `reassigned_locals_in_module`). +/// A body that captures `f`, or reads it as a module global, can then treat a +/// call `f(...)` exactly as `let_stmt.rs` treats a body-local closure Let: +/// the known-func_id guarded direct path, with its compile-time typed-clone +/// selection and STATIC (inlinable) fast call. Walks the same scope set as +/// `collect_module_local_types`: module init, function bodies, and class +/// constructors/methods/getters/setters. Nested closure bodies are not +/// entered — a capture chain through two frames still resolves at the outer +/// walk when the Let is in one of these scopes. +pub(crate) fn collect_immutable_closure_bindings( + hir: &HirModule, +) -> std::collections::HashMap { + fn scan_stmts( + stmts: &[perry_hir::Stmt], + out: &mut std::collections::HashMap, + ) { + for stmt in stmts { + match stmt { + perry_hir::Stmt::Let { + id, + mutable: false, + init: + Some(perry_hir::Expr::Closure { + func_id, + params, + is_async: false, + is_generator: false, + .. + }), + .. + } => { + out.insert(*id, (*func_id, params.len())); + } + perry_hir::Stmt::If { + then_branch, + else_branch, + .. + } => { + scan_stmts(then_branch, out); + if let Some(body) = else_branch { + scan_stmts(body, out); + } + } + perry_hir::Stmt::While { body, .. } | perry_hir::Stmt::DoWhile { body, .. } => { + scan_stmts(body, out) + } + perry_hir::Stmt::For { init, body, .. } => { + if let Some(init) = init { + scan_stmts(std::slice::from_ref(init), out); + } + scan_stmts(body, out); + } + perry_hir::Stmt::Try { + body, + catch, + finally, + } => { + scan_stmts(body, out); + if let Some(catch) = catch { + scan_stmts(&catch.body, out); + } + if let Some(body) = finally { + scan_stmts(body, out); + } + } + perry_hir::Stmt::Switch { cases, .. } => { + for case in cases { + scan_stmts(&case.body, out); + } + } + perry_hir::Stmt::Labeled { body, .. } => { + scan_stmts(std::slice::from_ref(body), out) + } + _ => {} + } + } + } + let mut out = std::collections::HashMap::new(); + scan_stmts(&hir.init, &mut out); + for f in &hir.functions { + scan_stmts(&f.body, &mut out); + } + for c in &hir.classes { + for m in &c.methods { + scan_stmts(&m.body, &mut out); + } + if let Some(ctor) = &c.constructor { + scan_stmts(&ctor.body, &mut out); + } + for (_, g) in &c.getters { + scan_stmts(&g.body, &mut out); + } + for (_, s) in &c.setters { + scan_stmts(&s.body, &mut out); + } + } + out +} diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index bf38be77b3..445ccf6a97 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -819,6 +819,7 @@ pub(super) fn compile_module_entry( .compiler_private_async_i1_control_locals, closure_rest_params, local_closure_func_ids: HashMap::new(), + guard_free_closure_bindings: std::collections::HashSet::new(), local_closure_param_counts: HashMap::new(), resolved_arrow_callback_targets: HashMap::new(), resolved_versioned_loop_callback_targets: HashMap::new(), @@ -1536,6 +1537,7 @@ pub(super) fn compile_module_entry( .compiler_private_async_i1_control_locals, closure_rest_params, local_closure_func_ids: HashMap::new(), + guard_free_closure_bindings: std::collections::HashSet::new(), local_closure_param_counts: HashMap::new(), resolved_arrow_callback_targets: HashMap::new(), resolved_versioned_loop_callback_targets: HashMap::new(), diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index d114dcce0e..a3e49f2a5d 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -1081,6 +1081,7 @@ pub(super) fn compile_function( .compiler_private_async_i1_control_locals, closure_rest_params, local_closure_func_ids: HashMap::new(), + guard_free_closure_bindings: std::collections::HashSet::new(), local_closure_param_counts: HashMap::new(), resolved_arrow_callback_targets: HashMap::new(), resolved_versioned_loop_callback_targets: HashMap::new(), diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index f61618e916..fb81bd1a9b 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -1761,6 +1761,12 @@ pub(super) fn emit_callee_binding_resolutions( { continue; } + // A statically-known callee takes the known-func_id guarded direct + // path — a static, inlinable call — which beats the entry-resolved + // indirect call this map would install. + if ctx.local_closure_func_ids.contains_key(&id) { + continue; + } if !matches!( ctx.local_type_hint(&id), Some(perry_hir::types::Type::Function(function)) diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index 927a0cd68b..50a0332b6b 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -487,6 +487,7 @@ pub(super) fn compile_method( .compiler_private_async_i1_control_locals, closure_rest_params, local_closure_func_ids: HashMap::new(), + guard_free_closure_bindings: std::collections::HashSet::new(), local_closure_param_counts: HashMap::new(), resolved_arrow_callback_targets: HashMap::new(), resolved_versioned_loop_callback_targets: HashMap::new(), @@ -1653,6 +1654,7 @@ pub(super) fn compile_static_method( .compiler_private_async_i1_control_locals, closure_rest_params, local_closure_func_ids: HashMap::new(), + guard_free_closure_bindings: std::collections::HashSet::new(), local_closure_param_counts: HashMap::new(), resolved_arrow_callback_targets: HashMap::new(), resolved_versioned_loop_callback_targets: HashMap::new(), diff --git a/crates/perry-codegen/src/collectors/mod.rs b/crates/perry-codegen/src/collectors/mod.rs index 93f371693e..b481e5d360 100644 --- a/crates/perry-codegen/src/collectors/mod.rs +++ b/crates/perry-codegen/src/collectors/mod.rs @@ -49,7 +49,7 @@ mod safepoint_sites; mod scalar_method_dispatch; mod scalar_methods; mod shadow_slots; -mod spec_abi_sites; +pub(crate) mod spec_abi_sites; mod this_as_value; mod uppercase_strings; diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 1b99f7b3f7..e4c6d0da24 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -615,6 +615,10 @@ pub(crate) struct FnCtx<'a> { /// Used by the closure call site in `lower_call` to look up the /// callee's rest param info from `closure_rest_params`. pub local_closure_func_ids: std::collections::HashMap, + /// Bindings whose closure identity holds with `FuncRef` strength (#7170 + /// R1's single-binding fact: one `Let`, never written anywhere, never + /// rebound). A call through one of these needs NO runtime identity guard. + pub guard_free_closure_bindings: std::collections::HashSet, /// LocalId → closure declared parameter count. Paired with /// `local_closure_func_ids` for guarded direct closure calls: direct /// calls only fire when the static arity exactly matches the call site. diff --git a/crates/perry-codegen/src/lower_call/early_branches.rs b/crates/perry-codegen/src/lower_call/early_branches.rs index 98d1559f1e..089e05d700 100644 --- a/crates/perry-codegen/src/lower_call/early_branches.rs +++ b/crates/perry-codegen/src/lower_call/early_branches.rs @@ -585,26 +585,104 @@ pub fn try_lower_closure_typed_local_call( }; let expected_arity = declared_count.to_string(); let call_arity = lowered_args.len().to_string(); - let guard_ok = ctx.block().call( - I32, - "js_typed_feedback_closure_direct_call_guard", - &[ - (I64, &site_id), - (DOUBLE, &recv_box), - (crate::types::PTR, &format!("@{}", closure_fn)), - (I32, &expected_arity), - (I32, &call_arity), - ], - ); - let guard_pass = ctx.block().icmp_ne(I32, &guard_ok, "0"); let fast_idx = ctx.new_block("closure_direct.fast"); let fallback_idx = ctx.new_block("closure_direct.fallback"); let merge_idx = ctx.new_block("closure_direct.merge"); let fast_label = ctx.block_label(fast_idx); let fallback_label = ctx.block_label(fallback_idx); let merge_label = ctx.block_label(merge_idx); - ctx.block() - .cond_br(&guard_pass, &fast_label, &fallback_label); + // Normal builds do not collect feedback (the same + // dispensation `expr/index_get/guarded_array.rs` documents + // for the array-read guard): decide the monomorphic case + // with an inline identity probe and keep the out-of-line + // guard — which records the observation — for the miss. + // Everything else the guard validates is already a + // compile-time fact at THIS site: `declared_count` and + // `has_rest` were checked against the known func_id above, + // and `expected_arity == call_arity` by the enclosing + // `declared_count == lowered_args.len()` gate. The only + // dynamic question is "is the value still the closure + // whose body is `@closure_fn`", and two compare-only loads + // answer it: `type_tag == CLOSURE_MAGIC` at the header's + // tag slot and `func_ptr == @closure_fn` at word 0. A + // forwarded (moved) closure fails the func-ptr compare — + // its word 0 holds the forwarding target — and takes the + // guard, which resolves forwarding as it always did. A + // non-closure heap object would need BOTH its tag word to + // spell "CLOS" AND its first word to equal this exact code + // address to slip through; the runtime's volatile-ordering + // ceremony guards a transmute-and-call of an ARBITRARY + // func_ptr, which this compare-only probe never does. + // #7170 R1 single-binding fact: identity holds with + // FuncRef strength, so the runtime guard AND the probe + // are both unnecessary — the value cannot be anything but + // this closure. Branch straight into the fast arm; the + // fallback stays only as the shared merge structure. + let guard_free = ctx.guard_free_closure_bindings.contains(id); + if guard_free { + ctx.block().br(&fast_label); + } else if !crate::expr::typed_feedback_emission_enabled() { + let guard_call_idx = ctx.new_block("closure_direct.guard_call"); + let probe_idx = ctx.new_block("closure_direct.inline_probe"); + let guard_call_label = ctx.block_label(guard_call_idx); + let probe_label = ctx.block_label(probe_idx); + { + let blk = ctx.block(); + let bits = blk.bitcast_double_to_i64(&recv_box); + let top16 = blk.lshr(I64, &bits, "48"); + let is_pointer = + blk.icmp_eq(I64, &top16, crate::nanbox::POINTER_TAG_TOP16_I64); + let handle = blk.and(I64, &bits, crate::nanbox::POINTER_MASK_I64); + // Above the small-handle id band: a real closure is + // a GC allocation, and the band's ids are unmapped + // low addresses the probe must never dereference. + let above_band = blk.icmp_ugt(I64, &handle, "1048575"); + let plausible = blk.and(I1, &is_pointer, &above_band); + blk.cond_br(&plausible, &probe_label, &guard_call_label); + } + ctx.current_block = probe_idx; + { + let tag_offset = crate::target_layout::closure_type_tag_offset_bytes( + ctx.target_triple, + ) + .to_string(); + let blk = ctx.block(); + let bits = blk.bitcast_double_to_i64(&recv_box); + let handle = blk.and(I64, &bits, crate::nanbox::POINTER_MASK_I64); + let tag_addr = blk.add(I64, &handle, &tag_offset); + let tag_ptr = blk.inttoptr(I64, &tag_addr); + let tag = blk.load(I32, &tag_ptr); + // CLOSURE_MAGIC — "CLOS" (0x434C4F53). Derived, not + // hand-typed: a transposed hand conversion of this + // constant made the probe miss on every call and + // cost three rounds of wrong conclusions. + const CLOSURE_MAGIC_I32: u32 = 0x434C_4F53; + let magic_ok = blk.icmp_eq(I32, &tag, &CLOSURE_MAGIC_I32.to_string()); + let fp_ptr = blk.inttoptr(I64, &handle); + let fp = blk.load(I64, &fp_ptr); + let expected_fp = blk.ptrtoint(&format!("@{}", closure_fn), I64); + let fp_ok = blk.icmp_eq(I64, &fp, &expected_fp); + let hit = blk.and(I1, &magic_ok, &fp_ok); + blk.cond_br(&hit, &fast_label, &guard_call_label); + } + ctx.current_block = guard_call_idx; + } + if !guard_free { + let guard_ok = ctx.block().call( + I32, + "js_typed_feedback_closure_direct_call_guard", + &[ + (I64, &site_id), + (DOUBLE, &recv_box), + (crate::types::PTR, &format!("@{}", closure_fn)), + (I32, &expected_arity), + (I32, &call_arity), + ], + ); + let guard_pass = ctx.block().icmp_ne(I32, &guard_ok, "0"); + ctx.block() + .cond_br(&guard_pass, &fast_label, &fallback_label); + } ctx.current_block = fast_idx; let typed_f64_param_reps = if ctx.typed_f64_closures.contains(&func_id) { diff --git a/crates/perry-codegen/src/target_layout.rs b/crates/perry-codegen/src/target_layout.rs index 38b2e397fa..cd9921b3f5 100644 --- a/crates/perry-codegen/src/target_layout.rs +++ b/crates/perry-codegen/src/target_layout.rs @@ -109,6 +109,14 @@ pub fn object_meta_slot_offset_bytes(target_triple: &str) -> u64 { /// capture pointers directly from their immutable capture slots. Keep the /// target derivation here: using the compiler host's pointer width would make /// cross-compiled arm64_32 watchOS closures read four bytes past the slot. +/// Byte offset of `ClosureHeader::type_tag` (the `CLOSURE_MAGIC` slot) for +/// the target: the header's last 4 bytes (`func_ptr` + `capture_count` +/// precede it), i.e. 12 on LP64 and 8 on ILP32 — the codegen mirror of the +/// runtime's `offset_of!`-derived `CLOSURE_TYPE_TAG_OFFSET`. +pub fn closure_type_tag_offset_bytes(target_triple: &str) -> u64 { + closure_header_size_bytes(target_triple) - 4 +} + pub fn closure_header_size_bytes(target_triple: &str) -> u64 { if target_is_ilp32(target_triple) { 12 From 572d184205ab4769d7e75a0d23b8386aebdbfc6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 29 Aug 2026 23:11:09 +0200 Subject: [PATCH 2/5] wip: keep trusted-box closures on the entry-resolved path --- crates/perry-codegen/src/codegen/artifacts.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/perry-codegen/src/codegen/artifacts.rs b/crates/perry-codegen/src/codegen/artifacts.rs index b76ca5841f..dea378f627 100644 --- a/crates/perry-codegen/src/codegen/artifacts.rs +++ b/crates/perry-codegen/src/codegen/artifacts.rs @@ -114,6 +114,14 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { let immutable_closure_bindings: std::collections::HashMap = crate::collectors::spec_abi_sites::single_binding_closure_locals(hir) .into_iter() + // A closure with a trusted-box clone is better served by the + // ENTRY-RESOLVED path: `js_closure_resolve_arrow_direct_call` + // hands back the trusted clone with its entry-cached box-capture + // pointers, which beats the known arm's public/typed call for + // capturing bodies (measured: 2.5 vs 5.1 ns). Seeding such an id + // would also make the resolution emitter skip it, robbing the + // call of the faster path. + .filter(|(_, func_id)| !trusted_box_closures.contains_key(func_id)) .filter_map(|(id, func_id)| { closure_param_counts .get(&func_id) From bdc4b160cb373fe21c65e818144788e0ff985c40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 30 Aug 2026 00:04:14 +0200 Subject: [PATCH 3/5] fix: restore collateral files the cross-base patch port reverted (#9086/#9092/#9093 era) --- crates/perry-codegen/src/codegen/artifacts.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/perry-codegen/src/codegen/artifacts.rs b/crates/perry-codegen/src/codegen/artifacts.rs index dea378f627..94684a5b86 100644 --- a/crates/perry-codegen/src/codegen/artifacts.rs +++ b/crates/perry-codegen/src/codegen/artifacts.rs @@ -111,6 +111,12 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { _ => None, }) .collect(); + // `PERRY_CALL_DEVIRT=0`/`off`/`false` empties the map, restoring the + // entry-resolved indirect path for every binding (A/B bisection). + let call_devirt_enabled = !matches!( + std::env::var("PERRY_CALL_DEVIRT").as_deref(), + Ok("0") | Ok("off") | Ok("false") + ); let immutable_closure_bindings: std::collections::HashMap = crate::collectors::spec_abi_sites::single_binding_closure_locals(hir) .into_iter() @@ -121,6 +127,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { // capturing bodies (measured: 2.5 vs 5.1 ns). Seeding such an id // would also make the resolution emitter skip it, robbing the // call of the faster path. + .filter(|_| call_devirt_enabled) .filter(|(_, func_id)| !trusted_box_closures.contains_key(func_id)) .filter_map(|(id, func_id)| { closure_param_counts From 49110e1cb9095815ded731cd8a834d76366b03d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 30 Aug 2026 01:15:57 +0200 Subject: [PATCH 4/5] fix(perry): register PERRY_CALL_DEVIRT as a build-cache input codegen_env_vars_are_build_cache_inputs was red: the knob empties the devirtualization map, so the two settings emit different call sequences and a cached object from one must not serve the other. --- crates/perry/src/commands/compile/build_cache.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index 246a0d9506..4ea4da94a2 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -53,6 +53,10 @@ const BUILD_CACHE_ENV_VARS: &[&str] = &[ // body entry instead of per call — the two settings emit different call // sequences, so a cached object from one must not serve the other. "PERRY_CALLEE_BINDING_RESOLUTION", + // #9105: gates devirtualizing calls to single-binding closure locals — + // off, the map is empty and every binding takes the entry-resolved + // indirect path, so the two settings emit different call sequences. + "PERRY_CALL_DEVIRT", // #9060: gates whether a reduce accumulator earns the stable-packed fast // clone's numeric proof — with it on, `s += arr[i]` lowers to an inline // fadd instead of `js_dynamic_string_or_number_add`, so the two settings From aa360c8456ddc1d80a599d97093722777de8fc9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 30 Aug 2026 01:55:57 +0200 Subject: [PATCH 5/5] chore(codegen): drop the superseded v1 immutable-closure-binding collector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit collect_immutable_closure_bindings is unreferenced — the devirtualization this PR ships resolves bindings through spec_abi_sites::single_binding_closure_locals, threaded via artifacts.rs, which is the collector the PR description names. Under -D warnings the dead function fails the lint gate: error: function `collect_immutable_closure_bindings` is never used Removed rather than wired: v2 supersedes it. One revert restores it if the module-wide oracle it describes is still wanted. --- .../src/codegen/closure_collect.rs | 104 ------------------ 1 file changed, 104 deletions(-) diff --git a/crates/perry-codegen/src/codegen/closure_collect.rs b/crates/perry-codegen/src/codegen/closure_collect.rs index b7b636f48e..ec6a84fa4c 100644 --- a/crates/perry-codegen/src/codegen/closure_collect.rs +++ b/crates/perry-codegen/src/codegen/closure_collect.rs @@ -825,107 +825,3 @@ pub(crate) fn collect_module_closures(hir: &HirModule) -> ModuleClosures { closure_arrow_functions, } } - -/// Module-wide `immutable binding -> closure func_id` facts, for statically -/// devirtualizing calls THROUGH those bindings in other bodies. -/// -/// A `let f = ` with `mutable: false` pins the binding's -/// value identity for the whole program once the module-wide reassignment -/// oracle clears it (the caller intersects with `reassigned_locals_in_module`). -/// A body that captures `f`, or reads it as a module global, can then treat a -/// call `f(...)` exactly as `let_stmt.rs` treats a body-local closure Let: -/// the known-func_id guarded direct path, with its compile-time typed-clone -/// selection and STATIC (inlinable) fast call. Walks the same scope set as -/// `collect_module_local_types`: module init, function bodies, and class -/// constructors/methods/getters/setters. Nested closure bodies are not -/// entered — a capture chain through two frames still resolves at the outer -/// walk when the Let is in one of these scopes. -pub(crate) fn collect_immutable_closure_bindings( - hir: &HirModule, -) -> std::collections::HashMap { - fn scan_stmts( - stmts: &[perry_hir::Stmt], - out: &mut std::collections::HashMap, - ) { - for stmt in stmts { - match stmt { - perry_hir::Stmt::Let { - id, - mutable: false, - init: - Some(perry_hir::Expr::Closure { - func_id, - params, - is_async: false, - is_generator: false, - .. - }), - .. - } => { - out.insert(*id, (*func_id, params.len())); - } - perry_hir::Stmt::If { - then_branch, - else_branch, - .. - } => { - scan_stmts(then_branch, out); - if let Some(body) = else_branch { - scan_stmts(body, out); - } - } - perry_hir::Stmt::While { body, .. } | perry_hir::Stmt::DoWhile { body, .. } => { - scan_stmts(body, out) - } - perry_hir::Stmt::For { init, body, .. } => { - if let Some(init) = init { - scan_stmts(std::slice::from_ref(init), out); - } - scan_stmts(body, out); - } - perry_hir::Stmt::Try { - body, - catch, - finally, - } => { - scan_stmts(body, out); - if let Some(catch) = catch { - scan_stmts(&catch.body, out); - } - if let Some(body) = finally { - scan_stmts(body, out); - } - } - perry_hir::Stmt::Switch { cases, .. } => { - for case in cases { - scan_stmts(&case.body, out); - } - } - perry_hir::Stmt::Labeled { body, .. } => { - scan_stmts(std::slice::from_ref(body), out) - } - _ => {} - } - } - } - let mut out = std::collections::HashMap::new(); - scan_stmts(&hir.init, &mut out); - for f in &hir.functions { - scan_stmts(&f.body, &mut out); - } - for c in &hir.classes { - for m in &c.methods { - scan_stmts(&m.body, &mut out); - } - if let Some(ctor) = &c.constructor { - scan_stmts(&ctor.body, &mut out); - } - for (_, g) in &c.getters { - scan_stmts(&g.body, &mut out); - } - for (_, s) in &c.setters { - scan_stmts(&s.body, &mut out); - } - } - out -}