diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 354afa195..c14ae8722 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -17,6 +17,7 @@ import { import { youngNurseryRequested } from "../young-nursery.js"; import { llvmBytes as llBytes, wtf8Bytes } from "../literals.js"; import { InternalCompilerError } from "../../errors.js"; +import { serializeModuleParts } from "../../ir/serialize.js"; import { createHash } from "node:crypto"; import { endsWithJump, @@ -88,6 +89,8 @@ import { fillAllocationLength, int32ArrayKey, selectInt32Arrays, + type Int32ArrayProof, + type Int32ArrayRevision, } from "../../ir/int32-arrays.js"; import { everyExpr, everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse.js"; import { @@ -103,6 +106,7 @@ import { resolveSelfCaptures } from "../../ir/self-captures.js"; import { analyzeInt32Slots, dynamicFieldStores, + Int32RevisionConflict, type DynamicFieldStores, Int32Slots, } from "../../ir/int32-slots.js"; @@ -554,15 +558,60 @@ export interface Int32SlotAnalysis { readonly slots: Int32Slots; } -/** The int32 slot analysis of `mod`, with the inputs LlEmitter gives it. */ -function analyzeModuleInt32Slots(mod: IrModule): Int32SlotAnalysis { +/** The int32 slot analysis of `mod`, with the inputs LlEmitter gives it. + * With `revise`, the analysis revises the functions of candidates that drop + * (Int32SlotInputs.revise), and `mod` is the module it started from. */ +function analyzeModuleInt32Slots( + mod: IrModule, + revise?: (fnName: string, dropped: ReadonlySet) => IrFunction, +): Int32SlotAnalysis { const nodeIndex = new IrNodeIndex(mod); const initializerBindings = findInitializerBindings(mod, nodeIndex); const dynamicStores = dynamicFieldStores(mod, undefined, undefined, nodeIndex); - const slots = analyzeInt32Slots(mod, { dynamicStores, initializerBindings, nodes: nodeIndex }); + const slots = analyzeInt32Slots(mod, { + dynamicStores, + initializerBindings, + nodes: nodeIndex, + ...(revise === undefined ? {} : { revise }), + }); return { mod, nodeIndex, initializerBindings, dynamicStores, slots }; } +/** The candidates an analysis keeps: int32 arrays and checked ones. */ +function keptInt32Arrays(analysis: Int32SlotAnalysis): ReadonlySet { + return new Set([...analysis.slots.int32Arrays, ...analysis.slots.checkedInt32Arrays]); +} + +/** An analysis that revised functions (analyzeModuleInt32Slots' `revise`) + * as the analysis of `mod`, the module of the functions it ended with: the + * node index of `mod`, and its initializer bindings and dynamic stores, + * which must be the ones the analysis read. Null when they differ. */ +function revisedAnalysis(analysis: Int32SlotAnalysis, mod: IrModule): Int32SlotAnalysis | null { + const nodeIndex = new IrNodeIndex(mod); + const initializerBindings = findInitializerBindings(mod, nodeIndex); + const dynamicStores = dynamicFieldStores(mod, undefined, undefined, nodeIndex); + const read = analysis.initializerBindings; + if (initializerBindings.size !== read.size) return null; + for (const [name, globals] of initializerBindings) { + const other = read.get(name); + if (other?.length !== globals.length || globals.some((g, i) => other[i] !== g)) return null; + } + const stores = analysis.dynamicStores; + if ( + dynamicStores.all !== stores.all || + dynamicStores.names.size !== stores.names.size || + [...dynamicStores.names].some((name) => !stores.names.has(name)) + ) + return null; + return { + mod, + nodeIndex, + initializerBindings, + dynamicStores, + slots: analysis.slots, + }; +} + /** The functions whose calls the backend may emit as calls of their * result-slot entries (record-results.ts). */ function resultEntryTargets(mod: IrModule): ReadonlySet { @@ -577,57 +626,127 @@ function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter { // Facts are keyed by node identity: emit the same module whether it was // lowered in memory or read back from its serialized form. const mod = unshareNodes(input); - // Each round of selectInt32Arrays analyzes the module it marks; the - // emitter of the module it returns reuses that round's analysis. - const rounds: { last: Int32SlotAnalysis | null } = { last: null }; - const proven = (marked: IrModule): ReadonlySet => { - const analysis = analyzeModuleInt32Slots(marked); - rounds.last = analysis; - return new Set([...analysis.slots.int32Arrays, ...analysis.slots.checkedInt32Arrays]); + // Each proof selectInt32Arrays asks for analyzes a marked module; the + // emitter of the module it returns reuses the last one. A proof that + // revised functions analyzed the module of the functions it ended with. + const rounds: { + last: Int32SlotAnalysis | null; + functions: readonly IrFunction[] | null; + revising: boolean; + } = { last: null, functions: null, revising: true }; + const proven = (marked: IrModule, revise: Int32ArrayRevision | null): Int32ArrayProof | null => { + rounds.functions = null; + if (revise === null || !rounds.revising) { + rounds.last = analyzeModuleInt32Slots(marked); + return { kept: keptInt32Arrays(rounds.last), functions: null }; + } + const latest = new Map(); + try { + rounds.last = analyzeModuleInt32Slots(marked, (fnName, dropped) => { + const fn = revise(fnName, dropped); + latest.set(fnName, fn); + return fn; + }); + } catch (error) { + if (!(error instanceof Int32RevisionConflict)) throw error; + rounds.last = null; + return null; + } + if (latest.size === 0) return { kept: keptInt32Arrays(rounds.last), functions: null }; + rounds.functions = marked.functions.map((fn) => latest.get(fn.name) ?? fn); + return { kept: keptInt32Arrays(rounds.last), functions: rounds.functions }; }; // Keep source storage intact for debugger inspection in dev builds. - const emitted = - options.debugSources === undefined - ? selectInt32Arrays( - versionParameters( - addRecordResultEntries( - scalarizeLiteralResults( - scalarizeNumericRecords( - specializeCallbackCalls( - specializeNumericCalls( - pairRowSums( - inlineExpressionFunctions( - foldConstantGlobals( - copyStableCaptures( - resolveSelfCaptures( - hoistFinalFieldReads( - freezeInitOnlyGlobals( - holdCharLocalsAsUnits( - dropUnwrittenPropertyBags(runPairedWhileLoopsAsFor(mod)), - ), - ), - ), - ), + if (options.debugSources !== undefined) return new LlEmitter(mod, options, null, null); + const passed = versionParameters( + addRecordResultEntries( + scalarizeLiteralResults( + scalarizeNumericRecords( + specializeCallbackCalls( + specializeNumericCalls( + pairRowSums( + inlineExpressionFunctions( + foldConstantGlobals( + copyStableCaptures( + resolveSelfCaptures( + hoistFinalFieldReads( + freezeInitOnlyGlobals( + holdCharLocalsAsUnits( + dropUnwrittenPropertyBags(runPairedWhileLoopsAsFor(mod)), ), ), ), ), - mangleFunction, ), - mangleFunction, ), ), ), + mangleFunction, ), + mangleFunction, ), - proven, - resultEntryTargets, - ) - : mod; - const prepared = rounds.last; + ), + ), + ), + ); + let emitted = selectInt32Arrays(passed, proven, resultEntryTargets); + let prepared = rounds.last; + // A proof that revised functions analyzed the module of the functions it + // ended with, which is the selected module unless selection fell back to + // rounds: then the selected module was proved by its own round. + if (prepared !== null && rounds.functions !== null && prepared.mod !== emitted) { + const functions = rounds.functions; + prepared = + emitted !== passed && + emitted.functions.length === functions.length && + emitted.functions.every((fn, i) => fn === functions[i]) + ? revisedAnalysis(prepared, emitted) + : null; + if (prepared === null && emitted !== passed) { + rounds.revising = false; + emitted = selectInt32Arrays(passed, proven, resultEntryTargets); + prepared = rounds.last; + } + } + if (process.env["SCRIPTC_INT32_VERIFY"] === "1") + verifyInt32Selection(passed, emitted, prepared?.mod === emitted ? prepared : null); return new LlEmitter(emitted, options, null, prepared?.mod === emitted ? prepared : null); } +/** SCRIPTC_INT32_VERIFY=1: throws unless the selection with revisions + * (`emitted`, and the analysis the emitter takes over) is the one rounds + * without revisions give: the same module, and the same facts. */ +function verifyInt32Selection( + passed: IrModule, + emitted: IrModule, + analysis: Int32SlotAnalysis | null, +): void { + let last: Int32SlotAnalysis | null = null; + const reference = selectInt32Arrays( + passed, + (marked) => { + last = analyzeModuleInt32Slots(marked); + return { kept: keptInt32Arrays(last), functions: null }; + }, + resultEntryTargets, + ); + const text = (mod: IrModule): string => serializeModuleParts(mod).join(""); + if (text(reference) !== text(emitted)) + throw new InternalCompilerError("int32 selection: the revised module differs from the rounds'"); + const facts = (mod: IrModule, prepared: Int32SlotAnalysis | null): string => + (prepared?.mod === mod ? prepared : analyzeModuleInt32Slots(mod)).slots.summary(mod); + const expected = facts(reference, last); + const actual = facts(emitted, analysis); + if (expected !== actual) { + const want = expected.split("\n"); + const got = actual.split("\n"); + const line = want.findIndex((entry, i) => entry !== got[i]); + throw new InternalCompilerError( + `int32 selection: the revised analysis differs from the rounds': ${want[line]?.slice(0, 400)} != ${got[line]?.slice(0, 400)}`, + ); + } +} + /** Emits the module's parts, emitting it again with the functions an * UnwindAbiViolation names on the pending-flag ABI. */ function emitModuleText(input: IrModule, options: LlvmTargetOptions): string[] { @@ -1443,7 +1562,12 @@ export class LlEmitter { /** Preorder intervals of the runtime error classes under THIS module's * class-forest numbering (main() stamps scr_error_vts with them, exactly * like the runtime ABI’s errorVtStampLines). */ - readonly errorIntervals: { kind: number; pre: number; post: number; lib: string }[] = []; + readonly errorIntervals: { + kind: number; + pre: number; + post: number; + lib: string; + }[] = []; /** The runtime emitter vtable's preorder interval, when the program * touches node:events (the class def rides the module exactly then) — * main() stamps scr_emitter_vt with it (emitterVtStampLines, ported). */ @@ -1452,7 +1576,12 @@ export class LlEmitter { * ported): the defs ride every emitter-touching module (the frontend * collects the whole emitter-rooted tree), and scr_stream.c links on * the same predicate, so the stamps always have their globals. */ - readonly streamIntervals: { vt: string; pre: number; post: number; lib: string }[] = []; + readonly streamIntervals: { + vt: string; + pre: number; + post: number; + lib: string; + }[] = []; // ── per-function state (reset in emitFunction) ───────────────────────── B = new BlockBuilder(); @@ -1525,7 +1654,12 @@ export class LlEmitter { /** Active induction bindings retain their width independently of size_t. */ integerLoopBindings = new Map< string, - { slot: string; type: "i32" | "i64"; signed?: boolean; range?: { min: number; max: number } } + { + slot: string; + type: "i32" | "i64"; + signed?: boolean; + range?: { min: number; max: number }; + } >(); /** Constant number locals whose latest emitted declaration stored an * exact integer beside the double, such as `const j = i * 4` in a @@ -1608,7 +1742,11 @@ export class LlEmitter { * (the fast copy) or as double arrays (an ordinary copy entered only * after they widened), as `function\0local` keys, and where each of the * fast copy's writes resumes in the third copy. */ - int32Kinds: { int32: Set; wide: Set; resume: Map } | null = null; + int32Kinds: { + int32: Set; + wide: Set; + resume: Map; + } | null = null; /** The resume label of the fast-copy write being emitted. */ int32KindResume: string | null = null; /** The function being emitted, for speculative globals. */ @@ -1682,7 +1820,12 @@ export class LlEmitter { * releases frames/scopes down to the recorded depths and branches to * `label` (the catch, or the exception-path finally) instead of * returning out of the function. Entering a try emits no code. */ - tryStack: { label: string; used: boolean; frameDepth: number; scopeDepth: number }[] = []; + tryStack: { + label: string; + used: boolean; + frameDepth: number; + scopeDepth: number; + }[] = []; /** Return type of the function being emitted — the unwind path returns * a dummy of this type (never read: callers check the flag first). */ currentReturnType: IrType = VOID; @@ -7198,7 +7341,10 @@ export class LlEmitter { return childDataDynThunkFor(this, chunk); } - emitterFixedAdapter(cbT: IrType & { kind: "func" }): { fn: string; shim: string } { + emitterFixedAdapter(cbT: IrType & { kind: "func" }): { + fn: string; + shim: string; + } { return emitterFixedAdapter(this, cbT); } diff --git a/packages/compiler/src/ir/int32-arrays.ts b/packages/compiler/src/ir/int32-arrays.ts index 353755c05..0677b2ad1 100644 --- a/packages/compiler/src/ir/int32-arrays.ts +++ b/packages/compiler/src/ir/int32-arrays.ts @@ -805,6 +805,41 @@ function rewriteCandidates( return { ...fn, body, locals: locals.filter((local) => !popLocals.has(local.id)) }; } +/** A module marked for its int32 array candidates: the candidates of + * each function (int32ArrayCandidates) and the element readers the marking + * inlines. */ +interface Int32ArrayMarking { + readonly mod: IrModule; + readonly readers: ReadonlyMap; + readonly candidates: ReadonlyMap>; +} + +/** markInt32ArrayCandidates with the candidates it marked; null when it + * marks none. */ +function markCandidates( + mod: IrModule, + keep: ReadonlySet | null, + redirected: ReadonlySet, +): Int32ArrayMarking | null { + if (mod.lib !== undefined) return null; + const readers = new Map(); + for (const fn of mod.functions) if (isElementReader(fn)) readers.set(fn.name, fn); + const all = moduleInt32ArrayCandidates(mod, new Set(readers.keys()), keep, redirected); + if (all.size === 0) return null; + const keys = new Set(); + const functions = mod.functions.map((fn) => { + const candidates = all.get(fn.name); + if (!candidates) return fn; + for (const id of candidates) keys.add(int32ArrayKey(fn.name, id)); + return rewriteCandidates(fn, candidates, readers); + }); + return { + mod: { ...mod, functions, int32ArrayCandidates: [...keys] }, + readers, + candidates: all, + }; +} + /** Propose the module's int32 array candidates (int32ArrayKey keys in * `int32ArrayCandidates`), only those in `keep` when given, and rewrite * their functions. */ @@ -813,42 +848,126 @@ export function markInt32ArrayCandidates( keep: ReadonlySet | null = null, redirected: ReadonlySet = new Set(), ): IrModule { - if (mod.lib !== undefined) return mod; - const readers = new Map(); - for (const fn of mod.functions) if (isElementReader(fn)) readers.set(fn.name, fn); - const all = moduleInt32ArrayCandidates(mod, new Set(readers.keys()), keep, redirected); - if (all.size === 0) return mod; - const keys = new Set(); - const functions = mod.functions.map((fn) => { - const candidates = all.get(fn.name); - if (!candidates) return fn; - for (const id of candidates) keys.add(int32ArrayKey(fn.name, id)); - return rewriteCandidates(fn, candidates, readers); - }); - return { ...mod, functions, int32ArrayCandidates: [...keys] }; + return markCandidates(mod, keep, redirected)?.mod ?? mod; +} + +/** Revises a function of a module marked for int32 array candidates when + * some of them drop: the function marked for its candidates outside + * `dropped` (int32ArrayKey keys), which is the unmarked function when none + * is left. */ +export type Int32ArrayRevision = (fnName: string, dropped: ReadonlySet) => IrFunction; + +/** What a proof of a marked module (selectInt32Arrays' `prove`) keeps. */ +export interface Int32ArrayProof { + /** The candidates it keeps. */ + readonly kept: ReadonlySet; + /** The functions of the module it proved, in module order, when it + * revised some through the Int32ArrayRevision it was given; otherwise + * null, for the marked module's own. */ + readonly functions: readonly IrFunction[] | null; } /** The module the backend emits: rewritten for exactly the candidates the - * int32 slot analysis (`prove`, returning its int32Arrays) keeps, so a - * module without int32 arrays is `mod` itself. A dropped candidate's - * rewrite is undone by marking again without it, until every candidate - * marked is kept. `redirected` names the functions some of whose calls the + * int32 slot analysis (`prove`) keeps, so a module without int32 arrays is + * `mod` itself. `redirected` names the functions some of whose calls the * backend emits as calls of another function (a result-slot entry): their - * parameters keep doubles. */ + * parameters keep doubles. + * + * The proof of the marked module gets an Int32ArrayRevision: as candidates + * drop, it revises their functions and goes on proving the revised module, + * so one analysis ends with the module marked for the candidates it keeps. + * That module is the answer when marking for those candidates again proposes + * exactly them, in the functions the proof revised. A proof that cannot + * follow a revision answers null; then each round marks the module, proves + * it without revisions, and marks again without the dropped candidates, + * until a round keeps every candidate it marks (four rounds at most). */ export function selectInt32Arrays( mod: IrModule, - prove: (marked: IrModule) => ReadonlySet, + prove: (marked: IrModule, revise: Int32ArrayRevision | null) => Int32ArrayProof | null, redirected: (mod: IrModule) => ReadonlySet = () => new Set(), ): IrModule { - let keep: ReadonlySet | null = null; const others = redirected(mod); - for (let round = 0; round < 4; round++) { + const marking = markCandidates(mod, null, others); + if (marking === null) return mod; + const original = new Map(mod.functions.map((fn) => [fn.name, fn])); + const markedFunctions = new Map(marking.mod.functions.map((fn) => [fn.name, fn])); + // Each function's latest revision and the candidates it is marked for. + const revisions = new Map }>(); + const revise: Int32ArrayRevision = (fnName, dropped) => { + const candidates = marking.candidates.get(fnName) ?? new Set(); + const ids = new Set(); + for (const id of candidates) if (!dropped.has(int32ArrayKey(fnName, id))) ids.add(id); + const latest = revisions.get(fnName); + if (latest !== undefined && latest.ids.size === ids.size) return latest.fn; + if (latest === undefined && ids.size === candidates.size) return markedFunctions.get(fnName)!; + const fn = + ids.size === 0 + ? original.get(fnName)! + : rewriteCandidates(original.get(fnName)!, ids, marking.readers); + revisions.set(fnName, { fn, ids }); + return fn; + }; + const proof = prove(marking.mod, revise); + const proposed = marking.mod.int32ArrayCandidates ?? []; + let keep: ReadonlySet | null = null; + let round = 0; + if (proof !== null && proof.functions === null) { + // The marked module itself was proved: the first round. + if (proposed.every((key) => proof.kept.has(key))) return marking.mod; + keep = new Set(proposed.filter((key) => proof.kept.has(key))); + round = 1; + } else if (proof !== null) { + const settled = settleRevisions(mod, marking, proof, revisions, others); + if (settled !== null) return settled; + } + for (; round < 4; round++) { const marked = markInt32ArrayCandidates(mod, keep, others); if (marked === mod) return mod; - const kept = prove(marked); - const proposed = marked.int32ArrayCandidates ?? []; - if (proposed.every((key) => kept.has(key))) return marked; - keep = new Set(proposed.filter((key) => kept.has(key))); + const kept = prove(marked, null)!.kept; + const roundProposed = marked.int32ArrayCandidates ?? []; + if (roundProposed.every((key) => kept.has(key))) return marked; + keep = new Set(roundProposed.filter((key) => kept.has(key))); } return mod; } + +/** The module marked for the candidates a revising proof kept, built from + * the functions it proved: null unless marking `mod` for those candidates + * proposes, in each function, exactly the candidates its proved function is + * marked for. `mod` itself when it proposes none. */ +function settleRevisions( + mod: IrModule, + marking: Int32ArrayMarking, + proof: Int32ArrayProof, + revisions: ReadonlyMap }>, + redirected: ReadonlySet, +): IrModule | null { + const kept = proof.kept; + const functions = proof.functions!; + if (functions.length !== mod.functions.length) return null; + const all = moduleInt32ArrayCandidates( + mod, + new Set(marking.readers.keys()), + new Set([...(marking.mod.int32ArrayCandidates ?? [])].filter((key) => kept.has(key))), + redirected, + ); + const keys = new Set(); + for (let i = 0; i < mod.functions.length; i++) { + const fn = mod.functions[i]!; + const proved = functions[i]!; + const candidates = all.get(fn.name); + const revision = revisions.get(fn.name); + const marked = revision?.ids ?? marking.candidates.get(fn.name) ?? new Set(); + if ((candidates?.size ?? 0) !== marked.size) return null; + if (candidates !== undefined) + for (const id of candidates) { + if (!marked.has(id)) return null; + keys.add(int32ArrayKey(fn.name, id)); + } + const expected = + revision?.fn ?? (marking.candidates.has(fn.name) ? marking.mod.functions[i]! : fn); + if (proved !== expected) return null; + } + if (all.size === 0) return mod; + return { ...mod, functions: [...functions], int32ArrayCandidates: [...keys] }; +} diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index 35207f79b..79702ce97 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -27,7 +27,7 @@ import { int32ArraySlot, int32ArrayStores, } from "./int32-arrays.js"; -import { everyExprChild, everyStmtChild, everyTypeChild } from "./traverse.js"; +import { everyExprChild, everyStmtChild, everyStmtList, everyTypeChild } from "./traverse.js"; /** The storage slot an array-valued expression reads, as an * IrContracts.int32Elements key: a local or parameter @@ -406,6 +406,47 @@ export class Int32Slots { for (const key of data.checkedInt32Arrays) slots.checkedInt32Arrays.add(key); return slots; } + + /** Every fact, in a canonical order, with each proven write as its + * position in `mod` (function index, then walk order): two analyses of + * equal modules hold the same facts when their summaries are equal. */ + summary(mod: IrModule): string { + const sorted = (values: Iterable): string => [...values].sort().join(","); + const lines = [ + `fields ${sorted(this.fields)}`, + `params ${sorted([...this.paramFacts].flatMap(([fn, params]) => [...params].map(([id, r]) => `${fn}\0${id}=${r.min}:${r.max}`)))}`, + `returns ${sorted(this.returns)}`, + `returnRanges ${sorted([...this.returnRanges].map(([fn, r]) => `${fn}=${r.min}:${r.max}`))}`, + `boxes ${sorted(this.boxes)}`, + `boxGroups ${sorted([...this.boxGroupOf].map(([member, group]) => `${member}=${group}`))}`, + `called ${sorted(this.called)}`, + `pinned ${sorted(this.pinned)}`, + `int32Locals ${sorted(this.int32Locals)}`, + `int32Elements ${sorted(this.int32Elements)}`, + `nonNegative ${sorted(this.nonNegative)}`, + `pinViolations ${sorted(this.pinViolations)}`, + `narrowedReads ${this.narrowedReads}`, + `int32Arrays ${sorted(this.int32Arrays)}`, + `checkedInt32Arrays ${sorted(this.checkedInt32Arrays)}`, + ]; + const positions: string[] = []; + mod.functions.forEach((fn, f) => { + let at = 0; + everyStmtList(fn.body, { + expr: (e) => { + if (this.provenWrites.has(e)) positions.push(`${f}:${at}`); + at++; + return true; + }, + stmt: () => { + at++; + return true; + }, + }); + }); + lines.push(`provenWrites ${this.provenWrites.size} ${positions.join(",")}`); + return lines.join("\n"); + } } /** Int32Slots as plain data (Int32Slots.encode). */ @@ -909,6 +950,95 @@ export interface Int32SlotInputs { initializerBindings?: ReadonlyMap; /** The module's node index, read instead of walking the bodies. */ nodes?: IrNodeIndex; + /** Revises a function of a module marked for int32 array candidates when + * candidates drop (int32-arrays.ts Int32ArrayRevision): the function + * marked without the `dropped` candidates. With it, a candidate that drops + * (and keeps no checked stores) takes its sites back to the element-reader + * calls the marking inlined, and the fixpoint goes on over the revised + * functions, so its result is the analysis of the module marked for the + * candidates it keeps. A revision that changes more of a function than + * those sites throws Int32RevisionConflict. */ + revise?: (fnName: string, dropped: ReadonlySet) => IrFunction; +} + +/** A revision (Int32SlotInputs.revise) changed a function in a way the + * fixpoint cannot follow: a call of a function no other call reaches, or + * any change besides element-reader calls taking the place of their + * inlined reads. The module needs an analysis of its own. */ +export class Int32RevisionConflict extends Error {} + +/** What the analysis's setup reads from a function body, besides direct + * calls: a revision must leave all of it unchanged. */ +function revisionStructure(fn: IrFunction): { + calls: { callee: string; args: number }[]; + rest: string; +} { + const calls: { callee: string; args: number }[] = []; + const rest: string[] = []; + const boxed = new Set(fn.locals.filter((local) => local.boxed).map((local) => local.id)); + const expr = (e: IrExpr): boolean => { + const referenced = exprFunctionReference(e); + if (referenced !== undefined) rest.push(`ref:${referenced}`); + switch (e.kind) { + case "call": + calls.push({ callee: e.callee, args: e.args.length }); + break; + case "new": + rest.push(`new:${e.className}:${e.args.length}`); + break; + case "varRef": + if (boxed.has(e.localId)) rest.push(`read:${e.localId}`); + break; + case "assignExpr": + case "incDec": + rest.push(`${e.kind}:${e.localId}`); + break; + case "fieldGet": + case "fieldIncDec": + rest.push(`${e.kind}:${e.className}\0${e.field}`); + break; + case "closure": + rest.push(`closure:${e.fnName}:${e.captures.join(",")}`); + break; + case "classRef": + rest.push(`classRef:${e.className}:${(e.captures ?? []).join(",")}`); + break; + case "virtualCall": + rest.push(`virtualCall:${e.className}.${e.method}`); + break; + case "instanceOfValue": + rest.push(`instanceOf:${e.classValue.kind}`); + break; + } + if (BOUNDARY_KINDS.has(e.kind)) rest.push(`boundary:${e.kind}`); + return everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => { + switch (s.kind) { + case "forOf": + rest.push(`forOf:${s.localId}`); + break; + case "tryCatch": + if (s.catchLocalId) rest.push(`catch:${s.catchLocalId}`); + break; + case "assign": + case "varDecl": + if (boxed.has(s.localId)) rest.push(`${s.kind}:${s.localId}`); + break; + case "throw": + rest.push("throw"); + break; + } + return everyStmtChild(s, expr, stmt); + }; + for (const s of fn.body) stmt(s); + rest.sort(); + for (const local of fn.locals) + if (local.boxed || local.tdz) + rest.push(`local:${local.id}:${local.boxed === true}:${local.tdz === true}`); + for (const capture of fn.captures ?? []) rest.push(`capture:${capture.localId}`); + for (const capture of fn.classCaptures ?? []) rest.push(`classCapture:${capture.localId}`); + return { calls, rest: rest.join("\n") }; } export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): Int32Slots { @@ -1280,6 +1410,36 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): }); return found; }; + // Candidates dropped without checked stores, and the revision of the + // functions that hold them (Int32SlotInputs.revise). + const dropped = new Set(); + const revise = (names: ReadonlySet): void => { + for (const name of names) { + const previous = functions.get(name)!; + const next = inputs.revise!(name, dropped); + if (next === previous) continue; + const before = revisionStructure(previous); + const after = revisionStructure(next); + if (before.rest !== after.rest) throw new Int32RevisionConflict(name); + // The revision adds the element-reader calls back: each must be of a + // function other calls already reach, with its arity. + const counts = new Map(); + for (const { callee } of before.calls) counts.set(callee, (counts.get(callee) ?? 0) + 1); + for (const { callee, args } of after.calls) { + const left = counts.get(callee) ?? 0; + if (left > 0) { + counts.set(callee, left - 1); + continue; + } + const target = functions.get(callee); + if (!called.has(callee) || target === undefined || target.params.length !== args) + throw new Int32RevisionConflict(name); + reads(`ret:${callee}`, name); + } + for (const left of counts.values()) if (left > 0) throw new Int32RevisionConflict(name); + functions.set(name, next); + } + }; const provenByFunction = new Map(); // Each function's parameter and result writes under its latest analysis: // at the fixpoint, the ranges of every write under the final facts. @@ -1324,6 +1484,8 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): for (const id of globalArrays) both.add(id); arrays = both; } + // Functions whose candidates this visit drops, revised after it. + const revised = new Set(); if (arrays && arrayGroups) { const requeue = (name: string): void => { if (queued.has(name)) return; @@ -1358,9 +1520,39 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): checkableInt32Allocations(fn, id) ) slots.checkedInt32Arrays.add(key); - else for (const member of group) reason(`array:${member}`, "an element store is not int32"); + else { + for (const member of group) reason(`array:${member}`, "an element store is not int32"); + if (inputs.revise !== undefined) { + // The revised module no longer marks the group: its functions + // leave the candidate sets the visits below consult. + for (const member of group) { + dropped.add(member); + if (member.startsWith("\0")) { + globalArrays.delete(member.slice(1)); + const names = arrayGroups.globalReaders.get(member); + if (names) for (const name of names) revised.add(name); + } else { + const cut = member.indexOf("\0"); + const owner = member.slice(0, cut); + const ids = arrayCandidates.get(owner); + ids?.delete(member.slice(cut + 1)); + if (ids?.size === 0) arrayCandidates.delete(owner); + revised.add(owner); + } + } + globalArrayFunctions.clear(); + for (const [member, names] of arrayGroups.globalReaders) + if (!dropped.has(member)) for (const name of names) globalArrayFunctions.add(name); + } + } } } + if (revised.size > 0) { + revise(revised); + // A revised body is analyzed again (it was queued with its readers); + // the writes of the body it replaces no longer count. + if (functions.get(fn.name) !== fn) continue; + } // An int32 written into a slot still assumed non-negative: widen the // assumption when the value may be negative, and reanalyze its readers. const settle = (slot: string, e: IrExpr): void => { diff --git a/packages/compiler/test/int32-element-array-emission.test.ts b/packages/compiler/test/int32-element-array-emission.test.ts index 605f64e04..fccf06973 100644 --- a/packages/compiler/test/int32-element-array-emission.test.ts +++ b/packages/compiler/test/int32-element-array-emission.test.ts @@ -3,8 +3,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, test } from "vitest"; import { compile, deserializeModule } from "../src/index.js"; -import type { IrModule } from "../src/ir/ir.js"; +import type { IrFunction, IrModule } from "../src/ir/ir.js"; import { markInt32ArrayCandidates, selectInt32Arrays } from "../src/ir/int32-arrays.js"; +import { analyzeInt32Slots, type Int32Slots } from "../src/ir/int32-slots.js"; +import { serializeModule } from "../src/ir/serialize.js"; async function emitLlvm(source: string): Promise { const dir = await mkdtemp(join(tmpdir(), "scriptc-int32-elements-")); @@ -210,12 +212,12 @@ test("only the candidates the proof keeps are rewritten", async () => { // An array that reaches `join` is never proposed. expect(proposed.some((key) => key.startsWith("joined\0"))).toBe(false); // Without a kept candidate, the module is the input itself. - expect(selectInt32Arrays(mod, () => new Set())).toBe(mod); + expect(selectInt32Arrays(mod, () => ({ kept: new Set(), functions: null }))).toBe(mod); const kept = new Set(["compose\0p.0", "compose\0q.0"]); - const selected = selectInt32Arrays( - mod, - (marked) => new Set((marked.int32ArrayCandidates ?? []).filter((key) => kept.has(key))), - ); + const selected = selectInt32Arrays(mod, (marked) => ({ + kept: new Set((marked.int32ArrayCandidates ?? []).filter((key) => kept.has(key))), + functions: null, + })); expect(selected.int32ArrayCandidates).toEqual([...kept]); for (const fn of selected.functions) if (fn.name !== "compose") expect(fn).toBe(mod.functions.find((f) => f.name === fn.name)); @@ -259,3 +261,47 @@ test("arrays passed to helpers and held in module globals get 4-byte slots", asy expect(functions(llvm, "mean")).not.toMatch(/getelementptr inbounds i32, ptr/); expect(llvm).toMatch(/@scr_arr_new(_small)?\(i32 7/); }); + +/** The module selectInt32Arrays selects, the analysis of its last proof, + * and how many proofs it asked for: with `revising`, each proof revises + * the functions of candidates that drop. */ +function selection( + mod: IrModule, + revising: boolean, +): { selected: IrModule; slots: Int32Slots; proofs: number } { + let slots: Int32Slots | null = null; + let proofs = 0; + const selected = selectInt32Arrays(mod, (marked, revise) => { + proofs++; + const latest = new Map(); + slots = analyzeInt32Slots( + marked, + revising && revise !== null + ? { + revise: (fnName, dropped) => { + const fn = revise(fnName, dropped); + latest.set(fnName, fn); + return fn; + }, + } + : {}, + ); + const kept = new Set([...slots.int32Arrays, ...slots.checkedInt32Arrays]); + if (latest.size === 0) return { kept, functions: null }; + return { kept, functions: marked.functions.map((fn) => latest.get(fn.name) ?? fn) }; + }); + return { selected, slots: slots!, proofs }; +} + +test("one proof that revises dropped candidates selects what rounds of proofs select", async () => { + for (const program of [source, sharedSource]) { + const mod = await lowerIr(program); + const rounds = selection(mod, false); + const revised = selection(mod, true); + // The rounds drop a candidate and prove the module again. + expect(rounds.proofs).toBeGreaterThan(1); + expect(revised.proofs).toBe(1); + expect(serializeModule(revised.selected, true)).toBe(serializeModule(rounds.selected, true)); + expect(revised.slots.summary(revised.selected)).toBe(rounds.slots.summary(rounds.selected)); + } +});