From f980fa42e8bf5e2b4d21bcf7786a00ba7fef3136 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 5 Aug 2026 10:03:33 +0200 Subject: [PATCH 1/4] perf(codegen): revive the #5093 class-field versioned loop for canonical-i32 counters Repsel Phase 1 made the canonical i32 slot the ONLY storage for a proven-integer local, so such a local has no `ctx.locals` entry. The #5093 matcher gated its counter and its bound on `ctx.locals`, so it matched nothing. Also teach the sloppy class-field store (#7423) about the loop fact, so the fast clone stays call-free. --- crates/perry-codegen/src/expr/property_set.rs | 56 +++++++++++++++++++ crates/perry-codegen/src/stmt/loops.rs | 32 ++++++++++- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/crates/perry-codegen/src/expr/property_set.rs b/crates/perry-codegen/src/expr/property_set.rs index 7e42451d6c..95aa16274f 100644 --- a/crates/perry-codegen/src/expr/property_set.rs +++ b/crates/perry-codegen/src/expr/property_set.rs @@ -130,6 +130,62 @@ pub(crate) fn try_lower_sloppy_class_field_raw_store( let recv_box = lower_expr(ctx, object)?; let val_double = lower_expr(ctx, value)?; + // #7287: inside the fast clone of a #5093 class-field versioned loop, this + // store is covered by the preheader's hoisted shape check — emit the same + // inline plain-finite check + bare slot store the STRICT arm emits (see + // `lower`'s class-field arm), instead of the per-access diamond. + // + // Sound in sloppy mode for the same reason #7423 made the fast arm + // mode-independent: the preheader proved not-frozen, no per-receiver + // descriptors, matching class id and keys token, and an intact typed + // layout, and the loop's body is call-free so none of that can change while + // the clone runs. A store that reaches the raw slot could not have been + // *rejected* in either mode, so there is no sloppy/strict divergence to + // preserve. Everything else — a non-finite or NaN-boxed value — side-exits + // to the slow clone BEFORE storing, and the slow clone re-executes the whole + // iteration through this unchanged sloppy lowering. + if let Expr::LocalGet(recv_id) = object { + if let Some((fact, _)) = crate::expr::class_field_loop_fact_lookup( + &ctx.class_field_loop_facts, + *recv_id, + &class_name, + property, + ) + .filter(|(_, loop_idx)| *loop_idx == field_index) + { + let obj_ptr = fact.obj_ptr.clone(); + let side_exit_label = fact.side_exit_label.clone(); + let store_idx = ctx.new_block("class_field_loop_store.sloppy_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 header_skip = + crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); + let blk = ctx.block(); + let fields_base = blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); + let field_ptr = blk.gep(DOUBLE, &fields_base, &[(I64, &field_index.to_string())]); + // No `js_array_numeric_value_to_raw_f64` canonicalization is + // needed: INT32-boxed and NaN values — the only inputs it + // rewrites — cannot pass the finite check above. + // + // GC_STORE_AUDIT(POINTER_FREE): the finite check proved + // `val_double` is a genuine unboxed double, never a heap + // pointer — no edge, no write barrier. + blk.store(DOUBLE, &val_double, &field_ptr); + } + return Ok(Some(val_double)); + } + } + let key_idx = ctx.strings.intern(property); let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); let field_idx_str = field_index.to_string(); diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index 88a16accad..be7915d55f 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -3401,7 +3401,9 @@ fn match_class_field_versioned_loop( if ctx.boxed_vars.contains(bound_id) { return None; } - if !ctx.locals.contains_key(bound_id) && !ctx.module_globals.contains_key(bound_id) { + if !local_has_readable_slot(ctx, *bound_id) + && !ctx.module_globals.contains_key(bound_id) + { return None; } if !local_bound_is_loop_invariant(condition?, update, body, *bound_id) { @@ -3421,7 +3423,7 @@ fn match_class_field_versioned_loop( ) { return None; } - if !ctx.locals.contains_key(&counter_id) + if !local_has_readable_slot(ctx, counter_id) || ctx.boxed_vars.contains(&counter_id) || !ctx.integer_locals.contains(&counter_id) || !loop_counter_bounds_are_safe(ctx, counter_id, update, body) @@ -5804,11 +5806,35 @@ pub(crate) fn classify_for_local_bound_dynamic( fn local_bound_storage_accessible(ctx: &crate::expr::FnCtx<'_>, bound_id: u32) -> bool { // Repsel Phase 1: a canonical-i32 bound has no `ctx.locals` entry; its // i32 slot is directly readable storage (better, even — no conversion). - (ctx.locals.contains_key(&bound_id) || ctx.local_slot_reps.contains_key(&bound_id)) + local_has_readable_slot(ctx, bound_id) && !ctx.boxed_vars.contains(&bound_id) && !ctx.module_globals.contains_key(&bound_id) } +/// Does `local_id` own function-local storage a loop matcher can read back +/// directly (as opposed to a closure capture or a stale HIR id)? +/// +/// Both registries have to be consulted. Representation-selection Phase 1 +/// (`expr/slot_rep.rs`) made the canonical i32 slot the **only** storage for a +/// proven-integer local: such a local is registered in `ctx.local_slot_reps` +/// (with its alloca in `ctx.i32_counter_slots`) and has **no** `ctx.locals` +/// entry at all. A bare `ctx.locals.contains_key(..)` test therefore stopped +/// admitting exactly the locals the loop matchers are written for — integer +/// counters and integer bounds — the moment Phase 1 landed. +/// +/// That is how #7287 happened: the #5093 class-field versioned loop gated on +/// `ctx.locals` for both its counter and its bound, so after Phase 1 it matched +/// nothing, and `09_method_calls` paid the per-access guard diamond on every +/// iteration with no hoisted form to fall into. It was unreachable in the other +/// configuration too — under the pre-Phase-1 parallel-shadow model a `++` +/// counter never earned an i32 shadow, which the lowering separately requires. +/// `class_field_versioned_loop_fires_for_module_scope_counter` is the assertion +/// that the lowering is live; keep it that way (CLAUDE.md, "a gate must assert +/// its subject was live"). +fn local_has_readable_slot(ctx: &crate::expr::FnCtx<'_>, local_id: u32) -> bool { + ctx.locals.contains_key(&local_id) || ctx.local_slot_reps.contains_key(&local_id) +} + fn local_bound_is_loop_invariant( cond: &perry_hir::Expr, update: Option<&perry_hir::Expr>, From fb2c6ff7f733d157409a24a6fe61cfce0c1cacd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 5 Aug 2026 10:20:19 +0200 Subject: [PATCH 2/4] test(codegen): assert the class-field versioned loop is actually reached (#7287) --- crates/perry-codegen/src/expr/property_set.rs | 7 +- .../src/stmt/class_field_loop_tests.rs | 312 ++++++++++++++++++ crates/perry-codegen/src/stmt/mod.rs | 2 + 3 files changed, 317 insertions(+), 4 deletions(-) create mode 100644 crates/perry-codegen/src/stmt/class_field_loop_tests.rs diff --git a/crates/perry-codegen/src/expr/property_set.rs b/crates/perry-codegen/src/expr/property_set.rs index 95aa16274f..2630a23ed7 100644 --- a/crates/perry-codegen/src/expr/property_set.rs +++ b/crates/perry-codegen/src/expr/property_set.rs @@ -160,10 +160,9 @@ pub(crate) fn try_lower_sloppy_class_field_raw_store( { 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, - ); + 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; diff --git a/crates/perry-codegen/src/stmt/class_field_loop_tests.rs b/crates/perry-codegen/src/stmt/class_field_loop_tests.rs new file mode 100644 index 0000000000..79f323fe7e --- /dev/null +++ b/crates/perry-codegen/src/stmt/class_field_loop_tests.rs @@ -0,0 +1,312 @@ +//! #7287: the #5093 class-field versioned loop must actually be REACHED. +//! +//! `lower_class_field_versioned_for` (`stmt/loops.rs`) hoists a monomorphic +//! `this.field` shape check into a loop preheader and runs a guard-free, +//! call-free fast clone. It was written for `benchmarks/suite/09_method_calls.ts` +//! and it is worth ~9× on it. It also matched **nothing** for months, in either +//! configuration, and nothing noticed: +//! +//! * with representation-selection Phase 1 on (the default), a proven-integer +//! loop counter's *only* storage is its canonical i32 slot — it has no +//! `ctx.locals` entry — and the matcher gated its counter and its bound on +//! `ctx.locals.contains_key(..)`; +//! * with Phase 1 off, the counter regains its `ctx.locals` entry but a bare +//! `i++` counter never earns an i32 *shadow*, which the lowering separately +//! requires. +//! +//! Every existing signal scored it as working. The lowering compiles, the +//! matcher is exercised by no test, `09_method_calls` still printed the right +//! answer, and the emitted object still differed from an unoptimised build (by +//! the *other* class-field lowerings). Only asserting that the versioned blocks +//! appear in the emitted IR distinguishes "implemented" from "reached" — see +//! CLAUDE.md, "a gate must assert its subject was live". +//! +//! So these tests assert on emitted block labels, and every one of them +//! requires the fast clone AND its guard-free store together: a preheader that +//! is emitted but branched around would still print `class_field.loop.*`. + +use crate::{compile_module, AppMetadata, CompileOptions}; +use perry_hir::types::Type; +use perry_hir::{ + BinaryOp, Class, ClassField, CompareOp, Expr, Module, ModuleInitKind, Stmt, UpdateOp, +}; + +fn ir_opts() -> CompileOptions { + CompileOptions { + target: None, + is_entry_module: true, + non_entry_module_prefixes: Vec::new(), + nextjs_path_init_modules: Vec::new(), + import_function_prefixes: std::collections::HashMap::new(), + import_function_ffi_aliases: std::collections::HashMap::new(), + import_function_origin_names: std::collections::HashMap::new(), + import_function_v8_specifiers: std::collections::HashMap::new(), + import_function_node_submodule: std::collections::HashMap::new(), + namespace_node_submodules: std::collections::HashMap::new(), + namespace_v8_specifiers: std::collections::HashMap::new(), + namespace_member_prefixes: std::collections::HashMap::new(), + namespace_member_origin_names: std::collections::HashMap::new(), + emit_ir_only: true, + verify_native_regions: false, + disable_buffer_fast_path: false, + namespace_imports: Vec::new(), + imported_classes: Vec::new(), + imported_enums: Vec::new(), + imported_async_funcs: std::collections::HashSet::new(), + type_aliases: std::collections::HashMap::new(), + imported_func_param_counts: std::collections::HashMap::new(), + imported_func_has_rest: std::collections::HashSet::new(), + imported_func_synthetic_arguments: std::collections::HashSet::new(), + imported_func_return_types: std::collections::HashMap::new(), + imported_vars: std::collections::HashSet::new(), + output_type: "executable".to_string(), + needs_stdlib: false, + needs_ui: false, + needs_geisterhand: false, + geisterhand_port: 7676, + enabled_features: Vec::new(), + native_module_init_names: Vec::new(), + js_module_specifiers: Vec::new(), + bundled_extensions: Vec::new(), + native_library_functions: Vec::new(), + i18n_table: None, + fast_math: false, + fp_contract_mode: crate::FpContractMode::Off, + app_metadata: AppMetadata::default(), + namespace_entries: Vec::new(), + dynamic_import_path_to_prefix: std::collections::HashMap::new(), + deferred_module_prefixes: std::collections::HashSet::new(), + module_init_deps: Vec::new(), + is_dynamic_import_target: false, + debug_locations: false, + module_source: None, + debug_source_line_offset: 0, + } +} + +fn counter_class() -> Class { + Class { + id: 101, + name: "Counter".to_string(), + type_params: Vec::new(), + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: vec![ClassField { + name: "value".to_string(), + key_expr: None, + ty: Type::Number, + init: None, + is_private: false, + is_readonly: false, + decorators: Vec::new(), + }], + constructor: None, + methods: Vec::new(), + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + computed_members: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + is_nested: false, + alloc_width_hint: 0, + } +} + +/// `counter.value = counter.value + 1`, in the shape the inliner leaves behind +/// for `counter.increment()` at module scope: a sloppy `PutValueSet` whose +/// target and receiver are the same local. +fn bump_stmt(recv_id: u32, strict: bool) -> Stmt { + Stmt::Expr(Expr::PutValueSet { + target: Box::new(Expr::LocalGet(recv_id)), + key: Box::new(Expr::String("value".to_string())), + value: Box::new(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(recv_id)), + property: "value".to_string(), + byte_offset: 0, + }), + right: Box::new(Expr::Integer(1)), + }), + receiver: Box::new(Expr::LocalGet(recv_id)), + strict, + }) +} + +/// The module-init shape of `benchmarks/suite/09_method_calls.ts` after +/// inlining: `const c = new Counter(); for (let i = 0; i < ; i++) c.value +/// = c.value + 1;`. +fn method_calls_module(bound: Expr, extra_init: Vec, strict: bool) -> Module { + let mut m = Module::new("class_field_loop.ts"); + m.classes = vec![counter_class()]; + let mut init = extra_init; + init.push(Stmt::Let { + id: 1, + name: "c".to_string(), + ty: Type::Named("Counter".to_string()), + mutable: false, + init: Some(Expr::New { + class_name: "Counter".to_string(), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + }), + }); + init.push(Stmt::For { + init: Some(Box::new(Stmt::Let { + id: 7, + name: "i".to_string(), + ty: Type::Any, + mutable: true, + init: Some(Expr::Integer(0)), + })), + condition: Some(Expr::Compare { + op: CompareOp::Lt, + left: Box::new(Expr::LocalGet(7)), + right: Box::new(bound), + }), + update: Some(Expr::Update { + id: 7, + op: UpdateOp::Increment, + prefix: false, + }), + body: vec![bump_stmt(1, strict)], + }); + m.init = init; + m.init_kind = ModuleInitKind::Eager; + m +} + +fn emit(m: &Module) -> String { + String::from_utf8(compile_module(m, ir_opts()).unwrap()).expect("LLVM IR should be UTF-8") +} + +/// Both halves of the transform, asserted together. +/// +/// `class_field.loop.fast.preheader` alone would pass on a lowering that emits +/// the versioned skeleton and then unconditionally branches to the slow clone +/// (which is exactly what `lower_class_field_versioned_for` does when the fast +/// clone turns out not to be call-free). The guard-free store block is the part +/// that only exists when the fast clone was really entered, and the hoisted +/// preheader check is what makes it sound — so require all three. +fn assert_versioned_loop_lowered(ir: &str, what: &str) { + for label in [ + "class_field.loop.fast.preheader", + "class_field_loop.preheader.deref", + "class_field_loop_store.sloppy_fast", + ] { + assert!( + ir.contains(label), + "{what}: expected the #5093 class-field versioned loop to be lowered, \ + but `{label}` is absent from the emitted IR. The matcher in \ + stmt/loops.rs declined — check that the loop counter and bound are \ + still admitted through `local_has_readable_slot` (repsel Phase 1 \ + stores a proven-integer local ONLY in its canonical i32 slot, with \ + no `ctx.locals` entry). See #7287." + ); + } + // The slow clone must survive as the cold arm: it is what a receiver that + // fails the preheader check (frozen, descriptor-bearing, wrong class) and + // every mid-loop store side exit falls into. + assert!( + ir.contains("for.class_field_slow.cond"), + "{what}: the versioned loop's SLOW clone is missing — a hoisted guard \ + with no fallback arm is worse than no hoist at all" + ); + // The fast clone must be free of the per-access diamond it exists to + // replace: no volatile gate load between the fast preheader and the store. + let fast = fast_clone_slice(ir); + assert!( + !fast.contains("@PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED"), + "{what}: the fast clone still reads the per-access inline-guard gate; \ + the whole point of the preheader check is that it does not" + ); + assert!( + !fast.contains("js_typed_feedback_class_field"), + "{what}: the fast clone still calls the class-field guard; it must be \ + call-free (call-free ⇒ allocation-free ⇒ no GC ⇒ the preheader's \ + cached object pointer stays valid)" + ); +} + +/// The emitted text from the fast clone's condition block up to the slow +/// clone's, i.e. exactly the blocks the fast copy owns. +fn fast_clone_slice(ir: &str) -> &str { + let start = ir + .find("for.class_field_fast.cond") + .expect("fast clone cond block"); + let end = ir[start..] + .find("for.class_field_slow.cond") + .map(|off| start + off) + .unwrap_or(ir.len()); + &ir[start..end] +} + +/// The exact `09_method_calls` shape: an integer-literal bound. +#[test] +fn class_field_versioned_loop_fires_for_literal_bound() { + let ir = emit(&method_calls_module( + Expr::Integer(10_000_000), + Vec::new(), + false, + )); + assert_versioned_loop_lowered(&ir, "literal bound"); +} + +/// The benchmark as actually written: the bound is a module-scope +/// `const ITERATIONS = 10000000`. Under repsel Phase 1 that const is a +/// canonical-i32 local with no `ctx.locals` entry either, so it exercises the +/// bound half of the admission fix independently of the counter half. +#[test] +fn class_field_versioned_loop_fires_for_module_scope_counter() { + let iterations = Stmt::Let { + id: 0, + name: "ITERATIONS".to_string(), + ty: Type::Number, + mutable: false, + init: Some(Expr::Integer(10_000_000)), + }; + let ir = emit(&method_calls_module( + Expr::LocalGet(0), + vec![iterations], + false, + )); + assert_versioned_loop_lowered(&ir, "module-scope const bound"); +} + +/// STRICT module scope takes a different store lowering +/// (`put_value_static_property_fast_path` → `property_set::lower`), which has +/// carried its own loop-fact branch since #5093. Both arms must reach the fast +/// clone, or an ESM/CJS difference silently changes which one a file gets — +/// the same class of path-dependence #7288 was. +#[test] +fn class_field_versioned_loop_fires_in_strict_mode() { + let ir = emit(&method_calls_module( + Expr::Integer(10_000_000), + Vec::new(), + true, + )); + for label in [ + "class_field.loop.fast.preheader", + "class_field_loop.preheader.deref", + "class_field_loop_store.fast", + ] { + assert!( + ir.contains(label), + "strict mode: expected `{label}` in the emitted IR (#7287)" + ); + } + assert!( + !fast_clone_slice(&ir).contains("@PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED"), + "strict mode: the fast clone still reads the per-access gate" + ); +} diff --git a/crates/perry-codegen/src/stmt/mod.rs b/crates/perry-codegen/src/stmt/mod.rs index e12564daf7..9b0aab9582 100644 --- a/crates/perry-codegen/src/stmt/mod.rs +++ b/crates/perry-codegen/src/stmt/mod.rs @@ -11,6 +11,8 @@ use crate::expr::{lower_expr, lower_expr_value, materialize_js_value, FnCtx}; use crate::native_value::{LoweredValue, MaterializationReason}; use crate::types::DOUBLE; +#[cfg(test)] +mod class_field_loop_tests; mod counter_range; mod if_stmt; mod let_buffer_views; From 39d3a44b05241befc46ecafd536a69b6982c2631 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 5 Aug 2026 10:26:03 +0200 Subject: [PATCH 3/4] docs(changelog): #7287 class-field loop guard hoist --- .../7287-class-field-loop-guard-hoist.md | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 changelog.d/7287-class-field-loop-guard-hoist.md diff --git a/changelog.d/7287-class-field-loop-guard-hoist.md b/changelog.d/7287-class-field-loop-guard-hoist.md new file mode 100644 index 0000000000..5706ec5036 --- /dev/null +++ b/changelog.d/7287-class-field-loop-guard-hoist.md @@ -0,0 +1,74 @@ +**Fixed** the #5093 class-field versioned loop matching **nothing**. It hoists a +monomorphic `this.field` shape check into a loop preheader and runs a guard-free, +call-free fast clone; it was written for `benchmarks/suite/09_method_calls.ts`, +and on that benchmark it is worth **9.0x** — 90 ms → 10 ms, min of 7 runs, which +puts Perry at Node 26.5.1's 11 ms instead of 8x behind it. `value:10000000` is +unchanged and still matches Node. + +`09_method_calls` is filed as a *dispatch* benchmark. It is not one: `increment()` +is fully inlined, there is no call in the loop, and #7287 measured the entire gap +in the `this.value` read + write — ~6.8 ns/iteration, about 22 cycles for what +should be `ldr`/`fadd`/`str`. There were zero `js_*` calls on the fast path. The +cost was ~60 IR instructions of per-access guard protecting three of useful work. + +**The guard hoist already existed and had been unreachable in both +configurations.** Representation-selection Phase 1 made a proven-integer local's +canonical i32 slot its *only* storage — such a local is registered in +`ctx.local_slot_reps` with its alloca in `ctx.i32_counter_slots`, and has no +`ctx.locals` entry at all. The matcher gated **both** its loop counter and its +loop bound on `ctx.locals.contains_key(..)`, so after Phase 1 it declined every +loop. Turning Phase 1 off does not recover it either: the counter regains its +`ctx.locals` entry but a bare `i++` never earns an i32 *shadow* under the +parallel-shadow model, which `lower_class_field_versioned_for` separately +requires. A sibling matcher had already been repaired for exactly this +(`local_bound_storage_accessible`); the class-field one was missed. Both sites now +share `local_has_readable_slot`. + +Reviving the matcher alone was not enough. Module top-level in a plain directory +is **sloppy**, so the store lowers through `try_lower_sloppy_class_field_raw_store` +(#7423), which had no loop-fact branch — the fast clone would have contained a +`js_put_value_set` call, and `lower_class_field_versioned_for` refuses to enter a +fast clone whose call-freeness it cannot prove, so it would have branched +unconditionally to the slow clone and changed nothing. The sloppy store now takes +the same inline plain-finite check + bare slot store the strict arm has had since +#5093. That is sound in sloppy mode for #7423's reason: the preheader proved +not-frozen, no per-receiver descriptors, matching class id and keys token, and an +intact typed layout, and the clone is call-free, so a store reaching the raw slot +is one that could not have been *rejected* in either mode. Every other value +side-exits to the slow clone before storing. + +Measured effect on the emitted IR for `09_method_calls`, post-`opt -O3`: the hot +loop goes from **71 instructions to 10**, and the 29-instruction guard chain moves +to the preheader where it runs once. LLVM then promotes the field load into a +loop-carried register. + +**The volatile-gate hypothesis measured zero.** #7287 proposed that +`load volatile @PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED` was pinning the guard +chain in the loop. De-volatilizing it in the emitted IR and re-running `opt -O3` +gives **71 hot-path instructions either way** — byte-identical block sizes bar one +instruction moving between two blocks. Marking every guard-arm call +`memory(none) nounwind willreturn` *as well* still leaves the two deref blocks at +22 and 25 instructions inside the loop: LLVM will not speculate loads out of a +conditionally-executed block whose receiver it cannot prove dereferenceable. The +hoist has to be Perry's own, which is what the #5093 preheader check is. The +"do not de-volatilize the gate" note in `expr/class_field_inline_guard.rs` stands. + +Regression coverage is three new `perry-codegen` unit tests +(`stmt/class_field_loop_tests.rs`) that assert the versioned blocks appear in the +emitted IR — for a literal bound, for the benchmark's module-scope `const` bound, +and in strict mode — and that the fast clone contains neither the volatile gate +nor a class-field guard call. All three fail on the parent commit. The lowering +had no test at all, which is why it could stop firing without anything going red +(CLAUDE.md, "a gate must assert its subject was live"). + +Verified: 25-case differential against Node covering `Object.defineProperty` on +the field, `Object.freeze`, `Object.seal`, an own accessor, a prototype accessor, +`delete` (before and mid-run), a subclass with a different layout, a receiver +alternating shape between iterations, and the mid-loop store side exits to +`Infinity` and `NaN` — byte-identical to Node, before and after. 68 `test_gap_*` +class/field/prototype tests compile-and-diff identically in both arms (66 pass, 2 +pre-existing failures unchanged). `cargo test --release -p perry-codegen --lib` +635 passed. The slow clone remains reachable and is demonstrably taken: a frozen +receiver runs 3186 ms → 3686 ms and a prototype accessor on a declared field runs +10.1 s → 11.3 s, both unchanged behaviour on the pre-existing cliff those cases +already fell off. From 02f92925458f9ad190dd442e4da40636cd60f991 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 5 Aug 2026 10:46:09 +0200 Subject: [PATCH 4/4] docs: name the fragment for its real PR (#7425) --- ...d-loop-guard-hoist.md => 7425-class-field-loop-guard-hoist.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{7287-class-field-loop-guard-hoist.md => 7425-class-field-loop-guard-hoist.md} (100%) diff --git a/changelog.d/7287-class-field-loop-guard-hoist.md b/changelog.d/7425-class-field-loop-guard-hoist.md similarity index 100% rename from changelog.d/7287-class-field-loop-guard-hoist.md rename to changelog.d/7425-class-field-loop-guard-hoist.md