diff --git a/script/benchmark-class-width/README.md b/script/benchmark-class-width/README.md new file mode 100644 index 000000000..b333b6ce2 --- /dev/null +++ b/script/benchmark-class-width/README.md @@ -0,0 +1,135 @@ +# Class-width benchmark (issue #559) + +Manual for reproducing the Rust measurement used to answer +[#559](https://github.com/glideapps/quicktype/issues/559) — see `RESULTS.md` +for the write-up. This benchmark only concerns Rust; it's the toolchain the +issue names and the one CI actually compiles (`.github/workflows/test-pr.yaml`, +`fixture: rust,schema-rust`). + +## What it's testing + +#559 claims `blns-object.json` and `keyword-unions.schema` have objects big +enough to slow down Rust compilation. The claimed mechanism: serde's +`#[derive(Deserialize)]` expands an N-field struct into one `visit_map` +function with N locals, an N-arm match, and N missing-field checks — cost +concentrated in one function, not spread across the file. + +To test that, candidates are built holding the **same keys spread across more, +narrower classes** — identical total field count, so a linear cost model +predicts identical compile times. Any drop would be the superlinear part the +issue is about. `n=1` is a control: same grouping as the checked-in file, so it +must reproduce today's numbers or the harness is wrong. + +## Requirements + +```bash +npm run build # need dist/index.js +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh # if cargo isn't installed +export PATH="$HOME/.cargo/bin:$PATH" # rustup installs here, not on PATH by default +``` + +Run everything from the repo root. Nothing here writes into the repo — all +candidates and build artifacts live under `$BENCH`, a scratch directory: + +```bash +export BENCH=/tmp/bench # or wherever you like +mkdir -p $BENCH/inputs +B=script/benchmark-class-width +``` + +## 1. Build candidates + +Same keys, increasingly narrow classes: + +```bash +for n in 1 2 4 8; do python3 $B/reshape-blns.py $n > $BENCH/inputs/blns-$n.json; done +for n in 1 2 5 10; do python3 $B/reshape-keyword-unions.py $n > $BENCH/inputs/ku-$n.schema; done +``` + +## 2. Tier 0 — structural check (no toolchain, ~5 seconds) + +Confirms the candidates isolate the right variable: total code volume should +stay roughly flat while the widest class drops sharply. + +```bash +python3 $B/measure.py --lang rust --src-lang json $BENCH/inputs/blns-{1,2,4,8}.json +python3 $B/measure.py --lang rust --src-lang schema $BENCH/inputs/ku-{1,2,5,10}.schema +``` + +Reference output: + +``` +candidate lines types widest widest type +-------------------------------------------------------------------- +blns-1.json 1776 4 230 A +blns-2.json 1801 7 116 A2 +blns-4.json 1844 13 59 A4 +blns-8.json 1941 25 30 A2 + +candidate lines types widest widest type +-------------------------------------------------------------------- +ku-1.schema 3952 554 276 Group1 +ku-2.schema 3957 555 138 Group1 +ku-5.schema 3972 558 56 Group5 +ku-10.schema 3997 563 28 Group10 +``` + +Lines move under 10% while widest class drops 8-10×. + +## 3. Tier 1 — the actual timing + +```bash +REPS=10 bash $B/bench-rust.sh --src-lang json $BENCH/inputs/blns-{1,2,4,8}.json +REPS=10 bash $B/bench-rust.sh --src-lang schema $BENCH/inputs/ku-{1,2,5,10}.schema +``` + +`bench-rust.sh` pins a shared `CARGO_TARGET_DIR` and does a throwaway warm-up +build first, so serde compiles once and every timed build recompiles only the +crate under test. Without that, the per-sample serde rebuild the real fixture +harness pays (~8s) would swamp the ~1-2s signal being measured. `touch` before +each build forces a genuine recompile. If a build fails, the script aborts +loudly rather than reporting a fast, meaningless number — a failed compile +looks like a great result if you don't check for it. + +`REPS=10` takes ~15-25s per candidate; use `REPS=3` for a quick check, `REPS=10` +for a number worth quoting — single-digit rep counts on the `ku` schema were +noisy enough in early runs to be misleading. + +Reference output: + +``` +candidate widest lines median_s +------------------------------------------------------ +blns-1.json 230 1776 0.73 +blns-2.json 116 1801 0.74 +blns-4.json 59 1844 0.78 +blns-8.json 30 1941 0.82 + +candidate widest lines median_s +------------------------------------------------------ +ku-1.schema 276 3952 1.98 +ku-2.schema 138 3957 1.97 +ku-5.schema 56 3972 2.00 +ku-10.schema 28 3997 2.02 +``` + +## Reading the result + +- **Steep drop as width falls** → the premise holds. +- **Flat, or a slight rise** → the premise doesn't hold on this rustc. + +Measured outcome: flat on `ku`, and `blns` gets **monotonically slower** as +classes narrow (0.73s → 0.82s, +12%) — more types cost more than the wide +struct saves. See `RESULTS.md` for the full write-up and what's worth doing +instead. + +## Troubleshooting + +- `cargo not found` → `export PATH="$HOME/.cargo/bin:$PATH"` (not persisted by + the rustup install used here). +- Glob like `$BENCH/inputs/ku-{1,2,5,10}.schema` passed through literally → + `$BENCH` isn't set in this shell, or step 1 wasn't run in it, so the files + don't exist and the brace expansion has nothing to match. +- `BUILD FAILED for ... -- timings would be meaningless` → the script caught a + real compile error; the tail of `rustc`'s output is printed above the + message. diff --git a/script/benchmark-class-width/RESULTS.md b/script/benchmark-class-width/RESULTS.md new file mode 100644 index 000000000..c4bad57de --- /dev/null +++ b/script/benchmark-class-width/RESULTS.md @@ -0,0 +1,141 @@ +# Issue #559 — measured on Rust, and the premise doesn't hold + +**Recommendation: close #559 as obsolete.** Narrowing the widest generated Rust +struct by 8-10× produces no compile-time improvement. If anything, it's +slightly slower. + +Measured 2026-08-12/13, macOS / Apple Silicon, rustc + cargo 1.97.1. + +## The claim under test + +#559 says `blns-object.json`, `keywords.json`, and `keyword-unions.schema` have +objects big enough to slow down compilation, "particularly Rust." It comes from +[#516](https://github.com/glideapps/quicktype/issues/516), where profiling with +`cargo-expand` and `-Ztime-passes` found 40k expanded lines, 30k in a single +method, dominated by rustc's `translation` pass. + +The claimed mechanism: for an N-field struct, serde's `#[derive(Deserialize)]` +expands into one `visit_map` function with N locals, an N-arm match, and N +missing-field checks. Cost concentrates in **one function**, not spread across +the file. Rust is the language actually named, the only one profiled in #516, +and the only one this repo's CI compiles for these fixtures +(`.github/workflows/test-pr.yaml`, `fixture: rust,schema-rust`) — so it's the +toolchain this benchmark measures directly rather than by inference. + +## Method + +Candidates hold the same keys spread across more, narrower classes — **identical +total field count**, so a linear cost model predicts identical compile times. +Any drop would be the superlinear component the issue describes. + +| candidate | lines | types | widest class | +|---|---|---|---| +| `blns-1` *(= the checked-in file)* | 1,776 | 4 | **230** | +| `blns-2` | 1,801 | 7 | 116 | +| `blns-4` | 1,844 | 13 | 59 | +| `blns-8` | 1,941 | 25 | 30 | + +| candidate | lines | types | widest class | +|---|---|---|---| +| `ku-1` *(≈ the checked-in file)* | 3,952 | 554 | **276** | +| `ku-2` | 3,957 | 555 | 138 | +| `ku-5` | 3,972 | 558 | 56 | +| `ku-10` | 3,997 | 563 | 28 | + +Total lines move under 10% in both cases while the widest class drops 8-10×. +Three guards: `n=1` is a control that must reproduce the checked-in file (it +does); every candidate is asserted lossless (identical key/value multiset); and +`bench-rust.sh` aborts if a build fails, since a failed build is fast and would +otherwise fake a win. + +**What's timed:** `cargo build` only — compile time, not `cargo run`, not the +JSON round-trip. serde is prebuilt once in a shared `CARGO_TARGET_DIR`, so each +timed build recompiles only `module_under_test.rs`. This isolates the specific +thing #559 is about (rustc compiling a wide struct), separate from the +per-sample fixture overhead covered below. + +## Results + +Median of 10 builds per candidate: + +| widest class | `blns` compile | `keyword-unions` compile | +|---|---|---| +| 276 / 230 *(control)* | 0.73s | 1.98s | +| 138 / 116 | 0.74s | 1.97s | +| 56 / 59 | 0.78s | 2.00s | +| 28 / 30 | **0.82s** | 2.02s | + +`keyword-unions` is flat to within 3%. `blns` is **monotonically slower** as +classes narrow — 0.73s → 0.82s, +12% — tracking the 9% growth in emitted lines: +splitting one wide struct into several narrow ones adds more code than it +removes cost. Neither result supports the issue's premise on the toolchain it +actually names. + +rustc has also changed substantially since the 2018 profile (rustc ~1.24 era); +whatever superlinear cost `-Ztime-passes` found in `translation` back then does +not reproduce on 1.97. + +## Where fixture time actually goes + +For context on the ~9% number below and why it matters: + +``` +one rust sample through the harness: 8,656 ms +of which the crate compile is: 790 ms (9%) +``` + +The other ~91% is dependency compilation: `fixtures.ts:281` copies +`test/fixtures/rust` into a fresh directory per sample, `RustLanguage` has no +`setupCommand`, and no `CARGO_TARGET_DIR` is set anywhere in the repo, so cargo +rebuilds serde/serde_derive/serde_json from scratch every time. + +Two things follow from this, and they cut in opposite directions from #559: + +- Splitting `blns-object.json` into 4 files, as #559 suggests, adds 3 more + samples — roughly 3 × 8.66s ≈ **26 seconds** per Rust JSON run — for a + compile-time change measured at ±0.1s. That's a net loss. +- The 91% figure suggests the real lever is elsewhere: **setting a shared + `CARGO_TARGET_DIR` for the Rust fixture is worth investigating separately.** + This is a plausible optimization based on where the time currently goes in a + single measured sample, not something this benchmark proves out end-to-end. + It wasn't tested against the full 52-sample suite, doesn't account for cache + contention if the harness's forked workers (`test/lib/multicore.ts`) write to + the same target directory concurrently, and CI's cold-start-per-job model may + behave differently than this local, warm-cache measurement. Worth a + follow-up spike, not a claimed fix. + +## Scope of the evidence + +This result is Rust-only, deliberately — see "The claim under test" above for +why that's the toolchain that matters for #559. Two earlier passes on other +toolchains (C++/g++ and Swift/swiftc, both also flat) and a discussion of +JVM/C# coverage are not reproduced here; ask if that's wanted for a broader +writeup. + +## Worth doing instead + +**1. Python cannot run `keyword-unions.schema` at all.** It generates a +`KeywordUnions(...)` call with **276 arguments**, past CPython's 255 limit — +hence `test/languages.ts:336`: + +```ts +skipSchema: [ + "keyword-unions.schema", // Requires more than 255 arguments +], +``` + +Grouping that schema's properties would let Python run it. This is the one +surviving reason to reshape a fixture, and it is a **coverage** fix, not a +performance one — independent of everything above. + +**2. `keywords.json` has drifted from its generator.** It contains three +keywords absent from `test/keywords.txt` — `clone`, `equalityContract`, +`printMembers` (C# record members, hand-inserted). `keyword-unions.schema` and +`keyword-enum.schema` both regenerate byte-identically; `keywords.json` does +not. **Running `test/make-keyword-tests.sh` today silently deletes that +coverage.** Unrelated to #559; worth its own fix. + +## Reproducing + +See `README.md` in this directory. Everything runs from a scratch `$BENCH` +directory; no repo files are modified. diff --git a/script/benchmark-class-width/bench-cpp.sh b/script/benchmark-class-width/bench-cpp.sh new file mode 100755 index 000000000..6c4470c4f --- /dev/null +++ b/script/benchmark-class-width/bench-cpp.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Tier 1, C++ -- the strongest non-Rust case for the hypothesis. +# +# nlohmann/json instantiates from_json/to_json templates per field, so a wide +# class means a lot of template instantiation concentrated in one translation +# unit. Unlike Rust there is no dependency build to exclude: json.hpp is +# header-only, so each compile is the whole cost and no shared target dir trick +# is needed. +# +# Usage: bench-cpp.sh --src-lang json $BENCH/inputs/blns-{1,2,4,8}.json +set -euo pipefail + +REPS="${REPS:-3}" +SRC_LANG=json +if [ "${1:-}" = "--src-lang" ]; then SRC_LANG="$2"; shift 2; fi + +command -v g++ >/dev/null || { echo "g++ not found"; exit 1; } +[ -f dist/index.js ] || { echo "dist/index.js missing -- run npm run build"; exit 1; } + +BENCH="${BENCH:-${CLAUDE_JOB_DIR:-/tmp}/bench}" +work="$BENCH/cpp" +mkdir -p "$work" +cp test/fixtures/cplusplus/main.cpp test/fixtures/cplusplus/Generators.hpp "$work/" + +# Same pinned revision the cplusplus fixture's setupCommand uses. +if [ ! -f "$work/json.hpp" ]; then + echo "fetching json.hpp ..." >&2 + curl -fsS -o "$work/json.hpp" \ + https://raw.githubusercontent.com/nlohmann/json/87df1d6708915ffbfa26a051ad7562ecc22e5579/src/json.hpp +fi + +printf "%-22s %8s %8s %10s\n" candidate widest lines "median_s" +printf -- "------------------------------------------------------\n" + +for input in "$@"; do + node dist/index.js --lang cplusplus --src-lang "$SRC_LANG" --top-level TopLevel \ + "$input" > "$work/quicktype.hpp" 2>/dev/null + + widest=$(awk ' + /^ *(struct|class) [A-Za-z_]/ { inb=1; n=0; next } + inb && /^ +[A-Za-z_].*;$/ { n++ } + inb && /^ *};/ { inb=0; if (n>max) max=n } + END { print max+0 }' "$work/quicktype.hpp") + lines=$(wc -l < "$work/quicktype.hpp" | tr -d ' ') + + # A failed compile is fast and would look like a great result. + if ! (cd "$work" && g++ -O0 -o quicktype -std=c++17 main.cpp 2>"$work/err"); then + echo "COMPILE FAILED for $(basename "$input") -- timings would be meaningless" >&2 + head -20 "$work/err" >&2 + exit 1 + fi + + times=() + for _ in $(seq "$REPS"); do + start=$(python3 -c 'import time; print(time.time())') + (cd "$work" && g++ -O0 -o quicktype -std=c++17 main.cpp 2>/dev/null) \ + || { echo "compile failed mid-run" >&2; exit 1; } + times+=("$(python3 -c "import time; print(f'{time.time()-$start:.2f}')")") + done + median=$(printf '%s\n' "${times[@]}" | sort -n | awk '{a[NR]=$1} END {print a[int((NR+1)/2)]}') + + printf "%-22s %8s %8s %10s\n" "$(basename "$input")" "$widest" "$lines" "$median" +done diff --git a/script/benchmark-class-width/bench-rust.sh b/script/benchmark-class-width/bench-rust.sh new file mode 100755 index 000000000..2bd375271 --- /dev/null +++ b/script/benchmark-class-width/bench-rust.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Tier 1 (canonical -- this is the case issue #559 is about): time the crate +# under test, with serde already built. +# +# The fixture harness copies test/fixtures/rust into a fresh directory per +# sample and there is no CARGO_TARGET_DIR anywhere in the repo, so every sample +# rebuilds serde from scratch. That fixed cost is large enough to swamp the +# signal we care about, so this script pins a shared target directory and does a +# throwaway warm-up build first -- every timed build then recompiles only +# quick_type_test. +# +# Requires rustup: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh +# +# Usage: bench-rust.sh --src-lang json $BENCH/inputs/blns-{1,2,4,8}.json +set -euo pipefail + +REPS="${REPS:-3}" +SRC_LANG=json +if [ "${1:-}" = "--src-lang" ]; then SRC_LANG="$2"; shift 2; fi + +command -v cargo >/dev/null || { echo "cargo not found -- install rustup"; exit 1; } +[ -f dist/index.js ] || { echo "dist/index.js missing -- run npm run build"; exit 1; } + +BENCH="${BENCH:-${CLAUDE_JOB_DIR:-/tmp}/bench}" +export CARGO_TARGET_DIR="$BENCH/target" +crate="$BENCH/crate" +mkdir -p "$crate" +cp test/fixtures/rust/Cargo.toml test/fixtures/rust/Cargo.lock test/fixtures/rust/main.rs "$crate/" + +build() { (cd "$crate" && cargo build --quiet 2>/dev/null); } + +# Warm-up: compiles serde once into the shared target dir. Timing discarded. +node dist/index.js --lang rust --src-lang "$SRC_LANG" --top-level TopLevel \ + "$1" > "$crate/module_under_test.rs" 2>/dev/null +build || { echo "warm-up build failed"; exit 1; } + +printf "%-22s %8s %8s %10s\n" candidate widest lines "median_s" +printf -- "------------------------------------------------------\n" + +for input in "$@"; do + # --top-level matches RustLanguage.topLevel; main.rs imports module_under_test::TopLevel. + node dist/index.js --lang rust --src-lang "$SRC_LANG" --top-level TopLevel \ + "$input" > "$crate/module_under_test.rs" 2>/dev/null + + widest=$(awk ' + /^pub (struct|enum) / { inb=1; n=0; next } + inb && /^ +pub / { n++ } + inb && /^}/ { inb=0; if (n>max) max=n } + END { print max+0 }' "$crate/module_under_test.rs") + lines=$(wc -l < "$crate/module_under_test.rs" | tr -d ' ') + + # A failed build is fast, and would look like a great result. Check once, + # loudly, before timing anything. + if ! (cd "$crate" && cargo build --quiet 2>"$crate/build.err"); then + echo "BUILD FAILED for $(basename "$input") -- timings would be meaningless" >&2 + head -20 "$crate/build.err" >&2 + exit 1 + fi + + times=() + for _ in $(seq "$REPS"); do + touch "$crate/module_under_test.rs" # force a rebuild of just this crate + start=$(python3 -c 'import time; print(time.time())') + build || { echo "build failed mid-run" >&2; exit 1; } + times+=("$(python3 -c "import time; print(f'{time.time()-$start:.2f}')")") + done + median=$(printf '%s\n' "${times[@]}" | sort -n | awk '{a[NR]=$1} END {print a[int((NR+1)/2)]}') + + printf "%-22s %8s %8s %10s\n" "$(basename "$input")" "$widest" "$lines" "$median" +done diff --git a/script/benchmark-class-width/bench-swift.sh b/script/benchmark-class-width/bench-swift.sh new file mode 100755 index 000000000..ba15c3817 --- /dev/null +++ b/script/benchmark-class-width/bench-swift.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Tier 1 (no install needed on macOS): time swiftc against each candidate. +# +# Swift runs keyword-unions.schema in CI today, and its type checker is +# superlinear in the size of a single declaration -- the same property that +# makes rustc slow on wide serde derives. +# +# Usage: bench-swift.sh --src-lang schema $BENCH/inputs/ku-{1,2,5,10}.schema +set -euo pipefail + +REPS="${REPS:-3}" +MODE="${MODE:--typecheck}" # -typecheck (fast) or -c (full codegen) +SRC_LANG=json +if [ "${1:-}" = "--src-lang" ]; then SRC_LANG="$2"; shift 2; fi + +command -v swiftc >/dev/null || { echo "swiftc not found"; exit 1; } +[ -f dist/index.js ] || { echo "dist/index.js missing -- run npm run build"; exit 1; } + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +printf "%-22s %8s %8s %10s\n" candidate widest lines "median_s" +printf -- "------------------------------------------------------\n" + +for input in "$@"; do + swift="$work/$(basename "${input%.*}").swift" + node dist/index.js --lang swift --src-lang "$SRC_LANG" "$input" > "$swift" 2>/dev/null + + widest=$(awk ' + /^(public )?(final )?(struct|class|enum) / { inb=1; n=0; next } + inb && /^ +(public )?(let|var) / { n++ } + inb && /^}/ { inb=0; if (n>max) max=n } + END { print max+0 }' "$swift") + lines=$(wc -l < "$swift" | tr -d ' ') + + # A failed compile is fast, and would look like a great result. + if ! swiftc $MODE -o "$work/out" "$swift" 2>"$work/err"; then + echo "COMPILE FAILED for $(basename "$input") -- timings would be meaningless" >&2 + head -20 "$work/err" >&2 + exit 1 + fi + + times=() + for _ in $(seq "$REPS"); do + start=$(python3 -c 'import time; print(time.time())') + swiftc $MODE -o "$work/out" "$swift" >/dev/null 2>&1 \ + || { echo "compile failed mid-run" >&2; exit 1; } + times+=("$(python3 -c "import time; print(f'{time.time()-$start:.2f}')")") + done + median=$(printf '%s\n' "${times[@]}" | sort -n | awk '{a[NR]=$1} END {print a[int((NR+1)/2)]}') + + printf "%-22s %8s %8s %10s\n" "$(basename "$input")" "$widest" "$lines" "$median" +done diff --git a/script/benchmark-class-width/measure.py b/script/benchmark-class-width/measure.py new file mode 100755 index 000000000..aac8c88f4 --- /dev/null +++ b/script/benchmark-class-width/measure.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Tier 0: structural metrics for generated code. No language toolchain needed. + +For each candidate input, runs quicktype and reports how many types it produced +and how wide the widest one is. Total field count is identical across +candidates, so a linear cost model predicts identical compile times; the widest +class is what drives the superlinear part. + +Usage: + measure.py --lang rust --src-lang json bench/inputs/blns-*.json + measure.py --lang python --src-lang schema bench/inputs/ku-*.schema +""" + +import argparse +import os +import re +import subprocess +import sys + +CLI = "dist/index.js" + +# (type-opening regex, member-line regex) per language. +SHAPES = { + "rust": (re.compile(r"^pub (struct|enum) (\w+)"), re.compile(r"^ +pub ")), + "swift": ( + re.compile(r"^(?:public )?(?:final )?(?:struct|class|enum) (\w+)"), + re.compile(r"^ +(?:public )?(?:let|var) "), + ), + "java": ( + re.compile(r"^(?:public )?(?:final )?class (\w+)"), + re.compile(r"^ +private "), + ), + "typescript": (re.compile(r"^export interface (\w+)"), re.compile(r"^ +\w+\??:")), + "dart": (re.compile(r"^class (\w+)"), re.compile(r"^ +(?:final )?\w+\??\s+\w+;")), +} + + +def generate(path, lang, src_lang): + proc = subprocess.run( + ["node", CLI, "--lang", lang, "--src-lang", src_lang, path], + capture_output=True, + text=True, + ) + if proc.returncode != 0: + sys.exit(f"quicktype failed on {path}:\n{proc.stderr}") + return proc.stdout + + +def widest_type(src, lang): + opener, member = SHAPES[lang] + types, widest, widest_name = 0, 0, None + inside, count, name = False, 0, None + for line in src.splitlines(): + m = opener.match(line) + if m: + inside, count, name = True, 0, m.groups()[-1] + continue + if inside: + if member.match(line): + count += 1 + elif line.startswith("}"): + inside = False + types += 1 + if count > widest: + widest, widest_name = count, name + return types, widest, widest_name + + +def widest_call(src): + """Widest call site — CPython rejects calls with more than 255 arguments.""" + best = 0 + for m in re.finditer(r"(\w+)\(((?:[^()]|\([^()]*\))*)\)", src): + n = len([a for a in m.group(2).split(",") if a.strip()]) + best = max(best, n) + return best + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--lang", default="rust") + ap.add_argument("--src-lang", default="json") + ap.add_argument("inputs", nargs="+") + args = ap.parse_args() + + if not os.path.exists(CLI): + sys.exit(f"{CLI} not found — run `npm run build` first") + + print(f"{'candidate':<24} {'lines':>7} {'types':>7} {'widest':>7} widest type") + print("-" * 68) + for path in args.inputs: + out = generate(path, args.lang, args.src_lang) + lines = len(out.splitlines()) + if args.lang == "python": + widest, types, name = widest_call(out), len(re.findall(r"^class ", out, re.M)), "(widest call site)" + elif args.lang in SHAPES: + types, widest, name = widest_type(out, args.lang) + else: + sys.exit(f"no shape rule for --lang {args.lang}") + print(f"{os.path.basename(path):<24} {lines:>7} {types:>7} {widest:>7} {name}") + + +if __name__ == "__main__": + main() diff --git a/script/benchmark-class-width/reshape-blns.py b/script/benchmark-class-width/reshape-blns.py new file mode 100755 index 000000000..282372776 --- /dev/null +++ b/script/benchmark-class-width/reshape-blns.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Regroup blns-object.json into N contiguous groups per top-level object. + +The generated code's cost is driven by the number of fields on a single class, +not by total code volume. This rewrites the fixture so the same set of keys is +spread over more, narrower classes. + +The `dontMakeAMap` sentinel is replicated into every group on purpose: an +all-string class with at least `stringMapSizeThreshold` (50) properties becomes +map-eligible in InferMaps.ts, so without a differently-typed property a group +would silently be inferred as a map instead of a class. + +Usage: + reshape-blns.py N [input.json] > output.json +""" + +import json +import sys + +SENTINEL = "dontMakeAMap" +DEFAULT_INPUT = "test/inputs/json/priority/blns-object.json" + + +def reshape(src, n): + out = {} + for name, obj in src.items(): + keys = [k for k in obj if k != SENTINEL] + for i in range(n): + chunk = keys[i * len(keys) // n : (i + 1) * len(keys) // n] + group = {SENTINEL: True} + group.update({k: obj[k] for k in chunk}) + out[f"{name}{i + 1}" if n > 1 else name] = group + return out + + +def main(): + if len(sys.argv) < 2: + sys.exit(__doc__) + n = int(sys.argv[1]) + path = sys.argv[2] if len(sys.argv) > 2 else DEFAULT_INPUT + with open(path) as f: + src = json.load(f) + # ensure_ascii matches the checked-in file's \uXXXX escaping. + print(json.dumps(reshape(src, n), indent=2, ensure_ascii=True)) + + +if __name__ == "__main__": + main() diff --git a/script/benchmark-class-width/reshape-keyword-unions.py b/script/benchmark-class-width/reshape-keyword-unions.py new file mode 100755 index 000000000..2c562db9a --- /dev/null +++ b/script/benchmark-class-width/reshape-keyword-unions.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Nest keyword-unions.schema's flat property map under N group objects. + +keyword-unions.schema is one object with 277 properties, which quicktype turns +into a single 277-field class. This nests the same keyword properties under +`group1`..`groupN` so no single class is wide, leaving each keyword's `oneOf` +body byte-identical. + +`dummy` stays at the top level so the root object is still heterogeneous. + +Usage: + reshape-keyword-unions.py N [input.schema] > output.schema +""" + +import json +import sys + +DEFAULT_INPUT = "test/inputs/schema/keyword-unions.schema" + + +def reshape(src, n): + props = dict(src["properties"]) + dummy = props.pop("dummy") + keys = list(props) + + grouped = {} + for i in range(n): + chunk = keys[i * len(keys) // n : (i + 1) * len(keys) // n] + name = f"group{i + 1}" + grouped[name] = { + "type": "object", + "title": name, + "properties": {k: props[k] for k in chunk}, + } + grouped["dummy"] = dummy + + return {"type": "object", "properties": grouped} + + +def group_of(src, n, keyword): + """Which group a given keyword lands in (1-based), or None.""" + props = dict(src["properties"]) + props.pop("dummy", None) + keys = list(props) + for i in range(n): + if keyword in keys[i * len(keys) // n : (i + 1) * len(keys) // n]: + return i + 1 + return None + + +def main(): + if len(sys.argv) < 2: + sys.exit(__doc__) + n = int(sys.argv[1]) + path = sys.argv[2] if len(sys.argv) > 2 else DEFAULT_INPUT + with open(path) as f: + src = json.load(f) + print(json.dumps(reshape(src, n), indent=4)) + + +if __name__ == "__main__": + main()