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:
- 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.
- 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.ts profile (from
16.1%).
churn_alloc.ts improves 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 than churn_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=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.
- 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 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).
Summary
Write barriers cost 16.1% of self time on
gc-handoff/bench/churn_alloc.ts— aprogram 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 typessay 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,sample2 s, 1500 leaf samples, quiet M1mini at load 1.4) of
churn_alloc.ts— 20M{v: number, w: number}literals pushed intoan array:
barrier_child_prologue(gc/barrier.rs:1042)write_barrier_*js_write_barrier_slotincremental_mark_barrier_*js_object_alloc_class_inline_keys+arena_alloc*The benchmark's only stores are
{ v: base + j, w: j }— two f64 fields — plus the arraypush. 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:
{v, w}object literalnew Node(v, w), both fieldsnumberA fixed-shape class with two declared
numberfields is the most statically-knownconstruction 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
Task
Make the barrier compile away where the stored value provably cannot be a GC pointer:
number/boolean(and the raw-f64representation 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.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.
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
churn_alloc.tsprofile (from16.1%).
churn_alloc.tsimproves measurably; combined with the sibling layout ticket, churn'sratio vs Node should drop from 16.0× toward ≤11×.
push_cls.ts(class form) is no longer slower thanchurn_alloc.ts(literal form).old→young edge. Run the full GC test suite,
PERRY_GC_VERIFY_EVACUATION=1andPERRY_GC_VERIFY_MARK=1over the bench set, and thegc_ratchetprobes including12_large_live_set. A missed barrier is a use-after-free, not a slowdown — this is theone place in these three tickets where a wrong answer corrupts memory.
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_allocslower — 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.
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.-staticwrapper crates);PERRY_NO_AUTO_OPTIMIZE=1onad-hoc compiles; never
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16for measured builds.Context
Same profile as the sibling tickets: #7510 (
gc::layoutconstruction-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 phases4a/4b (#6915, #6919).