Skip to content

perf(gc): make a repeat typed-shape construction a header bit-set (#7510) - #7535

Merged
proggeramlug merged 9 commits into
mainfrom
perf/7510-construction-shape-install
Aug 6, 2026
Merged

perf(gc): make a repeat typed-shape construction a header bit-set (#7510)#7535
proggeramlug merged 9 commits into
mainfrom
perf/7510-construction-shape-install

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Item 1 of #7510 — "construction should become a header bit-set, not a side-table insert" — plus a finding on the #7512 residual that was folded into it. #7525 landed the prerequisite (the per-object side tables are now provably empty on a monomorphic workload) and named this as the next lever.

Result

bench (20M constructions) main this branch speedup
churn_alloc{v, w} object literal 1.93 s 1.77 s 1.090×
churn_alloc — independent repeat 1.99 s 1.83 s 1.087×
push_clsnew Node(v, w) 2.31 s 2.10 s 1.100×
poly_shapes — 16 shapes cycled, the memo's worst case 2.89 s 2.73 s 1.059×

Interleaved best-of-9 user CPU on the pinned quiet host (perry-macos.local, M1/8 cores, load 1.6–2.4 across the runs). Arms alternate every round, so drift in machine state hits both equally.

Hit rate, counted rather than reasoned about. An lldb breakpoint with an auto-continue command on shape_install_shared, over the identical program:

binary constructions shape_install_shared entries
main 100,000 100,000
this branch 100,000 1
this branch 20,000,000 1

#7525's first commit shipped a fast path that fired once in 40 million calls and was believed anyway. This is the same instrument pointed the other way.

The profile agrees: on the same symbolicated 2 s sample, shape_install_shared drops from 33 samples to below the reporting threshold (< 7), and js_gc_init_typed_shape_layout from 133 to 106.

No GC regression, in the strongest available form. PERRY_GC_DIAG=1 output is byte-identical between the arms on all three benchmarks — 315 lines for churn_alloc, 317 for push_cls, 311 for poly_shapes; every cycle's copied_objects / copied_bytes / promoted_* / freed_bytes and every step decision the same. Independently, the 12 gc_ratchet probes measured back-to-back under both runtimes agree on 108 of 108 semantic metrics (retention + GC accounting), 0 differing. That is what you would expect: nothing here changes what is allocated or what the collector decides, only how the layout metadata gets published.

Reproducing that locally: gc_ratchet.py check against the pinned baseline fails on my machine with 29 regression rows — and fails with the same 29 rows for the unmodified origin/main runtime. The pinned artifact was captured on the dedicated bench host at perry 0.5.1280; comparing anything to it from a loaded laptop at 0.5.1300 measures baseline drift, not this change. The main-vs-branch diff above is the control that isolates it, and CI's gc-ratchet job is the gate.

This does not meet #7510's headline ≥1.5× bar, and the ticket already records why: the profile it was filed from is out of date, and after #7469/#7474/#7486/#7487/#7501/#7525 the whole gc::layout family is ~11% of churn_alloc, not 33.6%. Removing the shape-install half of it entirely could not have delivered 1.5×. #7510 stays open for items 2 and 3 (layout_forget_object is now 1.4%, layout_note_slot 7.5%) and for the #7512 residual discussed at the bottom.

What was costing 11%

Symbolicated leaf profile of churn_alloc (20M {v, w} object literals; pinned quiet host at load 1.8; 1488 samples):

symbol samples share
js_gc_init_typed_shape_layout 133 8.9%
shape_install_shared 33 2.2%

Since #6893 the descriptor these two install is per-shape, not per-object: every same-shape object shares one canonical TypedLayoutDescriptor in SHAPE_LAYOUTS, keyed by the shared keys_array. The only per-object work left is two header bits — GC_OBJ_TYPED_LAYOUT_INTACT and GC_LAYOUT_POINTER_FREE/GC_LAYOUT_SIDE_MASK.

The call did not know that. For the 20-millionth {v, w} literal it still

  • built a TypedLayoutDescriptor — 72 bytes, two 32-byte Vec-carrying enums, one of them cloned, all of it dropped on the way out,
  • took a RefCell borrow on the thread-local SHAPE_LAYOUTS,
  • hashed the keys_array pointer and probed the map,
  • compared the freshly built descriptor field-by-field against the one already stored,

to reach the conclusion the first construction had already reached. shape_install_shared's hit arm — Some(Some(existing)) if existing == descriptor — writes nothing at all.

The design

gc/shape_install.rs memoises exactly that map answer, in a thread-local direct-mapped table:

SHAPE_LAYOUTS[keys] holds Some(D), where D is the descriptor that (slot_count, raw_words, pointer_words) describes.

On a hit the construction is: validate, set two header bits, done.

The memo decides nothing about the object. Everything the header declaration rests on is still re-derived per construction, ahead of the memo:

  • field_count == slot_count,
  • raw-f64 / pointer mask disjointness,
  • the per-slot loop asserting each raw-f64 slot holds a plain double and no pointer-bearing slot sits outside the pointer mask.

And POINTER_FREE vs SIDE_MASK is recomputed from the pointer mask, never read back from the memo — the entry carries no header state at all. That split is the soundness bar: a wrong POINTER_FREE is a use-after-free factory (heap_payload_slot_selection short-circuits on it and skips the whole payload without consulting any mask), so that decision must not depend on a cache. A stale entry can only cost work.

While reworking those checks they stopped building a LayoutSlotMask at all — they read the caller's mask words directly. LayoutSlotMask is a 32-byte enum with a Vec arm, so two of them meant drop glue on six early-return paths and, for any shape wider than 64 slots, two heap allocations per construction. LayoutSlotMask::intersects survives as the test-only reference implementation that mask_word_helpers_agree_with_layout_slot_mask pins the three word helpers against, across trailing-zero words, bits past slot_count, and slots past the end of the array.

Self-healing

An entry is falsified by exactly one transition: SHAPE_LAYOUTS[keys] ceasing to be Some(D). shape_install_shared is the only writer of that map and its only such transition is the ambiguity poison (Some(Some(_)) => insert(None), two live layouts sharing one key set). That branch drops the whole table. Entries are never removed from SHAPE_LAYOUTS and never overwritten with a different Some, so there is no other way to go stale.

Everything else already degrades to a miss:

  • the keys_array moves — live objects report the new address, nothing matches, the slow path re-installs;
  • the old address is recycled by another shape — its constructions arrive with different mask globals, so they miss and poison; if they arrive with the same mask globals and slot count the entry's claim is still true of them, because SHAPE_LAYOUTS is keyed by that same address and its entries are never pruned;
  • PERRY_SHAPE_LAYOUT_KEYED=0record is only reachable from a successful shape_install_shared, which that knob gates, so the table stays empty and every lookup misses.

This table is not a GC root, and the module says so explicitly, because a runtime-side cache of a raw heap pointer usually is one and the static root-dominance checker cannot see it. keys is only ever compared as an integer; it is never dereferenced, never handed to the mutator, never used to keep anything alive.

Two sizing decisions, both measured

The slot index mixes the two mask-global addresses in with the shape. Two object-literal sites with the same key names share one keys_array but get separate private unnamed_addr constant mask globals unless LLVM's constant merger folds them; keying on the shape alone would put both in one entry and let them evict each other every iteration of a loop that builds both. Any index is correct — the full tuple is compared on the way out — so this is purely about not colliding.

The table is 32 entries, not 8. A direct-mapped table cycled round-robin by more shapes than it has slots hits zero percent of the time: each entry is evicted by its partner before it is read again, so it pays the probe and gets nothing. A 16-shape churn loop against 8 slots measured a reproducible 0.993× — the one regression this change had. At 32 slots the same loop fits and turns into 1.059× (and 1.070× against the 8-slot build directly), with the monomorphic numbers unchanged. The whole table is 1 KiB of const-initialised thread-local, so a program with one shape pays for a page it never touches.

Testing

gc/tests/layout_trace/shape_install_memo.rs plus gc/shape_install.rs's own unit tests, 9 in total.

It firesmemo_fires_on_every_repeat_construction_of_one_shape counts: 64 instances of one shape must produce exactly 1 install and 63 hits. This is the assertion #7525 did not have, and its absence is why that PR's first commit shipped a fast path that hit once in 40 million calls.

It decides nothinga_memo_hit_produces_the_same_header_state_as_the_install compares the _reserved layout bits of an object published by the memo against one published by the install; a_contradicting_field_is_refused_even_with_the_memo_warm builds an instance whose raw-f64-declared slot holds a heap string and asserts it is refused with the table warm, that the hit counter did not move (validation ran first), and that both of its heap fields stay enumerable under the conservative fallback.

It healsambiguity_poison_invalidates_the_memo installs a shape, warms the memo, poisons that shape by installing a different layout under the same key names, and asserts the next construction of the original layout misses and lands correctly on the per-object path.

The GC witnessmemo_installed_objects_survive_a_copying_minor_with_their_children builds six instances of a { n: number; s: string } shape, asserts 5 of the 6 were published by the memo (subject-was-live, not merely "nothing threw"), forces an evacuating minor, and asserts the cycle actually moved ≥ 12 objects, that every instance relocated, that its raw-f64 slot survived verbatim, and that its string child relocated with its slot rewritten and its bytes intact.

The sabotage arma_pointer_free_declaration_on_this_shape_strands_the_child is permanent: it publishes this exact shape POINTER_FREE by hand and asserts the collector then enumerates zero payload children. That is the use-after-free the witness is green against, and it is why the memo stores no header state.

Sabotage verified by hand, three ways, each caught:

sabotage applied what failed
fast path reads the state out of the memo (set_layout_state(POINTER_FREE) unconditionally) the witness, at instance 1 must expose its one pointer field to the collector — left 0, right 1
memo hit short-circuits the per-slot validation an instance that contradicts its shape's mask must not be declared intact
poison branch does not call invalidate() the poison must have dropped the memo entry

Beyond the unit tests:

  • cargo test -p perry-runtime1771 passed, 0 failed.
  • 20 object/class/shape-heavy programs from test-files/ compiled and run under both arms: byte-identical output, 20/20. Re-run against the Node oracle on the final build: 18 match byte-for-byte, 1 is skipped (node cannot run it), and 1 is test_gap_2159_defineproperty_class_prototype, an entry that is already in test-parity/known_failures.json as a standing gap pre-dating v0.5.1205.
  • The same 20 programs under PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=64 — the arm that faults on a stale from-space deref — produce output identical to the plain runs, 20/20. Verified not vacuous: PERRY_GC_DIAG=1 prints [gc-fromspace-protect] retired_set=… five times on those runs, so copying minors really did retire and quarantine from-space pages.
  • The perf(GC): make per-object layout O(1)-loadable — kill per-operation thread-local layout tracking (umbrella: method_calls/array-downgrade/object-property) #5094 access-path benchmarks (09_method_calls, bench_object_property, bench_numeric_array_downgrade) show no change — they run in 10–120 ms, which is below what this harness can resolve, so this is "no regression", not a measurement.
  • raw_handle_debt.py 999 (baseline 999); gc_store_site_inventory.py, addr_class_inventory.py, check_file_size.sh all clean; cargo fmt --all -- --check clean.

The #7512 residual: why the reorder still does not work

The comment on #7510 folds in #7512's remaining half — js_gc_init_typed_shape_layout is emitted after the constructor call, so no raw-f64 class-field store inside a constructor body can pass its guard. This PR does not move it, and the reason is sharper than "the slots hold undefined":

undefined is 0x7FFC_…, and layout_raw_f64_bits rejects any tag in [SHORT_STRING_TAG = 0x7FF9, STRING_TAG = 0x7FFF], so a pre-ctor install fails validation today. Relaxing that — accepting undefined in a raw-f64 slot at install time — would be safe for the collector: undefined is not pointer-bearing, so a skipped raw-f64 slot strands nothing.

It is not safe for readers. class_field_fast_contract states the contract the codegen-inlined path relies on: "the codegen-inlined fast path concludes slot K is raw-f64 purely from the per-object intact bit … the inline path could never read a NaN-boxed value as a raw double". Publishing the layout before the constructor runs makes exactly that false for every declared-but-not-yet-assigned field, and class C { v: number; constructor() { console.log(this.v) } } must still print undefined.

So it is not a runtime relaxation of the install-time check. It needs a codegen-side definite-assignment proof — every raw-f64-masked field written before any read of it — or a two-stage install that declares slots raw-f64 as the prologue fills them. Either is a separate change with its own soundness surface, so it stays on #7510.

Refs #7510, #7512, #7525, #6893, #5094.

Summary by CodeRabbit

  • Performance
    • Improved typed-shape construction by reusing recently installed layout information.
    • Added safe fallback behavior when cached information is unavailable or invalidated.
  • Bug Fixes
    • Strengthened validation of object fields against their declared layouts.
    • Ensured layout changes invalidate cached installation data.
    • Preserved correct behavior during garbage collection and pointer tracing.
  • Tests
    • Added coverage for reuse, invalidation, layout validation, pointer tracing, and garbage-collection safety.
  • Chores
    • Updated the application version to 0.5.1303.

@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: a50eec8e-5c7c-4732-b707-9fb28644557c

📥 Commits

Reviewing files that changed from the base of the PR and between 83dac78 and ab671fd.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/7535-construction-shape-install.md
  • crates/perry-runtime/src/gc/layout.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/shape_install.rs
  • crates/perry-runtime/src/gc/tests/layout_trace.rs
  • crates/perry-runtime/src/gc/tests/layout_trace/shape_install_memo.rs
  • crates/perry-runtime/src/tls_hot.rs
📝 Walkthrough

Walkthrough

This change adds a thread-local memo for validated typed-shape installations. It uses raw mask words, invalidates entries when layouts become ambiguous, integrates with hot TLS storage, and adds tests for validation, copying GC, and pointer tracing.

Changes

Typed shape installation

Layer / File(s) Summary
Construction memo core
crates/perry-runtime/src/gc/shape_install.rs
Adds raw mask predicates, packed direct-mapped keys, thread-local storage, lookup and record operations, invalidation, and focused memo tests.
Layout installation integration
crates/perry-runtime/src/gc/layout.rs, crates/perry-runtime/src/gc/mod.rs, crates/perry-runtime/src/tls_hot.rs
Uses the memo during typed-layout initialization, validates raw mask words, invalidates ambiguous layouts, records shared installations, and exposes the memo through hot TLS.
Behavioral validation and documented constraints
crates/perry-runtime/src/gc/tests/layout_trace.rs, crates/perry-runtime/src/gc/tests/layout_trace/shape_install_memo.rs, changelog.d/7535-construction-shape-install.md, CLAUDE.md, Cargo.toml
Adds tests for repeat hits, validation bypasses, invalidation, copying GC, and pointer tracing. Documents memo behavior, records the unresolved raw-f64 constructor limitation, and updates the package version.

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

Sequence Diagram(s)

sequenceDiagram
  participant Constructor
  participant LayoutInstaller
  participant ShapeInstallMemo
  participant SHAPE_LAYOUTS
  participant CopyingGC
  Constructor->>LayoutInstaller: validate object fields and mask words
  LayoutInstaller->>ShapeInstallMemo: lookup shape-install key
  alt memo hit
    ShapeInstallMemo-->>LayoutInstaller: cached layout state
    LayoutInstaller-->>Constructor: write header state
  else memo miss
    LayoutInstaller->>SHAPE_LAYOUTS: install shared or per-object layout
    SHAPE_LAYOUTS-->>LayoutInstaller: layout descriptor
    LayoutInstaller->>ShapeInstallMemo: record confirmed shared installation
    LayoutInstaller-->>Constructor: write layout state
  end
  Constructor->>CopyingGC: expose installed object
  CopyingGC-->>Constructor: relocate object and pointer children
Loading

Possibly related PRs

  • PerryTS/perry#6939: Adds shape-keyed typed-layout machinery that this memo extends during construction.
  • PerryTS/perry#7525: Modifies shared-shape layout installation and descriptor handling used by this memo.
  • PerryTS/perry#7532: Modifies the typed-shape layout initialization paths used by this memo.

Suggested reviewers: jdalton

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: optimizing repeated typed-shape construction through header bit-setting.
Description check ✅ Passed The description explains the change, rationale, measurements, tests, related issues, limitations, and verification results in sufficient detail.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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 💡 1
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch perf/7510-construction-shape-install
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/7510-construction-shape-install

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

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@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: 1

🤖 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 `@changelog.d/7535-construction-shape-install.md`:
- Around line 20-21: Update the stale entry count in the description of the
gc/shape_install memoisation table from 8 to 32, keeping the references to the
thread-local direct-mapped table and its existing behavior unchanged.
🪄 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: bc3f52f3-c9cf-4b38-a6f8-bf6fc1261d9c

📥 Commits

Reviewing files that changed from the base of the PR and between 5c53f06 and 4ec58bc.

📒 Files selected for processing (7)
  • changelog.d/7535-construction-shape-install.md
  • crates/perry-runtime/src/gc/layout.rs
  • crates/perry-runtime/src/gc/mod.rs
  • crates/perry-runtime/src/gc/shape_install.rs
  • crates/perry-runtime/src/gc/tests/layout_trace.rs
  • crates/perry-runtime/src/gc/tests/layout_trace/shape_install_memo.rs
  • crates/perry-runtime/src/tls_hot.rs

Comment thread changelog.d/7535-construction-shape-install.md Outdated

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
changelog.d/7535-construction-shape-install.md (1)

83-93: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the issue reference in the residual note.

Line [83] identifies the unresolved constructor-ordering issue as #7512, but line [93] says it remains on #7510. The PR objective also identifies this issue as #7512. Update line [93] to reference the correct tracking issue.

🤖 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 `@changelog.d/7535-construction-shape-install.md` around lines 83 - 93, Update
the residual note so its closing tracking reference uses `#7512` instead of `#7510`,
keeping the surrounding explanation and issue references unchanged.
🤖 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.

Outside diff comments:
In `@changelog.d/7535-construction-shape-install.md`:
- Around line 83-93: Update the residual note so its closing tracking reference
uses `#7512` instead of `#7510`, keeping the surrounding explanation and issue
references unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c4c183fe-a45a-49ce-831b-db54773a1584

📥 Commits

Reviewing files that changed from the base of the PR and between 4ec58bc and 4cbfc9f.

📒 Files selected for processing (1)
  • changelog.d/7535-construction-shape-install.md

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Update line [93] to reference the correct tracking issue.

Not taken as written — #7510 is the correct tracking issue — but the finding is fair, because the paragraph never said why, and read as self-contradictory. Fixed the ambiguity instead, in a158225.

#7510's own comment folds the residual in explicitly: "Folding in the residual half of #7512, per the diagnosis there (#7515 merged the other half) … it is why #7512's remaining cost belongs here rather than in a repsel ticket." #7512 is still open, but for the broader new Klass()-vs-object-literal gap, not for the ordering residual. The paragraph now opens by saying that, so the closing #7510 follows from the text rather than looking like a typo.

Ralph Küpper added 9 commits August 6, 2026 18:59
)

`js_gc_init_typed_shape_layout` runs on every construction of a typed
object literal and every `new` of a class with a typed field layout.
Since #6893 the descriptor it installs is per-SHAPE, shared through
`SHAPE_LAYOUTS` and keyed by the canonical `keys_array`; the only
per-object work left is two header bits. The call did not know that.
For the 20-millionth `{v, w}` literal it still built a
`TypedLayoutDescriptor` (two 32-byte `Vec`-carrying enums, cloned once
and dropped), took a `RefCell` borrow, hashed `keys`, and compared the
fresh descriptor field-by-field against the one already stored — to
reach the conclusion the first construction had already reached.

`gc::shape_install` memoises exactly that map answer, and nothing else:
"`SHAPE_LAYOUTS[keys]` holds `Some(D)`, where `D` is what these mask
globals describe". Everything the header declaration rests on —
`field_count == slot_count`, mask disjointness, and the per-slot
validation that each raw-f64 slot holds a plain double and no
pointer-bearing slot sits outside the pointer mask — is still re-derived
per object, and `POINTER_FREE` vs `SIDE_MASK` is recomputed from the
pointer mask rather than read back from the memo. A stale entry can
therefore only cost work, never correctness.

The two soundness checks now read the caller's mask words directly
instead of building a `LayoutSlotMask` per construction, which also
takes the drop glue off six early-return paths and the `Vec` allocation
off every construction of a shape wider than 64 slots.
`LayoutSlotMask::intersects` survives as the test-only reference the
word helpers are pinned against.

Self-healing: an entry is falsified by one transition only —
`SHAPE_LAYOUTS` poisoning a shape to ambiguous — and that branch drops
the table. A relocated or recycled `keys_array` degrades to a miss, and
the table is not a GC root: `keys` is compared as an integer and never
dereferenced.
… shapes (#7510)

Two object-literal sites with the same key names share one `keys_array`
but get separate mask globals unless LLVM's constant merger folds them,
so an index keyed on the shape alone would put both in one entry and let
them evict each other every iteration of a loop that builds both. Any
index is correct — the full tuple is still compared — so this is purely
about not colliding.
Measured, not guessed. A direct-mapped table cycled round-robin by more
shapes than it has slots hits ZERO percent of the time — each entry is
evicted by its partner before it is read again — so it pays the probe
and gets nothing back. A 16-shape churn loop against 8 slots was a
reproducible 0.993x on the pinned host. At 32 slots the same loop fits.
The table is 1 KiB of const-initialised thread-local, so a program with
one shape pays a page it never touches.
The memo keys on the ADDRESS of the mask words. A `const` is inlined at
each use site rather than having one address, so the test fixture only
worked because rustc happened to promote it to a single static. Codegen
emits `private unnamed_addr constant` globals, which genuinely have one
address — a `static` is the faithful model as well as the stable one.
CodeRabbit caught that the fragment still said 8 entries after the sizing
commit raised it to 32. Also records the headline numbers and the
sizing rationale, which the fragment predated.
#7512

CodeRabbit read the paragraph as contradicting itself: it opens on #7512
and closes on #7510. The reference is right — #7510's own comment folded
#7512's remainder in as its construction-path item — but the fragment
never said so. It does now, and it notes that #7512 stays open for the
broader class-vs-object-literal gap.
@proggeramlug
proggeramlug force-pushed the perf/7510-construction-shape-install branch from a158225 to ab671fd Compare August 6, 2026 16:59
@proggeramlug
proggeramlug merged commit 0693966 into main Aug 6, 2026
8 of 12 checks passed
@proggeramlug
proggeramlug deleted the perf/7510-construction-shape-install branch August 6, 2026 16:59
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

proggeramlug pushed a commit that referenced this pull request Aug 7, 2026
…oops

The outlined per-`new`-site allocator has been the default since [#bloat]:
it collapses ~145 lines of per-class-constant IR per site into one
js_object_alloc_class_inline_keys call. The size half of that decision still
holds — measured ~268 bytes of machine code per site, +214,656 over an
800-site program.

The SPEED half has inverted. The comment reads '~17% faster on an 8M-allocation
loop'; today the outlined form is 1.81x SLOWER on churn_alloc and 1.78x on
push_cls. Nothing about the inline bump changed — everything around the
allocation got cheaper (#7474 #7486 #7487 #7501 #7525 #7532 #7535 #7536 #7552),
so the surviving FFI call and the thread-local resolutions it performs now
dominate what its code bloat costs. Those resolutions cannot be made cheaper on
Darwin: Mach-O has no local-exec TLS model, and building the runtime with
-Ztls-model=local-exec leaves the blr through the TLV descriptor byte-identical
(measured 1.02x). Only their count can be reduced.

So the choice becomes per site rather than global. A `new` inside a loop takes
the inline bump; everything else keeps the outlined call and adds nothing to
binary size. Loop membership reuses the existing loop_targets stack — switch
frames push an empty continue label, every loop pushes a real one, the same
discriminator Stmt::Continue already relies on.

Measured: churn_alloc 1.81x, push_cls 1.81x, churn 1.56x — the full
unconditional-inline ceiling. Size +0 bytes for 800 sites none of which are in
loops; equal to all-inline when every site is. tree is -1.4%.
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