From 377af617d38f1ad2c8250b17c5341a89b7ea2a40 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 14:28:28 -0500 Subject: [PATCH] Add --exceptions=rare to unwind through recursive functions With --exceptions=rare, recursive functions and functions up to three calls above a throw also propagate exceptions by unwinding on Linux, so calls to them need no pending-flag test. Functions with a throw of their own keep the flag. The default, common, keeps the current rules, and builds without the flag emit the same code as before. The option is part of the executable cache key; the bootstrap's cached route leaves builds with it to the full compiler, and coverage and library builds refuse it. --- docs/content/docs/cli.mdx | 3 + packages/cli/src/bootstrap.ts | 3 + packages/cli/test/exceptions.test.ts | 94 +++++++++++++++++++ packages/compiler/src/backend/llvm/emitter.ts | 11 ++- .../src/backend/llvm/unwind-abi.test.ts | 34 +++++++ .../compiler/src/backend/llvm/unwind-abi.ts | 36 ++++--- packages/compiler/src/cli/command.ts | 13 ++- packages/compiler/src/cli/usage.ts | 7 ++ packages/compiler/src/compile-types.ts | 6 ++ .../src/executable/executable-cache.ts | 4 + packages/compiler/src/index.ts | 3 + packages/compiler/src/native/prepare.ts | 1 + 12 files changed, 198 insertions(+), 17 deletions(-) create mode 100644 packages/cli/test/exceptions.test.ts diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index 9efd25342..f439edf52 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -166,6 +166,9 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
--large-pages
On Linux, when the program starts, move its code onto 2 MiB transparent huge pages, as Node's --use-largepages=silent 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 (/sys/kernel/mm/transparent_hugepage/enabled is always or madvise), 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.
+
--exceptions <common|rare>
+
On Linux, say how often the program throws. With the default, common, recursive functions and functions up to three calls above a throw 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 rare, 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 throw 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 --profile-use should be collected from a build with the same setting.
+
--target-cpu <baseline|x86-64-v2|x86-64-v3|x86-64-v4|native>
On x86-64 targets, compile the program's code for an x86-64 microarchitecture level. The default, baseline, 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. native uses the highest level the build machine supports. Valid for executable, assembly, and object output on x86-64 targets. See Target CPU.
diff --git a/packages/cli/src/bootstrap.ts b/packages/cli/src/bootstrap.ts index cc1ca35ff..7bdb9d213 100644 --- a/packages/cli/src/bootstrap.ts +++ b/packages/cli/src/bootstrap.ts @@ -75,6 +75,8 @@ async function tryFastPath(): Promise { 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 || diff --git a/packages/cli/test/exceptions.test.ts b/packages/cli/test/exceptions.test.ts new file mode 100644 index 000000000..3e87049a1 --- /dev/null +++ b/packages/cli/test/exceptions.test.ts @@ -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 => { + 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); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index ae533b31f..2cb5d2f87 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -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; diff --git a/packages/compiler/src/backend/llvm/unwind-abi.test.ts b/packages/compiler/src/backend/llvm/unwind-abi.test.ts index da490324b..7a5343aae 100644 --- a/packages/compiler/src/backend/llvm/unwind-abi.test.ts +++ b/packages/compiler/src/backend/llvm/unwind-abi.test.ts @@ -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 }]) { diff --git a/packages/compiler/src/backend/llvm/unwind-abi.ts b/packages/compiler/src/backend/llvm/unwind-abi.ts index 2c0721a13..1e067cb5d 100644 --- a/packages/compiler/src/backend/llvm/unwind-abi.ts +++ b/packages/compiler/src/backend/llvm/unwind-abi.ts @@ -83,13 +83,17 @@ function recursiveFunctions(edges: ReadonlyMap): 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, excluded: ReadonlySet = new Set(), /** The module's node index: the bodies' nodes are read from it, in walk order, without walking. */ nodes?: IrNodeIndex, + rareExceptions = false, ): Set { const names = new Set(mod.functions.map((fn) => fn.name)); const blocked = new Set(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(); 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", diff --git a/packages/compiler/src/cli/command.ts b/packages/compiler/src/cli/command.ts index 042f09dcb..5ee462ecf 100644 --- a/packages/compiler/src/cli/command.ts +++ b/packages/compiler/src/cli/command.ts @@ -119,6 +119,7 @@ async function main(args: string[], host: CliHost): Promise { 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 { 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 { 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 { 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 { 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 { } : {}), ...(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 diff --git a/packages/compiler/src/cli/usage.ts b/packages/compiler/src/cli/usage.ts index b9de6ce0a..e8d8f6e7b 100644 --- a/packages/compiler/src/cli/usage.ts +++ b/packages/compiler/src/cli/usage.ts @@ -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 + 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 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" }, diff --git a/packages/compiler/src/compile-types.ts b/packages/compiler/src/compile-types.ts index b8dc71e3b..5a382d9c6 100644 --- a/packages/compiler/src/compile-types.ts +++ b/packages/compiler/src/compile-types.ts @@ -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. diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index a1a322e48..6dc60709a 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -169,6 +169,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. */ @@ -338,6 +340,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] : []), @@ -387,6 +390,7 @@ function routeKey(options: Omit 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) }), diff --git a/packages/compiler/src/native/prepare.ts b/packages/compiler/src/native/prepare.ts index 703c00559..bf496aa99 100644 --- a/packages/compiler/src/native/prepare.ts +++ b/packages/compiler/src/native/prepare.ts @@ -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