Skip to content

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

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. Constructionjs_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. Deathlayout_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 noteslayout_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).

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