Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions benchmarks/large_json_literals/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Large JSON literal lowering (#10151 / #10161)

`generate.py` reproduces the typed record shape and 2,000-element numeric table
from the #10161 performance audit. `hot.ts` runs 20,000 passes over each: numeric
array indexing, then `q.w + q.tags.length + q.id` on 400 records. The separate
`records-N.ts` files scale the same record shape to locate the LLVM codegen cliff.
`numbers.ts` and `records.ts` run those same loops in separate entrypoints. The
generated fixtures are deliberately outside the repository.

Run on a Linux build host with LLVM 22 and a release compiler plus matching
runtime/stdlib archives. In the issue lane, every command below is passed through
`./remote.sh`; do not build or run the probes on the Mac.

```sh
python3 benchmarks/large_json_literals/generate.py /tmp/literal-probes
cd /tmp/literal-probes
# Avoid inheriting unrelated define settings from a parent directory.
printf '{}\n' > perry.json
export PERRY_RUNTIME_DIR=/path/to/pinned/compiler-and-archives
PERRY_BIN=$PERRY_RUNTIME_DIR/perry
/usr/bin/time -v timeout 600 env PERRY_CODEGEN_PROGRESS=all "$PERRY_BIN" compile hot.ts \
--no-auto-optimize --output ./hot --cache-dir cache-hot
./hot
/usr/bin/time -v timeout 600 env PERRY_CODEGEN_PROGRESS=all "$PERRY_BIN" compile records-4800.ts \
--no-link --no-auto-optimize --output records-4800.o --cache-dir cache-records-4800
```

For an A/B, compile with each pinned compiler to separate outputs and fresh cache
directories, then run the executables in alternating order five times. Compare
both checksums as well as median `table_ms` / `recs_ms`. Repeat the no-link command
for the other record counts. The 600-second timeout bounds an intentionally
pathological **ordinary-lowering** measurement; it is not a CI performance test.

The compact path has two tiers. Flat primitive arrays qualify at 1,024 AST value
nodes or 64 KiB of UTF-8 string content. Records and nested arrays have a much
higher threshold of 24,576 nodes or 1 MiB of UTF-8 key/string content. This
preserves ordinary lowering's static record shapes
for mid-size hot data; JSON-parsed records otherwise pay the generic property IC
cost. The cutoff tests live in `perry-hir`, and the timed define/semantics/cache
regressions live in `crates/perry/tests/issue_10151_large_json_define.rs`.

## Linux measurements (LLVM 22.1.8, 2026-09-13)

The ordinary control is the compiler code at `8a058e2053` (the PR's base), built
by removing the compact-lowering hook from this branch. Both variants link the
same freshly built runtime/stdlib archives. Compilers and archives were copied
together to separate directories, away from the shared Cargo target. The
original broad compact rule at `d0bb9b1fd5` was also retained for comparison.

| Record count | Literal source bytes | AST value nodes | Ordinary compile | Compact compile |
| ---: | ---: | ---: | ---: | ---: |
| 4,800 | 307,740 | 33,601 | 475.17 s | 0.35 s |
| 6,400 | 412,540 | 44,801 | >600 s (timeout) | 0.37 s |

These are no-link compiles with fresh caches. At 6,400 records, IR emission
finished in 4.5 seconds, producing about 85.1 MiB of estimated IR and a
1,446,386-instruction function before optimization. Four of five LLVM units
finished in under a second; the remaining unit timed out. The 24,576-node
threshold switches this shape at 3,511 records, 27% below even the 4,800-record
eight-minute case and 45% below the 6,400-record timeout. It is 24 times the
primitive node threshold; the text threshold is 16 times larger.

Five interleaved runs of the final binaries gave these medians:

| Fixture / hot loop | Ordinary control | Two-tier rule |
| --- | ---: | ---: |
| Separate 2,000-number array | 71 ms | 71 ms |
| Separate 400 typed records | 295 ms | 294 ms |
| Combined audit file: numbers | 290 ms | 291 ms |
| Combined audit file: records | 292 ms | 291 ms |

Checksums match (`79042210000` for numbers, `2011000000` for records). The
original broad rule measured 71 ms / 938 ms in the combined file. Restoring
ordinary record lowering removes that record-read regression. However, its
622k-instruction initialization keeps the **combined** unit above the existing
100k-instruction O0 machine-code limit, also slowing its numeric loop. HIR
inspection confirms that the numeric array still uses `JsonParse`. The isolated
number probe shows no intrinsic runtime speedup on this host. Do not describe
the earlier combined-file speedup as an unconditional primitive-array win.

With the final compiler, the preserved OpenCode define (4,623,800 bytes,
213 providers) compiles and links in 1.52 seconds. The refreshed installed
snapshot (4,637,074 bytes, also 213 providers) takes 1.54 seconds. Both print 213.
69 changes: 69 additions & 0 deletions benchmarks/large_json_literals/generate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Generate the #10161 typed hot-loop and record-array codegen probes.

Usage: python3 benchmarks/large_json_literals/generate.py /tmp/literal-probes
This only writes TypeScript; compile/run it with the compiler being measured.
"""

import json
from pathlib import Path
import sys


def records(count):
return [dict(id=i, name=f"n{i}", tags=["a", f"b{i % 5}"], w=i / 4)
for i in range(count)]


def table():
values = []
for i in range(2000):
n = (i * 427799) % 1000003 - 500000
values.append(n + (0.5 if n >= 0 else -0.5) if i % 7 == 0 else n)
return values


REC_TYPE = "type Rec = { id: number; name: string; tags: string[]; w: number };\n"
HOT_LOOP = """
let t0 = performance.now();
let s = 0;
for (let r = 0; r < 20000; r++) { for (let i = 0; i < table.length; i++) { s += table[i] * (i & 3); } }
const t1 = performance.now();
let w = 0;
for (let r = 0; r < 20000; r++) { for (let j = 0; j < recs.length; j++) { const q = recs[j]; w += q.w + q.tags.length + q.id; } }
const t2 = performance.now();
console.log("table_ms", Math.round(t1 - t0), "recs_ms", Math.round(t2 - t1), s, w);
"""


def main():
root = Path(sys.argv[1])
root.mkdir(parents=True, exist_ok=True)
source = ("const table: number[] = " + json.dumps(table()) + ";\n" + REC_TYPE
+ "const recs: Rec[] = " + json.dumps(records(400)) + ";\n" + HOT_LOOP)
(root / "hot.ts").write_text(source)
# Separate entrypoints distinguish representation cost from the existing
# whole-function LLVM size guards triggered by another literal in main.
numbers = ("const table: number[] = " + json.dumps(table()) + ";\n"
+ HOT_LOOP.split("const t1 =")[0]
+ 'console.log("table_ms", Math.round(performance.now() - t0), s);\n')
(root / "numbers.ts").write_text(numbers)
record_hot = (REC_TYPE + "const recs: Rec[] = " + json.dumps(records(400)) + ";\n"
+ "const t1 = performance.now();\n"
+ HOT_LOOP.split("const t1 = performance.now();", 1)[1].split("console.log(")[0]
+ 'console.log("recs_ms", Math.round(t2 - t1), w);\n')
(root / "records.ts").write_text(record_hot)
for count in [400, 1600, 3200, 4800, 6400, 12800]:
data = records(count)
literal = json.dumps(data)
source = (REC_TYPE + "const recs: Rec[] = " + literal + ";\n"
+ "let w = 0;\n"
+ "for (let j = 0; j < recs.length; j++) { const q = recs[j]; w += q.w + q.tags.length + q.id; }\n"
+ "console.log(recs.length, w);\n")
(root / f"records-{count}.ts").write_text(source)
text_bytes = sum(11 + len(r["name"]) + sum(map(len, r["tags"])) for r in data)
print(f"records={count} source_bytes={len(literal)} "
f"value_nodes={1 + 7 * count} key_string_bytes={text_bytes}")


if __name__ == "__main__":
main()
22 changes: 22 additions & 0 deletions changelog.d/10151-large-json-define.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
### Fix large JSON defines stalling LLVM code generation

- Lower large JSON-compatible literals, including build-time defines, to the
serialized-string `JSON.parse` intrinsic used for JSON imports. Flat arrays
of primitives use the 1,024-value-node / 64 KiB string threshold; other
object/array literals use a higher 24,576-node / 1 MiB key/string threshold.
Either size limit is sufficient, independent of function instruction budgets.
- Keep mid-size records on ordinary lowering so hot property reads retain their
static layouts. The higher threshold bounds the LLVM cost of huge record
literals while still handling the 4.6 MB OpenCode models.dev define.
- Keep parsing at the original evaluation site, preserving fresh values on
repeated reads and avoiding evaluation in untaken branches. Existing
`typeof` folding and define inputs to both cache keys are unchanged.
- Preserve property order, string escaping and negative zero; fall back for
JavaScript-specific constructs such as prototype setters, holes, spreads,
getters and non-finite numbers.
- Add unit coverage for both threshold tiers and direct lowering of 400 typed
records, a reproducible record-array/codegen benchmark, and timed regressions
for a >1 MiB define, cache invalidation, small direct literals, fresh nested objects,
shadowed `JSON` bindings and array behavior.

Fixes #10151.
6 changes: 6 additions & 0 deletions crates/perry-hir/src/lower/lower_expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ mod arm_optchain;
mod arm_unary;
mod assignment;
mod helpers;
mod json_literal;
mod reactive_text;

pub(crate) use arm_bin::lower_bin_expr;
Expand Down Expand Up @@ -99,6 +100,11 @@ pub(crate) fn lower_expr(ctx: &mut LoweringContext, expr: &ast::Expr) -> Result<
}

fn lower_expr_impl(ctx: &mut LoweringContext, expr: &ast::Expr) -> Result<Expr> {
if matches!(expr, ast::Expr::Object(_) | ast::Expr::Array(_)) {
if let Some(value) = json_literal::lower_large_json_literal(expr) {
return Ok(value);
}
}
match expr {
ast::Expr::Lit(lit) => lower_lit(lit),
ast::Expr::Ident(ident) => lower_ident_expr(ctx, ident),
Expand Down
Loading
Loading