mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-28 06:53:42 +08:00
Keep the native compiler's LLVM text as the emitter's parts
The LLVM emitter builds a module as parts and joined them into one string: on a large program such as tsc-ts the parts and the 150 MB joined text were live together at the end of emission. The native compiler only writes, escapes and hashes the text, so it now takes the emitter's parts (emitLlvmModuleParts) and never joins them. Consecutive small parts are joined into batches of about 1 MiB as they are written, escaped or hashed (joinedParts), so the many small parts cost few calls. A frontend cache hit reads the text back as one part. The three emission entry points share one module pipeline (moduleEmitter).
This commit is contained in:
@@ -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]));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user