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
3 changes: 3 additions & 0 deletions crates/perry-runtime/src/arena/page_meta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,9 @@ pub(crate) fn old_page_clear_dirty(page: usize) {

#[cfg(test)]
pub(crate) fn old_arena_page_index_clear_for_tests() {
// Wiping page metadata makes real old-arena objects unclassifiable —
// stand the #6179 differential verifier down for this thread's test.
crate::gc::CLASSIFIER_VERIFY_SUPPRESSED.with(|c| c.set(true));
OLD_GEN_PAGE_OBJECTS.with(|index| index.borrow_mut().clear());
}

Expand Down
76 changes: 64 additions & 12 deletions crates/perry-runtime/src/gc/cycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,15 @@ impl RootScanCycleState {
if budget == 0 {
return false;
}
// #6179: classifier-mode (budgeted) cycles have no exact set
// and traced conservative words cannot tolerate heuristic
// false positives — never conservative-scan here, even if a
// ManualGcScanGuard appears mid-cycle (drain-before-manual-gc
// finishing a parked cycle under the guard).
if valid_ptrs.classifier_mode {
self.subphase = RootScanSubphase::MutableSlots;
return false;
}
let conservative_scan_decision = conservative_stack_scan_decision();
// #5029: minors retain old-gen conservative discoveries
// pin-only (no trace) — see try_mark_conservative_word.
Expand Down Expand Up @@ -734,28 +743,49 @@ pub(super) fn test_malloc_trim_call_count() -> usize {
TEST_MALLOC_TRIM_CALLS.with(Cell::get)
}

#[cfg(all(test, target_env = "gnu"))]
#[cfg(all(test, any(target_env = "gnu", target_os = "macos")))]
fn record_test_malloc_trim_call() {
TEST_MALLOC_TRIM_CALLS.with(|calls| calls.set(calls.get().saturating_add(1)));
}

fn run_malloc_trim(progress_kind: GcProgressKind) -> MallocTrimOutcome {
if progress_kind.is_budgeted() {
// #6179/#6180 RSS floor: budgeted cycles are the DEFAULT-path collector
// once incremental graduates — skipping allocator trim there meant a
// long-lived incremental process never returned freed allocator pages to
// the OS (2026-07-09 audit finding). Trim runs at Reclaim, outside the
// atomic tail, and is itself bounded allocator maintenance.

#[cfg(target_env = "gnu")]
{
#[cfg(test)]
record_test_malloc_trim_call();

let start = Instant::now();
unsafe {
libc::malloc_trim(0);
}
return MallocTrimOutcome {
status: AllocatorMaintenanceStatus::Skipped,
reason: AllocatorMaintenanceReason::OrdinaryBudgeted,
elapsed_us: 0,
status: AllocatorMaintenanceStatus::Executed,
reason: AllocatorMaintenanceReason::ExplicitOrEmergency,
elapsed_us: start.elapsed().as_micros() as u64,
};
}

#[cfg(target_env = "gnu")]
#[cfg(target_os = "macos")]
{
#[cfg(test)]
record_test_malloc_trim_call();

// Darwin counterpart of glibc's malloc_trim: ask every malloc zone
// to return clean pages to the OS. Bounded allocator maintenance —
// same placement (Reclaim, outside the atomic tail).
unsafe extern "C" {
fn malloc_zone_pressure_relief(zone: *mut core::ffi::c_void, goal: usize) -> usize;
}
let start = Instant::now();
unsafe {
libc::malloc_trim(0);
// NULL zone = all zones; goal 0 = release as much as possible.
malloc_zone_pressure_relief(core::ptr::null_mut(), 0);
}
return MallocTrimOutcome {
status: AllocatorMaintenanceStatus::Executed,
Expand All @@ -764,7 +794,7 @@ fn run_malloc_trim(progress_kind: GcProgressKind) -> MallocTrimOutcome {
};
}

#[cfg(not(target_env = "gnu"))]
#[cfg(not(any(target_env = "gnu", target_os = "macos")))]
{
MallocTrimOutcome {
status: AllocatorMaintenanceStatus::Unsupported,
Expand Down Expand Up @@ -1050,9 +1080,20 @@ impl GcCycleState {

fn step_build_valid_pointer_set(&mut self, budget: GcWorkBudget) {
let phase_start = trace_phase_start(&self.trace);
let builder = self
.valid_builder
.get_or_insert_with(ValidPointerSetBuilder::new);
let builder = self.valid_builder.get_or_insert_with(|| {
// #6179: budgeted cycles are precise (no conservative scan,
// non-moving) — skip the O(heap) exact census and resolve
// membership via the page-metadata classifier, which the
// differential mode proves is a census superset. Synchronous
// cycles keep the exact set: they force the conservative
// scan, whose TRACED roots cannot tolerate a heuristic
// false positive.
if self.progress_kind.is_budgeted() {
ValidPointerSetBuilder::new_classifier()
} else {
ValidPointerSetBuilder::new()
}
});
if !builder.step(budget.work_units) {
trace_phase_record(&mut self.trace, "build_valid_pointer_set", phase_start);
return;
Expand Down Expand Up @@ -1545,7 +1586,18 @@ impl GcCycleState {
if let Some(minor) = self.minor.as_ref() {
let targeted_old_blocks = (minor.evacuation.old_page_moved_bytes > 0)
.then(|| minor.old_page_source_blocks.block_indices.clone());
(true, false, targeted_old_blocks, minor.malloc_sweep_due)
// Budgeted cycles must NOT age-bump: whole-cycle
// allocate-black (#6224 soundness) marks every mid-cycle
// birth, and the age-bump reads MARKED as "survived" —
// dead churn then tenures into old-gen two cycles later,
// where minors never reclaim it (measured: ~700 MB of
// tenured garbage on a churn loop, 928 MB RSS vs the
// synchronous collector's 524 MB with identical arena
// block counts). Promotion under incremental happens via
// the copied-minor path, which runs between budgeted
// cycles and ages genuinely-surviving objects only.
let age_bump = !self.progress_kind.is_budgeted();
(age_bump, false, targeted_old_blocks, minor.malloc_sweep_due)
} else {
(false, true, None, true)
};
Expand Down
2 changes: 1 addition & 1 deletion crates/perry-runtime/src/gc/progress.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ pub const GC_MUTATOR_ASSIST_SOFT_PAUSE_US: u64 = 500;
/// ~its trigger step and RSS lands near parity. When the collector is
/// keeping up (debt ≈ 0) the budget stays at the base, so low-latency
/// workloads never see the scaled assists.
pub const GC_ASSIST_DEBT_BYTES_PER_WORK_UNIT: u64 = 64;
pub const GC_ASSIST_DEBT_BYTES_PER_WORK_UNIT: u64 = 32;

/// Runtime-visible classification for GC progress.
///
Expand Down
28 changes: 14 additions & 14 deletions crates/perry-runtime/src/gc/tests/incremental_sweep_reclaim.rs
Original file line number Diff line number Diff line change
Expand Up @@ -543,7 +543,7 @@ fn budgeted_reclaim_slices_conservative_pin_cleanup() {
}

#[test]
fn budgeted_reclaim_skips_process_malloc_trim() {
fn budgeted_reclaim_runs_process_malloc_trim() {
let _trace_guard = TestGcTraceCaptureGuard::force_enabled();
let _guard = CopyingNurseryTestGuard::new(1);
let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers();
Expand All @@ -561,23 +561,23 @@ fn budgeted_reclaim_skips_process_malloc_trim() {

let completed = complete_budgeted_gc_cycle();
assert_eq!(completed.status, JS_GC_STEP_STATUS_COMPLETED);
assert_eq!(
test_malloc_trim_call_count(),
0,
"ordinary budgeted reclaim must not invoke process-wide malloc_trim"
assert!(
test_malloc_trim_call_count() >= 1,
"budgeted reclaim must invoke allocator trim (#6180 RSS floor)"
);
assert!(gc_collection_count() > before);
assert_eq!(tracked_malloc_headers_matching(&dead_headers), 0);
assert_eq!(js_shadow_slot_get(0) & POINTER_MASK, live as u64);

let event = take_test_last_gc_trace_json().expect("budgeted reclaim should emit trace JSON");
assert_eq!(
event["allocator_maintenance"]["malloc_trim"]["status"].as_str(),
Some("skipped")
);
assert_eq!(
event["allocator_maintenance"]["malloc_trim"]["reason"].as_str(),
Some("ordinary_budgeted")
);
assert_eq!(event["phase_us"]["malloc_trim"].as_u64(), Some(0));
// #6180 RSS floor: budgeted cycles now RUN allocator trim (the audit
// found long-lived incremental processes never returned freed allocator
// pages to the OS). Outcome is platform-dependent (executed on glibc,
// unsupported elsewhere) — never the old budgeted skip.
let trim = &event["allocator_maintenance"]["malloc_trim"];
assert_ne!(trim["reason"].as_str(), Some("ordinary_budgeted"));
assert!(matches!(
trim["status"].as_str(),
Some("executed") | Some("unsupported")
));
}
16 changes: 11 additions & 5 deletions crates/perry-runtime/src/gc/tests/telemetry_verifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,16 +80,21 @@ fn assert_budgeted_ordinary_trace(event: &serde_json::Value, expected_kind: &str
Some(true)
);
let malloc_trim = &event["allocator_maintenance"]["malloc_trim"];
assert_eq!(malloc_trim["status"].as_str(), Some("skipped"));
assert_eq!(malloc_trim["reason"].as_str(), Some("ordinary_budgeted"));
// #6180 RSS floor: budgeted cycles now RUN allocator trim (previously
// skipped with reason ordinary_budgeted). The outcome is platform-
// dependent: executed on glibc, unsupported elsewhere — but never the
// old budgeted skip.
assert_ne!(malloc_trim["reason"].as_str(), Some("ordinary_budgeted"));
assert!(matches!(
malloc_trim["status"].as_str(),
Some("executed") | Some("unsupported")
));
assert_eq!(malloc_trim["progress_kind"].as_str(), Some(expected_kind));
assert_eq!(malloc_trim["class"].as_str(), Some("ordinary_budgeted"));
assert_eq!(
malloc_trim["ordinary_pause_stats_include"].as_bool(),
Some(false)
);
assert_eq!(malloc_trim["elapsed_us"].as_u64(), Some(0));
assert_eq!(event["phase_us"]["malloc_trim"].as_u64(), Some(0));
for (index, step) in event["pause_steps"]
.as_array()
.expect("ordinary trace should include pause steps")
Expand Down Expand Up @@ -289,7 +294,8 @@ fn emergency_full_trace_is_excluded_from_ordinary_pause_stats() {
malloc_trim["ordinary_pause_stats_include"].as_bool(),
Some(false)
);
if cfg!(target_env = "gnu") {
if cfg!(any(target_env = "gnu", target_os = "macos")) {
// glibc malloc_trim / darwin malloc_zone_pressure_relief (#6180).
assert_eq!(malloc_trim["status"].as_str(), Some("executed"));
assert_eq!(
malloc_trim["reason"].as_str(),
Expand Down
Loading
Loading