From 263cf3270cabc8318caf143bfcd0783ed8a2a4a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 24 Aug 2026 08:16:21 +0200 Subject: [PATCH] perf(codegen): guard direct Uint32Array RMW --- benchmarks/issue-8692/RESULTS.md | 66 +++ benchmarks/issue-8692/repro.js | 32 ++ changelog.d/8692-guarded-uint32-rmw.md | 7 + crates/perry-codegen/src/expr/index_set.rs | 8 + crates/perry-codegen/src/expr/mod.rs | 1 + .../perry-codegen/src/expr/typed_array_rmw.rs | 410 ++++++++++++++++++ .../tests/typed_array_rmw_8692.rs | 285 ++++++++++++ .../perry/src/commands/compile/build_cache.rs | 3 + test-files/test_issue_8692_typed_array_rmw.ts | 133 ++++++ .../test_issue_8692_typed_array_rmw_gc.ts | 28 ++ 10 files changed, 973 insertions(+) create mode 100644 benchmarks/issue-8692/RESULTS.md create mode 100644 benchmarks/issue-8692/repro.js create mode 100644 changelog.d/8692-guarded-uint32-rmw.md create mode 100644 crates/perry-codegen/src/expr/typed_array_rmw.rs create mode 100644 crates/perry-codegen/tests/typed_array_rmw_8692.rs create mode 100644 test-files/test_issue_8692_typed_array_rmw.ts create mode 100644 test-files/test_issue_8692_typed_array_rmw_gc.ts diff --git a/benchmarks/issue-8692/RESULTS.md b/benchmarks/issue-8692/RESULTS.md new file mode 100644 index 0000000000..d74dc27bf8 --- /dev/null +++ b/benchmarks/issue-8692/RESULTS.md @@ -0,0 +1,66 @@ +# Issue #8692 benchmark evidence + +Measured 2026-08-24 on an Apple M1 Max running Darwin 25.5.0. The issue +worktree is based on commit `8224d879a`; the baseline arm uses the same branch +build with `PERRY_TYPED_ARRAY_RMW=0`. Node is v26.5.1. All Perry inputs were +compiled with `PERRY_NO_AUTO_OPTIMIZE=1`, the release runtime, `--no-cache`, and +no PGO. + +## Reduced reproduction + +The input is `repro.js`: 1,000 `Uint32Array` elements, 2,000 iterations, and +2,000,000 total dynamic indexed updates. Both arms and Node returned checksum +`2000`. + +Protocol: three warmups followed by 11 alternating enabled/disabled process +pairs. Times below are medians of the elapsed time measured inside the program. +RSS is the median of three `/usr/bin/time -l` process runs. Binary sizes are +exact bytes. + +| Build | Median | Paired wins | RSS | Executable | +| --- | ---: | ---: | ---: | ---: | +| `PERRY_TYPED_ARRAY_RMW=0` | 80.315 ms | — | 13,500,416 B | 14,734,768 B | +| guarded direct RMW | 14.760 ms | 11/11 | 13,565,952 B | 14,734,768 B | +| Node v26.5.1 | 2.620 ms | — | — | — | + +The guarded lowering is **5.30x faster** than the disabled baseline. RSS rises +by 65,536 bytes (0.49%) and executable-size delta is zero. This result does not +claim Node parity. + +The optimized specialized function's `ta.rmw.load`/`ta.rmw.store` blocks contain +`load i32`, `uitofp`, `fadd`, and `store i32`; they contain none of +`js_typed_array_index_get_dynamic`, `js_dynamic_string_or_number_add`, or +`js_typed_array_index_set_dynamic`. The emitted IR retains a full generic +get/add/set block for precondition failure and a set-only block for post-RHS +invalidation. The native-representation artifact records +`TypedArrayRmw.guarded_direct_uint32_add` as `checked_native`, its exact-index and +bounds guard, the GC-visible receiver reload, and a separate explicit dynamic +fallback record. A compiler test also records the rejection reason +`rhs_not_canonical_number`. + +## `ecs-benchmark` simple iteration + +Source: `ooflorent/ecs-benchmark` at +`7b53a36606118e8b2a450a2ba4919939c86bbd2e`. Each wrapper imports the repository's +unchanged `simple_iter` case and calls `setup(1000)`. Iteration counts were +calibrated per case to keep Perry samples near the upstream harness's roughly +500 ms target. Node, enabled Perry, and disabled Perry all returned the same +case name, iteration count, and `semantic: "completed"` record. + +After one process warmup, seven enabled/disabled pairs ran simultaneously so +both arms saw the same shared-host load; child creation order alternated. RSS +was captured on every measured process. The table reports the median elapsed +time of each arm. The upstream README documents typical run-to-run variance of +1–4%, so the increases below are neutral. More decisively, the final Mach-O +`__text` section is byte-identical between enabled and disabled binaries for all +four cases: this optimization is not selected by these workloads. + +| Case | Iterations | Disabled | Enabled | Delta | Disabled / enabled RSS | Size delta | `__text` SHA-256 | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- | +| `wolf-ecs/simple_iter` | 250 | 254.540 ms | 259.059 ms | +1.78% | 202,113,024 / 202,244,096 B | 0 B | `b2987e304a8b402d699d80d145a1a1b01391e83591a36e130a4591ffcd9424f6` | +| `becsy/simple_iter` | 15 | 169.343 ms | 167.251 ms | -1.24% | 46,415,872 / 46,432,256 B | 0 B | `8bdfe53cc561edd2d268b5ec61939ca46bd2140f98303471496b81ab7f7302b1` | +| `javelin-ecs/simple_iter` | 15 | 284.079 ms | 287.036 ms | +1.04% | 93,716,480 / 93,749,248 B | 0 B | `ec44c45d2a942a29877795c85b68ed218b13c7c0f0073892ad90437485b5cc46` | +| `piecs/simple_iter` | 1,500 | 736.326 ms | 751.261 ms | +2.03% | 16,171,008 / 16,203,776 B | 0 B | `cd1d310b8ad30a1f90e6dba95ca82dbe4cd8d344188a3868f9744168a7336b8a` | + +Exact enabled/disabled executable sizes were respectively 14,949,648; +16,436,632; 26,191,360; and 14,966,232 bytes for the four rows. diff --git a/benchmarks/issue-8692/repro.js b/benchmarks/issue-8692/repro.js new file mode 100644 index 0000000000..f9834945ed --- /dev/null +++ b/benchmarks/issue-8692/repro.js @@ -0,0 +1,32 @@ +// Reduced wolf-ecs kernel from https://github.com/PerryTS/perry/issues/8692. +// Compile with `PERRY_NO_AUTO_OPTIMIZE=1` for the ticket's stable A/B protocol. +class Query extends Array { + archetypes = this; +} + +class Archetype extends Array { + entities = this; +} + +const entityCount = 1_000; +const iterations = 2_000; +const query = new Query(); +const archetype = new Archetype(); +for (let i = 0; i < entityCount; i++) archetype.push(i); +query.push(archetype); + +const components = new Uint32Array(entityCount); + +function system(values) { + for (let i = 0, length = query.length; i < length; i++) { + const current = query[i]; + for (let j = 0, length = current.length; j < length; j++) { + values[current[j]] += 1; + } + } +} + +const start = performance.now(); +for (let i = 0; i < iterations; i++) system(components); +const elapsedMs = performance.now() - start; +console.log(JSON.stringify({ elapsedMs, checksum: components[0] })); diff --git a/changelog.d/8692-guarded-uint32-rmw.md b/changelog.d/8692-guarded-uint32-rmw.md new file mode 100644 index 0000000000..156af11f9f --- /dev/null +++ b/changelog.d/8692-guarded-uint32-rmw.md @@ -0,0 +1,7 @@ +### Performance + +- Fuse dynamic-index `Uint32Array` numeric read-modify-write expressions behind + explicit representation, backing-store, exact-index, and bounds guards. The + hot arm keeps the load/add/store in native SSA, while precondition failure and + post-RHS invalidation retain full JavaScript evaluation order and conversion + semantics through explicit generic fallbacks. diff --git a/crates/perry-codegen/src/expr/index_set.rs b/crates/perry-codegen/src/expr/index_set.rs index 83ddf7c589..86c1b256de 100644 --- a/crates/perry-codegen/src/expr/index_set.rs +++ b/crates/perry-codegen/src/expr/index_set.rs @@ -754,6 +754,14 @@ pub(crate) fn lower( index, value, } => { + if let Some(result) = + super::typed_array_rmw::try_lower_guarded_uint32_add(ctx, object, index, value)? + { + if value_discarded { + return Ok(double_literal(0.0)); + } + return Ok(result); + } // Issue #611: `globalThis[] = value` writes to the // persistent global-this singleton (see the matching IndexGet // arm above for context). diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 1b91d93e68..56611f8111 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -2183,6 +2183,7 @@ mod index_set_guarded; mod index_set_typed_array; mod instance_misc1; mod member_update; +mod typed_array_rmw; pub(crate) use instance_misc1::builtin_parent_reserved_class_id; pub(crate) mod class_field_inline_guard; pub(crate) mod element_shape_guard; diff --git a/crates/perry-codegen/src/expr/typed_array_rmw.rs b/crates/perry-codegen/src/expr/typed_array_rmw.rs new file mode 100644 index 0000000000..949b2c696a --- /dev/null +++ b/crates/perry-codegen/src/expr/typed_array_rmw.rs @@ -0,0 +1,410 @@ +//! Guarded direct read-modify-write for a numeric typed-array element. +//! +//! Compound computed assignments are deliberately lowered through immutable +//! base/key temporaries by the HIR (`hoist_compound_member_assign`). That +//! preserves JavaScript's once-only reference evaluation, but it also hides a +//! specialized-entry `TaPtr` behind an `Any`-typed exact alias and turns +//! `values[key] += rhs` into three independently-lowered operations. Each +//! operation then loses a different part of the representation proof. +//! +//! This module recognizes the representation shape after those temporaries: +//! +//! ```text +//! base[key] = base[key] + numeric_rhs +//! ``` +//! +//! when `base` and `key` are immutable locals and `base` traces through exact +//! local aliases to a Uint32Array candidate. The runtime guard, rather than a +//! TypeScript annotation, proves pointer identity, inline storage, concrete +//! kind, an exact numeric index, and bounds. Guard failure runs the unchanged +//! generic get/add/set lowering. The RHS runs only after the direct load, and +//! the view/kind/bounds guard is checked again after the RHS; a failure there +//! performs only the pending set with the already-computed value, so user code +//! and abrupt completion are never repeated. + +use anyhow::Result; +use perry_hir::{BinaryOp, Expr}; + +use crate::nanbox::POINTER_MASK_I64; +use crate::native_value::{ + BoundsState, BufferAccessMode, BufferElem, ExpectedNativeRep, LoweredValue, + MaterializationReason, +}; +use crate::types::{DOUBLE, I1, I32, I64}; + +use super::{lower_expr, lower_expr_native, FnCtx}; + +const UINT32_KIND: u64 = 5; + +#[derive(Clone, Copy)] +struct Candidate<'a> { + object: &'a Expr, + index: &'a Expr, + rhs: &'a Expr, + receiver_id: u32, +} + +fn enabled() -> bool { + !matches!( + std::env::var("PERRY_TYPED_ARRAY_RMW").as_deref(), + Ok("0") | Ok("off") | Ok("false") | Ok("OFF") | Ok("FALSE") + ) +} + +fn local_id(expr: &Expr) -> Option { + match expr { + Expr::LocalGet(id) => Some(*id), + _ => None, + } +} + +fn exact_alias_root(ctx: &FnCtx<'_>, mut id: u32) -> u32 { + // The alias map is fail-closed and normally acyclic. Keep a small bound + // anyway so corrupted/debug HIR cannot hang codegen. + for _ in 0..64 { + let Some(next) = ctx.local_value_aliases.get(&id).copied() else { + break; + }; + if next == id { + break; + } + id = next; + } + id +} + +fn receiver_is_uint32_candidate(ctx: &FnCtx<'_>, id: u32) -> Option { + let root = exact_alias_root(ctx, id); + if ctx + .buffer_view_slots + .get(&root) + .is_some_and(|view| matches!(view.elem, BufferElem::U32)) + { + return Some(root); + } + matches!( + ctx.local_type_hint(&root), + Some(perry_hir::types::Type::Named(name)) if name == "Uint32Array" + ) + .then_some(root) +} + +fn match_shape<'a>( + ctx: &FnCtx<'_>, + object: &'a Expr, + index: &'a Expr, + value: &'a Expr, +) -> Option<(u32, &'a Expr)> { + let (object_id, index_id) = (local_id(object)?, local_id(index)?); + // These are the semantic condition that allows one reference snapshot to + // replace the two syntactic reads. The HIR-generated compound-assignment + // temps satisfy it, and ordinary immutable user locals do too. + if ctx.reassigned_locals.contains(&object_id) || ctx.reassigned_locals.contains(&index_id) { + return None; + } + let Expr::Binary { + op: BinaryOp::Add, + left, + right, + } = value + else { + return None; + }; + let Expr::IndexGet { + object: read_object, + index: read_index, + } = left.as_ref() + else { + return None; + }; + if local_id(read_object) != Some(object_id) || local_id(read_index) != Some(index_id) { + return None; + } + Some((object_id, right)) +} + +fn record_rejection(ctx: &mut FnCtx<'_>, receiver_id: u32, reason: &str) { + let rejected = LoweredValue::js_value("0.0".to_string()); + ctx.record_lowered_value_with_access_mode( + "TypedArrayRmw", + Some(receiver_id), + "TypedArrayRmw.rejected", + &rejected, + Some(BoundsState::Unknown), + None, + Some(BufferAccessMode::DynamicFallback), + Some(MaterializationReason::RuntimeApi), + false, + false, + vec![ + "typed_array_rmw=rejected".to_string(), + format!("typed_array_rmw_rejection={reason}"), + ], + ); +} + +/// Pointer/inline-storage/kind cache guard. Returns the unboxed header address +/// and the guard condition. The address is used only on a passing edge. +fn emit_receiver_guard(ctx: &mut FnCtx<'_>, object_box: &str) -> (String, String) { + let tag_mask = crate::nanbox::i64_literal(crate::nanbox::TAG_MASK); + let blk = ctx.block(); + let object_bits = blk.bitcast_double_to_i64(object_box); + let raw = blk.and(I64, &object_bits, POINTER_MASK_I64); + let tagged = blk.and(I64, &object_bits, &tag_mask); + let is_pointer = blk.icmp_eq(I64, &tagged, crate::nanbox::POINTER_TAG_I64); + let view_guard = blk.load(I64, "@PERRY_TA_VIEW_GUARD"); + let inline_storage = blk.icmp_eq(I64, &view_guard, "0"); + 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 = blk.load(I64, &entry_ptr); + let cached_addr = blk.lshr(I64, &entry, "8"); + let address_matches = blk.icmp_eq(I64, &cached_addr, &raw); + let kind = blk.and(I64, &entry, "255"); + let kind_matches = blk.icmp_eq(I64, &kind, &UINT32_KIND.to_string()); + let guard = blk.and(I1, &is_pointer, &inline_storage); + let guard = blk.and(I1, &guard, &address_matches); + (raw, blk.and(I1, &guard, &kind_matches)) +} + +fn emit_index_range_guard(ctx: &mut FnCtx<'_>, index_box: &str) -> String { + // Ordered comparisons reject NaN and every NaN-boxed non-number. The + // upper bound makes the following fptosi-to-i64 defined. + let ge_zero = ctx.block().fcmp("oge", index_box, "0.0"); + let below_u32_limit = ctx.block().fcmp("olt", index_box, "4294967296.0"); + ctx.block().and(I1, &ge_zero, &below_u32_limit) +} + +fn emit_safe_toint32_range_guard(ctx: &mut FnCtx<'_>, value: &str) -> String { + // LlBlock::toint32 implements truncation/modulo through an intermediate + // i64. LLVM fptosi is poison outside the signed-i64 range, so unusually + // large finite sums (and NaN/Infinity) take the set-only semantic fallback + // instead. The common Uint32 accumulator stays wholly inline. + let above_min = ctx.block().fcmp("oge", value, "-9223372036854775808.0"); + let below_max = ctx.block().fcmp("olt", value, "9223372036854775808.0"); + ctx.block().and(I1, &above_min, &below_max) +} + +fn emit_exact_and_bounds_guard( + ctx: &mut FnCtx<'_>, + raw: &str, + index_box: &str, +) -> (String, String) { + let index_i64 = ctx.block().fptosi(DOUBLE, index_box, I64); + let roundtrip = ctx.block().sitofp(I64, &index_i64, DOUBLE); + let exact = ctx.block().fcmp("oeq", &roundtrip, index_box); + let header_ptr = ctx.block().inttoptr(I64, raw); + let length = ctx.block().load(I32, &header_ptr); + let length_i64 = ctx.block().zext(I32, &length, I64); + let in_bounds = ctx.block().icmp_ult(I64, &index_i64, &length_i64); + (index_i64, ctx.block().and(I1, &exact, &in_bounds)) +} + +fn emit_generic_set( + ctx: &mut FnCtx<'_>, + object: &Expr, + index: &Expr, + value: &str, +) -> Result { + // Re-read the immutable reference temporaries after any allocating RHS; + // their slots are the GC-visible source of truth. + let object_box = lower_expr(ctx, object)?; + let index_box = lower_expr(ctx, index)?; + Ok(ctx.block().call( + DOUBLE, + "js_dyn_index_set", + &[(DOUBLE, &object_box), (DOUBLE, &index_box), (DOUBLE, value)], + )) +} + +/// Try the guarded Uint32Array `base[key] += numeric_rhs` lowering. +/// +/// `Ok(None)` means the expression is outside this representation contract and +/// the ordinary IndexSet lowering must remain byte-for-byte in charge. +pub(super) fn try_lower_guarded_uint32_add( + ctx: &mut FnCtx<'_>, + object: &Expr, + index: &Expr, + value: &Expr, +) -> Result> { + if !enabled() { + return Ok(None); + } + let Some((object_id, rhs)) = match_shape(ctx, object, index, value) else { + return Ok(None); + }; + let Some(receiver_id) = receiver_is_uint32_candidate(ctx, object_id) else { + return Ok(None); + }; + // `+` can concatenate or operate on BigInt. Admit only a value whose + // runtime representation is already proven to be a canonical Number; + // declared annotations are intentionally insufficient. + if !crate::type_analysis::expr_produces_canonical_raw_f64(ctx, rhs) { + record_rejection(ctx, receiver_id, "rhs_not_canonical_number"); + return Ok(None); + } + let candidate = Candidate { + object, + index, + rhs, + receiver_id, + }; + + // The HIR has already evaluated the source base and computed key once into + // immutable locals. Loading those values here therefore has no user-code + // effect and is the correct reference snapshot for both arms. + let object_box = lower_expr(ctx, candidate.object)?; + let index_box = lower_expr(ctx, candidate.index)?; + let (raw, receiver_ok) = emit_receiver_guard(ctx, &object_box); + let index_range_ok = emit_index_range_guard(ctx, &index_box); + let precheck_ok = ctx.block().and(I1, &receiver_ok, &index_range_ok); + + let convert_idx = ctx.new_block("ta.rmw.index.convert"); + let load_idx = ctx.new_block("ta.rmw.load"); + let full_fallback_idx = ctx.new_block("ta.rmw.full_fallback"); + let post_guard_idx = ctx.new_block("ta.rmw.post_rhs_guard"); + let store_idx = ctx.new_block("ta.rmw.store"); + let set_fallback_idx = ctx.new_block("ta.rmw.set_fallback"); + let merge_idx = ctx.new_block("ta.rmw.merge"); + let convert_label = ctx.block_label(convert_idx); + let load_label = ctx.block_label(load_idx); + let full_fallback_label = ctx.block_label(full_fallback_idx); + let post_guard_label = ctx.block_label(post_guard_idx); + let store_label = ctx.block_label(store_idx); + let set_fallback_label = ctx.block_label(set_fallback_idx); + let merge_label = ctx.block_label(merge_idx); + ctx.block() + .cond_br(&precheck_ok, &convert_label, &full_fallback_label); + + ctx.current_block = convert_idx; + let (index_i64, exact_and_in_bounds) = emit_exact_and_bounds_guard(ctx, &raw, &index_box); + ctx.block() + .cond_br(&exact_and_in_bounds, &load_label, &full_fallback_label); + + // Fast read and JS-number addition. Load before RHS evaluation: that + // ordering is observable when the RHS mutates the same element. + ctx.current_block = load_idx; + let old_value = { + let blk = ctx.block(); + let data_base = blk.add(I64, &raw, "16"); + let byte_offset = blk.shl(I64, &index_i64, "2"); + let address = blk.add(I64, &data_base, &byte_offset); + let ptr = blk.inttoptr(I64, &address); + let raw_u32 = blk.load(I32, &ptr); + blk.uitofp(I32, &raw_u32, DOUBLE) + }; + let rhs = lower_expr_native(ctx, candidate.rhs, ExpectedNativeRep::F64)?.value; + let sum = ctx.block().fadd(&old_value, &rhs); + ctx.block().br(&post_guard_label); + let fast_sum_end = ctx.block().label.clone(); + + // The RHS may expose/detach backing storage or otherwise invalidate the + // cache. Revalidate before deriving the store address. On failure only + // PutValue remains; the read and RHS must not be repeated. + ctx.current_block = post_guard_idx; + // The RHS can allocate and trigger a moving collection. Reload the + // immutable reference temporary from its GC-visible slot instead of + // retaining the pre-RHS NaN-boxed pointer SSA value. + let post_object_box = lower_expr(ctx, candidate.object)?; + let (post_raw, post_receiver_ok) = emit_receiver_guard(ctx, &post_object_box); + let (_, post_bounds_ok) = emit_exact_and_bounds_guard(ctx, &post_raw, &index_box); + let post_ok = ctx.block().and(I1, &post_receiver_ok, &post_bounds_ok); + let conversion_ok = emit_safe_toint32_range_guard(ctx, &sum); + let post_ok = ctx.block().and(I1, &post_ok, &conversion_ok); + ctx.block() + .cond_br(&post_ok, &store_label, &set_fallback_label); + + ctx.current_block = store_idx; + { + let blk = ctx.block(); + let data_base = blk.add(I64, &post_raw, "16"); + let byte_offset = blk.shl(I64, &index_i64, "2"); + let address = blk.add(I64, &data_base, &byte_offset); + let ptr = blk.inttoptr(I64, &address); + let wrapped = blk.toint32(&sum); + // GC_STORE_AUDIT(POINTER_FREE): Uint32Array backing bytes cannot hold + // a heap edge; ToUint32 shares the runtime's modulo-2^32 bit result. + blk.store(I32, &wrapped, &ptr); + blk.br(&merge_label); + } + let store_end = ctx.block().label.clone(); + + ctx.current_block = set_fallback_idx; + let set_fallback_value = emit_generic_set(ctx, candidate.object, candidate.index, &sum)?; + ctx.block().br(&merge_label); + let set_fallback_end = ctx.block().label.clone(); + + // Full semantic fallback: lower the original get/add tree unchanged, then + // perform the pending generic set. This owns non-number keys, OOB, + // fractional/negative/NaN indices, proxy/annotation lies, views, detached + // stores, and every abrupt-completion case. + ctx.current_block = full_fallback_idx; + let generic_sum = lower_expr(ctx, value)?; + let full_fallback_value = + emit_generic_set(ctx, candidate.object, candidate.index, &generic_sum)?; + ctx.block().br(&merge_label); + let full_fallback_end = ctx.block().label.clone(); + + ctx.current_block = merge_idx; + let result = ctx.block().phi( + DOUBLE, + &[ + (&sum, &store_end), + (&set_fallback_value, &set_fallback_end), + (&full_fallback_value, &full_fallback_end), + ], + ); + + let lowered = LoweredValue::f64(result.clone()); + ctx.record_lowered_value_with_access_mode( + "TypedArrayRmw", + Some(candidate.receiver_id), + "TypedArrayRmw.guarded_direct_uint32_add", + &lowered, + Some(BoundsState::Guarded { + guard_id: "typed_array_rmw_exact_index_and_bounds".to_string(), + }), + None, + Some(BufferAccessMode::CheckedNative), + None, + false, + false, + vec![ + "typed_array_rmw=selected".to_string(), + "typed_array_kind=Uint32Array".to_string(), + "typed_array_guard=pointer+inline_storage+kind_cache+exact_numeric_index+bounds" + .to_string(), + "post_rhs_guard=backing_store+kind+bounds".to_string(), + "post_rhs_receiver=reload_gc_visible_local".to_string(), + "uint32_conversion_guard=signed_i64_range".to_string(), + "full_fallback=generic_get+js_add+generic_set".to_string(), + "post_rhs_fallback=generic_set_without_repeating_rhs".to_string(), + ], + ); + let fallback = LoweredValue::js_value(generic_sum); + ctx.record_lowered_value_with_access_mode( + "TypedArrayRmw", + Some(candidate.receiver_id), + "TypedArrayRmw.explicit_fallback", + &fallback, + Some(BoundsState::Unknown), + None, + Some(BufferAccessMode::DynamicFallback), + Some(MaterializationReason::RuntimeApi), + false, + false, + vec![ + "typed_array_rmw_fallback=guard_failure".to_string(), + "evaluation_order=base,key,get,rhs,add,set".to_string(), + ], + ); + + // `fast_sum_end` is deliberately retained as an assertion of CFG shape: + // the RHS block must terminate at the post-RHS guard, not at the store. + debug_assert_ne!(fast_sum_end, store_end); + Ok(Some(result)) +} diff --git a/crates/perry-codegen/tests/typed_array_rmw_8692.rs b/crates/perry-codegen/tests/typed_array_rmw_8692.rs new file mode 100644 index 0000000000..bbaae64016 --- /dev/null +++ b/crates/perry-codegen/tests/typed_array_rmw_8692.rs @@ -0,0 +1,285 @@ +use perry_codegen::{compile_module, CompileOptions}; +use perry_hir::types::Type; +use perry_hir::{BinaryOp, Expr, Function, Module, Param, Stmt, TYPED_ARRAY_KIND_UINT32}; + +#[path = "native_proof_support/mod.rs"] +mod native_proof_support; +use native_proof_support::{artifact_env_lock, artifact_for_module, NativeRepsEnv}; + +const VALUES: u32 = 100; +const KEY: u32 = 101; +const BASE_TMP: u32 = 102; +const KEY_TMP: u32 = 103; + +fn param(id: u32, name: &str) -> Param { + Param { + id, + name: name.to_string(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + } +} + +fn rmw_function(rhs: Expr) -> Function { + let read = Expr::IndexGet { + object: Box::new(Expr::LocalGet(BASE_TMP)), + index: Box::new(Expr::LocalGet(KEY_TMP)), + }; + Function { + id: 7, + name: "bump".to_string(), + type_params: Vec::new(), + params: vec![param(VALUES, "values"), param(KEY, "key")], + return_type: Type::Number, + body: vec![ + // The exact immutable aliases emitted by + // `hoist_compound_member_assign` for `values[key] += rhs`. + Stmt::Let { + id: BASE_TMP, + name: "__cmpd_base_test".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::LocalGet(VALUES)), + }, + Stmt::Let { + id: KEY_TMP, + name: "__cmpd_key_test".to_string(), + ty: Type::Any, + mutable: false, + init: Some(Expr::LocalGet(KEY)), + }, + Stmt::Expr(Expr::IndexSet { + object: Box::new(Expr::LocalGet(BASE_TMP)), + index: Box::new(Expr::LocalGet(KEY_TMP)), + value: Box::new(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(read), + right: Box::new(rhs), + }), + }), + Stmt::Return(Some(Expr::Integer(0))), + ], + 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, + } +} + +fn rmw_module(name: &str, rhs: Expr) -> Module { + let mut module = Module::new(name); + module.functions.push(rmw_function(rhs)); + module.init = vec![ + Stmt::Let { + id: 1, + name: "values".to_string(), + ty: Type::Named("Uint32Array".to_string()), + mutable: false, + init: Some(Expr::TypedArrayNew { + kind: TYPED_ARRAY_KIND_UINT32, + arg: Some(Box::new(Expr::Integer(4))), + }), + }, + Stmt::Let { + id: 2, + name: "keys".to_string(), + ty: Type::Array(Box::new(Type::Any)), + mutable: false, + init: Some(Expr::Array(vec![Expr::Integer(0)])), + }, + Stmt::Expr(Expr::Call { + callee: Box::new(Expr::FuncRef(7)), + args: vec![ + Expr::LocalGet(1), + Expr::IndexGet { + object: Box::new(Expr::LocalGet(2)), + index: Box::new(Expr::Integer(0)), + }, + ], + type_args: Vec::new(), + byte_offset: 0, + }), + ]; + module +} + +fn compile_ir(module: Module) -> String { + String::from_utf8( + compile_module( + &module, + CompileOptions { + emit_ir_only: true, + ..CompileOptions::default() + }, + ) + .expect("module compiles"), + ) + .expect("LLVM IR is UTF-8") +} + +fn function_containing<'a>(ir: &'a str, marker: &str) -> &'a str { + let start = ir + .match_indices("define ") + .find(|(start, _)| { + let end = ir[*start..] + .find('\n') + .map_or(ir.len(), |offset| start + offset); + ir[*start..end].contains(marker) + }) + .map(|(start, _)| start) + .unwrap_or_else(|| panic!("function containing `{marker}` not found:\n{ir}")); + let rest = &ir[start..]; + let end = rest.find("\n}\n").map_or(rest.len(), |offset| offset + 3); + &rest[..end] +} + +fn block_containing<'a>(function: &'a str, marker: &str) -> &'a str { + let start = function + .lines() + .scan(0usize, |offset, line| { + let start = *offset; + *offset += line.len() + 1; + Some((start, line)) + }) + .find(|(_, line)| line.contains(marker) && line.trim_end().ends_with(':')) + .map(|(start, _)| start) + .unwrap_or_else(|| panic!("block containing `{marker}` not found:\n{function}")); + let rest = &function[start..]; + let end = rest.find("\n\n").unwrap_or(rest.len()); + &rest[..end] +} + +fn compile_artifact(module: Module) -> serde_json::Value { + let _lock = artifact_env_lock(); + struct ArtifactDir(std::path::PathBuf); + impl Drop for ArtifactDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let dir = ArtifactDir(std::env::temp_dir().join(format!( + "perry_typed_array_rmw_8692_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ))); + std::fs::create_dir_all(&dir.0).unwrap(); + let _env = NativeRepsEnv::install(&dir.0, false); + let name = module.name.clone(); + compile_module( + &module, + CompileOptions { + emit_ir_only: true, + ..CompileOptions::default() + }, + ) + .expect("module compiles with artifact recording"); + artifact_for_module(&dir.0, &name) +} + +#[test] +fn specialized_uint32_dynamic_index_rmw_has_a_call_free_fast_arm_and_fallback() { + let ir = compile_ir(rmw_module("typed_array_rmw_8692.ts", Expr::Integer(1))); + let specialized = function_containing(&ir, "$spec_ta5x4"); + let load = block_containing(specialized, "ta.rmw.load"); + let store = block_containing(specialized, "ta.rmw.store"); + let fallback = block_containing(specialized, "ta.rmw.full_fallback"); + + assert!( + specialized.contains("@PERRY_TA_VIEW_GUARD") + && specialized.contains("@PERRY_TA_KIND_CACHE"), + "the selected RMW must retain explicit inline-storage and kind guards:\n{specialized}" + ); + assert!( + load.contains("load i32") && load.contains("uitofp i32") && load.contains("fadd"), + "the hot read/add arm must stay in native numeric SSA:\n{load}" + ); + assert!( + store.contains("store i32"), + "Uint32 conversion and the direct backing-store write must remain in the guarded store arm:\n{store}" + ); + for helper in [ + "js_typed_array_index_get_dynamic", + "js_dynamic_string_or_number_add", + "js_typed_array_index_set_dynamic", + ] { + assert!( + !load.contains(helper) && !store.contains(helper), + "the guarded fast arm must not call `{helper}`:\n{load}\n{store}" + ); + } + assert!( + fallback.contains("js_typed_array_index_get_dynamic") + && fallback.contains("js_dynamic_string_or_number_add"), + "guard failure must retain the semantic get/add fallback:\n{fallback}\n{specialized}" + ); + assert!( + specialized.contains("ta.rmw.set_fallback") + && specialized.contains("call double @js_dyn_index_set"), + "post-RHS invalidation must retain a set-only continuation fallback:\n{specialized}" + ); +} + +#[test] +fn native_artifact_reports_selected_guards_and_both_fallbacks() { + let artifact = compile_artifact(rmw_module( + "typed_array_rmw_artifact_8692.ts", + Expr::Integer(1), + )); + let records = artifact["records"].as_array().unwrap(); + assert!( + records.iter().any(|record| { + record["expr_kind"] == "TypedArrayRmw" + && record["consumer"] == "TypedArrayRmw.guarded_direct_uint32_add" + && record["access_mode"] == "checked_native" + && record["notes"].as_array().is_some_and(|notes| { + notes.iter().any(|note| note == "typed_array_rmw=selected") + && notes.iter().any(|note| { + note == "post_rhs_fallback=generic_set_without_repeating_rhs" + }) + && notes + .iter() + .any(|note| note == "post_rhs_receiver=reload_gc_visible_local") + }) + }), + "selected RMW guard/fallback evidence missing:\n{artifact:#}" + ); + assert!( + records.iter().any(|record| { + record["expr_kind"] == "TypedArrayRmw" + && record["consumer"] == "TypedArrayRmw.explicit_fallback" + && record["access_mode"] == "dynamic_fallback" + }), + "explicit dynamic fallback evidence missing:\n{artifact:#}" + ); +} + +#[test] +fn native_artifact_explains_rejection_for_a_noncanonical_rhs() { + let artifact = compile_artifact(rmw_module( + "typed_array_rmw_rejected_8692.ts", + Expr::String("x".to_string()), + )); + let records = artifact["records"].as_array().unwrap(); + assert!( + records.iter().any(|record| { + record["expr_kind"] == "TypedArrayRmw" + && record["consumer"] == "TypedArrayRmw.rejected" + && record["notes"].as_array().is_some_and(|notes| { + notes + .iter() + .any(|note| note == "typed_array_rmw_rejection=rhs_not_canonical_number") + }) + }), + "RMW rejection reason missing from explain-lowering/native artifacts:\n{artifact:#}" + ); +} diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index 9e92fb8672..5ca8b4c2db 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -112,6 +112,9 @@ const BUILD_CACHE_ENV_VARS: &[&str] = &[ "PERRY_STATIC_STRING_LOWERING", "PERRY_STRING_INIT_CHUNK_SIZE", "PERRY_TA_PARAM_F64_READ", + // #8692: disables guarded direct Uint32Array read-modify-write lowering; + // toggling it changes the generated CFG and helper calls. + "PERRY_TYPED_ARRAY_RMW", "PERRY_WATCHOS_ARM64_32", // #8105 — number-by-construction locals (see the collector of the same // name); `=0` empties the fact and changes every affected function's IR. diff --git a/test-files/test_issue_8692_typed_array_rmw.ts b/test-files/test_issue_8692_typed_array_rmw.ts new file mode 100644 index 0000000000..6450be2ec4 --- /dev/null +++ b/test-files/test_issue_8692_typed_array_rmw.ts @@ -0,0 +1,133 @@ +// #8692: a Uint32Array indexed `+=` keeps one guarded numeric representation +// through get/add/set. The smaller sibling fixture +// `test_issue_8692_typed_array_rmw_gc.ts` runs the same parameter/global/alias +// representation under forced moving collections. + +// The ticket's complete reduced wolf-ecs reproduction. Both Node and Perry +// must report checksum 2000; the compiler-output ratchet separately asserts +// that the guarded fast arm contains no generic get/add/set helper. +class Query extends Array { + archetypes = this; +} + +class Archetype extends Array { + entities = this; +} + +const entityCount = 1_000; +const iterations = 2_000; +const query = new Query(); +const archetype = new Archetype(); +for (let i = 0; i < entityCount; i++) archetype.push(i); +query.push(archetype); + +const components = new Uint32Array(entityCount); + +function system(values: Uint32Array): void { + for (let i = 0, length = query.length; i < length; i++) { + const current = query[i]; + for (let j = 0, length = current.length; j < length; j++) { + values[current[j]] += 1; + } + } +} + +for (let i = 0; i < iterations; i++) system(components); +console.log("repro", components[0], components[entityCount - 1]); + +// Dynamic-key guard success plus every important guard-failure class. A +// string canonical index must stay semantic even though it cannot use the raw +// numeric arm; OOB/fractional/NaN/infinite/negative writes remain no-ops. +function bump(values: Uint32Array, key: any): void { + values[key] += 1; +} + +const guarded = new Uint32Array(3); +guarded[0] = 9; +const keys: any[] = [0, -1, 1.5, NaN, Infinity, 99, "0"]; +for (let i = 0; i < keys.length; i++) bump(guarded, keys[i]); +console.log("guards", guarded[0], guarded[1], guarded[2]); + +// Uint32 conversion is modulo 2^32, while the assignment expression itself +// yields the unwrapped numeric sum. +const wrapping = new Uint32Array(1); +wrapping[0] = 0xffffffff; +const expressionValue = (wrapping[0] += 2); +console.log("wrapping", wrapping[0], expressionValue); + +const conversions = new Uint32Array(3); +conversions[0] = 5; +const negativeFraction = (conversions[0] += -6.75); +conversions[1] = 1; +const hugeFinite = (conversions[1] += 1e300); +conversions[2] = 1; +const infinite = (conversions[2] += 1e300 * 1e300); +console.log( + "conversions", + conversions[0], + negativeFraction, + conversions[1], + hugeFinite, + conversions[2], + infinite, +); + +// The read precedes RHS evaluation. Mutating through an alias in the RHS +// must not change the old value used by the addition, and RHS is called once. +const aliased = new Uint32Array(1); +const same = aliased; +aliased[0] = 5; +let rhsCalls = 0; +function mutatingRhs(): number { + rhsCalls++; + same[0] = 40; + const churn: object[] = []; + for (let i = 0; i < 256; i++) churn.push({ i, text: "x".repeat(64) }); + return 2; +} +same[0] += +mutatingRhs(); +console.log("alias-order", aliased[0], rhsCalls); + +// Abrupt RHS completion performs no store and is never retried. +const abrupt = new Uint32Array(1); +abrupt[0] = 12; +let throws = 0; +try { + abrupt[0] += +(() => { + throws++; + throw new Error("stop"); + })(); +} catch (error) { + console.log("abrupt", (error as Error).message, abrupt[0], throws); +} + +// Captured/module-global representation. The dynamic key prevents a static +// bounds proof, while the runtime kind/index guard keeps the direct arm safe. +const globalValues = new Uint32Array(2); +function capturedBump(key: any): void { + globalValues[key] += 1; +} +const dynamicKeys: any[] = [0, 1, 0]; +for (let i = 0; i < dynamicKeys.length; i++) capturedBump(dynamicKeys[i]); +console.log("captured", globalValues[0], globalValues[1]); + +// ArrayBuffer views and detached stores are intentionally rejected by the +// inline-storage guard. Detaching in the RHS also pins the get-before-RHS and +// pending-set semantics without allowing a stale direct backing-store write. +const backing = new ArrayBuffer(4); +const detached = new Uint32Array(backing); +detached[0] = 7; +let detachCalls = 0; +function detachRhs(): number { + detachCalls++; + backing.transfer(); + return 3; +} +const detachedResult = (detached[0] += +detachRhs()); +console.log( + "detached", + detachedResult, + detached[0] === undefined, + backing.byteLength, + detachCalls, +); diff --git a/test-files/test_issue_8692_typed_array_rmw_gc.ts b/test-files/test_issue_8692_typed_array_rmw_gc.ts new file mode 100644 index 0000000000..d998732c53 --- /dev/null +++ b/test-files/test_issue_8692_typed_array_rmw_gc.ts @@ -0,0 +1,28 @@ +// #8692 moving-GC witness for the guarded direct Uint32Array RMW. Keep this +// workload deliberately small: scheduling a collection at every opportunity +// over the ticket's 2,000,000-update performance ratchet would turn a focused +// correctness arm into a minutes-long parity test. +// parity-env: PERRY_GC_MOVING_LOOP_POLLS=1 PERRY_GC_SCHEDULE_SEED=8692 PERRY_GC_SCHEDULE_RATE=1 PERRY_GC_SCHEDULE_ALLOC_KB=0 PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 PERRY_GC_PROTECT_FROMSPACE=1 + +const values = new Uint32Array(32); + +function allocatingOne(round: number, key: number): number { + // Unary `+` at the call site makes the RHS a proven Number while retaining + // this allocating call between the direct element load and the post-RHS + // receiver reload/guard. + const churn: object[] = []; + for (let i = 0; i < 8; i++) { + churn.push({ round, key, i, text: ("gc-" + round + "-" + key + "-" + i).repeat(4) }); + } + return 1; +} + +function bump(target: Uint32Array, key: any, round: number): void { + target[key] += +allocatingOne(round, key); +} + +for (let round = 0; round < 8; round++) { + for (let i = 0; i < values.length; i++) bump(values, i, round); +} + +console.log("moving-rmw", values[0], values[31]);