You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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):
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:
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.
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
gc::layout symbols fall below 8% of self time on the churn_alloc.ts profile
(from 33.6%).
churn_alloc.ts improves by ≥1.5× (2.44 s → ≤1.6 s) on a quiet host.
churn.ts improves correspondingly; ratio vs Node drops from 16.0× to ≤11×.
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.
The 8 gc_ratchet probes plus 12_large_live_set hold.
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).
Summary
Granular member of the #5094 family. #5094's table covers the access paths
(
this.fieldget/set, array element writes, dynamic property writes). This ticket isthe 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 intoa 1000-element array — a symbolicated profile attributes 33.6% of self time to
gc::layoutper-object side-table maintenance, against 7.7% for the actualallocation.
layout_forget_objectalone 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,sample2 s, 1500 leaf samples, quiet M1mini at load 1.4):
gc::layoutside tableslayout_forget_object(gc/layout.rs:336)layout_note_slot(gc/layout.rs:685)js_gc_init_typed_shape_layout(gc/layout.rs:962)shape_install_*LayoutSlot*_tlv_get_addrjs_array_length,js_array_note_numeric_write)js_object_alloc_class_inline_keys6.3%,arena_alloc*1.4%)Note the interaction with the TLS row:
TYPED_LAYOUTS/LAYOUT_SLOT_MASKSarethread-locals, so an unknown but material share of the 17%
_tlv_get_addris also thissubsystem. #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_layoutis emitted bylower_call/new.rson everyconstruction, and
layout_forget_objectruns on every object death — so the costscales 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)
push_numat 2.7× shows the array machinery is not the problem. Subtracting it, objectconstruction is ~2.14 s, 79% of churn, and ~76% of that is bookkeeping rather than
allocation.
Repro
churn_alloc.tsis ingc-handoff/bench/alongsidepush_num.ts,push_cls.tsandchurn_read.ts(the decomposition set).Task
Apply #5094's "layout is canonical" design to the construction/death path specifically:
js_gc_init_typed_shape_layoutpernewshould become a headerbit-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 everynew#6893 established theper-class interning this can hang off.
layout_forget_objectshould be free when the object never diverged fromits 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 notesthe map is usually empty and skips the hash — but the call, the TLS resolve and the
branch still happen per object.
layout_note_sloton stores into a slot that still matches thecanonical mask should compile away entirely.
The fallback path (object diverged → today's
TYPED_LAYOUTSbehaviour) stays exactly asit 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×.PERRY_GC_TRACE=1on churn still shows ~105 cycles, ~0.004 GBcopied, positive reclamation every cycle, max pause in the low milliseconds;
tree.tsstays at ~43 cycles / ~0.017 GB.
method_calls,bench_numeric_array_downgrade,bench_object_property) — this change should helpthem, but confirm.
gc_ratchetprobes plus12_large_live_sethold.cargo testworkspace sweep green (exclude cross-host UI crates on macOS).Traps
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_TRACEare load-independent.perryoutput is stripped;PERRY_DEBUG_SYMBOLS=1is required or the profileis all
???.perry-runtimeis rlib-only, the.acomes from the-staticwrappers.PERRY_NO_AUTO_OPTIMIZE=1on ad-hoc compiles;rm -rf node_modules/.cache/perryafterswitching compilers.
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16for anything measured — it miscompilesthe release runtime.
PERRY_WRITE_BARRIERS=0makes this benchmark slower (2.44 → 5.21 s) because it alsoswitches 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 objectliteral). Predecessor: #6893 (memory half of this subsystem, closed). Related: #7469
(the remaining
_tlv_get_addrshare), #6759 (V8-style object model).