Files
scriptc/benchmarks/regions/run.mjs
T
Chris Tate df74099468 Free released regions without a pass over the whole heap
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.
2026-10-16 07:01:01 -05:00

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));