diff --git a/packages/compiler/src/frontend/lowering/deferred-module-initializers.test.ts b/packages/compiler/src/frontend/lowering/deferred-module-initializers.test.ts index 53d6bac87..28f05bfce 100644 --- a/packages/compiler/src/frontend/lowering/deferred-module-initializers.test.ts +++ b/packages/compiler/src/frontend/lowering/deferred-module-initializers.test.ts @@ -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 }; diff --git a/packages/compiler/src/frontend/lowering/deferred-module-initializers.ts b/packages/compiler/src/frontend/lowering/deferred-module-initializers.ts index b80eb7ac0..eb22b4085 100644 --- a/packages/compiler/src/frontend/lowering/deferred-module-initializers.ts +++ b/packages/compiler/src/frontend/lowering/deferred-module-initializers.ts @@ -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 IrStmt[] }[]>(); private readonly registryGlobals = new Set(); + /** Every key ever deferred, pending or materialized. */ + private readonly deferredKeys = new Set(); + /** The last scan of each function (scanOf). */ + private readonly scans = new Map(); 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(); + 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 { const demanded = new Set(); const classHelpers = new Map(); 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(); - const byName = new Map(functions.map((fn) => [fn.name, fn])); - const inspectedHelpers = new Set(); + 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(); 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[]>(); - 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; + +/** 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, +): 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(); + const classes = new Set(); + 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, +): { body: ScanEvent[]; placeholders: PlaceholderBlock[] } { + const body: ScanEvent[] = []; + const placeholders: PlaceholderBlock[] = []; + const typeEvents = lowerer ? typeScanner(lowerer, body) : null; + const reads = new Set(); + 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