Problem
perry emits one LLVM translation unit per source module. A large minified bundle (the 13MB @anthropic-ai/claude-code cli.js) lowers to a single ~1.1GB / 28.7M-line .ll, and clang -c needs ~13–15GB RSS to compile that one TU — the hard wall that stops the bundle from producing a native binary. IR-efficiency work (#5334, levers A–D, all merged) trimmed it ~12%, but the floor is structural: one giant TU.
This is the same scaling problem rustc solves with codegen units and LLVM with parallel codegen / ThinLTO: split the work into independently-compiled units so peak memory is bounded by unit size, not whole-program size.
Approach — codegen units (CGUs)
Composition of the bundle .ll (measured): function bodies are 76% (~21.8M lines); all module globals + string constants are only ~413K lines (1.4%). So duplicating the small shared-global set into each unit is cheap, and each unit shrinks nearly linearly with the function partition.
Render-time partition of the fully-populated LlModule (the seam is right before to_ir()):
- Partition
LlModule.functions (≈92K) into N buckets.
- Each CGU
.ll =
- target triple + attributes + replicated metadata (so
!N refs resolve);
- the full string-constant + global set, with
private/internal/bare-external definitions promoted to linkonce_odr (linker dedups; external declarations like external global replicated as-is). Globals are tiny (1.4%), so this duplication is negligible;
- the bucket's function
defines, plus a declare for every function it calls that lives in another bucket (reusing to_ir's existing "skip declare if also defined here" logic — generate declares for all functions, drop the locally-defined ones);
- Compile each CGU with
clang -c (bounded peak memory; parallelizable), then ld -r the N objects into one — so compile_module(...) -> Result<Vec<u8>> and all callers/tests stay unchanged.
Per-function machinery already co-travels correctly: each function self-carries its pending_declares and its IC/rodata globals, and IC-site numbering is already baked into the rendered text, so partitioning doesn't renumber.
Gating
PERRY_CODEGEN_UNITS=N forces N units; otherwise auto by module size (callable count, same proxy as lever B), defaulting to 1 unit (current behavior, zero risk) for normal modules. Large modules get N = clamp(callable_count / target_per_unit).
Expected impact
N units → clang peak RSS ≈ whole/N (e.g. N=12 on the bundle → ~1–2GB/unit instead of ~15GB), plus wall-clock speedup from compiling units in parallel. Directly removes the clang-memory wall regardless of total IR size — the structural fix.
Plan
LlModule::render_codegen_units(n) -> Vec<String> + linkage promotion + cross-unit declares.
- Linker: compile N unit texts → N objects →
ld -r → one object.
- Gate in
compile_module (default 1; env + size override).
- Validate on a small multi-function program (force N=2: cross-unit calls + shared globals link + run correctly), then on the bundle (peak RSS per unit; produces a binary).
- Optimize later: declare only referenced symbols; parallel unit compiles.
Surfaced taking a real 13MB app all the way through perry → clang. Follow-on to the IR-efficiency roadmap #5334.
Problem
perry emits one LLVM translation unit per source module. A large minified bundle (the 13MB
@anthropic-ai/claude-codecli.js) lowers to a single ~1.1GB / 28.7M-line.ll, andclang -cneeds ~13–15GB RSS to compile that one TU — the hard wall that stops the bundle from producing a native binary. IR-efficiency work (#5334, levers A–D, all merged) trimmed it ~12%, but the floor is structural: one giant TU.This is the same scaling problem rustc solves with codegen units and LLVM with parallel codegen / ThinLTO: split the work into independently-compiled units so peak memory is bounded by unit size, not whole-program size.
Approach — codegen units (CGUs)
Composition of the bundle
.ll(measured): function bodies are 76% (~21.8M lines); all module globals + string constants are only ~413K lines (1.4%). So duplicating the small shared-global set into each unit is cheap, and each unit shrinks nearly linearly with the function partition.Render-time partition of the fully-populated
LlModule(the seam is right beforeto_ir()):LlModule.functions(≈92K) into N buckets..ll=!Nrefs resolve);private/internal/bare-external definitions promoted tolinkonce_odr(linker dedups; external declarations likeexternal globalreplicated as-is). Globals are tiny (1.4%), so this duplication is negligible;defines, plus adeclarefor every function it calls that lives in another bucket (reusingto_ir's existing "skip declare if also defined here" logic — generate declares for all functions, drop the locally-defined ones);clang -c(bounded peak memory; parallelizable), thenld -rthe N objects into one — socompile_module(...) -> Result<Vec<u8>>and all callers/tests stay unchanged.Per-function machinery already co-travels correctly: each function self-carries its
pending_declaresand its IC/rodata globals, and IC-site numbering is already baked into the rendered text, so partitioning doesn't renumber.Gating
PERRY_CODEGEN_UNITS=Nforces N units; otherwise auto by module size (callable count, same proxy as lever B), defaulting to 1 unit (current behavior, zero risk) for normal modules. Large modules getN = clamp(callable_count / target_per_unit).Expected impact
N units → clang peak RSS ≈ whole/N (e.g. N=12 on the bundle → ~1–2GB/unit instead of ~15GB), plus wall-clock speedup from compiling units in parallel. Directly removes the clang-memory wall regardless of total IR size — the structural fix.
Plan
LlModule::render_codegen_units(n) -> Vec<String>+ linkage promotion + cross-unit declares.ld -r→ one object.compile_module(default 1; env + size override).Surfaced taking a real 13MB app all the way through perry → clang. Follow-on to the IR-efficiency roadmap #5334.