You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
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
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.jsinit_body (94,108 → 4,568,704), @octokit/plugin-rest-endpoint-methods generated/endpoints.jsinit_body (328,913 / 364,146), @agentclientprotocol/sdk schema.zod.gen.jsinit_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.
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 direxport 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.
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.
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.
Activity
proggeramlug commentedon Sep 14, 2026
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:hot.ts/records.tsin the same directory are the runtime arms: compare the printed checksum plus mediantable_ms/recs_msover 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 throughJSON.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 injson_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.AnonShape_<hash>_constructorplus the module's__init_body. Both appear by name in perry's own compile notices, which makes per-module attribution easy (below).Measurement recipes
grep "instructions after IR optimization" <compile log>gives<function> has <n> instructions …per offending function.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..text: build withPERRY_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.PERRY_SAVE_LL=<dir>writes per-unit IR (it takes a directory, not1).OpenCode modules that compile through this path today
Measure these standalone with
--no-linkinstead of building the whole app (a full OpenCode compile is ~2 h and ~25 GB):html-entities/lib/named_references.js(twoAnonShape_…_constructorat 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.ts144,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 withnode_modulessits at/root/claude-opencode/opencodeon 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
.textin the binary.Host rules
Build and measure on the Linux host, never on a laptop: per-lane
CARGO_TARGET_DIR, own--cache-dir, and checkpgrep -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-linkmodule measurements above are enough for this work.proggeramlug commentedon Sep 14, 2026
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;.text6,297,159 → 6,017 bytes. Required Linux tests/checks pass; hosted CI is running.