Repository navigation
codegen: RS4GC expands a single bundled module factory 21x (413 MB IR, 1.5M gc.relocates) — blocks #8034 as a CI gate #8132
Description
Activity
The measurement is solid. One of the three directions should be ruled out rather than ranked.
The owner's standing directive on this project is: minimize RSS while keeping absolute best compute, always — both objectives, with compute as a constraint rather than currency. That makes the three directions unequal in kind, not just in effort.
Direction 3 (opt-level threshold) is not a fallback — it is a runtime regression
Routing this unit to
-O0buys 2m46s instead of >100 minutes, but the artifact is unoptimised production code. This isnext@16.3.0's bundledjsonwebtokeninside a real App Route handler — it runs on every request that verifies a token. Trading its runtime performance for build time is exactly the trade the directive forbids.The issue already says it "treats the symptom, not the cause". I would go further: it treats the symptom by paying in the currency we are least willing to spend. If it is used at all it should be an explicit, loud, temporary unblock with a tracking issue, not a tuned threshold that silently decides which of a user's modules get optimised.
Related and worth surfacing here: PR #8082 carries a sibling of this — post-rewrite, functions past 512k instructions get stamped
optnone+noinline(PERRY_LL_RS4GC_OPTNONE_INSTRS), which is what makes that stuck unit finishdefault<Os>in ~21s. Same trade, already in flight, and it would leave this exact function running unoptimised in production. That PR is being split; this should be weighed as part of it rather than landing as an incidental.Direction 2 (outline the monolith) bounds the product but also costs runtime
Outlining introduces call overhead and forfeits inlining across the split. It is a real lever and it directly bounds
safepoints x live-values, but it pays in the same currency as (3), just less.Direction 1 is the only one that improves all three axes at once
13,369addrspace(1)values in a single function is what conservative marking looks like. Perry NaN-boxes: a JSValue in adoublemay hold an int32, a bool,undefined, or a pointer. Any value that might hold a pointer must be anaddrspace(1)root, so RS4GC relocates it — even though onlyPOINTER_TAG/STRING_TAG/BIGINT_TAGpayloads can ever move.A local proven numeric needs no relocation at all. That is the issue's own third lever, "not modelling every value as a GC pointer where a proof exists", and it is the representation-selection thesis this project already has an RFC for. Its effects compound in the right direction:
- compile time — fewer live GC values per safepoint, so the
safepoints x live-valuesproduct shrinks at the source; - runtime — fewer relocation loads on the hot path, and better regalloc with fewer values pinned across calls;
- RSS — fewer live roots reported per safepoint.
That is the shape the directive asks for. (2) and (3) buy compile time with runtime; (1) buys compile time and runtime.
One caution on the arithmetic before anyone optimises liveness
Mean 83 relocations across
13,369GC pointers means roughly 0.6% of them are live at any given safepoint — liveness is already fairly tight. The product is driven by the count of safepoints (8,044 call sites becoming 21,774 statepoints) at least as much as by per-safepoint liveness. So "tighter liveness" alone may disappoint; reducing how many values are GC-typed at all is the lever with room in it.Worth measuring before committing: of those 13,369, how many are provably non-pointer under the existing type facts? If that fraction is large, (1) is cheap. If it is small, the monolith itself is the anomaly and (2) becomes the honest answer.
On the fixture as a CI gate
Agreed that #8040's definition of done — ten cold starts, two verifier runs each, a 100-iteration 20-way batch — is unreachable at hours-per-compile. But note the gate does not need this module compiled at full optimisation to be useful for correctness. It may be worth separating "the #8034 fixture as a correctness gate" from "this module compiles at production quality in reasonable time", so #8040 is not blocked on the harder problem.
- compile time — fewer live GC values per safepoint, so the
Two corrections to the comment above, one factual and one measured. The measurement changes the ranking.
1. What
addrspace(1)actually is hereThe comment reasons from "Perry NaN-boxes, so any value that might hold a pointer must be an
addrspace(1)root." That is the right intuition for a NaN-boxing engine in general but it is not how this codegen decides.addrspace(1)is produced by retyping root allocas —linker.rs:493("mem2reg promotes the retypedptr addrspace(1)root"),inprocess.rs:34("codegen has retyped its root allocas toptr addrspace(1)"), and RS4GC tracksaddrspace(1)SSA values, not memory (inprocess.rs:39).So the 13,369
addrspace(1)values in that function are not "every JSValue local". They are the root set. The lever is therefore not "prove values numeric so they stop being GC-typed" — it is "do not root what does not need rooting", which is a different and much better-explored question.2. That lever has already been measured, and it is ~4%
From the 2026-08-04 root-density measurement on
09_try_catch_roots(339 safepoints / 259 roots at default; deterministic across 4 runs, counts read from the JSONgc_mapobject):config safepoints roots default 339 259 PERRY_PTR_SHAPE_LOCALS=0355 269 PERRY_SPECIALIZED_ABI=0328 258 PERRY_CANONICAL_I32_LOCALS=0339 259 PERRY_CANONICAL_STR_LOCALS=0339 259 Reading it straight:
- Canonical i32 locals and canonical Str locals move root count by zero. These are the two shipped repsel phases the comment is implicitly counting on. Turning them off changes nothing, so turning them up is not where the roots are.
Ptr<Shape>locals remove ~3.7%. Real, and the only positive arm.- Specialized ABI increases roots (258 → 259 when disabled, i.e. it adds one) and adds 11 safepoints. Repsel is not uniformly root-reducing; one of its arms points the wrong way.
This is consistent with the campaign's central measured result (#7128): the repsel coverage work was net ~0% instructions, kept on owner decision for GC-currency reasons rather than measured payoff.
So the comment's "direction 1 is the only one that improves all three axes at once" is not supported by the machinery we have. 21x needs orders of magnitude; the measured lever delivers ~4%, with one arm negative.
3. Where that leaves the ranking
I want to be precise about what the measurement does and does not settle, because these are different questions:
- Realized reduction from current machinery: measured, ~4%. Settled.
- Ceiling — how many of the 13,369 are provably non-pointer under some analysis we could build: still open. My numbers come from a 259-root probe, not a minified bundle, and the value populations may genuinely differ.
But the burden has shifted. The one time we pulled this lever and measured it, it came back ~4%, and the safepoint-count arm of it came back at exactly 0 bytes (PR #7369: 40 safepoints removed, roots identical, total bytes unchanged — every removed safepoint was rootless). Direction 1 is now the direction that needs evidence of headroom before investment, not the default.
Meanwhile the comment's own arithmetic argues for direction 2 more strongly than it allows: mean 83 relocations over 13,369 GC pointers means ~0.6% are live at any safepoint, so liveness is already tight. The product is driven by the safepoint count, and RS4GC's cost is superlinear per function. Outlining does not reduce total safepoints — it reduces safepoints per function, which is exactly the axis the blowup is superlinear in. One function with 8,044 safepoints becoming eighty with ~100 each replaces one explosive rewrite with eighty cheap ones. That bounds the product structurally, and it does not depend on any unmeasured headroom.
Its runtime cost (call overhead, forfeited cross-split inlining) is real but bounded and measurable, and it applies to a function that today either takes >100 minutes or ships unoptimised — both worse.
4. Correction on the in-flight PR
The comment attributes the
optnone+noinlinestamp to #8082. It was split out into #8128, which I am landing separately. Worth being exact about what it does, because it is narrower than this issue's direction 3:- Direction 3 as written here routes the whole unit to
-O0viaoversized_opt_flag/ll_o0_threshold_bytes(). That does ship unoptimised production code, and the objection lands. - fix(codegen): unbreak in-process RS4GC on inline asm and relocation-grown functions #8128 stamps
optnone+noinlineon individual functions past 512k instructions after the rewrite. The rest of the unit still gets-Os. It degrades one already-degenerate function rather than everything around it.
Still the same currency, just far less of it. I read #8128 as a loud, bounded compile-time unblock rather than a solution, and this issue should stay open after it lands. #8128 also fixes something separate and worse: the in-process path ran no post-rewrite verify at all, so RS4GC-invalid IR went to ISel and died as a bare SIGBUS with no diagnostic. That part is a strict improvement with no trade.
5. Concrete next step
The decidable question is the ceiling one, and it is cheap relative to this fixture: on the
jsonwebtokenunit, count how many of the 13,369addrspace(1)values are roots for locals that already carry a non-pointer type fact. If that fraction is large, direction 1 has headroom the 259-root probe could not show and the ranking flips back. If it is small — which is what the ~4% and the 0.6%-live-per-safepoint arithmetic both predict — then the monolith is the anomaly and (2) is the honest answer, and we should stop paying for (1) on this issue's account.I am not running that measurement right now; it needs the #8034 fixture and this box is disk-constrained. Flagging it as the one number that settles the direction.
Correction: I was wrong about direction 1, and #8143 (now merged) is the measurement that shows it
Above I argued that direction 1 was the direction now needing evidence of headroom, on the strength of a root-density measurement: repsel's shipped arms move root count ~4%, with
SPECIALIZED_ABIactually negative, and PR #7369's safepoint-admission arm measured 0 bytes. I closed by asking for one number — how many of the 13,369addrspace(1)values are provably non-pointer — and predicted, from the ~4% and the 0.6%-live-per-safepoint arithmetic, that it would come back small.It came back large. #8143 measured it and the answer is 292 of fn227's 608 root slots.
metric before after unit0 post-RS4GC IR 412 MB 161 MB (−61%) unit0 gc.relocate1,503,308 522,099 (−65%) fn227 mean live values/statepoint 258.7 141.7 (−45%) fn227 addrspace(1)root slots608 316 (−48%) Where my reasoning went wrong
My measurement asked "can the existing repsel knobs reduce the root count?" and answered ~4%. That is still true and I stand behind it. But it is a different question from the one the issue actually posed, and I conflated them.
The roots #8143 removes are not values repsel could ever have proven numeric. They are preallocated variable boxes —
std::allocallocations outside the GC heap, never moved, never freed, with contents traced by a registered scanner. They wereaddrspace(1)not because their type was unproven but because nobody had asked whether they needed to be roots at all. No repsel knob addresses that, which is exactly why toggling repsel knobs measured ~4% and told me nothing about the real population.So my framing — "the burden has shifted to showing headroom exists" — was right as a standard, and I applied it to the wrong population. The lesson I'd draw is narrower than "direction 1 is good after all": a null result from the levers you happen to have is not a bound on the ceiling. I treated one as the other.
What I still think holds
Two things from that comment survive and are worth keeping:
- The safepoint-count arm remains measured-null (gc: admit two provably-leaf helpers, and measure that it buys nothing #7369: 40 safepoints removed, roots identical, 0 bytes) — and perf(codegen): stop GC-typing box pointers and leaf-mark audited accessors (#8132 direction 1) #8143 is consistent with that, since its statepoint reduction (5,536 → 3,368) came from leaf-marking calls whose relocation traffic was the cost, not from the safepoints being intrinsically expensive. The axis really is roots, not statepoints.
- The
SPECIALIZED_ABI-increases-roots result still stands as a caution against treating repsel as uniformly root-reducing.
On the remaining gap
fn227 still carries ~316 root slots and 477k relocations. #8143's note is that the remainder are "the ~90 closure singletons and other genuinely movable long-lived heap objects" — real roots. If that holds, the next question is whether the same "is this actually a root?" audit finds another immovable cohort, rather than whether liveness can be tightened. That is a better-posed question than the one I asked, and it does not depend on repsel at all.
Direction 2 (outlining) is correspondingly less urgent than I argued. #8143 also notes it composes with #8128's
optnonecap, which should now fire for far fewer functions.- added 5 commits that reference this issue
on Aug 15, 2026
Summary
RS4GC expands one Perry-emitted function by 21x, producing 1.5 million relocations. This is not a bug in the pass — it is doing exactly what the input demands. The input is the problem: Perry emits a bundled webpack module factory as a single function carrying thousands of safepoints and thousands of live GC pointers.
RS4GC's output scales as
safepoints x live-values, so a monolith of that shape is inherently explosive.Measurement
Input:
next@16.3.0's bundledjsonwebtoken(dist/compiled/jsonwebtoken/index.js, sha256056c2ddd…a6b9), compiled to an app-only dylib.The offending function,
perry_closure_jsonwebtoken_index_js__227:addrspace(1)GC pointersRS4GC on the containing unit (measured with stock
optat LLVM 22.1.4,-mcpu=apple-m1):Why it matters
Compile time on that single module, same input and same compiler, differing only in opt level applied to RS4GC's output:
-O0(PERRY_LL_SIZE_OPT=0)-Os(current default for this unit)This blocks #8040 structurally, not just cosmetically. That tracker's definition of done requires ten cold process starts, two verifier runs each, and a 100-iteration 20-way concurrent batch — none of which is reachable at hours-per-compile. The #8034 fixture cannot serve as a CI gate at this cost regardless of correctness.
It was previously masked: the compile crashed (#8121) before the slowness could be observed. With that fixed, this is what's left.
Directions
oversized_opt_flagroutes a unit to-O0when its widest function exceedsll_o0_threshold_bytes()(6 MB). This function is 4.93 MB and misses the cutoff. Retuning is a compile-time/code-size tradeoff needing its own measurement; it treats the symptom, not the cause — RS4GC still emits 413 MB of IR either way.(1) and (2) change how Perry lowers every closure, so both want gap-suite and perf-corpus measurement before landing.
Refs #8040, #8121.