Skip to content

gc(nursery): the steady-state cap is bounded by RSS, not re-denominated by survivor statistics — denomination ends at the first copying minor, no mortality shrink, cap-due minors rate the scale - #9968

Draft
proggeramlug wants to merge 9 commits into
PerryTS:mainfrom
proggeramlug:perf/nursery-cap-rss-evidence

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Runtime-only, stacked on #9949 (a771c33fc, itself on #9861). Written by codex from the campaign's TN5/NS3 diags (cc-perf-campaign/DESIGN_nursery_cap_evidence.md in the campaign repo); rows on perrymaster pending (stage NC: paired against #9949 on the same bundle cache, then the gc_ratchet ladder).

Why

Two survivor statistics shrink the copying nursery's cap on evidence that says nothing about what a big nursery costs, and on cc both fire:

  1. The object denomination re-denominates the steady-state cap on every minor. influx_driven_nursery_cap_bytes = base × NURSERY_CAP_SCALE × mean_surviving_object_bytes / 72 (perf(gc): the nursery/promotion budget is denominated in BYTES but the collector's cost is per-OBJECT — compaction is taxed back #7929/perf(object): remove the derivable object_type and field_count header words (56 B -> 48 B) [HELD: #8157 refuted; footprint-coupled residual + new #8094 guard cost] #8122, clamped to [500 ‰, 1000 ‰]). On the landing base (TN5 diag, 4-turn cc @3300) the scale sits at ×4 from minor 4 on and never moves, yet the effective cap oscillates 64 → 54 → 35.5 → 41.7 → 38.2 → 32 → 33.7 → 64 → 56 → 40 → 34.6 → 60.4 → 32.8 … MB: cc's survivors are small strings/boxes (36–61 B mean) and 12 of the 20 steady minors ran at a 32–42 MB cap — every one cap-due. perf(gc): the nursery/promotion budget is denominated in BYTES but the collector's cost is per-OBJECT — compaction is taxed back #7929 measured the denomination on deeplist/retain1, where a minor's cost is the objects it moves; in the copying steady state the cost is fixed + O(moved) (MP phase table: a steady cc minor is 46 ms of which 2.5 ms is copying), and moved objects per allocated byte are set by survival, not by the cap. Denominating the allocated band in objects only multiplies the fixed cost.
  2. The mortality shrink collapses the scale. retune_nursery_cap_scale shrinks one step when eden_live < cap/100 on two consecutive minors. NS3's n16 control: 4x→2x at eden_live 46 KB and 2x→1x at 33 KB (two reply-phase minors where everything died), then 16 MB for the rest of the run — 22–26 minors vs 13 at the same base and the same peak RSS. Low survival is the reason a big nursery is free; the rule read it as the reason to give it up.

What changes (nothing re-tuned: 16 MB base, ×2 steps, ×4 ceiling, 4 % grow band, 2-cycle debounce, 72 B reference, 500 ‰ floor all stand)

Fixture replays (in the tests)

  • TN5 trace (25 minors): every cap-due row after the ramp computes 67,108,864 B; the 32–42 MB rows disappear.
  • NS3 trace (26 minors): growth exactly at rows 2 and 4 as recorded; the mortality shrinks at rows 7 and 11 disappear; scale 4 at row 26.

Tests (named; sabotage stated in the campaign report)

steady_state_cap_is_not_object_denominated_after_first_copying_minor (sabotage: let the survivor mean feed the steady cap → row 8 returns 56,841,207 B), first_minor_keeps_the_object_denomination (drop the regime test → the raw 16 MiB band instead of 9,311,354 B at a 40 B mean), nursery_cap_scale_does_not_shrink_on_low_mortality (restore < cap/100 → row 7 shrinks to 2), nursery_cap_scale_still_grows_on_influx, non_cap_due_minor_does_not_rate_the_scale (drop the cap-due return → two forced high-influx minors grow the scale). #9949's tests unchanged.

Gates (local)

cargo test -p perry-runtime --release --lib -- --test-threads=1: 3,272 passed, 0 failed, 4 ignored (35 in the tenuring filter). cargo build --release -p perry-runtime --features wasm-host: ok. rustfmt / diff-check / file-size: ok.

Falsifiers (perrymaster, pending)

Control = #9949 relinked on the same cache. Cap = 64 MB on every cap-due minor after minor 6; scale transitions = the ramp only; copying minors per 4 turns 25 → ≈ 20–21; 4-turn CPU −2…−4 % (≈ 64 ms per avoided steady minor); peak RSS +0…+3 %; promoted t2–4 ± 2 MB; 400-char turn flat or better; gc_ratchet: no probe above baseline instructions or outside its RSS tolerance — deeplist, retain1, tree_wide are the ones #7929 measured; a regression there means steady denomination was pricing real copy work and the fallback is to weight it by the previous minor's copy share. Kill: any row above +10 % max RSS, or minors down with CPU up.

GC policy: needs the run-extended-tests label.

Measured (perrymaster NC, 2026-09-08; control = #9949's tree, arm = + c186483, same bundle cache)

  • Mechanism exact, both rounds: the control's cap after the ramp oscillates 64 → 61 → 37 → 41 → 38 → 34 → 34 → 64 → 56 → 34 … MB (12 of 20 steady minors at 32–42 MB); the arm holds 64 MB on every minor from minor 5 on; scale transitions are the ramp only (1x -> 2x at minor 1, 2x -> 4x at minor 3, both why=influx cap_due=true); the object denomination is applied=true once (allocation seed) and applied=false afterwards while the measured mean keeps moving (66 → 77 → 84 → 133 → 85 → 118 → 110 → 61 …) and the band stays 67,108,864 B. Every later minor is logged not rated: with the cap at 64 MB the steady minors are armed by the arena trigger below the cap, so none is cap-due.
  • Copying minors per 4 turns 26/25 → 19/20; copied bytes per turn window 8.1/7.8/7.7 → 7.4/5.7/5.4 MB; promoted bytes in turns 2–4 within ±1 MB; 400-char turns identical on every axis.
  • CPU flat: 4-turn totals 9.18 → 9.48 s (+3.3 %, one turn-3 excursion) and 8.90 → 8.83 s (−0.8 %); peak RSS 622/608 → 617/629 MB (+0…+3 %). The −2…−4 % prediction priced each avoided minor at its whole pause; per the phase table only root scan and the side-table walks are per-minor constants, so six avoided minors are worth ≈ 0.15 s, inside row noise, and the larger eden costs locality.
  • Verdict: the policy does what it claims (no flapping, evidence-only rating, denomination confined to the tracing regime) and is CPU-neutral on cc. Not a performance landing; kept as a draft pending the gc_ratchet probe ladder (NC2) for the RSS/instructions envelope on the probe corpus.

VERDICT (2026-09-08): not landing — the kill condition fired on the probe corpus

gc_ratchet ladder (14 probes + tree_wide/churn/cycles/pipeline/12_binary_trees, 7 repeats, plain archives as CI builds them): instructions hold (churn −12.1 %, the rest ±0.4 %) but max RSS regresses on small-live-set programs: 05_closure_capture +15.5 %, 08_map_set_sidetables +13.3 %, 04_dead_after_deep_stack +7.1 %, 10_store_receiver_across_alloc +6.4 %, cycles +25.5 %, pipeline +7.4 %. Cause: the control's mortality shrink brings such a program's nursery down to 14.7–16.8 MB; without it the 4×-scale band stays at 22 MB for the program's life (fewer minors, no wall gain, more touched pages). The live-set-bound probes also promote more without the steady denomination (12_large_live_set promoted +9.5 %, 13 heap_used +6.6 %), so #7929's measurement stands for that regime. Combined with the cc rows (CPU-neutral), this PR is kept only as the record: the mortality shrink is the RSS regulator for small programs, and cc's minor cadence is set by the arena trigger, not the cap.

https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo

Ralph Küpper and others added 9 commits September 7, 2026 14:32
…opy (PerryTS#9851)

The adaptive tenuring loop takes its one and only survivor-round mortality
sample on the FIRST minor of the process -- when the cohort really is immortal
(99.1 % survival) -- drops the threshold to 1, and thereby destroys its ability
to ever sample again: n=1 across 352 minors. In steady state an aging round
filters 26.1 % of each cohort, and the loop cannot see it.

`retune_after_scavenge` picks the threshold from `S = 1 + desired / influx`, the
largest S whose projected survivor occupancy `(S-1) x influx` fits the desired
survivor size. With integer division, any influx above `desired` yields exactly
1 -- there is no rung at 2 or 3. On the compiled claude-code TUI the first drop
reads `eden_live_bytes=12075344` against `desired=1048576`.

S=1 does not reduce the surviving data; it relocates it, from the survivor space
-- where the next minor re-examines it for free -- to the old generation, which
only a full can reclaim. The occupancy formula has no term for that. And S=1 is
self-sealing: nothing is copied, so `copied_bytes` is 0, so next cycle
`prev_copied` is 0, so the survival-rate lock's guard (`prev_copied >=
substantial`) is false forever. Both remaining exits -- the occupancy recompute
and PROMOTE_LOCK's unlock -- are QUIET-INFLUX exits, which say nothing about
lifetime. The loop concludes "long-lived" from a premise about space and then
removes its ability to check.

Measured, 4 streamed turns in one process, both arms from one binary via the
diagnostic knob PERRY_GC_TENURING_SURVIVALS, 3300-character replies:

                                   adaptive        pinned S=2
  minors at S=1            351 of 352 (100 % of promotion)     0
  threshold transitions                   1                    7
  mortality samples                       1                  393
  median mortality                     0.9 %               26.1 %
  ...steady turns 2 / 3 / 4    not measurable   26.1 / 26.1 / 26.1 %
  substantial cohorts < 10 %            1/1                5/358
  promoted                          1057 MB               792 MB

The occupancy rule now stops at the lowest threshold that still PRODUCES that
measurement. 2 is forced by the requirement rather than tuned: at S=1 nothing
enters the survivor space, at S=2 exactly one cohort does.

The clamp is at the USE SITE, not inside `compute_target_survivals`: that pure
function has a second caller, `full_seed_promotes_on_first_copy`, which gates the
sweep seed on `... != 1`. Clamping the shared function would silently disarm the
sweep seed, which is one of the two paths that IS allowed to reach 1. Reaching 1
still belongs to the survival-rate lock and the sweep seed, which measure
mortality; both are untouched, so the rule is self-limiting -- on a workload
whose cohort genuinely does not die the lock fires after one cohort's copy and
takes the loop back to 1. On claude-code it correctly does not: 5 of 358
substantial cohorts sit under the lock's 90 % bar, so the clamp holds rather
than oscillating.

Tests. `target_formula_matches_projected_occupancy` is byte-identical -- the
arithmetic is untouched, and that test is the proof. Four tests move an expected
value 1 -> 2 and keep their names, structure and invariants:
`drops_immediately_and_rises_debounced` (asymmetric response: immediate drop,
debounced rise -- 4 -> 2 shows it as well as 4 -> 1),
`steady_heavy_influx_is_a_fixed_point` (fixed-pointness, now at 2),
`heavy_influx_lowers_threshold_and_promotes_next_cycle` (its promotion half is
untouched: the cohort was copied once, so `next_age` is 2 on cycle 2 and it
still tenures exactly when the test says) and
`quiet_cycles_restore_power_on_threshold_debounced` (the debounced restore is
asserted structurally and survives). Two new tests: the two-phase attributed
pair -- occupancy alone holds at the floor and has not taken the lock's route,
then a substantial fully-surviving cohort still reaches 1 through the lock --
and a dying-cohort test at claude-code's measured 74 % survival.
… survivor space

Follow-up to the previous commit, and caused by it. PerryTS#9851's clamp stops the
occupancy rule concluding "promote on first copy", and measuring the relinked
candidate showed it buys -7 % of promotion where the pinned control buys -26 %:
85 % of promotion still happens at S=1, now reached through the survival-rate
lock 8-12 times per four-turn run.

That is a consequence of the clamp, not a coincidence. At S=1 nothing is copied,
so `prev_copied` is 0 and the lock's guard can never be satisfied -- the previous
commit's own argument. Removing the seal hands the lock its guard back, and the
lock then reaches 1 by itself.

The lock tested

    prev_copied >= substantial && survivor_live_bytes * 10 >= prev_copied * 9

where `survivor_live_bytes` is every live byte leaving the from-survivor space
this cycle, of any age, and `prev_copied` is the previous cycle's whole intake.
Those two scopes MATCH: the survivor spaces are a strict semispace pair (to-space
reset before the minor, everything copied into it, then flip), so the from-space
at cycle N holds exactly what cycle N-1 copied. The ratio is well-formed and
cannot exceed 1. The defect is not the arithmetic.

The defect is which POPULATION the ratio rates, and that is chosen by the very
threshold the lock sets. At S <= 2 the space holds one fresh cohort (age-2 is
promoted) and the ratio is one aging round's survival -- 74 % on the compiled
claude-code TUI, under the 90 % bar. At S = 3-4 it also holds age-2 and age-3
objects, which have already survived a round and are therefore selected for
longevity, so the aggregate clears 90 % while a fresh cohort does not. The rule
reads its own setting back as evidence. The clamp is what lets the debounced
rise reach 3 and 4, which is why this only became visible once the seal was gone.

The copier now accounts the fresh half of each cycle. `eden_copied_bytes` is
what this cycle copied out of EDEN into the to-survivor space (no re-copies) --
one cohort's intake. `survivor_first_round_live_bytes` is what came back out of
the from-survivor space alive with a stored survival age of 1, i.e. members of
exactly the cohort the previous cycle's `eden_copied_bytes` counted; the age is
already in the header at copy time (`copied_survival_age`), so no new per-object
state is needed. `retune_after_scavenge` keeps its arity and its two lock
parameters are redefined to those, which is the whole change at the policy end:
both sides of the ratio are now scoped to one cohort at every threshold.

Both new counts are on the `[gc-copy-minor]` diagnostic line next to the
whole-space ones, so first-round mortality is readable from ANY build rather
than only from an instrumented branch -- the measurement this policy is about
should not require a custom binary.

Measured, one binary, three arms via `PERRY_GC_TENURING_SURVIVALS`, 3300-char
replies, 4 turns in one process, macOS arm64:

  arm                          minors   promoted   S=1 share   via the lock
  =1 (pre-clamp equivalent)      356     1055 MB      100 %          -
  clamp only, run 1              368      982 MB       85 %          8
  clamp only, run 2              384      980 MB       84 %         12
  =2 (positive control)          380      785 MB        0 %         n/a

Tests. No existing expected value moves -- all 1,069 gc tests pass unchanged,
which is itself the finding: nothing in the suite distinguished the two scopes,
because they are equal on every heap whose survivor space holds one generation,
and that is every heap at a threshold of 2 or below. So the premise gets a test
of its own on a real heap: two rooted objects introduced one cycle apart at the
power-on threshold, asserting that the two numbers AGREE while only one
generation is resident and then DIFFER once an aged resident joins it, with the
aged object in the whole-space number and not in the cohort number. A test-only
witness (`test_last_cohort_split`) reports the pair the copier computed.

The two lock tests keep their values and gain the scoping in their names and
comments; `a_cohort_that_dies_in_its_round_holds_at_the_occupancy_floor` now
states that this same heap locks if the call site passes the whole space, which
is what it used to pass.

The previous commit's changelog fragment claimed the lock correctly stays out on
claude-code (5 of 358 substantial cohorts under the bar). That figure was taken
with the threshold PINNED, where every cohort the lock can rate is a first-round
cohort; it does not describe the rule running, and the fragment is corrected
rather than left to be read as a result.

Claude-Session: https://claude.ai/code/session_014knX724SYDogwzsXybCGxp
…d is measured

The symmetric half of PerryTS#9851. That commit stopped the occupancy rule concluding
"promote on first copy" -- a claim about LIFETIME derived from a measurement of
SPACE. The same formula makes the same category error at the other end:

    compute_target_survivals = 1 + desired / influx      (capped at the ceiling)

returns the ceiling for a tiny influx AND for a zero one. On the first minors of
a process -- heap nearly empty, no cohort ever followed -- occupancy therefore
claims the MAXIMUM, before a single object has been given the chance to die.
It is the expensive direction of the error, because every survivor is then
copied up to three times before it may be promoted.

Measured on the landing base (main5 + PerryTS#9881, one binary, four env arms, two
rounds of 4 turns at 3300 and 400, quiet host), this startup excursion is the
WHOLE difference between the adaptive loop and a pinned threshold:

  * unset vs pinned S=1: turn-1 CPU 3.41 s vs 3.02 s at 3300 (+0.35..0.45 s
    both rounds) and 1.05 s vs 0.72 s at 400 (+50 %), while the sum over turns
    2-4 is within noise (6.18-6.23 vs 6.35-6.41);
  * the adaptive arm's transitions are `4 -> 2 (occupancy) -> 1 (lock)` and ALL
    of them land inside turn 1; turns 2-4 run at S=1 with nothing copied.

So the adaptive policy's only cost on this workload was a startup claim it had
no evidence for, and its steady state was already the pinned one.

The rule is now symmetric: **until one survivor round has actually been rated,
the occupancy rule holds at `OCCUPANCY_MIN_SURVIVALS`.** That value is not a
tuning choice; it is the lowest threshold that PRODUCES the measurement the
rule needs in order to say anything -- at 1 nothing enters the survivor space,
at 2 exactly one cohort does. The power-on threshold becomes the same value for
the same reason: starting at the ceiling is a lifetime claim made before the
process has run.

`SURVIVOR_ROUND_MEASURED` is set the moment a cohort the previous cycle copied
becomes rateable, so the gate lifts after about two minors and the ladder is
unchanged from then on -- it delays the claim until evidence exists, it does not
remove the ladder. The two paths that MEASURE mortality are untouched: the
survival-rate lock and the sweep seed may still reach 1 whenever they have the
evidence for it.

`compute_target_survivals` is again left alone, and its test is again the proof:
the arithmetic still returns the ceiling for a zero and a tiny influx. Only what
the loop may do with that changes.

Tests. A new two-phase test: eight startup-shaped minors (tiny influx, nothing
copied) must leave the loop at the floor and out of the lock; then, once a
cohort has gone through the survivor space and been followed, the debounced rise
must still reach the ceiling. Sabotage: delete the gate, or restore the power-on
value to the ceiling, and phase 1 fails.

Two existing tests move with the power-on value and keep their properties:
`drops_immediately_and_rises_debounced` is about the ladder's ASYMMETRY, so it
now seeds a fully-dying cohort first (which rates a round without involving the
lock) and then tests the same immediate-drop / debounced-rise behaviour;
`sweep_seed_refuses_a_small_fully_live_eden` asserts the threshold is unchanged
from power-on, which is the floor now. `survival_rate_lock_breaks_a_saturated_
pipeline` needs no change -- the lock firing implies a rated round, so its
ladder recovery is unaffected.

NOT COMPILED: the box is at 7 GB free, under this campaign's 12 GB build floor,
so neither the build nor the suite has been run against this commit. The braces
balance and the reasoning above is stated per test, but that is a review and not
a check.
LAST_COHORT_SPLIT (cfg(test), copying.rs) is test_only; SURVIVOR_ROUND_MEASURED
(tenuring.rs) is a boolean, not a GC pointer. Inventory only; no code change.
Exclude startup cohorts from promote-on-first-copy decisions and require
three consecutive substantial high-survival rounds before latching S=1.
Gate sweep seeding on the same startup boundary and expose cumulative copy
and promotion price inputs under GC diagnostics.
Record the lock map, diagnostic schema, sabotage cases, disk-blocked gates,
and the exact TN follow-up request with falsifiable predictions.
Keep object denomination in the pre-copy tracing regime, remove mortality
shrinkage, and rate influx growth only when the triggering nursery was
actually cap-due. Preserve survivor censuses as diagnostics and add replay
tests for both captured workloads.

Claude-Session: https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo
Record the code facts, both fixture replays, test sabotages, validation gates,
pressure-predicate disposition, design contradictions, and perrymaster
falsifiers for the nursery-cap RSS policy.

Claude-Session: https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo
@coderabbitai

coderabbitai Bot commented Sep 7, 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.

@proggeramlug proggeramlug added the run-extended-tests Opt PR into compile-smoke/parity/doc-tests/drizzle-mysql-smoke label Sep 7, 2026
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