Skip to content

perf(gc): write-barrier tower B3 (slot elision) + B4 (generation bit in the header) — the unstarted half of #7187 #7715

Closed

Description

@proggeramlug

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=0 cannot 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.

Activity

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

    @proggeramlug
    ContributorAuthor

    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.
    • This is not 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 in gc-handoff/; if that file still exists somewhere, the comparison should be redone on it before B4 is designed to any number.
    • 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.

  3. proggeramlug commented on Aug 12, 2026

    @proggeramlug
    ContributorAuthor

    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.

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

    1. 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.
    2. 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.
    3. 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.
  4. proggeramlug commented on Aug 12, 2026

    @proggeramlug
    ContributorAuthor

    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.

    Two things deliberately not done

    ★ A CI gap this surfaced

    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.

  5. proggeramlug commented on Aug 13, 2026

    @proggeramlug
    ContributorAuthor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    performanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions