diff --git a/packages/compiler/native/cli.profdata.gz b/packages/compiler/native/cli.profdata.gz index aa0e9c36b..b5f1e079f 100644 Binary files a/packages/compiler/native/cli.profdata.gz and b/packages/compiler/native/cli.profdata.gz differ diff --git a/packages/compiler/src/backend/immutability.ts b/packages/compiler/src/backend/immutability.ts index 21380de8f..379ad60ee 100644 --- a/packages/compiler/src/backend/immutability.ts +++ b/packages/compiler/src/backend/immutability.ts @@ -3,6 +3,7 @@ import type { DynamicFieldStores } from "../ir/int32-slots.js"; import type { IrExpr, IrModule, IrStmt } from "../ir/ir.js"; import { everyExprChild, everyStmtChild } from "../ir/traverse.js"; +import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../ir/node-index.js"; import { computeCycleMutability, constructorEscapePoint, @@ -97,7 +98,10 @@ export interface ConstructionFacts { privateStores: Set; } -export function computeConstructionFacts(mod: IrModule): ConstructionFacts { +/** The nodes that can reach a constructor with a reused receiver. */ +const CONSTRUCTOR_USE_KINDS: readonly IrNodeKind[] = ["call", "closure"]; + +export function computeConstructionFacts(mod: IrModule, nodes?: IrNodeIndex): ConstructionFacts { const facts: ConstructionFacts = { privateStores: new Set() }; if (mod.lib !== undefined) return facts; const classes = new Map((mod.classes ?? []).map((c) => [c.name, c] as const)); @@ -122,6 +126,7 @@ export function computeConstructionFacts(mod: IrModule): ConstructionFacts { c = classes.get(c)!.base; } }; + const scan = nodes?.scan(CONSTRUCTOR_USE_KINDS); for (const fn of mod.functions) { const own = ctorClass.get(fn.name); const thisId = own !== undefined ? thisOf(own) : null; @@ -129,22 +134,19 @@ export function computeConstructionFacts(mod: IrModule): ConstructionFacts { own !== undefined && classes.get(own)!.base !== undefined ? `%${classes.get(own)!.base}.constructor` : null; - const expr = (e: IrExpr): boolean => { - if (e.kind === "call") { - const target = ctorClass.get(e.callee); + eachNodeOfKinds(fn, scan, (node) => { + if (node.kind === "call") { + const target = ctorClass.get(node.callee); if ( target !== undefined && - !(e.callee === baseCtor && e.args.length > 0 && isThisRef(e.args[0]!, thisId)) + !(node.callee === baseCtor && node.args.length > 0 && isThisRef(node.args[0]!, thisId)) ) markReused(target); - } else if (e.kind === "closure") { - const target = ctorClass.get(e.fnName); + } else if (node.kind === "closure") { + const target = ctorClass.get(node.fnName); if (target !== undefined) markReused(target); } - return everyExprChild(e, expr, stmt); - }; - const stmt = (s: IrStmt): boolean => everyStmtChild(s, expr, stmt); - fn.body.every(stmt); + }); } // The first escaping statement of each constructor (body length: none). @@ -192,32 +194,32 @@ export function computeConstructionFacts(mod: IrModule): ConstructionFacts { return facts; } +/** The nodes that write a binding: statement and expression assignments. */ +const ASSIGNMENT_KINDS: readonly IrNodeKind[] = ["assign", "assignExpr"]; + /** Module constants whose value never changes once initialized: immutable, * not TDZ-checked (every read follows the initialization), no import-cycle * init flag, and at most one assignment in the whole module. Such a global * owns its value for the rest of the program, so a read of it can be * borrowed across any later operand or call. */ -export function stableGlobals(mod: IrModule): Set { +export function stableGlobals(mod: IrModule, nodes?: IrNodeIndex): Set { const candidates = new Set( (mod.globals ?? []) .filter((g) => !g.mutable && g.tdz !== true && g.initFlag === undefined) .map((g) => g.id), ); const assigned = new Set(); - const expr = (e: IrExpr): boolean => { - if (e.kind === "assignExpr" && candidates.has(e.localId)) candidates.delete(e.localId); - return everyExprChild(e, expr, stmt); - }; - const stmt = (s: IrStmt): boolean => { - if (s.kind === "assign" && candidates.has(s.localId)) { - if (assigned.has(s.localId)) candidates.delete(s.localId); - assigned.add(s.localId); - } - return everyStmtChild(s, expr, stmt); - }; + const scan = nodes?.scan(ASSIGNMENT_KINDS); for (const fn of mod.functions) { if (candidates.size === 0) break; - fn.body.every(stmt); + eachNodeOfKinds(fn, scan, (node) => { + if (node.kind === "assignExpr" && candidates.has(node.localId)) + candidates.delete(node.localId); + else if (node.kind === "assign" && candidates.has(node.localId)) { + if (assigned.has(node.localId)) candidates.delete(node.localId); + assigned.add(node.localId); + } + }); } return candidates; } diff --git a/packages/compiler/src/backend/llvm/appended-array-literals.ts b/packages/compiler/src/backend/llvm/appended-array-literals.ts index 09a2976de..3c09904e3 100644 --- a/packages/compiler/src/backend/llvm/appended-array-literals.ts +++ b/packages/compiler/src/backend/llvm/appended-array-literals.ts @@ -9,8 +9,8 @@ * one directly or through a binding read from it. The choice never changes * behavior. */ -import type { IrExpr, IrModule, IrStmt } from "../../ir/ir.js"; -import { everyStmtList } from "../../ir/traverse.js"; +import type { IrExpr, IrModule } from "../../ir/ir.js"; +import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../../ir/node-index.js"; const APPENDING_METHODS: ReadonlySet = new Set([ "push", @@ -30,7 +30,27 @@ function isEmptyLiteral(e: IrExpr): boolean { ); } -export function findAppendedArrayLiterals(module: IrModule): ReadonlySet { +/** The nodes that name an appended binding or field, and the stores that + * initialize one. */ +const TARGET_KINDS: readonly IrNodeKind[] = [ + "arrIntrinsic", + "assignExpr", + "arraySet", + "varDecl", + "assign", +]; +const STORE_KINDS: readonly IrNodeKind[] = [ + "assignExpr", + "varDecl", + "assign", + "fieldSet", + "recordSet", +]; + +export function findAppendedArrayLiterals( + module: IrModule, + index?: IrNodeIndex, +): ReadonlySet { // Bindings are function-scoped by id; module globals are shared. const localsOf = new Map>(); for (const fn of module.functions) { @@ -65,20 +85,15 @@ export function findAppendedArrayLiterals(module: IrModule): ReadonlySet const field = fieldOf(target); if (field !== null) appendedFields.add(field); }; + const targets = index?.scan(TARGET_KINDS); for (const fn of module.functions) { - everyStmtList(fn.body, { - expr: (e) => { - if (e.kind === "arrIntrinsic" && APPENDING_METHODS.has(e.method)) - noteTarget(fn.name, e.receiver); - else if (e.kind === "assignExpr") noteAlias(fn.name, e.localId, e.value); - return true; - }, - stmt: (s) => { - if (s.kind === "arraySet") noteTarget(fn.name, s.arr); - else if (s.kind === "varDecl") noteAlias(fn.name, s.localId, s.init); - else if (s.kind === "assign") noteAlias(fn.name, s.localId, s.value); - return true; - }, + eachNodeOfKinds(fn, targets, (node) => { + if (node.kind === "arrIntrinsic" && APPENDING_METHODS.has(node.method)) + noteTarget(fn.name, node.receiver); + else if (node.kind === "assignExpr") noteAlias(fn.name, node.localId, node.value); + else if (node.kind === "arraySet") noteTarget(fn.name, node.arr); + else if (node.kind === "varDecl") noteAlias(fn.name, node.localId, node.init); + else if (node.kind === "assign") noteAlias(fn.name, node.localId, node.value); }); } for (const binding of appendedBindings) @@ -89,23 +104,18 @@ export function findAppendedArrayLiterals(module: IrModule): ReadonlySet const consider = (value: IrExpr | null, appended: boolean): void => { if (value && appended && isEmptyLiteral(value)) result.add(value); }; + const stores = index?.scan(STORE_KINDS); for (const fn of module.functions) { const binding = (localId: string): boolean => appendedBindings.has(bindingKey(fn.name, localId)); - everyStmtList(fn.body, { - expr: (e) => { - if (e.kind === "assignExpr") consider(e.value, binding(e.localId)); - return true; - }, - stmt: (s: IrStmt) => { - if (s.kind === "varDecl") consider(s.init, binding(s.localId)); - else if (s.kind === "assign") consider(s.value, binding(s.localId)); - else if (s.kind === "fieldSet") - consider(s.value, appendedFields.has(fieldKey(s.className, s.field))); - else if (s.kind === "recordSet") - consider(s.value, appendedFields.has(fieldKey(s.shapeId, s.field))); - return true; - }, + eachNodeOfKinds(fn, stores, (node) => { + if (node.kind === "assignExpr") consider(node.value, binding(node.localId)); + else if (node.kind === "varDecl") consider(node.init, binding(node.localId)); + else if (node.kind === "assign") consider(node.value, binding(node.localId)); + else if (node.kind === "fieldSet") + consider(node.value, appendedFields.has(fieldKey(node.className, node.field))); + else if (node.kind === "recordSet") + consider(node.value, appendedFields.has(fieldKey(node.shapeId, node.field))); }); } return result; diff --git a/packages/compiler/src/backend/llvm/constant-callbacks.ts b/packages/compiler/src/backend/llvm/constant-callbacks.ts index cf5c1b062..4b277dab7 100644 --- a/packages/compiler/src/backend/llvm/constant-callbacks.ts +++ b/packages/compiler/src/backend/llvm/constant-callbacks.ts @@ -1,9 +1,28 @@ import type { IrExpr, IrFunction, IrModule } from "../../ir/ir.js"; -import { exprFunctionReference, moduleFunctionReferences } from "../../ir/function-references.js"; +import { + exprFunctionReference, + FUNCTION_REFERENCE_KINDS, + moduleFunctionReferences, +} from "../../ir/function-references.js"; import { writtenLocals } from "../../ir/callback-specialization.js"; +import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../../ir/node-index.js"; import { everyStmtList } from "../../ir/traverse.js"; import type { CallLifetimes } from "./call-lifetimes.js"; +/** The nodes the body census below reads. */ +const CENSUS_KINDS: readonly IrNodeKind[] = [ + "varDecl", + "assign", + "forOf", + "rethrow", + "tryCatch", + "call", + "callValue", + "assignExpr", + "incDec", + ...FUNCTION_REFERENCE_KINDS, +]; + /** Closed direct-call consumers can devirtualize a callback when every * incoming call supplies the same function. Keep its evaluated environment, * arguments and ordinary ownership; only the loaded code pointer changes. @@ -15,6 +34,7 @@ import type { CallLifetimes } from "./call-lifetimes.js"; export function findConstantCallbacks( mod: IrModule, lifetimes: CallLifetimes, + index?: IrNodeIndex, ): Map> { const functions = new Map(mod.functions.map((fn) => [fn.name, fn])); const definitions = new Map>(); @@ -50,7 +70,7 @@ export function findConstantCallbacks( const writes = new Set(); // Parameters and captures each function (or a closure sharing the // binding) writes after entry. - const rebinds = writtenLocals(mod); + const rebinds = writtenLocals(mod, index); // A function named anywhere but as a direct call's callee can be entered // by a generated adapter or the runtime, which a direct-call census cannot // specialize: the entry, exports, class helpers and methods here, and @@ -66,6 +86,7 @@ export function findConstantCallbacks( // The initializer of each immutable function-typed local declared once: // its only write, even when a capture box (not a stable slot) holds it. const constDefinitions = new Map>(); + const scan = index?.scan(CENSUS_KINDS); for (const fn of mod.functions) { const defs = new Map(); const consts = new Map(); @@ -85,51 +106,64 @@ export function findConstantCallbacks( const emptyDecls = new Map(); const assigned = new Map(); const rebound = new Set(); - everyStmtList(fn.body, { - stmt: (stmt) => { - if (stmt.kind === "varDecl" && stmt.init?.type.kind === "func" && stable?.has(stmt.localId)) - defs.set(stmt.localId, stmt.init); - if (stmt.kind === "varDecl" && immutable.has(stmt.localId)) - consts.set(stmt.localId, consts.has(stmt.localId) ? null : stmt.init); - if (stmt.kind === "varDecl" && hoisted.has(stmt.localId)) { - if (stmt.init !== null) rebound.add(stmt.localId); - emptyDecls.set(stmt.localId, (emptyDecls.get(stmt.localId) ?? 0) + 1); - } - if (stmt.kind === "assign" && hoisted.has(stmt.localId)) { - const values = assigned.get(stmt.localId) ?? []; - values.push(stmt.value); - assigned.set(stmt.localId, values); - } - if (stmt.kind === "forOf" || stmt.kind === "rethrow") rebound.add(stmt.localId); - if (stmt.kind === "tryCatch" && stmt.catchLocalId !== null) rebound.add(stmt.catchLocalId); - if (stmt.kind === "assign") { - if (globalIds.has(stmt.localId)) { - if (globals.has(stmt.localId)) writes.add(stmt.localId); - else globals.set(stmt.localId, stmt.value); + eachNodeOfKinds(fn, scan, (node) => { + switch (node.kind) { + case "varDecl": + if (node.init?.type.kind === "func" && stable?.has(node.localId)) + defs.set(node.localId, node.init); + if (immutable.has(node.localId)) + consts.set(node.localId, consts.has(node.localId) ? null : node.init); + if (hoisted.has(node.localId)) { + if (node.init !== null) rebound.add(node.localId); + emptyDecls.set(node.localId, (emptyDecls.get(node.localId) ?? 0) + 1); } + return; + case "assign": + if (hoisted.has(node.localId)) { + const values = assigned.get(node.localId) ?? []; + values.push(node.value); + assigned.set(node.localId, values); + } + if (globalIds.has(node.localId)) { + if (globals.has(node.localId)) writes.add(node.localId); + else globals.set(node.localId, node.value); + } + return; + case "forOf": + case "rethrow": + rebound.add(node.localId); + return; + case "tryCatch": + if (node.catchLocalId !== null) rebound.add(node.catchLocalId); + return; + case "call": + if (consumers.has(node.callee)) { + const calls = incoming.get(node.callee) ?? []; + calls.push({ owner: fn.name, args: node.args }); + incoming.set(node.callee, calls); + } + return; + case "callValue": + if (node.callee.kind === "varRef") callees.add(node.callee.localId); + return; + case "closure": { + escaped.add(node.fnName); + const sites = closureSites.get(node.fnName) ?? []; + sites.push({ owner: fn.name, captures: node.captures }); + closureSites.set(node.fnName, sites); + return; } - return true; - }, - expr: (expr) => { - if (expr.kind === "call" && consumers.has(expr.callee)) { - const calls = incoming.get(expr.callee) ?? []; - calls.push({ owner: fn.name, args: expr.args }); - incoming.set(expr.callee, calls); + case "orDefault": { + const referenced = exprFunctionReference(node); + if (referenced !== undefined) escaped.add(referenced); + return; } - if (expr.kind === "callValue" && expr.callee.kind === "varRef") - callees.add(expr.callee.localId); - const referenced = exprFunctionReference(expr); - if (referenced !== undefined) escaped.add(referenced); - if (expr.kind === "closure") { - const sites = closureSites.get(expr.fnName) ?? []; - sites.push({ owner: fn.name, captures: expr.captures }); - closureSites.set(expr.fnName, sites); - } - if ((expr.kind === "assignExpr" || expr.kind === "incDec") && globalIds.has(expr.localId)) - writes.add(expr.localId); - if (expr.kind === "assignExpr" || expr.kind === "incDec") rebound.add(expr.localId); - return true; - }, + case "assignExpr": + case "incDec": + if (globalIds.has(node.localId)) writes.add(node.localId); + rebound.add(node.localId); + return; + } }); for (const [id, values] of assigned) if (emptyDecls.get(id) === 1 && values.length === 1 && !rebound.has(id)) diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index c0cc9fef2..050c3fb90 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -169,6 +169,7 @@ import { type MapEntryFusion } from "./map-entry-fusion.js"; import { emitBorrowedFieldSequence, guardedValue } from "./borrowed-receivers.js"; import { emitBorrowedInputs } from "./borrowed-inputs.js"; import { findWalkBorrows, type WalkBorrowHost } from "./walk-borrows.js"; +import { IrNodeIndex } from "../../ir/node-index.js"; import { lastUseRead } from "./local-extents.js"; import { findReboundParameters } from "./rebound-parameters.js"; import { ReferenceEffects } from "./reference-effects.js"; @@ -1183,6 +1184,9 @@ export class LlEmitter { /** Module constants that keep one value once initialized * (immutability.ts stableGlobals). */ stableGlobals: ReadonlySet = new Set(); + /** Every function body's nodes in walk order (ir/node-index.ts), for the + * constructor's analyses. */ + readonly nodeIndex: IrNodeIndex; private readonly tdzGlobals = new Map(); readonly constantNumericTables: ReadonlyMap; /** Constant-key reads on checked-dynamic objects, each with its own @@ -1251,7 +1255,7 @@ export class LlEmitter { /** The module's runtime features (moduleRuntimeFeatures), found with one * walk of the module the first time any of them is read. */ get runtimeFeatures(): RuntimeFeatures { - return (this.runtimeFeaturesValue ??= moduleRuntimeFeatures(this.mod)); + return (this.runtimeFeaturesValue ??= moduleRuntimeFeatures(this.mod, this.nodeIndex)); } /** Whether timer and tick scheduling goes through the *_als entry * points (RuntimeFeatures asyncContext). */ @@ -1548,8 +1552,15 @@ export class LlEmitter { ((options.functionWorkers ?? 1) > 1 && options.forkPrimitives !== undefined && options.debugSources === undefined); - this.stackTraces = observesStacks(mod); - this.nullableUnions = new NullableUnions(mod, options.debugSources === undefined); + // One walk of every body, which the analyses below read instead of + // walking the bodies themselves. + this.nodeIndex = new IrNodeIndex(mod); + this.stackTraces = observesStacks(mod, this.nodeIndex); + this.nullableUnions = new NullableUnions( + mod, + options.debugSources === undefined, + this.nodeIndex, + ); this.mockTimers = this.runtimeFeatures.mockTimers; this.debug = options.debugSources === undefined @@ -1562,19 +1573,20 @@ export class LlEmitter { this.nullableUnions.armTypes(), ); this.constantNumericTables = findConstantNumericTables(mod); - this.initializerBindings = findInitializerBindings(mod); + this.initializerBindings = findInitializerBindings(mod, this.nodeIndex); // One walk finds the dynamic field stores every analysis below consults. - const dynamicStores = dynamicFieldStores(mod); + const dynamicStores = dynamicFieldStores(mod, undefined, undefined, this.nodeIndex); this.int32Slots = analyzeInt32Slots(mod, { dynamicStores, initializerBindings: this.initializerBindings, + nodes: this.nodeIndex, }); this.liveViewsReadOnly = mod.lib === undefined && !dynamicStores.all && dynamicStores.names.size === 0; this.int32Abis = this.debug === null ? findInt32Abis(mod, this.int32Slots) : new Map(); this.immortalGlobals = new Set(mod.contracts?.immortalGlobals ?? []); this.programLifetime = new Set(mod.contracts?.programLifetime ?? []); - this.finalFields = analyzeFinalFields(mod, false, dynamicStores); + this.finalFields = analyzeFinalFields(mod, false, dynamicStores, this.nodeIndex); this.sizeType = options.pointerBits === 32 ? "i32" : "i64"; this.ffiExtendNarrowIntegers = options.wasi === true || ffiExtendsNarrowIntegers(options.targetTriple); @@ -1622,7 +1634,7 @@ export class LlEmitter { this.ffiHasRetainedCallback = hasRetainedFfiCallback(mod.ffiImports ?? []); this.ffiHasForeignCallback = hasForeignFfiCallback(mod.ffiImports ?? []); for (const fn of mod.functions) this.fnByName.set(fn.name, fn); - this.appendedArrayLiterals = findAppendedArrayLiterals(mod); + this.appendedArrayLiterals = findAppendedArrayLiterals(mod, this.nodeIndex); for (const entry of mod.ffiImports ?? []) { this.ffiByName.set(entry.name, entry); } @@ -1630,10 +1642,11 @@ export class LlEmitter { // and per async fiber; WASI, Windows and library builds have none. const triple = (options.targetTriple ?? "").toLowerCase(); const windows = triple === "" ? process.platform === "win32" : triple.includes("windows"); - this.publishedTypes = computePublishedTypes(mod); + this.publishedTypes = computePublishedTypes(mod, this.nodeIndex); const mt = computeMayThrow(mod, { stackChecks: !this.wasi && mod.lib === undefined && !windows, published: this.publishedTypes, + nodes: this.nodeIndex, }); this.mayThrow = mt.fns; this.workerEntryPolls = mt.workerEntryPolls ?? null; @@ -1692,12 +1705,14 @@ export class LlEmitter { new Set((mod.globals ?? []).map((g) => g.id)), { unions: mod.unions, records: mod.records }, this.immutableFields, + this.nodeIndex, ); const walkParameters = new Map>(); if (this.debug === null) { const host = { pointerLocal: (local: IrLocal) => this.plainReference(local.type), borrowsWithoutOwning: (e: IrExpr) => this.borrowsWithoutOwning(e), + nodes: this.nodeIndex, }; for (const fn of this.fnByName.values()) { const preserving = this.referenceEffects.functions.has(fn.name); @@ -1750,7 +1765,7 @@ export class LlEmitter { ); this.lentStringParameters = this.debug === null ? findLentStringParameters(this.fnByName, this.callLifetimes) : new Map(); - this.constantCallbacks = findConstantCallbacks(mod, this.callLifetimes); + this.constantCallbacks = findConstantCallbacks(mod, this.callLifetimes, this.nodeIndex); // Integer clones signal a stack overflow through their result, which // needs the single-threaded guard; worker programs also poll for // termination, and debug builds and stack traces keep every frame. @@ -1892,7 +1907,7 @@ export class LlEmitter { this.globalTypes.set(g.id, g.type); if (g.tdz) this.tdzGlobals.set(g.id, g.name); } - this.stableGlobals = stableGlobals(mod); + this.stableGlobals = stableGlobals(mod, this.nodeIndex); // Runtime error, EventEmitter, stream, EventTarget and Event classes // have known layouts. Subclasses embed the runtime prefixes from // classes.ts. diff --git a/packages/compiler/src/backend/llvm/nullable-unions.ts b/packages/compiler/src/backend/llvm/nullable-unions.ts index 2c355f551..d9b9cdfcf 100644 --- a/packages/compiler/src/backend/llvm/nullable-unions.ts +++ b/packages/compiler/src/backend/llvm/nullable-unions.ts @@ -6,6 +6,7 @@ import { type IrUnionDef, } from "../../ir/ir.js"; import { everyModuleNode } from "../../ir/traverse.js"; +import type { IrNodeIndex } from "../../ir/node-index.js"; /** A union of exactly one reference arm and one or two unit arms * (`C | undefined`, `C | null`, `C | null | undefined`) whose VALUES are @@ -120,12 +121,12 @@ export class NullableUnions { /** `shapes`: allow shape unions (debug builds keep boxes, whose * debug-info layouts describe every arm). */ - constructor(mod: IrModule, shapes = true) { + constructor(mod: IrModule, shapes = true, nodes?: IrNodeIndex) { const emitted = new Set(); for (const cls of mod.classes ?? []) if (!cls.runtime) emitted.add(cls.name); const records = new Set(); for (const shape of mod.records ?? []) records.add(shape.id); - const boxed = boxedUnionIds(mod); + const boxed = boxedUnionIds(mod, nodes); // Shape unions stay out of library builds (their host ABI marshals // boxes) and of programs with lifetime regions (whose walks report a // reference slot by its type's release entry point). @@ -277,13 +278,20 @@ function shapeUnion( /** Unions the runtime itself inspects as tagged boxes keep that * representation: Map keys and Set elements hash and compare union keys by * tag and payload inside the C map. */ -function boxedUnionIds(mod: IrModule): Set { +function boxedUnionIds(mod: IrModule, nodes?: IrNodeIndex): Set { const boxed = new Set(); // Only the module's union definitions consult the result. if (!mod.unions?.length) return boxed; const mark = (t: IrType): void => { if (t.kind === "union") boxed.add(t.unionId); }; + if (nodes !== undefined) { + for (const t of nodes.types) { + if (t.kind === "map") mark(t.key); + else if (t.kind === "set") mark(t.elem); + } + return boxed; + } everyModuleNode(mod, { expr: () => true, stmt: () => true, diff --git a/packages/compiler/src/backend/llvm/receivers.ts b/packages/compiler/src/backend/llvm/receivers.ts index 67ca17188..5e1077ee5 100644 --- a/packages/compiler/src/backend/llvm/receivers.ts +++ b/packages/compiler/src/backend/llvm/receivers.ts @@ -342,6 +342,7 @@ export function analyzeBorrowedReturns(emitter: LlEmitter): void { borrowsWithoutOwning: (e: IrExpr) => emitter.borrowsWithoutOwning(e), borrowedReturn: (callee: string) => emitter.borrowedReturns.has(callee), parameterAllowed: (id: string) => allowed.has(id), + nodes: emitter.nodeIndex, }; return emitter.referenceEffects.functions.has(fn.name) ? host diff --git a/packages/compiler/src/backend/llvm/reference-writes.ts b/packages/compiler/src/backend/llvm/reference-writes.ts index df2ec5032..89132a715 100644 --- a/packages/compiler/src/backend/llvm/reference-writes.ts +++ b/packages/compiler/src/backend/llvm/reference-writes.ts @@ -9,6 +9,7 @@ import { type IrUnionDef, } from "../../ir/ir.js"; import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; +import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../../ir/node-index.js"; import { borrowsMapMutationReceiver, borrowsMapReadInputs } from "./map-read-lifetimes.js"; import { preservesRegexInputs, VirtualTargets } from "./reference-effects.js"; import { borrowsStringInputs } from "./string-lifetimes.js"; @@ -227,6 +228,8 @@ export class ReferenceWrites { /** Fields no code writes once their object is constructed * (immutability.ts, from cycle-mutability's construction-only facts). */ private readonly immutable: ImmutableFields | null = null, + /** The module's node index, read instead of walking the bodies. */ + private readonly nodes?: IrNodeIndex, ) { this.key(HEAP_KEY); this.unions = new Map((types.unions ?? []).map((u) => [u.id, u])); @@ -236,25 +239,25 @@ export class ReferenceWrites { const constructors = new Set(classes.map((cls) => `%${cls.name}.constructor`)); this.constructors = constructors; this.freshConstructors = [...functions.values()].every((fn) => - fn.body.every((s) => this.onlySuperCalls(fn, s, constructors)), + this.onlySuperCalls(fn, constructors), ); - this.callTargets = new CallTargets(functions, this.virtualTargets); + this.callTargets = new CallTargets(functions, this.virtualTargets, nodes); this.solve(constructors); } - /** Every direct call of a constructor is a `super` call from a + /** Every direct call of a constructor in `fn` is a `super` call from a * constructor on its own `this`. */ - private onlySuperCalls(fn: IrFunction, s: IrStmt, constructors: ReadonlySet): boolean { + private onlySuperCalls(fn: IrFunction, constructors: ReadonlySet): boolean { const thisId = constructors.has(fn.name) ? fn.params[0]?.localId : undefined; - const expr = (e: IrExpr): boolean => { - if (e.kind === "call" && constructors.has(e.callee)) { - const self = e.args[0]; - if (thisId === undefined || !self || !isThisRef(self, thisId)) return false; + let only = true; + eachNodeOfKinds(fn, this.nodes?.scan(CALL_KINDS), (node) => { + if (node.kind === "call" && constructors.has(node.callee)) { + const self = node.args[0]; + if (thisId === undefined || !self || !isThisRef(self, thisId)) only = false; } - return everyExprChild(e, expr, stmt); - }; - const stmt = (node: IrStmt): boolean => everyStmtChild(node, expr, stmt); - return stmt(s); + return only; + }); + return only; } private key(k: string): number { @@ -299,7 +302,7 @@ export class ReferenceWrites { } const thisId = external && constructors.has(fn.name) ? (fn.params[0]?.localId ?? null) : null; const boxed = boxedLocals(fn); - const privates = privateArrays(fn, boxed); + const privates = privateArrays(fn, boxed, this.nodes); const sink: WriteSink = { add: (k) => { // Interned for queries; a callee's locals are private to it. @@ -749,7 +752,7 @@ export class ReferenceWrites { enter(fn: IrFunction): void { this.current = fn; this.currentBoxed = boxedLocals(fn); - this.currentPrivateArrays = privateArrays(fn, this.currentBoxed); + this.currentPrivateArrays = privateArrays(fn, this.currentBoxed, this.nodes); this.currentLocals = new Set(fn.locals.map((local) => local.id)); this.inConstructor = this.constructors.has(fn.name); for (const p of fn.params) this.currentLocals.add(p.localId); @@ -973,6 +976,29 @@ function privateReceiver(arr: IrExpr, privates: ReadonlySet): boolean { * element out nor let the array escape. */ const PRIVATE_ARRAY_METHODS = new Set(["length", "push"]); +/** The nodes the scans below read. */ +const CALL_KINDS: readonly IrNodeKind[] = ["call"]; +const CALL_SITE_KINDS: readonly IrNodeKind[] = ["call", "new", "virtualCall", "closure"]; +const DEFINITION_KINDS: readonly IrNodeKind[] = ["assignExpr", "incDec", "varDecl", "assign"]; + +/** The nodes privateArrays reads. */ +const PRIVATE_ARRAY_KINDS: readonly IrNodeKind[] = [ + "varRef", + "assignExpr", + "incDec", + "closure", + "classRef", + "arrIntrinsic", + "arrayHas", + "varDecl", + "assign", + "arraySet", + "arraySetUndefined", + "arrayDelete", + "arraySetLength", + "return", +]; + /** Array locals whose storage the function allocated itself and that * nothing else can reach before the function returns them: declared once * from an array literal or `new Array(n)`, never rebound, unboxed, and @@ -985,7 +1011,11 @@ const PRIVATE_ARRAY_METHODS = new Set(["length", "push"]); * stay out of the function's summary (a caller's borrows predate the * array). The literal map, filter and fused-pipeline helpers fill their * results this way. */ -function privateArrays(fn: IrFunction, boxed: ReadonlySet): ReadonlySet { +function privateArrays( + fn: IrFunction, + boxed: ReadonlySet, + nodes: IrNodeIndex | undefined, +): ReadonlySet { if (fn.async || fn.generator) return new Set(); const candidates = new Set(); for (const local of fn.locals) @@ -1001,54 +1031,50 @@ function privateArrays(fn: IrFunction, boxed: ReadonlySet): ReadonlySet< if (e.kind === "varRef" && candidates.has(e.localId)) permitted.set(e, (permitted.get(e) ?? 0) + 1); }; - const expr = (e: IrExpr): boolean => { - switch (e.kind) { + eachNodeOfKinds(fn, nodes?.scan(PRIVATE_ARRAY_KINDS), (node) => { + switch (node.kind) { case "varRef": { - const left = permitted.get(e) ?? 0; - if (left === 0) rejected.add(e.localId); - else permitted.set(e, left - 1); + const left = permitted.get(node) ?? 0; + if (left === 0) rejected.add(node.localId); + else permitted.set(node, left - 1); break; } case "assignExpr": case "incDec": - rejected.add(e.localId); + rejected.add(node.localId); break; case "closure": case "classRef": - for (const id of e.captures ?? []) rejected.add(id); + for (const id of node.captures ?? []) rejected.add(id); break; case "arrIntrinsic": - if (PRIVATE_ARRAY_METHODS.has(e.method)) permit(e.receiver); + if (PRIVATE_ARRAY_METHODS.has(node.method)) permit(node.receiver); break; case "arrayHas": - permit(e.arr); + permit(node.arr); break; - } - return everyExprChild(e, expr, stmt); - }; - const stmt = (s: IrStmt): boolean => { - switch (s.kind) { case "varDecl": - declarations.set(s.localId, (declarations.get(s.localId) ?? 0) + 1); - if (s.init === null || (s.init.kind !== "arrayLit" && s.init.kind !== "arrayNewLen")) - rejected.add(s.localId); + declarations.set(node.localId, (declarations.get(node.localId) ?? 0) + 1); + if ( + node.init === null || + (node.init.kind !== "arrayLit" && node.init.kind !== "arrayNewLen") + ) + rejected.add(node.localId); break; case "assign": - rejected.add(s.localId); + rejected.add(node.localId); break; case "arraySet": case "arraySetUndefined": case "arrayDelete": case "arraySetLength": - permit(s.arr); + permit(node.arr); break; case "return": - if (s.value !== null) permit(s.value); + if (node.value !== null) permit(node.value); break; } - return everyStmtChild(s, expr, stmt); - }; - fn.body.every(stmt); + }); const out = new Set(); for (const id of candidates) if (!rejected.has(id) && declarations.get(id) === 1) out.add(id); return out; @@ -1081,30 +1107,30 @@ class CallTargets { constructor( private readonly functions: ReadonlyMap, virtualTargets: VirtualTargets, + private readonly nodes: IrNodeIndex | undefined, ) { const site = (callee: string, caller: IrFunction, args: readonly IrExpr[]): void => { let list = this.sites.get(callee); if (!list) this.sites.set(callee, (list = [])); list.push({ caller, args }); }; + const scan = nodes?.scan(CALL_SITE_KINDS); for (const fn of functions.values()) { if (fn.captures !== undefined || fn.classCaptures !== undefined) this.opaque.add(fn.name); - const expr = (e: IrExpr): boolean => { - if (e.kind === "call") site(e.callee, fn, e.args); - else if (e.kind === "new") site(`%${e.className}.constructor`, fn, [e, ...e.args]); - else if (e.kind === "virtualCall") { - const targets = virtualTargets.targets(e.className, e.method); - for (const t of targets ?? []) site(t, fn, e.args); - } else if (e.kind === "closure") { - this.opaque.add(e.fnName); - let list = this.creators.get(e.fnName); - if (!list) this.creators.set(e.fnName, (list = [])); + eachNodeOfKinds(fn, scan, (node) => { + if (node.kind === "call") site(node.callee, fn, node.args); + else if (node.kind === "new") + site(`%${node.className}.constructor`, fn, [node, ...node.args]); + else if (node.kind === "virtualCall") { + const targets = virtualTargets.targets(node.className, node.method); + for (const t of targets ?? []) site(t, fn, node.args); + } else if (node.kind === "closure") { + this.opaque.add(node.fnName); + let list = this.creators.get(node.fnName); + if (!list) this.creators.set(node.fnName, (list = [])); list.push(fn); } - return everyExprChild(e, expr, stmt); - }; - const stmt = (st: IrStmt): boolean => everyStmtChild(st, expr, stmt); - fn.body.every(stmt); + }); } // Fixpoint over function-typed parameter positions. const positions: [IrFunction, number][] = []; @@ -1225,18 +1251,13 @@ class CallTargets { defs = new Map(); const boxed = new Set(fn.locals.filter((l) => l.boxed).map((l) => l.id)); const counts = new Map(); - const expr = (e: IrExpr): boolean => { - if (e.kind === "assignExpr" || e.kind === "incDec") counts.set(e.localId, 2); - return everyExprChild(e, expr, stmt); - }; - const stmt = (st: IrStmt): boolean => { - if (st.kind === "varDecl") { - counts.set(st.localId, (counts.get(st.localId) ?? 0) + 1); - defs!.set(st.localId, st.init); - } else if (st.kind === "assign") counts.set(st.localId, 2); - return everyStmtChild(st, expr, stmt); - }; - fn.body.every(stmt); + eachNodeOfKinds(fn, this.nodes?.scan(DEFINITION_KINDS), (node) => { + if (node.kind === "assignExpr" || node.kind === "incDec") counts.set(node.localId, 2); + else if (node.kind === "varDecl") { + counts.set(node.localId, (counts.get(node.localId) ?? 0) + 1); + defs!.set(node.localId, node.init); + } else if (node.kind === "assign") counts.set(node.localId, 2); + }); for (const [local, n] of counts) if (n !== 1 || boxed.has(local)) defs.set(local, null); this.definitions.set(fn, defs); } diff --git a/packages/compiler/src/backend/llvm/walk-borrows.ts b/packages/compiler/src/backend/llvm/walk-borrows.ts index dc3e03c19..86b8f3d21 100644 --- a/packages/compiler/src/backend/llvm/walk-borrows.ts +++ b/packages/compiler/src/backend/llvm/walk-borrows.ts @@ -1,5 +1,5 @@ import { isRefCounted, type IrExpr, type IrFunction, type IrLocal } from "../../ir/ir.js"; -import { everyStmtList } from "../../ir/traverse.js"; +import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../../ir/node-index.js"; /** What the emitter knows about a projection's storage. */ export interface WalkBorrowHost { @@ -22,8 +22,24 @@ export interface WalkBorrowHost { * (whose own rebinding releases nothing). Absent: the body preserves * every edge. */ unwritten?(value: IrExpr, candidates: ReadonlySet): boolean; + /** The module's node index, read instead of walking the body. */ + nodes?: IrNodeIndex; } +/** The node kinds analyzeWalks reads from a body. */ +const WALK_KINDS: readonly IrNodeKind[] = [ + "varDecl", + "assign", + "forOf", + "rethrow", + "tryCatch", + "return", + "assignExpr", + "incDec", + "closure", + "classRef", +]; + export interface WalkFacts { locals: ReadonlySet; /** Every return yields a walk (or a throw), so the body can return its @@ -71,44 +87,36 @@ export function analyzeWalks(fn: IrFunction, host: WalkBorrowHost): WalkFacts { if (!list) definitions.set(id, (list = [])); list.push(value); }; - everyStmtList(fn.body, { - stmt: (s) => { - switch (s.kind) { - case "varDecl": - define(s.localId, s.init); - break; - case "assign": - define(s.localId, s.value); - break; - case "forOf": - excluded.add(s.localId); - break; - case "rethrow": - excluded.add(s.localId); - break; - case "tryCatch": - guarded = true; - if (s.catchLocalId !== null) excluded.add(s.catchLocalId); - break; - case "return": - returns.push(s.value); - break; - } - return true; - }, - expr: (e) => { - switch (e.kind) { - case "assignExpr": - case "incDec": - excluded.add(e.localId); - break; - case "closure": - case "classRef": - for (const id of e.captures ?? []) excluded.add(id); - break; - } - return true; - }, + eachNodeOfKinds(fn, host.nodes?.scan(WALK_KINDS), (node) => { + switch (node.kind) { + case "varDecl": + define(node.localId, node.init); + break; + case "assign": + define(node.localId, node.value); + break; + case "forOf": + excluded.add(node.localId); + break; + case "rethrow": + excluded.add(node.localId); + break; + case "tryCatch": + guarded = true; + if (node.catchLocalId !== null) excluded.add(node.catchLocalId); + break; + case "return": + returns.push(node.value); + break; + case "assignExpr": + case "incDec": + excluded.add(node.localId); + break; + case "closure": + case "classRef": + for (const id of node.captures ?? []) excluded.add(id); + break; + } }); const plain = (id: string): boolean => { const local = locals.get(id); diff --git a/packages/compiler/src/backend/may-throw.ts b/packages/compiler/src/backend/may-throw.ts index a04cce856..0cfab9244 100644 --- a/packages/compiler/src/backend/may-throw.ts +++ b/packages/compiler/src/backend/may-throw.ts @@ -3,7 +3,8 @@ import type { IrExpr, IrStmt, IrModule, IrType, SrcLoc } from "../ir/ir.js"; import { isFfiCallbackParam, MAY_THROW_ARR_METHODS, MAY_THROW_BYTES_METHODS } from "../ir/ir.js"; import { MAY_THROW_LIB_FNS } from "../ir/builtin-effects.js"; -import { everyExprChild, everyStmtChild, everyStmtList } from "../ir/traverse.js"; +import { everyExprChild, everyStmtChild } from "../ir/traverse.js"; +import { eachNodeOfKinds, type IrNodeIndex } from "../ir/node-index.js"; import { hasRetainedFfiCallback } from "./ffi-callbacks.js"; import { computePublishedTypes, writesPublished } from "./publication.js"; @@ -40,6 +41,8 @@ export function computeMayThrow( explain?: boolean; /** computePublishedTypes(mod), when the caller already has it. */ published?: ReturnType; + /** The module's node index, read instead of walking the bodies. */ + nodes?: IrNodeIndex; } = {}, ): { fns: Set; @@ -52,8 +55,9 @@ export function computeMayThrow( * entry (and may therefore throw the stack-overflow RangeError). */ stackChecks?: Set; } { - const terminations = mod.workers === true ? workerTerminationPolls(mod) : null; - const stackChecks = options.stackChecks === true ? stackCheckedFunctions(mod) : null; + const terminations = mod.workers === true ? workerTerminationPolls(mod, options.nodes) : null; + const stackChecks = + options.stackChecks === true ? stackCheckedFunctions(mod, options.nodes) : null; // Stores that can reach a published object throw the frozen TypeError. const published = options.published !== undefined ? options.published : computePublishedTypes(mod); @@ -898,7 +902,19 @@ function boundedNode(rec: IrExpr | IrStmt): boolean { * finishes after finitely many steps or reaches a polling callee. `polls` * names the functions that poll (and therefore may unwind with the * termination sentinel); `entry` the subset that polls on entry. */ -export function workerTerminationPolls(mod: IrModule): { +/** The kinds call-graph census below can skip: boundedNode answers true + * for them, and the census reads nothing else from them. */ +const CENSUS_SKIPPED_KINDS: ReadonlySet = new Set( + [...BOUNDED_KINDS].filter( + (kind) => + !["while", "doWhile", "for", "forOf", "call", "new", "virtualCall", "closure"].includes(kind), + ), +); + +export function workerTerminationPolls( + mod: IrModule, + nodes?: IrNodeIndex, +): { entry: Set; polls: Set; } { @@ -914,11 +930,12 @@ export function workerTerminationPolls(mod: IrModule): { const edges = new Map(); const entry = new Set(); const polls = new Set(); + const scan = nodes?.scanExcept(CENSUS_SKIPPED_KINDS); for (const fn of mod.functions) { const out: string[] = []; let bounded = fn.async !== true && fn.generator === undefined && fn.name !== mod.entry; let loops = false; - const visit = (rec: IrExpr | IrStmt): boolean => { + const visit = (rec: IrExpr | IrStmt): void => { if (!boundedNode(rec)) bounded = false; switch (rec.kind) { case "while": @@ -937,9 +954,8 @@ export function workerTerminationPolls(mod: IrModule): { out.push(...(methodImpls.get(rec.method) ?? [])); break; } - return true; }; - everyStmtList(fn.body, { expr: visit, stmt: visit }); + eachNodeOfKinds(fn, scan, visit); edges.set( fn.name, out.filter((callee) => names.has(callee)), @@ -960,7 +976,7 @@ export function workerTerminationPolls(mod: IrModule): { * library call; see boundedNode) also reaches every function that such an * operation can invoke: closure targets and class members. Non-recursive * functions, including leaf callbacks, stay check-free. */ -export function stackCheckedFunctions(mod: IrModule): Set { +export function stackCheckedFunctions(mod: IrModule, nodes?: IrNodeIndex): Set { const reentry = "\0reentry"; const methodImpls = new Map(); const memberPrefixes = new Set(); @@ -975,12 +991,13 @@ export function stackCheckedFunctions(mod: IrModule): Set { const names = new Set(mod.functions.map((fn) => fn.name)); const edges = new Map(); const targets = new Set(); + const scan = nodes?.scanExcept(CENSUS_SKIPPED_KINDS); for (const fn of mod.functions) { const dot = fn.name.indexOf("."); if (dot > 0 && memberPrefixes.has(fn.name.slice(0, dot + 1))) targets.add(fn.name); const out: string[] = []; let reenters = false; - const visit = (rec: IrExpr | IrStmt): boolean => { + const visit = (rec: IrExpr | IrStmt): void => { if (!boundedNode(rec)) reenters = true; switch (rec.kind) { case "call": @@ -996,9 +1013,8 @@ export function stackCheckedFunctions(mod: IrModule): Set { targets.add(rec.fnName); break; } - return true; }; - everyStmtList(fn.body, { expr: visit, stmt: visit }); + eachNodeOfKinds(fn, scan, visit); const callees = out.filter((callee) => names.has(callee)); if (reenters) callees.push(reentry); edges.set(fn.name, callees); diff --git a/packages/compiler/src/backend/publication.ts b/packages/compiler/src/backend/publication.ts index af65fa39a..473a0cba4 100644 --- a/packages/compiler/src/backend/publication.ts +++ b/packages/compiler/src/backend/publication.ts @@ -3,7 +3,7 @@ * emitter guards them. */ import type { IrExpr, IrModule, IrStmt, IrType } from "../ir/ir.js"; import { typeKey } from "../ir/ir.js"; -import { everyStmtList } from "../ir/traverse.js"; +import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "../ir/node-index.js"; import { computeConstructionFacts } from "./immutability.js"; /** Array methods that write their receiver (the guarded ones). */ @@ -71,17 +71,22 @@ export class PublishedTypes { } } -export function computePublishedTypes(mod: IrModule): PublishedTypes | null { +/** The nodes that publish a value: intrinsics. */ +const PUBLISHING_KINDS: readonly IrNodeKind[] = ["intrinsic"]; + +export function computePublishedTypes(mod: IrModule, nodes?: IrNodeIndex): PublishedTypes | null { const roots: IrType[] = []; - const visit = (e: IrExpr): boolean => { - // Values read from an InternTable were published by whichever thread - // stored them. - if (e.kind === "intrinsic" && (e.name === "threads.publish" || e.name === "itable.value")) - roots.push(e.type); - return true; - }; + const scan = nodes?.scan(PUBLISHING_KINDS); for (const fn of mod.functions) - everyStmtList(fn.body, { expr: visit, stmt: (_s: IrStmt) => true }); + eachNodeOfKinds(fn, scan, (node) => { + // Values read from an InternTable were published by whichever thread + // stored them. + if ( + node.kind === "intrinsic" && + (node.name === "threads.publish" || node.name === "itable.value") + ) + roots.push(node.type); + }); if (roots.length === 0) return null; const classes = new Map((mod.classes ?? []).map((c) => [c.name, c])); @@ -95,7 +100,7 @@ export function computePublishedTypes(mod: IrModule): PublishedTypes | null { const records = new Map((mod.records ?? []).map((r) => [r.id, r])); const unions = new Map((mod.unions ?? []).map((u) => [u.id, u])); const out = new PublishedTypes(); - out.privateStores = computeConstructionFacts(mod).privateStores; + out.privateStores = computeConstructionFacts(mod, nodes).privateStores; const seen = new Set(); const stack = [...roots]; const addHierarchy = (className: string): void => { diff --git a/packages/compiler/src/backend/stack-observations.ts b/packages/compiler/src/backend/stack-observations.ts index c448897f1..0709d588e 100644 --- a/packages/compiler/src/backend/stack-observations.ts +++ b/packages/compiler/src/backend/stack-observations.ts @@ -1,15 +1,32 @@ /* Whole-program stack-trace gate (see observesStacks). Pure function of the * IR module; the emitter keeps a source frame in every function only when * it answers true. */ -import { exprFunctionReference, moduleFunctionReferences } from "../ir/function-references.js"; +import { + exprFunctionReference, + FUNCTION_REFERENCE_KINDS, + moduleFunctionReferences, +} from "../ir/function-references.js"; +import { + eachNodeOfKinds, + type IrKindScan, + type IrNodeIndex, + type IrNodeKind, +} from "../ir/node-index.js"; import { MAY_THROW_LIB_FNS } from "../ir/builtin-effects.js"; import type { IrExpr, IrFunction, IrModule, IrStmt, IrType } from "../ir/ir.js"; import { MAY_THROW_ARR_METHODS } from "../ir/ir.js"; -import { everyExprChild, everyStmtChild, everyStmtList } from "../ir/traverse.js"; +import { everyExprChild, everyStmtChild } from "../ir/traverse.js"; import { unreachableStatements } from "./may-throw.js"; import type { PublishedTypes } from "./publication.js"; import { writesPublished } from "./publication.js"; +/** The nodes the scans below read: stack reads, the "stack" literal, call + * sites and function references, declarations. */ +const STACK_READ_KINDS: readonly IrNodeKind[] = ["libCall", "dynKeyGet"]; +const STRING_KINDS: readonly IrNodeKind[] = ["strLit"]; +const CENSUS_KINDS: readonly IrNodeKind[] = ["call", ...FUNCTION_REFERENCE_KINDS]; +const DECLARATION_KINDS: readonly IrNodeKind[] = ["varDecl"]; + /** Lib calls that read a captured stack trace. */ const STACK_READS: ReadonlySet = new Set(["error.stack"]); @@ -40,10 +57,13 @@ class StackKeys { // The module walks run on the first question that needs them: a program // without a dynamic key read asks none. - constructor(private readonly mod: IrModule) {} + constructor( + private readonly mod: IrModule, + private readonly nodes: IrNodeIndex | undefined, + ) {} private spellsStack(): boolean { - return (this.spelled ??= spellsStack(this.mod)); + return (this.spelled ??= spellsStack(this.mod, this.nodes)); } private callCensus(): { @@ -56,20 +76,18 @@ class StackKeys { const named = new Set(moduleFunctionReferences(mod)); // Class methods, accessors and constructors are called through dispatch. const classNames = new Set((mod.classes ?? []).map((cls) => cls.name)); + const scan = this.nodes?.scan(CENSUS_KINDS); for (const fn of mod.functions) { if (isClassMember(fn.name, classNames)) named.add(fn.name); - everyStmtList(fn.body, { - stmt: () => true, - expr: (expr) => { - if (expr.kind === "call") { - const sites = calls.get(expr.callee) ?? []; - sites.push({ caller: fn, args: expr.args }); - calls.set(expr.callee, sites); - } - const referenced = exprFunctionReference(expr); + eachNodeOfKinds(fn, scan, (node) => { + if (node.kind === "call") { + const sites = calls.get(node.callee) ?? []; + sites.push({ caller: fn, args: node.args }); + calls.set(node.callee, sites); + } else if (node.kind === "closure" || node.kind === "orDefault") { + const referenced = exprFunctionReference(node); if (referenced !== undefined) named.add(referenced); - return true; - }, + } }); } return (this.census = { calls, named }); @@ -127,13 +145,9 @@ class StackKeys { let inits = this.inits.get(fn); if (inits === undefined) { inits = new Map(); - everyStmtList(fn.body, { - stmt: (stmt) => { - if (stmt.kind === "varDecl") - inits!.set(stmt.localId, inits!.has(stmt.localId) ? null : (stmt.init ?? null)); - return true; - }, - expr: () => true, + eachNodeOfKinds(fn, this.nodes?.scan(DECLARATION_KINDS), (node) => { + if (node.kind === "varDecl") + inits!.set(node.localId, inits!.has(node.localId) ? null : (node.init ?? null)); }); this.inits.set(fn, inits); } @@ -161,21 +175,25 @@ function readsStack(fn: IrFunction, expr: IrExpr, keys: StackKeys): boolean { } /** Whether the module contains the string literal "stack". */ -function spellsStack(mod: IrModule): boolean { - return mod.functions.some( - (fn) => - !everyStmtList(fn.body, { - stmt: () => true, - expr: (expr) => !(expr.kind === "strLit" && expr.value === "stack"), - }), - ); +function spellsStack(mod: IrModule, nodes: IrNodeIndex | undefined): boolean { + const scan = nodes?.scan(STRING_KINDS); + return mod.functions.some((fn) => { + let found = false; + eachNodeOfKinds(fn, scan, (node) => { + found = node.kind === "strLit" && node.value === "stack"; + return !found; + }); + return found; + }); } -function containsStackRead(fn: IrFunction, keys: StackKeys): boolean { - return !everyStmtList(fn.body, { - stmt: () => true, - expr: (expr) => !readsStack(fn, expr, keys), +function containsStackRead(fn: IrFunction, keys: StackKeys, scan: IrKindScan | undefined): boolean { + let found = false; + eachNodeOfKinds(fn, scan, (node, statement) => { + found = !statement && readsStack(fn, node as IrExpr, keys); + return !found; }); + return found; } /** Visit every expression of `body` that execution can reach: statements @@ -199,9 +217,10 @@ function visitLiveExprs(body: IrStmt[], visit: (expr: IrExpr) => void): void { * Flight server keep development-only stack parsing behind `throw` stubs, * and those reads must not cost every call of the program a frame. * Programs without any stack read answer false without the walk. */ -export function observesStacks(mod: IrModule): boolean { - const keys = new StackKeys(mod); - if (!mod.functions.some((fn) => containsStackRead(fn, keys))) return false; +export function observesStacks(mod: IrModule, nodes?: IrNodeIndex): boolean { + const keys = new StackKeys(mod, nodes); + const reads = nodes?.scan(STACK_READ_KINDS); + if (!mod.functions.some((fn) => containsStackRead(fn, keys, reads))) return false; const byName = new Map(mod.functions.map((fn) => [fn.name, fn])); const classNames = new Set((mod.classes ?? []).map((cls) => cls.name)); const pending: string[] = [...moduleFunctionReferences(mod)]; diff --git a/packages/compiler/src/ir/callback-specialization.ts b/packages/compiler/src/ir/callback-specialization.ts index 00432621d..6038ccaa5 100644 --- a/packages/compiler/src/ir/callback-specialization.ts +++ b/packages/compiler/src/ir/callback-specialization.ts @@ -2,6 +2,7 @@ import type { IrContracts, IrExpr, IrFunction, IrModule, IrStmt } from "./ir.js" import { exprFunctionReference, moduleFunctionReferences } from "./function-references.js"; import { reparseExpr, reparseStmt } from "./serialize.js"; import { everyStmtList, mapExprChildren, mapStmtChildren } from "./traverse.js"; +import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "./node-index.js"; /** Largest body (IR nodes) that is copied per callback target. */ const MAX_FUNCTION_NODES = 300; @@ -15,11 +16,24 @@ const COPIED_NODE_FRACTION = 0.25; * closure it creates writes through its environment (and transitively the * closures those create). A parameter or capture absent from its function's * set holds the value it entered with for the whole call. */ -export function writtenLocals(mod: IrModule): Map> { - return rebindings(mod).written; +export function writtenLocals(mod: IrModule, nodes?: IrNodeIndex): Map> { + return rebindings(mod, nodes).written; } -function rebindings(mod: IrModule): { +/** The nodes that rebind a local or capture bindings. */ +const REBINDING_KINDS: readonly IrNodeKind[] = [ + "assign", + "forOf", + "tryCatch", + "assignExpr", + "incDec", + "closure", +]; + +function rebindings( + mod: IrModule, + nodes?: IrNodeIndex, +): { written: Map>; /** Bindings of each function that a closure it creates writes. */ closureWritten: Map>; @@ -27,20 +41,15 @@ function rebindings(mod: IrModule): { const written = new Map>(); const closureWritten = new Map>(); const sites: { owner: string; fnName: string; captures: string[] }[] = []; + const scan = nodes?.scan(REBINDING_KINDS); for (const fn of mod.functions) { const ids = new Set(); - everyStmtList(fn.body, { - stmt: (stmt) => { - if (stmt.kind === "assign" || stmt.kind === "forOf") ids.add(stmt.localId); - if (stmt.kind === "tryCatch" && stmt.catchLocalId !== null) ids.add(stmt.catchLocalId); - return true; - }, - expr: (expr) => { - if (expr.kind === "assignExpr" || expr.kind === "incDec") ids.add(expr.localId); - if (expr.kind === "closure" && expr.captures.length) - sites.push({ owner: fn.name, fnName: expr.fnName, captures: expr.captures }); - return true; - }, + eachNodeOfKinds(fn, scan, (node) => { + if (node.kind === "assign" || node.kind === "forOf") ids.add(node.localId); + else if (node.kind === "tryCatch" && node.catchLocalId !== null) ids.add(node.catchLocalId); + else if (node.kind === "assignExpr" || node.kind === "incDec") ids.add(node.localId); + else if (node.kind === "closure" && node.captures.length) + sites.push({ owner: fn.name, fnName: node.fnName, captures: node.captures }); }); written.set(fn.name, ids); } diff --git a/packages/compiler/src/ir/final-fields.ts b/packages/compiler/src/ir/final-fields.ts index b0340cbaa..991339c6c 100644 --- a/packages/compiler/src/ir/final-fields.ts +++ b/packages/compiler/src/ir/final-fields.ts @@ -1,6 +1,7 @@ import { isRefCounted, type IrClassDef, type IrExpr, type IrModule, type IrStmt } from "./ir.js"; import { dynamicFieldStores, type DynamicFieldStores } from "./int32-slots.js"; import { everyExprChild, everyStmtChild } from "./traverse.js"; +import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "./node-index.js"; /** Whole-program final reference fields. * @@ -75,6 +76,9 @@ export function constructorClass(fnName: string): string | null { : null; } +/** The nodes that write a class field. */ +const FIELD_WRITE_KINDS: readonly IrNodeKind[] = ["fieldSet", "fieldIncDec"]; + /** `scalars` extends the analysis from reference-counted fields to fields of * every type, for consumers that read a final field's value once instead of * borrowing it (field-values.ts). */ @@ -82,6 +86,7 @@ export function analyzeFinalFields( mod: IrModule, scalars = false, stores?: DynamicFieldStores, + nodes?: IrNodeIndex, ): FinalFields { const classes = new Map((mod.classes ?? []).map((c) => [c.name, c])); const finals = new FinalFields(classes); @@ -121,30 +126,25 @@ export function analyzeFinalFields( } if (finals.size === 0) return finals; + const scan = nodes?.scan(FIELD_WRITE_KINDS); for (const fn of mod.functions) { const ctor = constructorClass(fn.name); const self = ctor !== null ? fn.params[0]?.localId : undefined; const selfLocal = self !== undefined ? fn.locals.find((l) => l.id === self) : undefined; // `this` must name the object under construction for the whole body. const constructing = selfLocal !== undefined && !selfLocal.boxed && !reassigns(fn.body, self!); - const stmt = (s: IrStmt): boolean => { - if (s.kind === "fieldSet") { - const family = finals.family(s.className, s.field); - if (family !== null && finals.isFinal(s.className, s.field)) { + eachNodeOfKinds(fn, scan, (node) => { + if (node.kind === "fieldSet") { + const family = finals.family(node.className, node.field); + if (family !== null && finals.isFinal(node.className, node.field)) { const own = - constructing && ctor !== null && inFamily(ctor, family) && isSelf(s.obj, self!); + constructing && ctor !== null && inFamily(ctor, family) && isSelf(node.obj, self!); if (!own) finals.drop(family, `written in ${fn.name}`); } - } - return everyStmtChild(s, expr, stmt); - }; - // `o.n++` updates a numeric field in place; reference fields never see it. - const expr = (e: IrExpr): boolean => { - if (e.kind === "fieldIncDec" && finals.isFinal(e.className, e.field)) - finals.drop(finals.family(e.className, e.field)!, `updated in ${fn.name}`); - return everyExprChild(e, expr, stmt); - }; - for (const s of fn.body) stmt(s); + } else if (node.kind === "fieldIncDec" && finals.isFinal(node.className, node.field)) + // `o.n++` updates a numeric field in place; reference fields never see it. + finals.drop(finals.family(node.className, node.field)!, `updated in ${fn.name}`); + }); } return finals; } diff --git a/packages/compiler/src/ir/function-references.ts b/packages/compiler/src/ir/function-references.ts index 0342172b6..db474e9bb 100644 --- a/packages/compiler/src/ir/function-references.ts +++ b/packages/compiler/src/ir/function-references.ts @@ -11,6 +11,9 @@ export function exprFunctionReference(expr: IrExpr): string | undefined { return undefined; } +/** The expression kinds exprFunctionReference answers for. */ +export const FUNCTION_REFERENCE_KINDS = ["closure", "orDefault"] as const; + /** Functions the module's metadata names: the entry, library exports and * the class prototype helpers the runtime calls. Class methods and * constructors follow the `%.` convention instead of being diff --git a/packages/compiler/src/ir/initializer-bindings.ts b/packages/compiler/src/ir/initializer-bindings.ts index cbda4241e..cb2cebc16 100644 --- a/packages/compiler/src/ir/initializer-bindings.ts +++ b/packages/compiler/src/ir/initializer-bindings.ts @@ -1,11 +1,27 @@ import type { IrExpr, IrFunction, IrGlobal, IrModule, IrStmt } from "./ir.js"; -import { everyExprChild, everyStmtChild, everyStmtList } from "./traverse.js"; +import { everyExprChild, everyStmtChild } from "./traverse.js"; +import { eachNodeOfKinds, type IrNodeIndex, type IrNodeKind } from "./node-index.js"; + +/** The nodes findInitializerBindings reads: binding uses and captures. */ +const USE_KINDS: readonly IrNodeKind[] = [ + "varRef", + "assignExpr", + "incDec", + "closure", + "assign", + "varDecl", + "forOf", + "tryCatch", +]; /** Globals private to a guarded, synchronous initializer have local numeric * dataflow: no other function can replace their slots, and reentrant module * evaluation returns before reaching them. Keep the actual global storage * and lifetime; these facts are only inputs to numeric and extent analysis. */ -export function findInitializerBindings(mod: IrModule): ReadonlyMap { +export function findInitializerBindings( + mod: IrModule, + nodes?: IrNodeIndex, +): ReadonlyMap { const result = new Map(); // Embedders can reset library state between entries. Async initialization // and lifted captures also remain outside this synchronous proof. @@ -17,6 +33,7 @@ export function findInitializerBindings(mod: IrModule): ReadonlyMap { - if (e.kind === "varRef") use(e.localId, false); - if (e.kind === "assignExpr" || e.kind === "incDec") use(e.localId, true); - if (e.kind === "closure") for (const id of e.captures) excluded.add(id); - return true; - }, - stmt: (s) => { - if (s.kind === "assign" || s.kind === "varDecl" || s.kind === "forOf") use(s.localId, true); - if (s.kind === "tryCatch" && s.catchLocalId) excluded.add(s.catchLocalId); - return true; - }, + eachNodeOfKinds(fn, scan, (node) => { + if (node.kind === "varRef") use(node.localId, false); + else if (node.kind === "assignExpr" || node.kind === "incDec") use(node.localId, true); + else if (node.kind === "closure") for (const id of node.captures) excluded.add(id); + else if (node.kind === "assign" || node.kind === "varDecl" || node.kind === "forOf") + use(node.localId, true); + else if (node.kind === "tryCatch" && node.catchLocalId) excluded.add(node.catchLocalId); }); } const guarded = new Set(); diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index df9e3c0a5..8b992e6a8 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -8,10 +8,20 @@ import { type IntegerRanges, type IntegerSlotFacts, } from "./integer-ranges.js"; -import { exprFunctionReference, moduleFunctionReferences } from "./function-references.js"; +import { + exprFunctionReference, + FUNCTION_REFERENCE_KINDS, + moduleFunctionReferences, +} from "./function-references.js"; +import { + eachNodeOfKinds, + type IrKindScan, + type IrNodeIndex, + type IrNodeKind, +} from "./node-index.js"; import { findInitializerBindings, withInitializerBindings } from "./initializer-bindings.js"; import { checkableInt32Allocations, int32ArrayStores } from "./int32-arrays.js"; -import { everyExprChild, everyStmtChild, everyStmtList, everyTypeChild } from "./traverse.js"; +import { everyExprChild, everyStmtChild, everyTypeChild } from "./traverse.js"; /** The storage slot an array-valued expression reads, as an * IrContracts.int32Elements key: a local or parameter @@ -467,10 +477,61 @@ const DYN_READ_ONLY = new Set([ "text.decodeStream", "worker.new", ]); +/** The node kinds each walk below reads. */ +const LIB_CALL_KINDS: readonly IrNodeKind[] = ["libCall"]; +const CALL_FACT_KINDS: readonly IrNodeKind[] = [ + ...FUNCTION_REFERENCE_KINDS, + "virtualCall", + "instanceOfValue", + "varRef", + "assignExpr", + "incDec", + "classRef", + "varDecl", + "assign", + "forOf", +]; +const CENSUS_KINDS: readonly IrNodeKind[] = [ + "call", + "new", + "varRef", + "fieldGet", + "fieldIncDec", + "closure", + "classRef", + "incDec", + "forOf", + "tryCatch", +]; +const DECLARATION_KINDS: readonly IrNodeKind[] = ["varDecl"]; +const CANDIDATE_WRITE_KINDS: readonly IrNodeKind[] = [ + "assign", + "varDecl", + "fieldSet", + "return", + "call", + "new", + "assignExpr", +]; +const CALL_KINDS: readonly IrNodeKind[] = ["call", "new"]; +const SLOT_WRITE_KINDS: readonly IrNodeKind[] = ["return", "call", "new"]; +const NARROW_WRITE_KINDS: readonly IrNodeKind[] = ["assign", "varDecl", "fieldSet", "assignExpr"]; + +/** Visits the nodes of `fn` in walk order; with `scan`, only those of its + * kinds, read from the module's node index (the visitors must ignore the + * other kinds). */ function walkFunction( fn: IrFunction, + scan: IrKindScan | undefined, visit: { expr?: (e: IrExpr) => void; stmt?: (s: IrStmt) => void }, ): void { + if (scan !== undefined) { + eachNodeOfKinds(fn, scan, (node, statement) => { + if (statement) visit.stmt?.(node as IrStmt); + else visit.expr?.(node as IrExpr); + }); + return; + } const expr = (e: IrExpr): boolean => { visit.expr?.(e); return everyExprChild(e, expr, stmt); @@ -498,6 +559,7 @@ export function dynamicFieldStores( mod: IrModule, records = new Map((mod.records ?? []).map((r) => [r.id, r])), unions = new Map((mod.unions ?? []).map((u) => [u.id, u])), + nodes?: IrNodeIndex, ): DynamicFieldStores { const dynTypes = new Map(); const dynNamed = new Map(); @@ -537,9 +599,10 @@ export function dynamicFieldStores( let dynWritesAll = false; // A store whose key is not a literal names every field: later stores add // nothing, so the walk stops there. + const scan = nodes?.scan(LIB_CALL_KINDS); for (const fn of mod.functions) { if (dynWritesAll) break; - walkFunction(fn, { + walkFunction(fn, scan, { expr: (e) => { if (dynWritesAll) return; if ( @@ -642,7 +705,7 @@ export function dynamicallyStoredFamilies( } }; for (const fn of mod.functions) - walkFunction(fn, { + walkFunction(fn, undefined, { expr: (e) => { const dynResult = DYN_KINDS.has(e.type.kind); const boundary = BOUNDARY_KINDS.has(e.kind); @@ -682,6 +745,7 @@ export function functionCallFacts( mod: IrModule, classes: ReadonlyMap, subtree: (className: string) => string[], + nodes?: IrNodeIndex, ): { valueEscaped: Set; virtualTargets: Set; @@ -716,7 +780,7 @@ export function functionCallFacts( const count = (map: Map, id: string): void => { map.set(id, (map.get(id) ?? 0) + 1); }; - walkFunction(fn, { + walkFunction(fn, nodes?.scan(CALL_FACT_KINDS), { expr: (e) => { const referenced = exprFunctionReference(e); if (referenced !== undefined) escaped.add(referenced); @@ -752,9 +816,12 @@ export function functionCallFacts( export interface Int32SlotInputs { dynamicStores?: DynamicFieldStores; initializerBindings?: ReadonlyMap; + /** The module's node index, read instead of walking the bodies. */ + nodes?: IrNodeIndex; } export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): Int32Slots { + const nodes = inputs.nodes; const classes = new Map((mod.classes ?? []).map((c) => [c.name, c])); const slots = new Int32Slots(classes); if (mod.lib) return slots; @@ -844,7 +911,7 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): /* ── functions: escapes, parameter and return candidates ── */ const functions = new Map(mod.functions.map((fn) => [fn.name, fn])); - const calls = functionCallFacts(mod, classes, subtree); + const calls = functionCallFacts(mod, classes, subtree, nodes); const escaped = new Set([...calls.valueEscaped, ...calls.virtualTargets]); const virtual = calls.runtimeVirtual; @@ -901,7 +968,7 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): for (const capture of fn.classCaptures ?? []) unstableBoxes.add(boxKey(fn.name, capture.localId)); const read = new Set(); - walkFunction(fn, { + walkFunction(fn, nodes?.scan(CENSUS_KINDS), { expr: (e) => { if (e.kind === "call") { called.add(e.callee); @@ -1001,7 +1068,7 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): let ids = initializedDeclarations.get(fn.name); if (ids === undefined) { const found = new Set(); - walkFunction(fn, { + walkFunction(fn, nodes?.scan(DECLARATION_KINDS), { stmt: (s) => { if (s.kind === "varDecl" && s.init) found.add(s.localId); }, @@ -1069,28 +1136,45 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): } return false; }; + const candidateWrites = nodes?.scan(CANDIDATE_WRITE_KINDS); const hasCandidateWrite = (fn: IrFunction): boolean => { if (arrayCandidates.has(fn.name)) return true; const box = (localId: string): boolean => slots.hasBox(slots.boxGroup(fn.name, localId)); const returns = slots.hasReturn(fn.name); - return !everyStmtList(fn.body, { - stmt: (s) => { - if (s.kind === "assign") return !box(s.localId); - if (s.kind === "varDecl") return s.init === null || !box(s.localId); - if (s.kind === "fieldSet") { - const family = slots.family(s.className, s.field); - return !family || !slots.hasFamily(family); + let found = false; + eachNodeOfKinds(fn, candidateWrites, (node) => { + switch (node.kind) { + case "assign": + found = box(node.localId); + break; + case "varDecl": + found = node.init !== null && box(node.localId); + break; + case "fieldSet": { + const family = slots.family(node.className, node.field); + found = !!family && slots.hasFamily(family); + break; } - return !(s.kind === "return" && returns); - }, - expr: (e) => { - if (e.kind === "call") return !candidateArgs(functions.get(e.callee), e.args.length, 0); - if (e.kind === "new") - return !candidateArgs(functions.get(`%${e.className}.constructor`), e.args.length, 1); - if (e.kind === "assignExpr") return !box(e.localId); - return true; - }, + case "return": + found = returns; + break; + case "call": + found = candidateArgs(functions.get(node.callee), node.args.length, 0); + break; + case "new": + found = candidateArgs( + functions.get(`%${node.className}.constructor`), + node.args.length, + 1, + ); + break; + case "assignExpr": + found = box(node.localId); + break; + } + return !found; }); + return found; }; const provenByFunction = new Map(); // Each function's parameter and result writes under its latest analysis: @@ -1107,7 +1191,7 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): const ranges: IntegerRanges = candidate ? analyzeIntegerRanges(numeric(fn), slots.facts(fn.name)) : new Map(); - if (ranges.size > 0) lastWrites.set(fn.name, slotWrites(fn, ranges, functions)); + if (ranges.size > 0) lastWrites.set(fn.name, slotWrites(fn, ranges, functions, nodes)); else lastWrites.delete(fn.name); const proven: IrExpr[] = []; // The walk below acts only at writes into candidate slots. @@ -1182,7 +1266,7 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): if (int32(value)) settle(`box:${group}`, value); else if (dropBox(group, `write in ${fn.name} is not int32`)) invalidate(`box:${group}`); }; - walkFunction(fn, { + walkFunction(fn, nodes?.scan(CANDIDATE_WRITE_KINDS), { stmt: (s) => { if (s.kind === "assign") boxWrite(s.localId, s.value); else if (s.kind === "varDecl" && s.init) boxWrite(s.localId, s.init); @@ -1218,10 +1302,23 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): for (const id of ids) if (slots.hasInt32ElementSlot(`l:${fnName}\0${id}`)) slots.int32Arrays.add(`${fnName}\0${id}`); - const ranged = rangedParameters(slots, functions, retry, numeric, (callee) => - readers.get(`ret:${callee}`), + const ranged = rangedParameters( + slots, + functions, + retry, + numeric, + (callee) => readers.get(`ret:${callee}`), + nodes, + ); + narrowSlotRanges( + slots, + functions, + numeric, + lastWrites, + ranged, + (slot) => readers.get(slot), + nodes, ); - narrowSlotRanges(slots, functions, numeric, lastWrites, ranged, (slot) => readers.get(slot)); return slots; } @@ -1256,6 +1353,7 @@ function rangedParameters( retry: ReadonlySet, numeric: (fn: IrFunction) => IrFunction, callersOf: (callee: string) => ReadonlySet | undefined, + nodes: IrNodeIndex | undefined, ): Set { const changed = new Set(); if (retry.size === 0) return changed; @@ -1325,7 +1423,7 @@ function rangedParameters( contribute(callee, param.localId, ranges.get(arg) ?? null); }); }; - walkFunction(fn, { + walkFunction(fn, nodes?.scan(CALL_KINDS), { expr: (e) => { if (e.kind === "call") args(functions.get(e.callee), e.args, 0); else if (e.kind === "new") args(functions.get(`%${e.className}.constructor`), e.args, 1); @@ -1371,6 +1469,7 @@ function slotWrites( fn: IrFunction, ranges: IntegerRanges, functions: ReadonlyMap, + nodes: IrNodeIndex | undefined, ): Map { const writes = new Map(); const write = (key: string, value: IrExpr | null): void => { @@ -1385,7 +1484,7 @@ function slotWrites( if (param) write(`param:${callee.name}\0${param.localId}`, arg); }); }; - walkFunction(fn, { + walkFunction(fn, nodes?.scan(SLOT_WRITE_KINDS), { stmt: (s) => { if (s.kind === "return") write(`ret:${fn.name}`, s.value); }, @@ -1428,6 +1527,7 @@ function narrowSlotRanges( lastWrites: ReadonlyMap>, changed: ReadonlySet, readersOf: (slot: string) => ReadonlySet | undefined, + nodes: IrNodeIndex | undefined, ): void { // Every parameter and result slot with a fact, and that fact. const proven = new Map(); @@ -1486,7 +1586,7 @@ function narrowSlotRanges( const level = slots.isNonNegative(`box:${group}`) ? NON_NEGATIVE_INT32_RANGE : INT32_RANGE; if (!rangeWithin(range, level)) sound = false; }; - walkFunction(fn, { + walkFunction(fn, nodes?.scan(NARROW_WRITE_KINDS), { stmt: (s) => { if (s.kind === "assign") box(s.localId, s.value); else if (s.kind === "varDecl" && s.init) box(s.localId, s.init); @@ -1497,7 +1597,7 @@ function narrowSlotRanges( if (e.kind === "assignExpr") box(e.localId, e.value); }, }); - return { writes: slotWrites(fn, ranges, functions), sound }; + return { writes: slotWrites(fn, ranges, functions, nodes), sound }; }; const writesByFunction = new Map(lastWrites); const join = (): Map => { diff --git a/packages/compiler/src/ir/node-index.ts b/packages/compiler/src/ir/node-index.ts new file mode 100644 index 000000000..2aac7df92 --- /dev/null +++ b/packages/compiler/src/ir/node-index.ts @@ -0,0 +1,214 @@ +/* One walk over a module's function bodies, recorded for analyses that + * only collect facts from nodes of a few kinds. Each such analysis used to + * walk every body itself; reading the recorded kind codes instead visits + * the same nodes in the same order without recursing through the tree. */ +import { InternalCompilerError } from "../errors.js"; +import type { IrExpr, IrFunction, IrModule, IrStmt, IrType } from "./ir.js"; +import { everyStmtList, everyTypeChild } from "./traverse.js"; + +/** An expression or statement kind. The two sets of kinds are disjoint. */ +export type IrNodeKind = IrExpr["kind"] | IrStmt["kind"]; + +/** The code of a kind no node has: it matches no recorded node. */ +const ABSENT = 255; + +/** Every expression and statement of each function body of a module, in + * the order everyStmtList visits them (a node before its children), with + * a kind code per node and, per function, the set of kinds present. + * + * The bodies must not change while the index is in use. Its owner (the + * LLVM emitter) builds it before its analyses run; no later pass rewrites + * a recorded body in place, and a rewritten copy of a function is walked. */ +export class IrNodeIndex { + /** Every type object the typed module traversal (everyModuleNode) + * reaches, nested ones included, each once. */ + readonly types: readonly IrType[]; + private readonly nodes: (IrExpr | IrStmt)[] = []; + private readonly codes: Uint8Array; + private readonly kindCodes = new Map(); + /** By function object: an analysis may see a rewritten copy of a + * function under the same name, whose nodes are not recorded here. */ + private readonly functionIndex = new Map(); + /** nodes[starts[i]] .. nodes[starts[i + 1] - 1] belong to function i. */ + private readonly starts: Int32Array; + /** Per function, eight words of kind-present bits. */ + private readonly present: Uint32Array; + /** By the kind list (or excluded set) a scan was made from. */ + private readonly scans = new Map(); + /** By kind code: 1 for statement kinds. */ + private readonly statements = new Uint8Array(256); + + constructor(mod: IrModule) { + const nodes = this.nodes; + const types: IrType[] = []; + const seenTypes = new Set(); + // A type object's children never change: one visit covers its tree. + const addType = (type: IrType): boolean => { + if (seenTypes.has(type)) return true; + seenTypes.add(type); + types.push(type); + return everyTypeChild(type, addType); + }; + const statementKinds = new Set(); + const visitor = { + expr: (node: IrExpr): boolean => { + nodes.push(node); + addType(node.type); + return true; + }, + stmt: (node: IrStmt): boolean => { + nodes.push(node); + statementKinds.add(node.kind); + return true; + }, + }; + const functions = mod.functions; + this.starts = new Int32Array(functions.length + 1); + for (let f = 0; f < functions.length; f++) { + this.functionIndex.set(functions[f]!, f); + this.starts[f] = nodes.length; + everyStmtList(functions[f]!.body, visitor); + } + this.starts[functions.length] = nodes.length; + // The other type slots everyModuleNode visits. + for (const fn of functions) { + for (const param of fn.params) addType(param.type); + addType(fn.returnType); + for (const local of fn.locals) addType(local.type); + for (const capture of fn.captures ?? []) addType(capture.type); + for (const capture of fn.classCaptures ?? []) addType(capture.type); + if (fn.generator !== undefined) { + addType(fn.generator.yieldT); + addType(fn.generator.nextT); + addType(fn.generator.resultType); + } + } + for (const cls of mod.classes ?? []) { + for (const capture of cls.localCaptures ?? []) addType(capture.type); + for (const field of cls.fields) addType(field.type); + } + for (const global of mod.globals ?? []) addType(global.type); + for (const record of mod.records ?? []) { + for (const field of record.fields) addType(field.type); + if (record.indexValue !== undefined) addType(record.indexValue); + } + for (const union of mod.unions ?? []) for (const arm of union.arms) addType(arm); + this.types = types; + const codes = new Uint8Array(nodes.length); + const present = new Uint32Array(functions.length * 8); + let previousKind = ""; + let previousCode = ABSENT; + for (let f = 0; f < functions.length; f++) { + const end = this.starts[f + 1]!; + for (let i = this.starts[f]!; i < end; i++) { + const kind = nodes[i]!.kind; + if (kind !== previousKind) { + let code = this.kindCodes.get(kind); + if (code === undefined) { + code = this.kindCodes.size; + if (code >= ABSENT) throw new InternalCompilerError("more IR node kinds than codes"); + this.kindCodes.set(kind, code); + } + previousKind = kind; + previousCode = code; + } + codes[i] = previousCode; + present[f * 8 + (previousCode >>> 5)]! |= 1 << (previousCode & 31); + } + } + this.codes = codes; + this.present = present; + for (const [kind, code] of this.kindCodes) + if (statementKinds.has(kind)) this.statements[code] = 1; + } + + /** 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 { + const known = this.scans.get(kinds); + if (known !== undefined) return known; + const codes: number[] = []; + for (const kind of kinds) { + const code = this.kindCodes.get(kind); + if (code !== undefined) codes.push(code); + } + return this.scanOf(kinds, codes); + } + + /** A scan for every expression. */ + expressions(): IrKindScan { + const known = this.scans.get(this.statements); + if (known !== undefined) return known; + const codes: number[] = []; + for (const code of this.kindCodes.values()) if (this.statements[code] === 0) codes.push(code); + return this.scanOf(this.statements, codes); + } + + /** A scan for the nodes of every kind but those in `excluded`. Pass a + * constant set: scans are kept by the set's identity. */ + scanExcept(excluded: ReadonlySet): IrKindScan { + const known = this.scans.get(excluded); + if (known !== undefined) return known; + const codes: number[] = []; + for (const [kind, code] of this.kindCodes) if (!excluded.has(kind)) codes.push(code); + return this.scanOf(excluded, codes); + } + + private scanOf(key: object, codes: readonly number[]): IrKindScan { + const table = new Uint8Array(256); + const words = new Uint32Array(8); + for (const code of codes) { + table[code] = 1; + words[code >>> 5]! |= 1 << (code & 31); + } + const scan = { index: this, table, words }; + this.scans.set(key, scan); + return scan; + } + + /** Calls `note` with each recorded node of `fn` whose kind `scan` names, + * in walk order, until it answers false. False (nothing called) when + * `fn` is not recorded. */ + visit(fn: IrFunction, scan: IrKindScan, note: IrNodeNote): boolean { + const f = this.functionIndex.get(fn); + if (f === undefined) return false; + let any = 0; + for (let w = 0; w < 8; w++) any |= this.present[f * 8 + w]! & scan.words[w]!; + if (any === 0) return true; + const { nodes, codes, statements } = this; + const table = scan.table; + const end = this.starts[f + 1]!; + for (let i = this.starts[f]!; i < end; i++) { + const code = codes[i]!; + if (table[code] !== 0 && note(nodes[i]!, statements[code] !== 0) === false) break; + } + return true; + } +} + +/** Receives a node and whether it is a statement; false stops the visit. */ +export type IrNodeNote = (node: IrExpr | IrStmt, statement: boolean) => boolean | void; + +/** The kinds one analysis reads, resolved against one index. */ +export interface IrKindScan { + readonly index: IrNodeIndex; + readonly table: Uint8Array; + readonly words: Uint32Array; +} + +/** Calls `note` for the nodes of `fn` in walk order (everyStmtList's) + * until it answers false: with `scan`, only those of its kinds, read from + * the index; without one (or for a function the index did not record), + * every node, by walking. `note` must ignore the kinds the scan does not + * name, so both answer the same. */ +export function eachNodeOfKinds( + fn: IrFunction, + scan: IrKindScan | undefined, + note: IrNodeNote, +): void { + if (scan !== undefined && scan.index.visit(fn, scan, note)) return; + everyStmtList(fn.body, { + expr: (node) => note(node, false) !== false, + stmt: (node) => note(node, true) !== false, + }); +} diff --git a/packages/compiler/src/ir/runtime-features.ts b/packages/compiler/src/ir/runtime-features.ts index 819cfbee0..bfd033f31 100644 --- a/packages/compiler/src/ir/runtime-features.ts +++ b/packages/compiler/src/ir/runtime-features.ts @@ -8,6 +8,7 @@ import { RUNTIME_STREAM_CLASSES, } from "./ir.js"; import { everyModuleNode } from "./traverse.js"; +import { eachNodeOfKinds, type IrNodeIndex } from "./node-index.js"; import { JsIcSites, hasJsIcSite } from "./js-ic.js"; const DYN_BUFFER_NUM_METHODS: ReadonlySet = new Set( @@ -400,8 +401,10 @@ export function isDynUndefined(e: IrExpr): boolean { return e.kind === "dynFrom" && e.value.kind === "unitLit" && e.value.unit === "undefined"; } -export function moduleRuntimeFeatures(mod: IrModule): RuntimeFeatures { - return scanRuntimeFeatures(mod); +/** With `nodes` (the module's node index), the scan reads the recorded + * expressions and types instead of walking the module. */ +export function moduleRuntimeFeatures(mod: IrModule, nodes?: IrNodeIndex): RuntimeFeatures { + return scanRuntimeFeatures(mod, undefined, nodes); } /** The module's record shapes, unions and class layouts by id. */ @@ -1126,7 +1129,11 @@ for (const name of RUNTIME_FEATURE_NAMES) { if (rule.expr) FEATURE_EXPR_TESTS.push(name); } -function scanRuntimeFeatures(mod: IrModule, stopAt?: RuntimeFeatureName): RuntimeFeatures { +function scanRuntimeFeatures( + mod: IrModule, + stopAt?: RuntimeFeatureName, + nodes?: IrNodeIndex, +): RuntimeFeatures { const features = noRuntimeFeatures(); const scans: { name: RuntimeFeatureName; scan: RuleScan }[] = []; for (const name of RUNTIME_FEATURE_NAMES) { @@ -1161,7 +1168,18 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: RuntimeFeatureName): Runtim for (const name of FEATURES_BY_TYPE.get(node.kind) ?? []) features[name] = true; return keepGoing(); }; - if (keepGoing()) everyModuleNode(mod, { expr, stmt: keepGoing, type }); + // Each condition is a fact about one expression or one type (the rule + // scans combine such facts in no particular order), so the index's + // expressions and distinct types answer as the walk does. An early stop + // (stopAt) keeps the walk. + if (nodes !== undefined && stopAt === undefined) { + const expressions = nodes.expressions(); + for (const fn of mod.functions) + eachNodeOfKinds(fn, expressions, (node) => { + expr(node as IrExpr); + }); + for (const t of nodes.types) type(t); + } else if (keepGoing()) everyModuleNode(mod, { expr, stmt: keepGoing, type }); for (const { name, scan } of scans) if (scan.result()) features[name] = true; // Inline-cache sites of dynamic-tier functions use the tier's shapes // (scr_js_object.c, with the SCR_JS_TIER build of scr_json.c). diff --git a/packages/compiler/test/node-index.test.ts b/packages/compiler/test/node-index.test.ts new file mode 100644 index 000000000..23fbc5203 --- /dev/null +++ b/packages/compiler/test/node-index.test.ts @@ -0,0 +1,157 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { beforeAll, expect, test } from "vitest"; +import { compile, deserializeModule } from "../src/index.js"; +import type { IrExpr, IrModule, IrStmt, IrType } from "../src/ir/ir.js"; +import { eachNodeOfKinds, IrNodeIndex, type IrNodeKind } from "../src/ir/node-index.js"; +import { everyModuleNode, everyStmtList } from "../src/ir/traverse.js"; + +async function lowerSource(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-node-index-")); + try { + const entry = join(dir, "main.ts"); + await writeFile(entry, source); + const outPath = join(dir, "main.ir.json"); + const result = await compile(entry, { outPath, outDir: dir, outputKind: "ir" }); + if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n")); + return deserializeModule(await readFile(outPath, "utf8")); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +let mod: IrModule; +let index: IrNodeIndex; + +beforeAll(async () => { + mod = await lowerSource(` +class Shape { + constructor(public readonly name: string, protected sides: number[]) {} + area(): number { return 0; } +} +class Square extends Shape { + constructor(private side: number) { super("square", [side, side, side, side]); } + override area(): number { return this.side * this.side; } +} +type Result = { ok: true; value: number } | { ok: false; error: string }; +function parse(text: string): Result { + const n = Number(text); + return Number.isNaN(n) ? { ok: false, error: "bad " + text } : { ok: true, value: n }; +} +function* count(limit: number): Generator { + for (let i = 0; i < limit; i++) yield i; +} +const totals = new Map(); +const seen = new Set(); +let calls = 0; +function record(key: string, value: number): void { + calls++; + totals.set(key, (totals.get(key) ?? 0) + value); + seen.add(key); +} +const adder = (k: number) => (x: number): number => x + k; +const inc = adder(1); +try { + for (const text of ["1", "x", "3"]) { + const r = parse(text); + if (r.ok) record("ok", inc(r.value)); + else throw new Error(r.error); + } +} catch (e) { + record("error", String(e).length); +} +let sum = 0; +for (const v of count(4)) sum += v; +const shapes: Shape[] = [new Square(2), new Shape("none", [])]; +console.log(sum, calls, [...totals], seen.size, shapes.map((s) => s.area()).join(",")); +`); + index = new IrNodeIndex(mod); +}); + +/** Every node everyStmtList visits in `body`, with whether it is a statement. */ +function walked(body: IrStmt[]): [IrExpr | IrStmt, boolean][] { + const out: [IrExpr | IrStmt, boolean][] = []; + everyStmtList(body, { + expr: (node) => (out.push([node, false]), true), + stmt: (node) => (out.push([node, true]), true), + }); + return out; +} + +function noted( + fn: IrModule["functions"][number], + scan: ReturnType | undefined, +): [IrExpr | IrStmt, boolean][] { + const out: [IrExpr | IrStmt, boolean][] = []; + eachNodeOfKinds(fn, scan, (node, statement) => { + out.push([node, statement]); + }); + return out; +} + +const NO_KINDS: ReadonlySet = new Set(); + +test("the index visits each body's nodes in everyStmtList's order", () => { + expect(mod.functions.length).toBeGreaterThan(5); + const all = index.scanExcept(NO_KINDS); + for (const fn of mod.functions) expect(noted(fn, all)).toEqual(walked(fn.body)); +}); + +const SOME_KINDS: readonly IrNodeKind[] = ["call", "varDecl", "closure", "return", "fieldGet"]; + +test("a scan visits only its kinds, in walk order, and the walk answers the same", () => { + const scan = index.scan(SOME_KINDS); + let visited = 0; + for (const fn of mod.functions) { + const expected = walked(fn.body).filter(([node]) => SOME_KINDS.includes(node.kind)); + expect(noted(fn, scan)).toEqual(expected); + // Without a scan, every node reaches `note`, as the walk visits them. + expect(noted(fn, undefined)).toEqual(walked(fn.body)); + visited += expected.length; + } + expect(visited).toBeGreaterThan(20); + expect(index.scan(SOME_KINDS)).toBe(scan); +}); + +test("a false answer stops the visit, with or without the index", () => { + const all = index.scanExcept(NO_KINDS); + for (const fn of mod.functions) { + for (const scan of [all, undefined]) { + let calls = 0; + eachNodeOfKinds(fn, scan, () => ++calls < 3); + expect(calls).toBe(Math.min(3, walked(fn.body).length)); + } + } +}); + +test("a function the index did not record is walked", () => { + const all = index.scanExcept(NO_KINDS); + for (const fn of mod.functions) { + const copy = { ...fn }; + expect(index.visit(copy, all, () => {})).toBe(false); + expect(noted(copy, all)).toEqual(walked(fn.body)); + } +}); + +test("the expression scan visits every expression", () => { + const expressions = index.expressions(); + for (const fn of mod.functions) + expect(noted(fn, expressions)).toEqual(walked(fn.body).filter(([, statement]) => !statement)); +}); + +test("the index's types are the type objects everyModuleNode visits, each once", () => { + const visited = new Set(); + everyModuleNode(mod, { + expr: () => true, + stmt: () => true, + type: (type) => (visited.add(type), true), + }); + expect(new Set(index.types)).toEqual(visited); + expect(index.types.length).toBe(visited.size); +}); + +test("a kind no node has matches nothing", () => { + const scan = index.scan(["regexLit"]); + for (const fn of mod.functions) expect(noted(fn, scan)).toEqual([]); +});