diff --git a/packages/compiler/src/backend/llvm/emit-module.ts b/packages/compiler/src/backend/llvm/emit-module.ts index c6d3dbff4..dfdd95451 100644 --- a/packages/compiler/src/backend/llvm/emit-module.ts +++ b/packages/compiler/src/backend/llvm/emit-module.ts @@ -102,10 +102,10 @@ function emitFunctionsInChunks(emitter: LlEmitter, chunks: number): string[] { return fnDefs; } -/** Modules with fewer functions emit in one process: forking and merging - * would cost more than the workers save (SCRIPTC_EMIT_WORKER_FUNCTIONS - * lowers it, for tests). */ -function workerMinFunctions(): number { +/** Modules with fewer functions emit in one process, and compute their + * analyses in it: forking and merging would cost more than the workers save + * (SCRIPTC_EMIT_WORKER_FUNCTIONS lowers it, for tests). */ +export function workerMinFunctions(): number { const value = Number(process.env["SCRIPTC_EMIT_WORKER_FUNCTIONS"] ?? "2000"); return Number.isFinite(value) && value > 0 ? value : 2000; } diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index a8c5d2c39..8049f8624 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -104,7 +104,7 @@ import { analyzeInt32Slots, dynamicFieldStores, type DynamicFieldStores, - type Int32Slots, + Int32Slots, } from "../../ir/int32-slots.js"; import { analyzeFinalFields, type FinalFields } from "../../ir/final-fields.js"; import { foldConstantGlobals } from "../../ir/constant-globals.js"; @@ -373,7 +373,9 @@ import { emitModuleParts, emitParts, helperDefs, + workerMinFunctions, } from "./emit-module.js"; +import { forkedAnalysis } from "./forked-analysis.js"; import { emitLoopCopies, emitSpeculativeWrite, @@ -1483,7 +1485,13 @@ export class LlEmitter { streamingSplitsEnabled = false; readonly initializerBindings: ReturnType; /** Whole-program int32 storage and parameter/result facts. */ - readonly int32Slots: Int32Slots; + get int32Slots(): Int32Slots { + return (this.int32SlotsValue ??= this.joinInt32Slots()); + } + private int32SlotsValue: Int32Slots | undefined; + /** The join of the int32 slot analysis a forked process computes + * (startInt32Slots). */ + private int32SlotsJoin: (() => Int32Slots) | undefined; /** The program has no runtime store into a checked-dynamic value * (dynamicFieldStores), so live views of classes and records never * commit back into their objects. Library builds keep their commits: @@ -1736,6 +1744,15 @@ export class LlEmitter { // One walk of every body, which the analyses below read instead of // walking the bodies themselves. this.nodeIndex = int32Analysis?.nodeIndex ?? new IrNodeIndex(mod); + this.initializerBindings = + int32Analysis?.initializerBindings ?? findInitializerBindings(mod, this.nodeIndex); + // One walk finds the dynamic field stores every analysis below consults. + const dynamicStores = + int32Analysis?.dynamicStores ?? dynamicFieldStores(mod, undefined, undefined, this.nodeIndex); + // Started before the other analyses, which may run while a forked + // process computes it (startInt32Slots). + if (int32Analysis !== null) this.int32SlotsValue = int32Analysis.slots; + else this.startInt32Slots(dynamicStores); this.stackTraces = observesStacks(mod, this.nodeIndex); this.nullableUnions = new NullableUnions( mod, @@ -1754,18 +1771,6 @@ export class LlEmitter { this.nullableUnions.armTypes(), ); this.constantNumericTables = findConstantNumericTables(mod); - this.initializerBindings = - int32Analysis?.initializerBindings ?? findInitializerBindings(mod, this.nodeIndex); - // One walk finds the dynamic field stores every analysis below consults. - const dynamicStores = - int32Analysis?.dynamicStores ?? dynamicFieldStores(mod, undefined, undefined, this.nodeIndex); - this.int32Slots = - int32Analysis?.slots ?? - analyzeInt32Slots(mod, { - dynamicStores, - initializerBindings: this.initializerBindings, - nodes: this.nodeIndex, - }); this.untypedIntegrity = mod.untypedIntegrity === true; this.liveViewsReadOnly = mod.lib === undefined && @@ -2211,6 +2216,46 @@ export class LlEmitter { ); // After the vtable slots: borrowing slots exclude their implementations. if (this.debug === null) this.analyzeBorrowedReturns(); + this.int32SlotsValue ??= this.joinInt32Slots(); + } + + /** Computes the int32 slot analysis, or starts it on a forked process. + * Dev builds read its result only near the end of the constructor + * (int32Abis is empty with debug info), so for a large module the + * process computes it while the constructor runs the other analyses. */ + private startInt32Slots(dynamicStores: DynamicFieldStores): void { + const mod = this.mod; + const nodes = this.nodeIndex; + const analyze = (): Int32Slots => + analyzeInt32Slots(mod, { + dynamicStores, + initializerBindings: this.initializerBindings, + nodes, + }); + const fork = this.targetOptions.forkPrimitives; + if ( + fork === undefined || + this.targetOptions.debugSources === undefined || + (this.targetOptions.functionWorkers ?? 1) < 2 || + mod.functions.length < workerMinFunctions() + ) { + this.int32SlotsValue = analyze(); + return; + } + this.int32SlotsJoin = forkedAnalysis( + fork, + analyze, + (slots) => slots.encode(nodes), + (text) => Int32Slots.decode(text, mod, nodes), + ); + } + + private joinInt32Slots(): Int32Slots { + const join = this.int32SlotsJoin; + if (join === undefined) + throw new InternalCompilerError("llvm emitter bug: int32 slots read before analysis"); + this.int32SlotsJoin = undefined; + return join(); } /** The borrowed parameters of a hierarchy's vtable slot (empty: owned). */ diff --git a/packages/compiler/src/backend/llvm/forked-analysis.ts b/packages/compiler/src/backend/llvm/forked-analysis.ts new file mode 100644 index 000000000..80ba35fac --- /dev/null +++ b/packages/compiler/src/backend/llvm/forked-analysis.ts @@ -0,0 +1,45 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ForkPrimitives } from "./emitter.js"; + +/** Starts `compute` on a forked child process, which inherits this process + * as it is at the fork, and returns the join that answers its result. The + * child writes `encode`'s text to a file and exits; the join waits for it + * and decodes the text. When the fork fails, the child fails, or `encode` + * answers null, the join runs `compute` here instead, so the result does + * not depend on the child. Join once. */ +export function forkedAnalysis( + fork: ForkPrimitives, + compute: () => T, + encode: (value: T) => string | null, + decode: (text: string) => T, +): () => T { + const dir = mkdtempSync(join(tmpdir(), "scriptc-analysis-")); + const file = join(dir, "result.json"); + const pid = fork.fork(); + if (pid === 0) { + let code = 1; + try { + const text = encode(compute()); + if (text !== null) { + writeFileSync(file, text); + code = 0; + } + } catch { + // The parent computes the result itself. + } + fork.exit(code); + } + return () => { + let text: string | null = null; + try { + if (pid > 0 && fork.wait(pid) === 0) text = readFileSync(file, "utf8"); + } catch { + // The result is computed here. + } finally { + rmSync(dir, { recursive: true, force: true }); + } + return text === null ? compute() : decode(text); + }; +} diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index 1616a2a77..35207f79b 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -352,6 +352,82 @@ export class Int32Slots { for (const params of this.paramFacts.values()) count += params.size; return count; } + + /** The analysis as JSON, for a process that shares this module and its + * node index (a forked one). Proven writes are numbered by their position + * in `nodes`. Null when a proven write is not in the index. */ + encode(nodes: IrNodeIndex): string | null { + const proven = nodes.expressionPositions(this.provenWrites); + if (proven === null) return null; + const data: Int32SlotsData = { + fields: [...this.fields], + familyOf: [...this.familyOf], + paramFacts: [...this.paramFacts].map(([fn, params]) => [fn, [...params]]), + returns: [...this.returns], + returnRanges: [...this.returnRanges], + boxes: [...this.boxes], + boxGroupOf: [...this.boxGroupOf], + provenWrites: proven, + called: [...this.called], + reasons: [...this.reasons], + pinned: [...this.pinned], + int32Locals: [...this.int32Locals], + int32Elements: [...this.int32Elements], + pinViolations: this.pinViolations, + nonNegative: [...this.nonNegative], + narrowedReads: this.narrowedReads, + int32Arrays: [...this.int32Arrays], + checkedInt32Arrays: [...this.checkedInt32Arrays], + }; + return JSON.stringify(data); + } + + /** The analysis `encode` wrote for `mod`, whose node index is `nodes`. */ + static decode(text: string, mod: IrModule, nodes: IrNodeIndex): Int32Slots { + const data = JSON.parse(text) as Int32SlotsData; + const slots = new Int32Slots(new Map((mod.classes ?? []).map((c) => [c.name, c]))); + for (const family of data.fields) slots.fields.add(family); + for (const [key, family] of data.familyOf) slots.familyOf.set(key, family); + for (const [fn, params] of data.paramFacts) slots.paramFacts.set(fn, new Map(params)); + for (const fn of data.returns) slots.returns.add(fn); + for (const [fn, range] of data.returnRanges) slots.returnRanges.set(fn, range); + for (const group of data.boxes) slots.boxes.add(group); + for (const [member, group] of data.boxGroupOf) slots.boxGroupOf.set(member, group); + for (const node of nodes.expressionsAt(data.provenWrites)) slots.provenWrites.add(node); + for (const fn of data.called) slots.called.add(fn); + for (const [key, why] of data.reasons) slots.reasons.set(key, why); + for (const key of data.pinned) slots.pinned.add(key); + for (const key of data.int32Locals) slots.int32Locals.add(key); + for (const key of data.int32Elements) slots.int32Elements.add(key); + slots.pinViolations.push(...data.pinViolations); + for (const key of data.nonNegative) slots.nonNegative.add(key); + slots.narrowedReads = data.narrowedReads; + for (const key of data.int32Arrays) slots.int32Arrays.add(key); + for (const key of data.checkedInt32Arrays) slots.checkedInt32Arrays.add(key); + return slots; + } +} + +/** Int32Slots as plain data (Int32Slots.encode). */ +interface Int32SlotsData { + fields: string[]; + familyOf: [string, string | null][]; + paramFacts: [string, [string, IntegerRange][]][]; + returns: string[]; + returnRanges: [string, IntegerRange][]; + boxes: string[]; + boxGroupOf: [string, string][]; + provenWrites: number[]; + called: string[]; + reasons: [string, string][]; + pinned: string[]; + int32Locals: string[]; + int32Elements: string[]; + pinViolations: string[]; + nonNegative: string[]; + narrowedReads: boolean; + int32Arrays: string[]; + checkedInt32Arrays: string[]; } const DYN_KINDS = new Set(["dyn", "jsval", "caught"]); diff --git a/packages/compiler/src/ir/node-index.ts b/packages/compiler/src/ir/node-index.ts index 2aac7df92..be1fa506a 100644 --- a/packages/compiler/src/ir/node-index.ts +++ b/packages/compiler/src/ir/node-index.ts @@ -122,6 +122,36 @@ export class IrNodeIndex { if (statementKinds.has(kind)) this.statements[code] = 1; } + /** The positions of the expressions `wanted` in the index, which an + * index of the same module in another process resolves with + * expressionsAt. Null when an expression is not recorded, or is recorded + * more than once. */ + expressionPositions(wanted: ReadonlySet): number[] | null { + const positions: number[] = []; + if (wanted.size === 0) return positions; + const found = new Set(); + const { nodes, codes, statements } = this; + for (let i = 0; i < nodes.length; i++) { + if (statements[codes[i]!] !== 0) continue; + const node = nodes[i]! as IrExpr; + if (!wanted.has(node)) continue; + if (found.has(node)) return null; + found.add(node); + positions.push(i); + } + return found.size === wanted.size ? positions : null; + } + + /** The expressions at `positions` (expressionPositions). */ + expressionsAt(positions: readonly number[]): IrExpr[] { + return positions.map((position) => { + const code = this.codes[position]; + if (code === undefined || this.statements[code] !== 0) + throw new InternalCompilerError(`no IR expression at index position ${position}`); + return this.nodes[position]! as IrExpr; + }); + } + /** A scan for the nodes of `kinds`, for eachNodeOfKinds. Pass a * constant list: scans are kept by the list's identity. */ scan(kinds: readonly IrNodeKind[]): IrKindScan { diff --git a/packages/compiler/test/chunked-emission.test.ts b/packages/compiler/test/chunked-emission.test.ts index f4977f035..6d2b2e06a 100644 --- a/packages/compiler/test/chunked-emission.test.ts +++ b/packages/compiler/test/chunked-emission.test.ts @@ -104,7 +104,9 @@ test("a Promise.all tuple adapter requested in a range is defined", async () => }, 60_000); // Debug metadata is numbered per emitter (debug-info.ts) and not journaled: -// a debug build emits its function bodies in one emitter. +// a debug build emits its function bodies in one emitter. Its one fork +// computes the int32 slot analysis; when the fork fails, the emitter +// computes it itself. test("debug builds emit function bodies serially, with every metadata reference defined", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-chunked-debug-")); const minFunctions = process.env["SCRIPTC_EMIT_WORKER_FUNCTIONS"]; @@ -148,7 +150,7 @@ test("debug builds emit function bodies serially, with every metadata reference const referenced = new Set([...emitted.matchAll(/(![0-9]+)\b/g)].map((m) => m[1])); expect([...referenced].filter((id) => !defined.has(id))).toEqual([]); } - expect(forks).toBe(0); + expect(forks).toBe(1); } finally { if (minFunctions === undefined) delete process.env["SCRIPTC_EMIT_WORKER_FUNCTIONS"]; else process.env["SCRIPTC_EMIT_WORKER_FUNCTIONS"] = minFunctions; diff --git a/packages/compiler/test/int32-slots-transfer.test.ts b/packages/compiler/test/int32-slots-transfer.test.ts new file mode 100644 index 000000000..3ea4f6a6a --- /dev/null +++ b/packages/compiler/test/int32-slots-transfer.test.ts @@ -0,0 +1,88 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../src/index.js"; +import { findInitializerBindings } from "../src/ir/initializer-bindings.js"; +import { analyzeInt32Slots, dynamicFieldStores, Int32Slots } from "../src/ir/int32-slots.js"; +import type { IrExpr, IrModule } from "../src/ir/ir.js"; +import { IrNodeIndex } from "../src/ir/node-index.js"; +import { unshareNodes } from "../src/ir/unshare.js"; + +// Programs with int32 fields, parameters, results, captured boxes, contract +// pins, int32 arrays and disqualified candidates. +const PROGRAMS = [ + "4451-int32-field-storage.ts", + "4452-int32-field-dynamic-writes.ts", + "4453-int32-closed-locals.ts", + "4630-contract-int32.ts", + "4970-int32-call-abi.ts", + "5323-int32-nonnegative-slot-facts.ts", + "5332-int32-narrowed-slot-facts.ts", + "5930-contract-int32-arrays.ts", + "7326-int32-element-arrays.ts", + "7580-int32-arrays-across-calls.ts", +]; + +async function lower(program: string, dir: string): Promise { + const entry = join(import.meta.dirname, "../../../tests/corpus", program); + const outPath = join(dir, `${program}.ir.json`); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + return readFile(outPath, "utf8"); +} + +/** The analysis LlEmitter makes of `mod`, with its node index. */ +function analyze(mod: IrModule): { nodes: IrNodeIndex; slots: Int32Slots } { + const nodes = new IrNodeIndex(mod); + const slots = analyzeInt32Slots(mod, { + nodes, + dynamicStores: dynamicFieldStores(mod, undefined, undefined, nodes), + initializerBindings: findInitializerBindings(mod, nodes), + }); + return { nodes, slots }; +} + +// A forked process holds its own copy of the module: the encoded analysis +// names nodes by index position, which resolve in any copy of the module. +test("the int32 slot analysis decodes against another copy of its module", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-int32-transfer-")); + try { + let provenWrites = 0; + for (const program of PROGRAMS) { + const ir = await lower(program, dir); + const first = analyze(unshareNodes(deserializeModule(ir))); + const copy = unshareNodes(deserializeModule(ir)); + const second = analyze(copy); + const text = first.slots.encode(first.nodes); + expect(text, program).not.toBeNull(); + const decoded = Int32Slots.decode(text!, copy, second.nodes); + expect(decoded, program).toEqual(second.slots); + for (const node of decoded.provenWrites) + expect(second.slots.provenWrites.has(node), program).toBe(true); + provenWrites += decoded.provenWrites.size; + } + expect(provenWrites).toBeGreaterThan(0); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}, 120_000); + +test("an analysis whose proven writes the index does not record does not encode", () => { + const loc = { file: "test.ts", start: 0, end: 0 }; + const mod: IrModule = { + irVersion: 17, + sourceFile: "test.ts", + entry: "%main", + functions: [ + { loc, name: "%main", params: [], locals: [], returnType: { kind: "void" }, body: [] }, + ], + }; + const { nodes, slots } = analyze(mod); + expect(slots.encode(nodes)).not.toBeNull(); + const elsewhere: IrExpr = { loc, kind: "numLit", value: 1, type: { kind: "f64" } }; + slots.provenWrites.add(elsewhere); + expect(nodes.expressionPositions(slots.provenWrites)).toBeNull(); + expect(slots.encode(nodes)).toBeNull(); +});