From cbb54add73b512065e650d0f329dae092fa2b621 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 26 Aug 2026 04:27:25 +0200 Subject: [PATCH] perf: specialize guarded ECS typed columns --- .../perry-codegen/src/expr/i32_fast_path.rs | 5 + crates/perry-codegen/src/expr/index_get.rs | 3 + crates/perry-codegen/src/expr/index_set.rs | 3 + crates/perry-codegen/src/expr/mod.rs | 49 ++- .../src/expr/proven_view_access.rs | 281 +++++++++--- crates/perry-codegen/src/expr/shadow_slot.rs | 12 +- .../src/runtime_decls/objects.rs | 5 + crates/perry-codegen/src/stmt/let_stmt.rs | 18 +- crates/perry-codegen/src/stmt/loops.rs | 7 + crates/perry-codegen/src/stmt/mod.rs | 1 + .../src/stmt/stable_packed_loop.rs | 412 ++++++++++++++++-- .../src/stmt/stable_packed_typed_array.rs | 401 +++++++++++++++++ crates/perry-runtime/src/array/subclass.rs | 147 ++++++- .../perry-runtime/src/array/subclass_tests.rs | 107 ++++- crates/perry-runtime/src/object/mod.rs | 5 +- crates/perry-runtime/src/typedarray/mod.rs | 21 + 16 files changed, 1351 insertions(+), 126 deletions(-) create mode 100644 crates/perry-codegen/src/stmt/stable_packed_typed_array.rs diff --git a/crates/perry-codegen/src/expr/i32_fast_path.rs b/crates/perry-codegen/src/expr/i32_fast_path.rs index 0b498d85be..7562f25ac5 100644 --- a/crates/perry-codegen/src/expr/i32_fast_path.rs +++ b/crates/perry-codegen/src/expr/i32_fast_path.rs @@ -1588,6 +1588,11 @@ fn lower_expr_native_u32(ctx: &mut FnCtx<'_>, e: &Expr) -> Result if let Some(lowered) = lower_packed_u32_loop_index_get(ctx, e)? { return Ok(lowered); } + if let Expr::IndexGet { object, index } = e { + if let Some(lowered) = super::try_lower_proven_view_checked_u32_load(ctx, object, index)? { + return Ok(lowered); + } + } if let Some(lowered) = crate::expr::lower_expr_value(ctx, e)? { let value = match lowered.rep { NativeRep::I32 | NativeRep::U32 | NativeRep::BufferLen => Some(lowered.value), diff --git a/crates/perry-codegen/src/expr/index_get.rs b/crates/perry-codegen/src/expr/index_get.rs index 39b96265e9..5c007fa0f0 100644 --- a/crates/perry-codegen/src/expr/index_get.rs +++ b/crates/perry-codegen/src/expr/index_get.rs @@ -71,6 +71,9 @@ use inline_dyn_typed_array::lower_inline_dyn_typed_array_get; /// object: a type-confused, `unbox`ed-pointer-plus-wrong-offset write, /// not merely a missed optimization. fn is_width_tracked_typed_array_receiver(ctx: &FnCtx<'_>, object: &Expr) -> bool { + if matches!(object, Expr::LocalGet(id) if ctx.buffer_view_slots.contains_key(id)) { + return true; + } // This predicate selects only runtime-validated typed-array helpers (or a // `buffer_view_slots` proof that invalidates on assignment), as documented // above. Preserve the declared kind as a hint for that dynamic fallback; diff --git a/crates/perry-codegen/src/expr/index_set.rs b/crates/perry-codegen/src/expr/index_set.rs index 86c1b256de..d25732f50f 100644 --- a/crates/perry-codegen/src/expr/index_set.rs +++ b/crates/perry-codegen/src/expr/index_set.rs @@ -128,6 +128,9 @@ fn lower_value_for_dynamic_index_set( /// typed-array object (data at byte 16): a type-confused write, not a missed /// optimization. fn is_width_tracked_typed_array_receiver(ctx: &FnCtx<'_>, object: &Expr) -> bool { + if matches!(object, Expr::LocalGet(id) if ctx.buffer_view_slots.contains_key(id)) { + return true; + } let ty = match object { Expr::LocalGet(id) => ctx.local_type_hint(id).cloned(), _ => crate::type_analysis::static_type_of(ctx, object), diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 1b16fbcb8d..00eb73a970 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -104,8 +104,9 @@ pub(crate) use pod_record::{ try_lower_pod_field_set, }; pub(crate) use proven_view_access::{ - index_is_exact_i32_shape, local_is_proven_int_store_view, + index_is_exact_i32_shape, is_proven_u32_view_read, local_is_proven_int_store_view, try_lower_proven_view_checked_f64_load, try_lower_proven_view_checked_store, + try_lower_proven_view_checked_u32_load, }; pub(crate) use range_facts::{ bounds_for_buffer_access_width, effective_alias_state_for_access, @@ -1643,6 +1644,10 @@ pub(crate) struct VersionedIndexedLoopFact { #[derive(Clone, Debug)] pub(crate) struct StablePackedNumericAccess { + /// One preheader-derived element-zero base for a mode-2 Array-subclass + /// prefix proven wholly inline or wholly spilled. When present, indexed + /// reads need no per-element storage-kind selection. + pub contiguous_base: Option, /// Whether the admitted receiver is a plain Array rather than an /// Array-subclass object. pub is_plain: String, @@ -1667,6 +1672,10 @@ pub(crate) struct StablePackedReadCache { /// pointer dirties the associated proof before entering the callee, and a /// dirty cache is never loaded. pub value_slot: String, + /// Canonical unsigned entity index paired with `value_slot`. Present only + /// when admission proved every element is an exact `u32`; consumers can + /// then reuse the native index without repeating ToUint32 conversion. + pub u32_slot: Option, /// Compile-time source-order state. The first lowered occurrence only /// populates the slots; later occurrences emit a runtime hit/miss test. pub has_producer: bool, @@ -1712,6 +1721,19 @@ pub(crate) struct StablePackedLoopFact { /// an untagged IEEE Number. This is requested only when the indexed value /// appears below a numeric operator in the cloned body. pub numeric_elements: bool, + /// The current guarded typed-array clone uses `array[counter]` as an + /// element key. Its first source occurrence validates and canonicalizes + /// the value to `u32`; repeated occurrences reuse those native bits. + pub u32_index_elements: bool, + /// Minimum immutable length of every pairwise-distinct admitted component + /// column. The entity guard checks its canonical index against this once, + /// allowing every component access in the iteration to be unchecked. + pub u32_component_bound: Option, + /// Equal-length component admission makes an out-of-range entity a + /// no-effect iteration: every typed-array read is `undefined` and every + /// store is ignored. Branch directly to this loop's update rather than + /// restarting the generic clone and replaying earlier effects. + pub u32_out_of_bounds_label: Option, /// Preheader-derived numeric storage bases. Admission proved the complete /// range is raw f64 and the call-free clone keeps these addresses stable. pub numeric_access: Option, @@ -1719,6 +1741,11 @@ pub(crate) struct StablePackedLoopFact { /// read. They may seed a nested candidate only while this fast-loop fact /// is active. pub derived_locals: std::collections::HashSet, + /// Immutable locals initialized from a proven Uint32Array view read in + /// this clone. Their ordinary JS slot still stores the exact Number, while + /// native stores may consume it with ToUint32 semantics without falling + /// back to the dynamic typed-array setter. + pub u32_view_derived_locals: std::collections::HashMap, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -3668,6 +3695,26 @@ pub(crate) fn lower_expr_value(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result + { + let slot = crate::stmt::stable_packed_loop::u32_view_derived_local_slot(ctx, *id) + .expect("guarded derived-u32 slot"); + let lowered = LoweredValue::u32(ctx.block().load(I32, &slot)); + ctx.record_lowered_value( + "LocalGet", + Some(*id), + "stable_packed_u32_view_derived_local", + &lowered, + None, + None, + None, + false, + false, + Vec::new(), + ); + Ok(Some(lowered)) + } Expr::LocalGet(id) if is_plain_f64_local(ctx, *id) => { let slot = ctx .locals diff --git a/crates/perry-codegen/src/expr/proven_view_access.rs b/crates/perry-codegen/src/expr/proven_view_access.rs index 5322fc22f5..d4041b00c6 100644 --- a/crates/perry-codegen/src/expr/proven_view_access.rs +++ b/crates/perry-codegen/src/expr/proven_view_access.rs @@ -168,26 +168,68 @@ fn proven_view_for( if ctx.closure_captures.contains_key(id) { return None; } - if !index_is_exact_i32_shape(ctx, index) { - return None; - } - if !can_lower_expr_as_i32( - index, - &ctx.i32_counter_slots, - ctx.flat_const_arrays, - &ctx.array_row_aliases, - ctx.integer_locals, - &ctx.const_number_locals, - ctx.clamp3_functions, - ctx.clamp_u8_functions, - ctx.integer_returning_functions, - ctx.i32_identity_functions, - ) { - return None; + let stable_u32_index = crate::stmt::stable_packed_loop::has_u32_index_fact(ctx, index); + if !stable_u32_index { + if !index_is_exact_i32_shape(ctx, index) { + return None; + } + if !can_lower_expr_as_i32( + index, + &ctx.i32_counter_slots, + ctx.flat_const_arrays, + &ctx.array_row_aliases, + ctx.integer_locals, + &ctx.const_number_locals, + ctx.clamp3_functions, + ctx.clamp_u8_functions, + ctx.integer_returning_functions, + ctx.i32_identity_functions, + ) { + return None; + } } Some((*id, view)) } +fn lower_checked_index(ctx: &mut FnCtx<'_>, index: &Expr) -> Result { + if let Some(index) = crate::stmt::stable_packed_loop::try_lower_u32_index(ctx, index) { + return Ok(index); + } + lower_expr_as_i32(ctx, index) +} + +pub(crate) fn is_proven_u32_view_read(ctx: &FnCtx<'_>, value: &Expr) -> bool { + let Expr::IndexGet { object, index } = value else { + return false; + }; + let Expr::LocalGet(id) = object.as_ref() else { + return false; + }; + ctx.buffer_view_slots.get(id).is_some_and(|view| { + view.pointer_state.is_stable() + && view.storage_inline_proven + && view.native_owned.is_none() + && view.index_unit == BufferIndexUnit::Element + && view.alias.allows_noalias() + && view.scope_idx.is_some() + && matches!(view.elem, BufferElem::U32) + && crate::stmt::stable_packed_loop::has_u32_index_fact(ctx, index) + }) +} + +fn proven_u32_view_value(ctx: &FnCtx<'_>, value: &Expr) -> bool { + if is_proven_u32_view_read(ctx, value) { + return true; + } + let Expr::LocalGet(id) = value else { + return false; + }; + ctx.stable_packed_loop_facts + .iter() + .rev() + .any(|fact| fact.u32_view_derived_locals.contains_key(id)) +} + /// Data pointer + entry-derived length for the proven view. The length load /// is `invariant` — a non-view typed array's length is immutable. fn load_data_and_len( @@ -234,7 +276,7 @@ pub(crate) fn try_lower_proven_view_checked_f64_load( // no receiver value or backing-store pointer has been materialized yet. // Lower the index expression first, then load `data_slot` below, so even a // collecting proven index leaves no movable or raw address live. - let idx_i32 = lower_expr_as_i32(ctx, index)?; + let idx_i32 = lower_checked_index(ctx, index)?; let (data_ptr, len) = load_data_and_len(ctx, &view); let load_idx = ctx.new_block("pview.get.load"); @@ -303,7 +345,9 @@ pub(crate) fn try_lower_proven_view_checked_f64_load( Some(id), "TypedArrayGet.proven_view_checked", &lowered, - Some(BoundsState::Unknown), + Some(BoundsState::Guarded { + guard_id: "proven_view_checked_bounds".to_string(), + }), Some(view.alias.clone()), Some(BufferAccessMode::CheckedNative), None, @@ -315,6 +359,132 @@ pub(crate) fn try_lower_proven_view_checked_f64_load( Ok(Some(result)) } +/// Inline checked Uint32Array read that preserves the raw lane for a native +/// consumer. The out-of-bounds arm is zero because the public read produces +/// `undefined`, whose ToUint32 value is zero. +pub(crate) fn try_lower_proven_view_checked_u32_load( + ctx: &mut FnCtx<'_>, + object: &Expr, + index: &Expr, +) -> Result> { + let Some((id, view)) = proven_view_for(ctx, object, index) else { + return Ok(None); + }; + if !matches!(view.elem, BufferElem::U32) { + return Ok(None); + } + let common_bound = crate::stmt::stable_packed_loop::has_u32_component_bound(ctx, index); + let idx_i32 = lower_checked_index(ctx, index)?; + let (data_ptr, len) = load_data_and_len(ctx, &view); + if common_bound { + let idx_i64 = ctx.block().zext(I32, &idx_i32, I64); + let byte_off = ctx.block().shl(I64, &idx_i64, "2"); + let elem_ptr = ctx.block().gep(I8, &data_ptr, &[(I64, &byte_off)]); + let lowered = LoweredValue::u32(ctx.block().load(I32, &elem_ptr)); + ctx.record_lowered_value_with_access_mode( + "TypedArrayGet", + Some(id), + "TypedArrayGet.proven_view_common_bound_u32", + &lowered, + Some(BoundsState::Guarded { + guard_id: "stable_packed_u32_component_bound".to_string(), + }), + Some(view.alias.clone()), + Some(BufferAccessMode::UncheckedNative), + None, + false, + false, + vec!["proven_view=unchecked_common_bound_u32".to_string()], + ); + attach_buffer_view_facts(ctx, &view); + return Ok(Some(lowered)); + } + let load_idx = ctx.new_block("pview.get_u32.load"); + let oob_idx = ctx.new_block("pview.get_u32.oob"); + let merge_idx = ctx.new_block("pview.get_u32.merge"); + let load_label = ctx.block_label(load_idx); + let oob_label = ctx.block_label(oob_idx); + let merge_label = ctx.block_label(merge_idx); + let in_bounds = ctx.block().icmp_ult(I32, &idx_i32, &len); + ctx.block().cond_br(&in_bounds, &load_label, &oob_label); + + ctx.current_block = load_idx; + let idx_i64 = ctx.block().zext(I32, &idx_i32, I64); + let byte_off = ctx.block().shl(I64, &idx_i64, "2"); + let elem_ptr = ctx.block().gep(I8, &data_ptr, &[(I64, &byte_off)]); + let raw = ctx.block().load(I32, &elem_ptr); + let load_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = oob_idx; + let oob_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = merge_idx; + let value = ctx.block().phi(I32, &[(&raw, &load_end), ("0", &oob_end)]); + let lowered = LoweredValue::u32(value); + ctx.record_lowered_value_with_access_mode( + "TypedArrayGet", + Some(id), + "TypedArrayGet.proven_view_checked_u32", + &lowered, + Some(BoundsState::Guarded { + guard_id: "proven_view_checked_bounds".to_string(), + }), + Some(view.alias.clone()), + Some(BufferAccessMode::CheckedNative), + None, + false, + false, + vec!["proven_view=checked_inline_u32; guards=none".to_string()], + ); + attach_buffer_view_facts(ctx, &view); + Ok(Some(lowered)) +} + +fn emit_proven_view_store( + ctx: &mut FnCtx<'_>, + view: &crate::native_value::BufferViewSlot, + data_ptr: &str, + idx_i32: &str, + value_native: &LoweredValue, +) { + let blk = ctx.block(); + let idx_i64 = blk.zext(I32, idx_i32, I64); + let byte_off = if view.element_width_bytes > 1 { + blk.shl( + I64, + &idx_i64, + &view.element_width_bytes.trailing_zeros().to_string(), + ) + } else { + idx_i64 + }; + let elem_ptr = blk.gep(I8, data_ptr, &[(I64, &byte_off)]); + // GC_STORE_AUDIT(POINTER_FREE): typed-array backing store. + match view.elem { + BufferElem::I8 | BufferElem::U8 => { + let byte = blk.trunc(I32, &value_native.value, I8); + blk.store(I8, &byte, &elem_ptr); + } + BufferElem::I16 | BufferElem::U16 => { + let half = blk.trunc(I32, &value_native.value, I16); + blk.store(I16, &half, &elem_ptr); + } + BufferElem::I32 | BufferElem::U32 => { + blk.store(I32, &value_native.value, &elem_ptr); + } + BufferElem::F32 => { + let narrow = blk.fptrunc(DOUBLE, &value_native.value, F32); + blk.store(F32, &narrow, &elem_ptr); + } + BufferElem::F64 => { + blk.store(DOUBLE, &value_native.value, &elem_ptr); + } + BufferElem::U8Clamped => unreachable!("gated before store emission"), + } +} + /// Inline checked element write (`object[index] = value`). The value is /// evaluated and coerced BEFORE the bounds check (spec order); an OOB write is /// a silent no-op. Returns the lowered (coerced-native) RHS so the assignment @@ -351,6 +521,7 @@ pub(crate) fn try_lower_proven_view_checked_store( if int_kind && !super::can_lower_integer_typed_array_store_value(ctx, value) && !super::can_lower_expr_as_i32_in_current_region(ctx, value) + && !proven_u32_view_value(ctx, value) { return Ok(None); } @@ -365,7 +536,7 @@ pub(crate) fn try_lower_proven_view_checked_store( } } - let idx_i32 = lower_expr_as_i32(ctx, index)?; + let idx_i32 = lower_checked_index(ctx, index)?; let value_native = if int_kind { let expected = if matches!(view.elem, BufferElem::U32) { ExpectedNativeRep::U32 @@ -386,6 +557,26 @@ pub(crate) fn try_lower_proven_view_checked_store( lower_expr_native(ctx, value, ExpectedNativeRep::F64)? }; let (data_ptr, len) = load_data_and_len(ctx, &view); + if crate::stmt::stable_packed_loop::has_u32_component_bound(ctx, index) { + emit_proven_view_store(ctx, &view, &data_ptr, &idx_i32, &value_native); + ctx.record_lowered_value_with_access_mode( + "TypedArraySet", + Some(id), + "TypedArraySet.proven_view_common_bound", + &value_native, + Some(BoundsState::Guarded { + guard_id: "stable_packed_u32_component_bound".to_string(), + }), + Some(view.alias.clone()), + Some(BufferAccessMode::UncheckedNative), + None, + false, + false, + vec!["proven_view=unchecked_common_bound".to_string()], + ); + attach_buffer_view_facts(ctx, &view); + return Ok(Some(value_native)); + } let store_idx = ctx.new_block("pview.set.store"); let done_idx = ctx.new_block("pview.set.done"); @@ -400,52 +591,8 @@ pub(crate) fn try_lower_proven_view_checked_store( ctx.current_block = store_idx; { - let blk = ctx.block(); - let idx_i64 = blk.zext(I32, &idx_i32, I64); - let byte_off = if view.element_width_bytes > 1 { - blk.shl( - I64, - &idx_i64, - &view.element_width_bytes.trailing_zeros().to_string(), - ) - } else { - idx_i64 - }; - let elem_ptr = blk.gep(I8, &data_ptr, &[(I64, &byte_off)]); - // Every arm below stores into `elem_ptr`, which addresses the view's - // BACKING STORE (`view.data_slot`). Typed-array elements are raw - // numeric bytes and can never hold a JSValue, so none of these stores - // creates a heap edge and none needs a write barrier. This is the - // codegen-side counterpart of the runtime carve-out for the - // `typedarray` / `typedarray_view` / `buffer` modules - // (`is_pointer_free_module` in scripts/gc_store_site_inventory.py). - match view.elem { - BufferElem::I8 | BufferElem::U8 => { - let byte = blk.trunc(I32, &value_native.value, I8); - // GC_STORE_AUDIT(POINTER_FREE): typed-array backing store. - blk.store(I8, &byte, &elem_ptr); - } - BufferElem::I16 | BufferElem::U16 => { - let half = blk.trunc(I32, &value_native.value, I16); - // GC_STORE_AUDIT(POINTER_FREE): typed-array backing store. - blk.store(I16, &half, &elem_ptr); - } - BufferElem::I32 | BufferElem::U32 => { - // GC_STORE_AUDIT(POINTER_FREE): typed-array backing store. - blk.store(I32, &value_native.value, &elem_ptr); - } - BufferElem::F32 => { - let narrow = blk.fptrunc(DOUBLE, &value_native.value, F32); - // GC_STORE_AUDIT(POINTER_FREE): typed-array backing store. - blk.store(F32, &narrow, &elem_ptr); - } - BufferElem::F64 => { - // GC_STORE_AUDIT(POINTER_FREE): typed-array backing store. - blk.store(DOUBLE, &value_native.value, &elem_ptr); - } - BufferElem::U8Clamped => unreachable!("gated above"), - } - blk.br(&done_label); + emit_proven_view_store(ctx, &view, &data_ptr, &idx_i32, &value_native); + ctx.block().br(&done_label); } ctx.current_block = done_idx; @@ -454,7 +601,9 @@ pub(crate) fn try_lower_proven_view_checked_store( Some(id), "TypedArraySet.proven_view_checked", &value_native, - Some(BoundsState::Unknown), + Some(BoundsState::Guarded { + guard_id: "proven_view_checked_bounds".to_string(), + }), Some(view.alias.clone()), Some(BufferAccessMode::CheckedNative), None, diff --git a/crates/perry-codegen/src/expr/shadow_slot.rs b/crates/perry-codegen/src/expr/shadow_slot.rs index 6b89e15e78..15c7887786 100644 --- a/crates/perry-codegen/src/expr/shadow_slot.rs +++ b/crates/perry-codegen/src/expr/shadow_slot.rs @@ -89,11 +89,13 @@ pub(crate) fn expr_is_known_non_pointer_shadow_value(ctx: &FnCtx<'_>, expr: &Exp // #6750 follow-up: a masked-index read covered by an ACTIVE // masked-window fact is a guard-proven numeric element load — never // a pointer — even when the receiver's static type is erased. - Expr::IndexGet { object, index } => matches!( - object.as_ref(), - Expr::LocalGet(arr_id) - if super::masked_window_fact_for_index(ctx, *arr_id, index).is_some() - ), + Expr::IndexGet { object, index } => { + matches!( + object.as_ref(), + Expr::LocalGet(arr_id) + if super::masked_window_fact_for_index(ctx, *arr_id, index).is_some() + ) || super::is_proven_u32_view_read(ctx, expr) + } // #6996: a typed-array / Buffer element read is a number (or // `undefined` out of range) BY CONSTRUCTION -- `lower_buffer_load`'s // inline byte load, `js_uint8array_index_get_value` and diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index 221cf470f0..2bd7a89eed 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -407,6 +407,11 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { &[I64, DOUBLE, I32, I32], ); module.declare_function("js_typed_array_masked_window_data_ptr", I64, &[DOUBLE]); + module.declare_function( + "js_packed_ecs_u32_loop_guard", + I64, + &[DOUBLE, DOUBLE, DOUBLE, DOUBLE, DOUBLE, DOUBLE, I32, PTR], + ); module.declare_function( "js_typed_feedback_packed_u32_array_loop_guard", I32, diff --git a/crates/perry-codegen/src/stmt/let_stmt.rs b/crates/perry-codegen/src/stmt/let_stmt.rs index 319f76c777..3a2d17ff92 100644 --- a/crates/perry-codegen/src/stmt/let_stmt.rs +++ b/crates/perry-codegen/src/stmt/let_stmt.rs @@ -13,7 +13,8 @@ use crate::expr::{ lower_expr_with_expected_type, unbox_str_handle, }; use crate::native_value::{ - LoweredValue, MaterializationReason, NativeRep, PodLayoutDecision, PodLocal, SemanticKind, + ExpectedNativeRep, LoweredValue, MaterializationReason, NativeRep, PodLayoutDecision, PodLocal, + SemanticKind, }; use crate::type_analysis::is_string_expr; use crate::types::{DOUBLE, I1, I32, I64, I8, PTR}; @@ -1696,7 +1697,15 @@ pub(crate) fn lower_let( false }; let v = if !used_i32_init { - let native_init = if matches!( + let derived_u32 = + crate::stmt::stable_packed_loop::u32_view_derived_local_slot(ctx, id).is_some(); + let native_init = if derived_u32 { + Some(crate::expr::lower_expr_native( + ctx, + init_expr, + ExpectedNativeRep::U32, + )?) + } else if matches!( refined_ty, perry_hir::types::Type::Number | perry_hir::types::Type::Int32 ) || (matches!(refined_ty, perry_hir::types::Type::Boolean) @@ -1739,6 +1748,11 @@ pub(crate) fn lower_let( ); v } else if matches!(lowered.rep, NativeRep::U32 | NativeRep::BufferLen) { + if let Some(native_slot) = + crate::stmt::stable_packed_loop::u32_view_derived_local_slot(ctx, id) + { + ctx.block().store(I32, &lowered.value, &native_slot); + } let v = ctx.block().uitofp(I32, &lowered.value, DOUBLE); ctx.block().store(DOUBLE, &v, &slot); ctx.record_lowered_value( diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index aeb0e4eda0..949a86d579 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -5270,6 +5270,13 @@ pub(super) fn lower_for_after_init_with_i32_bound( let body_label = ctx.block_label(body_idx); let update_label = ctx.block_label(update_idx); let exit_label = ctx.block_label(exit_idx); + if let Some(fact) = ctx + .stable_packed_loop_facts + .last_mut() + .filter(|fact| fact.u32_component_bound.is_some()) + { + fact.u32_out_of_bounds_label = Some(update_label.clone()); + } // Branch from the block holding the init into the cond block. ctx.block().br(&cond_label); diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index 98a682b315..7b52c4a0d8 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -29,6 +29,7 @@ mod masked_window_region; #[cfg(test)] mod prealloc_module_global_tests; pub(crate) mod stable_packed_loop; +mod stable_packed_typed_array; mod switch_stmt; mod try_stmt; mod unused_expr; diff --git a/crates/perry-codegen/src/stmt/stable_packed_loop.rs b/crates/perry-codegen/src/stmt/stable_packed_loop.rs index 41cfaa47a2..a65929016e 100644 --- a/crates/perry-codegen/src/stmt/stable_packed_loop.rs +++ b/crates/perry-codegen/src/stmt/stable_packed_loop.rs @@ -23,6 +23,7 @@ struct Candidate { array_id: u32, bound: LoopBound, numeric_elements: bool, + u32_index_elements: bool, capture_index: Option, capture_uses_box: bool, nested_derived: bool, @@ -30,18 +31,32 @@ struct Candidate { cache_repeated_index_reads: bool, } +fn required_numeric_mode(numeric_elements: bool, u32_index_elements: bool) -> &'static str { + if u32_index_elements { + "2" + } else if numeric_elements { + "1" + } else { + "0" + } +} + +fn exact_target_read(expr: &Expr, array_id: u32, counter_id: u32) -> bool { + matches!( + expr, + Expr::IndexGet { object, index } + if matches!(object.as_ref(), Expr::LocalGet(id) if *id == array_id) + && matches!(index.as_ref(), Expr::LocalGet(id) if *id == counter_id) + ) +} + fn target_below_numeric_operator( expr: &Expr, array_id: u32, counter_id: u32, numeric_context: bool, ) -> bool { - if matches!( - expr, - Expr::IndexGet { object, index } - if matches!(object.as_ref(), Expr::LocalGet(id) if *id == array_id) - && matches!(index.as_ref(), Expr::LocalGet(id) if *id == counter_id) - ) { + if exact_target_read(expr, array_id, counter_id) { return numeric_context; } if matches!(expr, Expr::Closure { .. }) { @@ -60,6 +75,56 @@ fn target_below_numeric_operator( found } +/// Whether the admitted read is used as the complete key of another indexed +/// access. A guarded typed-array loop validates and canonicalizes this value +/// once per source iteration, then reuses the native `u32` at every component +/// access. +fn target_is_index_key(expr: &Expr, array_id: u32, counter_id: u32) -> bool { + if matches!(expr, Expr::Closure { .. }) { + return false; + } + let is_target = |candidate: &Expr| exact_target_read(candidate, array_id, counter_id); + match expr { + Expr::IndexGet { object, index } + | Expr::IndexSet { object, index, .. } + | Expr::IndexUpdate { object, index, .. } => { + if is_target(index) { + return true; + } + target_is_index_key(object, array_id, counter_id) + || target_is_index_key(index, array_id, counter_id) + } + Expr::PutValueSet { + target, + key, + value, + receiver, + .. + } => { + if is_target(key) { + return true; + } + [ + target.as_ref(), + key.as_ref(), + value.as_ref(), + receiver.as_ref(), + ] + .into_iter() + .any(|child| target_is_index_key(child, array_id, counter_id)) + } + _ => { + let mut found = false; + perry_hir::walker::walk_expr_children(expr, &mut |child| { + if !found && target_is_index_key(child, array_id, counter_id) { + found = true; + } + }); + found + } + } +} + fn leading_read_requires_numeric(body: &[Stmt], array_id: u32, counter_id: u32) -> bool { let Some(first) = body.first() else { return false; @@ -76,6 +141,22 @@ fn leading_read_requires_numeric(body: &[Stmt], array_id: u32, counter_id: u32) target_below_numeric_operator(expr, array_id, counter_id, false) } +fn leading_read_requires_u32_index(body: &[Stmt], array_id: u32, counter_id: u32) -> bool { + let Some(first) = body.first() else { + return false; + }; + let expr = match first { + Stmt::Let { + init: Some(expr), .. + } + | Stmt::Expr(expr) + | Stmt::Throw(expr) + | Stmt::Return(Some(expr)) => expr, + _ => return false, + }; + target_is_index_key(expr, array_id, counter_id) +} + fn expr_flags(expr: &Expr, array_id: u32, counter_id: u32, target: &mut bool, call: &mut bool) { if matches!( expr, @@ -485,11 +566,14 @@ fn match_candidate( } return None; } + let u32_index_elements = leading_read_requires_u32_index(body, array_id, counter_id); Some(Candidate { counter_id, array_id, bound, - numeric_elements: leading_read_requires_numeric(body, array_id, counter_id), + numeric_elements: u32_index_elements + || leading_read_requires_numeric(body, array_id, counter_id), + u32_index_elements, capture_index, capture_uses_box: capture_index.is_some() && ctx.boxed_vars.contains(&array_id), nested_derived, @@ -505,6 +589,12 @@ pub(super) fn record_derived_local(ctx: &mut FnCtx<'_>, id: u32, init: &Expr, mu if mutable || ctx.reassigned_locals.contains(&id) { return; } + if crate::expr::is_proven_u32_view_read(ctx, init) { + let native_slot = ctx.func.alloca_entry(I32); + if let Some(fact) = ctx.stable_packed_loop_facts.last_mut() { + fact.u32_view_derived_locals.insert(id, native_slot); + } + } let Expr::IndexGet { object, index } = init else { return; }; @@ -523,6 +613,13 @@ pub(super) fn record_derived_local(ctx: &mut FnCtx<'_>, id: u32, init: &Expr, mu fact.derived_locals.insert(id); } +pub(crate) fn u32_view_derived_local_slot(ctx: &FnCtx<'_>, id: u32) -> Option { + ctx.stable_packed_loop_facts + .iter() + .rev() + .find_map(|fact| fact.u32_view_derived_locals.get(&id).cloned()) +} + fn descriptor_word(ctx: &mut FnCtx<'_>, descriptor: &str, index: u64) -> String { let ptr = ctx .block() @@ -538,6 +635,7 @@ fn build_numeric_access( ctx: &mut FnCtx<'_>, descriptor: &str, live_raw: &str, + contiguous_u32_prefix: bool, ) -> StablePackedNumericAccess { let kind = descriptor_word(ctx, descriptor, 0); let is_plain = ctx.block().icmp_eq(I64, &kind, "1"); @@ -614,7 +712,20 @@ fn build_numeric_access( let safe_spill = ctx.block().select(I1, &has_spill, I64, &spill, live_raw); let spill_offset = ctx.block().add(I64, &element_bytes, "8"); let object_spill_base = ctx.block().add(I64, &safe_spill, &spill_offset); + let contiguous_base = contiguous_u32_prefix.then(|| { + // Mode-2 admission rejects prefixes that cross the inline/spill + // boundary (and rejects plain Arrays), so storage selection belongs + // in this preheader rather than in every entity iteration. + ctx.block().select( + I1, + &has_inline, + I64, + &object_inline_base, + &object_spill_base, + ) + }); StablePackedNumericAccess { + contiguous_base, is_plain, plain_base, object_inline_count, @@ -762,7 +873,10 @@ pub(crate) fn try_lower_index_get( &[ (DOUBLE, &receiver), (DOUBLE, &fact.bound), - (I32, if fact.numeric_elements { "1" } else { "0" }), + ( + I32, + required_numeric_mode(fact.numeric_elements, fact.u32_index_elements), + ), (PTR, &fact.descriptor), ], ); @@ -780,11 +894,18 @@ pub(crate) fn try_lower_index_get( // revalidation therefore cannot side-exit to the generic loop at the // current counter: that would replay the earlier effects. Fall back // for this one indexed read and merge back at the exact source point. - let fallback_idx = ctx.new_block("packed_index.generic_fallback"); - let read_merge_idx = ctx.new_block("packed_index.revalidated_merge"); let continue_label = ctx.block_label(continue_idx); - let fallback_label = ctx.block_label(fallback_idx); - ctx.block().cond_br(&pass, &continue_label, &fallback_label); + let fallback = (!fact.u32_index_elements).then(|| { + let fallback_idx = ctx.new_block("packed_index.generic_fallback"); + let read_merge_idx = ctx.new_block("packed_index.revalidated_merge"); + let fallback_label = ctx.block_label(fallback_idx); + (fallback_idx, read_merge_idx, fallback_label) + }); + let miss_label = fallback + .as_ref() + .map(|(_, _, label)| label.as_str()) + .unwrap_or(fact.side_exit_label.as_str()); + ctx.block().cond_br(&pass, &continue_label, miss_label); ctx.current_block = continue_idx; ctx.block().store(I64, &live_raw, live_raw_slot); ctx.block().store(I1, "0", dirty_slot); @@ -796,11 +917,18 @@ pub(crate) fn try_lower_index_get( I64, &[(&clean_raw, &clean_end), (&live_raw, &validated_end)], ); - per_read_numeric_access = fact - .numeric_elements - .then(|| build_numeric_access(ctx, &fact.descriptor, &merged_live_raw)); + per_read_numeric_access = fact.numeric_elements.then(|| { + build_numeric_access( + ctx, + &fact.descriptor, + &merged_live_raw, + fact.u32_index_elements, + ) + }); per_read_live_raw = Some(merged_live_raw); - per_read_fallback = Some((fallback_idx, read_merge_idx, fallback_label, receiver)); + if let Some((fallback_idx, read_merge_idx, fallback_label)) = fallback { + per_read_fallback = Some((fallback_idx, read_merge_idx, fallback_label, receiver)); + } } let fact = ctx .stable_packed_loop_facts @@ -808,10 +936,31 @@ pub(crate) fn try_lower_index_get( .rev() .find(|fact| fact.array_local_id == *array_id && fact.counter_local_id == *counter_id)? .clone(); + let u32_oob_label = u32_out_of_bounds_label(&fact).to_string(); let raw = per_read_live_raw.or(fact.live_receiver_handle)?; let idx_i64 = ctx.block().zext(I32, &idx_i32, I64); if let Some(access) = per_read_numeric_access.or(fact.numeric_access) { let byte_offset = ctx.block().shl(I64, &idx_i64, "3"); + if let Some(base) = access.contiguous_base.as_ref() { + let element_addr = ctx.block().add(I64, base, &byte_offset); + let element_ptr = ctx.block().inttoptr(I64, &element_addr); + let (direct, native_u32) = if fact.u32_index_elements { + let native = ctx.block().load(I32, &element_ptr); + (ctx.block().uitofp(I32, &native, DOUBLE), Some(native)) + } else { + (ctx.block().load(DOUBLE, &element_ptr), None) + }; + let resolved = finish_revalidated_read(ctx, direct, idx_i32.clone(), per_read_fallback); + return Some(finish_repeated_read_cache( + ctx, + resolved, + idx_i32, + repeated_read_cache, + native_u32.as_deref(), + &u32_oob_label, + fact.u32_component_bound.as_deref(), + )); + } let plain_addr = ctx.block().add(I64, &access.plain_base, &byte_offset); let inline_addr = ctx .block() @@ -836,6 +985,9 @@ pub(crate) fn try_lower_index_get( resolved, idx_i32, repeated_read_cache, + None, + &u32_oob_label, + fact.u32_component_bound.as_deref(), )); } let kind = descriptor_word(ctx, &fact.descriptor, 0); @@ -965,6 +1117,9 @@ pub(crate) fn try_lower_index_get( resolved, idx_i32, repeated_read_cache, + None, + &u32_oob_label, + fact.u32_component_bound.as_deref(), )) } @@ -1046,6 +1201,9 @@ fn finish_repeated_read_cache( resolved: String, idx_i32: String, cache_miss: Option, + native_u32: Option<&str>, + side_exit_label: &str, + component_bound: Option<&str>, ) -> String { let Some(cache_miss) = cache_miss else { return resolved; @@ -1061,6 +1219,15 @@ fn finish_repeated_read_cache( }; ctx.block().store(I32, &idx_i32, &cache.counter_slot); ctx.block().store(DOUBLE, &resolved, &cache.value_slot); + if let Some(u32_slot) = cache.u32_slot.as_ref() { + // Validate the entity id at its first source occurrence. A miss exits + // before the conservative typed-array clone can perform an observable + // effect, while hits reuse these exact native bits for every later + // component access in the same source iteration. + let canonical = + emit_canonical_u32_guard(ctx, &resolved, native_u32, side_exit_label, component_bound); + ctx.block().store(I32, &canonical, u32_slot); + } ctx.block().store(I1, "1", &cache.valid_slot); let Some((cached, hit_end, merge_idx)) = hit else { return resolved; @@ -1074,6 +1241,37 @@ fn finish_repeated_read_cache( .phi(DOUBLE, &[(&cached, &hit_end), (&resolved, &miss_end)]) } +/// Consume the exact-u32 prefix established by mode-2 runtime admission. That +/// persistent, mutation-invalidated proof makes `fptoui` defined here. A +/// shared component-length check below still keeps each direct typed-array +/// access within its owning allocation. +fn emit_canonical_u32_guard( + ctx: &mut FnCtx<'_>, + value: &str, + native_u32: Option<&str>, + out_of_bounds_label: &str, + component_bound: Option<&str>, +) -> String { + let canonical = native_u32 + .map(ToOwned::to_owned) + .unwrap_or_else(|| ctx.block().fptoui(DOUBLE, value, I32)); + if let Some(bound) = component_bound { + let in_bounds = ctx.block().icmp_ult(I32, &canonical, bound); + let continue_idx = ctx.new_block("stable_packed.component.in_bounds"); + let continue_label = ctx.block_label(continue_idx); + ctx.block() + .cond_br(&in_bounds, &continue_label, out_of_bounds_label); + ctx.current_block = continue_idx; + } + canonical +} + +fn u32_out_of_bounds_label(fact: &StablePackedLoopFact) -> &str { + fact.u32_out_of_bounds_label + .as_deref() + .unwrap_or(&fact.side_exit_label) +} + /// Complete a nested-derived indexed read. The direct arm has already /// consumed the live raw address with no intervening safepoint. On a guard or /// defensive-layout miss, the ordinary arraylike helper performs exactly this @@ -1121,6 +1319,83 @@ pub(crate) fn has_numeric_index_fact(ctx: &FnCtx<'_>, expr: &Expr) -> bool { }) } +pub(crate) fn has_u32_index_fact(ctx: &FnCtx<'_>, expr: &Expr) -> bool { + let Expr::IndexGet { object, index } = expr else { + return false; + }; + let (Expr::LocalGet(array_id), Expr::LocalGet(counter_id)) = (object.as_ref(), index.as_ref()) + else { + return false; + }; + ctx.stable_packed_loop_facts.iter().rev().any(|fact| { + fact.u32_index_elements + && fact.array_local_id == *array_id + && fact.counter_local_id == *counter_id + }) +} + +pub(crate) fn has_u32_component_bound(ctx: &FnCtx<'_>, expr: &Expr) -> bool { + let Expr::IndexGet { object, index } = expr else { + return false; + }; + let (Expr::LocalGet(array_id), Expr::LocalGet(counter_id)) = (object.as_ref(), index.as_ref()) + else { + return false; + }; + ctx.stable_packed_loop_facts.iter().rev().any(|fact| { + fact.u32_component_bound.is_some() + && fact.array_local_id == *array_id + && fact.counter_local_id == *counter_id + }) +} + +/// Lower an admitted packed entity-id read and return its canonical native +/// u32 bits. Repeated source occurrences share both the guarded read and this +/// conversion through `StablePackedReadCache`. +pub(crate) fn try_lower_u32_index(ctx: &mut FnCtx<'_>, expr: &Expr) -> Option { + if !has_u32_index_fact(ctx, expr) { + return None; + } + let Expr::IndexGet { object, index } = expr else { + return None; + }; + let value = try_lower_index_get(ctx, object, index)?; + let (cache_slot, out_of_bounds_label, component_bound) = ctx + .stable_packed_loop_facts + .iter() + .rev() + .find(|fact| { + matches!( + (object.as_ref(), index.as_ref()), + (Expr::LocalGet(array_id), Expr::LocalGet(counter_id)) + if fact.array_local_id == *array_id + && fact.counter_local_id == *counter_id + ) + }) + .map(|fact| { + ( + fact.repeated_read_cache + .as_ref() + .and_then(|cache| cache.u32_slot.clone()), + fact.u32_out_of_bounds_label + .clone() + .unwrap_or_else(|| fact.side_exit_label.clone()), + fact.u32_component_bound.clone(), + ) + })?; + Some(if let Some(slot) = cache_slot { + ctx.block().load(I32, &slot) + } else { + emit_canonical_u32_guard( + ctx, + &value, + None, + &out_of_bounds_label, + component_bound.as_deref(), + ) + }) +} + /// Refresh a captured receiver at fast-iteration entry. The closure pointer is /// reloaded through its GC root by ordinary `LocalGet` lowering, then the full /// runtime admission rechecks identity, forwarding, layout, descriptors, @@ -1144,7 +1419,10 @@ pub(super) fn emit_iteration_guard( &[ (DOUBLE, &receiver), (DOUBLE, &fact.bound), - (I32, if fact.numeric_elements { "1" } else { "0" }), + ( + I32, + required_numeric_mode(fact.numeric_elements, fact.u32_index_elements), + ), (PTR, &fact.descriptor), ], ); @@ -1165,7 +1443,7 @@ pub(super) fn emit_iteration_guard( let numeric_access = fact .numeric_elements - .then(|| build_numeric_access(ctx, &fact.descriptor, &live_raw)); + .then(|| build_numeric_access(ctx, &fact.descriptor, &live_raw, fact.u32_index_elements)); if let Some(active) = ctx.stable_packed_loop_facts.last_mut() { active.live_receiver_handle = Some(live_raw); active.numeric_access = numeric_access; @@ -1183,6 +1461,17 @@ pub(super) fn lower( let Some(candidate) = match_candidate(ctx, init, condition, update, body) else { return Ok(false); }; + let typed_array_candidate = super::stable_packed_typed_array::find_candidate( + ctx, + body, + candidate.array_id, + candidate.counter_id, + candidate.u32_index_elements, + ); + // The stronger entity-id fact exists solely to feed the guarded column + // view clone. Mode-2 admission establishes or consumes its persistent, + // mutation-invalidated exact-u32 prefix proof. + let u32_index_elements = typed_array_candidate.is_some(); let inserted_counter = if ctx.i32_counter_slots.contains_key(&candidate.counter_id) { false } else { @@ -1202,24 +1491,53 @@ pub(super) fn lower( LoopBound::Snapshot(bound_id) => crate::expr::lower_expr(ctx, &Expr::LocalGet(bound_id))?, LoopBound::LiveLength => "-1.0".to_string(), }; - let descriptor = ctx.func.alloca_entry_array(I64, 7); - let guard_args = [ - (DOUBLE, receiver.as_str()), - (DOUBLE, bound_box.as_str()), - (I32, if candidate.numeric_elements { "1" } else { "0" }), - (PTR, descriptor.as_str()), - ]; - let (admitted, admitted_live_raw) = if candidate.capture_index.is_some() { - let live_raw = ctx - .block() - .call(I64, "js_packed_arraylike_loop_guard_live", &guard_args); - (ctx.block().icmp_ne(I64, &live_raw, "0"), Some(live_raw)) - } else { - let guard = ctx - .block() - .call(I32, "js_packed_arraylike_loop_guard", &guard_args); - (ctx.block().icmp_ne(I32, &guard, "0"), None) - }; + let descriptor = ctx + .func + .alloca_entry_array(I64, if u32_index_elements { 11 } else { 7 }); + let (admitted, admitted_live_raw, typed_array_admission) = + if let Some(typed_array_candidate) = typed_array_candidate.as_ref() { + let (admission, live_raw) = super::stable_packed_typed_array::emit_fused_admission( + ctx, + typed_array_candidate, + &receiver, + &bound_box, + &descriptor, + )?; + (admission.guard.clone(), Some(live_raw), Some(admission)) + } else { + let guard_args = [ + (DOUBLE, receiver.as_str()), + (DOUBLE, bound_box.as_str()), + ( + I32, + required_numeric_mode(candidate.numeric_elements, false), + ), + (PTR, descriptor.as_str()), + ]; + if candidate.capture_index.is_some() { + let live_raw = + ctx.block() + .call(I64, "js_packed_arraylike_loop_guard_live", &guard_args); + ( + ctx.block().icmp_ne(I64, &live_raw, "0"), + Some(live_raw), + None, + ) + } else { + let guard = ctx + .block() + .call(I32, "js_packed_arraylike_loop_guard", &guard_args); + (ctx.block().icmp_ne(I32, &guard, "0"), None, None) + } + }; + // The conservative column matcher proves the cloned body call-free, and + // `fast_raw` below reloads the rooted derived receiver after every + // admission helper has returned. Its packed layout therefore stays valid + // for the complete clone. Keep the broader per-read revalidation tier for + // nested generic bodies, but let this clone hoist all descriptor-derived + // bases into its preheader. + let revalidate_before_indexed_read = + candidate.nested_requires_access_revalidation && typed_array_admission.is_none(); // Deliberately left unterminated until the emitted fast clone has been // scanned. The cached receiver below is safe only when no runtime call can // allocate, collect, or revoke an admitted layout while that clone runs. @@ -1250,8 +1568,16 @@ pub(super) fn lower( .and(I64, &fast_bits, crate::nanbox::POINTER_MASK_I64) }; let fast_scan_start = ctx.func.num_blocks(); + let installed_typed_array_views = typed_array_admission + .as_ref() + .map(|admission| super::stable_packed_typed_array::install_views(ctx, admission)); let numeric_access = if candidate.numeric_elements { - Some(build_numeric_access(ctx, &descriptor, &fast_raw)) + Some(build_numeric_access( + ctx, + &descriptor, + &fast_raw, + u32_index_elements, + )) } else { None }; @@ -1267,6 +1593,7 @@ pub(super) fn lower( valid_slot: ctx.func.alloca_entry(I1), counter_slot: ctx.func.alloca_entry(I32), value_slot: ctx.func.alloca_entry(DOUBLE), + u32_slot: u32_index_elements.then(|| ctx.func.alloca_entry(I32)), has_producer: false, }); if let Some(cache) = repeated_read_cache.as_ref() { @@ -1297,14 +1624,20 @@ pub(super) fn lower( admitted_bound: bound64, live_length_bound: matches!(candidate.bound, LoopBound::LiveLength), revalidate_each_iteration: candidate.capture_index.is_some(), - revalidate_before_indexed_read: candidate.nested_requires_access_revalidation, + revalidate_before_indexed_read, revalidation_dirty_slot: revalidation_dirty_slot.clone(), revalidation_live_raw_slot, repeated_read_cache, live_receiver_handle: Some(fast_raw), numeric_elements: candidate.numeric_elements, + u32_index_elements, + u32_component_bound: installed_typed_array_views + .as_ref() + .map(|installed| installed.common_length.clone()), + u32_out_of_bounds_label: None, numeric_access, derived_locals: std::collections::HashSet::new(), + u32_view_derived_locals: std::collections::HashMap::new(), }); super::loops::lower_for_after_init_with_i32_bound( ctx, @@ -1316,6 +1649,9 @@ pub(super) fn lower( Some((candidate.counter_id, bound_i32)), )?; ctx.stable_packed_loop_facts.pop(); + if let Some(installed) = installed_typed_array_views { + super::stable_packed_typed_array::restore_views(ctx, installed); + } if let Some(slot) = revalidation_dirty_slot.as_ref() { ctx.func .reg_counter() diff --git a/crates/perry-codegen/src/stmt/stable_packed_typed_array.rs b/crates/perry-codegen/src/stmt/stable_packed_typed_array.rs new file mode 100644 index 0000000000..15d171fe07 --- /dev/null +++ b/crates/perry-codegen/src/stmt/stable_packed_typed_array.rs @@ -0,0 +1,401 @@ +//! Loop-local guarded views for erased ECS-style component columns. +//! +//! Type information is commonly lost at the system boundary: a pair of +//! component columns arrives as `any`, while the inner entity loop repeatedly +//! executes `a[entities[j]]` / `b[entities[j]]`. Per-access dynamic TypedArray +//! dispatch is correct but disproportionately expensive. This module admits a +//! narrow fast clone when runtime evidence proves all of the facts the native +//! buffer-view lowering needs once for the complete loop: +//! +//! * the entity list has the stable packed `u32`-index proof; +//! * two to four erased locals are used only as indexed receivers in the body; +//! * every receiver is an owning `Uint32Array`; and +//! * the receiver addresses are pairwise distinct. +//! +//! Any miss enters the unchanged generic clone. The admitted HIR body is +//! deliberately call/observer-free, so no alias can expose `.buffer` and +//! convert an owning array to a side-table view while its cached data pointer +//! is live. TypedArray headers themselves are tenured and non-moving. + +use std::collections::HashMap; + +use anyhow::Result; +use perry_hir::{Expr, Stmt}; + +use crate::expr::FnCtx; +use crate::native_value::{ + AliasState, BufferElem, BufferIndexUnit, BufferViewPointerState, BufferViewSlot, LengthSource, +}; +use crate::types::{DOUBLE, I1, I32, I64, I8, PTR}; + +const MIN_RECEIVERS: usize = 2; +const MAX_RECEIVERS: usize = 4; + +#[derive(Default)] +struct LocalUses { + total: usize, + receiver: usize, + accesses: usize, +} + +#[derive(Clone)] +pub(super) struct Candidate { + local_ids: Vec, +} + +pub(super) struct Admission { + pub guard: String, + raw_receivers: Vec<(u32, String)>, +} + +pub(super) struct InstalledViews { + previous: Vec<(u32, Option)>, + pub common_length: String, +} + +fn exact_entity_read(expr: &Expr, array_id: u32, counter_id: u32) -> bool { + matches!( + expr, + Expr::IndexGet { object, index } + if matches!(object.as_ref(), Expr::LocalGet(id) if *id == array_id) + && matches!(index.as_ref(), Expr::LocalGet(id) if *id == counter_id) + ) +} + +fn collect_expr_uses(expr: &Expr, uses: &mut HashMap) { + if let Expr::LocalGet(id) = expr { + uses.entry(*id).or_default().total += 1; + } + match expr { + Expr::IndexGet { object, .. } + | Expr::IndexSet { object, .. } + | Expr::IndexUpdate { object, .. } => { + if let Expr::LocalGet(id) = object.as_ref() { + let use_ = uses.entry(*id).or_default(); + use_.receiver += 1; + use_.accesses += 1; + } + } + Expr::PutValueSet { + target, receiver, .. + } => { + if let (Expr::LocalGet(target_id), Expr::LocalGet(receiver_id)) = + (target.as_ref(), receiver.as_ref()) + { + if target_id == receiver_id { + let use_ = uses.entry(*target_id).or_default(); + // The central HIR walker visits both operands. + use_.receiver += 2; + use_.accesses += 1; + } + } + } + Expr::Closure { .. } => return, + _ => {} + } + perry_hir::walker::walk_expr_children(expr, &mut |child| collect_expr_uses(child, uses)); +} + +fn collect_stmt_uses(stmt: &Stmt, uses: &mut HashMap) { + match stmt { + Stmt::Let { + init: Some(expr), .. + } + | Stmt::Expr(expr) + | Stmt::Return(Some(expr)) + | Stmt::Throw(expr) => collect_expr_uses(expr, uses), + Stmt::If { + condition, + then_branch, + else_branch, + } => { + collect_expr_uses(condition, uses); + then_branch + .iter() + .for_each(|stmt| collect_stmt_uses(stmt, uses)); + if let Some(branch) = else_branch { + branch.iter().for_each(|stmt| collect_stmt_uses(stmt, uses)); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { condition, body } => { + collect_expr_uses(condition, uses); + body.iter().for_each(|stmt| collect_stmt_uses(stmt, uses)); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(init) = init { + collect_stmt_uses(init, uses); + } + if let Some(condition) = condition { + collect_expr_uses(condition, uses); + } + if let Some(update) = update { + collect_expr_uses(update, uses); + } + body.iter().for_each(|stmt| collect_stmt_uses(stmt, uses)); + } + Stmt::Labeled { body, .. } => collect_stmt_uses(body, uses), + Stmt::Try { + body, + catch, + finally, + } => { + body.iter().for_each(|stmt| collect_stmt_uses(stmt, uses)); + if let Some(catch) = catch { + catch + .body + .iter() + .for_each(|stmt| collect_stmt_uses(stmt, uses)); + } + if let Some(finally) = finally { + finally + .iter() + .for_each(|stmt| collect_stmt_uses(stmt, uses)); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + collect_expr_uses(discriminant, uses); + for case in cases { + if let Some(test) = case.test.as_ref() { + collect_expr_uses(test, uses); + } + case.body + .iter() + .for_each(|stmt| collect_stmt_uses(stmt, uses)); + } + } + _ => {} + } +} + +/// This is intentionally a whitelist, not a generic "may call" classifier. +/// It proves that no path in the admitted clone can run user code capable of +/// exposing a selected receiver's backing buffer. +fn safe_expr(expr: &Expr, selected: &[u32], entity_array_id: u32, counter_id: u32) -> bool { + match expr { + Expr::Undefined + | Expr::Null + | Expr::Bool(_) + | Expr::Number(_) + | Expr::Integer(_) + | Expr::LocalGet(_) => true, + Expr::IndexGet { object, index } => { + exact_entity_read(expr, entity_array_id, counter_id) + || matches!(object.as_ref(), Expr::LocalGet(id) if selected.contains(id)) + && exact_entity_read(index, entity_array_id, counter_id) + } + Expr::IndexSet { + object, + index, + value, + } => { + matches!(object.as_ref(), Expr::LocalGet(id) if selected.contains(id)) + && exact_entity_read(index, entity_array_id, counter_id) + && safe_expr(value, selected, entity_array_id, counter_id) + } + Expr::PutValueSet { + target, + key, + value, + receiver, + .. + } => { + matches!( + (target.as_ref(), receiver.as_ref()), + (Expr::LocalGet(target_id), Expr::LocalGet(receiver_id)) + if target_id == receiver_id && selected.contains(target_id) + ) && exact_entity_read(key, entity_array_id, counter_id) + && safe_expr(value, selected, entity_array_id, counter_id) + } + _ => false, + } +} + +fn safe_body(body: &[Stmt], selected: &[u32], entity_array_id: u32, counter_id: u32) -> bool { + body.iter().all(|stmt| match stmt { + Stmt::Let { + init: Some(expr), .. + } + | Stmt::Expr(expr) => safe_expr(expr, selected, entity_array_id, counter_id), + _ => false, + }) +} + +pub(super) fn find_candidate( + ctx: &FnCtx<'_>, + body: &[Stmt], + entity_array_id: u32, + counter_id: u32, + u32_index_elements: bool, +) -> Option { + if !u32_index_elements || ctx.disable_buffer_fast_path { + return None; + } + let mut uses = HashMap::new(); + body.iter() + .for_each(|stmt| collect_stmt_uses(stmt, &mut uses)); + let mut local_ids: Vec = uses + .into_iter() + .filter_map(|(id, use_)| { + if id == entity_array_id + || id == counter_id + || use_.accesses < 2 + || use_.receiver != use_.total + || !ctx.locals.contains_key(&id) + || ctx.boxed_vars.contains(&id) + || ctx.closure_captures.contains_key(&id) + || ctx.reassigned_locals.contains(&id) + || ctx.buffer_view_slots.contains_key(&id) + || !matches!( + crate::type_analysis::static_type_of(ctx, &Expr::LocalGet(id)), + None | Some(perry_hir::types::Type::Any) + | Some(perry_hir::types::Type::Unknown) + ) + { + return None; + } + Some(id) + }) + .collect(); + local_ids.sort_unstable(); + if !(MIN_RECEIVERS..=MAX_RECEIVERS).contains(&local_ids.len()) + || !safe_body(body, &local_ids, entity_array_id, counter_id) + { + return None; + } + Some(Candidate { local_ids }) +} + +pub(super) fn emit_fused_admission( + ctx: &mut FnCtx<'_>, + candidate: &Candidate, + source_receiver: &str, + bound: &str, + descriptor: &str, +) -> Result<(Admission, String)> { + let mut columns = Vec::with_capacity(MAX_RECEIVERS); + for id in &candidate.local_ids { + columns.push(crate::expr::lower_expr(ctx, &Expr::LocalGet(*id))?); + } + while columns.len() < MAX_RECEIVERS { + columns.push("0.0".to_string()); + } + let live_raw = ctx.block().call( + I64, + "js_packed_ecs_u32_loop_guard", + &[ + (DOUBLE, source_receiver), + (DOUBLE, bound), + (DOUBLE, &columns[0]), + (DOUBLE, &columns[1]), + (DOUBLE, &columns[2]), + (DOUBLE, &columns[3]), + (I32, &candidate.local_ids.len().to_string()), + (PTR, descriptor), + ], + ); + let guard = ctx.block().icmp_ne(I64, &live_raw, "0"); + let raw_receivers = candidate + .local_ids + .iter() + .enumerate() + .map(|(index, id)| { + let slot = ctx + .block() + .gep(I64, descriptor, &[(I64, &(7 + index).to_string())]); + (*id, ctx.block().load(I64, &slot)) + }) + .collect(); + Ok(( + Admission { + guard, + raw_receivers, + }, + live_raw, + )) +} + +fn reserve_alias_scope(ctx: &mut FnCtx<'_>, data_slot: &str) -> u32 { + let scope_idx = ctx.buffer_alias_base + ctx.buffer_data_slots.len() as u32; + // Loop-local views are removed before the generic clone is lowered, but + // module metadata is emitted from the final map length. Retain a synthetic + // unreachable key so scope ids stay unique and every metadata reference is + // declared. Real HIR LocalIds cannot collide because we probe all maps. + let mut reservation = u32::MAX; + while ctx.locals.contains_key(&reservation) + || ctx.module_globals.contains_key(&reservation) + || ctx.buffer_data_slots.contains_key(&reservation) + || ctx.buffer_view_slots.contains_key(&reservation) + { + reservation = reservation.wrapping_sub(1); + } + ctx.buffer_data_slots + .insert(reservation, (data_slot.to_string(), scope_idx)); + scope_idx +} + +pub(super) fn install_views(ctx: &mut FnCtx<'_>, admission: &Admission) -> InstalledViews { + let mut previous = Vec::with_capacity(admission.raw_receivers.len()); + let mut common_length: Option = None; + for (id, raw) in &admission.raw_receivers { + let (data_slot, length_slot, length) = { + let header = ctx.block().inttoptr(I64, raw); + let length = ctx.block().load(I32, &header); + let data = ctx.block().gep(I8, &header, &[(I32, "16")]); + let data_slot = ctx.func.alloca_entry(PTR); + let length_slot = ctx.func.alloca_entry(I32); + ctx.block().store(PTR, &data, &data_slot); + ctx.block().store(I32, &length, &length_slot); + (data_slot, length_slot, length) + }; + common_length = Some(if let Some(current) = common_length { + let shorter = ctx.block().icmp_ult(I32, &length, ¤t); + ctx.block().select(I1, &shorter, I32, &length, ¤t) + } else { + length.clone() + }); + let scope_idx = reserve_alias_scope(ctx, &data_slot); + let old = ctx.buffer_view_slots.insert( + *id, + BufferViewSlot { + data_slot, + length_slot: Some(length_slot), + scope_idx: Some(scope_idx), + elem: BufferElem::U32, + element_width_bytes: 4, + index_unit: BufferIndexUnit::Element, + view_byte_offset: Some(0), + length_offset_from_data: -16, + alias: AliasState::NoAliasGuarded { + guard_id: "stable_packed_u32_columns".to_string(), + }, + length_source: Some(LengthSource::Unknown), + native_owned: None, + pointer_state: BufferViewPointerState::Stable, + storage_inline_proven: true, + }, + ); + previous.push((*id, old)); + } + InstalledViews { + previous, + common_length: common_length.expect("typed-array candidate has at least two receivers"), + } +} + +pub(super) fn restore_views(ctx: &mut FnCtx<'_>, installed: InstalledViews) { + for (id, old) in installed.previous { + if let Some(old) = old { + ctx.buffer_view_slots.insert(id, old); + } else { + ctx.buffer_view_slots.remove(&id); + } + } +} diff --git a/crates/perry-runtime/src/array/subclass.rs b/crates/perry-runtime/src/array/subclass.rs index 9ec0ef3ee5..9c5d60197a 100644 --- a/crates/perry-runtime/src/array/subclass.rs +++ b/crates/perry-runtime/src/array/subclass.rs @@ -21,14 +21,18 @@ use crate::value::JSValue; // // bit 0 existing custom-[[Prototype]] flag // bit 1 payload valid -// bits 8..31 verified numeric prefix bound (24 bits, max 16,000,000) +// bit 2 compact nonnegative-int entity proof (mode 2) +// bits 8..31 verified prefix bound (24 bits, max 16,000,000) // bits 32..63 exact semantic ShapeId const PACKED_NUMERIC_META_VALID: u64 = 1 << 1; +const PACKED_NUMERIC_META_U32: u64 = 1 << 2; const PACKED_NUMERIC_META_BOUND_SHIFT: u32 = 8; const PACKED_NUMERIC_META_BOUND_MASK: u64 = 0x00FF_FFFF << PACKED_NUMERIC_META_BOUND_SHIFT; const PACKED_NUMERIC_META_SHAPE_MASK: u64 = 0xFFFF_FFFF_0000_0000; -const PACKED_NUMERIC_META_MASK: u64 = - PACKED_NUMERIC_META_VALID | PACKED_NUMERIC_META_BOUND_MASK | PACKED_NUMERIC_META_SHAPE_MASK; +const PACKED_NUMERIC_META_MASK: u64 = PACKED_NUMERIC_META_VALID + | PACKED_NUMERIC_META_U32 + | PACKED_NUMERIC_META_BOUND_MASK + | PACKED_NUMERIC_META_SHAPE_MASK; // #8655: Array-subclass instances use ordinary ObjectHeader property slots, // but their hot numeric reads have a much stronger invariant than a generic @@ -317,6 +321,7 @@ unsafe fn subclass_numeric_prefix_is_proven( obj: *const ObjectHeader, shape_id: u32, bound: u32, + require_u32: bool, ) -> bool { let Some(header) = crate::value::addr_class::try_read_gc_header(obj as usize) else { return false; @@ -334,8 +339,13 @@ unsafe fn subclass_numeric_prefix_is_proven( let payload_valid = flags & PACKED_NUMERIC_META_VALID != 0; let proven_bound = ((flags & PACKED_NUMERIC_META_BOUND_MASK) >> PACKED_NUMERIC_META_BOUND_SHIFT) as u32; + let exact_u32 = flags & PACKED_NUMERIC_META_U32 != 0; let proven_shape = (flags >> 32) as u32; - if payload_valid && proven_shape == shape_id && proven_bound >= bound { + if payload_valid + && proven_shape == shape_id + && proven_bound >= bound + && require_u32 == exact_u32 + { return true; } clear_packed_subclass_numeric_proof(obj as *mut ObjectHeader); @@ -347,6 +357,7 @@ unsafe fn publish_subclass_numeric_prefix( obj: *const ObjectHeader, shape_id: u32, bound: u32, + exact_u32: bool, ) -> bool { let meta = (*obj).meta; if meta.is_null() || bound > 16_000_000 { @@ -355,6 +366,11 @@ unsafe fn publish_subclass_numeric_prefix( let flags = (*meta).flags; (*meta).flags = (flags & !PACKED_NUMERIC_META_MASK) | PACKED_NUMERIC_META_VALID + | if exact_u32 { + PACKED_NUMERIC_META_U32 + } else { + 0 + } | (u64::from(bound) << PACKED_NUMERIC_META_BOUND_SHIFT) | (u64::from(shape_id) << 32); let Some(header) = crate::value::addr_class::try_read_gc_header(obj as usize) else { @@ -374,12 +390,13 @@ unsafe fn ensure_subclass_numeric_prefix( obj: *const ObjectHeader, layout: DenseSubclassLayout, bound: u32, + require_u32: bool, ) -> bool { if bound == 0 { return true; } let shape_id = (*obj).parent_class_id; - if subclass_numeric_prefix_is_proven(obj, shape_id, bound) { + if subclass_numeric_prefix_is_proven(obj, shape_id, bound, require_u32) { return true; } for index in 0..bound { @@ -406,7 +423,7 @@ unsafe fn ensure_subclass_numeric_prefix( .add(slot as usize) }; let value = JSValue::from_bits(ptr::read(value_ptr)); - if value.is_int32() { + let number = if value.is_int32() { // `push(i)` commonly stores Perry's compact INT32 Number tag. The // direct clone consumes raw doubles, so normalize that Number to // its representation-equivalent f64 bits during the one-time @@ -415,12 +432,39 @@ unsafe fn ensure_subclass_numeric_prefix( // barrier nor a layout downgrade. // GC_STORE_AUDIT(POINTER_FREE): canonical raw-f64 Number bits // replace compact int32 Number bits in an already numeric slot. - ptr::write(value_ptr, (value.as_int32() as f64).to_bits()); + let integer = value.as_int32(); + let number = integer as f64; + if !require_u32 { + ptr::write(value_ptr, number.to_bits()); + } + number } else if !value.is_number() { return false; + } else { + value.as_number() + }; + if require_u32 { + // ECS entity ids in this tier are normalized to Perry's ordinary + // compact INT32 Number representation. Generic reads still + // observe the same JS Number, while generated component access + // can consume the low native lane without an f64 conversion. + // Values outside the nonnegative i31 subset retain the generic + // loop; no public behavior is narrowed. + if !number.is_finite() + || number < 0.0 + || number > i32::MAX as f64 + || number.fract() != 0.0 + { + return false; + } + if !value.is_int32() { + // GC_STORE_AUDIT(POINTER_FREE): compact Number bits replace + // raw-f64 Number bits in an already numeric slot. + ptr::write(value_ptr, JSValue::int32(number as i32).bits()); + } } } - publish_subclass_numeric_prefix(obj, shape_id, bound) + publish_subclass_numeric_prefix(obj, shape_id, bound, require_u32) } #[inline] @@ -650,6 +694,13 @@ fn packed_arraylike_loop_guard( if bound > length || length > capacity || capacity > 16_000_000 { return None; } + // Mode 2 is the stronger ECS entity-id contract. Plain Arrays do not + // carry the per-prefix payload needed to distinguish an exact-u32 + // proof from their whole-array raw-f64 bit, so retain the generic + // clone for them. + if require_numeric >= 2 { + return None; + } if require_numeric != 0 { // The raw-f64 invariant is an O(1) GcHeader bit after its first // self-healing scan, and every nonnumeric Array write already @@ -694,8 +745,19 @@ fn packed_arraylike_loop_guard( if bound > length || bound > layout.dense_prefix_len || length > 16_000_000 { return None; } + if require_numeric >= 2 { + // The direct ECS clone uses one preheader base. Reject the uncommon + // layout whose admitted prefix straddles inline object fields and the + // object-owned spill array; the unchanged generic clone handles it. + let Some(end_slot) = layout.element_base.checked_add(bound) else { + return None; + }; + if layout.element_base < layout.live_inline_slots && end_slot > layout.live_inline_slots { + return None; + } + } if require_numeric != 0 { - if !unsafe { ensure_subclass_numeric_prefix(object, layout, bound) } { + if !unsafe { ensure_subclass_numeric_prefix(object, layout, bound, require_numeric >= 2) } { return None; } } @@ -744,6 +806,58 @@ pub extern "C" fn js_packed_arraylike_loop_guard_live( .unwrap_or(0) } +/// Fused admission for the call-free ECS swap clone. The source layout and +/// exact-u32 prefix are validated by the packed-loop guard, while two to four +/// erased component columns must be pairwise-distinct owning Uint32Arrays. +/// The first seven output words retain the ordinary source descriptor; words +/// 7..10 receive up to four stable component header addresses. A zero return +/// leaves the complete operation to the unchanged generic loop. +#[no_mangle] +#[inline(never)] +pub extern "C" fn js_packed_ecs_u32_loop_guard( + receiver: f64, + bound: f64, + column0: f64, + column1: f64, + column2: f64, + column3: f64, + column_count: i32, + out: *mut u64, +) -> i64 { + if out.is_null() || !(2..=4).contains(&column_count) { + return 0; + } + let Some((_, live_raw)) = packed_arraylike_loop_guard(receiver, bound, 2, out) else { + return 0; + }; + let columns = [column0, column1, column2, column3]; + let mut addresses = [0usize; 4]; + let mut common_length = None; + for index in 0..column_count as usize { + let address = crate::typedarray::inline_u32_addr(columns[index]); + if address == 0 || addresses[..index].contains(&address) { + return 0; + } + let length = unsafe { (*(address as *const crate::typedarray::TypedArrayHeader)).length }; + if common_length.is_some_and(|common| common != length) { + return 0; + } + common_length = Some(length); + addresses[index] = address; + } + unsafe { + for (index, address) in addresses + .iter() + .copied() + .take(column_count as usize) + .enumerate() + { + out.add(7 + index).write(address as u64); + } + } + live_raw as i64 +} + /// Revalidate a receiver against facts published by a successful complete /// loop admission. Unlike the admitting guard, this path never rediscovers or /// republishes the dense layout: exact class/ShapeId and header checks make the @@ -843,6 +957,7 @@ pub extern "C" fn js_packed_arraylike_loop_revalidate_live( || (!live_length_bound && length < admitted_bound) || length > capacity || capacity > 16_000_000 + || require_numeric >= 2 || (require_numeric != 0 && header._reserved & crate::gc::GC_ARRAY_RAW_F64_LAYOUT == 0) { return 0; @@ -881,7 +996,12 @@ pub extern "C" fn js_packed_arraylike_loop_revalidate_live( || (!live_length_bound && length < admitted_bound) || (require_numeric != 0 && !unsafe { - subclass_numeric_prefix_is_proven(object, (*object).parent_class_id, admitted_bound) + subclass_numeric_prefix_is_proven( + object, + (*object).parent_class_id, + admitted_bound, + require_numeric >= 2, + ) }) { return 0; @@ -950,7 +1070,12 @@ fn revalidate_admitted_subclass_live( || (bound != -1.0 && length < admitted_bound) || (require_numeric != 0 && !unsafe { - subclass_numeric_prefix_is_proven(object, (*object).parent_class_id, admitted_bound) + subclass_numeric_prefix_is_proven( + object, + (*object).parent_class_id, + admitted_bound, + require_numeric >= 2, + ) }) { return 0; diff --git a/crates/perry-runtime/src/array/subclass_tests.rs b/crates/perry-runtime/src/array/subclass_tests.rs index 84083167f4..2b67a7ad5e 100644 --- a/crates/perry-runtime/src/array/subclass_tests.rs +++ b/crates/perry-runtime/src/array/subclass_tests.rs @@ -20,7 +20,7 @@ use super::subclass::{ array_object_receiver, array_subclass_fast_index_get, array_subclass_fast_length, is_array_subclass_class_id, js_packed_arraylike_index_get, js_packed_arraylike_loop_guard, - raw_receiver_is_heap_object, + js_packed_ecs_u32_loop_guard, raw_receiver_is_heap_object, }; use crate::array::{clean_arr_ptr, js_array_alloc, ArrayHeader}; use crate::object::{js_object_alloc, ObjectHeader}; @@ -265,6 +265,111 @@ fn packed_numeric_proof_is_retired_by_sso_index_overwrite() { ); } +#[test] +fn fused_ecs_guard_requires_distinct_owning_u32_columns_and_exact_entity_ids() { + let class_id = 0x0074_8691; + crate::object::js_register_class_parent(class_id, CLASS_ID_ARRAY); + let obj = js_object_alloc(class_id, 2); + assert!(!obj.is_null()); + let receiver = crate::value::js_nanbox_pointer(obj as i64); + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver_h = scope.root_nanbox_f64(receiver); + crate::node_stream::js_array_subclass_init(receiver_h.get_nanbox_f64(), 0.0); + for (index, value) in [3.0, 12.0, 7.0].into_iter().enumerate() { + let live_raw = receiver_h.get_nanbox_f64().to_bits() & 0x0000_FFFF_FFFF_FFFF; + crate::object::js_object_set_index_polymorphic(live_raw as i64, index as f64, value); + } + + let left = + crate::typedarray::js_typed_array_new_empty(crate::typedarray::KIND_UINT32 as i32, 16); + let right = + crate::typedarray::js_typed_array_new_empty(crate::typedarray::KIND_UINT32 as i32, 16); + let wrong_kind = + crate::typedarray::js_typed_array_new_empty(crate::typedarray::KIND_INT32 as i32, 16); + let short = + crate::typedarray::js_typed_array_new_empty(crate::typedarray::KIND_UINT32 as i32, 8); + let left_value = crate::value::js_nanbox_pointer(left as i64); + let right_value = crate::value::js_nanbox_pointer(right as i64); + let wrong_value = crate::value::js_nanbox_pointer(wrong_kind as i64); + let short_value = crate::value::js_nanbox_pointer(short as i64); + let mut facts = [0u64; 11]; + + assert_ne!( + js_packed_ecs_u32_loop_guard( + receiver_h.get_nanbox_f64(), + 3.0, + left_value, + right_value, + 0.0, + 0.0, + 2, + facts.as_mut_ptr(), + ), + 0 + ); + assert_eq!(facts[7], left as u64); + assert_eq!(facts[8], right as u64); + assert_eq!( + js_packed_ecs_u32_loop_guard( + receiver_h.get_nanbox_f64(), + 3.0, + left_value, + left_value, + 0.0, + 0.0, + 2, + facts.as_mut_ptr(), + ), + 0, + "aliased component columns must retain generic assignment semantics" + ); + assert_eq!( + js_packed_ecs_u32_loop_guard( + receiver_h.get_nanbox_f64(), + 3.0, + left_value, + wrong_value, + 0.0, + 0.0, + 2, + facts.as_mut_ptr(), + ), + 0, + "non-Uint32 component columns must not borrow the direct clone" + ); + assert_eq!( + js_packed_ecs_u32_loop_guard( + receiver_h.get_nanbox_f64(), + 3.0, + left_value, + short_value, + 0.0, + 0.0, + 2, + facts.as_mut_ptr(), + ), + 0, + "unequal column lengths need per-column out-of-bounds semantics" + ); + + let live_raw = receiver_h.get_nanbox_f64().to_bits() & 0x0000_FFFF_FFFF_FFFF; + crate::object::js_object_set_index_polymorphic(live_raw as i64, 1.0, 12.5); + assert_eq!( + js_packed_ecs_u32_loop_guard( + receiver_h.get_nanbox_f64(), + 3.0, + left_value, + right_value, + 0.0, + 0.0, + 2, + facts.as_mut_ptr(), + ), + 0, + "a fractional entity id must revoke the exact-u32 source proof" + ); +} + #[test] fn dense_array_subclass_guard_rejects_other_object_brands() { let obj = js_object_alloc(0x0074_8656, 2); diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index df05d1fb87..985c9c7902 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -1529,8 +1529,9 @@ pub struct ObjectMeta { /// `accessor_descriptors` table twin of `attr_key_bits`. pub accessor_key_bits: u64, /// Object-only state and compact scalar proof payloads. Bit 0 is the - /// custom-prototype flag. #8690 reserves bit 1 plus bits 8..63 for the - /// packed Array-subclass numeric-prefix proof (verified bound + ShapeId); + /// custom-prototype flag. #8690 reserves bits 1..2 and 8..63 for the + /// packed Array-subclass numeric-prefix proof (kind, verified bound, and + /// ShapeId); /// its address-reuse-safe authority is a type-specific GcHeader bit. /// In particular, GcHeader bit 12 is `GC_OBJ_TYPED_LAYOUT_INTACT`, so /// using that word for prototype divergence made every typed-layout diff --git a/crates/perry-runtime/src/typedarray/mod.rs b/crates/perry-runtime/src/typedarray/mod.rs index aa419894b2..d2af56b85a 100644 --- a/crates/perry-runtime/src/typedarray/mod.rs +++ b/crates/perry-runtime/src/typedarray/mod.rs @@ -585,6 +585,27 @@ pub extern "C" fn js_typed_array_masked_window_data_ptr(receiver: f64) -> i64 { data_ptr(addr as *const TypedArrayHeader) as i64 } +/// One-time loop admission primitive for erased ECS component columns. Return +/// the stable owning-header address only for an exact inline `Uint32Array`. +/// Consult the authoritative registry instead of the tiny direct-mapped kind +/// cache: sibling columns can collide there, which is harmless for individual +/// accesses but must not make a whole-loop proof spuriously fail forever. +#[inline] +pub(crate) fn inline_u32_addr(receiver: f64) -> usize { + let value = crate::value::JSValue::from_bits(receiver.to_bits()); + if !value.is_pointer() { + return 0; + } + let addr = value.as_pointer::() as usize; + if lookup_typed_array_kind(addr) != Some(KIND_UINT32) + || crate::native_arena::is_native_typed_view(addr as *const TypedArrayHeader) + || crate::typedarray_view::view_meta_of(addr).is_some() + { + return 0; + } + addr +} + #[inline] pub(crate) fn data_ptr_mut(ta: *mut TypedArrayHeader) -> *mut u8 { unsafe {