Keep the borrowed dispatch path through the inherited-method fallback

The keyed read of a record of functions is now `r[k] ?? inherited(r, k)`.
A pass-through `??` over a pointer keyed read whose stored values are never
unit lends the hit like the plain read: canBorrowReceiver and
ReferenceWrites.readKeys see through it, a default that only returns
interned function values or undefined needs no owner (so `const h = r[k]`
still aliases the entry), and a bound default is owned by a statement-frame
slot whose release skips the call while it stays empty. The miss helper
stays out of line, and the impls avoid code that would make every indirect
call look like a heap writer. An emission test pins that function-table
dispatch neither boxes, retains nor looks up through the runtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-25 16:28:16 -07:00
co-authored by Claude Opus 5.5
parent 14bb8d4156
commit 7dfe5e7033
8 changed files with 259 additions and 22 deletions
@@ -655,6 +655,9 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
// checked dynamic call. Keeping that fallback out of line leaves the
// tests small enough to inline at each call site.
if (fn.name.startsWith("%dyn.class.protocol.fallback.")) attrs = `noinline ${attrs}`;
// A keyed record read's inherited-method miss path (record-inherited.ts)
// stays out of line, so the hit path keeps its own small frame.
else if (fn.name.startsWith("%rec.inherited.")) attrs = `noinline cold ${attrs}`;
else if (fn.name.startsWith("%dyn.class.protocol.")) attrs = `inlinehint ${attrs}`;
if (fn.name.startsWith(RESULT_ENTRY_PREFIX)) attrs = `inlinehint ${attrs}`;
if (unoptimized && attrs === FN_ATTRS) attrs = `noinline optnone ${attrs}`;
+28 -2
View File
@@ -292,8 +292,10 @@ import {
emitBytesIntrinsic,
} from "./expr-bytes.js";
import {
borrowedRecordKeyGetOr,
emitRecordKeyGet,
emitRegexIntrinsic,
immortalDefault,
keyedRecordReadInto,
pointerRecordKeyGet,
} from "./expr-records.js";
@@ -3377,8 +3379,14 @@ export class LlEmitter {
/** Registers a SLOT whose current contents the frame owns (conditional
* results: optional chains, branch joins that park ownership). */
ownSlot(slot: string, type: IrType): void {
if (isRefCounted(type)) this.currentFrame().push({ name: slot, type, slot: true });
ownSlot(slot: string, type: IrType, rarelyFilled = false): void {
if (isRefCounted(type))
this.currentFrame().push({
name: slot,
type,
slot: true,
...(rarelyFilled ? { rarelyFilled: true } : {}),
});
}
/** The innermost lexical scope releases a frame object's reference
@@ -3489,6 +3497,17 @@ export class LlEmitter {
this.B.line(`${t} = load ptr, ptr ${v.name}`);
if (v.boxed) {
this.B.line(`call void ${boxReleaseSym(this.shapeHost)}(ptr ${t})`);
} else if (v.rarelyFilled && !this.B.isTerminated()) {
// Skip the release call on the common empty path.
const filled = this.B.tmp();
this.B.line(`${filled} = icmp ne ptr ${t}, null`);
const release = this.B.newLabel("slot.release"),
join = this.B.newLabel("slot.release.j");
this.B.condBr(filled, release, join);
this.B.startBlock(release);
this.releaseFrame(t, v.type);
this.B.br(join);
this.B.startBlock(join);
} else {
this.releaseFrame(t, v.type);
}
@@ -6038,6 +6057,11 @@ export class LlEmitter {
case "recordGet":
case "recordKeyGet":
return this.borrowsWithoutOwning(e) && this.unownedWalk(e.obj);
// `r[k] ?? inherited(r, k)` whose default is immortal.
case "nullish": {
const read = borrowedRecordKeyGetOr(this, e);
return read !== null && immortalDefault(this, e.right) && this.unownedWalk(read);
}
case "mapIntrinsic": {
// A Map binding's own pointer (maps are not plainReference types).
if (!this.borrowsWithoutOwning(e) || e.receiver.kind !== "varRef") return false;
@@ -7313,6 +7337,8 @@ export class LlEmitter {
return pointerRecordKeyGet(this, e);
case "mapIntrinsic":
return pointerMapGet(this, e);
case "nullish":
return borrowedRecordKeyGetOr(this, e) !== null;
// emitReadReceiver lends a required element of a borrowed array when
// computing the index removes no edge.
case "arrayGet":
@@ -22,6 +22,8 @@ export interface LlValue {
* use it in place of a round trip (integer-values.ts `indexInteger`). */
wideInteger?: WideInteger;
slot?: boolean;
/** A frame slot that usually stays NULL: its release tests it first. */
rarelyFilled?: boolean;
/** A sequence local held in a capture box; release the box itself. */
boxed?: boolean;
}
@@ -1,7 +1,14 @@
/* Focused LLVM expression emission extracted from emitter.ts. */
import { InternalCompilerError } from "../../errors.js";
import { undefinedArmTag, unionWideningTags } from "../../ir/analysis.js";
import { type IrExpr, type IrType, isRefCounted, type SrcLoc, typeEquals } from "../../ir/ir.js";
import {
type IrExpr,
type IrType,
isRefCounted,
isUnitType,
type SrcLoc,
typeEquals,
} from "../../ir/ir.js";
import { LlvmUnsupportedError } from "./unsupported.js";
import type { LlvmEmitterContext, LlValue } from "./expr-context.js";
import { f64Lit, llvmCommentText } from "./common.js";
@@ -11,6 +18,7 @@ import { canReplaceWith, regexLiteralCompiles } from "./regex-matchers.js";
import { isScalarUnionDef, loadInlineUnion, presentBits } from "./inline-unions.js";
import { preservesRegexInputs } from "./reference-effects.js";
import { emitNumberMapSlot, emitStringMapEntry } from "./map-number-lookup.js";
import { everyStmtList } from "../../ir/traverse.js";
/** Widen one tagged read. The source is borrowed unless the overflow map
* supplied an owned value. Each selected reference payload is retained
@@ -468,6 +476,7 @@ export function pointerRecordKeyGet(host: LlvmEmitterContext, e: IrExpr): boolea
export function emitBorrowedRecordKeyGet(
host: LlvmEmitterContext,
e: IrExpr & { kind: "recordKeyGet" },
fallback: IrExpr | null = null,
): LlValue {
const B = host.B;
if (e.type.kind !== "union")
@@ -477,6 +486,15 @@ export function emitBorrowedRecordKeyGet(
const key = host.canBorrowReceiver(e.key) ? host.emitReadReceiver(e.key) : host.emitExpr(e.key);
const slot = B.slot();
B.entryAllocas.push(`${slot} = alloca ptr`);
// A default that needs an owner gets a frame slot, empty on every path
// until a miss fills it.
let owner: string | null = null;
if (fallback !== null && !immortalDefault(host, fallback)) {
owner = B.slot();
B.entryAllocas.push(`${owner} = alloca ptr`);
B.line(`store ptr null, ptr ${owner}`);
host.ownSlot(owner, resultType, true);
}
const join = B.newLabel("rkb.j");
const shape = host.recordShape(e.shapeId);
for (const f of e.overflowOnly === true ? [] : shape.fields) {
@@ -506,8 +524,23 @@ export function emitBorrowedRecordKeyGet(
B.br(join);
B.startBlock(ln);
}
const undefTag = undefinedArmTag(resultType, host.unionsById);
B.line(`store ptr ${host.unitInstanceRef(resultType.unionId, undefTag)}, ptr ${slot}`);
if (fallback !== null && immortalDefault(host, fallback)) {
// An immortal default (an interned function value or undefined) needs
// no owner at all.
const value = host.emitExpr(fallback);
host.toHeap(value);
B.line(`store ptr ${value.name}, ptr ${slot}`);
} else if (fallback !== null && owner !== null) {
// `r[k] ?? d` (borrowedRecordKeyGetOr): the default is owned by a
// statement-frame slot, which the consumer may borrow like a hit.
const value = host.emitExpr(fallback);
host.toHeap(value);
B.line(`store ptr ${value.name}, ptr ${owner}`);
B.line(`store ptr ${value.name}, ptr ${slot}`);
} else {
const undefTag = undefinedArmTag(resultType, host.unionsById);
B.line(`store ptr ${host.unitInstanceRef(resultType.unionId, undefTag)}, ptr ${slot}`);
}
B.br(join);
B.startBlock(join);
const t = B.tmp();
@@ -515,6 +548,54 @@ export function emitBorrowedRecordKeyGet(
return { name: t, type: e.type };
}
/** `r[k] ?? d` in its pass-through shape over a pointerRecordKeyGet whose
* stored values are never unit (so only a miss evaluates `d`): the hit
* lends the stored pointer and the default is frame-owned, so the result
* borrows for the rest of the statement. */
export function borrowedRecordKeyGetOr(
host: LlvmEmitterContext,
e: IrExpr,
): (IrExpr & { kind: "recordKeyGet" }) | null {
if (e.kind !== "nullish" || e.left.kind !== "recordKeyGet") return null;
if (!typeEquals(e.type, e.left.type) || !pointerRecordKeyGet(host, e.left)) return null;
const shape = host.recordShape(e.left.shapeId);
const holdsUnit = (t: IrType): boolean =>
isUnitType(t) ||
t.kind === "dyn" ||
(t.kind === "union" && (host.unionsById.get(t.unionId)?.arms ?? []).some(isUnitType));
if (shape.indexValue !== undefined && holdsUnit(shape.indexValue)) return null;
if (e.left.overflowOnly !== true && shape.fields.some((f) => holdsUnit(f.type))) return null;
return e.left;
}
/** The default is a call to a helper whose every result is immortal: the
* unit arm or a closure over no captures (an interned function value), as
* the unbound inherited-method reads return. */
export function immortalDefault(host: LlvmEmitterContext, fallback: IrExpr): boolean {
if (fallback.kind !== "call") return false;
const fn = host.fnByName.get(fallback.callee);
if (!fn || fn.async || fn.generator) return false;
const immortal = (v: IrExpr | null): boolean => {
if (v === null) return false;
if (v.kind === "unitLit") return true;
if (v.kind === "unionWrap") return immortal(v.value);
if (v.kind === "closure") {
const target = host.fnByName.get(v.fnName);
return v.captures.length === 0 && target !== undefined && target.captures === undefined;
}
return false;
};
let ok = true;
everyStmtList(fn.body, {
stmt: (st) => {
if (st.kind === "return" && !immortal(st.value)) ok = false;
return ok;
},
expr: () => ok,
});
return ok;
}
/** A keyed read whose result is a scalar union (`r[k]` on a
* `Record<string, number>`, at `number | undefined`) and whose every hit
* is a number or boolean arm of it, or an inline field of the same union.
@@ -24,6 +24,7 @@ import { emitDowncastGuard, emitUnionNarrowGuard } from "./narrow-guards.js";
import { emitScalarOptional, isScalarOptionalSource } from "./local-union-storage.js";
import { borrowableInputs } from "./borrowed-inputs.js";
import {
borrowedRecordKeyGetOr,
emitBorrowedRecordKeyGet,
emitStackRecordKeyGet,
pointerRecordKeyGet,
@@ -104,6 +105,10 @@ export function emitReadReceiver(emitter: LlEmitter, e: IrExpr): LlValue {
if (arrayRead) return emitProjectedArrayRead(emitter, arrayRead);
if (e.kind === "recordKeyGet" && emitter.canBorrowReceiver(e))
return emitBorrowedRecordKeyGet(emitter, e);
if (e.kind === "nullish" && emitter.canBorrowReceiver(e)) {
const read = borrowedRecordKeyGetOr(emitter, e);
if (read) return emitBorrowedRecordKeyGet(emitter, read, e.right);
}
if (e.kind === "mapIntrinsic" && emitter.canBorrowReceiver(e))
return emitBorrowedMapGet(emitter, e);
const narrow = emitter.checkedNarrows.get(e);
@@ -315,6 +320,12 @@ export function canBorrowReceiver(emitter: LlEmitter, e: IrExpr): boolean {
emitter.canBorrowReceiver(e.obj) &&
emitter.referenceEffects.preserves(e.key)
);
// `r[k] ?? inherited(r, k)` lends a hit the same way; its default is
// owned by the statement frame (so never by a longer-lived alias).
case "nullish": {
const read = borrowedRecordKeyGetOr(emitter, e);
return read !== null && emitter.canBorrowReceiver(read);
}
// So does a Map.get while the map keeps it.
case "mapIntrinsic":
return (
@@ -930,6 +930,10 @@ export class ReferenceWrites {
out.push(ANY_RECORD_KEY);
for (const f of this.records.get(node.shapeId)?.fields ?? []) out.push(`r:${f.name}`);
return this.expr(node.key) === null && walk(node.obj);
case "nullish":
// `r[k] ?? d`: a hit is the read's own value; the default runs only
// on a miss and is owned apart from any location.
return walk(node.left);
case "mapIntrinsic":
// A typed Map.get: any entry of any map.
if (node.method !== "get" || node.args.length !== 1 || !borrowsMapReadInputs(node))
@@ -499,7 +499,8 @@ function builtinAnswer(
type: BOOL,
loc,
});
if (arg === undefined || !mayBeSymbol(arg.type)) return has(propertyKeyOf(lowerer, arg, loc));
if (arg === undefined || !mayBeSymbol(lowerer, arg.type))
return has(propertyKeyOf(lowerer, arg, loc));
const id = declare("%protoKey", DYN);
const held = varRef(id, DYN, loc);
return {
@@ -520,13 +521,16 @@ function builtinAnswer(
}
}
/** Whether a value of `type` may be a symbol at run time. */
function mayBeSymbol(type: IrType): boolean {
return !(
isUnitType(type) ||
type.kind === "string" ||
type.kind === "f64" ||
type.kind === "bool"
/** Whether a value of `type` may be a symbol at run time: a checked value
* or a union with a symbol or checked arm. Records, class instances,
* arrays and functions never are, and their keys convert without running
* dynamic-tier code (which would make every indirect call a heap writer to
* ReferenceWrites). */
function mayBeSymbol(lowerer: Lowerer, type: IrType): boolean {
if (type.kind === "dyn" || type.kind === "symbol") return true;
if (type.kind !== "union") return false;
return (lowerer.unions.get(type.unionId)?.arms ?? []).some(
(arm) => arm.kind === "dyn" || arm.kind === "symbol",
);
}
@@ -540,16 +544,43 @@ function propertyKeyOf(lowerer: Lowerer, arg: IrExpr | undefined, loc: SrcLoc):
type: STRING,
loc,
};
if (arg.type.kind === "string") return arg;
if (arg.type.kind === "f64" || arg.type.kind === "bool")
const type = arg.type;
if (type.kind === "string") return arg;
if (type.kind === "f64" || type.kind === "bool")
return { kind: "toString", operand: arg, type: STRING, loc };
return {
kind: "libCall",
fn: "dyn.toStringCoerce",
args: [lowerer.coerceToExpected(arg, DYN)],
type: STRING,
loc,
};
// Unions of scalars convert through the union's own ToString.
if (
type.kind === "union" &&
(lowerer.unions.get(type.unionId)?.arms ?? []).every(
(arm) =>
isUnitType(arm) || arm.kind === "string" || arm.kind === "f64" || arm.kind === "bool",
)
)
return { kind: "toString", operand: arg, type: STRING, loc };
// A record, or a class instance without its own toString, spells
// "[object Object]" without running any code.
if (
type.kind === "record" ||
(type.kind === "object" && defaultToString(lowerer, type.className))
)
return { kind: "strLit", value: "[object Object]", type: STRING, loc };
// A checked value converts through the dynamic tier it already lives in.
if (type.kind === "dyn")
return { kind: "libCall", fn: "dyn.toStringCoerce", args: [arg], type: STRING, loc };
// Other keys (arrays, functions, class instances with toString) would run
// code; the built-in answers the checked-conversion TypeError for them.
return nodeThrowExpr(1, "", OTHER_ARM_FAILURE_MESSAGE, STRING, loc);
}
/** A source class whose instances answer Object.prototype.toString. */
function defaultToString(lowerer: Lowerer, className: string): boolean {
const info = lowerer.classes.get(className);
return (
info !== undefined &&
info.decl !== undefined &&
lowerer.findMethodOn(info, "toString") === null &&
!lowerer.overrideBelow(info, "toString")
);
}
/** `value` returned as `ret`: as is, wrapped into a union arm or a checked
@@ -0,0 +1,79 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test } from "vitest";
import { compile } from "../src/index.js";
const directories: string[] = [];
afterEach(async () => {
await Promise.all(directories.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
async function emit(source: string): Promise<string> {
const outDir = await mkdtemp(join(tmpdir(), "scriptc-function-table-"));
directories.push(outDir);
const entry = join(outDir, "main.ts");
await writeFile(entry, source);
const outPath = join(outDir, "program.ll");
const result = await compile(entry, { entry, outDir, outPath, outputKind: "llvm" });
expect(result.ok, JSON.stringify(result)).toBe(true);
return readFile(outPath, "utf8");
}
function definition(llvm: string, symbol: string): string {
const match = new RegExp(`^define internal [^\\n]*@${symbol}(?:\\.i32)?\\([^]*?^}`, "m").exec(
llvm,
);
expect(match, symbol).not.toBeNull();
return match![0];
}
// A record of functions dispatched by key: the hit lends the stored closure
// to the call (no union box, no lookup through the runtime, no closure
// retain or release), and only a miss tests the key against
// Object.prototype's method names.
const source = `
type Op = (acc: number, arg: number) => number;
const ops: Record<string, Op> = {
add: (acc, arg) => acc + arg,
neg: (acc) => -acc,
};
function asserted(name: string, acc: number): number {
return ops[name]!(acc, 1);
}
function optional(name: string, acc: number): number {
return ops[name]?.(acc, 1) ?? acc;
}
// Object arguments: the inherited methods' key conversion must not make
// every indirect call a heap writer (which would end all borrowing).
class Point {
constructor(readonly x: number) {}
}
type Measure = (p: Point, q: Point) => number;
const measures: Record<string, Measure> = { sum: (p, q) => p.x + q.x };
function objects(name: string, p: Point): number {
return measures[name]!(p, p);
}
function checked(name: string, acc: number): number {
const handler = ops[name];
return handler ? handler(acc, 1) : acc;
}
let acc = 1;
for (const name of ["add", "neg", "nope", "toString"]) {
acc = asserted(name === "nope" ? "add" : name === "toString" ? "neg" : name, acc);
acc = optional(name, acc);
acc = checked(name, acc);
acc += objects(name === "nope" ? "sum" : "sum", new Point(acc));
}
console.log(acc);
`;
test("function-table dispatch borrows the stored closure on a hit", async () => {
const llvm = await emit(source);
for (const fn of ["asserted", "optional", "checked", "objects"]) {
const body = definition(llvm, `sc_bf_${fn}`);
expect(body, fn).not.toMatch(/scr_union_new_ref|scr_map_get_str_ref/);
expect(body, fn).not.toMatch(/scr_closure_retain|sc_nretain_func/);
expect(body, fn).toMatch(/sc_map_entry_str/);
}
});