Skip emitter analysis walks whose answers are already known

The LLVM emitter's constructor walks the whole module about 33 times.
Several walks repeat work another already did, or run where the result
is provably empty:

- computePublishedTypes and computeCycleMutability run once and are
  shared (may-throw, immutable fields and traced shapes took their own).
- The WebCrypto-asymmetric and Ed25519 runtime-feature conditions join
  the feature scan's one walk instead of walking the module again.
- int32 slots: the fixpoint skips the write walk of a function without
  a candidate write, dynamicFieldStores stops once every field name is
  dynamic, and dynamicallyStoredFamilies returns early when no f64
  field can be named by a dynamic store.
- stableGlobals stops once no candidate is left, the second pass of
  findAppendedArrayLiterals and the target resolution of
  findConstantCallbacks run only when there is something to find,
  boxedUnionIds needs unions, and observesStacks walks for call sites
  and the "stack" literal only when a dynamic key asks.
- everyModuleNode visits type trees without a closure per node.

The emitted LLVM IR is unchanged.
This commit is contained in:
Chris Tate
2026-10-22 14:15:29 -05:00
parent 8d4ee22009
commit fbe4067b3b
12 changed files with 159 additions and 58 deletions
@@ -11,7 +11,7 @@ import {
STRING,
VOID,
} from "../ir/ir.js";
import { computeCycleMutability, fieldKey } from "./cycle-mutability.js";
import { type CycleMutability, computeCycleMutability, fieldKey } from "./cycle-mutability.js";
/** Cycle capability for native code generation.
* Nodes are shape units and unions; edges are the references their fields
@@ -38,8 +38,10 @@ import { computeCycleMutability, fieldKey } from "./cycle-mutability.js";
export function computeTraced(
mod: IrModule,
stores?: DynamicFieldStores,
/** computeCycleMutability's answer for this module, when the caller has it. */
precomputed?: CycleMutability,
): { shapes: Set<string>; unions: Set<string> } {
const { shapes, unions } = analyzeCycles(mod, false, stores);
const { shapes, unions } = analyzeCycles(mod, false, stores, precomputed);
return { shapes, unions };
}
@@ -93,6 +95,7 @@ function analyzeCycles(
mod: IrModule,
labels: boolean,
stores?: DynamicFieldStores,
precomputed?: CycleMutability,
): {
shapes: Set<string>;
unions: Set<string>;
@@ -125,7 +128,7 @@ function analyzeCycles(
const lifetimeRoots = new Set(mod.contracts?.programLifetime ?? []);
const programLifetime = (className: string): boolean =>
lifetimeRoots.size > 0 && lifetimeRoots.has(rootOf(className));
const mutability = computeCycleMutability(mod, unitOfClass, stores);
const mutability = precomputed ?? computeCycleMutability(mod, unitOfClass, stores);
const shapeDefs = [
...classes.map((c) => ({
key: `object:${c.name}`,
@@ -69,6 +69,22 @@ export function fieldKey(unit: string, field: string): string {
return `${unit}\0${field}`;
}
/** The cycle unit of a class: `object:<root>`, the root of its hierarchy,
* as computeImmutableFields and computeTraced group classes. */
export function hierarchyUnitOfClass(mod: IrModule): (className: string) => string {
const baseOf = new Map((mod.classes ?? []).map((c) => [c.name, c.base ?? null] as const));
const roots = new Map<string, string>();
return (name) => {
const known = roots.get(name);
if (known !== undefined) return `object:${known}`;
let cur = name;
for (let base = baseOf.get(cur); base !== null && base !== undefined; base = baseOf.get(cur))
cur = base;
roots.set(name, cur);
return `object:${cur}`;
};
}
export function computeCycleMutability(
mod: IrModule,
unitOfClass: (className: string) => string,
@@ -48,6 +48,8 @@ export class ImmutableFields {
export function computeImmutableFields(
mod: IrModule,
stores?: DynamicFieldStores,
/** computeCycleMutability's answer for this module, when the caller has it. */
precomputed?: CycleMutability,
): ImmutableFields | null {
if (mod.lib !== undefined) return null;
const baseOf = new Map((mod.classes ?? []).map((c) => [c.name, c.base ?? null] as const));
@@ -62,7 +64,7 @@ export function computeImmutableFields(
return cur;
};
const unitOfClass = (name: string): string => `object:${rootOf(name)}`;
const mutability = computeCycleMutability(mod, unitOfClass, stores);
const mutability = precomputed ?? computeCycleMutability(mod, unitOfClass, stores);
return mutability.all ? null : new ImmutableFields(mutability, unitOfClass);
}
@@ -213,6 +215,9 @@ export function stableGlobals(mod: IrModule): Set<string> {
}
return everyStmtChild(s, expr, stmt);
};
for (const fn of mod.functions) fn.body.every(stmt);
for (const fn of mod.functions) {
if (candidates.size === 0) break;
fn.body.every(stmt);
}
return candidates;
}
@@ -85,6 +85,7 @@ export function findAppendedArrayLiterals(module: IrModule): ReadonlySet<IrExpr>
for (const field of fieldAliases.get(binding) ?? []) appendedFields.add(field);
const result = new Set<IrExpr>();
if (appendedBindings.size === 0 && appendedFields.size === 0) return result;
const consider = (value: IrExpr | null, appended: boolean): void => {
if (value && appended && isEmptyLiteral(value)) result.add(value);
};
@@ -252,11 +252,13 @@ export function findConstantCallbacks(
// entries actually used as indirect callees in each body. A module-wide
// global/function cross product would dominate large-source emission.
const globalTargets = new Map<string, string>();
const result = new Map<string, Map<string, string>>();
// Only indirect callees get targets: without one, nothing resolves.
if (![...indirectCallees.values()].some((callees) => callees.size > 0)) return result;
for (const [id, value] of globals) {
const target = resolve(null, value);
if (target) globalTargets.set(id, target);
}
const result = new Map<string, Map<string, string>>();
for (const fn of mod.functions) {
const needed = indirectCallees.get(fn.name)!;
if (!needed.size) continue;
@@ -175,6 +175,7 @@ import { emitDynamicExpr } from "./expr-dynamic.js";
import { emitIntrinsicExpr, emitSerializationExpr, emitAsyncExpr } from "./expr-async.js";
import { PublishWalkers } from "./publish.js";
import { computeImmutableFields, stableGlobals, type ImmutableFields } from "../immutability.js";
import { computeCycleMutability, hierarchyUnitOfClass } from "../cycle-mutability.js";
import { computePublishedTypes, type PublishedTypes } from "../publication.js";
import { emitExpr } from "./expr-dispatch.js";
@@ -1312,8 +1313,10 @@ 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);
const mt = computeMayThrow(mod, {
stackChecks: !this.wasi && mod.lib === undefined && !windows,
published: this.publishedTypes,
});
this.mayThrow = mt.fns;
this.workerEntryPolls = mt.workerEntryPolls ?? null;
@@ -1322,11 +1325,12 @@ export class LlEmitter {
// runtime moves deep prefix nests to a fresh stack using the guard,
// which Windows lacks.
this.stacklessAsyncAllowed = this.stacklessGenerators && mod.lib === undefined && !windows;
this.publishedTypes = computePublishedTypes(mod);
this.unframedFunctions = this.stackTraces
? captureFreeFunctions(mod, this.stackChecks, this.publishedTypes)
: new Set();
this.immutableFields = computeImmutableFields(mod, dynamicStores);
// One field-mutability answer serves immutable fields and traced shapes.
const cycleMutability = computeCycleMutability(mod, hierarchyUnitOfClass(mod), dynamicStores);
this.immutableFields = computeImmutableFields(mod, dynamicStores, cycleMutability);
this.constructorFunctions = new Set((mod.classes ?? []).map((c) => `%${c.name}.constructor`));
this.indirectMayThrow = mt.indirect || mod.workers === true;
for (const cls of mod.classes ?? []) {
@@ -1451,7 +1455,7 @@ export class LlEmitter {
this.receiverReaders = new AmbientReceiverReaders(this.fnByName);
this.stackCaptures = new StackCaptures(this.fnByName, this.callLifetimes, this.stackCallbacks);
this.lazyCaptures = new LazyCaptures(this.fnByName);
const traced = computeTraced(mod, dynamicStores);
const traced = computeTraced(mod, dynamicStores, cycleMutability);
this.tracedShapes = traced.shapes;
this.tracedUnions = traced.unions;
// A shape union's value is whichever record it holds, and the collector
@@ -279,6 +279,8 @@ function shapeUnion(
* tag and payload inside the C map. */
function boxedUnionIds(mod: IrModule): Set<string> {
const boxed = new Set<string>();
// 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);
};
+8 -2
View File
@@ -35,7 +35,12 @@ export type MayThrowReason =
* and a generator's `.return()` unwind through `catch`. */
export function computeMayThrow(
mod: IrModule,
options: { stackChecks?: boolean; explain?: boolean } = {},
options: {
stackChecks?: boolean;
explain?: boolean;
/** computePublishedTypes(mod), when the caller already has it. */
published?: ReturnType<typeof computePublishedTypes>;
} = {},
): {
fns: Set<string>;
/** With `explain`: the reason each may-throw function was marked. */
@@ -50,7 +55,8 @@ export function computeMayThrow(
const terminations = mod.workers === true ? workerTerminationPolls(mod) : null;
const stackChecks = options.stackChecks === true ? stackCheckedFunctions(mod) : null;
// Stores that can reach a published object throw the frozen TypeError.
const published = computePublishedTypes(mod);
const published =
options.published !== undefined ? options.published : computePublishedTypes(mod);
interface Facts {
throws: boolean;
callees: string[];
@@ -29,35 +29,50 @@ const NON_STACK_KEY_TYPES: ReadonlySet<IrType["kind"]> = new Set(["f64", "bigint
* containers or calls; a key the program assembles from other strings or
* from data it reads keeps the static answer. */
class StackKeys {
private readonly spelled: boolean;
private readonly calls = new Map<string, { caller: IrFunction; args: IrExpr[] }[]>();
private readonly named: Set<string>;
private spelled: boolean | undefined;
private census:
| { calls: Map<string, { caller: IrFunction; args: IrExpr[] }[]>; named: Set<string> }
| undefined;
private readonly inits = new Map<IrFunction, Map<string, IrExpr | null>>();
/** Settled answers by function and local: true where a search reached a
* key that can be "stack", false where a search that found none passed. */
private readonly answers = new Map<string, boolean>();
constructor(mod: IrModule) {
this.spelled = spellsStack(mod);
this.named = new Set(moduleFunctionReferences(mod));
// 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) {}
private spellsStack(): boolean {
return (this.spelled ??= spellsStack(this.mod));
}
private callCensus(): {
calls: Map<string, { caller: IrFunction; args: IrExpr[] }[]>;
named: Set<string>;
} {
if (this.census !== undefined) return this.census;
const mod = this.mod;
const calls = new Map<string, { caller: IrFunction; args: IrExpr[] }[]>();
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));
for (const fn of mod.functions) {
if (isClassMember(fn.name, classNames)) this.named.add(fn.name);
if (isClassMember(fn.name, classNames)) named.add(fn.name);
everyStmtList(fn.body, {
stmt: () => true,
expr: (expr) => {
if (expr.kind === "call") {
const sites = this.calls.get(expr.callee) ?? [];
const sites = calls.get(expr.callee) ?? [];
sites.push({ caller: fn, args: expr.args });
this.calls.set(expr.callee, sites);
calls.set(expr.callee, sites);
}
const referenced = exprFunctionReference(expr);
if (referenced !== undefined) this.named.add(referenced);
if (referenced !== undefined) named.add(referenced);
return true;
},
});
}
return (this.census = { calls, named });
}
mayBeStack(fn: IrFunction, key: IrExpr): boolean {
@@ -71,7 +86,7 @@ class StackKeys {
private keyMayBeStack(fn: IrFunction, key: IrExpr, visited: Set<string>): boolean {
if (key.kind === "strLit") return key.value === "stack";
if (!this.spelled) return false;
if (!this.spellsStack()) return false;
if (NON_STACK_KEY_TYPES.has(key.type.kind)) return false;
if (key.kind === "toString" && NON_STACK_KEY_TYPES.has(key.operand.type.kind)) return false;
if (key.kind === "dynFrom" && NON_STACK_KEY_TYPES.has(key.value.type.kind)) return false;
@@ -93,8 +108,9 @@ class StackKeys {
if (local === undefined || local.mutable) return true;
const index = fn.params.findIndex((param) => param.localId === localId);
if (index !== -1) {
const sites = this.calls.get(fn.name);
if (this.named.has(fn.name) || sites === undefined) return true;
const { calls, named } = this.callCensus();
const sites = calls.get(fn.name);
if (named.has(fn.name) || sites === undefined) return true;
return sites.some(
(site) =>
site.args.length !== fn.params.length ||
+29 -18
View File
@@ -508,7 +508,10 @@ export function dynamicFieldStores(
};
const dynWritten = new Set<string>();
let dynWritesAll = false;
for (const fn of mod.functions)
// A store whose key is not a literal names every field: later stores add
// nothing, so the walk stops there.
for (const fn of mod.functions) {
if (dynWritesAll) break;
walkFunction(fn, {
expr: (e) => {
if (dynWritesAll) return;
@@ -524,6 +527,7 @@ export function dynamicFieldStores(
}
},
});
}
return { all: dynWritesAll, names: dynWritten };
}
@@ -536,6 +540,22 @@ export function dynamicallyStoredFamilies(
slots: Int32Slots,
stores?: DynamicFieldStores,
): Set<string> {
const records = new Map((mod.records ?? []).map((r) => [r.id, r]));
const unions = new Map((mod.unions ?? []).map((u) => [u.id, u]));
// A live dyn view (typed-ref capsule) of an instance writes back into
// its fields only after a runtime store into the capsule; the commit then
// writes every field from the view, which holds the exact snapshot value
// of any property no store named. So reaching dynamic code disqualifies
// exactly the field names some dynamic store in the program can name.
const { all: dynWritesAll, names: dynWritten } =
stores ?? dynamicFieldStores(mod, records, unions);
const storable = (field: { name: string; type: IrType }): boolean =>
field.type.kind === "f64" &&
!field.name.startsWith("%") &&
(dynWritesAll || dynWritten.has(field.name));
const families = new Set<string>();
// Without a field some store can name, no class is worth exposing.
if (!(mod.classes ?? []).some((cls) => cls.fields.some(storable))) return families;
const classes = new Map((mod.classes ?? []).map((c) => [c.name, c]));
const children = new Map<string, string[]>();
for (const cls of classes.values())
@@ -554,8 +574,6 @@ export function dynamicallyStoredFamilies(
}
return out;
};
const records = new Map((mod.records ?? []).map((r) => [r.id, r]));
const unions = new Map((mod.unions ?? []).map((u) => [u.id, u]));
const exposedClasses = new Set<string>();
const seenNamed = new Set<string>();
let exposeAll = false;
@@ -596,13 +614,6 @@ export function dynamicallyStoredFamilies(
});
}
};
// A live dyn view (typed-ref capsule) of an instance writes back into
// its fields only after a runtime store into the capsule; the commit then
// writes every field from the view, which holds the exact snapshot value
// of any property no store named. So reaching dynamic code disqualifies
// exactly the field names some dynamic store in the program can name.
const { all: dynWritesAll, names: dynWritten } =
stores ?? dynamicFieldStores(mod, records, unions);
for (const fn of mod.functions)
walkFunction(fn, {
expr: (e) => {
@@ -628,16 +639,10 @@ export function dynamicallyStoredFamilies(
if (s.kind === "throw") expose(s.value.type);
},
});
const families = new Set<string>();
for (const cls of classes.values()) {
if (!exposeAll && !exposedClasses.has(cls.name)) continue;
for (const field of cls.fields)
if (
field.type.kind === "f64" &&
!field.name.startsWith("%") &&
(dynWritesAll || dynWritten.has(field.name))
)
families.add(slots.family(cls.name, field.name)!);
if (storable(field)) families.add(slots.family(cls.name, field.name)!);
}
return families;
}
@@ -1058,12 +1063,18 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}):
if (params) for (const id of params.keys()) reads(`param:${fn.name}\0${id}`, fn.name);
// Range facts are consulted only at writes into candidate slots; slots
// are only ever dropped, so a body without such a write needs none.
const ranges: IntegerRanges = hasCandidateWrite(fn)
const candidate = hasCandidateWrite(fn);
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));
else lastWrites.delete(fn.name);
const proven: IrExpr[] = [];
// The walk below acts only at writes into candidate slots.
if (!candidate) {
provenByFunction.set(fn.name, proven);
continue;
}
const int32 = (e: IrExpr): boolean => withinInt32(ranges.get(e));
// An int32 written into a slot still assumed non-negative: widen the
// assumption when the value may be negative, and reanalyze its readers.
+42 -14
View File
@@ -605,14 +605,23 @@ const STDIO_EXTENDED_NAMES: ReadonlySet<string> = new Set(["setEncoding", "resiz
/** The libCall nodes the rules below test. */
type LibCallNode = IrExpr & { kind: "libCall" };
/** A condition over several nodes of the module (a WebCrypto call and an
* algorithm name): the scan's one walk shows it every expression, and the
* feature is set when `result` answers true after the walk. */
interface RuleScan {
expr(node: IrExpr): void;
result(): boolean;
}
/** What makes a program need one optional runtime unit. The feature is set
* when any condition holds: `initial` from the module's declarations, a
* libCall named in `fns` or starting with one of `prefixes`, a libCall
* `libCall` accepts, another expression node `expr` accepts, or a type
* slot of one of the `types` kinds. A libCall or type that needs several
* units appears in each unit's rule. */
* `libCall` accepts, another expression node `expr` accepts, a type slot of
* one of the `types` kinds, or a `scan` over the whole module. A libCall or
* type that needs several units appears in each unit's rule. */
interface RuntimeFeatureRule {
initial?: (mod: IrModule) => boolean;
scan?: () => RuleScan;
fns?: readonly IrLibFn[];
prefixes?: readonly string[];
libCall?: (node: LibCallNode) => boolean;
@@ -650,17 +659,32 @@ const WEBCRYPTO_ASYMMETRIC_NAMES: ReadonlySet<string> = new Set([
/** Whether a string literal in the module names Ed25519, without regard
* to case ("ed25519" for node:crypto, "Ed25519" for WebCrypto): the
* Ed25519 unit links then, alongside the pki unit. */
export function moduleNamesEd25519(mod: IrModule): boolean {
function ed25519NameScan(): RuleScan {
let named = false;
everyModuleNode(mod, {
return {
expr: (node) => {
if (node.kind === "strLit" && node.value.toLowerCase() === "ed25519") named = true;
return !named;
if (!named && node.kind === "strLit" && node.value.toLowerCase() === "ed25519") named = true;
},
stmt: () => !named,
type: () => true,
});
return named;
result: () => named,
};
}
/** moduleUsesWebCryptoAsymmetric's condition as a runtime-feature scan. */
function webCryptoAsymmetricScan(): RuleScan {
let webcrypto = false;
let named = false;
return {
expr: (node) => {
if (node.kind === "libCall" && node.fn.startsWith("webcrypto.")) webcrypto = true;
else if (
!named &&
node.kind === "strLit" &&
WEBCRYPTO_ASYMMETRIC_NAMES.has(node.value.toLowerCase())
)
named = true;
},
result: () => webcrypto && named,
};
}
/** Whether the module calls WebCrypto and a string literal in it names one
@@ -906,10 +930,10 @@ const RUNTIME_FEATURE_RULES = {
perfLoop: rule({ fns: ["perf.idleTime"], prefixes: ["perf.loop", "perf.eld"] }),
/** scr_pki.c over the mbedTLS archive: asymmetric KeyObjects and
* signatures. */
pki: rule({ initial: moduleUsesWebCryptoAsymmetric, fns: [...PKI_LIB_FNS] }),
pki: rule({ scan: webCryptoAsymmetricScan, fns: [...PKI_LIB_FNS] }),
/** scr_ed25519.c (the vendored libsodium Ed25519), linked with pki: a
* string literal in the module names Ed25519. */
ed25519: rule({ initial: moduleNamesEd25519 }),
ed25519: rule({ scan: ed25519NameScan }),
processEvents: rule({ fns: [...PROCESS_EVENT_LIB_FNS, "util.styleText"], types: ["procStream"] }),
/** scr_url_legacy.c parses and stringifies queries. */
qs: rule({ fns: [...QS_LIB_FNS, ...LEGACY_URL_LIB_FNS] }),
@@ -1070,9 +1094,11 @@ for (const name of RUNTIME_FEATURE_NAMES) {
function scanRuntimeFeatures(mod: IrModule, stopAt?: RuntimeFeatureName): RuntimeFeatures {
const features = noRuntimeFeatures();
const scans: { name: RuntimeFeatureName; scan: RuleScan }[] = [];
for (const name of RUNTIME_FEATURE_NAMES) {
const initial = ruleOf(name).initial;
const { initial, scan } = ruleOf(name);
if (initial && initial(mod)) features[name] = true;
if (scan) scans.push({ name, scan: scan() });
}
const keepGoing = (): boolean => stopAt === undefined || !features[stopAt];
let tables: TypeTables | undefined;
@@ -1083,6 +1109,7 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: RuntimeFeatureName): Runtim
classes: new Map((mod.classes ?? []).map((c) => [c.name, c])),
});
const expr = (node: IrExpr): boolean => {
for (let i = 0; i < scans.length; i++) scans[i]!.scan.expr(node);
if (node.kind === "libCall") {
const fn = node.fn;
for (const name of FEATURES_BY_FN.get(fn) ?? []) features[name] = true;
@@ -1101,6 +1128,7 @@ function scanRuntimeFeatures(mod: IrModule, stopAt?: RuntimeFeatureName): Runtim
return keepGoing();
};
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).
if (!features.dynTier && mod.functions.some((fn) => fn.tier === "dynamic")) {
+9 -2
View File
@@ -109,8 +109,15 @@ export interface IrModuleVisitor extends IrVisitor {
* source text, locations, labels, or other metadata. A false predicate stops
* the whole walk. Results are never retained across mutable compiler passes. */
export function everyModuleNode(mod: IrModule, visitor: IrModuleVisitor): boolean {
const type = (node: IrType, loc: SrcLoc): boolean =>
visitor.type(node, loc) && everyTypeChild(node, (child) => type(child, loc));
// Every node of one type tree reports the tree's location: one shared
// child callback serves them all.
let typeLoc: SrcLoc;
const typeNode = (node: IrType): boolean =>
visitor.type(node, typeLoc) && everyTypeChild(node, typeNode);
const type = (node: IrType, loc: SrcLoc): boolean => {
typeLoc = loc;
return typeNode(node);
};
const expr = (node: IrExpr): boolean =>
visitor.expr(node) && everyExprChild(node, expr, stmt) && type(node.type, node.loc);
const stmt = (node: IrStmt): boolean => visitor.stmt(node) && everyStmtChild(node, expr, stmt);