perf: reuse recursive class type analysis during final lowering

- Reuse completed type graph checks across globals and local variables.
- Preserve cyclic class validation and native cleanup behavior.
This commit is contained in:
Chris Tate
2026-09-30 14:20:55 -05:00
parent cc4a6f3e32
commit c328c2643d
5 changed files with 177 additions and 4 deletions
@@ -1,6 +1,7 @@
import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
import { RuntimeOptionalLocals } from "./runtime-optional-locals.js";
import { sanitizeUnregisteredClassTypes } from "./sanitize-class-types.js";
import { UnregisteredClassTypes } from "./unregistered-class-types.js";
import { buildUnionNarrow } from "./union-narrow.js";
import { planUnionRetag, buildUnionRetag, planRecordUnionWrap, buildRecordUnionWrap } from "./union-retag.js";
import type { WidthLift } from "./width-lift.js";
@@ -3765,9 +3766,12 @@ export class Lowerer {
// fences), so drop it — guarded by a reference scan, with the
// validator's registration check as the backstop for anything that
// does slip through with a live reference.
const brokenGlobals = this.globalsList.filter((g) => this.typeNamesUnregisteredClass(g.type));
const unregistered = new UnregisteredClassTypes(
(id) => this.shapes.get(id), (id) => this.unions.get(id), (name) => this.classes.has(name),
);
const brokenGlobals = this.globalsList.filter((g) => unregistered.has(g.type));
const brokenLocalFns = functions.filter((fn) =>
fn.locals.some((l) => this.typeNamesUnregisteredClass(l.type)),
fn.locals.some((l) => unregistered.has(l.type)),
);
if (brokenGlobals.length > 0 || brokenLocalFns.length > 0) {
const referencedIn = (root: unknown): Set<string> => {
@@ -3810,7 +3814,7 @@ export class Lowerer {
// those out from under them.
const referenced = referencedIn([fn.body, fn.params, fn.captures ?? [], fn.classCaptures ?? []]);
fn.locals = fn.locals.filter(
(l) => referenced.has(l.id) || !this.typeNamesUnregisteredClass(l.type),
(l) => referenced.has(l.id) || !unregistered.has(l.type),
);
}
}
@@ -0,0 +1,70 @@
import { expect, test } from "vitest";
import { F64, VOID, type IrRecordShape, type IrType, type IrUnionDef } from "../../ir/ir.js";
import { UnregisteredClassTypes } from "./unregistered-class-types.js";
const record = (shapeId: string): IrType => ({ kind: "record", shapeId });
const union = (unionId: string): IrType => ({ kind: "union", unionId });
const missing: IrType = { kind: "object", className: "Missing" };
test("cyclic references wait for sibling checks before caching safe types", () => {
const records = new Map<string, IrRecordShape>([
["a", { id: "a", fields: [{ name: "cycle", type: record("b") }, { name: "missing", type: missing }] }],
["b", { id: "b", fields: [{ name: "back", type: union("u") }, { name: "safe", type: record("safe") }] }],
["safe", { id: "safe", fields: [{ name: "value", type: F64 }] }],
]);
const unions = new Map<string, IrUnionDef>([["u", { id: "u", arms: [record("a")] }]]);
const index = new UnregisteredClassTypes((id) => records.get(id), (id) => unions.get(id), () => false);
expect(index.has(record("a"))).toBe(true);
expect(index.has(record("b"))).toBe(true);
expect(index.has(union("u"))).toBe(true);
expect(index.has(record("safe"))).toBe(false);
});
test("shared recursive graphs are traversed once across repeated local types", () => {
const records = new Map<string, IrRecordShape>();
const count = 2048;
for (let i = 0; i < count; i++) records.set(String(i), {
id: String(i), fields: [{ name: "next", type: record(String((i + 1) % count)) }],
});
let lookups = 0;
const index = new UnregisteredClassTypes((id) => { lookups++; return records.get(id); }, () => undefined, () => true);
for (let i = 0; i < count; i++) {
expect(index.has({ kind: "array", elem: record(String(i)) })).toBe(false);
}
expect(lookups).toBe(count);
});
test("nested containers, signatures and index values expose missing instances", () => {
const shape: IrRecordShape = { id: "r", fields: [], indexValue: missing };
const definition: IrUnionDef = { id: "u", arms: [record("r")] };
const index = new UnregisteredClassTypes(() => shape, () => definition, () => false);
const types: IrType[] = [
{ kind: "array", elem: missing }, { kind: "set", elem: missing },
{ kind: "map", key: missing, value: F64 }, { kind: "map", key: F64, value: missing },
{ kind: "promise", inner: missing },
{ kind: "func", params: [missing], ret: VOID }, { kind: "func", params: [], ret: missing },
record("r"), union("u"),
];
for (const type of types) expect(index.has(type)).toBe(true);
expect(index.has({ kind: "classval", className: "Missing" })).toBe(false);
});
test("record and union IDs have independent identities", () => {
const index = new UnregisteredClassTypes(
(id) => ({ id, fields: [] }), (id) => ({ id, arms: [missing] }), () => false,
);
expect(index.has(record("same"))).toBe(false);
expect(index.has(union("same"))).toBe(true);
expect(index.has(record("same"))).toBe(false);
});
test("fresh indexes observe later class registration and type-table completion", () => {
let shape: IrRecordShape | undefined;
const classes = new Set<string>();
const fresh = () => new UnregisteredClassTypes(() => shape, () => undefined, (name) => classes.has(name));
expect(fresh().has(record("r"))).toBe(false);
shape = { id: "r", fields: [{ name: "value", type: missing }] };
expect(fresh().has(record("r"))).toBe(true);
classes.add("Missing");
expect(fresh().has(record("r"))).toBe(false);
});
@@ -0,0 +1,77 @@
import type { IrRecordShape, IrType, IrUnionDef } from "../../ir/ir.js";
/** Reuse class-reference checks while the type tables and class registry are
* fixed. Create a new index after lowering or retention changes those tables. */
export class UnregisteredClassTypes {
private readonly safeRecords = new Set<string>();
private readonly safeUnions = new Set<string>();
private readonly badRecords = new Set<string>();
private readonly badUnions = new Set<string>();
constructor(
private readonly record: (id: string) => IrRecordShape | undefined,
private readonly union: (id: string) => IrUnionDef | undefined,
private readonly hasClass: (name: string) => boolean,
) {}
has(root: IrType): boolean {
const pending: IrType[] = [root];
const records = new Set<string>();
const unions = new Set<string>();
let bad = false;
while (pending.length > 0 && !bad) {
const type = pending.pop()!;
switch (type.kind) {
case "object":
bad = !this.hasClass(type.className);
break;
case "array":
case "set":
pending.push(type.elem);
break;
case "map":
pending.push(type.value, type.key);
break;
case "promise":
pending.push(type.inner);
break;
case "func":
pending.push(type.ret);
for (let i = type.params.length - 1; i >= 0; i--) pending.push(type.params[i]!);
break;
case "record": {
if (this.badRecords.has(type.shapeId)) { bad = true; break; }
if (this.safeRecords.has(type.shapeId) || records.has(type.shapeId)) break;
records.add(type.shapeId);
const shape = this.record(type.shapeId);
if (shape !== undefined) {
for (let i = shape.fields.length - 1; i >= 0; i--) pending.push(shape.fields[i]!.type);
if (shape.indexValue !== undefined) pending.push(shape.indexValue);
}
break;
}
case "union": {
if (this.badUnions.has(type.unionId)) { bad = true; break; }
if (this.safeUnions.has(type.unionId) || unions.has(type.unionId)) break;
unions.add(type.unionId);
const definition = this.union(type.unionId);
if (definition !== undefined) {
for (let i = definition.arms.length - 1; i >= 0; i--) pending.push(definition.arms[i]!);
}
break;
}
}
}
if (bad) {
// Only the root is proven bad; visited sibling types may be safe.
if (root.kind === "record") this.badRecords.add(root.shapeId);
if (root.kind === "union") this.badUnions.add(root.unionId);
} else {
// A cycle back-edge is provisional. Cache negative answers only after
// the entire reachable graph has been checked, including its siblings.
for (const id of records) this.safeRecords.add(id);
for (const id of unions) this.safeUnions.add(id);
}
return bad;
}
}
+22 -1
View File
@@ -1,5 +1,6 @@
import { F64, typeKey, type IrExpr, type IrModule, type IrType } from "../../../packages/compiler/src/ir/ir.js";
import { F64, typeKey, type IrExpr, type IrModule, type IrRecordShape, type IrType, type IrUnionDef } from "../../../packages/compiler/src/ir/ir.js";
import { sanitizeUnregisteredClassTypes } from "../../../packages/compiler/src/frontend/lowering/sanitize-class-types.js";
import { UnregisteredClassTypes } from "../../../packages/compiler/src/frontend/lowering/unregistered-class-types.js";
const loc = { file: "class-types.ts", start: 0, end: 1 };
const missing: IrType = { kind: "object", className: "Fenced" };
@@ -21,6 +22,26 @@ for (const record of module.records ?? []) {
if (record.indexValue) console.log(typeKey(record.indexValue));
}
for (const union of module.unions ?? []) console.log(union.arms.map(typeKey).join(","));
const records = new Map<string, IrRecordShape>();
const unions = new Map<string, IrUnionDef>();
for (let i = 0; i < 1024; i++) records.set(String(i), { id: String(i), fields: [
{ name: "next", type: { kind: "record", shapeId: String((i + 1) % 1024) } },
] });
let lookups = 0;
const references = new UnregisteredClassTypes(
(id) => { lookups++; return records.get(id); }, (id) => unions.get(id), (name) => name === "Registered",
);
let bad = false;
for (let i = 0; i < 1024; i++) bad = references.has({ kind: "record", shapeId: String(i) }) || bad;
console.log(bad, lookups);
records.set("a", { id: "a", fields: [
{ name: "cycle", type: { kind: "record", shapeId: "b" } }, { name: "missing", type: missing },
] });
records.set("b", { id: "b", fields: [{ name: "back", type: { kind: "union", unionId: "u" } }] });
unions.set("u", { id: "u", arms: [{ kind: "record", shapeId: "a" }] });
const cyclic = new UnregisteredClassTypes((id) => records.get(id), (id) => unions.get(id), () => false);
console.log(cyclic.has({ kind: "record", shapeId: "a" }), cyclic.has({ kind: "record", shapeId: "b" }),
cyclic.has({ kind: "union", unionId: "u" }), cyclic.has({ kind: "record", shapeId: "0" }));
console.log(missing.kind, kept.kind, F64.kind);
sanitizeUnregisteredClassTypes(module, (name) => name === "Registered");
console.log(expr.type.kind);
@@ -25,6 +25,7 @@ test("native class type cleanup updates shared IR without dynamic snapshots", as
}
expect(native.stdout).toBe(oracle.stdout);
expect(native.stdout).toMatch(/^f64 f64\n/);
expect(native.stdout).toContain("false 1024\ntrue true true false\n");
expect(native.stdout).toMatch(/object object f64\nf64\n$/);
} finally { rmSync(directory, { recursive: true, force: true }); }
}, 300_000);