merge: integrate compiler performance improvements

- Combine the latest frontend and LLVM converter improvements with static buffer and prototype support.
This commit is contained in:
Chris Tate
2026-09-30 13:32:23 -05:00
16 changed files with 462 additions and 215 deletions
+4 -22
View File
@@ -89,7 +89,7 @@ import {
traceArg,
vAdapters,
} from "./shapes.js";
import type { ExprOf, LibCallExpr, LlStreamTypedRefAdapter, LlStreamTypedRefContext, LlValue } from "./expr-context.js";
import type { ExprOf, LibCallExpr, LlStreamTypedRefAdapter, LlValue } from "./expr-context.js";
export { LlvmUnsupportedError } from "./unsupported.js";
@@ -4698,19 +4698,10 @@ export class LlEmitter {
}
liveDynRefAdapter(t: IrType): LlStreamTypedRefAdapter {
const key = typeKey(t);
const existing = this.liveDynRefAdapters.get(key);
if (existing) return existing;
if (!streamTypedRefEligible(t) && !isDynTypedRefType(t)) {
throw new InternalCompilerError(`llvm emitter bug: live dyn ref of ${key}`);
throw new InternalCompilerError(`llvm emitter bug: live dyn ref of ${typeKey(t)}`);
}
const prefix = `sc_ldr_${this.liveDynRefAdapters.size}`;
return streamTypedRefMaterializeAdapter(
this,
t,
{ prefix, adapters: this.liveDynRefAdapters },
`${prefix}_materialize`,
);
return streamTypedRefMaterializeAdapter(this, t);
}
liveDynUnionRefAdapter(
@@ -4723,17 +4714,8 @@ export class LlEmitter {
B: BlockBuilder,
t: IrType,
value: string,
ctx: LlStreamTypedRefContext,
): string {
return streamTypedRefBoxValue(this, B, t, value, ctx);
}
streamTypedRefMaterializeAdapter(
t: IrType,
ctx: LlStreamTypedRefContext,
preferredSnapshot?: string,
): LlStreamTypedRefAdapter {
return streamTypedRefMaterializeAdapter(this, t, ctx, preferredSnapshot);
return streamTypedRefBoxValue(this, B, t, value);
}
streamFromArrayAdapter(
@@ -23,9 +23,3 @@ export interface LlStreamTypedRefAdapter {
snapshot: string;
commit: string;
}
export interface LlStreamTypedRefContext {
prefix: string;
adapters: Map<string, LlStreamTypedRefAdapter>;
unions?: Map<string, string>;
}
@@ -0,0 +1,57 @@
import { expect, test } from "vitest";
import { DYN, STRING, UNDEFINED_T, VOID, type IrFunction, type IrModule, type IrType } from "../../ir/ir.js";
import { emitLlvmModule } from "./emitter.js";
function sharedGraph(roots: number): IrModule {
const loc = { file: "live-references.ts", start: 0, end: 0 };
const leaf: IrType = { kind: "record", shapeId: "leaf" };
const root: IrType = { kind: "record", shapeId: "root" };
const next: IrType = { kind: "union", unionId: "next" };
const inputs: (IrType & { kind: "union" })[] = Array.from({ length: roots }, (_, index) => ({ kind: "union", unionId: `input${index}` }));
return {
irVersion: 13, sourceFile: loc.file, entry: "main",
records: [
{ id: "leaf", fields: [{ name: "name", type: STRING }, { name: "next", type: next }, { name: "children", type: { kind: "array", elem: leaf } }] },
{ id: "root", fields: [{ name: "first", type: leaf }, { name: "second", type: leaf }] },
],
unions: [{ id: "next", arms: [leaf, UNDEFINED_T] }, ...inputs.map((type) => ({
id: type.unionId, arms: [root, leaf, STRING],
}))],
functions: [
{ name: "main", params: [], returnType: VOID, locals: [], body: [], loc },
...inputs.map((type, index): IrFunction => ({
name: `box${index}`, params: [{ name: "value", localId: "value", type }], returnType: DYN,
locals: [{ id: "value", name: "value", type, mutable: false }], loc,
body: [{ kind: "return" as const, value: {
kind: "dynFrom" as const, value: { kind: "varRef" as const, localId: "value", type, loc }, liveRef: true, type: DYN, loc,
}, loc }],
})),
],
};
}
test("overlapping union roots share the recursive materialization graph", () => {
for (const pointerBits of [32, 64] as const) {
for (const roots of [2, 32]) {
const llvm = emitLlvmModule(sharedGraph(roots), { pointerBits });
const materializers = [...llvm.matchAll(/^define internal ptr @\S+\(ptr %p\).*; materialize live stream value (.+)$/gm)];
// One record root, one recursive leaf, and its array, independent of
// how many enclosing union arms reach the shared graph.
expect(materializers).toHaveLength(3);
expect(new Set(materializers.map((match) => match[1])).size).toBe(3);
expect(llvm.match(/; materialize live union value /g)).toHaveLength(roots + 1);
const symbols = [...llvm.matchAll(/^define .*? @(\S+)\(/gm)].map((match) => match[1]);
expect(new Set(symbols).size).toBe(symbols.length);
}
}
});
test("converter registries belong to one emitted module", () => {
const original = sharedGraph(2);
const first = emitLlvmModule(original);
const changed = sharedGraph(2);
changed.records![0]!.fields.push({ name: "extra", type: STRING });
const second = emitLlvmModule(changed);
expect(second).not.toBe(first);
expect(emitLlvmModule(original)).toBe(first);
});
@@ -8,7 +8,7 @@ import { BlockBuilder } from "./blocks.js";
import { classFieldIndex, classStructSym } from "./classes.js";
import { llvmCommentText } from "./common.js";
import { FN_ATTRS, llFieldType, releaseSym, traceArg, vAdapters } from "./shapes.js";
import type { LlvmEmitterContext, LlStreamTypedRefAdapter, LlStreamTypedRefContext } from "./expr-context.js";
import type { LlvmEmitterContext, LlStreamTypedRefAdapter } from "./expr-context.js";
export function dynPromiseAdapter(host: LlvmEmitterContext, inner: IrType): string {
if (!isRefCounted(inner) || inner.kind === "dyn") {
@@ -304,15 +304,7 @@ export function liveDynUnionRefAdapter(host: LlvmEmitterContext,
const adapters = new Map<number, LlStreamTypedRefAdapter>();
for (const { arm, tag } of mutableArms) {
const prefix = `${sym}_${tag}`;
adapters.set(
tag,
host.streamTypedRefMaterializeAdapter(
arm,
{ prefix, adapters: new Map() },
`${prefix}_materialize`,
),
);
adapters.set(tag, host.liveDynRefAdapter(arm));
}
const B = new BlockBuilder();
@@ -406,107 +398,10 @@ export function liveDynUnionRefAdapter(host: LlvmEmitterContext,
return sym;
}
function nestedTypedRefUnionAdapter(
host: LlvmEmitterContext,
t: IrType & { kind: "union" },
ctx: LlStreamTypedRefContext,
): string {
const key = typeKey(t);
if (ctx.unions === undefined) ctx.unions = new Map<string, string>();
const unions = ctx.unions;
const existing = unions.get(key);
if (existing) return existing;
const def = host.unionsById.get(t.unionId);
if (!def) {
throw new InternalCompilerError(
`llvm emitter bug: typed-ref union ${t.unionId} is undeclared`,
);
}
const sym = `${ctx.prefix}_union_${unions.size}`;
unions.set(key, sym);
const B = new BlockBuilder();
const tagPtr = B.tmp();
const tagValue = B.tmp();
B.line(`${tagPtr} = getelementptr inbounds %ScrUnion, ptr %u, i64 0, i32 1`);
B.line(`${tagValue} = load i32, ptr ${tagPtr}`);
const bad = B.newLabel("tr.union.bad");
const labels = def.arms.map(() => B.newLabel("tr.union.arm"));
B.terminate(
`switch i32 ${tagValue}, label %${bad} [ ${def.arms.map((_, index) => `i32 ${index}, label %${labels[index]}`).join(" ")} ]`,
);
def.arms.forEach((arm, index) => {
B.startBlock(labels[index]!);
if (streamTypedRefEligible(arm) || isDynTypedRefType(arm)) {
const adapter = host.streamTypedRefMaterializeAdapter(arm, ctx);
const rc = vAdapters(host.shapeHost, arm);
const armKey = typeKey(arm);
const payloadPtr = B.tmp();
const payload = B.tmp();
const boxed = B.tmp();
B.line(`${payloadPtr} = getelementptr inbounds %ScrUnion, ptr %u, i64 0, i32 5`);
B.line(`${payload} = load ptr, ptr ${payloadPtr}`);
B.line(
`${boxed} = call ptr ${typedRefConstructor(host.shapeHost, arm)}(ptr ${payload}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${host.cstr(armKey)}, ${host.sizeType} ${Buffer.byteLength(armKey, "utf8")}, ptr @${adapter.snapshot}, ptr ${adapter.commit})`,
);
B.terminate(`ret ptr ${boxed}`);
} else if (arm.kind === "undefinedT") {
host.declare(`declare ptr @scr_dyn_undefined()`);
host.declare(`declare ptr @scr_dyn_retain_v(ptr)`);
const undef = B.tmp();
const boxed = B.tmp();
B.line(`${undef} = call ptr @scr_dyn_undefined()`);
B.line(`${boxed} = call ptr @scr_dyn_retain_v(ptr ${undef})`);
B.terminate(`ret ptr ${boxed}`);
} else if (arm.kind === "nullT") {
host.declare(`declare ptr @scr_dyn_new_null()`);
const boxed = B.tmp();
B.line(`${boxed} = call ptr @scr_dyn_new_null()`);
B.terminate(`ret ptr ${boxed}`);
} else if (arm.kind === "f64") {
host.declare(`declare double @scr_union_get_f64(ptr)`);
host.declare(`declare ptr @scr_dyn_new_num(double)`);
const value = B.tmp();
const boxed = B.tmp();
B.line(`${value} = call double @scr_union_get_f64(ptr %u)`);
B.line(`${boxed} = call ptr @scr_dyn_new_num(double ${value})`);
B.terminate(`ret ptr ${boxed}`);
} else if (arm.kind === "bool") {
host.declare(`declare zeroext i1 @scr_union_get_bool(ptr)`);
host.declare(`declare ptr @scr_dyn_new_bool(i1 zeroext)`);
const value = B.tmp();
const boxed = B.tmp();
B.line(`${value} = call zeroext i1 @scr_union_get_bool(ptr %u)`);
B.line(`${boxed} = call ptr @scr_dyn_new_bool(i1 ${value})`);
B.terminate(`ret ptr ${boxed}`);
} else {
const payloadPtr = B.tmp();
const payload = B.tmp();
const boxed = B.tmp();
B.line(`${payloadPtr} = getelementptr inbounds %ScrUnion, ptr %u, i64 0, i32 5`);
B.line(`${payload} = load ptr, ptr ${payloadPtr}`);
B.line(`${boxed} = call ptr @${host.dyn.toDynHelper(arm)}(ptr ${payload})`);
B.terminate(`ret ptr ${boxed}`);
}
});
B.startBlock(bad);
host.declare(`declare void @scr_trap(ptr)`);
B.line(`call void @scr_trap(ptr ${host.cstr("scriptc: internal error: invalid union tag\n")})`);
B.terminate(`unreachable`);
host.resolveThunkDefs.push(
`define internal ptr @${sym}(ptr %u) ${FN_ATTRS} { ; typed-ref union ${key}`,
B.render(),
`}`,
``,
);
return sym;
}
export function streamTypedRefBoxValue(host: LlvmEmitterContext,
B: BlockBuilder,
t: IrType,
value: string,
ctx: LlStreamTypedRefContext,
): string {
const boxed = B.tmp();
if (
@@ -515,7 +410,7 @@ export function streamTypedRefBoxValue(host: LlvmEmitterContext,
(arm) => streamTypedRefEligible(arm) || isDynTypedRefType(arm),
) ?? false)
) {
B.line(`${boxed} = call ptr @${nestedTypedRefUnionAdapter(host, t, ctx)}(ptr ${value})`);
B.line(`${boxed} = call ptr @${host.liveDynUnionRefAdapter(t)}(ptr ${value})`);
return boxed;
}
if (!streamTypedRefEligible(t) && !isDynTypedRefType(t)) {
@@ -529,7 +424,7 @@ export function streamTypedRefBoxValue(host: LlvmEmitterContext,
);
return boxed;
}
const nested = host.streamTypedRefMaterializeAdapter(t, ctx);
const nested = host.liveDynRefAdapter(t);
const rc = vAdapters(host.shapeHost, t);
const key = typeKey(t);
@@ -541,16 +436,15 @@ export function streamTypedRefBoxValue(host: LlvmEmitterContext,
export function streamTypedRefMaterializeAdapter(host: LlvmEmitterContext,
t: IrType,
ctx: LlStreamTypedRefContext,
preferredSnapshot?: string,
): LlStreamTypedRefAdapter {
const key = typeKey(t);
const existing = ctx.adapters.get(key);
const existing = host.liveDynRefAdapters.get(key);
if (existing) return existing;
const snapshot = preferredSnapshot ??
`${ctx.prefix}_nested_${ctx.adapters.size}`;
// The converter depends only on the static type. Intern the complete
// graph across roots and union arms so recursive types emit once.
const snapshot = `sc_ldr_${host.liveDynRefAdapters.size}_materialize`;
const adapter: LlStreamTypedRefAdapter = { snapshot, commit: "null" };
ctx.adapters.set(key, adapter);
host.liveDynRefAdapters.set(key, adapter);
if (isDynTypedRefType(t)) {
const fields = host.classMeta.get(t.className)?.def.fields;
if (fields && !classDynViewSupported(fields, (id) => host.recordsById.get(id), (id) => host.unionsById.get(id))) {
@@ -596,7 +490,7 @@ export function streamTypedRefMaterializeAdapter(host: LlvmEmitterContext,
B.line(`${boolValue} = trunc i8 ${fieldValue} to i1`);
fieldValue = boolValue;
}
const boxed = host.streamTypedRefBoxValue(B, field.type, fieldValue, ctx);
const boxed = host.streamTypedRefBoxValue(B, field.type, fieldValue);
B.line(`call void @scr_dyn_obj_set(ptr ${out}, ptr ${host.cstr(field.name)}, ${host.sizeType} ${Buffer.byteLength(field.name, "utf8")}, ptr ${boxed})`);
}
if (meta.def.fields.some((field) => field.name === DYN_CLASS_PROPERTIES)) {
@@ -650,7 +544,6 @@ export function streamTypedRefMaterializeAdapter(host: LlvmEmitterContext,
B,
field.type,
fieldValue,
ctx,
);
B.line(`call void @scr_dyn_arr_push(ptr ${out}, ptr ${boxed})`);
}
@@ -682,7 +575,6 @@ export function streamTypedRefMaterializeAdapter(host: LlvmEmitterContext,
B,
field.type,
fieldValue,
ctx,
);
B.line(`call void @scr_dyn_obj_set(ptr ${out}, ptr ${host.cstr(field.name)}, ${host.sizeType} ${Buffer.byteLength(field.name, "utf8")}, ptr ${boxed})`);
}
@@ -730,7 +622,7 @@ export function streamTypedRefMaterializeAdapter(host: LlvmEmitterContext,
value = B.tmp();
B.line(`${value} = call ptr @scr_arr_get_ref(ptr %p, double ${index}) ; +1`);
}
const boxed = host.streamTypedRefBoxValue(B, elem, value, ctx);
const boxed = host.streamTypedRefBoxValue(B, elem, value);
B.line(`call void @scr_dyn_arr_push(ptr ${out}, ptr ${boxed})`);
if (isRefCounted(elem)) {
B.line(`call void ${releaseSym(host.shapeHost, elem)}(ptr ${value})`);
@@ -779,7 +671,7 @@ export function streamFromArrayAdapter(host: LlvmEmitterContext,
elem.kind !== "dyn" &&
elem.kind !== "string" &&
elem.kind !== "union";
const snapshot = `${sym}_materialize`;
let snapshot = `${sym}_materialize`;
let value: string;
if (elem.kind === "f64") {
host.declare(`declare double @scr_arr_get_f64(ptr, double)`);
@@ -872,11 +764,9 @@ export function streamFromArrayAdapter(host: LlvmEmitterContext,
const keyPtr = host.cstr(key);
let commit: string;
if (streamTypedRefEligible(elem)) {
commit = host.streamTypedRefMaterializeAdapter(
elem,
{ prefix: snapshot, adapters: new Map() },
snapshot,
).commit;
const adapter = host.liveDynRefAdapter(elem);
snapshot = adapter.snapshot;
commit = adapter.commit;
} else {
commit = host.streamTypedRefCommitAdapter(elem, snapshot);
host.resolveThunkDefs.push(
@@ -868,14 +868,14 @@ export function provenanceElidedConstDecl(lowerer: Lowerer, decl: ts.VariableDec
}
function withRuntimeOptionalScope<T>(lowerer: Lowerer, lower: () => T): T {
const optionalOnEntry = [...lowerer.runtimeOptionalLocals];
lowerer.runtimeOptionalLocals.beginScope();
try {
return lower();
} finally {
// A guard inside one branch or loop body proves presence only for
// its remaining statements. Keep assignments that reactivated a
// slot, and restore every absence possibility from the outer scope.
for (const local of optionalOnEntry) lowerer.runtimeOptionalLocals.add(local);
lowerer.runtimeOptionalLocals.endScope();
}
}
@@ -1,4 +1,5 @@
import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
import { RuntimeOptionalLocals } from "./runtime-optional-locals.js";
import { sanitizeUnregisteredClassTypes } from "./sanitize-class-types.js";
import { buildUnionNarrow } from "./union-narrow.js";
import { planUnionRetag, buildUnionRetag, planRecordUnionWrap, buildRecordUnionWrap } from "./union-retag.js";
@@ -1242,7 +1243,7 @@ export class Lowerer {
/** Locals widened beyond the checker's type because an inferred indexed
* read can be absent at runtime. Bare reads preserve that union until a
* surrounding JavaScript guard/default consumes it. */
readonly runtimeOptionalLocals = new Set<IrLocal>();
readonly runtimeOptionalLocals = new RuntimeOptionalLocals();
/** An effectful switch test can change the original binding after the
* discriminant was captured. A case match narrows the captured value,
* not that mutable binding; preserve its tagged representation on reads. */
@@ -2922,8 +2923,15 @@ export class Lowerer {
}
return e;
};
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;
let symbol = symbolOf(ts.isPropertyAccessExpression(node) ? node.name : node);
if (symbol === null || signatureBySymbol.has(symbol)) {
callableSymbols.set(node, symbol);
return symbol;
}
const seen = new Set<ts.Symbol>();
while (symbol && !signatureBySymbol.has(symbol) && !seen.has(symbol)) {
seen.add(symbol);
@@ -2934,6 +2942,7 @@ export class Lowerer {
symbol = symbolOf(peel(declaration.initializer));
} else break;
}
callableSymbols.set(node, symbol);
return symbol;
};
const explicitlyNonNull = (node: ts.Expression): boolean => {
@@ -3022,9 +3031,10 @@ export class Lowerer {
const e = peel(node);
if (!ts.isBinaryExpression(e) || e.operatorToken.kind !== ts.SyntaxKind.PlusToken) return null;
const stringArrayRead = (part: ts.Expression): boolean => {
const t = this.mapTypeOf(this.typeOf(part));
const p = peel(part);
if (t?.kind !== "string" || !ts.isElementAccessExpression(p)) return false;
if (!ts.isElementAccessExpression(p)) return false;
const t = this.mapTypeOf(this.typeOf(part));
if (t?.kind !== "string") return false;
const recv = this.mapTypeOf(this.typeOf(p.expression));
return recv?.kind === "array" && recv.elem.kind === "string";
};
@@ -3343,50 +3353,52 @@ export class Lowerer {
}
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
const callbackIndices = hofCallbackIndices(node.expression.name.text, node.arguments.length >= 2);
const receiverNode = node.expression.expression;
const receiverTs = this.typeOf(receiverNode);
let receiver = this.mapTypeOf(receiverTs);
// An array method after an optional-chain guard only runs on the
// present receiver. The checker includes the chain's short-circuit
// undefined in intermediate call types, so use the same narrowed
// receiver the method lowerer sees when promoting HOF callbacks.
if (
callbackIndices !== null && receiver?.kind === "union" &&
(node.expression.questionDotToken !== undefined || isOptionalChainTail(this, node))
) {
receiver = this.mapTypeOf(this.checker.getNonNullableType(receiverTs));
}
const callback = callbackIndices === null ? undefined : node.arguments[0];
const tuple = receiver?.kind === "record" && this.shapes.get(receiver.shapeId)?.tuple === true;
// A JS/evolving-any array may have acquired a precise FLOW type
// at this call while its actual binding remains a checked-dynamic
// array. Its callback consumes DYN values through runtime method
// dispatch, so widening that callback to the native array value
// ABI makes it impossible to box back into DYN. Judge a simple
// binding by its declaration type, the same storage fact local
// declaration lowering uses.
let nativeArrayReceiver = receiver?.kind === "array" || tuple;
if (nativeArrayReceiver && ts.isIdentifier(receiverNode)) {
const receiverSymbol = symbolOf(receiverNode);
const declaration = receiverSymbol ? this.checker.valueDeclarationOf(receiverSymbol) : undefined;
if (declaration && ts.isVariableDeclaration(declaration)) {
const declared = this.mapTypeOf(this.typeOf(declaration.name));
if (declared?.kind !== "array" && !(declared?.kind === "record" && this.shapes.get(declared.shapeId)?.tuple === true)) {
nativeArrayReceiver = false;
const callback = node.arguments[0];
if (callbackIndices !== null && callback !== undefined && !ts.isSpreadElement(callback)) {
const receiverNode = node.expression.expression;
const receiverTs = this.typeOf(receiverNode);
let receiver = this.mapTypeOf(receiverTs);
// An array method after an optional-chain guard only runs on the
// present receiver. The checker includes the chain's short-circuit
// undefined in intermediate call types, so use the same narrowed
// receiver the method lowerer sees when promoting HOF callbacks.
if (
receiver?.kind === "union" &&
(node.expression.questionDotToken !== undefined || isOptionalChainTail(this, node))
) {
receiver = this.mapTypeOf(this.checker.getNonNullableType(receiverTs));
}
const tuple = receiver?.kind === "record" && this.shapes.get(receiver.shapeId)?.tuple === true;
// A JS/evolving-any array may have acquired a precise FLOW type
// at this call while its actual binding remains a checked-dynamic
// array. Its callback consumes DYN values through runtime method
// dispatch, so widening that callback to the native array value
// ABI makes it impossible to box back into DYN. Judge a simple
// binding by its declaration type, the same storage fact local
// declaration lowering uses.
let nativeArrayReceiver = receiver?.kind === "array" || tuple;
if (nativeArrayReceiver && ts.isIdentifier(receiverNode)) {
const receiverSymbol = symbolOf(receiverNode);
const declaration = receiverSymbol ? this.checker.valueDeclarationOf(receiverSymbol) : undefined;
if (declaration && ts.isVariableDeclaration(declaration)) {
const declared = this.mapTypeOf(this.typeOf(declaration.name));
if (declared?.kind !== "array" && !(declared?.kind === "record" && this.shapes.get(declared.shapeId)?.tuple === true)) {
nativeArrayReceiver = false;
}
}
}
}
if (nativeArrayReceiver && callback && !ts.isSpreadElement(callback)) {
if (promoteHofCallback(callback, callbackIndices!)) changed = true;
const method = node.expression.name.text;
if ((method === "reduce" || method === "reduceRight") &&
(node.arguments.length < 2 || callbackReturnsOptional(callback))) {
if (promoteHofCallback(callback, [0, 1])) changed = true;
const result = this.runtimeOptionalType(this.irTypeOf(node));
const previous = this.runtimeOptionalReduceTypes.get(node);
if (!previous || !typeEquals(previous, result)) {
this.runtimeOptionalReduceTypes.set(node, result);
changed = true;
if (nativeArrayReceiver) {
if (promoteHofCallback(callback, callbackIndices)) changed = true;
const method = node.expression.name.text;
if ((method === "reduce" || method === "reduceRight") &&
(node.arguments.length < 2 || callbackReturnsOptional(callback))) {
if (promoteHofCallback(callback, [0, 1])) changed = true;
const result = this.runtimeOptionalType(this.irTypeOf(node));
const previous = this.runtimeOptionalReduceTypes.get(node);
if (!previous || !typeEquals(previous, result)) {
this.runtimeOptionalReduceTypes.set(node, result);
changed = true;
}
}
}
}
@@ -0,0 +1,82 @@
import { expect, test } from "vitest";
import { F64, type IrLocal } from "../../ir/ir.js";
import { RuntimeOptionalLocals } from "./runtime-optional-locals.js";
const local = (id: string): IrLocal => ({ id, name: id, type: F64, mutable: true });
test("scope exit restores entry facts and keeps surviving assignments", () => {
const facts = new RuntimeOptionalLocals();
const outer = local("outer"), added = local("added"), temporary = local("temporary");
facts.add(outer);
facts.beginScope();
expect(facts.delete(outer)).toBe(true);
expect(facts.delete(outer)).toBe(false);
facts.add(added);
facts.add(temporary);
facts.delete(temporary);
facts.endScope();
expect(facts.has(outer)).toBe(true);
expect(facts.has(added)).toBe(true);
expect(facts.has(temporary)).toBe(false);
});
test("nested restores preserve the original entry generation", () => {
const facts = new RuntimeOptionalLocals();
const value = local("value");
facts.add(value);
facts.beginScope();
facts.beginScope();
facts.delete(value);
facts.add(value);
facts.endScope();
facts.delete(value);
facts.endScope();
expect(facts.has(value)).toBe(true);
});
test("an absent outer member added and removed inside a child stays absent until the outer exit", () => {
const facts = new RuntimeOptionalLocals();
const value = local("value");
facts.add(value);
facts.beginScope();
facts.delete(value);
facts.beginScope();
facts.add(value);
facts.delete(value);
facts.endScope();
expect(facts.has(value)).toBe(false);
facts.endScope();
expect(facts.has(value)).toBe(true);
expect(() => facts.endScope()).toThrow("not open");
});
test("nested operations agree with snapshot-and-union restoration", () => {
const values = Array.from({ length: 12 }, (_, index) => local(String(index)));
const facts = new RuntimeOptionalLocals();
const reference = new Set<IrLocal>();
const snapshots: IrLocal[][] = [];
let random = 0xabc123;
for (let step = 0; step < 12000; step++) {
random = (Math.imul(random, 1664525) + 1013904223) >>> 0;
const value = values[(random >>> 8) % values.length]!;
const action = random % 6;
if (action === 0 && snapshots.length < 12) {
snapshots.push([...reference]);
facts.beginScope();
} else if (action === 1 && snapshots.length > 0) {
for (const entry of snapshots.pop()!) reference.add(entry);
facts.endScope();
} else if (action < 4) {
reference.add(value);
facts.add(value);
} else {
expect(facts.delete(value)).toBe(reference.delete(value));
}
for (const entry of values) expect(facts.has(entry), `step ${step}, local ${entry.id}`).toBe(reference.has(entry));
}
while (snapshots.length > 0) {
for (const entry of snapshots.pop()!) reference.add(entry);
facts.endScope();
}
for (const entry of values) expect(facts.has(entry)).toBe(reference.has(entry));
});
@@ -0,0 +1,42 @@
import type { IrLocal } from "../../ir/ir.js";
/** Absence facts survive assignments, but a branch-local guard must not
* narrow an outer binding after that branch ends. Scope exit restores the
* members present on entry while retaining new facts learned inside it. */
export class RuntimeOptionalLocals {
private generation = 0;
private readonly active = new Map<IrLocal, number>();
private readonly scopes: { generation: number; removed: Map<IrLocal, number> }[] = [];
has(local: IrLocal): boolean { return this.active.has(local); }
add(local: IrLocal): void {
if (!this.active.has(local)) this.active.set(local, ++this.generation);
}
delete(local: IrLocal): boolean {
const generation = this.active.get(local);
if (generation === undefined) return false;
this.active.delete(local);
const scope = this.scopes[this.scopes.length - 1];
if (scope !== undefined && generation <= scope.generation) scope.removed.set(local, generation);
return true;
}
beginScope(): void {
// Only removals of entry members need undoing. Recording their original
// generation avoids copying all locals seen by the lowering pass.
this.scopes.push({ generation: this.generation, removed: new Map() });
}
endScope(): void {
const scope = this.scopes.pop();
if (scope === undefined) throw new Error("runtime optional scope is not open");
for (const [local, generation] of scope.removed) {
// A removed member may have been added again in a nested scope. Keep
// its original age so an enclosing scope still recognizes it.
const current = this.active.get(local);
if (current === undefined || current > generation) this.active.set(local, generation);
}
}
}
+16 -6
View File
@@ -103,13 +103,23 @@ export class AstFile {
* properties are views over the wire; they never copy identity-bearing
* nodes into structural records. */
export class AstNode {
constructor(readonly file: AstFile, readonly index: number) {}
readonly kind: SyntaxKind;
readonly pos: number;
readonly end: number;
readonly flags: NodeFlags;
readonly data: number;
constructor(readonly file: AstFile, readonly index: number) {
// Materialize immutable scalar metadata once. Lowering repeatedly reads
// it while refining the same node, without needing another wire decode.
const wire = file.wire;
this.kind = wire.kind(index);
this.pos = wire.pos(index);
this.end = wire.end(index);
this.flags = wire.flags(index);
this.data = wire.data(index);
}
get kind(): SyntaxKind { return this.file.wire.kind(this.index); }
get pos(): number { return this.file.wire.pos(this.index); }
get end(): number { return this.file.wire.end(this.index); }
get flags(): NodeFlags { return this.file.wire.flags(this.index); }
get data(): number { return this.file.wire.data(this.index); }
get id(): string { return `${this.index}.${this.kind}.${this.file.root.path}`; }
get parent(): AstNode | undefined {
const index = this.file.wire.semanticParent(this.index);
@@ -59,6 +59,27 @@ test("hash words remain unsigned and ordered", () => {
expect(file.parseOptionsKey).toBe("4294967295");
});
test("node words respect a byte view's offset, signed spans and unsigned flags", () => {
const original = response();
const padded = new Uint8Array(original.length + 11).fill(0xff);
padded.set(original, 3);
const bytes = padded.subarray(3, 3 + original.length);
const at = 120 + 3 * NODE_LEN;
word(bytes, at + 4, 0xffffffff);
word(bytes, at + 8, 0x80000000);
word(bytes, at + 24, 0xfedcba98);
const file = new AstWireFile(bytes);
expect(file.kind(3)).toBe(AstKind.Identifier);
expect(file.pos(3)).toBe(-1);
expect(file.end(3)).toBe(-2147483648);
expect(file.flags(3)).toBe(0xfedcba98);
expect(file.data(3)).toBe(0x40000002);
expect(file.next(3)).toBe(4);
expect(file.parent(3)).toBe(2);
expect(file.kind(4)).toBe(AstKind.Identifier);
expect(() => file.kind(5)).toThrow(AstDecodeError);
});
test("truncated headers, sections and node tables are rejected", () => {
for (let size = 0; size < HEADER_SIZE; size++) expect(() => new AstWireFile(new Uint8Array(size))).toThrow(AstDecodeError);
for (const [offset, value] of [[0, 0], [24, 40], [28, 59], [28, 67], [32, 59], [36, 69], [40, 119], [40, 0xffffffff], [48, 9], [52, 8], [56, 2]]) {
+13 -8
View File
@@ -1,4 +1,4 @@
import { AstDecodeError, AstMsgpackReader, astBounds, astI32, astU32, decodeAstString } from "./ast-bytes.js";
import { AstDecodeError, AstMsgpackReader, astBounds, astU32, decodeAstString } from "./ast-bytes.js";
import {
AstKind, HEADER_OFFSET_EXTENDED_DATA, HEADER_OFFSET_HASH_HI0, HEADER_OFFSET_HASH_HI1,
HEADER_OFFSET_HASH_LO0, HEADER_OFFSET_HASH_LO1, HEADER_OFFSET_METADATA, HEADER_OFFSET_NODES,
@@ -64,10 +64,12 @@ export class AstWireFile {
private readonly extended: number;
private readonly structured: number;
private readonly nodes: number;
private readonly view: DataView;
private readonly stringCache = new Map<number, string>();
constructor(private readonly bytes: Uint8Array) {
astBounds(bytes, 0, HEADER_SIZE);
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const metadata = astU32(bytes, HEADER_OFFSET_METADATA);
if ((metadata >>> 24) !== PROTOCOL_VERSION) throw new AstDecodeError(`unsupported protocol version ${metadata >>> 24}`);
this.stringOffsets = astU32(bytes, HEADER_OFFSET_STRING_TABLE_OFFSETS);
@@ -104,20 +106,23 @@ export class AstWireFile {
return this.nodes + index * NODE_LEN;
}
kind(index: number): number { return astU32(this.bytes, this.nodeOffset(index) + NODE_OFFSET_KIND); }
pos(index: number): number { return astI32(this.bytes, this.nodeOffset(index) + NODE_OFFSET_POS); }
end(index: number): number { return astI32(this.bytes, this.nodeOffset(index) + NODE_OFFSET_END); }
flags(index: number): number { return astU32(this.bytes, this.nodeOffset(index) + NODE_OFFSET_FLAGS); }
data(index: number): number { return astU32(this.bytes, this.nodeOffset(index) + NODE_OFFSET_DATA); }
// The constructor validates the complete fixed-width node table, and
// nodeOffset validates its index. Read each word in one operation while
// preserving little-endian decoding even in an unaligned byte view.
kind(index: number): number { return this.view.getUint32(this.nodeOffset(index) + NODE_OFFSET_KIND, true); }
pos(index: number): number { return this.view.getInt32(this.nodeOffset(index) + NODE_OFFSET_POS, true); }
end(index: number): number { return this.view.getInt32(this.nodeOffset(index) + NODE_OFFSET_END, true); }
flags(index: number): number { return this.view.getUint32(this.nodeOffset(index) + NODE_OFFSET_FLAGS, true); }
data(index: number): number { return this.view.getUint32(this.nodeOffset(index) + NODE_OFFSET_DATA, true); }
next(index: number): number {
const next = astU32(this.bytes, this.nodeOffset(index) + NODE_OFFSET_NEXT);
const next = this.view.getUint32(this.nodeOffset(index) + NODE_OFFSET_NEXT, true);
if (next !== 0 && (next <= index || next >= this.nodeCount)) throw new AstDecodeError("invalid sibling link");
return next;
}
parent(index: number): number {
const parent = astU32(this.bytes, this.nodeOffset(index) + NODE_OFFSET_PARENT);
const parent = this.view.getUint32(this.nodeOffset(index) + NODE_OFFSET_PARENT, true);
if (parent >= this.nodeCount || (index <= 1 ? parent > index : parent >= index)) throw new AstDecodeError("invalid parent link");
return parent;
}
@@ -22,6 +22,7 @@ export class CheckerCache {
readonly assignableTypes = new Map<Type, Map<Type, boolean>>();
readonly nonNullableType = new Map<Type, Type | undefined>();
readonly propertiesOfType = new Map<Type, readonly Ts7Symbol[]>();
readonly propertyOfType = new Map<Type, Map<string, Ts7Symbol | undefined>>();
readonly indexInfosOfType = new Map<Type, readonly IndexInfo[]>();
readonly typeArgumentsOf = new Map<Type, readonly Type[]>();
readonly arrayTypeAnswer = new Map<Type, boolean>();
@@ -67,6 +68,7 @@ export class CheckerCache {
this.assignableTypes.clear();
this.nonNullableType.clear();
this.propertiesOfType.clear();
this.propertyOfType.clear();
this.indexInfosOfType.clear();
this.typeArgumentsOf.clear();
this.arrayTypeAnswer.clear();
@@ -253,3 +253,70 @@ test("negative assignability answers remain independent in both directions", ()
expect(() => facade.isTypeAssignableTo(b, a)).toThrow("disposed");
expect(requests).toHaveLength(2);
});
test("property lookup caches hits and misses by type and name within each project", () => {
const requests: string[] = [];
let fail = true;
const snapshot = new SemanticSnapshot(1, {
text: (method, payload) => {
expect(method).toBe("getPropertyOfType");
const query = JSON.parse(payload) as { project: string; type: number; name: string };
requests.push(`${query.project}:${query.type}:${query.name}`);
if (query.name === "retry" && fail) { fail = false; throw new Error("transient failure"); }
if (query.name === "missing") return "null";
return JSON.stringify({ id: query.type * 10 + (query.project === "first" ? 1 : 2), project: query.project,
name: query.name, flags: 4, checkFlags: 0 });
},
binary: () => { throw new Error("unexpected binary request"); },
});
const first = snapshot.addProject("first", () => undefined);
const second = snapshot.addProject("second", () => undefined);
const a = new CheckerFacade(new SemanticChecker(first));
const b = new CheckerFacade(new SemanticChecker(second));
const firstType = first.type({ id: 1, flags: TypeFlags.Object });
const otherType = first.type({ id: 2, flags: TypeFlags.Object });
const secondType = second.type({ id: 1, flags: TypeFlags.Object });
const symbol = a.getPropertyOfType(firstType, "value");
for (let repeat = 0; repeat < 3; repeat++) {
expect(a.getPropertyOfType(firstType, "value")).toBe(symbol);
expect(a.getPropertyOfType(firstType, "missing")).toBeUndefined();
expect(a.getPropertyOfType(otherType, "value")).not.toBe(symbol);
expect(b.getPropertyOfType(secondType, "value")).not.toBe(symbol);
}
expect(requests).toEqual(["first:1:value", "first:1:missing", "first:2:value", "second:1:value"]);
expect(() => a.getPropertyOfType(firstType, "retry")).toThrow("transient failure");
expect(a.getPropertyOfType(firstType, "retry")).toBeDefined();
a.getPropertyOfType(firstType, "retry");
expect(requests.slice(4)).toEqual(["first:1:retry", "first:1:retry"]);
first.dispose();
expect(() => a.getPropertyOfType(firstType, "value")).toThrow("disposed");
expect(() => a.getPropertyOfType(firstType, "missing")).toThrow("disposed");
expect(b.getPropertyOfType(secondType, "value")).toBeDefined();
snapshot.dispose();
});
test("dependency edits invalidate positive and negative property answers", () => {
const h = connect({ "tsconfig.json": config,
"main.ts": 'import { value } from "./dependency.js"; export const result = value;',
"dependency.ts": "export const value = { before: 1 };" });
try {
const first = h.session.updateSnapshot({ openProjects: [h.path("tsconfig.json")] });
const project = first.getProjects()[0]!;
const root = project.program.getSourceFile(h.path("main.ts"))!;
const old = new CheckerFacade(project.checker);
const type = old.getTypeAtLocation(declaration(root, "result").name!);
expect(old.getPropertyOfType(type, "before")).toBeDefined();
expect(old.getPropertyOfType(type, "after")).toBeUndefined();
h.paths.set(h.path("dependency.ts"), "export const value = { after: 2 };");
const second = h.session.updateSnapshot({ fileChanges: { changed: [h.path("dependency.ts")] } });
const nextProject = second.getProjects()[0]!;
const nextRoot = nextProject.program.getSourceFile(h.path("main.ts"))!;
expect(nextRoot).toBe(root);
const next = new CheckerFacade(nextProject.checker);
const nextType = next.getTypeAtLocation(declaration(nextRoot, "result").name!);
expect(next.getPropertyOfType(nextType, "before")).toBeUndefined();
expect(next.getPropertyOfType(nextType, "after")).toBeDefined();
expect(old.getPropertyOfType(type, "before")).toBeDefined();
expect(old.getPropertyOfType(type, "after")).toBeUndefined();
} finally { h.close(); }
});
+11 -1
View File
@@ -288,7 +288,17 @@ export class CheckerFacade {
/** 5.9.3's type.getProperty(name). */
getPropertyOfType(type: Type, name: string): Ts7Symbol | undefined {
this.ensureActive();
return this.raw.getPropertyOfType(type, name);
let properties = this.cache.propertyOfType.get(type);
if (properties === undefined) {
properties = new Map<string, Ts7Symbol | undefined>();
this.cache.propertyOfType.set(type, properties);
}
// Absence is an answer too. Both keys belong to this immutable project;
// another snapshot or project must ask its own checker.
if (properties.has(name)) return properties.get(name);
const symbol = this.raw.getPropertyOfType(type, name);
properties.set(name, symbol);
return symbol;
}
/** The 5.9.3 checker never answered undefined from getTypeAtLocation-
@@ -9286,6 +9286,12 @@
],
"diags": []
},
"<repo>/tests/corpus/live-reference-shared-converters.ts": {
"order": [
"<repo>/tests/corpus/live-reference-shared-converters.ts"
],
"diags": []
},
"<repo>/tests/corpus/llvm-read-receiver-lifetime.ts": {
"order": [
"<repo>/tests/corpus/llvm-read-receiver-lifetime.ts"
@@ -0,0 +1,67 @@
class Cell {
name: string;
value: number;
next: Cell | undefined = undefined;
children: Cell[] = [];
constructor(name: string, value: number) {
this.name = name;
this.value = value;
}
}
class Envelope {
label: string;
child: Cell;
constructor(label: string, child: Cell) {
this.label = label;
this.child = child;
}
}
function first(value: Cell | Envelope | undefined): unknown { return value; }
function second(value: Cell | string | null): unknown { return value; }
function third(value: Envelope | number | boolean): unknown { return value; }
function render(value: unknown): string { return JSON.stringify(value); }
const leaf = new Cell("leaf", 1);
const parent = new Cell("parent", 2);
parent.next = leaf;
parent.children.push(leaf);
const envelope = new Envelope("shared", parent);
const one = first(parent);
const two = second(parent);
const three = third(envelope);
console.log(one === two, (one as Cell) === parent, (three as Envelope).child === parent);
console.log(JSON.stringify(one), JSON.stringify(two), JSON.stringify(three));
leaf.value = 9;
console.log(JSON.stringify(one), JSON.stringify(three));
const view = one as { value: unknown; next: unknown; children: unknown };
view.value = 7;
console.log(parent.value, (two as Cell).value);
const childView = view.next as { value: unknown };
childView.value = 11;
console.log(leaf.value, parent.children[0] === leaf);
// The same type occurs as a union arm, a nested field, and a stream element.
async function streams(): Promise<void> {
const records: { name: string; cell: Cell }[] = [
{ name: "first", cell: parent }, { name: "second", cell: leaf },
];
const direct: unknown = records[0]!;
console.log(render(direct));
for await (const item of ReadableStream.from(records)) {
console.log(item.name, item.cell === parent || item.cell === leaf, render(item));
item.cell.value++;
}
console.log(parent.value, leaf.value, render(direct));
console.log(first(undefined) === undefined, second(null) === null, second("text"), third(4), third(false));
parent.next = parent;
try { JSON.stringify(one); }
catch (error) { console.log("cycle", error instanceof TypeError); }
parent.next = leaf;
console.log(JSON.stringify(two));
}
void streams();