Skip to content

perf(gc): gate the array-push write barrier on a live parent-generation test (#7511) - #7602

Merged
proggeramlug merged 6 commits into
mainfrom
perf/7511-barrier-elide-pic
Aug 7, 2026
Merged

perf(gc): gate the array-push write barrier on a live parent-generation test (#7511)#7602
proggeramlug merged 6 commits into
mainfrom
perf/7511-barrier-elide-pic

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Closes part of #7511.

Re-measured first — the headline had NOT collapsed

The ticket's 16.1% / ~25% figures predate #7536, #7594 and #7596, so this starts
with a fresh symbolicated profile at v0.5.1339 (PERRY_DEBUG_SYMBOLS=1 + sample,
push_cls.ts, 2286 leaf samples under main):

symbol leaf samples share
js_write_barrier_slot 346 15.1%
write_barrier_decoded_parent 161 7.0%
barrier_child_prologue 139 6.1%
incremental_mark_barrier_value 30 1.3%
barrier total 676 29.6%

It had grown, not collapsed. All four symbols are the GC write barrier
(gc/barrier.rs), not the Ptr<Shape> promotion barrier — checked, per #7187.

All of it is one call site. The call graph names it: perry_fn_push_cls_ts__chunk + 568,
i.e. keep.push(new Node(...)). push_cls_ts__Node_constructor appears as a leaf
with no barrier beneath it — the constructor's two number field stores already
pay nothing, because a declared-number field selects the raw-f64 representation
and its store is guarded by an inline plain-finite check with a downgrading cold
fallback. That is why #7536 correctly measured push_cls unchanged.

What #7536 already covered, and why it cannot reach this

#7536 put the class-field store's three bookkeeping calls behind one inline test of
the stored value's bits. PERRY_GC_TRACE shows why that test cannot fire here:

bench calls non_pointer_child_skips parent_not_old_skips old_to_young_slow_hits new_inserts
push_cls 19,945,222 0 19,743,573 (99.0%) 0 0
churn_alloc 19,945,222 0 19,743,573 0 0
churn 19,945,222 0 19,743,573 0 0
tree 62,612,898 20,867,845 41,271,511 0 0
cycles 15,674,953 7,832,430 7,368,973 0 0
retain 6,304,687 0 0 3,204,922 6,268

non_pointer_child_skips == 0: the pushed value genuinely is a heap pointer, so
expr_produces_non_pointer_bits_by_construction is not merely unhelpful, it is
correct to say "pointer". And the remembered set is never inserted into — not
once — in 20 million calls
. The waste is entirely parent-side.

#7536's own fragment hands off the put.pic.hit path as the follow-up; that turned
out to be the wrong lead for these benches (it is PutValueSet, and push_cls's
field stores never reach a barrier at all), so this takes the lever the counters
actually point at.

The change

A parent-side question admits no by-construction proof — keep crosses hundreds of
collections between its allocation and its last push, so "the parent is young" is
exactly the claim #7501 showed gets revoked at runtime. So it is decided by a live
test at the store
:

parent_may_need_remembering(parent) :=
      (header(parent).gc_flags & GC_FLAG_TENURED) != 0
   || PERRY_INCREMENTAL_MARK_BARRIER_ACTIVE_COUNT != 0

Both clauses are load-bearing, for unrelated reasons.

TENURED. The remembered set only ever needs an entry when
barrier_parent_needs_remembering classifies the parent Old. Soundness is the
superset property #7536 used: Old ⟹ TENURED, so !TENURED ⟹ !Old, and this gate
can only skip a subset of what the runtime already skips. Every path that places an
object into an old-gen block sets the bit in the same breath — gc/copying.rs:612-637
selects arena_alloc_gc_old and GC_FLAG_TENURED from one promote expression;
gc/oldgen.rs:1740 and :1838; buffer/header.rs:486; typedarray/mod.rs:722;
json_tape.rs via arena_alloc_gc_old_born_tenured — and nothing clears it on a
live object. The stronger reading: a parent that is neither physically old nor
logically tenured is fully traced by every minor GC (gc/trace.rs:747), so its
edges are rediscovered.

Incremental. Skipping the call also skips barrier_child_prologue's
incremental_mark_barrier_value — the insertion/SATB shading, which is not a
generational question and must never be dropped while a cycle is live. A zero count
proves this thread's INCREMENTAL_MARK_BARRIER_VALID_PTRS is null, because
incremental_mark_barrier_enable installs the thread-local before incrementing
the count, an ordering gc/barrier.rs already documents as load-bearing. This reuses
the exact gate expr/shadow_inline.rs and expr/shadow_slot.rs already emit for the
root shading barrier — no new runtime symbol.

The gate reads the array's header byte at arr_handle - 7, which the nofwd block
already loads for its forwarding test in the dominating block, so the whole thing
is one and, one icmp, one global load and an or. It is emitted only inside
apush.inbounds, reached only after that header test, so the dereference rests on a
validation the existing code already performs — the "NaN-boxed parent bits would be
dereferenced" hazard does not apply. The slot store stays unconditional and outside
the branch. Under PERRY_WRITE_BARRIERS=0 nothing is emitted at all.

Emitted IR (real push_cls.ts, --trace llvm):

  store double %r68, ptr %r103                    ; slot store: unconditional
  call void @js_string_addref_if_heap_string(double %r68)
  %r107 = load i8, ptr %r106                      ; arr_handle - 7
  %r108 = and i8 %r107, 32                        ; GC_FLAG_TENURED
  %r109 = icmp ne i8 %r108, 0
  %r110 = load atomic i32, ptr @PERRY_INCREMENTAL_MARK_BARRIER_ACTIVE_COUNT seq_cst
  %r111 = icmp ne i32 %r110, 0
  %r112 = or i1 %r109, %r111
  br i1 %r112, label %apush.barrier.21, label %apush.barrier.done.22
apush.barrier.21:
  call void @js_write_barrier_slot(i64 %r72, i64 %r102, i64 %r104)

Effect

Barrier calls, same counters:

bench calls before calls after Δ
push_cls 19,945,222 119,674 −99.4%
churn_alloc 19,945,222 119,674 −99.4%
churn 19,945,222 119,674 −99.4%
push_num 19,584,064 117,506 −99.4%
tree / cycles / deeplist unchanged unchanged field stores, not array push
retain 6,304,687 6,303,669 −1,018 (all unarmed_skips)

retain's real work is bit-identical: old_to_young_slow_hits 3,204,922 and
new_inserts 6,268 in both arms. Every genuine remembered-set insert is preserved.
GC cycle counts identical in both arms on every bench (push_cls/churn_alloc/churn
105, tree 42, cycles 16, retain 7, deeplist 4, push_num 18).

Timing — pinned quiet M1 mini, load ~1.5, best-of-5 user+sys, both arms linked
against the same runtime archive, baseline measured twice:

bench base run1 base run2 treat speedup
push_cls 0.670 0.650 0.490 1.33x
churn_alloc 0.680 0.660 0.510 1.29x
push_num 0.310 0.310 0.250 1.24x
churn 0.960 0.950 0.800 1.19x
cycles 0.850 0.970 0.820 1.04x (noisiest row)
tree 8.470 8.270 8.160 1.01x
retain 1.840 1.770 1.760 1.01x
churn_read 0.360 0.350 0.350 1.00x
deeplist 1.290 1.280 1.290 1.00x

deeplist is the only row that could read as a regression; base run1 measured 1.290,
identical to treat, so it is noise. tree/cycles/deeplist gain little because
their barrier traffic is class-field stores, not array pushes — that is the honest
scope limit of this PR, and the remaining #7511 lever.

Validation

Local only — CI has a deep backlog, so none of this is CI-confirmed.

  • All 10 bench binaries produce byte-identical stdout with rc=0, including
    cls_mistyped.ts (the mistyped-number-field GC probe: 20000 strings survive).
  • PERRY_GC_VERIFY_EVACUATION=1 PERRY_GC_VERIFY_MARK=1 clean on both arms.
  • PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_DIAG=1 clean on both arms,
    instrument proven live: 110 [gc-fromspace-protect] mode=ProtectPages retired_set=#N
    lines on push_cls, 12 on retain — copying minors really ran and from-space was
    quarantined and mprotected.
  • gc_root_dominance_corpus.sh → 128/128 sources, 148 .ll. Both gated modes green:
    --moving-only --seeded-violations 40 reports 40 planted, 40 caught, 0 missed;
    --unrooted-allocas reports 0 over 7772 gc-capable allocas. Allowlist untouched.
  • cargo test -p perry-runtime --lib --no-fail-fast: 1848 passed / 0 failed on 3 of 4
    runs. One run had a single failure I could not reproduce in three further runs —
    consistent with the known macOS parallel-test flakiness, but I am flagging it rather
    than calling it clean.
  • cargo test -p perry-codegen --lib: 674 passed / 0 failed.
  • raw_handle_debt.py 998 (baseline 998); addr_class_inventory.py passed;
    class_id_collisions.py passed; check_file_size.sh OK; cargo fmt --all -- --check clean.

Sabotage evidence (every new test shown able to fail)

mutation result
S1 codegen comparand → 0x40 codegen_tenured_comparand_matches_the_runtime_flag + witness RED
S2 drop the incremental clause the_incremental_clause_forces_the_call_for_a_young_parent RED
S3 buffer_alloc forgets GC_FLAG_TENURED every_old_gen_birth_path_sets_tenured RED
S4 gate's or → constant-true array_push_barrier_is_gated_on_the_parent_header RED
S5 sink a slot store into the gated block array_push_slot_store_stays_outside_the_gate RED
restored all green

S4 initially did NOT fail, and that is worth reading. Replacing the or with a
constant-true leaves both and i8 …, 32 and the incremental global in the IR — the
clauses are still computed, just no longer consulted — so a substring-matching test
stayed green while the gate had stopped gating: CLAUDE.md hazard 4 in my own test.
The test now follows the cond_br's condition back to its definition and requires an
or i1 of an i8 header test and an i32 count test.

One thing I tried and reverted, deliberately

A debug_assert! in barrier_parent_needs_remembering checking Old ⟹ TENURED at
the point the classification is made. That is the better enforcement — every old-parent
store in every debug run would recheck it — and arena_alloc_gc_old genuinely does
not establish the invariant (it writes GC_FLAG_ARENA | gc_birth_extra_flags() and
leaves the bit to its eight callers). But dozens of existing tests build old-gen
fixtures straight from that allocator without the bit — alloc_old_test_object,
alloc_old_test_array, alloc_old_test_promise, most of gc/tests/oldgen.rs — some
deliberately, so it fired on fixtures rather than defects and turned cargo-test red.
The invariant is instead pinned over the production birth paths by
every_old_gen_birth_path_sets_tenured (sabotage S3), and the reason the assert is
absent is recorded where someone would next try to add it. Making it shippable means
fixing those fixtures first, which is its own change.

Summary by CodeRabbit

  • Performance

    • Improved array insertion efficiency by avoiding unnecessary garbage-collection write-barrier work when it is not required.
  • Reliability

    • Preserved correct memory management for older-generation arrays and during incremental garbage collection.
    • Added validation covering array updates, garbage collection, and reference tracking.
  • Release

    • Updated the application version to 0.5.1341.
    • Added release documentation describing the array performance and safety improvements.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2cd82ac5-af5a-4738-9005-fdb9ef6aa86d

📥 Commits

Reviewing files that changed from the base of the PR and between a5fddd3 and a3a610f.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/7602-array-push-barrier-parent-gate.md
  • crates/perry-codegen/src/expr/array_push.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/expr/write_barrier.rs
  • crates/perry-runtime/src/gc/barrier.rs
  • crates/perry-runtime/src/gc/tests/inline_generation_gate_contract.rs
  • crates/perry-runtime/src/gc/tests/mod.rs

📝 Walkthrough

Walkthrough

Array-push code generation now stores the element unconditionally and emits a write barrier only when the parent is tenured or incremental marking is active. Runtime and IR tests validate the generation contract, remembered-set behavior, barrier placement, and incremental-marking behavior. The workspace version is updated to 0.5.1341.

Changes

Generation-aware array barriers

Layer / File(s) Summary
Runtime generation contract
crates/perry-runtime/src/gc/barrier.rs, crates/perry-runtime/src/gc/tests/*
The runtime documents the GC_FLAG_TENURED contract. Tests cover old-generation allocation paths, remembered-set behavior, nursery-parent elision, and active incremental marking.
Conditional barrier emission
crates/perry-codegen/src/expr/write_barrier.rs, crates/perry-codegen/src/expr/mod.rs
Codegen checks the parent tenured flag or incremental mark-barrier count before emitting js_write_barrier_slot. The helper remains disabled when write barriers are disabled.
Array push integration and IR validation
crates/perry-codegen/src/expr/array_push.rs, changelog.d/7602-array-push-barrier-parent-gate.md, CLAUDE.md, Cargo.toml
Array pushes keep slot storage unconditional and emit the gated barrier afterward. IR tests verify both gate conditions and barrier placement. Documentation and package metadata record the change and version 0.5.1341.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ArrayPush
  participant WriteBarrierEmitter
  participant RuntimeGC
  ArrayPush->>WriteBarrierEmitter: provide parent bits, slot address, and child bits
  WriteBarrierEmitter->>RuntimeGC: check GC_FLAG_TENURED and marking count
  RuntimeGC-->>WriteBarrierEmitter: return barrier-needed condition
  WriteBarrierEmitter->>RuntimeGC: emit js_write_barrier_slot when condition is true
Loading

Possibly related issues

Possibly related PRs

  • PerryTS/perry#6831 — Directly related array-slot write-barrier changes for old-to-young remembered-set edges.
  • PerryTS/perry#7193 — Related write-barrier implementation changes in the runtime GC barrier path.
  • PerryTS/perry#7536 — Related conditional GC bookkeeping in codegen write-barrier paths.

Suggested reviewers: thehypnoo

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/7511-barrier-elide-pic

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Ralph Küpper added 6 commits August 7, 2026 23:50
…on test (#7511)

The array push is the only surviving barrier call site on push_cls /
churn_alloc / churn, and PERRY_GC_TRACE counts 19,945,222 calls of which
19,743,573 (99.0%) end in parent_not_old_skips, with old_to_young_slow_hits
and new_inserts both ZERO. #7536's value-side test cannot reach any of it
(non_pointer_child_skips == 0 — the pushed value really is a heap pointer);
the waste is entirely parent-side.

Skip the call when the parent's live header carries no GC_FLAG_TENURED AND
no incremental cycle is active. The second clause is what keeps the SATB /
insertion shading in barrier_child_prologue alive, and reuses the same
PERRY_INCREMENTAL_MARK_BARRIER_ACTIVE_COUNT gate shadow_inline.rs already
emits for the root shading barrier.

Also assert Old => TENURED where the classification is made, so the
invariant the codegen gate rests on can actually fail.
Runtime: gc::tests::inline_generation_gate_contract pins the codegen
comparand against GC_FLAG_TENURED, asserts Old => TENURED over the three
birth paths where the bit is a caller's obligation rather than a
consequence of surviving, and carries a stranding witness whose sabotaged
always-false gate leaves the old->young edge unrecorded.

Codegen: array_push::parent_gate_tests pins the emitted structure — the
barrier alone inside apush.barrier.<n>, the slot store outside it, and both
clauses of the gate present. Lives in the crate so --lib sees it rather
than the tag-only integration suite.

Moves the invariant predicate to gc/types.rs beside the flag it reads,
keeping barrier.rs under the 2000-line cap.
Replacing the gate's `or` with a constant-true left both `and i8 …, 32`
and the incremental global in the IR — the clauses are still computed,
just no longer consulted — so the structural test stayed green while the
gate had stopped gating. Follow the cond_br's condition back to its
definition and require an `or i1` of an i8 header test and an i32 count
test.
A debug_assert! in barrier_parent_needs_remembering was the better
enforcement point, but dozens of existing tests build old-gen fixtures
straight from arena_alloc_gc_old without the bit — some deliberately — so
it fired on fixtures, not defects. The invariant stays pinned over the
PRODUCTION birth paths by inline_generation_gate_contract, and the reason
the assert is absent is recorded where someone would try to add it.
@proggeramlug
proggeramlug force-pushed the perf/7511-barrier-elide-pic branch from 6124650 to a3a610f Compare August 7, 2026 22:07
@proggeramlug
proggeramlug merged commit 3a71a80 into main Aug 7, 2026
@proggeramlug
proggeramlug deleted the perf/7511-barrier-elide-pic branch August 7, 2026 22:07
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit before merge — verified, merged as v0.5.1341

Perf reproduced: interleaved best-of-5 both arms, my own builds of current
main vs the PR — push_cls 0.69–0.72 s → 0.50–0.51 s = 1.38×, output
identical. Consistent with the claimed 1.33× quiet-host figure.

The soundness evidence that matters most reproduced bit-for-bit: retain
the workload whose barriers are real — shows old_to_young_slow_hits = 3,204,922 and new_inserts = 6,268 on BOTH arms, to the digit. Every genuine
old→young edge is still recorded; only the 99% of calls that never inserted
anything are skipped. That, plus the design point that a !TENURED parent is
fully traced by every minor (so its edges are rediscovered, not lost), is the
argument — and the incremental clause preserving SATB shading closes the one
non-generational hole.

Sabotage re-verified, both directions:

  • Drifting codegen's GC_FLAG_TENURED_I8 "32"→"16" →
    array_push_barrier_is_gated_on_the_parent_header red. Note the contract is
    a three-copy pin (codegen string, runtime mirror CODEGEN_GC_FLAG_TENURED,
    real flag): my drift of the codegen copy was caught by the IR test while the
    runtime equality test stayed green — the two tests jointly cover both drift
    directions, but anyone reading codegen_tenured_comparand_matches_the_runtime_flag
    alone should know it pins the mirror, not the emitted IR.
  • Gate forced constant-true → the same IR test red. The S4 hardening (follow the
    cond_br to its definition rather than grepping for substrings) is real — my
    constant-true sabotage is exactly the mutation the pre-hardened version
    missed, per the report's own disclosure.

Root-dominance re-run because codegen changed: corpus 128/128,
--moving-only 0 violations with 40/40 seeded caught, --unrooted-allocas 0.
Full suites 1,851/0 runtime, 677/0 codegen; all four lint gates + fmt clean.

One honest limitation of my audit

I could not reproduce the ZEAL+PROTECT_FROMSPACE arm: with
PERRY_GC_MOVING_LOOP_POLLS=1 compiled in (3 polls verified in the IR) and
zeal on, my bench runs performed zero copying minors — instrument armed,
collector never moving, i.e. the exact vacuous-green shape CLAUDE.md warns
about. The report claims 110 retired quarantine sets, presumably on a workload
shape with event-loop boundaries rather than these compute-only benches. I am
NOT treating my zero as evidence against the PR — the bit-identical
remembered-set counters above are the stronger and direct soundness check — but
the zeal-on-compute-only-bench recipe not arming at all on a current binary
is worth a look; if zeal_forced_collections() is genuinely zero there, every
green "ran under zeal" claim on such benches is vacuous, which is #7154-family
instrument territory.

Kept from the report, for the record

The debug_assert!(Old ⟹ TENURED) revert story is the right call, correctly
documented: the invariant holds on all 8 production birth paths (pinned by
every_old_gen_birth_path_sets_tenured) but test fixtures build old-gen
objects without the bit, so a continuous assert fires on fixtures. And the
honest scope statement stands: tree/cycles/deeplist gain ~nothing because
their barriers are class-field stores — that is the remaining #7511 lever, and
the issue stays open for it.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant