mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-31 07:23:06 +08:00
Every release of a LifetimeRegion, and every retry at region creation, ran scr_collect_cycles(): full cycle-collector passes over the thread's heap, to a fixpoint, before checking any region, in case dead cycles of ordinary objects held counted references into it. Freeing a region ran them again whenever the owned ordinary objects left counts behind after the region released its fields. Next to a large heap of candidates (a type checker beside per-file emit regions) those passes cost far more than the regions. A retry now checks the due regions first. A region whose references are all explained frees without a pass; only when one would be kept does the heap get the full pass, after which every due region is judged as before. The first check leaves a region it cannot free as it was, so the backoff, the kept report and SCRIPTC_REGION_DEBUG see only the check after the pass. It keeps what its walk found: a region whose counted references the pass did not change is judged from that, without walking it again. A pass that freed a dead cycle referring only to an owned object leaves the count unchanged; such a region waits for its next attempt. After a region releases its instances' fields, what can still hold counts is the owned ordinary objects' cycles. Each has a member that lost a reference in those releases and is buffered as a candidate, and the release walk entered every cycle member. The cycle collector gains a pass limited to a set of candidates (scr_cyc_collect_set): it takes the set's members out of the buffers up to a given generation, reading only the buffers' pointers, so the candidates it leaves are not touched, and it walks at full depth without moving the schedule's baselines. A dead object such a pass reaches through a candidate it did not take leaves its buffer before it is freed. Freeing a region runs these passes over the walk's objects from the youngest buffer out, and the whole heap only if counts remain. SCRIPTC_REGION_STATS also prints, per thread at exit, how many full passes over the heap regions needed.
112 lines
5.0 KiB
JavaScript
112 lines
5.0 KiB
JavaScript
#!/usr/bin/env node
|
|
// Lifetime-region benchmarks: builds each program in four variants and times
|
|
// interleaved runs, reporting medians of what the programs print.
|
|
//
|
|
// node benchmarks/regions/run.mjs [--scriptc=<checkout>] [--baseline=<checkout>]
|
|
// [--runs=7] [--only=name,...] [--json=out.json]
|
|
//
|
|
// Variants: `on` (ProgramLifetime classes, no regions: LifetimeRegion is
|
|
// replaced by a class whose run calls its callback), `off` (the same without
|
|
// `implements ProgramLifetime`), `region` (LifetimeRegion from
|
|
// @scriptc/experimental), `region-plain` (the region build run without
|
|
// constructing a region), and Node.js. `on` and `off` build with
|
|
// --baseline (default: --scriptc). Every variant must print the same result.
|
|
import { spawnSync } from "node:child_process";
|
|
import { mkdirSync, readFileSync, rmSync, writeFileSync } 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, "../..") },
|
|
baseline: { type: "string" },
|
|
runs: { type: "string", default: "7" },
|
|
only: { type: "string" },
|
|
json: { type: "string" },
|
|
},
|
|
});
|
|
|
|
const CONFIGS = [
|
|
// DESIGN-style generations: per-file regions and a checker region.
|
|
{ name: "generations-edit", program: "generations.ts", args: ["200", "2000", "50", "edit"] },
|
|
{ name: "generations-all", program: "generations.ts", args: ["100", "2000", "50", "all"] },
|
|
{ name: "generations-one-shot", program: "generations.ts", args: ["800", "4000", "1", "edit"] },
|
|
// A tokenizer, parser, binder and checker rebuilt in one region per build.
|
|
{ name: "compiler-watch", program: "compiler.ts", args: ["200", "60", "10"] },
|
|
{ name: "compiler-one-shot", program: "compiler.ts", args: ["400", "60", "1"] },
|
|
// Per-file emit regions next to a large live heap of ordinary objects.
|
|
{ name: "emit-files", program: "emit.ts", args: ["400000", "200", "2000"] },
|
|
];
|
|
|
|
const work = join(tmpdir(), `scriptc-region-bench-${process.pid}`);
|
|
mkdirSync(work, { recursive: true });
|
|
const stub = `class LifetimeRegion {\n run<T>(fn: () => T): T {\n return fn();\n }\n release(): void {}\n}`;
|
|
|
|
function build(checkout, source, out) {
|
|
const r = spawnSync(process.execPath, [join(checkout, "packages/cli/dist/main.js"), "build", source, "-o", out], {
|
|
encoding: "utf8",
|
|
});
|
|
if (r.status !== 0) throw new Error(`build ${source} failed:\n${r.stderr}${r.stdout}`);
|
|
return out;
|
|
}
|
|
|
|
const binaries = new Map();
|
|
function variants(program) {
|
|
if (binaries.has(program)) return binaries.get(program);
|
|
const src = readFileSync(join(here, program), "utf8");
|
|
const on = join(here, `.${program}.on.ts`);
|
|
const off = join(here, `.${program}.off.ts`);
|
|
const withoutRegions = src.replace(/^import \{ LifetimeRegion \} from "@scriptc\/experimental";$/m, stub);
|
|
writeFileSync(on, withoutRegions);
|
|
writeFileSync(off, withoutRegions.replaceAll(" implements ProgramLifetime", ""));
|
|
const baseline = values.baseline ?? values.scriptc;
|
|
const v = {
|
|
on: [build(baseline, on, join(work, `${program}-on`))],
|
|
off: [build(baseline, off, join(work, `${program}-off`))],
|
|
region: [build(values.scriptc, join(here, program), join(work, `${program}-region`))],
|
|
};
|
|
rmSync(on);
|
|
rmSync(off);
|
|
v["region-plain"] = v.region;
|
|
v.node = [process.execPath, join(here, program)];
|
|
binaries.set(program, v);
|
|
return v;
|
|
}
|
|
|
|
const median = (xs) => [...xs].sort((a, b) => a - b)[xs.length >> 1];
|
|
const results = {};
|
|
for (const config of CONFIGS) {
|
|
if (values.only && !values.only.split(",").includes(config.name)) continue;
|
|
const v = variants(config.program);
|
|
const samples = Object.fromEntries(Object.keys(v).map((k) => [k, []]));
|
|
const outputs = new Map();
|
|
for (let run = 0; run < Number(values.runs); run++) {
|
|
const order = Object.keys(v).sort(() => Math.random() - 0.5);
|
|
for (const name of order) {
|
|
const [cmd, ...pre] = v[name];
|
|
const args = name === "region-plain" ? [...config.args, "plain"] : config.args;
|
|
const r = spawnSync(cmd, [...pre, ...args], { encoding: "utf8", cwd: here, maxBuffer: 1 << 26 });
|
|
if (r.status !== 0) throw new Error(`${config.name} ${name} failed:\n${r.stderr}`);
|
|
const lines = r.stdout.trim().split("\n");
|
|
outputs.set(name, lines[0].replace(/ gen0 .*/, ""));
|
|
const metrics = {};
|
|
for (const [, key, value] of lines.at(-1).matchAll(/([a-z0-9-]+) ([0-9.]+) (?:ms|MB)/g))
|
|
metrics[key] = Number(value);
|
|
samples[name].push(metrics);
|
|
}
|
|
}
|
|
if (new Set(outputs.values()).size !== 1) throw new Error(`${config.name}: outputs differ`);
|
|
results[config.name] = Object.fromEntries(
|
|
Object.entries(samples).map(([name, s]) => [
|
|
name,
|
|
Object.fromEntries(Object.keys(s[0]).map((k) => [k, median(s.map((m) => m[k]))])),
|
|
]),
|
|
);
|
|
console.log(config.name);
|
|
console.table(results[config.name]);
|
|
}
|
|
if (values.json) writeFileSync(values.json, JSON.stringify(results, null, 2));
|