Repository navigation
codegen: structured intra-function outlining of oversized generated functions (module-entry IIFE) #8595
Description
Activity
- addedperformanceRuntime, compile-time, build-size, or memory performanceRuntime, compile-time, build-size, or memory performance
on Aug 22, 2026 Implementation design (concrete plan, grounded in current
main)Having mapped the seams, here is how to build this correctly, and — importantly — why it can reuse the existing, tested globalization machinery instead of inventing new global-promotion code. Filing the design first because this is surgery on the single most semantically load-bearing path (all module init runs in
@main); a rushed version that breaks eval order / TDZ / hoisting would silently miscompile every program.The mechanism it hinges on (already exists)
emit_module_globals(crates/perry-codegen/src/codegen/module_globals_emit.rs:120-160) globalizes a top-levelletiff it is referenced from a function/method/closure body (referenced_from_fn), leaving lets used only inside@mainas stack allocas. So: if the entry body is split into chunk functions, every top-level let used by more than one chunk is automatically referenced "from a function body" and gets globalized by the existing rule. Cross-chunk shared state falls out for free — no new promotion pass. Each chunk becomes a small function → bounded RS4GC fan-out (keeps native roots, retires #8583's spill to a rare safety net) and fast ISel (fixes the #4880 half).The transform (HIR→HIR, before codegen)
Add a module pass beside
transform_async_to_generator(invoked atcrates/perry/src/commands/compile/collect_modules.rs:1941), so codegen is untouched and compiles the chunk functions through the ordinary function path.- Gate. Only run when:
!hir.has_top_level_await(ir/module.rs), the entry is not a generator/async context, and the init body exceeds a size threshold (statement count and/or estimated IR). Off by default behindPERRY_OUTLINE_ENTRYuntil validated, then size-gated on. - Chunk only at top-level statement boundaries of
hir.init. Never split inside a compound statement — a top-levelif/try/foris oneStmtwhose control flow (break/continue/return, landing pads) stays wholly within one chunk. This sidesteps cross-chunk control flow entirely. - Hoist declarations, split initializers. For each top-level
let/const/var x = init, emit a bare declarationlet x;that STAYS inhir.init(soemit_module_globals's "walk hir.init's top-level Lets" step still sees it as a globalization candidate), and movex = initinto the chunk. Destructuring/patterns: hoist each bound name, lower the pattern-assignment into the chunk.constis fine at HIR level (no reassignment check there). - Emit chunk functions. For each chunk, mint a fresh
FuncId(max(existing ids)+1), build aFunction { id, name: "__perry_entry_chunk_K", params: [], body: <chunk stmts>, is_async:false, ... }, push tohir.functions. The chunk body references the hoisted top-level LocalIds directly (as@maindid) — the escape rule then globalizes exactly those used across chunks. - Rewrite
hir.initto: the hoisted bare declarations, thenStmt::Expr(Call(chunk_0)),…(chunk_1), … in order. Register each chunk in the same maps codegen builds forhir.functions(func_names, func_signatures) — automatic if they go through the normal function-collection path.
Correctness hazards + how each is handled
- Eval order: chunk calls run in source order; within a chunk, statements are verbatim. Preserved.
- TDZ: a globalized bare
letis zero-initialized, so a read-before-init across chunks would not TDZ-throw. This is not a new gap — perry's existing escaping-let globalization has the identical property today. Documented; matches current behavior. - Hoisting: top-level
functiondecls are already compiled as separate functions (script_global_functions) and hoisted before init runs — unaffected.varhoisting: barevar x;hoist to init front behaves as today. this/arguments/new.targetat module top level: module scope has none in ESM; for Script scope, verify the entrythishandling isn't relied on across a chunk boundary (gate out Script entries initially if needed).- Exceptions: a top-level
throw/tryis within one chunk; an uncaught throw in chunk_k must still abort init exactly as a throw in@maindoes — chunk calls are ordinary calls on the init path, so an unwound exception propagates identically. The entry's existing exception boundary (lower_async_rejecting_*/ the dylib per-moduletryatentry.rs:1318) wraps the chunk calls the same way it wraps inline init.
Staging
- PR 1 (infra, no behavior): the pass skeleton + gate +
PERRY_OUTLINE_ENTRY+ a diagnostic reporting how many statements/chunks WOULD be outlined. Lets us measure candidate bundles safely. - PR 2 (the transform, gated off): decl-hoist/init-split + chunking + chunk-function emission. Validate on toy programs first (including one with a let written in chunk A and read in chunk B under a forced moving GC — reuse the
gc_root_spill_mixed_frames_8583harness shape), then the gap suite, then thecli.jsacceptance compile on a quiet host. - PR 3 (default-on, size-gated): flip the gate for oversized entries once the corpus is clean; confirm [perf][regression] Large minified bundle becomes a multi-hour compile after RS4GC/opt-tier changes (83 CGUs, ~17 GiB RSS) #8583's spill no longer fires on
cli.jsand per-unit emit times drop.
Validation that it fixes the right thing
After PR 2 on
cli.js:@mainshould shrink from ~13,170 roots / 439k instrs to many small chunk functions, so (a) #8583's spill message stops namingmain, (b) no single unit carries a 250k-instruction body (the #4880 emit floor), and (c) the total compile returns toward the pre-regression ~40-50 min. Those three are the acceptance signals.I'm filing this as the design rather than a half-built PR because the init path is where every program's module initialization runs — correctness here is not negotiable, and it needs the full gap-suite + a quiet host to validate. Happy to proceed to PR 1 (the safe infra/measurement slice) immediately on request.
- Gate. Only run when:
- added 4 commits that reference this issue
on Aug 22, 2026 - added a commit that references this issue
on Aug 24, 2026 - added a commit that references this issue
on Sep 19, 2026 - added 3 commits that reference this issue
on Sep 28, 2026
Summary
Perry compiles a module's entire top level into a single LLVM function (
@main). For a large minified bundle that function is enormous — the Claude Codecli.jsentry is ~68 MB of IR, 439k instructions, with ~13,170 GC-root slots. Essentially every backend algorithm is super-linear in single-function size, so one giant function is simultaneously pathological for:rewrite-statepoints-for-gc— relocation fan-out islive_roots × safepoints; on@mainthat expands 439k → 6.5M instructions and the subsequent-Osnever finishes (this is the root cause behind [perf][regression] Large minified bundle becomes a multi-hour compile after RS4GC/opt-tier changes (83 CGUs, ~17 GiB RSS) #8583).-O0/FastISel fallback was removed in fix: make compile output TypeScript-developer friendly #8421).#8583 works around the GC half by spilling the giant function's roots to the shadow frame so RS4GC skips it. That is a correct stopgap but a heuristic bandage: it leaves a threshold to calibrate, does nothing for the ISel/emit cost, and keeps producing the giant function that every other backend pass then struggles with.
module.rs's own comment already flags this: the 68 MB IIFE is "irreducible without structured intra-function outlining."Proposal
Outline the module-entry body (and, more generally, any oversized generated function) into many smaller functions in codegen, before LLVM sees it:
codegen/entry.rs::compile_module_entry) into ordered range-functions called in sequence;letthat crosses a chunk boundary to a global — the mechanism already exists (module_globals_emit.rsglobalizes lets that escape into closures);Why this is the architecturally correct fix (not a workaround)
Scope / risk
Larger than #8583's series and touches GC root analysis, entry lowering, and global promotion — hence a separate tracker. The
secret-tests/claude-code-build/split-bundle-modules.mjs/compose-bundle-entry.mjstooling is a source-level approximation of the same idea and can inform chunk boundaries.Follow-up to #8583. Related backend pathology context: #8421 (removed the oversized-function
-O0fallback), #4880, #8128.