From df02c7240752a909eef012363e3714fc9889a814 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 24 Oct 2026 21:59:19 -0700 Subject: [PATCH] Analyze init factories once per call site Static-object specialization's stage 3 prerequisite (DESIGN-constant-objects.md): a function called from module initialization (top-level code outside loops, or another such clone) is analyzed once per call site, up to 64 sites per function and 4 nested clones deep. The objects and arrays it creates are one allocation per call site, and each clone runs at most once, so they are singletons: the schemas zod's and valibot's builders create at init become constant objects instead of "init-factory" near misses. Stage 1 report (SCRIPTC_DEBUG_SHAPES=const): - zod 3.25 (z.object bench): constant objects 7 -> 48 (fields 57 -> 155), init-factory near misses 12 -> 1, foldable reads 22 -> 72. - valibot 1.1: constant objects 2 -> 5, init-factory 6 -> 0. The overlay's annotations and the zod and namespace benchmarks are unchanged. The overlay counts union arms rather than atoms, so the per-site allocations of one record shape stay one typedef. SCRIPTC_SHAPE_SITE_CLONES sets the per-function limit (0: off). Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/shape-inference/analyze.ts | 109 +++++++++++++++--- .../shape-inference/constant-graphs.ts | 39 ++++++- .../src/frontend/shape-inference/overlay.ts | 8 +- .../src/frontend/shape-inference/report.ts | 47 ++++++-- .../shape-inference/static-objects.ts | 28 ++++- .../compiler/test/constant-graphs.test.ts | 7 +- 6 files changed, 198 insertions(+), 40 deletions(-) diff --git a/packages/compiler/src/frontend/shape-inference/analyze.ts b/packages/compiler/src/frontend/shape-inference/analyze.ts index 4c4513929a..d783a2ad76 100644 --- a/packages/compiler/src/frontend/shape-inference/analyze.ts +++ b/packages/compiler/src/frontend/shape-inference/analyze.ts @@ -86,6 +86,8 @@ export interface LiteralSite { readonly hasComputedKey: boolean; readonly spreads: number[]; readonly inPackage: boolean; + /** The per-call-site clone that allocates it ("" outside clones). */ + readonly ctx: string; } export interface Op { @@ -263,6 +265,11 @@ const PRIMITIVE_MEMBERS: Readonly(); - edgeFor(site: Node, callee: FunctionInfo): void { - const ctx = this.siteCtx.get(site); + edgeFor(site: Node, callee: FunctionInfo, args?: number[]): void { + const ctx = + (args !== undefined ? this.argsFrom.get(args) : undefined) ?? this.siteCtx.get(site); this.calls.push({ caller: ctx?.caller ?? null, callee, @@ -1250,7 +1258,9 @@ class Analyzer { spread: boolean, site: Node, ): void { - const key = `ctor:${this.nodeKey(decl)}:${instance.id}:${this.nodeKey(site)}`; + // The arguments' nodes too: one `new` site walked in several contexts + // (receiver classes, per-site clones) passes each context's values. + const key = `ctor:${this.nodeKey(decl)}:${instance.id}:${this.nodeKey(site)}:${args.join(",")}`; if (this.boundCalls.has(key)) return; this.boundCalls.add(key); const entry = this.ensureClass(decl); @@ -1263,7 +1273,7 @@ class Analyzer { this.activate(info); this.add(info.thisNode, instance); this.bindArgs(info, args, spread); - this.edgeFor(site, info); + this.edgeFor(site, info, args); return; } const base = this.baseClass(decl); @@ -1299,7 +1309,7 @@ class Analyzer { /** Call of a function atom with a receiver node. */ callFunction( - info: FunctionInfo, + callee: FunctionInfo, thisNode: number | null, args: number[], spread: boolean, @@ -1308,7 +1318,7 @@ class Analyzer { ): void { // `() => Object.create(null)` helpers: each call site gets its own // dictionary, as if the helper were inlined. - if (this.isFactory(info.node)) { + if (this.isFactory(callee.node)) { const d = this.atoms.intern(`dict:${this.nodeKey(site)}`, () => ({ kind: "dictionary", label: `Object.create@${locLabel(site)}`, @@ -1317,6 +1327,7 @@ class Analyzer { this.add(dst, d); return; } + const info = this.siteClone(callee, site, args) ?? callee; this.activate(info); const fnNode = info.node; const key = `call:${info.ctx}|${this.nodeKey(fnNode)}:${this.nodeKey(site)}:${thisNode ?? -1}:${dst}`; @@ -1331,7 +1342,47 @@ class Analyzer { } else if (info.isGenerator) { this.add(dst, this.top); } else this.edge(info.ret, dst); - this.edgeFor(site, info); + this.edgeFor(site, info, args); + } + + /** Per-call-site clones of init factories: a function called from + * module initialization (top-level code outside loops, or another such + * clone) is analyzed once per call site, so the objects it creates are + * one allocation per site (`z.string()` and `z.object(…)` schemas, + * valibot's schema literals) instead of one per function. Clones run + * at most once, which makes their objects singletons. */ + private readonly siteClones = new Map(); + /** The site-clone context a literal at `node` is allocated in, or "". */ + private siteCloneCtx(node: Node): string { + const root = this.ctxRoot; + if (!this.ctx.includes("@S") || root === null) return ""; + const inside = + node.getSourceFile() === root.getSourceFile() && node.pos >= root.pos && node.end <= root.end; + return inside ? this.ctx : ""; + } + private siteClone(info: FunctionInfo, site: Node, args: number[]): FunctionInfo | null { + if (SITE_CLONES === 0 || info.isGenerator || ts.isSourceFile(info.node)) return null; + if (ts.isClassLikeDeclaration(info.node)) return null; + const sc = this.argsFrom.get(args); + if (sc === undefined || sc.inLoop || this.forceLoop || this.repeatingSites.has(site)) + return null; + const caller = sc.caller; + if (caller === null) return null; + // The clone itself, not a closure created inside it (which inherits + // its context but may run any number of times). + const callerInit = + ts.isSourceFile(caller.node) || (caller.ctx.includes("@S") && caller.root === caller.node); + if (!callerInit) return null; + // A clone inside a clone keys on the whole chain of sites. + const chain = caller.ctx.includes("@S") ? caller.ctx.slice(caller.ctx.indexOf("@S")) : ""; + if (chain.split("@S").length > SITE_CLONE_DEPTH) return null; + const ctx = `${info.ctx}${chain}@S${this.nodeKey(site)}`; + const existing = this.functions.get(`${ctx}|${this.nodeKey(info.node)}`); + if (existing !== undefined) return existing; + const count = this.siteClones.get(info.node) ?? 0; + if (count >= SITE_CLONES) return null; + this.siteClones.set(info.node, count + 1); + return this.functionInfo(info.node, info.ownerClass, ctx); } private readonly factories = new Map(); @@ -1982,11 +2033,11 @@ class Analyzer { hasProto = true; if (!keys.includes(key)) keys.push(key); } - const atom = this.atoms.intern(`obj:${this.nodeKey(e)}`, () => ({ - kind: "object", - label: label(), - node: e, - })); + const sctx = this.siteCloneCtx(e); + const atom = this.atoms.intern( + sctx === "" ? `obj:${this.nodeKey(e)}` : `obj:${sctx}|${this.nodeKey(e)}`, + () => ({ kind: "object", label: label(), node: e, ...(sctx === "" ? {} : { ctx: sctx }) }), + ); const n = this.newNode(); this.add(n, atom); for (const p of list(e.properties)) { @@ -2036,6 +2087,7 @@ class Analyzer { hasComputedKey, spreads, inPackage, + ctx: sctx, }); return n; } @@ -2174,11 +2226,16 @@ class Analyzer { } arrayLiteral(e: ts.ArrayLiteralExpression): number { - const atom = this.atoms.intern(`arr:${this.nodeKey(e)}`, () => ({ - kind: "array", - label: `[]@${locLabel(e)}`, - node: e, - })); + const sctx = this.siteCloneCtx(e); + const atom = this.atoms.intern( + sctx === "" ? `arr:${this.nodeKey(e)}` : `arr:${sctx}|${this.nodeKey(e)}`, + () => ({ + kind: "array", + label: `[]@${locLabel(e)}`, + node: e, + ...(sctx === "" ? {} : { ctx: sctx }), + }), + ); const n = this.newNode(); this.add(n, atom); const el = this.element(atom); @@ -2203,6 +2260,7 @@ class Analyzer { hasComputedKey: false, spreads: [], inPackage: isPackageJsFile(e.getSourceFile()), + ctx: sctx, }); return n; } @@ -2701,9 +2759,24 @@ class Analyzer { } out.push(this.expr(a)); } + // The walk context of this call (one argument list per call site and + // context): calls fire during solving, after the walk moved on. + this.argsFrom.set(out, { + caller: this.currentFn, + inLoop: this.loopDepth > 0 || this.forceLoop, + }); return { args: out, spread }; } + /** Per argument list (args()), the calling function and whether the call + * is in a loop, in the context the call was walked in. siteCtx keeps + * the first context per syntax node, which a site walked in several + * contexts (receiver classes, per-site clones) does not determine. */ + private readonly argsFrom = new WeakMap< + number[], + { caller: FunctionInfo | null; inLoop: boolean } + >(); + call(e: ts.CallExpression): number { this.noteSite(e); const callee = unparen(e.expression); @@ -2847,7 +2920,7 @@ class Analyzer { this.add(info.thisNode, inst); this.bindArgs(info, args, spread); this.edge(info.ret, dst); - this.edgeFor(e, info); + this.edgeFor(e, info, args); } return; } diff --git a/packages/compiler/src/frontend/shape-inference/constant-graphs.ts b/packages/compiler/src/frontend/shape-inference/constant-graphs.ts index dffa8d17d3..ceea474bd1 100644 --- a/packages/compiler/src/frontend/shape-inference/constant-graphs.ts +++ b/packages/compiler/src/frontend/shape-inference/constant-graphs.ts @@ -42,6 +42,8 @@ export interface Candidate { /** Whether the creating syntax is a plain data literal. */ readonly plain: boolean; readonly inPackage: boolean; + /** The per-call-site clone that allocates it ("" outside clones). */ + readonly ctx?: string; } export interface ConstantObject { @@ -187,6 +189,34 @@ export function computeConstantGraphs(analysis: ShapeAnalysis): ConstantGraphs { return result; }; + // A per-call-site clone (analyze.ts siteClone) runs once when its one + // call site runs once: its caller is module code, or another such clone. + const edgesToInfo = new Map(); + for (const e of analysis.calls) { + let l = edgesToInfo.get(e.callee); + if (l === undefined) edgesToInfo.set(e.callee, (l = [])); + l.push(e); + } + const onceInfoMemo = new Map(); + const onceInfo = (info: FunctionInfo): boolean => { + if (ts.isSourceFile(info.node)) return true; + if (!info.ctx.includes("@S")) return once(info.node); + const known = onceInfoMemo.get(info); + if (known !== undefined) return known; + onceInfoMemo.set(info, false); + const edges = edgesToInfo.get(info) ?? []; + const result = + !escapedNodes.has(info.node) && + edges.length > 0 && + new Set(edges.map((e) => e.site)).size === 1 && + edges.every( + (e) => + !e.inLoop && e.caller !== null && !inLoop(e.site, e.caller.node) && onceInfo(e.caller), + ); + onceInfoMemo.set(info, result); + return result; + }; + // Whether every call of a function comes from once-only code outside // loops, through any number of call sites: an init factory, whose // allocations are one object per call site (schema builders called at @@ -240,6 +270,7 @@ export function computeConstantGraphs(analysis: ShapeAnalysis): ConstantGraphs { site.keys === null ), inPackage: site.inPackage, + ctx: site.ctx, }); } const inPackage = (n: Node): boolean => @@ -271,7 +302,13 @@ export function computeConstantGraphs(analysis: ShapeAnalysis): ConstantGraphs { // A function object is created where its expression or declaration // runs; its prototype with it. const owner = ownerFunction(site.node); - if (!once(owner) || inLoop(site.node, owner)) { + // Allocated in a per-call-site clone: once when that clone runs once. + const cloneOwner = + site.ctx !== undefined && site.ctx !== "" + ? infosByNode.get(owner)?.find((i) => i.ctx === site.ctx) + : undefined; + const runsOnce = cloneOwner !== undefined ? onceInfo(cloneOwner) : once(owner); + if (!runsOnce || inLoop(site.node, owner)) { const factory = !inLoop(site.node, owner) && initOnly(owner); if (site.inPackage || factory) nearMisses.push({ diff --git a/packages/compiler/src/frontend/shape-inference/overlay.ts b/packages/compiler/src/frontend/shape-inference/overlay.ts index 17d978ab38..023f55deb8 100644 --- a/packages/compiler/src/frontend/shape-inference/overlay.ts +++ b/packages/compiler/src/frontend/shape-inference/overlay.ts @@ -166,7 +166,6 @@ export function planShapeOverlay( const typeOf = (atoms: ReadonlySet, inner: boolean, depth: number): string | null => { if (atoms.size === 0) return inner ? "any" : null; const arms = new Set(); - let objects = 0; // Every array arm becomes one Array<…> of the joined element values. const elements = new Set(); let hasArray = false; @@ -189,17 +188,14 @@ export function planShapeOverlay( break; case "instance": t = a.node !== undefined ? className(a.node) : null; - objects++; break; case "object": { - objects++; const v = pSites.get(a); if (v === undefined || depth >= 4 || !siteKeys.has(a)) return inner ? "any" : null; recordSites.push(a); continue; } case "array": { - objects++; if (analysis.escaped.has(a) || (siteVerdicts.has(a) && !pSites.has(a))) return inner ? "any" : null; // Only arrays the program writes as literals: a builtin's array @@ -246,7 +242,9 @@ export function planShapeOverlay( // A promise beside other values cannot cross into untyped code // (maybe-promise unions stay typed-only), so it stays untyped here. if (hasPromise) return inner ? "any" : null; - if (objects > MAX_ARMS) return inner ? "any" : null; + // Arms, not atoms: per-site allocations of one record shape are one + // typedef. + if (arms.size > MAX_ARMS) return inner ? "any" : null; if (arms.has("any")) return inner ? "any" : null; return [...arms].join(" | "); }; diff --git a/packages/compiler/src/frontend/shape-inference/report.ts b/packages/compiler/src/frontend/shape-inference/report.ts index c18d9eb56b..1f3f14e7b3 100644 --- a/packages/compiler/src/frontend/shape-inference/report.ts +++ b/packages/compiler/src/frontend/shape-inference/report.ts @@ -81,6 +81,18 @@ export function hotFunctions(analysis: ShapeAnalysis): Set { return hot; } +/** Operations that reach an escaped site through a ⊤ receiver. */ +const TOP_REACHING: ReadonlySet = new Set([ + "delete", + "defineProperty", + "setPrototypeOf", + "seal", + "proxy", + "assign", + "freeze", + "lengthWrite", +]); + export function buildShapeReport(analysis: ShapeAnalysis, filter: string): ShapeReport { const lines: string[] = []; const all = filter === "all" || filter === "verbose"; @@ -106,6 +118,23 @@ export function buildShapeReport(analysis: ShapeAnalysis, filter: string): Shape } if (atoms.has(analysis.top)) topOps.push(o); } + // Through ⊤, only reflective operations and length writes reach an + // escaped site (writes, reads and computed writes count only when the + // site is a direct receiver). One per syntax node and kind: contexts repeat ops. + const reflectiveTopOps: typeof analysis.ops = []; + { + const seen = new Set(); + const ids = new Map(); + for (const o of topOps) { + if (!TOP_REACHING.has(o.kind)) continue; + let id = ids.get(o.node); + if (id === undefined) ids.set(o.node, (id = ids.size)); + const key = `${o.kind}:${id}`; + if (seen.has(key)) continue; + seen.add(key); + reflectiveTopOps.push(o); + } + } // Key-order groups: sites with the same key set and different orders need brands. const orders = new Map>(); @@ -129,7 +158,7 @@ export function buildShapeReport(analysis: ShapeAnalysis, filter: string): Shape for (const sp of s.spreads) if (atomsOf(analysis, sp).has(analysis.top)) reasons.push("spread of ⊤"); const direct = byAtom.get(s.atom) ?? []; - const ops = escapes ? [...direct, ...topOps] : direct; + const ops = escapes ? [...direct, ...reflectiveTopOps] : direct; const keys = new Set(s.keys ?? []); const fieldsOf = analysis.fields.get(s.atom); const knownKeys = @@ -144,31 +173,33 @@ export function buildShapeReport(analysis: ShapeAnalysis, filter: string): Shape }; for (const o of ops) { const viaTop = !atomsOf(analysis, o.recv).has(s.atom); - const where = `${locLabel(o.node)}${viaTop ? " via ⊤" : ""}`; + if (viaTop && !TOP_REACHING.has(o.kind)) continue; + // Formatted only for a reason that is reported. + const at = (): string => `${locLabel(o.node)}${viaTop ? " via ⊤" : ""}`; switch (o.kind) { case "delete": case "defineProperty": case "setPrototypeOf": case "seal": case "proxy": - note(`${o.kind} at ${where}`); + note(`${o.kind} at ${at()}`); break; case "assign": - note(`Object.assign target at ${where}`); + note(`Object.assign target at ${at()}`); break; case "freeze": freezable = true; break; case "computedWrite": if (!viaTop) - note(s.kind === "array" ? `index write at ${where}` : `computed write at ${where}`); + note(s.kind === "array" ? `index write at ${at()}` : `computed write at ${at()}`); break; case "lengthWrite": - if (s.kind === "array") note(`length write at ${where}`); + if (s.kind === "array") note(`length write at ${at()}`); break; case "write": if (s.kind === "object" && !viaTop && o.name !== null && !knownKeys.has(o.name)) - note(`adds key '${o.name}' at ${where}`); + note(`adds key '${o.name}' at ${at()}`); break; case "read": if ( @@ -180,7 +211,7 @@ export function buildShapeReport(analysis: ShapeAnalysis, filter: string): Shape !knownKeys.has(o.name) && !OBJECT_PROTOTYPE_MEMBERS.has(o.name) ) - note(`reads absent key '${o.name}' at ${where} (escapes)`); + note(`reads absent key '${o.name}' at ${at()} (escapes)`); break; default: break; diff --git a/packages/compiler/src/frontend/shape-inference/static-objects.ts b/packages/compiler/src/frontend/shape-inference/static-objects.ts index 6b4617489c..9b67d2a823 100644 --- a/packages/compiler/src/frontend/shape-inference/static-objects.ts +++ b/packages/compiler/src/frontend/shape-inference/static-objects.ts @@ -325,6 +325,25 @@ export function planStaticObjectRewrites( for (const op of analysis.ops) if (op.kind !== "read" && op.kind !== "write" && op.kind !== "lengthWrite") for (const a of analysis.nodes[op.recv]!.atoms) computedRecv.add(a); + // One entry per (kind, syntax node): a node the analysis walked in + // several contexts (receiver classes, per-site clones) is rewritten only + // when the receivers of every context together satisfy the conditions. + const mergedOps: { op: (typeof analysis.ops)[number]; atoms: Set }[] = []; + { + const byKey = new Map }>(); + const ids = new Map(); + for (const op of analysis.ops) { + let id = ids.get(op.node); + if (id === undefined) ids.set(op.node, (id = ids.size)); + const key = `${op.kind}:${op.name ?? ""}:${id}`; + let entry = byKey.get(key); + if (entry === undefined) { + byKey.set(key, (entry = { op, atoms: new Set() })); + mergedOps.push(entry); + } + for (const a of analysis.nodes[op.recv]!.atoms) entry.atoms.add(a); + } + } const forIns: ts.Expression[] = []; const reflective: ts.CallExpression[] = []; for (const sf of analysis.files) { @@ -686,9 +705,8 @@ export function planStaticObjectRewrites( // Direct references: `e.k` (called, constructed or read) in the same file // whose receiver is exactly the constant object holding a hoisted // function or class. - for (const op of analysis.ops) { + for (const { op, atoms } of mergedOps) { if (op.kind !== "read" || op.name === null) continue; - const atoms = analysis.nodes[op.recv]!.atoms; if (atoms.size !== 1) continue; const recv = [...atoms][0]!; const o = constantAtoms.get(recv); @@ -729,9 +747,8 @@ export function planStaticObjectRewrites( // member stored after the object was created could be read before the // store; those stay reads.) let foldedReads = 0; - for (const op of analysis.ops) { + for (const { op, atoms } of mergedOps) { if (op.kind !== "read" || op.name === null) continue; - const atoms = analysis.nodes[op.recv]!.atoms; if (atoms.size !== 1) continue; const o = constantAtoms.get([...atoms][0]!); if (o === undefined || o.site.kind !== "literal") continue; @@ -775,14 +792,13 @@ export function planStaticObjectRewrites( // table has no accessors, and comparing a string runs no code, so only // the representation of the call changes. const tableCalls: { sf: ts.SourceFile; call: ts.CallExpression; arms: [string, string][] }[] = []; - for (const op of analysis.ops) { + for (const { op, atoms } of mergedOps) { if (op.kind !== "computedRead") continue; const node = op.node; if (!ts.isElementAccessExpression(node) || node.questionDotToken !== undefined) continue; const call = node.parent; if (call === undefined || !ts.isCallExpression(call) || call.expression !== node) continue; if (call.questionDotToken !== undefined || topStatement(call) === null) continue; - const atoms = analysis.nodes[op.recv]!.atoms; const where = `table call ${node.getText()}`; if (atoms.size !== 1) { kept.push(`${where}: the table may be ${atoms.size} values`); diff --git a/packages/compiler/test/constant-graphs.test.ts b/packages/compiler/test/constant-graphs.test.ts index a89276ce73..e3238ab57a 100644 --- a/packages/compiler/test/constant-graphs.test.ts +++ b/packages/compiler/test/constant-graphs.test.ts @@ -101,8 +101,11 @@ test("constant graphs: namespaces, revealing modules and prototypes, with near m // A deleted key, and an object stored into the global object. expect(text).toMatch(/near miss ns-fixture\/index\.js:16: reflective-write \(delete/); expect(text).toMatch(/near miss ns-fixture\/index\.js:18: escapes/); - // A factory called from two init sites: one object per call site. - expect(text).toMatch(/near miss ns-fixture\/index\.js:20: init-factory \(.*2 call sites\)/); + // A factory called from two init sites is analyzed per call site: each + // call allocates its own constant object. + expect(text).not.toMatch(/init-factory/); + // (7 objects without per-site clones: the two options were one.) + expect(text).toMatch(/ns-fixture: 9 constant objects/); }); // Functions that one call site runs repeatedly: an ES5 constructor and an