You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
gc_budgeted_due_trigger's base ArenaBytes arm tests arena_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 of ArenaBytes precisely 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 - floor the arithmetic re-arms at new_total + floor whatever step
says. 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=1 captures, 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_bytes from the firing's own [gc-copy-minor] ran line, against pre_in_use from the [gc-step] line that same collection emits.
capture
ArenaBytes firings
median YOUNG share of the tested quantity
old / large-object share
base 3300 i0
49
0.9 %
99.1 %
base 3300 i12
50
0.9 %
99.1 %
integ 3300 i0
47
0.9 %
99.1 %
integ 3300 i12
50
0.9 %
99.1 %
integ 400
42
1.5 %
98.5 %
Stable to the digit across two binaries and two workload sizes. Script: secret-tests/cc-perf-campaign scratch basis_an.py.
The three shapes the arm actually fires in
Splitting the same captures by phase (at the first [gc-step] whose pre_in_use reaches 90 MB, where the streamed turn's allocation ramp begins)
separates three mechanisms that the single name ArenaBytes hides:
shape
what crossed
where it occurs
nursery at the firing
(a) request-lowered
nothing — the trigger was lowered to "now" by the tiny-parse pressure guard after a small JSON.parse; in_use is flat across ~30 consecutive firings
pre-turn, and the whole 400-char turn
< 1 MB in 23 of 42
(b) crossed by the previous collection's promotion
six MallocCount minors promoting ~3.2 MB each grow the old generation past a threshold no collection refreshed
the 3300-char streaming turn, 8 of ~60 collections
856 bytes (promoted_bytes=216 freed_bytes=640), every time
(c) genuine whole-arena growth, quiet nursery
16 MB of old-generation or large-object growth with no collection in between
does not occur in any cc capture
—
Shape (a) is #9831/#9838's. Shape (b) is a finisher asymmetry
(gc_finish_malloc_trigger_collection never 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.md with the
branches (refuted/arena-*, head-stamped REFUTED — DO NOT LAND):
yield to an arm that can act — measured inert: the arms each
re-baseline when they fire, so they interleave rather than coincide. At the
moment ArenaBytes is due, MallocCount has just re-baselined. 100 minors and
a 50/50 arm split in both arms, identical to the digit. There is nothing to
yield to.
route the residual to the budgeted old-gen arm — the routed arm is OldReclaim, which is evaluated first and has already declined at that
moment. 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::OldReclaim never touches GC_NEXT_TRIGGER_BYTES, so it re-fires on every evaluation.
gate on young occupancy (the shape proposed below) — −21.7 % turn CPU at
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
Arena pressure schedules, on the observing call, the collection that can act
on what the arm measures: a nursery minor when the young generation holds at
least a block, the escalating full when the pacing reading says so — and
otherwise nothing but a re-baseline, because a whole-arena total with a quiet
nursery is old-generation growth, which the old-generation arms pace.
Both old-generation quantities already have arms that can act on them
(OldReclaim's proportional band and arena_growth_full_escalation_due), and
both 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's
invariant 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: the
cheapest way to make the collector do something. Six assert Minor, and of
those only dirty_store_workload_reports_remembered_set_and_ordinary_pauses
needs 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_SIZE of young occupancy,
via the force_next_general_arena_alloc_slow idiom eight sibling runtime_roots tests already use — which is why those eight passed under the
variant while the 26 did not.
Sabotage: remove the young gate → the first fails (the arm fires a 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_pressure level-1 contract ("trigger lowered, collection deferred
to the next check") becomes "…if the nursery can be acted on" (level ≥ 2 sets GC_OLD_RECLAIM_PENDING and is unaffected), and the tiny-parse channel's
cadence 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 that
allocates 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.
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.