diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 5eecda3ec..3f3ecbe3e 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -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])); diff --git a/packages/compiler/src/native/cache.ts b/packages/compiler/src/native/cache.ts index 566df308a..3c9bdf411 100644 --- a/packages/compiler/src/native/cache.ts +++ b/packages/compiler/src/native/cache.ts @@ -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 { diff --git a/packages/compiler/src/native/compiler.ts b/packages/compiler/src/native/compiler.ts index 2c850cb14..a37d76f80 100644 --- a/packages/compiler/src/native/compiler.ts +++ b/packages/compiler/src/native/compiler.ts @@ -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`); diff --git a/packages/compiler/src/native/executable-cache.ts b/packages/compiler/src/native/executable-cache.ts index a122fb759..c0ac755b9 100644 --- a/packages/compiler/src/native/executable-cache.ts +++ b/packages/compiler/src/native/executable-cache.ts @@ -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, diff --git a/packages/compiler/src/native/prepare.test.ts b/packages/compiler/src/native/prepare.test.ts index 7e22450da..95274f1cd 100644 --- a/packages/compiler/src/native/prepare.test.ts +++ b/packages/compiler/src/native/prepare.test.ts @@ -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["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); }); diff --git a/packages/compiler/src/native/prepare.ts b/packages/compiler/src/native/prepare.ts index edc90c449..a2c627edf 100644 --- a/packages/compiler/src/native/prepare.ts +++ b/packages/compiler/src/native/prepare.ts @@ -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", diff --git a/packages/compiler/src/native/text-file.test.ts b/packages/compiler/src/native/text-file.test.ts index b497bc4a2..7534b5058 100644 --- a/packages/compiler/src/native/text-file.test.ts +++ b/packages/compiler/src/native/text-file.test.ts @@ -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"); +}); diff --git a/packages/compiler/src/native/text-file.ts b/packages/compiler/src/native/text-file.ts index d377c5273..8b0953120 100644 --- a/packages/compiler/src/native/text-file.ts +++ b/packages/compiler/src/native/text-file.ts @@ -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 { + 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); }