From 1f2a129967980f790dca1c6fc92ae73d981b5851 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 11 Aug 2026 20:57:40 +0200 Subject: [PATCH 1/2] docs(gc): establish current collector source of truth --- .github/workflows/test.yml | 18 +- CLAUDE.md | 8 +- .../gc_ratchet/baseline/gc-ratchet-v1.json | 1 - benchmarks/gc_ratchet/gc_ratchet.py | 1 - .../gc_array_prototype_hole_read_6981.rs | 1 - .../gc_closure_self_pointer_root_7055.rs | 1 - docs/ecs-perf-case-study.md | 7 +- docs/engine-plan.md | 20 +- docs/generational-gc-plan.md | 19 +- docs/po/de.po | 4 - docs/po/es.po | 4 - docs/po/fr.po | 4 - docs/po/id.po | 4 - docs/po/it.po | 4 - docs/po/ja.po | 4 - docs/po/ko.po | 4 - docs/po/messages.pot | 4 - docs/po/th.po | 4 - docs/po/vi.po | 4 - docs/po/zh-CN.po | 4 - docs/src/SUMMARY.md | 1 + docs/src/internals/garbage-collector.md | 177 +++++++++++++++++ docs/src/internals/memory-model.md | 76 +++++--- docs/src/testing/ci-gate-scheduling.md | 2 +- docs/statepoint-gc-experiment.md | 6 + scripts/check_gc_env_knobs.py | 182 ++++++++++++++++++ scripts/gate_freshness.json | 2 +- scripts/run_memory_stability_tests.sh | 7 +- 28 files changed, 469 insertions(+), 104 deletions(-) create mode 100644 docs/src/internals/garbage-collector.md create mode 100755 scripts/check_gc_env_knobs.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 26bf979d50..20cfe0f143 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -234,6 +234,16 @@ jobs: python3 scripts/gc_runtime_root_holders.py --self-test python3 scripts/gc_runtime_root_holders.py + # #7877. A deleted GC knob left executable CI arms that still looked + # distinct but selected the same collector. Derive the accepted names + # from live runtime/codegen parsers; historical journals are path-exact + # exemptions and cannot license a current script or reference page. + - name: GC environment-knob drift audit + if: ${{ !cancelled() }} + run: | + python3 scripts/check_gc_env_knobs.py --self-test + python3 scripts/check_gc_env_knobs.py + # #7341 layer 3. A RuntimeHandleScope gives an object liveness; it does # nothing for a raw pointer already read out of the slot. Every rooting bug # in the quarantine sweep had rooting ALREADY -- what was missing was @@ -2279,10 +2289,10 @@ jobs: # of allocate-and-discard (would catch a future block-pinning / # cache-leak / tenuring-trap regression in the gen-GC work), and # (2) crashes when gc() is forced aggressively during JSON parse, - # deep recursion, or closure init. Each test runs under default, - # PERRY_GEN_GC=1, and PERRY_GEN_GC=1 PERRY_WRITE_BARRIERS=1 so a - # regression in any GC mode is caught. Linux-only because /usr/bin/time - # availability + RSS reporting differs on Windows runners. + # deep recursion, or closure init. Each test runs under the default, + # full mark-sweep, explicit generational, and forced-evacuation verifier + # configurations. Linux/macOS only because /usr/bin/time availability + + # RSS reporting differs on Windows runners. - name: Memory stability tests if: runner.os == 'Linux' || runner.os == 'macOS' env: diff --git a/CLAUDE.md b/CLAUDE.md index 49f5092b3e..c6d5075ba5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,7 +108,7 @@ TypeScript (.ts) → Parse (SWC) → AST → Lower → HIR → Transform → Cod | **perry-hir** | HIR types and data structures, plus AST→HIR lowering | | **perry-transform** | IR passes (closure conversion, async lowering, inlining) | | **perry-codegen** | LLVM-based native code generation | -| **perry-runtime** | Runtime: value.rs, object.rs, array.rs, string.rs, gc.rs, arena.rs, thread.rs | +| **perry-runtime** | Runtime: value.rs, object/, array/, string/, gc/, arena/, thread/ | | **perry-stdlib** | Node.js API support (mysql2, redis, fetch, fastify, ws, etc.) | | **perry-ui** / **perry-ui-macos** / **perry-ui-ios** / **perry-ui-tvos** | Native UI (AppKit/UIKit) | @@ -129,9 +129,9 @@ Key functions: `js_nanbox_string/pointer/bigint`, `js_nanbox_get_pointer`, `js_g ## Garbage Collection -Generational mark-sweep GC in `crates/perry-runtime/src/gc.rs` (default since v0.5.237 / Phase D). Two regions in the per-thread arena: nursery (`ARENA`, fills with new allocations, swept on minor GC) and old-gen (`OLD_ARENA`, holds tenured/evacuated objects). Precise shadow-stack roots + ~55 registered side-table scanners (`gc/mod.rs:298+`); a conservative stack scan exists but production mode resolves to SkipDisabled, so liveness rests on codegen shadow-stack spilling plus `RuntimeHandleScope` in runtime helpers. Write barriers populate a remembered set so minor GC can avoid retracing the old-gen. Two-bit aging (`HAS_SURVIVED` / `TENURED`) promotes nursery survivors after 2 minor cycles; the C4b evacuation policy moves non-pinned tenured objects into old-gen with full reference rewriting only when generated write barriers are active and nursery/RSS pressure plus measured movable candidates justify the work. Idle nursery blocks observed empty for 2 GC cycles are `dealloc`'d back to the OS (C4b-δ, v0.5.235), and the next-trigger calc is hard-capped at the initial threshold (64 MB) so >90%-freed step-doubling can't blow up peak occupancy (C4b-δ-tune, v0.5.236). Triggers on arena block allocation (1 MB blocks since v0.5.196), malloc count threshold, or explicit `gc()` call. 8-byte GcHeader per allocation. +The current collector source of truth is `docs/src/internals/garbage-collector.md`. Implementation lives in the `crates/perry-runtime/src/gc/` and `arena/` module trees. Native RS4GC roots are the target-aware default where the runtime can walk frames; shadow frames are the fallback on unsupported targets. The conservative native-stack scan is diagnostic-only by default (`Auto` resolves to `SkipDisabled`). -**Escape hatches**: `PERRY_GEN_GC=0`/`off`/`false` reverts to full mark-sweep (bisection only). (`PERRY_GEN_GC_EVACUATE` was **deleted** in #7611 — it moved 0 of 96 gc-ratchet cells, and its one unique effect was vetoing forced evacuation, i.e. silently disarming the #7154 stress instrument (`PERRY_GC_SCHEDULE_SEED`). Policy evacuation is unconditional now except on budgeted low-pause cycles, which is the arm with a behavioural test.) `PERRY_GC_FORCE_EVACUATE=1` stress-copies every marked non-pinned nursery object when generated write barriers are active, **including on the explicit `gc()` path** — since #6946 a manual `gc()` under this knob runs an evacuating minor before its full mark-sweep, instead of a full sweep that moved nothing. `PERRY_GC_VERIFY_EVACUATION=1` panics if any mutable live slot still points at a forwarded nursery object after an evacuation/rewrite cycle. `PERRY_WRITE_BARRIERS=0`/`off`/`false` disables codegen-emitted write barriers at compile time and runtime exact helper barriers at runtime for benchmark/debug bisection; unset, `=1`/`on`/`true` keep barriers enabled. `PERRY_GC_DIAG=1` prints per-cycle diagnostics, including evacuation-policy decisions for considered cycles and `barriers_inactive` skips. +**Escape hatches**: `PERRY_GEN_GC=0`/`off`/`false` reverts to full mark-sweep (bisection only). #7611 deleted the ambient evacuation-policy veto after it moved 0 of 96 gc-ratchet cells and could silently disarm the #7154 stress instrument (`PERRY_GC_SCHEDULE_SEED`). Policy evacuation is unconditional now except on budgeted low-pause cycles, which has a behavioural test. `PERRY_GC_FORCE_EVACUATE=1` stress-copies every marked non-pinned nursery object when generated write barriers are active, **including on the explicit `gc()` path** — since #6946 a manual `gc()` under this knob runs an evacuating minor before its full mark-sweep, instead of a full sweep that moved nothing. `PERRY_GC_VERIFY_EVACUATION=1` panics if any mutable live slot still points at a forwarded nursery object after an evacuation/rewrite cycle. `PERRY_WRITE_BARRIERS=0`/`off`/`false` disables codegen-emitted write barriers at compile time and runtime exact helper barriers at runtime for benchmark/debug bisection; unset, `=1`/`on`/`true` keep barriers enabled. `PERRY_GC_DIAG=1` prints per-cycle diagnostics, including evacuation-policy decisions for considered cycles and `barriers_inactive` skips. ### Rooting-bug instruments (#7154 family) — what each knob ACTUALLY gates @@ -142,7 +142,7 @@ A "GC value live but not rooted across a collection point" bug is invisible at c | `PERRY_GC_PROTECT_FROMSPACE=1` (or `poison`) | the from-space reset performed by the **copying minor** (`arena::copying_reset_from_spaces_and_flip`). Retired Eden + active-survivor blocks are detached into a bounded quarantine, poison-filled (`0xDEADBEEFBAADF0DE`, `obj_type = 0xDE`) and, at `=1`, `mprotect(PROT_NONE)`d. A stale deref then SIGSEGVs at the faulting instruction; the installed reporter names the address, the retiring minor, and the last-known object's `obj_type`/size, then restores `SIG_DFL` and re-faults so a core/debugger still sees the real site. `poison` skips `mprotect`. | change the non-moving minor's `arena_reset_empty_blocks`, the full mark-sweep's reclaim, old-gen defrag, or the malloc sweep. **A run with zero copying minors protects nothing** — check that `PERRY_GC_DIAG=1` prints a `[gc-fromspace-protect] retired_set=#N` line. | | `PERRY_GC_PROTECT_FROMSPACE_DEPTH=N` (default 4) | how many retired page-sets stay quarantined. Evicted sets are restored to RW and **recycled back into Eden**, never `dealloc`'d, so footprint is bounded at `N × from-space bytes`. `0` is clamped to 1 — a depth of 0 would read as ON and protect nothing. **Raise this when a suspected bug does not fault**: a value can cross hundreds of collections between its last valid observation and its stale use (one per back-edge poll at `PERRY_GC_SCHEDULE_RATE=1`). #7154's `new C(…)` reproducer needs `800` — its constructor crosses 600 polls, so the default 4 misses it silently. | — | | `PERRY_GC_FROMSPACE_SCAN_ABORT=1` | now **implies** `PERRY_GC_FROMSPACE_SCAN=1`. It used to be inert alone (the scan never ran, so nothing aborted, and the run reported success). | — | -| `PERRY_GC_SCHEDULE_SEED=` | seeded GC-schedule fuzzing — the collection schedule as a knob, from normal pacing up to a collection at every handled safepoint (`PERRY_GC_SCHEDULE_RATE=1`). Three things, exactly: (1) `js_gc_loop_safepoint` stops requiring `GC_SAFEPOINT_PENDING` before descending into `gc_safepoint_moving_minor`; (2) inside `gc_safepoint_moving_minor`, **past the entry guards**, a per-thread safepoint counter advances once per handled safepoint and, when `gc_budgeted_due_trigger()` reports nothing due, a minor runs anyway iff `splitmix64(splitmix64(seed) ^ counter) < threshold`; (3) `gc_force_evacuate_enabled()` becomes true, so survivors MOVE. **A value that does not parse as `u64` reads as OFF, not as seed 0.** The seed is printed at startup, from the process-exit teardown funnel every exit path routes through (`report_exit_summary`, on the collection-side-allocation release — perry's `_exit` paths never reach `atexit`, which is only a libc-return backstop), and on panic/SIGSEGV/SIGBUS/SIGABRT/SIGILL/SIGTRAP — the signal reporter chains to (and is re-layered on top of) the from-space quarantine's, so the two compose. Live-subject counters: `gc::gc_schedule_safepoints()` / `gc::gc_schedule_forced_collections()`. | bypass `gc_safepoint_moving_minor`'s entry guards — and a blocked safepoint deliberately does **not** tick the counter, so the ordinal sequence tracks the program's safepoints rather than its allocation state. The forced-evacuation implication is **unconditional**, per #7611 (the `PERRY_GEN_GC_EVACUATE` veto is deleted). Nor emit loop polls — those are a compile-time property (`PERRY_GC_MOVING_LOOP_POLLS`, default ON since #7721; a binary compiled with `=0` has none), and without them a seeded run only fires at event-loop boundaries and a compute-only loop never collects. Nor *suppress* pressure-driven collections — the rate is additional density, never less. Determinism is **per-thread**: the counter is thread-local, so a single-threaded program replays exactly, while a `perry/thread` program is only as reproducible as its OS scheduling. Say which you measured. | +| `PERRY_GC_SCHEDULE_SEED=` | seeded GC-schedule fuzzing — the collection schedule as a knob, from normal pacing up to a collection at every handled safepoint (`PERRY_GC_SCHEDULE_RATE=1`). Three things, exactly: (1) `js_gc_loop_safepoint` stops requiring `GC_SAFEPOINT_PENDING` before descending into `gc_safepoint_moving_minor`; (2) inside `gc_safepoint_moving_minor`, **past the entry guards**, a per-thread safepoint counter advances once per handled safepoint and, when `gc_budgeted_due_trigger()` reports nothing due, a minor runs anyway iff `splitmix64(splitmix64(seed) ^ counter) < threshold`; (3) `gc_force_evacuate_enabled()` becomes true, so survivors MOVE. **A value that does not parse as `u64` reads as OFF, not as seed 0.** The seed is printed at startup, from the process-exit teardown funnel every exit path routes through (`report_exit_summary`, on the collection-side-allocation release — perry's `_exit` paths never reach `atexit`, which is only a libc-return backstop), and on panic/SIGSEGV/SIGBUS/SIGABRT/SIGILL/SIGTRAP — the signal reporter chains to (and is re-layered on top of) the from-space quarantine's, so the two compose. Live-subject counters: `gc::gc_schedule_safepoints()` / `gc::gc_schedule_forced_collections()`. | bypass `gc_safepoint_moving_minor`'s entry guards — and a blocked safepoint deliberately does **not** tick the counter, so the ordinal sequence tracks the program's safepoints rather than its allocation state. The forced-evacuation implication is **unconditional** since #7611 deleted the ambient veto that could disarm it. Nor emit loop polls — those are a compile-time property (`PERRY_GC_MOVING_LOOP_POLLS`, default ON since #7721; a binary compiled with `=0` has none), and without them a seeded run only fires at event-loop boundaries and a compute-only loop never collects. Nor *suppress* pressure-driven collections — the rate is additional density, never less. Determinism is **per-thread**: the counter is thread-local, so a single-threaded program replays exactly, while a `perry/thread` program is only as reproducible as its OS scheduling. Say which you measured. | | `PERRY_GC_SCHEDULE_RATE=<0..1>` (default `0.05`) | **only** the threshold `PERRY_GC_SCHEDULE_SEED`'s hash is compared against — the expected fraction of handled safepoints that collect. Out-of-range values clamp (a `2` reads as 1.0); unparseable and NaN fall back to the default. | do anything at all without a seed. It is inert alone. `=0` is an on-but-selects-nothing control (banner and reporters still install), `=1` collects at every handled safepoint — the maximum-density endpoint, where the seed stops mattering because every ordinal is selected whatever it hashes to. There is deliberately **no allocation-point level**: the alloc-point arm forces a conservative stack scan, which makes the copying minor ineligible, so an "every allocation" density would run non-moving minors and move nothing. | | `PERRY_GC_SCHEDULE_ALLOC_KB=N` (default 4) | how much NEW nursery material must accumulate before a loop back-edge poll becomes a candidate the seed may select (#7728). A high-water mark measured AFTER each collection, not a delta, so a collection that frees nothing cannot loop. `0` restores the literal every-poll candidate set — right for a small fixture or a window that executes once, and far slower. | change the schedule itself: the seed still decides which candidates collect, so `(seed, counter)` replay is unaffected. Nor apply to microtask-pump safepoints — it paces the loop arm only. | diff --git a/benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json b/benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json index 94a4d25cbb..15c3c1d5b6 100644 --- a/benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json +++ b/benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json @@ -32,7 +32,6 @@ "env": { "PERRY_NO_AUTO_OPTIMIZE": null, "PERRY_GEN_GC": null, - "PERRY_GEN_GC_EVACUATE": null, "PERRY_WRITE_BARRIERS": null }, "binaries": { diff --git a/benchmarks/gc_ratchet/gc_ratchet.py b/benchmarks/gc_ratchet/gc_ratchet.py index feaa1de550..da3303b99a 100644 --- a/benchmarks/gc_ratchet/gc_ratchet.py +++ b/benchmarks/gc_ratchet/gc_ratchet.py @@ -381,7 +381,6 @@ def toolchain_description(perry: Path) -> dict[str, Any]: "env": { "PERRY_NO_AUTO_OPTIMIZE": os.environ.get("PERRY_NO_AUTO_OPTIMIZE"), "PERRY_GEN_GC": os.environ.get("PERRY_GEN_GC"), - "PERRY_GEN_GC_EVACUATE": os.environ.get("PERRY_GEN_GC_EVACUATE"), "PERRY_WRITE_BARRIERS": os.environ.get("PERRY_WRITE_BARRIERS"), }, "binaries": binary_fingerprints(perry), diff --git a/crates/perry/tests/gc_array_prototype_hole_read_6981.rs b/crates/perry/tests/gc_array_prototype_hole_read_6981.rs index fced6442a7..07c087ca6a 100644 --- a/crates/perry/tests/gc_array_prototype_hole_read_6981.rs +++ b/crates/perry/tests/gc_array_prototype_hole_read_6981.rs @@ -104,7 +104,6 @@ const ORACLE_RELOCATE: &str = "120,105,125,679,142,125,133,128,115,135,126,109,1 /// the unfixed compiler. Clear the whole family, then apply the arm's own vars. const GC_ENV_OVERRIDES: &[&str] = &[ "PERRY_GEN_GC", - "PERRY_GEN_GC_EVACUATE", "PERRY_GC_SCAVENGE", "PERRY_GC_SCAVENGE_NURSERY_MB", "PERRY_GC_MOVING_SAFEPOINT", diff --git a/crates/perry/tests/gc_closure_self_pointer_root_7055.rs b/crates/perry/tests/gc_closure_self_pointer_root_7055.rs index 16114cb967..d930adaded 100644 --- a/crates/perry/tests/gc_closure_self_pointer_root_7055.rs +++ b/crates/perry/tests/gc_closure_self_pointer_root_7055.rs @@ -137,7 +137,6 @@ fn relocating_minor_does_not_replay_an_async_loop_iteration() { // settings. const GC_ENV_OVERRIDES: &[&str] = &[ "PERRY_GEN_GC", - "PERRY_GEN_GC_EVACUATE", "PERRY_GC_SCAVENGE", "PERRY_GC_SCAVENGE_NURSERY_MB", "PERRY_GC_MOVING_SAFEPOINT", diff --git a/docs/ecs-perf-case-study.md b/docs/ecs-perf-case-study.md index a2e910b5e8..58c898ce74 100644 --- a/docs/ecs-perf-case-study.md +++ b/docs/ecs-perf-case-study.md @@ -1,5 +1,10 @@ # Closing the perry/bun ratio on ECS workloads +> **Historical performance case study.** Collector paths and source locations +> below describe the May 2026 implementation. Use the +> [current collector page](src/internals/garbage-collector.md) for today's +> defaults and controls. + **Status:** complete. Written 2026-05-03 after landing the final commit on `wip/ecs-loop-fixes`. **Goal:** every workload in [`@codehz/ecs`](https://github.com/codehz/ecs) @@ -471,7 +476,7 @@ order of effort/impact: could short-circuit common `arr.length` / `str.length` / `map.size` paths via gc_type tag inline, before falling through to `js_object_get_field_by_name`. ~15-30 ms potential. -4. **Finish the evac path (`PERRY_GEN_GC_EVACUATE=1`).** Off by default, +4. **Finish the then-experimental evacuation path.** It was off by default, and broken — fails after ~1 round with "Component type 1 is not in this archetype." The blocker is correctness in `rewrite_forwarded_references` / `drain_trace_worklist_inner` (gc.rs). Multi-day work; would let diff --git a/docs/engine-plan.md b/docs/engine-plan.md index 1f73b38aad..e7b24b8246 100644 --- a/docs/engine-plan.md +++ b/docs/engine-plan.md @@ -2,11 +2,13 @@ **Goal (owner):** best performance, best RSS footprint, minimal binary size. -**Tracker:** #7294 (routing only — this document is authoritative). **History:** -every dated status section, incident narrative and superseded sequencing lives -in [`engine-plan-history.md`](engine-plan-history.md); this file holds only the -current state and the remaining work so it stays readable across context loads. -Last synced **2026-08-08** (v0.5.1350). Since v0.5.1345: the gc-ratchet is +**Status:** a performance worklist, not an architecture source of truth. The +routing tracker #7294 is closed. Current collector architecture and operations +live in [`src/internals/garbage-collector.md`](src/internals/garbage-collector.md); +dated incident narrative and superseded sequencing live in +[`engine-plan-history.md`](engine-plan-history.md). Last audited for GC drift +**2026-08-11**. The status narrative below is retained as measured history: +the gc-ratchet is repaired, re-pinned, and liveness-proven (#7609 — fail open per cell, fail closed on the verdict; owner action: promote to required after its first green `main` run); the element-shape invariant gained a real revocation matrix @@ -396,10 +398,10 @@ already working, on a workload that happens to reach it through `JSON.parse`. - ~~#7477 DirectParser float divergence~~ — **fixed** (#7483, single correctly-rounded division per Clinger; all three of `PERRY_JSON_TAPE=0`, `=1` and node produce the same checksum). #7478 is unblocked. -- **The statepoint lowering has no static root-dominance checker.** The - restored gates (#7452, #7460) verify the shadow-stack lowering only; the - checker anchors on `@js_shadow_slot_bind`, which statepoint IR does not - emit. Named at the call sites rather than papered over with a lowered floor. +- ~~The statepoint lowering has no static root-dominance checker.~~ **Closed by + #7663.** `gc-root-dominance-statepoints` reads the production statepoint + rewrite and checks `gc.statepoint` `"gc-live"` bundles. The shadow and native + arms remain separate contexts because they inspect different IR contracts. - **Ratchet probe coverage gap**: all GC-ratchet probes run at the default nursery cap; a large-Eden arm would have caught both #7472 and the #7481 residual. diff --git a/docs/generational-gc-plan.md b/docs/generational-gc-plan.md index 1af4168abe..8cfbffc39e 100644 --- a/docs/generational-gc-plan.md +++ b/docs/generational-gc-plan.md @@ -1,10 +1,10 @@ # Plan: Generational GC for Perry -**Status:** proposed, pre-implementation. Written 2026-04-24 after -v0.5.206 (Step 2 lazy JSON parse complete). This doc captures the -design before any code lands — the implementation is multi-week and -touches codegen + runtime + GC in concert, so agreement on direction -matters. +> **Historical design and implementation log.** Written 2026-04-24 before the +> work began; all phases later shipped. Do not use the proposed knobs or root +> defaults below as current operations guidance. See +> [the current collector page](src/internals/garbage-collector.md) for shipped +> modes, supported controls, target-specific roots, and validation commands. **Goal:** structurally beat Bun on peak RSS for general (non-JSON) workloads. Today Perry's `bench_json_roundtrip` with lazy flag beats @@ -98,7 +98,8 @@ correctness. ### Nursery layout -- Per-thread, 2 MB default (configurable via `PERRY_NURSERY_MB` env). +- Per-thread, 2 MB proposed default (the named nursery knob never shipped; + the current pacing control is documented on the current collector page). - Flat bump-allocator — same as today's arena but smaller. - Fills fast, resets on every minor GC. - Objects in nursery always have `GcHeader.gc_flags & GC_FLAG_YOUNG`. @@ -279,7 +280,7 @@ addressable from elsewhere. 5. **Don't yet rewrite references.** Compiled code following pointers to evacuated objects WILL crash because their nursery slots now hold forwarding pointers. Gate this whole phase on - a temporary `PERRY_GEN_GC_EVACUATE=1` env var so default + a temporary evacuation-policy env gate so default behavior is untouched. #### C4b-γ — Reference rewriting @@ -305,7 +306,7 @@ qualify because their formerly-tenured occupants have moved. **Ship criterion C4b complete:** `bench_json_roundtrip` direct- path RSS ≤70 MB (down from current 109 MB) without time regression beyond 10%. Full test corpus clean under -`PERRY_GEN_GC=1 PERRY_GEN_GC_EVACUATE=1`. +`PERRY_GEN_GC=1` plus the temporary evacuation-policy gate. ### Phase D — Flip defaults + clean up conservative scanner @@ -492,7 +493,7 @@ JS frames stay covered. per-object remembered-set. Better for write-heavy workloads. Defer until we have data showing the simple remembered-set approach is the bottleneck. -- **Flip `PERRY_GEN_GC_EVACUATE=1` default:** evacuation is +- **Flip the temporary evacuation-policy gate's default:** evacuation is correctness-safe and tested but a no-op on workloads where nothing tenures (so the work it does is overhead, not benefit). Flipping benefits from production-soak data on programs where diff --git a/docs/po/de.po b/docs/po/de.po index a8e6b6f437..e98470b9b0 100644 --- a/docs/po/de.po +++ b/docs/po/de.po @@ -56923,10 +56923,6 @@ msgstr "" "Generationalen Modus deaktivieren; auf vollständiges Mark-Sweep zurückfallen" " (nur für Bisection vorgesehen)." -#: src/internals/memory-model.md:116 -msgid "`PERRY_GEN_GC_EVACUATE=0` / `off` / `false`" -msgstr "" - #: src/internals/memory-model.md:116 msgid "" "Disable policy evacuation. `=1` / `on` / `true` is accepted as \"allow the " diff --git a/docs/po/es.po b/docs/po/es.po index 1c4193071f..b6ea157790 100644 --- a/docs/po/es.po +++ b/docs/po/es.po @@ -57352,10 +57352,6 @@ msgstr "" "Deshabilita el modo generacional; recurre al mark-sweep completo (destinado " "solo a bisección)." -#: src/internals/memory-model.md:116 -msgid "`PERRY_GEN_GC_EVACUATE=0` / `off` / `false`" -msgstr "" - #: src/internals/memory-model.md:116 msgid "" "Disable policy evacuation. `=1` / `on` / `true` is accepted as \"allow the " diff --git a/docs/po/fr.po b/docs/po/fr.po index e8a21a0bec..a558735ce8 100644 --- a/docs/po/fr.po +++ b/docs/po/fr.po @@ -57487,10 +57487,6 @@ msgstr "" "Désactive le mode générationnel ; revient au balayage complet mark-sweep " "(réservé à la bisection)." -#: src/internals/memory-model.md:116 -msgid "`PERRY_GEN_GC_EVACUATE=0` / `off` / `false`" -msgstr "" - #: src/internals/memory-model.md:116 msgid "" "Disable policy evacuation. `=1` / `on` / `true` is accepted as \"allow the " diff --git a/docs/po/id.po b/docs/po/id.po index e07ebe868f..a52dc1a8ea 100644 --- a/docs/po/id.po +++ b/docs/po/id.po @@ -56326,10 +56326,6 @@ msgstr "" "Nonaktifkan mode generasional; kembali ke full mark-sweep (dimaksudkan hanya" " untuk bisection)." -#: src/internals/memory-model.md:116 -msgid "`PERRY_GEN_GC_EVACUATE=0` / `off` / `false`" -msgstr "`PERRY_GEN_GC_EVACUATE=0` / `off` / `false`" - #: src/internals/memory-model.md:116 msgid "" "Disable policy evacuation. `=1` / `on` / `true` is accepted as \"allow the " diff --git a/docs/po/it.po b/docs/po/it.po index 2e9b5ff475..f033628e49 100644 --- a/docs/po/it.po +++ b/docs/po/it.po @@ -56799,10 +56799,6 @@ msgstr "" "Disabilita la modalità generazionale; torna al mark-sweep completo " "(destinato solo alla bisection)." -#: src/internals/memory-model.md:116 -msgid "`PERRY_GEN_GC_EVACUATE=0` / `off` / `false`" -msgstr "" - #: src/internals/memory-model.md:116 msgid "" "Disable policy evacuation. `=1` / `on` / `true` is accepted as \"allow the " diff --git a/docs/po/ja.po b/docs/po/ja.po index 371e8ac3e8..38c3e7ef65 100644 --- a/docs/po/ja.po +++ b/docs/po/ja.po @@ -53906,10 +53906,6 @@ msgid "" "bisection only)." msgstr "世代別モードを無効にし、フルマークスイープにフォールバックします(二分法専用)。" -#: src/internals/memory-model.md:116 -msgid "`PERRY_GEN_GC_EVACUATE=0` / `off` / `false`" -msgstr "" - #: src/internals/memory-model.md:116 msgid "" "Disable policy evacuation. `=1` / `on` / `true` is accepted as \"allow the " diff --git a/docs/po/ko.po b/docs/po/ko.po index 079f7f380e..c68bd9b132 100644 --- a/docs/po/ko.po +++ b/docs/po/ko.po @@ -53857,10 +53857,6 @@ msgid "" "bisection only)." msgstr "세대별 모드를 비활성화하고 전체 mark-sweep으로 폴백합니다 (이분법 전용)." -#: src/internals/memory-model.md:116 -msgid "`PERRY_GEN_GC_EVACUATE=0` / `off` / `false`" -msgstr "" - #: src/internals/memory-model.md:116 msgid "" "Disable policy evacuation. `=1` / `on` / `true` is accepted as \"allow the " diff --git a/docs/po/messages.pot b/docs/po/messages.pot index 5e1ba90a62..489b2c4bf1 100644 --- a/docs/po/messages.pot +++ b/docs/po/messages.pot @@ -50715,10 +50715,6 @@ msgid "" "bisection only)." msgstr "" -#: src/internals/memory-model.md:116 -msgid "`PERRY_GEN_GC_EVACUATE=0` / `off` / `false`" -msgstr "" - #: src/internals/memory-model.md:116 msgid "" "Disable policy evacuation. `=1` / `on` / `true` is accepted as \"allow the " diff --git a/docs/po/th.po b/docs/po/th.po index 7e6b42595e..1d0783ba54 100644 --- a/docs/po/th.po +++ b/docs/po/th.po @@ -55800,10 +55800,6 @@ msgstr "" "ปิดโหมด generational; fallback ไปยัง full mark-sweep (มีไว้สำหรับการ " "bisection เท่านั้น)" -#: src/internals/memory-model.md:116 -msgid "`PERRY_GEN_GC_EVACUATE=0` / `off` / `false`" -msgstr "" - #: src/internals/memory-model.md:116 msgid "" "Disable policy evacuation. `=1` / `on` / `true` is accepted as \"allow the " diff --git a/docs/po/vi.po b/docs/po/vi.po index 9e65129f5d..9ca76094ac 100644 --- a/docs/po/vi.po +++ b/docs/po/vi.po @@ -56247,10 +56247,6 @@ msgstr "" "Vô hiệu hóa chế độ generational; rơi về full mark-sweep (chỉ dành cho " "bisection)." -#: src/internals/memory-model.md:116 -msgid "`PERRY_GEN_GC_EVACUATE=0` / `off` / `false`" -msgstr "" - #: src/internals/memory-model.md:116 msgid "" "Disable policy evacuation. `=1` / `on` / `true` is accepted as \"allow the " diff --git a/docs/po/zh-CN.po b/docs/po/zh-CN.po index 8d2977880f..373a9f2128 100644 --- a/docs/po/zh-CN.po +++ b/docs/po/zh-CN.po @@ -53286,10 +53286,6 @@ msgid "" "bisection only)." msgstr "禁用分代模式;回退到全量标记清除(仅用于二分调试)。" -#: src/internals/memory-model.md:116 -msgid "`PERRY_GEN_GC_EVACUATE=0` / `off` / `false`" -msgstr "" - #: src/internals/memory-model.md:116 msgid "" "Disable policy evacuation. `=1` / `on` / `true` is accepted as \"allow the " diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 87d39db6fa..a6afec0807 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -177,6 +177,7 @@ # Internals - [Memory Model](internals/memory-model.md) +- [Garbage Collector](internals/garbage-collector.md) - [Explicit Memory Control](internals/explicit-memory.md) - [The GC rooting invariant (codegen)](internals/gc-rooting-invariant.md) - [RFC: rooting by construction](internals/rfc-rooting-by-construction.md) diff --git a/docs/src/internals/garbage-collector.md b/docs/src/internals/garbage-collector.md new file mode 100644 index 0000000000..00b1c2aaf6 --- /dev/null +++ b/docs/src/internals/garbage-collector.md @@ -0,0 +1,177 @@ +# Garbage collector: current architecture and operations + +> **Current as of 2026-08-11.** This is the source of truth for the collector +> that ships. The [generational plan][generational-plan] and +> [statepoint experiment][statepoint-experiment] are chronological +> evidence; their opening decisions describe the date they were written, not +> today's defaults. + +Perry uses a per-thread, tracing generational collector. JavaScript values are +NaN-boxed, allocations carry an 8-byte `GcHeader`, and each runtime thread owns +a nursery plus an old-generation arena. The implementation is the +`crates/perry-runtime/src/gc/` and `crates/perry-runtime/src/arena/` module +trees; code generation's root lowering lives in +`crates/perry-codegen/src/codegen/` and `native_root_coverage/`. + +## Collection paths + +New GC-managed allocations normally enter 1 MiB nursery blocks. Two flag bits, +`HAS_SURVIVED` and `TENURED`, record age. A collection can take one of three +paths: + +1. **Copying minor.** At a precise safepoint, live young objects are copied, + roots and heap slots are rewritten, and whole from-space blocks are reset. + Tenured survivors can move into `OLD_ARENA`. This is the fast nursery path. +2. **Non-moving minor/fallback.** When collection begins somewhere that cannot + safely relocate every live reference, the nursery is marked and swept in + place. Budgeted low-pause cycles also use a non-moving path. +3. **Full mark-sweep.** Major pacing, critical host pressure, explicit full + work, or `PERRY_GEN_GC=0` trace both generations and reclaim dead old objects + as well as nursery garbage. + +`PERRY_GC_SCAVENGE` is on by default and lets nursery pressure route to the +direct minor. `PERRY_GC_SCAVENGE_NURSERY_MB` tunes its base high-water cap +(16 MiB by default); tenuring feedback may grow the effective cap. Generated +write barriers are also on by default. Turning them off makes generational +minors unsound, so the runtime deliberately falls back to full mark-sweep. + +Old-generation page-defragmentation selection exists, but production +compaction is **off**. `PERRY_GC_OLD_DEFRAG=1` is a debugging/reproduction arm +while #7876 tracks the missing rewrite contract; nursery evacuation and normal +old-generation sweep are unaffected. + +## Roots, by target + +One root-set analysis feeds two lowerings: + +| target | shipped precise-root lowering | +|---|---| +| 64-bit AArch64/arm64 and x86-64, including x86-64 Windows | LLVM RS4GC statepoints plus Perry's compact native stack map | +| `arm64_32` watchOS, ARM64 Windows, and unsupported architectures | Perry shadow frames | + +This is target-aware, not host-aware. `PERRY_RS4GC=0` selects the shadow +lowering for bisection; `PERRY_RS4GC=1` requests native roots and fails closed +if the target cannot emit/read them. `PERRY_SHADOW_STACK=0` disables only the +shadow lowering—native-root analysis remains enabled when native roots are the +selected backend. + +Runtime-owned roots do not live in generated frames. Registered scanners visit +module globals, pending async work, caches, registries, and other side tables; +`scripts/gc_runtime_root_holders.py` keeps the inventory complete. Runtime +helpers keep temporary values in `RuntimeHandleScope`/`RuntimeHandle` and must +re-read a handle after a call that can collect. + +The conservative native-stack scan is not part of the production default: +`Auto` resolves to `SkipDisabled`. `PERRY_CONSERVATIVE_STACK_SCAN=full` is an +explicit diagnostic/sensitivity arm. A full scan pins ambiguous roots and +therefore makes the copying minor ineligible; it is useful evidence, not a +second normal rooting backend. + +## Barriers and weak references + +Every old-to-young pointer publication must hit the remembered-set barrier. +Codegen emits barriers for generated heap stores and runtime helpers perform +the same bookkeeping for their own exact stores. A minor traces remembered old +parents instead of retracing all of old-gen. + +WeakRef, WeakMap, WeakSet, and FinalizationRegistry targets are excluded from +the strong trace. The copying minor processes only registered weak holders and +repairs forwarded addresses. Full/fallback collection currently walks the +whole arena during weak processing; that phase is atomic and unsliced (#7874). + +## Budgets, memory pressure, and released blocks + +The collector derives a heap budget, in priority order, from +`PERRY_GC_HEAP_LIMIT` (MiB), Apple embedded available-memory APIs, container +limits, then half of physical RAM. Budgets below 1 GiB scale trigger ceilings, +reclaim thresholds, nursery deferral slack, and RSS pressure thresholds down; +desktop/server defaults remain unchanged. + +Platform hosts call `js_gc_memory_pressure(level)`: + +- warning (`1`) requests a prompt minor; +- critical (`2+`) requests a full collection so old-gen garbage and idle arena + blocks can be reclaimed; +- if collection is unsafe, the request is made sticky and drains at the next + precise safepoint/allocation check. + +Released 1 MiB blocks first enter a per-thread LIFO reuse pool capped at +64 MiB. Overflow is returned to the allocator and thread exit drains its own +pool. Critical pressure and small device budgets do not yet drain or resize an +already-populated pool; #7875 tracks that bounded-but-device-blind residue. + +## Supported controls + +These are the operational controls most useful outside collector development: + +| knob | purpose | +|---|---| +| `PERRY_GEN_GC=0` | bisection fallback to full mark-sweep | +| `PERRY_WRITE_BARRIERS=0` | compile/runtime barrier bisection; also forces full mark-sweep | +| `PERRY_GC_SCAVENGE=0` | disable direct nursery scavenging for pacing comparison | +| `PERRY_GC_SCAVENGE_NURSERY_MB=N` | set the base nursery cap | +| `PERRY_GC_HEAP_LIMIT=N` | override the process heap budget in MiB | +| `PERRY_RS4GC=0` | select shadow roots on a native-root-capable target | +| `PERRY_CONSERVATIVE_STACK_SCAN=full` | diagnostic full native-stack scan; disables copying | +| `PERRY_GC_TRACE=1` | emit structured per-cycle trace records | +| `PERRY_GC_DIAG=1` | emit human-readable collector diagnostics | + +Rooting stress uses `PERRY_GC_SCHEDULE_SEED`, +`PERRY_GC_SCHEDULE_RATE`, `PERRY_GC_SCHEDULE_ALLOC_KB`, +`PERRY_GC_FORCE_EVACUATE`, `PERRY_GC_VERIFY_EVACUATION`, +`PERRY_GC_PROTECT_FROMSPACE`, `PERRY_GC_PROTECT_FROMSPACE_DEPTH`, +`PERRY_GC_FROMSPACE_SCAN`, and `PERRY_GC_FROMSPACE_SCAN_ABORT`. Their exact +contracts and non-vacuity requirements live in the +[rooting invariant](gc-rooting-invariant.md). Research/bisection controls such +as `PERRY_GC_INCREMENTAL`, `PERRY_GC_PROMOTE_IN_PLACE`, +`PERRY_GC_MAJOR_PACING_FLOOR_MB`, `PERRY_GC_MAJOR_PACING_GROWTH`, +`PERRY_GC_MOVING_SAFEPOINT`, `PERRY_GC_MOVING_LOOP_POLLS`, +`PERRY_GC_SAFEPOINT_ONLY`, and `PERRY_STACKMAP_WALKER` are accepted but are not +additional supported collector modes. + +`scripts/check_gc_env_knobs.py` derives the accepted names from live +runtime/codegen/compiler parsers and rejects a current document, executable +script, workflow, or translation catalog that names a deleted knob. + +## Validation and CI authority + +As of 2026-08-11, branch protection requires `lint`, `cargo-test`, `parity`, +`compile-smoke`, `api-docs-drift`, `security-audit`, and +`conformance-smoke-complete`. The GC-specific coverage is split deliberately: + +| check | where it runs | required status | +|---|---|---| +| root-holder custody and GC-knob drift self-tests/live scans | `test.yml` → `lint` | yes (`lint`) | +| runtime unit suite and `run_memory_stability_tests.sh` four-mode matrix | `test.yml` → `cargo-test` | yes (`cargo-test`) | +| emitted root dominance, including native statepoint IR | `gc-root-dominance.yml` | not currently branch-required | +| pinned collector counters/RSS/wall matrix | `gc-ratchet.yml` | not currently branch-required | +| thread-local mechanism/policy budget | `tls-budget.yml` | not currently branch-required | + +Useful local preflight commands: + +```bash +python3 scripts/check_gc_env_knobs.py --self-test +python3 scripts/check_gc_env_knobs.py +python3 scripts/gc_runtime_root_holders.py --self-test +python3 scripts/gc_runtime_root_holders.py +python3 scripts/gc_root_dominance_check.py --self-test +cargo test -p perry-runtime --lib +``` + +The dedicated performance/rooting workflows have broader compiler and host +requirements; their workflow files are the authority for exact commands and +relevance filters. + +## Historical evidence + +- [Generational GC plan][generational-plan]: original design and + phase-by-phase landing log. +- [Statepoint GC experiment][statepoint-experiment]: chronological + prototype measurements and corrections leading to the native-root default. +- [GC rooting invariant](gc-rooting-invariant.md): current codegen soundness + rule, checker modes, known blind spots, and debugging instruments. +- [Memory Model](memory-model.md): NaN-boxing, allocation representation, and + platform memory-tooling notes. + +[generational-plan]: https://github.com/PerryTS/perry/blob/main/docs/generational-gc-plan.md +[statepoint-experiment]: https://github.com/PerryTS/perry/blob/main/docs/statepoint-gc-experiment.md diff --git a/docs/src/internals/memory-model.md b/docs/src/internals/memory-model.md index 99f2301ecf..70be7cb205 100644 --- a/docs/src/internals/memory-model.md +++ b/docs/src/internals/memory-model.md @@ -4,6 +4,10 @@ Perry compiles TypeScript directly to native code via LLVM, but JavaScript is a If you've ever wondered "does Perry use reference counting?" — no. There is no `Rc` at runtime. Perry has a real tracing GC, described below. +For the dated source of truth on shipped collection paths, target-specific +root lowerings, memory pressure, block pooling, supported knobs, and CI, see +[Garbage collector: current architecture and operations](garbage-collector.md). + ## Value representation: NaN-boxing Every JavaScript value in Perry is a single 64-bit word. The encoding piggy-backs on IEEE 754: any `f64` whose exponent is all-ones and whose mantissa is non-zero is a NaN, and there are ~2⁵² distinct NaN bit patterns. Perry uses the high 16 bits as a type tag and the low 48 (or 32) bits as the payload. @@ -37,7 +41,8 @@ Within a thread, the heap is two arenas: - **`ARENA`** — the nursery. New allocations land here. Carved into 1 MB blocks (since v0.5.196). - **`OLD_ARENA`** — the old generation. Holds objects that have survived enough minor GCs to be tenured. -Every allocation, in either arena, is prefixed by an 8-byte `GcHeader` (`crates/perry-runtime/src/gc.rs:14`): +Every allocation, in either arena, is prefixed by an 8-byte `GcHeader` +(`crates/perry-runtime/src/gc/types.rs`): ```rust #[repr(C)] @@ -51,35 +56,50 @@ pub struct GcHeader { Callers receive a pointer **after** the header (`ptr + 8`), so from TypeScript code's perspective the header is invisible. The collector finds the header by subtracting 8. -Allocation goes through `gc_malloc(size, obj_type)` (`gc.rs:606`). LLVM-generated code emits calls to this for every object literal, array literal, closure capture, string concat, BigInt operation, etc. There is no allocation primitive in the IR that bypasses this — going through `gc_malloc` is how the GC accounts for live memory and decides when to collect. +Allocation goes through `gc_malloc(size, obj_type)` in the `gc/` module tree. +LLVM-generated code emits calls to this for every object literal, array +literal, closure capture, string concat, BigInt operation, etc. Going through +the GC allocation funnel is how the collector accounts for memory and decides +when to collect. ## How the GC finds roots This is the part most people are surprised by: if Perry compiles through LLVM, the optimizer is free to keep values in registers, spill them to stack slots, rematerialize them — none of which the collector can introspect. So how does the collector know which JS values are live? -Three mechanisms, used together: - -### 1. Precise shadow stack (codegen-emitted) +Three mechanisms cover different storage locations: -Codegen emits, at function entry, a call to `js_shadow_frame_push(slot_count)` (`gc.rs:493`). This reserves a frame in a thread-local shadow stack. Every JS-level local variable in the function gets a slot, and every assignment to that local emits a paired `js_shadow_slot_set(idx, value)` call. On function exit, codegen emits `js_shadow_frame_pop`. +### 1. Target-aware precise roots (codegen-emitted) -The result: at any GC safepoint, the collector can walk the shadow stack and see the live NaN-boxed value of every TS-level local in every active frame, regardless of what LLVM did with registers. This is the "precise" half of the root scan — `shadow_stack_root_scanner` (`gc.rs:3860`). +One pointer-local analysis feeds two correct lowerings. On supported 64-bit +AArch64/arm64 and x86-64 targets, native RS4GC statepoints plus Perry's compact +stack map are the default. `arm64_32` watchOS, ARM64 Windows, and unsupported +architectures use Perry's heap-backed shadow frames. The fallback is a root +lowering, not an unrooted mode; see [the current GC page](garbage-collector.md#roots-by-target). -### 2. Conservative native-stack scan +At a safepoint the selected map describes each live managed local regardless of +whether LLVM kept it in a register, spilled it, or relocated it. -Some values are not on the shadow stack — most importantly, anything currently in a CPU register or in a Rust runtime frame at the moment GC fires. For these, the collector scans the native stack word-by-word and, for each word, checks whether it looks like a pointer into one of the arenas. Anything that does is **conservatively pinned** for that cycle (`is_conservatively_pinned`, `gc.rs:3747`). +### 2. Conservative native-stack diagnostic -Pinning means: the object isn't freed, and isn't moved (the evacuation pass skips it). False positives are acceptable — they just keep a dead object alive for one more cycle. False negatives would be catastrophic — they'd free a live object — and the shadow stack + scanner registration below ensure they don't happen for known roots. +The production default does not scan the native stack conservatively: `Auto` +resolves to `SkipDisabled`. `PERRY_CONSERVATIVE_STACK_SCAN=full` is a diagnostic +sensitivity arm that scans words which look like arena pointers and pins the +corresponding objects for that cycle. Because an ambiguous root cannot be +rewritten safely, this arm makes the copying minor ineligible. ### 3. Registered runtime root scanners -Some roots live in the runtime itself, not in user code: pending Promises, timer callbacks, exception state, async-context stacks, async-hooks state, shape caches, transition caches, overflow fields, JSON-parse scratch tables, the string intern table. Each is registered with the collector via `gc_register_root_scanner(scanner_fn)` (`gc.rs:807`), and the collector invokes each scanner during the mark phase. There are 9 such scanners currently registered (`gc.rs:3232`–`3940`). +Some roots live in the runtime itself, not in user code: pending Promises, +timer callbacks, exception state, async-context stacks, shape caches, overflow +fields, parse scratch tables, and intern tables. The collector invokes the +registered scanners during marking; `scripts/gc_runtime_root_holders.py` +enumerates the holders and refuses unclassified or stale inventory entries. ## Generational behaviour Most JS allocations die young — object literals in a loop body, short-lived closures, intermediate strings. A generational collector exploits this by collecting the nursery frequently and the old gen rarely. -Perry uses two-bit aging encoded in `gc_flags` (`gc.rs:64`): +Perry uses two-bit aging encoded in `gc_flags` (`gc/types.rs`): - First minor GC an object survives: `GC_FLAG_HAS_SURVIVED` is set. - Second minor GC it survives: `GC_FLAG_TENURED` is set, and the object is logically promoted to old-gen. @@ -94,19 +114,24 @@ Generational collectors have one fundamental problem: if an old-gen object point The fix is a **write barrier**: every time a pointer field is written, the runtime checks "is this old → young?" and, if so, records the parent in a **remembered set**. Minor GCs treat remembered-set entries as additional roots. -In Perry, the runtime barrier is always present: `js_write_barrier(parent, child)` (`gc.rs:3773`). Codegen emits write-barrier calls by default so copied minor GC and evacuation can rely on exact dirty-page data. Set `PERRY_WRITE_BARRIERS=0`/`off`/`false` during compile to suppress generated barrier calls for benchmark/debug bisection; at runtime, the same setting disables runtime exact helper barriers. Copied-minor and evacuation then treat barrier data as inactive and fall back to conservative paths. +In Perry, codegen emits write-barrier calls by default so copied minor GC and +evacuation can rely on exact remembered-set data. Set +`PERRY_WRITE_BARRIERS=0`/`off`/`false` during compile for bisection; at runtime, +the same setting disables exact helper barriers. Generational minors then fall +back to full mark-sweep rather than trusting an empty remembered set. ## Triggers and tuning -`gc_check_trigger` (`gc.rs:919`) fires on three signals: - -1. **Arena block allocation** — every time a new 1 MB block is allocated for the nursery. -2. **Malloc count threshold** — too many malloc-tracked objects (strings, closures, …) outstanding. -3. **Explicit `gc()` call** from user code. +`gc_check_trigger` in `gc/policy.rs` responds to four signal families: -The next-trigger calculation steps up after each cycle but is hard-capped at the initial threshold (64 MB) so that a workload which frees >90% of the nursery on each cycle can't drift peak occupancy upward through step-doubling (C4b-δ-tune, v0.5.236). +1. **Nursery pressure** — allocation growth and the adaptive nursery cap. +2. **Malloc count pressure** — too many separately tracked allocations. +3. **Major pacing** — unreclaimed post-collection bytes outgrow the live baseline. +4. **Explicit/host requests** — user `gc()` and warning/critical OS memory pressure. -Idle nursery blocks observed empty for 2 GC cycles are `dealloc`'d back to the OS (C4b-δ, v0.5.235), so a workload's RSS shrinks once the burst is over. +Device/container budgets scale trigger and reclaim ceilings down. Released +blocks enter a bounded per-thread reuse pool before allocator return; see the +current GC page for pressure-level and pool limitations. ## Escape hatches and diagnostics @@ -133,7 +158,7 @@ to collapse that detection latency. **All are default-off and inert when off.** | `PERRY_GC_PROTECT_FROMSPACE=poison` | As above without `mprotect`: poison only. Use where a fault is unwanted, or for the sub-page block edges `mprotect` cannot cover (those are always poison-filled and counted separately). | | `PERRY_GC_PROTECT_FROMSPACE_DEPTH=N` | How many retired page-sets stay quarantined (default `4`, minimum `1`). Expired sets are restored to read/write and **recycled back into Eden**, never freed, so the quarantine is a ring: steady-state footprint is bounded by `N × from-space bytes` and no `mprotect`'d page is ever handed to the system allocator. | | `PERRY_GC_FROMSPACE_SCAN_ABORT=1` | Abort on the **first** offending slot the whole-heap from-space scan finds, printing slot, holder, target (including the target's `obj_type`) and a collector backtrace. Now implies `PERRY_GC_FROMSPACE_SCAN=1`; previously it was silently inert on its own. | -| `PERRY_GC_SCHEDULE_SEED=` | **Seeded GC-schedule fuzzing** — when nursery pressure is not due, add a minor collection at a handled safepoint when a deterministic pseudo-random function of the seed and a per-thread safepoint ordinal selects it. It never *suppresses* a pressure-driven collection; the rate is additional density on top of normal pacing. The collection schedule as a knob: enough extra collections to turn a rare "what was live when the collector ran" bug into a frequent one, and — because the schedule is a pure function of `(seed, counter)` — **a failing seed is a reproducer**. Implies forced evacuation **unconditionally**, so survivors actually move — `PERRY_GEN_GC_EVACUATE`, whose `=0` used to veto that, was deleted in #7611 precisely because an ambient veto silently turned a #7154 instrument into a no-op. It does **not** bypass `gc_safepoint_moving_minor`'s entry guards (in-allocation, suppressed, unsafe FFI zone, non-zero root-lock depth, budgeted cycle): a safepoint reached in any of those states still declines to collect, and does not consume a schedule slot. A value that does not parse as a `u64` reads as OFF, not as seed 0. Composes with the two above; that pairing is what turns a rooting bug into an immediate precise fault. | +| `PERRY_GC_SCHEDULE_SEED=` | **Seeded GC-schedule fuzzing** — when nursery pressure is not due, add a minor collection at a handled safepoint when a deterministic pseudo-random function of the seed and a per-thread safepoint ordinal selects it. It never *suppresses* a pressure-driven collection; the rate is additional density on top of normal pacing. A failing seed is a reproducer. The schedule implies forced evacuation unconditionally; #7611 deleted the ambient evacuation veto that could silently disarm this instrument. It does **not** bypass `gc_safepoint_moving_minor`'s entry guards (in-allocation, suppressed, unsafe FFI zone, non-zero root-lock depth, budgeted cycle): a safepoint reached in any of those states still declines to collect, and does not consume a schedule slot. A value that does not parse as a `u64` reads as OFF, not as seed 0. Composes with the two above; that pairing is what turns a rooting bug into an immediate precise fault. | | `PERRY_GC_SCHEDULE_RATE=<0..1>` | Expected fraction of eligible handled safepoints that receive an *additional* schedule-triggered collection (default `0.05`). Inert without a seed. `0` selects nothing but still installs the banner and reporters, so it is a clean control arm; `1` collects at every handled safepoint — maximum pressure, in the spirit of V8's `--stress-scavenge`, and the point where the seed stops mattering because every ordinal is selected. Out-of-range values clamp. | These instruments have explicit caveats, because each has burned a prior @@ -188,9 +213,14 @@ investigation: ## Why this design -The combination — NaN-boxing for cheap value representation, per-thread arenas to avoid cross-thread sync, precise shadow stack + conservative stack scan for safe root discovery under an opaque optimizer (LLVM), generational aging for nursery-friendly workloads — is what lets Perry both go through LLVM and run a managed language without a fight. +The combination—NaN-boxing, per-thread arenas, target-aware precise roots, +registered runtime scanners, barriers, and generational aging—is what lets +Perry go through LLVM and still run a moving managed heap. -Going to native code does not preclude having a GC. It just means the GC's relationship with the compiled code is mediated by an ABI: codegen emits calls to `gc_malloc`, `js_shadow_frame_push/pop`/`js_shadow_slot_set`, and `js_write_barrier`, and the runtime crate (linked in as native code) is a real generational mark-sweep collector. There is nothing reference-counted at runtime. +Going to native code does not preclude having a GC. It means the collector's +relationship with compiled code is mediated by an ABI and the selected root +map; the linked runtime remains a real tracing collector. There is nothing +reference-counted at runtime. ## Profiling Perry's memory on macOS diff --git a/docs/src/testing/ci-gate-scheduling.md b/docs/src/testing/ci-gate-scheduling.md index c9ab683ebb..44d0c8e579 100644 --- a/docs/src/testing/ci-gate-scheduling.md +++ b/docs/src/testing/ci-gate-scheduling.md @@ -152,7 +152,7 @@ The starvation was silent *by construction*: an empty result set looks exactly l a healthy one nobody checked. Rescheduling the gates does not fix that — a cron that silently stops firing fails the same way. -`gate-freshness.yml` runs hourly on `ubuntu-latest` and calls +`gate-freshness.yml` runs every two hours on `ubuntu-latest` and calls `scripts/check_gate_freshness.py`, which asks the Actions API for each gate's most recent **successful** non-PR run on the default branch and fails when it is older than that gate's budget in `scripts/gate_freshness.json`. On failure it opens — or diff --git a/docs/statepoint-gc-experiment.md b/docs/statepoint-gc-experiment.md index 0771cf8b8a..b91f39225f 100644 --- a/docs/statepoint-gc-experiment.md +++ b/docs/statepoint-gc-experiment.md @@ -1,5 +1,11 @@ # Explicit statepoint GC experiment +> **Historical experiment journal.** Its sections retain the decision at each +> measurement date, including conclusions superseded later in this same file. +> Native RS4GC roots now ship by default on supported 64-bit AArch64/arm64 and +> x86-64 targets; other targets use shadow frames. See +> [the current collector page](src/internals/garbage-collector.md#roots-by-target). + Date: 2026-07-31 Branch: `exp/stackmap-viability` diff --git a/scripts/check_gc_env_knobs.py b/scripts/check_gc_env_knobs.py new file mode 100755 index 0000000000..8f38576c92 --- /dev/null +++ b/scripts/check_gc_env_knobs.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Reject GC environment knobs that no live parser owns. + +Current documentation, executable scripts, workflow configuration, and the +generated gettext catalogs are claims about supported controls. This checker +extracts those claims and compares them with direct environment reads in the +runtime, code generator, and compiler. Historical experiment journals are +named exemptions: they preserve evidence without licensing a dead knob in a +live gate or current reference page. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from collections import defaultdict +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent + +# Keep the never-shipped nursery spelling visible to the scanner without making +# this regex definition itself look like a current documentation claim. +NEVER_SHIPPED_NURSERY = r"PERRY_" r"NURSERY_MB" +KNOB_RE = re.compile( + rf"\b(?:PERRY_GEN_GC(?:_[A-Z0-9_]+)?|PERRY_GC_[A-Z0-9_]+|" + rf"{NEVER_SHIPPED_NURSERY}|PERRY_WRITE_BARRIERS|PERRY_SHADOW_STACK|" + rf"PERRY_RS4GC|PERRY_STACKMAP_WALKER|PERRY_CONSERVATIVE_STACK_SCAN)\b" +) +PARSER_RE = re.compile( + r'(?:std::env::var(?:_os)?|env_var)\(\s*"(PERRY_[A-Z0-9_]+)"\s*\)' +) + +PARSER_ROOTS = ( + "crates/perry-runtime/src", + "crates/perry-codegen/src", + "crates/perry/src", +) +CLAIM_ROOTS = ( + "docs", + "scripts", + "benchmarks/gc_ratchet", + ".github/workflows", +) +CLAIM_SUFFIXES = {".json", ".md", ".po", ".pot", ".py", ".sh", ".yaml", ".yml"} + +# These are chronological evidence, not current reference documentation. Keep +# this list path-exact: adding a whole directory would let a new current page +# inherit an exemption accidentally. +HISTORICAL_DOCS = { + "docs/ecs-perf-case-study.md", + "docs/generational-gc-plan.md", + "docs/statepoint-gc-experiment.md", +} + +# Script-owned output plumbing shares the PERRY_GC_ prefix but is intentionally +# not parsed by runtime/codegen. Each exception names the executable owner and +# is checked for staleness too. +SCRIPT_OWNED = { + "PERRY_GC_EVIDENCE_DIR": "scripts/run_memory_stability_tests.sh", +} + + +def strip_rust_comments(source: str) -> str: + """Remove comments so a deleted parser cannot survive as coverage.""" + without_blocks = re.sub(r"/\*.*?\*/", "", source, flags=re.DOTALL) + return re.sub(r"//[^\n]*", "", without_blocks) + + +def production_rust_files(root: Path): + for base in PARSER_ROOTS: + for path in (root / base).rglob("*.rs"): + rel = path.relative_to(root) + if "tests" in rel.parts or path.name == "tests.rs" or path.name.endswith("_tests.rs"): + continue + yield path + + +def parsed_knobs(root: Path) -> dict[str, set[str]]: + found: dict[str, set[str]] = defaultdict(set) + for path in production_rust_files(root): + rel = path.relative_to(root).as_posix() + source = strip_rust_comments(path.read_text(encoding="utf-8")) + for name in PARSER_RE.findall(source): + found[name].add(rel) + return found + + +def claim_files(root: Path): + yield root / "CLAUDE.md" + for base in CLAIM_ROOTS: + for path in (root / base).rglob("*"): + if path.is_file() and path.suffix in CLAIM_SUFFIXES: + rel = path.relative_to(root).as_posix() + if rel not in HISTORICAL_DOCS: + yield path + + +def claimed_knobs(root: Path) -> dict[str, set[str]]: + found: dict[str, set[str]] = defaultdict(set) + for path in claim_files(root): + rel = path.relative_to(root).as_posix() + for name in KNOB_RE.findall(path.read_text(encoding="utf-8")): + found[name].add(rel) + return found + + +def problems_for( + claims: dict[str, set[str]], parsers: dict[str, set[str]], root: Path +) -> list[str]: + problems = [] + allowed = set(parsers) | set(SCRIPT_OWNED) + for name in sorted(set(claims) - allowed): + paths = ", ".join(sorted(claims[name])) + problems.append(f"{name}: claimed by {paths}, but no live parser owns it") + for name, owner in SCRIPT_OWNED.items(): + owner_path = root / owner + if not owner_path.is_file() or name not in owner_path.read_text(encoding="utf-8"): + problems.append(f"{name}: script-owned exception is stale; expected it in {owner}") + return problems + + +def self_test() -> int: + live = "PERRY_GC_" + "LIVE" + deleted = "PERRY_GEN_GC_" + "DELETED" + source = ( + f'let _ = std::env::var("{live}");\n' + f'// let _ = std::env::var("{deleted}");\n' + ) + extracted = set(PARSER_RE.findall(strip_rust_comments(source))) + failures = [] + if extracted != {live}: + failures.append(f"comment stripping admitted the wrong parser set: {sorted(extracted)}") + + claims = {live: {"docs/current.md"}, deleted: {"scripts/live.sh"}} + found = problems_for(claims, {live: {"runtime.rs"}}, REPO) + if not any(deleted in problem for problem in found): + failures.append("a claimed knob with no parser passed") + if any(live in problem for problem in found): + failures.append("a claimed knob with a live parser failed") + + historical = Path("docs/generational-gc-plan.md").as_posix() + if historical not in HISTORICAL_DOCS: + failures.append("the historical generational plan lost its exact exemption") + + for failure in failures: + print(f"GC env-knob self-test FAILED: {failure}", file=sys.stderr) + if failures: + return 1 + print("GC env-knob self-test: OK (dead and commented parsers are rejected)") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--self-test", action="store_true") + args = parser.parse_args() + if args.self_test: + return self_test() + + parsers = parsed_knobs(REPO) + claims = claimed_knobs(REPO) + problems = problems_for(claims, parsers, REPO) + if problems: + print("GC environment-knob drift check FAILED:", file=sys.stderr) + for problem in problems: + print(f" - {problem}", file=sys.stderr) + print( + "Delete or replace the stale claim, add a real production parser, " + "or name a genuinely historical document in HISTORICAL_DOCS.", + file=sys.stderr, + ) + return 1 + print( + f"GC environment-knob drift check OK: {len(claims)} claimed knobs, " + f"{len(parsers)} live env parsers, {len(HISTORICAL_DOCS)} historical documents exempt" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/gate_freshness.json b/scripts/gate_freshness.json index 2d74aa934b..8f17bd18e2 100644 --- a/scripts/gate_freshness.json +++ b/scripts/gate_freshness.json @@ -1,7 +1,7 @@ { "_comment": [ "Freshness budgets for the post-merge gate sweeps. Checked by", - "scripts/check_gate_freshness.py, run hourly by .github/workflows/gate-freshness.yml.", + "scripts/check_gate_freshness.py, run every two hours by .github/workflows/gate-freshness.yml.", "", "WHY THIS FILE EXISTS: #7856. Every heavy gate produced zero results on `main`", "for over two days and nobody noticed, because an empty result set is", diff --git a/scripts/run_memory_stability_tests.sh b/scripts/run_memory_stability_tests.sh index c0485f8dbb..f1240781dd 100755 --- a/scripts/run_memory_stability_tests.sh +++ b/scripts/run_memory_stability_tests.sh @@ -35,8 +35,7 @@ # - mark-sweep (PERRY_GEN_GC=0 — bisection escape hatch) # - explicit generational GC (PERRY_GEN_GC=1) # - force-evac+verify (default write barriers + forced evacuation verifier: -# PERRY_GEN_GC_EVACUATE=1 PERRY_GC_FORCE_EVACUATE=1 -# PERRY_GC_VERIFY_EVACUATION=1) +# PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1) # so a regression in any mode is caught. # # Usage: scripts/run_memory_stability_tests.sh @@ -386,7 +385,7 @@ run_test() { "default||" "mark-sweep||PERRY_GEN_GC=0" "gen-gc-explicit||PERRY_GEN_GC=1" - "force-evac+verify||PERRY_GEN_GC=1 PERRY_GEN_GC_EVACUATE=1 PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1" + "force-evac+verify||PERRY_GEN_GC=1 PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1" ) for spec in "${mode_specs[@]}"; do @@ -1566,7 +1565,7 @@ run_target_collector_architecture_gates() { "default_copying|4852|$workloads_dir/default_copying.ts" "string_heavy|17028|$workloads_dir/string_heavy.ts" "closure_heavy|243150|$workloads_dir/closure_heavy.ts" - "async_promise_closures|18540|$workloads_dir/async_promise_closures.ts|PERRY_GEN_GC_EVACUATE=0 PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1" + "async_promise_closures|18540|$workloads_dir/async_promise_closures.ts|PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1" "large_object_barriers|36052|$workloads_dir/large_object_barriers.ts" "raw_numeric_layouts|71|benchmarks/compiler_output/fixtures/raw_numeric_layout_smoke.ts" ) From d4ae856fb4609f5c99a862ef5a7b2771f628c8aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 11 Aug 2026 20:59:04 +0200 Subject: [PATCH 2/2] chore: add changelog for PR 7883 --- changelog.d/7883-current-gc-docs.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 changelog.d/7883-current-gc-docs.md diff --git a/changelog.d/7883-current-gc-docs.md b/changelog.d/7883-current-gc-docs.md new file mode 100644 index 0000000000..f980d4a6cf --- /dev/null +++ b/changelog.d/7883-current-gc-docs.md @@ -0,0 +1,20 @@ +### GC documentation now has one current source of truth and rejects deleted knobs + +The generational collector accumulated several chronological plans whose opening +decisions no longer matched the shipped implementation. A deleted +`PERRY_GEN_GC_EVACUATE` setting also remained in required memory-stability arms, +ratchet metadata, current documentation, and the generated translation catalogs; +those test arms looked distinct while selecting the same runtime behavior. + +A dated collector architecture/operations page now records the shipped collection +paths, target-specific root lowering, barriers and weak processing, pressure and +pooling behavior, supported controls, old-generation defragmentation status, and +the CI contexts that are actually required. The experiment journals are explicitly +historical, and stale source paths, checker status, engine-plan authority, and gate +freshness cadence are corrected. + +The dead setting is removed from every live/generated claim. A new CI audit derives +accepted GC knob names from uncommented production runtime, codegen, and compiler +parsers, while allowing only three path-exact historical journals. Its self-test +plants a deleted knob behind a commented-out parser and proves that neither can make +a live claim pass.