mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-24 06:14:01 +08:00
benchmarks/scaling: 128 probes of common operations (arrays, strings, Map/Set/records, JSON, closures, exceptions, regex, dates, promises, timers, console, fs) run at 3-4 sizes under scriptc and Node with identical-stdout checks; run.mjs flags superlinear growth and >3x Node, --strace-only reports syscalls per operation, strace-workloads.mjs straces the runtime benchmark suite. Results and the full probe table: results/h40-pathology-hunt/REPORT.md. Pathologies fixed on this branch (probe ms at 1e3/1e4/1e5/1e6, Linux): i in number[] base 43/5291/timeout -> 0.03/0.20/1.5/15 (Node 0.19/0.73/6.8/39) k in Record<string> base 4.8/436/timeout -> 0.15/1.5/20/319 (Node 0.96/8.9/100/1452) re.exec /g loop base 1.1/70/7910/t.o. -> 0.49/4.1/40/416 (Node 0.47/4.6/43/398) clearTimeout n live base 1.3/10/1014/t.o. -> 1.3/3.9/38/740 (Node 3.8/9.4/46/527) new RegExp repeat base 3.9/38/368/3811 -> 0.13/1.1/11/106 (Node 0.49/2.9/23/174) console.error 2 writes/line -> 1 write/line Suite A/B, Linux x86_64 sandbox, candidate vs perf/stack/06 (--layouts=4 --runs=16), all workloads ok: | workload | baseline ms | candidate ms | change | 95% CI | verdict | size | node ms | candidate/node | | --- | ---: | ---: | ---: | --- | --- | ---: | ---: | ---: | | json-records | 202.3 | 201.9 | -0.2% | -7.0% .. +3.8% | neutral | +0.0% | 458.29 | 0.44x | | regex-logs | 166.8 | 166.4 | -0.2% | -1.6% .. +1.1% | neutral | +0.1% | 239.97 | 0.69x | | ast-interp | 268.6 | 269.9 | +0.5% | -4.6% .. +3.8% | neutral | +0.0% | 242.7 | 1.11x | | records-sort | 648.7 | 670.4 | +3.3% | -4.7% .. +10.0% | neutral | +0.0% | 222.69 | 3.01x | | template-render | 69.6 | 70.7 | +1.7% | -0.2% .. +2.6% | neutral | +0.0% | 187.3 | 0.38x | | functional-pipeline | 221.8 | 214.9 | -3.1% | -7.3% .. +0.7% | neutral | +0.0% | 249.06 | 0.86x | | async-pipeline | 197.3 | 198.2 | +0.4% | -1.2% .. +3.0% | neutral | +0.2% | 89.25 | 2.22x | | word-graph | 259.2 | 256.0 | -1.3% | -6.3% .. +3.0% | neutral | +0.0% | 469.48 | 0.55x | | alloc-trees | 318.8 | 316.8 | -0.6% | -3.0% .. +1.2% | neutral | +0.0% | 127.68 | 2.48x | | numeric-kernels | 46.4 | 46.5 | +0.1% | -1.6% .. +3.9% | neutral | +0.0% | 133.42 | 0.35x | | validate-errors | 176.5 | 178.1 | +0.9% | -3.1% .. +4.7% | neutral | +0.0% | 702.45 | 0.25x | | csv-numbers | 336.2 | 336.8 | +0.2% | -2.0% .. +1.2% | neutral | +0.0% | 500.14 | 0.67x | | log-summary | 165.7 | 164.6 | -0.6% | -5.9% .. +1.9% | neutral | +0.0% | 290.81 | 0.57x | | inventory-report | 321.9 | 320.1 | -0.6% | -6.7% .. +9.5% | neutral | +0.0% | 431.4 | 0.74x | geomean change: +0.0% geomean candidate/node: 0.768x (lower is better; goal: well below 1) macOS lane not run (all measurement in the sandbox per task instructions).
249 lines
9.9 KiB
JavaScript
249 lines
9.9 KiB
JavaScript
#!/usr/bin/env node
|
|
// Scaling probe runner: builds each probe file with scriptc (release), runs every
|
|
// case at increasing sizes under the compiled binary and under Node, checks that
|
|
// stdout matches, and reports in-process time (stderr `T=<ms>`), the growth
|
|
// exponent between the two largest sizes, and the scriptc/Node ratio.
|
|
//
|
|
// node benchmarks/scaling/run.mjs [--scriptc=<checkout>] [--only=file:case,...]
|
|
// [--reps=3] [--timeout=20] [--json=out.json] [--strace]
|
|
import { spawnSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { parseArgs } from "node:util";
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const { values } = parseArgs({
|
|
options: {
|
|
scriptc: { type: "string", default: resolve(here, "../..") },
|
|
only: { type: "string" },
|
|
reps: { type: "string", default: "3" },
|
|
timeout: { type: "string", default: "20" },
|
|
json: { type: "string" },
|
|
strace: { type: "boolean", default: false },
|
|
"strace-only": { type: "boolean", default: false },
|
|
"no-node": { type: "boolean", default: false },
|
|
tag: { type: "string", default: "" },
|
|
},
|
|
});
|
|
|
|
const S = [1e3, 1e4, 1e5, 1e6];
|
|
const SM = [1e3, 3e3, 1e4, 3e4];
|
|
const SQ = [250, 500, 1000, 2000];
|
|
// [file, case, sizes]
|
|
const CASES = [
|
|
...[
|
|
"push", "pop", "shift", "queue", "splice-tail", "slice-small", "concat", "indexOf",
|
|
"includes-str", "sort-num", "sort-sorted", "sort-str", "sort-obj", "reverse", "in-arr",
|
|
"in-arr-str", "holes", "sparse", "spread", "map-filter-reduce", "array-from", "join",
|
|
"flat", "lastIndexOf", "findIndex", "nested2d", "length-truncate",
|
|
].map((c) => ["arrays", c, S]),
|
|
["arrays", "unshift", SM],
|
|
["arrays", "splice-mid", [1e4, 1e5, 1e6]],
|
|
...[
|
|
"concat-loop", "concat-num", "concat-read", "concat-then-scan", "template", "split-join",
|
|
"split-chars", "replace-one", "replace-all", "replace-regex", "replace-fn", "slice-loop",
|
|
"substring-loop", "indexOf-all", "indexOf-miss", "includes", "padStart", "repeat",
|
|
"upper-lower", "charAt-loop", "index-loop", "for-of-chars", "nonascii-concat",
|
|
"nonascii-index", "nonascii-slice", "str-compare", "trim", "lines-split", "num-to-string",
|
|
"parse-nums", "starts-with", "locale-compare",
|
|
].map((c) => ["strings", c, S]),
|
|
["strings", "concat-prepend", SM],
|
|
...[
|
|
"map-num", "map-str", "map-churn", "map-churn-first", "lru-touch", "set-churn",
|
|
"set-iter-delete", "set-from-array", "obj-dict", "obj-dict-delete", "obj-num-keys",
|
|
"object-keys", "spread-small", "object-assign", "from-entries", "group-by",
|
|
"class-instances", "map-obj-keys",
|
|
].map((c) => ["collections", c, S]),
|
|
["collections", "spread-accumulate", SQ],
|
|
...[
|
|
"json-wide", "json-indent", "json-nested", "json-many-small", "json-big-string", "closures",
|
|
"closure-counter", "try-no-throw", "try-throw", "throw-deep", "finally", "regex-new",
|
|
"regex-literal", "regex-exec-g", "match-all", "regex-split", "generator", "many-generators",
|
|
"destructure", "date-new", "date-now", "perf-now", "date-format", "date-parse", "date-local",
|
|
"math-random", "math-ops", "error-new",
|
|
].map((c) => ["misc", c, S]),
|
|
["misc", "json-deep", [3e3, 3e4, 3e5]],
|
|
...[
|
|
"await-chain", "async-calls", "promise-all", "then-chain", "microtasks", "next-ticks",
|
|
"console-log", "console-log-multi", "stdout-write", "console-error", "buffer-ops",
|
|
"fs-big-read", "timers-cancel",
|
|
].map((c) => ["async-io", c, S]),
|
|
["async-io", "timers-seq", [100, 1000, 10000]],
|
|
["async-io", "timers-many", S],
|
|
["async-io", "immediates", [1e3, 1e4, 1e5]],
|
|
["async-io", "fs-small", [100, 1000, 10000]],
|
|
["async-io", "fs-append", [1000, 10000, 100000]],
|
|
];
|
|
|
|
const only = values.only ? new Set(values.only.split(",")) : undefined;
|
|
const selected = CASES.filter(
|
|
([f, c]) => !only || only.has(`${f}:${c}`) || only.has(f) || only.has(c),
|
|
);
|
|
const reps = Number(values.reps);
|
|
const timeoutMs = Number(values.timeout) * 1000;
|
|
const work = join(tmpdir(), `scaling-probes${values.tag ? "-" + values.tag : ""}`);
|
|
mkdirSync(work, { recursive: true });
|
|
const cli = join(values.scriptc, "packages/cli/dist/bootstrap.js");
|
|
|
|
const binaries = new Map();
|
|
for (const file of new Set(selected.map(([f]) => f))) {
|
|
const bin = join(work, file);
|
|
const t = Date.now();
|
|
const r = spawnSync(
|
|
process.execPath,
|
|
[cli, "build", join(here, `${file}.ts`), "--optimization=release", "-o", bin],
|
|
{ encoding: "utf8" },
|
|
);
|
|
if (r.status !== 0) {
|
|
console.error(`build ${file} failed:\n${r.stdout}\n${r.stderr}`);
|
|
continue;
|
|
}
|
|
console.error(`built ${file} in ${Date.now() - t} ms`);
|
|
binaries.set(file, bin);
|
|
}
|
|
|
|
function runOnce(cmd, args) {
|
|
const t = process.hrtime.bigint();
|
|
const r = spawnSync(cmd, args, {
|
|
encoding: "utf8",
|
|
timeout: timeoutMs,
|
|
maxBuffer: 1 << 30,
|
|
env: { ...process.env, NODE_NO_WARNINGS: "1" },
|
|
});
|
|
const wall = Number(process.hrtime.bigint() - t) / 1e6;
|
|
if (r.error || r.status !== 0) {
|
|
return { error: r.error ? String(r.error.code ?? r.error) : `exit ${r.status}: ${r.stderr.slice(-300)}` };
|
|
}
|
|
const m = /T=([0-9.]+)\s*$/.exec(r.stderr);
|
|
return {
|
|
ms: m ? Number(m[1]) : NaN,
|
|
wall,
|
|
out: createHash("sha1").update(r.stdout).digest("hex"),
|
|
tail: r.stdout.slice(-120),
|
|
};
|
|
}
|
|
function measure(cmd, args) {
|
|
let best;
|
|
for (let i = 0; i < reps; i++) {
|
|
const r = runOnce(cmd, args);
|
|
if (r.error) return r;
|
|
if (!best || r.ms < best.ms) best = r;
|
|
if (r.ms > 3000) break;
|
|
}
|
|
return best;
|
|
}
|
|
function straceCount(bin, args) {
|
|
const out = join(work, "strace.txt");
|
|
const r = spawnSync("strace", ["-f", "-c", "-o", out, bin, ...args], {
|
|
encoding: "utf8",
|
|
timeout: timeoutMs * 3,
|
|
maxBuffer: 1 << 30,
|
|
});
|
|
if (r.status !== 0 || !existsSync(out)) return undefined;
|
|
const calls = {};
|
|
let total = 0;
|
|
for (const line of readFileSync(out, "utf8").split("\n")) {
|
|
const m = /^\s*[0-9.]+\s+[0-9.]+\s+\d+\s+(\d+)\s+(?:\d+\s+)?(\w+)\s*$/.exec(line);
|
|
if (m && m[2] !== "total") {
|
|
calls[m[2]] = Number(m[1]);
|
|
total += Number(m[1]);
|
|
}
|
|
}
|
|
return { total, calls };
|
|
}
|
|
|
|
if (values["strace-only"]) {
|
|
// Syscalls per operation: diff the strace -c counts of the two smallest
|
|
// sizes so process startup cancels out.
|
|
const out = [];
|
|
for (const [file, name, sizes] of selected) {
|
|
const bin = binaries.get(file);
|
|
if (!bin) continue;
|
|
const [n0, n1] = sizes;
|
|
const a = straceCount(bin, [name, String(n0)]);
|
|
const b = straceCount(bin, [name, String(n1)]);
|
|
if (!a || !b) {
|
|
console.log(`${file}:${name} strace failed`);
|
|
continue;
|
|
}
|
|
const per = {};
|
|
for (const [k, v] of Object.entries(b.calls)) {
|
|
const d = (v - (a.calls[k] ?? 0)) / (n1 - n0);
|
|
if (d > 0.001) per[k] = d;
|
|
}
|
|
const top = Object.entries(per)
|
|
.sort((x, y) => y[1] - x[1])
|
|
.slice(0, 6)
|
|
.map(([k, v]) => `${k}=${v < 0.1 ? v.toFixed(4) : v.toFixed(2)}`)
|
|
.join(" ");
|
|
const perOp = (b.total - a.total) / (n1 - n0);
|
|
console.log(`${file}:${name} n=${n0}->${n1} startup=${a.total} total=${b.total} per-op=${perOp.toFixed(4)} ${top}`);
|
|
out.push({ file, case: name, n0, n1, a, b, per, perOp });
|
|
if (values.json) writeFileSync(values.json, JSON.stringify(out, null, 1));
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
const results = [];
|
|
for (const [file, name, sizes] of selected) {
|
|
const bin = binaries.get(file);
|
|
if (!bin) continue;
|
|
const rows = [];
|
|
let scDead = false;
|
|
let nodeDead = values["no-node"];
|
|
for (const n of sizes) {
|
|
const args = [name, String(n)];
|
|
const sc = scDead ? { error: "skipped" } : measure(bin, args);
|
|
const nd = nodeDead
|
|
? { error: "skipped" }
|
|
: measure(process.execPath, ["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", join(here, `${file}.ts`), ...args]);
|
|
if (sc.error) scDead = true;
|
|
if (nd.error) nodeDead = true;
|
|
const row = { n, sc_ms: sc.ms, sc_wall: sc.wall, node_ms: nd.ms, node_wall: nd.wall };
|
|
if (sc.error) row.sc_error = sc.error;
|
|
if (nd.error) row.node_error = nd.error;
|
|
if (sc.out && nd.out && sc.out !== nd.out) row.mismatch = { sc: sc.tail, node: nd.tail };
|
|
rows.push(row);
|
|
if (scDead && nodeDead) break;
|
|
}
|
|
const ok = rows.filter((r) => Number.isFinite(r.sc_ms));
|
|
const exp = (key) => {
|
|
const v = rows.filter((r) => Number.isFinite(r[key]));
|
|
if (v.length < 2) return undefined;
|
|
const a = v[v.length - 2];
|
|
const b = v[v.length - 1];
|
|
return Math.log(Math.max(b[key], 0.05) / Math.max(a[key], 0.05)) / Math.log(b.n / a.n);
|
|
};
|
|
const last = ok[ok.length - 1];
|
|
const entry = {
|
|
file,
|
|
case: name,
|
|
rows,
|
|
sc_exp: exp("sc_ms"),
|
|
node_exp: exp("node_ms"),
|
|
ratio: last && Number.isFinite(last.node_ms) ? last.sc_ms / Math.max(last.node_ms, 0.01) : undefined,
|
|
};
|
|
if (values.strace && last) entry.strace = { n: last.n, ...straceCount(bin, [name, String(last.n)]) };
|
|
const flags = [];
|
|
if (rows.some((r) => r.sc_error && !r.node_error)) flags.push("SC-FAIL");
|
|
if (rows.some((r) => r.mismatch)) flags.push("MISMATCH");
|
|
if (entry.sc_exp !== undefined && entry.sc_exp > 1.3 && last.sc_ms > 5 && entry.sc_exp > (entry.node_exp ?? 1) + 0.25)
|
|
flags.push("SUPERLINEAR");
|
|
if (entry.ratio !== undefined && entry.ratio > 3 && last.sc_ms > 2) flags.push(">3x");
|
|
entry.flags = flags;
|
|
results.push(entry);
|
|
const fmt = (v) => (v === undefined || !Number.isFinite(v) ? "-" : v < 10 ? v.toFixed(2) : v.toFixed(0));
|
|
const cells = rows
|
|
.map((r) => `${r.n}:${r.sc_error ? "ERR" : fmt(r.sc_ms)}/${r.node_error ? "ERR" : fmt(r.node_ms)}`)
|
|
.join(" ");
|
|
console.log(
|
|
`${file}:${name} ${cells} exp=${fmt(entry.sc_exp)}/${fmt(entry.node_exp)} ratio=${fmt(entry.ratio)} ${flags.join(",")}` +
|
|
(entry.strace ? ` sys=${entry.strace.total}` : ""),
|
|
);
|
|
if (values.json) writeFileSync(values.json, JSON.stringify(results, null, 1));
|
|
}
|
|
if (values.json) writeFileSync(values.json, JSON.stringify(results, null, 1));
|