Repository navigation
[perf][regression] Large minified bundle becomes a multi-hour compile after RS4GC/opt-tier changes (83 CGUs, ~17 GiB RSS) #8583
Description
Activity
- addedperformanceRuntime, compile-time, build-size, or memory performanceRuntime, compile-time, build-size, or memory performance
on Aug 22, 2026 Investigation + fix series (measured)
Root-caused on
origin/mainand split into four stacked PRs. The reconciliation of #8421 (never silently change a function's optimization level) and #8128 (RS4GC relocation fan-out makes some functions unoptimizable) is in the issue body above; the short version is that the degree of freedom is which precise-root lowering a function uses, not how hard LLVM optimizes it.What the code showed beyond the report
- The ~27-minute "partitioning" phase is a quadratic text pass, not partition bookkeeping.
function/precise_roots.rs(lower_roots_for_rs4gc,retype_landing_pads_for_statepoints) is O(lines × root-pointers) / O(pads × lines) and runs on every stack-map function at render time, and the partitioner renders each function twice. It never ran under the July shadow-stack default — it arrived with native roots. - The surviving
PERRY_LL_PREOPT_OPTNONE_INSTRSknob is GC-unsound if enabled:optnonemakes the pass manager skipmem2reg/sccpwhile RS4GC still runs, so the demoted function's root allocas are never promoted and the collector never sees them (proven by test).
Measurement (isolated
@mainunit, Homebrew LLVM 22.1.4)The partitioner isolates the 68 MB IIFE (
@main) into its own unit.arm result @mainpre-RS4GC439,116 instrs; 795 root slots, ~106k call sites (HIR: 13,170 root slots × 5,622 safepoints) RS4GC on the unit 62.0s, 7.1 GiB RSS; 439k → 6,532,814 instrs (~15×) -Oson the un-rewritten unit5.05s, 520 MiB — whole unit, @mainincluded-Oson the RS4GC'd 6.5M-instr unit (today)did not finish under a 45-min cap So the pathology is entirely RS4GC relocation fan-out on one function; remove the fan-out for it and
-Osfinishes in seconds.The four PRs
- perf(codegen): make native-root text lowering linear in the function (#8583) #8585 — make
precise_roots.rslinear (oracle-equivalence tested; 2000 roots × 44k lines: 49s → 0.29s). Fixes the 27-min partition phase. - fix(codegen): delete the unsound pre-RS4GC optnone knob; add a post-rewrite instruction budget and per-unit codegen stats (#8583) #8586 — delete the unsound pre-RS4GC knob; add a post-rewrite per-function instruction budget (
PERRY_LL_RS4GC_MAX_INSTRS, default 1.5Mi) that fails the unit loudly, never demotes; add per-unit codegen diagnostics underPERRY_CODEGEN_UNIT_TIMINGS. - perf(codegen): spill giant-function GC roots to a shadow frame to bound RS4GC fan-out (#8583) #8589 (stacks on fix(codegen): delete the unsound pre-RS4GC optnone knob; add a post-rewrite instruction budget and per-unit codegen stats (#8583) #8586) — root spilling: a function whose estimate
root_slots × safepointsexceedsPERRY_ROOT_SPILL_RELOCATIONS(default 4e6) keeps its roots in the shadow frame (pre-feat(gc): make native roots (statepoints) the default #7370 lowering) so RS4GC skips it.-Osand the runtime root walk are unchanged; the frame pointer is kept; each spill is reported. End-to-end mixed-frame GC differential test passes. Estimate distribution over all 83,310 functions: only@mainat 74M, then a long tail (>4M: 1, >2M: 2, >1M: 2) — the default cleanly isolates@main. - perf(codegen): CPU-aware default for native LLVM unit workers (#8583) #8593 (stacks on perf(codegen): spill giant-function GC roots to a shadow frame to bound RS4GC fan-out (#8583) #8589) — CPU-aware default for native LLVM unit workers (was a hard-coded 2; now half the CPUs clamped [2,8] on non-Windows, 2 on Windows).
Acceptance run (default threshold, on the real bundle)
The spill fires exactly as designed —
@main's 13,170 roots go to a shadow frame — and the partition phase drops from ~27 min to ~9 min. The RS4GC fan-out regression is resolved.It also surfaced a second, pre-existing pathology that #8421 removed a fallback for: dense generated closures (e.g. one 246k→491k-instr minified module factory with only ~130 roots — so not fan-out, ×1.6) cost
rs4gc 121s + emit 272sper unit in-Os/SelectionDAG. That is the original #4880 "one giant generated function is slow at-O1+" case, whose-O0(FastISel) routing #8421 also removed. It is orthogonal to GC roots (spilling doesn't reduce ISel), and there are many such closures across the ~84 units. #8593 (more workers) divides that floor so the compile completes in bounded time; it does not remove it.Restoring the pre-regression ~40–50 min wall needs the #4880 fix too — structured intra-function outlining of the giant minified closures (the honest fix;
module.rs's own comment already flags the 68 MB IIFE as irreducible without it), or a reported per-function FastISel routing for dense low-root functions. That is a separate, larger piece; filing it as a follow-up rather than folding an opt-level change back into this series, per #8421's principle.- The ~27-minute "partitioning" phase is a quadratic text pass, not partition bookkeeping.
Follow-ups filed for the two architectural fixes discussed (the root causes behind the residual cost that root-spilling + more workers only bound, not remove):
- codegen: structured intra-function outlining of oversized generated functions (module-entry IIFE) #8595 — codegen: structured intra-function outlining of the oversized module-entry function. The real fix for both the RS4GC fan-out and the Compile-time blowup on wide object literals (2100 keys ≈ 2 min, 3000 keys > 7 min) #4880 ISel/emit cost: stop producing one 68 MB function. Keeps native statepoint roots everywhere; once it lands, this issue's root-spilling should rarely fire.
- gc: reduce safepoint density (safepoint polling instead of every-call statepoints) #8596 — gc: reduce safepoint density (safepoint polling vs every-call statepoints). Shrinks the
× safepointsfactor in the fan-out for the whole codebase, not just the outlier. Larger GC-architecture change; filed as an option to track.
- added 10 commits that reference this issue
on Aug 22, 2026 Status: the compile regression is resolved in practice — the full cli.js 2.1.112 bundle now compiles to a working 240 MB native binary (
./cc --version→2.1.112 (Claude Code),--helpOK,-preaches the API). The fixes: root-spill estimate counting allocating literals + call-result temporaries (#8633, merged), diverged-blockis_terminatedguards (#8652, merged), the const-array descriptor (#8647, merged), typed-array read inlining (#8617, merged), and property/index-store safepoint counting (#8678, open).Keeping this open until the last pieces land on main and a default-config acceptance passes: (a) #8678 merged; (b) the spill-threshold/budget calibration (#8679) reconciled so the
__AnonShape_*_constructorspills without thePERRY_ROOT_SPILL_RELOCATIONS=1500000override. Separately, the runtime side (interactive TUI SIGSEGV;-pstreaming-response hang) is being investigated and is out of scope for this compile-regression ticket.- added a commit that references this issue
on Aug 24, 2026 Resolved — default-config acceptance passes on
mainRan the outstanding gate: compile the full cli.js 2.1.112 bundle on current
main(c76b43930) with default config — noPERRY_ROOT_SPILL_RELOCATIONSoverride.- Codegen completed in 84 min (partition/freeze/LLVM, all 87 units), no override.
apply_budget_spill_retry(codegen: reconcile the root-spill threshold (#8623, 32M) with the RS4GC instruction budget (#8586, 1.57M) — budget-gap functions get refused instead of spilled #8679) auto-spilled the budget-overrunning functions to shadow-frame precise roots at the requested opt level — no manual threshold needed. - Direct baseline: the same bundle on a pre-fix base (before
safepoint_sites.rs/apply_budget_spill_retry) was still grinding in that exact phase at 78+ min and never completed (killed). So the multi-hour/unbounded compile is what's fixed. - The binary links and runs:
cc-accept --version→2.1.112 (Claude Code).
Both stated closing criteria are on
main:- (a) property/index-store safepoint counting (perf(codegen): count property/index stores as GC safepoints in the spill estimate (#8583) #8678) — merged via perf(codegen): refresh rooted arrays, gate super-scope, and count store safepoints #8680.
- (b) spill-threshold/budget reconciliation (codegen: reconcile the root-spill threshold (#8623, 32M) with the RS4GC instruction budget (#8586, 1.57M) — budget-gap functions get refused instead of spilled #8679) —
apply_budget_spill_retryre-lowers a budget violator with shadow-frame roots instead of hard-refusing, so the override is no longer required.
Note: the first acceptance run failed at the link step for an unrelated build-recipe reason — the coherent
perry-ext-*staticlib set (events/http/net/ws/zlib) must be built in the same cargo invocation as stdlib (tokio unification, COMPILE_CLAUDE_CODE.md §2). Rebuilding that set and re-linking (the codegen object is cached) produced the working binary above; it's orthogonal to the compile-time regression this ticket tracks.The residual outlining follow-up (#8595, the 68 MB single-function ISel/emit cost that root-spilling bounds but doesn't remove) stays tracked separately. Runtime-side items (interactive TUI SIGSEGV; the
-pstreaming hang, now fixed by #8681/#8707) were always out of scope here.Closing.
- Codegen completed in 84 min (partition/freeze/LLVM, all 87 units), no override.
What happened
A cold native compile of the public
@anthropic-ai/claude-code@2.1.112standalone bundle no longer finishes in the previously observed time on current
Perry
main.On Perry
0.5.1515(b00e261f8114dd0f39d061131703a918e17b408f), thecompiler selected 83 codegen units, spent about 27 minutes partitioning the
already-lowered module, reached approximately 17 GiB RSS, and then took 11--14
minutes for each of the first LLVM units that completed. I interrupted the run
after 74.5 minutes because only two units had completed and the remaining
linear ETA was multiple hours.
No output binary was produced, so this also prevents measuring the intended
runtime/GC performance of current Perry on this real application.
This appears to be a compile-time regression caused by the interaction of:
function/unit
-O0fallback;-Osbecoming the default for every native module; andcodegen units without being able to split the largest individual functions.
The important point is that inter-function codegen-unit splitting cannot bound
LLVM work for one giant minified IIFE. Perry's own source comment identifies a
68 MB IIFE in this exact
cli.jscase as irreducible without structuredintra-function outlining.
What I expected
The default compile should finish in a bounded amount of time and memory,
without sending RS4GC-expanded multi-million-instruction functions through an
unbounded optimizing middle-end.
As a historical reference, the same 13.7 MB input compiled on Perry commit
4340bffc9bb7500aaf1863a3ba334921c3a92fe8(2026-07-29) in approximately40--50 minutes with roughly 5 GiB peak RSS. That build produced a working
native executable.
The current backend and GC strategy do not need to reproduce the old wall time
exactly, but a default compile should complete rather than regress toward a
multi-hour build with more than 3x the recorded memory use.
Reproduction
This is a scale-triggered compiler issue, so I have not reduced it to a small
TypeScript snippet. The input is a deterministic, publicly obtainable npm
artifact.
1. Build current Perry and the runtime libraries
2. Obtain the standalone Claude Code bundle
Expected input identity:
Version 2.1.112 is used because it is the last Claude Code package in this
series that contains the standalone
cli.jsbundle; later packages are thindownload shims and are not equivalent compiler inputs.
3. Run a cold compile with codegen diagnostics
--no-auto-optimizemakes this a cleaner application-codegen reproduction andmatches the successful July command. The observed current run omitted that
flag, but the stall was inside application LLVM codegen, before the
runtime/stdlib auto-optimization stage, so it is not the source of this issue.
For RSS on macOS, run the command through
/usr/bin/time -l, or sample theperryprocess while it is in thepartitioning/freezing/LLVM codegenphase.Observed diagnostic output/timeline
The current compiler reported:
Approximate phase timings from the cold default run:
freezing 83...The callable-count policy alone would choose
ceil(35501 / 6000) = 6units. Therefore the 83-unit decision comes from thenew 20 MiB estimated-function-IR arm and implies roughly 1.6 GiB of function IR
before RS4GC runs. The reported 1773.1 MiB includes the per-unit scaffold as
well. RS4GC can then expand individual functions further inside each LLVM
worker, which is not represented by that pre-rewrite estimate.
Two additional exploratory arms did not improve the result:
PERRY_LLVM_INPROCESS=0: still partitioning/materializing the 83 textualunits after 65 minutes; clang workers had not started.
PERRY_CODEGEN_UNITS=1 PERRY_LLVM_INPROCESS=0: still finalizing generated IRafter 50 minutes.
Those two exploratory runs, especially the single-unit arm, were affected by
other Rust/Perry builds running on the same host and should not be treated as
clean comparative benchmarks. The primary run's absolute wall time was also
hurt by host contention and low free disk. However, contention does not explain
the deterministic policy changes, the 83-unit selection, the 27-minute
partition construction, or the known RS4GC giant-function behavior described
below.
Environment
perry 0.5.1515b00e261f8114dd0f39d061131703a918e17b408farm64-apple-darwinrustc 1.97.1 (8bab26f4f 2026-07-14)cargo 1.97.1 (c980f4866 2026-06-30)clang-2100.1.1.101)Suspected regression and source evidence
1. RS4GC became the default after the successful baseline
Commit
148f97bc2(#7370, 2026-08-04) made native roots/statepoints the default on supported
targets. The July baseline predates that change and used the shadow-stack
lowering unless RS4GC was explicitly enabled.
This matters because Perry has already measured pathological RS4GC relocation
fan-out on minified bundles. Commit
e34b84029(#8128) records one 51k-line closure growing 40x to 2.1 million instructions;
one
-Osfunction pass then ran for more than 65 CPU-minutes withoutfinishing.
2. The guard for that exact blow-up was removed
#8128 added a post-RS4GC guard: functions above 512k instructions were stamped
optnone+noinlinebefore the normal optimization pipeline. Its commitmessage reports that the affected unit then completed in approximately 21
seconds.
Commit
9051d3fd2(#8421, 2026-08-19) removed that post-rewrite guard. The same commit also
removed the older oversized-module and widest-function
-O0routing fromlinker.rs.The current replacement,
PERRY_LL_PREOPT_OPTNONE_INSTRS, is opt-in and has adefault of zero. It is also evaluated before
rewrite-statepoints-for-gc. Consequently, it cannot detect a function thatis moderate before RS4GC but explodes only after relocation fan-out unless the
user guesses a sufficiently low pre-rewrite threshold.
3.
-Osis now unconditional by defaultCommit
40e2ce86d(#8457, 2026-08-20) made
-Osthe default native optimization level. Its sourcecomment explicitly records an approximately 21% compile-time cost on the
bundle used for that decision.
On current
main,PERRY_LL_SIZE_OPT=0changes-Osto-O3; it no longerrestores the historical pathological-function
-O0escape hatch. Both-Osand
-O3run an optimizing middle-end and can be super-linear for the giantfunction shape that the removed guard was designed to catch.
4. Auto-partitioning now chooses 83 units but cannot split the bottleneck
Commit
81a88de40(#8017, 2026-08-13) changed
decide_codegen_unitsfrom callable-count-onlysizing to the maximum of callable count and estimated function IR, targeting
20 MiB per unit with a cap of 128.
That policy selects 83 units here instead of the approximately 6 units selected
by the July formula. The partitioner correctly balances functions largest
first, but its own comment says the 68 MB Claude IIFE is irreducible through
inter-function splitting. Therefore more units add partitioning, scaffolding,
partial-link, and scheduling overhead without bounding LLVM's work on the
largest bodies.
The native path also uses two codegen-unit workers by default. With 83 units
and early unit times already around 11--14 minutes, bounded concurrency does
not make the default build practical.
Why this does not look like only machine contention
The host was not quiet, so the exact wall-clock values should be remeasured on
an idle machine. Nevertheless, the suspected mechanism is independently
documented in Perry's own history:
-Ospass exceeded65 minutes;
-O0fallback are absent from currentmain;mainnow sends all functions through-Osby default; andThe observed 660.7 s and 808.3 s unit completions are consistent with that
known failure mode even if contention inflated them.
Resolution direction
Restoring the #8128 post-RS4GC
optnoneguard is not the intended fix:#8421 removed it on a principle that still stands (the optimization level is a
contract; compile-time scalability belongs in codegen, never in a silent
per-function opt downgrade). #8128's observation also still stands (statepoint
relocation is O(live roots x safepoints) and some shapes are not optimizable
after RS4GC expands them). Both describe the same function; what makes it
unoptimizable is the relocation fan-out, not its size.
The reconciled design therefore treats which precise-root lowering a function
uses as the per-function degree of freedom, not how hard LLVM optimizes it:
function/precise_roots.rs(
lower_roots_for_rs4gc,retype_landing_pads_for_statepoints) isO(lines x root-pointers) / O(pads x lines) and runs twice per function
(partition reference scan + freeze). On this bundle that is the ~27-minute
"partition" phase, and it never ran under the July shadow-stack default.
PERRY_LL_PREOPT_OPTNONE_INSTRSknob.optnonemakesthe pass manager skip the
mem2reg/sccpprefix of the statepointpipeline while RS4GC (a module pass keyed on the
gcattribute) stillruns, so a demoted function keeps root allocas the collector never sees.
Default cap is 0 so nothing hits it today.
assertion that fails the unit with the function name and pre/post counts
instead of demoting. Bounded without touching the optimization contract.
size a function's frame, estimate relocations (
slots x call sites) and,above a threshold, keep that function's roots in a shadow frame instead of
statepoints. The runtime already walks stack-map and shadow-frame roots in
one visitor and the fp-chain walker skips record-less frames, and codegen
emits the same body lines under both lowerings, so no new lowering or
runtime machinery is needed. Every function keeps
-Os; the choice isreported at default verbosity and in
--report-size, with a strict modethat makes a spill an error.
smaller than the largest indivisible function; 2 hard-coded native workers
vs the text path's cores/4).
fix (separate tracker).
Suggested acceptance criteria
public 2.1.112 bundle.
-Osor-O3; the protective path is covered by a discriminating regression test.pre-/post-RS4GC function and the selected optimization treatment.
the IR-size heuristic selects dozens of codegen units.
default build does not require undocumented environment overrides to finish.