Skip to content

perf(gc): write barriers cost 16% on an all-numeric store workload — elide on provably-non-pointer stores #7511

Description

@proggeramlug

Summary

Write barriers cost 16.1% of self time on gc-handoff/bench/churn_alloc.ts — a
program that stores only doubles. Every barrier on that benchmark is wasted work: the
stored values are number-typed fields that can never hold a GC pointer, and the types
say so at compile time.

For scale, on the same profile the actual allocation is 7.7%. Barriers cost more than
twice what allocating costs.

Evidence

Symbolicated profile (PERRY_DEBUG_SYMBOLS=1, sample 2 s, 1500 leaf samples, quiet M1
mini at load 1.4) of churn_alloc.ts — 20M {v: number, w: number} literals pushed into
an array:

symbol share
barrier_child_prologue (gc/barrier.rs:1042) 8.9%
write_barrier_* 3.2%
js_write_barrier_slot 2.9%
incremental_mark_barrier_* 1.1%
barrier total 16.1%
(for comparison) js_object_alloc_class_inline_keys + arena_alloc* 7.7%

The benchmark's only stores are { v: base + j, w: j } — two f64 fields — plus the array
push. No pointer is ever written into an object slot.

Why it is not already elided

#7486 ("elide provably-dead per-store bookkeeping on class-field stores") landed and did
not cover this. The clearest signal is that the class form is worse than the object
literal form on the identical workload:

variant Perry node ratio
churn_alloc — {v, w} object literal 2.44 s 0.14 s 17.4×
push_cls — new Node(v, w), both fields number 3.99 s 0.14 s 28.5×

A fixed-shape class with two declared number fields is the most statically-known
construction form in the language, and it is the slowest. That anomaly is #7512; it is called out here because it is evidence that the existing elision is not
reaching declared-primitive fields.

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

./sym_churn_alloc >/dev/null & P=$!; sleep 0.7
sample $P 2 -mayDie -f /tmp/sym.sample; wait $P
# "Sort by top of stack" — sum the barrier_* / write_barrier_* / js_write_barrier_slot rows

Task

Make the barrier compile away where the stored value provably cannot be a GC pointer:

  1. Declared-primitive fields. A field annotated number / boolean (and the raw-f64
    representation repsel already selects for them) can never hold a pointer. Codegen should
    emit the store with no barrier at all, not a barrier that returns early at runtime.
    barrier_child_prologue at 8.9% is the runtime early-out being reached 20M times.
  2. Object-literal initialisation of a fresh object. Stores into an object that has not
    yet escaped the constructor cannot create an old→young edge, because the object is by
    construction in the nursery and younger than anything it can point at. Initialising
    stores should skip the barrier wholesale — this is the standard "initializing write"
    exemption.
  3. Confirm the interaction with repsel. Phases 4a/4b already select unboxed
    representations for numeric fields; if a slot is known raw-f64 the barrier is provably
    dead. If that information is available at the store site and simply not consulted, this
    may be a small change.

Keep the barrier for anything not provably primitive. The goal is removing it from the
statically-known cases, not weakening the invariant.

Acceptance criteria

  1. Barrier symbols fall below 4% of self time on the churn_alloc.ts profile (from
    16.1%).
  2. churn_alloc.ts improves measurably; combined with the sibling layout ticket, churn's
    ratio vs Node should drop from 16.0× toward ≤11×.
  3. push_cls.ts (class form) is no longer slower than churn_alloc.ts (literal form).
  4. GC correctness is non-negotiable. The remembered set must still capture every real
    old→young edge. Run the full GC test suite, PERRY_GC_VERIFY_EVACUATION=1 and
    PERRY_GC_VERIFY_MARK=1 over the bench set, and the gc_ratchet probes including
    12_large_live_set. A missed barrier is a use-after-free, not a slowdown — this is the
    one place in these three tickets where a wrong answer corrupts memory.
  5. No GC-behaviour drift: churn stays ~105 cycles / ~0.004 GB copied with positive
    reclamation every cycle; tree.ts ~43 cycles / ~0.017 GB.
  6. cargo test workspace sweep green (exclude cross-host UI crates on macOS).

Traps

  • PERRY_WRITE_BARRIERS=0 cannot be used to measure this. It makes churn_alloc
    slower — 2.44 s → 5.21 s — because it also switches the collector out of evacuating
    mode (evacuation requires generated barriers active). The 16.1% figure is from the
    profile only; I could not isolate it experimentally, and neither will you via that knob.
  • Do not benchmark on the dev Mac while builds run (load 15–140 there). Use
    ssh perry@perry-macos.local (M1, 8 cores, idles ~1.5); ship the static binaries over,
    no toolchain needed. Fallback: best-of-N user CPU, within ~5% of the quiet host.
  • PERRY_DEBUG_SYMBOLS=1 is required or the profile is unreadable.
  • Rebuild runtime and stdlib (-static wrapper crates); PERRY_NO_AUTO_OPTIMIZE=1 on
    ad-hoc compiles; never CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 for measured builds.

Context

Same profile as the sibling tickets: #7510 (gc::layout construction-path cost,
33.6%, under umbrella #5094) and #7512 (class-vs-literal anomaly). Related: #7486 (the elision that did not
reach this), #7469 (_tlv_get_addr, 17.0% on the same profile), repsel phases
4a/4b (#6915, #6919).

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