Keep the native compiler's LLVM text as the emitter's parts (#1919)

This commit is contained in:
Chris Tate
2026-10-22 20:56:27 -05:00
committed by GitHub
8 changed files with 89 additions and 56 deletions
+16 -31
View File
@@ -483,7 +483,8 @@ function versionParameters(mod: IrModule): IrModule {
return versionLoopBoundParameters(mod, mangleFunction);
}
export function emitLlvmModule(input: IrModule, options: LlvmTargetOptions = {}): string {
/** The emitter for `input` after the module passes every emission runs. */
function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter {
// Facts are keyed by node identity: emit the same module whether it was
// lowered in memory or read back from its serialized form.
const mod = unshareNodes(input);
@@ -515,7 +516,19 @@ export function emitLlvmModule(input: IrModule, options: LlvmTargetOptions = {})
)
: mod,
options,
).emit();
);
}
export function emitLlvmModule(input: IrModule, options: LlvmTargetOptions = {}): string {
return moduleEmitter(input, options).emit();
}
/** emitLlvmModule's text as parts that concatenate to it, without the copy
* that joining them makes: a large program's text is 150 MB. */
export function emitLlvmModuleParts(input: IrModule, options: LlvmTargetOptions = {}): string[] {
return moduleEmitter(input, options)
.emitParts()
.flatMap((part, index) => (index === 0 ? [part] : ["\n", part]));
}
/** Keep large translation units below the host's single-string limit. */
@@ -525,35 +538,7 @@ export function emitLlvmModuleSource(
): string | readonly string[] {
// IR validation, contracts and the frontend's disposal run after lowering without checks.
relieveHeapPressure(true);
const mod = unshareNodes(input);
const parts = new LlEmitter(
options.debugSources === undefined
? versionParameters(
addRecordResultEntries(
scalarizeNumericRecords(
specializeCallbackCalls(
specializeNumericCalls(
foldConstantGlobals(
copyStableCaptures(
resolveSelfCaptures(
hoistFinalFieldReads(
freezeInitOnlyGlobals(
holdCharLocalsAsUnits(dropUnwrittenPropertyBags(mod)),
),
),
),
),
),
mangleFunction,
),
mangleFunction,
),
),
),
)
: mod,
options,
).emitParts();
const parts = moduleEmitter(input, options).emitParts();
const length = parts.reduce((total, part) => total + part.length, Math.max(0, parts.length - 1));
if (length <= 256 * 1024 * 1024) return parts.join("\n");
return parts.flatMap((part, index) => (index === 0 ? [part] : ["\n", part]));
+8 -1
View File
@@ -14,7 +14,7 @@ import {
} from "node:fs";
import { join } from "node:path";
import { resolveBuildCacheRoot } from "../backend/cache-root.js";
import { writeTextGeneratedSync } from "./text-file.js";
import { joinedParts, writeTextGeneratedSync } from "./text-file.js";
/** UTF-8 bytes one native compiler string can hold (the runtime's
* SCR_STR_MAX_CAP). Exceeding it traps rather than throwing, so payloads
@@ -28,6 +28,13 @@ export function contentDigest(value: string | Uint8Array): string {
return hash.digest("hex");
}
/** contentDigest of the text the parts concatenate to. */
export function partsDigest(parts: readonly string[]): string {
const hash = createHash("sha256");
for (const part of joinedParts(parts)) hash.update(part);
return hash.digest("hex");
}
/** contentDigest of a file's bytes, read in 1 MiB blocks so a large file
* (a module's LLVM text) is never held whole. */
export function fileDigest(path: string): string {
+4 -4
View File
@@ -362,7 +362,7 @@ export class NativeCompiler {
mkdirSync(inputDirectory);
const llvmInput = outputKind === "llvm" ? stagedOutput : join(inputDirectory, "program.ll");
// In slices: the module text is never encoded whole.
writeTextPartsSync(llvmInput, [llvm]);
writeTextPartsSync(llvmInput, llvm);
timing("llvm-write");
if (outputKind === "llvm") {
renameSync(stagedOutput, output);
@@ -496,7 +496,7 @@ export class NativeCompiler {
renameSync(stagedOutput + ".dSYM", output + ".dSYM");
}
mkdirSync(options.outDir, { recursive: true });
writeTextPartsSync(llvmPath, [llvm]);
writeTextPartsSync(llvmPath, llvm);
let irPath: string | undefined;
if (options.emitIr) {
irPath = join(options.outDir, `${stem}.ir.json`);
@@ -585,7 +585,7 @@ export class NativeCompiler {
const llvm = prepared.llvm;
const llvmInput = join(stage, "program.ll");
// In slices: the module text is never encoded whole.
writeTextPartsSync(llvmInput, [llvm]);
writeTextPartsSync(llvmInput, llvm);
timing("llvm-write");
const features = prepared.features;
const pack = selectNativeRuntimePack(
@@ -648,7 +648,7 @@ export class NativeCompiler {
});
}
timing("native-archive");
writeTextPartsSync(llvmPath, [llvm]);
writeTextPartsSync(llvmPath, llvm);
let irPath: string | undefined;
if (options.emitIr) {
irPath = join(options.outDir, `${stem}.lib.ir.json`);
@@ -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 } from "./cache.js";
import { contentDigest, NativeCache, partsDigest } from "./cache.js";
interface InputIdentity {
path: string;
@@ -254,7 +254,7 @@ export class NativeExecutableCache {
export function openNativeExecutableCache(
cache: NativeCache | null,
toolchain: NativeToolchain,
llvm: string,
llvm: readonly string[],
options: CompileRequestOptions,
ffi: FfiProfile | null,
pack: NativeRuntimeSelection,
@@ -299,7 +299,7 @@ export function openNativeExecutableCache(
const key = contentDigest(
JSON.stringify({
schema: 1,
llvm: contentDigest(llvm),
llvm: partsDigest(llvm),
options,
toolchain,
runtimeIdentity: pack.packageText,
+11 -5
View File
@@ -11,6 +11,12 @@ import { NativeCache } from "./cache.js";
import { prepareNativeExecutable } from "./prepare.js";
import type { NativeToolchain } from "./toolchain.js";
/** A prepared input with its LLVM parts joined: a cache hit reads the text
* back as one part. */
function text(input: ReturnType<ReturnType<typeof fixture>["prepare"]>) {
return input.ok ? { ...input, llvm: input.llvm.join("") } : input;
}
const scratch: string[] = [];
afterEach(() => {
for (const path of scratch.splice(0)) rmSync(path, { recursive: true, force: true });
@@ -68,14 +74,14 @@ test("warm frontend reuse survives output creation and invalidates imported sour
if (!first.ok) return;
mkdirSync(f.options.outDir);
writeFileSync(f.options.outPath, "binary placeholder");
writeFileSync(join(f.options.outDir, "main.ll"), first.llvm);
writeFileSync(join(f.options.outDir, "main.ll"), first.llvm.join(""));
const warm = f.prepare();
expect(warm).toEqual(first);
expect(text(warm)).toEqual(text(first));
expect(f.loads()).toBe(1);
writeFileSync(f.dependency, 'export const value = "second";\n');
const edited = f.prepare();
expect(edited.ok).toBe(true);
if (edited.ok) expect(edited.llvm).not.toBe(first.llvm);
if (edited.ok) expect(edited.llvm.join("")).not.toBe(first.llvm.join(""));
expect(f.loads()).toBe(2);
});
@@ -101,13 +107,13 @@ test("serialized IR travels as bounded parts through the frontend cache", () =>
expect(mod.functions.length).toBeGreaterThan(0);
const warm = f.prepare({ emitIr: true });
expect(f.loads()).toBe(1);
expect(warm).toEqual(first);
expect(text(warm)).toEqual(text(first));
const family = join(f.cache.root, "frontend");
const key = readdirSync(family).find((name) => /^[0-9a-f]{64}$/.test(name))!;
const payload = JSON.parse(readFileSync(join(family, key), "utf8"));
expect(payload.schema).toBe("scriptc.native-frontend.v3");
expect(payload.input.llvm).toBe("");
expect(payload.input.ir).toBeNull();
expect(payload.llvm).toBe(first.llvm);
expect(payload.llvm).toBe(first.llvm.join(""));
expect(payload.ir).toEqual(first.ir);
});
+15 -8
View File
@@ -7,7 +7,11 @@ import type {
CompileLibraryOptions,
CompileRequestOptions,
} from "../compile-types.js";
import { emitLlvmModule, type ForkPrimitives, lowRegionTarget } from "../backend/llvm/emitter.js";
import {
emitLlvmModuleParts,
type ForkPrimitives,
lowRegionTarget,
} from "../backend/llvm/emitter.js";
import { inlineRcOption } from "../backend/llvm/shapes.js";
import { executableLinkFeatures } from "../backend/executable-features.js";
import type { NativeLinkFeatures } from "../backend/native-link-info-core.js";
@@ -29,13 +33,15 @@ import type { LibraryProfile } from "../library/library-profile.js";
import type { IrModule } from "../ir/ir.js";
import { deserializeModuleBytes, serializeModuleParts } from "../ir/serialize.js";
import { contentDigest, NATIVE_STRING_BYTES, type NativeCache } from "./cache.js";
import { batchTextParts, textSlices } from "./text-file.js";
import { batchTextParts, joinedParts, textSlices } from "./text-file.js";
import type { NativeToolchain } from "./toolchain.js";
import { nativeFileIdentity } from "./file-identity.js";
export interface NativeExecutableInput {
ok: true;
llvm: string;
/** The module's LLVM text as parts that concatenate to it
* (emitLlvmModuleParts), never joined: a large program's is 150 MB. */
llvm: readonly string[];
/** Compact serialized IR as bounded parts (`batchTextParts`): compiler-scale
* modules exceed one native string. */
ir: string[] | null;
@@ -71,7 +77,8 @@ function* frontendCacheParts(
input: { ...input, llvm: "", ir: null },
});
yield metadata.slice(0, metadata.length - 1) + ',"llvm":"';
for (const slice of textSlices(input.llvm)) yield JSON.stringify(slice).slice(1, -1);
for (const part of joinedParts(input.llvm))
for (const slice of textSlices(part)) yield JSON.stringify(slice).slice(1, -1);
yield '"';
if (input.ir === null) yield ',"ir":null}';
else {
@@ -122,7 +129,7 @@ function readFrontendCache(
const cached: unknown = JSON.parse(bytes.toString("utf8"));
if (!validCachedInput(cached) || !frontendInputsStillMatch(cached.probes, exclusions))
return null;
return { ...cached.input, llvm: cached.llvm, ir: cached.ir };
return { ...cached.input, llvm: [cached.llvm], ir: cached.ir };
}
function implementationIdentity(toolchain: NativeToolchain): string {
@@ -310,8 +317,8 @@ export function prepareNativeExecutable(
timing("ir-serialize");
const llvm =
outputKind === "ir"
? ""
: emitLlvmModule(prepared.mod, {
? []
: emitLlvmModuleParts(prepared.mod, {
targetTriple: toolchain.target.llvmTriple,
pointerBits: toolchain.target.pointerBits,
wasi: toolchain.target.platform === "wasi",
@@ -427,7 +434,7 @@ export function prepareNativeLibrary(
);
});
if (!prepared.ok) return prepared;
const llvm = emitLlvmModule(prepared.mod, {
const llvm = emitLlvmModuleParts(prepared.mod, {
targetTriple: toolchain.target.llvmTriple,
pointerBits: toolchain.target.pointerBits,
wasi: toolchain.target.platform === "wasi",
+11 -1
View File
@@ -2,7 +2,7 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test } from "vitest";
import { textSlices, writeTextPartsSync } from "./text-file.js";
import { joinedParts, textSlices, writeTextPartsSync } from "./text-file.js";
const directories: string[] = [];
afterEach(() => {
@@ -40,3 +40,13 @@ test("a part larger than one slice is written as its UTF-8 bytes", () => {
expect(seen.join("")).toBe("head," + large + "tail");
expect(Math.max(...seen.map((slice) => slice.length))).toBeLessThanOrEqual(1024 * 1024);
});
test("small parts join into batches and a long part passes through whole", () => {
const small = Array.from({ length: 3000 }, (_, index) => `line ${index}\n`);
const long = "x".repeat(2 * 1024 * 1024);
const parts = [...small, long, "tail"];
const joined = [...joinedParts(parts)];
expect(joined.join("")).toBe(parts.join(""));
expect(joined.length).toBeLessThan(5);
expect(joined).toContain("tail");
});
+21 -3
View File
@@ -42,6 +42,24 @@ export function* textSlices(text: string, length = TEXT_WRITE_LENGTH): Generator
}
}
/** `parts` with consecutive parts joined into strings of about
* TEXT_WRITE_LENGTH UTF-16 units (a longer part passes through whole), so
* writing or escaping many small parts takes few calls. */
export function* joinedParts(parts: readonly string[]): Generator<string> {
let batch: string[] = [];
let length = 0;
for (const part of parts) {
batch.push(part);
length += part.length;
if (length >= TEXT_WRITE_LENGTH) {
yield batch.length === 1 ? batch[0]! : batch.join("");
batch = [];
length = 0;
}
}
if (batch.length > 0) yield batch.join("");
}
/** Encodes and writes `part` to `fd` in slices (`textSlices`), so a large
* part is never encoded whole. `written` sees each slice after it is
* written. */
@@ -55,8 +73,8 @@ function writeTextPart(fd: number, part: string, written?: (slice: string) => vo
}
}
/** Write text parts to `path` in order without joining them first; pass
* batched parts (`batchTextParts`) for many small ones. */
/** Write text parts to `path` in order without joining them whole: small
* parts are written in batches (`joinedParts`). */
export function writeTextPartsSync(
path: string,
parts: readonly string[],
@@ -64,7 +82,7 @@ export function writeTextPartsSync(
): void {
const fd = openSync(path, "w");
try {
for (const part of parts) writeTextPart(fd, part, written);
for (const part of joinedParts(parts)) writeTextPart(fd, part, written);
} finally {
closeSync(fd);
}