Skip to content

codegen: RS4GC expands a single bundled module factory 21x (413 MB IR, 1.5M gc.relocates) — blocks #8034 as a CI gate #8132

Description

@proggeramlug

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

  1. 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.
  2. Split the monolith. Outline so no single function carries 8k safepoints. Bounds the product directly.
  3. 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.

Refs #8040, #8121.

Activity

  1. proggeramlug commented on Aug 15, 2026

    @proggeramlug
    ContributorAuthor

    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,369 addrspace(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.

    Refs #8040, #8121, #8082.

  2. proggeramlug commented on Aug 15, 2026

    @proggeramlug
    ContributorAuthor

    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.
    • fix(codegen): unbreak in-process RS4GC on inline asm and relocation-grown functions #8128 stamps optnone + noinline on 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 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.

    Refs #8040, #8121, #8128.

  3. proggeramlug commented on Aug 15, 2026

    @proggeramlug
    ContributorAuthor

    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:

    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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions