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/10141-class-prototype-index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
category: Performance
title: Index class prototype objects during startup
---

Class prototype membership checks now use an exact address index that is kept
in sync across registry updates and copying collections. This removes repeated
full-registry scans from property definition while large native module graphs
initialize.
6 changes: 6 additions & 0 deletions crates/perry-runtime/src/gc/tests/copying_side_tables.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ fn test_copying_minor_rewrites_class_side_table_values_and_function_keys() {
assert!(crate::arena::pointer_in_nursery(value_after));
assert_ne!(prototype_object_after, prototype_object);
assert!(crate::arena::pointer_in_nursery(prototype_object_after));
assert!(crate::object::is_registered_class_prototype_object(
prototype_object_after
));
assert!(!crate::object::is_registered_class_prototype_object(
prototype_object
));
assert_ne!(decl_prototype_object_after, decl_prototype_object);
assert!(crate::arena::pointer_in_nursery(
decl_prototype_object_after
Expand Down
18 changes: 10 additions & 8 deletions crates/perry-runtime/src/object/class_gc_roots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,11 @@ pub fn scan_class_inheritance_roots_mut(visitor: &mut crate::gc::RuntimeRootVisi
if let Ok(mut guard) = table.write() {
if let Some(map) = guard.as_mut() {
for ptr in map.values_mut() {
let old_ptr = *ptr;
visitor.visit_usize_slot(ptr);
// Evacuation moves a prototype to an address the filter has
// never seen. Admit what the visitor left behind, under the
// same write guard, so the address is admitted before any
// reader can find it.
crate::object::class_registry::note_class_prototype_object_registered(*ptr);
crate::object::class_registry::class_prototype_object_addr_index_rekey(
old_ptr, *ptr,
);
}
}
}
Expand Down Expand Up @@ -79,13 +78,16 @@ pub fn scan_class_inheritance_roots_mut(visitor: &mut crate::gc::RuntimeRootVisi
#[cfg(test)]
pub(crate) fn test_seed_class_inheritance_roots(proto_cid: u32, proto_ptr: usize) {
// GC_STORE_AUDIT(ROOT): test seed mirrors CLASS_PROTOTYPE_OBJECTS values scanned by scan_class_inheritance_roots_mut.
crate::object::class_registry::note_class_prototype_object_registered(proto_ptr);
CLASS_PROTOTYPE_OBJECTS.with(|table| {
let old = CLASS_PROTOTYPE_OBJECTS.with(|table| {
let mut guard = table.write().unwrap();
guard
.get_or_insert_with(std::collections::HashMap::new)
.insert(proto_cid, proto_ptr);
.insert(proto_cid, proto_ptr)
});
crate::object::class_registry::class_prototype_object_addr_index_rekey(
old.unwrap_or(0),
proto_ptr,
);
}

#[cfg(test)]
Expand Down
7 changes: 3 additions & 4 deletions crates/perry-runtime/src/object/class_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,14 @@ pub(crate) use state::{
class_own_static_field_value, class_own_string_member_names, 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_addr_index_contains, class_prototype_object_addr_index_rekey,
class_prototype_object_root_store, class_ref_dynamic_prop_root_store,
class_register_declared_static_global_slot, class_static_defined_attrs, class_static_prototype,
class_static_prototype_is_nulled, class_static_prototype_root_clear,
class_static_prototype_root_store, class_static_set_defined_attrs, class_unmark_key_deleted,
global_object_prototype_bits, is_bound_native_constructor_closure_value,
is_non_constructable_builtin_function_value, note_class_prototype_object_registered,
parent_closure_in_chain, throw_non_constructable_builtin_function, CLASS_PROTOTYPE_ADDR_FILTER,
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 @@ -194,8 +195,6 @@ pub(crate) use dispatch::{
};

// ── parent_static.rs ────────────────────────────────────────────────────────
#[cfg(test)]
pub(crate) use parent_static::test_class_prototype_scan_count;
pub(crate) use parent_static::{
call_private_static_method_for_owner, call_registered_static_method, call_static_method,
class_chain_has_instance_accessor, class_dynamic_static_accessor_descriptor,
Expand Down
10 changes: 5 additions & 5 deletions crates/perry-runtime/src/object/class_registry/gc_roots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,9 @@ pub fn scan_class_side_table_roots_mut(visitor: &mut crate::gc::RuntimeRootVisit
if let Ok(mut guard) = table.write() {
if let Some(map) = guard.as_mut() {
for proto_addr in map.values_mut() {
let old_addr = *proto_addr;
visitor.visit_usize_slot(proto_addr);
// See the twin in `class_gc_roots::scan_class_inheritance_roots_mut`.
super::note_class_prototype_object_registered(*proto_addr);
super::class_prototype_object_addr_index_rekey(old_addr, *proto_addr);
}
}
}
Expand Down Expand Up @@ -420,10 +420,9 @@ fn scan_class_side_table_root_slot(
CLASS_PROTOTYPE_OBJECTS.with(|table| {
if let Ok(mut guard) = table.write() {
if let Some(proto_addr) = guard.as_mut().and_then(|map| map.get_mut(class_id)) {
let old_addr = *proto_addr;
visitor.visit_usize_slot(proto_addr);
// The per-slot GC step moves one prototype at a time;
// it carries the same obligation as the bulk scanner.
super::note_class_prototype_object_registered(*proto_addr);
super::class_prototype_object_addr_index_rekey(old_addr, *proto_addr);
}
}
});
Expand Down Expand Up @@ -683,6 +682,7 @@ pub(crate) fn test_clear_class_side_table_roots() {
*guard = None;
}
});
super::state::CLASS_PROTOTYPE_ADDR_COUNTS.with(|index| index.borrow_mut().clear());
CLASS_DECL_PROTOTYPE_OBJECTS.with(|table| {
if let Ok(mut guard) = table.write() {
*guard = None;
Expand Down
61 changes: 1 addition & 60 deletions crates/perry-runtime/src/object/class_registry/parent_static.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1851,66 +1851,7 @@ pub fn is_registered_class_prototype_object(ptr: usize) -> bool {
if crate::value::addr_class::is_handle_band(ptr) {
return false;
}
// An address no registration ever admitted cannot be in the map, so reject
// it here — inline, and in particular before the `map.values().any(…)`
// linear scan below, which is what this probe actually costs (#9225).
// 99.05% of the calls on `claude-code --help` end here.
if !crate::object::class_registry::CLASS_PROTOTYPE_ADDR_FILTER.may_contain(ptr) {
// Machine-check the writer set rather than enumerate it. The filter is
// sound only if EVERY route that stores an address into
// `CLASS_PROTOTYPE_OBJECTS` admits it first — the insert, both GC root
// scanners, the per-slot GC step and the test seeds — and a route added
// without admitting would not crash: it would silently report a live
// prototype as "not a prototype". In a debug build every rejection is
// therefore re-derived from the map itself, which turns that into a
// panic in the first test that exercises the route. Compiled out
// entirely in release.
#[cfg(debug_assertions)]
{
// `try_read`, not `read`, for the reason the symbol twin gives:
// the rejection path never took this lock before, so a blocking
// audit could hang on a caller the audited code would not have.
let present = CLASS_PROTOTYPE_OBJECTS.with(|table| {
table.try_read().is_ok_and(|guard| {
guard
.as_ref()
.is_some_and(|map| map.values().any(|&p| p == ptr))
})
});
assert!(
!present,
"CLASS_PROTOTYPE_ADDR_FILTER rejected {ptr:#x}, but it IS a \
registered class prototype. Some route stored it into \
CLASS_PROTOTYPE_OBJECTS without calling \
`note_class_prototype_object_registered` first."
);
}
return false;
}
#[cfg(test)]
TEST_CLASS_PROTOTYPE_SCANS.with(|c| c.set(c.get().wrapping_add(1)));
CLASS_PROTOTYPE_OBJECTS.with(|table| {
if let Ok(guard) = table.read() {
if let Some(map) = guard.as_ref() {
return map.values().any(|&p| p == ptr);
}
}
false
})
}

#[cfg(test)]
thread_local! {
/// Test-only count of `is_registered_class_prototype_object` calls that got
/// past `CLASS_PROTOTYPE_ADDR_FILTER` and reached the linear scan. The filter
/// is a fast path, and a fast path nobody can prove ran is not a fast path
/// (same contract as `buffer::header::TEST_BUFFER_REGISTRY_PROBES`).
static TEST_CLASS_PROTOTYPE_SCANS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}

#[cfg(test)]
pub(crate) fn test_class_prototype_scan_count() -> u64 {
TEST_CLASS_PROTOTYPE_SCANS.with(|c| c.get())
crate::object::class_registry::class_prototype_object_addr_index_contains(ptr)
}

/// Walk the prototype chain of `class_id` and return the id of the class that
Expand Down
91 changes: 39 additions & 52 deletions crates/perry-runtime/src/object/class_registry/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ crate::perry_thread_local! {
/// stable global address lets the runtime keep both views coherent.
pub(super) static CLASS_DECLARED_STATIC_GLOBAL_SLOTS: std::cell::RefCell<HashMap<u32, HashMap<String, usize>>> =
std::cell::RefCell::new(HashMap::new());
/// Exact inverse membership index for `CLASS_PROTOTYPE_OBJECTS`.
///
/// Several class ids may deliberately share one prototype object, so the
/// value is a reference count rather than a set bit. Keeping this beside
/// the forward map turns the hot "is this any class's prototype?" probe
/// from a linear value scan into one pointer-hash lookup. The former
/// monotone address filter rejected small graphs cheaply but saturated on
/// class-heavy module graphs and fell back to O(classes).
pub(super) static CLASS_PROTOTYPE_ADDR_COUNTS: std::cell::RefCell<crate::fast_hash::PtrHashMap<usize, u32>> =
std::cell::RefCell::new(crate::fast_hash::new_ptr_hash_map());
}

pub(crate) fn is_non_constructable_builtin_function_value(value: f64) -> bool {
Expand Down Expand Up @@ -414,54 +424,33 @@ crate::perry_thread_local! {
pub static CLASS_PROTOTYPE_OBJECTS: RwLock<Option<HashMap<u32, usize>>> = RwLock::new(None);
}

/// Monotone address filter over the values of [`CLASS_PROTOTYPE_OBJECTS`].
///
/// `is_registered_class_prototype_object` answers "is this heap object some
/// class's registered prototype?" with `map.values().any(…)` — a LINEAR SCAN,
/// #9225 — and the caller that asks it is
/// `descriptor_state::disable_inline_guards_for_descriptor_target`, which runs
/// on every `Object.defineProperty`. esbuild's `__export(exports, { … })` makes
/// that thousands of calls per bundle: on `claude-code --help` the probe is
/// called 26,290 times, answers `true` **122** times, and costs **0.46%** of
/// the run — roughly 1,200 instructions per call, which is the scan.
///
/// A monotone `[lo, hi]` window cannot help here: prototypes are ordinary
/// GC-heap objects interleaved with everything else, and replaying the real
/// argument stream against the window the registrations build rejects only
/// 54.0%. The same replay against this filter rejects **99.05%** (26,041 of
/// 26,290; 122 genuine `true` answers preserved, 127 false positives), and it
/// rejects them before the thread-local resolution, the `RwLock` and the scan.
///
/// Rejecting is sound because every route that puts an address into the map
/// admits it here first — see [`note_class_prototype_object_registered`] — and
/// removals never clear bits, which only makes the filter weaker, never wrong.
/// The completeness of that writer set is machine-checked rather than
/// enumerated: see the probe.
///
/// This does not close #9225. A false positive still pays the scan, so the
/// table's O(n) slope survives at ~1% of its strength; the O(1) inverse index
/// #9225 asks for is still the right structural fix, and this filter sits in
/// front of it either way.
pub(crate) static CLASS_PROTOTYPE_ADDR_FILTER: crate::registry_latch::RegistryAddrFilter =
crate::registry_latch::RegistryAddrFilter::new();

/// Admit `addr` into [`CLASS_PROTOTYPE_ADDR_FILTER`].
///
/// EVERY route that stores an address into [`CLASS_PROTOTYPE_OBJECTS`] must
/// call this **before** the address becomes findable — the insert below, the
/// two GC root scanners and the per-slot GC step (all of which rewrite stored
/// addresses through `visit_usize_slot`), and the test seeds. A route that
/// forgets does not crash: the probe reports a live prototype as "not a
/// prototype", so `getOwnPropertyDescriptor(C.prototype, …)` and the
/// descriptor-guard disable silently change behaviour. The debug-build audit in
/// the probe exists to turn that into a test failure.
///
/// The GC scanners admit AFTER the visitor rewrites the slot, which is still
/// "before it is findable": they hold the table's write guard across both, so
/// no reader can observe the new address until the guard drops.
#[inline]
pub(crate) fn note_class_prototype_object_registered(addr: usize) {
CLASS_PROTOTYPE_ADDR_FILTER.admit(addr);
pub(crate) fn class_prototype_object_addr_index_contains(addr: usize) -> bool {
CLASS_PROTOTYPE_ADDR_COUNTS.with(|index| index.borrow().contains_key(&addr))
}

/// Apply one forward-map value change to the exact inverse membership index.
/// GC root visitors call this after rewriting a prototype address; ordinary
/// stores call it after replacing a class id's prototype.
pub(crate) fn class_prototype_object_addr_index_rekey(old: usize, new: usize) {
if old == new {
return;
}
CLASS_PROTOTYPE_ADDR_COUNTS.with(|index| {
let mut index = index.borrow_mut();
if old != 0 {
if let Some(count) = index.get_mut(&old) {
if *count == 1 {
index.remove(&old);
} else {
*count -= 1;
}
}
}
if new != 0 {
*index.entry(new).or_insert(0) += 1;
}
});
}

crate::perry_thread_local! {
Expand Down Expand Up @@ -636,16 +625,14 @@ pub(crate) fn class_prototype_object_root_store(class_id: u32, proto_ptr: *mut O
if class_id == 0 || proto_ptr.is_null() {
return;
}
// Admit before the insert, so the address is never in the map while the
// filter still rejects it. See `note_class_prototype_object_registered`.
note_class_prototype_object_registered(proto_ptr as usize);
CLASS_PROTOTYPE_OBJECTS.with(|table| {
let old = CLASS_PROTOTYPE_OBJECTS.with(|table| {
let mut guard = table.write().unwrap();
if guard.is_none() {
*guard = Some(HashMap::new());
}
guard.as_mut().unwrap().insert(class_id, proto_ptr as usize);
guard.as_mut().unwrap().insert(class_id, proto_ptr as usize)
});
class_prototype_object_addr_index_rekey(old.unwrap_or(0), proto_ptr as usize);
crate::gc::runtime_write_barrier_root_raw_ptr(proto_ptr);
}

Expand Down
11 changes: 6 additions & 5 deletions crates/perry-runtime/src/object/descriptor_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -333,15 +333,16 @@ pub(crate) fn test_reset_class_field_inline_guard() {
/// setup that runs during every program's startup, `Object.freeze` on a config
/// object, …) no longer disable the #5093 fast path process-wide.
///
/// The prototype-registry probes scan by value (O(#classes)). This comment
/// used to add "descriptor installs are rare and never on the hot property
/// path, so the scan cost is acceptable", and that is false for every bundle:
/// The prototype-registry probe used to scan by value (O(#classes)). This
/// comment used to add "descriptor installs are rare and never on the hot
/// property path, so the scan cost is acceptable", and that is false for every bundle:
/// esbuild's `__export(exports, { … })` makes `Object.defineProperty` a
/// module-init primitive — claude-code's bundle contains 1,526 of them — so
/// this function runs 26,290 times on `claude --help` and
/// `is_registered_class_prototype_object`'s scan alone was 0.46% of the run.
/// It is now fronted by `CLASS_PROTOTYPE_ADDR_FILTER`, which rejects 99.05% of
/// those calls before the scan; the O(#classes) slope itself is #9225.
/// It is now served by the exact inverse prototype-address index, including GC
/// rekeys, so each negative probe stays O(1) even when a class-heavy graph
/// saturates the older monotone address filter (#9225, #10106).
///
/// #6759 C5a — per-KEY refinement (the follow-up the paragraph above used to
/// promise): the inline fast path only ever compiles accesses to DECLARED
Expand Down
39 changes: 19 additions & 20 deletions crates/perry-runtime/src/registry_latch_probes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -378,40 +378,39 @@ fn symbol_probe_rejects_a_filtered_address_without_touching_the_registry() {
);
}

/// The class-prototype address filter. The probe behind it is a LINEAR SCAN
/// (#9225) reached through a thread-local and an `RwLock`, and its one caller —
/// `descriptor_state::disable_inline_guards_for_descriptor_target` — runs on
/// every `Object.defineProperty`, so the rejection is what keeps a bundle's
/// `__export(exports, { … })` init off the scan entirely.
/// The class-prototype probe uses an exact inverse address index. Keep both
/// positive and negative membership covered: class-heavy bundles saturate the
/// older monotone filter, while this index must remain O(1) and exact (#9225).
#[test]
fn class_prototype_probe_rejects_a_filtered_address_without_scanning() {
fn class_prototype_probe_uses_exact_inverse_membership() {
use crate::object as class_registry;

// A registered prototype, seeded through the real store so the filter is
// admitted exactly as production admits it.
// A registered prototype, seeded through the real store so the inverse
// index is updated exactly as production updates it.
let proto = crate::object::js_object_alloc(0, 2) as usize;
assert!(proto != 0, "test premise: the prototype object allocated");
class_registry::test_seed_class_prototype_object_root(0x7f00_0001, proto);

let before = class_registry::test_class_prototype_scan_count();
assert!(
!class_registry::is_registered_class_prototype_object(FAR_OUTSIDE_ANY_WINDOW),
"an address no registration admitted is not a class prototype"
);
assert_eq!(
class_registry::test_class_prototype_scan_count(),
before,
"the address filter must answer without reaching the scan"
);

assert!(
class_registry::is_registered_class_prototype_object(proto),
"the filter must not hide a registered class prototype"
);
assert!(
class_registry::test_class_prototype_scan_count() > before,
"a filter-admitted address must reach the scan"
"the inverse index must find a registered class prototype"
);

// Two class ids can share one prototype. Replacing one must retain the
// old address until the last forward-map reference moves away.
let replacement = crate::object::js_object_alloc(0, 2) as usize;
class_registry::test_seed_class_prototype_object_root(0x7f00_0002, proto);
class_registry::test_seed_class_prototype_object_root(0x7f00_0001, replacement);
assert!(class_registry::is_registered_class_prototype_object(proto));
class_registry::test_seed_class_prototype_object_root(0x7f00_0002, replacement);
assert!(!class_registry::is_registered_class_prototype_object(proto));
assert!(class_registry::is_registered_class_prototype_object(
replacement
));
}

/// `alloc_shared_sab` publishes a backing that `is_registered_buffer` reports
Expand Down
Loading