From cd606e96ff3c6a1bb116aee98ec78ded42218c1e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 26 Oct 2026 19:53:49 -0500 Subject: [PATCH] Key baseline cache entries by their program's inputs A recorded baseline entry now holds a hash of its program's inputs: the entry file and its relative imports (paths and contents) and the optimization level. --baseline-cache uses an entry only when the program still hashes the same, so a program or an import that changed since the record builds its baseline, instead of a recorded build error or artifact hash of the old sources settling the comparison as both-fail or identical. Records take schema 2; older records are ignored. --- AGENTS.md | 2 +- scripts/zero-cost-compare.mjs | 22 ++++++++++++++++++ scripts/zero-cost-gate.mjs | 34 ++++++++++++++++++++++------ tests/harness/zero-cost-gate.test.ts | 26 +++++++++++++++++++++ 4 files changed, 76 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 65c1b6e89..3e631a92f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,7 @@ A change that must not cost good code anything, such as a compatibility fix that node scripts/zero-cost-gate.mjs --baseline=/vercel/sandbox/prev --candidate=. --allow='\barguments\b' ``` -The whole gate takes 30 to 75 minutes on one 8-vCPU Sandbox, so shard it across Sandboxes. `--shard=i/n` splits the bench and corpus programs; the self-hosted compiler runs in shard 1, or alone with `--set=self`, and its candidate-source build overlaps its comparison build on hosts with 40 GB or more. Each shard writes `--json`, and `node scripts/zero-cost-merge.mjs ...` merges them into one verdict, recomputing the compile-time intervals over every shard. `--record-baseline-cache=` builds only the baseline and records each program's artifact hashes; `--baseline-cache=` then skips the baseline build of every program whose candidate artifacts hash the same, which halves the work for a change that leaves most programs identical. A record applies only to the built baseline checkout, toolchain, and paths it was made with, and is ignored otherwise. +The whole gate takes 30 to 75 minutes on one 8-vCPU Sandbox, so shard it across Sandboxes. `--shard=i/n` splits the bench and corpus programs; the self-hosted compiler runs in shard 1, or alone with `--set=self`, and its candidate-source build overlaps its comparison build on hosts with 40 GB or more. Each shard writes `--json`, and `node scripts/zero-cost-merge.mjs ...` merges them into one verdict, recomputing the compile-time intervals over every shard. `--record-baseline-cache=` builds only the baseline and records each program's artifact hashes; `--baseline-cache=` then skips the baseline build of every program whose candidate artifacts hash the same, which halves the work for a change that leaves most programs identical. A record applies only to the built baseline checkout, toolchain, and paths it was made with, and is ignored otherwise; a program whose entry, relative imports, or optimization level changed since the record builds its baseline. Test location follows scope: diff --git a/scripts/zero-cost-compare.mjs b/scripts/zero-cost-compare.mjs index a8e531aed..15d638e15 100644 --- a/scripts/zero-cost-compare.mjs +++ b/scripts/zero-cost-compare.mjs @@ -629,6 +629,28 @@ export function timingGroups(rows) { /** A content hash of one artifact file, as the baseline cache records it. */ export const artifactHash = hash; +/** The hash of what a program's build reads, as the baseline cache records + * it: `files` (the entry and its relative imports) by path relative to + * `root` and content, read through `read`, and the optimization level. */ +export function programInputsHash(root, files, optimization, read) { + const h = createHash("sha1").update(`${optimization}\0`); + for (const file of [...files].sort()) + h.update(`${file.startsWith(root + "/") ? file.slice(root.length + 1) : file}\0`) + .update(read(file)) + .update("\0"); + return h.digest("hex"); +} + +/** The baseline cache entry recorded for program `id`, or undefined when the + * cache has none or recorded it for other inputs (`programInputsHash`): a + * program or an import that changed since the record must build its + * baseline, or a recorded build error or artifact hash of the old sources + * would settle the new sources' comparison. */ +export function recordedBaseline(cache, id, inputs) { + const entry = cache?.programs[id]; + return entry !== undefined && entry.inputs === inputs ? entry : undefined; +} + /** The comparison a cached baseline entry settles without building the * baseline, or null when the baseline must be built: identical artifacts * (every recorded hash equals the candidate's) or the same build error on diff --git a/scripts/zero-cost-gate.mjs b/scripts/zero-cost-gate.mjs index 746727366..bc312f2a6 100644 --- a/scripts/zero-cost-gate.mjs +++ b/scripts/zero-cost-gate.mjs @@ -41,8 +41,10 @@ import { dynamicTierSymbols, median, nmOutput, + programInputsHash, programLines, programVerdict, + recordedBaseline, timingGroups, timingVerdict, } from "./zero-cost-compare.mjs"; @@ -117,11 +119,14 @@ and compares instruction counts per function of the executable's own symbols, excluding the allocator's prefault thread. --record-baseline-cache= builds only the baseline and records each -program's artifact hashes (or build error) in , then exits. +program's artifact hashes (or build error) in , with a hash of the +program's inputs (its entry, its relative imports and its optimization +level), then exits. --baseline-cache= reads such a record: a program whose candidate artifacts hash the same as the recorded baseline's is identical without a baseline build, and the same build error on both sides is both-fail; any -other program builds the baseline as usual. The record applies only to the +other program builds the baseline as usual, and so does a program whose +inputs changed since the record. The record applies only to the baseline revision and toolchain it was made with; otherwise it is ignored. The self program always builds both sides, since its builds are timed. @@ -473,6 +478,16 @@ function toolchainFingerprint(root) { return h.digest("hex"); } +/** The hash of `program`'s inputs, which its baseline cache entry records. */ +function inputsHash(program) { + return programInputsHash( + programsRoot, + programInputs(program.entry), + program.kind === "source" ? "source" : (program.optimization ?? "release"), + (file) => readFileSync(file), + ); +} + /** The content hashes of one build's artifacts (null for a missing one). */ function buildHashes(build) { const hashes = {}; @@ -488,7 +503,7 @@ if (recordPath === undefined && values["baseline-cache"] !== undefined) { const file = resolve(values["baseline-cache"]); const cache = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : null; const fingerprint = cache === null ? null : toolchainFingerprint(trees.baseline); - if (cache?.schema === 1 && cache.fingerprint === fingerprint) baselineCache = cache; + if (cache?.schema === 2 && cache.fingerprint === fingerprint) baselineCache = cache; else console.log( `baseline cache ${file} ${cache === null ? "is missing" : "was made with another baseline or toolchain"}; building every baseline`, @@ -527,7 +542,7 @@ const started = Date.now(); if (recordPath !== undefined) { const record = { - schema: 1, + schema: 2, date: report.date, fingerprint: toolchainFingerprint(trees.baseline), baseline: report.trees.baseline, @@ -537,14 +552,16 @@ if (recordPath !== undefined) { programs.map((program) => ({ run: async () => { const build = await buildProgram(program, "baseline"); + const inputs = inputsHash(program); record.programs[program.id] = build.ok ? { + inputs, ok: true, ms: build.ms, hashes: buildHashes(build), ...(build.exe ? { exeBytes: statSync(build.exe).size } : {}), } - : { ok: false, ms: build.ms ?? null, error: build.error }; + : { inputs, ok: false, ms: build.ms ?? null, error: build.error }; if (build.dir) rmSync(build.dir, { recursive: true, force: true }); }, })), @@ -581,7 +598,10 @@ await pool( programs.map((program) => ({ heavy: program.kind === "source", run: async () => { - const recorded = program.kind === "source" ? undefined : baselineCache?.programs[program.id]; + const recorded = + program.kind === "source" || baselineCache === null + ? undefined + : recordedBaseline(baselineCache, program.id, inputsHash(program)); let baseline; let candidate; let settled = null; @@ -593,7 +613,7 @@ await pool( else { candidate = await buildProgram(program, "candidate"); settled = cachedComparison(recorded, candidate, candidate.ok ? buildHashes(candidate) : {}); - // A cached identical baseline is byte for byte the candidate's build. + // A cached identical baseline has the candidate's artifacts. baseline = settled === null ? await buildProgram(program, "baseline") diff --git a/tests/harness/zero-cost-gate.test.ts b/tests/harness/zero-cost-gate.test.ts index 4a9372078..e99eca8d1 100644 --- a/tests/harness/zero-cost-gate.test.ts +++ b/tests/harness/zero-cost-gate.test.ts @@ -10,8 +10,10 @@ import { dynamicTierSymbols, mergeReports, nmOutput, + programInputsHash, programLines, programVerdict, + recordedBaseline, timingVerdict, } from "../../scripts/zero-cost-compare.mjs"; @@ -223,6 +225,30 @@ test("a recorded baseline settles identical programs and shared build errors", ( expect(cachedComparison(failed, { ok: true }, hashes)).toBeNull(); }); +test("a recorded baseline applies only to the program inputs it was recorded for", () => { + const files: Record = { + "/p/tests/corpus/a/main.ts": 'import { f } from "./lib.ts";', + "/p/tests/corpus/a/lib.ts": "export const f = 1;", + }; + const read = (file: string) => Buffer.from(files[file]!); + const paths = Object.keys(files); + const inputs = programInputsHash("/p", paths, "release", read); + expect(programInputsHash("/p", [...paths].reverse(), "release", read)).toBe(inputs); + expect(programInputsHash("/p", paths, "speed", read)).not.toBe(inputs); + expect(programInputsHash("/q", paths, "release", read)).not.toBe(inputs); + // An edit to an import changes the hash as an edit to the entry does. + files["/p/tests/corpus/a/lib.ts"] = "export const f = 2;"; + const edited = programInputsHash("/p", paths, "release", read); + expect(edited).not.toBe(inputs); + const entry = { inputs, ok: false, ms: 3, error: "SC1090" }; + const cache = { programs: { "corpus/a/main.ts": entry } }; + expect(recordedBaseline(cache, "corpus/a/main.ts", inputs)).toBe(entry); + // A record of the old sources must not settle the new sources as both-fail or identical. + expect(recordedBaseline(cache, "corpus/a/main.ts", edited)).toBeUndefined(); + expect(recordedBaseline(cache, "corpus/b/main.ts", inputs)).toBeUndefined(); + expect(recordedBaseline(null, "corpus/a/main.ts", inputs)).toBeUndefined(); +}); + test("merged gate shards judge compile time and comparisons over every shard", () => { const trees = { baseline: { revision: "a" }, candidate: { revision: "b" } }; const row = (id: string, ratio: number) => ({