Summary
GC_NEXT_TRIGGER_BYTES's own doc says it is "bumped after each
gc_collect_inner based on collection effectiveness". It is not.
gc_finish_arena_trigger_collection re-baselines the arena trigger and — if
the collection swept malloc — the malloc trigger.
gc_finish_malloc_trigger_collection re-baselines only the malloc
trigger.
So the whole-arena threshold is measured from the last arena-kind
collection, not from the last collection. The asymmetry predates the budgeted
split (9d3bd2e3b's pre-split gc_check_trigger had the same two branches).
It is justified in one direction only: an arena minor may legitimately skip
the malloc sweep, so it must not move the malloc trigger — pinned by
test_gc_check_trigger_copied_minor_without_malloc_sweep_preserves_malloc_trigger.
The reverse has no such justification: a MallocCount minor is the same
nursery collection with the malloc sweep added. The arena was swept.
take_promoted_young_capacity_credit's own doc names the invariant being
broken — it says the credit is "read exactly once, by the post-collection
trigger rebaseline", which presumes every collection performs one. Today a
MallocCount minor's promotion credit is not consumed until the next
arena-kind collection.
Measured
Compiled claude-code TUI, PERRY_GC_DIAG=1, per firing, four 3300-character
captures across two independently built binaries (cc_main_0905,
cc_int_0905):
The streaming turn runs a strict 6:1 pattern. Six MallocCount minors
(12.6–14.4 MB nursery, 22 % survival) each promote 3.1–3.2 MB, growing the old
generation by ~19 MB = the 16 MB headroom floor plus credit. The arena total
crosses GC_NEXT_TRIGGER_BYTES inside the sixth minor's promotion, and at
the very next safepoint the arm fires on a nursery of 856 bytes —
promoted_bytes=216 freed_bytes=640, every time.
|
|
| such firings per 3300-char turn |
8, of ~60 collections |
| nursery at the firing |
856 bytes |
| bytes freed |
640 |
| per-collection fixed cost paid anyway |
root scan, side-table prune, dirty-page restore — ~24 % of the turn across all collections |
| such firings per 400-char turn |
0 (that turn has no MallocCount firings at all) |
One collection in seven of the streaming turn pays the entire per-collection
fixed cost to free 640 bytes, because the malloc finisher never re-baselined the
arena trigger.
Fix
Factor the threshold re-baseline out of gc_finish_arena_trigger_collection
and call it from gc_finish_malloc_trigger_collection as well, with
pre_in_use captured for MallocCount cycles. All three paths that dispatch to
that finisher — alloc-point direct, moving safepoint, budgeted — pass it. The
adaptive step sees the same pct_freed the same nursery would have shown the
arena arm.
Fulls (BudgetedGcRebaseline::OldReclaim, idle reclaim) stay untouched: after a
full that released blocks the un-moved trigger sits further above the new
total, which is the conservative direction, and a full's cadence belongs to the
old-generation band.
Contract impact: none. The arm's dueness predicate is byte-identical; when
it is due it fires the same collection. The 26 tests that the arm's other
candidate changes break all pass unmodified here — they suppress the malloc
trigger and never run a MallocCount minor.
Relationship to the neighbouring work
This is not #9831/#9838's shape and does not overlap it. #9838 fixes the
trigger being lowered to "now" by the tiny-parse pressure guard (shape (a),
the pre-turn and 400-char firings). This is the trigger being crossed by the
previous collection's promotion because nothing refreshed it (shape (b), the
3300-char streaming turn). The two are disjoint populations in the same
captures; #9838's diag shows ArenaBytes 41 → 13, and shape (b)'s 8 firings are
inside that residual 13.
Note also that #9838 introduces note_collection_finished_arena_occupancy as
"the funnel every cycle finishes through" and refreshes its guard base there for
every collection kind. That is the same symmetry this issue asks for,
applied to a different cell — the arena trigger is the one pacing quantity still
keyed to a single collection kind.
Caveat on the cc-visible size of this. The mechanism rides entirely on
MallocCount minors, and on cc the native pressure that arm exists to relieve
is measured to be 100 % RegExpHeaders (199,873 of 199,926 malloc-backed GC
allocations in one 400-char reply, 80.0 B/object). A change moving those headers
to the nursery is in flight; if it lands, MallocCount loses its input on cc
and shape (b) has no producer there. That does not make the asymmetry less of a
defect — it makes its cc-visible effect a function of what else lands, and any
CPU number for it must state the combination it was taken on.
Summary
GC_NEXT_TRIGGER_BYTES's own doc says it is "bumped after eachgc_collect_innerbased on collection effectiveness". It is not.gc_finish_arena_trigger_collectionre-baselines the arena trigger and — ifthe collection swept malloc — the malloc trigger.
gc_finish_malloc_trigger_collectionre-baselines only the malloctrigger.
So the whole-arena threshold is measured from the last arena-kind
collection, not from the last collection. The asymmetry predates the budgeted
split (
9d3bd2e3b's pre-splitgc_check_triggerhad the same two branches).It is justified in one direction only: an arena minor may legitimately skip
the malloc sweep, so it must not move the malloc trigger — pinned by
test_gc_check_trigger_copied_minor_without_malloc_sweep_preserves_malloc_trigger.The reverse has no such justification: a
MallocCountminor is the samenursery collection with the malloc sweep added. The arena was swept.
take_promoted_young_capacity_credit's own doc names the invariant beingbroken — it says the credit is "read exactly once, by the post-collection
trigger rebaseline", which presumes every collection performs one. Today a
MallocCountminor's promotion credit is not consumed until the nextarena-kind collection.
Measured
Compiled claude-code TUI,
PERRY_GC_DIAG=1, per firing, four 3300-charactercaptures across two independently built binaries (
cc_main_0905,cc_int_0905):The streaming turn runs a strict 6:1 pattern. Six
MallocCountminors(12.6–14.4 MB nursery, 22 % survival) each promote 3.1–3.2 MB, growing the old
generation by ~19 MB = the 16 MB headroom floor plus credit. The arena total
crosses
GC_NEXT_TRIGGER_BYTESinside the sixth minor's promotion, and atthe very next safepoint the arm fires on a nursery of 856 bytes —
promoted_bytes=216 freed_bytes=640, every time.MallocCountfirings at all)One collection in seven of the streaming turn pays the entire per-collection
fixed cost to free 640 bytes, because the malloc finisher never re-baselined the
arena trigger.
Fix
Factor the threshold re-baseline out of
gc_finish_arena_trigger_collectionand call it from
gc_finish_malloc_trigger_collectionas well, withpre_in_usecaptured forMallocCountcycles. All three paths that dispatch tothat finisher — alloc-point direct, moving safepoint, budgeted — pass it. The
adaptive step sees the same
pct_freedthe same nursery would have shown thearena arm.
Fulls (
BudgetedGcRebaseline::OldReclaim, idle reclaim) stay untouched: after afull that released blocks the un-moved trigger sits further above the new
total, which is the conservative direction, and a full's cadence belongs to the
old-generation band.
Contract impact: none. The arm's dueness predicate is byte-identical; when
it is due it fires the same collection. The 26 tests that the arm's other
candidate changes break all pass unmodified here — they suppress the malloc
trigger and never run a
MallocCountminor.Relationship to the neighbouring work
This is not #9831/#9838's shape and does not overlap it. #9838 fixes the
trigger being lowered to "now" by the tiny-parse pressure guard (shape (a),
the pre-turn and 400-char firings). This is the trigger being crossed by the
previous collection's promotion because nothing refreshed it (shape (b), the
3300-char streaming turn). The two are disjoint populations in the same
captures; #9838's diag shows
ArenaBytes41 → 13, and shape (b)'s 8 firings areinside that residual 13.
Note also that #9838 introduces
note_collection_finished_arena_occupancyas"the funnel every cycle finishes through" and refreshes its guard base there for
every collection kind. That is the same symmetry this issue asks for,
applied to a different cell — the arena trigger is the one pacing quantity still
keyed to a single collection kind.
Caveat on the cc-visible size of this. The mechanism rides entirely on
MallocCountminors, and on cc the native pressure that arm exists to relieveis measured to be 100 %
RegExpHeaders (199,873 of 199,926 malloc-backed GCallocations in one 400-char reply, 80.0 B/object). A change moving those headers
to the nursery is in flight; if it lands,
MallocCountloses its input on ccand shape (b) has no producer there. That does not make the asymmetry less of a
defect — it makes its cc-visible effect a function of what else lands, and any
CPU number for it must state the combination it was taken on.