mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-02 08:35:07 +08:00
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:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user