Call function tables without boxing or reference counting (#2173)

This commit is contained in:
Chris Tate
2026-10-25 18:17:54 -05:00
committed by GitHub
34 changed files with 1045 additions and 107 deletions
+1
View File
@@ -16,6 +16,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea
| `dynamic-methods` | cpu | untyped JavaScript calling builtin methods on values it does not type: string `slice`/`indexOf`/`toUpperCase`/`padStart`, array `push`/`pop`/`includes`, `Map` and `Set`, and an object's own methods |
| `dynamic-properties` | cpu | untyped JavaScript reading and writing properties of values it does not type: records built by object literals in two key orders, nested records, and instances of a class and its subclass |
| `functional-pipeline` | cpu | `filter`/`map`/`reduce`/`flatMap` chains, closures, small records |
| `handler-tables` | cpu | dispatch tables of functions: `Record<string, fn>` read as `t[k]!(…)`, `t[k]?.(…)` and through a checked local, a `Map<string, fn>`, an array of handlers, and an object literal of methods; some handlers declare fewer parameters than the table type |
| `intern-table`, `intern-table-map`, `intern-table-1`, `intern-table-map-1` | cpu | worker threads interning the same type-like objects under string keys and records under number keys, then hits and misses: one shared `InternTable` per kind of key, or a module-level `Map` per thread (the `-map` entries), with 8 threads or 1 (the `-1` entries); under Node.js every thread has its own tables |
| `json-records` | cpu | `JSON.stringify`, typed `JSON.parse`, Map aggregation |
| `json-unions` | cpu | `JSON.parse` into discriminated unions: an event log with eight kinds and nested expression trees with nine, evaluated after the cast |
@@ -0,0 +1,6 @@
{
"entry": "workloads/handler-tables.ts",
"args": [
"4"
]
}
@@ -0,0 +1,108 @@
// Dispatch tables of functions: a `Record<string, fn>` of opcode handlers
// called as `ops[name]!(…)`, `ops[name]?.(…)` and through a checked local,
// a `Map<string, fn>` of the same handlers, an array of handlers indexed by
// opcode number, and an object literal of methods. Some handlers declare
// fewer parameters than the table's function type.
type Op = (acc: number, arg: number) => number;
const ops: Record<string, Op> = {
add: (acc, arg) => acc + arg,
sub: (acc, arg) => acc - arg,
mul: (acc, arg) => (acc * arg) % 1000003,
neg: (acc) => -acc,
half: (acc) => Math.floor(acc / 2),
mix: (acc, arg) => (acc ^ arg) & 0xffff,
};
const byName = new Map<string, Op>(Object.entries(ops));
const byCode: Op[] = Object.values(ops);
const names = Object.keys(ops);
const methods = {
add(acc: number, arg: number): number {
return acc + arg;
},
sub(acc: number, arg: number): number {
return acc - arg;
},
mul(acc: number, arg: number): number {
return (acc * arg) % 1000003;
},
};
interface Insn {
name: string;
code: number;
arg: number;
}
function program(length: number): Insn[] {
const out: Insn[] = [];
let seed = 12345;
for (let i = 0; i < length; i++) {
seed = (seed * 1103515245 + 12345) % 2147483648;
const code = seed % (names.length + 1);
// One in seven instructions names an opcode no table holds.
out.push({ name: code < names.length ? names[code]! : "nop", code, arg: (seed >> 8) % 97 });
}
return out;
}
function runAsserted(insns: Insn[]): number {
let acc = 1;
for (const insn of insns) if (insn.name !== "nop") acc = ops[insn.name]!(acc, insn.arg);
return acc;
}
function runOptional(insns: Insn[]): number {
let acc = 1;
for (const insn of insns) acc = ops[insn.name]?.(acc, insn.arg) ?? acc + 1;
return acc;
}
function runChecked(insns: Insn[]): number {
let acc = 1;
for (const insn of insns) {
const handler = ops[insn.name];
if (handler) acc = handler(acc, insn.arg);
}
return acc;
}
function runMap(insns: Insn[]): number {
let acc = 1;
for (const insn of insns) {
const handler = byName.get(insn.name);
if (handler !== undefined) acc = handler(acc, insn.arg);
}
return acc;
}
function runArray(insns: Insn[]): number {
let acc = 1;
for (const insn of insns) if (insn.code < byCode.length) acc = byCode[insn.code]!(acc, insn.arg);
return acc;
}
function runMethods(insns: Insn[]): number {
let acc = 1;
for (const insn of insns) {
if (insn.code === 0) acc = methods.add(acc, insn.arg);
else if (insn.code === 1) acc = methods.sub(acc, insn.arg);
else if (insn.code === 2) acc = methods["mul"](acc, insn.arg);
}
return acc;
}
const rounds = Number(process.argv[2] ?? "4");
const insns = program(200_000);
const totals = [0, 0, 0, 0, 0, 0];
for (let round = 0; round < rounds * 10; round++) {
totals[0] = (totals[0]! + runAsserted(insns)) % 1e9;
totals[1] = (totals[1]! + runOptional(insns)) % 1e9;
totals[2] = (totals[2]! + runChecked(insns)) % 1e9;
totals[3] = (totals[3]! + runMap(insns)) % 1e9;
totals[4] = (totals[4]! + runArray(insns)) % 1e9;
totals[5] = (totals[5]! + runMethods(insns)) % 1e9;
}
console.log(totals.join(" "));
@@ -101,6 +101,13 @@ export class CheckedNarrows {
}
}
/** A call to a checked extraction whose helper borrows its argument, so
* emitCheckedNarrowReceiver can lend the payload instead of calling it. */
export function borrowsCheckedNarrow(host: LlvmEmitterContext, e: IrExpr): boolean {
if (e.kind !== "call" || host.checkedNarrows.get(e) === null) return false;
return host.callLifetimes.borrowed.get(e.callee)?.has(0) === true && host.mayThrow.has(e.callee);
}
/** A checked extraction consumed at once by a field or tag load. The
* successful arm borrows the payload from the union, which itself borrows
* from its source: a dense array read keeps the element in the array slot,
@@ -113,7 +120,7 @@ export function emitCheckedNarrowReceiver(
narrow: CheckedNarrowCall,
): LlValue | null {
const callee = narrow.call.callee;
if (!host.callLifetimes.borrowed.get(callee)?.has(0) || !host.mayThrow.has(callee)) return null;
if (!borrowsCheckedNarrow(host, narrow.call)) return null;
const B = host.B;
const read = host.optionalArrayReads.get(narrow.value);
const union = read
@@ -277,6 +277,10 @@ export function emitFieldAbsentTest(B: BlockBuilder, value: string, undefinedTag
return absent;
}
/** ScrClosure.function_kind's SCR_FN_RECEIVER_FREE bit (scr_closure.h):
* the body never observes the ambient receiver. */
export const CLOSURE_RECEIVER_FREE = 16;
/** The right-hand side of a JS function-identity comparison: two closures
* are one function when their identity roots match (a signature adapter
* stands for the function it adapts). Both operands must be closures. */
@@ -299,6 +299,47 @@ test("receiver readers propagate backwards through direct calls and recursion",
).toBe(false);
});
test("signature adapters that only forward the receiver ignore it with their original", () => {
const forward = (receiver: IrExpr): IrStmt => ({
kind: "return",
value: {
kind: "callValue",
callee: ref("f.0"),
receiver,
args: [{ kind: "varRef", localId: "a.0", type: F64, loc }],
type: BOOL,
loc,
},
loc,
});
const ambient: IrExpr = { kind: "libCall", fn: "dyn.this", args: [], type: { kind: "dyn" }, loc };
const adapter: IrFunction = {
name: "%fn.adapt.x.impl",
params: [{ localId: "a.0", name: "a", type: F64 }],
captures: [{ localId: "f.0", name: "f", type: callback }],
locals: [
{ id: "f.0", name: "f", type: callback, mutable: false, boxed: true },
{ id: "a.0", name: "a", type: F64, mutable: false },
],
returnType: BOOL,
body: [forward(ambient)],
loc,
};
const readers = (): AmbientReceiverReaders =>
new AmbientReceiverReaders(new Map([[adapter.name, adapter]]));
// The adapter itself reads the receiver, only to forward it.
expect(readers().ignores(adapter)).toBe(false);
expect(readers().forwardsOnly(adapter)).toBe(true);
// Any other read of the receiver, or forwarding something else, does not.
adapter.body = [thisRead, forward(ambient)];
expect(readers().forwardsOnly(adapter)).toBe(false);
adapter.body = [forward(ref("a.0", F64))];
expect(readers().forwardsOnly(adapter)).toBe(false);
adapter.body = [forward(ambient)];
adapter.captures = [];
expect(readers().forwardsOnly(adapter)).toBe(false);
});
test("virtual calls admit every same-named method", () => {
const call: IrStmt = {
kind: "exprStmt",
+53 -8
View File
@@ -183,14 +183,14 @@ import {
unionBoxBits,
} from "./inline-unions.js";
import { OptionalArrayReads, type LocalArrayRead } from "./local-array-reads.js";
import { CheckedNarrows } from "./checked-narrows.js";
import { borrowsCheckedNarrow, CheckedNarrows } from "./checked-narrows.js";
import {
emitDenseArraySet,
emitDenseSlotUnlink,
SCR_ELEM_I32,
type ArrayAccess,
} from "./dense-array-access.js";
import { matchMapRead, type MapReadLifetimes } from "./map-read-lifetimes.js";
import { matchMapRead, pointerMapGet, type MapReadLifetimes } from "./map-read-lifetimes.js";
import { type MapEntryFusion } from "./map-entry-fusion.js";
import { emitBorrowedFieldSequence, guardedValue } from "./borrowed-receivers.js";
import { emitBorrowedInputs } from "./borrowed-inputs.js";
@@ -213,7 +213,13 @@ import {
} from "./string-slices.js";
import { LazyCaptures } from "./lazy-captures.js";
import { StackCaptures } from "./stack-captures.js";
import { emitFieldAbsentTest, f64Lit, SCR_ARR_FIELD, SCR_ARR_FROZEN } from "./common.js";
import {
CLOSURE_RECEIVER_FREE,
emitFieldAbsentTest,
f64Lit,
SCR_ARR_FIELD,
SCR_ARR_FROZEN,
} from "./common.js";
import { ffiExtendsNarrowIntegers } from "../targets.js";
import {
emitLiteralExpr,
@@ -285,7 +291,12 @@ import {
emitBytesSet,
emitBytesIntrinsic,
} from "./expr-bytes.js";
import { emitRegexIntrinsic, emitRecordKeyGet, keyedRecordReadInto } from "./expr-records.js";
import {
emitRecordKeyGet,
emitRegexIntrinsic,
keyedRecordReadInto,
pointerRecordKeyGet,
} from "./expr-records.js";
import {
dynPromiseAdapter,
streamTypedRefCommitAdapter,
@@ -2560,7 +2571,7 @@ export class LlEmitter {
ret === "void" ? ` ${call}` : ` %r = ${call}`,
ret === "void" ? ` ret void` : ` ret ${ret} %r`,
`}`,
`@${mangleFnClosure(name)} = internal ${this.mod.workers === true || this.mod.lib?.threadInstances === true ? "thread_local " : ""}global %ScrClosure { ${this.sizeType} -1, ptr @${mangleWrapper(name)}, ${this.sizeType} 0, ptr null, i32 ${(fn.generator ? 1 : 0) + (fn.async ? 2 : 0) + (fn.ownsPrototype ? 4 : 0)}, i32 0, ptr null, ptr ${lending ? `@${mangleBorrowingWrapper(name)}` : "null"} }`,
`@${mangleFnClosure(name)} = internal ${this.mod.workers === true || this.mod.lib?.threadInstances === true ? "thread_local " : ""}global %ScrClosure { ${this.sizeType} -1, ptr @${mangleWrapper(name)}, ${this.sizeType} 0, ptr null, i32 ${this.closureFunctionKind(fn)}, i32 0, ptr null, ptr ${lending ? `@${mangleBorrowingWrapper(name)}` : "null"} }`,
``,
);
if (lending)
@@ -5737,7 +5748,10 @@ export class LlEmitter {
unwrittenRead(e: IrExpr, writes: Writes): boolean {
const rw = this.referenceWrites;
if (rw === null) return false;
const keys = rw.readKeys(e, (call) => this.borrowedReturnKeys(call.callee));
// A checked extraction reads nothing besides the union it narrows.
const keys = rw.readKeys(e, (call) =>
borrowsCheckedNarrow(this, call) ? [] : this.borrowedReturnKeys(call.callee),
);
return keys !== null && !rw.conflicts(keys, writes);
}
@@ -6014,7 +6028,21 @@ export class LlEmitter {
}
case "fieldGet":
case "recordGet":
case "recordKeyGet":
return this.borrowsWithoutOwning(e) && this.unownedWalk(e.obj);
case "mapIntrinsic": {
// A Map binding's own pointer (maps are not plainReference types).
if (!this.borrowsWithoutOwning(e) || e.receiver.kind !== "varRef") return false;
const binding = this.binding(e.receiver.localId);
return (
binding.kind === "global" ||
(binding.kind === "local" &&
binding.local !== undefined &&
!binding.local.boxed &&
!binding.local.tdz &&
!this.localUnionStorage.has(e.receiver.localId))
);
}
case "unionNarrow":
case "downcast":
case "upcast":
@@ -7272,6 +7300,11 @@ export class LlEmitter {
return this.nullableFieldGet(e) === null;
case "recordGet":
return true;
// A keyed read whose hits are all the result's own pointer.
case "recordKeyGet":
return pointerRecordKeyGet(this, e);
case "mapIntrinsic":
return pointerMapGet(this, e);
// emitReadReceiver lends a required element of a borrowed array when
// computing the index removes no edge.
case "arrayGet":
@@ -7417,8 +7450,20 @@ export class LlEmitter {
return wrapEmitterListener(this, target, adapterFn);
}
unwrapNullableClosure(u: string, funcTag: number): string {
return unwrapNullableClosure(this, u, funcTag);
/** ScrClosure.function_kind for a closure over `fn`: generator 1, async
* 2, own prototype 4, and SCR_FN_RECEIVER_FREE (16) when its body never
* observes the ambient receiver, so indirect callers skip the bracket. */
closureFunctionKind(fn: IrFunction): number {
return (
(fn.generator ? 1 : 0) +
(fn.async ? 2 : 0) +
(fn.ownsPrototype ? 4 : 0) +
(this.receiverReaders.ignores(fn) ? CLOSURE_RECEIVER_FREE : 0)
);
}
unwrapNullableClosure(u: string, unionId: string, funcTag: number): string {
return unwrapNullableClosure(this, u, unionId, funcTag);
}
closeBindThunkFor(cbUnion: IrType, retServer: boolean): string {
@@ -974,10 +974,20 @@ export function wrapEmitterListener(
export function unwrapNullableClosure(
host: LlvmEmitterContext,
u: string,
unionId: string,
funcTag: number,
): string {
const B = host.B;
host.declare(`declare ptr @scr_closure_retain_v(ptr)`);
const nullable = host.nullableUnions.get(unionId);
if (nullable !== null) {
// The union is the closure pointer itself (NULL for the unit arm).
if (nullable.repr !== "single" || nullable.nullTag >= 0 || nullable.refTag !== funcTag)
throw new InternalCompilerError("llvm emitter bug: nullable callback union shape");
const r = B.tmp();
B.line(`${r} = call ptr ${retainSym(host.shapeHost, { kind: "union", unionId })}(ptr ${u})`);
return r;
}
const slot = B.slot();
B.entryAllocas.push(`${slot} = alloca ptr`);
B.line(`store ptr null, ptr ${slot}`);
@@ -1026,9 +1036,9 @@ export function closeBindThunkFor(
"llvm emitter bug: bound-close callback union lacks its func arm",
);
const oneParam = funcArm.params.length === 1;
const cbNullable = host.nullableUnions.get(cbUnion.unionId);
host.declare(`declare ptr @scr_box_get_ref(ptr)`);
host.declare(`declare void @scr_closure_release(ptr)`);
host.declare(`declare void @scr_union_release(ptr)`);
host.declare(`declare ptr @scr_closure_retain_v(ptr)`);
host.declare(`declare void @scr_net_server_close_direct(ptr, ptr)`);
let trampoline: string | null = null;
@@ -1072,13 +1082,11 @@ export function closeBindThunkFor(
` %capsp = getelementptr inbounds %ScrClosure, ptr %self, i64 1`,
` %bx = load ptr, ptr %capsp`,
` %srv = call ptr @scr_box_get_ref(ptr %bx) ; +1`,
` %tagp = getelementptr inbounds %ScrUnion, ptr %cbu, i64 0, i32 1`,
` %tag = load i32, ptr %tagp`,
...unionTagLines(cbNullable, "%cbu", "%tag"),
` %isfn = icmp eq i32 %tag, ${funcTag}`,
` br i1 %isfn, label %fn, label %go`,
`fn:`,
` %pp = getelementptr inbounds %ScrUnion, ptr %cbu, i64 0, i32 5`,
` %pv = load ptr, ptr %pp`,
...unionPeekLines(cbNullable, "%cbu", "%pv"),
];
if (oneParam) {
d.push(
@@ -1097,7 +1105,7 @@ export function closeBindThunkFor(
` br label %go`,
`go:`,
` %regv = load ptr, ptr %regslot`,
` call void @scr_union_release(ptr %cbu) ; the callee owns its +1 param`,
` call void ${releaseSym(host.shapeHost, cbUnion)}(ptr %cbu) ; the callee owns its +1 param`,
` call void @scr_net_server_close_direct(ptr %srv, ptr %regv) ; reg moves`,
);
if (retServer) {
+102 -13
View File
@@ -42,7 +42,13 @@ import {
} from "./classes.js";
import { LlvmUnsupportedError } from "./unsupported.js";
import type { LlvmEmitterContext, ExprOf, LlValue } from "./expr-context.js";
import { f64Lit, ffiNativeTypeLl, ffiNativeParamLl, ffiNativeReturnLl } from "./common.js";
import {
CLOSURE_RECEIVER_FREE,
f64Lit,
ffiNativeTypeLl,
ffiNativeParamLl,
ffiNativeReturnLl,
} from "./common.js";
import { canStackUnion, emitStackUnion } from "./stack-unions.js";
import { emitStackRecordKeyGet, scalarRecordKeyGet } from "./expr-records.js";
import { emitCallArrayRead } from "./local-array-reads.js";
@@ -761,8 +767,7 @@ export function emitCallExpr(
`${c} = call ptr @scr_closure_new(ptr @${host.callTarget(e.fnName)}, ${host.sizeType} ${e.captures.length})`,
);
const out = host.own({ name: c, type: e.type });
const functionKind =
(target.generator ? 1 : 0) + (target.async ? 2 : 0) + (target.ownsPrototype ? 4 : 0);
const functionKind = host.closureFunctionKind(target);
if (functionKind) {
const kindPtr = B.tmp();
B.line(`${kindPtr} = getelementptr inbounds %ScrClosure, ptr ${c}, i64 0, i32 4`);
@@ -800,6 +805,25 @@ export function emitCallExpr(
: 0;
host.declare(`declare void @scr_closure_adopt_identity(ptr, ptr, i32)`);
B.line(`call void @scr_closure_adopt_identity(ptr ${c}, ptr ${original}, i32 ${length})`);
if (host.receiverReaders.forwardsOnly(target)) {
// The adapter forwards the ambient receiver to its original,
// so it ignores the receiver exactly when the original does.
const fromPtr = B.tmp(),
from = B.tmp(),
bit = B.tmp(),
toPtr = B.tmp(),
to = B.tmp(),
kind = B.tmp();
B.line(
`${fromPtr} = getelementptr inbounds %ScrClosure, ptr ${original}, i64 0, i32 4`,
);
B.line(`${from} = load i32, ptr ${fromPtr}`);
B.line(`${bit} = and i32 ${from}, ${CLOSURE_RECEIVER_FREE}`);
B.line(`${toPtr} = getelementptr inbounds %ScrClosure, ptr ${c}, i64 0, i32 4`);
B.line(`${to} = load i32, ptr ${toPtr}`);
B.line(`${kind} = or i32 ${to}, ${bit}`);
B.line(`store i32 ${kind}, ptr ${toPtr}`);
}
}
});
return out;
@@ -813,7 +837,18 @@ export function emitCallExpr(
const ft = e.callee.type;
if (e.args.length !== ft.params.length)
throw new LlvmUnsupportedError("callValue:arity", e.loc);
const callee = host.stackCallbacks.emit(host, e.callee) ?? emitBorrowedInput(host, e.callee);
// A receiver that reads the ambient `this` binds what the callee
// would see without a binding: the call forwards it unchanged and
// needs no receiver bracket (signature adapters forward this way).
const forwardsReceiver =
e.receiver?.kind === "libCall" &&
e.receiver.fn === "dyn.this" &&
e.receiver.args.length === 0;
const callee =
host.stackCallbacks.emit(host, e.callee) ??
(lendsCallee(host, e, forwardsReceiver)
? host.emitReadReceiver(e.callee)
: emitBorrowedInput(host, e.callee));
// A class instance bound as the callee's `this` (`h.fn(x)`) is boxed
// only if the callee reads it: the receiver window records the
// instance (owned by this frame for the whole call) with its typed-ref
@@ -825,7 +860,10 @@ export function emitCallExpr(
!host.shapeHost.isErrorClass(e.receiver.value.type.className)
? host.emitExpr(e.receiver.value)
: null;
const receiver = e.receiver === undefined || lazyReceiver ? null : host.emitExpr(e.receiver);
const receiver =
e.receiver === undefined || lazyReceiver || forwardsReceiver
? null
: host.emitExpr(e.receiver);
const pushReceiver = (): void => {
if (lazyReceiver) {
const t = lazyReceiver.type;
@@ -865,7 +903,7 @@ export function emitCallExpr(
? borrowableInputs(host, e.args, false, host.referenceWrites?.summary(known!.name))
: [];
let dynamic = false;
if (!direct && e.receiver === undefined) {
if (!direct && (e.receiver === undefined || forwardsReceiver)) {
const references = new Set<number>();
ft.params.forEach((param, index) => {
if (isRefCounted(param)) references.add(index);
@@ -898,16 +936,51 @@ export function emitCallExpr(
lent.has(i) ? { name: host.retainFrame(a.name, a.type), type: a.type } : a,
)
: args;
const invoke = (target: string, needsReceiver: boolean, values = args): string => {
// An entry through the closure's own pointer brackets the call only
// when the closure may observe the receiver (SCR_FN_RECEIVER_FREE).
const invoke = (
target: string,
bracket: boolean,
values = args,
indirect = false,
): string => {
const needsReceiver = bracket && !forwardsReceiver;
let observes: string | null = null;
if (needsReceiver) {
host.declare("declare void @scr_dyn_this_pop()");
pushReceiver();
if (indirect) {
const kindPtr = B.tmp(),
kind = B.tmp(),
free = B.tmp();
observes = B.tmp();
B.line(
`${kindPtr} = getelementptr inbounds %ScrClosure, ptr ${callee.name}, i64 0, i32 4`,
);
B.line(`${kind} = load i32, ptr ${kindPtr}`);
B.line(`${free} = and i32 ${kind}, ${CLOSURE_RECEIVER_FREE}`);
B.line(`${observes} = icmp eq i32 ${free}, 0`);
const push = B.newLabel("receiver.push"),
pushed = B.newLabel("receiver.call");
B.condBr(observes, push, pushed);
B.startBlock(push);
pushReceiver();
B.br(pushed);
B.startBlock(pushed);
} else pushReceiver();
}
const result = e.type.kind === "void" ? "" : B.tmp();
B.line(
`${result ? `${result} = ` : ""}call ${host.llType(e.type)} ${target}(${argListOf(values)})`,
);
if (needsReceiver) B.line("call void @scr_dyn_this_pop()");
if (observes !== null) {
const pop = B.newLabel("receiver.pop"),
popped = B.newLabel("receiver.done");
B.condBr(observes, pop, popped);
B.startBlock(pop);
B.line("call void @scr_dyn_this_pop()");
B.br(popped);
B.startBlock(popped);
} else if (needsReceiver) B.line("call void @scr_dyn_this_pop()");
return result;
};
const lending =
@@ -953,11 +1026,11 @@ export function emitCallExpr(
B.line(`${has} = icmp ne ptr ${entry}, null`);
B.condBr(has, borrowing, owning);
B.startBlock(borrowing);
const lentResult = invoke(entry, true);
const lentResult = invoke(entry, true, args, true);
const borrowingEnd = B.currentLabel();
B.br(done);
B.startBlock(owning);
const ownedResult = invoke(fn, true, ownedArgs());
const ownedResult = invoke(fn, true, ownedArgs(), true);
const owningEnd = B.currentLabel();
B.br(done);
B.startBlock(done);
@@ -966,7 +1039,7 @@ export function emitCallExpr(
B.line(
`${t} = phi ${host.llType(e.type)} [ ${lentResult}, %${borrowingEnd} ], [ ${ownedResult}, %${owningEnd} ]`,
);
} else if (!direct) t = invoke(fn, true, ownedArgs());
} else if (!direct) t = invoke(fn, true, ownedArgs(), true);
else {
// A global initializer may not have run, or a callback may enter
// through a boundary the census cannot prove. Validate the actual
@@ -984,7 +1057,7 @@ export function emitCallExpr(
const fastEnd = B.currentLabel();
B.br(done);
B.startBlock(fallback);
const indirect = invoke(fn, true, ownedArgs());
const indirect = invoke(fn, true, ownedArgs(), true);
const fallbackEnd = B.currentLabel();
B.br(done);
B.startBlock(done);
@@ -1358,6 +1431,22 @@ function ctorArguments(
* walk consumes without owning it (LlEmitter.canBorrowReceiver proved every
* reference argument borrowable and every argument edge-preserving). The
* result stays reachable from the borrowed arguments until the consumer. */
/** A closure value read from a location the call cannot write (a keyed
* table entry, a field, a checked extraction of either) is entered without
* a reference of its own: the location keeps the closure alive until the
* call returns. Plain stable bindings already lend (emitBorrowedInput). */
function lendsCallee(
host: LlvmEmitterContext,
e: ExprOf<"callValue">,
forwardsReceiver: boolean,
): boolean {
const writes = host.referenceWrites;
if (writes === null || host.canBorrowCallArgument(e.callee) || !host.canBorrowReceiver(e.callee))
return false;
const later = e.receiver === undefined || forwardsReceiver ? e.args : [e.receiver, ...e.args];
return borrowableInputs(host, [e.callee, ...later], false, writes.ownWrites(e))[0] === true;
}
export function emitBorrowedResultCall(host: LlvmEmitterContext, e: ExprOf<"call">): LlValue {
const callee = host.fnByName.get(e.callee);
if (!callee || !host.borrowedReturns.has(e.callee))
@@ -499,10 +499,7 @@ export function emitOptionalChainReceiver(
// needs no reference of its own while the body cannot remove an edge
// (so nothing can overwrite the projection before the body finishes).
const borrowReceiver =
!multiple &&
isRefCounted(narrowed) &&
host.canBorrowReceiver(e.receiver) &&
host.referenceEffects.preserves(e.body);
!multiple && isRefCounted(narrowed) && borrowsChainReceiver(host, e.receiver, e.body);
// An optional array read of a nullable-pointer union is the element
// pointer itself, read inline and owned by the statement frame until
// the body finishes; the bind slot borrows it like an emitExpr value.
@@ -562,6 +559,16 @@ export function emitOptionalChainReceiver(
};
}
/** An optional chain's receiver can lend its payload to the bind slot when
* the body leaves the receiver's locations alone: it removes no edge at
* all, or it writes none of the locations the receiver reads. */
function borrowsChainReceiver(host: LlvmEmitterContext, receiver: IrExpr, body: IrExpr): boolean {
if (!host.canBorrowReceiver(receiver)) return false;
if (host.referenceEffects.preserves(body)) return true;
const writes = host.referenceWrites;
return writes !== null && host.unwrittenRead(receiver, writes.expr(body));
}
/** `a?.b ?? d` narrowed to the chain body's present arm. The body wraps a
* value of the result type in a non-unit arm, so the default runs exactly
* when the receiver is nullish and the intermediate result box is never
@@ -588,16 +595,21 @@ function emitNullishOptionalChain(host: LlvmEmitterContext, e: ExprOf<"nullish">
if (unitTags.length === 0 || present.length !== 1) return null;
const B = host.B;
const narrowed = receiverDef.arms[present[0]!]!;
const r = host.canStackReceiver(chain.receiver)
? host.emitReadReceiver(chain.receiver)
: host.emitExpr(chain.receiver);
// A receiver read from a location the body cannot write lends its
// payload to the bind slot for the whole body (`table[k]?.(…) ?? d`).
const borrowed =
isRefCounted(narrowed) && borrowsChainReceiver(host, chain.receiver, chain.body.value);
const r =
borrowed || host.canStackReceiver(chain.receiver)
? host.emitReadReceiver(chain.receiver)
: host.emitExpr(chain.receiver);
const bindTy = host.llType(narrowed);
const bind = B.slot();
B.entryAllocas.push(`${bind} = alloca ${bindTy}`);
B.line(
`store ${bindTy} ${bindTy === "ptr" ? "null" : bindTy === "double" ? f64Lit(0) : "false"}, ptr ${bind}`,
);
host.ownSlot(bind, narrowed);
if (!borrowed) host.ownSlot(bind, narrowed);
const isUnit = host.tagInSet(r.name, receiverDef.id, unitTags);
const ty = host.llType(e.type);
const slot = B.slot();
@@ -610,7 +622,10 @@ function emitNullishOptionalChain(host: LlvmEmitterContext, e: ExprOf<"nullish">
host.emitBranchInto(slot, e.right);
B.br(lj);
B.startBlock(lb);
B.line(`store ${bindTy} ${host.unionExtract(r.name, receiverDef.id, narrowed)}, ptr ${bind}`);
const payload = borrowed
? host.unionPeek(r.name, receiverDef.id)
: host.unionExtract(r.name, receiverDef.id, narrowed);
B.line(`store ${bindTy} ${payload}, ptr ${bind}`);
host.chainSlots.set(chain.id, { name: bind, type: narrowed, slot: true });
host.emitBranchInto(slot, chain.body.value);
host.chainSlots.delete(chain.id);
@@ -11,7 +11,7 @@ import { bindLiveArrayOps } from "./expr-stream-bridges.js";
/* Focused LLVM expression emission extracted from emitter.ts. */
import { InternalCompilerError } from "../../errors.js";
import { emitNullableIsTag, emitNullablePresent } from "./union-repr.js";
import { isObjectArm } from "./nullable-unions.js";
import { isPointerIdentityArm } from "./nullable-unions.js";
import { streamTypedRefEligible } from "../../ir/analysis.js";
import {
DYN,
@@ -908,13 +908,13 @@ export function emitDynamicExpr(
if (!def)
throw new InternalCompilerError(`llvm emitter bug: equality of unknown union ${e.unionId}`);
const nullable = host.nullableUnions.get(def.id);
if (nullable && isObjectArm(nullable)) {
if (nullable && isPointerIdentityArm(nullable)) {
// Object identity, and NULL === NULL for the unit arm: one compare.
const eq = B.tmp();
B.line(`${eq} = icmp ${e.negated ? "ne" : "eq"} ptr ${l.name}, ${r.name}`);
return { name: eq, type: e.type };
}
if (nullable) {
if (nullable && nullable.repr === "single" && nullable.arm.kind === "string") {
// String arm: identical pointers are equal (the same string or the
// same unit encoding); otherwise equal only when both hold strings
// with the same bytes.
@@ -10,6 +10,7 @@ import { regexLiteralReplacer, regexLiteralTemplate } from "./expr-primitives.js
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";
/** Widen one tagged read. The source is borrowed unless the overflow map
* supplied an owned value. Each selected reference payload is retained
@@ -417,8 +418,8 @@ export function emitRecordKeyGet(
e: IrExpr & { kind: "recordKeyGet" },
): LlValue {
const B = host.B;
const obj = host.emitExpr(e.obj);
const k = host.emitExpr(e.key);
// The read runs no user code: both inputs lend for its duration.
const [obj, k] = emitBorrowedInputs(host, [e.obj, e.key]) as [LlValue, LlValue];
const ty = host.llType(e.type);
const slot = B.slot();
B.entryAllocas.push(`${slot} = alloca ${ty}`);
@@ -440,6 +441,80 @@ export function emitRecordKeyGet(
return host.own({ name: t, type: e.type });
}
/** A keyed read whose every hit is the result's own pointer: the result
* is a nullable-pointer union (nullable-unions.ts) with an undefined arm,
* and each declared field and the overflow value hold exactly the result
* type or its reference arm in a plain (non-inline) slot. A miss is the
* immortal undefined encoding. Such a read can lend the stored pointer:
* borrowedRecordKeyGet reads it without a reference. */
export function pointerRecordKeyGet(host: LlvmEmitterContext, e: IrExpr): boolean {
if (e.kind !== "recordKeyGet" || e.missNaN === true || e.type.kind !== "union") return false;
const resultType = e.type;
const nullable = host.nullableUnions.single(resultType.unionId);
if (nullable === null || undefinedArmTag(resultType, host.unionsById) < 0) return false;
const exact = (t: IrType): boolean => typeEquals(t, resultType) || typeEquals(t, nullable.arm);
const shape = host.recordShape(e.shapeId);
if (e.overflowOnly !== true) {
for (const f of shape.fields)
if (host.recordFieldInline(e.shapeId, f.name) || !exact(f.type)) return false;
}
return shape.indexValue === undefined || typeEquals(shape.indexValue, nullable.arm);
}
/** A pointerRecordKeyGet read without a reference: the declared field or
* overflow entry the key names, or the undefined encoding for a miss. The
* record (and through it the stored value) must stay unchanged and alive
* until the consumer is done with the pointer (canBorrowReceiver). */
export function emitBorrowedRecordKeyGet(
host: LlvmEmitterContext,
e: IrExpr & { kind: "recordKeyGet" },
): LlValue {
const B = host.B;
if (e.type.kind !== "union")
throw new InternalCompilerError("llvm emitter bug: borrowed keyed read of a non-union");
const resultType = e.type;
const obj = host.emitReadReceiver(e.obj);
const key = host.canBorrowReceiver(e.key) ? host.emitReadReceiver(e.key) : host.emitExpr(e.key);
const slot = B.slot();
B.entryAllocas.push(`${slot} = alloca ptr`);
const join = B.newLabel("rkb.j");
const shape = host.recordShape(e.shapeId);
for (const f of e.overflowOnly === true ? [] : shape.fields) {
const hit = B.tmp();
B.line(
`${hit} = call zeroext i1 ${host.literalEquals(f.name)}(ptr ${key.name}) ; ${llvmCommentText(f.name)}`,
);
const lh = B.newLabel("rkb.h");
const ln = B.newLabel("rkb.n");
B.condBr(hit, lh, ln);
B.startBlock(lh);
const { ptr, type } = host.recordFieldPtr(obj.name, e.shapeId, f.name);
B.line(`store ptr ${host.loadRecordField(ptr, type, false)}, ptr ${slot}`);
B.br(join);
B.startBlock(ln);
}
if (shape.indexValue) {
const ovf = host.recordOvfPtr(obj.name, e.shapeId);
const entry = emitStringMapEntry(host, ovf, key.name);
const found = B.tmp();
B.line(`${found} = icmp ne ptr ${entry}, null`);
const lh = B.newLabel("rkb.h");
const ln = B.newLabel("rkb.n");
B.condBr(found, lh, ln);
B.startBlock(lh);
B.line(`store ptr ${emitNumberMapSlot(host, entry, "ptr")}, ptr ${slot}`);
B.br(join);
B.startBlock(ln);
}
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();
B.line(`${t} = load ptr, ptr ${slot}`);
return { name: t, type: e.type };
}
/** 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.
@@ -735,27 +810,17 @@ export function keyedRecordReadInto(
B.br(join);
B.startBlock(ln);
} else {
host.declare(`declare ptr @scr_map_get_str_ref(ptr, ptr)`);
// The entry lookup filters absent keys inline; a hit retains the
// stored value with its own type's retain (map-number-lookup.ts). A
// nullable value's NULL is a stored unit arm: presence decides.
const lh = B.newLabel("rkg.h");
const ln = B.newLabel("rkg.n");
let raw: string;
if (iv.kind === "union" && host.nullableUnions.has(iv.unionId)) {
// NULL is a stored unit arm of a nullable value: presence decides.
host.declare(`declare zeroext i1 @scr_map_has_str(ptr, ptr)`);
const has = B.tmp();
B.line(`${has} = call zeroext i1 @scr_map_has_str(ptr ${ovf}, ptr ${keyName})`);
B.condBr(has, lh, ln);
B.startBlock(lh);
raw = B.tmp();
B.line(`${raw} = call ptr @scr_map_get_str_ref(ptr ${ovf}, ptr ${keyName})`);
} else {
raw = B.tmp();
const isnull = B.tmp();
B.line(`${raw} = call ptr @scr_map_get_str_ref(ptr ${ovf}, ptr ${keyName})`);
B.line(`${isnull} = icmp eq ptr ${raw}, null`);
B.condBr(isnull, ln, lh);
B.startBlock(lh);
}
const entry = emitStringMapEntry(host, ovf, keyName);
const found = B.tmp();
B.line(`${found} = icmp ne ptr ${entry}, null`);
B.condBr(found, lh, ln);
B.startBlock(lh);
const raw = host.retainHeap(emitNumberMapSlot(host, entry, "ptr"), iv);
B.line(`store ${ty} ${surface(iv, raw, true)}, ptr ${slot}`);
B.br(join);
B.startBlock(ln);
@@ -95,11 +95,12 @@ export function sniCallbackArgs(host: LlvmEmitterContext, cb: LlValue): AbiArgum
let type = cb.type,
value = cb.name;
if (type.kind === "union") {
const arms = host.unionsById.get(type.unionId)?.arms ?? [];
const unionId = type.unionId;
const arms = host.unionsById.get(unionId)?.arms ?? [];
const tag = arms.findIndex((arm) => arm.kind === "func");
if (tag < 0) throw new InternalCompilerError("SNI callback union lacks its function arm");
type = arms[tag]!;
value = host.unwrapNullableClosure(value, tag);
value = host.unwrapNullableClosure(value, unionId, tag);
} else host.toHeap(cb);
if (type.kind !== "func" || type.params[1] === undefined)
throw new InternalCompilerError("SNI callback lacks its answer function");
@@ -165,7 +165,7 @@ export function emitNetworkHttpLibCall(host: LlvmEmitterContext, e: LibCallExpr)
const funcTag = def ? def.arms.findIndex((a) => a.kind === "func") : -1;
if (funcTag < 0)
throw new InternalCompilerError(`llvm emitter bug: ${e.fn} union lacks its func arm`);
cb = host.unwrapNullableClosure(args[cbIndex]!.name, funcTag);
cb = host.unwrapNullableClosure(args[cbIndex]!.name, cbT.unionId, funcTag);
}
}
const valueCount = reusePort ? 5 : 4;
@@ -12,13 +12,18 @@ import type { CallLifetimes } from "./call-lifetimes.js";
import type { ReferenceEffects } from "./reference-effects.js";
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
import {
emitCompactMapSlot,
emitNumberMapGetRef,
emitNumberMapGetScalar,
emitNumberMapSlot,
emitStringMapEntry,
emitStringMapGetScalar,
emitStringMapGetRef,
compactMapValues,
noteNumberMapKey,
} from "./map-number-lookup.js";
import { InternalCompilerError } from "../../errors.js";
import { undefinedArmTag } from "../../ir/analysis.js";
import { mapKeyAccess, mapKeyParamType } from "./shapes.js";
import { rememberMapEntry } from "./map-entry-fusion.js";
@@ -490,3 +495,62 @@ export function emitStackMapRead(host: LlvmEmitterContext, read: LocalMapRead):
B.line(`store i32 ${selected}, ptr ${tag}`);
return { value: { name: box, type: read.type }, owner };
}
/** A string-keyed Map.get whose result is a nullable-pointer union over the
* map's own value type (`Map<string, F>` read as `F | undefined`): a hit is
* the stored pointer itself and a miss the immortal undefined encoding, so
* the read can lend the stored value (emitBorrowedMapGet). */
export function pointerMapGet(host: LlvmEmitterContext, e: IrExpr): boolean {
if (
e.kind !== "mapIntrinsic" ||
e.method !== "get" ||
e.args.length !== 1 ||
e.type.kind !== "union" ||
e.receiver.type.kind !== "map" ||
e.receiver.type.key.kind !== "string" ||
!borrowsMapReadInputs(e)
)
return false;
const nullable = host.nullableUnions.single(e.type.unionId);
return (
nullable !== null &&
typeEquals(nullable.arm, e.receiver.type.value) &&
undefinedArmTag(e.type, host.unionsById) >= 0
);
}
/** A pointerMapGet read without a reference: the map (and through it the
* stored value) must stay unchanged and alive until the consumer is done
* with the pointer (canBorrowReceiver). */
export function emitBorrowedMapGet(
host: LlvmEmitterContext,
e: IrExpr & { kind: "mapIntrinsic" },
): LlValue {
const B = host.B;
const mapType = e.receiver.type;
if (e.type.kind !== "union" || mapType.kind !== "map")
throw new InternalCompilerError("llvm emitter bug: borrowed Map.get shape");
const receiver = host.emitReadReceiver(e.receiver);
const keyExpr = e.args[0]!;
const key = host.canBorrowReceiver(keyExpr)
? host.emitReadReceiver(keyExpr)
: host.emitExpr(keyExpr);
const compact = compactMapValues(host, mapType.key, mapType.value);
const entry = emitStringMapEntry(host, receiver.name, key.name, compact);
const found = B.tmp();
B.line(`${found} = icmp ne ptr ${entry}, null`);
const hit = B.newLabel("mapb.hit"),
miss = B.newLabel("mapb.miss"),
join = B.newLabel("mapb.join");
B.condBr(found, hit, miss);
B.startBlock(hit);
const value = compact ? emitCompactMapSlot(host, entry) : emitNumberMapSlot(host, entry, "ptr");
B.br(join);
B.startBlock(miss);
B.br(join);
B.startBlock(join);
const absent = host.unitInstanceRef(e.type.unionId, undefinedArmTag(e.type, host.unionsById));
const result = B.tmp();
B.line(`${result} = phi ptr [ ${value}, %${hit} ], [ ${absent}, %${miss} ]`);
return { name: result, type: e.type };
}
@@ -7,7 +7,7 @@ import {
type IrType,
type IrUnionDef,
} from "../../ir/ir.js";
import { NullableUnions } from "./nullable-unions.js";
import { isObjectArm, isPointerIdentityArm, NullableUnions } from "./nullable-unions.js";
const loc = { file: "nullable-unions.ts", start: 0, end: 0 };
const a: IrType = { kind: "record", shapeId: "a" };
@@ -111,3 +111,22 @@ test("library, lifetime-region and debug builds keep record unions boxed", () =>
expect(new NullableUnions(module(unions, { lifetimeRegions: true })).get("ab")).toBeNull();
expect(new NullableUnions(module(unions)).shapes("ab")).not.toBeNull();
});
test("function arms are nullable pointers compared by identity root", () => {
const fn: IrType = { kind: "func", params: [F64], ret: F64 };
const unions = new NullableUnions(
module([
{ id: "fu", arms: [fn, UNDEFINED_T] },
{ id: "fnu", arms: [NULL_T, fn, UNDEFINED_T] },
{ id: "ff", arms: [fn, F64] },
]),
);
expect(unions.single("fu")).toMatchObject({ refTag: 0, unitTag: 1, nullTag: -1, arm: fn });
expect(unions.single("fnu")).toMatchObject({ refTag: 1, unitTag: 2, nullTag: 0 });
expect(unions.get("ff")).toBeNull();
const single = unions.single("fu")!;
// Always truthy, but `===` compares adapters by the function they adapt.
expect(isObjectArm(single)).toBe(true);
expect(isPointerIdentityArm(single)).toBe(false);
expect(unions.armTypes().get("fu")).toEqual(fn);
});
@@ -89,21 +89,30 @@ export const NULLABLE_ABSENT = "@sc_nullable_absent";
/** The reference arms whose instances are never NULL. Class and record
* entry points all tolerate NULL; strings, Stats snapshots, arrays, maps and
* sets retain through a NULL-skipping wrapper (shapes.ts). Object arms are
* always truthy and compare by identity; string arms are truthy when
* non-empty and compare bytes. */
* sets retain through a NULL-skipping wrapper (shapes.ts), as do closures
* (function arms). Object arms are always truthy and compare by identity
* (function arms by identity root); string arms are truthy when non-empty
* and compare bytes. */
export type NullableRefArm =
| { kind: "object"; className: string }
| { kind: "record"; shapeId: string }
| { kind: "string" }
| { kind: "stats" }
| (IrType & { kind: "array" | "map" | "set" });
| (IrType & { kind: "array" | "map" | "set" | "func" });
/** The arm is a JS object: always truthy, compared by identity. */
/** The arm is a JS object: always truthy. */
export function isObjectArm(nullable: PointerUnion): boolean {
return nullable.repr === "shapes" || nullable.arm.kind !== "string";
}
/** The arm compares by pointer identity. Function arms compare identity
* roots instead: a signature adapter is the function it adapts. */
export function isPointerIdentityArm(nullable: PointerUnion): boolean {
return (
nullable.repr === "shapes" || (nullable.arm.kind !== "string" && nullable.arm.kind !== "func")
);
}
/** The representation decision for every union of one module. It is a
* pure function of the module, so serialized-IR and in-memory emission
* agree. */
@@ -264,7 +273,8 @@ function nullableShape(
return { ...base, arm: { kind: "record", shapeId: arm.shapeId } };
if (arm.kind === "string") return { ...base, arm: { kind: "string" } };
if (arm.kind === "stats") return { ...base, arm: { kind: "stats" } };
if (arm.kind === "array" || arm.kind === "map" || arm.kind === "set") return { ...base, arm };
if (arm.kind === "array" || arm.kind === "map" || arm.kind === "set" || arm.kind === "func")
return { ...base, arm };
return null;
}
@@ -11,14 +11,24 @@ import type { LlValue } from "./expr-context.js";
import { guardedValue } from "./borrowed-receivers.js";
import { InternalCompilerError } from "../../errors.js";
import { emitBorrowedArrayRead, emitProjectedArrayRead } from "./local-array-reads.js";
import { emitStackMapRead, matchMapRead } from "./map-read-lifetimes.js";
import {
emitBorrowedMapGet,
emitStackMapRead,
matchMapRead,
pointerMapGet,
} from "./map-read-lifetimes.js";
import { emitBorrowedResultCall, emitCallExpr } from "./expr-calls.js";
import { emitCheckedNarrowReceiver } from "./checked-narrows.js";
import { borrowsCheckedNarrow, emitCheckedNarrowReceiver } from "./checked-narrows.js";
import { canStackUnion, emitStackUnion } from "./stack-unions.js";
import { emitDowncastGuard, emitUnionNarrowGuard } from "./narrow-guards.js";
import { emitScalarOptional, isScalarOptionalSource } from "./local-union-storage.js";
import { borrowableInputs } from "./borrowed-inputs.js";
import { emitStackRecordKeyGet, scalarRecordKeyGet } from "./expr-records.js";
import {
emitBorrowedRecordKeyGet,
emitStackRecordKeyGet,
pointerRecordKeyGet,
scalarRecordKeyGet,
} from "./expr-records.js";
import { type WalkBorrowHost, analyzeWalks } from "./walk-borrows.js";
/** Read a receiver whose value is consumed immediately by a field or tag
@@ -92,6 +102,10 @@ export function emitReadReceiver(emitter: LlEmitter, e: IrExpr): LlValue {
return emitBorrowedResultCall(emitter, e);
const arrayRead = emitter.optionalArrayReads.get(e);
if (arrayRead) return emitProjectedArrayRead(emitter, arrayRead);
if (e.kind === "recordKeyGet" && emitter.canBorrowReceiver(e))
return emitBorrowedRecordKeyGet(emitter, e);
if (e.kind === "mapIntrinsic" && emitter.canBorrowReceiver(e))
return emitBorrowedMapGet(emitter, e);
const narrow = emitter.checkedNarrows.get(e);
const narrowed = narrow ? emitCheckedNarrowReceiver(emitter, narrow) : null;
if (narrowed) return narrowed;
@@ -294,7 +308,27 @@ export function canBorrowReceiver(emitter: LlEmitter, e: IrExpr): boolean {
// snapshot, so neither the callee nor a later argument may replace
// what it was read from: a callee that preserves every edge cannot,
// and any other one must leave the argument's locations unwritten.
// A keyed read lends the stored pointer while the record keeps it.
case "recordKeyGet":
return (
pointerRecordKeyGet(emitter, e) &&
emitter.canBorrowReceiver(e.obj) &&
emitter.referenceEffects.preserves(e.key)
);
// So does a Map.get while the map keeps it.
case "mapIntrinsic":
return (
pointerMapGet(emitter, e) &&
emitter.canBorrowReceiver(e.receiver) &&
emitter.referenceEffects.preserves(e.args[0]!)
);
case "call": {
// A checked extraction lends the payload of the union it narrows
// (emitCheckedNarrowReceiver).
if (borrowsCheckedNarrow(emitter, e)) {
const narrow = emitter.checkedNarrows.get(e)!;
return emitter.canBorrowReceiver(narrow.value);
}
if (!emitter.borrowedReturns.has(e.callee)) return false;
if (!e.args.every((arg) => emitter.referenceEffects.preserves(arg))) return false;
const preserving = emitter.referenceEffects.functions.has(e.callee);
@@ -97,3 +97,46 @@ test("an array that is read back or escapes keeps its element writes", () => {
const input = fill("input", [store("input", element("input"))]);
expect(writes(input).conflicts(["a"], writes(input).summary("input"))).toBe(true);
});
test("a keyed record read depends on every field and keyed entry of records", () => {
const handler: IrType = { kind: "func", params: [F64], ret: F64 };
const table: IrType = { kind: "record", shapeId: "t" };
const key: IrExpr = { kind: "strLit", value: "k", type: { kind: "string" }, loc };
const read: IrExpr = {
kind: "recordKeyGet",
obj: ref("table", table),
shapeId: "t",
key,
type: { kind: "union", unionId: "hu" },
loc,
};
const keyedWrite: IrStmt = {
kind: "recordKeySet",
obj: ref("table", table),
shapeId: "t",
key,
value: ref("h", handler),
loc,
};
const fn = (name: string, body: IrStmt[]): IrFunction => ({
name,
loc,
returnType: F64,
params: [{ localId: "table", name: "table", type: table }],
locals: [
{ id: "table", name: "table", type: table, mutable: false },
{ id: "h", name: "h", type: handler, mutable: false },
],
body,
});
const records = [{ id: "t", fields: [{ name: "add", type: handler }], indexValue: handler }];
const quiet = fn("quiet", []);
const writer = fn("writer", [keyedWrite]);
const functions = new Map([quiet, writer].map((f) => [f.name, f]));
const rw = new ReferenceWrites(functions, [], () => false, new Set(), { records });
rw.enter(quiet);
const keys = rw.readKeys(read);
expect(keys).toEqual(expect.arrayContaining(["r:*", "r:add"]));
expect(rw.conflicts(keys!, rw.summary("quiet"))).toBe(false);
expect(rw.conflicts(keys!, rw.summary("writer"))).toBe(true);
});
@@ -92,6 +92,8 @@ const INERT_LIB_PREFIXES = [
];
const INERT_LIB_CALLS = new Set([
"dyn.compare",
// Reads the ambient receiver (a fresh reference to the bound value).
"dyn.this",
"error.new",
"error.nodeThrow",
"dyn.typedRefIs",
@@ -923,6 +925,17 @@ export class ReferenceWrites {
case "arrayGet":
out.push(ARRAY_KEY);
return this.expr(node.index) === null && walk(node.arr);
case "recordKeyGet":
// Any declared field or overflow entry the key may name.
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 "mapIntrinsic":
// A typed Map.get: any entry of any map.
if (node.method !== "get" || node.args.length !== 1 || !borrowsMapReadInputs(node))
return false;
out.push(MAP_KEY);
return this.expr(node.args[0]!) === null && walk(node.receiver);
case "unionNarrow":
case "upcast":
case "downcast":
@@ -32,10 +32,12 @@ export class StackCallbacks {
const count = B.tmp();
B.line(`${count} = getelementptr inbounds %ScrClosure, ptr ${closure}, i32 0, i32 2`);
B.line(`store ${host.sizeType} ${value.captures.length}, ptr ${count}`);
if (this.functions.get(value.fnName)?.ownsPrototype) {
const target = this.functions.get(value.fnName);
const functionKind = target ? host.closureFunctionKind(target) : 0;
if (functionKind !== 0) {
const kind = B.tmp();
B.line(`${kind} = getelementptr inbounds %ScrClosure, ptr ${closure}, i32 0, i32 4`);
B.line(`store i32 4, ptr ${kind}`);
B.line(`store i32 ${functionKind}, ptr ${kind}`);
}
value.captures.forEach((localId, index) => {
const box = host.retainBox(host.captureBox(localId));
+66 -26
View File
@@ -1,6 +1,7 @@
import { isBorrowSafeMath } from "./analysis.js";
import type { IrExpr, IrFunction, IrLibFn, IrStmt } from "./ir.js";
import { eachNodeOfKinds, type IrNodeIndex } from "./node-index.js";
import { everyExprChild, everyStmtChild } from "./traverse.js";
/** Expression kinds that run no user code of their own and never consult
* the ambient receiver stack. Calls are admitted here and resolved through
@@ -151,6 +152,7 @@ function opaqueOperand(e: IrExpr): boolean {
* this set needs no receiver bracket. */
export class AmbientReceiverReaders {
private readers: Set<string> | null = null;
private byMethod: Map<string, string[]> | null = null;
constructor(
private readonly functions: ReadonlyMap<string, IrFunction>,
@@ -163,8 +165,63 @@ export class AmbientReceiverReaders {
return !this.compute().has(fn.name);
}
private compute(): Set<string> {
if (this.readers) return this.readers;
/** A signature adapter body that observes the ambient receiver only by
* forwarding it, unchanged, to the original it captures (a callValue on
* that capture whose receiver is the plain `this` read). Such an adapter
* ignores the receiver exactly when its original does. */
forwardsOnly(fn: IrFunction): boolean {
if (fn.async || fn.generator) return false;
const original = fn.captures?.[0]?.localId;
if (original === undefined) return false;
const readers = this.compute();
const edge = (name: string): boolean => this.functions.has(name) && !readers.has(name);
const stmt = (node: IrStmt): boolean =>
RECEIVER_FREE_STMTS.has(node.kind) && everyStmtChild(node, expr, stmt);
const expr = (node: IrExpr): boolean => {
if (node.kind === "callValue" && node.receiver !== undefined) {
const receiver = node.receiver;
return (
receiver.kind === "libCall" &&
receiver.fn === "dyn.this" &&
receiver.args.length === 0 &&
node.callee.kind === "varRef" &&
node.callee.localId === original &&
node.args.every(expr)
);
}
return this.admits(node, edge) && everyExprChild(node, expr, stmt);
};
return fn.body.every(stmt);
}
/** Whether one expression node, apart from its children, leaves the
* ambient receiver alone; `edge` admits a direct callee. */
private admits(expr: IrExpr, edge: (name: string) => boolean): boolean {
if (!RECEIVER_FREE_EXPRS.has(expr.kind)) return false;
switch (expr.kind) {
case "call":
return edge(expr.callee);
case "new":
return edge(`%${expr.className}.constructor`);
case "virtualCall": {
// Any class's override may run: admit only when every
// same-named method is itself receiver-free.
const targets = this.methods().get(expr.method) ?? [];
return targets.length > 0 && targets.every(edge);
}
case "libCall":
return isBorrowSafeMath(expr.fn) || RECEIVER_FREE_LIB_FNS.has(expr.fn);
case "toString":
return !opaqueOperand(expr.operand);
default: {
const operands = dataIntrinsicOperands(expr);
return operands === null || !operands.some(opaqueOperand);
}
}
}
private methods(): Map<string, string[]> {
if (this.byMethod) return this.byMethod;
const byMethod = new Map<string, string[]>();
for (const name of this.functions.keys()) {
const dot = name.lastIndexOf(".");
@@ -174,6 +231,12 @@ export class AmbientReceiverReaders {
list.push(name);
byMethod.set(method, list);
}
this.byMethod = byMethod;
return byMethod;
}
private compute(): Set<string> {
if (this.readers) return this.readers;
const readers = new Set<string>();
const callers = new Map<string, string[]>();
const scan = this.nodes?.scanExcept(TRIVIALLY_RECEIVER_FREE);
@@ -185,34 +248,11 @@ export class AmbientReceiverReaders {
callees.add(name);
return true;
};
const expr = (expr: IrExpr): boolean => {
if (!RECEIVER_FREE_EXPRS.has(expr.kind)) return false;
switch (expr.kind) {
case "call":
return edge(expr.callee);
case "new":
return edge(`%${expr.className}.constructor`);
case "virtualCall": {
// Any class's override may run: admit only when every
// same-named method is itself receiver-free.
const targets = byMethod.get(expr.method) ?? [];
return targets.length > 0 && targets.every(edge);
}
case "libCall":
return isBorrowSafeMath(expr.fn) || RECEIVER_FREE_LIB_FNS.has(expr.fn);
case "toString":
return !opaqueOperand(expr.operand);
default: {
const operands = dataIntrinsicOperands(expr);
return operands === null || !operands.some(opaqueOperand);
}
}
};
if (!reads)
eachNodeOfKinds(fn, scan, (node, statement) => {
const free = statement
? RECEIVER_FREE_STMTS.has((node as IrStmt).kind)
: expr(node as IrExpr);
: this.admits(node as IrExpr, edge);
if (!free) reads = true;
return free;
});
@@ -40,9 +40,11 @@ try {
const module = deserializeModule(readFileSync(output, "utf8"));
expect(validateModule(module)).toEqual([]);
const llvm = emitLlvmModule(module, {});
// The fallback calls the closure's entry, after giving it an undefined
// receiver when it reads one, so its edge comes from receiver.done then.
const guards = [
...llvm.matchAll(
/(%t\d+) = phi i1 \[ false, %callback\.direct\d+ \], \[ true, %callback\.indirect\d+ \]/g,
/(%t\d+) = phi i1 \[ false, %callback\.direct\d+ \], \[ true, %(?:callback\.indirect|receiver\.done)\d+ \]/g,
),
];
// The quiet every and map callbacks; not the throwing one.
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8230-function-record-dispatch.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8231-function-table-shapes.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8232-optional-function-values.ts"
],
"diags": []
}
+8 -1
View File
@@ -98,7 +98,7 @@ typedef struct ScrClosure {
* (like the dyn→closure edge): a cycle through it is merely never
* collected. */
ScrBox *props;
uint32_t function_kind; /* low bits: 0 ordinary, 1 generator, 2 async, 3 async generator; bit 2: ordinary own prototype; bit 3: checked callable adapter in caps[0] */
uint32_t function_kind; /* low bits: 0 ordinary, 1 generator, 2 async, 3 async generator; bit 2: ordinary own prototype; bit 3: checked callable adapter in caps[0]; bit 4: SCR_FN_RECEIVER_FREE */
/* The root's `length` as boxed values report it; meaningful only when
* identity is set (the root's own boxes carry their arity). */
uint32_t identity_length;
@@ -117,6 +117,13 @@ typedef struct ScrClosure {
ScrBox *caps[];
} ScrClosure;
/* function_kind bit 4: the body never observes the ambient receiver (the
* `this` an unbound call binds), so compiled callers skip the receiver
* bracket around an indirect call. Set only by the compiler; the kind
* proper is function_kind & SCR_FN_KIND_MASK. */
#define SCR_FN_RECEIVER_FREE 16u
#define SCR_FN_KIND_MASK 15u
ScrClosure *scr_closure_new(void *fn, size_t ncaps); /* +1; caller fills caps with +1 box refs */
static inline ScrClosure *scr_closure_retain(ScrClosure *c) {
+6 -3
View File
@@ -6908,7 +6908,7 @@ ScrDyn *scr_dyn_class_base_prototype(ScrDyn *constructor) {
default: break;
}
if (constructor->kind != SCR_DYN_FUNC || constructor->v.fn.class_obj ||
scr_closure_identity(constructor->v.fn.clo)->function_kind != 4) {
(scr_closure_identity(constructor->v.fn.clo)->function_kind & SCR_FN_KIND_MASK) != 4) {
static const char message[] = "Class extends value is not a native ordinary constructor";
scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1);
return NULL;
@@ -7056,7 +7056,8 @@ void scr_dyn_name_class_prototype(ScrDyn *prototype, const ScrStr *name) {
/* Ordinary native function constructors retain their live prototype table.
* A constructor may replace the fresh receiver with another object. */
ScrDyn *scr_dyn_construct(const ScrDyn *callee, const ScrDyn *args, const ScrStr *what) {
if (callee->kind != SCR_DYN_FUNC || callee->v.fn.class_obj || scr_closure_identity(callee->v.fn.clo)->function_kind != 4)
if (callee->kind != SCR_DYN_FUNC || callee->v.fn.class_obj ||
(scr_closure_identity(callee->v.fn.clo)->function_kind & SCR_FN_KIND_MASK) != 4)
return scr_bytes_construct(callee, args, what);
ScrDyn *prototype = scr_dyn_fn_get(callee, "prototype", 9);
if (!prototype) return NULL;
@@ -9191,7 +9192,9 @@ bool scr_dyn_is_callable(const ScrDyn *value) {
int scr_dyn_function_kind(const ScrDyn *value) {
while (value->kind == SCR_DYN_PROXY) value = value->v.proxy.target;
return value->kind == SCR_DYN_FUNC ? scr_closure_identity(value->v.fn.clo)->function_kind : 0;
return value->kind == SCR_DYN_FUNC
? (int)(scr_closure_identity(value->v.fn.clo)->function_kind & SCR_FN_KIND_MASK)
: 0;
}
void scr_dyn_adopt_identity(ScrClosure *adapter, const ScrDyn *value) {
@@ -0,0 +1,107 @@
// Records of functions used as dispatch tables: keyed reads consumed at once
// (`t[k]!(…)`, `t[k]?.(…)`, a checked local), missing keys, handlers that
// declare fewer parameters than the table's type, handlers that replace,
// delete or re-add table entries (their own included) while they run, and
// key order after such mutation.
type Handler = (x: number, y: number) => number;
let log: string[] = [];
function make(tag: string): Handler {
let calls = 0;
return (x, y) => {
calls++;
log.push(tag + ":" + calls);
return x * 10 + y;
};
}
const table: Record<string, Handler> = {
add: (x, y) => x + y,
neg: (x) => -x,
pow: (x, y) => x ** y,
made: make("made"),
};
function keys(): string[] {
return process.argv.length > 99 ? [] : ["add", "neg", "pow", "made", "missing", ""];
}
for (const k of keys()) {
const h = table[k] as Handler | undefined;
console.log(JSON.stringify(k), typeof h, h === undefined, h !== undefined && h === table[k]);
console.log(" optional", table[k]?.(3, 4) ?? "none");
if (h) console.log(" checked", h(2, 5));
try {
console.log(" asserted", table[k]!(5, 2));
} catch (e) {
console.log(" asserted threw", (e as Error).name);
}
}
// A handler that replaces its own entry while it runs: the replaced closure
// keeps running with its captured state.
function selfReplacing(): Handler {
const state = { hits: 0, label: "first" };
return (x, y) => {
table["swap"] = make("second");
state.hits++;
log.push(state.label + " still running after replace, hits " + state.hits);
return x + y + state.hits;
};
}
table["swap"] = selfReplacing();
console.log("swap 1", table["swap"]!(1, 2));
console.log("swap 2", table["swap"]?.(1, 2));
console.log("swap 3", table["swap"]!(1, 2));
// A handler that deletes its own entry.
function selfDeleting(name: string): Handler {
const captured = [name, "captured"];
return (x) => {
delete table[name];
log.push(captured.join(" ") + " after delete");
return x;
};
}
table["gone"] = selfDeleting("gone");
const goneKey = keys()[0] === "add" ? "gone" : "add";
console.log("gone", table[goneKey]?.(7, 0) ?? "none", table[goneKey]?.(7, 0) ?? "none");
// A checked local keeps its closure after the entry changes.
table["kept"] = make("kept-old");
const kept = table["kept"] as Handler | undefined;
table["kept"] = make("kept-new");
delete table["kept"];
if (kept) console.log("kept", kept(4, 4));
console.log("kept entry", table["kept"] === undefined);
// Iteration while handlers add and delete keys: key order follows inserts.
const order: Record<string, Handler> = {
b: (x) => x + 1,
a: (x) => {
delete order["c"];
order["z"] = (v) => v * 2;
return x + 2;
},
c: (x) => x + 3,
"1": (x) => x + 4,
};
for (const k in order) console.log("in", k, order[k]?.(1, 0));
console.log("keys", Object.keys(order).join(","));
for (const k of Object.keys(order)) {
order[k + k] = (x) => x;
console.log("of", k, order[k]!(10, 0));
}
console.log("keys", Object.keys(order).join(","));
// Reassigning the table binding itself from inside a handler.
let current: Record<string, Handler> = {
go: (x) => {
current = { go: (y) => y * 100 };
const local = [x, x];
return local[0]! + local[1]!;
},
};
console.log("rebind", current["go"]!(3, 0), current["go"]!(3, 0));
console.log(log.join("\n"));
+108
View File
@@ -0,0 +1,108 @@
// Function dispatch through other table shapes: Map<string, fn>, arrays of
// handlers, object literals of methods (with and without `this`), handler
// mutation of the Map during calls, and adapters for handlers that declare
// fewer parameters than the table type.
type Op = (acc: number, arg: number) => number;
const trace: string[] = [];
const byName = new Map<string, Op>();
byName.set("add", (acc, arg) => acc + arg);
byName.set("half", (acc) => acc / 2);
byName.set("reset", () => {
byName.delete("reset");
byName.set("reset", (acc) => acc - 1);
trace.push("reset replaced itself");
return 0;
});
byName.set("clear", (acc) => {
const keep = [acc, "before clear"];
byName.clear();
trace.push(keep.join(" ") + ", size " + byName.size);
return acc;
});
function program(): string[] {
return process.argv.length > 99 ? [] : ["add", "half", "nop", "reset", "reset", "add", "clear", "add"];
}
let acc = 8;
for (const name of program()) {
const handler = byName.get(name);
if (handler !== undefined) acc = handler(acc, 3);
else trace.push("no " + name);
console.log(name, acc, byName.get(name)?.(1, 1) ?? "-");
}
const byCode: Op[] = [(a, b) => a - b, (a) => a * 3, (a, b) => Math.max(a, b)];
let total = 1;
for (let i = 0; i < 9; i++) {
total = byCode[i % 4]?.(total, i) ?? total + 100;
if (i % 4 < byCode.length) total = byCode[i % 4]!(total, 1);
}
console.log("array", total);
const counter = {
base: 5,
bump(by: number): number {
this.base += by;
return this.base;
},
twice(by: number): number {
return this.bump(by) + this.bump(by);
},
plain(by: number): number {
return by * 2;
},
};
console.log("methods", counter.bump(1), counter.twice(2), counter.plain(3), counter["plain"](4));
// Methods that read `this`, called through computed keys and through a
// signature adapter (a method declaring fewer parameters than its type).
interface Account {
balance: number;
deposit: (amount: number, note: string) => number;
withdraw: (amount: number, note: string) => number;
peek: (amount: number, note: string) => number;
}
const account: Account = {
balance: 10,
deposit(amount) {
this.balance += amount;
return this.balance;
},
withdraw(amount, note) {
this.balance -= amount;
trace.push("withdraw " + note);
return this.balance;
},
peek: (amount) => amount,
};
for (const op of ["deposit", "withdraw", "peek", "deposit"] as const) {
console.log(op, account[op](5, "n-" + op), account.balance);
}
const detached = account.peek;
console.log("detached", detached(3, "x"));
interface Machine {
state: string;
on: Record<string, (m: Machine, input: number) => string>;
}
const machine: Machine = {
state: "idle",
on: {
idle: (m, input) => (input > 0 ? "running" : m.state),
running: (m, input) => {
if (input < 0) {
m.on["stopped"] = () => "idle";
return "stopped";
}
return m.state;
},
},
};
for (const input of [0, 1, 2, -1, 5, 3]) {
const next = machine.on[machine.state]?.(machine, input) ?? machine.state;
console.log("machine", machine.state, input, "->", next);
machine.state = next;
}
console.log(trace.join("\n"));
@@ -0,0 +1,75 @@
// Values typed `F | undefined` for a function type F: identity and
// equality (signature adapters compare as the function they adapt), typeof,
// truthiness, storage in arrays, records, class fields and Maps, optional
// parameters and returns.
type Unary = (x: number) => number;
type Binary = (x: number, y: number) => number;
const double: Unary = (x) => x * 2;
const asBinary: Binary = double;
const maybe = (flag: boolean): Unary | undefined => (flag ? double : undefined);
const a = maybe(true);
const b = maybe(true);
const none = maybe(false);
console.log(a === b, a === none, none === undefined, typeof a, typeof none, !!a, !!none);
console.log(a === double, (asBinary as unknown) === (double as unknown));
const slots: (Unary | undefined)[] = [double, undefined, (x) => x + 1];
console.log(slots.map((f) => (f ? f(5) : "empty")).join(" "), slots.length);
console.log(slots.indexOf(double), slots.includes(undefined), slots.filter((f) => f !== undefined).length);
interface Hooks {
before?: Unary;
after?: Unary;
}
const hooks: Hooks = { after: (x) => x - 1 };
console.log(hooks.before?.(1) ?? "no before", hooks.after?.(1) ?? "no after", "before" in hooks);
class Button {
onClick: Unary | undefined;
clicks = 0;
click(): number {
this.clicks++;
return this.onClick?.(this.clicks) ?? -1;
}
}
const button = new Button();
console.log(button.click());
button.onClick = (n) => n * 100;
console.log(button.click(), button.click());
button.onClick = undefined;
console.log(button.click(), button.onClick === undefined);
const registry = new Map<string, Unary | undefined>([
["set", double],
["unset", undefined],
]);
console.log(registry.get("set")?.(4), registry.get("unset")?.(4), registry.has("unset"), registry.get("other") === undefined);
function run(x: number, transform?: Unary, combine?: Binary): number {
const t = transform ? transform(x) : x;
return combine === undefined ? t : combine(t, x);
}
console.log(run(3), run(3, double), run(3, undefined, (p, q) => p * q), run(3, (x) => -x, asBinary));
function pick(name: string): Binary | undefined {
switch (name) {
case "mul":
return (x, y) => x * y;
case "first":
return (x) => x;
default:
return undefined;
}
}
for (const name of ["mul", "first", "none"]) {
const f = pick(name);
console.log(name, f?.(6, 7), f === undefined ? "undefined" : typeof f);
}
let late: Unary | undefined;
const callLate = (): number => late?.(9) ?? 0;
console.log(callLate());
late = (x) => x + 0.5;
console.log(callLate());
@@ -0,0 +1 @@
{ "plain": 4700, "san": 5800 }
@@ -0,0 +1 @@
{ "plain": 4700, "san": 5800 }
@@ -0,0 +1 @@
{ "plain": 4700, "san": 5800 }