Skip to content

perf(repsel): per-array homogeneous element-shape invariant (#7480) - #7496

Merged
proggeramlug merged 8 commits into
mainfrom
perf/7480-element-shape-invariant
Aug 6, 2026
Merged

perf(repsel): per-array homogeneous element-shape invariant (#7480)#7496
proggeramlug merged 8 commits into
mainfrom
perf/7480-element-shape-invariant

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Builds the shared prerequisite decided in #7480. Invariant layer only — no consumer, and no emitted-code change.

#7480's scoping finding was that its two candidate routes are not "A vs B": both need the same missing fact first — a per-array homogeneous element-shape invariant, "every element of this array is an object of class C". The only array-level invariants that existed were the numeric ones (GC_ARRAY_RAW_F64_LAYOUT / _HOLES). This PR adds the pointer sibling and stops there, because #6377's lesson is that every added proof un-gates latent fast paths its own microbench never exercises: the proof lands and is tested on its own before anything reads it.

Storage — deliberately the same shape as Phase 4a's dense bit

4a's dense bit works because the collector copies the whole _reserved word when it moves an object, so the invariant survives a copying minor with no side-table walk and no per-move bookkeeping. This mirrors it point for point:

4a (RAW_F64_LAYOUT) here (ELEMENT_SHAPE)
fast proof _reserved bit 7 _reserved bit 11
rides a move yes (_reserved is copied) yes (same word)
self-heals by rescan ensure_array_numeric_raw_f64 ensure_element_shape
move fixup transfer_array_numeric_layout transfer_element_shape
clear funnel clear_array_numeric_layout clear_element_shape

Bit 11 is shared with the object-only OBJ_FLAG_HAS_DESCRIPTORS — the same disjoint-by-obj_type reuse GC_ARRAY_RAW_F64_HOLES (bit 12) already makes against GC_OBJ_TYPED_LAYOUT_INTACT. _reserved had no free bit left.

The one thing 4a does not need is a payload: "raw f64" is the whole fact, so its bit is the whole record. A shape id does not fit in a bit, so it lives in an address-keyed thread-local record moved by transfer_element_shape from inside layout_transfer — the same call site, and the same split, as TYPED_LAYOUTS. The bit stays the authority: a fresh allocation's _reserved is zero, so a stale record left at a recycled address is unreachable, and a bit with no record fails closed (and clears itself).

The record is { class_id: u32, verified_len: u32, epoch: u64, generation: u64 } — four integers, no heap pointer. It is therefore deliberately not a gc_register_mutable_root_scanner entry: there is nothing in it for the collector to mark or rewrite, the class_id is a registry index, and the key is only ever compared, never dereferenced. Dead keys are dropped by prune_dead_element_shape_owners on the same collection hook that prunes ARRAY_NAMED_PROPS (footprint only).

Where it is maintained, and why that one place is enough

The single element-store hook hangs off gc::layout_note_slot. That is the one funnel both the runtime's element-store helpers (note_array_slot, note_array_slot_layout_only, store_array_slot, note_array_hole_fill_slot) and codegen's inline element stores already pass through — array_store_needs_layout_note elides the note only when the array is statically proven numeric and pointer-free, which an element-shape array can never be. The call sits ahead of layout_note_slot's GC_LAYOUT_UNKNOWN early return (an all-pointer array is marked unknown on its first generic write) and costs a GC_TYPE_ARRAY compare plus a bit test on the header word the next line reads anyway. An array without the bit exits on one predictable-not-taken branch.

verified_len is the structural half, and it is what keeps the matrix small enough to enumerate: the record pins the length it was verified against, and a query requires the current length to still match. Every operation that changes length outside the store funnels — pop, shift, splice, length = n, sparse extend, codegen's inline append — is therefore invalidated automatically on the next query, with no call site of its own.

Invalidation matrix as implemented

event outcome mechanism
first shaped push into an empty array set layout_note_slot hook, index == 0 && length == 0
matching push / matching in-bounds overwrite keep (append extends verified_len) same hook
array built outside the funnels (inline literal, JSON.parse, map) set on demand ensure_element_shape rescan — self-healing, like 4a
push / store of a different class, or of a non-pointer clear same hook
delete arr[i] clear funnels a TAG_HOLE store through the same hook
arr.length = n (truncate and extend) clear TAG_HOLE stores + verified_len mismatch
pop, and any length change behind the runtime's back clear verified_len != length, fail-closed
shift/unshift/splice/fill/copyWithin/reverse/sort clear rebuild_array_layout — the post-hoc funnel they all already use
a numeric layout being declared (js_array_fill_f64_*, js_array_alloc) clear set_array_numeric_layout; the two invariants are mutually exclusive
C.prototype.m = …, Object.defineProperty(C.prototype, …) clear, globally generation bump in invalidate_class_prototype_fast_guards, the existing single latch all three prototype-write entries funnel through
Object.setPrototypeOf(o, …) / __proto__ = clear, globally generation bump in object_set_static_prototype_impl, inside the instance_override gate (its quiet sibling fires on every new F())
copying minor / old-gen defrag / growth forwarding keep bit rides _reserved; record moved by transfer_element_shape
empty array declined a vacuous proof would licence reads that cannot happen
array with per-index descriptors declined same stand-down the raw-f64 element accessors make

An element object gaining an expando is deliberately not an invalidation: class_id is unchanged, so "every element is an object of class C" still holds. Layout stability within a class is the existing GC_OBJ_TYPED_LAYOUT_INTACT bit's job, and a consumer composes the two.

Versioning

Three AtomicU64 counters, all starting at 1 (the crate's convention for invalidation counters — PROP_PLAN_EPOCH, PERRY_IC_EPOCH — not a per-thread Cell, since the class registry a generation bump answers to is process-wide):

  • js_array_element_shape_epoch() — bumped on every clear or invalidation, of any array. This is the word a hoisted guard re-reads. One relaxed load, one compare, no side-table probe, no rescan. Deliberately coarse: an unrelated array's clear deopts a running loop, which errs in the safe direction.
  • CLASS_SHAPE_GENERATION — bumped only by prototype surgery. Records carry the generation they were installed under and fail their next query, so one prototype write retires every outstanding record at O(1) without enumerating arrays.
  • ELEMENT_SHAPE_PROOF_SEQ — not an invalidation signal but an identity source. Every established proof takes the next value and carries it unchanged while it is kept or extended, so a consumer that pinned (class_id, epoch) can never be fooled by the same array being re-proven after a break, nor by a different array being established at a recycled address. Identities are never read back out of the table, which is what makes a record that outlives its array harmless rather than a donor (review finding 2).

Design note: how #5093's versioned-loop clone consumes this

lower_class_field_versioned_for (crates/perry-codegen/src/stmt/loops.rs) already has exactly the skeleton the element-shape consumer needs, and the extension is mechanical:

  1. Match. The array analogue of match_class_field_versioned_loop: for (let i = 0; i < A.length; i++) { … A[i].f … } over a region-local A. collectors/ptr_shape_elements.rs's E1–E5 already computes this candidate set (empty-literal provenance, push-only mutation, in-bounds induction-variable reads, no IndexSet / splice / length write), so the matcher reuses those facts rather than re-deriving them.

  2. Preheader guard. Where the class-field version emits emit_class_field_loop_preheader_check (class id + keys_array + intact bit), the array version emits:

    %cid = call i32 @js_array_ensure_element_shape(i64 %arr_handle)   ; O(n) once, O(1) after
    %ok  = icmp eq i32 %cid, <expected_class_id>
    br i1 %ok, label %fast.preheader, label %slow.preheader
    

    <expected_class_id> is the compile-time class the E2 producers push, already available as a class_keys_global. The ensure call must be placed before the array head is reloaded into the fast clone's cached register, for the same reason the class-field version keeps "receiver load → check → loop entry" call-free: the pointer the check validated has to be the pointer the clone uses.

  3. Clone + entry condition. Reuse lower_for_after_init_with_i32_bound for the fast clone and lower_for_after_init for the slow one, and keep the existing fast_clone_call_free verification verbatim. That check is what makes an epoch re-read unnecessary in this first consumer: a call-free clone cannot allocate, cannot collect, and — because E3 admits no mutator on A — cannot retire the invariant, so the preheader guard holds for the whole trip count. js_array_element_shape_epoch() is the back-edge re-check for the later, weaker form whose clone is allowed to contain calls: reload it and side-exit to slow.preheader on change. js_array_element_shape_check(arr, class_id, epoch) is the same test pinned to one array's proof identity, for a consumer that would rather not deopt on an unrelated array's clear.

  4. Body. Inside the fast clone, A[i] yields a value whose class is <expected_class_id> by the guard, so it is rule-1 new C(...)-strength provenance for ptr_shape.rselement_read_class already issues exactly that fact — and every r.field lowers guard-free through 3b's existing proven-local machinery. Nothing new is needed on the read side.

  5. Deopt. Guard false → slow.preheader, the unmodified clone, exactly as today. There is no state to unwind: the fast clone is entered only from the preheader.

Group integrity, the numeric_fields stand-down and the rooting contract are all unchanged from ptr_shape_elements.rs — this adds a runtime witness for a fact that pass could previously only assert statically.

Verification

29 new tests — 27 in array/element_shape_tests.rs covering every row of the matrix above plus the two lifecycle hooks (forget_element_shape, prune_dead_element_shape_owners), and 2 in gc/tests/layout_trace/element_shape.rs for GC survival. The GC pair asserts its subject was live before it asserts a verdict — assert_copied_minor_trace(…) plus "the array must actually have moved" — so a run with zero copying minors cannot pass. One of them additionally pushes after the move and asserts the proof both extends on a match and retires on a mismatch, which is what proves the record is reachable at the new key rather than merely readable once. Both files serialize on a shared poison-tolerant lock taken ahead of any state-restoring guard (review finding 1).

cargo test -p perry-runtime element_shape run 1 run 2 run 3
default parallelism 29 passed 29 passed 29 passed
--test-threads=1 29 passed 29 passed 29 passed

Full runtime suite, 6 runs at default parallelism: 4 green, 2 red — both reds promise::keyed_table::tests::settling_many_keys_is_not_quadratic, never an element-shape test. Pre-existing: pristine main (f05ae3b), same host, same 6-run protocol, is also 4 green / 2 red, once on that same wall-clock scaling assertion and once on tui::tree::tests::register_increments_handle. Same rate, same tests, neither in a module this PR touches.

  • python3 scripts/raw_handle_debt.py — 999 (baseline 999); the new module has zero bare reads and takes no raw pointer across an allocation point.
  • python3 scripts/addr_class_inventory.py — passed; element headers are read through addr_class::try_read_gc_header, which also rejects the small-buffer slab addresses that pass a bare plausibility check but carry no GcHeader.
  • bash scripts/check_file_size.sh — OK. cargo fmt --all -- --check — clean.
  • No emitted-code change, checked both ways. git diff --name-only main...HEAD touches crates/perry-runtime only, so perry-codegen / perry-hir / perry-transform / perry are byte-identical to main and the emitted IR is unchanged by construction; and a --trace llvm probe over an array-of-new C(…) workload (build-by-push, indexed field reads, an inline object-literal array) emits 0 occurrences of element_shape in its .ll and links 0 such symbols into the binary. The same probe emits 6 js_gc_note_slot_layout calls, which is the funnel this PR's maintenance hook rides — the design claim is exercised, not assumed. No keepalive-anchors #[used] statics were added: that feature is on by default, so an anchor would pin five uncalled functions into every shipped binary, the exact dead-strip defeat the hello-size campaign traced its regression to. The consumer PR anchors whichever symbol it emits.

Refs #7480.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@proggeramlug, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 5 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 349475c5-152c-47c0-8cb0-85bb04388709

📥 Commits

Reviewing files that changed from the base of the PR and between a1fcdeb and 4a5f9a1.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (14)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/7496-element-shape-invariant.md
  • crates/perry-runtime/src/array/element_shape.rs
  • crates/perry-runtime/src/array/element_shape_tests.rs
  • crates/perry-runtime/src/array/header.rs
  • crates/perry-runtime/src/array/mod.rs
  • crates/perry-runtime/src/gc/dead_owner.rs
  • crates/perry-runtime/src/gc/layout.rs
  • crates/perry-runtime/src/gc/tests/layout_trace.rs
  • crates/perry-runtime/src/gc/tests/layout_trace/element_shape.rs
  • crates/perry-runtime/src/gc/types.rs
  • crates/perry-runtime/src/object/class_registry/prototype_methods.rs
  • crates/perry-runtime/src/object/prototype_chain.rs
📝 Walkthrough

Walkthrough

The runtime adds per-array homogeneous object-shape proofs. It maintains proofs across stores, layout changes, prototype invalidation, allocation cleanup, and copying garbage collection. New C APIs expose proof establishment, class identity, versions, epochs, and guard checks.

Changes

Array element-shape invariant

Layer / File(s) Summary
Shape records and public contracts
crates/perry-runtime/src/array/element_shape.rs, crates/perry-runtime/src/gc/types.rs, crates/perry-runtime/src/array/mod.rs, crates/perry-runtime/src/array/header.rs
Defines the header flag, address-keyed records, proof metadata, module wiring, public APIs, and sibling access to array headers and storage.
Proof establishment and validation
crates/perry-runtime/src/array/element_shape.rs
Validates pointer values and descriptors, establishes proofs by scanning arrays, tracks epochs and generations, and exposes C ABI queries and guard checks.
Mutation and GC lifecycle
crates/perry-runtime/src/array/element_shape.rs, crates/perry-runtime/src/array/header.rs, crates/perry-runtime/src/gc/layout.rs, crates/perry-runtime/src/gc/dead_owner.rs, crates/perry-runtime/src/object/...
Updates or clears proofs during element stores, layout rebuilds, prototype changes, allocation cleanup, dead-owner pruning, and array relocation.
Invariant and relocation tests
crates/perry-runtime/src/array/element_shape_tests.rs, crates/perry-runtime/src/gc/tests/layout_trace/..., changelog.d/7496-element-shape-invariant.md
Tests establishment, mutation invalidation, epoch behavior, FFI checks, numeric layouts, prototype changes, relocation, and copying-minor GC behavior. The changelog records the invariant and test coverage.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly identifies the main runtime change: a per-array homogeneous element-shape invariant.
Description check ✅ Passed The description clearly covers the summary, implementation, related issue, tests, scope, and verification, despite not using every template heading.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/7480-element-shape-invariant

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
crates/perry-runtime/src/array/element_shape_tests.rs (1)

355-368: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for forget_element_shape and prune_dead_element_shape_owners.

The suite covers the store, rebuild, numeric-layout, prototype, and transfer paths. Two lifecycle hooks have no test:

  • forget_element_shape, reached from layout_clear_for_ptr on object death and address recycling. It is the only path that removes the record rather than only clearing the bit. It is what prevents a recycled address from inheriting a stale per-array epoch.
  • prune_dead_element_shape_owners, reached from the dead-owner fan-out in crates/perry-runtime/src/gc/dead_owner.rs line 220.

A test for each would assert that test_element_shape_record_exists returns false after the hook runs.

Based on learnings from PR 7227: for GC-rooted thread-local cache scanners and their reset helpers, add independent tests covering the cleanup hooks rather than assuming shared teardown exists.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/array/element_shape_tests.rs` around lines 355 -
368, Add independent tests in the element-shape test suite for both lifecycle
hooks: invoke forget_element_shape through the layout_clear_for_ptr/object-death
or address-recycling path, and invoke prune_dead_element_shape_owners through
the dead-owner cleanup path. In each test, create an element-shape record first,
run the hook, and assert test_element_shape_record_exists returns false; do not
rely on shared teardown to provide coverage.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-runtime/src/array/element_shape_tests.rs`:
- Around line 285-299: Introduce one shared serialization guard in a module
reachable by both test suites, protecting all tests that read or mutate
ELEMENT_SHAPE_EPOCH or CLASS_SHAPE_GENERATION. Apply it to the test
the_global_epoch_advances_on_a_clear_and_holds_still_otherwise and
prototype_surgery_retires_every_outstanding_proof, plus proof-survival tests
such as matching_pushes_extend_the_verified_prefix in
crates/perry-runtime/src/array/element_shape_tests.rs; in
crates/perry-runtime/src/gc/tests/layout_trace/element_shape.rs, acquire the
same guard in proven_array before its first push and hold it throughout both
copying-minor tests. Ensure the guard serializes both files’ tests against each
other.

In `@crates/perry-runtime/src/array/element_shape.rs`:
- Around line 455-484: Update transfer_element_shape so that when the
destination fails closed because moved is true but had_bit is false, it also
removes the record stored at new_user. Preserve the existing bit-clearing
behavior and ensure subsequent installs at new_user cannot inherit the
transferred array’s epoch.

---

Nitpick comments:
In `@crates/perry-runtime/src/array/element_shape_tests.rs`:
- Around line 355-368: Add independent tests in the element-shape test suite for
both lifecycle hooks: invoke forget_element_shape through the
layout_clear_for_ptr/object-death or address-recycling path, and invoke
prune_dead_element_shape_owners through the dead-owner cleanup path. In each
test, create an element-shape record first, run the hook, and assert
test_element_shape_record_exists returns false; do not rely on shared teardown
to provide coverage.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d41a210d-1722-4e75-a11f-37a873178c80

📥 Commits

Reviewing files that changed from the base of the PR and between a1fcdeb and a37ec75.

📒 Files selected for processing (12)
  • changelog.d/7496-element-shape-invariant.md
  • crates/perry-runtime/src/array/element_shape.rs
  • crates/perry-runtime/src/array/element_shape_tests.rs
  • crates/perry-runtime/src/array/header.rs
  • crates/perry-runtime/src/array/mod.rs
  • crates/perry-runtime/src/gc/dead_owner.rs
  • crates/perry-runtime/src/gc/layout.rs
  • crates/perry-runtime/src/gc/tests/layout_trace.rs
  • crates/perry-runtime/src/gc/tests/layout_trace/element_shape.rs
  • crates/perry-runtime/src/gc/types.rs
  • crates/perry-runtime/src/object/class_registry/prototype_methods.rs
  • crates/perry-runtime/src/object/prototype_chain.rs

Comment thread crates/perry-runtime/src/array/element_shape_tests.rs
Comment thread crates/perry-runtime/src/array/element_shape.rs
proggeramlug pushed a commit that referenced this pull request Aug 6, 2026
…heritance hole

Three review findings from #7496:

1. The two process-wide counters made the tests order-dependent — the
   `ELEMENT_SHAPES` table is thread-local, but `ELEMENT_SHAPE_EPOCH` and
   `CLASS_SHAPE_GENERATION` are not, so one test's clear was another's
   observation. Both test files now take a shared, poison-tolerant
   `ELEMENT_SHAPE_TEST_LOCK` as their FIRST statement, so unwind order drops
   the state-restoring guards while the lock is still held.

2. `transfer_element_shape` moved the record before reading `had_bit`, so a
   fail-closed transfer could leave an ORPHAN record at the destination for a
   later establishment to inherit an identity from — silently continuing a
   different array's proof identity, which is the versioning a consumer is
   supposed to guard on. Fixed at the root: proof identities now come from a
   monotone `ELEMENT_SHAPE_PROOF_SEQ` rather than being read back from
   whatever record sits at the address, establishing is separated from
   extending, a clear drops the record outright, and the fail-closed transfer
   leaves nothing behind.

3. Added coverage for the two lifecycle hooks that stop a recycled address
   inheriting a stale identity: `forget_element_shape` (object death) and
   `prune_dead_element_shape_owners` (collection hook).
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Done in 1ae6551 — and agreed that these are the same failure mode as the previous finding, not a separate nitpick: forget_element_shape and prune_dead_element_shape_owners are precisely what stop a recycled address carrying a stale record.

#[test]
fn forget_element_shape_removes_the_record_on_address_recycling() {
    let _serialized = test_serialize();
    let arr = built_from_pushes(CLASS_A, 3);
    let retired = proof(arr).expect("proven").epoch;
    assert!(test_element_shape_record_exists(arr as usize));

    forget_element_shape(arr as usize);          // gc::layout_clear_for_ptr

    assert!(!test_element_shape_record_exists(arr as usize));
    unsafe { assert!(!test_element_shape_bit_set(arr)) };
    assert!(proof(arr).is_none());

    let reused = unsafe { ensure_element_shape(arr) }.expect("still homogeneous");
    assert_ne!(reused.epoch, retired);
}

#[test]
fn pruning_dead_owners_removes_their_records() {
    let _serialized = test_serialize();
    let dead = built_from_pushes(CLASS_A, 2);
    let live = built_from_pushes(CLASS_A, 2);
    let dead_key = dead as usize;

    prune_dead_element_shape_owners(&|owner| owner == dead_key);   // gc::dead_owner::fan_out

    assert!(!test_element_shape_record_exists(dead as usize));
    assert!(test_element_shape_record_exists(live as usize));
    assert!(proof(live).is_some());
}

Both assert the recycling consequence as well as the removal, so they fail if the hook stops removing or if the identity discipline regresses. The prune test also pins the other direction — a live owner's record and proof must survive — so a prune that over-collects cannot pass either.

proggeramlug pushed a commit that referenced this pull request Aug 6, 2026
Ralph Küpper added 8 commits August 6, 2026 10:35
The shared prerequisite both #7480 routes need: an O(1), array-level answer
to "are all the elements the same shape?". Invariant layer only — no
consumer, no emitted-code change.

Storage mirrors Phase 4a's dense bit: a `GcHeader._reserved` bit (11, shared
with the object-only `OBJ_FLAG_HAS_DESCRIPTORS`) that the copying collector
carries for free, plus an address-keyed record moved by `layout_transfer`
exactly as `TYPED_LAYOUTS` is. The bit is the authority, so a stale record
at a recycled address is unreachable and a missing record fails closed.

Maintained from `gc::layout_note_slot` — the one funnel both the runtime's
element-store helpers and codegen's inline element stores already reach,
since `array_store_needs_layout_note` elides the note only for arrays
statically proven numeric and pointer-free, which an element-shape array
can never be.

The record holds four integers and no heap pointer, so it is deliberately
not a `gc_register_mutable_root_scanner` entry; dead keys are dropped on the
same collection hook that prunes `ARRAY_NAMED_PROPS`.
…ssor

`try_read_gc_header` additionally rejects small-buffer slab addresses,
which pass `is_plausible_heap_addr` but carry no GcHeader at all — a bare
`addr - GC_HEADER_SIZE` read there sees the previous slab entry's data
bytes as a header.
`keepalive-anchors` is a DEFAULT feature, so a `#[used]` static would pin
five uncalled functions into every shipped binary — the dead-strip defeat
the hello-size campaign traced its regression to. The consumer PR anchors
whichever symbol it emits.
…heritance hole

Three review findings from #7496:

1. The two process-wide counters made the tests order-dependent — the
   `ELEMENT_SHAPES` table is thread-local, but `ELEMENT_SHAPE_EPOCH` and
   `CLASS_SHAPE_GENERATION` are not, so one test's clear was another's
   observation. Both test files now take a shared, poison-tolerant
   `ELEMENT_SHAPE_TEST_LOCK` as their FIRST statement, so unwind order drops
   the state-restoring guards while the lock is still held.

2. `transfer_element_shape` moved the record before reading `had_bit`, so a
   fail-closed transfer could leave an ORPHAN record at the destination for a
   later establishment to inherit an identity from — silently continuing a
   different array's proof identity, which is the versioning a consumer is
   supposed to guard on. Fixed at the root: proof identities now come from a
   monotone `ELEMENT_SHAPE_PROOF_SEQ` rather than being read back from
   whatever record sits at the address, establishing is separated from
   extending, a clear drops the record outright, and the fail-closed transfer
   leaves nothing behind.

3. Added coverage for the two lifecycle hooks that stop a recycled address
   inheriting a stale identity: `forget_element_shape` (object death) and
   `prune_dead_element_shape_owners` (collection hook).
@proggeramlug
proggeramlug force-pushed the perf/7480-element-shape-invariant branch from 4cf2f94 to 4a5f9a1 Compare August 6, 2026 08:35
@proggeramlug
proggeramlug merged commit eb8a9ac into main Aug 6, 2026
6 of 10 checks passed
@proggeramlug
proggeramlug deleted the perf/7480-element-shape-invariant branch August 6, 2026 08:35
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