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
Activity
- addedperformanceRuntime, compile-time, build-size, or memory performanceRuntime, compile-time, build-size, or memory performance
on Sep 18, 2026 The compiler confirms this itself
Rebuilt with
PERRY_OUTLINE_ENTRY_REPORT=1(report-only; no effect on the transform):[perry] entry-outline: node_modules/typescript/lib/_tsc.js: 3 top-level stmts → 1 chunk(s) of ~200, 0 cross-chunk let(s) to globalize; candidate=false3 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:
[perry] entry-outline: tsc-entry.ts: 0 top-level stmts → 0 chunk(s) ...; candidate=falseRelated 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.
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:
- The size argument stands on its own — one 6.30 MB function, 463,716 instructions, the O0 demotion — and should be judged on that.
- The runtime line should not be used as motivation here. Fixing this issue will not move that number; fixing perf(string):
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.- added a commit that references this issue
on Sep 19, 2026
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 inhir.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'slib/_tsc.js(5.9 MB, CJS) compiles into one 6.30 MB LLVM function.Evidence
Compiling
_tsc.json Perry 0.5.1579 (macOS arm64), the build reports:463,716 instructions in one function — the same shape and nearly the same magnitude as the ~439k-instruction
cli.jsentry quoted in #8595's own summary.From the linked binary's symbol table (
--report-size):_perry_closure_…__tsc_js__1_perry_closure_…__tsc_js__0_perry_closure_…__tsc_js__4801_…__tsc_js__init_body— what #8595 targets__perry_entry_chunk_*symbolsZero 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.rsoperates only onhir.init:And automatic admission requires a large top-level statement stream:
For a CJS module,
hir.initholds roughly one statement (invoke the factory, publishmodule.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: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_targetwraps the body as text insidefunction __perry_cjs_factory() {...}, nested in an anonymous IIFE. Lowering represents that as aStmt::Letnaming anExpr::Closure, nested insidehir.init's own expression tree — not ahir.functionsentry, because it is lexically nested rather than a top-level declaration.hir.inititself keeps only the handful of wrapper statements, which is why admission never fires.The practical consequence: an implementation that goes looking in
hir.functionsis 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 checkingnm, not by unit tests. Anyone working this should confirm withnmfor__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:
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-levellets, so a CJS factory'svars would need the equivalent treatment.Reproduction
Perry 0.5.1579, macOS arm64. Related: #8595 (closed, completed), #8583, #8883, #8228, #10574.