Skip to content

compile: mid-size record literals (~10k–24k nodes) take minutes and GBs on the ordinary lowering path (records-3200: 342 s / 1.9 GB) — the cliff just below #10161's compact cutoff #10173

Description

@proggeramlug

Follow-up to #10151 / PR #10161 (landed in train #10172). Parent: #10107 (OpenCode); relevant to the code-size campaign (secret-tests/PROMPT_CODE_SIZE_PER_FUNCTION.md).

Problem

Ordinary lowering of a mid-size record literal still hits a compile-time cliff below the compact-path cutoff that #10161 set (24,576 nodes / 1 MiB for records). Measured by the merge-queue re-audit of #10161 on macOS and by the lane on Linux, with the audit's { id, name, tags, w } record-array shape:

records nodes ordinary lowering
400 ~2.8k fast (432–444 ms hot loop, correct)
3,200 ~22k 342 s compile, 1.9 GB RSS (unchanged from main)
4,800 33,601 475 s
6,400 44,801 >600 s (timed out) — now compact path, 0.94 s

So any literal table between roughly 10k and 24k nodes costs minutes and gigabytes to compile. OpenCode's graph carries several such tables that compile through this path today: html-entities/lib/named_references.js (two AnonShape_…_constructor functions at 223,112 and 151,112 pre-RS4GC instructions, RS4GC-expanded to 5.2 M and 2.4 M), mime/types/other.ts (522,756 instructions after IR optimization), entities/lib/esm/generated/encode_html.js init_body (94,108 → 4,568,704), @octokit/plugin-rest-endpoint-methods generated/endpoints.js init_body (328,913 / 364,146), @agentclientprotocol/sdk schema.zod.gen.js init_body (307,564). These are also a large share of the 1,048 MiB of .text in the OpenCode binary.

What to find out

  • Where the super-linear cost is for a 22k-node literal on the ordinary path (HIR lowering of the synthetic AnonShape constructor, LLVM on a single huge straight-line function, RS4GC relocation explosion, or the shadow-frame retry).
  • Whether ordinary lowering can stay static-shaped but data-driven for large literals (e.g. one shared constructor fed from a serialized table, or chunked initializers), so that mid-size records keep literal-shape property reads (the reason fix(hir): keep large JSON defines as serialized data #10161 did not route them through JSON.parse) without the minutes-long compile and the multi-MB functions.

Acceptance

  • records-3200 compiles in seconds on the ordinary path with property reads at literal-shape speed (same hot-loop time as records-400 scaled).
  • The OpenCode modules named above shrink measurably (report .text per module before/after) and no runtime regression on the benchmark suite.

Activity

  1. added
    bugConfirmed defect or regression
    performanceRuntime, compile-time, build-size, or memory performance
    on Sep 13, 2026
  2. proggeramlug commented on Sep 14, 2026

    @proggeramlug
    ContributorAuthor

    Handoff notes (everything needed to start cold)

    Reproduction — it already exists in-tree

    benchmarks/large_json_literals/ (added by #10161's PR) generates exactly the shape this issue measures, and its README documents the protocol:

    python3 benchmarks/large_json_literals/generate.py /tmp/literal-probes
    cd /tmp/literal-probes && printf '{}\n' > perry.json      # don't inherit defines from a parent dir
    export PERRY_RUNTIME_DIR=/path/to/pinned/compiler-and-archives
    PERRY_BIN=$PERRY_RUNTIME_DIR/perry
    # the cliff case from the table above (~22k nodes):
    /usr/bin/time -v timeout 600 env PERRY_CODEGEN_PROGRESS=all "$PERRY_BIN" compile records-3200.ts \
      --no-link --no-auto-optimize --output records-3200.o --cache-dir cache-records-3200
    # controls: records-400 (fast), records-4800 (475 s), records-6400 (compact path, 0.94 s)

    hot.ts / records.ts in the same directory are the runtime arms: compare the printed checksum plus median table_ms / recs_ms over five alternating runs. That is the "no runtime regression" check in the acceptance list — the record arm must keep literal-shape property-read speed, which is why #10161 deliberately did not route mid-size records through JSON.parse.

    Code map

    • crates/perry-hir/src/lower/lower_expr/json_literal.rs — the compact path and its cutoffs (flat primitive arrays 1,024 nodes / 64 KiB; records and nested arrays 24,576 nodes / 1 MiB). Tests in json_literal/tests.rs.
    • crates/perry-hir/src/lower/lower_expr.rs — the hook that routes a literal into that path.
    • crates/perry/tests/issue_10151_large_json_define.rs — timed define/semantics/cache regressions.
    • docs/src/cli/flags.md — the documented knobs.
    • The ordinary path is what runs below those cutoffs: the synthetic AnonShape_<hash>_constructor plus the module's __init_body. Both appear by name in perry's own compile notices, which makes per-module attribution easy (below).

    Measurement recipes

    • Instructions after IR optimization (the numbers in the table): perry prints them itself. grep "instructions after IR optimization" <compile log> gives <function> has <n> instructions … per offending function.
    • Fast-emit fallback — an unexplored lead. The same notices end with: "shrinking this function is what lifts the whole unit back. Override with PERRY_LL_FAST_EMIT_MAX_INSTRS=<n> (raise) or =0 (disable)." So one oversized function drops its whole codegen unit off the fast emission path. Worth measuring whether the minutes are spent there rather than in RS4GC.
    • Per-module .text: build with PERRY_DEBUG_SYMBOLS=1, then size the module's symbols by its mangled prefix (nm --size-sort <binary> | grep <module_prefix>); the prefix is the path-derived name that appears in the notices.
    • IR dumps: PERRY_SAVE_LL=<dir> writes per-unit IR (it takes a directory, not 1).

    OpenCode modules that compile through this path today

    Measure these standalone with --no-link instead of building the whole app (a full OpenCode compile is ~2 h and ~25 GB):
    html-entities/lib/named_references.js (two AnonShape_…_constructor at 223,112 and 151,112 pre-RS4GC, expanding to 5.2 M and 2.4 M), mime/types/other.ts (522,756 after IR optimization; mime/types/standard.ts 144,416), entities/lib/esm/generated/encode_html.js (94,108 → 4,568,704), @octokit/plugin-rest-endpoint-methods generated/endpoints.js (328,913 / 364,146), @agentclientprotocol/sdk schema.zod.gen.js (307,564), @babel/helpers lib/helpers-generated.js (271,252). A v1.18.30 checkout with node_modules sits at /root/claude-opencode/opencode on the build host (read-only for lanes).

    Why this matters beyond compile time

    In today's OpenCode build perry reports 38 oversized modules that must run exclusively to cap peak memory. They serialize the tail of every compile: at 3 module jobs the run averages ~52 modules/min overall, and these are the ones that cannot overlap. They are also a large share of the 1,048 MiB of .text in the binary.

    Host rules

    Build and measure on the Linux host, never on a laptop: per-lane CARGO_TARGET_DIR, own --cache-dir, and check pgrep -f "perry compile src/index[.]ts" is empty before anything large. A memory watchdog now runs there (/root/claude-opencode/perry-mem-guard.sh) and kills any perry compile above 36 GB RSS — two OOM kills took the box (and its sshd) down today. Do not start a full OpenCode compile without coordinating; the standalone --no-link module measurements above are enough for this work.

  3. proggeramlug commented on Sep 14, 2026

    @proggeramlug
    ContributorAuthor

    Ready-for-review PR: #10273

    Includes phase attribution, the fast-emission control, three standalone OpenCode module size/instruction comparisons, five alternating runtime runs per arm, and regression coverage. records-3200: 95.49 s → 0.63 s; peak RSS 1,440 → 238 MiB; .text 6,297,159 → 6,017 bytes. Required Linux tests/checks pass; hosted CI is running.

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 regressionperformanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions