Skip to content

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

@proggeramlug

Summary

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):

  • price the headroom by the step — −10.8 % CPU, +22 % settled footprint.
    The forbidden trade; now documented in-tree by fix(gc): price the tiny-parse pressure guard by the productivity backoff (#9831) #9838's comment.
  • 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.
  • two new tests in the gc: incremental old-gen work costs 14% on a program that never collects (asyncpipe, zero GC cycles) #7909 two-phase shape, so the decline is attributed
    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.
  • 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.

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