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
6 changes: 6 additions & 0 deletions changelog.d/11098-spill-layout-notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
### Performance

Named-property writes to object spill slots now skip the full GC layout update
when an overwrite keeps the same pointer kind. Scalar-to-pointer and
pointer-to-scalar transitions still update the slot mask, while repeated
numeric and pointer writes avoid the redundant bookkeeping.
11 changes: 5 additions & 6 deletions crates/perry-runtime/src/gc/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,8 @@ unsafe fn layout_all_pointer_array_append(
}

pub(crate) fn layout_note_slot(parent_user: usize, slot_index: usize, value_bits: u64) {
#[cfg(test)]
crate::object::TEST_LAYOUT_NOTE_SLOT_CALLS.with(|calls| calls.set(calls.get() + 1));
if slot_index > 16_000_000 {
return;
}
Expand Down Expand Up @@ -1056,15 +1058,12 @@ pub(crate) fn layout_note_slot(parent_user: usize, slot_index: usize, value_bits
}

/// Existing-slot layout note with the value that was overwritten.
///
/// The GC slot mask records one bit of information: whether a slot can carry a
/// heap edge. Replacing one pointer-bearing value with another cannot change
/// that bit. Arrays still have a second, independent invariant to maintain —
/// their homogeneous element-shape record — so the pointer-over-pointer path
/// The GC slot mask records whether a slot can carry a heap edge. Replacing one
/// pointer-bearing value with another leaves that bit unchanged. Arrays also
/// maintain a homogeneous element-shape record, so the pointer-over-pointer path
/// runs that hook after validating/chasing the owner header and then stops
/// before the typed-layout and per-slot-mask machinery. Object-backed packed
/// numeric proofs are retired for the same reason as in [`layout_note_slot`].
///
/// Scalar-over-scalar keeps the historical fast return. A change in either
/// direction uses the complete note so pointer masks and typed descriptors are
/// updated exactly as before.
Expand Down
4 changes: 3 additions & 1 deletion crates/perry-runtime/src/object/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,9 @@ pub(crate) use spill::{
#[cfg(test)]
use spill::{spill_capable_owner, spill_get, SPILL_MAX_FIELD_INDEX};
#[cfg(test)]
pub(crate) use spill::{test_set_spill_safepoint_hook, SpillSafepointHook};
pub(crate) use spill::{
test_set_spill_safepoint_hook, SpillSafepointHook, TEST_LAYOUT_NOTE_SLOT_CALLS,
};
mod string_proto_thunks;
#[cfg(feature = "temporal")]
mod temporal_proto;
Expand Down
77 changes: 76 additions & 1 deletion crates/perry-runtime/src/object/spill.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@

use super::*;

#[cfg(test)]
thread_local! {
pub(crate) static TEST_LAYOUT_NOTE_SLOT_CALLS: Cell<usize> = const { Cell::new(0) };
}

// Last-accessed overflow Vec cache — one entry, keyed by `obj_ptr`.
// Skips the outer HashMap lookup on consecutive writes to the same
// object (exactly the row-build pattern: a single object gets its
Expand Down Expand Up @@ -64,6 +69,7 @@ unsafe fn spill_elements(spill: *const crate::array::ArrayHeader) -> *mut u64 {
#[inline]
unsafe fn spill_store_slot(spill: *mut crate::array::ArrayHeader, index: usize, vbits: u64) {
let slot = spill_elements(spill).add(index);
let old_bits = *slot;
*slot = vbits;
// Length is the buffer's high-water mark: `js_array_alloc_with_length`
// sets length = REQUESTED capacity while the physical capacity rounds up
Expand All @@ -75,7 +81,12 @@ unsafe fn spill_store_slot(spill: *mut crate::array::ArrayHeader, index: usize,
if index >= (*spill).length as usize {
(*spill).length = (index + 1) as u32;
}
crate::gc::layout_note_slot(spill as usize, index, vbits);
// Spill elements are boxed JS values just like ordinary array slots. The
// old value is already in hand, so preserve the same overwrite invariant
// as array stores: scalar -> scalar and pointer -> pointer cannot change
// the GC slot mask and need no full layout note. Transitions in either
// direction still take the complete path below.
crate::gc::layout_note_slot_aware(spill as usize, index, vbits, old_bits);
crate::gc::runtime_write_barrier_slot(spill as usize, slot as usize, vbits);
}

Expand Down Expand Up @@ -564,4 +575,68 @@ mod tests {
"a real instance overflow must still teach the class high-water mark"
);
}

#[test]
fn spill_overwrites_only_note_pointer_kind_transitions() {
let _lock = crate::gc::global_side_table_test_lock();
let _trigger_guard = crate::gc::GcTriggerThresholdTestGuard::suppress_automatic_triggers();
let owner = js_object_alloc(0x6B45_5A13, 0);
let slot = 18;

TEST_LAYOUT_NOTE_SLOT_CALLS.with(|calls| calls.set(0));
spill_set(owner as usize, slot, 1.0f64.to_bits());
assert_eq!(TEST_LAYOUT_NOTE_SLOT_CALLS.with(Cell::get), 0);

TEST_LAYOUT_NOTE_SLOT_CALLS.with(|calls| calls.set(0));
spill_set(owner as usize, slot, 2.0f64.to_bits());
assert_eq!(
TEST_LAYOUT_NOTE_SLOT_CALLS.with(Cell::get),
0,
"a scalar overwrite must not enter the full layout hook"
);

let child_a = js_object_alloc(0x6B45_5A14, 0);
let child_a_bits =
crate::value::POINTER_TAG | (child_a as u64 & crate::value::POINTER_MASK);
TEST_LAYOUT_NOTE_SLOT_CALLS.with(|calls| calls.set(0));
spill_set(owner as usize, slot, child_a_bits);
assert_eq!(
TEST_LAYOUT_NOTE_SLOT_CALLS.with(Cell::get),
1,
"a scalar-to-pointer transition must update the slot layout"
);

let spill = crate::object::test_spill_buffer_addr(owner as usize);
assert_eq!(
crate::gc::test_layout_pointer_slot_count(spill, slot + 1),
Some(1)
);

let child_b = js_object_alloc(0x6B45_5A15, 0);
let child_b_bits =
crate::value::POINTER_TAG | (child_b as u64 & crate::value::POINTER_MASK);
TEST_LAYOUT_NOTE_SLOT_CALLS.with(|calls| calls.set(0));
spill_set(owner as usize, slot, child_b_bits);
assert_eq!(
TEST_LAYOUT_NOTE_SLOT_CALLS.with(Cell::get),
0,
"a pointer overwrite must preserve the existing mask bit"
);
assert_eq!(
crate::gc::test_layout_pointer_slot_count(spill, slot + 1),
Some(1)
);

TEST_LAYOUT_NOTE_SLOT_CALLS.with(|calls| calls.set(0));
spill_set(owner as usize, slot, 3.0f64.to_bits());
assert_eq!(
TEST_LAYOUT_NOTE_SLOT_CALLS.with(Cell::get),
1,
"a pointer-to-scalar transition must clear the slot layout"
);
assert_eq!(
crate::gc::test_layout_pointer_slot_count(spill, slot + 1),
Some(0)
);
}
}
6 changes: 6 additions & 0 deletions scripts/gc_runtime_root_holders.json
Original file line number Diff line number Diff line change
Expand Up @@ -953,6 +953,12 @@
"verdict": "not_a_gc_pointer",
"why": "View-mode diagnostic tally of install-time RegExp.prototype.test walks. A plain AtomicU64 incremented once by record_canonical_test_site and read as a count by tests; it never stores an address or NaN-boxed value. The actual prototype and closure roots live together in REGEXP_PROTOTYPE_TEST_SITE and are visited by scan_canonical_test_site_roots_mut."
},
{
"file": "crates/perry-runtime/src/object/spill.rs",
"name": "TEST_LAYOUT_NOTE_SLOT_CALLS",
"verdict": "test_only",
"why": "#[cfg(test)] Cell<usize> counter for asserting spill overwrites skip the full layout-note path when the pointer kind is unchanged. It stores only a call count and is absent from shipped binaries."
},
{
"file": "crates/perry-runtime/src/object/shapes.rs",
"name": "SHAPE_YOUNG_LOG_SUPPRESSED",
Expand Down
Loading