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
53 changes: 47 additions & 6 deletions crates/perry-runtime/src/gc/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ thread_local! {
pub(super) const GC_SUPPRESSED_TINY_PARSE_BYTES: usize = 1024 * 1024;
pub(super) const GC_SUPPRESSED_TINY_PARSE_IN_USE_TRIGGER_BYTES: usize = 48 * 1024 * 1024;
pub(super) const GC_SUPPRESSED_TINY_PARSE_FULL_GC_IN_USE_TRIGGER_BYTES: usize = 24 * 1024 * 1024;
const GC_SUPPRESSED_PARSE_BOUNDARY_DRAIN_STEPS: usize = 128;

pub(super) fn gc_suppressed_parse_is_tiny(parse_growth: usize) -> bool {
parse_growth <= GC_SUPPRESSED_TINY_PARSE_BYTES
Expand Down Expand Up @@ -626,20 +627,19 @@ pub fn gc_bump_malloc_trigger() {
trigger.set(bytes_now);
}
});
gc_check_trigger();
} else {
crate::arena::arena_start_fresh_general_block();
GC_SUPPRESSED_TINY_PARSE_COLLECTION_PENDING.with(|pending| pending.set(true));
}
}
}

/// Run a full collection that was armed by tiny JSON parse churn.
/// Run a collection that was armed by tiny JSON parse churn.
///
/// This is separate from the raise-only post-parse trigger bump. Full
/// mark-sweep needs the collection to happen before the next suppressed parse,
/// not immediately after the previous one, otherwise the parse result is still
/// rooted and every churn block looks partially live.
/// This is separate from the raise-only post-parse trigger bump. The collection
/// needs to happen before the next suppressed parse, not immediately after the
/// previous one, otherwise the parse result is still rooted and every churn
/// block looks partially live.
pub fn gc_collect_pending_suppressed_parse() {
let pending = GC_SUPPRESSED_TINY_PARSE_COLLECTION_PENDING.with(|pending| {
let was_pending = pending.get();
Expand All @@ -656,6 +656,47 @@ pub fn gc_collect_pending_suppressed_parse() {
return;
}

if gc_budgeted_cycle_active() {
for _ in 0..GC_SUPPRESSED_PARSE_BOUNDARY_DRAIN_STEPS {
let result = gc_runtime_safepoint();
match result.status {
JS_GC_STEP_STATUS_ACTIVE => continue,
JS_GC_STEP_STATUS_COMPLETED | JS_GC_STEP_STATUS_IDLE => break,
JS_GC_STEP_STATUS_SKIPPED => {
GC_SUPPRESSED_TINY_PARSE_COLLECTION_PENDING.with(|pending| pending.set(true));
return;
}
_ => break,
}
}
if gc_budgeted_cycle_active() {
GC_SUPPRESSED_TINY_PARSE_COLLECTION_PENDING.with(|pending| pending.set(true));
return;
}
}

let in_use = crate::arena::arena_in_use_bytes();
if gen_gc_enabled() {
let old_pending = GC_OLD_RECLAIM_PENDING.with(|pending| pending.get());
let old_in_use = crate::arena::old_gen_in_use_bytes();
let old_baseline = GC_LAST_OLD_RECLAIM_IN_USE_BYTES.with(|bytes| bytes.get());
if old_pending || old_reclaim_pressure_due(old_in_use, old_baseline) {
GC_OLD_RECLAIM_PENDING.with(|pending| pending.set(false));
gc_collect_full_mark_sweep_with_trigger(GcTriggerSnapshot::capture(
GcTriggerKind::OldGenBytes,
))
.emit_after_current();
return;
}
}

if gen_gc_enabled() && in_use >= GC_SUPPRESSED_TINY_PARSE_IN_USE_TRIGGER_BYTES {
let outcome =
gc_collect_inner_with_trigger(GcTriggerSnapshot::capture(GcTriggerKind::ArenaBytes));
gc_finish_arena_trigger_collection(in_use, outcome);
return;
}

let total = crate::arena::arena_total_bytes();
GC_NEXT_TRIGGER_BYTES.with(|trigger| {
if trigger.get() > total {
Expand Down
104 changes: 104 additions & 0 deletions crates/perry-runtime/src/gc/tests/triggers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ struct GcBumpTriggerTestGuard {
malloc_step: usize,
trigger_bumped: bool,
pre_suppress_bytes: usize,
suppressed_parse_pending: bool,
}

impl GcBumpTriggerTestGuard {
Expand Down Expand Up @@ -36,6 +37,11 @@ impl GcBumpTriggerTestGuard {
previous
}),
pre_suppress_bytes: GC_PRE_SUPPRESS_BYTES.with(|bytes| bytes.get()),
suppressed_parse_pending: GC_SUPPRESSED_TINY_PARSE_COLLECTION_PENDING.with(|pending| {
let previous = pending.get();
pending.set(false);
previous
}),
};
GC_PRE_SUPPRESS_BYTES.with(|bytes| bytes.set(0));
previous
Expand Down Expand Up @@ -66,9 +72,17 @@ impl Drop for GcBumpTriggerTestGuard {
GC_MALLOC_COUNT_STEP.with(|step| step.set(self.malloc_step));
GC_TRIGGER_BUMPED.with(|bumped| bumped.set(self.trigger_bumped));
GC_PRE_SUPPRESS_BYTES.with(|bytes| bytes.set(self.pre_suppress_bytes));
GC_SUPPRESSED_TINY_PARSE_COLLECTION_PENDING
.with(|pending| pending.set(self.suppressed_parse_pending));
}
}

fn reset_old_reclaim_pressure() {
let old_in_use = crate::arena::old_gen_in_use_bytes();
GC_LAST_OLD_RECLAIM_IN_USE_BYTES.with(|bytes| bytes.set(old_in_use));
GC_OLD_RECLAIM_PENDING.with(|pending| pending.set(false));
}

#[test]
fn test_gc_bump_tiny_parse_caps_arena_trigger_at_collector_ceiling() {
let _guard = GcBumpTriggerTestGuard::new(0, GC_THRESHOLD_INITIAL_BYTES);
Expand Down Expand Up @@ -172,6 +186,96 @@ fn test_gc_bump_never_lowers_existing_arena_trigger() {
assert!(!GcBumpTriggerTestGuard::trigger_bumped());
}

#[test]
fn test_pending_tiny_parse_boundary_collection_completes_and_rebaselines() {
let _trace_guard = TestGcTraceCaptureGuard::force_enabled();
let _copying_guard = CopyingNurseryTestGuard::new(1);
let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers();
let _bump_guard = GcBumpTriggerTestGuard::new(usize::MAX, GC_THRESHOLD_INITIAL_BYTES);
reset_old_reclaim_pressure();

let live = crate::string::js_string_from_bytes(b"parse-boundary-live".as_ptr(), 19) as usize;
js_shadow_slot_set(0, string_bits(live));

while crate::arena::arena_in_use_bytes()
< GC_SUPPRESSED_TINY_PARSE_IN_USE_TRIGGER_BYTES + (2 * 1024 * 1024)
{
let _ = crate::arena::arena_alloc_gc(8 * 1024, 8, GC_TYPE_STRING);
}
let pre_in_use = crate::arena::arena_in_use_bytes();
let before = gc_collection_count();

GC_SUPPRESSED_TINY_PARSE_COLLECTION_PENDING.with(|pending| pending.set(true));
gc_collect_pending_suppressed_parse();

assert_eq!(gc_collection_count(), before + 1);
assert!(
crate::arena::arena_in_use_bytes() < pre_in_use / 8,
"parse-boundary collection should reclaim dead tiny-parse churn"
);
assert!(
GC_NEXT_TRIGGER_BYTES.with(|trigger| trigger.get()) > crate::arena::arena_total_bytes(),
"completed boundary collection should rebaseline the arena trigger"
);

let event =
take_test_last_gc_trace_json().expect("parse-boundary collection should emit trace JSON");
assert_eq!(event["collection_kind"].as_str(), Some("minor"));
assert_eq!(event["trigger"]["kind"].as_str(), Some("arena_bytes"));

let live_after = (js_shadow_slot_get(0) & POINTER_MASK) as *const crate::StringHeader;
unsafe {
assert_string_bytes(live_after, b"parse-boundary-live");
}
}

#[test]
fn test_pending_parse_boundary_prioritizes_old_reclaim_pressure() {
let _trace_guard = TestGcTraceCaptureGuard::force_enabled();
let _copying_guard = CopyingNurseryTestGuard::new(1);
let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers();
let _bump_guard = GcBumpTriggerTestGuard::new(usize::MAX, GC_THRESHOLD_INITIAL_BYTES);

let live_old = crate::arena::arena_alloc_gc_old(40, 8, GC_TYPE_STRING) as usize;
js_shadow_slot_set(0, ptr_bits(live_old));
GC_LAST_OLD_RECLAIM_IN_USE_BYTES.with(|bytes| bytes.set(0));
GC_OLD_RECLAIM_PENDING.with(|pending| pending.set(true));

let mut dead_bytes = 0usize;
let dead_chunks = (GC_OLD_GEN_RECLAIM_THRESHOLD_BYTES / (1024 * 1024)) + 2;
for _ in 0..dead_chunks {
let dead = crate::arena::arena_alloc_gc_old(1024 * 1024, 8, GC_TYPE_STRING) as usize;
let (_header, total) = old_test_header_and_size(dead);
dead_bytes = dead_bytes.saturating_add(total);
}
let old_before = crate::arena::old_gen_in_use_bytes();
let before = gc_collection_count();

GC_SUPPRESSED_TINY_PARSE_COLLECTION_PENDING.with(|pending| pending.set(true));
gc_collect_pending_suppressed_parse();

assert_eq!(gc_collection_count(), before + 1);
assert!(
crate::arena::old_gen_in_use_bytes() < old_before / 4,
"parse-boundary old reclaim should reclaim dead old JSON pressure"
);
assert!(
GC_LAST_OLD_RECLAIM_IN_USE_BYTES.with(|bytes| bytes.get())
<= crate::arena::old_gen_in_use_bytes(),
"completed old reclaim should rebaseline old-generation pressure"
);
assert!(dead_bytes >= GC_OLD_GEN_RECLAIM_THRESHOLD_BYTES);

let event =
take_test_last_gc_trace_json().expect("parse-boundary old reclaim should emit trace JSON");
assert_eq!(event["collection_kind"].as_str(), Some("full"));
assert_eq!(event["trigger"]["kind"].as_str(), Some("old_gen_bytes"));

let live_after = (js_shadow_slot_get(0) & POINTER_MASK) as usize;
assert_eq!(live_after, live_old);
assert!(crate::arena::pointer_in_old_gen(live_after));
}

#[test]
fn test_old_reclaim_pressure_uses_threshold_and_growth() {
assert!(!old_reclaim_pressure_due(
Expand Down
7 changes: 5 additions & 2 deletions crates/perry-runtime/src/json/parse_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,8 +325,9 @@ fn should_use_tape_parse(len: usize, bytes: &[u8]) -> bool {
///
/// Wraps the tape path in the same GC-safety contract as the direct
/// parser (pending parse-boundary collection → gc_check_trigger →
/// suppress → parse → unsuppress → bump malloc trigger + cache trim) so
/// it's a drop-in replacement behind the feature flag.
/// suppress → parse → unsuppress → bump malloc trigger + parse-boundary
/// scheduling + cache trim) so it's a drop-in replacement behind the feature
/// flag.
pub(crate) unsafe fn try_parse_via_tape(
text_ptr: *const StringHeader,
bytes: &[u8],
Expand Down Expand Up @@ -357,6 +358,7 @@ pub(crate) unsafe fn try_parse_via_tape(

crate::gc::gc_unsuppress();
crate::gc::gc_bump_malloc_trigger();
crate::gc::gc_schedule_parse_boundary_collection_if_pressure();
parse_root_restore(text_root);

PARSE_KEY_CACHE.with(|c| {
Expand Down Expand Up @@ -448,6 +450,7 @@ pub unsafe extern "C" fn js_json_parse_typed_array(

crate::gc::gc_unsuppress();
crate::gc::gc_bump_malloc_trigger();
crate::gc::gc_schedule_parse_boundary_collection_if_pressure();
parse_root_restore(text_root);

PARSE_KEY_CACHE.with(|c| {
Expand Down