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
35 changes: 35 additions & 0 deletions changelog.d/9840-regexp-header-nursery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
### Performance

- **A `RegExp` header is allocated in the nursery instead of the malloc arm.**
A JS regex literal evaluates to a fresh `RegExp` every time it is reached, and
`js_regexp_new` allocated each header with `gc_malloc`. On the claude-code TUI
that is, per 400-character reply (`PERRY_GC_TRACE`), **199,873 of 199,926
malloc-tracked GC allocations — 100.0 %**, 80 bytes each, 99.2 % of them
freed, with the malloc registry swinging **101,929 entries down to 1,689**
across a single minor. Every one of those paid a mimalloc allocation, a push
onto `MALLOC_STATE.objects`, an insert into the malloc-registry `PtrHashSet`
(which rehashes as it grows), and at death a malloc-sweep visit and a free —
old-generation prices for an object that overwhelmingly dies young.

Nothing required the malloc arm. `GC_TYPE_REGEXP` is already declared
`ArenaOrMalloc` and movable; `GcMoveHookKind::RegExpSideTables` already rekeys
`REGEX_POINTERS`, `REGEX_SOURCE_TABLE` and the expando owner after evacuation,
and `GcLayoutSlotKind::RegExpFields` already traces the header's two string
edges and its `meta` record. What kept production on `gc_malloc` was
finalization: the copying minor's from-space flip runs no per-object finalize
hooks, so a nursery header that died young would leak its `Arc` programs and
its registry entries. That is now handled exactly as `Map`, `Set` and `Error`
handle theirs — `finalize_dead_copied_minor_from_space_regexps` after a copied
minor, `collect_dead_registered_regexps_post_trace` at sweep entry for the
non-copying cycle kinds, and the ordinary old-generation sweep for a header
that has been promoted.

Every regex program cache (`REGEX_CACHE`, `FANCY_CACHE`,
`REPEAT_MATCHER_CACHE`, `VALIDATED_PATTERNS`, the site cache) keys on pattern
and flags CONTENT, not on the header address, so a moving header costs them
nothing.

Note that this **changes the collection schedule** rather than only removing
work: the `MallocCount` trigger loses essentially all of its input on this
workload, while ~16 MB per reply moves into the nursery. The schedule is
reported with the change rather than assumed unchanged.
1 change: 1 addition & 0 deletions crates/perry-runtime/src/gc/copying.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1908,6 +1908,7 @@ fn finalize_dead_copied_minor_from_space_side_allocations() {
crate::map::finalize_dead_copied_minor_from_space_maps();
crate::set::finalize_dead_copied_minor_from_space_sets();
crate::node_submodules::diagnostics_gc::finalize_dead_copied_minor_from_space_errors();
crate::regex::finalize_dead_copied_minor_from_space_regexps();
// 2026-07-09 GC audit wave 2: the from-space flip runs no per-object
// finalize hooks, so entries keyed by dead from-space owners in the
// object-address-keyed side tables are pruned here (headers still intact).
Expand Down
7 changes: 7 additions & 0 deletions crates/perry-runtime/src/gc/dead_owner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,13 @@ impl PostTraceProbe {
/// from-space (eden or the active survivor half) and was neither marked nor
/// forwarded — every live from-space object was evacuated (FORWARDED) or is
/// pinned-and-marked by this point. Mirrors `is_dead_copied_minor_from_space_map`.
/// Crate-visible form for the per-type registry walkers that finalize their
/// own dead from-space instances after a copied minor (`regex`): is `addr` a
/// from-space `obj_type` cell that was neither evacuated nor pinned?
pub(crate) fn owner_is_dead_copied_minor_from_space_of_type(addr: usize, obj_type: u8) -> bool {
owner_is_dead_copied_minor_from_space(addr, Some(obj_type))
}

fn owner_is_dead_copied_minor_from_space(addr: usize, expected_obj_type: Option<u8>) -> bool {
let space = crate::arena::classify_heap_space(addr);
if !matches!(space, crate::arena::HeapSpace::NurseryEden)
Expand Down
1 change: 1 addition & 0 deletions crates/perry-runtime/src/gc/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ pub(crate) use copying_pointer_set::CopyingPointerSet;
#[cfg(test)]
pub(crate) use copying::MAX_YOUNG_MOVE_BYTES;
mod dead_owner;
pub(crate) use dead_owner::owner_is_dead_copied_minor_from_space_of_type;
mod old_free;
use old_free::*;
pub(crate) use old_free::{old_free_bytes, old_free_filter_range, old_free_take_exact};
Expand Down
7 changes: 7 additions & 0 deletions crates/perry-runtime/src/gc/oldgen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1154,6 +1154,7 @@ pub(super) struct IncrementalSweepState {
subphase: SweepCycleSubphase,
dead_maps: Vec<usize>,
dead_sets: Vec<usize>,
dead_regexps: Vec<usize>,
dead_buffers: Vec<usize>,
dead_typed_arrays: Vec<usize>,
dead_lazy_arrays: Vec<usize>,
Expand All @@ -1177,6 +1178,7 @@ impl IncrementalSweepState {
subphase: SweepCycleSubphase::Malloc,
dead_maps: Vec::new(),
dead_sets: Vec::new(),
dead_regexps: Vec::new(),
dead_buffers: Vec::new(),
dead_typed_arrays: Vec::new(),
dead_lazy_arrays: Vec::new(),
Expand Down Expand Up @@ -1215,6 +1217,7 @@ impl IncrementalSweepState {
);
self.dead_maps = crate::map::collect_dead_registered_maps_post_trace(full_trace);
self.dead_sets = crate::set::collect_dead_registered_sets_post_trace(full_trace);
self.dead_regexps = crate::regex::collect_dead_registered_regexps_post_trace(full_trace);
self.dead_buffers = crate::buffer::collect_dead_registered_buffers_post_trace(full_trace);
self.dead_typed_arrays =
crate::typedarray::collect_dead_registered_typed_arrays_post_trace(full_trace);
Expand All @@ -1227,6 +1230,7 @@ impl IncrementalSweepState {
});
if !self.dead_maps.is_empty()
|| !self.dead_sets.is_empty()
|| !self.dead_regexps.is_empty()
|| !self.dead_buffers.is_empty()
|| !self.dead_typed_arrays.is_empty()
|| !self.dead_lazy_arrays.is_empty()
Expand All @@ -1245,6 +1249,8 @@ impl IncrementalSweepState {
crate::map::finalize_collected_dead_map(addr);
} else if let Some(addr) = self.dead_sets.pop() {
crate::set::finalize_collected_dead_set(addr);
} else if let Some(addr) = self.dead_regexps.pop() {
crate::regex::finalize_collected_dead_regexp(addr);
} else if let Some(addr) = self.dead_buffers.pop() {
crate::buffer::finalize_collected_dead_buffer(addr);
} else if let Some(addr) = self.dead_typed_arrays.pop() {
Expand All @@ -1259,6 +1265,7 @@ impl IncrementalSweepState {
}
if self.dead_maps.is_empty()
&& self.dead_sets.is_empty()
&& self.dead_regexps.is_empty()
&& self.dead_buffers.is_empty()
&& self.dead_typed_arrays.is_empty()
&& self.dead_lazy_arrays.is_empty()
Expand Down
45 changes: 45 additions & 0 deletions crates/perry-runtime/src/gc/tests/copying/survival_and_malloc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -891,3 +891,48 @@ fn test_copied_minor_promotable_census_filtered_walk_matches_unfiltered() {
the equivalence assert above must not be vacuously 0 == 0"
);
}

/// #9819 follow-up: `js_regexp_new` allocates the header in the NURSERY. A
/// header that dies young must be finalized by the copied minor — its `Arc`
/// program released and its registry entries removed — because the from-space
/// flip runs no per-object finalize hooks. Without
/// `finalize_dead_copied_minor_from_space_regexps` the dead address stays in
/// `REGEX_POINTERS` and the program's strong count never comes back down.
#[test]
fn nursery_regexp_that_dies_young_is_finalized_by_the_copied_minor() {
let _guard = CopyingNurseryTestGuard::new(1);
let dead = crate::regex::test_construct_regexp_and_exec_once("b(?:c)+d-die-young", "g");
let live = crate::regex::test_construct_regexp_and_exec_once("b(?:c)+d-die-young", "g");
let dead_addr = dead as usize;
let live_addr = live as usize;
// Premise: production construction is nursery-allocated now.
assert!(crate::arena::pointer_in_nursery(dead_addr), "the header must be nursery-allocated");
assert!(crate::regex::test_regex_pointer_entry_exists(dead_addr));
assert!(crate::regex::test_regex_source_entry_exists(dead_addr));
// Both headers share one program through the site cache.
let count_before = crate::regex::test_regexp_std_program_strong_count(live);
assert!(count_before >= 2);

// Only `live` is rooted; `dead` is garbage.
js_shadow_slot_set(0, ptr_bits(live_addr));
let _ = gc_collect_minor();

let live_new = (js_shadow_slot_get(0) & POINTER_MASK) as usize;
assert_ne!(live_new, 0, "the rooted RegExp must survive");
assert_ne!(live_new, live_addr, "the rooted RegExp must be evacuated");
assert!(crate::regex::regex_header_has_magic(live_new as *const _));
assert!(crate::regex::test_regex_pointer_entry_exists(live_new));
assert!(crate::regex::test_regex_source_entry_exists(live_new));

assert!(
!crate::regex::test_regex_pointer_entry_exists(dead_addr),
"a nursery RegExp that died must be removed from REGEX_POINTERS by the copied minor"
);
assert!(!crate::regex::test_regex_source_entry_exists(dead_addr));
assert_eq!(
crate::regex::test_regexp_std_program_strong_count(live_new as *const _),
count_before - 1,
"the dead header's Arc clone of the shared program must have been dropped"
);
js_shadow_slot_set(0, 0);
}
Loading
Loading