diff --git a/CLAUDE.md b/CLAUDE.md index 379b015f73..9b08a61640 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1570 +**Current Version:** 0.5.1571 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index d2a41f5a52..7142aaaa5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5583,7 +5583,7 @@ checksum = "1542e48011813fbdf3c075da4a4ed53ee93c816eef62e36eb5064a6fd2be10a5" [[package]] name = "perry" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "anyhow", "base64 0.22.1", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "perry-dispatch", "serde", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-audio-miniaudio" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "cc", "libc", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "aho-corasick", "anyhow", @@ -5681,7 +5681,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "anyhow", "perry-hir", @@ -5689,7 +5689,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "anyhow", "perry-hir", @@ -5697,7 +5697,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "anyhow", "perry-dispatch", @@ -5706,7 +5706,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "anyhow", "perry-hir", @@ -5714,7 +5714,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "anyhow", "base64 0.22.1", @@ -5726,7 +5726,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "anyhow", "perry-hir", @@ -5734,7 +5734,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "async-trait", "clap", @@ -5758,14 +5758,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "serde", "serde_json", @@ -5773,7 +5773,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1570" +version = "0.5.1571" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5784,7 +5784,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "anyhow", "clap", @@ -5799,7 +5799,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "block2", "objc2", @@ -5809,7 +5809,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "argon2", "perry-ffi", @@ -5818,7 +5818,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "perry-ffi", "reqwest", @@ -5827,7 +5827,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "bcrypt", "perry-ffi", @@ -5835,7 +5835,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "perry-ffi", "rusqlite", @@ -5843,7 +5843,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "perry-ffi", "scraper", @@ -5851,7 +5851,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "perry-ffi", "perry-runtime", @@ -5859,7 +5859,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "chrono", "cron", @@ -5869,7 +5869,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "chrono", "perry-ffi", @@ -5877,7 +5877,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "perry-ffi", "rust_decimal", @@ -5885,7 +5885,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "perry-ffi", "serde_json", @@ -5893,7 +5893,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "perry-ffi", "rand 0.10.2", @@ -5901,7 +5901,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "perry-ffi", "perry-runtime", @@ -5909,14 +5909,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "bytes", "http-body-util", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "bytes", "lazy_static", @@ -5946,7 +5946,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "base64 0.22.1", "bytes", @@ -5978,7 +5978,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "lazy_static", "perry-ffi", @@ -5988,7 +5988,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "base64 0.22.1", "jsonwebtoken", @@ -5999,7 +5999,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "lru", "perry-ffi", @@ -6008,7 +6008,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "chrono", "perry-ffi", @@ -6016,7 +6016,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "bson", "futures-util", @@ -6028,7 +6028,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "chrono", "perry-ffi", @@ -6040,7 +6040,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "nanoid", "perry-ffi", @@ -6049,7 +6049,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "bytes", "perry-ffi", @@ -6064,7 +6064,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "const-oid 0.10.2", "der 0.8.1", @@ -6083,7 +6083,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "lettre", "perry-ffi", @@ -6093,7 +6093,7 @@ dependencies = [ [[package]] name = "perry-ext-parcel-watcher" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "notify", "perry-ffi", @@ -6105,7 +6105,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "perry-ffi", "printpdf", @@ -6113,7 +6113,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "perry-ffi", "sqlx", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-qs" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "perry-ffi", "perry-runtime", @@ -6131,7 +6131,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "governor", "perry-ffi", @@ -6139,7 +6139,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "fast_image_resize", "image", @@ -6150,7 +6150,7 @@ dependencies = [ [[package]] name = "perry-ext-streams" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "lazy_static", "perry-ffi", @@ -6159,7 +6159,7 @@ dependencies = [ [[package]] name = "perry-ext-typescript" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "anyhow", "perry-ffi", @@ -6179,7 +6179,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "perry-ffi", "perry-runtime", @@ -6188,7 +6188,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "perry-ffi", "uuid", @@ -6196,7 +6196,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "perry-ffi", "perry-validation", @@ -6205,7 +6205,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "futures-util", "lazy_static", @@ -6218,7 +6218,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "brotli", "flate2", @@ -6228,7 +6228,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "dashmap 6.2.1", "once_cell", @@ -6238,7 +6238,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "anyhow", "perry-api-manifest", @@ -6258,11 +6258,11 @@ dependencies = [ [[package]] name = "perry-native-registration" -version = "0.5.1570" +version = "0.5.1571" [[package]] name = "perry-parser" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "anyhow", "perry-diagnostics", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "perry-perex" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "perex", "regex", @@ -6283,7 +6283,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "ahash", "base64 0.22.1", @@ -6341,14 +6341,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6437,21 +6437,21 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "perry-hir", ] [[package]] name = "perry-ui" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "dirs", "perry-ffi", @@ -6461,7 +6461,7 @@ dependencies = [ [[package]] name = "perry-ui-android" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "base64 0.22.1", "jni", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "rand 0.10.2", "serde", @@ -6486,7 +6486,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "base64 0.22.1", "cairo-rs 0.22.9", @@ -6509,7 +6509,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "base64 0.22.1", "block2", @@ -6526,7 +6526,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "base64 0.22.1", "block2", @@ -6543,7 +6543,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1570" +version = "0.5.1571" [[package]] name = "perry-ui-test" @@ -6554,11 +6554,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1570" +version = "0.5.1571" [[package]] name = "perry-ui-tvos" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "base64 0.22.1", "block2", @@ -6575,7 +6575,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "base64 0.22.1", "block2", @@ -6592,7 +6592,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "block2", "libc", @@ -6606,7 +6606,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "base64 0.22.1", "libc", @@ -6625,7 +6625,7 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "base64 0.22.1", "libc", @@ -6638,7 +6638,7 @@ dependencies = [ [[package]] name = "perry-updater" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "anyhow", "base64 0.22.1", @@ -6653,7 +6653,7 @@ dependencies = [ [[package]] name = "perry-validation" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "idna", "regex", @@ -6663,7 +6663,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1570" +version = "0.5.1571" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 8d2a6ab121..1703902254 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -338,7 +338,7 @@ codegen-units = 1 codegen-units = 1 [workspace.package] -version = "0.5.1570" +version = "0.5.1571" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/benchmarks/large_json_literals/README.md b/benchmarks/large_json_literals/README.md index f3ebd8290d..e05041a3b0 100644 --- a/benchmarks/large_json_literals/README.md +++ b/benchmarks/large_json_literals/README.md @@ -1,5 +1,10 @@ # Large JSON literal lowering (#10151 / #10161) +The follow-up [#10173 audit](measurements-10173.md) measures the remaining +ordinary-path cliff and its static-shape descriptor replacement. `measure.py` +records fresh-cache standalone compile time, peak RSS, section sizes and phase +logs; `alternate.py` checks five alternating runs of pinned before/after binaries. + `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 diff --git a/benchmarks/large_json_literals/alternate.py b/benchmarks/large_json_literals/alternate.py new file mode 100644 index 0000000000..30e15e0367 --- /dev/null +++ b/benchmarks/large_json_literals/alternate.py @@ -0,0 +1,58 @@ +"""Five alternating runtime runs of already-linked Linux benchmark binaries. + +Usage: alternate.py RESULTS_DIR [hot numbers records records-hot-3200] +Each fixture must have before-FIXTURE/app and after-FIXTURE/app. One untimed +warmup per arm precedes the five measured pairs, pinned to the same CPU. +""" + +import json +import os +from pathlib import Path +import statistics +import subprocess +import sys + + +def main(): + if os.uname().sysname != "Linux": + raise SystemExit("build-host measurements only") + root = Path(sys.argv[1]).resolve() + fixtures = sys.argv[2:] or ["hot", "numbers", "records", "records-hot-3200"] + cpu = max(os.sched_getaffinity(0)) + report = {"cpu": cpu, "warmups_per_arm": 1, "runs_per_arm": 5, "fixtures": {}} + for fixture in fixtures: + rows = {"before": [], "after": []} + expected = None + for iteration in range(6): + for arm in rows: + binary = root / f"{arm}-{fixture}" / "app" + output = subprocess.check_output( + ["taskset", "-c", str(cpu), str(binary)], text=True, + cwd=binary.parent, timeout=60, + ).strip() + tokens = output.split() + timings = {} + while tokens and tokens[0].endswith("_ms"): + key, value, *tokens = tokens + timings[key] = int(value) + if expected is None: + expected = tokens + if tokens != expected or not timings: + raise AssertionError(f"{fixture} {arm}: bad output {output!r}") + if iteration: + rows[arm].append(timings) + print(f"{fixture} {arm} {iteration}: {output}", flush=True) + report["fixtures"][fixture] = { + "checksums": expected, + "runs": rows, + "medians_ms": { + arm: {key: statistics.median(row[key] for row in runs) + for key in runs[0]} + for arm, runs in rows.items() + }, + } + (root / "runtime.json").write_text(json.dumps(report, indent=2) + "\n") + + +if __name__ == "__main__": + main() diff --git a/benchmarks/large_json_literals/generate.py b/benchmarks/large_json_literals/generate.py index 1d86d888a8..cad35caa92 100644 --- a/benchmarks/large_json_literals/generate.py +++ b/benchmarks/large_json_literals/generate.py @@ -52,6 +52,10 @@ def main(): + 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) + # Same 20,000 passes with eight times as many records. Compare runtime + # divided by eight with records.ts; compare checksums between compilers. + scaled_hot = record_hot.replace(json.dumps(records(400)), json.dumps(records(3200)), 1) + (root / "records-hot-3200.ts").write_text(scaled_hot) for count in [400, 1600, 3200, 4800, 6400, 12800]: data = records(count) literal = json.dumps(data) diff --git a/benchmarks/large_json_literals/measure.py b/benchmarks/large_json_literals/measure.py new file mode 100644 index 0000000000..c082ee4662 --- /dev/null +++ b/benchmarks/large_json_literals/measure.py @@ -0,0 +1,89 @@ +"""Fresh-cache, standalone literal measurements; run on the Linux build host. + +Usage: measure.py COMPILER_DIR SOURCE RESULT_DIR [--link] [--fast-emit N] +The result directory must not exist. Logs, GNU time, section sizes and symbol +sizes are retained alongside summary.json. No application graph is compiled. +""" + +import argparse +import json +import os +from pathlib import Path +import re +import subprocess +import time + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("compiler", type=Path) + parser.add_argument("source", type=Path) + parser.add_argument("result", type=Path) + parser.add_argument("--link", action="store_true") + parser.add_argument("--fast-emit", type=int) + parser.add_argument("--profile", action="store_true") + parser.add_argument("--timeout", type=int, default=600) + args = parser.parse_args() + if os.uname().sysname != "Linux": + parser.error("build-host measurements only") + # Inspect only Perry processes: the guard cannot match this script or its + # shell command line. Another lane's full application compile gets priority. + running = subprocess.run( + ["ps", "-C", "perry", "-o", "args="], capture_output=True, text=True + ).stdout + if re.search(r"\bcompile src/index\.ts\b", running): + parser.error("a full OpenCode compile is running; wait for it to exit") + compiler = args.compiler.resolve() + source = args.source.resolve() + result = args.result.resolve() + result.mkdir(parents=True, exist_ok=False) + (result / "perry.json").write_text("{}\n") + env = os.environ.copy() + env.update(PERRY_RUNTIME_DIR=str(compiler), PERRY_CODEGEN_PROGRESS="all", + PERRY_CODEGEN_UNIT_TIMINGS="1", PERRY_DEBUG_SYMBOLS="1") + if args.fast_emit is not None: + env["PERRY_LL_FAST_EMIT_MAX_INSTRS"] = str(args.fast_emit) + output = result / ("app" if args.link else "module.o") + command = ["/usr/bin/time", "-v", "-o", str(result / "time.txt"), + "timeout", str(args.timeout), str(compiler / "perry"), "compile", + str(source), "--no-auto-optimize", "--output", str(output), + "--cache-dir", str(result / "cache")] + if not args.link: + command.append("--no-link") + if args.profile: + command = ["perf", "record", "-F", "99", "--call-graph", "dwarf,8192", + "-o", str(result / "perf.data"), "--"] + command + (result / "command.json").write_text(json.dumps(command, indent=2) + "\n") + started = time.monotonic() + with (result / "compile.log").open("w") as log: + process = subprocess.Popen(command, cwd=result, env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, errors="replace", bufsize=1) + for line in process.stdout: + log.write(f"{time.monotonic() - started:9.3f} {line}") + log.flush() + status = process.wait() + summary = dict(source=str(source), compiler=str(compiler), status=status, + wall_seconds=round(time.monotonic() - started, 3)) + if (result / "time.txt").exists(): + timing = (result / "time.txt").read_text() + rss = re.search(r"Maximum resident set size \(kbytes\): (\d+)", timing) + summary["rss_kib"] = int(rss[1]) if rss else None + if status == 0: + sections = subprocess.check_output(["size", "-A", str(output)], text=True) + (result / "sections.txt").write_text(sections) + summary["text_bytes"] = sum( + int(match[1]) for match in + re.finditer(r"^\.text\S*\s+(\d+)", sections, re.MULTILINE) + ) + symbols = subprocess.check_output( + ["nm", "--print-size", "--size-sort", str(output)], text=True + ) + (result / "symbols.txt").write_text(symbols) + (result / "summary.json").write_text(json.dumps(summary, indent=2) + "\n") + print(json.dumps(summary), flush=True) + raise SystemExit(status) + + +if __name__ == "__main__": + main() diff --git a/benchmarks/large_json_literals/measurements-10173.json b/benchmarks/large_json_literals/measurements-10173.json new file mode 100644 index 0000000000..82974358ef --- /dev/null +++ b/benchmarks/large_json_literals/measurements-10173.json @@ -0,0 +1,414 @@ +{ + "base_commit": "4a3c8ec9e2f449b39e6e6844ae1cdeab79fd918c", + "llvm": "22.1.8", + "platform": "Linux x86_64", + "baseline_note": "Only optimized-unit instruction-count diagnostic added to base; release compiler and matching runtime/stdlib archives built in this lane.", + "flags": [ + "--no-auto-optimize", + "--no-link" + ], + "pins": { + "baseline": { + "libperry_runtime.a": "ab3044c50f23ab664d1fac6e6762dda2b3a740d6802dde8bdaa8e12bfc684ad4", + "libperry_stdlib.a": "7dad8063d877118f137ff928d3ce1863b4ba264b90bb2d1d7659e97525876c88", + "perry": "88db949d211fcef94e8f5d8e01a52eb3a219091b09cd9529d010c0d68e4bbea4" + }, + "candidate": { + "libperry_runtime.a": "16317309c42dc6ef64c5aa6f738d9268243304b6c0e22c6eecdafa1bb650d322", + "libperry_stdlib.a": "ff51f8c394dd216edd2b1fbc56a9f26f0e81d2b3c26b8514d6f401842d533063", + "perry": "16697fa26381edb22c8acee7b8e6e52f7b1f97d7f6608d59b3e6acfca005d177" + } + }, + "standalone": { + "records-400": { + "before": { + "source": "/root/claude-opencode/lanes/literal-results/probes/records-400.ts", + "compiler": "/root/claude-opencode/lanes/literal-results/baseline", + "status": 0, + "wall_seconds": 40.369, + "rss_kib": 1730844, + "text_bytes": 14342070, + "phase_evidence": [ + " 31.568 [perry] codegen: `main` has 622076 instructions after IR optimization (unit total 624083, 5 functions, opt 26.201s)" + ] + }, + "after": { + "source": "/root/claude-opencode/lanes/literal-results/probes/records-400.ts", + "compiler": "/root/claude-opencode/lanes/literal-results/candidate", + "status": 0, + "wall_seconds": 0.3, + "rss_kib": 200264, + "text_bytes": 5949, + "phase_evidence": [ + " 0.110 [perry] codegen: `main` has 1087 instructions after IR optimization (unit total 1470, 5 functions, opt 0.023s)" + ] + } + }, + "records-3200": { + "before": { + "source": "/root/claude-opencode/lanes/literal-results/probes/records-3200.ts", + "compiler": "/root/claude-opencode/lanes/literal-results/baseline", + "status": 0, + "wall_seconds": 95.49, + "rss_kib": 1474996, + "text_bytes": 6297159, + "phase_evidence": [ + " 1.187 [perry] codegen: `perry_fn_records_3200_ts__u__5f__5f_perry_5f_entry_5f_chunk_5f_records_2d_3200_2e_ts_5f_1` has 960 instructions after IR optimization (unit total 1390, 8 functions, opt 0.022s)", + " 1.212 [perry] codegen: records_3200_ts: unit 3/3: 8 fns; pre-RS4GC 2391 instrs (widest perry_fn_records_3200_ts__u__5f__5f_perry_5f_entry_5f_chunk_5f_records_2d_3200_2e_ts_5f_1 1715); post-RS4GC 1676 instrs (x0.7; widest perry_fn_records_3200_ts__u__5f__5f_perry_5f_entry_5f_chunk_5f_records_2d_3200_2e_ts_5f_1 1055); rs4gc 0.0s, opt 0.0s, emit 0.0s", + " 1.214 [perry] codegen: `__perry_init_strings_records_3200_ts_chunk0` has 12865 instructions after IR optimization (unit total 12865, 1 functions, opt 0.033s)", + " 1.597 [perry] codegen: records_3200_ts: unit 2/3: 1 fns; pre-RS4GC 12865 instrs (widest __perry_init_strings_records_3200_ts_chunk0 12865); post-RS4GC 12865 instrs (x1.0; widest __perry_init_strings_records_3200_ts_chunk0 12865); rs4gc 0.0s, opt 0.0s, emit 0.4s", + " 90.934 [perry] codegen: `perry_fn_records_3200_ts__u__5f__5f_perry_5f_entry_5f_chunk_5f_records_2d_3200_2e_ts_5f_0` has 650941 instructions after IR optimization (unit total 650941, 1 functions, opt 88.272s)", + " 93.817 [perry] codegen: records_3200_ts: unit 1/3: 1 fns; pre-RS4GC 774396 instrs (widest perry_fn_records_3200_ts__u__5f__5f_perry_5f_entry_5f_chunk_5f_records_2d_3200_2e_ts_5f_0 774396); post-RS4GC 723186 instrs (x0.9; widest perry_fn_records_3200_ts__u__5f__5f_perry_5f_entry_5f_chunk_5f_records_2d_3200_2e_ts_5f_0 723186); rs4gc 0.7s, opt 88.2s, emit 2.8s" + ] + }, + "after": { + "source": "/root/claude-opencode/lanes/literal-results/probes/records-3200.ts", + "compiler": "/root/claude-opencode/lanes/literal-results/candidate", + "status": 0, + "wall_seconds": 0.633, + "rss_kib": 243844, + "text_bytes": 6017, + "phase_evidence": [ + " 0.437 [perry] codegen: `perry_fn_records_3200_ts__u__5f__5f_perry_5f_entry_5f_chunk_5f_records_2d_3200_2e_ts_5f_1` has 960 instructions after IR optimization (unit total 1435, 9 functions, opt 0.022s)" + ] + } + }, + "mime-other": { + "before": { + "source": "/root/claude-opencode/opencode/node_modules/.bun/mime@4.1.0/node_modules/mime/types/other.ts", + "compiler": "/root/claude-opencode/lanes/literal-results/baseline", + "status": 0, + "wall_seconds": 674.625, + "rss_kib": 2377108, + "text_bytes": 13670794, + "phase_evidence": [ + " 386.216 [perry] codegen: `other_ts____AnonShape_799e9b5d71a66990_constructor` has 522756 instructions after IR optimization (unit total 1037401, 7 functions, opt 364.792s)" + ] + }, + "after": { + "source": "/root/claude-opencode/opencode/node_modules/.bun/mime@4.1.0/node_modules/mime/types/other.ts", + "compiler": "/root/claude-opencode/lanes/literal-results/candidate", + "status": 0, + "wall_seconds": 0.874, + "rss_kib": 236224, + "text_bytes": 2517, + "phase_evidence": [ + " 0.699 [perry] codegen: `other_ts____AnonShape_799e9b5d71a66990_constructor` has 1370 instructions after IR optimization (unit total 1500, 7 functions, opt 0.059s)" + ] + } + }, + "mime-standard": { + "before": { + "source": "/root/claude-opencode/opencode/node_modules/.bun/mime@4.1.0/node_modules/mime/types/standard.ts", + "compiler": "/root/claude-opencode/lanes/literal-results/baseline", + "status": 0, + "wall_seconds": 63.74, + "rss_kib": 856244, + "text_bytes": 3419680, + "phase_evidence": [ + " 35.487 [perry] codegen: `standard_ts____AnonShape_88e425ef48475126_constructor` has 144416 instructions after IR optimization (unit total 277972, 7 functions, opt 33.181s)" + ] + }, + "after": { + "source": "/root/claude-opencode/opencode/node_modules/.bun/mime@4.1.0/node_modules/mime/types/standard.ts", + "compiler": "/root/claude-opencode/lanes/literal-results/candidate", + "status": 0, + "wall_seconds": 0.834, + "rss_kib": 227604, + "text_bytes": 1637, + "phase_evidence": [ + " 0.665 [perry] codegen: `standard_ts____AnonShape_88e425ef48475126_constructor` has 666 instructions after IR optimization (unit total 796, 7 functions, opt 0.028s)" + ] + } + }, + "named-references": { + "before": { + "source": "/root/claude-opencode/opencode/node_modules/.bun/html-entities@2.3.3/node_modules/html-entities/lib/named-references.js", + "compiler": "/root/claude-opencode/lanes/literal-results/baseline", + "status": 0, + "wall_seconds": 263.806, + "rss_kib": 4159680, + "text_bytes": 5069754, + "phase_evidence": [ + " 70.674 perry: `named_references_js____AnonShape_f9fef27cc00cd6ca_constructor` exceeded the post-RS4GC instruction budget (151112 -> 2426669 instructions; cap 1572864); retrying it with precise GC roots in a shadow frame at the requested optimization level (#8679)", + " 77.385 [perry] codegen: `named_references_js____AnonShape_f9fef27cc00cd6ca_constructor` has 116357 instructions after IR optimization (unit total 240457, 3 functions, opt 6.133s)", + " 106.732 [perry] codegen: named_references_js: unit 2/2: 3 fns; pre-RS4GC 282891 instrs (widest named_references_js____AnonShape_f9fef27cc00cd6ca_constructor 139033); post-RS4GC 283445 instrs (x1.0; widest named_references_js____AnonShape_f9fef27cc00cd6ca_constructor 137512); rs4gc 0.2s, opt 6.1s, emit 29.3s", + " 218.148 perry: `named_references_js____AnonShape_1397b18db6910348_constructor` exceeded the post-RS4GC instruction budget (223112 -> 5189309 instructions; cap 1572864); retrying it with precise GC roots in a shadow frame at the requested optimization level (#8679)", + " 230.937 [perry] codegen: `named_references_js____AnonShape_1397b18db6910348_constructor` has 171797 instructions after IR optimization (unit total 429153, 35 functions, opt 11.652s)", + " 262.524 [perry] codegen: named_references_js: unit 1/2: 35 fns; pre-RS4GC 328080 instrs (widest named_references_js____AnonShape_1397b18db6910348_constructor 205273); post-RS4GC 501232 instrs (x1.5; widest named_references_js____AnonShape_1397b18db6910348_constructor 203032); rs4gc 0.7s, opt 11.6s, emit 31.6s" + ] + }, + "after": { + "source": "/root/claude-opencode/opencode/node_modules/.bun/html-entities@2.3.3/node_modules/html-entities/lib/named-references.js", + "compiler": "/root/claude-opencode/lanes/literal-results/candidate", + "status": 0, + "wall_seconds": 1.762, + "rss_kib": 308792, + "text_bytes": 70266, + "phase_evidence": [ + " 1.304 [perry] codegen: `perry_closure_named_references_js__1` has 5023 instructions after IR optimization (unit total 20950, 36 functions, opt 0.513s)" + ] + } + } + }, + "controls": { + "records-3200": { + "source": "/root/claude-opencode/lanes/literal-results/probes/records-3200.ts", + "compiler": "/root/claude-opencode/lanes/literal-results/baseline", + "status": 0, + "wall_seconds": 92.427, + "rss_kib": 1510916, + "text_bytes": 6297159, + "phase_evidence": [ + " 1.656 [perry] codegen: `perry_fn_records_3200_ts__u__5f__5f_perry_5f_entry_5f_chunk_5f_records_2d_3200_2e_ts_5f_1` has 960 instructions after IR optimization (unit total 1390, 8 functions, opt 0.022s)", + " 1.681 [perry] codegen: `__perry_init_strings_records_3200_ts_chunk0` has 12865 instructions after IR optimization (unit total 12865, 1 functions, opt 0.033s)", + " 1.681 [perry] codegen: records_3200_ts: unit 3/3: 8 fns; pre-RS4GC 2391 instrs (widest perry_fn_records_3200_ts__u__5f__5f_perry_5f_entry_5f_chunk_5f_records_2d_3200_2e_ts_5f_1 1715); post-RS4GC 1676 instrs (x0.7; widest perry_fn_records_3200_ts__u__5f__5f_perry_5f_entry_5f_chunk_5f_records_2d_3200_2e_ts_5f_1 1055); rs4gc 0.0s, opt 0.0s, emit 0.0s", + " 2.033 [perry] codegen: records_3200_ts: unit 2/3: 1 fns; pre-RS4GC 12865 instrs (widest __perry_init_strings_records_3200_ts_chunk0 12865); post-RS4GC 12865 instrs (x1.0; widest __perry_init_strings_records_3200_ts_chunk0 12865); rs4gc 0.0s, opt 0.0s, emit 0.4s", + " 85.474 [perry] codegen: `perry_fn_records_3200_ts__u__5f__5f_perry_5f_entry_5f_chunk_5f_records_2d_3200_2e_ts_5f_0` has 650941 instructions after IR optimization (unit total 650941, 1 functions, opt 82.332s)", + " 88.238 [perry] codegen: records_3200_ts: unit 1/3: 1 fns; pre-RS4GC 774396 instrs (widest perry_fn_records_3200_ts__u__5f__5f_perry_5f_entry_5f_chunk_5f_records_2d_3200_2e_ts_5f_0 774396); post-RS4GC 723186 instrs (x0.9; widest perry_fn_records_3200_ts__u__5f__5f_perry_5f_entry_5f_chunk_5f_records_2d_3200_2e_ts_5f_0 723186); rs4gc 0.7s, opt 82.3s, emit 2.7s" + ] + }, + "records-3200-optimized-emit": { + "source": "/root/claude-opencode/lanes/literal-results/probes/records-3200.ts", + "compiler": "/root/claude-opencode/lanes/literal-results/baseline", + "status": 0, + "wall_seconds": 126.21, + "rss_kib": 1951424, + "text_bytes": 3609143, + "phase_evidence": [ + " 1.223 [perry] codegen: `perry_fn_records_3200_ts__u__5f__5f_perry_5f_entry_5f_chunk_5f_records_2d_3200_2e_ts_5f_1` has 960 instructions after IR optimization (unit total 1390, 8 functions, opt 0.022s)", + " 1.248 [perry] codegen: records_3200_ts: unit 3/3: 8 fns; pre-RS4GC 2391 instrs (widest perry_fn_records_3200_ts__u__5f__5f_perry_5f_entry_5f_chunk_5f_records_2d_3200_2e_ts_5f_1 1715); post-RS4GC 1676 instrs (x0.7; widest perry_fn_records_3200_ts__u__5f__5f_perry_5f_entry_5f_chunk_5f_records_2d_3200_2e_ts_5f_1 1055); rs4gc 0.0s, opt 0.0s, emit 0.0s", + " 1.249 [perry] codegen: `__perry_init_strings_records_3200_ts_chunk0` has 12865 instructions after IR optimization (unit total 12865, 1 functions, opt 0.035s)", + " 1.695 [perry] codegen: records_3200_ts: unit 2/3: 1 fns; pre-RS4GC 12865 instrs (widest __perry_init_strings_records_3200_ts_chunk0 12865); post-RS4GC 12865 instrs (x1.0; widest __perry_init_strings_records_3200_ts_chunk0 12865); rs4gc 0.0s, opt 0.0s, emit 0.4s", + " 91.288 [perry] codegen: `perry_fn_records_3200_ts__u__5f__5f_perry_5f_entry_5f_chunk_5f_records_2d_3200_2e_ts_5f_0` has 650941 instructions after IR optimization (unit total 650941, 1 functions, opt 88.438s)", + " 125.047 [perry] codegen: records_3200_ts: unit 1/3: 1 fns; pre-RS4GC 774396 instrs (widest perry_fn_records_3200_ts__u__5f__5f_perry_5f_entry_5f_chunk_5f_records_2d_3200_2e_ts_5f_0 774396); post-RS4GC 723186 instrs (x0.9; widest perry_fn_records_3200_ts__u__5f__5f_perry_5f_entry_5f_chunk_5f_records_2d_3200_2e_ts_5f_0 723186); rs4gc 0.7s, opt 88.4s, emit 33.8s" + ] + }, + "mime-other": { + "source": "/root/claude-opencode/opencode/node_modules/.bun/mime@4.1.0/node_modules/mime/types/other.ts", + "compiler": "/root/claude-opencode/lanes/literal-results/baseline", + "status": 124, + "wall_seconds": 600.136, + "rss_kib": 2225916, + "phase_evidence": [ + " 351.556 [perry] codegen: `other_ts____AnonShape_799e9b5d71a66990_constructor` has 522756 instructions after IR optimization (unit total 1037401, 7 functions, opt 339.134s)" + ] + } + }, + "runtime": { + "cpu": 15, + "warmups_per_arm": 1, + "runs_per_arm": 5, + "fixtures": { + "hot": { + "checksums": [ + "79042210000", + "2011000000" + ], + "runs": { + "before": [ + { + "table_ms": 286, + "recs_ms": 277 + }, + { + "table_ms": 288, + "recs_ms": 273 + }, + { + "table_ms": 288, + "recs_ms": 275 + }, + { + "table_ms": 287, + "recs_ms": 275 + }, + { + "table_ms": 287, + "recs_ms": 277 + } + ], + "after": [ + { + "table_ms": 70, + "recs_ms": 169 + }, + { + "table_ms": 71, + "recs_ms": 169 + }, + { + "table_ms": 70, + "recs_ms": 168 + }, + { + "table_ms": 70, + "recs_ms": 169 + }, + { + "table_ms": 70, + "recs_ms": 169 + } + ] + }, + "medians_ms": { + "before": { + "table_ms": 287, + "recs_ms": 275 + }, + "after": { + "table_ms": 70, + "recs_ms": 169 + } + } + }, + "numbers": { + "checksums": [ + "79042210000" + ], + "runs": { + "before": [ + { + "table_ms": 70 + }, + { + "table_ms": 70 + }, + { + "table_ms": 70 + }, + { + "table_ms": 70 + }, + { + "table_ms": 70 + } + ], + "after": [ + { + "table_ms": 70 + }, + { + "table_ms": 70 + }, + { + "table_ms": 70 + }, + { + "table_ms": 70 + }, + { + "table_ms": 70 + } + ] + }, + "medians_ms": { + "before": { + "table_ms": 70 + }, + "after": { + "table_ms": 70 + } + } + }, + "records": { + "checksums": [ + "2011000000" + ], + "runs": { + "before": [ + { + "recs_ms": 273 + }, + { + "recs_ms": 273 + }, + { + "recs_ms": 273 + }, + { + "recs_ms": 273 + }, + { + "recs_ms": 273 + } + ], + "after": [ + { + "recs_ms": 150 + }, + { + "recs_ms": 151 + }, + { + "recs_ms": 150 + }, + { + "recs_ms": 150 + }, + { + "recs_ms": 150 + } + ] + }, + "medians_ms": { + "before": { + "recs_ms": 273 + }, + "after": { + "recs_ms": 150 + } + } + }, + "records-hot-3200": { + "checksums": [ + "128088000000" + ], + "runs": { + "before": [ + { + "recs_ms": 1242 + }, + { + "recs_ms": 1249 + }, + { + "recs_ms": 1246 + }, + { + "recs_ms": 1247 + }, + { + "recs_ms": 1242 + } + ], + "after": [ + { + "recs_ms": 1176 + }, + { + "recs_ms": 1177 + }, + { + "recs_ms": 1176 + }, + { + "recs_ms": 1175 + }, + { + "recs_ms": 1175 + } + ] + }, + "medians_ms": { + "before": { + "recs_ms": 1246 + }, + "after": { + "recs_ms": 1176 + } + } + } + } + } +} diff --git a/benchmarks/large_json_literals/measurements-10173.md b/benchmarks/large_json_literals/measurements-10173.md new file mode 100644 index 0000000000..bb3f01364f --- /dev/null +++ b/benchmarks/large_json_literals/measurements-10173.md @@ -0,0 +1,163 @@ +# Mid-size record literal cliff (#10173) + +The 3,200-record fixture has 22,401 AST value nodes, below the existing +24,576-node record cutoff. It still lowers to `__AnonShape_*` HIR, preserving +typed property reads. This change replaces its construction code with a static +descriptor and a shared runtime materializer. It does not lower the JSON.parse +cutoff or change property-read codegen. + +## Attribution + +Linux x86_64, LLVM 22.1.8, release compiler at `4a3c8ec9e2` (v0.5.1568). +Both arms were built in this lane with the identical package set: +`cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static`. +Each compiler and its freshly built archives were pinned together, with +`PERRY_RUNTIME_DIR` set explicitly. The baseline includes only the diagnostic +addition that reports optimized instruction counts below the existing budget. +All builds, tests and execution ran on the Linux host through `remote.sh`. + +The fresh-cache 3,200-record baseline takes **95.49 s / 1,440 MiB** here. Its +critical codegen unit has these timings: + +| Phase | Time | Evidence | +| --- | ---: | --- | +| Lowering function bodies to LLVM IR | 0.4 s | The synthetic four-field constructor itself takes <0.1 s | +| RS4GC preprocessing | 0.7 s | 774,396 → 723,186 instructions; the existing relocation estimate selects shadow roots up front | +| LLVM IR optimization | **88.27 s** | 650,941 instructions remain in the initializer | +| Machine emission, default 600,000-instruction cap | 2.8 s | The single-function unit uses the O0 machine pipeline | + +A separate `perf record -F 99 --call-graph dwarf,8192` run corroborates the +phase attribution: 82.33 s optimizing, 0.7 s in RS4GC, 2.7 s emitting. The +compiler is stripped, so the pass timings, rather than unsymbolized sample +addresses, are the useful attribution. That run takes 92.43 s including perf +overhead. This is a new baseline, not a restatement of the issue's older 342 s +measurement. + +The handoff's fast-emission lead was tested explicitly. Setting +`PERRY_LL_FAST_EMIT_MAX_INSTRS=0` takes **126.21 s / 1,906 MiB**: optimization +still costs 88.4 s and emission rises to 33.8 s. `.text` falls from 6,297,159 +to 3,609,143 bytes, but compilation becomes slower. Raising that limit would +not fix this cliff. The default fallback already bounds its emission cost. + +Other shapes hit different parts of the same expansion. html-entities named +references expands a wide constructor from 223,112 to **5,189,309 instructions** +in RS4GC and retries with shadow roots. MIME `other.ts` spends 339.13 s in IR +optimization on its initial timed-out run, then more than four minutes in +machine emission. HIR lowering takes only hundredths of a second there. + +## Implementation and tradeoffs + +Constant record trees with at least 256 HIR value nodes are serialized into +read-only data. The gate requires at least one record and proves each +constructor consists solely of ordered parameter-to-field stores. It declines +effects, user constructors, captures, computed properties, spreads and strings +requiring the separate WTF-16 path. Primitive-only arrays retain their existing +lowering. The speculative descriptor walk is depth-bounded. + +The schema uses the existing class ID, rooted keys global, typed ShapeId global +and immutable raw/pointer masks. The materializer creates fresh mutable arrays, +strings and objects, fills fields in order, and validates the same typed layout +used by ordinary `new`. It contains no user callbacks and suppresses GC while +building partial trees, like the existing constant-array descriptor. Its table +holds addresses of registered module root slots, never cached heap pointers. + +Wide synthetic constructors (32+ fields) still need a callable body even when +constant sites bypass them. They now marshal boxed arguments into a stack +buffer and call one shared strict-assignment loop; `noinline` prevents LLVM +from duplicating that marshalling body. The helper roots all operands before +the first assignment, rereads them after setters, and preserves strict +descriptor/prototype behavior. Arbitrary value expressions still evaluate on +the ordinary path. This addresses both huge initialization sites and huge +otherwise-unused constructor definitions. Chunking would retain many repeated +allocation/rooting/store sequences; static data removes those sequences while +keeping the existing object representation and read performance. + +## Fresh-cache standalone measurements + +`--no-link --no-auto-optimize`, default optimization and GC settings. `.text` +is the sum of `.text*` sections from `size -A` on the resulting module object; +it excludes shared runtime code. RSS is GNU time's maximum RSS, converted from +KiB to MiB. Each module resolves to exactly one input module; no full OpenCode +compile was run. Times are individual observations on a shared build host. + +| Input | Seconds before → after | Peak MiB before → after | `.text` bytes before → after | +| --- | ---: | ---: | ---: | +| records-400 | 40.369 → 0.300 | 1,690 → 196 | 14,342,070 → 5,949 | +| records-3200 | 95.490 → 0.633 | 1,440 → 238 | 6,297,159 → 6,017 | +| MIME other.ts | 674.625 → 0.874 | 2,321 → 231 | 13,670,794 → 2,517 | +| MIME standard.ts | 63.740 → 0.834 | 836 → 222 | 3,419,680 → 1,637 | +| html-entities named-references.js | 263.806 → 1.762 | 4,062 → 302 | 5,069,754 → 70,266 | + +The modules are MIME 4.1.0 `types/{other,standard}.ts` and html-entities 2.3.3 +`lib/named-references.js`, from the read-only OpenCode dependency checkout. +The initial MIME `other.ts` run timed out at 600 s; the table uses its completed +rerun with a longer timeout. + +| Input | Largest function instructions after IR optimization, before → after | Sum over all emitted units, before → after | +| --- | ---: | ---: | +| records-400 | 622,076 → 1,087 | 624,083 → 1,470 | +| records-3200 | 650,941 → 960 | 665,196 → 1,435 | +| MIME other.ts | 522,756 → 1,370 | 1,037,401 → 1,500 | +| MIME standard.ts | 144,416 → 666 | 277,972 → 796 | +| html-entities named-references.js | 171,797 → 5,023 | 669,610 → 20,950 | + +For named references, the counts describe the successfully emitted units after +the baseline retry. Its largest remaining candidate function is a closure; +the other MIME maxima are the synthetic constructors. For records-3200 the +candidate maximum is the read loop; the descriptor initializer is smaller. + +## Runtime: five alternating pairs per fixture + +One warmup per arm, then five `before, after` pairs, pinned to the same CPU +(15). The initial scaling series overlapped another lane's release build and +was retained separately; the complete series below was repeated after that +build exited. Both arms use their own matching pinned archives. + +| Fixture / loop | Before median | After median | Checksum, identical in both arms | +| --- | ---: | ---: | ---: | +| hot.ts numeric | 287 ms | 70 ms | 79,042,210,000 | +| hot.ts records | 275 ms | 169 ms | 2,011,000,000 | +| Isolated numeric | 70 ms | 70 ms | 79,042,210,000 | +| Isolated 400 records | 273 ms | 150 ms | 2,011,000,000 | +| Isolated 3,200 records | 1,246 ms | 1,176 ms | 128,088,000,000 | + +The 3,200-record loop visits eight times as many records: 1,176 / 8 = **147 ms**, +within 2% of the 400-record 150 ms median. The combined-file speedup includes +recovering optimized machine emission for the read loops; it is not an +intrinsic numeric-array speedup. The isolated numeric arm is unchanged. + +## Validation and reproduction + +Passed: `cargo test -p perry-hir`; all ten `json_literal` cutoff tests; +`cargo test -p perry --test issue_10151_large_json_define`; three descriptor +codegen tests; descriptor and constructor runtime tests with +`RUST_TEST_THREADS=1`; build-cache environment registration; formatting, +file-size, address-classification, runtime-root inventory and Node-pin checks. +The 3,200-record integration regression has a 60-second compile limit, requires +static-shape HIR without `JsonParse`, and checks order, freshness, mutation and +collection between materializations. A codegen unit test also caps each emitted +function body, so a faster CI machine cannot hide the code-size cliff. + +Forced-evacuation unit tests require an actual copying minor and changed object +addresses, then verify traced children and assignment after a collecting +setter. A compiled record/effectful-wide-record GC probe passes with both native +and shadow roots. Static checks of its shadow LLVM IR cover 89 root stores and +80 GC-capable allocas: zero dominance violations, fatal stale-register uses or +unrooted allocas. No root-checker exemptions were added. + +Generate probes with `generate.py`, then use: + +```sh +python3 benchmarks/large_json_literals/measure.py /path/to/pinned/before \ + /path/to/probes/records-3200.ts /path/to/results/before-records-3200 +# Repeat with the candidate and the three standalone module paths. +# Add --profile for perf; add --fast-emit 0 for the emission control. +# Link hot/numbers/records/records-hot-3200 with --link into before-*/after-*. +python3 benchmarks/large_json_literals/alternate.py /path/to/results +``` + +Raw phase logs, `perf.data`, section/symbol sizes, commands, archive hashes and +all runtime samples are retained in the lane's `literal-results` directory. +The checked-in JSON records the measurements and executable/archive hashes. +Unverified: full OpenCode binary size/startup, other dependency modules, and +non-Linux targets; the requested standalone measurements do not establish them. diff --git a/changelog.d/10268-gc-medium-parse-side-allocation-pacing.md b/changelog.d/10268-gc-medium-parse-side-allocation-pacing.md new file mode 100644 index 0000000000..c4a59995d6 --- /dev/null +++ b/changelog.d/10268-gc-medium-parse-side-allocation-pacing.md @@ -0,0 +1,70 @@ +### Fixed + +**GC: a `JSON.parse` loop over medium documents retained ~32 MB of dead tape per cycle.** + +`records_array_16k:parse` was the one row of the 50-cell JSON matrix still losing +on peak RSS: **74.98 MiB against Node 26.5.1's 65 and Bun's 71** on the bench +mini. It now peaks at **48.86 MiB** — 25 % under Node — with CPU 2.8 % *better* +than before. + +**Where the bytes were.** A lazily-parsed document's memory is not in the arena. +Parsing the 13 197-byte `records_array_16k` fixture puts ~1.1 KB in the nursery +(the `LazyArrayHeader`, its sparse cache and bitmap) and ~24 KB of tape in a +`json_tape_store` side allocation. Every pacing input a parse boundary reads is +denominated in *arena* bytes, so the young generation saw 1/24th of what the +process was holding. `PERRY_GC_DIAG=1` over the row's 11 284 iterations at +`origin/main`: **eight collections, every one a full mark-sweep from +`alloc_point_old_reclaim`**, each firing at `external_side=33.6 MB` with +`arena_total` between 3 and 8 MB, `old_in_use=0`, and `from_space` never above +6.9 MB against a 16 MB nursery cap. The row's only pacing was the old-reclaim +growth band happening to read those side bytes — i.e. 32 MB of dead tape per +cycle. + +**The fix, in two halves.** + +* A third arm on `tiny_parse_generational_collection_due`, keyed on + `external_side_live_bytes()` with the `max(floor, baseline)` growth band + old-gen reclaim already uses, based at the reading the last collection left + behind. A futile collection (an old-owned cluster whose tape survives) + re-bases the band at the surviving value, so repeats space out geometrically + instead of livelocking. +* The band's counterweight: all reported side-buffer releases stay in the + old-reclaim pressure term until the next full + (`external_side_old_reclaim_pressure_bytes`). Only a full returns arena + capacity, and on these rows the external term was paying for that too; + draining it with cheap minors alone took `records_array_1m:sparse` from seven + fulls to one, the arena's dirty pages from 29 MB to 55 MB, and peak RSS from + 63.5 to 73.6 MiB *even though live external bytes had halved*. The + cumulative term restores the full cadence on that measured workload. It + also includes mutator-side releases (tape materialization, regex scratch, + native-addon adjustments and buffer replacement), so it can trigger fulls + earlier than the previous live-only term on other workloads. + +**Measured** on the bench mini, 9 interleaved rounds, best of each, all 50 cells +of the JSON matrix (peak RSS, MiB / CPU, ms): + +| row | main | this | ΔRSS | ΔCPU | +|---|---|---|---|---| +| `records_array_16k:parse` | 74.98 / 155.2 | 48.86 / 150.8 | **−34.8 %** | −2.8 % | +| `records_array_16k:sparse` | 65.17 / 149.5 | 44.48 / 142.5 | −31.8 % | −4.7 % | +| `records_array_8m:roundtrip` | 129.00 / 146.7 | 99.16 / 139.6 | −23.1 % | −4.8 % | +| `records_array_16k:roundtrip` | 63.72 / 143.5 | 51.12 / 152.0 | −19.8 % | +5.9 % | +| `records_array_8m:parse` | 97.84 / 134.2 | 85.44 / 131.7 | −12.7 % | −1.9 % | +| `records_array_8m:sparse` | 98.39 / 132.7 | 86.00 / 129.2 | −12.6 % | −2.6 % | +| `records_array_1m:roundtrip` | 61.36 / 162.9 | 58.23 / 165.5 | −5.1 % | +1.6 % | +| `numbers_1m:parse` | 62.81 / 160.7 | 60.28 / 164.8 | −4.0 % | +2.6 % | +| `records_array_1m:parse` | 65.78 / 157.7 | 64.75 / 158.9 | −1.6 % | +0.8 % | +| `heterogeneous_1m:parse` | 59.05 / 160.3 | 62.52 / 159.4 | **+5.9 %** | −0.6 % | + +The remaining 40 rows move by less than 1 % on both axes. `heterogeneous_1m:parse` +is the one row that grows: its arena capacity goes 5.24 → 8.39 MiB (one extra +live non-general block plus one in-place promoted block) for a live-external +reading that falls — the standing cost of running copying minors on a row that +previously ran none — while its nine full collections are preserved exactly. + +`gc_ratchet` (14 probes, 7 repeats each, `main` vs this): correctness passes on +all 14, and **`heap_used_bytes` and `heap_total_bytes` are bit-identical on every +probe** — the gated retention counters do not move. Peak RSS (min of 7) stays +within ±0.5 %, the largest being `03_cross_gen_writes` 25.25 → 25.38 MiB +(+0.13 MiB) and `01_nursery_churn` 27.17 → 27.30 MiB; wall clock runs from +`08_map_set_sidetables` −5.1 % to `06_string_retention` +1.2 %. diff --git a/changelog.d/10273-record-literal-cliff.md b/changelog.d/10273-record-literal-cliff.md new file mode 100644 index 0000000000..c14d2babf9 --- /dev/null +++ b/changelog.d/10273-record-literal-cliff.md @@ -0,0 +1,14 @@ +Fix the mid-size record-literal compile cliff while retaining static record +shapes and typed property reads (#10173, OpenCode bring-up #10107). Constant +record trees now materialize from read-only descriptors using the same class +IDs, rooted keys, typed shape IDs and field masks as ordinary construction. +Wide synthetic constructors share a strict assignment helper with precise +operand roots instead of duplicating huge field-store bodies. + +On the Linux LLVM 22 audit, 3,200 records compile in 0.63 seconds versus 95.49 +seconds, with peak RSS falling from 1,440 to 238 MiB and module `.text` from +6.30 MB to 6.02 KB. Five alternating runtime runs preserve both checksums and +numeric performance; record reads improve. Regression coverage checks bounded +IR, the below-cutoff compile case, fresh mutable values, and real GC movement. +See `benchmarks/large_json_literals/measurements-10173.md` for phase attribution, +the fast-emission control and standalone OpenCode module measurements. diff --git a/changelog.d/10275-headers-proxy-record-init.md b/changelog.d/10275-headers-proxy-record-init.md new file mode 100644 index 0000000000..51485ea7f8 --- /dev/null +++ b/changelog.d/10275-headers-proxy-record-init.md @@ -0,0 +1,3 @@ +Accept a Proxy as a `Headers` record init. `new Headers(new Proxy({ "x-a": "1" }, {}))` raised "Headers constructor: init is not iterable": the record path required a plain heap object, and a proxy value is a proxy id rather than one, so neither the iterable nor the record branch applied. The constructor now reads a proxied init's own keys, enumerability and values through the proxy's `ownKeys`, `getOwnPropertyDescriptor` and `get` traps, matching how the spec reads a record init through the object's internal methods. Plain objects, arrays, maps, sets and string inits are unchanged. This unblocks OpenCode's request path, where the AI SDK hands the fetch layer a proxied header record (#10107). + +Only enumerable own properties enter the record. Missing and non-enumerable properties are skipped, enumerable symbol keys throw during ByteString conversion, and descriptor/get order is preserved. diff --git a/crates/perry-codegen/src/codegen/literal_constructor.rs b/crates/perry-codegen/src/codegen/literal_constructor.rs new file mode 100644 index 0000000000..51a60d2a55 --- /dev/null +++ b/crates/perry-codegen/src/codegen/literal_constructor.rs @@ -0,0 +1,58 @@ +//! Wide synthetic constructors keep their existing boxed ABI, but marshal +//! arguments once for a shared runtime assignment loop. This also bounds the +//! constructors emitted for shapes whose literal sites use descriptors. + +use crate::module::LlModule; +use crate::types::{DOUBLE, I32, I64, PTR}; + +pub(super) fn try_compile( + module: &mut LlModule, + class: &perry_hir::Class, + method: &perry_hir::Function, + name: &str, + keys_global: Option<&String>, +) -> bool { + if class.fields.len() < 32 + || !class.is_literal_shape() + || method.name != format!("{}_constructor", class.name) + || method.params.len() != class.fields.len() + { + return false; + } + let Some(keys) = keys_global else { + return false; + }; + let mut params = vec![(DOUBLE, "%this_arg".to_string())]; + params.extend( + method + .params + .iter() + .map(|p| (DOUBLE, format!("%arg{}", p.id))), + ); + let function = module.define_function(name, DOUBLE, params); + function.no_inline = true; + function.create_block("entry"); + let values = function.alloca_entry_array(DOUBLE, method.params.len()); + let block = function.block_mut(0).unwrap(); + for (i, param) in method.params.iter().enumerate() { + let slot = block.gep(DOUBLE, &values, &[(I64, &i.to_string())]); + block.store(DOUBLE, &format!("%arg{}", param.id), &slot); + } + // No allocating operation precedes this call. The runtime roots the + // receiver and complete buffer before it can run a setter; no managed + // operand is used here after the call, so this frame needs no GC slots. + block.call_void( + "js_literal_shape_initialize", + &[ + (DOUBLE, "%this_arg"), + (PTR, &format!("@{keys}")), + (PTR, &values), + (I32, &method.params.len().to_string()), + ], + ); + block.ret( + DOUBLE, + &crate::nanbox::double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), + ); + true +} diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index 1c3451e7e6..cdccaffa7b 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -125,6 +125,24 @@ pub(super) fn compile_method( if name == super::arguments::SYNTHETIC_ARGUMENTS_LENGTH_TYPE ) }); + if typed_public_trampoline.is_none() + && !force_generic_body + && proven_this.is_none() + && nonnegative_index_params.is_none() + && !fast_array_handle_clone + && !ptr_array_cache_clone + && !guarded_undefined_clone + && !pshape_arg_clone + && super::literal_constructor::try_compile( + llmod, + class, + method, + &public_llvm_name, + cross_module.class_keys_globals.get(&class.name), + ) + { + return Ok(()); + } // Representation-selection Phase 5a: the proven-`this` clone is a SECOND, // additive body compiled from the same HIR through the same statement // lowerer. It never replaces the public symbol and never participates in diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index a211a5782c..05a3c19e95 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -213,6 +213,7 @@ mod tdz_names; // `pub(crate)` so `crate::linker` can read the inline-hot-small policy // (`inline_hot_small_enabled` / `inline_hot_small_hint_threshold`). pub(crate) mod helpers; +mod literal_constructor; mod method; mod method_registry; mod method_trampolines; diff --git a/crates/perry-codegen/src/expr/dispatch.rs b/crates/perry-codegen/src/expr/dispatch.rs index 7fd8fd61c4..4832b5e94c 100644 --- a/crates/perry-codegen/src/expr/dispatch.rs +++ b/crates/perry-codegen/src/expr/dispatch.rs @@ -31,6 +31,9 @@ pub(crate) fn lower_expr(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { if let Some(value) = super::suffix_cursor::try_lower(ctx, expr)? { return Ok(value); } + if let Some(value) = super::literal_descriptor::try_lower(ctx, expr) { + return Ok(value); + } if let Some(lowered) = lower_expr_value(ctx, expr)? { if ctx.discard_expr_value { return Ok(materialize_js_value_without_record(ctx, lowered)); diff --git a/crates/perry-codegen/src/expr/literal_descriptor.rs b/crates/perry-codegen/src/expr/literal_descriptor.rs new file mode 100644 index 0000000000..7a68cd610b --- /dev/null +++ b/crates/perry-codegen/src/expr/literal_descriptor.rs @@ -0,0 +1,175 @@ +//! Constant record trees retain their ordinary HIR shapes, but construction is +//! data-driven. A descriptor references the same rooted keys and typed ShapeId +//! globals as `new`; it never changes the representation used by property reads. + +use std::collections::HashMap; + +use perry_hir::Expr; + +use super::FnCtx; +use crate::types::{DOUBLE, I32, PTR}; + +const MIN_NODES: usize = 256; +const MAX_DEPTH: usize = 128; +// Must match runtime::array::literal_descriptor::LiteralShape (repr(C)). +const SHAPE_TYPE: &str = "{ i32, i32, ptr, ptr, ptr, i32, ptr, i32 }"; + +#[derive(Default)] +struct Descriptor { + bytes: Vec, + shapes: Vec, + shape_indices: HashMap, + nodes: usize, +} + +impl Descriptor { + fn u32(&mut self, value: usize) -> Option<()> { + self.bytes + .extend_from_slice(&u32::try_from(value).ok()?.to_le_bytes()); + Some(()) + } + + fn shape(&mut self, ctx: &FnCtx<'_>, name: &str, argc: usize) -> Option { + if let Some(index) = self.shape_indices.get(name) { + return Some(*index); + } + let class = ctx.classes.get(name)?; + if !class.is_literal_shape() || argc != class.fields.len() { + return None; + } + let keys = ctx.class_keys_globals.get(name)?; + let class_id = ctx.class_ids.get(name)?; + if ctx.class_field_counts.get(name).copied()? as usize != argc { + return None; + } + let layout = crate::typed_shape::class_typed_layout(ctx.classes, name); + let mask_ref = |words: &[u64], global: String| { + if words.is_empty() { + "null".to_string() + } else { + format!("@{global}") + } + }; + let raw_mask = mask_ref( + &layout.raw_f64_mask_words, + crate::typed_shape::raw_f64_mask_global_name_from_keys_global(keys), + ); + let pointer_mask = mask_ref( + &layout.pointer_mask_words, + crate::typed_shape::mask_global_name_from_keys_global(keys), + ); + let shape_id = crate::typed_shape::shape_id_global_name_from_keys_global(keys); + let index = u32::try_from(self.shapes.len()).ok()?; + self.shapes.push(format!( + "{SHAPE_TYPE} {{ i32 {class_id}, i32 {argc}, ptr @{keys}, ptr @{shape_id}, \ + ptr {raw_mask}, i32 {}, ptr {pointer_mask}, i32 {} }}", + layout.raw_f64_mask_words.len(), + layout.pointer_mask_words.len(), + )); + self.shape_indices.insert(name.to_string(), index); + Some(index) + } + + fn value(&mut self, ctx: &FnCtx<'_>, expr: &Expr, depth: usize) -> Option<()> { + if depth > MAX_DEPTH { + return None; + } + self.nodes += 1; + match expr { + Expr::Number(n) => { + self.bytes.push(0); + self.bytes.extend_from_slice(&n.to_le_bytes()); + } + Expr::Integer(n) => { + self.bytes.push(0); + self.bytes.extend_from_slice(&(*n as f64).to_le_bytes()); + } + Expr::Array(elements) => { + self.bytes.push(1); + self.u32(elements.len())?; + for element in elements { + self.value(ctx, element, depth + 1)?; + } + } + Expr::Bool(true) => self.bytes.push(2), + Expr::Bool(false) => self.bytes.push(3), + Expr::Null => self.bytes.push(4), + Expr::Undefined => self.bytes.push(5), + Expr::String(value) => { + self.bytes.push(6); + self.u32(value.len())?; + self.bytes.extend_from_slice(value.as_bytes()); + } + Expr::New { + class_name, + args, + cap_args_appended: 0, + .. + } => { + // Recheck arity on cache hits too: malformed/extra-argument + // constructions must retain normal evaluation and call semantics. + if ctx.classes.get(class_name)?.fields.len() != args.len() { + return None; + } + let index = self.shape(ctx, class_name, args.len())?; + self.bytes.push(7); + self.bytes.extend_from_slice(&index.to_le_bytes()); + for arg in args { + self.value(ctx, arg, depth + 1)?; + } + } + // Calls, spreads, computed properties, captures and user classes + // all keep ordinary evaluation; no partially serialized expression + // is ever emitted or evaluated. + _ => return None, + } + Some(()) + } +} + +pub(super) fn try_lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Option { + if !matches!(expr, Expr::Array(_) | Expr::New { .. }) { + return None; + } + let mut descriptor = Descriptor::default(); + descriptor.value(ctx, expr, 0)?; + if descriptor.nodes < MIN_NODES || descriptor.shapes.is_empty() { + return None; + } + let len = u32::try_from(descriptor.bytes.len()).ok()?; + let index = ctx.ic_site_counter; + ctx.ic_site_counter += 1; + let function: String = ctx + .func + .name + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + let name = format!("perry_literal_{function}_{index}"); + let mut bytes = String::with_capacity(descriptor.bytes.len() * 3); + for byte in descriptor.bytes { + use std::fmt::Write; + write!(&mut bytes, "\\{byte:02X}").unwrap(); + } + ctx.typed_parse_rodata.push(format!( + "@{name} = private unnamed_addr constant [{len} x i8] c\"{bytes}\"", + )); + let count = descriptor.shapes.len(); + ctx.typed_parse_rodata.push(format!( + "@{name}_shapes = private constant [{count} x {SHAPE_TYPE}] [{}]", + descriptor.shapes.join(", "), + )); + Some(ctx.block().call( + DOUBLE, + "js_value_from_literal_descriptor", + &[ + (PTR, &format!("@{name}")), + (I32, &len.to_string()), + (PTR, &format!("@{name}_shapes")), + (I32, &count.to_string()), + ], + )) +} + +#[cfg(test)] +mod tests; diff --git a/crates/perry-codegen/src/expr/literal_descriptor/tests.rs b/crates/perry-codegen/src/expr/literal_descriptor/tests.rs new file mode 100644 index 0000000000..addb748a28 --- /dev/null +++ b/crates/perry-codegen/src/expr/literal_descriptor/tests.rs @@ -0,0 +1,168 @@ +use perry_hir::types::Type; +use perry_hir::{Class, ClassField, Expr, Function, Module, Param, Stmt}; + +fn fixture(records: usize, fields: usize) -> Module { + let class_name = "__AnonShape_descriptor_test"; + let params: Vec<_> = (0..fields) + .map(|i| Param { + id: i as u32 + 1, + name: format!("k{i}"), + ty: Type::Number, + default: None, + decorators: vec![], + is_rest: false, + arguments_object: None, + }) + .collect(); + let constructor = Function { + id: 1, + name: "constructor".into(), + type_params: vec![], + body: params + .iter() + .map(|p| { + Stmt::Expr(Expr::PropertySet { + object: Box::new(Expr::This), + property: p.name.clone(), + value: Box::new(Expr::LocalGet(p.id)), + }) + }) + .collect(), + params, + return_type: Type::Void, + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: vec![], + decorators: vec![], + was_plain_async: false, + was_unrolled: false, + }; + let class = Class { + id: 101, + name: class_name.into(), + type_params: vec![], + extends: None, + extends_name: None, + extends_expr: None, + native_extends: None, + heritage_lexically_shadowed: false, + fields: (0..fields) + .map(|i| ClassField { + name: format!("k{i}"), + ty: Type::Number, + key_expr: None, + init: None, + is_private: false, + is_readonly: false, + decorators: vec![], + }) + .collect(), + constructor: Some(constructor), + methods: vec![], + getters: vec![], + setters: vec![], + static_fields: vec![], + static_methods: vec![], + static_accessor_names: vec![], + static_accessor_fn_ids: vec![], + computed_members: vec![], + decorators: vec![], + is_exported: false, + aliases: vec![], + is_nested: false, + alloc_width_hint: 0, + specialized_from: None, + }; + let mut module = Module::new("literal_descriptor_test"); + module.classes.push(class); + module.init.push(Stmt::Let { + id: fields as u32 + 100, + name: "records".into(), + ty: Type::Array(Box::new(Type::Named(class_name.into()))), + mutable: false, + init: Some(Expr::Array( + (0..records) + .map(|r| Expr::New { + class_name: class_name.into(), + args: (0..fields).map(|i| Expr::Number((r + i) as f64)).collect(), + type_args: vec![], + byte_offset: 0, + cap_args_appended: 0, + }) + .collect(), + )), + }); + module +} + +fn ir(module: &Module) -> String { + String::from_utf8( + crate::compile_module(module, super::super::class_field_barrier_tests::ir_opts()) + .expect("literal fixture lowers"), + ) + .unwrap() +} + +#[test] +fn cliff_literal_emits_one_materializer_and_bounded_function_bodies() { + let ir = ir(&fixture(3200, 4)); + assert_eq!( + ir.matches("call double @js_value_from_literal_descriptor(") + .count(), + 1 + ); + // Count instructions inside functions, not descriptor bytes in globals. + let mut in_function = false; + let mut lines = 0; + for line in ir.lines() { + if line.starts_with("define ") { + in_function = true; + lines = 0; + } else if line == "}" { + assert!( + lines < 2000, + "a literal must not recreate a giant function: {lines} lines" + ); + in_function = false; + } else if in_function { + lines += 1; + } + } + assert!(ir.contains("perry_class_keys_")); + assert!(ir.contains("perry_class_shape_id_")); + assert!(ir.contains("perry_typed_shape_raw_f64_mask_")); +} + +#[test] +fn small_literals_and_effectful_constructors_keep_normal_evaluation() { + let small = ir(&fixture(2, 4)); + assert!(!small.contains("call double @js_value_from_literal_descriptor(")); + let mut effectful = fixture(80, 4); + effectful.classes[0] + .constructor + .as_mut() + .unwrap() + .body + .push(Stmt::Expr(Expr::Number(7.0))); + let effectful = ir(&effectful); + assert!(!effectful.contains("call double @js_value_from_literal_descriptor(")); +} + +#[test] +fn wide_shape_constructor_uses_the_shared_assignment_loop() { + // Below the descriptor node threshold: exercise the actual constructor + // call, not only an otherwise-unused constructor emitted beside a blob. + let ir = ir(&fixture(1, 128)); + assert!(!ir.contains("call double @js_value_from_literal_descriptor(")); + assert_eq!( + ir.matches("call void @js_literal_shape_initialize(") + .count(), + 1 + ); + assert!( + ir.contains("noinline"), + "LLVM must not duplicate the marshalling body" + ); +} diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index cef3f103ff..c9392c76bc 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -34,6 +34,7 @@ use crate::types::{DOUBLE, F32, I1, I16, I32, I64, I8, PTR}; mod array_literal; mod bitset_test; pub(crate) mod hot_tls; +mod literal_descriptor; #[cfg(test)] mod map_entry_at_tests; pub(crate) use bitset_test::is_u32_bitset_test; diff --git a/crates/perry-codegen/src/inprocess/optimize_emit.rs b/crates/perry-codegen/src/inprocess/optimize_emit.rs index e7b879b809..edbe37ca32 100644 --- a/crates/perry-codegen/src/inprocess/optimize_emit.rs +++ b/crates/perry-codegen/src/inprocess/optimize_emit.rs @@ -168,6 +168,18 @@ pub(super) fn optimize_and_emit( if let Some(stats) = stats.as_deref_mut() { stats.optimize_secs = optimize_started.elapsed().as_secs_f64(); } + // Include small units as well as budget offenders, so before/after size + // audits do not lose their subject when a fix brings it below the cap. + if std::env::var_os("PERRY_CODEGEN_UNIT_TIMINGS").is_some() { + let (functions, total, widest) = module_instruction_census(module); + if let Some((name, instructions)) = widest { + eprintln!( + "[perry] codegen: `{name}` has {instructions} instructions after IR optimization \ + (unit total {total}, {functions} functions, opt {:.3}s)", + optimize_started.elapsed().as_secs_f64(), + ); + } + } // The IR pipeline above has already done the requested optimization. For // an extreme generated function, LLVM's optimized *machine* pipeline can diff --git a/crates/perry-codegen/src/runtime_decls/arrays.rs b/crates/perry-codegen/src/runtime_decls/arrays.rs index 72e9122781..77d2adcd70 100644 --- a/crates/perry-codegen/src/runtime_decls/arrays.rs +++ b/crates/perry-codegen/src/runtime_decls/arrays.rs @@ -41,6 +41,11 @@ pub fn declare_phase_b_arrays(module: &mut LlModule) { // from a static rodata descriptor blob in ONE call — (descriptor_ptr, // blob_len). Returns the nanboxed JS value (a fresh, mutable array). module.declare_function("js_value_from_const_descriptor", DOUBLE, &[PTR, I32]); + module.declare_function( + "js_value_from_literal_descriptor", + DOUBLE, + &[PTR, I32, PTR, I32], + ); module.declare_function("js_array_push_f64", I64, &[I64, DOUBLE]); module.declare_function("js_array_push_u31_with_length", I64, &[I64, I32, PTR]); module.declare_function("js_array_push_f64_spec", I64, &[I64, DOUBLE]); diff --git a/crates/perry-codegen/src/runtime_decls/objects.rs b/crates/perry-codegen/src/runtime_decls/objects.rs index 7362967250..4991ebc26c 100644 --- a/crates/perry-codegen/src/runtime_decls/objects.rs +++ b/crates/perry-codegen/src/runtime_decls/objects.rs @@ -23,6 +23,11 @@ use super::*; /// The inline bump allocator now handles most object allocation directly; /// `js_object_alloc(0, N)` is the fallback for dynamic cases. pub fn declare_phase_b_objects(module: &mut LlModule) { + module.declare_function( + "js_literal_shape_initialize", + VOID, + &[DOUBLE, PTR, PTR, I32], + ); // #5093: sticky runtime flag (i8, 0 = enabled) gating the codegen-inlined // class-field shape-guard fast path. The inline guard loads this directly // and falls back to the full `js_typed_feedback_class_field_*_guard` call diff --git a/crates/perry-hir/src/ir/decl.rs b/crates/perry-hir/src/ir/decl.rs index b47e36604f..30a4490eb7 100644 --- a/crates/perry-hir/src/ir/decl.rs +++ b/crates/perry-hir/src/ir/decl.rs @@ -290,6 +290,59 @@ pub struct Class { } impl Class { + /// The shape-only class synthesized for a closed object literal. Check the + /// complete constructor rather than trusting its name: data materialization + /// may bypass this body only when it does exactly these positional stores. + pub fn is_literal_shape(&self) -> bool { + if !self.name.starts_with("__AnonShape_") + || self.extends.is_some() + || self.extends_name.is_some() + || self.extends_expr.is_some() + || self.native_extends.is_some() + || !self.methods.is_empty() + || !self.getters.is_empty() + || !self.setters.is_empty() + || !self.static_fields.is_empty() + || !self.static_methods.is_empty() + || !self.computed_members.is_empty() + || !self.decorators.is_empty() + || self.alloc_width_hint != 0 + { + return false; + } + let Some(ctor) = &self.constructor else { + return false; + }; + ctor.is_strict + && !ctor.is_async + && !ctor.is_generator + && ctor.captures.is_empty() + && ctor.decorators.is_empty() + && ctor.params.len() == self.fields.len() + && ctor.body.len() == self.fields.len() + && self + .fields + .iter() + .zip(&ctor.params) + .zip(&ctor.body) + .all(|((field, param), stmt)| { + field.init.is_none() + && field.key_expr.is_none() + && !field.is_private + && field.decorators.is_empty() + && param.default.is_none() + && !param.is_rest + && param.arguments_object.is_none() + && param.decorators.is_empty() + && matches!(stmt, + Stmt::Expr(Expr::PropertySet { object, property, value }) + if matches!(object.as_ref(), Expr::This) + && property == &field.name + && matches!(value.as_ref(), Expr::LocalGet(id) if *id == param.id) + ) + }) + } + /// True for the metadata-only stub `compile_module` synthesizes for a class /// IMPORTED from another module (`perry-codegen/src/codegen/mod.rs`, "Build /// a stub Class with the minimum fields the codegen needs"). diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index 353f4883df..fc6adf5041 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -15,6 +15,8 @@ fn make_ctx() -> LoweringContext { LoweringContext::new("test.ts") } +mod literal_shape; + #[test] fn static_source_import_is_visible_before_its_declaration() { let source = r#" diff --git a/crates/perry-hir/src/lower/tests/literal_shape.rs b/crates/perry-hir/src/lower/tests/literal_shape.rs new file mode 100644 index 0000000000..59da8267ef --- /dev/null +++ b/crates/perry-hir/src/lower/tests/literal_shape.rs @@ -0,0 +1,35 @@ +use super::*; + +#[test] +fn literal_shape_proof_rejects_constructor_effects_and_nonliteral_classes() { + let mut ctx = make_ctx(); + ctx.synthesize_anon_shape_class(&[("id".into(), Type::Number), ("name".into(), Type::String)]); + let shape = ctx.pending_classes.pop().unwrap(); + assert!(shape.is_literal_shape(), "the actual lowering must qualify"); + let mut changed = shape.clone(); + changed + .constructor + .as_mut() + .unwrap() + .body + .push(Stmt::Expr(Expr::Number(1.0))); + assert!(!changed.is_literal_shape()); + let mut changed = shape.clone(); + changed.constructor.as_mut().unwrap().params[0].default = Some(Expr::Number(1.0)); + assert!(!changed.is_literal_shape()); + let mut changed = shape.clone(); + changed.fields[0].init = Some(Expr::Number(1.0)); + assert!(!changed.is_literal_shape()); + let mut changed = shape.clone(); + changed.extends_name = Some("Base".into()); + assert!(!changed.is_literal_shape()); + let mut changed = shape.clone(); + changed.name = "UserClass".into(); + assert!(!changed.is_literal_shape()); + let mut changed = shape; + changed.constructor.as_mut().unwrap().body.swap(0, 1); + assert!( + !changed.is_literal_shape(), + "field order is part of the proof" + ); +} diff --git a/crates/perry-runtime/src/array/literal_descriptor.rs b/crates/perry-runtime/src/array/literal_descriptor.rs new file mode 100644 index 0000000000..5dac40f5a2 --- /dev/null +++ b/crates/perry-runtime/src/array/literal_descriptor.rs @@ -0,0 +1,261 @@ +//! Materialization of constant trees with compiler-authored record layouts. +//! The schema table contains addresses of existing module root slots, never a +//! cached heap pointer. Every invocation creates fresh arrays and objects. + +use crate::value::JSValue; + +/// Keep in sync with perry-codegen::expr::literal_descriptor::SHAPE_TYPE. +#[repr(C)] +pub struct LiteralShape { + class_id: u32, + field_count: u32, + keys_slot: *const u64, + shape_id_slot: *const u32, + raw_mask: *const u64, + raw_mask_len: u32, + pointer_mask: *const u64, + pointer_mask_len: u32, +} + +struct Reader<'a> { + bytes: &'a [u8], + shapes: &'a [LiteralShape], + pos: usize, +} + +impl Reader<'_> { + fn take(&mut self, len: usize) -> Option<&[u8]> { + let end = self.pos.checked_add(len)?; + let bytes = self.bytes.get(self.pos..end)?; + self.pos = end; + Some(bytes) + } + + fn u32(&mut self) -> Option { + Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?)) + } + + fn value(&mut self, depth: usize) -> Option { + if depth > 128 { + return None; + } + match self.take(1)?[0] { + 0 => Some(JSValue::number(f64::from_le_bytes( + self.take(8)?.try_into().ok()?, + ))), + 1 => { + let count = self.u32()?; + if count as usize > self.bytes.len() - self.pos { + return None; + } + let array = super::js_array_alloc_literal(count); + let mut numeric = count != 0; + for i in 0..count { + numeric &= self.bytes.get(self.pos) == Some(&0); + let value = self.value(depth + 1)?; + // The common store maintains string sharing, array element + // facts, GC slot layout and write barriers. + unsafe { super::store_array_slot(array, i as usize, value.bits()) }; + } + if numeric { + super::js_array_mark_numeric_f64_layout(array); + } + Some(JSValue::pointer(array as *const u8)) + } + 2 => Some(JSValue::bool(true)), + 3 => Some(JSValue::bool(false)), + 4 => Some(JSValue::null()), + 5 => Some(JSValue::undefined()), + 6 => { + let len = self.u32()?; + let bytes = self.take(len as usize)?; + let string = crate::string::js_string_from_bytes(bytes.as_ptr(), len); + Some(JSValue::from_bits( + crate::value::js_nanbox_string(string as i64).to_bits(), + )) + } + 7 => { + let index = self.u32()? as usize; + let shape = self.shapes.get(index)?; + if shape.keys_slot.is_null() || shape.shape_id_slot.is_null() { + return None; + } + let object = crate::object::js_object_alloc_class_inline_keys_stamped( + shape.class_id, + 0, + shape.field_count, + unsafe { *shape.keys_slot } as *mut super::ArrayHeader, + unsafe { *shape.shape_id_slot }, + ); + for i in 0..shape.field_count { + let value = self.value(depth + 1)?; + crate::object::js_object_set_field(object, i, value); + } + // Use the very same immutable masks and typed ShapeId as new. + // Validate the completed fields before enabling direct reads. + crate::gc::js_gc_init_typed_shape_layout( + object as u64, + shape.field_count, + shape.raw_mask, + shape.raw_mask_len, + shape.pointer_mask, + shape.pointer_mask_len, + ); + Some(JSValue::pointer(object as *const u8)) + } + _ => None, + } + } +} + +/// Inputs are compiler-owned static data. GC suppression protects partial +/// trees during recursive allocation, just as for the existing constant-array +/// descriptor and JSON parser. No user code runs in this materializer. +#[no_mangle] +pub extern "C" fn js_value_from_literal_descriptor( + bytes: *const u8, + len: u32, + shapes: *const LiteralShape, + shape_count: u32, +) -> f64 { + if bytes.is_null() || shapes.is_null() || len == 0 || shape_count == 0 { + return f64::from_bits(JSValue::undefined().bits()); + } + let _suppress = crate::gc::GcSuppressScope::new(); + let mut reader = Reader { + bytes: unsafe { std::slice::from_raw_parts(bytes, len as usize) }, + shapes: unsafe { std::slice::from_raw_parts(shapes, shape_count as usize) }, + pos: 0, + }; + f64::from_bits(reader.value(0).unwrap_or_else(JSValue::undefined).bits()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn literal_descriptor_preserves_shape_freshness_and_traced_children() { + let _guard = crate::gc::CopyingNurseryTestGuard::new(0); + let _triggers = crate::gc::GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _evacuation = crate::gc::knob_overrides::ForcedEvacuationTestGuard::on(); + let _verification = crate::gc::knob_overrides::VerifyEvacuationTestGuard::on(); + crate::gc::register_runtime_handle_root_scanner_for_tests(); + const RAW: &[u64] = &[1]; + const POINTERS: &[u64] = &[2]; + const CLASS_ID: u32 = 1017301; + let keys = + crate::object::js_build_class_keys_array(CLASS_ID, 2, b"id\0name\0".as_ptr(), 8) as u64; + let shape_id = crate::gc::js_gc_typed_shape_id_for_keys( + CLASS_ID, + keys, + 2, + RAW.as_ptr(), + 1, + POINTERS.as_ptr(), + 1, + ); + let shape = LiteralShape { + class_id: CLASS_ID, + field_count: 2, + keys_slot: &keys, + shape_id_slot: &shape_id, + raw_mask: RAW.as_ptr(), + raw_mask_len: 1, + pointer_mask: POINTERS.as_ptr(), + pointer_mask_len: 1, + }; + // {id:-0, name:"snowman☃"}, using the public compiler/runtime ABI. + let mut bytes = vec![7, 0, 0, 0, 0, 0]; + bytes.extend_from_slice(&(-0.0_f64).to_le_bytes()); + bytes.push(6); + let name = "snowman☃".as_bytes(); + bytes.extend_from_slice(&(name.len() as u32).to_le_bytes()); + bytes.extend_from_slice(name); + let scope = crate::gc::RuntimeHandleScope::new(); + let a = scope.root_nanbox_f64(js_value_from_literal_descriptor( + bytes.as_ptr(), + bytes.len() as u32, + &shape, + 1, + )); + let b = scope.root_nanbox_f64(js_value_from_literal_descriptor( + bytes.as_ptr(), + bytes.len() as u32, + &shape, + 1, + )); + assert_ne!(a.get_nanbox_f64().to_bits(), b.get_nanbox_f64().to_bits()); + let object = |handle: &crate::gc::RuntimeHandle<'_>| { + JSValue::from_bits(handle.get_nanbox_f64().to_bits()) + .as_pointer::() + }; + let a_ptr = object(&a); + assert_eq!(unsafe { (*a_ptr).class_id }, CLASS_ID); + assert_eq!( + unsafe { crate::object::shapes::object_shape_stamp(a_ptr) }, + shape_id + ); + let header = unsafe { crate::value::addr_class::try_read_gc_header(a_ptr as usize) } + .expect("the descriptor must allocate a managed object"); + assert_ne!(header._reserved & crate::gc::GC_OBJ_TYPED_LAYOUT_INTACT, 0); + assert_eq!( + crate::object::js_object_get_field(a_ptr, 0).bits(), + (-0.0_f64).to_bits() + ); + assert!(crate::object::js_object_get_field(a_ptr, 1).is_string()); + crate::object::js_object_set_field(a_ptr as *mut _, 0, JSValue::number(99.0)); + let cycles = crate::gc::copying_minor_cycles(); + crate::gc::gc_collect_minor(); + assert!(crate::gc::copying_minor_cycles() > cycles); + assert_ne!(object(&a), a_ptr, "the rooted record must actually move"); + assert_eq!( + crate::object::js_object_get_field(object(&a), 0).as_number(), + 99.0 + ); + assert_eq!( + crate::object::js_object_get_field(object(&b), 0).bits(), + (-0.0_f64).to_bits() + ); + let expected_name = scope.root_string_ptr(crate::string::js_string_from_bytes( + name.as_ptr(), + name.len() as u32, + )); + let actual_name = crate::object::js_object_get_field(object(&b), 1); + assert_eq!( + crate::value::js_jsvalue_equals( + f64::from_bits(actual_name.bits()), + expected_name.with_const_ptr::(|ptr| { + crate::value::js_nanbox_string(ptr as i64) + }), + ), + 1 + ); + } + + #[test] + fn literal_descriptor_rejects_truncated_data_and_invalid_shape_indices() { + let shape = LiteralShape { + class_id: 0, + field_count: 0, + keys_slot: std::ptr::null(), + shape_id_slot: std::ptr::null(), + raw_mask: std::ptr::null(), + raw_mask_len: 0, + pointer_mask: std::ptr::null(), + pointer_mask_len: 0, + }; + for bytes in [ + &[0_u8][..], + &[6, 10, 0, 0, 0], + &[7, 1, 0, 0, 0], + &[1, 255, 255, 255, 255], + ] { + assert_eq!( + js_value_from_literal_descriptor(bytes.as_ptr(), bytes.len() as u32, &shape, 1) + .to_bits(), + JSValue::undefined().bits() + ); + } + } +} diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index 86d376eda2..6141572233 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -20,6 +20,7 @@ mod keys_len_cap_tests; /// #9371: lazy dense growth for large pre-sized holey arrays. #[cfg(test)] mod large_presized_tests; +mod literal_descriptor; /// Test-only strict-dense store helpers, split out of `indexing.rs` for the /// 2000-line cap. #[cfg(test)] diff --git a/crates/perry-runtime/src/gc/diag_sites.rs b/crates/perry-runtime/src/gc/diag_sites.rs index 3b2f83bb79..a1e44bad03 100644 --- a/crates/perry-runtime/src/gc/diag_sites.rs +++ b/crates/perry-runtime/src/gc/diag_sites.rs @@ -45,6 +45,7 @@ pub(super) fn trigger_decision(site: &'static str, kind: &'static str) { let nursery_cap = tenuring::scavenge_nursery_cap_effective_bytes(); let old_reclaimable = policy::old_gen_reclaimable_pressure_bytes(); let external = policy::external_side_live_bytes(); + let external_drained = policy::GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL.with(Cell::get); let old_baseline = policy::GC_LAST_OLD_RECLAIM_IN_USE_BYTES.with(Cell::get); let old_band = policy::gc_old_reclaim_growth_band_bytes(old_baseline); let old_threshold = gc_old_gen_reclaim_threshold_dyn_bytes(); @@ -57,7 +58,8 @@ pub(super) fn trigger_decision(site: &'static str, kind: &'static str) { eprintln!( "[gc-trigger] site={site} kind={kind} arena_total={arena_total} next_base={next_base} armed={armed} \ from_space={from_space} nursery_cap={nursery_cap} old_in_use={old_in_use} old_free={old_free} \ - old_reclaimable={old_reclaimable} external_side={external} old_baseline={old_baseline} \ + old_reclaimable={old_reclaimable} external_side={external} \ + external_drained={external_drained} old_baseline={old_baseline} \ old_band={old_band} old_threshold={old_threshold} old_pending={old_pending} retaining={retaining} \ malloc={malloc} next_malloc={next_malloc} promoted_since_full={} cohort_bound={}", promoted_cohort::promoted_since_full(), diff --git a/crates/perry-runtime/src/gc/policy.rs b/crates/perry-runtime/src/gc/policy.rs index d5ef74ba47..7af0232a0f 100644 --- a/crates/perry-runtime/src/gc/policy.rs +++ b/crates/perry-runtime/src/gc/policy.rs @@ -478,8 +478,16 @@ pub(super) fn tiny_parse_pressure_due_with( /// is safe to consult where the absolute in-use guard was not: a minor lowers /// the quantity it tests to the survivors, so it cannot fire again until the /// cap has been refilled. +/// +/// Medium-parse pacing (2026-09-14) adds the third arm for the same reason the +/// second one exists, one currency over: both of the first two are denominated +/// in ARENA bytes, and a lazily-parsed document's bytes are not in the arena at +/// all. See +/// [`external_side_parse_pressure_due`]. pub(super) fn tiny_parse_generational_collection_due(in_use: usize, in_use_trigger: usize) -> bool { - tiny_parse_pressure_due(in_use, in_use_trigger) || young_scavenge_cap_due() + tiny_parse_pressure_due(in_use, in_use_trigger) + || young_scavenge_cap_due() + || external_side_parse_pressure_due() } /// The live [`tiny_parse_pressure_due_with`]: current base and step. @@ -522,13 +530,22 @@ fn diag_tiny_parse_forced_collection(site: &str, in_use: usize) { } let base = GC_TINY_PARSE_PRESSURE_BASE_BYTES.with(Cell::get); let step = GC_STEP_BYTES.with(Cell::get); + // Medium-parse pacing (2026-09-14): the side-allocation arm's own inputs, + // so a diag reader can tell which of the three arms priced this collection + // rather than re-deriving it — the same "assert the subject was live" rule. + let external = external_side_live_bytes(); + let external_base = GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES.with(Cell::get); eprintln!( - "[gc-tiny-parse] forced collection site={} in_use={} base={} headroom={} step={}", + "[gc-tiny-parse] forced collection site={} in_use={} base={} headroom={} step={} \ + external_side={} external_base={} external_band={}", site, in_use, base, tiny_parse_pressure_headroom_bytes(step), - step + step, + external, + external_base, + external_side_parse_band_bytes(external_base) ); } @@ -631,6 +648,18 @@ const GC_EXTERNAL_SIDE_ALLOC_STEP: usize = 16 * 1024 * 1024; crate::perry_thread_local! { static GC_EXTERNAL_SIDE_ALLOC_PENDING: std::cell::Cell = const { std::cell::Cell::new(0) }; static GC_EXTERNAL_SIDE_LIVE_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Medium-parse pacing (2026-09-14): [`external_side_live_bytes`] as the + /// last collection ended — the base of the parse-boundary growth band + /// ([`external_side_parse_pressure_due_with`]). A byte COUNT, never an + /// address; written only from `note_collection_finished_arena_occupancy`. + pub(super) static GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES: std::cell::Cell = + const { std::cell::Cell::new(0) }; + /// Medium-parse pacing (2026-09-14): external side bytes that a + /// collection or mutator operation has released since the last full — see + /// [`external_side_old_reclaim_pressure_bytes`]. A byte COUNT, never an + /// address. + pub(super) static GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL: std::cell::Cell = + const { std::cell::Cell::new(0) }; } /// Live bytes currently held by external Map/Set side buffers on this thread. @@ -639,6 +668,54 @@ pub(super) fn external_side_live_bytes() -> usize { GC_EXTERNAL_SIDE_LIVE_BYTES.with(Cell::get) } +/// Medium-parse pacing (2026-09-14): how many bytes of external side allocation +/// may accumulate past the last collection before a `JSON.parse` boundary is +/// due. +/// +/// Deliberately the `max(floor, proportional)` shape of +/// [`gc_old_reclaim_growth_band_bytes`], for the two reasons that shape exists: +/// +/// * a program whose live side set is genuinely large (a retained multi-MB +/// `Map`) must not collect once per parse, so the band grows with it; and +/// * a collection that CANNOT lower the number this band watches re-baselines +/// it at the surviving value, so futile repeats space out geometrically +/// instead of firing at a constant step. That is what keeps this arm off the +/// #7437/#7592 livelock: a lazy array whose cluster was born OLD +/// (`json_tape::lazy_cluster_is_old`) keeps its tape through the nursery +/// collection this arm schedules, and the next band is then twice as far +/// away rather than due again at the next parse. +pub(super) fn external_side_parse_band_bytes(baseline: usize) -> usize { + gc_trigger_headroom_floor_bytes().max(baseline) +} + +/// [`external_side_parse_pressure_due`] with both readings supplied. +pub(super) fn external_side_parse_pressure_due_with(live: usize, baseline: usize) -> bool { + live >= baseline.saturating_add(external_side_parse_band_bytes(baseline)) +} + +/// Medium-parse pacing (2026-09-14): has external side-allocation churn earned +/// a parse-boundary collection? +/// +/// Every other pacing input a parse boundary reads is denominated in ARENA +/// bytes, and a lazily-parsed document's memory is not in the arena: a 13 KB +/// `records_array_16k` parse puts ~1.1 KB (header + sparse cache + bitmap) in +/// the nursery and ~24 KB of tape in a `json_tape_store` side allocation. So +/// the young generation reads 1/24th of what the process is actually holding, +/// and a parse loop reaches its nursery cap 24x later than the memory says it +/// should. Measured on `records_array_16k:parse` at `origin/main` +/// (`PERRY_GC_DIAG=1`, 11 284 iterations): EIGHT collections, every one of them +/// a full mark-sweep from `alloc_point_old_reclaim`, each firing at +/// `external_side=33.6 MB` with `arena_total` between 3 and 8 MB, +/// `old_in_use=0` and `from_space` never above 7 MB against a 16 MB nursery +/// cap. The only pacing this workload had was the old-reclaim growth band +/// reading those side bytes, i.e. 32 MB of dead tape per cycle. +pub(super) fn external_side_parse_pressure_due() -> bool { + external_side_parse_pressure_due_with( + external_side_live_bytes(), + GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES.with(Cell::get), + ) +} + /// Record `bytes` of fresh external side-buffer allocation (Map entries / /// Set elements — creation or growth delta) and poke the trigger check when /// the accumulated churn window fills. Callers must invoke this only when @@ -663,9 +740,31 @@ pub(crate) fn gc_note_external_side_alloc(bytes: usize) { } } -/// Record that a Map/Set side buffer of `bytes` was freed (GC finalizer). +/// Record released external side bytes, from either a collector or a mutator operation. pub(crate) fn gc_note_external_side_free(bytes: usize) { GC_EXTERNAL_SIDE_LIVE_BYTES.with(|c| c.set(c.get().saturating_sub(bytes))); + GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL.with(|c| c.set(c.get().saturating_add(bytes))); +} + +/// The external side-buffer term of OLD-RECLAIM pressure. +/// +/// Live bytes plus all reported releases since the last full baseline. The +/// release hook also runs during JSON tape materialization, regex scratch +/// teardown, native-addon adjustments and Map/Set buffer replacement; this +/// accounting is broader than bytes freed by a minor collection. +/// +/// Holding released bytes in this term keeps cheap collections from removing +/// the pressure that pays for arena-capacity reclamation. On the measured +/// `records_array_1m:sparse` loop, the live-only alternative reduced fulls from +/// seven to one and raised peak RSS from 63.5 to 73.6 MiB despite freeing more +/// tape. The cumulative term restored that workload's full-collection cadence. +/// +/// This is not a general guarantee of identical pacing: a mutator-side release +/// lowers the previous live-only term but leaves this cumulative term unchanged, +/// so other workloads can reach the full-collection threshold earlier. The full +/// baseline resets the released-byte contribution before pricing the next band. +pub(super) fn external_side_old_reclaim_pressure_bytes() -> usize { + external_side_live_bytes().saturating_add(GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL.with(Cell::get)) } #[inline] @@ -1931,8 +2030,8 @@ pub(super) fn copied_minor_promotion_handoff_due(trigger_kind: GcTriggerKind) -> return false; } let promotable = copied_minor_promotable_active_survivor_bytes(); - let old_in_use = - old_gen_reclaimable_pressure_bytes().saturating_add(external_side_live_bytes()); + let old_in_use = old_gen_reclaimable_pressure_bytes() + .saturating_add(external_side_old_reclaim_pressure_bytes()); let baseline = GC_LAST_OLD_RECLAIM_IN_USE_BYTES.with(|bytes| bytes.get()); copied_minor_promotion_handoff_pressure_due(promotable, old_in_use, baseline) } @@ -2043,8 +2142,8 @@ pub(super) fn maybe_schedule_old_reclaim_after_copied_minor() { // a tenured-then-dead Map holds its multi-MB buffer until a full // reclaim's old-gen sweep finalizes it, so the buffer bytes must be // able to escalate that reclaim. - let old_in_use = - old_gen_reclaimable_pressure_bytes().saturating_add(external_side_live_bytes()); + let old_in_use = old_gen_reclaimable_pressure_bytes() + .saturating_add(external_side_old_reclaim_pressure_bytes()); let baseline = GC_LAST_OLD_RECLAIM_IN_USE_BYTES.with(|bytes| bytes.get()); if old_reclaim_pressure_due(old_in_use, baseline) { GC_OLD_RECLAIM_PENDING.with(|pending| pending.set(true)); @@ -2068,10 +2167,15 @@ pub(super) fn request_old_reclaim_for_untraced_promotions(bytes: usize) { } pub(super) fn finish_full_old_reclaim_baseline() { + // Medium-parse pacing (2026-09-14): the full this baseline records is the + // collection the drained bytes were being held for, so the debt is paid + // here — before the baseline is read, or the baseline would carry it into + // the next band incorrectly including already released bytes in the following growth band. + GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL.with(|c| c.set(0)); // Baseline includes external side-buffer bytes (#6010) so the growth // delta in `old_reclaim_pressure_due` stays unit-consistent. - let old_in_use = - old_gen_reclaimable_pressure_bytes().saturating_add(external_side_live_bytes()); + let old_in_use = old_gen_reclaimable_pressure_bytes() + .saturating_add(external_side_old_reclaim_pressure_bytes()); GC_LAST_OLD_RECLAIM_IN_USE_BYTES.with(|bytes| bytes.set(old_in_use)); // #10182: this full verified everything old; the promoted cohort starts over. super::promoted_cohort::note_full_finished(old_in_use); @@ -2240,6 +2344,12 @@ pub(super) fn note_collection_finished_arena_occupancy(full: bool) { GC_LAST_COLLECTION_POST_IN_USE_BYTES.with(|cell| cell.set(bytes)); // #9831: the same moment, in the units the tiny-parse guard reads. GC_TINY_PARSE_PRESSURE_BASE_BYTES.with(|cell| cell.set(crate::arena::arena_in_use_bytes())); + // Medium-parse pacing (2026-09-14): and in the units the parse-boundary + // side-allocation band reads. This is the site that makes the band self-correcting: whatever the sweep + // and the from-space pass just released has already been subtracted from + // `external_side_live_bytes`, so a collection that freed the tapes + // re-bases at ~0 and one that could not re-bases at the surviving value. + GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES.with(|cell| cell.set(external_side_live_bytes())); super::arena_right_size::note_collection_finished(bytes, full); } @@ -3378,7 +3488,7 @@ pub(super) fn gc_budgeted_due_trigger_eval() -> (Option, bool let old_pending = GC_OLD_RECLAIM_PENDING.with(Cell::get); // #6010: external Map/Set side-buffer bytes escalate to OldReclaim too. let old_reclaimable = old_gen_reclaimable_pressure_bytes(); - let old_in_use = old_reclaimable.saturating_add(external_side_live_bytes()); + let old_in_use = old_reclaimable.saturating_add(external_side_old_reclaim_pressure_bytes()); let old_baseline = GC_LAST_OLD_RECLAIM_IN_USE_BYTES.with(|bytes| bytes.get()); if old_pending || old_reclaim_pressure_due(old_in_use, old_baseline) { return (Some(BudgetedGcTrigger::OldReclaim), true); diff --git a/crates/perry-runtime/src/gc/tests/tiny_parse_pressure.rs b/crates/perry-runtime/src/gc/tests/tiny_parse_pressure.rs index e6eedd9dbf..50b505da7a 100644 --- a/crates/perry-runtime/src/gc/tests/tiny_parse_pressure.rs +++ b/crates/perry-runtime/src/gc/tests/tiny_parse_pressure.rs @@ -20,6 +20,7 @@ use super::super::heap_budget::{ gc_trigger_absolute_ceiling_bytes, gc_trigger_headroom_floor_bytes, }; use super::super::policy::{ + external_side_parse_band_bytes, external_side_parse_pressure_due_with, tiny_parse_boundary_poll_next, tiny_parse_pressure_due, tiny_parse_pressure_due_with, tiny_parse_pressure_headroom_bytes, GC_STEP_BYTES, GC_THRESHOLD_INITIAL_BYTES, GC_THRESHOLD_MAX_BYTES, GC_TINY_PARSE_BOUNDARY_POLL_INTERVAL, @@ -253,6 +254,17 @@ fn a_due_nursery_cap_schedules_the_boundary_collection_below_the_in_use_guard() !tiny_parse_pressure_due(in_use, 48 * MB), "fixture: the priced in-use guard must not be due, or this proves nothing" ); + // Medium-parse pacing (2026-09-14) added a third arm to the same + // predicate. Pin it quiet for the duration, so a side-allocation band that + // happened to be due could not make this test's negative half pass for the + // wrong reason. + let _external_base = ExternalBaseGuard::set( + crate::gc::policy::external_side_live_bytes() + gc_trigger_headroom_floor_bytes(), + ); + assert!( + !crate::gc::policy::external_side_parse_pressure_due(), + "fixture: the side-allocation arm must not be due, or this proves nothing" + ); { let _cap = ScavengeNurseryCapTestGuard::due_at_bytes(usize::MAX); @@ -272,3 +284,293 @@ fn a_due_nursery_cap_schedules_the_boundary_collection_below_the_in_use_guard() } GC_SUPPRESSED_TINY_PARSE_COLLECTION_PENDING.with(|p| p.set(false)); } + +// ─────────────────────────────────────────────────────────────────────────── +// Medium-parse pacing (2026-09-14): the side-allocation arm. +// +// Both pre-existing arms are denominated in ARENA bytes, and a lazily-parsed +// document's bytes are not in the arena. The readings below are the measured +// ones from `records_array_16k:parse` at `origin/main` (`PERRY_GC_DIAG=1`, +// 11 284 iterations of a 13 197-byte fixture): every one of the eight +// collections the row ran was a full mark-sweep from `alloc_point_old_reclaim`, +// fired at `external_side=33 570 480` with `arena_total` 3–8 MB, `old_in_use=0` +// and `from_space` never above 6 857 064 against a 16 MB nursery cap. +// +// Sabotage-proved: deleting the `|| external_side_parse_pressure_due()` arm +// from `tiny_parse_generational_collection_due` fails +// `side_allocation_pressure_schedules_the_boundary_collection_below_both_arena_arms` +// while `the_measured_defect_is_invisible_to_both_arena_denominated_arms` — the +// twin that says what the harm IS — keeps passing, which is the point: the two +// together say "nothing else was going to collect this". Pricing the band at a +// bare floor (`gc_trigger_headroom_floor_bytes()`, dropping the `.max(baseline)`) +// fails `a_collection_that_cannot_free_the_side_bytes_doubles_the_band` and +// `a_genuinely_large_live_side_set_is_not_due_at_the_floor`. +// ─────────────────────────────────────────────────────────────────────────── + +/// The measured `records_array_16k:parse` readings at the moment `origin/main` +/// finally collected. +const MEASURED_EXTERNAL_SIDE_BYTES: usize = 33_570_480; +const MEASURED_ARENA_IN_USE_BYTES: usize = 3 * MB; + +/// Restores the side-allocation band's base cell, which the live predicate and +/// every finished collection write. +struct ExternalBaseGuard(usize); + +impl ExternalBaseGuard { + fn set(base: usize) -> Self { + Self( + super::super::policy::GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES + .with(|cell| cell.replace(base)), + ) + } +} + +impl Drop for ExternalBaseGuard { + fn drop(&mut self) { + super::super::policy::GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES.with(|cell| cell.set(self.0)); + } +} + +/// The twin that says what the harm is. Nothing here asserts the new arm — it +/// asserts that WITHOUT it the measured workload has no arm at all, which is +/// what made 32 MB of dead tape per cycle the row's steady state. +#[test] +fn the_measured_defect_is_invisible_to_both_arena_denominated_arms() { + use super::super::policy::ScavengeNurseryCapTestGuard; + let _cells = LiveCellsGuard::set(GC_THRESHOLD_INITIAL_BYTES, 0); + // The priced in-use guard reads `arena_in_use_bytes()`: 3 MB against a + // 48 MB trigger. + assert!( + !tiny_parse_pressure_due(MEASURED_ARENA_IN_USE_BYTES, 48 * MB), + "3 MB of arena is far below the in-use guard — it can never fire here" + ); + // The nursery cap reads `copying_from_space_in_use_bytes()`: the row's + // high-water was 6.9 MB against the 16 MB cap, because a lazy array puts + // ~1.1 KB in the nursery per parse and ~24 KB in a side allocation. + let _cap = ScavengeNurseryCapTestGuard::due_at_bytes(usize::MAX); + assert!( + !super::super::policy::young_scavenge_cap_due(), + "the nursery cap cannot see a byte of the 32 MB the process is holding" + ); +} + +#[test] +fn the_side_allocation_arm_is_due_at_the_measured_readings() { + assert!( + external_side_parse_pressure_due_with(MEASURED_EXTERNAL_SIDE_BYTES, 0), + "32 MB of side allocation past an empty base must be due" + ); +} + +#[test] +fn an_empty_base_is_due_exactly_at_the_headroom_floor() { + let floor = gc_trigger_headroom_floor_bytes(); + assert!( + external_side_parse_pressure_due_with(floor, 0), + "the floor's worth of side allocation past an empty base is due" + ); + assert!( + !external_side_parse_pressure_due_with(floor - 1, 0), + "one byte short of the floor is not" + ); + assert!( + !external_side_parse_pressure_due_with(0, 0), + "a program that has allocated no side buffers is never due" + ); +} + +#[test] +fn a_collection_that_cannot_free_the_side_bytes_doubles_the_band() { + // The livelock shape this band is built against: a lazy array whose sparse + // cache is at or past `LARGE_POINTER_BEARING_OBJECT_THRESHOLD_BYTES` is born + // OLD (`json_tape::lazy_cluster_is_old`), so the nursery collection this arm + // schedules cannot prove its owner dead and its tape survives. The base then + // re-bases at the surviving value and the next band is twice as far away, + // rather than being due again at the very next parse. + let survived = 24 * MB; + assert!( + !external_side_parse_pressure_due_with(survived, survived), + "a collection that freed nothing must not be immediately due again" + ); + assert!( + external_side_parse_pressure_due_with(2 * survived, survived), + "doubling past the surviving value is due" + ); + assert!( + !external_side_parse_pressure_due_with(2 * survived - 1, survived), + "one byte short of doubling is not" + ); +} + +#[test] +fn a_genuinely_large_live_side_set_is_not_due_at_the_floor() { + // A retained multi-MB `Map` contributes to the same counter. Its bytes must + // not force a collection at every parse boundary for the rest of the run. + let live_map = 64 * MB; + let floor = gc_trigger_headroom_floor_bytes(); + assert!( + !external_side_parse_pressure_due_with(live_map + floor, live_map), + "the band must grow with a genuinely large live side set, not stay at the floor" + ); + assert_eq!( + external_side_parse_band_bytes(live_map), + live_map, + "past the floor the band is the baseline itself (100% growth)" + ); + assert_eq!( + external_side_parse_band_bytes(0), + floor, + "an empty baseline buys exactly the headroom floor" + ); +} + +/// The wiring test: the arm reaches the boundary-collection scheduler, and does +/// so below both arena-denominated arms. +#[test] +fn side_allocation_pressure_schedules_the_boundary_collection_below_both_arena_arms() { + use super::super::policy::{ + gc_schedule_parse_boundary_collection_if_pressure, ScavengeNurseryCapTestGuard, + GC_SUPPRESSED_TINY_PARSE_COLLECTION_PENDING, + }; + use super::support::*; + let _isolation = GcTestIsolationGuard::new(); + let _pacing = crate::gc::policy::force_moving_gc_pacing(); + let _cells = LiveCellsGuard::set(GC_THRESHOLD_INITIAL_BYTES, 0); + let pending = || GC_SUPPRESSED_TINY_PARSE_COLLECTION_PENDING.with(std::cell::Cell::get); + let _cap = ScavengeNurseryCapTestGuard::due_at_bytes(usize::MAX); + GC_SUPPRESSED_TINY_PARSE_COLLECTION_PENDING.with(|p| p.set(false)); + + let in_use = crate::arena::arena_in_use_bytes(); + assert!( + !tiny_parse_pressure_due(in_use, 48 * MB), + "fixture: the priced in-use guard must not be due, or this proves nothing" + ); + { + // A base one floor above the live reading keeps the side arm off too, + // so all three arms are quiet: the scheduler must do nothing. + let _base = ExternalBaseGuard::set( + super::super::policy::external_side_live_bytes() + gc_trigger_headroom_floor_bytes(), + ); + assert!( + !super::super::policy::external_side_parse_pressure_due(), + "fixture: the side arm must start quiet, or the negative half is vacuous" + ); + gc_schedule_parse_boundary_collection_if_pressure(); + assert!(!pending(), "no arm is due: nothing scheduled"); + } + { + // Now only the side arm is due — an empty base with side bytes already + // a floor past it, which is the `records_array_16k:parse` shape. + let _base = ExternalBaseGuard::set(0); + crate::gc::gc_note_external_side_alloc(gc_trigger_headroom_floor_bytes()); + // The alloc notice itself pokes `gc_check_trigger`, whose collection + // would re-base the cell it just crossed; re-pin the base being tested. + let _repin = ExternalBaseGuard::set(0); + assert!( + super::super::policy::external_side_parse_pressure_due(), + "fixture: the side arm must be due, or the positive half is vacuous" + ); + gc_schedule_parse_boundary_collection_if_pressure(); + let scheduled = pending(); + crate::gc::gc_note_external_side_free(gc_trigger_headroom_floor_bytes()); + assert!( + scheduled, + "a due side-allocation band schedules the boundary collection" + ); + } + GC_SUPPRESSED_TINY_PARSE_COLLECTION_PENDING.with(|p| p.set(false)); +} + +/// The band is self-correcting only if the base is recorded AFTER the sweep and +/// the from-space pass have released what they can. Assert the identity of the +/// two readings, not merely that the cell moved. +#[test] +fn a_finished_collection_moves_the_external_base_to_the_post_collection_reading() { + use super::super::js_gc_collect; + use super::super::policy::{external_side_live_bytes, GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES}; + let _base = ExternalBaseGuard::set(usize::MAX); + js_gc_collect(); + let base = GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES.with(|cell| cell.get()); + assert_ne!( + base, + usize::MAX, + "a finished collection must record the side-allocation base" + ); + assert_eq!( + base, + external_side_live_bytes(), + "the base must be the POST-collection `external_side_live_bytes()` reading" + ); +} + +/// The band's counterweight: the cheap collections it schedules must not hide +/// pressure from the arm that pays for arena-capacity release. +/// +/// Sabotage-proved (run 2026-09-14, each against the whole +/// `tiny_parse_pressure` filter): reverting +/// `external_side_old_reclaim_pressure_bytes` to the bare +/// `external_side_live_bytes()` read fails BOTH tests below; deleting the reset +/// from `finish_full_old_reclaim_baseline` fails +/// `a_full_collection_clears_the_drained_debt` alone. +#[test] +fn a_drained_side_byte_still_pays_old_reclaim_until_the_next_full() { + use super::super::policy::{ + external_side_live_bytes, external_side_old_reclaim_pressure_bytes, + GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL, + }; + use super::support::*; + let _isolation = GcTestIsolationGuard::new(); + let restore = GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL.with(|cell| cell.replace(0)); + let live_before = external_side_live_bytes(); + assert_eq!( + external_side_old_reclaim_pressure_bytes(), + live_before, + "fixture: with no drained debt the term is the live reading" + ); + + const BYTES: usize = 3 * MB; + crate::gc::gc_note_external_side_alloc(BYTES); + let charged = external_side_old_reclaim_pressure_bytes(); + assert_eq!(charged, live_before + BYTES); + // Reporting a release lowers the live reading while preserving the + // cumulative term, whether the release came from a collector or mutator. + crate::gc::gc_note_external_side_free(BYTES); + assert_eq!( + external_side_live_bytes(), + live_before, + "the live reading must fall by what was released" + ); + assert_eq!( + external_side_old_reclaim_pressure_bytes(), + charged, + "old-reclaim must still be charged for a byte a full has not yet paid for" + ); + GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL.with(|cell| cell.set(restore)); +} + +#[test] +fn a_full_collection_clears_the_drained_debt() { + use super::super::js_gc_collect; + use super::super::policy::{ + external_side_live_bytes, external_side_old_reclaim_pressure_bytes, + GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL, + }; + let restore = GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL.with(|cell| cell.replace(7 * MB)); + assert_ne!( + external_side_old_reclaim_pressure_bytes(), + external_side_live_bytes(), + "fixture: the debt must be non-zero, or this proves nothing" + ); + js_gc_collect(); + assert_eq!( + GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL.with(std::cell::Cell::get), + 0, + "the full that the debt was held for pays it" + ); + assert_eq!( + external_side_old_reclaim_pressure_bytes(), + external_side_live_bytes(), + "with the debt paid the term is the live reading again" + ); + GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL.with(|cell| cell.set(restore)); +} diff --git a/crates/perry-runtime/src/object/literal_constructor.rs b/crates/perry-runtime/src/object/literal_constructor.rs new file mode 100644 index 0000000000..52e9023cc5 --- /dev/null +++ b/crates/perry-runtime/src/object/literal_constructor.rs @@ -0,0 +1,110 @@ +//! Shared slow constructor for wide synthetic record shapes. Its boxed ABI +//! still implements strict property assignment, including descriptor/prototype +//! changes. Constant literal sites normally bypass it via a descriptor. + +/// `values` is an immediately consumed compiler stack buffer. Root every +/// operand before the first assignment, since a setter may enter user code +/// and collect. The keys address names an existing registered module root. +#[no_mangle] +pub extern "C" fn js_literal_shape_initialize( + receiver: f64, + keys_slot: *const u64, + values: *const f64, + count: u32, +) { + if keys_slot.is_null() || values.is_null() { + return; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver = scope.root_nanbox_f64(receiver); + let keys = scope.root_raw_mut_ptr(unsafe { *keys_slot } as *mut crate::array::ArrayHeader); + let values: Vec<_> = unsafe { std::slice::from_raw_parts(values, count as usize) } + .iter() + .map(|value| scope.root_nanbox_f64(*value)) + .collect(); + for (i, value) in values.iter().enumerate() { + // Inspect the rooted key array without letting its raw pointer escape. + // The returned value stays owned by that array until js_put_value_set + // roots all three operands at entry; no allocation occurs in between. + let key = keys.with_const_ptr::(|keys_array| { + if keys_array.is_null() || i >= unsafe { (*keys_array).length } as usize { + None + } else { + Some(unsafe { *crate::array::array_elements_ptr(keys_array).add(i) }) + } + }); + let Some(key) = key else { return }; + let this = receiver.get_nanbox_f64(); + crate::proxy::js_put_value_set(this, f64::from_bits(key), value.get_nanbox_f64(), this, 1); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + extern "C" fn collecting_setter(_closure: *const crate::ClosureHeader, _value: f64) -> f64 { + crate::gc::gc_collect_minor(); + f64::from_bits(crate::value::TAG_UNDEFINED) + } + + #[test] + fn literal_constructor_roots_later_values_across_a_collecting_setter() { + let _guard = crate::gc::CopyingNurseryTestGuard::new(0); + let _triggers = crate::gc::GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _evacuation = crate::gc::knob_overrides::ForcedEvacuationTestGuard::on(); + let _verification = crate::gc::knob_overrides::VerifyEvacuationTestGuard::on(); + crate::gc::register_runtime_handle_root_scanner_for_tests(); + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver = scope.root_raw_mut_ptr(crate::object::js_object_alloc(0, 0)); + let setter = crate::closure::js_closure_alloc(collecting_setter as *const u8, 0); + let descriptor = crate::object::js_object_alloc(0, 0); + let set_key = crate::js_string_from_bytes(b"set".as_ptr(), 3); + crate::object::js_object_set_field_by_name( + descriptor, + set_key, + crate::value::js_nanbox_pointer(setter as i64), + ); + let head_key = crate::js_string_from_bytes(b"head".as_ptr(), 4); + crate::object::js_object_define_property( + receiver.with_mut_ptr::(|ptr| crate::value::js_nanbox_pointer(ptr as i64)), + crate::value::js_nanbox_string(head_key as i64), + crate::value::js_nanbox_pointer(descriptor as i64), + ); + let keys = + crate::object::js_build_class_keys_array(1017302, 3, b"head\0text\0n\0".as_ptr(), 12) + as u64; + let text = b"later value must survive the first setter"; + let string = crate::js_string_from_bytes(text.as_ptr(), text.len() as u32); + // The caller's plain buffer is deliberately NOT rooted. The helper + // must transfer all values to handles before the first setter runs. + let values = [1.0, crate::value::js_nanbox_string(string as i64), 42.0]; + let before = receiver.with_const_ptr::(|ptr| ptr as usize); + let cycles = crate::gc::copying_minor_cycles(); + let (_, after) = receiver.across_const::(|| { + let boxed = + receiver.with_mut_ptr::(|ptr| crate::value::js_nanbox_pointer(ptr as i64)); + js_literal_shape_initialize(boxed, &keys, values.as_ptr(), 3); + }); + assert!(crate::gc::copying_minor_cycles() > cycles); + assert_ne!(after as usize, before); + let key = crate::js_string_from_bytes(b"text".as_ptr(), 4); + let actual = + receiver.with_const_ptr(|ptr| crate::object::js_object_get_field_by_name(ptr, key)); + let mut scratch = [0; crate::value::SHORT_STRING_MAX_LEN]; + let (bytes, len) = + crate::string::str_bytes_from_jsvalue(f64::from_bits(actual.bits()), &mut scratch) + .expect("later string field must survive"); + assert_eq!( + unsafe { std::slice::from_raw_parts(bytes, len as usize) }, + text + ); + let key = crate::js_string_from_bytes(b"n".as_ptr(), 1); + assert_eq!( + receiver.with_const_ptr(|ptr| { + crate::object::js_object_get_field_by_name(ptr, key).as_number() + }), + 42.0 + ); + } +} diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index c069973a18..5388a2b606 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -151,6 +151,7 @@ pub(crate) use native_module_registry::nm_ctor_lookup; // Re-exported for submodule installers that delegate to a native module // (`fs/promises` → `fs.constants`, `sys` → `util`). pub(crate) use native_module_registry::{js_nm_install_fs, js_nm_install_perf, js_nm_install_util}; +mod literal_constructor; mod native_module_stream; pub(crate) mod native_this_alias; mod object_literal_ops; diff --git a/crates/perry-stdlib/src/fetch/headers.rs b/crates/perry-stdlib/src/fetch/headers.rs index 792ed67c43..eab946c46c 100644 --- a/crates/perry-stdlib/src/fetch/headers.rs +++ b/crates/perry-stdlib/src/fetch/headers.rs @@ -82,6 +82,38 @@ fn has_sync_iterator(value: f64) -> bool { raw != 0 && perry_runtime::closure::is_closure_ptr(raw as usize) } +/// A short description of a rejected `Headers` init, so the thrown message +/// names what was passed instead of only saying it is not iterable. Kept cheap: +/// it runs only on the error path. +fn describe_headers_init(value: f64) -> String { + let jsval = JSValue::from_bits(value.to_bits()); + if jsval.is_any_string() { + return "string".to_string(); + } + if perry_runtime::proxy::js_proxy_is_proxy(value) != 0 { + return "Proxy".to_string(); + } + if perry_runtime::js_array_is_array(value).to_bits() == TAG_TRUE { + return "array".to_string(); + } + let raw = perry_runtime::js_nanbox_get_pointer(value); + if raw == 0 { + return format!("{:#018x}", value.to_bits()); + } + let addr = raw as usize; + if perry_runtime::map::is_registered_map(addr) { + return "Map".to_string(); + } + if perry_runtime::set::is_registered_set(addr) { + return "Set".to_string(); + } + match gc_type_for_raw_ptr(raw) { + Some(t) if t == perry_runtime::gc::GC_TYPE_OBJECT => "object".to_string(), + Some(t) => format!("gc type {t}"), + None => format!("non-heap {:#018x}", value.to_bits()), + } +} + fn is_headers_init_iterable(value: f64) -> bool { let jsval = JSValue::from_bits(value.to_bits()); if jsval.is_any_string() { @@ -121,6 +153,16 @@ fn read_headers_record_entries( if has_sync_iterator(value) { return None; } + // A Proxy wrapping a record (`new Headers(new Proxy(headers, {}))`) is a + // valid record init: the spec reads the init's own keys and values through + // the object's internal methods, which for a Proxy means its `ownKeys` and + // `get` traps. The pointer below is a proxy id, not a `GC_TYPE_OBJECT` + // heap object, so without this branch the record path bailed and the + // constructor reported "init is not iterable" for an ordinary header + // object (OpenCode's request path, tracker #10107). + if perry_runtime::proxy::js_proxy_is_proxy(value) != 0 { + return unsafe { read_proxy_record_entries(value, scope) }; + } let raw = perry_runtime::js_nanbox_get_pointer(value); if gc_type_for_raw_ptr(raw) != Some(perry_runtime::gc::GC_TYPE_OBJECT) { return None; @@ -155,18 +197,88 @@ fn read_headers_record_entries( } } -unsafe fn materialize_headers_init_iterable( +/// Web IDL record conversion queries each own descriptor before converting its +/// key and reading its value. Both descriptor and get traps may collect. +unsafe fn read_proxy_record_entries( value: f64, scope: &perry_runtime::gc::RuntimeHandleScope, -) -> *const perry_runtime::ArrayHeader { - if !is_headers_init_iterable(value) { - headers_init_type_error("Headers constructor: init is not iterable"); +) -> Option> { + let proxy_handle = scope.root_nanbox_f64(value); + let keys_value = perry_runtime::proxy::js_proxy_own_keys(proxy_handle.get_nanbox_f64()); + let keys_handle = scope.root_nanbox_f64(keys_value); + let keys_raw = perry_runtime::js_nanbox_get_pointer(keys_handle.get_nanbox_f64()); + if keys_raw == 0 { + return Some(Vec::new()); + } + let len = perry_runtime::js_array_length(keys_raw as *const perry_runtime::ArrayHeader); + let enumerable_key = scope.root_nanbox_f64(perry_runtime::value::js_nanbox_string( + perry_runtime::js_string_from_bytes(b"enumerable".as_ptr(), 10) as i64, + )); + let mut entries = Vec::with_capacity(len as usize); + for i in 0..len { + let entry_scope = perry_runtime::gc::RuntimeHandleScope::new(); + let keys_now = perry_runtime::js_nanbox_get_pointer(keys_handle.get_nanbox_f64()); + let key = entry_scope.root_nanbox_f64(perry_runtime::array::js_array_get_f64( + keys_now as *const perry_runtime::ArrayHeader, + i, + )); + let descriptor = perry_runtime::proxy::js_reflect_get_own_property_descriptor( + proxy_handle.get_nanbox_f64(), + key.get_nanbox_f64(), + ); + if descriptor.to_bits() == TAG_UNDEFINED { + continue; + } + let descriptor = entry_scope.root_nanbox_f64(descriptor); + // Coercing an inline string can allocate. Read the descriptor pointer + // only after that coercion, and reread the key before the get trap. + let enumerable_ptr = + perry_runtime::builtins::js_string_coerce(enumerable_key.get_nanbox_f64()); + let descriptor_ptr = perry_runtime::js_nanbox_get_pointer(descriptor.get_nanbox_f64()); + let enumerable = perry_runtime::js_object_get_field_by_name_f64( + descriptor_ptr as *const perry_runtime::ObjectHeader, + enumerable_ptr, + ); + if perry_runtime::value::js_is_truthy(enumerable) == 0 { + continue; + } + if !JSValue::from_bits(key.get_nanbox_f64().to_bits()).is_any_string() { + headers_init_type_error( + "Headers constructor: symbol key cannot be converted to a ByteString", + ); + } + let key_ptr = perry_runtime::builtins::js_string_coerce(key.get_nanbox_f64()); + if key_ptr.is_null() { + continue; + } + let name = string_from_header(key_ptr as *const StringHeader).unwrap_or_default(); + let value = + perry_runtime::proxy::js_proxy_get(proxy_handle.get_nanbox_f64(), key.get_nanbox_f64()); + entries.push((name, header_init_string(value))); } - let arr_value = perry_runtime::array::js_for_of_to_array(value); + Some(entries) +} + +unsafe fn materialize_headers_init_iterable( + value: &perry_runtime::gc::RuntimeHandle<'_>, + scope: &perry_runtime::gc::RuntimeHandleScope, +) -> *const perry_runtime::ArrayHeader { + // Iterator lookup and conversion may invoke user code and move the init. + // Reuse the caller's root for conversion and the new error diagnostics. + if !is_headers_init_iterable(value.get_nanbox_f64()) { + headers_init_type_error(&format!( + "Headers constructor: init is not iterable (received {})", + describe_headers_init(value.get_nanbox_f64()) + )); + } + let arr_value = perry_runtime::array::js_for_of_to_array(value.get_nanbox_f64()); let arr_handle = scope.root_nanbox_f64(arr_value); let raw = perry_runtime::js_nanbox_get_pointer(arr_handle.get_nanbox_f64()); if raw == 0 { - headers_init_type_error("Headers constructor: init is not iterable"); + headers_init_type_error(&format!( + "Headers constructor: init is not iterable (received {})", + describe_headers_init(value.get_nanbox_f64()) + )); } raw as *const perry_runtime::ArrayHeader } @@ -245,12 +357,20 @@ pub unsafe extern "C" fn js_headers_init_from_value(handle: f64, init: f64) -> f return f64::from_bits(TAG_UNDEFINED); } + // A Proxy value is not a Headers handle: its NaN-box would otherwise be + // masked into a registry id and could alias a live Headers entry, silently + // copying the wrong (or no) headers. Route it to the record path below. + let is_proxy_init = perry_runtime::proxy::js_proxy_is_proxy(init) != 0; let source_id = handle_id(init); - let cloned = HEADERS_REGISTRY - .lock() - .unwrap() - .get(&source_id) - .map(|store| store.entries.clone()); + let cloned = if is_proxy_init { + None + } else { + HEADERS_REGISTRY + .lock() + .unwrap() + .get(&source_id) + .map(|store| store.entries.clone()) + }; if let Some(entries) = cloned { append_header_entries(target_id, entries); return f64::from_bits(TAG_UNDEFINED); @@ -265,7 +385,7 @@ pub unsafe extern "C" fn js_headers_init_from_value(handle: f64, init: f64) -> f return f64::from_bits(TAG_UNDEFINED); } - let arr = materialize_headers_init_iterable(init_now, &scope); + let arr = materialize_headers_init_iterable(&init_handle, &scope); let entries = read_headers_iterable_entries(arr, &scope); append_header_entries(target_id, entries); f64::from_bits(TAG_UNDEFINED) diff --git a/crates/perry/tests/headers_proxy_record_init.rs b/crates/perry/tests/headers_proxy_record_init.rs new file mode 100644 index 0000000000..efacb0ae20 --- /dev/null +++ b/crates/perry/tests/headers_proxy_record_init.rs @@ -0,0 +1,102 @@ +//! `new Headers(init)` must accept a Proxy wrapping a record, reading the init's +//! own keys and values through the proxy's traps. perry took the record path only +//! for plain heap objects, so a proxied header record raised +//! "Headers constructor: init is not iterable" — OpenCode's request path hit this +//! on every `run` (tracker #10107). +//! Two neighbours are deliberately out of scope here: a proxy wrapping an *array* +//! (`Array.from` over such a value segfaults, #10270) and a proxied init passed +//! through `new Request(url, { headers })`, which takes a different path (#10274). + +use std::path::PathBuf; +use std::process::Command; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn runtime_dir() -> PathBuf { + if let Some(runtime) = std::env::var_os("PERRY_RUNTIME_DIR") { + return PathBuf::from(runtime); + } + // Use the same profile as this integration executable. Building archives + // here would change Cargo feature unification beneath the running suite. + perry_bin() + .parent() + .expect("compiler directory") + .to_path_buf() +} + +const SOURCE: &str = r#" +const t = (name: string, f: () => any) => { try { console.log(name, JSON.stringify(f())) } catch (e: any) { console.log(name, "THROW", e.message) } } +const dump = (h: any) => { const out: string[] = []; h.forEach((v: string, k: string) => out.push(k + "=" + v)); return out.sort() } +t("P1 plain proxy", () => dump(new Headers(new Proxy({ "x-a": "1", "x-b": "2" }, {}) as any))) +t("P2 proxy with get trap", () => dump(new Headers(new Proxy({ "x-a": "1" }, { get: (t: any, k: any) => (typeof k === "string" && k in t ? "trapped" : (t as any)[k]) }) as any))) +t("P3 proxy with ownKeys trap hiding a key", () => dump(new Headers(new Proxy({ "x-a": "1", "x-b": "2" }, { ownKeys: () => ["x-a"], getOwnPropertyDescriptor: () => ({ configurable: true, enumerable: true, value: "1" }) }) as any))) +t("P4 proxy over empty object", () => dump(new Headers(new Proxy({}, {}) as any))) +t("P6 nested proxy", () => dump(new Headers(new Proxy(new Proxy({ "x-a": "1" }, {}), {}) as any))) +t("P8 plain object still works", () => dump(new Headers({ "x-a": "1" }))) +t("P9 array still works", () => dump(new Headers([["x-a", "1"]]))) +t("P10 map still works", () => dump(new Headers(new Map([["x-a", "1"]]) as any))) +t("P11 non-enumerable key", () => { const o: any = {"x-a":"1"}; Object.defineProperty(o,"x-hidden",{value:"secret"}); return dump(new Headers(new Proxy(o,{}))) }) +t("P12 missing descriptor", () => dump(new Headers(new Proxy({}, {ownKeys:()=>["x-ghost"]})))) +t("P13 enumerable symbol", () => { const o:any={}; o[Symbol("header")]="value"; try { new Headers(new Proxy(o,{})); return "no throw"; } catch(e:any) { return e.name; } }) +t("P14 hidden symbol", () => { const o:any={}; Object.defineProperty(o,Symbol("hidden"),{value:"secret",enumerable:false}); return dump(new Headers(new Proxy(o,{}))); }) +t("P15 descriptor/get order", () => { const order:string[]=[]; const o:any={"x-a":"1","x-b":"2"}; const h=new Headers(new Proxy(o,{getOwnPropertyDescriptor(t:any,k:any){order.push("desc:"+String(k));return Object.getOwnPropertyDescriptor(t,k);},get(t:any,k:any){if(typeof k==="string")order.push("get:"+k);return t[k];}})); return [dump(h),order]; }) +"#; + +const EXPECTED: &str = "P1 plain proxy [\"x-a=1\",\"x-b=2\"]\nP2 proxy with get trap [\"x-a=trapped\"]\nP3 proxy with ownKeys trap hiding a key [\"x-a=1\"]\nP4 proxy over empty object []\nP6 nested proxy [\"x-a=1\"]\nP8 plain object still works [\"x-a=1\"]\nP9 array still works [\"x-a=1\"]\nP10 map still works [\"x-a=1\"]\nP11 non-enumerable key [\"x-a=1\"]\nP12 missing descriptor []\nP13 enumerable symbol \"TypeError\"\nP14 hidden symbol []\nP15 descriptor/get order [[\"x-a=1\",\"x-b=2\"],[\"desc:x-a\",\"get:x-a\",\"desc:x-b\",\"get:x-b\"]]\n"; + +fn compile_and_run(source: &str) -> String { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let output = dir.path().join("main_bin"); + std::fs::write(&entry, source).expect("write entry"); + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&output) + .arg("--no-cache") + .arg("--no-codegen") + .env("PERRY_NO_AUTO_OPTIMIZE", "1") + .env("PERRY_RUNTIME_DIR", runtime_dir()) + .output() + .expect("run perry compile"); + assert!( + compile.status.success(), + "perry compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + let run = Command::new(&output) + .current_dir(dir.path()) + .output() + .expect("run compiled binary"); + assert!( + run.status.success(), + "compiled binary failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + String::from_utf8(run.stdout).expect("UTF-8 output") +} + +#[test] +fn headers_accepts_a_proxied_record_init() { + assert_eq!(compile_and_run(SOURCE), EXPECTED); +} + +#[test] +fn rejected_headers_init_diagnostic_identifies_the_value() { + let source = r#" +for (const value of [123, true]) { + try { new Headers(value as any); console.log("accepted"); } + catch (e: any) { console.log(e.name, e.message); } +} +"#; + assert_eq!( + compile_and_run(source), + "TypeError Headers constructor: init is not iterable (received 0x405ec00000000000)\nTypeError Headers constructor: init is not iterable (received 0x7ffc000000000004)\n" + ); +} diff --git a/crates/perry/tests/issue_10151_large_json_define.rs b/crates/perry/tests/issue_10151_large_json_define.rs index 2486cf6b88..e5998ba36d 100644 --- a/crates/perry/tests/issue_10151_large_json_define.rs +++ b/crates/perry/tests/issue_10151_large_json_define.rs @@ -226,3 +226,52 @@ console.log(JSON.stringify(value.nested)); "z,a,1e-7,nested,padding\n2 true 255\n[true,null,\"quote\\\"\\n\\\\☃\"]\n" ); } + +#[test] +fn mid_size_record_literal_keeps_shapes_without_the_compile_cliff() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join("perry.json"), "{}\n").unwrap(); + // 22,401 value nodes: below the record JSON.parse cutoff, but the old + // ordinary path took over five minutes. The shared 60-second timeout is + // intentionally well above the seconds this case should need in CI. + let records = (0..3200) + .map(|i| { + format!( + r#"{{id:{i},name:"n{i}",tags:["a","b{}"],w:{}}}"#, + i % 5, + i as f64 / 4.0, + ) + }) + .collect::>() + .join(","); + std::fs::write( + root.join("main.ts"), + format!( + r#" +type Rec = {{id:number,name:string,tags:string[],w:number}}; +function read(): Rec[] {{ return [{records}]; }} +const a = read(); +gc(); +const b = read(); +let sum = 0; +for (let i = 0; i < a.length; i++) {{ + const q = a[i]; + sum += q.w + q.tags.length + q.id; +}} +console.log(a.length, sum, Object.keys(a[0]).join(",")); +a[0].tags[0] = "changed"; +a[0].w = 99; +console.log(b[0].tags[0], b[0].w, a === b, a[0] === b[0]); +"#, + ), + ) + .unwrap(); + let hir = compile(root, &["--print-hir"]); + assert!(hir.contains("__AnonShape_"), "record shapes must survive"); + assert!( + !hir.contains("JsonParse("), + "this is the ordinary-path case" + ); + assert_eq!(run(root), "3200 6404400 id,name,tags,w\na 0 false false\n"); +} diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index d172c74801..2d8de2811a 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -313,7 +313,7 @@ "file": "crates/perry-runtime/src/gc/census.rs", "name": "PASS1_MARKED", "verdict": "non_moving_snapshot", - "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete → sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs — it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase — after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged — `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` — and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` → `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only — no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound — the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses — no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects — and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module — all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete → sweep-entry window of a synchronous full — where PASS1_MARKED is populated and consumed within one `run_to_completion` — is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize — INSIDE the window — the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-13 for #10182's full-collection throughput follow-up, which touched `gc/cycle.rs` in one hunk, INSIDE the window: the `RememberedSetRebuild` subphase of a synchronous full now first asks `verify::full_remembered_rebuild_provably_empty` and, when it holds, installs `OldToYoungRememberedRebuildState::provably_empty()` (an empty sticky set, no walk) instead of the require-marked rebuild. The predicate reads `arena_block_snapshots()` (one `Vec` through the global allocator), the census's per-block reached/pre-marked facts and the malloc registry's length; the constructor bumps a `Cell` counter and prints one line under `PERRY_GC_DIAG`. None of it allocates a GC object, relocates anything, collects, or runs a JS callback, and both census boundaries stay where they were. Re-audited 2026-09-14 for #10182's pacing-full work, which touched `gc/cycle.rs`, `gc/mod.rs` and `gc/policy.rs`. `gc/cycle.rs`: `GcCycleState::new_full` no longer calls `materialize_all_promoted_page_runs`; that call ran in the constructor, before the census and far before `census_pass1_if_armed`, and removing it adds nothing to the window. `gc/mod.rs`: one `mod promoted_cohort;` declaration. `gc/policy.rs`: (a) `credit_promoted_bytes_to_old_baseline` also credits a `Cell` cohort counter (it runs after a copying minor completes); (b) `finish_full_old_reclaim_baseline` also records the verified old live bytes and resets that counter (Publish, after `step_sweep` consumed the snapshot); (c) `gc_safepoint_moving_minor` arms and disarms the promotion-census record around its nursery minor and calls `run_promoted_cohort_full_if_due`, which starts a synchronous full through the same `gc_collect_full_mark_sweep_with_trigger` entry and reads byte counters before and after it. All of it runs before a cycle starts or after it completes; none of it runs between mark completion and sweep entry, allocates a GC object, relocates anything, or calls into JS. The census the promoted-cohort full may adopt from the promotion walk is built in `BuildValidPointerSet`, before either boundary. Both boundaries are unchanged. Re-audited 2026-09-14 for the #10182 dead-stack scrub in `gc/cycle.rs`: `step_build_valid_pointer_set` now calls `scrub_dead_stack_below`, which zeroes a local array in its own frame (dead stack below the caller), right after the census finishes — in `BuildValidPointerSet`, before the root scan and far before `census_pass1_if_armed`. It writes no heap memory, allocates nothing, relocates nothing and calls no JS; both boundaries are unchanged. Re-audited 2026-09-14 for #10241 (cohort survival), which touched `gc/cycle.rs` and `gc/policy.rs`. `gc/cycle.rs`: one call, `promoted_cohort::survival::check_minor_view_at_full_sweep_start()`, in `step_sweep` immediately AFTER `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS, i.e. outside the window. It is a no-op unless a promoted-cohort full armed its survival probe; when armed it walks the old page index over the preceding minor's dirty pages (`old_arena_walk_objects_on_pages`, Rust-allocator Vecs), reads GC headers' mark flags and the slots of unmarked ones, and records one enum. It writes no heap memory, allocates no GC object, relocates nothing and calls no JS. `gc/policy.rs`: `run_promoted_cohort_full_if_due` arms the probe before `gc_collect_full_mark_sweep_with_trigger` and takes it after the full returns (feeding `note_full_measured_promotion_survival` and one diagnostic line); both run before a cycle starts or after it completes. Both boundaries are unchanged. Re-audited 2026-09-14 for #10241's in-place-only cohort: `gc/policy.rs` drops the `promoted_cohort::note_promoted` call from `credit_promoted_bytes_to_old_baseline` (the copying minor now calls `promoted_cohort::note_minor_promotion` itself, after the credit). Both run at the end of a copying minor, outside any full cycle; the mark-complete to sweep-entry window is unchanged.", + "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete → sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs — it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase — after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged — `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` — and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` → `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only — no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound — the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses — no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects — and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected. Re-audited 2026-09-07 for the regex census rows: all new work is in `take_census` after `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS; neither boundary nor the intervening cycle control flow changed. Re-audited 2026-09-08 (train144) after #9976 and #9977 touched pinned files. `gc/mod.rs` gains exactly three lines: `mod copying_phase;` and `mod regex_census;` (declarations) and one `reg_scanner!(regex::site_test::scan_roots_mut)` registration. A scanner registration adds a root SOURCE for the mutable-root walks; it does not move either census boundary and runs nowhere between them. `gc/census.rs` widens `side_tables()` to `pub(super)`, extends it with regex rows and adds a test module — all census REPORTING, which runs from the diagnostic dump, not inside a cycle. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-08 for #9849 JSON construction deferral. `gc/mod.rs` adds the `json_defer` module/re-export and a trusted-header layout helper used only by already-validated JSON emitters; neither changes or runs in collector phase control flow. `gc/policy.rs` adds JSON completion scheduling, construction-grace checks, and safepoint deferral predicates. These are called from mutator-side JSON allocation/output boundaries and ordinary safepoint entry; they do not alter `step_mark_propagation`, `step_sweep`, or invoke callbacks or relocation between the census boundaries. The mark-complete to sweep-entry window is unchanged. The follow-up adds a cfg(test)-only one-shot boolean for deterministic explicit-pressure fixtures; it is absent from production builds and cannot affect the census window. The first predicate read consumes it, so post-parse accounting exercises normal pricing. Re-audited 2026-09-09 for bounded tiny-JSON completion polling. The policy.rs changes split the mutator-side pending-parse check into an inlined empty fast path plus an outlined debt-service path, and amortize the mutator-side arena-pressure read across 64 bounded parse completions. Neither function is reachable from step_mark_propagation or step_sweep; neither census boundary nor the synchronous full-cycle interval between them changes. Re-audited 2026-09-09 for lazy JSON record batches: policy.rs only widens gc_budgeted_cycle_active visibility from pub(super) to pub(crate). Its body remains a read-only Cell query. The new caller is lazy_get materialization in the mutator; run_to_completion, step_mark_propagation, census snapshot consumption at step_sweep, and the synchronous non-moving window are unchanged. Re-audited 2026-09-09 for completed JSON-output debt: the added gc_service_json_output_sweep function calls the existing trigger check from a rooted mutator boundary and reports whether its malloc-count request remains due. It is not called from any census or collector phase; the synchronous mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-09 for the JSON byte-debt carry: the same mutator-only service helper now distinguishes requests satisfied before its call from those satisfied by its trigger check. The added enum contains no payload, both count reads are scalar, and no census boundary or collector phase changed. Re-audited 2026-09-11 for #10055: gc/mod.rs only registers the weak UTF-16 index scanner during gc_init. It neither marks strings nor allocates GC objects or runs JS; offset vectors use the Rust allocator. The mark-complete to sweep-entry census window and cycle control flow are unchanged. Re-audited 2026-09-11 for #10054: gc/mod.rs adds only the trim-cache mutable-root scanner registration in gc_init. Its scanner visits two existing string slots without allocating or invoking JS. Root scanning still precedes mark completion, and neither census boundary nor the synchronous mark-complete to sweep-entry window changes. Re-audited 2026-09-11 for #10060: the census array classifier now reads the logical element start and bounds its scan by the remaining capacity. The helper only reads the existing GC/header words and performs pointer arithmetic; it cannot allocate, collect, or call JS. This classifier runs in take_census after PASS1_MARKED has been taken out of TLS. Neither census boundary nor the mark-complete to sweep-entry control flow changed. Re-audited for #8512: gc/mod.rs only enables the existing PTY mutable-root scanner on Windows; it changes no mark/sweep phase or census boundary. The scanner visits NaN-boxed slots without running JS callbacks. Re-audited 2026-09-12 for the single regular-expression engine: `gc/mod.rs` changes `mod prefetch;` to `pub(crate) mod prefetch;` so the RegExp owner-table walks can prefetch headers, a visibility change with no new call in collector control flow; `gc/census.rs` changes only its `#[cfg(test)]` `regex_census_tests` module, dropping assertions for the previous engine's cache rows. Neither boundary (`census_pass1_if_armed` in `step_mark_propagation`, `census_take_if_armed_at_full_sweep_start` in `step_sweep`) nor the synchronous mark-complete to sweep-entry interval changes. Re-audited 2026-09-13 after the #10169 fix touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` gains only `pub(crate) use` re-exports (`policy::note_young_leaf_born_old`, `policy::young_generation_holds_a_nursery`, `promote_in_place::{young_generation_measured_dying, young_generation_measured_retained}`, and cfg(test) survival seeders). `gc/policy.rs` gains a `Cell` thread-local (`GC_YOUNG_LEAF_BORN_OLD`, no pointer), its setter, a pure predicate over `copying_from_space_in_use_bytes` vs the base nursery cap, and a consumed-once branch at the top of `gc_budgeted_due_trigger` that may answer `YoungScavengeCap` ahead of `OldReclaim`. That branch decides WHICH collection a safepoint starts (a minor instead of a full); it runs before any cycle begins and never inside one, so the mark-complete → sweep-entry window of a synchronous full — where PASS1_MARKED is populated and consumed within one `run_to_completion` — is unchanged, and neither hunk adds an allocation, a JS callback, or a relocation to it. Re-audited 2026-09-13 for the heap generation (#10164 cross-call search positions): `gc/mod.rs` only declares `pub(crate) mod heap_generation;`. `gc/cycle.rs` wraps the `Sweep` and `Reclaim` arms of `GcCycleState::step` in a `HeapChange` scope and opens one inside `atomic_finalize_minor_prelude`'s evacuation branch (with a nested one around old-page defrag). Opening and closing a scope only increments two thread-local integer cells (`HEAP_GENERATION`, `OPEN_HEAP_CHANGES`); a first thread-local read may allocate a key through the global allocator, which neither relocates nor runs JS. The `Sweep` scope opens immediately before `step_sweep`, i.e. before `census_take_if_armed_at_full_sweep_start` takes PASS1_MARKED out of TLS, and adds no relocation, collection or JS callback to the synchronous mark-complete to sweep-entry window; the minor-prelude scope is unreachable from a full cycle, which bypasses `MinorPrelude`. Neither boundary nor the intervening control flow changed. Re-audited 2026-09-13 for #10182 block-granular reclamation, which touched `gc/cycle.rs`. Two hunks: (a) in the `RememberedSetRebuild` subphase of AtomicFinalize — INSIDE the window — the require-marked old-to-young rebuild is now constructed with `OldToYoungRememberedRebuildState::new_skipping`, whose cursor never enters blocks the census recorded as holding no reached, pinned or pre-marked object (`BlockCensus::unmarked_blocks`); computing that list reads `arena_block_snapshots()` and allocates one `Vec` through the global allocator. It visits a subset of the same objects the rebuild already walked (every skipped object would have been rejected as unmarked), and it neither allocates a GC object, relocates anything, nor runs a JS callback. (b) In `step_sweep`, `IncrementalSweepState::with_block_skip` runs after `census_take_if_armed_at_full_sweep_start` has already taken PASS1_MARKED out of TLS. Neither boundary moved and the synchronous mark-complete to sweep-entry interval gains no relocation, collection or callback. Re-audited 2026-09-11 for the startup memory profile: gc/mod.rs only retains the pre-main allocator-policy constructor in js_gc_init. The constructor applies process allocation options, without invoking GC or JS. No census boundary, collector phase, or mark-complete to sweep-entry control flow changed. Re-audited 2026-09-13 for #10179: census.rs only adds a native regex cache metadata row and its unit assertion; snapshot consumption and the full-cycle window are unchanged. Re-audited 2026-09-14 for the GC due-check fast path, which touched `gc/mod.rs` and `gc/policy.rs`. `gc/mod.rs` only changes the safepoint re-exports: `gc_runtime_safepoint` becomes cfg(test) and `gc_runtime_safepoint_poll` is added. `gc/policy.rs`: the budgeted step returns a debt-free `GcStepReport` (debt is attached by the FFI and test entry points after the step returns) and moves cycle start/step into an out-of-line `gc_budgeted_start_or_step`; `gc_check_trigger` reuses a repeatable due-trigger answer through `DueTriggerMemo`, placed after its `GC_FLAG_IN_ALLOC` and suppression early returns; the young scavenge cap reuses the old-gen pressure value the due trigger already read and checks the census-seeded flag first. All of it runs from mutator safepoints, allocation-point trigger checks and the host step API, before a cycle starts or between budgeted steps. None of it is reachable between `census_pass1_if_armed` in `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` in `step_sweep` of a synchronous full: an allocation inside that window reaches `gc_check_trigger` with `GC_FLAG_IN_ALLOC` set and returns before the changed code. No allocation, relocation, collection or JS callback is added to the window. Re-audited 2026-09-14 for the tiny-parse nursery-cap boundary, which touched `gc/policy.rs`. It adds `tiny_parse_generational_collection_due`, a pure predicate (the existing `tiny_parse_pressure_due` OR the existing `young_scavenge_cap_due` read), and calls it instead of `tiny_parse_pressure_due` from `gc_bump_malloc_trigger_inner` and `gc_collect_pending_suppressed_parse_slow` (generational branch only) and from `gc_schedule_parse_boundary_collection_if_pressure`. All three are JSON.parse mutator-side boundaries, none reachable from `step_mark_propagation` or `step_sweep`; the predicate reads counters and allocates nothing. Neither census boundary nor the synchronous mark-complete to sweep-entry interval changed. Re-audited 2026-09-13 for #10182's full-collection throughput follow-up, which touched `gc/cycle.rs` in one hunk, INSIDE the window: the `RememberedSetRebuild` subphase of a synchronous full now first asks `verify::full_remembered_rebuild_provably_empty` and, when it holds, installs `OldToYoungRememberedRebuildState::provably_empty()` (an empty sticky set, no walk) instead of the require-marked rebuild. The predicate reads `arena_block_snapshots()` (one `Vec` through the global allocator), the census's per-block reached/pre-marked facts and the malloc registry's length; the constructor bumps a `Cell` counter and prints one line under `PERRY_GC_DIAG`. None of it allocates a GC object, relocates anything, collects, or runs a JS callback, and both census boundaries stay where they were. Re-audited 2026-09-14 for #10182's pacing-full work, which touched `gc/cycle.rs`, `gc/mod.rs` and `gc/policy.rs`. `gc/cycle.rs`: `GcCycleState::new_full` no longer calls `materialize_all_promoted_page_runs`; that call ran in the constructor, before the census and far before `census_pass1_if_armed`, and removing it adds nothing to the window. `gc/mod.rs`: one `mod promoted_cohort;` declaration. `gc/policy.rs`: (a) `credit_promoted_bytes_to_old_baseline` also credits a `Cell` cohort counter (it runs after a copying minor completes); (b) `finish_full_old_reclaim_baseline` also records the verified old live bytes and resets that counter (Publish, after `step_sweep` consumed the snapshot); (c) `gc_safepoint_moving_minor` arms and disarms the promotion-census record around its nursery minor and calls `run_promoted_cohort_full_if_due`, which starts a synchronous full through the same `gc_collect_full_mark_sweep_with_trigger` entry and reads byte counters before and after it. All of it runs before a cycle starts or after it completes; none of it runs between mark completion and sweep entry, allocates a GC object, relocates anything, or calls into JS. The census the promoted-cohort full may adopt from the promotion walk is built in `BuildValidPointerSet`, before either boundary. Both boundaries are unchanged. Re-audited 2026-09-14 for the #10182 dead-stack scrub in `gc/cycle.rs`: `step_build_valid_pointer_set` now calls `scrub_dead_stack_below`, which zeroes a local array in its own frame (dead stack below the caller), right after the census finishes — in `BuildValidPointerSet`, before the root scan and far before `census_pass1_if_armed`. It writes no heap memory, allocates nothing, relocates nothing and calls no JS; both boundaries are unchanged. Re-audited 2026-09-14 for #10241 (cohort survival), which touched `gc/cycle.rs` and `gc/policy.rs`. `gc/cycle.rs`: one call, `promoted_cohort::survival::check_minor_view_at_full_sweep_start()`, in `step_sweep` immediately AFTER `census_take_if_armed_at_full_sweep_start` has taken PASS1_MARKED out of TLS, i.e. outside the window. It is a no-op unless a promoted-cohort full armed its survival probe; when armed it walks the old page index over the preceding minor's dirty pages (`old_arena_walk_objects_on_pages`, Rust-allocator Vecs), reads GC headers' mark flags and the slots of unmarked ones, and records one enum. It writes no heap memory, allocates no GC object, relocates nothing and calls no JS. `gc/policy.rs`: `run_promoted_cohort_full_if_due` arms the probe before `gc_collect_full_mark_sweep_with_trigger` and takes it after the full returns (feeding `note_full_measured_promotion_survival` and one diagnostic line); both run before a cycle starts or after it completes. Both boundaries are unchanged. Re-audited 2026-09-14 for #10241's in-place-only cohort: `gc/policy.rs` drops the `promoted_cohort::note_promoted` call from `credit_promoted_bytes_to_old_baseline` (the copying minor now calls `promoted_cohort::note_minor_promotion` itself, after the credit). Both run at the end of a copying minor, outside any full cycle; the mark-complete to sweep-entry window is unchanged. Re-audited 2026-09-14 for the parse-boundary side-allocation band (medium-parse pacing), which touched `gc/policy.rs`. Three hunks: (a) a `Cell` thread-local (`GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES`, a byte COUNT, no pointer) plus three pure predicates over it and `external_side_live_bytes()`; (b) that predicate added as a third disjunct of `tiny_parse_generational_collection_due`, which is read only from the three JSON.parse mutator-side boundaries (`gc_bump_malloc_trigger_inner`, `gc_collect_pending_suppressed_parse_slow`, `gc_schedule_parse_boundary_collection_if_pressure`), none of them reachable from `step_mark_propagation` or `step_sweep`; and (c) one extra `Cell` store in `note_collection_finished_arena_occupancy` plus two extra reads in the `PERRY_GC_DIAG` tiny-parse line. `note_collection_finished_arena_occupancy` runs from `publish_reclaim_outcome` in the Publish subphase, i.e. AFTER `step_sweep` has already `take()`n the snapshot out of the thread-local, exactly as #9831's store on the same line does. Nothing added allocates a GC object, relocates anything, or runs a JS callback, and neither census boundary moved. Re-audited 2026-09-14 for the drained-bytes counterweight to that band, which touched `gc/policy.rs` again. Four hunks: a second `Cell` thread-local (`GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL`, a byte COUNT); one increment of it inside `gc_note_external_side_free`; a pure read (`external_side_old_reclaim_pressure_bytes`) substituted for `external_side_live_bytes()` at the four old-reclaim pressure sites; and one `Cell` store at the top of `finish_full_old_reclaim_baseline`. None of it can run between the census boundaries. `gc_note_external_side_free` is also reached by mutator-side tape materialization, regex scratch teardown, native-addon adjustments and buffer replacement. Its added operation is only a saturating increment of a scalar Cell, with no GC allocation, relocation, collection or JS callback, so this wider caller set does not invalidate the census window. `finish_full_old_reclaim_baseline` runs from `publish_reclaim_outcome` in the Publish subphase, the same place #9831's store already sits. The pressure reads happen at trigger decisions, before a cycle starts. No allocation, relocation, collection or JS callback is added to the mark-complete -> sweep-entry window, and neither boundary moved.", "window": { "start": { "file": "crates/perry-runtime/src/gc/census.rs", @@ -331,7 +331,7 @@ "crates/perry-runtime/src/gc/census.rs": "5c151725460ffb92a55a6bee781123ef5159263b4ce5958d16570f78216e0d67", "crates/perry-runtime/src/gc/cycle.rs": "b035dcb44df029358cbab0afaa526e8e506765f5178034663257e18ceefaf9df", "crates/perry-runtime/src/gc/mod.rs": "9fedd2790f48154aaeceefb4805d3fbaa2fdf3c407529b326425fde86c2bf9a5", - "crates/perry-runtime/src/gc/policy.rs": "aee430efe60cedec2bb5c7aba7bf3e929636145049630f2cb106cceff7613b44", + "crates/perry-runtime/src/gc/policy.rs": "895c6f4bd1a6e491adf348ecfa89985b03e354fcee7cf73826bb590f9ace9163", "crates/perry-runtime/src/gc/progress.rs": "a5ad3971bbe4047229ca57325234780daa85921dbc778e1c08dff4ad07ccfb96" } } @@ -402,6 +402,18 @@ "verdict": "not_a_gc_pointer", "why": "#9772: releasable block BYTES the last idle selection promised — a size, not an address. A `Cell` compared against what the collection actually released." }, + { + "file": "crates/perry-runtime/src/gc/policy.rs", + "name": "GC_EXTERNAL_SIDE_DRAINED_SINCE_FULL", + "verdict": "not_a_gc_pointer", + "why": "External side-buffer bytes reported released since the last full baseline, including collector finalization and mutator operations such as JSON tape materialization, regex scratch teardown, native-addon adjustments and Map/Set buffer replacement. This cumulative count keeps released bytes in old-reclaim pressure until a full resets it, and can schedule a full earlier than the previous live-only term. A byte COUNT in Cell, never a heap address or NaN-boxed value. Written by gc_note_external_side_free, reset by finish_full_old_reclaim_baseline and test guards, read by external_side_old_reclaim_pressure_bytes and diagnostics." + }, + { + "file": "crates/perry-runtime/src/gc/policy.rs", + "name": "GC_LAST_COLLECTION_EXTERNAL_SIDE_BYTES", + "verdict": "not_a_gc_pointer", + "why": "Medium-parse pacing (2026-09-14): `external_side_live_bytes()` (a running sum of Map/Set entry-buffer and JSON tape side-allocation byte counts) recorded as each collection ends, read back by the parse-boundary side-allocation band to price growth since then. A byte COUNT in a `Cell`, never an address or a NaN-boxed value: written only from `note_collection_finished_arena_occupancy` and the test guard, read only by `external_side_parse_pressure_due` and `diag_tiny_parse_forced_collection`. Same shape and same verdict as its sibling `GC_TINY_PARSE_PRESSURE_BASE_BYTES` two declarations over." + }, { "file": "crates/perry-runtime/src/gc/policy.rs", "name": "GC_TINY_PARSE_BOUNDARY_POLL_REMAINING",