Skip to content
Merged
6 changes: 6 additions & 0 deletions packages/compiler/src/backend/llvm/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ export class BlockBuilder {
private tempCounter = 0;
private labelCounter = 0;
debugLocation: string | null = null;
/** The body is a synchronous IR function: the active exception cell is one
* value from entry to every pending check. Other builders (adapters,
* runtime helpers) keep loading it at each check. */
excCellInvariant = false;
/** The entry-block load of the active exception cell, once requested. */
excCell: string | null = null;
/** Function epilogue shared by normal and exceptional returns. */
returnEpilogue: string | null = null;
/** Temps known to hold immortal values (temp names are only unique
Expand Down
5 changes: 4 additions & 1 deletion packages/compiler/src/backend/llvm/call-lifetimes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,9 @@ function collectUses(fn: IrFunction, nullableField: NullableFieldTest): Uses {
export function analyzeCallLifetimes(
functions: ReadonlyMap<string, IrFunction>,
nullableField: NullableFieldTest = () => false,
/** Rebound parameters that only ever hold borrowed walk pointers
* (walk-borrows.ts): they keep the borrowed convention although written. */
walkParameters: ReadonlyMap<string, ReadonlySet<number>> = new Map(),
): CallLifetimes {
const usesByFunction = new Map<string, Uses>();
const lazyCaptures = new LazyCaptures(functions);
Expand Down Expand Up @@ -255,7 +258,7 @@ export function analyzeCallLifetimes(
// any synchronous body; captured cells are boxed and marked invalid.
continue;
}
const borrowed = new Set<number>();
const borrowed = new Set<number>(walkParameters.get(fn.name));
// A captured parameter that nothing rebinds keeps its entry value; its
// environment box takes a reference of its own when it is created.
const unchangedCaptures = lazyCaptures.parameters(fn);
Expand Down
19 changes: 19 additions & 0 deletions packages/compiler/src/backend/llvm/emitter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -532,3 +532,22 @@ test("literal zero division emits the JavaScript NaN constant in development bui
);
expect(dynamic).toContain("fdiv double");
});

test("synchronous bodies load the active exception cell once for all pending checks", () => {
const body = (llvm: string, name: string): string => {
const start = llvm.indexOf(`@${name}(`);
return llvm.slice(start, llvm.indexOf("\n}\n", start));
};
const module = moduleFor([call(), call(), call()]);
const work = body(emitLlvmModule(module), "sc_f_work");
expect(work.match(/= load ptr, ptr @scr_exc_active/g)).toHaveLength(1);
expect(work.slice(work.indexOf("entry:")).split("\n").slice(1).join("\n")).toMatch(
/^(?: %[^\n]* = alloca [^\n]*\n)* %exc\.cell = load ptr, ptr @scr_exc_active/,
);
expect(work.match(/= load i32, ptr %exc\.cell\b/g)).toHaveLength(3);
// Worker executables fold the context signal into each check and keep
// loading their thread-local cell there.
const workers = body(emitLlvmModule({ ...module, workers: true }), "sc_f_work");
expect(workers).not.toContain("%exc.cell");
expect(workers.match(/= load ptr, ptr @scr_exc_active/g)).toHaveLength(3);
});
355 changes: 348 additions & 7 deletions packages/compiler/src/backend/llvm/emitter.ts

Large diffs are not rendered by default.

33 changes: 31 additions & 2 deletions packages/compiler/src/backend/llvm/expr-calls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,17 @@ export function emitCallExpr(
}
const t = B.tmp();
B.line(`${t} = call ${host.llType(e.type)} ${target}(${argList})`);
const out = host.own({ name: t, type: e.type });
if (host.mayThrow.has(e.callee)) host.emitPendingCheck(out);
let out: LlValue;
if (borrowed && host.borrowedReturns.has(e.callee)) {
// A borrowed-return body hands back +0 (a null dummy when it threw):
// test the exception first, then own the result before the
// argument snapshots that keep it reachable are released.
if (host.mayThrow.has(e.callee)) host.emitPendingCheck();
out = host.own({ name: host.retainValue(t, e.type), type: e.type });
} else {
out = host.own({ name: t, type: e.type });
if (host.mayThrow.has(e.callee)) host.emitPendingCheck(out);
}
if (borrowed) {
host.moveTemp(out);
host.releaseFrame(host.frames.pop()!);
Expand Down Expand Up @@ -990,3 +999,23 @@ export function emitCallExpr(
}
}
}

/** A direct call to a borrowed-return function whose result a receiver or
* 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. */
export function emitBorrowedResultCall(host: LlvmEmitterContext, e: ExprOf<"call">): LlValue {
const callee = host.fnByName.get(e.callee);
if (!callee || !host.borrowedReturns.has(e.callee))
throw new InternalCompilerError(`llvm emitter bug: borrowed result of ${e.callee}`);
const args = e.args.map((arg) =>
isRefCounted(arg.type) ? host.emitReadReceiver(arg) : host.emitExpr(arg),
);
const argList = args.map((a, i) => `${host.llType(callee.params[i]!.type)} ${a.name}`).join(", ");
const t = host.B.tmp();
host.B.line(
`${t} = call ${host.llType(e.type)} @${mangleBorrowedFunction(e.callee)}(${argList}) ; borrowed result`,
);
if (host.mayThrow.has(e.callee)) host.emitPendingCheck();
return { name: t, type: e.type };
}
3 changes: 2 additions & 1 deletion packages/compiler/src/backend/llvm/local-array-reads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ import {
import { analyzeCallLifetimes } from "./call-lifetimes.js";

/** A pending-exception check: the inline active-cell test or a runtime call. */
const PENDING_CHECK = /@scr_exc_(?:active|pending)\b/;
// Synchronous bodies test the kind of the entry-loaded exception cell.
const PENDING_CHECK = /@scr_exc_(?:active|pending)\b| = load i32, ptr %exc\.cell\b/;

const loc = { file: "local-array.ts", start: 0, end: 0 };
const element: IrType = { kind: "record", shapeId: "cell" };
Expand Down
13 changes: 12 additions & 1 deletion packages/compiler/src/backend/llvm/local-array-reads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,19 @@ export class OptionalArrayReads {
* that the parameter holding the array is never replaced or captured. */
function stableArrayParameters(fn: IrFunction, lifetimes: CallLifetimes): Set<string> {
const borrowed = lifetimes.borrowed.get(fn.name);
// Borrowed walk parameters may be rebound by statements (walk-borrows.ts).
const rebound = new Set<string>();
everyStmtList(fn.body, {
stmt: (stmt) => {
if (stmt.kind === "assign") rebound.add(stmt.localId);
return true;
},
expr: () => true,
});
return new Set(
fn.params.filter((_, index) => borrowed?.has(index)).map((param) => param.localId),
fn.params
.filter((param, index) => borrowed?.has(index) && !rebound.has(param.localId))
.map((param) => param.localId),
);
}

Expand Down
77 changes: 77 additions & 0 deletions packages/compiler/src/backend/llvm/rebound-parameters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { expect, test } from "vitest";
import { F64, type IrExpr, type IrFunction, type IrStmt, type IrType } from "../../ir/ir.js";
import { findReboundParameters } from "./rebound-parameters.js";

const loc = { file: "rebound.ts", start: 0, end: 0 };
const NODE: IrType = { kind: "object", className: "Node" };
const ref = (localId: string): IrExpr => ({ kind: "varRef", localId, type: NODE, loc });
const assign = (localId: string, value: IrExpr): IrStmt => ({
kind: "assign",
localId,
value,
loc,
});

function fn(body: IrStmt[], params: { id: string; type?: IrType }[]): IrFunction {
return {
name: "work",
params: params.map((p) => ({ localId: p.id, name: p.id, type: p.type ?? NODE })),
locals: params.map((p) => ({ id: p.id, name: p.id, type: p.type ?? NODE, mutable: true })),
body,
returnType: F64,
loc,
};
}

const plain = (type: IrType): boolean => type.kind === "object";

test("statement rebinding of reference parameters keeps the borrowed convention", () => {
const body = [
assign("a", ref("b")),
{ kind: "if", cond: ref("a"), then: [assign("b", ref("a"))], else_: null, loc } as IrStmt,
];
const f = fn(body, [{ id: "a" }, { id: "b" }, { id: "untouched" }]);
expect(findReboundParameters(f, plain, new Set())).toEqual(new Set(["a", "b"]));
// Parameters already borrowed as walks are left to the walk analysis.
expect(findReboundParameters(f, plain, new Set(["a"]))).toEqual(new Set(["b"]));
});

test("expression writes, scalar types, captures and suspension keep the owned parameter", () => {
const sequence: IrExpr = {
kind: "seqExpr",
stmts: [assign("a", ref("b"))],
result: { kind: "numLit", value: 0, type: F64, loc },
type: F64,
loc,
};
const inExpression = fn([{ kind: "exprStmt", expr: sequence, loc }], [{ id: "a" }, { id: "b" }]);
expect(findReboundParameters(inExpression, plain, new Set()).size).toBe(0);
const scalar = fn(
[assign("n", { kind: "numLit", value: 1, type: F64, loc })],
[{ id: "n", type: F64 }],
);
expect(findReboundParameters(scalar, plain, new Set()).size).toBe(0);
const captured = fn(
[
assign("a", ref("b")),
{
kind: "exprStmt",
expr: {
kind: "closure",
name: "inner",
captures: ["a"],
type: F64,
loc,
} as unknown as IrExpr,
loc,
},
],
[{ id: "a" }, { id: "b" }],
);
expect(findReboundParameters(captured, plain, new Set()).size).toBe(0);
const suspending: IrFunction = {
...fn([assign("a", ref("b"))], [{ id: "a" }, { id: "b" }]),
async: true,
};
expect(findReboundParameters(suspending, plain, new Set()).size).toBe(0);
});
72 changes: 72 additions & 0 deletions packages/compiler/src/backend/llvm/rebound-parameters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { IrExpr, IrFunction, IrStmt, IrType } from "../../ir/ir.js";
import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";

/** Parameters that keep the borrowed calling convention although the body
* rebinds them (`source = (source as LiteralType).regularType`). The
* incoming value stays the caller's borrow; every value the body assigns is
* owned through a separate owner slot, which each rebinding releases and
* replaces and which the function scope releases on every exit. Reads see
* the parameter slot, so whole-value uses retain as for any binding.
*
* Only plain statement assignments qualify: expression-position writes,
* loop and catch bindings, declarations and captures keep the ordinary
* owned parameter. Suspending bodies and environments use the owned ABI. */
export function findReboundParameters(
fn: IrFunction,
plainReference: (type: IrType) => boolean,
excluded: ReadonlySet<string>,
): ReadonlySet<string> {
const result = new Set<string>();
if (fn.async || fn.generator || fn.captures || fn.classCaptures) return result;
const locals = new Map(fn.locals.map((local) => [local.id, local]));
const candidates = new Set(
fn.params
.filter((param) => {
const local = locals.get(param.localId);
return (
local !== undefined &&
!local.boxed &&
!local.tdz &&
!excluded.has(param.localId) &&
plainReference(param.type)
);
})
.map((param) => param.localId),
);
if (candidates.size === 0) return result;
const rebound = new Set<string>();
const invalid = new Set<string>();
const expr = (e: IrExpr): boolean => {
switch (e.kind) {
case "assignExpr":
case "incDec":
invalid.add(e.localId);
break;
case "closure":
case "classRef":
for (const id of e.captures ?? []) invalid.add(id);
break;
}
return everyExprChild(e, expr, (s) => stmt(s, true));
};
const stmt = (s: IrStmt, inExpression: boolean): boolean => {
switch (s.kind) {
case "assign":
if (inExpression) invalid.add(s.localId);
else rebound.add(s.localId);
break;
case "varDecl":
case "forOf":
case "rethrow":
invalid.add(s.localId);
break;
case "tryCatch":
if (s.catchLocalId !== null) invalid.add(s.catchLocalId);
break;
}
return everyStmtChild(s, expr, (child) => stmt(child, inExpression));
};
for (const s of fn.body) stmt(s, false);
for (const id of candidates) if (rebound.has(id) && !invalid.has(id)) result.add(id);
return result;
}
42 changes: 41 additions & 1 deletion packages/compiler/src/backend/llvm/reference-effects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import {
F64,
STRING,
VOID,
type IrClassDef,
type IrExpr,
type IrFunction,
type IrStmt,
} from "../../ir/ir.js";
import { ReferenceEffects, preservesRegexInputs } from "./reference-effects.js";
import { ReferenceEffects, VirtualTargets, preservesRegexInputs } from "./reference-effects.js";

const loc = { file: "effects.ts", start: 0, end: 0 };
const number: IrExpr = { kind: "numLit", value: 1, type: F64, loc };
Expand Down Expand Up @@ -195,3 +196,42 @@ test("nullish fallbacks and Math calls preserve references; their operands still
expect(summary.preserves(fallback(write))).toBe(false);
expect(summary.preserves(fallback(call("read")))).toBe(true);
});

test("virtual calls preserve references only when every reachable override does", () => {
const cls = (name: string, methods: string[], base?: string, extra?: Partial<IrClassDef>) =>
({ name, base, fields: [], methods, loc, ...extra }) as IrClassDef;
const classes = [
cls("A", ["run", "size"], undefined, { abstractMethods: ["size"] }),
cls("B", ["size"], "A"),
cls("C", ["run"], "B"),
cls("D", [], "A"),
];
const targets = new VirtualTargets(classes);
expect(targets.targets("A", "run")).toEqual(["%A.run", "%C.run"]);
// B inherits A.run; only C overrides below it. Abstract slots have no body.
expect(targets.targets("B", "run")).toEqual(["%A.run", "%C.run"]);
expect(targets.targets("A", "size")).toEqual(["%B.size"]);
expect(targets.targets("D", "run")).toEqual(["%A.run"]);
expect(
new VirtualTargets([...classes, cls("E", ["run"], "D", { runtime: true })]).targets("A", "run"),
).toBe(null);
const virtual: IrExpr = {
kind: "virtualCall",
className: "A",
method: "run",
args: [],
type: F64,
loc,
};
const build = (mutating: boolean) => {
const functions = [fn("%A.run"), fn("%C.run"), fn("caller", [virtual])];
if (mutating) functions[1]!.body.push({ kind: "assign", localId: "owner", value: text, loc });
return new ReferenceEffects(new Map(functions.map((f) => [f.name, f])), () => false, classes);
};
expect(build(false).preserves(virtual)).toBe(true);
expect(build(false).functions.has("caller")).toBe(true);
expect(build(true).preserves(virtual)).toBe(false);
expect(build(true).functions.has("caller")).toBe(false);
// Without class facts a virtual call stays a barrier.
expect(effects([fn("%A.run")]).preserves(virtual)).toBe(false);
});
Loading
Loading