diff --git a/CLAUDE.md b/CLAUDE.md index fbfff02673..33850c1792 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.1426 +**Current Version:** 0.5.1427 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index a34a2d893d..d68b86a9a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1426" +version = "0.5.1427" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1426" +version = "0.5.1427" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1426" +version = "0.5.1427" [[package]] name = "perry-ui-tvos" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1426" +version = "0.5.1427" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 5b083490b6..13413226ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1426" +version = "0.5.1427" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/changelog.d/7733-retain-live-set-major-pacing.md b/changelog.d/7733-retain-live-set-major-pacing.md new file mode 100644 index 0000000000..39bc04d779 --- /dev/null +++ b/changelog.d/7733-retain-live-set-major-pacing.md @@ -0,0 +1,85 @@ +### gc: a heap where nothing dies stopped being marked over and over + +`gc-handoff/bench/retain.ts` builds a 3 M-element array of `{a, b}` records, +keeps every one of them live, then sums a field. **Nothing is ever garbage**, +and Perry spent **1.26 s of a 1.31 s run inside the collector** (96%). Node +does it in 0.13 s. Two independent causes, both measured on the pinned quiet +M1 against `c156f8a41`. + +#### Two full mark-sweeps that reclaimed 4 MB between them + +`PERRY_GC_TRACE=1 ./n_retain` before: + +| cycle | kind | pause | arena in-use before → after | +|---|---|--:|---| +| 3 | **full** | 160.6 ms | 67 → 63 MB | +| 6 | **full** | 483.5 ms | 204 → 204 MB | + +`arena_growth_full_escalation_due` escalates a minor to a full once arena +live-bytes pass K× (K = 2) the live set measured after the last full. Its own +doc-comment claimed that "a workload with a legitimately large *stable* live +set (retain-style) does not over-escalate — its arena hovers near its own +baseline". `retain`'s live set is not stable, it **grows**: every doubling +crosses the threshold, and each resulting full marks a heap where nothing has +died. 644 ms of pause for 4 MB, and the second full moved arena in-use by +exactly zero. + +So price a full by what it reclaims. A full that shrinks arena in-use by less +than `MAJOR_PACING_PRODUCTIVE_YIELD_PCT` (20%) shifts the next escalation +threshold left by one — capped at 2, so the multiplier tops out at 8× — and a +productive full resets the shift to 0 in one step. The yield is deliberately +measured on the **same** metric the escalation gate reads, so the two cannot +disagree about whether a full helped, and it is deliberately scoped to +*escalated* fulls: an explicit `gc()` never moves the backoff, or a +`gc()`-in-a-loop test would drive it straight to the cap. Old-gen garbage is +unaffected either way — `old_reclaim_pressure_due` still forces its own full. + +Churn-shaped workloads reclaim most of the heap on every full, hold the shift +at 0, and are paced exactly as before. + +#### Every evacuated object took a process-global mutex to hash an empty map + +`object_static_prototype_owner_moved` — the `ObjectOverflowFields` move hook, +run once per moved object — took a `Mutex>` and ran a +SipHash `remove` against the residual `Object.setPrototypeOf` registry. + +That registry is empty in any program that never re-prototypes a +non-meta-capable owner, and a latch already says so: `OBJECT_PROTOTYPES_NONEMPTY`, +stored `Release` *before* the insert, and read by both siblings +(`object_static_prototype`, `prune_dead_object_prototype_owners`). The move +hook was the one reader that skipped it, so a 3 M-record promotion paid 2.5 M +lock/unlock pairs and 2.5 M hash probes against an empty map. That is why a +single-threaded benchmark profiled with `pthread_mutex_lock` and +`std::hash::random::RandomState` in its top ten. + +#### The bug this nearly shipped as + +The first cut recorded the pre-full arena reading at the two +`gc_start_budgeted_cycle_for_pressure` call sites. Both correct, both the wrong +sites: the escalation the shipped safepoint path actually takes lives in +`gc::gc_collect_minor_with_trigger_inner`, so the reading was never recorded, +`update_major_pacing_backoff` early-returned on every cycle, and the change +measured **1.31 s → 1.28 s** — the mutex fix alone — with every test green and +the decision function correct in isolation. + +The recording now happens **inside** `arena_growth_full_escalation_due` on the +`true` verdict, so a call site added later is priced by construction, and the +GC trace gained a `major_pacing` block (`baseline_bytes`, `backoff_shift`, +`escalate_above_bytes`) so the subject can be asserted live rather than +inferred from a green run. That block is what showed the backoff sitting at 0 +through all seven cycles. + +#### Not fixed here, measured and named + +`retain` is still ~5× Node, and the residue is one number: **~220 ns of +per-object bookkeeping for every promoted object** (2.5 M promotions on this +workload), spread over `arena_alloc_gc_old`, `layout_transfer`, +`old_page_account_promoted_object`, the move hooks, and two page-generation +classifications per visited slot. The structural answer is V8-style whole-page +promotion — relabel a nearly-all-live Eden block as old-gen instead of +evacuating it object by object — which needs none of that per-object work. +Second on the list: `gc::verify::restore_surviving_dirty_coverage` walks +**every** slot of a parent on a pre-cycle dirty page, where the scan it repairs +(`scan_dirty_object_slots`) walks only the slots on dirty pages — 8.8% of a +pure-retain profile, re-walking the whole 3 M-element backing store on every +minor. diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index 8c6a43ab0a..1451183889 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -865,6 +865,26 @@ thread_local! { /// Total arena in-use bytes measured right after the last FULL mark-sweep — /// the baseline for major-GC pacing (`arena_growth_full_escalation_due`). pub(super) static GC_LAST_FULL_ARENA_IN_USE_BYTES: Cell = const { Cell::new(0) }; + /// Total arena in-use bytes measured when the last FULL mark-sweep STARTED. + /// Paired with `GC_LAST_FULL_ARENA_IN_USE_BYTES` to price what that full + /// actually reclaimed — see `GC_MAJOR_PACING_BACKOFF_SHIFT`. + pub(super) static GC_FULL_CYCLE_PRE_IN_USE_BYTES: Cell = const { Cell::new(0) }; + /// Yield-adaptive backoff for major-GC pacing (#7726). + /// + /// `arena_growth_full_escalation_due` escalates a minor to a full once the + /// arena's live bytes pass K× the last full's live set. On a workload whose + /// live set genuinely GROWS — every record retained, nothing to reclaim — + /// that gate fires on the growth itself and buys nothing: measured on + /// `gc-handoff/bench/retain.ts`, the two escalated fulls cost 644 ms of a + /// 1.31 s run and moved arena in-use by 4 MB total, the second one by zero. + /// + /// So price each full by what it reclaimed and shift K left when the answer + /// is "almost nothing". A churn workload's fulls reclaim most of the heap, + /// keep the shift at 0, and pace exactly as before. The shift is capped and + /// resets on the first productive full, and it does not touch the + /// `OldReclaim` escalation — old-gen garbage still forces a full through + /// `old_reclaim_pressure_due` regardless of this backoff. + pub(super) static GC_MAJOR_PACING_BACKOFF_SHIFT: Cell = const { Cell::new(0) }; /// Re-entrancy guard for the #5476 direct old-gen reclaim driven from /// `gc_check_trigger`: the full collection must not recursively trigger /// another reclaim if a hook it runs allocates. @@ -1475,10 +1495,118 @@ pub(super) fn finish_full_old_reclaim_baseline() { // Record the TOTAL post-full live set for major-GC pacing (young+old): the // full sweep is the only collection that frees forwarding stubs, so this is // the "clean" size the arena returns to and the base for the K× growth gate. - GC_LAST_FULL_ARENA_IN_USE_BYTES.with(|bytes| bytes.set(crate::arena::arena_in_use_bytes())); + let post_in_use = crate::arena::arena_in_use_bytes(); + GC_LAST_FULL_ARENA_IN_USE_BYTES.with(|bytes| bytes.set(post_in_use)); + update_major_pacing_backoff(post_in_use); GC_OLD_RECLAIM_PENDING.with(|pending| pending.set(false)); } +/// Percent of the pre-full live set a full must reclaim to count as productive. +/// Below this the next arena-growth escalation is pushed out (see +/// `GC_MAJOR_PACING_BACKOFF_SHIFT`). +/// Deliberately low. A full that reclaims 20% of the heap is still doing real +/// work, and pushing its successor out would carry that garbage twice as long; +/// the shape this exists for reclaims single-digit percent (5.9% and 0.0% on +/// `retain.ts`). Raising it trades RSS for pause time. +const MAJOR_PACING_PRODUCTIVE_YIELD_PCT: usize = 20; + +/// Cap on the backoff shift: the escalation multiplier tops out at +/// `growth_num << 2`, i.e. 8× with the default `growth_num` of 2. Bounded so a +/// long run of low-yield fulls cannot disable arena-growth pacing outright. +const MAJOR_PACING_BACKOFF_SHIFT_MAX: u32 = 2; + +/// Record what the just-finished full reclaimed and adjust the pacing backoff. +/// +/// `pre` is the arena in-use reading captured when the full cycle started +/// (`note_full_cycle_started`); a full that shrinks it by less than +/// `MAJOR_PACING_PRODUCTIVE_YIELD_PCT` shifts the next escalation threshold +/// left by one, a productive full resets the shift to 0. Deliberately measured +/// on the SAME metric the escalation gate reads (`arena_in_use_bytes`) so the +/// two cannot disagree about whether a full helped. +fn update_major_pacing_backoff(post_in_use: usize) { + let pre_in_use = GC_FULL_CYCLE_PRE_IN_USE_BYTES.with(|bytes| bytes.get()); + if pre_in_use == 0 { + // No start reading (a full driven from a path that does not announce + // itself): leave the shift alone rather than guess a yield. + return; + } + GC_FULL_CYCLE_PRE_IN_USE_BYTES.with(|bytes| bytes.set(0)); + let reclaimed = pre_in_use.saturating_sub(post_in_use); + let productive = + reclaimed.saturating_mul(100) / pre_in_use >= MAJOR_PACING_PRODUCTIVE_YIELD_PCT; + GC_MAJOR_PACING_BACKOFF_SHIFT.with(|shift| { + if productive { + shift.set(0); + } else { + shift.set( + shift + .get() + .saturating_add(1) + .min(MAJOR_PACING_BACKOFF_SHIFT_MAX), + ); + } + }); +} + +/// Announce the start of an ESCALATED full mark-sweep so +/// `update_major_pacing_backoff` can price what it reclaimed. Called only from +/// `arena_growth_full_escalation_due`, so an explicit `gc()` — whose yield says +/// nothing about arena-growth pacing, and whose repeated use would otherwise +/// drive the shift to its cap — never moves the backoff. +fn note_full_cycle_started() { + GC_FULL_CYCLE_PRE_IN_USE_BYTES.with(|bytes| bytes.set(crate::arena::arena_in_use_bytes())); +} + +/// Current arena-growth escalation backoff shift. +#[cfg(any(feature = "diagnostics", test))] +pub(super) fn major_pacing_backoff_shift() -> u32 { + GC_MAJOR_PACING_BACKOFF_SHIFT.with(|shift| shift.get()) +} + +/// `(post-full baseline bytes, backoff shift, arena in-use bytes needed to +/// escalate the next minor to a full)` — emitted in the GC trace so a gate can +/// prove the backoff actually engaged rather than merely that nothing threw. +/// A zero baseline means no full has run yet, and the threshold is reported as +/// 0 because the `baseline == 0` clause escalates unconditionally. +#[cfg(feature = "diagnostics")] +pub(super) fn major_pacing_snapshot() -> (usize, u32, usize) { + let (_floor, growth_num) = major_pacing_config(); + let baseline = GC_LAST_FULL_ARENA_IN_USE_BYTES.with(|bytes| bytes.get()); + let shift = major_pacing_backoff_shift(); + let threshold = baseline.saturating_mul(growth_num.saturating_mul(1usize << shift)); + (baseline, shift, threshold) +} + +#[cfg(test)] +pub(super) fn test_reset_major_pacing_backoff() { + GC_MAJOR_PACING_BACKOFF_SHIFT.with(|shift| shift.set(0)); + GC_FULL_CYCLE_PRE_IN_USE_BYTES.with(|bytes| bytes.set(0)); +} + +/// The pre-full arena reading `arena_growth_full_escalation_due` recorded, or 0 +/// if it declined to escalate. Non-zero is the proof that the escalation is +/// PRICED — without it the backoff cannot fire and the pacing silently reverts +/// to the unconditional K× rule. +#[cfg(test)] +pub(super) fn test_major_pacing_pre_in_use_bytes() -> usize { + GC_FULL_CYCLE_PRE_IN_USE_BYTES.with(|bytes| bytes.get()) +} + +#[cfg(test)] +pub(super) fn test_set_major_pacing_baseline(bytes: usize) -> usize { + GC_LAST_FULL_ARENA_IN_USE_BYTES.with(|cell| { + let previous = cell.get(); + cell.set(bytes); + previous + }) +} + +#[cfg(test)] +pub(super) fn test_note_full_cycle_reclaimed(pre_in_use: usize, post_in_use: usize) { + GC_FULL_CYCLE_PRE_IN_USE_BYTES.with(|bytes| bytes.set(pre_in_use)); + update_major_pacing_backoff(post_in_use); +} + pub(super) fn gc_bump_malloc_trigger_with_snapshot(current: usize, bytes_now: usize) -> bool { let step = GC_MALLOC_COUNT_STEP.with(|c| c.get()); GC_NEXT_MALLOC_TRIGGER.with(|c| c.set(current + step)); @@ -2404,11 +2532,13 @@ pub(super) fn test_start_budgeted_minor_fallback_state_with_trace( /// pay for a full, and by the K× ratio so a workload with a legitimately large /// *stable* live set (retain-style) does not over-escalate — its arena hovers /// near its own baseline, well under K×. -pub(super) fn arena_growth_full_escalation_due() -> bool { - // Config is parsed ONCE — this runs on the minor-GC path, so no per-call - // env lookup / parse / String alloc. The env vars are for tuning and - // measurement (read at process start); defaults chosen so churn oscillates - // ~baseline..2×baseline and stays below node's peak. +/// `(floor_bytes, growth_num)` for major-GC pacing. +/// +/// Parsed ONCE — this runs on the minor-GC path, so no per-call env lookup / +/// parse / String alloc. The env vars are for tuning and measurement (read at +/// process start); defaults chosen so churn oscillates ~baseline..2×baseline +/// and stays below node's peak. +fn major_pacing_config() -> (usize, usize) { use std::sync::OnceLock; static CONFIG: OnceLock<(usize, usize)> = OnceLock::new(); let &(floor_bytes, growth_num) = CONFIG.get_or_init(|| { @@ -2426,6 +2556,29 @@ pub(super) fn arena_growth_full_escalation_due() -> bool { .unwrap_or(DEFAULT_GROWTH_NUM); (floor_bytes, growth_num) }); + (floor_bytes, growth_num) +} + +/// Every caller acts on a `true` by running a FULL immediately, so the pre-full +/// arena reading is recorded HERE rather than at the call sites — that is what +/// keeps the escalation and the pricing of its result from drifting apart. +/// +/// The first cut of #7726 wired the two `gc_start_budgeted_cycle_for_pressure` +/// sites by hand and missed the one in `gc::gc_collect_minor_with_trigger_inner` +/// — which is the site the shipped safepoint path actually takes. The backoff +/// then never fired, and the whole change measured as a 30 ms no-op on +/// `retain.ts` while every test still passed. Recording inside the predicate +/// makes a future call site correct by construction. +pub(super) fn arena_growth_full_escalation_due() -> bool { + let due = arena_growth_full_escalation_due_inner(); + if due { + note_full_cycle_started(); + } + due +} + +fn arena_growth_full_escalation_due_inner() -> bool { + let (floor_bytes, growth_num) = major_pacing_config(); if floor_bytes == 0 { return false; // PERRY_GC_MAJOR_PACING_FLOOR_MB=0 disables the pacing } @@ -2435,7 +2588,15 @@ pub(super) fn arena_growth_full_escalation_due() -> bool { } let baseline = GC_LAST_FULL_ARENA_IN_USE_BYTES.with(|bytes| bytes.get()); // No full yet (baseline 0): bound the initial growth once we clear the floor. - baseline == 0 || in_use > baseline.saturating_mul(growth_num) + if baseline == 0 { + return true; + } + // Yield-adaptive: a full that reclaimed almost nothing pushes the next + // escalation out (`GC_MAJOR_PACING_BACKOFF_SHIFT`). Shift the multiplier, + // not the baseline, so one productive full restores the original pacing. + let shift = GC_MAJOR_PACING_BACKOFF_SHIFT.with(|shift| shift.get()); + let growth = growth_num.saturating_mul(1usize << shift); + in_use > baseline.saturating_mul(growth) } fn gc_start_budgeted_cycle_for_pressure(progress_kind: GcProgressKind) -> Option { diff --git a/crates/perry-runtime/src/gc/telemetry.rs b/crates/perry-runtime/src/gc/telemetry.rs index 61d48cc690..4271d4e54e 100644 --- a/crates/perry-runtime/src/gc/telemetry.rs +++ b/crates/perry-runtime/src/gc/telemetry.rs @@ -1116,6 +1116,13 @@ impl GcCycleTrace { let allocator_maintenance_json = allocator_maintenance_json(self.allocator_maintenance, self.progress_kind); let steps_value = steps_json(self.steps_before, steps_after); + let (pacing_baseline, pacing_shift, pacing_threshold) = + super::policy::major_pacing_snapshot(); + let major_pacing_json = serde_json::json!({ + "baseline_bytes": pacing_baseline, + "backoff_shift": pacing_shift, + "escalate_above_bytes": pacing_threshold, + }); serde_json::json!({ "event": "gc_cycle", "collection_kind": self.collection_kind.as_str(), @@ -1148,6 +1155,7 @@ impl GcCycleTrace { "debt": debt_json, "allocator_maintenance": allocator_maintenance_json, "steps": steps_value, + "major_pacing": major_pacing_json, }) } diff --git a/crates/perry-runtime/src/gc/tests/triggers.rs b/crates/perry-runtime/src/gc/tests/triggers.rs index 06642565a8..b7a81ab507 100644 --- a/crates/perry-runtime/src/gc/tests/triggers.rs +++ b/crates/perry-runtime/src/gc/tests/triggers.rs @@ -667,3 +667,113 @@ fn polls_default_matches_codegen_mirror() { ); } } + +// ─────────────────────────────────────────────────────────────────────────── +// Yield-adaptive major-GC pacing (#7726). +// +// `arena_growth_full_escalation_due` escalates a minor to a FULL once arena +// live-bytes pass K× the last full's live set. On a monotonically growing +// all-live heap that gate fires on the growth itself and reclaims ~nothing: +// measured on `gc-handoff/bench/retain.ts`, the two escalated fulls cost 644 ms +// of a 1.31 s run and moved arena in-use by 4 MB total — the second by zero. +// So price each full by what it reclaimed and push the next escalation out when +// the answer is "almost nothing". +// +// These assert the DECISION FUNCTION over recorded (pre, post) pairs, which is +// the part a benchmark cannot pin: a green retain time proves the backoff fired +// on that one shape, not that a productive full still resets it. +// ─────────────────────────────────────────────────────────────────────────── +#[test] +fn major_pacing_backoff_defaults_to_zero_and_needs_a_recorded_cycle_start() { + use super::super::policy::{ + major_pacing_backoff_shift, test_note_full_cycle_reclaimed, test_reset_major_pacing_backoff, + }; + test_reset_major_pacing_backoff(); + assert_eq!( + major_pacing_backoff_shift(), + 0, + "default pacing must be exactly today's: escalate at K× the baseline" + ); + // A full with no recorded start (`note_full_cycle_started` never ran — an + // explicit `gc()`, not an arena-growth escalation) must not move the shift: + // its yield says nothing about the gate this backoff paces. + test_note_full_cycle_reclaimed(0, 0); + assert_eq!(major_pacing_backoff_shift(), 0); + test_reset_major_pacing_backoff(); +} + +#[test] +fn major_pacing_backs_off_on_futile_fulls_and_resets_on_a_productive_one() { + use super::super::policy::{ + major_pacing_backoff_shift, test_note_full_cycle_reclaimed, test_reset_major_pacing_backoff, + }; + const MB: usize = 1024 * 1024; + test_reset_major_pacing_backoff(); + + // retain.ts's own two fulls: 67 MB → 63 MB (5.9%) and 204 MB → 204 MB (0%). + test_note_full_cycle_reclaimed(67 * MB, 63 * MB); + assert_eq!( + major_pacing_backoff_shift(), + 1, + "a 5.9%-yield full backs off" + ); + test_note_full_cycle_reclaimed(204 * MB, 204 * MB); + assert_eq!( + major_pacing_backoff_shift(), + 2, + "a 0%-yield full backs off again" + ); + + // Capped: a long run of futile fulls must not disable pacing outright. + test_note_full_cycle_reclaimed(400 * MB, 400 * MB); + test_note_full_cycle_reclaimed(800 * MB, 800 * MB); + assert_eq!(major_pacing_backoff_shift(), 2, "the shift is capped"); + + // A churn-shaped full reclaims most of the heap and restores the original + // pacing immediately — the backoff is not a ratchet. + test_note_full_cycle_reclaimed(800 * MB, 200 * MB); + assert_eq!( + major_pacing_backoff_shift(), + 0, + "a productive full resets the backoff in one step" + ); + + // Exactly at the threshold counts as productive (>=, not >). + test_note_full_cycle_reclaimed(100 * MB, 80 * MB); + assert_eq!(major_pacing_backoff_shift(), 0); + // One percent under it does not. + test_note_full_cycle_reclaimed(100 * MB, 81 * MB); + assert_eq!(major_pacing_backoff_shift(), 1); + test_reset_major_pacing_backoff(); +} + +/// The escalation and the pricing of its result must not be separable. +/// +/// #7726's first cut recorded the pre-full reading at the two +/// `gc_start_budgeted_cycle_for_pressure` call sites and missed the one in +/// `gc::gc_collect_minor_with_trigger_inner` — the site the shipped safepoint +/// path actually takes. Every test still passed, the decision function was +/// correct in isolation, and the change measured as a 30 ms no-op on a +/// benchmark it should have taken 480 ms off. The recording now lives INSIDE +/// `arena_growth_full_escalation_due`; this pins that coupling, in the one +/// direction a unit test can force without a 32 MB heap. +#[test] +fn declining_to_escalate_records_no_pre_full_reading() { + use super::super::policy::{ + arena_growth_full_escalation_due, test_major_pacing_pre_in_use_bytes, + test_reset_major_pacing_backoff, test_set_major_pacing_baseline, + }; + test_reset_major_pacing_backoff(); + // A baseline no live arena can exceed forces the verdict to `false`. + let previous = test_set_major_pacing_baseline(usize::MAX / 4); + let due = arena_growth_full_escalation_due(); + let recorded = test_major_pacing_pre_in_use_bytes(); + test_set_major_pacing_baseline(previous); + test_reset_major_pacing_backoff(); + assert!(!due, "an unreachable baseline must not escalate"); + assert_eq!( + recorded, 0, + "a declined escalation must leave no pre-full reading behind — a stale \ + one would price the NEXT full against the wrong heap" + ); +} diff --git a/crates/perry-runtime/src/object/prototype_chain.rs b/crates/perry-runtime/src/object/prototype_chain.rs index 55b75730cf..575ea3b899 100644 --- a/crates/perry-runtime/src/object/prototype_chain.rs +++ b/crates/perry-runtime/src/object/prototype_chain.rs @@ -340,6 +340,18 @@ pub(crate) fn object_static_prototype_owner_moved(old_owner: usize, new_owner: u if old_owner == 0 || new_owner == 0 || old_owner == new_owner { return; } + // The residual registry is EMPTY until a non-meta-capable owner records a + // prototype, and the latch is stored (`Release`) *before* that insert — so + // a `false` read here proves there is no entry to migrate. Its two sibling + // readers (`object_static_prototype`, `prune_dead_object_prototype_owners`) + // already gate on it; this one did not, so every evacuated object took a + // process-global `Mutex` and paid a SipHash probe against an empty + // map. On a promotion-heavy workload that is one lock + one hash per moved + // object (2.5 M of each on `gc-handoff/bench/retain.ts`), and it showed up + // as `pthread_mutex_lock` + `RandomState` in a single-threaded profile. + if !OBJECT_PROTOTYPES_NONEMPTY.load(Ordering::Acquire) { + return; + } if let Ok(mut map) = get_object_prototypes().lock() { if let Some(proto_bits) = map.remove(&old_owner) { map.insert(new_owner, proto_bits);