From 9c1cabb08cde80966b0af87f30c3f88f23dd39d2 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 21 Oct 2026 08:57:32 -0500 Subject: [PATCH] Remove repeated lookups from the class dynamic-dispatch passes ClassDynamicDispatch.process runs once per reachability wave and builds dispatch arms for every boxed class. Several lookups inside it grew with the program and repeated for every dispatch or member: - propertyReceivers was recomputed at every call site, including once per property dispatch. It now returns its last plans for a mode while the boxed classes, the registered classes, the subclass links (Lowerer.subclassLinks) and the constructed classes (Lowerer.constructedClassCount) are unchanged, and each boxed class still maps to the same ClassInfo. In canonical mode it also skips the subtree of a class it has already seen, which adds no plans. - The property dispatch loop tests the answered-class set before the member lookups, and the method and protocol loops test candidates in a Set instead of with Array.includes. A dynamic method call scans the boxed constructors for a static method only when no instance candidate answers the method, the only case that reads the result. - Lowerer.liftedFnNamed indexes liftedFns by name and replaces the linear liftedFns.find scans in the dispatch, prototype, descriptor and method-value helpers. The emitted IR is unchanged. --- .../frontend/lowering/class-descriptors.ts | 2 +- .../lowering/class-dynamic-dispatch.test.ts | 3 + .../lowering/class-dynamic-dispatch.ts | 71 ++++++++++++++----- .../frontend/lowering/class-method-values.ts | 2 +- .../src/frontend/lowering/class-prototypes.ts | 4 +- .../src/frontend/lowering/lower-classes.ts | 15 +++- .../compiler/src/frontend/lowering/lowerer.ts | 22 ++++++ 7 files changed, 94 insertions(+), 25 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/class-descriptors.ts b/packages/compiler/src/frontend/lowering/class-descriptors.ts index cb980f0d0..0b807e450 100644 --- a/packages/compiler/src/frontend/lowering/class-descriptors.ts +++ b/packages/compiler/src/frontend/lowering/class-descriptors.ts @@ -177,7 +177,7 @@ export function lowerClassDataDescriptor( function descriptorGuard(lowerer: Lowerer, info: ClassInfo, loc: SrcLoc): string { const name = `%descriptorGuard:${info.def.name}`; - const existing = lowerer.liftedFns.find((fn) => fn.name === name); + const existing = lowerer.liftedFnNamed(name); const key = varRef("key", DYN, loc); const fields = new Set(); const collect = (owner: ClassInfo): void => { diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.test.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.test.ts index 1c5c84cdf..dfb8bd7a5 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.test.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.test.ts @@ -32,6 +32,9 @@ function context() { unions: new Map(), classes: new Map(), liftedFns, + liftedFnNamed: (name: string | undefined) => liftedFns.find((fn) => fn.name === name), + subclassLinks: 0, + constructedClassCount: () => -1, diags: [], prototypeMethodAccesses: new Map(), protocolDispatches: new Map(), diff --git a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts index cb5c28444..d9ef0de62 100644 --- a/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/class-dynamic-dispatch.ts @@ -366,7 +366,7 @@ function readOnlyBagBody(body: IrStmt[], orEmpty = false): IrStmt[] { export function classPropertiesHelper(lowerer: Lowerer, loc: SrcLoc, readOnly = false): IrFunction { const name = readOnly ? READ_PROPERTIES : "%dyn.class.properties"; - const existing = lowerer.liftedFns.find((fn) => fn.name === name); + const existing = lowerer.liftedFnNamed(name); if (existing) return existing; const helper: IrFunction = { name, @@ -1003,7 +1003,7 @@ export class ClassDynamicDispatch { const loc = this.propertyBag.loc; const method = classMethodValue(lowerer, info.decl, info, "toJSON", loc); const prototype = method ? classPrototypeData(lowerer, info, loc) : null; - const helper = lowerer.liftedFns.find((fn) => fn.name === info.def.prototypeDataHelper); + const helper = lowerer.liftedFnNamed(info.def.prototypeDataHelper); const init = helper?.body[0]; if ( !info.prototypeReflectionReady && @@ -1236,6 +1236,15 @@ export class ClassDynamicDispatch { byMethod.set(method, matching); return matching; }; + const candidateSets = new Map>(); + const candidateSet = (method: string): ReadonlySet => { + let set = candidateSets.get(method); + if (!set) { + set = new Set(candidates(method)); + candidateSets.set(method, set); + } + return set; + }; for (const fn of functions) { // Discovery already visits every expression. Preserve bodies without // dispatch sites instead of rebuilding their entire typed IR tree on @@ -2220,16 +2229,16 @@ export class ClassDynamicDispatch { : expr.kind === "dynInvoke" ? expr : null; - const staticCandidates = - invoke && + // Static methods matter only for methods no instance answers. + const staticCandidates = (method: string): boolean => [...this.boxedConstructors].some((name) => { const info = lowerer.classes.get(name); - return info && findStaticOn(lowerer, info, invoke.method)?.method; + return info && findStaticOn(lowerer, info, method)?.method; }); if ( !invoke || (candidates(invoke.method).length === 0 && - !staticCandidates && + !staticCandidates(invoke.method) && !(invoke.method === "toString" && lowerer.classes.has("%Error"))) ) return expr; @@ -2501,10 +2510,13 @@ export class ClassDynamicDispatch { }); changed = true; } - for (const plan of this.propertyReceivers(lowerer, true)) { + // Compute the candidates where the first plan would have, so the + // memoized set is the same whichever plans are already answered. + const plans = this.propertyReceivers(lowerer, true); + const matching = plans.length ? candidateSet(dispatch.source.method) : null; + for (const plan of plans) { const { info } = plan; - if (!candidates(dispatch.source.method).includes(info) || dispatch.classes.has(plan.key)) - continue; + if (dispatch.classes.has(plan.key) || !matching!.has(info)) continue; dispatch.classes.add(plan.key); const loc = dispatch.source.loc; const value = varRef("p.0", DYN, loc); @@ -2648,7 +2660,7 @@ export class ClassDynamicDispatch { if ( plan.info !== plan.capsule || dispatch.classes.has(plan.key) || - (!dispatch.field && !candidates(dispatch.method).includes(plan.info)) + (!dispatch.field && !candidateSet(dispatch.method).has(plan.info)) ) continue; dispatch.classes.add(plan.key); @@ -2738,6 +2750,8 @@ export class ClassDynamicDispatch { } for (const plan of this.propertyReceivers(lowerer, true)) { const { info } = plan; + // Classes answered in an earlier pass skip the member lookups. + if (dispatch.classes.has(plan.key)) continue; if ( !isClassOwnEnumerableFieldName(dispatch.name) || (!(info.builtinStream !== undefined && streamProperty(dispatch.name)) && @@ -2751,7 +2765,6 @@ export class ClassDynamicDispatch { !findMethodOn(lowerer, info, `set:${dispatch.name}`)) ) continue; - if (dispatch.classes.has(plan.key)) continue; dispatch.classes.add(plan.key); const loc = dispatch.fn.loc; const { receiver, condition } = this.propertyReceiver(plan, loc); @@ -3310,7 +3323,7 @@ export class ClassDynamicDispatch { loc: SrcLoc, ): IrExpr { const name = fn === "dyn.hasKey" ? "%dyn.class.bagHas" : "%dyn.class.bagHasComputed"; - let helper = lowerer.liftedFns.find((item) => item.name === name); + let helper = lowerer.liftedFnNamed(name); if (!helper) { const keyType = fn === "dyn.hasKey" ? STRING : DYN; const value = varRef("p.0", DYN, loc); @@ -3907,7 +3920,7 @@ export class ClassDynamicDispatch { private readPropertyBagOrEmpty(lowerer: Lowerer, receiver: IrExpr, loc: SrcLoc): IrExpr { // One call per read site: large JS programs have a branch per class and // property, so the absent-bag handling lives in the shared helper. - let helper = lowerer.liftedFns.find((fn) => fn.name === READ_PROPERTIES_OR_EMPTY); + let helper = lowerer.liftedFnNamed(READ_PROPERTIES_OR_EMPTY); if (!helper) { // Settled by finalize; until then, the read-only bag's own dispatch. helper = { @@ -4256,17 +4269,38 @@ export class ClassDynamicDispatch { add(root); } + /** propertyReceivers results by mode, with the class graph they were + * computed from: the boxed classes, the registered classes, the subclass + * links and the constructed classes only grow, so equal counts and the + * same capsules give the same plans. */ + private readonly receiverPlans = new Map< + string, + { revision: string; capsules: (ClassInfo | undefined)[]; plans: readonly PropertyReceiver[] } + >(); + /** A capsule retains its static type, while its object may be a subclass. * Preorder insertion lets later branches test the most derived layout first. */ private propertyReceivers( lowerer: Lowerer, streams = false, normalize = true, - ): PropertyReceiver[] { + ): readonly PropertyReceiver[] { + const mode = `${streams}:${normalize}`; + const revision = `${this.boxed.size}:${lowerer.classes.size}:${lowerer.subclassLinks}:${lowerer.constructedClassCount()}`; + const cached = this.receiverPlans.get(mode); + if (cached?.revision === revision) { + let i = 0; + let same = true; + for (const name of this.boxed) + if (lowerer.classes.get(name) !== cached.capsules[i++]) same = false; + if (same) return cached.plans; + } + const capsules: (ClassInfo | undefined)[] = []; const plans: PropertyReceiver[] = []; const seen = new Set(); for (const name of this.boxed) { const capsule = lowerer.classes.get(name); + capsules.push(capsule); if ( !capsule || ((capsule.builtinEmitter || capsule.builtinStream) && !streams) || @@ -4275,10 +4309,10 @@ export class ClassDynamicDispatch { continue; const canonical = normalize && !capsule.builtinStream && !capsule.builtinEmitter; const visit = (info: ClassInfo): void => { - if ( - (!canonical || !seen.has(info.def.name)) && - (info === capsule || lowerer.classCanBeConstructed(info)) - ) { + // A seen class was visited with its whole subtree, and a canonical + // visit adds no plan for a seen class. + if (canonical && seen.has(info.def.name)) return; + if (info === capsule || lowerer.classCanBeConstructed(info)) { seen.add(info.def.name); plans.push({ info, @@ -4290,6 +4324,7 @@ export class ClassDynamicDispatch { }; visit(capsule); } + this.receiverPlans.set(mode, { revision, capsules, plans }); return plans; } diff --git a/packages/compiler/src/frontend/lowering/class-method-values.ts b/packages/compiler/src/frontend/lowering/class-method-values.ts index 1197d9bdd..6e8256513 100644 --- a/packages/compiler/src/frontend/lowering/class-method-values.ts +++ b/packages/compiler/src/frontend/lowering/class-method-values.ts @@ -426,7 +426,7 @@ function reflectedMethodValue( ); } if (value.kind === "closure" && value.captures.length === 0) { - const body = lowerer.liftedFns.find((fn) => fn.name === value.fnName); + const body = lowerer.liftedFnNamed(value.fnName); if (body?.captures?.length === 0) delete body.captures; } const cache = staticMethod diff --git a/packages/compiler/src/frontend/lowering/class-prototypes.ts b/packages/compiler/src/frontend/lowering/class-prototypes.ts index 7b4b58ae3..d2919e202 100644 --- a/packages/compiler/src/frontend/lowering/class-prototypes.ts +++ b/packages/compiler/src/frontend/lowering/class-prototypes.ts @@ -172,7 +172,7 @@ export function classPrototypeData( } if (!metadataOnly && !info.prototypeObservedMethodsReady) { info.prototypeObservedMethodsReady = true; - const helper = lowerer.liftedFns.find((fn) => fn.name === info.def.prototypeDataHelper)!; + const helper = lowerer.liftedFnNamed(info.def.prototypeDataHelper)!; const value = varRef(`%g.${info.def.prototypeDataHelper}`, DYN, loc); // Materialize only observed method slots. Own declarations stop lookup // at the correct prototype even if an ancestor is replaced later. An @@ -307,7 +307,7 @@ export function reflectClassPrototype( if (info.base.prototypeReflectionRefusal) return refuse("prototype reflection with this base class is not supported yet"); } - const helper = lowerer.liftedFns.find((fn) => fn.name === info.def.prototypeDataHelper); + const helper = lowerer.liftedFnNamed(info.def.prototypeDataHelper); const initialization = helper?.body[0]; if (!helper || initialization?.kind !== "if" || info.prototypeReflectionReady) return prototype; info.prototypeReflectionReady = true; diff --git a/packages/compiler/src/frontend/lowering/lower-classes.ts b/packages/compiler/src/frontend/lowering/lower-classes.ts index 371541df8..f312e9229 100644 --- a/packages/compiler/src/frontend/lowering/lower-classes.ts +++ b/packages/compiler/src/frontend/lowering/lower-classes.ts @@ -946,7 +946,10 @@ export function registerBuiltinErrorClasses(lowerer: Lowerer): void { throwingSetters: [], staticFields: [], }; - if (base) base.subclasses.push(info); + if (base) { + base.subclasses.push(info); + lowerer.subclassLinks++; + } lowerer.classes.set(irName, info); } } @@ -1026,7 +1029,10 @@ export function registerBuiltinStreamClasses(lowerer: Lowerer): void { throwingSetters: [], staticFields: [], }; - if (base) base.subclasses.push(info); + if (base) { + base.subclasses.push(info); + lowerer.subclassLinks++; + } lowerer.classes.set(irName, info); } } @@ -4471,7 +4477,10 @@ export function collectClassShapeInner( }; lowerer.genericClassByDecl.set(decl, info.generic); } - if (base) base.subclasses.push(info); + if (base) { + base.subclasses.push(info); + lowerer.subclassLinks++; + } lowerer.classes.set(className, info); if (computedBase && !functionLocalClass(decl)) { info.def.baseValueGlobal = `%g.${className}.computedBase`; diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 543149f83..be5db5cc6 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -3042,6 +3042,10 @@ export class Lowerer { // top level, top = the innermost nested function currently lowering). fnStack: FnCtx[] = []; readonly liftedFns: IrFunction[] = []; + /** The first lifted function of each name (liftedFnNamed), for the + * liftedFns indexed so far. liftedFns only grows. */ + private readonly liftedFnsByName = new Map(); + private liftedFnsIndexed = 0; /** Runtime-provided classes used as values (defineClassValueConstruct), * by IR class name. */ readonly runtimeClassValues = new Map(); @@ -3113,6 +3117,10 @@ export class Lowerer { * can contain any retained descendant, but importing its declaration * alone does not make every descendant constructible. */ private constructedClasses: Set | null = null; + /** Classes added to some ClassInfo's subclasses (lower-classes.ts). With + * the class and constructed-class counts, it tells caches over the class + * graph when the graph changed. */ + subclassLinks = 0; /** Set while a subclass constructor's super() call notes its base * constructor edge: that edge does not instantiate the base exactly. */ private superConstructorEdge = false; @@ -3129,6 +3137,20 @@ export class Lowerer { info.mixinInstance !== undefined ); } + /** How many classes classCanBeConstructed admits by name, or -1 while + * it admits every class. The count only grows. */ + constructedClassCount(): number { + return this.constructedClasses?.size ?? -1; + } + /** The first lifted function named `name`, the one + * `liftedFns.find((fn) => fn.name === name)` returns. */ + liftedFnNamed(name: string | undefined): IrFunction | undefined { + for (; this.liftedFnsIndexed < this.liftedFns.length; this.liftedFnsIndexed++) { + const fn = this.liftedFns[this.liftedFnsIndexed]!; + if (!this.liftedFnsByName.has(fn.name)) this.liftedFnsByName.set(fn.name, fn); + } + return name === undefined ? undefined : this.liftedFnsByName.get(name); + } /** Coverage remainder mode: the reachability gate inverts (see wantBody) * and no module is built. */ readonly remainder: boolean;