Skip to content

gc: gc_finish_malloc_trigger_collection never re-baselines the arena trigger, so a MallocCount minor's promotion crosses a threshold nothing refreshed #9840

Description

@proggeramlug

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions