gc(tenuring): the survival-rate lock latches S=1 only on steady-state evidence, never from the startup cohort - #9949
Draft
proggeramlug wants to merge 7 commits into
Draft
Conversation
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…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.
proggeramlug
force-pushed
the
perf/tenuring-evidence-lock
branch
from
September 7, 2026 12:32
2f7d203 to
a771c33
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Runtime-only, stacked on #9861 (
2248fba56). Written by codex from the campaign's TN4 measurement; not yet compiled — perrymaster's gate ladder and the TN5 rows will be appended here.Why
TN4 (main + #9861, 4-turn cc replies, quiet host): the adaptive tenuring loop's only S change is
survivals 2 -> 1 (lock, eden_live_bytes=3.2 MB desired=2.1 MB)at startup, right after the second copying minor — the survival-rate lock rates the process's startup cohort (copied once at S=2, returned nearly intact) and latches promote-on-first-copy before the first turn. From then on it promotes ~7 MB per turn where S=2 pinned promotes 4.3, and turn 1 costs +0.3…0.4 s against S=2 pinned (and +0.5 against S=1 pinned) with extra full-cycle work; turns 2–4 are equal across arms; S=2 pinned is the best arm on four-turn CPU (−5…6 %), peak and settled RSS. The power-on fix in #9861 gated the occupancy rule from claiming the ceiling before a round is measured; the lock still concluded from the first rated round. This is the campaign design note's item 1: the first rateable cohort of a process is the least representative one.What changes
prev_cohort_copied > 0(the cohort-scoped signal from fix(gc): the tenuring occupancy rule may not claim promote-on-first-copy (#9851) #9861). The lock's entry now requires the round to be post-startup (startup = the process's first two rated survivor cohorts — the loop's own threshold-invariant evidence; the allocation census is deliberately not the marker because it seeds before the first copying minor), with fresh intake ≥desired / 4(the existing substantial-volume bar) and survival ≥ 90 % (the existing bar), for K = 3 consecutive rated rounds (PROMOTE_LOCK_STREAK); any rated round failing those resets the streak. K = 3 is the smallest window that rejects a one- or two-cycle phase boundary while a truly non-dying workload still reaches S=1 within five rated cohorts.seed_promote_lock_from_sweepkeeps its two conditions but refuses while fewer than two cohorts are rated. The unlock path is unchanged except that unlocking clears the entry streak.PERRY_GC_DIAGeach copying minor accumulates copied and promoted bytes; with the cumulative minor pause andstep_us + remark_usthese givecopy_cost = copy_pause_us / tenuring_copied_bytesandpromote_cost = promote_us / tenuring_promoted_bytes, the two terms of the design's decision rule (keep aging while mortality > copy_cost / promote_cost). The 90 % constant stays until TN5 supplies both prices aligned.rounds_rated= streak= survival_permille= copied_bytes= startup= copy_pause_us= tenuring_copied_bytes= promote_us= tenuring_promoted_bytes=; the[gc-time]exit block gains the four cumulative fields. No cost when diagnostics are off.Tests (named; sabotage stated in the campaign report; not yet executed)
startup_shaped_survivors_do_not_contribute_to_the_lock_streak,k_steady_fully_surviving_rounds_latch_promote_on_first_copy(the kill condition: a non-dying workload must still latch),mortality_inside_the_steady_window_resets_the_lock_streak,sweep_seed_cannot_latch_from_a_startup_census; #9861's pinned-S tests andoccupancy_may_not_claim_the_ceiling_before_any_round_is_measuredunchanged.Predictions for TN5 (falsifiers)
On cc, the adaptive arm shows no latch during startup; turn 1 equals S=2 pinned within noise; turns 2–4 equal S=2 pinned; promoted bytes in turns 2–4 ≈ 12 MB; peak and settled RSS equal S=2 pinned's. Gates run so far: rustfmt, diff check, file-size gate, root-holder inventory + self-test. Not run (disk): the runtime suite, the archive build, the default compiler build. Draft until perrymaster's ladder and rows are on this PR.
Measured — TN5 (perrymaster, main
504e180d0+ #9861 + this commit, runtime relinked on main's cache; gate: default build, archive feature set, runtime suite 3238 passed / 0 failed / 4 ignored with all 20 named tests green; 4-turn graceful runs, quiet box, stamped rows)400: adaptive 0.73, 0.34, 0.53, 0.36 (S2:7) = pinned S=2 0.76, 0.34, 0.58, 0.37; pinned S=1 0.70, 0.40, 0.47, 0.36. Second adaptive round (re-run clean, load 0.07): 3.07, 1.94, 1.97, 2.03 → 9.01; S-history S2:16 S3:7 S4:2, every transition occupancy-driven with
streak=0(rated survival 10–699 ‰, so the lock never has evidence on cc); promoted t2–4 9.8 MB; peak 618 / settled 523 — the same as round 1 and as S=2 pinned.Reading: the predictions hold. The adaptive arm never latches promote-on-first-copy on cc; it climbs the occupancy ladder (2 → 3 → 4 → 2 …) on measured rounds, turn 1 equals S=2 pinned, turns 2–4 equal S=2 pinned, steady promotion is the lowest of all arms (10.0 MB), and the four-turn total (8.87 s) beats every pin and improves on main's adaptive by −7.7 %. Peak RSS within S=2 pinned's range, settled equal. Prices from the new counters (per run): copy ≈ 10.5 ns/byte at S=2 and ≈ 22.8 at the adaptive arm's higher ages, promote ≈ 32–57 ns/byte; the design's decision rule now has both terms on file.