You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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.
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.
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.
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 LonglivedandOld — 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.
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, a2.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, noinstrumentation of mine — from its own
[gc-young-log]rows underPERRY_GC_DIAG=1. One 3300-char streamed reply through the offline harness(
secret-tests/cc-permission-harness/stream_scale.py, chunk 50), 103minor-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_lenthe same rows report — i.e. what the pre-#9755whole-table walk would have visited on those same collections.
closure.dynamic_propsshapes.families+indicesobject.transition_cacheobject.descriptorsPer-walk medians (n=103 each):
table_lenclosure.dynamic_propsshapes.families+indicesobject.transition_cacheobject.descriptorsRepresentative raw rows, verbatim:
Note the third row:
kept=78629exceedslogged=76312, and every row hasvisited == logged.The logs do not drain on the typical minor
kept/loggedacross the turn:passesobject.descriptorsshapes.families+indicesobject.transition_cacheclosure.dynamic_propsOn 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
mincolumn), but the steady state re-keeps.Why, structurally
A scanner's keep-predicate is
young_log::addr_is_minor_relevant, and itreturns true for
Longlived— deliberately, and correctly for a scanner: alonglived 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.
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, andscan_closure_side_tables_youngloopstake_sorteduntil the log is emptywhile
scan_closure_owner's move hook re-notes the new key — so are-keyed owner is plausibly visited twice within one collection. But
loggedsums across rounds, so the rows cannot separate that fromcross-minor accumulation. An instrument that reports distinct keys per
collection separately from total visits would settle it.
For a scanner — not a prune — is a
Longlivedkey that has been scannedonce 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 wassimply 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 excludesLonglivedandOld— strictly stronger than the scanner's. And cc'stenuring diag shows
tenuring_survivals=1withcopied_objects=0 promoted_objects=3747on late minors: every survivor ispromoted 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 == 0on 37 % of minors, anddead <= young_beforeon 152/152 minor prunes. Same mechanism, oppositeoutcome — which is the point of the rule above.
Reproducing
Then sum
visitedandtable_lenpertable=formode=youngrows.