diff --git a/packages/compiler/src/backend/llvm/emit-function.ts b/packages/compiler/src/backend/llvm/emit-function.ts index 8c9ace9f3..01622794a 100644 --- a/packages/compiler/src/backend/llvm/emit-function.ts +++ b/packages/compiler/src/backend/llvm/emit-function.ts @@ -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}`; diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 5e48b602a..8475a3853 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -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": diff --git a/packages/compiler/src/backend/llvm/expr-context.ts b/packages/compiler/src/backend/llvm/expr-context.ts index ac5262571..e6f534dc1 100644 --- a/packages/compiler/src/backend/llvm/expr-context.ts +++ b/packages/compiler/src/backend/llvm/expr-context.ts @@ -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; } diff --git a/packages/compiler/src/backend/llvm/expr-records.ts b/packages/compiler/src/backend/llvm/expr-records.ts index af4bb092f..df4eb48a0 100644 --- a/packages/compiler/src/backend/llvm/expr-records.ts +++ b/packages/compiler/src/backend/llvm/expr-records.ts @@ -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`, at `number | undefined`) and whose every hit * is a number or boolean arm of it, or an inline field of the same union. diff --git a/packages/compiler/src/backend/llvm/receivers.ts b/packages/compiler/src/backend/llvm/receivers.ts index 8524a51fb..80cff7a8c 100644 --- a/packages/compiler/src/backend/llvm/receivers.ts +++ b/packages/compiler/src/backend/llvm/receivers.ts @@ -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 ( diff --git a/packages/compiler/src/backend/llvm/reference-writes.ts b/packages/compiler/src/backend/llvm/reference-writes.ts index 79ac98a7d..56379a443 100644 --- a/packages/compiler/src/backend/llvm/reference-writes.ts +++ b/packages/compiler/src/backend/llvm/reference-writes.ts @@ -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)) diff --git a/packages/compiler/src/frontend/lowering/record-inherited.ts b/packages/compiler/src/frontend/lowering/record-inherited.ts index 65d070f1d..320f461c6 100644 --- a/packages/compiler/src/frontend/lowering/record-inherited.ts +++ b/packages/compiler/src/frontend/lowering/record-inherited.ts @@ -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 diff --git a/packages/compiler/test/function-table-dispatch-emission.test.ts b/packages/compiler/test/function-table-dispatch-emission.test.ts new file mode 100644 index 000000000..584a9efaf --- /dev/null +++ b/packages/compiler/test/function-table-dispatch-emission.test.ts @@ -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 { + 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 = { + 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 = { 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/); + } +});