Skip to content

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

Description

@proggeramlug

Summary

#8595 ("structured intra-function outlining of oversized generated functions (module-entry IIFE)") landed and works — but it only splits hir.init, the module top level. A CommonJS module's body is not in hir.init: Perry lowers it into a factory closure. So for a large CJS bundle the outliner sees an essentially empty entry, does nothing, and the pathology #8595 was filed to remove is still there in full.

Concretely: typescript@5.9.3's lib/_tsc.js (5.9 MB, CJS) compiles into one 6.30 MB LLVM function.

Evidence

Compiling _tsc.js on Perry 0.5.1579 (macOS arm64), the build reports:

perry: `perry_closure_node_modules_typescript_lib__tsc_js__1` has 463716 instructions
after IR optimization, above the optimized machine-pipeline budget 100000; ...
emitting this unit ... through LLVM's O0 machine pipeline

463,716 instructions in one function — the same shape and nearly the same magnitude as the ~439k-instruction cli.js entry quoted in #8595's own summary.

From the linked binary's symbol table (--report-size):

symbol size
_perry_closure_…__tsc_js__1 6.30 MB
_perry_closure_…__tsc_js__0 2.46 MB
_perry_closure_…__tsc_js__4801 2.44 MB
_…__tsc_js__init_bodywhat #8595 targets 0.88 MB
__perry_entry_chunk_* symbols 0

Zero outlined chunks were produced. The module entry that the outliner does inspect is 0.88 MB; the body it needs to reach is the 6.30 MB closure.

Mechanism

crates/perry-codegen/src/codegen/entry_outline.rs operates only on hir.init:

// analyze_entry_outlining_with_target
let stmts = &hir.init;
// outline_entry, line ~646
let original = std::mem::take(&mut hir.init);

And automatic admission requires a large top-level statement stream:

const DEFAULT_AUTO_MIN_STMTS: usize = 1_000;
const DEFAULT_AUTO_MIN_SAFEPOINTS: usize = 4_000;

For a CJS module, hir.init holds roughly one statement (invoke the factory, publish module.exports); the tens of thousands of real top-level statements live inside the factory closure. The confirming string is visible in the binary's own retained function source, which begins:

function() {
function __perry_cjs_factory() {

So the admission test can never fire on CJS, no matter how large the module is.

Correction to the mechanism above

I described the location wrong, and it matters. I wrote that the CJS body "lives in hir.functions". It does not.

cjs_wrap::wrap_commonjs_for_target wraps the body as text inside function __perry_cjs_factory() {...}, nested in an anonymous IIFE. Lowering represents that as a Stmt::Let naming an Expr::Closure, nested inside hir.init's own expression tree — not a hir.functions entry, because it is lexically nested rather than a top-level declaration. hir.init itself keeps only the handful of wrapper statements, which is why admission never fires.

The practical consequence: an implementation that goes looking in hir.functions is a silent no-op — it compiles, runs, and changes nothing. Verified by exactly that happening in a first attempt at #10603, caught only by compiling a real fixture and checking nm, not by unit tests. Anyone working this should confirm with nm for __perry_entry_chunk_* symbols rather than trusting a green suite.

Why it matters beyond compile time

#8583 / #8883 / #8228 all bounded compile time for this shape. This issue is about what the surviving giant function costs in the shipped artifact:

  • it blows the machine-pipeline budget, so the whole codegen unit is emitted through LLVM's O0 pipeline — no machine-level optimisation, in both size and speed;
  • the resulting tsc binary is 86.4 MB, against 20 MB for Static Hermes on the same input;
  • ./tsc-perry --noEmit demo.ts on a two-line file takes 11 m 33 s (663.29s user, 96% cpu, quiesced box), against 0.78 s for node tsc.js — a ~890x gap, while producing byte-identical, correct output and exit code 2.

CJS is not a corner case for this: it is the shape of most large published bundles.

Suggested direction

Admit oversized function bodies, not just hir.init — or, narrower and probably enough in practice, recognise the CJS factory closure as the logical module entry and route it through the existing chunking path. The globalization machinery #8595 reuses (emit_module_globals) is keyed on module-level lets, so a CJS factory's vars would need the equivalent treatment.

Reproduction

npm install typescript@5.9.3
echo 'import "typescript/lib/_tsc.js";' > tsc-entry.ts
# package.json: {"perry":{"compilePackages":["typescript"],"allow":{"compilePackages":["typescript"]}}}
perry compile tsc-entry.ts -o tsc-perry

Perry 0.5.1579, macOS arm64. Related: #8595 (closed, completed), #8583, #8883, #8228, #10574.

Activity

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