Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions changelog.d/8672-method-name-prototype-guards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
category: Performance
title: Restore method-scoped prototype guards
---

Prototype mutation now invalidates direct-call guards by method-name slot
instead of permanently disabling every method guard in the process. Hash
collisions remain conservative, and dynamic prototype replacement retains a
global fail-closed escape hatch.
16 changes: 11 additions & 5 deletions crates/perry-codegen/src/collectors/proven_this_routing_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -692,11 +692,11 @@ 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.
/// Pin the complete safety gate: acquire both the all-method escape latch and
/// the FNV-indexed method-name 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);
Expand All @@ -707,6 +707,12 @@ fn single_arm_method_shape_guard_is_inlined_with_the_runtime_contract() {
),
"the inline guard must acquire the runtime's release-published sticky latch:\n{probe}"
);
assert!(
probe.contains(
"getelementptr i8, ptr @PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED_BY_METHOD",
) && probe.matches("load atomic i8").count() >= 2,
"the inline guard must acquire its method-name invalidation byte:\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}"
Expand Down
29 changes: 23 additions & 6 deletions crates/perry-codegen/src/lower_call/method_override.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,17 @@ const SHAPE_ID_RANGE_LEN: &str = "1073741824"; // 0x4000_0000
/// 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.
/// all-method escape latch and this method name's invalidation byte are 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,
method_guard_slot: &str,
fast_label: &str,
fallback_label: &str,
) {
Expand All @@ -60,7 +62,15 @@ fn emit_inline_direct_method_shape_guard(
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 all_methods_ok = blk.icmp_eq(I8, &invalidated, "0");
let method_slot_ptr = blk.gep(
I8,
"@PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED_BY_METHOD",
&[(I64, method_guard_slot)],
);
let method_invalidated = blk.load_atomic_acquire(I8, &method_slot_ptr, 1);
let method_ok = blk.icmp_eq(I8, &method_invalidated, "0");
let prototype_ok = blk.and(I1, &all_methods_ok, &method_ok);
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");
Expand Down Expand Up @@ -395,6 +405,7 @@ pub(super) fn emit_guarded_direct_method_call(
let entry = ctx.strings.entry(key_idx);
let bytes_global = format!("@{}", entry.bytes_global);
let name_len_str = entry.byte_len.to_string();
let method_guard_slot_str = (entry.dispatch_hash & 0xffff).to_string();
let dispatch_global = ctx.strings.static_dispatch_global(key_idx);
let site_id = if shape_only_guard {
None
Expand Down Expand Up @@ -452,7 +463,11 @@ pub(super) fn emit_guarded_direct_method_call(
let cid = ctx.block().call(
I32,
"js_method_direct_shape_class",
&[(DOUBLE, recv_box), (crate::types::PTR, &shape_slot)],
&[
(DOUBLE, recv_box),
(crate::types::PTR, &shape_slot),
(I32, &method_guard_slot_str),
],
);
let shape_id = ctx.block().load(I32, &shape_slot);
{
Expand Down Expand Up @@ -486,6 +501,7 @@ pub(super) fn emit_guarded_direct_method_call(
recv_box,
&expected_class_id_str,
&expected_shape_id,
&method_guard_slot_str,
&fast_label,
&fallback_label,
);
Expand All @@ -504,6 +520,7 @@ pub(super) fn emit_guarded_direct_method_call(
(DOUBLE, recv_box),
(I32, &expected_class_id_str),
(I32, &expected_shape_id),
(I32, &method_guard_slot_str),
],
)
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,7 @@ pub(crate) fn try_lower_instance_method_call(
let probe_entry = ctx.strings.entry(key_idx_probe);
let probe_bytes_global = format!("@{}", probe_entry.bytes_global);
let probe_name_len_str = probe_entry.byte_len.to_string();
let method_guard_slot_str = (probe_entry.dispatch_hash & 0xffff).to_string();
let probe_override_idx = ctx.new_block("idisp.override");
let probe_dispatch_idx = ctx.new_block("idisp.dispatch");
let probe_outer_merge_idx = ctx.new_block("idisp.outer_merge");
Expand Down Expand Up @@ -536,7 +537,11 @@ pub(crate) fn try_lower_instance_method_call(
let cid = ctx.block().call(
I32,
"js_method_direct_shape_class",
&[(DOUBLE, &recv_box), (crate::types::PTR, &shape_slot)],
&[
(DOUBLE, &recv_box),
(crate::types::PTR, &shape_slot),
(I32, &method_guard_slot_str),
],
);
let shape_id = ctx.block().load(I32, &shape_slot);
shape_probe_cid = Some(cid.clone());
Expand Down
14 changes: 12 additions & 2 deletions crates/perry-codegen/src/runtime_decls/objects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ pub fn declare_phase_b_objects(module: &mut LlModule) {
// 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);
// Per-method sticky invalidation table indexed by low FNV-1a bits. A
// collision is conservative: it only disables another direct guard.
module.add_external_global(
"PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED_BY_METHOD",
"[65536 x 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.
Expand Down Expand Up @@ -214,8 +220,12 @@ pub fn declare_phase_b_objects(module: &mut LlModule) {
I32,
&[I64, DOUBLE, I32, I32, PTR, I64, PTR],
);
module.declare_function("js_method_direct_shape_guard", I32, &[DOUBLE, I32, I32]);
module.declare_function("js_method_direct_shape_class", I32, &[DOUBLE, PTR]);
module.declare_function(
"js_method_direct_shape_guard",
I32,
&[DOUBLE, I32, I32, I32],
);
module.declare_function("js_method_direct_shape_class", I32, &[DOUBLE, PTR, I32]);
module.declare_function(
"js_typed_feedback_closure_direct_call_guard",
I32,
Expand Down
21 changes: 12 additions & 9 deletions crates/perry-runtime/src/object/class_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,9 @@ pub(crate) use state::{
class_parent_closure, class_parent_closure_root_store, class_prototype_method_is_enumerable,
class_prototype_method_set_enumerable, class_prototype_method_value_cache_root_store,
class_prototype_object_root_store, class_static_defined_attrs, class_static_set_defined_attrs,
global_object_prototype_bits, is_bound_native_constructor_closure_value,
is_non_constructable_builtin_function_value, parent_closure_in_chain,
throw_non_constructable_builtin_function,
class_unmark_key_deleted, global_object_prototype_bits,
is_bound_native_constructor_closure_value, is_non_constructable_builtin_function_value,
parent_closure_in_chain, throw_non_constructable_builtin_function,
};
pub use state::{
ClassVTable, VTableMethodEntry, CLASS_DECL_PROTOTYPE_OBJECTS, CLASS_DYNAMIC_PARENT_VALUE,
Expand Down Expand Up @@ -104,12 +104,15 @@ pub(crate) use class_meta::{
CLASS_ID_TEXT_ENCODER_STREAM,
};
#[cfg(test)]
pub(crate) use prototype_methods::CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED;
pub(crate) use prototype_methods::{
class_prototype_fast_guards_invalidated, class_prototype_method_guard_slot,
CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED, CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED_BY_METHOD,
};

// ── prototype_methods.rs ────────────────────────────────────────────────────
pub(crate) use prototype_methods::{
class_prototype_fast_guards_invalidated, class_prototype_method_root_store,
invalidate_class_prototype_fast_guards, mirror_prototype_method_on_object,
class_prototype_fast_guard_invalidated_for_method, class_prototype_method_root_store,
invalidate_class_prototype_fast_guards_for_method, mirror_prototype_method_on_object,
synthetic_class_id_for_function,
};
pub use prototype_methods::{
Expand All @@ -120,9 +123,9 @@ pub use prototype_methods::{
// ── construct.rs / vm_brand.rs ──────────────────────────────────────────────
pub(crate) use construct::{
extends_target_must_throw, is_callable_function_value, js_value_is_constructor,
lookup_prototype_method, nm_ctor_child_process, nm_ctor_cluster, nm_ctor_fs, nm_ctor_readline,
nm_ctor_repl, nm_ctor_stream, nm_ctor_tls, nm_ctor_tty, nm_ctor_vm, nm_ctor_wasi,
promise_parent_in_chain,
lookup_own_prototype_method, lookup_prototype_method, nm_ctor_child_process, nm_ctor_cluster,
nm_ctor_fs, nm_ctor_readline, nm_ctor_repl, nm_ctor_stream, nm_ctor_tls, nm_ctor_tty,
nm_ctor_vm, nm_ctor_wasi, promise_parent_in_chain,
};
pub use construct::{
js_ctor_return_override, js_new_function_construct, js_new_function_construct_apply,
Expand Down
19 changes: 16 additions & 3 deletions crates/perry-runtime/src/object/class_registry/construct.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1862,16 +1862,29 @@ pub(super) fn is_arrow_function_value(value: f64) -> bool {
/// `(class_id, name)`, or None if no assignment matched. Walks the
/// parent-class chain so methods registered on a base class are found
/// via subclass instances.
pub(crate) fn lookup_own_prototype_method(class_id: u32, name: &str) -> Option<f64> {
if class_is_key_deleted(class_id, name) {
return None;
}
CLASS_PROTOTYPE_METHODS.with(|table| {
let guard = table.read().ok()?;
let bits = guard.as_ref()?.get(&class_id)?.get(name)?;
Some(f64::from_bits(*bits))
})
}

pub(crate) fn lookup_prototype_method(class_id: u32, name: &str) -> Option<f64> {
CLASS_PROTOTYPE_METHODS.with(|table| {
let guard = table.read().ok()?;
let map = guard.as_ref()?;
let mut cid = class_id;
let mut depth = 0usize;
while depth < 32 {
if let Some(per_class) = map.get(&cid) {
if let Some(&bits) = per_class.get(name) {
return Some(f64::from_bits(bits));
if !class_is_key_deleted(cid, name) {
if let Some(per_class) = map.get(&cid) {
if let Some(&bits) = per_class.get(name) {
return Some(f64::from_bits(bits));
}
}
}
match crate::object::class_generic_origin(cid).or_else(|| get_parent_class_id(cid)) {
Expand Down
4 changes: 4 additions & 0 deletions crates/perry-runtime/src/object/class_registry/gc_roots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,10 @@ pub(crate) fn test_clear_class_side_table_roots() {
}
});
CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED.store(false, std::sync::atomic::Ordering::Release);
CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED_BY_METHOD
.write()
.unwrap()
.clear();
FUNCTION_CLASS_IDS.with(|table| {
if let Ok(mut guard) = table.write() {
*guard = None;
Expand Down
98 changes: 76 additions & 22 deletions crates/perry-runtime/src/object/class_registry/prototype_methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,19 +100,36 @@ 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.
// Production codegen reads this fail-closed all-method byte and the scoped
// table below before entering a guarded direct-method arm. Keep the test state
// per-thread so one mutation test cannot poison unrelated tests running in
// parallel; generated programs link the non-test symbols below.
#[cfg(not(test))]
#[no_mangle]
pub static PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED: std::sync::atomic::AtomicU8 =
std::sync::atomic::AtomicU8::new(0);

/// Sticky per-method invalidation bytes for compiler-emitted direct-method
/// guards. Prototype writes always have a property name, so they only need to
/// retire guards for that name. The low 16 bits of the name's FNV-1a hash
/// select a byte; collisions conservatively retire additional names.
pub(crate) const CLASS_PROTOTYPE_METHOD_GUARD_SLOT_COUNT: usize = 1 << 16;
pub(crate) const CLASS_PROTOTYPE_METHOD_GUARD_SLOT_MASK: u64 =
(CLASS_PROTOTYPE_METHOD_GUARD_SLOT_COUNT - 1) as u64;

#[cfg(not(test))]
#[no_mangle]
pub static PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED_BY_METHOD: [std::sync::atomic::AtomicU8;
CLASS_PROTOTYPE_METHOD_GUARD_SLOT_COUNT] =
[const { std::sync::atomic::AtomicU8::new(0) }; CLASS_PROTOTYPE_METHOD_GUARD_SLOT_COUNT];

#[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) static CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED_BY_METHOD:
std::sync::RwLock<std::collections::HashSet<u16>> =
std::sync::RwLock::new(std::collections::HashSet::new());
}

pub(crate) fn class_prototype_fast_guards_invalidated() -> bool {
Expand All @@ -127,30 +144,68 @@ pub(crate) fn class_prototype_fast_guards_invalidated() -> bool {
}
}

#[inline]
pub(crate) fn class_prototype_method_guard_slot(name: &str) -> u32 {
(super::super::key_bytes_hash(name.as_ptr(), name.len())
& CLASS_PROTOTYPE_METHOD_GUARD_SLOT_MASK) as u32
}

#[inline]
pub(crate) fn class_prototype_fast_guard_invalidated_for_method(slot: u32) -> bool {
if class_prototype_fast_guards_invalidated() {
return true;
}
let slot = (slot as usize) & (CLASS_PROTOTYPE_METHOD_GUARD_SLOT_COUNT - 1);
#[cfg(not(test))]
{
PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED_BY_METHOD[slot]
.load(std::sync::atomic::Ordering::Acquire)
!= 0
}
#[cfg(test)]
{
CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED_BY_METHOD
.read()
.unwrap()
.contains(&(slot as u16))
}
}

#[inline]
fn retire_prototype_dependent_caches() {
// #7480: prototype surgery retires element-shape proofs.
crate::array::invalidate_all_element_shapes();
// #7769: method-dispatch caches are keyed by VTABLE_GEN.
VTABLE_GEN.fetch_add(1, std::sync::atomic::Ordering::Release);
}

pub(crate) fn invalidate_class_prototype_fast_guards_for_method(name: &str) {
let slot = class_prototype_method_guard_slot(name) as usize;
#[cfg(not(test))]
PERRY_CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED_BY_METHOD[slot]
.store(1, std::sync::atomic::Ordering::Release);
#[cfg(test)]
CLASS_PROTOTYPE_FAST_GUARDS_INVALIDATED_BY_METHOD
.write()
.unwrap()
.insert(slot as u16);
retire_prototype_dependent_caches();
}

#[allow(dead_code)] // Fail-closed escape hatch for a future keyless mutation path.
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
// reliable description of its instances. This is the existing single
// latch all three prototype-write entry points funnel through
// (`js_register_prototype_method`, `class_prototype_method_root_store`,
// and the class-registry state path), so one generation bump here retires
// every outstanding record at O(1).
crate::array::invalidate_all_element_shapes();
// #7769: prototype surgery can change which member a `recv.m()` resolves
// to, and the method-dispatch caches (`vtable_ic`, `obj_dispatch_ic`) key
// their entries on `VTABLE_GEN`. Those caches were only retired by class
// REGISTRATION, so a `Class.prototype.m = fn` after first dispatch left
// them serving the pre-surgery answer. Retire them here, at the one latch
// all three prototype-write entry points funnel through — the same O(1)
// argument as the element-shape invalidation above.
VTABLE_GEN.fetch_add(1, std::sync::atomic::Ordering::Release);
// Unknown-key prototype surgery cannot use a scoped slot. Retire every
// direct-method guard, then perform the common cache invalidations.
retire_prototype_dependent_caches();
}

pub(crate) fn class_prototype_method_root_store(class_id: u32, name: String, value_bits: u64) {
// Assignment after `delete C.prototype.m` creates the property again.
class_unmark_key_deleted(class_id, &name);
CLASS_PROTOTYPE_METHODS.with(|table| {
let mut guard = table.write().unwrap();
if guard.is_none() {
Expand All @@ -163,7 +218,7 @@ pub(crate) fn class_prototype_method_root_store(class_id: u32, name: String, val
.or_default()
.insert(name.clone(), value_bits);
});
invalidate_class_prototype_fast_guards();
invalidate_class_prototype_fast_guards_for_method(&name);
crate::gc::runtime_write_barrier_root_nanbox(value_bits);
// #5024: the side table makes the method dispatchable, but own-key
// enumeration on the prototype OBJECT (Object.keys / getOwnPropertyNames /
Expand Down Expand Up @@ -250,7 +305,6 @@ pub unsafe extern "C" fn js_register_prototype_method(
name_len: usize,
value: f64,
) {
invalidate_class_prototype_fast_guards();
if class_id == 0 || name_ptr.is_null() || name_len == 0 {
return;
}
Expand Down
Loading
Loading