Skip to content

gc: adaptive tenuring picks survival age 1 and pays 2-5 unproductive full collections per claude-code turn for it #9851

Description

@proggeramlug

The adaptive tenuring loop selects survival age S = 1 for a large minority of
minors on claude-code, and each turn pays 2-5 full collections that reclaim
0.2-0.3 %
for it. Pinning the age removes them, monotonically. This is on
main; it was surfaced by the regex-header-nursery work (#9845) but is not
caused by it — the unmodified allocator arm shows it identically.

Measurement

One 400-character claude-code reply, offline mock-API rig, sandbox rx.
PERRY_GC_DIAG=1 + PERRY_GC_TRACE=1. A = unmodified allocator,
R = #9845's nursery header. =N pins PERRY_GC_TENURING_SURVIVALS.

arm tenuring ages chosen fulls first-full reclaim old-gen live at last full
A adaptive {1:16, 2:6, 3:5, 4:15} 7 0.2 % 49.9 MB
A adaptive {1:12, 2:6, 3:5, 4:15} 5 0.3 % 48.3 MB
A pinned =2 {2:38} 3 0.3 % 45.3 MB
R adaptive {1:13, 2:5, 3:5, 4:11} 5 0.2 % 48.7 MB
R adaptive {1:15, 2:5, 3:4, 4:12} 7 0.2 % 46.6 MB
R pinned =2 {2:35} 3 0.3 % 45.5 MB
R pinned =4 {4:36} 2 0.2 % 34.7 MB

Two readings:

  • The effect is on main, not on perf(regex): allocate the RegExp header in the nursery, not the malloc arm #9845. A and R adaptive overlap completely
    ({7,5} against {5,7}) and their age distributions are near-identical, so the
    allocator change does not alter tenuring behaviour. A pre-registered
    prediction that R would show more fulls was falsified by this matrix.
  • Fewer fulls, monotone in the pinned age, in both arms: 5-7 adaptive -> 3
    at =2 -> 2 at =4, with old-gen live at the last full falling 48-50 MB ->
    45 MB -> 35 MB.

Why this is a defect and not a tuning preference

The fulls being removed reclaim 0.2-0.3 %. By #9589's rule — price a
collection by what it freed, never by occupancy — they are unproductive, and the
policy is scheduling 2-5 of them per turn because it tenured objects that were
about to die. Promoting on the first survival converts a short-lived object into
old-gen occupancy, and occupancy is what schedules the full.

This is the cost half of the argument; the arena lane owns the producer and the
structural fix (the occupancy rule's S = 1 + desired/influx reaching exactly 1
with no rung at 2 or 3, and the invariant that S = 1 destroys the measurement
that could refute it, since prev_copied = 0 holds the guard false forever).
Its rule — occupancy may lower only to 2, and reaching 1 belongs to the
mortality-measuring exits — is consistent with this matrix.

Three constraints on how these numbers may be used

  1. Count claim only. This box ran at load 100-300 throughout; its CPU
    figures are unusable and none are quoted here. Every number above is a count
    or a byte total from the trace.
  2. Not merged with the other host. These fulls reclaim 0.2-0.3 %; the same
    arms on a quiet host reclaim 14-25 %. The two hosts sit in different regions
    of the same policy and their numbers are not combined into one table.
  3. PERRY_GC_TENURING_SURVIVALS=2 is the diagnostic, not the fix. It is
    documented diagnostic-only, and moving a threshold is explicitly not the
    remedy being proposed.

Reproduce

stream_scale.py LABEL HOME PORT 100 400 150 \
   --env PERRY_GC_DIAG=1 --env PERRY_GC_TRACE=1 --stderr OUT.txt -- <binary>
stream_scale.py LABEL HOME PORT 100 400 150 \
   --env PERRY_GC_DIAG=1 --env PERRY_GC_TRACE=1 \
   --env PERRY_GC_TENURING_SURVIVALS=2 --stderr OUT_t2.txt -- <binary>

Count collection_kind == "full" records, and read tenuring_survivals= off
the [gc-copy-minor] ran lines.

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