Reuse deferred initializer scans while only bindings are pending

This commit is contained in:
Chris Tate
2026-10-22 00:16:11 -05:00
parent e003e6abc1
commit 561f66cf5b
2 changed files with 275 additions and 99 deletions
@@ -91,6 +91,26 @@ test("finds replaced blocks and references appended to an existing body in a lat
expect(functions[0]!.body).toEqual([{ kind: "block", body: [value(7)], loc }]);
});
test("reuses the scan of an unchanged body while only bindings are pending", () => {
const { initializers, pending, lowerer } = fixture();
const placeholder = block(loc);
const functions = [fn("init", [placeholder]), fn("use", [value(1)])];
pending.set("registry", [{ loc, lower: () => [value(7)] }]);
expect(initializers.process(lowerer, functions)).toBe(false);
const walk = vi.spyOn(traversal, "everyStmtList");
try {
expect(initializers.process(lowerer, functions)).toBe(false);
expect(walk).not.toHaveBeenCalled();
// A statement added to the same list is found.
functions[1]!.body.push(read("registry"));
expect(initializers.process(lowerer, functions)).toBe(true);
expect(walk.mock.calls.filter(([body]) => body === functions[0]!.body)).toHaveLength(0);
} finally {
walk.mockRestore();
}
expect(placeholder.body).toEqual([value(7)]);
});
test("indexes new subtrees for other demanded initializers in the same pass", () => {
const { initializers, pending, lowerer } = fixture();
const nestedLoc = { ...loc, start: 10, end: 11 };
@@ -12,6 +12,7 @@ import {
} from "./lower-classes.js";
import { classPrototypeData } from "./class-prototypes.js";
import { bindingNeverReassigned } from "./binding-analysis.js";
import { InternalCompilerError } from "../../errors.js";
/** A registry of existing class/function values allocates data, but does
* not execute those bodies. Keep its initializer at its source position
@@ -20,6 +21,10 @@ import { bindingNeverReassigned } from "./binding-analysis.js";
export class DeferredModuleInitializers {
private readonly pending = new Map<string, { loc: IrStmt["loc"]; lower: () => IrStmt[] }[]>();
private readonly registryGlobals = new Set<string>();
/** Every key ever deferred, pending or materialized. */
private readonly deferredKeys = new Set<string>();
/** The last scan of each function (scanOf). */
private readonly scans = new Map<IrFunction, FunctionScan>();
defer(lowerer: Lowerer, statement: ts.Statement): IrStmt | null {
let key: string;
@@ -126,6 +131,83 @@ export class DeferredModuleInitializers {
process(lowerer: Lowerer, functions: readonly IrFunction[]): boolean {
if (this.pending.size === 0) return false;
// Scans keep only the reads of deferred bindings.
for (const key of this.pending.keys()) {
if (this.deferredKeys.has(key)) continue;
this.deferredKeys.add(key);
this.scans.clear();
}
// Class and prototype keys follow the classes IR types reach, and passes
// change those inside statements they keep (a bag initializer gains a
// prototype). While only bindings are pending, the scans of unchanged
// statement lists are reused; SCRIPTC_CHECK_DEFERRED_SCANS=1 checks each
// such pass against fresh scans.
const bindingsOnly = ![...this.pending.keys()].some(
(key) => key.startsWith("class:") || key.startsWith("prototype:"),
);
const scans = functions.map((fn) => this.scanOf(lowerer, fn, bindingsOnly));
const demanded = this.demanded(lowerer, functions, scans);
if (CHECK_SCANS && bindingsOnly) {
const fresh = functions.map((fn) => scanFunction(lowerer, fn, this.deferredKeys));
const expected = this.demanded(lowerer, functions, fresh);
const blocks = (list: readonly FunctionScan[]): PlaceholderBlock[] =>
list.flatMap((scan) => scan.placeholders.filter((block) => block.body.length === 0));
const reused = blocks(scans);
const live = blocks(fresh);
if (
[...demanded].join("\0") !== [...expected].join("\0") ||
reused.length !== live.length ||
reused.some((block, index) => block !== live[index])
)
throw new InternalCompilerError(
"reused deferred initializer scans disagree with a fresh scan",
);
}
if (demanded.size === 0) return false;
// The placeholders come from the scans, which are fresh for every list a
// transform replaced between waves. Each is kept with the functions whose
// bodies hold it. Searching every body again for each initializer would
// make materializing large module registries quadratic in the program size.
const placeholders = new Map<string, { block: PlaceholderBlock; fns: IrFunction[] }[]>();
const add = (block: PlaceholderBlock, fns: IrFunction[]): void => {
const key = locationKey(block.loc);
const blocks = placeholders.get(key) ?? [];
blocks.push({ block, fns });
placeholders.set(key, blocks);
};
functions.forEach((fn, index) => {
for (const block of scans[index]!.placeholders) if (block.body.length === 0) add(block, [fn]);
});
for (const id of demanded) {
const initialize = this.pending.get(id)!;
this.pending.delete(id);
for (const action of initialize) {
const body = action.lower();
const holders: IrFunction[] = [];
for (const { block, fns } of placeholders.get(locationKey(action.loc)) ?? []) {
if (block.body.length === 0) {
block.body.push(...body);
holders.push(...fns);
}
}
if (holders.length === 0) continue;
// These bodies changed inside unchanged statement lists.
for (const fn of holders) this.scans.delete(fn);
// An inserted body can introduce a placeholder needed by another
// action in this pass. Unattached bodies are not part of the live IR.
for (const nested of scanStatements(body).placeholders) add(nested, holders);
}
}
return demanded.size !== 0;
}
/** The pending keys the scanned functions reference, in the order the
* functions and their walks meet them. */
private demanded(
lowerer: Lowerer,
functions: readonly IrFunction[],
scans: readonly FunctionScan[],
): Set<string> {
const demanded = new Set<string>();
const classHelpers = new Map<string, string>();
for (const info of lowerer.classes.values()) {
@@ -133,116 +215,190 @@ export class DeferredModuleInitializers {
if (info.def.prototypeDataHelper)
classHelpers.set(info.def.prototypeDataHelper, `prototype:${info.def.name}`);
}
const seen = new Set<IrType>();
const byName = new Map(functions.map((fn) => [fn.name, fn]));
const inspectedHelpers = new Set<string>();
const byName = new Map(functions.map((fn, index) => [fn.name, index]));
const usedHelpers: string[] = [];
const demandType = (type: IrType): void => {
if (seen.has(type)) return;
seen.add(type);
if (type.kind === "object" || type.kind === "classval") {
for (let info = lowerer.classes.get(type.className); info; info = info.base ?? undefined) {
const key = `class:${info.def.name}`;
if (this.pending.has(key)) demanded.add(key);
const prototype = `prototype:${info.def.name}`;
if (type.kind === "object" && this.pending.has(prototype)) demanded.add(prototype);
const demand = (events: readonly ScanEvent[]): void => {
for (const event of events) {
if (event.kind === "read") {
if (this.pending.has(event.id)) demanded.add(event.id);
} else if (event.kind === "call") {
const owner = classHelpers.get(event.callee);
if (owner) {
if (this.pending.has(owner)) demanded.add(owner);
usedHelpers.push(event.callee);
}
} else {
for (
let info = lowerer.classes.get(event.className);
info;
info = info.base ?? undefined
) {
const key = `class:${info.def.name}`;
if (this.pending.has(key)) demanded.add(key);
const prototype = `prototype:${info.def.name}`;
if (event.kind === "object" && this.pending.has(prototype)) demanded.add(prototype);
}
}
} else if (type.kind === "array") demandType(type.elem);
else if (type.kind === "union") lowerer.unions.get(type.unionId)?.arms.forEach(demandType);
else if (type.kind === "record") {
const shape = lowerer.shapes.get(type.shapeId);
shape?.fields.forEach((field) => demandType(field.type));
if (shape?.indexValue) demandType(shape.indexValue);
} else if (type.kind === "func") {
type.params.forEach(demandType);
demandType(type.ret);
} else if (type.kind === "promise") demandType(type.inner);
}
};
for (const fn of functions) {
functions.forEach((fn, index) => {
// Prototype helpers are allocated eagerly while collecting metadata.
// Their base-helper calls must not make unused classes reachable.
if (classHelpers.has(fn.name)) continue;
fn.params.forEach((param) => demandType(param.type));
demandType(fn.returnType);
everyStmtList(fn.body, {
stmt: () => true,
expr: (expr) => {
demandType(expr.type);
if (readsBinding(expr) && this.pending.has(expr.localId)) demanded.add(expr.localId);
if (expr.kind === "call") {
const owner = classHelpers.get(expr.callee);
if (owner) {
if (this.pending.has(owner)) demanded.add(owner);
usedHelpers.push(expr.callee);
}
}
return true;
},
});
}
if (classHelpers.has(fn.name)) return;
demand(scans[index]!.signature);
demand(scans[index]!.body);
});
const inspectedHelpers = new Set<string>();
while (usedHelpers.length) {
const name = usedHelpers.pop()!;
if (inspectedHelpers.has(name)) continue;
inspectedHelpers.add(name);
const fn = byName.get(name);
if (!fn) continue;
everyStmtList(fn.body, {
stmt: () => true,
expr: (expr) => {
demandType(expr.type);
if (readsBinding(expr) && this.pending.has(expr.localId)) demanded.add(expr.localId);
if (expr.kind === "call") {
const owner = classHelpers.get(expr.callee);
if (owner) {
if (this.pending.has(owner)) demanded.add(owner);
usedHelpers.push(expr.callee);
}
}
return true;
},
});
const index = byName.get(name);
if (index !== undefined) demand(scans[index]!.body);
}
if (demanded.size === 0) return false;
// Transforms replace blocks between waves, so index the live placeholders
// anew in each pass. Searching every body again for each initializer makes
// materializing large module registries quadratic in the program size.
const placeholders = new Map<string, Extract<IrStmt, { kind: "block" }>[]>();
const locationKey = (loc: IrStmt["loc"]): string =>
JSON.stringify([loc.file, loc.start, loc.end]);
const indexPlaceholders = (body: IrStmt[]): void => {
everyStmtList(body, {
stmt: (statement) => {
if (statement.kind === "block" && statement.body.length === 0) {
const key = locationKey(statement.loc);
const blocks = placeholders.get(key) ?? [];
blocks.push(statement);
placeholders.set(key, blocks);
}
return true;
},
expr: () => true,
});
};
for (const fn of functions) indexPlaceholders(fn.body);
for (const id of demanded) {
const initialize = this.pending.get(id)!;
this.pending.delete(id);
for (const action of initialize) {
const body = action.lower();
let inserted = false;
for (const block of placeholders.get(locationKey(action.loc)) ?? []) {
if (block.body.length === 0) {
block.body.push(...body);
inserted = true;
}
}
// An inserted body can introduce a placeholder needed by another
// action in this pass. Unattached bodies are not part of the live IR.
if (inserted) indexPlaceholders(body);
}
}
return demanded.size !== 0;
return demanded;
}
/** What `fn` references that can demand an initializer, in the order a
* walk of it meets them. While only bindings are pending (`reuse`), a
* function's scan is reused while its statement list is the same array
* with the same length and the same first and last statements: passes that
* change a body either replace its list or add statements to it, and this
* pass forgets the scan of a function whose placeholder it fills. */
private scanOf(lowerer: Lowerer, fn: IrFunction, reuse: boolean): FunctionScan {
const cached = this.scans.get(fn);
if (
reuse &&
cached !== undefined &&
cached.statements === fn.body &&
cached.length === fn.body.length &&
cached.first === fn.body[0] &&
cached.last === fn.body[fn.body.length - 1]
) {
return cached;
}
const scan = scanFunction(lowerer, fn, this.deferredKeys);
this.scans.set(fn, scan);
return scan;
}
}
type PlaceholderBlock = Extract<IrStmt, { kind: "block" }>;
/** One reference a scan records: a class an IR type reaches (`object` also
* reaches its prototype), a binding read, or a call to a class data
* helper. */
type ScanEvent =
| { kind: "object" | "classval"; className: string }
| { kind: "read"; id: string }
| { kind: "call"; callee: string };
interface FunctionScan {
statements: IrStmt[];
length: number;
first: IrStmt | undefined;
last: IrStmt | undefined;
/** References of the parameter and return types. */
signature: ScanEvent[];
/** References of the body's expressions. */
body: ScanEvent[];
/** Empty blocks, in walk order: an initializer's position. */
placeholders: PlaceholderBlock[];
}
/** Class data helpers (classStaticData, classPrototypeData). */
const CLASS_HELPER_PREFIXES = ["%static.data.", "%prototype.data."];
/** SCRIPTC_CHECK_DEFERRED_SCANS=1 checks each pass that reuses scans against fresh scans. */
const CHECK_SCANS = process.env["SCRIPTC_CHECK_DEFERRED_SCANS"] === "1";
function locationKey(loc: IrStmt["loc"]): string {
return JSON.stringify([loc.file, loc.start, loc.end]);
}
/** The references of `fn` (FunctionScan). Each reference is kept at its
* first occurrence, except calls, and only reads of `deferred` keys. */
function scanFunction(
lowerer: Lowerer,
fn: IrFunction,
deferred: ReadonlySet<string>,
): FunctionScan {
const signature: ScanEvent[] = [];
const typeEvents = typeScanner(lowerer, signature);
for (const param of fn.params) typeEvents(param.type);
typeEvents(fn.returnType);
const { body, placeholders } = scanStatements(fn.body, lowerer, deferred);
return {
statements: fn.body,
length: fn.body.length,
first: fn.body[0],
last: fn.body[fn.body.length - 1],
signature,
body,
placeholders,
};
}
/** Records, once each, the classes the types it is given reach. */
function typeScanner(lowerer: Lowerer, events: ScanEvent[]): (type: IrType) => void {
const seen = new Set<IrType>();
const classes = new Set<string>();
const visit = (type: IrType): void => {
if (seen.has(type)) return;
seen.add(type);
if (type.kind === "object" || type.kind === "classval") {
const key = `${type.kind}:${type.className}`;
if (!classes.has(key)) {
classes.add(key);
events.push({ kind: type.kind, className: type.className });
}
} else if (type.kind === "array") visit(type.elem);
else if (type.kind === "union") lowerer.unions.get(type.unionId)?.arms.forEach(visit);
else if (type.kind === "record") {
const shape = lowerer.shapes.get(type.shapeId);
shape?.fields.forEach((field) => visit(field.type));
if (shape?.indexValue) visit(shape.indexValue);
} else if (type.kind === "func") {
type.params.forEach(visit);
visit(type.ret);
} else if (type.kind === "promise") visit(type.inner);
};
return visit;
}
/** The references and placeholders of a statement list. Without a lowerer
* it collects only placeholders. */
function scanStatements(
statements: IrStmt[],
lowerer?: Lowerer,
deferred?: ReadonlySet<string>,
): { body: ScanEvent[]; placeholders: PlaceholderBlock[] } {
const body: ScanEvent[] = [];
const placeholders: PlaceholderBlock[] = [];
const typeEvents = lowerer ? typeScanner(lowerer, body) : null;
const reads = new Set<string>();
everyStmtList(statements, {
stmt: (statement) => {
if (statement.kind === "block" && statement.body.length === 0) placeholders.push(statement);
return true;
},
expr: (expr) => {
if (typeEvents === null) return true;
typeEvents(expr.type);
if (readsBinding(expr) && deferred!.has(expr.localId) && !reads.has(expr.localId)) {
reads.add(expr.localId);
body.push({ kind: "read", id: expr.localId });
}
// Every call, as the helpers are visited from a stack in call order.
if (
expr.kind === "call" &&
CLASS_HELPER_PREFIXES.some((prefix) => expr.callee.startsWith(prefix))
)
body.push({ kind: "call", callee: expr.callee });
return true;
},
});
return { body, placeholders };
}
/** A read of a binding's current value: a plain read, or an update