Repository navigation
perf(gc): write barriers cost 16% on an all-numeric store workload — elide on provably-non-pointer stores #7511
Description
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 rowsTask
Make the barrier compile away where the stored value provably cannot be a GC pointer:
- 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_prologueat 8.9% is the runtime early-out being reached 20M times. - 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. - 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
- Barrier symbols fall below 4% of self time on the
churn_alloc.tsprofile (from
16.1%). churn_alloc.tsimproves measurably; combined with the sibling layout ticket, churn's
ratio vs Node should drop from 16.0× toward ≤11×.push_cls.ts(class form) is no longer slower thanchurn_alloc.ts(literal form).- 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=1and
PERRY_GC_VERIFY_MARK=1over the bench set, and thegc_ratchetprobes 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. - No GC-behaviour drift: churn stays ~105 cycles / ~0.004 GB copied with positive
reclamation every cycle;tree.ts~43 cycles / ~0.017 GB. cargo testworkspace sweep green (exclude cross-host UI crates on macOS).
Traps
PERRY_WRITE_BARRIERS=0cannot be used to measure this. It makeschurn_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=1is required or the profile is unreadable.- Rebuild runtime and stdlib (
-staticwrapper crates);PERRY_NO_AUTO_OPTIMIZE=1on
ad-hoc compiles; neverCARGO_PROFILE_RELEASE_CODEGEN_UNITS=16for 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
docs(changelog): #7511 class-field store bookkeeping guard (PR #7536)
docs(changelog): record the quiet-host A/B for #7511 (churn_alloc 1.18x)
perf(gc): decide a class-field store's GC bookkeeping with one inline…
docs(changelog): #7511 class-field store bookkeeping guard (PR #7536)
docs(changelog): record the quiet-host A/B for #7511 (churn_alloc 1.18x)
perf(gc): decide a class-field store's GC bookkeeping with one inline…
proggeramlug commentedon Aug 7, 2026
Barriers are still the top family — but not the barriers this ticket describes. Re-measured on
d2dca5823(after #7536 and #7586), twosampleruns each, leaf shares.js_write_barrier_slotwrite_barrier_decoded_parentbarrier_child_prologueUp 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:
98.5% is called from
chunk, the loop function — the constructor does not appear as a barrier caller at all.push_cls_ts__Node_constructorcontributes zero. So the declared-numberfield stores this ticket is about are already barrier-free; #7486 and #7536 got them.chunk's only store iskeep.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:
number.And the tempting third option is unsound. One might try proving the parent array is young:
keepis allocated inchunkand never escapes. But the loop allocates 1,000 objects, so minor collections run during it, andkeepsurvives 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 thatPERRY_WRITE_BARRIERS=0cannot 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_blockemits a barejs_write_barrier_slotFFI 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% ofbarrier_child_prologue's samples come fromjs_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=1plus the ratchet probes before it goes anywhere near main.fix(codegen): a typed-array store used as an expression evaluated to 0
perf(gc): gate the array-push write barrier on a live parent-generati…
test(gc): pin the #7511 parent-generation gate from both sides
perf(gc): gate the array-push write barrier on the parent header (#7511…
proggeramlug commentedon Aug 9, 2026
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'skeep.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% ofpush_cls's symbolicated leaf profile,push_cls1.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.
perf(codegen): put the numeric array push's GC bookkeeping behind one…
perf(codegen): put the numeric array push's GC bookkeeping behind one…
perf(codegen): put the static write PIC's GC bookkeeping behind one l…
perf(regex): construction skips the barrier's parent classification
perf(runtime): skip the newborn barrier for closure births while no c…
perf(runtime): skip the newborn barrier for closure births while no c…