Skip to content

perf(gc): layout side tables are 34% of object construction — the construction/death half of #5094 (allocation is 7.7%) #7510

Closed

Description

@proggeramlug

Summary

Granular member of the #5094 family. #5094's table covers the access paths
(this.field get/set, array element writes, dynamic property writes). This ticket is
the construction and death path, which that table does not mention and which is now
the single largest cost in allocation-heavy code.

On gc-handoff/bench/churn_alloc.ts — 20M {v: number, w: number} literals pushed into
a 1000-element array — a symbolicated profile attributes 33.6% of self time to
gc::layout per-object side-table maintenance
, against 7.7% for the actual
allocation
. layout_forget_object alone is 14.5%, nearly twice the allocator.

#6893 closed the memory half of this subsystem (per-class layout interning took the
record footprint 289 B → 133 B). The CPU half was never touched.

Evidence

Symbolicated profile (PERRY_DEBUG_SYMBOLS=1, sample 2 s, 1500 leaf samples, quiet M1
mini at load 1.4):

group share
gc::layout side tables 33.6%
— layout_forget_object (gc/layout.rs:336) 14.5%
— layout_note_slot (gc/layout.rs:685) 7.9%
— js_gc_init_typed_shape_layout (gc/layout.rs:962) 7.7%
— shape_install_* 2.2%
— LayoutSlot* 1.3%
_tlv_get_addr 17.0%
write barriers (see sibling ticket) 16.1%
typed-feedback guards 9.2%
array (js_array_length, js_array_note_numeric_write) 6.2%
the allocation itself (js_object_alloc_class_inline_keys 6.3%, arena_alloc* 1.4%) 7.7%
user code 3.7%

Note the interaction with the TLS row: TYPED_LAYOUTS / LAYOUT_SLOT_MASKS are
thread-locals, so an unknown but material share of the 17% _tlv_get_addr is also this
subsystem. #7474 already cached the hot TLS addresses and TLS still costs 17%; removing
the layout ops removes their share of it too. One fix, two rows.

js_gc_init_typed_shape_layout is emitted by lower_call/new.rs on every
construction, and layout_forget_object runs on every object death — so the cost
scales with allocation rate, not with live set. That is why it dominates churn and not
the retain/tree benchmarks.

Workload decomposition (quiet mini, best-of-3)

variant Perry node ratio
churn (full) 2.72 s 0.17 s 16.0×
churn_alloc — object literal + push 2.44 s 0.14 s 17.4×
churn_read — element reads only 0.35 s 0.08 s 4.3×
push_num — numbers into array, no object 0.30 s 0.11 s 2.7×

push_num at 2.7× shows the array machinery is not the problem. Subtracting it, object
construction is ~2.14 s, 79% of churn
, and ~76% of that is bookkeeping rather than
allocation.

Repro

cd gc-handoff/bench
export PERRY_RUNTIME_DIR=<repo>/target/release
PERRY_DEBUG_SYMBOLS=1 PERRY_NO_AUTO_OPTIMIZE=1 <repo>/target/release/perry \
    churn_alloc.ts -o sym_churn_alloc     # PERRY_DEBUG_SYMBOLS=1 is what makes the profile readable

./sym_churn_alloc >/dev/null & P=$!; sleep 0.7
sample $P 2 -mayDie -f /tmp/sym.sample; wait $P
# read the "Sort by top of stack" section; demangle with rustfilt or read the raw _RNv… names

churn_alloc.ts is in gc-handoff/bench/ alongside push_num.ts, push_cls.ts and
churn_read.ts (the decomposition set).

Task

Apply #5094's "layout is canonical" design to the construction/death path specifically:

  1. Construction — js_gc_init_typed_shape_layout per new should become a header
    bit-set, not a side-table insert. The mask is already a compile-time constant per class
    (codegen emits perry_typed_shape_raw_f64_mask_<class>); TYPED_LAYOUTS stores per-class-constant layout masks per OBJECT — O(objects) memory (272MB on churn bench) + hashmap insert on every new #6893 established the
    per-class interning this can hang off.
  2. Death — layout_forget_object should be free when the object never diverged from
    its canonical shape. Today it runs unconditionally per dead object; with a canonical
    bit it becomes a bit-test that is almost always false. gc/layout.rs:938 already notes
    the map is usually empty and skips the hash — but the call, the TLS resolve and the
    branch still happen per object.
  3. Slot notes — layout_note_slot on stores into a slot that still matches the
    canonical mask should compile away entirely.

The fallback path (object diverged → today's TYPED_LAYOUTS behaviour) stays exactly as
it is. This is about not paying for it on the overwhelmingly common case.

Acceptance criteria

  1. gc::layout symbols fall below 8% of self time on the churn_alloc.ts profile
    (from 33.6%).
  2. churn_alloc.ts improves by ≥1.5× (2.44 s → ≤1.6 s) on a quiet host.
  3. churn.ts improves correspondingly; ratio vs Node drops from 16.0× to ≤11×.
  4. No GC regressions: PERRY_GC_TRACE=1 on churn still shows ~105 cycles, ~0.004 GB
    copied, positive reclamation every cycle, max pause in the low milliseconds; tree.ts
    stays at ~43 cycles / ~0.017 GB.
  5. No regression on 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 (method_calls,
    bench_numeric_array_downgrade, bench_object_property) — this change should help
    them, but confirm.
  6. The 8 gc_ratchet probes plus 12_large_live_set hold.
  7. cargo test workspace sweep green (exclude cross-host UI crates on macOS).

Traps

  • Do not benchmark on the dev Mac while builds run — it sits at load 15–140 from other
    agents. Use the dedicated quiet host: ssh perry@perry-macos.local (M1, 8 cores, idles
    ~1.5). Perry binaries are statically linked arm64 — just ship them over
    (tar czf - n_* | ssh perry@… 'tar xzf - -C ~/benchX'), no toolchain needed there.
    Fallback on a loaded host: best-of-N user CPU time, which tracks the quiet host's
    wall clock within ~5%. Peak RSS and PERRY_GC_TRACE are load-independent.
  • Default perry output is stripped; PERRY_DEBUG_SYMBOLS=1 is required or the profile
    is all ???.
  • Rebuild runtime and stdlib — perry-runtime is rlib-only, the .a comes from the
    -static wrappers.
  • PERRY_NO_AUTO_OPTIMIZE=1 on ad-hoc compiles; rm -rf node_modules/.cache/perry after
    switching compilers.
  • Never CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 for anything measured — it miscompiles
    the release runtime.
  • PERRY_WRITE_BARRIERS=0 makes this benchmark slower (2.44 → 5.21 s) because it also
    switches the collector out of evacuating mode. It cannot be used to isolate costs.

Context

Parent: #5094 (umbrella — access paths). Sibling tickets from the same profile: #7511 (write barriers on
non-pointer stores, 16.1%) and #7512 (new Klass() slower than the equivalent object
literal). Predecessor: #6893 (memory half of this subsystem, closed). Related: #7469
(the remaining _tlv_get_addr share), #6759 (V8-style object model).

Activity

  1. added
    performanceRuntime, compile-time, build-size, or memory performance
    on Aug 6, 2026
  2. proggeramlug commented on Aug 6, 2026

    @proggeramlug
    ContributorAuthor

    Folding in the residual half of #7512, per the diagnosis there (#7515 merged the other half).

    js_gc_init_typed_shape_layout is emitted AFTER the constructor call (lower_call/new.rs; the ordering is visible in a --trace llvm dump). The consequence is that no raw-f64-declared class-field store inside any constructor can pass its guard — neither the inline and i16 %r, 4096 INTACT test nor class_field_fast_contract's layout_typed_raw_f64_slot_for_user. Every such store falls back to js_put_value_set.

    The user-visible shape of this is worth stating plainly, because it inverts the usual assumption:

    Declaring the fields number is what makes the class slower. More type information selects a representation whose guard the construction path has made unsatisfiable.

    That is the #6377 lesson in a new costume — a "more type visibility" change un-gating a path that cannot actually fire — and it is why #7512's remaining cost belongs here rather than in a repsel ticket.

    The obvious fix does not work. Moving the call before the ctor fails because init_typed_shape_layout (gc/layout.rs:897) validates that each raw-f64 slot holds a plain double, and fresh slots hold undefined. So this needs the construction path to establish the layout in a way that tolerates the pre-initialization state — i.e. it is the construction-path item of this issue, not a one-line reorder.

    Concrete task wording for whoever picks this up: make the typed-shape layout observable to stores inside the constructor body, without asserting slot contents that the prologue has not written yet. Acceptance is an IR census showing the two real field stores in constructor(v, w) { this.v = v; this.w = w } with v: number; w: number no longer routing through js_put_value_set.

    Cross-refs: #7512 (diagnosis + census), #7515 (the dead-store half, merged), #7511 is not implicated — the object literal emits more barrier work per store and is still the faster form.

  3. proggeramlug commented on Aug 6, 2026

    @proggeramlug
    ContributorAuthor

    Item 1 (construction) is up as #7532, together with the residual half of #7512 folded in above. 1.553× on push_cls on the pinned host, and the collector result is strictly better rather than merely equal — promotion 210,488 → 64 bytes, peak RSS 29.6 → 24.1 MB, cycles unchanged. Those are exactly the numbers the equivalent object literal already had, so #7512's inversion is closed on both axes.

    The mechanism, since it is not the obvious one: js_gc_declare_typed_shape_layout skips the slot validation that makes the existing call impossible to move earlier, and codegen carries the proof instead — every raw-f64 field prologue-assigned from a plain parameter (#7486's predicate), and an empty pointer mask so the declared state is POINTER_FREE, byte-identical to what birth already sets. A mistyped store still downgrades through the ordinary guard → boxed setter → layout_note_slot path; there is a 20,000-instance witness under GC zeal.

    Two findings for this ticket, both of which change what is left to do.

    1. churn_alloc cannot be fixed by item 1, because object literals carry no types. HIR rewrites a closed-shape literal to new __AnonShape_<hash>(…) whose synthesized constructor is a qualifying prologue — but the minted class's field types come out as Any, not Number. So there was never a raw-f64 store path there to unlock. The corollary is worse than the missed optimisation: since Any is pointer-bearing, {v: number, w: number} is currently declared to the collector as two pointer slots, and js_gc_init_typed_shape_layout spends its 13.6% installing that. The literal path's construction cost is a type-propagation gap in mint_anon_shape_class's field_shapes, not an ordering one. Worth its own ticket; I have not filed one.

    2. This ticket's acceptance criteria were written against a stale profile and should be restated. Measured on main at d3472a529, layout_forget_object is 3.0% of churn_alloc, not the 14.5% quoted in the description — the #7469/#7474/#7486/#7487/#7501 wave already took it. Removing the side tables entirely could never have delivered ≥1.5× on churn_alloc, so criteria 1–3 were unreachable from items 1–3 as scoped. The current leaf profile is:

    symbol share
    _tlv_get_addr 17.8%
    js_gc_init_typed_shape_layout 13.6%
    barrier_child_prologue 9.3%
    js_typed_feedback_class_field_set_guard 7.5%
    js_object_alloc_class_inline_keys 6.6%
    js_array_length 6.4%
    layout_note_slot 6.2%
    user code 4.0%
    layout_forget_object 3.0%
    shape_install_shared 2.5%

    Remaining on this ticket: item 3 (layout_note_slot compiling away on a store that still matches the canonical mask, ~6%). Item 2 shipped in #7525, item 1 in #7532.

    Not yet verified by me, and I would not claim them: criterion 5 (the #5094 access-path benchmarks — I could not find method_calls / bench_numeric_array_downgrade / bench_object_property under those names in benchmarks/), criterion 6 (the 8 gc_ratchet probes plus 12_large_live_set), and criterion 7 (the workspace cargo test sweep — I ran perry-runtime and perry-codegen only, the latter with a failure set identical to origin/main's 22 pre-existing).

  4. proggeramlug commented on Aug 6, 2026

    @proggeramlug
    ContributorAuthor

    Item 1 is in #7535. Two findings worth recording on the ticket itself.

    The #7512 residual folded in here is not a runtime relaxation. The reason the pre-ctor reorder fails is usually stated as "fresh slots hold undefined, so init_typed_shape_layout rejects them", which is true — undefined is 0x7FFC_… and layout_raw_f64_bits rejects any tag in [SHORT_STRING_TAG = 0x7FF9, STRING_TAG = 0x7FFF]. But relaxing that check would be safe for the collector: undefined is not pointer-bearing, so declaring an unwritten slot raw-f64 strands nothing.

    What it is not safe for is readers. class_field_fast_contract spells out the contract the codegen-inlined path depends 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 body runs makes that false for every declared-but-not-yet-assigned field, and

    class C { v: number; constructor() { console.log(this.v); this.v = 1; } }

    must still print undefined. So this 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. Not a one-line reorder and not an install-time relaxation.

    The remaining gc::layout cost, re-measured after #7535. Symbolicated leaf profile of churn_alloc on the pinned host, so items 2 and 3 can be scoped against current numbers rather than the ticket's original ones:

    symbol before #7535 after
    js_gc_init_typed_shape_layout 8.9% ~8%
    shape_install_shared 2.2% below the reporting threshold
    layout_note_slot (item 3) 7.5% 7.4%
    layout_forget_object (item 2) 1.4% 1.7%

    layout_note_slot is now the largest remaining member of the family by a wide margin — item 3 is the next lever here, not item 2. For context on the same profile: _tlv_get_addr 20%, barrier_child_prologue + js_write_barrier_slot + write_barrier_decoded_parent ~20% (#7511), the typed-feedback guards (js_typed_feedback_class_field_set_guard + class_field_fast_contract) ~13%, and the allocation itself ~9%.

  5. 3 remaining items

  6. proggeramlug commented on Aug 6, 2026

    @proggeramlug
    ContributorAuthor

    Item 1 landed as #7532 (1.553× on push_cls, promotion 210,488 → 64 B). Verified intact and green on 82be85459 after #7535/#7536/#7545 touched the same files.

    Item 3 is already done — measured, not assumed. Re-profiled churn_alloc on current main and layout_note_slot is 1 sample out of ~990 (0.1%), reached only through js_array_push_f64_grow → note_array_slot. The 7.4% in the comment above predates #7536; between #7501 (declared array element layout) and #7536 (#7511's one inline test) the per-store note has already been elided on this workload. There is nothing left to build for item 3 here — it would want a different benchmark to justify it.

    That is the third time this ticket's numbers have gone stale between measurement and implementation (14.5% → 3.0% for item 2, 7.4% → 0.1% for item 3). Worth re-measuring before anyone picks up the next thing on it.

    Current churn_alloc leaf profile (main 82be85459, PERRY_DEBUG_SYMBOLS=1, ~990 leaf samples):

    symbol share
    _tlv_get_addr 26.7%
    js_object_alloc_class_inline_keys 9.3%
    typed_shape_layout_entry 9.0%
    barrier_child_prologue 8.6%
    js_typed_feedback_class_field_set_guard 7.4%
    class_field_fast_contract 6.3%
    js_array_length 6.0%
    write_barrier_decoded_parent 5.7%
    js_write_barrier_slot 3.3%
    user code 3.1%
    layout_forget_object 1.7%
    layout_note_slot 0.1%

    gc::layout is now ~10.7%, down from 26.0% when this ticket was opened — items 1 + 2 + #7535 between them. Criterion 1 (below 8%) is close but not met, and the remaining 9% is typed_shape_layout_entry, i.e. the per-object install that survives #7535's memoisation.

    Criteria 2 and 3 (churn_alloc ≥1.5×, node ratio ≤11×) cannot be reached from this ticket, and I have filed why: #7547. A for initializer registers its local as Type::Any, so {v: base + j, w: j} inside a loop mints an anon shape with Any fields — the raw-f64 mask is empty, the pointer mask is full, and a {v: number, w: number} literal is handed to the collector as two traced pointer slots. Moving the same annotation out of the for-initializer is worth 1.184× on churn_alloc on its own (best-of-9, interleaved; full isolation table in #7547). That is HIR type inference, not layout bookkeeping.

    For reference, churn_alloc is now 1.586 s on this host against the 2.44 s in the description.

    Still unverified by me, unchanged from my earlier comment: criterion 5 (the #5094 access-path benchmarks — I could not find method_calls / bench_numeric_array_downgrade / bench_object_property under those names in benchmarks/), criterion 6 (the 8 gc_ratchet probes plus 12_large_live_set), criterion 7 (the workspace sweep — I ran perry-runtime green at 1797 and perry-codegen with a failure set identical to origin/main's).

  7. proggeramlug commented on Aug 7, 2026

    @proggeramlug
    ContributorAuthor

    Item 3 re-confirmed collapsed on current main (969b447cc, v0.5.1315), and this time with a ceiling number so it does not need re-picking-up a fourth time.

    The previous comment measured item 3 at 0.1% on 82be85459. #7550 and #7552 have landed since, and they change churn_alloc materially (the typed-feedback guard pair that was 13.7% of the profile is gone, and the bench is 1.586 s → 1.28 s on this host), so the number was worth re-taking rather than inheriting.

    layout_note_slot on current main

    Pinned quiet host (M1 mini, load 1.2–1.7), PERRY_DEBUG_SYMBOLS=1, 6 × sample … 1 -mayDie, 5,869 leaf samples aggregated from the Sort by top of stack sections:

    symbol share
    _tlv_get_addr 30.50%
    js_object_alloc_class_inline_keys 9.71%
    barrier_child_prologue 8.04%
    __AnonShape_…_constructor 7.29%
    js_array_length 7.14%
    typed_shape_layout_entry 6.97%
    write_barrier_decoded_parent 5.49%
    user code (chunk) 4.98%
    js_write_barrier_slot 4.11%
    layout_forget_object 2.40%
    layout_note_slot 0.03% (2 samples of 5,869)

    Those 2 samples reach it only through js_array_push_f64_grow → note_array_slot, the same path the previous comment named.

    Static census, which is the stronger statement: codegen emits zero js_gc_note_slot_layout call sites for churn_alloc — the symbol is not in the linked binary at all. Both halves of the program are already covered without one:

    Emitted note call sites across the bench family, --trace llvm:

    bench js_gc_note_slot_layout …_aware
    churn_alloc, churn, churn_read, push_cls, push_cls_read, retain 0 0
    push_num, churn_num 1 0
    deeplist 0 1
    tree, cycles 0 2

    The ceiling — what item 3 could buy even done perfectly

    layout_note_slot stubbed to an unconditional early return (black_box guarded so the body is not dead-stripped and the inliner still sees it), separate target dir per arm, identical -p set, PERRY_RUNTIME_DIR pinned, same user objects linked against the two runtime archives. Stdout is byte-identical on all four benches. Interleaved best-of-N user+sys:

    bench note live note removed ceiling
    churn_alloc 1.280 s 1.260 s 1.016×
    cycles 0.860 s 0.870 s 0.989×
    deeplist 1.580 s 1.480 s 1.068×
    tree 8.730 s 7.880 s 1.108×

    So on this ticket's acceptance bench, deleting the per-slot note entirely is worth 1.6% — against criterion 2's ≥1.5×. Any sound elision captures some fraction of that, so item 3 cannot move criterion 2 and is not worth the risk budget: a wrong pointer mask strands live objects, and heap_payload_slot_selection short-circuits on POINTER_FREE without consulting any mask.

    tree's 1.108× is the one number that looks tempting and is not available. Most of it is not the note's own cost but the per-object side-table population it feeds (per_object_slot_mask 4.17%, layout_forget_object 8.99%, LayoutSlotMask::insert 0.92% on that bench) — and tree's stores are this.left = <pointer>, which is exactly the case that must be recorded. There is no sound version of that arm.

    The guards that keep it collapsed already exist

    I went looking for what would catch a silent regression, expecting to have to add it, and it is already there and sabotage-tested:

    • gc/tests/copying/all_pointer_elements_7469.rs — a_pointer_append_through_the_runtime_preserves_the_declaration_across_growth, a_replace_or_a_numeric_append_downgrades_to_a_conservative_scan, and undeclared_array_enumerates_no_child_slots_which_is_what_the_declaration_buys as the permanent sabotage arm.
    • perry-codegen/tests/typed_shape_descriptors.rs — both directions of the static elision, including pointer_store_into_numeric_array_keeps_layout_note_and_barrier and mixed_array_literal_emits_layout_note_calls.
    • perry-hir/tests/anon_shape_field_types.rs (perf(hir): type a for-head binding so a loop-built object literal stops being declared as pointer slots (#7544) #7550) — pins the field typing that removed the literal's note sites.

    What is actually left on this ticket

    gc::layout is now ~9.4% of churn_alloc (typed_shape_layout_entry 6.97% + layout_forget_object 2.40% + layout_note_slot 0.03% + layout_transfer 0.03%), so criterion 1 (below 8%) is still just missed. The residue is not per-slot bookkeeping any more:

    • typed_shape_layout_entry at 6.97% is the per-object install that survives perf(gc): make a repeat typed-shape construction a header bit-set (#7510) #7535's memoisation;
    • layout_forget_object at 2.40% is, with PER_OBJECT_LAYOUTS_NONEMPTY proving both maps empty, essentially all thread-local addressing — and _tlv_get_addr is now 30.5% of the whole profile, up from the 17% in the description and the 26.7% in the comment above.

    Both of those are the TLS-addressing lever (#7469's territory), not layout bookkeeping. Recommend closing item 3 as done, with the ceiling table above as the reason it should not be reopened.

  8. added 2 commits that reference this issue on Aug 7, 2026
  9. proggeramlug commented on Aug 9, 2026

    @proggeramlug
    ContributorAuthor

    All three items shipped and measured out; closing on the recommendation already in this issue's own last comment.

    GC_OBJ_TYPED_LAYOUT_INTACT is live at gc/layout.rs:80 and used through the file exactly as the shipped design describes.

    Worth recording the meta-lesson this issue taught, since it outlived the issue: the numbers here went stale three times during the work. Re-profile before coding to any figure in a ticket — including the ones in this one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    performanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions