From d10ab2cfee0096860fee92d8e4aef7ec9a0cdc0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 22 Aug 2026 05:16:21 +0200 Subject: [PATCH 1/3] perf(runtime): scope method guard descriptor invalidation --- .../src/object/class_registry.rs | 3 +- .../src/object/descriptor_state.rs | 23 +++++++++----- .../src/typed_feedback/guards.rs | 9 +++++- .../perry-runtime/src/typed_feedback/tests.rs | 30 +++++++++++++++++++ 4 files changed, 56 insertions(+), 9 deletions(-) diff --git a/crates/perry-runtime/src/object/class_registry.rs b/crates/perry-runtime/src/object/class_registry.rs index 0988503bf9..229e9fba95 100644 --- a/crates/perry-runtime/src/object/class_registry.rs +++ b/crates/perry-runtime/src/object/class_registry.rs @@ -103,7 +103,8 @@ pub(crate) use prototype_methods::CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED; // ── prototype_methods.rs ──────────────────────────────────────────────────── pub(crate) use prototype_methods::{ class_prototype_fast_guards_invalidated, class_prototype_method_root_store, - mirror_prototype_method_on_object, synthetic_class_id_for_function, + invalidate_class_prototype_fast_guards, mirror_prototype_method_on_object, + synthetic_class_id_for_function, }; pub use prototype_methods::{ js_class_register_static_field, js_get_function_prototype_method, diff --git a/crates/perry-runtime/src/object/descriptor_state.rs b/crates/perry-runtime/src/object/descriptor_state.rs index 1c457b059c..53b5c1d234 100644 --- a/crates/perry-runtime/src/object/descriptor_state.rs +++ b/crates/perry-runtime/src/object/descriptor_state.rs @@ -195,11 +195,16 @@ pub(crate) fn test_reset_class_field_inline_guard() { /// registration are recorded in [`PROTO_DESCRIPTOR_KEY_HASHES`] and /// retro-checked by [`note_declared_instance_field_name`] when the class /// arrives. -pub(crate) fn disable_class_field_inline_guard_for_target(obj: usize, key: &str) { - if crate::array::object_prototype_addr_matches(obj) +pub(crate) fn disable_inline_guards_for_descriptor_target(obj: usize, key: &str) { + let is_prototype_target = crate::array::object_prototype_addr_matches(obj) || class_registry::is_registered_class_prototype_object(obj) - || class_registry::class_id_for_decl_prototype_object(obj).is_some() - { + || class_registry::class_id_for_decl_prototype_object(obj).is_some(); + if is_prototype_target { + // Direct method guards are not key-aware. Conservatively retire them + // after any user descriptor/accessor install on a prototype that can + // affect a class instance. Own-instance installs are rejected by the + // receiver's `OBJ_FLAG_HAS_DESCRIPTORS` header bit instead. + class_registry::invalidate_class_prototype_fast_guards(); let hash = super::key_bytes_hash(key.as_ptr(), key.len()); note_proto_descriptor_key_hash(hash); if declared_field_name_hash_exists(hash) { @@ -218,7 +223,7 @@ static DECLARED_FIELD_NAME_HASHES: std::sync::RwLock>> = std::sync::RwLock::new(None); @@ -682,7 +687,7 @@ pub(crate) fn set_property_attrs(obj: usize, key: String, attrs: PropertyAttrs) let st = state(); st.descriptors.property_attrs_in_use.set(true); GLOBAL_DESCRIPTORS_IN_USE.store(true, Ordering::Relaxed); - disable_class_field_inline_guard_for_target(obj, &key); + disable_inline_guards_for_descriptor_target(obj, &key); note_meta_descriptor_key(obj, &key, false); st.descriptors .property_descriptors @@ -877,7 +882,7 @@ pub(crate) fn set_accessor_descriptor(obj: usize, key: String, acc: AccessorDesc let st = state(); st.descriptors.accessors_in_use.set(true); GLOBAL_DESCRIPTORS_IN_USE.store(true, Ordering::Relaxed); - disable_class_field_inline_guard_for_target(obj, &key); + disable_inline_guards_for_descriptor_target(obj, &key); note_accessor_descriptor_key(&key); note_meta_descriptor_key(obj, &key, true); st.descriptors @@ -1181,6 +1186,10 @@ mod c5a_tests { "a prototype install keyed by a non-field name must not poison \ the inline class-field fast path" ); + assert!( + class_registry::class_prototype_fast_guards_invalidated(), + "a prototype descriptor must retire unkeyed direct-method guards" + ); // Field-style install: key declared by a registered class. note_declared_instance_field_name(b"c5a_field_x"); diff --git a/crates/perry-runtime/src/typed_feedback/guards.rs b/crates/perry-runtime/src/typed_feedback/guards.rs index 3abb5c6002..c9045638ff 100644 --- a/crates/perry-runtime/src/typed_feedback/guards.rs +++ b/crates/perry-runtime/src/typed_feedback/guards.rs @@ -1033,6 +1033,13 @@ pub unsafe extern "C" fn js_typed_feedback_method_direct_call_guard( /// probe plus an inline compare chain over the base's subclass closure turns /// the same information into a direct call. See /// `perry-codegen/src/lower_call/method_override.rs`. +/// +/// Descriptor invalidation is deliberately scoped rather than process-wide: +/// an own descriptor sets `OBJ_FLAG_HAS_DESCRIPTORS` on this receiver, while a +/// user descriptor on a registered class/Object prototype flips the same +/// sticky prototype latch checked below. A descriptor on an unrelated object +/// can affect neither method resolution nor this exact ShapeId proof and must +/// not poison every direct-method site in the process. #[no_mangle] pub unsafe extern "C" fn js_method_direct_shape_class( receiver: f64, @@ -1050,7 +1057,7 @@ pub unsafe extern "C" fn js_method_direct_shape_class( }; if (*gc_header).obj_type != crate::gc::GC_TYPE_OBJECT || (*gc_header).gc_flags & crate::gc::GC_FLAG_FORWARDED != 0 - || crate::object::descriptors_in_use() + || (*gc_header)._reserved & crate::gc::OBJ_FLAG_HAS_DESCRIPTORS != 0 || crate::object::class_prototype_fast_guards_invalidated() { return 0; diff --git a/crates/perry-runtime/src/typed_feedback/tests.rs b/crates/perry-runtime/src/typed_feedback/tests.rs index 20a40837b7..edf073331a 100644 --- a/crates/perry-runtime/src/typed_feedback/tests.rs +++ b/crates/perry-runtime/src/typed_feedback/tests.rs @@ -1966,6 +1966,36 @@ fn method_direct_shape_guard_requires_the_exact_compiler_pair() { 0 ); + // An unrelated descriptor used to poison every direct-method guard in the + // process through `GLOBAL_DESCRIPTORS_IN_USE`. It cannot affect this + // receiver or its prototype chain, so the exact compiler pair remains a + // valid proof. + let unrelated = crate::object::js_object_alloc(0, 0); + crate::object::descriptor_state::set_property_attrs( + unrelated as usize, + "unrelated_method_guard_descriptor".to_string(), + crate::object::descriptor_state::PropertyAttrs::new(false, true, true), + ); + assert_eq!( + unsafe { + super::guards::js_method_direct_shape_guard(receiver, class_id, expected_shape_id) + }, + 1 + ); + + // Own descriptors remain fail-closed even if the ShapeId word itself is + // unchanged: the GcHeader bit is the authoritative per-receiver proof. + unsafe { + let gc = (obj as *mut u8).sub(crate::gc::GC_HEADER_SIZE) as *mut crate::gc::GcHeader; + let original_reserved = (*gc)._reserved; + (*gc)._reserved |= crate::gc::OBJ_FLAG_HAS_DESCRIPTORS; + assert_eq!( + super::guards::js_method_direct_shape_guard(receiver, class_id, expected_shape_id), + 0 + ); + (*gc)._reserved = original_reserved; + } + // The classifier returns an untrusted header token; only the exact // compiler-published pair licenses the direct call. A divergent stamp must // miss even when it remains in the process-global ShapeId range. From 7461291a79baabf8376bac01678a2453c5e2ddb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 22 Aug 2026 05:16:31 +0200 Subject: [PATCH 2/3] perf(codegen): inline monomorphic method shape guards --- crates/perry-codegen/src/block.rs | 13 ++ .../collectors/proven_this_routing_tests.rs | 59 +++++++- crates/perry-codegen/src/dialect/mod.rs | 11 ++ crates/perry-codegen/src/dialect/tests.rs | 14 ++ crates/perry-codegen/src/inst.rs | 7 + .../src/lower_call/method_override.rs | 128 +++++++++++++++++- .../src/runtime_decls/objects.rs | 4 + crates/perry-codegen/src/target_layout.rs | 87 ++++++++++++ .../class_registry/prototype_methods.rs | 23 +++- .../perry/tests/method_shape_inline_guard.rs | 90 ++++++++++++ 10 files changed, 428 insertions(+), 8 deletions(-) create mode 100644 crates/perry/tests/method_shape_inline_guard.rs diff --git a/crates/perry-codegen/src/block.rs b/crates/perry-codegen/src/block.rs index 1c3d4c0322..cc7564e843 100644 --- a/crates/perry-codegen/src/block.rs +++ b/crates/perry-codegen/src/block.rs @@ -634,6 +634,19 @@ impl LlBlock { r } + /// Acquire atomic load for a sticky runtime gate whose publishing store is + /// release-ordered. The explicit alignment is required by LLVM atomics. + pub fn load_atomic_acquire(&mut self, ty: LlvmType, ptr: &str, alignment: u32) -> String { + let r = self.reg(); + self.push_inst(crate::inst::LlInst::Load { + dst: r.clone(), + ty, + ptr: ptr.to_string(), + flavor: crate::inst::LoadFlavor::AtomicAcquire(alignment), + }); + r + } + /// Sequentially-consistent atomic load for globals shared with runtime /// atomics. The explicit alignment is required by LLVM atomic loads. pub fn load_atomic_seq_cst(&mut self, ty: LlvmType, ptr: &str, alignment: u32) -> String { diff --git a/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs b/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs index e32ffc93c3..3bb92d985e 100644 --- a/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs +++ b/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs @@ -542,7 +542,10 @@ fn guarded_pshape_call_site_is_preceded_by_a_shape_id_guard() { }); let prefix = &probe[..call_pos]; let guarded = prefix.contains("call i32 @js_typed_feedback_method_direct_call_guard(") - || prefix.contains("call i32 @js_method_direct_shape_guard("); + || prefix.contains("call i32 @js_method_direct_shape_guard(") + || prefix.contains( + "load atomic i8, ptr @PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED acquire", + ); assert!( guarded, "{target}: no ShapeId guard call precedes it in `probe` — a \ @@ -553,6 +556,60 @@ fn guarded_pshape_call_site_is_preceded_by_a_shape_id_guard() { } } +/// The single-pair shape-only arm is small enough to inline at the call site. +/// Pin the complete safety gate: acquire the prototype-mutation latch, accept +/// both the boxed-pointer and internal raw-pointer ABIs, reject addresses +/// outside the target heap range before dereference, reject own descriptors, +/// then compare the exact class/ShapeId pair. The out-of-line guard must be +/// absent from this caller. +#[test] +fn single_arm_method_shape_guard_is_inlined_with_the_runtime_contract() { + let ir = emit(&guarded_site_module(), false); + let probe = function_body(&ir, "__probe("); + assert!( + probe.contains( + "load atomic i8, ptr @PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED acquire, align 1", + ), + "the inline guard must acquire the runtime's release-published sticky latch:\n{probe}" + ); + assert!( + !probe.contains("call i32 @js_method_direct_shape_guard("), + "a monomorphic shape-only site must not retain the out-of-line guard call:\n{probe}" + ); + assert!( + probe.contains("icmp eq i64") + && probe.contains(", 32765") + && probe.contains(", 0"), + "the dereference gate must accept the 0x7FFD boxed pointer tag and the internal raw-pointer form:\n{probe}" + ); + let target = crate::codegen::default_target_triple(); + let heap_floor = crate::target_layout::heap_addr_lower_bound_inclusive(&target); + let heap_ceiling = crate::target_layout::heap_addr_upper_bound_exclusive(&target); + assert!( + probe.contains("icmp uge i64") + && probe.contains(&format!(", {heap_floor}")) + && probe.contains("icmp ult i64") + && probe.contains(&format!(", {heap_ceiling}")), + "the dereference gate must reject candidates outside the target heap range:\n{probe}" + ); + assert!( + probe.contains("method_direct.inline_deref") + && probe.contains("getelementptr i8, ptr") + && probe.contains("i64 -8") + && probe.contains("i64 -7") + && probe.contains("i64 -6") + && probe.contains("and i16") + && probe.contains(", 2048") + && probe.contains("icmp ne i32") + && probe.contains("i64 4") + && probe.contains("add i32") + && probe.contains(", -2147483648") + && probe.contains("icmp ult i32") + && probe.contains(", 1073741824"), + "the header block must check the GC type, forwarding flag, own-descriptor bit, nonzero class id, ShapeId domain, and live ShapeId:\n{probe}" + ); +} + /// Regression (#7128), Phase 3b guard-free site: a shape-proven LOCAL whose /// method also has a typed-receiver clone must still route to the proven-`this` /// clone. diff --git a/crates/perry-codegen/src/dialect/mod.rs b/crates/perry-codegen/src/dialect/mod.rs index 202e9acef6..87c3f3ad82 100644 --- a/crates/perry-codegen/src/dialect/mod.rs +++ b/crates/perry-codegen/src/dialect/mod.rs @@ -1379,6 +1379,17 @@ impl<'ctx, 'm> FnReader<'ctx, 'm> { let _ = i.set_alignment(*n); } } + LF::AtomicAcquire(n) => { + if let Some(i) = v.as_instruction_value() { + unsafe { + llvm_sys::core::LLVMSetOrdering( + i.as_value_ref(), + llvm_sys::LLVMAtomicOrdering::LLVMAtomicOrderingAcquire, + ) + }; + let _ = i.set_alignment(*n); + } + } LF::AtomicSeqCst(n) => { if let Some(i) = v.as_instruction_value() { unsafe { diff --git a/crates/perry-codegen/src/dialect/tests.rs b/crates/perry-codegen/src/dialect/tests.rs index 53841c989a..36121796a5 100644 --- a/crates/perry-codegen/src/dialect/tests.rs +++ b/crates/perry-codegen/src/dialect/tests.rs @@ -221,6 +221,20 @@ fn compiled_module_ir_round_trips_through_the_reader() { assert!(n > 0, "round-tripped an empty module"); } +#[test] +fn acquire_atomic_load_round_trips_through_the_reader() { + let ir = r#" +@gate = external global i8 + +define i8 @load_gate() { +entry: + %value = load atomic i8, ptr @gate acquire, align 1 + ret i8 %value +} +"#; + assert_eq!(roundtrip_ir(ir, "acquire_load"), 2); +} + /// The `format!` templates `expr/channel.rs` emits for its `<4 x i32>` SIMD /// byte-channel reduction, in emission order. The fixture below is BUILT from /// these strings rather than duplicating them, and each is asserted to still diff --git a/crates/perry-codegen/src/inst.rs b/crates/perry-codegen/src/inst.rs index f5f0920abd..2fbc1b6c40 100644 --- a/crates/perry-codegen/src/inst.rs +++ b/crates/perry-codegen/src/inst.rs @@ -36,6 +36,7 @@ pub enum LoadFlavor { Aligned(u32), Volatile, AtomicMonotonic(u32), + AtomicAcquire(u32), AtomicSeqCst(u32), /// `!invariant.load !0` tagged (issue #52). Invariant, @@ -219,6 +220,12 @@ impl LlInst { " {dst} = load atomic {ty}, ptr {ptr} monotonic, align {n}" ); } + LoadFlavor::AtomicAcquire(n) => { + let _ = write!( + out, + " {dst} = load atomic {ty}, ptr {ptr} acquire, align {n}" + ); + } LoadFlavor::AtomicSeqCst(n) => { let _ = write!( out, diff --git a/crates/perry-codegen/src/lower_call/method_override.rs b/crates/perry-codegen/src/lower_call/method_override.rs index 4c23e8078b..c16a4bab74 100644 --- a/crates/perry-codegen/src/lower_call/method_override.rs +++ b/crates/perry-codegen/src/lower_call/method_override.rs @@ -11,7 +11,109 @@ use crate::expr::{ }; use crate::nanbox::double_literal; use crate::native_value::LoweredValue; -use crate::types::{DOUBLE, I1, I32, I64}; +use crate::types::{DOUBLE, I1, I16, I32, I64, I8}; + +const POINTER_TAG_HI16: &str = "32765"; // 0x7FFD +const GC_TYPE_OBJECT: &str = "2"; +const GC_FLAG_FORWARDED_I8: &str = "-128"; // 0x80 as i8 +const OBJ_FLAG_HAS_DESCRIPTORS_I16: &str = "2048"; // 0x0800 +const SHAPE_ID_BASE_NEG_I32: &str = "-2147483648"; // subtract 0x8000_0000 +const SHAPE_ID_RANGE_LEN: &str = "1073741824"; // 0x4000_0000 + +/// Emit the single-arm equivalent of `js_method_direct_shape_guard` directly +/// into the generated module. The guard remains dynamic at every call site: +/// arbitrary callback code may replace a prototype method or mutate the +/// receiver between loop iterations. +/// +/// The first block proves that the value is a tagged heap pointer or the raw +/// object-address form used by internal method ABIs before any dereference. +/// The second block reproduces the runtime helper's production contract: the +/// class-prototype invalidation latch is clear, the receiver is a non-forwarded +/// ordinary object without own descriptors, and its exact `(class_id, ShapeId)` +/// pair still matches the compiler-published pair. Any failed proof takes the +/// unchanged dynamic method fallback. +fn emit_inline_direct_method_shape_guard( + ctx: &mut FnCtx<'_>, + recv_box: &str, + expected_class_id: &str, + expected_shape_id: &str, + fast_label: &str, + fallback_label: &str, +) { + let deref_idx = ctx.new_block("method_direct.inline_deref"); + let deref_label = ctx.block_label(deref_idx); + let heap_floor = + crate::target_layout::heap_addr_lower_bound_inclusive(ctx.target_triple).to_string(); + let heap_ceiling = + crate::target_layout::heap_addr_upper_bound_exclusive(ctx.target_triple).to_string(); + + { + let blk = ctx.block(); + let invalidated = + blk.load_atomic_acquire(I8, "@PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED", 1); + let prototype_ok = blk.icmp_eq(I8, &invalidated, "0"); + let recv_bits = blk.bitcast_double_to_i64(recv_box); + let recv_handle = blk.and(I64, &recv_bits, crate::nanbox::POINTER_MASK_I64); + let tag = blk.lshr(I64, &recv_bits, "48"); + let is_tagged_ptr = blk.icmp_eq(I64, &tag, POINTER_TAG_HI16); + // Internal method ABIs also carry an unboxed raw object address in a + // double-sized slot. `normalize_raw_object_addr` accepts exactly this + // top-word-zero form; all other non-pointer NaN-box tags remain + // rejected before dereference. + let is_raw_ptr = blk.icmp_eq(I64, &tag, "0"); + let is_ptr = blk.or(I1, &is_tagged_ptr, &is_raw_ptr); + let above_floor = blk.icmp_uge(I64, &recv_handle, &heap_floor); + let below_ceiling = blk.icmp_ult(I64, &recv_handle, &heap_ceiling); + let in_heap_range = blk.and(I1, &above_floor, &below_ceiling); + let ptr_safe = blk.and(I1, &is_ptr, &in_heap_range); + let can_deref = blk.and(I1, &prototype_ok, &ptr_safe); + blk.cond_br(&can_deref, &deref_label, fallback_label); + } + + ctx.current_block = deref_idx; + { + let blk = ctx.block(); + let recv_bits = blk.bitcast_double_to_i64(recv_box); + let recv_handle = blk.and(I64, &recv_bits, crate::nanbox::POINTER_MASK_I64); + let obj_ptr = blk.inttoptr(I64, &recv_handle); + + 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 forwarded = blk.and(I8, &gflags, GC_FLAG_FORWARDED_I8); + let not_forwarded = blk.icmp_eq(I8, &forwarded, "0"); + + let reserved_ptr = blk.gep(I8, &obj_ptr, &[(I64, "-6")]); + let reserved = blk.load(I16, &reserved_ptr); + let descriptor_bits = blk.and(I16, &reserved, OBJ_FLAG_HAS_DESCRIPTORS_I16); + let no_own_descriptors = blk.icmp_eq(I16, &descriptor_bits, "0"); + + let class_ptr = blk.gep(I8, &obj_ptr, &[(I64, "0")]); + let class_id = blk.load(I32, &class_ptr); + let class_valid = blk.icmp_ne(I32, &class_id, "0"); + let class_ok = blk.icmp_eq(I32, &class_id, expected_class_id); + + let shape_ptr = blk.gep(I8, &obj_ptr, &[(I64, "4")]); + let shape_id = blk.load(I32, &shape_ptr); + // `is_shape_id` is `[0x8000_0000, 0xC000_0000)`. Subtract the base + // modulo i32 and compare with the range length, matching the runtime + // helper without a call. + let shape_id_rel = blk.add(I32, &shape_id, SHAPE_ID_BASE_NEG_I32); + let shape_valid = blk.icmp_ult(I32, &shape_id_rel, SHAPE_ID_RANGE_LEN); + let shape_ok = blk.icmp_eq(I32, &shape_id, expected_shape_id); + + let mut pass = blk.and(I1, >ype_ok, ¬_forwarded); + pass = blk.and(I1, &pass, &no_own_descriptors); + pass = blk.and(I1, &pass, &class_valid); + pass = blk.and(I1, &pass, &class_ok); + pass = blk.and(I1, &pass, &shape_valid); + pass = blk.and(I1, &pass, &shape_ok); + blk.cond_br(&pass, fast_label, fallback_label); + } +} fn typed_i1_method_signature_note(reps: &[crate::codegen::TypedParamRep]) -> String { let first = reps.first().map(|rep| rep.label()).unwrap_or("void"); @@ -292,9 +394,11 @@ pub(super) fn emit_guarded_direct_method_call( ctx.current_block = guard_idx; // Multi-arm form: ONE probe resolves the receiver's class id and keys // token (every precondition `js_method_direct_shape_guard` checks except - // the comparison itself), then an inline compare chain picks the arm. The - // single-arm form keeps its original single call. + // the comparison itself), then an inline compare chain picks the arm. A + // shape-only single-arm site emits the equivalent guard inline; other + // single-arm sites retain the runtime helper. let multi_arm = !subclass_arms.is_empty(); + let inline_single_arm = shape_only_guard && !multi_arm; if multi_arm { let shape_slot = ctx.func.alloca_entry(I32); let cid = ctx.block().call( @@ -328,9 +432,21 @@ pub(super) fn emit_guarded_direct_method_call( } ctx.current_block = guard_idx; } - let guard_ok = if multi_arm { + if inline_single_arm { + emit_inline_direct_method_shape_guard( + ctx, + recv_box, + &expected_class_id_str, + &expected_shape_id, + &fast_label, + &fallback_label, + ); + } + let guard_ok = if multi_arm || inline_single_arm { // The chain above already terminated the guard block and every test - // block; `fast_idx` / `fallback_idx` are entered from it unchanged. + // block, or the inline single-arm guard terminated both its pointer + // gate and header block; `fast_idx` / `fallback_idx` are entered from + // either form unchanged. String::new() } else if shape_only_guard { ctx.block().call( @@ -360,7 +476,7 @@ pub(super) fn emit_guarded_direct_method_call( ], ) }; - if !multi_arm { + if !multi_arm && !inline_single_arm { let guard_pass = ctx.block().icmp_ne(I32, &guard_ok, "0"); ctx.block() .cond_br(&guard_pass, &fast_label, &fallback_label); diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index e1f7d3b73e..0faf427979 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -29,6 +29,10 @@ pub fn declare_phase_b_objects(module: &mut LlModule) { // when it is non-zero (descriptors / typed-feedback in use). Defined in // perry-runtime as `PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED`. module.add_external_global("PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED", I8); + // Sticky runtime flag (i8, 0 = valid) for class-prototype method guards. + // Direct-method lowering reads it with acquire ordering before touching a + // receiver header; prototype mutation stores 1 with release ordering. + module.add_external_global("PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED", I8); // #7834/#7873: process-global count of threads with per-object records. // `0` proves both per-object side tables are empty everywhere, so a // construction site can skip `js_gc_forget_object_layout` outright. diff --git a/crates/perry-codegen/src/target_layout.rs b/crates/perry-codegen/src/target_layout.rs index 5bf11cb93b..7efbdd6a3a 100644 --- a/crates/perry-codegen/src/target_layout.rs +++ b/crates/perry-codegen/src/target_layout.rs @@ -25,6 +25,47 @@ pub fn target_is_ilp32(target_triple: &str) -> bool { || target_triple.ends_with("gnux32") } +/// Exclusive upper bound accepted by the runtime's `is_valid_obj_ptr` for a +/// candidate GC address. Linux-family AArch64 can use the full low 48-bit VA +/// range; every other target keeps the canonical low-half 47-bit ceiling. +/// Inline pointer guards must use this target-derived value before touching a +/// `GcHeader`, matching `perry-runtime/src/value/addr_class.rs`. +pub(crate) fn heap_addr_upper_bound_exclusive(target_triple: &str) -> u64 { + let triple = target_triple.to_ascii_lowercase(); + let is_aarch64 = triple.starts_with("aarch64") || triple.starts_with("arm64"); + // HarmonyOS triples contain the spelling `linux-ohos`, but Rust exposes + // them as `target_os = "ohos"`; the runtime therefore keeps the 47-bit + // ceiling there. Android is explicitly in the full-range arm. + let is_linux_family = + triple.contains("android") || (triple.contains("linux") && !triple.contains("ohos")); + if is_aarch64 && is_linux_family { + 0x1_0000_0000_0000 + } else { + 0x8000_0000_0000 + } +} + +/// Inclusive lower bound for a candidate GC address after excluding Perry's +/// small-handle band. Mainstream hosted targets accept low virtual addresses, +/// so the 1 MiB handle ceiling is the effective floor. Other targets use the +/// runtime's conservative 2 TiB floor before any `GcHeader` dereference. +pub(crate) fn heap_addr_lower_bound_inclusive(target_triple: &str) -> u64 { + let triple = target_triple.to_ascii_lowercase(); + let mainstream_os = triple.contains("android") + || triple.contains("darwin") + || (triple.contains("linux") && !triple.contains("ohos")) + || triple.contains("windows") + || triple.contains("ios") + || triple.contains("tvos") + || triple.contains("watchos") + || triple.contains("visionos"); + if mainstream_os { + 0x10_0000 + } else { + 0x200_0000_0000 + } +} + /// `std::mem::size_of::()` for the target. /// /// #8047: `ObjectHeader` is two `u32`s (`class_id` @0, `parent_class_id` @4 — @@ -201,6 +242,52 @@ mod tests { assert_eq!(object_header_size_bytes("arm64_32-apple-watchos"), 16); } + #[test] + fn heap_address_ceiling_matches_runtime_targets() { + assert_eq!( + heap_addr_upper_bound_exclusive("aarch64-unknown-linux-gnu"), + 0x1_0000_0000_0000 + ); + assert_eq!( + heap_addr_upper_bound_exclusive("aarch64-linux-android"), + 0x1_0000_0000_0000 + ); + for triple in [ + "aarch64-apple-darwin", + "arm64_32-apple-watchos", + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-ohos", + "x86_64-pc-windows-msvc", + ] { + assert_eq!( + heap_addr_upper_bound_exclusive(triple), + 0x8000_0000_0000, + "{triple}" + ); + } + } + + #[test] + fn heap_address_floor_matches_runtime_targets_and_handle_band() { + for triple in [ + "aarch64-apple-darwin", + "aarch64-apple-ios", + "arm64_32-apple-watchos", + "x86_64-unknown-linux-gnu", + "aarch64-linux-android", + "x86_64-pc-windows-msvc", + ] { + assert_eq!(heap_addr_lower_bound_inclusive(triple), 0x10_0000); + } + for triple in ["aarch64-unknown-linux-ohos", "riscv64gc-unknown-none-elf"] { + assert_eq!( + heap_addr_lower_bound_inclusive(triple), + 0x200_0000_0000, + "{triple}" + ); + } + } + /// Two emitters divide the header size by 8 to get a WORD index. #8047 /// keeps ILP32 at 16 with explicit padding so that remains exact. #[test] diff --git a/crates/perry-runtime/src/object/class_registry/prototype_methods.rs b/crates/perry-runtime/src/object/class_registry/prototype_methods.rs index a0d4cb91e8..e875a438cd 100644 --- a/crates/perry-runtime/src/object/class_registry/prototype_methods.rs +++ b/crates/perry-runtime/src/object/class_registry/prototype_methods.rs @@ -52,16 +52,37 @@ crate::perry_thread_local! { RwLock::new(None); } +// Production codegen reads this byte directly before entering a guarded +// direct-method arm. Keep the test build per-thread so one mutation test cannot +// poison unrelated tests running in parallel; generated programs link the +// non-test symbol below. +#[cfg(not(test))] +#[no_mangle] +pub static PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED: std::sync::atomic::AtomicU8 = + std::sync::atomic::AtomicU8::new(0); + +#[cfg(test)] per_test_global! { pub(crate) static CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); } pub(crate) fn class_prototype_fast_guards_invalidated() -> bool { - CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED.load(std::sync::atomic::Ordering::Acquire) + #[cfg(not(test))] + { + PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED.load(std::sync::atomic::Ordering::Acquire) + != 0 + } + #[cfg(test)] + { + CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED.load(std::sync::atomic::Ordering::Acquire) + } } pub(crate) fn invalidate_class_prototype_fast_guards() { + #[cfg(not(test))] + PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED.store(1, std::sync::atomic::Ordering::Release); + #[cfg(test)] CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED.store(true, std::sync::atomic::Ordering::Release); // #7480: prototype surgery is the one event that retires an element-shape // proof without touching any array — the class's shape stopped being a diff --git a/crates/perry/tests/method_shape_inline_guard.rs b/crates/perry/tests/method_shape_inline_guard.rs new file mode 100644 index 0000000000..5718abd7bb --- /dev/null +++ b/crates/perry/tests/method_shape_inline_guard.rs @@ -0,0 +1,90 @@ +//! Regression coverage for the inlined monomorphic method-shape guard. +//! +//! The direct call is emitted in `invoke.ts`, while `main.ts` installs an own +//! method through an alias the callee module cannot see statically. The inline +//! guard must therefore re-check the live ShapeId on every call. The fixture +//! also proves an unrelated descriptor does not poison the site before an own +//! method replacement changes the receiver shape and reaches dynamic dispatch. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn write_fixture(root: &Path) -> PathBuf { + std::fs::write( + root.join("counter.ts"), + r#" +export class Counter { + value(): number { return 1 } +} +"#, + ) + .expect("write counter module"); + std::fs::write( + root.join("invoke.ts"), + r#" +import { Counter } from "./counter" + +export function invoke(counter: Counter): number { + return counter.value() +} +"#, + ) + .expect("write guarded caller module"); + let entry = root.join("main.ts"); + std::fs::write( + &entry, + r#" +import { Counter } from "./counter" +import { invoke } from "./invoke" + +const own: any = new Counter() +console.log("base:", invoke(own)) + +const unrelated: any = {} +Object.defineProperty(unrelated, "locked", { value: 1, writable: false }) +console.log("unrelated descriptor:", invoke(own)) + +own.value = () => 7 +console.log("own:", invoke(own)) +"#, + ) + .expect("write entry module"); + entry +} + +#[test] +fn live_shape_change_deopts_to_dynamic_dispatch() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = write_fixture(dir.path()); + let binary = dir.path().join("method_shape_guard"); + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&binary) + .output() + .expect("compile fixture"); + assert!( + compile.status.success(), + "compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + let run = Command::new(&binary).output().expect("run fixture"); + assert!( + run.status.success(), + "fixture failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&run.stdout), + "base: 1\nunrelated descriptor: 1\nown: 7\n" + ); +} From 68fefb93bcebf461cb7b9914abc22165d9c7be70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 22 Aug 2026 05:17:31 +0200 Subject: [PATCH 3/3] docs(changelog): record method guard speedup --- changelog.d/8573-inline-method-shape-guard.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelog.d/8573-inline-method-shape-guard.md diff --git a/changelog.d/8573-inline-method-shape-guard.md b/changelog.d/8573-inline-method-shape-guard.md new file mode 100644 index 0000000000..7c208a21b9 --- /dev/null +++ b/changelog.d/8573-inline-method-shape-guard.md @@ -0,0 +1,5 @@ +Scoped direct-method descriptor invalidation to the receiver and relevant +prototype mutations, then inlined complete monomorphic ShapeId guards. On a +10,000-entity callback-heavy query with an unrelated descriptor, read-only time +falls from 3.7599 to 0.3381 ms/op and accumulation from 3.6702 to 0.2829 ms/op; +inlining contributes a further 7.4% and 8.1% paired reduction respectively.