diff --git a/CLAUDE.md b/CLAUDE.md index 2898b71a13..e31f1c8e67 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1338 +**Current Version:** 0.5.1339 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index b6a75fc249..f4b1a459fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1338" +version = "0.5.1339" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1338" +version = "0.5.1339" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1338" +version = "0.5.1339" [[package]] name = "perry-ui-tvos" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1338" +version = "0.5.1339" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 0070cc4e0c..b92fe0bc64 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1338" +version = "0.5.1339" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/7596-live-proportional-gc-budgets.md b/changelog.d/7596-live-proportional-gc-budgets.md new file mode 100644 index 0000000000..82c0538f98 --- /dev/null +++ b/changelog.d/7596-live-proportional-gc-budgets.md @@ -0,0 +1,28 @@ +**perf(gc): live-proportional collection budgets at both generations (#7592)** + +Three changes, one principle: no constant band may pace a collector whose +per-cycle cost is O(live) — total work goes quadratic in the live set, and a +bigger constant only moves the cliff. + +1. The survivor-promotion handoff fires on **current** old-gen pressure only. + It used to fire on `old + promotable` — but promotable bytes sit in the + survivor space, where a full mark-sweep can neither reclaim them nor the + old-gen space they have not yet occupied. A handoff over a near-empty + old-gen is guaranteed futile (measured: 1,015 ms over 4.2 MB, 0 freed). +2. A copying minor's promoted bytes credit the old-reclaim baseline: they are + live by construction, so a reclaim fired because promotion crossed a + threshold finds them all live and frees nothing (measured: 2,100 ms over + 274 MB just-promoted, 0.0 MB freed). Standard GOGC trade: the one visible + ratchet cost is `12_large_live_set.heap_total_bytes` +36 % (reserved + high-water from collecting less often) while its heap_used is flat and its + peak RSS and wall both improve. +3. Both pacing bands become live-proportional: the old-reclaim growth band is + `max(32 MB, baseline/2)` (shared by dueness and debt so they cannot + diverge), and the scavenge nursery cap is `max(influx_driven, + old_gen_reclaimable/2)` — keyed on old-gen occupancy, not total arena + in-use, which would be a fixed point from-space can never cross. + +`json_pipeline` `build_out`, 500k records: 61.6 s (main) / 12.1 s (#7594 +latch) → **5.8 s**, 10.6× vs main, ns/record flat within ~30 % across a 20× +size range (main grows 70×). Output hash identical on every row; RSS +1.7 % +over the latch arm. diff --git a/crates/perry-runtime/src/gc/copying.rs b/crates/perry-runtime/src/gc/copying.rs index f5f0ea54e1..b308009f72 100644 --- a/crates/perry-runtime/src/gc/copying.rs +++ b/crates/perry-runtime/src/gc/copying.rs @@ -1279,6 +1279,11 @@ pub(super) fn gc_collect_minor_copying_fast_path_with_eligibility( // #7592: this is the promotion the survivor-promotion handoff exists to // enable, so it releases the latch that suppressed a repeat handoff. note_copying_minor_completed(); + // #7592: promoted bytes are live by construction — credit them to the + // old-reclaim baseline BEFORE the pressure check below, or the check reads + // the stale baseline and schedules a full that is guaranteed to free + // nothing (see `credit_promoted_bytes_to_old_baseline`). + credit_promoted_bytes_to_old_baseline(collector.stats.promoted_bytes); maybe_schedule_old_reclaim_after_copied_minor(); retune_after_scavenge( collector.stats.eden_live_bytes, diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index 3d47859afa..d330c56424 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -1172,13 +1172,59 @@ pub(super) fn old_gen_reclaimable_pressure_bytes() -> usize { crate::arena::old_gen_in_use_bytes().saturating_sub(super::old_free_bytes()) } +/// #7592: divisor for the proportional old-reclaim growth band — the next +/// full reclaim fires when old-gen has grown `baseline / 2` (50%) past the +/// post-reclaim baseline, Go's GOGC shape. +const OLD_RECLAIM_GROWTH_DIVISOR: usize = 2; + +/// #7592: how much old-gen may grow past the post-reclaim baseline before the +/// next full reclaim is due. The constant `gc_old_gen_reclaim_growth_dyn_bytes` +/// band survives as the floor, but a *constant* band cannot be the whole +/// answer: each full reclaim costs O(live), so a fixed-bytes cadence makes +/// total major-GC work quadratic in the live set — the same shape #7594 +/// removed one generation down, just paced by promotion instead of +/// allocation. A band proportional to the baseline makes the major count +/// logarithmic in heap growth (`∫ dL/(L/2) = 2·ln(Lmax/L0)`), so total major +/// work stays linear in the final live set. +/// +/// This is also strictly better on the #7437 failure mode (a reclaim that +/// cannot actually lower the number it watches, e.g. pinned survivors): the +/// futile reclaim resets the baseline to the still-high value, so the next +/// band is *larger*, spacing futile repeats out instead of re-firing every +/// constant step. +/// +/// Shared by `old_reclaim_pressure_due` and `gc_old_reclaim_debt_bytes` so +/// the "is it due" predicate and the debt arithmetic cannot diverge (#7024's +/// two-predicates-collapse family). +pub(super) fn gc_old_reclaim_growth_band_bytes(baseline: usize) -> usize { + gc_old_gen_reclaim_growth_dyn_bytes().max(baseline / OLD_RECLAIM_GROWTH_DIVISOR) +} + #[inline] pub(super) fn old_reclaim_pressure_due(old_in_use: usize, baseline: usize) -> bool { (old_in_use >= gc_old_gen_reclaim_threshold_dyn_bytes() && baseline < gc_old_gen_reclaim_threshold_dyn_bytes()) - || old_in_use.saturating_sub(baseline) >= gc_old_gen_reclaim_growth_dyn_bytes() + || old_in_use.saturating_sub(baseline) >= gc_old_reclaim_growth_band_bytes(baseline) } +/// Whether an imminent promotion justifies a full old reclaim FIRST. +/// +/// #7592: the pressure test is on the CURRENT old-gen occupancy only. It used +/// to be `old_reclaim_pressure_due(old_in_use + promotable_bytes, _)` — a +/// *prediction* of where old-gen would land after the promotion — which is +/// the #7594 mistake in another coat: the promotable bytes are in the +/// survivor space, where a full mark-sweep can neither reclaim them (they are +/// live) nor reclaim the old-gen space they have not yet occupied. A handoff +/// scheduled on predicted pressure over a near-empty old-gen is guaranteed +/// futile — measured on #7592's `json_pipeline` at 500k records as a 1,015 ms +/// full over 4.2 MB of old-gen that freed nothing. The only useful work a +/// handoff can do is clear CURRENT old garbage so the promotion lands in +/// reused holes; when old-gen has no reclaimable pressure of its own, the +/// promotion should simply proceed and grow it. +/// +/// `promotable_bytes` remains the reason to *bother* checking: below the +/// handoff minimum the upcoming promotion is too small to be worth a full +/// collection under any old-gen state. #[inline] pub(super) fn copied_minor_promotion_handoff_pressure_due( promotable_bytes: usize, @@ -1186,7 +1232,7 @@ pub(super) fn copied_minor_promotion_handoff_pressure_due( baseline: usize, ) -> bool { promotable_bytes >= gc_copy_promotion_handoff_min_dyn_bytes() - && old_reclaim_pressure_due(old_in_use.saturating_add(promotable_bytes), baseline) + && old_reclaim_pressure_due(old_in_use, baseline) } pub(super) fn copied_minor_promotable_active_survivor_bytes() -> usize { @@ -1287,6 +1333,31 @@ pub(super) fn copied_minor_promotion_handoff_due(trigger_kind: GcTriggerKind) -> copied_minor_promotion_handoff_pressure_due(promotable, old_in_use, baseline) } +/// #7592: credit a copying minor's promoted bytes to the old-reclaim +/// baseline. +/// +/// Promoted bytes are live *by construction* — only marked-live objects get +/// copied — so a full mark-sweep fired the moment promotion pushes old-gen +/// over a threshold is guaranteed to find them all live and free nothing +/// (measured: a 2,100 ms full over 274 MB of just-promoted objects, 0.0 MB +/// freed). Treating the promotion delta as part of the "clean" baseline means +/// the next reclaim fires only after old-gen has *grown or churned* past it — +/// the proportional band (`gc_old_reclaim_growth_band_bytes`) then prices the +/// reclaim off the real live set. Pre-existing old garbage is unaffected: the +/// credit is exactly the promoted delta, never a resync to current in-use. +/// +/// The trade is the standard GOGC one: bytes that are promoted and then die +/// quickly now wait for the growth band instead of the next threshold +/// crossing. That is deliberate — a promoted-then-dead cohort big enough to +/// matter moves the band by its own size. +pub(super) fn credit_promoted_bytes_to_old_baseline(promoted_bytes: usize) { + if promoted_bytes == 0 { + return; + } + GC_LAST_OLD_RECLAIM_IN_USE_BYTES + .with(|bytes| bytes.set(bytes.get().saturating_add(promoted_bytes))); +} + pub(super) fn maybe_schedule_old_reclaim_after_copied_minor() { // #6010: external Map/Set side buffers count toward old-gen pressure — // a tenured-then-dead Map holds its multi-MB buffer until a full @@ -1924,7 +1995,9 @@ pub(super) fn gc_old_reclaim_debt_bytes(old_in_use: usize, baseline: usize) -> u let trigger = if baseline < gc_old_gen_reclaim_threshold_dyn_bytes() { gc_old_gen_reclaim_threshold_dyn_bytes() } else { - baseline.saturating_add(gc_old_gen_reclaim_growth_dyn_bytes()) + // Same proportional band as `old_reclaim_pressure_due` (#7592) — + // debt and dueness must share one trigger or they diverge. + baseline.saturating_add(gc_old_reclaim_growth_band_bytes(baseline)) }; old_in_use.saturating_sub(trigger) as u64 } diff --git a/crates/perry-runtime/src/gc/tenuring.rs b/crates/perry-runtime/src/gc/tenuring.rs index 400188d5fd..a41b73bedf 100644 --- a/crates/perry-runtime/src/gc/tenuring.rs +++ b/crates/perry-runtime/src/gc/tenuring.rs @@ -109,10 +109,39 @@ pub(super) fn tenuring_survivals() -> u8 { /// count AND promotes objects that a larger Eden would have let die young. /// The scale grows only while survivor influx stays a heavy fraction of /// Eden, so the small-live-set workloads #7377 fixed never leave 16 MB. +/// +/// #7592: the influx-driven product is the floor, not the whole answer — it +/// is bounded by `base × NURSERY_CAP_SCALE_MAX` (64 MB), and any *constant* +/// cap sets collection cadence independently of how much is live while each +/// collection's fixed cost is O(old-gen) (#6181: full-region sweep walk, +/// whole-heap remembered-set rebuild). Total young-GC work is then +/// `(alloc / cap) × O(live)` — quadratic in the live set. The effective cap +/// is therefore also proportional to the TENURED live set, which keeps work +/// per allocated byte bounded as the heap grows. +/// +/// The proportional term deliberately keys on old-gen occupancy, NOT total +/// arena in-use: the cap gates `young_scavenge_cap_due()` against from-space +/// occupancy, and from-space is part of arena in-use — a cap defined by a +/// total that includes the young generation is a fixed point from-space can +/// never cross, and scavenging stops entirely (measured on #7592: 0 copying +/// minors at 200k records, the nursery never evacuated). Old-gen is outside +/// the young generation, so no self-reference. Reclaimable pressure (in-use +/// minus free-list holes, #7437) rather than raw in-use, so dead-but-swept +/// old bytes do not inflate Eden. pub(super) fn scavenge_nursery_cap_effective_bytes() -> usize { - gc_scavenge_nursery_cap_bytes().saturating_mul(NURSERY_CAP_SCALE.with(Cell::get) as usize) + let influx_driven = + gc_scavenge_nursery_cap_bytes().saturating_mul(NURSERY_CAP_SCALE.with(Cell::get) as usize); + let tenured_proportional = old_gen_reclaimable_pressure_bytes() / TENURED_EDEN_DIVISOR; + influx_driven.max(tenured_proportional) } +/// #7592: divisor for the tenured-proportional nursery cap — Eden may grow to +/// half the tenured live set before a scavenge is forced. Peak young-gen RSS +/// contribution is therefore bounded at `tenured / 2`; the young collection +/// count is logarithmic in heap growth on promote-heavy workloads +/// (`old_{n+1} ≈ old_n × (1 + 1/2)`) instead of linear in bytes allocated. +const TENURED_EDEN_DIVISOR: usize = 2; + /// Target steady-state survivor occupancy: 1/16 of the effective nursery /// cap, so the tenuring dials track both the configured base and the /// influx-driven scale. diff --git a/crates/perry-runtime/src/gc/tests/triggers.rs b/crates/perry-runtime/src/gc/tests/triggers.rs index 84be4a90bb..d42080e2a2 100644 --- a/crates/perry-runtime/src/gc/tests/triggers.rs +++ b/crates/perry-runtime/src/gc/tests/triggers.rs @@ -200,30 +200,94 @@ fn test_old_reclaim_pressure_uses_threshold_and_growth() { )); } +/// #7592: the handoff full fires on CURRENT old-gen pressure only. It used to +/// add `promotable_bytes` into the pressure estimate — a prediction of where +/// old-gen would land after the promotion — but promotable bytes sit in the +/// survivor space, where a full mark-sweep can neither reclaim them nor the +/// old-gen space they have not yet occupied. A handoff over a near-empty +/// old-gen is guaranteed futile (measured: 1,015 ms over 4.2 MB, 0 freed). #[test] -fn test_copying_minor_promotion_handoff_uses_predicted_old_pressure() { +fn test_copying_minor_promotion_handoff_requires_current_old_pressure() { + // Below the handoff minimum: never due, whatever old-gen looks like. assert!(!copied_minor_promotion_handoff_pressure_due( GC_COPY_PROMOTION_HANDOFF_MIN_BYTES - 1, GC_OLD_GEN_RECLAIM_THRESHOLD_BYTES, 0, )); + // The #7592 shape: a huge imminent promotion over a near-empty old-gen. + // Predicted pressure said "due" here; a full over 4 MB frees nothing. + assert!(!copied_minor_promotion_handoff_pressure_due( + 108 * 1024 * 1024, + 4 * 1024 * 1024, + 0, + )); + // Current old-gen pressure is real (threshold crossing): due. assert!(copied_minor_promotion_handoff_pressure_due( GC_COPY_PROMOTION_HANDOFF_MIN_BYTES, - GC_OLD_GEN_RECLAIM_THRESHOLD_BYTES - GC_COPY_PROMOTION_HANDOFF_MIN_BYTES, + GC_OLD_GEN_RECLAIM_THRESHOLD_BYTES, 0, )); + // Current growth past the baseline exceeds the proportional band: due. assert!(copied_minor_promotion_handoff_pressure_due( 26 * 1024 * 1024, - 20 * 1024 * 1024, + 60 * 1024 * 1024, 8 * 1024 * 1024, )); + // Same growth, but the baseline's proportional band swallows it: not due. assert!(!copied_minor_promotion_handoff_pressure_due( 26 * 1024 * 1024, - 20 * 1024 * 1024, - 20 * 1024 * 1024, + 160 * 1024 * 1024, + 120 * 1024 * 1024, )); } +/// #7592: the proportional old-reclaim growth band and the promoted-bytes +/// baseline credit. +#[test] +fn test_old_reclaim_band_is_proportional_and_promotion_credits_baseline() { + // Small baselines keep the constant floor. + assert_eq!( + gc_old_reclaim_growth_band_bytes(0), + gc_old_gen_reclaim_growth_dyn_bytes() + ); + // Large baselines scale: the band is baseline/2 once that exceeds the + // floor, so major count is logarithmic in heap growth. + let big = 400 * 1024 * 1024; + assert_eq!(gc_old_reclaim_growth_band_bytes(big), big / 2); + // Dueness and debt share the band: at exactly band-1 past the baseline, + // not due and zero debt; at band, due and debt begins. + let baseline = big; + let band = gc_old_reclaim_growth_band_bytes(baseline); + assert!(!old_reclaim_pressure_due(baseline + band - 1, baseline)); + assert!(old_reclaim_pressure_due(baseline + band, baseline)); + // Debt counts bytes strictly PAST the trigger (pre-existing convention: + // due at the trigger with zero debt), but both must derive the trigger + // from the same proportional band. + assert_eq!(gc_old_reclaim_debt_bytes(baseline + band - 1, baseline), 0); + assert_eq!(gc_old_reclaim_debt_bytes(baseline + band + 1, baseline), 1); + + // Promotion credit: promoted bytes are live by construction, so a reclaim + // must not become due merely because promotion crossed a threshold. + let _guard = GcTestIsolationGuard::new(); + let prev = GC_LAST_OLD_RECLAIM_IN_USE_BYTES.with(|b| b.get()); + GC_LAST_OLD_RECLAIM_IN_USE_BYTES.with(|b| b.set(4 * 1024 * 1024)); + credit_promoted_bytes_to_old_baseline(270 * 1024 * 1024); + let credited = GC_LAST_OLD_RECLAIM_IN_USE_BYTES.with(|b| b.get()); + assert_eq!(credited, 274 * 1024 * 1024); + // The #7592 cycle-6 shape: old-gen jumped to 274 MB purely by promotion. + assert!( + !old_reclaim_pressure_due(274 * 1024 * 1024, credited), + "a reclaim right after promotion is guaranteed to free nothing" + ); + credit_promoted_bytes_to_old_baseline(0); + assert_eq!( + GC_LAST_OLD_RECLAIM_IN_USE_BYTES.with(|b| b.get()), + credited, + "zero promotion must not move the baseline" + ); + GC_LAST_OLD_RECLAIM_IN_USE_BYTES.with(|b| b.set(prev)); +} + /// #7592: a handoff full must not repeat without the copying minor it exists to /// enable. ///