Skip to content

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

Closed

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

Activity

  1. added
    performanceRuntime, compile-time, build-size, or memory performance
    on Aug 6, 2026
  2. proggeramlug commented on Aug 7, 2026

    @proggeramlug
    ContributorAuthor

    Barriers are still the top family — but not the barriers this ticket describes. Re-measured on d2dca5823 (after #7536 and #7586), two sample runs each, leaf shares.

    symbol push_cls churn_alloc
    js_write_barrier_slot 14.6% 10.7%
    write_barrier_decoded_parent 7.6% 6.7%
    barrier_child_prologue 5.8% 5.9%
    total 28.0% 23.3%

    Up from the 16.1% in the description — nothing regressed, the rows around them collapsed (#7525, #7532, #7552, #7566, #7586).

    Where the cost actually is. Attributing each barrier symbol to its immediate caller:

    js_write_barrier_slot            133 samples
        131   98.5%  perry_fn_push_cls_ts__chunk
          2    1.5%  main
    
    write_barrier_decoded_parent      49 samples
         47   95.9%  perry_fn_push_cls_ts__chunk
    

    98.5% is called from chunk, the loop function — the constructor does not appear as a barrier caller at all. push_cls_ts__Node_constructor contributes zero. So the declared-number field stores this ticket is about are already barrier-free; #7486 and #7536 got them.

    chunk's only store is keep.push(new Node(base + j, j)) — an array element store of a pointer. That is what the remaining 28% is.

    Both listed remedies therefore miss it:

    1. "Declared-primitive fields" — does not apply. The stored value is a genuine heap pointer, not a number.
    2. "Object-literal initialisation of a fresh object" — does not apply either. The parent is the array, not the fresh object, and the write is an ordinary element store.

    And the tempting third option is unsound. One might try proving the parent array is young: keep is allocated in chunk and never escapes. But the loop allocates 1,000 objects, so minor collections run during it, and keep survives them — it can be promoted mid-loop, at which point the push becomes a real old→young edge. A static "parent is young" claim is false exactly when it matters, which is why the check is dynamic. Related: the description's own note that PERRY_WRITE_BARRIERS=0 cannot bound this cost.

    The actionable lever is different, and already has a precedent in this ticket's own history. #7536 gave class-field stores "one inline test, not three calls". Array element stores never got that treatment — expr/write_barrier.rs::emit_write_barrier_slot_on_block emits a bare js_write_barrier_slot FFI call with no inline pre-test, and the profile shows the resulting three-deep chain (js_write_barrier_slot → barrier_child_prologue → write_barrier_decoded_parent; 78.6% of barrier_child_prologue's samples come from js_write_barrier_slot).

    So the concrete next step is to mirror #7536 onto the array-store path: an inline test that skips the call in the common case, keeping the full barrier for everything it cannot rule out. That does not weaken the invariant — it moves the same decision inline — which matters given criterion 4.

    Suggested amendment to this ticket: replace task items 1 and 2 (both completed or inapplicable) with "extend #7536's inline barrier test to array element stores", and restate the acceptance benchmark, since the <4% target was set against a barrier population that no longer exists on churn_alloc.

    I have not implemented this. Flagging rather than landing it because criterion 4 is right that a wrong answer here corrupts memory, and I would want the inline test's miss-path validated under PERRY_GC_VERIFY_EVACUATION=1 plus the ratchet probes before it goes anywhere near main.

  3. proggeramlug commented on Aug 9, 2026

    @proggeramlug
    ContributorAuthor

    Closed by #7602 — the lever was taken, but the issue's premise about which stores cost was wrong, and that is the useful part.

    This issue asked to elide barriers on provably-non-pointer stores, on the reading that churn_alloc.ts "stores only doubles" so every barrier on it is wasted. Profiling to attribute the 16.1% found otherwise: 98.5% of the barrier cost came from the loop function's keep.push(ptr) — a genuinely pointer-valued array store — and zero from the constructor's numeric field stores. The numeric stores the issue proposed to elide were not the ones paying.

    So the fix that shipped gates the array-push barrier on the parent header (GC_FLAG_TENURED, or incremental-mark active), not on the value's type: 29.6% of push_cls's symbolicated leaf profile, push_cls 1.33–1.38×.

    Worth recording why the original reading was tempting and wrong: "the parent array is young, so the barrier is unnecessary" is unsound — that was checked and rejected during the design. The sound gate is the parent's TENURED bit or an active incremental mark, and the design as first written also missed the SATB shade clause.

    If a fresh profile of an all-numeric store workload still shows barrier cost worth taking, that is a new issue with new numbers rather than this one reopened — this campaign has been sent at the wrong thing by a stale figure five times, and the figures here are two weeks old.

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