Skip to content

GC: #9755's young-entry logs are a net 0.78x on claude-code, and closure.dynamic_props is a 2.56x regression — the logs do not drain because the scanner predicate keeps Longlived #9841

Description

@proggeramlug

Summary

The young-entry logs added by #9755 are, on the compiled claude-code binary
today, a net 0.78x — and for one table, closure.dynamic_props, a
2.56x regression: the minor-scoped walk visits 4.42 M entries per turn
where walking the whole table would have visited 1.73 M.

Measured on cc_int_0905 — an unmodified binary, no patch, no
instrumentation of mine — from its own [gc-young-log] rows under
PERRY_GC_DIAG=1. One 3300-char streamed reply through the offline harness
(secret-tests/cc-permission-harness/stream_scale.py, chunk 50), 103
minor-scoped collections, 5 full traces.

The numbers

Minor-scoped walks only (mode=young), summed over the turn. "Full-walk cost"
is the sum of the table_len the same rows report — i.e. what the pre-#9755
whole-table walk would have visited on those same collections.

table log visits / turn full-walk cost ratio
closure.dynamic_props 4,421,197 1,725,125 2.56x — COSTS MORE
shapes.families+indices 4,088,628 5,614,509 0.73x
object.transition_cache 494,964 1,687,552 0.29x
object.descriptors 235,366 2,870,638 0.08x
total 9,240,155 11,897,824 0.78x

Per-walk medians (n=103 each):

table median visited median table_len visited/len
closure.dynamic_props 42,616 20,601 2.07
shapes.families+indices 36,163 58,751 0.62
object.transition_cache 3,492 16,384 0.21
object.descriptors 1,706 33,234 0.05

Representative raw rows, verbatim:

[gc-young-log] copying_minor table=closure.dynamic_props mode=young passes=2 logged=72368 visited=72368 kept=72368 table_len=12285 skipped=0
[gc-young-log] copying_minor table=closure.dynamic_props mode=young passes=2 logged=119926 visited=119926 kept=79744 table_len=9777 skipped=0
[gc-young-log] copying_minor table=shapes.families+indices mode=young passes=2 logged=76312 visited=76312 kept=78629 table_len=33156 skipped=0
[gc-young-log] copying_minor table=object.descriptors mode=young passes=2 logged=27270 visited=27270 kept=27270 table_len=14018 skipped=766

Note the third row: kept=78629 exceeds logged=76312, and every row has
visited == logged.

The logs do not drain on the typical minor

kept/logged across the turn:

table median mean min passes
object.descriptors 0.999 0.905 0.000 2
shapes.families+indices 1.000 0.985 0.446 2
object.transition_cache 1.000 0.946 0.360 2
closure.dynamic_props 1.000 0.957 0.122 2

On the median minor, for every one of the four tables, nothing is dropped
everything visited is re-logged for the next minor. Occasional collections shed
a lot (the min column), but the steady state re-keeps.

Why, structurally

A scanner's keep-predicate is young_log::addr_is_minor_relevant, and it
returns true for Longlived — deliberately, and correctly for a scanner: a
longlived object may hold a pointer to a young one, so it stays a root. But cc
allocates shape-key arrays in the longlived space
(js_array_alloc_with_length_longlived, #179), so on this workload the
"relevant" set is close to the whole table and the log cannot shrink toward it.

#9755's headline was taken when 10.5k of 566k shape descriptors were carried.
On cc today the carried set is the table, plus duplicates.

Two questions this issue poses rather than answers

Both decide the fix, and neither should be guessed at from the rows above.

  1. 4.42 M visits against 1.73 M entries: are entries logged more than once
    per minor, or does the log accumulate across minors without draining?

    Every row reports passes=2, and
    scan_closure_side_tables_young loops take_sorted until the log is empty
    while scan_closure_owner's move hook re-notes the new key — so a
    re-keyed owner is plausibly visited twice within one collection. But
    logged sums across rounds, so the rows cannot separate that from
    cross-minor accumulation. An instrument that reports distinct keys per
    collection separately from total visits would settle it.

  2. For a scanner — not a prune — is a Longlived key that has been scanned
    once and found to hold no nursery pointer still a root the next minor
    needs, or can it be dropped until it is written again?
    This is the real
    design question. Answering it needs the write-barrier half of perf(gc): young-entry logs for the side-table root scanners #9755 in
    front of you: dropping such a key is only sound if every write that could
    install a nursery pointer into a longlived object re-arms the log.

The rule this establishes

The payoff of a young-entry log must be measured per table, never assumed
from #9755's headline.
Each converted table needs its own
visited-vs-table_len row before and after, on a realistic workload. The
existing [gc-young-log] telemetry already reports everything needed — it was
simply never read back per table on cc.

Why this does not apply to the layout-table prune (in flight)

Recording it so the two are not conflated. A prune asks "which owners
died?", so its predicate is layout_key_may_be_nursery, which excludes
Longlived and Old — strictly stronger than the scanner's. And cc's
tenuring diag shows tenuring_survivals=1 with
copied_objects=0 promoted_objects=3747 on late minors: every survivor is
promoted straight to old, so a key falls out of a prune log after one minor.
A counter on that prune measures 1.85 % of visited entries as
possibly-dead, young_before == 0 on 37 % of minors, and
dead <= young_before on 152/152 minor prunes. Same mechanism, opposite
outcome — which is the point of the rule above.

Reproducing

cd secret-tests/cc-permission-harness
TT_PROJ=/tmp/tt_proj_x python3 stream_scale.py yl /tmp/tt_home_x 9852 50 3300 300 \
  --env PERRY_GC_DIAG=1 --stderr /tmp/yl.err -- /path/to/cc_binary
grep '\[gc-young-log\]' /tmp/yl.err

Then sum visited and table_len per table= for mode=young rows.

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