Skip to content

gc: allocation never begins a precise collection phase (RFC deferred collection S5, runtime) - #11630

Draft
proggeramlug wants to merge 15 commits into
mainfrom
gc/s5-runtime-invariant
Draft

proggeramlug wants to merge 15 commits into
mainfrom
gc/s5-runtime-invariant

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

RFC deferred collection (#11528), step S5, runtime half: an allocation never begins a precise or moving collection phase (D2), plus the unmapped-frame safety net and the valve gate (decisions 5 and 10). The new polls are the follow-up PR (gc/s5-entry-polls). This PR changes no call's safepoint classification.

Allocation-point inventory and routing

An allocation point is the dynamic extent of gc_check_trigger. Every slow path funnels into it: arena_cell_alloc → gc_check_trigger, every gc_malloc → gc_check_trigger_inlined, the JSON mid-parse checks (json/parse_api.rs, parse_inline_object.rs), and the root-lock flush of a deferred trigger check. AllocationPointGuard (gc/alloc_point.rs) marks that extent.

RFC § 1 entry where before now
A-old (OldReclaim) gc/policy.rs gc_check_trigger_evaluate conservative full at the alloc point unchanged (decision 1). Reported, not gated
A-valve same conservative non-moving minor after 64 MiB slack unchanged. Gated to zero (decision 5)
polls-off direct minor same conservative non-moving minor unchanged (the valve with zero slack under the kill switch)
A-assist policy.rs gc_budgeted_start_or_step → cycle.rs step_root_scan / FinalRootRemark precise frame-root read inside a mutator assist parked: the step refuses the phase (frame_root_phase_refused), the poll is armed, and the next declared poll serves it (gc_safepoint_moving_minor → serve_budgeted_root_phase). Heap-only phases still advance from assists
A-emerg gc/mod.rs gc_try_emergency_reclaim conservative full unchanged
D (root-lock flush) policy.rs flush_deferred_gc_request ran the deferred minor, full or manual gc() at the lock exit a trigger check still runs (as an alloc point); a collection is handed to the next poll (GC_POLL_OWED_REQUEST). This is option 2 of #11523
  • Enforcement. assert_d2_synchronous_collection() runs at both synchronous chokepoints (gc_collect_minor_with_trigger_inner, gc_collect_full_mark_sweep_with_trigger). It panics in every build if a collection begins at an allocation point without requesting the conservative scan. It checks the request (ManualGcScanGuard), because the test isolation guards and PERRY_CONSERVATIVE_STACK_SCAN=off override the decision on purpose.
  • Deleted. The PERRY_GC_SAFEPOINT_ONLY runtime contract (heal/strict), and with it ConservativeScanSite::SafepointContractHeal. D2 is now the default. Codegen still reads the variable for its research AllocNoReentry switch; S6 replaces that with the table.

New, and an owner question: the parked-cycle valve. An active budgeted cycle blocks every other collection, the nursery valve included. So a parked cycle with no poll in reach would grow without bound. After the nursery slack past the park point, the phase is served at the allocation point, and that firing is counted (parked_valve_fires, gated to zero). This is precise at an allocation point:

  • It is sound only while every allocating call is a statepoint, i.e. until S6.
  • Budgeted cycles are classifier-mode, so they cannot take the conservative scan themselves.
  • Before S6 it must be proven unreachable or replaced by aborting the parked cycle, which is a design item of its own.

This is raised on #11528.

Safety net (layer 3)

gc/roots/stack_maps_frame_verify.rs: a precise collection that finds a frame whose function is a generated statepoint function, but whose return address matches no record, panics.

  • Membership.

    • The unwinder's _Unwind_GetRegionStart.
    • _Unwind_Find_FDE on the x29-chain walker.
    • RUNTIME_FUNCTION.BeginAddress on Windows.

    Each result is looked up in the GC map's function list. No range table was needed.

  • Zero-record functions. An instrumented compile (PERRY_GC_INSTRUMENTS=1, PERRY_GC_VERIFY_FRAMES or a seed at compile time) also lists statepoint functions that have zero records, i.e. functions whose every call is a leaf. v6 runtimes already skip such entries, so the format is unchanged.

  • Not checked. Collections that requested the conservative scan.

  • Armed by PERRY_GC_VERIFY_FRAMES=1 (a new instrument knob), by PERRY_GC_SCHEDULE_SEED, and in debug_assertions / gcaudit builds.

  • Release status: off. Every unmatched frame costs an FDE lookup, and the zero-record listing costs map bytes. Instrumented CI arms cover it.

  • Liveness. frames_verified= on [gc-schedule] done and [gc-verify-frames]. gc_instrument_smoke.sh gains arm 8, which fails on frames_verified=0.

Gates

  • Valve (decision 5). PERRY_GC_VALVE_LEDGER=<file> makes every process append one line at exit. scripts/gc_valve_ledger_check.py (with a self-test) fails on any valve_fires / parked_valve_fires / d2_violations above zero, and on fewer lines than passing tests, which proves the check ran.
    • Wired into the pr-tier gap-suite shards.
    • Wired into a new gc-stress step over the 14 ratchet probes (scripts/gc_valve_ratchet_probes.sh).
  • Unsafe zones (decision 10). Bytes of arena growth while GC_UNSAFE_ZONES is held, on [gc-alloc-point]. Diagnostic only.
  • Decision 3 metric. max_poll_wait_bytes: the most the arena grew between arming a collection and draining it at a poll (or giving it to the valve).

Measurements

All on qb4 (EPYC 9275F), release builds, PERRY_NO_AUTO_OPTIMIZE=1, same base be39bbf3c, arms interleaved. Instructions are perf stat instructions:u medians of 5 runs; peak RSS is the ru_maxrss median. The box is shared, so wall time is not used.

Compute (32 programs): benchmarks/suite, bench_*, the 14 GC ratchet probes, the event-loop server fixture and the decision-3 fixtures.

this PR vs main
largest instruction delta, any program +0.11% (probe_02)
programs within ±0.1% 31 of 32
bench_string_heavy +0.00%

bench_string_heavy was +3.6% until the unsafe-zone note moved off the inlined arena_cell_alloc onto the out-of-line block-reserve path.

RSS, decision 8.

main this PR Δ
worst single program, peak +1.68% (12_binary_trees, bar ≤2%)
ratchet median peak RSS (14 probes, 7 repeats) 53.62 MB 53.80 MB +0.34%
ratchet median settled RSS 49.50 MB 49.57 MB +0.13%
minor cycles, copied and promoted counts identical on every probe

Owner call: the median delta is positive, inside the harness's documented 0.41% spread, and matches the 93 KB (+0.6%) of added text (verifier and counters). If "no median regression" means exactly zero, this does not meet it.

cc bundle (claude-code 2.1.112, compiled with PR #11631, which contains this PR, 5 paired runs, private HOME; -p turns are blocked by #11559):

Δ instructions Δ peak RSS
--version +0.002% +0.00%
--help +0.002% +0.60%

The compile itself is 67 min and 37.8 GB peak in both arms.

Latency (decision 6). Event-loop server fixture: 30k requests over setImmediate, a 200-object response graph each.

  • Default configuration. Budgeted cycles never start; p50 53–54 µs and p99 68–71 µs in all arms.
  • Forced budgeted cycles (PERRY_GC_MOVING_LOOP_POLLS=0 PERRY_GC_SCAVENGE=0, 5 rounds). No valve fired. Serving the root scan at the poll in one step first made step_max_us 40–53 ms against 34–35 ms. The poll now serves one host-sized slice per poll, like a host step, and stays armed until the phase is done. After that change:
main this PR
step_max_us 26–35 ms 26–36 ms
final_remark_max_us 1.3–2.1 ms 1.3–2.2 ms
p99 5.8–7.7 ms 5.2–7.7 ms
p999 19–29 ms 17–30 ms

Tests

  • gc/tests/alloc_point_invariant.rs:
    • assists park at both root phases, the poll serves each, and the cycle completes with the root intact (subject-live asserts on the phase and the counters);
    • sabotage: a precise collection planted at an allocation point panics;
    • a conservative collection there is allowed;
    • a root-lock exit hands its collection to the poll;
    • the parked valve fires and is counted;
    • the unsafe-zone counter.
  • stack_maps_frame_verify.rs: sabotage. An unmatched frame in a listed generated function panics under a precise collection, and a runtime frame or a conservative collection does not.
  • The existing tests that asserted "the lock exit collects" now assert that it does not and that the poll does (roots.rs, noncollecting_root_lock.rs). The assist-drives-the-cycle tests call the loop's back-edge poll (support::back_edge_poll).
  • Runtime lib suite (RUST_TEST_THREADS=1 cargo test --release -p perry-runtime --lib, final head): 4712 passed, 0 failed.
  • GC gap subset (57 test_gap_gc*, run on the stacked entry-polls build, which contains this PR): 57/57, and the valve ledger has 57 lines, all zero. The full gap suite and gc-stress run in CI.

@proggeramlug proggeramlug added the run-extended-tests Opt PR into compile-smoke/parity/doc-tests/drizzle-mysql-smoke label Sep 28, 2026
@coderabbitai

coderabbitai Bot commented Sep 28, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-extended-tests Opt PR into compile-smoke/parity/doc-tests/drizzle-mysql-smoke

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant