Skip to content

perry compile OOMs at ~34 GB RSS on Effect (effect@3.21.2) — codegen phase #309

Closed

Description

@proggeramlug

What happened

perry compile of a 4-line program importing Effect (a popular TypeScript framework, ~12k weekly downloads of the effect package) consumes 34 GB of RSS and 249 GB of peak memory footprint before being SIGKILL'd by the OS after ~7 minutes of wall-clock time, during the codegen phase.

Module collection and parse succeed cleanly (152 native modules collected). The compiler reaches Generating code... and then memory grows unboundedly.

What you expected

perry compile to either (a) finish, (b) finish in proportional memory to the source size (Effect is ~150 modules and ~30k LOC for Effect.ts alone — substantial but not pathological), or (c) emit a clear "compiler limit exceeded" error at a sensible memory bound rather than letting the OS kill the process.

Minimal reproduction

mkdir effect-oom && cd effect-oom
cat > package.json <<'JSON'
{
  "name": "effect-oom",
  "version": "0.0.0",
  "type": "module",
  "perry": { "compilePackages": ["effect"] }
}
JSON
bun add effect    # installs effect@3.21.2 + 3 transitive deps
// test_direct.ts
import * as Effect from "effect/Effect";

const program = Effect.succeed(42);
const result = Effect.runSync(program);
console.log("result:", result);

Command:

/usr/bin/time -l perry compile test_direct.ts -o /tmp/out

Diagnostic output

Collecting modules...
  Compile package: effect
Found 152 module(s): 152 native, 0 JavaScript
Generating code...
[ ~7 min later, OS SIGKILLs the process ]
exit=137 (SIGKILL)

/usr/bin/time -l summary on the run that crashed:

      430.45 real       384.12 user        35.67 sys
  36482269184  maximum resident set size       (~34.0 GB)
     23261581  page reclaims
        21674  page faults
            0  swaps
       442    voluntary context switches
    186928    involuntary context switches
6795075182564  instructions retired
1253129492975  cycles elapsed
267389742376  peak memory footprint            (~249 GB virtual)
time: command terminated abnormally

The compiler emits 173 Warning: unknown identifier '...' lines during HIR lowering — most of these are arguments (148×, used heavily by Effect's dual() data-first/data-last helper) and Intl (~10×, used in DateTime / Schedule modules). These are pre-existing categorical gaps (arguments is documented in docs/src/language/limitations.md); I do not believe they cause the OOM but they may be amplifying it via the per-warning lowering fallback path.

Environment

  • Perry version: 0.5.399
  • Host OS: macOS 26.4 (arm64, Apple Silicon)
  • Target: native (default — no --target flag)
  • Installed via: from source (cargo build --release against current main)
  • Effect version: 3.21.2 (latest as of filing)

Notes for triage

  • Module collection + parse phases finish promptly. Memory growth begins after Generating code.... This points the finger at HIR transforms, codegen, or a pass that runs between them. Reasonable starting suspects from CHANGELOG.md history:
    • widen_mutable_captures in crates/perry-hir/src/lower.rs — v0.5.336 noted three back-to-back walks; quadratic factor possible on a deeply-nested HIR like Effect's Channel/Stream/Fiber generic chains.
    • compute_max_local_id walks — class-scan was added for Class method body cannot capture enclosing-function locals #212 (v0.5.323) and Gap: await using (Symbol.asyncDispose / ES2024 explicit resource management) #154 (v0.5.388); similar walk over Effect's many class methods could compound.
    • The async_to_generator transform (v0.5.371) — Effect uses generators heavily for Effect.gen; this pass walks every fn body.
    • The inliner (crates/perry-transform/src/inline.rs) — Effect's dual() helper produces functions that are tiny and called from everywhere; inlining cost may be quadratic in call-site count.
  • Suggested investigation: build Perry with debug symbols (RUSTFLAGS='-g' cargo build --release), run the repro under samply or Instruments → Allocations, look for the call site holding the most live bytes when RSS is at ~10-20 GB. If a single transform pass owns most of the heap, that's the wedge.
  • Workaround for users today: none. import { Effect } from "effect" (the bare form) instead "compiles" but silently produces result: 0 because Perry's collector doesn't traverse export * as Effect from "./Effect.js" — filed separately as #ADJACENT.

Why this matters

Effect is one of the largest and most-watched TypeScript-only frameworks. A successful "perry compile your Effect app to a native binary" story is a strong lever for adoption — but at 34 GB RSS the bar isn't just "Effect works", it's "Perry works on any large TypeScript codebase." This OOM almost certainly reproduces on other large libraries too (Angular, NestJS, RxJS-heavy apps); Effect is just the largest pure-TS surface I've tested.

Activity

  1. proggeramlug commented on Apr 30, 2026

    @proggeramlug
    ContributorAuthor

    Adjacent silent-correctness bug filed as #310 — together they form the full Effect compat story.

  2. proggeramlug commented on Apr 30, 2026

    @proggeramlug
    ContributorAuthor

    Auto-closed by the v0.5.403 commit. Summary of the fix for future readers:

    Root cause: inheritance-chain cycle in compile_module's class_table. Effect declares same-named classes across modules — class Base extends Class<any> inside Data.ts's TaggedClass IIFE plus class Class<A,E,R> extends Base<A,E,R> in Effectable.ts. When the cross-module-import resolver pulls every exported class into Data.ts's imported_class_stubs (the v0.5.362 #240 augmentation fires for every Effect module since they reference unresolved type names like bare A/B/E1/R1 generics by the dozen), the stub for Class carries parent_name = "Base" from Effectable's source. In Data.ts's namespace that resolves to Data.ts's OWN local Base — different class, same name — forming Base → Class → Base → … cycle. apply_field_initializers_recursive then accumulated visited names into chain: Vec<String> without bound: ~3.5 GB/sec single-threaded, no upper limit.

    Fix: one centralized cycle break in compile_module at crates/perry-codegen/src/codegen.rs — runs once after imported_class_stubs are built but before they enter class_table. DFS through the (local ∪ stub) union name space with a HashSet visited-set + depth cap of 64; drops extends_name = None on stubs whose chain closes a cycle. All downstream chain walks then operate on a guaranteed-acyclic graph.

    End-to-end on the exact repro:

    Metric Before After Delta
    Wall 430s 2.45s 175× faster
    Max RSS 34 GB 129 MB 263× less
    Peak 249 GB virtual 120 MB ~2000× less
    Exit SIGKILL 137 clean compile —

    All 152 Effect modules now compile cleanly to .o files. The link step then fails with separate ld: symbol(s) not found errors for unresolved cross-module method symbols (e.g. RedBlackTreeIterator__next) — pre-existing dispatch gaps for specific Effect patterns (likely the v0.5.362 polymorphic-receiver augmentation pulling in classes whose method bodies don't get emitted), tracked separately as the natural follow-up to "perry compile and links an Effect app". The OOM that this issue documented is fully fixed.

    Verified no regression: gap tests 27/28 = baseline (lone fail is the documented console_methods ci-env quirk); inheritance-chain regression smoke (3-level Animal/Dog/Puppy with super-method calls) compiles + runs correctly; existing #212 / #240 / #261 regression tests all match node --experimental-strip-types byte-for-byte.

    The fundamental name-collision issue (different classes that happen to share a name across modules) is left unfixed — a proper resolution requires module-prefixing class names in HIR, which is a multi-day refactor with breaking implications for the entire dispatch / instanceof / class-id machinery. The cycle break here is purely defensive and lets compilation proceed even when name collisions occur.

  3. added 2 commits that reference this issue on Apr 30, 2026
  4. proggeramlug commented on Apr 30, 2026

    @proggeramlug
    ContributorAuthor

    End-to-end Effect compat is now tracked under #321 — the codegen long tail surfaced once this was resolved is split across #314 / #315 / #316 / #317 / #318 / #319 / #320.

  5. added 3 commits that reference this issue on Apr 30, 2026
  6. added a commit that references this issue on Apr 30, 2026
  7. added 5 commits that reference this issue on Apr 30, 2026
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

    bugConfirmed defect or regression

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions