You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
perf(gc): write-barrier tower B3 (slot elision) + B4 (generation bit in the header) — the unstarted half of #7187 #7715
The two unstarted follow-ups from #7187, refiled so they stop hiding behind a mostly-finished umbrella.
What already shipped (and is why #7187 closed): Phase A — lazy barrier arming (#7250); Phase B — the dirty-page mark cache (#7298). Both are live.
B3 — slot elision. Skip the write-barrier slot computation where the store provably cannot create an old→young edge. The original analysis identified this as the next lever after the mark cache.
B4 — the generation bit. Carry generation in the object header so classify_heap_generation becomes a header read rather than an arena-range classification.
Why B4 is the interesting one
#7187's own evidence: classify_heap_generation was 19% of batch.ts — 57.4% total GC bookkeeping — with ZERO collections running. That is pure mutator-side cost paid by the write-barrier tower on a program that never collects.
Related and worth reading first: #7511 established that the barrier cost is 98.5% the loop function's keep.push(ptr) and zero from the constructor, and that the "parent array is young" shortcut is unsound. #7602 landed the array-store barrier design that came out of it; its design notes missed the SATB shade clause, so check that before designing anything adjacent.
Before coding
Re-profile. These percentages are days old and this repo has repeatedly found ticket figures stale on re-measure (#7510's went stale three times during its own work; #7698 and #7702 both re-measured lower). Also note PERRY_WRITE_BARRIERS=0cannot be used to bound barrier cost — it changes the GC regime, so the A/B is not measuring what it appears to.
Bench host per the standing rule: the quiet M1 mini.
Re-profiled before coding, as this issue instructs. B4's headline number does not reproduce, and its ceiling is lower than the ticket implies.
Getting a profile at all (worth recording — it cost me an hour)
Perry-compiled binaries carry no local symbol table, so sample resolves every frame as ??? and any profile of them is useless. PERRY_DEBUG_SYMBOLS=1 at compile time fixes it (31,263 symbols vs 0). Anyone re-measuring this family needs that flag first.
The fixture
#7187's figure was measured with zero collections running, which is the property that makes it a mutator-side claim. retain_live_big is not that shape — profiled, it is 82.5% inside gc_collect_minor_with_trigger_inner, so it measures the collector, not the barrier tower. So I built the shape the claim is about: a 64-node fixed live set, no allocation in the loop, 400k × 64 iterations of two pointer-field stores each. PERRY_GC_TRACE=1 confirms 0 collections; 2.06 s of pure barrier-path work.
What it says (perry-dev, inclusive shares, 428 root samples)
symbol
share
js_typed_feedback_class_field_set_guard
31.3%
js_array_get_index_or_string
30.4%
js_array_get_f64
22.0%
js_write_barrier_slot
21.3%
gc::barrier::decode_heap_addr
4.2%
incremental_mark_barrier_v…
1.9%
classify_heap_generation does not appear as its own entry.js_write_barrier_slot profiles as a leaf, so classify_heap_generation — still called at gc/barrier.rs:838 — is inlined into it. It cannot be 19% of anything and be inside a barrier that totals 21.3%; the whole barrier path is now barely above the figure B4 was scoped against.
That reframes B4: it is not "recover 19%", it is "recover some fraction of 21.3%, on a workload chosen to maximise barrier pressure". A header-bit generation read still removes real work, but the arithmetic that justified it no longer holds, and #7511's finding that the barrier cost is 98.5% the loop's keep.push(ptr) points the same way — the cost is concentrated where the store happens, not in classification.
Also visible: on this fixture the typed-feedback field-set guard costs more than the barrier (31.3% vs 21.3%), and the two array-read helpers together cost more than both. If the goal is mutator-side pointer-store throughput, those are the larger levers and neither is in this ticket.
Caveats, stated rather than buried
perry-dev (opt-level=1), which inflates out-of-line runtime helpers relative to --release; the ordering is more trustworthy than the absolute shares. A release profile needs PERRY_DEBUG_SYMBOLS=1 plus a non-stripping link, which I did not set up.
Per this issue's own warning I did not try to bound barrier cost with PERRY_WRITE_BARRIERS=0, since that changes the GC regime rather than isolating the barrier.
Leaving open and rescoped by the numbers rather than closed: B3 (slot elision) is untouched by the above and still stands on its own, and B4 is still a real removal of work — just not a 19% one.
Re-profiled, then B3 shipped as #7959. B4 is measured-negative — recommend closing that half.
Full working notes: gc-handoff/BARRIER-NOTES.md.
Where the barrier still costs, measured (19-program GC corpus, ac52a5c38)
Static census of js_write_barrier{,_slot} call sites (--trace llvm, bucketed
by the block they sit in) plus the dynamic write_barrier trace counters:
program
calls
non_pointer_child
parent_not_old
RS inserts
pipeline
1 265 933
1 265 925 (99.999%)
0
0
asyncpipe
757 360
311 111
0
0
shapes
85 210
37 439
0
0
churn
119 716
0
118 235
0
interp
11 539
678
322
74 pages
iso_miss
11 302
596
172
78 pages
The remembered set is essentially never inserted into on this corpus. The
one big codegen-reachable population is pipeline's: 1.27 M calls from six
ungated idxset.recv_prop.fast sites (this.vals[i] = v in Registry.set),
99.999% of which return at barrier_child_prologue because the value is a
number — and parent_not_old_skips there is 0, so the parent-generation
gate #7511/#7871 already shipped would have skipped nothing.
The barrier at the two array-element store sites now sits behind the value test
and then the parent test, nested value-first (the parent test does a monotonic
load LLVM cannot hoist out of the loop; fusing would charge it to the numeric
stores this exists to make free). cmp says 16/19 corpus binaries identical
and the 3 that differ are exactly the 3 containing the site; pipeline is −2.85% instructions / −3.91% cycles, the other two flat.
One thing that did not transfer from #7511's class-field emitter: its layout
note is inside the value test, but layout_note_slot funnels array::note_element_store (#7480), and a non-pointer stored over a pointer is
exactly the store that must clear GC_ARRAY_ELEMENT_SHAPE. The note stays
outside.
B4 — recommend closing this half
There is no bit to put the generation in.gc_flags is full; gc/types.rs:975 says so in terms — "This is the last bit in the u8
gc_flags. Adding more flags requires extending GcHeader (currently 8 bytes
total — extending breaks ABI everywhere)". _reserved is fully allocated
too (bits 0–2 integrity, 3–5 copy survival age, 6 null-proto, 7 raw-f64, 8
typed-array proto, 9 arguments, 10 array descriptors, 11 HAS_DESCRIPTORS/ARRAY_ELEMENT_SHAPE, 12 RAW_F64_HOLES/TYPED_LAYOUT_INTACT,
14–15 layout state) and already obj_type-overloaded.
GC_FLAG_TENURED already IS the approximate generation bit and is what
codegen tests. What it cannot express is Old exactly, and that gap is churn's 118 235 parent_not_old_skips: objects the non-moving minor
promoted logically (gc/oldgen.rs:860/979/1523 set the bit without moving)
carry it while still living in the nursery. gc/trace.rs:799-810 is explicit
that this state must still be traced, which is why the approximation is sound
— and why closing it needs a genuinely new bit. Outside churn the entire
corpus totals 494 such calls.
A runtime-side !TENURED short-circuit in barrier_parent_needs_remembering
buys nothing either, for the same reason: every call that reaches it got
there by passing the codegen gate, so its parent is already TENURED.
So B4's prize is 118 k early-returning calls on one benchmark, behind a
GcHeader ABI break. The #7187-era "classify_heap_generation is 19% of batch.ts" figure has already been re-measured down twice (the previous comment
on this issue put the whole runtime barrier at 21.3% on a fixture built to
maximise it); this is the third.
What I could not close
asyncpipe's 757 k calls are not codegen-reachable — its census is 6
gated + 53 already-value-gated + 0 ungated, and neither shape can produce a
non-pointer-child call, so they come from runtime-internal callers
(array/alloc.rs, the runtime_store_* helpers, promise/closure capture
stores). Those are same-crate and inlinable; sizing them needs a symbolicated
profile, not another gate.
The opaque emit_write_barrier sites have no parent gate, and adding one is
not a copy-paste: the parent is a box pointer read from a capture slot, and js_box_set_bits deliberately tolerates a stale one
(is_registered_box_ptr before any deref — "a known failure mode for closure
prologues at hub-scale"). An inline parent - 7 read would SIGSEGV exactly
where the runtime silently skips today.
B3 shipped (#7959); B4 recommended for closure as measured-negative
The census that redirected both bullets
Static (--trace llvm) + dynamic write_barrier counters across the 19-program GC corpus:
program
calls
non_pointer_child
parent_not_old
RS inserts
pipeline
1,265,933
1,265,925 (99.999%)
0
0
asyncpipe
757,360
311,111
0
0
churn
119,716
0
118,235
0
interp / iso_miss
~11.4k each
~600
322 / 172
74 / 78 pages
The remembered set is essentially never inserted into. The one large codegen-reachable waste was pipeline's 1.27 M calls from six ungated idxset.recv_prop.fast sites (this.vals[i] = v), 99.999% of which return at barrier_child_prologue because the value is a number — and parent_not_old_skips there is 0, so a parent-gate would have skipped nothing. Hence B3 as shipped is a value gate, nested value-first (fusing into one and would put a non-hoistable monotonic load on the numeric-store path).
Result: pipeline −2.85% instructions / −3.91% cycles, with cmp showing 16/19 corpus binaries identical and the 3 that differ being exactly pipeline/interp/iso_miss — the programs the census says contain the site.
B4: there is no bit, and it would buy nothing
gc_flags is full; gc/types.rs:975 states extending GcHeader breaks ABI everywhere. _reserved is fully allocated and obj_type-overloaded.
GC_FLAG_TENURED already is the approximate generation bit. The gap it cannot express is churn's 118k parent_not_old_skips — logically-promoted-in-nursery objects, which gc/trace.rs:799-810 requires be traced. Outside churn the entire corpus totals 494 such calls.
A runtime-side !TENURED short-circuit buys nothing either: every call reaching that predicate got there by passing the gate.
Recommend closing B4 and keeping this issue open only if the remaining B-items are still wanted.
cargo test -p perry-codegen --lib was 921/921 green while the full cargo test -p perry-codegen was red — typed_shape_descriptors::pointer_store_into_numeric_array_keeps_layout_note_and_barrier asserted the call sat between two labels it no longer sits between. Integration suites under crates/*/tests/ do not run per-PR, so that would have landed green and sat red for days. The test was updated to follow the edge (branch into the gate, call inside it) rather than weakened; full suite now 27/27, exit 0.
Closing with both requested halves disposed by current-main evidence.
B3 shipped in #7959 and is present on main as d78efca: guarded element stores gate the write barrier on the stored value, with edge-based codegen coverage. The measured result and scope are recorded above: pipeline -2.85% instructions / -3.91% cycles, with exactly the predicted corpus binaries changing.
B4 is a measured-negative, not unfinished work:
GC_FLAG_TENURED already is the safe approximate generation bit used by codegen and runtime.
The exact Old-vs-logically-tenured-in-nursery distinction would require a new bit.
gc_flags is full, and extending GcHeader is an ABI-wide change.
The remaining measurable population was 118k early-return calls in churn and 494 across the rest of the corpus, far below the stale 19% premise.
Current main a9a99d8 retains the #7959 implementation and the GC_FLAG_TENURED invariant. Reopening this design would require new measurements that overturn the recorded census, not the original stale headline.
Activity
proggeramlug commentedon Aug 10, 2026
Re-profiled before coding, as this issue instructs. B4's headline number does not reproduce, and its ceiling is lower than the ticket implies.
Getting a profile at all (worth recording — it cost me an hour)
Perry-compiled binaries carry no local symbol table, so
sampleresolves every frame as???and any profile of them is useless.PERRY_DEBUG_SYMBOLS=1at compile time fixes it (31,263 symbols vs 0). Anyone re-measuring this family needs that flag first.The fixture
#7187's figure was measured with zero collections running, which is the property that makes it a mutator-side claim.
retain_live_bigis not that shape — profiled, it is 82.5% insidegc_collect_minor_with_trigger_inner, so it measures the collector, not the barrier tower. So I built the shape the claim is about: a 64-node fixed live set, no allocation in the loop, 400k × 64 iterations of two pointer-field stores each.PERRY_GC_TRACE=1confirms 0 collections; 2.06 s of pure barrier-path work.What it says (perry-dev, inclusive shares, 428 root samples)
js_typed_feedback_class_field_set_guardjs_array_get_index_or_stringjs_array_get_f64js_write_barrier_slotgc::barrier::decode_heap_addrincremental_mark_barrier_v…classify_heap_generationdoes not appear as its own entry.js_write_barrier_slotprofiles as a leaf, soclassify_heap_generation— still called atgc/barrier.rs:838— is inlined into it. It cannot be 19% of anything and be inside a barrier that totals 21.3%; the whole barrier path is now barely above the figure B4 was scoped against.That reframes B4: it is not "recover 19%", it is "recover some fraction of 21.3%, on a workload chosen to maximise barrier pressure". A header-bit generation read still removes real work, but the arithmetic that justified it no longer holds, and #7511's finding that the barrier cost is 98.5% the loop's
keep.push(ptr)points the same way — the cost is concentrated where the store happens, not in classification.Also visible: on this fixture the typed-feedback field-set guard costs more than the barrier (31.3% vs 21.3%), and the two array-read helpers together cost more than both. If the goal is mutator-side pointer-store throughput, those are the larger levers and neither is in this ticket.
Caveats, stated rather than buried
perry-dev(opt-level=1), which inflates out-of-line runtime helpers relative to--release; the ordering is more trustworthy than the absolute shares. A release profile needsPERRY_DEBUG_SYMBOLS=1plus a non-stripping link, which I did not set up.batch.ts. repsel: rule-1 provenance for unbound allocations — the 506 are 96% record literals, and Perry's own CJS IIFE is the wall (#7152 follow-up) #7170's 19.03% was measured on a sort path I could not find ingc-handoff/; if that file still exists somewhere, the comparison should be redone on it before B4 is designed to any number.PERRY_WRITE_BARRIERS=0, since that changes the GC regime rather than isolating the barrier.Leaving open and rescoped by the numbers rather than closed: B3 (slot elision) is untouched by the above and still stands on its own, and B4 is still a real removal of work — just not a 19% one.
proggeramlug commentedon Aug 12, 2026
Re-profiled, then B3 shipped as #7959. B4 is measured-negative — recommend closing that half.
Full working notes:
gc-handoff/BARRIER-NOTES.md.Where the barrier still costs, measured (19-program GC corpus,
ac52a5c38)Static census of
js_write_barrier{,_slot}call sites (--trace llvm, bucketedby the block they sit in) plus the dynamic
write_barriertrace counters:The remembered set is essentially never inserted into on this corpus. The
one big codegen-reachable population is
pipeline's: 1.27 M calls from sixungated
idxset.recv_prop.fastsites (this.vals[i] = vinRegistry.set),99.999% of which return at
barrier_child_prologuebecause the value is anumber — and
parent_not_old_skipsthere is 0, so the parent-generationgate #7511/#7871 already shipped would have skipped nothing.
B3 → #7959
The barrier at the two array-element store sites now sits behind the value test
and then the parent test, nested value-first (the parent test does a
monotonicload LLVM cannot hoist out of the loop; fusing would charge it to the numeric
stores this exists to make free).
cmpsays 16/19 corpus binaries identicaland the 3 that differ are exactly the 3 containing the site;
pipelineis−2.85% instructions / −3.91% cycles, the other two flat.
One thing that did not transfer from #7511's class-field emitter: its layout
note is inside the value test, but
layout_note_slotfunnelsarray::note_element_store(#7480), and a non-pointer stored over a pointer isexactly the store that must clear
GC_ARRAY_ELEMENT_SHAPE. The note staysoutside.
B4 — recommend closing this half
gc_flagsis full;gc/types.rs:975says so in terms — "This is the last bit in the u8gc_flags. Adding more flags requires extending GcHeader (currently 8 bytes
total — extending breaks ABI everywhere)".
_reservedis fully allocatedtoo (bits 0–2 integrity, 3–5 copy survival age, 6 null-proto, 7 raw-f64, 8
typed-array proto, 9 arguments, 10 array descriptors, 11
HAS_DESCRIPTORS/ARRAY_ELEMENT_SHAPE, 12RAW_F64_HOLES/TYPED_LAYOUT_INTACT,14–15 layout state) and already
obj_type-overloaded.GC_FLAG_TENUREDalready IS the approximate generation bit and is whatcodegen tests. What it cannot express is
Oldexactly, and that gap ischurn's 118 235parent_not_old_skips: objects the non-moving minorpromoted logically (
gc/oldgen.rs:860/979/1523set the bit without moving)carry it while still living in the nursery.
gc/trace.rs:799-810is explicitthat this state must still be traced, which is why the approximation is sound
— and why closing it needs a genuinely new bit. Outside
churnthe entirecorpus totals 494 such calls.
!TENUREDshort-circuit inbarrier_parent_needs_rememberingbuys nothing either, for the same reason: every call that reaches it got
there by passing the codegen gate, so its parent is already TENURED.
So B4's prize is 118 k early-returning calls on one benchmark, behind a
GcHeader ABI break. The #7187-era "
classify_heap_generationis 19% ofbatch.ts" figure has already been re-measured down twice (the previous commenton this issue put the whole runtime barrier at 21.3% on a fixture built to
maximise it); this is the third.
What I could not close
asyncpipe's 757 k calls are not codegen-reachable — its census is 6gated + 53 already-value-gated + 0 ungated, and neither shape can produce a
non-pointer-child call, so they come from runtime-internal callers
(
array/alloc.rs, theruntime_store_*helpers, promise/closure capturestores). Those are same-crate and inlinable; sizing them needs a symbolicated
profile, not another gate.
emit_write_barriersites have no parent gate, and adding one isnot a copy-paste: the parent is a box pointer read from a capture slot, and
js_box_set_bitsdeliberately tolerates a stale one(
is_registered_box_ptrbefore any deref — "a known failure mode for closureprologues at hub-scale"). An inline
parent - 7read would SIGSEGV exactlywhere the runtime silently skips today.
perf(codegen): gate the guarded element store's write barrier on the …
perf(codegen): gate the guarded element store's write barrier on the …
proggeramlug commentedon Aug 12, 2026
B3 shipped (#7959); B4 recommended for closure as measured-negative
The census that redirected both bullets
Static (
--trace llvm) + dynamicwrite_barriercounters across the 19-program GC corpus:The remembered set is essentially never inserted into. The one large codegen-reachable waste was
pipeline's 1.27 M calls from six ungatedidxset.recv_prop.fastsites (this.vals[i] = v), 99.999% of which return atbarrier_child_prologuebecause the value is a number — andparent_not_old_skipsthere is 0, so a parent-gate would have skipped nothing. Hence B3 as shipped is a value gate, nested value-first (fusing into oneandwould put a non-hoistablemonotonicload on the numeric-store path).Result:
pipeline−2.85% instructions / −3.91% cycles, withcmpshowing 16/19 corpus binaries identical and the 3 that differ being exactlypipeline/interp/iso_miss— the programs the census says contain the site.B4: there is no bit, and it would buy nothing
gc_flagsis full;gc/types.rs:975states extendingGcHeaderbreaks ABI everywhere._reservedis fully allocated andobj_type-overloaded.GC_FLAG_TENUREDalready is the approximate generation bit. The gap it cannot express ischurn's 118kparent_not_old_skips— logically-promoted-in-nursery objects, whichgc/trace.rs:799-810requires be traced. Outsidechurnthe entire corpus totals 494 such calls.!TENUREDshort-circuit buys nothing either: every call reaching that predicate got there by passing the gate.Recommend closing B4 and keeping this issue open only if the remaining B-items are still wanted.
Two things deliberately not done
layout_note_slotfunnelsarray::note_element_store(repsel: element-shape proofs through arrays — measured 6.2× vs node; route = invariant bit → versioned-loop consumer → element Ptr<Shape> #7480), and a non-pointer stored over a pointer is exactly the store that must clearGC_ARRAY_ELEMENT_SHAPE. That half of perf(gc): write barriers cost 16% on an all-numeric store workload — elide on provably-non-pointer stores #7511's argument does not transfer to arrays.emit_write_barrier(wb.maybe) sites are not a copy-paste of perf(gc): write barriers cost 16% on an all-numeric store workload — elide on provably-non-pointer stores #7511. Their parent is a box pointer from a capture slot, andjs_box_set_bitsdeliberately tolerates a stale one (is_registered_box_ptrbefore any deref). An inlineparent - 7read would SIGSEGV where the runtime silently skips.★ A CI gap this surfaced
cargo test -p perry-codegen --libwas 921/921 green while the fullcargo test -p perry-codegenwas red —typed_shape_descriptors::pointer_store_into_numeric_array_keeps_layout_note_and_barrierasserted the call sat between two labels it no longer sits between. Integration suites undercrates/*/tests/do not run per-PR, so that would have landed green and sat red for days. The test was updated to follow the edge (branch into the gate, call inside it) rather than weakened; full suite now 27/27, exit 0.proggeramlug commentedon Aug 13, 2026
Closing with both requested halves disposed by current-main evidence.
B3 shipped in #7959 and is present on main as d78efca: guarded element stores gate the write barrier on the stored value, with edge-based codegen coverage. The measured result and scope are recorded above: pipeline -2.85% instructions / -3.91% cycles, with exactly the predicted corpus binaries changing.
B4 is a measured-negative, not unfinished work:
Current main a9a99d8 retains the #7959 implementation and the GC_FLAG_TENURED invariant. Reopening this design would require new measurements that overturn the recorded census, not the original stale headline.
perf(codegen): gate array-store GC bookkeeping inline (boolean-store …
perf(codegen): gate array-store GC bookkeeping inline (boolean-store …
perf(codegen): gate array-store GC bookkeeping inline (boolean-store …