Merge main

This commit is contained in:
Chris Tate
2026-10-28 13:19:00 -05:00
486 changed files with 26941 additions and 1221 deletions
+84 -1
View File
@@ -17,6 +17,11 @@ on:
type: choice
options: [full, darwin]
default: full
scope:
description: Every lane, or only the shape overlay validation (shape_overlay)
type: choice
options: [all, shape_overlay]
default: all
concurrency:
# A new push to a pull request cancels its previous run. Every push to main runs to completion in a group of
@@ -59,7 +64,10 @@ jobs:
sameRepository && trustedAuthor && typeof stackBase === 'string' && stackBase.length > 0 &&
typeof prBase === 'string' && prBase.length > 0 && stackBase !== prBase &&
prBase !== defaultBranch;
const full = !upperLayer;
// A manual run of the shape overlay validation alone skips the other lanes.
const shapeOnly = process.env.GITHUB_EVENT_NAME === 'workflow_dispatch' &&
event.inputs?.scope === 'shape_overlay';
const full = !upperLayer && !shapeOnly;
appendFileSync(process.env.GITHUB_OUTPUT, `full=${full}\n`);
console.log(full ? 'Run full CI' : 'Defer full CI until this PR becomes the lowest unmerged layer');
NODE
@@ -390,6 +398,81 @@ jobs:
- run: tar -xzf "$RUNNER_TEMP/native-linux-x64-gnu.tar.gz"
- run: pnpm test tests/harness/three.test.ts -t "'nodes' runs statically through LLVM on 'native'"
# Shape inference's overlay (opt-in, SCRIPTC_SHAPE_OVERLAY=1) over the
# suites of real packages: nightly and on manual runs only (scope
# shape_overlay runs it alone), outside the `test` roll-up. Every case
# must build and run as in the default build. It builds its own native
# packages, so it does not depend on the other lanes.
shape_overlay_native:
if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
name: build (Linux x64 native packages, shape overlay)
runs-on: ubuntu-24.04
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
- uses: ./.github/actions/llvm-linux
- run: pnpm install --frozen-lockfile
- uses: ./.github/actions/zig
- uses: ./.github/actions/native-packages
with:
target: linux-x64-gnu
- name: Preserve executable permissions in shared packages
run: >-
tar -czf "$RUNNER_TEMP/native-linux-x64-gnu.tar.gz"
packages/llvm-linux-x64-gnu/bin
packages/runtime-linux-x64-gnu/artifacts
packages/runtime-linux-x64-gnu/runtime-pack.json
- uses: actions/upload-artifact@v4
with:
name: native-linux-x64-gnu-shape-overlay
path: ${{ runner.temp }}/native-linux-x64-gnu.tar.gz
retention-days: 1
shape_overlay:
needs: [shape_overlay_native]
if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
name: test (Linux, shape overlay, ${{ matrix.suite }} ${{ matrix.shard }}/8)
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
suite: [npm, npm-static, three, effect]
shard: [1, 2, 3, 4, 5, 6, 7, 8]
env:
SCRIPTC_SHAPE_OVERLAY: "1"
SCRIPTC_TEST_WORKERS: "1"
SCRIPTC_EFFECT_TEST_CONCURRENCY: "1"
SCRIPTC_TEST_SHARD: ${{ matrix.shard }}/8
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-linux-x64-gnu-shape-overlay
path: ${{ runner.temp }}
- run: tar -xzf "$RUNNER_TEMP/native-linux-x64-gnu.tar.gz"
- name: Run the suite with the overlay on
run: |
case "${{ matrix.suite }}" in
npm) pnpm test tests/harness/npm-shape-overlay.test.ts ;;
npm-static) pnpm test tests/harness/npm-static.test.ts ;;
three) pnpm test tests/harness/three.test.ts ;;
effect) pnpm test tests/harness/effect.test.ts tests/harness/effect4.test.ts ;;
esac
# Build each instrumented seed once. Downstream contracts retain the same
# compiler and source artifacts without repeating seed construction.
bootstrap_seed:
+2
View File
@@ -1,4 +1,6 @@
node_modules/
# A symlinked node_modules is a file to git, which node_modules/ does not match.
/packages/*/node_modules
.pnpm-store/
# test-fixture packages are TEST DATA, committed on purpose
!tests/diagnostics/npm-imports/node_modules/
+1
View File
@@ -245,6 +245,7 @@
"packages/compiler/src/frontend/npm-static-rewrite.ts",
"packages/compiler/src/frontend/npm-static-bundled-cjs.ts",
"packages/compiler/src/library/semantic-source.ts",
"packages/compiler/src/node-islands/effect-gate.ts",
],
"rules": {
"no-restricted-imports": [
+4 -1
View File
@@ -10,7 +10,8 @@ To prepare a release:
4. Wrap the new entry in `<!-- release:start -->` and `<!-- release:end -->` markers; this marked block is also the GitHub release body
5. Remove the `<!-- release:start -->` and `<!-- release:end -->` markers from the previous release entry; only the latest release should have markers
6. With Zig on `PATH`, run `SCRIPTC_CROSS=1 pnpm exec vitest run tests/harness/library-cross.test.ts` and require the cross-target library conformance lane to pass
7. Commit to `main`
7. Refresh the runtime profile on a quiet Linux x64 host: `node scripts/refresh-runtime-profile.mjs --tsc-ts <tsc-ts checkout> --project <TypeORM>:tsconfig.bench.json --project <T3 server>:tsconfig.bench.json --project <VS Code>/src:tsconfig.bench.json`, and include the new `packages/runtime-pack-common/profiles/linux-x64-gnu.profdata.gz` (see the runtime profile paragraph below)
8. Commit to `main`
CI (`.github/workflows/release.yml`) compares the version in `packages/cli/package.json` to what `scriptc` has on npm. If it differs, it builds platform packages on matching macOS, Linux, and Windows runners, verifies the version spine, and publishes the runtime, helper, compiler, and CLI packages in dependency order. After the publish succeeds, a separate job creates the git tag `v<version>` and the GitHub release with the marked changelog entry as its body, and attaches `surface-manifest.json` — the machine-readable listing of the surface the static tier compiles at that version (stable per-entry ids, so two releases diff mechanically; see `packages/compiler/src/coverage/surface-manifest.ts` for the schema). The job regenerates the manifest from the tree and fails on any byte difference from the committed file before attaching, so the asset is always the manifest of the code being released. The same file ships inside the `@scriptc/compiler` package as `@scriptc/compiler/surface-manifest.json`.
@@ -20,6 +21,8 @@ Release builds of the native CLI optimize the compiler with a profile collected
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.
The Linux x64 runtime pack optimizes its speed-flavor units with the committed runtime profile `packages/runtime-pack-common/profiles/linux-x64-gnu.profdata.gz`. Runtime functions whose code changed since the profile was collected are optimized as without a profile. The pack manifest records how the profile matched (`flavors.speed.runtime_profile`): the matched and changed functions, and the share of the profile's counts on changed functions. `packages/runtime-pack-common/test/runtime-profile.test.ts` fails when changed functions hold more than 5% of the counts, and names the functions with the most counts. Refresh the profile in each release commit (step 7), and between releases when that test fails.
Publishing uses npm trusted publishing (OIDC) — there is no npm token secret.
Each published package must have a GitHub Actions trusted publisher for
`release.yml` and the `Release` environment. A missing configuration fails
+1
View File
@@ -21,6 +21,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea
| `json-records` | cpu | `JSON.stringify`, typed `JSON.parse`, Map aggregation |
| `json-unions` | cpu | `JSON.parse` into discriminated unions: an event log with eight kinds and nested expression trees with nine, evaluated after the cast |
| `keyed-option-writes` | cpu | class methods whose names are also keys of option records written through element accesses: `enabled[name] = true` over a union of literal names and `totals["count"] = n` |
| `layered-errors` | cpu | validation errors thrown below a stack of small functions that hold strings and arrays, and caught per entry in a loop: about half of 640,000 entries fail |
| `line-reader` | cpu | `readline` over a file stream: 'line' events for a 67 MB access log (every 50th line not ASCII), with a status count, a path tally and byte totals |
| `log-lines` | cpu | 200k immediate `console.log`/`process.stdout.write` lines into the runner's pipe |
| `log-summary`, `inventory-report` | cpu | the multi-module applications from `benchmarks/builds`, on 300k-line inputs |
@@ -0,0 +1,6 @@
{
"entry": "workloads/layered-errors.ts",
"args": [
"1"
]
}
@@ -0,0 +1,95 @@
// A configuration loader whose validation errors are thrown at the bottom
// of a stack of small layers and caught per entry at the top: each layer
// holds strings and arrays while the error passes through it.
class ConfigError extends Error {
readonly key: string;
constructor(key: string, message: string) {
super(message);
this.key = key;
}
}
let seed = 4242;
function random(): number {
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
return seed / 4294967296;
}
function fail(key: string, text: string): never {
throw new ConfigError(key, `invalid value "${text}" for ${key}`);
}
function digits(key: string, text: string): number {
let value = 0;
for (let i = 0; i < text.length; i++) {
const c = text.charCodeAt(i);
if (c < 48 || c > 57) fail(key, text);
value = value * 10 + (c - 48);
}
return value;
}
function parseNumber(key: string, text: string): number {
return text.length === 0 ? fail(key, text) : digits(key, text);
}
function checkRange(key: string, text: string, max: number): number {
const value = parseNumber(key, text);
return value > max ? fail(key, text) : value;
}
function coerce(key: string, raw: string, max: number): number {
const trimmed = raw.trim();
const parts = [key, trimmed];
const value = checkRange(key, trimmed, max);
return value + parts.length;
}
function resolveValue(key: string, raw: string, defaults: Map<string, string>): number {
const text = raw === "default" ? (defaults.get(key) ?? "") : raw;
const label = "value:" + key;
return coerce(key, text, 100000) + label.length;
}
function loadEntry(section: string, entry: string, defaults: Map<string, string>): number {
const eq = entry.indexOf("=");
const key = section + "." + entry.slice(0, eq);
const path = [section, key];
return resolveValue(key, entry.slice(eq + 1), defaults) + path.length;
}
function loadEntryWithContext(section: string, entry: string, defaults: Map<string, string>): number {
const context = `${section}/${entry.length}`;
return loadEntry(section, entry, defaults) + context.length;
}
function makeEntry(i: number): string {
const r = random();
const name = "k" + (i % 97);
if (r < 0.3) return `${name}=x${i}`;
if (r < 0.4) return `${name}=`;
if (r < 0.5) return `${name}=${200000 + i}`;
if (r < 0.55) return `${name}=default`;
return `${name}= ${Math.floor(random() * 90000)} `;
}
const rounds = Number(process.argv[2] ?? "1");
const defaults = new Map<string, string>();
for (let i = 0; i < 97; i += 2) defaults.set(`net.k${i}`, String(i * 10));
const sections = ["net", "db", "cache", "auth"];
const entries: string[] = [];
for (let i = 0; i < 4000; i++) entries.push(makeEntry(i));
let loaded = 0;
let sum = 0;
let failed = 0;
const failedKeys = new Map<string, number>();
for (let round = 0; round < rounds * 40; round++) {
for (const section of sections) {
for (const entry of entries) {
try {
sum += loadEntryWithContext(section, entry, defaults);
loaded++;
} catch (error) {
if (!(error instanceof ConfigError)) throw error;
failed++;
failedKeys.set(error.key, (failedKeys.get(error.key) ?? 0) + 1);
}
}
}
}
console.log(`loaded ${loaded} failed ${failed} sum ${sum} distinct failing keys ${failedKeys.size}`);
console.log(`net.k0 failures ${failedKeys.get("net.k0") ?? 0}`);
+21 -2
View File
@@ -176,7 +176,7 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
<dd>On Linux, build an instrumented executable for profile-guided optimization. Each run writes a raw profile to the path pattern, where <code>%p</code> expands to the process ID; the <code>LLVM_PROFILE_FILE</code> environment variable overrides the pattern at run time. A relative pattern resolves from the current working directory. Instrumented programs run slower. With <code>--profile-use</code>, the build is optimized by that profile and instrumented after inlining, the second phase of a <a href="#context-sensitive-profiles">context-sensitive profile</a>. Valid only for executable builds with <code>--optimization=release</code> or <code>speed</code>.</dd>
<dt><code>--profile-use &lt;file.profdata&gt;</code></dt>
<dd>Optimize the executable with a profile from <code>scriptc merge-profiles</code>: code is laid out, inlined, and split by how the profiled runs used it. Code the profile does not describe, or describes from an older version of the program, is optimized as without a profile, so a stale profile only loses part of its gain. A new or changed profile compiles every partition of the program again. <code>scriptc build</code> prints how the profile matched the program's functions on stderr; see <a href="#profile-guided-optimization">Profile-guided optimization</a>. A profile with <a href="#context-sensitive-profiles">context-sensitive counts</a> also orders each function's blocks by them on Linux. Valid only for executable builds with <code>--optimization=release</code> or <code>speed</code>.</dd>
<dd>Optimize the executable with a profile from <code>scriptc merge-profiles</code>: code is laid out, inlined, and split by how the profiled runs used it. Code the profile does not describe, or describes from an older version of the program, is optimized as without a profile, so a stale profile only loses part of its gain; a <a href="#block-counts">block-count file</a> beside the profile gives changed functions their counts again. A new or changed profile compiles every partition of the program again. <code>scriptc build</code> prints how the profile matched the program's functions on stderr; see <a href="#profile-guided-optimization">Profile-guided optimization</a>. A profile with <a href="#context-sensitive-profiles">context-sensitive counts</a> also orders each function's blocks by them on Linux. Valid only for executable builds with <code>--optimization=release</code> or <code>speed</code>.</dd>
<dt><code>--npm-static &lt;pkg[,pkg…]|auto&gt;</code></dt>
<dd>Name npm packages to compile even if they don't ship their own type declarations (repeatable). A trailing <code>...</code>, as in <code>fs-extra...</code>, also names every package that package depends on. <code>auto</code> uses the default automatic selection. Packages that can't compile produce diagnostics. See <a href="/docs/dependencies#explicit-package-selection">npm Dependencies</a>.</dd>
@@ -266,7 +266,7 @@ $ scriptc merge-profiles -o app.profdata profiles/*.profraw
$ scriptc build app.ts --optimization=speed --profile-use app.profdata
```
Train on workloads like the ones the program will run: code the training never reached is optimized as without a profile. The profile can be committed and reused as the program changes. Functions whose control flow changed since the profile was collected lose their profile, and the rest keep theirs, so regenerate it when much of the program has changed. [Build parallelism](/docs/how-it-works#build-parallelism) describes what a profile adds to build time.
Train on workloads like the ones the program will run: code the training never reached is optimized as without a profile. The profile can be committed and reused as the program changes, and in other checkouts and on other machines: it names functions by the entry's file name, not its directory. Functions whose control flow changed since the profile was collected lose their profile, and the rest keep theirs, so regenerate it when much of the program has changed. [Build parallelism](/docs/how-it-works#build-parallelism) describes what a profile adds to build time.
`scriptc build --profile-use` prints one line on stderr with how the profile matched the program's functions:
@@ -304,6 +304,25 @@ $ scriptc build app.ts --optimization=speed --profile-use app-cs.profdata
A profile that already holds context-sensitive counts cannot start a second phase. Merge the second phase's raw profiles into the first-phase profile instead. Both phases run the same workloads.
### Block counts
A profile matches a function only while the function's control flow hashes as it did when the profile was collected, so a change to the program or to scriptc leaves the changed functions without counts. A block-count file beside the profile, with its name and a `.blocks` extension in place of `.profdata` (`app.blocks` beside `app.profdata`), describes each function by the labels of its blocks instead. `--profile-use` reads it when it exists and annotates the functions whose control flow changed (the mismatched ones; a function the profile has no record of keeps no counts): blocks that keep their place in the function take their recorded counts, and the counts of the other blocks follow from the counts of the blocks and edges around them. The report then adds a line, and the warning counts only the functions that remain without counts:
```
scriptc: profile app-cs.profdata: 0 functions annotated, 1 mismatched, 0 without a record
scriptc: profile app-cs.profdata: 1 function without profile counts annotated from app-cs.blocks
```
A `--profile-use` build writes the block counts of the functions its profile annotates when the `SCRIPTC_RECORD_PROFILE_BLOCKS` environment variable names a path; such a build reuses no cached artifacts. For a context-sensitive profile, record them in the second phase, which the first-phase profile annotates:
```console
$ SCRIPTC_RECORD_PROFILE_BLOCKS=app-cs.blocks scriptc build app.ts --optimization=speed --profile-use app.profdata --profile-generate profiles-cs/%p.profraw -o app-instrumented-cs
```
A new or changed block-count file compiles every partition of the program again. Delete it when the profile it was recorded with is replaced.
The file holds the counts before inlining. With a context-sensitive profile, a function whose control flow changed, and a function it is inlined into, also lose their context-sensitive counts and keep the counts from before inlining.
## Target CPU
By default, x86-64 executables run on any x86-64 CPU, so the compiler uses only the baseline instruction set (SSE2). `--target-cpu` compiles the program's code for a higher x86-64 microarchitecture level. Each level includes the ones below it:
+8
View File
@@ -366,6 +366,10 @@ Static code has no `WeakRef`, a `FinalizationRegistry` never runs its cleanup ca
A long-running program, such as a compiler in watch mode, can free program-lifetime instances one generation at a time with `LifetimeRegion` from [`@scriptc/experimental`](/docs/experimental). Instances created while `region.run(fn)` runs belong to the region. After `region.release()`, scriptc frees them together once nothing outside the region refers to any of them, and removes the `WeakMap` and `WeakSet` entries they are keys of. Instances created outside every region are never freed, as described above.
### Inline array fields
An array field of a program-lifetime class whose elements are program-lifetime instances can keep its elements in the instance itself, after the other fields, with `InlineArray` from [`@scriptc/experimental`](/docs/experimental). The constructor writes the field once from one of its parameters; reads that index the field, read its `length` or iterate it load the elements from the instance, and other reads get an array of them. The array passed to the constructor is one that nothing else keeps, and the arrays fields hold are never written. scriptc reports a field that does not keep these rules (SC7009).
## Diagnostics
Each failed contract names the declaration and the reason:
@@ -468,5 +472,9 @@ $ scriptc build reject.ts
<td>SC7008</td>
<td>A <code>ProgramLifetime</code> class is not the root of its hierarchy, or is part of a library build.</td>
</tr>
<tr>
<td>SC7009</td>
<td>An <code>InlineArray</code> field is not on a <code>ProgramLifetime</code> class, is written other than once from a constructor parameter, or is read in a way that could tell its reads apart.</td>
</tr>
</tbody>
</table>
+70
View File
@@ -334,3 +334,73 @@ scriptc requires:
Each instance of a key class, and of every class in its hierarchy, carries a 4-byte slot index, 0 until a value is set for it. The first `set` for a key assigns the index; indices are never reused. `get` and `has` read the index, a page of the thread's family and the entry, with no lock, hash or atomic update. A family stores values in pages of 1024 entries, allocated as the thread sets values in them.
A family holds a reference to each of the thread's values. A value is released when it is replaced or deleted, when its key is freed, and when the thread exits. Keys that are freed with a [`LifetimeRegion`](#lifetimeregion) keep their values until the thread exits, a value that belongs to a region keeps the region allocated while the family holds it, and a value that refers to its own key keeps the key alive. Programs that never construct a `ThreadSlots` do not run any of its code, and their classes have no slot index.
## InlineArray
`InlineArray<T>` marks an array field of a [`ProgramLifetime`](/docs/contracts#programlifetime) class whose elements scriptc keeps in the instance itself, after its other fields, instead of in an array object of their own. It suits a program with millions of short arrays that never change once built, such as the children of each node of a syntax tree: an ordinary array costs a separate allocation with a header, and a reference to it in the instance.
```ts:lists.ts
import type { ProgramLifetime } from "@scriptc/types";
import type { InlineArray } from "@scriptc/experimental";
class Token implements ProgramLifetime {
readonly text: string;
constructor(text: string) {
this.text = text;
}
}
class TokenList implements ProgramLifetime {
readonly pos: number;
readonly tokens: InlineArray<Token>;
constructor(pos: number, tokens: readonly Token[]) {
this.pos = pos;
this.tokens = tokens;
}
}
function width(list: TokenList): number {
let total = 0;
for (const token of list.tokens) total += token.text.length;
return total;
}
const list = new TokenList(0, ["let", "x", "=", "1"].map((text) => new Token(text)));
console.log(list.tokens.length, width(list), list.tokens[0]!.text);
console.log(list.tokens.map((token) => token.text).join(" "));
```
Both runtimes print the same:
```console
$ node lists.ts
4 6 let
let x = 1
$ scriptc build lists.ts -o lists >/dev/null
$ ./lists
4 6 let
let x = 1
```
### Field rules
scriptc requires:
- `T` to be a `ProgramLifetime` class, and the field to be an instance field of a `ProgramLifetime` class;
- the constructor of the class that declares the field to write it once, from one of its parameters. A subclass constructor passes one of its own parameters there through `super(...)`. Nothing else writes the field;
- the array passed to the constructor to be one that nothing else keeps: a new array (an array literal, a copy such as `slice()`, `[...items]` or `Array.from(items)`, or the result of a function that returns only such arrays or its own argument), a local array at its last use (calls that only read it or add to it may come first, and a loop may pass it and then assign a new array to the local), a parameter whose callers each pass such an array, an array that an InlineArray field already holds, or a `const` array that is never written, such as a shared empty array;
- the arrays fields hold never to be written, through a read of the field (which the `readonly` type allows only through a cast) or through any other reference. scriptc follows these arrays through locals, parameters, return values, fields, the elements of arrays, Maps and Sets, and closures, and reports one that reaches a write, a dynamic value (also inside the fields of an object that becomes one), a call through a function value (also inside an array, Map or Set passed there), or a library call other than printing and `JSON.stringify` without a replacer. The report names the field read the array may come from;
- the arrays fields hold, and their reads, not to be compared by identity: with `===` or `!==`, as an `indexOf` or `includes` argument, or as a Map key or Set element. Two reads of the field need not give the same array. Compare the elements instead, or keep a copy made with `slice()` where identity matters;
- instances of the class not to be constructed through a class value, and not to become dynamic values (for example through a conversion to `unknown`), directly or inside another value that can hold one: an instance of a class with such a field, a record, array, Map or Set, or a value typed as a base class. A dynamic view reads the field as a new array each time, so a class whose instances can become dynamic values cannot hold an InlineArray field. Printing, `JSON.stringify` without a replacer, `publish` and a worker's `workerData` only read or copy the value and are allowed. The report names the conversion.
scriptc reports a field that does not keep these rules (SC7009).
### Reads
Indexing the field, reading its `length` and iterating it with `for...of` read the elements from the instance, directly or through a `const` local used only in those ways. Any other use, such as passing the field to a function or calling one of its methods, gets an array of the elements, which is frozen in scriptc builds. Under Node.js the field holds the array the constructor stored. The two agree because the field rules keep that array from being written.
### Inline array cost
Each element takes 4 bytes in the instance, and the field's own slot holds the count. The constructor copies the elements from the array it is passed, so that array can be released. The elements start at the same offset in every class of the hierarchy, after the largest set of fixed fields. A read that gets an array allocates a small header that refers to the instance's elements, and frees it when the read's value is released.
The elements stay in an ordinary array object, and the field behaves like any other array field, in builds with sanitizers, in programs that use [`LifetimeRegion`](#lifetimeregion), and on platforms other than 64-bit Linux.
@@ -65,4 +65,7 @@
Their underlying sources, sinks, and transformers, queuing strategies with a `size` function, readers and writers, `tee`, `pipeTo` and `pipeThrough` with their options and an abort signal, `ReadableStream.from`, and async iteration settle promises in Node's order, and the streams take own properties.
`TextDecoderStream` decodes UTF-8; its other encodings are unsupported.
A JavaScript `ReadableStream` given as a `Response` or `Request` body, or as `fetch`'s body, is read through a stream of its own: `response.body` is that stream rather than the one passed in, and the stream passed in is locked from the start.
In JavaScript, a `Response` or `Request` body has the members of a `ReadableStream`, including `tee`, `pipeTo`, `pipeThrough`, `values`, and `for await`, and its lock and `bodyUsed` follow them.
After one of those members is used, a body given to a new `Response` or `Request` is locked from the start, as a JavaScript `ReadableStream` is.
In a program that names none of the web stream classes, those members are available when the program's JavaScript names one of them or iterates a body with `for await`; reached only through a computed key, they are not.
In JavaScript, a byte stream (`type: 'bytes'`), a BYOB reader, a class that extends one of these classes, `Object.getPrototypeOf` on these values, their use as `WeakMap` and `WeakSet` keys, and `ReadableStream.from` of a function that is not iterable are unsupported.
+16
View File
@@ -316,6 +316,22 @@
{
"text": "Slot cost",
"id": "slot-cost"
},
{
"text": "InlineArray",
"id": "inlinearray"
},
{
"text": "Field rules",
"id": "field-rules"
},
{
"text": "Reads",
"id": "reads"
},
{
"text": "Inline array cost",
"id": "inline-array-cost"
}
]
},
+1
View File
@@ -88,6 +88,7 @@ add_executable(scriptc-llvm-codegen
src/emit.cpp
src/main.cpp
src/partition.cpp
src/profile-blocks.cpp
src/profile.cpp
src/runtime-import.cpp
src/runtime-unit.cpp
+246 -39
View File
@@ -3,6 +3,7 @@
#include "cache.h"
#include "diagnostics.h"
#include "partition.h"
#include "profile-blocks.h"
#include "runtime-import.h"
#include "target.h"
@@ -13,6 +14,7 @@
#include "llvm/ADT/StringMap.h"
#include "llvm/ADT/StringRef.h"
#include "llvm/ADT/StringSet.h"
#include "llvm/Analysis/ProfileSummaryInfo.h"
#include "llvm/Analysis/RuntimeLibcallInfo.h"
#include "llvm/Analysis/TargetLibraryInfo.h"
#include "llvm/Bitcode/BitcodeReader.h"
@@ -34,6 +36,7 @@
#include "llvm/PassInfo.h"
#include "llvm/PassRegistry.h"
#include "llvm/Passes/PassBuilder.h"
#include "llvm/ProfileData/InstrProf.h"
#include "llvm/ProfileData/InstrProfReader.h"
#include "llvm/Support/CommandLine.h"
#include "llvm/Support/PGOOptions.h"
@@ -41,6 +44,7 @@
#include "llvm/Support/Caching.h"
#include "llvm/Support/FileSystem.h"
#include "llvm/Support/MemoryBuffer.h"
#include "llvm/Support/Path.h"
#include "llvm/Support/SHA256.h"
#include "llvm/Support/SourceMgr.h"
#include "llvm/Support/ThreadPool.h"
@@ -104,6 +108,10 @@ std::optional<EmitOptions> parseEmitOptions(int Argc, char **Argv) {
Options.ProfileGenerate = Value.str();
else if (Arg == "--profile-use")
Options.ProfileUse = Value.str();
else if (Arg == "--profile-blocks")
Options.ProfileBlocks = Value.str();
else if (Arg == "--record-profile-blocks")
Options.RecordProfileBlocks = Value.str();
else if (Arg == "--split" && Value == "codegen")
Options.SplitCodegen = true;
else
@@ -245,37 +253,46 @@ struct UnnameStructTypes : PassInfoMixin<UnnameStructTypes> {
// control flow changed since it was collected (hash mismatch), and functions
// 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.
// in Unknown. Recovered counts the mismatched functions that --profile-blocks
// annotated, so it never exceeds Mismatched.
struct ProfileMatch {
std::atomic<uint64_t> Annotated{0};
std::atomic<uint64_t> Mismatched{0};
std::atomic<uint64_t> Missing{0};
std::atomic<uint64_t> Recovered{0};
std::atomic<uint64_t> Unknown{0};
// Set once the annotation before inlining has run. The context-sensitive
// annotation after inlining, which a single-module pipeline also runs,
// reports its mismatched and missing functions again; they are not
// counted twice.
std::atomic<bool> Counted{false};
void add(const ProfileMatch &Other) {
Annotated += Other.Annotated;
Mismatched += Other.Mismatched;
Missing += Other.Missing;
Recovered += Other.Recovered;
}
std::string serialize() const {
return formatv("{0} {1} {2}", Annotated.load(), Mismatched.load(),
Missing.load())
return formatv("{0} {1} {2} {3}", Annotated.load(), Mismatched.load(),
Missing.load(), Recovered.load())
.str();
}
bool deserialize(StringRef Text) {
SmallVector<StringRef, 3> Parts;
SmallVector<StringRef, 4> Parts;
Text.split(Parts, ' ');
uint64_t Values[3];
if (Parts.size() != 3)
uint64_t Values[4];
if (Parts.size() != 4)
return false;
for (size_t I = 0; I < 3; ++I)
for (size_t I = 0; I < 4; ++I)
if (Parts[I].getAsInteger(10, Values[I]))
return false;
Annotated += Values[0];
Mismatched += Values[1];
Missing += Values[2];
Recovered += Values[3];
return true;
}
};
@@ -285,7 +302,9 @@ struct ProfileMatch {
// context's default handling.
class ProfileMatchHandler : public DiagnosticHandler {
public:
explicit ProfileMatchHandler(ProfileMatch &Match) : Match(Match) {}
explicit ProfileMatchHandler(
ProfileMatch &Match, std::vector<std::string> *MismatchedNames = nullptr)
: Match(Match), MismatchedNames(MismatchedNames) {}
bool handleDiagnostics(const DiagnosticInfo &Info) override {
if (Info.getKind() != DK_PGOProfile)
@@ -294,17 +313,28 @@ public:
raw_string_ostream Stream(Message);
DiagnosticPrinterRawOStream Printer(Stream);
Info.print(Printer);
if (StringRef(Message).contains("hash mismatch"))
++Match.Mismatched;
else if (StringRef(Message).contains_insensitive("no profile data"))
++Match.Missing;
else
bool Mismatched = StringRef(Message).contains("hash mismatch");
if (!Mismatched &&
!StringRef(Message).contains_insensitive("no profile data"))
return false;
if (Match.Counted)
return true;
if (!Mismatched) {
++Match.Missing;
return true;
}
++Match.Mismatched;
// "...(hash mismatch) <function> Hash = <hash>"
if (MismatchedNames != nullptr) {
StringRef Name = StringRef(Message).split("(hash mismatch) ").second;
MismatchedNames->push_back(Name.split(" Hash = ").first.str());
}
return true;
}
private:
ProfileMatch &Match;
std::vector<std::string> *MismatchedNames;
};
// The annotation warns about functions the profile does not describe only
@@ -319,17 +349,61 @@ void reportMissingProfiles() {
(void)Reported;
}
// Functions of `M` that the profile annotated with counts.
uint64_t annotatedFunctions(const Module &M) {
uint64_t Count = 0;
for (const Function &F : M) {
// The --profile-blocks counts, read once before any partition needs them
// (inspectProfile), or null without them.
std::unique_ptr<BlockCounts> FallbackCounts;
// The block counts a --record-profile-blocks build collects, or null.
std::unique_ptr<BlockRecorder> Recorder;
// Whether the profile annotation found F's control flow changed: it tags
// such a function with this annotation.
bool hasHashMismatchTag(const Function &F) {
if (const MDNode *Annotation = F.getMetadata(LLVMContext::MD_annotation))
for (const MDOperand &Operand : Annotation->operands())
if (const auto *Name = dyn_cast<MDString>(Operand))
if (Name->getString() == "instr_prof_hash_mismatch")
return true;
return false;
}
// Whether F has counts from a profile.
bool hasProfileCounts(const Function &F) {
std::optional<Function::ProfileCount> Entry = F.getEntryCount();
return Entry && !Entry->isSynthetic();
}
// Runs right after the profile annotation, before inlining and dead code
// elimination change the functions it annotated: counts them and records
// their block counts, then annotates the functions whose control flow
// changed from FallbackCounts. A function the profile has no record of keeps
// no counts, as without block counts. Returns the analyses that remain valid.
PreservedAnalyses afterProfileAnnotation(Module &M, ProfileMatch &Match) {
std::vector<Function *> Mismatched;
for (Function &F : M) {
if (F.isDeclaration())
continue;
std::optional<Function::ProfileCount> Entry = F.getEntryCount();
if (Entry && !Entry->isSynthetic())
++Count;
if (!hasProfileCounts(F)) {
if (hasHashMismatchTag(F))
Mismatched.push_back(&F);
continue;
}
++Match.Annotated;
if (Recorder)
Recorder->add(F);
}
return Count;
if (!FallbackCounts)
return PreservedAnalyses::all();
ProfileSummaryInfo Summary(M);
PreservedAnalyses Preserved = PreservedAnalyses::all();
for (Function *F : Mismatched) {
auto Record = FallbackCounts->find(F->getName());
if (Record == FallbackCounts->end())
continue;
annotateFromBlocks(*F, Record->second, Summary);
++Match.Recovered;
Preserved = PreservedAnalyses::none();
}
return Preserved;
}
// Prints the profile match as the successful emit's one line of JSON.
@@ -337,6 +411,7 @@ void reportProfileMatch(const ProfileMatch &Match) {
json::Object Profile{{"annotated", Match.Annotated.load()},
{"mismatched", Match.Mismatched.load()},
{"missing", Match.Missing.load()},
{"recovered", Match.Recovered.load()},
{"unknown_partitions", Match.Unknown.load()}};
json::Object Response{{"ok", true}, {"profile", std::move(Profile)}};
outs() << formatv("{0}\n", json::Value(std::move(Response)));
@@ -369,9 +444,36 @@ std::optional<EmitFailure> inspectProfile(const EmitOptions &Options) {
enableProfileLayout();
enableExtTspPlacement();
}
if (!Options.ProfileBlocks.empty()) {
std::string Error;
FallbackCounts = readProfileBlocks(Options.ProfileBlocks, Error);
if (!FallbackCounts)
return EmitFailure{"profile_blocks_read_failed", Error};
}
if (!Options.RecordProfileBlocks.empty())
Recorder = std::make_unique<BlockRecorder>();
return std::nullopt;
}
// Writes the block counts a --record-profile-blocks build collected.
std::optional<EmitFailure> writeRecordedBlocks(const EmitOptions &Options) {
if (!Recorder)
return std::nullopt;
if (std::optional<std::string> Error =
Recorder->write(Options.RecordProfileBlocks))
return EmitFailure{"profile_blocks_write_failed",
Options.RecordProfileBlocks + ": " + *Error};
return std::nullopt;
}
// The source file name a program's modules carry: the entry's file name,
// without its directory. Profiles name a module's local functions after it
// ("<name>;<function>"), so a profile collected in one checkout matches the
// builds of every other one.
std::string moduleSourceName(StringRef SourcePath) {
return sys::path::filename(SourcePath).str();
}
// Owns the analysis managers a pipeline needs for one module.
struct Pipeline {
PassInstrumentationCallbacks Callbacks;
@@ -391,16 +493,41 @@ struct Pipeline {
PB.crossRegisterProxies(LAM, FAM, CGAM, MAM);
}
// Adds to Count the functions the profile annotation gave counts to, as
// soon as that pass has run: inlining and dead code elimination later in
// the pipeline delete annotated functions.
void countAnnotated(std::atomic<uint64_t> &Count) {
// Runs afterProfileAnnotation as soon as the profile annotation has run.
// The context-sensitive annotation after inlining runs the same pass
// again, on functions the first one already counted.
void afterProfileAnnotation(ProfileMatch &Match) {
Callbacks.registerAfterPassCallback(
[&Count](StringRef PassID, Any IR, const PreservedAnalyses &) {
[this, &Match, Done = false](StringRef PassID, Any IR,
const PreservedAnalyses &) mutable {
if (Done || PassID != "PGOInstrumentationUse")
return;
const auto *M = llvm::any_cast<const Module *>(&IR);
if (M == nullptr)
return;
Done = true;
// The callback receives the module the pipeline runs on; only the
// signature is const.
Module &Annotated = const_cast<Module &>(**M);
MAM.invalidate(Annotated,
scriptc::afterProfileAnnotation(Annotated, Match));
Match.Counted = true;
});
}
// Adds to Names the profile names of the functions the annotation gave
// counts to, as soon as that pass has run.
void collectAnnotated(std::vector<std::string> &Names) {
Callbacks.registerAfterPassCallback(
[&Names](StringRef PassID, Any IR, const PreservedAnalyses &) {
if (PassID != "PGOInstrumentationUse")
return;
if (const auto *M = llvm::any_cast<const Module *>(&IR))
Count += annotatedFunctions(**M);
for (const Function &F : **M) {
std::optional<Function::ProfileCount> Entry = F.getEntryCount();
if (!F.isDeclaration() && Entry && !Entry->isSynthetic())
Names.push_back(getIRPGOFuncName(F));
}
});
}
};
@@ -592,7 +719,7 @@ emitModule(Module &M, TargetMachine &Machine, OptimizationLevel Level,
std::make_unique<ProfileMatchHandler>(Match));
Pipeline P(Machine, Level, std::move(Profile));
if (Annotating)
P.countAnnotated(Match.Annotated);
P.afterProfileAnnotation(Match);
P.PB.buildPerModuleDefaultPipeline(Level).run(M, P.MAM);
if (std::optional<EmitFailure> Failure =
verify(M, "post_optimization_verification_failed"))
@@ -678,7 +805,7 @@ Expected<std::unique_ptr<Module>> loadPartition(const EmitOptions &Options,
return createStringError(inconvertibleErrorCode(), Stream.str());
}
if (!Options.SourcePath.empty())
Mod->setSourceFileName(Options.SourcePath);
Mod->setSourceFileName(moduleSourceName(Options.SourcePath));
Mod->setTargetTriple(Triple(Options.Target));
Mod->setDataLayout(Machine.createDataLayout());
if (std::optional<EmitFailure> Failure = verify(*Mod, "verification_failed"))
@@ -767,7 +894,9 @@ compileOptimizedPartitions(const EmitOptions &Options,
SharedFailure Failures;
forEachPartition(Count, [&](size_t I) {
std::string Key = hashParts({Salt, "summary", Plan.Modules[I]});
if ((Summaries[I] = Cache.lookup(Key, "bc"))) {
// A reused summary has no block counts to record, so a recording build
// annotates every partition again.
if (!Recorder && (Summaries[I] = Cache.lookup(Key, "bc"))) {
if (Annotating) {
std::unique_ptr<MemoryBuffer> Counts = Cache.lookup(Key, "match");
if (!Counts || !Match.deserialize(Counts->getBuffer()))
@@ -809,7 +938,7 @@ compileOptimizedPartitions(const EmitOptions &Options,
// link; the post-link optimization reads the annotated weights.
Pipeline P(*Machine, Level, profileOptions(Options));
if (Annotating)
P.countAnnotated(PartitionMatch.Annotated);
P.afterProfileAnnotation(PartitionMatch);
ModulePassManager MPM = P.PB.buildThinLTOPreLinkDefaultPipeline(Level);
MPM.addPass(UnnameStructTypes());
// The module hash is set at the thin link instead (see Names).
@@ -1016,6 +1145,17 @@ compilePartitions(const EmitOptions &Options, const PartitionPlan &Plan,
Profile.getError().message()};
Salt = hashParts({Salt, "profile-use", (*Profile)->getBuffer()});
}
// The block counts change the partitions they annotate.
if (!Options.ProfileBlocks.empty()) {
ErrorOr<std::unique_ptr<MemoryBuffer>> Blocks =
MemoryBuffer::getFile(Options.ProfileBlocks, /*IsText=*/false,
/*RequiresNullTerminator=*/false);
if (!Blocks)
return EmitFailure{"profile_blocks_read_failed",
Options.ProfileBlocks + ": " +
Blocks.getError().message()};
Salt = hashParts({Salt, "profile-blocks", (*Blocks)->getBuffer()});
}
for (const std::string &Path : Options.ImportBitcode) {
ErrorOr<std::unique_ptr<MemoryBuffer>> Bitcode =
MemoryBuffer::getFile(Path, /*IsText=*/false,
@@ -1115,6 +1255,8 @@ int emitPartitions(const EmitOptions &Options) {
Failure = writePartitionObjects(Options, Objects, Temporaries);
for (size_t I = 0; !Failure && I < Temporaries.size(); ++I)
Failure = publish(Temporaries[I], Options.Outputs[I]);
if (!Failure)
Failure = writeRecordedBlocks(Options);
if (Failure) {
for (const SmallString<256> &Temporary : Temporaries)
sys::fs::remove(Temporary);
@@ -1309,7 +1451,7 @@ emitSplitCodegen(const EmitOptions &Options, Module &M, TargetMachine &Machine,
{
Pipeline P(Machine, Level, profileOptions(Options));
if (Annotating)
P.countAnnotated(Match.Annotated);
P.afterProfileAnnotation(Match);
P.PB.buildPerModuleDefaultPipeline(Level).run(M, P.MAM);
}
if (std::optional<EmitFailure> Failure =
@@ -1371,6 +1513,44 @@ emitSplitCodegen(const EmitOptions &Options, Module &M, TargetMachine &Machine,
return Failure;
}
// Lists the annotated and the mismatched functions of one module in
// `Result`, each with the sum of its counters in the profile. `ProfileNames`
// maps the module's symbol names, which the mismatch warnings use, to
// profile names.
std::optional<std::string>
weighProfileMatch(const std::string &ProfileUse,
const StringMap<std::string> &ProfileNames,
const std::vector<std::string> &AnnotatedNames,
const std::vector<std::string> &MismatchedNames,
ModuleProfileMatch &Result) {
Expected<std::unique_ptr<IndexedInstrProfReader>> Reader =
IndexedInstrProfReader::create(ProfileUse, *vfs::getRealFileSystem());
if (!Reader)
return ProfileUse + ": " + toString(Reader.takeError());
StringMap<uint64_t> Counts;
for (const NamedInstrProfRecord &Record : **Reader) {
uint64_t Sum = 0;
for (uint64_t Count : Record.Counts)
Sum += Count;
Counts[Record.Name] += Sum;
}
if (Error E = (*Reader)->getError())
return ProfileUse + ": " + toString(std::move(E));
auto CountsOf = [&Counts](StringRef Name) -> uint64_t {
auto It = Counts.find(Name);
return It == Counts.end() ? 0 : It->second;
};
for (const std::string &Name : AnnotatedNames)
Result.AnnotatedCounts.emplace_back(Name, CountsOf(Name));
for (const std::string &Name : MismatchedNames) {
auto It = ProfileNames.find(Name);
std::string ProfileName = It == ProfileNames.end() ? Name : It->second;
uint64_t Sum = CountsOf(ProfileName);
Result.MismatchedCounts.emplace_back(std::move(ProfileName), Sum);
}
return std::nullopt;
}
} // namespace
std::optional<std::string> optimizeModule(Module &M, TargetMachine &Machine,
@@ -1393,18 +1573,34 @@ std::optional<std::string> optimizeModule(Module &M, TargetMachine &Machine,
}
bool Annotating = Profile && Profile->Action == PGOOptions::IRUse;
ProfileMatch Match;
if (Annotating)
// The annotation reports a mismatched function by its symbol name; the
// profile names local functions after their source file.
StringMap<std::string> ProfileNames;
std::vector<std::string> MismatchedNames;
std::vector<std::string> AnnotatedNames;
if (Annotating) {
for (const Function &F : M)
if (!F.isDeclaration())
ProfileNames[F.getName()] = getIRPGOFuncName(F);
M.getContext().setDiagnosticHandler(
std::make_unique<ProfileMatchHandler>(Match));
std::make_unique<ProfileMatchHandler>(Match, &MismatchedNames));
}
{
Pipeline P(Machine, Level, std::move(Profile));
if (Annotating)
P.countAnnotated(Match.Annotated);
if (Annotating) {
P.afterProfileAnnotation(Match);
P.collectAnnotated(AnnotatedNames);
}
P.PB.buildPerModuleDefaultPipeline(Level).run(M, P.MAM);
}
Result.Annotated = Match.Annotated.load();
Result.Mismatched = Match.Mismatched.load();
Result.Missing = Match.Missing.load();
if (Annotating) {
if (std::optional<std::string> Failure = weighProfileMatch(
ProfileUse, ProfileNames, AnnotatedNames, MismatchedNames, Result))
return Failure;
}
if (std::optional<EmitFailure> Failure =
verify(M, "post_optimization_verification_failed"))
return Failure->Message;
@@ -1450,6 +1646,12 @@ int emit(const EmitOptions &Options) {
return reportError("invalid_import",
"runtime bitcode import requires an optimized build",
Options.DiagnosticFormat);
if (Options.ProfileUse.empty() &&
(!Options.ProfileBlocks.empty() || !Options.RecordProfileBlocks.empty()))
return reportError("invalid_profile_blocks",
"--profile-blocks and --record-profile-blocks require "
"--profile-use",
Options.DiagnosticFormat);
if (!Options.ProfileUse.empty())
reportMissingProfiles();
if (std::optional<EmitFailure> Failure = inspectProfile(Options))
@@ -1476,7 +1678,7 @@ int emit(const EmitOptions &Options) {
return reportError("invalid_ir", Stream.str(), Options.DiagnosticFormat);
}
if (!Options.SourcePath.empty())
Mod->setSourceFileName(Options.SourcePath);
Mod->setSourceFileName(moduleSourceName(Options.SourcePath));
std::string LookupError;
std::unique_ptr<TargetMachine> Machine =
@@ -1522,8 +1724,11 @@ int emit(const EmitOptions &Options) {
: CodeGenFileType::AssemblyFile;
if (Options.SplitCodegen) {
ProfileMatch Match;
if (std::optional<EmitFailure> Failure =
emitSplitCodegen(Options, *Mod, *Machine, Level, Match))
std::optional<EmitFailure> Failure =
emitSplitCodegen(Options, *Mod, *Machine, Level, Match);
if (!Failure)
Failure = writeRecordedBlocks(Options);
if (Failure)
return reportError(Failure->Code, Failure->Message,
Options.DiagnosticFormat);
if (!Options.ProfileUse.empty())
@@ -1537,6 +1742,8 @@ int emit(const EmitOptions &Options) {
Options.Outputs[0], Temporaries, Match);
if (!Failure)
Failure = publish(Temporaries[0], Options.Outputs[0]);
if (!Failure)
Failure = writeRecordedBlocks(Options);
if (Failure) {
for (const SmallString<256> &Temporary : Temporaries)
sys::fs::remove(Temporary);
+14 -1
View File
@@ -3,6 +3,7 @@
#include <cstdint>
#include <optional>
#include <string>
#include <utility>
#include <vector>
namespace llvm {
@@ -34,6 +35,13 @@ struct EmitOptions {
std::string ProfileGenerate;
// Indexed profile (.profdata) that guides optimization. Empty: none.
std::string ProfileUse;
// With ProfileUse: block counts (profile-blocks.h) that annotate the
// functions whose control flow changed since the profile was collected.
// Empty: none.
std::string ProfileBlocks;
// With ProfileUse: writes the block counts of the functions the profile
// annotates to this path. Empty: none.
std::string RecordProfileBlocks;
// With several outputs: optimize the module whole and divide only its code
// generation among the outputs.
bool SplitCodegen = false;
@@ -44,11 +52,16 @@ int emit(const EmitOptions &Options);
// How a profile matched the functions of one optimized module: functions
// that received its counts, functions whose control flow changed since it
// was collected, and functions it has no record of.
// was collected, and functions it has no record of. The annotated and the
// mismatched functions are also listed by profile name, with the sum of the
// profile's counters for each, so a changed hot function can weigh more than
// a changed cold one.
struct ModuleProfileMatch {
uint64_t Annotated = 0;
uint64_t Mismatched = 0;
uint64_t Missing = 0;
std::vector<std::pair<std::string, uint64_t>> AnnotatedCounts;
std::vector<std::pair<std::string, uint64_t>> MismatchedCounts;
};
// Optimizes `M` with the per-module default pipeline at `OptLevel`, as `emit`
+114 -11
View File
@@ -36,6 +36,10 @@ struct Symbol {
// Local definitions are rewritten with hidden visibility.
std::string PromotedHeader;
std::vector<unsigned> Refs;
// The functions its body calls directly (sorted).
std::vector<unsigned> Calls;
// A variable defined with "constant", whose loads the optimizer can fold.
bool Constant = false;
std::vector<unsigned> Metadata;
// The source-module group the emitter wrote before this definition, or
// empty.
@@ -74,6 +78,8 @@ uint32_t stableHash(StringRef Name) {
return Hash;
}
constexpr unsigned MinGroupedPartitions = 32;
// Places the functions whose root carries a source-module group by group: a
// module's functions call each other most, so keeping them together leaves
// fewer calls for the thin link to import across partitions. A group larger
@@ -83,11 +89,11 @@ uint32_t stableHash(StringRef Name) {
// lighter of two partitions chosen by hashes of its key, after the functions
// placed by name. The placement depends only on the module text and the
// partition count. Modules with fewer than MinGroupedPartitions partitions
// keep the placement by name: with few partitions per core, the balance of
// the largest partitions decides the build time more than the imports.
// keep the placement by name, which balanceRoots evens out: with few
// partitions per core, the balance of the largest partitions decides the
// build time more than the imports.
void placeGroups(std::vector<Symbol> &Symbols, ArrayRef<unsigned> Roots,
unsigned Count) {
constexpr unsigned MinGroupedPartitions = 32;
if (Count < MinGroupedPartitions)
return;
auto Bytes = [](const Symbol &S) -> uint64_t {
@@ -153,6 +159,67 @@ void placeGroups(std::vector<Symbol> &Symbols, ArrayRef<unsigned> Roots,
Symbols[I].Partition = Where[Keys[I]];
}
// In modules of fewer than MinGroupedPartitions partitions, which
// placeGroups leaves placed by name, the functions of each root (a
// function and the chain that follows it) move together to another partition
// when their name's partition would hold more than 1.25 times the mean
// function bytes: each root probes the next partitions in turn, keeping the
// first with room, else its own. An edit then recompiles about a mean
// partition, and the largest partition, which bounds a cold build, stays
// near the mean unless one root is larger. Roots and their bytes, and so
// placement, depend only on the module text.
void balanceRoots(std::vector<Symbol> &Symbols, ArrayRef<unsigned> Roots,
ArrayRef<char> Live, unsigned Count) {
if (Count >= MinGroupedPartitions)
return;
DenseMap<unsigned, uint64_t> RootBytes;
uint64_t Total = 0;
for (unsigned I = 0; I < Symbols.size(); ++I)
if (Symbols[I].Kind == SymbolKind::Function && Live[I])
RootBytes[Roots[I]] += Symbols[I].Header.size() + Symbols[I].Body.size();
// Each root counts in whole 4 KiB units, so that an edit that grows a root
// by less than that leaves every comparison, and every other root's
// partition, unchanged.
constexpr uint64_t Unit = 4096;
for (auto &Entry : RootBytes) {
Entry.second = (Entry.second + Unit - 1) / Unit * Unit;
Total += Entry.second;
}
std::vector<unsigned> Order;
for (auto &Entry : RootBytes)
Order.push_back(Entry.first);
llvm::sort(Order, [&](unsigned A, unsigned B) {
return Symbols[A].Name < Symbols[B].Name;
});
uint64_t Cap = Total * 5 / 4 / Count;
std::vector<uint64_t> Load(Count, 0);
DenseMap<unsigned, unsigned> Placed;
// Roots of more than half the cap are placed first, in name order, so
// that small roots fill around them; the others follow in name order.
auto Place = [&](unsigned Root) {
uint64_t Bytes = RootBytes[Root];
unsigned Home = stableHash(Symbols[Root].Name) % Count, Chosen = Home;
for (unsigned K = 0; K < Count; ++K) {
unsigned P = (Home + K) % Count;
if (Load[P] + Bytes <= Cap) {
Chosen = P;
break;
}
}
Load[Chosen] += Bytes;
Placed[Root] = Chosen;
};
for (unsigned Root : Order)
if (RootBytes[Root] * 2 > Cap)
Place(Root);
for (unsigned Root : Order)
if (RootBytes[Root] * 2 <= Cap)
Place(Root);
for (unsigned I = 0; I < Symbols.size(); ++I)
if (Symbols[I].Kind == SymbolKind::Function && Live[I])
Symbols[I].Partition = Placed[Roots[I]];
}
bool startsWithLinkage(StringRef Rest) {
for (StringRef Linkage :
{"linkonce", "weak", "common", "appending", "extern_weak",
@@ -278,6 +345,7 @@ bool parseGlobal(StringRef Line, Symbol &S) {
}
if (Cursor.consume_front("constant ")) {
Keyword = "constant";
S.Constant = true;
break;
}
size_t Space = Cursor.find(' ');
@@ -325,10 +393,13 @@ bool parseGlobal(StringRef Line, Symbol &S) {
}
// Collects the symbols (sorted) and metadata nodes (in order of first
// appearance) that Text references outside quoted strings.
// appearance) that Text references outside quoted strings, and in Calls the
// symbols it names as direct callees, which the emitter writes followed by
// "(".
void scanReferences(StringRef Text, const StringMap<unsigned> &Index,
std::vector<unsigned> &Refs,
std::vector<unsigned> &Metadata) {
std::vector<unsigned> &Metadata,
std::vector<unsigned> *Calls = nullptr) {
DenseSet<unsigned> Seen(Metadata.begin(), Metadata.end());
bool Quoted = false;
for (size_t I = 0; I < Text.size(); ++I) {
@@ -343,8 +414,12 @@ void scanReferences(StringRef Text, const StringMap<unsigned> &Index,
size_t Length = nameLength(Text, I + 1);
if (Length != 0) {
auto It = Index.find(Text.substr(I + 1, Length));
if (It != Index.end())
if (It != Index.end()) {
Refs.push_back(It->second);
if (Calls && I + 1 + Length < Text.size() &&
Text[I + 1 + Length] == '(')
Calls->push_back(It->second);
}
I += Length;
}
} else if (C == '!' && I + 1 < Text.size() && isDigit(Text[I + 1])) {
@@ -359,6 +434,10 @@ void scanReferences(StringRef Text, const StringMap<unsigned> &Index,
}
llvm::sort(Refs);
Refs.erase(std::unique(Refs.begin(), Refs.end()), Refs.end());
if (Calls) {
llvm::sort(*Calls);
Calls->erase(std::unique(Calls->begin(), Calls->end()), Calls->end());
}
}
// Appends Text with every unquoted metadata reference renumbered by Map.
@@ -539,7 +618,7 @@ std::optional<ProgramPartitions> partitionProgramText(StringRef Text,
scanReferences(S.Header, Index, S.Refs, S.Metadata);
if (!S.Body.empty()) {
std::vector<unsigned> Refs;
scanReferences(S.Body, Index, Refs, S.Metadata);
scanReferences(S.Body, Index, Refs, S.Metadata, &S.Calls);
S.Refs.insert(S.Refs.end(), Refs.begin(), Refs.end());
llvm::sort(S.Refs);
S.Refs.erase(std::unique(S.Refs.begin(), S.Refs.end()), S.Refs.end());
@@ -594,7 +673,15 @@ std::optional<ProgramPartitions> partitionProgramText(StringRef Text,
// inlining candidate of it, or data only it uses; keep it with that
// definition so its partition can inline it without importing, and so a
// helper an edit adds lands in the partition of the code that uses it. A
// chain through a global continues to the function that uses the global.
// function that its referrer only takes the address of is not an inlining
// candidate: a closure body whose address its creator passes to the
// runtime, or a compiled regular expression's matcher that a mutable
// global points to, gives the referrer nothing to inline, and joining it
// would make every edit of the referrer optimize it again. Such a function
// is placed by its own name, unless its body is under AddressOnlyBytes (a
// trampoline or thread entry, which costs little to optimize again). A
// chain through a constant global, whose loads the optimizer can turn into
// direct calls, continues to the function that uses the global.
// A function body larger than this is past LLVM's inline cost limit even
// for the only call to a local function, so it is placed by its own name.
// A chain stops where it would leave a function's source-module group for
@@ -608,9 +695,14 @@ std::optional<ProgramPartitions> partitionProgramText(StringRef Text,
// partition, the program is optimized whole (see emitPartitions).
constexpr size_t MaxInlinedBodyBytes = 256 * 1024;
constexpr size_t GroupedChainBytes = 1024 * 1024;
constexpr size_t AddressOnlyBytes = 512;
bool GroupedChains = Text.size() > GroupedChainBytes;
constexpr unsigned Shared = ~1u;
std::vector<unsigned> Referrer(Symbols.size(), NoPartition);
// Whether a definition may follow its first referrer: it is not a
// function, or the referrer calls it or is a constant global, or its body
// is under AddressOnlyBytes.
std::vector<char> MayFollow(Symbols.size(), 0);
for (unsigned I = 0; I < Symbols.size(); ++I) {
const Symbol &S = Symbols[I];
if (S.Kind == SymbolKind::Declaration || !Live[I])
@@ -618,13 +710,23 @@ std::optional<ProgramPartitions> partitionProgramText(StringRef Text,
for (unsigned Ref : S.Refs) {
if (Ref == I)
continue;
Referrer[Ref] = Referrer[Ref] == NoPartition ? I : Shared;
if (Referrer[Ref] == NoPartition) {
Referrer[Ref] = I;
MayFollow[Ref] =
Symbols[Ref].Kind != SymbolKind::Function ||
Symbols[Ref].Body.size() < AddressOnlyBytes ||
(S.Kind == SymbolKind::Function
? std::binary_search(S.Calls.begin(), S.Calls.end(), Ref)
: S.Constant);
} else {
Referrer[Ref] = Shared;
}
}
}
auto Follows = [&](unsigned Child, unsigned Parent) {
const Symbol &S = Symbols[Child];
if (!S.Local || Parent == NoPartition || Parent == Shared ||
Symbols[Parent].Kind == SymbolKind::Declaration)
if (!S.Local || !MayFollow[Child] || Parent == NoPartition ||
Parent == Shared || Symbols[Parent].Kind == SymbolKind::Declaration)
return false;
if (S.Kind == SymbolKind::Function &&
(S.Body.size() > MaxInlinedBodyBytes ||
@@ -656,6 +758,7 @@ std::optional<ProgramPartitions> partitionProgramText(StringRef Text,
Symbols[I].Partition = stableHash(Symbols[Root].Name) % Count;
}
placeGroups(Symbols, Roots, Count);
balanceRoots(Symbols, Roots, Live, Count);
// A global follows the function its chain ends at, then the first
// function that references it, then its own name.
for (unsigned I = 0; I < Symbols.size(); ++I)
+503
View File
@@ -0,0 +1,503 @@
#include "profile-blocks.h"
#include "llvm/ADT/DenseMap.h"
#include "llvm/ADT/DepthFirstIterator.h"
#include "llvm/ADT/SmallPtrSet.h"
#include "llvm/ADT/SmallString.h"
#include "llvm/ADT/SmallVector.h"
#include "llvm/ADT/StringExtras.h"
#include "llvm/Analysis/BlockFrequencyInfo.h"
#include "llvm/Analysis/BranchProbabilityInfo.h"
#include "llvm/Analysis/LoopInfo.h"
#include "llvm/Analysis/ProfileSummaryInfo.h"
#include "llvm/IR/CFG.h"
#include "llvm/IR/Dominators.h"
#include "llvm/IR/Function.h"
#include "llvm/IR/Instructions.h"
#include "llvm/IR/ProfDataUtils.h"
#include "llvm/Support/FileSystem.h"
#include "llvm/Support/MemoryBuffer.h"
#include "llvm/Support/raw_ostream.h"
#include "llvm/Transforms/Instrumentation/PGOInstrumentation.h"
#include "llvm/Transforms/Utils/SampleProfileInference.h"
#include <algorithm>
using namespace llvm;
namespace scriptc {
namespace {
constexpr StringRef Header = "scriptc-profile-blocks 1";
// An edge whose branch weight is below this fraction of its branch's total
// counts as unlikely in flow inference.
constexpr uint64_t UnlikelyRatio = 1000;
// Alignments of more blocks than this product fall back to matching labels
// that occur once on each side.
constexpr uint64_t MaxAlignmentCells = uint64_t(1) << 22;
void writeEscaped(raw_ostream &OS, StringRef Text) {
if (Text.empty()) {
OS << '\\';
return;
}
for (unsigned char C : Text) {
if (C <= ' ' || C >= 0x7f || C == '\\')
OS << '\\' << hexdigit(C >> 4, /*LowerCase=*/true)
<< hexdigit(C & 15, /*LowerCase=*/true);
else
OS << C;
}
}
bool readEscaped(StringRef Token, std::string &Out) {
Out.clear();
if (Token == "\\")
return true;
for (size_t I = 0; I < Token.size(); ++I) {
if (Token[I] != '\\') {
Out.push_back(Token[I]);
continue;
}
if (I + 2 >= Token.size())
return false;
unsigned High = hexDigitValue(Token[I + 1]);
unsigned Low = hexDigitValue(Token[I + 2]);
if (High > 15 || Low > 15)
return false;
Out.push_back(static_cast<char>(High << 4 | Low));
I += 2;
}
return true;
}
// A block label's kind: the label without its digits. The emitter numbers
// each function's blocks in order (`if.t3`, `loop.c4`), so a block added or
// removed earlier in the function renumbers the blocks after it but keeps
// their kinds.
std::string kindOf(StringRef Label) {
std::string Kind;
Kind.reserve(Label.size());
for (char C : Label)
if (!isDigit(C))
Kind.push_back(C);
return Kind;
}
struct Token {
std::string Kind;
StringRef Label;
size_t Successors;
};
bool sameKind(const Token &A, const Token &B) {
return A.Successors == B.Successors && A.Kind == B.Kind;
}
// Pairs every block of Blocks with the recorded block that holds its place,
// or -1: the longest common subsequence of the two block orders by kind and
// successor count, which prefers pairs whose labels are equal. Common
// leading and trailing runs pair up first; a remaining middle too large to
// align pairs the labels that occur once on each side.
std::vector<int64_t> alignBlocks(const FunctionBlocks &Record,
ArrayRef<const BasicBlock *> Blocks) {
std::vector<Token> Old, New;
Old.reserve(Record.Blocks.size());
for (const BlockRecord &Block : Record.Blocks)
Old.push_back({kindOf(Block.Label), Block.Label, Block.Successors.size()});
New.reserve(Blocks.size());
for (const BasicBlock *Block : Blocks) {
SmallPtrSet<const BasicBlock *, 4> Distinct(succ_begin(Block),
succ_end(Block));
New.push_back(
{kindOf(Block->getName()), Block->getName(), Distinct.size()});
}
std::vector<int64_t> Match(New.size(), -1);
size_t Begin = 0;
while (Begin < Old.size() && Begin < New.size() &&
sameKind(Old[Begin], New[Begin])) {
Match[Begin] = static_cast<int64_t>(Begin);
++Begin;
}
size_t OldEnd = Old.size(), NewEnd = New.size();
while (OldEnd > Begin && NewEnd > Begin &&
sameKind(Old[OldEnd - 1], New[NewEnd - 1])) {
--OldEnd;
--NewEnd;
Match[NewEnd] = static_cast<int64_t>(OldEnd);
}
size_t Rows = OldEnd - Begin, Columns = NewEnd - Begin;
if (Rows == 0 || Columns == 0)
return Match;
auto Score = [&](size_t I, size_t J) -> uint32_t {
const Token &A = Old[Begin + I], &B = New[Begin + J];
if (!sameKind(A, B))
return 0;
return A.Label == B.Label ? 2 : 1;
};
if (uint64_t(Rows) * Columns <= MaxAlignmentCells) {
// Best[I][J]: the best score of the middle's suffixes from I and J.
size_t Width = Columns + 1;
std::vector<uint32_t> Best((Rows + 1) * Width, 0);
for (size_t I = Rows; I-- > 0;)
for (size_t J = Columns; J-- > 0;) {
uint32_t Value =
std::max(Best[(I + 1) * Width + J], Best[I * Width + J + 1]);
if (uint32_t S = Score(I, J))
Value = std::max(Value, S + Best[(I + 1) * Width + J + 1]);
Best[I * Width + J] = Value;
}
size_t I = 0, J = 0;
while (I < Rows && J < Columns) {
uint32_t S = Score(I, J);
if (S && Best[I * Width + J] == S + Best[(I + 1) * Width + J + 1]) {
Match[Begin + J] = static_cast<int64_t>(Begin + I);
++I;
++J;
} else if (Best[I * Width + J] == Best[(I + 1) * Width + J]) {
++I;
} else {
++J;
}
}
return Match;
}
StringMap<int64_t> OldLabels, NewLabels;
for (size_t I = Begin; I < OldEnd; ++I) {
auto [It, Inserted] = OldLabels.try_emplace(Old[I].Label, I);
if (!Inserted)
It->second = -1;
}
for (size_t J = Begin; J < NewEnd; ++J) {
auto [It, Inserted] = NewLabels.try_emplace(New[J].Label, J);
if (!Inserted)
It->second = -1;
}
for (const auto &Entry : NewLabels) {
auto It = OldLabels.find(Entry.getKey());
if (Entry.second < 0 || It == OldLabels.end() || It->second < 0 ||
!sameKind(Old[It->second], New[Entry.second]))
continue;
Match[Entry.second] = It->second;
}
return Match;
}
// LLVM's profile inference costs, with recorded edge counts that cost as much
// to change as recorded block counts. Its default leaves edges free to
// change, and its rebalancing of the flow below a counted block spreads it
// evenly over the block's edges whatever their counts.
ProfiParams inferenceParams() {
ProfiParams Params;
Params.EvenFlowDistribution = true;
Params.RebalanceUnknown = false;
Params.JoinIslands = true;
Params.CostBlockInc = 10;
Params.CostBlockDec = 20;
Params.CostBlockEntryInc = 40;
Params.CostBlockEntryDec = 10;
Params.CostBlockZeroInc = 11;
Params.CostBlockUnknownInc = 0;
Params.CostJumpInc = 10;
Params.CostJumpFTInc = 10;
Params.CostJumpDec = 20;
Params.CostJumpFTDec = 20;
Params.CostJumpUnknownInc = 0;
Params.CostJumpUnknownFTInc = 0;
return Params;
}
void markFrequency(Function &F, uint64_t EntryCount, uint64_t MaxCount,
const ProfileSummaryInfo &Summary) {
if (Summary.isHotCount(EntryCount))
F.addFnAttr(Attribute::InlineHint);
else if (Summary.isColdCount(MaxCount) && !F.hasFnAttribute(Attribute::Hot))
F.addFnAttr(Attribute::Cold);
}
} // namespace
std::unique_ptr<BlockCounts> readProfileBlocks(StringRef Path,
std::string &Error) {
ErrorOr<std::unique_ptr<MemoryBuffer>> Buffer = MemoryBuffer::getFile(Path);
if (!Buffer) {
Error = Path.str() + ": " + Buffer.getError().message();
return nullptr;
}
auto Blocks = std::make_unique<BlockCounts>();
StringRef Rest = (*Buffer)->getBuffer();
size_t Line = 0;
auto Fail = [&](StringRef Why) {
Error = (Path + ":" + Twine(Line) + ": " + Why).str();
return nullptr;
};
auto NextLine = [&]() {
auto [Current, Remaining] = Rest.split('\n');
Rest = Remaining;
++Line;
return Current;
};
if (NextLine() != Header)
return Fail("not a scriptc profile blocks file");
SmallVector<StringRef, 16> Fields;
std::string Name, Label;
while (!Rest.empty()) {
Fields.clear();
NextLine().split(Fields, ' ', -1, /*KeepEmpty=*/false);
uint64_t Entry = 0, Count = 0;
if (Fields.size() != 4 || Fields[0] != "f" ||
!readEscaped(Fields[1], Name) || Fields[2].getAsInteger(10, Entry) ||
Fields[3].getAsInteger(10, Count))
return Fail("expected a function line");
FunctionBlocks &Function = (*Blocks)[Name];
Function.EntryCount = Entry;
Function.Blocks.resize(Count);
for (BlockRecord &Block : Function.Blocks) {
Fields.clear();
if (Rest.empty())
return Fail("expected a block line");
NextLine().split(Fields, ' ', -1, /*KeepEmpty=*/false);
if (Fields.size() < 2 || !readEscaped(Fields[0], Label) ||
Fields[1].getAsInteger(10, Block.Count))
return Fail("expected a block line");
Block.Label = Label;
for (StringRef Field : ArrayRef(Fields).drop_front(2)) {
auto [Position, EdgeCount] = Field.split(':');
uint32_t Successor = 0;
uint64_t Value = 0;
if (Position.getAsInteger(10, Successor) ||
EdgeCount.getAsInteger(10, Value) || Successor >= Count)
return Fail("expected a successor and its edge count");
Block.Successors.push_back({Successor, Value});
}
}
}
return Blocks;
}
void annotateFromBlocks(Function &F, const FunctionBlocks &Record,
const ProfileSummaryInfo &Summary) {
if (Record.EntryCount == 0 || Record.Blocks.empty()) {
F.setEntryCount(Function::ProfileCount(0, Function::PCT_Real));
if (Summary.hasProfileSummary() && !F.hasFnAttribute(Attribute::Hot))
F.addFnAttr(Attribute::Cold);
return;
}
std::vector<const BasicBlock *> Order;
for (const BasicBlock &Block : F)
Order.push_back(&Block);
std::vector<int64_t> Match = alignBlocks(Record, Order);
std::vector<bool> Paired(Record.Blocks.size(), false);
for (int64_t Recorded : Match)
if (Recorded >= 0)
Paired[Recorded] = true;
// Flow runs through the blocks that the entry reaches and that reach an
// exit, in the function's order, as LLVM's profile inference requires.
df_iterator_default_set<const BasicBlock *> Forward, Backward;
for (const BasicBlock *Block : depth_first_ext(&F, Forward))
(void)Block;
for (const BasicBlock &Block : F)
if (succ_empty(&Block))
for (const BasicBlock *Reaching :
inverse_depth_first_ext(&Block, Backward))
(void)Reaching;
DenseMap<const BasicBlock *, size_t> Position, Node;
FlowFunction Flow;
for (size_t I = 0; I < Order.size(); ++I) {
Position[Order[I]] = I;
if (!Forward.contains(Order[I]) || !Backward.contains(Order[I]))
continue;
Node[Order[I]] = Flow.Blocks.size();
FlowBlock Block;
Block.Index = Flow.Blocks.size();
if (Match[I] >= 0) {
Block.Weight = Record.Blocks[Match[I]].Count;
Block.HasUnknownWeight = false;
} else if (I == 0) {
Block.Weight = Record.EntryCount;
Block.HasUnknownWeight = false;
}
Flow.Blocks.push_back(Block);
}
uint64_t EntryCount = Record.EntryCount, MaxCount = Record.EntryCount;
if (Flow.Blocks.size() > 1 && Node.count(&F.getEntryBlock())) {
for (const BasicBlock *Block : Order) {
auto Source = Node.find(Block);
if (Source == Node.end())
continue;
int64_t From = Match[Position[Block]];
SmallVector<const BasicBlock *, 4> Targets, Successors;
for (const BasicBlock *Successor : successors(Block)) {
Successors.push_back(Successor);
if (!is_contained(Targets, Successor))
Targets.push_back(Successor);
}
SmallVector<uint32_t, 4> Weights;
extractBranchWeights(*Block->getTerminator(), Weights);
const BlockRecord *Recorded = From >= 0 ? &Record.Blocks[From] : nullptr;
for (size_t K = 0; K < Targets.size(); ++K) {
auto Target = Node.find(Targets[K]);
if (Target == Node.end())
continue;
FlowJump Jump;
Jump.Source = Source->second;
Jump.Target = Target->second;
if (Recorded != nullptr) {
// An edge to the recorded target's pair takes that edge's count.
// An edge to an unpaired target takes the count of the recorded
// edge in its place among the block's targets, which a paired
// block has as many of, when that edge's target is unpaired too:
// a target whose label changed keeps its edge's count.
int64_t To = Match[Position[Targets[K]]];
auto Edge = llvm::find_if(Recorded->Successors, [&](const auto &E) {
return To >= 0 && E.first == static_cast<uint64_t>(To);
});
if (Edge == Recorded->Successors.end() && To < 0 &&
Recorded->Successors.size() == Targets.size() &&
!Paired[Recorded->Successors[K].first])
Edge = Recorded->Successors.begin() + K;
if (Edge != Recorded->Successors.end()) {
Jump.Weight = Edge->second;
Jump.HasUnknownWeight = false;
}
}
// An edge without a recorded count that the function's own branch
// weights (from llvm.expect) make unlikely takes flow only when no
// other path can.
if (Jump.HasUnknownWeight && Weights.size() == Successors.size()) {
uint64_t Total = 0, Weight = 0;
for (size_t I = 0; I < Successors.size(); ++I) {
Total += Weights[I];
if (Successors[I] == Targets[K])
Weight += Weights[I];
}
Jump.IsUnlikely = Weight * UnlikelyRatio < Total;
}
Flow.Jumps.push_back(Jump);
}
}
for (FlowJump &Jump : Flow.Jumps) {
Flow.Blocks[Jump.Source].SuccJumps.push_back(&Jump);
Flow.Blocks[Jump.Target].PredJumps.push_back(&Jump);
}
applyFlowInference(inferenceParams(), Flow);
EntryCount = Flow.Blocks[0].Flow;
MaxCount = 0;
for (const FlowBlock &Block : Flow.Blocks)
MaxCount = std::max(MaxCount, Block.Flow);
if (MaxCount > 0 && EntryCount == 0)
EntryCount = 1;
// Branch weights as the profile annotation sets them: the first
// successor position of each target takes the edge's count.
DenseMap<std::pair<size_t, size_t>, uint64_t> JumpFlow;
for (const FlowJump &Jump : Flow.Jumps)
JumpFlow[{Jump.Source, Jump.Target}] = Jump.Flow;
for (BasicBlock &Block : F) {
auto Source = Node.find(&Block);
Instruction *Terminator = Block.getTerminator();
if (Source == Node.end() || Flow.Blocks[Source->second].Flow == 0 ||
Terminator->getNumSuccessors() < 2 ||
!(isa<BranchInst>(Terminator) || isa<SwitchInst>(Terminator) ||
isa<IndirectBrInst>(Terminator) || isa<InvokeInst>(Terminator) ||
isa<CallBrInst>(Terminator)))
continue;
SmallVector<uint64_t, 4> Counts(Terminator->getNumSuccessors(), 0);
SmallPtrSet<const BasicBlock *, 4> Seen;
uint64_t Max = 0;
for (unsigned I = 0; I < Terminator->getNumSuccessors(); ++I) {
const BasicBlock *Successor = Terminator->getSuccessor(I);
auto Target = Node.find(Successor);
if (Target == Node.end() || !Seen.insert(Successor).second)
continue;
Counts[I] = JumpFlow.lookup({Source->second, Target->second});
Max = std::max(Max, Counts[I]);
}
if (Max > 0)
setProfMetadata(Terminator, Counts, Max);
}
}
F.setEntryCount(Function::ProfileCount(EntryCount, Function::PCT_Real));
markFrequency(F, EntryCount, MaxCount, Summary);
}
void BlockRecorder::add(const Function &F) {
std::optional<Function::ProfileCount> Entry = F.getEntryCount();
if (!Entry || Entry->isSynthetic())
return;
std::string Text;
raw_string_ostream OS(Text);
OS << "f ";
writeEscaped(OS, F.getName());
if (Entry->getCount() == 0) {
OS << " 0 0\n";
} else {
OS << ' ' << Entry->getCount() << ' ' << F.size() << '\n';
Function &Mutable = const_cast<Function &>(F);
DominatorTree Dominators(Mutable);
LoopInfo Loops(Dominators);
BranchProbabilityInfo Probabilities(F, Loops);
BlockFrequencyInfo Frequencies(F, Probabilities, Loops);
DenseMap<const BasicBlock *, uint32_t> Position;
uint32_t Next = 0;
for (const BasicBlock &Block : F)
Position[&Block] = Next++;
for (const BasicBlock &Block : F) {
uint64_t Count = Frequencies.getBlockProfileCount(&Block).value_or(0);
writeEscaped(OS, Block.getName());
OS << ' ' << Count;
// Edges to one target through several successor positions add up.
SmallVector<std::pair<uint32_t, uint64_t>, 4> Edges;
const Instruction *Terminator = Block.getTerminator();
for (unsigned I = 0, E = Terminator->getNumSuccessors(); I < E; ++I) {
uint32_t Target = Position.lookup(Terminator->getSuccessor(I));
uint64_t EdgeCount =
Probabilities.getEdgeProbability(&Block, I).scale(Count);
auto It = llvm::find_if(Edges, [&](const auto &Edge) {
return Edge.first == Target;
});
if (It == Edges.end())
Edges.push_back({Target, EdgeCount});
else
It->second += EdgeCount;
}
for (auto [Target, EdgeCount] : Edges)
OS << ' ' << Target << ':' << EdgeCount;
OS << '\n';
}
}
std::lock_guard<std::mutex> Guard(Lock);
Records[F.getName().str()] = std::move(Text);
}
std::optional<std::string> BlockRecorder::write(StringRef Path) {
SmallString<256> Temporary(Path);
Temporary += ".tmp-%%%%%%";
int Fd = -1;
if (std::error_code EC = sys::fs::createUniqueFile(Temporary, Fd, Temporary))
return EC.message();
{
raw_fd_ostream Stream(Fd, /*shouldClose=*/true);
Stream << Header << '\n';
std::lock_guard<std::mutex> Guard(Lock);
for (const auto &[Name, Text] : Records)
Stream << Text;
Stream.close();
if (Stream.has_error()) {
std::string Message = Stream.error().message();
Stream.clear_error();
sys::fs::remove(Temporary);
return Message;
}
}
if (std::error_code EC = sys::fs::rename(Temporary, Path)) {
sys::fs::remove(Temporary);
return EC.message();
}
return std::nullopt;
}
} // namespace scriptc
+83
View File
@@ -0,0 +1,83 @@
#pragma once
#include "llvm/ADT/StringMap.h"
#include "llvm/ADT/StringRef.h"
#include <cstdint>
#include <map>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <utility>
#include <vector>
namespace llvm {
class Function;
class ProfileSummaryInfo;
} // namespace llvm
namespace scriptc {
// A profile's block counts (a .blocks file), which annotate the functions
// whose control flow changed since the profile was collected. The profile
// annotation matches a function only when its control flow hashes as it did
// then; the block counts describe each function by its blocks' labels, which
// an unrelated change to the function leaves mostly as they were.
//
// The file is text. Its first line is `scriptc-profile-blocks 1`; each
// function follows as a line `f <name> <entry count> <blocks>` and then one
// line per block in the function's order: `<label> <count>` and, per
// successor, `<position>:<edge count>`, where the position is the
// successor's place in the function's block list. Names and labels escape
// spaces, control characters, backslashes and non-ASCII bytes as `\hh`; an
// empty label is a single backslash.
// One block of a function's record.
struct BlockRecord {
std::string Label;
uint64_t Count = 0;
// Successor positions with the count of the edge to each.
std::vector<std::pair<uint32_t, uint64_t>> Successors;
};
// One function's record: its entry count and blocks. A function that never
// ran has an entry count of zero and no blocks.
struct FunctionBlocks {
uint64_t EntryCount = 0;
std::vector<BlockRecord> Blocks;
};
using BlockCounts = llvm::StringMap<FunctionBlocks>;
// Reads a .blocks file; the error describes a file that does not parse.
std::unique_ptr<BlockCounts> readProfileBlocks(llvm::StringRef Path,
std::string &Error);
// Annotates F, which the profile left without counts, from its record:
// blocks that keep their place in the function's labeled block order take
// their recorded counts, and their edges the recorded edge counts, by target
// or, between targets that lost their place, by position. The counts of the
// other blocks and edges follow from flow conservation, which avoids the
// edges the function's own branch weights make unlikely. Sets the entry
// count, branch weights and the hot or cold attribute as the profile
// annotation does.
void annotateFromBlocks(llvm::Function &F, const FunctionBlocks &Record,
const llvm::ProfileSummaryInfo &Summary);
// The records of the functions a profile annotated, collected from every
// partition and written as one .blocks file in name order.
class BlockRecorder {
public:
// Records F's block counts, which the profile annotation just gave it.
void add(const llvm::Function &F);
// Writes the file through a private sibling; returns an error message on
// failure.
std::optional<std::string> write(llvm::StringRef Path);
private:
std::mutex Lock;
std::map<std::string, std::string> Records;
};
} // namespace scriptc
+14 -3
View File
@@ -276,9 +276,20 @@ int runtimeUnit(int Argc, char **Argv) {
Options->ProfileGeneratePattern, Options->ProfileUse, Match))
return fail("optimization_failed", *Failure);
if (!Options->ProfileUse.empty()) {
json::Object Profile{{"annotated", Match.Annotated},
{"mismatched", Match.Mismatched},
{"missing", Match.Missing}};
// Each matched function's counters in the profile, by profile name.
auto Counts =
[](const std::vector<std::pair<std::string, uint64_t>> &List) {
json::Object Object;
for (const auto &[Name, Sum] : List)
Object[Name] = Sum;
return Object;
};
json::Object Profile{
{"annotated", Match.Annotated},
{"mismatched", Match.Mismatched},
{"missing", Match.Missing},
{"annotated_counts", Counts(Match.AnnotatedCounts)},
{"mismatched_counts", Counts(Match.MismatchedCounts)}};
json::Object Response{{"ok", true}, {"profile", std::move(Profile)}};
outs() << formatv("{0}\n", json::Value(std::move(Response)));
} else if (Options->ProfileGenerate) {
+22 -1
View File
@@ -1,6 +1,6 @@
import { execFile } from "node:child_process";
import { createRequire } from "node:module";
import { mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises";
import { copyFile, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
@@ -85,6 +85,15 @@ test.skipIf(process.platform !== "linux")(
await scriptc(["build", entry, "--profile-use", sparse, "-o", optimized], env);
expect((await execFileAsync(optimized, ["1000"])).stdout).toBe("total 167499\n");
// SCRIPTC_RECORD_PROFILE_BLOCKS writes the block counts of the
// functions the profile annotates.
const recorded = join(dir, "recorded.blocks");
await scriptc(["build", entry, "--profile-use", merged, "-o", optimized], {
...env,
SCRIPTC_RECORD_PROFILE_BLOCKS: recorded,
});
expect((await readFile(recorded, "utf8")).split("\n")[0]).toBe("scriptc-profile-blocks 1");
// A profile from an older version of the program still builds it. The
// edited function no longer matches its counts, and the build warns.
await writeFile(entry, PROGRAM + 'console.log("edited");\n');
@@ -94,6 +103,18 @@ test.skipIf(process.platform !== "linux")(
expect(stale.stderr).toMatch(
/\nscriptc: warning: [1-9]\d* of the \d+ functions .* describes \([\d.]+%\) changed since it was collected and are optimized without their counts; collect the profile again\n$/,
);
// The block counts beside the profile annotate the edited function.
await copyFile(recorded, join(dir, "app.blocks"));
const recovered = await scriptc(
["build", entry, "--profile-use", merged, "-o", optimized],
env,
);
expect((await execFileAsync(optimized, ["1000"])).stdout).toBe("total 167499\nedited\n");
expect(recovered.stderr).toMatch(/ [1-9]\d* mismatched, /);
expect(recovered.stderr).toMatch(
/ [1-9]\d* functions? without profile counts annotated from \S+app\.blocks\n/,
);
expect(recovered.stderr).not.toContain("warning");
} finally {
await rm(dir, { recursive: true, force: true });
}
+5
View File
@@ -42,6 +42,11 @@ declare module "node:fs" {
/* The bare-encoding spelling — the options record's encoding key alone. */
export function writeFileSync(path: string | URL, data: string, encoding: "utf8" | "utf-8"): void;
export function writeFileSync(path: string | URL, data: string | Uint8Array): void;
/* The file-descriptor form: Node writes the whole data to an already
* open descriptor and leaves it open (closeSync owns its lifetime, and
* the write lands at the descriptor's current offset). */
export function writeFileSync(fd: number, data: string): void;
export function writeFileSync(fd: number, data: string, encoding: "utf8" | "utf-8"): void;
export function appendFileSync(path: string | URL, data: string | Uint8Array): void;
export function existsSync(path: string | URL): boolean;
export function mkdirSync(path: string | URL): void;
@@ -22,6 +22,7 @@ export function executableLinkFeatures(mod: IrModule, largePages = false): Nativ
...(features.stdioExtended ? { stdioExtended: true } : {}),
...(features.nodeIsland ? { nodeIsland: true } : {}),
...(features.moduleNamespace ? { moduleNamespace: true } : {}),
...(features.moduleNamespaceDyn ? { moduleNamespaceDyn: true } : {}),
zlib: features.zlib,
...(features.brotli ? { brotli: true } : {}),
assert: features.assert,
@@ -67,6 +67,38 @@ console.log(total);
expect(body).not.toContain("ScrArena.closed");
expect(body).not.toContain("@scr_arena_disposal");
}
// A record literal stores every field, and no dispose entry reads them:
// the record's constructor does not clear the object. A class constructor
// may leave fields unset, so the class's does.
const record = constructors.find((body) => body.includes("@sc_rnew_"))!;
const cls = constructors.find((body) => !body.includes("@sc_rnew_"))!;
expect(record).not.toContain("@llvm.memset");
expect(cls).toContain("@llvm.memset");
});
test("record literals in an arena that is not closed keep the clear their dispose entries need", async () => {
// The items hold outside strings, so their groups are not closed.
const llvm = await llvmFor(`
interface Item {
label: string;
next: Item | null;
}
function chain(labels: string[], n: number): Item {
return n === 0 ? { label: labels[0]!, next: null } : { label: labels[n % labels.length]!, next: chain(labels, n - 1) };
}
function length(item: Item): number {
return item.next === null ? item.label.length : item.label.length + length(item.next);
}
const labels = ["a", "bb", "ccc"];
let total = 0;
for (let round = 0; round < 4; round++) total += length(chain(labels, 10 + round));
console.log(total);
`);
expect(llvm).toMatch(/@scr_arena_open\(ptr %[\w.]+, i32 0\)/);
const items = arenaConstructors(llvm).filter((body) => body.includes("@sc_rnew_"));
expect(items.length).toBe(1);
expect(items[0]).toContain(".dispose");
expect(items[0]).toContain("@llvm.memset");
});
test("an arena that is not closed keeps the dispose list in every arena-aware constructor", async () => {
+13 -1
View File
@@ -275,6 +275,12 @@ export function arenaCurrentAddress(host: ArenaHost, dest: string): string[] | n
* 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.
*
* `clear` is what the bump path zeroes: the whole object, or only the
* collector header, for callers that store every word of the object before
* anything reads it (a record literal of a shape with no dispose entries:
* `init` stores the count and the shape number, and the literal every
* field). scr_arena_obj and the heap constructor return zeroed objects.
*/
export function arenaNewLines(
host: ArenaHost,
@@ -284,6 +290,7 @@ export function arenaNewLines(
dispose: string | null,
init: string[],
result: string,
clear: "object" | "header" = "object",
): string[] {
const S = host.sizeType;
// ScrArena's fields and dispose entries are pointer-sized (scr_arena.c).
@@ -328,8 +335,13 @@ export function arenaNewLines(
`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 (clear === "object")
lines.push(` call void @llvm.memset.p0.${S}(ptr align 8 %bp, i8 0, ${S} %n, i1 false)`);
else if (cyc !== null)
lines.push(
` call void @llvm.memset.p0.${S}(ptr align 8 %bp, i8 0, ${S} ${CYC_HDR}, i1 false)`,
);
if (cyc !== null)
lines.push(
` %id = trunc i32 %tid to i16`,
+11 -1
View File
@@ -97,12 +97,22 @@ export class BlockBuilder {
if (s === UNWIND_RESUME) {
const exception = this.tmp();
this.line(`${exception} = load { ptr, i32 }, ptr ${UNWIND_SLOT}`);
s = `resume { ptr, i32 } ${exception}`;
this.resumeUnwind(exception);
return;
}
this.cur.term = ` ${this.withDebugLocation(s)}`;
}
}
/** Ends the block by continuing an unwind with `exception`, the value
* of a landing pad (UNWIND_RESUME_DECLARATION). */
resumeUnwind(exception: string): void {
const token = this.tmp();
this.line(`${token} = extractvalue { ptr, i32 } ${exception}, 0`);
this.line(`call void @scr_unwind_resume(ptr ${token})`);
this.terminate("unreachable");
}
br(label: string, attachment = ""): void {
this.terminate(`br label %${label}${attachment}`);
}
@@ -594,6 +594,35 @@ test("closure bodies get local facts but never parameter facts", () => {
expect(analyze(fn).locals.get("closure")).toEqual(new Set(["item"]));
});
test("union locals copied to other locals are reported with their copies", () => {
const fn = helper("copies");
fn.locals.push(local("source"), { ...local("first"), mutable: true }, local("second"));
fn.body = [
{ kind: "varDecl", localId: "source", init: ref("value"), loc },
{ kind: "varDecl", localId: "first", init: ref("source"), loc },
{ kind: "assign", localId: "first", value: ref("source"), loc },
{ kind: "varDecl", localId: "second", init: ref("first"), loc },
{ kind: "exprStmt", expr: tag("second"), loc },
];
const result = analyze(fn);
expect(result.projectedLocals.get("copies")).toEqual(new Set(["second"]));
expect(result.copiedLocals.get("copies")).toEqual(
new Map([
["source", ["first", "first"]],
["first", ["second"]],
]),
);
// A copied parameter stays rejected: callers may lend it a stack box.
expect(result.parameters.has("copies")).toBe(false);
// A copy inside an expression is an ordinary whole-value use.
fn.body.push({
kind: "exprStmt",
expr: { kind: "assignExpr", localId: "first", value: ref("source"), type: optional, loc },
loc,
});
expect(analyze(fn).copiedLocals.get("copies")?.has("source") ?? false).toBe(false);
});
test("stores into nullable-pointer class fields project the stored union", () => {
const self: IrType = { kind: "object", className: "Node" };
const store = (className: string, field: string, value: IrExpr): IrStmt => ({
@@ -34,6 +34,10 @@ interface Uses {
/** Narrowed payloads of union bindings passed directly as call arguments:
* the payload stays in place if the callee's parameter is proved. */
payloadForwards: Map<string, ForwardedUse[]>;
/** Union bindings whose whole value initializes or is assigned to
* another union local (`const x = y`, `x = y` as a statement), with the
* locals it is copied to. */
copies: Map<string, string[]>;
}
interface Parameter {
@@ -59,6 +63,11 @@ export interface CallLifetimes {
/** Projection-only local uses, allowing statement assignments. Each
* initializer and assignment still needs a separate storage proof. */
projectedLocals: Map<string, Set<string>>;
/** Locals that projectedLocals leaves out only because their whole value
* is copied to other union locals, with those locals. Such a local is
* only projected when every local it is copied to snapshots the copy
* (local-union-storage.ts) instead of keeping the box. */
copiedLocals: Map<string, Map<string, readonly string[]>>;
/** Generator bodies own their parameters for the whole body, including
* every suspension: the frame releases them only on its final exit. A
* reference parameter nothing rebinds, declares or captures is therefore
@@ -132,6 +141,7 @@ function collectUses(
forwards: new Map(),
payloadKept: new Set(),
payloadForwards: new Map(),
copies: new Map(),
};
const forward = (
table: Map<string, ForwardedUse[]>,
@@ -292,6 +302,18 @@ function collectUses(
}
return everyExprChild(node, expr, (node) => stmt(node, true));
}
/** `const x = y` or a statement `x = y` of union locals: the copy is
* recorded instead of rejecting `y`. A parameter's copy still rejects it,
* since callers may lend projection-only parameters a stack box. */
const params = new Set(fn.params.map((param) => param.localId));
const unionCopy = (target: string, value: IrExpr | null | undefined): boolean => {
if (value?.kind !== "varRef" || value.type.kind !== "union" || params.has(value.localId))
return false;
let targets = uses.copies.get(value.localId);
if (!targets) uses.copies.set(value.localId, (targets = []));
targets.push(target);
return true;
};
function stmt(node: IrStmt, inExpression: boolean): boolean {
switch (node.kind) {
case "return":
@@ -300,6 +322,7 @@ function collectUses(
break;
case "varDecl":
uses.declarations.set(node.localId, (uses.declarations.get(node.localId) ?? 0) + 1);
if (unionCopy(node.localId, node.init)) return true;
// A receiver temporary (`%compoundReceiver = this`) projects its
// source as long as the temporary itself is only projected.
if (
@@ -342,6 +365,7 @@ function collectUses(
// Sequence expressions can rebind a previous call argument before
// its consumer runs. Those locals need an owned heap snapshot.
if (inExpression) uses.invalid.add(node.localId);
else if (unionCopy(node.localId, node.value)) return true;
break;
case "forOf":
// The binding is initialized afresh for each iteration. Its uses
@@ -429,6 +453,7 @@ export function analyzeCallLifetimes(
borrowed: new Map(),
bindings: new Map(),
projectedLocals: new Map(),
copiedLocals: new Map(),
generatorParameters: new Map(),
};
for (const fn of functions.values()) {
@@ -602,6 +627,7 @@ export function analyzeCallLifetimes(
const params = new Set(fn.params.map((param) => param.localId));
const safe = new Set<string>();
const projected = new Set<string>();
const copied = new Map<string, readonly string[]>();
for (const local of fn.locals) {
if (
params.has(local.id) ||
@@ -611,13 +637,18 @@ export function analyzeCallLifetimes(
)
continue;
const forwards = uses.forwards.get(local.id) ?? [];
if (forwards.every((use) => target(use)?.safe === true)) {
projected.add(local.id);
if (eligible(local) && !uses.written.has(local.id)) safe.add(local.id);
if (!forwards.every((use) => target(use)?.safe === true)) continue;
const copies = uses.copies.get(local.id);
if (copies) {
copied.set(local.id, copies);
continue;
}
projected.add(local.id);
if (eligible(local) && !uses.written.has(local.id)) safe.add(local.id);
}
result.locals.set(fn.name, safe);
result.projectedLocals.set(fn.name, projected);
if (copied.size > 0) result.copiedLocals.set(fn.name, copied);
}
// Consumed parameters take the owned convention, unless only projected.
for (const { fn, index } of consumed) {
+99 -2
View File
@@ -43,6 +43,7 @@ import { llvmCommentText } from "./common.js";
import { INLINE_UNION_FIELD } from "./inline-unions.js";
import { NullableRefFields } from "./nullable-fields.js";
import type { Int32Slots } from "../../ir/int32-slots.js";
import type { InlineArrayClass } from "../../ir/inline-arrays.js";
import {
cycleRootLines,
traceFieldVisitLines,
@@ -117,12 +118,30 @@ export class LlClassMeta {
* a compact class instance as a 32-bit index (COMPACT_REF) rather than a
* pointer: compactRefLoadLines decodes it, compactRefStoreLines encodes it. */
compressed: boolean[] = [];
/** Compact classes with an inline array field (ir/inline-arrays.ts): the
* field's slot holds the element count as an i32, and the elements follow
* the fixed fields as 32-bit references from `tailOffset`, the same offset
* in every class of the declaring class's subtree. Absent otherwise. */
inlineTail?: LlInlineTail;
constructor(readonly def: IrClassDef) {
this.root = this;
}
}
/** A class's inline array field (LlClassMeta.inlineTail). */
export interface LlInlineTail {
/** The field's index in def.fields. */
readonly field: number;
/** Byte offset of the first element. */
readonly tailOffset: number;
/** The elements' class. */
readonly elemClass: string;
/** The index in a `new` expression's args of the array the field is
* initialized from, whose length the allocation reads. */
readonly lengthArg: number;
}
/** The class graph: link base/children, number the forest in preorder
* (roots and children in module class order, matching the
* runtime's stamped error vtables), and compute each hierarchy's virtual
@@ -451,6 +470,12 @@ export function classFieldIndex(
throw new InternalCompilerError(
`llvm emitter bug: unknown field ${field} on class ${meta.def.name}`,
);
// An inline array field's slot holds its element count: its reads and
// writes go through inline-arrays.ts.
if (meta.inlineTail?.field === idx && meta.compact)
throw new InternalCompilerError(
`llvm emitter bug: field slot of the inline array field ${meta.def.name}.${field}`,
);
const compressed = meta.compressed[idx] === true;
// Callers that read or write a field's storage as a pointer must not see
// a compressed one; callers that decode and encode pass "compressed".
@@ -783,6 +808,7 @@ export function assignCompactLayouts(
field: number,
isCompact: (className: string) => boolean,
) => boolean,
inlineArrays?: ReadonlyMap<string, InlineArrayClass>,
): void {
const isCompact = (className: string): boolean => {
const target = metaMap.get(className);
@@ -814,7 +840,18 @@ export function assignCompactLayouts(
? base!.compressed[i]!
: compressedRef !== undefined && compressedRef(meta.def, i, isCompact),
);
// An inline array field (ir/inline-arrays.ts) whose elements are compact
// keeps its element count in its slot.
const inline = inlineArrays?.get(meta.def.name);
const inlineField =
inline !== undefined && isCompact(inline.elemClass) && isCompact(inline.declaringClass)
? inline.fieldIndex
: -1;
const types = fieldTypes(meta.def).map((t, i) => {
if (i === inlineField) {
compressed[i] = false;
return "i32";
}
if (!compressed[i]) return t;
if (t !== "ptr")
throw new InternalCompilerError(
@@ -862,6 +899,44 @@ export function assignCompactLayouts(
meta.compactSlots = slots;
meta.compressed = compressed;
}
if (inlineArrays !== undefined) assignInlineTails(metaMap, inlineArrays);
}
/** Gives each compact class of an inline array field's subtree its tail
* (LlClassMeta.inlineTail): the elements start after the largest fixed part
* of any class in the subtree, so that a read through any static type of
* the subtree finds them at the same offset. */
function assignInlineTails(
metaMap: ReadonlyMap<string, LlClassMeta>,
inlineArrays: ReadonlyMap<string, InlineArrayClass>,
): void {
const subtrees = new Map<string, LlClassMeta[]>();
for (const [name, plan] of inlineArrays) {
const meta = metaMap.get(name);
if (meta === undefined || !meta.compact) continue;
// The layout gave the field its i32 count only when the elements are
// compact (assignCompactLayouts).
const slot = meta.compactSlots[plan.fieldIndex];
if (slot === undefined || meta.compactElems[slot]?.type !== "i32") continue;
const members = subtrees.get(plan.declaringClass);
if (members === undefined) subtrees.set(plan.declaringClass, [meta]);
else members.push(meta);
}
for (const members of subtrees.values()) {
let end = 0;
for (const meta of members)
for (const e of meta.compactElems) end = Math.max(end, e.offset + e.size);
const tailOffset = alignTo(end, 4);
for (const meta of members) {
const plan = inlineArrays.get(meta.def.name)!;
meta.inlineTail = {
field: plan.fieldIndex,
tailOffset,
elemClass: plan.elemClass,
lengthArg: plan.lengthArg,
};
}
}
}
function alignTo(n: number, align: number): number {
@@ -901,6 +976,8 @@ export function classUndefinedFieldInits(
if (f.name === "%cause") return;
// A nullable-pointer field's zeroed slot already is its unit arm.
if (nullable?.get(meta.def.name, f.name)) return;
// An inline array field's slot holds its count, which `new` stores.
if (meta.inlineTail?.field === i && meta.compact) return;
// Only union and dyn fields get a store here; a compressed field is a
// class reference, which starts NULL.
const { index, compressed } = classFieldIndex(meta, f.name, "compressed");
@@ -1421,8 +1498,12 @@ export function emitClassShapes(
// members, undefined-admitting union fields at the interned unit
// instance. Traced shapes allocate with the collector header; alloc.ts
// inlines the allocator fast paths and the alloc note.
// An inline array field's instance (LlClassMeta.inlineTail) takes the
// field's element count and has room for the elements after its fixed
// fields.
const tail = meta.compact && regionRefs ? (meta.inlineTail ?? null) : null;
const nw: string[] = [
`define internal ptr @${mangleClassNew(cls.name)}() ${FN_ATTRS} { ; new ${cls.name}`,
`define internal ptr @${mangleClassNew(cls.name)}(${tail !== null ? "i32 %inl.len" : ""}) ${FN_ATTRS} { ; new ${cls.name}`,
`entry:`,
];
const cycType = `@${mangleClassTrace(cls.name)}.cyctype`;
@@ -1450,7 +1531,14 @@ export function emitClassShapes(
// Compressed fields refer to compact instances by their index in the
// program-lifetime region, so every compact instance lives there:
// zeroed, 8-byte aligned and never freed.
nw.push(...plrAllocLines(host, sizeOf));
if (tail !== null) {
nw.push(
` %inl.n = zext i32 %inl.len to ${host.sizeType}`,
` %inl.bytes = shl nuw nsw ${host.sizeType} %inl.n, 2`,
` %inl.size = add nuw nsw ${host.sizeType} %inl.bytes, ${tail.tailOffset}`,
);
nw.push(...plrAllocLines(host, "%inl.size"));
} else nw.push(...plrAllocLines(host, sizeOf));
} else
nw.push(
...emitObjectAlloc(
@@ -1527,6 +1615,11 @@ export function emitClassShapes(
);
}
nw.push(...undefFieldInits(host, meta, nullable));
if (tail !== null)
nw.push(
` %inl.countp = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${meta.compactSlots[tail.field]!}`,
` store i32 %inl.len, ptr %inl.countp ; ${llvmCommentText(cls.name)} inline array count`,
);
// 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.
@@ -1678,6 +1771,10 @@ export function emitClassObjDefs(
throw new InternalCompilerError(
`llvm emitter bug: class object for ${className} without a constructor`,
);
if (meta.inlineTail !== undefined)
throw new InternalCompilerError(
`llvm emitter bug: class object for ${className}, which has an inline array field`,
);
const params = ctor.params.slice(valueCtor.firstArg);
const paramDecls = params.map((p, i) => `${llType(p.type)} %a${i}`).join(", ");
const ctorArgs = params.map((p, i) => `${llType(p.type)} %a${i}`);
@@ -62,7 +62,7 @@ import { emitIntegerClone, emitIntegerDispatcher } from "./integer-clones.js";
import { beginFunctionLoops, LOOPS } from "./loop-state.js";
import { runningExtremumUpdates } from "./running-extrema.js";
import { findIntegerOrNaNLocals } from "./wrapping-sums.js";
import { UNWIND_PERSONALITY, UNWIND_SLOT } from "./unwind-abi.js";
import { UNWIND_PERSONALITY, UNWIND_RESUME_DECLARATION, UNWIND_SLOT } from "./unwind-abi.js";
export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
const B = new BlockBuilder();
@@ -681,7 +681,10 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
emitter.privateGlobalSlots.clear();
// Landing pads (emitDirectCall) need the personality.
const personality = emitter.usesLandingPads ? ` ${UNWIND_PERSONALITY}` : "";
if (emitter.usesLandingPads) emitter.declare(`declare i32 @__gcc_personality_v0(...)`);
if (emitter.usesLandingPads) {
emitter.declare(`declare i32 @__gcc_personality_v0(...)`);
emitter.declare(UNWIND_RESUME_DECLARATION);
}
let body = `define internal ${ret} @${symbol}${int32Abi ? INT32_ABI_SUFFIX : ""}(${params.join(", ")}) ${attrs}${personality}${debug} { ; ${fn.name}\n${B.render()}\n}`;
if (int32Abi) body += "\n" + emitter.emitInt32AbiWrapper(fn, symbol, int32Abi);
if (emitter.integerClones.members.has(fn.name)) {
@@ -1,4 +1,5 @@
/* LlEmitter's statement emission: the statement switch, try/catch, switch, loop copies, speculative writes and string self-concatenation. */
import { emitInlineArrayInit, inlineFieldTail } from "./inline-arrays.js";
import { emitLoopBytesHeaders } from "./bytes-headers.js";
import { noteBytesReceiver } from "./expr-bytes.js";
import {
@@ -73,7 +74,7 @@ import { bytesHeaderAlias } from "./bytes-alias.js";
import { mangleLocal } from "../mangle.js";
import { LlvmUnsupportedError } from "./unsupported.js";
import type { LlValue } from "./expr-context.js";
import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
import { everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse.js";
import { widenInteger } from "./integer-values.js";
import { SCALAR_UNION_RESULT } from "./int32-abi.js";
import { emitLiteralSwitch } from "./switch-dispatch.js";
@@ -620,6 +621,25 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
// release — a release can trigger a cycle collection, which must
// never see a heap edge whose count was already given up).
// Classes and records share the struct layout, so one emission.
// The constructor's write of an inline array field copies the array's
// elements into the instance (backend/llvm/inline-arrays.ts).
const tail =
s.kind === "fieldSet" ? inlineFieldTail(emitter, s.className, s.field) : undefined;
if (tail !== undefined && s.kind === "fieldSet") {
const obj = emitter.emitStableReceiver(s.obj, [s.value]);
const v = emitter.emitExpr(s.value);
emitter.emitStoreGuard(obj.name, s);
emitter.heapCensus?.countStore(
(line) => B.line(line),
() => B.tmp(),
s.className,
s.field,
s.value,
v.name,
);
emitInlineArrayInit(emitter, obj.name, s.className, tail, v.name);
break;
}
const nullable =
s.kind === "fieldSet" ? emitter.nullableFields.get(s.className, s.field) : null;
if (nullable && s.kind === "fieldSet") {
@@ -1290,6 +1310,37 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
emitter.workerLoopPolls(s) &&
(loopAccessesTypedArrays(s) || (countedLoop !== null && innermost));
const workerBudget = stripMined || bounded ? null : emitter.workerLoopBudget(s);
// An innermost counted loop, or a two-counter loop closing in by one
// step on each side, has a run whose length its counters fix on entry.
// With a small body, a run of at most WORKER_UNPOLLED_RUN steps takes a
// plain copy of the loop without a poll, which polls once as it leaves
// when it ran more than WORKER_POLL_BLOCK steps, like a strip-mined run;
// longer runs take the loop as it is emitted otherwise.
const shortRuns: { stride: number; left: () => string } | null =
!innermost ||
bounded ||
capturedLets.length > 0 ||
!emitter.workerLoopPolls(s) ||
!withinNodes(s.body, MAX_SHORT_RUN_NODES)
? null
: countedLoop !== null && countedLimit !== null && integerSlot !== null
? {
stride: Math.abs(countedLoop.step),
left: () => {
const start = B.tmp(),
left = B.tmp();
B.line(`${start} = load i64, ptr ${integerSlot}`);
B.line(
countedLoop.step > 0
? `${left} = sub nsw i64 ${countedLimit}, ${start}`
: `${left} = sub nsw i64 ${start}, ${countedLimit}`,
);
return left;
},
}
: pairedLoop !== null && pairedLoop.low.step === 1 && pairedLoop.high.step === -1
? { stride: 2, left: () => emitPairedLoopGap(emitter, pairedSlots) }
: null;
// One slot for every copy of the loop: a speculative copy resumes an
// iteration in another copy's body, which then reads this bound.
let blockBound: string | null = null;
@@ -1305,48 +1356,12 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
}
}
freshenBindings();
emitter.emitLoopCopies(s, capturedLets.length === 0, le, (exit) => {
if (stripMined) {
const counter: StripMinedCounter = countedLoop
? {
localId: countedLoop.localId,
slot: integerSlot!,
type: "i64",
signed: true,
step: countedLoop.step,
followers: countedLoop.followers.map((f, i) => ({
slot: followerSlots[i]!,
step: f.step,
})),
limit: () => countedLimit!,
}
: {
localId: integerLoop!.localId,
slot: integerSlot!,
type: emitter.sizeType,
signed: false,
step: 1,
followers: [],
limit: () => {
const receiver = emitter.emitStableReceiver(integerLoop!.limitReceiver, []);
const lenPtr = B.tmp();
const len = B.tmp();
B.line(
`${lenPtr} = getelementptr inbounds %ScrBytes, ptr ${receiver.name}, i64 0, i32 1`,
);
B.line(
`${len} = load ${emitter.sizeType}, ptr ${lenPtr}${bytesHeaderAlias(emitter)}`,
);
return len;
},
};
emitStripMinedCountedLoop(emitter, s, counter, blockBound!, exit, runSlot);
return;
}
// The loop as a plain header, body and update, polling by `budget`.
const emitPlainLoop = (exit: string, budget: string | null): void => {
const lc = B.newLabel("loop.c");
const lb = B.newLabel("loop.b");
const lu =
s.update || capturedLets.length > 0 || workerBudget !== null ? B.newLabel("loop.u") : lc;
s.update || capturedLets.length > 0 || budget !== null ? B.newLabel("loop.u") : lc;
// A periodic guard on the counter (periodic-guards.ts): the back
// edge asks LLVM to unroll the loop by the guard's period.
const period =
@@ -1417,7 +1432,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
B.br(lu, lu === lc ? backEdge : "");
if (lu !== lc) {
B.startBlock(lu);
emitter.emitWorkerLoopCheck(workerBudget);
emitter.emitWorkerLoopCheck(budget);
freshenBindings();
if (integerLoop && integerSlot) {
const old = B.tmp();
@@ -1449,6 +1464,78 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
} else if (s.update) emitter.emitStmt(s.update);
B.br(lc, backEdge);
}
};
emitter.emitLoopCopies(s, capturedLets.length === 0, le, (exit) => {
// Speculative copies resume iterations inside the copy they enter,
// which must keep its polls.
if (
shortRuns &&
emitter.speculation === null &&
emitter.speculationResume === null &&
emitter.speculationHeader === null &&
emitter.int32Kinds === null
) {
// The run's length in counter steps (two counter steps an
// iteration for a two-counter loop), signed: a run that does not
// enter the body is short too.
const left = shortRuns.left();
const inRun = B.tmp(),
pollsAfter = B.tmp();
const shortLabel = B.newLabel("loop.short"),
longLabel = B.newLabel("loop.long"),
shortEnd = B.newLabel("loop.short.end"),
shortPoll = B.newLabel("loop.short.poll");
B.line(`${inRun} = icmp sle i64 ${left}, ${shortRuns.stride * WORKER_UNPOLLED_RUN}`);
B.condBr(inRun, shortLabel, longLabel);
B.startBlock(shortLabel);
emitPlainLoop(shortEnd, null);
// A run longer than one block polls once as it leaves.
B.startBlock(shortEnd);
B.line(`${pollsAfter} = icmp sgt i64 ${left}, ${shortRuns.stride * WORKER_POLL_BLOCK}`);
B.condBr(pollsAfter, shortPoll, exit);
B.startBlock(shortPoll);
emitter.emitWorkerPoll();
B.br(exit);
B.startBlock(longLabel);
}
if (stripMined) {
const counter: StripMinedCounter = countedLoop
? {
localId: countedLoop.localId,
slot: integerSlot!,
type: "i64",
signed: true,
step: countedLoop.step,
followers: countedLoop.followers.map((f, i) => ({
slot: followerSlots[i]!,
step: f.step,
})),
limit: () => countedLimit!,
}
: {
localId: integerLoop!.localId,
slot: integerSlot!,
type: emitter.sizeType,
signed: false,
step: 1,
followers: [],
limit: () => {
const receiver = emitter.emitStableReceiver(integerLoop!.limitReceiver, []);
const lenPtr = B.tmp();
const len = B.tmp();
B.line(
`${lenPtr} = getelementptr inbounds %ScrBytes, ptr ${receiver.name}, i64 0, i32 1`,
);
B.line(
`${len} = load ${emitter.sizeType}, ptr ${lenPtr}${bytesHeaderAlias(emitter)}`,
);
return len;
},
};
emitStripMinedCountedLoop(emitter, s, counter, blockBound!, exit, runSlot);
return;
}
emitPlainLoop(exit, workerBudget);
});
B.startBlock(le);
emitter.releaseScope(emitter.scopes.pop()!);
@@ -2781,6 +2868,34 @@ function boundedLoopRuns(emitter: LlEmitter, loop: IrStmt): number {
return trips * (1 + nested);
}
/** The most IR nodes the body of a loop with an unpolled copy for short
* runs may have: the copy duplicates the loop. */
const MAX_SHORT_RUN_NODES = 256;
/** Whether `body` has at most `limit` statement and expression nodes. */
function withinNodes(body: IrStmt[], limit: number): boolean {
let nodes = 0;
return everyStmtList(body, {
expr: () => ++nodes <= limit,
stmt: () => ++nodes <= limit,
});
}
/** The gap between the counters of a two-counter loop stepping them by 1
* and -1 (`for (let i = a, j = b; i < j; i++, j--)`), from the values in
* `slots`: each iteration closes it by two, so a run takes about half the
* gap in iterations, and none when the gap is closed. */
function emitPairedLoopGap(emitter: LlEmitter, slots: readonly string[]): string {
const B = emitter.B;
const low = B.tmp(),
high = B.tmp(),
gap = B.tmp();
B.line(`${low} = load i64, ptr ${slots[0]}`);
B.line(`${high} = load i64, ptr ${slots[1]}`);
B.line(`${gap} = sub nsw i64 ${high}, ${low}`);
return gap;
}
/** The counter of a strip-mined loop: its binding, its slot, the LLVM
* integer type and signedness of its values, its step, the counters stepped
* beside it, and the limit, emitted in the outer header before each block. */
+155 -34
View File
@@ -88,6 +88,13 @@ import {
import { specializeNumericCalls } from "../../ir/numeric-call-specialization.js";
import { versionLoopBoundParameters } from "../../ir/parameter-versions.js";
import { inlineExpressionFunctions } from "../../ir/expression-inlining.js";
import {
inlineArrayReads,
NO_INLINE_ARRAYS,
planInlineArrays,
type InlineArrayPlan,
} from "../../ir/inline-arrays.js";
import { inlineArraysApply, programLifetimeClassOf } from "./inline-arrays.js";
import { pairRowSums } from "../../ir/row-pairs.js";
import {
allocatedLiteral,
@@ -131,7 +138,12 @@ import {
type Int32Abi,
} from "./int32-abi.js";
import { type Int32Local } from "./int32-locals.js";
import { findIntegerClones, NO_INTEGER_CLONES, type IntegerClones } from "./integer-clones.js";
import {
findIntegerClones,
NO_INTEGER_CLONES,
type CloneReferences,
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";
@@ -148,6 +160,7 @@ import {
UNWIND_PERSONALITY,
UNWIND_RAISE,
UNWIND_RAISE_DECLARATION,
UNWIND_RESUME_DECLARATION,
UNWIND_RESUME,
UNWIND_SLOT,
UnwindAbiViolation,
@@ -348,6 +361,7 @@ import {
classStructSym,
programLifetimeClass,
type LlClassMeta,
type LlInlineTail,
} from "./classes.js";
import { LifetimeRegions, REGION_TYPES } from "./regions.js";
import {
@@ -371,6 +385,7 @@ import {
NULLABLE_NULL,
NullableUnions,
pointerTagFn,
type NullableUnion,
} from "./nullable-unions.js";
import { classUnionHeaders, emitClassUnionHelpers } from "./class-unions.js";
import { emitNullablePresent, emitUnionPeek, emitUnionTag } from "./union-repr.js";
@@ -689,6 +704,13 @@ function resultEntryTargets(mod: IrModule): ReadonlySet<string> {
return targets;
}
/** The reads and constructions of inline array fields prepared for their
* layout (ir/inline-arrays.ts), where the target keeps them inline. */
function inlineArraysPass(options: LlvmTargetOptions, mod: IrModule): IrModule {
if (!inlineArraysApply(mod, options)) return mod;
return inlineArrayReads(mod, planInlineArrays(mod, programLifetimeClassOf(mod)));
}
/** The emitter for `input` after the module passes every emission runs. */
function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter {
// Facts are keyed by node identity: emit the same module whether it was
@@ -734,14 +756,17 @@ function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter {
specializeCallbackCalls(
specializeNumericCalls(
pairRowSums(
inlineExpressionFunctions(
foldConstantGlobals(
copyStableCaptures(
resolveSelfCaptures(
hoistFinalFieldReads(
freezeInitOnlyGlobals(
holdCharLocalsAsUnits(
dropUnwrittenPropertyBags(runPairedWhileLoopsAsFor(mod)),
inlineArraysPass(
options,
inlineExpressionFunctions(
foldConstantGlobals(
copyStableCaptures(
resolveSelfCaptures(
hoistFinalFieldReads(
freezeInitOnlyGlobals(
holdCharLocalsAsUnits(
dropUnwrittenPropertyBags(runPairedWhileLoopsAsFor(mod)),
),
),
),
),
@@ -1762,6 +1787,8 @@ export class LlEmitter {
readonly int32Abis: ReadonlyMap<string, Int32Abi>;
/** Pure number functions with speculative integer clones. */
readonly integerClones: IntegerClones;
/** The records integer clones read (64-bit layouts). */
readonly cloneReferences: CloneReferences | undefined;
/** The IR function being emitted. */
currentFunctionName = "";
@@ -1774,6 +1801,9 @@ export class LlEmitter {
/** ProgramLifetime hierarchy roots (contract types): their instances are
* immortal from construction (classes.ts programLifetimeClass). */
readonly programLifetime: ReadonlySet<string>;
/** Inline array fields this emission may keep inline (ir/inline-arrays.ts);
* the classes that do have LlClassMeta.inlineTail. */
readonly inlineArrays: InlineArrayPlan;
numericLocals = new Map<string, IrLocal>();
captureIds = new Set<string>();
/** Active induction bindings retain their width independently of size_t. */
@@ -2220,6 +2250,12 @@ export class LlEmitter {
this.immutableFields,
this.nodeIndex,
);
// Inline array fields (ir/inline-arrays.ts), where the module passes
// prepared their reads and constructions.
this.inlineArrays =
options.debugSources === undefined && inlineArraysApply(mod, options)
? planInlineArrays(mod, programLifetimeClassOf(mod))
: NO_INLINE_ARRAYS;
const walkParameters = new Map<string, Set<number>>();
if (this.debug === null) {
const host = {
@@ -2288,26 +2324,6 @@ export class LlEmitter {
};
}
this.constantCallbacks = findConstantCallbacks(mod, this.callLifetimes, this.nodeIndex);
// Integer clones signal a stack overflow through their result, which
// needs the single-threaded guard; worker programs also poll for
// termination, and debug builds and stack traces keep every frame.
// Clones read number arrays their function borrows (64-bit layouts).
this.integerClones =
this.debug === null &&
this.stackChecks !== null &&
mod.workers !== true &&
mod.lib === undefined &&
!this.stackTraces
? findIntegerClones(
mod,
this.sizeType === "i64"
? (fn, index) => this.callLifetimes.borrowed.get(fn)?.has(index) === true
: undefined,
)
: NO_INTEGER_CLONES;
// Integer clones and their dispatchers call each other through their
// own lowering, which keeps plain calls and flag tests.
for (const name of this.integerClones.members) this.unwindAbi.delete(name);
this.stackCallbacks = new StackCallbacks(this.fnByName);
this.receiverReaders = new AmbientReceiverReaders(this.fnByName, this.nodeIndex);
this.stackCaptures = new StackCaptures(this.fnByName, this.callLifetimes, this.stackCallbacks);
@@ -2470,6 +2486,30 @@ export class LlEmitter {
this.shapeHost.recordViews = views;
this.recordViewTargets = views.targets as Set<string>;
}
// Integer clones signal a stack overflow through their result. Worker
// programs have them only as ELF executables, where the stack guard
// and the pending word clones poll for termination are plain
// thread-locals; debug builds and stack traces keep every frame.
// Clones read number arrays and record graphs their function borrows
// (64-bit layouts), but no record that may be a view (planned above).
this.cloneReferences = this.sizeType === "i64" ? this.recordCloneReferences() : undefined;
this.integerClones =
this.debug === null &&
this.stackChecks !== null &&
(mod.workers !== true || this.executableTls) &&
mod.lib === undefined &&
!this.stackTraces
? findIntegerClones(
mod,
this.sizeType === "i64"
? (fn, index) => this.callLifetimes.borrowed.get(fn)?.has(index) === true
: undefined,
this.cloneReferences,
)
: NO_INTEGER_CLONES;
// Integer clones and their dispatchers call each other through their
// own lowering, which keeps plain calls and flag tests.
for (const name of this.integerClones.members) this.unwindAbi.delete(name);
this.walkers = new LlWalkers(this.shapeHost);
this.dyn = new LlDyn(this.shapeHost);
for (const g of mod.globals ?? []) {
@@ -2557,6 +2597,7 @@ export class LlEmitter {
return nullable !== null && isCompact(nullable.arm.className);
}
: undefined,
this.inlineArrays.classes,
);
this.compactRegion =
mod.lib === undefined &&
@@ -4781,6 +4822,13 @@ export class LlEmitter {
refs: "pointer" | "compressed" = "pointer",
): { ptr: string; type: IrType; inline: boolean; compressed: boolean } {
const meta = this.classMetaOf(className);
// An inline array field's slot holds its element count: its reads and
// writes go through backend/llvm/inline-arrays.ts.
const tail = this.inlineTailOf(className);
if (tail !== undefined && meta.def.fields[tail.field]!.name === field)
throw new InternalCompilerError(
`llvm emitter bug: field pointer to the inline array field ${className}.${field}`,
);
const { index, type, compressed } = classFieldIndex(meta, field, refs);
const p = this.B.tmp();
this.B.line(
@@ -4928,6 +4976,49 @@ export class LlEmitter {
/** The field-slot pointer of a record member (rc header at index 0).
* `inline`: the slot stores a scalar union's tag and bits. */
/** The record layouts integer clones read in place: record pointers,
* `R | null` unions of a record (NULL is `null`), and fields stored as a
* double, an i8 boolean or such a pointer. Records that may be views
* (record-views.ts) are not among them. */
private recordCloneReferences(): CloneReferences {
// A view's fields live in the record it views (recordViewFieldPtr), not
// at its own shape's offsets.
const plain = (shapeId: string): boolean =>
this.recordsById.has(shapeId) && this.shapeHost.recordViews?.targets.has(shapeId) !== true;
const nullable = (unionId: string): NullableUnion | null => {
const single = this.nullableUnions.single(unionId);
if (single === null || single.nullTag !== -1 || single.arm.kind !== "record") return null;
if (!plain(single.arm.shapeId)) return null;
const unit = this.unionsById.get(unionId)?.arms[single.unitTag];
return unit?.kind === "nullT" ? single : null;
};
const reference = (type: IrType): boolean =>
type.kind === "record"
? plain(type.shapeId)
: type.kind === "union" && nullable(type.unionId) !== null;
return {
reference,
arm: (unionId, tag) => {
const single = nullable(unionId);
if (single === null) return null;
return tag === single.refTag ? "record" : tag === single.unitTag ? "null" : null;
},
field: (shapeId, field) => {
const shape = this.recordsById.get(shapeId);
const index = shape?.fields.findIndex((f) => f.name === field) ?? -1;
if (shape === undefined || index < 0 || !plain(shapeId)) return null;
const type = shape.fields[index]!.type;
if (this.inlineUnions.record(type)) return null;
if (type.kind !== "f64" && type.kind !== "bool" && !reference(type)) return null;
return {
struct: mangleRecordStruct(shapeId),
index: index + this.nullableUnions.fieldBase(shapeId),
type,
};
},
};
}
recordFieldPtr(
objName: string,
shapeId: string,
@@ -6020,7 +6111,8 @@ export class LlEmitter {
const exception = B.tmp();
B.line(`${exception} = landingpad { ptr, i32 } cleanup`);
for (const index of borrowed) this.releaseFrame(`%p${index}`, fn.params[index]!.type);
B.terminate(`resume { ptr, i32 } ${exception}`);
this.declare(UNWIND_RESUME_DECLARATION);
B.resumeUnwind(exception);
B.startBlock(ok);
} else B.line(ret === "void" ? call : `%result = ${call}`);
// A borrowed result is reachable from the parameters: own it first. A
@@ -7177,7 +7269,19 @@ export class LlEmitter {
fieldBase: (shapeId) => this.nullableUnions.fieldBase(shapeId),
classInfo: (className) => {
const meta = this.classMetaOf(className);
const tail = this.inlineTailOf(className);
const tailField = tail === undefined ? undefined : meta.def.fields[tail.field]!;
return {
...(tail !== undefined && tailField !== undefined
? {
tail: {
name: tailField.name,
countIndex: meta.compactSlots[tail.field]!,
offset: tail.tailOffset,
elem: { kind: "object" as const, className: tail.elemClass },
},
}
: {}),
def: meta.def,
hierarchy: meta.hierarchy,
pre: meta.pre,
@@ -7185,9 +7289,10 @@ export class LlEmitter {
runtimeRooted: meta.root.def.runtime === true,
idInRc: meta.idInRc,
compact: meta.compact,
// Inline scalar-union fields are plain bits: nothing to push.
fields: meta.def.fields.flatMap((f) =>
this.inlineUnions.classField(className, f.name)
// Inline scalar-union fields are plain bits: nothing to push. An
// inline array field's elements follow the fixed fields (`tail`).
fields: meta.def.fields.flatMap((f, i) =>
this.inlineUnions.classField(className, f.name) || tail?.field === i
? []
: [
{
@@ -7410,6 +7515,13 @@ export class LlEmitter {
return emitReadReceiver(this, e);
}
/** The inline array field of `className`'s instances, where they keep it
* inline (LlClassMeta.inlineTail; classes.ts gives their `new` the count). */
inlineTailOf(className: string): LlInlineTail | undefined {
const meta = this.classMeta.get(className);
return meta !== undefined && meta.compact && this.compactRegion ? meta.inlineTail : undefined;
}
/** The frame slot a reassigned stack-object local keeps for its value. */
stackObjectSlot(localId: string, type: IrType): string {
let slot = this.stackObjectSlots.get(localId);
@@ -7599,6 +7711,15 @@ export class LlEmitter {
return canBorrowReceiver(this, e);
}
/** A read of an inline array field (ir/inline-arrays.ts). It makes a view
* of the elements that the read owns: no field holds an array that could
* keep a borrowed copy alive. Known before the class layouts are, from
* the module's plan; layouts that keep the field an ordinary array only
* lose the borrow. */
inlineArrayRead(e: IrExpr): boolean {
return e.kind === "fieldGet" && this.inlineArrays.classes.get(e.className)?.field === e.field;
}
/** A reference held as one plain pointer that emitReadReceiver can read
* without owning: class instances, records, arrays, strings, typed arrays
* and nullable-pointer unions. Tagged union boxes, closures and dynamic
@@ -7632,7 +7753,7 @@ export class LlEmitter {
if (matchMapRead(e, this.boxedUnionsById)) return false;
switch (e.kind) {
case "fieldGet":
return this.nullableFieldGet(e) === null;
return this.nullableFieldGet(e) === null && !this.inlineArrayRead(e);
case "recordGet":
return true;
// A keyed read whose hits are all the result's own pointer.
@@ -54,6 +54,7 @@ import {
ffiNativeTypeLl,
ffiNativeParamLl,
ffiNativeReturnLl,
SCR_ARR_FIELD,
} from "./common.js";
import { canStackUnion, emitStackUnion } from "./stack-unions.js";
import { emitStackRecordKeyGet, scalarRecordKeyGet } from "./expr-records.js";
@@ -1121,8 +1122,28 @@ export function emitCallExpr(
throw new InternalCompilerError(
`llvm emitter bug: new ${e.className} without a constructor`,
);
// An instance with an inline array field is allocated with room for
// the elements of the array its constructor writes the field from
// (ir/inline-arrays.ts made that argument a local).
const tail = host.inlineTailOf(e.className);
let count = "";
if (tail !== undefined) {
const source = e.args[tail.lengthArg];
if (source?.kind !== "varRef")
throw new InternalCompilerError(
`llvm emitter bug: new ${e.className} reads its inline array from an expression`,
);
const arr = host.emitReadReceiver(source);
const lenp = B.tmp();
const len = B.tmp();
B.line(
`${lenp} = getelementptr inbounds %ScrArr, ptr ${arr.name}, i32 0, i32 ${SCR_ARR_FIELD.len}`,
);
B.line(`${len} = load i32, ptr ${lenp}`);
count = `i32 ${len}`;
}
const o = B.tmp();
B.line(`${o} = call ptr @${host.allocSym(mangleClassNew(e.className), e)}()`);
B.line(`${o} = call ptr @${host.allocSym(mangleClassNew(e.className), e)}(${count})`);
host.heapCensus?.countNew((line) => B.line(line), e.className);
const out = host.own({ name: o, type: e.type });
emitConstruction(host, e, o, ctor);
@@ -1,5 +1,6 @@
import { dynDesc } from "../../ir/analysis.js";
/* Focused LLVM expression emission extracted from emitter.ts. */
import { emitInlineArrayLength, inlineFieldTail } from "./inline-arrays.js";
import { arraySearchHelperName, arraySearchKind } from "./array-search.js";
import { InternalCompilerError } from "../../errors.js";
import { isStableReceiverOperand, undefinedArmTag } from "../../ir/analysis.js";
@@ -650,6 +651,14 @@ export function emitArrIntrinsic(
into?: { box: string; tag: string; payload: string },
): LlValue {
const B = host.B;
// The length of an inline array field, read from the instance.
if (e.method === "length" && e.receiver.kind === "fieldGet") {
const tail = inlineFieldTail(host, e.receiver.className, e.receiver.field);
if (tail !== undefined) {
const obj = host.emitStableReceiver(e.receiver.obj, e.args);
return emitInlineArrayLength(host, obj.name, e.receiver.className, tail, e.type);
}
}
// Every array intrinsic borrows its receiver, including mutation and
// retained-identity results. Scalar reads also admit the narrower proof
// that a mutable binding survives the remaining operand evaluations.
@@ -1,4 +1,10 @@
/* Focused LLVM expression emission extracted from emitter.ts. */
import {
emitInlineArrayGet,
emitInlineArrayState,
emitInlineArrayView,
inlineFieldTail,
} from "./inline-arrays.js";
import { InternalCompilerError } from "../../errors.js";
import { matchStringSelfConcat, undefinedArmTag } from "../../ir/analysis.js";
import { isRefCounted, type IrExpr, type IrStrCmpOp, type IrType } from "../../ir/ir.js";
@@ -1181,6 +1187,26 @@ export function emitContainerExpr(
return out;
}
case "arrayGet": {
// An element of an inline array field, read from the instance.
if (e.arr.kind === "fieldGet" && e.arr.type.kind === "array") {
const tail = inlineFieldTail(host, e.arr.className, e.arr.field);
if (tail !== undefined) {
const obj = host.emitStableReceiver(e.arr.obj, [e.index]);
const idx = host.emitExpr(e.index);
const value = emitInlineArrayGet(
host,
obj.name,
e.arr.className,
tail,
idx,
e.arr.type.elem,
true,
);
return isRefCounted(e.arr.type.elem)
? host.own({ name: value, type: e.type })
: { name: value, type: e.type };
}
}
// Element reads run no user code: a projection receiver (an array in
// a parameter's field) stays rooted while the index preserves edges.
const arr = host.emitStableReceiver(e.arr, [e.index]);
@@ -1233,6 +1259,16 @@ export function emitContainerExpr(
return host.own({ name: read.value, type: e.type });
}
case "arrayHas": {
// An inline array field has no holes: presence is the bounds check.
if (e.arr.kind === "fieldGet") {
const tail = inlineFieldTail(host, e.arr.className, e.arr.field);
if (tail !== undefined) {
const obj = host.emitStableReceiver(e.arr.obj, [e.index]);
const idx = host.emitExpr(e.index);
const name = emitInlineArrayState(host, obj.name, e.arr.className, tail, idx, "has");
return { name, type: e.type };
}
}
const arr = host.emitStableReceiver(e.arr, [e.index]);
const idx = host.emitExpr(e.index);
if (e.arr.type.kind !== "array")
@@ -1252,6 +1288,16 @@ export function emitContainerExpr(
return { name: t, type: e.type };
}
case "arrayState": {
// An inline array field has no holes: presence is the bounds check.
if (e.arr.kind === "fieldGet") {
const tail = inlineFieldTail(host, e.arr.className, e.arr.field);
if (tail !== undefined) {
const obj = host.emitStableReceiver(e.arr.obj, [e.index]);
const idx = host.emitExpr(e.index);
const name = emitInlineArrayState(host, obj.name, e.arr.className, tail, idx, "state");
return { name, type: e.type };
}
}
const arr = host.emitStableReceiver(e.arr, [e.index]);
const idx = host.emitExpr(e.index);
if (e.arr.type.kind !== "array")
@@ -1356,6 +1402,14 @@ export function emitRecordExpr(
switch (e.kind) {
case "fieldGet": {
const obj = host.emitReadReceiver(e.obj);
// An inline array field read as a value: a frozen array over the
// elements (backend/llvm/inline-arrays.ts).
const tail = inlineFieldTail(host, e.className, e.field);
if (tail !== undefined)
return host.own({
name: emitInlineArrayView(host, obj.name, e.className, tail),
type: e.type,
});
const { ptr, type, inline } = host.classFieldPtr(
obj.name,
e.className,
@@ -35,6 +35,7 @@ import {
import { SCR_ARR_FIELD, llvmCommentText } from "./common.js";
import {
FN_ATTRS,
arrOps,
elemAccess,
llFieldType,
loadStructField,
@@ -44,6 +45,7 @@ import {
type ShapeHost,
} from "./shapes.js";
import type { LlvmEmitterContext, LlStreamTypedRefAdapter } from "./expr-context.js";
import { inlineFieldTail } from "./inline-arrays.js";
import type { NullableRefField } from "./nullable-fields.js";
import { emitUnionPeek, emitUnionTag } from "./union-repr.js";
import { inlineUnionCopyLines } from "./inline-unions.js";
@@ -1703,6 +1705,27 @@ function classFieldDynValue(
t: Extract<IrType, { kind: "object" }>,
field: LlClassMeta["def"]["fields"][number],
): { boxed: string; nullable: NullableRefField | null; fieldValue: string } {
// An inline array field reads as a frozen array over the instance's
// elements (inline-arrays.ts), which the box keeps.
const tail = inlineFieldTail(host, t.className, field.name);
if (tail !== undefined) {
const countp = B.tmp(),
count = B.tmp(),
at = B.tmp(),
view = B.tmp();
B.line(
`${countp} = getelementptr inbounds %${classStructSym(t.className)}, ptr %p, i64 0, i32 ${meta.compactSlots[tail.field]!}`,
);
B.line(`${count} = load i32, ptr ${countp}`);
B.line(`${at} = getelementptr inbounds i8, ptr %p, i64 ${tail.tailOffset}`);
const ops = arrOps(host.shapeHost, { kind: "object", className: tail.elemClass });
host.declare(`declare ptr @scr_arr_view_cref(ptr, ptr, i32)`);
host.declare(`declare void @scr_arr_release(ptr)`);
B.line(`${view} = call ptr @scr_arr_view_cref(ptr ${ops}, ptr ${at}, i32 ${count})`);
const boxed = host.streamTypedRefBoxValue(B, field.type, view);
B.line(`call void @scr_arr_release(ptr ${view})`);
return { boxed, nullable: null, fieldValue: view };
}
const { index, compressed } = classFieldIndex(meta, field.name, "compressed");
const fieldPtr = B.tmp();
let fieldValue = B.tmp();
@@ -0,0 +1,257 @@
/* Inline array fields in the LLVM backend (ir/inline-arrays.ts plans them).
*
* A compact class with such a field (LlClassMeta.inlineTail) keeps the
* field's element count as an i32 in the field's slot and the elements as
* 32-bit region references after its fixed fields, at the subtree's tail
* offset. `new` reads the count from the constructor argument the field is
* written from and allocates the instance with room for them; the
* constructor's write copies the argument's elements there
* (scr_arr_copy_cref_tail). Reads that index the field, read its length or
* iterate it load the elements directly; any other read gives a frozen array
* over them (scr_arr_view_cref). */
import { isRefCounted, type IrModule, type IrType } from "../../ir/ir.js";
import { mangleClassStruct } from "../mangle.js";
import { compactRefLoadLines, type LlInlineTail } from "./classes.js";
import { f64Lit } from "./common.js";
import type { LlvmTargetOptions } from "./emitter.js";
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
import { arrOps } from "./shapes.js";
/** Whether `mod` emitted with `options` can keep inline array fields inline:
* an executable for a 64-bit target whose compact instances live in the low
* region (32-bit references), without lifetime regions (which keep their
* own class layouts), and with fields to keep. */
export function inlineArraysApply(mod: IrModule, options: LlvmTargetOptions): boolean {
return (
(mod.contracts?.inlineArrays?.length ?? 0) > 0 &&
options.pointerBits !== 32 &&
options.lowRegion === true &&
mod.lib === undefined &&
mod.lifetimeRegions !== true
);
}
/** Whether instances of `className` are ProgramLifetime instances: the
* root of its hierarchy is declared `implements ProgramLifetime`. */
export function programLifetimeClassOf(mod: IrModule): (className: string) => boolean {
const roots = new Set(mod.contracts?.programLifetime ?? []);
const bases = new Map<string, string | undefined>();
for (const cls of mod.classes ?? []) bases.set(cls.name, cls.base);
return (className) => {
let name: string | undefined = className;
let root = className;
while (name !== undefined && bases.has(name)) {
root = name;
name = bases.get(name);
}
return bases.has(className) && roots.has(root);
};
}
/** The field of `className` whose elements its instances keep inline, when
* `field` is it. */
export function inlineFieldTail(
host: LlvmEmitterContext,
className: string,
field: string,
): LlInlineTail | undefined {
const tail = host.inlineTailOf(className);
if (tail === undefined) return undefined;
return host.classMeta.get(className)!.def.fields[tail.field]!.name === field ? tail : undefined;
}
/** Loads the element count of the inline array field of `obj` (an instance
* of `className`) as an i32; returns the value's name. */
export function emitInlineCount(
host: LlvmEmitterContext,
obj: string,
className: string,
tail: LlInlineTail,
): string {
const B = host.B;
const meta = host.classMeta.get(className)!;
const countp = B.tmp();
const count = B.tmp();
B.line(
`${countp} = getelementptr inbounds %${mangleClassStruct(className)}, ptr ${obj}, i64 0, i32 ${meta.compactSlots[tail.field]!}`,
);
B.line(`${count} = load i32, ptr ${countp}`);
return count;
}
/** The address of the first element of the inline array field of `obj`. */
export function emitInlineElements(
host: LlvmEmitterContext,
obj: string,
tail: LlInlineTail,
): string {
const B = host.B;
const at = B.tmp();
B.line(`${at} = getelementptr inbounds i8, ptr ${obj}, i64 ${tail.tailOffset}`);
return at;
}
/** The constructor's write of the inline array field of `obj`: copies the
* elements of `value` (an array) into the instance, which `new` allocated
* for that array's length. */
export function emitInlineArrayInit(
host: LlvmEmitterContext,
obj: string,
className: string,
tail: LlInlineTail,
value: string,
): void {
const count = emitInlineCount(host, obj, className, tail);
const at = emitInlineElements(host, obj, tail);
host.declare(`declare void @scr_arr_copy_cref_tail(ptr, ptr, i32)`);
host.B.line(`call void @scr_arr_copy_cref_tail(ptr ${value}, ptr ${at}, i32 ${count})`);
}
/** The inline array field of `obj` as an array value: a frozen array over
* the elements (scr_arr_view_cref). Returns the owned (+1) array's name. */
export function emitInlineArrayView(
host: LlvmEmitterContext,
obj: string,
className: string,
tail: LlInlineTail,
): string {
const count = emitInlineCount(host, obj, className, tail);
const at = emitInlineElements(host, obj, tail);
const ops = arrOps(host.shapeHost, { kind: "object", className: tail.elemClass });
host.declare(`declare ptr @scr_arr_view_cref(ptr, ptr, i32)`);
const view = host.B.tmp();
host.B.line(`${view} = call ptr @scr_arr_view_cref(ptr ${ops}, ptr ${at}, i32 ${count})`);
return view;
}
/** The index as an i64 slot, and whether the number is an integer below
* `count` (an i32). */
function emitInlineIndex(
host: LlvmEmitterContext,
count: string,
index: LlValue,
): { slot: string; within: string } {
const B = host.B;
const bound = B.tmp();
B.line(`${bound} = zext i32 ${count} to i64`);
const integer = index.integer;
if (integer !== undefined) {
let slot = integer.name;
if (integer.type === "i32") {
slot = B.tmp();
B.line(`${slot} = ${integer.signed ? "sext" : "zext"} i32 ${integer.name} to i64`);
}
const within = B.tmp();
B.line(`${within} = icmp ult i64 ${slot}, ${bound}`);
return { slot, within };
}
host.declare(`declare i64 @llvm.fptosi.sat.i64.f64(double)`);
const truncated = B.tmp();
const back = B.tmp();
const exact = B.tmp();
const below = B.tmp();
const within = B.tmp();
B.line(`${truncated} = call i64 @llvm.fptosi.sat.i64.f64(double ${index.name})`);
B.line(`${back} = sitofp i64 ${truncated} to double`);
B.line(`${exact} = fcmp oeq double ${back}, ${index.name}`);
B.line(`${below} = icmp ult i64 ${truncated}, ${bound}`);
B.line(`${within} = and i1 ${exact}, ${below}`);
return { slot: truncated, within };
}
/** `index in obj.field` (`has`, an i1) or the slot state (`state`, a double:
* HOLE=0, VALUE=1) for an inline array field. Its elements are instances,
* never holes or undefined, so an index is present exactly when it is an
* integer within the count. */
export function emitInlineArrayState(
host: LlvmEmitterContext,
obj: string,
className: string,
tail: LlInlineTail,
index: LlValue,
kind: "has" | "state",
): string {
const count = emitInlineCount(host, obj, className, tail);
const { within } = emitInlineIndex(host, count, index);
if (kind === "has") return within;
const state = host.B.tmp();
host.B.line(`${state} = select i1 ${within}, double ${f64Lit(1)}, double ${f64Lit(0)}`);
return state;
}
/** `obj.field[index]` for an inline array field: the element at an index
* within the count, loaded from the instance. Any other index takes the
* runtime getter over a view, which reports it like a read of an ordinary
* array. With `retain`, the result is an owned reference when the element
* type is reference counted (like emitDenseArrayRead); without, borrowed
* from the instance. */
export function emitInlineArrayGet(
host: LlvmEmitterContext,
obj: string,
className: string,
tail: LlInlineTail,
index: LlValue,
element: IrType,
retain: boolean,
): string {
const B = host.B;
const count = emitInlineCount(host, obj, className, tail);
const at = emitInlineElements(host, obj, tail);
const { slot, within } = emitInlineIndex(host, count, index);
const hit = B.newLabel("inl.get.hit");
const miss = B.newLabel("inl.get.miss");
const join = B.newLabel("inl.get.join");
B.condBr(within, hit, miss);
B.startBlock(hit);
const p = B.tmp();
const ref = B.tmp();
const raw = B.tmp();
B.line(`${p} = getelementptr inbounds i32, ptr ${at}, i64 ${slot}`);
B.line(`${ref} = load i32, ptr ${p}`);
for (const line of compactRefLoadLines(ref, raw, `${raw}.r`)) B.line(line.trimStart());
const value = retain && isRefCounted(element) ? host.retainFrame(raw, element) : raw;
const hitEnd = B.currentLabel();
B.br(join);
B.startBlock(miss);
// Outside the count: the getter of an ordinary array over the elements
// decides, as for any array (an out-of-bounds read traps).
const ops = arrOps(host.shapeHost, { kind: "object", className: tail.elemClass });
const getter = retain ? "scr_arr_get_ref" : "scr_arr_borrow_ref";
host.declare(`declare ptr @scr_arr_view_cref(ptr, ptr, i32)`);
host.declare(`declare ptr @${getter}(ptr, double)`);
host.declare(`declare void @scr_arr_release(ptr)`);
const view = B.tmp();
const slow = B.tmp();
B.line(`${view} = call ptr @scr_arr_view_cref(ptr ${ops}, ptr ${at}, i32 ${count})`);
B.line(`${slow} = call ptr @${getter}(ptr ${view}, double ${indexDouble(host, index)}) cold`);
B.line(`call void @scr_arr_release(ptr ${view})`);
const missEnd = B.currentLabel();
B.br(join);
B.startBlock(join);
const result = B.tmp();
B.line(`${result} = phi ptr [ ${value}, %${hitEnd} ], [ ${slow}, %${missEnd} ]`);
return result;
}
/** The index as a double (an integer snapshot's own double is its name). */
function indexDouble(host: LlvmEmitterContext, index: LlValue): string {
if (index.type.kind === "f64") return index.name;
const d = host.B.tmp();
host.B.line(`${d} = sitofp i32 ${index.name} to double`);
return d;
}
/** `obj.field.length` for an inline array field. */
export function emitInlineArrayLength(
host: LlvmEmitterContext,
obj: string,
className: string,
tail: LlInlineTail,
type: IrType,
): LlValue {
const count = emitInlineCount(host, obj, className, tail);
const length = host.B.tmp();
host.B.line(`${length} = uitofp i32 ${count} to double`);
return { name: length, type, uint32: count };
}
@@ -341,3 +341,164 @@ test("borrowed number arrays: length and element reads qualify", () => {
const withOther: IrModule = { ...mod, functions: [sum, other] };
expect(findIntegerClones(withOther, () => true).members.has("other")).toBe(false);
});
// A tree record whose children are `Tree | null` (tag 0 null, tag 1 the
// record), and count(t) = t.left === null || t.right === null ? 1 :
// 1 + count(t.left!) + count(t.right!), the binary-trees check.
const TREE: IrType = { kind: "record", shapeId: "rTree" };
const CHILD: IrType = { kind: "union", unionId: "uChild" };
const field = (obj: IrExpr, name: string, type: IrType): IrExpr => ({
kind: "recordGet",
obj,
shapeId: "rTree",
field: name,
type,
loc,
});
const isNull = (value: IrExpr): IrExpr => ({
kind: "unionIsTag",
unionId: "uChild",
tag: 0,
negated: false,
value,
type: BOOL,
loc,
});
const child = (name: string): IrExpr => ({
kind: "unionNarrow",
unionId: "uChild",
tag: 1,
value: field(ref("t", TREE), name, CHILD),
type: TREE,
loc,
});
const count = fn(
"count",
["t"],
[
ret(
ternary(
{
kind: "logical",
op: "||",
left: isNull(field(ref("t", TREE), "left", CHILD)),
right: isNull(field(ref("t", TREE), "right", CHILD)),
type: BOOL,
loc,
},
num(1),
bin("+", bin("+", num(1), call("count", [child("left")])), call("count", [child("right")])),
),
),
],
F64,
[TREE],
);
// leftmost(t) = t.left === null ? 0 : leftmost(t.left!), a plain tail call.
const leftmost = fn(
"leftmost",
["t"],
[
ret(
ternary(
isNull(field(ref("t", TREE), "left", CHILD)),
num(0),
call("leftmost", [child("left")]),
),
),
],
F64,
[TREE],
);
function treeModule(workers: boolean): IrModule {
return {
irVersion: 17,
sourceFile: loc.file,
entry: "main",
records: [
{
id: "rTree",
fields: [
{ name: "left", type: CHILD },
{ name: "right", type: CHILD },
{ name: "size", type: F64 },
],
},
],
unions: [{ id: "uChild", arms: [{ kind: "nullT" }, TREE] }],
...(workers ? { workers: true } : {}),
functions: [
{
...fn(
"main",
["t"],
[stmt(call("count", [ref("t", TREE)])), stmt(call("leftmost", [ref("t", TREE)]))],
VOID,
[TREE],
),
params: [],
},
count,
leftmost,
],
};
}
test("borrowed records: field reads, null tests and narrows qualify", () => {
const mod = treeModule(false);
const refs = {
reference: (t: IrType) =>
(t.kind === "record" && t.shapeId === "rTree") ||
(t.kind === "union" && t.unionId === "uChild"),
arm: (unionId: string, tag: number) =>
unionId === "uChild" ? (tag === 0 ? ("null" as const) : ("record" as const)) : null,
field: (shapeId: string, name: string) =>
shapeId === "rTree" && name !== "size"
? { struct: "sc_rs_rTree", index: name === "left" ? 1 : 2, type: CHILD }
: null,
};
// Records need the layout oracle and a borrowed parameter.
expect(findIntegerClones(mod, () => true).members.has("count")).toBe(false);
expect(findIntegerClones(mod, () => false, refs).members.has("count")).toBe(false);
expect(findIntegerClones(mod, () => true, refs).dispatched.has("count")).toBe(true);
// A field the oracle does not lay out in place disqualifies the reader.
const sized = fn(
"sized",
["t"],
[ret(bin("+", field(ref("t", TREE), "size", F64), call("sized", [child("left")])))],
F64,
[TREE],
);
const withSized: IrModule = { ...mod, functions: [...mod.functions, sized] };
expect(findIntegerClones(withSized, () => true, refs).members.has("sized")).toBe(false);
});
test("record clones read fields in place; worker executables poll and use the thread's guard", () => {
for (const workers of [false, true]) {
const mod = treeModule(workers);
expect(validateModule(mod)).toEqual([]);
const llvm = emitLlvmModule(mod, { targetTriple: "x86_64-unknown-linux-gnu" });
const clone = define(llvm, "sc_f_count.int");
expect(clone).toContain("define internal { i32, i1 } @sc_f_count.int(ptr %a0)");
expect(clone).toMatch(/getelementptr inbounds %sc_rs_rTree, ptr %t\d+, i64 0, i32 1/);
// A NULL child bails before the call (the ordinary body reports it).
expect(clone).toMatch(/icmp ne ptr %t\d+, null/);
// count(t.right) is the accumulating loop's next iteration.
expect(clone.match(/= call \{ i32, i1 \} @sc_f_count\.int\(/g)!.length).toBeGreaterThan(0);
expect(clone).toMatch(/br label %tail\d+/);
expect(clone).not.toContain("retain");
const plain = define(llvm, "sc_f_leftmost.int");
if (workers) {
// One poll per call; the accumulating loop polls nowhere else, a loop
// over plain tail calls every POLL_INTERVAL iterations.
expect(clone.match(/load atomic i32, ptr @scr_exc_flag monotonic/g)).toHaveLength(1);
expect(clone).not.toContain("poll countdown");
expect(plain).toContain("poll countdown");
expect(plain.match(/load atomic i32, ptr @scr_exc_flag monotonic/g)).toHaveLength(2);
expect(llvm).toMatch(/@scr_stack_guard = external thread_local/);
} else {
expect(clone).not.toContain("@scr_exc_flag");
expect(plain).not.toContain("poll countdown");
}
}
});
@@ -83,24 +83,52 @@ const BAIL = `${CLONE_RESULT} { i32 poison, i1 true }`;
/** The stack one replaced recursion level is charged, in bytes. Ordinary
* frames of these functions are about that size. */
const VIRTUAL_FRAME = 32;
/** Iterations between the polls of a loop over plain tail calls in worker
* executables (emitLoopPoll). */
const POLL_INTERVAL = 64;
/** The single-threaded stack guard, declared as the emitter's ordinary
* stack checks declare it (clones only exist without workers). */
export const STACK_GUARD_DECL = "@scr_stack_guard = external global ptr";
/** The stack guard and the termination word, declared as the emitter's
* ordinary stack checks and polls declare them: process-wide in programs
* without workers, per thread in ELF worker executables (the only worker
* programs with clones). */
export function stackGuardDecl(workers: boolean): string {
return `@scr_stack_guard = external ${workers ? "thread_local " : ""}global ptr`;
}
const EXC_FLAG_DECL = "@scr_exc_flag = external thread_local(localexec) global i32, align 4";
export const INTEGER_CLONE_SUFFIX = ".int";
export const INTEGER_DISPATCH_SUFFIX = ".spec";
type Scalar = "num" | "bool";
/** A value kind: an int32 number, a boolean, or a borrowed number array. */
type Kind = Scalar | "arr";
/** A value kind: an int32 number, a boolean, a borrowed number array, or a
* reference into a borrowed record graph. */
type Kind = Scalar | "arr" | "ref";
/** The records a clone may read (from the emitter's layouts). A reference
* is a plain pointer: a record, or a union of one record arm and `null`
* (NULL). A clone only reads its records' fields, and it has no effect, so
* every record reachable from a borrowed parameter stays alive and
* unchanged while it runs: it reads them without retaining them. */
export interface CloneReferences {
/** Whether values of `type` are such pointers. */
reference(type: IrType): boolean;
/** For a tag of a reference union: whether it names the record arm
* ("record") or the `null` arm ("null"). */
arm(unionId: string, tag: number): "record" | "null" | null;
/** Where record shape `shapeId` keeps `field` (its struct and element
* index), or null when the field is not a slot a clone reads in place:
* a number (a double), a boolean (an i8) or a reference. */
field(shapeId: string, field: string): { struct: string; index: number; type: IrType } | null;
}
function scalarKind(type: IrType): Scalar | null {
return type.kind === "f64" ? "num" : type.kind === "bool" ? "bool" : null;
}
function valueKind(type: IrType): Kind | null {
return type.kind === "array" && type.elem.kind === "f64" ? "arr" : scalarKind(type);
function valueKind(type: IrType, refs: CloneReferences | undefined): Kind | null {
if (type.kind === "array" && type.elem.kind === "f64") return "arr";
if (refs?.reference(type) === true) return "ref";
return scalarKind(type);
}
/** The LLVM type of a value of `kind` inside a clone. */
@@ -119,20 +147,26 @@ function isInt32Literal(value: number): boolean {
}
/** The call targets of `fn` when its body has the qualifying shape. */
function cloneShape(fn: IrFunction, borrowed?: BorrowedParameter): Set<string> | null {
function cloneShape(
fn: IrFunction,
borrowed?: BorrowedParameter,
refs?: CloneReferences,
): Set<string> | null {
if (fn.async || fn.generator !== undefined || fn.captures !== undefined) return null;
if (fn.classCaptures !== undefined && fn.classCaptures.length > 0) return null;
const result = scalarKind(fn.returnType);
if (result === null) return null;
const locals = new Map<string, IrLocal>(fn.locals.map((l) => [l.id, l]));
// Array parameters are only read: never assigned, and their locals are
// the parameters themselves.
// Array and reference parameters are only read: never assigned, and their
// locals are the parameters themselves.
const arrays = new Set<string>();
for (const [index, p] of fn.params.entries())
if (valueKind(p.type) === "arr") {
if (borrowed?.(fn.name, index) !== true) return null;
arrays.add(p.localId);
}
const references = new Set<string>();
for (const [index, p] of fn.params.entries()) {
const kind = valueKind(p.type, refs);
if (kind !== "arr" && kind !== "ref") continue;
if (borrowed?.(fn.name, index) !== true) return null;
(kind === "arr" ? arrays : references).add(p.localId);
}
const localKind = (id: string): Scalar | null => {
const local = locals.get(id);
if (!local || local.boxed || local.tdz) return null;
@@ -140,12 +174,38 @@ function cloneShape(fn: IrFunction, borrowed?: BorrowedParameter): Set<string> |
};
const arrayRef = (e: IrExpr): boolean => e.kind === "varRef" && arrays.has(e.localId);
for (const p of fn.params)
if (!arrays.has(p.localId) && (localKind(p.localId) === null || scalarKind(p.type) === null))
if (
!arrays.has(p.localId) &&
!references.has(p.localId) &&
(localKind(p.localId) === null || scalarKind(p.type) === null)
)
return null;
/** A field the clone reads in place, of a record a reference reads. */
const readsField = (e: Extract<IrExpr, { kind: "recordGet" }>): boolean =>
reference(e.obj) && refs?.field(e.shapeId, e.field) != null;
/** A reference parameter, a reference field of a record one reaches, or
* a narrow of a reference union to its record arm. */
const reference = (e: IrExpr): boolean => {
if (refs?.reference(e.type) !== true) return false;
switch (e.kind) {
case "varRef":
return references.has(e.localId);
case "recordGet":
return readsField(e);
case "unionNarrow":
return refs.arm(e.unionId, e.tag) === "record" && reference(e.value);
default:
return false;
}
};
const callees = new Set<string>();
const expr = (e: IrExpr, want: Scalar): boolean => {
if (scalarKind(e.type) !== want) return false;
switch (e.kind) {
case "recordGet":
return readsField(e);
case "unionIsTag":
return want === "bool" && refs?.arm(e.unionId, e.tag) != null && reference(e.value);
case "numLit":
return isInt32Literal(e.value);
case "boolLit":
@@ -202,8 +262,10 @@ function cloneShape(fn: IrFunction, borrowed?: BorrowedParameter): Set<string> |
case "call":
callees.add(e.callee);
return e.args.every((a) => {
const kind = valueKind(a.type);
return kind === "arr" ? arrayRef(a) : kind !== null && expr(a, kind);
const kind = valueKind(a.type, refs);
if (kind === "arr") return arrayRef(a);
if (kind === "ref") return reference(a);
return kind !== null && expr(a, kind);
});
default:
return false;
@@ -238,7 +300,11 @@ function cloneShape(fn: IrFunction, borrowed?: BorrowedParameter): Set<string> |
}
/** Every call in `fn`'s body has arguments of its callee's parameter kinds. */
function callsMatch(fn: IrFunction, byName: ReadonlyMap<string, IrFunction>): boolean {
function callsMatch(
fn: IrFunction,
byName: ReadonlyMap<string, IrFunction>,
refs: CloneReferences | undefined,
): boolean {
let ok = true;
const visit = (e: IrExpr): void => {
switch (e.kind) {
@@ -248,7 +314,7 @@ function callsMatch(fn: IrFunction, byName: ReadonlyMap<string, IrFunction>): bo
!callee ||
callee.params.length !== e.args.length ||
scalarKind(callee.returnType) !== scalarKind(e.type) ||
callee.params.some((p, i) => valueKind(p.type) !== valueKind(e.args[i]!.type))
callee.params.some((p, i) => valueKind(p.type, refs) !== valueKind(e.args[i]!.type, refs))
)
ok = false;
e.args.forEach(visit);
@@ -295,12 +361,16 @@ function callsMatch(fn: IrFunction, byName: ReadonlyMap<string, IrFunction>): bo
return ok;
}
export function findIntegerClones(mod: IrModule, borrowed?: BorrowedParameter): IntegerClones {
export function findIntegerClones(
mod: IrModule,
borrowed?: BorrowedParameter,
refs?: CloneReferences,
): IntegerClones {
const byName = new Map(mod.functions.map((fn) => [fn.name, fn]));
const edges = new Map<string, Set<string>>();
for (const fn of mod.functions) {
const callees = cloneShape(fn, borrowed);
if (callees && callsMatch(fn, byName)) edges.set(fn.name, callees);
const callees = cloneShape(fn, borrowed, refs);
if (callees && callsMatch(fn, byName, refs)) edges.set(fn.name, callees);
}
// Drop functions that call a non-member until none does.
for (let changed = true; changed;) {
@@ -414,6 +484,11 @@ export interface IntegerCloneHost {
declare(decl: string): void;
/** Lines setting the i1 `dest` to "an exception is pending". */
pendingTestLines(dest: string): string[];
/** The records clones read (the same oracle findIntegerClones used). */
readonly cloneReferences: CloneReferences | undefined;
/** An ELF worker executable: the stack guard is per thread, and clones
* poll the thread's pending word, which a stop request also sets. */
readonly executableTls: boolean;
}
interface NumValue {
@@ -590,7 +665,9 @@ class CloneEmitter {
};
const params = new Set(fn.params.map((p) => p.localId));
for (const local of fn.locals) {
const kind = params.has(local.id) ? valueKind(local.type) : scalarKind(local.type);
const kind = params.has(local.id)
? valueKind(local.type, this.host.cloneReferences)
: scalarKind(local.type);
if (kind === null || local.boxed) continue;
const slot = `%l${this.slotCount++}`;
B.entryAllocas.push(`${slot} = alloca ${kindType(kind)} ; ${local.name}`);
@@ -602,6 +679,7 @@ class CloneEmitter {
});
this.frame = frame;
if (inline === null && this.stackCheck) this.emitStackCheck();
if (inline === null) this.emitPoll();
const tail = this.tailShape();
if (!tail.loop) {
this.assumeParamRanges();
@@ -613,12 +691,57 @@ class CloneEmitter {
B.entryAllocas.push(`${frame.accSlot} = alloca i32 ; accumulator`);
B.line(`store i32 0, ptr ${frame.accSlot}`);
}
const budget = tail.plain && this.host.executableTls ? `%l${this.slotCount++}` : null;
if (budget !== null) {
B.entryAllocas.push(`${budget} = alloca i32 ; poll countdown`);
B.line(`store i32 0, ptr ${budget}`);
}
frame.loopLabel = B.newLabel("tail");
B.br(frame.loopLabel);
B.startBlock(frame.loopLabel);
if (budget !== null) this.emitLoopPoll(budget);
this.assumeParamRanges();
}
/** In an ELF worker executable, bails when the thread's pending word is
* set: an exception is pending, or the worker must stop. The dispatcher
* then forwards it. Ordinary bodies observe a stop through their pending
* tests after calls and in loops, which clones do not run, so a clone
* polls when it is called and every POLL_INTERVAL iterations of a loop
* over plain tail calls, which can run forever. An accumulating loop runs
* at most until its virtual stack pointer passes the guard. The load is
* ordered, so LLVM keeps it in loops it forms out of tail calls. */
private emitPoll(): void {
if (!this.host.executableTls) return;
const { B } = this;
this.host.declare(EXC_FLAG_DECL);
const word = B.tmp();
const clear = B.tmp();
B.line(`${word} = load atomic i32, ptr @scr_exc_flag monotonic, align 4`);
B.line(`${clear} = icmp eq i32 ${word}, 0`);
this.require(clear);
}
/** The poll of a loop iteration, every POLL_INTERVAL iterations. */
private emitLoopPoll(budget: string): void {
const { B } = this;
const left = B.tmp();
const rest = B.tmp();
const due = B.tmp();
B.line(`${left} = load i32, ptr ${budget}`);
B.line(`${rest} = sub i32 ${left}, 1`);
B.line(`store i32 ${rest}, ptr ${budget}`);
B.line(`${due} = icmp eq i32 ${left}, 0`);
const poll = B.newLabel("poll");
const next = B.newLabel("poll.next");
B.condBr(due, poll, next);
B.startBlock(poll);
B.line(`store i32 ${POLL_INTERVAL - 1}, ptr ${budget}`);
this.emitPoll();
B.br(next);
B.startBlock(next);
}
/** Starts the virtual stack pointer of an activation with an accumulating
* loop. */
private openVirtualStack(frame: Frame, parent: Frame | null): void {
@@ -673,7 +796,9 @@ class CloneEmitter {
emit(): string {
const { B, fn } = this;
const params = fn.params.map((p, i) => `${kindType(valueKind(p.type)!)} %a${i}`);
const params = fn.params.map(
(p, i) => `${kindType(valueKind(p.type, this.host.cloneReferences)!)} %a${i}`,
);
this.openFrame(
fn.params.map((_, i) => `%a${i}`),
null,
@@ -720,16 +845,17 @@ class CloneEmitter {
return this.inlineSelf && this.frame.inline === null && this.isSelfCall(e);
}
/** Whether the body has self tail calls, and whether one accumulates. */
private tailShape(): { loop: boolean; accumulates: boolean } {
let loop = false;
/** Whether the body has self tail calls, whether one accumulates, and
* whether one is a plain tail call. */
private tailShape(): { loop: boolean; accumulates: boolean; plain: boolean } {
let plain = false;
let accumulates = false;
const ret = (e: IrExpr): void => {
if (e.kind === "ternary") {
ret(e.then);
ret(e.else_);
} else if (this.isSelfCall(e)) loop = true;
else if (this.accumulatingCall(e) !== null) loop = accumulates = true;
} else if (this.isSelfCall(e)) plain = true;
else if (this.accumulatingCall(e) !== null) accumulates = true;
};
const stmts = (list: readonly IrStmt[]): void => {
for (const s of list) {
@@ -741,7 +867,7 @@ class CloneEmitter {
}
};
stmts(this.fn.body);
return { loop, accumulates };
return { loop: plain || accumulates, accumulates, plain };
}
private isSelfCall(e: IrExpr): e is Extract<IrExpr, { kind: "call" }> {
@@ -779,7 +905,7 @@ class CloneEmitter {
host.declare(`declare ptr @llvm.stacksave.p0()`);
host.declare(`declare i1 @llvm.expect.i1(i1, i1)`);
host.declare(`declare void @scr_stack_overflow()`);
host.declare(STACK_GUARD_DECL);
host.declare(stackGuardDecl(host.executableTls));
const sp = B.tmp();
const guard = B.tmp();
const low = B.tmp();
@@ -809,7 +935,7 @@ class CloneEmitter {
* continuing at the overflow block past the guard. */
private consumeFrame(): void {
const { B } = this;
this.host.declare(STACK_GUARD_DECL);
this.host.declare(stackGuardDecl(this.host.executableTls));
const depth = B.tmp();
const next = B.tmp();
const guardPtr = B.tmp();
@@ -991,7 +1117,9 @@ class CloneEmitter {
exprs: readonly IrExpr[],
marker = "",
): string {
const list = args.map((a, i) => `${kindType(valueKind(exprs[i]!.type)!)} ${a}`);
const list = args.map(
(a, i) => `${kindType(valueKind(exprs[i]!.type, this.host.cloneReferences)!)} ${a}`,
);
const result = this.B.tmp();
this.B.line(
`${result} = ${marker}call ${CLONE_RESULT} @${mangleFunction(callee)}${INTEGER_CLONE_SUFFIX}(${list.join(", ")})`,
@@ -1007,6 +1135,43 @@ class CloneEmitter {
return value;
}
/** The record pointer of a reference expression (cloneShape's
* `reference`): a parameter, a field read, or a narrow to the record arm,
* which bails on NULL so the ordinary body reports it. */
private emitRef(e: IrExpr): string {
const { B } = this;
switch (e.kind) {
case "varRef": {
const value = B.tmp();
B.line(`${value} = load ptr, ptr ${this.frame.slots.get(e.localId)!.slot}`);
return value;
}
case "recordGet": {
const value = B.tmp();
B.line(`${value} = load ptr, ptr ${this.emitFieldPtr(e)}`);
return value;
}
case "unionNarrow": {
const value = this.emitRef(e.value);
const present = B.tmp();
B.line(`${present} = icmp ne ptr ${value}, null`);
this.require(present);
return value;
}
default:
throw new Error(`integer clone: unexpected reference expression ${e.kind}`);
}
}
/** The address of the field a record read names. */
private emitFieldPtr(e: Extract<IrExpr, { kind: "recordGet" }>): string {
const obj = this.emitRef(e.obj);
const { struct, index } = this.host.cloneReferences!.field(e.shapeId, e.field)!;
const ptr = this.B.tmp();
this.B.line(`${ptr} = getelementptr inbounds %${struct}, ptr ${obj}, i64 0, i32 ${index}`);
return ptr;
}
/** `a.length`, bailing beyond the int32 range. */
private emitLength(array: string): string {
const { B } = this;
@@ -1108,12 +1273,14 @@ class CloneEmitter {
private emitArgs(call: Extract<IrExpr, { kind: "call" }>): string[] {
return call.args.map((a) => {
const kind = valueKind(a.type);
const kind = valueKind(a.type, this.host.cloneReferences);
return kind === "arr"
? this.emitArray(a)
: kind === "num"
? this.narrow(this.emitNum(a))
: this.emitBool(a);
: kind === "ref"
? this.emitRef(a)
: kind === "num"
? this.narrow(this.emitNum(a))
: this.emitBool(a);
});
}
@@ -1202,6 +1369,12 @@ class CloneEmitter {
return { name: this.emitLength(this.emitArray(e.receiver)), wide: false };
case "arrayGet":
return { name: this.emitElement(e.arr, e.index), wide: false };
case "recordGet": {
// A number field that is exactly an int32; any other number bails.
const number = B.tmp();
B.line(`${number} = load double, ptr ${this.emitFieldPtr(e)}`);
return { name: this.exactInt32(number), wide: false };
}
case "unary": {
const operand = this.narrow(this.emitNum(e.operand));
const value = B.tmp();
@@ -1351,6 +1524,21 @@ class CloneEmitter {
B.line(`${value} = xor i1 ${operand}, true`);
return value;
}
case "recordGet": {
const byte = B.tmp();
const value = B.tmp();
B.line(`${byte} = load i8, ptr ${this.emitFieldPtr(e)}`);
B.line(`${value} = trunc i8 ${byte} to i1`);
return value;
}
case "unionIsTag": {
// NULL is the `null` arm; any other pointer the record arm.
const pointer = this.emitRef(e.value);
const isNull = this.host.cloneReferences!.arm(e.unionId, e.tag) === "null";
const value = B.tmp();
B.line(`${value} = icmp ${isNull !== e.negated ? "eq" : "ne"} ptr ${pointer}, null`);
return value;
}
case "toBool": {
if (scalarKind(e.operand.type) === "bool") return this.emitBool(e.operand);
const operand = this.narrow(this.emitNum(e.operand));
@@ -1441,9 +1629,10 @@ export function emitIntegerDispatcher(
const cloneArgs: string[] = [];
const exact: string[] = [];
fn.params.forEach((p, i) => {
const kind = valueKind(p.type);
if (kind === "bool" || kind === "arr") {
// Borrowed arrays pass through to the clone and the ordinary entry.
const kind = valueKind(p.type, host.cloneReferences);
if (kind === "bool" || kind === "arr" || kind === "ref") {
// Borrowed arrays and records pass through to the clone and the
// ordinary entry.
const type = kindType(kind);
params.push(`${type} %p${i}`);
entryArgs.push(`${type} %p${i}`);
@@ -1274,7 +1274,7 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
case "webstream":
// A stream's start, pull and cancel steps run as promise jobs, so
// a program with the classes runs its event loop.
if (e.fn === "webstream.class") host.usesTimers = true;
if (e.fn === "webstream.class" || e.fn === "webstream.install") host.usesTimers = true;
return host.emitGenericLibCall(e);
case "test":
return emitTestLibCall(host, e);
@@ -120,6 +120,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"util.throttle": "scr_util_throttle",
"webstream.class": "scr_webstream_class",
"webstream.instanceOf": "scr_webstream_instanceof",
"webstream.install": "scr_webstream_install",
"mime.typeNew": "scr_mime_type_new",
"mime.typeParse": "scr_mime_type_parse",
"mime.paramsNew": "scr_mime_params_new",
@@ -305,6 +306,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"process.builtinModule": "scr_process_builtin_module",
"process.builtinUnsupported": "scr_process_builtin_unsupported",
"module.namespaceValue": "scr_module_namespace_value",
"module.namespaceDyn": "scr_module_namespace_dyn_value",
"fs.callbackValue": "scr_fs_callback_value",
"fs.callbackCall": "scr_fs_callback_call",
"process.hrtimeValue": "scr_process_hrtime_value",
@@ -556,6 +558,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"fs.readFileSyncBuf": "scr_fs_read_file_bytes",
"fs.readFileSyncBytes": "scr_fs_read_file_bytes",
"fs.writeFileSyncBytes": "scr_fs_write_file_bytes",
"fs.writeFileSyncFd": "scr_fs_write_file_fd",
"fs.appendFileSyncBytes": "scr_fs_append_file_bytes",
"fs.readFdSyncBytes": "scr_fs_read_fd_bytes",
// Stats snapshots (scr_lib.c): statSync/lstatSync throw like the other
@@ -1046,6 +1049,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"nodeIsland.callSync": "scr_node_island_call_sync",
"nodeIsland.call": "scr_node_island_call",
"nodeIsland.require": "scr_ni_require",
"nodeIsland.defer": "scr_node_island_defer",
"nodeIsland.import": "scr_ni_import",
"nodeIsland.get": "scr_node_island_get",
"nodeIsland.getKey": "scr_ni_get",
@@ -1056,6 +1060,12 @@ export const LIB_FN_SYMS: Record<string, string> = {
"nodeIsland.applyTo": "scr_node_island_apply_to",
"nodeIsland.construct": "scr_node_island_construct",
"nodeIsland.instanceof": "scr_node_island_instanceof",
"nodeIsland.classMethods": "scr_node_island_class_methods",
"nodeIsland.share": "scr_node_island_share",
"nodeIsland.sharedTdz": "scr_node_island_shared_tdz",
"nodeIsland.classMethodsOf": "scr_node_island_class_methods_of",
"nodeIsland.copyArrays": "scr_node_island_copy_arrays",
"nodeIsland.awaitCopy": "scr_node_island_await_copy",
"dyn.keySet": "scr_dyn_key_set",
"dyn.keySetComputed": "scr_dyn_key_set_computed",
"dyn.keyDelete": "scr_dyn_key_delete",
@@ -2034,7 +2044,9 @@ export const USES_TIMERS_LIB_FNS = new Set<string>([
"nodeIsland.call",
"nodeIsland.callSync",
"nodeIsland.require",
"nodeIsland.defer",
"nodeIsland.import",
"nodeIsland.awaitCopy",
"nodeIsland.get",
"nodeIsland.getKey",
"nodeIsland.set",
@@ -8,6 +8,7 @@ import {
type IrType,
type IrUnionDef,
} from "../../ir/ir.js";
import { emitInlineArrayGet, inlineFieldTail } from "./inline-arrays.js";
import { everyStmtList } from "../../ir/traverse.js";
import { analyzeCallLifetimes, type CallLifetimes } from "./call-lifetimes.js";
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
@@ -607,6 +608,25 @@ export function emitBorrowedArrayRead(
slot: string,
): void {
const B = host.B;
// An element of an inline array field, borrowed from the instance.
if (read.arr.kind === "fieldGet" && read.arr.type.kind === "array") {
const tail = inlineFieldTail(host, read.arr.className, read.arr.field);
if (tail !== undefined) {
const obj = host.emitStableReceiver(read.arr.obj, [read.index]);
const index = host.emitExpr(read.index);
const value = emitInlineArrayGet(
host,
obj.name,
read.arr.className,
tail,
index,
read.arr.type.elem,
false,
);
B.line(`store ptr ${value}, ptr ${slot}`);
return;
}
}
const array = host.emitStableReceiver(read.arr, [read.index]);
const loopIndex = host.emitIntegerLoopIndex(read.index);
if (loopIndex !== null && host.provenDenseIndex(read.arr, read.index)) {
@@ -130,9 +130,10 @@ export function findLocalUnionStorage(
const types = new Map(fn.locals.map((local) => [local.id, local.type]));
const result = new Map<string, LocalUnionStorageProof>();
const safe = lifetimes.projectedLocals.get(fn.name);
if (!safe?.size) return result;
const copied = lifetimes.copiedLocals.get(fn.name);
if (!safe?.size && !copied?.size) return result;
for (const local of fn.locals) {
if (local.type.kind !== "union" || !safe.has(local.id)) continue;
if (local.type.kind !== "union" || !(safe?.has(local.id) || copied?.has(local.id))) continue;
const arms = unions.get(local.type.unionId)?.arms;
if (!arms) continue;
const refs = arms.filter(isRefCounted);
@@ -149,6 +150,18 @@ export function findLocalUnionStorage(
return true;
},
});
// A copied local's box stays private only while every local it is copied
// to snapshots the copy's tag and bits: one that keeps the box pointer
// makes the source a heap box again, which can cascade along the copies.
for (let changed = copied !== undefined; changed;) {
changed = false;
for (const [id, targets] of copied!) {
if (result.has(id) && !targets.every((target) => result.has(target))) {
result.delete(id);
changed = true;
}
}
}
return result;
}
+33 -1
View File
@@ -80,6 +80,10 @@ export interface PublishClassInfo {
* `compressed` when the field holds a 32-bit reference (classes.ts
* LlClassMeta.compressed), which the walker decodes. */
fields: { name: string; index: number; type: IrType; compressed?: boolean }[];
/** An inline array field (classes.ts LlClassMeta.inlineTail), which
* `fields` leaves out: the GEP index of its i32 element count, the byte
* offset of its 32-bit references and their type. */
tail?: { name: string; countIndex: number; offset: number; elem: IrType };
}
/** scr_pub_mark's object kinds (SCR_PUB_KIND_* in scr_runtime.h), for the RC audit. */
@@ -406,6 +410,7 @@ export class PublishWalkers {
loc,
info.idInRc,
info.compact,
info.tail,
);
}
@@ -564,6 +569,7 @@ export class PublishWalkers {
loc: SrcLoc,
classId = false,
compact = false,
tail?: PublishClassInfo["tail"],
): string[] {
const lines = [
head,
@@ -592,7 +598,33 @@ export class PublishWalkers {
);
n++;
}
lines.push(` br label %done`, `done:`, ` ret void`, `}`);
const tailFn =
tail === undefined ? null : this.walker(tail.elem, `${path}.${tail.name}[]`, loc);
if (tail !== undefined && tailFn !== null) {
// Each element of an inline array field, as a 32-bit reference.
lines.push(
` br label %tail.pre`,
`tail.pre:`,
` %tail.cp = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${tail.countIndex} ; ${llvmComment(tail.name)}`,
` %tail.n = load i32, ptr %tail.cp`,
` %tail.at = getelementptr inbounds i8, ptr %o, i64 ${tail.offset}`,
` br label %tail.head`,
`tail.head:`,
` %tail.i = phi i32 [ 0, %tail.pre ], [ %tail.next, %tail.latch ]`,
` %tail.end = icmp uge i32 %tail.i, %tail.n`,
` br i1 %tail.end, label %done, label %tail.body`,
`tail.body:`,
` %tail.p = getelementptr inbounds i32, ptr %tail.at, i32 %tail.i`,
` %tail.r = load i32, ptr %tail.p`,
...compactRefLoadLines(`%tail.r`, `%tail.v`, `%tail.d`),
...this.push(`%tail.v`, tailFn, `tail`, this.compactRef(tail.elem)),
` br label %tail.latch`,
`tail.latch:`,
` %tail.next = add nuw i32 %tail.i, 1`,
` br label %tail.head`,
);
} else lines.push(` br label %done`);
lines.push(`done:`, ` ret void`, `}`);
return lines;
}
}
@@ -1,4 +1,5 @@
/* LlEmitter's receivers: receiver reads, and the analyses that decide when calls, receivers and returns borrow. */
import { inlineFieldTail } from "./inline-arrays.js";
import type { LlEmitter } from "./emitter.js";
import {
type IrExpr,
@@ -133,6 +134,9 @@ export function emitReadReceiver(emitter: LlEmitter, e: IrExpr): LlValue {
// A nullable-pointer field has no box to borrow. Projections use
// emitUnionProjection; other borrowers get an owned heap box.
if (emitter.nullableFieldGet(e)) return emitter.emitExpr(e);
// An inline array field has no array to borrow: its read makes a view.
if (e.kind === "fieldGet" && inlineFieldTail(emitter, e.className, e.field) !== undefined)
return emitter.emitExpr(e);
if (e.kind === "recordGet" || e.kind === "fieldGet") {
const receiver = emitter.emitReadReceiver(e.obj);
if (e.kind === "recordGet") {
@@ -224,8 +228,9 @@ export function canBorrowCallArgument(emitter: LlEmitter, value: IrExpr): boolea
// An exact capsule owns its instance while the capsule's binding holds it.
if (value.kind === "dynCheck" && value.exact)
return value.value.kind === "varRef" && emitter.canBorrowCallArgument(value.value);
// An inline union field has no box to lend.
if (emitter.inlineFieldGet(value)) return false;
// An inline union field has no box to lend, an inline array field no
// array.
if (emitter.inlineFieldGet(value) || emitter.inlineArrayRead(value)) return false;
// Class casts reinterpret the same pointer (prefix layouts).
if ((value.kind === "upcast" || value.kind === "downcast") && value.value.type.kind === "object")
return emitter.canBorrowCallArgument(value.value);
@@ -308,10 +313,15 @@ export function canBorrowReceiver(emitter: LlEmitter, e: IrExpr): boolean {
case "upcast":
return emitter.canBorrowReceiver(e.value);
// An inline union field has no box to lend: projections read it
// through emitReadReceiver's private stack box.
// through emitReadReceiver's private stack box. An inline array field
// has no array to lend.
case "fieldGet":
case "recordGet":
return !emitter.inlineFieldGet(e) && emitter.canBorrowReceiver(e.obj);
return (
!emitter.inlineFieldGet(e) &&
!emitter.inlineArrayRead(e) &&
emitter.canBorrowReceiver(e.obj)
);
case "ternary":
return emitter.canBorrowReceiver(e.then) && emitter.canBorrowReceiver(e.else_);
case "seqExpr": {
+13 -1
View File
@@ -2387,8 +2387,20 @@ export function emitRecordShapes(
` %shp = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${SHAPE_WORD_INDEX}`,
` store i32 ${shapeNumber}, ptr %shp ; shape number`,
);
// A record literal covers every field of its shape (ir.ts recordLit),
// so without dispose entries, which read the fields at the drop, no
// field is read before the literal stores it.
defs.push(
...arenaNewLines(host, newSym, sizeOf, traced ? cycType : null, dispose, init, "%o"),
...arenaNewLines(
host,
newSym,
sizeOf,
traced ? cycType : null,
dispose,
init,
"%o",
dispose === null ? "header" : "object",
),
);
if (dispose !== null)
defs.push(
@@ -172,8 +172,11 @@ test.each(["return", "alias", "capture", "store"])(
work.body.push(ret(ref("item")));
}
if (reason === "alias") {
// The alias escapes, so it holds a heap box and so does its source.
work.returnType = optional;
work.locals.push({ id: "alias", name: "alias", type: optional, mutable: false });
work.body.push({ kind: "varDecl", localId: "alias", init: ref("item"), loc });
work.body.push(ret(ref("alias")));
}
if (reason === "capture") {
work.locals[0]!.boxed = true;
@@ -204,6 +207,27 @@ test.each(["return", "alias", "capture", "store"])(
},
);
test("copies to a projection-only alias keep both locals in private storage", () => {
const mod = fixture();
const work: IrFunction = {
name: "work",
params: [],
locals: [],
body: [],
returnType: VOID,
loc,
};
mod.functions.unshift(work);
declareLocal(mod, wrap(fresh()));
work.locals.push({ id: "alias", name: "alias", type: optional, mutable: true });
work.body.push({ kind: "varDecl", localId: "alias", init: ref("item"), loc });
work.body.push({ kind: "assign", localId: "alias", value: ref("item"), loc });
work.body.push(effect(call("read", [ref("alias")], record)));
const text = body(emit(mod), "sc_f_work");
expect(text).toContain("alloca %ScrUnion");
expect(text).not.toMatch(/@scr_union_(?:new|retain|release)/);
});
test("forwarded parameters never retain or release a borrowed stack box", () => {
const mod = fixture();
const outer: IrFunction = {
@@ -93,7 +93,9 @@ test("a method of a class outside any hierarchy is on the unwinding ABI; closure
// closure over mid is a pointer target and returns with the flag.
const add = body(ir, "Counter_add");
expect(add).toMatch(/invoke double @sc_b?f_[^(]*mid/);
expect(add).toMatch(/resume \{ ptr, i32 \} %[\w.]+/);
expect(add).toMatch(/call void @scr_unwind_resume\(ptr %[\w.]+\)\n\s+unreachable/);
expect(add).not.toMatch(/\bresume \{/);
expect(ir).toContain("declare void @scr_unwind_resume(ptr) cold");
expect(add).not.toContain("@scr_unwind_raise");
const closures = ir
.split(/\n(?=define )/)
@@ -212,7 +214,10 @@ test("an invoked wrapper of a raising function resumes the unwind; its address m
expect(out).toContain(
"%r = invoke double @sc_bf_body.i32(i32 %i) to label %ub.ok.0 unwind label %ub.lp.0",
);
expect(out).toContain("resume { ptr, i32 } %ub.v.0");
expect(out).toContain(
"%ub.e.0 = extractvalue { ptr, i32 } %ub.v.0, 0\n call void @scr_unwind_resume(ptr %ub.e.0)\n unreachable",
);
expect(out).toContain("declare void @scr_unwind_resume(ptr) cold");
expect(() =>
bridgePlainUnwindCalls([
`${raising}\n@table = internal constant [1 x ptr] [ptr @sc_bf_body.i32]`,
@@ -277,11 +282,27 @@ test("calls that cannot unwind are marked nounwind", () => {
" unreachable",
"}",
].join("\n");
const [out] = bridgePlainUnwindCalls([raising]);
const resuming = [
"define internal void @sc_bf_resumes(ptr %b) #0 personality ptr @__gcc_personality_v0 {",
"entry:",
" invoke void @sc_bf_raises(ptr %b) to label %ok unwind label %lp",
"ok:",
" ret void",
"lp:",
" %e = landingpad { ptr, i32 } cleanup",
" %t = extractvalue { ptr, i32 } %e, 0",
" call void @scr_unwind_resume(ptr %t)",
" unreachable",
"}",
].join("\n");
const [out] = bridgePlainUnwindCalls([[raising, resuming].join("\n")]);
expect(out).toContain("%n = call i64 @scr_bytes_length(ptr %b) nounwind ; length");
expect(out).toContain("call void @scr_bytes_release(ptr %b) nounwind, !dbg !4");
expect(out).toContain("%r = call ptr %b(ptr %b) nounwind");
expect(out).toContain(" call void @scr_unwind_raise()\n");
// The resume unwinds: where LLVM inlines the function at an invoke, the
// call becomes an invoke of the caller's landing pad.
expect(out).toContain(" call void @scr_unwind_resume(ptr %t)\n");
});
test("functions without a plain call of a raising function change only their call lines", () => {
@@ -76,9 +76,11 @@ function recursiveFunctions(edges: ReadonlyMap<string, readonly string[]>): Set<
* than a `call`'s callee, and not called from an async or generator body
* (coroutine bodies keep plain calls).
*
* A native unwind costs about 2 µs a raise and 0.1 µs a frame, against a
* few nanoseconds for a flag test, so the functions an exception is likely to
* cross many of keep the flag: those with a `throw` or rethrow of their own
* A native unwind costs about 0.05 µs a raise and 10 to 25 ns a frame on
* x86-64 (scr_unwind.c's cached plans) and about 2 µs a raise and 0.1 µs a
* frame through libgcc's unwinder, against a few nanoseconds for a flag
* test, so the functions an exception is likely to cross many of keep the
* flag: those with a `throw` or rethrow of their own
* and those up to THROW_LEVELS calls above one (a handler near the throw
* takes it without a native unwind), and the recursive ones (deep stacks
* are recursions). */
@@ -254,8 +256,8 @@ export function unwindAbiFunctions(
* its own takes the exception. */
export const UNWIND_RAISE = "call void @scr_unwind_raise()\n unreachable";
/** What the unwind of a landing pad ends with when no handler of the
* function takes the exception: the unwind goes on (`resume`) with the
* exception the landing pad stored in UNWIND_SLOT. The block builder
* function takes the exception: the unwind goes on (scr_unwind_resume) with
* the exception the landing pad stored in UNWIND_SLOT. The block builder
* expands it (BlockBuilder.terminate). */
export const UNWIND_RESUME = "resume { ptr, i32 } <unwind slot>";
/** The function's slot for the exception its landing pads take. */
@@ -264,13 +266,17 @@ export const UNWIND_SLOT = "%unw.exc";
* would call the sanitizer's no-return hook before it, which fails on fiber
* stacks; scr_unwind_raise clears the unwound frames' shadow itself. */
export const UNWIND_RAISE_DECLARATION = "declare void @scr_unwind_raise() cold";
/** scr_unwind_resume's declaration. Landing pads call it with the
* exception they took to continue the unwind (BlockBuilder.resumeUnwind);
* like scr_unwind_raise, it is not `noreturn`. */
export const UNWIND_RESUME_DECLARATION = "declare void @scr_unwind_resume(ptr) cold";
/** The personality of functions with landing pads: the landing pads are
* cleanups that scr_unwind_raise's forced unwind runs. */
* cleanups that scr_unwind_raise's unwind runs. */
export const UNWIND_PERSONALITY = "personality ptr @__gcc_personality_v0";
/** An unwind leaving the function: a raise, or a landing pad's `resume`. */
const UNWINDS = /call void @scr_unwind_raise\(\)|\n\s+resume /;
/** An unwind leaving the function: a raise, or a landing pad's resume. */
const UNWINDS = /call void @scr_unwind_raise\(\)|call void @scr_unwind_resume\(/;
/** A pending-flag test: the thread's exception word, the active cell, or the runtime query. */
const PENDING_TEST = /@scr_exc_flag\b|%exc\.cell\b|@scr_exc_active\b|@scr_exc_pending\(/;
@@ -307,15 +313,16 @@ function argumentsEnd(line: string, open: number): number {
return close;
}
/** A call that cannot unwind, marked so: only scr_unwind_raise and the
* raising functions propagate an unwind (runtime code and every function
* reached through a pointer return normally), so every other call is
* `nounwind`, and the inliner keeps it a call where it inlines a raising
* function at an invoke. */
/** A call that cannot unwind, marked so: only scr_unwind_raise,
* scr_unwind_resume and the raising functions propagate an unwind (runtime
* code and every function reached through a pointer return normally), so
* every other call is `nounwind`, and the inliner keeps it a call where it
* inlines a raising function at an invoke. */
function markNounwind(line: string): string {
const call = line.indexOf("call ");
const callee = CALLEE.exec(line.slice(call));
if (callee === null || callee[0] === "@scr_unwind_raise(") return line;
if (callee === null || callee[0] === "@scr_unwind_raise(" || callee[0] === "@scr_unwind_resume(")
return line;
const close = argumentsEnd(line, call + callee.index + callee[0].length - 1);
return close >= line.length
? line
@@ -446,6 +453,7 @@ export function bridgePlainUnwindCalls(parts: string[]): string[] {
}
if (violations.size > 0) throw new UnwindAbiViolation(violations);
let personality = false;
let resumes = false;
let counter = 0;
const out = split.map((chunks) =>
chunks
@@ -525,8 +533,11 @@ export function bridgePlainUnwindCalls(parts: string[]): string[] {
result.push(
`${pad}:`,
`${indent}%ub.v.${n} = landingpad { ptr, i32 } cleanup`,
`${indent}resume { ptr, i32 } %ub.v.${n}`,
`${indent}%ub.e.${n} = extractvalue { ptr, i32 } %ub.v.${n}, 0`,
`${indent}call void @scr_unwind_resume(ptr %ub.e.${n})`,
`${indent}unreachable`,
);
resumes = true;
result.push(`${ok}:`);
} else if (m[2] !== undefined) {
const type = (m[3] ?? "").replace(RETURN_ATTRIBUTES, "").trim();
@@ -585,5 +596,7 @@ export function bridgePlainUnwindCalls(parts: string[]): string[] {
);
if (personality && !out.some((part) => part.includes("@__gcc_personality_v0(...)")))
out[0] = `declare i32 @__gcc_personality_v0(...)\n${out[0]}`;
if (resumes && !out.some((part) => part.includes(UNWIND_RESUME_DECLARATION)))
out[0] = `${UNWIND_RESUME_DECLARATION}\n${out[0]}`;
return out;
}
@@ -202,6 +202,36 @@ test("profiles reach the helper and key the native cache by their bytes", async
expect(calls[2]).toContain("--profile-use /profiles/a.profdata");
});
test("block counts reach the helper, and a build that records them reuses no artifact", async () => {
const pkg = await fakePackage();
const use = { path: "/profiles/a.profdata", digest: "a".repeat(64), contextSensitive: false };
const base = request(pkg.root, pkg.packageJson, join(pkg.root, "used.o"));
await emitNativeArtifact({ ...base, profile: { use } });
const blocks = { path: "/profiles/a.blocks", digest: "c".repeat(64) };
const annotated = {
...base,
outputPath: join(pkg.root, "blocks.o"),
profile: { use: { ...use, blocks } },
};
await emitNativeArtifact(annotated);
await emitNativeArtifact({ ...annotated, outputPath: join(pkg.root, "blocks-again.o") });
const recording = {
...base,
outputPath: join(pkg.root, "recorded.o"),
profile: { use, recordBlocks: "/work/a.blocks" },
};
await emitNativeArtifact(recording);
await emitNativeArtifact(recording);
const calls = (await readFile(pkg.log, "utf8")).trim().split("\n");
// The block counts key the cache; each recording build runs the helper.
expect(calls).toHaveLength(4);
expect(calls[1]).toContain(
"--profile-use /profiles/a.profdata --profile-blocks /profiles/a.blocks",
);
expect(calls[2]).toContain("--record-profile-blocks /work/a.blocks");
expect(calls[3]).toContain("--record-profile-blocks /work/a.blocks");
});
test("a cached artifact keeps the profile match of the build that produced it", async () => {
const pkg = await fakePackage({
profileReport:
@@ -213,7 +243,8 @@ test("a cached artifact keeps the profile match of the build that produced it",
use: { path: "/profiles/a.profdata", digest: "a".repeat(64), contextSensitive: false },
},
};
const match = { annotated: 120, mismatched: 7, missing: 40, unknownPartitions: 0 };
// A helper that predates block-count files reports no recovered count.
const match = { annotated: 120, mismatched: 7, missing: 40, recovered: 0, unknownPartitions: 0 };
expect((await emitNativeArtifact(used)).profileMatch).toEqual(match);
const again = await emitNativeArtifact({ ...used, outputPath: join(pkg.root, "again.o") });
expect(again.profileMatch).toEqual(match);
@@ -228,6 +259,7 @@ test("a cached artifact keeps the profile match of the build that produced it",
annotated: 0,
mismatched: 0,
missing: 0,
recovered: 0,
unknownPartitions: 1,
});
// Builds without a profile carry no match, from the helper or the cache.
@@ -425,8 +425,13 @@ export async function emitNativeArtifact(
);
}
const helper = await resolveHelper(target, options.resolvePackageJson, options.helperHost);
// A build that records block counts runs the helper on every partition.
const root = await prepareBuildCacheRoot(
options.cacheRoot === undefined ? buildCacheRoot() : options.cacheRoot,
options.profile?.recordBlocks !== undefined
? null
: options.cacheRoot === undefined
? buildCacheRoot()
: options.cacheRoot,
);
const key = cacheKey(options, target, helper.identity);
const outputs = nativePartitionPaths(options.outputPath, options.partitions ?? 1);
@@ -556,6 +561,7 @@ function helperProfileMatch(match: NativeProfileMatch): Record<string, number> {
annotated: match.annotated,
mismatched: match.mismatched,
missing: match.missing,
recovered: match.recovered,
unknown_partitions: match.unknownPartitions,
};
}
@@ -573,6 +579,7 @@ async function cachedProfileMatch(
annotated: 0,
mismatched: 0,
missing: 0,
recovered: 0,
unknownPartitions: partitions,
}
);
@@ -40,6 +40,9 @@ export interface NativeLinkFeatures {
/** Module namespace objects as checked-dynamic values link
* scr_module_namespace.c; absent (false) otherwise. */
moduleNamespace?: boolean;
/** A module namespace boxed for typed code links
* scr_module_namespace_dyn.c; absent (false) otherwise. */
moduleNamespaceDyn?: boolean;
zlib: boolean;
/** node:zlib's Brotli forms: scr_brotli.c and the vendored brotli archive. */
brotli?: boolean;
@@ -10,6 +10,8 @@ import {
profileGenerateLinkerArgs,
profileHasContextCounts,
profileLayoutLinkerArgs,
profileBlocksPath,
recordProfileBlocksPath,
} from "./native-profile.js";
import { NATIVE_TARGETS } from "./targets.js";
@@ -127,11 +129,22 @@ test("only the instrumented link adds the profile runtime", () => {
});
test("the helper's profile match is read from its output", () => {
expect(
parseNativeProfileMatch(
'{"ok":true,"profile":{"annotated":120,"mismatched":7,"missing":40,"recovered":6,"unknown_partitions":1}}\n',
),
).toEqual({ annotated: 120, mismatched: 7, missing: 40, recovered: 6, unknownPartitions: 1 });
// A helper that predates block-count files reports no recovered count.
expect(
parseNativeProfileMatch(
'{"ok":true,"profile":{"annotated":120,"mismatched":7,"missing":40,"unknown_partitions":1}}\n',
),
).toEqual({ annotated: 120, mismatched: 7, missing: 40, unknownPartitions: 1 });
).toEqual({ annotated: 120, mismatched: 7, missing: 40, recovered: 0, unknownPartitions: 1 });
expect(
parseNativeProfileMatch(
'{"ok":true,"profile":{"annotated":1,"mismatched":0,"missing":0,"recovered":-1,"unknown_partitions":0}}\n',
),
).toBeNull();
// A helper that predates the report prints nothing.
expect(parseNativeProfileMatch("")).toBeNull();
expect(parseNativeProfileMatch('{"ok":true}\n')).toBeNull();
@@ -144,6 +157,7 @@ test("the build reports the match, and warns when the profile is stale", () => {
annotated: 9170,
mismatched: 0,
missing: 28636,
recovered: 0,
unknownPartitions: 0,
}),
).toBe(
@@ -155,6 +169,7 @@ test("the build reports the match, and warns when the profile is stale", () => {
annotated: 90,
mismatched: 10,
missing: 5,
recovered: 0,
unknownPartitions: 1,
}),
).toBe(
@@ -165,6 +180,7 @@ test("the build reports the match, and warns when the profile is stale", () => {
annotated: 5208,
mismatched: 3739,
missing: 28859,
recovered: 0,
unknownPartitions: 2,
}),
).toBe(
@@ -177,9 +193,69 @@ test("the build reports the match, and warns when the profile is stale", () => {
annotated: 0,
mismatched: 0,
missing: 12,
recovered: 0,
unknownPartitions: 0,
}),
).toBe(
"scriptc: profile app.profdata: 0 functions annotated, 0 mismatched, 12 without a record\n",
);
// Functions the block-count file annotated have counts, so they leave the warning's share.
expect(
renderNativeProfileMatch("app.profdata", {
annotated: 5208,
mismatched: 3739,
missing: 28859,
recovered: 3600,
unknownPartitions: 0,
}),
).toBe(
"scriptc: profile app.profdata: 5208 functions annotated, 3739 mismatched, 28859 without a record\n" +
"scriptc: profile app.profdata: 3600 functions without profile counts annotated from app.blocks\n",
);
expect(
renderNativeProfileMatch("app.profdata", {
annotated: 5208,
mismatched: 3739,
missing: 28859,
recovered: 1,
unknownPartitions: 0,
}),
).toBe(
"scriptc: profile app.profdata: 5208 functions annotated, 3739 mismatched, 28859 without a record\n" +
"scriptc: profile app.profdata: 1 function without profile counts annotated from app.blocks\n" +
"scriptc: warning: 3738 of the 8947 functions app.profdata describes (41.8%) changed since it was collected and are optimized without their counts; collect the profile again\n",
);
});
test("the block-count file sits beside the profile", () => {
expect(profileBlocksPath("/p/tsc-ts.cs.profdata")).toBe("/p/tsc-ts.cs.blocks");
expect(profileBlocksPath("/p/cli.profile")).toBe("/p/cli.profile.blocks");
expect(recordProfileBlocksPath({})).toBeUndefined();
expect(recordProfileBlocksPath({ SCRIPTC_RECORD_PROFILE_BLOCKS: "" })).toBeUndefined();
expect(recordProfileBlocksPath({ SCRIPTC_RECORD_PROFILE_BLOCKS: "/p/a.blocks" })).toBe(
"/p/a.blocks",
);
});
test("the block-count file reaches the helper and keys the caches", () => {
const use = { path: "/p/a.profdata", digest: "d", contextSensitive: false };
const blocks = { path: "/p/a.blocks", digest: "b" };
expect(nativeProfileHelperArgs({ use: { ...use, blocks } })).toEqual([
"--profile-use",
"/p/a.profdata",
"--profile-blocks",
"/p/a.blocks",
]);
expect(nativeProfileHelperArgs({ use, recordBlocks: "/w/a.blocks" })).toEqual([
"--profile-use",
"/p/a.profdata",
"--record-profile-blocks",
"/w/a.blocks",
]);
// Recording needs a profile to record from.
expect(
nativeProfileHelperArgs({ generate: "/r/%p.profraw", recordBlocks: "/w/a.blocks" }),
).toEqual(["--profile-generate", "/r/%p.profraw"]);
expect(nativeProfileKey({ use })).toBe("profile-use:d");
expect(nativeProfileKey({ use: { ...use, blocks } })).toBe("profile-use:d\0profile-blocks:b");
});
+80 -14
View File
@@ -11,6 +11,14 @@
* and merging its raw profiles into the first profile adds the counts of the
* inlined code. A profile with those counts also orders each function's
* blocks by them and moves cold blocks to separate sections.
*
* A function whose control flow changed since the profile was collected
* takes none of its counts. A block-count file beside the profile
* (profileBlocksPath) describes each function by its blocks' labels instead,
* and annotates those functions: blocks that keep their place take their
* recorded counts, and the counts of the others follow from flow
* conservation. A `--profile-use` build writes that file for the functions
* its profile annotates when SCRIPTC_RECORD_PROFILE_BLOCKS names its path.
*/
import type { NativeOptimization } from "./optimization.js";
@@ -19,9 +27,40 @@ export interface NativeProfileOptions {
* `use`, the instrumentation runs after inlining and records
* context-sensitive counts. */
generate?: string;
/** Indexed profile, the digest of its bytes (which key every cache), and
* whether it holds context-sensitive counts. */
use?: { path: string; digest: string; contextSensitive: boolean };
/** Indexed profile, the digest of its bytes (which key every cache),
* whether it holds context-sensitive counts, and its block-count file
* with the digest of its bytes when there is one. */
use?: {
path: string;
digest: string;
contextSensitive: boolean;
blocks?: { path: string; digest: string };
};
/** With `use`: the path the build writes the block counts of the
* functions the profile annotates to. Such a build reuses no cached
* artifacts. */
recordBlocks?: string;
}
/** The environment variable that names the block-count file a
* `--profile-use` build writes. */
export const RECORD_PROFILE_BLOCKS_ENV = "SCRIPTC_RECORD_PROFILE_BLOCKS";
/** The block-count file beside a profile: its path with `.blocks` in place
* of a `.profdata` extension, or with `.blocks` appended. */
export function profileBlocksPath(profilePath: string): string {
return profilePath.endsWith(".profdata")
? `${profilePath.slice(0, -".profdata".length)}.blocks`
: `${profilePath}.blocks`;
}
/** The block-count file a `--profile-use` build writes, from the
* environment, or undefined. */
export function recordProfileBlocksPath(
env: Record<string, string | undefined>,
): string | undefined {
const path = env[RECORD_PROFILE_BLOCKS_ENV];
return path === undefined || path === "" ? undefined : path;
}
/** Whether an indexed profile holds context-sensitive counts: the header's
@@ -59,18 +98,25 @@ export function nativeProfileHelperArgs(profile: NativeProfileOptions | undefine
return [
...(profile?.generate === undefined ? [] : ["--profile-generate", profile.generate]),
...(profile?.use === undefined ? [] : ["--profile-use", profile.use.path]),
...(profile?.use?.blocks === undefined ? [] : ["--profile-blocks", profile.use.blocks.path]),
...(profile?.use === undefined || profile.recordBlocks === undefined
? []
: ["--record-profile-blocks", profile.recordBlocks]),
];
}
/** How an optimized build's profile matched the program's functions, as the
* helper reports it: functions that took the profile's counts, functions
* whose control flow changed since the profile was collected, functions the
* profile has no record of (new, or never run while it was collected), and
* partitions reused from the cache without their counts. */
* profile has no record of (new, or never run while it was collected),
* mismatched functions that the block-count file annotated (never more
* than the mismatched ones), and partitions reused from the cache without
* their counts. */
export interface NativeProfileMatch {
annotated: number;
mismatched: number;
missing: number;
recovered: number;
unknownPartitions: number;
}
@@ -93,10 +139,18 @@ export function parseNativeProfileMatch(stdout: string): NativeProfileMatch | nu
const annotated = count("annotated");
const mismatched = count("mismatched");
const missing = count("missing");
// A helper that predates block-count files reports no recovered count.
const recovered = profile["recovered"] === undefined ? 0 : count("recovered");
const unknownPartitions = count("unknown_partitions");
if (annotated === null || mismatched === null || missing === null || unknownPartitions === null)
if (
annotated === null ||
mismatched === null ||
missing === null ||
recovered === null ||
unknownPartitions === null
)
continue;
return { annotated, mismatched, missing, unknownPartitions };
return { annotated, mismatched, missing, recovered, unknownPartitions };
}
return null;
}
@@ -107,9 +161,15 @@ export function parseNativeProfileMatch(stdout: string): NativeProfileMatch | nu
export const STALE_PROFILE_SHARE = 0.1;
/** `scriptc build --profile-use`'s report on stderr: one line with the match,
* and a warning line when more than STALE_PROFILE_SHARE of the functions the
* profile describes changed since it was collected. */
export function renderNativeProfileMatch(profile: string, match: NativeProfileMatch): string {
* a line with the functions the block-count file annotated when there are
* any, and a warning line when more than STALE_PROFILE_SHARE of the
* functions the profile describes changed since it was collected and took
* no counts. */
export function renderNativeProfileMatch(
profile: string,
match: NativeProfileMatch,
blocks: string = profileBlocksPath(profile),
): string {
const unknown =
match.unknownPartitions === 0
? ""
@@ -117,11 +177,16 @@ export function renderNativeProfileMatch(profile: string, match: NativeProfileMa
const lines = [
`scriptc: profile ${profile}: ${match.annotated} functions annotated, ${match.mismatched} mismatched, ${match.missing} without a record${unknown}`,
];
const described = match.annotated + match.mismatched;
if (described > 0 && match.mismatched / described > STALE_PROFILE_SHARE) {
const percent = ((match.mismatched / described) * 100).toFixed(1);
if (match.recovered > 0)
lines.push(
`scriptc: warning: ${match.mismatched} of the ${described} functions ${profile} describes (${percent}%) changed since it was collected and are optimized without their counts; collect the profile again`,
`scriptc: profile ${profile}: ${match.recovered} function${match.recovered === 1 ? "" : "s"} without profile counts annotated from ${blocks}`,
);
const described = match.annotated + match.mismatched;
const uncounted = Math.max(0, match.mismatched - match.recovered);
if (described > 0 && uncounted / described > STALE_PROFILE_SHARE) {
const percent = ((uncounted / described) * 100).toFixed(1);
lines.push(
`scriptc: warning: ${uncounted} of the ${described} functions ${profile} describes (${percent}%) changed since it was collected and are optimized without their counts; collect the profile again`,
);
}
return `${lines.join("\n")}\n`;
@@ -132,6 +197,7 @@ export function nativeProfileKey(profile: NativeProfileOptions | undefined): str
return [
...(profile?.generate === undefined ? [] : [`profile-generate:${profile.generate}`]),
...(profile?.use === undefined ? [] : [`profile-use:${profile.use.digest}`]),
...(profile?.use?.blocks === undefined ? [] : [`profile-blocks:${profile.use.blocks.digest}`]),
].join("\0");
}
@@ -164,6 +164,9 @@ export interface CcOptions {
/** Module namespace objects as checked-dynamic values: compiles
* scr_module_namespace.c. */
moduleNamespace?: boolean;
/** A module namespace boxed for typed code: compiles
* scr_module_namespace_dyn.c. */
moduleNamespaceDyn?: boolean;
/** The program uses the process-events surface (signal/exit listeners,
* stdin events, for-await over stdin — moduleUsesProcessEvents on the
* IR): compiles scr_events.c into the binary. Event-free binaries keep
@@ -202,6 +202,8 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
unit((u) => u.opts.wasm, "scr_wasm.c"),
// Module namespace objects as checked-dynamic values.
unit((u) => u.opts.moduleNamespace === true, "scr_module_namespace.c"),
// A module namespace as the frozen null-prototype object typed code sees.
unit((u) => u.opts.moduleNamespaceDyn === true, "scr_module_namespace_dyn.c"),
unit(
(u) =>
u.opts.dynAsync ||
@@ -371,7 +373,13 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
unitArgs((u) => (u.tlsCa && targetPlatform(u.driver) === "win32" ? ["-lcrypt32"] : [])),
// Fetch uses the native network, TLS, and HTTP runtime.
unit((u) => u.nativeFetch, "scr_fetch.c"),
unit((u) => u.opts.nodeIsland === true, "scr_node_island.c", "scr_node_island_handle.c"),
unit(
(u) => u.opts.nodeIsland === true,
"scr_node_island.c",
"scr_node_island_handle.c",
"scr_node_island_export.c",
"scr_node_island_boot.c",
),
// Readable.fromWeb reads a web ReadableStream through its reader.
unit((u) => u.nativeFetch && u.opts.stream, "scr_stream_from_web.c"),
];
@@ -103,6 +103,7 @@ export interface RuntimeFeatureSet extends NativeLinkFeatures {
nodeIsland: boolean;
brotli: boolean;
moduleNamespace: boolean;
moduleNamespaceDyn: boolean;
nativeFetch: boolean;
abortSignalOnly: boolean;
netEffective: boolean;
@@ -312,6 +313,7 @@ export function effectiveRuntimeFeatures(
nodeIsland: features.nodeIsland === true,
brotli: features.brotli === true,
moduleNamespace: features.moduleNamespace === true,
moduleNamespaceDyn: features.moduleNamespaceDyn === true,
nativeFetch,
// The fetch unit has AbortController and AbortSignal; scr_abort.c is
// the same implementation without the fetch transport.
+2 -1
View File
@@ -102,7 +102,8 @@ Options:
code is laid out, inlined and split by how the profiled
runs used it. Code the profile does not describe, or
describes from an older version of the program, is
optimized as without one. build prints how the profile
optimized as without one, unless a .blocks file beside
the profile describes it. build prints how the profile
matched on stderr, and warns when more than 10% of the
functions it describes changed (release or speed;
--emit=exe only)
@@ -938,6 +938,9 @@ export interface StaticFetchMembers {
headersCalls: ReadonlySet<string>;
readableStreamReads: ReadonlySet<string>;
readableStreamCalls: ReadonlySet<string>;
/** ReadableStream prototype members the native stream does not
* implement, symbol-keyed ones as `[Symbol.name]`. */
readableStreamUnsupported: ReadonlySet<string>;
}
let staticFetchMemberSets: StaticFetchMembers | undefined;
@@ -961,6 +964,16 @@ export function staticFetchMembers(): StaticFetchMembers {
),
readableStreamReads: new Set(profile.members.readableStreamReads),
readableStreamCalls: new Set(profile.members.readableStreamCalls),
readableStreamUnsupported: new Set(
profile.inventory.entries
.filter(
(entry) =>
entry.owner === "ReadableStream" &&
(entry.placement === "prototype" || entry.placement === "prototype-symbol") &&
entry.status === "unsupported",
)
.map((entry) => entry.member),
),
};
return staticFetchMemberSets;
}
+2 -1
View File
@@ -86,7 +86,8 @@ export interface CompileBaseOptions {
/** Eligible packages compile automatically. Explicit names override automatic selection heuristics. */
npmStatic?: readonly string[] | "auto";
/** Shape inference's typed overlay for opted-in packages' JavaScript.
* False disables it (`--no-shape-overlay`); a package opts out with
* True enables it, false disables it (`--no-shape-overlay`); unset
* follows SCRIPTC_SHAPE_OVERLAY. A package opts out with
* `"scriptc": { "packages": { "<name>": { "shapes": false } } }`. */
shapeOverlay?: boolean;
/** Outbound native FFI manifest. Its signature-only TypeScript bindings
+74
View File
@@ -67,6 +67,8 @@ import {
type ContractAnnotations,
type ContractNote,
} from "../frontend/contract-types.js";
import { planInlineArrays } from "../ir/inline-arrays.js";
import { checkInlineArraySources } from "../ir/inline-array-sources.js";
/** The largest array index: 2^32 - 2 (ECMA-262 array index). */
const MAX_ARRAY_INDEX = 2 ** 32 - 2;
@@ -255,6 +257,7 @@ class ContractChecker {
run(): ContractCheckResult {
this.checkProgramLifetime();
this.checkInlineArrays();
this.checkNoThrow();
this.checkAcyclic();
const elements = new Int32ElementContracts(this);
@@ -323,6 +326,77 @@ class ContractChecker {
this.record("programLifetime", roots);
}
/* ── InlineArray ── */
/** `readonly f: InlineArray<C>` (experimental, ir/inline-arrays.ts): the
* field's elements may live in the instance itself, which only a field
* written once from a constructor argument and never again can have.
* Checked: the class and C are ProgramLifetime classes, the constructor of
* the declaring class writes the field once from one of its parameters,
* every subclass constructor passes it one of its own through super(...),
* nothing else writes the field, no read of it is compared by identity
* (two reads need not give the same array), and no instance of the
* hierarchy is constructed through a class value or becomes a dynamic
* value. */
private checkInlineArrays(): void {
if (this.notes.inlineArrays.size === 0) return;
const roots = new Set(this.contracts.programLifetime ?? []);
const lifetime = (className: string): boolean => {
let cls = this.classes.get(className);
while (cls?.base !== undefined) cls = this.classes.get(cls.base);
return cls !== undefined && roots.has(cls.name);
};
const entries: string[] = [];
const notes = new Map<string, ContractNote>();
for (const [key, note] of this.notes.inlineArrays) {
const at = key.lastIndexOf("\0");
const field = key.slice(at + 1);
for (const cls of this.classesAt(key.slice(0, at))) {
if (!lifetime(cls.name)) {
this.report(
"SC7009",
note.loc,
`'${note.name}' is an InlineArray field of a class that is not ProgramLifetime`,
"declare the root class of the hierarchy `implements ProgramLifetime`: inline elements live in the instance, which is never freed",
);
continue;
}
const entry = `${cls.name}\0${field}`;
entries.push(entry);
notes.set(entry, note);
}
}
if (entries.length === 0) return;
const plan = planInlineArrays(
{ ...this.mod, contracts: { ...this.mod.contracts, inlineArrays: entries } },
lifetime,
);
for (const r of plan.rejected) {
const note = notes.get(`${r.declaringClass}\0${r.field}`)!;
const at = r.loc === undefined ? "" : ` (at ${this.where(r.loc)})`;
this.report(
"SC7009",
note.loc,
`'${note.name}' cannot keep its InlineArray contract: ${r.reason}${at}`,
"write the field once in the constructor from one of its parameters (subclasses pass theirs through super(...)), never again, and keep the class's instances out of class values and dynamic values",
);
}
// Where the arrays come from and who writes them (ir/inline-array-sources.ts).
for (const issue of checkInlineArraySources(this.mod, plan)) {
const note = notes.get(`${issue.declaringClass}\0${issue.field}`)!;
const from = issue.origin === undefined ? "" : ` (from ${this.where(issue.origin)})`;
this.report(
"SC7009",
issue.loc,
`'${note.name}' (InlineArray): ${issue.reason}${from}`,
"an InlineArray field holds its array for good: build a new array for each constructor call, and never write to an array a field holds",
);
}
const kept = new Set<string>();
for (const cls of plan.classes.values()) kept.add(`${cls.declaringClass}\0${cls.field}`);
this.record("inlineArrays", [...kept]);
}
/* ── Acyclic ── */
private checkAcyclic(): void {
+4 -3
View File
@@ -107,6 +107,7 @@ export const DIAGNOSTIC_CODES = {
SC7006: "Acyclic contract in an unchecked position",
SC7007: "Acyclic type can lie on a reference cycle",
SC7008: "ProgramLifetime class extends another class",
SC7009: "InlineArray field contract not kept",
SC8001: "package or module runs in a Node island (note)",
SC8002: "Node islands off for a package or module outside the native subset",
SC8003: "package marked native is outside the native subset",
@@ -114,6 +115,9 @@ export const DIAGNOSTIC_CODES = {
SC8005: "no usable libnode pack for a program that calls into Node.js",
SC8006: "Node island modules the build cannot collect",
SC8010: "value crossing a Node island boundary in a form without an exact copy",
SC8011: "native class extending a Node island class outside the supported forms",
SC8020: "stateful module duplicated across the Node island boundary",
SC8022: "island code extends a class of a compiled module",
SC8030: "type patch: no range covers the installed copy",
SC8031: "type patch: selector matches nothing",
SC8032: "type patch: declaration already has the type",
@@ -151,11 +155,8 @@ export const RETIRED_DIAGNOSTIC_CODES = {
* that work lands it moves its codes into DIAGNOSTIC_CODES; when it is
* abandoned, delete them here. */
export const RESERVED_DIAGNOSTIC_CODES = {
SC8011: "Node islands (ni/* branches)",
SC8012: "Node islands (ni/* branches)",
SC8020: "Node islands (ni/* branches)",
SC8021: "Node islands (ni/* branches)",
SC8022: "Node islands (ni/* branches)",
SC8023: "Node islands (ni/* branches)",
} as const satisfies Record<`SC${number}`, string>;
@@ -71,7 +71,7 @@
* build checks (SC6103, frontend/shape-inference)
* SC7xxx contract types (@scriptc/types): a NoThrow, Int32, Immutable,
* Acyclic or ProgramLifetime contract that does not hold or
* cannot be checked (SC7001–SC7008, contracts/check.ts)
* cannot be checked (SC7001–SC7009, contracts/check.ts)
* SC8xxx Node islands: packages and modules outside the native subset
* that run (or, in report-only mode, would run) in a Node
* island — unknown project configuration (SC8004), a package
@@ -336,6 +336,56 @@ export function projectConfigDiag(
};
}
/** SC8011 — a native class extends a class owned by a Node island. Native
* subclasses of island classes are scope-B milestone NI-5b. */
export function nativeSubclassOfIslandDiag(loc: SrcLoc): ScrDiagnostic {
return {
code: "SC8011",
message:
"a native class cannot extend a class owned by a Node island yet (native subclasses of island classes)",
loc,
hint: "keep the subclass in Node: move it into the island module, or mark its module as a Node island; a registry shim of the package also lifts this",
};
}
/** SC8020 — a stateful module the program compiles is duplicated across
* the Node island boundary (an explicit "duplicate", or a form NI-6 cannot
* share yet): each side has its own state. */
export function sharedModuleDuplicatedDiag(
module: string,
why: string,
loc: SrcLoc,
): ScrDiagnostic {
return {
code: "SC8020",
message: `module '${module}' is duplicated across the Node island boundary although it is stateful: ${why}; the program and its islands each see their own copy`,
loc,
hint: 'remove "share": "duplicate" from its scriptc.packages entry to share one instance',
};
}
/** SC8022 — island code extends a class of a module the program compiles.
* A native instance cannot take an island subclass's overrides, so the
* module is not owned by the program: a package runs in Node for both
* sides (the build retries with it as a Node island); a program module is
* refused. */
export function sharedClassExtendedDiag(
pkg: string | null,
file: string,
base: string,
loc: SrcLoc,
): ScrDiagnostic {
return {
code: "SC8022",
message:
pkg !== null
? `package '${pkg}' is shared with a Node island that extends its class '${base}': it runs in Node for both sides`
: `module '${file}' cannot be shared with a Node island that extends its class '${base}' (island subclasses of native classes)`,
loc,
hint: "move the subclass into the program, or mark the module as a Node island",
};
}
/** SC8002 — Node islands are off (or limited to marked islands) and an
* npm package is outside the native subset. */
export function nodeIslandsOffDiag(pkg: string, why: string, loc: SrcLoc): ScrDiagnostic {
@@ -67,6 +67,7 @@ export interface EarlyExecutableNativeFeatures {
stdioExtended?: boolean;
nodeIsland?: boolean;
moduleNamespace?: boolean;
moduleNamespaceDyn?: boolean;
zlib: boolean;
brotli?: boolean;
assert: boolean;
@@ -254,7 +255,7 @@ export interface EarlyExecutableCachePublish extends EarlyExecutableCacheHit {
function isProfileMatch(value: unknown): value is NativeProfileMatch {
if (typeof value !== "object" || value === null) return false;
const match = value as Record<string, unknown>;
return ["annotated", "mismatched", "missing", "unknownPartitions"].every((key) => {
return ["annotated", "mismatched", "missing", "recovered", "unknownPartitions"].every((key) => {
const count = match[key];
return typeof count === "number" && Number.isInteger(count) && count >= 0;
});
+9 -6
View File
@@ -119,12 +119,15 @@ export function prepareExecutableModule(
}
try {
lowered = fe.lower({
...(islandDecision !== null ? { nodeIslands: islandDecision } : {}),
targetPlatform: buildPlatform,
...(ffi !== null ? { ffiImports: ffi.functions } : {}),
...(opts.tier !== undefined ? { tierMode: opts.tier } : {}),
});
lowered = fe.lower(
{
...(islandDecision !== null ? { nodeIslands: islandDecision } : {}),
targetPlatform: buildPlatform,
...(ffi !== null ? { ffiImports: ffi.functions } : {}),
...(opts.tier !== undefined ? { tierMode: opts.tier } : {}),
},
(module) => validateModule(module).length === 0,
);
timing("lower");
} catch (e) {
// The last-resort panic fence: an upstream tsgo panic that crossed a
@@ -16,15 +16,33 @@ import { type HeritageElement, isHeritageElement } from "./heritage-elements.js"
export const CONTRACT_TYPES_MODULE = "@scriptc/types";
export type ContractMarker = "Int32" | "NoThrow" | "Immutable" | "Acyclic" | "ProgramLifetime";
/** The module and name of the inline array field marker, which is
* experimental: `readonly f: InlineArray<C>` on an instance field of a
* ProgramLifetime class (ir/inline-arrays.ts). */
export const INLINE_ARRAY_MODULE = "@scriptc/experimental";
export const INLINE_ARRAY_MARKER = "InlineArray";
export type ContractMarker =
| "Int32"
| "NoThrow"
| "Immutable"
| "Acyclic"
| "ProgramLifetime"
| typeof INLINE_ARRAY_MARKER;
const MARKERS: ReadonlySet<string> = new Set([
"Int32",
"NoThrow",
"Immutable",
"Acyclic",
"ProgramLifetime",
INLINE_ARRAY_MARKER,
]);
/** The module a marker is imported from. */
function markerModule(marker: string): string {
return marker === INLINE_ARRAY_MARKER ? INLINE_ARRAY_MODULE : CONTRACT_TYPES_MODULE;
}
/** One written contract: what it promises and where the annotation is. */
export interface ContractNote {
marker: ContractMarker;
@@ -67,6 +85,9 @@ export interface ContractAnnotations {
acyclic: AcyclicNote[];
/** ProgramLifetime classes (`implements ProgramLifetime`) by class key. */
classes: Map<string, ContractNote>;
/** Inline array fields (`readonly f: InlineArray<C>`) by
* `${class key}\0${field name}`. */
inlineArrays: Map<string, ContractNote>;
diagnostics: ScrDiagnostic[];
/** Number of annotations found (for timing and tests). */
count: number;
@@ -123,7 +144,7 @@ function markerNamed(
importDecl === undefined ||
!ts.isImportDeclaration(importDecl) ||
!ts.isStringLiteral(importDecl.moduleSpecifier) ||
importDecl.moduleSpecifier.text !== CONTRACT_TYPES_MODULE
importDecl.moduleSpecifier.text !== markerModule(member)
)
return null;
return MARKERS.has(member) ? (member as ContractMarker) : null;
@@ -158,6 +179,7 @@ export function collectContractAnnotations(
fields: new Map(),
acyclic: [],
classes: new Map(),
inlineArrays: new Map(),
diagnostics: [],
count: 0,
};
@@ -166,7 +188,11 @@ export function collectContractAnnotations(
ts.SyntaxKind.ExpressionWithTypeArguments,
]);
for (const sf of files) {
if (sf.isDeclarationFile || !sf.text.includes(CONTRACT_TYPES_MODULE)) continue;
if (
sf.isDeclarationFile ||
(!sf.text.includes(CONTRACT_TYPES_MODULE) && !sf.text.includes(INLINE_ARRAY_MODULE))
)
continue;
ts.walkPreorder(
sf,
(node) => {
@@ -183,6 +209,7 @@ export function collectContractAnnotations(
out.count++;
if (marker === "Acyclic") placeAcyclicAlias(out, checker, node, resolveUnit);
else if (marker === "ProgramLifetime") unsupported(out, node, marker);
else if (marker === INLINE_ARRAY_MARKER) placeInlineArray(out, node);
else place(out, checker, node, marker);
},
kinds,
@@ -203,6 +230,17 @@ function unsupported(out: ContractAnnotations, node: ts.Node, marker: ContractMa
);
return;
}
if (marker === INLINE_ARRAY_MARKER) {
out.diagnostics.push(
contractDiag(
"SC7006",
locOf(node),
"the InlineArray contract is not checked in this position (InlineArray is checked on instance fields of ProgramLifetime classes)",
"write it directly as the type of an instance field: readonly f: InlineArray<C>",
),
);
return;
}
const where =
marker === "NoThrow"
? "NoThrow wraps a function's written return type"
@@ -282,6 +320,28 @@ function placeHeritage(
unsupported(out, node, marker);
}
/** `readonly f: InlineArray<C>`: an instance field of a class. */
function placeInlineArray(out: ContractAnnotations, ref: ts.TypeReferenceNode): void {
const parent = ref.parent;
if (
parent === undefined ||
!ts.isPropertyDeclaration(parent) ||
parent.type !== ref ||
ref.typeArguments?.nodes.length !== 1 ||
ts.getModifiers(parent)?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword) === true ||
!(ts.isIdentifier(parent.name) || ts.isPrivateIdentifier(parent.name))
)
return unsupported(out, ref, INLINE_ARRAY_MARKER);
const cls = parent.parent;
if (cls === undefined || !(ts.isClassDeclaration(cls) || ts.isClassExpression(cls)))
return unsupported(out, ref, INLINE_ARRAY_MARKER);
out.inlineArrays.set(`${contractLocKey(locOf(cls))}\0${parent.name.text}`, {
marker: INLINE_ARRAY_MARKER,
loc: locOf(ref),
name: `${nameText(cls.name)}.${parent.name.text}`,
});
}
/** `type T = Acyclic<{ ... }>`: the aliased object type. */
function placeAcyclicAlias(
out: ContractAnnotations,
@@ -173,6 +173,16 @@ export class FrontendInputTracker {
}
}
/** Raw file names whose source text this tracker recorded. */
private readonly notedTexts = new Set<string>();
/** Records a raw read of a source file's text, once per file name. */
noteText(fileName: string): void {
if (this.notedTexts.has(fileName)) return;
this.notedTexts.add(fileName);
this.record({ op: "text", path: resolve(fileName) });
}
record(probe: FrontendInputProbe): void {
const key = `${probe.op}\0${probe.path}`;
if (probe.op === "text" && this.probes.has(key)) return;
@@ -238,7 +248,7 @@ function record(probe: FrontendInputProbe): void {
// callbacks, line numbers) changes with edits that keep the tokens.
observeSourceText((fileName) => {
const tracker = synchronousTracker ?? activeTracker.getStore();
if (tracker !== undefined) tracker.record({ op: "text", path: resolve(fileName) });
if (tracker !== undefined) tracker.noteText(fileName);
});
/** Mark the active frontend as unsafe to persist because an exact host query
@@ -260,6 +260,9 @@ const PURE_KINDS = new Set<IrExpr["kind"]>([
"unionIsTag",
"unionNarrow",
"unionWrap",
// Its statements are checked on their own (declarations and assignments
// are recorded writes).
"seqExpr",
]);
/** The locals evaluating `value` may assign, and whether it may run other
@@ -273,7 +276,11 @@ function valueEffects(lowerer: Lowerer, value: IrExpr): { writes: Set<string>; o
everyExpr(value, {
expr: (e) => {
if (e.kind === "assignExpr" || e.kind === "incDec") writes.add(e.localId);
else if (e.kind === "call" ? !readers.has(e.callee) : !PURE_KINDS.has(e.kind)) opaque = true;
else if (e.kind === "libCall") {
// The "undefined" key of an undefined index is a constant.
if (e.fn !== "array.undefinedKey") opaque = true;
} else if (e.kind === "call" ? !readers.has(e.callee) : !PURE_KINDS.has(e.kind))
opaque = true;
return true;
},
stmt: (s) => {
@@ -314,6 +321,16 @@ function readsMutableLocal(lowerer: Lowerer, e: IrExpr): boolean {
});
}
/** Whether `receiver` still reads the same value after `index` runs: the
* index assigns none of the bindings it reads, and runs no code that could
* assign them through a closure. */
export function unaffectedByIndex(lowerer: Lowerer, receiver: IrExpr, index: IrExpr): boolean {
const effects = valueEffects(lowerer, index);
return (
unaffectedBy(receiver, effects) && !(effects.opaque && readsMutableLocal(lowerer, receiver))
);
}
/** A number, string or boolean element type. Arrays of it hold a present
* undefined in the element's state, so `(T | undefined)[]` stores T
* elements (the type mapper) and a T[] seen as one stays the same array. */
@@ -647,26 +647,27 @@ export function lowerWithArraySpecies(
// instance is created (with that length for map, 0 otherwise), then
// the method runs to that length and its elements move into the
// instance.
const bounded =
result.kind === "call" &&
result.args[0]?.kind === "varRef" &&
result.args[0].localId === local.id
? boundedArrayHofHelper(lowerer, result.callee)
: null;
if (bounded === null || result.kind !== "call")
const stmts: IrStmt[] = [{ kind: "varDecl", localId: local.id, init: receiver, loc }];
let bound: IrLocal | undefined;
const rebound = rebuildSpeciesCall(result, local.id, (inner) => {
const args: IrExpr[] = [receiverRef];
for (const arg of inner.args.slice(1)) {
const held = lowerer.declareHiddenLocal("%speciesArgument", arg.type);
stmts.push({ kind: "varDecl", localId: held.id, init: arg, loc });
args.push({ kind: "varRef", localId: held.id, type: arg.type, loc });
}
const length = lowerer.declareHiddenLocal("%speciesLength", F64);
bound = length;
args.push({ kind: "varRef", localId: length.id, type: F64, loc });
const helper = boundedArrayHofHelper(lowerer, inner.callee);
return helper === null ? null : { ...inner, callee: helper, args };
});
if (rebound === null || bound === undefined)
lowerer.unsupported(
"SC1090",
call,
`'${access.name.text}' in this form when the program declares a class that extends Array`,
);
const stmts: IrStmt[] = [{ kind: "varDecl", localId: local.id, init: receiver, loc }];
const args: IrExpr[] = [receiverRef];
for (const arg of result.args.slice(1)) {
const held = lowerer.declareHiddenLocal("%speciesArgument", arg.type);
stmts.push({ kind: "varDecl", localId: held.id, init: arg, loc });
args.push({ kind: "varRef", localId: held.id, type: arg.type, loc });
}
const bound = lowerer.declareHiddenLocal("%speciesLength", F64);
stmts.push({
kind: "varDecl",
localId: bound.id,
@@ -681,7 +682,6 @@ export function lowerWithArraySpecies(
loc,
});
const boundRef: IrExpr = { kind: "varRef", localId: bound.id, type: F64, loc };
args.push(boundRef);
const instance = lowerer.declareHiddenLocal("%speciesInstance", receiver.type);
const length: IrExpr = mode.resultLength
? boundRef
@@ -706,7 +706,7 @@ export function lowerWithArraySpecies(
fn: "array.speciesFinish",
args: [
{ kind: "varRef", localId: instance.id, type: receiver.type, loc },
{ ...result, callee: bounded, args },
rebound,
{ kind: "boolLit", value: mode.setLength, type: BOOL, loc },
],
type: result.type,
@@ -736,6 +736,31 @@ export function lowerWithArraySpecies(
};
}
/** `e` with the `%arr.<method>` helper call over `receiverLocal` rebuilt by
* `rebuild`, or null when `e` holds no such call. The helper call is the
* one taking the receiver as its first argument; a filter whose callback
* narrows converts the filtered array after the helper ran, so the call
* can sit behind a wrapper (the checked-narrowing call), which this
* rebuild leaves in place. */
function rebuildSpeciesCall(
e: IrExpr,
receiverLocal: string,
rebuild: (inner: IrExpr & { kind: "call" }) => IrExpr | null,
): IrExpr | null {
if (e.kind !== "call") return null;
const first = e.args[0];
if (first !== undefined && first.kind === "varRef" && first.localId === receiverLocal)
return rebuild(e);
for (let i = 0; i < e.args.length; i++) {
const replaced = rebuildSpeciesCall(e.args[i]!, receiverLocal, rebuild);
if (replaced === null) continue;
const args = e.args.slice();
args[i] = replaced;
return { ...e, args };
}
return null;
}
/** `C.of(...items)` and `C.from(iterable)` on a class declared `extends
* Array` that declares neither: Array.of / Array.from's elements, in an
* instance the class constructs (`new C(items.length)` for of, `new C()`
@@ -997,6 +997,42 @@ export function lowerFsWriteBytesCall(
return null;
}
// The writeFileSync(fd, data[, "utf8"]) form — Node accepts a file
// descriptor where it accepts a path (write to a descriptor openSync
// returned, the descriptor staying open: closeSync owns its lifetime).
// Routed by the ARGUMENT's static type, like readFileSync(fd).
export function lowerFsWriteDescriptorCall(
lowerer: Lowerer,
expr: ts.CallExpression,
loc: SrcLoc,
): IrExpr {
const supported = 'only string data is supported: writeFileSync(fd, data, "utf8")';
const dataNode = expr.arguments.nodes[1]!;
const dataIr = lowerer.mapTypeOf(lowerer.typeOf(dataNode));
if (dataIr?.kind !== "string") {
lowerer.noLowering(
`writeFileSync(fd) of '${dataIr ? lowerer.fmt(dataIr) : lowerer.checker.typeToString(lowerer.typeOf(dataNode))}' data`,
dataNode,
supported,
);
}
if (expr.arguments.nodes.length > 3) {
lowerer.noLowering(
`writeFileSync(fd) with ${expr.arguments.nodes.length} arguments`,
expr,
supported,
);
}
const fd = lowerer.lowerExprExpecting(expr.arguments.nodes[0]!, F64);
return {
kind: "libCall",
fn: "fs.writeFileSyncFd",
args: [fd, lowerer.lowerExprExpecting(dataNode, STRING)],
type: VOID,
loc,
};
}
export function lowerFsWriteOptionsCall(
lowerer: Lowerer,
expr: ts.CallExpression,
@@ -14,6 +14,7 @@ import {
lowerFsReadSyncCall,
lowerFsWriteSyncCall,
lowerFsReadDescriptorCall,
lowerFsWriteDescriptorCall,
lowerFsEncodedRead,
lowerFsPromisesEncodedRead,
lowerFsMkdirSyncCall,
@@ -331,6 +332,17 @@ export function lowerBuiltinModuleCall(
const lowered = lowerFsWriteBytesCall(lowerer, expr, bi, loc);
if (lowered) return lowered;
}
// writeFileSync(fd, data): Node accepts an open descriptor where it
// accepts a path. Routed by the FIRST ARGUMENT's static type, like
// readFileSync(fd) (before the path forms complete the data type).
if (
bi.module === "fs" &&
bi.member === "writeFileSync" &&
expr.arguments.nodes.length >= 2 &&
lowerer.mapTypeOf(lowerer.typeOf(expr.arguments.nodes[0]!))?.kind === "f64"
) {
return lowerFsWriteDescriptorCall(lowerer, expr, loc);
}
if (
bi.module === "fs" &&
(bi.member === "writeFileSync" || bi.member === "appendFileSync") &&
@@ -5,7 +5,11 @@ import * as ts from "../../ts7/adapter.js";
import type { Lowerer } from "../lowerer.js";
import { countedFor, varRef } from "../../../ir/build.js";
import { arrayValueStore, optionalElementValue, primitiveElement } from "../array-values.js";
import { maybePromiseResolve, maybePromiseUnionOf } from "../maybe-promise.js";
import {
maybePromiseResolve,
maybePromiseUnionOf,
promiseOrUnitUnionOf,
} from "../maybe-promise.js";
import { isJsSourceFile, locOf } from "../../program.js";
import {
BOOL,
@@ -670,11 +674,17 @@ export function lowerPromiseStaticCall(
if (v.type.kind !== "promise") lowerer.badType(argNode, argT);
return v;
}
const maybePromise = argIr ? maybePromiseUnionOf(lowerer, argIr) : null;
// A promise-or-unit union (`Promise<void> | void`,
// `Promise<T> | undefined`) resolves exactly like a maybe-promise
// one: Node adopts a promise operand and fulfills every other value
// as itself (a nullish arm fulfills undefined).
const maybePromise = argIr
? (maybePromiseUnionOf(lowerer, argIr) ?? promiseOrUnitUnionOf(lowerer, argIr))
: null;
if (maybePromise) {
const resultT = lowerer.mapTypeOf(lowerer.typeOf(call));
const v = lowerer.lowerExpr(argNode);
const mp = maybePromiseUnionOf(lowerer, v.type);
const mp = maybePromiseUnionOf(lowerer, v.type) ?? promiseOrUnitUnionOf(lowerer, v.type);
const resolved =
resultT?.kind === "promise" && mp
? maybePromiseResolve(lowerer, v, mp, resultT, loc)
@@ -1,9 +1,19 @@
import { staticFetchMembers } from "../../../compat/fetch-profile.js";
import { numLit } from "../../../ir/build.js";
import { BOOL, DYN, STRING, type IrExpr, type SrcLoc } from "../../../ir/ir.js";
import { everyStmt } from "../../../ir/traverse.js";
import {
BOOL,
DYN,
STRING,
VOID,
type IrExpr,
type IrFunction,
type SrcLoc,
} from "../../../ir/ir.js";
import * as ts from "../../ts7/adapter.js";
import { isJsSourceFile, locOf } from "../../program.js";
import { constituentTypes } from "../../ts7/checker.js";
import type { Lowerer } from "../lowerer.js";
import { ENTRY_NAME, type Lowerer } from "../lowerer.js";
import { stdlibGlobalNameOf } from "../surfaces.js";
import { streamWebImportOf } from "./module-bindings.js";
@@ -221,3 +231,98 @@ export function isWebStreamValueInJavaScript(lowerer: Lowerer, node: ts.Expressi
return true;
return typeInvolvesWebStream(lowerer, lowerer.typeOf(node));
}
/** Whether a checker type is the stdlib ReadableStream, or a union with it. */
function isReadableStreamType(lowerer: Lowerer, type: ts.Type): boolean {
if ((type.flags & ts.TypeFlags.Union) !== 0)
return constituentTypes(type).some((t) => isReadableStreamType(lowerer, t));
const symbol = type.getSymbol();
return (
symbol !== undefined &&
ts.symbolName(symbol) === "ReadableStream" &&
lowerer.isStdlibSymbol(symbol)
);
}
/** Whether a member access resolves to a member of the stdlib
* ReadableStream interface. */
function isReadableStreamMember(lowerer: Lowerer, access: ts.PropertyAccessExpression): boolean {
const member = lowerer.checker.getSymbolAtLocation(access.name);
if (member === undefined || !lowerer.isStdlibSymbol(member)) return false;
let owner: ts.Node | undefined = member.declarations[0]?.parent;
while (owner !== undefined && !ts.isInterfaceDeclaration(owner)) owner = owner.parent;
return owner !== undefined && owner.name.text === "ReadableStream";
}
/** Whether a checker type is any or unknown, which a fetch body can be. */
function isUntypedType(type: ts.Type): boolean {
return (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0;
}
/** Whether the program links the fetch unit (a fetch. entry point), whose
* Response and Request bodies can reach JavaScript as untyped values. */
function programLinksFetch(functions: IrFunction[]): boolean {
let found = false;
const visitor = {
expr: (e: IrExpr): boolean => {
if (e.kind === "libCall" && e.fn.startsWith("fetch.")) found = true;
return !found;
},
stmt: (): boolean => !found,
};
for (const fn of functions) {
for (const s of fn.body) everyStmt(s, visitor);
if (found) return true;
}
return false;
}
/** Whether a JavaScript source of the program uses a ReadableStream member
* that the fetch unit's own stream does not implement (the fetch profile's
* readableStreamUnsupported): a member access the checker resolves to the
* interface, or `for await` over a ReadableStream value. The same member
* names on an untyped receiver, or `for await` over an untyped value,
* count in a program that links the fetch unit, since the value can be a
* fetch body. */
function programUsesWebStreamOnlyMembers(lowerer: Lowerer, functions: IrFunction[]): boolean {
const members = staticFetchMembers().readableStreamUnsupported;
const iterates = members.has("[Symbol.asyncIterator]");
let untyped = false;
for (const file of lowerer.program.getSourceFiles()) {
if (file.isDeclarationFile || !isJsSourceFile(file)) continue;
let found = false;
ts.walkPreorder(file, (node) => {
let receiver: ts.Type | null = null;
if (ts.isPropertyAccessExpression(node) && members.has(node.name.text)) {
if (isReadableStreamMember(lowerer, node)) found = true;
else receiver = lowerer.typeOf(node.expression);
} else if (iterates && ts.isForOfStatement(node) && node.awaitModifier !== undefined) {
receiver = lowerer.typeOf(node.expression);
if (isReadableStreamType(lowerer, receiver)) found = true;
}
if (found) return "stop";
if (receiver !== null && isUntypedType(receiver)) untyped = true;
return undefined;
});
if (found) return true;
}
return untyped && programLinksFetch(functions);
}
/** Links the web stream classes into a program whose JavaScript uses a
* ReadableStream member the fetch unit's own stream lacks: a fetch body's
* handle stands for a web ReadableStream once such a member is used
* (scr_fetch.c, sf_stream_web_view). %main installs the classes first. A
* program that names one of the classes links them already. */
export function installFetchBodyWebStreams(lowerer: Lowerer, functions: IrFunction[]): void {
if (programNamesWebStreamClass(lowerer) || !programUsesWebStreamOnlyMembers(lowerer, functions))
return;
const main = functions.find((fn) => fn.name === ENTRY_NAME);
if (!main) return;
const loc = main.loc;
main.body.unshift({
kind: "exprStmt",
expr: { kind: "libCall", fn: "webstream.install", args: [], type: VOID, loc },
loc,
});
}
@@ -62,6 +62,22 @@ export function refuseX509DynValues(lowerer: Lowerer, functions: readonly IrFunc
)
return;
const seen = new Map<string, boolean>();
// A type is recursive when it reaches itself without naming a shape or a
// union: `type Rec = string | Rec[]` passes through the array, set, map
// or promise case below. Those kinds have no id to memoize by, so a type
// met again while it is still being visited answers false: what a cycle
// adds to `holds` (a certificate exists somewhere in the type) is
// reachability, and nothing outside the cycle makes it reachable.
const visiting = new Set<IrType>();
const descend = (t: IrType, visit: () => boolean): boolean => {
if (visiting.has(t)) return false;
visiting.add(t);
try {
return visit();
} finally {
visiting.delete(t);
}
};
const holds = (t: IrType): boolean => {
switch (t.kind) {
case "record": {
@@ -79,13 +95,24 @@ export function refuseX509DynValues(lowerer: Lowerer, functions: readonly IrFunc
}
case "array":
case "set":
return holds(t.elem);
return descend(t, () => holds(t.elem));
case "map":
return holds(t.key) || holds(t.value);
return descend(t, () => holds(t.key) || holds(t.value));
case "promise":
return holds(t.inner);
case "union":
return lowerer.unions.get(t.unionId)?.arms.some(holds) === true;
return descend(t, () => holds(t.inner));
case "union": {
// Unions have an id, so a cycle that names one is answered from the
// memo above; the identity guard covers a cycle that does not.
const key = `union:${t.unionId}`;
const known = seen.get(key);
if (known !== undefined) return known;
return descend(t, () => {
seen.set(key, false);
const result = lowerer.unions.get(t.unionId)?.arms.some(holds) === true;
seen.set(key, result);
return result;
});
}
default:
return false;
}
@@ -3382,10 +3382,13 @@ export function lowerTuplePredicateCall(
* of the receiver's length when it starts. ArraySpeciesCreate reads the
* receiver's length before the species constructor runs, so a constructor
* that appends to the receiver does not extend the loop
* (lowerWithArraySpecies). Null when `name` is no such helper. */
* (lowerWithArraySpecies). Every helper that reads a length is one, the
* flatMap whose callback returns a union included, whether or not a
* pipeline can fuse it (arrHofInfo holds the fusable ones); forEach makes
* no array, so it stays out. Null when `name` is no such helper. */
export function boundedArrayHofHelper(lowerer: Lowerer, name: string): string | null {
const info = lowerer.arrHofInfo.get(name);
if (!info || info.method === "forEach") return null;
if (!name.startsWith("%arr.")) return null;
if (lowerer.arrHofInfo.get(name)?.method === "forEach") return null;
const bounded = `${name}.bounded`;
if (lowerer.liftedFnNamed(bounded)) return bounded;
const fn = lowerer.liftedFnNamed(name);
@@ -4,6 +4,7 @@ import { elementMayBeForeign, foreignElementThrow } from "../array-values.js";
import { countedFor, numLit, varRef } from "../../../ir/build.js";
import {
BOOL,
DYN,
F64,
arrayOf,
typeEquals,
@@ -19,15 +20,18 @@ import type { Lowerer } from "../lowerer.js";
/** Copy into fresh storage after every argument has evaluated. Array
* arguments retain their own element ABI; only copied values are widened.
* Missing slots stay holes and present undefined stays present. */
* Missing slots stay holes and present undefined stays present. A shape
* entry is an array argument's element type, null for an element value, or
* "dyn" for a checked-dynamic argument, which JS spreads when it is an
* array at run time (IsConcatSpreadable) and appends otherwise. */
export function arrayConcatHelper(
lowerer: Lowerer,
elem: IrType,
shape: (IrType | null)[],
shape: (IrType | null | "dyn")[],
node: ts.CallExpression,
loc: SrcLoc,
): string {
const key = `concat:${typeKey(elem)}:${shape.map((type) => (type ? `a:${typeKey(type)}` : "e")).join(";")}`;
const key = `concat:${typeKey(elem)}:${shape.map((type) => (type === "dyn" ? "d" : type ? `a:${typeKey(type)}` : "e")).join(";")}`;
const existing = lowerer.arrHofHelpers.get(key);
if (existing) return existing;
const name = keyedHelperName(lowerer, "%arr.concat", key);
@@ -37,7 +41,7 @@ export function arrayConcatHelper(
...shape.map((type, i) => ({
localId: `x.${i}`,
name: `x${i}`,
type: type ? arrayOf(type) : elem,
type: type === "dyn" ? DYN : type ? arrayOf(type) : elem,
})),
];
const locals: IrLocal[] = params.map((p) => ({
@@ -173,6 +177,43 @@ export function arrayConcatHelper(
},
];
};
// A checked-dynamic argument: an array spreads its elements, converted
// to the element type; any other value is appended as one element.
const appendDyn = (source: IrExpr, site: ts.Node): IrStmt[] => [
{
kind: "if",
cond: { kind: "dynTest", test: "array", value: source, type: BOOL, loc },
then: [
{
kind: "exprStmt",
expr: {
kind: "arrIntrinsic",
method: "concatSpread",
receiver: out,
args: [lowerer.coerceInto(site, source, arrT)],
type: F64,
loc,
},
loc,
},
],
else_: [
{
kind: "exprStmt",
expr: {
kind: "arrIntrinsic",
method: "push",
receiver: out,
args: [lowerer.coerceInto(site, source, elem)],
type: F64,
loc,
},
loc,
},
],
loc,
},
];
const body: IrStmt[] = [
{
kind: "varDecl",
@@ -182,11 +223,17 @@ export function arrayConcatHelper(
},
...append(varRef("a.0", arrT, loc), elem, node.expression),
...shape.flatMap((type, i) =>
append(varRef(`x.${i}`, type ? arrayOf(type) : elem, loc), type, node.arguments.nodes[i]!),
type === "dyn"
? appendDyn(varRef(`x.${i}`, DYN, loc), node.arguments.nodes[i]!)
: append(
varRef(`x.${i}`, type ? arrayOf(type) : elem, loc),
type,
node.arguments.nodes[i]!,
),
),
{ kind: "return", value: out, loc },
];
if (shape.some((type) => type && !typeEquals(type, elem))) {
if (shape.some((type) => type !== null && type !== "dyn" && !typeEquals(type, elem))) {
for (const id of ["i.0", "offset.0", "count.0"])
locals.push({ id, name: id, type: F64, mutable: true });
}
@@ -1158,6 +1158,37 @@ export function lowerArrayFromCall(
loc,
};
}
// `Array.from(typedArray, mapper)`: the element read is live (a
// mapper that writes the source changes later reads, as in Node),
// so this is a loop over the typed array, not a toArray snapshot.
if (src.type.kind === "bytes" && args.length === 2) {
const callback = lowerArrayCallback(lowerer, args[1]!, [F64], arrayOf(F64));
if (callback.arity > 2)
lowerer.noLowering("Array.from mapper with an array parameter", args[1]!);
const fnRet = callback.fnArg.type.ret;
if (fnRet.kind === "void" || fnRet.kind === "func")
lowerer.badType(call, lowerer.typeOf(call));
requireProducedArrayElement(lowerer, call, "'Array.from(typedArray, mapper)'", fnRet);
const outElem = callbackArrayElement(lowerer, call, fnRet);
// The element kind rides in the key because the lifted helper's
// source parameter carries it (the reads themselves are always f64).
const key = `fromBytes:${src.type.elem}:${typeKey(outElem)}:${typeKey(fnRet)}:${callback.arity}`;
let helper = lowerer.arrHofHelpers.get(key);
if (!helper) {
helper = keyedHelperName(lowerer, "%arr.fromBytes", key);
lowerer.arrHofHelpers.set(key, helper);
lowerer.liftedFns.push(
buildArrayFromBytesFn(lowerer, helper, src.type, outElem, fnRet, callback.arity, loc),
);
}
return {
kind: "call",
callee: helper,
args: [src, callback.fnArg],
type: arrayOf(outElem),
loc,
};
}
// A union of typed arrays and number arrays (indexed-unions.ts).
const indexedArms = indexedUnionArms(lowerer, src.type);
if (indexedArms && args.length === 1)
@@ -1320,6 +1351,96 @@ function buildArrayFromArrayFn(
};
}
/** The `Array.from(typedArray, mapper)` generation loop: every index is
* read through the typed array itself (live, like Node's iteration), so
* a mapper that writes the source is visible to the indexes it has not
* visited yet. */
function buildArrayFromBytesFn(
lowerer: Lowerer,
name: string,
bytesT: IrType & { kind: "bytes" },
outElem: IrType,
fnRet: IrType,
arity: number,
loc: SrcLoc,
): IrFunction {
const outT = arrayOf(outElem);
const fnT = funcOf([F64, F64].slice(0, arity), fnRet);
const a = varRef("a.0", bytesT, loc);
const i = varRef("i.0", F64, loc);
const out = varRef("out.0", outT, loc);
// The index parameter is the second one, never read for an index the
// element covers, so an element-only mapper drops it.
const reads: IrExpr[] = [
{ kind: "bytesIntrinsic", method: "get", receiver: a, args: [i], type: F64, loc },
i,
];
const mapped: IrExpr = {
kind: "callValue",
callee: varRef("f.0", fnT, loc),
args: reads.slice(0, arity),
type: fnRet,
loc,
};
const held = !typeEquals(fnRet, outElem);
const value: IrExpr = held ? varRef("v.0", fnRet, loc) : mapped;
return {
name,
params: [
{ localId: "a.0", name: "a", type: bytesT },
{ localId: "f.0", name: "f", type: fnT },
],
returnType: outT,
locals: [
{ id: "a.0", name: "a", type: bytesT, mutable: true },
{ id: "f.0", name: "f", type: fnT, mutable: true },
{ id: "out.0", name: "out", type: outT, mutable: false },
{ id: "i.0", name: "i", type: F64, mutable: true },
...(held ? [{ id: "v.0", name: "v", type: fnRet, mutable: false }] : []),
],
body: [
{
kind: "varDecl",
localId: "out.0",
init: { kind: "arrayLit", elems: [], type: outT, loc },
loc,
},
{ kind: "varDecl", localId: "i.0", init: numLit(0, loc), loc },
{
kind: "while",
cond: {
kind: "bin",
op: "<",
left: i,
right: {
kind: "bytesIntrinsic",
method: "length",
receiver: a,
args: [],
type: F64,
loc,
},
type: BOOL,
loc,
},
body: [
...(held ? [{ kind: "varDecl", localId: "v.0", init: mapped, loc } as const] : []),
arrayValueStore(lowerer, out, i, value, outElem, loc),
{
kind: "assign",
localId: "i.0",
value: { kind: "bin", op: "+", left: i, right: numLit(1, loc), type: F64, loc },
loc,
},
],
loc,
},
{ kind: "return", value: out, loc },
],
loc,
};
}
/** `Array.from(s)` / `[...s]` on a STRING: the code-point split into a
* fresh string[] (scr_str_code_points). */
export function strCharsCall(src: IrExpr, loc: SrcLoc): IrExpr {
@@ -998,7 +998,7 @@ export function lowerArrayMethodCall(
// an array-of-arrays receiver given a bare inner array, where TS
// types it as an element but JS would SPREAD it — is fenced.
const receiver = lowerer.lowerExpr(access.expression);
const shape: (IrType | null)[] = [];
const shape: (IrType | null | "dyn")[] = [];
const args: IrExpr[] = [];
for (const argNode of call.arguments.nodes) {
if (ts.isSpreadElement(argNode)) {
@@ -1032,6 +1032,16 @@ export function lowerArrayMethodCall(
args.push(arg);
continue;
}
// A checked-dynamic value: whether JS spreads it is decided at run
// time (an array spreads one level, anything else is one element).
if (
(argIr === null || argIr.kind === "dyn") &&
(probedArg ?? tryLowerExpression(lowerer, argNode))?.type.kind === "dyn"
) {
shape.push("dyn");
args.push(lowerer.lowerExpr(argNode));
continue;
}
// An element value — union elements wrap exactly like a push.
shape.push(null);
args.push(lowerer.lowerExprExpecting(argNode, elem));
@@ -2368,6 +2368,33 @@ export function lowerStrictEquality(
negated: boolean,
loc: SrcLoc,
): IrExpr {
// JavaScript: a checked-dynamic name the checker's flow analysis narrowed
// (`let c; ... (c = s[i]) === " "`) is compared unchecked. Strict
// equality never throws, and the flow type is the checker's inference
// (an indexed read past the end is undefined whatever it says).
const flowNarrowed = (n: ts.Node, e: IrExpr): IrExpr | null =>
ts.isIdentifier(n) &&
e.kind === "dynCheck" &&
e.value.type.kind === "dyn" &&
isJsSourceFile(ts.getSourceFileOfNode(n))
? e.value
: null;
const scalar = (e: IrExpr): boolean =>
e.type.kind === "string" || e.type.kind === "f64" || e.type.kind === "bool";
{
const l = flowNarrowed(leftNode, left);
const r = flowNarrowed(rightNode, right);
if ((l !== null && (scalar(right) || r !== null)) || (r !== null && scalar(left))) {
const cmp: IrExpr = {
kind: "dynScalarEq",
left: l ?? lowerer.coerceInto(leftNode, left, DYN),
right: r ?? lowerer.coerceInto(rightNode, right, DYN),
type: BOOL,
loc,
};
return negated ? { kind: "unary", op: "!", operand: cmp, type: BOOL, loc } : cmp;
}
}
const plainBothNum = left.type.kind === "f64" && right.type.kind === "f64";
const bothStr = left.type.kind === "string" && right.type.kind === "string";
const indexed = tryLowerIndexedComparison(lowerer, left, right, negated, loc);
@@ -66,6 +66,7 @@ import {
lowerSafeIndexRead,
primitiveElement,
unionArrayValueRead,
unaffectedByIndex,
} from "../array-values.js";
import { lowerOptionalStringIndex, lowerUnionStringIndex } from "../string-index.js";
import { InternalCompilerError } from "../../../errors.js";
@@ -2475,14 +2476,35 @@ export function lowerElementWrite(lowerer: Lowerer, expr: ts.BinaryExpression):
(value.type.kind === "union" && lowerer.armTag(value.type.unionId, UNDEFINED_T) >= 0)
? arrayValueType(lowerer, arr.type.elem)
: arr.type.elem;
return arrayValueStore(
lowerer,
arr,
index,
lowerer.coerceInto(expr.right, value, expected),
arr.type.elem,
locOf(expr),
);
const stored = lowerer.coerceInto(expr.right, value, expected);
// A binding's `T | undefined` value is stored by two writes that both
// read the index (arrayValueStore). A computed index, such as the key
// conversion of a union index that declares a temporary, is held once
// ahead of them. The stores then read the receiver after the index, so
// an index that can rebind the receiver (`a[(a = b, 0)] = u`) keeps the
// stores as they are, reading the receiver first.
if (
stored.kind === "varRef" &&
stored.type.kind === "union" &&
lowerer.armTag(stored.type.unionId, UNDEFINED_T) >= 0 &&
!typeEquals(stored.type, arr.type.elem) &&
arr.kind === "varRef" &&
index.kind !== "varRef" &&
index.kind !== "numLit" &&
unaffectedByIndex(lowerer, arr, index)
) {
const loc = locOf(expr);
const held = lowerer.declareHiddenLocal("%arrayIndex", index.type);
return {
kind: "block",
body: [
{ kind: "varDecl", localId: held.id, init: index, loc },
arrayValueStore(lowerer, arr, varRef(held.id, held.type, loc), stored, arr.type.elem, loc),
],
loc,
};
}
return arrayValueStore(lowerer, arr, index, stored, arr.type.elem, locOf(expr));
}
/** Environment property names use ToPrimitive with the string hint, so
@@ -529,6 +529,27 @@ export function maybeNarrow(lowerer: Lowerer, expr: IrExpr, node: ts.Node): IrEx
)
)
return expr;
// A `let c;` binding without a type takes its flow types from what
// the code assigns, and the checker's types for those values can omit
// undefined (`c = s[i]` past the end): reads keep the actual value.
if (
symbol &&
symbol.declarations.some(
(decl) =>
ts.isVariableDeclaration(decl) &&
decl.initializer === undefined &&
decl.type === undefined &&
ts.isIdentifier(decl.name) &&
decl.parent?.parent !== undefined &&
ts.isVariableStatement(decl.parent.parent) &&
!/@type\b/.test(
ts
.getSourceFileOfNode(decl)
.text.slice(decl.parent?.parent?.pos ?? decl.pos, ts.tokenStartOfNode(decl)),
),
)
)
return expr;
if (
symbol &&
symbol.declarations.some(
@@ -3592,11 +3592,12 @@ export function lowerUnionProperty(
* message. The caller (the property/element dispatch) maybeNarrows. */
export function lowerUnionKeyedRead(
lowerer: Lowerer,
expr: ts.Expression,
expr: ts.Node,
unionId: string,
value: IrExpr,
key: IrExpr,
literalField: string | null,
missingFieldAsUndefined = false,
): IrExpr | null {
const def = lowerer.unions.get(unionId);
if (!def) return null;
@@ -3646,6 +3647,14 @@ export function lowerUnionKeyedRead(
}
if (!shape.indexValue) {
if (literalField === null && shape.fields.length > 0) continue;
// A record arm that does not declare the key at all answers
// UNDEFINED in JS. Destructuring asks for that answer (its own
// desugar reads a missing property as undefined, Node-exact); a
// dotted read keeps refusing until narrowing proves the arm.
if (literalField !== null && missingFieldAsUndefined) {
push(UNDEFINED_T);
continue;
}
return null;
}
if (!pushAnswer(shape.indexValue)) return null;
@@ -3690,6 +3699,12 @@ export function lowerUnionKeyedRead(
if (!surfaces(declared)) return null;
continue;
}
if (literalField !== null && !shape.indexValue && missingFieldAsUndefined) {
// The missing-field answer above must surface as the join's
// undefined arm (the backend's keyed-read miss path stores it).
if (!(type.kind === "union" && lowerer.armTag(type.unionId, UNDEFINED_T) >= 0)) return null;
continue;
}
const ovfShape: IrRecordShape =
literalField !== null && shape.indexValue ? { ...shape, fields: [] } : shape;
if (!recordKeyResultOk(lowerer, ovfShape, type)) return null;
@@ -12,6 +12,9 @@ export interface FnCtx {
/** Lexical this has no checker symbol and one binding per function.
* Direct parameters and threaded captures use this same slot. */
thisLocal: IrLocal | null;
/** Set when a lookup of `this` returned thisLocal, from this function's
* body or from a nested function that captures it. */
thisRead?: true;
/** Derived constructors initialize fields at the actual super() site,
* including sites nested in a try/finally block. Nested functions do not
* inherit this hook. */
@@ -135,7 +138,10 @@ export function bindingInContext(
ctx: FnCtx,
symbol: ts.Symbol | "arguments" | undefined,
): IrLocal | null {
if (symbol === undefined) return ctx.thisLocal;
if (symbol === undefined) {
if (ctx.thisLocal !== null) ctx.thisRead = true;
return ctx.thisLocal;
}
if (symbol === "arguments") return ctx.argumentsLocal ?? null;
for (let i = ctx.scopes.length - 1; i >= 0; i--) {
const local = ctx.scopes[i]!.get(symbol);
@@ -279,7 +279,11 @@ import { isThisParameter, type ParamShape } from "./call-signatures.js";
import { dynamicOverloadChoice } from "./overload-choice.js";
import { lowerStringIteratorValue } from "./iterator-adapters.js";
import { checkedArmExtraction } from "./coercions/unions.js";
import { maybePromiseReturnStmt, promiseArmedUnionOf } from "./maybe-promise.js";
import {
maybePromiseReturnStmt,
promiseArmedUnionOf,
promiseOrUnitUnionOf,
} from "./maybe-promise.js";
import {
requireObjLitGenericReceiver,
objLitGenericFnNodeOf,
@@ -4888,9 +4892,10 @@ const DYN_STRING_ONLY_METHODS = new Set([
/** `Array.isArray(v)` — a real runtime test on `unknown` values (the checked-dynamic tree's
* array kind: dyn arrays answer true, bytes/objects/scalars false — exactly
* JS, Uint8Array included), a compile-time constant on statically-typed
* ones (an `T[]` value IS an array, every other static kind is not; folded
* only over side-effect-free reads, the `in`-operator discipline; unions
* fence with the narrow-first hint). Null when the callee isn't THE
* ones (an `T[]` value IS an array, every other static kind is not; a
* computed argument is evaluated and its value discarded, the
* `in`-operator discipline; unions fence with the narrow-first hint). Null
* when the callee isn't THE
* stdlib Array.isArray, so the chain keeps trying. */
function lowerArrayIsArrayCall(
lowerer: Lowerer,
@@ -4960,14 +4965,23 @@ function lowerArrayIsArrayCall(
);
}
if (arg.type.kind === "caught") return null;
if (arg.kind === "varRef" || arg.kind === "recordGet" || arg.kind === "fieldGet") {
return { kind: "boolLit", value: lowerer.isArrayValueType(arg.type), type: BOOL, loc };
}
lowerer.unsupported(
"SC1090",
call,
"statically-decided Array.isArray on computed arguments (bind the value to a variable first)",
);
const folded: IrExpr = {
kind: "boolLit",
value: lowerer.isArrayValueType(arg.type),
type: BOOL,
loc,
};
if (arg.kind === "varRef" || arg.kind === "recordGet" || arg.kind === "fieldGet") return folded;
// A computed argument (`Array.isArray(make())`, `Array.isArray([1, 2])`)
// still answers from its static type, so evaluate the argument for its
// effects and discard the value.
return {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: arg, loc }],
result: folded,
type: BOOL,
loc,
};
}
/** Predicate declarations currently being inlined — re-entrancy guard
@@ -5998,9 +6012,19 @@ export function lowerPromiseMethodCall(
call: ts.CallExpression,
access: ts.PropertyAccessExpression,
): IrExpr | null {
if (call.questionDotToken || access.questionDotToken) return null;
if (lowerer.chainBlocked(access, call)) return null;
const member = access.name.text;
if (member !== "then" && member !== "catch" && member !== "finally") return null;
// A call that ENDS an optional chain (`refreshing?.catch(h)`,
// `client?.close().catch(h)`) checker-types as the chain's
// undefined-armed union while the body this lowering builds is the
// promise arm — `finishOptionalChain` wraps it back into the union.
// Take the arm so the handler typing sees the promise it handles.
const callResultType = (): IrType | undefined => {
const t = lowerer.mapTypeOf(lowerer.typeOf(call));
if (!t || t.kind === "promise") return t ?? undefined;
return promiseOrUnitUnionOf(lowerer, t)?.promise ?? t;
};
let recvT =
lowerer.mapTypeOf(lowerer.typeOf(access.expression)) ??
dynFallbackType(lowerer, access.expression, lowerer.typeOf(access.expression));
@@ -6117,7 +6141,7 @@ export function lowerPromiseMethodCall(
const rejectedNode = call.arguments.nodes[1]!;
const fulfilledAbsent = isAbsentHandler(fulfilledNode);
const rejectedAbsent = isAbsentHandler(rejectedNode);
const resultT = lowerer.mapTypeOf(lowerer.typeOf(call));
const resultT = callResultType();
// An untyped call in JavaScript whose receiver arrived as a typed
// promise (a function instantiated with one): Promise.prototype.then
// over the promise as a checked-dynamic value, which shares its
@@ -6516,7 +6540,7 @@ export function lowerPromiseMethodCall(
`then handlers whose parameter is not the settled value's type (expected '${lowerer.fmt(inner)}', got '${lowerer.fmt(param)}')`,
);
}
let resultT = lowerer.mapTypeOf(lowerer.typeOf(call));
let resultT = callResultType();
// JavaScript whose declared result type has no mapping (zod's
// `Promise.all(items).then((r) => ParseStatus.mergeArray(status, r))`,
// typed by a .d.ts union): the handler's value settles the promise as
@@ -6864,7 +6888,7 @@ export function lowerPromiseMethodCall(
"value — take `(e)` or `(e: unknown)` and narrow with instanceof)",
);
}
const resultT = lowerer.mapTypeOf(lowerer.typeOf(call));
const resultT = callResultType();
if (resultT?.kind !== "promise") {
lowerer.noLowering(
"catch with this handler's result type",
@@ -6908,7 +6932,10 @@ export function lowerPromiseMethodCall(
if (ts.isBlock(hb)) {
catchBody = lowerer.lowerStmts(hb.statements.nodes);
} else if (R.kind === "void") {
catchBody = [{ kind: "exprStmt", expr: lowerer.lowerExpr(hb), loc: locOf(hb) }];
// The handler's value is discarded, so the body lowers as a
// statement: `() => undefined` is a pure no-op (a bare unit
// literal has no IR value), not an expression to evaluate.
catchBody = [lowerer.lowerExprStatement(hb)];
} else {
// Bare-expression handler: `(e) => v` (promise results flatten
// through the async-return path, like any `return v`).
@@ -125,6 +125,8 @@ import {
requireSpecOf,
} from "../program.js";
import { PoisonError, dynFallbackType, jsFuncNameOf, newFnCtx, own } from "./lowerer.js";
import { islandValueOrigin } from "../node-api-import.js";
import { nativeSubclassOfIslandDiag } from "../../diagnostics/diagnostic.js";
import { declarationGroupKey } from "./type-failure.js";
import { isAssertClassNew } from "./assert-instances.js";
import { isCjsRequireGlobal } from "./lower-node-module.js";
@@ -2212,6 +2214,10 @@ export function collectClassShapeInner(
continue;
}
}
if (t && islandValueOrigin(lowerer.program, t.expression)) {
lowerer.pushDiag(nativeSubclassOfIslandDiag(locOf(t)));
throw new PoisonError();
}
if (!t || !ts.isIdentifier(t.expression)) {
if (t && adoptCallableBase(t.expression)) continue;
lowerer.unsupported("SC1090", clause, "extending computed expressions");
@@ -27,7 +27,11 @@ import { checkedPrivateReceiver, localPrivateBrandClass } from "./private-brands
import { staticCondition } from "./static-typeof.js";
import { constituentTypes } from "../ts7/checker.js";
import type { Lowerer } from "./lowerer.js";
import { lowerMaybePromiseAwait, maybePromiseUnionOf } from "./maybe-promise.js";
import {
lowerMaybePromiseAwait,
maybePromiseUnionOf,
promiseArmedUnionOf,
} from "./maybe-promise.js";
import { checkedClassUnionAssertion } from "./class-assertions.js";
import {
BIGINT_T,
@@ -676,9 +680,23 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr {
loc,
};
}
if (inner.kind === "union" || inner.kind === "dyn") {
// A union inner would need an arm-wise re-tag into the result
if (inner.kind === "union") {
// A UNION inner (`Promise<A | B> | undefined`): the awaited
// result is the checker's own `Awaited` of the operand, which
// the maybe-promise lowering already produces — it awaits the
// narrowed promise arm and coerces its settled union, and the
// unit arms keep JS's one-microtask hop. Only the promise-or-
// unit shape reaches here, so the maybe-promise union is the
// same union with its only data arms being units.
const armed = promiseArmedUnionOf(lowerer, value.type);
if (armed) return lowerMaybePromiseAwait(lowerer, expr, value, armed, loc);
lowerer.unsupported(
"SC1090",
expr,
`awaiting '${lowerer.fmt(value.type)}' (the promise's inner type has no combined result union — await the promise arm after narrowing instead)`,
);
}
if (inner.kind === "dyn") {
lowerer.unsupported(
"SC1090",
expr,
@@ -4,7 +4,7 @@ import { declareArgumentCount } from "./argument-counts.js";
import { dynUndefinedExpr, varRef } from "../../ir/build.js";
import { everyStmtList, transformStmtList } from "../../ir/traverse.js";
import { bindingNeverReassigned } from "./binding-analysis.js";
import { declareContextLocal } from "./function-context.js";
import { declareContextLocal, type FnCtx } from "./function-context.js";
import { isErrorFirstErrorType } from "./builtins/arguments.js";
import { InternalCompilerError } from "../../errors.js";
import * as ts from "../ts7/adapter.js";
@@ -76,6 +76,19 @@ function declareCallReceiver(
};
}
/** Remove the receiver declaration when no lookup of `this` reached it:
* the parameters, the body and nested arrows never read it, so the entry
* does not read the call-site receiver. */
function dropUnreadReceiver(ctx: FnCtx, prologue: IrStmt[], receiver: IrStmt | null): void {
if (receiver === null || receiver.kind !== "varDecl" || ctx.thisRead) return;
const at = prologue.indexOf(receiver);
if (at < 0) return;
prologue.splice(at, 1);
const local = ctx.locals.findIndex((l) => l.id === receiver.localId);
if (local >= 0) ctx.locals.splice(local, 1);
ctx.thisLocal = null;
}
/** True when the identifier resolves (through import aliases) to a
* top-level function declaration of ANY program file (not merely a
* same-named local shadowing one). Functions declared directly in a
@@ -502,6 +515,7 @@ export function lowerLambda(
funcType = { ...funcType, ret: bodyReturn };
fnCtx.returnType = bodyReturn;
}
dropUnreadReceiver(fnCtx, prologue, receiver);
body = [...prologue, ...body];
// Bare-expression bodies never pass through lowerStmts, so the
// lib-boundary chokepoint runs here (idempotent for block bodies,
@@ -624,12 +638,11 @@ export function lowerFunction(lowerer: Lowerer, decl: ts.FunctionDeclaration): I
}
const undefinedResult = undefinedResultParam(lowerer, decl, sig.params, false);
const lowerBody = (): IrStmt[] => lowerer.lowerStmts(bodyBlock.statements.nodes);
const body = [
...prologue,
...(undefinedResult
? lowerUndefinedResultBody(lowerer, decl, undefinedResult, lowerBody)
: lowerBody()),
];
const statements = undefinedResult
? lowerUndefinedResultBody(lowerer, decl, undefinedResult, lowerBody)
: lowerBody();
dropUnreadReceiver(ctx, prologue, receiver);
const body = [...prologue, ...statements];
appendImplicitUndefinedReturn(lowerer, body, bodyReturn, locOf(decl));
const fn: IrFunction = {
name: sig.name,
@@ -16,6 +16,11 @@ import * as ts from "../ts7/adapter.js";
import { collectRebindableFunctions, isRebindableFunction } from "./rebindable-functions.js";
import { bindingSource } from "../binding-source.js";
import type { Lowerer } from "./lowerer.js";
import {
sharedInitFlagStores,
sharedModulePlans,
sharedModuleRegistrations,
} from "./lower-shared-modules.js";
import { dirname as dirnamePath, resolve as resolvePath } from "node:path";
import { packageNameOfPath, probeNodeImportRefusal } from "../npm.js";
import { isNpmStaticPackage } from "../npm-static.js";
@@ -3120,6 +3125,10 @@ export function lowerFileInit(
});
}
if (sf === lowerer.entry) header.push(...lowerer.punycodeLinkWarning());
// Modules shared with Node islands register before any module runs
// (NI-6); the plan also creates their declarations' TDZ flags.
if (sharedModulePlans(lowerer).length > 0 && sf === lowerer.entry)
header.push(...sharedModuleRegistrations(lowerer));
// A module that reads builtin globals through the global object, by
// computed names or through a sloppy-mode function's `this`, installs
// the program's lookup before its body runs.
@@ -3381,6 +3390,7 @@ export function lowerFileInit(
stmt,
deferred ? [deferred] : lowerer.lowerStmts([stmt]),
);
if (lowerer.sharedInitFlags.size > 0) stmtIr.push(...sharedInitFlagStores(lowerer, stmt));
// Class EXPRESSIONS inside this statement queued their static
// inits while it lowered: they land immediately before it — JS's
// order for the supported whole-initializer positions.
@@ -35,6 +35,8 @@ import {
type SrcLoc,
} from "../../ir/ir.js";
import { varRef } from "../../ir/build.js";
import { classMethodValue } from "./class-method-values.js";
import { funcTypeFromParamShapes } from "./call-signatures.js";
import { resolveBareModule } from "../resolve.js";
import { nodeIslandBoundaryDiag, nodeIslandModuleDiag } from "../../diagnostics/diagnostic.js";
import {
@@ -334,6 +336,16 @@ function islandLoadPath(lowerer: Lowerer, path: string, loc: SrcLoc): IrExpr {
return literal;
}
/** The path literal of a module the program shares with its islands
* (lower-shared-modules.ts), named by its path in the shipped tree like a
* load's. */
export function islandSharedPath(lowerer: Lowerer, path: string, loc: SrcLoc): IrExpr {
const literal: IrExpr & { kind: "strLit" } = { kind: "strLit", value: path, type: STRING, loc };
lowerer.nodeIslandModules.addProvided(path);
lowerer.nodeIslandLoads.push({ literal, path });
return literal;
}
function loadIslandModule(lowerer: Lowerer, path: string, loc: SrcLoc): IrExpr {
return {
kind: "libCall",
@@ -344,6 +356,20 @@ function loadIslandModule(lowerer: Lowerer, path: string, loc: SrcLoc): IrExpr {
};
}
/** A static import's load: `nodeIsland.defer(path, snapshot)`, which the
* build decides after lowering (node-islands/snapshot.ts, scope-B NI-7):
* a module proved effect-free at load keeps it with its interface snapshot
* and evaluates on first use; any other becomes nodeIsland.require. */
function deferIslandModule(lowerer: Lowerer, path: string, loc: SrcLoc): IrExpr {
return {
kind: "libCall",
fn: "nodeIsland.defer",
args: [islandLoadPath(lowerer, path, loc), { kind: "strLit", value: "", type: STRING, loc }],
type: DYN,
loc,
};
}
/** A static import (or re-export) of an island package: loads the module
* in the importer's %init prelude. Returns false when `spec` is not an
* island module. */
@@ -371,7 +397,7 @@ export function collectIslandImport(
actions.push({
kind: "assign",
localId: binding.global.id,
value: loadIslandModule(lowerer, path, loc),
value: deferIslandModule(lowerer, path, loc),
loc,
});
// CommonJS named imports: Node's namespace snapshots them once the
@@ -528,6 +554,35 @@ function fromIsland(lowerer: Lowerer, raw: IrExpr, node: ts.Expression): IrExpr
};
return mapped.inner.kind === "dyn" ? promise : lowerer.coerceToExpected(promise, mapped);
}
// Arrays of copied values (or arrays of those) cross as copies of the
// island array, directly or as an awaited promise's value; the checked
// cast then meets the copy. A native array that crossed in comes back as
// itself.
if (mapped.kind === "promise" && copiedArrayType(lowerer, mapped.inner)) {
const promise: IrExpr = {
kind: "libCall",
fn: "nodeIsland.awaitCopy",
args: [raw],
type: { kind: "promise", inner: DYN },
loc: raw.loc,
};
return lowerer.coerceToExpected(promise, mapped);
}
if (copiedArrayType(lowerer, mapped)) {
const copy: IrExpr = {
kind: "libCall",
fn: "nodeIsland.copyArrays",
args: [raw],
type: DYN,
loc: raw.loc,
};
return lowerer.coerceToExpected(copy, mapped);
}
// NI-5: a native value the program passed in comes back as itself (an
// export); the checked cast unboxes it, and anything else fails the cast
// (types don't lie).
if (!copiedType(lowerer, mapped) && exportedByReference(lowerer, tsType))
return lowerer.coerceToExpected(raw, mapped);
if (!copiedType(lowerer, mapped))
boundaryFail(
lowerer,
@@ -537,6 +592,12 @@ function fromIsland(lowerer: Lowerer, raw: IrExpr, node: ts.Expression): IrExpr
return lowerer.coerceToExpected(raw, mapped);
}
/** An array whose elements are copied values or such arrays. */
function copiedArrayType(lowerer: Lowerer, t: IrType): boolean {
if (t.kind !== "array") return false;
return copiedType(lowerer, t.elem) || copiedArrayType(lowerer, t.elem);
}
/** Whether a value of IR type `t` is copied at the boundary: primitives,
* strings, null/undefined, and unions of those. */
function copiedType(lowerer: Lowerer, t: IrType): boolean {
@@ -589,7 +650,20 @@ function toIsland(lowerer: Lowerer, node: ts.Expression): IrExpr {
case "handle":
return lowerer.coerceToExpected(lowerer.lowerExpr(node), DYN);
case "dyn":
// NI-5: the runtime exports dyn objects by reference and copies only
// primitives, strings and (for now) arrays.
return lowerer.coerceToExpected(lowerer.lowerExpr(node), DYN);
case "refused":
// NI-5: closures, promises, records and class instances cross by
// reference (scr_node_island_export.c): their dyn boxes keep the
// native referent, and the closure's thunk checks its arguments.
if (exportedByReference(lowerer, lowerer.typeOf(node))) {
const lowered = lowerer.lowerExpr(node);
const value = lowerer.coerceToExpected(lowered, DYN);
return lowered.type.kind === "object"
? withClassMethods(lowerer, value, lowered.type.className, node)
: value;
}
return boundaryFail(
lowerer,
`a '${lowerer.checker.typeToString(lowerer.typeOf(node))}' value (a native object would cross as a copy and lose its identity)`,
@@ -598,6 +672,175 @@ function toIsland(lowerer: Lowerer, node: ts.Expression): IrExpr {
}
}
/** Whether an island reads the lowered method `name` by that name: a
* public, string-keyed prototype method. The constructor, #private methods,
* accessor halves (`get:x`, `set:x`) and symbol-keyed slots (`sym:iterator`)
* are not properties an island can name. */
function islandMethodName(name: string): boolean {
return name !== "constructor" && !name.startsWith("#") && !/^(?:get|set|sym):/.test(name);
}
/** A class instance crossing into an island: its capsule answers island
* reads of its fields, and its prototype methods come from a table the
* runtime builds once per class (nodeIsland.classMethods): the unbound
* method values, whose thunks check the receiver Node calls them on. */
function withClassMethods(
lowerer: Lowerer,
value: IrExpr,
className: string,
blame: ts.Node,
): IrExpr {
const builder = classMethodsBuilderOf(lowerer, className, blame, value.loc);
if (builder === null) return value;
return {
kind: "libCall",
fn: "nodeIsland.classMethods",
args: [value, builder],
type: DYN,
loc: value.loc,
};
}
/** The builder of a class's method table (a dyn closure answering
* {name: unbound method value}), or null for a class without methods. */
function classMethodsBuilderOf(
lowerer: Lowerer,
className: string,
blame: ts.Node,
loc: SrcLoc,
): IrExpr | null {
const info = lowerer.classes.get(className);
if (info === undefined || info.def.runtime) return null;
const names: string[] = [];
for (
let c: typeof info | undefined = info;
c !== undefined;
c = c.def.base ? lowerer.classes.get(c.def.base) : undefined
) {
for (const m of c.def.methods ?? [])
if (!names.includes(m) && islandMethodName(m)) names.push(m);
}
if (names.length === 0) return null;
const builder = `%island.methods:${className}`;
if (!lowerer.classMethodValueHelpers.has(builder)) {
lowerer.classMethodValueHelpers.add(builder);
const fields: { key: IrExpr; value: IrExpr }[] = [];
for (const m of names) {
const method = classMethodValue(lowerer, blame, info, m, loc);
if (method === null) continue;
fields.push({
key: { kind: "strLit", value: m, type: STRING, loc },
value: lowerer.coerceToExpected(method, DYN),
});
}
lowerer.liftedFns.push({
name: builder,
params: [],
returnType: DYN,
locals: [],
body: [{ kind: "return", value: { kind: "dynObjLit", fields, type: DYN, loc }, loc }],
loc,
});
}
const closure: IrExpr = {
kind: "closure",
fnName: builder,
captures: [],
type: funcTypeFromParamShapes([], DYN),
loc,
};
return lowerer.coerceToExpected(closure, DYN);
}
/** NI-6: the method-table builder of the class a shared module exports
* (`ident` is its declaration's name), or null. */
export function classMethodsBuilder(
lowerer: Lowerer,
ident: ts.Identifier,
loc: SrcLoc,
): IrExpr | null {
const decl = ident.parent;
if (decl === undefined || !ts.isClassDeclaration(decl)) return null;
return classMethodsBuilderOf(lowerer, lowerer.classNamer(decl), ident, loc);
}
/** NI-6: `new C(...)` from island code for the class a shared module
* exports: a function with the constructor's parameters (its dyn thunk
* checks the island's arguments) that constructs an instance, as a dyn
* closure, or null when the constructor's shape is not a plain list of
* parameters (the island's `new` then throws). */
export function classConstructHelper(
lowerer: Lowerer,
ident: ts.Identifier,
loc: SrcLoc,
): IrExpr | null {
const decl = ident.parent;
if (decl === undefined || !ts.isClassDeclaration(decl)) return null;
const className = lowerer.classNamer(decl);
const info = lowerer.classes.get(className);
if (info === undefined || info.def.runtime || info.localClass || info.generic) return null;
for (let c: typeof info | null = info; c; c = c.base) if (c.def.baseValueGlobal) return null;
if (lowerer.inheritsBuiltinErrorCtor(info)) return null;
const shapes = info.ctorParams;
if (shapes.some((s) => s.mode !== "required" && s.mode !== "omittable")) return null;
const name = `%island.new:${className}`;
if (!lowerer.classMethodValueHelpers.has(name)) {
lowerer.classMethodValueHelpers.add(name);
lowerer.noteEdge(`%${className}.constructor`);
const params = shapes.map((s, i) => ({ localId: `p${i}`, name: `p${i}`, type: s.type }));
const objectType: IrType = { kind: "object", className };
lowerer.liftedFns.push({
name,
params,
returnType: objectType,
locals: params.map((p) => ({ id: p.localId, name: p.name, type: p.type, mutable: false })),
body: [
{
kind: "return",
value: {
kind: "new",
className,
args: params.map((p) => varRef(p.localId, p.type, loc)),
type: objectType,
loc,
},
loc,
},
],
loc,
});
}
const closure: IrExpr = {
kind: "closure",
fnName: name,
captures: [],
type: funcTypeFromParamShapes(shapes, { kind: "object", className }),
loc,
};
return lowerer.coerceToExpected(closure, DYN);
}
/** Native values NI-5 exports into the island by reference: functions,
* promises, and object types other than arrays, tuples, Maps and Sets
* (records, interfaces, class instances). A union qualifies when every
* arm does or is copied. */
function exportedByReference(lowerer: Lowerer, type: ts.Type): boolean {
const checker = lowerer.checker;
if (type.isUnionType())
return ts
.constituentTypes(type)
.every(
(t) =>
(t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void)) !== 0 ||
exportedByReference(lowerer, t),
);
if ((type.flags & ts.TypeFlags.Object) === 0) return false;
if (checker.isArrayType(type) || checker.isTupleType(type)) return false;
const name = type.getSymbol()?.escapedName;
if (name === "Map" || name === "Set" || name === "WeakMap" || name === "WeakSet") return false;
return true;
}
/** A fresh object or array literal crosses as a copy (a plain dyn object
* or array the runtime copies into V8): nothing native can alias it. Its
* members cross by the same rules (toIsland). */
@@ -0,0 +1,602 @@
/* Shared modules between the program and its Node islands (scope-B NI-6,
* DESIGN.md §2.6). A module the program compiles that island code also
* imports would otherwise exist twice, with two states. Rule: each module
* has exactly one owner.
*
* - Program modules (user code): always shared, owned by the program.
* - npm packages compiled natively and imported by island code: shared
* when stateful (module-level state written after evaluation, exported
* classes, evaluation effects), duplicated when pure (each side has its
* own copy, which nobody can observe). `scriptc.packages.<name>.share`
* ("shared" | "duplicate") overrides; duplicating a stateful package is
* SC8020, a warning, because the two copies diverge.
*
* A shared module is registered at program start (nodeIsland.share, never
* loads Node) with its accessor: a generated function that answers the
* current values of its exports as a dyn array, the TDZ sentinel for a let
* or const whose declaration has not run yet (a flag the module's %init
* sets right after the declaration). The bootstrap's loader hooks
* (runtime scr_node_island_hooks.cjs) resolve island imports of the module
* to a generated ES module over those values.
*
* Island code is found syntactically: the files requireModule() loads and
* every JavaScript file of an island package, then the files they import
* that the program does not compile (Node's side of the closure). An
* import that resolves to a compiled module makes it a candidate. A
* specifier inside a comment or string can only add a candidate (an extra
* shared module), never lose one. */
import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
import { resolveFrom } from "../../node-islands/resolve.js";
import { join } from "node:path";
import * as ts from "../ts7/adapter.js";
import { locOf, isCjsJsFile } from "../program.js";
import { PoisonError, newFnCtx, type Lowerer } from "./lowerer.js";
import { BOOL, DYN, STRING, VOID, type IrExpr, type IrStmt, type SrcLoc } from "../../ir/ir.js";
import { resolveBareModule } from "../resolve.js";
import { packageDirOf } from "../../node-islands/origin.js";
import { requireModuleSpecifiers, scanModuleSpecifiers } from "../../node-islands/modules.js";
import { readProjectConfig } from "../../project-config.js";
import {
sharedClassExtendedDiag,
sharedModuleDuplicatedDiag,
} from "../../diagnostics/diagnostic.js";
import {
classConstructHelper,
classMethodsBuilder,
islandSharedPath,
} from "./lower-node-island.js";
import { dynUndefinedExpr } from "../../ir/build.js";
import { funcTypeFromParamShapes } from "./call-signatures.js";
import { stripTypeWrappers } from "./typed-arguments.js";
type ExportKind = "function" | "class" | "let" | "const" | "var";
interface SharedExport {
name: string;
kind: ExportKind;
/** The declaration's name: its reference is the export's value. */
ident: ts.Identifier;
/** let/const: the flag global set after the declaration ran. */
flag?: string;
}
export interface SharedModulePlan {
sf: ts.SourceFile;
/** The real path Node resolves the module to. */
path: string;
exports: SharedExport[];
}
const MAX_ISLAND_FILES = 4000;
function real(path: string): string {
try {
return realpathSync(path);
} catch {
return path;
}
}
function isJsFile(path: string): boolean {
return /\.(?:[cm]?js)$/.test(path);
}
/** The files `spec` may name from `from` (require and import conditions). */
function resolveAll(from: string, spec: string): string[] {
if (spec.startsWith("node:")) return [];
const out = new Set<string>();
try {
out.add(real(resolveFrom(from, spec)));
} catch {
/* not resolvable for require */
}
if (!spec.startsWith(".") && !spec.startsWith("/")) {
for (const kind of ["import", "require"] as const) {
const js = resolveBareModule(from, spec, "js-only", kind);
if (js !== null) out.add(real(js.typesFile));
}
}
return [...out];
}
function packageJsFiles(dir: string, out: string[]): void {
let names: string[];
try {
names = readdirSync(dir);
} catch {
return;
}
for (const name of names) {
if (out.length >= MAX_ISLAND_FILES) return;
if (name === "node_modules" || name.startsWith(".")) continue;
const path = join(dir, name);
let isDirectory: boolean;
try {
isDirectory = statSync(path).isDirectory();
} catch {
continue;
}
if (isDirectory) packageJsFiles(path, out);
else if (isJsFile(name)) out.push(real(path));
}
}
function isExported(stmt: ts.Node): boolean {
const mods = ts.canHaveModifiers(stmt) ? ts.getModifiers(stmt) : undefined;
return mods?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) === true;
}
function isDefaultExport(stmt: ts.Node): boolean {
const mods = ts.canHaveModifiers(stmt) ? ts.getModifiers(stmt) : undefined;
return mods?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword) === true;
}
/** The top-level declarations of a module by name. */
function localDeclarations(
sf: ts.SourceFile,
): Map<string, { kind: ExportKind; ident: ts.Identifier }> {
const out = new Map<string, { kind: ExportKind; ident: ts.Identifier }>();
for (const stmt of sf.statements.nodes) {
if (ts.isFunctionDeclaration(stmt) && stmt.name && stmt.body)
out.set(stmt.name.text, { kind: "function", ident: stmt.name });
else if (ts.isClassDeclaration(stmt) && stmt.name)
out.set(stmt.name.text, { kind: "class", ident: stmt.name });
else if (ts.isVariableStatement(stmt)) {
const flags = stmt.declarationList.flags;
const kind: ExportKind =
(flags & ts.NodeFlags.Const) !== 0
? "const"
: (flags & ts.NodeFlags.Let) !== 0
? "let"
: "var";
for (const d of stmt.declarationList.declarations.nodes)
if (ts.isIdentifier(d.name)) out.set(d.name.text, { kind, ident: d.name });
}
}
return out;
}
/** The module's value exports (re-exports and default exports of
* expressions are not shared: NOTES.md shortcut). */
function moduleExports(sf: ts.SourceFile): SharedExport[] {
const locals = localDeclarations(sf);
const out: SharedExport[] = [];
for (const stmt of sf.statements.nodes) {
if (ts.isExportDeclaration(stmt)) {
if (stmt.moduleSpecifier || stmt.isTypeOnly || !stmt.exportClause) continue;
if (!ts.isNamedExports(stmt.exportClause)) continue;
for (const el of stmt.exportClause.elements.nodes) {
if (el.isTypeOnly) continue;
const local = locals.get((el.propertyName ?? el.name).text);
if (local !== undefined) out.push({ name: el.name.text, ...local });
}
continue;
}
if (!isExported(stmt)) continue;
const def = isDefaultExport(stmt);
if (ts.isFunctionDeclaration(stmt) && stmt.name && stmt.body)
out.push({ name: def ? "default" : stmt.name.text, kind: "function", ident: stmt.name });
else if (ts.isClassDeclaration(stmt) && stmt.name)
out.push({ name: def ? "default" : stmt.name.text, kind: "class", ident: stmt.name });
else if (ts.isVariableStatement(stmt)) {
const flags = stmt.declarationList.flags;
const kind: ExportKind =
(flags & ts.NodeFlags.Const) !== 0
? "const"
: (flags & ts.NodeFlags.Let) !== 0
? "let"
: "var";
for (const d of stmt.declarationList.declarations.nodes)
if (ts.isIdentifier(d.name)) out.push({ name: d.name.text, kind, ident: d.name });
}
}
return out;
}
/** The name of a class imported from `spec` that the island file extends
* (`class X extends Base` or `extends ns.Base`), or null. */
function extendedImport(text: string, spec: string): string | null {
const quoted = spec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const names: string[] = [];
const named = new RegExp(
`import\\s*(?:(\\w+)\\s*,\\s*)?\\{([^}]*)\\}\\s*from\\s*["']${quoted}["']`,
"g",
);
for (const m of text.matchAll(named)) {
if (m[1] !== undefined) names.push(m[1]);
for (const part of m[2]!.split(",")) {
const local = part
.trim()
.split(/\s+as\s+/)
.at(-1)
?.trim();
if (local) names.push(local);
}
}
const star = new RegExp(`import\\s*\\*\\s*as\\s+(\\w+)\\s*from\\s*["']${quoted}["']`, "g");
for (const m of text.matchAll(star)) names.push(`${m[1]!}\\.\\w+`);
const def = new RegExp(`import\\s+(\\w+)\\s+from\\s*["']${quoted}["']`, "g");
for (const m of text.matchAll(def)) names.push(m[1]!);
for (const name of names) {
const m = new RegExp(`\\bclass\\s+[\\w$]*\\s*extends\\s+(${name})\\b`).exec(text);
if (m !== null) return m[1]!;
}
return null;
}
const MUTATING_METHODS = new Set([
"set",
"add",
"delete",
"clear",
"push",
"pop",
"shift",
"unshift",
"splice",
"sort",
"reverse",
"fill",
"copyWithin",
]);
/** Why a module is stateful (one reason), or null when it is pure: no
* module-level binding reachable from its exports is written after
* evaluation, no exported class (its instances may cross or meet
* instanceof in island code), and no top-level effect beyond declarations. */
/** Whether evaluating a module-level initializer runs no code: literals,
* names, functions and classes it creates, `require()` of a module (which
* is judged on its own), and object, array and operator expressions over
* those. */
function inertInitializer(node: ts.Expression): boolean {
const e = stripTypeWrappers(node);
if (
ts.isIdentifier(e) ||
ts.isLiteralExpression(e) ||
ts.isNoSubstitutionTemplateLiteral(e) ||
ts.isArrowFunction(e) ||
ts.isFunctionExpression(e) ||
e.kind === ts.SyntaxKind.TrueKeyword ||
e.kind === ts.SyntaxKind.FalseKeyword ||
e.kind === ts.SyntaxKind.NullKeyword
)
return true;
if (ts.isClassExpression(e))
return !e.members.nodes.some(
(m) =>
ts.isClassStaticBlockDeclaration(m) ||
(ts.isPropertyDeclaration(m) &&
m.initializer !== undefined &&
!inertInitializer(m.initializer)),
);
if (ts.isParenthesizedExpression(e)) return inertInitializer(e.expression);
if (ts.isPrefixUnaryExpression(e))
return (
e.operator !== ts.SyntaxKind.PlusPlusToken &&
e.operator !== ts.SyntaxKind.MinusMinusToken &&
inertInitializer(e.operand)
);
if (ts.isBinaryExpression(e))
return (
!ts.isAssignmentOperator(e.operatorToken.kind) &&
e.operatorToken.kind !== ts.SyntaxKind.CommaToken &&
inertInitializer(e.left) &&
inertInitializer(e.right)
);
if (ts.isConditionalExpression(e))
return (
inertInitializer(e.condition) && inertInitializer(e.whenTrue) && inertInitializer(e.whenFalse)
);
if (ts.isArrayLiteralExpression(e))
return e.elements.nodes.every((el) => !ts.isSpreadElement(el) && inertInitializer(el));
if (ts.isObjectLiteralExpression(e))
return e.properties.nodes.every(
(p) =>
ts.isShorthandPropertyAssignment(p) ||
ts.isMethodDeclaration(p) ||
(ts.isPropertyAssignment(p) &&
!ts.isComputedPropertyName(p.name) &&
inertInitializer(p.initializer)),
);
if (ts.isCallExpression(e))
return (
ts.isIdentifier(e.expression) &&
e.expression.text === "require" &&
e.arguments.nodes.length === 1 &&
ts.isStringLiteralLike(e.arguments.nodes[0]!)
);
return false;
}
export function statefulReason(sf: ts.SourceFile): string | null {
const moduleLevel = new Map<string, string>(); // name -> kind
for (const stmt of sf.statements.nodes) {
if (ts.isClassDeclaration(stmt) && isExported(stmt))
return `it exports the class '${stmt.name?.text ?? "default"}'`;
if (ts.isVariableStatement(stmt)) {
const flags = stmt.declarationList.flags;
for (const d of stmt.declarationList.declarations.nodes) {
// A copy would run the initializer a second time.
if (d.initializer !== undefined && !inertInitializer(d.initializer))
return "its variable initializers run code";
if (!ts.isIdentifier(d.name)) continue;
if ((flags & ts.NodeFlags.Const) === 0) {
if (isExported(stmt)) return `it exports the mutable binding '${d.name.text}'`;
moduleLevel.set(d.name.text, "let");
} else {
moduleLevel.set(d.name.text, "const");
}
}
continue;
}
if (
ts.isFunctionDeclaration(stmt) ||
ts.isClassDeclaration(stmt) ||
ts.isImportDeclaration(stmt) ||
ts.isExportDeclaration(stmt) ||
ts.isInterfaceDeclaration(stmt) ||
ts.isTypeAliasDeclaration(stmt) ||
ts.isExportAssignment(stmt) ||
ts.isEmptyStatement(stmt)
)
continue;
if (ts.isExpressionStatement(stmt) && ts.isStringLiteral(stmt.expression)) continue; // directives
return "its evaluation has effects beyond declarations";
}
// Writes from function bodies to module-level bindings.
let reason: string | null = null;
const visit = (node: ts.Node, inFunction: boolean): void => {
if (reason !== null) return;
const fn = ts.isFunctionLike(node);
if (inFunction) {
if (ts.isBinaryExpression(node) && ts.isAssignmentOperator(node.operatorToken.kind)) {
let target: ts.Node = node.left;
while (ts.isPropertyAccessExpression(target) || ts.isElementAccessExpression(target))
target = target.expression;
if (ts.isIdentifier(target) && moduleLevel.has(target.text))
reason = `its function bodies write the module-level binding '${target.text}'`;
} else if (
(ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) &&
(node.operator === ts.SyntaxKind.PlusPlusToken ||
node.operator === ts.SyntaxKind.MinusMinusToken) &&
ts.isIdentifier(node.operand) &&
moduleLevel.has(node.operand.text)
) {
reason = `its function bodies write the module-level binding '${node.operand.text}'`;
} else if (
ts.isCallExpression(node) &&
ts.isPropertyAccessExpression(node.expression) &&
ts.isIdentifier(node.expression.expression) &&
moduleLevel.has(node.expression.expression.text) &&
MUTATING_METHODS.has(node.expression.name.text)
) {
reason = `its function bodies mutate the module-level '${node.expression.expression.text}'`;
}
}
ts.forEachChild(node, (child) => visit(child, inFunction || fn));
};
visit(sf, false);
return reason;
}
/** The modules the program shares with its islands (computed once). */
export function sharedModulePlans(lowerer: Lowerer): SharedModulePlan[] {
if (lowerer.sharedModules !== null) return lowerer.sharedModules;
const plans: SharedModulePlan[] = [];
lowerer.sharedModules = plans;
const compiled = new Map<string, ts.SourceFile>();
for (const sf of lowerer.moduleOrder) {
if (sf.isDeclarationFile) continue;
compiled.set(real(sf.fileName), sf);
}
// Island roots: requireModule() targets and island package files. A
// target the program compiles itself is loaded in Node as well: shared.
const queue: string[] = [];
const candidates = new Set<ts.SourceFile>();
for (const sf of lowerer.moduleOrder) {
const text = sf.text;
if (!text.includes("requireModule")) continue;
const from = real(sf.fileName);
for (const spec of requireModuleSpecifiers(text)) {
try {
const target = real(resolveFrom(from, spec));
const own = compiled.get(target);
if (own !== undefined) candidates.add(own);
else queue.push(target);
} catch {
/* lowering reports it */
}
}
}
for (const island of lowerer.nodeIslands?.packages.values() ?? [])
packageJsFiles(island.dir, queue);
if (queue.length === 0 && candidates.size === 0) return plans;
const seen = new Set<string>();
// Classes island code extends, by the compiled module that exports them.
const extended = new Map<ts.SourceFile, string>();
while (queue.length > 0 && seen.size < MAX_ISLAND_FILES) {
const file = queue.pop()!;
if (seen.has(file) || compiled.has(file) || !isJsFile(file)) continue;
seen.add(file);
let text: string;
try {
text = readFileSync(file, "utf8");
} catch {
continue;
}
// Specifiers in code only: a comment or a string that looks like an
// import names no module.
for (const spec of scanModuleSpecifiers(text).specifiers) {
for (const target of resolveAll(file, spec)) {
const sf = compiled.get(target);
if (sf !== undefined) {
candidates.add(sf);
const base = extendedImport(text, spec);
if (base !== null && !extended.has(sf)) extended.set(sf, base);
} else if (!seen.has(target)) queue.push(target);
}
}
}
if (candidates.size === 0) return plans;
const config = readProjectConfig(lowerer.entry.fileName);
for (const sf of lowerer.moduleOrder) {
if (!candidates.has(sf)) continue;
const path = real(sf.fileName);
const pkg = packageDirOf(path);
const loc: SrcLoc = { file: sf.fileName, start: 0, end: 0 };
const share = pkg === null ? "shared" : (config.packages.get(pkg.name)?.share ?? "auto");
const stateful = pkg === null ? "it is a program module" : statefulReason(sf);
const base = extended.get(sf);
if (base !== undefined) {
// Island code extends one of its classes: a native instance cannot
// take an island subclass's overrides, so the module cannot be owned
// by the program. A package then runs in Node for both sides
// (Node-owned, through handles; the build retries with it as an
// island); a program module is refused.
lowerer.pushDiag(sharedClassExtendedDiag(pkg?.name ?? null, sf.fileName, base, loc));
continue;
}
if (isCjsJsFile(sf, lowerer.program)) {
// Shortcut: CommonJS modules are not shared yet; each side keeps its
// copy, so a stateful one is reported.
if (stateful !== null)
lowerer.sharedModuleWarnings.push(
sharedModuleDuplicatedDiag(
pkg?.name ?? sf.fileName,
`${stateful}, and CommonJS modules cannot be shared yet`,
loc,
),
);
continue;
}
if (share === "duplicate" || (share === "auto" && stateful === null)) {
if (stateful !== null)
lowerer.sharedModuleWarnings.push(
sharedModuleDuplicatedDiag(
pkg?.name ?? sf.fileName,
`${stateful}, and it is marked "share": "duplicate"`,
loc,
),
);
continue;
}
const exports = moduleExports(sf);
for (const e of exports) {
if (e.kind !== "let" && e.kind !== "const") continue;
const flag = `%g.shared.${plans.length}.${e.name}%initialized`;
lowerer.globalsList.push({ id: flag, name: "%initialized", type: BOOL, mutable: true });
e.flag = flag;
let stmt: ts.Node | undefined = e.ident.parent;
while (stmt !== undefined && !ts.isVariableStatement(stmt)) stmt = stmt.parent;
if (stmt !== undefined) {
const list = lowerer.sharedInitFlags.get(stmt as ts.Statement) ?? [];
list.push(flag);
lowerer.sharedInitFlags.set(stmt as ts.Statement, list);
}
}
plans.push({ sf, path, exports });
}
if (plans.length > 0) lowerer.usesNodeIsland = true;
// Before island loads are lowered (the plans are computed in the first
// module's header): the module tree ships these as placeholders.
for (const plan of plans) lowerer.nodeIslandModules.addProvided(plan.path);
return plans;
}
/** The flag stores after a shared module's declaration statement. */
export function sharedInitFlagStores(lowerer: Lowerer, stmt: ts.Statement): IrStmt[] {
const flags = lowerer.sharedInitFlags.get(stmt);
if (flags === undefined) return [];
const loc = locOf(stmt);
return flags.map((flag) => ({
kind: "assign",
localId: flag,
value: { kind: "boolLit", value: true, type: BOOL, loc },
loc,
}));
}
/** The registrations the entry's %init runs first: one nodeIsland.share
* per shared module, with its accessor. */
export function sharedModuleRegistrations(lowerer: Lowerer): IrStmt[] {
const plans = sharedModulePlans(lowerer);
const out: IrStmt[] = [];
plans.forEach((plan, index) => {
const loc: SrcLoc = { file: plan.sf.fileName, start: 0, end: 0 };
const name = `%island.shared:${index}`;
const ctx = newFnCtx(false, null, null, DYN);
lowerer.fnStack.push(ctx);
const elems: IrExpr[] = [];
const kept: SharedExport[] = [];
try {
for (const e of plan.exports) {
let value: IrExpr;
try {
value = lowerer.coerceToExpected(lowerer.lowerExpr(e.ident), DYN);
} catch (error) {
if (!(error instanceof PoisonError)) throw error;
continue;
}
if (e.kind === "class") {
const builder = classMethodsBuilder(lowerer, e.ident, loc) ?? dynUndefinedExpr(loc);
const construct = classConstructHelper(lowerer, e.ident, loc) ?? dynUndefinedExpr(loc);
value = {
kind: "libCall",
fn: "nodeIsland.classMethodsOf",
args: [value, builder, construct],
type: DYN,
loc,
};
}
if (e.flag !== undefined) {
value = {
kind: "ternary",
cond: { kind: "varRef", localId: e.flag, type: BOOL, loc },
then: value,
else_: { kind: "libCall", fn: "nodeIsland.sharedTdz", args: [], type: DYN, loc },
type: DYN,
loc,
};
}
elems.push(value);
kept.push(e);
}
} finally {
lowerer.fnStack.pop();
}
lowerer.liftedFns.push({
name,
params: [],
returnType: DYN,
locals: ctx.locals,
body: [{ kind: "return", value: { kind: "dynArrLit", elems, type: DYN, loc }, loc }],
loc,
});
const exportsJson = JSON.stringify(kept.map((e) => ({ name: e.name, kind: e.kind })));
out.push({
kind: "exprStmt",
expr: {
kind: "libCall",
fn: "nodeIsland.share",
args: [
islandSharedPath(lowerer, plan.path, loc),
{ kind: "strLit", value: exportsJson, type: STRING, loc },
lowerer.coerceToExpected(
{
kind: "closure",
fnName: name,
captures: [],
type: funcTypeFromParamShapes([], DYN),
loc,
},
DYN,
),
],
type: VOID,
loc,
},
loc,
});
});
return out;
}
@@ -10,8 +10,12 @@ import {
lowerIndexedUnionNumberRead,
lowerIndexedUnionRead,
} from "./indexed-unions.js";
import { declaredMemberOf, isPromisePrototypeRead } from "./expressions/property-access.js";
import { hostsAll } from "./expressions/hosted-narrowing.js";
import {
declaredMemberOf,
isPromisePrototypeRead,
lowerUnionKeyedRead,
} from "./expressions/property-access.js";
import { hostedStorageView, hostsAll } from "./expressions/hosted-narrowing.js";
import { requestGeneralStreamSurface } from "./general-streams.js";
import { lowerForAwaitDynStream, nodeStreamInterfaceSide } from "./dyn-streams.js";
import { lowerForAwaitValueStream } from "./builtins/hash-stream.js";
@@ -3538,6 +3542,34 @@ export function lowerBindingPattern(
.get(srcType.unionId)
?.arms.every((arm) => arm.kind === "record" || arm.kind === "object")
) {
const arms = lowerer.unions.get(srcType.unionId)!.arms;
if (!unionObjectPatternFits(lowerer, pattern, arms)) {
// NARROW FIRST: the checker may have refined the union at this
// very site (`if (u.kind === "a") { const { x } = u }`, an `in`
// guard) while the IR value keeps the stored, wider union. Bind
// the narrowed view once and destructure that.
const view = narrowedUnionPatternSource(
lowerer,
pattern,
present,
srcType.unionId,
locOf(pattern),
);
if (view !== null) {
const tmp = lowerer.declareHiddenLocal("%destrN", view.type);
out.push({ kind: "varDecl", localId: tmp.id, init: view, loc: locOf(pattern) });
lowerer.lowerBindingPattern(
pattern,
() => ({ kind: "varRef", localId: tmp.id, type: view.type, loc: locOf(pattern) }),
view.type,
isLet,
out,
dynSpell,
allowDynObject,
);
return;
}
}
lowerUnionObjectPattern(lowerer, pattern, srcRef, srcType, isLet, out);
return;
}
@@ -3950,11 +3982,235 @@ function lowerUrlObjectPattern(
}
}
/** The fresh record for `{ a, ...rest }` over a UNION source: per arm,
* the unconsumed fields copied out at destructure time into a fresh
* record of the checker's arm-wise rest type, selected by the runtime
* tag. Null-safe answer: an arm whose remaining fields do not spell an
* arm of the checker's rest type keeps the named fence. */
function unionObjectRestValue(
lowerer: Lowerer,
el: ts.BindingElement,
srcRef: () => IrExpr,
srcType: IrType & { kind: "union" },
arms: readonly IrType[],
): IrExpr {
const loc = locOf(el);
const restT = patternBindingType(lowerer, el.name!);
const fence: (why: string) => never = (why) => lowerer.unsupported("SC1031", el, why);
if (restT === null) {
fence(`rest bindings over union sources (${NARROW_FIRST})`);
}
// The consumed set is the pattern's literal keys: a computed sibling
// would make it a runtime fact.
const consumed = new Set<string>();
const pattern = el.parent;
if (ts.isObjectBindingPattern(pattern)) {
for (const sib of pattern.elements.nodes) {
if (sib === el || sib.dotDotDotToken) continue;
const key = sib.propertyName ?? sib.name;
if (key && ts.isComputedPropertyName(key)) {
fence("rest bindings beside computed keys (the consumed set is a runtime fact)");
}
if (key && (ts.isIdentifier(key) || ts.isStringLiteral(key) || ts.isNumericLiteral(key))) {
consumed.add(key.text);
}
}
}
const pieces: { tag: number; value: IrExpr }[] = [];
for (let tag = 0; tag < arms.length; tag++) {
const arm = arms[tag]!;
if (arm.kind !== "record") {
fence(`rest bindings over union sources (${NARROW_FIRST})`);
}
const shape = lowerer.shapes.get(arm.shapeId);
if (!shape || shape.indexValue) {
fence(
"rest bindings over index-signature shapes (the undeclared entries would need overflow packing)",
);
}
const rem = shape.fields.filter((f) => !consumed.has(f.name));
if (rem.some((f) => f.name.startsWith("%"))) {
fence("rest bindings over accessor shapes (the entries are closures, not data)");
}
const armRef: IrExpr = {
kind: "unionNarrow",
unionId: srcType.unionId,
tag,
value: srcRef(),
type: arm,
loc,
};
const packed: IrExpr = lowerer.keyOrder.copy(
{
kind: "recordLit",
fields: rem.map((f) => ({
name: f.name,
value: lowerer.presenceKeepingCopy(
armRef,
arm.shapeId,
f.name,
{
kind: "recordGet",
obj: armRef,
shapeId: arm.shapeId,
field: f.name,
type: f.type,
loc,
},
loc,
),
})),
type: {
kind: "record",
shapeId: lowerer.shapes.intern(rem.map((f) => ({ name: f.name, type: f.type }))),
},
loc,
},
arm.shapeId,
);
const coerced = lowerer.coerceInto(el, packed, restT);
if (!typeEquals(coerced.type, restT)) {
fence(
`rest bindings over union sources (the remaining fields of an arm do not spell an arm of '${lowerer.fmt(restT)}')`,
);
}
pieces.push({ tag, value: coerced });
}
let result: IrExpr | null = null;
for (let i = pieces.length - 1; i >= 0; i--) {
const piece = pieces[i]!;
result =
result === null
? piece.value
: {
kind: "ternary",
cond: {
kind: "unionIsTag",
unionId: srcType.unionId,
tag: piece.tag,
negated: false,
value: srcRef(),
type: BOOL,
loc,
},
then: piece.value,
else_: result,
type: restT,
loc,
};
}
if (result === null) {
fence(`rest bindings over union sources (${NARROW_FIRST})`);
}
return result;
}
/** The one type every arm declares for `propName`, or null when an arm
* does not declare it (or types it differently, or not at all). */
function unionPatternCommonType(
lowerer: Lowerer,
arms: readonly IrType[],
propName: string,
): IrType | null {
let common: IrType | null = null;
for (const arm of arms) {
let ft: IrType | undefined;
if (arm.kind === "record") {
ft = lowerer.shapes.get(arm.shapeId)?.fields.find((f) => f.name === propName)?.type;
} else if (arm.kind === "object") {
ft = lowerer.classes.get(arm.className)?.fields.get(propName);
}
if (!ft || ft.kind === "void" || isUnitType(ft) || (common && !typeEquals(common, ft))) {
return null;
}
common = ft;
}
return common;
}
/** Whether the unionDisc fast path lowers this pattern over these arms
* unchanged: no rest element, and every element's key folds to one name
* every arm declares with one type. */
function unionObjectPatternFits(
lowerer: Lowerer,
pattern: ts.ObjectBindingPattern,
arms: readonly IrType[],
): boolean {
for (const el of pattern.elements.nodes) {
if (el.name === undefined) continue;
if (el.dotDotDotToken) return false;
const prop = el.propertyName ?? el.name;
const propName =
ts.isIdentifier(prop) ||
ts.isComputedPropertyName(prop) ||
ts.isStringLiteralLike(prop) ||
ts.isNumericLiteral(prop)
? patternKeyNameOf(lowerer, prop as ts.PropertyName)
: null;
if (propName === null) return false;
if (unionPatternCommonType(lowerer, arms, propName) === null) return false;
}
return true;
}
/** The CHECKER-NARROWED view of a destructuring source: `if (u.kind ===
* "a") { const { x } = u }` or an `in` guard refines the type at the
* pattern's own site, while the IR value keeps the stored (wider) union
* and the un-narrowed lowering. Bind the value to the narrowed sub-union
* — a trusted arm extraction for one arm, a re-tag with the excluded
* arms as traps for a sub-union — so the pattern reads the arms the
* checker proved. Null when the checker did not narrow, when the
* refinement is an intersection of stored records (hostedStorageView
* keeps that layout), or when nothing was excluded. */
function narrowedUnionPatternSource(
lowerer: Lowerer,
pattern: ts.ObjectBindingPattern,
value: IrExpr,
fromId: string,
loc: SrcLoc,
): IrExpr | null {
const from = lowerer.unions.get(fromId);
if (!from) return null;
const narrowed = lowerer.irTypeOf(pattern);
if (narrowed.kind === "void" || isUnitType(narrowed)) return null;
const siteArms =
narrowed.kind === "union" ? lowerer.unions.get(narrowed.unionId)?.arms : [narrowed];
if (!siteArms || siteArms.length === 0) return null;
const allowed = new Set<number>();
for (const arm of siteArms) {
const tag = lowerer.armTag(fromId, arm);
// Not a stored arm: a checker intersection that refines the stored
// records, which keeps the value's layout.
if (tag < 0) return hostedStorageView(lowerer, value, narrowed, loc);
allowed.add(tag);
}
if (allowed.size === 0 || allowed.size === from.arms.length) return null;
if (allowed.size === 1) {
const tag = [...allowed][0]!;
// The checker proved the tag; the extraction traps on a mismatch
// instead of reading another arm.
return { kind: "unionNarrow", unionId: fromId, tag, value, type: from.arms[tag]!, loc };
}
const arms = from.arms.filter((_, tag) => allowed.has(tag));
const subId = lowerer.unions.transform(from, arms);
const helper = lowerer.narrowedRetagHelper(pattern, fromId, subId, loc);
if (!helper) return null;
return {
kind: "call",
callee: helper,
args: [value],
type: { kind: "union", unionId: subId },
loc,
};
}
/** An object pattern over a union of records and class instances
* (`function f({ kind, path }: Add | Remove)`): each element reads a data
* field every arm declares with one type, through the runtime tag, as
* `value.field` does. Rest elements and fields the arms type differently
* stay fenced. */
* field through the runtime tag, as `value.field` does — the shared
* `unionDisc` when every arm declares it with one type, else the
* `unionKeyGet` join (an arm that does not declare the key answers
* undefined, as JS reads it). A rest element packs each arm's unconsumed
* fields into a fresh record. Class arms still need same-typed fields. */
function lowerUnionObjectPattern(
lowerer: Lowerer,
pattern: ts.ObjectBindingPattern,
@@ -3967,7 +4223,17 @@ function lowerUnionObjectPattern(
for (const el of pattern.elements.nodes) {
if (el.name === undefined) continue;
if (el.dotDotDotToken) {
lowerer.unsupported("SC1031", el, `rest bindings over union sources (${NARROW_FIRST})`);
// `{ a, ...rest }` over a union: each arm packs its own unconsumed
// fields into a FRESH record (JS's CopyDataProperties makes a fresh
// object too), and the checker's rest type is the union of those
// per-arm packs. The tag decides which pack runs.
lowerer.bindPatternTarget(
el.name,
unionObjectRestValue(lowerer, el, srcRef, srcType, arms),
isLet,
out,
);
continue;
}
const prop = el.propertyName ?? el.name;
const propName =
@@ -3984,35 +4250,43 @@ function lowerUnionObjectPattern(
"destructuring with computed keys that do not fold to one property name",
);
}
let common: IrType | null = null;
for (const arm of arms) {
let ft: IrType | undefined;
if (arm.kind === "record") {
ft = lowerer.shapes.get(arm.shapeId)?.fields.find((f) => f.name === propName)?.type;
} else if (arm.kind === "object") {
ft = lowerer.classes.get(arm.className)?.fields.get(propName);
}
if (!ft || ft.kind === "void" || isUnitType(ft) || (common && !typeEquals(common, ft))) {
common = null;
break;
}
common = ft;
}
if (common === null) {
lowerer.unsupported(
"SC1031",
const common = unionPatternCommonType(lowerer, arms, propName);
let value: IrExpr;
if (common !== null) {
value = {
kind: "unionDisc",
unionId: srcType.unionId,
field: propName,
value: srcRef(),
type: common,
loc: locOf(el),
};
} else {
// The unionDisc generalization (`unionKeyGet`): each arm answers
// its own declared type and an arm that does not declare the
// key answers undefined — exactly JS's desugar, which reads a
// missing property as undefined.
const loc = locOf(el);
const joined = lowerUnionKeyedRead(
lowerer,
el,
`destructuring '${propName}' from a union whose arms type it differently (every arm must be a record or class with a same-typed field '${propName}' — ${NARROW_FIRST})`,
srcType.unionId,
srcRef(),
{ kind: "strLit", value: propName, type: STRING, loc },
propName,
true,
);
if (joined === null) {
lowerer.unsupported(
"SC1031",
el,
`destructuring '${propName}' from a union whose arms answer it with types that have no combined representation (every arm's '${
propName
}' must join into one union — ${NARROW_FIRST})`,
);
}
value = joined;
}
let value: IrExpr = {
kind: "unionDisc",
unionId: srcType.unionId,
field: propName,
value: srcRef(),
type: common,
loc: locOf(el),
};
if (el.initializer) value = applyBindingDefault(lowerer, el, value);
lowerer.bindPatternTarget(el.name, value, isLet, out);
}
@@ -5592,6 +5866,14 @@ function inferredEvolvingLetType(lowerer: Lowerer, decl: ts.VariableDeclaration)
invalid = true;
return "stop";
}
// An index read's checker type omits the undefined a read past the
// end (or of an absent key) answers.
let right = node.right;
while (ts.isParenthesizedExpression(right)) right = right.expression;
if (ts.isElementAccessExpression(right)) {
invalid = true;
return "stop";
}
const mapped = lowerer.mapTypeOf(lowerer.typeOf(node.right));
if (!mapped || mapped.kind === "dyn" || mapped.kind === "void" || isUnitType(mapped)) {
invalid = true;
@@ -7117,8 +7399,17 @@ export function lowerSwitch(lowerer: Lowerer, stmt: ts.SwitchStatement): IrStmt
if (dk === "dyn") return lowerDynSwitch(lowerer, stmt, disc, labels);
if (dk === "union") return lowerUnionSwitch(lowerer, stmt, disc, labels);
// Objects, arrays, functions and other references match their cases
// with ===, which compares them by identity.
if (dk !== "f64" && dk !== "string" && dk !== "bool")
// with ===, which compares them by identity. In JavaScript, a case
// test of another type (a namespace enum member read through an
// untyped object) compares with === too.
const mixedJsCases =
isJsSourceFile(ts.getSourceFileOfNode(stmt)) &&
stmt.caseBlock.clauses.nodes.some(
(clause) =>
ts.isCaseClause(clause) &&
tryLowerExpression(lowerer, clause.expression)?.type.kind !== dk,
);
if ((dk !== "f64" && dk !== "string" && dk !== "bool") || mixedJsCases)
return lowerBranchSwitch(lowerer, stmt, disc, labels, (stableDisc, test, expression) =>
lowerStrictEquality(
lowerer,
@@ -41,7 +41,11 @@ import {
type UnitEvents,
type WaveHost,
} from "./unit-journal.js";
import { moduleNamespaceObject, moduleNamespaceValue } from "./module-namespace-elements.js";
import {
moduleNamespaceDynValue,
moduleNamespaceObject,
moduleNamespaceValue,
} from "./module-namespace-elements.js";
import { keyedHelperName, moduleTagNumber, portableRelativePath } from "./stable-names.js";
import { positionTag, StructuralPositions } from "./structural-positions.js";
import {
@@ -118,7 +122,11 @@ import { fenceWebCryptoMember, lowerWebCryptoCall } from "./builtins/webcrypto.j
import { valueStreamOf } from "./builtins/hash-stream.js";
import { lowerNetAddressCall, lowerNetAddressNew } from "./builtins/net-address.js";
import { lowerMimeCall, lowerMimeNew } from "./builtins/mime.js";
import { lowerWebStreamNew, lowerWebStreamStaticCall } from "./builtins/web-streams-dyn.js";
import {
installFetchBodyWebStreams,
lowerWebStreamNew,
lowerWebStreamStaticCall,
} from "./builtins/web-streams-dyn.js";
import { dynUndefinedExpr, nodeThrowExpr, numLit, varRef } from "../../ir/build.js";
import { iteratorSlotAdapter } from "./iterator-adapters.js";
import { iterableSlotValue } from "./iterable-values.js";
@@ -197,6 +205,7 @@ import {
captureContextBinding,
declareContextLocal,
declareContextThis,
newFnCtx,
type FnCtx,
} from "./function-context.js";
import type { FrontendServices } from "../services.js";
@@ -221,6 +230,7 @@ import { recordTextCodecClass } from "../../ir/ir.js";
import { assignLegacyTiers, type DynamicFunction } from "../../ir/tiers.js";
import { dynamicSlotOperationCounts, dynamicTierWarning } from "./dynamic-tier.js";
import { promoteJsvals } from "../../ir/jsval.js";
import { returnDispatchReceivers } from "./receiver-returns.js";
import {
analyzeTiers,
dynamicStatementCount,
@@ -1468,7 +1478,10 @@ function lowerToIrWithIslands(
}
// Divergence warnings over the program's own modules: computed once,
// after lowering, from the same checker (never part of a failure).
const divergences = scanDivergences(resultLowerer, moduleOrder);
const divergences = [
...scanDivergences(resultLowerer, moduleOrder),
...resultLowerer.sharedModuleWarnings,
];
timing("divergence-scan", { found: divergences.length });
if (divergences.length > 0) result = { ...result, divergences };
// The performance warning (SC6101): likely-hot functions of the program
@@ -3631,6 +3644,11 @@ export class Lowerer {
}[] = [];
/** Each island module load's path literal, set to its tree path when lowering ends. */
readonly nodeIslandLoads: { literal: IrExpr & { kind: "strLit" }; path: string }[] = [];
/** NI-6: the modules shared with Node islands (lower-shared-modules.ts),
* their SC8020 warnings, and the TDZ flags their declarations set. */
sharedModules: import("./lower-shared-modules.js").SharedModulePlan[] | null = null;
readonly sharedModuleWarnings: ScrDiagnostic[] = [];
readonly sharedInitFlags = new Map<ts.Statement, string[]>();
/** Per-directory answers of experimental-import.ts's declaration-file test. */
readonly experimentalFiles = new Map<string, boolean>();
/** Interned LifetimeRegion.run helpers, by callback type key. */
@@ -4116,6 +4134,10 @@ export class Lowerer {
setTable("freezableArrays", this.freezableArrays),
checkedTable("moduleOrder", () => this.moduleOrder.length),
checkedTable("diags", () => this.diags.length),
// The shared-module plan runs inside the first unit that lowers a
// module; a warning it adds is a diagnostic of that unit, so its count
// is journaled like `diags` (the plan itself is unjournaled).
checkedTable("sharedModuleWarnings", () => this.sharedModuleWarnings.length),
// Node island call sites hold literals of the unit's IR that lowering's
// end rewrites to module tree paths (nodeIslandRecord): a unit that adds
// one lowers again rather than replaying a record.
@@ -4393,6 +4415,11 @@ export class Lowerer {
"argumentCountSlots",
"coercions.widenedReturnTypes",
"packageFileMemo",
// The modules shared with the islands (NI-6): a pure function of the
// program's modules, computed once, and the TDZ flags its plan gives a
// statement.
"sharedModules",
"sharedInitFlags",
"slotOriginMemo",
"recordOriginMemo",
"boundaryCastOrder",
@@ -5020,6 +5047,7 @@ export class Lowerer {
classImplementedProtocol: (type) => this.classImplementedProtocol(type),
isStdlibFile: (sf) => this.isStdlibFile(sf),
isNpmFile: (sf) => this.isNpmFile(sf),
isExperimentalFile: (sf) => isExperimentalDeclarationFile(sf, this.experimentalFiles),
// The declaration-typed boundary's generated types (decl-projection.ts)
// describe data, like an explicitly mapped external type surface.
isExternalTypeFile: (sf) =>
@@ -5233,6 +5261,79 @@ export class Lowerer {
return this.moduleOrder.find((sf) => tsgoPath(resolve(sf.fileName)) === path) ?? null;
}
/** A module namespace taken as a checked-dynamic value (`const u: unknown
* = ns`, `take(ns)`): the runtime's namespace object — one per module,
* Node's sorted export keys, live reads through a compiled getter, and
* Node's read-only write errors — built by `moduleNamespaceDynValue`.
* `node:` builtins have no compiled source and no complete export table,
* so they keep their fence. */
moduleNamespaceDyn(ns: IrExpr & { type: IrType & { kind: "moduleNs" } }): IrExpr | null {
if (!ns.type.moduleId.startsWith("file:")) return null;
return moduleNamespaceDynValue(this, ns);
}
/** A `Promise<module namespace>` taken as `Promise<unknown>` (or as
* `unknown`): one lifted async mapper per module awaits the promise and
* answers the same namespace object a bare namespace value boxes to, so
* the settlement is Node's namespace while the value stays a promise.
* The import() promise reaches these slots alone, and the runtime's
* per-module namespace cache keeps `await mapped === await original`; a
* rejection propagates unchanged. */
moduleNamespacePromiseDynValue(
value: IrExpr & { type: IrType & { kind: "promise" } },
): IrExpr | null {
const inner = value.type.inner;
if (inner.kind !== "moduleNs" || !inner.moduleId.startsWith("file:")) return null;
const loc = value.loc;
const name = `%module.promise:${inner.moduleId}`;
const promiseT: IrType & { kind: "promise" } = { kind: "promise", inner };
if (!this.liftedFns.some((fn) => fn.name === name)) {
const context = newFnCtx(false, null, null, DYN);
context.isAsync = true;
const previousClass = this.currentClass;
this.currentClass = null;
this.fnStack.push(context);
let namespace: IrExpr | null = null;
try {
const awaited: IrExpr & { type: IrType & { kind: "moduleNs" } } = {
kind: "awaitExpr",
value: varRef("promise", promiseT, loc),
type: inner,
loc,
};
namespace = moduleNamespaceDynValue(this, awaited);
if (namespace) {
const fn: IrFunction = {
name,
params: [{ localId: "promise", name: "promise", type: promiseT }],
returnType: DYN,
locals: [
{ id: "promise", name: "promise", type: promiseT, mutable: false },
...context.locals,
],
body: [{ kind: "return", value: namespace, loc }],
async: true,
loc,
};
this.liftedFns.push(fn);
}
} finally {
this.fnStack.pop();
this.currentClass = previousClass;
}
// A namespace with no compiled source has no dynamic value: keep the
// promise unconverted so the destination's own fence reports it.
if (!namespace) return null;
}
return {
kind: "call",
callee: name,
args: [value],
type: { kind: "promise", inner: DYN },
loc,
};
}
builtinErrorInfoOf(symbol: ts.Symbol | null | undefined): ClassInfo | null {
return builtinErrorInfoOf(this, symbol);
}
@@ -9466,6 +9567,8 @@ export class Lowerer {
guardFrozenWrites(this, functions);
applyHandleProperties(this, functions);
installBufferValue(functions);
installFetchBodyWebStreams(this, functions);
returnDispatchReceivers(this, functions);
// Globals typed by a class that never REGISTERED (a JS class whose
// collection fenced — Symbol-keyed fields, an unsupported base): the
@@ -11985,6 +12088,21 @@ export class Lowerer {
const result = this.coerceToExpected(expr.result, expected);
return { ...expr, result, type: result.type };
}
// A `Promise<module namespace>` widening into `Promise<unknown>`, or
// into an `unknown` slot: map the payload through the namespace's
// dynamic value (moduleNamespacePromiseDynValue). A destination that
// wants the namespace type itself keeps the promise unchanged; the
// union path reuses this through its single-promise-arm conversion.
if (
expr.type.kind === "promise" &&
expr.type.inner.kind === "moduleNs" &&
(expected.kind === "dyn" || (expected.kind === "promise" && expected.inner.kind === "dyn"))
) {
const mapped = this.moduleNamespacePromiseDynValue(
expr as IrExpr & { type: IrType & { kind: "promise" } },
);
if (mapped) expr = mapped;
}
// An instance of a class declared `extends Array` is its array (the
// same object); an array slot of another element type converts it as
// an array.
@@ -12132,6 +12250,16 @@ export class Lowerer {
const object = moduleNamespaceObject(this, ns) ?? moduleNamespaceValue(this, ns);
if (object) return object;
}
// A TypeScript source's module namespace as an untyped value: the
// runtime's namespace object with live reads, Node's sorted keys and
// read-only writes. `node:` builtins have no compiled source or
// complete export table here and keep their fence below.
if (expr.type.kind === "moduleNs") {
const boxed = this.moduleNamespaceDyn(
expr as IrExpr & { type: IrType & { kind: "moduleNs" } },
);
if (boxed) return boxed;
}
if (
expr.type.kind === "moduleNs" &&
expr.type.moduleId === "builtin:events" &&
@@ -23,6 +23,18 @@ export function maybePromiseUnionOf(lowerer: Lowerer, t: IrType): MaybePromiseUn
return armed;
}
/** The promise arm of a promise-or-unit union (`Promise<void> | void`,
* `Promise<T> | undefined`): exactly one promise arm and only nullish
* arms beside it, so no other arm carries a value the promise path would
* have to reconcile. A guard that proves the value present (`?.`, an
* explicit narrow) leaves this arm alone. */
export function promiseOrUnitUnionOf(lowerer: Lowerer, t: IrType): MaybePromiseUnion | null {
const armed = promiseArmedUnionOf(lowerer, t);
if (!armed) return null;
if (!armed.def.arms.every((a, i) => i === armed.promiseTag || isUnitType(a))) return null;
return armed;
}
/** Any union with exactly one promise arm: a maybe-promise union or a
* promise-or-unit union (`Promise<void> | void`). An async `return`
* resolves both the same way: adopt the promise arm, fulfill otherwise. */
@@ -4,6 +4,7 @@ import * as ts from "../ts7/adapter.js";
import {
DYN,
STRING,
SYMBOL_T,
type IrExpr,
type IrFunction,
type IrStmt,
@@ -173,28 +174,28 @@ export function moduleNamespaceObject(
};
}
/** A module namespace taken as an untyped value whose exports a copy
* cannot represent: a binding that can change after initialization (`let`,
* `var`), or an export the copy cannot name. The value is the runtime's
* namespace object (scr_module_namespace_value): one per module, whose
* property reads call a compiled getter for the export's current value and
* whose writes throw Node's TypeError. An export whose value has no
* checked-dynamic form throws its fence when it is read, and listing the
* object's keys or inspecting it throws the "not supported yet" error. */
export function moduleNamespaceValue(
/** The compiled getter behind a namespace object: one lifted function per
* module that answers an export's current value by name, plus the module's
* sorted export names. An export whose value has no checked-dynamic form
* throws its fence when it is read. */
function moduleNamespaceGetter(
lowerer: Lowerer,
ns: IrExpr & { type: IrType & { kind: "moduleNs" } },
): IrExpr | null {
): { name: string; keys: string[] } | null {
const source = lowerer.sourceFileOfModuleNamespace(ns.type);
if (!source) return null;
const symbol = lowerer.checker.getSymbolAtLocation(source);
if (!symbol) return null;
const exports: { key: string; declaration: ts.Node | undefined }[] = [];
const exports: { key: string; declaration: ts.Node | undefined; symbol: ts.Symbol }[] = [];
for (const exported of lowerer.checker.getExportsOfModule(symbol)) {
const target =
exported.flags & ts.SymbolFlags.Alias ? lowerer.checker.getAliasedSymbol(exported) : exported;
if (!(target.flags & ts.SymbolFlags.Value)) continue;
exports.push({ key: ts.symbolName(exported), declaration: target.valueDeclaration });
exports.push({
key: ts.symbolName(exported),
declaration: target.valueDeclaration,
symbol: target,
});
}
exports.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
const name = `%module.namespace:${ns.type.moduleId}`;
@@ -206,24 +207,37 @@ export function moduleNamespaceValue(
lowerer.fnStack.push(context);
try {
const body: IrStmt[] = [];
for (const { key, declaration } of exports) {
for (const { key, declaration, symbol: exportSymbol } of exports) {
const node = declaration ?? source;
const diagsBefore = lowerer.diags.length;
let read: IrStmt[];
try {
const identifier = declaration?.name as ts.Node | undefined;
if (!identifier || !ts.isIdentifier(identifier)) {
lowerer.unsupported(
"SC1090",
node,
`reading the unnamed export '${key}' through a module namespace object`,
);
let value: IrExpr;
if (identifier && ts.isIdentifier(identifier)) {
value = lowerer.lowerExprExpecting(identifier, DYN);
} else {
// `export default <expression>` and the anonymous default
// class/function have no named binding to read. The module's
// pre-registered default global holds the value the export
// statement snapshotted — the same storage a default import
// reads — so the namespace property answers it instead of
// fencing. A default whose type has no checked-dynamic form
// still fails the coercion below and keeps its fence.
const storage = lowerer.globalsBySymbol.get(exportSymbol);
if (!storage) {
lowerer.unsupported(
"SC1090",
node,
`reading the unnamed export '${key}' through a module namespace object`,
);
}
value = lowerer.coerceToExpected(varRef(storage.id, storage.type, loc), DYN);
}
const value = lowerer.lowerExprExpecting(identifier, DYN);
if (value.type.kind !== "dyn") {
lowerer.unsupported(
"SC1101",
identifier,
identifier ?? node,
`reading the export '${key}' ('${lowerer.fmt(value.type)}') through a module namespace object`,
);
}
@@ -263,6 +277,24 @@ export function moduleNamespaceValue(
lowerer.currentClass = previousClass;
}
}
return { name, keys: exports.map(({ key }) => key) };
}
/** A module namespace taken as an untyped value whose exports a copy
* cannot represent: a binding that can change after initialization (`let`,
* `var`), or an export the copy cannot name. The value is the runtime's
* namespace object (scr_module_namespace_value): one per module, whose
* property reads call a compiled getter for the export's current value and
* whose writes throw Node's TypeError. An export whose value has no
* checked-dynamic form throws its fence when it is read, and listing the
* object's keys or inspecting it throws the "not supported yet" error. */
export function moduleNamespaceValue(
lowerer: Lowerer,
ns: IrExpr & { type: IrType & { kind: "moduleNs" } },
): IrExpr | null {
const getter = moduleNamespaceGetter(lowerer, ns);
if (!getter) return null;
const loc = ns.loc;
const value: IrExpr = {
kind: "libCall",
fn: "module.namespaceValue",
@@ -270,13 +302,82 @@ export function moduleNamespaceValue(
strLit(ns.type.moduleId, loc),
{
kind: "arrayLit",
elems: exports.map(({ key }) => strLit(key, loc)),
elems: getter.keys.map((key) => strLit(key, loc)),
type: arrayOf(STRING),
loc,
},
{
kind: "dynFrom",
value: { kind: "closure", fnName: name, captures: [], type: funcOf([STRING], DYN), loc },
value: {
kind: "closure",
fnName: getter.name,
captures: [],
type: funcOf([STRING], DYN),
loc,
},
type: DYN,
loc,
},
],
type: DYN,
loc,
};
if (ns.kind === "moduleNsRef") return value;
return {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: ns, loc }],
result: value,
type: DYN,
loc,
};
}
/** A module namespace boxed for typed code through a dynamic slot
* (`const u: unknown = ns`, `take(ns)`): Node's namespace object as
* scr_module_namespace_dyn.c builds it — one non-extensible null-prototype
* object per module whose own sorted export names are enumerable read-only
* accessors, whose `Symbol.toStringTag` is "Module", whose reads call the same
* compiled getter, and whose writes and coerces throw Node's TypeErrors. A
* `node:` builtin has no compiled export table, so it keeps its fence. */
export function moduleNamespaceDynValue(
lowerer: Lowerer,
ns: IrExpr & { type: IrType & { kind: "moduleNs" } },
): IrExpr | null {
const getter = moduleNamespaceGetter(lowerer, ns);
if (!getter) return null;
const loc = ns.loc;
const value: IrExpr = {
kind: "libCall",
fn: "module.namespaceDyn",
args: [
strLit(ns.type.moduleId, loc),
{
kind: "arrayLit",
elems: getter.keys.map((key) => strLit(key, loc)),
type: arrayOf(STRING),
loc,
},
{
kind: "dynFrom",
value: {
kind: "closure",
fnName: getter.name,
captures: [],
type: funcOf([STRING], DYN),
loc,
},
type: DYN,
loc,
},
{
kind: "dynFrom",
value: {
kind: "libCall",
fn: "sym.wellKnown",
args: [strLit("toStringTag", loc)],
type: SYMBOL_T,
loc,
},
type: DYN,
loc,
},
@@ -0,0 +1,147 @@
/* Methods that return their receiver, called through an untyped value.
*
* A JavaScript method that ends every path with `return this` (a fluent
* setter such as `step(dx, dy) { this.x += dx; return this; }`) boxes its
* receiver on every call: a lookup of the instance's live capsule, or a new
* capsule when it has none. A class dispatch helper
* (`%dyn.class.call.N`) that calls such a method already holds a capsule
* of the same instance at its exact class: its receiver after
* normalization. The helper now calls a copy of the method that returns
* nothing and answers its own receiver, so the call boxes nothing. A
* direct call whose result nothing reads calls the copy too. Other callers
* (vtables, method values, calls that use the result) keep the method
* itself. */
import type { IrExpr, IrFunction, IrStmt } from "../../ir/ir.js";
import { VOID } from "../../ir/ir.js";
import { varRef } from "../../ir/build.js";
import { everyStmtList, transformStmtList } from "../../ir/traverse.js";
import type { Lowerer } from "./lowerer.js";
const DISPATCH_PREFIX = "%dyn.class.call.";
const BOX_EXACT_PREFIX = "%dyn.class.boxExact:";
/** The name of a method's copy that returns nothing. */
function receiverCopyName(method: string): string {
return `%dyn.class.receiver:${method.slice(1)}`;
}
export function returnDispatchReceivers(lowerer: Lowerer, functions: IrFunction[]): void {
const byName = new Map(functions.map((fn) => [fn.name, fn]));
const copies = new Map<string, IrFunction | null>();
const copyOf = (name: string): IrFunction | null => {
const known = copies.get(name);
if (known !== undefined) return known;
const method = byName.get(name);
const copy = method && returnsReceiver(lowerer, method) ? receiverCopy(method) : null;
copies.set(name, copy);
if (copy) functions.push(copy);
return copy;
};
for (const helper of functions) {
if (!helper.name.startsWith(DISPATCH_PREFIX)) continue;
const receiver = helper.params[0]?.localId;
if (receiver === undefined || !normalizedOnly(helper, receiver)) continue;
for (const stmt of helper.body) {
if (stmt.kind !== "if" || stmt.then.length !== 1) continue;
const only = stmt.then[0]!;
if (only.kind !== "return" || only.value === null || only.value.kind !== "call") continue;
const call = only.value;
if (!receiverArgument(call.args[0], receiver)) continue;
const copy = copyOf(call.callee);
if (!copy) continue;
stmt.then = [
{
kind: "exprStmt",
expr: { ...call, callee: copy.name, type: VOID },
loc: only.loc,
},
{ kind: "return", value: varRef(receiver, only.value.type, only.loc), loc: only.loc },
];
}
}
// A direct call whose result nothing reads (`builder.set(1);` in typed
// code) needs no capsule at all.
for (const fn of [...functions]) {
everyStmtList(fn.body, {
stmt: (s) => {
if (s.kind === "exprStmt" && s.expr.kind === "call") {
const copy = copyOf(s.expr.callee);
if (copy) s.expr = { ...s.expr, callee: copy.name, type: VOID };
}
return true;
},
expr: () => true,
});
}
}
/** The helper's receiver is assigned only by its normalization, so it is
* the capsule of the instance's own class wherever a branch reads it. */
function normalizedOnly(helper: IrFunction, receiver: string): boolean {
return everyStmtList(helper.body, {
stmt: (s) =>
!(
(s.kind === "assign" || s.kind === "forOf") &&
s.localId === receiver &&
!(
s.kind === "assign" &&
s.value.kind === "call" &&
s.value.callee === "%dyn.class.normalize"
)
),
expr: (e) => !((e.kind === "assignExpr" || e.kind === "incDec") && e.localId === receiver),
});
}
/** `dynCheck(receiver)`, possibly upcast to the method's class. */
function receiverArgument(arg: IrExpr | undefined, receiver: string): boolean {
if (arg?.kind === "upcast") arg = arg.value;
return arg?.kind === "dynCheck" && arg.value.kind === "varRef" && arg.value.localId === receiver;
}
/** Whether every path of a synchronous method returns `this` boxed at its
* exact class. */
function returnsReceiver(lowerer: Lowerer, fn: IrFunction): boolean {
if (fn.async !== undefined || fn.generator !== undefined) return false;
if (fn.captures?.length || fn.classCaptures?.length) return false;
if (fn.returnType.kind !== "dyn") return false;
const self = fn.params[0];
if (self?.type.kind !== "object" || self.name !== "this") return false;
const last = fn.body[fn.body.length - 1];
if (last?.kind !== "return") return false;
const isSelf = (value: IrExpr | null): boolean => {
if (value === null) return false;
if (value.kind === "call" && value.callee.startsWith(BOX_EXACT_PREFIX))
return thisRef(value.args[0], self.localId);
// Without exact boxing the capsule names the static class, which is
// the instance's own class only when nothing extends it.
if (value.kind === "dynFrom" && value.liveRef === undefined && value.fnName === undefined) {
const className = self.type.kind === "object" ? self.type.className : "";
return (
thisRef(value.value, self.localId) &&
(lowerer.classes.get(className)?.subclasses.length ?? 1) === 0
);
}
return false;
};
return everyStmtList(fn.body, {
stmt: (s) => s.kind !== "return" || isSelf(s.value),
expr: () => true,
});
}
function thisRef(value: IrExpr | undefined, thisId: string): boolean {
return value?.kind === "varRef" && value.localId === thisId;
}
/** A copy of `method` whose returns return nothing. */
function receiverCopy(method: IrFunction): IrFunction {
const copy = structuredClone(method);
copy.name = receiverCopyName(method.name);
copy.returnType = VOID;
copy.body = transformStmtList(copy.body, {
stmt: (s): IrStmt => (s.kind === "return" ? { ...s, value: null } : s),
expr: (e) => e,
});
return copy;
}
@@ -2197,7 +2197,9 @@ export const BUILTIN_MODULE_FN_ALIASES: Record<
rename: ["fs.checkPath"],
readlinkSync: ["fs.readlinkSyncStr", "fs.readlinkSyncBuffer"],
// The bytes-data form and the { mode } options form.
writeFileSync: ["fs.writeFileSyncBytes", "fs.writeFileModeSync"],
// The bytes-data form, the { mode } options form, and the
// file-descriptor data form.
writeFileSync: ["fs.writeFileSyncBytes", "fs.writeFileModeSync", "fs.writeFileSyncFd"],
appendFileSync: ["fs.appendFileSyncBytes"],
// The fs stream members (bytesRead/bytesWritten, pending, path, close).
createReadStream: [
@@ -62,3 +62,28 @@ export function nodeApiCalleeOf(program: ts.Program, callee: ts.Expression): Nod
if (!isNodeApiMember(member)) return null;
return isNodeApiNamespace(program, callee.expression) ? member : null;
}
/** Whether an expression evaluates to a value owned by a Node island: a
* requireModule(...) result or anything reached from one through member
* reads, element reads, destructuring or a const alias (`const { Point } =
* requireModule("./lib.cjs")`, `lib.Point`, `const P = lib.Point`). Used
* to refuse native subclasses of island classes (SC8011). */
export function islandValueOrigin(program: ts.Program, expr: ts.Expression, depth = 0): boolean {
if (depth > 8) return false;
let e: ts.Node = expr;
for (;;) {
if (ts.isParenthesizedExpression(e)) e = e.expression;
else if (ts.isPropertyAccessExpression(e) || ts.isElementAccessExpression(e)) e = e.expression;
else if (ts.isAsExpression(e) || ts.isNonNullExpression(e)) e = e.expression;
else break;
}
if (ts.isCallExpression(e)) return nodeApiCalleeOf(program, e.expression) === "requireModule";
if (!ts.isIdentifier(e)) return false;
const symbol = program.getTypeChecker().getSymbolAtLocation(e);
let decl: ts.Node | undefined = symbol ? symbol.declarations[0] : undefined;
// A destructuring binding: climb to the declaration that initializes it.
while (decl !== undefined && ts.isBindingElement(decl)) decl = decl.parent?.parent;
if (decl === undefined || !ts.isVariableDeclaration(decl) || decl.initializer === undefined)
return false;
return islandValueOrigin(program, decl.initializer, depth + 1);
}
+226 -16
View File
@@ -1,7 +1,17 @@
import { shapeOverlayReport } from "./shape-inference/index.js";
/** Shared frontend orchestration. Hosts supply a program loader so Node
* and native callers use the same npm selection, preflight and lowering. */
import {
beginShapeOverlayBuild,
excludeRefusedOverlay,
recordShapeOverlayMemo,
restoreShapeOverlayState,
setShapeOverlayDropped,
shapeOverlayActive,
shapeOverlayMemoPlainFences,
shapeOverlayReport,
} from "./shape-inference/index.js";
import type { ScrDiagnostic } from "../diagnostics/diagnostic.js";
import type { IrModule } from "../ir/ir.js";
import type { SrcLoc } from "../ir/ir.js";
import type { NpmStaticStatus } from "../coverage/report.js";
import { entryFunctionExports, type EntryExportInfo } from "./lib-exports.js";
@@ -110,7 +120,9 @@ export interface Frontend {
* dispose — it reads the ts7 AST). */
entryContract: () => ContractFacts;
sourceTexts: () => Map<string, string>;
lower: (opts: LowerOptions) => LowerResult;
/** `valid`: the caller's IR validation; a module it rejects with the
* shape overlay on is lowered again without the overlay. */
lower: (opts: LowerOptions, valid?: (module: IrModule) => boolean) => LowerResult;
/** Each requested or detected package's native compilation outcome,
* including the first refusal reason for ineligible packages. */
npmStatic: NpmStaticStatus[];
@@ -517,6 +529,10 @@ export function runFrontend(
}
}
/** Rounds of dropping single refused overlay annotations before the
* fallback drops whole files' overlays. */
const OVERLAY_ANNOTATION_ROUNDS = 4;
function loadFrontend(
entryPath: string,
loadProgram: ProgramLoader,
@@ -531,6 +547,7 @@ function loadFrontend(
// separate compiles in a long-lived process. A cache miss must observe
// package.json edits before it can publish a new early-library entry.
clearResolveCaches();
beginShapeOverlayBuild();
const statuses: NpmStaticStatus[] = [];
const npmSites = new Map<string, SrcLoc>();
// Node island detection (the report, or a build that may run islands):
@@ -615,6 +632,32 @@ function loadFrontend(
// and library builds with a package-specific diagnostic.
let load = reusableScout ?? loadProgram(entryPath, { npmStatic: requested, externalTypes });
let preflight = reusablePreflight ?? checkPreflight(load);
// Shape inference never makes the preflight fail: when the overlay's
// program reports diagnostics the program without it does not (its type
// references can load package files the program would not), the build
// continues without the overlay.
if (preflight.length > 0 && load.shapeOverlayState !== undefined) {
restoreShapeOverlayState(load.shapeOverlayState);
if (shapeOverlayActive()) {
const key = (d: ScrDiagnostic): string => `${d.code}|${d.loc.file}|${d.loc.start}`;
setShapeOverlayDropped(true);
const plain = loadProgram(entryPath, { npmStatic: requested, externalTypes });
const plainPreflight = checkPreflight(plain);
const known = new Set(plainPreflight.map(key));
if (preflight.some((d) => !known.has(key(d)))) {
load.dispose();
load = plain;
preflight = plainPreflight;
if (process.env["SCRIPTC_DEBUG_SHAPE_FALLBACK"] !== undefined)
process.stderr.write(
`[shape-fallback] preflight: the overlay's program failed; overlay off\n`,
);
} else {
plain.dispose();
setShapeOverlayDropped(false);
}
}
}
// Library mode's fixpoint: the opted-in packages' files joined the
// program just now, and THEIR bare edges (import statements and
// top-level requires) name packages the scout could not see. Judge each
@@ -755,7 +798,9 @@ function loadFrontend(
}
}
const finalLoad = load;
// The lowering may reload once the overlay drops refused annotations.
let finalLoad = load;
const finalNpmStatic = [...effective];
// An unknown key or value in the project's scriptc configuration makes
// the Node island report unavailable (SC8004). The settings configure
// only that report: a build warns about them and goes on.
@@ -814,18 +859,183 @@ function loadFrontend(
texts.set(projectConfig.manifest, projectConfig.manifestText);
return texts;
},
lower: (opts) => {
const lowered = lowerToIr(finalLoad.program, finalLoad.entry, finalLoad.moduleOrder, {
...opts,
frontendServices: finalLoad.services,
startupCrash: finalLoad.startupCrash ?? null,
inlineRequireCycleCalls: finalLoad.inlineRequireCycleCalls,
moduleEdges: finalLoad.moduleEdges,
dynamicImportRejections: finalLoad.dynamicImportRejections,
projectCheckJs: finalLoad.typeWorld?.projectCheckJs ?? false,
externalTypes: finalLoad.externalTypes,
externalTypeSpecifiersByFile: finalLoad.externalTypeSpecifiersByFile,
});
lower: (opts, valid) => {
const lowerLoad = (l: typeof finalLoad): LowerResult => {
if (l.shapeOverlayState !== undefined) restoreShapeOverlayState(l.shapeOverlayState);
return lowerToIr(l.program, l.entry, l.moduleOrder, {
...opts,
frontendServices: l.services,
startupCrash: l.startupCrash ?? null,
inlineRequireCycleCalls: l.inlineRequireCycleCalls,
moduleEdges: l.moduleEdges,
dynamicImportRejections: l.dynamicImportRejections,
projectCheckJs: l.typeWorld?.projectCheckJs ?? false,
externalTypes: l.externalTypes,
externalTypeSpecifiersByFile: l.externalTypeSpecifiersByFile,
});
};
const lowerOnce = (): LowerResult => lowerLoad(finalLoad);
let lowered = lowerOnce();
// Shape inference never breaks compilation (shape-inference/
// fallback.ts): refusals drop the overlay annotations they depend on
// and the program reloads. Rounds that stop converging escalate to
// the overlay of every file involved, then to no overlay at all.
// Refusals deferred to run time (runtime fences) are compared with
// the build without the overlay below: a fence the overlay did not
// cause stays as it is without the overlay.
let level: "annotations" | "files" | "all" = "annotations";
// Deferred refusals the coverage pass found in code the lowering
// judged unreached count too: that code can still run.
const fencesOf = (l: LowerResult): ScrDiagnostic[] => [
...l.runtimeFences,
...(l.unreached?.runtimeFences ?? []),
];
if (process.env["SCRIPTC_DEBUG_SHAPE_FALLBACK"] !== undefined)
process.stderr.write(
`[shape-fallback] lowered: overlay ${shapeOverlayActive()}, ${lowered.diagnostics.length} refusals, ${fencesOf(lowered).length} deferred\n`,
);
// Failing refusals first; deferred refusals are compared with the
// build without the overlay below.
for (let round = 0; shapeOverlayActive(); round++) {
const refusals = lowered.diagnostics;
if (refusals.length === 0) break;
if (round >= OVERLAY_ANNOTATION_ROUNDS && level === "annotations") level = "files";
else if (round > OVERLAY_ANNOTATION_ROUNDS) level = "all";
let changed = excludeRefusedOverlay(finalLoad.program, refusals, level);
while (!changed && level !== "all") {
level = level === "annotations" ? "files" : "all";
changed = excludeRefusedOverlay(finalLoad.program, refusals, level);
}
// The program reloads anyway: the same round also drops what the
// deferred refusals blame, which often saves the comparison below.
if (level !== "all" && fencesOf(lowered).length > 0)
changed =
excludeRefusedOverlay(finalLoad.program, fencesOf(lowered), "fences") || changed;
if (process.env["SCRIPTC_DEBUG_SHAPE_FALLBACK"] !== undefined)
process.stderr.write(
`[shape-fallback] round ${round} level ${level}: ${refusals.length} refusals (${refusals
.slice(0, process.env["SCRIPTC_DEBUG_SHAPE_FALLBACK"] === "all" ? undefined : 3)
.map(
(d) =>
`${d.loc.file.split("/").pop()}:${d.loc.start} ${process.env["SCRIPTC_DEBUG_SHAPE_FALLBACK"] === "all" ? d.message : d.message.slice(0, 60)}`,
)
.join("; ")}), changed ${changed}\n`,
);
if (!changed) break;
const t0 = performance.now();
finalLoad.dispose();
finalLoad = loadProgram(entryPath, { npmStatic: finalNpmStatic, externalTypes });
const t1 = performance.now();
// The preflight fills the module order; the tsc world reads the
// files as written, so it answers as the first load did.
const reloadPreflight = checkPreflight(finalLoad);
if (reloadPreflight.length > 0)
return { ...lowered, module: null, diagnostics: reloadPreflight };
const t2 = performance.now();
lowered = lowerOnce();
if (process.env["SCRIPTC_DEBUG_SHAPE_FALLBACK"] !== undefined)
process.stderr.write(
`[shape-fallback] reload ${(t1 - t0).toFixed(0)} ms, preflight ${(t2 - t1).toFixed(0)} ms, lower ${(performance.now() - t2).toFixed(0)} ms\n`,
);
}
// Deferred refusals the program without the overlay does not have
// mean the overlay changed what runs (the overlay must not add
// failures the default build lacks). The build lowers the program
// without the overlay once, drops what the added refusals blame and
// reloads; when they persist (or blame nothing), it compiles without
// the overlay, reusing that lowering. A memo of the same JavaScript
// that already knows every remaining refusal skips the comparison.
let plainFences: Set<string> | null = null;
if (shapeOverlayActive() && lowered.diagnostics.length === 0) {
const keyOf = (l: typeof finalLoad, d: ScrDiagnostic): string => {
const sf = l.program.getSourceFile(d.loc.file);
const line = sf === undefined ? -1 : sf.getLineAndCharacterOfPosition(d.loc.start).line;
return `${d.code}|${d.loc.file}|${line}`;
};
const remembered = shapeOverlayMemoPlainFences();
const fences = fencesOf(lowered);
const knownByMemo =
remembered !== null && fences.every((d) => remembered.has(keyOf(finalLoad, d)));
if (fences.length > 0 && !knownByMemo) {
const overlayState = finalLoad.shapeOverlayState;
setShapeOverlayDropped(true);
const plainLoad = loadProgram(entryPath, { npmStatic: finalNpmStatic, externalTypes });
const plainPreflight = checkPreflight(plainLoad);
const plain = plainPreflight.length === 0 ? lowerLoad(plainLoad) : null;
setShapeOverlayDropped(false);
if (overlayState !== undefined) restoreShapeOverlayState(overlayState);
if (plain === null || plain.diagnostics.length > 0) {
// Nothing to compare with: the default build itself fails.
plainLoad.dispose();
} else {
const known = new Set(fencesOf(plain).map((d) => keyOf(plainLoad, d)));
plainFences = known;
let usePlain = false;
for (let round = 0; ; round++) {
const added = fencesOf(lowered).filter((d) => !known.has(keyOf(finalLoad, d)));
if (added.length === 0) break;
const changed =
round < OVERLAY_ANNOTATION_ROUNDS &&
excludeRefusedOverlay(finalLoad.program, added, "fences");
if (process.env["SCRIPTC_DEBUG_SHAPE_FALLBACK"] !== undefined)
process.stderr.write(
`[shape-fallback] ${added.length} deferred refusals the default build lacks (${added
.slice(0, 3)
.map((d) => `${d.loc.file.split("/").pop()}: ${d.message.slice(0, 60)}`)
.join("; ")}), changed ${changed}\n`,
);
if (!changed) {
usePlain = true;
break;
}
finalLoad.dispose();
finalLoad = loadProgram(entryPath, { npmStatic: finalNpmStatic, externalTypes });
if (checkPreflight(finalLoad).length > 0) {
usePlain = true;
break;
}
lowered = lowerOnce();
if (lowered.diagnostics.length > 0) {
usePlain = true;
break;
}
}
if (usePlain) {
if (process.env["SCRIPTC_DEBUG_SHAPE_FALLBACK"] !== undefined)
process.stderr.write(`[shape-fallback] overlay off\n`);
setShapeOverlayDropped(true);
if (plainLoad.shapeOverlayState !== undefined)
restoreShapeOverlayState(plainLoad.shapeOverlayState);
finalLoad.dispose();
finalLoad = plainLoad;
lowered = plain;
} else {
plainLoad.dispose();
}
}
}
}
// A module the caller's IR validator rejects (a lowering bug the
// overlay's types reach) compiles without the overlay.
if (
shapeOverlayActive() &&
lowered.module !== null &&
valid !== undefined &&
!valid(lowered.module)
) {
if (process.env["SCRIPTC_DEBUG_SHAPE_FALLBACK"] !== undefined)
process.stderr.write(
`[shape-fallback] the IR validator rejected the module; overlay off\n`,
);
setShapeOverlayDropped(true);
finalLoad.dispose();
finalLoad = loadProgram(entryPath, { npmStatic: finalNpmStatic, externalTypes });
const reloadPreflight = checkPreflight(finalLoad);
if (reloadPreflight.length > 0)
return { ...lowered, module: null, diagnostics: reloadPreflight };
lowered = lowerOnce();
}
recordShapeOverlayMemo(plainFences);
// The declaration-typed boundary's notes (SC6102) join the
// performance warnings; they never fail a build.
const notes = [
@@ -859,6 +1069,6 @@ function loadFrontend(
refusals,
};
},
dispose: finalLoad.dispose,
dispose: () => finalLoad.dispose(),
};
}
+70 -11
View File
@@ -154,8 +154,11 @@ import { resetDeclOriginVersions } from "./lowering/decl-origin.js";
import {
reportShapes,
resetShapeOverlay,
shapeOverlayDone,
shapeOverlayEnabled,
shapeOverlayer,
captureShapeOverlayState,
type ShapeOverlayLoadState,
} from "./shape-inference/index.js";
import { inferredJsDiagnosticSuppressed } from "./inferred-js-diagnostics.js";
import {
@@ -374,6 +377,9 @@ export interface TypeWorldDifferences {
export interface LoadResult {
program: ts.Program;
entry: ts.SourceFile;
/** Shape inference's overlay state for this load (restored before its
* lowering: a later load resets the per-process registry). */
shapeOverlayState?: ShapeOverlayLoadState;
/** User source files in Node's evaluation order: depth-first postorder
* of the import graph from the entry, each module once. The entry is
* last. Empty until checkPreflight runs (and empty when preflight fails
@@ -901,7 +907,7 @@ function loadProgram7(
projector?: (program: ts.Program) => DeclProjection | null,
/** Shape inference's typed overlay (shape-inference/overlay.ts):
* rewritten package JavaScript for the lowering world, or null. */
overlayer?: (program: ts.Program) => ReadonlyMap<string, string> | null,
overlayer?: (program: ts.Program, round: number) => ReadonlyMap<string, string> | null,
): LoadResult & { disposeAll: () => void } {
const config = adoptProjectConfig7(host, entryPath);
const configuredPaths = config.options["paths"];
@@ -1055,23 +1061,45 @@ function loadProgram7(
}
}
// Shape inference's overlay: package files typed from proven facts. The
// tsc gate keeps reading the files as written.
// A load without the overlay must not see an earlier load's overlay
// files (the registry is per process).
resetShapeOverlay();
// tsc gate keeps reading the files as written. loadProgram resets the
// overlay registry before the lowering world's load.
const overlayOriginals = new Map<string, string | null>();
if (overlayer !== undefined && jsx.refusal === null) {
const overlay = overlayer(program);
if (overlay !== null && overlay.size > 0) {
// Rounds: structural rewrites, then the typed overlay of the rebuilt
// program (shape-inference/index.ts).
const timeShapes = process.env["SCRIPTC_DEBUG_SHAPE_TIME"] !== undefined;
for (let round = 0; round < 3; round++) {
const t0 = timeShapes ? performance.now() : 0;
const overlay = overlayer(program, round);
if (timeShapes)
process.stderr.write(
`[shape-time] round ${round} overlayer ${(performance.now() - t0).toFixed(0)} ms\n`,
);
if (overlay === null || overlay.size === 0) break;
const t1 = timeShapes ? performance.now() : 0;
const before = program;
for (const [file, text] of overlay) host.addVirtualFile(file, text);
program = host.createProgram([...programRoots, overridesDtsPath()], options);
// The tsc gate reads the files as written: its diagnostics are
// taken before the overlay's files replace them on the host, and
// its project world (load.projectWorld()) is built over the files
// as written.
if (tscWorld === undefined)
tscWorld = {
program: before,
diagnostics: ts.getPreEmitDiagnostics(before),
projectWorld: null,
declWorldFallback: true,
};
else if (tscWorld.program !== before) before.dispose();
for (const file of overlay.keys())
if (!overlayOriginals.has(file))
overlayOriginals.set(file, before.getSourceFile(file)?.text ?? null);
for (const [file, text] of overlay) host.addVirtualFile(file, text);
program = host.createProgram([...programRoots, overridesDtsPath()], options);
if (tscWorld.program !== before) before.dispose();
if (timeShapes)
process.stderr.write(
`[shape-time] round ${round} gate+program ${(performance.now() - t1).toFixed(0)} ms\n`,
);
if (shapeOverlayDone()) break;
}
}
const entry = program.getSourceFile(entryPath);
@@ -1081,6 +1109,7 @@ function loadProgram7(
return {
program,
entry,
shapeOverlayState: captureShapeOverlayState(),
moduleOrder: [],
configDiags: config.diags,
...(declBoundaryNotes.length > 0 ? { declBoundaryNotes } : {}),
@@ -1094,7 +1123,10 @@ function loadProgram7(
externalTypeSpecifiersByFile,
...(tscWorld === undefined ? {} : { jsxSources: jsx.sources }),
...(tscWorld === undefined ? {} : { tscWorld }),
projectWorld: () => (projectWorld ??= host.createProgram(programRoots, options)),
projectWorld: () =>
(projectWorld ??= withFilesAsWritten(host, overlayOriginals, () =>
host.createProgram(programRoots, options),
)),
disposeAll: () => {
try {
projectWorld?.dispose();
@@ -1122,6 +1154,27 @@ export interface ProgramLoadOptions {
externalTypes?: readonly (readonly [string, string])[];
}
/** Runs `build` with the overlay's files served as written (their original
* texts), then serves the overlay's texts again. */
function withFilesAsWritten<T>(
host: ts.Ts7Host,
originals: ReadonlyMap<string, string | null>,
build: () => T,
): T {
if (originals.size === 0) return build();
const overlaid = new Map<string, string>();
for (const [file, text] of originals) {
const current = host.virtualFileText(file);
if (current !== undefined) overlaid.set(file, current);
if (text !== null) host.addVirtualFile(file, text);
}
try {
return build();
} finally {
for (const [file, text] of overlaid) host.addVirtualFile(file, text);
}
}
export function loadProgram(
entryPath: string,
services: FrontendServices,
@@ -1335,6 +1388,12 @@ export function loadProgram(
return projection;
};
try {
// The overlay registry is per process and describes the lowering
// world's load: a load without the overlay must not see an earlier
// load's overlay files. The authored world (authored()) is a second load
// of the same build, which preflight can create after the overlay
// rounds, so it leaves the registry as this load set it.
resetShapeOverlay();
const load = loadProgram7(
host,
entryPath,
File diff suppressed because it is too large Load Diff
@@ -42,6 +42,8 @@ export interface Candidate {
/** Whether the creating syntax is a plain data literal. */
readonly plain: boolean;
readonly inPackage: boolean;
/** The per-call-site clone that allocates it ("" outside clones). */
readonly ctx?: string;
}
export interface ConstantObject {
@@ -187,6 +189,34 @@ export function computeConstantGraphs(analysis: ShapeAnalysis): ConstantGraphs {
return result;
};
// A per-call-site clone (analyze.ts siteClone) runs once when its one
// call site runs once: its caller is module code, or another such clone.
const edgesToInfo = new Map<FunctionInfo, (typeof analysis.calls)[number][]>();
for (const e of analysis.calls) {
let l = edgesToInfo.get(e.callee);
if (l === undefined) edgesToInfo.set(e.callee, (l = []));
l.push(e);
}
const onceInfoMemo = new Map<FunctionInfo, boolean>();
const onceInfo = (info: FunctionInfo): boolean => {
if (ts.isSourceFile(info.node)) return true;
if (!info.ctx.includes("@S")) return once(info.node);
const known = onceInfoMemo.get(info);
if (known !== undefined) return known;
onceInfoMemo.set(info, false);
const edges = edgesToInfo.get(info) ?? [];
const result =
!escapedNodes.has(info.node) &&
edges.length > 0 &&
new Set(edges.map((e) => e.site)).size === 1 &&
edges.every(
(e) =>
!e.inLoop && e.caller !== null && !inLoop(e.site, e.caller.node) && onceInfo(e.caller),
);
onceInfoMemo.set(info, result);
return result;
};
// Whether every call of a function comes from once-only code outside
// loops, through any number of call sites: an init factory, whose
// allocations are one object per call site (schema builders called at
@@ -240,6 +270,7 @@ export function computeConstantGraphs(analysis: ShapeAnalysis): ConstantGraphs {
site.keys === null
),
inPackage: site.inPackage,
ctx: site.ctx,
});
}
const inPackage = (n: Node): boolean =>
@@ -271,7 +302,13 @@ export function computeConstantGraphs(analysis: ShapeAnalysis): ConstantGraphs {
// A function object is created where its expression or declaration
// runs; its prototype with it.
const owner = ownerFunction(site.node);
if (!once(owner) || inLoop(site.node, owner)) {
// Allocated in a per-call-site clone: once when that clone runs once.
const cloneOwner =
site.ctx !== undefined && site.ctx !== ""
? infosByNode.get(owner)?.find((i) => i.ctx === site.ctx)
: undefined;
const runsOnce = cloneOwner !== undefined ? onceInfo(cloneOwner) : once(owner);
if (!runsOnce || inLoop(site.node, owner)) {
const factory = !inLoop(site.node, owner) && initOnly(owner);
if (site.inPackage || factory)
nearMisses.push({
@@ -348,10 +385,14 @@ export function computeConstantGraphs(analysis: ShapeAnalysis): ConstantGraphs {
mixedReads = 0;
for (const op of analysis.ops) {
if (op.kind !== "read" || op.name === null) continue;
const atoms = analysis.nodes[op.recv]!.atoms;
const constant = [...atoms].filter((a) => constantKeys.get(a)?.has(op.name!) === true);
// A nullish receiver throws (absent keys read undefined in the analysis).
const optional = (op.node.flags & ts.NodeFlags.OptionalChain) !== 0;
const atoms = [...analysis.nodes[op.recv]!.atoms].filter(
(a) => optional || !(a.kind === "prim" && (a.label === "undefined" || a.label === "null")),
);
const constant = atoms.filter((a) => constantKeys.get(a)?.has(op.name!) === true);
if (constant.length === 0) continue;
if (atoms.size === 1) {
if (atoms.length === 1) {
foldableReads++;
const p = op.node.parent;
if (p !== undefined && ts.isCallExpression(p) && p.expression === op.node) foldableCalls++;

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