Reuse linked executables on Linux (#2077)

This commit is contained in:
Chris Tate
2026-10-24 00:50:58 -05:00
committed by GitHub
5 changed files with 278 additions and 32 deletions
+32 -2
View File
@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import {
closeSync,
existsSync,
mkdirSync,
mkdtempSync,
openSync,
@@ -88,14 +89,19 @@ export class NativeCache {
}
}
write(family: string, key: string, bytes: string | Uint8Array): void {
write(
family: string,
key: string,
bytes: string | Uint8Array,
digest = contentDigest(bytes),
): void {
let stage: string | null = null;
try {
const directory = join(this.root, family);
mkdirSync(directory, { recursive: true, mode: 0o700 });
stage = mkdtempSync(join(directory, ".write-"));
writeFileSync(join(stage, "payload"), bytes);
writeFileSync(join(stage, "digest"), contentDigest(bytes) + "\n");
writeFileSync(join(stage, "digest"), digest + "\n");
const path = join(directory, key);
// Concurrent writers publish identical content under the same key;
// a reader between the two renames simply treats it as a miss.
@@ -112,6 +118,29 @@ export class NativeCache {
}
}
/** Whether a marker or entry exists, without reading or checking it. */
has(family: string, key: string): boolean {
return existsSync(join(this.root, family, key));
}
/** Records an empty marker that has() finds. */
mark(family: string, key: string): void {
try {
const directory = join(this.root, family);
mkdirSync(directory, { recursive: true, mode: 0o700 });
writeFileSync(join(directory, key), "");
} catch {
/* Cache failures never change a build's result. */
}
}
/** Writes `bytes` under their content digest, and returns the digest. */
writeContent(family: string, bytes: Uint8Array): string {
const digest = contentDigest(bytes);
this.write(family, digest, bytes, digest);
return digest;
}
/** Publish text parts without joining them into one string. */
writeParts(family: string, key: string, parts: Generator<string>): void {
let stage: string | null = null;
@@ -149,6 +178,7 @@ export class NativeCache {
"object",
"sanitizer",
"executable",
"linked",
"binary",
"dsym",
"coverage",
+26 -4
View File
@@ -56,7 +56,7 @@ import { clearFenceEvalCaches } from "../library/fence-eval.js";
import { archiveNativeLibrary, localizeNativeLibrary, linkNativeWasmLibrary } from "./library.js";
import type { NativeToolchain } from "./toolchain.js";
import { evaluateNativeComptime } from "./comptime.js";
import { contentDigest, fileDigest, type NativeCache } from "./cache.js";
import { contentDigest, fileDigest, partsDigest, type NativeCache } from "./cache.js";
import {
nativeProfileKey,
nativeProfileRefusal,
@@ -168,6 +168,7 @@ export class NativeCompiler {
outputKind: "obj" | "asm",
partitions = 1,
profile?: NativeProfileOptions,
llvmDigest: () => string = () => fileDigest(input),
): string[] {
const toolchain = this.toolchain;
const outputs = nativePartitionPaths(output, partitions);
@@ -186,7 +187,7 @@ export class NativeCompiler {
key = contentDigest(
JSON.stringify({
schema: 1,
llvm: fileDigest(input),
llvm: llvmDigest(),
outputKind,
optimization,
source,
@@ -253,6 +254,7 @@ export class NativeCompiler {
stage: string,
library: boolean,
profile?: NativeProfileOptions,
llvmDigest?: () => string,
): string[] {
// Library archives merge dev shards relocatably; executables link the
// helper's program partitions directly.
@@ -263,7 +265,16 @@ export class NativeCompiler {
const split = llvm === null ? null : splitLlvmLibraryProgram(llvm);
if (split === null) {
const partitions = library ? 1 : nativeProgramPartitions(statSync(input).size);
return this.emitObject(input, output, source, optimization, "obj", partitions, profile);
return this.emitObject(
input,
output,
source,
optimization,
"obj",
partitions,
profile,
llvmDigest,
);
}
const objects: string[] = [];
for (const [index, shard] of split.shards.entries()) {
@@ -358,6 +369,9 @@ export class NativeCompiler {
return { ok: true, artifact: { kind: "ir", path: output } };
}
const { llvm, features } = prepared;
// The executable and object caches key the same LLVM text.
let llvmDigest: string | undefined;
const digestLlvm = (): string => (llvmDigest ??= partsDigest(llvm));
const inputDirectory = join(stage, "input");
mkdirSync(inputDirectory);
const llvmInput = outputKind === "llvm" ? stagedOutput : join(inputDirectory, "program.ll");
@@ -385,7 +399,14 @@ export class NativeCompiler {
const executableCache =
executablePack === null
? null
: openNativeExecutableCache(this.cache, toolchain, llvm, options, ffi, executablePack);
: openNativeExecutableCache(
this.cache,
toolchain,
digestLlvm,
options,
ffi,
executablePack,
);
const restored = executableCache?.restore(stagedOutput) ?? false;
timing(restored ? "executable-cache-hit" : "executable-cache-miss");
if (!restored) {
@@ -408,6 +429,7 @@ export class NativeCompiler {
stage,
false,
profile,
digestLlvm,
);
else
this.emitObject(
@@ -0,0 +1,86 @@
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, expect, test } from "vitest";
import type { NativeRuntimeSelection } from "../backend/runtime-pack-native.js";
import { NativeCache } from "./cache.js";
import { openNativeExecutableCache } from "./executable-cache.js";
import type { NativeToolchain } from "./toolchain.js";
const directories: string[] = [];
const saved = new Map<string, string | undefined>();
beforeEach(() => {
for (const name of ["SCRIPTC_LINKER", "SCRIPTC_LLVM_HELPER", "CC", "LDFLAGS", "LIBRARY_PATH"]) {
saved.set(name, process.env[name]);
delete process.env[name];
}
});
afterEach(() => {
for (const [name, value] of saved) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
saved.clear();
for (const path of directories.splice(0)) rmSync(path, { recursive: true, force: true });
});
/** A toolchain whose linker is a driver script named `driver`; it answers
* `-print-prog-name=clang` with its own path. */
function fixture(platform: string, driver: string) {
const root = mkdtempSync(join(tmpdir(), "scriptc-executable-cache-open-"));
directories.push(root);
const bin = join(root, "bin");
const helper = join(root, "helper");
const runtime = join(root, "runtime");
for (const directory of [bin, helper, runtime]) mkdirSync(directory);
const linker = join(bin, driver);
writeFileSync(linker, '#!/bin/sh\necho "$0"\n');
chmodSync(linker, 0o755);
writeFileSync(join(helper, "codegen"), "helper");
writeFileSync(join(helper, "package.json"), "{}");
writeFileSync(join(runtime, "package.json"), "{}");
writeFileSync(join(runtime, "runtime-pack.json"), "{}");
const toolchain = {
compilerVersion: "test",
target: { platform },
helperExecutable: join(helper, "codegen"),
helperPackageRoot: helper,
runtimePackRoot: runtime,
linker,
linkerArgs: [],
dsymutil: "dsymutil",
} as unknown as NativeToolchain;
const pack = {
root: runtime,
manifest: { licenses: [] },
packageText: "{}",
manifestText: "{}",
selected: { runtime: [], archives: [] },
flavor: "release",
} as unknown as NativeRuntimeSelection;
const open = () =>
openNativeExecutableCache(
new NativeCache(join(root, "cache")),
toolchain,
() => "llvm digest",
{ outPath: join(root, "program"), outDir: root, optimization: "release" },
null,
pack,
);
return { open };
}
test.skipIf(process.platform === "win32")(
"Linux builds that link with a clang driver use the executable cache",
() => {
expect(fixture("linux", "clang").open()).not.toBeNull();
expect(fixture("linux", "clang-22").open()).not.toBeNull();
expect(fixture("linux", "gcc").open()).toBeNull();
expect(fixture("windows", "clang").open()).toBeNull();
expect(fixture("wasi", "clang").open()).toBeNull();
// On darwin only Apple's driver at /usr/bin/clang qualifies.
expect(fixture("darwin", "clang").open()).toBeNull();
process.env["SCRIPTC_LLVM_HELPER"] = "/elsewhere/codegen";
expect(fixture("linux", "clang").open()).toBeNull();
},
);
@@ -222,3 +222,61 @@ test("a failed or empty link trace cannot publish a completed executable", () =>
});
expect(entry.trace([], f.stage)).toBe(false);
});
test.each(["ld", "crt", "libc"] as const)(
"a Linux key's second link records the linker, startup objects and libc; replacing the %s misses",
(input) => {
const f = fixture();
const lib = join(f.root, "lib");
mkdirSync(lib);
const paths = {
ld: join(f.root, "ld"),
crt: join(lib, "Scrt1.o"),
libc: join(lib, "libc.so.6"),
};
writeFileSync(paths.ld, "linker");
writeFileSync(paths.crt, "startup object");
writeFileSync(paths.libc, "c library");
const nonshared = join(lib, "libc_nonshared.a");
writeFileSync(nonshared, "archive");
const program = join(f.stage, "program.o");
writeFileSync(program, "program");
// With -v, clang prints the linker job it runs, as -### would; GNU ld's
// -t names each file it loads, and an archive member as archive(member).
const job = ` "${paths.ld}" "-pie" "-o" "${f.output}" "${paths.crt}" "-L${lib}" "${program}" "-lc"`;
const loaded = [paths.crt, program, paths.libc, `${nonshared}(atexit.oS)`].join("\n");
vi.mocked(spawnSync).mockImplementation((_command, args) => {
const verbose = (args as string[]).includes("-v");
return {
pid: 1,
status: 0,
signal: null,
output: [],
stdout: verbose ? loaded + "\n" : "",
stderr: verbose ? job + "\n" : "",
};
});
const open = () => new NativeExecutableCache(f.cache, f.key, "clang", false, [f.runtime], true);
// A key's first link is not traced; the caller links the output itself.
expect(open().trace(["-o", f.output], f.stage)).toBe(false);
expect(spawnSync).not.toHaveBeenCalled();
expect(open().restore(f.destination)).toBe(false);
// Its next link traces both the driver and the linker in one run.
const entry = open();
expect(entry.trace(["-o", f.output], f.stage)).toBe(true);
expect(spawnSync).toHaveBeenCalledTimes(1);
expect(vi.mocked(spawnSync).mock.calls[0]![1]).toEqual(["-o", f.output, "-v", "-Wl,-t"]);
entry.publish(f.output);
const recorded = (
JSON.parse(f.cache.read("executable", f.key)!.toString()) as { inputs: { path: string }[] }
).inputs.map((saved) => saved.path);
expect(recorded).toEqual(expect.arrayContaining([...Object.values(paths), nonshared, lib]));
expect(recorded).not.toContain(program);
expect(open().restore(f.destination)).toBe(true);
const before = statSync(paths[input]);
writeFileSync(paths[input] + ".new", "x".repeat(before.size));
utimesSync(paths[input] + ".new", before.atime, before.mtime);
renameSync(paths[input] + ".new", paths[input]);
expect(open().restore(f.destination)).toBe(false);
},
);
@@ -11,7 +11,7 @@ import {
import { runNativeTool } from "../backend/native-tools.js";
import type { NativeRuntimeSelection } from "../backend/runtime-pack-native.js";
import type { NativeToolchain } from "./toolchain.js";
import { contentDigest, NativeCache, partsDigest } from "./cache.js";
import { contentDigest, NativeCache } from "./cache.js";
interface InputIdentity {
path: string;
@@ -48,9 +48,22 @@ function identity(path: string): InputIdentity {
};
}
function sameIdentity(a: InputIdentity, b: InputIdentity): boolean {
return (
a.path === b.path &&
a.canonical === b.canonical &&
a.kind === b.kind &&
a.dev === b.dev &&
a.ino === b.ino &&
a.size === b.size &&
a.mtime === b.mtime &&
a.ctime === b.ctime
);
}
function stillMatches(inputs: InputIdentity[]): boolean {
try {
return inputs.every((input) => JSON.stringify(identity(input.path)) === JSON.stringify(input));
return inputs.every((input) => sameIdentity(identity(input.path), input));
} catch {
return false;
}
@@ -165,6 +178,8 @@ function stageSymbols(bytes: Buffer, binary: string): void {
export class NativeExecutableCache {
private inputs: InputIdentity[];
private traced = false;
/** Linux: no entry exists and the key was never linked before. */
private readonly firstLink: boolean;
constructor(
private readonly cache: NativeCache,
@@ -172,23 +187,28 @@ export class NativeExecutableCache {
private readonly linker: string,
private readonly debug: boolean,
paths: string[],
/** Linux: trace with one verbose link, from a key's second link on. */
private readonly linux = false,
) {
this.inputs = [...new Set(paths)].sort().map(identity);
this.firstLink = linux && !cache.has("executable", key) && !cache.has("linked", key);
// A first link neither restores nor traces, so it needs no identities.
this.inputs = this.firstLink ? [] : [...new Set(paths)].sort().map(identity);
}
restore(output: string): boolean {
if (this.firstLink) return false;
try {
const metadata = this.cache.read("executable", this.key);
if (metadata === null) return false;
const entry: unknown = JSON.parse(metadata.toString("utf8"));
if (!validEntry(entry) || !stillMatches(entry.inputs) || !stillMatches(this.inputs))
return false;
const saved = new Map(entry.inputs.map((input) => [input.path, input]));
if (
!this.inputs.every((input) =>
entry.inputs.some(
(saved) => saved.path === input.path && JSON.stringify(saved) === JSON.stringify(input),
),
)
!this.inputs.every((input) => {
const recorded = saved.get(input.path);
return recorded !== undefined && sameIdentity(recorded, input);
})
)
return false;
if (this.debug !== (entry.symbols !== null)) return false;
@@ -209,14 +229,24 @@ export class NativeExecutableCache {
* Directories from the driver's search line detect a newly added candidate.
* Private build inputs already belong to the LLVM/runtime cache identity.
* A true result means the requested output was linked successfully and its
* inputs remained stable. The caller can publish that output directly. */
* inputs remained stable. The caller can publish that output directly. On
* false, the caller links the output itself. */
trace(args: string[], stage: string): boolean {
this.traced = false;
try {
// On Linux a key's first link only records that it was linked; a later
// link of the same key traces, so a build that runs once traces nothing.
if (this.firstLink) {
this.cache.mark("linked", this.key);
return false;
}
const roots = [resolve(stage), realpathSync(stage)];
const dry = toolOutput(this.linker, [...args, "-###"]);
const driverInputs = tracePaths(dry, true, roots).map(identity);
const linked = toolOutput(this.linker, [...args, "-Wl,-t"]);
let driverInputs = this.linux
? []
: tracePaths(toolOutput(this.linker, [...args, "-###"]), true, roots).map(identity);
// On Linux, -v prints the jobs that -### would, while the link runs.
const linked = toolOutput(this.linker, [...args, ...(this.linux ? ["-v"] : []), "-Wl,-t"]);
if (this.linux) driverInputs = tracePaths(linked, true, roots).map(identity);
const paths = tracePaths(linked, false, roots);
if (paths.length === 0 || !stillMatches(this.inputs) || !stillMatches(driverInputs))
return false;
@@ -233,10 +263,8 @@ export class NativeExecutableCache {
if (!this.traced || !stillMatches(this.inputs)) return;
const binary = readFileSync(output);
const symbols = this.debug ? readSymbols(output) : null;
const binaryKey = contentDigest(binary);
const symbolsKey = symbols === null ? null : contentDigest(symbols);
this.cache.write("binary", binaryKey, binary);
if (symbols !== null && symbolsKey !== null) this.cache.write("dsym", symbolsKey, symbols);
const binaryKey = this.cache.writeContent("binary", binary);
const symbolsKey = symbols === null ? null : this.cache.writeContent("dsym", symbols);
if (!stillMatches(this.inputs)) return;
const entry: ExecutableEntry = {
schema: "scriptc.native-executable.v1",
@@ -254,16 +282,18 @@ export class NativeExecutableCache {
export function openNativeExecutableCache(
cache: NativeCache | null,
toolchain: NativeToolchain,
llvm: readonly string[],
llvmDigest: () => string,
options: CompileRequestOptions,
ffi: FfiProfile | null,
pack: NativeRuntimeSelection,
): NativeExecutableCache | null {
// Match the established complete-cache contract: default Apple driver,
// known runtime inputs, and no wrapper or caller-supplied library searches.
// Match the established complete-cache contract: the default Apple driver
// or a Linux clang driver, known runtime inputs, and no wrapper or
// caller-supplied library searches.
const platform = toolchain.target.platform;
if (
cache === null ||
toolchain.target.platform !== "darwin" ||
(platform !== "darwin" && platform !== "linux") ||
options.sanitize ||
// The key names a profile by path, not by its bytes.
options.profileGenerate !== undefined ||
@@ -277,9 +307,23 @@ export function openNativeExecutableCache(
return null;
try {
const linker = commandPath(toolchain.linker);
if (realpathSync(linker) !== "/usr/bin/clang") return null;
const effective = commandPath(runNativeTool(linker, ["-print-prog-name=clang"]).trim());
const debug = options.optimization === "dev" && !options.strip;
// Apple's driver at /usr/bin/clang, or a clang driver on Linux. Both
// name every input of a link in their -### and -Wl,-t traces.
if (
platform === "darwin"
? realpathSync(linker) !== "/usr/bin/clang"
: !/^clang(-\d+)?$/.test(basename(realpathSync(linker)))
)
return null;
// Apple's /usr/bin/clang is a shim for the active developer directory's
// driver; a Linux clang driver is itself the driver.
const effective =
platform === "darwin"
? commandPath(runNativeTool(linker, ["-print-prog-name=clang"]).trim())
: linker;
// Linux keeps debug information in the executable; darwin keeps it in a
// separate dSYM, which the cache stores beside the executable.
const debug = platform === "darwin" && options.optimization === "dev" && !options.strip;
const paths = [
linker,
effective,
@@ -291,6 +335,7 @@ export function openNativeExecutableCache(
(artifact) => join(pack.root, artifact.path),
),
];
if (platform === "linux") paths.push(join(pack.root, "runtime-pack.json"));
if (debug) {
const dsymutil = commandPath(toolchain.dsymutil);
if (realpathSync(dsymutil) !== "/usr/bin/dsymutil") return null;
@@ -299,11 +344,16 @@ export function openNativeExecutableCache(
const key = contentDigest(
JSON.stringify({
schema: 1,
llvm: partsDigest(llvm),
llvm: llvmDigest(),
options,
toolchain,
runtimeIdentity: pack.packageText,
runtimeManifest: pack.manifestText,
// On Linux the manifest's identity names it: restore checks that
// identity again, as it does every other input.
runtimeManifest:
platform === "darwin"
? pack.manifestText
: identity(join(pack.root, "runtime-pack.json")),
linker: identity(linker),
effective: identity(effective),
environment: toolchainEnvironmentFingerprint(),
@@ -311,7 +361,7 @@ export function openNativeExecutableCache(
path: process.env["PATH"] ?? null,
}),
);
return new NativeExecutableCache(cache, key, linker, debug, paths);
return new NativeExecutableCache(cache, key, linker, debug, paths, platform === "linux");
} catch {
return null;
}