Revisit only the changed sites of the optional-read fixed point (#1703)

This commit is contained in:
Chris Tate
2026-10-21 14:22:33 -05:00
committed by GitHub
4 changed files with 232 additions and 12 deletions
@@ -121,6 +121,7 @@ import { ArrayElementStates } from "./runtime-optional-elements.js";
import { indexReadInBounds } from "./runtime-optional-bounds.js";
import { ArrayOwnership } from "./runtime-optional-ownership.js";
import { RecordFlowClasses } from "./runtime-optional-records.js";
import { SiteDependencies } from "./site-dependencies.js";
import { StringIndexBounds } from "./string-index-bounds.js";
import { NanCodedReads } from "./nan-coded-reads.js";
import { RuntimeOptionalLocals } from "./runtime-optional-locals.js";
@@ -4688,10 +4689,15 @@ export class Lowerer {
// resolveValueSymbol also flushes deferred diagnostics and applies
// merged-namespace fences, whose authority remains ordinary lowering.
this.checker.prefetchSymbolRoots(sourceFiles, true);
// The facts each fixed-point site reads, by the symbols, nodes and
// element keys they belong to (created with the site list below).
let siteDependencies: SiteDependencies | null = null;
const symbolOf = (node: ts.Node): ts.Symbol | null => {
const s = this.checker.getSymbolAtLocation(node);
if (!s) return null;
return s.flags & ts.SymbolFlags.Alias ? this.checker.getAliasedSymbol(s) : s;
const symbol = s.flags & ts.SymbolFlags.Alias ? this.checker.getAliasedSymbol(s) : s;
siteDependencies?.read(symbol);
return symbol;
};
const signatureOf = (node: ts.Identifier): FnSig | null => {
const symbol = symbolOf(node);
@@ -4876,7 +4882,10 @@ export class Lowerer {
const callableSymbols = new Map<ts.Expression, ts.Symbol | null>();
const callableSymbolOf = (node: ts.Expression): ts.Symbol | null => {
const cached = callableSymbols.get(node);
if (cached !== undefined) return cached;
if (cached !== undefined) {
if (cached !== null) siteDependencies?.read(cached);
return cached;
}
let symbol = symbolOf(ts.isPropertyAccessExpression(node) ? node.name : node);
if (symbol === null || signatureBySymbol.has(symbol)) {
callableSymbols.set(node, symbol);
@@ -5165,7 +5174,10 @@ export class Lowerer {
if (ts.isPropertyAccessExpression(e) && isSettledIteratorValueRead(this, e)) return true;
if (ts.isPropertyAccessExpression(e) && isOsConstantsSymbol(symbolOf(e.expression), this))
return true;
if (ts.isCallExpression(e) && this.runtimeOptionalReduceTypes.has(e)) return true;
if (ts.isCallExpression(e)) {
siteDependencies?.read(e);
if (this.runtimeOptionalReduceTypes.has(e)) return true;
}
if (
ts.isCallExpression(e) &&
e.arguments.nodes.length === 0 &&
@@ -5663,6 +5675,7 @@ export class Lowerer {
if (fnType?.kind === "func" && bodyReturnsOptional(fn)) {
const widenedReturn = this.runtimeOptionalType(fnType.ret);
provenance?.note("callback-return", fn, provenanceSite);
siteDependencies?.read(fn);
const previousReturn = this.runtimeOptionalFunctionReturnType(fn, fnType.ret);
this.runtimeOptionalFunctionReturns.set(fn, widenedReturn);
if (!typeEquals(previousReturn, widenedReturn)) changed = true;
@@ -5898,8 +5911,10 @@ export class Lowerer {
arm.elem.kind === "union"
? this.armTag(arm.elem.unionId, UNDEFINED_T) >= 0
: isUnitType(arm.elem) || arm.elem.kind === "void"
)
) {
if (!optionalElementKeys.has(key)) siteDependencies?.changed(key);
optionalElementKeys.add(key);
}
keys.push(key);
}
return keys;
@@ -6011,7 +6026,10 @@ export class Lowerer {
return keys;
};
const elementStates = new ArrayElementStates({
elementAdmitsUndefined: (key) => optionalElementKeys.has(key),
elementAdmitsUndefined: (key) => {
siteDependencies?.read(key);
return optionalElementKeys.has(key);
},
ownerOf: (node) => ownership.ownerOf(node),
allocationOwner: (node) => ownership.allocationOwner(node),
arrayKeys: arrayKeysOf,
@@ -6084,6 +6102,8 @@ export class Lowerer {
note: provenance
? (fact, key, site) => provenance.note(`element-${fact}`, site, site, key)
: null,
read: (fact) => siteDependencies?.read(fact),
changed: (fact) => siteDependencies?.changed(fact),
});
// Each pass revisits every array callback site; the last visit holds
// the decision at the fixed point.
@@ -6385,6 +6405,7 @@ export class Lowerer {
) {
if (promoteHofCallback(callback, [0, 1])) changed = true;
const result = this.runtimeOptionalType(this.irTypeOf(node));
siteDependencies?.read(node);
const previous = this.runtimeOptionalReduceTypes.get(node);
if (!previous || !typeEquals(previous, result)) {
this.runtimeOptionalReduceTypes.set(node, result);
@@ -6452,6 +6473,7 @@ export class Lowerer {
)
continue;
for (const target of familyBySymbol.get(symbol) ?? [symbol]) {
siteDependencies?.read(target);
const shape = signatureBySymbol.get(target)?.params[i];
if (!shape || shape.mode !== "required") continue;
const widened = addUndefined(shape.type);
@@ -6496,6 +6518,7 @@ export class Lowerer {
if (!ts.isSpreadElement(arg) && shape && i !== restAt && fieldsOfValue(arg)) {
const fields = fieldsOfValue(arg)!;
for (const target of familyBySymbol.get(symbol) ?? [symbol]) {
siteDependencies?.read(target);
const parameterShape = signatureBySymbol.get(target)?.params[i];
const parameter = functionDeclBySymbol.get(target)?.parameters?.nodes[i];
if (!parameterShape || parameterShape.type.kind !== "record") continue;
@@ -6541,6 +6564,7 @@ export class Lowerer {
// argument can make its elements undefined, not the pack.
// Keep every virtual implementation on the same array ABI.
for (const target of familyBySymbol.get(symbol) ?? [symbol]) {
siteDependencies?.read(target);
const rest = signatureBySymbol.get(target)?.params[i];
if (rest?.mode !== "rest" || rest.type.kind !== "array") continue;
const widened = arrayOf(addUndefined(rest.type.elem));
@@ -6562,6 +6586,7 @@ export class Lowerer {
// downstream calls and returns see the same optional value.
const family = familyBySymbol.get(symbol);
for (const target of family ?? [symbol]) {
siteDependencies?.read(target);
const parameterShape = signatureBySymbol.get(target)?.params[i];
if (!parameterShape) continue;
parameterShape.type = addUndefined(parameterShape.type);
@@ -6592,8 +6617,21 @@ export class Lowerer {
});
}
};
for (const node of sitesOf(sf)) scan(node);
return changed;
// A site whose facts are unchanged since its last visit would compute
// that visit's result again, except in a full pass.
const base = siteBase.get(sf)!;
const dependencies = siteDependencies!;
const sites = sitesOf(sf);
let fileChanged = false;
for (let i = 0; i < sites.length; i++) {
if (!fullPass && !dependencies.isStale(base + i)) continue;
changed = false;
dependencies.begin(base + i);
scan(sites[i]!);
dependencies.end(changed);
if (changed) fileChanged = true;
}
return fileChanged;
};
const scanReturns = (symbol: ts.Symbol, decl: ts.FunctionLikeDeclaration): boolean => {
let changed = false;
@@ -6643,23 +6681,41 @@ export class Lowerer {
optionalSymbols.add(symbol);
this.earlyReadVars.add(symbol);
}
let changed = true;
while (changed) {
// Number the sites of every file. The first and the last pass visit
// every site; the passes between visit only the sites whose facts
// changed (SiteDependencies). When such a pass changes nothing, a full
// pass confirms the fixed point, and the iteration resumes if it
// changes a fact after all.
const siteBase = new Map<ts.SourceFile, number>();
let siteCount = 0;
for (const sf of sourceFiles) {
siteBase.set(sf, siteCount);
siteCount += sitesOf(sf).length;
}
siteDependencies = new SiteDependencies(siteCount);
let fullPass = true;
for (let changed = true, first = true; changed || !fullPass; first = false) {
fullPass = first || !changed;
changed = false;
for (const [symbol, field] of staticFieldsBySymbol) {
if (!optionalSymbols.has(symbol) && mayBeOptional(field.initializer)) {
optionalSymbols.add(symbol);
siteDependencies.changed(symbol);
provenance?.note("static-field", field.initializer.parent, field.initializer);
changed = true;
}
}
for (const sf of sourceFiles) if (scanFile(sf)) changed = true;
for (const [symbol, decl] of functionDeclBySymbol)
if (scanReturns(symbol, decl)) changed = true;
for (const [symbol, decl] of functionDeclBySymbol) {
if (!scanReturns(symbol, decl)) continue;
siteDependencies.changed(symbol);
changed = true;
}
const noteGenericReturn = (symbol: ts.Symbol, decl: ts.FunctionLikeDeclaration): void => {
if (optionalReturns.has(symbol) || isJsSourceFile(ts.getSourceFileOfNode(decl))) return;
if (!returnsOf(decl).some(genericMayBeOptional)) return;
optionalReturns.add(symbol);
siteDependencies!.changed(symbol);
this.runtimeOptionalGenericReturns.add(decl);
provenance?.note("return", decl.name ?? decl, decl);
changed = true;
@@ -6696,6 +6752,7 @@ export class Lowerer {
: previous;
if (!typeEquals(previous, next)) {
this.runtimeOptionalFunctionReturns.set(fn, next);
siteDependencies.changed(fn);
provenance?.note("lambda-return", fn, fn.body);
changed = true;
}
@@ -6713,10 +6770,12 @@ export class Lowerer {
if (symbol && next.kind === "union" && this.armTag(next.unionId, UNDEFINED_T) >= 0) {
if (!optionalReturns.has(symbol)) {
optionalReturns.add(symbol);
siteDependencies.changed(symbol);
changed = true;
}
} else if (symbol && !arithmeticReturns.has(symbol)) {
arithmeticReturns.set(symbol, next);
siteDependencies.changed(symbol);
changed = true;
}
}
@@ -6733,6 +6792,7 @@ export class Lowerer {
: this.runtimeOptionalType(previous);
if (!typeEquals(previous, next)) {
this.runtimeOptionalFunctionReturns.set(fn, next);
siteDependencies.changed(fn);
changed = true;
}
}
@@ -6746,6 +6806,7 @@ export class Lowerer {
for (const symbol of optional ? family : []) {
if (!optionalReturns.has(symbol)) {
optionalReturns.add(symbol);
siteDependencies.changed(symbol);
provenance?.note(
"return",
functionDeclBySymbol.get(symbol),
@@ -6768,10 +6829,14 @@ export class Lowerer {
const previous = arithmeticReturns.get(symbol);
if (previous && typeEquals(previous, joined)) continue;
arithmeticReturns.set(symbol, joined);
siteDependencies.changed(symbol);
changed = true;
}
}
}
// The site facts serve only the fixed point; release them for the rest
// of lowering.
siteDependencies = null;
// NaN-coded slots convert optional values on entry instead of holding
// the union, so promotion never applies to them.
for (const symbol of [...optionalSymbols])
@@ -39,8 +39,16 @@ export interface ElementStateHost {
allocationOwner(node: ts.Expression): ts.Symbol | null;
/** Debug provenance for a new fact. */
note: ((fact: "undefined" | "holes", key: string, site: ts.Node | undefined) => void) | null;
/** The scan read, or added to, the facts of an element key, of an owning
* slot, or of every array (ANY_ARRAY), for dependency tracking
* (SiteDependencies). */
read?(fact: string | ts.Symbol): void;
changed?(fact: string | ts.Symbol): void;
}
/** The fact name of the facts shared by every array. */
const ANY_ARRAY = "*";
/** What an array value can contain besides present values. */
interface Contents {
undefined: boolean;
@@ -179,8 +187,10 @@ export class ArrayElementStates {
if (built !== undefined) return built;
}
const owner = this.host.ownerOf(e);
if (owner !== null)
if (owner !== null) {
this.host.read?.(owner);
return { undefined: this.undefinedOwners.has(owner), holes: this.holeyOwners.has(owner) };
}
return this.stored(this.host.arrayKeys(e));
}
@@ -317,7 +327,9 @@ export class ArrayElementStates {
/** The facts recorded for arrays with these element ABIs. */
private stored(keys: readonly string[] | null): Contents | null {
if (this.ownFactsOnly) return NOTHING;
this.host.read?.(ANY_ARRAY);
if (keys === null) return null;
for (const key of keys) this.host.read?.(key);
return {
undefined:
this.anyUndefined ||
@@ -333,6 +345,7 @@ export class ArrayElementStates {
const keys = this.host.arrayKeys(node);
if (keys !== null && keys.length === 0) return false;
const contents = this.contents(node);
this.host.read?.(ANY_ARRAY);
return contents === null
? this.anyUndefined || this.anyHoley
: contents.undefined || contents.holes;
@@ -368,6 +381,7 @@ export class ArrayElementStates {
this.holeyOwners.add(owner);
changed = true;
}
if (changed) this.host.changed?.(owner);
return changed;
}
let changed = false;
@@ -406,6 +420,7 @@ export class ArrayElementStates {
if (owner === null) return this.addUndefined(this.host.arrayKeys(receiver));
if (this.undefinedOwners.has(owner)) return false;
this.undefinedOwners.add(owner);
this.host.changed?.(owner);
return true;
}
@@ -415,6 +430,7 @@ export class ArrayElementStates {
if (owner === null) return this.addHoles(this.host.arrayKeys(receiver));
if (this.holeyOwners.has(owner)) return false;
this.holeyOwners.add(owner);
this.host.changed?.(owner);
return true;
}
@@ -422,6 +438,7 @@ export class ArrayElementStates {
if (keys === null) {
if (this.anyUndefined) return false;
this.anyUndefined = true;
this.host.changed?.(ANY_ARRAY);
this.host.note?.("undefined", "*", this.site);
return true;
}
@@ -429,6 +446,7 @@ export class ArrayElementStates {
for (const key of keys) {
if (this.undefinedKeys.has(key)) continue;
this.undefinedKeys.add(key);
this.host.changed?.(key);
this.host.note?.("undefined", key, this.site);
changed = true;
}
@@ -439,6 +457,7 @@ export class ArrayElementStates {
if (keys === null) {
if (this.anyHoley) return false;
this.anyHoley = true;
this.host.changed?.(ANY_ARRAY);
this.host.note?.("holes", "*", this.site);
return true;
}
@@ -446,6 +465,7 @@ export class ArrayElementStates {
for (const key of keys) {
if (this.holeyKeys.has(key)) continue;
this.holeyKeys.add(key);
this.host.changed?.(key);
this.host.note?.("holes", key, this.site);
changed = true;
}
@@ -0,0 +1,56 @@
import { expect, test } from "vitest";
import { SiteDependencies } from "./site-dependencies.js";
test("a changed fact makes the sites that read it stale", () => {
const dependencies = new SiteDependencies(3);
const a = {};
const b = "key";
dependencies.begin(0);
dependencies.read(a);
dependencies.end(false);
dependencies.begin(1);
dependencies.read(b);
dependencies.end(false);
expect([0, 1, 2].map((site) => dependencies.isStale(site))).toEqual([false, false, false]);
dependencies.changed(b);
expect([0, 1, 2].map((site) => dependencies.isStale(site))).toEqual([false, true, false]);
dependencies.begin(1);
expect(dependencies.isStale(1)).toBe(false);
});
test("a visit that changes a fact makes every reader of its facts stale", () => {
const dependencies = new SiteDependencies(3);
const shared = {};
const own = {};
dependencies.begin(0);
dependencies.read(shared);
dependencies.end(false);
dependencies.begin(2);
dependencies.read(own);
dependencies.end(false);
dependencies.begin(1);
dependencies.read(shared);
dependencies.read(shared);
dependencies.end(true);
expect([0, 1, 2].map((site) => dependencies.isStale(site))).toEqual([true, true, false]);
});
test("reads outside a visit are not recorded", () => {
const dependencies = new SiteDependencies(1);
const fact = {};
dependencies.read(fact);
dependencies.changed(fact);
expect(dependencies.isStale(0)).toBe(false);
});
test("a site visited again is listed once per fact", () => {
const dependencies = new SiteDependencies(3);
const fact = {};
for (const site of [0, 2, 1, 2, 0]) {
dependencies.begin(site);
dependencies.read(fact);
dependencies.end(false);
}
dependencies.changed(fact);
expect([0, 1, 2].map((site) => dependencies.isStale(site))).toEqual([true, true, true]);
});
@@ -0,0 +1,79 @@
/** The facts each site of a fixed-point scan read on its last visit, so that
* a pass visits again only the sites whose facts changed since.
*
* Sites are numbered from 0. A fact is any value usable as a Map key: a
* symbol, a syntax node, or a string that names a derived fact. A visit
* reports what it reads (`read`). When a visit changes a fact, every site
* that read one of the facts this visit read is visited again: the facts a
* visit changes belong to what it read. Facts changed outside a visit, or
* not read before they change, are reported with `changed`.
*
* A site that is not visited again would compute what its last visit
* computed, as long as every fact it reads is reported. The scan confirms
* that with a pass over every site once a pass changes nothing, and resumes
* when that pass changes a fact: a fact this tracking misses costs passes,
* not a different result. */
export class SiteDependencies {
/** Each fact's entry: the visit that last read it, so that a visit lists a
* fact once, followed by the sites that read it, ascending. */
private readonly readers = new Map<unknown, number[]>();
private readonly stale: Uint8Array;
private site = -1;
private visit = 0;
private facts: unknown[] = [];
constructor(siteCount: number) {
this.stale = new Uint8Array(siteCount);
}
/** A fact that site `index` read changed since its last visit. */
isStale(index: number): boolean {
return this.stale[index] === 1;
}
/** Starts a visit of site `index`. */
begin(index: number): void {
this.site = index;
this.visit++;
this.stale[index] = 0;
this.facts = [];
}
/** Ends the visit; `changed` when it changed a fact. */
end(changed: boolean): void {
this.site = -1;
if (changed) for (const fact of this.facts) this.changed(fact);
}
/** The site being visited read `fact`. */
read(fact: unknown): void {
if (this.site < 0) return;
const entry = this.readers.get(fact);
if (entry === undefined) {
this.readers.set(fact, [this.visit, this.site]);
this.facts.push(fact);
return;
}
if (entry[0] === this.visit) return;
entry[0] = this.visit;
this.facts.push(fact);
// A pass visits sites in ascending order, so a site is new at the end;
// a site visited again is usually listed already.
const site = this.site;
let low = 1;
let high = entry.length;
if (entry[high - 1]! < site) low = high;
while (low < high) {
const middle = (low + high) >>> 1;
if (entry[middle]! < site) low = middle + 1;
else high = middle;
}
if (entry[low] !== site) entry.splice(low, 0, site);
}
/** `fact` changed: every site that read it is visited again. */
changed(fact: unknown): void {
const entry = this.readers.get(fact);
if (entry !== undefined) for (let i = 1; i < entry.length; i++) this.stale[entry[i]!] = 1;
}
}