Skip to content

perf(gc): borrow the shape record in the slot visitor, stop moving the iterator (#10362) - #10371

Closed
proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:perf/10362-slot-iterator-memcpy
Closed

proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:perf/10362-slot-iterator-memcpy

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Part of #10362 (piece 1 of the per-collection cost).

Problem

visit_gc_layout_slot_descriptors builds a HeapChildSlotIterator (152 bytes) for every traced object via
gc_child_slots. 40 bytes of it are a ShapeDescriptor lifted out of the shape table (#8122). Two
by-value moves of that iterator compile to out-of-line memcpy(…, 152) calls:

Fix

  • object/shapes.rs: new ShapeRecordRef, a Copy handle to a live slab record (live inline-slot
    bound, keys word, keys slot). shape_record_by_id and object_shape_record make the same slab
    probe as the descriptor lookups, without lifting a copy. note_old_generation_carrier and
    note_full_trace_carrier take the handle. ShapeDescriptor::keys_slot is now used only by tests, so it is cfg(test).
  • object/gc_slots.rs: gc_shape_keys_edge_slot and gc_field_slot_range take the handle.
  • gc/layout.rs: the iterator carries Option<ShapeRecordRef> (8 bytes; iterator 152 → 120 bytes), and so do the
    _from shared-mask helpers. The Array/Closure arms build the iterator in place with let-else.
  • gc/layout_slot_visit.rs: the Masked arm iterates &mut child_slots.

Why it is sound

Numbers (instructions:u, min of 5, same host; base = main 33690c5)

fixture base this PR delta
gc3 12,572,217,899 12,275,666,439 -2.36%
w1000 1,090,106,616 1,081,650,574 -0.78%
w5000 2,049,859,772 2,018,016,372 -1.55%
w20000 5,094,906,374 4,994,539,985 -1.97%
oldyoung 1,525,494,497 1,503,368,788 -1.45%
alloc 320,266,262 320,266,170 0.00%
152-byte memcpy calls on gc3: 6,181,945 → 6.
Note: the issue's instructions:u profile showed memmove at ~14%. That event skids on this CPU. Under cycles:pp
memmove is 1.3% on base (0.48% after), which is consistent with the −2.36% measured here.

Gates

  • Output byte-identical to node on gc3, w1000, w5000, w20000, oldyoung, alloc.
  • PERRY_GC_FROMSPACE_SCAN_ABORT=1 exits 0 on all six, with scan lines on every cycle. Positive control: gc3 and w1000
    built at fcd108b (pre-fix(hir): a null-typed field is not a proof the GC may skip the slot (#10348) #10352) abort with "1 dangling reference(s)", so the gate can fail.
  • cargo test --release -p perry --test gc_record_null_typed_field_10348 passes.
  • cargo test --release -p perry-runtime -- --test-threads=1: same result on base and fix (3952 pass, 1 fail, 4 ignored).
    The failure is the same pre-existing test on both arms (heap_generation::a_free_or_move_outside_every_scope_is_caught_in_debug_builds).
  • cargo fmt --check clean. The clippy warning set is identical to base (879). File-size check OK (gc/layout.rs = 2000).
  • GC store-site inventory, addr-class inventory, thread-local policy and root-dominance poll-reach checks: same results as base.
  • Not a gate: PERRY_GC_VERIFY_EVACUATION=1 exits 0 on this PR, but also on the known-bad pre-fix(hir): a null-typed field is not a proof the GC may skip the slot (#10348) #10352 gc3.

Changelog

changelog.d/10371-gc-slot-iterator-memcpy.md (### Performance).

Summary by CodeRabbit

  • Performance Improvements
    • Improved garbage collection performance by reducing unnecessary internal iterator copying during object tracing.
    • Reduced instruction counts by up to 2.36% in representative workloads, with additional gains across smaller retained sets and generational churn.
    • Preserved existing garbage collection correctness and behavior.

Ralph Küpper added 2 commits September 16, 2026 13:11
…e iterator (PerryTS#10362)

Base: 33690c5 (main, includes the PerryTS#10348 fix from PerryTS#10352).

gc_child_slots built a 152-byte HeapChildSlotIterator for every traced
object, 40 bytes of it a lifted ShapeDescriptor (PerryTS#8122). Two by-value moves
of that iterator compiled to out-of-line 152-byte memcpy calls inside
visit_gc_layout_slot_descriptors:

  * the Array and Closure arms' `Option::map(..).unwrap_or_else(..)`
    temporary, copied into the caller's slot (62% of the calls);
  * `for .. in child_slots` in the Masked arm, which moved the iterator
    into the loop (35%).

On the PerryTS#10362 retained-graph fixture that was 6,181,945 memcpy calls of
exactly 152 bytes (counted with an LD_PRELOAD shim). After this change: 6.

  * object/shapes.rs: ShapeRecordRef, a Copy handle to one live slab
    record (live bound, keys word, keys slot). shape_record_by_id and
    object_shape_record make the same slab probe as the descriptor lookups,
    without lifting a copy. note_old_generation_carrier and
    note_full_trace_carrier take the handle. ShapeDescriptor::record_ref()
    adapts the one caller that holds a descriptor. keys_slot() is now used
    only by tests, so it is cfg(test).
  * object/gc_slots.rs: gc_shape_keys_edge_slot and gc_field_slot_range
    take the handle.
  * gc/layout.rs: the iterator carries Option<ShapeRecordRef> (8 bytes; the
    iterator is now 120). The `_from` mask helpers take the handle.
    gc_child_slots' Array and Closure arms build the iterator directly in
    the return slot with let-else, as the ObjectFields arm already did.
  * gc/layout_slot_visit.rs: the Masked arm iterates `&mut child_slots`.

Still one shape-table probe per receiver (PerryTS#8122). The handle is read at the
same points the lifted copy was: the carrier notes and the keys edge, before
any visit. It relies on the same record-address validity the carrier notes
already write through (PerryTS#9706). The PerryTS#8112 old_carrier/ephemeron gate and the
PerryTS#9726 full-trace note are unchanged.

instructions:u, min of 5, same host, base (main 33690c5) vs this:
  gc3       12,572,092,967 -> 12,275,653,370  -2.36%
  w1000      1,090,107,336 ->  1,081,645,123  -0.78%
  w5000      2,049,860,743 ->  2,018,013,960  -1.55%
  w20000     5,094,775,666 ->  4,994,573,033  -1.97%
  oldyoung   1,525,484,973 ->  1,503,383,712  -1.45%
  alloc-only   320,266,130 ->    320,266,017   0.00%

All outputs match node byte for byte. PERRY_GC_FROMSPACE_SCAN_ABORT=1 is
clean on all six fixtures, and it aborts on the same gc3 built at
fcd108b. PERRY_GC_VERIFY_EVACUATION=1 on gc3 exits 0.
@coderabbitai

coderabbitai Bot commented Sep 16, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 332ad3ce-c3da-4639-9672-d3bcf8932920

📥 Commits

Reviewing files that changed from the base of the PR and between 33690c5 and 6c9e2a6.

📒 Files selected for processing (5)
  • changelog.d/10371-gc-slot-iterator-memcpy.md
  • crates/perry-runtime/src/gc/layout.rs
  • crates/perry-runtime/src/gc/layout_slot_visit.rs
  • crates/perry-runtime/src/object/gc_slots.rs
  • crates/perry-runtime/src/object/shapes.rs

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


📝 Walkthrough

Walkthrough

The GC slot visitor now uses ShapeRecordRef handles instead of copied ShapeDescriptor values. It resolves object shapes once, constructs iterators directly, and iterates masked payload slots by mutable reference to reduce iterator copies.

Changes

Shape record GC flow

Layer / File(s) Summary
ShapeRecordRef resolution
crates/perry-runtime/src/object/shapes.rs
Adds ShapeRecordRef and shape-record lookup helpers. The wrapper exposes live slot counts, keys, and key slots.
Carrier and slot integration
crates/perry-runtime/src/object/shapes.rs, crates/perry-runtime/src/object/gc_slots.rs
Carrier-note and GC slot helpers now accept ShapeRecordRef and read record data through its accessors.
Layout lookup and iterator reuse
crates/perry-runtime/src/gc/layout.rs, crates/perry-runtime/src/gc/layout_slot_visit.rs, changelog.d/10371-gc-slot-iterator-memcpy.md
GC layout paths reuse one shape record, construct array and closure iterators directly, and borrow the iterator in masked scans. The changelog records the measured copy reduction and performance results.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Refactor

Sequence Diagram(s)

sequenceDiagram
  participant GC
  participant gc_child_slots
  participant ShapeTable
  participant HeapChildSlotIterator
  GC->>gc_child_slots: enumerate object child slots
  gc_child_slots->>ShapeTable: resolve object_shape_record once
  ShapeTable-->>gc_child_slots: return ShapeRecordRef
  gc_child_slots->>HeapChildSlotIterator: construct iterator directly
  HeapChildSlotIterator-->>GC: yield child slots without iterator copy
Loading

Merge Risk: ⚪ Minimal · up to 6c9e2

No confirmed correctness or safety issue remains; the optimization is ready to merge.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main GC performance change: borrowing shape records and avoiding iterator moves.
Description check ✅ Passed The description is detailed and covers the problem, implementation changes, related issue, performance results, soundness rationale, validation gates, and changelog entry. It does not use the template…
Docstring Coverage ✅ Passed Docstring coverage is 88.89% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 27 functions across 4 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

Copy link
Copy Markdown
Contributor Author

Landed via merge train #10393 (v0.5.1582). All source commits preserve authorship; merged main matches the validated train exactly.

proggeramlug pushed a commit that referenced this pull request Sep 17, 2026
Base: 6c9e2a6 (PR #10371's head), which this stacks on.

`layout_transfer` runs for every evacuated object on every copying minor, both
old-generation evacuations and `js_array_grow`. All four callers copy the
source header's `_reserved` into the destination first — the minor through
`reserved_with_copied_survival_age`, which rewrites only the age bits — so
every layout fact a header carries has already arrived: the layout state,
`GC_LAYOUT_ALL_POINTERS`, the raw-f64 / holes flags, `GC_ARRAY_ELEMENT_SHAPE`
and `GC_OBJ_TYPED_LAYOUT_INTACT`.

The funnel re-derived that half anyway, per object: two header
classifications, a rewrite of bits that were already equal, an out-of-line
call per array and per object, the #7510 flag-and-filter gate evaluated twice,
and — for every intact object — a ShapeId-keyed `SHAPE_LAYOUTS` probe whose
answer a relocation cannot change. Measured with gdb `stepi` on the #10362
retained-graph fixture: 160 instructions per moved array, 245 per moved
object, 518M in total (4.2% of the run), none of which reached a side-table
record. Both per-object maps held one key (`PERRY_LAYOUT_DIAG`).

The contract the callers always satisfied is now the funnel's stated contract,
asserted in test and debug builds, and the funnel moves only what a header
cannot carry: the element-shape record (#7480), the residual static-prototype
owner registry (#9304), and the per-object `TYPED_LAYOUTS` / `LAYOUT_SLOT_MASKS`
entries (#7510). Each is gated inline by the bit or latch that governs it, and
the record moves themselves live in a `#[cold]` slow path.

* `gc/layout/transfer.rs` (new): the funnel, its gates and the contract
  assertion. `gc/layout.rs` drops to 1919 lines, off the 2000-line cap.
* `gc/layout_tables.rs`: `per_object_layouts_may_hold_either` answers the
  #7510 gate for both maps and both addresses in one hot-slot resolution.
* `object/prototype_chain.rs`: the registry latch is readable without the call.
* `array/header.rs`, `array/element_shape.rs`: `transfer_array_numeric_layout`
  and `clear_element_shape_ptr` were only ever called by the header half of the
  funnel and are deleted. That also drops a spurious
  `invalidate_representation_change` a hole-tolerant array took on every move
  (the transfer cleared the flag it was about to set again); the counter feeds
  `PERRY_TYPED_FEEDBACK_TRACE` output only.

Behaviour: one change, the lazy intact downgrade. The bit is a fact of the
object and of two tables a move does not touch, so re-asking at move time could
only downgrade objects that happen to move. The state it cleared — intact while
no descriptor is reachable — is legal and handled: `shape_install_shared`
poisons a shape's shared entry and leaves "any still-INTACT siblings" to fall
back, #8115 clears the bit at the first contradicting store, the trace falls
back to `GC_LAYOUT_UNKNOWN` and scans every slot, and the query helpers answer
"no descriptor". An unmoved sibling keeps its bit today, so nothing could have
depended on the move clearing it.

`test_poisoned_shape_intact_and_per_object_record_survive_a_copying_minor`
builds that exact state and drives it through a real copying minor: the moved
receiver keeps the bit, answers every query as it did before the move, and its
child survives and is rewritten, while the sibling that poisoned the shape
keeps its per-object record across the move. It fails on the parent commit (the
old funnel clears the bit) and fails again under a sabotaged funnel that does
not move the per-object records.
`test_layout_transfer_requires_the_relocation_header_copy` pins the contract.

instructions:u, min of 5, base 6c9e2a6 vs this:
  gc3       12,275,577,365 -> 11,880,548,143  -3.22%
  w1000      1,081,647,372 ->  1,054,559,901  -2.50%
  w5000      2,018,014,139 ->  1,919,532,121  -4.88%
  w20000     4,994,632,943 ->  4,797,474,484  -3.95%
  oldyoung   1,503,368,405 ->  1,471,395,073  -2.13%
  alloc-only   320,266,214 ->    320,265,756   0.00%

That is 154 instructions per relocation on gc3 (2,560,042 relocations on both
arms, counted with uprobes), and 71% of the ceiling a full knockout of the
funnel measured. The cold path is entered 1,213 times, 0.05% of relocations.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant