Skip to content

codegen: structured intra-function outlining of oversized generated functions (module-entry IIFE) #8595

Description

@proggeramlug

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 Code cli.js entry 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:

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

  • chunk the top-level statement stream (codegen/entry.rs::compile_module_entry) into ordered range-functions called in sequence;
  • promote every module-level let that crosses a chunk boundary to a global — the mechanism already exists (module_globals_emit.rs globalizes lets that escape into closures);
  • preserve evaluation order and TDZ semantics across the chunk boundaries.

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.mjs tooling 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 -O0 fallback), #4880, #8128.

Activity

  1. added
    performanceRuntime, compile-time, build-size, or memory performance
    on Aug 22, 2026
  2. proggeramlug commented on Aug 22, 2026

    @proggeramlug
    ContributorAuthor

    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-level let iff it is referenced from a function/method/closure body (referenced_from_fn), leaving lets used only inside @main as 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 at crates/perry/src/commands/compile/collect_modules.rs:1941), so codegen is untouched and compiles the chunk functions through the ordinary function path.

    1. 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 behind PERRY_OUTLINE_ENTRY until validated, then size-gated on.
    2. Chunk only at top-level statement boundaries of hir.init. Never split inside a compound statement — a top-level if/try/for is one Stmt whose control flow (break/continue/return, landing pads) stays wholly within one chunk. This sidesteps cross-chunk control flow entirely.
    3. Hoist declarations, split initializers. For each top-level let/const/var x = init, emit a bare declaration let x; that STAYS in hir.init (so emit_module_globals's "walk hir.init's top-level Lets" step still sees it as a globalization candidate), and move x = init into the chunk. Destructuring/patterns: hoist each bound name, lower the pattern-assignment into the chunk. const is fine at HIR level (no reassignment check there).
    4. Emit chunk functions. For each chunk, mint a fresh FuncId (max(existing ids)+1), build a Function { id, name: "__perry_entry_chunk_K", params: [], body: <chunk stmts>, is_async:false, ... }, push to hir.functions. The chunk body references the hoisted top-level LocalIds directly (as @main did) — the escape rule then globalizes exactly those used across chunks.
    5. Rewrite hir.init to: the hoisted bare declarations, then Stmt::Expr(Call(chunk_0)), …(chunk_1), … in order. Register each chunk in the same maps codegen builds for hir.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 let is 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 function decls are already compiled as separate functions (script_global_functions) and hoisted before init runs — unaffected. var hoisting: bare var x; hoist to init front behaves as today.
    • this / arguments / new.target at module top level: module scope has none in ESM; for Script scope, verify the entry this handling isn't relied on across a chunk boundary (gate out Script entries initially if needed).
    • Exceptions: a top-level throw/try is within one chunk; an uncaught throw in chunk_k must still abort init exactly as a throw in @main does — 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-module try at entry.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_8583 harness shape), then the gap suite, then the cli.js acceptance 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.js and per-unit emit times drop.

    Validation that it fixes the right thing

    After PR 2 on cli.js: @main should shrink from ~13,170 roots / 439k instrs to many small chunk functions, so (a) #8583's spill message stops naming main, (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.

  3. added a commit that references this issue on Aug 24, 2026
    8224d87
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

    performanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions