Skip to content

perf(size): outline the CJS factory body, not just hir.init (#10575) - #10603

Closed
proggeramlug wants to merge 1 commit into
mainfrom
fix/10575-cjs-entry-outline
Closed

proggeramlug wants to merge 1 commit into
mainfrom
fix/10575-cjs-entry-outline

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Summary

  • codegen: structured intra-function outlining of oversized generated functions (module-entry IIFE) #8595's entry outliner only ever chunked hir.init. For a CommonJS module, cjs_wrap::wrap_commonjs_for_target wraps the whole body as text inside function __perry_cjs_factory() {...}, nested in an anonymous IIFE. Ordinary lowering represents that as a Stmt::Let (naming an Expr::Closure) inside hir.init's expression tree — not a hir.functions entry, since it's lexically nested, not a top-level declaration. hir.init itself ends up with only a handful of wrapper statements (the _cjs binding, export default, …), so admission never fired: on typescript@5.9.3's lib/_tsc.js the real body stayed a single 463,716-instruction / 6.30 MB closure, past the machine-pipeline budget and emitted through LLVM's O0 fallback.
  • find_cjs_factory_closure/find_cjs_factory_closure_mut locate that closure by walking hir.init's statement/expression tree. outline_entry_module now tries hir.init first (unchanged codegen: structured intra-function outlining of oversized generated functions (module-entry IIFE) #8595 behavior) and, only if that's not a candidate, falls back to the factory's body using the identical chunk_statements/analyze_stmts_outlining machinery — same admission thresholds, same fail-safe gates (top-level await, TDZ preallocation), same __perry_entry_chunk_* naming. A module is only ever outlined from one origin per compile.
  • The factory always captures exactly one id from its enclosing IIFE scope: its own name. The wrapper's preamble does __cjs_module.__perry_cjs_factory = __perry_cjs_factory;, a load-bearing self-reference perry-runtime's module_require.rs calls through (js_closure_call0) on a circular-require recovery path. A chunk is a plain, non-capturing hir.functions entry and can't read a captured id the way the original closure could, so classify_for_chunking keeps any statement referencing a captured id inline in the residual body — never relocated into a chunk — preserving the exact closure-capture codegen already emits for it. This is deliberately not solved by promoting the captured id to a module global the way a cross-chunk hir.init let is: a global is one program-wide instance, but a closure capture is fresh per invocation: promoting it would silently change re-invocation semantics on that recovery path.
  • module_globals_emit.rs folds the factory's own logical statements into the same cross-chunk-let promotion emit_module_globals already does for hir.init, so a var shared across the factory's new chunks gets the same @perry_global_* treatment an hir.init cross-chunk let gets.

Verification

  • cargo test -p perry-codegen --lib — 1598 passed (19 in entry_outline, including 6 new perf(size): #8595 entry outlining only splits hir.init, so a CJS module body is never outlined — tsc becomes one 6.30 MB function #10575-specific tests: factory-shape matching/rejection, factory outlining when hir.init isn't a candidate, hir.init still winning when both independently qualify, and the self-reference-capture-stays-inline invariant).
  • Proved the regression tests can fail: stashed the patch, ran a minimal probe against pristine origin/main with a synthetic 1200-statement CJS-factory-shaped module — outline_entry_module returned Skipped("below automatic outlining threshold"), reproducing the issue exactly.
  • Targeted integration tests (cjs_wrap_builtin_require, issue_4872_barrel_default_reexports) — 7 passed, confirming small/ordinary CJS modules are unaffected.
  • Synthetic 2107-statement CJS fixture (cross-chunk vars plus a closure created early and invoked from far-later statements, exercising the same escape-analysis path as the self-reference capture): output matches Node's own execution of the same file exactly ({"total":1500,"counter":1000,"logLength":1500,"tag":"bigcjs-fixture"}), both with outlining forced on and with PERRY_OUTLINE_ENTRY=0 (the disable path reproduces the original 211,164-instruction/O0 pathology, confirming the fix is real and the kill switch still works).
  • Full typescript@5.9.3 build (node_modules/typescript/lib/_tsc.js, the issue's own repro):
    • PERRY_OUTLINE_ENTRY_REPORT=1 now reports cjs factory: 20 stmts → 2 chunk(s) ...; candidate=true (already outlined; ...) for _tsc.js, where main previously only reported on hir.init (candidate=false, 3-8 stmts).
    • nm on the linked binary shows real __perry_entry_chunk_*-derived symbols (perry_fn_..._entry_chunk_..._0 through _19, plus wrapper trampolines) — none existed before.
    • The single 463,716-instruction closure from the issue report no longer exists anywhere in the build log.
    • Correctness: ./tsc-perry-10575 --noEmit demo.tsdemo.ts(2,23): error TS2322: Type 'string' is not assignable to type 'number'., exit 2 — byte-identical to node node_modules/typescript/lib/_tsc.js --noEmit demo.ts. --versionVersion 5.9.3.

Honest caveat on size for this specific input

_tsc.js still has two individual chunks around 190k/396k instructions (down from one 463,716-instruction whole-factory function) — a few of _tsc.js's top-level statements are themselves large enough (e.g. sizable literals/tables) that even isolated into their own chunk they remain sizable; that's a sub-statement-granularity problem this issue's own suggested direction (route the CJS factory through the existing chunking path) doesn't attempt to solve. The linked binary grew from 86.4 MB to 100.0 MB here — splitting one function into ~20 adds per-function prologue/GC-safepoint-scaffolding overhead that isn't fully offset by escaping the O0 fallback for every chunk, since a couple of chunks still hit it. Correctness is unaffected either way; a synthetic fixture without _tsc.js's size-outlier statements saw a real size reduction (11.0 MB → 9.2 MB) alongside eliminating the O0 fallback entirely.

Test plan

  • cargo test -p perry-codegen --lib (1598 passed)
  • cargo test -p perry --test cjs_wrap_builtin_require --test issue_4872_barrel_default_reexports (7 passed)
  • cargo fmt -p perry-codegen -- --check / cargo clippy -p perry-codegen --lib clean on touched files
  • Regression test verified to fail on pristine origin/main
  • Full typescript@5.9.3 build: nm chunk symbols present, --noEmit demo.ts / --version byte-identical to baseline

Summary by CodeRabbit

  • Bug Fixes
    • Improved CommonJS module handling by correctly processing eligible factory functions during code generation.
    • Preserved strict-mode behavior and captured outer-scope values when splitting module code.
    • Fixed cross-module variables from CommonJS factories so they are consistently shared and recognized across generated chunks.
    • Added safer fallback behavior for unsupported factory shapes, including async, generator, or parameterized functions.
  • Tests
    • Added coverage for CommonJS outlining, fallback behavior, precedence, shared variables, and capture preservation.

#8595's entry outliner only ever chunked hir.init. For a CommonJS
module, cjs_wrap::wrap_commonjs_for_target wraps the whole body as
text inside a `function __perry_cjs_factory() {...}` closure nested in
an anonymous IIFE; hir.init ends up with only a handful of wrapper
statements, so admission never fired and the real body stayed one
giant function. On typescript 5.9.3's _tsc.js this was a single
463,716-instruction/6.30 MB closure, past the machine-pipeline budget.

find_cjs_factory_closure(_mut) locates that closure by walking
hir.init's statement/expression tree (it is a Stmt::Let naming an
Expr::Closure, not a hir.functions entry, since it is lexically
nested). outline_entry_module now tries hir.init first (unchanged
#8595 behavior) and falls back to the factory's body with the
identical chunk_statements/analyze_stmts_outlining machinery, so a
module is only ever outlined from one origin per compile.

The factory always captures its own name from the wrapper's IIFE
scope (`__cjs_module.__perry_cjs_factory = __perry_cjs_factory;`,
which perry-runtime's module_require.rs calls through on a
circular-require recovery path). A chunk is a plain, non-capturing
function and can't read a captured id, so classify_for_chunking keeps
any statement referencing one inline in the residual body rather than
promoting it to a module global -- a global would turn a
per-invocation-fresh capture into one program-wide instance and could
silently break that recovery path.

module_globals_emit.rs folds the factory's own logical statements into
the same cross-chunk-let promotion emit_module_globals already does
for hir.init, so a var shared across the factory's new chunks gets the
same @perry_global_* treatment hir.init cross-chunk lets get.

Verified on a synthetic 2107-statement CJS fixture (cross-chunk vars
plus a closure created early and invoked from far-later statements)
against Node's own output, and on a full typescript 5.9.3 build: the
463,716-instruction closure is gone, entry-outline reports
"cjs factory: ... candidate=true", nm shows __perry_entry_chunk_*
symbols, and `--noEmit demo.ts` / `--version` output and exit codes
are byte-identical to before.
@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 6adf960a-56f7-4a2d-bbea-83da3bbcea2f

📥 Commits

Reviewing files that changed from the base of the PR and between 9df5075 and d28a9b2.

📒 Files selected for processing (2)
  • crates/perry-codegen/src/codegen/entry_outline.rs
  • crates/perry-codegen/src/codegen/module_globals_emit.rs

Included review availability: Your plan provides up to 8 included reviews per hour; 2 remain after this review.


📝 Walkthrough

Walkthrough

Changes

CommonJS factory outlining

Layer / File(s) Summary
Factory discovery and analysis
crates/perry-codegen/src/codegen/entry_outline.rs
The generator validates __perry_cjs_factory, reconstructs its logical statements, reports its outlining status, and analyzes its residual bindings.
Capture-aware outlining and chunk generation
crates/perry-codegen/src/codegen/entry_outline.rs
Outlining tries hir.init first, then the CommonJS factory. Captured IDs remain inline. Generated chunks preserve source strictness.
Global promotion and validation
crates/perry-codegen/src/codegen/entry_outline.rs, crates/perry-codegen/src/codegen/module_globals_emit.rs
Global emission includes outlined factory statements. Tests cover shape matching, fallback, precedence, captures, and cross-chunk promotion.

Priority: ⬇️ Low

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant ModuleEntry
  participant EntryOutline
  participant GeneratedChunks
  participant ModuleGlobals
  ModuleEntry->>EntryOutline: outline hir.init or __perry_cjs_factory
  EntryOutline->>GeneratedChunks: generate chunks and residual statements
  GeneratedChunks-->>ModuleEntry: update module entry
  ModuleGlobals->>EntryOutline: read logical outlined statements
  EntryOutline-->>ModuleGlobals: return factory residual statements
  ModuleGlobals->>ModuleGlobals: promote cross-chunk bindings
Loading

Merge Risk: ⚪ Minimal · up to d28a9

No concrete current-head issue was established, so the change is mergeable after normal checks.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: outlining the CommonJS factory body instead of only hir.init.
Description check ✅ Passed The description is detailed and covers the change, rationale, implementation, verification results, caveats, and test commands. It omits the template's explicit Changes, Related issue, and Checklist s…
Docstring Coverage ✅ Passed Docstring coverage is 82.93% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 41 functions across 2 files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Picking this up for a merge train. It has no changelog.d/ fragment, which is a hard gate failure (scripts/check_changeset_fragment.sh), so it can't ride as-is. I've written one — changelog.d/10603-cjs-factory-entry-outline.md — and will carry it as a train repair commit unless you'd rather write your own, in which case say so and I'll drop mine.

Two things surfaced while reading the diff closely enough to describe it accurately. Neither blocks the change; both are documentation rather than behaviour.

1. The self-reference is an object-literal field, not an assignment. The module doc, is_cjs_factory_shape, classify_for_chunking and one test comment all describe the wrapper preamble as

__cjs_module.__perry_cjs_factory = __perry_cjs_factory;

but the generated text emits it as a field inside the single literal (cjs_wrap/wrap.rs:986):

__perry_cjs_factory: {cjs_factory_value},

inside const __cjs_module = { … }. The substance is identical — the body still references the name bound in the enclosing IIFE, which is why the capture exists and why the must-stay machinery is needed — so the fix is right. But four comments describe a shape the compiler never emits, and someone will eventually grep for that assignment and not find it.

Related, and not covered by the "captures exactly one id" wording: cjs_factory_value is the literal "undefined" when flat_default_class.is_some() (wrap.rs:944-948), so that wrapper shape has no self-reference and the factory captures nothing. The empty capture set is handled correctly — stmt_references_any returns false immediately — so this is a case the prose doesn't cover rather than a defect.

2. Test count. The PR body says "6 new #10575-specific tests"; the diff adds 5 (entry_outline goes 14 → 19, which matches your "19 in entry_outline"). Minor, but the fragment quotes the number.

Everything else I checked held up: the shared chunk_statements/analyze_stmts_outlining path, one origin per compile, the 1_000/4_000 admission thresholds, the generalized outlined_entry_global_let_ids scanning both hir.init and the residual factory body, and the module_globals_emit.rs chaining.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed via merge train #10710 (v0.5.1597). All source commits preserve authorship; merged main matches the validated train exactly.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant