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
gc/perf: classify_heap_generation is 19% of batch.ts (57.4% total GC bookkeeping) with ZERO collections running — write-barrier tower needs its own lever (#5094 evidence) #7187
The batch.ts ranked profile (Pi 5, perf record instructions:u, THROTTLE==0 asserted, 99.85% coverage; full account on #7170) found:
57.4% of the program is GC bookkeeping — and zero collections run (PERRY_GC_DIAG silent, no mark/sweep symbol appears). The largest single symbol is classify_heap_generation at 19.03% (657M instructions) — 2.3× the next symbol — called from the write barrier on the sort's own element stores. The layout side-table machinery adds another 12.91%.
Per comparison: ~2,597 instructions, of which sort scaffolding (dominated by barrier/layout bookkeeping) costs 1,495 — more than the user's comparator logic (1,103).
Why this needs its own lever
The planned levers were mapped against the profile: #6759 (shape tree) squarely hits the 12.91% layout side-table; #7151/#7170-R1 hit generated-code costs. Nothing planned addresses rank 1. A write barrier that spends 19% of the program classifying heap generations for a heap that has never collected is pure overhead by construction.
Candidate directions (to be designed, not prescribed):
utf16_cmp_bytes (string/compare.rs:15) runs from_utf8 validation + UTF-16 re-encode on BOTH operands per comparison — an ASCII fast path deletes ~3.1% of total program instructions (separate, trivial PR).
The vdispatch-tower lever is NOT supported by this workload: comparator dispatch resolves a direct fn pointer once (array/sort.rs:23); total closure dispatch 0.08%.
Design: where the 19.03% actually goes, and the correction the lazy-arming premise needs
Read-only session (see §9 — the local disk floor blocked the one build, so nothing
here is landed). Everything below is derived from the code at d8be5f4a2 plus the
profile posted on #7170; anything I could not derive is called out in §8.
1. The premise in the issue statement is wrong, and the fix is the interesting part
"until the first minor collection, there is no young/old distinction worth recording"
There is. arena_alloc_gc (arena/allocators.rs:241) routes any allocation whose
padded total exceeds LARGE_OBJECT_THRESHOLD_BYTES (16 KiB, gc/types.rs:69)
straight to arena_alloc_gc_old and stamps GC_FLAG_TENURED. Old-gen exists
from the first large allocation, before any collector has ever run — and batch.ts is exactly that case: N = 40_000 elements × 8 B = 320 KiB, so copy
and its merge scratch are both born-old, while every Shaped record they hold is
nursery. Every one of the sort's element moves is a genuine old→young edge that
the first minor will need.
This is not hypothetical: the comment on note_array_slot_layout_only
(array/header.rs:1556) is the postmortem of the last time this was got wrong — "155 edges, all born-old array→young object; slot_page_ever_dirty=false = the
store never hit any barrier", JSON.parse filling born-old arrays, children swept
live, value is not a function. A barrier that no-ops behind a naive "nothing has
collected yet" flag reproduces that bug exactly, and it reproduces it on the
workload that motivated this issue.
So lazy arming is still right, but its contract has to be stated on the
collection side, not the barrier side:
While unarmed, the barrier records nothing. In exchange, the first collection
on a thread does not read the remembered set at all — it reconstructs the
old→young edge set by walking old-gen + the malloc registry — and arms the
barrier on the way out.
Equivalently: unarmed ⇒ every old page is dirty. That is sound by construction
rather than by audit, and it is bounded: during the unarmed window old-gen holds
only born-old objects (nothing has been promoted, because nothing has collected),
each ≥ 16 KiB, so the walk is at most old_bytes / 16 KiB objects. On batch.ts
that is 2 objects.
The primitive already exists and is already used by full cycles: OldToYoungRememberedRebuildState (gc/verify.rs:269), reached via rebuild_minor_old_to_young_remembered_set(). It is budgeted/steppable, it already
filters on barrier_parent_needs_remembering, and it already covers both the arena
cursor and MALLOC_STATE.objects. The unarmed first collection wants a narrower
variant (old-gen arena only, via old_arena_walk_objects, plus the malloc
registry the minor already walks for its sweep) rather than the whole-heap ArenaObjectCursor.
2. The two halves, separated at the call site
The good news: the split is already physical in the source. The never-skippable
half and the skippable half are adjacent statements, not interleaved logic.
This is#7179. Objects are born POINTER_FREE; heap_payload_slot_selection short-circuits on that state and skips the entire payload. A slot whose pointer was never recorded is invisible to the FIRST minor — not imprecise, invisible. Arming has nothing to do with it: the layout state is consumed by every collection including the first.
Representation invariant (an INT32 box landing in a raw-f64 masked slot permanently evicts the typed descriptor). Runs before the store, unrelated to generations.
js_string_addref on STRING_TAG (gc/barrier.rs:1287)
Aliasing/refcount contract for in-place js_string_append. Nothing to do with GC generations.
SATB/insertion shading for an in-progress incremental cycle. Note this one is insidewrite_barrier_slot_inner, before the parent decode — the arming check must sit after it, and structurally it can never be a problem (an incremental cycle implies a collection has run implies armed), but the ordering must be written down or a later refactor will hoist the check. runtime_write_barrier_slot's existing !write_barriers_enabled() arm already demonstrates the intended separation: it skips the barrier and still calls incremental_mark_barrier_value.
Skippable while unarmed — all of it inside write_barrier_slot_inner after the
child decode and the incremental probe:
the EVER_DIRTY_OLD_PAGES note inside them (diagnostic-only, env-gated)
A third site that is neither, and is the reason to be careful: the post-cycle
remembered-set verifier (gc/verify.rs:155 "#5029 repair", old_young_slot_covered
/ old_young_external_slot_covered, and PERRY_GC_FROMSPACE_SCAN) asserts that
every old→young slot it finds was covered by the cycle's dirty snapshot. Under
lazy arming, on the first cycle, none of them are — the coverage comes from the
reconstruct, not the snapshot. If the verifier is not taught the unarmed
contract it will either fire spuriously or, worse, be relaxed into uselessness.
The correct change is to make the reconstruct populate the snapshot the verifier
reads, so the verifier's predicate is unchanged and stays load-bearing.
3. What classify_heap_generation costs, and why it is called four times per store
classify_heap_generation (arena/page_meta.rs:438) is: null check → addr >> 20
→ single-entry thread-local PAGE_GENERATION_CACHE probe (key + range containment)
→ on miss, PAGE_GENERATIONS.with(RefCell::borrow) → HashMap::get(&key) → PageGenerationSlot::find (linear over a Vec in the Multiple arm) → write the
cache.
Call count per barriered element store on the batch.ts sort path (parent =
born-old array, child = nursery Shaped, i.e. the slow path all the way through —
which the #7170 callgraph confirms, since mark_dirty_old_page at 6.73% is only
reachable when the parent classifies Old):
#
site
operand
1 MiB bucket
1
decode_heap_addr(parent) (gc/barrier.rs:1088)
parent
old
2
barrier_parent_needs_remembering (:1040)
parent
old
3
remembered_child_needs_tracking (:1016)
child
nursery
4
remember_old_to_young_slot (:1106)
slot
old
decode_heap_addr(child) costs nothing here — the child is POINTER_TAG-boxed and
takes the tag fast path.
Call #1 is pure redundancy.runtime_write_barrier_slot receives parent_addr: usize — already decoded — and hands it to js_write_barrier_slot as a bare u64, so decode_heap_addr falls into its "possible raw pointer" arm and pays a
full classify_heap_generation just to re-derive a value the caller already had.
The other two runtime entry points do not do this: runtime_write_barrier_external_slot
(:1293) and runtime_write_barrier_gc_slot (:1310) both POINTER_TAG | the
address first.
Call #3 defeats the cache. The cache is one entry, shared with classify_heap_space. Parent and slot live in the born-old array's bucket; the
child lives in a nursery bucket. So the sequence per store is old, old(hit), nursery(evict), old(evict) — a deterministic two-address thrash on
a one-entry cache. Two of four calls take the full hashmap path by construction,
every store, forever.
Per-call cost, from the posted numbers. The sort's element moves are
~1.06 M (insertion phase: 1250 runs × 32, ~291 k swaps × 2 sets; merge phase:
11 bottom-up passes × 40 000; plus the 40 000-element copy-back). At 4 calls per
store that is ~4.2 M calls against 657 M instructions ⇒ ~155 instructions per classify_heap_generation call, averaged over hits and misses. For a function
whose fast path is a shift and two compares, that is the miss path dominating.
Where the miss path's instructions go — one predicted contributor worth naming. PageGenerationMap is HashMap<usize, PageGenerationSlot, BuildHasherDefault<IdentityHasher>>
with a localIdentityHasher (page_meta.rs:112) whose write_usize is self.0 = value as u64 — no mixing. std's HashMap is hashbrown: the bucket index
comes from the low bits of the hash and the control byte h2 from hash >> 57. Keys here are addr >> 20 ≈ 2²⁶–2²⁷, so h2 == 0 for every
entry in the table. Every SIMD group probe therefore matches every occupied
slot in the group, and each match costs a real key comparison — a scattered load
into a ~40-byte bucket — before the right one is found. With ~70–130 buckets live
(one per 1 MiB region: a 64 MiB nursery plus old blocks) that is on the order of
4–8 spurious key compares per lookup.
The codebase already diagnosed this exact failure mode and shipped the fix
elsewhere: fast_hash.rs's module doc, and its mix(h) = h ^ (h >> 32) avalanche
step, whose comment records a 455 ms → 830 ms regression from omitting it. PAGE_GENERATIONS never got it because it grew its own hasher.
This h2 argument is reasoned from hashbrown's construction, not measured — see §8.
4. The armed check
Storage: a process-global static AtomicBool, not a thread-local. Arming is
monotone, one-way, and conservative-safe in the early direction, so a global is
sound: a relaxed load of a static is adrp/ldr, whereas a thread_local!
costs _tlv_get_addr — a call — on macOS. Given this is a per-heap-store path
on the project's primary dev host, the TLS form would eat a visible share of what
it saves.
Per-thread bookkeeping stays thread-local: a Cell<bool> recording whether this thread has collected, which is what decides "reconstruct on this
collection". The two can disagree safely. If thread B arms globally first,
thread A's barrier arms early (it stops being free, nothing more) and A's own
first collection still reconstructs, because A's per-thread flag is still false.
The two edge cases are a superset and a no-op respectively; there is no window
where an edge is neither recorded nor reconstructed.
Placement: inside write_barrier_slot_inner, immediately after incremental_mark_barrier_value(child) and before the parent decode.
On the pointer-child path it is ~3 instructions against a current minimum of
~40 (one cached classify) and a realistic ~600 (four calls, two missing). Once
armed it is a permanently-taken, perfectly-predicted branch.
Arming trigger: the end of the first collection on the thread — specifically
after remembered_set_clear(), so the armed window and the cleared-log window
start together and there is no gap in which a store is neither remembered nor
covered by the reconstruct.
5. Verification — the gate must be able to observe its own subject
Per the doctrine and #7166's [gc-scan-fallback] census, "nothing crashed" is not
a verdict here. Three assertions, all cheap:
A census line, under the existing PERRY_GC_TRACE/PERRY_GC_DIAG surface: barrier_armed=<0|1> reconstructed_edges=<n> rs_pages=<n> per cycle. The
existing BarrierTraceCounters (gc/barrier.rs:880) is the right home — it
already counts OldToYoungSlowHits, NewInserts, ParentNotOldSkips.
A sabotage arm, in the fix(gc): record layout + write barrier on raw pointer publishes into traced payload slots #7179 style: a unit test that suppresses the
reconstruct and asserts the born-old-array→young-object case fails. A test
that passes with and without the reconstruct is testing nothing. The natural
fixture is the one the bug already has a name for — a born-old array filled with
young children, first minor, copied_objects > 0 kept true by a rooted young
canary so the liveness gate cannot be satisfied by the edge under test.
6. Constraints answered
#7161 / non-moving default — does the classification become useless work? No. The non-moving minor consumes the remembered set exactly as the evacuating
one does. remembered_child_needs_tracking's own doc comment
(gc/barrier.rs:1015) states the reason: minors sweep both the nursery and the
malloc registry, and old parents are black leaves in a minor, so an unremembered
old→nursery edge leaves the child unmarked and it is swept while live. The two
consumers are mark_remembered_set_roots (gc/barrier.rs:41, via RootScanSubphase::RememberedSet in gc/cycle.rs:667) and the copying path's snapshot.dirty_old_pages walk (gc/copying.rs:791). Lazy arming helps both
modes identically, and #7161's default flip does not change the calculus.
RSS. The remembered set starting empty is a real but second-order win. Its
size is bounded by pages touched, not objects: DIRTY_OLD_PAGES is a PtrHashSet<usize> of 4 KiB page numbers, so batch.ts's 320 KiB array
contributes ~80 entries. The larger structural win is EXTERNAL_DIRTY_SLOT_PAGES
(PtrHashMap<usize, Vec<usize>> — a growing Vec of owning headers per external
page) and, on the test path, REMEMBERED_SET. The genuinely important RSS
statement is different and simpler: a program that never collects — which is most
of what Perry compiles, CLIs and short request handlers — currently builds and
retains a remembered set it will never read. Under lazy arming it allocates none of
it.
#7154 boundary.#7154 is now closed, and the residual work jdalton is on has
moved out of gc/ entirely — #7188 (try_imported_array_methods folding an
imported binding's .map to Expr::ArrayMap on method name alone), #7189
(export * as ns), #7190 (defineProperty(Class, "name")), #7191 (spread method
call on an imported-array receiver). All HIR/lowering. No overlap with barrier or
policy code. The one file worth a same-day check before landing is gc/verify.rs, because #7179's from-space scan lives there and §2's third bullet
touches it.
7. A and B: composition, and what lands first
They are complementary, and neither subsumes the other:
A removes the pre-collection cost. On batch.ts that is essentially the whole
barrier: the 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 callgraph puts 28.12% of the program inclusive under write_barrier_slot_inner (classify 19.20 + mark_dirty_old_page 6.73 + 2.19
self), of which everything except the child decode, the incremental probe, and
the new armed check is skipped. ~26% of the program, deleted, on a workload
that runs zero collections. For a long-running server it buys only the startup
window.
B caps the post-arming cost, which is what a server pays forever.
Order — B first, then A. Three reasons:
B's first two items are provably semantics-free (see §8 for what "provably" does
and does not cover here) and can land, be measured, and be believed on their own.
A changes when the collector reads its remembered set, which is the highest-blast-radius
category of change in this repo.
B shrinks A's residual: after B, the classify calls that survive arming are
cheap, so A's remaining benefit is honestly attributable to the skip rather than
to accidentally dodging a bad hash function.
A's correct form (§1) needs a reconstruct path and a verifier update; B item 1
is a signature change and item 2 is a type alias.
B, in increment order:
B1 — stop re-deriving a parent address the caller already decoded. runtime_write_barrier_slot should reach write_barrier_slot_inner with parent_addr intact instead of round-tripping it through decode_heap_addr's
raw-pointer arm. Cleanest form is to split write_barrier_slot_inner into a ..._decoded(parent_addr: usize, …) core that js_write_barrier_slot calls after
decoding; the ~18 non-test Rust callers then all skip call Support custom menu bar items #1. Behaviour is identical, and the one case worth checking closes: today a
malloc-GC parent reaching this path classifies Unknown, so decode_heap_addr
returns 0 and the barrier exits at NonPointerParentSkips. After the change it
reaches barrier_parent_needs_remembering(parent_addr, external_slot = false),
which classifies Unknown, is not Old, and external_slot is false — so it
exits at ParentNotOldSkips. Same outcome, one fewer classify. (The
external/malloc parents that do need remembering arrive through runtime_write_barrier_external_slot/_gc_slot, which already tag their parent
and are untouched.) Removes 1 of 4 classify calls on every runtime-path barriered store: ~25% of a
19.03% symbol ≈ 4.7% of batch.ts, for a signature change.
B2 — give PAGE_GENERATIONS the project's own pointer hasher. Delete IdentityHasher (used for nothing else) and make type PageGenerationMap = crate::fast_hash::PtrHashMap<usize, PageGenerationSlot>;.
Costs one multiply + xor per lookup; buys back the h2 degeneracy of §3. PAGE_GENERATIONS is only ever point-queried (get/get_mut/insert/remove
at page_meta.rs:367/392/451/483 — the for key in first..=last loops walk key ranges, not the map), so iteration order is not observable and there is no
determinism exposure.
B3 — the slot operand is redundant given the parent. By the time remember_old_to_young_slot runs, barrier_parent_needs_remembering has already
established the parent is Old. For the non-external path the slot is interior to
the parent's own allocation, and an allocation never straddles a generation
boundary — so classify_heap_generation(slot) can be replaced by an interior
test against [parent_header, parent_header + size), which the function already
has the header for. Removes call useEffect + setState panics with RefCell already borrowed on macOS ARM64 #4. Slightly more argument than B1/B2, so it is
a separate PR.
B4 (structural, not next) — a physical generation bit in GcHeader. This is
the O(1)-loadable answer for the two object operands, and it would collapse
calls linux compilation and README #2 and Using the fetch API #3 to a byte load from a header already in cache. GC_FLAG_TENURED
cannot be reused for it: gc/oldgen.rs:973/1084/1518 set it on an age bump
while the object is still physically in the nursery, and gc/trace.rs:731
explicitly ORs "physical region orGC_FLAG_TENURED" precisely because the
two differ. A new, physically-maintained bit means touching every promotion and
evacuation site — real GC-internals work, memory-corruption class, and not a
first increment.
8. Composition with #6759, and what does not overlap
They overlap in diagnosis and barely at all in mechanism.
Concretely: after B1+B3, one classify per store remains (the child); B2 makes it
cheap; B4 or #6759-A makes it a load. A removes it entirely until the first
collection. There is no double-counting between the two campaigns because they own
different side tables.
9. Unmeasured / unverified, plainly
Nothing was built and nothing was run. The local disk sat at ~9 GB free for
this session, below the 15 GB floor this work was scoped under (the same
constraint reported on GC: evacuating minor drops an old-to-young field[1] edge, crashing with 'value is not a function' #7154's thread: "local disk sat between 8 and 13 GB free
for the whole session"). B1 and B2 are ~20 lines together and are one build away;
they are deliberately not pushed as an unbuilt, untested GC-internals PR.
The h2 degeneracy in §3 is reasoned from hashbrown's construction, not
profiled. The claim "h2 == 0 for every key" follows from IdentityHasher
returning the key verbatim and hashbrown taking the control byte from hash >> 57;
the consequence (4–8 spurious key compares per lookup) is an estimate from the
live-bucket count, not a measurement. perf stat on the Pi, B2 alone, settles it.
The split of the 28.12% barrier subtree between skippable and non-skippable is
read off the callgraph, not separately measured. The incremental_mark_barrier_value
share attributable to this subtree (vs. the 1.85% total) is not broken out.
Whether B1's benefit survives on workloads whose barrier parent is nursery.
The redundant call Support custom menu bar items #1 is paid by every runtime-path barriered store regardless of
outcome, so it should — but batch.ts is the only workload with a profile.
The first collection's reconstruct cost on a heap with a large born-old
population (many JSON.parse results, large typed arrays). Bounded by old_bytes / 16 KiB objects, but not measured, and it is a one-time latency spike
rather than throughput.
aarch64 only, single-threaded only. The global-AtomicBool-vs-thread-local
argument in §4 is about macOS _tlv_get_addr and is not measured on either host.
Increment 1 landed as a PR: #7193 (direction B, items B1 + B2)
Per the plan above: B before A. #7193 takes B1 (stop re-deriving the parent address) and B2 (the page-map hasher). Phase A (lazy arming) stays a design here pending its own dispatch. Three things the implementation changed about what is written above.
1. B1 was not purely a runtime_write_barrier_slot question — the round-trip was FILTERING
§7's B1 argued the malloc-GC-parent case through runtime_write_barrier_slot (bare u64 → decode_heap_addr's raw-pointer arm → classify → Unknown → 0). That analysis holds. What it missed is the other two entry points, which NaN-box:
For an address with high bits set, that OR produces something whose tag is no longerPOINTER_TAG, so decode_heap_addr returned 0 and the barrier exited early. That accident was load-bearing: closure/dynamic_props.rs parks props under non-address owner keys (its own unit test uses 0xC10C_AB1E_0000_1803) and reaches runtime_write_barrier_external_slot with them. Removing the round-trip SIGSEGVs there — malloc_gc_parent_addr dereferences parent - GC_HEADER_SIZE behind nothing but a < GC_HEADER_SIZE + 0x1000 floor.
So B1 ships with the filter made explicit (barrier_parent_addr_is_dereferenceable = addr_class::is_plausible_heap_addr + 8-alignment), applied in the entry point and inside malloc_gc_parent_addr itself so the function that dereferences carries its own guard. That is a strict improvement on the prior state, which left the NaN-boxed callers able to hand a plausible-but-unaligned parent to a header read.
Worth recording as a general lesson for the rest of this campaign: §2's "the split is already physical in the source" is true for layout-vs-barrier, and false for decode-vs-filter.decode_heap_addr is named as a decoder and doubles as the barrier's only address validator. Phase A's arming check sits after it and so is unaffected, but any future increment that reorders or skips that call inherits this.
2. The h2 degeneracy is no longer a prediction
§3 flagged it as "reasoned from hashbrown's construction, not profiled", and §9 listed it as unmeasured. It is now measured, by a unit test with the old hasher reinstated:
hashbrown control byte must vary across generation class keys,
got 1 distinct values from 64 consecutive buckets (an identity hasher yields 1)
Every generation-class key in the table shared control byte 0, exactly as predicted. The consequence (4–8 spurious key comparisons per lookup) remains an estimate — that needs a profile.
3. Matrix + ratchet state, since both bear on Phase A's verification design
Both gates ran on #7193 (--arms all --pressure 8; full log on the PR). Two findings that Phase A's §5 will have to plan around:
test_gap_repsel_p4a3_ptr_numarray fails on all ten requires=move arms at origin/main — untriaged, not in gc_repsel_triage.txt, signature output-mismatch cycles=2 evacuated=0 scavenged=…. Any barrier change now lands on a matrix that is already red in exactly the arms that exercise the barrier under relocation. Probably gc-matrix: test_gap_repsel_p4a3_numarray_growth never crosses a GC trigger, so all 19 of its GC cells assert nothing #7016's neighbourhood. Someone should own it before Phase A, or Phase A's matrix evidence will be read through the same ten cells.
The good news for Phase A's §5: the requires=move arms bit on 28/29 corpus files, so the machinery to prove an arming flip actually goes live does work. The proposed requires=arm ladder rung still stands.
Unchanged
§1 (born-old breaks the "no young/old distinction pre-collection" premise; the contract belongs on the collection side), §2's layout/barrier split, §4's global-AtomicBool-not-thread-local argument, §6's #7161 answer (the non-moving minor consumes the remembered set identically), and §8's #6759 relationship all stand as written. B3 (the slot operand is redundant given the parent) and B4 (a physical generation bit — notGC_FLAG_TENURED) remain unstarted.
Phase A is in #7250, and the "self-inflicted preamble" lead is a false lead — the split is 0% / 100%
Two things to record here: the preamble question, answered; and the counts the
lever actually removes, measured rather than estimated.
1. The CJS preamble contributes 0% of rank 1. Two different things share the word "barrier"
The brief for this work opened with cjs_wrap/wrap.rs's Object.defineProperty(require, 'name', …) preamble as the first thing to check,
on the reading that it "armed the write barrier in 100% of modules" — carried
forward from #7139/#7152/#7170.
It does arm a barrier in 100% of CommonJS modules. It is not this one.
The preamble arms the compile-time Ptr<Shape> promotion barrier: ptr_shape_report.rs's MODULE_BARRIER / rule 5, "the module contains a
shape barrier (Object.defineProperty, delete, setPrototypeOf/__proto__
write, new Proxy, or a mutating Reflect.*). The first-increment rule
disables ALL Ptr promotion in the module regardless of the barrier's
target." Raised at collectors/ptr_shape.rs:306; exempted for the wrap
scaffolding by collectors/cjs_scaffolding.rs (perf(repsel): exempt CommonJS module scaffolding from the Ptr<Shape> rule-5 barrier #7139), present on current main.
Rank 1 is the GC write barrier's remembered-set maintenance — classify_heap_generation reached from runtime_write_barrier_slot. wrap.rs contains zero write-barrier references, in either direction.
And batch.ts is a plain .ts entry that is never CJS-wrapped at all: zero __perry_cjs_require strings in its compiled binary.
Split: 0% preamble, 100% intrinsic runtime barrier. Phase A is therefore a
runtime fix, and no compiler-side work is on this path. Worth flagging for the
rest of the campaign: three separate issues have now spent effort on "the CJS
wrapper is arming a barrier", and the word is overloaded between a repsel
promotion hazard and a GC remembered-set log.
2. The counts, measured on the base binary
batch.ts, macOS arm64 (M1 Pro), release build of origin/main @ 8b024958f.
Counts read out of the existing PERRY_GC_TRACE write-barrier counters via a
trailing collect() — they accumulate from process start and are dumped by the
first cycle, so one trailing collection reports the whole program. Counts, not
timings: this host sat at load average 40–55 for the entire session (other
agents building), so nothing here is a wall-clock claim.
Collections on unmodified batch.ts: zero, PERRY_GC_DIAG silent —
replicating #7170 on a second host and a second architecture.
counter
count
calls
2,265,777
non_pointer_child_skips (the #6011 fast path, untouched)
280,536
reaching the remembered-set half
1,985,241 (87.6%)
parent_not_old_skips
211,497
old_to_young_slow_hits
1,773,744
dirty_page_mark_attempts
1,773,744
new_dirty_pages
517
That pins two of §9's "unmeasured" items on the design comment above:
mark_dirty_old_page is 99.971% redundant even when armed. 1,773,744
attempts produced 517 new dirty pages. That is the 6.73% symbol, and it is
a standing B-family target independent of arming: a page already in DIRTY_OLD_PAGES is re-hashed and re-inserted ~3,400 times on average.
3. What #7250 does, and the correction §1 of the design demanded
The design comment's §1 is right and #7250 implements it: the naive
"nothing has collected, so there is no old generation" premise is false, and the
contract is stated on the reading side.
While unarmed the barrier records nothing. In exchange, the first read of the
remembered set on a thread does not trust the log at all — it reconstructs the
complete old→young edge set from the heap and arms the barrier on the way past.
The placement that makes this enforceable rather than auditable: remembered_dirty_snapshot() is the sole read path. Every collector —
the budgeted/full cycle's RememberedSetRootMarkState, the copying nursery fast
path and its preflight, the cycle's pre-clear coverage snapshot, the
evacuation verifier — obtains the dirty set there; the only other touches of DIRTY_OLD_PAGES / EXTERNAL_DIRTY_SLOT_PAGES / REMEMBERED_SET are the
barrier's own writes, the clear, and diagnostics, and the arena's per-page dirty bit feeds old_page_summary telemetry only. Arming inside that one
function means no collector can observe an unarmed — therefore empty — log.
This also disposes of §2's third bullet (the #5029 verifier). No verifier change
was needed: it reaches its snapshot through the same function, so by the time it
runs, the reconstruct has. Its predicate stays load-bearing and untouched.
Two things the design left open that the implementation had to decide:
Arm first, walk second. A store landing while the reconstruct is in flight
must be logged, because the walk may already have passed its parent.
Branch census on batch.ts: unarmed_skips = 1,985,241 — matching the base's calls − non_pointer_child_skips to the unit — with old_to_young_slow_hits, parent_not_old_skips and dirty_page_mark_attempts all 0, and reconstructs = 1 recovering 393 old pages (fewer than the 517 the barrier
logged: the reconstruct records only edges live at collection time, so it is
strictly more precise). A probe that collects before the sort and again at the
end reports the armed steady state counter-for-counter identical across arms.
Compiled binary −32,936 bytes. Max RSS unchanged.
The RSS argument in §6 does not show up here. 517 page numbers is a few
KB against a 24 MB RSS. It may matter on a workload that dirties far more
old pages, unmeasured.
The first collection's reconstruct cost is not measured as latency. It is
one synchronous whole-heap header walk per thread — 220,318 objects on batch.ts — and full cycles already perform the identical walk every cycle
(AtomicFinalizeSubphase::RememberedSetRebuild). Narrowing it to old-gen +
the malloc registry is sound (barrier_parent_needs_remembering admits
nothing else) and is the obvious follow-up; not done in perf(gc): stop maintaining a remembered set nothing has read yet (#7187 Phase A) #7250.
aarch64/macOS, single-threaded, one workload. The multi-thread argument —
a worker armed early by another thread's collection still reconstructs on its
own first read, because that flag is thread-local — is implemented and
reasoned, not exercised by a test.
B3 (slot-operand elision) and B4 (a physical generation bit in GcHeader, which GC_FLAG_TENURED cannot serve) remain unstarted.
Phase B is in #7298: mark_dirty_old_page collapses 1,773,744 → 3,243, page set unchanged
Recording the numbers and, more usefully, the one measurement that changed the
design.
The batch.ts measurement has to be re-armed after Phase A
The 1,773,744 / 517 figures in the comment above were taken at 8b024958f, before#7250. On current mainbatch.ts reaches mark_dirty_old_page zero times — the barrier is never armed, because the program never collects.
Phase B's subject does not exist on that workload as shipped, which is worth
saying plainly: this lever pays on programs that have collected, and batch.ts has to be armed (a leading perry/gccollect()) to see it at all.
Armed, it reproduces the pre-Phase-A traffic counter-for-counter.
The mechanism was picked from the page sequence, not from intuition
Instrumenting the exact sequence of pages the barrier hands to mark_dirty_old_page and simulating cache shapes over it:
shape
hit rate
calls left
1-entry
99.7817%
3,873
2-entry LRU
99.8730%
2,253
4-entry LRU
99.8730%
2,253
8 / 32-entry direct-mapped
99.8730%
2,253
512-entry direct-mapped
99.9423%
1,023
2048-entry direct-mapped
99.9531%
832
The redundancy is consecutive: 3,872 same-page runs over 1,774,374 calls,
mean 458, longest 13,803. One entry captures essentially all of it, and every
larger shape buys ≤0.17 further percentage points for more state on a path that
runs on every heap store. A one-usize thread-local last-page cache it is.
Before / after, same workload, one target dir per arm
Every pre-existing write_barrier counter is identical across the arms
(calls 2,265,777; non_pointer_child_skips 280,536; parent_not_old_skips
211,497; old_to_young_slow_hits and dirty_page_mark_attempts 1,773,744; new_dirty_pages and new_inserts 517). The only difference is the new dirty_page_cache_hits: 0 → 1,770,501, i.e. calls reaching the modbuf and
the arena metadata go 1,773,744 → 3,243.
Page set proven unchanged in-process rather than by diffing two ASLR'd runs:
an instrumented build recorded both the set of pages the barrier asked to mark
and the set that actually reached the recording body, and asserted equality —
517 in both arms, PAGE_SET_EQUAL=true in both.
GC matrix (--arms pr, 52-file corpus): FAIL=0, exit 0, liveness gate green
with movement asserted — safepoint_minor copy-minor 35/52, evac_minor 51/52, force_verify 51/52.
A new B-family candidate, deliberately NOT taken
remember_old_to_young_slot runs classify_heap_generation(slot_addr)
immediately before the mark. Hoisting the cache guard above it would remove a
further 1.77M calls to the rank-1 symbol — but only under the extra premise
"page P is in the cache ⇒ a slot in P still classifies Old", which a block
freed mid-cycle can falsify. Skipping the classify when that premise is false
skips mark_dirty_parent_span, i.e. loses an edge. That is heap corruption for
a perf win, so #7298 stops at the airtight boundary: the guard only elides the
exact call whose effect it caches. Worth doing properly (a generation epoch, or
B4's physical generation bit, would make the premise checkable in O(1)) — filing
it here rather than sneaking it in.
B3 (slot-operand elision) and B4 (a physical generation bit in GcHeader)
remain unstarted.
Closing — Phase A and Phase B both shipped; the two follow-ups this issue named were never started and are refiled so they are visible as work rather than as the tail of a finished ticket.
Shipped: Phase A, lazy barrier arming (#7250). Phase B, the dirty-page mark cache (#7298). Both live on main.
Refiled:#7715 carries B3 (slot elision) and B4 (generation bit in the object header), together with this issue's headline evidence — classify_heap_generation at 19% of batch.ts, 57.4% total GC bookkeeping, with zero collections running — and the two traps that go with it: #7511's finding that the barrier cost is 98.5% the loop's keep.push(ptr) and that the "parent array is young" shortcut is unsound, and the fact that PERRY_WRITE_BARRIERS=0 cannot bound barrier cost because it changes the GC regime.
Anyone picking it up should re-profile first. Every percentage in both issues is days old, and figures in this repo have gone stale under far less.
Activity
proggeramlug commentedon Aug 1, 2026
Design: where the 19.03% actually goes, and the correction the lazy-arming premise needs
Read-only session (see §9 — the local disk floor blocked the one build, so nothing
here is landed). Everything below is derived from the code at
d8be5f4a2plus theprofile posted on #7170; anything I could not derive is called out in §8.
1. The premise in the issue statement is wrong, and the fix is the interesting part
There is.
arena_alloc_gc(arena/allocators.rs:241) routes any allocation whosepadded total exceeds
LARGE_OBJECT_THRESHOLD_BYTES(16 KiB,gc/types.rs:69)straight to
arena_alloc_gc_oldand stampsGC_FLAG_TENURED. Old-gen existsfrom the first large allocation, before any collector has ever run — and
batch.tsis exactly that case:N = 40_000elements × 8 B = 320 KiB, socopyand its merge scratch are both born-old, while every
Shapedrecord they hold isnursery. Every one of the sort's element moves is a genuine old→young edge that
the first minor will need.
This is not hypothetical: the comment on
note_array_slot_layout_only(
array/header.rs:1556) is the postmortem of the last time this was got wrong —"155 edges, all born-old array→young object;
slot_page_ever_dirty=false= thestore never hit any barrier",
JSON.parsefilling born-old arrays, children sweptlive,
value is not a function. A barrier that no-ops behind a naive "nothing hascollected yet" flag reproduces that bug exactly, and it reproduces it on the
workload that motivated this issue.
So lazy arming is still right, but its contract has to be stated on the
collection side, not the barrier side:
Equivalently: unarmed ⇒ every old page is dirty. That is sound by construction
rather than by audit, and it is bounded: during the unarmed window old-gen holds
only born-old objects (nothing has been promoted, because nothing has collected),
each ≥ 16 KiB, so the walk is at most
old_bytes / 16 KiBobjects. Onbatch.tsthat is 2 objects.
The primitive already exists and is already used by full cycles:
OldToYoungRememberedRebuildState(gc/verify.rs:269), reached viarebuild_minor_old_to_young_remembered_set(). It is budgeted/steppable, it alreadyfilters on
barrier_parent_needs_remembering, and it already covers both the arenacursor and
MALLOC_STATE.objects. The unarmed first collection wants a narrowervariant (old-gen arena only, via
old_arena_walk_objects, plus the mallocregistry the minor already walks for its sweep) rather than the whole-heap
ArenaObjectCursor.2. The two halves, separated at the call site
The good news: the split is already physical in the source. The never-skippable
half and the skippable half are adjacent statements, not interleaved logic.
array/header.rs:1545:runtime_store_jsvalue_slot(gc/barrier.rs:1262) has the identical shape.Never skippable — the #7179 class, one line per site:
layout_note_slot(gc/barrier.rs:1289,array/header.rs:1550)POINTER_FREE;heap_payload_slot_selectionshort-circuits on that state and skips the entire payload. A slot whose pointer was never recorded is invisible to the FIRST minor — not imprecise, invisible. Arming has nothing to do with it: the layout state is consumed by every collection including the first.canonicalize_{array_numeric,typed_slot}_store_bitsjs_string_addrefonSTRING_TAG(gc/barrier.rs:1287)js_string_append. Nothing to do with GC generations.incremental_mark_barrier_value(child)(gc/barrier.rs:974)write_barrier_slot_inner, before the parent decode — the arming check must sit after it, and structurally it can never be a problem (an incremental cycle implies a collection has run implies armed), but the ordering must be written down or a later refactor will hoist the check.runtime_write_barrier_slot's existing!write_barriers_enabled()arm already demonstrates the intended separation: it skips the barrier and still callsincremental_mark_barrier_value.Skippable while unarmed — all of it inside
write_barrier_slot_innerafter thechild decode and the incremental probe:
barrier_parent_needs_remembering(parent_addr, external_slot)remembered_child_needs_tracking(child_addr)remember_old_to_young_slot/remember_old_to_young_external_slotmark_dirty_old_page/mark_dirty_parent_span/mark_dirty_external_slot_pageEVER_DIRTY_OLD_PAGESnote inside them (diagnostic-only, env-gated)A third site that is neither, and is the reason to be careful: the post-cycle
remembered-set verifier (
gc/verify.rs:155"#5029 repair",old_young_slot_covered/
old_young_external_slot_covered, andPERRY_GC_FROMSPACE_SCAN) asserts thatevery old→young slot it finds was covered by the cycle's dirty snapshot. Under
lazy arming, on the first cycle, none of them are — the coverage comes from the
reconstruct, not the snapshot. If the verifier is not taught the unarmed
contract it will either fire spuriously or, worse, be relaxed into uselessness.
The correct change is to make the reconstruct populate the snapshot the verifier
reads, so the verifier's predicate is unchanged and stays load-bearing.
3. What
classify_heap_generationcosts, and why it is called four times per storeclassify_heap_generation(arena/page_meta.rs:438) is: null check →addr >> 20→ single-entry thread-local
PAGE_GENERATION_CACHEprobe (key + range containment)→ on miss,
PAGE_GENERATIONS.with(RefCell::borrow)→HashMap::get(&key)→PageGenerationSlot::find(linear over aVecin theMultiplearm) → write thecache.
Call count per barriered element store on the
batch.tssort path (parent =born-old array, child = nursery
Shaped, i.e. the slow path all the way through —which the #7170 callgraph confirms, since
mark_dirty_old_pageat 6.73% is onlyreachable when the parent classifies
Old):decode_heap_addr(parent)(gc/barrier.rs:1088)barrier_parent_needs_remembering(:1040)remembered_child_needs_tracking(:1016)remember_old_to_young_slot(:1106)decode_heap_addr(child)costs nothing here — the child isPOINTER_TAG-boxed andtakes the tag fast path.
Call #1 is pure redundancy.
runtime_write_barrier_slotreceivesparent_addr: usize— already decoded — and hands it tojs_write_barrier_slotas a bareu64, sodecode_heap_addrfalls into its "possible raw pointer" arm and pays afull
classify_heap_generationjust to re-derive a value the caller already had.The other two runtime entry points do not do this:
runtime_write_barrier_external_slot(
:1293) andruntime_write_barrier_gc_slot(:1310) bothPOINTER_TAG |theaddress first.
Call #3 defeats the cache. The cache is one entry, shared with
classify_heap_space. Parent and slot live in the born-old array's bucket; thechild lives in a nursery bucket. So the sequence per store is
old, old(hit), nursery(evict), old(evict)— a deterministic two-address thrash ona one-entry cache. Two of four calls take the full hashmap path by construction,
every store, forever.
Per-call cost, from the posted numbers. The sort's element moves are
~1.06 M (insertion phase: 1250 runs × 32, ~291 k swaps × 2 sets; merge phase:
11 bottom-up passes × 40 000; plus the 40 000-element copy-back). At 4 calls per
store that is ~4.2 M calls against 657 M instructions ⇒ ~155 instructions per
classify_heap_generationcall, averaged over hits and misses. For a functionwhose fast path is a shift and two compares, that is the miss path dominating.
Where the miss path's instructions go — one predicted contributor worth naming.
PageGenerationMapisHashMap<usize, PageGenerationSlot, BuildHasherDefault<IdentityHasher>>with a local
IdentityHasher(page_meta.rs:112) whosewrite_usizeisself.0 = value as u64— no mixing. std'sHashMapis hashbrown: the bucket indexcomes from the low bits of the hash and the control byte
h2fromhash >> 57. Keys here areaddr >> 20≈ 2²⁶–2²⁷, soh2 == 0for everyentry in the table. Every SIMD group probe therefore matches every occupied
slot in the group, and each match costs a real key comparison — a scattered load
into a ~40-byte bucket — before the right one is found. With ~70–130 buckets live
(one per 1 MiB region: a 64 MiB nursery plus old blocks) that is on the order of
4–8 spurious key compares per lookup.
The codebase already diagnosed this exact failure mode and shipped the fix
elsewhere:
fast_hash.rs's module doc, and itsmix(h) = h ^ (h >> 32)avalanchestep, whose comment records a 455 ms → 830 ms regression from omitting it.
PAGE_GENERATIONSnever got it because it grew its own hasher.This h2 argument is reasoned from hashbrown's construction, not measured — see §8.
4. The armed check
static AtomicBool, not a thread-local. Arming ismonotone, one-way, and conservative-safe in the early direction, so a global is
sound: a relaxed load of a
staticisadrp/ldr, whereas athread_local!costs
_tlv_get_addr— a call — on macOS. Given this is a per-heap-store pathon the project's primary dev host, the TLS form would eat a visible share of what
it saves.
Cell<bool>recording whetherthis thread has collected, which is what decides "reconstruct on this
collection". The two can disagree safely. If thread B arms globally first,
thread A's barrier arms early (it stops being free, nothing more) and A's own
first collection still reconstructs, because A's per-thread flag is still false.
The two edge cases are a superset and a no-op respectively; there is no window
where an edge is neither recorded nor reconstructed.
write_barrier_slot_inner, immediately afterincremental_mark_barrier_value(child)and before the parent decode.cbnz.It is not free, and two things bound it:
child_addr == 0early return, so theoverwhelmingly common store — any number into any slot — pays zero
additional instructions. That early return is simple EMA (Exponential Moving Average) loop over a 100k-element array runs **7x slower** in the compiled Perry binary (2300ms) compared to tsx (328ms) or node (343ms). #6011's fast path and this design
must not disturb it.
~40 (one cached classify) and a realistic ~600 (four calls, two missing). Once
armed it is a permanently-taken, perfectly-predicted branch.
js_write_barrier_slot; putting the check in the Rust callee keeps thecodegen/runtime gate-pair problem (fix(gc): disable evacuating minor by default pending #7154 (use-after-free on dynamically-added fields) #7161's "both gates must agree") from
existing at all. Inlining it into generated code is a later, separate question.
Arming trigger: the end of the first collection on the thread — specifically
after
remembered_set_clear(), so the armed window and the cleared-log windowstart together and there is no gap in which a store is neither remembered nor
covered by the reconstruct.
5. Verification — the gate must be able to observe its own subject
Per the doctrine and #7166's
[gc-scan-fallback]census, "nothing crashed" is nota verdict here. Three assertions, all cheap:
PERRY_GC_TRACE/PERRY_GC_DIAGsurface:barrier_armed=<0|1> reconstructed_edges=<n> rs_pages=<n>per cycle. Theexisting
BarrierTraceCounters(gc/barrier.rs:880) is the right home — italready counts
OldToYoungSlowHits,NewInserts,ParentNotOldSkips.scripts/gc_repsel_matrix.shliveness requirement. The harness already hasthe
collect/scavenge/moverequires=ladder built for exactly this reason(gc-matrix: --pressure disables the very path #7019 added — the 'default' arm runs ZERO copying minors on all 22 corpus rows #7024/gc-matrix: the 'moved' liveness counter sums the C4b mark-sweep evacuation with the copying minor, so a move-arm can be green with zero scavenged objects #7025). Lazy arming needs a fourth:
requires=arm— the arm is greenonly if some cycle reports
barrier_armed=1and a later cycle reports anon-empty remembered set. Without the second clause a workload that collects
once certifies nothing about the armed steady state. The
requires=movearms(
evac_minor,force_evac,loop_polls, and the sixrep_*_offrows) alreadyexercise the moving path via
PERRY_GC_MOVING_LOOP_POLLS=1since fix(gc): disable evacuating minor by default pending #7154 (use-after-free on dynamically-added fields) #7161, so thepost-arm evacuating path has coverage; it just needs the arming assertion added.
reconstruct and asserts the born-old-array→young-object case fails. A test
that passes with and without the reconstruct is testing nothing. The natural
fixture is the one the bug already has a name for — a born-old array filled with
young children, first minor,
copied_objects > 0kept true by a rooted youngcanary so the liveness gate cannot be satisfied by the edge under test.
6. Constraints answered
#7161 / non-moving default — does the classification become useless work?
No. The non-moving minor consumes the remembered set exactly as the evacuating
one does.
remembered_child_needs_tracking's own doc comment(
gc/barrier.rs:1015) states the reason: minors sweep both the nursery and themalloc registry, and old parents are black leaves in a minor, so an unremembered
old→nursery edge leaves the child unmarked and it is swept while live. The two
consumers are
mark_remembered_set_roots(gc/barrier.rs:41, viaRootScanSubphase::RememberedSetingc/cycle.rs:667) and the copying path'ssnapshot.dirty_old_pageswalk (gc/copying.rs:791). Lazy arming helps bothmodes identically, and #7161's default flip does not change the calculus.
RSS. The remembered set starting empty is a real but second-order win. Its
size is bounded by pages touched, not objects:
DIRTY_OLD_PAGESis aPtrHashSet<usize>of 4 KiB page numbers, sobatch.ts's 320 KiB arraycontributes ~80 entries. The larger structural win is
EXTERNAL_DIRTY_SLOT_PAGES(
PtrHashMap<usize, Vec<usize>>— a growingVecof owning headers per externalpage) and, on the test path,
REMEMBERED_SET. The genuinely important RSSstatement is different and simpler: a program that never collects — which is most
of what Perry compiles, CLIs and short request handlers — currently builds and
retains a remembered set it will never read. Under lazy arming it allocates none of
it.
#7154 boundary. #7154 is now closed, and the residual work jdalton is on has
moved out of
gc/entirely — #7188 (try_imported_array_methodsfolding animported binding's
.maptoExpr::ArrayMapon method name alone), #7189(
export * as ns), #7190 (defineProperty(Class, "name")), #7191 (spread methodcall on an imported-array receiver). All HIR/lowering. No overlap with barrier or
policy code. The one file worth a same-day check before landing is
gc/verify.rs, because #7179's from-space scan lives there and §2's third bullettouches it.
7. A and B: composition, and what lands first
They are complementary, and neither subsumes the other:
batch.tsthat is essentially the wholebarrier: the 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 callgraph puts 28.12% of the program inclusive under
write_barrier_slot_inner(classify 19.20 +mark_dirty_old_page6.73 + 2.19self), of which everything except the child decode, the incremental probe, and
the new armed check is skipped. ~26% of the program, deleted, on a workload
that runs zero collections. For a long-running server it buys only the startup
window.
Order — B first, then A. Three reasons:
and does not cover here) and can land, be measured, and be believed on their own.
A changes when the collector reads its remembered set, which is the highest-blast-radius
category of change in this repo.
cheap, so A's remaining benefit is honestly attributable to the skip rather than
to accidentally dodging a bad hash function.
is a signature change and item 2 is a type alias.
B, in increment order:
B1 — stop re-deriving a parent address the caller already decoded.
runtime_write_barrier_slotshould reachwrite_barrier_slot_innerwithparent_addrintact instead of round-tripping it throughdecode_heap_addr'sraw-pointer arm. Cleanest form is to split
write_barrier_slot_innerinto a..._decoded(parent_addr: usize, …)core thatjs_write_barrier_slotcalls afterdecoding; the ~18 non-test Rust callers then all skip call Support custom menu bar items #1.
Behaviour is identical, and the one case worth checking closes: today a
malloc-GC parent reaching this path classifies
Unknown, sodecode_heap_addrreturns 0 and the barrier exits at
NonPointerParentSkips. After the change itreaches
barrier_parent_needs_remembering(parent_addr, external_slot = false),which classifies
Unknown, is notOld, andexternal_slotis false — so itexits at
ParentNotOldSkips. Same outcome, one fewer classify. (Theexternal/malloc parents that do need remembering arrive through
runtime_write_barrier_external_slot/_gc_slot, which already tag their parentand are untouched.)
Removes 1 of 4 classify calls on every runtime-path barriered store: ~25% of a
19.03% symbol ≈ 4.7% of
batch.ts, for a signature change.B2 — give
PAGE_GENERATIONSthe project's own pointer hasher. DeleteIdentityHasher(used for nothing else) and maketype PageGenerationMap = crate::fast_hash::PtrHashMap<usize, PageGenerationSlot>;.Costs one multiply + xor per lookup; buys back the h2 degeneracy of §3.
PAGE_GENERATIONSis only ever point-queried (get/get_mut/insert/removeat
page_meta.rs:367/392/451/483— thefor key in first..=lastloops walk keyranges, not the map), so iteration order is not observable and there is no
determinism exposure.
B3 — the slot operand is redundant given the parent. By the time
remember_old_to_young_slotruns,barrier_parent_needs_rememberinghas alreadyestablished the parent is
Old. For the non-external path the slot is interior tothe parent's own allocation, and an allocation never straddles a generation
boundary — so
classify_heap_generation(slot)can be replaced by an interiortest against
[parent_header, parent_header + size), which the function alreadyhas the header for. Removes call useEffect + setState panics with RefCell already borrowed on macOS ARM64 #4. Slightly more argument than B1/B2, so it is
a separate PR.
B4 (structural, not next) — a physical generation bit in
GcHeader. This isthe O(1)-loadable answer for the two object operands, and it would collapse
calls linux compilation and README #2 and Using the fetch API #3 to a byte load from a header already in cache.
GC_FLAG_TENUREDcannot be reused for it:
gc/oldgen.rs:973/1084/1518set it on an age bumpwhile the object is still physically in the nursery, and
gc/trace.rs:731explicitly ORs "physical region or
GC_FLAG_TENURED" precisely because thetwo differ. A new, physically-maintained bit means touching every promotion and
evacuation site — real GC-internals work, memory-corruption class, and not a
first increment.
8. Composition with #6759, and what does not overlap
They overlap in diagnosis and barely at all in mechanism.
V8 has 2–3 dependent loads — describes
PAGE_GENERATIONSperfectly. But Architecture: adopt V8's object-model construction — explicit runtime state, self-describing headers, shape tree (phases A–C) #6759'sfix does not reach it:
PAGE_GENERATIONSis keyed by 1 MiB page bucket, notby object, so Phase B's
ObjectMeta(which hangs off anObjectHeaderslot)has nowhere to put it. B4 above is the equivalent move for generation, and it is
a
GcHeaderbit rather than anObjectMetafield — a different header, on adifferent budget (
GcHeaderis 8 bytes with sparegc_flagsbits and a free_reserved: u16).RuntimeStatefetch per runtime entry point instead of oneTLS access per table access) does apply directly, and it is the part that helps
most on macOS: classify's two
LocalKey::withcalls become field accesses on apointer the entry point already holds. That is a straight multiplier on whatever
B1–B3 leave behind, and it needs no coordination with this work.
touched by anything here — §2 exists to keep it that way.
Concretely: after B1+B3, one classify per store remains (the child); B2 makes it
cheap; B4 or #6759-A makes it a load. A removes it entirely until the first
collection. There is no double-counting between the two campaigns because they own
different side tables.
9. Unmeasured / unverified, plainly
this session, below the 15 GB floor this work was scoped under (the same
constraint reported on GC: evacuating minor drops an old-to-young field[1] edge, crashing with 'value is not a function' #7154's thread: "local disk sat between 8 and 13 GB free
for the whole session"). B1 and B2 are ~20 lines together and are one build away;
they are deliberately not pushed as an unbuilt, untested GC-internals PR.
profiled. The claim "
h2 == 0for every key" follows fromIdentityHasherreturning the key verbatim and hashbrown taking the control byte from
hash >> 57;the consequence (4–8 spurious key compares per lookup) is an estimate from the
live-bucket count, not a measurement.
perf staton the Pi, B2 alone, settles it.(657 M from 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, ~1.06 M element stores derived from the sort's own structure).
The store count is not instrumented; a counter on
note_array_slotwould pin it.read off the callgraph, not separately measured. The
incremental_mark_barrier_valueshare attributable to this subtree (vs. the 1.85% total) is not broken out.
The redundant call Support custom menu bar items #1 is paid by every runtime-path barriered store regardless of
outcome, so it should — but
batch.tsis the only workload with a profile.population (many
JSON.parseresults, large typed arrays). Bounded byold_bytes / 16 KiBobjects, but not measured, and it is a one-time latency spikerather than throughput.
AtomicBool-vs-thread-localargument in §4 is about macOS
_tlv_get_addrand is not measured on either host.proggeramlug commentedon Aug 1, 2026
Increment 1 landed as a PR: #7193 (direction B, items B1 + B2)
Per the plan above: B before A. #7193 takes B1 (stop re-deriving the parent address) and B2 (the page-map hasher). Phase A (lazy arming) stays a design here pending its own dispatch. Three things the implementation changed about what is written above.
1. B1 was not purely a
runtime_write_barrier_slotquestion — the round-trip was FILTERING§7's B1 argued the malloc-GC-parent case through
runtime_write_barrier_slot(bareu64→decode_heap_addr's raw-pointer arm → classify →Unknown→ 0). That analysis holds. What it missed is the other two entry points, which NaN-box:For an address with high bits set, that OR produces something whose tag is no longer
POINTER_TAG, sodecode_heap_addrreturned 0 and the barrier exited early. That accident was load-bearing:closure/dynamic_props.rsparks props under non-address owner keys (its own unit test uses0xC10C_AB1E_0000_1803) and reachesruntime_write_barrier_external_slotwith them. Removing the round-trip SIGSEGVs there —malloc_gc_parent_addrdereferencesparent - GC_HEADER_SIZEbehind nothing but a< GC_HEADER_SIZE + 0x1000floor.So B1 ships with the filter made explicit (
barrier_parent_addr_is_dereferenceable=addr_class::is_plausible_heap_addr+ 8-alignment), applied in the entry point and insidemalloc_gc_parent_addritself so the function that dereferences carries its own guard. That is a strict improvement on the prior state, which left the NaN-boxed callers able to hand a plausible-but-unaligned parent to a header read.Worth recording as a general lesson for the rest of this campaign: §2's "the split is already physical in the source" is true for layout-vs-barrier, and false for decode-vs-filter.
decode_heap_addris named as a decoder and doubles as the barrier's only address validator. Phase A's arming check sits after it and so is unaffected, but any future increment that reorders or skips that call inherits this.2. The h2 degeneracy is no longer a prediction
§3 flagged it as "reasoned from hashbrown's construction, not profiled", and §9 listed it as unmeasured. It is now measured, by a unit test with the old hasher reinstated:
Every generation-class key in the table shared control byte 0, exactly as predicted. The consequence (4–8 spurious key comparisons per lookup) remains an estimate — that needs a profile.
3. Matrix + ratchet state, since both bear on Phase A's verification design
Both gates ran on #7193 (
--arms all --pressure 8; full log on the PR). Two findings that Phase A's §5 will have to plan around:test_gap_repsel_p4a3_ptr_numarrayfails on all tenrequires=movearms atorigin/main— untriaged, not ingc_repsel_triage.txt, signatureoutput-mismatch cycles=2 evacuated=0 scavenged=…. Any barrier change now lands on a matrix that is already red in exactly the arms that exercise the barrier under relocation. Probably gc-matrix: test_gap_repsel_p4a3_numarray_growth never crosses a GC trigger, so all 19 of its GC cells assert nothing #7016's neighbourhood. Someone should own it before Phase A, or Phase A's matrix evidence will be read through the same ten cells.checkreportsminor_cycles = 0andheap_used_bytes +5333%on every probe, because the artifact was pinned while the evacuating minor was default-ON and fix(gc): disable evacuating minor by default pending #7154 (use-after-free on dynamically-added fields) #7161 flipped it default-OFF. It cannot currently give a verdict on anything. (And its workflow's last runs onmainare allcancelled— CLAUDE.md failure mode Using the fetch API #3.) perf(gc): stop the runtime write barrier re-deriving its parent address (#7187 increment 1) #7193 substitutes a direct branch-vs-base A/B plus a base-vs-base run, which is the shape Phase A should use too: base-vs-base is what proves the metric can register a difference at all, and without it "0 differences" is indistinguishable from a dead gate.The good news for Phase A's §5: the
requires=movearms bit on 28/29 corpus files, so the machinery to prove an arming flip actually goes live does work. The proposedrequires=armladder rung still stands.Unchanged
§1 (born-old breaks the "no young/old distinction pre-collection" premise; the contract belongs on the collection side), §2's layout/barrier split, §4's global-
AtomicBool-not-thread-local argument, §6's #7161 answer (the non-moving minor consumes the remembered set identically), and §8's #6759 relationship all stand as written. B3 (the slot operand is redundant given the parent) and B4 (a physical generation bit — notGC_FLAG_TENURED) remain unstarted.perf(gc): stop the runtime write barrier re-deriving its parent addre…
proggeramlug commentedon Aug 2, 2026
Phase A is in #7250, and the "self-inflicted preamble" lead is a false lead — the split is 0% / 100%
Two things to record here: the preamble question, answered; and the counts the
lever actually removes, measured rather than estimated.
1. The CJS preamble contributes 0% of rank 1. Two different things share the word "barrier"
The brief for this work opened with
cjs_wrap/wrap.rs'sObject.defineProperty(require, 'name', …)preamble as the first thing to check,on the reading that it "armed the write barrier in 100% of modules" — carried
forward from #7139/#7152/#7170.
It does arm a barrier in 100% of CommonJS modules. It is not this one.
Ptr<Shape>promotion barrier:ptr_shape_report.rs'sMODULE_BARRIER/ rule 5, "the module contains ashape barrier (
Object.defineProperty,delete,setPrototypeOf/__proto__write,
new Proxy, or a mutatingReflect.*). The first-increment ruledisables ALL Ptr promotion in the module regardless of the barrier's
target." Raised at
collectors/ptr_shape.rs:306; exempted for the wrapscaffolding by
collectors/cjs_scaffolding.rs(perf(repsel): exempt CommonJS module scaffolding from the Ptr<Shape> rule-5 barrier #7139), present on currentmain.classify_heap_generationreached fromruntime_write_barrier_slot.wrap.rscontains zero write-barrier references, in either direction.batch.tsis a plain.tsentry that is never CJS-wrapped at all: zero__perry_cjs_requirestrings in its compiled binary.Split: 0% preamble, 100% intrinsic runtime barrier. Phase A is therefore a
runtime fix, and no compiler-side work is on this path. Worth flagging for the
rest of the campaign: three separate issues have now spent effort on "the CJS
wrapper is arming a barrier", and the word is overloaded between a repsel
promotion hazard and a GC remembered-set log.
2. The counts, measured on the base binary
batch.ts, macOS arm64 (M1 Pro), release build oforigin/main@8b024958f.Counts read out of the existing
PERRY_GC_TRACEwrite-barrier counters via atrailing
collect()— they accumulate from process start and are dumped by thefirst cycle, so one trailing collection reports the whole program. Counts, not
timings: this host sat at load average 40–55 for the entire session (other
agents building), so nothing here is a wall-clock claim.
Collections on unmodified
batch.ts: zero,PERRY_GC_DIAGsilent —replicating #7170 on a second host and a second architecture.
callsnon_pointer_child_skips(the #6011 fast path, untouched)parent_not_old_skipsold_to_young_slow_hitsdirty_page_mark_attemptsnew_dirty_pagesThat pins two of §9's "unmeasured" items on the design comment above:
stores × 4 classifies ≈ 4.2M calls and derived ~155 instructions per
classify_heap_generation. The real figure is 1,985,241 barrier bodies,giving 5,532,729 classify calls post-perf(gc): stop the runtime write barrier re-deriving its parent address (#7187 increment 1) #7193 (211,497 × 1 on the
parent-not-old exit; 1,773,744 × 3 — parent, child, slot — on the slow path)
and 7,517,970 pre-perf(gc): stop the runtime write barrier re-deriving its parent address (#7187 increment 1) #7193. Against 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 657M instructions that is
~87 instructions per call, not ~155. The symbol share is unchanged; only
the per-call cost estimate moves, and it moves down, which makes the
h2-degeneracy story (§3) proportionally more of what is left.
mark_dirty_old_pageis 99.971% redundant even when armed. 1,773,744attempts produced 517 new dirty pages. That is the 6.73% symbol, and it is
a standing B-family target independent of arming: a page already in
DIRTY_OLD_PAGESis re-hashed and re-inserted ~3,400 times on average.3. What #7250 does, and the correction §1 of the design demanded
The design comment's §1 is right and #7250 implements it: the naive
"nothing has collected, so there is no old generation" premise is false, and the
contract is stated on the reading side.
The placement that makes this enforceable rather than auditable:
remembered_dirty_snapshot()is the sole read path. Every collector —the budgeted/full cycle's
RememberedSetRootMarkState, the copying nursery fastpath and its preflight, the cycle's pre-clear coverage snapshot, the
evacuation verifier — obtains the dirty set there; the only other touches of
DIRTY_OLD_PAGES/EXTERNAL_DIRTY_SLOT_PAGES/REMEMBERED_SETare thebarrier's own writes, the clear, and diagnostics, and the arena's per-page
dirtybit feedsold_page_summarytelemetry only. Arming inside that onefunction means no collector can observe an unarmed — therefore empty — log.
This also disposes of §2's third bullet (the #5029 verifier). No verifier change
was needed: it reaches its snapshot through the same function, so by the time it
runs, the reconstruct has. Its predicate stays load-bearing and untouched.
Two things the design left open that the implementation had to decide:
must be logged, because the walk may already have passed its parent.
ordering §2 asked to be written down. After, so the simple EMA (Exponential Moving Average) loop over a 100k-element array runs **7x slower** in the compiled Perry binary (2300ms) compared to tsx (328ms) or node (343ms). #6011 numeric-store fast
path pays nothing new and SATB shading is never skipped. Before, so the
unarmed window also skips
decode_heap_addr's raw-pointer arm.4. Verified end to end, and what is NOT verified
Branch census on
batch.ts:unarmed_skips = 1,985,241— matching the base'scalls − non_pointer_child_skipsto the unit — withold_to_young_slow_hits,parent_not_old_skipsanddirty_page_mark_attemptsall 0, andreconstructs = 1recovering 393 old pages (fewer than the 517 the barrierlogged: the reconstruct records only edges live at collection time, so it is
strictly more precise). A probe that collects before the sort and again at the
end reports the armed steady state counter-for-counter identical across arms.
Compiled binary −32,936 bytes. Max RSS unchanged.
Not verified, plainly:
The 19.03% / 6.73% / 2.19% instruction shares remain 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 Pi 5 profile,
which I did not reproduce; what I reproduced is the zero-collection fact and
the call counts underneath those shares.
KB against a 24 MB RSS. It may matter on a workload that dirties far more
old pages, unmeasured.
one synchronous whole-heap header walk per thread — 220,318 objects on
batch.ts— and full cycles already perform the identical walk every cycle(
AtomicFinalizeSubphase::RememberedSetRebuild). Narrowing it to old-gen +the malloc registry is sound (
barrier_parent_needs_rememberingadmitsnothing else) and is the obvious follow-up; not done in perf(gc): stop maintaining a remembered set nothing has read yet (#7187 Phase A) #7250.
a worker armed early by another thread's collection still reconstructs on its
own first read, because that flag is thread-local — is implemented and
reasoned, not exercised by a test.
B3 (slot-operand elision) and B4 (a physical generation bit in
GcHeader, whichGC_FLAG_TENUREDcannot serve) remain unstarted.perf(gc): stop maintaining a remembered set nothing has read yet (#7187…
proggeramlug commentedon Aug 3, 2026
Phase B is in #7298:
mark_dirty_old_pagecollapses 1,773,744 → 3,243, page set unchangedRecording the numbers and, more usefully, the one measurement that changed the
design.
The
batch.tsmeasurement has to be re-armed after Phase AThe 1,773,744 / 517 figures in the comment above were taken at
8b024958f,before #7250. On current
mainbatch.tsreachesmark_dirty_old_pagezero times — the barrier is never armed, because the program never collects.
Phase B's subject does not exist on that workload as shipped, which is worth
saying plainly: this lever pays on programs that have collected, and
batch.tshas to be armed (a leadingperry/gccollect()) to see it at all.Armed, it reproduces the pre-Phase-A traffic counter-for-counter.
The mechanism was picked from the page sequence, not from intuition
Instrumenting the exact sequence of pages the barrier hands to
mark_dirty_old_pageand simulating cache shapes over it:The redundancy is consecutive: 3,872 same-page runs over 1,774,374 calls,
mean 458, longest 13,803. One entry captures essentially all of it, and every
larger shape buys ≤0.17 further percentage points for more state on a path that
runs on every heap store. A one-
usizethread-local last-page cache it is.Before / after, same workload, one target dir per arm
Every pre-existing
write_barriercounter is identical across the arms(
calls2,265,777;non_pointer_child_skips280,536;parent_not_old_skips211,497;
old_to_young_slow_hitsanddirty_page_mark_attempts1,773,744;new_dirty_pagesandnew_inserts517). The only difference is the newdirty_page_cache_hits: 0 → 1,770,501, i.e. calls reaching the modbuf andthe arena metadata go 1,773,744 → 3,243.
Page set proven unchanged in-process rather than by diffing two ASLR'd runs:
an instrumented build recorded both the set of pages the barrier asked to mark
and the set that actually reached the recording body, and asserted equality —
517 in both arms,
PAGE_SET_EQUAL=truein both.GC matrix (
--arms pr, 52-file corpus):FAIL=0, exit 0, liveness gate greenwith movement asserted —
safepoint_minorcopy-minor 35/52,evac_minor51/52,force_verify51/52.A new B-family candidate, deliberately NOT taken
remember_old_to_young_slotrunsclassify_heap_generation(slot_addr)immediately before the mark. Hoisting the cache guard above it would remove a
further 1.77M calls to the rank-1 symbol — but only under the extra premise
"page P is in the cache ⇒ a slot in P still classifies
Old", which a blockfreed mid-cycle can falsify. Skipping the classify when that premise is false
skips
mark_dirty_parent_span, i.e. loses an edge. That is heap corruption fora perf win, so #7298 stops at the airtight boundary: the guard only elides the
exact call whose effect it caches. Worth doing properly (a generation epoch, or
B4's physical generation bit, would make the premise checkable in O(1)) — filing
it here rather than sneaking it in.
B3 (slot-operand elision) and B4 (a physical generation bit in
GcHeader)remain unstarted.
perf(gc): stop re-recording the page the write barrier just recorded (#…
proggeramlug commentedon Aug 9, 2026
Closing — Phase A and Phase B both shipped; the two follow-ups this issue named were never started and are refiled so they are visible as work rather than as the tail of a finished ticket.
Shipped: Phase A, lazy barrier arming (#7250). Phase B, the dirty-page mark cache (#7298). Both live on
main.Refiled: #7715 carries B3 (slot elision) and B4 (generation bit in the object header), together with this issue's headline evidence —
classify_heap_generationat 19% ofbatch.ts, 57.4% total GC bookkeeping, with zero collections running — and the two traps that go with it: #7511's finding that the barrier cost is 98.5% the loop'skeep.push(ptr)and that the "parent array is young" shortcut is unsound, and the fact thatPERRY_WRITE_BARRIERS=0cannot bound barrier cost because it changes the GC regime.Anyone picking it up should re-profile first. Every percentage in both issues is days old, and figures in this repo have gone stale under far less.