From 6b020f57c9b0bbf297d242b8ab96c6d794a044e5 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 24 Aug 2026 08:10:03 +0200 Subject: [PATCH 1/2] perf(transform): scalar-replace aggregate call literals --- .../fixtures/scalar_replacement_literals.ts | 25 + benchmarks/compiler_output/workloads.toml | 30 +- crates/perry-codegen/src/stmt/let_stmt.rs | 2 + .../perry-codegen/src/stmt/let_stmt_facts.rs | 34 + .../perry-transform/src/aggregate_scalar.rs | 884 ++++++++++++++++++ crates/perry-transform/src/inline/analysis.rs | 146 +++ .../src/inline/call_inliner.rs | 165 ++++ crates/perry-transform/src/inline/mod.rs | 141 ++- crates/perry-transform/src/lib.rs | 2 + ...gap_8691_scalar_replace_aggregate_calls.ts | 25 + 10 files changed, 1449 insertions(+), 5 deletions(-) create mode 100644 crates/perry-transform/src/aggregate_scalar.rs create mode 100644 test-files/test_gap_8691_scalar_replace_aggregate_calls.ts diff --git a/benchmarks/compiler_output/fixtures/scalar_replacement_literals.ts b/benchmarks/compiler_output/fixtures/scalar_replacement_literals.ts index 39f4849e43..8cf18106c9 100644 --- a/benchmarks/compiler_output/fixtures/scalar_replacement_literals.ts +++ b/benchmarks/compiler_output/fixtures/scalar_replacement_literals.ts @@ -10,4 +10,29 @@ function scalarReplacementChecksum(): number { return values[0] + values[1] + values[2] + values.length; } +class Position {} +class Velocity {} + +let aggregateChecksum = 0; + +function consumeAggregate(initializers: { component: unknown }[]): void { + for (let i = 0; i < initializers.length; i++) { + const initializer = initializers[i]; + if (initializer.component === Position) aggregateChecksum += 1; + if (initializer.component === Velocity) aggregateChecksum += 2; + } +} + +function scalarAggregateCallChecksum(): number { + aggregateChecksum = 0; + const iterations = 500_000; + for (let i = 0; i < iterations; i++) { + consumeAggregate([{ component: Position }, { component: Velocity }]); + consumeAggregate([{ component: Position }]); + consumeAggregate([{ component: Velocity }]); + } + return aggregateChecksum; +} + console.log(scalarReplacementChecksum()); +console.log(scalarAggregateCallChecksum()); diff --git a/benchmarks/compiler_output/workloads.toml b/benchmarks/compiler_output/workloads.toml index 7b194654f9..763813cc81 100644 --- a/benchmarks/compiler_output/workloads.toml +++ b/benchmarks/compiler_output/workloads.toml @@ -1349,17 +1349,29 @@ function_contains = "scalarReplacementChecksum" regex_none = ["@js_object_get_field", "@js_object_set_field", "@js_array_get", "@js_array_set"] detail = "scalar-replaced literals do not use runtime property or array access helpers" +[[workloads.scalar_replacement_literals.ir_checks]] +name = "known_aggregate_call_no_object_or_array_heap_alloc" +function_contains = "scalarAggregateCallChecksum" +regex_none = ["@js_object_alloc", "@js_object_alloc_with_shape", "@js_array_alloc"] +detail = "known fixed-aggregate consumers scalar-replace descriptor objects and carrier arrays" + +[[workloads.scalar_replacement_literals.ir_checks]] +name = "known_aggregate_call_no_property_or_array_runtime_access" +function_contains = "scalarAggregateCallChecksum" +regex_none = ["@js_object_get_field", "@js_object_set_field", "@js_array_get", "@js_array_set"] +detail = "known fixed-aggregate consumers read scalar fields without runtime aggregate helpers" + [[workloads.scalar_replacement_literals.stdout_checks]] name = "scalar_replacement_checksum" -equals = "17\n" +equals = "17\n3000000\n" detail = "scalar-replacement fixture stdout checksum" [workloads.scalar_replacement_literals.native_rep_checks] -function_contains = "scalarReplacementChecksum" allow_materialization_reasons = ["runtime_api"] [[workloads.scalar_replacement_literals.native_rep_checks.require_records]] name = "scalar_object_literal_store" +source_function = "scalarReplacementChecksum" expr_kind = "ScalarObjectLiteralInit" consumer = "scalar_object_field_store" native_rep_name = "js_value" @@ -1367,6 +1379,7 @@ access_mode = "none" [[workloads.scalar_replacement_literals.native_rep_checks.require_records]] name = "scalar_object_field_get" +source_function = "scalarReplacementChecksum" expr_kind = "ScalarObjectFieldGet" consumer = "scalar_object_field_load" native_rep_name = "js_value" @@ -1374,6 +1387,7 @@ access_mode = "none" [[workloads.scalar_replacement_literals.native_rep_checks.require_records]] name = "scalar_object_field_set" +source_function = "scalarReplacementChecksum" expr_kind = "ScalarObjectFieldSet" consumer = "scalar_object_field_store" native_rep_name = "js_value" @@ -1381,6 +1395,7 @@ access_mode = "none" [[workloads.scalar_replacement_literals.native_rep_checks.require_records]] name = "scalar_array_literal_store" +source_function = "scalarReplacementChecksum" expr_kind = "ScalarArrayLiteralInit" consumer = "scalar_array_element_store" native_rep_name = "js_value" @@ -1388,11 +1403,22 @@ access_mode = "none" [[workloads.scalar_replacement_literals.native_rep_checks.require_records]] name = "scalar_array_index_get" +source_function = "scalarReplacementChecksum" expr_kind = "ScalarArrayIndexGet" consumer = "scalar_array_element_load" native_rep_name = "js_value" access_mode = "none" +[[workloads.scalar_replacement_literals.native_rep_checks.require_records]] +name = "known_aggregate_call_scalar_fields" +source_function = "scalarAggregateCallChecksum" +expr_kind = "ScalarAggregateFieldInit" +consumer = "scalar_object_field_store" +native_rep_name = "js_value" +access_mode = "none" +notes_contains = "carrier_array=elided" +min = 4 + [workloads.width_aware_buffer_kernels] source = "benchmarks/compiler_output/fixtures/width_aware_buffer_kernels.ts" kind = "width_aware_buffer_kernels" diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index c0d8e4224a..706dde5c59 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -6,6 +6,7 @@ use super::let_buffer_views::{math_min_length_buffer_ids, register_noalias_buffe use super::let_stmt_facts::{ buffer_local_alias_source, collect_scalar_class_data, native_i32_alias_source, note_ptr_shape_scalar_replaced, pod_view_count_source, record_pod_rejection, + record_scalar_aggregate_field, }; use super::unused_expr::lower_unused_expr; use crate::expr::{ @@ -1901,6 +1902,7 @@ pub(crate) fn lower_let( } } } + record_scalar_aggregate_field(ctx, id, name, &v); v } else { String::new() // unused below; cleanup blocks check used_i32_init diff --git a/crates/perry-codegen/src/stmt/let_stmt_facts.rs b/crates/perry-codegen/src/stmt/let_stmt_facts.rs index 66100f6cc6..4494a1be44 100644 --- a/crates/perry-codegen/src/stmt/let_stmt_facts.rs +++ b/crates/perry-codegen/src/stmt/let_stmt_facts.rs @@ -7,6 +7,40 @@ use super::*; use crate::native_value::BufferAccessMode; +/// #8691: the aggregate scalar-replacement transform erases the carrier array +/// and object literals before codegen, leaving one synthetic local per field. +/// Preserve lowering evidence for those eliminated allocations so the result +/// remains visible to `--explain-lowering`. +pub(super) fn record_scalar_aggregate_field(ctx: &mut FnCtx<'_>, id: u32, name: &str, value: &str) { + if !name.starts_with("__perry_scalar_aggregate_") { + return; + } + let lowered = crate::native_value::LoweredValue { + semantic: crate::native_value::SemanticKind::JsValue, + rep: crate::native_value::NativeRep::JsValue, + llvm_ty: DOUBLE, + value: value.to_string(), + }; + ctx.record_lowered_value_with_access_mode( + "ScalarAggregateFieldInit", + Some(id), + "scalar_object_field_store", + &lowered, + None, + None, + None, + None, + false, + false, + vec![ + format!("local={name}"), + "carrier_array=elided".to_string(), + "carrier_object=elided".to_string(), + "write_barrier=0".to_string(), + ], + ); +} + pub(super) fn pod_view_count_source(ctx: &FnCtx<'_>, expr: &perry_hir::Expr) -> String { match expr { perry_hir::Expr::Integer(n) => format!("constant:{n}"), diff --git a/crates/perry-transform/src/aggregate_scalar.rs b/crates/perry-transform/src/aggregate_scalar.rs new file mode 100644 index 0000000000..afad665554 --- /dev/null +++ b/crates/perry-transform/src/aggregate_scalar.rs @@ -0,0 +1,884 @@ +//! Scalar replacement for fixed aggregate literals exposed by inlining. +//! +//! The ordinary codegen escape pass handles `const point = { x, y }` and +//! `const values = [x, y]`, but an ECS-style helper exposes a nested shape: +//! +//! ```text +//! const arg = [{ component: Position }, { component: Velocity }]; +//! const item = arg[0]; +//! read(item.component); +//! ``` +//! +//! Once a known helper has been inlined and its short loop unrolled, neither +//! carrier identity is observable. This pass replaces every object field with +//! a synthetic scalar local, rewrites the proven field reads, and removes the +//! carrier array and element aliases. Any identity, mutation, reflection, +//! closure capture, dynamic index, missing/inherited property, or method-call +//! receiver use rejects the whole candidate and leaves normal materialization +//! intact. + +use std::collections::{HashMap, HashSet}; + +use perry_hir::types::{LocalId, Type}; +use perry_hir::{Expr, Module, Stmt}; + +const MAX_SCALAR_AGGREGATE_LEN: usize = 8; +const MAX_SCALAR_AGGREGATE_FIELDS: usize = 16; + +type AnonShapeFields = HashMap>; + +pub fn run(module: &mut Module) { + let mut next_local_id = crate::generator::compute_max_local_id(module).saturating_add(1); + let mut source_span_remaps = Vec::new(); + // Closed-shape object literals are represented as `new __AnonShape_*` + // before transforms run. Constructor parameter names retain the literal's + // source field order, while the call arguments retain its value order. + let anon_shape_fields: AnonShapeFields = module + .classes + .iter() + .filter(|class| class.name.starts_with("__AnonShape_")) + .filter_map(|class| { + let constructor = class.constructor.as_ref()?; + (!constructor.params.is_empty()).then(|| { + ( + class.name.clone(), + constructor + .params + .iter() + .map(|param| param.name.clone()) + .collect(), + ) + }) + }) + .collect(); + + scalarize_stmts( + &mut module.init, + &mut next_local_id, + &mut source_span_remaps, + &anon_shape_fields, + ); + for function in &mut module.functions { + scalarize_stmts( + &mut function.body, + &mut next_local_id, + &mut source_span_remaps, + &anon_shape_fields, + ); + } + for class in &mut module.classes { + if let Some(constructor) = &mut class.constructor { + scalarize_stmts( + &mut constructor.body, + &mut next_local_id, + &mut source_span_remaps, + &anon_shape_fields, + ); + } + for method in &mut class.methods { + scalarize_stmts( + &mut method.body, + &mut next_local_id, + &mut source_span_remaps, + &anon_shape_fields, + ); + } + for (_, getter) in &mut class.getters { + scalarize_stmts( + &mut getter.body, + &mut next_local_id, + &mut source_span_remaps, + &anon_shape_fields, + ); + } + for (_, setter) in &mut class.setters { + scalarize_stmts( + &mut setter.body, + &mut next_local_id, + &mut source_span_remaps, + &anon_shape_fields, + ); + } + for method in &mut class.static_methods { + scalarize_stmts( + &mut method.body, + &mut next_local_id, + &mut source_span_remaps, + &anon_shape_fields, + ); + } + } + + for (source_id, new_id) in source_span_remaps { + if let Some(span) = module.local_source_spans.get(&source_id).copied() { + module.local_source_spans.insert(new_id, span); + } + } +} + +fn scalarize_stmts( + stmts: &mut Vec, + next_local_id: &mut LocalId, + source_span_remaps: &mut Vec<(LocalId, LocalId)>, + anon_shape_fields: &AnonShapeFields, +) { + let candidates: Vec = stmts + .iter() + .filter_map(|stmt| match stmt { + Stmt::Let { + id, + mutable: false, + init: Some(Expr::Array(elements)), + .. + } if aggregate_elements_are_plain(elements, anon_shape_fields) => Some(*id), + _ => None, + }) + .collect(); + + for array_id in candidates { + let _ = scalarize_candidate( + stmts, + array_id, + next_local_id, + source_span_remaps, + anon_shape_fields, + ); + } + + // A candidate created inside a branch/loop belongs to that nested lexical + // statement list, so process child lists after the current scope. + for stmt in stmts { + match stmt { + Stmt::If { + then_branch, + else_branch, + .. + } => { + scalarize_stmts( + then_branch, + next_local_id, + source_span_remaps, + anon_shape_fields, + ); + if let Some(else_branch) = else_branch { + scalarize_stmts( + else_branch, + next_local_id, + source_span_remaps, + anon_shape_fields, + ); + } + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } => { + scalarize_stmts(body, next_local_id, source_span_remaps, anon_shape_fields); + } + Stmt::For { init, body, .. } => { + if let Some(init) = init { + let mut init_vec = vec![(**init).clone()]; + scalarize_stmts( + &mut init_vec, + next_local_id, + source_span_remaps, + anon_shape_fields, + ); + if init_vec.len() == 1 { + **init = init_vec.remove(0); + } + } + scalarize_stmts(body, next_local_id, source_span_remaps, anon_shape_fields); + } + Stmt::Try { + body, + catch, + finally, + } => { + scalarize_stmts(body, next_local_id, source_span_remaps, anon_shape_fields); + if let Some(catch) = catch { + scalarize_stmts( + &mut catch.body, + next_local_id, + source_span_remaps, + anon_shape_fields, + ); + } + if let Some(finally) = finally { + scalarize_stmts( + finally, + next_local_id, + source_span_remaps, + anon_shape_fields, + ); + } + } + Stmt::Switch { cases, .. } => { + for case in cases { + scalarize_stmts( + &mut case.body, + next_local_id, + source_span_remaps, + anon_shape_fields, + ); + } + } + // A labeled body is one statement rather than a statement list. + // Aggregate-call inlining never creates this shape; keep it on the + // conservative materialized path instead of inventing a wrapper. + Stmt::Labeled { .. } + | Stmt::Let { .. } + | Stmt::Expr(_) + | Stmt::Return(_) + | Stmt::Throw(_) + | Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::PreallocateBoxes(_) + | Stmt::PreallocateTdzBoxes(_) + | Stmt::ReleaseBoxes(_) => {} + } + } +} + +fn element_properties( + element: &Expr, + anon_shape_fields: &AnonShapeFields, +) -> Option> { + match element { + Expr::Object(properties) => Some(properties.clone()), + Expr::New { + class_name, + args, + cap_args_appended: 0, + .. + } if class_name.starts_with("__AnonShape_") => { + let fields = anon_shape_fields.get(class_name)?; + (fields.len() == args.len()) + .then(|| fields.iter().cloned().zip(args.iter().cloned()).collect()) + } + _ => None, + } +} + +fn aggregate_elements_are_plain(elements: &[Expr], anon_shape_fields: &AnonShapeFields) -> bool { + !elements.is_empty() + && elements.len() <= MAX_SCALAR_AGGREGATE_LEN + && elements.iter().all(|element| { + element_properties(element, anon_shape_fields).is_some_and(|properties| { + !properties.is_empty() + && properties.len() <= MAX_SCALAR_AGGREGATE_FIELDS + && properties.iter().all(|(key, value)| { + key != "__proto__" + && !matches!( + value, + Expr::Closure { + captures_this: true, + .. + } + ) + }) + }) + }) +} + +fn const_index(expr: &Expr) -> Option { + match expr { + Expr::Integer(value) if *value >= 0 => usize::try_from(*value).ok(), + Expr::Number(value) + if value.is_finite() + && *value >= 0.0 + && value.fract() == 0.0 + && *value <= usize::MAX as f64 => + { + Some(*value as usize) + } + _ => None, + } +} + +fn scalarize_candidate( + stmts: &mut Vec, + array_id: LocalId, + next_local_id: &mut LocalId, + source_span_remaps: &mut Vec<(LocalId, LocalId)>, + anon_shape_fields: &AnonShapeFields, +) -> bool { + let Some(elements) = stmts.iter().find_map(|stmt| match stmt { + Stmt::Let { + id, + init: Some(Expr::Array(elements)), + .. + } if *id == array_id => Some(elements.clone()), + _ => None, + }) else { + return false; + }; + if !aggregate_elements_are_plain(&elements, anon_shape_fields) { + return false; + } + + let properties: Vec> = elements + .iter() + .map(|element| element_properties(element, anon_shape_fields)) + .collect::>() + .expect("aggregate_elements_are_plain checked the shape"); + + let keys: Vec> = properties + .iter() + .map(|properties| properties.iter().map(|(key, _)| key.clone()).collect()) + .collect(); + let mut aliases = HashMap::new(); + collect_aliases(stmts, array_id, elements.len(), &mut aliases); + if !stmts_are_safe(stmts, array_id, &aliases, &keys) { + return false; + } + + let mut scalar_lets = Vec::new(); + let mut fields: Vec> = Vec::with_capacity(elements.len()); + for (element_index, properties) in properties.into_iter().enumerate() { + let mut element_fields = HashMap::new(); + for (property_index, (key, value)) in properties.into_iter().enumerate() { + let id = *next_local_id; + *next_local_id = next_local_id.saturating_add(1); + source_span_remaps.push((array_id, id)); + scalar_lets.push(Stmt::Let { + id, + name: format!( + "__perry_scalar_aggregate_{array_id}_{element_index}_{property_index}" + ), + ty: Type::Any, + mutable: false, + init: Some(value), + }); + // Object literal duplicate keys are last-write-wins, while every + // value expression above is still evaluated in source order. + element_fields.insert(key, id); + } + fields.push(element_fields); + } + + rewrite_stmts(stmts, array_id, &aliases, &fields); + let Some(declaration_index) = stmts + .iter() + .position(|stmt| matches!(stmt, Stmt::Let { id, .. } if *id == array_id)) + else { + return false; + }; + stmts.splice(declaration_index..=declaration_index, scalar_lets); + true +} + +fn collect_aliases( + stmts: &[Stmt], + array_id: LocalId, + len: usize, + aliases: &mut HashMap, +) { + for stmt in stmts { + if let Stmt::Let { + id, + mutable: false, + init: Some(Expr::IndexGet { object, index }), + .. + } = stmt + { + if matches!(object.as_ref(), Expr::LocalGet(candidate) if *candidate == array_id) { + if let Some(index) = const_index(index).filter(|index| *index < len) { + aliases.insert(*id, index); + } + } + } + match stmt { + Stmt::If { + then_branch, + else_branch, + .. + } => { + collect_aliases(then_branch, array_id, len, aliases); + if let Some(else_branch) = else_branch { + collect_aliases(else_branch, array_id, len, aliases); + } + } + Stmt::While { body, .. } | Stmt::DoWhile { body, .. } | Stmt::For { body, .. } => { + collect_aliases(body, array_id, len, aliases); + } + Stmt::Try { + body, + catch, + finally, + } => { + collect_aliases(body, array_id, len, aliases); + if let Some(catch) = catch { + collect_aliases(&catch.body, array_id, len, aliases); + } + if let Some(finally) = finally { + collect_aliases(finally, array_id, len, aliases); + } + } + Stmt::Switch { cases, .. } => { + for case in cases { + collect_aliases(&case.body, array_id, len, aliases); + } + } + _ => {} + } + } +} + +fn candidate_field( + object: &Expr, + property: &str, + array_id: LocalId, + aliases: &HashMap, + fields: &[HashSet], +) -> Option { + let index = match object { + Expr::LocalGet(alias) => aliases.get(alias).copied(), + Expr::IndexGet { object, index } if matches!(object.as_ref(), Expr::LocalGet(id) if *id == array_id) => { + const_index(index) + } + _ => None, + }?; + fields + .get(index) + .is_some_and(|element| element.contains(property)) + .then_some(index) +} + +fn expr_is_safe( + expr: &Expr, + array_id: LocalId, + aliases: &HashMap, + fields: &[HashSet], +) -> bool { + match expr { + Expr::PropertyGet { + object, property, .. + } => { + if candidate_field(object, property, array_id, aliases, fields).is_some() { + return true; + } + if property == "length" + && matches!(object.as_ref(), Expr::LocalGet(id) if *id == array_id) + { + return true; + } + } + Expr::IndexGet { object, .. } if matches!(object.as_ref(), Expr::LocalGet(id) if *id == array_id) => + { + // Only an alias declaration or a direct property receiver is safe; + // both parents intercept this expression before recursion reaches it. + return false; + } + Expr::LocalGet(id) if *id == array_id || aliases.contains_key(id) => return false, + Expr::LocalSet(id, _) | Expr::Update { id, .. } + if *id == array_id || aliases.contains_key(id) => + { + return false; + } + Expr::Closure { + captures, + mutable_captures, + .. + } if captures + .iter() + .chain(mutable_captures.iter()) + .any(|id| *id == array_id || aliases.contains_key(id)) => + { + return false; + } + Expr::Call { callee, .. } | Expr::CallSpread { callee, .. } + if matches!( + callee.as_ref(), + Expr::PropertyGet { object, property, .. } + if candidate_field(object, property, array_id, aliases, fields).is_some() + ) => + { + // `item.method()` observes the original object as `this`. + return false; + } + Expr::Delete(operand) + if matches!( + operand.as_ref(), + Expr::PropertyGet { object, property, .. } + if candidate_field(object, property, array_id, aliases, fields).is_some() + ) => + { + return false; + } + _ => {} + } + + let mut safe = true; + perry_hir::walker::walk_expr_children(expr, &mut |child| { + safe &= expr_is_safe(child, array_id, aliases, fields); + }); + safe +} + +fn stmts_are_safe( + stmts: &[Stmt], + array_id: LocalId, + aliases: &HashMap, + fields: &[HashSet], +) -> bool { + for stmt in stmts { + let safe = match stmt { + Stmt::Let { id, init, .. } if *id == array_id => true, + Stmt::Let { + id, + init: Some(Expr::IndexGet { object, index }), + .. + } if aliases.contains_key(id) + && matches!(object.as_ref(), Expr::LocalGet(candidate) if *candidate == array_id) + && const_index(index) == aliases.get(id).copied() => + { + true + } + Stmt::Let { init, .. } => init + .as_ref() + .is_none_or(|expr| expr_is_safe(expr, array_id, aliases, fields)), + Stmt::Expr(expr) | Stmt::Throw(expr) => expr_is_safe(expr, array_id, aliases, fields), + Stmt::Return(value) => value + .as_ref() + .is_none_or(|expr| expr_is_safe(expr, array_id, aliases, fields)), + Stmt::If { + condition, + then_branch, + else_branch, + } => { + expr_is_safe(condition, array_id, aliases, fields) + && stmts_are_safe(then_branch, array_id, aliases, fields) + && else_branch + .as_deref() + .is_none_or(|branch| stmts_are_safe(branch, array_id, aliases, fields)) + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + expr_is_safe(condition, array_id, aliases, fields) + && stmts_are_safe(body, array_id, aliases, fields) + } + Stmt::For { + init, + condition, + update, + body, + } => { + init.as_deref().is_none_or(|init| { + stmts_are_safe(std::slice::from_ref(init), array_id, aliases, fields) + }) && condition + .as_ref() + .is_none_or(|expr| expr_is_safe(expr, array_id, aliases, fields)) + && update + .as_ref() + .is_none_or(|expr| expr_is_safe(expr, array_id, aliases, fields)) + && stmts_are_safe(body, array_id, aliases, fields) + } + Stmt::Try { + body, + catch, + finally, + } => { + stmts_are_safe(body, array_id, aliases, fields) + && catch + .as_ref() + .is_none_or(|catch| stmts_are_safe(&catch.body, array_id, aliases, fields)) + && finally + .as_deref() + .is_none_or(|body| stmts_are_safe(body, array_id, aliases, fields)) + } + Stmt::Switch { + discriminant, + cases, + } => { + expr_is_safe(discriminant, array_id, aliases, fields) + && cases.iter().all(|case| { + case.test + .as_ref() + .is_none_or(|test| expr_is_safe(test, array_id, aliases, fields)) + && stmts_are_safe(&case.body, array_id, aliases, fields) + }) + } + Stmt::Labeled { body, .. } => stmts_are_safe( + std::slice::from_ref(body.as_ref()), + array_id, + aliases, + fields, + ), + Stmt::PreallocateBoxes(ids) + | Stmt::PreallocateTdzBoxes(ids) + | Stmt::ReleaseBoxes(ids) => !ids + .iter() + .any(|id| *id == array_id || aliases.contains_key(id)), + Stmt::Break | Stmt::Continue | Stmt::LabeledBreak(_) | Stmt::LabeledContinue(_) => true, + }; + if !safe { + return false; + } + } + true +} + +fn replacement_for_expr( + expr: &Expr, + array_id: LocalId, + aliases: &HashMap, + fields: &[HashMap], +) -> Option { + let Expr::PropertyGet { + object, property, .. + } = expr + else { + return None; + }; + if property == "length" && matches!(object.as_ref(), Expr::LocalGet(id) if *id == array_id) { + return Some(Expr::Integer(fields.len() as i64)); + } + let index = match object.as_ref() { + Expr::LocalGet(alias) => aliases.get(alias).copied(), + Expr::IndexGet { object, index } if matches!(object.as_ref(), Expr::LocalGet(id) if *id == array_id) => { + const_index(index) + } + _ => None, + }?; + fields + .get(index)? + .get(property) + .copied() + .map(Expr::LocalGet) +} + +fn rewrite_expr( + expr: &mut Expr, + array_id: LocalId, + aliases: &HashMap, + fields: &[HashMap], +) { + if let Some(replacement) = replacement_for_expr(expr, array_id, aliases, fields) { + *expr = replacement; + return; + } + perry_hir::walker::walk_expr_children_mut(expr, &mut |child| { + rewrite_expr(child, array_id, aliases, fields) + }); +} + +fn rewrite_stmts( + stmts: &mut Vec, + array_id: LocalId, + aliases: &HashMap, + fields: &[HashMap], +) { + let mut index = 0; + while index < stmts.len() { + if matches!(&stmts[index], Stmt::Let { id, .. } if aliases.contains_key(id)) { + stmts.remove(index); + continue; + } + match &mut stmts[index] { + Stmt::Let { init, .. } => { + if let Some(init) = init { + rewrite_expr(init, array_id, aliases, fields); + } + } + Stmt::Expr(expr) | Stmt::Throw(expr) => rewrite_expr(expr, array_id, aliases, fields), + Stmt::Return(value) => { + if let Some(value) = value { + rewrite_expr(value, array_id, aliases, fields); + } + } + Stmt::If { + condition, + then_branch, + else_branch, + } => { + rewrite_expr(condition, array_id, aliases, fields); + rewrite_stmts(then_branch, array_id, aliases, fields); + if let Some(else_branch) = else_branch { + rewrite_stmts(else_branch, array_id, aliases, fields); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + rewrite_expr(condition, array_id, aliases, fields); + rewrite_stmts(body, array_id, aliases, fields); + } + Stmt::For { + condition, + update, + body, + .. + } => { + if let Some(condition) = condition { + rewrite_expr(condition, array_id, aliases, fields); + } + if let Some(update) = update { + rewrite_expr(update, array_id, aliases, fields); + } + rewrite_stmts(body, array_id, aliases, fields); + } + Stmt::Try { + body, + catch, + finally, + } => { + rewrite_stmts(body, array_id, aliases, fields); + if let Some(catch) = catch { + rewrite_stmts(&mut catch.body, array_id, aliases, fields); + } + if let Some(finally) = finally { + rewrite_stmts(finally, array_id, aliases, fields); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + rewrite_expr(discriminant, array_id, aliases, fields); + for case in cases { + if let Some(test) = &mut case.test { + rewrite_expr(test, array_id, aliases, fields); + } + rewrite_stmts(&mut case.body, array_id, aliases, fields); + } + } + Stmt::Labeled { .. } + | Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::PreallocateBoxes(_) + | Stmt::PreallocateTdzBoxes(_) + | Stmt::ReleaseBoxes(_) => {} + } + index += 1; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use perry_hir::CompareOp; + + fn object(value: i64) -> Expr { + Expr::Object(vec![("component".to_string(), Expr::Integer(value))]) + } + + fn property(object: Expr, name: &str) -> Expr { + Expr::PropertyGet { + object: Box::new(object), + property: name.to_string(), + byte_offset: 0, + } + } + + fn aggregate_fixture(observe_identity: bool) -> Module { + let mut module = Module::new("aggregate-scalar.ts"); + module.init = vec![ + Stmt::Let { + id: 1, + name: "values".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::Array(vec![object(10), object(20)])), + }, + Stmt::Let { + id: 2, + name: "first".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::IndexGet { + object: Box::new(Expr::LocalGet(1)), + index: Box::new(Expr::Integer(0)), + }), + }, + Stmt::Expr(if observe_identity { + Expr::Compare { + op: CompareOp::Eq, + left: Box::new(Expr::LocalGet(2)), + right: Box::new(Expr::LocalGet(2)), + } + } else { + property(Expr::LocalGet(2), "component") + }), + ]; + module + } + + #[test] + fn replaces_nested_carriers_with_scalar_field_locals() { + let mut module = aggregate_fixture(false); + run(&mut module); + + assert!(module.init.iter().all(|stmt| { + !matches!( + stmt, + Stmt::Let { + init: Some(Expr::Array(_) | Expr::Object(_)), + .. + } + ) + })); + assert!(!module + .init + .iter() + .any(|stmt| matches!(stmt, Stmt::Let { id: 2, .. }))); + assert!(matches!( + module.init.last(), + Some(Stmt::Expr(Expr::LocalGet(_))) + )); + } + + #[test] + fn identity_observation_keeps_materialized_aggregate() { + let mut module = aggregate_fixture(true); + run(&mut module); + + assert!(module.init.iter().any(|stmt| { + matches!( + stmt, + Stmt::Let { + id: 1, + init: Some(Expr::Array(_)), + .. + } + ) + })); + assert!(module + .init + .iter() + .any(|stmt| matches!(stmt, Stmt::Let { id: 2, .. }))); + } + + #[test] + fn mutation_reflection_and_unknown_calls_keep_materialized_aggregate() { + let hazards = vec![ + Expr::PropertySet { + object: Box::new(Expr::LocalGet(2)), + property: "component".to_string(), + value: Box::new(Expr::Integer(30)), + }, + Expr::ObjectKeys(Box::new(Expr::LocalGet(2))), + Expr::Call { + callee: Box::new(Expr::FuncRef(99)), + args: vec![Expr::LocalGet(2)], + type_args: Vec::new(), + byte_offset: 0, + }, + ]; + + for hazard in hazards { + let mut module = aggregate_fixture(false); + *module.init.last_mut().expect("observer statement") = Stmt::Expr(hazard); + run(&mut module); + + assert!(module.init.iter().any(|stmt| { + matches!( + stmt, + Stmt::Let { + id: 1, + init: Some(Expr::Array(_)), + .. + } + ) + })); + } + } +} diff --git a/crates/perry-transform/src/inline/analysis.rs b/crates/perry-transform/src/inline/analysis.rs index 35dc08147f..d33c06a0b9 100644 --- a/crates/perry-transform/src/inline/analysis.rs +++ b/crates/perry-transform/src/inline/analysis.rs @@ -166,6 +166,152 @@ pub fn is_inlinable(func: &Function) -> bool { true } +/// A deliberately narrow extension of the ordinary inliner for issue #8691. +/// +/// Fixed aggregate literals are especially expensive when a tiny helper walks +/// them with the canonical `for (let i = 0; i < values.length; i++)` loop. The +/// general inliner rejects every loop, which prevents the later static-loop +/// unroller and aggregate scalar-replacement pass from ever seeing the +/// consumer next to the literal. Admit only a single, bounded, forward loop +/// over one parameter and only when every use of that parameter is a length +/// read or an indexed read. The call-site path separately requires a short +/// array of plain object literals; all other calls keep the normal ABI. +pub fn scalar_aggregate_loop_param(func: &Function) -> Option { + if func.is_async + || func.is_generator + || !func.captures.is_empty() + || func.params.iter().any(|param| param.is_rest) + || func.body.len() != 1 + || body_references_dynamic_this(&func.body) + || body_calls_func(&func.body, func.id) + { + return None; + } + + let Stmt::For { + init: Some(init), + condition: Some(condition), + update: Some(update), + body, + } = &func.body[0] + else { + return None; + }; + let Stmt::Let { + id: index_id, + init: Some(Expr::Integer(0)), + .. + } = init.as_ref() + else { + return None; + }; + let Expr::Compare { + op: perry_hir::CompareOp::Lt, + left, + right, + } = condition + else { + return None; + }; + if !matches!(left.as_ref(), Expr::LocalGet(id) if id == index_id) + || !matches!( + update, + Expr::Update { + id, + op: perry_hir::UpdateOp::Increment, + .. + } if id == index_id + ) + { + return None; + } + let Expr::PropertyGet { + object, property, .. + } = right.as_ref() + else { + return None; + }; + let Expr::LocalGet(param_id) = object.as_ref() else { + return None; + }; + if property != "length" || !func.params.iter().any(|param| param.id == *param_id) { + return None; + } + + fn expr_uses_param_safely(expr: &Expr, param_id: LocalId, index_id: LocalId) -> bool { + match expr { + Expr::IndexGet { object, index } if matches!(object.as_ref(), Expr::LocalGet(id) if *id == param_id) => + { + matches!(index.as_ref(), Expr::LocalGet(id) if *id == index_id) + } + Expr::PropertyGet { + object, property, .. + } if matches!(object.as_ref(), Expr::LocalGet(id) if *id == param_id) => { + property == "length" + } + Expr::LocalGet(id) if *id == param_id => false, + Expr::Closure { captures, .. } if captures.contains(¶m_id) => false, + // Calls and constructors can observe or mutate module state while + // module-init locals are cached. The dedicated path does not need + // either shape, so keep its body call-free. + Expr::Call { .. } | Expr::CallSpread { .. } | Expr::New { .. } => false, + _ => { + let mut safe = true; + perry_hir::walker::walk_expr_children(expr, &mut |child| { + safe &= expr_uses_param_safely(child, param_id, index_id); + }); + safe + } + } + } + + fn stmts_use_param_safely( + stmts: &[Stmt], + param_id: LocalId, + index_id: LocalId, + statement_budget: &mut usize, + ) -> bool { + for stmt in stmts { + *statement_budget += 1; + if *statement_budget > MAX_INLINE_STMTS { + return false; + } + let safe = match stmt { + Stmt::Let { init, .. } => init + .as_ref() + .is_none_or(|expr| expr_uses_param_safely(expr, param_id, index_id)), + Stmt::Expr(expr) | Stmt::Throw(expr) => { + expr_uses_param_safely(expr, param_id, index_id) + } + Stmt::If { + condition, + then_branch, + else_branch, + } => { + expr_uses_param_safely(condition, param_id, index_id) + && stmts_use_param_safely(then_branch, param_id, index_id, statement_budget) + && else_branch.as_deref().is_none_or(|branch| { + stmts_use_param_safely(branch, param_id, index_id, statement_budget) + }) + } + Stmt::PreallocateBoxes(_) + | Stmt::PreallocateTdzBoxes(_) + | Stmt::ReleaseBoxes(_) => true, + // Returns and nested/abrupt control flow need a labeled inline + // boundary. Keep the first slice intentionally smaller. + _ => false, + }; + if !safe { + return false; + } + } + true + } + + let mut statement_budget = 0; + stmts_use_param_safely(body, *param_id, *index_id, &mut statement_budget).then_some(*param_id) +} + /// Inlinability for a *method* invoked with a known (exact) receiver. Identical /// to [`is_inlinable`] except the dynamic-`this` rejection is relaxed: the /// method-inliner substitutes `this` for the concrete receiver diff --git a/crates/perry-transform/src/inline/call_inliner.rs b/crates/perry-transform/src/inline/call_inliner.rs index 095bd7f683..935464aeff 100644 --- a/crates/perry-transform/src/inline/call_inliner.rs +++ b/crates/perry-transform/src/inline/call_inliner.rs @@ -1429,6 +1429,12 @@ pub fn try_inline_simple_call( // Check for regular function call if let Expr::FuncRef(func_id) = callee.as_ref() { if let Some(func) = func_candidates.get(func_id) { + // Loop-bearing candidates are admitted only for the dedicated + // fixed-aggregate path in `try_inline_call`. Expression-level + // inlining has nowhere to place their control flow. + if !has_simple_control_flow(&func.body) { + return None; + } // Pattern 1: single Return(expr) if func.body.len() == 1 { if let Stmt::Return(Some(return_expr)) = &func.body[0] { @@ -1702,6 +1708,156 @@ pub fn try_inline_simple_call( None } +const MAX_SCALAR_AGGREGATE_CALL_LEN: usize = 8; + +pub(crate) fn scalar_aggregate_call_len( + func: &Function, + args: &[Expr], +) -> Option<(LocalId, usize)> { + let param_id = scalar_aggregate_loop_param(func)?; + let param_index = func.params.iter().position(|param| param.id == param_id)?; + let Expr::Array(elements) = args.get(param_index)? else { + return None; + }; + if elements.is_empty() + || elements.len() > MAX_SCALAR_AGGREGATE_CALL_LEN + || elements.iter().any(|element| match element { + Expr::Object(properties) => { + properties.is_empty() || properties.iter().any(|(key, _)| key == "__proto__") + } + // Closed-shape object literals have already been rewritten by HIR + // lowering to constructor calls whose positional arguments are the + // property values. The replacement pass resolves their field names + // from the synthesized class before removing either allocation. + Expr::New { + class_name, + args, + cap_args_appended, + .. + } => { + !class_name.starts_with("__AnonShape_") + || args.is_empty() + || args.len() > 16 + || *cap_args_appended != 0 + } + _ => true, + }) + { + return None; + } + Some((param_id, elements.len())) +} + +/// Replace the loop-bound read on the original parameter before local-id +/// substitution. The actual argument remains bound to a normal local until +/// the post-unroll scalar-replacement pass proves every element use; only the +/// fresh literal's immutable length is folded here. +fn fold_scalar_aggregate_param_length(stmts: &mut [Stmt], param_id: LocalId, len: usize) { + fn fold_expr(expr: &mut Expr, param_id: LocalId, len: usize) { + if matches!( + expr, + Expr::PropertyGet { object, property, .. } + if property == "length" + && matches!(object.as_ref(), Expr::LocalGet(id) if *id == param_id) + ) { + *expr = Expr::Integer(len as i64); + return; + } + perry_hir::walker::walk_expr_children_mut(expr, &mut |child| { + fold_expr(child, param_id, len) + }); + } + + for stmt in stmts { + match stmt { + Stmt::Let { init, .. } => { + if let Some(init) = init { + fold_expr(init, param_id, len); + } + } + Stmt::Expr(expr) | Stmt::Throw(expr) => fold_expr(expr, param_id, len), + Stmt::Return(value) => { + if let Some(value) = value { + fold_expr(value, param_id, len); + } + } + Stmt::If { + condition, + then_branch, + else_branch, + } => { + fold_expr(condition, param_id, len); + fold_scalar_aggregate_param_length(then_branch, param_id, len); + if let Some(else_branch) = else_branch { + fold_scalar_aggregate_param_length(else_branch, param_id, len); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + fold_expr(condition, param_id, len); + fold_scalar_aggregate_param_length(body, param_id, len); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(init) = init { + fold_scalar_aggregate_param_length( + std::slice::from_mut(init.as_mut()), + param_id, + len, + ); + } + if let Some(condition) = condition { + fold_expr(condition, param_id, len); + } + if let Some(update) = update { + fold_expr(update, param_id, len); + } + fold_scalar_aggregate_param_length(body, param_id, len); + } + Stmt::Try { + body, + catch, + finally, + } => { + fold_scalar_aggregate_param_length(body, param_id, len); + if let Some(catch) = catch { + fold_scalar_aggregate_param_length(&mut catch.body, param_id, len); + } + if let Some(finally) = finally { + fold_scalar_aggregate_param_length(finally, param_id, len); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + fold_expr(discriminant, param_id, len); + for case in cases { + if let Some(test) = &mut case.test { + fold_expr(test, param_id, len); + } + fold_scalar_aggregate_param_length(&mut case.body, param_id, len); + } + } + Stmt::Labeled { body, .. } => fold_scalar_aggregate_param_length( + std::slice::from_mut(body.as_mut()), + param_id, + len, + ), + Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::PreallocateBoxes(_) + | Stmt::PreallocateTdzBoxes(_) + | Stmt::ReleaseBoxes(_) => {} + } + } +} + /// Try to inline a call that may have multiple statements pub fn try_inline_call( expr: &Expr, @@ -1717,6 +1873,11 @@ pub fn try_inline_call( // Handle regular function calls if let Expr::FuncRef(func_id) = callee.as_ref() { if let Some(func) = func_candidates.get(func_id) { + let scalar_aggregate = if has_simple_control_flow(&func.body) { + None + } else { + Some(scalar_aggregate_call_len(func, args)?) + }; // Extra actual args are evaluated before a JS call even when // the callee declares fewer params. The current inliner maps // params with zip(), so it cannot preserve those trailing @@ -1787,6 +1948,10 @@ pub fn try_inline_call( let mut inlined_body = func.body.clone(); + if let Some((param_id, len)) = scalar_aggregate { + fold_scalar_aggregate_param_length(&mut inlined_body, param_id, len); + } + // Collect all LocalIds from Let statements in the body and remap them let body_local_ids = collect_body_local_ids(&inlined_body); for old_id in body_local_ids { diff --git a/crates/perry-transform/src/inline/mod.rs b/crates/perry-transform/src/inline/mod.rs index 9053e7f117..50cbf13977 100644 --- a/crates/perry-transform/src/inline/mod.rs +++ b/crates/perry-transform/src/inline/mod.rs @@ -28,8 +28,9 @@ pub use cross_module::{ // Internal-to-crate re-exports for cross-sibling access via `use super::*;`. pub(crate) use analysis::{ find_max_local_id_in_module, is_inlinable, is_inlinable_method, method_lookup_is_unshadowed, + scalar_aggregate_loop_param, }; -pub(crate) use call_inliner::inline_calls_in_stmts; +pub(crate) use call_inliner::{inline_calls_in_stmts, scalar_aggregate_call_len}; pub(crate) use clamp::{is_clamp3, is_clamp_u8}; pub(crate) use closure_analysis::{ body_contains_closure_capturing, body_contains_super_call, body_references_dynamic_this, @@ -114,6 +115,136 @@ pub fn inline_functions( span_remaps.finish(&mut module.local_source_spans); } +/// Module init caches globals in locals, so an ordinary call between an +/// inlined impure consumer and its next use could observe stale backing +/// storage. Admit the #8691 init path only when every executable call is an +/// eligible call to this one consumer, apart from `console.log` (whose +/// arguments are evaluated from the cached locals before entering runtime). +fn impure_scalar_aggregate_init_is_safe(stmts: &[Stmt], target: &Function) -> bool { + fn is_console_log(expr: &Expr) -> bool { + matches!( + expr, + Expr::PropertyGet { object, property, .. } + if property == "log" && matches!(object.as_ref(), Expr::GlobalGet(_)) + ) + } + + fn expr_is_safe(expr: &Expr, target: &Function, saw_target: &mut bool) -> bool { + match expr { + Expr::Call { callee, args, .. } if matches!(callee.as_ref(), Expr::FuncRef(id) if *id == target.id) => + { + *saw_target = true; + scalar_aggregate_call_len(target, args).is_some() + && args.iter().all(|arg| expr_is_safe(arg, target, saw_target)) + } + Expr::Call { callee, args, .. } if is_console_log(callee) => { + args.iter().all(|arg| expr_is_safe(arg, target, saw_target)) + } + Expr::Call { .. } | Expr::CallSpread { .. } => false, + Expr::New { + class_name, + args, + cap_args_appended: 0, + .. + } if class_name.starts_with("__AnonShape_") => { + args.iter().all(|arg| expr_is_safe(arg, target, saw_target)) + } + Expr::New { .. } => false, + // Creating a closure does not execute its body. A later call would + // itself be rejected above, so its body is irrelevant here. + Expr::Closure { .. } => true, + _ => { + let mut safe = true; + perry_hir::walker::walk_expr_children(expr, &mut |child| { + safe &= expr_is_safe(child, target, saw_target); + }); + safe + } + } + } + + fn stmts_are_safe(stmts: &[Stmt], target: &Function, saw_target: &mut bool) -> bool { + stmts.iter().all(|stmt| match stmt { + Stmt::Let { init, .. } => init + .as_ref() + .is_none_or(|expr| expr_is_safe(expr, target, saw_target)), + Stmt::Expr(expr) | Stmt::Throw(expr) => expr_is_safe(expr, target, saw_target), + Stmt::Return(value) => value + .as_ref() + .is_none_or(|expr| expr_is_safe(expr, target, saw_target)), + Stmt::If { + condition, + then_branch, + else_branch, + } => { + expr_is_safe(condition, target, saw_target) + && stmts_are_safe(then_branch, target, saw_target) + && else_branch + .as_deref() + .is_none_or(|branch| stmts_are_safe(branch, target, saw_target)) + } + Stmt::While { condition, body } | Stmt::DoWhile { body, condition } => { + expr_is_safe(condition, target, saw_target) + && stmts_are_safe(body, target, saw_target) + } + Stmt::For { + init, + condition, + update, + body, + } => { + init.as_deref().is_none_or(|stmt| { + stmts_are_safe(std::slice::from_ref(stmt), target, saw_target) + }) && condition + .as_ref() + .is_none_or(|expr| expr_is_safe(expr, target, saw_target)) + && update + .as_ref() + .is_none_or(|expr| expr_is_safe(expr, target, saw_target)) + && stmts_are_safe(body, target, saw_target) + } + Stmt::Try { + body, + catch, + finally, + } => { + stmts_are_safe(body, target, saw_target) + && catch + .as_ref() + .is_none_or(|catch| stmts_are_safe(&catch.body, target, saw_target)) + && finally + .as_deref() + .is_none_or(|body| stmts_are_safe(body, target, saw_target)) + } + Stmt::Switch { + discriminant, + cases, + } => { + expr_is_safe(discriminant, target, saw_target) + && cases.iter().all(|case| { + case.test + .as_ref() + .is_none_or(|test| expr_is_safe(test, target, saw_target)) + && stmts_are_safe(&case.body, target, saw_target) + }) + } + Stmt::Labeled { body, .. } => { + stmts_are_safe(std::slice::from_ref(body.as_ref()), target, saw_target) + } + Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::PreallocateBoxes(_) + | Stmt::PreallocateTdzBoxes(_) + | Stmt::ReleaseBoxes(_) => true, + }) + } + + let mut saw_target = false; + stmts_are_safe(stmts, target, &mut saw_target) && saw_target +} + fn inline_functions_inner( module: &mut Module, extra_methods: &HashMap<(String, String), MethodCandidate>, @@ -360,7 +491,7 @@ fn inline_functions_inner( if is_clamp3(f) || is_clamp_u8(f) { continue; } - if is_inlinable(f) { + if is_inlinable(f) || scalar_aggregate_loop_param(f).is_some() { func_candidates.insert(f.id, f.clone()); } } @@ -563,7 +694,11 @@ fn inline_functions_inner( { let pure_func_candidates: HashMap = func_candidates .iter() - .filter(|(_, f)| is_pure_function(f)) + .filter(|(_, f)| { + is_pure_function(f) + || (scalar_aggregate_loop_param(f).is_some() + && impure_scalar_aggregate_init_is_safe(&module.init, f)) + }) .map(|(id, f)| (*id, f.clone())) .collect(); let mut next_local_id = module_max_id + 1; diff --git a/crates/perry-transform/src/lib.rs b/crates/perry-transform/src/lib.rs index 03f00dd604..6ce199f539 100644 --- a/crates/perry-transform/src/lib.rs +++ b/crates/perry-transform/src/lib.rs @@ -6,6 +6,7 @@ //! - Optimization passes (function inlining) //! - i18n string localization +mod aggregate_scalar; pub mod async_to_generator; pub mod closure; pub mod deforest; @@ -47,5 +48,6 @@ pub use unroll::unroll_static_loops; /// the order here stops the two facts from drifting apart.) pub fn post_inline_cleanups(module: &mut perry_hir::Module) { unroll_static_loops(module); + aggregate_scalar::run(module); prop_cse::run(module); } diff --git a/test-files/test_gap_8691_scalar_replace_aggregate_calls.ts b/test-files/test_gap_8691_scalar_replace_aggregate_calls.ts new file mode 100644 index 0000000000..ae35077311 --- /dev/null +++ b/test-files/test_gap_8691_scalar_replace_aggregate_calls.ts @@ -0,0 +1,25 @@ +// #8691: fixed arrays of plain descriptor objects passed to a known helper +// must retain JS semantics when the inliner, static-loop unroller, and escape +// proof remove both carrier identities. + +class Position {} +class Velocity {} + +let checksum = 0; + +function consume(initializers: { component: unknown }[]): void { + for (let i = 0; i < initializers.length; i++) { + const initializer = initializers[i]; + if (initializer.component === Position) checksum += 1; + if (initializer.component === Velocity) checksum += 2; + } +} + +const iterations = 500_000; +for (let i = 0; i < iterations; i++) { + consume([{ component: Position }, { component: Velocity }]); + consume([{ component: Position }]); + consume([{ component: Velocity }]); +} + +console.log(checksum); From 354a10a99df5294dbd664e1b42b8c7bff59cf3ec Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Mon, 24 Aug 2026 08:20:17 +0200 Subject: [PATCH 2/2] docs(changelog): note aggregate scalar replacement --- changelog.d/8703-scalar-aggregate-calls.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 changelog.d/8703-scalar-aggregate-calls.md diff --git a/changelog.d/8703-scalar-aggregate-calls.md b/changelog.d/8703-scalar-aggregate-calls.md new file mode 100644 index 0000000000..74770b7f82 --- /dev/null +++ b/changelog.d/8703-scalar-aggregate-calls.md @@ -0,0 +1,7 @@ +### Performance + +- Scalar-replace short arrays of non-escaping object literals passed to known, + bounded aggregate consumers. Their carrier arrays, descriptor objects, + property/index accesses, and write barriers are now eliminated after + conservative inlining and loop unrolling; identity-observing and otherwise + escaping uses continue to materialize normally.