Repository navigation
gc: the base ArenaBytes arm paces the nursery by a quantity that is 99 % old generation (#7909's young basis, not applied to the base arm) #9839
Description
Activity
Second workload, same defect — plus a self-contained 20-line reproducer
Confirming this on something much smaller than the cc TUI, measured on main
33690c563. The fixture is plain JS (gc3from #10362: 300 000 allocated 8-node chains into a rolling ring of 40 000, no dependencies), so the arm can be studied without a compiled app.PERRY_GC_DIAG=1, per copying minor:minor survival copied promoted freed pause 1–5 1000 ‰ 0 11–45 MB each 0 bytes 2–9 ms 6–9 ~680 ‰ 14–30 MB 0–30 MB ~14 MB each 119–176 ms Triggers over the run: 10
ArenaBytes, 3OldGenBytes, plus 4 fulls.The first five collections reclaim nothing at all — they are pure promotion, 115 MB of it, into an old generation that then needs the fulls to clear. That is this issue's shape (c) with a quiet-nursery twist: the arm keeps firing nursery minors while the quantity it tests grows because of what the previous minor promoted. Promote → arena grows → arm fires → promote more. On cc you measured the young share of the tested quantity at 0.9 %; here the loop is visible end to end in a single 20-line program.
The other half: the cap ceiling
NURSERY_CAP_SCALE_MAX = 4(16 MB × 4) is reached on this workload, and the growth rule keys on influx (eden_live > cap/25) rather than on whether collections are reclaiming anything. Both facts point the same way: the nursery is below the workload's death window (the ring only drops a chain after 40 000 more are allocated), so everything is still reachable at collection time.Sweeping the base cap, holding allocation constant (instructions:u / peak RSS / max pause / collections):
default 16 MB 32 MB 128 MB 256 MB gc3 (40k retained) 12.57 G / 225 MB / 174 ms / 9 5.77 G / 227 MB / 116 ms / 5 4.34 G / 214 MB / 204 ms / 2 3.19 G / 250 MB / 209 ms / 1 w1000 (1k retained) 1.09 G / 65 MB / 8 ms / 11 1.10 G / 67 MB / 10 ms / 10 1.08 G / 141 MB / 20 ms / 2 1.14 G / 190 MB / 26 ms / 1 oldyoung 1.53 G / 85 MB / 33 ms / 3 1.33 G / 83 MB / 35 ms / 2 0.53 G / 81 MB / — / 0 0.53 G / 82 MB / — / 0 alloc-only 320 M / 47 MB / 3 ms / 7 361 M / 55 MB / 6 ms / 3 270 M / 94 MB / — / 0 270 M / 94 MB / — / 0 Two things worth taking from that table:
- A bigger constant is not the answer. It is nearly free on gc3 (−54 % instructions at 32 MB for +2 MB RSS and a lower max pause) and costs w1000 three times the memory for nothing. Any fix has to be evidence-driven, not a new constant.
- The signal to drive it already exists per cycle.
freed_bytes ≈ 0withsurvival_permille ≈ 1000says exactly "this collection reclaimed nothing because the nursery is smaller than this workload's death window". w1000 and alloc never show it; gc3 shows it five times in a row.
I'm drafting a policy change along those lines (grow only on that signal, bounded by the existing
tenured/2term and a memory budget, plus this issue's basis fix so promoted bytes stop pacing the nursery). Happy to hand it over instead if this is already someone's — the issue reads unclaimed, and nothing is implemented yet.Reproducer, node/bun-identical, expected
checksum=-606613590 live=-593353216 nodes=320000:var CHAINS = 40000, CHAIN_LEN = 8, TOTAL = 300000; function makeChain(seed) { var head = null; for (var i = 0; i < CHAIN_LEN; i++) head = { id: seed + i, payload: [seed, i, (seed ^ i) | 0, (seed + i * 3) | 0], next: head }; return head; } var ring = new Array(CHAINS); for (var i = 0; i < CHAINS; i++) ring[i] = null; var checksum = 0; for (var n = 0; n < TOTAL; n++) { var c = makeChain(n); var tag = "n" + (n % 1024); checksum = (checksum + c.payload[n & 3] + tag.length) | 0; ring[n % CHAINS] = c; } var live = 0, nodes = 0; for (var i = 0; i < CHAINS; i++) { var cur = ring[i]; while (cur !== null) { live = (live + cur.id) | 0; nodes++; cur = cur.next; } } console.log("checksum=" + checksum + " live=" + live + " nodes=" + nodes);
To see the diagnostics at all:
perry compilestrips thediagnosticsfeature via auto-optimize, so build the fixture withPERRY_NO_AUTO_OPTIMIZE=1 PERRY_RUNTIME_DIR=<tree>/target/release PERRY_LIB_DIR=<same>(without the DIR overrides it picks up a prebuilt archive and refuses as stale).Correction: my attribution above was wrong — the base arm fired zero times on that fixture
I claimed "10
ArenaBytes, 3OldGenBytes" and "four of the nine minors fired below the nursery cap". Both are wrong, and the error is worth recording because the label invites it.trigger=ArenaByteson the[gc-copy-minor]line does not identify the arm. The safepoint handler maps bothBudgetedGcTrigger::ArenaBytesandBudgetedGcTrigger::YoungScavengeCaptoGcTriggerKind::ArenaBytes(policy.rs:3589), anddiag_sites::trigger_decisionstamps the same string (policy.rs:3636). The[gc-trigger]line is what separates them. Re-reading my own capture:[gc-trigger] site=safepoint kind=ArenaBytes arena_total=14680064 next_base=134217728 from_space=11534256 nursery_cap=10938744arena_total(14.7 MB) is nowhere nearnext_base(134 MB);from_space(11.53 MB) is at 105 % ofnursery_cap(10.94 MB). That is the cap arm, due on its own young basis. Across all 13 decisions on gc3,arena_totalnever reachesnext_base: 0 base-arm firings, 10 cap-arm, 3PromotedCohortfulls.Across six fixtures: 1 base-arm firing in 47 nursery-churn decisions (w5000), and that one is healthy — young was 84 % of the tested quantity and the minor freed 36.7 MB at 94 ‰ survival. It doubles as a positive control that the classifier can see base-arm firings, so the zeros are real.
My "below the cap" claim was a cross-line pairing error: I took
desired=1396703from a[gc-tenuring] sweep-seedline emitted two minors later and paired it with minor 1's Eden. At minor 1 the cap was 10,938,744, not 22.3 MB — #8122's object census had re-denominated it down (mean_object_bytes 72 -> 47, scale 652 ‰). Every minor fired at 102–105 % of its cap. None below.What the fixture does show
The numbers themselves reproduce exactly (minors 1–5
survival=1000 copied=0 freed=0, promoting 11.5/11.5/23.1/24.1/45.1 MB; minors 6–9 survival ~680, freed ~14.4 MB, pauses 121–185 ms). The mechanism is not pacing:- Minors 1–5 have
copy_evacuation=0/0/0withpromotion=…/240299/11534256— whole-block promotion. Eden's blocks go to the old generation without liveness examination, sosurvival=1000, freed=0is definitional rather than an outcome. - The transition at minor 6 is exact:
copied_bytes=30,720,672= 40,000 chains × 768 B = the ring's entire retained set. The death window is 30.7 MB. No minor can reclaim anything until Eden exceeds it and takes the copying path.
So this fixture is evidence for the cap-growth and promotion-path question, not for this issue's basis. It does not belong to #9839 and I withdraw it as such.
On the basis fix itself
For anyone picking this up:
secret-tests/cc-perf-campaign/DESIGN_arena_contract.md§4 already works the solution space (design C), and records that variant 1 — the plain young gate — fails 26 tests in 7 modules, and that shape (c) "does not occur in any cc capture". It does not occur in any of my six either (0/47). Design C stays architecturally right, and its measurable value on the workloads either of us has is flat by construction. It would need a fixture that actually produces shape (c) (large-object or born-old growth with a quiet nursery, #5476's shape) before a gate table on it means anything.Apologies for the noise on the issue. The measurement stands; the attribution was mine and it was wrong.
- Minors 1–5 have
Summary
gc_budgeted_due_trigger's baseArenaBytesarm testsarena_total_bytes()— young generation + old generation + large objects — and schedules a
nursery minor, which can act on the young part only. Measured on the
compiled claude-code TUI, the young part is 0.9 % of the quantity the arm
tests at the median firing.
The sibling arm was already moved off exactly this basis. #7909 split
young_scavenge_cap_due()out ofArenaBytesprecisely because"the quantity this one tests is one a budgeted low-pause NON-MOVING cycle
cannot lower", and its doc comment predicts this data. The split was never
applied to the base arm, which still paces the nursery by the whole arena.
This is the basis half of the arm's design defect. #9831 / #9838 are the
step half, and they are complementary, not alternatives: #9838 fixes who
pulls the trigger down (the tiny-parse pressure guard) and deliberately leaves
the arm's own re-arm alone, recording in a new comment that above
ceiling - floorthe arithmetic re-arms atnew_total + floorwhateverstepsays. That closes the step route by measurement (−10.8 % CPU for +22 % settled
footprint, the forbidden trade). It leaves the basis untouched.
Measured
Five
PERRY_GC_DIAG=1captures, two independently built binaries(
cc_main_0905,cc_int_0905), 3300- and 400-character streamed replies.Per firing (not per trigger evaluation — the decision-input line describes a
different population), nursery occupancy is
copied_bytes + promoted_bytes + freed_bytesfrom the firing's own[gc-copy-minor] ranline, againstpre_in_usefrom the[gc-step]line that same collection emits.ArenaBytesfiringsStable to the digit across two binaries and two workload sizes. Script:
secret-tests/cc-perf-campaignscratchbasis_an.py.The three shapes the arm actually fires in
Splitting the same captures by phase (at the first
[gc-step]whosepre_in_usereaches 90 MB, where the streamed turn's allocation ramp begins)separates three mechanisms that the single name
ArenaByteshides:JSON.parse;in_useis flat across ~30 consecutive firingsMallocCountminors promoting ~3.2 MB each grow the old generation past a threshold no collection refreshedpromoted_bytes=216 freed_bytes=640), every timeShape (a) is #9831/#9838's. Shape (b) is a finisher asymmetry
(
gc_finish_malloc_trigger_collectionnever re-baselines the arena trigger)and is being fixed separately. Shape (c) is this issue's, and it is the only
one where the arm's basis is the defect rather than a symptom: a nursery minor
on it frees nothing, because there is nothing young to free.
Why the other routes are closed
Four variants were built and measured; all are recorded in
secret-tests/cc-perf-campaign/HANDOFF_arenabytes_solution_space.mdwith thebranches (
refuted/arena-*, head-stampedREFUTED — DO NOT LAND):The forbidden trade; now documented in-tree by fix(gc): price the tiny-parse pressure guard by the productivity backoff (#9831) #9838's comment.
re-baseline when they fire, so they interleave rather than coincide. At the
moment
ArenaBytesis due,MallocCounthas just re-baselined. 100 minors anda 50/50 arm split in both arms, identical to the digit. There is nothing to
yield to.
OldReclaim, which is evaluated first and has already declined at thatmoment. An arm that fires a full exactly when the full's own pacer says "not
yet" is a second pacer with a worse constant. It also cannot clear its own
cause:
BudgetedGcRebaseline::OldReclaimnever touchesGC_NEXT_TRIGGER_BYTES, so it re-fires on every evaluation.400 chars, −4.4 % at 3300, footprint and RSS improving at both, minors
100 → 78 with total reclaimed up 3.5 %. It fails 26 tests in 7 modules,
because arena pressure with a quiet nursery is then served by nothing.
That last number is why this is worth filing rather than dropping: the measured
win is real and the objection is a genuine hole, not a stale contract.
Proposed restatement
Both old-generation quantities already have arms that can act on them
(
OldReclaim's proportional band andarena_growth_full_escalation_due), andboth are evaluated before this one, so the declined case is handed on, not
dropped. The re-baseline on decline is what stops the once-per-block cadence the
routed variant hit.
What it would take
The 26 failing tests were read one by one (table in
secret-tests/cc-perf-campaign/DESIGN_arena_contract.md§1). No test'sinvariant is "a minor ran on a quiet nursery." Twenty are machinery
invariants — bounded stepping, phase parking, born-marked allocation,
drain-before-manual-gc, the atomic-finalize remark, trace shape, FFI safety,
root survival — for which
make_arena_trigger_due()is a fixture: thecheapest way to make the collector do something. Six assert
Minor, and ofthose only
dirty_store_workload_reports_remembered_set_and_ordinary_pausesneeds a minor in substance (it verifies remembered-set telemetry, which only a
minor produces).
So the change is one fixture plus two tests:
make_arena_trigger_due()also guarantees ≥BLOCK_SIZEof young occupancy,via the
force_next_general_arena_alloc_slowidiom eight siblingruntime_rootstests already use — which is why those eight passed under thevariant while the 26 did not.
rather than merely absent: the quiet-nursery decline re-baselines above total
and starts nothing; the same heap with a block of young occupancy starts the
minor.
on an empty nursery); remove the re-baseline → the second fails (the arm stays
due, the once-per-block cadence).
Two contract statements move with it and belong in the changelog: the
js_gc_memory_pressurelevel-1 contract ("trigger lowered, collection deferredto the next check") becomes "…if the nursery can be acted on" (level ≥ 2 sets
GC_OLD_RECLAIM_PENDINGand is unaffected), and the tiny-parse channel'scadence is unchanged.
Why an issue and not a PR
On cc this buys nothing today. Shape (c) does not occur in any capture, and
for shape (a) the young gate is identical in effect to #9838 — the guard
re-lowers the trigger at the next parse, so gating the arm merely moves the
firing to the next block rather than removing it. #9838 removes those
collections at the source, and it is the better fix for that shape. The young
gate's measured −21.7 % at 400 characters is the same prize #9838 claims;
only one of the two can have it, and they must not be measured as independent.
What this change buys is architectural: it closes the shape-(c) hole for
programs shaped like #5476 (4 M small allocations → 1.9 GB RSS when nothing
serves pressure), where a useless minor runs per 16 MB of large-object growth
today, and it makes the 26 fixtures honest about what they are testing.
Caveat on every 400-character figure above: they are figures on the current
JSON.parse. Shape (a)'s input is one parse per SSE delta; a parser thatallocates differently moves the number in either direction. The mechanism — an
arm testing a quantity 99 % of which it cannot act on — does not depend on the
parser.