Load the active exception cell once per synchronous body

Every inline pending-exception check in an executable loaded the
runtime's active-cell pointer and then its kind: two dependent loads
after each call that may throw, because LLVM must assume any call can
change the pointer. Fiber switches restore the active cell before control
returns to a synchronous frame (stack switches, eager spawns and
generator resumes all swap back to the caller's cell), so the pointer is
one value for the whole invocation. Synchronous IR function bodies now
load it once in the entry block and each check reads only the kind.

The flag lives on the body's BlockBuilder, so adapters and runtime
helpers keep the per-check load. Async functions, generators and worker
executables (whose checks also fold in the context stop signal) keep the
existing form; libraries still test through scr_exc_pending.
This commit is contained in:
Malte Ubl
2026-10-09 15:03:13 -07:00
parent 294b443b32
commit 6ff0b7c2df
6 changed files with 48 additions and 5 deletions
@@ -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
@@ -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);
});
+17 -2
View File
@@ -3800,9 +3800,21 @@ export class LlEmitter {
const tl = this.mod.workers === true ? "thread_local " : "";
this.declare(`@scr_exc_active = external ${tl}global ptr`);
this.declare(`declare i1 @llvm.expect.i1(i1, i1)`);
const cell = B.tmp();
let cell: string;
if (B.excCellInvariant && this.mod.workers !== true) {
// Fiber switches restore the active cell before control returns to a
// synchronous frame, so its pointer is one value for the whole call:
// load it once in the entry block and test only the kind here.
if (B.excCell === null) {
B.excCell = "%exc.cell";
B.entryAllocas.push(`${B.excCell} = load ptr, ptr @scr_exc_active`);
}
cell = B.excCell;
} else {
cell = B.tmp();
B.line(`${cell} = load ptr, ptr @scr_exc_active`);
}
const kind = B.tmp();
B.line(`${cell} = load ptr, ptr @scr_exc_active`);
B.line(`${kind} = load i32, ptr ${cell}`);
let word = kind;
if (this.mod.workers === true) {
@@ -4770,6 +4782,9 @@ export class LlEmitter {
private emitFunction(fn: IrFunction): string {
const B = new BlockBuilder();
this.B = B;
// Fiber switches restore the active exception cell before control
// returns to a synchronous frame (emitPendingCheck).
B.excCellInvariant = fn.async !== true && fn.generator === undefined;
this.debugScope = this.debug?.function(fn) ?? null;
B.debugLocation = this.debug?.location(fn.loc, this.debugScope) ?? null;
this.frames = [];
@@ -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" };
@@ -18,7 +18,8 @@ import { emitLlvmModule } from "./emitter.js";
import { borrowsStringInputs } from "./string-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: "strings.ts", start: 0, end: 1 };
const ref = (id: string, type: IrType = STRING): IrExpr => ({
@@ -8,7 +8,8 @@ import { emitLlvmModule } from "../src/backend/llvm/emitter.js";
import { type IrModule } from "../src/ir/ir.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/;
async function lower(source: string): Promise<IrModule> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-input-lifetimes-"));