Repository navigation
perf(gc): layout side tables are 34% of object construction — the construction/death half of #5094 (allocation is 7.7%) #7510
Description
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… nameschurn_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:
- Construction —
js_gc_init_typed_shape_layoutpernewshould become a header
bit-set, not a side-table insert. The mask is already a compile-time constant per class
(codegen emitsperry_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 everynew#6893 established the
per-class interning this can hang off. - Death —
layout_forget_objectshould 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:938already notes
the map is usually empty and skips the hash — but the call, the TLS resolve and the
branch still happen per object. - Slot notes —
layout_note_sloton 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
gc::layoutsymbols fall below 8% of self time on thechurn_alloc.tsprofile
(from 33.6%).churn_alloc.tsimproves by ≥1.5× (2.44 s → ≤1.6 s) on a quiet host.churn.tsimproves correspondingly; ratio vs Node drops from 16.0× to ≤11×.- No GC regressions:
PERRY_GC_TRACE=1on 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. - 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. - The 8
gc_ratchetprobes plus12_large_live_sethold. cargo testworkspace 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 andPERRY_GC_TRACEare load-independent. - Default
perryoutput is stripped;PERRY_DEBUG_SYMBOLS=1is required or the profile
is all???. - Rebuild runtime and stdlib —
perry-runtimeis rlib-only, the.acomes from the
-staticwrappers. PERRY_NO_AUTO_OPTIMIZE=1on ad-hoc compiles;rm -rf node_modules/.cache/perryafter
switching compilers.- Never
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16for anything measured — it miscompiles
the release runtime. PERRY_WRITE_BARRIERS=0makes 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
proggeramlug commentedon Aug 6, 2026
Folding in the residual half of #7512, per the diagnosis there (#7515 merged the other half).
js_gc_init_typed_shape_layoutis emitted AFTER the constructor call (lower_call/new.rs; the ordering is visible in a--trace llvmdump). The consequence is that no raw-f64-declared class-field store inside any constructor can pass its guard — neither the inlineand i16 %r, 4096INTACT test norclass_field_fast_contract'slayout_typed_raw_f64_slot_for_user. Every such store falls back tojs_put_value_set.The user-visible shape of this is worth stating plainly, because it inverts the usual assumption:
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 holdundefined. 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 }withv: number; w: numberno longer routing throughjs_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.
chore(changelog): rename the #7510 entry to its PR number
perf(gc): skip the per-object layout side tables while they are prova…
chore(changelog): rename the #7510 item-1 entry to its PR number
proggeramlug commentedon Aug 6, 2026
Item 1 (construction) is up as #7532, together with the residual half of #7512 folded in above. 1.553× on
push_clson 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_layoutskips 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 isPOINTER_FREE, byte-identical to what birth already sets. A mistyped store still downgrades through the ordinary guard → boxed setter →layout_note_slotpath; 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_alloccannot be fixed by item 1, because object literals carry no types. HIR rewrites a closed-shape literal tonew __AnonShape_<hash>(…)whose synthesized constructor is a qualifying prologue — but the minted class's field types come out asAny, notNumber. So there was never a raw-f64 store path there to unlock. The corollary is worse than the missed optimisation: sinceAnyis pointer-bearing,{v: number, w: number}is currently declared to the collector as two pointer slots, andjs_gc_init_typed_shape_layoutspends its 13.6% installing that. The literal path's construction cost is a type-propagation gap inmint_anon_shape_class'sfield_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_objectis 3.0% ofchurn_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× onchurn_alloc, so criteria 1–3 were unreachable from items 1–3 as scoped. The current leaf profile is:_tlv_get_addrjs_gc_init_typed_shape_layoutbarrier_child_prologuejs_typed_feedback_class_field_set_guardjs_object_alloc_class_inline_keysjs_array_lengthlayout_note_slotlayout_forget_objectshape_install_sharedRemaining on this ticket: item 3 (
layout_note_slotcompiling 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_propertyunder those names inbenchmarks/), criterion 6 (the 8gc_ratchetprobes plus12_large_live_set), and criterion 7 (the workspacecargo testsweep — I ranperry-runtimeandperry-codegenonly, the latter with a failure set identical toorigin/main's 22 pre-existing).docs(changelog): fragment for #7535 (#7510 item 1)
perf(gc): size the shape-install memo for a 32-shape working set (#7510)
proggeramlug commentedon Aug 6, 2026
Item 1 is in #7535. Two findings worth recording on the ticket itself.
The
#7512residual folded in here is not a runtime relaxation. The reason the pre-ctor reorder fails is usually stated as "fresh slots holdundefined, soinit_typed_shape_layoutrejects them", which is true —undefinedis0x7FFC_…andlayout_raw_f64_bitsrejects any tag in[SHORT_STRING_TAG = 0x7FF9, STRING_TAG = 0x7FFF]. But relaxing that check would be safe for the collector:undefinedis not pointer-bearing, so declaring an unwritten slot raw-f64 strands nothing.What it is not safe for is readers.
class_field_fast_contractspells 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, andmust 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::layoutcost, re-measured after #7535. Symbolicated leaf profile ofchurn_allocon the pinned host, so items 2 and 3 can be scoped against current numbers rather than the ticket's original ones:js_gc_init_typed_shape_layoutshape_install_sharedlayout_note_slot(item 3)layout_forget_object(item 2)layout_note_slotis 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_addr20%,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%.docs(changelog): say why the ordering residual is tracked on #7510, not
perf(gc): make a repeat typed-shape construction a header bit-set (#7510
perf(gc): spread the shape-install memo across mask globals, not just…
docs(changelog): fragment for #7535 (#7510 item 1)
perf(gc): size the shape-install memo for a 32-shape working set (#7510)
docs(changelog): say why the ordering residual is tracked on #7510, not
3 remaining items
perf(codegen,gc): declare a class's typed-shape layout at allocation,…
proggeramlug commentedon Aug 6, 2026
Item 1 landed as #7532 (1.553× on
push_cls, promotion 210,488 → 64 B). Verified intact and green on82be85459after #7535/#7536/#7545 touched the same files.Item 3 is already done — measured, not assumed. Re-profiled
churn_allocon current main andlayout_note_slotis 1 sample out of ~990 (0.1%), reached only throughjs_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_allocleaf profile (main82be85459,PERRY_DEBUG_SYMBOLS=1, ~990 leaf samples):_tlv_get_addrjs_object_alloc_class_inline_keystyped_shape_layout_entrybarrier_child_prologuejs_typed_feedback_class_field_set_guardclass_field_fast_contractjs_array_lengthwrite_barrier_decoded_parentjs_write_barrier_slotlayout_forget_objectlayout_note_slotgc::layoutis 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% istyped_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. Aforinitializer registers its local asType::Any, so{v: base + j, w: j}inside a loop mints an anon shape withAnyfields — 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× onchurn_allocon its own (best-of-9, interleaved; full isolation table in #7547). That is HIR type inference, not layout bookkeeping.For reference,
churn_allocis 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_propertyunder those names inbenchmarks/), criterion 6 (the 8gc_ratchetprobes plus12_large_live_set), criterion 7 (the workspace sweep — I ranperry-runtimegreen at 1797 andperry-codegenwith a failure set identical toorigin/main's).proggeramlug commentedon Aug 7, 2026
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 changechurn_allocmaterially (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_sloton current mainPinned quiet host (M1 mini, load 1.2–1.7),
PERRY_DEBUG_SYMBOLS=1, 6 ×sample … 1 -mayDie, 5,869 leaf samples aggregated from theSort by top of stacksections:_tlv_get_addrjs_object_alloc_class_inline_keysbarrier_child_prologue__AnonShape_…_constructorjs_array_lengthtyped_shape_layout_entrywrite_barrier_decoded_parentchunk)js_write_barrier_slotlayout_forget_objectlayout_note_slotThose 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_layoutcall sites forchurn_alloc— the symbol is not in the linked binary at all. Both halves of the program are already covered without one:{v, w}field stores are statically elided byclass_field_store_needs_layout_note(non-pointer by construction), which perf(hir): type a for-head binding so a loop-built object literal stops being declared as pointer slots (#7544) #7550/perf(hir): type a for-initializer declaration instead of registering it as Any (#7547) #7552 unlocked by giving the minted anon shapeNumberfields instead ofAny;keep.push(obj)pointer store is covered by perf(gc): declare array element layout once at allocation instead of per push #7501's declaration-at-allocation plus perf(runtime): allocation path spends 34% of self time in _tlv_get_addr — 24× behind Node on object churn with the collector already idle #7469's append exception, so the runtime-side note it does reach is a handful of header tests.Emitted note call sites across the bench family,
--trace llvm:js_gc_note_slot_layout…_awarechurn_alloc,churn,churn_read,push_cls,push_cls_read,retainpush_num,churn_numdeeplisttree,cyclesThe ceiling — what item 3 could buy even done perfectly
layout_note_slotstubbed to an unconditional early return (black_boxguarded so the body is not dead-stripped and the inliner still sees it), separate target dir per arm, identical-pset,PERRY_RUNTIME_DIRpinned, same user objects linked against the two runtime archives. Stdout is byte-identical on all four benches. Interleaved best-of-Nuser+sys:churn_alloccyclesdeeplisttreeSo 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_selectionshort-circuits onPOINTER_FREEwithout 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_mask4.17%,layout_forget_object8.99%,LayoutSlotMask::insert0.92% on that bench) — andtree's stores arethis.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, andundeclared_array_enumerates_no_child_slots_which_is_what_the_declaration_buysas the permanent sabotage arm.perry-codegen/tests/typed_shape_descriptors.rs— both directions of the static elision, includingpointer_store_into_numeric_array_keeps_layout_note_and_barrierandmixed_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::layoutis now ~9.4% ofchurn_alloc(typed_shape_layout_entry6.97% +layout_forget_object2.40% +layout_note_slot0.03% +layout_transfer0.03%), so criterion 1 (below 8%) is still just missed. The residue is not per-slot bookkeeping any more:typed_shape_layout_entryat 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_objectat 2.40% is, withPER_OBJECT_LAYOUTS_NONEMPTYproving both maps empty, essentially all thread-local addressing — and_tlv_get_addris 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.
docs(engine-plan): #7510 measured out; _tlv_get_addr is the top lever
docs(engine-plan): #7510 measured out; _tlv_get_addr is the top lever (…
proggeramlug commentedon Aug 9, 2026
All three items shipped and measured out; closing on the recommendation already in this issue's own last comment.
layout_forget_object, via perf(gc): skip the per-object layout side tables while they are provably empty (#7510) #7525 (push_cls1.55×).layout_note_slot, measured down to 2 of 5,869 samples, with zero relevant call sites emitted for the target workload.GC_OBJ_TYPED_LAYOUT_INTACTis live atgc/layout.rs:80and 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.
perf(codegen): stamp a pointer-free shape's typed layout into the all…
perf(codegen): stamp a pointer-free shape's typed layout into the all…
fix(async,gc): close the ReleaseBoxes id-remap holes and re-argue the…
fix(async,gc): close the ReleaseBoxes id-remap holes and re-argue the…
fix(async,gc): close the ReleaseBoxes id-remap holes and re-argue the…
fix(async,gc): close the ReleaseBoxes id-remap holes and re-argue the…
fix(async): release and reuse a completed plain-async activation's bo…
perf(gc): relocation moves only address-keyed layout records (#10362)