Skip to content

perf(gc): skip the element-shape table when neither address of a move advertises a proof - #9808

Closed
proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:perf/element-shape-transfer-gate
Closed

perf(gc): skip the element-shape table when neither address of a move advertises a proof#9808
proggeramlug wants to merge 1 commit into
PerryTS:mainfrom
proggeramlug:perf/element-shape-transfer-gate

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Closes part of #9792. Independent of #9807 (different table, different file);
they were measured in one binary and can land in either order.

The change

transfer_element_shape runs from layout_transfer for every relocated
array
— growth forwarding, a copying minor, an old-gen defrag. It already
computes had_bit for free from two header words it has read anyway, then took
ELEMENT_SHAPES' RefCell and hashed both addresses regardless, for two
removes that on the common path remove nothing.

It now returns before the table when neither address advertises a proof.

Why the gate has exactly one safe shape

Gating on !had_bit alone would be wrong. A destination that still advertises
a proof is describing storage the move has just replaced, so that case must
keep the full fail-closed path — the existing test
a_fail_closed_transfer_leaves_no_record_for_the_next_array_to_inherit fails
under exactly that sabotage, and so does the new test, on its named assertion:

a transfer whose source proved nothing must still fail the destination closed —
gating on `!had_bit` alone would leave a live proof over storage the move has
just replaced

(Verified by making that edit and running the suite: 2 failures, then restored.)

The safety case for what skipping leaves behind

Skipping leaves a record in ELEMENT_SHAPES at an address whose bit is clear.
That state is not new — it is one the design already names and defends against,
in three independent places:

  • The bit is the sole authority for a read. element_shape_proof returns
    None on a clear bit before it touches the table; note_element_store_with_bit
    is gated on the same bit before its record_for. Those are the only two
    callers of record_for, and both check first. So a record behind a clear bit
    is unreachable, not merely stale.
  • establish draws every identity from ELEMENT_SHAPE_PROOF_SEQ, never
    from the record at the address — its doc comment says this exists precisely
    so "a survivor record — left by a fail-closed transfer, a dead array whose
    prune has not run yet — can never donate its identity to the array
    established here next".
  • prune_dead_element_shape_owners drops it on the next collection, which
    is the same footprint-only guarantee a fail-closed transfer already relied on
    ("a stale record can never be read, because a recycled allocation's fresh
    _reserved is zero").

The new test asserts the first two directly rather than arguing them: it builds
the survivor state (record present, bit cleared), runs the transfer, asserts no
proof reads back, then establishes at that very address and asserts the epoch
is not the survivor's.

Numbers

Rig: the offline mock-API harness, cc_relink/cc_base_new (main 1d63fa91f,
this PR's base) as the before arm, node measured in the same session. The
candidate is a full compile of the claude-code bundle carrying both #9807 and
#9808
— they land independently but were measured in one binary. Runtime-only
diff, so both binaries come from the same codegen.

400-character streamed reply — quiet box (1-min load 4.2–5.9), 4 paired runs,
arm order alternated each pair:

metric before after node
turn CPU (s) 7.52 / 6.07 / 6.34 / 6.39 8.09 / 6.26 / 6.19 / 5.98 0.25 / 0.26 / 0.25 / 0.25
turn CPU median / min 6.37 / 6.07 6.22 / 5.98 0.25 / 0.25
settled footprint (MB) 449 / 452 / 489 / 493 436 / 428 / 448 / 450 171 / 328 / 172 / 329
footprint end-of-turn (MB) 540 / 540 / 545 / 543 524 / 537 / 542 / 543 171 / 327 / 172 / 328
peak RSS (MB) 650 / 653 / 649 / 653 662 / 650 / 649 / 647 369 / 370 / 369 / 372
CPU in the next 12 s (s) 5.76 / 4.87 / 4.87 / 4.71 7.24 / 6.85 / 4.49 / 4.27 0.02 / 0.02 / 0.01 / 0.03

3300-character streamed reply — quiet subset (load 6.4–10.0), 3 paired runs:

metric before after node
turn CPU (s) 74.95 / 75.24 / 79.31 71.84 / 74.91 / 51.62 0.54 / 0.56
peak RSS (MB) 1308 / 1299 / 1294 1297 / 1310 / 1356 402 / 407

timed_turn (37 keystrokes then two short turns), taken at load 35–50 and so
reported for completeness only:
startup 4.80 s before vs 2.60 / 1.80 s after;
typing CPU r2 1.56 vs 1.49 / 0.87 s; echo p90 66 vs 48 / 28 ms; turn r2 CPU
0.92 vs 1.03 / 1.06 s. The before arm lost one of its two runs, so one side is
n=1.

Reading, stated plainly

The CPU win is modest and inside the run-to-run spread: −2 % on the
400-char median, −4 % on the 3300-char median, and the before arm wins two of
the four paired 400-char comparisons. The one consistent directional result is
settled footprint, lower in 4 of 4 paired 400-char runs (median 470 → 442 MB,
−6 %)
— which is what removing 50 MB of per-turn Vec allocation should look
like. Peak RSS is flat.

The row that reads worse after is CPU in the next 12 s (median 4.87 →
5.67 s), and four pairs do not resolve it: the after arm's own spread there is
4.27–7.24 s against the before arm's 4.71–5.76 s.

This is the size of result that was predicted before the run — the prune costs
one walk of the live keys per collection, and removing two of three walks is a
small share of a turn in which the collector is doing much more elsewhere.
Neither metric regresses, which is the bar.

A first reading was wrong and is withdrawn

Measured at 1-min load 17–58, the candidate looked 40–58 % slower on the
3300-char arm. Repeating the same pairs on a quiet box inverted it (−4.1 %,
−0.4 %, −35 %), and the before arm's own samples for one unchanged binary
ranged 52–79 s across those loads. No CPU number taken above ~12 load on this
box is usable, and the footprint column is bimodal exactly as the campaign's
invariant 0 says (676–706 vs 1113–1134 MB in the same pair set, decided by
whether a full collection fell in the window).

Binary provenance, and the one thing the measured binary does not carry

The measured candidate is a full compile (no object-cache reuse) of the bundle
carrying this diff and #9807 on top of 1d63fa91f, which is exactly cc_base_new's commit,
so the two arms differ only by the runtime change — the diff touches no
perry-codegen/perry-hir file, so both binaries carry the same emitted JS.

The branch has since been rebased onto current main. The only thing the rebase
adds is the changelog fragment's rename to 9808-…, which is what the lint
job requires to count it as a fragment at all. Nothing in element_shape.rs
changed, so the measured binary matches this diff.

What this leaves behind, and what would falsify the safety case

Skipping leaves a record in ELEMENT_SHAPES at an address whose bit is clear
where the old unconditional remove would have swept it. That is a real
change in table occupancy, not only in correctness, and it deserves naming: the
records that used to be cleared opportunistically by any later move touching
that address are now cleared only by prune_dead_element_shape_owners. That
prune runs once per collection — PERRY_GC_DIAG and PERRY_LAYOUT_DIAG on
the same 400-character reply show 40 copying minors plus 6 full cycles against
46 prunes — so the extra occupancy is bounded by one collection's array churn,
not unbounded.

The measurement that would falsify this is peak RSS and settled footprint:
an accumulating table shows up there first. Both are flat-or-better in the
table above (peak RSS 649 → 649 MB median at 400 chars, 1300 → 1310 MB at
3300; settled footprint down 6 %). The census does not currently break out
ELEMENT_SHAPES, so this is an indirect answer rather than a direct count —
adding that row is the cheap way to make it direct, and is worth doing whether
or not this lands.

Tests

New: a_transfer_skips_the_table_only_when_neither_address_advertises_a_proof,
covering both arms of the gate's boundary. Plus a #[cfg(test)]
test_clear_element_shape_bit_only helper to construct the survivor state.

cargo test --release -p perry-runtime --lib -- --test-threads=1: 3,143
passed, 0 failed
(54 under array::element_shape). cargo clippy -p perry-runtime clean.

The run-extended-tests label is on this PR so the GC gates actually run
rather than showing skipping. Reading notes: #9782 is open (full mark-sweep
gc-stress arms), and gc-root-dominance red with violations: 0 is the
corpus floor ("checked 5,625 function(s), need at least 6,000"), which a
runtime-only diff cannot move.

https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m

Summary by CodeRabbit

  • Performance

    • Improved runtime efficiency when transferring array element-shape information by skipping unnecessary bookkeeping when no active shape proof is present.
  • Bug Fixes

    • Preserved fail-safe handling when destination shape information remains active, ensuring stale or invalid metadata cannot be treated as valid proof.
  • Tests

    • Added coverage for both optimized transfer paths and safety behavior around retained metadata.

@coderabbitai

coderabbitai Bot commented Sep 5, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 5a0b32e1-8e93-496f-a19a-d34902f09690

📥 Commits

Reviewing files that changed from the base of the PR and between c7361c8 and ffa9e91.

📒 Files selected for processing (3)
  • changelog.d/9808-element-shape-transfer-gate.md
  • crates/perry-runtime/src/array/element_shape.rs
  • crates/perry-runtime/src/array/element_shape_tests.rs

Included review availability: Your plan provides up to 8 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

transfer_element_shape now skips side-table access when neither address advertises an element-shape proof. Tests cover stale records and the fail-closed path when the destination still advertises a proof. The changelog documents the gate and its safety conditions.

Changes

Element shape transfer optimization

Layer / File(s) Summary
Transfer gate and survivor-state validation
crates/perry-runtime/src/array/element_shape.rs, crates/perry-runtime/src/array/element_shape_tests.rs, changelog.d/9808-element-shape-transfer-gate.md
transfer_element_shape returns early when both proof bits are clear. A test helper creates records with cleared bits. Tests verify stale records cannot be read or donate epochs, while a destination proof still triggers fail-closed cleanup.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to ffa9e

Array relocation now avoids unnecessary element-shape side-table work when neither address advertises a proof, while retaining cleanup when the destination does. The covered boundary behavior leaves no identified merge-readiness risk.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary optimization: skipping the element-shape table when neither move address advertises a proof.
Description check ✅ Passed The description provides the change summary, safety rationale, related issue, test coverage, commands, results, benchmark data, and limitations. It does not use the template headings or include the ch…
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 2 files. (1 skipped: 1 …
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@proggeramlug proggeramlug added the run-extended-tests Opt PR into compile-smoke/parity/doc-tests/drizzle-mysql-smoke label Sep 5, 2026
@proggeramlug
proggeramlug force-pushed the perf/element-shape-transfer-gate branch from 650361b to 7d9c08d Compare September 5, 2026 14:42
… advertises a proof

`transfer_element_shape` runs for every relocated array and already decides
`had_bit` from header words it has read anyway — then took the side table's
`RefCell` and hashed both addresses regardless, for two removes that remove
nothing whenever the source proved nothing and the destination advertises
nothing. It now returns before the table in that case.

The gate cannot be `!had_bit` alone: a destination still advertising a proof
describes storage the move has just replaced, so that case keeps the full
fail-closed path. Both halves are pinned by a new test, which fails on its
named assertion if the gate is widened.

Leaving a record behind at an address whose bit is clear is not a new state:
the bit is the sole authority for a read, `establish` draws identities from
`ELEMENT_SHAPE_PROOF_SEQ` rather than from the record at the address, and
`prune_dead_element_shape_owners` drops it on the next collection — the same
guarantees a fail-closed transfer already depended on.

Claude-Session: https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed on main via merge train #9866 (rebase-merged, so your commits keep their authorship). Thanks!

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.

1 participant