Stop importing runtime bitcode into program builds (#1701)

This commit is contained in:
Chris Tate
2026-10-21 14:13:58 -05:00
committed by GitHub
8 changed files with 11 additions and 189 deletions
+1 -1
View File
@@ -127,7 +127,7 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
<dd>Remove symbol and debug payload when linking an executable, including cross-compiled targets. This flag preserves code optimization.</dd>
<dt><code>--optimization &lt;release|dev|speed&gt;</code></dt>
<dd>Select native optimization for executable, assembly, or object output. The default is <code>release</code> (<code>-O2</code>), which emits string retains and the retains and releases of dynamic values inline and lets LLVM inline small runtime functions that keep no internal runtime state into executable code; programs large enough to be compiled in parallel partitions skip that inlining to keep build time and size in check. <code>dev</code> uses <code>-O0</code> and emits source locations for TypeScript and JavaScript breakpoints and stack frames. LLVM builds include source variable names and native storage descriptions; see <a href="/docs/limitations#native-debugging">Native debugging</a> for the supported representations. macOS executable builds also produce an adjacent <code>.dSYM</code> bundle. <code>speed</code> is <code>-O2</code> plus optimizations that trade binary size and build time for run-time speed: it lets LLVM inline more small runtime functions into program code, emits the remaining reference counting inline, and on x86-64 starts every runtime function on a cache-line boundary. Expect larger executables and longer cold builds than <code>release</code>; how much faster a program runs depends on how much of its time is spent in the runtime. Runtime inlining needs a runtime pack built with the LLVM helper; where a runtime pack lacks it, programs link the same runtime without it and <code>speed</code> links the release runtime and keeps inline reference counting. Selecting <code>speed</code> never changes what <code>release</code> builds produce, and the two never share cached artifacts. Library profiles accept <code>"optimization": "speed"</code> for inline reference counting. In executables large enough to be divided into partitions, <code>dev</code> compiles each partition on its own, and <code>release</code> and <code>speed</code> add a step that lets partitions import small functions from each other; see <a href="/docs/how-it-works#build-parallelism">Build parallelism</a>.</dd>
<dd>Select native optimization for executable, assembly, or object output. The default is <code>release</code> (<code>-O2</code>), which emits string retains and the retains and releases of dynamic values inline. <code>dev</code> uses <code>-O0</code> and emits source locations for TypeScript and JavaScript breakpoints and stack frames. LLVM builds include source variable names and native storage descriptions; see <a href="/docs/limitations#native-debugging">Native debugging</a> for the supported representations. macOS executable builds also produce an adjacent <code>.dSYM</code> bundle. <code>speed</code> is <code>-O2</code> plus optimizations that trade binary size and build time for run-time speed: it emits the remaining reference counting inline and, on x86-64, starts every runtime function on a cache-line boundary. Expect larger executables and longer cold builds than <code>release</code>; how much faster a program runs depends on how much of its time is spent in the runtime. Where a runtime pack has no speed runtime, <code>speed</code> links the release runtime and keeps inline reference counting. Selecting <code>speed</code> never changes what <code>release</code> builds produce, and the two never share cached artifacts. Library profiles accept <code>"optimization": "speed"</code> for inline reference counting. In executables large enough to be divided into partitions, <code>dev</code> compiles each partition on its own, and <code>release</code> and <code>speed</code> add a step that lets partitions import small functions from each other; see <a href="/docs/how-it-works#build-parallelism">Build parallelism</a>.</dd>
<dt><code>--windows-subsystem &lt;console|gui&gt;</code></dt>
<dd>Select the subsystem of a Windows executable. <code>console</code> is the default; <code>gui</code> prevents Windows from opening a console window for the app. Valid only for executable builds targeting Windows.</dd>
+1 -1
View File
@@ -39,7 +39,7 @@ WebAssembly code generation differs from LLVM's default pipeline in two ways tha
`--cache-dir <dir>` names a private directory the caller owns for one helper build. Simplified partitions are stored under the hash of their exact source and objects under the hash of the exact module after importing, each with a trailing digest and published by rename, so a rebuild recompiles only the partitions an edit can affect.
Unsanitized `speed` program builds, and unsanitized `release` builds of programs small enough for one partition, pass `--import-bitcode <unit.bc>` once per runtime-pack unit the program links that ships bitcode. Small runtime functions reachable from the program's runtime calls are imported as `available_externally` so LLVM can inline them; the runtime objects remain the only definitions. The helper never runs a sanitizer pass, so with import it also drops the emitter's inert `sanitize_address` attributes, which would otherwise block that inlining. Without `--import-bitcode` the module is optimized exactly as emitted.
`--import-bitcode <unit.bc>`, given once per runtime-pack unit that ships bitcode, imports runtime functions for inlining; scriptc's program builds do not pass it. Small runtime functions reachable from the program's runtime calls are imported as `available_externally` so LLVM can inline them; the runtime objects remain the only definitions. The helper never runs a sanitizer pass, so with import it also drops the emitter's inert `sanitize_address` attributes, which would otherwise block that inlining. Without `--import-bitcode` the module is optimized exactly as emitted.
Runtime-pack builds emit each unit of the executable `speed` flavor through the helper:
@@ -168,30 +168,6 @@ test("resolves a package helper, emits atomically, and caches by all native inpu
expect(secondArtifact.dependencies).toEqual(firstArtifact.dependencies);
});
test("runtime bitcode imports reach the helper and key the native cache", async () => {
const pkg = await fakePackage();
const base = request(pkg.root, pkg.packageJson, join(pkg.root, "plain.o"));
await emitNativeArtifact(base);
const imported = {
...request(pkg.root, pkg.packageJson, join(pkg.root, "imported.o")),
importBitcode: {
paths: ["/pack/a.bc", "/pack/b.bc"],
digests: ["a".repeat(64), "b".repeat(64)],
},
};
await emitNativeArtifact(imported);
await emitNativeArtifact({
...imported,
outputPath: join(pkg.root, "changed.o"),
importBitcode: { ...imported.importBitcode, digests: ["a".repeat(64), "c".repeat(64)] },
});
await emitNativeArtifact({ ...imported, outputPath: join(pkg.root, "again.o") });
const calls = (await readFile(pkg.log, "utf8")).trim().split("\n");
expect(calls).toHaveLength(3);
expect(calls[0]).not.toContain("--import-bitcode");
expect(calls[1]).toContain("--import-bitcode /pack/a.bc --import-bitcode /pack/b.bc");
});
test("profiles reach the helper and key the native cache by their bytes", async () => {
const pkg = await fakePackage();
const base = request(pkg.root, pkg.packageJson, join(pkg.root, "plain.o"));
@@ -111,8 +111,6 @@ export interface NativeCodegenOptions {
cacheRoot?: string | null;
/** Program partitions, written to nativePartitionPaths(outputPath). */
partitions?: number;
/** Runtime bitcode whose small functions the helper imports for inlining. */
importBitcode?: { paths: readonly string[]; digests: readonly string[] };
/** Profile-guided optimization: instrumentation or an indexed profile. */
profile?: NativeProfileOptions;
}
@@ -339,11 +337,6 @@ function cacheKey(
.update("\0")
.update(options.sourcePath)
.update((options.partitions ?? 1) > 1 ? `\0partitions:${options.partitions}` : "")
.update(
options.importBitcode === undefined
? ""
: `\0import:${options.importBitcode.digests.join(",")}`,
)
.update(options.profile === undefined ? "" : `\0${nativeProfileKey(options.profile)}`)
.digest("hex");
}
@@ -482,7 +475,6 @@ export async function emitNativeArtifact(
"json",
"--source-path",
options.sourcePath,
...(options.importBitcode?.paths ?? []).flatMap((path) => ["--import-bitcode", path]),
...(partitionCache === null ? [] : ["--cache-dir", partitionCache]),
...nativeProfileHelperArgs(options.profile),
]);
@@ -3,8 +3,8 @@
* - `release` (default): -O2 program code against the release runtime flavor.
* - `dev`: -O0, source line tables, and cached object shards.
* - `speed`: -O2 plus optimizations that trade binary size and build time for
* run-time speed (a size-for-speed runtime flavor, runtime bitcode import,
* inline reference counting). Release output never depends on them.
* run-time speed (a size-for-speed runtime flavor, inline reference
* counting). Release output never depends on them.
*
* Speed is an optimized posture everywhere release is: it shares -O2, release
* linker flags, and release-only program partitioning. Cache keys spell it
@@ -9,7 +9,6 @@ import type { NativeLinkFeatures } from "./native-link-info.js";
import {
effectiveRuntimeFeatures,
evaluateRuntimePredicate,
loadRuntimeBitcode,
loadRuntimePack,
parseRuntimePackManifest,
RuntimePackError,
@@ -262,80 +261,10 @@ describe("runtime pack manifests", () => {
expect(fileHandle.archives.map((path) => path.split("/").at(-1))).toEqual(["zlib.a"]);
});
test("runtime bitcode follows the selected flavor variant and is verified", async () => {
const { root, packagePath, manifest } = await fixture();
const bitcode = async (path: string, bytes: string) => {
await writeFile(join(root, path), bytes);
return {
path,
sha256: createHash("sha256").update(bytes).digest("hex"),
size: Buffer.byteLength(bytes),
};
};
const unit = manifest.flavors.release!.runtime_units[0]!;
const withBitcode: RuntimePackManifest = {
...manifest,
flavors: {
...manifest.flavors,
speed: {
optimization: "-O2",
runtime_units: [
{
...unit,
variants: [
{ ...unit.variants[0]!, bitcode: await bitcode("artifacts/base.bc", "base-bc") },
{
...unit.variants[1]!,
bitcode: await bitcode("artifacts/legacy.bc", "legacy-bc"),
},
unit.variants[2]!,
],
},
],
},
},
};
const resolver = () => packagePath;
const speed = { target: MACOS_ARM64_TARGET, optimization: "speed" } as const;
// Release programs import their own units' bitcode. Speed programs
// never import release bitcode: without a speed flavor they import none.
await writeFile(
join(root, "runtime-pack.json"),
JSON.stringify({
...withBitcode,
flavors: { ...withBitcode.flavors, release: withBitcode.flavors.speed, speed: undefined },
}),
);
expect(await loadRuntimeBitcode({ ...speed, features: BASE, resolver })).toBeNull();
const release = await loadRuntimeBitcode({
target: MACOS_ARM64_TARGET,
optimization: "release",
features: BASE,
resolver,
});
expect(release?.paths.map((path) => basename(path))).toEqual(["base.bc"]);
await writeFile(join(root, "runtime-pack.json"), JSON.stringify(withBitcode));
const legacy = await loadRuntimeBitcode({
...speed,
features: { ...BASE, textDecoderLegacy: true },
resolver,
});
expect(legacy?.paths.map((path) => basename(path))).toEqual(["legacy.bc"]);
expect(legacy?.digests).toEqual([createHash("sha256").update("legacy-bc").digest("hex")]);
// A variant without bitcode imports nothing.
expect(
await loadRuntimeBitcode({
...speed,
features: { ...BASE, fileHandle: true },
resolver,
}),
).toBeNull();
await writeFile(join(root, "artifacts/base.bc"), "damaged");
await expect(loadRuntimeBitcode({ ...speed, features: BASE, resolver })).rejects.toThrow(
"hash mismatch",
);
const malformed = structuredClone(withBitcode);
malformed.flavors.speed!.runtime_units[0]!.variants[0]!.bitcode = {
test("runtime unit bitcode must stay inside the pack", async () => {
const { manifest } = await fixture();
const malformed = structuredClone(manifest);
malformed.flavors.release!.runtime_units[0]!.variants[0]!.bitcode = {
path: "../escape.bc",
sha256: "0".repeat(64),
size: 1,
@@ -46,8 +46,6 @@ export interface RuntimePackSelection {
sourceDependencies: NativeArtifactDependency[];
selectedRuntimeArtifacts: RuntimePackArtifact[];
selectedArchiveArtifacts: RuntimePackArtifact[];
/** Bitcode of the selected runtime units that ship it, in unit order. */
selectedRuntimeBitcode: RuntimePackArtifact[];
}
async function verifyArtifact(root: string, artifact: RuntimePackArtifact): Promise<string> {
@@ -249,49 +247,5 @@ export async function loadRuntimePack(options: {
sourceDependencies,
selectedRuntimeArtifacts: selectedVariants,
selectedArchiveArtifacts: selectedArchives,
selectedRuntimeBitcode: selectedVariants.flatMap((variant) =>
variant.bitcode === undefined ? [] : [variant.bitcode],
),
};
}
export interface RuntimeBitcodeImport {
paths: string[];
/** Content digests, in path order, for cache keys. */
digests: string[];
dependencies: NativeArtifactDependency[];
}
/** Verified bitcode for the runtime units of the given flavor that a program
* links, for the helper's available_externally import. Release units carry
* their compiled bitcode reduced to the bodies that need no unit-local
* state; speed units carry their promoted bitcode. Null when the selected
* units ship no bitcode (a pack built without the helper, or no speed
* flavor). */
export async function loadRuntimeBitcode(options: {
target: NativeTargetSpec;
features: NativeLinkFeatures;
optimization: "release" | "speed";
env?: NodeJS.ProcessEnv;
resolver?: (specifier: string) => string;
}): Promise<RuntimeBitcodeImport | null> {
const pack = await loadRuntimePack(options);
// Without a speed flavor, speed programs link release units; only the
// speed flavor's promoted objects match speed bitcode.
if (options.optimization === "speed" && pack.manifest.flavors.speed === undefined) return null;
if (pack.selectedRuntimeBitcode.length === 0) return null;
const paths = await Promise.all(
pack.selectedRuntimeBitcode.map((artifact) => verifyArtifact(pack.root, artifact)),
);
let dependencies: NativeArtifactDependency[];
try {
dependencies = await snapshotDependencies([pack.manifestPath, ...paths]);
} catch {
throw new RuntimePackError("runtime pack bitcode changed during selection", "invalid");
}
return {
paths,
digests: pack.selectedRuntimeBitcode.map((artifact) => artifact.sha256),
dependencies,
};
}
+3 -32
View File
@@ -82,7 +82,7 @@ import { privateSiblingPath } from "./backend/build-cache.js";
import { nativeCodegenTarget, nativeCodegenTargetRefusal } from "./backend/targets.js";
import { windowsSubsystemLinkerArgs, type WindowsSubsystem } from "./backend/targets.js";
import { createNativeLinkInfo } from "./backend/native-link-info.js";
import { RuntimePackError, loadRuntimeBitcode } from "./backend/runtime-pack.js";
import { RuntimePackError } from "./backend/runtime-pack.js";
import { createNativeLinkPlan } from "./backend/link-plan.js";
import {
nativeProfileKey,
@@ -634,7 +634,6 @@ async function emitNativeProgramObject(
entryPath: string,
opts: CompileRequestOptions,
llvm: LlvmInput,
features: EarlyExecutableNativeFeatures,
profile: NativeProfileOptions | undefined,
): Promise<NativeProgramObject> {
const stem = basename(entryPath).replace(/\.(ts|mts|cts|js|mjs|cjs)$/, "");
@@ -648,24 +647,6 @@ async function emitNativeProgramObject(
const llvmBytes = llvmInputBytes(llvm);
const partitions =
opts.nativeProgramObject === true || target === null ? 1 : nativeProgramPartitions(llvmBytes);
// Optimized programs import small runtime functions from the bitcode of
// the exact runtime units they link, so LLVM can inline across the
// boundary. Release units ship only the bodies that need none of their
// unit-local state, which keeps the import cheap; speed units promote
// that state and offer more. Every partition reads the bitcode again, so
// release imports only into programs small enough for one partition: in
// larger ones the build-time and size cost outgrows the gain.
const runtimeBitcode =
opts.sanitize !== true &&
target !== null &&
(opts.optimization === "speed" ||
(opts.optimization !== "dev" && nativeProgramPartitions(llvmBytes) === 1))
? await loadRuntimeBitcode({
target,
features,
optimization: opts.optimization === "speed" ? "speed" : "release",
})
: null;
try {
const artifact = await emitNativeArtifact({
outputPath: linkPath,
@@ -675,16 +656,13 @@ async function emitNativeProgramObject(
optimization: opts.optimization === "dev" ? "0" : "2",
...(opts.sanitize === undefined ? {} : { sanitize: opts.sanitize }),
partitions,
...(runtimeBitcode === null
? {}
: { importBitcode: { paths: runtimeBitcode.paths, digests: runtimeBitcode.digests } }),
...(profile === undefined ? {} : { profile }),
});
return {
linkPath,
partitionPaths: artifact.outputPaths.slice(1),
artifactPath,
dependencies: [...artifact.dependencies, ...(runtimeBitcode?.dependencies ?? [])],
dependencies: artifact.dependencies,
};
} catch (error) {
await removeNativeProgramObject(linkPath, nativePartitionPaths(linkPath, partitions));
@@ -1467,7 +1445,6 @@ async function compileTracked(
// A large program's LLVM exceeds the engine's string limit as one
// string; the emitter takes it in parts, as on a cold build.
await readTextParts(earlyHit.llvmPath),
earlyHit.native,
profile,
);
nativeInputPath = nativeProgramObject.linkPath;
@@ -1583,13 +1560,7 @@ async function compileTracked(
);
}
try {
nativeProgramObject = await emitNativeProgramObject(
entryPath,
opts,
llvmSource,
nativeFeatures,
profile,
);
nativeProgramObject = await emitNativeProgramObject(entryPath, opts, llvmSource, profile);
timing("native-object");
await recordEarlyExecutableNativeBuild(cacheRoot, executableCacheOptions).catch(
() => undefined,