Skip to content

perf(gc): prune the per-object layout tables from a young-entry log on a minor - #9895

Draft
proggeramlug wants to merge 5 commits into
PerryTS:mainfrom
proggeramlug:perf/prune-young-logs
Draft

perf(gc): prune the per-object layout tables from a young-entry log on a minor#9895
proggeramlug wants to merge 5 commits into
PerryTS:mainfrom
proggeramlug:perf/prune-young-logs

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Draft. A clean 5-round rotation on main decides ready, and the head commit
is not yet compiled (see Known gaps).

What

A minor's death prune for the two per-object layout tables
(LAYOUT_SLOT_MASKS + TYPED_LAYOUTS) walks a young-entry log
(gc/young_log.rs) instead of both tables. The second commit takes
closure.dynamic_props off its young scanner walk, which the same
measurement shows is worse than the full walk it replaced. The third fixes the
tests the second broke and proves the guard it moved can fail.

Why the prune, and why only the prune

Both of a minor's deadness predicates require the owner to be in the nursery:
owner_is_dead_copied_minor_from_space demands eden or the active survivor
half, and PostTraceProbe::owner_is_dead on a minor demands an in-arena,
untenured HeapGeneration::Nursery address. An owner that was old at the last
prune is still old, so visiting it cannot remove anything.
layout_key_may_be_nursery is a strict superset of both, and
dead <= young_before held on 152/152 minor prunes when measured directly.

A full prune keeps its whole-table walk — old owners do die in a full trace —
and rebuilds the log from the survivors it already classifies.

Measured

Quiet host. HC = main 35c36f425 + #9838 + #9860 + #9857 + a measurement
counter; HCY = HC + this branch; both relinked on the same cached objects, so
the pair differs by this change alone.

3300-char, 3-turn totals (s) HC HCY
rounds 20.09 / 17.97 / 18.03 17.06 / 16.64 / 16.31
MIN 17.97 16.31−1.66 s, −9 %
lower in 3 of 3 rounds
400-char HC MIN HCY MIN
3.30 3.24, lower 3/3

Peak RSS 559–566 vs 557–563 MB — flat. [gc-time] minor_us 22.12 s (48.3 %)
→ 20.28 s (46.0 %) at 3300, 2.72 → 2.54 at 400.

Load confound, stated: 1-minute load fell from 12–15 during the counter
runs to about 2 by the last timing round, which favours later runs. HCY was
lower in the two rounds where HC ran first, so ordering does not account
for the direction — but the 5-round rotation against plain main is what makes
this ready.

An independent counter says the win is not something else moving: the #5029
dirty-coverage restore walk is the same work in both arms (dirty_old_pages
992 → 1,008, slots_visited 58,505 → 57,972, productivity 0.0245 vs 0.0246).

The mechanism, which does not depend on a timing run

From [gc-young-log] table=gc.layout_tables, 273 minor cycles: 366,347
entries visited against 36,938,896 the whole-table walk would visit — 99.0 %
skipped
, zero cycles worse than a full walk, and inert in full mode (0
skipped) by design. On an earlier base the same rows read 96.2 % / 26×, with
median entries-visited-per-prune of 1 and 41 of 111 minors visiting
zero
— the walk is not shrunk, it is deleted. kept/logged median 0.000:
this log drains.

Second commit: closure.dynamic_props back to full mode

table skipped
gc.layout_tables 99.0 %
object.descriptors 95.7 %
object.transition_cache 83 %
shapes.families+indices 80 %
closure.dynamic_props 2.2–2.7 %, worse than full on 223/273 and 217/272 cycles

One predicate explains the spread. A scanner keeps
addr_is_minor_relevant, true for Longlived by design because a longlived
object can point at a young one, so its candidate set is close to the whole
table and the log never drains (kept/logged median 1.000 there). A prune
asks who died, so it excludes Longlived and Old both. Same mechanism,
opposite sign — which is why this PR adds a young walk to one table and removes
one from another.

The log stays: the death prune still uses it and the full scanner rebuilds it.
Rule 2's log-completeness re-derivation moves into that prune, because the
scanner it lived in is gone and the prune is now the log's only consumer.
Dropping it would have removed the only guard on a log a prune still trusts,
and the failure mode is a dead owner's entries surviving silently, not a slow
walk.

Third commit: the tests the second one broke, and a guard proved able to fail

The policy reversal broke three tests that assert row.partial for
closure.dynamic_props — I changed the contract without grepping for its
tests. They are re-pointed rather than deleted: in each case the property under
test (a side-table-only value is traced and evacuated; a young value under an
old owner is traced; an old owner's entries survive) is unchanged, and only the
walk delivering it differs. old_closure_entries_are_skipped_by_a_minor became
..._survive_a_minor_full_walk, because there is no skip left to observe for
that table — which is the finding.

The moved rule-2 guard had been exercised and never fired, which is not the
same as being able to fire. dropping_a_logged_closure_owner_trips_the_prune_rule2_check
drops every logged owner while leaving the tables populated — what a writer
publishing without arming leaves behind — and requires the prune to panic with
its own named message. It runs in a child process because that panic can cross
an extern "C" frame and abort rather than unwind.

Correctness

  • Rule 1, arm before publish, at all five writers: both insert wrappers, the
    in-borrow mask mint in layout_note_slot, and both per-object move hooks.
  • Rule 2, machine-checked and sabotage-proved: deleting the arm at
    gc/layout.rs:958 makes
    gc::tests::runtime_roots::hook_dispatch_handles::test_bound_timer_dispatch_roots_args_during_async_hook_init_gc
    panic with "young log for gc.layout_tables does not name …". The witness is
    an unrelated fixture, not the test written for it — the stronger result.
  • Rule 3, counters: note_walk per prune; three tests read the rows back.
  • PERRY_YOUNG_LAYOUT_RECORDS stays exact — the codegen gate
    emit_gated_forget_object_layout depends on it — because the young walk
    counts per map entry (in_masks + in_typed), as the full prune does.
  • The address filter is deliberately not rebuilt on a minor: that walk is
    what this removes, its false is the only load-bearing answer, and a stale
    set bit is a false positive.

cargo test --release -p perry-runtime --config 'profile.release.package.perry-runtime.debug-assertions=true' -- --test-threads=1 gc::tests::969 passed, 0 failed on the first commit.

Known gaps

  • The head commit has not been compiled. Disk on the box it was written on
    was at 9 GB against a 12 GB floor, so it is not type-checked and not run. It
    needs a debug cargo test -p perry-runtime --lib before it is believed.
  • The 5-round rotation against plain main is queued and decides ready.

Verdict on the landing base (perrymaster, main 33e2856c5 vs main + this branch, 5×3300 + 3×400, quiet box, 2026-09-06 22:40 CEST)

main + young log
3300 turn CPU, per round 2.66 2.71 2.67 2.68 2.69 2.68 2.78 2.66 2.66 2.68
3300 MIN / mean 2.66 / 2.68 2.66 / 2.69
3300 peak RSS 607–615 MB 613–617 MB (+3…+10)
3300 settled 120 s 489 MB 518 MB (+29, one row)
400 turn CPU 0.99 1.06 1.00 1.03 1.09 0.97

Measured flat on main: CPU ±0.07 s at both lengths; peak within +10 MB; settled +29 MB at 3300 on one row. The pre-registered prediction ("flat on a post-#9857 base") holds here; FC2's −9 % was a property of the I2 base, where the restore pass was ~20× dearer. The counter result (99 % of young-mode layout-table visits skipped) stands as measured, and the closure.dynamic_props full-mode revert (commit 2) is correct on its own evidence — but with turn CPU unchanged and a settled-footprint cost, this PR does not land as a performance change. Left as draft; the revert of closure.dynamic_props' young walk could be split out if #9897's instrument fix wants it.

Raw: perrymaster /root/rig9831/combPY.jsonl, idlePY.jsonl.

Superseded by #9976 (2026-09-08)

Only the layout-prune commit (19a6cd2) survives, replayed patch-identically onto the current tree in #9976 and measured there (LAYOUT + TYPED pruning 3.8–4.5 ms → 0.3 ms per steady cc minor). 390796a is already on main; the closure-log removal (41a8af7 and its two test follow-ups) is dropped because MP measured that log paying (closure_dynamic_props 3.75 → 3.06 ms) on the current tree.

https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo

Ralph Küpper added 3 commits September 6, 2026 06:56
… filter that admits when it is outgrown

`prune_dead_per_object_layout_owners` walked every live key three times per
collection — `retain`, `layout_addr_filter_rebuild` (which buffered them all
into a `Vec<usize>` first), then `recount_young_layout_records`. The last two
want exactly the survivor set `retain` already visits, so they fold into its
closure and the `Vec` disappears; on the compiled claude-code TUI that `Vec`
alone allocated 50.6 MB per 400-character reply.

The new `PERRY_LAYOUT_DIAG` instrument reports what made this expensive:
162,258 live keys against a 4,096-bit address sketch documented for "one or
two entries", with all 4,096 bits set. Every probe answers "may hold", so the
early returns the sketch exists to serve never fire, and each rebuild is an
O(live keys) walk that restores the all-ones state it started from. Past one
eighth of the bits the rebuild now reaches that state in O(1) instead.
Widening the sketch is a codegen change — the geometry and hash are mirrored
in `emit_gated_forget_object_layout` — and would need ~190 KB of inline TLS
per thread to discriminate at this occupancy.

`transfer_per_object_descriptor` gains the emptiness test its shared flag
cannot express: one `len` load instead of two hashes per evacuated object,
for a map that is empty for the whole of a cc turn.

`LAYOUT_DIAG` is declared with `crate::perry_thread_local!`, as
`scripts/check_thread_locals.py` requires of every new declaration.

Claude-Session: https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m
…n a minor

A minor's death prune for `LAYOUT_SLOT_MASKS` + `TYPED_LAYOUTS` asked "which
owner died?" of every key in tables sized by everything the program ever
created. Both of a minor's deadness predicates require the owner to be in the
nursery — `owner_is_dead_copied_minor_from_space` demands eden or the active
survivor half, `PostTraceProbe::owner_is_dead` on a minor demands an in-arena,
untenured `HeapGeneration::Nursery` address — so an owner that was old at the
last prune is still old and the visit cannot remove anything.

Measured on a compiled claude-code streamed reply (`PERRY_GC_DIAG=1`, counter
on a measurement-only branch): 6,792,375 entries visited across 109 minor
prunes, of which at most 125,367 (1.85 %) could possibly have died; median
`young_before/visited` 0.0000; 40 of those 109 minors had NOTHING that could
have died while carrying 39 % of all visits. 54x over-visit at 3300
characters, 25x at 400. `dead <= young_before` held on 152/152 minor prunes.

So both maps take the young-entry log of PerryTS#9754 (`gc/young_log.rs`), kept in
the existing `PerObjectLayoutHint` hot slot so no new thread-local is declared
and every writer arms it with a TLS resolution it has already paid for:

* every writer notes a key `layout_key_may_be_nursery` admits BEFORE the entry
  becomes findable (rule 1) — the two insert wrappers, the in-borrow mask mint
  in `layout_note_slot`, and both per-object move hooks;
* a minor walks the log: a logged key in neither map is stale and drops, a
  present dead key is removed from both maps, a live key is re-logged only
  while still young, so a promoted owner leaves the log for good;
* a full prune keeps its whole-table walk (old owners DO die in a full trace)
  and rebuilds the log from the survivors it is already classifying;
* under `debug_assertions` the young prune re-derives the candidate set from
  the maps and panics on any young key the log does not name (rule 2);
* `note_walk` records logged/visited/kept/table_len per prune, so
  `[gc-young-log] table=gc.layout_tables` prints the skip and the tests read
  it back (rule 3).

The address filter is deliberately NOT rebuilt on a minor: the whole-table
walk that rebuilt it is what this removes, its `false` is the only
load-bearing answer, and a stale set bit is a false positive. The amortised
rebuild in `layout_addr_filter_add` and the full prune keep it selective.

Why this table pays where PerryTS#9754's scanners did not: a scanner keeps
`addr_is_minor_relevant`, true for `Longlived` by design, and cc allocates its
shape-key arrays longlived, so those logs never drain (`kept/logged` median
1.000, and `closure.dynamic_props` is a 2.56x regression there). A prune
excludes `Longlived` and `Old` both, and cc promotes every survivor after one
survival. Same mechanism, opposite sign, decided by the predicate.

`YoungLog::clear` loses its `#[cfg(test)]`: a prune that early-returns on the
emptiness proof must drop the log with it, or a workload that repeatedly fills
and empties the maps between collections accumulates stale keys for ever.
…rse than full

PerryTS#9754 gave four side tables a minor-scoped root scan. Measured per table on
the compiled claude-code TUI, three of them earn it and this one does not.

Both arms of a 3-turn 3300-character run on a quiet host (`[gc-young-log]`
rows, 273 and 272 minor cycles):

| table | skipped | verdict |
|---|---|---|
| `gc.layout_tables` | 99.0 % (366,347 visited of 36,938,896) | earns it |
| `object.descriptors` | 95.7 % | earns it |
| `object.transition_cache` | 83 % | earns it |
| `shapes.families+indices` | 80 % | earns it |
| **`closure.dynamic_props`** | **2.2-2.7 %** | **WORSE THAN FULL on 223/273 and 217/272 cycles** |

The cause is one predicate, and it is why the same technique lands so
differently on adjacent tables. A SCANNER's keep-test is
`addr_is_minor_relevant`, which returns true for `Longlived` BY DESIGN — a
longlived object can point at a young one — so the "relevant" set is close to
the whole table and the log never drains: `kept/logged` has median 1.000 here.
Multi-round re-logging then makes the young walk visit MORE entries than the
full walk it replaces. That is the same finding, and the same fix, as the
shape-cache young log dropped for skipping 0 % and costing 35 % more.

So every pass over this table is a full pass again.

The LOG ITSELF STAYS. `prune_dead_closure_side_table_owners_young` uses it,
and a PRUNE's predicate is not the scanner's: it asks who DIED, so it excludes
`Longlived` and `Old` both. That asymmetry is the whole point — the same
mechanism is a 99.0 % skip on one walk and a 2.2 % skip on another, decided
entirely by which predicate the walk keeps on. The full scanner rebuilds the
log from what it finds, so the prune's candidate set stays complete.

Rule 2 moves with it. The log-completeness re-derivation ran at the top of the
minor-scoped scanner; that walk is gone, and the prune is now the log's only
consumer, so the machine check that catches a writer publishing without arming
moves into the prune. Dropping the scanner without moving it would have
deleted the only guard on a log a prune still trusts — the failure would not
have been a slow walk but a dead owner's entries surviving in silence.

Not yet verified: this file has only been compiled with `debug_assertions`
OFF, so the moved `#[cfg(debug_assertions)]` call has not been type-checked
and the moved rule 2 has not been observed to fire.
@proggeramlug proggeramlug added the run-extended-tests Opt PR into compile-smoke/parity/doc-tests/drizzle-mysql-smoke label Sep 6, 2026
@coderabbitai

coderabbitai Bot commented Sep 6, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Ralph Küpper added 2 commits September 6, 2026 17:40
… contract, and prove the moved rule-2 guard can fail

Two problems with the previous commit, both mine.

**1. It broke three tests and I did not run them.** `41a8af7da` reversed the
walk policy for `closure.dynamic_props` without grepping for the tests that
assert it. On a debug-assertions run: 3225 passed, 3 failed —
`young_closure_prop_value_is_moved_through_the_log`,
`young_value_under_an_old_closure_owner_is_logged_by_the_value` and
`old_closure_entries_are_skipped_by_a_minor`, each asserting `row.partial`,
i.e. exactly the policy that commit reverses. They fail in release too.

Re-pointed at the current contract rather than deleted, because in every case
the property under test survives and only the walk that delivers it changed:

* `..._is_traced_and_moved_by_a_minor` — a value reachable ONLY through the
  side table is still traced, evacuated and re-keyed. Now asserts the FULL
  walk, and that it rebuilds the log for the death prune.
* `..._is_traced_by_a_minor` — a young value and prototype under an OLD owner
  are still traced. Renamed off "is_logged_by_the_value": the log is still
  armed by the value, but its only consumer is now the prune, which keys on
  OWNERS, so value-based arming has no reader left. Flagged in the test, not
  removed — dropping it is a separate change needing its own measurement.
* `old_closure_entries_survive_a_minor_full_walk` — was "are skipped by a
  minor", asserting `visited == 0`. There is no skip left to observe for this
  table, which IS the finding; it now asserts the full walk and the surviving
  correctness property.

**2. The rule-2 guard I moved had been exercised but never seen to fail.** The
layout-prune tests drive the prune and passed, so the moved
`debug_assert_closure_young_log_complete` compiles and runs — but "did not
fire" is not "can fire", and a check that cannot fail is documentation.

`dropping_a_logged_closure_owner_trips_the_prune_rule2_check` drops every
logged owner while LEAVING the three tables populated — precisely what a
writer publishing without arming leaves behind — and requires the prune to
panic with its own named message. Clearing the tables as well would empty the
relevant set and the guard would return early, i.e. prove nothing; the new
`test_drop_closure_young_log` hook exists to avoid exactly that.

It runs in a child process: the panic is raised inside a collection and can
cross an `extern "C"` frame and abort rather than unwind, which
`#[should_panic]` cannot catch. Asserting on the child's status and stderr is
robust to both, and is the isolation pattern
`test_armed_per_object_layout_thread_exit_disarms_global_count` already uses.
It is `#[cfg(debug_assertions)]`, since a release build has no guard to trip
and the child would exit 0.

NOT COMPILED. Disk on this box is at 9 GB against a 12 GB floor, so this
commit has not been type-checked, formatted-checked beyond `cargo fmt`, or
run. It needs a debug `cargo test -p perry-runtime --lib` before it is
believed.
`old_closure_entries_survive_a_minor_full_walk` failed on
`assert!(row.table_len >= 2)` with
`YoungLogWalk { partial: false, logged: 1, visited: 1, kept: 0, table_len: 1 }`.

It is the assertion, not the semantics, and the reason is a field-name
collision I carried over without checking.

**`table_len` means two different things in this one table's two walks.** The
young walk computed `props.len() + prototypes.len() + deleted.len()`; this
fixture's single owner appears in `props` AND `deleted_keys`, so it counted 2.
The full walk builds a deduped OWNER vec, so the same state counts 1. I moved
the assertion from one walk to the other and kept the old number.

**`kept=0` with `logged=1` is correct and is not a stale log entry.** On a
FULL row `logged` is set to `table_len` by construction (`logged: table_len,
visited: table_len`), so it means "one owner considered", not "one entry still
logged". The log after the walk holds `kept` entries, and 0 is right: an old
owner whose only value is a number and whose only other state is a deleted key
has nothing a minor can act on, so it must not be re-logged. That property is
now asserted (`kept < table_len`) instead of being an unexplained field in a
failure message.

Test-only. No product code changes.

The collision cuts the safe way for the verdict that motivated the revert, and
that is worth stating rather than leaving to be rechecked. `young_log_an.py`
derives `skipped = table_len * passes - visited` and flags `visited >
table_len * passes` as worse-than-full. In young mode `table_len` is the
INFLATED sum-of-three-maps, so it makes worse-than-full HARDER to trigger and
makes the reported skip percentage LARGER than the truth. The measured
`closure.dynamic_props` result — 2.2-2.7 % skipped, worse than full on 223/273
and 217/272 cycles — is therefore conservative in both directions, and the
real case for taking that table off its young walk is stronger, not weaker.

@jdalton jdalton left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of 0a427a39f2d3ad08c0be0c57b27e0d329f525c80 (2026-09-07).

The distinction between death-pruning young owners and scanning old owners that may reference young values is essential, and the diff makes it explicit. Please resolve the telemetry units alongside #9897: the layout full walk reports map-entry counts (masks.len() + typed.len()), while the young log deduplicates keys and reports unique-key visits/kept counts. A key in both maps counts twice in one mode and once in the other. Add a fixture with an owner present in both maps and name/report map entries and unique owners separately before using their ratio as a speedup. This does not establish a GC correctness defect; it affects the performance evidence.

Validation scope: source/diff inspection; I have not run this PR's build or test suite locally.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-extended-tests Opt PR into compile-smoke/parity/doc-tests/drizzle-mysql-smoke

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants