mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-29 07:04:09 +08:00
Compute dev builds' int32 slot analysis on a forked process (#2195)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user