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
codegen: RS4GC expands a single bundled module factory 21x (413 MB IR, 1.5M gc.relocates) — blocks #8034 as a CI gate #8132
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 bundled jsonwebtoken (dist/compiled/jsonwebtoken/index.js, sha256 056c2ddd…a6b9), compiled to an app-only dylib.
The offending function, perry_closure_jsonwebtoken_index_js__227:
property
value
size
4.93 MB
basic blocks
8,471
lines
101,601
call sites (safepoints)
8,044
allocas
1,015
addrspace(1) GC pointers
13,369
RS4GC on the containing unit (measured with stock opt at LLVM 22.1.4, -mcpu=apple-m1):
pre-RS4GC IR: 19 MB
post-RS4GC IR: 413 MB <- 21x
statepoints: 21,774
gc.relocate: 1,502,654 <- mean 83 operands/statepoint, max 426
Why it matters
Compile time on that single module, same input and same compiler, differing only in opt level applied to RS4GC's output:
arm
result
-O0 (PERRY_LL_SIZE_OPT=0)
2m46.5s, 40 MB dylib
-Os (current default for this unit)
>100 minutes, stopped without finishing
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
Reduce values live across safepoints. ~83 relocations per statepoint is the underlying inefficiency, and shrinking it helps every program, not just this one. Likely levers: tighter liveness, sinking/rematerialization, or not modelling every value as a GC pointer where a proof exists.
Split the monolith. Outline so no single function carries 8k safepoints. Bounds the product directly.
Opt-level threshold (separate, bounded — being handled apart from this issue). oversized_opt_flag routes a unit to -O0 when its widest function exceeds ll_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.
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 -O0 buys 2m46s instead of >100 minutes, but the artifact is unoptimised production code. This is next@16.3.0's bundled jsonwebtoken inside 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 finish default<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 a double may hold an int32, a bool, undefined, or a pointer. Any value that might hold a pointer must be an addrspace(1) root, so RS4GC relocates it — even though only POINTER_TAG/STRING_TAG/BIGINT_TAG payloads 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-values product 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,369 GC 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.
Two corrections to the comment above, one factual and one measured. The measurement changes the ranking.
1. What addrspace(1) actually is here
The 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 retyped ptr addrspace(1) root"), inprocess.rs:34 ("codegen has retyped its root allocas to ptr addrspace(1)"), and RS4GC tracks addrspace(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 JSON gc_map object):
config
safepoints
roots
default
339
259
PERRY_PTR_SHAPE_LOCALS=0
355
269
PERRY_SPECIALIZED_ABI=0
328
258
PERRY_CANONICAL_I32_LOCALS=0
339
259
PERRY_CANONICAL_STR_LOCALS=0
339
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 + noinline stamp 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 -O0 via oversized_opt_flag / ll_o0_threshold_bytes(). That does ship unoptimised production code, and the objection lands.
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 jsonwebtoken unit, count how many of the 13,369 addrspace(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_ABI actually negative, and PR #7369's safepoint-admission arm measured 0 bytes. I closed by asking for one number — how many of the 13,369 addrspace(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.relocate
1,503,308
522,099 (−65%)
fn227 mean live values/statepoint
258.7
141.7 (−45%)
fn227 addrspace(1) root slots
608
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::alloc allocations outside the GC heap, never moved, never freed, with contents traced by a registered scanner. They were addrspace(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 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 optnone cap, which should now fire for far fewer functions.
Activity
proggeramlug commentedon Aug 15, 2026
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:
safepoints x live-valuesproduct shrinks at the source;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.
Refs #8040, #8121, #8082.
proggeramlug commentedon Aug 15, 2026
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):PERRY_PTR_SHAPE_LOCALS=0PERRY_SPECIALIZED_ABI=0PERRY_CANONICAL_I32_LOCALS=0PERRY_CANONICAL_STR_LOCALS=0Reading it straight:
Ptr<Shape>locals remove ~3.7%. Real, and the only positive arm.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:
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:-O0viaoversized_opt_flag/ll_o0_threshold_bytes(). That does ship unoptimised production code, and the objection lands.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.
Refs #8040, #8121, #8128.
test(codegen): update the captured-write hygiene test to the #8132 co…
perf(codegen): stop GC-typing box pointers and leaf-mark audited acce…
proggeramlug commentedon Aug 15, 2026
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.
gc.relocateaddrspace(1)root slotsWhere 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:
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.codegen: give the oversized-unit per-function arm its own cap (no def…
fix(build-cache): register the new oversized-unit cap as a cache input
codegen: give the oversized-unit per-function arm its own cap (no def…
fix(build-cache): register the new oversized-unit cap as a cache input
codegen: give the oversized-unit per-function arm its own cap (measur…
fix(gc): review fixes for arena capture cells (#11179)
fix(gc): review fixes for arena capture cells (#11179)