diff --git a/changelog.d/8594-prefix-update-index-ranges.md b/changelog.d/8594-prefix-update-index-ranges.md new file mode 100644 index 0000000000..467287ebb8 --- /dev/null +++ b/changelog.d/8594-prefix-update-index-ranges.md @@ -0,0 +1,7 @@ +Eliminated dynamic typed-array property lookups for bounded update-expression +indices such as `P[++i]`. Integer range analysis now models the distinct prefix +and postfix results and conservatively retains loop facts across updates. On +the Blowfish-shaped `typed_array` workload this reduced retired instructions +from 55.717 G to 14.647 G (-73.71%) and wall time from 4.063 s to 2.498 s +(-38.51%) on the quiet M1 mini, while the typed/untyped ratio stayed at 1.000 +and peak RSS decreased 1.19%. diff --git a/crates/perry-codegen/src/codegen/ordinary_param_guard_tests.rs b/crates/perry-codegen/src/codegen/ordinary_param_guard_tests.rs index 330aafbf4d..239d0b10e3 100644 --- a/crates/perry-codegen/src/codegen/ordinary_param_guard_tests.rs +++ b/crates/perry-codegen/src/codegen/ordinary_param_guard_tests.rs @@ -5,7 +5,7 @@ use crate::{compile_module, CompileOptions}; use perry_hir::types::{ObjectType, PropertyInfo, Type}; -use perry_hir::{BinaryOp, CompareOp, Expr, Function, Module, Param, Stmt, TypeAlias}; +use perry_hir::{BinaryOp, CompareOp, Expr, Function, Module, Param, Stmt, TypeAlias, UpdateOp}; use std::collections::HashMap; fn function_ir<'a>(ir: &'a str, marker: &str) -> &'a str { @@ -334,6 +334,128 @@ fn numeric_by_construction_local_drops_specialized_clone_root() { ); } +#[test] +fn guarded_typed_array_clone_keeps_prefix_update_indices_native() { + let read = Function { + id: 1, + name: "read".to_string(), + type_params: Vec::new(), + params: vec![Param { + id: 10, + name: "table".to_string(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }], + return_type: Type::Number, + body: vec![ + Stmt::Let { + id: 11, + name: "i".to_string(), + ty: Type::Number, + mutable: true, + init: Some(Expr::Integer(0)), + }, + Stmt::Let { + id: 12, + name: "sum".to_string(), + ty: Type::Number, + mutable: true, + init: Some(Expr::Integer(0)), + }, + Stmt::While { + condition: Expr::Compare { + op: CompareOp::Lt, + left: Box::new(Expr::LocalGet(11)), + right: Box::new(Expr::Integer(2)), + }, + body: vec![ + Stmt::Expr(Expr::LocalSet( + 12, + Box::new(Expr::Binary { + op: BinaryOp::BitXor, + left: Box::new(Expr::LocalGet(12)), + right: Box::new(Expr::IndexGet { + object: Box::new(Expr::LocalGet(10)), + index: Box::new(Expr::Update { + id: 11, + op: UpdateOp::Increment, + prefix: true, + }), + }), + }), + )), + Stmt::Expr(Expr::LocalSet( + 12, + Box::new(Expr::Binary { + op: BinaryOp::BitXor, + left: Box::new(Expr::LocalGet(12)), + right: Box::new(Expr::IndexGet { + object: Box::new(Expr::LocalGet(10)), + index: Box::new(Expr::Update { + id: 11, + op: UpdateOp::Increment, + prefix: true, + }), + }), + }), + )), + ], + }, + Stmt::Return(Some(Expr::LocalGet(12))), + ], + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }; + let mut module = Module::new("prefix_update_typed_array.ts"); + module.functions.push(read); + module.init.extend([ + Stmt::Let { + id: 20, + name: "table".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::TypedArrayNew { + kind: perry_hir::TYPED_ARRAY_KIND_INT32, + arg: Some(Box::new(Expr::Integer(4))), + }), + }, + Stmt::Expr(Expr::Call { + callee: Box::new(Expr::FuncRef(1)), + args: vec![Expr::LocalGet(20)], + type_args: Vec::new(), + byte_offset: 0, + }), + ]); + + let opts = CompileOptions { + emit_ir_only: true, + output_type: "executable".to_string(), + ..Default::default() + }; + let ir = String::from_utf8(compile_module(&module, opts).expect("module compiles")) + .expect("LLVM IR is UTF-8"); + let specialized = function_ir(&ir, "read$spec_ta4x4("); + + assert!( + specialized.contains("load i32"), + "the guarded descriptor clone must load Int32Array elements natively:\n{specialized}" + ); + assert!( + !specialized.contains("js_typed_array_index_get_dynamic") + && !specialized.contains("js_typed_array_get"), + "both prefix-update reads must retain their proven element ranges:\n{specialized}" + ); +} + #[test] fn nonsuspending_async_function_needs_no_direct_call_site_for_its_guarded_clone() { // An async body with no `await` runs to completion synchronously, so the diff --git a/crates/perry-codegen/src/expr/range_facts.rs b/crates/perry-codegen/src/expr/range_facts.rs index 64d8a06e2d..5d35ec8dbe 100644 --- a/crates/perry-codegen/src/expr/range_facts.rs +++ b/crates/perry-codegen/src/expr/range_facts.rs @@ -339,6 +339,22 @@ fn int_range_expr_inner( | Expr::PodLayoutAlignOf { .. } | Expr::PodLayoutOffsetOf { .. } => pod_layout_constant_i64(ctx, expr).map(IntRange::exact), Expr::LocalGet(id) => int_range_for_local(ctx, *id, seen), + // An update expression is both a read and a write, but its returned + // value still has an exact range relative to the value on entry: + // postfix returns the old range, prefix returns the stepped range. + // Keeping that distinction lets a proven loop counter remain an + // integer element key in `table[++i]` without truncating a fractional + // or out-of-range dynamic property key into an i32 index. + Expr::Update { id, op, prefix } => { + let old = int_range_for_local(ctx, *id, seen)?; + if !prefix { + return Some(old); + } + match op { + UpdateOp::Increment => checked_range_add(old, IntRange::exact(1)), + UpdateOp::Decrement => checked_range_sub(old, IntRange::exact(1)), + } + } Expr::IndexGet { object, index } => int_typed_array_load_range(ctx, object, index, seen), Expr::Binary { op, left, right } => { // Result-shape rules that need no range on one (or either) @@ -570,12 +586,36 @@ pub(crate) fn invalidate_local_write_facts(ctx: &mut FnCtx<'_>, id: u32) { pub(crate) fn record_int_facts_for_update(ctx: &mut FnCtx<'_>, id: u32, op: UpdateOp) { ctx.int_range_aliases.remove(&id); - let remains_nonnegative = match op { - UpdateOp::Increment => ctx.nonnegative_integer_locals.contains(&id), - UpdateOp::Decrement => int_range_for_local(ctx, id, &mut std::collections::HashSet::new()) - .is_some_and(|range| range.min >= 1), - }; - ctx.int_range_facts.retain(|fact| fact.local_id != id); + let was_nonnegative = ctx.nonnegative_integer_locals.contains(&id); + + // A dominating loop/branch fact remains useful after ++/--, but the write + // must not make it narrower. Dropping the fact made the second `P[++i]` in + // a Blowfish round opaque even after the first one had proved `[1, 16]`. + // Widen each active fact to cover both the old and stepped intervals. The + // union is conservative even when an update is conditional: lowering one + // branch cannot make the fact assume that the other branch updated too. + ctx.int_range_facts.retain_mut(|fact| { + if fact.local_id != id { + return true; + } + let shifted = match op { + UpdateOp::Increment => checked_range_add(fact.range, IntRange::exact(1)), + UpdateOp::Decrement => checked_range_sub(fact.range, IntRange::exact(1)), + }; + if let Some(range) = shifted { + fact.range = IntRange { + min: fact.range.min.min(range.min), + max: fact.range.max.max(range.max), + }; + true + } else { + false + } + }); + + let active_range = int_range_for_local(ctx, id, &mut std::collections::HashSet::new()); + let remains_nonnegative = active_range.is_some_and(IntRange::is_nonnegative) + || matches!(op, UpdateOp::Increment) && was_nonnegative; if remains_nonnegative { ctx.nonnegative_integer_locals.insert(id); } else {