From 16dae5696d76b27fe90cda678446e4bb086bc93a Mon Sep 17 00:00:00 2001 From: Ralph Date: Sun, 5 Jul 2026 22:17:03 -0700 Subject: [PATCH 1/3] =?UTF-8?q?perf(codegen,runtime):=20#5093=20=E2=80=94?= =?UTF-8?q?=20class-field=20loop=20versioning:=20monomorphic=20field=20hot?= =?UTF-8?q?=20loops=20run=20call-free?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collapse #5093 tracks: for (let i = k0; i < B; i++) whose single statement is a raw-f64 class-field store (or scalar accumulator) on a loop-invariant receiver of statically known class now lowers as a versioned loop pair. The preheader runs the inline shape check ONCE (enable flag, pointer tag/band, gc-type, not-forwarded, REGULAR, class_id, field_count, keys identity, typed-layout intact bit, and not-frozen when the loop stores); the fast clone does bare GEP+load/store on the preheader-cached object pointer with NO guard and NO fallback call, so the receiver stays in a register and LLVM collapses the body to load/fadd/store. Raw stores keep an inline plain-finite value check that side-exits to the slow clone's preheader BEFORE any side effect of the current iteration commits (single-effect-last body shape, per the #6011 range-loop protocol), and the slow clone — today's guarded loop — resumes at the current i. Safety (memory-corruption class): the fast clone is call-free ⇒ allocation-free ⇒ no GC can move the receiver or run any runtime path that mutates class_id / keys / field_count / intact / frozen / the enable flag mid-loop (all mutation sites are runtime calls; GC triggers only on allocation). The matcher enforces call-freeness by shape (pure-arithmetic walker mirroring the packed-f64 range loop's, property name + class name denylists mirroring every dispatch gate ahead of the class-field diamonds), and lowering VERIFIES it: the preheader's deref block is left unterminated until the fast clone's emitted blocks are scanned for non-intrinsic calls — an unpredicted call wires the preheader straight to the slow clone instead. Also fixes the regression that had silently disabled the #5198 inline class-field fast path in every program: the runtime bootstrap installs fn.name property descriptors on builtin closures through the generic defineProperty path, and set_property_attrs/set_accessor_descriptor flipped the process-global PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED kill switch on ANY descriptor install. The flip is now scoped to descriptor targets that can actually shadow a guarded class-field slot access — a class instance (nonzero class_id) or a registered class prototype object (exactly what descriptor_blocks_class_field_{get,set} walk). GLOBAL_DESCRIPTORS_IN_USE is still set unconditionally, so guard calls keep routing through the full contract whenever any descriptor exists. 09_method_calls: ~3300ms -> 10-16ms (10M counter.increment() calls), vs node ~22ms on the same machine; issue target was <= ~50ms. PERRY_DISABLE_CLASS_FIELD_INLINE=1 escape hatch verified (routes the preheader to the slow clone, ~3200ms). Validation: new parity test test_class_field_loop_versioned.ts (hot loop, decl-init, multi-read, overflow-to-Infinity side exit, subclass class_id miss, frozen TypeError, defineProperty-accessor disable, accumulator) byte-identical to node; test_class_field_raw_f64_downgrade still byte-identical; cargo test -p perry-runtime / -p perry-codegen at parity with pristine main (the two standing failures — dyn_prop mark order dependence, pod_field_read native-proof — pre-exist unchanged); class-suite parity spot check 11/13 with both diffs pre-existing (known_failures #2159, node-crashing test_class_field_layout). Co-Authored-By: Claude Fable 5 --- crates/perry-codegen/src/block.rs | 17 + crates/perry-codegen/src/codegen/closure.rs | 1 + crates/perry-codegen/src/codegen/entry.rs | 2 + crates/perry-codegen/src/codegen/function.rs | 1 + crates/perry-codegen/src/codegen/method.rs | 2 + .../src/expr/class_field_inline_guard.rs | 134 +++++ crates/perry-codegen/src/expr/mod.rs | 57 +- crates/perry-codegen/src/expr/property_get.rs | 63 ++ .../src/expr/property_get/helpers.rs | 62 ++ crates/perry-codegen/src/expr/property_set.rs | 97 +++ crates/perry-codegen/src/stmt/loops.rs | 552 ++++++++++++++++++ test-files/test_class_field_loop_versioned.ts | 122 ++++ 12 files changed, 1109 insertions(+), 1 deletion(-) create mode 100644 test-files/test_class_field_loop_versioned.ts diff --git a/crates/perry-codegen/src/block.rs b/crates/perry-codegen/src/block.rs index e8d83dcc8d7..bbe5e0f44bc 100644 --- a/crates/perry-codegen/src/block.rs +++ b/crates/perry-codegen/src/block.rs @@ -124,6 +124,23 @@ impl LlBlock { self.terminated } + /// #5093: true if this block contains a `call` to anything other than an + /// `@llvm.*` intrinsic or an inline-asm marker. The class-field versioned + /// loop uses this to verify AT COMPILE TIME that its fast clone came out + /// call-free (no runtime call ⇒ no allocation ⇒ no GC ⇒ the + /// preheader-cached receiver pointer cannot move and the hoisted shape + /// check cannot be invalidated mid-loop). Intrinsic/libm-style calls + /// never enter the perry runtime, so they cannot trigger a collection. + pub fn contains_gc_unsafe_call(&self) -> bool { + self.instructions.iter().any(|line| { + let Some(pos) = line.find("call ") else { + return false; + }; + let callee = &line[pos..]; + !callee.contains("@llvm.") && !callee.contains(" asm ") + }) + } + /// Allocate a fresh SSA register name in the enclosing function's /// virtual register pool (e.g. `"%r42"`). Safe to call between /// `gep` / other instructions that may emit sub-registers. Pair with diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 375c8b3a5c2..e201bcf23d9 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -809,6 +809,7 @@ pub(super) fn compile_closure( cached_lengths: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), + class_field_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), i1_local_slots: HashMap::new(), index_used_locals: native_facts.index_used_locals(), diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 224e45b1e5e..9aea84e19af 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -642,6 +642,7 @@ pub(super) fn compile_module_entry( cached_lengths: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), + class_field_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), i1_local_slots: HashMap::new(), index_used_locals: main_native_facts.index_used_locals(), @@ -1176,6 +1177,7 @@ pub(super) fn compile_module_entry( cached_lengths: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), + class_field_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), i1_local_slots: HashMap::new(), index_used_locals: init_native_facts.index_used_locals(), diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 332abb7bfed..9389fa3d5fb 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -545,6 +545,7 @@ pub(super) fn compile_function( cached_lengths: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), + class_field_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), i1_local_slots: HashMap::new(), index_used_locals: native_facts.index_used_locals(), diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index 72d3fd9d5e8..ee82ad2daeb 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -429,6 +429,7 @@ pub(super) fn compile_method( cached_lengths: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), + class_field_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), i1_local_slots: HashMap::new(), index_used_locals: native_facts.index_used_locals(), @@ -1304,6 +1305,7 @@ pub(super) fn compile_static_method( cached_lengths: HashMap::new(), bounded_index_pairs: Vec::new(), packed_f64_loop_facts: Vec::new(), + class_field_loop_facts: Vec::new(), i32_counter_slots: HashMap::new(), i1_local_slots: HashMap::new(), index_used_locals: native_facts.index_used_locals(), diff --git a/crates/perry-codegen/src/expr/class_field_inline_guard.rs b/crates/perry-codegen/src/expr/class_field_inline_guard.rs index d8853e97be2..d4d5243225d 100644 --- a/crates/perry-codegen/src/expr/class_field_inline_guard.rs +++ b/crates/perry-codegen/src/expr/class_field_inline_guard.rs @@ -39,6 +39,140 @@ const OBJ_FLAG_FROZEN_BIT: &str = "1"; // OBJ_FLAG_FROZEN (0x01) const OBJ_FLAG_HAS_DESCRIPTORS_BIT: &str = "2048"; // OBJ_FLAG_HAS_DESCRIPTORS (0x800) const F64_EXP_MASK: &str = "9218868437227405312"; // 0x7FF0_0000_0000_0000 +/// Emit the `i1` "plain finite number" predicate on a value's raw bits: true +/// iff the exponent field is not all-ones. Rejects ±Inf, every NaN (canonical +/// or boxed), and therefore every NaN-box tag — exactly the values the +/// runtime set contract (`is_plain_number_bits`) refuses to store raw. +pub(crate) fn emit_plain_finite_number_check( + blk: &mut crate::block::LlBlock, + value_bits: &str, +) -> String { + let exp = blk.and(I64, value_bits, F64_EXP_MASK); + blk.icmp_ne(I64, &exp, F64_EXP_MASK) +} + +/// #5093 loop versioning: emit the whole-loop shape check in a versioned +/// loop's preheader. +/// +/// This is the hoisted form of [`emit_class_field_inline_precheck`]: the same +/// strict subset of the runtime `class_field_fast_contract`, evaluated ONCE +/// before loop entry, branching to `fast_label` (the fast clone's preheader) +/// when the monomorphic shape holds and to `slow_label` (the slow clone's +/// preheader, i.e. today's guarded loop) otherwise. Evaluating it once is +/// sound only because the fast clone's body is call-free (matcher-enforced in +/// `stmt/loops.rs`): with no calls there is no allocation, so no GC can move +/// the object or run any of the runtime paths that mutate class_id / +/// keys_array / field_count / the typed-layout intact bit / the frozen bit / +/// the process-global enable flag mid-loop. +/// +/// `max_field_index` is the largest packed slot index the loop touches +/// (`field_count ugt max_field_index` covers every access). `require_raw_f64` +/// adds the per-object typed-layout intact check (any raw-f64 read or write in +/// the loop); `require_not_frozen` adds the frozen-bit check (any write in the +/// loop). Per-store value checks are NOT emitted here — the fast clone's +/// stores keep their inline plain-finite check and side-exit to `slow_label`. +/// +/// Returns `(obj_ptr, shape_ok)`: the SSA name of the receiver object pointer +/// (`inttoptr` of `obj_handle`) and the accumulated `i1` shape predicate, +/// both emitted in the deref block. The deref block is deliberately left +/// UNTERMINATED with `ctx.current_block` pointing at it: the caller lowers +/// the fast clone first, verifies it really came out call-free +/// (`LlBlock::contains_gc_unsafe_call`), and only then terminates the deref +/// block — `cond_br(shape_ok, fast, slow)` on success, or an unconditional +/// branch to the slow clone if some unpredicted lowering path emitted a call +/// (never enter a fast clone whose call-freeness is unproven). The deref +/// block dominates the fast preheader, so the fast clone may use `obj_ptr` +/// directly for raw slot access. +#[allow(clippy::too_many_arguments)] +pub(crate) fn emit_class_field_loop_preheader_check( + ctx: &mut FnCtx, + obj_bits: &str, + obj_handle: &str, + expected_class_id: &str, + expected_keys: &str, + max_field_index: u32, + require_raw_f64: bool, + require_not_frozen: bool, + slow_label: &str, +) -> (String, String) { + let deref_idx = ctx.new_block("class_field_loop.preheader.deref"); + let deref_label = ctx.block_label(deref_idx); + let max_field_index_str = max_field_index.to_string(); + + // Gate: enable flag first (volatile — the runtime flips it sticky 0 -> 1 + // when descriptors / typed feedback / verify mode come into use), then + // prove the receiver is a real heap object before dereferencing. + { + let blk = ctx.block(); + let flag = blk.load_volatile(I8, "@PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED"); + let flag_ok = blk.icmp_eq(I8, &flag, "0"); + let tag = blk.lshr(I64, obj_bits, "48"); + let is_ptr = blk.icmp_eq(I64, &tag, POINTER_TAG_HI16); + let above_band = blk.icmp_ugt(I64, obj_handle, HANDLE_BAND_TOP); + let ptr_safe = blk.and(I1, &is_ptr, &above_band); + let can_inline = blk.and(I1, &ptr_safe, &flag_ok); + blk.cond_br(&can_inline, &deref_label, slow_label); + } + + ctx.current_block = deref_idx; + { + let blk = ctx.block(); + let obj_ptr = blk.inttoptr(I64, obj_handle); + + // GcHeader (precedes the object by 8 bytes): obj_type @-8 (i8), + // gc_flags @-7 (i8), _reserved @-6 (i16). + let gtype_ptr = blk.gep(I8, &obj_ptr, &[(I64, "-8")]); + let gtype = blk.load(I8, >ype_ptr); + let gtype_ok = blk.icmp_eq(I8, >ype, GC_TYPE_OBJECT); + + let gflags_ptr = blk.gep(I8, &obj_ptr, &[(I64, "-7")]); + let gflags = blk.load(I8, &gflags_ptr); + let fwd = blk.and(I8, &gflags, GC_FLAG_FORWARDED_I8); + let not_fwd = blk.icmp_eq(I8, &fwd, "0"); + + let res_ptr = blk.gep(I8, &obj_ptr, &[(I64, "-6")]); + let reserved = blk.load(I16, &res_ptr); + + // ObjectHeader: object_type @0 (i32)==REGULAR, class_id @4 (i32), + // field_count @12 (i32), keys_array @16 (i64). + let object_type = blk.load(I32, &obj_ptr); + let ot_ok = blk.icmp_eq(I32, &object_type, OBJECT_TYPE_REGULAR); + + let cid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "4")]); + let class_id = blk.load(I32, &cid_ptr); + let cid_ok = blk.icmp_eq(I32, &class_id, expected_class_id); + + let fc_ptr = blk.gep(I8, &obj_ptr, &[(I64, "12")]); + let field_count = blk.load(I32, &fc_ptr); + let fc_ok = blk.icmp_ugt(I32, &field_count, &max_field_index_str); + + let ka_ptr = blk.gep(I8, &obj_ptr, &[(I64, "16")]); + let keys_array = blk.load(I64, &ka_ptr); + let ka_ok = blk.icmp_eq(I64, &keys_array, expected_keys); + + let mut acc = blk.and(I1, >ype_ok, ¬_fwd); + acc = blk.and(I1, &acc, &ot_ok); + acc = blk.and(I1, &acc, &cid_ok); + acc = blk.and(I1, &acc, &fc_ok); + acc = blk.and(I1, &acc, &ka_ok); + + if require_raw_f64 { + let intact = blk.and(I16, &reserved, TYPED_LAYOUT_INTACT_BIT); + let intact_ok = blk.icmp_ne(I16, &intact, "0"); + acc = blk.and(I1, &acc, &intact_ok); + } + + if require_not_frozen { + let frozen = blk.and(I16, &reserved, OBJ_FLAG_FROZEN_BIT); + let not_frozen = blk.icmp_eq(I16, &frozen, "0"); + acc = blk.and(I1, &acc, ¬_frozen); + } + + // No terminator: the caller branches after verifying the fast clone. + (obj_ptr, acc) + } +} + /// Emit the inline class-field shape pre-check. /// /// Before calling, the caller must have already created `fast_label` (the slot diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index b1c528edaff..8edef47496b 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -671,6 +671,20 @@ pub(crate) struct FnCtx<'a> { /// `i` in bounds. pub packed_f64_loop_facts: Vec, + /// #5093: scoped loop-versioning facts for monomorphic class-field loops. + /// Pushed only around the FAST clone of `lower_class_field_versioned_for` + /// (`stmt/loops.rs`): the loop preheader already proved the receiver's + /// exact class shape (class_id, keys identity, field_count, typed-layout + /// intact bit, not-frozen, inline-guard enable flag), and the matcher + /// proved the fast body is call-free (no allocation ⇒ no GC ⇒ the cached + /// `obj_ptr` cannot move and the shape cannot change mid-loop). Inside + /// that clone, `recv.field` GET/SET on a tracked raw-f64 field lowers to + /// a bare GEP+load/store on `obj_ptr` with no guard and no fallback call; + /// SET keeps an inline plain-finite-number check that side-exits to the + /// slow clone's preheader BEFORE committing any side effect of the + /// current iteration. + pub class_field_loop_facts: Vec, + /// Parallel i32 counter slots for integer loop counters that are /// used as bounded array indices. When a for-loop counter is in /// `integer_locals` AND appears in `bounded_index_pairs`, `lower_for` @@ -1157,6 +1171,47 @@ pub(crate) struct PackedF64LoopFact { pub allow_holes: bool, } +/// #5093: one fact per (receiver, versioned loop). See +/// `FnCtx::class_field_loop_facts` for the safety argument. +#[derive(Debug, Clone)] +pub(crate) struct ClassFieldLoopFact { + /// LocalId of the loop-invariant receiver (plain local or module global). + pub recv_local_id: u32, + pub scope_id: u32, + /// Class the preheader check proved exactly (by class_id compare). + pub class_name: String, + /// SSA name of the receiver object pointer, `inttoptr`'d in the + /// preheader's deref block. Dominates every block of the fast clone and + /// is stable for the clone's whole lifetime because the fast body is + /// call-free (no allocation ⇒ no GC ⇒ no evacuation). + pub obj_ptr: String, + /// Slow clone's preheader label. A raw-f64 store whose value fails the + /// inline plain-finite check branches here; the slow clone re-executes + /// the current iteration from scratch (no side effect has committed yet). + pub side_exit_label: String, + /// property name -> packed slot index. Every entry is a declared raw-f64 + /// candidate field validated by the matcher via + /// `class_field_global_index` / `class_field_declared_type`. + pub fields: std::collections::BTreeMap, +} + +/// Find the innermost active class-field loop fact covering +/// `(recv_local_id, class_name, property)`. Returns the fact and the packed +/// slot index of the field. +pub(crate) fn class_field_loop_fact_lookup<'f>( + facts: &'f [ClassFieldLoopFact], + recv_local_id: u32, + class_name: &str, + property: &str, +) -> Option<(&'f ClassFieldLoopFact, u32)> { + facts.iter().rev().find_map(|fact| { + if fact.recv_local_id != recv_local_id || fact.class_name != class_name { + return None; + } + fact.fields.get(property).map(|idx| (fact, *idx)) + }) +} + impl<'a> FnCtx<'a> { pub fn next_loop_proof_scope_id(&mut self) -> u32 { let id = self.next_loop_proof_scope_id; @@ -1235,7 +1290,7 @@ pub(crate) use index_get::packed_f64_loop_index_parts; mod index_set; mod instance_misc1; pub(crate) use instance_misc1::builtin_parent_reserved_class_id; -mod class_field_inline_guard; +pub(crate) mod class_field_inline_guard; mod js_runtime; mod literals_vars; mod logical_collections; diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index 79b6fb7dee8..2b30228ae04 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -1156,6 +1156,69 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { ctx.class_ids.get(&class_name), ctx.class_keys_globals.get(&class_name).cloned(), ) { + // #5093 loop versioning: inside the fast clone of a + // class-field versioned loop, a tracked field read on + // the proven receiver lowers to a bare slot load on + // the preheader-cached object pointer — no shape + // check, no guard call, no fallback (the preheader + // proved the shape once and the call-free clone keeps + // it true; see stmt/loops.rs). + let loop_fact_ptr = match object.as_ref() { + Expr::LocalGet(recv_id) => crate::expr::class_field_loop_fact_lookup( + &ctx.class_field_loop_facts, + *recv_id, + &class_name, + property, + ) + .filter(|(_, loop_idx)| *loop_idx == field_index) + .map(|(fact, _)| fact.obj_ptr.clone()), + _ => None, + }; + if let Some(obj_ptr) = loop_fact_ptr { + let field_idx_str = field_index.to_string(); + let blk = ctx.block(); + let fields_base = blk.gep(I8, &obj_ptr, &[(I64, "24")]); + let field_ptr = blk.gep(DOUBLE, &fields_base, &[(I64, &field_idx_str)]); + let val = blk.load(DOUBLE, &field_ptr); + let fast = LoweredValue { + semantic: SemanticKind::JsNumber, + rep: NativeRep::F64, + llvm_ty: DOUBLE, + value: val.clone(), + }; + ctx.record_lowered_value_with_access_mode_and_facts( + "ClassFieldGet", + None, + "class_field_get.loop_raw_f64_load", + &fast, + Some(BoundsState::Guarded { + guard_id: "class_field_loop_preheader_check".to_string(), + }), + None, + Some(BufferAccessMode::CheckedNative), + None, + None, + None, + vec![raw_f64_layout_fact( + None, + "consumed", + "class_field_loop_preheader_check", + None, + )], + Vec::new(), + false, + false, + vec![ + format!("class={}", class_name), + format!("field={}", property), + format!("field_index={}", field_idx_str), + "receiver_proof=loop_preheader_shape_check".to_string(), + "field_layout=raw_f64_slot_array".to_string(), + "loop_versioning=class_field_fast_clone".to_string(), + ], + ); + return Ok(val); + } let recv_box = lower_expr(ctx, object)?; let key_idx = ctx.strings.intern(property); let key_handle_global = diff --git a/crates/perry-codegen/src/expr/property_get/helpers.rs b/crates/perry-codegen/src/expr/property_get/helpers.rs index d6f433e3e06..acd91bef05c 100644 --- a/crates/perry-codegen/src/expr/property_get/helpers.rs +++ b/crates/perry-codegen/src/expr/property_get/helpers.rs @@ -371,6 +371,68 @@ pub(crate) fn lower_raw_f64_class_field_get_for_number_context( return Ok(None); }; + // #5093 loop versioning: inside the fast clone of a class-field versioned + // loop, a tracked number-context field read on the proven receiver lowers + // to a bare slot load on the preheader-cached object pointer — no shape + // check, no guard call, no fallback (see stmt/loops.rs). Mirrors the hook + // in the generic class-field GET diamond (property_get.rs). + let loop_fact_ptr = match object.as_ref() { + Expr::LocalGet(recv_id) => crate::expr::class_field_loop_fact_lookup( + &ctx.class_field_loop_facts, + *recv_id, + &class_name, + property, + ) + .filter(|(_, loop_idx)| *loop_idx == field_index) + .map(|(fact, _)| fact.obj_ptr.clone()), + _ => None, + }; + if let Some(obj_ptr) = loop_fact_ptr { + let field_idx_str = field_index.to_string(); + let blk = ctx.block(); + let fields_base = blk.gep(I8, &obj_ptr, &[(I64, "24")]); + let field_ptr = blk.gep(DOUBLE, &fields_base, &[(I64, &field_idx_str)]); + let val = blk.load(DOUBLE, &field_ptr); + let fast = LoweredValue { + semantic: SemanticKind::JsNumber, + rep: NativeRep::F64, + llvm_ty: DOUBLE, + value: val.clone(), + }; + ctx.record_lowered_value_with_access_mode_and_facts( + "ClassFieldGet", + None, + "class_field_get_number.loop_raw_f64_load", + &fast, + Some(BoundsState::Guarded { + guard_id: "class_field_loop_preheader_check".to_string(), + }), + None, + Some(BufferAccessMode::CheckedNative), + None, + None, + None, + vec![raw_f64_layout_fact( + None, + "consumed", + "class_field_loop_preheader_check", + None, + )], + Vec::new(), + false, + false, + vec![ + format!("class={}", class_name), + format!("field={}", property), + format!("field_index={}", field_idx_str), + "receiver_proof=loop_preheader_shape_check".to_string(), + "field_layout=raw_f64_slot_array".to_string(), + "loop_versioning=class_field_fast_clone".to_string(), + ], + ); + return Ok(Some(val)); + } + let recv_box = lower_expr(ctx, object)?; let key_idx = ctx.strings.intern(property); let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); diff --git a/crates/perry-codegen/src/expr/property_set.rs b/crates/perry-codegen/src/expr/property_set.rs index 046a4a5f819..42bef479659 100644 --- a/crates/perry-codegen/src/expr/property_set.rs +++ b/crates/perry-codegen/src/expr/property_set.rs @@ -445,6 +445,103 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { .as_ref() .is_some_and(crate::typed_shape::type_is_raw_f64_candidate); let requires_raw_f64_str = if requires_raw_f64 { "1" } else { "0" }; + // #5093 loop versioning: inside the fast clone of a + // class-field versioned loop, a tracked raw-f64 field + // store on the proven receiver lowers to an inline + // plain-finite value check + bare slot store on the + // preheader-cached object pointer. A value that is + // not a plain finite double (±Inf/NaN, or any NaN-box + // tag — including INT32-boxed integers) side-exits to + // the slow clone's preheader BEFORE the store, so the + // slow clone re-executes the whole iteration and + // routes the value through the runtime guard exactly + // as today (downgrade semantics preserved). + if requires_raw_f64 { + let loop_fact = match object.as_ref() { + Expr::LocalGet(recv_id) => { + crate::expr::class_field_loop_fact_lookup( + &ctx.class_field_loop_facts, + *recv_id, + &class_name, + property, + ) + .filter(|(_, loop_idx)| *loop_idx == field_index) + .map(|(fact, _)| { + (fact.obj_ptr.clone(), fact.side_exit_label.clone()) + }) + } + _ => None, + }; + if let Some((obj_ptr, side_exit_label)) = loop_fact { + let field_idx_str = field_index.to_string(); + let store_idx = ctx.new_block("class_field_loop_store.fast"); + let store_label = ctx.block_label(store_idx); + { + let blk = ctx.block(); + let val_bits = blk.bitcast_double_to_i64(&val_double); + let finite = crate::expr::class_field_inline_guard:: + emit_plain_finite_number_check(blk, &val_bits); + blk.cond_br(&finite, &store_label, &side_exit_label); + } + ctx.current_block = store_idx; + { + let blk = ctx.block(); + let fields_base = blk.gep(I8, &obj_ptr, &[(I64, "24")]); + let field_ptr = + blk.gep(DOUBLE, &fields_base, &[(I64, &field_idx_str)]); + // GC_STORE_AUDIT(POINTER_FREE): the inline + // finite check above proved `val_double` is a + // genuine (unboxed, finite) double, never a + // heap pointer, so the slot carries no edge + // and needs no write barrier. No raw-f64 + // canonicalization call is needed either: + // INT32-boxed and NaN values — the only + // inputs `js_array_numeric_value_to_raw_f64` + // rewrites — cannot pass the check. + blk.store(DOUBLE, &val_double, &field_ptr); + } + let stored = LoweredValue { + semantic: SemanticKind::JsNumber, + rep: NativeRep::F64, + llvm_ty: DOUBLE, + value: val_double.clone(), + }; + ctx.record_lowered_value_with_access_mode_and_facts( + "ClassFieldSet", + None, + "class_field_set.loop_raw_f64_store", + &stored, + Some(BoundsState::Guarded { + guard_id: "class_field_loop_preheader_check".to_string(), + }), + None, + Some(BufferAccessMode::CheckedNative), + None, + None, + None, + vec![raw_f64_layout_fact( + None, + "consumed", + "class_field_loop_preheader_check", + None, + )], + Vec::new(), + false, + false, + vec![ + format!("class={}", class_name), + format!("field={}", property), + format!("field_index={}", field_idx_str), + "receiver_proof=loop_preheader_shape_check".to_string(), + "field_layout=raw_f64_slot_array".to_string(), + "loop_versioning=class_field_fast_clone".to_string(), + "rhs_numeric_guard=inline_plain_finite_check".to_string(), + "store_guard_failure=side_exit_slow_restart".to_string(), + ], + ); + return Ok(val_double); + } + } // #5334 lever B: oversized modules full-outline the entire // class-field-SET IC diamond (guard + fast store + // fallback) to a single `js_class_field_set_ic(...)` call. diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index 58e16c57310..1504492518c 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -986,6 +986,551 @@ fn lower_packed_f64_range_versioned_for( Ok(true) } +/// #5093: property names with dedicated branches in the property-get/set +/// lowering dispatch ahead of the class-field diamond (`length` header loads, +/// `errors` runtime call, accessor-ish names, …). A tracked field must not +/// collide or the fast clone's access would lower through a different — +/// possibly calling — path, breaking the call-free guarantee. +const CLASS_FIELD_LOOP_PROP_DENYLIST: &[&str] = &[ + "length", + "errors", + "size", + "prototype", + "constructor", + "__proto__", + "caller", + "arguments", + "name", + "message", + "stack", + "toString", + "valueOf", +]; + +/// #5093: class names with dedicated (builtin-flavored) branches in the +/// property lowering dispatch; a user class sharing one of these names could +/// be intercepted before the class-field diamond. +const CLASS_FIELD_LOOP_CLASS_DENYLIST: &[&str] = &[ + "Headers", + "URLPattern", + "ClientRequest", + "Agent", + "Socket", + "Server", + "BlockList", + "ReadableStream", + "ReadableStreamDefaultReader", + "WritableStream", + "WritableStreamDefaultWriter", + "URL", + "URLSearchParams", + "Function", +]; + +#[derive(Clone, Copy)] +enum ClassFieldLoopBound { + /// `i < `. + Constant(i64), + /// `i < b` where `b` is a loop-invariant plain local or module global. + Local(u32), +} + +struct ClassFieldVersionedLoop { + counter_id: u32, + bound: ClassFieldLoopBound, + recv_id: u32, + class_name: String, + expected_class_id: u32, + keys_global_name: String, + /// property -> (packed slot index, written). All raw-f64 candidates. + fields: std::collections::BTreeMap, +} + +/// #5093: effect-free expression walk for the class-field versioned loop. +/// Tracked `recv.prop` reads, numeric locals, numeric literals and pure +/// arithmetic/Math only — the same shapes `packed_f64_range_loop_pure_expr_ +/// collect` admits, minus array accesses, plus class-field reads. Everything +/// here must lower without emitting a call that can allocate (libm intrinsic +/// calls are fine: they cannot trigger a GC). +fn class_field_loop_pure_expr_collect( + ctx: &FnCtx<'_>, + expr: &perry_hir::Expr, + counter_id: u32, + recv: &mut Option, + props: &mut std::collections::BTreeMap, +) -> bool { + use perry_hir::Expr; + match expr { + Expr::PropertyGet { object, property } => { + let Expr::LocalGet(obj_id) = object.as_ref() else { + return false; + }; + if *obj_id == counter_id { + return false; + } + match recv { + Some(r) if *r == *obj_id => {} + Some(_) => return false, // single receiver per loop + None => *recv = Some(*obj_id), + } + props.entry(property.clone()).or_insert(false); + true + } + // Reading the receiver as a VALUE (outside a tracked field access) + // could flow it into arbitrary lowering; only allow scalar reads the + // type analysis proves numeric. + Expr::LocalGet(id) => { + recv.map_or(true, |r| r != *id) && crate::type_analysis::is_numeric_expr(ctx, expr) + } + Expr::Number(_) | Expr::Integer(_) => true, + Expr::Binary { left, right, .. } => { + crate::type_analysis::is_numeric_expr(ctx, expr) + && class_field_loop_pure_expr_collect(ctx, left, counter_id, recv, props) + && class_field_loop_pure_expr_collect(ctx, right, counter_id, recv, props) + } + Expr::NumberCoerce(operand) => { + class_field_loop_pure_expr_collect(ctx, operand, counter_id, recv, props) + } + Expr::MathImul(left, right) | Expr::MathPow(left, right) => { + class_field_loop_pure_expr_collect(ctx, left, counter_id, recv, props) + && class_field_loop_pure_expr_collect(ctx, right, counter_id, recv, props) + } + Expr::MathMin(values) | Expr::MathMax(values) => values + .iter() + .all(|expr| class_field_loop_pure_expr_collect(ctx, expr, counter_id, recv, props)), + Expr::MathAbs(value) + | Expr::MathSqrt(value) + | Expr::MathFloor(value) + | Expr::MathCeil(value) + | Expr::MathRound(value) + | Expr::MathTrunc(value) + | Expr::MathSign(value) + | Expr::MathF16round(value) => { + class_field_loop_pure_expr_collect(ctx, value, counter_id, recv, props) + } + _ => false, + } +} + +/// #5093: class-field versioned loop — the "collapse" this issue tracks. +/// +/// Matches `for (let i = k0; i < B; i++) ` where `B` is an +/// integer literal or a loop-invariant local/module-global and the statement's +/// only side effect is a raw-f64 class-field store on a loop-invariant +/// receiver of statically known class (or a scalar `LocalSet` accumulator), +/// with every other subexpression pure per the walker above. +/// +/// The single-statement / effect-last restriction is the side-exit protocol +/// (same as the #6011 range loop): the fast clone's only mid-loop bail is the +/// store's inline plain-finite value check, which fires BEFORE the store — so +/// jumping to the slow clone's preheader re-executes the current iteration +/// without duplicating any effect. +fn match_class_field_versioned_loop( + ctx: &FnCtx<'_>, + init: Option<&Stmt>, + condition: Option<&perry_hir::Expr>, + update: Option<&perry_hir::Expr>, + body: &[Stmt], +) -> Option { + use perry_hir::{CompareOp, Expr, UpdateOp}; + // Oversized modules full-outline the class-field diamonds for code size; + // keep the versioned clone (which would re-inline them) off there. + if crate::codegen::full_outline_ic_enabled() { + return None; + } + if !ctx.pending_labels.is_empty() { + return None; + } + let (counter_id, start) = match init? { + Stmt::Let { + id, + init: Some(init_expr), + .. + } => { + let start = match init_expr { + Expr::Integer(n) => *n, + Expr::Number(n) if n.is_finite() && n.fract() == 0.0 => *n as i64, + _ => return None, + }; + (*id, start) + } + _ => return None, + }; + if !(0..=i64::from(i32::MAX)).contains(&start) { + return None; + } + let (op, left, right) = match condition? { + Expr::Compare { op, left, right } => (*op, left.as_ref(), right.as_ref()), + _ => return None, + }; + if !matches!(op, CompareOp::Lt) || !matches!(left, Expr::LocalGet(id) if *id == counter_id) { + return None; + } + let bound = match right { + Expr::Integer(k) if (0..=i64::from(i32::MAX)).contains(k) => { + ClassFieldLoopBound::Constant(*k) + } + Expr::LocalGet(bound_id) if *bound_id != counter_id => { + if ctx.boxed_vars.contains(bound_id) { + return None; + } + if !ctx.locals.contains_key(bound_id) && !ctx.module_globals.contains_key(bound_id) { + return None; + } + if !local_bound_is_loop_invariant(condition?, update, body, *bound_id) { + return None; + } + ClassFieldLoopBound::Local(*bound_id) + } + _ => return None, + }; + if !matches!( + update?, + Expr::Update { + id, + op: UpdateOp::Increment, + .. + } if *id == counter_id + ) { + return None; + } + if !ctx.locals.contains_key(&counter_id) + || ctx.boxed_vars.contains(&counter_id) + || !ctx.integer_locals.contains(&counter_id) + || !loop_counter_bounds_are_safe(ctx, counter_id, update, body) + || !loop_counter_entry_i32_range_is_safe(init, counter_id) + { + return None; + } + + // Single-statement body whose only side effect commits after every + // potential side exit. + let [Stmt::Expr(effect)] = body else { + return None; + }; + let mut recv: Option = None; + let mut props: std::collections::BTreeMap = std::collections::BTreeMap::new(); + match effect { + // `recv.prop = ` — the benchmark shape. Lowering + // rewrites the static-key PutValueSet through the PropertySet + // class-field diamond (`put_value_static_property_fast_path`). + Expr::PutValueSet { + target, + key, + value, + receiver, + .. + } => { + let (Expr::LocalGet(t), Expr::LocalGet(r)) = (target.as_ref(), receiver.as_ref()) + else { + return None; + }; + if t != r { + return None; + } + let Expr::String(prop) = key.as_ref() else { + return None; + }; + recv = Some(*t); + if !class_field_loop_pure_expr_collect(ctx, value, counter_id, &mut recv, &mut props) { + return None; + } + props + .entry(prop.clone()) + .and_modify(|written| *written = true) + .or_insert(true); + } + Expr::PropertySet { + object, + property, + value, + } => { + let Expr::LocalGet(obj_id) = object.as_ref() else { + return None; + }; + recv = Some(*obj_id); + if !class_field_loop_pure_expr_collect(ctx, value, counter_id, &mut recv, &mut props) { + return None; + } + props + .entry(property.clone()) + .and_modify(|written| *written = true) + .or_insert(true); + } + // Scalar accumulator: `acc = `. No + // store side exit exists, so re-execution can never happen; the + // LocalSet itself must still target a plain numeric non-shadow local. + Expr::LocalSet(id, value) => { + if *id == counter_id + || !ctx.locals.contains_key(id) + || ctx.boxed_vars.contains(id) + || ctx.module_globals.contains_key(id) + || ctx.shadow_slot_map.contains_key(id) + || !crate::type_analysis::is_numeric_expr(ctx, &Expr::LocalGet(*id)) + { + return None; + } + if !class_field_loop_pure_expr_collect(ctx, value, counter_id, &mut recv, &mut props) { + return None; + } + if recv == Some(*id) { + return None; + } + if let ClassFieldLoopBound::Local(bound_id) = bound { + if bound_id == *id { + return None; + } + } + } + _ => return None, + } + let recv_id = recv?; + if props.is_empty() || recv_id == counter_id { + return None; + } + if let ClassFieldLoopBound::Local(bound_id) = bound { + if bound_id == recv_id { + return None; + } + } + + // Receiver: loop-invariant, directly addressable, not aliased by another + // representation (POD / scalar replacement take different lowering paths). + if ctx.boxed_vars.contains(&recv_id) + || ctx.pod_records.contains_key(&recv_id) + || ctx.scalar_replaced.contains_key(&recv_id) + { + return None; + } + if !ctx.locals.contains_key(&recv_id) && !ctx.module_globals.contains_key(&recv_id) { + return None; + } + if !local_bound_is_loop_invariant(condition?, update, body, recv_id) { + return None; + } + let class_name = + crate::type_analysis::receiver_class_name(ctx, &perry_hir::Expr::LocalGet(recv_id))?; + if CLASS_FIELD_LOOP_CLASS_DENYLIST.contains(&class_name.as_str()) { + return None; + } + let class = ctx.classes.get(&class_name)?; + if !class.computed_members.is_empty() { + return None; + } + let expected_class_id = *ctx.class_ids.get(&class_name)?; + let keys_global_name = ctx.class_keys_globals.get(&class_name)?.clone(); + + let mut fields = std::collections::BTreeMap::new(); + for (prop, written) in props { + if CLASS_FIELD_LOOP_PROP_DENYLIST.contains(&prop.as_str()) { + return None; + } + // Accessors route through synthesized __get_/__set_ methods before + // the class-field diamond; `class_field_global_index` also rejects + // accessor-shadowed names, but mirror the dispatch gate exactly. + if ctx + .methods + .contains_key(&(class_name.clone(), format!("__get_{prop}"))) + || ctx + .methods + .contains_key(&(class_name.clone(), format!("__set_{prop}"))) + { + return None; + } + let field_index = crate::type_analysis::class_field_global_index(ctx, &class_name, &prop)?; + let raw_f64 = crate::type_analysis::class_field_declared_type(ctx, &class_name, &prop) + .as_ref() + .is_some_and(crate::typed_shape::type_is_raw_f64_candidate); + if !raw_f64 { + return None; + } + fields.insert(prop, (field_index, written)); + } + + Some(ClassFieldVersionedLoop { + counter_id, + bound, + recv_id, + class_name, + expected_class_id, + keys_global_name, + fields, + }) +} + +/// #5093: lowering for [`match_class_field_versioned_loop`], modeled on +/// [`lower_packed_f64_range_versioned_for`]. The bound is materialized to i32 +/// once (with a finite-integral check for local/global bounds), the inline +/// class-field shape check runs once in the preheader, and the fast clone +/// lowers with a scoped [`crate::expr::ClassFieldLoopFact`] so every tracked +/// field access is a bare GEP load/store on the preheader-cached object +/// pointer. Store side exits resume at the current `i` in the slow clone. +/// +/// SAFETY (memory-corruption class — see #5093): between the preheader's +/// receiver load and the end of the fast clone, NO call may be emitted. The +/// matcher enforces this by shape (single pure-arithmetic statement, all +/// field accesses tracked, counter/bound machinery call-free); the preheader +/// itself emits only bit ops, loads, and the finite-integral bound checks. +/// Call-free ⇒ allocation-free ⇒ no GC ⇒ the object cannot move and none of +/// the checked shape facts can change while the fast clone runs. +fn lower_class_field_versioned_for( + ctx: &mut FnCtx<'_>, + init: Option<&Stmt>, + condition: Option<&perry_hir::Expr>, + update: Option<&perry_hir::Expr>, + body: &[Stmt], +) -> Result { + let Some(matched) = match_class_field_versioned_loop(ctx, init, condition, update, body) else { + return Ok(false); + }; + // The fast clone's cond reads the counter through its i32 slot; without + // one the versioned copy would win nothing. + if !ctx.i32_counter_slots.contains_key(&matched.counter_id) { + return Ok(false); + } + + let fast_pre_idx = ctx.new_block("class_field.loop.fast.preheader"); + let slow_pre_idx = ctx.new_block("class_field.loop.slow.preheader"); + let merge_idx = ctx.new_block("class_field.loop.merge"); + let fast_pre_label = ctx.block_label(fast_pre_idx); + let slow_pre_label = ctx.block_label(slow_pre_idx); + let merge_label = ctx.block_label(merge_idx); + + // One-time i32 materialization of the bound (mirrors the #6011 range + // loop): non-number / NaN / fractional / out-of-range bounds keep full JS + // trip-count semantics in the slow clone. + let bound_i32: String = match matched.bound { + ClassFieldLoopBound::Constant(k) => k.to_string(), + ClassFieldLoopBound::Local(bound_id) => { + let bound_d = lower_expr(ctx, &perry_hir::Expr::LocalGet(bound_id))?; + let is_number = emit_js_value_is_number(ctx, &bound_d); + let range_idx = ctx.new_block("class_field.loop.bound.range"); + let convert_idx = ctx.new_block("class_field.loop.bound.convert"); + let check_idx = ctx.new_block("class_field.loop.shape_check"); + let range_label = ctx.block_label(range_idx); + let convert_label = ctx.block_label(convert_idx); + let check_label = ctx.block_label(check_idx); + ctx.block() + .cond_br(&is_number, &range_label, &slow_pre_label); + + ctx.current_block = range_idx; + let ge_zero = ctx.block().fcmp("oge", &bound_d, "0.0"); + let le_max = { + let max_literal = format!("{:.1}", i32::MAX as f64); + ctx.block().fcmp("ole", &bound_d, &max_literal) + }; + let in_range = ctx.block().and(I1, &ge_zero, &le_max); + ctx.block() + .cond_br(&in_range, &convert_label, &slow_pre_label); + + ctx.current_block = convert_idx; + let bound_i32 = ctx.block().fptosi(DOUBLE, &bound_d, I32); + let roundtrip = ctx.block().sitofp(I32, &bound_i32, DOUBLE); + let is_integral = ctx.block().fcmp("oeq", &roundtrip, &bound_d); + ctx.block() + .cond_br(&is_integral, &check_label, &slow_pre_label); + + ctx.current_block = check_idx; + bound_i32 + } + }; + + // Receiver load + hoisted shape check. From here to loop entry the + // emitted IR is call-free, so the pointer the check validates is the + // pointer the fast clone uses. + let recv_box = lower_expr(ctx, &perry_hir::Expr::LocalGet(matched.recv_id))?; + let (obj_bits, obj_handle, expected_keys) = { + let blk = ctx.block(); + let obj_bits = blk.bitcast_double_to_i64(&recv_box); + let obj_handle = blk.and(I64, &obj_bits, crate::nanbox::POINTER_MASK_I64); + let expected_keys = blk.load(I64, &format!("@{}", matched.keys_global_name)); + (obj_bits, obj_handle, expected_keys) + }; + let max_field_index = matched + .fields + .values() + .map(|(field_index, _)| *field_index) + .max() + .expect("matcher requires >= 1 tracked field"); + let has_store = matched.fields.values().any(|(_, written)| *written); + let expected_class_id_str = matched.expected_class_id.to_string(); + let (obj_ptr, shape_ok) = + crate::expr::class_field_inline_guard::emit_class_field_loop_preheader_check( + ctx, + &obj_bits, + &obj_handle, + &expected_class_id_str, + &expected_keys, + max_field_index, + // Every tracked field is a raw-f64 candidate: reads rely on the + // intact bit, so require it whether or not the loop stores. + true, + has_store, + &slow_pre_label, + ); + // The deref block is left unterminated on purpose: it branches into the + // fast clone only after the clone is PROVEN call-free below. + let deref_idx = ctx.current_block; + + let scope_id = ctx.next_loop_proof_scope_id(); + let fast_scan_start = ctx.func.num_blocks(); + ctx.current_block = fast_pre_idx; + ctx.class_field_loop_facts + .push(crate::expr::ClassFieldLoopFact { + recv_local_id: matched.recv_id, + scope_id, + class_name: matched.class_name.clone(), + obj_ptr, + side_exit_label: slow_pre_label.clone(), + fields: matched + .fields + .iter() + .map(|(prop, (field_index, _))| (prop.clone(), *field_index)) + .collect(), + }); + lower_for_after_init_with_i32_bound( + ctx, + init, + condition, + update, + body, + "for.class_field_fast", + Some((matched.counter_id, bound_i32)), + )?; + ctx.class_field_loop_facts + .retain(|fact| fact.scope_id != scope_id); + if !ctx.block().is_terminated() { + ctx.block().br(&merge_label); + } + let fast_scan_end = ctx.func.num_blocks(); + + // Compile-time verification of the safety invariant: the fast clone must + // be call-free (no runtime call ⇒ no allocation ⇒ no GC ⇒ the cached + // `obj_ptr` cannot move and the hoisted shape check stays true). The + // matcher makes this true by construction; if some unpredicted lowering + // path emitted a call anyway, never enter the fast clone — run the slow + // clone unconditionally and leave the fast blocks as unreachable code. + let fast_clone_call_free = !ctx.func.blocks()[fast_pre_idx].contains_gc_unsafe_call() + && (fast_scan_start..fast_scan_end) + .all(|idx| !ctx.func.blocks()[idx].contains_gc_unsafe_call()); + ctx.current_block = deref_idx; + if fast_clone_call_free { + ctx.block() + .cond_br(&shape_ok, &fast_pre_label, &slow_pre_label); + } else { + ctx.block().br(&slow_pre_label); + } + + ctx.current_block = slow_pre_idx; + lower_for_after_init(ctx, init, condition, update, body, "for.class_field_slow")?; + if !ctx.block().is_terminated() { + ctx.block().br(&merge_label); + } + + ctx.current_block = merge_idx; + Ok(true) +} + fn record_packed_f64_loop_guard_artifacts( ctx: &mut FnCtx<'_>, arr_id: u32, @@ -1650,6 +2195,13 @@ pub(crate) fn lower_for( return Ok(()); } + // #5093: monomorphic class-field hot loops (`counter.value = counter.value + // + 1` after method inlining). Shape check hoisted to a preheader; fast + // clone is call-free raw slot access. + if lower_class_field_versioned_for(ctx, init, condition, update, body)? { + return Ok(()); + } + lower_for_after_init(ctx, init, condition, update, body, "for") } diff --git a/test-files/test_class_field_loop_versioned.ts b/test-files/test_class_field_loop_versioned.ts new file mode 100644 index 00000000000..c9aa8eb835d --- /dev/null +++ b/test-files/test_class_field_loop_versioned.ts @@ -0,0 +1,122 @@ +// #5093: class-field versioned loop — the call-free fast clone must preserve +// exact JS semantics across the preheader shape check, the per-store +// plain-finite side exit, and every slow-clone fallback. + +// 1) The canonical hot loop (method inlined -> counter.value = counter.value + 1). +class Counter { + value: number; + constructor() { + this.value = 0; + } + increment(): void { + this.value = this.value + 1; + } +} +const counter = new Counter(); +for (let i = 0; i < 100000; i++) { + counter.increment(); +} +console.log("hot:" + counter.value); + +// 2) Field initialized at declaration + direct store in the loop body. +class Cell { + v: number = 5; +} +const cell = new Cell(); +for (let i = 0; i < 1000; i++) { + cell.v = cell.v + 2; +} +console.log("decl-init:" + cell.v); + +// 3) Loop-invariant module-const bound + multiple reads per iteration. +const N = 2048; +class Pair { + a: number = 1; + b: number = 2; +} +const pair = new Pair(); +for (let i = 0; i < N; i++) { + pair.a = pair.a + pair.b; +} +console.log("pair:" + pair.a + "," + pair.b); + +// 4) Mid-loop overflow to Infinity: the raw store's plain-finite check must +// side-exit to the slow clone WITHOUT committing, and the slow clone must +// finish the remaining iterations with identical semantics. +class Grow { + x: number = 1e308; +} +const grow = new Grow(); +for (let i = 0; i < 8; i++) { + grow.x = grow.x * 2; +} +console.log("overflow:" + grow.x); + +// 5) Subclass instance held in a base-typed binding: the preheader class_id +// check must reject it (subclass has its own class id) and the slow clone +// must produce correct results, including the shadowing subclass field order. +class Base { + n: number = 0; +} +class Derived extends Base { + extra: number = 7; +} +const asBase: Base = new Derived(); +for (let i = 0; i < 500; i++) { + asBase.n = asBase.n + 1; +} +console.log("subclass:" + asBase.n + "," + (asBase as Derived).extra); + +// 6) Frozen receiver: strict-mode store to a frozen object throws TypeError. +// The preheader's not-frozen check routes the loop to the slow clone, which +// must throw exactly like an unversioned loop. +class Frost { + f: number = 3; +} +const frost = new Frost(); +Object.freeze(frost); +let frozenError = "none"; +try { + for (let i = 0; i < 10; i++) { + frost.f = frost.f + 1; + } +} catch (e) { + frozenError = (e as Error).constructor.name; +} +console.log("frozen:" + frozenError + "," + frost.f); + +// 7) defineProperty on a class instance: installs a descriptor on a +// class-relevant target, which must disable the inline fast path globally and +// keep accessor semantics through the loop. +class Acc { + y: number = 10; +} +const acc = new Acc(); +let getterHits = 0; +Object.defineProperty(acc, "y", { + get() { + getterHits = getterHits + 1; + return 42; + }, + configurable: true, +}); +let sum = 0; +for (let i = 0; i < 5; i++) { + sum = sum + acc.y; +} +console.log("accessor:" + sum + "," + (getterHits > 0 ? "hit" : "miss")); + +// 8) Accumulator-only loop (no field store): reads must stay raw and the +// result must match plain evaluation. +class Score { + s: number = 1.5; +} +const score = new Score(); +function tally(): number { + let acc2 = 0; + for (let i = 0; i < 4000; i++) { + acc2 = acc2 + score.s; + } + return acc2; +} +console.log("tally:" + tally()); From 3d7974780c24d3c9b695412fdeffdd4d4dcd0b8b Mon Sep 17 00:00:00 2001 From: Ralph Date: Sun, 5 Jul 2026 22:30:44 -0700 Subject: [PATCH 2/3] lint: move GC_STORE_AUDIT(POINTER_FREE) marker into the store-site inventory window The inventory requires the marker within +/-6 lines of the raw store; the explanatory comment had pushed it 9 lines away. Co-Authored-By: Claude Fable 5 --- crates/perry-codegen/src/expr/property_set.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/perry-codegen/src/expr/property_set.rs b/crates/perry-codegen/src/expr/property_set.rs index 42bef479659..3e5e8b75c32 100644 --- a/crates/perry-codegen/src/expr/property_set.rs +++ b/crates/perry-codegen/src/expr/property_set.rs @@ -489,15 +489,15 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let fields_base = blk.gep(I8, &obj_ptr, &[(I64, "24")]); let field_ptr = blk.gep(DOUBLE, &fields_base, &[(I64, &field_idx_str)]); - // GC_STORE_AUDIT(POINTER_FREE): the inline - // finite check above proved `val_double` is a - // genuine (unboxed, finite) double, never a - // heap pointer, so the slot carries no edge - // and needs no write barrier. No raw-f64 - // canonicalization call is needed either: + // No raw-f64 canonicalization call is needed: // INT32-boxed and NaN values — the only // inputs `js_array_numeric_value_to_raw_f64` - // rewrites — cannot pass the check. + // rewrites — cannot pass the finite check. + // + // GC_STORE_AUDIT(POINTER_FREE): the inline + // finite check proved `val_double` is a + // genuine (unboxed, finite) double, never a + // heap pointer — no edge, no write barrier. blk.store(DOUBLE, &val_double, &field_ptr); } let stored = LoweredValue { From ca6a0cd0a4ab316f2fd9915a93224ee833741034 Mon Sep 17 00:00:00 2001 From: Ralph Date: Sun, 5 Jul 2026 23:18:35 -0700 Subject: [PATCH 3/3] fix(codegen): versioned-loop preheader vets OBJ_FLAG_HAS_DESCRIPTORS per-receiver Adapt the #5093 loop preheader to the #5654 kill-switch semantics that landed on main: instance-level descriptor installs no longer flip the process-global gate (the per-access precheck vets the receiver's OBJ_FLAG_HAS_DESCRIPTORS bit instead), so the hoisted whole-loop check must test the same bit. Once per loop is sound: installing a descriptor mid-loop would take a runtime call, which the call-free fast clone cannot make. Co-Authored-By: Claude Fable 5 --- .../src/expr/class_field_inline_guard.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/perry-codegen/src/expr/class_field_inline_guard.rs b/crates/perry-codegen/src/expr/class_field_inline_guard.rs index d4d5243225d..744bee0ca66 100644 --- a/crates/perry-codegen/src/expr/class_field_inline_guard.rs +++ b/crates/perry-codegen/src/expr/class_field_inline_guard.rs @@ -156,6 +156,17 @@ pub(crate) fn emit_class_field_loop_preheader_check( acc = blk.and(I1, &acc, &fc_ok); acc = blk.and(I1, &acc, &ka_ok); + // #5654: a receiver that has ever had a property / accessor descriptor + // installed on it needs the guard's descriptor-aware dispatch (an + // accessor must fire on reads, a non-writable slot must reject + // stores). Instance-level installs no longer flip the process-global + // gate, so the hoisted check must vet the per-object flag — once, for + // the whole loop: installing a descriptor mid-loop would require a + // runtime call, which the call-free fast clone cannot make. + let has_desc = blk.and(I16, &reserved, OBJ_FLAG_HAS_DESCRIPTORS_BIT); + let no_desc = blk.icmp_eq(I16, &has_desc, "0"); + acc = blk.and(I1, &acc, &no_desc); + if require_raw_f64 { let intact = blk.and(I16, &reserved, TYPED_LAYOUT_INTACT_BIT); let intact_ok = blk.icmp_ne(I16, &intact, "0");