diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 677e51775..a402f912f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -101,8 +101,8 @@ jobs: if (receiptRun) appendFileSync(process.env.GITHUB_OUTPUT, `receipt_run=${receiptRun}\n`); NODE - # Collects the native compiler's profile and function layout on this commit - # (scripts/refresh-cli-profile.mjs, scripts/refresh-cli-layout.mjs), so every + # Collects the native compiler's function layout and profile on this commit + # (scripts/refresh-cli-layout.mjs, scripts/refresh-cli-profile.mjs), so every # platform's CLI build optimizes and lays out the code it compiles. The # committed ones fall behind main within a day or two. If this job fails, # the CLI builds use the committed profile and layout. @@ -111,7 +111,7 @@ jobs: needs: check-release if: needs.check-release.outputs.should_release == 'true' && needs.check-release.outputs.reuse_artifacts != 'true' runs-on: ubuntu-24.04 - timeout-minutes: 45 + timeout-minutes: 75 continue-on-error: true permissions: contents: read @@ -136,14 +136,20 @@ jobs: git clone --quiet --depth 1 https://github.com/vercel-labs/tsc-ts "$RUNNER_TEMP/tsc-ts" echo "tsc-ts $(git -C "$RUNNER_TEMP/tsc-ts" rev-parse HEAD)" npm ci --prefix "$RUNNER_TEMP/tsc-ts" + # The layout comes first: the profile matches the functions of builds + # with the layout its instrumented compiler was built with, so the + # profile is collected with a second instrumented compiler built with + # this layout. + - name: Collect the function layout + run: >- + node scripts/refresh-cli-layout.mjs --jobs 4 + --output "$RUNNER_TEMP/cli-profile/cli.layout.gz" --work "$RUNNER_TEMP/cli-layout-work" - name: Collect the profile + env: + SCRIPTC_NATIVE_LAYOUT: ${{ runner.temp }}/cli-profile/cli.layout.gz run: >- node scripts/refresh-cli-profile.mjs --tsc-ts "$RUNNER_TEMP/tsc-ts" --jobs 4 --output "$RUNNER_TEMP/cli-profile/cli.profdata.gz" --work "$RUNNER_TEMP/cli-profile-work" - - name: Collect the function layout - run: >- - node scripts/refresh-cli-layout.mjs --jobs 4 --instrumented "$RUNNER_TEMP/cli-profile-work/cli" - --output "$RUNNER_TEMP/cli-profile/cli.layout.gz" - uses: actions/upload-artifact@v4 with: name: cli-profile diff --git a/AGENTS.md b/AGENTS.md index 00ce6bfd1..3e631a92f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,8 @@ their supported native-clang contracts locally; other hosts retain those checks in the Sandboxes. Both lanes green is the bar before shipping any change. +`SCRIPTC_SANDBOX_NATIVE_REFERENCE` names a built checkout inside a prepared `SCRIPTC_SANDBOX_SNAPSHOT` or image: each lane copies that checkout's LLVM helper and runtime pack instead of rebuilding them when their source inputs are identical. Sandbox creation is rate limited per team in vCPUs per minute, so the runner retries a rate-limited creation with backoff. Larger Sandboxes (`SCRIPTC_SANDBOX_VCPUS` up to 32, with `SCRIPTC_TEST_WORKERS` at half the vCPUs) shorten the lanes most. + `VERCEL_OIDC_TOKEN` is the preferred credential. `VERCEL_TOKEN` remains supported with explicit `VERCEL_TEAM_ID` and `VERCEL_PROJECT_ID`. The custom `SCRIPTC_SANDBOX_IMAGE` is optional: without it, the gate starts from @@ -58,6 +60,8 @@ A change that must not cost good code anything, such as a compatibility fix that node scripts/zero-cost-gate.mjs --baseline=/vercel/sandbox/prev --candidate=. --allow='\barguments\b' ``` +The whole gate takes 30 to 75 minutes on one 8-vCPU Sandbox, so shard it across Sandboxes. `--shard=i/n` splits the bench and corpus programs; the self-hosted compiler runs in shard 1, or alone with `--set=self`, and its candidate-source build overlaps its comparison build on hosts with 40 GB or more. Each shard writes `--json`, and `node scripts/zero-cost-merge.mjs ...` merges them into one verdict, recomputing the compile-time intervals over every shard. `--record-baseline-cache=` builds only the baseline and records each program's artifact hashes; `--baseline-cache=` then skips the baseline build of every program whose candidate artifacts hash the same, which halves the work for a change that leaves most programs identical. A record applies only to the built baseline checkout, toolchain, and paths it was made with, and is ignored otherwise; a program whose entry, relative imports, or optimization level changed since the record builds its baseline. + Test location follows scope: - Co-locate white-box unit tests with implementation files under diff --git a/RELEASING.md b/RELEASING.md index 4480e3fbc..a33da97f5 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -16,9 +16,9 @@ CI (`.github/workflows/release.yml`) compares the version in `packages/cli/packa The release job builds each pinned LLVM helper, precompiled runtime pack, and native CLI on its matching host. The native CLI package bundles its TypeScript checker, declarations, and comptime evaluator, and depends on its platform's LLVM helper and runtime pack packages at the same version; the compiler resolves them beside itself. The standalone archives bundle the helper and runtime pack too. npm postinstall installs the matching native executable; Node is needed for npm installation but not native compilation or execution. The user's toolchain performs the final platform link. Sanitized builds also use it to instrument program LLVM and compile the C runtime. Platform packages publish ahead of `@scriptc/compiler` and `scriptc`. The GitHub release attaches standalone compiler archives, their SHA-256 checksums, and the surface manifest after npm publication succeeds. -Release builds of the native CLI optimize the compiler with a profile collected on the release commit. The `cli-profile` job runs `node scripts/refresh-cli-profile.mjs` on Linux x64, which builds an instrumented compiler, runs it on tsc-ts, a slice of the corpus, and the runtime benchmark workloads, and writes the merged sparse profile. Every platform's CLI build uses that profile (`SCRIPTC_NATIVE_PROFILE_USE`), and the GitHub release attaches it as `scriptc--cli.profdata.gz`. Code the profile does not describe, or describes from older compiler sources, is optimized as without a profile. When the profile job fails, the CLI builds use the committed profile `packages/compiler/native/cli.profdata.gz`, which local and CI builds of the native compiler also use. Refresh the committed profile when compiler code has changed substantially since the last refresh: run `node scripts/refresh-cli-profile.mjs --tsc-ts ` on a quiet Linux x64 host and commit the new profile on its own. +Release builds of the native CLI optimize the compiler with a profile collected on the release commit. The `cli-profile` job runs `node scripts/refresh-cli-profile.mjs` on Linux x64, which builds an instrumented compiler, runs it on tsc-ts, a slice of the corpus, and the runtime benchmark workloads, and writes the merged profile. The profile keeps the functions that training never ran, with zero counts, so the CLI builds optimize them as cold code and place them after the code that ran. Every platform's CLI build uses that profile (`SCRIPTC_NATIVE_PROFILE_USE`), and the GitHub release attaches it as `scriptc--cli.profdata.gz`. Code the profile does not describe, or describes from older compiler sources, is optimized as without a profile. When the profile job fails, the CLI builds use the committed profile `packages/compiler/native/cli.profdata.gz`, which local and CI builds of the native compiler also use. Refresh the committed profile when compiler code has changed substantially since the last refresh: run `node scripts/refresh-cli-profile.mjs --tsc-ts ` on a quiet Linux x64 host and commit the new profile on its own. -Builds of the native compiler also order its functions by the committed layout `packages/compiler/native/cli.layout.gz`: the functions that `--version` and rebuilds of unchanged programs run come first, then the ones other builds run, ordered by the share of the layout's training builds that run each, so a run maps fewer pages of the executable. Functions the layout does not list keep their usual order. Function names of closures and generated helpers change with nearby edits, so the `cli-profile` job also collects a layout on the release commit (`scripts/refresh-cli-layout.mjs`, reusing the job's instrumented compiler), and the CLI builds use it (`SCRIPTC_NATIVE_LAYOUT`). Refresh the committed layout the same way as the profile, with `node scripts/refresh-cli-layout.mjs`, and commit it on its own. +Builds of the native compiler also order its functions by the committed layout `packages/compiler/native/cli.layout.gz`: the functions that `--version` and rebuilds of unchanged programs run come first, then the ones other builds run, ordered by the share of the layout's training builds that run each, so a run maps fewer pages of the executable. Functions the layout does not list keep their usual order. Function names of closures and generated helpers change with nearby edits, so the `cli-profile` job also collects a layout on the release commit (`scripts/refresh-cli-layout.mjs`), and the CLI builds use it (`SCRIPTC_NATIVE_LAYOUT`). Instrumented builds use the same layout as optimized builds, because a function laid out in a different partition than in the instrumented build no longer matches its profile record. The job therefore collects the layout first and then builds the instrumented compiler for the profile with that layout. Refresh the committed layout the same way, with `node scripts/refresh-cli-layout.mjs`, before the profile, and commit each on its own. Publishing uses npm trusted publishing (OIDC) — there is no npm token secret. Each published package must have a GitHub Actions trusted publisher for diff --git a/docs/content/docs/how-it-works.mdx b/docs/content/docs/how-it-works.mdx index a6520b46e..3747679c4 100644 --- a/docs/content/docs/how-it-works.mdx +++ b/docs/content/docs/how-it-works.mdx @@ -134,8 +134,8 @@ Program objects define `main` and leave selected `scr_*` runtime functions undef ## Native runtime -- **Memory:** Values use reference counting. Acyclic values are freed when their last reference is released, and a cycle collector runs at defined collection points. Some cycles involving opaque native values can remain allocated. When the main thread's script ends, the process exits without freeing what the program still holds, and the operating system reclaims it; worker threads free their heaps when they exit. Builds with `--sanitize`, and processes run with `SCRIPTC_EXIT_FREE=1` (for external leak checkers), free everything at exit. -- **Concurrency:** An `async` function that suspends only at its own `await` expressions compiles to a stackless coroutine whose heap frame holds the values live across those awaits; other suspending `async` functions and async generators use stackful fibers. The native event loop schedules microtasks, timers, and I/O, using kqueue on macOS and epoll on Linux. +- **Memory:** Values use reference counting. Acyclic values are freed when their last reference is released, and a cycle collector runs at defined collection points. When the compiler proves that a group of objects is released together, such as a tree that a recursive function builds and its caller only reads, the group is allocated in an arena and released at once, without freeing each object or collecting its cycles. Some cycles involving opaque native values can remain allocated. When the main thread's script ends, the process exits without freeing what the program still holds, and the operating system reclaims it; worker threads free their heaps when they exit. Builds with `--sanitize`, and processes run with `SCRIPTC_EXIT_FREE=1` (for external leak checkers), free everything at exit. +- **Concurrency:** An `async` function or async generator that suspends only at its own `await` and `yield` expressions compiles to a stackless coroutine whose heap frame holds the values live across them; other suspending `async` functions and async generators use stackful fibers, as do debug builds, libraries, worker programs and Windows executables. The native event loop schedules microtasks, timers, and I/O, using kqueue on macOS and epoll on Linux. - **Networking:** Supported `net`, `http`, `https`, `tls`, `dgram`, and `dns` APIs use native implementations. TLS uses vendored mbedTLS. - **Numbers:** Values use JavaScript's double-precision representation. Number-to-string formatting uses shortest-roundtrip output and is tested against Node.js. - **Regular expressions and Unicode:** The runtime uses QuickJS's regular-expression and Unicode libraries, linked into programs that use them. diff --git a/docs/content/limitations/array-bounds-and-runtime-errors.mdx b/docs/content/limitations/array-bounds-and-runtime-errors.mdx index 7490a1e15..082dd37f3 100644 --- a/docs/content/limitations/array-bounds-and-runtime-errors.mdx +++ b/docs/content/limitations/array-bounds-and-runtime-errors.mdx @@ -11,6 +11,6 @@ Runtime traps cannot be caught. User-thrown values and supported exceptions, including JSON parse, checked-cast, filesystem, regex, and `String.prototype.repeat` count errors, can be caught; hard traps such as out-of-range reads of statically typed ordinary arrays terminate the process. Deep recursion in Linux and macOS executables throws Node's catchable `RangeError: Maximum call stack size exceeded`, on the main thread, in worker threads, and inside async functions and generators. -The depth at which it is thrown differs from Node: the main thread uses the process stack limit (usually 8 MiB), worker threads get an 8 MiB stack, and async functions and generators run on 512 KiB stacks, so native code usually recurses deeper than Node on threads and somewhat less deeply inside async bodies. +The depth at which it is thrown differs from Node: the main thread uses the process stack limit (usually 8 MiB), worker threads get an 8 MiB stack, async functions and generators that keep a fiber run on 512 KiB stacks, and stackless ones run on the stack that resumes them, so native code usually recurses deeper than Node on threads and somewhat less deeply inside fiber bodies. Recursion inside runtime library operations, such as serializing deeply nested data, is not checked. Windows executables, WASI builds and library builds do not check the stack, and exhausting it there terminates the process. diff --git a/native/llvm-codegen/src/emit.cpp b/native/llvm-codegen/src/emit.cpp index c7efef13f..a263ee601 100644 --- a/native/llvm-codegen/src/emit.cpp +++ b/native/llvm-codegen/src/emit.cpp @@ -212,8 +212,8 @@ struct UnnameStructTypes : PassInfoMixin { // How the --profile-use profile matched the functions it annotated, summed // over partitions: functions that received its counts, functions whose // control flow changed since it was collected (hash mismatch), and functions -// it has no record of (new, or never run in training: the merged profile is -// sparse). A partition reused from the cache without its counts is counted +// it has no record of (new since it was collected, or never run in training +// when the profile was merged with --sparse). A partition reused from the cache without its counts is counted // in Unknown. struct ProfileMatch { std::atomic Annotated{0}; diff --git a/packages/compiler/native/cli.layout.gz b/packages/compiler/native/cli.layout.gz index 4ede69568..84a2558b5 100644 Binary files a/packages/compiler/native/cli.layout.gz and b/packages/compiler/native/cli.layout.gz differ diff --git a/packages/compiler/native/cli.profdata.gz b/packages/compiler/native/cli.profdata.gz index aa9398b5f..4480e4ecb 100644 Binary files a/packages/compiler/native/cli.profdata.gz and b/packages/compiler/native/cli.profdata.gz differ diff --git a/packages/compiler/src/backend/llvm/alloc.ts b/packages/compiler/src/backend/llvm/alloc.ts index 2efeb96ac..d227ac005 100644 --- a/packages/compiler/src/backend/llvm/alloc.ts +++ b/packages/compiler/src/backend/llvm/alloc.ts @@ -141,7 +141,7 @@ const CYC_GEN = 3; /** ScrCycHdr.buf, an i32 at this header offset. */ export const CYC_BUF = 4; /** The emitted descriptor `{ trace, free_fn, kind, id }` (ScrCycType). */ -const CYC_TYPE = "{ ptr, ptr, i32, i32 }"; +export const CYC_TYPE = "{ ptr, ptr, i32, i32 }"; function declareState(host: AllocHost): void { if (host.threadLocalAlloc !== true) host.declare(`@scr_sa = external global ${SA_TYPE}`); diff --git a/packages/compiler/src/backend/llvm/arenas.ts b/packages/compiler/src/backend/llvm/arenas.ts new file mode 100644 index 000000000..ee5ea8ab0 --- /dev/null +++ b/packages/compiler/src/backend/llvm/arenas.ts @@ -0,0 +1,437 @@ +/* Group arenas (runtime scr_arena.h): objects that region-groups.ts proves + * die together are bump-allocated in an arena and released at once. + * + * - A root (a fresh-result call or `new` whose value its caller keeps in + * one scope) opens an arena before it is evaluated and closes it after. + * The scope that holds the value drops the arena when it exits, on every + * path. A statement-frame entry closes the arena too, so an exception + * that skips the close never leaves it the allocation target. + * - A call region (a call of a leak-free function that returns no + * reference) opens an arena and drops it as soon as the call returns, + * or when its statement frame unwinds. + * + * region-sites.ts keeps only the roots and call regions whose groups grow, + * and picks the allocations that take the arena: those whose value the + * group keeps. Arena-aware functions are the ones reached from the kept + * roots and call regions. There, a kept `new`, record or array literal + * calls the arena-aware constructor (`.r`, `scr_arr_new*_r`), which + * allocates in the current arena when one is open and on the heap + * otherwise, and a call whose value the group does not keep runs with the + * arena suspended. Every other allocation site stays as it is. + * + * Arenas are off in programs that construct weak collections, in programs + * of more than ARENA_FUNCTION_LIMIT functions, in libraries, and with + * lifetime regions. */ +import type { IrClassDef, IrFunction, IrModule, IrType } from "../../ir/ir.js"; +import { + analyzeClosedGroups, + analyzeRegionGroups, + source, + type RegionGroups, +} from "./region-groups.js"; +import { CYC_BUF, CYC_HDR, CYC_TYPE } from "./alloc.js"; +import { ALLOCATIONS, children, forEachNode, isExpr, type Node } from "./region-ir.js"; +import { planRegionSites, reachableFrom, type RegionSites } from "./region-sites.js"; + +/** The count an arena object starts with (scr_arena.h SCR_ARENA_RC). */ +export const ARENA_RC = 2 ** 29; + +/** Group arenas are on unless SCRIPTC_ARENAS=0 (for comparisons). */ +export function arenasRequested(): boolean { + return process.env["SCRIPTC_ARENAS"] !== "0"; +} + +export interface ArenaPlan { + groups: RegionGroups; + sites: RegionSites; + /** Record shapes and classes that get an arena-aware constructor. */ + shapes: Set; + classes: Set; +} + +export interface ArenaEligibility { + shape(shapeId: string): boolean; + cls(def: IrClassDef): boolean; + /** The reference arm of a union stored as a nullable pointer, or null. */ + nullableArm(unionId: string): IrType | null; + /** Whether values of `t` can be on a reference cycle (the collector + * traces them). */ + traced(t: IrType): boolean; +} + +/** Whether `mod` can construct a WeakMap or WeakSet, directly or through + * a stored global constructor. A weak collection's entry for a key goes + * when the key is freed, and an arena object is never freed on its own, so + * such programs use no arenas. */ +function constructsWeakCollections(mod: IrModule): boolean { + let found = false; + const visit = (node: Node): void => { + if (found) return; + if (node.kind === "libCall") { + if (node.fn === "weakMap.new" || node.fn === "weakSet.new") found = true; + else if (node.fn === "dyn.globalConstructor") { + const name = node.args[0]; + if (name?.kind !== "strLit" || name.value === "WeakMap" || name.value === "WeakSet") + found = true; + } + } + const kids: Node[] = []; + children(node, kids); + for (const k of kids) visit(k); + }; + for (const fn of mod.functions) for (const s of fn.body) visit(s); + return found; +} + +/** Programs with more functions than this use no arenas: the analysis + * visits every function, and on programs this large it costs more compile + * time than their arenas save at run time. */ +const ARENA_FUNCTION_LIMIT = 4000; + +/** The arena plan of `mod`, or null when nothing would use an arena. */ +export function planArenas(mod: IrModule, eligible: ArenaEligibility): ArenaPlan | null { + if (mod.functions.length > ARENA_FUNCTION_LIMIT || constructsWeakCollections(mod)) return null; + const groups = analyzeRegionGroups(mod); + const sites = planRegionSites(mod, groups); + if (groups.roots.size === 0 && groups.calls.size === 0) return null; + const shapes = new Set(); + const classes = new Set(); + const defs = new Map((mod.classes ?? []).map((c) => [c.name, c])); + const note = (node: Node): void => { + const kept = sites.arenaSites.has(node) || groups.roots.has(node); + if ( + kept && + node.kind === "recordLit" && + node.type.kind === "record" && + eligible.shape(node.type.shapeId) + ) + shapes.add(node.type.shapeId); + if (kept && node.kind === "new") { + const def = defs.get(node.className); + if (def !== undefined && eligible.cls(def)) classes.add(node.className); + } + const kids: Node[] = []; + children(node, kids); + for (const k of kids) note(k); + }; + for (const fn of mod.functions) + if (groups.reachable.has(fn.name)) for (const s of fn.body) note(s); + for (const r of groups.roots) if (r.kind === "new") note(r); + // A group's heap objects die when its arena drops, unless heap objects + // hold each other in a cycle: the collector frees those later, and by + // then their references into the arena point at recycled memory. So a + // group keeps its arena only if every cycle-capable object it can + // allocate takes the arena. + const takesArena = (node: Node): boolean => + (sites.arenaSites.has(node) || groups.roots.has(node)) && + ((node.kind === "recordLit" && node.type.kind === "record" && shapes.has(node.type.shapeId)) || + (node.kind === "new" && classes.has(node.className)) || + node.kind === "arrayLit"); + const unsafe = cycleUnsafeFunctions(mod, groups.reachable, sites, takesArena, eligible.traced); + for (const r of [...groups.roots, ...groups.calls]) { + const runs = reachableFrom(mod, [r]); + if ([...runs].some((f) => unsafe.has(f)) || (r.kind === "new" && !takesArena(r))) { + groups.roots.delete(r); + groups.calls.delete(r); + groups.drops.delete(r); + } + } + if (groups.roots.size === 0 && groups.calls.size === 0) return null; + groups.reachable = reachableFrom(mod, [...groups.roots, ...groups.calls]); + const arenaType = (t: IrType): boolean => { + if (t.kind === "object") return classes.has(t.className); + if (t.kind === "record") return shapes.has(t.shapeId); + if (t.kind === "union") { + const arm = eligible.nullableArm(t.unionId); + return arm !== null && arenaType(arm); + } + // Arrays of arena objects: kept array literals of them take the arena. + return t.kind === "array" && t.elem.kind !== "union" && arenaType(t.elem); + }; + const keptSite = (e: Node): boolean => sites.arenaSites.has(e) || groups.roots.has(e); + for (const r of analyzeClosedGroups(mod, groups, arenaType, keptSite)) groups.closed.add(r); + return { groups, sites, shapes, classes }; +} + +/** The functions in `reachable` that can allocate a cycle-capable object + * on the heap while an arena is open: one that does not take the arena + * (`takesArena`), or a call that runs with the arena suspended and reaches + * any cycle-capable allocation. */ +function cycleUnsafeFunctions( + mod: IrModule, + reachable: ReadonlySet, + sites: RegionSites, + takesArena: (node: Node) => boolean, + traced: (t: IrType) => boolean, +): Set { + const fns = new Map(mod.functions.map((f) => [f.name, f])); + const allocatesTraced = (node: Node): boolean => + isExpr(node) && + traced(node.type) && + (node.kind === "closure" + ? node.captures.length > 0 + : (ALLOCATIONS.has(node.kind) || source(node) || node.kind === "libCall") && + node.kind !== "call" && + node.kind !== "callValue"); + // Functions with a cycle-capable allocation, and what they call. + const direct = new Set(); + const calls = new Map>(); + const heap = new Set(); + for (const name of reachable) { + const fn = fns.get(name); + if (fn === undefined) continue; + const out = new Set(); + calls.set(name, out); + forEachNode(fn.body, (node) => { + if (allocatesTraced(node)) { + direct.add(name); + if (!takesArena(node)) heap.add(name); + } + if (node.kind === "call") out.add(node.callee); + else if (node.kind === "new") out.add(`%${node.className}.constructor`); + else if (node.kind === "closure") out.add(node.fnName); + }); + } + const tracedSomewhere = new Set(direct); + for (let changed = true; changed;) { + changed = false; + for (const [name, out] of calls) + if (!tracedSomewhere.has(name) && [...out].some((c) => tracedSomewhere.has(c))) { + tracedSomewhere.add(name); + changed = true; + } + } + for (const name of reachable) { + const fn = fns.get(name); + if (fn === undefined || heap.has(name)) continue; + forEachNode(fn.body, (node) => { + if (!sites.heapCalls.has(node)) return; + const target = + node.kind === "call" + ? node.callee + : node.kind === "new" + ? `%${node.className}.constructor` + : null; + // A suspended call of an unknown function (a closure, a virtual call) + // may allocate anything. + if (target === null || tracedSomewhere.has(target)) heap.add(name); + }); + } + return heap; +} + +/** The arena-aware constructor of a record shape or class. */ +export function arenaNewSym(newSym: string): string { + return `${newSym}.r`; +} + +/** The function that releases an arena object's fields at the drop. */ +export function arenaDisposeSym(newSym: string): string { + return `${newSym}.dispose`; +} + +/** Whether `fn` allocates through arena-aware constructors. */ +export function arenaAware(plan: ArenaPlan | null, fn: IrFunction | undefined): boolean { + return plan !== null && fn !== undefined && plan.groups.reachable.has(fn.name); +} + +/** What arenaNewLines needs from its host (a subset of ShapeHost). */ +export interface ArenaHost { + declare(decl: string): void; + threadLocalAlloc?: boolean; + inlineAlloc?: boolean; + /** The target C ABI's `size_t`: also the pointer width. */ + readonly sizeType: "i32" | "i64"; +} + +/** Instructions that set `dest` to the address of the calling thread's + * open arena pointer (scr_arena.c's scr_arena_cur), or null on targets + * without the inline allocator, which call scr_arena_current, + * scr_arena_suspend and scr_arena_resume instead. In worker programs the + * pointer is thread-local. */ +export function arenaCurrentAddress(host: ArenaHost, dest: string): string[] | null { + if (host.inlineAlloc !== true) return null; + if (host.threadLocalAlloc !== true) { + host.declare(`@scr_arena_cur = external global ptr`); + return [`${dest} = getelementptr inbounds i8, ptr @scr_arena_cur, i64 0`]; + } + host.declare(`@scr_arena_cur = external thread_local global ptr`); + host.declare(`declare nonnull ptr @llvm.threadlocal.address.p0(ptr nonnull)`); + return [`${dest} = call ptr @llvm.threadlocal.address.p0(ptr @scr_arena_cur)`]; +} + +/** + * The arena-aware constructor `.r`: an object of `size` bytes (a + * `size_t` constant expression) in the current arena, behind the collector header + * `cyc` names (or none), which `init` sets up the way the heap constructor + * does (with the count ARENA_RC). When `dispose` names a function and the + * arena is not closed, the object goes on the arena's dispose list, which + * the drop runs. The heap constructor's object when no arena is open or + * the arena is full. The bump, the clear, the header and the dispose entry + * are inline (scr_arena.c's ScrArena ABI); scr_arena_obj takes a new chunk + * and scr_arena_disposal grows the list. + */ +export function arenaNewLines( + host: ArenaHost, + newSym: string, + size: string, + cyc: string | null, + dispose: string | null, + init: string[], + result: string, +): string[] { + const S = host.sizeType; + // ScrArena's fields and dispose entries are pointer-sized (scr_arena.c). + const word = S === "i32" ? 4 : 8; + host.declare(`declare ptr @scr_arena_obj(ptr, ${S}, ptr)`); + host.declare(`declare void @llvm.memset.p0.${S}(ptr, i8, ${S}, i1)`); + const cur = arenaCurrentAddress(host, "%cur"); + const current = + cur === null + ? (host.declare(`declare ptr @scr_arena_current()`), [` %a = call ptr @scr_arena_current()`]) + : [...cur.map((line) => ` ${line}`), ` %a = load ptr, ptr %cur`]; + const lines: string[] = [ + `define internal ptr @${arenaNewSym(newSym)}() nounwind alwaysinline { ; in the open arena`, + `entry:`, + ...current, + ` %on = icmp ne ptr %a, null`, + ` br i1 %on, label %arena, label %heap`, + `arena:`, + ]; + // A traced type's first object registers its descriptor on the slow path. + if (cyc !== null) + lines.push( + ` %tidp = getelementptr inbounds ${CYC_TYPE}, ptr ${cyc}, i32 0, i32 3`, + ` %tid = load atomic i32, ptr %tidp acquire, align 4`, + ` %creg = icmp ne i32 %tid, 0`, + ); + const hdr = cyc === null ? 0 : CYC_HDR; + lines.push( + ` %n0 = add ${S} ${size}, ${hdr + 7}`, + ` %n = and ${S} %n0, -8`, + ` %bp = load ptr, ptr %a`, + ` %limp = getelementptr inbounds i8, ptr %a, ${S} ${word}`, + ` %lim = load ptr, ptr %limp`, + ` %bpi = ptrtoint ptr %bp to ${S}`, + ` %limi = ptrtoint ptr %lim to ${S}`, + ` %room = sub ${S} %limi, %bpi`, + ` %fits0 = icmp ule ${S} %n, %room`, + ); + if (cyc !== null) lines.push(` %fits = and i1 %fits0, %creg`); + lines.push( + ` br i1 ${cyc === null ? "%fits0" : "%fits"}, label %carve, label %slow`, + `carve:`, + ` %end = getelementptr inbounds i8, ptr %bp, ${S} %n`, + ` store ptr %end, ptr %a`, + ` call void @llvm.memset.p0.${S}(ptr align 8 %bp, i8 0, ${S} %n, i1 false)`, + ); + if (cyc !== null) + lines.push( + ` %id = trunc i32 %tid to i16`, + ` store i16 %id, ptr %bp ; ScrCycHdr.id`, + ` %bufp = getelementptr inbounds i8, ptr %bp, i64 ${CYC_BUF}`, + ` store i32 -1, ptr %bufp ; ScrCycHdr.buf = SCR_CYC_ACYCLIC`, + ); + lines.push( + ` %of = getelementptr inbounds i8, ptr %bp, ${S} ${hdr}`, + ` br label %init`, + `slow:`, + ` %os = call ptr @scr_arena_obj(ptr %a, ${S} ${size}, ptr ${cyc ?? "null"})`, + ` %got = icmp ne ptr %os, null`, + ` br i1 %got, label %init, label %heap`, + `init:`, + ` %o = phi ptr [ %of, %carve ], [ %os, %slow ]`, + ...init, + ); + if (dispose !== null) { + host.declare(`declare void @scr_arena_disposal(ptr, ptr, ptr)`); + lines.push( + ` %closedp = getelementptr inbounds i8, ptr %a, ${S} ${4 * word} ; ScrArena.closed`, + ` %closed = load i32, ptr %closedp`, + ` %skip = icmp ne i32 %closed, 0`, + ` br i1 %skip, label %done, label %list`, + `list:`, + ` %dpp = getelementptr inbounds i8, ptr %a, ${S} ${2 * word} ; ScrArena.disposals`, + ` %dp = load ptr, ptr %dpp`, + ` %dep = getelementptr inbounds i8, ptr %a, ${S} ${3 * word} ; ScrArena.disposals_end`, + ` %de = load ptr, ptr %dep`, + ` %full = icmp eq ptr %dp, %de`, + ` br i1 %full, label %grow, label %push`, + `push:`, + ` store ptr %o, ptr %dp ; ScrArenaDisposal.obj`, + ` %dfp = getelementptr inbounds i8, ptr %dp, ${S} ${word}`, + ` store ptr @${dispose}, ptr %dfp ; ScrArenaDisposal.dispose`, + ` %dn = getelementptr inbounds i8, ptr %dp, ${S} ${2 * word}`, + ` store ptr %dn, ptr %dpp`, + ` br label %done`, + `grow:`, + ` call void @scr_arena_disposal(ptr %a, ptr %o, ptr @${dispose})`, + ` br label %done`, + `done:`, + ); + } + lines.push( + ` ret ptr ${result}`, + `heap:`, + ` %h = call ptr @${newSym}()`, + ` ret ptr %h`, + `}`, + ``, + ); + return lines; +} + +/** Dispose lines for field `i` of `%o` at `ptr` (a getelementptr into + * `%o`), released by `release`: a plain reference (no union encoding) is + * released only when it is a heap object, since an arena or immortal + * object's count (the rc word's low half) is at least 2^28 and its release + * would change nothing. */ +export function disposeFieldLines( + i: number, + gep: string, + plain: boolean, + release: string, + comment: string, +): string[] { + const load = [` %f${i} = ${gep}`, ` %v${i} = load ptr, ptr %f${i}`]; + if (!plain) return [...load, ` call void ${release}(ptr %v${i}) ; ${comment}`]; + return [ + ...load, + ` %nn${i} = icmp ne ptr %v${i}, null`, + ` br i1 %nn${i}, label %chk${i}, label %next${i}`, + `chk${i}:`, + ` %rc${i} = load i32, ptr %v${i}`, + ` %heap${i} = icmp ult i32 %rc${i}, ${2 ** 28}`, + ` br i1 %heap${i}, label %rel${i}, label %next${i}`, + `rel${i}:`, + ` call void ${release}(ptr %v${i}) ; ${comment}`, + ` br label %next${i}`, + `next${i}:`, + ]; +} + +/** Whether values of `t` are plain pointers to counted objects. */ +export function plainRefType(t: IrType): boolean { + return ( + t.kind === "object" || + t.kind === "record" || + t.kind === "array" || + t.kind === "map" || + t.kind === "set" || + t.kind === "func" || + t.kind === "string" + ); +} + +/** The dispose function: releases each reference field (`fields`, lines + * that load `%v` and release it) of the object `%o`. */ +export function arenaDisposeLines(newSym: string, fields: string[]): string[] { + return [ + `define internal void @${arenaDisposeSym(newSym)}(ptr %o) nounwind { ; arena drop`, + `entry:`, + ...fields, + ` ret void`, + `}`, + ``, + ]; +} diff --git a/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts b/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts index 742d256b7..76f229215 100644 --- a/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts +++ b/packages/compiler/src/backend/llvm/bytes-loop-versions.test.ts @@ -222,6 +222,55 @@ test("negative factors, rebinding, captures and outer loops are not versioned", expect(match(fn([outer]), inner)).not.toBeNull(); }); +test("loops with nested loops version only for accesses inside them", () => { + // for (let i = 0; i < 64; i++) { + // let t = 0; + // for (let j = 0; j < n; j++) t = t + src[j]; + // out[i] = t; + // } + const sum = (): IrStmt[] => [ + { kind: "varDecl", localId: "t", init: num(0), loc }, + counted( + [ + { + kind: "assign", + localId: "t", + value: bin("+", ref("t"), get(ref("src", f64s), ref("j"))), + loc, + }, + ], + ref("n"), + "j", + ), + ]; + const extra = [local("t", F64, true)]; + // The guard would prove only `out[i]`, which runs once per row. + const rows = counted([...sum(), set(ref("out", f64s), ref("i"), ref("t"))], num(64)); + expect(match(fn([rows], extra), rows)).toBeNull(); + expect(match(fn([rows], extra), rows.body[1] as IrStmt & { kind: "for" })).not.toBeNull(); + // The guard proves `out[i]` inside a nested loop too. + const inside = counted( + [...sum(), counted([set(ref("out", f64s), ref("i"), ref("t"))], num(2), "j")], + num(64), + ); + expect(match(fn([inside], extra), inside)?.bounds.size).toBe(1); + // A loop whose nested loops access no typed array versions for its own + // accesses. + const scalar = counted( + [ + { kind: "varDecl", localId: "t", init: num(0), loc }, + counted( + [{ kind: "assign", localId: "t", value: bin("+", ref("t"), ref("j")), loc }], + ref("n"), + "j", + ), + set(ref("out", f64s), ref("i"), ref("t")), + ], + num(64), + ); + expect(match(fn([scalar], extra), scalar)?.bounds.size).toBe(1); +}); + test("emitted loops version the body and tag typed-array memory", () => { const loop = counted([ set(ref("out", f64s), ref("i"), bin("*", get(ref("src", f64s), ref("i")), num(2))), diff --git a/packages/compiler/src/backend/llvm/bytes-loop-versions.ts b/packages/compiler/src/backend/llvm/bytes-loop-versions.ts index ccf217f9f..d0bef7fd6 100644 --- a/packages/compiler/src/backend/llvm/bytes-loop-versions.ts +++ b/packages/compiler/src/backend/llvm/bytes-loop-versions.ts @@ -196,9 +196,10 @@ const INVARIANT_OPS = new Set(["+", "-", "*", "&", "|", "^", "<<", ">>", ">>>"]) * and writes thread-owned views directly. * * Only small synchronous loops are versioned (nested loops included), and - * the emitter bounds how deeply versions nest. The checked loop runs - * whenever the guard fails, and keeps every out-of-bounds read, ignored - * write and NaN-index behavior. */ + * the emitter bounds how deeply versions nest. A loop whose nested loops + * access typed arrays versions only for an access inside them. The checked + * loop runs whenever the guard fails, and keeps every out-of-bounds read, + * ignored write and NaN-index behavior. */ export function matchBytesLoopVersion( loop: IrStmt & { kind: "for" | "while" }, counted: IntegerCountedForLoop | null, @@ -492,7 +493,15 @@ export function matchBytesLoopVersion( const pairedCounters = paired !== null && counted === null ? new Set([paired.low.localId, paired.high.localId]) : null; const pairedNodes = new Map(); + // Accesses inside the loop's nested loops, and inside its nested while + // loops. + const nested = new Set(); + const nestedWhile = new Set(); + let loops = 0, + whiles = 0; const access = (node: Node, array: IrExpr, index: IrExpr, width: number): void => { + if (loops > 0) nested.add(node); + if (whiles > 0) nestedWhile.add(node); const ref = root(array); if (!ref) return; if (facts.workers && ref.type.kind === "bytes" && !facts.owned.has(node)) { @@ -603,7 +612,15 @@ export function matchBytesLoopVersion( access(s, s.arr, s.index, 1); scope(s, s.arr, true); } - return everyStmtChild(s, visitExpr, visitStmt); + const isLoop = + s.kind === "for" || s.kind === "forOf" || s.kind === "while" || s.kind === "doWhile"; + const isWhile = s.kind === "while" || s.kind === "doWhile"; + if (isLoop) loops++; + if (isWhile) whiles++; + const result = everyStmtChild(s, visitExpr, visitStmt); + if (isLoop) loops--; + if (isWhile) whiles--; + return result; }; loop.body.every(visitStmt); if (loop.kind === "while") visitExpr(loop.cond); @@ -714,6 +731,18 @@ export function matchBytesLoopVersion( used.add(receiver); for (const node of nodes) bounds.add(node); } + // A loop whose nested loops access typed arrays versions only when the + // guard proves the bounds of one of those accesses (or, in a worker + // program, the ownership of one inside a nested while loop, which never + // versions for ownership alone). Otherwise the guard covers accesses that + // run once per pass, while each copy of the body repeats every nested + // loop and each nested loop's own version. + if ( + nested.size > 0 && + ![...bounds].some((node) => nested.has(node)) && + ![...ownedNodes].some((node) => nestedWhile.has(node)) + ) + return null; const counterChecks: CounterCheck[] = []; for (const [id, min] of counterMins) { const info = whileAccesses!.counters.get(id)!; diff --git a/packages/compiler/src/backend/llvm/classes.ts b/packages/compiler/src/backend/llvm/classes.ts index 5941f5d5c..30265c66c 100644 --- a/packages/compiler/src/backend/llvm/classes.ts +++ b/packages/compiler/src/backend/llvm/classes.ts @@ -1,3 +1,11 @@ +import { + ARENA_RC, + arenaDisposeLines, + arenaDisposeSym, + arenaNewLines, + disposeFieldLines, + plainRefType, +} from "./arenas.js"; import { InternalCompilerError } from "../../errors.js"; /** Class layouts, reference counting, hierarchy intervals, virtual tables and * constructor values. Opaque LLVM pointers allow virtual slots to store method @@ -1415,6 +1423,8 @@ export function emitClassShapes( storedHeaderBytes(meta), ), ); + // The header and field set-up, shared with the arena-aware constructor. + const initStart = nw.length; if (meta.compact && !meta.hierarchy) nw.push(` store i32 ${compactHeaderWord(meta)}, ptr %o ; no count (compactHeaderWord)`); else if (meta.compact) { @@ -1483,6 +1493,7 @@ export function emitClassShapes( // Young instances: program-owned traced classes. Classes rooted at a // runtime base (errors, emitters, events, streams) carry runtime state // and stay with trial deletion, like every runtime family. + const init = nw.slice(initStart); if ( traced && !lifetime && @@ -1492,6 +1503,52 @@ export function emitClassShapes( nw.push(...youngEntryLines(host)); nw.push(` ret ptr ${meta.hierarchy ? "%o.vt" : "%o"}`, `}`, ``); defs.push(...nw); + if ( + host.arenaClasses?.has(cls.name) === true && + cls.arrayElem === undefined && + !lifetime && + regions === null && + !meta.compact && + runtimePrefix === null && + !isStreamRooted && + !meta.threadSlot + ) { + // The arena count in place of 1, in the rc word or its low half. + const counted = meta.idInRc + ? ` store i64 ${classIdRcWord(meta, false)}, ptr %o.vt` + : ` store ${host.sizeType} 1, ptr %o`; + const arenaCount = meta.idInRc + ? ` store i64 ${(BigInt(meta.pre + 1) * 2n ** 32n + BigInt(ARENA_RC)).toString()}, ptr %o.vt` + : ` store ${host.sizeType} ${ARENA_RC}, ptr %o`; + const newSym = mangleClassNew(cls.name); + const dispose = refFields.length > 0 ? arenaDisposeSym(newSym) : null; + defs.push( + ...arenaNewLines( + host, + newSym, + sizeOf, + traced ? cycType : null, + dispose, + init.map((line) => (line.startsWith(counted) ? arenaCount : line)), + meta.hierarchy ? "%o.vt" : "%o", + ), + ); + if (dispose !== null) + defs.push( + ...arenaDisposeLines( + newSym, + refFields.flatMap((f, t) => + disposeFieldLines( + t, + `getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${f.index}`, + plainRefType(f.type), + releaseSym(host, f.type), + llvmCommentText(f.name), + ), + ), + ), + ); + } if (traced) { // trace: visit exactly the cycle-capable fields; gcFree: release diff --git a/packages/compiler/src/backend/llvm/dense-array-access.ts b/packages/compiler/src/backend/llvm/dense-array-access.ts index fabe70f37..b1e65a23f 100644 --- a/packages/compiler/src/backend/llvm/dense-array-access.ts +++ b/packages/compiler/src/backend/llvm/dense-array-access.ts @@ -19,6 +19,7 @@ * length, so reads never reach the state check for it; a write past the * bound tests the flag and, without states, appends at the length. */ +import { InternalCompilerError } from "../../errors.js"; import { isRefCounted, type IrExpr, type IrType } from "../../ir/ir.js"; import { f64Lit, @@ -371,7 +372,9 @@ export function emitDenseArrayGet( /** emitDenseArrayGet, with the int32 of an int32 array's element: the * slot itself on the dense path, the runtime getter's double converted - * (exactly: the element is an int32) on the other. */ + * (exactly: the element is an int32) on the other. With `inexact`, the + * getter's double may be something other than an int32 (NaN from + * `scr_arr_get_number`): the read branches there when it is. */ export function emitDenseArrayRead( host: LlvmEmitterContext, array: string, @@ -382,6 +385,7 @@ export function emitDenseArrayRead( getter: string, retain: boolean, proven = false, + inexact?: string, ): { value: string; int32: string | null } { const B = host.B; const ty = valueType(acc); @@ -463,22 +467,68 @@ export function emitDenseArrayRead( : READ_FALLBACK; B.line(`${slowValue} = call ${ty} @${getter}(ptr ${array}, double ${index.name})${attrs}`); let slowInt: string | null = null; - if (fastInt !== null) { + let slowLabel = slow; + if (fastInt !== null && inexact !== undefined) { + // An int32 that is not -0, or the inexact branch. + host.declare("declare i32 @llvm.fptosi.sat.i32.f64(double)"); + slowInt = B.tmp(); + const back = B.tmp(), + exact = B.tmp(), + bits = B.tmp(), + notNegativeZero = B.tmp(), + ok = B.tmp(); + B.line(`${slowInt} = call i32 @llvm.fptosi.sat.i32.f64(double ${slowValue})`); + B.line(`${back} = sitofp i32 ${slowInt} to double`); + B.line(`${exact} = fcmp oeq double ${back}, ${slowValue}`); + B.line(`${bits} = bitcast double ${slowValue} to i64`); + B.line(`${notNegativeZero} = icmp ne i64 ${bits}, -9223372036854775808`); + B.line(`${ok} = and i1 ${exact}, ${notNegativeZero}`); + slowLabel = B.newLabel("arr.get.int32"); + B.condBr(ok, slowLabel, inexact); + B.startBlock(slowLabel); + } else if (fastInt !== null) { slowInt = B.tmp(); B.line(`${slowInt} = fptosi double ${slowValue} to i32`); } B.br(join); B.startBlock(join); const result = B.tmp(); - B.line(`${result} = phi ${ty} [ ${fast}, %${fastLabel} ], [ ${slowValue}, %${slow} ]`); + B.line(`${result} = phi ${ty} [ ${fast}, %${fastLabel} ], [ ${slowValue}, %${slowLabel} ]`); let int32: string | null = null; if (fastInt !== null && slowInt !== null) { int32 = B.tmp(); - B.line(`${int32} = phi i32 [ ${fastInt}, %${fastLabel} ], [ ${slowInt}, %${slow} ]`); + B.line(`${int32} = phi i32 [ ${fastInt}, %${fastLabel} ], [ ${slowInt}, %${slowLabel} ]`); } return { value: result, int32 }; } +/** emitDenseArrayNumber of an int32 array, with the element's int32: a + * read that answers NaN (or another value that is not an int32) branches + * to `inexact`. */ +export function emitDenseArrayNumberInt32( + host: LlvmEmitterContext, + array: string, + index: LlValue, + indexExpr: IrExpr | undefined, + proven: boolean, + inexact: string, +): { value: string; int32: string } { + const read = emitDenseArrayRead( + host, + array, + index, + indexExpr, + "i32", + { kind: "f64" }, + "scr_arr_get_number", + false, + proven, + inexact, + ); + if (read.int32 === null) throw new InternalCompilerError("llvm emitter bug: int32 read"); + return { value: read.value, int32: read.int32 }; +} + /** `scr_arr_get_number`: a numeric element, or NaN for every missing, * hole, or present-undefined slot (answered by the runtime). */ export function emitDenseArrayNumber( diff --git a/packages/compiler/src/backend/llvm/emit-function.ts b/packages/compiler/src/backend/llvm/emit-function.ts index 24e1d06a3..3be3d979f 100644 --- a/packages/compiler/src/backend/llvm/emit-function.ts +++ b/packages/compiler/src/backend/llvm/emit-function.ts @@ -338,6 +338,7 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string { } emitter.currentCoro = { kind: fn.generator !== undefined ? "generator" : "async", + awaits: fn.async === true, id, handle, self, @@ -525,6 +526,7 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string { } } emitter.scopes.push(fnScope); + emitter.beginArenaFunction(fn); // Stackful native fibers and native coroutine frames retain these frames // while suspended. The active exception context owns the chain, so // concurrent fibers isolate it. @@ -862,10 +864,14 @@ export function emitGenScaffolding(emitter: LlEmitter): string[] { ]; const genNew = coroBody && !emitter.wasi ? "scr_gen_new_stackless" : "scr_gen_new"; if (genNew !== "scr_gen_new") emitter.declare(`declare ptr @${genNew}(ptr, ptr, ptr)`); + const asyncGenNew = + coroBody && !emitter.wasi ? "scr_async_gen_new_stackless" : "scr_async_gen_new"; + if (settleAsync !== null && asyncGenNew !== "scr_async_gen_new") + emitter.declare(`declare ptr @${asyncGenNew}(ptr, ptr, ptr, ptr)`); sp.push( settleAsync === null ? ` %gg = call ptr @${genNew}(ptr @${mangleTrampoline(fn.name)}, ptr %ap, ptr @${mangleGenDrop(fn.name)})` - : ` %gg = call ptr @scr_async_gen_new(ptr @${mangleTrampoline(fn.name)}, ptr %ap, ptr @${mangleGenDrop(fn.name)}, ptr @${settleAsync})`, + : ` %gg = call ptr @${asyncGenNew}(ptr @${mangleTrampoline(fn.name)}, ptr %ap, ptr @${mangleGenDrop(fn.name)}, ptr @${settleAsync})`, ...(fn.generator.tag !== undefined ? [` call void @scr_gen_set_tag(ptr %gg, ptr ${emitter.cstr(fn.generator.tag)})`] : []), diff --git a/packages/compiler/src/backend/llvm/emit-statements.ts b/packages/compiler/src/backend/llvm/emit-statements.ts index 1ea18d8e1..1eeceff4a 100644 --- a/packages/compiler/src/backend/llvm/emit-statements.ts +++ b/packages/compiler/src/backend/llvm/emit-statements.ts @@ -105,8 +105,11 @@ const MAX_CALLBACK_VERSION_COST = 400; export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { const B = emitter.B; emitter.frames.push([]); + if (emitter.arenas !== null) + emitter.statementFrames.add(emitter.frames[emitter.frames.length - 1]!); switch (s.kind) { case "varDecl": { + emitter.ownArenasOf(s.localId); if (s.init && emitter.int32Locals.has(s.localId)) { emitter.storeInt32Local(s.localId, emitter.emitExpr(s.init), s.init); break; @@ -1670,7 +1673,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void { B.line(`call void @scr_async_gen_return_awaits()`); } if (emitter.currentAsyncGenerator && s.awaited !== true && emitter.finallyStack.length > 0) { - if (emitter.wasi) emitter.emitWasiSuspend(null); + if (emitter.coroAwait) emitter.emitCoroAwait(null); else { emitter.declare(`declare void @scr_await_hop()`); B.line(`call void @scr_await_hop()`); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 4e6f8af0b..8ece55841 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -343,6 +343,15 @@ import { type LlClassMeta, } from "./classes.js"; import { LifetimeRegions, REGION_TYPES } from "./regions.js"; +import { + arenaAware, + arenaCurrentAddress, + arenaNewSym, + arenasRequested, + planArenas, + type ArenaPlan, +} from "./arenas.js"; +import { children, type Node } from "./region-ir.js"; import { LlDyn, recordDynKeepsIdentity, type DynHost } from "./dyn.js"; import { VirtualBorrows } from "./virtual-borrows.js"; import { LlvmUnsupportedError } from "./unsupported.js"; @@ -375,6 +384,7 @@ import { retainSym, traceArg, vAdapters, + traceAdapter, } from "./shapes.js"; import type { ExprOf, LibCallExpr, LlStreamTypedRefAdapter, LlValue } from "./expr-context.js"; import { INLINE_LITERAL_BYTES } from "./string-literal-equality.js"; @@ -421,6 +431,7 @@ import { fieldAliasMetadata, isStackUnionSource, } from "./receivers.js"; +import { findLoopCallees } from "./loop-callees.js"; export { LlvmUnsupportedError } from "./unsupported.js"; @@ -465,6 +476,9 @@ export interface LlScopeEntry { slot: string; type: IrType; boxed?: boolean; + /** A group arena list slot (arenas.ts), dropped after the scope's other + * entries. */ + arena?: boolean; /** A lazily created box: the slot stays null on paths that build no * environment, which skip the release call. */ lazy?: boolean; @@ -845,16 +859,23 @@ function isIdentifierByte(code: number): boolean { } /** Runtime entry points (and await-call entries) that may park the running - * execution context, as they appear in emitted function text. */ + * execution context, as they appear in emitted function text. An async + * generator's next, return and throw only queue a request and run its body + * to its next suspension, which returns to the caller, and the body's own + * bookkeeping calls do not suspend it. */ export const SUSPENDS = - /@(?:scr_(?:await_|module_await\b|promise_await_settled\b|test_sub\b|gen_yield_|async_gen_|fiber_)|sc_a[wd]_)/; + /@(?:scr_(?:await_|module_await\b|promise_await_settled\b|test_sub\b|gen_yield_|async_gen_(?!new\b|next_|return_|throw\b|hop_done\b)|fiber_)|sc_a[wd]_)/; /** The suspensions of a fiber body's own awaits, which its stackless * coroutine form suspends itself at instead. */ -const AWAIT_SUSPENSIONS = /@(?:scr_await_(?:void|f64|bool|str|ref|hop)\b|sc_a[wd]_[A-Za-z0-9_]+)/g; +const AWAIT_SUSPENSIONS = + /@(?:scr_await_(?:void|f64|bool|str|ref|hop|dyn_value)\b|sc_a[wd]_[A-Za-z0-9_]+)/g; +/** The suspensions of a fiber generator body's own yields, which its + * coroutine form also suspends itself at. */ +const YIELD_SUSPENSIONS = /@scr_(?:gen_yield_(?:f64|bool|ref))\b/g; /** SUSPENDS in a stackless coroutine body, whose frame bookkeeping reads its * own fiber and promise. */ const COROUTINE_SUSPENDS = - /@(?:scr_(?:await_|module_await\b|promise_await_settled\b|test_sub\b|gen_yield_|async_gen_|fiber_(?!self\b|promise\b))|sc_a[wd]_)/; + /@(?:scr_(?:await_|module_await\b|promise_await_settled\b|test_sub\b|gen_yield_|async_gen_(?!new\b|next_|return_|throw\b|hop_done\b)|fiber_(?!self\b|promise\b))|sc_a[wd]_)/; /** Captured bindings (boxes a body declares or receives) from which a * function compiles without LLVM optimization (see boxSlotCount). */ export const UNOPTIMIZED_BOX_SLOTS = 512; @@ -1276,11 +1297,13 @@ export class LlEmitter { * generator stacks) and so do debug builds (the debugger reads locals * from the native frame). */ private stacklessGenerators = false; - /** Async bodies that suspend only at their own awaits compile to the same - * switched coroutines on native targets (selectStacklessAsync), under the - * generators' conditions; library and Windows builds keep fibers. */ + /** Async bodies that suspend only at their own awaits (and an async + * generator's yields) compile to the same switched coroutines on native + * targets (selectStacklessAsync), under the generators' conditions; + * library and Windows builds keep fibers. */ private stacklessAsyncAllowed = false; - /** The async functions selectStacklessAsync lowered as coroutines. */ + /** The async functions and async generators selectStacklessAsync lowered + * as coroutines. */ readonly stacklessAsync = new Set(); /** Some function in the module is lowered as a coroutine. */ usesCoroutines = false; @@ -1489,6 +1512,9 @@ export class LlEmitter { /** Functions that check the native stack guard on entry * (stackCheckedFunctions); null where the runtime has no guard. */ readonly stackChecks: ReadonlySet | null; + /** Small leaf functions called only from loops, whose direct calls are + * `alwaysinline` (loop-callees.ts). */ + readonly loopCallees: ReadonlySet; /** Static types whose values can be published (@scriptc/threads): their * stores carry the frozen-object guard. Null without publish calls. */ readonly publishedTypes: PublishedTypes | null; @@ -1903,9 +1929,12 @@ export class LlEmitter { currentUnionResult = false; /** Active only while emitting a body lowered with LLVM's * switched-coroutine intrinsics (wasm32 async bodies and generators, - * native synchronous generators; see coroLowered). */ + * native synchronous generators, and the native async bodies + * selectStacklessAsync chose; see coroLowered). */ currentCoro: { kind: "async" | "generator"; + /** The body awaits: an async function or an async generator. */ + awaits: boolean; id: string; handle: string; self: string; @@ -2076,6 +2105,7 @@ export class LlEmitter { : new Set(); this.workerEntryPolls = mt.workerEntryPolls ?? null; this.stackChecks = mt.stackChecks ?? null; + this.loopCallees = findLoopCallees(mod); // A stackless call's eager prefix borrows its caller's stack; the // runtime moves deep prefix nests to a fresh stack using the guard, // which Windows lacks. @@ -2412,6 +2442,40 @@ export class LlEmitter { mod.lib === undefined && options.lowRegion === true && [...this.classMeta.values()].some((m) => m.compact); + // Group arenas: off with lifetime regions, which own their classes' + // allocation, and in libraries, whose exports any host code calls. + if (arenasRequested() && mod.lifetimeRegions !== true && mod.lib === undefined) { + this.arenas = planArenas(mod, { + shape: (id) => { + const shape = this.recordsById.get(id); + return ( + shape !== undefined && + shape.freezable !== true && + shape.indexValue === undefined && + this.publishedTypes?.recordGuarded(id) !== true + ); + }, + cls: (def) => { + const meta = this.classMeta.get(def.name); + return ( + meta !== undefined && + !meta.compact && + !meta.threadSlot && + // An Array subclass's instance is an array (the runtime's header). + def.arrayElem === undefined && + meta.root.def.arrayElem === undefined && + !this.programLifetime.has(def.name) && + this.publishedTypes?.classGuarded(def.name) !== true + ); + }, + nullableArm: (unionId) => this.nullableUnions.single(unionId)?.arm ?? null, + traced: (t) => traceAdapter(this.shapeHost, t) !== null, + }); + if (this.arenas !== null) { + this.shapeHost.arenaShapes = this.arenas.shapes; + this.shapeHost.arenaClasses = this.arenas.classes; + } + } for (const [name, rec] of RUNTIME_ERROR_CLASSES) { const meta = this.classMeta.get(name); if (!meta) break; // hand-written IR without the builtin defs: no stamps @@ -2713,19 +2777,21 @@ export class LlEmitter { } } - /** Chooses the native async functions whose bodies compile to switched - * coroutines instead of running on a fiber: a suspended call then keeps a - * heap frame of the values live across its awaits rather than a fiber and - * its stack. A body qualifies when its only suspension points are its own - * awaits, which the coroutine form suspends itself at (emitCoroAwait), so - * nothing it runs can park the stack it borrows. It is first emitted as - * usual: that text may suspend only through await runtime calls and - * await-call entries, which the coroutine form replaces, and may call no - * function that parks. The coroutine form is then emitted and must pass - * the same test with no exceptions (its frame bookkeeping excepted); - * otherwise the fiber form stays. Bodies that never suspend stay - * fiberless, and module evaluation bodies and `dyn` results keep fibers - * as they do there. The runtime traps if a stackless body ever parks. */ + /** Chooses the native async functions and async generators whose bodies + * compile to switched coroutines instead of running on a fiber: a + * suspended call then keeps a heap frame of the values live across its + * awaits and yields rather than a fiber and its stack. A body qualifies + * when its only suspension points are its own awaits and yields, which + * the coroutine form suspends itself at (emitCoroAwait, + * emitCoroSuspendPrepared), so nothing it runs can park the stack it + * borrows. It is first emitted as usual: that text may suspend only + * through await and yield runtime calls and await-call entries, which the + * coroutine form replaces, and may call no function that parks. The + * coroutine form is then emitted and must pass the same test with no + * exceptions (its frame bookkeeping excepted); otherwise the fiber form + * stays. Bodies that never suspend stay fiberless, and module evaluation + * bodies and async functions with `dyn` results keep fibers as they do + * there. The runtime traps if a stackless body ever parks. */ selectStacklessAsync(fnDefs: string[]): void { if (!this.stacklessAsyncAllowed) return; const parks = this.parkingFunctions(); @@ -2740,11 +2806,16 @@ export class LlEmitter { return true; }; this.mod.functions.forEach((fn, index) => { - if (fn.async !== true || fn.generator !== undefined) return; + if (fn.async !== true) return; if (fn.asyncCacheGlobal !== undefined || fn.asyncCycleCacheGlobal !== undefined) return; - if (fn.returnType.kind === "dyn" || !parks.has(fn.name)) return; - const fiberText = fnDefs[index]!; - if (!qualifies(fn, fiberText.replace(AWAIT_SUSPENSIONS, ""), SUSPENDS)) return; + // A dyn result may adopt a thenable when it settles the promise; an + // async generator's completion value settles its request's record. + if (fn.generator === undefined && fn.returnType.kind === "dyn") return; + if (!parks.has(fn.name)) return; + // An async generator's body also suspends at its own yields. + let fiberText = fnDefs[index]!.replace(AWAIT_SUSPENSIONS, ""); + if (fn.generator !== undefined) fiberText = fiberText.replace(YIELD_SUSPENSIONS, ""); + if (!qualifies(fn, fiberText, SUSPENDS)) return; this.stacklessAsync.add(fn.name); const coroText = this.emitFunction(fn); if (!qualifies(fn, coroText, COROUTINE_SUSPENDS)) { @@ -3157,7 +3228,7 @@ export class LlEmitter { * native async bodies selectStacklessAsync chose. */ coroLowered(fn: IrFunction): boolean { if (this.wasi) return fn.async === true || fn.generator !== undefined; - if (fn.async === true) return fn.generator === undefined && this.stacklessAsync.has(fn.name); + if (fn.async === true) return this.stacklessAsync.has(fn.name); return this.stacklessGenerators && fn.generator !== undefined; } emitGenScaffolding(): string[] { @@ -3527,6 +3598,7 @@ export class LlEmitter { /** Releases a statement frame's temps and owned slots. */ releaseTemps(frame: LlValue[]): void { for (const v of frame) { + if (v.arena !== undefined) continue; if (v.slot) { const t = this.B.tmp(); this.B.line(`${t} = load ptr, ptr ${v.name}`); @@ -3539,10 +3611,19 @@ export class LlEmitter { this.releaseFrame(v.name, v.type); } } + // Arena lists last: the frame's temps may be arena objects. + for (const v of frame) + if (v.arena === "drop") this.B.line(`call void @scr_arena_drop(ptr ${v.name})`); + else if (v.arena === "close") { + const head = this.B.tmp(); + this.B.line(`${head} = load ptr, ptr ${v.name}`); + this.B.line(`call void @scr_arena_close(ptr ${head})`); + } } releaseScope(scope: LlScopeEntry[]): void { for (const e of scope) { + if (e.arena === true) continue; const t = this.B.tmp(); this.B.line(`${t} = load ptr, ptr ${e.slot}`); if (e.boxed && e.lazy) { @@ -3564,6 +3645,9 @@ export class LlEmitter { this.releaseFrame(t, e.type); // runtime releases are NULL-tolerant } } + // Arena lists last: the scope's bindings may hold arena objects. + for (const e of scope) + if (e.arena === true) this.B.line(`call void @scr_arena_drop(ptr ${e.slot})`); } /** THE release-on-jump path (break/continue/return): pending statement @@ -3888,6 +3972,15 @@ export class LlEmitter { * calls (unwindAbiFunctions excludes their callees). */ emitDirectCall(line: string, callee: string): boolean { const B = this.B; + // A small leaf called only from loops inlines into each of them, past + // LLVM's cost threshold (loop-callees.ts). Debug builds keep the call. + if (this.debug === null && this.loopCallees.has(callee)) { + const comment = line.indexOf(" ; "); + line = + comment < 0 + ? `${line} alwaysinline` + : `${line.slice(0, comment)} alwaysinline${line.slice(comment)}`; + } if (!this.unwindAbi.has(callee) || this.currentCoro !== null || B.isTerminated()) { B.line(line); return false; @@ -5228,7 +5321,7 @@ export class LlEmitter { /** Awaits in the current body suspend its coroutine: every wasm32 async * body, and native stackless async bodies (selectStacklessAsync). */ get coroAwait(): boolean { - return this.wasi || this.currentCoro?.kind === "async"; + return this.wasi || this.currentCoro?.awaits === true; } /** Suspends the current coroutine body at an await of `promise` (null: a @@ -5267,7 +5360,7 @@ export class LlEmitter { * coroutine has already suspended until the promise settled, so its * extraction takes no further turn. */ awaitResultFn(kind: "void" | "f64" | "bool" | "str" | "ref"): string { - return !this.wasi && this.currentCoro?.kind === "async" + return !this.wasi && this.currentCoro?.awaits === true ? `scr_coro_await_${kind}` : `scr_await_${kind}`; } @@ -6335,6 +6428,7 @@ export class LlEmitter { type: local.type, slot: true, ...(local.boxed ? { boxed: true } : {}), + ...(local.arena === true ? { arena: "drop" as const } : {}), }); } return result; @@ -7429,9 +7523,196 @@ export class LlEmitter { } emitExpr(e: IrExpr): LlValue { + return this.arenas === null ? emitExpr(this, e) : this.emitArenaExpr(e, this.arenas); + } + + /** emitExpr when group arenas are planned. Kept out of emitExpr, whose + * frame each level of a nested expression holds while it is emitted. */ + private emitArenaExpr(e: IrExpr, arenas: ArenaPlan): LlValue { + if (arenas.groups.calls.has(e)) { + // A call region: everything the call allocates dies when it returns. + // The statement drops the arena after its temps, which an + // arena-aware caller can have allocated in it while evaluating the + // arguments; any other caller drops it as soon as the call returns. + const slot = this.arenaSlot(e); + this.statementFrame().push({ name: slot, type: e.type, slot: true, arena: "drop" }); + this.B.line(`call ptr @scr_arena_open(ptr ${slot}, i32 ${this.arenaClosed(e)})`); + const value = emitExpr(this, e); + if (!arenaAware(arenas, this.fnByName.get(this.currentFunctionName))) + this.B.line(`call void @scr_arena_drop(ptr ${slot})`); + return value; + } + if (arenas.sites.heapCalls.has(e) && this.arenaSite(null)) { + // A call whose value the group does not keep: everything it allocates + // takes the heap. + const saved = this.suspendArena(); + const value = emitExpr(this, e); + this.resumeArena(saved); + return value; + } + if (this.opensArena(e)) { + // A root: its group lives until the scope holding its value exits + // (the statement frame for a value used in place). + const slot = this.arenaSlot(e); + const local = arenas.groups.drops.get(e) ?? null; + this.statementFrame().push({ + name: slot, + type: e.type, + slot: true, + arena: local === null ? "drop" : "close", + }); + const arena = this.B.tmp(); + this.B.line(`${arena} = call ptr @scr_arena_open(ptr ${slot}, i32 ${this.arenaClosed(e)})`); + const value = emitExpr(this, e); + this.B.line(`call void @scr_arena_close(ptr ${arena})`); + return value; + } return emitExpr(this, e); } + /** Group arenas (arenas.ts): the plan, the list slot of each root and + * call region, and which locals' scopes drop which roots' arenas. */ + arenas: ArenaPlan | null = null; + private arenaSlots = new Map(); + /** The frames emitStmtBody opens for whole statements (arenas only). */ + readonly statementFrames = new Set(); + + /** The frame of the statement being emitted: a nested frame (call + * arguments, branches) ends before the values built in it do. */ + statementFrame(): LlValue[] { + for (let i = this.frames.length - 1; i >= 0; i--) + if (this.statementFrames.has(this.frames[i]!)) return this.frames[i]!; + return this.currentFrame(); + } + + /** The arena list slot of root or call region `e`: allocated, cleared and + * (for a root dropped with a local) owned by that local's scope when the + * function starts or the local is declared. */ + arenaSlot(e: Node): string { + let slot = this.arenaSlots.get(e); + if (slot === undefined) { + slot = this.B.tmp(); + this.B.entryAllocas.push(`${slot} = alloca ptr`, `store ptr null, ptr ${slot}`); + this.arenaSlots.set(e, slot); + this.declare(`declare ptr @scr_arena_open(ptr, i32)`); + this.declare(`declare void @scr_arena_close(ptr)`); + this.declare(`declare void @scr_arena_drop(ptr)`); + } + return slot; + } + + /** Whether emitExpr opens an arena for `e`: a root call or `new` of a + * counted value. */ + opensArena(e: Node): e is Extract { + return ( + this.arenas !== null && + this.arenas.groups.roots.has(e) && + (e.kind === "call" || e.kind === "new") && + isRefCounted(e.type) + ); + } + + /** 1 when the group of root or call region `e` is closed: its objects + * hold only each other and immortal values. */ + arenaClosed(e: Node): number { + return this.arenas?.groups.closed.has(e) === true ? 1 : 0; + } + + /** Registers on the innermost scope the arena lists of the roots whose + * arenas `localId`'s scope drops. */ + ownArenasOf(localId: string): void { + const roots = this.arenaDropsByLocal.get(localId); + if (roots === undefined) return; + const scope = this.scopes[this.scopes.length - 1]!; + for (const { root, type } of roots) + scope.push({ slot: this.arenaSlot(root), type, arena: true }); + } + + /** The roots of the function being emitted, by the local that drops + * them; set when a function's emission starts. */ + arenaDropsByLocal = new Map(); + + /** Starts a function's arena bookkeeping: its roots by dropping local, + * with the lists of locals the body never declares (parameters, loop + * and catch bindings) owned by the function's own scope. */ + beginArenaFunction(fn: IrFunction): void { + this.arenaSlots.clear(); + this.statementFrames.clear(); + this.arenaDropsByLocal = new Map(); + const arenas = this.arenas; + if (arenas === null) return; + const declared = new Set(); + const visit = (node: Node): void => { + if (node.kind === "varDecl") declared.add(node.localId); + if (this.opensArena(node)) { + const local = arenas.groups.drops.get(node); + if (local !== undefined && local !== null) { + const list = this.arenaDropsByLocal.get(local) ?? []; + list.push({ root: node, type: node.type }); + this.arenaDropsByLocal.set(local, list); + } + } + const kids: Node[] = []; + children(node, kids); + for (const kid of kids) visit(kid); + }; + for (const s of fn.body) visit(s); + for (const local of [...this.arenaDropsByLocal.keys()]) + if (!declared.has(local)) this.ownArenasOf(local); + } + + /** Whether allocation site `e` (null: any) in the function being + * emitted takes the open arena: the function is arena-aware and the group + * keeps the site's value (region-sites.ts). */ + arenaSite(e: IrExpr | null): boolean { + const arenas = this.arenas; + if (!arenaAware(arenas, this.fnByName.get(this.currentFunctionName))) return false; + return e === null || arenas!.sites.arenaSites.has(e); + } + + /** Stops any arena being the allocation target; returns what restores it. */ + suspendArena(): string { + const saved = this.B.tmp(); + const cur = this.B.tmp(); + const lines = arenaCurrentAddress(this.shapeHost, cur); + if (lines === null) { + this.declare(`declare ptr @scr_arena_suspend()`); + this.B.line(`${saved} = call ptr @scr_arena_suspend()`); + } else { + for (const line of lines) this.B.line(line); + this.B.line(`${saved} = load ptr, ptr ${cur}`); + this.B.line(`store ptr null, ptr ${cur}`); + } + return saved; + } + + /** Makes the arena suspendArena stopped (`saved`) the target again. */ + resumeArena(saved: string): void { + const cur = this.B.tmp(); + const lines = arenaCurrentAddress(this.shapeHost, cur); + if (lines === null) { + this.declare(`declare void @scr_arena_resume(ptr)`); + this.B.line(`call void @scr_arena_resume(ptr ${saved})`); + } else { + for (const line of lines) this.B.line(line); + this.B.line(`store ptr ${saved}, ptr ${cur}`); + } + } + + /** The arena-aware constructor of `newSym` for an allocation of a + * record shape or class in the function being emitted, or `newSym`. */ + allocSym(newSym: string, e: IrExpr): string { + const arenas = this.arenas; + if (arenas === null) return newSym; + const planned = + e.type.kind === "record" + ? arenas.shapes.has(e.type.shapeId) + : e.kind === "new" && arenas.classes.has(e.className); + if (!planned) return newSym; + const aware = this.arenaSite(e) || arenas.groups.roots.has(e); + return aware ? arenaNewSym(newSym) : newSym; + } + /** The helper classification of this module's inline-cache sites. */ get jsIcSites(): JsIcSites { return (this.jsIcSitesMemo ??= new JsIcSites(this.mod)); diff --git a/packages/compiler/src/backend/llvm/expr-calls.ts b/packages/compiler/src/backend/llvm/expr-calls.ts index 88d094800..18181de50 100644 --- a/packages/compiler/src/backend/llvm/expr-calls.ts +++ b/packages/compiler/src/backend/llvm/expr-calls.ts @@ -1104,7 +1104,7 @@ export function emitCallExpr( `llvm emitter bug: new ${e.className} without a constructor`, ); const o = B.tmp(); - B.line(`${o} = call ptr @${mangleClassNew(e.className)}()`); + B.line(`${o} = call ptr @${host.allocSym(mangleClassNew(e.className), e)}()`); const out = host.own({ name: o, type: e.type }); emitConstruction(host, e, o, ctor); return out; diff --git a/packages/compiler/src/backend/llvm/expr-containers.ts b/packages/compiler/src/backend/llvm/expr-containers.ts index 7eec14e4e..279afdcd0 100644 --- a/packages/compiler/src/backend/llvm/expr-containers.ts +++ b/packages/compiler/src/backend/llvm/expr-containers.ts @@ -62,12 +62,14 @@ import { import { exactInteger, widenInteger } from "./integer-values.js"; import { emitDenseArrayNumber, + emitDenseArrayNumberInt32, emitDenseArrayPop, emitDenseArrayPush, runtimeAccess, type ArrayAccess, } from "./dense-array-access.js"; import { INT32_ARRAY_METHODS } from "../../ir/int32-arrays.js"; +import { INT32_RANGE } from "../../ir/integer-ranges.js"; import { emitUnionWiden } from "./expr-records.js"; import { registerReplay } from "./emit-journal.js"; @@ -694,6 +696,25 @@ export function emitArrIntrinsic( )!; return { name: value, type: e.type }; } + // In a store statement of an int32 kind loop's fast copy, a read that + // is not an int32 (a missing element's NaN) leaves for the + // statement's resume point, so the element is at hand as an i32. + if (!table && elem.kind === "f64" && access === "i32" && host.int32KindResume !== null) { + const read = emitDenseArrayNumberInt32( + host, + r.name, + index, + e.args[0], + host.provenDenseIndex(e.receiver, e.args[0]!), + host.int32KindResume, + ); + return { + name: read.value, + type: e.type, + uint32: read.int32, + integer: { name: read.int32, type: "i32", signed: true, range: INT32_RANGE }, + }; + } if (!table && elem.kind === "f64") return { name: emitDenseArrayNumber( diff --git a/packages/compiler/src/backend/llvm/expr-context.ts b/packages/compiler/src/backend/llvm/expr-context.ts index a5b7f61dd..0df23f10a 100644 --- a/packages/compiler/src/backend/llvm/expr-context.ts +++ b/packages/compiler/src/backend/llvm/expr-context.ts @@ -33,6 +33,9 @@ export interface LlValue { slot?: boolean; /** A sequence local held in a capture box; release the box itself. */ boxed?: boolean; + /** A group arena list slot (arenas.ts): the frame closes the newest + * arena on it, or drops the whole list, after its other temps. */ + arena?: "close" | "drop"; } export interface WideInteger { diff --git a/packages/compiler/src/backend/llvm/expr-control.ts b/packages/compiler/src/backend/llvm/expr-control.ts index 6e0fd4acb..28281478a 100644 --- a/packages/compiler/src/backend/llvm/expr-control.ts +++ b/packages/compiler/src/backend/llvm/expr-control.ts @@ -11,6 +11,10 @@ import { emitRecordKeyGetOr } from "./expr-records.js"; import { NULLABLE_NULL, SHAPE_WORD_INDEX, shapeWordHeader } from "./nullable-unions.js"; import { emitUnionTag } from "./union-repr.js"; +/** Each kind emits in a function of its own: emitting a nested chain of + * these expressions holds one emitControlExpr frame per level, and in an + * unoptimized build of the compiler a frame holds every local of the + * function. */ export function emitControlExpr( host: LlvmEmitterContext, e: ExprOf< @@ -25,446 +29,25 @@ export function emitControlExpr( | "nullish" >, ): LlValue { - const B = host.B; switch (e.kind) { - case "dynDestrCheck": { - // RequireObjectCoercible with V8's destructuring TypeError. dyn - // values check in the runtime helper and pass through unchanged. - const v = host.emitExpr(e.value); - const first = e.firstProp !== undefined ? host.cstr(e.firstProp) : "null"; - - const helper = host.dyn.dynDestrCheckHelper(); - B.line(`call void @${helper}(ptr ${v.name}, ptr ${host.cstr(e.spelling)}, ptr ${first})`); - host.emitPendingCheck(); - return v; - } - case "dynIterN": { - // GetIterator + first-N steps as a fresh array (V8's exact - // not-iterable TypeError on non-iterables) through the native dyn helper. - const v = emitBorrowedInput(host, e.value); - - const helper = host.dyn.dynIterNHelper(); - const t = B.tmp(); - B.line( - `${t} = call ptr @${helper}(ptr ${v.name}, ${host.sizeType} ${e.count}, ptr ${e.notIterableMessage ? host.cstr(e.notIterableMessage) : "null"})`, - ); - const out = host.own({ name: t, type: e.type }); - host.emitPendingCheck(); - return out; - } + case "dynDestrCheck": + return emitDynDestrCheck(host, e); + case "dynIterN": + return emitDynIterN(host, e); case "toBool": return { name: host.truthy(host.emitUnionProjection(e.operand)), type: e.type }; - case "logical": { - const tagSet = e.op === "||" ? unionTagSet(e) : null; - if (tagSet) return { name: emitUnionTagSet(host, tagSet), type: e.type }; - // JS value semantics: the result is the deciding operand itself. - // Left evaluates once, ownership moves into the result slot; when - // the branch takes the right operand the stale left releases first - // and the right runs in its own frame. - const ty = host.llType(e.type); - const l = host.emitExpr(e.left); - host.toFrame(l); - const slot = B.slot(); - B.entryAllocas.push(`${slot} = alloca ${ty}`); - B.line(`store ${ty} ${l.name}, ptr ${slot}`); - const integerSlot = e.type.kind === "f64" && l.uint32 !== undefined ? B.slot() : undefined; - if (integerSlot !== undefined) { - B.entryAllocas.push(`${integerSlot} = alloca i32`); - B.line(`store i32 ${l.uint32!}, ptr ${integerSlot}`); - } - const truthy = host.truthy(l); - const rightLabel = B.newLabel("log.r"); - const joinLabel = B.newLabel("log.j"); - if (e.op === "&&") B.condBr(truthy, rightLabel, joinLabel); - else B.condBr(truthy, joinLabel, rightLabel); - B.startBlock(rightLabel); - if (isRefCounted(e.type)) host.releaseFrame(l.name, e.type); - const rightInteger = host.emitBranchInto(slot, e.right, integerSlot); - B.br(joinLabel); - B.startBlock(joinLabel); - const t = B.tmp(); - B.line(`${t} = load ${ty}, ptr ${slot}`); - if (integerSlot !== undefined && rightInteger) { - const uint32 = B.tmp(); - B.line(`${uint32} = load i32, ptr ${integerSlot}`); - return { name: t, type: e.type, uint32 }; - } - return host.own({ name: t, type: e.type }); - } - case "ternary": { - // Exactly one arm evaluates; each arm runs in its own frame and - // moves the chosen value into the result slot. - const ty = host.llType(e.type); - const c = host.emitExpr(e.cond); - const slot = B.slot(); - B.entryAllocas.push(`${slot} = alloca ${ty}`); - const integerSlot = e.type.kind === "f64" ? B.slot() : undefined; - if (integerSlot !== undefined) B.entryAllocas.push(`${integerSlot} = alloca i32`); - const lt = B.newLabel("tern.t"); - const lf = B.newLabel("tern.f"); - const lj = B.newLabel("tern.j"); - B.condBr(c.name, lt, lf); - B.startBlock(lt); - const thenInteger = host.emitBranchInto(slot, e.then, integerSlot); - B.br(lj); - B.startBlock(lf); - const elseInteger = host.emitBranchInto(slot, e.else_, integerSlot); - B.br(lj); - B.startBlock(lj); - const t = B.tmp(); - B.line(`${t} = load ${ty}, ptr ${slot}`); - // A view is available only when every incoming branch wrote it. - // Incomplete views are unused and disappear during promotion. - if (integerSlot !== undefined && thenInteger && elseInteger) { - const uint32 = B.tmp(); - B.line(`${uint32} = load i32, ptr ${integerSlot}`); - return { name: t, type: e.type, uint32 }; - } - // A choice between interned literals is an interned literal: like a - // strLit operand, nothing owns it (retain and release are no-ops). - if (literalChoice(e)) return host.markImmortal({ name: t, type: e.type }); - return host.own({ name: t, type: e.type }); - } - case "optChain": { - // `a?.b` / `f?.()`: the nullish test inverted. The receiver - // evaluates once into an ordinary frame temp (borrowed); on a unit - // tag the result is the interned undefined arm and the body never - // runs; otherwise the narrowed payload fills the bind slot (+1, - // frame-owned through a SLOT entry — NULL on the unit path, where - // the frame's release is a no-op) and the body reads it through - // chainRecv. - if (e.receiver.type.kind === "dyn") { - // A dyn (dyn) receiver — the `rawName?.match(re)` step: the - // nullish test reads the node's kind tag; the unit path is the - // undefined dyn singleton (dyn results) or nothing (void - // bodies), the body runs over the bound receiver otherwise. - const r = host.emitExpr(e.receiver); - const kd = host.dynKind(r.name); - const isU = B.tmp(); - const isN = B.tmp(); - const isUnit = B.tmp(); - B.line(`${isU} = icmp eq i32 ${kd}, ${DYN_KIND.UNDEF}`); - B.line(`${isN} = icmp eq i32 ${kd}, ${DYN_KIND.NULL}`); - B.line(`${isUnit} = or i1 ${isU}, ${isN}`); - const bind = B.slot(); - B.entryAllocas.push(`${bind} = alloca ptr`); - B.line(`store ptr null, ptr ${bind}`); - host.ownSlot(bind, e.receiver.type); - host.declare(`declare ptr @scr_dyn_retain_v(ptr)`); - if (e.type.kind === "void") { - const lb = B.newLabel("ocd.b"); - const lj = B.newLabel("ocd.j"); - B.condBr(isUnit, lj, lb); - B.startBlock(lb); - const rr = B.tmp(); - B.line(`${rr} = call ptr @scr_dyn_retain_v(ptr ${r.name})`); - B.line(`store ptr ${rr}, ptr ${bind}`); - host.chainSlots.set(e.id, { name: bind, type: e.receiver.type, slot: true }); - host.frames.push([]); - host.emitExpr(e.body); - host.releaseTemps(host.frames.pop()!); - host.chainSlots.delete(e.id); - B.br(lj); - B.startBlock(lj); - return { name: "", type: e.type }; - } - if (e.type.kind !== "dyn") - throw new InternalCompilerError("llvm emitter bug: dyn optChain result kind"); - const slot = B.slot(); - B.entryAllocas.push(`${slot} = alloca ptr`); - const lu = B.newLabel("ocd.u"); - const lb = B.newLabel("ocd.b"); - const lj = B.newLabel("ocd.j"); - B.condBr(isUnit, lu, lb); - B.startBlock(lu); - host.declare(`declare ptr @scr_dyn_undefined()`); - const un = B.tmp(); - const ur = B.tmp(); - B.line(`${un} = call ptr @scr_dyn_undefined()`); - B.line(`${ur} = call ptr @scr_dyn_retain_v(ptr ${un})`); - B.line(`store ptr ${ur}, ptr ${slot}`); - B.br(lj); - B.startBlock(lb); - const rr = B.tmp(); - B.line(`${rr} = call ptr @scr_dyn_retain_v(ptr ${r.name})`); - B.line(`store ptr ${rr}, ptr ${bind}`); - host.chainSlots.set(e.id, { name: bind, type: e.receiver.type, slot: true }); - host.emitBranchInto(slot, e.body); - host.chainSlots.delete(e.id); - B.br(lj); - B.startBlock(lj); - const t = B.tmp(); - B.line(`${t} = load ptr, ptr ${slot}`); - return host.own({ name: t, type: e.type }); - } - - const { isUnit, bindPresent } = emitOptionalChainReceiver(host, e); - if (e.type.kind === "void") { - // Statement form (cb?.()): no result value at all. - const lb = B.newLabel("oc.b"); - const lj = B.newLabel("oc.j"); - B.condBr(isUnit, lj, lb); - B.startBlock(lb); - bindPresent(); - host.frames.push([]); - host.emitExpr(e.body); - host.releaseTemps(host.frames.pop()!); - host.chainSlots.delete(e.id); - B.br(lj); - B.startBlock(lj); - return { name: "", type: e.type }; - } - if (e.type.kind === "dyn") { - // A dyn-typed chain (`pricing?.[key]` over an unknown-valued - // index signature): the unit path is the undefined dyn value — - // dyn represents undefined directly, no union wrapper exists. - const slotD = B.slot(); - B.entryAllocas.push(`${slotD} = alloca ptr`); - const lu = B.newLabel("ocu.u"); - const lb = B.newLabel("ocu.b"); - const lj = B.newLabel("ocu.j"); - B.condBr(isUnit, lu, lb); - B.startBlock(lu); - host.declare(`declare ptr @scr_dyn_undefined()`); - host.declare(`declare ptr @scr_dyn_retain_v(ptr)`); - const un = B.tmp(); - const ur = B.tmp(); - B.line(`${un} = call ptr @scr_dyn_undefined()`); - B.line(`${ur} = call ptr @scr_dyn_retain_v(ptr ${un})`); - B.line(`store ptr ${ur}, ptr ${slotD}`); - B.br(lj); - B.startBlock(lb); - bindPresent(); - host.emitBranchInto(slotD, e.body); - host.chainSlots.delete(e.id); - B.br(lj); - B.startBlock(lj); - const t = B.tmp(); - B.line(`${t} = load ptr, ptr ${slotD}`); - return host.own({ name: t, type: e.type }); - } - if (e.type.kind !== "union") - throw new LlvmUnsupportedError(`optChainResult:${e.type.kind}`, e.loc); - const undefTag = undefinedArmTag(e.type, host.unionsById); - if (undefTag < 0) - throw new InternalCompilerError( - "llvm emitter bug: optChain result lacks its undefined arm", - ); - const ty = host.llType(e.type); - const slot = B.slot(); - B.entryAllocas.push(`${slot} = alloca ${ty}`); - const lu = B.newLabel("oc.u"); - const lb = B.newLabel("oc.b"); - const lj = B.newLabel("oc.j"); - B.condBr(isUnit, lu, lb); - B.startBlock(lu); - B.line(`store ptr ${host.unitInstanceRef(e.type.unionId, undefTag)}, ptr ${slot}`); - B.br(lj); - B.startBlock(lb); - bindPresent(); - host.emitBranchInto(slot, e.body); - host.chainSlots.delete(e.id); - B.br(lj); - B.startBlock(lj); - const t = B.tmp(); - B.line(`${t} = load ${ty}, ptr ${slot}`); - return host.own({ name: t, type: e.type }); - } - case "chainRecv": { - const bound = host.chainSlots.get(e.id); - if (!bound) - throw new InternalCompilerError(`llvm emitter bug: chainRecv "${e.id}" outside its chain`); - const v = B.tmp(); - B.line(`${v} = load ${host.llType(bound.type)}, ptr ${bound.name}`); - if (!isRefCounted(e.type)) return { name: v, type: e.type }; - return host.own({ name: host.retainFrame(v, e.type), type: e.type }); - } - case "orDefault": { - // `u || d` narrowed to the single non-unit arm: nullish's dance - // with the union TRUTHY switch as the test — truthy extracts the - // arm (+1 for ref kinds), falsy releases and runs the default. - if (e.left.type.kind !== "union") - throw new InternalCompilerError("llvm emitter bug: orDefault left is not a union"); - if ( - e.retag === undefined && - !host.nullableUnions.has(e.left.type.unionId) && - host.isStackUnionSource(e.left) - ) { - // A stack box (`arr[i] || d`): test truthiness and extract the - // payload (+1). The box is never retained or released. - const l = host.emitUnionProjection(e.left); - const ty = host.llType(e.type); - const slot = B.slot(); - B.entryAllocas.push(`${slot} = alloca ${ty}`); - const lt = B.newLabel("ord.t"), - lf = B.newLabel("ord.f"), - lj = B.newLabel("ord.j"); - B.condBr(host.truthy(l), lt, lf); - B.startBlock(lt); - B.line( - `store ${ty} ${host.unionExtract(l.name, e.left.type.unionId, e.type)}, ptr ${slot}`, - ); - B.br(lj); - B.startBlock(lf); - host.emitBranchInto(slot, e.right); - B.br(lj); - B.startBlock(lj); - const t = B.tmp(); - B.line(`${t} = load ${ty}, ptr ${slot}`); - return host.own({ name: t, type: e.type }); - } - const l = host.emitExpr(e.left); - host.toFrame(l); - const ty = host.llType(e.type); - const slot = B.slot(); - B.entryAllocas.push(`${slot} = alloca ${ty}`); - const truthy = host.truthy(l); - const lt = B.newLabel("ord.t"); - const lf = B.newLabel("ord.f"); - const lj = B.newLabel("ord.j"); - B.condBr(truthy, lt, lf); - B.startBlock(lt); - if (e.retag !== undefined) { - // Retagged shape: the whole box goes to the union→union helper, - // which CONSUMES it (callees own their params) — no release on - // this side. The store precedes the pending check so an unwind - // leaves only the NULL dummy behind, in a slot nothing reads. - const t = B.tmp(); - B.line( - `${t} = call ${ty} @${host.callTarget(e.retag)}(${host.llType(e.left.type)} ${l.name})`, - ); - B.line(`store ${ty} ${t}, ptr ${slot}`); - if (host.mayThrow.has(e.retag)) host.emitPendingCheck(); - } else if (host.nullableUnions.single(e.left.type.unionId)) { - // The owned nullable pointer IS the owned arm value. - B.line(`store ${ty} ${l.name}, ptr ${slot}`); - } else { - B.line( - `store ${ty} ${host.unionExtract(l.name, e.left.type.unionId, e.type)}, ptr ${slot}`, - ); - host.releaseFrame(l.name, e.left.type); - } - B.br(lj); - B.startBlock(lf); - host.releaseFrame(l.name, e.left.type); - host.emitBranchInto(slot, e.right); - B.br(lj); - B.startBlock(lj); - const t = B.tmp(); - B.line(`${t} = load ${ty}, ptr ${slot}`); - return host.own({ name: t, type: e.type }); - } - case "nullish": { - // `a ?? b`: logical's move/release dance with the left's runtime - // TAG against its unit arms as the test. Pass-through shape: the - // result IS the left box. Narrowed shape: the single non-unit - // arm's payload extracts (+1 for ref kinds) and the box releases. - - if (e.left.type.kind === "dyn") { - // The native checked-value kind decides whether the left is - // nullish; the right runs lazily in its branch. - const l = host.emitExpr(e.left); - host.toFrame(l); - host.declare(`declare zeroext i1 @scr_dyn_is_nullish(ptr)`); - const slot = B.slot(); - B.entryAllocas.push(`${slot} = alloca ptr`); - const isN = B.tmp(); - B.line(`${isN} = call zeroext i1 @scr_dyn_is_nullish(ptr ${l.name})`); - const lu = B.newLabel("nuld.u"); - const lv = B.newLabel("nuld.v"); - const lj = B.newLabel("nuld.j"); - B.condBr(isN, lu, lv); - B.startBlock(lu); - host.releaseFrame(l.name, e.left.type); - host.emitBranchInto(slot, e.right); - B.br(lj); - B.startBlock(lv); - B.line(`store ptr ${l.name}, ptr ${slot}`); - B.br(lj); - B.startBlock(lj); - const t = B.tmp(); - B.line(`${t} = load ptr, ptr ${slot}`); - return host.own({ name: t, type: e.type }); - } - if (e.left.type.kind !== "union") - throw new InternalCompilerError("llvm emitter bug: nullish left is not a union"); - const def = host.unionsById.get(e.left.type.unionId); - if (!def) - throw new InternalCompilerError( - `llvm emitter bug: nullish of unknown union ${e.left.type.unionId}`, - ); - const unitTags = def.arms.flatMap((a, i) => (isUnitType(a) ? [i] : [])); - if (unitTags.length === 0) - throw new InternalCompilerError("llvm emitter bug: nullish union lacks unit arms"); - const fused = emitNullishOptionalChain(host, e); - if (fused) return fused; - if ( - e.left.kind === "recordKeyGet" && - e.left.missNaN !== true && - typeEquals(e.type, e.left.type) && - storesNoUnit(host, e.left) - ) - return emitRecordKeyGetOr(host, e.left, e.right); - if ( - !typeEquals(e.type, e.left.type) && - (e.left.kind === "varRef" || - host.isStackUnionSource(e.left) || - host.nullableFieldGet(e.left) !== null) - ) { - // Narrowed shape over a binding or a stack box (`arr[i] ?? d`, - // `map.get(k) ?? d`): test the tag and extract the payload (+1). - // The box is never retained or released. A binding is borrowed: - // no code runs between its read and the extraction, and the - // default never touches it. Bindings proven projection-only may - // hold stack boxes, which must never reach RC entry points. - const l = host.emitUnionProjection(e.left); - const ty = host.llType(e.type); - const slot = B.slot(); - B.entryAllocas.push(`${slot} = alloca ${ty}`); - const isUnit = host.tagInSet(l.name, def.id, unitTags); - const lu = B.newLabel("nul.u"); - const lv = B.newLabel("nul.v"); - const lj = B.newLabel("nul.j"); - B.condBr(isUnit, lu, lv); - B.startBlock(lu); - host.emitBranchInto(slot, e.right); - B.br(lj); - B.startBlock(lv); - B.line(`store ${ty} ${host.unionExtract(l.name, def.id, e.type)}, ptr ${slot}`); - B.br(lj); - B.startBlock(lj); - const t = B.tmp(); - B.line(`${t} = load ${ty}, ptr ${slot}`); - return host.own({ name: t, type: e.type }); - } - const l = host.emitExpr(e.left); - host.toFrame(l); - const ty = host.llType(e.type); - const slot = B.slot(); - B.entryAllocas.push(`${slot} = alloca ${ty}`); - const isUnit = host.tagInSet(l.name, def.id, unitTags); - const lu = B.newLabel("nul.u"); - const lv = B.newLabel("nul.v"); - const lj = B.newLabel("nul.j"); - B.condBr(isUnit, lu, lv); - B.startBlock(lu); - host.releaseFrame(l.name, e.left.type); - host.emitBranchInto(slot, e.right); - B.br(lj); - B.startBlock(lv); - if (typeEquals(e.type, e.left.type) || host.nullableUnions.single(def.id)) { - // Pass-through, or a nullable pointer that IS its owned arm value. - B.line(`store ${ty} ${l.name}, ptr ${slot}`); - } else { - B.line(`store ${ty} ${host.unionExtract(l.name, def.id, e.type)}, ptr ${slot}`); - host.releaseFrame(l.name, e.left.type); - } - B.br(lj); - B.startBlock(lj); - const t = B.tmp(); - B.line(`${t} = load ${ty}, ptr ${slot}`); - return host.own({ name: t, type: e.type }); - } + case "logical": + return emitLogical(host, e); + case "ternary": + return emitTernary(host, e); + case "optChain": + return emitOptChain(host, e); + case "chainRecv": + return emitChainRecv(host, e); + case "orDefault": + return emitOrDefault(host, e); + case "nullish": + return emitNullish(host, e); default: { const _exhaustive: never = e; void _exhaustive; @@ -473,6 +56,450 @@ export function emitControlExpr( } } +function emitDynDestrCheck(host: LlvmEmitterContext, e: ExprOf<"dynDestrCheck">): LlValue { + const B = host.B; + // RequireObjectCoercible with V8's destructuring TypeError. dyn + // values check in the runtime helper and pass through unchanged. + const v = host.emitExpr(e.value); + const first = e.firstProp !== undefined ? host.cstr(e.firstProp) : "null"; + + const helper = host.dyn.dynDestrCheckHelper(); + B.line(`call void @${helper}(ptr ${v.name}, ptr ${host.cstr(e.spelling)}, ptr ${first})`); + host.emitPendingCheck(); + return v; +} + +function emitDynIterN(host: LlvmEmitterContext, e: ExprOf<"dynIterN">): LlValue { + const B = host.B; + // GetIterator + first-N steps as a fresh array (V8's exact + // not-iterable TypeError on non-iterables) through the native dyn helper. + const v = emitBorrowedInput(host, e.value); + + const helper = host.dyn.dynIterNHelper(); + const t = B.tmp(); + B.line( + `${t} = call ptr @${helper}(ptr ${v.name}, ${host.sizeType} ${e.count}, ptr ${e.notIterableMessage ? host.cstr(e.notIterableMessage) : "null"})`, + ); + const out = host.own({ name: t, type: e.type }); + host.emitPendingCheck(); + return out; +} + +function emitLogical(host: LlvmEmitterContext, e: ExprOf<"logical">): LlValue { + const B = host.B; + const tagSet = e.op === "||" ? unionTagSet(e) : null; + if (tagSet) return { name: emitUnionTagSet(host, tagSet), type: e.type }; + // JS value semantics: the result is the deciding operand itself. + // Left evaluates once, ownership moves into the result slot; when + // the branch takes the right operand the stale left releases first + // and the right runs in its own frame. + const ty = host.llType(e.type); + const l = host.emitExpr(e.left); + host.toFrame(l); + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca ${ty}`); + B.line(`store ${ty} ${l.name}, ptr ${slot}`); + const integerSlot = e.type.kind === "f64" && l.uint32 !== undefined ? B.slot() : undefined; + if (integerSlot !== undefined) { + B.entryAllocas.push(`${integerSlot} = alloca i32`); + B.line(`store i32 ${l.uint32!}, ptr ${integerSlot}`); + } + const truthy = host.truthy(l); + const rightLabel = B.newLabel("log.r"); + const joinLabel = B.newLabel("log.j"); + if (e.op === "&&") B.condBr(truthy, rightLabel, joinLabel); + else B.condBr(truthy, joinLabel, rightLabel); + B.startBlock(rightLabel); + if (isRefCounted(e.type)) host.releaseFrame(l.name, e.type); + const rightInteger = host.emitBranchInto(slot, e.right, integerSlot); + B.br(joinLabel); + B.startBlock(joinLabel); + const t = B.tmp(); + B.line(`${t} = load ${ty}, ptr ${slot}`); + if (integerSlot !== undefined && rightInteger) { + const uint32 = B.tmp(); + B.line(`${uint32} = load i32, ptr ${integerSlot}`); + return { name: t, type: e.type, uint32 }; + } + return host.own({ name: t, type: e.type }); +} + +function emitTernary(host: LlvmEmitterContext, e: ExprOf<"ternary">): LlValue { + const B = host.B; + // Exactly one arm evaluates; each arm runs in its own frame and + // moves the chosen value into the result slot. + const ty = host.llType(e.type); + const c = host.emitExpr(e.cond); + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca ${ty}`); + const integerSlot = e.type.kind === "f64" ? B.slot() : undefined; + if (integerSlot !== undefined) B.entryAllocas.push(`${integerSlot} = alloca i32`); + const lt = B.newLabel("tern.t"); + const lf = B.newLabel("tern.f"); + const lj = B.newLabel("tern.j"); + B.condBr(c.name, lt, lf); + B.startBlock(lt); + const thenInteger = host.emitBranchInto(slot, e.then, integerSlot); + B.br(lj); + B.startBlock(lf); + const elseInteger = host.emitBranchInto(slot, e.else_, integerSlot); + B.br(lj); + B.startBlock(lj); + const t = B.tmp(); + B.line(`${t} = load ${ty}, ptr ${slot}`); + // A view is available only when every incoming branch wrote it. + // Incomplete views are unused and disappear during promotion. + if (integerSlot !== undefined && thenInteger && elseInteger) { + const uint32 = B.tmp(); + B.line(`${uint32} = load i32, ptr ${integerSlot}`); + return { name: t, type: e.type, uint32 }; + } + // A choice between interned literals is an interned literal: like a + // strLit operand, nothing owns it (retain and release are no-ops). + if (literalChoice(e)) return host.markImmortal({ name: t, type: e.type }); + return host.own({ name: t, type: e.type }); +} + +function emitOptChain(host: LlvmEmitterContext, e: ExprOf<"optChain">): LlValue { + const B = host.B; + // `a?.b` / `f?.()`: the nullish test inverted. The receiver + // evaluates once into an ordinary frame temp (borrowed); on a unit + // tag the result is the interned undefined arm and the body never + // runs; otherwise the narrowed payload fills the bind slot (+1, + // frame-owned through a SLOT entry — NULL on the unit path, where + // the frame's release is a no-op) and the body reads it through + // chainRecv. + if (e.receiver.type.kind === "dyn") { + // A dyn (dyn) receiver — the `rawName?.match(re)` step: the + // nullish test reads the node's kind tag; the unit path is the + // undefined dyn singleton (dyn results) or nothing (void + // bodies), the body runs over the bound receiver otherwise. + const r = host.emitExpr(e.receiver); + const kd = host.dynKind(r.name); + const isU = B.tmp(); + const isN = B.tmp(); + const isUnit = B.tmp(); + B.line(`${isU} = icmp eq i32 ${kd}, ${DYN_KIND.UNDEF}`); + B.line(`${isN} = icmp eq i32 ${kd}, ${DYN_KIND.NULL}`); + B.line(`${isUnit} = or i1 ${isU}, ${isN}`); + const bind = B.slot(); + B.entryAllocas.push(`${bind} = alloca ptr`); + B.line(`store ptr null, ptr ${bind}`); + host.ownSlot(bind, e.receiver.type); + host.declare(`declare ptr @scr_dyn_retain_v(ptr)`); + if (e.type.kind === "void") { + const lb = B.newLabel("ocd.b"); + const lj = B.newLabel("ocd.j"); + B.condBr(isUnit, lj, lb); + B.startBlock(lb); + const rr = B.tmp(); + B.line(`${rr} = call ptr @scr_dyn_retain_v(ptr ${r.name})`); + B.line(`store ptr ${rr}, ptr ${bind}`); + host.chainSlots.set(e.id, { name: bind, type: e.receiver.type, slot: true }); + host.frames.push([]); + host.emitExpr(e.body); + host.releaseTemps(host.frames.pop()!); + host.chainSlots.delete(e.id); + B.br(lj); + B.startBlock(lj); + return { name: "", type: e.type }; + } + if (e.type.kind !== "dyn") + throw new InternalCompilerError("llvm emitter bug: dyn optChain result kind"); + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca ptr`); + const lu = B.newLabel("ocd.u"); + const lb = B.newLabel("ocd.b"); + const lj = B.newLabel("ocd.j"); + B.condBr(isUnit, lu, lb); + B.startBlock(lu); + host.declare(`declare ptr @scr_dyn_undefined()`); + const un = B.tmp(); + const ur = B.tmp(); + B.line(`${un} = call ptr @scr_dyn_undefined()`); + B.line(`${ur} = call ptr @scr_dyn_retain_v(ptr ${un})`); + B.line(`store ptr ${ur}, ptr ${slot}`); + B.br(lj); + B.startBlock(lb); + const rr = B.tmp(); + B.line(`${rr} = call ptr @scr_dyn_retain_v(ptr ${r.name})`); + B.line(`store ptr ${rr}, ptr ${bind}`); + host.chainSlots.set(e.id, { name: bind, type: e.receiver.type, slot: true }); + host.emitBranchInto(slot, e.body); + host.chainSlots.delete(e.id); + B.br(lj); + B.startBlock(lj); + const t = B.tmp(); + B.line(`${t} = load ptr, ptr ${slot}`); + return host.own({ name: t, type: e.type }); + } + + const { isUnit, bindPresent } = emitOptionalChainReceiver(host, e); + if (e.type.kind === "void") { + // Statement form (cb?.()): no result value at all. + const lb = B.newLabel("oc.b"); + const lj = B.newLabel("oc.j"); + B.condBr(isUnit, lj, lb); + B.startBlock(lb); + bindPresent(); + host.frames.push([]); + host.emitExpr(e.body); + host.releaseTemps(host.frames.pop()!); + host.chainSlots.delete(e.id); + B.br(lj); + B.startBlock(lj); + return { name: "", type: e.type }; + } + if (e.type.kind === "dyn") { + // A dyn-typed chain (`pricing?.[key]` over an unknown-valued + // index signature): the unit path is the undefined dyn value — + // dyn represents undefined directly, no union wrapper exists. + const slotD = B.slot(); + B.entryAllocas.push(`${slotD} = alloca ptr`); + const lu = B.newLabel("ocu.u"); + const lb = B.newLabel("ocu.b"); + const lj = B.newLabel("ocu.j"); + B.condBr(isUnit, lu, lb); + B.startBlock(lu); + host.declare(`declare ptr @scr_dyn_undefined()`); + host.declare(`declare ptr @scr_dyn_retain_v(ptr)`); + const un = B.tmp(); + const ur = B.tmp(); + B.line(`${un} = call ptr @scr_dyn_undefined()`); + B.line(`${ur} = call ptr @scr_dyn_retain_v(ptr ${un})`); + B.line(`store ptr ${ur}, ptr ${slotD}`); + B.br(lj); + B.startBlock(lb); + bindPresent(); + host.emitBranchInto(slotD, e.body); + host.chainSlots.delete(e.id); + B.br(lj); + B.startBlock(lj); + const t = B.tmp(); + B.line(`${t} = load ptr, ptr ${slotD}`); + return host.own({ name: t, type: e.type }); + } + if (e.type.kind !== "union") + throw new LlvmUnsupportedError(`optChainResult:${e.type.kind}`, e.loc); + const undefTag = undefinedArmTag(e.type, host.unionsById); + if (undefTag < 0) + throw new InternalCompilerError("llvm emitter bug: optChain result lacks its undefined arm"); + const ty = host.llType(e.type); + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca ${ty}`); + const lu = B.newLabel("oc.u"); + const lb = B.newLabel("oc.b"); + const lj = B.newLabel("oc.j"); + B.condBr(isUnit, lu, lb); + B.startBlock(lu); + B.line(`store ptr ${host.unitInstanceRef(e.type.unionId, undefTag)}, ptr ${slot}`); + B.br(lj); + B.startBlock(lb); + bindPresent(); + host.emitBranchInto(slot, e.body); + host.chainSlots.delete(e.id); + B.br(lj); + B.startBlock(lj); + const t = B.tmp(); + B.line(`${t} = load ${ty}, ptr ${slot}`); + return host.own({ name: t, type: e.type }); +} + +function emitChainRecv(host: LlvmEmitterContext, e: ExprOf<"chainRecv">): LlValue { + const B = host.B; + const bound = host.chainSlots.get(e.id); + if (!bound) + throw new InternalCompilerError(`llvm emitter bug: chainRecv "${e.id}" outside its chain`); + const v = B.tmp(); + B.line(`${v} = load ${host.llType(bound.type)}, ptr ${bound.name}`); + if (!isRefCounted(e.type)) return { name: v, type: e.type }; + return host.own({ name: host.retainFrame(v, e.type), type: e.type }); +} + +function emitOrDefault(host: LlvmEmitterContext, e: ExprOf<"orDefault">): LlValue { + const B = host.B; + // `u || d` narrowed to the single non-unit arm: nullish's dance + // with the union TRUTHY switch as the test — truthy extracts the + // arm (+1 for ref kinds), falsy releases and runs the default. + if (e.left.type.kind !== "union") + throw new InternalCompilerError("llvm emitter bug: orDefault left is not a union"); + if ( + e.retag === undefined && + !host.nullableUnions.has(e.left.type.unionId) && + host.isStackUnionSource(e.left) + ) { + // A stack box (`arr[i] || d`): test truthiness and extract the + // payload (+1). The box is never retained or released. + const l = host.emitUnionProjection(e.left); + const ty = host.llType(e.type); + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca ${ty}`); + const lt = B.newLabel("ord.t"), + lf = B.newLabel("ord.f"), + lj = B.newLabel("ord.j"); + B.condBr(host.truthy(l), lt, lf); + B.startBlock(lt); + B.line(`store ${ty} ${host.unionExtract(l.name, e.left.type.unionId, e.type)}, ptr ${slot}`); + B.br(lj); + B.startBlock(lf); + host.emitBranchInto(slot, e.right); + B.br(lj); + B.startBlock(lj); + const t = B.tmp(); + B.line(`${t} = load ${ty}, ptr ${slot}`); + return host.own({ name: t, type: e.type }); + } + const l = host.emitExpr(e.left); + host.toFrame(l); + const ty = host.llType(e.type); + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca ${ty}`); + const truthy = host.truthy(l); + const lt = B.newLabel("ord.t"); + const lf = B.newLabel("ord.f"); + const lj = B.newLabel("ord.j"); + B.condBr(truthy, lt, lf); + B.startBlock(lt); + if (e.retag !== undefined) { + // Retagged shape: the whole box goes to the union→union helper, + // which CONSUMES it (callees own their params) — no release on + // this side. The store precedes the pending check so an unwind + // leaves only the NULL dummy behind, in a slot nothing reads. + const t = B.tmp(); + B.line(`${t} = call ${ty} @${host.callTarget(e.retag)}(${host.llType(e.left.type)} ${l.name})`); + B.line(`store ${ty} ${t}, ptr ${slot}`); + if (host.mayThrow.has(e.retag)) host.emitPendingCheck(); + } else if (host.nullableUnions.single(e.left.type.unionId)) { + // The owned nullable pointer IS the owned arm value. + B.line(`store ${ty} ${l.name}, ptr ${slot}`); + } else { + B.line(`store ${ty} ${host.unionExtract(l.name, e.left.type.unionId, e.type)}, ptr ${slot}`); + host.releaseFrame(l.name, e.left.type); + } + B.br(lj); + B.startBlock(lf); + host.releaseFrame(l.name, e.left.type); + host.emitBranchInto(slot, e.right); + B.br(lj); + B.startBlock(lj); + const t = B.tmp(); + B.line(`${t} = load ${ty}, ptr ${slot}`); + return host.own({ name: t, type: e.type }); +} + +function emitNullish(host: LlvmEmitterContext, e: ExprOf<"nullish">): LlValue { + const B = host.B; + // `a ?? b`: logical's move/release dance with the left's runtime + // TAG against its unit arms as the test. Pass-through shape: the + // result IS the left box. Narrowed shape: the single non-unit + // arm's payload extracts (+1 for ref kinds) and the box releases. + + if (e.left.type.kind === "dyn") { + // The native checked-value kind decides whether the left is + // nullish; the right runs lazily in its branch. + const l = host.emitExpr(e.left); + host.toFrame(l); + host.declare(`declare zeroext i1 @scr_dyn_is_nullish(ptr)`); + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca ptr`); + const isN = B.tmp(); + B.line(`${isN} = call zeroext i1 @scr_dyn_is_nullish(ptr ${l.name})`); + const lu = B.newLabel("nuld.u"); + const lv = B.newLabel("nuld.v"); + const lj = B.newLabel("nuld.j"); + B.condBr(isN, lu, lv); + B.startBlock(lu); + host.releaseFrame(l.name, e.left.type); + host.emitBranchInto(slot, e.right); + B.br(lj); + B.startBlock(lv); + B.line(`store ptr ${l.name}, ptr ${slot}`); + B.br(lj); + B.startBlock(lj); + const t = B.tmp(); + B.line(`${t} = load ptr, ptr ${slot}`); + return host.own({ name: t, type: e.type }); + } + if (e.left.type.kind !== "union") + throw new InternalCompilerError("llvm emitter bug: nullish left is not a union"); + const def = host.unionsById.get(e.left.type.unionId); + if (!def) + throw new InternalCompilerError( + `llvm emitter bug: nullish of unknown union ${e.left.type.unionId}`, + ); + const unitTags = def.arms.flatMap((a, i) => (isUnitType(a) ? [i] : [])); + if (unitTags.length === 0) + throw new InternalCompilerError("llvm emitter bug: nullish union lacks unit arms"); + const fused = emitNullishOptionalChain(host, e); + if (fused) return fused; + if ( + e.left.kind === "recordKeyGet" && + e.left.missNaN !== true && + typeEquals(e.type, e.left.type) && + storesNoUnit(host, e.left) + ) + return emitRecordKeyGetOr(host, e.left, e.right); + if ( + !typeEquals(e.type, e.left.type) && + (e.left.kind === "varRef" || + host.isStackUnionSource(e.left) || + host.nullableFieldGet(e.left) !== null) + ) { + // Narrowed shape over a binding or a stack box (`arr[i] ?? d`, + // `map.get(k) ?? d`): test the tag and extract the payload (+1). + // The box is never retained or released. A binding is borrowed: + // no code runs between its read and the extraction, and the + // default never touches it. Bindings proven projection-only may + // hold stack boxes, which must never reach RC entry points. + const l = host.emitUnionProjection(e.left); + const ty = host.llType(e.type); + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca ${ty}`); + const isUnit = host.tagInSet(l.name, def.id, unitTags); + const lu = B.newLabel("nul.u"); + const lv = B.newLabel("nul.v"); + const lj = B.newLabel("nul.j"); + B.condBr(isUnit, lu, lv); + B.startBlock(lu); + host.emitBranchInto(slot, e.right); + B.br(lj); + B.startBlock(lv); + B.line(`store ${ty} ${host.unionExtract(l.name, def.id, e.type)}, ptr ${slot}`); + B.br(lj); + B.startBlock(lj); + const t = B.tmp(); + B.line(`${t} = load ${ty}, ptr ${slot}`); + return host.own({ name: t, type: e.type }); + } + const l = host.emitExpr(e.left); + host.toFrame(l); + const ty = host.llType(e.type); + const slot = B.slot(); + B.entryAllocas.push(`${slot} = alloca ${ty}`); + const isUnit = host.tagInSet(l.name, def.id, unitTags); + const lu = B.newLabel("nul.u"); + const lv = B.newLabel("nul.v"); + const lj = B.newLabel("nul.j"); + B.condBr(isUnit, lu, lv); + B.startBlock(lu); + host.releaseFrame(l.name, e.left.type); + host.emitBranchInto(slot, e.right); + B.br(lj); + B.startBlock(lv); + if (typeEquals(e.type, e.left.type) || host.nullableUnions.single(def.id)) { + // Pass-through, or a nullable pointer that IS its owned arm value. + B.line(`store ${ty} ${l.name}, ptr ${slot}`); + } else { + B.line(`store ${ty} ${host.unionExtract(l.name, def.id, e.type)}, ptr ${slot}`); + host.releaseFrame(l.name, e.left.type); + } + B.br(lj); + B.startBlock(lj); + const t = B.tmp(); + B.line(`${t} = load ${ty}, ptr ${slot}`); + return host.own({ name: t, type: e.type }); +} + /** The receiver half of a union-receiver `a?.b`. The receiver evaluates * once; `isUnit` tests its unit arms, and `bindPresent` fills the chain's * bind slot with the narrowed payload for the body to read through diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 1a4973a3a..5d2a1cfeb 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -1080,16 +1080,21 @@ export function emitContainerExpr( B.line(`${fits} = icmp ule ${host.sizeType} ${capacity}, ${SMALL_ARRAY_SLOTS}`); B.condBr(fits, smallLabel, sizedLabel); B.startBlock(smallLabel); - B.line(`${smallArr} = ${arrNewCall(host.shapeHost, elem, "0", true)}`); + B.line(`${smallArr} = ${arrNewCall(host.shapeHost, elem, "0", true, host.arenaSite(e))}`); B.br(joined); B.startBlock(sizedLabel); - B.line(`${sizedArr} = ${arrNewCall(host.shapeHost, elem, capacity)}`); + B.line( + `${sizedArr} = ${arrNewCall(host.shapeHost, elem, capacity, false, host.arenaSite(e))}`, + ); B.br(joined); B.startBlock(joined); B.line( `${arr} = phi ptr [ ${smallArr}, %${smallLabel} ], [ ${sizedArr}, %${sizedLabel} ]`, ); - } else B.line(`${arr} = ${arrNewCall(host.shapeHost, elem, String(capacity), small)}`); + } else + B.line( + `${arr} = ${arrNewCall(host.shapeHost, elem, String(capacity), small, host.arenaSite(e))}`, + ); } const out = host.own({ name: arr, type: e.type }); const acc: ArrayAccess = int32 ? "i32" : elemAccess(elem); @@ -1362,7 +1367,7 @@ export function emitRecordExpr( throw new InternalCompilerError("llvm emitter bug: recordLit of non-record type"); const shapeId = e.type.shapeId; const rec = B.tmp(); - B.line(`${rec} = call ptr @${mangleRecordNew(shapeId)}()`); + B.line(`${rec} = call ptr @${host.allocSym(mangleRecordNew(shapeId), e)}()`); const out = host.own({ name: rec, type: e.type }); // A null-prototype record: SCR_MAP_NULL_PROTO on the fresh map. if (e.nullProto) B.line(`store i8 1, ptr ${host.recordMapFlagsPtr(rec, shapeId)}`); diff --git a/packages/compiler/src/backend/llvm/int32-kind-loops.ts b/packages/compiler/src/backend/llvm/int32-kind-loops.ts index 12e38c1f0..d596a731d 100644 --- a/packages/compiler/src/backend/llvm/int32-kind-loops.ts +++ b/packages/compiler/src/backend/llvm/int32-kind-loops.ts @@ -57,6 +57,8 @@ function isPure(e: IrExpr): boolean { case "arrayGet": case "arrayHas": return e.arr.kind === "varRef" && isPure(e.index); + case "arrIntrinsic": + return e.method === "getNumber" && e.receiver.kind === "varRef" && isPure(e.args[0]!); default: return false; } diff --git a/packages/compiler/src/backend/llvm/integer-clones.ts b/packages/compiler/src/backend/llvm/integer-clones.ts index 8e9074736..0ea3947b2 100644 --- a/packages/compiler/src/backend/llvm/integer-clones.ts +++ b/packages/compiler/src/backend/llvm/integer-clones.ts @@ -192,6 +192,8 @@ function cloneShape(fn: IrFunction, borrowed?: BorrowedParameter): Set | case "ternary": return expr(e.cond, "bool") && expr(e.then, want) && expr(e.else_, want); case "arrIntrinsic": + if (e.method === "getNumber") + return want === "num" && arrayRef(e.receiver) && expr(e.args[0]!, "num"); return ( want === "num" && e.method === "length" && e.args.length === 0 && arrayRef(e.receiver) ); @@ -269,6 +271,9 @@ function callsMatch(fn: IrFunction, byName: ReadonlyMap): bo case "arrayGet": visit(e.index); return; + case "arrIntrinsic": + if (e.method === "getNumber") visit(e.args[0]!); + return; default: return; } @@ -433,6 +438,8 @@ function callsAnything(e: IrExpr): boolean { return callsAnything(e.cond) || callsAnything(e.then) || callsAnything(e.else_); case "arrayGet": return callsAnything(e.index); + case "arrIntrinsic": + return e.method === "getNumber" && callsAnything(e.args[0]!); default: return false; } @@ -1021,10 +1028,10 @@ class CloneEmitter { * keeps every slot below `filled` a present value) whose element is * exactly an int32. Everything else (holes, indices outside the prefix, * other numbers) bails: the ordinary body reads it. */ - private emitElement(e: Extract): string { + private emitElement(arr: IrExpr, indexExpr: IrExpr): string { const { B } = this; - const array = this.emitArray(e.arr); - const index = this.narrow(this.emitNum(e.index)); + const array = this.emitArray(arr); + const index = this.narrow(this.emitNum(indexExpr)); const filledField = B.tmp(); const filled = B.tmp(); const inside = B.tmp(); @@ -1188,9 +1195,13 @@ class CloneEmitter { if (this.inlines(e)) return { name: this.emitInlineCall(e), wide: false }; return { name: this.emitCall(e), wide: true }; case "arrIntrinsic": + // `getNumber` reads an element like `arrayGet`: a missing one bails, + // and the ordinary body reads it as NaN. + if (e.method === "getNumber") + return { name: this.emitElement(e.receiver, e.args[0]!), wide: false }; return { name: this.emitLength(this.emitArray(e.receiver)), wide: false }; case "arrayGet": - return { name: this.emitElement(e), wide: false }; + return { name: this.emitElement(e.arr, e.index), wide: false }; case "unary": { const operand = this.narrow(this.emitNum(e.operand)); const value = B.tmp(); diff --git a/packages/compiler/src/backend/llvm/js-ic.ts b/packages/compiler/src/backend/llvm/js-ic.ts index dc1aeab16..dbd11c155 100644 --- a/packages/compiler/src/backend/llvm/js-ic.ts +++ b/packages/compiler/src/backend/llvm/js-ic.ts @@ -84,10 +84,13 @@ function kindIs(host: Host, d: string, kind: number, yes: string, no: string): v * inline made polymorphic reads 7-10% faster (the design prototype's W2/W3) * and monomorphic method code no faster, for 65-225 more bytes per site; * the prototype hit made method-heavy code 3-6% faster for 15-90 bytes. - * So a site tests entry 0, own or prototype, and its probe does the rest. - * (0 makes every access probe: with a runtime built with SCR_JS_VERIFY, - * that probes every access.) */ -const INLINE_WAYS: number = 1; + * Two entries cover the common two-layout site (object literals of one + * record built in two key orders): on bench:runtime's dynamic-properties + * they made the run 13% faster than one entry, and 26% faster without its + * class instances; four were no faster than two. So a site tests entries + * 0 and 1, own or prototype, and its probe does the rest. (0 makes every access probe: with a runtime + * built with SCR_JS_VERIFY, that probes every access.) */ +const INLINE_WAYS: number = 2; const INLINE_PROTO = true; /** The address of slot `slot` of layout-mode object `d`. */ diff --git a/packages/compiler/src/backend/llvm/lib-dispatch.ts b/packages/compiler/src/backend/llvm/lib-dispatch.ts index f2ca89fd8..ee2ee154b 100644 --- a/packages/compiler/src/backend/llvm/lib-dispatch.ts +++ b/packages/compiler/src/backend/llvm/lib-dispatch.ts @@ -218,7 +218,7 @@ export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll host.emitCoroAwait(null); return { name: "", type: e.type }; } - if (host.wasi && e.fn === "async.awaitDyn") { + if (host.coroAwait && e.fn === "async.awaitDyn") { const value = host.emitExpr(e.args[0]!); host.declare(`declare ptr @scr_dyn_promise_of(ptr)`); const promise = B.tmp(); @@ -230,15 +230,16 @@ export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll B.line(`${isPromise} = icmp ne ptr ${promise}, null`); B.condBr(isPromise, wait, hop); B.startBlock(wait); - host.emitWasiSuspend(promise); + host.emitCoroAwait(promise); B.br(ready); B.startBlock(hop); - host.emitWasiSuspend(null); + host.emitCoroAwait(null); B.br(ready); B.startBlock(ready); - host.declare(`declare ptr @scr_await_dyn_value_settled(ptr)`); + const extract = host.wasi ? "scr_await_dyn_value_settled" : "scr_coro_await_dyn_value"; + host.declare(`declare ptr @${extract}(ptr)`); const result = B.tmp(); - B.line(`${result} = call ptr @scr_await_dyn_value_settled(ptr ${value.name})`); + B.line(`${result} = call ptr @${extract}(ptr ${value.name})`); const output = host.own({ name: result, type: e.type }); host.emitPendingCheck(); return output; diff --git a/packages/compiler/src/backend/llvm/loop-callees.test.ts b/packages/compiler/src/backend/llvm/loop-callees.test.ts new file mode 100644 index 000000000..f918af944 --- /dev/null +++ b/packages/compiler/src/backend/llvm/loop-callees.test.ts @@ -0,0 +1,83 @@ +import { expect, test } from "vitest"; +import { + BOOL, + F64, + VOID, + type IrExpr, + type IrFunction, + type IrModule, + type IrStmt, +} from "../../ir/ir.js"; +import { findLoopCallees } from "./loop-callees.js"; + +const loc = { file: "callees.ts", start: 0, end: 0 }; +const num = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc }); +const ref = (localId: string): IrExpr => ({ kind: "varRef", localId, type: F64, loc }); +const call = (callee: string): IrExpr => ({ kind: "call", callee, args: [num(1)], type: F64, loc }); +const run = (e: IrExpr): IrStmt => ({ kind: "exprStmt", expr: e, loc }); +const ret = (e: IrExpr): IrStmt => ({ kind: "return", value: e, loc }); +const loop = (body: IrStmt[]): IrStmt => ({ + kind: "while", + cond: { kind: "bin", op: "<", left: ref("i"), right: num(10), type: BOOL, loc }, + body, + loc, +}); +const fn = (name: string, body: IrStmt[], extra: Partial = {}): IrFunction => ({ + name, + params: [{ localId: "x", name: "x", type: F64 }], + returnType: F64, + locals: [{ id: "x", name: "x", type: F64, mutable: false }], + body, + loc, + ...extra, +}); +const module = (functions: IrFunction[]): IrModule => ({ + irVersion: 17, + sourceFile: loc.file, + entry: "__main", + functions: [ + { name: "__main", params: [], returnType: VOID, locals: [], body: [], loc }, + ...functions, + ], +}); + +test("a small leaf called only from loops is a loop callee", () => { + const callees = findLoopCallees( + module([ + fn("leaf", [loop([run(ref("x"))]), ret(ref("x"))]), + fn("caller", [loop([run(call("leaf"))]), loop([run(call("leaf"))]), ret(num(0))]), + ]), + ); + expect([...callees]).toEqual(["leaf"]); +}); + +test("a call outside a loop, a third call site, an own call or a large body keeps the call", () => { + const leaf = fn("leaf", [ret(ref("x"))]); + // A call site outside any loop. + expect( + findLoopCallees(module([leaf, fn("caller", [run(call("leaf")), loop([run(call("leaf"))])])])) + .size, + ).toBe(0); + // Three call sites. + const three = fn("caller", [loop([run(call("leaf")), run(call("leaf")), run(call("leaf"))])]); + expect(findLoopCallees(module([leaf, three])).size).toBe(0); + // A callee that calls another function. + const calling = fn("calling", [ret(call("leaf"))]); + const caller = fn("caller", [loop([run(call("calling"))])]); + // (leaf is then called outside a loop, from calling.) + expect(findLoopCallees(module([leaf, calling, caller])).size).toBe(0); + // Two loops, or more than 12 statements. + const twoLoops = fn("leaf", [loop([]), loop([]), ret(ref("x"))]); + expect(findLoopCallees(module([twoLoops, fn("caller", [loop([run(call("leaf"))])])])).size).toBe( + 0, + ); + const long = fn("leaf", [...Array.from({ length: 12 }, () => run(ref("x"))), ret(ref("x"))]); + expect(findLoopCallees(module([long, fn("caller", [loop([run(call("leaf"))])])])).size).toBe(0); + // Async functions and library modules. + const asyncLeaf = fn("leaf", [ret(ref("x"))], { async: true }); + expect(findLoopCallees(module([asyncLeaf, fn("caller", [loop([run(call("leaf"))])])])).size).toBe( + 0, + ); + const lib = { ...module([leaf, fn("caller", [loop([run(call("leaf"))])])]), lib: {} } as IrModule; + expect(findLoopCallees(lib).size).toBe(0); +}); diff --git a/packages/compiler/src/backend/llvm/loop-callees.ts b/packages/compiler/src/backend/llvm/loop-callees.ts new file mode 100644 index 000000000..a3d767dae --- /dev/null +++ b/packages/compiler/src/backend/llvm/loop-callees.ts @@ -0,0 +1,110 @@ +import type { IrExpr, IrFunction, IrModule, IrStmt } from "../../ir/ir.js"; +import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; + +/** Statements a callee may have, counting nested ones. */ +const MAX_STATEMENTS = 12; +/** Direct call sites a callee may have. */ +const MAX_CALL_SITES = 2; + +/** Expressions that run other code: a callee that has none calls nothing, + * so inlining it never inlines a call chain or a recursion. */ +const CALLS = new Set([ + "call", + "callValue", + "virtualCall", + "dynCall", + "dynInvoke", + "new", + "newValue", + "newPromise", + "ffiCall", + "awaitExpr", + "awaitUnionExpr", +]); + +function isLoop(s: IrStmt): boolean { + return s.kind === "for" || s.kind === "while" || s.kind === "doWhile" || s.kind === "forOf"; +} + +/** Small functions that are called only from inside loops: a per-element + * helper such as + * + * function pick(list: Gene[], rand: number): number { + * for (const gene of list) if (rand < gene.c) return gene.sc; + * return list[list.length - 1]!.sc; + * } + * + * called from a loop over the elements. After its loop versions and + * checks, such a function is often past LLVM's inline cost threshold, so + * every element pays a call, the spills around it, and checks that the + * caller's loop could hoist once inlined. + * + * A function qualifies when it is a synchronous, non-generator function + * with no captures and no own call (CALLS), has at most MAX_STATEMENTS + * statements and one loop, and has one to MAX_CALL_SITES direct call sites + * in the module, each inside a loop body of its caller. The emitter marks + * those calls `alwaysinline`. Functions the module exports as a library + * keep the cost model's decision. */ +export function findLoopCallees(mod: IrModule): Set { + const result = new Set(); + if (mod.lib !== undefined) return result; + const candidates = new Map(); + for (const fn of mod.functions) { + if (fn.async || fn.generator || fn.captures !== undefined || fn.classCaptures !== undefined) + continue; + let statements = 0; + let loops = 0; + let calls = false; + const expr = (e: IrExpr): boolean => { + if (CALLS.has(e.kind)) calls = true; + return !calls && everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => { + statements++; + if (isLoop(s)) loops++; + return statements <= MAX_STATEMENTS && loops <= 1 && everyStmtChild(s, expr, stmt); + }; + if (!fn.body.every(stmt) || calls) continue; + candidates.set(fn.name, fn); + } + if (candidates.size === 0) return result; + // Call sites of each candidate, and whether every one is inside a loop. + const sites = new Map(); + for (const fn of mod.functions) { + let depth = 0; + const expr = (e: IrExpr): boolean => { + if (e.kind === "call" && candidates.has(e.callee)) { + const site = sites.get(e.callee) ?? { count: 0, inLoops: true }; + site.count++; + if (depth === 0 || e.callee === fn.name) site.inLoops = false; + sites.set(e.callee, site); + } + return everyExprChild(e, expr, stmt); + }; + const stmt = (s: IrStmt): boolean => { + if (!isLoop(s)) return everyStmtChild(s, expr, stmt); + // A loop's header runs per iteration too, except a for's init. + if (s.kind === "for" && s.init) stmt(s.init); + depth++; + if (s.kind === "for") { + if (s.cond) expr(s.cond); + if (s.update) stmt(s.update); + s.body.every(stmt); + } else if (s.kind === "forOf") { + depth--; + expr(s.iterable); + depth++; + s.body.every(stmt); + } else if (s.kind === "while" || s.kind === "doWhile") { + expr(s.cond); + s.body.every(stmt); + } + depth--; + return true; + }; + fn.body.every(stmt); + } + for (const [name, site] of sites) + if (site.inLoops && site.count >= 1 && site.count <= MAX_CALL_SITES) result.add(name); + return result; +} diff --git a/packages/compiler/src/backend/llvm/region-groups.test.ts b/packages/compiler/src/backend/llvm/region-groups.test.ts new file mode 100644 index 000000000..1e4e8dd0c --- /dev/null +++ b/packages/compiler/src/backend/llvm/region-groups.test.ts @@ -0,0 +1,205 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../../index.js"; +import type { IrExpr, IrModule, IrType } from "../../ir/ir.js"; +import { analyzeClosedGroups, analyzeRegionGroups } from "./region-groups.js"; +import { children, type Node } from "./region-ir.js"; + +async function moduleFor(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-region-groups-")); + try { + const entry = join(dir, "main.ts"), + output = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath: output, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((item) => `${item.code}: ${item.message}`).join("\n")); + return deserializeModule(await readFile(output, "utf8")); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** The calls of `callee` in the module, in order. */ +function callsOf(mod: IrModule, callee: string): IrExpr[] { + const out: IrExpr[] = []; + const visit = (node: Node): void => { + if (node.kind === "call" && node.callee === callee) out.push(node); + const kids: Node[] = []; + children(node, kids); + for (const k of kids) visit(k); + }; + for (const fn of mod.functions) for (const s of fn.body) visit(s); + return out; +} + +// Every object and record is an arena type here, and every union a nullable +// pointer to one (the emitter decides both from layouts). +const everyObject = (t: IrType): boolean => + t.kind === "object" || t.kind === "record" || t.kind === "union"; + +const TREE = ` +class Node { + left: Node | null; + right: Node | null; + constructor(left: Node | null, right: Node | null) { + this.left = left; + this.right = right; + } + count(): number { + return 1 + (this.left === null ? 0 : this.left.count()) + (this.right === null ? 0 : this.right.count()); + } +} +function tree(depth: number): Node { + return depth === 0 ? new Node(null, null) : new Node(tree(depth - 1), tree(depth - 1)); +} +`; + +test("a builder whose result its caller only reads is a closed root", async () => { + const mod = await moduleFor(`${TREE} +let total = 0; +for (let d = 0; d < 4; d++) total += tree(d).count(); +console.log(total); +`); + const groups = analyzeRegionGroups(mod); + expect(groups.fresh.has("tree")).toBe(true); + const [root] = callsOf(mod, "tree").filter((c) => groups.roots.has(c)); + expect(root).toBeDefined(); + expect(groups.drops.get(root!)).toBeNull(); + expect(groups.reachable.has("tree")).toBe(true); + expect(analyzeClosedGroups(mod, groups, everyObject).has(root!)).toBe(true); +}); + +test("a result kept past its caller's frame is no root", async () => { + const mod = await moduleFor(`${TREE} +const kept: Node[] = []; +for (let d = 0; d < 4; d++) kept.push(tree(d)); +console.log(kept.length); +`); + const groups = analyzeRegionGroups(mod); + expect(callsOf(mod, "tree").some((c) => groups.roots.has(c))).toBe(false); +}); + +test("a function that stores what it allocates in a global is not leak-free", async () => { + const mod = await moduleFor(` +interface P { x: number } +const seen: P[] = []; +function note(x: number): void { + seen.push({ x }); +} +function sum(n: number): number { + let s = 0; + for (let i = 0; i < n; i++) { + const p = { x: i }; + s += p.x; + } + return s; +} +note(1); +console.log(sum(3), seen.length); +`); + const groups = analyzeRegionGroups(mod); + expect(groups.leakFree.has("note")).toBe(false); + expect(callsOf(mod, "note").some((c) => groups.calls.has(c))).toBe(false); + expect(groups.leakFree.has("sum")).toBe(true); +}); + +test("a leak-free call that returns no reference is a call region", async () => { + const mod = await moduleFor(` +interface V { x: number; y: number } +function add(a: V, b: V): V { + return { x: a.x + b.x, y: a.y + b.y }; +} +function walk(n: number): number { + let at: V = { x: 0, y: 0 }; + for (let i = 0; i < n; i++) at = add(at, { x: i, y: 1 }); + return at.x + at.y; +} +console.log(walk(10)); +`); + const groups = analyzeRegionGroups(mod); + const [region] = callsOf(mod, "walk"); + expect(groups.calls.has(region!)).toBe(true); +}); + +test("a graph that holds strings or outside objects is not closed", async () => { + const mod = await moduleFor(` +class Tag { + name: string; + constructor(name: string) { + this.name = name; + } +} +class Box { + tag: Tag; + owner: Tag | null; + constructor(tag: Tag, owner: Tag | null) { + this.tag = tag; + this.owner = owner; + } +} +const shared = new Tag("shared"); +function labelled(n: number): Box { + let box = new Box(new Tag("t" + n), null); + for (let i = 0; i < n; i++) box = new Box(new Tag("t" + i), null); + return box; +} +function owned(n: number): Box { + let box = new Box(new Tag("x"), shared); + for (let i = 0; i < n; i++) box = new Box(new Tag("x"), shared); + return box; +} +let n = 0; +for (let i = 0; i < 3; i++) n += labelled(i).tag.name.length + owned(i).tag.name.length; +console.log(n); +`); + const groups = analyzeRegionGroups(mod); + const closed = analyzeClosedGroups(mod, groups, everyObject); + const labelled = callsOf(mod, "labelled").filter((c) => groups.roots.has(c)); + const owned = callsOf(mod, "owned").filter((c) => groups.roots.has(c)); + expect(labelled.length).toBe(1); + expect(owned.length).toBe(1); + expect(closed.has(labelled[0]!)).toBe(false); + expect(closed.has(owned[0]!)).toBe(false); +}); + +test("a root bound in a loop body is dropped with that body's local", async () => { + const mod = await moduleFor(`${TREE} +function run(n: number): number { + let total = 0; + for (let i = 0; i < n; i++) { + const t = tree(i % 4); + total += t.count(); + } + return total; +} +console.log(run(10)); +`); + const groups = analyzeRegionGroups(mod); + const [root] = callsOf(mod, "tree").filter((c) => groups.roots.has(c)); + const run = mod.functions.find((f) => f.name === "run")!; + const local = groups.drops.get(root!); + expect(run.locals.find((l) => l.id === local)?.name).toBe("t"); +}); + +test("a root whose value a callee writes into is not closed", async () => { + const mod = await moduleFor(`${TREE} +function graft(n: Node, extra: Node): void { + n.left = extra; +} +const spare = new Node(null, null); +let total = 0; +for (let d = 0; d < 3; d++) { + const t = tree(d); + graft(t, spare); + total += t.count(); +} +console.log(total); +`); + const groups = analyzeRegionGroups(mod); + const closed = analyzeClosedGroups(mod, groups, everyObject); + for (const root of callsOf(mod, "tree").filter((c) => groups.roots.has(c))) + expect(closed.has(root)).toBe(false); +}); diff --git a/packages/compiler/src/backend/llvm/region-groups.ts b/packages/compiler/src/backend/llvm/region-groups.ts new file mode 100644 index 000000000..ce8354c92 --- /dev/null +++ b/packages/compiler/src/backend/llvm/region-groups.ts @@ -0,0 +1,1697 @@ +/* Region groups: call sites whose result is a fresh object graph the + * caller only borrows and then drops, so everything the call allocates + * can live in an arena that is dropped at once (arenas.ts). + * + * The group of a call is every object allocated while it runs. It can be + * dropped with its result when nothing outside reaches any member once the + * call returns except through the result. A function is leak-free when + * every value it allocates (an allocation, a fresh-producing builtin, or + * the result of a call) dies in its frame, is returned, or is stored into + * the graph of a value it returns, and every function it calls is + * leak-free too. Members may refer out of the group (to longer-lived + * objects, which an arena would release when it drops); nothing outside + * may refer in. A function is fresh when it is leak-free and returns only + * values it allocated. + * + * A root is a call of a fresh function (or a leak-free construction) whose + * value, and every reference read out of it, the caller keeps in its frame: + * deep parameter summaries, where reading a reference out of a parameter + * makes a value the callee must keep too. Everything is solved + * optimistically over the call graph and refined to a fixpoint. */ +import { + isRefCounted, + type IrExpr, + type IrFunction, + type IrModule, + type IrStmt, + type IrType, +} from "../../ir/ir.js"; +import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; +import { + ALLOCATIONS, + FRESH_ARRAY_METHODS, + argsOf, + children, + forEachNode, + indexOfNode, + isExpr, + keepsInFrame, + same, + refType, + transparent, + type Node, +} from "./region-ir.js"; +import { VirtualTargets } from "./reference-effects.js"; + +/** The module functions a virtual call can run, or null when unknown (or + * when `node` is no virtual call). */ +function virtualTargetsOf(vt: VirtualTargets, node: Node): readonly string[] | null { + return node.kind === "virtualCall" ? vt.targets(node.className, node.method) : null; +} + +export interface RegionGroups { + /** Fresh call and `new` expressions whose value the caller only borrows. */ + roots: Set; + /** Calls of leak-free functions that return no reference: everything + * they allocate is dead when they return. */ + calls: Set; + /** Where each root's arena is dropped: the local whose scope exit drops + * it, or null for the end of the statement. */ + drops: Map; + /** Functions whose allocations go to the open arena. */ + reachable: Set; + /** Roots and call regions whose groups are closed: every reference a + * member holds is to a member or an immortal value (analyzeClosedGroups + * fills it in). */ + closed: Set; + /** The locals that share a root's value in its function (empty for a + * value used in place), and the function of each root and call region. */ + rootLocals: Map>; + rootFunction: Map; + /** Functions whose results are fresh. */ + fresh: Set; + /** Functions that keep nothing they allocate except through their result. */ + leakFree: Set; + /** Functions that allocate, directly or through what they call. */ + allocates: Set; +} + +/** Projections whose result is a reference read out of the operand. + * Strings are leaves, not group members: reading one out of a member and + * keeping it anywhere keeps no member. */ +function projection(child: Node, parent: Node | undefined): boolean { + if (parent === undefined || !isExpr(parent) || !refType(parent.type)) return false; + switch (parent.kind) { + case "fieldGet": + case "recordGet": + case "recordKeyGet": + return same(parent.obj, child); + case "arrayGet": + return same(parent.arr, child); + case "unionKeyGet": + return same(parent.value, child); + case "mapIntrinsic": + case "setIntrinsic": + return ( + same(parent.receiver, child) && + (parent.method === "get" || parent.method === "iterKey" || parent.method === "iterValue") + ); + default: + return false; + } +} + +const LOOP_KINDS = new Set(["while", "doWhile", "for", "forOf"]); + +/** Collection methods that add their arguments to the receiver. */ +const ADDING_METHODS = new Set(["push", "unshift", "set", "add"]); + +export const throws = (e: IrExpr): boolean => e.kind === "libCall" && e.fn === "error.nodeThrow"; + +/** Receiver methods that read a collection or remove from it, and so add + * nothing to its graph. */ +const READ_RECEIVER = new Set([ + "get", + "has", + "delete", + "iterKey", + "iterValue", + "size", + "clear", + "length", + "at", + "includes", + "indexOf", + "lastIndexOf", + "indexEq", + "getNumber", + "join", + "nextPresent", + "slice", + "concatSpread", + "pop", + "shift", +]); + +/** The local a reference is read out of, through casts, checked reads and + * projections (`l.items[0]` → l), or the local itself. */ +function projectionBase(e: IrExpr, through?: (call: IrExpr) => IrExpr | null): string | null { + let at: IrExpr = e; + for (let i = 0; i < 64; i++) { + if (at.kind === "varRef") return at.localId; + if (at.kind === "call" && through !== undefined) { + const next = through(at); + if (next === null) return null; + at = next; + continue; + } + if (at.kind === "fieldGet" || at.kind === "recordGet" || at.kind === "recordKeyGet") + at = at.obj; + else if (at.kind === "arrayGet") at = at.arr; + else if (at.kind === "unionKeyGet") at = at.value; + else if ( + (at.kind === "mapIntrinsic" || at.kind === "setIntrinsic") && + (at.method === "get" || at.method === "iterKey" || at.method === "iterValue") + ) + at = at.receiver; + else if ( + at.kind === "unionNarrow" || + at.kind === "upcast" || + at.kind === "downcast" || + at.kind === "unionWrap" || + at.kind === "unionRetype" + ) + at = at.value; + else if (at.kind === "ternary" && throws(at.then)) at = at.else_; + else if (at.kind === "ternary" && throws(at.else_)) at = at.then; + else return null; + } + return null; +} + +/** The local whose graph a store into `target` writes (`l.f.items` → l). */ +export function storeTarget(target: IrExpr | undefined): string | null { + let at: IrExpr | undefined = target; + for (let i = 0; at !== undefined && i < 64; i++) { + if (at.kind === "varRef") return at.localId; + if (at.kind === "fieldGet" || at.kind === "recordGet" || at.kind === "recordKeyGet") + at = at.obj; + else if (at.kind === "arrayGet") at = at.arr; + else if (at.kind === "unionNarrow" || at.kind === "upcast" || at.kind === "downcast") + at = at.value; + // A checked read (`entry.skus` on `entry | undefined`) throws on one side. + else if (at.kind === "ternary" && throws(at.then)) at = at.else_; + else if (at.kind === "ternary" && throws(at.else_)) at = at.then; + else return null; + } + return null; +} + +/** An expression that produces a value it allocates (a group member). */ +export function source(e: Node): boolean { + if (!isExpr(e)) return false; + if (ALLOCATIONS.has(e.kind)) return true; + if (e.kind === "arrIntrinsic") return FRESH_ARRAY_METHODS.has(e.method); + if (e.kind === "strIntrinsic") return e.method === "split"; + if (e.kind === "libCall") return e.fn === "json.parse"; + if (e.kind === "dynCheck") return e.value.kind === "libCall" && e.value.fn === "json.parse"; + if (e.kind === "call" || e.kind === "callValue") return refType(e.type); + return false; +} + +/** Store-like uses: the value goes into the graph of `target`'s value. */ +export function storeOf(value: Node, parent: Node): IrExpr | undefined { + switch (parent.kind) { + case "fieldSet": + case "recordSet": + case "recordKeySet": + return same(parent.value, value) ? parent.obj : undefined; + case "arraySet": + return same(parent.value, value) ? parent.arr : undefined; + case "arrIntrinsic": + return (parent.method === "push" || parent.method === "unshift") && + indexOfNode(parent.args, value) >= 0 + ? parent.receiver + : undefined; + case "mapIntrinsic": + return parent.method === "set" && indexOfNode(parent.args, value) >= 0 + ? parent.receiver + : undefined; + case "setIntrinsic": + return parent.method === "add" && indexOfNode(parent.args, value) >= 0 + ? parent.receiver + : undefined; + default: + return undefined; + } +} + +/** Content positions: the value becomes part of the new value `parent`. */ +export function contentOf(value: Node, parent: Node): boolean { + switch (parent.kind) { + case "arrayLit": + case "recordLit": + case "new": + case "mapNew": + case "setNew": + return !same(parent, value); + case "recordClone": + return !same(parent.source, value); + default: + return false; + } +} + +/** Whether `parent` is a fresh array holding the elements of `child`, an + * array of references (`xs.slice()`, `a.concat(b)`): the elements share the + * fate of the copy. Strings are leaves, not members. */ +function copiesElements(child: Node, parent: Node | undefined): boolean { + if (parent === undefined || parent.kind !== "arrIntrinsic" || !isExpr(child)) return false; + if (!FRESH_ARRAY_METHODS.has(parent.method) && !SPREAD_METHODS.has(parent.method)) return false; + if ( + !same(parent.receiver, child) && + !(parent.method === "concatSpread" && indexOfNode(parent.args, child) >= 0) + ) + return false; + const t = child.type; + return t.kind === "array" && refType(t.elem); +} + +/** Array intrinsics that copy their array arguments' elements. */ +const SPREAD_METHODS = new Set(["pushSpread", "unshiftSpread", "concatSpread"]); + +/** Array intrinsics that call their function-typed arguments natively. */ +const CALLBACK_INTRINSICS = new Set(["sortDense", "sortValues"]); +const callbackArg = (child: Node, parent: Node | undefined): boolean => + parent !== undefined && + parent.kind === "arrIntrinsic" && + CALLBACK_INTRINSICS.has(parent.method) && + indexOfNode(parent.args, child) >= 0 && + isExpr(child) && + child.type.kind === "func"; + +type Use = "bound" | "kept" | "returned" | "stored" | "content" | "internal" | "thrown" | "escapes"; +interface UseInfo { + bound?: string; + /** For "internal": the index of the call in `parents`. */ + at?: number; + target?: string | null; +} + +export function analyzeRegionGroups(mod: IrModule): RegionGroups { + const fns = new Map(); + for (const fn of mod.functions) fns.set(fn.name, fn); + const vt = new VirtualTargets(mod.classes ?? []); + const suspends = (fn: IrFunction): boolean => fn.async === true || fn.generator !== undefined; + // Who refers to each function: by calls (direct, virtual, constructions), + // and by closures too. A function's summaries depend only on those of + // the functions it refers to, so the fixpoints below revisit just the + // referrers of a function whose summary changed. + const callers = new Map>(); + const referrers = new Map>(); + const directAlloc = new Set(); + for (const fn of mod.functions) { + const note = (map: Map>, name: string): void => { + let set = map.get(name); + if (set === undefined) map.set(name, (set = new Set())); + set.add(fn.name); + }; + forEachNode(fn.body, (node) => { + if (node.kind === "call") { + note(callers, node.callee); + note(referrers, node.callee); + } else if (node.kind === "virtualCall") + for (const t of virtualTargetsOf(vt, node) ?? []) { + note(callers, t); + note(referrers, t); + } + else if (node.kind === "new") { + const ctor = `%${node.className}.constructor`; + note(callers, ctor); + note(referrers, ctor); + } else if (node.kind === "closure") note(referrers, node.fnName); + if (source(node) && node.kind !== "call") directAlloc.add(fn.name); + }); + } + /** Runs `step` on the functions named, and again on the referrers of each + * whose summary it reports changed, until nothing changes. */ + const solve = ( + names: Iterable, + by: Map>, + step: (fn: IrFunction) => boolean, + ): void => { + const queue = [...names]; + const queued = new Set(queue); + while (queue.length > 0) { + const name = queue.pop()!; + queued.delete(name); + const fn = fns.get(name); + if (fn === undefined || !step(fn)) continue; + for (const r of by.get(name) ?? new Set()) + if (!queued.has(r)) { + queued.add(r); + queue.push(r); + } + } + }; + + // Deep summaries. + const deep = new Map>(); + // Internal parameters: ones a leak-free function only borrows, returns, or + // stores into the graphs of values it allocates (so an argument a caller + // allocated stays inside the caller's group). Optimistic, refined below. + const internal = new Map>(); + // Callback parameters (see below), and the function being analyzed. + const callbacks = new Map>(); + let current: IrFunction | null = null; + // While finding roots: a value passed to the function's own callback + // parameter leaves the frame (its callers' callbacks are unknown here). + let framePass = false; + const keeps = (child: Node, parent: Node | undefined): boolean => { + if (parent?.kind === "virtualCall") { + const index = indexOfNode(parent.args, child); + const targets = virtualTargetsOf(vt, parent); + return ( + index >= 0 && + targets !== null && + targets.length > 0 && + targets.every((t) => deep.get(t)?.has(index) === true) + ); + } + // A spread copies the array's elements into another array: they + // escape with it. + if ( + parent?.kind === "arrIntrinsic" && + SPREAD_METHODS.has(parent.method) && + !same(parent.receiver, child) && + isExpr(child) + ) { + const t = child.type; + return t.kind !== "array" || !refType(t.elem); + } + if (callbackArg(child, parent)) return true; + if (parent?.kind === "jsonStringify") return true; + // Comparisons only read their operands. + if (parent?.kind === "strCmp" || parent?.kind === "strEq") return true; + if (parent?.kind === "bin" && !refType(parent.type)) return true; + return keepsInFrame(child, parent, deep); + }; + + /** How the value of `node` (`parents` its ancestors) is used, after the + * expressions that pass it on (and, with `projections`, the reads of a + * reference out of it). */ + const useOf = (node: Node, parents: readonly Node[], projections: boolean, out: UseInfo): Use => { + let value: Node = node; + let at = parents.length - 1; + while ( + at >= 0 && + (transparent(value, parents[at]) || + copiesElements(value, parents[at]) || + (projections && projection(value, parents[at]))) + ) + value = parents[at--]!; + const p = at >= 0 ? parents[at] : undefined; + if (p === undefined) return "escapes"; + if ( + (p.kind === "varDecl" && p.init !== null && same(p.init, value)) || + (p.kind === "assign" && same(p.value, value)) + ) { + out.bound = p.localId; + return "bound"; + } + if (p.kind === "return") return "returned"; + if (p.kind === "throw") return "thrown"; + if (p.kind === "exprStmt") return "kept"; + const target = storeOf(value, p); + if (target !== undefined) { + out.target = storeTarget(target); + return "stored"; + } + if (contentOf(value, p)) return "content"; + if (keeps(value, p)) return "kept"; + if (p.kind === "callValue" && current !== null && !framePass) { + const i = indexOfNode(p.args, value) >= 0 ? paramIndex(current, p.callee) : -1; + if (i >= 0 && callbacks.get(current.name)!.has(i)) return "kept"; + } + if (p.kind === "call") { + const c = p; + const i = indexOfNode(c.args, value); + // The callee may hand the value to the callbacks it is passed, which + // must keep it too. + if ( + i >= 0 && + internal.get(c.callee)?.has(i) === true && + [...(callbacks.get(c.callee) ?? new Set())].every((j) => callbackClean(c.args[j])) + ) { + out.at = at; + return "internal"; + } + } + return "escapes"; + }; + + /** How the result of the call at `parents[at]` is used, through calls + * that keep it in their own results (`report(select(load()))`). */ + const resultUse = (at: number, parents: readonly Node[], out: UseInfo): Use => { + let index = at; + for (let i = 0; i < 64; i++) { + const info: UseInfo = {}; + const use = useOf(parents[index]!, parents.slice(0, index), false, info); + if (use !== "internal") { + // Field by field: an Object.assign onto a record makes the + // self-hosted compiler read class and record properties dynamically. + if (info.bound !== undefined) out.bound = info.bound; + if (info.at !== undefined) out.at = info.at; + if (info.target !== undefined) out.target = info.target; + return use; + } + index = info.at!; + } + return "escapes"; + }; + /** `fn`'s body in preorder, with each node's parent (-1 for statements + * of the body), the uses of locals that can hold references, and the + * sources: walked once, for escapingLocals' many passes. */ + interface Flat { + nodes: Node[]; + parent: number[]; + refs: number[]; + sources: number[]; + } + const flats = new Map(); + const flatOf = (fn: IrFunction): Flat => { + let flat = flats.get(fn.name); + if (flat !== undefined) return flat; + flat = { nodes: [], parent: [], refs: [], sources: [] }; + const f = flat; + // Parents come before their children, so each node's parent is indexed. + const index = new Map(); + forEachNode(fn.body, (node, parent) => { + const i = f.nodes.length; + index.set(node, i); + f.nodes.push(node); + f.parent.push(parent === undefined ? -1 : index.get(parent)!); + if (source(node)) f.sources.push(i); + if (node.kind === "varRef" && (refType(node.type) || node.type.kind === "string")) + f.refs.push(i); + }); + flats.set(fn.name, flat); + return flat; + }; + /** The ancestors of node `i` of `flat`, outermost first, into `out`. */ + const ancestors = (flat: Flat, i: number, out: Node[]): Node[] => { + out.length = 0; + for (let p = flat.parent[i]!; p >= 0; p = flat.parent[p]!) out.push(flat.nodes[p]!); + out.reverse(); + return out; + }; + /** Locals of `fn` whose value (or a reference read out of it) escapes. + * With `members` (the locals bound to group members), a value may also be + * returned, be content of a new value, or be stored into a member's + * graph. */ + const escapingLocals = ( + fn: IrFunction, + members: Set | null, + classes?: Map, + ): Set => { + const out = new Set(); + const add = (id: string): void => { + out.add(id); + }; + const own = new Set(fn.locals.map((l) => l.id)); + const group = new Map(); + const find = (id: string): string => { + let r = id; + while (group.has(r) && group.get(r) !== r) r = group.get(r)!; + return r; + }; + const unite = (a: string, b: string): void => { + const ra = find(a), + rb = find(b); + if (ra !== rb) group.set(ra, rb); + }; + // Copies: locals bound to a local or to a fresh copy of one (`slice`). + // A store of a value read out of a copy into the graph of another copy + // adds no reference into the group, unless a member went into a copy. + const copy = new Map(); + const findCopy = (id: string): string => { + let r = id; + while (copy.has(r) && copy.get(r) !== r) r = copy.get(r)!; + return r; + }; + const uniteCopy = (a: string, b: string): void => { + const ra = findCopy(a), + rb = findCopy(b); + if (ra !== rb) copy.set(ra, rb); + }; + const filled = new Set(); + const selfStores: [string, string][] = []; + const flat = flatOf(fn); + const parents: Node[] = []; + if (members !== null) + for (const i of flat.sources) { + const node = flat.nodes[i]!; + ancestors(flat, i, parents); + // A copy class holds something new once a value it did not have + // goes into it: stored into its graph, or bound to one of its locals + // (`visited = new Map(); this.visited = visited`). A `slice` of a + // local in the class holds only what the class held. + const info: UseInfo = {}; + const use = useOf(node, parents, false, info); + if (use === "stored" && info.target) filled.add(info.target); + if (use === "bound" && !(node.kind === "arrIntrinsic" && node.method === "slice")) + filled.add(info.bound!); + } + // Uses of locals that can hold references (a value without references + // cannot keep a member). + for (const i of flat.refs) { + const node = flat.nodes[i]!; + if (node.kind !== "varRef") continue; + ancestors(flat, i, parents); + const id = node.localId; + const info: UseInfo = {}; + const use = useOf(node, parents, true, info); + if (use === "bound") { + if (own.has(info.bound!)) { + unite(id, info.bound!); + uniteCopy(id, info.bound!); + } else add(id); + } else if (use === "internal" && members === null) { + // The callee keeps the value only in its result: the value stays + // in this frame while the result does (a module global needs no + // frame to hold it). + const result: UseInfo = {}; + const r = resultUse(info.at!, parents, result); + if (r === "bound" && own.has(result.bound!)) { + if (own.has(id)) unite(id, result.bound!); + } else if (r !== "kept") add(id); + } else if (use === "thrown") { + // A thrown value leaves the frame and outlives the group. + add(id); + } else if (use === "returned" || use === "content" || use === "internal") { + if (members === null) add(id); + } else if (use === "stored") { + if (members?.has(id) && info.target) filled.add(info.target); + if ( + members !== null && + info.target !== null && + info.target !== undefined && + !members.has(info.target) && + own.has(info.target) + ) + selfStores.push([id, info.target]); + else if ( + members === null || + info.target === null || + info.target === undefined || + !members.has(info.target) + ) + add(id); + } else if (use === "escapes") add(id); + // `const copy = id.slice()`. + // An optional chain on a Node, or a field read in the same condition + // as its kind test, tests every Node kind in turn: the kind is read + // after an undefined check, and the fields in a statement of their own. + const p = parents[parents.length - 1]; + if (members !== null && p !== undefined && p.kind === "arrIntrinsic") { + if (same(p.receiver, node) && p.method === "slice") { + const copied: UseInfo = {}; + if (useOf(p, parents.slice(0, -1), false, copied) === "bound" && own.has(copied.bound!)) + uniteCopy(id, copied.bound!); + } + } + } + const filledCopies = new Set([...filled].map(findCopy)); + for (const [id, target] of selfStores) + if (findCopy(id) !== findCopy(target) || filledCopies.has(findCopy(id))) add(id); + for (const local of fn.locals) if (local.boxed === true) out.add(local.id); + const roots = new Set(); + for (const id of out) roots.add(find(id)); + for (const local of fn.locals) if (roots.has(find(local.id))) out.add(local.id); + if (classes !== undefined) for (const local of fn.locals) classes.set(local.id, find(local.id)); + return out; + }; + + const solveDeep = (): void => { + for (const fn of mod.functions) { + const safe = new Set(); + if (!suspends(fn)) + fn.params.forEach((param, i) => { + const local = fn.locals.find((l) => l.id === param.localId); + if ((refType(param.type) || param.type.kind === "string") && local?.boxed !== true) + safe.add(i); + }); + deep.set(fn.name, safe); + } + solve(fns.keys(), referrers, (fn) => { + const safe = deep.get(fn.name)!; + if (safe.size === 0) return false; + const esc = escapingLocals(fn, null); + let changed = false; + fn.params.forEach((param, i) => { + if (safe.has(i) && esc.has(param.localId)) { + safe.delete(i); + changed = true; + } + }); + return changed; + }); + }; + solveDeep(); + + // Leak-free and fresh functions, optimistic. + const leakFree = new Set(); + const fresh = new Set(); + for (const fn of mod.functions) { + if (suspends(fn)) continue; + leakFree.add(fn.name); + if (refType(fn.returnType)) fresh.add(fn.name); + const inner = new Set(); + fn.params.forEach((param, i) => { + const local = fn.locals.find((l) => l.id === param.localId); + if ((refType(param.type) || param.type.kind === "string") && local?.boxed !== true) + inner.add(i); + }); + internal.set(fn.name, inner); + } + let internalChanged = false; + const unwrap = (e: Node): Node => { + let at: Node = e; + for (let i = 0; i < 16; i++) { + if ( + at.kind === "unionWrap" || + at.kind === "upcast" || + at.kind === "downcast" || + at.kind === "unionRetype" + ) + at = at.value; + else break; + } + return at; + }; + // Callback parameters: parameters a function calls (or passes on to a + // callback parameter). Each argument for one must be a leak-free closure, + // or a callback parameter of the caller. + const reassignedMemo = new Map>(); + const reassignedIn = (fn: IrFunction): Set => { + let out = reassignedMemo.get(fn.name); + if (out !== undefined) return out; + out = new Set(); + for (const node of flatOf(fn).nodes) if (node.kind === "assign") out.add(node.localId); + for (const l of fn.locals) if (l.boxed === true) out.add(l.id); + reassignedMemo.set(fn.name, out); + return out; + }; + const paramIndex = (fn: IrFunction, e: Node): number => { + const u = unwrap(e); + if (u.kind !== "varRef") return -1; + const id = u.localId; + if (reassignedIn(fn).has(id)) return -1; + return fn.params.findIndex((p) => p.localId === id); + }; + for (const fn of mod.functions) callbacks.set(fn.name, new Set()); + solve(fns.keys(), callers, (fn) => { + const set = callbacks.get(fn.name)!; + const before = set.size; + for (const node of flatOf(fn).nodes) { + if (node.kind === "callValue") { + const i = paramIndex(fn, node.callee); + if (i >= 0) set.add(i); + } else if (node.kind === "arrIntrinsic" && CALLBACK_INTRINSICS.has(node.method)) { + for (const a of node.args) + if (a.type.kind === "func") { + const i = paramIndex(fn, a); + if (i >= 0) set.add(i); + } + } else if (node.kind === "call") { + for (const j of callbacks.get(node.callee) ?? new Set()) { + const arg = node.args[j]; + const i = arg === undefined ? -1 : paramIndex(fn, arg); + if (i >= 0) set.add(i); + } + } + } + return set.size !== before; + }); + const callbackClean = (arg: IrExpr | undefined): boolean => { + if (arg === undefined) return true; + const u = unwrap(arg); + if (u.kind === "closure") { + // The callee passes the callback values it borrows (or that came from + // its caller): its parameters must stay internal. + const name = u.fnName; + const target = fns.get(name); + if (target === undefined || !leakFree.has(name)) return false; + const inner = internal.get(name)!; + return target.params.every((p, i) => !refType(p.type) || inner.has(i)); + } + if (current === null || framePass) return false; + const i = paramIndex(current, u); + return i >= 0 && callbacks.get(current.name)!.has(i); + }; + const calleesClean = (node: Node): boolean => { + if (node.kind === "call") { + if (!leakFree.has(node.callee)) return false; + for (const j of callbacks.get(node.callee) ?? new Set()) + if (!callbackClean(node.args[j])) return false; + return true; + } + if (node.kind === "callValue" && current !== null) { + const i = paramIndex(current, node.callee); + return i >= 0 && callbacks.get(current.name)!.has(i); + } + if (node.kind === "arrIntrinsic" && CALLBACK_INTRINSICS.has(node.method)) + return node.args.every((a) => a.type.kind !== "func" || callbackClean(a)); + if (node.kind === "virtualCall") { + const t = virtualTargetsOf(vt, node); + return t !== null && t.length > 0 && t.every((x) => leakFree.has(x)); + } + if ( + node.kind === "callValue" || + node.kind === "newValue" || + node.kind === "dynCall" || + node.kind === "dynInvoke" + ) + return false; + if (node.kind === "new") return leakFree.has(`%${node.className}.constructor`); + return true; + }; + /** The values `e` can produce, through casts and conditionals. */ + const leaves = (e: Node, out: Node[] = []): Node[] => { + const u = unwrap(e); + if (u.kind === "ternary") { + // A side that throws produces no value. + if (!throws(u.then)) leaves(u.then, out); + if (!throws(u.else_)) leaves(u.else_, out); + } else out.push(u); + return out; + }; + /** A source whose value is a new group member (a call result only when + * the callee is fresh). */ + const memberSource = (u: Node): boolean => + source(u) && u.kind !== "callValue" && (u.kind !== "call" || fresh.has(u.callee)); + /** What analyze reads of `fn` that no summary changes: the values bound + * to each local, the stores into locals' graphs, and the locals that are + * not closed (see analyze). */ + const bodyFacts = new Map< + string, + { bindings: Map; stores: [string, Node][]; open: Set } + >(); + const factsOf = ( + fn: IrFunction, + ): { bindings: Map; stores: [string, Node][]; open: Set } => { + const memo = bodyFacts.get(fn.name); + if (memo !== undefined) return memo; + const bindings = new Map(); + const scan = (node: Node): void => { + if (node.kind === "varDecl" || node.kind === "assign") { + const v = node.kind === "varDecl" ? node.init : node.value; + if (v !== null) bindings.set(node.localId, [...(bindings.get(node.localId) ?? []), v]); + } + const kids: Node[] = []; + children(node, kids); + for (const k of kids) scan(k); + }; + for (const s of fn.body) scan(s); + const stores: [string, Node][] = []; + const open = new Set(); + { + const parents: Node[] = []; + const walk = (node: Node): void => { + const parent = parents[parents.length - 1]; + if (parent !== undefined) { + const t = storeOf(node, parent); + if (t !== undefined) { + const target = storeTarget(t); + if (target !== null) stores.push([target, node]); + } + } + if (node.kind === "varRef") { + const id = node.localId; + let value: Node = node; + let at = parents.length - 1; + while (at >= 0 && (transparent(value, parents[at]) || projection(value, parents[at]))) + value = parents[at--]!; + const p = parents[at]; + if (p !== undefined && isExpr(p)) { + const x = p; + if ( + x.kind === "call" || + x.kind === "virtualCall" || + x.kind === "callValue" || + x.kind === "new" || + x.kind === "newValue" || + x.kind === "dynCall" || + x.kind === "dynInvoke" || + x.kind === "libCall" + ) + open.add(id); + else if ( + (x.kind === "arrIntrinsic" || + x.kind === "mapIntrinsic" || + x.kind === "setIntrinsic") && + same(x.receiver, value) && + !READ_RECEIVER.has(x.method) && + !x.method.startsWith("iter") && + storeOf(x.args[0] ?? x, x) === undefined + ) + open.add(id); + } + } + const kids: Node[] = []; + children(node, kids); + parents.push(node); + for (const kid of kids) walk(kid); + parents.pop(); + }; + for (const st of fn.body) walk(st); + } + const facts = { bindings, stores, open }; + bodyFacts.set(fn.name, facts); + return facts; + }; + const analyze = (fn: IrFunction): { leakFree: boolean; fresh: boolean } => { + current = fn; + // Member locals: bound only to sources, other members, or non-references. + const members = new Set(fn.locals.filter((l) => l.boxed !== true).map((l) => l.id)); + for (const p of fn.params) members.delete(p.localId); + // A constructor's `this` is the object its `new` allocated. + const ctor = fn.name.endsWith(".constructor") && fn.params.length > 0; + const thisId = ctor ? fn.params[0]!.localId : null; + // Closed members: members whose graph holds only members and leaves + // (values without a graph: numbers, strings). A reference read out of a + // closed member is a member too. A local is not closed when it is passed + // to a call (which may store into its graph) or used as the receiver of + // a method that adds what this analysis does not see. + const { bindings, stores, open } = factsOf(fn); + const closed = new Set([...members].filter((id) => !open.has(id) && id !== thisId)); + const leaf = (u: Node): boolean => isExpr(u) && !refType(u.type); + const closedSource = (u: Node): boolean => { + if (u.kind === "libCall") return u.fn === "json.parse"; + if (u.kind === "dynCheck") return closedSource(u.value); + if (u.kind === "strIntrinsic") return u.method === "split"; + if ( + u.kind !== "arrayLit" && + u.kind !== "recordLit" && + u.kind !== "mapNew" && + u.kind !== "setNew" + ) + return false; + const kids: Node[] = []; + children(u, kids); + return kids.every((k) => !isExpr(k) || closedValue(k)); + }; + const closedValue = (v: Node): boolean => + leaves(v).every((u) => { + if (leaf(u) || closedSource(u)) return true; + const base = isExpr(u) ? projectionBase(u) : null; + return base !== null && closed.has(base); + }); + for (let again = true; again;) { + again = false; + for (const id of [...members]) { + const list = bindings.get(id) ?? []; + if (id === thisId) continue; + const ok = + list.length > 0 && + list.every((v) => + leaves(v).every((u) => { + if (memberSource(u) || leaf(u)) return true; + if (u.kind === "varRef") return members.has(u.localId); + const base = isExpr(u) ? projectionBase(u) : null; + return base !== null && closed.has(base); + }), + ); + if (!ok) { + members.delete(id); + closed.delete(id); + again = true; + } + } + for (const id of [...closed]) { + const ok = + members.has(id) && + (bindings.get(id) ?? []).every(closedValue) && + stores.every(([target, value]) => target !== id || closedValue(value)); + if (!ok) { + closed.delete(id); + again = true; + } + } + // A local bound to a reference read out of another shares its graph. + for (const [id, list] of bindings) + if (!closed.has(id)) + for (const v of list) + for (const u of leaves(v)) { + const base = isExpr(u) ? projectionBase(u) : null; + if (base !== null && base !== id && closed.delete(base)) again = true; + } + } + if (thisId !== null) members.add(thisId); + // Members that escape (other than by return or into a member's graph) + // are no longer members; repeat, since a store into a former member's + // graph now escapes too. + let esc = escapingLocals(fn, members); + for (let again = true; again;) { + again = false; + for (const id of esc) if (members.delete(id)) again = true; + if (again) esc = escapingLocals(fn, members); + } + // Every source must die, be returned, or go into a member's graph; a + // constructor's `this` must stay a member. + const inner = internal.get(fn.name)!; + fn.params.forEach((param, i) => { + if (inner.has(i) && esc.has(param.localId)) { + inner.delete(i); + internalChanged = true; + } + }); + let clean = thisId === null || members.has(thisId); + let returnsFresh = true; + const parents: Node[] = []; + const walk = (node: Node): void => { + if (!calleesClean(node)) clean = false; + if (source(node)) { + const info: UseInfo = {}; + const use = useOf(node, parents, false, info); + if (use === "bound") { + if (esc.has(info.bound!) || !fn.locals.some((l) => l.id === info.bound)) { + clean = false; + } + } else if (use === "stored") { + if (info.target === null || info.target === undefined || !members.has(info.target)) { + clean = false; + } + } else if (use === "escapes") { + clean = false; + } else if (use === "thrown") { + // A thrown value outlives the group, so it may hold no member and + // nothing allocated with it (a message string is fine). + const kids: Node[] = []; + children(node, kids); + for (const k of kids) + if ( + isExpr(k) && + refType(k.type) && + leaves(k).some((u) => { + if (source(u)) return true; + const base = isExpr(u) ? projectionBase(u) : null; + return base !== null && members.has(base); + }) + ) + clean = false; + } + } + if (node.kind === "return") { + const v = node.value; + if (v !== null && refType(v.type)) { + for (const u of leaves(v)) { + if (!(memberSource(u) || (u.kind === "varRef" && members.has(u.localId)))) + returnsFresh = false; + if (u.kind === "call" && !fresh.has(u.callee)) returnsFresh = false; + } + } + } + const kids: Node[] = []; + children(node, kids); + parents.push(node); + for (const kid of kids) walk(kid); + parents.pop(); + }; + for (const s of fn.body) walk(s); + current = null; + return { leakFree: clean, fresh: clean && returnsFresh }; + }; + solve(leakFree, referrers, (fn) => { + if (!leakFree.has(fn.name)) return false; + internalChanged = false; + const r = analyze(fn); + if (!r.leakFree) { + leakFree.delete(fn.name); + fresh.delete(fn.name); + internal.get(fn.name)!.clear(); + return true; + } + return (!r.fresh && fresh.delete(fn.name)) || internalChanged; + }); + + // With internal parameters known, a value passed to one stays in the + // frame while the call's result does: solve the deep summaries again. + solveDeep(); + // Functions that allocate, directly or through what they call. + const allocates = new Set(); + solve(directAlloc, callers, (fn) => { + if (allocates.has(fn.name)) return false; + allocates.add(fn.name); + return true; + }); + // Roots, and call regions: calls of leak-free functions that allocate and + // return no reference, so all they allocate is dead when they return. + const roots = new Set(); + const calls = new Set(); + /** Whether the operands of `node` (evaluated inside its group) are clean: + * every call in them is leak-free, no library call returns a reference + * (the runtime may keep it: `process.argv`'s cached array), and every value they + * allocate goes to `node` as an internal or borrowed argument, or into the + * value of another operand. */ + const operandsClean = (node: Node, parents: readonly Node[]): boolean => { + const stack: Node[] = [...parents, node]; + let ok = true; + const visit = (n: Node): void => { + if (!ok) return; + if (!same(n, node)) { + if (!calleesClean(n)) ok = false; + if (n.kind === "libCall" && n.fn !== "json.parse" && refType(n.type)) ok = false; + // `JSON.parse(text) as T`: the parse is the checked value itself. + const checked = node.kind === "dynCheck" && same(node.value, n); + if (source(n) && !checked) { + const info: UseInfo = {}; + const use = useOf(n, stack, false, info); + const at = info.at ?? -1; + if ( + !(use === "kept" || use === "content" || (use === "internal" && at >= parents.length)) + ) + ok = false; + } + } + const kids: Node[] = []; + children(n, kids); + const inner = !same(n, node); + if (inner) stack.push(n); + for (const k of kids) visit(k); + if (inner) stack.pop(); + }; + visit(node); + return ok; + }; + const drops = new Map(); + const rootLocals = new Map>(); + const rootFunction = new Map(); + framePass = true; + for (const fn of mod.functions) { + if (suspends(fn)) continue; + current = fn; + const classes = new Map(); + const esc = escapingLocals(fn, null, classes); + const own = new Set(fn.locals.map((l) => l.id)); + const paths = declarationPaths(fn); + /** The local of `bound`'s class whose scope holds every other one's, + * or undefined when no one does. */ + const classOf = (bound: string): string[] => { + const cls = classes.get(bound); + return fn.locals.filter((l) => classes.get(l.id) === cls).map((l) => l.id); + }; + const dropLocal = (bound: string): string | undefined => { + const members = classOf(bound); + const path = (id: string): readonly number[] => paths.get(id) ?? []; + return members.find((m) => + members.every((o) => { + const a = path(m); + const b = path(o); + return a.length <= b.length && a.every((x, i) => b[i] === x); + }), + ); + }; + const parents: Node[] = []; + const walk = (node: Node): void => { + const isRoot = + isExpr(node) && + ((node.kind === "call" && fresh.has(node.callee)) || + (node.kind === "new" && leakFree.has(`%${node.className}.constructor`)) || + ((node.kind === "libCall" || node.kind === "dynCheck" || node.kind === "strIntrinsic") && + source(node)) || + (node.kind === "arrIntrinsic" && source(node))); + const callbacksClean = + node.kind !== "call" || + [...(callbacks.get(node.callee) ?? new Set())].every((j) => + callbackClean(node.args[j]), + ); + const clean = + (isRoot || node.kind === "call") && callbacksClean && operandsClean(node, parents); + if ( + node.kind === "call" && + clean && + !refType(node.type) && + // A constructor (a `super(...)` call) stores into `this`, which its + // caller holds: it is leak-free only as part of a `new`. + !node.callee.endsWith(".constructor") && + leakFree.has(node.callee) && + allocates.has(node.callee) + ) { + calls.add(node); + rootFunction.set(node, fn); + } + if (isRoot && clean) { + const info: UseInfo = {}; + let use = useOf(node, parents, false, info); + if (use === "internal") use = resultUse(info.at!, parents, info); + rootFunction.set(node, fn); + if (use === "kept") { + roots.add(node); + drops.set(node, null); + rootLocals.set(node, new Set()); + } else if (use === "bound" && own.has(info.bound!) && !esc.has(info.bound!)) { + const local = dropLocal(info.bound!); + // An assignment in a loop would add a group per iteration to a + // list that is dropped only when the local's scope exits. + let binding = parents.length - 1; + while ( + binding >= 0 && + parents[binding]!.kind !== "varDecl" && + parents[binding]!.kind !== "assign" + ) + binding--; + const reassignedInLoop = + binding >= 0 && + parents[binding]!.kind === "assign" && + parents.slice(0, binding).some((p) => LOOP_KINDS.has(p.kind)); + if (local !== undefined && !reassignedInLoop) { + roots.add(node); + drops.set(node, local); + rootLocals.set(node, new Set(classOf(info.bound!))); + } + } + } + const kids: Node[] = []; + children(node, kids); + parents.push(node); + for (const kid of kids) walk(kid); + parents.pop(); + }; + for (const s of fn.body) walk(s); + } + current = null; + // Functions that run inside an arena: what roots and call regions call, + // and what those call, with the closures they pass. + const reachable = new Set(); + const queue: string[] = []; + const reach = (name: string): void => { + if (fns.has(name) && !reachable.has(name)) { + reachable.add(name); + queue.push(name); + } + }; + const callees = (node: Node): void => { + if (node.kind === "call") reach(node.callee); + else if (node.kind === "virtualCall") + for (const t of virtualTargetsOf(vt, node) ?? []) reach(t); + else if (node.kind === "new") reach(`%${node.className}.constructor`); + else if (node.kind === "closure") reach(node.fnName); + }; + const scanAll = (node: Node, visit: (n: Node) => void): void => { + visit(node); + const kids: Node[] = []; + children(node, kids); + for (const k of kids) scanAll(k, visit); + }; + for (const r of [...roots, ...calls]) scanAll(r, callees); + while (queue.length > 0) { + const fn = fns.get(queue.pop()!)!; + for (const st of fn.body) scanAll(st, callees); + } + const closed = new Set(); + return { + roots, + calls, + drops, + rootLocals, + rootFunction, + reachable, + closed, + fresh, + leakFree, + allocates, + }; +} + +/** For each local declared in `fn`, the statement lists that enclose its + * declaration, outermost first (the body included); a declaration inside an + * expression (a sequence's statements) is nested in the statement that + * holds it. Parameters and locals without a declaration have none: the + * function's own scope. A `for…of` variable counts as declared in the list + * holding the loop. */ +function declarationPaths(fn: IrFunction): Map { + const out = new Map(); + // Each statement list (and each expression holding one) gets an id. + let next = 0; + const walk = (list: readonly IrStmt[], path: readonly number[]): void => { + if (list.length === 0) return; + const here = [...path, next++]; + for (const stmt of list) { + if (stmt.kind === "varDecl" || stmt.kind === "forOf") out.set(stmt.localId, here); + for (const inner of stmtLists(stmt)) walk(inner, here); + // Statements inside the statement's expressions, one level deeper. + const visit = (e: IrExpr): boolean => { + if (e.kind === "seqExpr") walk(e.stmts, [...here, next++]); + everyExprChild(e, visit, () => true); + return true; + }; + everyStmtChild(stmt, visit, () => true); + } + }; + walk(fn.body, []); + return out; +} + +/** The statement lists directly inside `stmt`. */ +function stmtLists(stmt: IrStmt): readonly (readonly IrStmt[])[] { + switch (stmt.kind) { + case "if": + return stmt.else_ === null ? [stmt.then] : [stmt.then, stmt.else_]; + case "while": + case "doWhile": + case "for": + case "forOf": + case "block": + return [stmt.body]; + case "switch": + return stmt.cases.map((c) => c.body); + case "tryCatch": + return [stmt.tryBody, stmt.catchBody ?? [], stmt.finallyBody ?? []]; + default: + return []; + } +} + +/** + * The roots and call regions whose groups are closed: every reference an + * arena object of the group holds is to an arena object of the group or an + * immortal value, so dropping the arena releases nothing. + * + * `arenaType(t)` says whether a value of type `t` is a plain reference to + * an object the arena-aware constructors allocate (no box around it). + * + * A function is closed when every reference it stores (into a field, an + * element, a collection, or the content of a new object) is closed: + * - a value without references, an immortal literal, or null/undefined; + * - an object of an arena type it allocates, or the result of a call of a + * closed function whose results are closed; + * - a local bound only to closed values, or a reference of an arena type + * read out of one; + * - a constructor's `this`; + * - a parameter, or a reference read out of one: its callers must pass a + * closed value (the function's stored parameters). + * Every function it calls must be closed and get closed values for their + * stored parameters. A root's group is then closed when its callee is, + * its arguments for stored parameters are closed in its caller, and the + * caller stores only closed values into the root's graph afterwards (there, + * only values without references, literals, and references read out of the + * root's own locals are closed: the caller's own allocations are on the + * heap). Solved optimistically to a fixpoint. + */ +export function analyzeClosedGroups( + mod: IrModule, + groups: RegionGroups, + arenaType: (t: IrType) => boolean, + /** Whether an allocation or call site's objects take the arena. */ + kept: (e: Node) => boolean = () => true, +): Set { + const fns = new Map(); + for (const fn of mod.functions) fns.set(fn.name, fn); + const vt = new VirtualTargets(mod.classes ?? []); + const closedFn = new Set([...groups.reachable].filter((f) => groups.leakFree.has(f))); + const closedFresh = new Set([...closedFn].filter((f) => groups.fresh.has(f))); + const stored = new Map>(); + const storedOf = (name: string): Set => stored.get(name) ?? new Set(); + const leavesOf = (e: IrExpr, out: IrExpr[] = []): IrExpr[] => { + let at = e; + for (let i = 0; i < 16; i++) + if ( + at.kind === "unionWrap" || + at.kind === "upcast" || + at.kind === "downcast" || + at.kind === "unionRetype" + ) + at = at.value; + else break; + if (at.kind === "ternary") { + if (!throws(at.then)) leavesOf(at.then, out); + if (!throws(at.else_)) leavesOf(at.else_, out); + } else out.push(at); + return out; + }; + // Functions that return only references read out of one parameter (or + // null/undefined), such as the checked element read `%arr.idxOr`: a call + // of one reads out of that argument. + const projects = new Map(); + for (const fn of mod.functions) { + const assigned = new Set(); + let index: number | null = null; + let ok = true; + const visit = (node: Node): void => { + if (!ok) return; + if (node.kind === "assign") assigned.add(node.localId); + if (node.kind === "return") { + const v = node.value; + if (v === null) ok = false; + else + for (const u of leavesOf(v)) { + if (u.kind === "unitLit") continue; + const base = projectionBase(u); + const i = base === null ? -1 : fn.params.findIndex((p) => p.localId === base); + if (i < 0 || (index !== null && index !== i)) ok = false; + else index = i; + } + } + const kids: Node[] = []; + children(node, kids); + for (const k of kids) visit(k); + }; + if (!refType(fn.returnType)) continue; + for (const st of fn.body) visit(st); + if (ok && index !== null && !assigned.has(fn.params[index]!.localId)) + projects.set(fn.name, index); + } + const through = (call: IrExpr): IrExpr | null => { + if (call.kind !== "call") return null; + const i = projects.get(call.callee); + return i === undefined ? null : (call.args[i] ?? null); + }; + /** Whether `e` (in `fn`, `own` its closed locals, `inArena` whether its + * allocations are arena objects) is a closed value; parameters it reads + * go into `params`. */ + const closedValue = ( + e: Node, + fn: IrFunction, + own: ReadonlySet, + inArena: boolean, + params: Set, + ): boolean => { + if (!isExpr(e)) return false; + const t = e.type; + if (t.kind === "string") return e.kind === "strLit"; + // Any other counted value (a typed array, a promise) is a heap object. + if (!refType(t) && !isRefCounted(t)) return true; + if (!arenaType(t)) return false; + return leavesOf(e).every((u) => { + if (u.kind === "unitLit") return true; + if (u.kind === "recordLit" || u.kind === "new" || u.kind === "arrayLit") + return inArena && arenaType(u.type) && kept(u); + if (u.kind === "call" && !projects.has(u.callee)) + return inArena && closedFresh.has(u.callee) && arenaType(u.type) && kept(u); + const base = projectionBase(u, through); + if (base === null) return false; + const i = fn.params.findIndex((p) => p.localId === base); + if (i >= 0) { + if (i === 0 && fn.name.endsWith(".constructor") && u.kind === "varRef") return true; + params.add(i); + return arenaType(u.type); + } + return own.has(base) && arenaType(u.type); + }); + }; + /** Every reference store and content position in `fn`'s body, and every + * call with the arguments its callee stores. */ + const siteMemo = new Map(); + const sites = (fn: IrFunction): { value: Node; target: string | null }[] => { + const memo = siteMemo.get(fn.name); + if (memo !== undefined) return memo; + const out: { value: Node; target: string | null }[] = []; + siteMemo.set(fn.name, out); + const walk = (node: Node, parent: Node | undefined): void => { + if (parent !== undefined && isExpr(node)) { + const target = storeOf(node, parent); + if (target !== undefined) out.push({ value: node, target: storeTarget(target) }); + else if (contentOf(node, parent) && parent.kind !== "new") + out.push({ value: node, target: null }); + } + const kids: Node[] = []; + children(node, kids); + for (const k of kids) walk(k, node); + }; + for (const st of fn.body) walk(st, undefined); + return out; + }; + const bindingMemo = new Map>(); + const bindingsOf = (fn: IrFunction): Map => { + const memo = bindingMemo.get(fn.name); + if (memo !== undefined) return memo; + const out = new Map(); + bindingMemo.set(fn.name, out); + const scan = (node: Node): void => { + if (node.kind === "varDecl" || node.kind === "assign") { + const v = node.kind === "varDecl" ? node.init : node.value; + if (v !== null) out.set(node.localId, [...(out.get(node.localId) ?? []), v]); + } + // A loop variable holds elements read out of the iterable. + if (node.kind === "forOf") + out.set(node.localId, [...(out.get(node.localId) ?? []), node.iterable]); + const kids: Node[] = []; + children(node, kids); + for (const k of kids) scan(k); + }; + for (const st of fn.body) scan(st); + return out; + }; + // Written parameters: those whose graph a function may store into, itself + // or through what it calls (any function, not only arena-aware ones). + const writes = new Map>(); + /** The parameter whose graph `local` reaches into, through bindings to + * references read out of it, or -1. */ + const paramOf = (fn: IrFunction, bindings: Map, local: string): number => { + let at = local; + for (let i = 0; i < 16; i++) { + const p = fn.params.findIndex((q) => q.localId === at); + if (p >= 0) return p; + const list = bindings.get(at); + if (list === undefined || list.length !== 1) return -1; + const base = projectionBase(list[0]!); + if (base === null || base === at) return -1; + at = base; + } + return -1; + }; + // Each function's own writes, and the parameters it passes to calls: a + // parameter passed where the callee writes is written too. + const passed = new Map< + string, + { param: number; callees: readonly string[] | null; index: number }[] + >(); + const writers = new Map>(); + // Only what the functions holding roots call, transitively, is asked. + const asked = new Set(); + { + const queue = [ + ...new Set( + [...groups.roots, ...groups.calls].flatMap((r) => groups.rootFunction.get(r)?.name ?? []), + ), + ]; + for (const name of queue) asked.add(name); + while (queue.length > 0) { + const fn = fns.get(queue.pop()!); + if (fn === undefined) continue; + const visit = (n: Node): void => { + const targets = + n.kind === "call" + ? [n.callee] + : n.kind === "virtualCall" + ? (virtualTargetsOf(vt, n) ?? []) + : []; + for (const t of targets) + if (fns.has(t) && !asked.has(t)) { + asked.add(t); + queue.push(t); + } + const kids: Node[] = []; + children(n, kids); + for (const k of kids) visit(k); + }; + for (const st of fn.body) visit(st); + } + } + for (const fn of mod.functions) { + if (!asked.has(fn.name)) continue; + const bindings = bindingsOf(fn); + const set = new Set(); + for (const { target } of sites(fn)) + if (target !== null) { + const i = paramOf(fn, bindings, target); + if (i >= 0) set.add(i); + } + const list: { param: number; callees: readonly string[] | null; index: number }[] = []; + const visit = (n: Node): void => { + const args = argsOf(n); + if (args !== null) { + const callees = + n.kind === "call" + ? [n.callee] + : n.kind === "virtualCall" + ? (virtualTargetsOf(vt, n) ?? []) + : null; + args.forEach((a, j) => { + const base = projectionBase(a); + const i = base === null ? -1 : paramOf(fn, bindings, base); + if (i < 0) return; + if (callees === null || callees.length === 0 || callees.some((c) => !fns.has(c))) + set.add(i); + else { + list.push({ param: i, callees, index: j }); + for (const c of callees) { + let w = writers.get(c); + if (w === undefined) writers.set(c, (w = new Set())); + w.add(fn.name); + } + } + }); + } + const kids: Node[] = []; + children(n, kids); + for (const k of kids) visit(k); + }; + for (const st of fn.body) visit(st); + writes.set(fn.name, set); + passed.set(fn.name, list); + } + { + const queue = [...asked]; + const queued = new Set(queue); + while (queue.length > 0) { + const name = queue.pop()!; + queued.delete(name); + const set = writes.get(name)!; + const before = set.size; + for (const { param, callees, index } of passed.get(name)!) + if (callees!.some((c) => writes.get(c)?.has(index) === true)) set.add(param); + if (set.size !== before) + for (const r of writers.get(name) ?? new Set()) + if (!queued.has(r)) { + queued.add(r); + queue.push(r); + } + } + } + /** The locals of `fn` bound only to closed values. */ + const closedLocals = ( + fn: IrFunction, + inArena: boolean, + seed?: ReadonlySet, + ): Set => { + const bindings = bindingsOf(fn); + const own = new Set( + fn.locals + .filter((l) => l.boxed !== true && !fn.params.some((p) => p.localId === l.id)) + .map((l) => l.id), + ); + for (const id of seed ?? new Set()) own.add(id); + for (let again = true; again;) { + again = false; + for (const id of [...own]) { + if (seed?.has(id) === true) continue; + const list = bindings.get(id) ?? []; + if (!list.every((v) => closedValue(v, fn, own, inArena, new Set()))) { + own.delete(id); + again = true; + } + } + } + return own; + }; + /** Whether the calls in `node`'s subtree are of closed functions with + * closed arguments for their stored parameters. */ + const callsClosed = ( + node: Node, + fn: IrFunction, + own: ReadonlySet, + inArena: boolean, + params: Set, + ): boolean => { + let ok = true; + const visit = (n: Node): void => { + if (!ok) return; + if (n.kind === "call" || n.kind === "new" || n.kind === "virtualCall") { + const targets = + n.kind === "call" + ? [n.callee] + : n.kind === "new" + ? [`%${n.className}.constructor`] + : (virtualTargetsOf(vt, n) ?? []); + if (targets.length === 0) ok = false; + const shift = n.kind === "new" ? 1 : 0; + for (const t of targets) { + if (!closedFn.has(t)) ok = false; + for (const j of storedOf(t)) { + const arg = n.args[j - shift]; + if (arg !== undefined && !closedValue(arg, fn, own, inArena, params)) ok = false; + } + } + } else if (n.kind === "callValue" || n.kind === "dynCall" || n.kind === "dynInvoke") + ok = false; + else if ( + (n.kind === "arrIntrinsic" || n.kind === "mapIntrinsic" || n.kind === "setIntrinsic") && + !READ_RECEIVER.has(n.method) && + !ADDING_METHODS.has(n.method) && + n.args.some((a) => refType(a.type) || isRefCounted(a.type)) + ) + ok = false; + const kids: Node[] = []; + children(n, kids); + for (const k of kids) visit(k); + }; + visit(node); + return ok; + }; + for (let changed = true; changed;) { + changed = false; + for (const name of [...closedFn]) { + const fn = fns.get(name)!; + const params = new Set(); + const own = closedLocals(fn, true); + let ok = sites(fn).every(({ value }) => closedValue(value, fn, own, true, params)); + for (const st of fn.body) if (ok) ok = callsClosed(st, fn, own, true, params); + let returnsClosed = true; + const scanReturns = (node: Node): void => { + if (node.kind === "return") { + const v = node.value; + if (v !== null && !closedValue(v, fn, own, true, params)) returnsClosed = false; + } + const kids: Node[] = []; + children(node, kids); + for (const k of kids) scanReturns(k); + }; + for (const st of fn.body) scanReturns(st); + const before = storedOf(name); + if ([...params].some((i) => !before.has(i))) { + stored.set(name, new Set([...before, ...params])); + changed = true; + } + if (!ok) { + closedFn.delete(name); + closedFresh.delete(name); + changed = true; + } else if (!returnsClosed && closedFresh.delete(name)) changed = true; + } + } + const out = new Set(); + for (const root of [...groups.roots, ...groups.calls]) { + const fn = groups.rootFunction.get(root); + if (fn === undefined) continue; + const locals = groups.rootLocals.get(root) ?? new Set(); + const bindings = bindingsOf(fn); + /** Whether `local` reaches into the root's graph: one of its locals, + * or bound (a loop variable included) to a reference read out of one. */ + const intoRoot = (local: string, depth = 0): boolean => + locals.has(local) || + (depth < 16 && + (bindings.get(local) ?? []).some((v) => { + const base = projectionBase(v); + return base !== null && base !== local && intoRoot(base, depth + 1); + })); + // In the caller only the root's own locals and what is read out of them + // are closed references. + const params = new Set(); + let ok = callsClosed(root, fn, locals, false, params) && params.size === 0; + // After the arena closes, a call that is passed the root's value (or a + // reference read out of it) must not write into it. + const passes = (n: Node, parent: Node | undefined): void => { + if (!ok) return; + if (parent !== undefined && isExpr(n)) { + const args = argsOf(parent); + const j = args === null ? -1 : indexOfNode(args, n); + const base = projectionBase(n); + if (j >= 0 && refType(n.type) && (same(n, root) || (base !== null && intoRoot(base)))) { + const callees = + parent.kind === "call" + ? [parent.callee] + : parent.kind === "virtualCall" + ? (virtualTargetsOf(vt, parent) ?? []) + : []; + if (callees.length === 0 || callees.some((c) => writes.get(c)?.has(j) !== false)) + ok = false; + } + } + const kids: Node[] = []; + children(n, kids); + for (const k of kids) passes(k, n); + }; + if (ok && !groups.calls.has(root)) for (const st of fn.body) passes(st, undefined); + if (ok && locals.size > 0) + ok = sites(fn).every( + ({ value, target }) => + target === null || + !intoRoot(target) || + (closedValue(value, fn, locals, false, params) && params.size === 0), + ); + if (ok) out.add(root); + } + return out; +} diff --git a/packages/compiler/src/backend/llvm/region-ir.ts b/packages/compiler/src/backend/llvm/region-ir.ts new file mode 100644 index 000000000..405558f06 --- /dev/null +++ b/packages/compiler/src/backend/llvm/region-ir.ts @@ -0,0 +1,271 @@ +/* IR helpers shared by the region analyses (region-groups.ts): node + * traversal, allocation kinds, and how a use of a value passes it on or + * keeps it in the frame. */ +import type { IrExpr, IrStmt, IrType } from "../../ir/ir.js"; +import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; + +export type Node = IrExpr | IrStmt; + +/** The kinds of heap allocation sites counted. */ +export const ALLOCATIONS = new Set([ + "arrayLit", + "arrayNewLen", + "mapNew", + "setNew", + "new", + "recordLit", + "recordClone", + "closure", +]); + +/** Array methods that return a fresh array without keeping the receiver. */ +export const FRESH_ARRAY_METHODS = new Set([ + "slice", + "concatSpread", + "toReversed", + "toSpliced", + "with", + "withUndefined", + "flatCopy", + "flatOne", + "toSortedPrimitive", + "splice", +]); +/** Array methods that return the receiver itself. */ +export const RECEIVER_RESULT = new Set([ + "reverse", + "fill", + "fillUndefined", + "copyWithin", + "sortPrimitive", +]); +/** Array methods whose arguments are only read (compared, copied from). */ +export const READ_ARGS = new Set([ + "includes", + "indexOf", + "indexEq", + "getNumber", + "join", + "slice", + "concatSpread", + "pushSpread", + "unshiftSpread", + "nextPresent", + "length", +]); + +export function refType(t: IrType): boolean { + return ( + t.kind === "array" || + t.kind === "map" || + t.kind === "set" || + t.kind === "object" || + t.kind === "record" || + t.kind === "func" || + t.kind === "union" + ); +} + +export function children(node: Node, out: Node[]): void { + const push = (child: Node): boolean => { + out.push(child); + return true; + }; + if ("type" in node && (node as IrExpr).type !== undefined && isExpr(node)) + everyExprChild(node as IrExpr, push, push); + else everyStmtChild(node as IrStmt, push, push); +} + +const STMT_KINDS = new Set([ + "varDecl", + "assign", + "exprStmt", + "if", + "while", + "doWhile", + "switch", + "for", + "arraySet", + "arraySetLength", + "arraySetUndefined", + "arrayDelete", + "bytesSet", + "forOf", + "return", + "fieldSet", + "recordSet", + "recordKeySet", + "recordKeyDelete", + "break", + "continue", + "block", + "throw", + "runtimeFence", + "rethrow", + "tryCatch", +]); +export function isExpr(node: Node): node is IrExpr { + return !STMT_KINDS.has(node.kind); +} + +/** Whether `a` and `b` are the same node. */ +export function same(a: Node | undefined, b: Node): boolean { + return a !== undefined && a === b; +} + +/** Whether `parent` passes `child`'s value on as its own value (a branch of + * a conditional, a union wrap or narrow, a class up- or downcast, the + * result of a sequence), so the value's fate is the parent's. */ +export function transparent(child: Node, parent: Node | undefined): boolean { + if (parent === undefined) return false; + switch (parent.kind) { + case "ternary": + return same(parent.then, child) || same(parent.else_, child); + case "nullish": + case "logical": + return true; + case "seqExpr": + return same(parent.result, child); + case "unionWrap": + case "unionRetype": + case "unionNarrow": + case "upcast": + case "downcast": + return same(parent.value, child); + default: + return false; + } +} + +/** Whether a use of a local's value as `child` of `parent` keeps it in the + * frame (given the current parameter summaries). */ +export function keepsInFrame( + child: Node, + parent: Node | undefined, + summaries: ReadonlyMap>, +): boolean { + if (parent === undefined) return false; + switch (parent.kind) { + case "exprStmt": + return true; + case "arrayGet": + case "arrayHas": + case "arrayState": + case "arraySetUndefined": + case "arrayDelete": + case "arraySetLength": + case "arraySet": + return same(parent.arr, child); + case "fieldGet": + case "recordGet": + case "recordKeyGet": + case "recordHas": + case "recordOvfHas": + case "recordOvfKeys": + case "fieldSet": + case "recordSet": + case "recordKeySet": + case "recordKeyDelete": + return same(parent.obj, child); + case "forOf": + return same(parent.iterable, child); + case "arrIntrinsic": + if (same(parent.receiver, child)) return !RECEIVER_RESULT.has(parent.method); + return READ_ARGS.has(parent.method); + case "mapIntrinsic": + if (same(parent.receiver, child)) return true; + return parent.method === "get" || parent.method === "has" || parent.method === "delete"; + case "setIntrinsic": + if (same(parent.receiver, child)) return true; + return parent.method === "has" || parent.method === "delete"; + case "bin": + return parent.op === "===" || parent.op === "!=="; + case "toBool": + case "unary": + case "unionIsTag": + case "unionEq": + return true; + case "unionDisc": + case "unionKeyGet": + return same(parent.value, child); + case "callValue": + return same(parent.callee, child); + case "call": { + const index = indexOfNode(parent.args, child); + return index >= 0 && summaries.get(parent.callee)?.has(index) === true; + } + default: + return false; + } +} + +/** The index of `node` in `list`, or -1. */ +export function indexOfNode(list: readonly IrExpr[], node: Node): number { + for (let i = 0; i < list.length; i++) if (same(list[i], node)) return i; + return -1; +} + +/** The arguments of a node that takes them (calls, constructions, + * intrinsics), or null. */ +export function argsOf(node: Node): readonly IrExpr[] | null { + switch (node.kind) { + case "call": + case "callValue": + case "virtualCall": + case "new": + case "newValue": + case "ffiCall": + case "dynCall": + case "dynInvoke": + case "intrinsic": + case "libCall": + case "arrIntrinsic": + case "bytesIntrinsic": + case "mapIntrinsic": + case "setIntrinsic": + case "strIntrinsic": + case "regexIntrinsic": + return node.args; + default: + return null; + } +} + +const LOOP_STMTS = new Set(["while", "doWhile", "for", "forOf"]); + +/** Calls `visit(node, parent, loops)` for every node of `body`, parents + * before their children (siblings in no set order), where `loops` counts + * the loops around `node`. Allocates nothing per node. */ +export function forEachNode( + body: readonly IrStmt[], + visit: (node: Node, parent: Node | undefined, loops: number) => void, +): void { + const nodes: Node[] = []; + const parents: (Node | undefined)[] = []; + const depths: number[] = []; + let parent: Node | undefined = undefined; + let depth = 0; + const pushExpr = (child: IrExpr): boolean => { + nodes.push(child); + parents.push(parent); + depths.push(depth); + return true; + }; + const pushStmt = (child: IrStmt): boolean => { + nodes.push(child); + parents.push(parent); + depths.push(depth); + return true; + }; + for (const st of body) pushStmt(st); + while (nodes.length > 0) { + const node = nodes.pop()!; + const above = parents.pop(); + const loops = depths.pop()!; + visit(node, above, loops); + parent = node; + depth = loops + (LOOP_STMTS.has(node.kind) ? 1 : 0); + if (isExpr(node)) everyExprChild(node, pushExpr, pushStmt); + else everyStmtChild(node, pushExpr, pushStmt); + } +} diff --git a/packages/compiler/src/backend/llvm/region-sites.test.ts b/packages/compiler/src/backend/llvm/region-sites.test.ts new file mode 100644 index 000000000..97fa94577 --- /dev/null +++ b/packages/compiler/src/backend/llvm/region-sites.test.ts @@ -0,0 +1,223 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../../index.js"; +import type { IrExpr, IrModule } from "../../ir/ir.js"; +import { analyzeRegionGroups } from "./region-groups.js"; +import { children, type Node } from "./region-ir.js"; +import { planRegionSites } from "./region-sites.js"; + +async function moduleFor(source: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-region-sites-")); + try { + const entry = join(dir, "main.ts"), + output = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath: output, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((item) => `${item.code}: ${item.message}`).join("\n")); + return deserializeModule(await readFile(output, "utf8")); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +/** The nodes in function `fn` matching `pick`, in order. */ +function nodesIn(mod: IrModule, fn: string, pick: (e: IrExpr) => boolean): IrExpr[] { + const out: IrExpr[] = []; + const visit = (node: Node): void => { + if (pick(node as IrExpr)) out.push(node as IrExpr); + const kids: Node[] = []; + children(node, kids); + for (const k of kids) visit(k); + }; + for (const s of mod.functions.find((f) => f.name === fn)!.body) visit(s); + return out; +} + +const calls = (callee: string) => (e: IrExpr) => e.kind === "call" && e.callee === callee; +const records = (e: IrExpr) => e.kind === "recordLit"; + +function plan(mod: IrModule) { + const groups = analyzeRegionGroups(mod); + const sites = planRegionSites(mod, groups); + return { groups, sites }; +} + +const TREE = ` +class Node { + left: Node | null; + right: Node | null; + constructor(left: Node | null, right: Node | null) { + this.left = left; + this.right = right; + } + count(): number { + return 1 + (this.left === null ? 0 : this.left.count()) + (this.right === null ? 0 : this.right.count()); + } +} +function tree(depth: number): Node { + return depth === 0 ? new Node(null, null) : new Node(tree(depth - 1), tree(depth - 1)); +} +`; + +test("a recursive builder's root takes an arena for every node", async () => { + const mod = await moduleFor(`${TREE} +let total = 0; +for (let d = 0; d < 4; d++) total += tree(d).count(); +console.log(total); +`); + const { groups, sites } = plan(mod); + const roots = nodesIn(mod, "%init.0", calls("tree")).filter((c) => groups.roots.has(c)); + expect(roots.length).toBe(1); + for (const n of nodesIn(mod, "tree", (e) => e.kind === "new")) + expect(sites.arenaSites.has(n)).toBe(true); + for (const c of nodesIn(mod, "tree", calls("tree"))) expect(sites.arenaSites.has(c)).toBe(true); +}); + +test("a builder of a bounded number of objects is no root", async () => { + const mod = await moduleFor(` +interface V { x: number; y: number } +function add(a: V, b: V): V { + return { x: a.x + b.x, y: a.y + b.y }; +} +let at: V = { x: 0, y: 0 }; +let sum = 0; +for (let i = 0; i < 10; i++) { + const step = add(at, { x: i, y: 1 }); + sum += step.x; +} +console.log(sum, at.y); +`); + const { groups } = plan(mod); + expect(groups.roots.size).toBe(0); + expect(groups.calls.size).toBe(0); + expect(groups.reachable.size).toBe(0); +}); + +test("a list a builder appends to in a loop takes the arena", async () => { + const mod = await moduleFor(` +interface Row { id: number; score: number } +function rows(n: number): Row[] { + const out: Row[] = []; + for (let i = 0; i < n; i++) { + const row: Row = { id: i, score: i * 2 }; + out.push(row); + } + return out; +} +let total = 0; +for (let k = 0; k < 3; k++) total += rows(10 + k).length; +console.log(total); +`); + const { groups, sites } = plan(mod); + expect(nodesIn(mod, "%init.0", calls("rows")).some((c) => groups.roots.has(c))).toBe(true); + const [row] = nodesIn(mod, "rows", records); + expect(sites.arenaSites.has(row!)).toBe(true); + const [out] = nodesIn(mod, "rows", (e) => e.kind === "arrayLit"); + expect(sites.arenaSites.has(out!)).toBe(true); +}); + +test("values that replace elements or die in the loop take the heap", async () => { + const mod = await moduleFor(` +interface V { x: number; y: number } +function vec(x: number, y: number): V { + return { x, y }; +} +function add(a: V, b: V): V { + return { x: a.x + b.x, y: a.y + b.y }; +} +function simulate(n: number, steps: number): number { + const points: V[] = []; + for (let i = 0; i < n; i++) points.push(vec(i, i)); + for (let s = 0; s < steps; s++) + for (let i = 0; i < n; i++) points[i] = add(points[i]!, vec(0, 1)); + let sum = 0; + for (const p of points) sum += p.x + p.y; + return sum; +} +console.log(simulate(100, 10)); +`); + const { groups, sites } = plan(mod); + // The list's elements are replaced, so it keeps nothing: no group grows. + expect(groups.calls.size).toBe(0); + expect(groups.roots.size).toBe(0); + expect(sites.arenaSites.size).toBe(0); +}); + +test("a value reassigned in a loop and returned takes the heap", async () => { + const mod = await moduleFor(` +class C { + re: number; + im: number; + constructor(re: number, im: number) { + this.re = re; + this.im = im; + } + times(o: C): C { + return new C(this.re * o.re - this.im * o.im, this.re * o.im + this.im * o.re); + } +} +function power(c: C, n: number): C { + let z = new C(1, 0); + for (let i = 0; i < n; i++) z = z.times(c); + return z; +} +let s = 0; +for (let k = 0; k < 5; k++) s += power(new C(0.5, 0.5), k).re; +console.log(s); +`); + const { groups } = plan(mod); + expect(nodesIn(mod, "%init.0", calls("power")).some((c) => groups.roots.has(c))).toBe(false); +}); + +test("calls in an arena-aware function whose values die take the heap", async () => { + const mod = await moduleFor(` +interface P { x: number; next: P | null } +function weight(p: P): number { + return p.x * 2; +} +function probe(x: number): P { + return { x, next: null }; +} +function chain(n: number): P { + let head: P = { x: 0, next: null }; + const first = head; + for (let i = 1; i < n; i++) { + const node: P = { x: weight(probe(i)), next: null }; + head.next = node; + head = node; + } + return first; +} +let total = 0; +for (let k = 0; k < 3; k++) total += chain(5).x; +console.log(total); +`); + const { groups, sites } = plan(mod); + expect(nodesIn(mod, "%init.0", calls("chain")).some((c) => groups.roots.has(c))).toBe(true); + const [probe] = nodesIn(mod, "chain", calls("probe")); + expect(sites.arenaSites.has(probe!)).toBe(false); + expect(sites.heapCalls.has(probe!)).toBe(true); + for (const node of nodesIn(mod, "chain", records)) expect(sites.arenaSites.has(node)).toBe(true); +}); + +test("a root assigned in a loop to a local declared outside it is no root", async () => { + const mod = await moduleFor(`${TREE} +function run(n: number): number { + let t = tree(1); + let total = t.count(); + for (let d = 0; d < n; d++) { + t = tree(d); + total += t.count(); + } + return total; +} +console.log(run(6)); +`); + const { groups } = plan(mod); + const [first, inLoop] = nodesIn(mod, "run", calls("tree")); + expect(groups.roots.has(first!)).toBe(true); + expect(groups.roots.has(inLoop!)).toBe(false); +}); diff --git a/packages/compiler/src/backend/llvm/region-sites.ts b/packages/compiler/src/backend/llvm/region-sites.ts new file mode 100644 index 000000000..95167524b --- /dev/null +++ b/packages/compiler/src/backend/llvm/region-sites.ts @@ -0,0 +1,349 @@ +/* Which allocations of a region group take its arena (arenas.ts), and + * which roots and call regions are worth one. + * + * An arena pays off for objects that live until the group is dropped: it + * saves their frees and teardown, but holds their memory until the drop. + * A temporary that dies early is cheaper on the heap, which reuses its + * memory at once. So in an arena-aware function an allocation takes the + * arena only when its value is kept by the group (its fate is the arena): + * - it is returned; + * - it is content of a new value that is kept; + * - it is stored, other than by an element write, into the graph of a + * local that is kept (`out.push(row)`, `last.next = member`); + * - it is bound to a local that is kept. + * A local is kept when its value is returned or stored or put into + * something kept; a constructor's `this` is kept (its object follows its + * `new`); and in the callee of a call region, a local declared at the top + * of the body lives until the region drops. An element write (`a[i] = v`) + * usually replaces a value, so its value takes the heap. + * + * Every other allocation takes the heap, and so does everything a call + * allocates when the call's value is not kept: the emitter suspends the + * arena around such a call (heapCalls). Heap objects are always safe in a + * group; only arena objects need the group's proof. + * + * A root or call region is worth an arena when its group grows: a kept + * allocation runs in a loop, or a kept value comes from a recursive call + * or from a call of a function whose group grows. Other groups are small, + * and opening and dropping their arenas costs more than their frees. */ +import type { IrFunction, IrModule } from "../../ir/ir.js"; +import { contentOf, source, storeOf, storeTarget, type RegionGroups } from "./region-groups.js"; +import { + argsOf, + children, + indexOfNode, + isExpr, + refType, + same, + transparent, + type Node, +} from "./region-ir.js"; +import { VirtualTargets } from "./reference-effects.js"; + +export interface RegionSites { + /** Allocation sites (and calls) whose value the group keeps. */ + arenaSites: Set; + /** Calls, constructions and closure calls in arena-aware functions whose + * value the group does not keep and whose callee may allocate: the + * arena is suspended while they run. */ + heapCalls: Set; +} + +const LOOPS = new Set(["while", "doWhile", "for", "forOf"]); + +/** Collection methods that remove what their receiver holds. */ +const REMOVALS = new Set(["pop", "shift", "splice", "delete", "clear"]); + +/** The local whose graph `node` drops values from (an element write, a + * length write, a delete, a removing method), or null. */ +function removalTarget(node: Node): string | null { + switch (node.kind) { + case "arraySet": + case "arraySetLength": + case "arraySetUndefined": + case "arrayDelete": + return storeTarget(node.arr); + case "recordKeyDelete": + return storeTarget(node.obj); + case "arrIntrinsic": + case "mapIntrinsic": + case "setIntrinsic": + return REMOVALS.has(node.method) ? storeTarget(node.receiver) : null; + default: + return null; + } +} + +/** The functions a call-like node may run, or null when unknown. */ +function targetsOf(vt: VirtualTargets, node: Node): readonly string[] | null { + if (node.kind === "call") return [node.callee]; + if (node.kind === "new") return [`%${node.className}.constructor`]; + if (node.kind === "virtualCall") { + const t = vt.targets(node.className, node.method); + return t !== null && t.length > 0 ? t : null; + } + return null; +} + +const callLike = (node: Node): boolean => + node.kind === "call" || + node.kind === "new" || + node.kind === "virtualCall" || + node.kind === "callValue" || + node.kind === "newValue"; + +/** The functions that run inside the arenas of `roots`: what they call, + * transitively, with the closures they pass. */ +export function reachableFrom(mod: IrModule, roots: Iterable): Set { + const fns = new Map(mod.functions.map((f) => [f.name, f])); + const vt = new VirtualTargets(mod.classes ?? []); + const reachable = new Set(); + const queue: string[] = []; + const visit = (node: Node): void => { + const reach = (name: string): void => { + if (fns.has(name) && !reachable.has(name)) { + reachable.add(name); + queue.push(name); + } + }; + if (node.kind === "closure") reach(node.fnName); + else for (const t of targetsOf(vt, node) ?? []) reach(t); + const kids: Node[] = []; + children(node, kids); + for (const k of kids) visit(k); + }; + for (const r of roots) visit(r); + while (queue.length > 0) for (const st of fns.get(queue.pop()!)!.body) visit(st); + return reachable; +} + +interface FnSites { + /** Kept call-like nodes and allocations, with whether each is in a loop. */ + kept: Map; + /** Call-like nodes whose value is not kept. */ + dropped: Node[]; +} + +/** Classifies the allocations and calls of `fn`. */ +function classify(fn: IrFunction, topLocalsKept: boolean): FnSites { + const locals = new Set(fn.locals.map((l) => l.id)); + // Alias classes: a local bound to another local shares its fate. + const parent = new Map(); + const find = (id: string): string => { + let r = id; + while (parent.has(r)) r = parent.get(r)!; + return r; + }; + const unite = (a: string, b: string): void => { + const ra = find(a), + rb = find(b); + if (ra !== rb) parent.set(ra, rb); + }; + const kept = new Set(); + const keep = (id: string): boolean => { + const r = find(id); + if (kept.has(r)) return false; + kept.add(r); + return true; + }; + const isKept = (id: string): boolean => kept.has(find(id)); + // Kept from the start (applied once the alias classes are known). + const seeds: string[] = []; + if (fn.name.endsWith(".constructor") && fn.params.length > 0) seeds.push(fn.params[0]!.localId); + if (topLocalsKept) { + const types = new Map(fn.locals.map((l) => [l.id, l.type])); + for (const st of fn.body) + if (st.kind === "varDecl" && refType(types.get(st.localId) ?? { kind: "void" })) + seeds.push(st.localId); + } + + // Uses of locals and of value-producing nodes, recorded once. + type Fate = + | { kind: "kept" } + | { kind: "local"; id: string } + | { kind: "target"; id: string } + | { kind: "node"; node: Node } + | { kind: "heap" }; + const fateOf = (node: Node, parents: readonly Node[]): Fate => { + let value: Node = node; + let at = parents.length - 1; + while (at >= 0 && transparent(value, parents[at])) value = parents[at--]!; + const p = at >= 0 ? parents[at] : undefined; + if (p === undefined) return { kind: "heap" }; + if (p.kind === "return") return { kind: "kept" }; + if ( + (p.kind === "varDecl" && p.init !== null && same(p.init, value)) || + (p.kind === "assign" && same(p.value, value)) + ) { + const id = p.localId; + // A local assigned in a loop usually drops its old value. + if (!locals.has(id) || (p.kind === "assign" && depth > 0)) return { kind: "heap" }; + return { kind: "local", id }; + } + const target = storeOf(value, p); + if (target !== undefined) { + if (p.kind === "arraySet") return { kind: "heap" }; + const id = storeTarget(target); + return id !== null && locals.has(id) ? { kind: "target", id } : { kind: "heap" }; + } + if (isExpr(p) && contentOf(value, p)) return { kind: "node", node: p }; + // An argument of a call that returns a reference follows the call's + // value: the callee may put it into its result. + const args = argsOf(p); + if ( + callLike(p) && + isExpr(p) && + refType(p.type) && + args !== null && + indexOfNode(args, value) >= 0 + ) + return { kind: "node", node: p }; + return { kind: "heap" }; + }; + + // Lossy locals: their graphs drop values (element writes, removals), so + // what is stored into them is not kept. + const lossy = new Set(); + const fates = new Map(); + const loops = new Map(); + const localUses: [string, Fate][] = []; + const parents: Node[] = []; + let depth = 0; + const walk = (node: Node): void => { + const removal = removalTarget(node); + if (removal !== null) lossy.add(removal); + if (source(node) || callLike(node)) { + fates.set(node, fateOf(node, parents)); + loops.set(node, depth > 0); + } else if (node.kind === "varRef" && locals.has(node.localId) && refType(node.type)) { + const f = fateOf(node, parents); + if (f.kind !== "heap") localUses.push([node.localId, f]); + } + const loop = LOOPS.has(node.kind); + if (loop) depth++; + const kids: Node[] = []; + children(node, kids); + parents.push(node); + for (const k of kids) walk(k); + parents.pop(); + if (loop) depth--; + }; + for (const st of fn.body) walk(st); + + // A local bound to another local joins its class. + for (const [id, f] of localUses) if (f.kind === "local") unite(id, f.id); + for (const id of [...lossy]) lossy.add(find(id)); + for (const id of seeds) keep(id); + const keptFate = (f: Fate, seen = 0): boolean => { + if (seen > 64) return false; + switch (f.kind) { + case "kept": + return true; + case "local": + return isKept(f.id); + case "target": + return isKept(f.id) && !lossy.has(find(f.id)); + case "node": { + const g = fates.get(f.node); + return g !== undefined && keptFate(g, seen + 1); + } + case "heap": + return false; + } + }; + for (let changed = true; changed;) { + changed = false; + for (const [id, f] of localUses) + if (f.kind !== "local" && keptFate(f) && keep(id)) changed = true; + } + const out: FnSites = { kept: new Map(), dropped: [] }; + for (const [e, f] of fates) + if (keptFate(f)) out.kept.set(e, loops.get(e)!); + else if (callLike(e)) out.dropped.push(e); + return out; +} + +/** + * Narrows `groups` to the roots and call regions worth an arena (with + * `reachable` recomputed for them) and classifies the allocations of the + * functions that run in their arenas. + */ +export function planRegionSites(mod: IrModule, groups: RegionGroups): RegionSites { + const fns = new Map(mod.functions.map((f) => [f.name, f])); + const vt = new VirtualTargets(mod.classes ?? []); + const regionCallees = new Set(); + for (const c of groups.calls) if (c.kind === "call") regionCallees.add(c.callee); + const sites = new Map(); + for (const name of groups.reachable) { + const fn = fns.get(name); + if (fn !== undefined) sites.set(name, classify(fn, regionCallees.has(name))); + } + // Which functions reach which (for recursion), over the reachable ones. + const calleesOf = (name: string): Set => { + const out = new Set(); + const s = sites.get(name); + if (s === undefined) return out; + for (const e of [...s.kept.keys(), ...s.dropped]) + for (const t of targetsOf(vt, e) ?? []) if (sites.has(t)) out.add(t); + return out; + }; + const edges = new Map([...sites.keys()].map((n) => [n, calleesOf(n)])); + const reaches = (from: string, to: string): boolean => { + const seen = new Set(); + const stack = [from]; + while (stack.length > 0) { + const n = stack.pop()!; + if (n === to) return true; + if (seen.has(n)) continue; + seen.add(n); + for (const m of edges.get(n) ?? new Set()) stack.push(m); + } + return false; + }; + const grows = new Set(); + for (let changed = true; changed;) { + changed = false; + for (const [name, s] of sites) { + if (grows.has(name)) continue; + let g = false; + for (const [e, inLoop] of s.kept) { + if (inLoop) g = true; + for (const t of targetsOf(vt, e) ?? []) if (grows.has(t) || reaches(t, name)) g = true; + if (g) break; + } + if (g) { + grows.add(name); + changed = true; + } + } + } + const worth = (r: Node): boolean => { + // Module code runs once, and its groups hold the program's own state. + if (r.kind === "call") return grows.has(r.callee) && !r.callee.startsWith("%init."); + if (r.kind !== "new") return false; + if (grows.has(`%${r.className}.constructor`)) return true; + return r.args.some((a) => a.kind === "call" && grows.has(a.callee)); + }; + for (const r of [...groups.roots]) + if (!worth(r)) { + groups.roots.delete(r); + groups.drops.delete(r); + } + for (const c of [...groups.calls]) if (!worth(c)) groups.calls.delete(c); + groups.reachable = reachableFrom(mod, [...groups.roots, ...groups.calls]); + + const arenaSites = new Set(); + const heapCalls = new Set(); + const mayAllocate = (e: Node): boolean => { + const t = targetsOf(vt, e); + return t === null || t.some((n) => groups.allocates.has(n) || !fns.has(n)); + }; + for (const name of groups.reachable) { + const s = sites.get(name); + if (s === undefined) continue; + for (const e of s.kept.keys()) arenaSites.add(e); + for (const e of s.dropped) + if (!groups.roots.has(e) && !groups.calls.has(e) && mayAllocate(e)) heapCalls.add(e); + } + return { arenaSites, heapCalls }; +} diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index ea429d71a..b5b1e735a 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -60,6 +60,14 @@ import { type InlineUnionFields, } from "./inline-unions.js"; import { JS_BITS, JS_TAGS } from "./jsval-encoding.js"; +import { + ARENA_RC, + arenaDisposeLines, + arenaDisposeSym, + arenaNewLines, + disposeFieldLines, + plainRefType, +} from "./arenas.js"; import { recordMayKeepSource } from "./record-sources.js"; import { LlvmUnsupportedError } from "./unsupported.js"; @@ -137,6 +145,10 @@ export interface ShapeHost { * allocate in regions and count references in their region chunk instead * of being empty (llvm/regions.ts). */ regions?: RegionHooks | null; + /** Record shapes and classes with an arena-aware constructor + * (arenas.ts). */ + arenaShapes?: ReadonlySet; + arenaClasses?: ReadonlySet; /** The target C ABI's `size_t` / refcount integer type. */ readonly sizeType: "i32" | "i64"; /** Worker executables: the inline allocator state is thread-local. */ @@ -1198,22 +1210,25 @@ export function arrNewCall( /** An empty array expected to receive a few elements (its first slots * live in its own block): capText must be "0". */ small = false, + /** In the current group arena when one is open (arenas.ts). */ + arena = false, ): string { + const r = arena ? "_r" : ""; const useRef = arrUsesRef(host, elem); if (!useRef) { if (small) { - host.declare(`declare ptr @scr_arr_new_small(i32)`); - return `call ptr @scr_arr_new_small(i32 ${elemKindNum(elem)})`; + host.declare(`declare ptr @scr_arr_new_small${r}(i32)`); + return `call ptr @scr_arr_new_small${r}(i32 ${elemKindNum(elem)})`; } - host.declare(`declare ptr @scr_arr_new(i32, ${host.sizeType})`); - return `call ptr @scr_arr_new(i32 ${elemKindNum(elem)}, ${host.sizeType} ${capText})`; + host.declare(`declare ptr @scr_arr_new${r}(i32, ${host.sizeType})`); + return `call ptr @scr_arr_new${r}(i32 ${elemKindNum(elem)}, ${host.sizeType} ${capText})`; } if (small) { - host.declare(`declare ptr @scr_arr_new_ops_small(ptr)`); - return `call ptr @scr_arr_new_ops_small(ptr ${arrOps(host, elem)})`; + host.declare(`declare ptr @scr_arr_new_ops_small${r}(ptr)`); + return `call ptr @scr_arr_new_ops_small${r}(ptr ${arrOps(host, elem)})`; } - host.declare(`declare ptr @scr_arr_new_ops(ptr, ${host.sizeType})`); - return `call ptr @scr_arr_new_ops(ptr ${arrOps(host, elem)}, ${host.sizeType} ${capText})`; + host.declare(`declare ptr @scr_arr_new_ops${r}(ptr, ${host.sizeType})`); + return `call ptr @scr_arr_new_ops${r}(ptr ${arrOps(host, elem)}, ${host.sizeType} ${capText})`; } /** The ScrElemKind and ops descriptor (or `null`) an array of `elem` @@ -2327,6 +2342,34 @@ export function emitRecordShapes( if (traced) nw.push(...youngEntryLines(host)); nw.push(` ret ptr %o`, `}`, ``); defs.push(...nw); + if (host.arenaShapes?.has(shape.id) === true && !shape.indexValue) { + const newSym = mangleRecordNew(shape.id); + const dispose = refMembers.length > 0 ? arenaDisposeSym(newSym) : null; + const init = [` store ${host.sizeType} ${ARENA_RC}, ptr %o`]; + if (shapeNumber !== 0) + init.push( + ` %shp = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${SHAPE_WORD_INDEX}`, + ` store i32 ${shapeNumber}, ptr %shp ; shape number`, + ); + defs.push( + ...arenaNewLines(host, newSym, sizeOf, traced ? cycType : null, dispose, init, "%o"), + ); + if (dispose !== null) + defs.push( + ...arenaDisposeLines( + newSym, + refMembers.flatMap((m, i) => + disposeFieldLines( + i, + `getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${m.index}`, + plainRefType(m.type), + releaseSym(host, m.type), + llvmCommentText(m.name), + ), + ), + ), + ); + } if (host.recordCloneShapes.has(shape.id)) { const attrs = shape.fields.length >= 16 ? "#2" : FN_ATTRS; diff --git a/packages/compiler/src/backend/llvm/stack-objects.ts b/packages/compiler/src/backend/llvm/stack-objects.ts index ae2b904db..36793598b 100644 --- a/packages/compiler/src/backend/llvm/stack-objects.ts +++ b/packages/compiler/src/backend/llvm/stack-objects.ts @@ -57,8 +57,9 @@ export function canStackObject(host: LlvmEmitterContext, e: IrExpr): e is StackO if (e.type.kind === "record" && host.recordsById.get(e.type.shapeId)?.freezable === true) return false; // A producer with a result-slot entry builds its fresh result in the - // caller's storage. - if (e.kind === "call") return host.fnByName.has(resultEntryName(e.callee)); + // caller's storage, unless the call roots a group: then the result goes + // in the group's arena with the objects it holds (arenas.ts). + if (e.kind === "call") return host.fnByName.has(resultEntryName(e.callee)) && !host.opensArena(e); if (e.kind === "recordLit") { if (e.type.kind !== "record") return false; const shape = host.recordsById.get(e.type.shapeId); diff --git a/packages/compiler/src/backend/may-throw.test.ts b/packages/compiler/src/backend/may-throw.test.ts index 0a950c2cc..1bbbdb299 100644 --- a/packages/compiler/src/backend/may-throw.test.ts +++ b/packages/compiler/src/backend/may-throw.test.ts @@ -406,7 +406,7 @@ test("worker programs poll for termination only where execution can be unbounded expect(computeMayThrow({ ...mod, workers: false }).workerEntryPolls).toBeUndefined(); }); -test("stack checks cover recursive components and recursion through reentrant operations", () => { +test("stack checks cover every recursive cycle, including recursion through reentrant operations", () => { const mod = moduleWith( fn("caller", [call("self"), call("left"), call("leaf"), call("walker")], []), fn("self", [call("self"), call("leaf")], []), @@ -420,7 +420,12 @@ test("stack checks cover recursive components and recursion through reentrant op fn("visitor", [callClosure("leaf")], []), ); const answer = computeMayThrow(mod, { stackChecks: true }); - expect([...answer.stackChecks!].sort()).toEqual(["left", "right", "self", "step", "walker"]); + // One check per cycle: self, one of left and right, and one of walker and + // step (the earlier function on ties). + expect([...answer.stackChecks!].sort()).toEqual(["left", "self", "walker"]); + expect( + [...computeMayThrow(mod, { stackChecks: "everyCycleMember" }).stackChecks!].sort(), + ).toEqual(["left", "right", "self", "step", "walker"]); // Checked functions throw the overflow RangeError, so their callers poll; // a throwing closure target makes every indirect call poll as well. expect(answer.indirect).toBe(true); @@ -437,7 +442,7 @@ test("stack checks cover recursive components and recursion through reentrant op expect(computeMayThrow(mod).fns.size).toBe(0); }); -test("stack-checked synchronous functions skip the worker entry poll", () => { +test("synchronous functions on a checked cycle skip the worker entry poll", () => { const asyncSelf: IrFunction = { ...fn("asyncSelf", [call("asyncSelf")], []), async: true }; const mod: IrModule = { ...moduleWith( @@ -452,10 +457,11 @@ test("stack-checked synchronous functions skip the worker entry poll", () => { workers: true, }; const answer = computeMayThrow(mod, { stackChecks: true }); - expect([...answer.stackChecks!].sort()).toEqual(["asyncSelf", "caller", "left", "right", "self"]); - // Callers test after every call of a stack-checked function, and its - // overflow unwinds through those tests, so only the module entry, async - // bodies and unbounded functions without a stack check poll on entry. + expect([...answer.stackChecks!].sort()).toEqual(["asyncSelf", "caller", "left", "self"]); + // Callers test after every call of a function on a checked cycle (it may + // throw), and an overflow unwinds through those tests, so only the module + // entry, async bodies and unbounded functions off any cycle poll on entry; + // right needs no poll although left holds the cycle's check. expect([...answer.workerEntryPolls!].sort()).toEqual(["asyncSelf", "caller", "walker"]); expect(answer.fns.has("self") && answer.fns.has("left") && answer.fns.has("right")).toBe(true); // Without stack checks (WASI, Windows, libraries) every cycle keeps its poll. @@ -469,6 +475,48 @@ test("stack-checked synchronous functions skip the worker entry poll", () => { ]); }); +test("stack checks meet every cycle once and bound the runs between checks", () => { + // A ring of 80 functions, with a chord from fn10 back to fn0, and a + // separate ring of three whose functions also call each other backwards. + const ring = Array.from({ length: 80 }, (_, i) => + fn(`fn${i}`, i === 10 ? [call("fn11"), call("fn0")] : [call(`fn${(i + 1) % 80}`)], []), + ); + const mod = moduleWith( + ...ring, + fn("a", [call("b"), call("c")], []), + fn("b", [call("c"), call("a")], []), + fn("c", [call("a"), call("b")], []), + ); + const checks = computeMayThrow(mod, { stackChecks: true }).stackChecks!; + // Removing the checked functions leaves no cycle, and no run of more than + // 32 unchecked calls. + const callees = new Map( + mod.functions.map((f) => [ + f.name, + f.body.flatMap((s) => + s.kind === "exprStmt" && s.expr.kind === "call" ? [s.expr.callee] : [], + ), + ]), + ); + const longest = new Map(); + const visiting = new Set(); + const run = (name: string): number => { + if (checks.has(name)) return 0; + expect(visiting.has(name)).toBe(false); + const known = longest.get(name); + if (known !== undefined) return known; + visiting.add(name); + const depth = 1 + Math.max(0, ...callees.get(name)!.map(run)); + visiting.delete(name); + longest.set(name, depth); + return depth; + }; + for (const f of mod.functions) expect(run(f.name)).toBeLessThanOrEqual(32); + // fn0 breaks both ring cycles, fn33 and fn66 bound the run along the + // ring, and two of a, b and c break their four cycles. + expect([...checks].sort()).toEqual(["a", "b", "fn0", "fn33", "fn66"]); +}); + test("class members are reachable from reentrant operations", () => { const mod: IrModule = { ...moduleWith( diff --git a/packages/compiler/src/backend/may-throw.ts b/packages/compiler/src/backend/may-throw.ts index 693d20b3b..53b0e56b4 100644 --- a/packages/compiler/src/backend/may-throw.ts +++ b/packages/compiler/src/backend/may-throw.ts @@ -37,7 +37,11 @@ export type MayThrowReason = export function computeMayThrow( mod: IrModule, options: { - stackChecks?: boolean; + /** Native stack checks: true for the functions that check on entry (one + * per call cycle, see stackCheckedFunctions); "everyCycleMember" for a + * model in which every function on a cycle checks, which answers a + * superset that does not depend on that choice. */ + stackChecks?: boolean | "everyCycleMember"; explain?: boolean; /** computePublishedTypes(mod), when the caller already has it. */ published?: ReturnType; @@ -56,8 +60,16 @@ export function computeMayThrow( stackChecks?: Set; } { const terminations = mod.workers === true ? workerTerminationPolls(mod, options.nodes) : null; + const stackGraph = + options.stackChecks === undefined || options.stackChecks === false + ? null + : stackCheckGraph(mod, options.nodes); const stackChecks = - options.stackChecks === true ? stackCheckedFunctions(mod, options.nodes) : null; + stackGraph === null + ? null + : options.stackChecks === "everyCycleMember" + ? stackGraph.cyclic + : cycleGuards(stackGraph); // Stores that can reach a published object throw the frozen TypeError. const published = options.published !== undefined ? options.published : computePublishedTypes(mod); @@ -488,7 +500,9 @@ export function computeMayThrow( fns: may, ...(why !== null ? { why } : {}), indirect, - ...(terminations ? { workerEntryPolls: entryPolls(mod, terminations.entry, stackChecks) } : {}), + ...(terminations + ? { workerEntryPolls: entryPolls(mod, terminations.entry, stackGraph?.cyclic ?? null, may) } + : {}), ...(stackChecks ? { stackChecks } : {}), }; } @@ -720,28 +734,32 @@ function describeThrowingNode(rec: IrExpr | IrStmt): string { } /** The termination polls a worker program places on function entry. A - * stack-checked synchronous function needs none: it may throw (the stack - * guard seeds the fixpoint), so every caller tests the pending state after - * the call, and a descent that never returns ends at the stack guard, whose - * RangeError unwinds through those tests. An outstanding terminate request - * keeps the alert word nonzero, so the first test that runs afterwards (a - * post-call test, a loop poll or the unwinding) stops the thread; dropping - * the entry poll only delays the stop by bounded work, which Node's - * asynchronous terminate allows. The entry poll's slow path rejoins the - * body, so every polled function kept its arguments live across a call. */ + * synchronous function on a call cycle that may throw needs none: every + * caller tests the pending state after the call, and a descent that never + * returns passes a stack-checked function of each cycle again and again + * (stackCheckedFunctions) and ends at its guard, whose RangeError unwinds + * through those tests. An outstanding terminate request keeps the alert word + * nonzero, so the first test that runs afterwards (a post-call test, a loop + * poll or the unwinding) stops the thread; dropping the entry poll only + * delays the stop by bounded work, which Node's asynchronous terminate + * allows. The entry poll's slow path rejoins the body, so every polled + * function kept its arguments live across a call. */ function entryPolls( mod: IrModule, entry: Set, - stackChecks: Set | null, + cyclic: Set | null, + may: Set, ): Set { - if (stackChecks === null) return entry; + if (cyclic === null) return entry; // Calling an async or generator function never tests the caller's pending // state, and the module entry has no caller: those keep their polls. const keep = new Set( mod.functions.filter((fn) => fn.async || fn.generator !== undefined).map((fn) => fn.name), ); keep.add(mod.entry); - return new Set([...entry].filter((name) => keep.has(name) || !stackChecks.has(name))); + return new Set( + [...entry].filter((name) => keep.has(name) || !cyclic.has(name) || !may.has(name)), + ); } /** Statement and expression kinds that cannot reenter scriptc code except @@ -971,16 +989,31 @@ export function workerTerminationPolls( return { entry, polls }; } -/** The functions that can take part in unbounded recursion, which check the - * native stack guard on entry: every function on a cycle of the call graph. - * Direct edges are calls, constructions and virtual dispatch. A function - * containing any operation that can reenter scriptc code other than through - * those edges (an indirect call, a callback-taking or dynamic operation, a - * library call; see boundedNode) also reaches every function that such an - * operation can invoke: closure targets and class members. Non-recursive - * functions, including leaf callbacks, stay check-free. */ +/** The call graph that bounds recursion, and the functions on its cycles: + * the ones that can take part in unbounded recursion. Direct edges are + * calls, constructions and virtual dispatch. A function containing any + * operation that can reenter scriptc code other than through those edges + * (an indirect call, a callback-taking or dynamic operation, a library call; + * see boundedNode) also reaches every function that such an operation can + * invoke: closure targets and class members, behind one shared `reentry` + * node. */ +interface StackCheckGraph { + edges: Map; + cyclic: Set; +} + +const REENTRY = "\0reentry"; + +/** The functions that check the native stack guard on entry: one function + * of every cycle of the call graph (stackCheckGraph), which bounds every + * unbounded recursion, since its frames repeat the cycle's guarded function + * again and again. Non-recursive functions, including leaf callbacks, stay + * check-free. See cycleGuards for the choice. */ export function stackCheckedFunctions(mod: IrModule, nodes?: IrNodeIndex): Set { - const reentry = "\0reentry"; + return cycleGuards(stackCheckGraph(mod, nodes)); +} + +function stackCheckGraph(mod: IrModule, nodes?: IrNodeIndex): StackCheckGraph { const methodImpls = new Map(); const memberPrefixes = new Set(); for (const cls of mod.classes ?? []) { @@ -1019,16 +1052,157 @@ export function stackCheckedFunctions(mod: IrModule, nodes?: IrNodeIndex): Set names.has(callee)); - if (reenters) callees.push(reentry); + if (reenters) callees.push(REENTRY); edges.set(fn.name, callees); } edges.set( - reentry, + REENTRY, [...targets].filter((name) => names.has(name)), ); const cyclic = cyclicFunctions(edges); - cyclic.delete(reentry); - return cyclic; + cyclic.delete(REENTRY); + return { edges, cyclic }; +} + +/** The longest run of calls between unchecked functions of a cycle that + * cycleGuards allows; deeper runs get a check. Acyclic call chains below a + * check are as deep as the program makes them already, so this keeps the + * stack used between two checks of the same order. */ +const MAX_UNCHECKED_CYCLE_RUN = 32; + +/** A small set of functions that meets every cycle of the stack-check graph + * (a feedback vertex set), chosen greedily: functions that cannot lie on a + * remaining cycle (no caller or no callee left among the candidates) drop + * out, a function that calls itself is chosen, and otherwise the function + * with the most caller-callee pairs, the one that breaks the most cycles, + * is chosen and removed; ties go to the earlier function. The reentry node + * is not a function and is never chosen. A last pass in topological order + * of the unchosen cycle functions adds a check where a run of them would + * exceed MAX_UNCHECKED_CYCLE_RUN calls. */ +function cycleGuards(graph: StackCheckGraph): Set { + const { edges, cyclic } = graph; + const chosen = new Set(); + if (cyclic.size === 0) return chosen; + const order = new Map(); + const callees = new Map>(); + const callers = new Map>(); + const candidates = [...[...edges.keys()].filter((name) => cyclic.has(name)), REENTRY]; + for (const name of candidates) { + order.set(name, order.size); + callees.set(name, new Set()); + callers.set(name, new Set()); + } + for (const name of candidates) + for (const callee of edges.get(name) ?? []) + if (callees.has(callee)) { + callees.get(name)!.add(callee); + callers.get(callee)!.add(name); + } + // Max-heap of [score, order, name]; an entry is stale once its function + // is gone or its score changed, and is skipped when popped. + const heap: [number, number, string][] = []; + const score = (name: string): number => + name === REENTRY ? -1 : callers.get(name)!.size * callees.get(name)!.size; + const above = (x: [number, number, string], y: [number, number, string]): boolean => + x[0] > y[0] || (x[0] === y[0] && x[1] < y[1]); + const push = (name: string): void => { + heap.push([score(name), order.get(name)!, name]); + for (let i = heap.length - 1; i > 0;) { + const parent = (i - 1) >> 1; + if (!above(heap[i]!, heap[parent]!)) break; + [heap[i], heap[parent]] = [heap[parent]!, heap[i]!]; + i = parent; + } + }; + const pop = (): [number, number, string] => { + const top = heap[0]!; + const last = heap.pop()!; + if (heap.length > 0) { + heap[0] = last; + for (let i = 0; ;) { + let best = i; + for (const child of [2 * i + 1, 2 * i + 2]) + if (child < heap.length && above(heap[child]!, heap[best]!)) best = child; + if (best === i) break; + [heap[i], heap[best]] = [heap[best]!, heap[i]!]; + i = best; + } + } + return top; + }; + const doomed: string[] = []; + const remove = (name: string): void => { + const out = callees.get(name)!; + const into = callers.get(name)!; + callees.delete(name); + callers.delete(name); + for (const callee of out) + if (callee !== name) { + const set = callers.get(callee)!; + set.delete(name); + if (set.size === 0) doomed.push(callee); + else push(callee); + } + for (const caller of into) + if (caller !== name) { + const set = callees.get(caller)!; + set.delete(name); + if (set.size === 0) doomed.push(caller); + else push(caller); + } + }; + // Removes the functions that can no longer lie on a cycle, transitively. + const settle = (): void => { + while (doomed.length > 0) { + const name = doomed.pop()!; + if (callees.has(name)) remove(name); + } + }; + for (const name of candidates) { + if (callees.get(name)!.size === 0 || callers.get(name)!.size === 0) doomed.push(name); + else push(name); + } + settle(); + for (const name of candidates) + if (callees.get(name)?.has(name) === true) { + chosen.add(name); + remove(name); + settle(); + } + while (heap.length > 0) { + const [entryScore, , name] = pop(); + if (!callees.has(name) || entryScore !== score(name) || name === REENTRY) continue; + chosen.add(name); + remove(name); + settle(); + } + // Bound the runs of unchecked cycle functions (acyclic once the chosen + // ones are removed; the reentry node takes no frame): the longest run + // ending at each function, in topological order, and a check where it + // would grow too long. + const unchecked = candidates.filter((name) => !chosen.has(name)); + const pending = new Map(unchecked.map((name) => [name, 0])); + for (const name of unchecked) + for (const callee of new Set(edges.get(name))) + if (pending.has(callee)) pending.set(callee, pending.get(callee)! + 1); + const run = new Map(); + const ready = unchecked.filter((name) => pending.get(name) === 0); + for (let i = 0; i < ready.length; i++) { + const name = ready[i]!; + let depth = (run.get(name) ?? 0) + (name === REENTRY ? 0 : 1); + if (depth > MAX_UNCHECKED_CYCLE_RUN && name !== REENTRY) { + chosen.add(name); + depth = 0; + } + for (const callee of new Set(edges.get(name))) { + if (!pending.has(callee)) continue; + run.set(callee, Math.max(run.get(callee) ?? 0, depth)); + const left = pending.get(callee)! - 1; + pending.set(callee, left); + if (left === 0) ready.push(callee); + } + } + return chosen; } /** Functions on a cycle of the call graph (Tarjan's strongly connected diff --git a/packages/compiler/src/backend/native-toolchain.test.ts b/packages/compiler/src/backend/native-toolchain.test.ts index 1651da083..8fb86d4af 100644 --- a/packages/compiler/src/backend/native-toolchain.test.ts +++ b/packages/compiler/src/backend/native-toolchain.test.ts @@ -630,20 +630,27 @@ test.skipIf(clangExecutable === undefined)( async () => { // clang ships its x86 intrinsic headers (, ...) for every // target, so __has_include finds them on arm64, where including them is - // an #error, and the same holds for the ARM headers on x86. The probe - // must guard them by target. Only the compiler's own freestanding - // headers are visible here, so the result does not depend on the host's - // C library. + // an #error, and the same holds for the ARM headers on x86 and for + // (zlib's RVV code) outside RISC-V with the vector + // extension. The probe must guard them by target. Only the compiler's own + // freestanding headers are visible here, so the result does not depend on + // the host's C library. const includes = (await implicitDependencyProbeIncludes(runtimeSrcDir())).filter( (include) => - include !== "" && /intrin\.h>$|^$|^$/.test(include), + include !== "" && + /intrin\.h>$|^$|^$|^$/.test(include), ); expect(includes).toContain(""); + expect(includes).toContain(""); const resourceDir = execFileSync(clangExecutable!, ["-print-resource-dir"], { encoding: "utf8", }).trim(); const source = includes.map(implicitDependencyIncludeDirective).join(""); - for (const target of ["aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu"]) { + for (const target of [ + "aarch64-unknown-linux-gnu", + "riscv64-unknown-linux-gnu", + "x86_64-unknown-linux-gnu", + ]) { const result = spawnSync( clangExecutable!, [ diff --git a/packages/compiler/src/backend/native/compiler-fingerprint.ts b/packages/compiler/src/backend/native/compiler-fingerprint.ts index 00d84c09b..4be20a4d0 100644 --- a/packages/compiler/src/backend/native/compiler-fingerprint.ts +++ b/packages/compiler/src/backend/native/compiler-fingerprint.ts @@ -207,7 +207,8 @@ function x86OnlyHeader(include: string): boolean { /** One guarded line of the implicit-toolchain probe source: the header is * included only when the compiler can find it and it belongs to the target - * being probed. */ + * being probed. clang ships for every target, and through + * clang 18 including it without the RISC-V vector extension is an #error. */ export function implicitDependencyIncludeDirective(include: string): string { const targetCondition = x86OnlyHeader(include) ? " && (defined(__i386__) || defined(__x86_64__) || defined(_M_IX86) || defined(_M_X64))" @@ -215,7 +216,9 @@ export function implicitDependencyIncludeDirective(include: string): string { ? " && defined(_WIN32)" : include === "" || include === "" || include === "" ? " && (defined(__arm__) || defined(__aarch64__) || defined(_M_ARM) || defined(_M_ARM64))" - : ""; + : include === "" + ? " && defined(__riscv_vector)" + : ""; return `#if __has_include(${include})${targetCondition}\n#include ${include}\n#endif\n`; } diff --git a/packages/compiler/src/backend/native/runtime-inputs.ts b/packages/compiler/src/backend/native/runtime-inputs.ts index 891fa9312..60f1a8960 100644 --- a/packages/compiler/src/backend/native/runtime-inputs.ts +++ b/packages/compiler/src/backend/native/runtime-inputs.ts @@ -70,6 +70,7 @@ export const EXECUTABLE_RUNTIME_SOURCES = [ "scr_cycle.c", "scr_publish.c", "scr_region.c", + "scr_arena.c", "scr_itable.c", "scr_slots.c", "scr_alloc.c", diff --git a/packages/compiler/src/contracts/check.ts b/packages/compiler/src/contracts/check.ts index 972d9ea12..6d8a19956 100644 --- a/packages/compiler/src/contracts/check.ts +++ b/packages/compiler/src/contracts/check.ts @@ -519,8 +519,10 @@ class ContractChecker { for (const fn of this.fnByLoc.get(key) ?? []) targets.push({ fn, note }); if (targets.length === 0) return; // Target-independent: the native stack guard (Linux, macOS) counts, so a - // contract holds on every platform. - const mt = computeMayThrow(this.mod, { stackChecks: true, explain: true }); + // contract holds on every platform. Every function on a call cycle counts + // as checking it, a superset of the functions the backend picks, so the + // answer does not depend on that choice. + const mt = computeMayThrow(this.mod, { stackChecks: "everyCycleMember", explain: true }); const proven: string[] = []; for (const { fn, note } of targets) { if (fn.async === true || fn.generator !== undefined) { @@ -1582,6 +1584,15 @@ class ImmutableContracts { for (let steps = 0; steps < 16; steps++) { if (root.kind === "fieldGet" || root.kind === "recordGet") root = root.obj; else if (root.kind === "arrayGet") root = root.arr; + else if (root.kind === "seqExpr") root = root.result; + // A receiver that is the element when it is present and otherwise + // throws (`xs[i]!.push(v)`) is that element read. + else if ( + root.kind === "ternary" && + root.else_.kind === "libCall" && + root.else_.fn === "error.nodeThrow" + ) + root = root.then; else if ( root.kind === "upcast" || root.kind === "downcast" || diff --git a/packages/compiler/src/frontend/lowering/expressions/binary.ts b/packages/compiler/src/frontend/lowering/expressions/binary.ts index 0d24561de..5c326ab1e 100644 --- a/packages/compiler/src/frontend/lowering/expressions/binary.ts +++ b/packages/compiler/src/frontend/lowering/expressions/binary.ts @@ -37,6 +37,7 @@ import { dynCompoundValue, lowerAbsenceProbe, lowerDynMemberAssignment, + lowerDereferencedElementAssignment, lowerOptionalNumber, lowerPresenceOperand, nullishBranches, @@ -334,6 +335,25 @@ function isMemberReceiver(node: ts.Expression): boolean { ); } +/** A literal default (`0`, `-1`, `""`, `true`): it always has its own type. + * Other defaults, such as an element read that can be undefined, keep the + * dynamic path. */ +function literalDefault(node: ts.Expression): boolean { + while (ts.isParenthesizedExpression(node)) node = node.expression; + if ( + ts.isPrefixUnaryExpression(node) && + (node.operator === ts.SyntaxKind.MinusToken || node.operator === ts.SyntaxKind.PlusToken) + ) + node = node.operand; + return ( + ts.isNumericLiteral(node) || + ts.isStringLiteral(node) || + ts.isNoSubstitutionTemplateLiteral(node) || + node.kind === ts.SyntaxKind.TrueKeyword || + node.kind === ts.SyntaxKind.FalseKeyword + ); +} + export function lowerNullishPair( lowerer: Lowerer, expr: ts.BinaryExpression, @@ -388,6 +408,26 @@ export function lowerNullishPair( lowerer.runtimeOptionalArithmeticTypes.set(expr, type); } if (type.kind === "dyn" && lowerer.dynConvertible(left.type)) { + // `counts.get(k) ?? 0` in JavaScript: a scalar optional left and a + // default of its value's type give a scalar, so the test and the default + // run on the typed path and only the result converts. Converting the + // optional left itself would allocate a union for every read. + if ( + rest.length === 1 && + primitive(rest[0]!) && + rightType && + typeEquals(rightType, rest[0]!) && + literalDefault(expr.right) + ) { + const typed: IrExpr = { + kind: "nullish", + left, + right: lowerer.lowerExprExpecting(expr.right, rest[0]!), + type: rest[0]!, + loc, + }; + return lowerer.coerceToExpected(typed, DYN); + } return { kind: "nullish", left: lowerer.coerceToExpected(left, DYN), @@ -1029,6 +1069,8 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr if (prototypeWrite) return prototypeWrite; const callableWrite = lowerNativeFunctionAssignment(lowerer, expr); if (callableWrite) return callableWrite; + const dereferenced = lowerDereferencedElementAssignment(lowerer, expr); + if (dereferenced) return dereferenced; } if ( op === ts.SyntaxKind.QuestionQuestionEqualsToken || diff --git a/packages/compiler/src/frontend/lowering/generator-dynamic-dispatch.ts b/packages/compiler/src/frontend/lowering/generator-dynamic-dispatch.ts index db542412f..459af8751 100644 --- a/packages/compiler/src/frontend/lowering/generator-dynamic-dispatch.ts +++ b/packages/compiler/src/frontend/lowering/generator-dynamic-dispatch.ts @@ -1,7 +1,8 @@ -import { dynUndefinedExpr, varRef } from "../../ir/build.js"; +import { dynUndefinedExpr, numLit, varRef } from "../../ir/build.js"; import { BOOL, DYN, + F64, STRING, canConvertToDyn, funcOf, @@ -16,7 +17,7 @@ import { import { transformStmtList } from "../../ir/traverse.js"; import { genResultRecord } from "../type-mapper.js"; import type { DispatchSites } from "./dispatch-sites.js"; -import type { Lowerer } from "./lowerer.js"; +import type { Lowerer, UntypedLoopSite } from "./lowerer.js"; type GeneratorType = Extract; type Invoke = Extract; @@ -401,6 +402,310 @@ export class GeneratorDynamicDispatch { }); changed = true; } + return this.stepUntypedLoops(lowerer, functions) || changed; + } + + /** The generator types a loop over an untyped iterable can step natively: + * sync generators for `for … of`, and both kinds for `for await` (a sync + * generator through the async-from-sync rules). Insertion order, so a + * type keeps its index as later waves add types. */ + private loopTypes(lowerer: Lowerer, site: UntypedLoopSite): [string, GeneratorType][] { + const out: [string, GeneratorType][] = []; + for (const [key, type] of this.generators) { + if (type.async === true && !site.async) continue; + const result = genResultRecord(type.yieldT, type.retT, lowerer.shapes, lowerer.unions); + if (result === null) continue; + // next() with no argument, as the protocol calls it. + const channel = type.nextT; + if (channel.kind !== "undefinedT" && channel.kind !== "void" && channel.kind !== "dyn") + continue; + if ( + !canConvertToDyn( + result, + (id) => lowerer.shapes.get(id), + (id) => lowerer.unions.get(id), + ) + ) + continue; + out.push([key, type]); + } + return out; + } + + /** Loops over untyped iterables step the program's own generators on the + * typed path. Once the loop has its iterator and next method, a kind local + * records which generator type the iterator is, when its next method is + * the generator's own (`%dyn.generator.method.next`); each step then + * resumes the generator directly and boxes only the value, where the + * protocol would box the result record and its promise and read them + * back. Every other iterator, and every early exit (return() through the + * protocol), keeps the dynamic path. The awaits are the protocol's: a + * `for await` over an async generator awaits next()'s promise, and over a + * sync generator awaits once for the result and, reached through + * Symbol.iterator, once for the value. */ + private stepUntypedLoops(lowerer: Lowerer, functions: readonly IrFunction[]): boolean { + if (this.generators.size === 0 || lowerer.untypedLoops.size === 0) return false; + // The loop reads next through the getter only once it exists. + if (!this.properties.has("next")) return false; + let changed = false; + for (const fn of functions) { + if (this.generated.has(fn)) continue; + const byKind = new Map(); + const byValue = new Map(); + for (const local of fn.locals) { + const site = lowerer.untypedLoops.get(local); + if (site === undefined) continue; + byKind.set(site.kind, site); + byValue.set(site.value, site); + } + if (byKind.size === 0) continue; + fn.body = transformStmtList(fn.body, { + expr: (expr) => expr, + stmt: (stmt) => { + if (stmt.kind !== "varDecl" || stmt.init === null) return stmt; + const opened = byKind.get(stmt.localId); + if (opened !== undefined) { + const types = this.loopTypes(lowerer, opened); + const init = this.loopKind(opened, types, stmt.loc); + if (this.loopKindCount(stmt.init) === types.length) return stmt; + changed = true; + return { ...stmt, init }; + } + const site = byValue.get(stmt.localId); + if (site === undefined) return stmt; + const types = this.loopTypes(lowerer, site); + // The protocol step is the innermost else of the type chain. + let fallback = stmt.init; + let present = 0; + while ( + fallback.kind === "ternary" && + fallback.cond.kind === "bin" && + fallback.cond.left.kind === "varRef" && + fallback.cond.left.localId === site.kind + ) { + fallback = fallback.else_; + present++; + } + if (present === types.length) return stmt; + let init = fallback; + for (let i = types.length - 1; i >= 0; i--) { + const [, type] = types[i]!; + const record = `${site.kind}.r${i + 1}`; + init = { + kind: "ternary", + cond: { + kind: "bin", + op: "===", + left: varRef(site.kind, F64, stmt.loc), + right: numLit(i + 1, stmt.loc), + type: BOOL, + loc: stmt.loc, + }, + then: this.loopStep(lowerer, fn, site, type, record, stmt.loc), + else_: init, + type: DYN, + loc: stmt.loc, + }; + } + changed = true; + return { ...stmt, init }; + }, + }); + } return changed; } + + /** How many generator types a loop's kind initializer tests: the chain + * inside its own-method test (loopKind). */ + private loopKindCount(init: IrExpr): number { + if (init.kind !== "ternary") return 0; + let count = 0; + for (let chain = init.then; chain.kind === "ternary"; chain = chain.else_) count++; + return count; + } + + /** The loop's kind: the 1-based index of the generator type its iterator + * is, when the next method it read is that generator's own, else 0. */ + private loopKind( + site: UntypedLoopSite, + types: readonly [string, GeneratorType][], + loc: IrExpr["loc"], + ): IrExpr { + const own: IrExpr = { + kind: "libCall", + fn: "dyn.sameValue", + args: [ + varRef(site.next, DYN, loc), + { + kind: "dynFrom", + value: { + kind: "closure", + fnName: "%dyn.generator.method.next", + captures: [], + type: funcOf([DYN], DYN), + loc, + }, + type: DYN, + loc, + }, + ], + type: BOOL, + loc, + }; + let kind: IrExpr = numLit(0, loc); + for (let i = types.length - 1; i >= 0; i--) + kind = { + kind: "ternary", + cond: { + kind: "libCall", + fn: "dyn.typedRefIs", + args: [ + varRef(site.iterator, DYN, loc), + { kind: "strLit", value: types[i]![0], type: STRING, loc }, + ], + type: BOOL, + loc, + }, + then: numLit(i + 1, loc), + else_: kind, + type: F64, + loc, + }; + return types.length === 0 + ? kind + : { kind: "ternary", cond: own, then: kind, else_: numLit(0, loc), type: F64, loc }; + } + + /** One step of a loop over an untyped iterable that is a `type` + * generator: sets the loop's done flag and answers the value, boxed. */ + private loopStep( + lowerer: Lowerer, + fn: IrFunction, + site: UntypedLoopSite, + type: GeneratorType, + recordId: string, + loc: IrExpr["loc"], + ): IrExpr { + const gen: IrExpr = { kind: "dynCheck", value: varRef(site.iterator, DYN, loc), type, loc }; + const setDone = (value: IrExpr): IrStmt => ({ kind: "assign", localId: site.done, value, loc }); + const doneRef = varRef(site.done, BOOL, loc); + const result = genResultRecord(type.yieldT, type.retT, lowerer.shapes, lowerer.unions)!; + if (!fn.locals.some((local) => local.id === recordId)) + fn.locals.push({ id: recordId, name: recordId, type: result, mutable: false }); + const field = (name: "value" | "done", fieldType: IrType): IrExpr => ({ + kind: "recordGet", + obj: varRef(recordId, result, loc), + shapeId: result.shapeId, + field: name, + type: fieldType, + loc, + }); + const valueType = lowerer.shapes + .get(result.shapeId)! + .fields.find((f) => f.name === "value")!.type; + const boxed: IrExpr = { kind: "dynFrom", value: field("value", valueType), type: DYN, loc }; + const awaitDyn = (value: IrExpr): IrExpr => ({ + kind: "libCall", + fn: "async.awaitDyn", + args: [value], + type: DYN, + loc, + }); + const resumed: IrExpr = { + kind: "genResume", + mode: "next", + gen, + arg: null, + type: type.async === true ? { kind: "promise", inner: result } : result, + loc, + }; + if (!site.async) + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: recordId, init: resumed, loc }, + setDone(field("done", BOOL)), + ], + result: { + kind: "ternary", + cond: doneRef, + then: dynUndefinedExpr(loc), + else_: boxed, + type: DYN, + loc, + }, + type: DYN, + loc, + }; + if (type.async === true) + return { + kind: "seqExpr", + stmts: [ + { + kind: "varDecl", + localId: recordId, + init: { kind: "awaitExpr", value: resumed, type: result, loc }, + loc, + }, + setDone(field("done", BOOL)), + ], + result: { + kind: "ternary", + cond: doneRef, + then: dynUndefinedExpr(loc), + else_: boxed, + type: DYN, + loc, + }, + type: DYN, + loc, + }; + // for await over a sync generator: the result awaits once. When the + // loop reached it through Symbol.iterator, the async-from-sync iterator + // also awaits the value (the final one too); a sync generator that an + // object's Symbol.asyncIterator returns passes the value as it is, as + // the protocol does. + const fromSync: IrExpr = + site.sync === null + ? { kind: "boolLit", value: false, type: BOOL, loc } + : varRef(site.sync, BOOL, loc); + return { + kind: "seqExpr", + stmts: [ + { kind: "varDecl", localId: recordId, init: resumed, loc }, + setDone(field("done", BOOL)), + { kind: "exprStmt", expr: awaitDyn(dynUndefinedExpr(loc)), loc }, + ], + result: { + kind: "ternary", + cond: doneRef, + then: { + kind: "ternary", + cond: fromSync, + then: { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: awaitDyn(boxed), loc }], + result: dynUndefinedExpr(loc), + type: DYN, + loc, + }, + else_: dynUndefinedExpr(loc), + type: DYN, + loc, + }, + else_: { + kind: "ternary", + cond: fromSync, + then: awaitDyn(boxed), + else_: boxed, + type: DYN, + loc, + }, + type: DYN, + loc, + }, + type: DYN, + loc, + }; + } } diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index 55db76117..9eacfa2b2 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -83,6 +83,8 @@ import { unitOnlyUnion } from "../type-mapper.js"; import { lowerYield } from "./lower-generators.js"; import { unionWideningTags } from "../../ir/analysis.js"; import { isSafeToDiscard, isSafeToRepeat } from "./expressions/evaluation-safety.js"; +import { everyExprChild } from "../../ir/traverse.js"; +import { declareContextLocal } from "./function-context.js"; import { hasOptionalChainGuard, isOptionalChainTail } from "./expressions/optional-chains.js"; import { classToStringConversion, classUnionToString } from "./class-to-primitive.js"; import { lowerCjsTopLevelThis, lowerRequireCacheElement } from "./lower-node-module.js"; @@ -413,6 +415,10 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr { const probe = lowerAbsenceProbe(lowerer, expr.expression); if (probe?.type.kind === "union") return probe; } + const dereferenced = dereferencedElementReceiver(lowerer, expr, loc); + if (dereferenced) return dereferenced; + const element = assertedElementOperand(lowerer, expr, loc); + if (element) return element; const inner = lowerer.lowerExpr(expr.expression); if (inner.type.kind === "union") { // `!` performs no runtime check: a missing array element stays @@ -1075,18 +1081,10 @@ function assertionOperand( loc: SrcLoc, ): IrExpr | null { if (inner.type.kind !== "union" || target === null || target.kind === "union") return null; - const arms = lowerer.unions.get(inner.type.unionId)?.arms; - if ( - !arms?.some(isUnitType) || - !arms.every( - (arm) => - arm.kind === "f64" || arm.kind === "string" || arm.kind === "bool" || isUnitType(arm), - ) - ) - return null; - // Array element reads keep their own handling of a missing element. A - // typed-array read is optional here only where its use tells undefined - // from NaN (assertedReadObservesUndefined). + if (!scalarUnion(lowerer, inner.type.unionId)) return null; + // Array element reads take assertedElementOperand or keep their own + // handling of a missing element. A typed-array read is optional here only + // where its use tells undefined from NaN (assertedReadObservesUndefined). let operand: ts.Expression = expr.expression; while (ts.isParenthesizedExpression(operand)) operand = operand.expression; if (ts.isElementAccessExpression(operand)) { @@ -1095,19 +1093,489 @@ function assertionOperand( } const use = convertingUseOf(lowerer, expr); if (use === null) return null; - if (use === "value") return inner; + if (use === "value" || use === "string") return inner; return lowerPrimitiveUnionNumber(lowerer, inner, expr, loc); } -/** How the converting use of `x!` (see assertionOperand) reads it: - * "number" where the use applies ToNumber, "value" where the consumer - * lowers the union itself (strings, equality, truthiness), null for other +/** `xs[i]!` on an array as the operand of a converting use (see + * assertionOperand). The read answers undefined for a missing element, as + * in Node, instead of trapping: a numeric use reads NaN for it, and other + * uses get the optional element value. Returns null for other reads and * uses. */ -function convertingUseOf(lowerer: Lowerer, expr: ts.NonNullExpression): "number" | "value" | null { +function assertedElementOperand( + lowerer: Lowerer, + expr: ts.NonNullExpression, + loc: SrcLoc, +): IrExpr | null { + let read: ts.Expression = expr.expression; + while (ts.isParenthesizedExpression(read)) read = read.expression; + if (!ts.isElementAccessExpression(read) || read.questionDotToken) return null; + // Plain arrays only: regex match results and other array subtypes keep + // their own element reads. + const receiverType = lowerer.typeOf(read.expression); + if ( + !lowerer.checker.isArrayType(receiverType) || + lowerer.mapTypeOf(receiverType)?.kind !== "array" + ) + return null; + // A counter below the length reads an existing index: the read keeps + // its plain element access. + if (lowerer.nanCodedReads?.counterIndexInBounds(read)) return null; + const target = lowerer.mapTypeOf(lowerer.typeOf(expr)); + if (!target || !isScalarArm(target)) return null; + const use = convertingUseOf(lowerer, expr); + if (use === null) return null; + if (use === "string" && target.kind === "string") + return stringElementOrUndefined(lowerer, read, loc); + const probe = lowerAbsenceProbe(lowerer, read); + if (probe?.type.kind !== "union" || !scalarUnion(lowerer, probe.type.unionId)) return null; + if (use === "value" || use === "string") return probe; + return lowerer.stripUndefinedArm(probe.type).kind === "f64" + ? lowerOptionalNumber(lowerer, probe, loc) + : lowerPrimitiveUnionNumber(lowerer, probe, expr, loc); +} + +/** `xs[i]!` on an array as the receiver of a member read + * (`xs[i]!.name`, `xs[i]!.size()`, `xs[i]![0]`). `!` performs no runtime + * check, so a missing element is undefined there and the member access + * throws Node's TypeError ("Cannot read properties of undefined (reading + * 'name')") instead of the bounds trap. The receiver is the element when it + * is present. A write (`xs[i]!.name = v`) is lowered as a statement by + * lowerDereferencedElementWrite, which evaluates the value before the + * receiver throws; elsewhere it keeps the bounds trap. Returns null for + * other reads and uses. */ +function dereferencedElementReceiver( + lowerer: Lowerer, + expr: ts.NonNullExpression, + loc: SrcLoc, +): IrExpr | null { + const use = runtimeOptionalUseOf(expr); + if (!use || isAssignmentTarget(use.access)) return null; + const parts = dereferencedElementParts(lowerer, expr, loc, false); + if (parts?.message == null) return null; + const message = parts.message; + const value: IrExpr = { + kind: "ternary", + cond: parts.present, + then: parts.element, + else_: nodeTypeError(message, parts.element.type, loc), + type: parts.element.type, + loc, + }; + return parts.stmts.length === 0 + ? value + : { kind: "seqExpr", stmts: parts.stmts, result: value, type: value.type, loc }; +} + +/** The parts of a member access through `xs[i]!` (see + * dereferencedElementReceiver): statements that evaluate the array and the + * index, whether the element is present, the element read, and Node's + * TypeError message for a missing one. A `write` holds its key, so its + * message may be left for dereferencedElementWrite to build from the key. */ +export function dereferencedElementParts( + lowerer: Lowerer, + expr: ts.NonNullExpression, + loc: SrcLoc, + write: boolean, +): { stmts: IrStmt[]; present: IrExpr; element: IrExpr; message: IrExpr | null } | null { + let read: ts.Expression = expr.expression; + while (ts.isParenthesizedExpression(read)) read = read.expression; + if (!ts.isElementAccessExpression(read) || read.questionDotToken) return null; + const receiverType = lowerer.typeOf(read.expression); + if ( + !lowerer.checker.isArrayType(receiverType) || + lowerer.mapTypeOf(receiverType)?.kind !== "array" + ) + return null; + if (lowerer.nanCodedReads?.counterIndexInBounds(read)) return null; + const use = runtimeOptionalUseOf(expr); + if (!use || use.optional || use.complex || use.comma || use.kind === "call") return null; + // A write holds its key, so the message can name any number or string + // key (dereferencedElementWrite builds it). + const message = + memberAccessMessage(lowerer, use.access) ?? + (write && ts.isElementAccessExpression(use.access) && namedKey(lowerer, use.access) + ? null + : undefined); + if (message === undefined) return null; + if (lowerer.mapTypeOf(lowerer.typeOf(read.argumentExpression))?.kind !== "f64") return null; + // The element must already have the asserted type: an element union the + // assertion or a guard narrows keeps its checked narrow. + const target = lowerer.mapTypeOf(lowerer.typeOf(expr)); + const declared = lowerer.mapTypeOf(receiverType); + if ( + !target || + declared?.kind !== "array" || + declared.elem.kind === "void" || + !typeEquals(declared.elem, target) + ) + return null; + const arr = lowerer.lowerExpr(read.expression); + if (arr.type.kind !== "array" || !typeEquals(arr.type.elem, target)) return null; + const elem = arr.type.elem; + const index = lowerer.lowerExprExpecting(read.argumentExpression, F64); + // Plain reads (a local, a field of one) evaluate once more instead of + // through a hidden local: the receiver then stays a borrowed element + // read, like the plain `xs[i]` it replaces. An index that can assign + // can replace the array binding or field (`xs[(xs = other, 0)]!`), so the + // array is held before it, as JavaScript reads it first. + const stmts: IrStmt[] = []; + const plain = (value: IrExpr): boolean => isSafeToRepeat(value) || plainProjection(value); + const held = (value: IrExpr, name: string): IrExpr => { + const tmp = lowerer.declareHiddenLocal(name, value.type); + stmts.push({ kind: "varDecl", localId: tmp.id, init: value, loc }); + return varRef(tmp.id, value.type, loc); + }; + const a = !mayAssign(index) && plain(arr) ? arr : held(arr, "%elementArray"); + const i = plain(index) ? index : held(index, "%elementIndex"); + return { + stmts, + present: { + kind: "bin", + op: "===", + left: { kind: "arrayState", arr: a, index: i, type: F64, loc }, + right: numLit(1, loc), + type: BOOL, + loc, + }, + element: { kind: "arrayGet", arr: a, index: i, type: elem, loc }, + message, + }; +} + +/** A write through `xs[i]!` (`xs[i]!.name = v`, `xs[i]![k] = v`): as in + * Node, the array, the index, the key and the value evaluate in order (the + * statements), then a missing element makes the write throw Node's + * TypeError and a present one takes the write, which reads the element, + * the key and the value through `overrides`. Null for other targets. */ +export function dereferencedElementWrite( + lowerer: Lowerer, + expr: ts.BinaryExpression, +): { + stmts: IrStmt[]; + present: IrExpr; + message: IrExpr; + overrides: { node: ts.Expression; value: IrExpr }[]; +} | null { + if (expr.operatorToken.kind !== ts.SyntaxKind.EqualsToken) return null; + const target = expr.left; + if ( + !(ts.isPropertyAccessExpression(target) || ts.isElementAccessExpression(target)) || + target.questionDotToken || + lowerer.chainRecvByNode.has(target.expression) + ) + return null; + let receiver: ts.Expression = target.expression; + while (ts.isParenthesizedExpression(receiver)) receiver = receiver.expression; + if (!ts.isNonNullExpression(receiver)) return null; + const loc = locOf(expr); + const keyValue = ts.isElementAccessExpression(target) + ? lowerer.lowerExpr(target.argumentExpression) + : null; + const rightValue = lowerer.lowerExpr(expr.right); + const parts = dereferencedElementParts(lowerer, receiver, loc, true); + if (parts === null) return null; + const stmts = [...parts.stmts]; + let present = parts.present; + let element = parts.element; + // JavaScript reads the element before the key and the value. When either + // has an effect, which can replace, remove or add the element, its + // presence and value are read first; otherwise the write tests and reads + // it after them. + if (!unobservable(rightValue) || (keyValue !== null && !unobservable(keyValue))) { + const presentLocal = lowerer.declareHiddenLocal("%elementPresent", BOOL); + // Assigned only when the element is present. + const elementLocal = declareContextLocal( + lowerer.ctx, + "%elementValue", + element.type, + true, + undefined, + undefined, + ); + stmts.push( + { kind: "varDecl", localId: presentLocal.id, init: present, loc }, + { kind: "varDecl", localId: elementLocal.id, init: null, loc }, + ); + present = varRef(presentLocal.id, BOOL, loc); + stmts.push({ + kind: "if", + cond: present, + then: [{ kind: "assign", localId: elementLocal.id, value: element, loc }], + else_: null, + loc, + }); + element = varRef(elementLocal.id, element.type, loc); + } + const overrides: { node: ts.Expression; value: IrExpr }[] = []; + // The write lowers the node with or without its parentheses. + const override = (node: ts.Expression, value: IrExpr): void => { + for (let n: ts.Expression = node; ; n = n.expression) { + overrides.push({ node: n, value }); + if (!ts.isParenthesizedExpression(n)) break; + } + }; + const held = (value: IrExpr, name: string, inline: boolean): IrExpr => { + if (inline) return value; + // A call without a result evaluates to undefined. + if (value.type.kind === "void") { + stmts.push({ kind: "exprStmt", expr: value, loc }); + return { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc }; + } + const tmp = lowerer.declareHiddenLocal(name, value.type); + stmts.push({ kind: "varDecl", localId: tmp.id, init: value, loc }); + return varRef(tmp.id, value.type, loc); + }; + override(target.expression, element); + let message = parts.message; + if (ts.isElementAccessExpression(target) && keyValue !== null) { + // The key evaluates before the value, which can assign the binding or + // field it reads; a literal, or an unobservable key the value cannot + // change, may evaluate in the write. + const key = held( + keyValue, + "%elementWriteKey", + keyValue.kind === "numLit" || + keyValue.kind === "strLit" || + (unobservable(keyValue) && !mayAssign(rightValue)), + ); + override(target.argumentExpression, key); + // The message names the key's value as the write evaluated it. + if (key !== keyValue || message === null) + message = keyMessage(messageHead(target), key, loc) ?? message; + } + // A value whose evaluation is unobservable may evaluate in the write + // itself, after the element is tested. + override( + expr.right, + held(rightValue, "%elementWriteValue", isSafeToRepeat(rightValue) || unobservable(rightValue)), + ); + if (message === null) return null; + return { stmts, present, message, overrides }; +} + +/** dereferencedElementWrite in value position (`f(xs[i]!.name = v)`). */ +export function lowerDereferencedElementAssignment( + lowerer: Lowerer, + expr: ts.BinaryExpression, +): IrExpr | null { + const write = dereferencedElementWrite(lowerer, expr); + if (write === null) return null; + const loc = locOf(expr); + const value = lowerer.withExpressionOverrides(write.overrides, () => lowerer.lowerExpr(expr)); + return { + kind: "seqExpr", + stmts: write.stmts, + result: { + kind: "ternary", + cond: write.present, + then: value, + else_: nodeTypeError(write.message, value.type, loc), + type: value.type, + loc, + }, + type: value.type, + loc, + }; +} + +/** Whether evaluating `e` has no effect and cannot throw: a discardable + * expression, or the compiler's element-or-undefined read of one. */ +function unobservable(e: IrExpr): boolean { + if (e.kind === "call" && e.callee.startsWith("%arr.idxOr.")) return e.args.every(unobservable); + return isSafeToDiscard(e); +} + +/** Whether evaluating `e` can assign a binding or a field: it contains an + * assignment, an increment, statements, a suspension, or a call that can + * run program code (a library call only through a function it is given). */ +function mayAssign(e: IrExpr): boolean { + switch (e.kind) { + case "call": + // The compiler's element-or-undefined read and union narrows run no + // program code. + if (!e.callee.startsWith("%arr.idxOr.") && !e.callee.startsWith("%union.narrow.")) + return true; + break; + case "assignExpr": + case "incDec": + case "seqExpr": + case "callValue": + case "virtualCall": + case "dynCall": + case "dynInvoke": + case "ffiCall": + case "new": + case "newValue": + case "awaitExpr": + case "awaitUnionExpr": + case "yieldExpr": + return true; + case "closure": + return false; + case "libCall": + if (e.args.some((arg) => arg.type.kind === "func")) return true; + break; + } + return !everyExprChild( + e, + (child) => !mayAssign(child), + () => false, + ); +} + +/** Node's TypeError, thrown in place of a value of `type`. */ +export function nodeTypeError(message: IrExpr, type: IrType, loc: SrcLoc): IrExpr { + return { + kind: "libCall", + fn: "error.nodeThrow", + args: [numLit(1, loc), strLit("", loc), message], + type, + loc, + }; +} + +/** Whether `access` is the target of `=`. */ +function isAssignmentTarget(access: ts.Expression): boolean { + let outer: ts.Expression = access; + while (ts.isParenthesizedExpression(outer.parent)) outer = outer.parent; + const parent = outer.parent; + return ( + ts.isBinaryExpression(parent) && + parent.left === outer && + parent.operatorToken.kind === ts.SyntaxKind.EqualsToken + ); +} + +/** A local, a number literal, or a field or record member read through a + * local: reading it again has no effect and yields the same value. */ +function plainProjection(e: IrExpr): boolean { + if (e.kind === "varRef" || e.kind === "numLit") return true; + return (e.kind === "fieldGet" || e.kind === "recordGet") && plainProjection(e.obj); +} + +/** Node's TypeError message for a member access on undefined: reading the + * member, or setting it when the access is the target of `=`. Null when + * the key is not a name, a literal, or a plain read of a number or string + * (which the message reads a second time). */ +function memberAccessMessage( + lowerer: Lowerer, + access: ts.PropertyAccessExpression | ts.ElementAccessExpression | ts.CallExpression, +): IrExpr | null { + const head = messageHead(access); + const loc = locOf(access); + if (ts.isPropertyAccessExpression(access)) { + if (ts.isPrivateIdentifier(access.name)) return null; + return strLit(`${head}${access.name.text}')`, loc); + } + if (!ts.isElementAccessExpression(access)) return null; + const key = runtimeOptionalElementKey(access.argumentExpression); + if (key !== null) return strLit(`${head}${key}')`, loc); + let keyNode = access.argumentExpression; + while (ts.isParenthesizedExpression(keyNode)) keyNode = keyNode.expression; + if (!ts.isIdentifier(keyNode)) return null; + const keyType = lowerer.mapTypeOf(lowerer.typeOf(keyNode))?.kind; + if (keyType !== "f64" && keyType !== "string") return null; + const keyValue = lowerer.lowerExpr(keyNode); + if (!isSafeToRepeat(keyValue) || keyValue.type.kind !== keyType) return null; + return keyMessage(head, keyValue, loc); +} + +/** The start of Node's TypeError message for a member access on undefined: + * setting the member when the access is the target of `=`, else reading + * it. */ +function messageHead(access: ts.Expression): string { + return isAssignmentTarget(access) + ? "Cannot set properties of undefined (setting '" + : "Cannot read properties of undefined (reading '"; +} + +/** Whether `access`'s key is typed a number or a string, which the message + * can name from its value. */ +function namedKey(lowerer: Lowerer, access: ts.ElementAccessExpression): boolean { + const kind = lowerer.mapTypeOf(lowerer.typeOf(access.argumentExpression))?.kind; + return kind === "f64" || kind === "string"; +} + +/** The message naming a key from its value (a number or a string), or null + * for another type. */ +function keyMessage(head: string, key: IrExpr, loc: SrcLoc): IrExpr | null { + if (key.type.kind !== "f64" && key.type.kind !== "string") return null; + const text: IrExpr = + key.type.kind === "string" ? key : { kind: "toString", operand: key, type: STRING, loc }; + const concat = (left: IrExpr, right: IrExpr): IrExpr => ({ + kind: "strConcat", + left, + right, + type: STRING, + loc, + }); + return concat(concat(strLit(head, loc), text), strLit("')", loc)); +} + +/** A string array's element converted to a string: the element, or + * "undefined" for a missing index or a hole. */ +function stringElementOrUndefined( + lowerer: Lowerer, + read: ts.ElementAccessExpression, + loc: SrcLoc, +): IrExpr | null { + if (lowerer.mapTypeOf(lowerer.typeOf(read.argumentExpression))?.kind !== "f64") return null; + const arr = lowerer.lowerExpr(read.expression); + if (arr.type.kind !== "array" || arr.type.elem.kind !== "string") return null; + const index = lowerer.lowerExprExpecting(read.argumentExpression, F64); + const stmts: IrStmt[] = []; + const held = (value: IrExpr, name: string): IrExpr => { + const tmp = lowerer.declareHiddenLocal(name, value.type); + stmts.push({ kind: "varDecl", localId: tmp.id, init: value, loc }); + return varRef(tmp.id, value.type, loc); + }; + // An index that can assign can replace the array binding or field, so + // the array is held before it, as JavaScript reads it first. + const a = !mayAssign(index) && isSafeToRepeat(arr) ? arr : held(arr, "%elementArray"); + const i = isSafeToRepeat(index) ? index : held(index, "%elementIndex"); + const value: IrExpr = { + kind: "ternary", + cond: { + kind: "bin", + op: "===", + left: { kind: "arrayState", arr: a, index: i, type: F64, loc }, + right: numLit(1, loc), + type: BOOL, + loc, + }, + then: { kind: "arrayGet", arr: a, index: i, type: STRING, loc }, + else_: strLit("undefined", loc), + type: STRING, + loc, + }; + return stmts.length === 0 ? value : { kind: "seqExpr", stmts, result: value, type: STRING, loc }; +} + +function isScalarArm(arm: IrType): boolean { + return arm.kind === "f64" || arm.kind === "string" || arm.kind === "bool" || isUnitType(arm); +} + +/** A union of numbers, strings, booleans, null and undefined with at least + * one unit arm. */ +function scalarUnion(lowerer: Lowerer, unionId: string): boolean { + const arms = lowerer.unions.get(unionId)?.arms; + return !!arms?.some(isUnitType) && arms.every(isScalarArm); +} + +/** How the converting use of `x!` (see assertionOperand) reads it: + * "number" where the use applies ToNumber, "string" where it converts the + * value to a string (a template span, `+` with a string), "value" where the + * consumer lowers the union itself (equality, truthiness, a comparison with + * a string), null for other uses. */ +function convertingUseOf( + lowerer: Lowerer, + expr: ts.NonNullExpression, +): "number" | "string" | "value" | null { let outer: ts.Expression = expr; while (ts.isParenthesizedExpression(outer.parent)) outer = outer.parent; const parent = outer.parent; - if (ts.isTemplateSpan(parent)) return parent.expression === outer ? "value" : null; + if (ts.isTemplateSpan(parent)) return parent.expression === outer ? "string" : null; if (ts.isTypeOfExpression(parent)) return "value"; if (ts.isPrefixUnaryExpression(parent)) { if (parent.operator === ts.SyntaxKind.ExclamationToken) return "value"; @@ -1140,7 +1608,7 @@ function convertingUseOf(lowerer: Lowerer, expr: ts.NonNullExpression): "number" if (op === ts.SyntaxKind.PlusEqualsToken && parent.right !== outer) return null; let partner = other; while (ts.isParenthesizedExpression(partner)) partner = partner.expression; - if (isString(other)) return ts.isNonNullExpression(partner) ? null : "value"; + if (isString(other)) return ts.isNonNullExpression(partner) ? null : "string"; if (!numericPartner(lowerer.typeOf(other))) return "value"; return isString(expr) ? null : "number"; } @@ -1226,7 +1694,8 @@ export function assertedReadObservesUndefined( lowerer: Lowerer, node: ts.NonNullExpression, ): boolean { - if (convertingUseOf(lowerer, node) === "value") return true; + const use = convertingUseOf(lowerer, node); + if (use === "value" || use === "string") return true; let outer: ts.Expression = node; while (ts.isParenthesizedExpression(outer.parent)) outer = outer.parent; const parent = outer.parent; diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index ad191f58a..d43aaabf8 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -242,10 +242,12 @@ import { globalSymbolKey } from "./expressions/global-symbols.js"; import { identifiersNamed } from "./identifier-uses.js"; import { lowerShortCircuitAssignment } from "./expressions/nullish-assignment.js"; import { + dereferencedElementWrite, jsHoldsDynObject, lowerEnvironmentKey, lowerNativeFunctionAssignment, lowerStrictEquality, + nodeTypeError, } from "./lower-exprs.js"; import { keptIdentityAssertion } from "./assertion-identity.js"; import { isNativeProxyInitializer } from "./expressions/native-proxy.js"; @@ -7992,6 +7994,29 @@ function lowerCjsSingleValueExport( * assignment, and `({ a, b } = e);` (destructuring ASSIGNMENT, which JS * requires parenthesized in statement position) lands in the * object-literal-target branch below. */ +/** dereferencedElementWrite in statement position. */ +function lowerDereferencedElementWrite(lowerer: Lowerer, expr: ts.BinaryExpression): IrStmt | null { + const write = dereferencedElementWrite(lowerer, expr); + if (write === null) return null; + const loc = locOf(expr); + return { + kind: "block", + body: [ + ...write.stmts, + { + kind: "if", + cond: write.present, + then: [ + lowerer.withExpressionOverrides(write.overrides, () => lowerExprStatement(lowerer, expr)), + ], + else_: [{ kind: "exprStmt", expr: nodeTypeError(write.message, VOID, loc), loc }], + loc, + }, + ], + loc, + }; +} + export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStmt { const stmtNode = expr.parent; while (ts.isParenthesizedExpression(expr)) expr = expr.expression; @@ -8254,6 +8279,10 @@ export function lowerExprStatement(lowerer: Lowerer, expr: ts.Expression): IrStm const ex = lowerExpandoAssignStmt(lowerer, expr); if (ex) return ex; } + { + const dereferenced = lowerDereferencedElementWrite(lowerer, expr); + if (dereferenced) return dereferenced; + } if (ts.isElementAccessExpression(expr.left)) return lowerer.lowerElementWrite(expr); if (ts.isPropertyAccessExpression(expr.left)) { fenceNodeModuleMutation(lowerer, expr.left, "assignment"); @@ -11824,6 +11853,7 @@ function lowerForOfDyn( const iterator = lowerer.declareHiddenLocal("%dofIterator", DYN); const next = lowerer.declareHiddenLocal("%dofNext", DYN); const fast = lowerer.declareHiddenLocal("%dofNative", BOOL); + const generator = lowerer.declareHiddenLocal("%dofGenerator", F64); const done = lowerer.declareHiddenLocal("%dofDone", BOOL); done.mutable = true; const value = lowerer.declareHiddenLocal("%dofValue", DYN); @@ -11833,6 +11863,15 @@ function lowerForOfDyn( needsClose.mutable = true; const error = lowerer.declareHiddenLocal("%dofError", CAUGHT); const syncIterator = asyncLoop ? lowerer.declareHiddenLocal("%dofSync", BOOL) : null; + lowerer.untypedLoops.set(generator, { + kind: generator.id, + value: value.id, + iterator: iterator.id, + next: next.id, + done: done.id, + async: asyncLoop, + sync: syncIterator?.id ?? null, + }); const awaitDyn = (value: IrExpr): IrExpr => ({ kind: "libCall", fn: "async.awaitDyn", @@ -12107,6 +12146,14 @@ function lowerForOfDyn( : iteratorCanStep(varRef(iterator.id, DYN, loc), varRef(next.id, DYN, loc), loc), loc, }, + // Which of the program's generator types the iterator is, once + // generator-dynamic-dispatch.ts knows them; 0 keeps the protocol. + { + kind: "varDecl", + localId: generator.id, + init: { kind: "numLit", value: 0, type: F64, loc }, + loc, + }, { kind: "varDecl", localId: done.id, diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 601be16c4..3d10d1982 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -21,7 +21,7 @@ import { import { isDeclBoundaryFile } from "../decl-projection.js"; import { relieveHeapPressure } from "../../heap-pressure.js"; import { moduleNamespaceObject, moduleNamespaceValue } from "./module-namespace-elements.js"; -import { keyedHelperName } from "./stable-names.js"; +import { keyedHelperName, moduleTagNumber, portableRelativePath } from "./stable-names.js"; import { positionTag, StructuralPositions } from "./structural-positions.js"; import { bindingSymbolOfRead, onlyForwarded, stripTypeWrappers } from "./typed-arguments.js"; import { @@ -217,7 +217,7 @@ export { newFnCtx, type FnCtx } from "./function-context.js"; * - Lexical scoping is resolved here: locals get function-unique ids * ("x.0", "x.1" for shadowing); the IR is scope-flat. */ -import { dirname, relative, resolve } from "node:path"; +import { dirname, resolve } from "node:path"; import { bindingSource } from "../binding-source.js"; import { compilerOwnedFileName, tsgoPath } from "../dts-paths.js"; import * as ts from "../ts7/adapter.js"; @@ -2068,6 +2068,21 @@ function byName(fns: readonly IrFunction[]): IrFunction[] { ); } +/** A loop over an untyped iterable (lowerForOfDyn). `kind` is a local the + * loop sets once it has opened the iterator: 0 for the dynamic protocol, + * or the 1-based index of the program's generator type it steps natively. + * `value` is the per-step local whose initializer takes one step. */ +export interface UntypedLoopSite { + kind: string; + value: string; + iterator: string; + next: string; + done: string; + /** `for await`; `sync` names its async-from-sync flag. */ + async: boolean; + sync: string | null; +} + /** The program module a createRequire binding requires. */ type CreateRequireTarget = NonNullable>; @@ -3591,6 +3606,11 @@ export class Lowerer { * values other than fresh literals (noteUntypedIntegrity). */ untypedIntegrity = false; readonly liftedFns: IrFunction[] = []; + /** `for … of` and `for await` loops over untyped iterables, by their + * generator-kind local (generator-dynamic-dispatch.ts steps the program's + * own generators there on the typed path). Local ids repeat across + * functions; the local itself is in its function's locals. */ + readonly untypedLoops = new Map(); /** global-values.ts's answer per JavaScript source: the receiver of its * first read of the global object by a computed name, or null. */ readonly computedGlobalReads = new Map(); @@ -3616,12 +3636,17 @@ export class Lowerer { * names do not depend on where the program sits, default library files * by their base name (two checkers install them in different * directories), and the files scriptc ships by their name inside scriptc, - * so names do not depend on where scriptc is installed either. */ - readonly positions = new StructuralPositions((sf) => - this.program.isSourceFileDefaultLibrary(sf) + * so names do not depend on where scriptc is installed either. Relative + * paths use `/` on every platform. */ + readonly positions = new StructuralPositions((sf) => this.stableFileName(sf)); + + /** The name of `sf` in positions and module tags. */ + stableFileName(sf: ts.SourceFile): string { + return this.program.isSourceFileDefaultLibrary(sf) ? `lib:${sf.fileName.slice(sf.fileName.lastIndexOf("/") + 1)}` - : (compilerOwnedFileName(sf.fileName) ?? relative(dirname(this.entry.fileName), sf.fileName)), - ); + : (compilerOwnedFileName(sf.fileName) ?? + portableRelativePath(dirname(this.entry.fileName), sf.fileName)); + } /** The outermost node whose token span is exactly `loc`, or null. */ nodeAtLoc(loc: SrcLoc): ts.Node | null { @@ -4320,9 +4345,20 @@ export class Lowerer { // moduleOrder BEFORE any pass constructed — lowerToIr runs // appendDynamicImportModules once on the shared array (a per-pass run // here would repeatedly extend the graph and duplicate cycle reports). - this.moduleOrder.forEach((sf, i) => { - this.fileTag.set(sf, sf === entry ? "" : `%m${i}.`); - }); + // A module's tag hashes its stable file name rather than numbering its + // place in moduleOrder, so adding or reordering modules leaves the other + // modules' function names, and the closure names keyed by them, + // unchanged. The native compiler's profile matches functions by name. + const tagNumbers = new Set(); + for (const sf of this.moduleOrder) { + if (sf === entry) { + this.fileTag.set(sf, ""); + continue; + } + const n = moduleTagNumber(this.stableFileName(sf), tagNumbers); + tagNumbers.add(n); + this.fileTag.set(sf, `%m${n}.`); + } if (this.moduleOrder.length === 0) this.fileTag.set(entry, ""); const jsDynamic = jsDynamicDeclarations(this.moduleOrder, this.checker); if (jsDynamic.size > 0) this.typeCtx.jsDynamicDeclaration = (decl) => jsDynamic.has(decl); diff --git a/packages/compiler/src/frontend/lowering/nan-coded-reads.ts b/packages/compiler/src/frontend/lowering/nan-coded-reads.ts index 24c981bd3..9456cfa18 100644 --- a/packages/compiler/src/frontend/lowering/nan-coded-reads.ts +++ b/packages/compiler/src/frontend/lowering/nan-coded-reads.ts @@ -451,6 +451,19 @@ export class NanCodedReads { // ---- present reads ------------------------------------------------------ + /** `xs[i]` whose index is a non-negative integer counter of the reading + * function and that a dominating `i < xs.length` guard bounds by the + * length: the read names an existing index. */ + counterIndexInBounds(read: ts.ElementAccessExpression): boolean { + const index = peelTransparent(read.argumentExpression); + if (!ts.isIdentifier(index)) return false; + const symbol = this.symbolOf(index); + if (!symbol || !this.nonNegativeCounter(symbol)) return false; + const decl = this.host.declarationOf(symbol); + if (!decl || this.containingFunction(decl) !== this.containingFunction(read)) return false; + return indexReadInBounds(read); + } + /** `t[i]` names an existing element: `i` is a non-negative integer * counter and a dominating guard bounds it by `t.length` (or by the * length of a typed array that always has the same length). diff --git a/packages/compiler/src/frontend/lowering/stable-names.test.ts b/packages/compiler/src/frontend/lowering/stable-names.test.ts new file mode 100644 index 000000000..dbef9b29e --- /dev/null +++ b/packages/compiler/src/frontend/lowering/stable-names.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "vitest"; +import { moduleTagNumber, portableRelativePath } from "./stable-names.js"; + +test("module tag numbers depend only on the file name and stay distinct", () => { + const first = moduleTagNumber("lib/a.ts", new Set()); + expect(moduleTagNumber("lib/a.ts", new Set([7, 8]))).toBe(first); + expect(moduleTagNumber("lib/b.ts", new Set())).not.toBe(first); + expect(moduleTagNumber("lib/a.ts", new Set([first]))).toBe((first + 1) >>> 0); + expect(moduleTagNumber("lib/a.ts", new Set([first, (first + 1) >>> 0]))).toBe((first + 2) >>> 0); +}); + +test("relative module paths use / on every platform", () => { + expect(portableRelativePath("/repo/src", "/repo/src/lib/a.ts", "linux")).toBe("lib/a.ts"); + expect(portableRelativePath("C:/repo/src", "C:/repo/src/lib/a.ts", "win32")).toBe("lib/a.ts"); + expect(portableRelativePath("C:\\repo\\src", "C:/repo/node_modules/x/index.d.ts", "win32")).toBe( + "../node_modules/x/index.d.ts", + ); + expect( + moduleTagNumber(portableRelativePath("C:/repo", "C:/repo/lib/a.ts", "win32"), new Set()), + ).toBe(moduleTagNumber(portableRelativePath("/repo", "/repo/lib/a.ts", "linux"), new Set())); +}); diff --git a/packages/compiler/src/frontend/lowering/stable-names.ts b/packages/compiler/src/frontend/lowering/stable-names.ts index 8f7d0967e..0d57bd2c9 100644 --- a/packages/compiler/src/frontend/lowering/stable-names.ts +++ b/packages/compiler/src/frontend/lowering/stable-names.ts @@ -1,4 +1,7 @@ +import * as posix from "node:path/posix"; +import * as win32 from "node:path/win32"; import { InternalCompilerError } from "../../errors.js"; +import { tsgoPath } from "../ts7/session-path.js"; /** The owner of the names keyedHelperName hands out. */ export interface KeyedNameOwner { @@ -46,3 +49,26 @@ export function keyedHelperName( throw new InternalCompilerError(`helper name ${name} is shared by two helper keys`); return name; } + +/** The number in the `%m.` tag of the module named `fileName`: a + * 32-bit FNV-1a hash of the name, so a module keeps its tag when other + * modules are added, removed or reordered. A number in `taken` moves to the + * next free one, which keeps tags distinct. */ +export function moduleTagNumber(fileName: string, taken: ReadonlySet): number { + let h = 0x811c9dc5; + for (let i = 0; i < fileName.length; i++) h = Math.imul(h ^ fileName.charCodeAt(i), 0x01000193); + let n = h >>> 0; + while (taken.has(n)) n = (n + 1) >>> 0; + return n; +} + +/** The path of `file` relative to the directory `from`, with `/` separators + * on every platform, so names built from it match across hosts. */ +export function portableRelativePath( + from: string, + file: string, + platform: NodeJS.Platform = process.platform, +): string { + const path = platform === "win32" ? win32.relative(from, file) : posix.relative(from, file); + return tsgoPath(path, platform); +} diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index 1334b9b99..be5f29768 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -1484,6 +1484,39 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): continue; } const int32 = (e: IrExpr): boolean => withinInt32(ranges.get(e)); + // The range of an exact integer that may also be NaN: an element read + // of a candidate through `getNumber` (`a[k]!` in arithmetic, NaN for a + // missing element), and `+`, `-` and `*` over such values and integers. + // A checked store widens the array at a NaN like at any value that is + // not an int32. + const integerOrNaN = (e: IrExpr): IntegerRange | null => { + const range = ranges.get(e); + if (range) return range; + if (e.kind === "numLit") + return Number.isInteger(e.value) ? { min: e.value, max: e.value } : null; + if (e.kind === "arrIntrinsic" && e.method === "getNumber" && e.receiver.kind === "varRef") + return slots.hasInt32ElementSlot(int32ArraySlot(int32ArrayKey(fn.name, e.receiver.localId))) + ? INT32_RANGE + : null; + if (e.kind !== "bin" || (e.op !== "+" && e.op !== "-" && e.op !== "*")) return null; + const left = integerOrNaN(e.left); + const right = left && integerOrNaN(e.right); + if (!left || !right) return null; + const bounds = + e.op === "+" + ? [left.min + right.min, left.max + right.max] + : e.op === "-" + ? [left.min - right.max, left.max - right.min] + : [ + left.min * right.min, + left.min * right.max, + left.max * right.min, + left.max * right.max, + ]; + const min = Math.min(...bounds); + const max = Math.max(...bounds); + return Math.abs(min) < 2 ** 53 && Math.abs(max) < 2 ** 53 ? { min, max } : null; + }; // Every element stored into an int32 array candidate must be an int32 // under the facts that assume the candidates' own elements are; a // failing store drops its array's element facts, and the function is @@ -1513,6 +1546,10 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): for (const [id, values] of int32ArrayStores(fn, arrays)) { const key = int32ArrayKey(fn.name, id); if (!slots.hasInt32ElementSlot(int32ArraySlot(key)) || values.every(int32)) continue; + // Checked stores serve a lone local: its allocations are the only + // arrays it holds. Its stores are judged under the element facts + // the ranges were computed with. + const checkable = values.every((value) => int32(value) || integerOrNaN(value) !== null); // The array's group drops together: every function reading a // member is analyzed again without its facts. const group = arrayGroups.group.get(key) ?? [key]; @@ -1523,17 +1560,11 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): if (names) for (const name of names) requeue(name); } else requeue(member.slice(0, member.indexOf("\0"))); } - // Checked stores serve a lone local: its allocations are the only - // arrays it holds. - const checkable = (value: IrExpr): boolean => { - const range = ranges.get(value); - return int32(value) || range != null; - }; if ( group.length === 1 && !key.startsWith("\0") && !params.has(id) && - values.every(checkable) && + checkable && checkableInt32Allocations(fn, id) ) slots.checkedInt32Arrays.add(key); diff --git a/packages/compiler/src/ir/parameter-versions.ts b/packages/compiler/src/ir/parameter-versions.ts index 5156d4bff..ca2958585 100644 --- a/packages/compiler/src/ir/parameter-versions.ts +++ b/packages/compiler/src/ir/parameter-versions.ts @@ -122,7 +122,7 @@ function numericLoop(loop: IrStmt, functions: ReadonlyMap): const target = functions.get(e.callee); allowed = target !== undefined && simple(target); } else if (e.kind === "libCall") allowed = e.fn.startsWith("math."); - else if (e.kind === "arrIntrinsic") allowed = e.method === "length"; + else if (e.kind === "arrIntrinsic") allowed = e.method === "length" || e.method === "getNumber"; return allowed && everyExprChild(e, expr, stmt); }; const stmt = (s: IrStmt): boolean => diff --git a/packages/compiler/test/await-call-emission.test.ts b/packages/compiler/test/await-call-emission.test.ts index 5a967cc5f..cb575e97c 100644 --- a/packages/compiler/test/await-call-emission.test.ts +++ b/packages/compiler/test/await-call-emission.test.ts @@ -9,13 +9,19 @@ afterEach(async () => { await Promise.all(directories.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); }); -async function emit(source: string): Promise { +async function emit(source: string, optimization?: "dev"): Promise { const outDir = await mkdtemp(join(tmpdir(), "scriptc-await-call-")); directories.push(outDir); const entry = join(outDir, "main.ts"); await writeFile(entry, source); const outPath = join(outDir, "program.ll"); - const result = await compile(entry, { entry, outDir, outPath, outputKind: "llvm" }); + const result = await compile(entry, { + entry, + outDir, + outPath, + outputKind: "llvm", + ...(optimization !== undefined ? { optimization } : {}), + }); expect(result.ok, JSON.stringify(result)).toBe(true); return readFile(outPath, "utf8"); } @@ -28,8 +34,9 @@ function definition(llvm: string, symbol: string): string { return match![0]; } -// Consuming an async generator keeps a body on its fiber: the await-call -// entries serve fiber bodies (stackless bodies suspend at their awaits). +// The await-call entries serve fiber bodies (stackless bodies suspend at +// their awaits). Debug builds keep every async body on its fiber, so the +// sources below that test them build with optimization "dev". const tick = ` async function* tick(): AsyncGenerator { yield 0; @@ -58,7 +65,7 @@ void main(); `; test("awaiting a direct call to a body that never suspends skips the promise", async () => { - const llvm = await emit(source); + const llvm = await emit(source, "dev"); expect(definition(llvm, "sc_f_middle")).toContain("call double @sc_aw_leaf(double"); const entry = definition(llvm, "sc_aw_leaf"); expect(entry).toContain("call double @sc_f_leaf(double %a0)"); @@ -74,7 +81,7 @@ test("awaiting a direct call to a body that never suspends skips the promise", a }); test("awaiting a direct call to a suspending body spawns and awaits in one call", async () => { - const llvm = await emit(source); + const llvm = await emit(source, "dev"); const main = definition(llvm, "sc_f_main"); expect(main).toContain("call double @sc_aw_middle(double"); // A call whose promise is kept keeps the promise-returning entry. @@ -82,7 +89,7 @@ test("awaiting a direct call to a suspending body spawns and awaits in one call" expect(definition(llvm, "sc_aw_middle")).toContain( "call double @scr_async_spawn_await_f64(ptr @sc_tr_middle, ptr %ap)", ); - // middle consumes an async generator, so it keeps its fiber. + // The debug build keeps middle on its fiber. expect(definition(llvm, "sc_as_middle")).toContain("@scr_async_spawn("); }); @@ -119,7 +126,7 @@ void main(); `; test("returning a direct call adopts its promise one microtask turn later", async () => { - const llvm = await emit(adoptSource); + const llvm = await emit(adoptSource, "dev"); expect(definition(llvm, "sc_f_relay")).toContain("call double @sc_ad_leaf(double"); // A body that never suspends: the adoption's then-job turn, then the // settled promise's own turn, still without materializing the promise. @@ -141,7 +148,7 @@ test("returning a direct call adopts its promise one microtask turn later", asyn }); test("a returned promise is adopted after finally regions, outside catch handlers", async () => { - const llvm = await emit(adoptSource); + const llvm = await emit(adoptSource, "dev"); const guarded = definition(llvm, "sc_f_guarded"); // The call produces the promise; the finally body runs; only then the // adoption turn and the await. @@ -167,7 +174,7 @@ async function middle(x: number): Promise { const z = await leaf(y); return y * 2 + z; } -async function fibered(): Promise { +async function fibered(): Promise { let s = 0; for await (const t of tick()) s += t; return s + (await middle(1)); @@ -206,8 +213,9 @@ test("bodies that suspend only at their own awaits compile to stackless coroutin expect(middle).not.toContain("@scr_await_f64("); expect(definition(llvm, "sc_as_middle")).toContain("@scr_async_spawn_stackless("); expect(definition(llvm, "sc_f_main")).toContain("@scr_coro_await_prepare("); - // A body that keeps its fiber still awaits a stackless callee in one - // call: spawn, then the ordinary await of its promise. + // A body with a dynamic result keeps its fiber, and still awaits a + // stackless callee in one call: spawn, then the ordinary await of its + // promise. expect(definition(llvm, "sc_as_fibered")).toContain("@scr_async_spawn("); expect(definition(llvm, "sc_f_fibered")).toContain("call double @sc_aw_middle(double"); const entry = definition(llvm, "sc_aw_middle"); diff --git a/packages/compiler/test/generator-emission.test.ts b/packages/compiler/test/generator-emission.test.ts index 47e5f16c5..6601813bd 100644 --- a/packages/compiler/test/generator-emission.test.ts +++ b/packages/compiler/test/generator-emission.test.ts @@ -9,13 +9,19 @@ afterEach(async () => { await Promise.all(directories.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); }); -async function emit(source: string): Promise { +async function emit(source: string, optimization?: "dev"): Promise { const outDir = await mkdtemp(join(tmpdir(), "scriptc-generator-")); directories.push(outDir); const entry = join(outDir, "main.ts"); await writeFile(entry, source); const outPath = join(outDir, "program.ll"); - const result = await compile(entry, { entry, outDir, outPath, outputKind: "llvm" }); + const result = await compile(entry, { + entry, + outDir, + outPath, + outputKind: "llvm", + ...(optimization !== undefined ? { optimization } : {}), + }); expect(result.ok, JSON.stringify(result)).toBe(true); return readFile(outPath, "utf8"); } @@ -117,7 +123,7 @@ console.log(sum, r.value); expect(llvm).toMatch(/define internal ptr @sc_gres_\d+\(ptr %g\)[^\n]*\(dyn channel\)/); }); -test("async generators keep their fibers", async () => { +test("async generators that suspend only at their own awaits and yields compile to stackless coroutines", async () => { const llvm = await emit(` async function* ticks(n: number): AsyncGenerator { for (let i = 0; i < n; i++) { @@ -130,6 +136,36 @@ async function main(): Promise { } void main(); `); + const body = definition(llvm, "sc_f_ticks"); + expect(body).toMatch(/^define internal void @sc_f_ticks(?:\.i32)?\(double [^)]*\) #1 /); + // The await suspends the coroutine (one turn for a non-promise operand), + // and the yield stores OUT before its own suspension. + expect(body).toContain("call zeroext i1 @scr_coro_hop_prepare(ptr"); + expect(body).toContain("call void @scr_coro_yield_f64(ptr"); + expect(body).toContain("call void @scr_gen_coro_finish(ptr"); + expect(body).not.toContain("@scr_await_hop("); + expect(body).not.toContain("@scr_gen_yield_f64("); + expect(definition(llvm, "sc_gs_ticks")).toContain("call ptr @scr_async_gen_new_stackless("); + // Consuming the generator does not park its consumer. + expect(definition(llvm, "sc_as_main")).toContain("@scr_async_spawn_stackless("); +}); + +test("debug builds keep async generators on their fibers", async () => { + const llvm = await emit( + ` +async function* ticks(n: number): AsyncGenerator { + for (let i = 0; i < n; i++) { + await null; + yield i; + } +} +async function main(): Promise { + for await (const t of ticks(3)) console.log(t); +} +void main(); +`, + "dev", + ); expect(definition(llvm, "sc_gs_ticks")).toContain("call ptr @scr_async_gen_new("); expect(llvm).not.toContain("presplitcoroutine"); }); diff --git a/packages/compiler/test/js-ic-emission.test.ts b/packages/compiler/test/js-ic-emission.test.ts index b350c743d..e0ad8e329 100644 --- a/packages/compiler/test/js-ic-emission.test.ts +++ b/packages/compiler/test/js-ic-emission.test.ts @@ -50,6 +50,10 @@ console.log(read(o), at(dyn([5, 6]), 1), call(o), o.n); ); expect(body(llvm, "read")).toContain("@scr_js_get_probe("); expect(body(llvm, "write")).toContain("@scr_js_store_probe("); + // Reads and writes test the first two entries inline: the second + // starts 40 bytes into the record. + for (const site of ["read", "write"]) + expect(body(llvm, site)).toMatch(/getelementptr inbounds i8, ptr @sc_js_ic_\d+, i64 40$/m); expect(body(llvm, "at")).toContain("@scr_js_keyed_probe("); expect(body(llvm, "call")).toContain("@scr_js_load_probe("); // A compound assignment stores its number result from the jsval: an diff --git a/packages/compiler/test/stable-names.test.ts b/packages/compiler/test/stable-names.test.ts index c82db039d..13ed000df 100644 --- a/packages/compiler/test/stable-names.test.ts +++ b/packages/compiler/test/stable-names.test.ts @@ -117,3 +117,44 @@ test("names do not depend on the directory the program sits in", async () => { const [second] = await lower(recursive); expect(names(second!)).toEqual(names(first!)); }); + +/** Lowers a program of several files; `main.ts` is the entry. */ +async function lowerFiles(files: Record): Promise { + const dir = await mkdtemp(join(tmpdir(), "scriptc-stable-names-")); + try { + for (const [name, source] of Object.entries(files)) await writeFile(join(dir, name), source); + const outPath = join(dir, "main.ir.json"); + const result = await compile(join(dir, "main.ts"), { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) + throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n")); + return deserializeModule(await readFile(outPath, "utf8")); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +test("a module's names do not depend on the modules loaded before it", async () => { + const shapes = ` +export class Box { + constructor(private value: number) {} + scaled(by: number): number { return this.value * by; } +} +export function boxes(count: number): number[] { + return Array.from({ length: count }, (_, i) => new Box(i).scaled(2)); +} +`; + const before = await lowerFiles({ + "shapes.ts": shapes, + "main.ts": `import { boxes } from "./shapes";\nconsole.log(boxes(3).join(","));\n`, + }); + const after = await lowerFiles({ + "offsets.ts": `export function offset(x: number): number { return x + 1; }\n`, + "shapes.ts": shapes, + "main.ts": `import { offset } from "./offsets";\nimport { boxes } from "./shapes";\nconsole.log(offset(1), boxes(3).join(","));\n`, + }); + // The class, its methods, the function and the closure it lifts. + const ofShapes = (mod: IrModule) => + names(mod).filter((name) => /Box|boxes/.test(name) || name.startsWith("%fn.")); + expect(ofShapes(before).length).toBeGreaterThanOrEqual(4); + expect(ofShapes(after)).toEqual(ofShapes(before)); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7393-non-null-element-operands.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7393-non-null-element-operands.ts.json new file mode 100644 index 000000000..e50ca92f4 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7393-non-null-element-operands.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7393-non-null-element-operands.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7395-non-null-element-dereference.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7395-non-null-element-dereference.ts.json new file mode 100644 index 000000000..17551e626 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/7395-non-null-element-dereference.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/7395-non-null-element-dereference.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8370-stack-overflow-cycles/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8370-stack-overflow-cycles/main.ts.json new file mode 100644 index 000000000..7e5125dde --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8370-stack-overflow-cycles/main.ts.json @@ -0,0 +1,7 @@ +{ + "order": [ + "/tests/corpus/8370-stack-overflow-cycles/shapes.ts", + "/tests/corpus/8370-stack-overflow-cycles/main.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8380-untyped-generator-loops.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8380-untyped-generator-loops.mjs.json new file mode 100644 index 000000000..91ef13397 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8380-untyped-generator-loops.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8380-untyped-generator-loops.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8381-untyped-generator-stages.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8381-untyped-generator-stages.mjs.json new file mode 100644 index 000000000..f272e7d88 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8381-untyped-generator-stages.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8381-untyped-generator-stages.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8382-stackless-async-generator-steps.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8382-stackless-async-generator-steps.mjs.json new file mode 100644 index 000000000..aa47502ae --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8382-stackless-async-generator-steps.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8382-stackless-async-generator-steps.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8383-stackless-async-generator-steps.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8383-stackless-async-generator-steps.ts.json new file mode 100644 index 000000000..2c9f5537a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8383-stackless-async-generator-steps.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8383-stackless-async-generator-steps.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8407-string-index-of-from-ascii.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8407-string-index-of-from-ascii.ts.json new file mode 100644 index 000000000..15651f491 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/8407-string-index-of-from-ascii.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/8407-string-index-of-from-ascii.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-array-subclass.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-array-subclass.ts.json new file mode 100644 index 000000000..84bff57e0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-array-subclass.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/arena-array-subclass.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-arrays.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-arrays.ts.json new file mode 100644 index 000000000..eafbe937c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-arrays.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/arena-arrays.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-cap.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-cap.ts.json new file mode 100644 index 000000000..70593bc13 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-cap.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/arena-cap.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-escapes.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-escapes.ts.json new file mode 100644 index 000000000..402b73088 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-escapes.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/arena-escapes.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-mixed.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-mixed.ts.json new file mode 100644 index 000000000..17269a5b1 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-mixed.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/arena-mixed.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-outside.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-outside.ts.json new file mode 100644 index 000000000..59a1a597c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-outside.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/arena-outside.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-region-throws.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-region-throws.ts.json new file mode 100644 index 000000000..392abe549 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-region-throws.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/arena-region-throws.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-regions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-regions.ts.json new file mode 100644 index 000000000..7f2425ffc --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-regions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/arena-regions.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-thrown-member.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-thrown-member.ts.json new file mode 100644 index 000000000..8657adc5c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-thrown-member.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/arena-thrown-member.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-throws.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-throws.ts.json new file mode 100644 index 000000000..c21f13a02 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-throws.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/arena-throws.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-trees.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-trees.ts.json new file mode 100644 index 000000000..9d9b75df2 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-trees.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/arena-trees.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-workers.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-workers.ts.json new file mode 100644 index 000000000..97915bb85 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/arena-workers.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/arena-workers.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/typed-array-nested-loop-versions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/typed-array-nested-loop-versions.ts.json new file mode 100644 index 000000000..66c967b58 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/typed-array-nested-loop-versions.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/typed-array-nested-loop-versions.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/untyped-nullish-scalar-defaults.mjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/untyped-nullish-scalar-defaults.mjs.json new file mode 100644 index 000000000..2bce0e918 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/untyped-nullish-scalar-defaults.mjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/untyped-nullish-scalar-defaults.mjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/untyped-nullish-scalar.test.ts b/packages/compiler/test/untyped-nullish-scalar.test.ts new file mode 100644 index 000000000..c9e496b88 --- /dev/null +++ b/packages/compiler/test/untyped-nullish-scalar.test.ts @@ -0,0 +1,46 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule, validateModule } from "../src/index.js"; + +test("`??` in JavaScript tests a scalar optional left on the typed path when the default has its type", async () => { + const dir = mkdtempSync(join(tmpdir(), "scriptc-untyped-nullish-")); + try { + const entry = join(dir, "main.mjs"); + writeFileSync( + entry, + [ + "const counts = new Map();", + 'for (const w of ["a", "b", "a"]) counts.set(w, (counts.get(w) ?? 0) + 1);', + 'console.log(counts.get("a") ?? 0, counts.get("z") ?? "none");', + ].join("\n"), + ); + const output = join(dir, "main.ir.json"); + const lowered = await compile(entry, { outputKind: "ir", outDir: dir, outPath: output }); + if (!lowered.ok) throw new Error(JSON.stringify(lowered.diagnostics)); + const module = deserializeModule(readFileSync(output, "utf8")); + expect(validateModule(module)).toEqual([]); + const nullish: { type: string; left: string }[] = []; + const visit = (node: unknown, parent: string | null): void => { + if (node === null || typeof node !== "object") return; + if (Array.isArray(node)) return node.forEach((child) => visit(child, parent)); + const n = node as { kind?: string; type?: { kind: string }; left?: { kind: string } }; + if (n.kind === "nullish") + nullish.push({ type: `${parent}>${n.type!.kind}`, left: n.left!.kind }); + for (const [key, value] of Object.entries(node)) + if (key !== "loc") visit(value, n.kind ?? parent); + }; + visit(module.functions, null); + // The loop's `?? 0`, whose result is an untyped value, reads the Map's + // number on the typed path and converts only the result. (The first + // console.log argument is a number already.) `?? "none"` takes another + // type, so its left converts first. + expect( + nullish.filter((n) => n.type === "dynFrom>f64" && n.left === "mapIntrinsic"), + ).toHaveLength(1); + expect(nullish.filter((n) => n.type.endsWith(">dyn") && n.left === "dynFrom")).toHaveLength(1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/runtime-pack-common/runtime-pack-matrix.mjs b/packages/runtime-pack-common/runtime-pack-matrix.mjs index 4ba2210fe..729e99c4b 100644 --- a/packages/runtime-pack-common/runtime-pack-matrix.mjs +++ b/packages/runtime-pack-common/runtime-pack-matrix.mjs @@ -47,6 +47,7 @@ export const BASE_RUNTIME_SOURCES = [ "scr_cycle.c", "scr_publish.c", "scr_region.c", + "scr_arena.c", "scr_itable.c", "scr_slots.c", "scr_alloc.c", diff --git a/packages/runtime/src/scr_arena.c b/packages/runtime/src/scr_arena.c new file mode 100644 index 000000000..e04a6ed35 --- /dev/null +++ b/packages/runtime/src/scr_arena.c @@ -0,0 +1,263 @@ +/* Group arenas: see scr_arena.h. */ +#include "scr_runtime.h" + +#include +#include +#include + +/* The first chunk lives inside the arena, so a small group (one result + * record, a short builder) touches no other memory. */ +#define SCR_ARENA_INLINE 512 +#define SCR_ARENA_CHUNK_MIN ((size_t)4096) +#define SCR_ARENA_CHUNK_MAX ((size_t)1 << 20) +/* Bytes an arena that is not closed takes before its call allocates on the + * heap again: a group whose objects die before it is dropped holds at most + * this much dead memory. A closed arena has no cap: once on the heap, its + * later objects could come to be held by earlier ones, which are not on + * its dispose list. Neither do objects with a collector header: on the + * heap, they could form cycles that outlive the drop (llvm/arenas.ts). */ +#define SCR_ARENA_CAP ((size_t)32 << 20) +/* What a thread keeps for reuse: arenas, chunk bytes, and dispose list + * entries per pooled arena. */ +#define SCR_ARENA_POOL 64 +#define SCR_ARENA_POOL_BYTES ((size_t)8 << 20) +#define SCR_ARENA_POOL_DISPOSALS ((size_t)4096) + +/* A chunk's objects run from scr_arena_chunk_start to its end. */ +typedef struct ScrArenaChunk { + struct ScrArenaChunk *next; + size_t size; /* bytes, this header included */ +} ScrArenaChunk; + +/* The leading fields are an ABI: emitted arena-aware constructors bump + * `bump` up to `limit`, test `closed`, and append to the dispose list at + * `disposals` up to `disposals_end` inline (llvm/arenas.ts). Without the + * small-object allocator `bump` and `limit` stay NULL, so every object + * takes scr_arena_obj and is a block of its own. */ +struct ScrArena { + char *bump, *limit; + ScrArenaDisposal *disposals, *disposals_end; + uint32_t closed; + uint32_t open; /* still the allocation target, or under one that is */ + ScrArena *outer; /* the allocation target before it opened */ + ScrArena *next; /* its scope's list, or the pool */ + ScrArenaDisposal *disposal_base; + ScrArenaChunk *chunks; /* newest first */ + size_t bytes; +#if !SCR_SMALL_ALLOC + char **blocks; + size_t nblocks, cap; +#endif + _Alignas(16) char inline_chunk[SCR_ARENA_INLINE]; +}; +/* llvm/arenas.ts addresses these fields in pointer-sized units. */ +#define SCR_ARENA_PTR sizeof(void *) +_Static_assert(offsetof(ScrArena, bump) == 0 && offsetof(ScrArena, limit) == SCR_ARENA_PTR, + "llvm/arenas.ts bumps the arena inline"); +_Static_assert(offsetof(ScrArena, disposals) == 2 * SCR_ARENA_PTR && + offsetof(ScrArena, disposals_end) == 3 * SCR_ARENA_PTR && + offsetof(ScrArena, closed) == 4 * SCR_ARENA_PTR, + "llvm/arenas.ts appends to the dispose list inline"); +_Static_assert(sizeof(ScrArenaDisposal) == 2 * SCR_ARENA_PTR, + "llvm/arenas.ts stores dispose entries of two pointers"); +_Static_assert(offsetof(ScrArenaHead, disposals) == offsetof(ScrArena, disposals) && + offsetof(ScrArenaHead, closed) == offsetof(ScrArena, closed), + "ScrArenaHead is ScrArena's prefix"); + +SCR_TL ScrArena *scr_arena_cur = NULL; +static SCR_TL ScrArena *scr_arena_pool; +static SCR_TL unsigned scr_arena_pooled; +static SCR_TL ScrArenaChunk *scr_arena_chunk_pool; +static SCR_TL size_t scr_arena_chunk_bytes; + +void *scr_arena_current(void) { return scr_arena_cur; } + +void *scr_arena_suspend(void) { + ScrArena *a = scr_arena_cur; + scr_arena_cur = NULL; + return a; +} + +void scr_arena_resume(void *arena) { scr_arena_cur = (ScrArena *)arena; } + +void *scr_arena_open(void **list, int32_t closed) { + ScrArena *a = scr_arena_pool; + if (a) { + scr_arena_pool = a->next; + scr_arena_pooled--; + } else { + a = malloc(sizeof *a); + if (!a) scr_trap("scriptc: out of memory\n"); + a->disposal_base = NULL; + a->disposals_end = NULL; +#if !SCR_SMALL_ALLOC + a->blocks = NULL; + a->cap = 0; +#endif + } + a->closed = closed != 0; + a->chunks = NULL; + a->bytes = 0; + a->disposals = a->disposal_base; +#if SCR_SMALL_ALLOC + a->bump = a->inline_chunk; + a->limit = a->inline_chunk + SCR_ARENA_INLINE; +#else + a->bump = a->limit = NULL; + a->nblocks = 0; +#endif + a->open = 1; + a->outer = scr_arena_cur; + scr_arena_cur = a; + a->next = (ScrArena *)*list; + *list = a; + return a; +} + +/* Makes `a`'s outer target current again, if `a` is still the target or + * under it: an exception can leave the arenas a call opened unclosed until + * their scopes drop them. */ +static void scr_arena_unlink(ScrArena *a) { + if (!a->open) return; + a->open = 0; + for (ScrArena *t = scr_arena_cur; t; t = t->outer) + if (t == a) { + scr_arena_cur = a->outer; + return; + } +} + +void scr_arena_close(void *arena) { + if (arena) scr_arena_unlink((ScrArena *)arena); +} + +void scr_arena_disposal(void *arena, void *obj, ScrArenaDispose dispose) { + ScrArena *a = (ScrArena *)arena; + if (a->disposals == a->disposals_end) { + size_t n = (size_t)(a->disposals - a->disposal_base); + size_t cap = n ? n * 2 : 64; + ScrArenaDisposal *grown = realloc(a->disposal_base, cap * sizeof *grown); + if (!grown) scr_trap("scriptc: out of memory\n"); + a->disposal_base = grown; + a->disposals = grown + n; + a->disposals_end = grown + cap; + } + a->disposals->obj = obj; + a->disposals->dispose = dispose; + a->disposals++; +} + +#if SCR_SMALL_ALLOC +static ScrArenaChunk *scr_arena_chunk(size_t want) { + for (ScrArenaChunk **p = &scr_arena_chunk_pool; *p; p = &(*p)->next) + if ((*p)->size >= want) { + ScrArenaChunk *c = *p; + *p = c->next; + scr_arena_chunk_bytes -= c->size; + return c; + } + ScrArenaChunk *c = malloc(want); + if (!c) scr_trap("scriptc: out of memory\n"); + c->size = want; + return c; +} + +static char *scr_arena_chunk_start(ScrArenaChunk *c) { + return (char *)(((uintptr_t)(c + 1) + 15) & ~(uintptr_t)15); +} + +/* A new current chunk with room for `n` bytes, or false past the cap + * when `capped`. */ +static bool scr_arena_refill(ScrArena *a, size_t n, bool capped) { + size_t want = a->chunks ? a->chunks->size * 2 : SCR_ARENA_CHUNK_MIN; + if (want > SCR_ARENA_CHUNK_MAX) want = SCR_ARENA_CHUNK_MAX; + size_t need = n + sizeof(ScrArenaChunk) + 15; + if (want < need) want = need; + if (capped && a->bytes + want > SCR_ARENA_CAP) return false; + ScrArenaChunk *c = scr_arena_chunk(want); + a->bytes += c->size; + c->next = a->chunks; + a->chunks = c; + a->bump = scr_arena_chunk_start(c); + a->limit = (char *)c + c->size; + return true; +} +#endif + +void *scr_arena_obj(void *arena, size_t size, void *cyc) { + ScrArena *a = (ScrArena *)arena; + size_t hdr = cyc ? sizeof(ScrCycHdr) : 0; + bool capped = !a->closed && !cyc; + if (capped && size > SCR_ARENA_CAP) return NULL; + size_t n = (hdr + size + 7) & ~(size_t)7; +#if SCR_SMALL_ALLOC + if ((size_t)(a->limit - a->bump) < n && !scr_arena_refill(a, n, capped)) return NULL; + char *block = a->bump; + a->bump += n; + memset(block, 0, n); +#else + if (capped && a->bytes + n > SCR_ARENA_CAP) return NULL; + a->bytes += n; + if (a->nblocks == a->cap) { + size_t cap = a->cap ? a->cap * 2 : 16; + char **grown = realloc(a->blocks, cap * sizeof *grown); + if (!grown) scr_trap("scriptc: out of memory\n"); + a->blocks = grown; + a->cap = cap; + } + char *block = calloc(1, n); + if (!block) scr_trap("scriptc: out of memory\n"); + a->blocks[a->nblocks++] = block; +#endif + if (!cyc) return block; + ScrCycHdr *ch = (ScrCycHdr *)block; + ch->id = (uint16_t)scr_cyc_type_id((ScrCycType *)cyc); + ch->buf = SCR_CYC_ACYCLIC; + return block + hdr; +} + +static void scr_arena_free(ScrArena *a) { + /* Every entry before any memory: a release can reach another object of + * the arena. Newest first, the reverse of construction. */ + for (ScrArenaDisposal *d = a->disposals; d != a->disposal_base;) { + d--; + d->dispose(d->obj); + } + if ((size_t)(a->disposals_end - a->disposal_base) > SCR_ARENA_POOL_DISPOSALS) { + free(a->disposal_base); + a->disposal_base = a->disposals_end = NULL; + } +#if SCR_SMALL_ALLOC + for (ScrArenaChunk *c = a->chunks, *next; c; c = next) { + next = c->next; + if (scr_arena_chunk_bytes + c->size <= SCR_ARENA_POOL_BYTES) { + c->next = scr_arena_chunk_pool; + scr_arena_chunk_pool = c; + scr_arena_chunk_bytes += c->size; + } else + free(c); + } + if (scr_arena_pooled < SCR_ARENA_POOL) { + a->next = scr_arena_pool; + scr_arena_pool = a; + scr_arena_pooled++; + return; + } +#else + for (size_t i = 0; i < a->nblocks; i++) free(a->blocks[i]); + free(a->blocks); +#endif + free(a->disposal_base); + free(a); +} + +void scr_arena_drop(void **list) { + ScrArena *a = (ScrArena *)*list; + *list = NULL; + while (a) { + ScrArena *next = a->next; + scr_arena_unlink(a); + scr_arena_free(a); + a = next; + } +} diff --git a/packages/runtime/src/scr_arena.h b/packages/runtime/src/scr_arena.h new file mode 100644 index 000000000..cdfe5a476 --- /dev/null +++ b/packages/runtime/src/scr_arena.h @@ -0,0 +1,115 @@ +#ifndef SCR_ARENA_H +#define SCR_ARENA_H +/* Group arenas (llvm/region-groups.ts, llvm/arenas.ts): the objects a call + * allocates while the compiler has proven they die together, bump-allocated + * and released at once. + * + * Two kinds of call open one: + * - a fresh-result call (a root) whose value its caller keeps in one scope: + * the arena stays the allocation target while the call runs, and is + * dropped when that scope exits, on every path; + * - a call region, a call of a function that keeps nothing it allocates + * and returns no reference: the arena is dropped when the call returns. + * + * Only allocation sites in functions the compiler reached from such a call + * consult the target (scr_arena_cur), and only those whose value the group + * keeps (llvm/region-sites.ts), so runtime-internal allocations and + * temporaries never land in an arena. + * + * An arena object is an ordinary object with an ordinary count, starting + * at SCR_ARENA_RC instead of 1, so no release reaches zero while it lives: + * no teardown, no free, and (with `buf` SCR_CYC_ACYCLIC) never a collector + * candidate. The count is far below SCR_RC_PUBLISHED, so the arena object + * stays mutable and thread-local like any other. Dropping the arena runs + * its dispose list, whose entries release their objects' fields (and + * storage) without freeing the objects, and then recycles the memory. A + * closed arena's objects hold only each other and immortal values, so only + * objects with storage of their own (arrays) go on its list. + * + * Without the small-object allocator (sanitized and audit builds), every + * arena object is a block of its own that the drop frees, so a use after + * the drop is a use after free that the sanitizer reports. */ +#include "scr_core.h" + +#include +#include + +/* The starting count of an arena object (a count, in the low 32 bits of + * the rc word, so it also suits classes that keep their id in the high + * half). */ +#define SCR_ARENA_RC ((uint32_t)1 << 29) + +typedef struct ScrArena ScrArena; +/* Releases what an arena object holds (its fields, an array's storage) + * without freeing the object. */ +typedef void (*ScrArenaDispose)(void *obj); + +/* An entry of an arena's dispose list. The emitted arena-aware + * constructors append one inline for an object whose type has fields to + * release, unless the arena is closed (llvm/arenas.ts). */ +typedef struct ScrArenaDisposal { + void *obj; + ScrArenaDispose dispose; +} ScrArenaDisposal; + +/* The arena that allocation sites in arena-aware functions use, or NULL. + * Emitted code reads scr_arena_cur directly outside worker programs. */ +extern SCR_TL ScrArena *scr_arena_cur; +void *scr_arena_current(void); +/* Stops any arena being the allocation target while a call whose value + * its group does not keep runs; returns the target, which + * scr_arena_resume restores. */ +void *scr_arena_suspend(void); +void scr_arena_resume(void *arena); +/* Opens an arena as the allocation target and links it onto `*list`, the + * list its scope drops. Returns it. A `closed` arena's objects hold only + * each other and immortal values (the compiler proved it), so dropping it + * releases nothing. */ +void *scr_arena_open(void **list, int32_t closed); +/* Stops `arena` (may be NULL) being the allocation target; its objects + * live on until its list is dropped. */ +void scr_arena_close(void *arena); +/* Drops every arena on `*list`, newest first, and clears the list. */ +void scr_arena_drop(void **list); +/* A zeroed object of `size` bytes in `arena` (behind a collector header + * when `cyc` is not NULL, its `buf` SCR_CYC_ACYCLIC), for when the emitted + * bump finds no room: a new chunk. Returns NULL when an arena that is not + * closed is past its byte cap and `cyc` is NULL, and the caller allocates + * on the heap. The caller stores the count (SCR_ARENA_RC). */ +void *scr_arena_obj(void *arena, size_t size, void *cyc); +/* Appends `obj` to `arena`'s dispose list, which the drop runs. */ +void scr_arena_disposal(void *arena, void *obj, ScrArenaDispose dispose); + +/* The leading fields of an arena (scr_arena.c's ScrArena), for the + * runtime's own inline allocation. */ +typedef struct ScrArenaHead { + char *bump, *limit; + ScrArenaDisposal *disposals, *disposals_end; + uint32_t closed; +} ScrArenaHead; + +/* `n` bytes (a multiple of 8) from `arena`'s current chunk, not cleared, + * or NULL when the chunk has no room (scr_arena_obj takes a new one). */ +static inline void *scr_arena_bump(void *arena, size_t n) { + ScrArenaHead *h = (ScrArenaHead *)arena; + if ((size_t)(h->limit - h->bump) < n) return NULL; + char *p = h->bump; + h->bump = p + n; + return p; +} + +/* scr_arena_disposal with the append inline. */ +static inline void scr_arena_dispose_later(void *arena, void *obj, ScrArenaDispose dispose) { + ScrArenaHead *h = (ScrArenaHead *)arena; + if (h->disposals == h->disposals_end) { + scr_arena_disposal(arena, obj, dispose); + return; + } + h->disposals->obj = obj; + h->disposals->dispose = dispose; + h->disposals++; +} + +static inline int scr_arena_is_closed(void *arena) { return ((ScrArenaHead *)arena)->closed != 0; } + +#endif diff --git a/packages/runtime/src/scr_arena.test.c b/packages/runtime/src/scr_arena.test.c new file mode 100644 index 000000000..8f369d4e3 --- /dev/null +++ b/packages/runtime/src/scr_arena.test.c @@ -0,0 +1,108 @@ +/* Group arenas (scr_arena.h): allocation, nesting, closing and dropping, + * and the drop's dispose list. Prints one line per check. */ +#include "scr_runtime.h" + +#include +#include + +typedef struct { + int64_t rc; + int64_t id; +} TestObj; + +static int disposed; +static int64_t disposed_sum; +static void test_dispose(void *obj) { + disposed++; + disposed_sum += ((TestObj *)obj)->id; +} +/* What an emitted arena-aware constructor does: the object, and its + * dispose entry unless the arena is closed. */ +static TestObj *obj(void *arena, int64_t id, int closed) { + TestObj *o = scr_arena_obj(arena, sizeof *o, NULL); + if (!o) return NULL; + o->rc = SCR_ARENA_RC; + o->id = id; + if (!closed) scr_arena_disposal(arena, o, test_dispose); + return o; +} + +int main(void) { + /* An open arena is the allocation target until it closes. */ + void *list = NULL; + void *a = scr_arena_open(&list, 0); + printf("current while open: %s\n", scr_arena_current() == a ? "yes" : "no"); + TestObj *first = obj(a, 1, 0); + int zero = 1, adjacent = 1; + TestObj *prev = first; + for (int i = 0; i < 16; i++) { + TestObj *o = scr_arena_obj(a, sizeof *o, NULL); + if (o->rc != 0 || o->id != 0) zero = 0; + if ((char *)o != (char *)(prev + 1)) adjacent = 0; + prev = o; + } + printf("objects start zeroed: %s\n", zero ? "yes" : "no"); +#if !SCR_SMALL_ALLOC + adjacent = 1; /* a block per object */ +#endif + printf("objects without headers: %s\n", adjacent ? "yes" : "no"); + /* A nested arena makes the outer one current again when it closes. */ + void *inner_list = NULL; + void *b = scr_arena_open(&inner_list, 0); + for (int i = 0; i < 10; i++) obj(b, 100, 0); + scr_arena_close(b); + scr_arena_close(b); + printf("outer current after inner close: %s\n", scr_arena_current() == a ? "yes" : "no"); + scr_arena_drop(&inner_list); + printf("inner drop disposed %d (sum %lld)\n", disposed, (long long)disposed_sum); + /* Many objects span chunks; each is disposed once. */ + disposed = 0; + disposed_sum = 0; + for (int i = 0; i < 100000; i++) obj(a, 1, 0); + scr_arena_close(a); + printf("current after close: %s\n", scr_arena_current() == NULL ? "none" : "some"); + scr_arena_drop(&list); + printf("drop disposed %d (sum %lld), list %s\n", disposed, (long long)disposed_sum, + list == NULL ? "cleared" : "kept"); + + /* A closed arena's objects take no dispose entries, and it has no cap. */ + disposed = 0; + void *closed = scr_arena_open(&list, 1); + int held = 0; + for (int i = 0; i < 3000000; i++) + if (obj(closed, 1, 1)) held++; + scr_arena_drop(&list); + printf("closed arena held %d, disposed %d, current %s\n", held, disposed, + scr_arena_current() == NULL ? "none" : "some"); + + /* Past the byte cap an arena that is not closed refuses, and its drop + * disposes everything it holds. */ + disposed = 0; + void *capped = scr_arena_open(&list, 0); + int refused = 0; + held = 0; + for (int i = 0; i < 3000000 && !refused; i++) { + if (obj(capped, 1, 0)) + held++; + else + refused = 1; + } + scr_arena_drop(&list); + printf("cap refused: %s, all disposed: %s\n", refused ? "yes" : "no", disposed == held ? "yes" : "no"); + + /* A drop of an arena that is still open (an exception skipped its close) + * restores the target it replaced. */ + void *outer_list = NULL, *skipped_list = NULL; + void *outer = scr_arena_open(&outer_list, 0); + scr_arena_open(&skipped_list, 0); + scr_arena_drop(&skipped_list); + printf("target after dropping an open arena: %s\n", scr_arena_current() == outer ? "outer" : "other"); + /* A suspended arena is no target until it resumes. */ + void *saved = scr_arena_suspend(); + printf("target while suspended: %s\n", scr_arena_current() == NULL ? "none" : "some"); + scr_arena_resume(saved); + printf("target after resume: %s\n", scr_arena_current() == outer ? "outer" : "other"); + scr_arena_close(outer); + scr_arena_drop(&outer_list); + return 0; +} diff --git a/packages/runtime/src/scr_arena.test.ts b/packages/runtime/src/scr_arena.test.ts new file mode 100644 index 000000000..0e48c8ea8 --- /dev/null +++ b/packages/runtime/src/scr_arena.test.ts @@ -0,0 +1,67 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { describe, expect, test } from "vitest"; +import { EXECUTABLE_RUNTIME_SOURCES } from "../../compiler/src/backend/native-toolchain.js"; + +const exec = promisify(execFile); + +const EXPECTED = [ + "current while open: yes", + "objects start zeroed: yes", + "objects without headers: yes", + "outer current after inner close: yes", + "inner drop disposed 10 (sum 1000)", + "current after close: none", + "drop disposed 100001 (sum 100001), list cleared", + "closed arena held 3000000, disposed 0, current none", + "cap refused: yes, all disposed: yes", + "target after dropping an open arena: outer", + "target while suspended: none", + "target after resume: outer", +]; + +// The size-class allocator build (chunks, the inline bump's fields), and +// the sanitized lane's build (a block per object, freed by the drop). +describe.skipIf(process.platform === "win32")("group arenas", () => { + test.each([ + ["size-class", ["-fsanitize=undefined"]], + ["sanitized", ["-fsanitize=address,undefined", "-DSCR_RC_AUDIT"]], + ])( + "open, allocate, close and drop (%s)", + async (_lane, flags) => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-arena-")); + try { + const bin = join(dir, "arena"); + await exec("clang", [ + "-std=c11", + "-O1", + "-pthread", + "-Wno-deprecated-declarations", + ...(process.platform === "linux" ? ["-D_GNU_SOURCE"] : []), + ...flags, + ...["scr_arena.test.c", ...EXECUTABLE_RUNTIME_SOURCES].map((source) => + join(import.meta.dirname, source), + ), + ...(process.platform === "linux" ? ["-lm"] : []), + "-o", + bin, + ]); + const { stdout } = await exec(bin, [], { + timeout: 60000, + env: { + ...process.env, + ASAN_OPTIONS: "halt_on_error=1", + UBSAN_OPTIONS: "halt_on_error=1", + }, + }); + expect(stdout.trim().split("\n")).toEqual(EXPECTED); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + 300000, + ); +}); diff --git a/packages/runtime/src/scr_array.c b/packages/runtime/src/scr_array.c index c7132f528..0e4513c6f 100644 --- a/packages/runtime/src/scr_array.c +++ b/packages/runtime/src/scr_array.c @@ -1426,15 +1426,53 @@ static void scr_arr_put_slots(ScrArr *a, size_t to, size_t count, const uint64_t /* Inlined into each constructor, so `small` and the slot unit fold to * constants there. */ +static void scr_arr_arena_dispose(void *object); +static void scr_arr_arena_dispose_closed(void *object); +static ScrCycType scr_arr_cyc_type; + +/* An array block of `size` bytes in the current group arena, or NULL. + * Its storage beyond the block is the heap's, so it goes on the arena's + * dispose list even when the arena is closed; there, its elements are + * arena objects and immortal values, which need no release. */ +static inline ScrArr *scr_arr_arena_block(size_t size, bool cyc) { + void *arena = scr_arena_cur; + if (!arena) return NULL; + size_t hdr = cyc ? sizeof(ScrCycHdr) : 0; + size_t n = (hdr + size + 7) & ~(size_t)7; + uint32_t id = cyc ? scr_cyc_type_id(&scr_arr_cyc_type) : 0; + char *block = scr_arena_bump(arena, n); + ScrArr *a; + if (block) { + /* The constructor sets every header field, as on the heap; only the + * collector header needs setting here. */ + if (cyc) *(ScrCycHdr *)block = (ScrCycHdr){.id = (uint16_t)id, .buf = SCR_CYC_ACYCLIC}; + a = (ScrArr *)(block + hdr); + } else { + a = scr_arena_obj(arena, size, cyc ? &scr_arr_cyc_type : NULL); + if (!a) return NULL; + } + scr_arena_dispose_later(arena, a, + scr_arena_is_closed(arena) ? scr_arr_arena_dispose_closed + : scr_arr_arena_dispose); + return a; +} + static inline __attribute__((always_inline)) ScrArr *scr_arr_make(ScrElemKind elem, size_t initial_cap, - bool small) { + bool small, + bool arena) { size_t unit = scr_elem_slot_width(elem); size_t inline_cap = small ? scr_arr_small_cap(SCR_ARR_INLINE_OFFSET, unit) : scr_arr_known_cap(SCR_ARR_INLINE_OFFSET, initial_cap, unit); - ScrArr *a = scr_mem_alloc(inline_cap ? SCR_ARR_INLINE_OFFSET + inline_cap * unit : sizeof(ScrArr)); - if (!a) scr_arr_oom(); - a->rc = 1; + size_t size = inline_cap ? SCR_ARR_INLINE_OFFSET + inline_cap * unit : sizeof(ScrArr); + ScrArr *a = arena ? scr_arr_arena_block(size, false) : NULL; + if (a) + a->rc = SCR_ARENA_RC; + else { + a = scr_mem_alloc(size); + if (!a) scr_arr_oom(); + a->rc = 1; + } a->len = 0; a->cap = 0; a->elem = (uint8_t)elem; @@ -1451,10 +1489,16 @@ static inline __attribute__((always_inline)) ScrArr *scr_arr_make(ScrElemKind el } ScrArr *scr_arr_new(ScrElemKind elem, size_t initial_cap) { - return scr_arr_make(elem, initial_cap, false); + return scr_arr_make(elem, initial_cap, false, false); } -ScrArr *scr_arr_new_small(ScrElemKind elem) { return scr_arr_make(elem, 0, true); } +ScrArr *scr_arr_new_small(ScrElemKind elem) { return scr_arr_make(elem, 0, true, false); } + +ScrArr *scr_arr_new_r(ScrElemKind elem, size_t initial_cap) { + return scr_arr_make(elem, initial_cap, false, true); +} + +ScrArr *scr_arr_new_small_r(ScrElemKind elem) { return scr_arr_make(elem, 0, true, true); } /* A non-cycle array whose final length the runtime knows when it builds * it (regex match results): its `cap` slots share the header's block, so @@ -1463,7 +1507,7 @@ ScrArr *scr_arr_new_small(ScrElemKind elem) { return scr_arr_make(elem, 0, true) * `cap` moves the slots out like any inline array. */ ScrArr *scr_arr_new_inline(ScrElemKind elem, size_t cap, bool states) { if (cap == 0) { - ScrArr *a = scr_arr_make(elem, 0, false); + ScrArr *a = scr_arr_make(elem, 0, false, false); if (states) a->spare |= SCR_ARR_STATES; return a; } @@ -1572,13 +1616,16 @@ static void scr_arr_gc_free(void *a0) { scr_cyc_free_inline(a); } +static ScrCycType scr_arr_cyc_type = {&scr_arr_trace_v, &scr_arr_gc_free, SCR_CYC_KIND_ARRAY}; + /* Inlined into each constructor like scr_arr_make: with `small`, the count * and `elem` constant, scr_arr_new_ops_small's size class folds to a table * index and the slot sizes to constants. */ static inline __attribute__((always_inline)) ScrArr *scr_arr_make_ops_kind(const ScrArrOps *ops, size_t initial_cap, bool small, - uint8_t elem) { + uint8_t elem, + bool arena) { ScrArr *a; bool cyc = ops->trace != NULL; /* The array starts without states: slots of the value width alone. */ @@ -1587,14 +1634,18 @@ static inline __attribute__((always_inline)) ScrArr *scr_arr_make_ops_kind(const size_t inline_cap = small ? scr_arr_small_cap(prefix, unit) : scr_arr_known_cap(prefix, initial_cap, unit); size_t size = inline_cap ? SCR_ARR_INLINE_OFFSET + inline_cap * unit : sizeof(ScrArr); - if (cyc) { - static ScrCycType cyc_type = { &scr_arr_trace_v, &scr_arr_gc_free, SCR_CYC_KIND_ARRAY }; - a = scr_cyc_alloc_inline(size, &cyc_type); - } else { - a = scr_mem_alloc(size); - if (!a) scr_arr_oom(); + a = arena ? scr_arr_arena_block(size, cyc) : NULL; + if (a) + a->rc = SCR_ARENA_RC; + else { + if (cyc) + a = scr_cyc_alloc_inline(size, &scr_arr_cyc_type); + else { + a = scr_mem_alloc(size); + if (!a) scr_arr_oom(); + } + a->rc = 1; } - a->rc = 1; a->len = 0; a->cap = 0; a->elem = elem; @@ -1622,17 +1673,26 @@ static inline __attribute__((always_inline)) ScrArr *scr_arr_make_ops_kind(const * branch inlines scr_arr_make_ops_kind with a constant kind. */ static inline __attribute__((always_inline)) ScrArr *scr_arr_make_ops(const ScrArrOps *ops, size_t initial_cap, - bool small) { + bool small, + bool arena) { if (__builtin_expect(scr_arr_ops_compact(ops), 0)) - return scr_arr_make_ops_kind(ops, initial_cap, small, SCR_ELEM_CREF); - return scr_arr_make_ops_kind(ops, initial_cap, small, SCR_ELEM_REF); + return scr_arr_make_ops_kind(ops, initial_cap, small, SCR_ELEM_CREF, arena); + return scr_arr_make_ops_kind(ops, initial_cap, small, SCR_ELEM_REF, arena); } ScrArr *scr_arr_new_ops(const ScrArrOps *ops, size_t initial_cap) { - return scr_arr_make_ops(ops, initial_cap, false); + return scr_arr_make_ops(ops, initial_cap, false, false); } -ScrArr *scr_arr_new_ops_small(const ScrArrOps *ops) { return scr_arr_make_ops(ops, 0, true); } +ScrArr *scr_arr_new_ops_small(const ScrArrOps *ops) { return scr_arr_make_ops(ops, 0, true, false); } + +/* The constructors arena-aware functions call (llvm/arenas.ts): in the + * current group arena when one is open. */ +ScrArr *scr_arr_new_ops_r(const ScrArrOps *ops, size_t initial_cap) { + return scr_arr_make_ops(ops, initial_cap, false, true); +} + +ScrArr *scr_arr_new_ops_small_r(const ScrArrOps *ops) { return scr_arr_make_ops(ops, 0, true, true); } /* Frees an acyclic array whose elements are already released. */ static inline __attribute__((always_inline)) void scr_arr_free(ScrArr *a) { @@ -1746,6 +1806,26 @@ static void scr_arr_destroy(void *object) { else scr_arr_free(a); } +/* An arena array at its arena's drop: its contents and the storage beyond + * its block go; the block goes with the arena. */ +static void scr_arr_arena_dispose(void *object) { + ScrArr *a = object; + scr_arr_release_contents(a); + scr_arr_free_parts(a); +#ifdef SCR_RC_AUDIT + scr_live_arrays--; +#endif +} + +/* The same in a closed arena, whose objects hold only each other and + * immortal values: only the storage beyond the block goes. */ +static void scr_arr_arena_dispose_closed(void *object) { + scr_arr_free_parts((ScrArr *)object); +#ifdef SCR_RC_AUDIT + scr_live_arrays--; +#endif +} + void scr_arr_subclass_init(ScrArr *a, uint8_t elem, const ScrArrOps *ops, const ScrArrClass *cls, bool cyc) { a->len = 0; diff --git a/packages/runtime/src/scr_array.h b/packages/runtime/src/scr_array.h index 0b162ff5e..86ef8d999 100644 --- a/packages/runtime/src/scr_array.h +++ b/packages/runtime/src/scr_array.h @@ -490,6 +490,12 @@ ScrArr *scr_arr_new_small(ScrElemKind elem); * SCR_ELEM_F64); no-op for any other kind. */ void scr_arr_widen_i32(ScrArr *a); ScrArr *scr_arr_new_ops_small(const ScrArrOps *ops); +/* The same constructors in the current group arena, when one is open + * (scr_arena.h): what arena-aware functions call. */ +ScrArr *scr_arr_new_r(ScrElemKind elem, size_t initial_cap); +ScrArr *scr_arr_new_small_r(ScrElemKind elem); +ScrArr *scr_arr_new_ops_r(const ScrArrOps *ops, size_t initial_cap); +ScrArr *scr_arr_new_ops_small_r(const ScrArrOps *ops); /* scr_arr_new(elem, cap) for a known final length: the `cap` slots share * the array's own block (one allocation). With `states`, the array starts * with states (for holes or present undefined). Returns +1. */ diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index f9c6944c2..4b6e45596 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -1518,7 +1518,11 @@ static void scr_switch(ScrCtx *from, ScrCtx *to, ScrFiber *to_fiber) { (void)to; scr_trap("scriptc: internal error: native stack switch reached on WASI\n"); #elif defined(SCR_ASAN_FIBERS) - void **save = from_fiber ? &from_fiber->fake_stack : &scr_main_fake_stack; + /* A finished fiber leaves for the last time: a NULL slot has + * AddressSanitizer free its fake stack, which otherwise stays mapped + * until exit. */ + void **save = from_fiber ? (from_fiber->done ? NULL : &from_fiber->fake_stack) + : &scr_main_fake_stack; const void *bottom = to_fiber ? to_fiber->stack : NULL; size_t size = to_fiber ? SCR_FIBER_STACK : 0; __sanitizer_start_switch_fiber(save, bottom, size); @@ -1527,10 +1531,13 @@ static void scr_switch(ScrCtx *from, ScrCtx *to, ScrFiber *to_fiber) { #else swapcontext(from, to); #endif + /* Back in this frame: the context that saved into `save` above runs + * again, whichever fiber `scr_current` names (a finished fiber returns + * to its resumer with no fiber to switch to). */ const void *old_bottom; size_t old_size; - __sanitizer_finish_switch_fiber( - scr_current ? scr_current->fake_stack : scr_main_fake_stack, &old_bottom, &old_size); + __sanitizer_finish_switch_fiber(from_fiber ? from_fiber->fake_stack : scr_main_fake_stack, + &old_bottom, &old_size); #elif defined(SCR_FAST_CTX) scr_ctx_swap(from, to); #else @@ -2228,6 +2235,7 @@ static void scr_prefix_trampoline(void) { scr_als_active = &f->als; entry(f, argpack); scr_current = carrier; + carrier->done = true; /* its stack is freed once the spawner is back */ scr_switch(&carrier->ctx, carrier->return_to, NULL); /* unreachable */ } @@ -5435,6 +5443,14 @@ ScrGen *scr_async_gen_new(void (*entry)(ScrFiber *, void *), void *argpack, return scr_gen_new_common(entry, argpack, drop_args, settle, false); } +#ifndef __wasi__ +ScrGen *scr_async_gen_new_stackless(void (*entry)(ScrFiber *, void *), void *argpack, + void (*drop_args)(void *), + void (*settle)(ScrGen *, ScrPromise *)) { + return scr_gen_new_common(entry, argpack, drop_args, settle, true); +} +#endif + ScrGen *scr_gen_retain(ScrGen *g) { if (g) g->rc++; return g; @@ -5783,7 +5799,7 @@ void scr_gen_ret_to_out(ScrGen *g) { * settle the generator's state, moving a finished body's exception to the * resumer and tearing the finished fiber down. `release` drops the hop's * own reference (see scr_gen_switch_in); `sync` marks a body known to be a - * synchronous generator (every stackless one is). */ + * synchronous generator. */ __attribute__((always_inline)) static inline void scr_gen_switched_back(ScrGen *g, ScrFiber *f, bool release, bool sync) { if (__builtin_expect(f->done, 0)) { @@ -5820,10 +5836,12 @@ static __attribute__((noinline)) void scr_async_gen_step_end(ScrFiber *f) { scr_ #ifndef __wasi__ /* The stackless form of the hop: the coroutine frame runs on this stack - * until its next yield or its final suspension returns here. `retain` - * holds the generator across the hop; a resumer whose own reference - * provably outlives the hop passes false. */ -__attribute__((always_inline)) static inline void scr_gen_coro_hop(ScrGen *g, bool retain) { + * until its next yield, an async generator's await, or its final + * suspension returns here. `retain` holds the generator across the hop; a + * resumer whose own reference provably outlives the hop passes false. + * `sync` marks a body known to be a synchronous generator. */ +__attribute__((always_inline)) static inline void scr_gen_coro_hop(ScrGen *g, bool retain, + bool sync) { ScrFiber *f = g->fiber; if (retain) scr_gen_retain(g); /* A synchronous generator has no AsyncLocalStorage context of its own: @@ -5848,10 +5866,29 @@ __attribute__((always_inline)) static inline void scr_gen_coro_hop(ScrGen *g, bo scr_exc_swap_cell(me != NULL ? &me->exc : NULL); scr_als_active = resumer_slot; if (g->is_async) scr_async_gen_step_end(f); - scr_gen_switched_back(g, f, retain, true); + scr_gen_switched_back(g, f, retain, sync); } -static void scr_gen_coro_in(ScrGen *g) { scr_gen_coro_hop(g, true); } +/* An async generator's step can resume the next one down a chain of them + * before it suspends, all on one stack, through runtime frames that check + * no guard: a step that would start below the stack guard throws the + * RangeError compiled code throws there, which rejects its request. */ +static __attribute__((noinline)) void scr_async_gen_coro_in(ScrGen *g) { + uintptr_t guard = scr_stack_guard; + if (guard != 0 && (uintptr_t)__builtin_frame_address(0) < guard) { + /* scr_stack_overflow's error, thrown here: this unit links without + * scr_context.c in the runtime's own tests. */ + static const char text[] = "Maximum call stack size exceeded"; + scr_throw_error_msg(SCR_ERR_RANGE, text, sizeof text - 1); + return; + } + scr_gen_coro_hop(g, true, false); +} + +static void scr_gen_coro_in(ScrGen *g) { + if (g->is_async) scr_async_gen_coro_in(g); + else scr_gen_coro_hop(g, true, true); +} #endif /* The consumer→fiber hop shared by every resume mode: switches in, and on @@ -5947,8 +5984,9 @@ void scr_gen_close(ScrGen *g) { bool scr_gen_step(ScrGen *g) { if (g->in.kind != SCR_EXC_NONE) scr_gen_slot_reset(&g->in); #ifndef __wasi__ - if ((g->state == SCR_GEN_SUSPENDED || g->state == SCR_GEN_UNSTARTED) && g->fiber->stackless) { - scr_gen_coro_hop(g, false); + if ((g->state == SCR_GEN_SUSPENDED || g->state == SCR_GEN_UNSTARTED) && g->fiber->stackless && + !g->is_async) { + scr_gen_coro_hop(g, false, true); return g->state == SCR_GEN_DONE; } #endif diff --git a/packages/runtime/src/scr_async.h b/packages/runtime/src/scr_async.h index e31ba5b1d..d2e16be43 100644 --- a/packages/runtime/src/scr_async.h +++ b/packages/runtime/src/scr_async.h @@ -143,6 +143,9 @@ void scr_promise_rethrow_top_level(ScrPromise *p); * value), or NULL with the rejection re-thrown into the awaiter. */ ScrDyn *scr_await_dyn(ScrPromise *p); ScrDyn *scr_await_dyn_value_settled(ScrDyn *value); +/* The native coroutine form's extraction: the body has already suspended + * until the value's promise settled, or taken the non-promise hop. */ +ScrDyn *scr_coro_await_dyn_value(ScrDyn *value); void scr_promise_resolve_dyn(ScrPromise *destination, ScrDyn *value); /* `await v` over a checked-dynamic VALUE: dyn promises adopt, everything * else takes JS's one-hop non-thenable await and answers itself (+1). */ diff --git a/packages/runtime/src/scr_async_dyn.c b/packages/runtime/src/scr_async_dyn.c index 046aebb38..6e5d9856e 100644 --- a/packages/runtime/src/scr_async_dyn.c +++ b/packages/runtime/src/scr_async_dyn.c @@ -827,6 +827,19 @@ ScrDyn *scr_await_dyn_value_settled(ScrDyn *value) { return value->kind == SCR_DYN_PROMISE ? scr_await_dyn(value->v.promise) : scr_dyn_retain(value); } +static ScrDyn *scr_dyn_promise_payload(ScrPromise *p); + +/* The same for a native stackless body: the suspension has taken the turn + * scr_await_dyn would take, so the extraction takes none. */ +ScrDyn *scr_coro_await_dyn_value(ScrDyn *value) { + if (value->kind != SCR_DYN_PROMISE) return scr_dyn_retain(value); + ScrPromise *p = value->v.promise; + if (!scr_promise_settled_now(p)) + scr_trap("scriptc: internal error: resumed before awaited promise settled\n"); + if (scr_promise_is_rejected(p)) return NULL; + return scr_dyn_promise_payload(p); +} + /* ── process warnings (emitWarning + the 'warning' event) ───────────── * Gated with the rest of this TU (a deprecation-emitting unit's gate * must imply the dynAsync link). Listeners are dyn functions; the diff --git a/packages/runtime/src/scr_generator.h b/packages/runtime/src/scr_generator.h index c73ceb900..cb61d93e4 100644 --- a/packages/runtime/src/scr_generator.h +++ b/packages/runtime/src/scr_generator.h @@ -60,6 +60,14 @@ void scr_gen_coro_finish(ScrFiber *self); ScrGen *scr_async_gen_new(void (*entry)(ScrFiber *, void *), void *argpack, void (*drop_args)(void *), void (*settle)(ScrGen *, ScrPromise *)); +/* An async generator whose body is a switched coroutine, as + * scr_gen_new_stackless's: its steps run on the resumer's stack (the loop's + * after an await), its awaits suspend through scr_coro_await_prepare and + * scr_coro_hop_prepare, and its yields store OUT through scr_coro_yield_*. + * Native targets only. */ +ScrGen *scr_async_gen_new_stackless(void (*entry)(ScrFiber *, void *), void *argpack, + void (*drop_args)(void *), + void (*settle)(ScrGen *, ScrPromise *)); /* Runs when return() reaches the generator before its first resume, with * the packed arguments still alive: an iterator helper closes its source. */ void scr_gen_set_unstarted_return(ScrGen *g, void (*hook)(void *)); diff --git a/packages/runtime/src/scr_js_object.test.ts b/packages/runtime/src/scr_js_object.test.ts index 49d24f65f..505bd4062 100644 --- a/packages/runtime/src/scr_js_object.test.ts +++ b/packages/runtime/src/scr_js_object.test.ts @@ -29,6 +29,7 @@ test("layouts stay within the layout and key-byte budgets, and dynamic-tier abse "scr_json.c", "scr_string.c", "scr_array.c", + "scr_arena.c", "scr_map.c", "scr_exception.c", "scr_error.c", diff --git a/packages/runtime/src/scr_json.test.ts b/packages/runtime/src/scr_json.test.ts index e181674ce..88a89c321 100644 --- a/packages/runtime/src/scr_json.test.ts +++ b/packages/runtime/src/scr_json.test.ts @@ -30,6 +30,7 @@ test.each([false, true])( "scr_json.c", "scr_string.c", "scr_array.c", + "scr_arena.c", "scr_map.c", "scr_exception.c", "scr_error.c", diff --git a/packages/runtime/src/scr_map.test.ts b/packages/runtime/src/scr_map.test.ts index 88aef9c63..b567884fc 100644 --- a/packages/runtime/src/scr_map.test.ts +++ b/packages/runtime/src/scr_map.test.ts @@ -27,6 +27,7 @@ test("number-key maps keep Map semantics across linear, direct and hashed indexe "scr_json.c", "scr_string.c", "scr_array.c", + "scr_arena.c", "scr_map.c", "scr_exception.c", "scr_error.c", diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index f19f3918b..5c73917f4 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -56,6 +56,7 @@ #include "scr_blob.h" #include "scr_object.h" #include "scr_region.h" +#include "scr_arena.h" #include "scr_itable.h" #include "scr_slots.h" #include "scr_async.h" diff --git a/packages/runtime/src/scr_string.c b/packages/runtime/src/scr_string.c index 71d629941..548a04fd5 100644 --- a/packages/runtime/src/scr_string.c +++ b/packages/runtime/src/scr_string.c @@ -2172,11 +2172,19 @@ double scr_str_char_code_at(ScrStr *s, double i) { } double scr_str_index_of(ScrStr *s, ScrStr *needle, double fromIndex) { - /* A search from the start of a proven-ASCII receiver: byte offsets are - * UTF-16 indexes, and a needle with other bytes is never found. A start - * index that is not positive (NaN, -0 or negative) clamps to 0. */ - if (s->ascii && !(fromIndex > 0)) { - const char *found = scr_byte_find(s->data, s->len, needle->data, needle->len); + /* A proven-ASCII receiver: byte offsets are UTF-16 indexes, and a needle + * with other bytes is never found. A start index that is not positive + * (NaN, -0 or negative) clamps to 0, a positive one truncates + * (ToIntegerOrInfinity) and clamps to the length, where only the empty + * needle is found. */ + if (s->ascii) { + size_t start = 0; + if (fromIndex > 0) { + if (fromIndex >= (double)s->len) return needle->len == 0 ? (double)s->len : -1.0; + start = (size_t)fromIndex; + } + const char *found = + scr_byte_find(s->data + start, s->len - start, needle->data, needle->len); return found ? (double)(found - s->data) : -1.0; } double pos = scr_to_integer_or_infinity(fromIndex); diff --git a/packages/runtime/test/array.test.ts b/packages/runtime/test/array.test.ts index 4b42b2beb..ffdafb5e0 100644 --- a/packages/runtime/test/array.test.ts +++ b/packages/runtime/test/array.test.ts @@ -24,6 +24,7 @@ beforeAll(async () => { bin, join(testDir, "test_array.c"), join(testDir, "../src/scr_array.c"), + join(testDir, "../src/scr_arena.c"), // Checked-value collection equality shares the native JSON value runtime. join(testDir, "../src/scr_json.c"), join(testDir, "../src/scr_map.c"), diff --git a/packages/runtime/test/bigint.test.ts b/packages/runtime/test/bigint.test.ts index ea0a8b79c..7b6a5e51a 100644 --- a/packages/runtime/test/bigint.test.ts +++ b/packages/runtime/test/bigint.test.ts @@ -32,6 +32,7 @@ beforeAll(async () => { // which pull in the closure/array/box machinery join(testDir, "../src/scr_union.c"), join(testDir, "../src/scr_array.c"), + join(testDir, "../src/scr_arena.c"), // Checked-value collection equality shares the native JSON value runtime. join(testDir, "../src/scr_json.c"), join(testDir, "../src/scr_bigint.c"), diff --git a/packages/runtime/test/bytes.test.ts b/packages/runtime/test/bytes.test.ts index 7ca0606ec..05123bf5f 100644 --- a/packages/runtime/test/bytes.test.ts +++ b/packages/runtime/test/bytes.test.ts @@ -40,6 +40,7 @@ beforeAll(async () => { join(testDir, "../src/scr_hash.c"), join(testDir, "../src/scr_string.c"), join(testDir, "../src/scr_array.c"), + join(testDir, "../src/scr_arena.c"), join(testDir, "../src/scr_map.c"), join(testDir, "../src/scr_exception.c"), join(testDir, "../src/scr_error.c"), diff --git a/packages/runtime/test/cipher.test.ts b/packages/runtime/test/cipher.test.ts index 6b44a4b4b..44bbc250c 100644 --- a/packages/runtime/test/cipher.test.ts +++ b/packages/runtime/test/cipher.test.ts @@ -20,6 +20,7 @@ const runtimeSources = [ "scr_hash.c", "scr_string.c", "scr_array.c", + "scr_arena.c", "scr_map.c", "scr_exception.c", "scr_error.c", diff --git a/packages/runtime/test/closure.test.ts b/packages/runtime/test/closure.test.ts index 96fad6c10..e4c6200dc 100644 --- a/packages/runtime/test/closure.test.ts +++ b/packages/runtime/test/closure.test.ts @@ -25,6 +25,7 @@ test("closure/box runtime: RC cascades clean under ASan + audit", async () => { join(srcDir, "scr_closure.c"), join(srcDir, "scr_string.c"), join(srcDir, "scr_array.c"), + join(srcDir, "scr_arena.c"), join(srcDir, "scr_json.c"), join(srcDir, "scr_bigint.c"), join(srcDir, "scr_map.c"), diff --git a/packages/runtime/test/dh.test.ts b/packages/runtime/test/dh.test.ts index d402d0465..1f324ffb0 100644 --- a/packages/runtime/test/dh.test.ts +++ b/packages/runtime/test/dh.test.ts @@ -20,6 +20,7 @@ const runtimeSources = [ "scr_hash.c", "scr_string.c", "scr_array.c", + "scr_arena.c", "scr_map.c", "scr_exception.c", "scr_error.c", diff --git a/packages/runtime/test/glob.test.ts b/packages/runtime/test/glob.test.ts index 92292787a..9900382ea 100644 --- a/packages/runtime/test/glob.test.ts +++ b/packages/runtime/test/glob.test.ts @@ -35,6 +35,7 @@ beforeAll(async () => { join(testDir, "../src/scr_string.c"), join(testDir, "../src/scr_map.c"), join(testDir, "../src/scr_array.c"), + join(testDir, "../src/scr_arena.c"), join(testDir, "../src/scr_json.c"), join(testDir, "../src/scr_bigint.c"), join(testDir, "../src/scr_exception.c"), diff --git a/packages/runtime/test/inspect.test.ts b/packages/runtime/test/inspect.test.ts index 772afe4ef..40d812e53 100644 --- a/packages/runtime/test/inspect.test.ts +++ b/packages/runtime/test/inspect.test.ts @@ -35,6 +35,7 @@ beforeAll(async () => { join(testDir, "../src/scr_string.c"), join(testDir, "../src/scr_number.c"), join(testDir, "../src/scr_array.c"), + join(testDir, "../src/scr_arena.c"), join(testDir, "../src/scr_bytes.c"), join(testDir, "../src/scr_shared.c"), join(testDir, "../src/scr_lib.c"), diff --git a/packages/runtime/test/json.test.ts b/packages/runtime/test/json.test.ts index b08f2fdd3..b049e6922 100644 --- a/packages/runtime/test/json.test.ts +++ b/packages/runtime/test/json.test.ts @@ -29,6 +29,7 @@ beforeAll(async () => { join(testDir, "../src/scr_bigint.c"), join(testDir, "../src/scr_string.c"), join(testDir, "../src/scr_array.c"), + join(testDir, "../src/scr_arena.c"), join(testDir, "../src/scr_map.c"), join(testDir, "../src/scr_exception.c"), join(testDir, "../src/scr_error.c"), diff --git a/packages/runtime/test/lib.test.ts b/packages/runtime/test/lib.test.ts index 2a32202b7..d1e9e8c90 100644 --- a/packages/runtime/test/lib.test.ts +++ b/packages/runtime/test/lib.test.ts @@ -35,6 +35,7 @@ beforeAll(async () => { join(testDir, "../src/scr_path.c"), join(testDir, "../src/scr_string.c"), join(testDir, "../src/scr_array.c"), + join(testDir, "../src/scr_arena.c"), join(testDir, "../src/scr_map.c"), join(testDir, "../src/scr_exception.c"), join(testDir, "../src/scr_error.c"), diff --git a/packages/runtime/test/map.test.ts b/packages/runtime/test/map.test.ts index 4db1889ac..4a56c3055 100644 --- a/packages/runtime/test/map.test.ts +++ b/packages/runtime/test/map.test.ts @@ -33,6 +33,7 @@ beforeAll(async () => { // which pull in the closure/array/box machinery join(testDir, "../src/scr_union.c"), join(testDir, "../src/scr_array.c"), + join(testDir, "../src/scr_arena.c"), // Checked-value collection equality shares the native JSON value runtime. join(testDir, "../src/scr_json.c"), join(testDir, "../src/scr_bigint.c"), diff --git a/packages/runtime/test/path.test.ts b/packages/runtime/test/path.test.ts index 7f0da7841..79d34c1c3 100644 --- a/packages/runtime/test/path.test.ts +++ b/packages/runtime/test/path.test.ts @@ -29,6 +29,7 @@ beforeAll(async () => { join(testDir, "../src/scr_string.c"), join(testDir, "../src/scr_number.c"), join(testDir, "../src/scr_array.c"), + join(testDir, "../src/scr_arena.c"), // Checked-value collection equality shares the native JSON value runtime. join(testDir, "../src/scr_json.c"), join(testDir, "../src/scr_map.c"), diff --git a/packages/runtime/test/regex.test.ts b/packages/runtime/test/regex.test.ts index 47853d960..cbcbc868b 100644 --- a/packages/runtime/test/regex.test.ts +++ b/packages/runtime/test/regex.test.ts @@ -36,6 +36,7 @@ beforeAll(async () => { join(testDir, "../src/scr_string.c"), join(testDir, "../src/scr_map.c"), join(testDir, "../src/scr_array.c"), + join(testDir, "../src/scr_arena.c"), join(testDir, "../src/scr_json.c"), join(testDir, "../src/scr_bigint.c"), join(testDir, "../src/scr_exception.c"), diff --git a/packages/runtime/test/runtime.test.ts b/packages/runtime/test/runtime.test.ts index 11bdf75b4..5b115f868 100644 --- a/packages/runtime/test/runtime.test.ts +++ b/packages/runtime/test/runtime.test.ts @@ -13,6 +13,7 @@ const RUNTIME_SOURCES = [ "scr_bigint.c", "scr_string.c", "scr_array.c", + "scr_arena.c", "scr_bytes.c", "scr_shared.c", "scr_bytes_io.c", diff --git a/packages/runtime/test/sha-kernels.test.ts b/packages/runtime/test/sha-kernels.test.ts index 6f7759348..4cb12a638 100644 --- a/packages/runtime/test/sha-kernels.test.ts +++ b/packages/runtime/test/sha-kernels.test.ts @@ -19,6 +19,7 @@ const runtimeSources = [ "scr_hash.c", "scr_string.c", "scr_array.c", + "scr_arena.c", "scr_map.c", "scr_exception.c", "scr_error.c", diff --git a/packages/runtime/test/sort.test.ts b/packages/runtime/test/sort.test.ts index 711a41f03..fed629b73 100644 --- a/packages/runtime/test/sort.test.ts +++ b/packages/runtime/test/sort.test.ts @@ -24,6 +24,7 @@ beforeAll(async () => { bin, join(testDir, "test_sort.c"), join(testDir, "../src/scr_array.c"), + join(testDir, "../src/scr_arena.c"), // Checked-value collection equality shares the native JSON value runtime. join(testDir, "../src/scr_json.c"), join(testDir, "../src/scr_map.c"), diff --git a/packages/runtime/test/string.test.ts b/packages/runtime/test/string.test.ts index 72a479a10..0ca58aaf4 100644 --- a/packages/runtime/test/string.test.ts +++ b/packages/runtime/test/string.test.ts @@ -30,6 +30,7 @@ beforeAll(async () => { // scr_str_split returns a real string[]: the array module and its // own dependencies join the link (the array.test.ts set). join(testDir, "../src/scr_array.c"), + join(testDir, "../src/scr_arena.c"), // Checked-value collection equality shares the native JSON value runtime. join(testDir, "../src/scr_json.c"), join(testDir, "../src/scr_map.c"), diff --git a/packages/runtime/test/tonumber.test.ts b/packages/runtime/test/tonumber.test.ts index 2a3c24575..0a95f2bb6 100644 --- a/packages/runtime/test/tonumber.test.ts +++ b/packages/runtime/test/tonumber.test.ts @@ -37,6 +37,7 @@ test("scr_string_to_number and scr_parse_float match Node on committed oracle ca // scr_string.c's own link closure (the string.test.ts set): // scr_str_split pulls the array module and its dependencies. join(testDir, "../src/scr_array.c"), + join(testDir, "../src/scr_arena.c"), // Checked-value collection equality shares the native JSON value runtime. join(testDir, "../src/scr_json.c"), join(testDir, "../src/scr_map.c"), diff --git a/packages/runtime/test/url-legacy.test.ts b/packages/runtime/test/url-legacy.test.ts index a237382ae..0af2f7768 100644 --- a/packages/runtime/test/url-legacy.test.ts +++ b/packages/runtime/test/url-legacy.test.ts @@ -38,6 +38,7 @@ beforeAll(async () => { join(testDir, "../src/scr_string.c"), join(testDir, "../src/scr_map.c"), join(testDir, "../src/scr_array.c"), + join(testDir, "../src/scr_arena.c"), join(testDir, "../src/scr_json.c"), join(testDir, "../src/scr_bigint.c"), join(testDir, "../src/scr_exception.c"), diff --git a/packages/runtime/test/url.test.ts b/packages/runtime/test/url.test.ts index 9876ec4e5..ea6948487 100644 --- a/packages/runtime/test/url.test.ts +++ b/packages/runtime/test/url.test.ts @@ -29,6 +29,7 @@ beforeAll(async () => { join(testDir, "../src/scr_string.c"), join(testDir, "../src/scr_number.c"), join(testDir, "../src/scr_array.c"), + join(testDir, "../src/scr_arena.c"), // Checked-value collection equality shares the native JSON value runtime. join(testDir, "../src/scr_json.c"), join(testDir, "../src/scr_map.c"), diff --git a/scripts/build-native-cli.mts b/scripts/build-native-cli.mts index 3dbe22bf3..22b9967ba 100644 --- a/scripts/build-native-cli.mts +++ b/scripts/build-native-cli.mts @@ -31,10 +31,10 @@ const sanitize = process.env["SCRIPTC_SAN"] === "1"; // SCRIPTC_NATIVE_PROFILE_USE builds without one. const profileGenerate = process.env["SCRIPTC_NATIVE_PROFILE_GENERATE"] || undefined; // Below this share of matching functions, the build warns that the profile -// has lost most of its gain. A profile refreshed on the same commit matched -// 92.7% of the native compiler's profiled functions, one refreshed 1.5 days -// and about 200 commits earlier 87.2%, and that one kept about half of the -// fresh profile's gain on a cold tsc-ts build. +// has lost most of its gain. A profile refreshed on the same commit, with +// the same layout, matches all of the native compiler's profiled functions. +// One refreshed 1.5 days and about 200 commits earlier matched 87.2%, and +// kept about half of the fresh profile's gain on a cold tsc-ts build. const PROFILE_MATCH_WARNING = 0.9; const committedProfile = join(root, "packages/compiler/native/cli.profdata.gz"); const requestedProfile = process.env["SCRIPTC_NATIVE_PROFILE_USE"]; @@ -46,11 +46,15 @@ const useProfile = (requestedProfile !== undefined || existsSync(committedProfile)); // Builds lay out the compiler's functions with the committed layout // (scripts/refresh-cli-layout.mjs), or the one SCRIPTC_NATIVE_LAYOUT names; -// an empty SCRIPTC_NATIVE_LAYOUT, and instrumented builds, use none. +// an empty SCRIPTC_NATIVE_LAYOUT uses none. Instrumented builds use the same +// layout as the builds their profile is for. The layout decides which +// functions share a partition, and a function whose partition differs from +// the instrumented build's is optimized differently before its control flow +// is hashed, so the profile's record for it no longer matches. const committedLayout = join(root, "packages/compiler/native/cli.layout.gz"); const requestedLayout = process.env["SCRIPTC_NATIVE_LAYOUT"]; const functionLayout = - profileGenerate !== undefined || requestedLayout === "" + requestedLayout === "" ? undefined : (requestedLayout ?? (existsSync(committedLayout) ? committedLayout : undefined)); let target = nativeCodegenTarget(); diff --git a/scripts/refresh-cli-profile.mjs b/scripts/refresh-cli-profile.mjs index dde4e21b9..a9436b505 100644 --- a/scripts/refresh-cli-profile.mjs +++ b/scripts/refresh-cli-profile.mjs @@ -6,7 +6,7 @@ * * Builds an instrumented native compiler for this Linux host, runs it on the * training builds below, and merges the raw profiles it writes into one - * sparse indexed profile. The training builds are tsc-ts (a cold `speed` + * indexed profile. The training builds are tsc-ts (a cold `speed` * build, then a rebuild after an edit), every seventh top-level corpus * program (by number, or by a hash of the name of a program without one) at * the default optimization and a further slice in `dev`, and the runtime @@ -14,6 +14,11 @@ * move much compiler code; a stale profile only loses part of its gain. A * refresh takes about 11 minutes on 16 cores. * + * The instrumented compiler is built with the committed function layout, or + * the one SCRIPTC_NATIVE_LAYOUT names, and the profile matches builds that + * use the same layout. When refreshing both, refresh the layout first + * (scripts/refresh-cli-layout.mjs). + * * node scripts/refresh-cli-profile.mjs --tsc-ts [--jobs 8] */ import { spawn, spawnSync } from "node:child_process"; @@ -210,12 +215,15 @@ const profiles = readdirSync(raw) .map((name) => join(raw, name)); rmSync(builds, { recursive: true, force: true }); const indexed = join(work, "cli.profdata"); -const merged = spawnSync(scriptc, ["merge-profiles", "--sparse", "-o", indexed, ...profiles], { +// The profile keeps the functions that never ran in training, with zero +// counts. Builds optimize those as cold and place them after the trained code, +// which keeps the compiler's .text smaller than a sparse profile does. +const merged = spawnSync(scriptc, ["merge-profiles", "-o", indexed, ...profiles], { stdio: "inherit", env: { ...trainingEnv, LLVM_PROFILE_FILE: join(work, "merge.profraw") }, }); if (merged.status !== 0) throw new Error("merge-profiles failed"); -// The indexed profile compresses to about an eighth of its size. +// The indexed profile compresses to about a sixth of its size. writeFileSync( profileOutput, gzipSync(readFileSync(indexed), { level: constants.Z_BEST_COMPRESSION }), diff --git a/scripts/sandbox-config.mjs b/scripts/sandbox-config.mjs index 9ab1f6fa1..9b64eba57 100644 --- a/scripts/sandbox-config.mjs +++ b/scripts/sandbox-config.mjs @@ -291,3 +291,22 @@ export function sandboxTestSourceConfig(env) { createArgs: ["--image", image.sandboxImage], }; } + +/** A built checkout inside a prepared snapshot whose native artifacts a + * lane may reuse when their inputs match (SCRIPTC_SANDBOX_NATIVE_REFERENCE). + * It needs the snapshot's tools, so it is ignored for the managed image. */ +export function sandboxNativeReference(env) { + loadLocalEnv(); + const source = env ?? process.env; + const reference = source.SCRIPTC_SANDBOX_NATIVE_REFERENCE?.trim(); + if (!reference) return undefined; + if (!sandboxTestSourceConfig(source).prepared) { + throw new Error( + "SCRIPTC_SANDBOX_NATIVE_REFERENCE needs SCRIPTC_SANDBOX_SNAPSHOT or SCRIPTC_SANDBOX_IMAGE", + ); + } + if (!/^\/[\w./-]+$/.test(reference) || reference.split("/").includes("..")) { + throw new Error("SCRIPTC_SANDBOX_NATIVE_REFERENCE must be an absolute path without '..'"); + } + return reference.replace(/\/+$/, ""); +} diff --git a/scripts/sandbox-test.mjs b/scripts/sandbox-test.mjs index 602ef033e..3fe063075 100644 --- a/scripts/sandbox-test.mjs +++ b/scripts/sandbox-test.mjs @@ -9,6 +9,7 @@ import { parseArgs } from "node:util"; import { fileURLToPath } from "node:url"; import { sandboxBootstrapCommand, + sandboxNativeReference, sandboxTestSourceConfig, sandboxRunnerConfig, sandboxEffectWorkerAllocation, @@ -112,6 +113,38 @@ const hostInvariantFiles = [ "packages/runtime/test/tonumber.test.ts", "packages/runtime/test/url.test.ts", ]; +// Native artifacts a lane needs and every source path their builds read. +// Output paths are excluded from the input comparison by these names. +const NATIVE_OUTPUT_NAMES = [ + "bin", + "artifacts", + "runtime-pack.json", + ".runtime-pack-*", + "node_modules", + "dist", + "build", + ".cache", +]; +const LLVM_HELPER_INPUTS = ["native/llvm-codegen", "packages/llvm-linux-x64-gnu"]; +const NATIVE_ARTIFACTS = { + llvm: { + inputs: LLVM_HELPER_INPUTS, + outputs: ["packages/llvm-linux-x64-gnu/bin/scriptc-llvm-codegen"], + }, + runtime: { + inputs: [ + ...LLVM_HELPER_INPUTS, + "packages/runtime", + "packages/runtime-pack-common", + "packages/runtime-linux-x64-gnu", + "packages/runtime-darwin-arm64/scripts", + ], + outputs: [ + "packages/runtime-linux-x64-gnu/artifacts", + "packages/runtime-linux-x64-gnu/runtime-pack.json", + ], + }, +}; const hostSchedule = sandboxHostSchedule(process.platform, hostInvariantFiles); const nativeHostInvariantFiles = hostSchedule.localInvariantFiles; const remoteWorkspaceReset = workspaceResetCommand("/workspace"); @@ -188,6 +221,8 @@ Environment: VERCEL_TOKEN Access-token fallback; also set VERCEL_TEAM_ID + VERCEL_PROJECT_ID SCRIPTC_SANDBOX_IMAGE Optional fully qualified VCR image (default: vercel/sandbox/universal) SCRIPTC_SANDBOX_SNAPSHOT Optional prepared snapshot with the pinned toolchain (exclusive with IMAGE) + SCRIPTC_SANDBOX_NATIVE_REFERENCE Optional built checkout inside the snapshot; its LLVM helper + and runtime pack are reused when their inputs are identical SCRIPTC_SANDBOX_VCPUS vCPUs per sandbox (default: 8) SCRIPTC_SANDBOX_TIMEOUT sandbox and command timeout (default: 45m) SCRIPTC_TEST_WORKERS Vitest workers per sandbox (default: 4) @@ -197,6 +232,7 @@ Environment: } const sourceConfig = sandboxTestSourceConfig(); +const nativeReference = sandboxNativeReference(); const bootstrapCommand = sandboxBootstrapCommand(sourceConfig.prepared); const vercelConfig = sandboxVercelConfig(); const vercelProcessEnv = sandboxVercelEnvironment(vercelConfig); @@ -496,6 +532,42 @@ const execIn = async ( } }; +/** The command that provides one native artifact (`llvm`: the Linux LLVM + * helper, `runtime`: the Linux runtime pack). It builds the artifact unless + * SCRIPTC_SANDBOX_NATIVE_REFERENCE names a built checkout inside the Sandbox + * (a prepared snapshot's) whose inputs for that artifact are identical; then + * it copies the reference's artifact. The runtime build runs the helper, so + * its inputs include the helper's. */ +function nativeArtifactCommand(artifact) { + const pkg = + artifact === "llvm" ? "@scriptc/llvm-linux-x64-gnu" : "@scriptc/runtime-linux-x64-gnu"; + if (nativeReference === undefined) return ["pnpm", ["--filter", pkg, "build:native"]]; + const build = `pnpm --filter ${pkg} build:native`; + const { inputs, outputs } = NATIVE_ARTIFACTS[artifact]; + const excludes = NATIVE_OUTPUT_NAMES.map((name) => `-x '${name}'`).join(" "); + const ref = nativeReference; + const script = [ + "set -eu", + `ref='${ref}'`, + "same=1", + ...outputs.map((output) => `[ -e "$ref/${output}" ] || same=0`), + ...inputs.map( + (input) => + `[ "$same" = 0 ] || diff -rq ${excludes} "$ref/${input}" "${input}" >/dev/null 2>&1 || same=0`, + ), + 'if [ "$same" = 1 ]; then', + ...outputs.map( + (output) => + ` rm -rf "${output}" && mkdir -p "$(dirname "${output}")" && cp -R "$ref/${output}" "${output}"`, + ), + ` echo "reused the ${artifact} artifact from $ref: identical inputs"`, + "else", + ` ${build}`, + "fi", + ].join("\n"); + return ["sh", ["-c", script]]; +} + async function preflight() { console.log("Sandbox preflight:"); console.log(` auth: ${vercelConfig.authSource}`); @@ -582,7 +654,10 @@ async function createWorker(worker) { vcpus, "--non-persistent", ]; - for (let attempt = 1; attempt <= 2; attempt++) { + // Sandbox creation is rate limited per team in vCPUs per minute, so a + // large fan-out (many shards, large Sandboxes) retries with backoff. + const attempts = 6; + for (let attempt = 1; attempt <= attempts; attempt++) { try { await vercel(args, { idleTimeoutMs: 60_000, @@ -598,8 +673,12 @@ async function createWorker(worker) { await execIn(worker, "true", [], {}, "create status", 60_000, "/"); return; } catch (error) { - if (attempt === 2) throw error; - console.warn(`[${worker.label}] Sandbox is not reachable; retrying creation once...`); + if (attempt === attempts) throw error; + const delay = 15_000 * attempt; + console.warn( + `[${worker.label}] Sandbox is not reachable; retrying creation in ${delay / 1000}s...`, + ); + await new Promise((resolve) => setTimeout(resolve, delay)); } } } @@ -781,19 +860,13 @@ try { // including TypeScript's incremental metadata. Rebuild both together. await execIn(worker, "pnpm", ["build:fresh"], {}, "", 2 * 60_000); // Workspace builds deliberately do not rebuild packaged native artifacts. - // Every remote lane needs the Linux helper and runtime from this worktree. + // Every remote lane needs the Linux helper and runtime from this worktree, + // built from its sources or reused from a reference checkout whose + // native inputs are identical. + await execIn(worker, ...nativeArtifactCommand("llvm"), {}, "LLVM helper", 5 * 60_000); await execIn( worker, - "pnpm", - ["--filter", "@scriptc/llvm-linux-x64-gnu", "build:native"], - {}, - "LLVM helper", - 5 * 60_000, - ); - await execIn( - worker, - "pnpm", - ["--filter", "@scriptc/runtime-linux-x64-gnu", "build:native"], + ...nativeArtifactCommand("runtime"), { CC: "zig", AR: "zig" }, "runtime pack", 5 * 60_000, diff --git a/scripts/zero-cost-compare.mjs b/scripts/zero-cost-compare.mjs index b412fa72a..70d9fce6d 100644 --- a/scripts/zero-cost-compare.mjs +++ b/scripts/zero-cost-compare.mjs @@ -6,6 +6,7 @@ * the first unit that differs. The self-hosted compiler's IR and LLVM exceed * V8's maximum string length, so splitting works on Buffers and hashes each * unit instead of decoding the artifact. */ +import assert from "node:assert/strict"; import { createHash } from "node:crypto"; const hash = (bytes) => createHash("sha1").update(bytes).digest("hex"); @@ -490,6 +491,57 @@ export function compareCounts(baseline, candidate) { }; } +/** The report lines the gate prints for one program. */ +export function programLines(entry) { + const lines = []; + const pct = (ratio) => `${ratio >= 1 ? "+" : ""}${((ratio - 1) * 100).toFixed(2)}%`; + const label = { + identical: "identical", + allowed: "allowed ", + differs: "DIFFERS ", + "build-error": "ERROR ", + "both-fail": "both-fail", + }[entry.verdict]; + const notes = []; + for (const [kind, result] of Object.entries(entry.artifacts)) + if (!result.identical) { + const counts = result.changed + ? ` (${result.changed.length} changed, ${result.added.length} added, ${result.removed.length} removed)` + : ""; + notes.push(`${kind}: ${result.first}${counts}`); + } + const size = entry.artifacts.exe?.bytes; + if (size && size.baseline !== size.candidate) + notes.push( + `size ${size.baseline} -> ${size.candidate} B (${pct(size.candidate / size.baseline)})`, + ); + if (entry.cachegrind?.total) { + const { total, deltas } = entry.cachegrind; + notes.push( + `Ir ${total.baseline} -> ${total.candidate} (${pct(total.candidate / Math.max(1, total.baseline))})` + + (deltas[0] + ? `, largest ${deltas[0].name} ${deltas[0].delta > 0 ? "+" : ""}${deltas[0].delta}` + : ""), + ); + } else if (entry.cachegrind?.skipped) + notes.push(`cachegrind skipped: ${entry.cachegrind.skipped}`); + for (const failure of entry.dynamicTier?.failures ?? []) notes.push(`SYMBOLS: ${failure}`); + lines.push(`${label} ${entry.id}${notes.length ? ` ${notes.join("; ")}` : ""}`); + // A build error's first line is often a warning; print all of it. + const errors = + entry.baselineError !== undefined && entry.baselineError === entry.candidateError + ? [["baseline and candidate", entry.baselineError]] + : [ + ["baseline", entry.baselineError], + ["candidate", entry.candidateError], + ].filter(([, error]) => error !== undefined); + for (const [side, error] of errors) { + lines.push(` ${side}:`); + for (const line of error.split("\n")) lines.push(` ${line}`); + } + return lines; +} + /* ── verdicts ──────────────────────────────────────────────────────────── */ /** One program's verdict from its build results and artifact comparisons. @@ -542,3 +594,142 @@ export function timeRatio(rows, resamples = 2000) { high: ratios[Math.floor(resamples * 0.975)], }; } + +/** The compile-time verdict for one group of timing rows (`bench` or + * `self`): it fails when the 95% interval lies entirely above the allowed + * growth, and is inconclusive when the interval straddles it. */ +export function timingVerdict(name, rows, maxGrowth) { + const result = timeRatio(rows); + const fails = result.low > 1 + maxGrowth; + const verdict = fails + ? "SLOWER" + : result.high > 1 + maxGrowth + ? "inconclusive (rerun with more runs)" + : "within tolerance"; + const pct = (ratio) => `${ratio >= 1 ? "+" : ""}${((ratio - 1) * 100).toFixed(2)}%`; + return { + result, + fails, + line: + `time ${name} ${rows.length > 1 ? "geomean " : ""}${pct(result.ratio)} ` + + `[95% CI ${pct(result.low)}, ${pct(result.high)}]: ${verdict}`, + failure: fails + ? `${name} compile time grew ${pct(result.ratio)} (CI low ${pct(result.low)})` + : null, + }; +} + +/** Timing rows grouped as the gate judges them. */ +export function timingGroups(rows) { + return { + bench: rows.filter((r) => r.id.startsWith("bench/")), + self: rows.filter((r) => r.id === "self/cli"), + }; +} + +/* ── baseline cache ────────────────────────────────────────────────────── */ + +/** A content hash of one artifact file, as the baseline cache records it. */ +export const artifactHash = hash; + +/** The hash of what a program's build reads, as the baseline cache records + * it: `files` (the entry and its relative imports) by path relative to + * `root` and content, read through `read`, and the optimization level. */ +export function programInputsHash(root, files, optimization, read) { + const h = createHash("sha1").update(`${optimization}\0`); + for (const file of [...files].sort()) + h.update(`${file.startsWith(root + "/") ? file.slice(root.length + 1) : file}\0`) + .update(read(file)) + .update("\0"); + return h.digest("hex"); +} + +/** The baseline cache entry recorded for program `id`, or undefined when the + * cache has none or recorded it for other inputs (`programInputsHash`): a + * program or an import that changed since the record must build its + * baseline, or a recorded build error or artifact hash of the old sources + * would settle the new sources' comparison. */ +export function recordedBaseline(cache, id, inputs) { + const entry = cache?.programs[id]; + return entry !== undefined && entry.inputs === inputs ? entry : undefined; +} + +/** The comparison a cached baseline entry settles without building the + * baseline, or null when the baseline must be built: identical artifacts + * (every recorded hash equals the candidate's) or the same build error on + * both sides. `candidate` holds the candidate's build result and `hashes` + * its artifact hashes. */ +export function cachedComparison(entry, candidate, hashes) { + if (entry === undefined) return null; + if (!entry.ok || !candidate.ok) + return !entry.ok && !candidate.ok && entry.error === candidate.error + ? { + baseline: { ok: false, error: entry.error, ms: entry.ms ?? null, cached: true }, + artifacts: {}, + } + : null; + const kinds = Object.keys(entry.hashes); + const differs = (kind) => entry.hashes[kind] === null || entry.hashes[kind] !== hashes[kind]; + if (kinds.length === 0 || kinds.some(differs)) return null; + const artifacts = {}; + for (const kind of kinds) artifacts[kind] = { identical: true }; + if (artifacts.exe) artifacts.exe.bytes = { baseline: entry.exeBytes, candidate: entry.exeBytes }; + return { baseline: { ok: true, ms: entry.ms ?? null }, artifacts }; +} + +/* ── sharded runs ──────────────────────────────────────────────────────── */ + +/** Merge the --json reports of gate shards that split one good-code set + * (--shard, --set) into a single report with one verdict. Program entries + * and their failures are concatenated; the compile-time groups and the + * "nothing was compared" check are recomputed over all shards. */ +export function mergeReports(reports) { + assert(reports.length > 0, "no gate reports to merge"); + const [first] = reports; + for (const report of reports) { + assert(report.schema === first.schema, "gate reports have different schemas"); + for (const label of ["baseline", "candidate"]) + assert( + report.trees[label].revision === first.trees[label].revision, + `gate reports compare different ${label} revisions`, + ); + } + const globals = new Set(reports.flatMap((r) => r.globalFailures ?? [])); + const merged = { + ...first, + shards: reports.length, + programs: reports.flatMap((r) => r.programs), + failures: reports.flatMap((r) => r.failures.filter((f) => !globals.has(f))), + globalFailures: [], + timing: null, + candidateSelf: reports.find((r) => r.candidateSelf)?.candidateSelf, + elapsedMs: Math.max(...reports.map((r) => r.elapsedMs ?? 0)), + }; + const ids = new Set(); + for (const p of merged.programs) { + assert(!ids.has(p.id), `program ${p.id} appears in more than one gate report`); + ids.add(p.id); + } + const timed = reports.filter((r) => r.timing !== null); + const lines = []; + if (timed.length > 0) { + const rows = timed.flatMap((r) => r.timing.rows); + const maxGrowth = timed[0].timing.maxGrowth; + merged.timing = { runs: timed[0].timing.runs, maxGrowth, rows, groups: {} }; + for (const [name, group] of Object.entries(timingGroups(rows))) { + if (group.length === 0) continue; + const { result, line, failure } = timingVerdict(name, group, maxGrowth); + merged.timing.groups[name] = result; + lines.push(line); + if (failure !== null) merged.globalFailures.push(failure); + } + } + const counts = {}; + for (const p of merged.programs) counts[p.verdict] = (counts[p.verdict] ?? 0) + 1; + if (!merged.programs.some((p) => ["identical", "allowed", "differs"].includes(p.verdict))) + merged.globalFailures.push("no program built with both checkouts, so nothing was compared"); + merged.failures.push(...merged.globalFailures); + merged.counts = counts; + merged.verdict = merged.failures.length > 0 ? "fail" : "pass"; + return { report: merged, timingLines: lines }; +} diff --git a/scripts/zero-cost-gate.mjs b/scripts/zero-cost-gate.mjs index a4427f7e8..bc312f2a6 100644 --- a/scripts/zero-cost-gate.mjs +++ b/scripts/zero-cost-gate.mjs @@ -13,6 +13,7 @@ * pattern signature) may differ; any other difference fails the gate. */ import assert from "node:assert/strict"; import { spawn, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { existsSync, mkdirSync, @@ -23,12 +24,14 @@ import { statSync, writeFileSync, } from "node:fs"; -import { availableParallelism } from "node:os"; +import { availableParallelism, totalmem } from "node:os"; import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; import { readWorkloads } from "../benchmarks/runtime/workloads.mjs"; import { + artifactHash, + cachedComparison, cachegrindCounts, compareArtifact, compareCounts, @@ -38,8 +41,12 @@ import { dynamicTierSymbols, median, nmOutput, + programInputsHash, + programLines, programVerdict, - timeRatio, + recordedBaseline, + timingGroups, + timingVerdict, } from "./zero-cost-compare.mjs"; const repoRoot = fileURLToPath(new URL("..", import.meta.url)); @@ -61,6 +68,8 @@ const { values } = parseArgs({ cachegrind: { type: "boolean", default: false }, jobs: { type: "string", default: String(Math.max(1, availableParallelism())) }, json: { type: "string" }, + "baseline-cache": { type: "string" }, + "record-baseline-cache": { type: "string" }, keep: { type: "boolean", default: false }, help: { type: "boolean", default: false }, }, @@ -72,6 +81,7 @@ if (values.help || values.baseline === undefined) { [--timing-runs=N] [--self-timing-runs=N] [--max-time-growth=2] [--max-size-growth=0] [--cachegrind] [--jobs=N] [--json=] [--keep] + [--baseline-cache=] [--record-baseline-cache=] Each checkout must be built (packages/cli/dist/bootstrap.js and this host's runtime pack). Both compile the same program files, taken from --programs @@ -82,7 +92,9 @@ benchmarks/builds applications), self (packages/compiler/src/native/cli.ts, LLVM only; the candidate's own compiler source must also build with the candidate), corpus (tests/corpus TypeScript programs; --corpus=all adds JavaScript entries). --quick is --set=bench --timing-runs=0, for CI. ---shard=i/n keeps every n-th corpus program; --only filters program ids. +--shard=i/n keeps every n-th bench and corpus program; the self program runs +in shard 1. scripts/zero-cost-merge.mjs merges the shards' --json reports +into one verdict. --only filters program ids. --allow= is the change's pattern signature: programs whose source files match it may differ (reported as "allowed"). Any other difference in @@ -106,6 +118,18 @@ cachegrind (bench workloads with their arguments, corpus programs without) and compares instruction counts per function of the executable's own symbols, excluding the allocator's prefault thread. +--record-baseline-cache= builds only the baseline and records each +program's artifact hashes (or build error) in , with a hash of the +program's inputs (its entry, its relative imports and its optimization +level), then exits. +--baseline-cache= reads such a record: a program whose candidate +artifacts hash the same as the recorded baseline's is identical without a +baseline build, and the same build error on both sides is both-fail; any +other program builds the baseline as usual, and so does a program whose +inputs changed since the record. The record applies only to the +baseline revision and toolchain it was made with; otherwise it is ignored. +The self program always builds both sides, since its builds are timed. + Exit status: 0 when the gate passes, 1 when it fails.`); process.exit(values.help ? 0 : 2); } @@ -200,13 +224,7 @@ function goodCodeSet() { if (main !== undefined) entries.push(main); } else if (exts.some((ext) => name.endsWith(`.${ext}`))) entries.push(path); } - let selected = entries; - if (values.shard) { - const [index, count] = values.shard.split("/").map(Number); - assert.ok(index >= 1 && index <= count, "--shard must be i/n with 1 <= i <= n"); - selected = entries.filter((_, k) => k % count === index - 1); - } - for (const entry of selected) { + for (const entry of entries) { const head = readFileSync(entry, "utf8").split("\n", 2); const speed = head.some((line) => /^\/\/ @optimization:\s*speed\s*$/.test(line)); programs.push({ @@ -217,7 +235,17 @@ function goodCodeSet() { }); } } - return programs.filter((p) => only === null || only.test(p.id)); + const selected = programs.filter((p) => only === null || only.test(p.id)); + if (!values.shard) return selected; + const [index, count] = values.shard.split("/").map(Number); + assert.ok( + Number.isInteger(index) && Number.isInteger(count) && index >= 1 && index <= count, + "--shard must be i/n with 1 <= i <= n", + ); + // The self program takes minutes and pairs its own timing: shard 1 owns it. + const self = selected.filter((p) => p.kind === "source"); + const rest = selected.filter((p) => p.kind !== "source"); + return [...(index === 1 ? self : []), ...rest.filter((_, k) => k % count === index - 1)]; } /* ── builds ────────────────────────────────────────────────────────────── */ @@ -415,7 +443,74 @@ function revision(root) { const fmtMs = (ms) => `${(ms / 1000).toFixed(2)}s`; const pct = (ratio) => `${ratio >= 1 ? "+" : ""}${((ratio - 1) * 100).toFixed(2)}%`; -const programs = goodCodeSet(); +/* ── baseline cache ────────────────────────────────────────────────────── */ + +/** Every file under `dir`, recursively, in a stable order. */ +function filesUnder(dir) { + if (!existsSync(dir)) return []; + return readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter((d) => d.isFile()) + .map((d) => join(d.parentPath, d.name)) + .sort(); +} + +/** What a baseline build depends on besides the program: the checkout's + * built compiler and CLI, its runtime packs and LLVM helpers, Node, the + * host and the program and checkout paths that reach the artifacts. */ +function toolchainFingerprint(root) { + const h = createHash("sha1"); + h.update(JSON.stringify([process.platform, process.arch, process.version, root, programsRoot])); + const clang = spawnSync("clang", ["--version"], { encoding: "utf8" }); + h.update(clang.stdout ?? ""); + const packages = join(root, "packages"); + for (const name of readdirSync(packages).sort()) { + const dir = join(packages, name); + const files = [ + ...filesUnder(join(dir, "dist")), + ...(name.startsWith("runtime-") + ? [join(dir, "runtime-pack.json"), ...filesUnder(join(dir, "artifacts"))] + : []), + ...(name.startsWith("llvm-") ? filesUnder(join(dir, "bin")) : []), + ]; + for (const file of files) + if (existsSync(file)) h.update(relative(root, file)).update(readFileSync(file)); + } + return h.digest("hex"); +} + +/** The hash of `program`'s inputs, which its baseline cache entry records. */ +function inputsHash(program) { + return programInputsHash( + programsRoot, + programInputs(program.entry), + program.kind === "source" ? "source" : (program.optimization ?? "release"), + (file) => readFileSync(file), + ); +} + +/** The content hashes of one build's artifacts (null for a missing one). */ +function buildHashes(build) { + const hashes = {}; + for (const kind of ["ir", "llvm", "exe"]) + if (kind in build) + hashes[kind] = build[kind] === null ? null : artifactHash(readFileSync(build[kind])); + return hashes; +} + +const recordPath = values["record-baseline-cache"]; +let baselineCache = null; +if (recordPath === undefined && values["baseline-cache"] !== undefined) { + const file = resolve(values["baseline-cache"]); + const cache = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : null; + const fingerprint = cache === null ? null : toolchainFingerprint(trees.baseline); + if (cache?.schema === 2 && cache.fingerprint === fingerprint) baselineCache = cache; + else + console.log( + `baseline cache ${file} ${cache === null ? "is missing" : "was made with another baseline or toolchain"}; building every baseline`, + ); +} + +const programs = goodCodeSet().filter((p) => recordPath === undefined || p.kind !== "source"); assert.ok(programs.length > 0, "the selected good-code set is empty"); const report = { schema: 1, @@ -429,6 +524,13 @@ const report = { programs: [], timing: null, failures: [], + // Failures judged over the whole good-code set (compile time, nothing + // compared); scripts/zero-cost-merge.mjs recomputes them across shards. + globalFailures: [], + baselineCache: + baselineCache === null + ? null + : { programs: Object.keys(baselineCache.programs).length, used: 0 }, }; console.log( `zero-cost gate: ${programs.length} programs (${[...sets].join(", ")}), ` + @@ -437,18 +539,95 @@ console.log( let failed = false; const started = Date.now(); + +if (recordPath !== undefined) { + const record = { + schema: 2, + date: report.date, + fingerprint: toolchainFingerprint(trees.baseline), + baseline: report.trees.baseline, + programs: {}, + }; + await pool( + programs.map((program) => ({ + run: async () => { + const build = await buildProgram(program, "baseline"); + const inputs = inputsHash(program); + record.programs[program.id] = build.ok + ? { + inputs, + ok: true, + ms: build.ms, + hashes: buildHashes(build), + ...(build.exe ? { exeBytes: statSync(build.exe).size } : {}), + } + : { inputs, ok: false, ms: build.ms ?? null, error: build.error }; + if (build.dir) rmSync(build.dir, { recursive: true, force: true }); + }, + })), + ); + mkdirSync(dirname(resolve(recordPath)), { recursive: true }); + writeFileSync(recordPath, JSON.stringify(record) + "\n"); + rmSync(work, { recursive: true, force: true }); + const failures = Object.values(record.programs).filter((p) => !p.ok).length; + console.log( + `zero-cost gate: recorded ${programs.length} baseline programs (${failures} build errors) ` + + `in ${recordPath} in ${fmtMs(Date.now() - started)}`, + ); + process.exit(0); +} + +/** The candidate's own compiler source must still build with the candidate + * (see below). On a host with room for a third self-hosted build it runs + * alongside the comparison builds instead of after them. */ +const checkCandidateSelf = + sets.has("self") && programsRoot !== trees.candidate && programs.some((p) => p.kind === "source"); +const buildCandidateSelf = () => + buildProgram( + { + id: "self/candidate-source", + entry: join(trees.candidate, "packages/compiler/src/native/cli.ts"), + kind: "source", + }, + "candidate", + ); +const earlyCandidateSelf = + checkCandidateSelf && totalmem() >= 40 * 1024 ** 3 ? buildCandidateSelf() : null; + await pool( programs.map((program) => ({ heavy: program.kind === "source", run: async () => { - const [baseline, candidate] = await Promise.all([ - buildProgram(program, "baseline"), - buildProgram(program, "candidate"), - ]); + const recorded = + program.kind === "source" || baselineCache === null + ? undefined + : recordedBaseline(baselineCache, program.id, inputsHash(program)); + let baseline; + let candidate; + let settled = null; + if (recorded === undefined) + [baseline, candidate] = await Promise.all([ + buildProgram(program, "baseline"), + buildProgram(program, "candidate"), + ]); + else { + candidate = await buildProgram(program, "candidate"); + settled = cachedComparison(recorded, candidate, candidate.ok ? buildHashes(candidate) : {}); + // A cached identical baseline has the candidate's artifacts. + baseline = + settled === null + ? await buildProgram(program, "baseline") + : settled.baseline.ok + ? { ...candidate, ms: settled.baseline.ms, cached: true } + : settled.baseline; + if (settled !== null) report.baselineCache.used++; + } const allowed = allow !== null && programInputs(program.entry).some((f) => allow.test(readFileSync(f, "utf8"))); - const artifacts = baseline.ok && candidate.ok ? compareBuilds(baseline, candidate) : {}; + const artifacts = + settled?.artifacts ?? + (baseline.ok && candidate.ok ? compareBuilds(baseline, candidate) : {}); // The benchmark workloads and the self-hosted compiler always build. const required = !program.id.startsWith("corpus/"); const { verdict, fails } = programVerdict({ @@ -465,6 +644,7 @@ await pool( buildMs: { baseline: baseline.ms ?? null, candidate: candidate.ms ?? null }, artifacts, }; + if (settled !== null) entry.cachedBaseline = true; if (!baseline.ok) entry.baselineError = baseline.error; if (!candidate.ok) entry.candidateError = candidate.error; const size = artifacts.exe?.bytes; @@ -506,7 +686,8 @@ await pool( nmCache.delete(baseline.exe); nmCache.delete(candidate.exe); if (!values.keep) { - if (baseline.dir) rmSync(baseline.dir, { recursive: true, force: true }); + if (baseline.dir && !baseline.cached) + rmSync(baseline.dir, { recursive: true, force: true }); if (candidate.dir) rmSync(candidate.dir, { recursive: true, force: true }); } }, @@ -514,50 +695,7 @@ await pool( ); function printProgram(entry) { - const label = { - identical: "identical", - allowed: "allowed ", - differs: "DIFFERS ", - "build-error": "ERROR ", - "both-fail": "both-fail", - }[entry.verdict]; - const notes = []; - for (const [kind, result] of Object.entries(entry.artifacts)) - if (!result.identical) { - const counts = result.changed - ? ` (${result.changed.length} changed, ${result.added.length} added, ${result.removed.length} removed)` - : ""; - notes.push(`${kind}: ${result.first}${counts}`); - } - const size = entry.artifacts.exe?.bytes; - if (size && size.baseline !== size.candidate) - notes.push( - `size ${size.baseline} -> ${size.candidate} B (${pct(size.candidate / size.baseline)})`, - ); - if (entry.cachegrind?.total) { - const { total, deltas } = entry.cachegrind; - notes.push( - `Ir ${total.baseline} -> ${total.candidate} (${pct(total.candidate / Math.max(1, total.baseline))})` + - (deltas[0] - ? `, largest ${deltas[0].name} ${deltas[0].delta > 0 ? "+" : ""}${deltas[0].delta}` - : ""), - ); - } else if (entry.cachegrind?.skipped) - notes.push(`cachegrind skipped: ${entry.cachegrind.skipped}`); - for (const failure of entry.dynamicTier?.failures ?? []) notes.push(`SYMBOLS: ${failure}`); - console.log(`${label} ${entry.id}${notes.length ? ` ${notes.join("; ")}` : ""}`); - // A build error's first line is often a warning; print all of it. - const errors = - entry.baselineError !== undefined && entry.baselineError === entry.candidateError - ? [["baseline and candidate", entry.baselineError]] - : [ - ["baseline", entry.baselineError], - ["candidate", entry.candidateError], - ].filter(([, error]) => error !== undefined); - for (const [side, error] of errors) { - console.log(` ${side}:`); - for (const line of error.split("\n")) console.log(` ${line}`); - } + for (const line of programLines(entry)) console.log(line); } /* Compile time: paired --emit=llvm builds, the baseline and the candidate @@ -603,29 +741,15 @@ if (timingRuns > 0) { ); } report.timing = { runs: timingRuns, maxGrowth: maxTimeGrowth, rows, groups: {} }; - const groups = { - bench: rows.filter((r) => r.id.startsWith("bench/")), - self: rows.filter((r) => r.id === "self/cli"), - }; - for (const [name, group] of Object.entries(groups)) { + for (const [name, group] of Object.entries(timingGroups(rows))) { if (group.length === 0) continue; - const result = timeRatio(group); + const { result, line, failure } = timingVerdict(name, group, maxTimeGrowth); report.timing.groups[name] = result; - const verdict = - result.low > 1 + maxTimeGrowth - ? "SLOWER" - : result.high > 1 + maxTimeGrowth - ? "inconclusive (rerun with more runs)" - : "within tolerance"; - console.log( - `time ${name} ${group.length > 1 ? "geomean " : ""}${pct(result.ratio)} ` + - `[95% CI ${pct(result.low)}, ${pct(result.high)}]: ${verdict}`, - ); - if (result.low > 1 + maxTimeGrowth) { + console.log(line); + if (failure !== null) { failed = true; - report.failures.push( - `${name} compile time grew ${pct(result.ratio)} (CI low ${pct(result.low)})`, - ); + report.failures.push(failure); + report.globalFailures.push(failure); } } } @@ -634,12 +758,8 @@ if (timingRuns > 0) { // from one checkout), so a change that adds compiler code the self-hosted // frontend cannot lower would pass unnoticed. The candidate's own compiler // source must still build with the candidate (LLVM only, not compared). -if (sets.has("self") && programsRoot !== trees.candidate) { - const entry = join(trees.candidate, "packages/compiler/src/native/cli.ts"); - const result = await buildProgram( - { id: "self/candidate-source", entry, kind: "source" }, - "candidate", - ); +if (checkCandidateSelf) { + const result = await (earlyCandidateSelf ?? buildCandidateSelf()); report.candidateSelf = result.ok ? { ok: true, buildMs: result.ms } : { ok: false, error: result.error }; @@ -658,7 +778,9 @@ const counts = {}; for (const p of report.programs) counts[p.verdict] = (counts[p.verdict] ?? 0) + 1; if (!report.programs.some((p) => ["identical", "allowed", "differs"].includes(p.verdict))) { failed = true; - report.failures.push("no program built with both checkouts, so nothing was compared"); + const failure = "no program built with both checkouts, so nothing was compared"; + report.failures.push(failure); + report.globalFailures.push(failure); } report.counts = counts; report.verdict = failed ? "fail" : "pass"; @@ -672,7 +794,8 @@ else console.log(`artifacts kept in ${work}`); console.log( `zero-cost gate: ${failed ? "FAIL" : "PASS"} — ${Object.entries(counts) .map(([k, v]) => `${v} ${k}`) - .join(", ")} in ${fmtMs(report.elapsedMs)}`, + .join(", ")} in ${fmtMs(report.elapsedMs)}` + + (report.baselineCache ? ` (${report.baselineCache.used} baselines from the cache)` : ""), ); for (const failure of report.failures) console.log(` ${failure}`); process.exit(failed ? 1 : 0); diff --git a/scripts/zero-cost-merge.mjs b/scripts/zero-cost-merge.mjs new file mode 100644 index 000000000..d32022dd9 --- /dev/null +++ b/scripts/zero-cost-merge.mjs @@ -0,0 +1,52 @@ +/* Merge zero-cost gate shards into one verdict. + * + * A sharded gate runs scripts/zero-cost-gate.mjs several times over one + * good-code set (--shard=i/n for the bench and corpus programs, the self + * program in shard 1 or its own --set=self run), each with --json. This + * script merges those reports: program verdicts and failures are + * concatenated, and the compile-time verdicts and the "nothing was + * compared" check are recomputed over every shard's rows. */ +import { readFileSync, writeFileSync } from "node:fs"; +import { parseArgs } from "node:util"; +import { mergeReports, programLines } from "./zero-cost-compare.mjs"; + +const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { + json: { type: "string" }, + quiet: { type: "boolean", default: false }, + help: { type: "boolean", default: false }, + }, +}); +if (values.help || positionals.length === 0) { + console.log(`Usage: node scripts/zero-cost-merge.mjs [--json=] [--quiet] ... + +Merges the --json reports of zero-cost gate shards and prints one summary: +every program that is not identical, the compile-time verdicts and the +failures. --quiet prints only the summary and failures. --json writes the +merged report. Exit status: 0 when the merged gate passes, 1 when it fails.`); + process.exit(values.help ? 0 : 2); +} + +const reports = positionals.map((file) => JSON.parse(readFileSync(file, "utf8"))); +const { report, timingLines } = mergeReports(reports); +if (!values.quiet) + for (const entry of report.programs) + if (entry.verdict !== "identical") for (const line of programLines(entry)) console.log(line); +for (const line of timingLines) console.log(line); +if (report.candidateSelf) + console.log( + report.candidateSelf.ok + ? `self candidate compiler source builds with the candidate (${(report.candidateSelf.buildMs / 1000).toFixed(2)}s)` + : "self candidate compiler source FAILS to build with the candidate", + ); +const cached = report.programs.filter((p) => p.cachedBaseline).length; +console.log( + `zero-cost gate: ${report.verdict === "pass" ? "PASS" : "FAIL"} — ${Object.entries(report.counts) + .map(([k, v]) => `${v} ${k}`) + .join(", ")} over ${report.shards} shards (longest ${(report.elapsedMs / 1000).toFixed(2)}s` + + `${cached ? `, ${cached} baselines from the cache` : ""})`, +); +for (const failure of report.failures) console.log(` ${failure}`); +if (values.json) writeFileSync(values.json, JSON.stringify(report, null, 2) + "\n"); +process.exit(report.verdict === "pass" ? 0 : 1); diff --git a/tests/corpus/7393-non-null-element-operands.ts b/tests/corpus/7393-non-null-element-operands.ts new file mode 100644 index 000000000..0fd7190a3 --- /dev/null +++ b/tests/corpus/7393-non-null-element-operands.ts @@ -0,0 +1,81 @@ +// Non-null assertions on array element reads whose index is out of bounds +// or names a hole, used as operands of operators that convert them. `!` +// performs no runtime check, so the read is undefined, as in Node: NaN in +// arithmetic, false in comparisons, "undefined" in strings. + +const values = [3, 1, 4, 1, 5]; +const words = ["alpha", "beta"]; +const flags = [true, false]; +const holey: number[] = []; +holey[3] = 7; + +function show(v: unknown): string { + return Object.is(v, -0) ? "-0" : String(v); +} + +function line(label: string, parts: unknown[]): void { + console.log(label.padEnd(10), parts.map(show).join(" ")); +} + +for (const k of [0, 4, 5, -1, 1.5, 100]) { + line(`k=${k}`, [ + values[k]! + 1, + values[k]! * 2, + -values[k]!, + values[k]! | 0, + values[k]! < 3, + values[k]! >= 1, + values[k]! === 1, + values[k]! !== 1, + `${values[k]!}`, + "v=" + values[k]!, + Math.max(values[k]!, 0), + words[k]! + "!", + `<${words[k]!}>`, + words[k]! < "b", + !flags[k]!, + ]); +} + +line("holes", [holey[0]! + 1, holey[3]! + 1, `${holey[1]!}`, holey[2]! === undefined, holey[9]! > 0]); + +// `+` with an untyped partner concatenates or adds when it runs. +const untypedText: any = "x"; +const untypedNumber: any = 5; +for (const k of [1, 9]) { + line(`untyped ${k}`, [ + values[k]! + untypedText, + untypedText + values[k]!, + values[k]! + untypedNumber, + words[k]! + untypedNumber, + words[k]! + untypedText, + ]); +} + +let sum = 0; +let text = ""; +for (let i = 0; i <= values.length; i++) { + sum += values[i]!; + text += words[i % 3]!; + text += ","; +} +line("compound", [sum, text]); + +// A counter array: an index past the end reads NaN, and the store widens +// the array to doubles. +function counter(n: number, m: number): number[] { + const a = new Array(n).fill(0) as number[]; + for (let i = 0; i < 6000; i++) a[i % m] = a[i % m]! + 1; + return [a[0]!, a[m - 1]!, a.length, a[n + 1]! + 0]; +} +line("counter", counter(5000, 5000)); +line("past", counter(5000, 5003)); +line("zero", counter(4, 0)); + +const grid = [ + [1, 2], + [3, 4], +]; +let total = 0; +for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) total += grid[r % 2]![c]! * 1; +line("grid", [total]); diff --git a/tests/corpus/7395-non-null-element-dereference.ts b/tests/corpus/7395-non-null-element-dereference.ts new file mode 100644 index 000000000..67d9249d1 --- /dev/null +++ b/tests/corpus/7395-non-null-element-dereference.ts @@ -0,0 +1,142 @@ +// Member reads and writes through non-null asserted array element reads. +// `!` performs no runtime check: a missing element is undefined, so the +// member access throws Node's TypeError, which the program can catch. + +class Cell { + count = 0; + label: string; + constructor(label: string) { + this.label = label; + } + bump(): number { + return ++this.count; + } +} + +function cell(label: string): Cell { + return new Cell(label); +} + +const cells = [cell("a"), cell("b")]; +const words = ["alpha", "beta"]; +const grid = [ + [1, 2], + [3, 4], +]; +const maps = [new Map([["x", 1]])]; +const holey: Cell[] = []; +holey[2] = cell("z"); +let reads = 0; +function at(n: number): number { + reads++; + return n; +} + +function attempt(label: string, f: () => unknown): void { + try { + console.log(label.padEnd(12), "ok", f()); + } catch (e) { + const error = e as Error; + console.log(label.padEnd(12), error instanceof TypeError, error.message); + } +} + +for (const k of [0, 1, 2, -1]) { + attempt(`label ${k}`, () => cells[k]!.label); + attempt(`bump ${k}`, () => cells[k]!.bump()); + attempt(`set ${k}`, () => (cells[k]!.label = "set")); + attempt(`count++ ${k}`, () => cells[k]!.count++); + attempt(`length ${k}`, () => words[k]!.length); + attempt(`upper ${k}`, () => words[k]!.toUpperCase()); + attempt(`grid ${k}`, () => grid[k]![0]); + attempt(`row ${k}`, () => { + const j = 1; + return grid[k]![j]; + }); + attempt(`push ${k}`, () => grid[k]!.push(9)); + attempt(`map ${k}`, () => maps[k]!.get("x")); +} + +attempt("hole", () => holey[0]!.label); +attempt("hole value", () => holey[2]!.label); +attempt("order", () => cells[at(7)]!.count + at(1)); +console.log("reads", reads); + +let total = 0; +try { + for (let i = 0; i < 4; i++) total += grid[i]![0]!; +} catch (e) { + console.log("loop", (e as Error).message, total); +} +console.log(cells.map((c) => `${c.label}:${c.count}`).join(" ")); + +// A write through a missing element evaluates the key and the value before +// it throws; an error the value throws is the one that propagates. +const events: string[] = []; +const note = (event: string, value: number): number => { + events.push(event); + return value; +}; +attempt("write order", () => { + cells[note("index", 7)]!.count = note("value", 1); +}); +attempt("write key order", () => { + grid[note("row", 9)]![note("column", 0)] = note("cell", 5); +}); +attempt("write literal key", () => { + grid[9]![0] = note("cell 0", 5); +}); +attempt("value throws", () => { + cells[7]!.count = (() => { + throw new Error("from the value"); + })(); +}); +attempt("present write", () => { + cells[0]!.count = note("present", 41) + 1; + return cells[0]!.count; +}); +console.log("events", events.join(",")); + +// The array is read before an index that replaces it. +let current = [new Cell("first")]; +const replace = (): number => { + current = []; + return 0; +}; +attempt("replaced read", () => current[replace()]!.label); +current = [new Cell("second")]; +attempt("replaced write", () => { + current[replace()]!.label = "written"; +}); +let names = ["kept"]; +attempt("replaced string", () => `${names[(names = [], 0)]!}`); +console.log(current.length, names.length); + +// The element is read before the key and the value: a value that replaces, +// removes or adds it writes to, or misses, the element the target read, and +// a key the value reassigns is the one read before it. +const slots = [new Cell("p"), new Cell("q")]; +const original = slots[0]!; +attempt("value replaces", () => { + slots[0]!.label = ((slots[0] = new Cell("r")), "set"); + return `${original.label} ${slots[0]!.label}`; +}); +const tail = slots[1]!; +attempt("value removes", () => { + slots[1]!.label = (slots.pop(), "gone"); + return `${tail.label} ${slots.length}`; +}); +attempt("value adds", () => { + slots[1]!.label = (slots.push(new Cell("s")), "added"); +}); +const table: number[][] = [[1, 2]]; +let column = 0; +attempt("value moves key", () => { + table[0]![column] = ((column = 1), 5); + return table[0]!.join(","); +}); +column = 0; +attempt("value moves missing key", () => { + table[4]![column] = ((column = 1), 5); +}); +console.log(slots.length, column); diff --git a/tests/corpus/8370-stack-overflow-cycles/main.ts b/tests/corpus/8370-stack-overflow-cycles/main.ts new file mode 100644 index 000000000..3b9ef5ddc --- /dev/null +++ b/tests/corpus/8370-stack-overflow-cycles/main.ts @@ -0,0 +1,69 @@ +// Unbounded recursion through each kind of call cycle raises Node's +// RangeError, and the program recovers and recurses again afterwards. +import { Even, Odd, Tree } from "./shapes.ts"; + +function probe(name: string, run: (n: number) => number): void { + try { + console.log(name, "returned", run(10_000_000)); + } catch (e) { + console.log(name, e instanceof RangeError, (e as Error).message); + } + console.log(name, "after", run(100)); +} + +function down(n: number): number { + return n === 0 ? 0 : 1 + down(n - 1); +} +probe("direct", down); + +function ping(n: number): number { + return n === 0 ? 0 : 1 + pong(n - 1); +} +function pong(n: number): number { + return n === 0 ? 0 : 2 + ping(n - 1); +} +probe("mutual", ping); + +function first(n: number): number { + return n === 0 ? 0 : 1 + second(n - 1); +} +function second(n: number): number { + return n === 0 ? 0 : 1 + third(n - 1); +} +function third(n: number): number { + return n === 0 ? 0 : 1 + fourth(n - 1); +} +function fourth(n: number): number { + return n === 0 ? 0 : 1 + fifth(n - 1); +} +function fifth(n: number): number { + return n === 0 ? 0 : 1 + first(n - 1); +} +probe("ring", first); + +function viaValue(n: number, again: (m: number) => number): number { + return n === 0 ? 0 : 1 + again(n - 1); +} +const callback = (m: number): number => viaValue(m, callback); +probe("closure", callback); + +function viaMap(n: number): number { + return n === 0 ? 0 : 1 + [n - 1].map(viaMap)[0]!; +} +probe("library callback", viaMap); + +const even = new Even(); +const odd = new Odd(); +even.next = odd; +odd.next = even; +probe("virtual", (n) => even.depth(n)); + +probe("constructor", (n) => { + let tree: Tree | null = new Tree(n); + let count = 0; + while (tree !== null) { + count++; + tree = tree.child; + } + return count; +}); diff --git a/tests/corpus/8370-stack-overflow-cycles/shapes.ts b/tests/corpus/8370-stack-overflow-cycles/shapes.ts new file mode 100644 index 000000000..6f5f6fada --- /dev/null +++ b/tests/corpus/8370-stack-overflow-cycles/shapes.ts @@ -0,0 +1,24 @@ +// Classes in their own module, so their names carry the module prefix. +export abstract class Link { + next: Link | null = null; + abstract depth(n: number): number; +} + +export class Even extends Link { + depth(n: number): number { + return n === 0 ? 0 : 1 + this.next!.depth(n - 1); + } +} + +export class Odd extends Link { + depth(n: number): number { + return n === 0 ? 0 : 2 + this.next!.depth(n - 1); + } +} + +export class Tree { + child: Tree | null; + constructor(n: number) { + this.child = n === 0 ? null : new Tree(n - 1); + } +} diff --git a/tests/corpus/8380-untyped-generator-loops.mjs b/tests/corpus/8380-untyped-generator-loops.mjs new file mode 100644 index 000000000..a6dd20ebe --- /dev/null +++ b/tests/corpus/8380-untyped-generator-loops.mjs @@ -0,0 +1,162 @@ +// Untyped for-of and for await loops over generator objects: values, +// completion values, early exits, errors, other iterables through the same +// loop, and the microtask order of each step. +function* numbers(n) { + for (let i = 0; i < n; i++) yield i; + return "done"; +} +function* mixed() { + yield "a"; + yield { k: 1 }; + yield undefined; + yield [1, 2]; + yield Promise.resolve(7); + return 99; +} +async function* asyncNumbers(n) { + for (let i = 0; i < n; i++) yield i * 10; + return "async done"; +} +async function* asyncMixed() { + yield "x"; + yield Promise.resolve(8); + yield null; +} + +function collect(it) { + const out = []; + for (const x of it) out.push(x); + return out; +} +async function collectAsync(it) { + const out = []; + for await (const x of it) out.push(x); + return out; +} +const show = (v) => JSON.stringify(v, (k, x) => (x instanceof Promise ? "promise" : x === undefined ? "undef" : x)); + +async function main() { + // The same loops over generators, arrays, Maps, strings and custom iterators. + console.log("sync", show(collect(numbers(4))), show(collect(mixed()))); + console.log("sync other", show(collect([1, 2])), show(collect(new Map([[1, 2]]))), show(collect("hé"))); + const custom = { + [Symbol.iterator]() { + let i = 0; + return { next: () => ({ value: i * i, done: i++ > 2 }) }; + }, + }; + console.log("sync custom", show(collect(custom))); + console.log("async", show(await collectAsync(asyncNumbers(3))), show(await collectAsync(asyncMixed()))); + console.log("async over sync", show(await collectAsync(mixed())), show(await collectAsync(numbers(3)))); + console.log("async other", show(await collectAsync([Promise.resolve(1), 2])), show(await collectAsync(custom))); + const asyncCustom = { + [Symbol.asyncIterator]() { + let i = 0; + return { next: async () => ({ value: `c${i}`, done: i++ > 1 }) }; + }, + }; + console.log("async custom", show(await collectAsync(asyncCustom))); + // Symbol.asyncIterator returning a sync generator: each result is awaited, + // its value is not, so a yielded promise reaches the loop as a promise. + const viaAsyncIterator = { [Symbol.asyncIterator]: () => mixed() }; + const seen = []; + for await (const x of viaAsyncIterator) seen.push(x instanceof Promise ? `promise ${await x}` : x); + console.log("async iterator sync", show(seen)); + + // A generator that has started keeps its position; one that finished stays done. + const started = numbers(5); + started.next(); + console.log("started", show(collect(started)), show(started.next())); + + // Early exits close the generator: finally blocks run once. + function* guarded(label) { + try { + yield 1; + yield 2; + } finally { + console.log(label, "finally"); + } + } + const pick = (v) => v; + for (const x of pick(guarded("break"))) { + console.log("break at", x); + break; + } + try { + for (const x of pick(guarded("throw"))) throw new Error(`body ${x}`); + } catch (e) { + console.log("caught", e.message); + } + const g = pick(guarded("after")); + for (const x of g) { + if (x === 1) break; + } + console.log("after break", show(g.next())); + async function* aguarded(label) { + try { + yield 1; + yield 2; + } finally { + console.log(label, "finally"); + } + } + for await (const x of pick(aguarded("abreak"))) { + console.log("abreak at", x); + break; + } + for await (const x of pick(guarded("sync abreak"))) { + console.log("sync abreak at", x); + break; + } + + // Errors from the generator body reach the loop. + function* failing() { + yield 1; + throw new TypeError("generator failed"); + } + async function* afailing() { + yield 1; + throw new RangeError("async generator failed"); + } + try { + for (const x of pick(failing())) console.log("failing got", x); + } catch (e) { + console.log("caught", e.name, e.message); + } + try { + for await (const x of pick(afailing())) console.log("afailing got", x); + } catch (e) { + console.log("caught", e.name, e.message); + } + // Microtask order: each step against a promise chain ticking alongside. + const ticker = (label, n) => { + let p = Promise.resolve(); + for (let i = 0; i < n; i++) p = p.then(() => console.log(label, "tick", i)); + return p; + }; + const consume = async (label, it) => { + for await (const x of it) console.log(label, "value", x); + console.log(label, "end"); + }; + await Promise.all([consume("sync", pick(numbers(3))), ticker("sync", 10)]); + await Promise.all([consume("async", pick(asyncNumbers(3))), ticker("async", 10)]); + await Promise.all([consume("promises", pick(mixed())), ticker("promises", 14)]); + + // Stages chained through untyped parameters. + async function* double(src) { + for await (const x of src) yield x * 2; + } + async function* keepOdd(src) { + for await (const x of src) if (x % 4 !== 0) yield x; + } + let sum = 0; + for await (const x of keepOdd(double(asyncNumbers(50)))) sum += x; + console.log("stages", sum); + let syncSum = 0; + function* inc(src) { + for (const x of src) yield x + 1; + } + for (const x of inc(inc(numbers(100)))) syncSum += x; + console.log("sync stages", syncSum); +} +main(); diff --git a/tests/corpus/8381-untyped-generator-stages.mjs b/tests/corpus/8381-untyped-generator-stages.mjs new file mode 100644 index 000000000..d326a00c1 --- /dev/null +++ b/tests/corpus/8381-untyped-generator-stages.mjs @@ -0,0 +1,82 @@ +// Untyped stages chained through generator objects: a sync generator source +// consumed with for await, async generator transforms, an async function +// consumer, and the same stages over arrays and custom iterables. +function run(source, transform, destination) { + return destination(transform(source())); +} +const N = 2000; +const total = await run( + function* () { + for (let i = 0; i < N; i++) yield i; + }, + async function* (src) { + for await (const n of src) yield n * 2; + }, + async function (src) { + let sum = 0; + for await (const n of src) sum += n; + return sum; + }, +); +console.log("total", total); + +async function* map(it, f) { + for await (const x of it) yield await f(x); +} +async function* take(it, n) { + if (n <= 0) return; + for await (const x of it) { + yield x; + if (--n === 0) return; + } +} +async function* source(n) { + try { + for (let i = 0; i < n; i++) yield i; + } finally { + console.log("source closed"); + } +} +async function toArray(it) { + const out = []; + for await (const x of it) out.push(x); + return out; +} +const half = async (x) => x / 2; +console.log(await toArray(take(map(source(10), half), 3))); +console.log(await toArray(map([1, 2, 3], half))); +console.log(await toArray(map(new Set(["a", "b"]), async (s) => s.toUpperCase()))); +const words = { + *[Symbol.iterator]() { + yield "x"; + yield "y"; + }, +}; +console.log(await toArray(map(words, async (s) => s + s))); + +// A stage that switches iterables between calls of the same loop. +function* letters() { + yield "p"; + yield "q"; +} +async function* numbersAsync() { + yield 1; + yield 2; +} +const sources = [letters(), numbersAsync(), ["z"], letters()]; +for (const s of sources) console.log(await toArray(s)); + +// Sync stages: generator to generator through untyped parameters. +function* inc(src) { + for (const x of src) yield x + 1; +} +function* evens(src) { + for (const x of src) if (x % 2 === 0) yield x; +} +function* range(n) { + for (let i = 0; i < n; i++) yield i; + return "range done"; +} +let s = 0; +for (const x of evens(inc(range(1000)))) s += x; +console.log("sync", s, [...inc([10, 20])], Array.from(evens(range(7)))); diff --git a/tests/corpus/8382-stackless-async-generator-steps.mjs b/tests/corpus/8382-stackless-async-generator-steps.mjs new file mode 100644 index 000000000..507040de0 --- /dev/null +++ b/tests/corpus/8382-stackless-async-generator-steps.mjs @@ -0,0 +1,172 @@ +// Async generators whose bodies suspend only at their own awaits and yields: +// requests queued while the body awaits, return() and throw() at a yield and +// during an await, finally blocks that await, yield* into another async +// generator, awaits of plain values and of settled and pending promises, a +// deep chain of stages, and the microtask order around them. +const log = []; +const tick = (label) => Promise.resolve().then(() => log.push(label)); +const later = (value) => new Promise((resolve) => setTimeout(() => resolve(value), 1)); + +async function* counter(n) { + try { + for (let i = 0; i < n; i++) { + const v = await (i % 2 === 0 ? i : Promise.resolve(i)); + log.push(`yield ${v}`); + yield v; + } + return "done"; + } finally { + log.push("counter finally"); + await later(0); + log.push("counter finally after await"); + } +} + +// Requests queued before the body settles the first one. +{ + const it = counter(3); + const requests = []; + for (let i = 0; i < 5; i++) requests.push(it.next()); + const results = []; + for (const request of requests) results.push(await request); + console.log(JSON.stringify(results)); + console.log(log.splice(0).join(" | ")); +} + +// return() at a yield runs the finally block, which awaits. +{ + const it = counter(5); + console.log(JSON.stringify(await it.next())); + const ret = it.return("early"); + tick("tick after return()"); + console.log(JSON.stringify(await ret)); + console.log(JSON.stringify(await it.next())); + console.log(log.splice(0).join(" | ")); +} + +// return() queued while the body awaits a pending promise. +async function* slow() { + try { + const v = await later(7); + yield v; + yield v + 1; + return "slow done"; + } finally { + log.push("slow finally"); + } +} +{ + const it = slow(); + const first = it.next(); + const ret = it.return("stopped"); + console.log(JSON.stringify(await first), JSON.stringify(await ret)); + console.log(log.splice(0).join(" | ")); +} + +// throw() at a yield, caught by the body, which keeps yielding. +async function* catcher() { + for (let i = 0; ; i++) { + try { + yield i; + } catch (e) { + log.push(`caught ${e}`); + await null; + yield `recovered ${i}`; + return; + } + } +} +{ + const it = catcher(); + console.log(JSON.stringify(await it.next())); + console.log(JSON.stringify(await it.throw("boom"))); + console.log(JSON.stringify(await it.next())); + console.log(log.splice(0).join(" | ")); +} + +// A body that throws after an await rejects its request and completes. +async function* failing() { + yield 1; + await later(0); + throw new Error("failed after await"); +} +{ + const it = failing(); + console.log(JSON.stringify(await it.next())); + try { + await it.next(); + } catch (e) { + console.log("rejected:", e.message); + } + console.log(JSON.stringify(await it.next())); +} + +// yield* into another async generator, and break out of for await. +async function* inner() { + try { + yield "a"; + yield await later("b"); + yield "c"; + } finally { + log.push("inner finally"); + } +} +async function* outer() { + yield "start"; + const r = yield* inner(); + log.push(`inner returned ${r}`); + yield "end"; +} +{ + const seen = []; + for await (const x of outer()) { + seen.push(x); + if (x === "b") break; + } + console.log(seen.join(",")); + console.log(log.splice(0).join(" | ")); +} + +// Stages chained through untyped values, interleaved with a promise chain. +async function* source(n) { + for (let i = 0; i < n; i++) yield i; +} +async function* map(it, f) { + for await (const x of it) yield await f(x); +} +async function* filter(it, p) { + for await (const x of it) if (p(x)) yield x; +} +{ + const order = []; + const chain = (async () => { + for await (const x of filter(map(source(6), async (x) => x * 3), (v) => v % 2 === 0)) order.push(`item ${x}`); + })(); + Promise.resolve() + .then(() => order.push("p1")) + .then(() => order.push("p2")) + .then(() => order.push("p3")); + await chain; + console.log(order.join(" ")); +} + +// A deep chain of pass-through stages. +async function* pass(it) { + for await (const x of it) yield x; +} +function stack(depth) { + return depth === 0 ? source(4) : pass(stack(depth - 1)); +} +{ + let sum = 0; + for await (const x of stack(500)) sum += x; + console.log("deep sum", sum); +} + +// Generator objects keep their tag and protocol. +{ + const g = counter(1); + console.log(Object.prototype.toString.call(g)); + console.log(JSON.stringify(await g.next()), JSON.stringify(await g.next())); + log.splice(0); +} diff --git a/tests/corpus/8383-stackless-async-generator-steps.ts b/tests/corpus/8383-stackless-async-generator-steps.ts new file mode 100644 index 000000000..f07e1d0f7 --- /dev/null +++ b/tests/corpus/8383-stackless-async-generator-steps.ts @@ -0,0 +1,99 @@ +// Typed async generators whose bodies suspend only at their own awaits and +// yields: number, string and object channels, values passed into next(), +// return() and throw() while the body awaits, finally blocks that await, +// yield* delegation, and an async function consuming them. +const log: string[] = []; +const later = (value: T): Promise => new Promise((resolve) => setTimeout(() => resolve(value), 1)); + +async function* numbers(n: number): AsyncGenerator { + try { + for (let i = 0; i < n; i++) { + yield i % 2 === 0 ? i : await later(i); + } + return "numbers done"; + } finally { + await later(0); + log.push("numbers finally"); + } +} + +async function* accumulate(): AsyncGenerator { + let total = 0; + for (;;) { + const v = yield total; + if (v < 0) return total; + total += await Promise.resolve(v); + } +} + +interface Point { + x: number; + y: number; +} +async function* points(n: number): AsyncGenerator { + for (let i = 0; i < n; i++) { + await null; + yield { x: i, y: i * i }; + } +} + +async function* labels(): AsyncGenerator { + yield "first"; + yield* (async function* () { + yield "inner one"; + yield await later("inner two"); + })(); + yield "last"; +} + +async function sum(it: AsyncGenerator): Promise { + let s = 0; + for await (const v of it) s += v; + return s; +} + +async function main(): Promise { + console.log("sum", await sum(numbers(6))); + console.log(log.splice(0).join(" | ")); + + const acc = accumulate(); + console.log(JSON.stringify(await acc.next(0))); + for (const v of [1, 2, 3]) console.log(JSON.stringify(await acc.next(v))); + console.log(JSON.stringify(await acc.next(-1))); + console.log(JSON.stringify(await acc.next(5))); + + for await (const p of points(3)) console.log(p.x, p.y); + + const words: string[] = []; + for await (const w of labels()) words.push(w); + console.log(words.join(", ")); + + const it = numbers(10); + console.log(JSON.stringify(await it.next())); + const pending = it.next(); + const ret = it.return("returned"); + console.log(JSON.stringify(await pending), JSON.stringify(await ret)); + console.log(log.splice(0).join(" | ")); + + const thrower = numbers(4); + await thrower.next(); + try { + await thrower.throw(new Error("thrown in")); + } catch (e) { + console.log("rejected:", (e as Error).message); + } + console.log(JSON.stringify(await thrower.next())); + console.log(log.splice(0).join(" | ")); + + const order: string[] = []; + const run = (async () => { + for await (const v of numbers(3)) order.push(`v${v}`); + })(); + void Promise.resolve() + .then(() => order.push("m1")) + .then(() => order.push("m2")); + await run; + console.log(order.join(" ")); + log.splice(0); +} +await main(); diff --git a/tests/corpus/8407-string-index-of-from-ascii.ts b/tests/corpus/8407-string-index-of-from-ascii.ts new file mode 100644 index 000000000..04582b7cd --- /dev/null +++ b/tests/corpus/8407-string-index-of-from-ascii.ts @@ -0,0 +1,38 @@ +// indexOf with a start index on ASCII receivers: integral, fractional, +// negative, -0, NaN, Infinity and out-of-range starts; empty needles, which +// are found at the clamped start; needles longer than the rest; needles with +// non-ASCII characters and lone surrogates, which an ASCII receiver never +// contains; and receivers built at run time (slices, concatenations, readline +// style splits) next to non-ASCII ones. The results match Node's. +const line = `10.1.2.3 - - [24/Oct/2026:10:00:00 +0000] "GET /api/items/7?page=3 HTTP/1.1" 200 5120 "agent"`; +const starts = [0, 1, 2.7, -1, -0, NaN, Infinity, -Infinity, line.length - 1, line.length, line.length + 5, 1e21]; +const needles = ["", '"', " ", "/", "api", "HTTP/1.1", line, line + "x", "é", "\ud83d", "\udca9", "💩"]; + +for (const needle of needles) { + const found = starts.map((start) => line.indexOf(needle, start)); + console.log(JSON.stringify(needle), found.join(" ")); +} + +const quote = line.indexOf('"'); +const space = line.indexOf(" ", quote); +const path = line.slice(space + 1, line.indexOf("/", line.indexOf("/", space) + 1)); +const close = line.indexOf('"', space); +console.log(quote, space, path, close, line.slice(close + 2, close + 5)); + +const built = ["alpha", "beta", "gamma"].join("/") + "/" + String(42); +for (let i = 0; i <= built.length + 1; i++) { + console.log(i, built.indexOf("/", i), built.indexOf("a", i), built.indexOf("", i), built.indexOf("42", i)); +} + +const mixed = "café/naïve/ascii/end"; +const ascii = mixed.slice(mixed.indexOf("ascii")); +console.log(ascii, ascii.indexOf("/", 1), ascii.indexOf("end", 3), mixed.indexOf("/", 5), mixed.indexOf("ascii", 6)); + +let count = 0; +let at = 0; +const csv = Array.from({ length: 200 }, (_, i) => `${i},${i * 3},row${i}`).join(","); +while ((at = csv.indexOf(",", at)) !== -1) { + count++; + at += 1.5; +} +console.log("commas", count); diff --git a/tests/corpus/arena-array-subclass.ts b/tests/corpus/arena-array-subclass.ts new file mode 100644 index 000000000..281d07823 --- /dev/null +++ b/tests/corpus/arena-array-subclass.ts @@ -0,0 +1,63 @@ +// Builders whose groups hold instances of a class that extends Array, +// beside a builder whose group takes an arena. An Array subclass's +// instance is an array, so a group that allocates one keeps its objects on +// the heap; the results and their teardown match Node either way. +class Row extends Array { + sum(): number { + let s = 0; + for (const v of this) s += v; + return s; + } +} + +class Branch { + row: Row; + left: Branch | null; + right: Branch | null; + constructor(row: Row, left: Branch | null, right: Branch | null) { + this.row = row; + this.left = left; + this.right = right; + } + total(): number { + return this.row.sum() + (this.left ? this.left.total() : 0) + (this.right ? this.right.total() : 0); + } +} + +class Pair { + left: Pair | null; + right: Pair | null; + constructor(left: Pair | null, right: Pair | null) { + this.left = left; + this.right = right; + } + count(): number { + return 1 + (this.left ? this.left.count() : 0) + (this.right ? this.right.count() : 0); + } +} + +function row(n: number): Row { + const r = new Row(); + for (let j = 0; j <= n; j++) r.push(j); + return r; +} + +function branches(depth: number): Branch { + return depth === 0 + ? new Branch(row(depth), null, null) + : new Branch(row(depth), branches(depth - 1), branches(depth - 1)); +} + +function pairs(depth: number): Pair { + return depth === 0 ? new Pair(null, null) : new Pair(pairs(depth - 1), pairs(depth - 1)); +} + +let checks = 0; +for (let round = 0; round < 20; round++) { + const b = branches(6); + const p = pairs(8); + const r = row(round); + checks += b.total() + p.count() + r.sum() + r.length; + if (round === 0) console.log(b.total(), b.row.length, b.row instanceof Row, p.count(), r.sum()); +} +console.log(checks); diff --git a/tests/corpus/arena-arrays.ts b/tests/corpus/arena-arrays.ts new file mode 100644 index 000000000..fb2134231 --- /dev/null +++ b/tests/corpus/arena-arrays.ts @@ -0,0 +1,49 @@ +// Groups with arrays: child lists that grow past their first storage, +// back references to parents and siblings, and lists of lists. +class Node { + parent: Node | undefined = undefined; + children: Node[] = []; + sibling: Node | undefined = undefined; + weight: number; + constructor(weight: number) { + this.weight = weight; + } +} + +function wide(depth: number, fan: number, parent: Node | undefined, weight: number): Node { + const node = new Node(weight); + node.parent = parent; + if (depth > 0) { + for (let i = 0; i < fan; i++) node.children.push(wide(depth - 1, fan, node, weight * 3 + i)); + for (let i = 0; i < fan; i++) node.children[i]!.sibling = node.children[(i + 1) % fan]!; + } + return node; +} + +function weigh(node: Node): number { + let total = node.weight; + for (const child of node.children) total += weigh(child); + return total % 1000003; +} + +let w = 0; +for (let round = 0; round < 30; round++) w = (w + weigh(wide(2 + (round % 3), 3 + (round % 20), undefined, round))) % 1000003; +console.log("weights", w); + +function grid(rows: number, cols: number): number[][] { + const out: number[][] = []; + for (let r = 0; r < rows; r++) { + const row: number[] = []; + for (let c = 0; c < cols; c++) row.push(r * cols + c); + out.push(row); + } + return out; +} +let cells = 0; +for (let k = 1; k < 40; k++) { + const g = grid(k, 70); + cells += g.length + g[k - 1]![69]! + g[0]!.length; +} +console.log("cells", cells); +const t = wide(2, 4, undefined, 1); +console.log(t.children.length, t.children[1]!.parent === t, t.children[3]!.sibling === t.children[0], t.children[2]!.children[3]!.weight); diff --git a/tests/corpus/arena-cap.ts b/tests/corpus/arena-cap.ts new file mode 100644 index 000000000..1ab1f43aa --- /dev/null +++ b/tests/corpus/arena-cap.ts @@ -0,0 +1,30 @@ +// A group too large for one arena: past its byte cap the rest of the +// group goes to the heap, and both parts are released together. +interface Cell { + index: number; + label: string; + next: Cell | null; +} + +function list(n: number): Cell { + let head: Cell = { index: 0, label: "c0", next: null }; + const first = head; + for (let i = 1; i < n; i++) { + const cell: Cell = { index: i, label: i % 1000 === 0 ? `c${i}` : "c", next: null }; + head.next = cell; + head = cell; + } + return first; +} + +function walk(c: Cell | null): number { + let total = 0; + let labels = 0; + for (let at = c; at !== null; at = at.next) { + total += at.index; + if (at.label.length > 1) labels++; + } + return total + labels; +} + +for (let round = 0; round < 3; round++) console.log("cells", walk(list(900000 + round))); diff --git a/tests/corpus/arena-escapes.ts b/tests/corpus/arena-escapes.ts new file mode 100644 index 000000000..f65a34e87 --- /dev/null +++ b/tests/corpus/arena-escapes.ts @@ -0,0 +1,72 @@ +// Values built in a group that leave it: handed to callbacks that keep +// them, stored by a method into a module list, kept from a getter, pushed +// into a caller's list, or captured by a returned closure. None of those +// groups takes an arena; the one whose values stay in the frame does. +class Node { + kids: Node[] = []; + label: string; + constructor(label: string) { + this.label = label; + } + get first(): Node { + return this.kids[0]!; + } + register(): void { + registry.push(this); + } +} +const registry: Node[] = []; +const saved: Node[] = []; +const held = new Map(); +function build(depth: number, label: string): Node { + const n = new Node(label); + for (let i = 0; depth > 0 && i < 3; i++) n.kids.push(build(depth - 1, `${label}.${i}`)); + return n; +} +function each(depth: number, cb: (n: Node) => void): number { + const t = build(depth, "e"); + cb(t); + return t.kids.length; +} +function visit(n: Node, cb: (x: Node) => void): void { + cb(n); + for (const k of n.kids) visit(k, cb); +} +function forward(depth: number, cb: (x: Node) => void): number { + const t = build(depth, "f"); + visit(t, cb); + return t.kids.length; +} +function buildInto(sink: Node[], depth: number): number { + const n = build(depth, "s"); + sink.push(n.kids[0]!); + return n.kids.length; +} +function closureOf(depth: number): () => string { + const n = build(depth, "c"); + return () => n.kids[1]!.label; +} +let total = 0; +for (let r = 0; r < 8; r++) { + total += each(2, (n) => saved.push(n.kids[1]!)); + total += forward(2, (x) => { + if (x.label.endsWith(".2")) saved.push(x); + }); + const t = build(2, `t${r}`); + t.kids.forEach((k) => { + if (k.label.endsWith(".0")) saved.push(k); + }); + const u = build(2, `u${r}`); + for (const k of u.kids) if (k.label.endsWith(".1")) saved.push(k); + const v = build(2, `v${r}`); + held.set(r, v.first); + const a = build(2, `a${r}`); + if (r % 3 === 0) a.kids[0]!.register(); + total += buildInto(saved, 2); + const w = build(2, `w${r}`); + total += w.kids.length + w.first.kids.length; +} +console.log(total, saved.length, saved.map((n) => n.label + "/" + n.kids.length).slice(0, 10).join(",")); +const f = closureOf(2); +console.log(f(), registry.map((n) => n.label + n.kids.length).join(",")); +console.log([...held.values()].map((n) => n.label + n.kids[0]!.label).join(",")); diff --git a/tests/corpus/arena-mixed.ts b/tests/corpus/arena-mixed.ts new file mode 100644 index 000000000..ff6c8e76c --- /dev/null +++ b/tests/corpus/arena-mixed.ts @@ -0,0 +1,80 @@ +// Groups built through virtual calls and sorted with callbacks, nested +// groups inside a group, and temporaries the group does not keep. +abstract class Shape { + abstract area(): number; + abstract scaled(k: number): Shape; +} +class Square extends Shape { + side: number; + constructor(side: number) { + super(); + this.side = side; + } + area(): number { + return this.side * this.side; + } + scaled(k: number): Shape { + return new Square(this.side * k); + } +} +class Circle extends Shape { + radius: number; + constructor(radius: number) { + super(); + this.radius = radius; + } + area(): number { + return Math.round(Math.PI * this.radius * this.radius * 100) / 100; + } + scaled(k: number): Shape { + return new Circle(this.radius * k); + } +} + +interface Box { + label: string; + shapes: Shape[]; +} + +function measure(s: Shape): { area: number } { + return { area: s.area() }; +} + +function boxes(n: number): Box[] { + const out: Box[] = []; + for (let i = 0; i < n; i++) { + const shapes: Shape[] = []; + for (let j = 0; j < 4; j++) { + const base: Shape = j % 2 === 0 ? new Square(j + i) : new Circle(j + 1); + shapes.push(base.scaled(2)); + } + // Temporaries the group does not keep. + let total = 0; + for (const s of shapes) total += measure(s).area; + shapes.sort((a, b) => a.area() - b.area()); + out.push({ label: `box${i}:${Math.round(total)}`, shapes }); + } + return out; +} + +function summarize(n: number): string { + const list = boxes(n); + let largest = ""; + let best = -1; + for (const b of list) { + const top = b.shapes[b.shapes.length - 1]!.area(); + if (top > best) { + best = top; + largest = b.label; + } + } + return `${largest}@${best}`; +} + +let out = ""; +for (let round = 1; round < 30; round++) { + const all = boxes(round); + out = `${all.length} ${all[0]!.label} ${all[round - 1]!.shapes[0]!.area()} ${summarize(round)}`; + if (round % 7 === 0) console.log(out); +} +console.log(out); diff --git a/tests/corpus/arena-outside.ts b/tests/corpus/arena-outside.ts new file mode 100644 index 000000000..cc965d5d3 --- /dev/null +++ b/tests/corpus/arena-outside.ts @@ -0,0 +1,49 @@ +// Groups whose members refer to objects outside them: a long-lived +// registry, strings built along the way, and a parameter's object. The +// arena's drop releases those references; the outside objects stay usable. +class Category { + members = 0; + name: string; + constructor(name: string) { + this.name = name; + } +} + +class Member { + next: Member | undefined = undefined; + category: Category; + label: string; + rank: number; + constructor(category: Category, label: string, rank: number) { + this.category = category; + this.label = label; + this.rank = rank; + category.members++; + } +} + +function ring(categories: Category[], size: number, seed: number): Member { + const first = new Member(categories[seed % categories.length]!, `m${seed}`, seed); + let last = first; + for (let i = 1; i < size; i++) { + const member = new Member(categories[(seed + i * 7) % categories.length]!, `m${seed}-${i}`, seed + i); + last.next = member; + last = member; + } + last.next = first; + return first; +} + +const categories: Category[] = []; +for (let i = 0; i < 5; i++) categories.push(new Category(`cat-${i}`)); + +let ranks = 0; +let labels = ""; +for (let round = 0; round < 200; round++) { + const r = ring(categories, 30, round); + ranks += r.next!.rank + r.next!.next!.rank; + if (round % 50 === 0) labels += `${r.label}:${r.category.name}:${r.next!.label} `; +} +console.log("ranks", ranks); +console.log("labels", labels.trim()); +for (const c of categories) console.log(c.name, c.members); diff --git a/tests/corpus/arena-region-throws.ts b/tests/corpus/arena-region-throws.ts new file mode 100644 index 000000000..073dca966 --- /dev/null +++ b/tests/corpus/arena-region-throws.ts @@ -0,0 +1,33 @@ +// Exceptions inside call regions: the region's arena is dropped as the +// exception leaves the call, and the caller's own objects are untouched. +interface Entry { + key: string; + value: number; + next: Entry | null; +} + +function tally(keys: string[], limit: number): number { + const entries: Entry[] = []; + for (const k of keys) { + entries.push({ key: k, value: k.length, next: entries.length > 0 ? entries[entries.length - 1]! : null }); + if (entries.length > limit) throw new RangeError(`too many entries: ${entries.length}`); + } + let sum = 0; + for (const e of entries) sum += e.value + (e.next === null ? 0 : e.next.value); + return sum; +} + +const kept: Entry[] = [{ key: "outside", value: 1, next: null }]; +const keys = "alpha beta gamma delta epsilon zeta eta theta iota kappa".split(" "); +let total = 0; +let errors = 0; +for (let round = 0; round < 40; round++) { + try { + total += tally(keys, 3 + (round % 10)); + } catch (e) { + errors++; + if (round < 6) console.log((e as Error).name, (e as Error).message); + } + kept.push({ key: `k${round}`, value: round, next: kept[kept.length - 1]! }); +} +console.log("total", total, "errors", errors, "kept", kept.length, kept[kept.length - 1]!.next!.key); diff --git a/tests/corpus/arena-regions.ts b/tests/corpus/arena-regions.ts new file mode 100644 index 000000000..cd1aa9679 --- /dev/null +++ b/tests/corpus/arena-regions.ts @@ -0,0 +1,44 @@ +// Call regions: calls of functions that build what they need and return +// a number, so everything they allocate is dead when they return. +interface Point { + x: number; + y: number; +} +interface Segment { + from: Point; + to: Point; +} + +function perimeter(n: number, scale: number): number { + const points: Point[] = []; + for (let i = 0; i < n; i++) points.push({ x: Math.cos(i) * scale, y: Math.sin(i) * scale }); + const segments: Segment[] = []; + for (let i = 0; i < n; i++) segments.push({ from: points[i]!, to: points[(i + 1) % n]! }); + let total = 0; + for (const s of segments) total += Math.hypot(s.to.x - s.from.x, s.to.y - s.from.y); + return Math.round(total * 1000) / 1000; +} + +let sum = 0; +for (let k = 3; k < 300; k++) sum += perimeter(k, k / 10); +console.log("perimeters", sum.toFixed(3)); + +function histogram(words: string[]): number { + const buckets: { word: string; count: number }[] = []; + for (const w of words) { + let found = false; + for (const b of buckets) + if (b.word === w) { + b.count++; + found = true; + } + if (!found) buckets.push({ word: w, count: 1 }); + } + let best = 0; + for (const b of buckets) best = Math.max(best, b.count * 100 + b.word.length); + return best; +} +const words = "the quick brown fox jumps over the lazy dog the end".split(" "); +let h = 0; +for (let i = 0; i < 200; i++) h += histogram(words); +console.log("histogram", h); diff --git a/tests/corpus/arena-thrown-member.ts b/tests/corpus/arena-thrown-member.ts new file mode 100644 index 000000000..68382075c --- /dev/null +++ b/tests/corpus/arena-thrown-member.ts @@ -0,0 +1,36 @@ +// A thrown value that holds an object built in a group: the group cannot +// take an arena, because the value outlives it in the catch handler. +class Node { + label: string; + weight: number; + constructor(label: string, weight: number) { + this.label = label; + this.weight = weight; + } +} +class BuildError { + node: Node; + constructor(node: Node) { + this.node = node; + } +} +function build(n: number, failAt: number): Node[] { + const out: Node[] = []; + for (let i = 0; i < n; i++) { + const node = new Node(`n${i}`, i); + out.push(node); + if (i === failAt) throw new BuildError(node); + } + return out; +} +let total = 0; +const saved: string[] = []; +for (let r = 0; r < 20; r++) { + try { + total += build(50, r % 4 === 0 ? 30 : -1).length; + } catch (e) { + const b = e as BuildError; + saved.push(b.node.label + ":" + b.node.weight); + } +} +console.log(total, saved.join(",")); diff --git a/tests/corpus/arena-throws.ts b/tests/corpus/arena-throws.ts new file mode 100644 index 000000000..b3dbd3c16 --- /dev/null +++ b/tests/corpus/arena-throws.ts @@ -0,0 +1,40 @@ +// A builder that throws part way: the arena of the group it was building +// is dropped as the exception leaves, and building goes on afterwards. +interface Item { + id: number; + tags: string[]; + next: Item | null; +} + +function chain(n: number, failAt: number): Item { + let head: Item = { id: 0, tags: ["head"], next: null }; + const first = head; + for (let i = 1; i < n; i++) { + if (i === failAt) throw new Error(`failed at ${i} of ${n}`); + const item: Item = { id: i, tags: [`t${i}`, `u${i % 3}`], next: null }; + head.next = item; + head = item; + } + return first; +} + +function length(item: Item | null): number { + let n = 0; + for (let at = item; at !== null; at = at.next) n++; + return n; +} + +let built = 0; +let failed = 0; +for (let round = 0; round < 60; round++) { + try { + const c = chain(20 + (round % 7), round % 5 === 0 ? 10 + (round % 3) : -1); + built += length(c) + c.next!.tags.length; + } catch (e) { + failed++; + if (round < 20) console.log("caught", (e as Error).message); + } +} +console.log("built", built, "failed", failed); +const last = chain(5, -1); +console.log(last.id, last.next!.id, last.next!.tags.join("/"), length(last)); diff --git a/tests/corpus/arena-trees.ts b/tests/corpus/arena-trees.ts new file mode 100644 index 000000000..eb736dbab --- /dev/null +++ b/tests/corpus/arena-trees.ts @@ -0,0 +1,70 @@ +// Recursive builders whose results their callers only read: each tree is a +// group that lives in an arena until the statement (or the loop body that +// holds it) ends. +class TreeNode { + left: TreeNode | null; + right: TreeNode | null; + value: number; + constructor(left: TreeNode | null, right: TreeNode | null, value: number) { + this.left = left; + this.right = right; + this.value = value; + } + sum(): number { + return this.value + (this.left === null ? 0 : this.left.sum()) + (this.right === null ? 0 : this.right.sum()); + } + depth(): number { + return 1 + Math.max(this.left === null ? 0 : this.left.depth(), this.right === null ? 0 : this.right.depth()); + } +} + +function build(depth: number, value: number): TreeNode { + return depth === 0 + ? new TreeNode(null, null, value) + : new TreeNode(build(depth - 1, value * 2), build(depth - 1, value * 2 + 1), value); +} + +let total = 0; +for (let d = 0; d < 12; d++) total += build(d, 1).sum(); +console.log("sums", total); + +for (let d = 2; d <= 4; d++) { + const t = build(d, 1); + console.log("tree", d, t.sum(), t.depth(), t.left!.value, t.right!.right!.value); +} + +function deepest(n: number): number { + let best = 0; + for (let i = 0; i < n; i++) { + const t = build(i % 6, i); + best = Math.max(best, t.depth() * 1000 + (t.sum() % 1000)); + } + return best; +} +console.log("deepest", deepest(40)); + +// Record-literal trees: a root call whose result its caller only reads +// builds the whole tree, its root node included, in the group's arena. +interface Node2 { + left: Node2 | null; + right: Node2 | null; + value: number; +} + +function buildRecord(depth: number, value: number): Node2 { + return depth === 0 + ? { left: null, right: null, value } + : { left: buildRecord(depth - 1, value * 2), right: buildRecord(depth - 1, value * 2 + 1), value }; +} + +function sumRecord(node: Node2): number { + return node.value + (node.left === null ? 0 : sumRecord(node.left)) + (node.right === null ? 0 : sumRecord(node.right)); +} + +let recordTotal = 0; +for (let d = 0; d < 12; d++) recordTotal += sumRecord(buildRecord(d, 1)); +console.log("record sums", recordTotal); +for (let d = 1; d <= 3; d++) { + const t = buildRecord(d, 1); + console.log("record tree", d, sumRecord(t), t.value, t.left!.value, t.right!.value); +} diff --git a/tests/corpus/arena-workers.ts b/tests/corpus/arena-workers.ts new file mode 100644 index 000000000..b08a49748 --- /dev/null +++ b/tests/corpus/arena-workers.ts @@ -0,0 +1,75 @@ +// Arena groups in worker threads: each worker builds and reads trees of +// classes and record literals, groups that live in the worker's own arena +// until the statement or loop body that holds them ends. The main thread +// builds its own groups meanwhile and keeps one tree for the whole run. +import { isMainThread, parentPort, Worker, workerData } from "node:worker_threads"; + +class Pair { + left: Pair | null; + right: Pair | null; + label: string; + constructor(left: Pair | null, right: Pair | null, label: string) { + this.left = left; + this.right = right; + this.label = label; + } +} + +interface Leaf { + left: Leaf | null; + right: Leaf | null; + weight: number; +} + +function pairs(depth: number, label: string): Pair { + return depth === 0 ? new Pair(null, null, label) : new Pair(pairs(depth - 1, label + "l"), pairs(depth - 1, label + "r"), label); +} + +function leaves(depth: number, weight: number): Leaf { + return depth === 0 + ? { left: null, right: null, weight } + : { left: leaves(depth - 1, weight + 1), right: leaves(depth - 1, weight * 2), weight }; +} + +function countPairs(p: Pair): number { + return 1 + (p.left === null ? 0 : countPairs(p.left)) + (p.right === null ? 0 : countPairs(p.right)); +} + +function rightmost(p: Pair): string { + return p.right === null ? p.label : rightmost(p.right); +} + +function weigh(l: Leaf): number { + return l.weight + (l.left === null ? 0 : weigh(l.left)) + (l.right === null ? 0 : weigh(l.right)); +} + +function task(depth: number, rounds: number): string { + let nodes = 0; + let total = 0; + for (let i = 0; i < rounds; i++) { + nodes += countPairs(pairs(depth, "p")); + total += weigh(leaves(depth, i)); + } + const kept = pairs(depth, "k"); + return `depth ${depth}: ${nodes} pairs, weight ${total}, rightmost ${rightmost(kept)}, kept ${countPairs(kept)}`; +} + +if (isMainThread) { + const longLived = leaves(10, 1); + const depths = [3, 5, 7, 9]; + const lines: string[] = new Array(depths.length).fill(""); + let pending = depths.length; + depths.forEach((depth, index) => { + const worker = new Worker(new URL(import.meta.url), { workerData: depth }); + worker.on("message", (line: string) => { + lines[index] = line; + if (--pending === 0) { + for (const l of lines) console.log(l); + console.log("main", task(6, 20)); + console.log("long lived", weigh(longLived)); + } + }); + }); +} else { + parentPort!.postMessage(task(workerData as number, 50)); +} diff --git a/tests/corpus/typed-array-nested-loop-versions.ts b/tests/corpus/typed-array-nested-loop-versions.ts new file mode 100644 index 000000000..19e408d30 --- /dev/null +++ b/tests/corpus/typed-array-nested-loop-versions.ts @@ -0,0 +1,68 @@ +// Loops whose own typed-array accesses run once per pass around nested +// loops that read typed arrays: row stores past the end, reads at NaN, +// negative and fractional rows, rows that read a typed array the nested +// loop writes, and a nested loop that reads the row's own result. +function rowSums(u: Float64Array, out: Float64Array, start: number, end: number): void { + for (let i = start; i < end; i++) { + let t = out[i] ?? -1; + for (let j = 0; j < u.length; j++) t += u[j]! * (i + 1); + out[i] = t; + } +} + +function scaled(u: Float64Array, scale: Float64Array, out: Float64Array, rows: number): void { + for (let i = 0; i < rows; i++) { + const s = scale[i * 2] ?? 0.5; + for (let j = 0; j < u.length; j++) out[j] = out[j]! + u[j]! * s; + out[i] = out[i]! - 1; + } +} + +function chained(v: Int32Array, rows: number): number { + let total = 0; + for (let i = 0; i < rows; i++) { + let k = 0; + while (k < v.length) { + v[k] = v[k]! + (v[i] ?? 7); + k++; + } + total += v[i + 1] ?? -3; + } + return total; +} + +const show = (label: string, v: Float64Array | Int32Array): void => { + const parts: string[] = []; + for (let k = 0; k < v.length; k++) parts.push(Object.is(v[k], -0) ? "-0" : String(v[k])); + console.log(label, parts.join(" ")); +}; + +const u = new Float64Array([0.5, -1.25, 3, 7.75]); +for (const [start, end] of [ + [0, 4], + [0, 7], + [2, 9], + [-2, 3], + [0.5, 3.5], + [NaN, 3], + [3, 1], +]) { + const out = new Float64Array(5).fill(2); + rowSums(u, out, start!, end!); + show(`rows ${start} ${end}`, out); +} + +for (const rows of [0, 1, 3, 6]) { + const out = new Float64Array(4).fill(1); + scaled(u, new Float64Array([2, -1, 0.25, 4, 8]), out, rows); + show(`scaled ${rows}`, out); + const same = new Float64Array([1, 2, 3, 4]); + scaled(same, same, same, rows); + show(`aliased ${rows}`, same); +} + +for (const rows of [0, 2, 4, 9]) { + const v = new Int32Array([1, -2, 3, 2147483647]); + const total = chained(v, rows); + show(`chained ${rows} ${total}`, v); +} diff --git a/tests/corpus/untyped-nullish-scalar-defaults.mjs b/tests/corpus/untyped-nullish-scalar-defaults.mjs new file mode 100644 index 000000000..507945456 --- /dev/null +++ b/tests/corpus/untyped-nullish-scalar-defaults.mjs @@ -0,0 +1,50 @@ +// `??` in untyped JavaScript where the left is an optional number, boolean or +// string and the default has the same type: Map reads, find(), a function that +// may return undefined, and defaults of another type, null or an object. +const words = ["alpha", "beta", "gamma", "beta", "delta", "beta", "alpha"]; +const counts = new Map(); +for (const word of words) counts.set(word, (counts.get(word) ?? 0) + 1); +for (const [word, n] of counts) console.log(word, n); + +const odd = new Map(); +odd.set("nan", NaN); +odd.set("zero", -0); +odd.set("one", 1); +for (const key of ["nan", "zero", "one", "missing"]) { + const value = odd.get(key) ?? 7; + console.log(key, value, Object.is(value, -0), Number.isNaN(value), typeof value); +} + +const flags = new Map(); +flags.set("on", true); +flags.set("off", false); +for (const key of ["on", "off", "missing"]) console.log(key, flags.get(key) ?? true); + +const names = new Map(); +names.set("a", ""); +names.set("b", "bee"); +for (const key of ["a", "b", "c"]) console.log(JSON.stringify(names.get(key) ?? "none")); + +const values = [3, 8, 13, 21]; +console.log(values.find((v) => v > 10) ?? -1, values.find((v) => v > 100) ?? -1); + +function lookup(table, key) { + return key in table ? table[key] : undefined; +} +const table = { x: 1.5, y: 0 }; +console.log(lookup(table, "x") ?? 0, lookup(table, "y") ?? 9, lookup(table, "z") ?? 9); + +// Defaults of another type keep the left's value or take the default as is. +console.log(counts.get("beta") ?? "none", counts.get("omega") ?? "none"); +console.log(counts.get("omega") ?? null, counts.get("beta") ?? null); +const fallback = { n: 0 }; +console.log(counts.get("omega") ?? fallback, counts.get("alpha") ?? fallback); + +// The result is an ordinary value in later arithmetic and comparisons. +let total = 0; +for (const word of ["alpha", "beta", "omega"]) total += (counts.get(word) ?? 0) * 10; +console.log(total, (counts.get("omega") ?? 0) === 0, (odd.get("nan") ?? 0) === (odd.get("nan") ?? 0)); + +// A default of the same type that can itself be undefined keeps its value. +const spare = [4, 5]; +console.log(counts.get("omega") ?? spare[9], counts.get("beta") ?? spare[9], counts.get("omega") ?? spare[1]); diff --git a/tests/harness/corpus-costs/7393-non-null-element-operands.ts.json b/tests/harness/corpus-costs/7393-non-null-element-operands.ts.json new file mode 100644 index 000000000..e053c13c8 --- /dev/null +++ b/tests/harness/corpus-costs/7393-non-null-element-operands.ts.json @@ -0,0 +1 @@ +{ "plain": 3000, "san": 3800 } diff --git a/tests/harness/corpus-costs/7395-non-null-element-dereference.ts.json b/tests/harness/corpus-costs/7395-non-null-element-dereference.ts.json new file mode 100644 index 000000000..e0d5a6a6d --- /dev/null +++ b/tests/harness/corpus-costs/7395-non-null-element-dereference.ts.json @@ -0,0 +1 @@ +{ "plain": 3585, "san": 5768 } diff --git a/tests/harness/corpus-costs/8370-stack-overflow-cycles/main.ts.json b/tests/harness/corpus-costs/8370-stack-overflow-cycles/main.ts.json new file mode 100644 index 000000000..29769db15 --- /dev/null +++ b/tests/harness/corpus-costs/8370-stack-overflow-cycles/main.ts.json @@ -0,0 +1 @@ +{ "plain": 2780, "san": 53539 } diff --git a/tests/harness/corpus-costs/8407-string-index-of-from-ascii.ts.json b/tests/harness/corpus-costs/8407-string-index-of-from-ascii.ts.json new file mode 100644 index 000000000..054b3c2b4 --- /dev/null +++ b/tests/harness/corpus-costs/8407-string-index-of-from-ascii.ts.json @@ -0,0 +1 @@ +{ "plain": 3900, "san": 5000 } diff --git a/tests/harness/corpus-costs/arena-array-subclass.ts.json b/tests/harness/corpus-costs/arena-array-subclass.ts.json new file mode 100644 index 000000000..a4a33f447 --- /dev/null +++ b/tests/harness/corpus-costs/arena-array-subclass.ts.json @@ -0,0 +1 @@ +{ "plain": 3100 } diff --git a/tests/harness/corpus-costs/arena-workers.ts.json b/tests/harness/corpus-costs/arena-workers.ts.json new file mode 100644 index 000000000..2180ff94b --- /dev/null +++ b/tests/harness/corpus-costs/arena-workers.ts.json @@ -0,0 +1 @@ +{ "plain": 3600, "san": 18500 } diff --git a/tests/harness/corpus-costs/typed-array-nested-loop-versions.ts.json b/tests/harness/corpus-costs/typed-array-nested-loop-versions.ts.json new file mode 100644 index 000000000..1bee89d1a --- /dev/null +++ b/tests/harness/corpus-costs/typed-array-nested-loop-versions.ts.json @@ -0,0 +1 @@ +{ "plain": 6500, "san": 8800 } diff --git a/tests/harness/corpus-costs/untyped-nullish-scalar-defaults.mjs.json b/tests/harness/corpus-costs/untyped-nullish-scalar-defaults.mjs.json new file mode 100644 index 000000000..e822c3bda --- /dev/null +++ b/tests/harness/corpus-costs/untyped-nullish-scalar-defaults.mjs.json @@ -0,0 +1 @@ +{ "plain": 3711, "san": 5463 } diff --git a/tests/harness/sandbox-config.test.ts b/tests/harness/sandbox-config.test.ts index cedaef600..9a330f746 100644 --- a/tests/harness/sandbox-config.test.ts +++ b/tests/harness/sandbox-config.test.ts @@ -5,6 +5,7 @@ import { sandboxBootstrapCommand, sandboxEffectWorkerAllocation, sandboxImageConfig, + sandboxNativeReference, sandboxRunnerConfig, sandboxTestSourceConfig, sandboxTestWorkerAllocation, @@ -154,6 +155,24 @@ test("ambiguous or malformed snapshot sources fail before creating workers", () ); }); +test("a native reference checkout needs a prepared source and an absolute path", () => { + const snapshot = { SCRIPTC_SANDBOX_SNAPSHOT: "snap_ABC123" }; + expect(sandboxNativeReference(snapshot)).toBeUndefined(); + expect( + sandboxNativeReference({ + ...snapshot, + SCRIPTC_SANDBOX_NATIVE_REFERENCE: " /vercel/sandbox/prev/ ", + }), + ).toBe("/vercel/sandbox/prev"); + expect(() => + sandboxNativeReference({ SCRIPTC_SANDBOX_NATIVE_REFERENCE: "/vercel/sandbox/prev" }), + ).toThrow("needs SCRIPTC_SANDBOX_SNAPSHOT"); + for (const path of ["prev", "/a/../b", "/a b", "/a;rm"]) + expect(() => + sandboxNativeReference({ ...snapshot, SCRIPTC_SANDBOX_NATIVE_REFERENCE: path }), + ).toThrow("absolute path"); +}); + test("OIDC authentication wins and supplies its own Sandbox scope", () => { const config = sandboxVercelConfig({ VERCEL_OIDC_TOKEN: "header.payload.signature", diff --git a/tests/harness/self-hosting-emission.test.ts b/tests/harness/self-hosting-emission.test.ts index 6d27b7851..e6364b855 100644 --- a/tests/harness/self-hosting-emission.test.ts +++ b/tests/harness/self-hosting-emission.test.ts @@ -59,9 +59,9 @@ test("the production layout and metadata stage lowers statically", async () => { }); for (const backend of ["llvm"] as const) { - // A sanitized build of the fixture and its emission cases take about half - // the default 300 s budget on an idle 16-core host. - test(`native LLVM layout and metadata emission (${backend})`, async () => { + // The sanitized dev build of the stage takes 170 to 300 s of CI time beside + // its shard's other tests. + test(`native LLVM layout and metadata emission (${backend})`, { timeout: 600_000 }, async () => { const dir = mkdtempSync( join(process.platform === "win32" ? tmpdir() : "/tmp", "scriptc-native-emission-"), ); @@ -270,5 +270,5 @@ for (const backend of ["llvm"] as const) { } finally { rmSync(dir, { recursive: true, force: true }); } - }, 600_000); + }); } diff --git a/tests/harness/zero-cost-gate.test.ts b/tests/harness/zero-cost-gate.test.ts index 565f8ba71..e99eca8d1 100644 --- a/tests/harness/zero-cost-gate.test.ts +++ b/tests/harness/zero-cost-gate.test.ts @@ -1,14 +1,20 @@ import { spawnSync } from "node:child_process"; import { expect, test } from "vitest"; import { + cachedComparison, cachegrindCounts, compareArtifact, compareCounts, compareExecutables, definedFunctionNames, dynamicTierSymbols, + mergeReports, nmOutput, + programInputsHash, + programLines, programVerdict, + recordedBaseline, + timingVerdict, } from "../../scripts/zero-cost-compare.mjs"; const ir = (functions: object[], globals: object[]) => @@ -191,3 +197,105 @@ test("the zero-cost gate fails differing programs unless they match the signatur expect(verdict({}, false, failed, failed, true)).toEqual({ verdict: "both-fail", fails: true }); expect(verdict({}, true, ok, failed)).toEqual({ verdict: "build-error", fails: true }); }); + +test("a recorded baseline settles identical programs and shared build errors", () => { + const hashes = { ir: "a", llvm: "b", exe: "c" }; + const entry = { ok: true, ms: 12, hashes, exeBytes: 100 }; + expect(cachedComparison(entry, { ok: true }, { ...hashes })).toEqual({ + baseline: { ok: true, ms: 12 }, + artifacts: { + ir: { identical: true }, + llvm: { identical: true }, + exe: { identical: true, bytes: { baseline: 100, candidate: 100 } }, + }, + }); + // Any other hash, a missing artifact or no record builds the baseline. + expect(cachedComparison(entry, { ok: true }, { ...hashes, exe: "d" })).toBeNull(); + expect( + cachedComparison({ ...entry, hashes: { ...hashes, ir: null } }, { ok: true }, hashes), + ).toBeNull(); + expect(cachedComparison(undefined, { ok: true }, hashes)).toBeNull(); + expect(cachedComparison(entry, { ok: false, error: "SC1090" }, {})).toBeNull(); + const failed = { ok: false, ms: 3, error: "SC1090" }; + expect(cachedComparison(failed, { ok: false, error: "SC1090" }, {})).toEqual({ + baseline: { ok: false, error: "SC1090", ms: 3, cached: true }, + artifacts: {}, + }); + expect(cachedComparison(failed, { ok: false, error: "SC1091" }, {})).toBeNull(); + expect(cachedComparison(failed, { ok: true }, hashes)).toBeNull(); +}); + +test("a recorded baseline applies only to the program inputs it was recorded for", () => { + const files: Record = { + "/p/tests/corpus/a/main.ts": 'import { f } from "./lib.ts";', + "/p/tests/corpus/a/lib.ts": "export const f = 1;", + }; + const read = (file: string) => Buffer.from(files[file]!); + const paths = Object.keys(files); + const inputs = programInputsHash("/p", paths, "release", read); + expect(programInputsHash("/p", [...paths].reverse(), "release", read)).toBe(inputs); + expect(programInputsHash("/p", paths, "speed", read)).not.toBe(inputs); + expect(programInputsHash("/q", paths, "release", read)).not.toBe(inputs); + // An edit to an import changes the hash as an edit to the entry does. + files["/p/tests/corpus/a/lib.ts"] = "export const f = 2;"; + const edited = programInputsHash("/p", paths, "release", read); + expect(edited).not.toBe(inputs); + const entry = { inputs, ok: false, ms: 3, error: "SC1090" }; + const cache = { programs: { "corpus/a/main.ts": entry } }; + expect(recordedBaseline(cache, "corpus/a/main.ts", inputs)).toBe(entry); + // A record of the old sources must not settle the new sources as both-fail or identical. + expect(recordedBaseline(cache, "corpus/a/main.ts", edited)).toBeUndefined(); + expect(recordedBaseline(cache, "corpus/b/main.ts", inputs)).toBeUndefined(); + expect(recordedBaseline(null, "corpus/a/main.ts", inputs)).toBeUndefined(); +}); + +test("merged gate shards judge compile time and comparisons over every shard", () => { + const trees = { baseline: { revision: "a" }, candidate: { revision: "b" } }; + const row = (id: string, ratio: number) => ({ + id, + baselineMs: [100, 100, 100], + candidateMs: [100 * ratio, 100 * ratio, 100 * ratio], + ratios: [ratio, ratio, ratio], + }); + const shard = ( + programs: object[], + rows: object[], + failures: string[] = [], + globals: string[] = [], + ) => ({ + schema: 1, + trees, + programs, + failures: [...failures, ...globals], + globalFailures: globals, + timing: { runs: 3, maxGrowth: 0.02, rows, groups: {} }, + elapsedMs: 10, + }); + const identical = (id: string) => ({ id, verdict: "identical", artifacts: {} }); + const fast = shard([identical("bench/a")], [row("bench/a", 1.0)]); + // Alone, this shard's bench is slower; with the other shard it is not. + const slow = shard( + [ + { + id: "corpus/x.ts", + verdict: "differs", + artifacts: { exe: { identical: false, first: "resized f" } }, + }, + ], + [row("bench/b", 1.03)], + ["corpus/x.ts: differs"], + ["bench compile time grew +3.00% (CI low +3.00%)"], + ); + const { report, timingLines } = mergeReports([fast, slow]); + expect(report.counts).toEqual({ identical: 1, differs: 1 }); + expect(report.failures).toEqual(["corpus/x.ts: differs"]); + expect(report.verdict).toBe("fail"); + expect(timingLines).toEqual([timingVerdict("bench", report.timing.rows, 0.02).line]); + expect(timingVerdict("bench", report.timing.rows, 0.02).fails).toBe(false); + expect(programLines(report.programs[1])).toEqual(["DIFFERS corpus/x.ts exe: resized f"]); + expect(mergeReports([fast]).report.verdict).toBe("pass"); + expect(mergeReports([shard([], [])]).report.failures).toEqual([ + "no program built with both checkouts, so nothing was compared", + ]); + expect(() => mergeReports([fast, fast])).toThrow(/more than one gate report/); +});