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
7 changes: 6 additions & 1 deletion crates/perry-runtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,12 @@ perry-diagnostics = { path = "../perry-diagnostics", optional = true }
# our code is binding glue per type. `compiled_data` vendors the IANA tz DB
# hermetically (needed by ZonedDateTime / Now), `sys-local` adds the
# current-system-zone lookup used by Temporal.Now.*ISO() with no argument.
temporal_rs = { version = "0.2.3", default-features = false, features = ["std", "compiled_data", "sys-local"], optional = true }
# NOTE: NO `sys-local` — that feature resolves the system time zone via
# `iana_time_zone`, which links CoreFoundation on macOS and (because Temporal's
# namespace is always registered) forced `-framework CoreFoundation` into every
# output binary. Perry supplies its own CF-free host system in `temporal::now`
# (clock via `SystemTime`, zone via `crate::date::host_time_zone_name`).
temporal_rs = { version = "0.2.3", default-features = false, features = ["std", "compiled_data"], optional = true }

serde.workspace = true
serde_json.workspace = true
Expand Down
219 changes: 201 additions & 18 deletions crates/perry-runtime/src/gc/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,15 @@ pub(crate) const GC_LAYOUT_ALL_POINTERS: u16 = 0x2000;
// canonical raw-f64 / pointer layout is known-valid — and cleared whenever that
// descriptor is removed. Every downgrade routes through `layout_set_typed_unknown`
// or the `layout_*` remove helpers below, all of which clear it, so the invariant
// intact bit set ⟹ TYPED_LAYOUTS holds this object's canonical descriptor
// holds at all times. The descriptor's raw-f64 mask is exactly the compile-time
// intact bit set ⟹ a canonical typed descriptor exists for this object,
// either per-object in `TYPED_LAYOUTS` OR (the #6893 common
// case) shared by shape in `SHAPE_LAYOUTS`, keyed by the
// object's `keys_array`
// holds at all times. (Before #6893 the descriptor was always the per-object
// `TYPED_LAYOUTS` entry; `shape_install_shared` now sets the bit while routing
// same-shape objects through the shared map, so the bit no longer implies a
// per-object entry — only that *some* descriptor is reachable.) The descriptor's
// raw-f64 mask is exactly the compile-time
// canonical mask codegen emits for the class, so combined with a class_id/
// keys_array match the codegen-inlined class-field shape guard can conclude
// "slot K is raw-f64" from this single bit — no cross-crate guard call, no
Expand Down Expand Up @@ -60,7 +67,7 @@ pub(super) fn clear_typed_layout_intact_for_user(user_ptr: usize) {
}
}

#[derive(Clone)]
#[derive(Clone, PartialEq, Eq)]
pub(super) enum LayoutSlotMask {
Inline(u64),
Heap(Vec<u64>),
Expand Down Expand Up @@ -275,7 +282,7 @@ impl LayoutSlotMask {
}
}

#[derive(Clone)]
#[derive(Clone, PartialEq, Eq)]
pub(super) struct TypedLayoutDescriptor {
pub(super) slot_count: usize,
pub(super) raw_f64_mask: LayoutSlotMask,
Expand All @@ -293,6 +300,139 @@ thread_local! {
pub(super) static TRACE_SLOT_READS: Cell<usize> = const { Cell::new(0) };
}

// #6893: SHAPE-keyed canonical typed layout. Replaces the per-OBJECT
// TYPED_LAYOUTS + LAYOUT_SLOT_MASKS storage for the common case where an
// object's live layout matches its shape (header `GC_OBJ_TYPED_LAYOUT_INTACT`).
// Keyed by the shared `keys_array` pointer — all same-shape objects share ONE
// canonical keys array ("shared keys_array IS a shape"), so this is O(shapes),
// not O(objects). Measured: object churn stores a per-object descriptor for
// every one of ~2M `{v,w}` objects (all identical) → ~392 MB; keying by the
// (single) shared keys_array collapses that to one entry (churn peak RSS
// 830→262 MB, behaviour-identical).
//
// Value `None` = AMBIGUOUS: two live layouts share the same key NAMES but
// different value TYPES (`{v:1,w:2}` vs `{v:"a",w:"b"}`); those objects fall
// back to the per-object maps. ACCELERATOR ONLY: a miss, a stale entry
// (keys_array relocated/recycled by a moving GC), an ambiguous shape, or a
// field-count mismatch all fall back to the per-object map and then the
// conservative scan — never a wrong descriptor (mirrors the ShapeTable trust
// model). Nothing to prune on object death (entries are per-shape, shared).
thread_local! {
static SHAPE_LAYOUTS: RefCell<crate::fast_hash::PtrHashMap<usize, Option<TypedLayoutDescriptor>>> =
RefCell::new(crate::fast_hash::new_ptr_hash_map());
}

fn shape_layout_keyed_enabled() -> bool {
use std::sync::OnceLock;
static E: OnceLock<bool> = OnceLock::new();
// Default ON; `PERRY_SHAPE_LAYOUT_KEYED=0` restores the per-object maps
// (A/B validation).
*E.get_or_init(|| {
std::env::var("PERRY_SHAPE_LAYOUT_KEYED")
.map(|v| v != "0")
.unwrap_or(true)
})
}

/// keys_array only exists on genuine shaped objects (`ObjectFields`). Arrays,
/// closures, RegExps etc. also flow through `layout_note_slot` /
/// `layout_visit_pointer_slots`, and reading `ObjectHeader::keys_array` off one
/// would interpret unrelated payload bytes as a pointer. Returns 0 for anything
/// that is not an ObjectFields object (⟹ callers skip the shared shape path).
#[inline]
unsafe fn object_keys_array_ptr(user_ptr: usize) -> usize {
if user_ptr < GC_HEADER_SIZE + 0x1000 {
return 0;
}
let header = header_from_user_ptr(user_ptr as *const u8);
if gc_type_layout_slot_kind((*header).obj_type) != GcLayoutSlotKind::ObjectFields {
return 0;
}
(*(user_ptr as *const crate::object::ObjectHeader)).keys_array as usize
}

/// The shared canonical descriptor for `user_ptr`'s shape, if shape-keying is
/// on, the object carries a keys_array, and the shape is unambiguous (`Some`).
#[inline]
unsafe fn shape_shared_descriptor(user_ptr: usize) -> Option<TypedLayoutDescriptor> {
if !shape_layout_keyed_enabled() {
return None;
}
let keys = object_keys_array_ptr(user_ptr);
if keys == 0 {
return None;
}
let desc = SHAPE_LAYOUTS.with(|m| m.borrow().get(&keys).and_then(|e| e.clone()))?;
// Defense-in-depth: the descriptor's `slot_count` is pinned to the owning
// object's `field_count` at install (`init_typed_shape_layout` rejects a
// mismatch). A differing current field_count means this object's shape is
// not the one the descriptor describes — e.g. a keys_array address reused by
// a shape with a different field count (moving-GC relocation before the new
// address is re-installed). Fall back (per-object → conservative).
let field_count = (*(user_ptr as *const crate::object::ObjectHeader)).field_count as usize;
if desc.slot_count != field_count {
return None;
}
Some(desc)
}

/// Trace-path helper: pointer mask for a SIDE_MASK object with no per-object
/// mask entry. Returns the shape's canonical pointer mask iff the object is
/// still INTACT (⟹ it was registered against the shared shape descriptor, not
/// a diverged per-object mask).
#[inline]
unsafe fn shape_shared_pointer_mask(
user_ptr: usize,
header: *const GcHeader,
) -> Option<LayoutSlotMask> {
if (*header)._reserved & GC_OBJ_TYPED_LAYOUT_INTACT == 0 {
return None;
}
shape_shared_descriptor(user_ptr).map(|d| d.pointer_mask)
}

/// Install `descriptor` as the canonical layout for `keys` and set the object's
/// header state (INTACT + POINTER_FREE/SIDE_MASK), WITHOUT any per-object map
/// entry. Returns `true` if the object now rides the shared shape descriptor;
/// `false` if the shape is ambiguous (caller falls back to per-object).
unsafe fn shape_install_shared(
keys: usize,
header: *mut GcHeader,
descriptor: &TypedLayoutDescriptor,
) -> bool {
let mut shared_ok = false;
SHAPE_LAYOUTS.with(|m| {
let mut m = m.borrow_mut();
match m.get(&keys) {
None => {
m.insert(keys, Some(descriptor.clone()));
shared_ok = true;
}
Some(Some(existing)) if existing == descriptor => {
shared_ok = true;
}
Some(Some(_)) => {
// Same keys, different layout ⟹ ambiguous. Poison the entry so
// future lookups (and any still-INTACT siblings) fall back.
m.insert(keys, None);
shared_ok = false;
}
Some(None) => {
shared_ok = false; // already ambiguous
}
}
});
if shared_ok {
header_set_typed_layout_intact(header);
if descriptor.pointer_mask.is_empty() {
set_layout_state(header, GC_LAYOUT_POINTER_FREE);
} else {
set_layout_state(header, GC_LAYOUT_SIDE_MASK);
}
}
shared_ok
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

pub(super) unsafe fn header_from_user_ptr(user_ptr: *const u8) -> *mut GcHeader {
(user_ptr as *mut u8).sub(GC_HEADER_SIZE) as *mut GcHeader
}
Expand Down Expand Up @@ -452,11 +592,12 @@ pub(crate) fn layout_clear_for_ptr(user_ptr: usize) {
}

/// True when `user_ptr`'s object currently has a canonical `TypedLayoutDescriptor`
/// installed in `TYPED_LAYOUTS`. Reads the O(1) `GC_OBJ_TYPED_LAYOUT_INTACT`
/// header bit instead of probing the thread-local map: the bit is maintained in
/// lock-step with every map insert/remove (intact set ⟺ descriptor present — see
/// the invariant documented on `GC_OBJ_TYPED_LAYOUT_INTACT`), so it answers the
/// same question without a per-call TLS hashmap touch. This is on the dynamic
/// — per-object in `TYPED_LAYOUTS` or (the #6893 common case) shared by shape in
/// `SHAPE_LAYOUTS`. Reads the O(1) `GC_OBJ_TYPED_LAYOUT_INTACT` header bit
/// instead of probing either map: the bit is maintained in lock-step with
/// descriptor install/removal (intact set ⟹ *some* descriptor is reachable —
/// see the invariant on `GC_OBJ_TYPED_LAYOUT_INTACT`), so it answers the same
/// question without a per-call TLS hashmap touch. This is on the dynamic
/// object-store hot path via `mark_object_dynamic_shape_unknown` (#5094).
pub(crate) fn layout_has_typed_descriptor(user_ptr: usize) -> bool {
layout_typed_intact_for_user(user_ptr)
Expand Down Expand Up @@ -495,16 +636,23 @@ pub(crate) fn layout_note_slot(parent_user: usize, slot_index: usize, value_bits
// The canonical typed-shape descriptor probe below is a thread-local
// hashmap lookup, paid on every field/element store. Gate it on the
// O(1) `GC_OBJ_TYPED_LAYOUT_INTACT` header bit: that bit is set and
// cleared in lock-step with every `TYPED_LAYOUTS` insert/remove (see the
// invariant documented on `GC_OBJ_TYPED_LAYOUT_INTACT`), so a clear bit
// proves the map has no entry for this object — the probe would return
// `None` and fall through to the pointer-mask path below. Skipping it
// removes the per-write TLS touch on the common dynamic-shape /
// pointer-free object and array store path (#5094). The inner `if let`
// cleared in lock-step with descriptor install/removal (per-object in
// `TYPED_LAYOUTS` or, since #6893, shared by shape in `SHAPE_LAYOUTS` —
// see the invariant on `GC_OBJ_TYPED_LAYOUT_INTACT`), so a clear bit
// proves neither map has a descriptor for this object — the probe would
// return `None` and fall through to the pointer-mask path below.
// Skipping it removes the per-write TLS touch on the common dynamic-shape
// / pointer-free object and array store path (#5094). The inner `if let`
// still tolerates a `None` defensively, so a transiently desynced bit
// can only cost an extra fall-through, never mis-track a slot.
if (*header)._reserved & GC_OBJ_TYPED_LAYOUT_INTACT != 0 {
if let Some(typed) = TYPED_LAYOUTS.with(|m| m.borrow().get(&parent_user).cloned()) {
// #6893: per-object descriptor (diverged/ambiguous objects) OR the
// shared shape descriptor (the common INTACT case). Exactly one is
// present for an INTACT object.
let typed = TYPED_LAYOUTS
.with(|m| m.borrow().get(&parent_user).cloned())
.or_else(|| shape_shared_descriptor(parent_user));
if let Some(typed) = typed {
if slot_index >= typed.slot_count {
layout_set_typed_unknown(header, parent_user);
return;
Expand Down Expand Up @@ -683,6 +831,22 @@ unsafe fn init_typed_shape_layout(
raw_f64_mask,
pointer_mask: pointer_mask.clone(),
};
// #6893: try the O(shapes) shared shape descriptor (keyed by the canonical
// keys_array) before per-object storage.
let keys = if shape_layout_keyed_enabled() {
object_keys_array_ptr(user_ptr)
} else {
0
};
if keys != 0 && shape_install_shared(keys, header, &descriptor) {
TYPED_LAYOUTS.with(|m| {
m.borrow_mut().remove(&user_ptr);
});
LAYOUT_SLOT_MASKS.with(|m| {
m.borrow_mut().remove(&user_ptr);
});
return;
}
TYPED_LAYOUTS.with(|m| {
m.borrow_mut().insert(user_ptr, descriptor);
});
Expand Down Expand Up @@ -788,6 +952,21 @@ pub extern "C" fn js_gc_init_unboxed_object_layout(
raw_f64_mask,
pointer_mask: pointer_mask.clone(),
};
// #6893: shared shape descriptor before per-object storage.
let keys = if shape_layout_keyed_enabled() {
object_keys_array_ptr(user_ptr)
} else {
0
};
if keys != 0 && shape_install_shared(keys, header, &descriptor) {
TYPED_LAYOUTS.with(|m| {
m.borrow_mut().remove(&user_ptr);
});
LAYOUT_SLOT_MASKS.with(|m| {
m.borrow_mut().remove(&user_ptr);
});
return;
}
TYPED_LAYOUTS.with(|m| {
m.borrow_mut().insert(user_ptr, descriptor);
});
Expand Down Expand Up @@ -937,7 +1116,9 @@ pub(super) fn layout_visit_pointer_slots<F: FnMut(usize)>(
}
return true;
}
let mask = LAYOUT_SLOT_MASKS.with(|m| m.borrow().get(&user_ptr).cloned());
let mask = LAYOUT_SLOT_MASKS
.with(|m| m.borrow().get(&user_ptr).cloned())
.or_else(|| shape_shared_pointer_mask(user_ptr, header));
let Some(mask) = mask else {
set_layout_state(header, GC_LAYOUT_UNKNOWN);
return false;
Expand Down Expand Up @@ -1245,7 +1426,9 @@ pub(super) unsafe fn heap_payload_slot_selection(
raw_numeric_recorded: false,
};
}
let mask = LAYOUT_SLOT_MASKS.with(|m| m.borrow().get(&user_ptr).cloned());
let mask = LAYOUT_SLOT_MASKS
.with(|m| m.borrow().get(&user_ptr).cloned())
.or_else(|| shape_shared_pointer_mask(user_ptr, header));
match mask {
Some(mask) => HeapPayloadSlotSelection::Masked {
mask,
Expand Down
10 changes: 10 additions & 0 deletions crates/perry-runtime/src/gc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,16 @@ pub(super) fn gc_collect_minor_with_trigger(trigger: GcTriggerSnapshot) -> GcCol
restore_minor_in_alloc(prev_in_alloc);
return outcome;
}
// #6893-followup: major-GC pacing. A non-moving minor can't free array-growth
// forwarding stubs, so reallocation-heavy churn grows the arena unbounded —
// only a full mark-sweep reclaims stubs. Escalate to a full once the arena's
// live bytes exceed K× the last full's live set (belt-and-suspenders for
// callers that reach a minor outside the budgeted pressure path).
if arena_growth_full_escalation_due() {
let outcome = gc_collect_full_mark_sweep_with_trigger(GcTriggerSnapshot::capture(trigger.kind));
restore_minor_in_alloc(prev_in_alloc);
return outcome;
}
let mut trace = GcCycleTrace::new(GcCollectionKind::Minor, trigger);
let start = Instant::now();
crate::arena::old_pages_begin_gc_cycle();
Expand Down
Loading
Loading