Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
perf(runtime): #6759 Phase C2 — per-key descriptor-presence summaries move into the per-object `ObjectMeta` record (two monotonic Bloom words). `get_property_attrs`/`get_accessor_descriptor` prove most misses in two loads (no `String` build, no table probe), the `Object.keys` fast path drops its O(table) owner scan, and `plain_data_write_may_intercept` refines the per-object descriptor flag per-key (an object with one `defineProperty`'d key keeps the dynamic-write fast path for its other keys). Also shields fresh objects at recycled addresses from dead owners' not-yet-pruned descriptor entries, and adds a nesting-safe `GcSuppressScope` no-move window around the meta allocation.
39 changes: 39 additions & 0 deletions crates/perry-runtime/src/gc/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,45 @@ pub fn gc_unsuppress() {
GC_FLAGS.with(|f| f.set(f.get() & !GC_FLAG_SUPPRESSED));
}

/// True while GC triggers are suppressed (see [`gc_suppress`]).
pub(crate) fn gc_is_suppressed() -> bool {
GC_FLAGS.with(|f| f.get()) & GC_FLAG_SUPPRESSED != 0
}

/// #6759 Phase C2: RAII no-move window for a TINY allocation. While alive,
/// every GC trigger is blocked (`GC_FLAG_SUPPRESSED` gates `gc_check_trigger`,
/// the budgeted stepper, and the safepoint moving minor), so no heap object
/// can move and raw pointers held anywhere up the caller stack stay valid
/// across the allocation.
///
/// Unlike [`gc_suppress`] this sets ONLY the flag: it skips the fresh-block /
/// pre-suppress-bytes bookkeeping that exists for JSON.parse's multi-megabyte
/// suppressed windows, because the intended use is a few dozen bytes (e.g. an
/// `ObjectMeta` record) where that accounting is pure overhead. Nesting-safe:
/// the previous suppression state is restored on drop, so a scope opened
/// inside an outer `gc_suppress` window does not unsuppress it early.
pub(crate) struct GcSuppressScope {
was_suppressed: bool,
}

impl GcSuppressScope {
pub(crate) fn new() -> Self {
let was_suppressed = gc_is_suppressed();
if !was_suppressed {
GC_FLAGS.with(|f| f.set(f.get() | GC_FLAG_SUPPRESSED));
}
GcSuppressScope { was_suppressed }
}
}

impl Drop for GcSuppressScope {
fn drop(&mut self) {
if !self.was_suppressed {
GC_FLAGS.with(|f| f.set(f.get() & !GC_FLAG_SUPPRESSED));
}
}
}

/// Rebaseline the malloc-count AND arena-bytes triggers to the current
/// live set so that objects just created during a GC-suppressed window
/// (e.g. JSON.parse) don't immediately trip a collection on the next
Expand Down
130 changes: 130 additions & 0 deletions crates/perry-runtime/src/gc/tests/dead_owner_side_tables.rs
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,136 @@ fn test_object_meta_prototype_survives_copied_minor_move() {
js_shadow_slot_set(1, 0);
}

/// #6759 Phase C2: the per-key descriptor summary in the meta record gates
/// table probes — exactly (no false negatives) for installed keys, and
/// authoritatively negative for a fresh owner and for keys whose Bloom bit
/// is clear. Non-meta-capable owners (handle-band ids) stay on the
/// conservative probe-always arm, so their installs still round-trip.
#[test]
fn test_descriptor_meta_summary_gates_probes() {
// NOTE: the guard already takes the process-global side-table lock —
// taking `global_side_table_test_lock()` here too self-deadlocks.
let _guard = GcTestIsolationGuard::new();

unsafe {
let (owner, _) = alloc_nursery_test_object(0);
let addr = owner as usize;

// Fresh meta-capable owner: null meta is an authoritative miss.
assert!(
!crate::object::descriptor_state::may_have_descriptor_entry(addr, "x", false),
"fresh owner must report no possible attr entry"
);
assert!(
!crate::object::descriptor_state::may_have_descriptor_entry(addr, "x", true),
"fresh owner must report no possible accessor entry"
);
assert!(
!crate::object::owner_may_have_descriptor_entries(addr, false),
"fresh owner must report no possible entries at all"
);

crate::object::set_property_attrs(
addr,
"x".to_string(),
crate::object::PropertyAttrs::new(false, true, true),
);
assert!(
crate::object::descriptor_state::may_have_descriptor_entry(addr, "x", false),
"installed key's bit must be set"
);
assert!(
crate::object::get_property_attrs(addr, "x").is_some_and(|a| !a.writable()),
"gated getter must still return the installed attrs"
);
assert!(
crate::object::get_property_attrs(addr, "unrelated").is_none(),
"un-installed key must miss through the gate"
);
// Exact negative when the bits don't collide; a collision only
// costs a (missing) probe, which the getter assertion above covers.
let x_bit = crate::object::descriptor_state::test_descriptor_key_bit("x");
let other_bit = crate::object::descriptor_state::test_descriptor_key_bit("unrelated");
if x_bit != other_bit {
assert!(
!crate::object::descriptor_state::may_have_descriptor_entry(
addr,
"unrelated",
false
),
"non-colliding un-installed key must be a summary miss"
);
}
// The attr install must not set the ACCESSOR word.
if x_bit != 0 {
assert!(
!crate::object::descriptor_state::may_have_descriptor_entry(addr, "x", true),
"attr install must not claim a possible accessor entry"
);
}

// Handle-band owner (no GC header): conservative arm, still works.
let handle = 0x400usize;
assert!(
crate::object::descriptor_state::may_have_descriptor_entry(handle, "x", false),
"non-meta-capable owner must stay conservative"
);
crate::object::set_property_attrs(
handle,
"h".to_string(),
crate::object::PropertyAttrs::new(false, true, true),
);
assert!(
crate::object::get_property_attrs(handle, "h").is_some_and(|a| !a.writable()),
"handle-band install must round-trip via the conservative arm"
);
crate::object::clear_property_attrs(handle, "h");
}
}

/// #6759 Phase C2: the summary bits live in the meta record, which moves
/// WITH its owner on a copied minor while `scan_descriptor_roots_mut`
/// rekeys the table entry to the owner's new address — the gated getter
/// must still resolve the entry at the moved address.
#[test]
fn test_descriptor_meta_summary_survives_copied_minor_move() {
let _guard = CopyingNurseryTestGuard::new(2);
// The scoped registry starts empty — install the scanner that rekeys
// descriptor-table owner addresses on evacuation.
gc_register_mutable_root_scanner(crate::object::descriptor_state::scan_descriptor_roots_mut);

let (owner, _) = unsafe { alloc_nursery_test_object(0) };
let old_addr = owner as usize;
crate::object::set_property_attrs(
old_addr,
"ro".to_string(),
crate::object::PropertyAttrs::new(false, true, false),
);
js_shadow_slot_set(0, ptr_bits(old_addr));

let _ = gc_collect_minor();

let new_addr = (js_shadow_slot_get(0) & POINTER_MASK) as usize;
assert_ne!(new_addr, old_addr, "test premise: the owner must move");
assert!(
crate::object::descriptor_state::may_have_descriptor_entry(new_addr, "ro", false),
"summary bits must travel with the moved owner's meta record"
);
let attrs = crate::object::get_property_attrs(new_addr, "ro")
.expect("rekeyed descriptor entry must resolve at the moved address");
assert!(
!attrs.writable() && !attrs.configurable(),
"moved owner must keep its installed attributes"
);
assert!(
crate::object::get_property_attrs(new_addr, "other").is_none(),
"un-installed key must still miss at the moved address"
);

crate::object::clear_property_attrs(new_addr, "ro");
js_shadow_slot_set(0, 0);
}

/// #6759 Phase B: the meta record is kept alive by its owner (the header
/// edge is a traced child slot) across a full non-moving collection, and an
/// explicit-null recording is preserved.
Expand Down
Loading
Loading