Skip to content

gc: incremental old-gen work costs 14% on a program that never collects (asyncpipe, zero GC cycles) #7909

Closed

Description

@proggeramlug

Summary

asyncpipe runs zero GC cycles — no [gc] line is ever printed — and yet ~33% of its leaf profile is collector machinery the mutator pays anyway: write barriers, per-object layout side tables, and RuntimeHandleScope rooting.

PERRY_GC_MOVING_LOOP_POLLS=0 measures −14.1% on asyncpipe while still running zero GC cycles. So the cost is not collection — it is an incremental old-gen mark/sweep being driven at back-edge polls that never completes a cycle and never reports one.

This is the largest single lever found in the asyncpipe round and it was deliberately left open: turning polls off is not a shippable fix (polls are default-ON since #7721 and removing them has its own history — see #7690, and the note in CLAUDE.md that a poll in a call-free clone deletes the element-shape clone). The question is why a program that never collects pays 14% for incremental collection.

Why this matters beyond one benchmark

It falsifies a premise this campaign has been using: "zero GC cycles" was treated as "GC levers are irrelevant here." That is wrong. A pure-mutator program still pays incremental-collector machinery, and on asyncpipe that is the top item.

PERRY_WRITE_BARRIERS=0 is +0.9% here, so codegen write barriers are genuinely not the cost — the incremental drive is.

Suggested direction

  • Establish why incremental old-gen work is being scheduled at all on a heap that never triggers a collection. If the answer is "the poll unconditionally does a slice of work", the fix is a cheap due-check before the slice, not disabling polls.
  • Any fix must assert its subject was live (per CLAUDE.md's four-ways-a-gate-cannot-fail): a gate here must show incremental slices actually ran on the baseline, or it proves nothing.

Evidence

gc-handoff/ASYNC2-NOTES.md — decomposition, counters, and reproduce commands. Measured with instructions retired (dev box was at load 12–165; see gc-handoff/MEASUREMENT-PROTOCOL.md).

Activity

  1. added
    performanceRuntime, compile-time, build-size, or memory performance
    on Aug 12, 2026
  2. proggeramlug commented on Aug 12, 2026

    @proggeramlug
    ContributorAuthor

    Answer: the heap DOES trigger a collection — it just never performs one

    Measured on main @ c109b089d, worktree wt-gc7909. Instrument added in #7923.

    The premise "zero GC cycles ⇒ no collection is due" is where this went wrong.
    gc_budgeted_due_trigger() returns Some(ArenaBytes) from the young-generation
    scavenge cap (young_scavenge_cap_due, 16 MB) and — because nothing ever
    collects — it stays due for the rest of the run.

    1. Every microtask drain runs gc_runtime_safepoint()
      (promise/microtasks.rs), which starts a budgeted incremental cycle as soon
      as any trigger is due.
    2. The cycle it starts for nursery pressure is low_pause_non_moving by
      construction, so it cannot evacuate and cannot lower
      copying_from_space_in_use_bytes() — the quantity the cap tests.
    3. While it is active, gc_safepoint_moving_minor rejects every precise
      safepoint at its budgeted entry guard, so the collector that could clear
      the trigger never runs again.
    4. At the pump's cadence (2048 work units per drain, ~17 drains in the whole
      program) it cannot finish.

    Self-sustaining, and completely silent: the [gc] trace is written by
    gc_finish_budgeted_cycle, so a cycle that never completes emits nothing.

    [gc-incremental] cycle_starts=1 steps=15 completions=0 active_at_exit=true
                     mark_barrier_arms=1 mark_barrier_armed_us=37214
                     skips(reentrant=0 no_trigger=2 start_blocked=0 resume_blocked=0)
                     copying_minors=0 loop_polls=1 poll_arm_events=0
    

    mark_barrier_armed_us=37214 is the cost: the SATB mark barrier armed for
    37 ms of a 127 ms program, taxing every heap store, every shadow-slot root
    store and every allocation for a cycle that collects nothing. That is also why
    PERRY_WRITE_BARRIERS=0 was +0.9 % — that knob disables the generational
    codegen store barrier; this is the incremental mark barrier, armed separately
    via PERRY_INCREMENTAL_MARK_BARRIER_ACTIVE_COUNT.

    The loop poll is not involved at all

    poll_arm_events=0, loop_polls=1 — the back-edge poll is armed zero times
    and taken once (the startup seed release) in the whole program.
    PERRY_GC_MOVING_LOOP_POLLS=0 acts here only through nursery_cap_active(),
    which is gc_moving_loop_polls_enabled() (gc/policy.rs).
    PERRY_GC_SCAVENGE_NURSERY_MB=4096, which moves the cap and nothing else,
    reproduces it to three digits (instructions retired, best of 7):

    arm instr (M) Δ
    base (cap 16 MB) 1993.1 —
    PERRY_GC_SCAVENGE_NURSERY_MB=4096 1757.7 −11.81 %
    PERRY_GC_MOVING_LOOP_POLLS=0 1757.6 −11.81 %
    PERRY_GC_SCAVENGE=0 2001.8 +0.44 %

    Cap sweep shows a clean cliff between 16 and 24 MB, then flat: 1997 → 1761 →
    1760 → 1758 → 1758 M for 16 / 24 / 32 / 48 / 64 MB.

    The obvious fix works and is a +51 % regression — blocked on #7915

    Giving the precise collector first refusal at the outermost pump boundary (run
    gc_safepoint_moving_minor() before gc_runtime_safepoint()) removes the stall
    exactly as designed — cycle_starts 1 → 0, mark_barrier_armed_us 37 214 → 0,
    copying_minors 0 → 2, all 19 corpus programs byte-identical — and costs:

    arm instr (M) Δ instr RSS (MB)
    base 1927.1 — 48.9
    reorder 2917.8 +51.4 % 76.7
    base, no trigger due 1691.7 −12.2 % 45.4
    reorder, no trigger due 1692.0 −12.2 % 45.4

    The control rows are inert to three digits, so the +51 % is the price of the two
    minors it unblocks, not of the change. One of those is a 134 ms minor that
    copied zero objects
    , spending its time scanning 218 455 pointer roots across
    82 registered runtime mutable root scanners — filed as a lead on #7915.

    This issue cannot be closed before #7915. They are one chain: #7909 is the
    mutator paying for a collection that is scheduled and then neither performed nor
    cancelled; #7915 is that performing it costs more than not performing it. The
    reorder is written up in gc-handoff/GC7909-NOTES.md §4 so it can be picked up
    as-is once the collection is affordable.

    #7923 ships the instrument only (no behaviour change): PERRY_GC_DIAG=1 — an
    existing knob, no new one — now reports started/stepped/completed cycles and the
    mark barrier's armed wall time, so this state is observable at all.

  3. proggeramlug commented on Aug 12, 2026

    @proggeramlug
    ContributorAuthor

    Re-measured on ac52a5c38 (0.5.1490): the filed cost is gone. The defect is not.

    Per the instruction to re-establish cost before designing anything. Own build,
    own worktree (wt-barrier), PERRY_NO_AUTO_OPTIMIZE=1, output byte-identical
    to m0810/expected/asyncpipe.txt, exit 0.

    1. The signature this issue was filed on now reads all-zero

    PERRY_GC_DIAG=1, the [gc-incremental] instrument #7923 added:

    counter as filed (c109b089d) today (ac52a5c38)
    cycle_starts 1 0
    steps 15 0
    active_at_exit true false
    mark_barrier_arms 1 0
    mark_barrier_armed_us 42 487 0
    safepoints_blocked_by_budgeted 5 0
    copying_minors 0 1
    host-safepoint no_trigger skips 2 9

    The chain was: nursery scavenge cap goes due → the microtask pump starts a
    budgeted low-pause non-moving cycle that cannot discharge nursery pressure →
    that cycle blocks gc_safepoint_moving_minor at its budgeted entry guard for
    the rest of the process → nothing collects → the cap stays due. #7933/#7939 took
    asyncpipe's young survival to 25‰, so the cap never goes due at all
    (no_trigger=9), no cycle is started, and the moving minor runs.

    2. The knob delta is 71% smaller in absolute terms

    ab_instr.py, instructions retired, best of 7, spread ≤ 0.55%:

    arm instr (M) Δ rss (MB)
    default 1180.8 — 39.6
    PERRY_GC_MOVING_LOOP_POLLS=0 1113.6 −5.69% 40.9
    PERRY_GC_SCAVENGE_NURSERY_MB=4096 1112.7 −5.77% 40.9

    Filed numbers were 1993.1 → 1757.7 (−11.81%). The program itself is 41%
    cheaper and the lever fell from 235.4 M to 68.1 M instructions.

    3. What the residual 5.8% now is — a real collection

    The two knobs still agree to three digits (nursery_cap_active() is
    gc_moving_loop_polls_enabled()), so it is still the cap. But what the cap now
    buys is one copying minor that does genuine work:

    [gc-copy-minor] ran survival_permille=25 copied_objects=6686 copied_bytes=446376
                    freed_bytes=17379064 trigger=ArenaBytes
    pause_us=6619   phase_us.copying_nursery=3919
    

    6.6 ms, 6 686 objects copied, 17.4 MB reclaimed, and RSS 1.3 MB lower
    than the arms that skip it. Compare the state this issue was filed in: a
    134 ms minor that copied zero objects and zero bytes. That is an
    ordinary instructions-for-footprint pacing trade, not "incremental old-gen work
    on a program that never collects".

    4. ★ But the defect is UNREACHED, not fixed — and it is one knob away

    GC7909-NOTES.md §3.5's asymmetry is still in the code. Lower the cap and the
    exact signature returns on today's main:

    PERRY_GC_SCAVENGE_NURSERY_MB cycle_starts steps completions active_at_exit mark_barrier_armed_us
    4 1 14 0 true 22 851
    8 0 0 0 false 0
    12 0 0 0 false 0
    16 (default) 0 0 0 false 0

    So "asyncpipe no longer pays for it" is a property of this week's allocation
    profile
    , not of the collector. Anything that raises young survival again, or
    any workload with a smaller effective nursery, walks straight back into a cycle
    that is started, never completed, never cancelled, and reports nothing.

    PERRY_GEN_GC=0 still arms the mark barrier for 58.7 ms on the same
    program — the arm that reaches the subject on the non-generational
    configuration.

    Proposed disposition

    Close this issue as measured-not-reproducing on its named subject (the
    14% is now 5.8%, and what remains is a collection that reclaims 17 MB, i.e.
    #7915/pacing territory), and carry the latent asymmetry forward as its own item
    — a budgeted low-pause non-moving cycle may claim a nursery-pressure trigger it
    cannot discharge, and then locks out the collector that can
    — closed with a
    test, not a knob. PERRY_GC_SCAVENGE_NURSERY_MB=4 on apps/asyncpipe.ts is
    a ready-made deterministic fixture, and [gc-incremental]'s
    cycle_starts > completions && active_at_exit is the assertion.

    Note for anyone re-running this: PERRY_GC_DIAG=1 alone prints the
    [gc-incremental] line but no per-cycle [gc] lines — those need
    PERRY_GC_TRACE=1 as well. Both zeros above were positive-controlled.

  4. proggeramlug commented on Aug 12, 2026

    @proggeramlug
    ContributorAuthor

    Re-measured on current main: the filed cost is gone, the defect is not

    Measured at ac52a5c38 (see gc-handoff/BARRIER-NOTES.md):

    counter when filed now
    cycle_starts 1 0
    steps 15 0
    active_at_exit true false
    mark_barrier_armed_us 42,487 0
    copying_minors 0 1

    Cause of the change: #7933/#7939 took asyncpipe's young survival to ~25‰, so the scavenge cap never goes due (no_trigger=9) and the budgeted cycle is never started.

    The knob delta fell −11.81% → −5.77% (235.4 M → 68.1 M instructions absolute), and what remains is now a real collection: 6.6 ms, 6,686 objects copied, 17.4 MB reclaimed, with RSS 1.3 MB lower than the arms that skip it. That is pacing (#7929/#7915), not "incremental work on a program that never collects".

    Why I am NOT closing this

    The asymmetry is unreached, not fixed. PERRY_GC_SCAVENGE_NURSERY_MB=4 reproduces the exact original signature on today's main:

    cycle_starts=1 steps=14 completions=0 active_at_exit=true mark_barrier_armed_us=22851
    

    So the livelock — a budgeted non-moving cycle that starts because the scavenge cap is due, cannot lower the quantity the cap tests, blocks the moving minor at its budgeted guard, and never completes — is still present in the collector. It is currently masked by an unrelated allocation-rate change on one benchmark. PERRY_GEN_GC=0 still arms the barrier for 58.7 ms.

    Re-scoped: this issue is now "close the pacing asymmetry with a deterministic regression test", not "recover 14% on asyncpipe". The =4 reproducer is deterministic, so the test is cheap and does not need a knob to express.

    That matters because the alternative — closing on today's numbers — would delete the knowledge and let the next allocation-rate change silently re-expose it, with no test to catch it. This repo has a documented history of exactly that: a mode that still exists is a decision that hasn't been made.

  5. added a commit that references this issue on Aug 12, 2026
  6. proggeramlug commented on Aug 12, 2026

    @proggeramlug
    ContributorAuthor

    Closed with a test in #7972 — and the recorded blocker is refuted by measurement

    Agent hdr, base a769fafc6 (0.5.1495). Write-up: gc-handoff/HDR-NOTES.md §1–2.

    1. ★ The named reproducer is stale — checked before using it

    Both this issue's last comment and my brief say
    PERRY_GC_SCAVENGE_NURSERY_MB=4 reproduces the filed signature on main. True at
    ac52a5c38 (0.5.1490); not true at a769fafc6. Full sweep on
    apps/asyncpipe.ts, output byte-exact and exit 0 in every cell:

    cap MB cycle_starts steps completions active_at_exit barrier armed us copying minors
    1 0 0 0 false 0 9
    2 1 20 1 false 87 939 3
    3 0 0 0 false 0 6
    4 0 0 0 false 0 4
    8 0 0 0 false 0 2
    16 (default) 0 0 0 false 0 1
    24 / 32 0 0 0 false 0 0

    The stall half (completions=0 active_at_exit=true) is not reachable on this
    program at any cap any more. The defect half is fully intact one notch lower, at
    cap 2: a budgeted low-pause non-moving cycle is still started for a trigger it
    cannot discharge, and still arms the SATB mark barrier for 87.9 ms of a ~127 ms
    program while locking the moving minor out.

    The value that reproduces moved twice in five patch versions. That is why the
    regression test drives the policy path directly rather than pinning a knob value
    to a benchmark.

    Also worth recording: the whole 19-program corpus starts zero budgeted cycles at
    default settings
    (cycle_starts=0, no_trigger=6). The defect is unreached at
    defaults everywhere, not only on asyncpipe.

    2. The fix (#7972)

    The cap becomes its own trigger variant, YoungScavengeCap. Every collection
    site treats it identically to ArenaBytes; the split exists only so the budgeted
    stepper can refuse to start a cycle for a trigger a low_pause_non_moving
    cycle cannot discharge, deferring instead to the precise safepoint — arena
    baseline included — exactly as gc_check_trigger's alloc-point arm already does.
    That asymmetry between the two paths was the bug. No new knob;
    PERRY_GC_DIAG=1 gains nursery_cap_deferred=N.

    3. ★ "+51 %, blocked on #7915" no longer holds

    GC7909-NOTES.md §4.2 measured the equivalent unblocking at c109b089d as
    +51 % instructions / +57 % RSS and concluded this issue could not close before
    #7915. Re-measured at PERRY_GC_SCAVENGE_NURSERY_MB=2, ab_instr.py best of 5,
    byte-exact output and exit 0 on both arms:

    arm instr (M) cycles (M) RSS (MB) Δ instr
    base 1573.8 369.5 37.7 —
    fix 1317.0 337.6 24.2 −16.31 %
    counter base fix
    cycle_starts 1 0
    mark_barrier_armed_us 87 939 0
    nursery_cap_deferred — 2
    copying_minors 3 9

    −16 % instructions and −13.5 MB RSS. The state removed (88 ms of armed barrier
    plus a locked-out collector) now costs more than the state created (six more
    copying minors). The blocker was a price, and the price moved.

    4. Cost at defaults: zero, for a stated reason

    19/19 byte-exact, exit 0 (also under PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800 PERRY_GC_VERIFY_EVACUATION=1); iso_miss
    canary checksum 437840 misses 0. Every GC counter bit-identical across all 19
    programs; RSS within 0.06 %; instructions −0.48 % … +0.63 %, inside the base arm's
    own spread. nursery_cap_deferred=0 on all 19 — nothing for the new branch to
    decline, which is exactly why the arms match.

    cargo test --release -p perry-runtime: 2229 passed, 0 failed. Sabotage-proved:
    with the branch disabled the new test fails on
    a cap-only host safepoint must report idle, not an active cycle, and the
    sabotage log's Compiling perry-runtime v count is 1, so the red is about the
    sabotaged code.

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

    performanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions