From 9f084579e899ca9944a3d463aafff8b896278f46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 07:36:52 +0200 Subject: [PATCH 1/3] perf(codegen): arr.some(capturelessArrow) runs as an inline loop with a direct body call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit js_array_some_captureless decided the receiver once and then, per element, re-resolved the head from its root, NaN-boxed the receiver and called the body through the function pointer. The lowering now makes the same one-time decision on the same live bits — GC_TYPE_ARRAY head, not forwarded, no indexed descriptors, the sticky PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED byte clear, length <= capacity — and runs the element loop inline: the head is re-read from its root every iteration (a forwarded head goes through the new js_array_live_head export), indices past the live length and holes are skipped, the arrow's body symbol is called directly with as many of (element, index, receiver) as it declares, and true/false results decide inline with js_is_truthy for anything else. Every receiver the loop does not admit takes the runtime helper, which stays the fallback. Claude-Session: https://claude.ai/code/session_01FUvFrRNZyc5qknBiJbYbby --- .../src/expr/array_callback_shape_tests.rs | 13 + .../src/expr/logical_collections.rs | 245 +++++++++++++++++- crates/perry-codegen/src/gc_call_effects.rs | 3 + crates/perry-codegen/src/root_reload.rs | 1 + .../perry-codegen/src/runtime_decls/arrays.rs | 3 + .../src/array/indexing_support.rs | 10 + 6 files changed, 268 insertions(+), 7 deletions(-) diff --git a/crates/perry-codegen/src/expr/array_callback_shape_tests.rs b/crates/perry-codegen/src/expr/array_callback_shape_tests.rs index 5418bcbfc5..ceeddf6776 100644 --- a/crates/perry-codegen/src/expr/array_callback_shape_tests.rs +++ b/crates/perry-codegen/src/expr/array_callback_shape_tests.rs @@ -214,6 +214,19 @@ fn captureless_inline_some_passes_the_callback_body_directly() { && ir.contains("ptr @perry_closure_array_some_captureless_ts__99"), "a captureless inline arrow should pass its body symbol directly:\n{ir}" ); + // The admitted receiver runs the loop inline: the arrow's body is a direct + // call (a null closure, then as many of element/index/receiver as it + // declares — one here), a hole skips, a `true` result exits without a + // truthiness call, and the runtime helper above is only the fallback. + assert!( + ir.contains("some.inline.loop") + && ir.contains( + "call double @perry_closure_array_some_captureless_ts__99(i64 0, double " + ) + && ir.contains("call i64 @js_array_live_head(") + && ir.contains("call i32 @js_is_truthy("), + "the captureless some loop should run inline with the direct body call:\n{ir}" + ); assert!( !ir.contains("call i64 @js_closure_alloc_singleton") && !ir.contains("call double @js_array_some("), diff --git a/crates/perry-codegen/src/expr/logical_collections.rs b/crates/perry-codegen/src/expr/logical_collections.rs index f90f795286..a5df90236d 100644 --- a/crates/perry-codegen/src/expr/logical_collections.rs +++ b/crates/perry-codegen/src/expr/logical_collections.rs @@ -76,6 +76,240 @@ fn is_static_number_key_map(ctx: &FnCtx<'_>, map: &Expr) -> bool { /// cannot be observed by `Array.prototype.some` and whose body cannot inspect /// a closure environment. The runtime may then invoke the code pointer /// directly without allocating/looking up a singleton ClosureHeader. +/// `arr.some(capturelessArrow)` as an inline loop, with `js_array_some_captureless` +/// as the fallback for every receiver the loop does not admit. +/// +/// The runtime helper decides the receiver ONCE — a plain `GC_TYPE_ARRAY` +/// head, no indexed descriptors, pristine `Array.prototype` / +/// `Object.prototype` index state, `length <= capacity` — and then runs the +/// element loop with one rooted re-resolution per element, a NaN-boxed +/// receiver per call and an indirect call through the function pointer. The +/// loop emitted here makes the same one-time decision on the same live bits +/// (the sticky `PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED` byte is the +/// prototype half), then per element: re-reads the head from its root (the +/// callback may have collected, or grown the array — a forwarded head goes +/// through `js_array_live_head`), skips indices past the live length and +/// holes, calls the arrow's body symbol directly with as many of +/// `(element, index, receiver)` as it declares, and decides `true` / `false` +/// results inline with `js_is_truthy` for anything else. Same contract as +/// the helper: the bound is the length at entry, holes are skipped, an +/// exotic or non-array receiver takes the helper. +fn lower_captureless_some_inline( + ctx: &mut FnCtx<'_>, + array: &Expr, + callback_func: &str, + param_count: usize, +) -> Result { + use crate::nanbox::{POINTER_TAG_TOP16_I64, TAG_HOLE_I64}; + use crate::types::{I1, I16, I8}; + const TAG_TRUE_I64: &str = "9222246136947933188"; // 0x7FFC_0000_0000_0004 + const TAG_FALSE_I64: &str = "9222246136947933187"; // 0x7FFC_0000_0000_0003 + rooting::with_rooted_group(ctx, 1, |ctx, group| { + let arr_idx = group.lower(ctx, array, true)?; + let arr_box0 = group.reread(ctx, arr_idx)?; + let admit_idx = ctx.new_block("some.inline.admit"); + let loop_idx = ctx.new_block("some.inline.loop"); + let body_idx = ctx.new_block("some.inline.body"); + let resolve_idx = ctx.new_block("some.inline.resolve"); + let live_idx = ctx.new_block("some.inline.live"); + let elem_idx = ctx.new_block("some.inline.elem"); + let call_idx = ctx.new_block("some.inline.call"); + let slow_idx = ctx.new_block("some.inline.slow"); + let truthy_idx = ctx.new_block("some.inline.truthy"); + let next_idx = ctx.new_block("some.inline.next"); + let found_idx = ctx.new_block("some.inline.found"); + let fallback_idx = ctx.new_block("some.inline.fallback"); + let merge_idx = ctx.new_block("some.inline.merge"); + let admit_l = ctx.block_label(admit_idx); + let loop_l = ctx.block_label(loop_idx); + let body_l = ctx.block_label(body_idx); + let resolve_l = ctx.block_label(resolve_idx); + let live_l = ctx.block_label(live_idx); + let elem_l = ctx.block_label(elem_idx); + let call_l = ctx.block_label(call_idx); + let slow_l = ctx.block_label(slow_idx); + let truthy_l = ctx.block_label(truthy_idx); + let next_l = ctx.block_label(next_idx); + let found_l = ctx.block_label(found_idx); + let fallback_l = ctx.block_label(fallback_idx); + let merge_l = ctx.block_label(merge_idx); + let counter = ctx.func.alloca_entry(I32); + + // A heap pointer, before any header is read. + { + let blk = ctx.block(); + let bits = blk.bitcast_double_to_i64(&arr_box0); + let top16 = blk.lshr(I64, &bits, "48"); + let is_pointer = blk.icmp_eq(I64, &top16, POINTER_TAG_TOP16_I64); + blk.cond_br(&is_pointer, &admit_l, &fallback_l); + } + // Admission: the helper's one-time decision, on the live bits. + ctx.current_block = admit_idx; + let len0 = { + let blk = ctx.block(); + let raw = unbox_to_i64(blk, &arr_box0); + let type_addr = blk.sub(I64, &raw, "8"); + let type_ptr = blk.inttoptr(I64, &type_addr); + let obj_type = blk.load(I8, &type_ptr); + let is_array = blk.icmp_eq(I8, &obj_type, "1"); // GC_TYPE_ARRAY + let flags_addr = blk.sub(I64, &raw, "7"); + let flags_ptr = blk.inttoptr(I64, &flags_addr); + let gc_flags = blk.load(I8, &flags_ptr); + let forwarded = blk.and(I8, &gc_flags, "128"); // GC_FLAG_FORWARDED + let not_forwarded = blk.icmp_eq(I8, &forwarded, "0"); + let reserved_addr = blk.sub(I64, &raw, "6"); + let reserved_ptr = blk.inttoptr(I64, &reserved_addr); + let reserved = blk.load(I16, &reserved_ptr); + let descriptors = blk.and(I16, &reserved, "1024"); // OBJ_FLAG_ARRAY_DESCRIPTORS + let no_descriptors = blk.icmp_eq(I16, &descriptors, "0"); + let invalidated = blk.load_volatile(I8, "@PERRY_ARRAY_INDEX_FAST_PATH_INVALIDATED"); + let prototype_clean = blk.icmp_eq(I8, &invalidated, "0"); + let len_ptr = blk.inttoptr(I64, &raw); + let length = blk.load(I32, &len_ptr); + let cap_addr = blk.add(I64, &raw, "4"); + let cap_ptr = blk.inttoptr(I64, &cap_addr); + let capacity = blk.load(I32, &cap_ptr); + let dense = blk.icmp_ule(I32, &length, &capacity); + let a = blk.and(I1, &is_array, ¬_forwarded); + let b = blk.and(I1, &a, &no_descriptors); + let c = blk.and(I1, &b, &prototype_clean); + let admitted = blk.and(I1, &c, &dense); + blk.store(I32, "0", &counter); + blk.cond_br(&admitted, &loop_l, &fallback_l); + length + }; + // loop: i < len0 ? (the bound is the length at entry) + ctx.current_block = loop_idx; + let false_box = { + let blk = ctx.block(); + let i = blk.load(I32, &counter); + let more = blk.icmp_ult(I32, &i, &len0); + // The merge phi's operands are materialised in the predecessors: + // a phi must lead its block. + let false_box = blk.bitcast_i64_to_double(TAG_FALSE_I64); + blk.cond_br(&more, &body_l, &merge_l); + false_box + }; + // body: re-read the head from its root; a forwarded head resolves. + ctx.current_block = body_idx; + let arr_box = group.reread(ctx, arr_idx)?; + let raw_reread = { + let blk = ctx.block(); + let raw = unbox_to_i64(blk, &arr_box); + let flags_addr = blk.sub(I64, &raw, "7"); + let flags_ptr = blk.inttoptr(I64, &flags_addr); + let gc_flags = blk.load(I8, &flags_ptr); + let forwarded = blk.and(I8, &gc_flags, "128"); + let is_forwarded = blk.icmp_ne(I8, &forwarded, "0"); + blk.cond_br(&is_forwarded, &resolve_l, &live_l); + raw + }; + ctx.current_block = resolve_idx; + let resolved = ctx + .block() + .call(I64, "js_array_live_head", &[(I64, &raw_reread)]); + ctx.block().br(&live_l); + // live: bounds against the live length, then the element. + ctx.current_block = live_idx; + let raw = ctx + .block() + .phi(I64, &[(&raw_reread, &body_l), (&resolved, &resolve_l)]); + let i = { + let blk = ctx.block(); + let i = blk.load(I32, &counter); + let len_ptr = blk.inttoptr(I64, &raw); + let live_len = blk.load(I32, &len_ptr); + let in_range = blk.icmp_ult(I32, &i, &live_len); + blk.cond_br(&in_range, &elem_l, &next_l); + i + }; + ctx.current_block = elem_idx; + let elem_bits = { + let blk = ctx.block(); + let i64_i = blk.zext(I32, &i, I64); + let byte_offset = blk.shl(I64, &i64_i, "3"); + let with_header = blk.add(I64, &byte_offset, "8"); + let elem_addr = blk.add(I64, &raw, &with_header); + let elem_ptr = blk.inttoptr(I64, &elem_addr); + let bits = blk.load(I64, &elem_ptr); + let is_hole = blk.icmp_eq(I64, &bits, TAG_HOLE_I64); + blk.cond_br(&is_hole, &next_l, &call_l); + bits + }; + ctx.current_block = call_idx; + let result = { + let blk = ctx.block(); + let elem = blk.bitcast_i64_to_double(&elem_bits); + let i_double = blk.uitofp(I32, &i, DOUBLE); + let recv = nanbox_pointer_inline(blk, &raw); + let mut args: Vec<(crate::types::LlvmType, &str)> = + vec![(I64, "0"), (DOUBLE, elem.as_str())]; + if param_count >= 2 { + args.push((DOUBLE, i_double.as_str())); + } + if param_count >= 3 { + args.push((DOUBLE, recv.as_str())); + } + let result = blk.call(DOUBLE, callback_func.trim_start_matches('@'), &args); + let bits = blk.bitcast_double_to_i64(&result); + let is_true = blk.icmp_eq(I64, &bits, TAG_TRUE_I64); + blk.cond_br(&is_true, &found_l, &slow_l); + result + }; + ctx.current_block = slow_idx; + { + let blk = ctx.block(); + let bits = blk.bitcast_double_to_i64(&result); + let is_false = blk.icmp_eq(I64, &bits, TAG_FALSE_I64); + blk.cond_br(&is_false, &next_l, &truthy_l); + } + ctx.current_block = truthy_idx; + { + let blk = ctx.block(); + let truthy = blk.call(I32, "js_is_truthy", &[(DOUBLE, &result)]); + let nonzero = blk.icmp_ne(I32, &truthy, "0"); + blk.cond_br(&nonzero, &found_l, &next_l); + } + ctx.current_block = next_idx; + { + let blk = ctx.block(); + let i = blk.load(I32, &counter); + let inc = blk.add(I32, &i, "1"); + blk.store(I32, &inc, &counter); + blk.br(&loop_l); + } + ctx.current_block = found_idx; + let true_box = { + let blk = ctx.block(); + let true_box = blk.bitcast_i64_to_double(TAG_TRUE_I64); + blk.br(&merge_l); + true_box + }; + ctx.current_block = fallback_idx; + let fallback_value = { + let blk = ctx.block(); + let arr_handle = unbox_to_i64(blk, &arr_box0); + let value = blk.call( + DOUBLE, + "js_array_some_captureless", + &[(I64, &arr_handle), (PTR, callback_func)], + ); + blk.br(&merge_l); + value + }; + ctx.current_block = merge_idx; + let blk = ctx.block(); + Ok(blk.phi( + DOUBLE, + &[ + (&false_box, &loop_l), + (&true_box, &found_l), + (&fallback_value, &fallback_l), + ], + )) + }) +} + fn captureless_some_callback(ctx: &FnCtx<'_>, callback: &Expr) -> Option { let Expr::Closure { func_id, @@ -346,13 +580,10 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // so we forward it directly without conversion. Expr::ArraySome { array, callback } => { if let Some(callback_func) = captureless_some_callback(ctx, callback) { - let arr_box = lower_expr(ctx, array)?; - let arr_handle = unbox_to_i64(ctx.block(), &arr_box); - return Ok(ctx.block().call( - DOUBLE, - "js_array_some_captureless", - &[(I64, &arr_handle), (PTR, &callback_func)], - )); + let Expr::Closure { params, .. } = callback.as_ref() else { + unreachable!("captureless_some_callback matched a closure"); + }; + return lower_captureless_some_inline(ctx, array, &callback_func, params.len()); } // #7615 slice 2: same callback window as `ArrayFilter` above. rooting::with_operands_rooted(ctx, &[array, callback], |ctx, vals| { diff --git a/crates/perry-codegen/src/gc_call_effects.rs b/crates/perry-codegen/src/gc_call_effects.rs index 6a2c9cb18d..b843c72ea7 100644 --- a/crates/perry-codegen/src/gc_call_effects.rs +++ b/crates/perry-codegen/src/gc_call_effects.rs @@ -157,6 +157,9 @@ pub(crate) fn classify_direct_callee(name: &str) -> GcCallEffect { // side-table remove `layout_init_pointer_free` already does on every // allocation. No Perry allocation, no re-entry into generated code. | "js_array_declare_all_pointer_elements" + // `clean_arr_ptr` on a raw head: reads headers and the forwarding + // registry, allocates nothing, never re-enters generated code. + | "js_array_live_head" // TLS dynamic-call context only. #8596 adds the `_get` reader — a bare // `IMPLICIT_THIS.with(|c| f64::from_bits(c.get()))` (`object/this_binding.rs`), // the exact shape of the already-admitted `_set` and `js_new_target_get`. diff --git a/crates/perry-codegen/src/root_reload.rs b/crates/perry-codegen/src/root_reload.rs index ad322ff5c2..decefcfda1 100644 --- a/crates/perry-codegen/src/root_reload.rs +++ b/crates/perry-codegen/src/root_reload.rs @@ -234,6 +234,7 @@ const NON_COLLECTING: &[&str] = &[ "js_tdz_suppress_end", "js_array_note_numeric_write", "js_array_declare_all_pointer_elements", + "js_array_live_head", "js_array_length", "js_object_mark_class", "js_class_object_pin_parent", diff --git a/crates/perry-codegen/src/runtime_decls/arrays.rs b/crates/perry-codegen/src/runtime_decls/arrays.rs index 7323b6adfc..5b2f96d739 100644 --- a/crates/perry-codegen/src/runtime_decls/arrays.rs +++ b/crates/perry-codegen/src/runtime_decls/arrays.rs @@ -173,6 +173,9 @@ pub fn declare_phase_b_arrays(module: &mut LlModule) { // js_gc_init_typed_shape_layout(obj: u64, slot_count: u32, raw_f64_mask_words: *const u64, raw_f64_mask_word_count: u32, pointer_mask_words: *const u64, pointer_mask_word_count: u32) module.declare_function("js_write_barrier", VOID, &[I64, I64]); module.declare_function("js_write_barrier_slot", VOID, &[I64, I64, I64]); + // perry-runtime: `array::indexing_support::js_array_live_head` — resolves a + // forwarded array head a generated loop re-read from its root. + module.declare_function("js_array_live_head", I64, &[I64]); module.declare_function( "js_write_barrier_slot_validated_parent", VOID, diff --git a/crates/perry-runtime/src/array/indexing_support.rs b/crates/perry-runtime/src/array/indexing_support.rs index df59ef5ac3..0458146f56 100644 --- a/crates/perry-runtime/src/array/indexing_support.rs +++ b/crates/perry-runtime/src/array/indexing_support.rs @@ -6,6 +6,16 @@ use super::*; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +/// Resolve a raw array head a generated loop re-read from its root after a +/// callback returned: the callback may have grown the array, leaving the root +/// on a forwarding stub. Pure `clean_arr_ptr`; null for anything that is not +/// an array. Generated `some` loops call this only when the re-read head's +/// header carries `GC_FLAG_FORWARDED`. +#[no_mangle] +pub extern "C" fn js_array_live_head(arr: i64) -> i64 { + clean_arr_ptr(arr as *const ArrayHeader) as i64 +} + /// A strict-mode element write (`arr[i] = v`) to a **frozen** array's existing /// index is `[[Set]]` on a non-writable data property with `Throw = true` /// (ECMA-262 §10.4.2.4 → OrdinarySetWithOwnDescriptor step 2.b.i), so it must From 9b34554588b6cdb02e0c7b2b23d3681cb7618f63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 09:59:55 +0200 Subject: [PATCH 2/3] perf(driver): fold module-level const literals into their reads after the transform phase, before codegen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `export const COMPONENT_ID_MAX = 1023` is a module-scope immutable let, and every read of it is a LocalGet the typed-ABI clone rules cannot type: a one-line predicate such as isComponentId (`id >= 1 && id <= COMPONENT_ID_MAX`) was refused its i1 clone (ReturnExprNotTypedI1Safe), so every call ran the boxed body — a global load and the full dynamic tag-coercion compare on both operands — instead of a guard and two fcmps. The fold puts the literal in place of the read. It is deliberately not a pipeline pass. Folded, those predicates become self-contained and the cross-module inliner harvests them; run inside the pipeline that consumed callers' inline budgets (world.set lost resolveSetOperation, −43%) and with a larger budget the inlined bodies still did the dynamic compare on the untyped call-site value. The driver runs it once every module has been transformed — harvests already taken from the unfolded bodies — so no inlining decision moves; only what codegen sees does. It precedes the HIR trace and the object-cache fingerprint, so both describe the tree codegen consumes. Claude-Session: https://claude.ai/code/session_01FUvFrRNZyc5qknBiJbYbby --- .../src/closure_local_inline.rs | 2 +- crates/perry-transform/src/lib.rs | 1 + .../perry-transform/src/module_const_fold.rs | 388 ++++++++++++++++++ .../src/commands/compile/run_pipeline.rs | 11 + 4 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 crates/perry-transform/src/module_const_fold.rs diff --git a/crates/perry-transform/src/closure_local_inline.rs b/crates/perry-transform/src/closure_local_inline.rs index 47ef3a8bb9..51611dab92 100644 --- a/crates/perry-transform/src/closure_local_inline.rs +++ b/crates/perry-transform/src/closure_local_inline.rs @@ -529,7 +529,7 @@ fn for_each_expr_in_stmt(stmt: &Stmt, f: &mut dyn FnMut(&Expr)) { } } -fn for_each_expr_in_stmt_mut(stmt: &mut Stmt, f: &mut dyn FnMut(&mut Expr)) { +pub(crate) fn for_each_expr_in_stmt_mut(stmt: &mut Stmt, f: &mut dyn FnMut(&mut Expr)) { match stmt { Stmt::Let { init, .. } => { if let Some(e) = init { diff --git a/crates/perry-transform/src/lib.rs b/crates/perry-transform/src/lib.rs index 1fb590cc25..26cdb1dddb 100644 --- a/crates/perry-transform/src/lib.rs +++ b/crates/perry-transform/src/lib.rs @@ -16,6 +16,7 @@ pub mod finally_inline; pub mod generator; pub mod i18n; pub mod inline; +pub mod module_const_fold; pub mod prop_cse; mod source_spans; pub mod state_desugar; diff --git a/crates/perry-transform/src/module_const_fold.rs b/crates/perry-transform/src/module_const_fold.rs new file mode 100644 index 0000000000..3e22229d6a --- /dev/null +++ b/crates/perry-transform/src/module_const_fold.rs @@ -0,0 +1,388 @@ +//! Fold reads of module-level `const` literals into the literal — run by the +//! driver on every module AFTER the whole transform phase, immediately before +//! code generation. +//! +//! `export const COMPONENT_ID_MAX = 1023;` lowers to a `Stmt::Let { mutable: +//! false, init: Some(Integer(1023)) }` in `module.init`, and every function in +//! the module reads it as a plain `LocalGet` of that module-scope id. Such a +//! read is opaque to the typed-ABI clone rules: `isComponentId(id)`, whose +//! body is `id >= 1 && id <= COMPONENT_ID_MAX`, was refused its `i1` clone +//! (`ReturnExprNotTypedI1Safe`), so every call ran the boxed body — a module +//! global load and the full dynamic tag-coercion compare on both operands — +//! instead of a guard and two `fcmp`s. With the literal in place of the read, +//! the body is straight-line typed and the clone is admitted. +//! +//! Why this is NOT a pipeline pass: the cross-module inliner admits only +//! bodies whose locals are their own, and the folded predicates qualify. Run +//! inside the pipeline, the fold made them harvestable, they consumed the +//! caller's inline budget, and a hot `world.set` lost `resolveSetOperation` +//! (a 43% regression); with a larger budget the inlined bodies still did the +//! dynamic compare on the untyped call-site value, a wash. Run after every +//! module has been transformed (harvests already taken from the unfolded +//! bodies), the fold changes no inlining decision and only what codegen sees. +//! +//! Admission: a top-level `module.init` `let` that is immutable, whose +//! initializer is a number, integer, string or boolean literal, and that no +//! `LocalSet` / `Update` in the module writes (a `const` cannot be, but the +//! scan is cheap and keeps the pass honest against synthesized bindings). +//! Reads are folded in every function, method, accessor and constructor +//! body, in closure bodies (the id is then dropped from the closure's capture +//! lists — the value it would have captured is the literal), and in +//! `module.init` statements AFTER the declaration. Reads before the +//! declaration are left alone: they are in the temporal dead zone and must +//! keep throwing. +use std::collections::{HashMap, HashSet}; + +use perry_hir::types::LocalId; +use perry_hir::walker::walk_expr_children_mut; +use perry_hir::{Expr, Function, Module, Stmt}; + +use crate::closure_local_inline::{for_each_expr_in_stmt_mut, nested_stmt_lists}; + +pub fn run(module: &mut Module) { + let mut consts: HashMap = HashMap::new(); + let mut decl_index: HashMap = HashMap::new(); + for (index, stmt) in module.init.iter().enumerate() { + if let Stmt::Let { + id, + mutable: false, + init: Some(init), + .. + } = stmt + { + if is_foldable_literal(init) { + consts.insert(*id, init.clone()); + decl_index.insert(*id, index); + } + } + } + if consts.is_empty() { + return; + } + // Anything written anywhere in the module is not a constant. + let mut written: HashSet = HashSet::new(); + for_each_function(module, &mut |f| { + collect_written_in_stmts(&f.body, &mut written) + }); + collect_written_in_stmts(&module.init, &mut written); + for id in written { + consts.remove(&id); + decl_index.remove(&id); + } + if consts.is_empty() { + return; + } + for_each_function(module, &mut |f| fold_stmts(&mut f.body, &consts)); + // `module.init`: only statements after each declaration. + for (index, stmt) in module.init.iter_mut().enumerate() { + let visible: HashMap = consts + .iter() + .filter(|(id, _)| decl_index.get(*id).is_some_and(|d| *d < index)) + .map(|(id, lit)| (*id, lit.clone())) + .collect(); + if visible.is_empty() { + continue; + } + fold_stmt(stmt, &visible); + } +} + +fn is_foldable_literal(expr: &Expr) -> bool { + matches!( + expr, + Expr::Integer(_) | Expr::Number(_) | Expr::String(_) | Expr::Bool(_) + ) +} + +fn for_each_function(module: &mut Module, f: &mut dyn FnMut(&mut Function)) { + for function in &mut module.functions { + f(function); + } + for class in &mut module.classes { + if let Some(ctor) = &mut class.constructor { + f(ctor); + } + for m in class + .methods + .iter_mut() + .chain(class.static_methods.iter_mut()) + { + f(m); + } + for (_, g) in &mut class.getters { + f(g); + } + for (_, s) in &mut class.setters { + f(s); + } + for cm in &mut class.computed_members { + f(&mut cm.function); + } + } +} + +fn collect_written_in_stmts(stmts: &[Stmt], written: &mut HashSet) { + fn visit(expr: &Expr, written: &mut HashSet) { + match expr { + Expr::LocalSet(id, _) | Expr::Update { id, .. } => { + written.insert(*id); + } + _ => {} + } + perry_hir::walker::walk_expr_children(expr, &mut |child| visit(child, written)); + } + for stmt in stmts { + walk_stmt_exprs(stmt, &mut |expr| visit(expr, written)); + } +} + +fn walk_stmt_exprs(stmt: &Stmt, f: &mut dyn FnMut(&Expr)) { + match stmt { + Stmt::Let { init: Some(e), .. } + | Stmt::Expr(e) + | Stmt::Throw(e) + | Stmt::Return(Some(e)) => f(e), + Stmt::If { + condition, + then_branch, + else_branch, + } => { + f(condition); + for s in then_branch { + walk_stmt_exprs(s, f); + } + if let Some(e) = else_branch { + for s in e { + walk_stmt_exprs(s, f); + } + } + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + f(condition); + for s in body { + walk_stmt_exprs(s, f); + } + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(s) = init { + walk_stmt_exprs(s, f); + } + if let Some(e) = condition { + f(e); + } + if let Some(e) = update { + f(e); + } + for s in body { + walk_stmt_exprs(s, f); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + f(discriminant); + for case in cases { + if let Some(t) = &case.test { + f(t); + } + for s in &case.body { + walk_stmt_exprs(s, f); + } + } + } + Stmt::Try { + body, + catch, + finally, + } => { + for s in body { + walk_stmt_exprs(s, f); + } + if let Some(c) = catch { + for s in &c.body { + walk_stmt_exprs(s, f); + } + } + if let Some(fin) = finally { + for s in fin { + walk_stmt_exprs(s, f); + } + } + } + Stmt::Labeled { body, .. } => walk_stmt_exprs(body, f), + _ => {} + } +} + +fn fold_stmts(stmts: &mut Vec, consts: &HashMap) { + for stmt in stmts.iter_mut() { + fold_stmt(stmt, consts); + } +} + +fn fold_stmt(stmt: &mut Stmt, consts: &HashMap) { + for inner in nested_stmt_lists(stmt) { + fold_stmts(inner, consts); + } + for_each_expr_in_stmt_mut(stmt, &mut |e| fold_expr(e, consts)); +} + +fn fold_expr(expr: &mut Expr, consts: &HashMap) { + if let Expr::LocalGet(id) = expr { + if let Some(lit) = consts.get(id) { + *expr = lit.clone(); + return; + } + } + if let Expr::Closure { + body, + captures, + mutable_captures, + .. + } = expr + { + captures.retain(|id| !consts.contains_key(id)); + mutable_captures.retain(|id| !consts.contains_key(id)); + fold_stmts(body, consts); + } + walk_expr_children_mut(expr, &mut |child| fold_expr(child, consts)); +} + +#[cfg(test)] +mod tests { + use super::*; + use perry_hir::types::Type; + use perry_hir::{CompareOp, Param}; + + fn func(id: u32, body: Vec) -> Function { + Function { + id, + name: format!("f{id}"), + type_params: Vec::new(), + params: vec![Param { + id: 8, + name: "x".to_string(), + ty: Type::Number, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }], + return_type: Type::Boolean, + body, + is_async: false, + is_generator: false, + is_strict: true, + is_exported: true, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + } + } + + fn le_const(const_id: u32) -> Stmt { + Stmt::Return(Some(Expr::Compare { + op: CompareOp::Le, + left: Box::new(Expr::LocalGet(8)), + right: Box::new(Expr::LocalGet(const_id)), + })) + } + + fn module_with_const(mutable: bool, init: Expr) -> Module { + let mut m = Module::new("types.ts"); + m.init.push(Stmt::Let { + id: 3, + name: "COMPONENT_ID_MAX".to_string(), + ty: Type::Number, + mutable, + init: Some(init), + }); + m.functions.push(func(1, vec![le_const(3)])); + m + } + + #[test] + fn an_immutable_literal_module_binding_folds_into_its_readers() { + let mut m = module_with_const(false, Expr::Integer(1023)); + run(&mut m); + assert!( + matches!( + &m.functions[0].body[0], + Stmt::Return(Some(Expr::Compare { right, .. })) + if matches!(right.as_ref(), Expr::Integer(1023)) + ), + "{:?}", + m.functions[0].body[0] + ); + } + + #[test] + fn a_mutable_binding_or_a_non_literal_initializer_is_left_alone() { + let mut m = module_with_const(true, Expr::Integer(1023)); + run(&mut m); + assert!(matches!( + &m.functions[0].body[0], + Stmt::Return(Some(Expr::Compare { right, .. })) if matches!(right.as_ref(), Expr::LocalGet(3)) + )); + let mut m = module_with_const( + false, + Expr::Binary { + op: perry_hir::BinaryOp::Mul, + left: Box::new(Expr::Integer(2)), + right: Box::new(Expr::Integer(3)), + }, + ); + run(&mut m); + assert!(matches!( + &m.functions[0].body[0], + Stmt::Return(Some(Expr::Compare { right, .. })) if matches!(right.as_ref(), Expr::LocalGet(3)) + )); + } + + #[test] + fn a_read_before_the_declaration_in_init_keeps_its_tdz_and_a_later_one_folds() { + let mut m = module_with_const(false, Expr::Integer(7)); + m.init.insert(0, Stmt::Expr(Expr::LocalGet(3))); + m.init.push(Stmt::Expr(Expr::LocalGet(3))); + run(&mut m); + assert!(matches!(&m.init[0], Stmt::Expr(Expr::LocalGet(3)))); + assert!(matches!(&m.init[2], Stmt::Expr(Expr::Integer(7)))); + } + + #[test] + fn a_closure_reading_the_constant_drops_it_from_its_captures() { + let mut m = module_with_const(false, Expr::Integer(5)); + m.functions[0].body = vec![Stmt::Return(Some(Expr::Closure { + func_id: 77, + params: Vec::new(), + return_type: Type::Boolean, + body: vec![le_const(3)], + captures: vec![3], + mutable_captures: Vec::new(), + captures_this: false, + captures_new_target: false, + enclosing_class: None, + is_arrow: true, + is_async: false, + is_generator: false, + is_strict: true, + }))]; + run(&mut m); + let Stmt::Return(Some(Expr::Closure { body, captures, .. })) = &m.functions[0].body[0] + else { + panic!("closure expected"); + }; + assert!(captures.is_empty(), "{captures:?}"); + assert!(matches!( + &body[0], + Stmt::Return(Some(Expr::Compare { right, .. })) if matches!(right.as_ref(), Expr::Integer(5)) + )); + } +} diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index 562cdb9fcc..5b358566a3 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -924,6 +924,17 @@ pub fn run_with_parse_cache( let i18n_table = apply_i18n_pass(&mut ctx, i18n_config.as_ref(), &i18n_translations, format); + // Module-level const literals fold into their reads only now, after the + // whole transform phase: every cross-module harvest has been taken from + // the unfolded bodies, so no inlining decision moves, while the typed-ABI + // clone rules and the lowering see the literal (a one-line predicate + // comparing against an `export const` earns its typed clone). This runs + // before the HIR trace and before the object-cache fingerprint, so both + // describe exactly what codegen consumes. + for hir_module in ctx.native_modules.values_mut() { + perry_transform::module_const_fold::run(hir_module); + } + if trace_hir { dump_hir_for_debug(&ctx, args.focus.as_deref()); } From 820bc1103cf88a3bffb504ca703b818f5f74e0c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 28 Aug 2026 11:15:34 +0200 Subject: [PATCH 3/3] changelog: fragment for #8933 Claude-Session: https://claude.ai/code/session_01FUvFrRNZyc5qknBiJbYbby --- changelog.d/8933-inline-some-const-fold.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelog.d/8933-inline-some-const-fold.md diff --git a/changelog.d/8933-inline-some-const-fold.md b/changelog.d/8933-inline-some-const-fold.md new file mode 100644 index 0000000000..ea3051430f --- /dev/null +++ b/changelog.d/8933-inline-some-const-fold.md @@ -0,0 +1,2 @@ +- **codegen:** `arr.some(capturelessArrow)` runs as an inline loop with a direct call of the arrow's body (rooted receiver re-read per element, forwarded heads through the new `js_array_live_head`, holes skipped, `true`/`false` decided inline); `js_array_some_captureless` stays the fallback for receivers the loop does not admit. +- **driver:** module-level `const` literals (`export const MAX = 1023`) fold into their reads after the whole transform phase and before codegen, so one-line predicates comparing against them earn their typed clones without changing any cross-module inlining decision. `codehz/ecs` "5k entities: 3 commands each + sync": +3.7% and +1.5% (15/15 paired runs each).