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
28 changes: 28 additions & 0 deletions changelog.d/10217-gc-block-granular-sweep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
A synchronous full mark-sweep now reclaims an arena block without walking its
objects when the cycle's exact pointer census shows the trace reached nothing
in it and nothing in it owes per-object sweep work (no finalizer, no pinned,
forwarded or pre-marked header, no raw-f64 array layout bits, and no
address-keyed side-table entry the dead-owner prune does not already drop).
The census seals its address-ordered runs at block boundaries, so a successful
membership query names the reached block at no extra lookup; the block cleanup
still resets or releases the block and drops its old-page index, and freed
bytes come from the census's per-block sums. The require-marked old-to-young
remembered-set rebuild skips the same unreached blocks. Budgeted fulls and
minors keep the per-object walk. `PERRY_GC_DIAG=1` prints
`block_skip_reclaimed_blocks/_objects/_bytes` on each `[gc] blocks:` line.

Measured on a loop that parses `records_array_8m.json`, keeps one tree, and
calls `gc()` each iteration: sweep 5.4 → 2.6 ms and 12–15 → 3.7–4.0 ms on the
fulls that follow a minor, atomic finalize 3.3 → 2.2 ms, census 2.7 → 3.3 ms;
the six fulls total 159 ms against 203 ms on the base. gc-ratchet gated
counters are unchanged on all 14 probes (block skip is live on 08, 09, 11 and
14), and the 22-row JSON matrix is unchanged within noise because none of its
rows runs a full collection on `main`.

The #10182 pacing half — an old-reclaim trigger bounded by the promoted cohort
— was measured and not included: every variant that produced the intended
regime (a full every two to three parses, peak RSS at or below Node's) cost
+136 to +304 ms of CPU on the target rows, far beyond their CPU lead. A full
over one live 20 MB tree costs ~100 ms even with dead blocks skipped: mark
45 ms, remembered-set rebuild 21 ms, per-live-object sweep accounting 16–19 ms,
census 10–15 ms.
2 changes: 1 addition & 1 deletion crates/perry-runtime/src/arena/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ pub(crate) use reset::{
copying_active_survivor_in_use_bytes, copying_from_space_in_use_bytes,
copying_prepare_to_space, copying_reset_from_spaces_and_flip, old_arena_reclaim_dead_blocks,
old_arena_reclaim_selected_dead_blocks, survivor_arena_reclaim_dead_blocks,
ArenaResetEmptyBlocksState, OldArenaReclaimDeadBlocksState,
survivor_block_index_range, ArenaResetEmptyBlocksState, OldArenaReclaimDeadBlocksState,
SurvivorArenaReclaimDeadBlocksState,
};
pub use reset::{arena_reset_all_blocks_to_zero, arena_reset_empty_blocks};
Expand Down
9 changes: 9 additions & 0 deletions crates/perry-runtime/src/arena/reset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,15 @@ pub(crate) fn block_in_copying_from_space(
block_idx < general_n || active_survivor.contains(&block_idx)
}

/// Global block indices of both survivor arenas (the region between the
/// general arena and the longlived arena).
pub(crate) fn survivor_block_index_range() -> std::ops::Range<usize> {
let general_n = ARENA.with(|a| unsafe { (*a.get()).blocks.len() });
let survivor0_n = SURVIVOR_ARENA_0.with(|a| unsafe { (*a.get()).blocks.len() });
let survivor1_n = SURVIVOR_ARENA_1.with(|a| unsafe { (*a.get()).blocks.len() });
general_n..general_n + survivor0_n + survivor1_n
}

pub(crate) fn active_survivor_block_index_range() -> std::ops::Range<usize> {
let general_n = ARENA.with(|a| unsafe { (*a.get()).blocks.len() });
let survivor0_n = SURVIVOR_ARENA_0.with(|a| unsafe { (*a.get()).blocks.len() });
Expand Down
52 changes: 40 additions & 12 deletions crates/perry-runtime/src/arena/walk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ pub(crate) struct ArenaObjectCursor {
block_pos: usize,
offset: usize,
finished: bool,
/// Global block indices this cursor never enters (#10182 block-granular
/// sweep). Empty for every other walker.
skip_blocks: Vec<bool>,
}

enum ArenaObjectCursorBlocks {
Expand Down Expand Up @@ -110,6 +113,7 @@ impl ArenaObjectCursorBuilder {
block_pos: 0,
offset: 0,
finished: false,
skip_blocks: Vec::new(),
});
}

Expand Down Expand Up @@ -264,6 +268,20 @@ impl ArenaObjectCursor {
self.finished
}

/// Never enter the blocks whose global index is set in `skip` (#10182).
/// Must be installed before the first `next`; a block the cursor is
/// already inside is not affected.
pub(crate) fn set_skip_blocks(&mut self, skip: Vec<bool>) {
self.skip_blocks = skip;
}

/// `(global block index, data, offset)` of the block the last yielded
/// object came from, as snapshotted when the cursor was built.
pub(crate) fn current_block_extent(&self) -> Option<(usize, usize, usize)> {
self.current_block
.map(|block| (block.block_idx, block.data, block.offset))
}

pub(crate) fn next(&mut self) -> Option<(*mut u8, usize)> {
let mut remaining = usize::MAX;
self.next_budgeted(&mut remaining)
Expand All @@ -273,21 +291,31 @@ impl ArenaObjectCursor {
if self.current_block.is_some() {
return true;
}
self.current_block = match &mut self.blocks {
ArenaObjectCursorBlocks::BlockIndex(blocks) => {
let block = blocks.get(self.block_pos).copied();
if block.is_some() {
self.block_pos += 1;
loop {
self.current_block = match &mut self.blocks {
ArenaObjectCursorBlocks::BlockIndex(blocks) => {
let block = blocks.get(self.block_pos).copied();
if block.is_some() {
self.block_pos += 1;
}
block
}
block
ArenaObjectCursorBlocks::Address(blocks) => blocks.next(),
};
let Some(block) = self.current_block else {
self.finished = true;
return false;
};
if !self
.skip_blocks
.get(block.block_idx)
.copied()
.unwrap_or(false)
{
return true;
}
ArenaObjectCursorBlocks::Address(blocks) => blocks.next(),
};
if self.current_block.is_none() {
self.finished = true;
return false;
self.current_block = None;
}
true
}
}

Expand Down
7 changes: 7 additions & 0 deletions crates/perry-runtime/src/array/element_shape.rs
Original file line number Diff line number Diff line change
Expand Up @@ -852,6 +852,13 @@ pub(crate) fn test_element_shape_record_exists(owner: usize) -> bool {
ELEMENT_SHAPES.with(|m| m.borrow().contains_key(&owner))
}

/// Plant a record (and its advertising bit) for `owner` without verifying any
/// elements — a fixture for the collector's side-table tests.
#[cfg(test)]
pub(crate) fn test_seed_element_shape_record(owner: usize) {
unsafe { establish(owner as *mut ArrayHeader, 1, 0, 0) };
}

#[cfg(test)]
pub(crate) fn test_clear_element_shape_table() {
ELEMENT_SHAPES.with(|m| m.borrow_mut().clear());
Expand Down
4 changes: 3 additions & 1 deletion crates/perry-runtime/src/array/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,9 @@ pub use self::element_shape::{
js_array_element_shape_version, js_array_ensure_element_shape,
};
#[cfg(test)]
pub(crate) use self::element_shape::{test_element_shape_record_exists, test_serialize};
pub(crate) use self::element_shape::{
test_element_shape_record_exists, test_seed_element_shape_record, test_serialize,
};
pub use self::flat_clone::{
js_array_clone, js_array_clone_for_spread, js_array_entries, js_array_flat,
js_array_flat_depth, js_array_keys, js_array_values, js_arraylike_flat,
Expand Down
25 changes: 24 additions & 1 deletion crates/perry-runtime/src/gc/cycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1283,12 +1283,25 @@ impl GcCycleState {
return;
}
let done = {
// #10182: a synchronous full's census knows which blocks
// the trace never reached; the require-marked walk skips
// them. A budgeted cycle has no census (and its mutator
// windows can still shade), so it walks everything.
let budgeted = self.progress_kind.is_budgeted();
let valid_ptrs = self.valid_ptrs.as_ref();
let state = self
.atomic_finalize
.as_mut()
.expect("atomic finalize state exists");
let rebuild = state.remembered_rebuild.get_or_insert_with(|| {
OldToYoungRememberedRebuildState::new(/* require_marked = */ true)
let skip = if budgeted {
None
} else {
valid_ptrs.and_then(|ptrs| ptrs.block_census.unmarked_blocks())
};
OldToYoungRememberedRebuildState::new_skipping(
/* require_marked = */ true, skip,
)
});
rebuild.step(budget)
};
Expand Down Expand Up @@ -1517,6 +1530,16 @@ impl GcCycleState {
full_trace && !self.progress_kind.is_budgeted(),
),
);
// #10182: a synchronous full reclaims dead, obligation-free blocks
// without walking them. Only its census-built pointer set records
// which blocks the trace reached; a budgeted cycle's classifier
// set is disarmed and this is a no-op.
if full_trace && !self.progress_kind.is_budgeted() {
if let Some(valid_ptrs) = self.valid_ptrs.as_ref() {
let sweep = self.sweep_state.take().expect("sweep state was just built");
self.sweep_state = Some(sweep.with_block_skip(&valid_ptrs.block_census));
}
}
}
let done = self
.sweep_state
Expand Down
Loading
Loading