Merge the restacked base into ni/i2-handles

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	packages/compiler/src/executable/executable-cache.ts
#	packages/compiler/src/index.ts
This commit is contained in:
Malte Ubl
2026-10-27 05:41:01 -07:00
391 changed files with 24294 additions and 3372 deletions
+35 -13
View File
@@ -676,11 +676,9 @@ jobs:
if: ${{ needs.validation_scope.outputs.full == 'true' && needs.lanes.outputs.macos == 'true' }}
name: test (macOS arm64 LLVM artifacts, no clang, ${{ matrix.shard }}/5)
runs-on: macos-15
# Shard 1 additionally runs package/install and link-parity contracts
# before its LLVM differential slice. The slice itself can take ~10
# minutes on a cold hosted runner, so 20 minutes cancels an otherwise
# healthy job before the differential completes. Slow runners need more
# headroom than that, so the limit is 50 minutes.
# Each LLVM differential slice takes 30 to 35 minutes on a hosted runner.
# The package/install and link-parity contracts run in their own job
# (llvm_artifacts_macos_arm64_contracts), so no slice carries them.
timeout-minutes: 50
strategy:
fail-fast: false
@@ -713,11 +711,39 @@ jobs:
- name: Node island harness
if: matrix.shard == 1
run: pnpm test tests/harness/node-island packages/libnode-common
- name: LLVM release/dev differential (${{ matrix.shard }}/5)
env:
SCRIPTC_TEST_WORKERS: "4"
run: pnpm test tests/harness/llvm-differential.test.ts
llvm_artifacts_macos_arm64_contracts:
needs: [validation_scope, lanes, native_macos]
if: ${{ needs.validation_scope.outputs.full == 'true' && needs.lanes.outputs.macos == 'true' }}
name: test (macOS arm64 LLVM artifacts, no clang, contracts)
runs-on: macos-15
# The no-clang object, helper parity, packaged helper and packed npm
# installation contracts: about 12 minutes, which used to run in front
# of shard 1's differential slice and took that job to 45-50 minutes.
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 11.1.3
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
cache: pnpm
- run: pnpm install --frozen-lockfile
- uses: actions/download-artifact@v4
with:
name: native-darwin-arm64
path: ${{ runner.temp }}
- run: tar -xzf "$RUNNER_TEMP/native-darwin-arm64.tar.gz"
- run: pnpm build
- name: No-clang assembly/object contract
if: matrix.shard == 1
run: pnpm test packages/cli/test/native-output.test.ts
- name: Helper diagnostics and object/link parity
if: matrix.shard == 1
run: >-
pnpm test
packages/compiler/test/native-codegen-integration.test.ts
@@ -725,17 +751,11 @@ jobs:
packages/cli/test/native-link-info.test.ts
packages/cli/test/runtime-pack.test.ts
tests/harness/native-object-example.test.ts
- name: LLVM release/dev differential (${{ matrix.shard }}/5)
env:
SCRIPTC_TEST_WORKERS: "4"
run: pnpm test tests/harness/llvm-differential.test.ts
- name: Packaged helper contract
if: matrix.shard == 1
run: |
TARBALL=$(pnpm --dir packages/llvm-darwin-arm64 pack --pack-destination "$RUNNER_TEMP" --silent)
node scripts/verify-llvm-package.mjs "$RUNNER_TEMP/$(basename "$TARBALL")"
- name: Packed npm installation smoke
if: matrix.shard == 1
env:
# Release optimization is covered by the compiler bootstrap jobs.
# This lane verifies packaging and installation with a faster seed.
@@ -1018,6 +1038,7 @@ jobs:
checker_identity,
llvm_artifacts_linux_x64,
llvm_artifacts_macos_arm64,
llvm_artifacts_macos_arm64_contracts,
llvm_artifacts_macos_x64,
windows_cli,
llvm_artifacts_windows_x64,
@@ -1051,6 +1072,7 @@ jobs:
lane checker_identity "${{ needs.checker_identity.result }}" "${{ needs.lanes.outputs.linux }}"
lane llvm_artifacts_linux_x64 "${{ needs.llvm_artifacts_linux_x64.result }}" "${{ needs.lanes.outputs.linux }}"
lane llvm_artifacts_macos_arm64 "${{ needs.llvm_artifacts_macos_arm64.result }}" "${{ needs.lanes.outputs.macos }}"
lane llvm_artifacts_macos_arm64_contracts "${{ needs.llvm_artifacts_macos_arm64_contracts.result }}" "${{ needs.lanes.outputs.macos }}"
lane llvm_artifacts_macos_x64 "${{ needs.llvm_artifacts_macos_x64.result }}" "${{ needs.lanes.outputs.macos }}"
lane windows_cli "${{ needs.windows_cli.result }}" "${{ needs.lanes.outputs.linux }}"
lane llvm_artifacts_windows_x64 "${{ needs.llvm_artifacts_windows_x64.result }}" "${{ needs.lanes.outputs.linux }}"
+13 -7
View File
@@ -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
+4
View File
@@ -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 <shard.json>...` merges them into one verdict, recomputing the compile-time intervals over every shard. `--record-baseline-cache=<file>` builds only the baseline and records each program's artifact hashes; `--baseline-cache=<file>` 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
+2
View File
@@ -26,6 +26,7 @@ RUN apt-get update \
libclang-rt-22-dev \
libpolly-22-dev \
libzstd-dev \
lld-22 \
llvm-22-dev \
ninja-build \
xz-utils \
@@ -33,6 +34,7 @@ RUN apt-get update \
&& ln -sf clang-22 /usr/bin/clang \
&& ln -sf clang++-22 /usr/bin/clang++ \
&& test "$(llvm-config-22 --version)" = 22.1.8 \
&& test -x "$(dirname "$(readlink -f /usr/bin/clang)")/ld.lld" \
&& rm -rf /var/lib/apt/lists/*
RUN curl --fail --silent --show-error --location \
+2 -2
View File
@@ -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-<version>-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 <tsc-ts checkout>` 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-<version>-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 <tsc-ts checkout>` 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
+1
View File
@@ -28,6 +28,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea
| `numeric-kernels` | cpu | sieve, hashing, `number[][]` matmul, `Float64Array` |
| `optional-fields` | cpu | records and class instances with `number \| undefined` and other scalar-union fields |
| `published-graphs` | cpu | syntax trees with parent links and a symbol table, published with `@scriptc/threads` and then read |
| `record-untyped-writes` | cpu | typed records written through untyped views: a counter helper taking `unknown` bumps fields of a stats record, and `Object.assign` merges patches into a settings record, with typed reads in between |
| `record-boundaries` | cpu | records built from unchecked array reads passed to and returned from annotated functions |
| `records-sort` | cpu | comparator sorts, group-by, `reduce`/`filter` |
| `regex-logs` | cpu | regex `exec` with groups, `test`, global `replace` |
@@ -0,0 +1,6 @@
{
"entry": "workloads/record-untyped-writes.ts",
"args": [
"4"
]
}
@@ -0,0 +1,49 @@
// Typed records updated through untyped views: a stats record written by a
// generic counter helper that takes `unknown`, settings merged with
// Object.assign, and typed reads in between. Each write stores into the
// native record; typed code reads the same object.
type Stats = { hits: number; misses: number; bytes: number; name: string; ok: boolean };
type Settings = { host: string; port: number; debug: boolean; retries: number };
const scale = Number(process.argv[2] ?? "1");
function bump(target: unknown, key: string, by: number): void {
const record = target as Record<string, number>;
record[key] = record[key]! + by;
}
const stats: Stats[] = [];
for (let i = 0; i < 16; i++) stats.push({ hits: 0, misses: 0, bytes: 0, name: "s" + i, ok: true });
let seed = 12345;
let checksum = 0;
const rounds = Math.floor(1_000_000 * scale);
for (let i = 0; i < rounds; i++) {
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
const s = stats[seed & 15]!;
if ((seed >>> 8) % 5 === 0) bump(s, "misses", 1);
else bump(s, "hits", 1);
bump(s, "bytes", (seed >>> 12) & 1023);
checksum += s.hits - s.misses;
}
const settings: Settings = { host: "localhost", port: 80, debug: false, retries: 0 };
const view: object = settings;
const patches: object[] = [
{ port: 8080 },
{ debug: true, retries: 3 },
{ host: "example.test" },
{ port: 443, debug: false },
];
for (let i = 0; i < Math.floor(200_000 * scale); i++) {
Object.assign(view, patches[i & 3]!);
checksum += settings.port + settings.retries;
}
let hits = 0;
let bytes = 0;
for (const s of stats) {
hits += s.hits;
bytes += s.bytes;
}
console.log("hits", hits, "bytes", bytes, "settings", JSON.stringify(settings), "checksum", checksum);
+3
View File
@@ -160,6 +160,9 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
<dt><code>--detached-exit</code></dt>
<dd>On Linux, when the program calls <code>process.exit()</code>, hand the release of its memory to a helper process so the parent sees the exit sooner. The kernel otherwise frees the whole address space before the exit is reported, which takes tens of milliseconds per gigabyte. Opt in only where the trade-offs are acceptable: for up to about 100 ms after the exit, the helper keeps the executable mapped (overwriting the binary in place fails with "text file busy"), its memory stays charged to the cgroup, and the teardown CPU time no longer appears in the parent's resource usage. Valid only for executable builds; it has no effect on other platforms.</dd>
<dt><code>--large-pages</code></dt>
<dd>On Linux, when the program starts, move its code onto 2 MiB transparent huge pages, as Node's <code>--use-largepages=silent</code> does. A large program's code then takes a few instruction TLB entries instead of hundreds, and runs faster. The move applies only when the system grants huge pages to memory that asks for them (<code>/sys/kernel/mm/transparent_hugepage/enabled</code> is <code>always</code> or <code>madvise</code>), and only to the code between the first and the last 2 MiB boundary of the executable, so small programs keep their code as it is. Opt in only where the trade-offs are acceptable: the move takes about 3 ms per 10 MiB of code at every start, all of the moved code stays resident and is not shared with other processes running the same executable, and profilers that read symbols from the executable file report the moved code as anonymous memory. If a step of the move fails, the program runs with its code where it was. Valid only for executable builds; it has no effect on other platforms.</dd>
<dt><code>--target-cpu &lt;baseline|x86-64-v2|x86-64-v3|x86-64-v4|native&gt;</code></dt>
<dd>On x86-64 targets, compile the program's code for an x86-64 microarchitecture level. The default, <code>baseline</code>, runs on any x86-64 CPU. A higher level lets LLVM use more instructions, and the executable then runs only on CPUs that support that level. <code>native</code> uses the highest level the build machine supports. Valid for executable, assembly, and object output on x86-64 targets. See <a href="#target-cpu">Target CPU</a>.</dd>
+21 -4
View File
@@ -34,7 +34,7 @@ Runtime pack and linker -> executable
## Build parallelism
Most of a build runs on one CPU core. The LLVM helper uses more cores only when it compiles an executable in partitions.
Most of a build runs on one CPU core. The LLVM helper uses more cores when it compiles an executable in partitions, and when it generates code for an optimized executable of one partition.
### Frontend
@@ -62,7 +62,7 @@ The count depends only on the size of the LLVM text, so a program has the same p
These builds are not divided into partitions and compile on one thread: `--emit=asm`, `--emit=obj`, and `--print=native-link-info` output, library builds (`--lib`; `dev` library builds are compiled in pieces, one after another), and `--sanitize` builds, which compile the LLVM IR with the C compiler.
The helper compiles partitions on a pool with one thread per hardware thread of the host, so an executable with four partitions uses at most four cores in the backend. A program of one partition is optimized and compiled on one thread.
The helper compiles partitions on a pool with one thread per hardware thread of the host, so an executable with four partitions uses at most four cores in the backend.
How partitions are compiled depends on `--optimization`:
@@ -71,6 +71,23 @@ How partitions are compiled depends on `--optimization`:
Reading the LLVM text and dividing it into partitions, and the import step, run on one thread.
### Code generation of one partition
A `release` or `speed` executable of one partition is optimized as one module on one thread. When its LLVM text is larger than 128 KiB, the helper then divides the optimized module for code generation and generates machine code for the parts in parallel, on the same pool. Each part holds a run of consecutive functions with about the same amount of code, so the executable keeps the functions in the same order. The module is optimized before it is divided, so each function's machine code is the same as from one thread, except that a jump to a function that another part also uses takes the longer form. When one function holds most of the code, the first part holds the whole module and the others are empty.
<table>
<thead>
<tr><th>LLVM text</th><th>Code generation parts</th></tr>
</thead>
<tbody>
<tr><td>Up to 128 KiB</td><td>1</td></tr>
<tr><td>Over 128 KiB, up to 256 KiB</td><td>4</td></tr>
<tr><td>Over 256 KiB, up to 1 MiB</td><td>8</td></tr>
</tbody>
</table>
Like partitions, the count depends only on the size of the LLVM text. A `dev` executable of one partition is compiled on one thread.
### Rebuilds
With two or more partitions, the helper keeps each partition's summary and object in the build cache (see `SCRIPTC_CACHE_DIR` in the [CLI reference](/docs/cli#environment-variables)). A rebuild after an edit compiles again only the partitions whose functions changed and, in `release` and `speed` builds, the partitions that import a changed function; the others come from the cache. Dividing the program, the import step, and linking run on every build. A cold build, with nothing cached, compiles every partition.
@@ -134,8 +151,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.
@@ -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.
+2
View File
@@ -35,6 +35,8 @@ requested output.
Repeating `--output` divides the program into that many partitions, which are compiled concurrently and linked together. The helper splits the emitter's LLVM assembly text directly, so partitions also parse concurrently; input outside the emitter's narrow grammar is parsed whole and split with LLVM instead. Internal definitions that no other definition reaches are left out of every partition, and the others become hidden so partitions can reference each other. At opt-level 0 each partition is compiled independently. Optimized partitions follow the ThinLTO model: each is simplified and summarized on its own thread, a thin link decides which small functions every partition imports for inlining and which hidden definitions become internal again, and each partition is then optimized and compiled on its own thread. Partition placement depends only on the module and the partition count, so output never depends on the host.
With `--split codegen`, several outputs divide only code generation. The helper optimizes the module whole, as it does for a single output, then divides its functions in module order into runs of about the same instruction count, one per output, and generates code for every output on its own thread. Linking the outputs in order lays the functions out as one output would. A local definition that another output uses becomes hidden so the objects can reference each other; the others stay local. Every output after the first is generated from a copy of the optimized module read back from bitcode with its use-list order, so each function's machine code matches a single output except for jumps to definitions that another output also uses. When one run would hold more than three quarters of the module's instructions, the first output holds the whole module and the other outputs are empty objects. Placement depends only on the optimized module and the output count. `--split codegen` requires an optimized build and no `--cache-dir`; scriptc passes it for optimized executables with more than 128 KiB of LLVM text that stay one partition.
WebAssembly code generation differs from LLVM's default pipeline in two ways that do not change its output. The pass that gives multiple-entry loops a single entry runs only on functions whose control flow is irreducible; its reachability analysis grows with the square of a function's block count even when it has nothing to change. Object emission also leaves out the module pass that collects external symbols for assembly declarations, so each function's machine code is freed once it is printed instead of when the whole module is compiled.
`--cache-dir <dir>` names a private directory the caller owns for one helper build. Simplified partitions are stored under the hash of their exact source and objects under the hash of the exact module after importing, each with a trailing digest and published by rename, so a rebuild recompiles only the partitions an edit can affect. Partitions drop the names of their struct types before the thin link, which would otherwise number same-named types from different partitions in an order that any edit can shift, changing the module after importing in partitions the edit did not touch.
+271 -3
View File
@@ -6,8 +6,11 @@
#include "runtime-import.h"
#include "target.h"
#include "llvm/ADT/STLExtras.h"
#include "llvm/ADT/SmallPtrSet.h"
#include "llvm/ADT/SmallString.h"
#include "llvm/ADT/StringExtras.h"
#include "llvm/ADT/StringMap.h"
#include "llvm/ADT/StringRef.h"
#include "llvm/ADT/StringSet.h"
#include "llvm/Analysis/RuntimeLibcallInfo.h"
@@ -48,6 +51,7 @@
#include "llvm/TargetParser/Triple.h"
#include "llvm/Transforms/Utils/SplitModule.h"
#include <algorithm>
#include <atomic>
#include <mutex>
#include <optional>
@@ -100,6 +104,8 @@ std::optional<EmitOptions> parseEmitOptions(int Argc, char **Argv) {
Options.ProfileGenerate = Value.str();
else if (Arg == "--profile-use")
Options.ProfileUse = Value.str();
else if (Arg == "--split" && Value == "codegen")
Options.SplitCodegen = true;
else
return std::nullopt;
}
@@ -212,8 +218,8 @@ struct UnnameStructTypes : PassInfoMixin<UnnameStructTypes> {
// 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<uint64_t> Annotated{0};
@@ -1043,6 +1049,251 @@ int emitPartitions(const EmitOptions &Options) {
return 0;
}
// An optimized module prepared for split code generation.
struct CodegenSplit {
// The module as bitcode, with the order of every use list.
SmallVector<char, 0> Bitcode;
// The output that holds each definition, by name.
StringMap<size_t> PartOf;
};
// Divides an optimized module's definitions among Count outputs for code
// generation. Functions are divided in module order into runs of about the
// same instruction count, so linking the outputs in order lays the functions
// out as one module would; a function that would take its run past an even
// share starts the next run. A local definition that another output uses
// becomes hidden so the outputs can reference each other; the others stay
// local. Variables and aliases go with output 0 unless a comdat, an aliasee
// or an ifunc resolver places them. When one run would hold more than three
// quarters of the instructions, dividing cannot shorten code generation: the
// result is empty and M is unchanged. Placement depends only on the module.
std::optional<CodegenSplit> splitForCodegen(Module &M, size_t Count) {
uint64_t Total = 0;
for (const Function &F : M)
if (!F.isDeclaration())
Total += F.getInstructionCount() + 1;
DenseMap<const GlobalValue *, size_t> Part;
DenseMap<const Comdat *, size_t> ComdatPart;
std::vector<uint64_t> Load(Count, 0);
uint64_t Share = (Total + Count - 1) / Count;
size_t Current = 0;
uint64_t InRun = 0;
for (Function &F : M) {
if (F.isDeclaration())
continue;
uint64_t Size = F.getInstructionCount() + 1;
if (InRun > 0 && InRun + Size > Share && Current + 1 < Count) {
++Current;
InRun = 0;
}
size_t Target = Current;
if (const Comdat *C = F.getComdat()) {
auto [It, Inserted] = ComdatPart.try_emplace(C, Target);
Target = It->second;
}
Part[&F] = Target;
Load[Target] += Size;
InRun += Size;
}
if (*std::max_element(Load.begin(), Load.end()) * 4 > Total * 3)
return std::nullopt;
auto PartOf = [&](const GlobalValue *GV) -> size_t {
if (const GlobalObject *Root = GV->getAliaseeObject()) {
if (const auto *IFunc = dyn_cast<GlobalIFunc>(Root))
Root = IFunc->getResolverFunction();
if (Root != nullptr && Root != GV)
GV = Root;
}
if (auto It = Part.find(GV); It != Part.end())
return It->second;
if (const auto *GO = dyn_cast<GlobalObject>(GV))
if (const Comdat *C = GO->getComdat())
if (auto It = ComdatPart.find(C); It != ComdatPart.end())
return It->second;
return 0;
};
// Whether a user of GV, through any constants, lies outside output Home.
auto UsedElsewhere = [&](const GlobalValue &GV, size_t Home) {
SmallVector<const User *, 8> Work(GV.users());
SmallPtrSet<const User *, 8> Seen;
while (!Work.empty()) {
const User *U = Work.pop_back_val();
if (!Seen.insert(U).second)
continue;
if (const auto *I = dyn_cast<Instruction>(U)) {
if (PartOf(I->getFunction()) != Home)
return true;
} else if (const auto *G = dyn_cast<GlobalValue>(U)) {
if (PartOf(G) != Home)
return true;
} else {
append_range(Work, U->users());
}
}
return false;
};
for (GlobalValue &GV : M.global_values()) {
if (GV.hasLocalLinkage() && UsedElsewhere(GV, PartOf(&GV))) {
GV.setLinkage(GlobalValue::ExternalLinkage);
GV.setVisibility(GlobalValue::HiddenVisibility);
}
if (!GV.hasName())
GV.setName("scr.split");
}
CodegenSplit Split;
for (const GlobalValue &GV : M.global_values())
if (!GV.isDeclaration())
Split.PartOf[GV.getName()] = PartOf(&GV);
raw_svector_ostream Stream(Split.Bitcode);
WriteBitcodeToFile(M, Stream, /*ShouldPreserveUseListOrder=*/true);
return Split;
}
// Reduces P, a copy of the split module, to the definitions of output I:
// the other definitions become declarations, and declarations that nothing
// uses are removed. A declaration nothing uses would still emit an untyped
// undefined symbol for its visibility, which some linkers reject against a
// thread-local definition. Removing definitions keeps the order of the
// remaining uses, so each function of the output reaches code generation
// as it would in the whole module.
void keepCodegenPart(Module &P, const StringMap<size_t> &PartOf, size_t I) {
auto Elsewhere = [&](const GlobalValue &GV) {
auto It = PartOf.find(GV.getName());
return It != PartOf.end() && It->second != I;
};
// Aliases and ifuncs cannot be declarations, so a declaration replaces
// them.
SmallVector<GlobalValue *, 4> Indirect;
for (GlobalAlias &GA : P.aliases())
if (Elsewhere(GA))
Indirect.push_back(&GA);
for (GlobalIFunc &GI : P.ifuncs())
if (Elsewhere(GI))
Indirect.push_back(&GI);
for (GlobalValue *GV : Indirect) {
GlobalValue *Declaration;
if (auto *Type = dyn_cast<FunctionType>(GV->getValueType()))
Declaration = Function::Create(Type, GlobalValue::ExternalLinkage,
GV->getAddressSpace(), "", &P);
else
Declaration = new GlobalVariable(
P, GV->getValueType(), /*isConstant=*/false,
GlobalValue::ExternalLinkage, nullptr, "", nullptr,
GV->getThreadLocalMode(), GV->getAddressSpace());
Declaration->setVisibility(GV->getVisibility());
Declaration->setDSOLocal(GV->isDSOLocal());
Declaration->takeName(GV);
GV->replaceAllUsesWith(Declaration);
GV->eraseFromParent();
}
for (Function &F : P)
if (!F.isDeclaration() && Elsewhere(F)) {
F.deleteBody();
F.setComdat(nullptr);
}
for (GlobalVariable &GV : P.globals())
if (!GV.isDeclaration() && Elsewhere(GV)) {
GV.setInitializer(nullptr);
GV.setComdat(nullptr);
GV.setLinkage(GlobalValue::ExternalLinkage);
}
if (I != 0)
P.setModuleInlineAsm("");
// Constant expressions that only the removed bodies used remain as users.
for (GlobalVariable &GV : make_early_inc_range(P.globals()))
if (GV.isDeclaration()) {
GV.removeDeadConstantUsers();
if (GV.use_empty())
GV.eraseFromParent();
}
for (Function &F : make_early_inc_range(P))
if (F.isDeclaration()) {
F.removeDeadConstantUsers();
if (F.use_empty())
F.eraseFromParent();
}
}
// Optimizes M whole, as a single output would, then divides its definitions
// among the outputs and generates code for each on its own thread. The first
// output is generated from M with Machine; the others from copies of M read
// back from bitcode, which keeps the order of every use list, so each
// function's code matches a single-output build except where it refers to a
// definition in another output. Placement depends only
// on the optimized module and the output count. When splitForCodegen keeps
// the module whole, the other outputs are empty objects.
std::optional<EmitFailure>
emitSplitCodegen(const EmitOptions &Options, Module &M, TargetMachine &Machine,
OptimizationLevel Level, ProfileMatch &Match) {
bool Annotating = !Options.ProfileUse.empty();
if (Annotating)
M.getContext().setDiagnosticHandler(
std::make_unique<ProfileMatchHandler>(Match));
{
Pipeline P(Machine, Level, profileOptions(Options));
if (Annotating)
P.countAnnotated(Match.Annotated);
P.PB.buildPerModuleDefaultPipeline(Level).run(M, P.MAM);
}
if (std::optional<EmitFailure> Failure =
verify(M, "post_optimization_verification_failed"))
return Failure;
size_t Count = Options.Outputs.size();
std::optional<CodegenSplit> Split = splitForCodegen(M, Count);
std::string Identifier = M.getModuleIdentifier();
std::string SourceFileName = M.getSourceFileName();
Triple TargetTriple = M.getTargetTriple();
DataLayout Layout = M.getDataLayout();
std::vector<std::vector<SmallString<256>>> Temporaries(Count);
SharedFailure Failures;
forEachPartition(Count, [&](size_t I) {
if (I == 0) {
if (Split)
keepCodegenPart(M, Split->PartOf, 0);
if (std::optional<EmitFailure> Failure =
generateCode(M, Machine, CodeGenFileType::ObjectFile,
Options.Outputs[0], Temporaries[0]))
Failures.set(std::move(*Failure));
return;
}
std::string LookupError;
std::unique_ptr<TargetMachine> PartMachine =
createTargetMachine(Options.Target, Options.OptLevel, LookupError);
if (!PartMachine)
return Failures.set({"target_machine_failed", LookupError});
LLVMContext Context;
std::unique_ptr<Module> Part;
if (Split) {
Expected<std::unique_ptr<Module>> Parsed = parseBitcodeFile(
MemoryBufferRef(
StringRef(Split->Bitcode.data(), Split->Bitcode.size()), "part"),
Context);
if (!Parsed)
return Failures.set(
{"partition_failed", toString(Parsed.takeError())});
Part = std::move(*Parsed);
keepCodegenPart(*Part, Split->PartOf, I);
} else {
Part = std::make_unique<Module>(Identifier, Context);
Part->setSourceFileName(SourceFileName);
Part->setTargetTriple(TargetTriple);
Part->setDataLayout(Layout);
}
if (std::optional<EmitFailure> Failure =
generateCode(*Part, *PartMachine, CodeGenFileType::ObjectFile,
Options.Outputs[I], Temporaries[I]))
return Failures.set(std::move(*Failure));
});
std::optional<EmitFailure> Failure = Failures.take();
for (size_t I = 0; I < Count && !Failure; ++I)
Failure = publish(Temporaries[I][0], Options.Outputs[I]);
if (Failure)
for (const std::vector<SmallString<256>> &Paths : Temporaries)
for (const SmallString<256> &Temporary : Paths)
sys::fs::remove(Temporary);
return Failure;
}
} // namespace
int emit(const EmitOptions &Options) {
@@ -1089,7 +1340,14 @@ int emit(const EmitOptions &Options) {
if (std::optional<EmitFailure> Failure = inspectProfile(Options))
return reportError(Failure->Code, Failure->Message,
Options.DiagnosticFormat);
if (Options.Outputs.size() > 1)
if (Options.SplitCodegen && (Options.Outputs.size() < 2 ||
Options.OptLevel == "0" ||
!Options.CacheDirectory.empty()))
return reportError("invalid_split",
"--split codegen requires several outputs, an "
"optimized build, and no cache directory",
Options.DiagnosticFormat);
if (Options.Outputs.size() > 1 && !Options.SplitCodegen)
return emitPartitions(Options);
SMDiagnostic ParseDiagnostic;
@@ -1147,6 +1405,16 @@ int emit(const EmitOptions &Options) {
CodeGenFileType Type = Options.FileType == "obj"
? CodeGenFileType::ObjectFile
: CodeGenFileType::AssemblyFile;
if (Options.SplitCodegen) {
ProfileMatch Match;
if (std::optional<EmitFailure> Failure =
emitSplitCodegen(Options, *Mod, *Machine, Level, Match))
return reportError(Failure->Code, Failure->Message,
Options.DiagnosticFormat);
if (!Options.ProfileUse.empty())
reportProfileMatch(Match);
return 0;
}
std::vector<SmallString<256>> Temporaries;
ProfileMatch Match;
std::optional<EmitFailure> Failure =
+3
View File
@@ -26,6 +26,9 @@ struct EmitOptions {
std::string ProfileGenerate;
// Indexed profile (.profdata) that guides optimization. Empty: none.
std::string ProfileUse;
// With several outputs: optimize the module whole and divide only its code
// generation among the outputs.
bool SplitCodegen = false;
};
std::optional<EmitOptions> parseEmitOptions(int Argc, char **Argv);
+7 -1
View File
@@ -77,7 +77,11 @@ async function tryFastPath(): Promise<number | null> {
values["target-cpu"] !== undefined ||
(values["external-types"] ?? []).length > 0 ||
// The full CLI validates the Node island policy flags.
hasNodeIslandFlags(values)
hasNodeIslandFlags(values) ||
// The full compiler writes the representation report and keys the heap
// census build (backend/llvm/repr-report.ts, heap-census.ts).
(process.env["SCRIPTC_REPR_REPORT"] ?? "") !== "" ||
(process.env["SCRIPTC_HEAP_CENSUS"] ?? "") !== ""
)
return null;
const backend = values.backend;
@@ -144,6 +148,7 @@ async function tryFastPath(): Promise<number | null> {
...(optimization === "dev" || optimization === "speed" ? { optimization } : {}),
...(values.strip ? { strip: true as const } : {}),
...(values["detached-exit"] ? { detachedExit: true as const } : {}),
...(values["large-pages"] ? { largePages: true as const } : {}),
...(values["cpu-dispatch"] === true ? { cpuDispatch: true as const } : {}),
...(checker === undefined ? {} : { checker }),
npmStatic,
@@ -230,6 +235,7 @@ async function coverageFastPath(
values.emit !== undefined ||
values.strip ||
values["detached-exit"] ||
values["large-pages"] ||
values["target-cpu"] !== undefined ||
values["cpu-dispatch"] !== undefined ||
values.lib ||
@@ -198,3 +198,57 @@ test.skipIf(process.platform === "win32")(
},
120_000,
);
test.skipIf(process.platform !== "linux")(
"a warm executable cache still builds the heap census and writes the representation report",
async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-cli-diagnostic-cache-"));
const cacheRoot = join(dir, "cache");
const entry = join(dir, "main.ts");
const outPath = join(dir, "program");
try {
await mkdir(cacheRoot, { mode: 0o700 });
// `new` and construction through a class value both count.
await writeFile(
entry,
[
"class Item { constructor(readonly id: number) {} }",
"const kind: typeof Item = Item;",
"const items = [new Item(1), new kind(2), new kind(3)];",
"console.log(items.length);",
"",
].join("\n"),
);
const build = async (env: NodeJS.ProcessEnv = {}): Promise<string> =>
(
await execFileAsync(
process.execPath,
["--import", tsxLoader, cliEntry, "build", entry, "-o", outPath],
{
env: { ...process.env, SCRIPTC_CACHE_DIR: cacheRoot, SCRIPTC_TIMING: "1", ...env },
maxBuffer: 4 * 1024 * 1024,
},
)
).stderr;
expect(await build()).toContain("scriptc lowering");
expect(await build()).not.toContain("scriptc lowering");
// The census build is its own cache entry.
const census = join(dir, "census.tsv");
await build({ SCRIPTC_HEAP_CENSUS: "1" });
const { stdout } = await execFileAsync(outPath, [], {
env: { ...process.env, SCRIPTC_HEAP_CENSUS: census },
});
expect(stdout).toBe("3\n");
expect(await readFile(census, "utf8")).toMatch(/^new\tItem\t\t3\t[1-9]\d*$/m);
// A report is written even when the executable is cached.
const report = join(dir, "repr.tsv");
await build({ SCRIPTC_REPR_REPORT: report });
expect(await readFile(report, "utf8")).toMatch(/^field\tItem\tid\t/m);
} finally {
await rm(dir, { recursive: true, force: true });
}
},
600_000,
);
+2
View File
@@ -48,6 +48,8 @@ describe("output option compatibility", () => {
[{ emit: "llvm", strip: true }, /strip/],
[{ emit: "obj", detachedExit: true }, /detached-exit/],
[{ emit: "llvm", detachedExit: true }, /detached-exit/],
[{ emit: "obj", largePages: true }, /large-pages/],
[{ emit: "llvm", largePages: true }, /large-pages/],
[{ emit: "asm", cpuDispatch: true }, /cpu-dispatch/],
[{ emit: "obj", profileGenerate: "/tmp/%p.profraw" }, /profile-generate/],
[{ emit: "llvm", profileUse: "/tmp/a.profdata" }, /profile-use/],
+1 -1
View File
@@ -54,7 +54,7 @@ test.runIf(process.platform === "linux" && process.arch === "x64")(
expect(baseline).not.toContain("sanitize_address");
expect(await build(["--target-cpu=baseline"])).toBe(baseline);
const v3 = await build(["--target-cpu=x86-64-v3"]);
expect(v3).toMatch(/^attributes #0 = \{ "target-cpu"="x86-64-v3" \}$/m);
expect(v3).toMatch(/^attributes #0 = \{ uwtable "target-cpu"="x86-64-v3" \}$/m);
if (runsV3) expect((await execFileAsync(outPath)).stdout).toBe(expected);
// The bootstrap's cached route builds without a level.
expect(await build([], true)).toBe(baseline);
Binary file not shown.
Binary file not shown.
@@ -5,10 +5,11 @@ import { hasForeignFfiCallback } from "./ffi-callbacks.js";
import type { NativeLinkFeatures } from "./native-link-info.js";
import { youngNurseryRequested } from "./young-nursery.js";
export function executableLinkFeatures(mod: IrModule): NativeLinkFeatures {
export function executableLinkFeatures(mod: IrModule, largePages = false): NativeLinkFeatures {
const features = moduleRuntimeFeatures(mod);
return {
...(mod.workers ? { workers: true } : {}),
...(largePages ? { largePages: true } : {}),
...(youngNurseryRequested() && mod.lib === undefined ? { youngNursery: true } : {}),
regex: features.regex,
copying: features.copying,
@@ -75,7 +75,7 @@ export function executableLinkInputs(options: {
* links as needed: the runtime defines the libm functions most programs
* reach (scr_glibc_math.c) and Math's transcendentals (scr_ieee754.c), so a
* program that calls no other libm function, such as pow, starts without
* loading libm.so. */
* loading libm.so when it links with lld (see inputsWithoutLld). */
export function systemLibraryLinkerArgs(
target: Pick<NativeTargetSpec, "platform">,
names: readonly string[],
+15 -14
View File
@@ -21,7 +21,7 @@ import {
import { type NativeArtifactDependency } from "./native/contracts.js";
import { RuntimePackError, stageRuntimePackArtifacts } from "./runtime-pack.js";
import type { NativeLinkPlan } from "./link-plan.js";
import { lldLinkerArgs } from "./lld.js";
import { inputsWithoutLld, lldLinkerArgs } from "./lld.js";
import { systemLibraryLinkerArgs } from "./link-plan-core.js";
import {
createDarwinDebugSymbols,
@@ -244,15 +244,19 @@ export async function linkNativeExecutable(
stagedRoot = staged.root;
// Linux links with lld when the default driver finds it.
const lld = explicitLinker === undefined ? lldLinkerArgs(plan.target.platform, linker) : [];
const args = [
...(explicitLinker === undefined ? plan.target.defaultLinkerArgs : []),
...lld,
...plan.driverFlags,
...plan.inputs.map((input) => staged.replacements.get(input) ?? input),
...systemLibraryLinkerArgs(plan.target, plan.systemLibraries),
"-o",
privateOut,
];
const linkArgs = (withLld: boolean): string[] => {
const inputs = plan.inputs.map((input) => staged.replacements.get(input) ?? input);
return [
...(explicitLinker === undefined ? plan.target.defaultLinkerArgs : []),
...(withLld ? lld : []),
...plan.driverFlags,
...(withLld ? inputs : inputsWithoutLld(plan.target.platform, inputs)),
...systemLibraryLinkerArgs(plan.target, plan.systemLibraries),
"-o",
privateOut,
];
};
const args = linkArgs(lld.length !== 0);
// The pack snapshots bracket both verification passes and private staging;
// the program-object snapshot begins before helper emission. Snapshot the
// remaining cache-bearing inputs before the linker consumes them, then
@@ -285,10 +289,7 @@ export async function linkNativeExecutable(
// A failed link with lld runs again with the driver's default linker,
// whose error (if any) is the one reported.
if (lld.length === 0) throw error;
await execFileAsync(
linker,
args.filter((arg) => !lld.includes(arg)),
);
await execFileAsync(linker, linkArgs(false));
});
const output = await stat(privateOut);
if (!output.isFile() || output.size === 0) throw new Error("linker produced no executable");
+8 -1
View File
@@ -2,7 +2,7 @@ import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync }
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test } from "vitest";
import { driverFindsLld, lldLinkerArgs } from "./lld.js";
import { driverFindsLld, inputsWithoutLld, lldLinkerArgs } from "./lld.js";
const directories: string[] = [];
afterEach(() => {
@@ -59,3 +59,10 @@ test.skipIf(process.platform === "win32")(
expect(lldLinkerArgs("linux", clang, { ...env, SCRIPTC_LINKER: clang })).toEqual([]);
},
);
test("Linux links without lld leave out the glibc math unit", () => {
const inputs = ["/pack/scr_lib.o", "/pack/scr_glibc_math.o", "/tmp/program.o"];
expect(inputsWithoutLld("linux", inputs)).toEqual(["/pack/scr_lib.o", "/tmp/program.o"]);
expect(inputsWithoutLld("darwin", inputs)).toEqual(inputs);
expect(inputsWithoutLld("windows", inputs)).toEqual(inputs);
});
+19 -1
View File
@@ -1,5 +1,5 @@
import { realpathSync, statSync } from "node:fs";
import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
import { basename, delimiter, dirname, isAbsolute, join, resolve } from "node:path";
function isFile(path: string): boolean {
try {
@@ -47,3 +47,21 @@ export function lldLinkerArgs(
? ["-fuse-ld=lld"]
: [];
}
/**
* The link inputs for a Linux link without lld. The runtime's
* scr_glibc_math.c defines glibc's floor, fmod, frexp and a few others as
* hidden functions so that a program calling no other libm function starts
* without libm.so. That works with lld, which garbage-collects the unused
* definitions and decides `--as-needed` from the live code. GNU ld decides
* `--as-needed` before section GC, so the runtime's unreachable code keeps
* libm needed, and it keeps every regular definition of a name that a needed
* shared library also defines, hidden or not: the unit would add all of its
* code to every executable and save nothing. Its functions answer what
* glibc's do, so linking glibc's own instead is equivalent.
*/
export function inputsWithoutLld(platform: string, inputs: readonly string[]): string[] {
return platform === "linux"
? inputs.filter((input) => basename(input) !== "scr_glibc_math.o")
: [...inputs];
}
+1 -1
View File
@@ -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}`);
@@ -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 (`<new>.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<string>;
classes: Set<string>;
}
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<string>();
const classes = new Set<string>();
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<string>,
sites: RegionSites,
takesArena: (node: Node) => boolean,
traced: (t: IrType) => boolean,
): Set<string> {
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<string>();
const calls = new Map<string, Set<string>>();
const heap = new Set<string>();
for (const name of reachable) {
const fn = fns.get(name);
if (fn === undefined) continue;
const out = new Set<string>();
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 `<new>.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<i>` 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`,
`}`,
``,
];
}
@@ -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))),
@@ -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<number, Node[]>();
// Accesses inside the loop's nested loops, and inside its nested while
// loops.
const nested = new Set<Node>();
const nestedWhile = new Set<Node>();
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)!;
+67 -2
View File
@@ -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
@@ -1089,9 +1097,15 @@ export function emitClassShapes(
// A traced class whose trace visits nothing carries the collector header
// only because its hierarchy is traced: it is never on a cycle. Its
// descriptor names scr_cyc_no_children and its objects start out
// SCR_CYC_ACYCLIC, so releases never buffer them.
// SCR_CYC_ACYCLIC, so releases never buffer them. An Array subclass's
// trace also visits its elements by their current element ops
// (scr_arr_subclass_trace), so it is never acyclic.
const acyclic =
traced && runtimePrefix === null && !isStreamRooted && tracedFields.length === 0;
traced &&
runtimePrefix === null &&
!isStreamRooted &&
tracedFields.length === 0 &&
cls.arrayElem === undefined;
const bounded =
runtimePrefix !== null ||
isStreamRooted ||
@@ -1415,6 +1429,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 +1499,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 +1509,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
@@ -1649,6 +1712,8 @@ export function emitClassObjDefs(
`define internal ptr @${mangleCtorThunk(className)}(ptr %class${paramDecls ? ", " + paramDecls : ""}) ${FN_ATTRS} { ; construct thunk ${className}`,
`entry:`,
` %o = call ptr @${mangleClassNew(className)}()`,
// Construction through a class value: the census counts it here.
...(host.heapCensus?.newLines(className) ?? []),
...(meta.def.localCaptures !== undefined
? [
` %class.owned = call ptr @scr_classobj_retain_v(ptr %class)`,
@@ -164,6 +164,44 @@ export function exactIndexLines(
return { lines, ok, wide };
}
/** exactIndexLines for the index `base + offset`, where `offset` is an
* integer of magnitude below 2^31: the round trip converts `base` instead of
* the sum, so accesses at several offsets from one base share the
* conversion. When `base` is an integer, the sum is in range exactly when
* the integer sum is (a length is at most 2^52, and a sum of 2^52 or more is
* out of range either way). A fractional `base` can still give an integer
* sum, as `1e-20 + 1` is 1, so `integral` false leaves the index to an
* exact check of the sum itself. */
export function exactOffsetIndexLines(
base: string,
offset: number,
length: string,
sizeType: string,
tmp: () => string,
): { lines: string[]; ok: string; wide: string; integral: string } {
const raw = tmp(),
integer = tmp(),
back = tmp(),
integral = tmp(),
wide = tmp(),
below = tmp(),
ok = tmp();
const lines = [
`${raw} = fptosi double ${base} to i64`,
`${integer} = freeze i64 ${raw}`,
`${back} = sitofp i64 ${integer} to double`,
`${integral} = fcmp oeq double ${back}, ${base}`,
`${wide} = add i64 ${integer}, ${offset}`,
];
let len64 = length;
if (sizeType !== "i64") {
len64 = tmp();
lines.push(`${len64} = zext ${sizeType} ${length} to i64`);
}
lines.push(`${below} = icmp ult i64 ${wide}, ${len64}`, `${ok} = and i1 ${integral}, ${below}`);
return { lines, ok, wide, integral };
}
/** User-controlled text embedded after an LLVM `;` comment marker. Preserve
* ordinary output unchanged, but encode control and line-separator code
* units so a property name can never inject a line or invalid source byte. */
@@ -18,10 +18,10 @@ const COPY_GROUPS: ReadonlyMap<string, string> = new Map([
["2", "4"],
]);
export function cpuDispatchAttributes(asan: string): string[] {
export function cpuDispatchAttributes(common: string): string[] {
return [
`attributes #3 = {${asan} "target-cpu"="x86-64-v3" }`,
`attributes #4 = { noinline${asan} "target-cpu"="x86-64-v3" }`,
`attributes #3 = {${common} "target-cpu"="x86-64-v3" }`,
`attributes #4 = { noinline${common} "target-cpu"="x86-64-v3" }`,
];
}
@@ -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,
@@ -300,6 +301,9 @@ function loadState(host: LlvmEmitterContext, guard: DenseGuard): string {
return state;
}
/** The ScrElemKind of double arrays (scr_array.h). */
export const SCR_ELEM_F64 = 0;
/** The ScrElemKind of int32 arrays (scr_array.h). */
export const SCR_ELEM_I32 = 7;
@@ -368,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,
@@ -379,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);
@@ -460,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(
+101 -26
View File
@@ -1,5 +1,5 @@
import { emitPerKeyOrder } from "./key-orders.js";
import { typedRefConstructor } from "./shapes.js";
import { rcMembers, typedRefConstructor } from "./shapes.js";
import { bindLiveArrayOps } from "./expr-stream-bridges.js";
import {
BYTES_ELEM_NUM,
@@ -32,10 +32,9 @@ import { InternalCompilerError } from "../../errors.js";
* are selected from the target C ABI.
* v.num double | v.b i8 | v.str/v.bytes ptr at +16;
* v.arr { len +16, cap +24, items +32 };
* v.obj { len +16, cap +24, entries +32 };
* v.obj { len +16 } (own properties; read them through
* scr_dyn_member_*, which know both object modes);
* v.fn { clo +16, thunk +24, sig +32, name +40, arity +48 }.
* ScrDynEntry { key, key_len, value, getter, setter, accessor, writable,
* enumerable, configurable } — 48 bytes on 64-bit, 24 on 32-bit.
* ScrDynKind: NULL=0 BOOL=1 NUM=2 STR=3 ARR=4 OBJ=5 UNDEF=6 BYTES=7
* FUNC=8 HANDLE=9.
* ScrBytes { rc +0; len +8; elem +16; data +24 }.
@@ -46,6 +45,7 @@ import {
DYN_HANDLE_KINDS,
isDirentShape,
KEY_ORDER_FIELD,
shapeHasAccessorSlots,
shapeKeyOrders,
isDynTypedRefType,
isRefCounted,
@@ -261,6 +261,8 @@ export class LlDyn {
/** Emitted function definitions, in interning order. */
readonly defs: string[] = [];
private readonly prefixSources = new Map<string, string[]>();
constructor(private readonly host: DynHost) {}
/** Journal replay (emit-journal.ts): builds or finds the helper a chunk
@@ -420,7 +422,7 @@ export class LlDyn {
* dyn view: inside a visit session (`wrapped`), a capsule keeps the view
* it was first given, so an outer visit can keep reading it. */
private viewFn(wrapped: boolean): string {
const fn = wrapped ? "scr_dyn_visit_view" : "scr_dyn_typed_ref_materialize";
const fn = wrapped ? "scr_dyn_visit_view" : "scr_dyn_typed_ref_peek";
this.host.declare(`declare ptr @${fn}(ptr)`);
return fn;
}
@@ -849,27 +851,23 @@ export class LlDyn {
return e;
}
/** Object entry field addresses: entries + i*sizeof(ScrDynEntry), with
* key, key_len, and value at offsets 0, 8, and 16 on 64-bit targets. */
/** Own property i of object `d` (borrowed key bytes, key length and
* value), read through the runtime's accessors: the object may be in
* layout or dictionary mode (scr_dyn.h). */
private entryAt(
B: BlockBuilder,
entries: string,
d: string,
i: string,
): { key: string; keyLen: string; value: string } {
const off = B.tmp();
const base = B.tmp();
B.line(`${off} = mul ${this.S} ${i}, ${this.abiOffset(48, 24)} ; sizeof(ScrDynEntry)`);
B.line(`${base} = getelementptr inbounds i8, ptr ${entries}, ${this.S} ${off}`);
this.host.declare(`declare ptr @scr_dyn_member_key(ptr, ${this.S})`);
this.host.declare(`declare ${this.S} @scr_dyn_member_key_len(ptr, ${this.S})`);
this.host.declare(`declare ptr @scr_dyn_member_value(ptr, ${this.S})`);
const key = B.tmp();
B.line(`${key} = load ptr, ptr ${base}`);
const klp = B.tmp();
const keyLen = B.tmp();
B.line(`${klp} = getelementptr inbounds i8, ptr ${base}, i64 ${this.abiOffset(8, 4)}`);
B.line(`${keyLen} = load ${this.S}, ptr ${klp}`);
const vp = B.tmp();
const value = B.tmp();
B.line(`${vp} = getelementptr inbounds i8, ptr ${base}, i64 ${this.abiOffset(16, 8)}`);
B.line(`${value} = load ptr, ptr ${vp}`);
B.line(`${key} = call ptr @scr_dyn_member_key(ptr ${d}, ${this.S} ${i})`);
B.line(`${keyLen} = call ${this.S} @scr_dyn_member_key_len(ptr ${d}, ${this.S} ${i})`);
B.line(`${value} = call ptr @scr_dyn_member_value(ptr ${d}, ${this.S} ${i})`);
return { key, keyLen, value };
}
@@ -1155,10 +1153,63 @@ export class LlDyn {
* Class layouts share their base prefix, so unboxing retains the same
* pointer and virtual dispatch still uses the object's original vtable. */
private typedRefMatches(B: BlockBuilder, t: IrType): string {
return this.typedRefMatchesAny(
B,
isDynTypedRefType(t)
? this.host.classSubtypes(t.className).map((className) => ({ kind: "object", className }))
: [t],
);
}
/** The record shapes whose values a pointer of shape `targetId` may
* reference unchanged: their struct begins with exactly the target's
* fields (same names, types and representations, so the same offsets),
* and everything the target's release, teardown and collector support
* would get wrong on them is absent. The extra fields hold no
* references (the target's release frees the block without visiting
* them; frees are size-class agnostic), neither shape carries a shape
* word, a cycle header, an overflow map, key order variants or accessor
* slots, and both freeze alike. A record that keeps undeclared source
* properties is refused at run time (scr_dyn_typed_ref_prefix_view). */
private prefixViewSources(targetId: string): string[] {
const memo = this.prefixSources.get(targetId);
if (memo) return memo;
const host = this.host;
const plain = (shape: IrRecordShape): boolean =>
!shape.tuple &&
!shape.indexValue &&
!isDirentShape(shape) &&
!shapeHasAccessorSlots(shape) &&
shapeKeyOrders(shape).length === 1 &&
!shape.fields.some((field) => field.name === KEY_ORDER_FIELD) &&
host.nullableUnions.shapeNumber(shape.id) === 0 &&
!host.tracedShapes.has(`record:${shape.id}`);
const target = host.recordsById.get(targetId);
const out: string[] = [];
if (target && plain(target) && target.fields.length > 0) {
const base = host.nullableUnions.fieldBase(targetId);
for (const shape of host.recordsById.values()) {
if (shape.id === targetId || shape.fields.length <= target.fields.length) continue;
if (!plain(shape) || shape.freezable !== target.freezable) continue;
if (host.nullableUnions.fieldBase(shape.id) !== base) continue;
const prefix = target.fields.every(
(field, i) =>
shape.fields[i]!.name === field.name &&
typeKey(shape.fields[i]!.type) === typeKey(field.type),
);
if (!prefix) continue;
const extra = rcMembers(shape, host.inlineUnions, base).some(
(member) => member.index >= target.fields.length + base && isRefCounted(member.type),
);
if (!extra) out.push(shape.id);
}
}
this.prefixSources.set(targetId, out);
return out;
}
private typedRefMatchesAny(B: BlockBuilder, types: readonly IrType[]): string {
this.host.declare(`declare zeroext i1 @scr_dyn_typed_ref_is(ptr, ptr, ${this.S})`);
const types: IrType[] = isDynTypedRefType(t)
? this.host.classSubtypes(t.className).map((className) => ({ kind: "object", className }))
: [t];
let matched = "false";
for (const type of types) {
const key = typeKey(type);
@@ -1441,9 +1492,8 @@ export class LlDyn {
if (shape.indexValue && shape.indexValue.kind !== "dyn") {
this.host.declare(`declare i32 @memcmp(ptr, ptr, ${this.S})`);
const n = this.lenOf(B, "%d");
const entries = this.itemsOf(B, "%d");
this.i64Loop(B, "dm.ov", n, (i, brNext) => {
const ent = this.entryAt(B, entries, i);
const ent = this.entryAt(B, "%d", i);
// Skip declared keys.
for (const f of shape.fields) {
const klen = Buffer.byteLength(f.name, "utf8");
@@ -2172,6 +2222,32 @@ export class LlDyn {
B.line(`${ref} = call ptr @scr_dyn_typed_ref_unbox(ptr %d)`);
B.terminate(`ret ptr ${ref}`);
B.startBlock(lNext);
if (t.kind === "record") {
// A record whose storage begins with exactly this type's storage
// is this type's value too: the conversion passes the pointer, so
// both names keep one object (decision D3).
const sources = this.prefixViewSources(t.shapeId);
if (sources.length > 0) {
const viewed = this.typedRefMatchesAny(
B,
sources.map((shapeId) => ({ kind: "record", shapeId })),
);
const lView = B.newLabel("dc.prefix");
const lOther = B.newLabel("dc.noprefix");
B.condBr(viewed, lView, lOther);
B.startBlock(lView);
host.declare(`declare ptr @scr_dyn_typed_ref_prefix_view(ptr)`);
const view = B.tmp();
B.line(`${view} = call ptr @scr_dyn_typed_ref_prefix_view(ptr %d)`);
const passed = B.tmp();
B.line(`${passed} = icmp ne ptr ${view}, null`);
const lPass = B.newLabel("dc.prefix.pass");
B.condBr(passed, lPass, lOther);
B.startBlock(lPass);
B.terminate(`ret ptr ${view}`);
B.startBlock(lOther);
}
}
// A constructor-function stream (scr_dyn_builtin_stream.c) carries
// its runtime stream.
const streamKind = t.kind === "object" ? builtinStreamKind(t.className) : null;
@@ -2777,9 +2853,8 @@ export class LlDyn {
B.line(`store i8 ${np}, ptr ${flagsp}`);
}
const n = this.lenOf(B, "%d");
const entries = this.itemsOf(B, "%d");
this.i64Loop(B, "dcv", n, (i, brNext) => {
const ent = this.entryAt(B, entries, i);
const ent = this.entryAt(B, "%d", i);
for (const f of shape.fields) {
const klen = Buffer.byteLength(f.name, "utf8");
const lenEq = B.tmp();
@@ -31,6 +31,7 @@ import { findLocalStackUnions } from "./stack-unions.js";
import { findLocalStackObjects, findReassignedStackObjects } from "./stack-objects.js";
import { findLocalUnionStorage } from "./local-union-storage.js";
import { INT32_RANGE, analyzeIntegerRangeFacts } from "../../ir/integer-ranges.js";
import { beginLoopEntryVersions } from "./loop-entry-versions.js";
import { findBytesBounds } from "./bytes-bounds.js";
import { findInt32Locals } from "./int32-locals.js";
import { findIntegerViews } from "./integer-views.js";
@@ -246,6 +247,7 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
const rangeFacts = analyzeIntegerRangeFacts(numericFn, slotFacts);
emitter.integerRanges = rangeFacts.ranges;
emitter.integerLoopRanges = rangeFacts.loopRanges;
beginLoopEntryVersions(emitter, numericFn, slotFacts, rangeFacts);
emitter.int32Elements = slotFacts.elements ?? null;
emitter.integerOrNaNLocals = findIntegerOrNaNLocals(emitter, numericFn);
emitter.bytesBounds = findBytesBounds(numericFn, emitter.integerRanges);
@@ -338,6 +340,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 +528,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 +866,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)})`]
: []),
@@ -201,19 +201,22 @@ function readRange(dir: string, range: number): { result: ChunkResult; texts: st
/** The attribute groups of the emitted functions: #0 for every function,
* #1 for coroutine bodies and #2 for out-of-line record clones. Each carries
* the build's `--target-cpu` level, and `sanitize_address` in sanitized
* builds. A group needs one attribute: without either, #0 holds a string
* attribute that LLVM ignores. Dispatching builds add the x86-64-v3 copies'
* #3 and #4 (cpu-dispatch.ts). */
* the build's `--target-cpu` level, `sanitize_address` in sanitized builds,
* and `uwtable` on native targets, so every function has an unwind table
* entry, including those LLVM infers `nounwind` (profilers and debuggers
* unwind through them). A group needs one attribute: without any, #0 holds a
* string attribute that LLVM ignores. Dispatching builds add the x86-64-v3
* copies' #3 and #4 (cpu-dispatch.ts). */
function functionAttributes(emitter: LlEmitter, hasNoInlineRecordClone: boolean): string[] {
const cpu = targetCpuAttribute(emitter.targetCpu);
const asan = emitter.sanitizeAttributes ? " sanitize_address" : "";
const base = `${asan}${cpu}` || ` "scriptc-function"`;
const common = `${asan}${emitter.wasi ? "" : " uwtable"}`;
const base = `${common}${cpu}` || ` "scriptc-function"`;
return [
`attributes #0 = {${base} }`,
...(emitter.usesCoroutines ? [`attributes #1 = {${asan} presplitcoroutine${cpu} }`] : []),
...(hasNoInlineRecordClone ? [`attributes #2 = { noinline${asan}${cpu} }`] : []),
...(emitter.cpuDispatch ? cpuDispatchAttributes(asan) : []),
...(emitter.usesCoroutines ? [`attributes #1 = {${common} presplitcoroutine${cpu} }`] : []),
...(hasNoInlineRecordClone ? [`attributes #2 = { noinline${common}${cpu} }`] : []),
...(emitter.cpuDispatch ? cpuDispatchAttributes(common) : []),
];
}
@@ -46,6 +46,7 @@ import { emitBorrowedInput } from "./borrowed-inputs.js";
import { f64Lit, llvmCommentText, loadArrCount, SCR_ARR_FIELD, sizeToDouble } from "./common.js";
import { planInt32KindLoop, type Int32KindLoopPlan } from "./int32-kind-loops.js";
import { withinInt32 } from "../../ir/integer-ranges.js";
import { emitLoopEntryVersion, loopEntryVersion } from "./loop-entry-versions.js";
import { resultSlotStore } from "../result-slots.js";
import {
matchIntegerArrayForLoop,
@@ -96,7 +97,7 @@ import {
import { emitStringInputs } from "./string-lifetimes.js";
import { LOOPS } from "./loop-state.js";
import { loopUnrollAttachment, periodicGuardPeriod } from "./periodic-guards.js";
import { loopAccessesTypedArrays } from "../loop-polls.js";
import { containsLoop, loopAccessesTypedArrays } from "../loop-polls.js";
import { emitConstantChoice, matchConstantChoice } from "./constant-choices.js";
/** A callback loop version copies the loop; keep the copies small. */
@@ -105,8 +106,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;
@@ -613,6 +617,14 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
// (The guard precedes the value here: the nullable store
// evaluates it itself.)
emitter.emitStoreGuard(obj.name, s);
emitter.heapCensus?.countStore(
(line) => B.line(line),
() => B.tmp(),
s.className,
s.field,
s.value,
null,
);
emitter.emitNullableFieldStore(obj.name, s.className, s.field, nullable, s.value);
break;
}
@@ -632,8 +644,17 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
? emitter.recordFieldInline(s.shapeId, s.field)
: emitter.inlineUnions.classField(s.className, s.field),
);
if (s.kind === "fieldSet") emitter.emitStoreGuard(obj.name, s);
else if (emitter.publishedTypes?.recordGuarded(s.shapeId) === true)
if (s.kind === "fieldSet") {
emitter.emitStoreGuard(obj.name, s);
emitter.heapCensus?.countStore(
(line) => B.line(line),
() => B.tmp(),
s.className,
s.field,
s.value,
v.name,
);
} else if (emitter.publishedTypes?.recordGuarded(s.shapeId) === true)
emitter.emitPublishedFieldGuard(obj.name, null, s.field);
const { ptr, type, inline } =
s.kind === "fieldSet"
@@ -1228,8 +1249,8 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
};
// A counted loop over typed arrays, or a loop to a typed array's
// length, polls between blocks of WORKER_POLL_BLOCK iterations instead
// of counting a budget in its body: the inner loop over a block then
// has no exit besides its own bound, which LLVM can vectorize. A typed
// of in its body: the inner loop over a block then has no exit
// besides its own bound, which LLVM can vectorize. A typed
// array's length is fixed, so each block can take it as its limit when
// the loop does not assign the array's binding.
//
@@ -1670,7 +1691,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()`);
@@ -2346,6 +2367,13 @@ export function emitLoopCopies(
exit: string,
emitCopy: (exit: string) => void,
): void {
const entry = allowed ? loopEntryVersion(emitter, loop) : null;
if (entry) {
emitLoopEntryVersion(emitter, loop, entry, () =>
emitLoopCopies(emitter, loop, allowed, exit, emitCopy),
);
return;
}
const plan =
allowed &&
emitter.speculativeLoopsEnabled &&
@@ -2760,8 +2788,8 @@ interface StripMinedCounter {
* iterations, with the cancellation poll between blocks: the outer header
* tests the counter against the loop's limit, each block runs the body up
* to the nearer of the limit and WORKER_POLL_BLOCK steps on, and the poll
* follows the block. It polls as often as the budget it replaces, and the
* inner loop's only exit besides `break` and `return` is its bound.
* follows the block. It polls once per block, and the inner loop's only
* exit besides `break` and `return` is its bound.
*
* With `runSlot` (for a loop that another loop of the function encloses), a
* block that ends at the limit leaves the loop without polling, and a run
@@ -2895,21 +2923,6 @@ function emitStripMinedCountedLoop(
B.br(lc);
}
/** Whether `body` holds a loop outside its closures. */
function containsLoop(body: IrStmt[]): boolean {
const inStmt = (s: IrStmt): boolean =>
s.kind === "while" ||
s.kind === "doWhile" ||
s.kind === "for" ||
s.kind === "forOf" ||
!everyStmtChild(
s,
() => true,
(child) => !inStmt(child),
);
return body.some(inStmt);
}
function enterLoopHeader(emitter: LlEmitter, header: string): void {
const dispatch = emitter.speculationHeader;
emitter.speculationHeader = null;
@@ -206,7 +206,7 @@ test("a target CPU level reaches every function attribute group and nothing else
const plain = emitLlvmModule(module);
expect(plain).not.toContain("target-cpu");
const v3 = emitLlvmModule(module, { targetCpu: "x86-64-v3" });
expect(v3).toMatch(/^attributes #0 = \{ sanitize_address "target-cpu"="x86-64-v3" \}$/m);
expect(v3).toMatch(/^attributes #0 = \{ sanitize_address uwtable "target-cpu"="x86-64-v3" \}$/m);
expect(v3.replaceAll(' "target-cpu"="x86-64-v3"', "")).toBe(plain);
expect(emitLlvmModule(module, { targetCpu: "baseline" })).toBe(plain);
});
@@ -215,13 +215,21 @@ test("only sanitized builds mark the emitted functions sanitize_address", () =>
const module = moduleFor([call()]);
const sanitized = emitLlvmModule(module);
expect(emitLlvmModule(module, { sanitizeAttributes: true })).toBe(sanitized);
expect(sanitized).toMatch(/^attributes #0 = \{ sanitize_address \}$/m);
expect(sanitized).toMatch(/^attributes #0 = \{ sanitize_address uwtable \}$/m);
const plain = emitLlvmModule(module, { sanitizeAttributes: false });
expect(plain).not.toContain("sanitize_address");
// A group needs an attribute: an ignored string attribute stands in.
expect(plain).toMatch(/^attributes #0 = \{ "scriptc-function" \}$/m);
expect(plain).toMatch(/^attributes #0 = \{ uwtable \}$/m);
const v3 = emitLlvmModule(module, { sanitizeAttributes: false, targetCpu: "x86-64-v3" });
expect(v3).toMatch(/^attributes #0 = \{ "target-cpu"="x86-64-v3" \}$/m);
expect(v3).toMatch(/^attributes #0 = \{ uwtable "target-cpu"="x86-64-v3" \}$/m);
});
test("native functions get unwind tables and wasm32-wasi functions do not", () => {
const module = moduleFor([call()]);
expect(emitLlvmModule(module)).toMatch(/^attributes #0 = \{ sanitize_address uwtable \}$/m);
const wasi = emitLlvmModule(module, { wasi: true, pointerBits: 32, sanitizeAttributes: false });
expect(wasi).not.toContain("uwtable");
// A group needs an attribute: an ignored string attribute stands in.
expect(wasi).toMatch(/^attributes #0 = \{ "scriptc-function" \}$/m);
});
test("inline RC inlines every fast path at speed and keeps box releases as calls in release", () => {
+387 -35
View File
@@ -1,4 +1,5 @@
import type { TargetCpu } from "../target-cpu.js";
import { loopEntryInlines } from "./loop-entry-versions.js";
import type { FunctionLayout } from "./function-layout.js";
import { relieveHeapPressure } from "../../heap-pressure.js";
import { wasmBoundsChecks } from "./wasm-modules.js";
@@ -27,7 +28,7 @@ import {
streamTypedRefEligible,
undefinedArmTag,
} from "../../ir/analysis.js";
import { loopTestsEveryIteration } from "../loop-polls.js";
import { containsLoop, loopTestsEveryIteration } from "../loop-polls.js";
import type {
IrBytesElem,
IrExpr,
@@ -129,6 +130,8 @@ import {
} from "./int32-abi.js";
import { type Int32Local } from "./int32-locals.js";
import { findIntegerClones, NO_INTEGER_CLONES, type IntegerClones } from "./integer-clones.js";
import { writeRepresentationReport } from "./repr-report.js";
import { heapCensusOf, type HeapCensus } from "./heap-census.js";
import { exactInteger, narrowInteger, widenInteger, type LlInteger } from "./integer-values.js";
import { findInitializerBindings } from "../../ir/initializer-bindings.js";
import { findConstantNumericTables, type ConstantNumericTable } from "../../ir/constant-tables.js";
@@ -343,6 +346,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 +387,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 +434,7 @@ import {
fieldAliasMetadata,
isStackUnionSource,
} from "./receivers.js";
import { findLoopCallees } from "./loop-callees.js";
export { LlvmUnsupportedError } from "./unsupported.js";
@@ -465,6 +479,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;
@@ -782,7 +799,9 @@ function emitModuleText(input: IrModule, options: LlvmTargetOptions): string[] {
for (let excluded = options.unwindExcluded ?? new Set<string>(); ;) {
const emitter = moduleEmitter(input, { ...options, unwindExcluded: excluded });
try {
return emitter.emitParts();
const parts = emitter.emitParts();
writeRepresentationReport(emitter);
return parts;
} catch (error) {
if (!(error instanceof UnwindAbiViolation)) throw error;
const names = new Set(excluded);
@@ -829,6 +848,11 @@ const NO_BORROWED: ReadonlySet<number> = new Set();
/** The nodes that write a binding by its id (numberGlobalWriters). */
const GLOBAL_WRITE_KINDS: readonly IrNodeKind[] = ["assign", "varDecl", "assignExpr", "incDec"];
/** workerLoopBudget's answer for a loop that polls on every iteration
* instead of counting down in a slot. */
const POLL_EVERY_ITERATION = "%worker.poll.every";
/** Runtime library calls (readsLoopMetrics). */
const LIB_CALL_KINDS: readonly IrNodeKind[] = ["libCall"];
@@ -843,16 +867,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;
@@ -1274,11 +1305,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<string>();
/** Some function in the module is lowered as a coroutine. */
usesCoroutines = false;
@@ -1488,6 +1521,9 @@ export class LlEmitter {
/** Functions that check the native stack guard on entry
* (stackCheckedFunctions); null where the runtime has no guard. */
readonly stackChecks: ReadonlySet<string> | null;
/** Small leaf functions called only from loops, whose direct calls are
* `alwaysinline` (loop-callees.ts). */
readonly loopCallees: ReadonlySet<string>;
/** Static types whose values can be published (@scriptc/threads): their
* stores carry the frozen-object guard. Null without publish calls. */
readonly publishedTypes: PublishedTypes | null;
@@ -1582,6 +1618,8 @@ export class LlEmitter {
readonly inlineUnions: InlineUnionFields;
/** Unions whose values are nullable arm pointers (nullable-unions.ts). */
readonly nullableUnions: NullableUnions;
/** The counters of a build instrumented for the heap census, or null. */
readonly heapCensus: HeapCensus | null;
/** The union table without nullable unions: the stack-box analyses. */
readonly boxedUnionsById: Map<string, IrUnionDef>;
readonly recordsById = new Map<string, IrRecordShape>();
@@ -1674,6 +1712,8 @@ export class LlEmitter {
* commit back into their objects. Library builds keep their commits:
* their values cross a native boundary. */
readonly liveViewsReadOnly: boolean;
/** IrModule.sloppyDynWrites: untyped writes in sloppy-mode code. */
readonly sloppyDynWrites: ReadonlySet<string>;
/** IrModule.foreignElementKeys: element keys whose arrays can hold
* SCR_ARR_FOREIGN elements. */
readonly foreignElementKeys: ReadonlySet<string>;
@@ -1902,9 +1942,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;
@@ -1969,6 +2012,8 @@ export class LlEmitter {
options.debugSources === undefined,
this.nodeIndex,
);
this.heapCensus = heapCensusOf(mod, this.nullableUnions);
for (const decl of this.heapCensus?.declarations() ?? []) this.declare(decl);
this.mockTimers = this.runtimeFeatures.mockTimers;
this.debug =
options.debugSources === undefined
@@ -1982,6 +2027,7 @@ export class LlEmitter {
);
this.constantNumericTables = findConstantNumericTables(mod);
this.untypedIntegrity = mod.untypedIntegrity === true;
this.sloppyDynWrites = new Set(mod.sloppyDynWrites ?? []);
this.liveViewsReadOnly =
mod.lib === undefined &&
!this.untypedIntegrity &&
@@ -2076,6 +2122,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.
@@ -2324,7 +2371,41 @@ export class LlEmitter {
virtualEntry: (implFn) => this.virtualEntry(implFn),
programLifetime: this.programLifetime,
regionAliasTag: (word) => this.memoryAliasAttachment(`region.${word}`),
heapCensus: this.heapCensus,
};
// An instance of a class that extends Array is an array: a slot of its
// element array type can hold it, and the collector visits it there as
// an array, which has a collector header when its element ops trace
// (scr_arr_make_ops_kind). Such a class carries a header too, and so
// does the rest of its hierarchy. A class whose elements are instances
// of another such class follows that class, so repeat until no class
// adds one.
const arrayClasses = (mod.classes ?? []).filter((c) => c.arrayElem !== undefined);
if (arrayClasses.length > 0) {
const baseOf = new Map((mod.classes ?? []).map((c) => [c.name, c.base] as const));
const rootOf = (name: string): string => {
let root = name;
for (
let base = baseOf.get(root);
base !== undefined && baseOf.has(base);
base = baseOf.get(root)
)
root = base;
return root;
};
for (let changed = true; changed;) {
changed = false;
for (const cls of arrayClasses) {
if (this.tracedShapes.has(`object:${cls.name}`)) continue;
if (traceAdapter(this.shapeHost, { kind: "array", elem: cls.arrayElem! }) === null)
continue;
const root = rootOf(cls.name);
for (const member of mod.classes ?? [])
if (rootOf(member.name) === root) this.tracedShapes.add(`object:${member.name}`);
changed = true;
}
}
}
this.walkers = new LlWalkers(this.shapeHost);
this.dyn = new LlDyn(this.shapeHost);
for (const g of mod.globals ?? []) {
@@ -2412,6 +2493,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
@@ -2540,7 +2655,8 @@ export class LlEmitter {
emitParts(): string[] {
const parts = emitParts(this);
const bridged = this.unwindAbi.size > 0 ? bridgePlainUnwindCalls(parts) : parts;
return this.cpuDispatch ? cloneForCpuDispatch(bridged) : bridged;
const out = this.cpuDispatch ? cloneForCpuDispatch(bridged) : bridged;
return this.heapCensus === null ? out : [...out, this.heapCensus.definitions().join("\n")];
}
/** The dispose-observer gates the emitted frees and record writers test
@@ -2713,19 +2829,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 +2858,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 +3280,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 +3650,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 +3663,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 +3697,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 +4024,17 @@ 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), and so does a function called
// only from a loop's entry-range version (loop-entry-versions.ts).
// Debug builds keep the call.
if (this.debug === null && (this.loopCallees.has(callee) || loopEntryInlines(this, 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;
@@ -4018,10 +4165,22 @@ export class LlEmitter {
);
}
/** The countdown slot of `loop`'s cancellation poll (emitWorkerLoopCheck),
* or null when the loop does not poll. An ELF worker executable's poll is
* one ordered load of the thread's own pending word. A loop nest keeps
* one countdown per loop, which its register pressure often leaves in
* memory, so the loops of a nest there poll on every iteration and get
* POLL_EVERY_ITERATION instead of a slot. A loop on its own keeps its
* countdown in a register, which costs less than the load. */
workerLoopBudget(
loop: Extract<IrStmt, { kind: "while" | "doWhile" | "for" | "forOf" }>,
): string | null {
if (!this.workerLoopPolls(loop)) return null;
if (
this.executableTls &&
(this.jumpTargets.some((target) => target.kind === "loop") || containsLoop(loop.body))
)
return POLL_EVERY_ITERATION;
const slot = this.B.slot();
this.B.entryAllocas.push(`${slot} = alloca i32 ; worker cancellation budget`);
this.B.line(`store i32 0, ptr ${slot}`);
@@ -4126,6 +4285,10 @@ export class LlEmitter {
* stay in the loop. */
emitWorkerLoopCheck(slot: string | null): void {
if (slot === null) return;
if (slot === POLL_EVERY_ITERATION) {
this.emitWorkerPoll();
return;
}
const B = this.B;
const budget = B.tmp(),
due = B.tmp(),
@@ -4145,9 +4308,9 @@ export class LlEmitter {
}
/** The cancellation poll itself. Calls and throwing operations retain
* their own checks. Loops amortize this extra poll across bounded
* batches of iterations so scalar work does not cross the runtime ABI on
* every iteration. */
* their own checks. Outside ELF worker executables, loops amortize this
* extra poll across bounded batches of iterations so scalar work does not
* cross the runtime ABI on every iteration. */
emitWorkerPoll(): void {
this.emitPendingCheck(undefined, true);
}
@@ -4533,6 +4696,7 @@ export class LlEmitter {
`${p} = getelementptr inbounds %${classStructSym(className)}, ptr ${objName}, i64 0, i32 ${index}`,
);
this.markFieldPointer(p, field);
this.heapCensus?.countAccess((line) => this.B.line(line), className, field);
if (compressed) this.compressedFieldPtrs.add(p);
return { ptr: p, type, inline: this.inlineUnions.classField(className, field), compressed };
}
@@ -5212,7 +5376,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
@@ -5251,7 +5415,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}`;
}
@@ -6319,6 +6483,7 @@ export class LlEmitter {
type: local.type,
slot: true,
...(local.boxed ? { boxed: true } : {}),
...(local.arena === true ? { arena: "drop" as const } : {}),
});
}
return result;
@@ -7413,9 +7578,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<Node, string>();
/** The frames emitStmtBody opens for whole statements (arenas only). */
readonly statementFrames = new Set<LlValue[]>();
/** 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<IrExpr, { kind: "call" | "new" }> {
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<string, { root: Node; type: IrType }[]>();
/** 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<string>();
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));
@@ -3,8 +3,8 @@ import { InternalCompilerError } from "../../errors.js";
import { emitBorrowedInput } from "./borrowed-inputs.js";
import type { BytesHeader } from "./bytes-headers.js";
import { BYTES_ELEMENT_SIZE, F64, type IrBytesElem, type IrExpr } from "../../ir/ir.js";
import type { LlvmEmitterContext, LlValue } from "./expr-context.js";
import { F64_INF, exactIndexLines, f64Lit, sizeToDouble } from "./common.js";
import type { LlvmEmitterContext, LlValue, OffsetIndex } from "./expr-context.js";
import { F64_INF, exactIndexLines, exactOffsetIndexLines, f64Lit, sizeToDouble } from "./common.js";
import { alwaysExactInteger, exactInteger } from "./integer-values.js";
import { byteNumberAccess } from "../../ir/byte-numbers.js";
import { emitByteNumber } from "./byte-numbers.js";
@@ -77,6 +77,27 @@ export function emitIntegerLoopIndex(host: LlvmEmitterContext, expr: IrExpr): st
return index;
}
/** The base and offset of a sum `base + c`, `c + base` or `base - c`,
* where `base` is a number local and `c` an integer literal of magnitude
* below 2^31, given the operand values the sum was computed from. Null for
* other sums. */
export function offsetIndex(expr: IrExpr, left: LlValue, right: LlValue): OffsetIndex | null {
if (expr.kind !== "bin" || expr.type.kind !== "f64" || (expr.op !== "+" && expr.op !== "-"))
return null;
const literal = (e: IrExpr): number | null =>
e.kind === "numLit" && Number.isInteger(e.value) && Math.abs(e.value) < 2 ** 31
? e.value
: null;
const local = (e: IrExpr): boolean => e.kind === "varRef" && e.type.kind === "f64";
const constant = literal(expr.right);
if (constant !== null && local(expr.left))
return { base: left.name, offset: expr.op === "+" ? constant : -constant };
const leading = literal(expr.left);
if (expr.op === "+" && leading !== null && local(expr.right))
return { base: right.name, offset: leading };
return null;
}
export function emitBytesIndex(
host: LlvmEmitterContext,
receiver: string,
@@ -134,10 +155,33 @@ export function emitBytesIndex(
B.line(`${ok} = and i1 ${nanIndex.ordered}, ${below}`);
wide = nanIndex.wide;
} else {
const exact = exactIndexLines(index, len, host.sizeType, () => B.tmp());
for (const line of exact.lines) B.line(line);
ok = exact.ok;
wide = exact.wide;
const offset = value.offsetIndex;
if (offset) {
const exact = exactOffsetIndexLines(offset.base, offset.offset, len, host.sizeType, () =>
B.tmp(),
);
for (const line of exact.lines) B.line(line);
// A fractional base: the sum itself decides, as it would without the
// base's conversion.
const from = B.currentLabel();
const sum = B.newLabel("bytes.index.sum");
const join = B.newLabel("bytes.index.join");
B.condBr(exact.integral, join, sum);
B.startBlock(sum);
const whole = exactIndexLines(index, len, host.sizeType, () => B.tmp());
for (const line of whole.lines) B.line(line);
B.br(join);
B.startBlock(join);
ok = B.tmp();
wide = B.tmp();
B.line(`${ok} = phi i1 [ ${exact.ok}, %${from} ], [ ${whole.ok}, %${sum} ]`);
B.line(`${wide} = phi i64 [ ${exact.wide}, %${from} ], [ ${whole.wide}, %${sum} ]`);
} else {
const exact = exactIndexLines(index, len, host.sizeType, () => B.tmp());
for (const line of exact.lines) B.line(line);
ok = exact.ok;
wide = exact.wide;
}
}
const invalid = B.newLabel("bytes.index.invalid");
const valid = B.newLabel("bytes.index.valid");
@@ -1104,7 +1104,8 @@ 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)}()`);
host.heapCensus?.countNew((line) => B.line(line), e.className);
const out = host.own({ name: o, type: e.type });
emitConstruction(host, e, o, ctor);
return out;
@@ -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(
@@ -30,9 +30,21 @@ export interface LlValue {
* read, a local that only holds those, or a sum, difference or product of
* them), so integer consumers need no conversion of the double. */
nanInteger?: string;
/** The sum `base + offset` of a number local and an integer literal of
* magnitude below 2^31: the base as the sum read it. Typed array indexes
* convert it instead of the sum (expr-bytes.ts). */
offsetIndex?: OffsetIndex;
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 OffsetIndex {
base: string;
offset: number;
}
export interface WideInteger {
+464 -437
View File
@@ -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
@@ -5,6 +5,7 @@ import { isRefCounted, type IrExpr, type IrStrCmpOp, type IrType } from "../../i
import { mangleRecordClone, mangleRecordNew } from "../mangle.js";
import { arrNewCall, elemAccess } from "./shapes.js";
import { LlvmUnsupportedError } from "./unsupported.js";
import { offsetIndex } from "./expr-bytes.js";
import type { LlvmEmitterContext, ExprOf, LlValue } from "./expr-context.js";
import { BYTES_ELEM_NUM, closureIdentityEqual, f64Lit } from "./common.js";
@@ -432,11 +433,13 @@ export function emitOperatorExpr(
const wideInteger = wideArithmetic(host, e.op, l, li, r, ri);
// The integer of an exact integer or NaN, for index and loop guards.
const nan = emitNanArithmetic(host, e, l, r, li, ri);
if (wrapped !== undefined || wideInteger || nan) {
const offset = offsetIndex(e, l, r);
if (wrapped !== undefined || wideInteger || nan || offset) {
const value: LlValue = { name: t, type: e.type };
if (nan) Object.assign(value, nan);
if (wrapped !== undefined) Object.assign(value, wrapped);
if (wideInteger) value.wideInteger = wideInteger;
if (offset) value.offsetIndex = offset;
return value;
}
} else {
@@ -1080,16 +1083,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 +1370,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)}`);
@@ -141,7 +141,7 @@ test("live record capsules bind field hooks in snapshot order", () => {
const llvm = emitLlvmModule(module, { pointerBits: 64 });
const tables = [
...llvm.matchAll(
/^@(\S+)_rops = internal global \{ i64, ptr, ptr, ptr, i8, ptr, ptr \} \{ i64 (\d+), /gm,
/^@(\S+)_rops = internal global \{ i64, ptr, ptr, ptr, i8, ptr, ptr, ptr \} \{ i64 (\d+), /gm,
),
];
// root and leaf get field hooks; the record with an index-like field name
@@ -1484,6 +1484,10 @@ function liveRecordOps(
G.startBlock(bad);
G.terminate(`unreachable`);
// Only a program that writes through a dynamic view needs the store
// hook (and its checked conversions), as for the commit adapter.
const setDefs = host.liveViewsReadOnly ? [] : liveRecordSet(host, t, `${snapshot}_set`, fields);
const literals = fields.map((field) => `ptr ${host.internLiteral(field.name)}`);
host.resolveThunkDefs.push(
`define internal zeroext i1 @${has}(ptr %p, ${host.sizeType} %i) ${FN_ATTRS} { ; live field presence of ${typeKey(t)}`,
@@ -1494,13 +1498,90 @@ function liveRecordOps(
G.render(),
`}`,
``,
...setDefs,
`@${names} = internal constant [${fields.length} x ptr] [${literals.join(", ")}]`,
// Mutable: the runtime fills the trailing key-array slot on first use.
`@${ops} = internal global { ${host.sizeType}, ptr, ptr, ptr, i8, ptr, ptr } { ${host.sizeType} ${fields.length}, ptr @${names}, ptr @${has}, ptr @${get}, i8 ${optional.length === 0 ? 1 : 0}, ptr null, ptr null }`,
// Mutable: the runtime fills the key-array slot on first use.
`@${ops} = internal global { ${host.sizeType}, ptr, ptr, ptr, i8, ptr, ptr, ptr } { ${host.sizeType} ${fields.length}, ptr @${names}, ptr @${has}, ptr @${get}, i8 ${optional.length === 0 ? 1 : 0}, ptr null, ptr null, ptr ${setDefs.length ? `@${snapshot}_set` : "null"} }`,
``,
);
}
/** The `set` hook of a live record's ops table: stores a dynamic write of
* present field `index` into the native record in place, so the capsule
* never becomes a snapshot for it. A value the field type does not accept
* answers false with no side effects, leaving the snapshot path to raise
* the commit's diagnostic; an inline union field always does. */
function liveRecordSet(
host: LlvmEmitterContext,
t: IrType & { kind: "record" },
sym: string,
fields: readonly IrRecordShape["fields"][number][],
): string[] {
const shape = host.recordsById.get(t.shapeId)!;
const struct = mangleRecordStruct(t.shapeId);
const S = new BlockBuilder();
const labels = fields.map(() => S.newLabel("live.record.store"));
const refuse = S.newLabel("live.record.refuse");
S.terminate(
`switch ${host.sizeType} %i, label %${refuse} [ ${fields
.map((_, position) => `${host.sizeType} ${position}, label %${labels[position]}`)
.join(" ")} ]`,
);
fields.forEach((field, position) => {
S.startBlock(labels[position]!);
const inline = host.inlineUnions.record(field.type);
if (inline) {
S.br(refuse);
return;
}
const fits = S.tmp();
S.line(`${fits} = call zeroext i1 @${host.dyn.dynMatchHelper(field.type)}(ptr %v)`);
const check = S.newLabel("live.record.check");
S.condBr(fits, check, refuse);
S.startBlock(check);
const valueTy = host.llType(field.type);
const next = S.tmp();
S.line(
`${next} = call ${valueTy === "i1" ? "zeroext " : ""}${valueTy} @${host.dyn.dynCheckHelper(field.type)}(ptr %v, ptr null)`,
);
// A conversion that throws has already raised the write's error.
const pending = S.tmp();
for (const line of host.pendingTestLines(pending)) S.line(line.trimStart());
const write = S.newLabel("live.record.write");
const done = S.newLabel("live.record.done");
S.condBr(pending, done, write);
S.startBlock(write);
const index = shape.fields.indexOf(field) + host.nullableUnions.fieldBase(t.shapeId);
const fieldPtr = S.tmp();
S.line(`${fieldPtr} = getelementptr inbounds %${struct}, ptr %p, i64 0, i32 ${index}`);
const fieldTy = llFieldType(field.type, false);
let stored = next;
if (fieldTy === "i8") {
stored = S.tmp();
S.line(`${stored} = zext i1 ${next} to i8`);
}
if (isRefCounted(field.type)) {
const old = S.tmp();
S.line(`${old} = load ptr, ptr ${fieldPtr}`);
S.line(`store ptr ${stored}, ptr ${fieldPtr} ; takes ownership`);
S.line(`call void ${releaseSym(host.shapeHost, field.type)}(ptr ${old})`);
} else {
S.line(`store ${fieldTy} ${stored}, ptr ${fieldPtr}`);
}
S.br(done);
S.startBlock(done);
S.terminate(`ret i1 true`);
});
S.startBlock(refuse);
S.terminate(`ret i1 false`);
return [
`define internal zeroext i1 @${sym}(ptr %p, ${host.sizeType} %i, ptr %v) ${FN_ATTRS} { ; live field store into ${typeKey(t)}`,
S.render(),
`}`,
``,
];
}
/** `@<snapshot>_rops` for a shape with orderVariants: a record-ops table
* whose `select` hook answers the `_o<i>_rops` table (liveRecordOps) of the
* record's current key order, which a write of an optional key can change
@@ -1524,7 +1605,7 @@ function liveRecordOrderSelect(
B.render(),
`}`,
``,
`@${snapshot}_rops = internal global { ${host.sizeType}, ptr, ptr, ptr, i8, ptr, ptr } { ${host.sizeType} 0, ptr null, ptr null, ptr null, i8 0, ptr null, ptr @${snapshot}_select }`,
`@${snapshot}_rops = internal global { ${host.sizeType}, ptr, ptr, ptr, i8, ptr, ptr, ptr } { ${host.sizeType} 0, ptr null, ptr null, ptr null, i8 0, ptr null, ptr @${snapshot}_select, ptr null }`,
``,
);
}
@@ -0,0 +1,241 @@
/* The heap census. A program built with SCRIPTC_HEAP_CENSUS set counts, for
* ranking representation work:
* - the instances of each class that `new` or a class value constructs,
* with the class's size;
* - for each array-typed class field, its stores (with the stored array's
* length in buckets and the total of those lengths), the stores whose
* value is an array literal written at the store, and its accesses;
* - for each record-typed class field, its stores and accesses.
* Accesses count every field address the backend computes for the field,
* so a store is also an access. At exit the program writes one
* tab-separated line per counter to the file the run's SCRIPTC_HEAP_CENSUS
* names, or to stderr. Linux only: the runtime calls the writer through a
* weak reference, as it does the profile writer. Counters are module
* globals updated with monotonic atomic adds, so they include every
* thread's work. Ordinary builds emit none of it. */
import type { IrClassDef, IrExpr, IrModule, IrType } from "../../ir/ir.js";
import { classStructSym } from "./classes.js";
import { llvmBytes, wtf8Bytes } from "../literals.js";
import type { NullableUnions } from "./nullable-unions.js";
/** Whether this build counts the heap census: SCRIPTC_HEAP_CENSUS is set
* and nonempty. The executable cache keys on it. */
export function heapCensusRequested(env: NodeJS.ProcessEnv = process.env): boolean {
const flag = env["SCRIPTC_HEAP_CENSUS"];
return flag !== undefined && flag !== "";
}
/** The census of the module being built, or null when it is off. */
export function heapCensusOf(mod: IrModule, unions: NullableUnions): HeapCensus | null {
if (!heapCensusRequested() || mod.lib !== undefined) return null;
return new HeapCensus(mod, unions);
}
/** A NUL-terminated i8 array constant of `text`. */
function cString(text: string): string {
const bytes = wtf8Bytes(text);
return `[${bytes.length + 1} x i8] c"${llvmBytes(bytes)}"`;
}
const LENGTH_BUCKETS = ["len0", "len1", "len2", "len3-4", "len5-8", "len9-16", "len17+"];
/** Upper bounds of LENGTH_BUCKETS but the last. */
const BUCKET_LIMITS = [0, 1, 2, 4, 8, 16];
interface FieldCounters {
/** First counter: stores, accesses, then (arrays) the literal stores,
* the length total and the buckets. */
base: number;
array: boolean;
/** The stored value is a pointer that is NULL for the union's unit arm. */
nullable: boolean;
}
export class HeapCensus {
private readonly names: string[] = [];
private readonly sizes: (string | null)[] = [];
private readonly instances = new Map<string, number>();
private readonly fields = new Map<string, FieldCounters>();
constructor(mod: IrModule, unions: NullableUnions) {
for (const cls of mod.classes ?? []) {
this.instances.set(cls.name, this.add(`new\t${cls.name}\t`, classStructSym(cls.name)));
for (const field of cls.fields) this.addField(cls, field.name, field.type, unions);
}
}
/** Counts one instance of `className`. */
countNew(line: (text: string) => void, className: string): void {
for (const text of this.newLines(className)) line(text);
}
/** The lines that count one instance of `className`: a `new`, or a
* construct thunk for class values (`newValue`). */
newLines(className: string): string[] {
const index = this.instances.get(className);
return index === undefined ? [] : [this.increment(index, "1")];
}
/** Counts an access of a field; true when the field is counted. */
countAccess(line: (text: string) => void, className: string, field: string): void {
const counters = this.fields.get(`${className}\0${field}`);
if (counters !== undefined) line(this.increment(counters.base + 1, "1"));
}
/** Counts a store of `value` (an LLVM pointer, computed from `source`)
* into a field: its length when the field holds arrays. `tmp` names fresh
* temporaries. */
countStore(
line: (text: string) => void,
tmp: () => string,
className: string,
field: string,
source: IrExpr,
value: string | null,
): void {
const counters = this.fields.get(`${className}\0${field}`);
if (counters === undefined) return;
line(this.increment(counters.base, "1"));
if (!counters.array) return;
const literal = source.kind === "unionWrap" ? source.value : source;
if (literal.kind === "arrayLit") line(this.increment(counters.base + 2, "1"));
if (value === null) return;
// A NULL pointer (the unit arm) counts as length 0: its length is read
// through a select that points NULL at a zeroed array header.
let array = value;
if (counters.nullable) {
const isNull = tmp();
array = tmp();
line(`${isNull} = icmp eq ptr ${value}, null`);
line(`${array} = select i1 ${isNull}, ptr @sc_census_zero_arr, ptr ${value}`);
}
const lenPtr = tmp(),
len32 = tmp(),
len = tmp();
line(`${lenPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 1`);
line(`${len32} = load i32, ptr ${lenPtr}`);
line(`${len} = zext i32 ${len32} to i64`);
line(this.increment(counters.base + 3, len));
// The bucket: the number of limits below the length.
let bucket = "0";
for (const limit of BUCKET_LIMITS) {
const above = tmp(),
step = tmp(),
next = tmp();
line(`${above} = icmp ugt i64 ${len}, ${limit}`);
line(`${step} = zext i1 ${above} to i64`);
line(`${next} = add i64 ${bucket}, ${step}`);
bucket = next;
}
const slot = tmp();
line(`${slot} = add i64 ${bucket}, ${counters.base + 4}`);
const ptr = tmp();
line(
`${ptr} = getelementptr inbounds [${this.names.length} x i64], ptr @sc_census, i64 0, i64 ${slot}`,
);
line(`atomicrmw add ptr ${ptr}, i64 1 monotonic`);
}
/** The module definitions: the counters, their names, the zero array
* header NULL stores read their length from, and the writer the runtime
* calls at exit (scr_census_write). */
definitions(): string[] {
const n = this.names.length;
const out = [`@sc_census = internal global [${n} x i64] zeroinitializer`];
// A zeroed %ScrArr: length 0.
out.push(`@sc_census_zero_arr = internal constant %ScrArr zeroinitializer`);
const names = this.names.map((name, i) => {
const bytes = cString(name);
out.push(`@sc_census_name.${i} = private unnamed_addr constant ${bytes}`);
return `ptr @sc_census_name.${i}`;
});
out.push(`@sc_census_names = internal constant [${n} x ptr] [${names.join(", ")}]`);
const sizes = this.sizes.map((sym) =>
sym === null ? "i64 0" : `i64 ptrtoint (ptr getelementptr (%${sym}, ptr null, i32 1) to i64)`,
);
out.push(`@sc_census_sizes = internal constant [${n} x i64] [${sizes.join(", ")}]`);
const env = cString("SCRIPTC_HEAP_CENSUS");
out.push(`@sc_census_env = private unnamed_addr constant ${env}`);
out.push(`@sc_census_mode = private unnamed_addr constant ${cString("w")}`);
out.push(`@sc_census_fmt = private unnamed_addr constant ${cString("%s\t%llu\t%llu\n")}`);
out.push(
`define void @scr_census_write() {`,
`entry:`,
` %path = call ptr @getenv(ptr @sc_census_env)`,
` %named = icmp ne ptr %path, null`,
` br i1 %named, label %open, label %err`,
`open:`,
` %file = call ptr @fopen(ptr %path, ptr @sc_census_mode)`,
` %opened = icmp ne ptr %file, null`,
` br i1 %opened, label %write, label %err`,
`err:`,
` %stderr = load ptr, ptr @stderr`,
` br label %write`,
`write:`,
` %out = phi ptr [ %file, %open ], [ %stderr, %err ]`,
` br label %loop`,
`loop:`,
` %i = phi i64 [ 0, %write ], [ %next, %loop ]`,
` %namep = getelementptr inbounds [${n} x ptr], ptr @sc_census_names, i64 0, i64 %i`,
` %name = load ptr, ptr %namep`,
` %countp = getelementptr inbounds [${n} x i64], ptr @sc_census, i64 0, i64 %i`,
` %count = load atomic i64, ptr %countp monotonic, align 8`,
` %sizep = getelementptr inbounds [${n} x i64], ptr @sc_census_sizes, i64 0, i64 %i`,
` %size = load i64, ptr %sizep`,
` %bytes = mul i64 %count, %size`,
` call i32 (ptr, ptr, ...) @fprintf(ptr %out, ptr @sc_census_fmt, ptr %name, i64 %count, i64 %bytes)`,
` %next = add i64 %i, 1`,
` %done = icmp eq i64 %next, ${n}`,
` br i1 %done, label %end, label %loop`,
`end:`,
` call i32 @fflush(ptr %out)`,
` ret void`,
`}`,
);
return out;
}
/** External declarations the writer uses. */
declarations(): string[] {
return [
"declare ptr @getenv(ptr)",
"declare ptr @fopen(ptr, ptr)",
"declare i32 @fprintf(ptr, ptr, ...)",
"declare i32 @fflush(ptr)",
"@stderr = external global ptr",
];
}
private add(name: string, sizeSym: string | null = null): number {
this.names.push(name);
this.sizes.push(sizeSym);
return this.names.length - 1;
}
private addField(cls: IrClassDef, field: string, type: IrType, unions: NullableUnions): void {
let array = type.kind === "array";
let nullable = false;
let record = type.kind === "record";
if (type.kind === "union") {
const single = unions.single(type.unionId);
if (single !== null && single.nullTag < 0) {
array = single.arm.kind === "array";
record = single.arm.kind === "record";
nullable = true;
}
}
if (!array && !record) return;
const key = `${cls.name}\t${field}`;
const base = this.add(`store\t${key}`);
this.add(`access\t${key}`);
if (array) {
this.add(`literal\t${key}`);
this.add(`elements\t${key}`);
for (const bucket of LENGTH_BUCKETS) this.add(`${bucket}\t${key}`);
}
this.fields.set(`${cls.name}\0${field}`, { base, array, nullable });
}
private increment(index: number, by: string): string {
return `atomicrmw add ptr getelementptr inbounds ([${this.names.length} x i64], ptr @sc_census, i64 0, i64 ${index}), i64 ${by} monotonic`;
}
}
@@ -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;
}
@@ -3,6 +3,7 @@ import type { IrExpr, IrFunction, IrLocal, IrModule, IrStmt, IrType } from "../.
import { mangleFunction } from "../mangle.js";
import { BlockBuilder } from "./blocks.js";
import { SCR_ARR_FIELD } from "./common.js";
import { SCR_ELEM_F64, SCR_ELEM_I32 } from "./dense-array-access.js";
/** Speculative integer clones of pure number-valued functions.
*
@@ -191,6 +192,8 @@ function cloneShape(fn: IrFunction, borrowed?: BorrowedParameter): Set<string> |
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)
);
@@ -268,6 +271,9 @@ function callsMatch(fn: IrFunction, byName: ReadonlyMap<string, IrFunction>): bo
case "arrayGet":
visit(e.index);
return;
case "arrIntrinsic":
if (e.method === "getNumber") visit(e.args[0]!);
return;
default:
return;
}
@@ -432,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;
}
@@ -1020,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<IrExpr, { kind: "arrayGet" }>): 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();
@@ -1044,9 +1052,39 @@ class CloneEmitter {
);
B.line(`${data} = load ptr, ptr ${dataField}`);
B.line(`${offset} = zext i32 ${index} to i64`);
// A number array keeps 8-byte doubles, or 4-byte int32 slots when it is
// proven to hold only int32s (SCR_ELEM_I32); any other storage bails.
const kindField = B.tmp();
const kind = B.tmp();
const isInt32 = B.tmp();
B.line(
`${kindField} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 ${SCR_ARR_FIELD.elem}`,
);
B.line(`${kind} = load i8, ptr ${kindField}`);
B.line(`${isInt32} = icmp eq i8 ${kind}, ${SCR_ELEM_I32}`);
const int32Slots = B.newLabel("elem.i32");
const doubleSlots = B.newLabel("elem.f64");
const join = B.newLabel("elem.join");
B.condBr(isInt32, int32Slots, doubleSlots);
B.startBlock(int32Slots);
const int32Slot = B.tmp();
const int32 = B.tmp();
B.line(`${int32Slot} = getelementptr inbounds i32, ptr ${data}, i64 ${offset}`);
B.line(`${int32} = load i32, ptr ${int32Slot}`);
B.br(join);
B.startBlock(doubleSlots);
const isDouble = B.tmp();
B.line(`${isDouble} = icmp eq i8 ${kind}, ${SCR_ELEM_F64}`);
this.require(isDouble);
B.line(`${slot} = getelementptr inbounds double, ptr ${data}, i64 ${offset}`);
B.line(`${number} = load double, ptr ${slot}`);
return this.exactInt32(number);
const exact = this.exactInt32(number);
const doubleEnd = B.currentLabel();
B.br(join);
B.startBlock(join);
const value = B.tmp();
B.line(`${value} = phi i32 [ ${int32}, %${int32Slots} ], [ ${exact}, %${doubleEnd} ]`);
return value;
}
/** The int32 the double `number` equals bit for bit (never -0, NaN, a
@@ -1157,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();
+257 -63
View File
@@ -4,13 +4,12 @@
* A property site in a dynamic-tier function owns one zeroed thread-local
* ScrJsIc record (`@sc_js_ic_N`). The code at the site is fixed:
*
* load ordinary object whose shape is entry 0's own-property shape:
* read entry `slot` inline unless it is an accessor; otherwise
* scr_js_get_probe (polymorphic, prototype hits, absent keys,
* misses)
* store ordinary object whose shape is entry 0's replace shape, outside
* an intact parse region, writable data entry: replace inline;
* otherwise scr_js_store_probe (adds, polymorphic, misses)
* load ordinary object whose layout is entry 0's own-property layout:
* read slot `slot` inline; otherwise scr_js_get_probe
* (polymorphic, prototype hits, absent keys, misses)
* store ordinary object whose layout is entry 0's replace layout,
* outside an intact parse region: replace inline; otherwise
* scr_js_store_probe (adds, polymorphic, misses)
* keyed dense array element at an integral number key: inline;
* otherwise scr_js_keyed_probe (string keys of ordinary objects)
* method dynInvoke's method read: scr_js_load_probe
@@ -22,16 +21,19 @@
* defines the operation's semantics; nothing is ever patched.
*
* Layout facts (64-bit only; 32-bit targets keep the helper calls):
* ScrDyn kind +8; v.obj {len +16, entries +32, region +72,
* shape +80}; v.arr {len +16, items +32, presence +56};
* v.num +16
* ScrDynEntry 48 bytes: value +16, accessor bit 0 of +40, writable +41
* ScrJsIc entries of 40 bytes {shape, target, holder, holder_shape,
* slot}; 168 bytes in all */
import { BOOL, DYN, F64, STRING, type IrExpr, type IrFunction } from "../../ir/ir.js";
* ScrDyn kind +8; v.obj {len +16, slots +32, region +72,
* layout +80}; v.arr {len +16, items +32, presence +56};
* v.num +16. Every property of an object with a layout is
* a writable data property (scr_dyn.h's layout mode);
* dictionary-mode objects have a null layout.
* v.typed_ref {ptr +16, type_key +40}.
* ScrJsIc entries of 40 bytes {layout, target, holder,
* holder_layout, slot}; 168 bytes in all */
import { BOOL, DYN, F64, STRING, type IrExpr, type IrFunction, type IrStmt } from "../../ir/ir.js";
import { jsIcMethodSites, type JsIcSite } from "../../ir/js-ic.js";
import { mangleBorrowedFunction } from "../mangle.js";
import { emitBorrowedInput } from "./borrowed-inputs.js";
import { classFieldBase, classFieldIndex } from "./classes.js";
import { DYN_KIND } from "./dyn.js";
import type { ExprOf, LlValue, LlvmEmitterContext } from "./expr-context.js";
import { JS_BITS } from "./jsval-encoding.js";
@@ -42,8 +44,6 @@ type Host = LlvmEmitterContext;
/** Words of one ScrJsIc record. */
export const JS_IC_WORDS = 21;
const ENTRY_STRIDE = 48;
/** The cache site a call is, when it is emitted in a dynamic-tier function
* of a 64-bit target. */
export function jsIcSiteOf(host: Host, e: ExprOf<"call">): JsIcSite | null {
@@ -86,34 +86,22 @@ 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 checks every cached shape against its object.) */
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 entry `slot` of object `d`'s entries. */
function entryAt(host: Host, d: string, slot: string): string {
/** The address of slot `slot` of layout-mode object `d`. */
function slotAt(host: Host, d: string, slot: string): string {
const B = host.B;
const entries = load(host, "ptr", d, 32);
const offset = B.tmp();
const entry = B.tmp();
B.line(`${offset} = mul i64 ${slot}, ${ENTRY_STRIDE}`);
B.line(`${entry} = getelementptr inbounds i8, ptr ${entries}, i64 ${offset}`);
return entry;
}
/** Branches to `miss` when the entry at `entry` is an accessor. */
function dataEntry(host: Host, entry: string, miss: string): void {
const B = host.B;
const flags = load(host, "i8", entry, 40);
const accessor = B.tmp();
const isAccessor = B.tmp();
B.line(`${accessor} = and i8 ${flags}, 1`);
B.line(`${isAccessor} = icmp ne i8 ${accessor}, 0`);
const data = B.newLabel("ic.data");
B.condBr(isAccessor, miss, data);
B.startBlock(data);
const slots = load(host, "ptr", d, 32);
const address = B.tmp();
B.line(`${address} = getelementptr inbounds ptr, ptr ${slots}, i64 ${slot}`);
return address;
}
/** The inline part of a load site, entered for an ordinary object `d`:
@@ -137,7 +125,7 @@ function inlineLoad(
B.br(miss);
return;
}
const shape = load(host, "ptr", d, 80);
const shape = load(host, "ptr", d, 80); // v.obj.layout
const present = B.tmp();
B.line(`${present} = icmp ne ptr ${shape}, null`);
let next = B.newLabel("ic.w");
@@ -160,9 +148,7 @@ function inlineLoad(
B.condBr(own, ownLabel, protoLabel);
const hit = (holder: string): void => {
const slot = load(host, "i64", ic, base + 32);
const entry = entryAt(host, holder, slot);
dataEntry(host, entry, miss);
const value = load(host, "ptr", entry, 16);
const value = load(host, "ptr", slotAt(host, holder, slot), 0);
const out = result(value);
incoming.push(`[ ${out}, %${B.currentLabel()} ]`);
B.br(join);
@@ -194,7 +180,7 @@ function inlineLoad(
/** The inline part of a store site that replaces an own data property,
* entered for an ordinary object `d` outside an intact parse region:
* tests the first `ways` cache entries; a hit calls `store` with the
* address of the entry's value word, which must end its block by
* address of the property's slot, which must end its block by
* branching to the site's join. */
function inlineReplace(
host: Host,
@@ -209,7 +195,7 @@ function inlineReplace(
B.br(miss);
return;
}
const shape = load(host, "ptr", d, 80);
const shape = load(host, "ptr", d, 80); // v.obj.layout
const present = B.tmp();
B.line(`${present} = icmp ne ptr ${shape}, null`);
let next = B.newLabel("ic.w");
@@ -230,21 +216,7 @@ function inlineReplace(
B.condBr(ok, matched, next);
B.startBlock(matched);
const slot = load(host, "i64", ic, base + 32);
const entry = entryAt(host, d, slot);
const flags = load(host, "i8", entry, 40);
const writable = load(host, "i8", entry, 41);
const accessor = B.tmp();
const data = B.tmp();
const canWrite = B.tmp();
const writes = B.tmp();
B.line(`${accessor} = and i8 ${flags}, 1`);
B.line(`${data} = icmp eq i8 ${accessor}, 0`);
B.line(`${canWrite} = icmp ne i8 ${writable}, 0`);
B.line(`${writes} = and i1 ${data}, ${canWrite}`);
const hit = B.newLabel("ic.st");
B.condBr(writes, hit, miss);
B.startBlock(hit);
store(gep(host, entry, 16));
store(slotAt(host, d, slot));
}
}
@@ -347,6 +319,222 @@ function keyString(host: Host, key: string, e: IrExpr): string {
return host.emitExpr({ kind: "strLit", value: key, type: STRING, loc: e.loc }).name;
}
/** A class instance's answer that a read helper gives by loading one
* declared field: the branch `if (dyn.typedRefIs(p, KEY)) { r =
* dynCheck(p); return [js.fromDyn](fieldGet C.f(r)) }`. */
interface CapsuleRead {
key: string;
className: string;
field: string;
}
/** The class branches of a read helper that a site can answer inline for
* capsules (class instances boxed as dyn values), or none.
*
* The helper first normalizes its receiver (`%dyn.class.normalize`
* reboxes a capsule keyed with a base class whose instance belongs to a
* subclass), then tests each class branch in turn. The site loads the
* field of a capsule whose key is a branch's key without normalizing, so
* a branch qualifies only when every subclass the normalization can
* rebox its key to has a branch that loads the same field from the same
* slot of a layout that extends the class's. Every other capsule, and
* every helper or normalization of another form, keeps the helper. */
function capsuleReads(host: Host, helper: IrFunction): CapsuleRead[] {
const receiver = helper.params[0]?.localId;
if (receiver === undefined || helper.params.length !== 1) return [];
const reads = new Map<string, CapsuleRead | null>();
for (const stmt of helper.body) {
if (normalizes(stmt, receiver)) continue;
if (stmt.kind !== "if" || stmt.else_ !== null) break;
if (notCapsule(stmt.cond, receiver)) continue;
const key = brandKey(stmt.cond, receiver);
if (key === null) return [];
if (!reads.has(key)) reads.set(key, capsuleRead(stmt, receiver, key));
}
const reboxes = normalizationTargets(host);
if (reboxes === null) return [];
const out: CapsuleRead[] = [];
for (const read of reads.values()) {
if (read === null || !plainField(host, read.className, read.field)) continue;
const targets = reboxes.get(read.key) ?? [];
const same = targets.every((target) => {
const other = reads.get(`object:${target}`);
return (
other != null &&
other.field === read.field &&
plainField(host, target, read.field) &&
extendsLayout(host, target, read.className, read.field)
);
});
if (same) out.push(read);
}
return out;
}
/** `p = %dyn.class.normalize(p)`. */
function normalizes(stmt: IrStmt, receiver: string): boolean {
return (
stmt.kind === "assign" &&
stmt.localId === receiver &&
stmt.value.kind === "call" &&
stmt.value.callee === "%dyn.class.normalize" &&
stmt.value.args.length === 1 &&
isLocal(stmt.value.args[0]!, receiver)
);
}
/** `!classCapsule(p)`: false for every capsule of a compiled class, so a
* capsule never enters the branch it guards. */
function notCapsule(cond: IrExpr, receiver: string): boolean {
return (
cond.kind === "dynTest" &&
cond.test === "classCapsule" &&
cond.negated === true &&
isLocal(cond.value, receiver)
);
}
function isLocal(e: IrExpr, id: string): boolean {
return e.kind === "varRef" && e.localId === id;
}
/** The capsule key `dyn.typedRefIs(p, "object:C")` tests, or null. */
function brandKey(cond: IrExpr, receiver: string): string | null {
if (cond.kind !== "libCall" || cond.fn !== "dyn.typedRefIs" || cond.args.length !== 2)
return null;
const [value, key] = cond.args;
if (!isLocal(value!, receiver) || key?.kind !== "strLit" || !key.value.startsWith("object:"))
return null;
return key.value;
}
/** The field a class branch loads, or null for a branch of another form. */
function capsuleRead(
branch: IrStmt & { kind: "if" },
receiver: string,
key: string,
): CapsuleRead | null {
const [decl, ret] = branch.then;
if (branch.then.length !== 2 || decl?.kind !== "varDecl" || ret?.kind !== "return") return null;
if (decl.init?.kind !== "dynCheck" || !isLocal(decl.init.value, receiver)) return null;
let value = ret.value;
if (value?.kind === "libCall" && value.fn === "js.fromDyn" && value.args.length === 1)
value = value.args[0]!;
if (value?.kind !== "fieldGet" || !isLocal(value.obj, decl.localId)) return null;
if (key !== `object:${value.className}`) return null;
return { key, className: value.className, field: value.field };
}
/** For each capsule key `%dyn.class.normalize` reboxes, the classes it
* may rebox to; null when the normalization has another form. */
function normalizationTargets(host: Host): Map<string, string[]> | null {
const targets = new Map<string, string[]>();
const normalize = host.fnByName.get("%dyn.class.normalize");
if (normalize === undefined) return targets;
const value = normalize.params[0]?.localId;
if (value === undefined) return null;
for (const stmt of normalize.body) {
if (stmt.kind === "return") {
if (stmt.value !== null && isLocal(stmt.value, value)) continue;
return null;
}
if (stmt.kind !== "if" || stmt.else_ !== null) return null;
if (notCapsule(stmt.cond, value)) continue;
const key = brandKey(stmt.cond, value);
if (key === null) return null;
const [decl, ...checks] = stmt.then;
if (decl?.kind !== "varDecl" || decl.init?.kind !== "dynCheck") return null;
const into = targets.get(key) ?? [];
for (const check of checks) {
if (
check.kind !== "if" ||
check.else_ !== null ||
check.cond.kind !== "instanceOf" ||
!isLocal(check.cond.value, decl.localId)
)
return null;
into.push(check.cond.className);
}
targets.set(key, into);
}
return targets;
}
/** A declared field of a compiled class that holds a plain dyn cell
* pointer: not compact, compressed, int32 or an inline union. */
function plainField(host: Host, className: string, field: string): boolean {
const meta = host.classMeta.get(className);
if (meta === undefined || meta.compact) return false;
const decl = meta.def.fields.find((f) => f.name === field);
if (decl === undefined || decl.type.kind !== "dyn") return false;
if (classFieldIndex(meta, field, "compressed").compressed) return false;
return (
!host.int32Slots.isField(className, field) && !host.inlineUnions.classField(className, field)
);
}
/** Whether `sub`'s layout starts with `base`'s through `field`: the same
* header and the same fields in the same slots, so a load through
* `base`'s struct reads `sub`'s field. */
function extendsLayout(host: Host, sub: string, base: string, field: string): boolean {
const a = host.classMeta.get(base);
const b = host.classMeta.get(sub);
if (a === undefined || b === undefined || b.compact) return false;
if (classFieldBase(a) !== classFieldBase(b)) return false;
const end = a.def.fields.findIndex((f) => f.name === field);
if (end < 0 || end >= b.def.fields.length) return false;
for (let i = 0; i <= end; i++) {
const x = a.def.fields[i]!;
const y = b.def.fields[i]!;
if (x.name !== y.name || x.type.kind !== y.type.kind) return false;
}
return (
classFieldIndex(a, field, "compressed").index === classFieldIndex(b, field, "compressed").index
);
}
/** The inline part of a read site for a capsule `d`: a capsule whose key
* is one of `reads`' keys answers with the (borrowed) field cell, branching
* to `join` and recording its phi input; anything else, including a field
* not yet set, branches to `miss`. */
function inlineCapsuleLoad(
host: Host,
d: string,
reads: readonly CapsuleRead[],
incoming: string[],
join: string,
miss: string,
): void {
const B = host.B;
const capsule = B.newLabel("icc.cap");
kindIs(host, d, DYN_KIND.TYPED_REF, capsule, miss);
B.startBlock(capsule);
// Capsules boxed by this program share its key constants.
const key = load(host, "ptr", d, 40);
let next = B.newLabel("icc.k");
B.br(next);
reads.forEach((read, i) => {
B.startBlock(next);
next = i + 1 < reads.length ? B.newLabel("icc.k") : miss;
const same = B.tmp();
B.line(`${same} = icmp eq ptr ${key}, ${host.cstr(read.key)}`);
const matched = B.newLabel("icc.m");
B.condBr(same, matched, next);
B.startBlock(matched);
const instance = load(host, "ptr", d, 16);
const { ptr } = host.classFieldPtr(instance, read.className, read.field);
const cell = B.tmp();
B.line(`${cell} = load ptr, ptr ${ptr}`);
const set = B.tmp();
B.line(`${set} = icmp ne ptr ${cell}, null`);
const hit = B.newLabel("icc.hit");
B.condBr(set, hit, miss);
B.startBlock(hit);
incoming.push(`[ ${cell}, %${B.currentLabel()} ]`);
B.br(join);
});
}
/** Emits a call to a classified helper as a cache site. */
export function emitJsIcCall(
host: Host,
@@ -401,9 +589,15 @@ function emitGet(host: Host, e: ExprOf<"call">, callee: IrFunction, key: string)
// conversion to the result type.
const hit = B.newLabel("icg.hit");
const hits: string[] = [];
kindIs(host, receiver.name, DYN_KIND.OBJ, object, probe);
const reads = capsuleReads(host, callee);
const other = reads.length > 0 ? B.newLabel("icg.other") : probe;
kindIs(host, receiver.name, DYN_KIND.OBJ, object, other);
B.startBlock(object);
inlineLoad(host, receiver.name, ic, INLINE_WAYS, INLINE_PROTO, (cell) => cell, hits, hit, probe);
if (reads.length > 0) {
B.startBlock(other);
inlineCapsuleLoad(host, receiver.name, reads, hits, hit, probe);
}
B.startBlock(probe);
// The read's probe also answers keys the receiver's plain chain lacks.
// Only `length` and `next` reads can be answered for a native array or
@@ -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;
@@ -755,7 +756,10 @@ export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll
// stream behind it (scr_dyn_for_await_stream.c)
e.fn === "dyn.asyncIteratorOpen" && host.dynForAwaitStreams
? "scr_stream_dyn_async_iterator_open"
: LIB_FN_SYMS[e.fn]);
: (e.fn === "dyn.keySet" || e.fn === "dyn.keySetComputed") &&
host.sloppyDynWrites.has(`${e.loc.file}\0${e.loc.start}\0${e.loc.end}`)
? `${LIB_FN_SYMS[e.fn]}_sloppy`
: LIB_FN_SYMS[e.fn]);
if (sym === undefined) throw new LlvmUnsupportedError(`libCall:${e.fn}`, e.loc);
// The runtime's Date functions take and answer time values: a Date
// operand passes its time value, read at once from the borrowed object,
@@ -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> = {}): 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);
});
@@ -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<string>([
"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<string> {
const result = new Set<string>();
if (mod.lib !== undefined) return result;
const candidates = new Map<string, IrFunction>();
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<string, { count: number; inLoops: boolean }>();
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;
}
@@ -0,0 +1,231 @@
/* Loop-entry range versions (ir/loop-entry-ranges.ts): a while loop whose
* integer work depends on locals entering it without range facts runs as
* two copies. A test on entry checks that each such local holds an integer
* in its assumed range; the fast copy is emitted under the facts that hold
* then, and the other copy is the loop as it would be emitted otherwise. */
import type { IrFunction, IrModule, IrStmt } from "../../ir/ir.js";
import { everyStmt, everyStmtList } from "../../ir/traverse.js";
import { exprFunctionReference, moduleFunctionReferences } from "../../ir/function-references.js";
import type { IntegerRangeFacts, IntegerSlotFacts } from "../../ir/integer-ranges.js";
import { findLoopEntryVersions, type LoopEntryVersion } from "../../ir/loop-entry-ranges.js";
import { f64Lit } from "./common.js";
import type { LlEmitter } from "./emitter.js";
import { defineModuleState } from "./emitter-state.js";
interface LoopEntryState {
fn: IrFunction | null;
facts: IntegerRangeFacts | null;
slots: IntegerSlotFacts;
/** Found on the first while loop the function emits. */
versions: Map<IrStmt, LoopEntryVersion> | null;
/** Loops whose copies are being emitted: a loop is versioned once. */
readonly versioning: Set<IrStmt>;
/** Direct calls per module function, or -1 for a function some value or
* the module's metadata references. */
calls: Map<string, number> | null;
/** The callees whose calls the fast copy being emitted inlines. */
inlined: ReadonlySet<string> | null;
}
const ENTRY_VERSIONS = defineModuleState<LoopEntryState>(() => ({
fn: null,
facts: null,
slots: {},
versions: null,
versioning: new Set<IrStmt>(),
calls: null,
inlined: null,
}));
/** The most IR nodes a callee may have for its call in a fast copy to be
* inlined (singleCallees). */
const MAX_INLINED_NODES = 400;
/** Sets the state for the function about to be emitted: `numericFn` and the
* facts emitFunction computed for it. Debug builds keep every loop as is. */
export function beginLoopEntryVersions(
emitter: LlEmitter,
numericFn: IrFunction,
slots: IntegerSlotFacts,
facts: IntegerRangeFacts,
): void {
const state = ENTRY_VERSIONS.of(emitter);
state.fn = emitter.debug === null ? numericFn : null;
state.slots = slots;
state.facts = facts;
state.versions = null;
state.versioning.clear();
}
/** The entry version of `loop`, when it has one and the emitter can test
* its locals: plain double slots of the current function, outside a
* speculative copy and a coroutine. */
export function loopEntryVersion(emitter: LlEmitter, loop: IrStmt): LoopEntryVersion | null {
if (loop.kind !== "while") return null;
const state = ENTRY_VERSIONS.of(emitter);
if (state.fn === null || state.facts === null || state.versioning.has(loop)) return null;
if (
!emitter.speculativeLoopsEnabled ||
emitter.speculation !== null ||
emitter.speculationResume !== null ||
emitter.currentCoro !== null
)
return null;
state.versions ??= findLoopEntryVersions(state.fn, state.facts, state.slots);
const version = state.versions.get(loop);
if (!version) return null;
for (const id of version.entry.keys())
if (!emitter.currentLocals.has(id) || !emitter.speculativeLocal(id)) return null;
return version;
}
/** Emit the entry test and both copies of `loop`. `emitCopies` emits the
* loop as it would be emitted without the version; it runs once under the
* version's facts and once as is. */
export function emitLoopEntryVersion(
emitter: LlEmitter,
loop: IrStmt,
version: LoopEntryVersion,
emitCopies: () => void,
): void {
const B = emitter.B;
let ok: string | null = null;
for (const [id, range] of version.entry) {
// An integer in [min, max] other than -0: the ordered comparisons
// reject NaN, and the bits of the value and its round trip agree only
// for integers that are not -0. A local the loop writes before it reads
// may be unset on entry, and a conversion out of range is poison:
// `freeze` makes both an arbitrary fixed value.
const raw = B.tmp(),
value = B.tmp(),
low = B.tmp(),
high = B.tmp(),
converted = B.tmp(),
integer = B.tmp(),
back = B.tmp(),
bits = B.tmp(),
backBits = B.tmp(),
exact = B.tmp(),
inRange = B.tmp(),
passed = B.tmp();
B.line(`${raw} = load double, ptr ${emitter.binding(id).slot}`);
B.line(`${value} = freeze double ${raw}`);
B.line(`${low} = fcmp oge double ${value}, ${f64Lit(range.min)}`);
B.line(`${high} = fcmp ole double ${value}, ${f64Lit(range.max)}`);
B.line(`${converted} = fptosi double ${value} to i64`);
B.line(`${integer} = freeze i64 ${converted}`);
B.line(`${back} = sitofp i64 ${integer} to double`);
B.line(`${bits} = bitcast double ${value} to i64`);
B.line(`${backBits} = bitcast double ${back} to i64`);
B.line(`${exact} = icmp eq i64 ${bits}, ${backBits}`);
B.line(`${inRange} = and i1 ${low}, ${high}`);
B.line(`${passed} = and i1 ${inRange}, ${exact}`);
if (ok === null) ok = passed;
else {
const both = B.tmp();
B.line(`${both} = and i1 ${ok}, ${passed}`);
ok = both;
}
}
const fast = B.newLabel("entry.fast"),
slow = B.newLabel("entry.slow");
// The test is expected to pass, which keeps the ordinary copy's calls
// cold for the inliner.
let likely = "true";
if (ok !== null) {
emitter.declare("declare i1 @llvm.expect.i1(i1, i1)");
likely = B.tmp();
B.line(`${likely} = call i1 @llvm.expect.i1(i1 ${ok}, i1 true)`);
}
B.condBr(likely, fast, slow);
const state = ENTRY_VERSIONS.of(emitter);
const originalRanges = emitter.integerRanges;
const originalLoopRanges = emitter.integerLoopRanges;
state.versioning.add(loop);
try {
B.startBlock(fast);
// An enclosing version may have narrowed a range already.
const ranges = new Map(originalRanges);
for (const [node, range] of version.ranges) {
const known = originalRanges.get(node);
ranges.set(
node,
known
? { min: Math.max(known.min, range.min), max: Math.min(known.max, range.max) }
: range,
);
}
emitter.integerRanges = ranges;
state.inlined = singleCallees(emitter, state, loop);
if (version.loopRanges) {
const loopRanges = new Map(originalLoopRanges);
loopRanges.set(loop, version.loopRanges);
emitter.integerLoopRanges = loopRanges;
}
emitCopies();
state.inlined = null;
emitter.integerRanges = originalRanges;
emitter.integerLoopRanges = originalLoopRanges;
B.startBlock(slow);
emitCopies();
} finally {
emitter.integerRanges = originalRanges;
emitter.integerLoopRanges = originalLoopRanges;
state.versioning.delete(loop);
state.inlined = null;
}
}
/** Whether the fast copy being emitted inlines its calls of `callee`. */
export function loopEntryInlines(emitter: LlEmitter, callee: string): boolean {
return ENTRY_VERSIONS.of(emitter).inlined?.has(callee) === true;
}
/** The functions the loop calls that have no other call or reference in
* the module and at most MAX_INLINED_NODES nodes. LLVM inlines a function's
* only call at almost any size; the two copies give each such function a
* second call, so the fast copy's call is marked to keep that inlining. */
function singleCallees(emitter: LlEmitter, state: LoopEntryState, loop: IrStmt): Set<string> {
const callees = new Set<string>();
everyStmt(loop, {
expr: (e) => {
if (e.kind === "call") callees.add(e.callee);
return true;
},
stmt: () => true,
});
const result = new Set<string>();
if (callees.size === 0) return result;
state.calls ??= countCalls(emitter.mod);
for (const name of callees) {
if (state.calls.get(name) !== 1 || name === state.fn?.name) continue;
const fn = emitter.mod.functions.find((f) => f.name === name);
if (fn && !fn.async && !fn.generator && nodeCount(fn.body) <= MAX_INLINED_NODES)
result.add(name);
}
return result;
}
function countCalls(mod: IrModule): Map<string, number> {
const calls = new Map<string, number>();
const referenced = new Set(moduleFunctionReferences(mod));
for (const fn of mod.functions)
everyStmtList(fn.body, {
expr: (e) => {
if (e.kind === "call") calls.set(e.callee, (calls.get(e.callee) ?? 0) + 1);
const name = exprFunctionReference(e);
if (name !== undefined) referenced.add(name);
return true;
},
stmt: () => true,
});
for (const name of referenced) calls.set(name, -1);
return calls;
}
function nodeCount(body: IrStmt[]): number {
let count = 0;
const visit = (): boolean => ++count <= MAX_INLINED_NODES;
everyStmtList(body, { expr: visit, stmt: visit });
return count;
}
@@ -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<IrModule> {
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);
});
File diff suppressed because it is too large Load Diff
@@ -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<string, Set<number>>,
): 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);
}
}
@@ -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<IrModule> {
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);
});
@@ -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<Node>;
/** 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<Node>;
}
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<Node>): Set<string> {
const fns = new Map(mod.functions.map((f) => [f.name, f]));
const vt = new VirtualTargets(mod.classes ?? []);
const reachable = new Set<string>();
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<Node, boolean>;
/** 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<string, string>();
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<string>();
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<string>();
const fates = new Map<Node, Fate>();
const loops = new Map<Node, boolean>();
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<string>();
for (const c of groups.calls) if (c.kind === "call") regionCallees.add(c.callee);
const sites = new Map<string, FnSites>();
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<string> => {
const out = new Set<string>();
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<string>();
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<string>()) stack.push(m);
}
return false;
};
const grows = new Set<string>();
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<Node>();
const heapCalls = new Set<Node>();
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 };
}
@@ -0,0 +1,65 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test } from "vitest";
import { compile, deserializeModule } from "../../index.js";
import { emitLlvmModule } from "./emitter.js";
import { REPR_REPORT_HEADER } from "./repr-report.js";
const saved = process.env["SCRIPTC_REPR_REPORT"];
afterEach(() => {
if (saved === undefined) delete process.env["SCRIPTC_REPR_REPORT"];
else process.env["SCRIPTC_REPR_REPORT"] = saved;
});
test("the representation report names each slot's storage and why a number stays a double", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-repr-report-"));
try {
const entry = join(dir, "main.ts"),
outPath = join(dir, "main.ir.json"),
report = join(dir, "repr.tsv");
await writeFile(
entry,
`
class Box {
id: number;
hits: number;
items: number[];
constructor(id: number, items: number[]) { this.id = id | 0; this.hits = 0; this.items = items; }
}
function scale(value: number, by: number = 2): number { return (value * by) | 0; }
function half(value: number): number { return value / 2; }
const boxes: Box[] = [];
for (let i = 0; i < 4; i++) boxes.push(new Box(i, [i, i + 1]));
for (const b of boxes) b.hits++;
console.log(scale(boxes[1]!.id), scale(3, 4), half(boxes.length), boxes[2]!.items.length, boxes[3]!.hits);
`,
);
const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" });
if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n"));
const mod = deserializeModule(await readFile(outPath, "utf8"));
process.env["SCRIPTC_REPR_REPORT"] = report;
emitLlvmModule(mod);
const lines = (await readFile(report, "utf8")).trimEnd().split("\n");
expect(lines[0]).toBe(REPR_REPORT_HEADER);
const rows = lines.slice(1).map((line) => line.split("\t"));
const row = (kind: string, owner: string, name: string) =>
rows.find((r) => r[0] === kind && r[1] === owner && r[2] === name);
expect(row("field", "Box", "id")?.slice(3, 5)).toEqual(["f64", "i32"]);
// `++` on a field keeps it a double, and the report says so.
expect(row("field", "Box", "hits")?.[4]).toBe("double");
expect(row("field", "Box", "hits")?.[5]).not.toBe("");
expect(row("field", "Box", "items")?.slice(3, 5)).toEqual(["array<f64>", "ptr"]);
expect(row("param", "scale", "value")?.[4]).toBe("i32");
// A defaulted parameter is a `number | undefined` box; the variant for
// calls that pass a number takes an i32.
expect(row("param", "scale", "by")?.slice(3, 5)).toEqual(["f64 | undefined", "box"]);
expect(row("param", "scale.nativeNumber", "by")?.[4]).toBe("i32");
// An array length may exceed the int32 range.
expect(row("param", "half", "value")?.[4]).toBe("double");
expect(row("param", "half", "value")?.[5]).toMatch(/^argument in .* is not int32$/);
expect(row("return", "half", "")?.slice(4)).toEqual(["double", "return is not int32"]);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
@@ -0,0 +1,141 @@
/* The representation report (SCRIPTC_REPR_REPORT=<file>): after a module is
* emitted, one tab-separated line per class field, function parameter,
* function result and union, naming the storage the backend chose for it
* and, for numbers kept as doubles, the reason the int32 analysis recorded
* (int32-slots.ts Int32Slots.reasons). It is a diagnostic for ranking
* representation work; nothing reads it back. */
import { writeFileSync } from "node:fs";
import { typeKey, type IrFunction, type IrModule, type IrType } from "../../ir/ir.js";
import type { LlEmitter } from "./emitter.js";
import { classFieldTypes } from "./classes.js";
import { INLINE_UNION_FIELD } from "./inline-unions.js";
export const REPR_REPORT_HEADER = "kind\towner\tname\ttype\tstorage\treason";
/** The file SCRIPTC_REPR_REPORT names, or null. A build that writes the
* report skips the executable cache, whose hit would emit no module. */
export function representationReportPath(env: NodeJS.ProcessEnv = process.env): string | null {
const path = env["SCRIPTC_REPR_REPORT"];
return path === undefined || path === "" ? null : path;
}
/** Writes the report of `emitter`'s module when SCRIPTC_REPR_REPORT names a
* file. */
export function writeRepresentationReport(emitter: LlEmitter): void {
const path = representationReportPath();
if (path !== null) writeFileSync(path, representationReport(emitter));
}
export function representationReport(emitter: LlEmitter): string {
const mod = emitter.mod;
const slots = emitter.int32Slots;
const lines = [REPR_REPORT_HEADER];
const row = (...cells: string[]): void => {
lines.push(cells.map((cell) => cell.replace(/[\t\n]/g, " ")).join("\t"));
};
const typeLabel = typeLabeler(mod);
// A union value that is not a plain box: a nullable or shape pointer.
const unionStorage = (t: IrType): string | null => {
if (t.kind !== "union") return null;
return emitter.nullableUnions.get(t.unionId)?.repr ?? "box";
};
for (const cls of mod.classes ?? []) {
const meta = emitter.classMeta.get(cls.name);
const types = classFieldTypes(emitter.shapeHost, cls, slots);
// Inherited fields come first in def.fields: report each field once,
// on the class that declares it.
const inherited = meta?.base?.def.fields.length ?? 0;
cls.fields.forEach((field, i) => {
if (i < inherited) return;
let storage = types[i]!;
if (storage === INLINE_UNION_FIELD) storage = "inline union";
else if (meta?.compressed[i] === true) storage = "ref32";
else if (storage === "ptr") storage = unionStorage(field.type) ?? "ptr";
let reason = "";
if (field.type.kind === "f64" && storage === "double") {
const family = slots.family(cls.name, field.name);
reason =
family === null
? "no field family"
: (slots.reasons.get(`field:${family}`) ?? "not a candidate");
}
row("field", cls.name, field.name, typeLabel(field.type), storage, reason);
});
}
for (const fn of mod.functions) {
const abi = emitter.int32Abis.get(fn.name);
fn.params.forEach((param, i) => {
let storage: string;
let reason = "";
if (param.type.kind === "f64") {
storage = abi?.params[i] === true ? "i32" : "double";
if (storage === "double") reason = parameterReason(emitter, fn, param.localId);
} else storage = unionStorage(param.type) ?? scalarStorage(param.type);
row("param", fn.name, param.name, typeLabel(param.type), storage, reason);
});
const ret = fn.returnType;
if (ret.kind === "void") continue;
let storage: string;
let reason = "";
if (ret.kind === "f64") {
storage = abi?.result === true ? "i32" : "double";
if (storage === "double")
reason = slots.reasons.get(`ret:${fn.name}`) ?? notCandidate(emitter, fn);
} else if (ret.kind === "union" && abi?.union === true) storage = "register pair";
else storage = unionStorage(ret) ?? scalarStorage(ret);
row("return", fn.name, "", typeLabel(ret), storage, reason);
}
for (const def of mod.unions ?? [])
row(
"union",
def.id,
"",
typeLabel({ kind: "union", unionId: def.id }),
unionStorage({ kind: "union", unionId: def.id })!,
"",
);
return lines.join("\n") + "\n";
}
function scalarStorage(t: IrType): string {
switch (t.kind) {
case "f64":
return "double";
case "bool":
return "i1";
default:
return "ptr";
}
}
function parameterReason(emitter: LlEmitter, fn: IrFunction, localId: string): string {
const slots = emitter.int32Slots;
const recorded = slots.reasons.get(`param:${fn.name}\0${localId}`);
if (recorded !== undefined) return recorded;
// Proven, but the function has no i32 entry (findInt32Abis).
if (slots.hasParam(fn.name, localId)) return "no i32 entry";
return notCandidate(emitter, fn);
}
/** Why a function's numbers were never int32 candidates: the analysis only
* proves functions that direct calls alone reach. */
function notCandidate(emitter: LlEmitter, fn: IrFunction): string {
if (fn.async) return "async function";
if (fn.generator) return "generator";
if (fn.captures !== undefined) return "closure";
if (!emitter.int32Slots.called.has(fn.name)) return "no direct call";
return "escapes or is dispatched";
}
/** Type labels with union arms spelled out. */
function typeLabeler(mod: IrModule): (t: IrType) => string {
const arms = new Map((mod.unions ?? []).map((def) => [def.id, def.arms]));
return (t) => {
if (t.kind !== "union") return typeKey(t);
const list = arms.get(t.unionId);
return list === undefined ? typeKey(t) : list.map(typeKey).join(" | ");
};
}
+54 -8
View File
@@ -2,6 +2,7 @@
* Containers store erased adapters; shape helpers retain, release and trace
* their fields. Every allocation has a size_t reference count at offset zero
* and participates in the runtime allocation audit. */
import type { HeapCensus } from "./heap-census.js";
import type { IrClassDef, IrModule, IrRecordShape, IrType } from "../../ir/ir.js";
import {
isIdentityCollectionKey,
@@ -60,6 +61,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";
@@ -70,6 +79,8 @@ const CYC_MAX_TYPES = 32768;
/** What the tables need from the emitter: the extern-declaration ledger
* and the module-wide cycle/shape indexes. */
export interface ShapeHost {
/** The counters of a build instrumented for the heap census (heap-census.ts). */
readonly heapCensus?: HeapCensus | null;
/** Chunk emitters only (emit-journal.ts): EmitJournal's allocated, built,
* opened, used and entry. */
journalAllocated?: ((name: string, op: string, args: readonly unknown[]) => void) | undefined;
@@ -137,6 +148,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<string>;
arenaClasses?: ReadonlySet<string>;
/** 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 +1213,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 +2345,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;
@@ -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);
@@ -137,3 +137,18 @@ export function loopAccessesTypedArrays(loop: Loop): boolean {
};
return loop.body.some(inStmt);
}
/** Whether `body` holds a loop outside its closures. */
export function containsLoop(body: readonly IrStmt[]): boolean {
const inStmt = (s: IrStmt): boolean =>
s.kind === "while" ||
s.kind === "doWhile" ||
s.kind === "for" ||
s.kind === "forOf" ||
!everyStmtChild(
s,
() => true,
(child) => !inStmt(child),
);
return body.some(inStmt);
}
@@ -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<string, number>();
const visiting = new Set<string>();
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(
+202 -28
View File
@@ -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<typeof computePublishedTypes>;
@@ -56,8 +60,16 @@ export function computeMayThrow(
stackChecks?: Set<string>;
} {
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<string>,
stackChecks: Set<string> | null,
cyclic: Set<string> | null,
may: Set<string>,
): Set<string> {
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<string, string[]>;
cyclic: Set<string>;
}
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<string> {
const reentry = "\0reentry";
return cycleGuards(stackCheckGraph(mod, nodes));
}
function stackCheckGraph(mod: IrModule, nodes?: IrNodeIndex): StackCheckGraph {
const methodImpls = new Map<string, string[]>();
const memberPrefixes = new Set<string>();
for (const cls of mod.classes ?? []) {
@@ -1019,16 +1052,157 @@ export function stackCheckedFunctions(mod: IrModule, nodes?: IrNodeIndex): Set<s
};
eachNodeOfKinds(fn, scan, visit);
const callees = out.filter((callee) => 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<string> {
const { edges, cyclic } = graph;
const chosen = new Set<string>();
if (cyclic.size === 0) return chosen;
const order = new Map<string, number>();
const callees = new Map<string, Set<string>>();
const callers = new Map<string, Set<string>>();
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<string, number>(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<string, number>();
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
@@ -32,6 +32,25 @@ export function nativeProgramPartitions(llvmBytes: number): number {
return Math.min(MAX_PROGRAM_PARTITIONS, 2 ** Math.ceil(Math.log2(required)));
}
/** LLVM text that an optimized one-partition program generates code for on
* one thread, the LLVM text per code generation part, and the most parts. */
const SINGLE_CODEGEN_BYTES = 128 * 1024;
const CODEGEN_PART_BYTES = 64 * 1024;
const MAX_CODEGEN_PARTS = 8;
/** Code generation parts for an optimized executable object that
* nativeProgramPartitions keeps as one partition. The helper optimizes the
* module whole, as for a single output, and then generates code for the parts
* concurrently (`--split codegen`), so the generated code does not change.
* Returns 1 for modules of at most SINGLE_CODEGEN_BYTES and for partitioned
* ones. The count is a power of two that depends only on the module's size,
* so artifacts never vary with the host. */
export function nativeCodegenParts(llvmBytes: number): number {
if (llvmBytes <= SINGLE_CODEGEN_BYTES || llvmBytes > SINGLE_PARTITION_BYTES) return 1;
const required = Math.ceil(llvmBytes / CODEGEN_PART_BYTES);
return Math.min(MAX_CODEGEN_PARTS, 2 ** Math.ceil(Math.log2(required)));
}
/** Output paths for each partition; the first is the requested path. */
export function nativePartitionPaths(path: string, count: number): string[] {
const dot = path.lastIndexOf(".");
@@ -8,7 +8,11 @@ import {
NativeCodegenError,
validateNativeCodegenVersion,
} from "./native-codegen.js";
import { nativePartitionPaths, nativeProgramPartitions } from "./native-codegen-core.js";
import {
nativeCodegenParts,
nativePartitionPaths,
nativeProgramPartitions,
} from "./native-codegen-core.js";
import { nativeArtifactDependenciesStillMatch } from "./native-toolchain.js";
import { LINUX_X64_GNU_TARGET, MACOS_ARM64_TARGET, WASM32_WASI_TARGET } from "./targets.js";
import { compilerReleaseVersion } from "../library/sidecar.js";
@@ -409,6 +413,41 @@ test("program partitions depend only on module size", () => {
]);
});
test("code generation parts depend only on module size", () => {
const kilobyte = 1024;
expect(nativeCodegenParts(128 * kilobyte)).toBe(1);
expect(nativeCodegenParts(128 * kilobyte + 1)).toBe(4);
expect(nativeCodegenParts(256 * kilobyte)).toBe(4);
expect(nativeCodegenParts(256 * kilobyte + 1)).toBe(8);
expect(nativeCodegenParts(1024 * kilobyte)).toBe(8);
// Partitioned modules already compile their partitions concurrently.
expect(nativeCodegenParts(1024 * kilobyte + 1)).toBe(1);
});
test("split code generation passes --split codegen and no partition cache", async () => {
const pkg = await fakePackage();
const output = join(pkg.root, "split", "program.o");
const artifact = await emitNativeArtifact({
...request(pkg.root, pkg.packageJson, output),
partitions: 4,
splitCodegen: true,
});
expect(artifact.outputPaths).toEqual(nativePartitionPaths(output, 4));
const args = (await readFile(pkg.log, "utf8")).trim().split(" ");
expect(args.filter((arg) => arg === "--output")).toHaveLength(4);
expect(args.join(" ")).toContain("--split codegen");
expect(args).not.toContain("--cache-dir");
// Partitions of the same module are a distinct artifact.
await emitNativeArtifact({
...request(pkg.root, pkg.packageJson, join(pkg.root, "partitions", "program.o")),
partitions: 4,
});
const calls = (await readFile(pkg.log, "utf8")).trim().split("\n");
expect(calls).toHaveLength(2);
expect(calls[1]).not.toContain("--split");
});
test("partitioned objects are emitted, cached and restored together", async () => {
const pkg = await fakePackage();
const first = join(pkg.root, "first", "program.o");
@@ -125,6 +125,9 @@ export interface NativeCodegenOptions {
cacheRoot?: string | null;
/** Program partitions, written to nativePartitionPaths(outputPath). */
partitions?: number;
/** With several partitions: optimize the module whole and divide only its
* code generation among them. */
splitCodegen?: boolean;
/** Profile-guided optimization: instrumentation or an indexed profile. */
profile?: NativeProfileOptions;
}
@@ -351,6 +354,7 @@ function cacheKey(
.update("\0")
.update(options.sourcePath)
.update((options.partitions ?? 1) > 1 ? `\0partitions:${options.partitions}` : "")
.update(options.splitCodegen === true ? "\0split:codegen" : "")
.update(options.profile === undefined ? "" : `\0${nativeProfileKey(options.profile)}`)
.digest("hex");
}
@@ -454,7 +458,7 @@ export async function emitNativeArtifact(
// edit recompiles only the partitions it affects. The directory names the
// exact helper build and target; the helper keys entries by content.
const partitionCache =
root === null || outputs.length < 2
root === null || outputs.length < 2 || options.splitCodegen === true
? null
: join(
root,
@@ -494,6 +498,7 @@ export async function emitNativeArtifact(
"--source-path",
options.sourcePath,
...(partitionCache === null ? [] : ["--cache-dir", partitionCache]),
...(options.splitCodegen === true && outputs.length > 1 ? ["--split", "codegen"] : []),
...nativeProfileHelperArgs(options.profile),
]);
for (const stage of stages) {
@@ -67,6 +67,9 @@ export interface NativeLinkFeatures {
eventsOnceSignal?: boolean;
/** util.MIMEType and util.MIMEParams: scr_mime.c. */
mime?: boolean;
/** `--large-pages`: scr_text_pages.c, which moves the executable's code
* onto transparent huge pages at startup. Absent (false) without it. */
largePages?: boolean;
/** util.debounce and util.throttle: scr_util_timing.c. */
utilTiming?: boolean;
/** Buffer.prototype methods called on checked-dynamic values link
@@ -39,6 +39,8 @@ import {
warmNativeCaches,
} from "./native-toolchain.js";
import { splitLlvmProgram } from "./llvm/split.js";
import { implicitToolchainFingerprints } from "./native/compiler-fingerprint.js";
import { fingerprintDependencyPaths } from "./native/dependency-files.js";
const scratch: string[] = [];
const TEST_CACHE_IDENTITY = "cc-cache-tests-v1";
@@ -326,9 +328,9 @@ test.skipIf(
let vendorEntries = await readdir(vendorRoot);
expect(vendorEntries.filter((name) => name.startsWith("mbedtls-")).length).toBe(2);
// Host clang uses system zlib, so only the Zig build materializes a zlib
// object family in the shared vendor root.
expect(vendorEntries.filter((name) => name.startsWith("zlib-")).length).toBe(1);
// Host clang and Zig each materialize their own vendored zlib object
// family in the shared vendor root.
expect(vendorEntries.filter((name) => name.startsWith("zlib-")).length).toBe(2);
// Invalidate the host archive. A targetless Zig rebuild must continue to
// use the separately keyed Zig archive instead of repairing or consuming
// the host-clang entry.
@@ -630,20 +632,27 @@ test.skipIf(clangExecutable === undefined)(
async () => {
// clang ships its x86 intrinsic headers (<emmintrin.h>, ...) 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
// <riscv_vector.h> (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>" && /intrin\.h>$|^<cpuid\.h>$|^<arm_(neon|acle)\.h>$/.test(include),
include !== "<intrin.h>" &&
/intrin\.h>$|^<cpuid\.h>$|^<arm_(neon|acle)\.h>$|^<riscv_vector\.h>$/.test(include),
);
expect(includes).toContain("<emmintrin.h>");
expect(includes).toContain("<riscv_vector.h>");
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!,
[
@@ -665,6 +674,29 @@ test.skipIf(clangExecutable === undefined)(
},
);
test.skipIf(clangExecutable === undefined)(
"the implicit toolchain probe leaves out a header the compiler rejects",
async () => {
// A system header that the runtime spells, shadowed by one that is an
// #error, as clang 18's <riscv_vector.h> is outside RISC-V. The probe
// still covers the other headers.
const dir = await mkdtemp(join(tmpdir(), "scriptc-probe-header-"));
scratch.push(dir);
await mkdir(join(dir, "sys"));
const rejected = join(dir, "sys", "auxv.h");
await writeFile(rejected, '#error "not for this target"\n');
const includes = await implicitDependencyProbeIncludes(runtimeSrcDir());
expect(includes).toContain("<sys/auxv.h>");
const fingerprints = await implicitToolchainFingerprints(
{ argv: [clangExecutable!, "-isystem", dir], targetArgs: [], target: null },
"probe-rejected-header",
);
const dependencies = fingerprintDependencyPaths(fingerprints.complete);
expect(dependencies).not.toContain(rejected);
expect(dependencies.some((path) => path.endsWith("/stdatomic.h"))).toBe(true);
},
);
test.skipIf(
process.platform === "win32" ||
clangExecutable === undefined ||
@@ -0,0 +1,92 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test, vi } from "vitest";
import {
NATIVE_CODEGEN_LLVM_VERSION,
NATIVE_CODEGEN_PROTOCOL_VERSION,
} from "./native-codegen-core.js";
import { verifyNativeHelper } from "./native-tools.js";
import { LINUX_X64_GNU_TARGET } from "./targets.js";
vi.mock("node:child_process", async (original) => ({
...(await original<typeof import("node:child_process")>()),
execFileSync: vi.fn(),
}));
const directories: string[] = [];
afterEach(() => {
vi.resetAllMocks();
for (const path of directories.splice(0)) rmSync(path, { recursive: true, force: true });
});
/** A helper package directory and the options that verify it. */
function helperOptions() {
const packageRoot = mkdtempSync(join(tmpdir(), "scriptc-helper-"));
directories.push(packageRoot);
const target = LINUX_X64_GNU_TARGET;
const helper = target.helper;
writeFileSync(
join(packageRoot, "package.json"),
JSON.stringify({ name: helper.packageName, version: "1.2.3" }),
);
vi.mocked(execFileSync).mockReturnValue(
JSON.stringify({
ok: true,
protocol_version: NATIVE_CODEGEN_PROTOCOL_VERSION,
scriptc_package_version: "1.2.3",
llvm_version: NATIVE_CODEGEN_LLVM_VERSION,
default_target: helper.defaultTarget,
data_layout: helper.defaultDataLayout,
targets: [target.llvmBackend],
supported_targets: [target.llvmTriple],
host_triple: target.llvmTriple,
}),
);
return {
executable: join(packageRoot, "helper"),
packageRoot,
compilerVersion: "1.2.3",
target,
helper,
};
}
test("a helper the verification record knows is not asked for its version again", () => {
const options = helperOptions();
let known = false;
const remember = vi.fn(() => {
known = true;
});
const verified = { known: () => known, remember };
verifyNativeHelper({ ...options, verified });
expect(execFileSync).toHaveBeenCalledTimes(1);
expect(remember).toHaveBeenCalledTimes(1);
verifyNativeHelper({ ...options, verified });
expect(execFileSync).toHaveBeenCalledTimes(1);
expect(remember).toHaveBeenCalledTimes(1);
});
test("a helper whose version answer is rejected is not recorded", () => {
const options = helperOptions();
vi.mocked(execFileSync).mockReturnValue(JSON.stringify({ ok: false }));
const remember = vi.fn();
expect(() =>
verifyNativeHelper({ ...options, verified: { known: () => false, remember } }),
).toThrow(/incompatible/);
expect(remember).not.toHaveBeenCalled();
});
test("the package identity is checked even for a helper the record knows", () => {
const options = helperOptions();
const remember = vi.fn();
expect(() =>
verifyNativeHelper({
...options,
compilerVersion: "9.9.9",
verified: { known: () => true, remember },
}),
).toThrow(/package mismatch/);
expect(execFileSync).not.toHaveBeenCalled();
});
+23 -4
View File
@@ -49,10 +49,14 @@ export function emitNativeObject(options: {
outputKind?: NativeCodegenOutputKind;
/** Program partitions, written to nativePartitionPaths(outputPath). */
partitions?: number;
/** With several partitions: optimize the module whole and divide only its
* code generation among them. */
splitCodegen?: boolean;
/** Private directory where the helper reuses partition artifacts. */
partitionCache?: string;
/** Profile-guided optimization: instrumentation or an indexed profile. */
profile?: NativeProfileOptions;
verified?: NativeHelperVerification;
}): { outputs: string[]; profileMatch: NativeProfileMatch | null } {
verifyNativeHelper(options);
const outputs = nativePartitionPaths(options.outputPath, options.partitions ?? 1);
@@ -73,9 +77,11 @@ export function emitNativeObject(options: {
"json",
"--source-path",
options.sourcePath,
...(options.partitionCache === undefined || outputs.length < 2
? []
: ["--cache-dir", options.partitionCache]),
...(options.splitCodegen === true && outputs.length > 1
? ["--split", "codegen"]
: options.partitionCache === undefined || outputs.length < 2
? []
: ["--cache-dir", options.partitionCache]),
...nativeProfileHelperArgs(options.profile),
]);
for (const output of outputs) requireNativeArtifact(output);
@@ -106,13 +112,24 @@ export function mergeNativeProfiles(options: {
]);
}
/** Verify installed identity even when a caller can reuse a cached object. */
/** Helpers that answered `version` as expected: `known` is true when the
* helper file, as it is now, already did, and `remember` records that this
* one just did. */
export interface NativeHelperVerification {
known(): boolean;
remember(): void;
}
/** Verify installed identity even when a caller can reuse a cached object.
* With `verified`, the version probe runs only for a helper it does not
* know yet. */
export function verifyNativeHelper(options: {
executable: string;
packageRoot: string;
compilerVersion: string;
target: NativeTargetSpec;
helper: NativeHelperSpec;
verified?: NativeHelperVerification;
}): void {
const identity = JSON.parse(readFileSync(options.packageRoot + "/package.json", "utf8")) as {
name: string;
@@ -126,8 +143,10 @@ export function verifyNativeHelper(options: {
`LLVM helper package mismatch: expected ${options.helper.packageName}@${options.compilerVersion}`,
);
}
if (options.verified?.known() === true) return;
const version = JSON.parse(
runNativeTool(options.executable, ["version", "--format=json"]),
) as Record<string, unknown>;
validateNativeCodegenVersion(version, options.target, options.helper, options.compilerVersion);
options.verified?.remember();
}
@@ -1,7 +1,7 @@
import { createHash } from "node:crypto";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { basename, dirname, join, resolve } from "node:path";
import { type CcDriver } from "./driver.js";
import { resolvedToolIdentity, resolvedTool } from "./tool-identity.js";
import { execFileAsync } from "./process.js";
@@ -168,7 +168,7 @@ export function effectiveCompilerInvocationFingerprint(
export async function implicitDependencyProbeIncludes(rtDir: string): Promise<string[]> {
const vendor = join(rtDir, "..", "vendor");
const quickjsSources = new Set<string>(LRE_SOURCES);
const zlibSources = new Set<string>(ZLIB_SOURCES);
const zlibSources = new Set<string>(ZLIB_SOURCES.map((source) => basename(source)));
const fileGroups = await Promise.all([
nativeSourceFiles(rtDir, false),
nativeSourceFiles(join(vendor, "ryu"), false),
@@ -181,7 +181,7 @@ export async function implicitDependencyProbeIncludes(rtDir: string): Promise<st
nativeSourceFiles(join(vendor, "mbedtls", "library"), true),
nativeSourceFiles(
join(vendor, "zlib"),
false,
true,
(name) => name.endsWith(".h") || zlibSources.has(name),
),
]);
@@ -199,13 +199,16 @@ export async function implicitDependencyProbeIncludes(rtDir: string): Promise<st
* __has_include finds them on arm64 too, where including them is an
* #error. Covers the SSE/AVX intrinsic families (<emmintrin.h>,
* <immintrin.h>, ...), <x86intrin.h>, <x86gprintrin.h> and <cpuid.h>. */
/** clang's x86 intrinsic headers: every <...intrin.h> but the Windows
* <intrin.h> (<xsaveintrin.h>, <immintrin.h>, ...), and <cpuid.h>. */
function x86OnlyHeader(include: string): boolean {
return include === "<cpuid.h>" || /^<(x86|x86gpr|[a-z]*mm)intrin\.h>$/.test(include);
return include === "<cpuid.h>" || /^<[a-z0-9_]+intrin\.h>$/.test(include);
}
/** 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 <riscv_vector.h> 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))"
@@ -213,7 +216,9 @@ export function implicitDependencyIncludeDirective(include: string): string {
? " && defined(_WIN32)"
: include === "<arm64_neon.h>" || include === "<arm_acle.h>" || include === "<arm_neon.h>"
? " && (defined(__arm__) || defined(__aarch64__) || defined(_M_ARM) || defined(_M_ARM64))"
: "";
: include === "<riscv_vector.h>"
? " && defined(__riscv_vector)"
: "";
return `#if __has_include(${include})${targetCondition}\n#include ${include}\n#endif\n`;
}
@@ -394,11 +399,8 @@ async function implicitToolchainFingerprintsFresh(
];
const [dependencyResults, linker, assembler, compilerInvocation] = await Promise.all([
Promise.all(
sources.map((source) =>
execFileAsync(compiler, [...probeArgs, source], {
cwd: probeDir,
maxBuffer: 16 * 1024 * 1024,
}),
sources.map((source, index) =>
probeIncludeDependencies(compiler, probeArgs, probeDir, source, sourceGroups[index]!),
),
),
execFileAsync(compiler, [...prefix, "-print-prog-name=ld"], { cwd: probeDir }),
@@ -418,14 +420,7 @@ async function implicitToolchainFingerprintsFresh(
const toolSpellings = [linker.stdout.trim(), assemblerSpelling].filter(
(value, index, all) => value !== "" && all.indexOf(value) === index,
);
const sourceSet = new Set(sources);
const dependencyPaths = [
...new Set(
dependencyResults
.flatMap((dependencies) => parseMakeDependencies(dependencies.stdout))
.filter((path) => !sourceSet.has(path)),
),
].sort();
const dependencyPaths = [...new Set(dependencyResults.flat())].sort();
probe = {
compilerIdentity: compilerIdentity ?? `<unresolved>\0${compiler}`,
compilerInvocation: normalizedProbeInvocation(compilerInvocation, probeDir),
@@ -508,6 +503,51 @@ async function implicitToolchainFingerprintsFresh(
};
}
/** Headers the dependency probe preprocesses at a time when it probes them
* one by one. */
const SINGLE_HEADER_PROBES = 8;
/** The files the compiler reads to preprocess `source`, a probe source that
* includes `includes`, apart from the probe's own sources. When the compiler
* rejects `source`, for example for a header it finds that is an #error
* outside its target (<riscv_vector.h> through clang 18 without the RISC-V
* vector extension), each header is preprocessed on its own, and a header
* that the compiler rejects on its own is left out, as an absent one is.
* Throws the compiler's error when it rejects every header. */
async function probeIncludeDependencies(
compiler: string,
probeArgs: readonly string[],
probeDir: string,
source: string,
includes: readonly string[],
): Promise<string[]> {
const dependencies = async (path: string): Promise<string[]> => {
const result = await execFileAsync(compiler, [...probeArgs, path], {
cwd: probeDir,
maxBuffer: 16 * 1024 * 1024,
});
return parseMakeDependencies(result.stdout).filter((dependency) => dependency !== path);
};
try {
return await dependencies(source);
} catch (error) {
const found: string[][] = [];
for (let start = 0; start < includes.length; start += SINGLE_HEADER_PROBES) {
const batch = includes.slice(start, start + SINGLE_HEADER_PROBES);
const results = await Promise.all(
batch.map(async (include, offset) => {
const single = `${source.slice(0, -2)}-${start + offset}.c`;
await writeFile(single, implicitDependencyIncludeDirective(include));
return dependencies(single).catch(() => null);
}),
);
for (const result of results) if (result !== null) found.push(result);
}
if (found.length === 0) throw error;
return found.flat();
}
}
const stableImplicitToolchainMemos = new Map<string, Promise<ImplicitToolchainFingerprints>>();
export function implicitToolchainFingerprints(
driver: Pick<CcDriver, "argv" | "targetArgs" | "target">,
@@ -76,14 +76,9 @@ export interface CcOptions {
fetch?: boolean;
/** The program uses zlib (index.ts detects zlib.* libCalls on the IR):
* compiles scr_zlib.c — the regex gating precedent, so zlib-free
* binaries keep their exact link line. The default host-clang build links
* the SYSTEM libz (macOS ships it), byte-identical to the historical line;
* every Zig build compiles the vendored zlib with the selected driver instead
* (ensureZlibObjects — zig has no libz in its sysroots). Compressed bytes may
* differ between the
* system and vendored libraries, which is why the corpus only ever
* compares round-trips and fixed-blob inflation, never raw deflate
* output. */
* binaries keep their exact link line. Every driver compiles the vendored
* zlib, the copy Node bundles (ensureZlibObjects), so compressed bytes match
* Node's. */
zlib?: boolean;
/** The program uses node:zlib's Brotli forms (with zlib): compiles
* scr_brotli.c and links the vendored brotli archive. */
@@ -134,6 +129,8 @@ export interface CcOptions {
eventsOnceSignal?: boolean;
/** util.MIMEType and util.MIMEParams: compiles scr_mime.c. */
mime?: boolean;
/** `--large-pages`: compiles scr_text_pages.c. */
largePages?: boolean;
/** util.debounce and util.throttle: compiles scr_util_timing.c. */
utilTiming?: boolean;
/** Buffer.prototype methods called on checked-dynamic values:
@@ -219,18 +219,13 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
// Blob and File (every blob.* call selects dynAsync, whose unit boxes
// their reads as dyn promises).
unit((u) => u.opts.dynAsync, "scr_blob.c"),
// The zlib UNIT (scr_zlib.c) gates on zlib.* IR use; the LINK (system
// libz on the default host-clang build, vendored objects on Zig builds)
// also serves the native fetch's gzip decoder — spread exactly once.
// The zlib UNIT (scr_zlib.c) gates on zlib.* IR use; the vendored zlib
// objects also serve the native fetch's gzip decoder — spread exactly once.
unitArgs((u) =>
u.opts.zlib
? isZigDriver(u.driver)
? ["-I", vendorZlibDir(), u.rt(join(u.rtDir, "scr_zlib.c")), ...u.zlibObjects]
: [u.rt(join(u.rtDir, "scr_zlib.c"))]
? ["-I", vendorZlibDir(), u.rt(join(u.rtDir, "scr_zlib.c")), ...u.zlibObjects]
: u.nativeFetch
? isZigDriver(u.driver)
? ["-I", vendorZlibDir(), ...u.zlibObjects]
: []
? ["-I", vendorZlibDir(), ...u.zlibObjects]
: [],
),
// Brotli (scr_brotli.c) plugs into the zlib engine and links the
@@ -254,6 +249,7 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
// event-target units.
unit((u) => u.opts.eventsOnceSignal === true, "scr_events_once_signal.c"),
unit((u) => u.opts.mime === true, "scr_mime.c"),
unit((u) => u.opts.largePages === true, "scr_text_pages.c"),
unit((u) => u.opts.utilTiming === true, "scr_util_timing.c"),
// The checked-dynamic HANDLE support unit (listener gate + runtime
// adapter closures): every referencing unit is one of the emitter or
@@ -392,7 +388,7 @@ const RUNTIME_DEFINES: readonly {
when: (opts: CcOptions) => boolean | undefined;
}[] = [
{ define: "SCR_TEXT_DECODER_LEGACY", when: (opts) => opts.textDecoderLegacy },
// Checked objects carry the dynamic tier's shapes (scr_json.c's SCR_JS_TIER build); only
// scr_json.c's SCR_JS_TIER build exports the dynamic tier's cache primitives; only
// programs with dynamic-tier code select it.
{ define: "SCR_JS_TIER", when: (opts) => opts.dynTier === true },
{ define: "SCR_WARNING_REPLACE", when: (opts) => opts.warningReplace },
@@ -678,11 +674,11 @@ export async function compileCInternal(
let lreObjects = regex
? lreObjectPaths(sanitize, driver, vendorBuildIdentity, vendorCacheRoot)
: [];
// Vendored zlib is the Zig story — default host-clang builds keep the exact
// historical `-lz` system link (see CcOptions.zlib). The native fetch's
// gzip decoder rides the same objects/link.
// Every driver links the vendored zlib (the copy Node bundles), so
// compressed bytes match Node's. The native fetch's gzip decoder rides the
// same objects.
let zlibObjects =
((opts.zlib ?? false) || nativeFetch) && isZigDriver(driver)
(opts.zlib ?? false) || nativeFetch
? zlibObjectPaths(sanitize, driver, vendorBuildIdentity, vendorCacheRoot)
: [];
const materializeVendorPrerequisites = async (
@@ -831,11 +827,6 @@ export async function compileCInternal(
...(opts.linkInputs ?? []),
...(opts.systemLibraries ?? []).map((name) => `-l${name}`),
...(opts.frameworks ?? []).flatMap((name) => ["-framework", name]),
// GNU ld resolves libraries from left to right and commonly enables
// --as-needed: host-clang libz must follow scr_zlib.c/scr_fetch.c and every
// generated/native input that references inflate symbols. Cross
// and targetless Zig builds use vendored zlib objects in the input section above.
...(((opts.zlib ?? false) || nativeFetch) && !isZigDriver(driver) ? ["-lz"] : []),
...unwinderLinkArgs(driver),
// glibc keeps libm separate from libc. This must trail the generated
// program and every native FFI input because GNU ld resolves archives
@@ -1089,7 +1080,6 @@ export async function compileCInternal(
...(targetPlatform(driver) === "win32" ? ["-ladvapi32", "-liphlpapi", "-lws2_32"] : []),
...(mbedtls && targetPlatform(driver) === "win32" ? ["-lbcrypt"] : []),
...(tlsCa && targetPlatform(driver) === "win32" ? ["-lcrypt32"] : []),
...(((opts.zlib ?? false) || nativeFetch) && !isZigDriver(driver) ? ["-lz"] : []),
...unwinderLinkArgs(driver),
...driver.linkArgs,
...executableLinkFlags,
@@ -1102,7 +1092,6 @@ export async function compileCInternal(
...(targetPlatform(driver) === "win32" ? ["-ladvapi32", "-liphlpapi", "-lws2_32"] : []),
...(mbedtls && targetPlatform(driver) === "win32" ? ["-lbcrypt"] : []),
...(tlsCa && targetPlatform(driver) === "win32" ? ["-lcrypt32"] : []),
...(((opts.zlib ?? false) || nativeFetch) && !isZigDriver(driver) ? ["-lz"] : []),
...unwinderLinkArgs(driver),
...driver.linkArgs,
...executableLinkFlags,
@@ -78,9 +78,8 @@ import { CacheInputsChangedError } from "./session.js";
* External-symbol contract: undefined references only to the target's C/math
* runtime and system APIs. Windows embedders additionally link advapi32,
* iphlpapi, and ws2_32; the platform driver supplies its ordinary CRT and
* kernel imports. Zlib rides the VENDORED per-flavor objects here even on
* hosts because the executable lane's system `-lz` cannot ride inside an
* archive. */
* kernel imports. Zlib rides the vendored per-flavor objects, as in the
* executable lane. */
export async function compileLibArchive(opts: LibArchiveOptions): Promise<void> {
clearCcCaches();
@@ -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",
@@ -91,6 +91,7 @@ export interface RuntimeFeatureSet extends NativeLinkFeatures {
dynForAwait: boolean;
eventsOnceSignal: boolean;
mime: boolean;
largePages: boolean;
utilTiming: boolean;
dynBuffer: boolean;
builtinValues: boolean;
@@ -296,6 +297,7 @@ export function effectiveRuntimeFeatures(
dynForAwait: features.dynForAwait === true,
eventsOnceSignal: features.eventsOnceSignal === true,
mime: features.mime === true,
largePages: features.largePages === true,
utilTiming: features.utilTiming === true,
dynBuffer: features.dynBuffer === true,
builtinValues: features.builtinValues === true,
@@ -18,6 +18,12 @@ const execFileAsync = promisify(execFile);
* runtime pack's executable_section_elimination flags). */
const LRE_SECTION_FLAGS = ["-ffunction-sections", "-fdata-sections"];
/** A vendored source's object file name: sources in subdirectories
* (zlib's contrib/optimizations/inflate.c) flatten to one directory. */
function vendorObjectName(source: string): string {
return source.replaceAll("/", "-").replace(/\.c$/, ".o");
}
import {
QJS_COMMIT,
MBEDTLS_VERSION,
@@ -25,6 +31,7 @@ import {
BROTLI_VERSION,
LRE_SOURCES,
ZLIB_SOURCES,
zlibInputs,
} from "./vendor-inputs.js";
export {
QJS_COMMIT,
@@ -243,11 +250,18 @@ export function createVendorArchives(context: VendorArchiveContext) {
return join(runtimeSrcDir(), "..", "vendor", "zlib");
}
/** The vendored zlib TUs behind Zig zlib support: every root *.c
* except the gzFile file-I/O units (gz*.c — nothing in scr_zlib.c
* references the gzFile API, and those TUs alone want unistd/io headers).
* The default host-clang build links the system libz; Zig builds use this
* list so targetless and explicit-target Zig inputs stay on one toolchain. */
/** The vendored zlib TUs every C-lane build links (zlibInputs picks them
* per target; the gzFile file-I/O units are never compiled, nothing in
* scr_zlib.c references the gzFile API). */
/** The triple zlibInputs reads for a driver: its explicit target, else
* the host. */
function zlibTriple(driver: Pick<CcDriver, "target">): string {
if (driver.target !== null) return driver.target;
const cpu =
process.arch === "x64" ? "x86_64" : process.arch === "arm64" ? "aarch64" : process.arch;
return `${cpu}-${process.platform === "win32" ? "windows" : process.platform}`;
}
function zlibObjectPaths(
sanitize: boolean,
@@ -260,15 +274,15 @@ export function createVendorArchives(context: VendorArchiveContext) {
(driver.argv.length === 1 && driver.argv[0] === "clang" ? "" : "-zigcc") +
`-${vendorCacheTargetFlavor(driver)}-${buildIdentity}`;
const cacheDir = join(cacheRoot, `zlib-${ZLIB_VERSION}-${flavor}`);
return ZLIB_SOURCES.map((f) => join(cacheDir, f.replace(/\.c$/, ".o")));
return zlibInputs(zlibTriple(driver)).sources.map((f) => join(cacheDir, vendorObjectName(f)));
}
/** The zlib objects for one flavor, compiled lazily on the first zlib-using
* Zig build (~1s) and cached like the lre objects —
* C-lane build (~1s) and cached like the lre objects —
* <build-cache>/vendor/zlib-<version>-<flavor>-<target>-<toolchain>/*.o — with the same atomic-rename
* publish (parallel first builds race safely; losers discard their work).
* Plain is -Os, asan matches the final link so the sanitized lane
* instruments the codec too. The flavor keys the driver and target exactly
* Plain is -O3, as Node builds zlib; asan matches the final link so the
* sanitized lane instruments the codec too. The flavor keys the driver and target exactly
* like the lre flavor: the keying
* must never hand a zig-built object set to a clang link off a shared
* directory. */
@@ -282,6 +296,7 @@ export function createVendorArchives(context: VendorArchiveContext) {
(sanitize ? "asan" : "plain") +
(driver.argv.length === 1 && driver.argv[0] === "clang" ? "" : "-zigcc") +
`-${vendorCacheTargetFlavor(driver)}-${buildIdentity}`;
const inputs = zlibInputs(zlibTriple(driver));
return ensureVendorObjects({
sanitize,
driver,
@@ -289,8 +304,9 @@ export function createVendorArchives(context: VendorArchiveContext) {
flavor,
name: "zlib",
vendor: vendorZlibDir(),
sources: ZLIB_SOURCES,
sources: inputs.sources,
objects: zlibObjectPaths(sanitize, driver, buildIdentity, cacheRoot),
flags: [...(sanitize ? [] : ["-O3"]), ...inputs.defines.map((define) => `-D${define}`)],
});
}
@@ -327,7 +343,7 @@ export function createVendorArchives(context: VendorArchiveContext) {
"-c",
join(vendor, f),
"-o",
join(buildDir, f.replace(/\.c$/, ".o")),
join(buildDir, vendorObjectName(f)),
],
{ cwd: buildDir },
);
@@ -0,0 +1,80 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { expect, test } from "vitest";
import { ZLIB_SOURCES, zlibInputs } from "./vendor-inputs.js";
const repoRoot = fileURLToPath(new URL("../../../..", import.meta.url));
const zlibDir = join(repoRoot, "packages/runtime/vendor/zlib");
const TRIPLES = [
// The runtime packs' targets.
"x86_64-unknown-linux-gnu",
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-gnu",
"aarch64-unknown-linux-musl",
"aarch64-unknown-linux-android26",
"arm64-apple-macosx14.0.0",
"x86_64-apple-macosx14.0.0",
"arm64-apple-ios15.0",
"arm64-apple-ios15.0-simulator",
"x86_64-pc-windows-msvc",
"wasm32-unknown-wasi",
// Zig cross targets and host spellings.
"x86_64-linux-gnu",
"aarch64-linux-musl",
"aarch64-macos",
"x86_64-windows-gnu",
"aarch64-windows-gnu",
"wasm32-wasi",
"x86_64-linux",
"aarch64-darwin",
];
test("the compiler's zlib inputs match the runtime packs' for every target", async () => {
const packInputs = (await import(
pathToFileURL(join(repoRoot, "packages/runtime-pack-common/scripts/zlib-inputs.mjs")).href
)) as { zlibInputs(triple: string): { sources: string[]; defines: string[] } };
for (const triple of TRIPLES) {
expect(zlibInputs(triple), triple).toEqual(packInputs.zlibInputs(triple));
}
});
test("every zlib input exists, and ZLIB_SOURCES lists every source a target compiles", () => {
const listed = new Set<string>(ZLIB_SOURCES);
for (const triple of TRIPLES) {
for (const source of zlibInputs(triple).sources) {
expect(listed.has(source), `${triple}: ${source}`).toBe(true);
expect(existsSync(join(zlibDir, source)), source).toBe(true);
}
}
});
test("zlib inputs follow Node's deps/zlib/zlib.gyp", () => {
const x64 = zlibInputs("x86_64-unknown-linux-gnu");
expect(x64.sources).toContain("adler32_simd.c");
expect(x64.sources).toContain("contrib/optimizations/inflate.c");
expect(x64.sources).not.toContain("inflate.c");
expect(x64.defines).toEqual([
"HAVE_HIDDEN",
"ADLER32_SIMD_SSSE3",
"X86_NOT_WINDOWS",
"INFLATE_CHUNK_SIMD_SSE2",
"INFLATE_CHUNK_READ_64LE",
"DEFLATE_SLIDE_HASH_SSE2",
]);
expect(zlibInputs("x86_64-pc-windows-msvc").defines).toContain("X86_WINDOWS");
const mac = zlibInputs("arm64-apple-macosx14.0.0");
expect(mac.sources).toContain("crc32_simd.c");
expect(mac.defines).toContain("ARMV8_OS_MACOS");
expect(mac.defines).toContain("CRC32_ARMV8_CRC32");
const ios = zlibInputs("arm64-apple-ios15.0");
expect(ios.sources).not.toContain("crc32_simd.c");
expect(ios.defines).not.toContain("CRC32_ARMV8_CRC32");
expect(ios.defines).toContain("INFLATE_CHUNK_SIMD_NEON");
expect(zlibInputs("aarch64-unknown-linux-android26").defines).toContain("ARMV8_OS_LINUX");
const wasm = zlibInputs("wasm32-unknown-wasi");
expect(wasm.sources).toContain("inflate.c");
expect(wasm.sources).not.toContain("contrib/optimizations/inflate.c");
expect(wasm.defines).toEqual(["HAVE_HIDDEN", "CPU_NO_SIMD"]);
});
+79 -3
View File
@@ -1,22 +1,98 @@
/** The pinned QuickJS, mbedTLS, zlib, and brotli inputs used by native recipes. */
export const QJS_COMMIT = "3c8f3d68953955950074c41c6e4d999562ae82a7";
export const MBEDTLS_VERSION = "3.6.7";
export const ZLIB_VERSION = "1.3.1";
/** The zlib Node v24.22.0 bundles (`process.versions.zlib` there), the
* Chromium fork in Node's deps/zlib (packages/runtime/vendor/zlib). */
export const ZLIB_VERSION = "1.3.2.1-motley-285e94b";
/** The brotli release Node v24.22.0 bundles (packages/runtime/vendor/brotli). */
export const BROTLI_VERSION = "1.2.0";
/** Exact translation-unit membership shared by cache builds and external
* source-pack recipes. */
export const LRE_SOURCES = ["libregexp.c", "libunicode.c"] as const;
export const ZLIB_SOURCES = [
const ZLIB_COMMON_SOURCES = [
"adler32.c",
"compress.c",
"cpu_features.c",
"crc32.c",
"deflate.c",
"infback.c",
"inffast.c",
"inflate.c",
"inftrees.c",
"trees.c",
"uncompr.c",
"zutil.c",
] as const;
/** Every zlib translation unit some target compiles (zlibInputs). */
export const ZLIB_SOURCES = [
...ZLIB_COMMON_SOURCES,
"adler32_simd.c",
"crc32_simd.c",
"inflate.c",
"contrib/optimizations/inffast_chunk.c",
"contrib/optimizations/inflate.c",
] as const;
/** The zlib translation units and defines for a target, chosen the way
* Node's deps/zlib/zlib.gyp chooses them. The SIMD units carry their own
* target attributes and check the CPU at run time, so every unit builds with
* the same flags, and compressed output does not depend on the choice.
* packages/runtime-pack-common/scripts/zlib-inputs.mjs keeps the same table
* for the runtime packs. `triple` is an LLVM or Zig target triple. */
export function zlibInputs(triple: string): { sources: string[]; defines: string[] } {
const [cpu = "", ...rest] = triple.split("-");
const system = rest.join("-");
const architecture =
cpu === "x86_64" || cpu === "amd64"
? "x64"
: cpu === "aarch64" || cpu === "arm64"
? "arm64"
: cpu;
const ios = system.includes("ios");
const windows =
system.includes("windows") || system.includes("win32") || system.includes("mingw");
// Android reads the CPU features through getauxval, as Linux does: Node's
// ARMV8_OS_ANDROID path needs the NDK's cpu-features library.
const linux = system.includes("linux") || system.includes("android");
const mac = !ios && (system.includes("darwin") || system.includes("macos"));
const sources: string[] = [...ZLIB_COMMON_SOURCES];
const defines = windows ? [] : ["HAVE_HIDDEN"];
if (architecture === "x64" && !ios) {
sources.push(
"adler32_simd.c",
"contrib/optimizations/inffast_chunk.c",
"contrib/optimizations/inflate.c",
);
defines.push(
"ADLER32_SIMD_SSSE3",
windows ? "X86_WINDOWS" : "X86_NOT_WINDOWS",
"INFLATE_CHUNK_SIMD_SSE2",
"INFLATE_CHUNK_READ_64LE",
"DEFLATE_SLIDE_HASH_SSE2",
);
} else if (architecture === "arm64") {
sources.push("contrib/optimizations/inffast_chunk.c", "contrib/optimizations/inflate.c");
defines.push(
"INFLATE_CHUNK_SIMD_NEON",
"INFLATE_CHUNK_READ_64LE",
"__ARM_NEON__",
"DEFLATE_SLIDE_HASH_NEON",
);
if (windows) {
defines.push("ARMV8_OS_WINDOWS");
} else if (linux || mac) {
// Node builds the Adler-32 and CRC-32 units for every arm64 target but
// iOS and Windows.
sources.push("adler32_simd.c", "crc32_simd.c");
defines.push(
"ADLER32_SIMD_NEON",
"CRC32_ARMV8_CRC32",
linux ? "ARMV8_OS_LINUX" : "ARMV8_OS_MACOS",
);
}
} else {
sources.push("inflate.c");
defines.push("CPU_NO_SIMD");
}
return { sources, defines };
}
+9 -1
View File
@@ -117,6 +117,7 @@ async function main(args: string[], host: CliHost): Promise<number> {
values.strip ||
values["windows-subsystem"] !== undefined ||
values["detached-exit"] ||
values["large-pages"] ||
values["target-cpu"] !== undefined ||
values["cpu-dispatch"] !== undefined ||
values["profile-generate"] !== undefined ||
@@ -178,6 +179,7 @@ async function main(args: string[], host: CliHost): Promise<number> {
values.sanitize ||
values["windows-subsystem"] !== undefined ||
values["detached-exit"] ||
values["large-pages"] ||
values["target-cpu"] !== undefined ||
values["cpu-dispatch"] !== undefined ||
values["profile-generate"] !== undefined ||
@@ -270,6 +272,7 @@ async function main(args: string[], host: CliHost): Promise<number> {
values.ffi !== undefined ||
values["windows-subsystem"] !== undefined ||
values["detached-exit"] ||
values["large-pages"] ||
values["target-cpu"] !== undefined ||
values["cpu-dispatch"] !== undefined ||
values["profile-generate"] !== undefined ||
@@ -281,7 +284,7 @@ async function main(args: string[], host: CliHost): Promise<number> {
hasNodeIslandFlags(values)
) {
fail(
"scriptc build --lib takes no --backend/--emit/--print/--optimization/--strip/--windows-subsystem/--detached-exit/--target-cpu/--cpu-dispatch/--profile-generate/--profile-use/--npm-static/--ffi/--external-types/--tier/--explain-dynamic: the profile pins the emission and optimization, npm imports are judged automatically, outbound FFI belongs to executable builds, external type mappings belong to coverage, and library artifacts are static-tier only",
"scriptc build --lib takes no --backend/--emit/--print/--optimization/--strip/--windows-subsystem/--detached-exit/--large-pages/--target-cpu/--cpu-dispatch/--profile-generate/--profile-use/--npm-static/--ffi/--external-types/--tier/--explain-dynamic: the profile pins the emission and optimization, npm imports are judged automatically, outbound FFI belongs to executable builds, external type mappings belong to coverage, and library artifacts are static-tier only",
);
}
const profilePath = resolve(profileArg);
@@ -333,6 +336,9 @@ async function main(args: string[], host: CliHost): Promise<number> {
if (command === "coverage" && values["detached-exit"]) {
fail(`--detached-exit is a build/run option\n\n${USAGE}`);
}
if (command === "coverage" && values["large-pages"]) {
fail(`--large-pages is a build/run option\n\n${USAGE}`);
}
if (command === "coverage" && values["profile-generate"] !== undefined) {
fail(`--profile-generate is a build/run option\n\n${USAGE}`);
}
@@ -510,6 +516,7 @@ async function main(args: string[], host: CliHost): Promise<number> {
...(values.optimization === undefined ? {} : { optimization: values.optimization }),
strip: values.strip,
detachedExit: values["detached-exit"],
largePages: values["large-pages"],
...(values["cpu-dispatch"] === undefined ? {} : { cpuDispatch: values["cpu-dispatch"] }),
...(values["profile-generate"] === undefined
? {}
@@ -638,6 +645,7 @@ async function main(args: string[], host: CliHost): Promise<number> {
: resolve(values.libnode),
}
: {}),
...(values["large-pages"] ? { largePages: true } : {}),
...targetCpuField(targetCpu),
...(values["cpu-dispatch"] === undefined ? {} : { cpuDispatch: values["cpu-dispatch"] }),
// The instrumented program resolves a relative pattern against its own
@@ -11,6 +11,7 @@ export interface OutputOptionValues {
strip?: boolean;
windowsSubsystem?: string;
detachedExit?: boolean;
largePages?: boolean;
cpuDispatch?: boolean;
profileGenerate?: string;
profileUse?: string;
@@ -76,6 +77,9 @@ export function resolveOutputOptions(
if (values.detachedExit && emit !== "exe") {
return { ok: false, message: `--detached-exit is only supported with --emit=exe` };
}
if (values.largePages && emit !== "exe") {
return { ok: false, message: `--large-pages is only supported with --emit=exe` };
}
if (values.cpuDispatch && emit !== "exe") {
return { ok: false, message: `--cpu-dispatch is only supported with --emit=exe` };
}
+6
View File
@@ -69,6 +69,11 @@ Options:
load at run time, or beside to copy it next to the
executable (default: SCRIPTC_LIBNODE at build time;
SCRIPTC_LIBNODE overrides it at run time)
--large-pages Linux: at startup, move the executable's code onto 2 MiB
transparent huge pages when the system allows them, for
large programs. Takes about 3 ms per 10 MiB of code, and
all of the moved code stays resident (opt in; --emit=exe
only)
--target-cpu <baseline|x86-64-v2|x86-64-v3|x86-64-v4|native>
x86-64: compile the program's code for a CPU level
(default: baseline, which runs on any x86-64 CPU). The
@@ -179,6 +184,7 @@ export const CLI_OPTIONS = {
"windows-subsystem": { type: "string" },
"detached-exit": { type: "boolean", default: false },
libnode: { type: "string" },
"large-pages": { type: "boolean", default: false },
"target-cpu": { type: "string" },
"cpu-dispatch": { type: "boolean" },
"profile-generate": { type: "string" },
+4
View File
@@ -48,6 +48,10 @@ export interface CompileBaseOptions {
* SCRIPTC_LIBNODE at build time. SCRIPTC_LIBNODE overrides the recorded
* directory at run time (node-islands/libnode.ts). */
libnode?: string;
/** On Linux, move the executable's code onto 2 MiB transparent huge pages
* at startup when the system allows them. The moved code stays resident.
* Executables only; no effect on other platforms. */
largePages?: boolean;
/** The x86-64 microarchitecture level the executable's code may require
* (`x86-64-v2` to `x86-64-v4`). The program's functions are compiled for
* that level, and the executable then runs only on CPUs that support it.
+13 -2
View File
@@ -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" ||
@@ -237,6 +237,7 @@ test("early executable keys isolate compile modes, paths, implementation, checke
{ ...f.options, optimization: "dev" },
{ ...f.options, optimization: "speed" },
{ ...f.options, detachedExit: true },
{ ...f.options, largePages: true },
{ ...f.options, targetCpu: "x86-64-v3" },
{ ...f.options, targetCpu: "x86-64-v4" },
{ ...f.options, cpuDispatch: true },
@@ -54,6 +54,8 @@ export interface EarlyExecutableNativeFeatures {
optimization?: "dev" | "speed";
workers?: boolean;
youngNursery?: boolean;
/** `--large-pages` links scr_text_pages.c. */
largePages?: boolean;
regex: boolean;
copying: boolean;
textDecoderLegacy: boolean;
@@ -160,6 +162,8 @@ export interface EarlyExecutableCacheOptions {
* into Node records it, and a hit requires the same identity, so the pack
* settings never change the key of a program that does not. */
libnodeIdentity?: () => string | undefined;
/** Omitted unless --large-pages is set. */
largePages?: true;
/** Omitted for the x86-64 baseline (`--target-cpu`). */
targetCpu?: TargetCpu;
/** Omitted for builds without runtime CPU dispatch. */
@@ -174,6 +178,8 @@ export interface EarlyExecutableCacheOptions {
checker?: string;
/** Omitted unless SCRIPTC_YOUNG_NURSERY=1 (backend/young-nursery.ts). */
youngNursery?: true;
/** Omitted unless SCRIPTC_HEAP_CENSUS is set (backend/llvm/heap-census.ts). */
heapCensus?: true;
npmStatic: readonly string[] | "auto" | null;
/** Omitted for the default Node island policy; else nodeIslandOptionsKey. */
nodeIslands?: string;
@@ -325,6 +331,7 @@ function cacheKey(options: EarlyExecutableCacheOptions): string {
...(options.strip ? ["strip"] : []),
...(options.windowsSubsystem === "gui" ? ["windows-subsystem-gui"] : []),
...(options.detachedExit ? ["detached-exit"] : []),
...(options.largePages ? ["large-pages"] : []),
...targetCpuKeyParts(options.targetCpu),
...cpuDispatchKeyParts(options.cpuDispatch),
...(options.profile !== undefined ? [options.profile] : []),
@@ -332,6 +339,7 @@ function cacheKey(options: EarlyExecutableCacheOptions): string {
...(options.checker !== undefined ? [`checker:${options.checker}`] : []),
...(options.youngNursery ? ["young-nursery"] : []),
...(options.nodeIslands !== undefined ? [`node-islands:${options.nodeIslands}`] : []),
...(options.heapCensus ? ["heap-census"] : []),
options.npmStatic === null
? "<npm-static-off>"
: options.npmStatic === "auto"
@@ -371,6 +379,7 @@ function routeKey(options: Omit<EarlyExecutableRouteOptions, "nativeEnvironment"
if (options.strip) hash.update("strip\0");
if (options.windowsSubsystem === "gui") hash.update("windows-subsystem-gui\0");
if (options.detachedExit) hash.update("detached-exit\0");
if (options.largePages) hash.update("large-pages\0");
for (const part of targetCpuKeyParts(options.targetCpu)) hash.update(`${part}\0`);
for (const part of cpuDispatchKeyParts(options.cpuDispatch)) hash.update(`${part}\0`);
if (options.profile !== undefined) hash.update(`${options.profile}\0`);
@@ -378,6 +387,7 @@ function routeKey(options: Omit<EarlyExecutableRouteOptions, "nativeEnvironment"
if (options.checker !== undefined) hash.update(`checker:${options.checker}\0`);
if (options.youngNursery) hash.update("young-nursery\0");
if (options.nodeIslands !== undefined) hash.update(`node-islands:${options.nodeIslands}\0`);
if (options.heapCensus) hash.update("heap-census\0");
hash
.update(
options.npmStatic === null
@@ -12,6 +12,7 @@ import type { ScrDiagnostic } from "../diagnostics/diagnostic.js";
import type { SrcLoc } from "../ir/ir.js";
import { locOf } from "./program.js";
import * as ts from "./ts7/adapter.js";
import { type HeritageElement, isHeritageElement } from "./heritage-elements.js";
export const CONTRACT_TYPES_MODULE = "@scriptc/types";
@@ -87,11 +88,11 @@ function markerOf(checker: ts.TypeChecker, ref: ts.TypeReferenceNode): ContractM
}
/** The marker a heritage clause names (`implements Acyclic`,
* `implements sc.ProgramLifetime`). */
function markerOfHeritage(
checker: ts.TypeChecker,
node: ts.ExpressionWithTypeArguments,
): ContractMarker | null {
* `implements sc.ProgramLifetime`), as an ExpressionWithTypeArguments or,
* in TypeScript 7.1's interface `extends` and class `implements`, a
* TypeReference. */
function markerOfHeritage(checker: ts.TypeChecker, node: HeritageElement): ContractMarker | null {
if (ts.isTypeReferenceNode(node)) return markerOf(checker, node);
const e = node.expression;
if (ts.isIdentifier(e)) return markerNamed(checker, e, e.text, false);
if (ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.expression) && ts.isIdentifier(e.name))
@@ -169,7 +170,7 @@ export function collectContractAnnotations(
ts.walkPreorder(
sf,
(node) => {
if (ts.isExpressionWithTypeArguments(node)) {
if (isHeritageElement(node)) {
const marker = markerOfHeritage(checker, node);
if (marker === null) return;
out.count++;
@@ -232,7 +233,7 @@ function unsupported(out: ContractAnnotations, node: ts.Node, marker: ContractMa
function placeHeritage(
out: ContractAnnotations,
checker: ts.TypeChecker,
node: ts.ExpressionWithTypeArguments,
node: HeritageElement,
marker: ContractMarker,
resolveUnit: ContractUnitResolver,
): void {
@@ -0,0 +1,82 @@
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
import { expect, test } from "vitest";
import { collectContractAnnotations } from "./contract-types.js";
import { isHeritageElement, heritageElementTarget } from "./heritage-elements.js";
import * as ts from "./ts7/adapter.js";
import { Ts7Api } from "./ts7/api.js";
import { Ts7Host } from "./ts7/program-host.js";
import { Ts7RpcClient } from "./ts7/rpc-client.js";
import { TS7_FILE_SYSTEM_CALLBACKS } from "./ts7/rpc-filesystem.js";
import { spawnTs7Wire } from "./ts7/rpc-process.js";
import { ts7TestServers, type Ts7TestServer } from "./ts7/test-servers.js";
const corpus = fileURLToPath(new URL("../../../../tests/corpus/", import.meta.url));
/** Corpus programs whose classes implement contract markers and interfaces. */
const programs = [
"4636-contract-program-lifetime-references.ts",
"5010-lifetime-region-generations.ts",
"7401-program-lifetime-array-teardown.ts",
];
function host(server: Ts7TestServer): Ts7Host {
return new Ts7Host(
(options) =>
new Ts7Api(
new Ts7RpcClient(
spawnTs7Wire(server.executable, [
"--api",
"--cwd",
options.cwd,
`--callbacks=${TS7_FILE_SYSTEM_CALLBACKS}`,
]),
),
options.fs,
options.collectTiming,
),
{ cwd: dirname(corpus) },
);
}
/** Each heritage element's clause token, kind and the text it refers to,
* and the program's contract annotations. */
function facts(server: Ts7TestServer, file: string) {
const programHost = host(server);
try {
const program = programHost.createProgram([corpus + file], {
strict: true,
types: [],
target: ts.ScriptTarget.ESNext,
});
const source = program.getSourceFile(corpus + file)!;
const elements: string[] = [];
ts.walkPreorder(source, (node) => {
if (!isHeritageElement(node)) return undefined;
const clause = node.parent as ts.HeritageClause;
elements.push(
`${clause.token === ts.SyntaxKind.ImplementsKeyword ? "implements" : "extends"} ${ts.getTextOfNode(heritageElementTarget(node))}`,
);
return undefined;
});
const contracts = collectContractAnnotations(program.getTypeChecker(), [source]);
return {
elements,
classes: [...contracts.classes.values()].map((note) => `${note.marker} ${note.name}`),
acyclic: contracts.acyclic.map((entry) => entry.note.name),
diagnostics: contracts.diagnostics.map((diagnostic) => diagnostic.code),
};
} finally {
programHost.close();
}
}
test("heritage elements and the contracts they name read the same from tsc 7.0 and tsc 7.1", () => {
const [old, next] = ts7TestServers();
for (const file of programs) {
const expected = facts(old!, file);
expect(expected.classes.length, file).toBeGreaterThan(0);
expect(expected.diagnostics, file).toEqual([]);
expect(facts(next!, file), file).toEqual(expected);
}
});
@@ -0,0 +1,28 @@
import * as ts from "./ts7/adapter.js";
/** An element of a heritage clause. TypeScript 7.0 parses every element as
* an ExpressionWithTypeArguments. TypeScript 7.1 parses the elements of an
* interface's `extends` and a class's `implements` as TypeReference nodes,
* whose names are entity names (an Identifier or a QualifiedName); a class's
* `extends` element stays an ExpressionWithTypeArguments. */
export type HeritageElement = ts.ExpressionWithTypeArguments | ts.TypeReferenceNode;
/** Whether `node` is a TypeReference that is a heritage clause element. */
export function isHeritageTypeReference(node: ts.Node): node is ts.TypeReferenceNode {
return (
ts.isTypeReferenceNode(node) && node.parent !== undefined && ts.isHeritageClause(node.parent)
);
}
/** Whether `node` is a heritage clause element in either form. */
export function isHeritageElement(node: ts.Node): node is HeritageElement {
return ts.isExpressionWithTypeArguments(node) || isHeritageTypeReference(node);
}
/** The name or expression a heritage clause element refers to: its
* expression (an ExpressionWithTypeArguments) or its type name (a
* TypeReference). */
export function heritageElementTarget(element: ts.Node): ts.Expression | ts.EntityName {
if (ts.isTypeReferenceNode(element)) return element.typeName;
return (element as ts.ExpressionWithTypeArguments).expression;
}
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import * as fs from "node:fs";
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
@@ -301,6 +302,36 @@ test("a settled file is checked by its identity, and any write invalidates it",
expect(frontendInputsStillMatch(snapshot)).toBe(false);
});
test("a settled file an earlier run read keeps that run's digest until it is written", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-inputs-"));
scratch.push(dir);
const source = join(dir, "entry.ts");
await writeFile(source, "export const answer = 1;\n");
const settledRead = (tracker: FrontendInputTracker, later: number) => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(Date.now() + later);
try {
tracker.runSynchronous(() => trackedReadFile(source));
} finally {
vi.useRealTimers();
}
const probe = tracker.snapshot().probes.find((entry) => entry.op === "file");
return probe?.op === "file" ? probe : undefined;
};
const first = settledRead(new FrontendInputTracker(), 5000)!;
expect(first.identity).toBeDefined();
// A digest only the previous snapshot holds shows it was not computed again.
const marker = "f".repeat(64);
const previous = { version: 1 as const, stable: true, probes: [{ ...first, digest: marker }] };
expect(settledRead(new FrontendInputTracker(previous), 5000)?.digest).toBe(marker);
await writeFile(source, "export const answer = 2;\n");
const rewritten = settledRead(new FrontendInputTracker(previous), 10000);
expect(rewritten?.identity).not.toBe(first.identity);
expect(rewritten?.digest).toBe(
createHash("sha256").update("export const answer = 2;\n").digest("hex"),
);
});
test("a file changed shortly before the read is hashed on every check", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-inputs-"));
scratch.push(dir);
@@ -127,6 +127,23 @@ let synchronousTracker: FrontendInputTracker | undefined;
export class FrontendInputTracker {
private readonly probes = new Map<string, FrontendInputProbe>();
private stable = true;
/** The digests `previous` recorded for files read with a settled identity. */
private readonly known = new Map<string, { identity: string; digest: string }>();
/** `previous` is the snapshot of an earlier run whose cache entry is being
* rebuilt: a file whose settled identity has not changed since then has the
* content it hashed, so its digest is not computed again. */
constructor(previous?: FrontendInputSnapshot) {
for (const probe of previous?.probes ?? [])
if (probe.op === "file" && probe.identity !== undefined)
this.known.set(probe.path, { identity: probe.identity, digest: probe.digest });
}
/** The digest an earlier run recorded for `path` with this identity. */
knownDigest(path: string, identity: string): string | undefined {
const known = this.known.get(path);
return known?.identity === identity ? known.digest : undefined;
}
run<T>(fn: () => T): T {
const parent = activeTracker.getStore();
@@ -254,7 +271,11 @@ export function trackedReadFile(path: string): string | null {
if (virtual !== undefined) return virtual;
try {
const { text, identity } = readWithIdentity(path);
const digest = frontendSourceDigest(text);
const known =
identity === null
? undefined
: (synchronousTracker ?? activeTracker.getStore())?.knownDigest(path, identity);
const digest = known ?? frontendSourceDigest(text);
record(
identity === null ? { op: "file", path, digest } : { op: "file", path, digest, identity },
);
@@ -10,6 +10,7 @@ import * as ts from "../../ts7/adapter.js";
import { type Lowerer } from "../lowerer.js";
import { isJsSourceFile, locOf } from "../../program.js";
import { stripTypeWrappers } from "../typed-arguments.js";
import { keyedHelperName } from "../stable-names.js";
import { dynUndefinedExpr, nodeThrowExpr } from "../../../ir/build.js";
import { lowerArrayFromCall, lowerArrayOfCall } from "../containers/array-construction.js";
import { boundedArrayHofHelper } from "../containers/array-callbacks.js";
@@ -305,6 +306,7 @@ export function lowerArrayClassInstanceOf(
const value = lowerer.lowerExpr(expr.left);
if (value.type.kind === "array")
return { kind: "instanceOf", value, className: info.def.name, type: BOOL, loc };
if (value.type.kind === "union") return arrayClassUnionInstanceOf(lowerer, value, info, loc);
if (value.type.kind === "dyn")
return {
kind: "libCall",
@@ -324,6 +326,62 @@ export function lowerArrayClassInstanceOf(
return null;
}
/** `x instanceof X` for a class X declared `extends Array` on a union (an
* element read of an array of X instances is `X | undefined`): an array arm
* tests the array's class at run time, a checked-dynamic arm asks the
* runtime, and every other arm is no instance. */
function arrayClassUnionInstanceOf(
lowerer: Lowerer,
value: IrExpr,
info: ClassInfo,
loc: SrcLoc,
): IrExpr | null {
if (value.type.kind !== "union") return null;
const unionId = value.type.unionId;
const arms = lowerer.unions.get(unionId)?.arms;
if (arms === undefined) return null;
const className = info.def.name;
const key = `instanceof.arrayClass:${unionId}:${className}`;
let helper = lowerer.valueHelpers.get(key);
if (!helper) {
helper = keyedHelperName(lowerer, "%instanceof.arrayClass", key);
lowerer.valueHelpers.set(key, helper);
const param: IrExpr = { kind: "varRef", localId: "value.0", type: value.type, loc };
const body: IrStmt[] = [];
arms.forEach((arm, tag) => {
if (arm.kind !== "array" && arm.kind !== "dyn") return;
const narrowed: IrExpr = { kind: "unionNarrow", unionId, tag, value: param, type: arm, loc };
const test: IrExpr =
arm.kind === "array"
? { kind: "instanceOf", value: narrowed, className, type: BOOL, loc }
: {
kind: "libCall",
fn: "array.dynIsClass",
args: [narrowed, { kind: "strLit", value: className, type: STRING, loc }],
type: BOOL,
loc,
};
body.push({
kind: "if",
cond: { kind: "unionIsTag", unionId, tag, negated: false, value: param, type: BOOL, loc },
then: [{ kind: "return", value: test, loc }],
else_: null,
loc,
});
});
body.push({ kind: "return", value: { kind: "boolLit", value: false, type: BOOL, loc }, loc });
lowerer.liftedFns.push({
name: helper,
params: [{ localId: "value.0", name: "value", type: value.type }],
returnType: BOOL,
locals: [{ id: "value.0", name: "value", type: value.type, mutable: false }],
body,
loc,
});
}
return { kind: "call", callee: helper, args: [value], type: BOOL, loc };
}
/** The class declared `extends Array` that a value of `type` is an instance
* of, or null. */
export function arrayClassOfType(lowerer: Lowerer, type: ts.Type): ClassInfo | null {
@@ -41,6 +41,7 @@ function context() {
protocolDispatches: new Map(),
classMethodValueSelections: new Map(),
isSubclassOf: () => false,
nodeAtLoc: () => null,
} as unknown as Lowerer;
const boxed = fn("box", [
statement({
@@ -38,6 +38,7 @@ import {
import { streamTypedRefEligible } from "../../ir/analysis.js";
import { everyStmtList, transformExpr, transformStmtList } from "../../ir/traverse.js";
import { PoisonError, type Lowerer } from "./lowerer.js";
import { isSloppyAt } from "./frozen-records.js";
import { erasedGenericInstance, implicitDefaultInstance } from "./generic-functions.js";
import { type ParamShape } from "./call-signatures.js";
import {
@@ -2418,6 +2419,11 @@ export class ClassDynamicDispatch {
computedRead.key.operand.type.kind === "f64"
? computedRead.key
: null;
// A sloppy-mode write ignores a refused assignment
// (sloppyDynamicWrites), so its sites share their own helper,
// whose write takes the first such site's source range.
const sloppyWrite =
!!computedWrite && !reflect && isSloppyAt(lowerer, computedWrite.loc);
const key = JSON.stringify([
!!computedWrite,
computedRead?.optional ?? false,
@@ -2426,6 +2432,7 @@ export class ClassDynamicDispatch {
numericKey,
reflect,
!!indexKey,
...(sloppyWrite ? ["sloppy"] : []),
]);
let dispatch = this.computed.get(key);
if (!dispatch) {
@@ -652,6 +652,17 @@ export function lowerArrayLiteral(
return lowered;
});
if (!needsStateBuild) {
// `[...text]`: the code points call already builds a fresh array of the
// literal's type, so it is the literal's value, as `Array.from(text)` is.
const only = elems[0];
if (
elems.length === 1 &&
spreads.length === 1 &&
only?.kind === "libCall" &&
only.fn === "str.codePoints" &&
typeEquals(only.type, type)
)
return { ...only, loc };
return { kind: "arrayLit", elems, ...(spreads.length > 0 ? { spreads } : {}), type, loc };
}
const out = lowerer.declareHiddenLocal("%arrayLit", type);
@@ -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 ||

Some files were not shown because too many files have changed in this diff Show More