perf(compiler): reduce development build fingerprint overhead

- Parallelize compiler fingerprint reads while preserving cache identity and invalidation guarantees.
- Add a reproducible development build benchmark and regression coverage for file changes.
This commit is contained in:
Chris Tate
2026-09-27 08:52:24 -05:00
parent 0af282af54
commit e41c02186e
5 changed files with 233 additions and 12 deletions
+1
View File
@@ -7,6 +7,7 @@
"scripts": {
"build": "pnpm -r --filter \"./packages/*\" run build",
"build:fresh": "rm -rf packages/compiler/dist packages/cli/dist node_modules/.cache/scriptc-tsc && pnpm build",
"bench:builds": "node scripts/bench-builds.mjs",
"test": "vitest run",
"test:ts7": "node scripts/test-ts7.mjs",
"test:fetch-conformance": "vitest run tests/harness/fetch-conformance.test.ts",
@@ -0,0 +1,99 @@
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, rename, rm, symlink, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, relative } from "node:path";
import { afterEach, expect, test } from "vitest";
import { compilerImplementationDependenciesStillMatch, compilerImplementationIdentity } from "./compiler-self-identity.js";
const scratch: string[] = [];
afterEach(async () => {
await Promise.all(scratch.splice(0).map((path) => rm(path, { recursive: true, force: true })));
});
async function fixture(): Promise<string> {
const root = await mkdtemp(join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-self-identity-"));
scratch.push(root);
return root;
}
test("parallel fingerprinting preserves the v1 byte stream and dependency order across batches", async () => {
const root = await fixture();
await mkdir(join(root, "a"));
await mkdir(join(root, "z"));
// Sorted depth-first order is deliberately DIFFERENT from sorting the
// complete paths (a.ts would precede a/index.ts). More than one batch,
// empty files, and differently sized reads exercise ordered hashing.
const files: [string, string][] = [
[join("a", "index.ts"), "export const value = 42;\n"],
["a.ts", ""],
...Array.from({ length: 40 }, (_, i): [string, string] => [
join("z", `file${String(i).padStart(2, "0")}.js`),
`// ${i}\n${"\u03bb".repeat(i * 1000)}`,
]),
];
await Promise.all([...files].reverse().map(([path, text]) => writeFile(join(root, path), text)));
const expected = createHash("sha256").update("scriptc-frontend-implementation-v1\0");
for (const [path, text] of files) expected.update(path).update("\0").update(text).update("\0");
const first = await compilerImplementationIdentity(true, root);
expect(first.digest).toBe(expected.digest("hex"));
expect(first.dependencies.map((item) => relative(root, item.path))).toEqual([
"", "a", join("a", "index.ts"), "a.ts", "z", ...files.slice(2).map(([path]) => path),
]);
expect(await compilerImplementationDependenciesStillMatch(first.dependencies)).toBe(true);
const withoutProof = await compilerImplementationIdentity(false, root);
expect(withoutProof).toEqual({ digest: first.digest, dependencies: [] });
});
test("content edits, including same-size writes, change the fingerprint and invalidate its proof", async () => {
const root = await fixture();
const source = join(root, "compiler.js");
await writeFile(source, "one");
const before = await compilerImplementationIdentity(true, root);
await writeFile(source, "two");
// Avoid depending on a filesystem's timestamp resolution for this test.
await utimes(source, new Date(1_000), new Date(1_000));
expect(await compilerImplementationDependenciesStillMatch(before.dependencies)).toBe(false);
const after = await compilerImplementationIdentity(true, root);
expect(after.digest).not.toBe(before.digest);
expect(await compilerImplementationDependenciesStillMatch(after.dependencies)).toBe(true);
});
test("added, renamed, and removed compiler files invalidate the captured directory proof", async () => {
const root = await fixture();
const directory = join(root, "nested");
await mkdir(directory);
await writeFile(join(directory, "first.js"), "unchanged");
const original = await compilerImplementationIdentity(true, root);
await writeFile(join(directory, "second.js"), "new");
await utimes(directory, new Date(1_000), new Date(1_000));
expect(await compilerImplementationDependenciesStillMatch(original.dependencies)).toBe(false);
const added = await compilerImplementationIdentity(true, root);
expect(added.digest).not.toBe(original.digest);
await rename(join(directory, "second.js"), join(directory, "renamed.js"));
expect(await compilerImplementationDependenciesStillMatch(added.dependencies)).toBe(false);
const renamed = await compilerImplementationIdentity(true, root);
expect(renamed.digest).not.toBe(added.digest);
await rm(join(directory, "renamed.js"));
expect(await compilerImplementationDependenciesStillMatch(renamed.dependencies)).toBe(false);
expect((await compilerImplementationIdentity(true, root)).digest).toBe(original.digest);
});
test.skipIf(process.platform === "win32")("symlinked dependencies remain outside the compiler package fingerprint", async () => {
const root = await fixture();
const external = await fixture();
await writeFile(join(root, "index.js"), "compiler");
await writeFile(join(external, "index.js"), "dependency");
await symlink(external, join(root, "dependency"));
await symlink(root, join(root, "cycle"));
const before = await compilerImplementationIdentity(true, root);
expect(before.dependencies.map((item) => relative(root, item.path))).toEqual(["", "index.js"]);
await writeFile(join(external, "index.js"), "changed dependency");
expect((await compilerImplementationIdentity(true, root)).digest).toBe(before.digest);
expect(await compilerImplementationDependenciesStillMatch(before.dependencies)).toBe(true);
});
test("missing compiler roots fail instead of producing a usable identity", async () => {
const root = await fixture();
await expect(compilerImplementationIdentity(true, join(root, "missing"))).rejects.toMatchObject({ code: "ENOENT" });
});
@@ -50,32 +50,49 @@ function dependency(
* metadata and loads the large compiler graph only when anything changed. */
export async function compilerImplementationIdentity(
captureDependencies = true,
implementationRoot = compilerImplementationRoot(),
): Promise<CompilerImplementationIdentity> {
const implementationRoot = compilerImplementationRoot();
const files: string[] = [];
const entries: { path: string; directory?: CompilerImplementationDependency | null }[] = [];
const dependencies: CompilerImplementationDependency[] = [];
const walk = async (directory: string): Promise<void> => {
const directoryInfo = dependency(directory, await lstat(directory));
if (captureDependencies && directoryInfo !== null) dependencies.push(directoryInfo);
const entries = await readdir(directory, { withFileTypes: true });
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
// Capture directories BEFORE enumeration: a new child arriving during
// the scan must invalidate this proof, even if readdir did not see it.
entries.push({
path: directory,
directory: captureDependencies ? dependency(directory, await lstat(directory)) : null,
});
const children = await readdir(directory, { withFileTypes: true });
children.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of children) {
const path = join(directory, entry.name);
if (entry.isDirectory()) {
await walk(path);
} else if (entry.isFile()) {
files.push(path);
const fileInfo = dependency(path, await lstat(path));
if (captureDependencies && fileInfo !== null) dependencies.push(fileInfo);
entries.push({ path });
}
}
};
await walk(implementationRoot);
// Keep the v1 digest byte-identical: dependency metadata augments replay
// validation without invalidating every existing library/executable entry.
// Read bounded batches concurrently, then hash in the original sorted DFS
// order. Serial reads spend hundreds of milliseconds in filesystem round
// trips on every application edit; reading the entire package at once
// would retain all its bytes and open an unbounded number of files.
const hash = createHash("sha256").update("scriptc-frontend-implementation-v1\0");
for (const file of files) {
hash.update(relative(implementationRoot, file)).update("\0").update(await readFile(file)).update("\0");
const batchSize = 16;
for (let start = 0; start < entries.length; start += batchSize) {
const batch = await Promise.all(entries.slice(start, start + batchSize).map(async (entry) => {
if (entry.directory !== undefined) return { ...entry, info: entry.directory, bytes: null };
const info = captureDependencies ? dependency(entry.path, await lstat(entry.path)) : null;
return { ...entry, info, bytes: await readFile(entry.path) };
}));
for (const entry of batch) {
if (entry.info !== null) dependencies.push(entry.info);
if (entry.bytes !== null) {
hash.update(relative(implementationRoot, entry.path)).update("\0").update(entry.bytes).update("\0");
}
}
}
return { digest: hash.digest("hex"), dependencies };
}
+96
View File
@@ -0,0 +1,96 @@
/* Reproducible CLI build latency: one empty-cache build, exact repeats, and
* actual edits to an imported module. Each invocation owns a fresh cache so
* an earlier benchmark cannot turn its edit cases into native object hits. */
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
const { values } = parseArgs({ options: { iterations: { type: "string", default: "5" } } });
const iterations = Number(values.iterations);
if (!Number.isInteger(iterations) || iterations < 1 || iterations > 100) {
throw new Error("--iterations must be an integer between 1 and 100");
}
if (process.env.SCRIPTC_TARGET && process.env.SCRIPTC_TARGET !== "native") {
throw new Error("bench:builds runs host executables; unset SCRIPTC_TARGET");
}
const cli = fileURLToPath(new URL("../packages/cli/dist/bootstrap.js", import.meta.url));
await access(cli).catch(() => { throw new Error("Build the workspace with pnpm build before running bench:builds"); });
const root = await mkdtemp(join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-bench-builds-"));
const cache = join(root, "cache");
const entry = join(root, "main.ts");
const binary = join(root, process.platform === "win32" ? "program.exe" : "program");
const env = { ...process.env, SCRIPTC_CACHE_DIR: cache };
delete env.SCRIPTC_NO_CACHE;
delete env.SCRIPTC_CACHE_MAX_MB;
delete env.SCRIPTC_TEST_STABLE_TOOLCHAIN;
const modules = 16;
const functions = 16;
const samples = [];
function moduleSource(module, offset) {
return Array.from({ length: functions }, (_, fn) =>
`export function value${fn}(n: number): number { return n * ${module + 1} + ${fn + offset}; }`,
).join("\n") + "\n";
}
function run(command, args) {
const result = spawnSync(command, args, { env, encoding: "utf8", maxBuffer: 16 * 1024 * 1024, timeout: 120_000 });
if (result.error) throw result.error;
assert.equal(result.signal, null, `${command} received ${result.signal}`);
return { stdout: result.stdout, stderr: result.stderr, status: result.status };
}
function build(phase) {
const start = performance.now();
const result = run(process.execPath, [cli, "build", entry, "--optimization=dev", "-o", binary]);
const ms = Math.round((performance.now() - start) * 10) / 10;
assert.equal(result.status, 0, result.stderr);
// Correctness checks are outside the timed build and run after EVERY edit,
// so a stale executable cannot masquerade as a faster rebuild.
const oracle = run(process.execPath, [entry]);
assert.equal(oracle.status, 0, oracle.stderr);
assert.deepEqual(run(binary, []), oracle);
samples.push({ phase, ms });
process.stderr.write(`${phase}: ${ms} ms\n`);
}
function median(phase) {
const sorted = samples.filter((sample) => sample.phase === phase).map((sample) => sample.ms).sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);
return Math.round((sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2) * 10) / 10;
}
try {
await mkdir(cache, { mode: 0o700 });
await Promise.all(Array.from({ length: modules }, (_, i) => writeFile(join(root, `module${i}.ts`), moduleSource(i, 0))));
await writeFile(entry, [
...Array.from({ length: modules }, (_, i) => `import * as m${i} from './module${i}.ts';`),
"let total = 0;",
...Array.from({ length: modules }, (_, i) => `total += ${Array.from({ length: functions }, (_, fn) => `m${i}.value${fn}(2)`).join(" + ")};`),
"console.log(total);",
"",
].join("\n"));
build("cold");
for (let i = 0; i < iterations; i++) build("unchanged");
for (let i = 1; i <= iterations; i++) {
await writeFile(join(root, "module0.ts"), moduleSource(0, i));
build("edit");
}
process.stdout.write(JSON.stringify({
node: process.version,
platform: process.platform,
arch: process.arch,
optimization: "dev",
modules: modules + 1,
functions: modules * functions,
iterations,
median_ms: Object.fromEntries(["cold", "unchanged", "edit"].map((phase) => [phase, median(phase)])),
samples,
}, null, 2) + "\n");
} finally {
await rm(root, { recursive: true, force: true });
}
+8
View File
@@ -69,6 +69,14 @@ Full-suite runs (`vitest run` with no filters) take an advisory machine-wide loc
## Build and oracle caches
### Development build benchmark
After rebuilding the workspace, `pnpm bench:builds` measures the built CLI on a generated 17-module, 256-function TypeScript program using `--optimization=dev`. It reports one build with an empty scriptc cache, five exact rebuilds, and five rebuilds after changing an imported module. Every run uses a fresh private cache and verifies each binary's stdout, stderr, and exit status against Node, outside the timed build. Temporary files are removed on completion. `pnpm bench:builds --iterations=3` changes the sample count; progress goes to stderr and the JSON result, including individual samples and medians, goes to stdout (use `pnpm --silent bench:builds` when capturing JSON).
This is a local latency benchmark, not a timing assertion in CI. Compare the same Node version, target, compiler installation, and machine load; the empty-cache sample does not flush OS filesystem or Node bytecode caches. Unset `SCRIPTC_TARGET` because the benchmark executes the resulting host binary. Use representative application measurements alongside this small module-graph baseline when choosing further optimizations.
### Cache layers
Test runs are dominated by clang (~275 corpus programs × two lanes at -O2/-O1+ASan). The production content-addressed build cache and the harness's oracle cache make repeat runs fast. Tests pin them under `node_modules/.cache/scriptc-tests/cas` (gitignored; override with `SCRIPTC_CACHE_DIR`) instead of using the per-user default:
- **binaries** (`bin/`, native-toolchain.ts): key = resolved clang identity/version + target/compiler environment + implicit system-header dependency bytes + linker/assembler identities + runtime fingerprint (every runtime .c/.h + the vendor pin) + the full normalized command line + the emitted C bytes (byte-stable by project invariant). A hit skips native code generation and linking; the binary still RUNS live, so no comparison or sanitizer coverage is ever skipped. Each hit is checksum-verified. The sanitized lane's flags land in naturally distinct keys. FFI archive/object inputs and ambient system libraries always relink because their named files can hide mutable transitive dependencies.