Skip to content

perf(gc): skip the per-object layout side tables while they are provably empty (#7510) - #7525

Merged
proggeramlug merged 4 commits into
mainfrom
perf/7510-layout-tables-empty-fast-path
Aug 6, 2026
Merged

perf(gc): skip the per-object layout side tables while they are provably empty (#7510)#7525
proggeramlug merged 4 commits into
mainfrom
perf/7510-layout-tables-empty-fast-path

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Partial fix for #7510 (construction/death half of #5094). The ticket's acceptance bar is not met — see "Where this lands" — so #7510 stays open.

What was wrong

LAYOUT_SLOT_MASKS and TYPED_LAYOUTS are address-keyed thread-local maps. Since #6893 moved the canonical typed layout of a well-behaved object into the shape-keyed SHAPE_LAYOUTS, what is left in them is the residue: objects that diverged from their shape, objects with no keys_array, ambiguous shapes. On a monomorphic workload that residue is empty for the whole run.

Empty was not free. Every allocation (layout_init_pointer_free), every typed-shape install, every object death (layout_clear_for_ptr) and every relocation (layout_transfer) probed both maps to clear whatever a previous tenant of a recycled address might have left — two RefCell round-trips plus two hashes, per object, to remove nothing.

Commit 1 — an emptiness proof, in one load

PER_OBJECT_LAYOUTS_NONEMPTY answers "is there anything in either map at all". Its false state is a proof of emptiness: only an insert can break it, and every insert routes through the guarded accessors in the new gc/layout_tables module, which arm it; the removal paths re-test and clear it again once the maps drain. A stale true costs exactly the pre-#7510 probe — the flag is an accelerator, never an authority, and assert_flag_sound in the new tests asserts the implication after every transition that can populate or drain either map.

layout_note_slot also stops cloning the descriptor out of the map on every store. The clone existed only so that layout_set_typed_unknown could not re-enter a live RefCell borrow; it now computes a two-state SlotVerdict inside the borrow and acts after it, so a Heap mask no longer allocates a Vec per write.

Commit 2 — the reason commit 1 was worth nothing on its own

Measured before believing it: the fast path fired once in 40 million calls on churn_alloc. One entry was holding both maps hostage — the canonical keys_array of the program's single object shape.

js_build_class_keys_array fills it with interned key strings and notes each element, which grows a per-array pointer mask; the shape cache then anchors that array for the program's lifetime (#179), so the mask never drains. Since ~every program builds at least one shape, the emptiness fast path was dead on arrival for essentially all of them — including, retroactively, the is_empty() guard #7469 added to layout_forget_object.

Once the last key is stored the mask is replaced by the GC_LAYOUT_ALL_POINTERS header declaration, which is exactly true of a keys array (every slot in 0..length holds an interned string) and immutable for the rest of the program (growing a shape builds a new array — shape_keys_grown). The per-element notes during the fill stay: they are what keeps the already-stored prefix traceable if allocating the next key string triggers a GC, and the declaration can only be made once the last slot is filled.

churn_alloc now runs with both maps at zero entries and the fast path on ~100% of calls.

Measurements

Symbolicated leaf profile of churn_alloc.ts (20M {v, w} literals): the gc::layout family falls from 26.0% → 20.8%, layout_forget_object 3.0% → 1.6%, js_gc_init_typed_shape_layout 13.6% → 9.5%.

Interleaved A/B (arms alternating per round, best-of-9 user CPU, corroborated on the pinned bench host):

bench speedup
push_num 1.10×
churn_alloc 1.03×
churn, push_cls, deeplist, churn_read 1.00×
tree 0.98–1.00×

tree is the one arm that can only lose: it genuinely holds per-object records, so its flag stays armed and it pays the fast-path test without getting the fast path. The removal paths key their re-test on remove(…).is_some() so an armed workload runs the pre-#7510 instruction sequence plus a load; that took tree from −2.2% back to within noise on the pinned host, and the residual is at the edge of what either host can resolve.

No GC regression. PERRY_GC_TRACE is field-identical between the arms: churn 105 cycles / 0.0036 GB copied, tree 43 cycles / 0.0159 GB, promoted bytes byte-for-byte equal, peak RSS within ±2 MB.

Where this lands

This does not meet #7510's acceptance bar (≥1.5× on churn_alloc, gc::layout below 8%), and the reason is worth recording: the profile the ticket was filed from is out of date. layout_forget_object is no longer 14.5% of churn_alloc — after #7469/#7474/#7486/#7487/#7501 it is 3.0%, so removing it entirely could never have delivered 1.5×.

What the profile now shows as the remaining layout cost is not the side tables at all. It is js_gc_init_typed_shape_layout + shape_install_shared (~13% combined), which still rebuild both masks, re-probe SHAPE_LAYOUTS and re-compare the descriptor on every construction of an already-installed shape. That is #7510's item 1 verbatim ("construction should become a header bit-set, not a side-table insert") and is the next lever; the emptiness fast path this PR establishes is a prerequisite for it, not a substitute.

Testing

  • cargo test --release -p perry-runtime — 1761 pass, 0 fail.
  • New gc/tests/layout_trace/per_object_tables.rs: the flag invariant across install / partial drain / typed downgrade / in-place mask growth, plus a witness that a shape's keys array declares all-pointer slots instead of a mask and still enumerates and traces all three key strings — so a green test means the declaration is as precise as the mask it replaced, not that the entry merely disappeared.
  • 20 object/class/shape-heavy programs from test-files/ compiled and run under both arms: byte-identical output, and identical again under PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 (the arm that would catch an imprecise all-pointer declaration by reclaiming or relocating a key string out from under the array).
  • cargo fmt --all -- --check clean; scripts/check_file_size.sh clean (layout.rs was 75 lines under the 2000-line cap, hence the layout_tables split).

Summary by CodeRabbit

  • Performance

    • Improved garbage collection efficiency by bypassing unnecessary per-object layout processing when no metadata is present.
    • Streamlined metadata handling during object movement, cleanup, and layout updates.
  • Bug Fixes

    • Improved pointer tracking for class key arrays, including during incremental construction.
    • Preserved correct child enumeration and tracing across layout changes.
  • Tests

    • Added coverage for layout creation, removal, cleanup, relocation, and tracing behavior.

Ralph Küpper added 3 commits August 6, 2026 13:54
…bly empty

#6893 moved the canonical typed layout of a well-behaved object into the
shape-keyed SHAPE_LAYOUTS map, leaving LAYOUT_SLOT_MASKS and TYPED_LAYOUTS
holding only objects that diverged from their shape — on a monomorphic
workload, nothing at all. Every allocation, typed-shape install, object death
and relocation still probed both maps to clear a record that was not there:
two RefCell round-trips plus two hashes each time. layout_forget_object was
14.5% of self time on the churn_alloc object-construction profile (#7510),
nearly twice the allocator it was bookkeeping for.

PER_OBJECT_LAYOUTS_NONEMPTY answers 'is there anything in either map' in one
load. Its false state is a proof of emptiness — only an insert can break that,
and every insert now routes through the guarded accessors in the new
gc/layout_tables module, which arm it; the removal paths re-test both maps and
clear it again. A stale true costs exactly the old probe, so the flag is an
accelerator and never an authority.

layout_note_slot also stops cloning the descriptor out of the map on every
store: it computes a SlotVerdict inside the borrow and acts after it, so a Heap
mask no longer allocates a Vec per write.
…t per element

The emptiness fast path from the previous commit fired ONCE in 40 million
calls on churn_alloc. One entry was holding both per-object maps hostage: the
canonical keys_array of the program's single object shape.

js_build_class_keys_array fills it with interned key strings and notes each
element, which grows a per-array pointer mask; the shape cache then anchors
that array for the program's lifetime (#179), so the mask never drains. Since
~every program builds at least one shape, the fast path was dead on arrival for
essentially all of them.

Once the last key is stored, the mask is replaced by the GC_LAYOUT_ALL_POINTERS
header declaration — exactly true of a keys array (every slot in 0..length
holds an interned string) and immutable for the rest of the program (growing a
shape builds a NEW array, shape_keys_grown). The per-element notes during the
fill stay: they keep the already-stored prefix traceable if allocating the next
key string triggers a GC, and the declaration can only be made once the last
slot is filled.

churn_alloc now runs with both maps at zero entries and the fast path on ~100%
of calls. The removal paths key their re-test on remove(..).is_some() so a
workload that genuinely holds records (tree) runs the pre-#7510 instruction
sequence plus a load.
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f1c84f4c-dedd-4eb7-98e7-2a60cb023a5b

📥 Commits

Reviewing files that changed from the base of the PR and between 2289e30 and 988140f.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (2)
  • CLAUDE.md
  • Cargo.toml

📝 Walkthrough

Walkthrough

The runtime centralizes per-object GC layout tables, adds an emptiness flag with hot-TLS access, updates layout operations and relocation, marks completed class key arrays as all-pointer layouts, and adds invariant and tracing tests.

Changes

Per-object layout fast path

Layer / File(s) Summary
Centralized layout table storage
crates/perry-runtime/src/gc/mod.rs, crates/perry-runtime/src/gc/layout_tables.rs
Adds thread-local typed-layout and slot-mask tables with guarded lookup, insertion, removal, relocation, cleanup, and emptiness tracking.
Runtime layout and TLS integration
crates/perry-runtime/src/gc/layout.rs, crates/perry-runtime/src/gc/hot_tls.rs, crates/perry-runtime/src/tls_hot.rs
Routes layout queries, updates, tracing, scanning, and relocation through centralized helpers. Adds cached access to PER_OBJECT_LAYOUTS_NONEMPTY and defers typed-layout downgrades until descriptor borrows end.
Keys-array layout and invariant tests
crates/perry-runtime/src/object/alloc.rs, crates/perry-runtime/src/gc/tests/layout_trace/*, changelog.d/7525-layout-tables-empty-fast-path.md
Declares completed class key arrays as all-pointer layouts. Tests flag transitions, typed downgrades, late pointer stores, object death, child enumeration, and string tracing.
Version metadata update
Cargo.toml, CLAUDE.md
Updates the workspace and documented version from 0.5.1295 to 0.5.1296.

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

Sequence Diagram(s)

sequenceDiagram
  participant GCLayout
  participant layout_tables
  participant HotTls
  participant GCTracer
  GCLayout->>layout_tables: insert or query per-object descriptor or slot mask
  layout_tables->>HotTls: read PER_OBJECT_LAYOUTS_NONEMPTY
  layout_tables-->>GCLayout: return guarded layout metadata
  GCLayout->>GCTracer: select pointer slots for tracing
Loading

Possibly related issues

Possibly related PRs

  • PerryTS/perry#6821 — Both modify typed-layout validation and per-object side-table handling.
  • PerryTS/perry#6974 — Both modify layout transfer and shared-shape descriptor handling.
  • PerryTS/perry#7474 — This PR extends the hot-TLS layout infrastructure with the per-object-layout emptiness flag.

Suggested labels: performance

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary change: skipping provably empty per-object GC layout side tables.
Description check ✅ Passed The description thoroughly covers the problem, implementation, measurements, limitations, related issues, and test results.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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
  • Commit unit tests in branch perf/7510-layout-tables-empty-fast-path

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 merged commit a5b995c into main Aug 6, 2026
8 of 12 checks passed
@proggeramlug
proggeramlug deleted the perf/7510-layout-tables-empty-fast-path branch August 6, 2026 12:29
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