Compute dev builds' int32 slot analysis on a forked process (#2195)

This commit is contained in:
Chris Tate
2026-10-25 02:46:02 -05:00
committed by GitHub
7 changed files with 306 additions and 20 deletions
@@ -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;
}
+59 -14
View File
@@ -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<typeof findInitializerBindings>;
/** 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). */
@@ -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<T>(
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);
};
}
+76
View File
@@ -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<IrType["kind"]>(["dyn", "jsval", "caught"]);
+30
View File
@@ -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<IrExpr>): number[] | null {
const positions: number[] = [];
if (wanted.size === 0) return positions;
const found = new Set<IrExpr>();
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 {
@@ -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;
@@ -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<string> {
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();
});