diff --git a/CLAUDE.md b/CLAUDE.md index 1b20620c71..bc77440c37 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.1348 +**Current Version:** 0.5.1349 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index a0f62a037d..eb5855e594 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1348" +version = "0.5.1349" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1348" +version = "0.5.1349" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1348" +version = "0.5.1349" [[package]] name = "perry-ui-tvos" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1348" +version = "0.5.1349" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 555c483a4a..a50487d252 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1348" +version = "0.5.1349" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/7613-promote-on-first-copy.md b/changelog.d/7613-promote-on-first-copy.md new file mode 100644 index 0000000000..0176263dcb --- /dev/null +++ b/changelog.d/7613-promote-on-first-copy.md @@ -0,0 +1,77 @@ +### GC: long-lived cohorts are copied once, not twice — the promote-on-first-copy seed (#7598) + +#7592's remainder. On promote-heavy workloads every long-lived object was copied +**twice** — Eden → survivor by one copying minor, survivor → old by the next. +`json_pipeline` at 500k: cycle 3 copied 280,997,080 bytes into the survivor +space, cycle 4 promoted the same bytes out of it. + +`gc/tenuring.rs` already computed the right condition. The survival-rate lock +keys on `prev_copied`, so it needs a **previous copying minor** to have filled +the survivor space — it engaged for cycle 4, which is precisely why the waste +was confined to cycle 3. The obstacle was latency, not blindness. + +**Every collection that reaches the mark-sweep path already walks every Eden +header and classifies it live or dead.** Those are exactly the two blind spots +of `retune_after_scavenge`, which only copying minors feed — a full mark-sweep, +or a non-copying minor fallback. So the census is read there, and when the +surviving Eden cohort exceeds the desired survivor occupancy (the module's +*existing* occupancy rule, read from a different place) **and** ≥90% of the +classified Eden bytes were live, the existing `PROMOTE_LOCK` engages and the +next copying minor *enters* at S=1. Exit stays the influx-based unlock, so the +seed adds no new oscillation path. + +**Why the signal is not a fixed point of the policy reading it.** This issue +produced three self-referential signals before this one: `promoted_bytes` is +zero by construction at S=4; #7596's first nursery cap gated from-space +occupancy on a total that included from-space; #7594's handoff scheduled a +non-moving full to relieve pressure only a moving cycle can relieve. The Eden +live/dead split cannot have that structure — it is produced by the mark-sweep's +own arena walk, whose marks come from reachability, and neither the mark phase +nor the sweep reads `tenuring_survivals()`. The threshold is consulted in +exactly one place, `copying.rs`'s per-object move, which this path does not run. +Measured while S was still 4: `eden_live_bytes=279,964,968 eden_dead_bytes=896 +live_pct=99 seeds=true`. + +**Determinism (#7432)** is preserved by construction: written at the end of a +completed sweep, read at the entry of a later copying minor, which snapshots it +once. Two callsite exclusions keep the *input* sound as well — budgeted cycles +(allocate-black marks every mid-cycle birth, so a churn Eden would read ~100% +live) and cycles that ran the conservative native-stack scan (its retention +varies run to run, so a policy fed from it would make the gated copy/promote +counters non-deterministic). + +**Measured**, `json_pipeline`, output hash identical on every row: + +| records | arm | collections | copied_bytes | promoted_bytes | bytes moved | +|--:|---|--:|--:|--:|--:| +| 200k | before | 4 | 113,227,216 | 114,275,776 | 227,502,992 | +| 200k | after | 3 | **0** | 113,227,216 | **113,227,216** | +| 500k | before | 4 | 280,997,080 | 282,045,656 | 563,042,736 | +| 500k | after | 3 | **0** | 280,997,080 | **280,997,080** | + +Bytes moved **0.498× / 0.499×** — halved, which is the signature that separates +this from a cadence change. The per-cycle trace makes that explicit: cycles 1–3 +are unchanged in kind, trigger, `old_before` and `eden_live` — cycle 3 receives +the *same 280,997,080 bytes to the byte* and merely sends them to old-gen +instead of the survivor space. The cycle that disappears is the old cycle 4, +whose own Eden influx was 1.0 MB and whose entire content was the second copy. + +On the pinned quiet host (`perry-macos`, 5 interleaved reps, `cmp`-identical +output): 200k **1.85 s → 1.44 s (−22.2%)**, peak RSS 608.7 MB → 485.7 MB +(**−20.2%**); 500k **5.12 s → 3.86 s (−24.6%)**, peak RSS 1,404.5 MB → +1,109.8 MB (**−21.0%**). + +**RSS goes down, not up**, which is the opposite of the design note's +expectation. Promoting earlier does raise the old-gen high-water mark, but it +removes a larger term: at S=4 the 268 MB cohort exists twice at once at the +peak, as Eden from-space plus survivor to-space. This is the first change in +this campaign whose wall-time win is not traded against RSS. + +**gc-ratchet (`--check`, `pinned_host`, on the #7609 baseline):** OK. Every +semantic counter on all 12 probes is **bit-identical** to a `main`-arm run +measured in the same session on the same host — the only differing cells are +`rss_bytes`/`peak_rss_bytes`/`wall_ms`, all inside band and all moving in both +arms. The seed does not fire on any probe, and the diagnostic proves that is a +*refusal*, not an absence: `12_large_live_set` prints `live_pct=36 seeds=false`. +`12_large_live_set.wall_ms` — #7610's flagged cell — reads −13.11% on the +`main` arm and −13.14% on this one, so this change does not move it. diff --git a/crates/perry-runtime/src/gc/copying.rs b/crates/perry-runtime/src/gc/copying.rs index b308009f72..7653d42760 100644 --- a/crates/perry-runtime/src/gc/copying.rs +++ b/crates/perry-runtime/src/gc/copying.rs @@ -1272,6 +1272,11 @@ pub(super) fn gc_collect_minor_copying_fast_path_with_eligibility( deallocated_bytes: reset.deallocated_bytes, retained_forwarded_stub_objects: 0, retained_forwarded_stub_bytes: 0, + // The copying minor's Eden census is `stats.eden_live_bytes`, fed + // to `retune_after_scavenge` directly; the #7598 sweep seed covers + // the collections that run NO copying minor. + eden_live_bytes: 0, + eden_dead_bytes: 0, }; trace.pause_us = start.elapsed().as_micros() as u64; trace.capture_layout_scans(); diff --git a/crates/perry-runtime/src/gc/cycle.rs b/crates/perry-runtime/src/gc/cycle.rs index 890b3633cd..456459af96 100644 --- a/crates/perry-runtime/src/gc/cycle.rs +++ b/crates/perry-runtime/src/gc/cycle.rs @@ -1738,6 +1738,24 @@ impl GcCycleState { trace.old_pages = crate::arena::old_page_summary(); } self.sweep = Some(sweep); + // #7598: seed promote-on-first-copy from THIS completed collection. + // Every cycle reaching here is a full or a non-copying minor — the two + // blind spots of `retune_after_scavenge`, which only copying minors + // feed. Policy, the self-reference argument and the two exclusions + // below all live in `tenuring.rs`: allocate-black makes a budgeted + // cycle's Eden read as ~100% live, and a conservative-scan cycle's + // mark set is not a sound liveness measurement. + if !self.progress_kind.is_budgeted() + && matches!( + super::roots::conservative_stack_scan_decision(), + super::roots::ConservativeStackScanDecision::SkipDisabled + ) + { + super::tenuring::seed_promote_lock_from_sweep( + sweep.eden_live_bytes as usize, + sweep.eden_dead_bytes as usize, + ); + } self.phase = GcCyclePhase::Reclaim; } diff --git a/crates/perry-runtime/src/gc/oldgen.rs b/crates/perry-runtime/src/gc/oldgen.rs index 731f2385a4..dd74a480bf 100644 --- a/crates/perry-runtime/src/gc/oldgen.rs +++ b/crates/perry-runtime/src/gc/oldgen.rs @@ -119,6 +119,11 @@ pub(super) struct SweepTraceStats { pub(super) deallocated_bytes: usize, pub(super) retained_forwarded_stub_objects: usize, pub(super) retained_forwarded_stub_bytes: usize, + /// #7598: bytes this walk classified live / dead in the general (Eden) + /// blocks. Consumed by `tenuring::seed_promote_lock_from_sweep`, which + /// documents the policy and why the signal is not self-referential. + pub(super) eden_live_bytes: u64, + pub(super) eden_dead_bytes: u64, } pub(super) fn evacuation_policy_initial_decision( @@ -1056,6 +1061,10 @@ fn legacy_sweep_with_age_bump_and_old_reclaim_targets( deallocated_bytes: reset.deallocated_bytes, retained_forwarded_stub_objects, retained_forwarded_stub_bytes, + // Legacy unbudgeted path, not reached in production and not wired to + // the #7598 seed. + eden_live_bytes: 0, + eden_dead_bytes: 0, } } @@ -1225,6 +1234,8 @@ impl IncrementalSweepState { deallocated_bytes: reset.deallocated_bytes, retained_forwarded_stub_objects: self.arena.retained_forwarded_stub_objects, retained_forwarded_stub_bytes: self.arena.retained_forwarded_stub_bytes, + eden_live_bytes: self.arena.eden_live_bytes, + eden_dead_bytes: self.arena.eden_dead_bytes, }; self.subphase = SweepCycleSubphase::Done; return true; @@ -1286,6 +1297,9 @@ struct ArenaSweepObjectsState { freed_bytes: u64, retained_forwarded_stub_objects: usize, retained_forwarded_stub_bytes: usize, + /// #7598 Eden census: see `SweepTraceStats`. + eden_live_bytes: u64, + eden_dead_bytes: u64, } impl ArenaSweepObjectsState { @@ -1315,6 +1329,8 @@ impl ArenaSweepObjectsState { freed_bytes: 0, retained_forwarded_stub_objects: 0, retained_forwarded_stub_bytes: 0, + eden_live_bytes: 0, + eden_dead_bytes: 0, } } @@ -1444,6 +1460,9 @@ impl ArenaSweepObjectsState { if block_idx < self.block_has_live.len() { self.block_has_live[block_idx] = true; } + if block_idx < self.resettable_general_n { + self.eden_live_bytes = self.eden_live_bytes.saturating_add((*header).size as u64); + } if age_bump_this && flags & GC_FLAG_TENURED == 0 { if flags & GC_FLAG_HAS_SURVIVED != 0 { (*header).gc_flags = @@ -1507,6 +1526,9 @@ impl ArenaSweepObjectsState { } let user_ptr = (header as *mut u8).add(GC_HEADER_SIZE); self.freed_bytes = self.freed_bytes.saturating_add(total_size as u64); + if block_idx < self.resettable_general_n { + self.eden_dead_bytes = self.eden_dead_bytes.saturating_add(total_size as u64); + } finalize_dead_arena_payload(header, user_ptr, self.overflow_active); if self.reclaim_dead_old_blocks && dead_old { invalidate_dead_old_arena_header(header, total_size); diff --git a/crates/perry-runtime/src/gc/tenuring.rs b/crates/perry-runtime/src/gc/tenuring.rs index 4082e7e653..185bc6c81f 100644 --- a/crates/perry-runtime/src/gc/tenuring.rs +++ b/crates/perry-runtime/src/gc/tenuring.rs @@ -60,6 +60,81 @@ //! loop is always on, and its neutral state — influx below `desired`, //! survivor cohorts that die in place — computes S=4, which is //! bit-for-bit the previous fixed behaviour. +//! +//! ## Seeding the lock from a non-copying collection (#7598) +//! +//! The survival-rate lock above is correct but **one cycle late by +//! construction**: it keys on `prev_copied`, so a *previous copying minor* +//! must already have filled the survivor space. On a workload with one +//! long-lived burst (`json_pipeline`: `out.push({…})` 500k times) the first +//! copying minor therefore always pays the wasted copy — measured 268 MB +//! Eden→survivor on cycle 3 and the same 268 MB survivor→old on cycle 4, +//! ~3.9 s of a 5.1 s phase spent copying one cohort twice. +//! +//! Both blind spots named on #7592 are the same shape: `retune_after_scavenge` +//! is fed **only** by copying minors, so full mark-sweeps and non-copying +//! minor fallbacks feed the loop nothing, and once a workload escalates to +//! those the loop goes blind exactly when it is needed. +//! +//! [`seed_promote_lock_from_sweep`] closes that. Every cycle that reaches the +//! mark-sweep path — a full, or a non-copying minor fallback — already walks +//! every Eden header and classifies it live or dead. That walk yields the two +//! numbers the lock wants, one collection *earlier* than the survivor +//! round-trip can produce them. +//! +//! ### Why this signal is not a fixed point of the policy reading it +//! +//! This issue has already produced three self-referential signals, so the +//! argument is spelled out rather than assumed: +//! +//! * The Eden-side probe reached for `promoted_bytes`, which is **zero by +//! construction at S=4** — "was the promotion rate high?" can never answer +//! yes while S=4 holds, so it could never leave S=4. +//! * #7596's first nursery cap gated from-space occupancy on a total that +//! *included* from-space, so the cap was a bound from-space could never +//! cross and scavenging stopped entirely. +//! * #7594's handoff scheduled a *non-moving* full to relieve pressure only a +//! *moving* cycle can relieve, so the predicate was true again next cycle. +//! +//! The Eden live/dead split has none of that structure. It is produced by the +//! mark-sweep's own arena walk: marks come from reachability from roots, and +//! neither the mark phase nor the walk reads `tenuring_survivals()` — the +//! threshold is consulted only by `copying.rs`'s per-object move, which this +//! path does not run. Concretely, at both endpoints the signal stays +//! measurable and keeps its meaning: +//! +//! | state | what Eden holds at the sweep | signal | +//! |---|---|---| +//! | S=4 (state to leave) | everything that has not aged out | high live fraction on a retaining workload ⇒ says "lock" | +//! | S=1 (state to stay in) | only Eden allocated since the last promotion | still the mutator's retention of recent Eden ⇒ still measurable | +//! +//! Exit is deliberately **not** this signal. Entering hands over to the +//! existing `PROMOTE_LOCK`, whose unlock condition (Eden influx below +//! `desired/4` for two consecutive copying minors) is already +//! threshold-invariant and already tested — so the seed cannot introduce an +//! enter/exit oscillation of its own. +//! +//! ### Determinism (#7432) +//! +//! #7432 forbids re-deciding S *while objects are being moved*: the +//! copied/promoted split would then depend on root traversal order. The seed +//! is written at the END of a completed sweep and read at the ENTRY of a later +//! copying minor (`CopyingNurseryCollector::new` snapshots it once), so every +//! object in a cycle still sees exactly one threshold and the counters stay a +//! pure function of (heap state at entry, threshold at entry). +//! +//! Two exclusions at the callsite keep the *input* deterministic too, and both +//! are refusals rather than tuning: +//! +//! * **Budgeted cycles.** Whole-cycle allocate-black marks every mid-cycle +//! birth, and this walk reads MARKED as live — a churn workload's births +//! would read as a ~100% live Eden. Same reason the age-bump is suppressed +//! there (`cycle.rs`). +//! * **Cycles that ran the conservative native-stack scan.** That scan retains +//! whatever the stack happens to look like a pointer to, by an amount that +//! varies run to run (`benchmarks/gc_ratchet/README.md`). A liveness +//! *measurement* taken under it is not sound, and feeding it into a policy +//! would make the gated copy/promote counters non-deterministic. use super::*; @@ -289,6 +364,86 @@ fn retune_nursery_cap_scale(eden_live_bytes: usize) { } } +/// Minimum Eden survival rate, in tenths, for a mark-sweep to seed the +/// promote-on-first-copy lock: ≥90% of the Eden bytes the sweep classified +/// must have been live. That is the "the aging round would filter nothing" +/// proof, measured directly instead of inferred from a survivor round-trip. +const FULL_SEED_LIVE_TENTHS: usize = 9; + +/// Would a completed mark-sweep's Eden census justify promote-on-first-copy? +/// +/// Pure function of the census and the survivor target so the policy is +/// testable without arranging a heap (the #7024 shape: a green test whose +/// subject never ran — see the sibling `scavenge_nursery_cap_from`). +/// +/// Two independent conditions, both required, each answering a different +/// question: +/// +/// 1. **Occupancy** — `compute_target_survivals(...) == 1`, i.e. the surviving +/// cohort alone already exceeds the desired survivor occupancy, so at any +/// S ≥ 2 it cannot fit and would be re-copied. This is deliberately the +/// module's *existing* rule; the only new thing is where the number is read +/// from. +/// 2. **Survival rate** — nearly nothing in Eden died, so a survivor round +/// would filter nothing. Without this an Eden that merely happens to be +/// large would promote its garbage too. +/// +/// The stated failure mode (design note on #7592): a normally churn-heavy +/// program whose nursery is atypically mostly-live at one sweep promotes one +/// Eden's worth of short-lived objects and pays an old-gen reclaim to get them +/// back. Exposure is bounded by one nursery cap and by the existing unlock +/// path; requiring BOTH conditions is what keeps it narrow. +pub(super) fn full_seed_promotes_on_first_copy( + eden_live_bytes: usize, + eden_dead_bytes: usize, + desired_bytes: usize, +) -> bool { + if compute_target_survivals(eden_live_bytes, desired_bytes) != 1 { + return false; + } + let classified = eden_live_bytes.saturating_add(eden_dead_bytes); + classified > 0 + && eden_live_bytes.saturating_mul(10) >= classified.saturating_mul(FULL_SEED_LIVE_TENTHS) +} + +/// Feed one finished mark-sweep's Eden census into the loop. `eden_live_bytes` +/// and `eden_dead_bytes` are the bytes the sweep walk classified live and dead +/// in the general (Eden) blocks. +/// +/// Callers must exclude budgeted cycles and cycles that ran the conservative +/// native-stack scan — see the module header for why those two inputs are not +/// sound liveness measurements. +pub(super) fn seed_promote_lock_from_sweep(eden_live_bytes: usize, eden_dead_bytes: usize) { + let already_locked = PROMOTE_LOCK.with(Cell::get); + let desired = desired_survivor_bytes(); + let seeds = full_seed_promotes_on_first_copy(eden_live_bytes, eden_dead_bytes, desired); + // Diagnostic, not a knob: print the census AND the verdict on every + // mark-sweep, including refusals. A policy that silently declines is + // indistinguishable from one that never ran (#7024/#7025), and the + // refusal reason is the number a future tuning decision needs. + if std::env::var_os("PERRY_GC_DIAG").is_some() { + let classified = eden_live_bytes.saturating_add(eden_dead_bytes); + let pct = if classified == 0 { + 0 + } else { + eden_live_bytes * 100 / classified + }; + eprintln!( + "[gc-tenuring] sweep-seed eden_live_bytes={eden_live_bytes} eden_dead_bytes={eden_dead_bytes} live_pct={pct} desired={desired} seeds={seeds} already_locked={already_locked}" + ); + } + if already_locked || !seeds { + return; + } + let current = TENURING_SURVIVALS.with(Cell::get); + PROMOTE_LOCK.with(|l| l.set(true)); + UNLOCK_STREAK.with(|s| s.set(0)); + RAISE_STREAK.with(|s| s.set(0)); + // PREV_COPIED_BYTES is deliberately untouched: it is the survival-rate + // lock's denominator, owned by the copying path. + set_survivals(current, 1, eden_live_bytes, "sweep-seed"); +} + fn diag_cap_scale(from: u8, to: u8, eden_live_bytes: usize) { if std::env::var_os("PERRY_GC_DIAG").is_some() { eprintln!( @@ -546,6 +701,120 @@ mod tests { ); } + // ── #7598's mark-sweep seed ───────────────────────────────────────── + // + // The lock above cannot engage before the SECOND copying minor. These + // exercise the seed that reads the same proof off a completed mark-sweep, + // one collection earlier. + + #[test] + fn sweep_seed_decides_before_the_first_copying_minor_snapshots_the_threshold() { + // `copying.rs` snapshots `tenuring_survivals()` in + // `CopyingNurseryCollector::new`, so the only value that can change + // what the first big minor does is the one standing BEFORE any + // `retune_after_scavenge` for that cycle has run. That is precisely + // what the survival-rate lock cannot reach and this seed can. + reset_for_test(); + let d = desired_survivor_bytes(); + let eden_live = d * 4; + assert_eq!( + tenuring_survivals(), + 4, + "with no input the loop is at the ceiling: the wasted copy state" + ); + + seed_promote_lock_from_sweep(eden_live, eden_live / 50); + assert_eq!( + tenuring_survivals(), + 1, + "the copying minor must ENTER at S=1, not be retuned to it afterwards" + ); + reset_for_test(); + } + + #[test] + fn sweep_seed_refuses_a_churn_eden() { + // The stated failure mode, guarded: a big Eden is not a survival + // signal. #7592 recorded exactly this trap ("Eden in-use at cycle + // start is not a survival signal — churn workloads also overshoot + // Eden, with ~0% survival"). Occupancy alone would say S=1 here. + reset_for_test(); + let d = desired_survivor_bytes(); + let eden_live = d * 4; + let eden_dead = eden_live * 9; + assert_eq!( + compute_target_survivals(eden_live, d), + 1, + "precondition: occupancy alone says lock here, so this test is \ + exercising the survival-rate half and not passing vacuously" + ); + seed_promote_lock_from_sweep(eden_live, eden_dead); + assert_eq!( + tenuring_survivals(), + 4, + "10% Eden survival must not seed promote-on-first-copy" + ); + reset_for_test(); + } + + #[test] + fn sweep_seed_refuses_a_small_fully_live_eden() { + // The other half: a nursery that is 100% live but far under the + // survivor target fits the survivor space, so aging still filters and + // the ladder must decide. Right after a scavenge this is the NORMAL + // reading, and seeding off it would lock every program at S=1. + reset_for_test(); + let d = desired_survivor_bytes(); + seed_promote_lock_from_sweep(d / 8, 0); + assert_eq!(tenuring_survivals(), 4); + reset_for_test(); + } + + #[test] + fn sweep_seed_rule_is_a_pure_function_of_the_census() { + // Table over the policy itself, with no heap state involved — the + // sibling `scavenge_nursery_cap_from` exists for the same reason + // (#7024: a test whose subject never ran). + let d = 1024 * 1024; + // Both conditions met. + assert!(full_seed_promotes_on_first_copy(4 * d, d / 10, d)); + // Exactly at the 90% survival boundary: 9 live, 1 dead. + assert!(full_seed_promotes_on_first_copy(9 * d, d, d)); + // One byte under it. + assert!(!full_seed_promotes_on_first_copy(9 * d - 1, d + 1, d)); + // Occupancy says the cohort fits the survivor space. + assert!(!full_seed_promotes_on_first_copy(d / 2, 0, d)); + // An empty census decides nothing (and must not divide by zero). + assert!(!full_seed_promotes_on_first_copy(0, 0, d)); + assert!(!full_seed_promotes_on_first_copy(0, 4 * d, d)); + } + + #[test] + fn sweep_seed_hands_over_to_the_existing_unlock_path() { + // The seed sets the lock and nothing else: exit stays the influx + // signal, which is measurable at S=1 (survivor occupancy is not). + // Without this the seed would need an exit condition of its own, and + // the obvious one — "Eden stopped being mostly live" — reads + // differently at S=1 than at S=4 and would oscillate. + reset_for_test(); + let d = desired_survivor_bytes(); + seed_promote_lock_from_sweep(d * 4, 0); + assert_eq!(tenuring_survivals(), 1); + + // Substantial influx holds the lock, exactly as if it had been set by + // the survivor round-trip. + for _ in 0..4 { + retune_after_scavenge(d * 4, 0, 0); + assert_eq!(tenuring_survivals(), 1); + } + // Quiet influx exits after the same debounce, to the same S=2. + retune_after_scavenge(0, 0, 0); + assert_eq!(tenuring_survivals(), 1); + retune_after_scavenge(0, 0, 0); + assert_eq!(tenuring_survivals(), 2); + reset_for_test(); + } + #[test] fn effective_nursery_cap_is_the_two_term_policy() { // Wiring pin: the effective accessor must be the composition, so a diff --git a/crates/perry-runtime/src/gc/tests/incremental_sweep_reclaim.rs b/crates/perry-runtime/src/gc/tests/incremental_sweep_reclaim.rs index 093adfd461..a057d57349 100644 --- a/crates/perry-runtime/src/gc/tests/incremental_sweep_reclaim.rs +++ b/crates/perry-runtime/src/gc/tests/incremental_sweep_reclaim.rs @@ -655,3 +655,89 @@ fn budgeted_reclaim_runs_process_malloc_trim() { Some("executed") | Some("unsupported") )); } + +#[test] +fn full_sweep_eden_census_counts_only_nursery_blocks() { + // #7598: `tenuring::seed_promote_lock_from_sweep` decides promote-on- + // first-copy from this census, so a census with a plausible-but-wrong + // answer would fire the policy on the wrong workloads and never show up + // as a crash. Three assertions, one per way it can be wrong: + // + // 1. live Eden bytes are counted at all; + // 2. dead Eden bytes are counted SEPARATELY, not folded into live + // (folding them makes the survival-rate test read 100% always); + // 3. old-gen live bytes are counted in NEITHER. Dropping the + // `block_idx < resettable_general_n` gate in `keep_live_object` + // would add this 4 MB old-gen object to `eden_live_bytes`, and any + // program with a large tenured set would then seed S=1 forever. + // + // The old-gen object is deliberately an order of magnitude larger than + // everything in the nursery so assertion 3 cannot pass by accident. + let _legacy_pacing = crate::gc::policy::force_legacy_gc_pacing(); + let _guard = CopyingNurseryTestGuard::new(1); + let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + clear_marks(); + clear_mark_seeds(); + crate::arena::old_pages_begin_gc_cycle(); + + const CHUNK: usize = 8 * 1024; + const LIVE_CHUNKS: usize = 32; + const DEAD_CHUNKS: usize = 16; + const OLD_BYTES: usize = 4 * 1024 * 1024; + + let mut live_nursery_bytes = 0usize; + for _ in 0..LIVE_CHUNKS { + let ptr = crate::arena::arena_alloc_gc(CHUNK, 8, GC_TYPE_STRING); + unsafe { + let header = header_from_user_ptr(ptr as *const u8); + (*header).gc_flags |= GC_FLAG_MARKED; + live_nursery_bytes += (*header).size as usize; + } + } + let mut dead_nursery_bytes = 0usize; + for _ in 0..DEAD_CHUNKS { + let ptr = crate::arena::arena_alloc_gc(CHUNK, 8, GC_TYPE_STRING); + unsafe { + dead_nursery_bytes += (*header_from_user_ptr(ptr as *const u8)).size as usize; + } + } + + // A LIVE old-gen resident: marked, so the sweep keeps it. This is the + // bulk of the heap and must not reach the Eden census. + let old = crate::arena::arena_alloc_gc_old(OLD_BYTES, 8, GC_TYPE_STRING); + let old_bytes = unsafe { + let header = header_from_user_ptr(old as *const u8); + (*header).gc_flags |= GC_FLAG_MARKED; + (*header).size as usize + }; + assert!( + old_bytes > live_nursery_bytes * 4, + "the old-gen resident must dominate the nursery for assertion 3 to bite" + ); + + // A FULL sweep: minor_sweep = false, so unmarked really means dead. + let mut sweep = IncrementalSweepState::new(false, true, None, true, false); + let stats = complete_incremental_sweep(&mut sweep); + + assert!( + stats.eden_live_bytes as usize >= live_nursery_bytes, + "marked nursery bytes must be counted live (got {}, expected >= {live_nursery_bytes})", + stats.eden_live_bytes + ); + assert!( + stats.eden_dead_bytes as usize >= dead_nursery_bytes, + "unmarked nursery bytes must be counted dead (got {}, expected >= {dead_nursery_bytes})", + stats.eden_dead_bytes + ); + assert!( + (stats.eden_live_bytes as usize) < old_bytes, + "old-gen live bytes must not reach the Eden census: eden_live_bytes={} \ + but the single old-gen resident alone is {old_bytes}", + stats.eden_live_bytes + ); + assert!( + (stats.eden_dead_bytes as usize) < old_bytes, + "old-gen bytes must not reach the Eden dead census either (got {})", + stats.eden_dead_bytes + ); +}