Repository navigation
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
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_body — what #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
tscbinary is 86.4 MB, against 20 MB for Static Hermes on the same input; ./tsc-perry --noEmit demo.tson a two-line file takes 11 m 33 s (663.29s user, 96% cpu, quiesced box), against 0.78 s fornode 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
proggeramlug commentedon Sep 18, 2026
The compiler confirms this itself
Rebuilt with
PERRY_OUTLINE_ENTRY_REPORT=1(report-only; no effect on the transform):3 top-level statements for a 133,818-line, 5.9 MB file — against
DEFAULT_AUTO_MIN_STMTS = 1_000. Socandidate=falseis not a threshold that needs tuning:hir.initholds only the CJS preamble, and the tens of thousands of real top-level statements are inside the factory closure where the outliner never looks. Lowering the threshold would not help; the statements simply are not inhir.init.For contrast, the same build on the ESM entry:
Related measurement
Raising the arm64 fast-emit ceiling so this function is not demoted to the O0 machine pipeline (#10586) recovers 6.4 MB — 86.4 MB → 80.0 MB — but leaves runtime essentially unchanged (663.29 s → 644.09 s user, n=1). So the O0 demotion is a size consequence of this issue, not the cause of the ~890x runtime gap, which remains unexplained.
proggeramlug commentedon Sep 18, 2026
The runtime number in this issue is now attributed — it is not this bug
This issue's "Why it matters" section cites
tsc --noEmit demo.tsat 11 m 33 s against node's 0.78 s. That gap has been root-caused and it is not caused by the oversized function or its O0 demotion: it is #10656,codePointAtbeing O(n) per call on any string containing a non-ASCII character.Evidence:
sampleon the linked binary shows ~85% of the process injs_string_code_point_atand ~12% incopy_utf16_range, i.e. ~97% in one accessor. TypeScript's scanner callscodePointAtper character andlib.dom.d.tscontains 45 non-ASCII characters, which is enough to make the whole scan quadratic. The same measurement reproduces on 0.5.1579 (663 s) and 0.5.1593 (658 s).Two consequences for this issue:
codePointAtis still O(n) per call on non-ASCII strings — #10067 fixed charCodeAt and bracket scans but missed it; costs natively compiled tsc 658 s vs node's 0.4 s #10656 should move nearly all of it.Separately, PR #10603 currently regresses
_tsc.jsfrom 86.4 MB to 100.0 MB because chunking is by statement count (DEFAULT_CHUNK_STMTS = 200), which does not bound instruction count — two chunks remain at ~190k and ~396k and still hit the O0 fallback. An instruction- or size-aware chunk budget looks like the missing piece.perf(size): outline the CJS factory body, not just hir.init (#10575)
proggeramlug commentedon Sep 20, 2026
Fixed by
08bf655af7(changelog.d/10603-cjs-factory-entry-outline.md).codegen/entry_outline.rsnow carries a section headed "#10575: CommonJS module bodies live outside hir.init" and shipsoutline_cjs_factory_module, which is exactly the gap this issue described in #8595's outlining.