Skip to content

gc: pretenure-accumulator admission infrastructure (#7598, scope-reduced per audit) - #7623

Closed
proggeramlug wants to merge 5 commits into
mainfrom
perf/7598-static-pretenure
Closed

gc: pretenure-accumulator admission infrastructure (#7598, scope-reduced per audit)#7623
proggeramlug wants to merge 5 commits into
mainfrom
perf/7598-static-pretenure

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Scope reduced per the two audits — admission infrastructure only. The original PR is preserved in the branch history (6e73919c8/f0a2e760a); this is what remains after acting on the root-cause finding.

What the audits established (both confirmed on my side)

  • The mechanism was implemented correctly — the 11-field wrapper site was admitted, born old, TENURED, no fallback, IR- and allocator-chain-verified.
  • The target was wrong: the ~113 MB the minor moves on json_pipeline is the runtime-allocated parse cohort, not codegen-visible literals (~12 MB total, ~1 MB live at minor time).
  • My measured 108 MB → 0 was a confound: the two arms differed in whether perf(gc): seed promote-on-first-copy from a completed mark-sweep (#7598) #7613's promote-on-first-copy seed fired — my base arm predated it. On current main the seed fires in both arms and pretenure's true marginal is ~1 MB. The audit's pinned-host census stands; my dev-host numbers do not.

What this PR now contains

Only the admission machinery, kept for a future dynamic-feedback pretenurer (the audit's disposition):

  • collect_pretenure_accumulator_locals — the loop-position proof (accumulator let at loop depth 0, every push at depth ≥ 1) layered on the all-pointer terms, with refusal tests for the per-iteration accumulator, mixed-depth pushes, out-of-loop pushes, nested-expression pushes, and non-admitted locals.
  • The region_runs_once parameter threaded explicitly through both fact-graph builders and every call site (module main/init = true; function/method/closure = false), with a graph-level test pinning both polarities. A function region's depth-0 accumulator is re-entered per call — the measured 6.6× adversarial regression — so only run-once regions may ever admit.

Removed, deliberately:

  • The pretenured allocator entry points, their keepalive anchors, and the codegen consumers — an unused #[no_mangle] entry point plus #[used] anchor is unused configuration (kill-policy) and un-strippable bytes (the hello-size anchor lesson). Infrastructure without callers is tests + collector only.
  • The deferred-page-registration fix — being extracted separately (crediting this PR's finding) on perf/old-page-registration-deferral; not duplicated here. Caller knowledge relevant to that PR: the flush-before-cycle invariant is satisfied by all three cycle constructors (GcCycleState::new_full, gc/mod.rs:203, policy.rs's budgeted arm), and unregister_old_object_pages is dead code, so the index accretes stale entries — the contains dedup is load-bearing for hole-reused addresses specifically.

For #7598

The constraint that must drive the next design, from the audit: the moved cohort is runtime-allocated, so the live routes are dynamic feedback or allocation-context pretenure inside the JSON materialiser. No admission refinement fixes that; json_pipeline's literals are structurally not the target.

Process (acknowledged)

Future measurements in this family: both arms against current main on the pinned mini, moved-bytes census quoted alongside wall, and the gap-suite result posted on the PR before merge consideration. The gap suite for this branch is running; its result lands here as an edit to this body.

Summary by CodeRabbit

  • Performance Improvements

    • Improved memory management for eligible array accumulators created outside loops and populated within loops.
    • Added safeguards so this optimization applies only to code paths that execute once, avoiding changes in repeated regions.
    • Expanded coverage across module initialization, functions, closures, and methods for more consistent behavior.
  • Documentation

    • Documented the new static allocation optimization and future enhancement areas.

Ralph Küpper added 4 commits August 8, 2026 04:56
…es (#7598)

The dominant remaining cost on promote-heavy loops after #7594/#7596 is
structural: every long-lived object is copied twice, Eden->survivor by
the first copying minor and survivor->old by the next (measured on
json_pipeline at 500k: 3.9 s of the 5.1 s build_out, 268 MB copied
twice).

Static pretenuring for the shape that causes it: an accumulator local
admitted by the all-pointer terms (one binding, never rebound, only
fresh-allocation pushes, no captures/boxes/globals) that is declared
OUTSIDE every loop and pushed into only INSIDE one. Its cohort is live
for the remainder of the loop by construction, so the pushed object is
born in old-gen with GC_FLAG_TENURED via the existing born-tenured
birth path (whose Old => TENURED obligation is contract-tested by
every_old_gen_birth_path_sets_tenured since #7602).

Two consumers of one mem::take'n flag (the #7590 take discipline: it
reaches exactly the root allocation, nested literals read false):
- lower_object_literal's shaped fast path (plain literals), and
- lower_call/new.rs's outlined-call arm (the AnonShape form object
  literals actually reach codegen in) -- a pretenured site takes the
  outlined born-tenured call instead of the inline Eden bump; the
  ~140-cycle call is noise against the double copy it removes.

Correctness is inherited, not asserted: constructor/field stores funnel
through runtime_store_jsvalue_slot and the #7602-gated barrier, both of
which read the LIVE parent header, so old->young field edges are
remembered exactly as for a promoted object.

The loop-position discriminator refuses the per-iteration accumulator
(`for { const keep=[]; keep.push(..) }`) whose cohort dies every
iteration -- pretenuring it would flood old-gen at allocation rate.
A function-local depth-0 accumulator dropped at return IS still
admitted; that adversarial case is measured in the PR alongside the win.
…ce regions (#7598)

Two fixes that turn the measured v1 loss (2.73s -> 4.19s at json 200k)
into a win (-> 1.54s, RSS -112 MB, hash identical):

1. arena_alloc_gc_old_born_tenured_bump: no per-allocation
   old_free_take_exact probe, and page registration DEFERRED into a
   thread-local buffer flushed at old_pages_begin_gc_cycle (every cycle
   kind constructs through it) or at a 64k-entry cap. The per-object
   register_old_object_pages was the profile's top cost: two RefCell
   borrows, two Vec allocations, and a linear dedup scan of the page's
   object list -- quadratic as a page fills. Every reader of that index
   runs at GC time, so cycle-start visibility is sufficient.

2. Pretenure admission now requires the REGION to run exactly once
   (module main/init -- entry.rs's two fact graphs pass true, every
   function/method/closure region passes false). A function body's
   depth-0 accumulator is re-entered per call and its cohort dies at
   return: measured 6.6x slower with 4x RSS when pretenured. Only a
   run-once region makes "declared outside every loop" a
   cohort-lifetime proof. The parameter is explicit at every call site
   so a new region kind must choose.

json_pipeline 500k: 8.4 -> 5.4 s, RSS -173 MB; adversarial and
push_bench emit zero pretenured calls (IR-verified) and are unchanged.
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds static accumulator eligibility analysis for all-pointer arrays, gates the resulting facts on run-once regions, and passes explicit region polarity from module and function-like code-generation paths.

Changes

Static accumulator pretenure

Layer / File(s) Summary
Accumulator eligibility analysis
crates/perry-codegen/src/collectors/all_pointer_arrays.rs, crates/perry-codegen/src/collectors/hir_facts.rs
The collector identifies outer-scope all-pointer array locals whose pushes occur only inside loops. Tests cover nested expressions, mixed push depths, and rejected locals.
Run-once fact-graph gating
crates/perry-codegen/src/collectors/hir_facts.rs, changelog.d/7623-static-pretenure-run-once-accumulators.md
Native fact collection accepts region_runs_once and clears accumulator facts for repeated regions. Tests cover run-once and repeated-region results.
Region execution polarity wiring
crates/perry-codegen/src/codegen/entry.rs, crates/perry-codegen/src/codegen/closure.rs, crates/perry-codegen/src/codegen/function.rs, crates/perry-codegen/src/codegen/method.rs
Module initialization passes true; closures, functions, and methods pass false to native fact collection.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: andrewtdiz, thehypnoo

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the main change: accumulator pretenure admission infrastructure, with relevant scope and issue context.
Description check ✅ Passed The description clearly explains the scope, rationale, retained changes, removed changes, issue context, and planned validation, so it is mostly complete despite missing some template headings.
✨ 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/7598-static-pretenure

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/perry-codegen/src/lower_call/new.rs (1)

628-654: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Consume the pretenure flag before lowering constructor arguments.

Line 634 takes the flag after lower_new_impl_inner lowers its arguments. For out.push(new Outer({})), the argument object literal consumes the flag first. Outer then allocates in young generation.

Take the flag before the argument-lowering loop. Keep the saved value for the allocator selection.

Proposed fix
+    let pretenure = std::mem::take(&mut ctx.pretenure_next_object_literal);
     let mut lowered_args: Vec<String> = Vec::with_capacity(args.len());
     let mut arg_roots: Vec<Option<String>> = Vec::with_capacity(args.len());
     for a in args {
         // ...
     }

-        let pretenure = std::mem::take(&mut ctx.pretenure_next_object_literal);
         if pretenure || (!force_inline_new && !new_site_is_in_loop(ctx)) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/lower_call/new.rs` around lines 628 - 654, Move
consumption of ctx.pretenure_next_object_literal to before the
constructor-argument lowering performed by lower_new_impl_inner, preserving the
taken value through the allocation path. Use that saved value for the pretenure
allocator selection so nested argument object literals cannot consume the flag
intended for the outer constructor.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-codegen/src/collectors/hir_facts.rs`:
- Around line 500-505: Split the oversized hir_facts module by moving a coherent
collector or test section out of the main file into a dedicated submodule,
preserving its behavior and updating module references as needed. Keep the
remaining logic in hir_facts intact, then run scripts/check_file_size.sh to
verify all source files meet the size limit.

---

Outside diff comments:
In `@crates/perry-codegen/src/lower_call/new.rs`:
- Around line 628-654: Move consumption of ctx.pretenure_next_object_literal to
before the constructor-argument lowering performed by lower_new_impl_inner,
preserving the taken value through the allocation path. Use that saved value for
the pretenure allocator selection so nested argument object literals cannot
consume the flag intended for the outer constructor.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 851b4618-34e5-4aec-8fb8-2113257f4b4c

📥 Commits

Reviewing files that changed from the base of the PR and between 38ff7ec and 6d45681.

📒 Files selected for processing (16)
  • changelog.d/7623-static-pretenure-run-once-accumulators.md
  • crates/perry-codegen/src/codegen/closure.rs
  • crates/perry-codegen/src/codegen/entry.rs
  • crates/perry-codegen/src/codegen/function.rs
  • crates/perry-codegen/src/codegen/method.rs
  • crates/perry-codegen/src/collectors/all_pointer_arrays.rs
  • crates/perry-codegen/src/collectors/hir_facts.rs
  • crates/perry-codegen/src/expr/array_push.rs
  • crates/perry-codegen/src/expr/mod.rs
  • crates/perry-codegen/src/expr/object_literal.rs
  • crates/perry-codegen/src/lower_call/new.rs
  • crates/perry-codegen/src/runtime_decls/objects.rs
  • crates/perry-runtime/src/arena/allocators.rs
  • crates/perry-runtime/src/arena/mod.rs
  • crates/perry-runtime/src/arena/page_meta.rs
  • crates/perry-runtime/src/object/alloc.rs

Comment on lines +500 to +505
// #7598: the loop-position subset whose pushed literals are born tenured.
array_facts.pretenure_accumulator_locals =
super::all_pointer_arrays::collect_pretenure_accumulator_locals(
stmts,
&array_facts.all_pointer_element_locals,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
wc -l crates/perry-codegen/src/collectors/hir_facts.rs

Repository: PerryTS/perry

Length of output: 205


Split this Rust source file before merge.

crates/perry-codegen/src/collectors/hir_facts.rs is 2,576 lines and exceeds the 2,000-line source-file limit. Move a coherent collector or test section into a submodule, then run scripts/check_file_size.sh before pushing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen/src/collectors/hir_facts.rs` around lines 500 - 505,
Split the oversized hir_facts module by moving a coherent collector or test
section out of the main file into a dedicated submodule, preserving its behavior
and updating module references as needed. Keep the remaining logic in hir_facts
intact, then run scripts/check_file_size.sh to verify all source files meet the
size limit.

Source: Coding guidelines

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Audit — NOT merged. The headline does not reproduce against current main on the pinned host.

Rebased onto v0.5.1355 (clean), built both arms identically (perry-dev,
PERRY_NO_AUTO_OPTIMIZE=1), and ran the A/B on the pinned quiet mini
(load 1.2), three interleaved rounds, hash-verified:

main v0.5.1355 this PR claimed
json 200k wall (mini, best/3) 2.74 s 2.74 s 2.40 → 1.54 s
peak RSS 464 MB 466 MB 543 → 431 MB
minor-moved bytes (census) 113,227,216 112,178,784 108 MB → 0.0
cycles 3 3

Wall identical, RSS identical, and the census is the tell: Δ ≈ 1 MB of
~113 MB
. The mechanism is not merely underperforming — it is barely firing.

What I verified is right: the IR admission is correct — exactly one
_pretenured call site in json_pipeline's main, zero in my adversarial
function-local-accumulator probe (the 6.6×-regression shape is refused), no
competing inline bump in the same function. The deferred-page-registration
soundness question closes cleanly too: classify_heap_generation (every
barrier remember-decision) reads the block-level hot_page_generations map,
not the deferred OLD_GEN_PAGE_OBJECTS object index, which flushes before
every cycle constructor. And output hashes are identical everywhere.

The open question the author-side needs to answer: the admitted site is
emitted and reachable, yet at runtime the moved-bytes delta says the accumulator
cohort is still being born young — either the pretenured allocator's fast path
is falling back, or the one admitted site is not the hot out.push({...}), or
the base your table was measured against differs from what 55ddab012 +
rebase produces. Your base row itself is suspect against the record: base
"copied by minors 108 MB" and RSS 543 MB do not match what #7613-era main
measures on the mini (single-hop 113 MB, 464 MB). Please re-measure both arms
against current main (v0.5.1355) on the pinned host and post the census
with PERRY_GC_TRACE cycle detail — if the win is real there, I will re-audit
immediately.

Worth salvaging regardless of the pretenure verdict: the
register_old_object_pages quadratic-per-allocation finding (two RefCell
borrows + a growing contains scan per object) plausibly taxes #7613's
promote path on every cycle today. If the pretenure half does not hold up,
split the deferred-registration fix into its own PR with its own A/B — it may
be the genuinely valuable part.

(Also noting: this is the third PR in this family whose promised gap-suite
comment never got posted. The A/B above is what stands in.)

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Follow-up: root cause found. The mechanism works; the measured win was a confound.

Traced the whole chain on the rebased branch:

  1. The admitted IR site is the hot wrapper — call @js_object_alloc_class_inline_keys_pretenured(i32 1, i32 0, i32 11, …),
    field_count = 11.
  2. alloc_class_inline_keys_impl(pretenure=true) routes unconditionally to
    arena_alloc_gc_old_born_tenured_bump, which is itself unconditional —
    old-arena bump, GC_FLAG_TENURED set in the same breath. There is no
    runtime fallback.
    The wrappers are genuinely born old.

So why is the census delta ~1 MB? Because the ~113 MB the minor moves is the
parse cohort
— the 200k parsed records and their subtrees, allocated by the
runtime JSON parser, kept live through outnot the accumulator
wrappers.
The wrappers total ~12 MB across the whole run, and at the moment
the single copying minor fires, only ~1 MB of them exist. That 1 MB is exactly
the delta I measured. Pretenuring codegen-visible literals cannot touch the
parse cohort, which is runtime-allocated.

Which means the PR's measured 108 MB → 0 cannot have been pretenure's
doing.
The coherent explanation for the base/pretenure split in the PR table:
the two arms differed in whether #7613's promote-on-first-copy seed fired
the pretenure arm's old-gen births change early cycle structure (a full
mark-sweep runs sooner, the sweep census seeds the lock, the parse cohort
single-hops), while the base arm's cycle order left the seed cold. On current
main the seed fires in both arms (my censuses: 3 cycles, ~113 MB single-hop,
both sides), so the confound disappears and pretenure's true marginal is the
~1 MB I measured. The base row's 543 MB RSS and 2,887 ms pause — neither
reproducible on the pinned host — point the same direction.

Disposition

  • The pretenure half should not merge for this workload — its achievable
    ceiling here is ~12 MB of moves, and the honest path to more is the PR's own
    stated alternative: dynamic feedback, not a better static proof. The
    admission machinery (run-once regions, refusal tests) is good infrastructure
    for that future and is worth keeping on the branch.
  • The deferred-page-registration fix should be extracted into its own PR
    with its own A/B.
    register_old_object_pages doing two RefCell borrows
    plus a growing contains scan per object plausibly taxes perf(gc): seed promote-on-first-copy from a completed mark-sweep (#7598) #7613's promote
    path on current main today
    — 113 MB of promotions per run through that
    code. That is the measurable, standalone win hiding in this PR.
  • perf(gc): allocation-site pretenuring — long-lived cohorts are copied twice (Eden→survivor→old) #7598 gets this diagnosis so the next attempt starts from "the moved cohort
    is runtime-allocated" rather than re-deriving it.

Closing the loop on my earlier comment: the author-side re-measure I asked for
is no longer needed — the confound is identified. What would change my mind on
the pretenure half is a workload where codegen-visible literals ARE the moved
cohort; json_pipeline is not it.

The two PR audits established that the pretenure mechanism was correct
but the target was wrong: json_pipeline's minor-moved cohort (~113 MB)
is the runtime-allocated parse tree, not codegen-visible literals
(~12 MB total, ~1 MB live at minor time), and the measured 108 MB -> 0
was a confound -- the base arm predated #7613's promote-on-first-copy
seed, which on current main fires in both arms.

Removed: the born-tenured allocator entry points and their keepalive
anchors (an unused #[no_mangle] + #[used] pair is unused configuration
per the kill-policy, and un-strippable bytes per the hello-size anchor
class), the codegen consumers, and the deferred-page-registration fix
(extracted separately on perf/old-page-registration-deferral, crediting
this PR's finding).

Kept: collect_pretenure_accumulator_locals with its refusal tests, and
the explicit region_runs_once parameter on both fact-graph builders
(module main/init true, function/method/closure false) with a
graph-level test pinning both polarities -- the admission half a future
dynamic-feedback pretenurer needs.
@proggeramlug proggeramlug changed the title perf(gc): static pretenuring for run-once accumulator loops — bump-and-defer old-gen births (#7598) gc: pretenure-accumulator admission infrastructure (#7598, scope-reduced per audit) Aug 8, 2026
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Re-audit of the stripped PR — the strip is correct, but this should not merge yet

Verified the restructure does what it says. IR is normalized-identical to
main
on json_pipeline, zero _pretenured call sites remain, the allocator
entry points and their #[used] keepalive anchors are gone (which was the
specific thing I asked for — a dead #[no_mangle] entry plus anchor is both an
unexercised configuration and un-strippable bytes). Codegen suite 697/0. The
seven collector tests are real and the run-once gate is sabotage-verified:
deleting the region_runs_once clear turns
pretenure_accumulator_fact_requires_a_run_once_region red.

Why not merge: the collector now runs on every compile of every region
and computes a HashSet<u32> that nothing reads. pretenure_accumulator()
has exactly two callers, both in tests. This isn't dead code the linker drops —
it is dead work in the compile path, and the region_runs_once parameter is
now threaded through five fact-graph call sites (closure.rs, entry.rs ×2,
function.rs, method.rs) to feed it.

That is the shape CLAUDE.md's kill-policy exists for, one level up from env
knobs: infrastructure whose only exerciser is its own test suite is a
decision that hasn't been made.
Concretely, the risk is not the microseconds
— compile time is unmeasurably changed at this size — it is that six months
from now the collector's admission rules will have drifted against a consumer
that never arrived, and the tests will still be green.

Two ways I'd take it, either is fine:

  1. Land it with its consumer whenever the pretenure work resumes on the
    route perf(gc): allocation-site pretenuring — long-lived cohorts are copied twice (Eden→survivor→old) #7598 now records (dynamic feedback, or allocation-context pretenure
    in the JSON materialiser — the static route cannot reach the moved cohort,
    which is runtime-allocated). The branch keeps; nothing is lost.
  2. Land it now as an explicitly-parked component if you want the analysis
    banked — but then the region_runs_once threading should carry a comment at
    each of the five call sites saying it feeds a currently-unconsumed fact, and
    the changelog fragment should say "no behaviour change, no consumer yet"
    rather than reading as a shipped optimization. I'd want perf(gc): allocation-site pretenuring — long-lived cohorts are copied twice (Eden→survivor→old) #7598 linked from
    the collector's doc comment as the consumer-to-be.

I have no objection to (2) if you prefer to bank it — say which and I'll merge
or hold accordingly. What I won't do is merge it described as an optimization,
because it currently optimizes nothing.

Unchanged from my previous comment: the deferred-page-registration half is
the genuinely valuable finding and is being extracted separately — it sits on
#7613's promote path on current main, ~113 MB/run through the quadratic
register_old_object_pages. That extraction credits this PR as its origin.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Author decision: option 1 — land it with its consumer, so closing now

Taking the first of the two offered paths, and the audit's own root-cause finding is the reason: the recorded #7598 routes are dynamic feedback or allocation-context pretenure inside the JSON materialiser, and neither necessarily consumes this static collector — a materialiser-context pretenurer lives inside the runtime parser and never sees HIR accumulator proofs, and a dynamic-feedback pretenurer keys on runtime allocation sites, with the static admission at most a candidate filter. Merging the collector parked would put dead work in every compile and five threaded call sites on main as a bet on the least likely consumer, with the drift-against-no-consumer risk you named.

The infrastructure is preserved where it costs nothing: branch perf/7598-static-pretenure (head eaa7057b7 — collector, five refusal tests, region_runs_once threading, graph-level polarity test, all green), referenced from #7598 alongside the constraint that must drive the next design. If the future pretenurer turns out to want the static admission after all, it lands together with it — and gets re-reviewed against whatever the admission rules have to be then, which is exactly the drift protection you were pointing at.

Closing unmerged. The deferred-page-registration extraction continues separately as planned; the caller notes in this PR's body stand for that work.

proggeramlug pushed a commit that referenced this pull request Aug 8, 2026
`register_old_object_pages` pays two RefCell borrows, two Vec allocations,
a hash lookup and a linear dedup scan of the page's object list per object
— and the scan grows as the page fills, so a burst of old-gen births into
one 4 KiB page is quadratic in the objects it lands there.

Since #7613's promote-on-first-copy that path is hot on ordinary workloads:
a copying minor promotes straight into old-gen via `arena_alloc_gc_old`, so
json_pipeline pushes ~113 MB of promotions per run through it.

`arena_alloc_gc_old` now records `(header_addr, total_size)` in a
thread-local buffer and one batched flush folds the whole burst in, holding
a single borrow of each table and scanning only the entries that predate
the batch — zero dedup comparisons for the fresh pages a bump-allocated
promotion burst actually fills. Allocation policy is unchanged: the
`old_free_take_exact` hole probe stays, so this is bookkeeping only.

Soundness rests on one rule: every reader AND every remover of
OLD_GEN_PAGE_OBJECTS / OLD_GEN_PAGE_META flushes first. Removers matter as
much as readers — a removal that runs while an entry is still deferred is a
no-op, and the later flush would then resurrect a dead object.

`arena_alloc_gc_old_excluding_pages` (old-page defrag relocation) stays
eager: it is rare, its per-object cost is dominated by the memcpy beside
it, and keeping it eager narrows the proof obligation.

Origin: extracted from #7623, whose pretenure half was a measurement
confound and is not merging.

Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix
proggeramlug added a commit that referenced this pull request Aug 8, 2026
)

Extracted from #7623 per its audit. register_old_object_pages paid two RefCell borrows, two Vec allocations and a linear contains scan per object -- quadratic in the objects a burst lands on one page -- and since #7613's promote-on-first-copy that is a per-object path (json_pipeline 200k promotes 1,657,966 objects / 113 MB in a single minor). Now batched into an allocation-free flush that scans only the pre-batch prefix of each page. Every reader AND remover flushes first, enforced by a source-level gate that fails on a new unflushed toucher and on a stale exemption. Clean-host A/B: -3.9%/-3.7% wall at 200k/500k, census identical.
proggeramlug pushed a commit that referenced this pull request Aug 8, 2026
…7598)

The parse cohort was the last structural GC cost on promote-heavy JSON
workloads: ~113 MB single-hopping through the copying minor every run
(#7613 made it single-hop; this makes it zero-hop). The #7623 audit
established why codegen-side pretenuring could never reach it -- the
cohort is runtime-allocated -- and named this route: allocation-context
pretenure inside the materialiser.

ParseBirthOldScope: a nesting-counted thread-local window (cached
hot-TLS probe, the same cost class as the free-list probe every
arena_alloc_gc already pays). While open, arena_alloc_gc births go
straight to arena_alloc_gc_old_born_tenured. The two lazy-tape
materialisation entries arm it (materialize, reparse_materialize), so
engagement is size-gated by the tape's own laziness threshold, and the
whole subtree -- records, strings, sub-objects, element backings, all of
which funnel through arena_alloc_gc -- is born together in the
generation it will live in. Subtree placement is what the #7623
parent-only attempt measurably lacked: it converts the would-be
old->young field edges into old->old, so the remembered set stays out
of it entirely.

Costs already paid by prior work: per-object old-birth bookkeeping is
deferred (#7624); Old => TENURED holds by construction through the
born-tenured wrapper (#7602's contract test covers the path).

Planted test: births inside the window are Old+TENURED, the window
nests (stringify force-materialize reaches reparse from inside a
window), and closing the last scope restores young births.
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