Skip to content

codegen: split large modules into parallel codegen units (bound clang peak memory; remove the single-TU wall) #5391

Description

@proggeramlug

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()):

  1. Partition LlModule.functions (≈92K) into N buckets.
  2. 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);
  3. 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

  1. LlModule::render_codegen_units(n) -> Vec<String> + linkage promotion + cross-unit declares.
  2. Linker: compile N unit texts → N objects → ld -r → one object.
  3. Gate in compile_module (default 1; env + size override).
  4. 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).
  5. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    performanceRuntime or compile-time performance

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions