Skip to content

perf(gc): the nursery/promotion budget is denominated in BYTES but the collector's cost is per-OBJECT — compaction is taxed back #7929

Description

@proggeramlug

Found while measuring #7916/#7714 (PR #7928, INLINE_SLOT_FLOOR 4 → 2).

The mismatch

The nursery/promotion budget is denominated in bytes. The collector's per-cycle cost is
dominated by per-object work (copy, forwarding, layout transfer, page registration —
~50–100 ns each). Those two are not the same quantity, and the gap opens exactly when object
representation gets more compact.

The measurement

Same program (gc-handoff/bench/retain1.ts, 1 M surviving 2-field literals), same commit,
one arm with 72-byte objects and one with 56-byte objects. Every minor fires at the same
byte mark and processes the same bytes
:

minor bytes, 72 B objects → 56 B objects objects, 72 B → 56 B
1 (copy) 17 694 064 → 17 694 216 245 752 → 315 969
2 (promote) 18 742 672 → 18 742 816 260 316 → 334 694
3 (promote) 34 601 760 → 34 602 008 480 580 → 617 893

The object ratio is 1.286 = 72/56, exactly. deeplist reproduces it to three decimals.

Consequences on those two rows: GC pause 39.60 → 50.10 ms (+26.5%), and that +10.5 ms
exceeds the whole program's cycle delta (25.2 M cycles ≈ 7.9 ms) — the mutator got faster
and the collector got slower. Cost per extra promoted object: 10.5 ms / 211 691 = 49.6 ns,
i.e. the per-object price did not change at all. Only the population inside a fixed byte
budget did.

Why it matters beyond one PR

Object representation is the current perf direction (#7916 established that the collector is
no longer the binding constraint on the retain cluster; the mutator is). But every byte
removed from an object increases the number of objects the collector handles per unit of its
own budget.
A byte-denominated trigger in front of a per-object cost model makes "make
objects smaller" and "make the collector do more work per cycle" the same action, so the
footprint work partially pays for itself in GC time until this is fixed.

What "fixed" would mean

An object-count term alongside the byte term in the nursery/promotion budget, so a cycle's
work — not its bytes — is what is being paced. Adjacent to #7715 (B4 generation bit) and
#7432 (adaptive tenuring young cap, which is the valve reading eden_live_bytes here).

Caveat that keeps this honest

Total promotion work is set by the surviving object count, which does not change — 1 M either
way. retain1 promotes 740 896 of 1 M objects with 72-byte objects and 952 587 with 56-byte
ones, so the smaller representation pulls ~211 k promotions forward into the measurement
window rather than creating them. A program that runs to completion pays the same total.
That makes the effect largest on exit-bounded microbenchmarks — which is most of the perf
corpus, so it is still worth pacing correctly.

Reproduce: PERRY_GC_TRACE=1 PERRY_GC_DIAG=1 ./p_retain1 on either side of PR #7928 and
compare the [gc-copy-minor] ran … lines. Full write-up: gc-handoff/REPR-NOTES.md §3d.

Activity

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

    @proggeramlug
    ContributorAuthor

    The corpus census: the standing spread is 110×, not 1.29×

    The issue's evidence is one PR's 72 B → 56 B shift (a 1.29× object-count change
    inside a fixed byte budget). Measuring the whole corpus says that is the small
    end of the effect. Mean surviving object size, derived from [gc-copy-minor] ran
    as (copied_bytes + promoted_bytes) / (copied_objects + promoted_objects),
    origin/main @ b8c1f103e, all 19 programs byte-exact and exit 0:

    program minors copied_ob promoted_ob mean surviving object B objects per 16 MB cap
    tree_wide 40 30 10 32.8 ~512 000
    cycles / tree 16 / 40 6 2 36.0 ~466 000
    interp 38 14 037 4 804 53.7 ~312 000
    iso_miss 50 14 634 4 812 53.8 ~312 000
    pipeline 6 97 30 57.6 ~291 000
    deeplist 3 247 573 742 717 72.0 ~233 000
    retain / retain1 5 / 3 245 752 2 109 820 / 740 896 72.0 ~233 000
    shapes 1 7 416 0 72.2 ~232 000
    asyncpipe 1 172 387 0 79.7 ~210 000
    churn / churn_alloc / push_cls 105 47 705 2 83.3 ~201 000
    retain_wide / retain_wide1 8 / 4 170 140 2 771 303 / 855 719 104.0 ~161 000
    push_num 18 18 0 3 600 ~4 660

    One 16 MB byte budget buys between ~4 660 and ~512 000 objects of collector
    work, decided only by the program's object shape.

    ★ retain is a live example of the counter trap: copied_objects = 245 752
    against promoted_objects = 2 109 820. Reading copied_objects alone reports
    that cycle as doing ~10 % of its actual per-object work.

    Narrowing where the byte denomination actually lives

    Four byte-denominated valves — and they are not equally wrong:

    site expression representation-invariant?
    policy.rs:107 young_scavenge_cap_due from_space_bytes >= cap_bytes NO — this is the mismatch
    tenuring.rs:243 desired_survivor_bytes cap / 16 n/a (it sizes a space)
    tenuring.rs:247 compute_target_survivals 1 + desired_bytes / eden_live_bytes YES, by cancellation
    tenuring.rs:334 retune_nursery_cap_scale eden_live_bytes ≷ cap/25, cap/100 YES, by cancellation

    Both tenuring.rs ratios compare a byte quantity against another byte quantity
    derived from the same cap, so uniformly scaling every object cancels out:
    (cap/16)/eden_bytes does not change. Only the trigger genuinely converts a
    fixed byte budget into a variable amount of per-object work.

    So this is one comparison, not a subsystem in the wrong units — which is good
    news for the fix's size and bad news for its cost.

    Why I did not ship a pacing change

    Adding an object term alongside the byte term
    (due = bytes >= cap_bytes || objects >= cap_objects) fires the collector
    earlier on small-object programs: a smaller per-cycle pause bought with
    more cycles, each paying the fixed root scan. #7915 measured that fixed cost
    at 82 scanners over 218 455 pointer roots on asyncpipe, and the #7926
    re-derivation just showed that on that program a single extra minor is the
    entire observable delta. On this corpus the trade looks net-negative on exactly
    the programs the campaign is trying to fix.

    Two further blockers for whoever picks this up:

    • The arena tracks no nursery object count.
      copying_from_space_in_use_bytes (arena/reset.rs:78) sums block.offset.
      An object term needs either a counter on the allocation fast path — which
      perf(runtime): allocation path spends 34% of self time in _tlv_get_addr — 24× behind Node on object churn with the collector already idle #7469 spent a campaign making cheap — or an estimate from the previous cycle's
      measured mean. The table above shows that mean is stable per program but
      spans 110× across programs, so a single constant will not do.
    • push_num's 3 600 B mean is arrays, not objects. An object-count cap has
      to be defined over the same population the collector charges per-item work
      for, or push_num trips it continuously.

    Full working: gc-handoff/PACING-NOTES.md §4.

  3. proggeramlug commented on Aug 12, 2026

    @proggeramlug
    ContributorAuthor

    Now measured end-to-end on the quiet mini, not a projection.

    Sweep at 8039fa798 (VERDICT: CLEAN, load 1.24, best-of-5) vs the previous clean sweep at 14e291f3a:

    bench before after delta
    deeplist 0.057 0.063 +10.5%
    retain1 0.068 0.074 +8.8%

    Both are perry-side (node moved <2% on each). This is the wall-clock realisation of exactly what #7928's author predicted: right-sizing objects 72 → 56 B means every minor fires at the same byte mark but processes 1.286× as many objects, taking retain1's GC pause 39.60 → 50.10 ms — more than the program's entire cycle delta.

    The tax is real, reproduced at the predicted magnitude on a quiet machine, and currently costs two benchmarks ~10% each.

    This issue now blocks the compaction direction rather than annotating it. #7916's follow-up (the ≥4-field header work, gc-handoff/REPR-NOTES.md §4) would take {a,b} to 32 B and retain_wide to 80 B — a much larger object-count multiplier and therefore a much larger tax, unless the budgets carry an object-count term first.

    The prior analysis stands as the starting point: of four byte-denominated valves, both tenuring.rs ratios are invariant by cancellation and only young_scavenge_cap_due is genuinely mis-denominated; the corpus census spans 32.8 B → 3600 B mean surviving object size (110×). The recorded blocker — an object term fires the collector earlier, and each added cycle pays #7915's fixed root scan — is still the thing to answer, but #7933/#7939 has since cut asyncpipe's young survival 770‰ → 24‰, which changes that calculus.

  4. proggeramlug commented on Aug 12, 2026

    @proggeramlug
    ContributorAuthor

    ⚠️ Post-merge correction: on a healthy collector the shipped term is net-NEGATIVE, and #7965 is a hard prerequisite

    #7961 merged (109de930f) while its validation was still running. The validation
    finished and contradicts the A/B in the PR body. Recording it here rather
    than leaving the PR's numbers standing.

    What the PR measured, and why it was misleading

    The PR's A/B was taken against ac52a5c38, which contains #7965 (bisected in
    that issue to #7944 / #7902 — untraced promotion stopped crediting the
    old-reclaim baseline, so deeplist/retain* each gained a futile full
    mark-sweep). Against that regressed base the term reads as a large win, because
    it happens to keep old-gen under the 48 MB one-shot threshold.

    Re-run against the pre-regression base (e886b56dd, the commit before
    #7902), with the same two commits cherry-picked on top so the only variable is
    the term itself:

    program base fulls / instr +term fulls / instr Δobjects Δinstr ΔRSS
    deeplist 0 / 1107.2 M 0 / 1093.5 M −11.8% −1.2% −0.0%
    retain1 0 / 1293.7 M 1 / 3342.4 M −11.8% +158.4% +25.4%
    retain 0 / 2596.4 M 1 / 4635.8 M −5.1% +78.5% +2.6%
    retain_wide 0 / 3029.3 M 0 / 3027.7 M 0 −0.1% 0.0%

    The term itself introduces the futile full on retain1 and retain.

    Mechanism — the same defect as #7965, reached from the other side

    old_reclaim_pressure_due's absolute arm is a one-shot latch:

    (old_in_use >= T && baseline < T) || ...     // T = 48 MB

    baseline (GC_LAST_OLD_RECLAIM_IN_USE_BYTES) is a running sum of promotion
    deltas
    , so whether it ends up above or below T is decided by promotion step
    size
    , not by the heap. On retain1:

    promotion steps final baseline latch
    base 18.74 + 34.60 MB 53.34 MB > T disarmed by overshoot
    +term 18.74 + 26.21 MB 44.95 MB < T still armed

    The smaller band makes the second step smaller, the baseline lands 3 MB short of
    T, and the latch survives. retain1's all array — a large object born straight
    into old-gen, whose growth never passes through promotion and so never credits
    the baseline — then pushes old_in_use past 48 MB and the latch fires. The full
    reports freed_bytes = 8 257 632, i.e. the array's dead backing stores: a
    whole-heap collection to reclaim 8 MB.

    deeplist has no such old-born array, which is why it is the one program in the
    table that stays clean — and its −1.2% is therefore the honest value of the
    object denomination on a healthy collector, not the −72.3% the PR quotes.

    So #7965 and this are one defect: a valve whose firing is decided by
    granularity rather than by the quantity it claims to measure.
    #7902 zeroed the
    baseline; this term lowers it. Either way the latch is left armed and a
    100 %-live old-gen gets a futile full.

    What I recommend

    1. Do not revert fix(gc): denominate the nursery constant band in objects (#7929) #7961 yet. On current main both arms already carry the
      perf(gc): main regressed the retain cluster 2.2-4.8x — retain now runs 2 full collections where it ran none (suspect #7901/#7902) #7965 full, so the term is measured there as retain1 −10.9%, retain
      −63.1%, deeplist −72.3% and is a net win today. Reverting now would make
      main worse.
    2. Fixing perf(gc): main regressed the retain cluster 2.2-4.8x — retain now runs 2 full collections where it ran none (suspect #7901/#7902) #7965 must re-measure this term in the same change. The moment the
      baseline is credited correctly, retain1/retain flip from −11 %/−63 % to
      +158 %/+78 %. That is a landmine, and it is armed.
    3. The durable fix is to stop the latch depending on step size at all — either
      credit old-gen growth to the baseline regardless of how it arrived, or refuse
      to schedule a reclaim when the last cycle measured young_survival_permille = 1000 (it was 1000 on every cycle in this table, i.e. the collector had just
      finished proving the reclaim cannot free anything).

    What stands from the PR

    • The corpus census, and that only the constant band is mis-denominated (both
      tenuring.rs ratios and the tenured-proportional arm are invariant by
      cancellation).
    • The one-sided clamp and its control set — push_num's 3600 B array-dominated
      mean is neutralised, and the 5 factor-1.000 programs calibrate the A/B's
      link-layout noise floor at −0.3 %…−3.5 %.
    • The recorded blocker is still refuted: an extra copying minor costs ~0.5 M
      instructions (churn: 43→172 minors for +4 % instructions and −60 % RSS), so
      "more cycles pay the fixed root scan" was never the reason not to do this.
    • What is not established, and what the PR over-claimed: that the term is worth
      ~10 % on the taxed programs. On a healthy collector it is worth ~1 %
      (deeplist, clean cell, 3 minors and 0 fulls in both arms). The ~10 % figures
      in the PR body and in REPR-NOTES.md §3d are entangled with cycle-kind
      changes and should not be quoted as per-object work.

    Full working: gc-handoff/BUDGET-NOTES.md §8.

  5. proggeramlug commented on Aug 12, 2026

    @proggeramlug
    ContributorAuthor

    #7929's band re-measured on top of the #7965 fix — the reported win does not survive, and the honest picture is a cost on the churn cluster

    #7961 merged on top of the #7965 regression (main was carrying spurious
    old_gen_bytes full mark-sweeps on the retain cluster at the time), so its
    headline numbers were measured against a baseline whose fulls this band change
    happened to move.

    Re-measured with #7965 fixed (PR #7968) in both arms — same compiler, same
    runtime archives, same corpus, only nursery_cap_object_scale_permille forced
    to 1000 in the off arm. 19/19 byte-exact + exit 0 in both. Cycle counts and
    instructions retired are load-independent.

    program cycles with #7929 cycles without instr M with instr M without
    retain 4 4 2 176 2 178
    retain1 2 2 956 958
    retain_wide 6 6 2 859 2 860
    retain_wide1 3 3 911 911
    deeplist 2 2 961 968
    cycles 22 12 1 836 1 808
    interp 44 31 11 708 11 619
    iso_miss 61 43 14 322 14 203
    churn / churn_alloc / push_cls 88 86 +0.1% —
    pipeline 7 6 2 599 2 598
    shapes, tree, tree_wide, push_num, asyncpipe, fib40 identical identical ±0.05% —

    1. On its stated target the denomination is worth ±0.2% and zero cycles

    The retain cluster is where the 72 B → 56 B representation shrink was measured
    and where the band was expected to pay. With #7965 fixed it does not respond at
    all: same cycle count, same full count, instruction counts within 0.2%.
    #7961's reported retain −63.1% / retain1 −10.9% were the spurious fulls
    moving, not the band.

    It is also not the large regression it was predicted to become once #7965 was
    fixed. On the two load-independent measures the retain cluster is simply
    insensitive to it.

    2. Where it does respond, it costs

    cycles 12 → 22 collections (+83%), iso_miss 43 → 61 (+42%), interp
    31 → 44 (+42%), churn/churn_alloc/push_cls 86 → 88, pipeline 6 → 7.

    These are cheap minors over a dying nursery, so instructions retired move only
    +0.8…+1.5% — but the collection count is a load-independent 40–80% increase,
    and total GC pause moved with it on this host (iso_miss 47.1 → 61.3 ms,
    interp 36.3 → 48.0 ms, cycles 6.5 → 8.2 ms; host was loaded, so treat the
    pause figures as shape and the counts as fact).

    The direction is the one #7929's own doc comment flags as the risky one, just
    mirrored: the doc argues that raising a cap is risky because it changes which
    cycle kinds fire. Lowering it on the churn cluster does not change cycle kinds,
    it changes cycle frequency, and the corpus rows above are the price.

    Not folded into PR #7968

    #7965's fix does not depend on this, and the two are separable at the census
    level — every retain row is identical across the #7929 arm. Re-tuning or
    reverting the band belongs here, with this table in hand. Reproduce with
    gc-handoff/rg/build_arm.sh + gc-handoff/c0/cycles.py; full working in
    gc-handoff/REGRESS-NOTES.md.

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