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).

Activity

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