diff --git a/changelog.d/6850-native-imul-typed-array-param.md b/changelog.d/6850-native-imul-typed-array-param.md new file mode 100644 index 0000000000..ca73a96b8a --- /dev/null +++ b/changelog.d/6850-native-imul-typed-array-param.md @@ -0,0 +1,31 @@ +### Changed + +- Lower two integer-math JS primitives that were compiled as runtime function + calls to native machine ops, closing an ~9x AOT-vs-JIT gap on hot integer + kernels (hashes/PRNGs/mixers/ciphers): + - **`Math.imul(a, b)`** now lowers to a single native `mul i32` whenever both + operands are provably in-range i32 (multiplication mod 2^32 has identical + low 32 bits for signed and unsigned operands, so this is exact). + Non-finite / fractional / `>2^32` operands keep the `js_math_imul` runtime + helper so JS `ToUint32`/`ToInt32` semantics (`NaN`/`±Infinity` -> 0) are + preserved. This also fixes the accumulator case `a = Math.imul(a, K)` where + the constant `K` exceeds `i32::MAX` (e.g. the golden-ratio mixer constant + `0x9e3779b1` = 2654435761): the i32 fast path now accepts integer literals + representable in 32 bits under either a signed or unsigned interpretation. + - **Reading a typed-array element through a parameter** (`S[i]` where + `S: Int32Array` etc. is a function parameter, in an i32/`ToInt32` context) + now lowers to a checked inline native load — a runtime guard (pointer + + inline-storage `PERRY_TA_VIEW_GUARD` + kind-cache) and a header-length + bounds check gate a bare width-correct load, an in-kind out-of-bounds read + yields `0` (`== ToInt32(undefined)`, the only observable value in that + context), and every rejected shape (view/detached/resizable backing, wrong + runtime kind) defers to the new `js_typed_array_read_int32` runtime + fallback. Perry already emitted bare loads for typed-array *locals* with + proven bounds (#6750); this extends the recognition to *parameters*, whose + length and storage are unknown at compile time. Plain-value parameter reads + still observe `undefined` out of bounds. + - On a 40M-iteration `Int32Array`-parameter bit-mixer that combines both + primitives, the two fallbacks previously cascaded the whole hot loop into + slow f64 `ToInt32` towers (`js_math_imul` x3, `js_typed_array_get` x3, + ~60 `sitofp`/`fptosi`/`select`); both runtime-call families now reach zero + and the read/multiply chain stays in native i32. diff --git a/crates/perry-codegen/src/expr/i32_fast_path.rs b/crates/perry-codegen/src/expr/i32_fast_path.rs index cdfa7b8d49..c71593af0c 100644 --- a/crates/perry-codegen/src/expr/i32_fast_path.rs +++ b/crates/perry-codegen/src/expr/i32_fast_path.rs @@ -15,7 +15,7 @@ use crate::native_value::{ use crate::type_analysis::{ expr_may_return_boxed_value_from_raw_f64_fallback, is_definitely_string_expr, is_numeric_expr, }; -use crate::types::{DOUBLE, F32, I32, I64}; +use crate::types::{DOUBLE, F32, I1, I16, I32, I64, I8}; /// Returns true if `e` provably produces a finite double whose magnitude is /// small enough (`|v| < 2^63`) for the unguarded `toint32_fast` lowering. @@ -266,6 +266,44 @@ pub(crate) fn try_flat_const_2d_int(e: &Expr) -> Option<(usize, usize, Vec) /// collapses). We only commit to the fast path when every leaf is /// recognizably int-sourced so the overall rhs lowers to a short chain of /// `add/sub/mul i32` instructions. +/// An integer literal is usable as an i32 leaf of an i32-native chain when its +/// value fits in 32 bits under EITHER a signed or an unsigned interpretation. +/// The i32 lowering truncates to the low 32 bits (`*n as i32`), and every +/// combining operator in an i32 chain — add/sub/mul, bitwise, shift, and +/// `Math.imul` — preserves low-32-bit two's-complement semantics, so a `>i32::MAX` +/// bit-mask/hash multiplier such as `0x9e3779b1` (2654435761) lowers to the +/// correct `mul i32` operand instead of falling off the fast path. Values that +/// exceed 32 bits (e.g. `2**32+3`) stay off the fast path so the runtime helper +/// applies JS `ToUint32`/`ToInt32` first. +fn integer_is_i32_bit_representable(n: i64) -> bool { + i32::try_from(n).is_ok() || u32::try_from(n).is_ok() +} + +/// A `Math.imul` operand is i32-lowerable in the current region when it is any +/// ordinary i32-native expression OR a 32-bit-representable integer literal. +/// The literal relaxation is confined to `Math.imul` — whose result is defined +/// as `ToInt32(ToUint32 * ToUint32 mod 2^32)` — because only there is the low-32 +/// truncation of a `>i32::MAX` literal exact (unlike a plain `*`, whose product +/// is evaluated in f64 and loses precision above 2^53). +pub(crate) fn imul_operand_i32_lowerable_in_current_region(ctx: &FnCtx<'_>, e: &Expr) -> bool { + matches!(e, Expr::Integer(n) if integer_is_i32_bit_representable(*n)) + || can_lower_expr_as_i32_in_current_region(ctx, e) +} + +/// Lower a `Math.imul` operand to an i32 SSA value. A 32-bit-representable +/// integer literal is truncated directly (`*n as i32` == its ToInt32, since a +/// JS numeric literal that fits in 32 bits is exact in f64) so the multiply +/// stays a clean `mul i32` with a constant operand instead of a fold-only +/// `fptosi`; every other operand routes through the normal i32-native lowering. +pub(crate) fn lower_imul_operand_i32(ctx: &mut FnCtx<'_>, e: &Expr) -> Result { + if let Expr::Integer(n) = e { + if integer_is_i32_bit_representable(*n) { + return Ok((*n as i32).to_string()); + } + } + Ok(lower_expr_native_i32(ctx, e)?.value) +} + pub(crate) fn can_lower_expr_as_i32( e: &Expr, i32_slots: &std::collections::HashMap, @@ -278,31 +316,35 @@ pub(crate) fn can_lower_expr_as_i32( i32_identity_fns: &std::collections::HashSet, ) -> bool { match e { + // Strict i32 range for a general leaf: a `>i32::MAX` literal must NOT + // enter an arbitrary i32 chain. In particular `x * BIGLIT | 0` computes + // the product in f64 (JS `*`), which loses precision above 2^53, so an + // exact `mul i32` would diverge from `ToInt32(f64_product)`. Only + // `Math.imul` (below) and the runtime helper interpret the operand under + // exact-low-32 semantics. Expr::Integer(n) => i32::try_from(*n).is_ok(), Expr::LocalGet(id) => i32_slots.contains_key(id) || integer_locals.contains(id), Expr::Uint8ArrayGet { .. } | Expr::BufferIndexGet { .. } => true, Expr::MathImul(a, b) => { - can_lower_expr_as_i32( - a, - i32_slots, - flat_const_arrays, - array_row_aliases, - integer_locals, - clamp3_fns, - clamp_u8_fns, - integer_returning_fns, - i32_identity_fns, - ) && can_lower_expr_as_i32( - b, - i32_slots, - flat_const_arrays, - array_row_aliases, - integer_locals, - clamp3_fns, - clamp_u8_fns, - integer_returning_fns, - i32_identity_fns, - ) + // `Math.imul(x, y) == ToInt32(ToUint32(x) * ToUint32(y) mod 2^32)`, + // so an integer literal operand is exact under low-32 truncation + // even when it exceeds `i32::MAX` (e.g. the `0x9e3779b1` mixer + // constant). Accept 32-bit-representable literal operands here only. + let operand_ok = |e: &Expr| { + matches!(e, Expr::Integer(n) if integer_is_i32_bit_representable(*n)) + || can_lower_expr_as_i32( + e, + i32_slots, + flat_const_arrays, + array_row_aliases, + integer_locals, + clamp3_fns, + clamp_u8_fns, + integer_returning_fns, + i32_identity_fns, + ) + }; + operand_ok(a) && operand_ok(b) } Expr::Binary { op: BinaryOp::BitOr, @@ -448,6 +490,190 @@ fn ta_int_elem_load_is_i32_provable(ctx: &FnCtx<'_>, object: &Expr, index: &Expr super::bounds_for_buffer_access_width(ctx, *id, index, 1).allows_inbounds() } +/// Element kind of a statically-typed **integer** typed-array receiver eligible +/// for the *checked* inline i32 element load. Returns +/// `(runtime_kind_tag, elem_llvm_ty, signed, elem_size_bytes)` for the integer +/// kinds whose element widens into a signed i32 (I8/U8/U8Clamped/I16/U16/I32); +/// `None` for U32 / the float kinds and for any non-typed-array / non-local +/// receiver. +/// +/// Unlike [`ta_int_elem_load_is_i32_provable`], this requires NEITHER a tracked +/// buffer view NOR a static bounds proof — which is exactly what an +/// `Int32Array` **parameter** (`function f(S: Int32Array){ S[i] }`) lacks, since +/// its length and inline-vs-view storage are unknown at compile time. Soundness +/// comes from the *checked* emission ([`lower_checked_typed_array_i32_load`]): a +/// runtime guard (pointer + inline-storage `PERRY_TA_VIEW_GUARD==0` + kind-cache +/// match) and a header-length bounds check gate a bare load, an in-kind +/// out-of-bounds read yields `0` (`== ToInt32(undefined)`), and every rejected +/// shape (view/detached/resizable backing, wrong runtime kind) defers to the +/// full runtime `[[Get]]`+`ToInt32`. Returning `0` on OOB is exact *only* in the +/// i32/`ToInt32` consumer context this predicate participates in — the sole +/// observable value there — so it is confined to the i32-native fast path. +fn checked_typed_array_i32_kind( + ctx: &FnCtx<'_>, + object: &Expr, +) -> Option<(u8, crate::types::LlvmType, bool, u32)> { + if ctx.disable_buffer_fast_path { + return None; + } + // Must be a plain local/param read so the receiver value is re-fetched at + // every access — reassignment / closure capture stay correct because the + // emission caches nothing across accesses. + let Expr::LocalGet(id) = object else { + return None; + }; + // A tracked buffer view (proven-bounds unchecked path, or a Buffer param) + // owns this receiver; don't shadow it. + if ctx.buffer_view_slots.contains_key(id) { + return None; + } + match crate::type_analysis::receiver_class_name(ctx, object).as_deref()? { + "Int8Array" => Some((0, I8, true, 1)), + "Uint8Array" => Some((1, I8, false, 1)), + "Uint8ClampedArray" => Some((8, I8, false, 1)), + "Int16Array" => Some((2, I16, true, 2)), + "Uint16Array" => Some((3, I16, false, 2)), + "Int32Array" => Some((4, I32, false, 4)), + _ => None, + } +} + +/// Emit a *checked* inline i32 typed-array element load for an integer-kind +/// receiver whose storage/length is not statically known (a typed-array +/// parameter). Mirrors the runtime `TypedArrayHeader` layout (length `u32` at +/// offset 0, inline data at offset 16) and the process-global fast-path facts +/// (`PERRY_TA_VIEW_GUARD`, `PERRY_TA_KIND_CACHE`). Hot path is a bare native +/// load; a genuine in-kind out-of-bounds read merges in `0`; every guard miss +/// defers to `js_typed_array_read_int32`. See [`checked_typed_array_i32_kind`] +/// for the soundness argument. Callers must have proven the receiver eligible +/// via that predicate. +fn lower_checked_typed_array_i32_load( + ctx: &mut FnCtx<'_>, + object: &Expr, + index: &Expr, + kind: u8, + elem_ty: crate::types::LlvmType, + signed: bool, + elem_size: u32, +) -> Result { + let obj_box = lower_expr(ctx, object)?; + let idx_i32 = lower_expr_as_i32(ctx, index)?; + + let chk_idx = ctx.new_block("cta.get.chk"); + let load_idx = ctx.new_block("cta.get.load"); + let oob_idx = ctx.new_block("cta.get.oob"); + let slow_idx = ctx.new_block("cta.get.slow"); + let merge_idx = ctx.new_block("cta.get.merge"); + let chk_label = ctx.block_label(chk_idx); + let load_label = ctx.block_label(load_idx); + let oob_label = ctx.block_label(oob_idx); + let slow_label = ctx.block_label(slow_idx); + let merge_label = ctx.block_label(merge_idx); + + let tag_mask = crate::nanbox::i64_literal(crate::nanbox::TAG_MASK); + + // ---- entry guard: pointer + inline-storage + kind-cache addr/kind ---- + let raw = { + let blk = ctx.block(); + let obj_bits = blk.bitcast_double_to_i64(&obj_box); + let raw = blk.and(I64, &obj_bits, crate::nanbox::POINTER_MASK_I64); + let tagged = blk.and(I64, &obj_bits, &tag_mask); + let is_ptr = blk.icmp_eq(I64, &tagged, crate::nanbox::POINTER_TAG_I64); + // View guard 0 => every live typed array uses inline storage, so + // `data == header + 16`. Any view/native-arena backing bumps it, + // routing such receivers to the slow path. + let vg = blk.load(I64, "@PERRY_TA_VIEW_GUARD"); + let vg_zero = blk.icmp_eq(I64, &vg, "0"); + // Kind-cache probe: slot = (raw >> 3) & 63; entry = (addr << 8) | kind. + let slot = blk.lshr(I64, &raw, "3"); + let slot = blk.and(I64, &slot, "63"); + let entry_ptr = blk.gep( + "[64 x i64]", + "@PERRY_TA_KIND_CACHE", + &[(I64, "0"), (I64, &slot)], + ); + let entry_val = blk.load(I64, &entry_ptr); + let entry_addr = blk.lshr(I64, &entry_val, "8"); + let addr_match = blk.icmp_eq(I64, &entry_addr, &raw); // also rejects empty slot 0 + let kind_bits = blk.and(I64, &entry_val, "255"); + let kind_ok = blk.icmp_eq(I64, &kind_bits, &kind.to_string()); + let g = blk.and(I1, &is_ptr, &vg_zero); + let g = blk.and(I1, &g, &addr_match); + let g = blk.and(I1, &g, &kind_ok); + blk.cond_br(&g, &chk_label, &slow_label); + raw + }; + + // ---- chk: bounds check against header length (u32 at offset 0) ---- + ctx.current_block = chk_idx; + { + let blk = ctx.block(); + let hdr_ptr = blk.inttoptr(I64, &raw); + let len = blk.load(I32, &hdr_ptr); + // `ult` also rejects a negative i32 index (wraps to a huge unsigned) — + // matching JS: `S[-1]` is undefined -> ToInt32 -> 0 (via the oob arm). + let in_bounds = blk.icmp_ult(I32, &idx_i32, &len); + blk.cond_br(&in_bounds, &load_label, &oob_label); + } + + // ---- load: bare per-kind element load (data base = raw + 16) ---- + ctx.current_block = load_idx; + let (load_val, load_end) = { + let blk = ctx.block(); + let data_base = blk.add(I64, &raw, "16"); + let idx_i64 = blk.zext(I32, &idx_i32, I64); + let shift = elem_size.trailing_zeros().to_string(); + let off = blk.shl(I64, &idx_i64, &shift); + let addr = blk.add(I64, &data_base, &off); + let ptr = blk.inttoptr(I64, &addr); + let raw_elem = blk.load(elem_ty, &ptr); + let val = if elem_size == 4 { + raw_elem // i32 element: already the target width + } else if signed { + blk.sext(elem_ty, &raw_elem, I32) + } else { + blk.zext(elem_ty, &raw_elem, I32) + }; + let end = blk.label.clone(); + blk.br(&merge_label); + (val, end) + }; + + // ---- oob: in-kind out-of-bounds -> 0 (== ToInt32(undefined)) ---- + ctx.current_block = oob_idx; + let oob_end = { + let blk = ctx.block(); + let end = blk.label.clone(); + blk.br(&merge_label); + end + }; + + // ---- slow: view / detached / wrong-kind -> full runtime read+ToInt32 ---- + ctx.current_block = slow_idx; + let (slow_val, slow_end) = { + let blk = ctx.block(); + let v = blk.call( + I32, + "js_typed_array_read_int32", + &[(I64, &raw), (I32, &idx_i32)], + ); + let end = blk.label.clone(); + blk.br(&merge_label); + (v, end) + }; + + // ---- merge ---- + ctx.current_block = merge_idx; + Ok(ctx.block().phi( + I32, + &[ + (load_val.as_str(), load_end.as_str()), + ("0", oob_end.as_str()), + (slow_val.as_str(), slow_end.as_str()), + ], + )) +} + fn packed_i32_loop_index_get_fact(ctx: &FnCtx<'_>, e: &Expr) -> Option { let Expr::IndexGet { object, index } = e else { return None; @@ -504,8 +730,8 @@ pub(crate) fn can_lower_expr_as_i32_in_current_region(ctx: &FnCtx<'_>, e: &Expr) } match e { Expr::MathImul(left, right) => { - can_lower_expr_as_i32_in_current_region(ctx, left) - && can_lower_expr_as_i32_in_current_region(ctx, right) + imul_operand_i32_lowerable_in_current_region(ctx, left) + && imul_operand_i32_lowerable_in_current_region(ctx, right) } Expr::Binary { op: BinaryOp::BitOr, @@ -545,6 +771,14 @@ pub(crate) fn can_lower_expr_as_i32_in_current_region(ctx: &FnCtx<'_>, e: &Expr) Expr::IndexGet { object, index } => { ta_int_elem_load_is_i32_provable(ctx, object, index) || super::masked_window::masked_window_i32_load_is_provable(ctx, object, index) + // The checked-kind fast path lowers `index` through `fptosi` + // (ToInt32), so a fractional index like `S[3.9]` would read + // element 3 — JS reads a fractional typed-array index as + // `undefined` (→ 0 in this ToInt32 consumer). Only take it with a + // proven integer index (the same gate the sibling typed-array + // read paths use in `index_get.rs`). + || (checked_typed_array_i32_kind(ctx, object).is_some() + && super::index_get::numeric_index_has_integer_array_index_proof(ctx, index)) } _ => false, } @@ -666,8 +900,8 @@ fn try_lower_expr_native_i32_structural(ctx: &mut FnCtx<'_>, e: &Expr) -> Result .cloned() .map(|slot| ctx.block().load(I32, &slot)), Expr::MathImul(a, b) => { - let l = lower_expr_native_i32(ctx, a)?.value; - let r = lower_expr_native_i32(ctx, b)?.value; + let l = lower_imul_operand_i32(ctx, a)?; + let r = lower_imul_operand_i32(ctx, b)?; Some(ctx.block().mul(I32, &l, &r)) } Expr::Binary { @@ -761,8 +995,18 @@ fn try_lower_expr_native_i32_structural(ctx: &mut FnCtx<'_>, e: &Expr) -> Result if ta_int_elem_load_is_i32_provable(ctx, object, index) { super::lower_typed_array_load(ctx, object, index)? .map(|lowered| i32_from_indexed_get_lowered(ctx, lowered)) - } else { + } else if let Some(v) = super::masked_window::lower_masked_window_index_get_i32(ctx, object, index)? + { + Some(v) + } else if let Some((kind, elem_ty, signed, elem_size)) = + checked_typed_array_i32_kind(ctx, object) + { + Some(lower_checked_typed_array_i32_load( + ctx, object, index, kind, elem_ty, signed, elem_size, + )?) + } else { + None } } _ => None, @@ -1058,8 +1302,8 @@ fn lower_expr_native_i32(ctx: &mut FnCtx<'_>, e: &Expr) -> Result } // Math.imul(a, b) → single `mul i32` instruction. Expr::MathImul(a, b) => { - let l = lower_expr_native_i32(ctx, a)?.value; - let r = lower_expr_native_i32(ctx, b)?.value; + let l = lower_imul_operand_i32(ctx, a)?; + let r = lower_imul_operand_i32(ctx, b)?; ctx.block().mul(I32, &l, &r) } Expr::Binary { diff --git a/crates/perry-codegen/src/expr/index_get.rs b/crates/perry-codegen/src/expr/index_get.rs index 8b2d05fb4c..9251185e4e 100644 --- a/crates/perry-codegen/src/expr/index_get.rs +++ b/crates/perry-codegen/src/expr/index_get.rs @@ -48,7 +48,7 @@ fn is_uint8array_receiver(ctx: &FnCtx<'_>, object: &Expr) -> bool { ) } -fn numeric_index_has_integer_array_index_proof(ctx: &FnCtx<'_>, index: &Expr) -> bool { +pub(crate) fn numeric_index_has_integer_array_index_proof(ctx: &FnCtx<'_>, index: &Expr) -> bool { fn range_is_nonnegative_i32(ctx: &FnCtx<'_>, index: &Expr) -> bool { int_range_expr(ctx, index) .is_some_and(|range| range.min >= 0 && range.max <= i32::MAX as i64) diff --git a/crates/perry-codegen/src/expr/math_simple.rs b/crates/perry-codegen/src/expr/math_simple.rs index e1faf33f83..9cc593a92d 100644 --- a/crates/perry-codegen/src/expr/math_simple.rs +++ b/crates/perry-codegen/src/expr/math_simple.rs @@ -12,8 +12,9 @@ use crate::type_analysis::{is_definitely_string_expr, is_numeric_expr, map_stati use crate::types::{DOUBLE, F32, I1, I32, I64}; use super::{ - can_lower_expr_as_i32, lower_expr, lower_expr_native, lower_math_operand, - nanbox_pointer_inline, nanbox_string_inline, record_collection_number_key_fallback, + can_lower_expr_as_i32, imul_operand_i32_lowerable_in_current_region, lower_expr, + lower_expr_native, lower_imul_operand_i32, lower_math_operand, nanbox_pointer_inline, + nanbox_string_inline, record_collection_number_key_fallback, record_collection_number_key_selected, record_collection_string_key_fallback, record_collection_string_key_selected, record_collection_string_key_value_selected, record_collection_typed_value_fallback, record_collection_typed_value_selected, @@ -416,9 +417,21 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } // -------- Math.imul — 32-bit wrapping integer multiply -------- - // Route through the runtime helper so non-finite inputs use JS - // ToInt32 semantics (`NaN`/±Infinity -> 0) instead of LLVM fptosi. + // Lower to a single native `mul i32` when BOTH operands are provably + // in-range i32: multiplication mod 2^32 has identical low 32 bits for + // signed and unsigned operands, so `mul i32(ToInt32(a), ToInt32(b))` + // is exact. Arbitrary operands (`NaN`/±Infinity/fractional/`>2^32`) + // MUST keep the runtime helper — a bare `fptosi` would violate JS + // ToUint32/ToInt32 semantics (`NaN`->0, `Inf`->0, truncation). Expr::MathImul(a, b) => { + if imul_operand_i32_lowerable_in_current_region(ctx, a) + && imul_operand_i32_lowerable_in_current_region(ctx, b) + { + let a_i32 = lower_imul_operand_i32(ctx, a)?; + let b_i32 = lower_imul_operand_i32(ctx, b)?; + let r = ctx.block().mul(I32, &a_i32, &b_i32); + return Ok(ctx.block().sitofp(I32, &r, DOUBLE)); + } let av = lower_expr(ctx, a)?; let bv = lower_expr(ctx, b)?; Ok(ctx diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 888497a281..fa8e70c184 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -77,9 +77,10 @@ pub(crate) use helpers::{ unbox_to_i64, }; pub(crate) use i32_fast_path::{ - can_lower_expr_as_i32, can_lower_expr_as_i32_in_current_region, is_known_finite, - lower_expr_as_i32, lower_expr_native, lower_packed_u32_loop_index_get, try_flat_const_2d_int, - try_lower_flat_const_index_get, + can_lower_expr_as_i32, can_lower_expr_as_i32_in_current_region, + imul_operand_i32_lowerable_in_current_region, is_known_finite, lower_expr_as_i32, + lower_expr_native, lower_imul_operand_i32, lower_packed_u32_loop_index_get, + try_flat_const_2d_int, try_lower_flat_const_index_get, }; pub(crate) use index::lower_index_set_fast; pub(crate) use nanbox_inline::{ diff --git a/crates/perry-codegen/src/runtime_decls/strings_part2.rs b/crates/perry-codegen/src/runtime_decls/strings_part2.rs index e1a5261389..8e8a26894d 100644 --- a/crates/perry-codegen/src/runtime_decls/strings_part2.rs +++ b/crates/perry-codegen/src/runtime_decls/strings_part2.rs @@ -102,6 +102,9 @@ pub(crate) fn declare_phase_b_strings_part2(module: &mut LlModule) { module.declare_function("js_typed_array_view", I64, &[I32, DOUBLE, DOUBLE, DOUBLE]); module.declare_function("js_typed_array_length", I32, &[I64]); module.declare_function("js_typed_array_get", DOUBLE, &[I64, I32]); + // Cold fallback for the inline checked-i32 typed-array element read + // (returns ToInt32 of the element, or 0 for OOB / view / wrong-kind). + module.declare_function("js_typed_array_read_int32", I32, &[I64, I32]); // #2063: string / dynamic-key `ta[key]` [[Get]] dispatcher (canonical // numeric index → element, else ordinary named-property [[Get]]). module.declare_function("js_typed_array_index_get_dynamic", DOUBLE, &[I64, DOUBLE]); diff --git a/crates/perry-runtime/src/typedarray/access.rs b/crates/perry-runtime/src/typedarray/access.rs index c6cbe7396f..f1e1aca2f3 100644 --- a/crates/perry-runtime/src/typedarray/access.rs +++ b/crates/perry-runtime/src/typedarray/access.rs @@ -43,6 +43,54 @@ pub extern "C" fn js_typed_array_get(ta: *const TypedArrayHeader, index: i32) -> } } +/// Cold fallback for the codegen inline **checked i32** typed-array element read +/// (integer-kind receivers reached through an erased / typed parameter — e.g. +/// `function f(S: Int32Array){ return S[i] | 0 }`). The inline path serves the +/// overwhelmingly common inline-storage, correct-kind, in-bounds case with a +/// bare native load, and yields `0` directly for a genuine in-kind out-of-bounds +/// read (`== ToInt32(undefined)`, the only observable value in the i32/ToInt32 +/// consumer context that path serves). It routes here only when its guard +/// rejects the access — a view/detached/resizable backing +/// (`PERRY_TA_VIEW_GUARD != 0`), a kind-cache miss, or a receiver that is not the +/// statically-expected kind. This helper performs the full ECMAScript +/// IntegerIndexedExotic `[[Get]]` (bounds-checked, view-aware, detach-safe) and +/// applies `ToInt32` to the result (`undefined` / non-finite -> `0`). Because it +/// is only ever consumed where the surrounding expression `ToInt32`s the value, +/// returning the i32 directly is exact. +#[no_mangle] +pub extern "C" fn js_typed_array_read_int32(ta: *const TypedArrayHeader, index: i32) -> i32 { + // Memory safety: this cold fallback is entered on a kind-cache miss / wrong + // runtime kind, which INCLUDES a receiver that is not actually a typed array + // — TS types are erased, so `function f(S: Int32Array){ S[i] }` compiles the + // statically-emitted checked path but may be called with an arbitrary value. + // `js_typed_array_get` would read `(*ta).length` (a `TypedArrayHeader` field) + // before classifying the pointer, type-confusing the first GC-header read. + // Validate the raw pointer is a registered typed array first (the same gate + // `strict_typed_array_from_raw` uses — it covers native/inline views); a + // non-typed-array receiver has no element to read, and + // `ToInt32(undefined) == 0` in this i32 consumer context. + let ta = clean_ta_ptr(ta); + if ta.is_null() || lookup_typed_array_kind(ta as usize).is_none() { + return 0; + } + let v = js_typed_array_get(ta, index); + // `js_typed_array_get` returns a plain finite f64 element for an in-bounds + // read and TAG_UNDEFINED (a NaN) for OOB. `ToInt32` maps NaN / ±Inf -> 0. + if !v.is_finite() { + return 0; + } + const TWO_32: f64 = 4_294_967_296.0; + (v.trunc().rem_euclid(TWO_32) as u32) as i32 +} + +// Codegen-only export: the inline checked-i32 read emits the call in +// `perry-codegen/src/expr/i32_fast_path.rs`; a whole-program bitcode link is +// otherwise free to internalize and dead-strip it (it has no internal Rust +// caller). The `#[used]` anchor pins it, mirroring the getter above. +#[used] +static KEEP_JS_TYPED_ARRAY_READ_INT32: extern "C" fn(*const TypedArrayHeader, i32) -> i32 = + js_typed_array_read_int32; + /// #2063 — dynamic / string-key `[[Get]]` on a TypedArray (`ta[key]`). /// /// The codegen element-read fast path only fires for statically-proven diff --git a/test-files/test_gap_math_imul_native.ts b/test-files/test_gap_math_imul_native.ts new file mode 100644 index 0000000000..70356a2cc3 --- /dev/null +++ b/test-files/test_gap_math_imul_native.ts @@ -0,0 +1,76 @@ +// Math.imul lowering to a native `mul i32` when both operands are provably +// in-range i32 (perry-codegen expr/math_simple.rs generic arm + +// expr/i32_fast_path.rs i32-native / accumulator path). Multiplication mod 2^32 +// has identical low 32 bits for signed and unsigned operands, so the native +// path is exact — but only for provable i32 operands. Non-finite / fractional / +// >2^32 operands MUST keep JS ToUint32/ToInt32 semantics via the runtime +// helper. Every result must match `node --experimental-strip-types` exactly. + +// --- Edge cases the native path must NOT take (kept on the runtime helper) --- +console.log(Math.imul(NaN, 5)); // 0 (NaN -> ToInt32 -> 0) +console.log(Math.imul(Infinity, 5)); // 0 +console.log(Math.imul(-Infinity, 3)); // 0 +console.log(Math.imul(1.9, 2)); // 2 (1.9 -> ToInt32 -> 1) +console.log(Math.imul(2 ** 32 + 3, 1)); // 3 (ToUint32(2^32+3) = 3) + +// --- Boundary / >i32::MAX constants the native path handles exactly --- +console.log(Math.imul(0x7fffffff, 2)); // -2 (wraps at i32 boundary) +console.log(Math.imul(0xffffffff, 5)); // -5 (0xffffffff -> -1 as i32) +console.log(Math.imul(-5, -3)); // 15 +console.log(Math.imul(0x9e3779b1, 3)); // multiplier > i32::MAX +console.log(Math.imul(0x9e3779b1 | 0, 0x85ebca6b | 0)); + +// --- Nested native imul (result of imul is itself a provable i32) --- +console.log(Math.imul(Math.imul(3, 7), 5)); // 105 + +// --- Variable i32 operands --- +let p = 123456789 | 0; +let q = -987654321 | 0; +console.log(Math.imul(p, q)); + +// --- The i32-accumulator chain: `a = Math.imul(a, K)` on a local with an i32 +// slot, whose constant K exceeds i32::MAX — the exact shape that failed to +// lower before the Integer-gate fix (0x9e3779b1 = 2654435761 > i32::MAX). --- +function mix(x: number): number { + let a = x | 0; + a = Math.imul(a, 0x9e3779b1); + a = (a ^ (a >>> 15)) | 0; + a = Math.imul(a, 0x85ebca6b); + a = (a ^ (a >>> 13)) | 0; + a = Math.imul(a, 0xc2b2ae35); + a = (a ^ (a >>> 16)) | 0; + return a | 0; +} +let acc = 0 | 0; +for (let i = 0; i < 5000; i++) acc = (acc ^ mix(acc ^ i)) | 0; +console.log(acc); + +// --- imul feeding `| 0` and arithmetic, in a tight loop (hash-like) --- +function fnv1aish(seed: number): number { + let h = seed | 0; + for (let i = 0; i < 32; i++) { + h = (h ^ i) | 0; + h = Math.imul(h, 0x01000193); // 16777619, a prime > i16 but < i32 + } + return h | 0; +} +console.log(fnv1aish(0x811c9dc5 | 0)); +console.log(fnv1aish(1), fnv1aish(-1), fnv1aish(0)); + +// --- Scoping guard: the 32-bit-literal relaxation is confined to Math.imul. +// A plain `*` computes its product in f64 (precision loss above 2^53), so +// `x * BIGLIT | 0` must NOT be lowered to an exact `mul i32` — it must stay +// `ToInt32(f64_product)`, matching Node. `+`/`-`/bitwise with a >i32::MAX +// literal stay f64-exact too. --- +let g = 5 | 0; +g = (g + 3000000000) | 0; +console.log(g); // Add: sum < 2^53, exact +g = (g * 2654435761) | 0; +console.log(g); // Mul: product > 2^53 -> f64 rounding, NOT exact mul i32 +g = (g ^ 0x9e3779b1) | 0; +console.log(g); // bitwise with >i32::MAX literal +console.log((123456789 * 2654435761) | 0); // large product | 0 +console.log((0xffffffff * 0xffffffff) | 0); // 2^64-ish product | 0 +console.log((2000000000 * 2000000000) | 0); // two large i32 values +console.log((1000003 * 1000033) | 0); // product < 2^53 (exact either way) + diff --git a/test-files/test_gap_typedarray_param_read.ts b/test-files/test_gap_typedarray_param_read.ts new file mode 100644 index 0000000000..ca8412232b --- /dev/null +++ b/test-files/test_gap_typedarray_param_read.ts @@ -0,0 +1,153 @@ +// Reading a typed-array element through a *parameter* (erased length / storage). +// perry-codegen expr/i32_fast_path.rs lowers an i32/ToInt32-context read of a +// typed-array PARAM to a checked inline native load (runtime guard: pointer + +// inline-storage PERRY_TA_VIEW_GUARD + kind-cache; header-length bounds check; +// bare load; 0 on in-kind OOB == ToInt32(undefined); slow fallback +// js_typed_array_read_int32 for view/detached/wrong-kind). A plain-value read +// still observes `undefined` OOB. Every line must match +// `node --experimental-strip-types` exactly. + +// ---- i32-context (bitwise) reads, one per integer kind, in a loop ---- +function xorI32(S: Int32Array, n: number): number { + let a = 0 | 0; + for (let i = 0; i < n; i++) a = (a ^ S[i & 7]) | 0; + return a | 0; +} +function xorI8(S: Int8Array, n: number): number { + let a = 0 | 0; + for (let i = 0; i < n; i++) a = (a ^ S[i & 7]) | 0; + return a | 0; +} +function xorU8(S: Uint8Array, n: number): number { + let a = 0 | 0; + for (let i = 0; i < n; i++) a = (a ^ S[i & 7]) | 0; + return a | 0; +} +function xorU8C(S: Uint8ClampedArray, n: number): number { + let a = 0 | 0; + for (let i = 0; i < n; i++) a = (a ^ S[i & 7]) | 0; + return a | 0; +} +function xorI16(S: Int16Array, n: number): number { + let a = 0 | 0; + for (let i = 0; i < n; i++) a = (a ^ S[i & 7]) | 0; + return a | 0; +} +function xorU16(S: Uint16Array, n: number): number { + let a = 0 | 0; + for (let i = 0; i < n; i++) a = (a ^ S[i & 7]) | 0; + return a | 0; +} +function xorU32(S: Uint32Array, n: number): number { + let a = 0 | 0; + for (let i = 0; i < n; i++) a = (a ^ S[i & 7]) | 0; + return a | 0; +} + +const i32 = Int32Array.from([-5, 100000, -2000000000, 7, 0x7fffffff, -1, 42, 999]); +const i8 = Int8Array.from([-5, 100, -128, 7, 127, -1, 42, 99]); +const u8 = Uint8Array.from([1, 200, 255, 7, 128, 0, 42, 99]); +const u8c = Uint8ClampedArray.from([1, 200, 255, 7, 128, 0, 42, 99]); +const i16 = Int16Array.from([-5, 30000, -32768, 7, 32767, -1, 42, 999]); +const u16 = Uint16Array.from([1, 60000, 65535, 7, 32768, 0, 42, 999]); +const u32 = Uint32Array.from([1, 4000000000, 0xffffffff, 7, 0x80000000, 0, 42, 999]); + +console.log("i32", xorI32(i32, 8)); +console.log("i8", xorI8(i8, 8)); +console.log("u8", xorU8(u8, 8)); +console.log("u8c", xorU8C(u8c, 8)); +console.log("i16", xorI16(i16, 8)); +console.log("u16", xorU16(u16, 8)); +console.log("u32", xorU32(u32, 8)); + +// ---- OOB in i32-context: reads past length contribute 0 (ToInt32(undefined)) ---- +function xorOob(S: Int32Array): number { + let a = 12345 | 0; + for (let i = 0; i < 16; i++) a = (a ^ S[i]) | 0; // i = 8..15 are OOB + return a | 0; +} +console.log("oob", xorOob(i32)); + +// A negative & fractional index in i32-context also read as 0. +function readMasked(S: Int32Array, i: number): number { + return (99 ^ S[i]) | 0; +} +console.log("neg", readMasked(i32, -1)); // S[-1] -> undefined -> 0 -> 99 ^ 0 +console.log("frac", readMasked(i32, 3.9)); // fractional -> undefined -> 0 +console.log("in", readMasked(i32, 3)); // in-bounds element 7 + +// ---- plain-value reads: OOB / negative / fractional must be `undefined` ---- +function readAt(S: Int32Array, i: number): number | undefined { + return S[i]; +} +console.log("v0", readAt(i32, 0), "v7", readAt(i32, 7)); +console.log("voob", readAt(i32, 8)); // undefined +console.log("vneg", readAt(i32, -1)); // undefined +console.log("vfrac", readAt(i32, 1.5)); // undefined +console.log("vstr", String(readAt(i32, 8))); // "undefined" +console.log("veq", readAt(i32, 8) === undefined); // true + +// ---- Float64Array param (element width != 4) ---- +function sumF64(S: Float64Array, n: number): number { + let s = 0; + for (let i = 0; i < n; i++) s += S[i]; + return s; +} +function readF64(S: Float64Array, i: number): number | undefined { + return S[i]; +} +function truncF64(S: Float64Array, i: number): number { + return S[i] | 0; // i32-context: float -> ToInt32 +} +const f64 = Float64Array.from([1.5, 2.25, -3.75, 100.125, 1e12 + 0.5]); +console.log("f64sum", sumF64(f64, 5)); +console.log("f64read", readF64(f64, 1), readF64(f64, 10)); +console.log("f64trunc", truncF64(f64, 0), truncF64(f64, 2), truncF64(f64, 4), truncF64(f64, 99)); + +// ---- Float32Array param (width 4, but float kind — stays on runtime read) ---- +function readF32(S: Float32Array, i: number): number | undefined { + return S[i]; +} +const f32 = Float32Array.from([0.5, -1.5, 2.5]); +console.log("f32", readF32(f32, 0), readF32(f32, 2), readF32(f32, 9)); + +// ---- view over an ArrayBuffer (non-inline storage -> slow fallback path) ---- +function viewXor(S: Int32Array, n: number): number { + let a = 0 | 0; + for (let i = 0; i < n; i++) a = (a ^ S[i]) | 0; + return a | 0; +} +function viewRead(S: Int32Array, i: number): number | undefined { + return S[i]; +} +const ab = new ArrayBuffer(16); +const view = new Int32Array(ab); +view[0] = 111; +view[1] = -222; +view[2] = 333; +view[3] = -444; +console.log("view", viewXor(view, 4), viewRead(view, 1), viewRead(view, 8)); + +// ---- detached buffer: reads are undefined (plain) / 0 (i32-context) ---- +const ab2 = new ArrayBuffer(16); +const det = new Int32Array(ab2); +det[0] = 7; +det[1] = 9; +console.log("predetach", viewRead(det, 0), viewXor(det, 4)); +ab2.transfer(); // detach ab2 (and its view `det`) +console.log("postdetach-plain", viewRead(det, 0)); // undefined +console.log("postdetach-i32", viewXor(det, 4)); // 0 (all OOB after detach) + +// ---- fractional index in i32 context must NOT take the checked native path ---- +// (regression: the fast path lowers the index via ToInt32, so `S[3.9]` would +// read element 3; JS reads a fractional typed-array index as undefined -> 0.) +function fracI32(S: Int32Array): number { + return S[3.9] | 0; +} +function fracVar(S: Int32Array, i: number): number { + return S[i] | 0; +} +const fr = new Int32Array([10, 20, 30, 40, 50]); +console.log("frac-lit", fracI32(fr)); // 0 (not 40) +console.log("frac-var", fracVar(fr, 2.5)); // 0 (not 30) +console.log("int-var", fracVar(fr, 3)); // 40 (integer var still fast+correct)