Add --exceptions=rare to unwind through recursive functions (#2481)

This commit is contained in:
Chris Tate
2026-10-28 15:17:22 -05:00
committed by GitHub
12 changed files with 198 additions and 17 deletions
+3
View File
@@ -166,6 +166,9 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
<dt><code>--large-pages</code></dt>
<dd>On Linux, when the program starts, move its code onto 2 MiB transparent huge pages, as Node's <code>--use-largepages=silent</code> does. A large program's code then takes a few instruction TLB entries instead of hundreds, and runs faster. The move applies only when the system grants huge pages to memory that asks for them (<code>/sys/kernel/mm/transparent_hugepage/enabled</code> is <code>always</code> or <code>madvise</code>), and only to the code between the first and the last 2 MiB boundary of the executable, so small programs keep their code as it is. Opt in only where the trade-offs are acceptable: the move takes about 3 ms per 10 MiB of code at every start, all of the moved code stays resident and is not shared with other processes running the same executable, and profilers that read symbols from the executable file report the moved code as anonymous memory. If a step of the move fails, the program runs with its code where it was. Valid only for executable builds; it has no effect on other platforms.</dd>
<dt><code>--exceptions &lt;common|rare&gt;</code></dt>
<dd>On Linux, say how often the program throws. With the default, <code>common</code>, recursive functions and functions up to three calls above a <code>throw</code> report exceptions to their callers with a status check after each call, which costs a few nanoseconds per call and next to nothing when an exception passes. With <code>rare</code>, those functions also propagate exceptions by unwinding the native stack: their calls need no check, which makes programs such as type checkers, whose work is deep recursion that seldom throws, a few percent faster. An exception that crosses those functions then costs a native unwind, about 2 µs plus 0.1 to 0.7 µs per frame it crosses, so programs that throw in loops or use exceptions for control flow can become several times slower. Functions with a <code>throw</code> of their own keep the status check either way. It applies to executables and their LLVM and assembly output; object output and other platforms keep the status checks. A profile for <code>--profile-use</code> should be collected from a build with the same setting.</dd>
<dt><code>--target-cpu &lt;baseline|x86-64-v2|x86-64-v3|x86-64-v4|native&gt;</code></dt>
<dd>On x86-64 targets, compile the program's code for an x86-64 microarchitecture level. The default, <code>baseline</code>, runs on any x86-64 CPU. A higher level lets LLVM use more instructions, and the executable then runs only on CPUs that support that level. <code>native</code> uses the highest level the build machine supports. Valid for executable, assembly, and object output on x86-64 targets. See <a href="#target-cpu">Target CPU</a>.</dd>
+3
View File
@@ -75,6 +75,8 @@ async function tryFastPath(): Promise<number | null> {
values.libnode === "beside" ||
// The full compiler resolves `native` against the build host.
values["target-cpu"] !== undefined ||
// The full CLI validates the value.
values.exceptions !== undefined ||
(values["external-types"] ?? []).length > 0 ||
// The full CLI validates the Node island policy flags.
hasNodeIslandFlags(values) ||
@@ -238,6 +240,7 @@ async function coverageFastPath(
values.static ||
values["detached-exit"] ||
values["large-pages"] ||
values.exceptions !== undefined ||
values["target-cpu"] !== undefined ||
values["cpu-dispatch"] !== undefined ||
values.lib ||
+94
View File
@@ -0,0 +1,94 @@
import { execFile } from "node:child_process";
import { createRequire } from "node:module";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import { expect, test } from "vitest";
const execFileAsync = promisify(execFile);
const require = createRequire(import.meta.url);
const repoRoot = join(import.meta.dirname, "../../..");
const cliEntry = join(repoRoot, "packages/cli/src/main.ts");
const bootstrapEntry = join(repoRoot, "packages/cli/dist/bootstrap.js");
const tsxLoader = join(dirname(require.resolve("tsx/package.json")), "dist/loader.mjs");
// A recursion that a throw below it crosses: with --exceptions=rare, walk
// calls itself with invoke.
const program = `function leaf(n: number): number {
if (n % 7 === 0) throw new RangeError("seven " + n);
return n;
}
function walk(n: number, depth: number): number {
const parts: string[] = ["w" + n];
const r = depth === 0 ? leaf(n) : walk(n, depth - 1);
return r + parts.length;
}
let sum = 0;
for (let i = 1; i < 40; i++) {
try { sum += walk(i, 20); } catch (e) { console.log((e as Error).message); }
}
console.log(sum);
`;
test.runIf(process.platform === "linux")(
"--exceptions=rare unwinds through recursive functions without mixing cache variants",
async () => {
const dir = await mkdtemp(join("/tmp", "scriptc-exceptions-"));
const entry = join(dir, "main.ts");
const outPath = join(dir, "program");
const llvmPath = join(dir, "main.ll");
const env = { ...process.env, SCRIPTC_CACHE_DIR: join(dir, "cache") };
const build = async (args: string[], bootstrap = false): Promise<string> => {
await execFileAsync(
process.execPath,
[
...(bootstrap ? [bootstrapEntry] : ["--import", tsxLoader, cliEntry]),
"build",
entry,
"-o",
outPath,
...args,
],
{ env, maxBuffer: 4 * 1024 * 1024 },
);
return readFile(llvmPath, "utf8");
};
const walkInvokes = (llvm: string): boolean =>
/^define internal [^\n]*@sc_b?f_walk\([^]*?invoke double @sc_b?f_walk[^]*?^}/m.test(llvm);
try {
await writeFile(entry, program);
const expected = `${(await execFileAsync(process.execPath, [entry])).stdout}`;
const common = await build([]);
expect(walkInvokes(common)).toBe(false);
expect(await build(["--exceptions=common"])).toBe(common);
const rare = await build(["--exceptions=rare"]);
expect(walkInvokes(rare)).toBe(true);
expect((await execFileAsync(outPath)).stdout).toBe(expected);
// The bootstrap's cached route builds with the default rules.
expect(await build([], true)).toBe(common);
expect((await execFileAsync(outPath)).stdout).toBe(expected);
await expect(build(["--exceptions=never"])).rejects.toThrow(
/unknown exception frequency "never"/,
);
} finally {
await rm(dir, { recursive: true, force: true });
}
},
240_000,
);
test("--exceptions is a build/run option", async () => {
const dir = await mkdtemp(join("/tmp", "scriptc-exceptions-coverage-"));
const entry = join(dir, "main.ts");
try {
await writeFile(entry, program);
const coverage = execFileAsync(
process.execPath,
["--import", tsxLoader, cliEntry, "coverage", entry, "--exceptions=rare"],
{ env: { ...process.env, SCRIPTC_CACHE_DIR: join(dir, "cache") } },
);
await expect(coverage).rejects.toThrow(/--exceptions is a build\/run option/);
} finally {
await rm(dir, { recursive: true, force: true });
}
}, 120_000);
+10 -1
View File
@@ -594,6 +594,9 @@ export interface LlvmTargetOptions {
/** The files of the program's Node island module tree, in manifest order
* (node-islands/modules.ts), when the executable carries the tree. */
nodeIslandModuleData?: Uint8Array;
/** The program throws rarely (`--exceptions=rare`): recursive functions
* and those near a `throw` also unwind (unwindAbiFunctions). */
rareExceptions?: boolean;
/** Translated WebAssembly memories check bounds explicitly instead of
* relying on guard pages (wasm-modules.ts wasmBoundsChecks). */
wasmBoundsChecks?: boolean;
@@ -2193,7 +2196,13 @@ export class LlEmitter {
(options.targetTriple
? /-linux-(?:gnu|musl)/.test(options.targetTriple)
: process.platform === "linux")
? unwindAbiFunctions(mod, this.mayThrow, options.unwindExcluded, this.nodeIndex)
? unwindAbiFunctions(
mod,
this.mayThrow,
options.unwindExcluded,
this.nodeIndex,
options.rareExceptions === true,
)
: new Set();
this.workerEntryPolls = mt.workerEntryPolls ?? null;
this.stackChecks = mt.stackChecks ?? null;
@@ -141,6 +141,40 @@ for (let i = -1; i < 2; i++) {
}
});
test("with rareExceptions, recursive functions and those near a throw unwind; throwing ones keep the flag", async () => {
const source = `
function leaf(n: number): number {
if (n < 0) throw new RangeError("negative");
return n;
}
function one(n: number): number { return leaf(n) + 1; }
function two(n: number): number { return one(n) + 1; }
function walk(n: number, depth: number): number {
const parts: string[] = ["w" + n];
const r = depth === 0 ? two(n) : walk(n, depth - 1);
return r + parts.length;
}
for (let i = -1; i < 2; i++) {
try { console.log(walk(i, 3)); } catch (e) { console.log((e as Error).message); }
}
`;
const mod = await moduleFor(source);
const common = emitLlvmModule(mod, LINUX);
const rare = emitLlvmModule(mod, { ...LINUX, rareExceptions: true });
for (const [caller, callee] of [
["two", "one"],
["walk", "walk"],
["walk", "two"],
] as const) {
const invoke = new RegExp(`invoke double @sc_b?f_[^(]*${callee}`);
expect(body(common, caller)).not.toMatch(invoke);
expect(body(rare, caller)).toMatch(invoke);
}
// one raises what leaf returns pending; leaf throws, so it keeps the flag.
expect(body(rare, "one")).toContain("call void @scr_unwind_raise()");
expect(body(rare, "one")).not.toMatch(/invoke double @sc_b?f_[^(]*leaf/);
});
test("without the option, and off Linux, emission keeps the pending-flag ABI", async () => {
const mod = await moduleFor(SOURCE);
for (const options of [{}, { targetTriple: "arm64-apple-macosx14.0.0", unwindAbi: true }]) {
@@ -83,13 +83,17 @@ function recursiveFunctions(edges: ReadonlyMap<string, readonly string[]>): Set<
* flag: those with a `throw` or rethrow of their own
* and those up to THROW_LEVELS calls above one (a handler near the throw
* takes it without a native unwind), and the recursive ones (deep stacks
* are recursions). */
* are recursions). With `rareExceptions` (`--exceptions=rare`) only the
* functions with a `throw` of their own keep the flag for that reason: the
* program throws rarely, so the flag tests after the calls of the others
* cost more than the raises. */
export function unwindAbiFunctions(
mod: IrModule,
mayThrow: ReadonlySet<string>,
excluded: ReadonlySet<string> = new Set(),
/** The module's node index: the bodies' nodes are read from it, in walk order, without walking. */
nodes?: IrNodeIndex,
rareExceptions = false,
): Set<string> {
const names = new Set(mod.functions.map((fn) => fn.name));
const blocked = new Set<string>(excluded);
@@ -222,20 +226,22 @@ export function unwindAbiFunctions(
// THROW_LEVELS calls above it. The union narrowing helpers' throws (a
// failed narrowing) don't count for their callers.
for (const name of throws) blocked.add(name);
let level = [...throws].filter((name) => !name.startsWith("%union."));
const near = new Set(level);
for (let i = 0; i < THROW_LEVELS; i++) {
const next: string[] = [];
for (const name of level)
for (const caller of callers.get(name) ?? [])
if (!near.has(caller)) {
near.add(caller);
next.push(caller);
}
level = next;
if (!rareExceptions) {
let level = [...throws].filter((name) => !name.startsWith("%union."));
const near = new Set(level);
for (let i = 0; i < THROW_LEVELS; i++) {
const next: string[] = [];
for (const name of level)
for (const caller of callers.get(name) ?? [])
if (!near.has(caller)) {
near.add(caller);
next.push(caller);
}
level = next;
}
for (const name of near) blocked.add(name);
for (const name of recursiveFunctions(callees)) blocked.add(name);
}
for (const name of near) blocked.add(name);
for (const name of recursiveFunctions(callees)) blocked.add(name);
const result = new Set<string>();
for (const fn of mod.functions) {
if (!mayThrow.has(fn.name) || blocked.has(fn.name)) continue;
@@ -243,7 +249,7 @@ export function unwindAbiFunctions(
result.add(fn.name);
}
if (nodes !== undefined && process.env["SCRIPTC_EMIT_FACTS_VERIFY"] === "1") {
const walked = unwindAbiFunctions(mod, mayThrow, excluded);
const walked = unwindAbiFunctions(mod, mayThrow, excluded, undefined, rareExceptions);
if (walked.size !== result.size || [...walked].some((name) => !result.has(name)))
throw new InternalCompilerError(
"unwind ABI: the functions read from the node index differ from a walk's",
+12 -1
View File
@@ -119,6 +119,7 @@ async function main(args: string[], host: CliHost): Promise<number> {
values["windows-subsystem"] !== undefined ||
values["detached-exit"] ||
values["large-pages"] ||
values.exceptions !== undefined ||
values["target-cpu"] !== undefined ||
values["cpu-dispatch"] !== undefined ||
values["profile-generate"] !== undefined ||
@@ -182,6 +183,7 @@ async function main(args: string[], host: CliHost): Promise<number> {
values["windows-subsystem"] !== undefined ||
values["detached-exit"] ||
values["large-pages"] ||
values.exceptions !== undefined ||
values["target-cpu"] !== undefined ||
values["cpu-dispatch"] !== undefined ||
values["profile-generate"] !== undefined ||
@@ -276,6 +278,7 @@ async function main(args: string[], host: CliHost): Promise<number> {
values["windows-subsystem"] !== undefined ||
values["detached-exit"] ||
values["large-pages"] ||
values.exceptions !== undefined ||
values["target-cpu"] !== undefined ||
values["cpu-dispatch"] !== undefined ||
values["profile-generate"] !== undefined ||
@@ -287,7 +290,7 @@ async function main(args: string[], host: CliHost): Promise<number> {
hasNodeIslandFlags(values)
) {
fail(
"scriptc build --lib takes no --backend/--emit/--print/--optimization/--strip/--static/--windows-subsystem/--detached-exit/--large-pages/--target-cpu/--cpu-dispatch/--profile-generate/--profile-use/--npm-static/--ffi/--external-types/--tier/--explain-dynamic: the profile pins the emission and optimization, npm imports are judged automatically, outbound FFI belongs to executable builds, external type mappings belong to coverage, and library artifacts are static-tier only",
"scriptc build --lib takes no --backend/--emit/--print/--optimization/--strip/--static/--windows-subsystem/--detached-exit/--large-pages/--exceptions/--target-cpu/--cpu-dispatch/--profile-generate/--profile-use/--npm-static/--ffi/--external-types/--tier/--explain-dynamic: the profile pins the emission and optimization, npm imports are judged automatically, outbound FFI belongs to executable builds, external type mappings belong to coverage, and library artifacts are static-tier only",
);
}
const profilePath = resolve(profileArg);
@@ -345,6 +348,13 @@ async function main(args: string[], host: CliHost): Promise<number> {
if (command === "coverage" && values["large-pages"]) {
fail(`--large-pages is a build/run option\n\n${USAGE}`);
}
if (command === "coverage" && values.exceptions !== undefined) {
fail(`--exceptions is a build/run option\n\n${USAGE}`);
}
const exceptions = values.exceptions;
if (exceptions !== undefined && exceptions !== "common" && exceptions !== "rare") {
fail(`unknown exception frequency "${exceptions}" (supported: common, rare)\n\n${USAGE}`);
}
if (command === "coverage" && values["profile-generate"] !== undefined) {
fail(`--profile-generate is a build/run option\n\n${USAGE}`);
}
@@ -654,6 +664,7 @@ async function main(args: string[], host: CliHost): Promise<number> {
}
: {}),
...(values["large-pages"] ? { largePages: true } : {}),
...(exceptions === "rare" ? { exceptions: "rare" as const } : {}),
...targetCpuField(targetCpu),
...(values["cpu-dispatch"] === undefined ? {} : { cpuDispatch: values["cpu-dispatch"] }),
// The instrumented program resolves a relative pattern against its own
+7
View File
@@ -79,6 +79,12 @@ Options:
large programs. Takes about 3 ms per 10 MiB of code, and
all of the moved code stays resident (opt in; --emit=exe
only)
--exceptions <common|rare>
Linux: how often the program throws (default: common).
rare lets recursive functions and functions near a
throw propagate exceptions by unwinding: their calls
need no exception check, and a throw that crosses them
costs a native unwind of several microseconds
--target-cpu <baseline|x86-64-v2|x86-64-v3|x86-64-v4|native>
x86-64: compile the program's code for a CPU level
(default: baseline, which runs on any x86-64 CPU). The
@@ -192,6 +198,7 @@ export const CLI_OPTIONS = {
"detached-exit": { type: "boolean", default: false },
libnode: { type: "string" },
"large-pages": { type: "boolean", default: false },
exceptions: { type: "string" },
"target-cpu": { type: "string" },
"cpu-dispatch": { type: "boolean" },
"profile-generate": { type: "string" },
+6
View File
@@ -57,6 +57,12 @@ export interface CompileBaseOptions {
* at startup when the system allows them. The moved code stays resident.
* Executables only; no effect on other platforms. */
largePages?: boolean;
/** How often the program throws (`--exceptions`). `rare` puts recursive
* functions and those near a `throw` on the unwinding exception ABI too:
* calls to them need no pending-flag test, and an exception that crosses
* them costs a native unwind. Linux executables only; `common` (the
* default) and other targets keep the default rules. */
exceptions?: "common" | "rare";
/** The x86-64 microarchitecture level the executable's code may require
* (`x86-64-v2` to `x86-64-v4`). The program's functions are compiled for
* that level, and the executable then runs only on CPUs that support it.
@@ -170,6 +170,8 @@ export interface EarlyExecutableCacheOptions {
libnodeIdentity?: () => string | undefined;
/** Omitted unless --large-pages is set. */
largePages?: true;
/** Omitted unless --exceptions=rare is set. */
exceptions?: "rare";
/** Omitted for the x86-64 baseline (`--target-cpu`). */
targetCpu?: TargetCpu;
/** Omitted for builds without runtime CPU dispatch. */
@@ -339,6 +341,7 @@ function cacheKey(options: EarlyExecutableCacheOptions): string {
...(options.windowsSubsystem === "gui" ? ["windows-subsystem-gui"] : []),
...(options.detachedExit ? ["detached-exit"] : []),
...(options.largePages ? ["large-pages"] : []),
...(options.exceptions === "rare" ? ["exceptions-rare"] : []),
...targetCpuKeyParts(options.targetCpu),
...cpuDispatchKeyParts(options.cpuDispatch),
...(options.profile !== undefined ? [options.profile] : []),
@@ -388,6 +391,7 @@ function routeKey(options: Omit<EarlyExecutableRouteOptions, "nativeEnvironment"
if (options.windowsSubsystem === "gui") hash.update("windows-subsystem-gui\0");
if (options.detachedExit) hash.update("detached-exit\0");
if (options.largePages) hash.update("large-pages\0");
if (options.exceptions === "rare") hash.update("exceptions-rare\0");
for (const part of targetCpuKeyParts(options.targetCpu)) hash.update(`${part}\0`);
for (const part of cpuDispatchKeyParts(options.cpuDispatch)) hash.update(`${part}\0`);
if (options.profile !== undefined) hash.update(`${options.profile}\0`);
+3
View File
@@ -1026,6 +1026,7 @@ async function prepareExecutableInput(
// Textual output shows the code an executable gets; objects keep
// the pending flag, since their link may not add an unwinder.
unwindAbi: buildPlatform === "linux" && outputKind !== "obj",
...(opts.exceptions === "rare" ? { rareExceptions: true } : {}),
objectAudit: opts.sanitize === true,
sanitizeAttributes: opts.sanitize === true,
lowRegion: lowRegionTarget(buildPlatform, opts.sanitize === true),
@@ -1113,6 +1114,7 @@ async function prepareExecutableInput(
...inlineRcOption(opts.optimization, opts.sanitize === true),
...(opts.detachedExit === true ? { detachedExit: true } : {}),
...(nodeIslandModuleData === undefined ? {} : { nodeIslandModuleData }),
...(opts.exceptions === "rare" ? { rareExceptions: true } : {}),
...targetCpuField(opts.targetCpu),
...(opts.cpuDispatch === true ? { cpuDispatch: true } : {}),
});
@@ -1820,6 +1822,7 @@ async function compileTracked(
// identity (node-islands/libnode.ts) and a hit requires it unchanged.
libnodeIdentity: () => libnodeCacheKey(opts.libnode, process.env, dirname(entryPath)),
...(opts.largePages === true ? { largePages: true as const } : {}),
...(opts.exceptions === "rare" ? { exceptions: "rare" as const } : {}),
...targetCpuField(opts.targetCpu),
...(opts.cpuDispatch === true ? { cpuDispatch: true as const } : {}),
...(profile === undefined ? {} : { profile: nativeProfileKey(profile) }),
+1
View File
@@ -443,6 +443,7 @@ export function prepareNativeExecutable(
timing,
...inlineRcOption(options.optimization, options.sanitize === true),
...(options.detachedExit === true && outputKind === "exe" ? { detachedExit: true } : {}),
...(options.exceptions === "rare" ? { rareExceptions: true } : {}),
...targetCpuField(options.targetCpu),
...(options.cpuDispatch === true && outputKind === "exe" ? { cpuDispatch: true } : {}),
...(options.optimization === "dev" && !options.strip