Sync runtime and compiler updates into canary

- Merge main's runtime optimizations and CI changes with canary's frontend improvements.
- Preserve adaptive cycle reclamation with inline allocation and deallocation.
- Repair native bootstrap callback narrowing and emitter expectations.
This commit is contained in:
Chris Tate
2026-10-08 17:41:20 -05:00
245 changed files with 115411 additions and 1396 deletions
+9
View File
@@ -0,0 +1,9 @@
# CI for stacked pull requests
CI follows GitHub's [lowest unmerged pull request condition](https://docs.github.com/en/pull-requests/how-tos/merge-and-close-pull-requests/optimizing-ci-for-stacked-pull-requests): a native stacked PR gets full validation when its base branch matches the stack's base branch. After a bottom PR merges, GitHub rebases the next PR onto the stack base and its new CI run performs full validation. The original stack position can remain greater than 1.
Ordinary PRs, fork PRs, outside contributors, pushes to `main`, and events with missing or incomplete scope metadata always receive full CI. Only upper stack layers authored by an `OWNER`, `MEMBER`, or `COLLABORATOR`, with both head and base branches in this repository, defer expensive jobs. Lint, formatting, and CI policy tests still run on every PR. Scope selection reads GitHub's event without checking out PR code and needs no token permissions.
An upper layer's `test` check intentionally fails until that layer becomes the lowest unmerged PR and all CI lanes pass. Merge these stacks individually from the bottom up. This prevents an untested upper layer or contiguous group from passing the merge gate. Skipping the required check would not enforce this policy because GitHub treats skipped jobs as successful. The aggregate check also runs after failed or cancelled dependencies and requires every full CI lane to succeed.
The stack base's branch protection or ruleset must require the GitHub Actions `test` check for this merge gate to be enforced. Workflow changes also need maintainer review: like other `pull_request` workflows, a PR can modify the workflow definition itself. Configure code owner review or equivalent repository rules for `.github/workflows/` as appropriate. The workflow does not configure repository protection or override permitted bypasses.
+98 -23
View File
@@ -10,6 +10,40 @@ concurrency:
cancel-in-progress: true
jobs:
# GitHub recommends stack.base.ref == base.ref for the lowest unmerged PR:
# https://docs.github.com/en/pull-requests/how-tos/merge-and-close-pull-requests/optimizing-ci-for-stacked-pull-requests
# Only trusted, same-repository upper layers defer full CI. Forks, ordinary
# PRs, pushes and incomplete metadata always retain full validation.
validation_scope:
name: Select CI scope
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions: {}
outputs:
full: ${{ steps.scope.outputs.full }}
steps:
- id: scope
# Read GitHub's event directly; do not run scripts from a PR checkout.
run: |
node <<'NODE'
const { appendFileSync, readFileSync } = require('node:fs');
const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'));
const pr = event.pull_request;
const repository = event.repository?.full_name;
const sameRepository = typeof repository === 'string' && repository.length > 0 &&
pr?.head?.repo?.full_name === repository && pr?.base?.repo?.full_name === repository;
const trustedAuthor = ['OWNER', 'MEMBER', 'COLLABORATOR'].includes(pr?.author_association);
const stackBase = pr?.stack?.base?.ref;
const prBase = pr?.base?.ref;
// Position 1 can already be merged; compare the current base branches.
const upperLayer = process.env.GITHUB_EVENT_NAME === 'pull_request' &&
sameRepository && trustedAuthor && typeof stackBase === 'string' && stackBase.length > 0 &&
typeof prBase === 'string' && prBase.length > 0 && stackBase !== prBase;
const full = !upperLayer;
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
code_quality:
name: lint and format
runs-on: ubuntu-24.04
@@ -26,14 +60,16 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm format:check
- name: Lint boundaries and formatter fixture protection
run: pnpm test tests/harness/code-quality.test.ts
- name: Code quality and CI policy contracts
run: pnpm test tests/harness/code-quality.test.ts tests/harness/ci-stack-scope.test.ts
env:
SCRIPTC_TEST_WORKERS: "1"
- name: Verify mixed-source generated AST getters
run: pnpm generate:ts7-ast --check
native_macos:
needs: validation_scope
if: ${{ needs.validation_scope.outputs.full == 'true' }}
name: build (macOS arm64 native packages)
runs-on: macos-15
steps:
@@ -62,7 +98,8 @@ jobs:
retention-days: 1
tests:
needs: native_macos
needs: [validation_scope, native_macos]
if: ${{ needs.validation_scope.outputs.full == 'true' }}
name: test (${{ matrix.flavor }}, ${{ matrix.shard }}/10)
# arm64 macOS: same clang/libc the differential + number-formatting
# oracles were verified against.
@@ -111,6 +148,8 @@ jobs:
run: node scripts/ci-test.mjs
native_linux:
needs: validation_scope
if: ${{ needs.validation_scope.outputs.full == 'true' }}
name: build (Linux x64 native packages)
runs-on: ubuntu-24.04
steps:
@@ -141,7 +180,8 @@ jobs:
retention-days: 1
effects:
needs: native_linux
needs: [validation_scope, native_linux]
if: ${{ needs.validation_scope.outputs.full == 'true' }}
name: test (Linux, ${{ matrix.flavor }}, Effect ${{ matrix.shard }}/16)
runs-on: ubuntu-24.04
strategy:
@@ -179,6 +219,8 @@ jobs:
# Build each instrumented seed once. Downstream contracts retain the same
# compiler and source artifacts without repeating seed construction.
bootstrap_seed:
needs: validation_scope
if: ${{ needs.validation_scope.outputs.full == 'true' }}
name: build (Linux, ${{ matrix.flavor }}, bootstrap seed)
runs-on: ubuntu-24.04
strategy:
@@ -221,7 +263,8 @@ jobs:
if-no-files-found: error
bootstrap:
needs: bootstrap_seed
needs: [validation_scope, bootstrap_seed]
if: ${{ needs.validation_scope.outputs.full == 'true' }}
name: test (Linux, ${{ matrix.flavor }}, native bootstrap, ${{ matrix.phase }})
# These contracts are portable. Standard Linux runners have enough memory
# for LLVM optimization alongside the compiler that produces its input.
@@ -281,6 +324,8 @@ jobs:
# Linux feature-test macro omissions at compile time and separate-libm
# omissions/order errors at link time.
linux_host_clang:
needs: validation_scope
if: ${{ needs.validation_scope.outputs.full == 'true' }}
name: test (linux host clang/glibc)
runs-on: ubuntu-24.04
timeout-minutes: 10
@@ -308,6 +353,8 @@ jobs:
# The TS7 parity sweep exercises the compiler frontend, so it can run
# alongside the Linux LLVM/runtime package build instead of following it.
ts7_parity:
needs: validation_scope
if: ${{ needs.validation_scope.outputs.full == 'true' }}
name: test (TypeScript 7 parity)
runs-on: ubuntu-24.04
timeout-minutes: 10
@@ -326,6 +373,8 @@ jobs:
run: pnpm test:ts7
llvm_artifacts_linux_x64:
needs: validation_scope
if: ${{ needs.validation_scope.outputs.full == 'true' }}
name: test (Linux x64 LLVM artifacts, no clang)
runs-on: ubuntu-24.04
timeout-minutes: 30
@@ -393,7 +442,8 @@ jobs:
run: pnpm test packages/cli/test/wasi-runtime-pack.test.ts tests/harness/wasm-library.test.ts
llvm_artifacts_macos_arm64:
needs: native_macos
needs: [validation_scope, native_macos]
if: ${{ needs.validation_scope.outputs.full == 'true' }}
name: test (macOS arm64 LLVM artifacts, no clang, ${{ matrix.shard }}/5)
runs-on: macos-15
# Shard 1 additionally runs package/install and link-parity contracts
@@ -479,6 +529,8 @@ jobs:
test "$("$RUNNER_TEMP/installed-program")" = 'hello world'
llvm_artifacts_macos_x64:
needs: validation_scope
if: ${{ needs.validation_scope.outputs.full == 'true' }}
name: test (macOS x64 LLVM artifacts, no clang)
runs-on: macos-15-intel
timeout-minutes: 20
@@ -515,7 +567,8 @@ jobs:
# slash styles, and the default executable must use the .exe suffix.
windows_cli:
name: test (windows CLI smoke)
needs: llvm_artifacts_windows_x64
needs: [validation_scope, llvm_artifacts_windows_x64]
if: ${{ needs.validation_scope.outputs.full == 'true' }}
runs-on: windows-latest
timeout-minutes: 15
env:
@@ -595,6 +648,8 @@ jobs:
--testNamePattern "Windows CA roots honor"
llvm_artifacts_windows_x64:
needs: validation_scope
if: ${{ needs.validation_scope.outputs.full == 'true' }}
name: test (Windows x64 MSVC LLVM artifacts)
# The official LLVM 22 Windows archives are MSVC-built and the helper
# selects CMake's VS 2022 generator. `windows-latest` can move to an
@@ -657,6 +712,8 @@ jobs:
retention-days: 1
mobile_runtime_packs:
needs: validation_scope
if: ${{ needs.validation_scope.outputs.full == 'true' }}
name: test (${{ matrix.runtime }})
strategy:
fail-fast: false
@@ -676,25 +733,43 @@ jobs:
with:
runtime: ${{ matrix.runtime }}
# Retain the pre-matrix "test" check for branch protection and badges. It
# passes only when every matrix shard and independent integration job does.
# Retain the pre-matrix "test" check for branch protection and badges.
# Require this check on the stack base. Upper layers must not pass before
# full validation, so untested groups cannot merge. Merge bottom-up.
test:
needs: [code_quality, tests, effects, bootstrap, linux_host_clang, ts7_parity, llvm_artifacts_linux_x64, llvm_artifacts_macos_arm64, llvm_artifacts_macos_x64, windows_cli, llvm_artifacts_windows_x64, mobile_runtime_packs]
# A superseded run must release its concurrency slot immediately.
if: ${{ !cancelled() }}
needs: [validation_scope, code_quality, native_macos, tests, native_linux, effects, bootstrap_seed, bootstrap, linux_host_clang, ts7_parity, llvm_artifacts_linux_x64, llvm_artifacts_macos_arm64, llvm_artifacts_macos_x64, windows_cli, llvm_artifacts_windows_x64, mobile_runtime_packs]
# Run even after failed or cancelled lanes: skipped required jobs allow merging.
if: ${{ always() }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: All lanes green
run: |
test "${{ needs.validation_scope.result }}" = success
test "${{ needs.code_quality.result }}" = success
test "${{ needs.tests.result }}" = success
test "${{ needs.effects.result }}" = success
test "${{ needs.bootstrap.result }}" = success
test "${{ needs.linux_host_clang.result }}" = success
test "${{ needs.ts7_parity.result }}" = success
test "${{ needs.llvm_artifacts_linux_x64.result }}" = success
test "${{ needs.llvm_artifacts_macos_arm64.result }}" = success
test "${{ needs.llvm_artifacts_macos_x64.result }}" = success
test "${{ needs.windows_cli.result }}" = success
test "${{ needs.llvm_artifacts_windows_x64.result }}" = success
test "${{ needs.mobile_runtime_packs.result }}" = success
case "${{ needs.validation_scope.outputs.full }}" in
true)
test "${{ needs.native_macos.result }}" = success
test "${{ needs.tests.result }}" = success
test "${{ needs.native_linux.result }}" = success
test "${{ needs.effects.result }}" = success
test "${{ needs.bootstrap_seed.result }}" = success
test "${{ needs.bootstrap.result }}" = success
test "${{ needs.linux_host_clang.result }}" = success
test "${{ needs.ts7_parity.result }}" = success
test "${{ needs.llvm_artifacts_linux_x64.result }}" = success
test "${{ needs.llvm_artifacts_macos_arm64.result }}" = success
test "${{ needs.llvm_artifacts_macos_x64.result }}" = success
test "${{ needs.windows_cli.result }}" = success
test "${{ needs.llvm_artifacts_windows_x64.result }}" = success
test "${{ needs.mobile_runtime_packs.result }}" = success
;;
false)
echo "::notice::Full CI is deferred until this PR becomes the lowest unmerged layer. Merge the stack from the bottom up."
exit 1
;;
*)
echo "Invalid CI scope"
exit 1
;;
esac
+49
View File
@@ -0,0 +1,49 @@
# Runtime benchmarks
Application-shaped workloads for measuring the speed of compiled executables. Each workload is deterministic, generates its own input (or reads a generated file), and prints a summary that must match Node.js byte-for-byte before it is timed.
| Workload | Category | Exercises |
| --- | --- | --- |
| `json-records` | cpu | `JSON.stringify`, typed `JSON.parse`, Map aggregation |
| `regex-logs` | cpu | regex `exec` with groups, `test`, global `replace` |
| `ast-interp` | cpu | tokenizer, recursive-descent parser, class dispatch |
| `records-sort` | cpu | comparator sorts, group-by, `reduce`/`filter` |
| `template-render` | cpu | template literals, escaping, `map`/`join` |
| `functional-pipeline` | cpu | `filter`/`map`/`reduce`/`flatMap` chains, closures, small records |
| `async-pipeline` | cpu | `async`/`await`, `Promise.all` fan-out |
| `word-graph` | cpu | string-keyed Map/Set, BFS |
| `alloc-trees` | cpu | allocation-heavy trees, immutable object-spread updates |
| `numeric-kernels` | cpu | sieve, hashing, `number[][]` matmul, `Float64Array` |
| `validate-errors` | cpu | throw/catch on a fraction of inputs, small call chains |
| `csv-numbers` | cpu | `split`, `Number`, `parseInt`, `toFixed` |
| `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 |
| `cli-config` | startup | a `deploy-plan` CLI: read a ~110 KB JSON manifest, validate, interpolate env values, order 80 services into dependency waves, print a summary |
| `http-api` | server | a `node:http` JSON API: routing, query parsing, a seeded catalog, per-cart state, typed JSON bodies, `ETag`/`If-None-Match`, auth and request-id headers |
Compare two checkouts (each with a built CLI and native artifacts for the host):
```console
$ pnpm bench:runtime --baseline=../scriptc-main --candidate=. --runs=15 --json=result.json
```
Without `--baseline`, the runner reports absolute medians. Baseline and candidate launches are interleaved, and each workload reports the median ratio with a bootstrap 95% confidence interval. `faster`/`slower` verdicts require the whole interval on one side of 1.0. A per-host lock serializes the timed phase so concurrent runs on one machine do not distort each other. Executables that time out or diverge from Node are reported, never timed. Use `--workloads=cli-config,http-api` to run a subset.
Node is timed in the same interleaved loop and every row reports candidate/Node; the summary prints the overall geomean and, when several categories ran, the geomean per category (`cpu`, `startup`, `server`). Node runs each `.ts` entry directly, so its times include type stripping and ESM detection, as a user running the TypeScript source would see.
## Workload kinds
Entries in `workloads.json` are process workloads unless they set `kind`.
Process workloads time whole launches (wall time, startup included). An optional `launches` count makes each sample the median of that many sequential launches; `cli-config` uses 20 so that a startup-dominated command (a few milliseconds compiled, tens of milliseconds under Node) is measured with low noise. Its table row is labeled `(per launch)`. On a heavily loaded macOS host, whole batches of launches can run several times slower (samples become bimodal), so judge startup changes on the Linux lane or a quiet machine.
Server workloads (`"kind": "server"`) run a listening program and a load client:
- The server binds `127.0.0.1` on port 0 and reports `PORT <n>` on stderr, the same protocol as `tests/harness/server.test.ts`.
- The runner then starts the workload's `client` script under the runner's Node. `clients/http-load.mjs` is the client for every server, Node and compiled alike, so the numbers differ only by server. It drives keep-alive HTTP/1.1 connections over loopback with one request in flight per connection (closed loop, no pipelining), spread across worker threads so that the client has CPU headroom.
- Each connection issues a fixed, seeded request sequence; mutable server state is partitioned per connection, so every response is a pure function of the connection and request index regardless of interleaving. A session runs an untimed warmup phase (which also opens the connections), a barrier, then the measured phase, and finally `POST /shutdown`, after which the server prints its totals and exits.
- Correctness compares four legs against Node byte-for-byte: server stdout, server stderr without the `PORT` line, server exit status, and client stdout (request and status counts plus a SHA-256 digest over every response's status, `content-type`, `cache-control`, `etag`, `x-request-id`, and body). Framing (`content-length` or chunked) and `date` are not compared.
- The timed sample is the measured-phase wall time for the fixed request count, so the main table, ratios, and confidence intervals read exactly like other rows (lower is better); the row is labeled `(server)`. A second table reports median requests per second, p50/p90/p99 latency, client CPU cores used during the measured phase, and peak RSS for Node and each contender. Client cores near the client's thread count mean the client, not the server, limits throughput.
- Server sessions already warm up in-process, so the runner uses at most one extra warmup session per contender.
`http-api` uses 64 connections, 2 client threads, 4,000 warmup and 40,000 measured requests (about one second per session).
+345
View File
@@ -0,0 +1,345 @@
/* Closed-loop HTTP/1.1 load client for the server workloads.
*
* Usage: node http-load.mjs --port=<n> [--connections=64] [--threads=2]
* [--warmup=4000] [--requests=40000]
*
* The same client drives every server (Node and compiled), so differences in
* the numbers come from the server. Each connection is a keep-alive socket
* that issues its own deterministic request sequence one request at a time
* (no pipelining); requests are split evenly across connections, and the
* connections are spread across worker threads so the client has CPU
* headroom. Mutable server state (carts) is partitioned per connection, so
* every response is a pure function of (connection, request index).
*
* Phases: connect + warmup requests (untimed) -> barrier -> measured requests.
* The measured wall time spans from releasing the barrier until the last
* connection finishes; per-request latency is sampled in the measured phase.
*
* stdout (compared byte-for-byte between servers): request and status counts
* plus a digest over every response's status, selected headers, and body.
* stderr: one `BENCH {json}` line with timing; nothing else on success.
* Finally sends POST /shutdown so the server prints its totals and exits. */
import { createHash } from "node:crypto";
import { connect } from "node:net";
import { parseArgs } from "node:util";
import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";
const HASHED_HEADERS = ["content-type", "cache-control", "etag", "x-request-id"];
/* ── deterministic schedule ───────────────────────────────────────────── */
function lcg(seed) {
let state = seed >>> 0;
return () => {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
return state / 4294967296;
};
}
const categories = ["books", "games", "garden", "kitchen", "music", "outdoor", "tools", "toys"];
/** The request list of one connection: [method, path, headers, body]. */
function schedule(conn, total) {
const random = lcg(0x9e3779b9 ^ Math.imul(conn + 1, 2654435761));
const list = [];
const cart = (k) => `c${conn}-${k % 4}`;
const token = `Bearer tok-${conn.toString(36).padStart(6, "0")}`;
const added = [];
for (let k = 0; k < total; k++) {
const headers = { "x-request-id": `${conn}-${k}` };
const r = random();
let method = "GET";
let path;
let body = "";
if (r < 0.1) path = "/health";
else if (r < 0.4) {
const id = random() < 0.03 ? 2000 + Math.floor(random() * 100) : Math.floor(random() * 2000);
path = `/products/${id}`;
if (id < 2000 && random() < 0.3) headers["if-none-match"] = `"p${id}-v1"`;
} else if (r < 0.6) {
const category = random() < 0.15 ? "" : categories[Math.floor(random() * categories.length)];
const params = [];
if (category) params.push(`category=${category}`);
params.push(`limit=${5 + Math.floor(random() * 20)}`);
if (random() < 0.5) params.push(`offset=${Math.floor(random() * 200)}`);
if (random() < 0.4) params.push(`minPrice=${Math.floor(random() * 60)}`);
path = `/products?${params.join("&")}`;
} else if (r < 0.8) {
method = "POST";
const k2 = Math.floor(random() * 8);
path = `/carts/${cart(k2)}/items`;
const productId = Math.floor(random() * 2000);
const auth = random();
if (auth >= 0.04) headers.authorization = token;
headers["content-type"] = "application/json";
body =
auth > 0.98
? `{"productId":${productId},`
: JSON.stringify({ productId, qty: 1 + Math.floor(random() * 3) });
if (auth >= 0.04 && auth <= 0.98) added.push([k2, productId]);
} else if (r < 0.9) path = `/carts/${cart(Math.floor(random() * 8))}`;
else if (r < 0.97) {
method = "DELETE";
const pick =
added.length > 0 && random() < 0.8 ? added[Math.floor(random() * added.length)] : null;
const productId = pick ? pick[1] : Math.floor(random() * 2000);
path = `/carts/${cart(pick ? pick[0] : Math.floor(random() * 8))}/items/sku-${100000 + productId}`;
headers.authorization = token;
} else path = random() < 0.5 ? "/admin/metrics" : "/products/7/reviews";
list.push(encode(method, path, headers, body));
}
return list;
}
function encode(method, path, headers, body) {
let head = `${method} ${path} HTTP/1.1\r\nhost: 127.0.0.1\r\nuser-agent: scriptc-bench/1\r\naccept: application/json\r\n`;
for (const [name, value] of Object.entries(headers)) head += `${name}: ${value}\r\n`;
if (method === "POST" || body.length > 0)
head += `content-length: ${Buffer.byteLength(body)}\r\n`;
return Buffer.from(head + "\r\n" + body);
}
/* ── response parsing ─────────────────────────────────────────────────── */
/** Parses one complete response from `buf`, or returns null if incomplete. */
function parseResponse(buf, method) {
const end = buf.indexOf("\r\n\r\n");
if (end < 0) return null;
const head = buf.toString("latin1", 0, end).split("\r\n");
const status = Number(head[0].split(" ")[1]);
const headers = new Map();
for (let i = 1; i < head.length; i++) {
const colon = head[i].indexOf(":");
headers.set(head[i].slice(0, colon).trim().toLowerCase(), head[i].slice(colon + 1).trim());
}
let offset = end + 4;
let body;
if (status === 204 || status === 304 || method === "HEAD" || (status >= 100 && status < 200))
body = Buffer.alloc(0);
else if ((headers.get("transfer-encoding") ?? "").toLowerCase().includes("chunked")) {
const parts = [];
for (;;) {
const lineEnd = buf.indexOf("\r\n", offset);
if (lineEnd < 0) return null;
const size = parseInt(buf.toString("latin1", offset, lineEnd), 16);
if (size === 0) {
// No trailers are expected: the terminator is "0\r\n\r\n".
if (buf.length < lineEnd + 4) return null;
offset = lineEnd + 4;
break;
}
if (buf.length < lineEnd + 2 + size + 2) return null;
parts.push(buf.subarray(lineEnd + 2, lineEnd + 2 + size));
offset = lineEnd + 2 + size + 2;
}
body = Buffer.concat(parts);
} else if (headers.has("content-length")) {
const length = Number(headers.get("content-length"));
if (buf.length < offset + length) return null;
body = buf.subarray(offset, offset + length);
offset += length;
} else throw new Error(`response without framing: ${head[0]}`);
return { status, headers, body, consumed: offset };
}
/* ── one keep-alive connection ────────────────────────────────────────── */
function runConnection(port, conn, requests, measuredFrom) {
return new Promise((resolve, reject) => {
const hash = createHash("sha256");
const statuses = new Map();
const latencies = [];
const methods = requests.map((r) => r.toString("latin1", 0, 8).split(" ")[0]);
let next = 0;
let sentAt = 0n;
let buf = Buffer.alloc(0);
let waitForGo = null;
const socket = connect({ port, host: "127.0.0.1", noDelay: true });
const send = () => {
sentAt = process.hrtime.bigint();
socket.write(requests[next]);
};
socket.on("error", reject);
socket.on("connect", send);
socket.on("data", (chunk) => {
buf = buf.length === 0 ? chunk : Buffer.concat([buf, chunk]);
const response = parseResponse(buf, methods[next]);
if (response === null) return;
if (response.consumed !== buf.length) {
reject(new Error(`connection ${conn}: unexpected bytes after response ${next}`));
return;
}
buf = Buffer.alloc(0);
if (next >= measuredFrom) latencies.push(Number(process.hrtime.bigint() - sentAt) / 1e6);
hash.update(`${response.status}\n`);
for (const name of HASHED_HEADERS)
hash.update(`${name}:${response.headers.get(name) ?? ""}\n`);
hash.update(response.body);
hash.update("\n");
statuses.set(response.status, (statuses.get(response.status) ?? 0) + 1);
next++;
if (next === requests.length) {
socket.end();
resolve({ conn, digest: hash.digest("hex"), statuses, latencies });
} else if (next === measuredFrom) {
waitForGo = send;
warmed();
} else send();
});
socket.on("close", () => {
if (next !== requests.length) reject(new Error(`connection ${conn} closed after ${next}`));
});
const warmed = () => pending.warm(conn, () => waitForGo());
});
}
/* Barrier bookkeeping shared by all connections of one thread. */
const pending = {
waiting: [],
expected: 0,
onAllWarm: null,
warm(conn, go) {
this.waiting.push(go);
if (this.waiting.length === this.expected) this.onAllWarm();
},
release() {
for (const go of this.waiting.splice(0)) go();
},
};
async function runThread({ port, conns, warmupPer, measuredPer }) {
pending.expected = conns.length;
const warm = new Promise((resolve) => (pending.onAllWarm = resolve));
const results = Promise.all(
conns.map((conn) =>
runConnection(port, conn, schedule(conn, warmupPer + measuredPer), warmupPer),
),
);
results.catch(() => {});
return { warm, results };
}
/* ── worker ───────────────────────────────────────────────────────────── */
if (!isMainThread) {
const { warm, results } = await runThread(workerData);
await warm;
parentPort.postMessage({ type: "warm" });
parentPort.once("message", async () => {
pending.release();
try {
parentPort.postMessage({ type: "done", results: await results });
} catch (error) {
parentPort.postMessage({ type: "error", message: String(error?.stack ?? error) });
}
});
} else {
await main();
}
async function main() {
const { values } = parseArgs({
options: {
port: { type: "string" },
connections: { type: "string", default: "64" },
threads: { type: "string", default: "2" },
warmup: { type: "string", default: "4000" },
requests: { type: "string", default: "40000" },
},
});
const port = Number(values.port);
const connections = Number(values.connections);
const threads = Math.min(Number(values.threads), connections);
// Every connection warms up with at least one request (which also opens it).
const warmupPer = Math.max(1, Math.ceil(Number(values.warmup) / connections));
const measuredPer = Math.ceil(Number(values.requests) / connections);
if (!(port > 0) || !(connections > 0) || !(threads > 0) || !(measuredPer > 0))
throw new Error("invalid arguments");
const watchdog = setTimeout(() => {
process.stderr.write("load client timed out\n");
process.exit(2);
}, 120_000);
const workers = Array.from({ length: threads }, (_, t) => {
const conns = [];
for (let c = t; c < connections; c += threads) conns.push(c);
return new Worker(new URL(import.meta.url), {
workerData: { port, conns, warmupPer, measuredPer },
});
});
const messages = workers.map((worker) => {
const queue = [];
let wake = null;
worker.on("message", (m) => (wake ? (wake(m), (wake = null)) : queue.push(m)));
worker.on("error", (error) => {
process.stderr.write(`load client worker failed: ${error.stack ?? error}\n`);
process.exit(2);
});
return () =>
queue.length > 0 ? Promise.resolve(queue.shift()) : new Promise((r) => (wake = r));
});
for (const next of messages) await next();
// cpuUsage covers every thread of the client process.
const cpuStart = process.cpuUsage();
const start = process.hrtime.bigint();
for (const worker of workers) worker.postMessage("go");
const finished = await Promise.all(messages.map((next) => next()));
const measuredMs = Number(process.hrtime.bigint() - start) / 1e6;
const cpu = process.cpuUsage(cpuStart);
for (const m of finished)
if (m.type !== "done") {
process.stderr.write(`load client error: ${m.message}\n`);
process.exit(2);
}
await Promise.all(workers.map((w) => w.terminate()));
const perConn = finished.flatMap((m) => m.results).sort((a, b) => a.conn - b.conn);
const digest = createHash("sha256");
const statuses = new Map();
const latencies = [];
for (const r of perConn) {
digest.update(r.digest);
for (const [status, n] of r.statuses) statuses.set(status, (statuses.get(status) ?? 0) + n);
for (const l of r.latencies) latencies.push(l);
}
latencies.sort((a, b) => a - b);
const pct = (p) => latencies[Math.min(latencies.length - 1, Math.floor(latencies.length * p))];
const measured = latencies.length;
await shutdown(port);
clearTimeout(watchdog);
const total = perConn.length * (warmupPer + measuredPer);
const lines = [`connections ${connections} requests ${total} measured ${measured}`];
for (const status of [...statuses.keys()].sort((a, b) => a - b))
lines.push(`status ${status} ${statuses.get(status)}`);
lines.push(`digest ${digest.digest("hex").slice(0, 32)}`);
console.log(lines.join("\n"));
const round = (x) => Math.round(x * 1000) / 1000;
process.stderr.write(
"BENCH " +
JSON.stringify({
measured_ms: round(measuredMs),
requests: measured,
rps: Math.round((measured / measuredMs) * 1000),
p50_ms: round(pct(0.5)),
p90_ms: round(pct(0.9)),
p99_ms: round(pct(0.99)),
max_ms: round(latencies[latencies.length - 1]),
client_cpu_ms: Math.round((cpu.user + cpu.system) / 1000),
threads,
}) +
"\n",
);
}
function shutdown(port) {
return new Promise((resolve, reject) => {
const socket = connect({ port, host: "127.0.0.1" });
let buf = Buffer.alloc(0);
socket.on("connect", () =>
socket.write(encode("POST", "/shutdown", { connection: "close" }, "")),
);
socket.on("data", (chunk) => (buf = Buffer.concat([buf, chunk])));
socket.on("error", reject);
socket.on("close", () => {
const response = parseResponse(buf, "POST");
if (response?.status === 200) resolve();
else reject(new Error(`shutdown failed: ${buf.toString("latin1", 0, 200)}`));
});
});
}
+143
View File
@@ -0,0 +1,143 @@
{
"workloads": [
{
"name": "json-records",
"entry": "workloads/json-records.ts",
"args": [
"4"
]
},
{
"name": "regex-logs",
"entry": "workloads/regex-logs.ts",
"args": [
"1"
]
},
{
"name": "ast-interp",
"entry": "workloads/ast-interp.ts",
"args": [
"5"
]
},
{
"name": "records-sort",
"entry": "workloads/records-sort.ts",
"args": [
"1"
]
},
{
"name": "template-render",
"entry": "workloads/template-render.ts",
"args": [
"1"
]
},
{
"name": "functional-pipeline",
"entry": "workloads/functional-pipeline.ts",
"args": [
"1"
]
},
{
"name": "async-pipeline",
"entry": "workloads/async-pipeline.ts",
"args": [
"1"
]
},
{
"name": "word-graph",
"entry": "workloads/word-graph.ts",
"args": [
"2"
]
},
{
"name": "alloc-trees",
"entry": "workloads/alloc-trees.ts",
"args": [
"1"
]
},
{
"name": "numeric-kernels",
"entry": "workloads/numeric-kernels.ts",
"args": [
"0.5"
]
},
{
"name": "validate-errors",
"entry": "workloads/validate-errors.ts",
"args": [
"1"
]
},
{
"name": "csv-numbers",
"entry": "workloads/csv-numbers.ts",
"args": [
"1"
]
},
{
"name": "log-lines",
"entry": "workloads/log-lines.ts",
"args": [
"4"
]
},
{
"name": "log-summary",
"entry": "../builds/log-summary/main.ts",
"input": "log-summary",
"args": [
"{input}",
"--min-status",
"200",
"--limit",
"12"
]
},
{
"name": "inventory-report",
"entry": "../builds/inventory-report/main.ts",
"input": "inventory-report",
"args": [
"{input}"
]
},
{
"name": "cli-config",
"entry": "workloads/cli-config.ts",
"input": "cli-config",
"launches": 20,
"args": [
"{input}",
"--env",
"production",
"--max-wave",
"12"
]
},
{
"name": "http-api",
"kind": "server",
"entry": "workloads/http-api.ts",
"args": [],
"client": {
"script": "clients/http-load.mjs",
"args": [
"--connections=64",
"--threads=2",
"--warmup=4000",
"--requests=40000"
]
}
}
]
}
@@ -0,0 +1,46 @@
// Allocation-heavy code: binary trees built and discarded, plus a
// reducer that produces immutable state updates with object spread.
class TreeNode {
readonly left: TreeNode | null;
readonly right: TreeNode | null;
constructor(left: TreeNode | null, right: TreeNode | null) {
this.left = left;
this.right = right;
}
check(): number {
return 1 + (this.left === null ? 0 : this.left.check()) + (this.right === null ? 0 : this.right.check());
}
}
function build(depth: number): TreeNode {
return depth === 0 ? new TreeNode(null, null) : new TreeNode(build(depth - 1), build(depth - 1));
}
interface State {
count: number;
total: number;
last: string;
history: number[];
}
interface Action {
type: string;
amount: number;
}
function reduce(state: State, action: Action): State {
if (action.type === "add") return { ...state, count: state.count + 1, total: state.total + action.amount, last: action.type };
if (action.type === "reset") return { ...state, total: 0, last: action.type, history: [...state.history.slice(-4), state.total] };
return { ...state, last: action.type };
}
const scale = Number(process.argv[2] ?? "1");
const maxDepth = 14;
let checks = 0;
for (let depth = 4; depth <= maxDepth; depth += 2) {
const iterations = Math.floor((1 << (maxDepth - depth + 4)) * scale);
for (let i = 0; i < iterations; i++) checks += build(depth).check();
}
const longLived = build(maxDepth);
let state: State = { count: 0, total: 0, last: "", history: [] };
for (let i = 0; i < Math.floor(300000 * scale); i++) {
state = reduce(state, { type: i % 97 === 0 ? "reset" : i % 3 === 0 ? "noop" : "add", amount: i % 13 });
}
console.log("checks", checks, "long", longLived.check(), "state", state.count, state.total, state.history.join(","));
+199
View File
@@ -0,0 +1,199 @@
// A tokenizer, recursive-descent parser, and class-based tree-walking
// evaluator for a small expression language with variables.
abstract class Expr {
abstract evaluate(env: Map<string, number>): number;
}
class Num extends Expr {
readonly value: number;
constructor(value: number) {
super();
this.value = value;
}
evaluate(_env: Map<string, number>): number {
return this.value;
}
}
class Var extends Expr {
readonly name: string;
constructor(name: string) {
super();
this.name = name;
}
evaluate(env: Map<string, number>): number {
return env.get(this.name) ?? 0;
}
}
class Binary extends Expr {
readonly op: string;
readonly left: Expr;
readonly right: Expr;
constructor(op: string, left: Expr, right: Expr) {
super();
this.op = op;
this.left = left;
this.right = right;
}
evaluate(env: Map<string, number>): number {
const l = this.left.evaluate(env);
const r = this.right.evaluate(env);
switch (this.op) {
case "+":
return l + r;
case "-":
return l - r;
case "*":
return l * r;
case "/":
return r === 0 ? 0 : l / r;
default:
return l % (r === 0 ? 1 : r);
}
}
}
class Call extends Expr {
readonly name: string;
readonly args: Expr[];
constructor(name: string, args: Expr[]) {
super();
this.name = name;
this.args = args;
}
evaluate(env: Map<string, number>): number {
const values = this.args.map((arg) => arg.evaluate(env));
if (this.name === "max") return Math.max(...values);
if (this.name === "min") return Math.min(...values);
return values.reduce((a, b) => a + b, 0);
}
}
type TokenKind = "num" | "ident" | "op" | "lparen" | "rparen" | "comma" | "end";
interface Token {
kind: TokenKind;
text: string;
}
function isDigit(c: number): boolean {
return (c >= 48 && c <= 57) || c === 46;
}
function isIdent(c: number): boolean {
return (c >= 97 && c <= 122) || c === 95;
}
function tokenize(source: string): Token[] {
const tokens: Token[] = [];
let i = 0;
while (i < source.length) {
const c = source.charCodeAt(i);
if (c === 32) {
i++;
} else if (c >= 48 && c <= 57) {
const start = i;
while (i < source.length && isDigit(source.charCodeAt(i))) i++;
tokens.push({ kind: "num", text: source.slice(start, i) });
} else if (isIdent(c)) {
const start = i;
while (i < source.length && isIdent(source.charCodeAt(i))) i++;
tokens.push({ kind: "ident", text: source.slice(start, i) });
} else if (c === 40) {
tokens.push({ kind: "lparen", text: "(" });
i++;
} else if (c === 41) {
tokens.push({ kind: "rparen", text: ")" });
i++;
} else if (c === 44) {
tokens.push({ kind: "comma", text: "," });
i++;
} else {
tokens.push({ kind: "op", text: source[i]! });
i++;
}
}
tokens.push({ kind: "end", text: "" });
return tokens;
}
class Parser {
private pos = 0;
private readonly tokens: Token[];
constructor(tokens: Token[]) {
this.tokens = tokens;
}
private peek(): Token {
return this.tokens[this.pos]!;
}
private next(): Token {
return this.tokens[this.pos++]!;
}
parseExpr(): Expr {
let left = this.parseTerm();
while (this.peek().kind === "op" && (this.peek().text === "+" || this.peek().text === "-")) {
const op = this.next().text;
left = new Binary(op, left, this.parseTerm());
}
return left;
}
private parseTerm(): Expr {
let left = this.parseFactor();
while (this.peek().kind === "op" && (this.peek().text === "*" || this.peek().text === "/" || this.peek().text === "%")) {
const op = this.next().text;
left = new Binary(op, left, this.parseFactor());
}
return left;
}
private parseFactor(): Expr {
const token = this.next();
if (token.kind === "num") return new Num(Number(token.text));
if (token.kind === "lparen") {
const inner = this.parseExpr();
this.next();
return inner;
}
if (this.peek().kind === "lparen") {
this.next();
const args: Expr[] = [];
while (this.peek().kind !== "rparen") {
args.push(this.parseExpr());
if (this.peek().kind === "comma") this.next();
}
this.next();
return new Call(token.text, args);
}
return new Var(token.text);
}
}
let seed = 7;
function random(): number {
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
return seed / 4294967296;
}
const names = ["alpha", "beta", "gamma", "delta", "x", "y"];
const ops = ["+", "-", "*", "/", "%"];
function generate(depth: number): string {
const r = random();
if (depth === 0 || r < 0.2) return r < 0.1 ? String(Math.floor(random() * 100)) : names[Math.floor(random() * names.length)]!;
if (r < 0.3) return `max(${generate(depth - 1)}, ${generate(depth - 1)}, ${generate(depth - 1)})`;
if (r < 0.4) return `(${generate(depth - 1)})`;
return `${generate(depth - 1)} ${ops[Math.floor(random() * ops.length)]!} ${generate(depth - 1)}`;
}
const scale = Number(process.argv[2] ?? "1");
const sources: string[] = [];
for (let i = 0; i < Math.floor(400 * scale); i++) sources.push(generate(7));
const env = new Map<string, number>();
let total = 0;
let chars = 0;
for (const source of sources) {
const tree = new Parser(tokenize(source)).parseExpr();
chars += source.length;
for (let step = 0; step < 40; step++) {
env.set("alpha", step);
env.set("beta", step * 2 + 1);
env.set("gamma", 3);
env.set("delta", step % 7);
env.set("x", 1.5);
env.set("y", -2);
const value = tree.evaluate(env);
if (Number.isFinite(value)) total += value;
}
}
console.log("sources", sources.length, "chars", chars, "total", total.toFixed(6));
@@ -0,0 +1,47 @@
// Async application code: request handlers awaiting cached and computed
// values, Promise.all fan-out, and sequential awaits in loops.
interface User {
id: number;
name: string;
score: number;
}
const cache = new Map<number, User>();
async function loadUser(id: number): Promise<User> {
const cached = cache.get(id);
if (cached !== undefined) return cached;
const user = { id, name: "user-" + id, score: (id * 37) % 101 };
cache.set(id, user);
return user;
}
async function authorize(user: User): Promise<boolean> {
return user.score % 7 !== 0;
}
async function handle(requestId: number): Promise<number> {
const user = await loadUser(requestId % 3000);
if (!(await authorize(user))) return 0;
const requests: Promise<User>[] = [loadUser((requestId + 1) % 3000), loadUser((requestId + 7) % 3000), loadUser((requestId + 13) % 3000)];
const friends = await Promise.all(requests);
let sum = user.score;
for (const friend of friends) sum += friend.score;
return sum;
}
async function batch(start: number, size: number): Promise<number> {
let total = 0;
for (let i = 0; i < size; i++) total += await handle(start + i);
return total;
}
async function main(): Promise<void> {
const scale = Number(process.argv[2] ?? "1");
const batches = Math.floor(200 * scale);
let total = 0;
for (let round = 0; round < batches; round += 10) {
const work: Promise<number>[] = [];
for (let b = 0; b < 10; b++) work.push(batch((round + b) * 100, 100));
const results = await Promise.all(work);
for (const r of results) total += r;
}
console.log("handled", batches * 100, "total", total);
}
void main();
+182
View File
@@ -0,0 +1,182 @@
// A short-running CLI: read a small JSON project manifest, validate it,
// resolve environment interpolation, order services into deployment waves,
// and print a plan summary. Each launch does little work, so process
// startup dominates; the runner times many launches.
import { readFileSync } from "node:fs";
interface EnvVar {
name: string;
value: string;
}
interface Route {
path: string;
methods: string[];
}
interface Service {
name: string;
image: string;
replicas: number;
port: number;
cpu: number;
memoryMb: number;
dependsOn: string[];
env: EnvVar[];
routes: Route[];
}
interface Manifest {
project: string;
region: string;
variables: EnvVar[];
services: Service[];
}
function usage(message: string): never {
process.stderr.write("deploy-plan: " + message + "\nusage: deploy-plan <manifest.json> [--env <name>] [--max-wave <n>]\n");
process.exit(2);
}
const argv = process.argv.slice(2);
let file = "";
let envName = "staging";
let maxWave = 0;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]!;
if (arg === "--env" || arg === "--max-wave") {
const value = argv[++i];
if (value === undefined) usage(arg + " needs a value");
else if (arg === "--env") envName = value;
else maxWave = parseInt(value, 10);
} else if (arg.startsWith("--")) usage("unknown flag " + arg);
else file = arg;
}
if (file === "") usage("missing manifest path");
const manifest = JSON.parse(readFileSync(file, "utf8")) as Manifest;
const problems: string[] = [];
const warnings: string[] = [];
const variables = new Map<string, string>();
for (const v of manifest.variables) variables.set(v.name, v.value);
variables.set("ENV", envName);
variables.set("REGION", manifest.region);
function interpolate(service: string, text: string): string {
let out = "";
let i = 0;
while (i < text.length) {
const start = text.indexOf("${", i);
if (start < 0) {
out += text.slice(i);
break;
}
const end = text.indexOf("}", start);
if (end < 0) {
problems.push(service + ": unterminated interpolation in '" + text + "'");
return text;
}
out += text.slice(i, start);
const key = text.slice(start + 2, end);
const value = variables.get(key);
if (value === undefined) {
warnings.push(service + ": undefined variable " + key);
} else out += value;
i = end + 1;
}
return out;
}
// Validation: unique names, ports, known dependencies, sane resources.
const byName = new Map<string, Service>();
const ports = new Map<number, string>();
for (const s of manifest.services) {
if (byName.has(s.name)) problems.push("duplicate service " + s.name);
byName.set(s.name, s);
const owner = ports.get(s.port);
if (owner !== undefined) problems.push(s.name + ": port " + s.port + " already used by " + owner);
else ports.set(s.port, s.name);
if (s.replicas < 1 || s.replicas > 50) warnings.push(s.name + ": unusual replica count " + s.replicas);
if (s.memoryMb / s.cpu > 4096) warnings.push(s.name + ": memory/cpu ratio above 4 GiB per core");
}
for (const s of manifest.services)
for (const dep of s.dependsOn)
if (!byName.has(dep)) problems.push(s.name + ": depends on unknown service " + dep);
// Resolve environment values; count distinct resolved images.
const images = new Set<string>();
let envCount = 0;
for (const s of manifest.services) {
images.add(interpolate(s.name, s.image));
for (const v of s.env) {
interpolate(s.name, v.value);
envCount++;
}
}
// Kahn's algorithm in name order: each wave holds services whose
// dependencies are all deployed by earlier waves.
const remaining = new Map<string, number>();
const dependents = new Map<string, string[]>();
for (const s of manifest.services) {
remaining.set(s.name, s.dependsOn.filter((d) => byName.has(d)).length);
for (const dep of s.dependsOn) {
const list = dependents.get(dep) ?? [];
list.push(s.name);
dependents.set(dep, list);
}
}
const waves: string[][] = [];
let ready = [...remaining].filter(([, n]) => n === 0).map(([name]) => name).sort();
while (ready.length > 0) {
waves.push(ready);
const next: string[] = [];
for (const name of ready) {
remaining.delete(name);
for (const d of dependents.get(name) ?? []) {
const left = (remaining.get(d) ?? 0) - 1;
remaining.set(d, left);
if (left === 0) next.push(d);
}
}
ready = next.sort();
}
if (remaining.size > 0) problems.push("dependency cycle among: " + [...remaining.keys()].sort().join(", "));
if (maxWave > 0 && waves.length > maxWave) problems.push("plan needs " + waves.length + " waves, limit is " + maxWave);
// Route table: method counts and path conflicts across services.
const routeOwners = new Map<string, string>();
const methodCounts = new Map<string, number>();
for (const s of manifest.services)
for (const r of s.routes)
for (const m of r.methods) {
const key = m + " " + r.path;
const owner = routeOwners.get(key);
if (owner !== undefined && owner !== s.name) problems.push("route " + key + " claimed by " + owner + " and " + s.name);
routeOwners.set(key, s.name);
methodCounts.set(m, (methodCounts.get(m) ?? 0) + 1);
}
let cpu = 0;
let memory = 0;
let replicas = 0;
for (const s of manifest.services) {
cpu += s.cpu * s.replicas;
memory += s.memoryMb * s.replicas;
replicas += s.replicas;
}
console.log("deploy plan for " + manifest.project + " (" + envName + ", " + manifest.region + ")");
console.log("services " + manifest.services.length + ", replicas " + replicas + ", images " + images.size + ", env vars " + envCount);
console.log("capacity " + cpu.toFixed(2) + " vCPU, " + (memory / 1024).toFixed(1) + " GiB");
console.log("routes " + routeOwners.size + " (" + [...methodCounts].sort((a, b) => (a[0] < b[0] ? -1 : 1)).map(([m, n]) => m + " " + n).join(", ") + ")");
for (let w = 0; w < waves.length; w++) {
const wave = waves[w]!;
const shown = wave.slice(0, 6).join(" ");
console.log("wave " + (w + 1) + ": " + wave.length + " services " + shown + (wave.length > 6 ? " ..." : ""));
}
console.log("warnings " + warnings.length);
for (const w of warnings.slice(0, 5)) console.log(" " + w);
if (problems.length > 0) {
console.log("problems " + problems.length);
for (const p of problems.slice(0, 10)) console.log(" " + p);
process.exitCode = 1;
} else console.log("ok");
@@ -0,0 +1,43 @@
// CSV parsing and numeric formatting: split, Number(), parseInt,
// toFixed, String(n), and string assembly of a report.
let seed = 1001;
function random(): number {
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
return seed / 4294967296;
}
function makeCsv(rows: number): string {
const lines: string[] = ["date,store,units,unit_price,discount"];
for (let i = 0; i < rows; i++) {
const day = 1 + (i % 28);
lines.push(
`2026-${String(1 + (i % 12)).padStart(2, "0")}-${String(day).padStart(2, "0")},store-${Math.floor(random() * 40)},${Math.floor(random() * 50)},${(random() * 200).toFixed(2)},${(random() * 0.3).toFixed(3)}`,
);
}
return lines.join("\n");
}
const scale = Number(process.argv[2] ?? "1");
const csv = makeCsv(Math.floor(150000 * scale));
const totals = new Map<string, number>();
const months: number[] = new Array<number>(13).fill(0);
let units = 0;
let formatted = 0;
for (let round = 0; round < 2; round++) {
const lines = csv.split("\n");
for (let i = 1; i < lines.length; i++) {
const cols = lines[i]!.split(",");
const month = parseInt(cols[0]!.slice(5, 7), 10);
const count = Number(cols[2]);
const price = Number(cols[3]);
const discount = Number(cols[4]);
const revenue = count * price * (1 - discount);
units += count;
months[month] = months[month]! + revenue;
totals.set(cols[1]!, (totals.get(cols[1]!) ?? 0) + revenue);
formatted += (revenue.toFixed(2) + "|" + String(count)).length;
}
}
const stores = [...totals.keys()].sort();
const report = stores.map((s) => `${s}: ${totals.get(s)!.toFixed(2)}`).join("\n");
console.log(report.split("\n").slice(0, 5).join("\n"));
console.log("units", units, "formatted", formatted, "jan", months[1]!.toFixed(2), "dec", months[12]!.toFixed(2));
@@ -0,0 +1,56 @@
// Functional collection pipelines: map/filter/reduce/flatMap chains,
// closures capturing configuration, and small intermediate objects.
interface Event {
user: number;
kind: string;
value: number;
time: number;
}
interface Point {
x: number;
y: number;
}
interface Scored {
user: number;
score: number;
}
let seed = 4242;
function random(): number {
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
return seed / 4294967296;
}
const kinds = ["view", "click", "purchase", "share"];
function makeEvents(count: number): Event[] {
const events: Event[] = [];
for (let i = 0; i < count; i++) {
events.push({ user: Math.floor(random() * 5000), kind: kinds[Math.floor(random() * kinds.length)]!, value: Math.floor(random() * 1000), time: i });
}
return events;
}
function makeScorer(weight: number, bonus: number): (e: Event) => number {
return (e) => (e.kind === "purchase" ? e.value * weight + bonus : e.kind === "click" ? weight : 0);
}
const scale = Number(process.argv[2] ?? "1");
const events = makeEvents(Math.floor(200000 * scale));
let total = 0;
for (let round = 0; round < 5; round++) {
const scorer = makeScorer(round + 1, round * 10);
const scored: Scored[] = events
.filter((e) => e.time % 5 !== round)
.map((e) => ({ user: e.user, score: scorer(e) }))
.filter((s) => s.score > 0);
total += scored.reduce((sum, s) => sum + s.score, 0);
const pairs = events.slice(0, 20000).flatMap((e) => (e.kind === "share" ? [e.user, e.value] : []));
total += pairs.length;
const points: Point[] = events.slice(0, 50000).map((e) => ({ x: e.value / 10, y: e.user / 100 }));
const centroid = points.reduce((acc: Point, p: Point): Point => ({ x: acc.x + p.x, y: acc.y + p.y }), { x: 0, y: 0 });
total += Math.round(centroid.x + centroid.y);
const some = events.some((e) => e.value === 999 - round);
const every = events.every((e) => e.value >= 0);
const found = events.findIndex((e) => e.user === 4999 - round);
total += (some ? 1 : 0) + (every ? 1 : 0) + found;
}
console.log("total", total);
+297
View File
@@ -0,0 +1,297 @@
// A small JSON API server: routing, query parsing, a seeded product catalog,
// per-cart mutable state, typed JSON request bodies, conditional GETs, and
// request/response header handling. Driven by clients/http-load.mjs, which
// partitions carts per connection so every response is deterministic. The
// server reports its port on stderr (`PORT <n>`) and prints route totals
// after the client's POST /shutdown closes it.
import { createServer } from "node:http";
import type { IncomingMessage, ServerResponse } from "node:http";
interface Product {
id: number;
sku: string;
name: string;
category: string;
price: number;
stock: number;
tags: string[];
version: number;
}
interface CartLine {
sku: string;
name: string;
qty: number;
unitPrice: number;
}
interface Cart {
id: string;
lines: CartLine[];
}
interface AddItem {
productId: number;
qty: number;
}
interface ProductPage {
category: string;
total: number;
offset: number;
items: ProductSummary[];
}
interface ProductSummary {
id: number;
name: string;
price: number;
inStock: boolean;
}
interface CartView {
id: string;
lines: CartLine[];
count: number;
total: number;
}
interface ApiError {
error: string;
status: number;
}
let seed = 2024;
function random(): number {
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
return seed / 4294967296;
}
const categories = ["books", "games", "garden", "kitchen", "music", "outdoor", "tools", "toys"];
const adjectives = ["red", "small", "classic", "smart", "eco", "deluxe", "basic", "pro"];
const products: Product[] = [];
const byCategory = new Map<string, Product[]>();
for (let i = 0; i < 2000; i++) {
const category = categories[i % categories.length]!;
const product: Product = {
id: i,
sku: "sku-" + (100000 + i),
name: adjectives[Math.floor(random() * adjectives.length)]! + " " + category + " item " + i,
category,
price: Math.round(100 + random() * 9900) / 100,
stock: Math.floor(random() * 40),
tags: random() < 0.3 ? ["sale", category] : [category],
version: 1,
};
products.push(product);
let list = byCategory.get(category);
if (list === undefined) {
list = [];
byCategory.set(category, list);
}
list.push(product);
}
const carts = new Map<string, Cart>();
const routeCounts = new Map<string, number>();
let requests = 0;
function count(route: string): void {
routeCounts.set(route, (routeCounts.get(route) ?? 0) + 1);
}
function send(req: IncomingMessage, res: ServerResponse, status: number, body: string): void {
res.statusCode = status;
res.setHeader("content-type", "application/json; charset=utf-8");
res.setHeader("cache-control", "no-store");
const requestId = req.headers["x-request-id"];
if (requestId !== undefined) res.setHeader("x-request-id", requestId);
res.end(body);
}
function fail(req: IncomingMessage, res: ServerResponse, status: number, message: string): void {
const error: ApiError = { error: message, status };
send(req, res, status, JSON.stringify(error));
}
function parseQuery(query: string): Map<string, string> {
const params = new Map<string, string>();
if (query === "") return params;
for (const pair of query.split("&")) {
const eq = pair.indexOf("=");
if (eq < 0) params.set(decodeURIComponent(pair), "");
else params.set(decodeURIComponent(pair.slice(0, eq)), decodeURIComponent(pair.slice(eq + 1)));
}
return params;
}
function intParam(params: Map<string, string>, name: string, fallback: number): number {
const raw = params.get(name);
if (raw === undefined) return fallback;
const value = parseInt(raw, 10);
return Number.isNaN(value) || value < 0 ? fallback : value;
}
function listProducts(req: IncomingMessage, res: ServerResponse, query: string): void {
const params = parseQuery(query);
const category = params.get("category") ?? "";
const source = category === "" ? products : (byCategory.get(category) ?? []);
const minPrice = intParam(params, "minPrice", 0);
const offset = intParam(params, "offset", 0);
const limit = Math.min(intParam(params, "limit", 20), 50);
const matching = source.filter((p) => p.price >= minPrice);
const page: ProductPage = {
category: category === "" ? "all" : category,
total: matching.length,
offset,
items: matching.slice(offset, offset + limit).map((p) => ({
id: p.id,
name: p.name,
price: p.price,
inStock: p.stock > 0,
})),
};
send(req, res, 200, JSON.stringify(page));
}
function getProduct(req: IncomingMessage, res: ServerResponse, idText: string): void {
const id = parseInt(idText, 10);
if (Number.isNaN(id) || id < 0 || id >= products.length) {
fail(req, res, 404, "product " + idText + " not found");
return;
}
const product = products[id]!;
const etag = '"p' + product.id + "-v" + product.version + '"';
if (req.headers["if-none-match"] === etag) {
res.statusCode = 304;
res.setHeader("etag", etag);
res.end();
return;
}
res.setHeader("etag", etag);
send(req, res, 200, JSON.stringify(product));
}
function viewCart(cart: Cart): string {
let count = 0;
let total = 0;
for (const line of cart.lines) {
count += line.qty;
total += line.qty * line.unitPrice;
}
const view: CartView = {
id: cart.id,
lines: cart.lines,
count,
total: Math.round(total * 100) / 100,
};
return JSON.stringify(view);
}
function authorized(req: IncomingMessage): boolean {
const auth = req.headers.authorization;
return auth !== undefined && auth.startsWith("Bearer ") && auth.length > 12;
}
function addToCart(req: IncomingMessage, res: ServerResponse, cartId: string, body: string): void {
if (!authorized(req)) {
fail(req, res, 401, "missing or invalid token");
return;
}
const contentType = req.headers["content-type"];
if (contentType === undefined || !contentType.startsWith("application/json")) {
fail(req, res, 415, "expected application/json");
return;
}
let item: AddItem;
try {
item = JSON.parse(body) as AddItem;
} catch {
fail(req, res, 400, "malformed JSON body");
return;
}
if (item.productId < 0 || item.productId >= products.length || item.qty <= 0) {
fail(req, res, 422, "invalid item");
return;
}
const product = products[item.productId]!;
let cart = carts.get(cartId);
if (cart === undefined) {
cart = { id: cartId, lines: [] };
carts.set(cartId, cart);
}
const existing = cart.lines.find((line) => line.sku === product.sku);
if (existing !== undefined) existing.qty += item.qty;
else cart.lines.push({ sku: product.sku, name: product.name, qty: item.qty, unitPrice: product.price });
send(req, res, 201, viewCart(cart));
}
function removeFromCart(req: IncomingMessage, res: ServerResponse, cartId: string, sku: string): void {
if (!authorized(req)) {
fail(req, res, 401, "missing or invalid token");
return;
}
const cart = carts.get(cartId);
const index = cart === undefined ? -1 : cart.lines.findIndex((line) => line.sku === sku);
if (cart === undefined || index < 0) {
fail(req, res, 404, "line " + sku + " not in cart " + cartId);
return;
}
cart.lines.splice(index, 1);
res.statusCode = 204;
res.end();
}
function handle(req: IncomingMessage, res: ServerResponse, body: string): void {
requests++;
const url = req.url ?? "/";
const method = req.method ?? "GET";
const q = url.indexOf("?");
const path = q < 0 ? url : url.slice(0, q);
const query = q < 0 ? "" : url.slice(q + 1);
const parts = path.split("/").filter((s) => s !== "");
if (method === "GET" && path === "/health") {
count("health");
send(req, res, 200, '{"ok":true}');
} else if (method === "GET" && path === "/products") {
count("list");
listProducts(req, res, query);
} else if (method === "GET" && parts.length === 2 && parts[0] === "products") {
count("product");
getProduct(req, res, parts[1]!);
} else if (method === "GET" && parts.length === 2 && parts[0] === "carts") {
count("cart");
const cart = carts.get(parts[1]!);
if (cart === undefined) fail(req, res, 404, "cart " + parts[1]! + " not found");
else send(req, res, 200, viewCart(cart));
} else if (method === "POST" && parts.length === 3 && parts[0] === "carts" && parts[2] === "items") {
count("add");
addToCart(req, res, parts[1]!, body);
} else if (method === "DELETE" && parts.length === 4 && parts[0] === "carts" && parts[2] === "items") {
count("remove");
removeFromCart(req, res, parts[1]!, parts[3]!);
} else if (method === "POST" && path === "/shutdown") {
send(req, res, 200, '{"bye":true}');
server.close(() => {
let lines = 0;
let units = 0;
for (const cart of carts.values()) {
lines += cart.lines.length;
for (const line of cart.lines) units += line.qty;
}
const names = [...routeCounts.keys()].sort();
console.log("requests " + requests);
for (const name of names) console.log(" " + name + " " + routeCounts.get(name)!);
console.log("carts " + carts.size + " lines " + lines + " units " + units);
});
} else {
count("unmatched");
fail(req, res, 404, "no route for " + method + " " + path);
}
}
const server = createServer((req, res) => {
let body = "";
req.on("data", (chunk: Buffer) => {
body += chunk.toString("utf8");
});
req.on("end", () => handle(req, res, body));
});
server.listen(0, "127.0.0.1", () => {
process.stderr.write("PORT " + server.address().port + "\n");
});
@@ -0,0 +1,102 @@
// JSON round trips of typed records: build, stringify, parse back into a
// declared shape, aggregate, and stringify the summary.
interface LineItem {
sku: string;
quantity: number;
price: number;
}
interface Order {
id: number;
customer: string;
region: string;
priority: boolean;
items: LineItem[];
tags: string[];
}
interface Summary {
region: string;
orders: number;
revenue: number;
topSku: string;
}
interface RegionTotals {
orders: number;
revenue: number;
skus: Map<string, number>;
}
let seed = 12345;
function random(): number {
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
return seed / 4294967296;
}
const regions = ["north", "south", "east", "west", "central"];
function makeOrders(count: number): Order[] {
const orders: Order[] = [];
for (let i = 0; i < count; i++) {
const items: LineItem[] = [];
const n = 1 + Math.floor(random() * 6);
for (let j = 0; j < n; j++) {
items.push({
sku: "sku-" + Math.floor(random() * 500),
quantity: 1 + Math.floor(random() * 9),
price: Math.round(random() * 10000) / 100,
});
}
orders.push({
id: i,
customer: "customer-" + Math.floor(random() * 2000),
region: regions[i % regions.length]!,
priority: random() < 0.2,
items,
tags: random() < 0.5 ? ["web"] : ["store", "promo"],
});
}
return orders;
}
function summarize(orders: Order[]): Summary[] {
const byRegion = new Map<string, RegionTotals>();
for (const order of orders) {
let entry = byRegion.get(order.region);
if (entry === undefined) {
entry = { orders: 0, revenue: 0, skus: new Map<string, number>() };
byRegion.set(order.region, entry);
}
entry.orders++;
for (const item of order.items) {
entry.revenue += item.quantity * item.price;
entry.skus.set(item.sku, (entry.skus.get(item.sku) ?? 0) + item.quantity);
}
}
const result: Summary[] = [];
for (const [region, entry] of byRegion) {
let topSku = "";
let topCount = -1;
for (const [sku, count] of entry.skus) {
if (count > topCount || (count === topCount && sku < topSku)) {
topSku = sku;
topCount = count;
}
}
result.push({ region, orders: entry.orders, revenue: Math.round(entry.revenue * 100) / 100, topSku });
}
result.sort((a, b) => (a.region < b.region ? -1 : a.region > b.region ? 1 : 0));
return result;
}
const scale = Number(process.argv[2] ?? "1");
const orders = makeOrders(Math.floor(4000 * scale));
let checksum = 0;
let last = "";
for (let round = 0; round < 6; round++) {
const text = JSON.stringify(orders);
checksum += text.length;
const parsed = JSON.parse(text) as Order[];
const summary = summarize(parsed);
last = JSON.stringify(summary);
checksum += last.length;
}
console.log(last);
console.log("checksum", checksum);
+62
View File
@@ -0,0 +1,62 @@
// A logging-heavy CLI: one console line per record, written immediately.
// Mixes the common console shapes (a single template literal, string plus
// number arguments, several primitive arguments) with process.stdout.write
// chunks and an occasional console.error line.
interface LogRecord {
id: number;
method: string;
path: string;
status: number;
latency: number;
cached: boolean;
}
const methods = ["GET", "POST", "PUT", "DELETE"];
const paths = ["/api/users", "/api/orders", "/health", "/static/app.js", "/api/search?q=caf\u00e9"];
let seed = 7;
function random(): number {
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
return seed / 4294967296;
}
function makeRecord(id: number): LogRecord {
const r = random();
return {
id,
method: methods[id % methods.length]!,
path: paths[Math.floor(random() * paths.length)]!,
status: r < 0.9 ? 200 : r < 0.97 ? 404 : 500,
latency: Math.round(random() * 250000) / 1000,
cached: random() < 0.3,
};
}
const scale = Number(process.argv[2] ?? "1");
const count = Math.round(50000 * scale);
let errors = 0;
let bytes = 0;
for (let i = 0; i < count; i++) {
const rec = makeRecord(i);
switch (i % 4) {
case 0:
console.log(`${rec.method} ${rec.path} ${rec.status} ${rec.latency}ms`);
break;
case 1:
console.log("request", rec.id, rec.method, rec.status);
break;
case 2:
console.log("latency", rec.latency, "cached", rec.cached);
break;
default: {
const chunk = "[" + rec.id + "] " + rec.path + (rec.cached ? " (cached)" : "") + "\n";
bytes += chunk.length;
process.stdout.write(chunk);
}
}
if (rec.status === 500) {
errors++;
if (errors % 100 === 0) console.error("error budget: " + errors + " server errors at record " + rec.id);
}
}
console.log("records", count, "errors", errors, "chunk chars", bytes);
@@ -0,0 +1,74 @@
// Integer- and float-heavy kernels: sieve, hashing, matrix multiply on
// number[][], histogram on plain arrays, and a Float64Array smoothing pass.
function sieve(limit: number): number {
const composite: boolean[] = new Array<boolean>(limit + 1).fill(false);
let count = 0;
for (let i = 2; i <= limit; i++) {
if (composite[i]) continue;
count++;
for (let j = i * i; j <= limit; j += i) composite[j] = true;
}
return count;
}
function fnv(values: number[]): number {
let h = 2166136261;
for (let i = 0; i < values.length; i++) {
h ^= values[i]! & 0xff;
h = Math.imul(h, 16777619) >>> 0;
}
return h;
}
function matmul(a: number[][], b: number[][], n: number): number[][] {
const c: number[][] = [];
for (let i = 0; i < n; i++) {
const row: number[] = new Array<number>(n).fill(0);
const ai = a[i]!;
for (let k = 0; k < n; k++) {
const aik = ai[k]!;
const bk = b[k]!;
for (let j = 0; j < n; j++) row[j] = row[j]! + aik * bk[j]!;
}
c.push(row);
}
return c;
}
const scale = Number(process.argv[2] ?? "1");
const primes = sieve(Math.floor(2000000 * scale));
const data: number[] = [];
let x = 1;
for (let i = 0; i < Math.floor(1000000 * scale); i++) {
x = (Math.imul(x, 48271) + 11) & 0x7fffffff;
data.push(x % 1000);
}
let hash = 0;
for (let r = 0; r < 4; r++) hash = (hash + fnv(data)) >>> 0;
const histogram: number[] = new Array<number>(100).fill(0);
for (let i = 0; i < data.length; i++) {
const bucket = data[i]! % 100;
histogram[bucket] = histogram[bucket]! + 1;
}
const n = Math.floor(120 * Math.sqrt(scale));
const a: number[][] = [];
const b: number[][] = [];
for (let i = 0; i < n; i++) {
const ra: number[] = [];
const rb: number[] = [];
for (let j = 0; j < n; j++) {
ra.push(((i * j) % 7) - 3);
rb.push(((i + j) % 5) - 2);
}
a.push(ra);
b.push(rb);
}
const c = matmul(a, b, n);
let trace = 0;
for (let i = 0; i < n; i++) trace += c[i]![i]!;
const signal = new Float64Array(data.length);
for (let i = 0; i < data.length; i++) signal[i] = data[i]! / 7;
let smooth = 0;
for (let pass = 0; pass < 3; pass++) {
for (let i = 1; i < signal.length - 1; i++) signal[i] = (signal[i - 1]! + signal[i]! * 2 + signal[i + 1]!) / 4;
}
for (let i = 0; i < signal.length; i += 1000) smooth += signal[i]!;
console.log("primes", primes, "hash", hash, "hist", histogram[0], histogram[99], "trace", trace, "smooth", smooth.toFixed(6));
@@ -0,0 +1,80 @@
// Business-record processing: multi-key sorts with comparator closures,
// group-by into Maps, and reductions over the groups.
interface Employee {
id: number;
name: string;
department: string;
level: number;
salary: number;
startYear: number;
}
let seed = 2024;
function random(): number {
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
return seed / 4294967296;
}
const departments = ["eng", "sales", "support", "finance", "legal", "design", "ops"];
const first = ["ana", "bo", "cy", "dee", "eli", "fay", "gus", "hal", "ivy", "jon"];
const last = ["smith", "jones", "lee", "khan", "garcia", "chen", "novak", "silva"];
function makeEmployees(count: number): Employee[] {
const result: Employee[] = [];
for (let i = 0; i < count; i++) {
result.push({
id: i,
name: first[Math.floor(random() * first.length)]! + " " + last[Math.floor(random() * last.length)]!,
department: departments[Math.floor(random() * departments.length)]!,
level: 1 + Math.floor(random() * 7),
salary: 40000 + Math.floor(random() * 160000),
startYear: 1995 + Math.floor(random() * 30),
});
}
return result;
}
function compareBy(keys: string[]): (a: Employee, b: Employee) => number {
return (a, b) => {
for (const key of keys) {
let d = 0;
if (key === "department") d = a.department < b.department ? -1 : a.department > b.department ? 1 : 0;
else if (key === "level") d = b.level - a.level;
else if (key === "salary") d = b.salary - a.salary;
else if (key === "name") d = a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
else d = a.id - b.id;
if (d !== 0) return d;
}
return 0;
};
}
const scale = Number(process.argv[2] ?? "1");
const employees = makeEmployees(Math.floor(60000 * scale));
let checksum = 0;
const orders = [
["department", "level", "salary", "id"],
["name", "id"],
["salary", "id"],
["level", "name", "id"],
];
for (const order of orders) {
const sorted = employees.slice().sort(compareBy(order));
for (let i = 0; i < sorted.length; i += 997) checksum = (checksum * 31 + sorted[i]!.id) % 1000000007;
}
const groups = new Map<string, Employee[]>();
for (const e of employees) {
const key = e.department + "/" + e.level;
const group = groups.get(key);
if (group === undefined) groups.set(key, [e]);
else group.push(e);
}
const lines: string[] = [];
for (const [key, group] of groups) {
const total = group.reduce((sum, e) => sum + e.salary, 0);
const veterans = group.filter((e) => e.startYear < 2010).length;
const top = group.reduce((best, e) => (e.salary > best.salary ? e : best), group[0]!);
lines.push(`${key} n=${group.length} avg=${Math.round(total / group.length)} vets=${veterans} top=${top.id}`);
}
lines.sort();
for (const line of lines.slice(0, 12)) console.log(line);
console.log("groups", lines.length, "checksum", checksum);
@@ -0,0 +1,49 @@
// Regex-driven log processing: match with capture groups, test, and replace.
let seed = 99;
function random(): number {
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
return seed / 4294967296;
}
const methods = ["GET", "POST", "PUT", "DELETE"];
const paths = ["/api/users", "/api/orders", "/static/app.js", "/health", "/api/items/search"];
function makeLines(count: number): string[] {
const lines: string[] = [];
for (let i = 0; i < count; i++) {
const ip = `10.${Math.floor(random() * 255)}.${Math.floor(random() * 255)}.${Math.floor(random() * 255)}`;
const method = methods[Math.floor(random() * methods.length)]!;
const path = paths[Math.floor(random() * paths.length)]! + (random() < 0.3 ? "?id=" + Math.floor(random() * 1000) : "");
const status = random() < 0.9 ? 200 : random() < 0.5 ? 404 : 500;
const ms = Math.floor(random() * 900);
const agent = random() < 0.5 ? "Mozilla/5.0 (Macintosh)" : "curl/8.4.0";
lines.push(`${ip} - - [07/Oct/2026:12:${String(i % 60).padStart(2, "0")}:00 +0000] "${method} ${path} HTTP/1.1" ${status} ${ms} "${agent}"`);
}
return lines;
}
const linePattern = /^(\d+\.\d+\.\d+\.\d+) - - \[([^\]]+)\] "(\w+) ([^ ?"]+)(?:\?([^ "]*))? HTTP\/1\.1" (\d{3}) (\d+) "([^"]*)"$/;
const botPattern = /curl|bot|spider/i;
const digits = /\d+/g;
const scale = Number(process.argv[2] ?? "1");
const lines = makeLines(Math.floor(30000 * scale));
const counts = new Map<string, number>();
let errors = 0;
let bots = 0;
let totalMs = 0;
let redactedLength = 0;
for (let round = 0; round < 3; round++) {
for (const line of lines) {
const m = linePattern.exec(line);
if (m === null) continue;
const key = m[3]! + " " + m[4]!;
counts.set(key, (counts.get(key) ?? 0) + 1);
if (m[6] !== "200") errors++;
if (botPattern.test(m[8]!)) bots++;
totalMs += Number(m[7]);
redactedLength += line.replace(digits, "#").length;
}
}
const keys = [...counts.keys()].sort();
for (const key of keys) console.log(key, counts.get(key));
console.log("errors", errors, "bots", bots, "ms", totalMs, "redacted", redactedLength);
@@ -0,0 +1,73 @@
// Server-side HTML rendering: template literals, escaping, joins, and
// conditional fragments over a list of typed view models.
interface Product {
id: number;
title: string;
description: string;
price: number;
tags: string[];
inStock: boolean;
rating: number;
}
let seed = 31337;
function random(): number {
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
return seed / 4294967296;
}
const words = ["fast", "durable", "<b>bold</b>", "eco", '"quoted"', "light", "smart", "R&D", "classic", "modern"];
function sentence(n: number): string {
const parts: string[] = [];
for (let i = 0; i < n; i++) parts.push(words[Math.floor(random() * words.length)]!);
return parts.join(" ");
}
function makeProducts(count: number): Product[] {
const products: Product[] = [];
for (let i = 0; i < count; i++) {
products.push({
id: i,
title: sentence(3),
description: sentence(20),
price: Math.round(random() * 100000) / 100,
tags: [words[i % words.length]!, words[(i * 7) % words.length]!],
inStock: random() < 0.8,
rating: Math.round(random() * 50) / 10,
});
}
return products;
}
function escapeHtml(text: string): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function stars(rating: number): string {
let out = "";
for (let i = 1; i <= 5; i++) out += i <= Math.round(rating) ? "*" : "-";
return out;
}
function renderProduct(p: Product): string {
const tags = p.tags.map((t) => `<li class="tag">${escapeHtml(t)}</li>`).join("");
return `<article id="p-${p.id}" class="${p.inStock ? "in" : "out"}">
<h2>${escapeHtml(p.title)}</h2>
<p>${escapeHtml(p.description)}</p>
<span class="price">$${p.price.toFixed(2)}</span>
<span class="rating" title="${p.rating}">${stars(p.rating)}</span>
<ul>${tags}</ul>
${p.inStock ? "<button>Add to cart</button>" : "<em>Sold out</em>"}
</article>`;
}
function renderPage(products: Product[], page: number): string {
const body = products.map(renderProduct).join("\n");
return `<!doctype html><html><head><title>Page ${page}</title></head><body>${body}</body></html>`;
}
const scale = Number(process.argv[2] ?? "1");
const products = makeProducts(Math.floor(2000 * scale));
let bytes = 0;
let hash = 0;
for (let page = 0; page < 10; page++) {
const html = renderPage(products, page);
bytes += html.length;
for (let i = 0; i < html.length; i += 101) hash = (hash * 33 + html.charCodeAt(i)) % 2147483647;
}
console.log("bytes", bytes, "hash", hash);
@@ -0,0 +1,76 @@
// Validation layers that throw and catch errors for a fraction of inputs,
// with deep call chains of small functions on the success path.
class ValidationError extends Error {
readonly field: string;
constructor(field: string, message: string) {
super(message);
this.field = field;
}
}
interface Signup {
email: string;
age: number;
name: string;
password: string;
}
let seed = 8080;
function random(): number {
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
return seed / 4294967296;
}
function makeSignup(i: number): Signup {
const bad = random();
return {
email: bad < 0.05 ? "broken" : `user${i}@example.com`,
age: bad > 0.97 ? -1 : 18 + Math.floor(random() * 60),
name: bad > 0.95 && bad <= 0.97 ? " " : "Name " + i,
password: random() < 0.03 ? "short" : "correct horse battery " + i,
};
}
function requireText(field: string, value: string, min: number): string {
if (value.length < min) throw new ValidationError(field, `${field} must have at least ${min} characters`);
return value;
}
function requireEmail(value: string): string {
const at = value.indexOf("@");
if (at <= 0 || value.indexOf(".", at) < 0) throw new ValidationError("email", "invalid email");
return value.toLowerCase();
}
function requireRange(field: string, value: number, min: number, max: number): number {
if (value < min || value > max) throw new ValidationError(field, `${field} out of range`);
return value;
}
function normalize(input: Signup): Signup {
return {
email: requireEmail(input.email),
age: requireRange("age", input.age, 13, 120),
name: requireText("name", input.name.trim(), 1),
password: requireText("password", input.password, 8),
};
}
function score(s: Signup): number {
return s.email.length + s.age + s.name.length + s.password.length;
}
const scale = Number(process.argv[2] ?? "1");
const inputs: Signup[] = [];
for (let i = 0; i < Math.floor(200000 * scale); i++) inputs.push(makeSignup(i));
const failures = new Map<string, number>();
let accepted = 0;
let total = 0;
for (let round = 0; round < 3; round++) {
for (const input of inputs) {
try {
const s = normalize(input);
accepted++;
total += score(s);
} catch (error) {
if (error instanceof ValidationError) failures.set(error.field, (failures.get(error.field) ?? 0) + 1);
else throw error;
}
}
}
console.log("accepted", accepted, "total", total);
for (const key of [...failures.keys()].sort()) console.log(key, failures.get(key));
@@ -0,0 +1,61 @@
// Map/Set-heavy text and graph processing: word frequencies, bigrams,
// and breadth-first search over a string-keyed adjacency map.
let seed = 555;
function random(): number {
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
return seed / 4294967296;
}
const syllables = ["ka", "lo", "mi", "ne", "ru", "sa", "to", "vi", "ze", "po", "qu", "ex"];
function word(): string {
const n = 1 + Math.floor(random() * 3);
let w = "";
for (let i = 0; i < n; i++) w += syllables[Math.floor(random() * syllables.length)]!;
return w;
}
const scale = Number(process.argv[2] ?? "1");
const text: string[] = [];
for (let i = 0; i < Math.floor(300000 * scale); i++) text.push(word());
const freq = new Map<string, number>();
for (const w of text) freq.set(w, (freq.get(w) ?? 0) + 1);
const bigrams = new Map<string, number>();
for (let i = 1; i < text.length; i++) {
const key = text[i - 1]! + " " + text[i]!;
bigrams.set(key, (bigrams.get(key) ?? 0) + 1);
}
const top = [...freq.entries()].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)).slice(0, 5);
console.log("words", freq.size, "bigrams", bigrams.size, "top", top.map((e) => e[0] + ":" + e[1]).join(","));
const graph = new Map<string, string[]>();
for (let i = 1; i < text.length; i += 3) {
const from = text[i - 1]!;
const to = text[i]!;
const edges = graph.get(from);
if (edges === undefined) graph.set(from, [to]);
else if (edges.length < 8) edges.push(to);
}
let reachable = 0;
let depthSum = 0;
const starts = [...graph.keys()].sort().slice(0, 60);
for (const start of starts) {
const seen = new Set<string>([start]);
let frontier = [start];
let depth = 0;
while (frontier.length > 0) {
const next: string[] = [];
for (const node of frontier) {
for (const neighbor of graph.get(node) ?? []) {
if (!seen.has(neighbor)) {
seen.add(neighbor);
next.push(neighbor);
}
}
}
frontier = next;
depth++;
}
reachable += seen.size;
depthSum += depth;
}
console.log("reachable", reachable, "depth", depthSum);
+247
View File
@@ -0,0 +1,247 @@
// Scaling probes: array operations. Usage: arrays <case> <n>
// stdout: deterministic checksum (compared with Node); stderr: T=<ms>.
let seed = 12345;
function rnd(): number {
seed = (Math.imul(seed, 1103515245) + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
}
function make(n: number): number[] {
const a: number[] = [];
for (let i = 0; i < n; i++) a.push(i);
return a;
}
function push(n: number): string {
const a: number[] = [];
for (let i = 0; i < n; i++) a.push(i * 2);
let s = 0;
for (let i = 0; i < a.length; i++) s += a[i]!;
return `${a.length} ${s}`;
}
function pop(n: number): string {
const a = make(n);
let s = 0;
while (a.length > 0) s += a.pop()!;
return `${s}`;
}
function shift(n: number): string {
const a = make(n);
let s = 0;
while (a.length > 0) s += a.shift()!;
return `${s}`;
}
function queue(n: number): string {
// BFS-style queue: push at the back, shift from the front, ~100 live.
const q: number[] = [];
let s = 0;
for (let i = 0; i < 100; i++) q.push(i);
for (let i = 0; i < n; i++) {
s += q.shift()!;
q.push(i);
}
return `${s} ${q.length}`;
}
function unshift(n: number): string {
const a: number[] = [];
for (let i = 0; i < n; i++) a.unshift(i);
return `${a.length} ${a[0]} ${a[a.length - 1]}`;
}
function spliceTail(n: number): string {
const a = make(n);
let s = 0;
while (a.length > 0) s += a.splice(a.length - 1, 1)[0]!;
return `${s}`;
}
function spliceMid(n: number): string {
const a = make(n);
let s = 0;
for (let k = 0; k < 1000; k++) {
const at = Math.floor(rnd() * a.length);
s += a.splice(at, 1)[0]!;
a.splice(at, 0, k);
}
return `${s} ${a.length}`;
}
function sliceSmall(n: number): string {
const a = make(n);
let s = 0;
for (let i = 0; i + 8 <= n; i++) s += a.slice(i, i + 8).length;
return `${s}`;
}
function concat(n: number): string {
const a = make(n);
const b = make(n);
let s = 0;
for (let k = 0; k < 10; k++) s += a.concat(b).length;
return `${s}`;
}
function indexOf(n: number): string {
const a = make(n);
let s = 0;
for (let k = 0; k < 100; k++) s += a.indexOf(n - 1 - k);
return `${s}`;
}
function includesStr(n: number): string {
const a: string[] = [];
for (let i = 0; i < n; i++) a.push("k" + i);
let s = 0;
for (let k = 0; k < 100; k++) if (a.includes("k" + (n - 1 - k))) s++;
return `${s}`;
}
function sortNum(n: number): string {
const a: number[] = [];
for (let i = 0; i < n; i++) a.push(Math.floor(rnd() * 1e9));
a.sort((x, y) => x - y);
return `${a[0]} ${a[n >> 1]} ${a[n - 1]}`;
}
function sortSorted(n: number): string {
const a = make(n);
a.sort((x, y) => x - y);
a.sort((x, y) => y - x);
return `${a[0]} ${a[n - 1]}`;
}
function sortStr(n: number): string {
const a: string[] = [];
for (let i = 0; i < n; i++) a.push("s" + Math.floor(rnd() * 1e9));
a.sort();
return `${a[0]} ${a[n - 1]}`;
}
function sortObj(n: number): string {
const a: { k: number; v: string }[] = [];
for (let i = 0; i < n; i++) a.push({ k: Math.floor(rnd() * 1000), v: "v" + i });
a.sort((x, y) => x.k - y.k);
return `${a[0]!.v} ${a[n - 1]!.v}`;
}
function reverse(n: number): string {
const a = make(n);
for (let k = 0; k < 10; k++) a.reverse();
return `${a[0]} ${a[n - 1]}`;
}
function inArr(n: number): string {
const a = make(n);
let c = 0;
for (let i = 0; i < n * 2; i++) if (i in a) c++;
return `${c}`;
}
function inArrStr(n: number): string {
const a: string[] = [];
for (let i = 0; i < n; i++) a.push("x" + i);
let c = 0;
for (let i = 0; i < n * 2; i++) if (i in a) c++;
return `${c}`;
}
function holes(n: number): string {
const a = new Array<number>(n);
for (let i = 1; i < n; i += 2) a[i] = i;
let c = 0;
let s = 0;
for (let i = 0; i < n; i++) if (i in a) c++;
a.forEach((v) => {
s += v;
});
return `${c} ${s} ${a.length}`;
}
function sparse(n: number): string {
const a: number[] = [];
for (let i = 0; i < n; i++) a[i * 1000] = i;
let s = 0;
a.forEach((v) => {
s += v;
});
return `${a.length} ${s}`;
}
function spread(n: number): string {
const a = make(n);
let s = 0;
for (let k = 0; k < 10; k++) s += [...a].length;
return `${s}`;
}
function mapFilterReduce(n: number): string {
const a = make(n);
const r = a
.map((x) => x * 3)
.filter((x) => x % 2 === 0)
.reduce((acc, x) => acc + x, 0);
return `${r}`;
}
function arrayFrom(n: number): string {
const a = Array.from({ length: n }, (_, i) => i * 2);
return `${a.length} ${a[n - 1]}`;
}
function join(n: number): string {
const a = make(n);
return `${a.join(",").length}`;
}
function flat(n: number): string {
const a: number[][] = [];
for (let i = 0; i < n; i += 4) a.push([i, i + 1, i + 2, i + 3]);
return `${a.flat().length}`;
}
function lastIndexOf(n: number): string {
const a = make(n);
let s = 0;
for (let k = 0; k < 100; k++) s += a.lastIndexOf(k);
return `${s}`;
}
function findIndex(n: number): string {
const a = make(n);
let s = 0;
for (let k = 0; k < 20; k++) s += a.findIndex((x) => x === n - 1 - k);
return `${s}`;
}
function nested2d(n: number): string {
const side = Math.floor(Math.sqrt(n));
const g: number[][] = [];
for (let i = 0; i < side; i++) g.push(new Array<number>(side).fill(i));
let s = 0;
for (let i = 0; i < side; i++) for (let j = 0; j < side; j++) s += g[i]![j]!;
return `${s}`;
}
function lengthTruncate(n: number): string {
const a = make(n);
let s = 0;
while (a.length > 0) {
s += a[a.length - 1]!;
a.length = a.length - 1;
}
return `${s}`;
}
const which = process.argv[2] ?? "";
const n = Number(process.argv[3] ?? "1000");
const t0 = performance.now();
let out = "";
switch (which) {
case "push": out = push(n); break;
case "pop": out = pop(n); break;
case "shift": out = shift(n); break;
case "queue": out = queue(n); break;
case "unshift": out = unshift(n); break;
case "splice-tail": out = spliceTail(n); break;
case "splice-mid": out = spliceMid(n); break;
case "slice-small": out = sliceSmall(n); break;
case "concat": out = concat(n); break;
case "indexOf": out = indexOf(n); break;
case "includes-str": out = includesStr(n); break;
case "sort-num": out = sortNum(n); break;
case "sort-sorted": out = sortSorted(n); break;
case "sort-str": out = sortStr(n); break;
case "sort-obj": out = sortObj(n); break;
case "reverse": out = reverse(n); break;
case "in-arr": out = inArr(n); break;
case "in-arr-str": out = inArrStr(n); break;
case "holes": out = holes(n); break;
case "sparse": out = sparse(n); break;
case "spread": out = spread(n); break;
case "map-filter-reduce": out = mapFilterReduce(n); break;
case "array-from": out = arrayFrom(n); break;
case "join": out = join(n); break;
case "flat": out = flat(n); break;
case "lastIndexOf": out = lastIndexOf(n); break;
case "findIndex": out = findIndex(n); break;
case "nested2d": out = nested2d(n); break;
case "length-truncate": out = lengthTruncate(n); break;
default: out = "unknown case";
}
const t1 = performance.now();
console.log(which, n, out);
console.error("T=" + (t1 - t0).toFixed(3));
+189
View File
@@ -0,0 +1,189 @@
// Scaling probes: promises, timers, console, fs. Usage: async-io <case> <n>
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
async function awaitChain(n: number): Promise<string> {
let s = 0;
for (let i = 0; i < n; i++) s += await Promise.resolve(i);
return `${s}`;
}
async function asyncCalls(n: number): Promise<string> {
async function step(i: number): Promise<number> {
await null;
return i & 7;
}
let s = 0;
for (let i = 0; i < n; i++) s += await step(i);
return `${s}`;
}
async function promiseAll(n: number): Promise<string> {
async function work(i: number): Promise<number> {
await Promise.resolve();
return i;
}
const ps: Promise<number>[] = [];
for (let i = 0; i < n; i++) ps.push(work(i));
const vs = await Promise.all(ps);
let s = 0;
for (const v of vs) s += v;
return `${s}`;
}
async function thenChain(n: number): Promise<string> {
let p: Promise<number> = Promise.resolve(0);
for (let i = 0; i < n; i++) p = p.then((v) => v + 1);
return `${await p}`;
}
async function timersSeq(n: number): Promise<string> {
let c = 0;
for (let i = 0; i < n; i++) {
await new Promise<void>((r) => setTimeout(r, 0));
c++;
}
return `${c}`;
}
async function timersMany(n: number): Promise<string> {
// n concurrent timers with spread-out delays (0..19 ms), fired in delay order.
let c = 0;
let last = -1;
let ordered = true;
await new Promise<void>((resolve) => {
for (let i = 0; i < n; i++) {
const d = (i * 7) % 20;
setTimeout(() => {
if (d < last) ordered = false;
last = d;
c++;
if (c === n) resolve();
}, d);
}
});
return `${c} ${ordered}`;
}
async function timersCancel(n: number): Promise<string> {
let fired = 0;
const hs = [setTimeout(() => fired++, 50)];
for (let i = 1; i < n; i++) hs.push(setTimeout(() => fired++, 50 + (i % 10)));
for (let i = 0; i < hs.length; i++) clearTimeout(hs[i]!);
await new Promise<void>((r) => setTimeout(r, 1));
return `${fired}`;
}
async function immediates(n: number): Promise<string> {
let c = 0;
for (let i = 0; i < n; i++) {
await new Promise<void>((r) => setImmediate(r));
c++;
}
return `${c}`;
}
async function microtasks(n: number): Promise<string> {
let c = 0;
await new Promise<void>((resolve) => {
const tick = (): void => {
c++;
if (c < n) queueMicrotask(tick);
else resolve();
};
queueMicrotask(tick);
});
return `${c}`;
}
async function nextTicks(n: number): Promise<string> {
let c = 0;
await new Promise<void>((resolve) => {
const tick = (): void => {
c++;
if (c < n) process.nextTick(tick);
else resolve();
};
process.nextTick(tick);
});
return `${c}`;
}
function consoleLog(n: number): string {
for (let i = 0; i < n; i++) console.log("line " + i);
return "done";
}
function consoleLogMulti(n: number): string {
for (let i = 0; i < n; i++) console.log("row", i, i * 2, true);
return "done";
}
function stdoutWrite(n: number): string {
for (let i = 0; i < n; i++) process.stdout.write("w" + i + "\n");
return "done";
}
function consoleError(n: number): string {
for (let i = 0; i < n; i++) console.error("err " + i);
return "done";
}
function fsSmall(n: number): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "probe-"));
for (let i = 0; i < n; i++) fs.writeFileSync(path.join(dir, "f" + i + ".txt"), "content " + i);
let t = 0;
for (let i = 0; i < n; i++) t += fs.readFileSync(path.join(dir, "f" + i + ".txt"), "utf8").length;
const names = fs.readdirSync(dir);
let e = 0;
for (let i = 0; i < n; i++) if (fs.existsSync(path.join(dir, "f" + i + ".txt"))) e++;
for (const nm of names) fs.unlinkSync(path.join(dir, nm));
fs.rmdirSync(dir);
return `${t} ${names.length} ${e}`;
}
function fsAppend(n: number): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "probe-"));
const f = path.join(dir, "log.txt");
for (let i = 0; i < n; i++) fs.appendFileSync(f, "entry " + i + "\n");
const len = fs.readFileSync(f, "utf8").length;
fs.unlinkSync(f);
fs.rmdirSync(dir);
return `${len}`;
}
function fsBigReadLines(n: number): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "probe-"));
const f = path.join(dir, "big.txt");
const lines: string[] = [];
for (let i = 0; i < n; i++) lines.push(`${i},item${i},${i % 13}`);
fs.writeFileSync(f, lines.join("\n"));
const text = fs.readFileSync(f, "utf8");
let s = 0;
for (const line of text.split("\n")) s += Number(line.slice(line.lastIndexOf(",") + 1));
fs.unlinkSync(f);
fs.rmdirSync(dir);
return `${s}`;
}
function bufferOps(n: number): string {
let t = 0;
for (let i = 0; i < n; i++) {
const b = Buffer.from("hello " + i, "utf8");
t += b.length + b.toString("hex").length + b.toString("base64").length;
}
return `${t}`;
}
const which = process.argv[2] ?? "";
const n = Number(process.argv[3] ?? "1000");
const t0 = performance.now();
let out = "";
switch (which) {
case "await-chain": out = await awaitChain(n); break;
case "async-calls": out = await asyncCalls(n); break;
case "promise-all": out = await promiseAll(n); break;
case "then-chain": out = await thenChain(n); break;
case "timers-seq": out = await timersSeq(n); break;
case "timers-many": out = await timersMany(n); break;
case "timers-cancel": out = await timersCancel(n); break;
case "immediates": out = await immediates(n); break;
case "microtasks": out = await microtasks(n); break;
case "next-ticks": out = await nextTicks(n); break;
case "console-log": out = consoleLog(n); break;
case "console-log-multi": out = consoleLogMulti(n); break;
case "stdout-write": out = stdoutWrite(n); break;
case "console-error": out = consoleError(n); break;
case "fs-small": out = fsSmall(n); break;
case "fs-append": out = fsAppend(n); break;
case "fs-big-read": out = fsBigReadLines(n); break;
case "buffer-ops": out = bufferOps(n); break;
default: out = "unknown case";
}
const t1 = performance.now();
console.log(which, n, out);
console.error("T=" + (t1 - t0).toFixed(3));
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# Build every probe file with the given scriptc checkout; print diagnostics.
# usage: build-all.sh <scriptc checkout> <outdir>
set -u
sc=${1:-.}
out=${2:-/tmp/scaling-build}
here=$(cd "$(dirname "$0")" && pwd)
mkdir -p "$out"
for f in arrays strings collections misc async-io; do
node "$sc/packages/cli/dist/bootstrap.js" build "$here/$f.ts" --optimization=release -o "$out/$f" >"$out/$f.log" 2>&1 &
done
wait
for f in arrays strings collections misc async-io; do
if [ -x "$out/$f" ]; then echo "ok $f"; else echo "FAIL $f"; grep -A3 'error SC' "$out/$f.log" | head -40; fi
done
+222
View File
@@ -0,0 +1,222 @@
// Scaling probes: Map/Set/object-as-dictionary and records. Usage: collections <case> <n>
let seed = 12345;
function rnd(): number {
seed = (Math.imul(seed, 1103515245) + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
}
function mapNum(n: number): string {
const m = new Map<number, number>();
for (let i = 0; i < n; i++) m.set(i * 7, i);
let s = 0;
for (let i = 0; i < n; i++) s += m.get(i * 7)!;
for (let i = 0; i < n; i++) m.delete(i * 7);
return `${s} ${m.size}`;
}
function mapStr(n: number): string {
const m = new Map<string, number>();
for (let i = 0; i < n; i++) m.set("key" + i, i);
let s = 0;
for (let i = 0; i < n; i++) s += m.get("key" + i) ?? 0;
return `${s} ${m.size}`;
}
function mapChurn(n: number): string {
// Sliding window: 1000 live keys, n insert+delete operations.
const m = new Map<number, number>();
let s = 0;
for (let i = 0; i < n; i++) {
m.set(i, i);
if (i >= 1000) m.delete(i - 1000);
if ((i & 1023) === 0) for (const v of m.values()) s += v & 1;
}
return `${s} ${m.size}`;
}
function mapChurnFirst(n: number): string {
// LRU-style: delete the oldest via keys().next() (iteration start after many deletes).
const m = new Map<number, number>();
let s = 0;
for (let i = 0; i < n; i++) {
m.set(i, i);
if (m.size > 100) {
const first = m.keys().next().value!;
s += first;
m.delete(first);
}
}
return `${s} ${m.size}`;
}
function lruTouch(n: number): string {
// LRU cache touch: delete + re-set existing keys (moves to the end).
const m = new Map<number, number>();
for (let i = 0; i < 1000; i++) m.set(i, i);
let s = 0;
for (let i = 0; i < n; i++) {
const k = Math.floor(rnd() * 1000);
const v = m.get(k)!;
m.delete(k);
m.set(k, v + 1);
s += v;
}
return `${s} ${m.size}`;
}
function setChurn(n: number): string {
const st = new Set<string>();
let c = 0;
for (let i = 0; i < n; i++) {
st.add("s" + i);
if (i >= 500) st.delete("s" + (i - 500));
if (st.has("s" + (i - 250))) c++;
}
return `${c} ${st.size}`;
}
function setIterDelete(n: number): string {
const st = new Set<number>();
for (let i = 0; i < n; i++) st.add(i);
let c = 0;
for (const v of st) {
if (v % 2 === 0) st.delete(v);
c++;
}
return `${c} ${st.size}`;
}
function setFromArray(n: number): string {
const a: number[] = [];
for (let i = 0; i < n; i++) a.push(i % 1000);
const st = new Set(a);
return `${st.size} ${[...st].length}`;
}
function objDict(n: number): string {
const o: Record<string, number> = {};
for (let i = 0; i < n; i++) o["k" + i] = i;
let s = 0;
for (let i = 0; i < n; i++) s += o["k" + i]!;
let c = 0;
for (let i = 0; i < n; i += 3) if ("k" + i in o) c++;
return `${s} ${c}`;
}
function objDictDelete(n: number): string {
const o: Record<string, number> = {};
let s = 0;
for (let i = 0; i < n; i++) {
o["k" + i] = i;
if (i >= 100) delete o["k" + (i - 100)];
}
for (const k in o) s += o[k]!;
return `${s} ${Object.keys(o).length}`;
}
function objNumKeys(n: number): string {
const o: Record<number, number> = {};
for (let i = 0; i < n; i++) o[i * 3] = i;
let s = 0;
for (let i = 0; i < n; i++) s += o[i * 3]!;
return `${s}`;
}
function objectKeys(n: number): string {
const o: Record<string, number> = {};
for (let i = 0; i < n; i++) o["p" + i] = i;
let t = 0;
for (let k = 0; k < 5; k++) {
t += Object.keys(o).length;
t += Object.entries(o).length;
t += Object.values(o).length;
}
return `${t}`;
}
function spreadAccumulate(n: number): string {
// reduce((acc, x) => ({...acc, [k]: v})) — quadratic by construction, compare to Node.
let acc: Record<string, number> = {};
for (let i = 0; i < n; i++) acc = { ...acc, ["f" + i]: i };
return `${Object.keys(acc).length}`;
}
function spreadSmall(n: number): string {
type P = { x: number; y: number; z: number; name: string };
let p: P = { x: 0, y: 0, z: 0, name: "p" };
let s = 0;
for (let i = 0; i < n; i++) {
p = { ...p, x: p.x + 1 };
s += p.x;
}
return `${s}`;
}
function objectAssign(n: number): string {
const o: Record<string, number> = {};
for (let i = 0; i < n; i++) Object.assign(o, { ["a" + (i % 100)]: i });
return `${Object.keys(o).length}`;
}
function fromEntries(n: number): string {
const e: [string, number][] = [];
for (let i = 0; i < n; i++) e.push(["e" + i, i]);
const o = Object.fromEntries(e);
return `${Object.keys(o).length}`;
}
function groupBy(n: number): string {
const groups = new Map<string, number[]>();
for (let i = 0; i < n; i++) {
const k = "g" + (i % 100);
let g = groups.get(k);
if (!g) {
g = [];
groups.set(k, g);
}
g.push(i);
}
let s = 0;
for (const [, g] of groups) s += g.length;
return `${s}`;
}
function classInstances(n: number): string {
class Pt {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
len(): number {
return Math.abs(this.x) + Math.abs(this.y);
}
}
const pts: Pt[] = [];
for (let i = 0; i < n; i++) pts.push(new Pt(i, -i));
let s = 0;
for (const p of pts) s += p.len();
return `${s}`;
}
function mapObjKeys(n: number): string {
const keys: { id: number }[] = [];
for (let i = 0; i < n; i++) keys.push({ id: i });
const m = new Map<{ id: number }, number>();
for (const k of keys) m.set(k, k.id);
let s = 0;
for (const k of keys) s += m.get(k)!;
return `${s}`;
}
const which = process.argv[2] ?? "";
const n = Number(process.argv[3] ?? "1000");
const t0 = performance.now();
let out = "";
switch (which) {
case "map-num": out = mapNum(n); break;
case "map-str": out = mapStr(n); break;
case "map-churn": out = mapChurn(n); break;
case "map-churn-first": out = mapChurnFirst(n); break;
case "lru-touch": out = lruTouch(n); break;
case "set-churn": out = setChurn(n); break;
case "set-iter-delete": out = setIterDelete(n); break;
case "set-from-array": out = setFromArray(n); break;
case "obj-dict": out = objDict(n); break;
case "obj-dict-delete": out = objDictDelete(n); break;
case "obj-num-keys": out = objNumKeys(n); break;
case "object-keys": out = objectKeys(n); break;
case "spread-accumulate": out = spreadAccumulate(n); break;
case "spread-small": out = spreadSmall(n); break;
case "object-assign": out = objectAssign(n); break;
case "from-entries": out = fromEntries(n); break;
case "group-by": out = groupBy(n); break;
case "class-instances": out = classInstances(n); break;
case "map-obj-keys": out = mapObjKeys(n); break;
default: out = "unknown case";
}
const t1 = performance.now();
console.log(which, n, out);
console.error("T=" + (t1 - t0).toFixed(3));
+284
View File
@@ -0,0 +1,284 @@
// Scaling probes: JSON, closures, exceptions, regex, generators, dates. Usage: misc <case> <n>
type Rec = { id: number; name: string; tags: string[]; score: number; active: boolean };
function records(n: number): Rec[] {
const out: Rec[] = [];
for (let i = 0; i < n; i++)
out.push({ id: i, name: "user" + i, tags: ["a" + (i % 5), "b"], score: i * 0.5, active: i % 3 === 0 });
return out;
}
type Node2 = { v: number; kids: Node2[] };
function tree(depth: number, fan: number): Node2 {
const kids: Node2[] = [];
if (depth > 0) for (let i = 0; i < fan; i++) kids.push(tree(depth - 1, fan));
return { v: depth, kids };
}
function jsonWide(n: number): string {
const s = JSON.stringify(records(n));
const back = JSON.parse(s) as Rec[];
return `${s.length} ${back.length} ${back[n - 1]!.name}`;
}
function jsonIndent(n: number): string {
const s = JSON.stringify(records(n), null, 2);
return `${s.length}`;
}
function jsonNested(n: number): string {
// ~n nodes in a fan-4 tree
let depth = 0;
let count = 1;
while (count * 4 < n) {
count *= 4;
depth++;
}
const t = tree(depth, 4);
const s = JSON.stringify(t);
const back = JSON.parse(s) as Node2;
return `${s.length} ${back.kids.length}`;
}
function jsonDeep(n: number): string {
// A deep chain (linked list) of n/100 levels... capped to stay under Node's recursion limit.
const depth = Math.min(n, 3000);
let s = "0";
for (let i = 0; i < depth; i++) s = "[" + s + "]";
let total = 0;
for (let k = 0; k < Math.max(1, Math.floor(n / depth)); k++) {
const v = JSON.parse(s) as unknown;
total += JSON.stringify(v).length;
}
return `${total}`;
}
function jsonManySmall(n: number): string {
let t = 0;
for (let i = 0; i < n; i++) {
const s = JSON.stringify({ i, s: "x" + i, ok: true });
const o = JSON.parse(s) as { i: number; s: string; ok: boolean };
t += o.i + s.length;
}
return `${t}`;
}
function jsonBigString(n: number): string {
const parts: string[] = [];
for (let i = 0; i < n; i++) parts.push("line \"" + i + "\"\n\t");
const big = parts.join("");
const s = JSON.stringify({ big });
const back = JSON.parse(s) as { big: string };
return `${s.length} ${back.big.length}`;
}
function closures(n: number): string {
const fns: (() => number)[] = [];
for (let i = 0; i < n; i++) fns.push(() => i * 2);
let s = 0;
for (const f of fns) s += f();
return `${s}`;
}
function closureCounter(n: number): string {
let s = 0;
for (let i = 0; i < n; i++) {
let c = i;
const inc = (): number => ++c;
inc();
s += inc();
}
return `${s}`;
}
function tryNoThrow(n: number): string {
let s = 0;
for (let i = 0; i < n; i++) {
try {
s += i;
} catch (e) {
s -= 1;
}
}
return `${s}`;
}
function tryThrow(n: number): string {
let s = 0;
for (let i = 0; i < n; i++) {
try {
if (i >= 0) throw new Error("bad " + i);
} catch (e) {
s += (e as Error).message.length;
}
}
return `${s}`;
}
function throwDeep(n: number): string {
function rec(d: number): number {
if (d === 0) throw new RangeError("deep");
return rec(d - 1) + 1;
}
let s = 0;
for (let i = 0; i < n; i++) {
try {
s += rec(20);
} catch (e) {
s += 1;
}
}
return `${s}`;
}
function finallyLoop(n: number): string {
let s = 0;
for (let i = 0; i < n; i++) {
try {
s += i & 3;
} finally {
s += 1;
}
}
return `${s}`;
}
function regexNew(n: number): string {
let c = 0;
for (let i = 0; i < n; i++) {
const re = new RegExp("^item-(\\d+)$");
if (re.test("item-" + i)) c++;
}
return `${c}`;
}
function regexLiteral(n: number): string {
let c = 0;
for (let i = 0; i < n; i++) if (/^item-(\d+)$/.test("item-" + i)) c++;
return `${c}`;
}
function regexExecG(n: number): string {
const parts: string[] = [];
for (let i = 0; i < n; i++) parts.push("k" + i + "=v" + i);
const s = parts.join("&");
const re = /(\w+)=(\w+)/g;
let m: RegExpExecArray | null;
let c = 0;
while ((m = re.exec(s)) !== null) c += m[2]!.length;
return `${c}`;
}
function matchAll(n: number): string {
const parts: string[] = [];
for (let i = 0; i < n; i++) parts.push("k" + i + "=v" + i);
const s = parts.join("&");
let c = 0;
for (const m of s.matchAll(/(\w+)=(\w+)/g)) c += m[1]!.length;
return `${c}`;
}
function regexSplit(n: number): string {
const parts: string[] = [];
for (let i = 0; i < n; i++) parts.push("a" + i);
const s = parts.join(" , ");
return `${s.split(/\s*,\s*/).length}`;
}
function generator(n: number): string {
function* gen(k: number): Generator<number> {
for (let i = 0; i < k; i++) yield i;
}
let s = 0;
for (const v of gen(n)) s += v;
return `${s}`;
}
function manyGenerators(n: number): string {
function* pair(a: number): Generator<number> {
yield a;
yield a + 1;
}
let s = 0;
for (let i = 0; i < n; i++) for (const v of pair(i)) s += v;
return `${s}`;
}
function destructure(n: number): string {
let s = 0;
for (let i = 0; i < n; i++) {
const [a, b] = [i, i + 1];
const { x, y } = { x: a, y: b };
s += x + y;
}
return `${s}`;
}
function dateNew(n: number): string {
let ok = 0;
for (let i = 0; i < n; i++) if (new Date().getTime() > 0) ok++;
return `${ok}`;
}
function dateNow(n: number): string {
let ok = 0;
for (let i = 0; i < n; i++) if (Date.now() > 0) ok++;
return `${ok}`;
}
function perfNow(n: number): string {
let ok = 0;
for (let i = 0; i < n; i++) if (performance.now() >= 0) ok++;
return `${ok}`;
}
function dateFormat(n: number): string {
let t = 0;
for (let i = 0; i < n; i++) {
const d = new Date(1700000000000 + i * 3600000);
t += d.toISOString().length + d.getUTCHours();
}
return `${t}`;
}
function dateParse(n: number): string {
let t = 0;
for (let i = 0; i < n; i++) t += Date.parse("2024-01-" + String((i % 28) + 1).padStart(2, "0") + "T10:00:00Z") / 1e9;
return `${Math.round(t)}`;
}
function dateLocal(n: number): string {
let t = 0;
for (let i = 0; i < n; i++) {
const d = new Date(1700000000000 + i * 3600000);
t += d.getHours() + d.getDate() + d.getTimezoneOffset();
}
return `${t}`;
}
function mathRandom(n: number): string {
let s = 0;
for (let i = 0; i < n; i++) s += Math.random();
return `${s > 0}`;
}
function mathOps(n: number): string {
let s = 0;
for (let i = 1; i < n; i++) s += Math.sqrt(i) + Math.log(i) + Math.sin(i) + Math.floor(i / 3) + (i % 7);
return `${s.toFixed(3)}`;
}
function errorStack(n: number): string {
let t = 0;
for (let i = 0; i < n; i++) t += new Error("e").message.length;
return `${t}`;
}
const which = process.argv[2] ?? "";
const n = Number(process.argv[3] ?? "1000");
const t0 = performance.now();
let out = "";
switch (which) {
case "json-wide": out = jsonWide(n); break;
case "json-indent": out = jsonIndent(n); break;
case "json-nested": out = jsonNested(n); break;
case "json-deep": out = jsonDeep(n); break;
case "json-many-small": out = jsonManySmall(n); break;
case "json-big-string": out = jsonBigString(n); break;
case "closures": out = closures(n); break;
case "closure-counter": out = closureCounter(n); break;
case "try-no-throw": out = tryNoThrow(n); break;
case "try-throw": out = tryThrow(n); break;
case "throw-deep": out = throwDeep(n); break;
case "finally": out = finallyLoop(n); break;
case "regex-new": out = regexNew(n); break;
case "regex-literal": out = regexLiteral(n); break;
case "regex-exec-g": out = regexExecG(n); break;
case "match-all": out = matchAll(n); break;
case "regex-split": out = regexSplit(n); break;
case "generator": out = generator(n); break;
case "many-generators": out = manyGenerators(n); break;
case "destructure": out = destructure(n); break;
case "date-new": out = dateNew(n); break;
case "date-now": out = dateNow(n); break;
case "perf-now": out = perfNow(n); break;
case "date-format": out = dateFormat(n); break;
case "date-parse": out = dateParse(n); break;
case "date-local": out = dateLocal(n); break;
case "math-random": out = mathRandom(n); break;
case "math-ops": out = mathOps(n); break;
case "error-new": out = errorStack(n); break;
default: out = "unknown case";
}
const t1 = performance.now();
console.log(which, n, out);
console.error("T=" + (t1 - t0).toFixed(3));
+248
View File
@@ -0,0 +1,248 @@
#!/usr/bin/env node
// Scaling probe runner: builds each probe file with scriptc (release), runs every
// case at increasing sizes under the compiled binary and under Node, checks that
// stdout matches, and reports in-process time (stderr `T=<ms>`), the growth
// exponent between the two largest sizes, and the scriptc/Node ratio.
//
// node benchmarks/scaling/run.mjs [--scriptc=<checkout>] [--only=file:case,...]
// [--reps=3] [--timeout=20] [--json=out.json] [--strace]
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
const here = dirname(fileURLToPath(import.meta.url));
const { values } = parseArgs({
options: {
scriptc: { type: "string", default: resolve(here, "../..") },
only: { type: "string" },
reps: { type: "string", default: "3" },
timeout: { type: "string", default: "20" },
json: { type: "string" },
strace: { type: "boolean", default: false },
"strace-only": { type: "boolean", default: false },
"no-node": { type: "boolean", default: false },
tag: { type: "string", default: "" },
},
});
const S = [1e3, 1e4, 1e5, 1e6];
const SM = [1e3, 3e3, 1e4, 3e4];
const SQ = [250, 500, 1000, 2000];
// [file, case, sizes]
const CASES = [
...[
"push", "pop", "shift", "queue", "splice-tail", "slice-small", "concat", "indexOf",
"includes-str", "sort-num", "sort-sorted", "sort-str", "sort-obj", "reverse", "in-arr",
"in-arr-str", "holes", "sparse", "spread", "map-filter-reduce", "array-from", "join",
"flat", "lastIndexOf", "findIndex", "nested2d", "length-truncate",
].map((c) => ["arrays", c, S]),
["arrays", "unshift", SM],
["arrays", "splice-mid", [1e4, 1e5, 1e6]],
...[
"concat-loop", "concat-num", "concat-read", "concat-then-scan", "template", "split-join",
"split-chars", "replace-one", "replace-all", "replace-regex", "replace-fn", "slice-loop",
"substring-loop", "indexOf-all", "indexOf-miss", "includes", "padStart", "repeat",
"upper-lower", "charAt-loop", "index-loop", "for-of-chars", "nonascii-concat",
"nonascii-index", "nonascii-slice", "str-compare", "trim", "lines-split", "num-to-string",
"parse-nums", "starts-with", "locale-compare",
].map((c) => ["strings", c, S]),
["strings", "concat-prepend", SM],
...[
"map-num", "map-str", "map-churn", "map-churn-first", "lru-touch", "set-churn",
"set-iter-delete", "set-from-array", "obj-dict", "obj-dict-delete", "obj-num-keys",
"object-keys", "spread-small", "object-assign", "from-entries", "group-by",
"class-instances", "map-obj-keys",
].map((c) => ["collections", c, S]),
["collections", "spread-accumulate", SQ],
...[
"json-wide", "json-indent", "json-nested", "json-many-small", "json-big-string", "closures",
"closure-counter", "try-no-throw", "try-throw", "throw-deep", "finally", "regex-new",
"regex-literal", "regex-exec-g", "match-all", "regex-split", "generator", "many-generators",
"destructure", "date-new", "date-now", "perf-now", "date-format", "date-parse", "date-local",
"math-random", "math-ops", "error-new",
].map((c) => ["misc", c, S]),
["misc", "json-deep", [3e3, 3e4, 3e5]],
...[
"await-chain", "async-calls", "promise-all", "then-chain", "microtasks", "next-ticks",
"console-log", "console-log-multi", "stdout-write", "console-error", "buffer-ops",
"fs-big-read", "timers-cancel",
].map((c) => ["async-io", c, S]),
["async-io", "timers-seq", [100, 1000, 10000]],
["async-io", "timers-many", S],
["async-io", "immediates", [1e3, 1e4, 1e5]],
["async-io", "fs-small", [100, 1000, 10000]],
["async-io", "fs-append", [1000, 10000, 100000]],
];
const only = values.only ? new Set(values.only.split(",")) : undefined;
const selected = CASES.filter(
([f, c]) => !only || only.has(`${f}:${c}`) || only.has(f) || only.has(c),
);
const reps = Number(values.reps);
const timeoutMs = Number(values.timeout) * 1000;
const work = join(tmpdir(), `scaling-probes${values.tag ? "-" + values.tag : ""}`);
mkdirSync(work, { recursive: true });
const cli = join(values.scriptc, "packages/cli/dist/bootstrap.js");
const binaries = new Map();
for (const file of new Set(selected.map(([f]) => f))) {
const bin = join(work, file);
const t = Date.now();
const r = spawnSync(
process.execPath,
[cli, "build", join(here, `${file}.ts`), "--optimization=release", "-o", bin],
{ encoding: "utf8" },
);
if (r.status !== 0) {
console.error(`build ${file} failed:\n${r.stdout}\n${r.stderr}`);
continue;
}
console.error(`built ${file} in ${Date.now() - t} ms`);
binaries.set(file, bin);
}
function runOnce(cmd, args) {
const t = process.hrtime.bigint();
const r = spawnSync(cmd, args, {
encoding: "utf8",
timeout: timeoutMs,
maxBuffer: 1 << 30,
env: { ...process.env, NODE_NO_WARNINGS: "1" },
});
const wall = Number(process.hrtime.bigint() - t) / 1e6;
if (r.error || r.status !== 0) {
return { error: r.error ? String(r.error.code ?? r.error) : `exit ${r.status}: ${r.stderr.slice(-300)}` };
}
const m = /T=([0-9.]+)\s*$/.exec(r.stderr);
return {
ms: m ? Number(m[1]) : NaN,
wall,
out: createHash("sha1").update(r.stdout).digest("hex"),
tail: r.stdout.slice(-120),
};
}
function measure(cmd, args) {
let best;
for (let i = 0; i < reps; i++) {
const r = runOnce(cmd, args);
if (r.error) return r;
if (!best || r.ms < best.ms) best = r;
if (r.ms > 3000) break;
}
return best;
}
function straceCount(bin, args) {
const out = join(work, "strace.txt");
const r = spawnSync("strace", ["-f", "-c", "-o", out, bin, ...args], {
encoding: "utf8",
timeout: timeoutMs * 3,
maxBuffer: 1 << 30,
});
if (r.status !== 0 || !existsSync(out)) return undefined;
const calls = {};
let total = 0;
for (const line of readFileSync(out, "utf8").split("\n")) {
const m = /^\s*[0-9.]+\s+[0-9.]+\s+\d+\s+(\d+)\s+(?:\d+\s+)?(\w+)\s*$/.exec(line);
if (m && m[2] !== "total") {
calls[m[2]] = Number(m[1]);
total += Number(m[1]);
}
}
return { total, calls };
}
if (values["strace-only"]) {
// Syscalls per operation: diff the strace -c counts of the two smallest
// sizes so process startup cancels out.
const out = [];
for (const [file, name, sizes] of selected) {
const bin = binaries.get(file);
if (!bin) continue;
const [n0, n1] = sizes;
const a = straceCount(bin, [name, String(n0)]);
const b = straceCount(bin, [name, String(n1)]);
if (!a || !b) {
console.log(`${file}:${name} strace failed`);
continue;
}
const per = {};
for (const [k, v] of Object.entries(b.calls)) {
const d = (v - (a.calls[k] ?? 0)) / (n1 - n0);
if (d > 0.001) per[k] = d;
}
const top = Object.entries(per)
.sort((x, y) => y[1] - x[1])
.slice(0, 6)
.map(([k, v]) => `${k}=${v < 0.1 ? v.toFixed(4) : v.toFixed(2)}`)
.join(" ");
const perOp = (b.total - a.total) / (n1 - n0);
console.log(`${file}:${name} n=${n0}->${n1} startup=${a.total} total=${b.total} per-op=${perOp.toFixed(4)} ${top}`);
out.push({ file, case: name, n0, n1, a, b, per, perOp });
if (values.json) writeFileSync(values.json, JSON.stringify(out, null, 1));
}
process.exit(0);
}
const results = [];
for (const [file, name, sizes] of selected) {
const bin = binaries.get(file);
if (!bin) continue;
const rows = [];
let scDead = false;
let nodeDead = values["no-node"];
for (const n of sizes) {
const args = [name, String(n)];
const sc = scDead ? { error: "skipped" } : measure(bin, args);
const nd = nodeDead
? { error: "skipped" }
: measure(process.execPath, ["--disable-warning=MODULE_TYPELESS_PACKAGE_JSON", join(here, `${file}.ts`), ...args]);
if (sc.error) scDead = true;
if (nd.error) nodeDead = true;
const row = { n, sc_ms: sc.ms, sc_wall: sc.wall, node_ms: nd.ms, node_wall: nd.wall };
if (sc.error) row.sc_error = sc.error;
if (nd.error) row.node_error = nd.error;
if (sc.out && nd.out && sc.out !== nd.out) row.mismatch = { sc: sc.tail, node: nd.tail };
rows.push(row);
if (scDead && nodeDead) break;
}
const ok = rows.filter((r) => Number.isFinite(r.sc_ms));
const exp = (key) => {
const v = rows.filter((r) => Number.isFinite(r[key]));
if (v.length < 2) return undefined;
const a = v[v.length - 2];
const b = v[v.length - 1];
return Math.log(Math.max(b[key], 0.05) / Math.max(a[key], 0.05)) / Math.log(b.n / a.n);
};
const last = ok[ok.length - 1];
const entry = {
file,
case: name,
rows,
sc_exp: exp("sc_ms"),
node_exp: exp("node_ms"),
ratio: last && Number.isFinite(last.node_ms) ? last.sc_ms / Math.max(last.node_ms, 0.01) : undefined,
};
if (values.strace && last) entry.strace = { n: last.n, ...straceCount(bin, [name, String(last.n)]) };
const flags = [];
if (rows.some((r) => r.sc_error && !r.node_error)) flags.push("SC-FAIL");
if (rows.some((r) => r.mismatch)) flags.push("MISMATCH");
if (entry.sc_exp !== undefined && entry.sc_exp > 1.3 && last.sc_ms > 5 && entry.sc_exp > (entry.node_exp ?? 1) + 0.25)
flags.push("SUPERLINEAR");
if (entry.ratio !== undefined && entry.ratio > 3 && last.sc_ms > 2) flags.push(">3x");
entry.flags = flags;
results.push(entry);
const fmt = (v) => (v === undefined || !Number.isFinite(v) ? "-" : v < 10 ? v.toFixed(2) : v.toFixed(0));
const cells = rows
.map((r) => `${r.n}:${r.sc_error ? "ERR" : fmt(r.sc_ms)}/${r.node_error ? "ERR" : fmt(r.node_ms)}`)
.join(" ");
console.log(
`${file}:${name} ${cells} exp=${fmt(entry.sc_exp)}/${fmt(entry.node_exp)} ratio=${fmt(entry.ratio)} ${flags.join(",")}` +
(entry.strace ? ` sys=${entry.strace.total}` : ""),
);
if (values.json) writeFileSync(values.json, JSON.stringify(results, null, 1));
}
if (values.json) writeFileSync(values.json, JSON.stringify(results, null, 1));
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env node
// strace -c every runtime benchmark workload built with a scriptc checkout and
// print its syscall totals (top calls first).
// node benchmarks/scaling/strace-workloads.mjs --scriptc=<checkout> [--out=dir]
import { spawnSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
const here = dirname(fileURLToPath(import.meta.url));
const suite = resolve(here, "../runtime");
const { values } = parseArgs({
options: {
scriptc: { type: "string", default: resolve(here, "../..") },
out: { type: "string", default: "/tmp/strace-workloads" },
json: { type: "string" },
},
});
mkdirSync(values.out, { recursive: true });
const { workloads } = JSON.parse(readFileSync(join(suite, "workloads.json"), "utf8"));
const cli = join(values.scriptc, "packages/cli/dist/bootstrap.js");
// Same inputs as scripts/bench-runtime.mjs generates.
function input(kind) {
const path = join(values.out, `${kind}.input`);
const lines =
kind === "log-summary"
? Array.from(
{ length: 300_000 },
(_, i) =>
`/route/${i % 37}?request=${i} ${i % 13 === 0 ? 500 : 200} ${i % 250} ${200 + (i % 4000)}`,
)
: Array.from({ length: 300_000 }, (_, i) =>
["sku-" + i, "category-" + (i % 17), "product " + i, i % 40, 199 + (i % 5000)].join("\t"),
);
writeFileSync(path, lines.join("\n") + "\n");
return path;
}
const results = [];
for (const w of workloads) {
const bin = join(values.out, w.name);
const build = spawnSync(
process.execPath,
[cli, "build", join(suite, w.entry), "--optimization=release", "-o", bin],
{ encoding: "utf8", cwd: dirname(join(suite, w.entry)) },
);
if (build.status !== 0) {
console.log(`${w.name}: build failed ${build.stderr.slice(0, 300)}`);
continue;
}
const trace = join(values.out, `${w.name}.strace`);
const args = (w.args ?? []).map((a) => (a === "{input}" ? input(w.input) : a));
const r = spawnSync("strace", ["-f", "-c", "-o", trace, bin, ...args], {
encoding: "utf8",
maxBuffer: 1 << 30,
cwd: dirname(join(suite, w.entry)),
});
const calls = {};
let total = 0;
for (const line of readFileSync(trace, "utf8").split("\n")) {
const m = /^\s*[0-9.]+\s+[0-9.]+\s+\d+\s+(\d+)\s+(?:\d+\s+)?(\w+)\s*$/.exec(line);
if (m && m[2] !== "total") {
calls[m[2]] = Number(m[1]);
total += Number(m[1]);
}
}
const top = Object.entries(calls)
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([k, v]) => `${k}=${v}`)
.join(" ");
console.log(`${w.name} status=${r.status} total=${total} ${top}`);
results.push({ name: w.name, status: r.status, total, calls });
}
if (values.json) writeFileSync(values.json, JSON.stringify(results, null, 1));
+251
View File
@@ -0,0 +1,251 @@
// Scaling probes: string operations. Usage: strings <case> <n>
let seed = 12345;
function rnd(): number {
seed = (Math.imul(seed, 1103515245) + 12345) & 0x7fffffff;
return seed / 0x7fffffff;
}
function words(n: number): string {
const parts: string[] = [];
for (let i = 0; i < n; i++) parts.push("w" + (i % 977));
return parts.join(" ");
}
function concatLoop(n: number): string {
let s = "";
for (let i = 0; i < n; i++) s += "x";
return `${s.length}`;
}
function concatNum(n: number): string {
let s = "";
for (let i = 0; i < n; i++) s += i + ",";
return `${s.length}`;
}
function concatRead(n: number): string {
// Append and read the tail every iteration (rope flatten pathology).
let s = "";
let c = 0;
for (let i = 0; i < n; i++) {
s += String.fromCharCode(97 + (i % 26));
c += s.charCodeAt(s.length - 1);
}
return `${s.length} ${c}`;
}
function concatThenScan(n: number): string {
let s = "";
for (let i = 0; i < n; i++) s += String.fromCharCode(97 + (i % 26));
let c = 0;
for (let i = 0; i < s.length; i++) c += s.charCodeAt(i);
return `${c}`;
}
function concatPrepend(n: number): string {
let s = "";
for (let i = 0; i < n; i++) s = "y" + s;
return `${s.length}`;
}
function template(n: number): string {
let total = 0;
for (let i = 0; i < n; i++) total += `item-${i}: ${i * 2} (${i % 7})`.length;
return `${total}`;
}
function splitJoin(n: number): string {
const s = words(n);
const p = s.split(" ");
return `${p.length} ${p.join("-").length}`;
}
function splitChars(n: number): string {
const s = "abcdefghij".repeat(n / 10);
return `${s.split("").length}`;
}
function replaceOne(n: number): string {
const s = words(n);
let t = 0;
for (let k = 0; k < 10; k++) t += s.replace("w5", "Q").length;
return `${t}`;
}
function replaceAll(n: number): string {
const s = words(n);
return `${s.replaceAll(" ", "_").length} ${s.replaceAll("w1", "ZZZ").length}`;
}
function replaceRegex(n: number): string {
const s = words(n);
return `${s.replace(/w(\d)/g, "v$1").length}`;
}
function replaceFn(n: number): string {
const s = words(n);
return `${s.replace(/\d+/g, (m) => String(Number(m) + 1)).length}`;
}
function sliceLoop(n: number): string {
const s = words(n);
let t = 0;
for (let i = 0; i + 5 < s.length && i < n * 4; i++) t += s.slice(i, i + 5).length;
return `${t}`;
}
function substringLoop(n: number): string {
const s = words(n);
let t = 0;
for (let i = 0; i + 5 < s.length && i < n * 4; i++) if (s.substring(i, i + 2) === "w1") t++;
return `${t}`;
}
function indexOfAll(n: number): string {
const s = words(n);
let c = 0;
let pos = s.indexOf("w12");
while (pos !== -1) {
c++;
pos = s.indexOf("w12", pos + 1);
}
return `${c}`;
}
function indexOfMiss(n: number): string {
const s = words(n);
let c = 0;
for (let k = 0; k < 20; k++) c += s.indexOf("zz" + k);
return `${c}`;
}
function includesLoop(n: number): string {
const s = words(n);
let c = 0;
for (let k = 0; k < 20; k++) if (s.includes("w" + (970 + k))) c++;
return `${c}`;
}
function padStart(n: number): string {
let t = 0;
for (let i = 0; i < n; i++) t += String(i).padStart(10, "0").length;
return `${t}`;
}
function repeat(n: number): string {
let t = 0;
for (let k = 0; k < 10; k++) t += "ab".repeat(n).length;
return `${t}`;
}
function upperLower(n: number): string {
const s = words(n);
return `${s.toUpperCase().length} ${s.toLowerCase().length}`;
}
function charAtLoop(n: number): string {
const s = words(n);
let c = 0;
for (let i = 0; i < s.length; i++) if (s.charAt(i) === "w") c++;
return `${c}`;
}
function indexLoop(n: number): string {
const s = words(n);
let c = 0;
for (let i = 0; i < s.length; i++) if (s[i] === " ") c++;
return `${c}`;
}
function forOfChars(n: number): string {
const s = words(n);
let c = 0;
for (const ch of s) if (ch === "1") c++;
return `${c}`;
}
function nonAsciiConcat(n: number): string {
let s = "";
for (let i = 0; i < n; i++) s += i % 10 === 0 ? "é" : "a";
let c = 0;
for (let i = 0; i < s.length; i += 7) c += s.charCodeAt(i);
return `${s.length} ${c}`;
}
function nonAsciiIndex(n: number): string {
const parts: string[] = [];
for (let i = 0; i < n; i++) parts.push(i % 10 === 0 ? "ü" : "w");
const s = parts.join("");
let c = 0;
for (let i = 0; i < s.length; i++) c += s.charCodeAt(i);
return `${c}`;
}
function nonAsciiSlice(n: number): string {
const parts: string[] = [];
for (let i = 0; i < n; i++) parts.push(i % 10 === 0 ? "ü" : "w");
const s = parts.join("");
let c = 0;
for (let i = 0; i + 3 < s.length; i++) c += s.slice(i, i + 3).length;
return `${c}`;
}
function strCompare(n: number): string {
const a: string[] = [];
for (let i = 0; i < n; i++) a.push("key" + Math.floor(rnd() * 1e6));
let c = 0;
for (let i = 1; i < n; i++) if (a[i]! < a[i - 1]!) c++;
return `${c}`;
}
function trimLines(n: number): string {
let t = 0;
for (let i = 0; i < n; i++) t += (" line " + i + " ").trim().length;
return `${t}`;
}
function linesSplit(n: number): string {
const lines: string[] = [];
for (let i = 0; i < n; i++) lines.push(`${i},name${i},${i * 3}`);
const text = lines.join("\n");
let s = 0;
for (const line of text.split("\n")) s += Number(line.split(",")[2]);
return `${s}`;
}
function numToString(n: number): string {
let t = 0;
for (let i = 0; i < n; i++) t += String(i * 1.5).length + (i * 0.1).toFixed(2).length;
return `${t}`;
}
function parseNums(n: number): string {
let s = 0;
for (let i = 0; i < n; i++) s += parseInt("" + i, 10) + parseFloat(i + ".5");
return `${s}`;
}
function startsWith(n: number): string {
const s = words(n);
const p = s.split(" ");
let c = 0;
for (const w of p) if (w.startsWith("w9") || w.endsWith("7")) c++;
return `${c}`;
}
function localeCompare(n: number): string {
const a: string[] = [];
for (let i = 0; i < n; i++) a.push("k" + Math.floor(rnd() * 1e6));
a.sort((x, y) => x.localeCompare(y));
return `${a[0]} ${a[n - 1]}`;
}
const which = process.argv[2] ?? "";
const n = Number(process.argv[3] ?? "1000");
const t0 = performance.now();
let out = "";
switch (which) {
case "concat-loop": out = concatLoop(n); break;
case "concat-num": out = concatNum(n); break;
case "concat-read": out = concatRead(n); break;
case "concat-then-scan": out = concatThenScan(n); break;
case "concat-prepend": out = concatPrepend(n); break;
case "template": out = template(n); break;
case "split-join": out = splitJoin(n); break;
case "split-chars": out = splitChars(n); break;
case "replace-one": out = replaceOne(n); break;
case "replace-all": out = replaceAll(n); break;
case "replace-regex": out = replaceRegex(n); break;
case "replace-fn": out = replaceFn(n); break;
case "slice-loop": out = sliceLoop(n); break;
case "substring-loop": out = substringLoop(n); break;
case "indexOf-all": out = indexOfAll(n); break;
case "indexOf-miss": out = indexOfMiss(n); break;
case "includes": out = includesLoop(n); break;
case "padStart": out = padStart(n); break;
case "repeat": out = repeat(n); break;
case "upper-lower": out = upperLower(n); break;
case "charAt-loop": out = charAtLoop(n); break;
case "index-loop": out = indexLoop(n); break;
case "for-of-chars": out = forOfChars(n); break;
case "nonascii-concat": out = nonAsciiConcat(n); break;
case "nonascii-index": out = nonAsciiIndex(n); break;
case "nonascii-slice": out = nonAsciiSlice(n); break;
case "str-compare": out = strCompare(n); break;
case "trim": out = trimLines(n); break;
case "lines-split": out = linesSplit(n); break;
case "num-to-string": out = numToString(n); break;
case "parse-nums": out = parseNums(n); break;
case "starts-with": out = startsWith(n); break;
case "locale-compare": out = localeCompare(n); break;
default: out = "unknown case";
}
const t1 = performance.now();
console.log(which, n, out);
console.error("T=" + (t1 - t0).toFixed(3));
+2 -2
View File
@@ -99,8 +99,8 @@ Verifies and reads the installed precompiled runtime objects and vendor archives
<dt><code>--strip</code></dt>
<dd>Remove symbol and debug payload when linking an executable, including cross-compiled targets. This flag preserves code optimization.</dd>
<dt><code>--optimization &lt;release|dev&gt;</code></dt>
<dd>Select native optimization for executable, assembly, or object output. The default is <code>release</code> (<code>-O2</code>). <code>dev</code> uses <code>-O0</code> and emits source locations for TypeScript and JavaScript breakpoints and stack frames. LLVM builds include source variable names and native storage descriptions; see <a href="/limitations#native-debugging">Native debugging</a> for the supported representations. macOS executable builds also produce an adjacent <code>.dSYM</code> bundle.</dd>
<dt><code>--optimization &lt;release|dev|speed&gt;</code></dt>
<dd>Select native optimization for executable, assembly, or object output. The default is <code>release</code> (<code>-O2</code>). <code>dev</code> uses <code>-O0</code> and emits source locations for TypeScript and JavaScript breakpoints and stack frames. LLVM builds include source variable names and native storage descriptions; see <a href="/limitations#native-debugging">Native debugging</a> for the supported representations. macOS executable builds also produce an adjacent <code>.dSYM</code> bundle. <code>speed</code> is <code>-O2</code> plus optimizations that trade binary size and build time for run-time speed: it lets LLVM inline small runtime functions into program code, emits reference counting inline, and on x86-64 starts every runtime function on a cache-line boundary. Expect larger executables and longer cold builds than <code>release</code>; how much faster a program runs depends on how much of its time is spent in the runtime. Runtime inlining needs a runtime pack built with the LLVM helper; where a runtime pack lacks it, <code>speed</code> links the release runtime and keeps inline reference counting. Selecting <code>speed</code> never changes what <code>release</code> builds produce, and the two never share cached artifacts. Library profiles accept <code>"optimization": "speed"</code> for inline reference counting.</dd>
<dt><code>--windows-subsystem &lt;console|gui&gt;</code></dt>
<dd>Select the subsystem of a Windows executable. <code>console</code> is the default; <code>gui</code> prevents Windows from opening a console window for the app. Valid only for executable builds targeting Windows.</dd>
+7 -4
View File
@@ -78,6 +78,8 @@ add_executable(scriptc-llvm-codegen
src/diagnostics.cpp
src/emit.cpp
src/main.cpp
src/runtime-import.cpp
src/runtime-unit.cpp
src/target.cpp
)
@@ -100,13 +102,13 @@ endif()
foreach(SCRIPTC_TARGET_BACKEND IN LISTS SCRIPTC_TARGET_BACKENDS)
if(SCRIPTC_TARGET_BACKEND STREQUAL "AArch64")
list(APPEND SCRIPTC_LLVM_TARGET_LIBS AArch64CodeGen)
list(APPEND SCRIPTC_LLVM_TARGET_LIBS AArch64CodeGen AArch64AsmParser)
target_compile_definitions(scriptc-llvm-codegen PRIVATE SCRIPTC_ENABLE_AARCH64=1)
elseif(SCRIPTC_TARGET_BACKEND STREQUAL "X86")
list(APPEND SCRIPTC_LLVM_TARGET_LIBS X86CodeGen)
list(APPEND SCRIPTC_LLVM_TARGET_LIBS X86CodeGen X86AsmParser)
target_compile_definitions(scriptc-llvm-codegen PRIVATE SCRIPTC_ENABLE_X86=1)
elseif(SCRIPTC_TARGET_BACKEND STREQUAL "WebAssembly")
list(APPEND SCRIPTC_LLVM_TARGET_LIBS WebAssemblyCodeGen)
list(APPEND SCRIPTC_LLVM_TARGET_LIBS WebAssemblyCodeGen WebAssemblyAsmParser)
target_compile_definitions(scriptc-llvm-codegen PRIVATE SCRIPTC_ENABLE_WEBASSEMBLY=1)
else()
message(FATAL_ERROR "unsupported SCRIPTC_TARGET_BACKENDS entry: ${SCRIPTC_TARGET_BACKEND}")
@@ -176,7 +178,7 @@ if(SCRIPTC_STATIC_LINUX_BUILD)
endif()
endfunction()
set(SCRIPTC_LLVM_LINK_ROOTS ${SCRIPTC_LLVM_TARGET_LIBS} BitReader BitWriter IRReader Passes TransformUtils)
set(SCRIPTC_LLVM_LINK_ROOTS ${SCRIPTC_LLVM_TARGET_LIBS} BitReader BitWriter IRReader Linker Passes TransformUtils)
foreach(component IN LISTS SCRIPTC_LLVM_LINK_ROOTS)
if(TARGET "LLVM${component}")
scriptc_collect_llvm_target("LLVM${component}")
@@ -202,6 +204,7 @@ else()
Core
Coroutines
IRReader
Linker
Passes
Support
Target
+12
View File
@@ -32,3 +32,15 @@ Emission uses LLVM 22's default per-module O2 pipeline, including coroutine
lowering, verifies before and after optimization, and publishes through a
private sibling file so a failed or interrupted request cannot truncate the
requested output.
`--optimization=speed` program builds pass `--import-bitcode <unit.bc>` once per runtime-pack unit the program links. Small runtime functions reachable from the program's runtime calls are imported as `available_externally` so LLVM can inline them; the runtime objects remain the only definitions. The helper never runs a sanitizer pass, so with import it also drops the emitter's inert `sanitize_address` attributes, which would otherwise block that inlining. Without `--import-bitcode` the module is optimized exactly as emitted.
Runtime-pack builds emit each unit of the executable `speed` flavor through the helper:
```console
scriptc-llvm-codegen runtime-unit --input unit.opt.bc --object unit.o \
--bitcode unit.bc --target x86_64-unknown-linux-gnu --tag scr_array \
[--function-sections --data-sections]
```
The input is the runtime compiler's optimized bitcode. The command promotes unit-local symbols to hidden `<name>.scrunit.<tag>` globals and writes the object and the import bitcode from that one module, so imported bodies can reference the unit's private state and helpers.
+29
View File
@@ -1,6 +1,7 @@
#include "emit.h"
#include "diagnostics.h"
#include "runtime-import.h"
#include "target.h"
#include "llvm/ADT/SmallString.h"
@@ -56,6 +57,8 @@ std::optional<EmitOptions> parseEmitOptions(int Argc, char **Argv) {
Options.DiagnosticFormat = Value.str();
else if (Arg == "--source-path")
Options.SourcePath = Value.str();
else if (Arg == "--import-bitcode")
Options.ImportBitcode.push_back(Value.str());
else
return std::nullopt;
}
@@ -311,6 +314,32 @@ int emit(const EmitOptions &Options) {
return reportError("verification_failed", VerificationStream.str(),
Options.DiagnosticFormat);
// Runtime bitcode import is the `speed` posture's opt-in; without it the
// module reaches the pipeline exactly as emitted.
if (!Options.ImportBitcode.empty()) {
if (Options.OptLevel == "0")
return reportError("invalid_import",
"runtime bitcode import requires an optimized build",
Options.DiagnosticFormat);
// The emitter marks every function sanitize_address so the sanitized
// lane's clang link can instrument it. This helper never runs a
// sanitizer pass, so the attribute is inert here except that it
// suppresses speculative loads and blocks inlining of runtime bodies,
// whose sanitizer attributes must match. Dropping it does not change
// program semantics.
for (Function &F : *Mod)
F.removeFnAttr(Attribute::SanitizeAddress);
if (std::optional<std::string> Error =
importRuntimeBitcode(*Mod, Options.ImportBitcode))
return reportError("runtime_import_failed", *Error,
Options.DiagnosticFormat);
std::string ImportError;
raw_string_ostream ImportStream(ImportError);
if (verifyModule(*Mod, &ImportStream))
return reportError("runtime_import_verification_failed",
ImportStream.str(), Options.DiagnosticFormat);
}
OptimizationLevel Level = optimizationLevel(Options.OptLevel);
CodeGenFileType Type = Options.FileType == "obj"
? CodeGenFileType::ObjectFile
+2
View File
@@ -16,6 +16,8 @@ struct EmitOptions {
std::string RelocationModel = "pic";
std::string DiagnosticFormat = "json";
std::string SourcePath;
// Runtime bitcode whose small functions are imported available_externally.
std::vector<std::string> ImportBitcode;
};
std::optional<EmitOptions> parseEmitOptions(int Argc, char **Argv);
+4 -1
View File
@@ -1,5 +1,6 @@
#include "diagnostics.h"
#include "emit.h"
#include "runtime-unit.h"
#include "target.h"
#include "llvm/ADT/StringRef.h"
@@ -72,6 +73,8 @@ int main(int Argc, char **Argv) {
report_fatal_error("scriptc LLVM fatal diagnostic self-test");
if (Argc >= 2 && StringRef(Argv[1]) == "version")
return version(Argc, Argv);
if (Argc >= 2 && StringRef(Argv[1]) == "runtime-unit")
return scriptc::runtimeUnit(Argc, Argv);
if (Argc >= 2 && StringRef(Argv[1]) == "emit") {
std::optional<scriptc::EmitOptions> Options =
scriptc::parseEmitOptions(Argc, Argv);
@@ -85,5 +88,5 @@ int main(int Argc, char **Argv) {
return scriptc::emit(*Options);
}
return scriptc::reportError("usage",
"expected 'version --format=json' or 'emit'");
"expected 'version --format=json', 'emit', or 'runtime-unit'");
}
+395
View File
@@ -0,0 +1,395 @@
#include "runtime-import.h"
#include "llvm/ADT/DenseMap.h"
#include "llvm/ADT/DenseSet.h"
#include "llvm/ADT/SmallPtrSet.h"
#include "llvm/ADT/Twine.h"
#include "llvm/Bitcode/BitcodeReader.h"
#include "llvm/IR/Constants.h"
#include "llvm/IR/Function.h"
#include "llvm/IR/GlobalAlias.h"
#include "llvm/IR/GlobalVariable.h"
#include "llvm/IR/InstIterator.h"
#include "llvm/IR/Instructions.h"
#include "llvm/IR/Module.h"
#include "llvm/Linker/Linker.h"
#include "llvm/Support/Error.h"
#include "llvm/Support/MemoryBuffer.h"
#include "llvm/Support/raw_ostream.h"
#include <cstdlib>
#include <memory>
#include <vector>
using namespace llvm;
namespace scriptc {
namespace {
// Upper bound on the instruction count of an imported function. The
// inliner never takes bodies much larger than this at O2, and bounding the
// import keeps the extra optimization work proportional to what can pay off.
//
// Like ThinLTO's function import, the limit applies to functions the program
// references directly and decays with every call-graph hop below them. Deep
// slow paths (cycle collection, destruction queues, allocation growth) then
// stay out of line instead of being folded bottom-up into small entry points
// until those entry points are too large to inline at program call sites.
constexpr unsigned MaxInstructions = 100;
constexpr unsigned DecayPercent = 30;
struct References {
// Every global referenced by the body, including through constant
// expressions.
SmallPtrSet<const GlobalValue *, 16> Globals;
// Local functions referenced other than as the callee of a direct call.
// Copying such a function would give it a second address.
bool LocalFunctionAddressTaken = false;
// First symbol that prevents import, for diagnostics.
std::string Blocker;
};
void collectConstant(const Constant *C, SmallPtrSetImpl<const GlobalValue *> &Out,
SmallPtrSetImpl<const Constant *> &Seen) {
if (const auto *GV = dyn_cast<GlobalValue>(C)) {
Out.insert(GV);
return;
}
if (!Seen.insert(C).second)
return;
for (const Use &Operand : C->operands())
if (const auto *Inner = dyn_cast<Constant>(Operand.get()))
collectConstant(Inner, Out, Seen);
}
bool candidate(const Function &F, unsigned Limit) {
if (F.isDeclaration() || F.isVarArg() || F.hasFnAttribute(Attribute::NoInline) ||
F.hasFnAttribute(Attribute::OptimizeNone) || F.hasFnAttribute(Attribute::Naked) ||
F.hasPersonalityFn() || F.hasPrefixData() || F.hasPrologueData())
return false;
// Only strong external or local definitions. A weak or linkonce
// definition may be replaced at link time, so its body is not the one
// that will run.
if (!F.hasExternalLinkage() && !F.hasLocalLinkage())
return false;
// The program calls the runtime ABI marker so that linking against a
// mismatched runtime object fails; that reference must survive.
if (F.getName().starts_with("scr_runtime_abi_"))
return false;
if (F.hasLocalLinkage() && F.hasAddressTaken())
return false;
unsigned Count = 0;
for (const Instruction &I : instructions(F)) {
if (++Count > Limit)
return false;
if (const auto *Call = dyn_cast<CallBase>(&I)) {
if (Call->hasFnAttr(Attribute::ReturnsTwice))
return false;
if (const Function *Callee = Call->getCalledFunction())
if (Callee->hasFnAttribute(Attribute::ReturnsTwice))
return false;
}
for (const Use &Operand : I.operands())
if (isa<BlockAddress>(Operand.get()))
return false;
}
return true;
}
References referencesOf(const Function &F) {
References Refs;
SmallPtrSet<const Constant *, 32> Seen;
for (const Instruction &I : instructions(F)) {
const auto *Call = dyn_cast<CallBase>(&I);
for (const Use &Operand : I.operands()) {
const Value *V = Operand.get();
if (const auto *Local = dyn_cast<Function>(V)) {
Refs.Globals.insert(Local);
if (Local->hasLocalLinkage() && !(Call && Call->isCallee(&Operand))) {
Refs.LocalFunctionAddressTaken = true;
if (Refs.Blocker.empty())
Refs.Blocker = ("&" + Local->getName()).str();
}
continue;
}
if (const auto *C = dyn_cast<Constant>(V)) {
SmallPtrSet<const GlobalValue *, 8> Inner;
collectConstant(C, Inner, Seen);
for (const GlobalValue *GV : Inner) {
Refs.Globals.insert(GV);
if (isa<Function>(GV) && GV->hasLocalLinkage()) {
Refs.LocalFunctionAddressTaken = true;
if (Refs.Blocker.empty())
Refs.Blocker = ("&" + GV->getName()).str();
}
}
}
}
}
return Refs;
}
// A local constant may be copied when its address is insignificant and its
// initializer only names external symbols or other copyable constants.
bool copyableConstant(const GlobalVariable &V,
DenseMap<const GlobalVariable *, bool> &Memo) {
auto Found = Memo.find(&V);
if (Found != Memo.end())
return Found->second;
Memo[&V] = false;
if (!V.isConstant() || V.isThreadLocal() || !V.hasAtLeastLocalUnnamedAddr() ||
!V.hasInitializer())
return false;
SmallPtrSet<const GlobalValue *, 8> Inner;
SmallPtrSet<const Constant *, 16> Seen;
collectConstant(V.getInitializer(), Inner, Seen);
for (const GlobalValue *GV : Inner) {
if (!GV->hasLocalLinkage()) {
if (isa<GlobalAlias>(GV) || isa<GlobalIFunc>(GV))
return false;
continue;
}
const auto *Nested = dyn_cast<GlobalVariable>(GV);
if (Nested == nullptr || !copyableConstant(*Nested, Memo))
return false;
}
Memo[&V] = true;
return true;
}
void stripTargetSpecificAttributes(Function &F) {
// The program module compiles for the helper's target machine; keep the
// imported bodies on the same subtarget so they are inline-compatible,
// and do not spread the runtime's stack protector into program frames.
F.removeFnAttr("target-cpu");
F.removeFnAttr("target-features");
F.removeFnAttr("tune-cpu");
F.removeFnAttr("stack-protector-buffer-size");
// Apple clang requests __chkstk_darwin probes, which upstream LLVM does not
// lower; program frames are not probed either.
F.removeFnAttr("probe-stack");
F.removeFnAttr("stack-probe-size");
F.removeFnAttr(Attribute::StackProtect);
F.removeFnAttr(Attribute::StackProtectStrong);
F.removeFnAttr(Attribute::StackProtectReq);
}
// Rewrites a merged runtime module so that linking it with LinkOnlyNeeded
// imports only safe, available_externally definitions.
void prepareImport(Module &Runtime, const std::vector<std::string> &Roots) {
unsigned Limit = MaxInstructions;
std::vector<Function *> Candidates;
DenseMap<const Function *, References> Refs;
DenseSet<const Function *> Importable;
DenseMap<const Function *, std::string> Blockers;
for (Function &F : Runtime)
if (candidate(F, Limit)) {
References R = referencesOf(F);
if (R.LocalFunctionAddressTaken) {
Blockers[&F] = R.Blocker;
continue;
}
Candidates.push_back(&F);
Importable.insert(&F);
Refs[&F] = std::move(R);
}
DenseMap<const GlobalVariable *, bool> ConstantMemo;
auto Restrict = [&]() {
bool Changed = true;
while (Changed) {
Changed = false;
for (Function *F : Candidates) {
if (!Importable.contains(F))
continue;
bool Ok = true;
for (const GlobalValue *GV : Refs[F].Globals) {
if (isa<GlobalAlias>(GV) || isa<GlobalIFunc>(GV)) {
Ok = false;
break;
}
if (!GV->hasLocalLinkage())
continue;
if (const auto *Callee = dyn_cast<Function>(GV)) {
if (!Importable.contains(Callee)) {
Ok = false;
break;
}
} else if (!copyableConstant(*cast<GlobalVariable>(GV), ConstantMemo)) {
Ok = false;
break;
}
}
if (!Ok) {
if (Blockers.find(F) == Blockers.end()) {
for (const GlobalValue *GV : Refs[F].Globals)
if (GV->hasLocalLinkage() || isa<GlobalAlias>(GV)) {
const auto *Callee = dyn_cast<Function>(GV);
const auto *Var = dyn_cast<GlobalVariable>(GV);
bool Blocks = Callee ? !Importable.contains(Callee)
: Var ? !copyableConstant(*Var, ConstantMemo)
: true;
if (Blocks) {
Blockers[F] = GV->getName().str();
break;
}
}
}
Importable.erase(F);
Changed = true;
}
}
}
};
Restrict();
// Select by call-graph distance from the program's references.
{
DenseSet<const Function *> Selected;
DenseSet<const Function *> Queued;
std::vector<std::pair<Function *, unsigned>> Queue;
for (const std::string &Name : Roots)
if (Function *F = Runtime.getFunction(Name))
if (Importable.contains(F) && Queued.insert(F).second)
Queue.push_back({F, 0});
for (size_t Next = 0; Next < Queue.size(); ++Next) {
auto [F, Depth] = Queue[Next];
double Allowed = Limit;
for (unsigned I = 0; I < Depth; ++I)
Allowed = Allowed * DecayPercent / 100.0;
if (F->getInstructionCount() > Allowed)
continue;
Selected.insert(F);
for (const GlobalValue *GV : Refs[F].Globals)
if (const auto *Callee = dyn_cast<Function>(GV)) {
auto *Mutable = const_cast<Function *>(Callee);
if (Importable.contains(Callee) && Queued.insert(Callee).second)
Queue.push_back({Mutable, Depth + 1});
}
}
Importable = std::move(Selected);
Restrict();
}
if (std::getenv("SCRIPTC_RUNTIME_IMPORT_DEBUG") != nullptr) {
for (Function &F : Runtime) {
if (F.isDeclaration())
continue;
const char *State = Importable.contains(&F) ? "import"
: Refs.count(&F) ? "reject-refs"
: "reject";
auto Blocker = Blockers.find(&F);
errs() << State << " " << F.getName() << " " << F.getInstructionCount()
<< " " << (Blocker == Blockers.end() ? "" : Blocker->second)
<< "\n";
}
}
for (Function &F : Runtime) {
if (F.isDeclaration())
continue;
if (Importable.contains(&F)) {
stripTargetSpecificAttributes(F);
if (!F.hasLocalLinkage()) {
F.setLinkage(GlobalValue::AvailableExternallyLinkage);
F.setComdat(nullptr);
}
} else if (!F.hasLocalLinkage()) {
F.deleteBody();
F.setComdat(nullptr);
}
}
std::vector<GlobalVariable *> Appending;
for (GlobalVariable &GV : Runtime.globals()) {
if (GV.hasAppendingLinkage()) {
Appending.push_back(&GV);
continue;
}
if (!GV.isDeclaration() && !GV.hasLocalLinkage()) {
GV.setInitializer(nullptr);
GV.setLinkage(GlobalValue::ExternalLinkage);
GV.setComdat(nullptr);
}
}
for (GlobalVariable *GV : Appending)
GV->eraseFromParent();
std::vector<GlobalAlias *> Aliases;
for (GlobalAlias &A : Runtime.aliases())
Aliases.push_back(&A);
for (GlobalAlias *A : Aliases) {
if (A->hasLocalLinkage())
continue;
GlobalValue *Declaration;
if (auto *Type = dyn_cast<FunctionType>(A->getValueType()))
Declaration = Function::Create(Type, GlobalValue::ExternalLinkage,
A->getAddressSpace(), "", &Runtime);
else
Declaration = new GlobalVariable(Runtime, A->getValueType(), false,
GlobalValue::ExternalLinkage, nullptr, "");
Declaration->takeName(A);
A->replaceAllUsesWith(Declaration);
A->eraseFromParent();
}
// Module-level assembly defines symbols in the runtime object (musl's
// getcontext/setcontext); the linker would otherwise copy it verbatim into
// the program and define those symbols twice.
Runtime.setModuleInlineAsm("");
// Module flags, identification, and autolink metadata describe the runtime
// object, which is linked separately; none of it applies to program code.
std::vector<NamedMDNode *> Named;
for (NamedMDNode &Node : Runtime.named_metadata())
Named.push_back(&Node);
for (NamedMDNode *Node : Named)
Runtime.eraseNamedMetadata(Node);
}
} // namespace
std::optional<std::string>
importRuntimeBitcode(Module &Program, const std::vector<std::string> &BitcodePaths) {
if (BitcodePaths.empty())
return std::nullopt;
LLVMContext &Context = Program.getContext();
std::unique_ptr<Module> Runtime;
std::unique_ptr<Linker> Merger;
for (const std::string &Path : BitcodePaths) {
ErrorOr<std::unique_ptr<MemoryBuffer>> Buffer = MemoryBuffer::getFile(Path);
if (!Buffer)
return (Twine("could not read runtime bitcode ") + Path + ": " +
Buffer.getError().message())
.str();
Expected<std::unique_ptr<Module>> Unit =
parseBitcodeFile((*Buffer)->getMemBufferRef(), Context);
if (!Unit)
return (Twine("invalid runtime bitcode ") + Path + ": " +
toString(Unit.takeError()))
.str();
if ((*Unit)->getDataLayout() != Program.getDataLayout())
return (Twine("runtime bitcode ") + Path +
" has a different data layout than the program")
.str();
(*Unit)->setTargetTriple(Program.getTargetTriple());
if (!Runtime) {
Runtime = std::move(*Unit);
Merger = std::make_unique<Linker>(*Runtime);
} else if (Merger->linkInModule(std::move(*Unit))) {
return (Twine("could not merge runtime bitcode ") + Path).str();
}
}
Merger.reset();
std::vector<std::string> Roots;
for (const Function &F : Program)
if (F.isDeclaration() && !F.isIntrinsic())
Roots.push_back(F.getName().str());
prepareImport(*Runtime, Roots);
if (Linker::linkModules(Program, std::move(Runtime), Linker::Flags::LinkOnlyNeeded))
return std::string("could not link runtime bitcode into the program");
return std::nullopt;
}
} // namespace scriptc
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include "llvm/IR/Module.h"
#include <optional>
#include <string>
#include <vector>
namespace scriptc {
// Makes small runtime functions visible to the program optimizer. Each input
// is LLVM bitcode for a runtime-pack unit that is also linked as a native
// object (both are emitted from one module by `runtime-unit`, which promotes
// unit-local symbols to hidden unit-qualified globals). Selected external
// definitions are linked into the program module as available_externally,
// exactly like ThinLTO function import: the optimizer may inline or
// specialize them, and every remaining reference resolves to the runtime
// object at link time. Selection starts at the runtime functions the
// program declares and follows calls with a size limit that decays per hop.
// Bodies that would need a second copy of unit-local state or a second
// address for a local function (possible only with unpromoted bitcode) are
// never imported.
//
// Returns an error message on failure.
std::optional<std::string>
importRuntimeBitcode(llvm::Module &Program,
const std::vector<std::string> &BitcodePaths);
} // namespace scriptc
+241
View File
@@ -0,0 +1,241 @@
// `runtime-unit`: turn one optimized runtime translation unit into the pair
// of artifacts a runtime pack ships for it: the native object and the
// bitcode the program build imports from (see runtime-import.cpp).
//
// Both come from one module. Every translation-unit-local symbol that an
// imported body could need (functions, mutable state, address-significant
// constants) is first promoted to a hidden external symbol with a stable,
// unit-qualified name, exactly like ThinLTO's local promotion. An imported
// copy of a small runtime function can then name the unit's private state
// and helpers, and the reference resolves to the single definition in the
// object. Promotion changes symbol binding only; the code is otherwise what
// the runtime compiler optimized.
#include "runtime-unit.h"
#include "diagnostics.h"
#include "target.h"
#include "llvm/ADT/SmallString.h"
#include "llvm/ADT/StringRef.h"
#include "llvm/Bitcode/BitcodeReader.h"
#include "llvm/Bitcode/BitcodeWriter.h"
#include "llvm/IR/Constants.h"
#include "llvm/IR/GlobalVariable.h"
#include "llvm/IR/LLVMContext.h"
#include "llvm/IR/LegacyPassManager.h"
#include "llvm/IR/Module.h"
#include "llvm/IR/Verifier.h"
#include "llvm/IRReader/IRReader.h"
#include "llvm/Support/FileSystem.h"
#include "llvm/Support/SourceMgr.h"
#include "llvm/Support/raw_ostream.h"
#include "llvm/Target/TargetMachine.h"
#include "llvm/TargetParser/Triple.h"
#include <memory>
#include <optional>
#include <string>
#include <vector>
using namespace llvm;
namespace scriptc {
namespace {
struct UnitOptions {
std::string Input;
std::string Object;
std::string Bitcode;
std::string Target;
std::string Tag;
bool FunctionSections = false;
bool DataSections = false;
};
std::optional<UnitOptions> parse(int Argc, char **Argv) {
UnitOptions Options;
for (int I = 2; I < Argc; ++I) {
StringRef Arg(Argv[I]);
if (Arg == "--function-sections") {
Options.FunctionSections = true;
continue;
}
if (Arg == "--data-sections") {
Options.DataSections = true;
continue;
}
if (!Arg.starts_with("--") || I + 1 >= Argc)
return std::nullopt;
StringRef Value(Argv[++I]);
if (Arg == "--input")
Options.Input = Value.str();
else if (Arg == "--object")
Options.Object = Value.str();
else if (Arg == "--bitcode")
Options.Bitcode = Value.str();
else if (Arg == "--target")
Options.Target = Value.str();
else if (Arg == "--tag")
Options.Tag = Value.str();
else
return std::nullopt;
}
if (Options.Input.empty() || Options.Object.empty() ||
Options.Bitcode.empty() || Options.Target.empty() || Options.Tag.empty())
return std::nullopt;
return Options;
}
// Constants whose address is insignificant are copied by the importer
// instead (string literals stay mergeable in their literal sections).
bool keepLocal(const GlobalValue &GV) {
if (const auto *Var = dyn_cast<GlobalVariable>(&GV))
return Var->isConstant() && !Var->isThreadLocal() &&
Var->hasAtLeastLocalUnnamedAddr();
return false;
}
void promoteLocals(Module &M, StringRef Tag) {
unsigned Anonymous = 0;
auto Promote = [&](GlobalValue &GV) {
if (!GV.hasLocalLinkage() || keepLocal(GV))
return;
std::string Base =
GV.hasName() ? GV.getName().str() : ("anon." + Twine(Anonymous++)).str();
GV.setName(Base + ".scrunit." + Tag.str());
GV.setLinkage(GlobalValue::ExternalLinkage);
GV.setVisibility(GlobalValue::HiddenVisibility);
GV.setDSOLocal(true);
};
for (Function &F : M)
if (!F.isDeclaration())
Promote(F);
for (GlobalVariable &V : M.globals())
if (!V.isDeclaration() && !V.hasAppendingLinkage())
Promote(V);
for (GlobalAlias &A : M.aliases())
Promote(A);
}
int fail(StringRef Code, const Twine &Message) {
return reportError(Code, Message, "json");
}
std::optional<std::string> writeFileAtomically(
StringRef Path, function_ref<void(raw_fd_ostream &)> Write) {
SmallString<256> Temporary(Path);
Temporary.append(".tmp-%%%%%%");
int Fd = -1;
if (std::error_code EC = sys::fs::createUniqueFile(Temporary, Fd, Temporary))
return EC.message();
{
raw_fd_ostream Out(Fd, true);
Write(Out);
Out.flush();
if (Out.has_error()) {
std::string Message = Out.error().message();
Out.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
int runtimeUnit(int Argc, char **Argv) {
std::optional<UnitOptions> Options = parse(Argc, Argv);
if (!Options)
return fail("usage",
"runtime-unit requires --input <bc> --object <o> --bitcode <bc> "
"--target <triple> --tag <unit> and accepts "
"--function-sections --data-sections");
LLVMContext Context;
SMDiagnostic Diagnostic;
std::unique_ptr<Module> M = parseIRFile(Options->Input, Diagnostic, Context);
if (!M) {
std::string Detail;
raw_string_ostream Stream(Detail);
Diagnostic.print("scriptc-llvm-codegen", Stream);
return fail("invalid_ir", Stream.str());
}
// Runtime compilers may spell OS and libc versions into the triple (zig:
// x86_64-unknown-linux5.10.0-gnu2.34.0) or use another environment ABI
// for the same platform (zig builds the Windows pack as windows-gnu). The
// object must follow the unit's own ABI, so code generation uses the
// unit's triple once architecture and OS match the pack target.
const Triple &UnitTriple = M->getTargetTriple();
Triple Expected(Options->Target);
if (UnitTriple.getArch() != Expected.getArch() ||
UnitTriple.getOS() != Expected.getOS())
return fail("target_mismatch", Twine("runtime unit targets '") +
M->getTargetTriple().str() + "', not '" +
Options->Target + "'");
std::string Error;
if (!supportsTarget(Options->Target))
return fail("unsupported_target", Twine("unsupported target '") +
Options->Target + "'");
std::unique_ptr<TargetMachine> Machine = createTargetMachine(
UnitTriple.str(), "2", Error, /*RequireAllowed=*/false);
if (!Machine)
return fail("target_machine_failed", Error);
if (M->getDataLayout() != Machine->createDataLayout())
return fail("data_layout_mismatch",
"runtime unit data layout differs from the helper target");
// Objects are position independent like every program object, and use
// the section layout the pack matrix requests for dead stripping.
Triple TargetTriple = UnitTriple;
// Clang always places WebAssembly functions and data in their own
// sections; the wasm object writer requires it.
Machine->Options.FunctionSections =
Options->FunctionSections || TargetTriple.isOSBinFormatWasm();
Machine->Options.DataSections =
Options->DataSections || TargetTriple.isOSBinFormatWasm();
Machine->Options.UseInitArray = TargetTriple.isOSBinFormatELF();
// Apple clang protects every frame against stack clashes by calling
// __chkstk_darwin, a probing method upstream LLVM does not lower. Keep the
// protection with LLVM's equivalent inline probes.
for (Function &F : *M)
if (F.getFnAttribute("probe-stack").getValueAsString() == "__chkstk_darwin")
F.addFnAttr("probe-stack", "inline-asm");
promoteLocals(*M, Options->Tag);
std::string VerifyError;
raw_string_ostream VerifyStream(VerifyError);
if (verifyModule(*M, &VerifyStream))
return fail("verification_failed", VerifyStream.str());
if (std::optional<std::string> Failure = writeFileAtomically(
Options->Bitcode,
[&](raw_fd_ostream &Out) { WriteBitcodeToFile(*M, Out); }))
return fail("output_write_failed", *Failure);
bool Unsupported = false;
std::optional<std::string> Failure =
writeFileAtomically(Options->Object, [&](raw_fd_ostream &Out) {
legacy::PassManager CodeGeneration;
if (Machine->addPassesToEmitFile(CodeGeneration, Out, nullptr,
CodeGenFileType::ObjectFile)) {
Unsupported = true;
return;
}
CodeGeneration.run(*M);
});
if (Unsupported) {
sys::fs::remove(Options->Object);
return fail("emission_not_supported",
"target does not support object emission");
}
if (Failure)
return fail("output_write_failed", *Failure);
return 0;
}
} // namespace scriptc
+9
View File
@@ -0,0 +1,9 @@
#pragma once
namespace scriptc {
// `scriptc-llvm-codegen runtime-unit`: emit a runtime-pack unit's object and
// its import bitcode from one optimized module with promoted locals.
int runtimeUnit(int Argc, char **Argv);
} // namespace scriptc
+12 -2
View File
@@ -14,6 +14,7 @@ void LLVMInitializeAArch64TargetInfo();
void LLVMInitializeAArch64Target();
void LLVMInitializeAArch64TargetMC();
void LLVMInitializeAArch64AsmPrinter();
void LLVMInitializeAArch64AsmParser();
}
#endif
#if defined(SCRIPTC_ENABLE_X86)
@@ -22,6 +23,7 @@ void LLVMInitializeX86TargetInfo();
void LLVMInitializeX86Target();
void LLVMInitializeX86TargetMC();
void LLVMInitializeX86AsmPrinter();
void LLVMInitializeX86AsmParser();
}
#endif
#if defined(SCRIPTC_ENABLE_WEBASSEMBLY)
@@ -30,6 +32,7 @@ void LLVMInitializeWebAssemblyTargetInfo();
void LLVMInitializeWebAssemblyTarget();
void LLVMInitializeWebAssemblyTargetMC();
void LLVMInitializeWebAssemblyAsmPrinter();
void LLVMInitializeWebAssemblyAsmParser();
}
#endif
@@ -44,18 +47,24 @@ void initializeTargets() {
LLVMInitializeAArch64Target();
LLVMInitializeAArch64TargetMC();
LLVMInitializeAArch64AsmPrinter();
// Runtime units may contain inline assembly (runtime-unit emission).
LLVMInitializeAArch64AsmParser();
#endif
#if defined(SCRIPTC_ENABLE_X86)
LLVMInitializeX86TargetInfo();
LLVMInitializeX86Target();
LLVMInitializeX86TargetMC();
LLVMInitializeX86AsmPrinter();
// Runtime units may contain inline assembly (runtime-unit emission).
LLVMInitializeX86AsmParser();
#endif
#if defined(SCRIPTC_ENABLE_WEBASSEMBLY)
LLVMInitializeWebAssemblyTargetInfo();
LLVMInitializeWebAssemblyTarget();
LLVMInitializeWebAssemblyTargetMC();
LLVMInitializeWebAssemblyAsmPrinter();
// Runtime units may contain inline assembly (runtime-unit emission).
LLVMInitializeWebAssemblyAsmParser();
#endif
Initialized = true;
}
@@ -107,9 +116,10 @@ static CodeGenOptLevel codeGenLevel(StringRef Level) {
std::unique_ptr<TargetMachine> createTargetMachine(StringRef TripleName,
StringRef OptLevel,
std::string &Error) {
std::string &Error,
bool RequireAllowed) {
initializeTargets();
if (!supportsTarget(TripleName)) {
if (RequireAllowed && !supportsTarget(TripleName)) {
Error = (Twine("target '") + TripleName + "' was not compiled into this helper").str();
return nullptr;
}
+1 -1
View File
@@ -36,6 +36,6 @@ void initializeTargets();
bool supportsTarget(llvm::StringRef Triple);
std::unique_ptr<llvm::TargetMachine>
createTargetMachine(llvm::StringRef Triple, llvm::StringRef OptLevel,
std::string &Error);
std::string &Error, bool RequireAllowed = true);
} // namespace scriptc
+1
View File
@@ -9,6 +9,7 @@
"build:fresh": "rm -rf packages/compiler/dist packages/cli/dist node_modules/.cache/scriptc-tsc && pnpm build",
"build:native-compiler": "node --max-old-space-size=8192 --import tsx scripts/build-native-cli.mts",
"bench:builds": "node scripts/bench-builds.mjs",
"bench:runtime": "node scripts/bench-runtime.mjs",
"bench:ast-access": "node scripts/bench-ast-access.mjs",
"bench:backend-analysis": "node scripts/bench-backend-analysis.mjs",
"test": "vitest run",
+7 -2
View File
@@ -71,7 +71,12 @@ async function tryFastPath(): Promise<number | null> {
const backend = values.backend;
if (backend !== undefined && backend !== "llvm") return null;
const optimization = values.optimization;
if (optimization !== undefined && optimization !== "release" && optimization !== "dev")
if (
optimization !== undefined &&
optimization !== "release" &&
optimization !== "dev" &&
optimization !== "speed"
)
return null;
const npmRaw = (values["npm-static"] ?? [])
.flatMap((value) => value.split(","))
@@ -118,7 +123,7 @@ async function tryFastPath(): Promise<number | null> {
sanitize: values.sanitize,
dynamic: values.dynamic,
backend: "llvm",
...(optimization === "dev" ? { optimization: "dev" as const } : {}),
...(optimization === "dev" || optimization === "speed" ? { optimization } : {}),
...(values.strip ? { strip: true as const } : {}),
npmStatic,
ffiProfile: ffiPath === null ? null : { path: ffiPath, bytes: ffiBytes! },
@@ -87,6 +87,16 @@ test("exact executable repeats skip lowering while edits and damaged outputs sta
expect(await run()).toBe("two\n");
expect((await build(["--optimization", "dev"])).stderr).not.toContain("scriptc lowering");
expect(await readFile(tuPath, "utf8")).toContain("define i32 @main");
// Speed shares release's -O2 class but links different code: it can
// consume neither the release nor the dev entry, and neither can
// consume its entry afterwards.
expect((await build(["--optimization", "speed"])).stderr).toContain("scriptc lowering");
expect(await run()).toBe("two\n");
expect((await build(["--optimization", "speed"])).stderr).not.toContain("scriptc lowering");
expect((await build(["--optimization", "release"])).stderr).not.toContain("scriptc lowering");
expect(await run()).toBe("two\n");
expect((await build(["--optimization", "speed"])).stderr).not.toContain("scriptc lowering");
} finally {
await rm(dir, { recursive: true, force: true });
}
+44
View File
@@ -0,0 +1,44 @@
import { execFile } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { expect, test } from "vitest";
const execFileAsync = promisify(execFile);
const repoRoot = join(import.meta.dirname, "../../..");
const bootstrap = join(repoRoot, "packages/cli/dist/bootstrap.js");
test("--optimization accepts release, dev, and speed and names them when rejecting", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-cli-optimization-"));
try {
const entry = join(dir, "main.ts");
await writeFile(entry, 'console.log("posture");\n');
const env = { ...process.env, SCRIPTC_CACHE_DIR: join(dir, "cache") };
const help = await execFileAsync(process.execPath, [bootstrap, "--help"], { env });
expect(help.stdout).toContain("--optimization <release|dev|speed>");
for (const args of [
["build", entry, "--optimization=fast"],
["run", entry, "--optimization", "O3"],
["cache", "warm", "--optimization=fast"],
]) {
await expect(
execFileAsync(process.execPath, [bootstrap, ...args], { env }),
).rejects.toMatchObject({
stderr: expect.stringContaining("(supported: release, dev, speed)"),
});
}
// Speed is an executable posture like release and dev.
await expect(
execFileAsync(
process.execPath,
[bootstrap, "build", entry, "--emit=llvm", "--optimization=speed"],
{ env },
),
).rejects.toMatchObject({
stderr: expect.stringContaining("--optimization is only meaningful with --emit=exe"),
});
} finally {
await rm(dir, { recursive: true, force: true });
}
}, 60_000);
@@ -9,8 +9,10 @@ import {
funcOf,
mapOf,
setOf,
type IrExpr,
type IrModule,
type IrRecordShape,
type IrStmt,
type IrType,
} from "../ir/ir.js";
import { computeTraced } from "./cycle-analysis.js";
@@ -31,7 +33,8 @@ const shape = (id: string, types: IrType[]): IrRecordShape => ({
fields: types.map((type, i) => ({ name: `field${i}`, type })),
});
function check(mod: IrModule, shapes: string[], unions: string[] = []): void {
/** Analyze `mod` exactly as given (its IR decides which edges are mutable). */
function checkExact(mod: IrModule, shapes: string[], unions: string[] = []): void {
const before = structuredClone(mod);
const actual = computeTraced(mod);
expect([...actual.shapes].sort()).toEqual(shapes.sort());
@@ -40,6 +43,44 @@ function check(mod: IrModule, shapes: string[], unions: string[] = []): void {
expect(mod).toEqual(before);
}
const value = (type: IrType): IrExpr => ({ kind: "varRef", localId: "v", type, loc });
/** Writes to every field after construction, so every field edge is mutable. */
function writes(mod: IrModule): IrStmt[] {
return [
...(mod.classes ?? []).flatMap((c) =>
c.fields.map((f): IrStmt => ({
kind: "fieldSet",
obj: value({ kind: "object", className: c.name }),
className: c.name,
field: f.name,
value: value(f.type),
loc,
})),
),
...(mod.records ?? []).flatMap((r) =>
r.fields.map((f): IrStmt => ({
kind: "recordSet",
obj: value(ref(r.id)),
shapeId: r.id,
field: f.name,
value: value(f.type),
loc,
})),
),
];
}
/** The mutable-graph contract: with every field written after
* construction, capability is the plain reachability fixpoint. */
function withWrites(mod: IrModule): IrModule {
const mutated = structuredClone(mod);
mutated.functions[0]!.body.push(...writes(mutated));
return mutated;
}
function check(mod: IrModule, shapes: string[], unions: string[] = []): void {
checkExact(withWrites(mod), shapes, unions);
}
test("removes an entire acyclic dependency chain to a fixed point", () => {
const mod = module();
mod.records = [shape("a", [ref("b")]), shape("b", [ref("c")]), shape("c", [STRING, F64])];
@@ -126,7 +167,7 @@ test("the runtime emitter and its descendants retain listener tracing", () => {
test("separate calls do not share mutable fixed-point state", () => {
const mod = module();
mod.records = [shape("same", [ref("same")])];
const previous = computeTraced(mod);
const previous = computeTraced(withWrites(mod));
mod.records = [shape("same", [STRING])];
check(mod, []);
expect([...previous.shapes]).toEqual(["record:same"]);
@@ -249,3 +290,267 @@ test("resolves a deep class hierarchy without recursive root traversal", () => {
mod.classes.map((c) => `object:${c.name}`),
);
});
// ── immutable edges ────────────────────────────────────────────────────
const obj = (className: string): IrType => ({ kind: "object", className });
const link: IrType = { kind: "union", unionId: "link" };
const thisRef = (className: string): IrExpr => ({
kind: "varRef",
localId: "this.0",
type: obj(className),
loc,
});
function ctor(className: string, body: IrStmt[]): IrModule["functions"][number] {
return {
name: `%${className}.constructor`,
params: [{ localId: "this.0", name: "this", type: obj(className) }],
locals: [],
returnType: VOID,
body,
loc,
};
}
const setThis = (className: string, field: string, type: IrType): IrStmt => ({
kind: "fieldSet",
obj: thisRef(className),
className,
field,
value: value(type),
loc,
});
/** `class Tree { readonly left: Tree | null; readonly right: Tree | null }`. */
function tree(extra: IrStmt[] = []): IrModule {
const mod = module();
mod.classes = [
{
name: "Tree",
fields: [
{ name: "left", type: link },
{ name: "right", type: link },
],
loc,
},
];
mod.unions = [{ id: "link", arms: [obj("Tree"), { kind: "nullT" }] }];
mod.functions.push(
ctor("Tree", [setThis("Tree", "left", link), setThis("Tree", "right", link), ...extra]),
);
return mod;
}
test("constructor-only self references are acyclic", () => {
checkExact(tree(), []);
const record = module();
record.records = [shape("list", [{ kind: "union", unionId: "next" }, F64])];
record.unions = [{ id: "next", arms: [ref("list"), { kind: "nullT" }] }];
checkExact(record, []);
});
test("a later write through any alias makes the edge mutable", () => {
const mod = tree();
mod.functions[0]!.body.push({
kind: "fieldSet",
obj: value(obj("Tree")),
className: "Tree",
field: "right",
value: value(link),
loc,
});
checkExact(mod, ["object:Tree"], ["link"]);
const record = module();
record.records = [shape("list", [ref("list")])];
record.functions[0]!.body.push({
kind: "recordKeySet",
obj: value(ref("list")),
shapeId: "list",
key: value(STRING),
value: value(ref("list")),
loc,
});
checkExact(record, ["record:list"]);
});
test("field increments count as writes", () => {
const mod = tree();
mod.functions[0]!.body.push({
kind: "exprStmt",
expr: {
kind: "fieldIncDec",
op: "+",
prefix: true,
obj: value(obj("Tree")),
className: "Tree",
field: "left",
fieldDyn: false,
type: F64,
loc,
},
loc,
});
checkExact(mod, ["object:Tree"], ["link"]);
});
test("this escaping from the constructor makes the hierarchy mutable", () => {
const escape: IrStmt = {
kind: "exprStmt",
expr: { kind: "call", callee: "register", args: [thisRef("Tree")], type: VOID, loc },
loc,
};
checkExact(tree([escape]), ["object:Tree"], ["link"]);
const capture: IrStmt = {
kind: "varDecl",
localId: "f.0",
init: { kind: "closure", fnName: "%fn0", captures: ["this.0"], type: funcOf([], F64), loc },
loc,
};
checkExact(tree([capture]), ["object:Tree"], ["link"]);
const self: IrStmt = setThis("Tree", "left", link);
(self as { value: IrExpr }).value = {
kind: "unionWrap",
unionId: "link",
tag: 0,
value: thisRef("Tree"),
type: link,
loc,
};
checkExact(tree([self]), ["object:Tree"], ["link"]);
});
test("field reads of this inside the constructor do not escape", () => {
const read: IrStmt = {
kind: "fieldSet",
obj: thisRef("Tree"),
className: "Tree",
field: "left",
value: {
kind: "fieldGet",
obj: thisRef("Tree"),
className: "Tree",
field: "right",
type: link,
loc,
},
loc,
};
checkExact(tree([read]), []);
});
test("derived constructors may initialize through super but not escape", () => {
const mod = module();
mod.classes = [
{ name: "Base", fields: [{ name: "next", type: link }], loc },
{
name: "Sub",
base: "Base",
fields: [
{ name: "next", type: link },
{ name: "other", type: link },
],
loc,
},
];
mod.unions = [{ id: "link", arms: [obj("Base"), { kind: "nullT" }] }];
const superCall: IrStmt = {
kind: "exprStmt",
expr: {
kind: "call",
callee: "%Base.constructor",
args: [{ kind: "upcast", value: thisRef("Sub"), type: obj("Base"), loc }, value(link)],
type: VOID,
loc,
},
loc,
};
mod.functions.push(
ctor("Base", [setThis("Base", "next", link)]),
ctor("Sub", [superCall, setThis("Sub", "next", link), setThis("Sub", "other", link)]),
);
checkExact(mod, []);
// Calling a constructor on anything but a derived constructor's own this.
const reinit = structuredClone(mod);
reinit.functions[0]!.body.push({
kind: "exprStmt",
expr: {
kind: "call",
callee: "%Base.constructor",
args: [value(obj("Base")), value(link)],
type: VOID,
loc,
},
loc,
});
checkExact(reinit, ["object:Base", "object:Sub"], ["link"]);
// A method of the subclass rewriting the inherited field.
const method = structuredClone(mod);
method.functions.push({
name: "%Sub.relink",
params: [{ localId: "this.0", name: "this", type: obj("Sub") }],
locals: [],
returnType: VOID,
body: [setThis("Sub", "next", link)],
loc,
});
checkExact(method, ["object:Base", "object:Sub"], ["link"]);
});
test("an immutable edge into a mutable cycle stays traced", () => {
// X.holder is constructor-only; Holder.x is rewritten later: x -> h -> x.
const mod = module();
mod.classes = [
{ name: "X", fields: [{ name: "holder", type: obj("Holder") }], loc },
{ name: "Holder", fields: [{ name: "x", type: { kind: "union", unionId: "maybeX" } }], loc },
{ name: "Wrapper", fields: [{ name: "x", type: obj("X") }], loc },
];
mod.unions = [{ id: "maybeX", arms: [obj("X"), { kind: "nullT" }] }];
mod.functions.push(ctor("X", [setThis("X", "holder", obj("Holder"))]));
mod.functions[0]!.body.push({
kind: "fieldSet",
obj: value(obj("Holder")),
className: "Holder",
field: "x",
value: value({ kind: "union", unionId: "maybeX" }),
loc,
});
checkExact(mod, ["object:X", "object:Holder", "object:Wrapper"], ["maybeX"]);
});
test("collection edges are mutable even behind constructor-only fields", () => {
const mod = module();
mod.classes = [{ name: "Call", fields: [{ name: "args", type: arrayOf(obj("Call")) }], loc }];
mod.functions.push(ctor("Call", [setThis("Call", "args", arrayOf(obj("Call")))]));
checkExact(mod, ["object:Call"]);
});
test("types that can become checked-dynamic capsules are mutable", () => {
const toDyn: IrStmt = {
kind: "exprStmt",
expr: { kind: "dynFrom", value: value(obj("Tree")), type: DYN, loc },
loc,
};
checkExact(tree([toDyn]), ["object:Tree"], ["link"]);
const thrown: IrStmt = { kind: "throw", value: value(link), loc };
checkExact(tree([thrown]), ["object:Tree"], ["link"]);
// Reached only through a boxed closure signature and a record field.
const holder = tree();
holder.records = [shape("box", [obj("Tree")])];
holder.functions[0]!.body.push({
kind: "exprStmt",
expr: { kind: "dynFrom", value: value(funcOf([], ref("box"))), type: DYN, loc },
loc,
});
checkExact(holder, ["object:Tree", "record:box"], ["link"]);
const logged = tree();
logged.functions[0]!.body.push({
kind: "exprStmt",
expr: { kind: "jsonStringify", value: value(arrayOf(link)), type: STRING, loc },
loc,
});
checkExact(logged, ["object:Tree"], ["link"]);
});
test("library builds keep the mutable-graph analysis", () => {
const mod = tree();
mod.lib = {} as NonNullable<IrModule["lib"]>;
checkExact(mod, ["object:Tree"], ["link"]);
});
+149 -72
View File
@@ -1,38 +1,31 @@
import type { IrModule, IrType } from "../ir/ir.js";
import { funcOf, mapOf, RUNTIME_EMITTER_CLASS, STRING, VOID } from "../ir/ir.js";
import { computeCycleMutability, fieldKey } from "./cycle-mutability.js";
/** Cycle capability for native code generation.
* Greatest fixpoint over shapes and unions: start optimistic (everything
* cycle-capable), repeatedly drop shapes with no cycle-capable field and
* unions with no cycle-capable arm until stable. Closures, checked values and promises
* are always cycle-capable; strings never are; arrays/Sets inherit their
* element type's capability and Maps inherit either key or value capability.
* A HIERARCHY is one unit of capability
* (a base-typed slot can hold any subclass and retain touches the cycle
* header, so header presence must be uniform across an extends tree): a
* unit is cycle-capable iff ANY member is — every backend uses the same grouping. */
* Nodes are shape units and unions; edges are the references their fields
* and arms can hold (collections flattened into their element, key and
* value types). Closures, checked values, class values, caught values and
* promises are INTRINSIC: they can reach anything, so a unit holding one is
* on a potential cycle. Strings never are.
*
* An edge is IMMUTABLE when it can only be stored while its owner is still
* under construction (cycle-mutability.ts derives this from the IR): such an
* object can only point to objects that existed before it, so a heap cycle
* made only of immutable edges is impossible. Union arms are immutable (the
* boxes are), and any edge through an array, Map, or Set is mutable because
* the container's contents are. A strongly connected component is
* cycle-capable iff it contains an intrinsic unit or a mutable internal
* edge; a unit or union is traced iff it can reach a cycle-capable
* component. With every edge mutable this is exactly the earlier greatest
* fixpoint: traced iff the unit reaches a cycle or an intrinsic reference.
*
* A HIERARCHY is one unit of capability (a base-typed slot can hold any
* subclass and retain touches the cycle header, so header presence must be
* uniform across an extends tree): a unit is cycle-capable iff ANY member
* is — every backend uses the same grouping. */
export function computeTraced(mod: IrModule): { shapes: Set<string>; unions: Set<string> } {
const classes = mod.classes ?? [];
const shapeDefs = [
...classes.map((c) => ({
key: `object:${c.name}`,
fields: [
...c.fields,
...(c.name === RUNTIME_EMITTER_CLASS
? [{ name: "<listeners>", type: funcOf([], VOID) }]
: []),
...(c.localCaptures !== undefined
? [{ name: "<class>", type: { kind: "classval" as const, className: c.name } }]
: []),
],
})),
...(mod.records ?? []).map((r) => ({
key: `record:${r.id}`,
fields: r.indexValue
? [...r.fields, { name: "<overflow>", type: mapOf(STRING, r.indexValue) }]
: r.fields,
})),
];
// Hierarchy units: root lookup over the base links (classes with a base
// or a subclass — and the runtime emitter class — form units under their
// root; standalone classes and records stay singleton units).
@@ -51,41 +44,70 @@ export function computeTraced(mod: IrModule): { shapes: Set<string>; unions: Set
for (const member of path) roots.set(member, cur);
return cur;
};
interface Capability {
const unitOfClass = (name: string): string => `object:${rootOf(name)}`;
const mutability = computeCycleMutability(mod, unitOfClass);
const shapeDefs = [
...classes.map((c) => ({
key: `object:${c.name}`,
unit: unitOfClass(c.name),
fields: [
...c.fields,
...(c.name === RUNTIME_EMITTER_CLASS
? [{ name: "<listeners>", type: funcOf([], VOID) }]
: []),
...(c.localCaptures !== undefined
? [{ name: "<class>", type: { kind: "classval" as const, className: c.name } }]
: []),
],
})),
...(mod.records ?? []).map((r) => ({
key: `record:${r.id}`,
unit: `record:${r.id}`,
fields: r.indexValue
? [...r.fields, { name: "<overflow>", type: mapOf(STRING, r.indexValue) }]
: r.fields,
})),
];
interface Node {
intrinsic: boolean;
dependencies: Set<Capability>;
dependents: Capability[];
remaining: number;
/** Dependency → whether any edge to it is mutable. */
edges: Map<Node, boolean>;
index: number;
low: number;
onStack: boolean;
component: number;
reverse: Node[];
traced: boolean;
}
const capability = (): Capability => ({
const node = (): Node => ({
intrinsic: false,
dependencies: new Set(),
dependents: [],
remaining: 0,
edges: new Map(),
index: -1,
low: 0,
onStack: false,
component: -1,
reverse: [],
traced: false,
});
const units = new Map<string, Capability>();
const shapes = new Map<string, Capability>();
const unions = new Map((mod.unions ?? []).map((u) => [u.id, capability()]));
const units = new Map<string, Node>();
const shapes = new Map<string, Node>();
const unions = new Map((mod.unions ?? []).map((u) => [u.id, node()]));
for (const s of shapeDefs) {
const key = s.key.startsWith("object:")
? `object:${rootOf(s.key.slice("object:".length))}`
: s.key;
let unit = units.get(key);
if (!unit) units.set(key, (unit = capability()));
let unit = units.get(s.unit);
if (!unit) units.set(s.unit, (unit = node()));
shapes.set(s.key, unit);
}
// Each field/arm is an OR of intrinsic capability and referenced units.
// Flatten collections into these edges; repeated references count once.
const addType = (unit: Capability, t: IrType): void => {
if (unit.intrinsic) return;
// Each field/arm is an OR of intrinsic capability and referenced nodes.
const addType = (from: Node, t: IrType, mutable: boolean): void => {
if (from.intrinsic) return;
switch (t.kind) {
case "func":
case "dyn":
case "classval":
case "promise":
case "caught":
unit.intrinsic = true;
unit.dependencies.clear();
from.intrinsic = true;
from.edges.clear();
break;
case "object":
case "record":
@@ -94,38 +116,93 @@ export function computeTraced(mod: IrModule): { shapes: Set<string>; unions: Set
t.kind === "union"
? unions.get(t.unionId)
: shapes.get(t.kind === "object" ? `object:${t.className}` : `record:${t.shapeId}`);
if (dependency) unit.dependencies.add(dependency);
if (dependency)
from.edges.set(dependency, (from.edges.get(dependency) ?? false) || mutable);
break;
}
case "map":
addType(unit, t.key);
addType(unit, t.value);
addType(from, t.key, true);
addType(from, t.value, true);
break;
case "set":
case "array":
addType(unit, t.elem);
addType(from, t.elem, true);
break;
}
};
for (const s of shapeDefs) for (const field of s.fields) addType(shapes.get(s.key)!, field.type);
for (const u of mod.unions ?? []) for (const arm of u.arms) addType(unions.get(u.id)!, arm);
const pending: Capability[] = [];
for (const unit of [...units.values(), ...unions.values()]) {
unit.remaining = unit.dependencies.size;
for (const dependency of unit.dependencies) dependency.dependents.push(unit);
if (!unit.intrinsic && unit.remaining === 0) pending.push(unit);
for (const s of shapeDefs) {
const unitMutable = mutability.all || mutability.units.has(s.unit);
for (const field of s.fields)
addType(
shapes.get(s.key)!,
field.type,
unitMutable || mutability.fields.has(fieldKey(s.unit, field.name)),
);
}
// Peel incapable leaves. Units in cycles retain a dependency, and units
// containing intrinsic references never enter the removal queue.
for (let i = 0; i < pending.length; i++) {
for (const dependent of pending[i]!.dependents) {
if (--dependent.remaining === 0 && !dependent.intrinsic) pending.push(dependent);
for (const u of mod.unions ?? [])
for (const arm of u.arms) addType(unions.get(u.id)!, arm, mutability.all);
// Tarjan's strongly connected components, iteratively (shape chains can
// be thousands deep).
const all = [...units.values(), ...unions.values()];
const stack: Node[] = [];
const componentCapable: boolean[] = [];
let nextIndex = 0;
for (const start of all) {
if (start.index >= 0) continue;
const work: { n: Node; next: Node[]; i: number }[] = [];
const enter = (n: Node): void => {
n.index = n.low = nextIndex++;
n.onStack = true;
stack.push(n);
work.push({ n, next: [...n.edges.keys()], i: 0 });
};
enter(start);
while (work.length > 0) {
const frame = work[work.length - 1]!;
if (frame.i < frame.next.length) {
const m = frame.next[frame.i++]!;
if (m.index < 0) enter(m);
else if (m.onStack) frame.n.low = Math.min(frame.n.low, m.index);
continue;
}
work.pop();
const n = frame.n;
if (work.length > 0) {
const parent = work[work.length - 1]!.n;
parent.low = Math.min(parent.low, n.low);
}
if (n.low !== n.index) continue;
const id = componentCapable.length;
const members: Node[] = [];
let member: Node;
do {
member = stack.pop()!;
member.onStack = false;
member.component = id;
members.push(member);
} while (member !== n);
let capable = false;
for (const m of members) {
if (m.intrinsic) capable = true;
for (const [dependency, mutable] of m.edges)
if (mutable && dependency.component === id) capable = true;
}
componentCapable.push(capable);
}
}
const retained = (unit: Capability): boolean => unit.intrinsic || unit.remaining > 0;
const tracedShapes = new Set(
[...shapes].filter(([, unit]) => retained(unit)).map(([key]) => key),
);
const tracedUnions = new Set([...unions].filter(([, unit]) => retained(unit)).map(([id]) => id));
// Traced: reaches a cycle-capable component (reverse reachability).
for (const n of all) for (const dependency of n.edges.keys()) dependency.reverse.push(n);
const pending = all.filter((n) => componentCapable[n.component]);
for (const n of pending) n.traced = true;
for (let i = 0; i < pending.length; i++) {
for (const dependent of pending[i]!.reverse) {
if (dependent.traced) continue;
dependent.traced = true;
pending.push(dependent);
}
}
const tracedShapes = new Set([...shapes].filter(([, unit]) => unit.traced).map(([key]) => key));
const tracedUnions = new Set([...unions].filter(([, n]) => n.traced).map(([id]) => id));
return { shapes: tracedShapes, unions: tracedUnions };
}
@@ -0,0 +1,242 @@
import type { IrExpr, IrFunction, IrModule, IrStmt, IrType } from "../ir/ir.js";
import { RUNTIME_EMITTER_CLASS, RUNTIME_STREAM_CLASSES } from "../ir/ir.js";
import { everyExprChild, everyModuleNode, everyStmtChild, everyTypeChild } from "../ir/traverse.js";
/** Which shape fields can be written after their object escapes.
*
* The cycle analysis treats a reference edge as IMMUTABLE when the program
* can only store into it while the owning object is still private to its
* construction. Every heap cycle then needs at least one mutable edge:
* an object can only point through immutable edges to objects that existed
* before it. The facts are derived from the final IR, never from TypeScript
* `readonly` (records intern structurally, and casts, `any`, and helper
* functions all reach the same field stores), and they are deliberately
* conservative:
*
* - every `fieldSet` outside a constructor's own `this`, `fieldIncDec`, and
* `recordSet` makes its (unit, field) mutable; `recordKeySet` on declared
* fields and `recordKeyDelete` make the whole record mutable;
* - a constructor in which `this` escapes (any use other than a field store
* target, a field read, or the receiver of the direct base constructor
* call — including closure and class captures) makes its whole hierarchy
* mutable, as does any other call to a constructor function;
* - every type that can cross into the checked-dynamic world (operands of
* `dynFrom`, including boxed closure signatures, thrown values, and
* arguments of library/intrinsic/JSON/FFI calls) makes its entire type
* closure mutable, because typed-ref capsules commit dynamic mutations
* straight into native fields;
* - runtime-rooted hierarchies (errors, emitters, streams) are mutable;
* - library builds and dynamic-island programs disable the refinement.
*
* Keys: `object:<hierarchy root>` / `record:<id>` units, fields as
* `<unit>\0<field>`. */
export interface CycleMutability {
/** Every edge is mutable (the refinement is disabled for this module). */
all: boolean;
/** Units whose every field is mutable. */
units: Set<string>;
/** Individually mutable fields. */
fields: Set<string>;
}
const DYNAMIC_ISLAND_EXPRS = new Set<string>([
"jsMarshal",
"jsOp",
"jsExit",
"jsBridgePromise",
"dynFromJsval",
]);
/** Expressions whose operands may be converted to checked-dynamic values
* (typed-ref capsules) by the backend or runtime. */
const DYNAMIC_BOUNDARY_EXPRS = new Set<string>([
"dynFrom",
"libCall",
"intrinsic",
"jsonStringify",
"ffiCall",
]);
export function fieldKey(unit: string, field: string): string {
return `${unit}\0${field}`;
}
export function computeCycleMutability(
mod: IrModule,
unitOfClass: (className: string) => string,
): CycleMutability {
const result: CycleMutability = { all: false, units: new Set(), fields: new Set() };
if (mod.lib !== undefined) return { ...result, all: true };
const classes = mod.classes ?? [];
const recordsById = new Map((mod.records ?? []).map((r) => [r.id, r] as const));
const unionsById = new Map((mod.unions ?? []).map((u) => [u.id, u] as const));
const ctorOf = new Map<string, (typeof classes)[number]>(
classes.map((c) => [`%${c.name}.constructor`, c]),
);
// The dynamic island stores engine values in native slots and can reach
// any object through them: no refinement.
let island = false;
everyModuleNode(mod, {
expr: (e) => !(island = island || DYNAMIC_ISLAND_EXPRS.has(e.kind)),
stmt: () => true,
type: (t) => !(island = island || t.kind === "jsval"),
});
if (island) return { ...result, all: true };
for (const c of classes) {
if (c.runtime || c.name === RUNTIME_EMITTER_CLASS || RUNTIME_STREAM_CLASSES.has(c.name))
result.units.add(unitOfClass(c.name));
}
// Types that can become checked-dynamic values: mark their whole closure.
const exposedTypes = new Set<string>();
const expose = (root: IrType): void => {
const stack: IrType[] = [root];
while (stack.length > 0) {
const t = stack.pop()!;
if (t.kind === "object" || t.kind === "classval") {
const unit = unitOfClass(t.className);
if (exposedTypes.has(unit)) continue;
exposedTypes.add(unit);
result.units.add(unit);
for (const c of classes)
if (unitOfClass(c.name) === unit) for (const f of c.fields) stack.push(f.type);
} else if (t.kind === "record") {
const unit = `record:${t.shapeId}`;
if (exposedTypes.has(unit)) continue;
exposedTypes.add(unit);
result.units.add(unit);
const shape = recordsById.get(t.shapeId);
for (const f of shape?.fields ?? []) stack.push(f.type);
if (shape?.indexValue !== undefined) stack.push(shape.indexValue);
} else if (t.kind === "union") {
const key = `union:${t.unionId}`;
if (exposedTypes.has(key)) continue;
exposedTypes.add(key);
for (const arm of unionsById.get(t.unionId)?.arms ?? []) stack.push(arm);
} else {
everyTypeChild(t, (child) => {
stack.push(child);
return true;
});
}
}
};
const isThisRef = (e: IrExpr, thisId: string | null): boolean => {
if (thisId === null) return false;
if (e.kind === "varRef") return e.localId === thisId;
if (e.kind === "upcast") return isThisRef(e.value, thisId);
if (e.kind === "seqExpr") return isThisRef(e.result, thisId);
return false;
};
for (const fn of mod.functions) {
const ctorClass = ctorOf.get(fn.name);
const thisId = ctorClass !== undefined ? (fn.params[0]?.localId ?? null) : null;
const baseCtor = ctorClass?.base !== undefined ? `%${ctorClass.base}.constructor` : null;
const expr = (e: IrExpr): boolean => {
if (DYNAMIC_BOUNDARY_EXPRS.has(e.kind)) {
everyExprChild(
e,
(child) => {
expose(child.type);
return true;
},
() => true,
);
}
switch (e.kind) {
case "fieldIncDec":
result.fields.add(fieldKey(unitOfClass(e.className), e.field));
break;
case "call": {
const target = ctorOf.get(e.callee);
if (
target !== undefined &&
!(e.callee === baseCtor && e.args.length > 0 && isThisRef(e.args[0]!, thisId))
)
result.units.add(unitOfClass(target.name));
break;
}
case "closure": {
const target = ctorOf.get(e.fnName);
if (target !== undefined) result.units.add(unitOfClass(target.name));
break;
}
}
return everyExprChild(e, expr, stmt);
};
const stmt = (s: IrStmt): boolean => {
switch (s.kind) {
case "fieldSet":
if (!isThisRef(s.obj, thisId))
result.fields.add(fieldKey(unitOfClass(s.className), s.field));
break;
case "recordSet":
result.fields.add(fieldKey(`record:${s.shapeId}`, s.field));
break;
case "recordKeySet":
if (s.overflowOnly !== true) result.units.add(`record:${s.shapeId}`);
break;
case "recordKeyDelete":
result.units.add(`record:${s.shapeId}`);
break;
case "throw":
expose(s.value.type);
break;
}
return everyStmtChild(s, expr, stmt);
};
fn.body.every(stmt);
if (ctorClass !== undefined && thisId !== null && constructorEscapes(fn, thisId, baseCtor))
result.units.add(unitOfClass(ctorClass.name));
}
return result;
}
/** Whether `this` can be observed by anything other than the constructor's
* own field stores before construction completes. Flow-insensitive: any
* escape anywhere in the body counts. */
function constructorEscapes(fn: IrFunction, thisId: string, baseCtor: string | null): boolean {
let escaped = false;
const isThisRef = (e: IrExpr): boolean =>
(e.kind === "varRef" && e.localId === thisId) ||
(e.kind === "upcast" && isThisRef(e.value)) ||
(e.kind === "seqExpr" && isThisRef(e.result));
// The non-`this` parts of a `this`-valued wrapper (seqExpr statements).
const wrapper = (e: IrExpr): boolean => {
if (e.kind === "upcast") return wrapper(e.value);
if (e.kind === "seqExpr") return e.stmts.every(stmt) && wrapper(e.result);
return true;
};
const expr = (e: IrExpr): boolean => {
if (e.kind === "varRef" && e.localId === thisId) {
escaped = true;
return false;
}
// Closures and class references are the expressions that capture locals.
if ((e.kind === "closure" || e.kind === "classRef") && (e.captures ?? []).includes(thisId)) {
escaped = true;
return false;
}
if (e.kind === "fieldGet" && isThisRef(e.obj)) return wrapper(e.obj);
if (
e.kind === "call" &&
baseCtor !== null &&
e.callee === baseCtor &&
e.args.length > 0 &&
isThisRef(e.args[0]!)
)
return wrapper(e.args[0]!) && e.args.slice(1).every(expr);
return everyExprChild(e, expr, stmt);
};
const stmt = (s: IrStmt): boolean => {
if (s.kind === "fieldSet" && isThisRef(s.obj)) return wrapper(s.obj) && expr(s.value);
if (s.kind === "exprStmt" && isThisRef(s.expr)) return wrapper(s.expr);
return everyStmtChild(s, expr, stmt);
};
fn.body.every(stmt);
return escaped;
}
@@ -1,4 +1,5 @@
/** Ordered link inputs shared by synchronous native and asynchronous Node hosts. */
import type { NativeOptimization } from "./optimization.js";
import {
executableOptimizationLinkerArgs,
executableStripLinkerArgs,
@@ -24,19 +25,32 @@ export function executableLinkInputs(options: {
runtimeObjects: readonly string[];
runtimeArchives: readonly string[];
runtimeSystemLibraries: readonly string[];
optimization: "release" | "dev";
optimization: NativeOptimization;
strip?: boolean;
windowsSubsystem?: WindowsSubsystem;
}): ExecutableLinkInputs {
if (options.ffiFrameworks.length !== 0 && options.target.platform !== "darwin")
throw new Error("FFI frameworks require a Darwin target");
return {
// Linkers lay out code in input order. The precompiled runtime and its
// archives go first so their addresses do not depend on the size of the
// program object: otherwise any program edit shifts every runtime
// function, and hot interpreter loops (the regex engine's especially)
// swing by double-digit percentages with incidental cache-line
// placement. Program code never references archive members directly,
// so single-pass archive resolution (GNU ld) still sees every runtime
// reference first. FFI libraries stay after the program that uses them;
// single-pass linkers get the archives once more after them in case a
// foreign library depends on a vendored one.
inputs: [
...options.runtimeObjects,
...options.runtimeArchives,
options.programObject,
...(options.programPartitions ?? []),
...options.ffiLibraries,
...options.runtimeObjects,
...options.runtimeArchives,
...(options.ffiLibraries.length !== 0 && options.target.platform !== "darwin"
? options.runtimeArchives
: []),
],
systemLibraries: [
...new Set([...options.ffiSystemLibraries, ...options.runtimeSystemLibraries]),
+2 -1
View File
@@ -6,6 +6,7 @@
* program, FFI inputs, runtime objects/archives, and system libraries meet
* the platform linker.
*/
import type { NativeOptimization } from "./optimization.js";
import type { FfiProfile } from "../ffi/ffi-manifest.js";
import type { NativeLinkFeatures } from "./native-link-info.js";
import type { NativeArtifactDependency } from "./native/contracts.js";
@@ -36,7 +37,7 @@ export async function createNativeLinkPlan(options: {
outPath: string;
features: NativeLinkFeatures;
ffi: FfiProfile | null;
optimization: "release" | "dev";
optimization: NativeOptimization;
strip?: boolean;
windowsSubsystem?: WindowsSubsystem;
programObjectDependencies?: readonly NativeArtifactDependency[];
@@ -0,0 +1,110 @@
import { describe, expect, test } from "vitest";
import { type AllocHost, emitObjectAlloc, emitObjectFree, inlineAllocSupported } from "./alloc.js";
function host(inlineAlloc: boolean): AllocHost & { decls: Set<string>; oom: boolean } {
const h = {
decls: new Set<string>(),
oom: false,
sizeType: "i64" as const,
inlineAlloc,
declare(decl: string) {
h.decls.add(decl);
},
needOom() {
h.oom = true;
},
};
return h;
}
/** Every SSA value and label is defined once (the lines are spliced into
* one function body). */
function expectSingleDefinitions(lines: string[]): void {
const defs = lines.flatMap((l) => {
const value = /^\s*(%[\w.]+) = /.exec(l);
const label = /^([\w.]+):/.exec(l);
return value ? [value[1]!] : label ? [`%${label[1]!}`] : [];
});
expect(new Set(defs).size).toBe(defs.length);
}
describe("emitted object allocation", () => {
test("inlines only where the runtime allocator state is a 64-bit process global", () => {
expect(inlineAllocSupported({ pointerBits: 64, targetTriple: "" })).toBe(true);
expect(inlineAllocSupported({ targetTriple: "x86_64-unknown-linux-gnu" })).toBe(true);
expect(inlineAllocSupported({ targetTriple: "arm64-apple-macosx14.0.0" })).toBe(true);
expect(inlineAllocSupported({ pointerBits: 32 })).toBe(false);
expect(inlineAllocSupported({ wasi: true })).toBe(false);
expect(inlineAllocSupported({ targetTriple: "wasm32-unknown-wasip1" })).toBe(false);
expect(inlineAllocSupported({ targetTriple: "x86_64-pc-windows-msvc" })).toBe(false);
expect(inlineAllocSupported({ targetTriple: "x86_64-w64-windows-gnu" })).toBe(false);
expect(inlineAllocSupported({ threadInstances: true })).toBe(false);
});
test("keeps the out-of-line calls when inlining is off", () => {
const h = host(false);
expect(emitObjectAlloc(h, "24", null)).toEqual([
" %o = call ptr @scr_rt_calloc(i64 24)",
" %isnull = icmp eq ptr %o, null",
" br i1 %isnull, label %oom, label %ok",
"oom:",
" call void @sc_oom()",
" unreachable",
"ok:",
" call void @scr_obj_alloc_note()",
]);
expect(emitObjectAlloc(h, "24", { trace: "@t", free: "@f" })).toEqual([
" %o = call ptr @scr_cyc_alloc(i64 24, ptr @t, ptr @f)",
" call void @scr_obj_alloc_note()",
]);
expect(emitObjectFree(h, true)).toEqual([
" call void @scr_obj_free_note()",
" call void @scr_cyc_free(ptr %o)",
]);
expect(emitObjectFree(h, false)).toEqual([
" call void @scr_obj_free_note()",
" call void @scr_weak_dispose(ptr %o)",
" call void @scr_rt_free(ptr %o)",
]);
expect([...h.decls].some((d) => d.includes("@scr_sa"))).toBe(false);
});
test("inline allocation falls back to the audited out-of-line entry points", () => {
const h = host(true);
const plain = emitObjectAlloc(h, "40", null, 16);
expectSingleDefinitions(plain);
expect(h.oom).toBe(true);
// Recycled blocks clear everything past the bytes the caller stores.
expect(plain).toContain(" %sa.z = getelementptr inbounds i8, ptr %sa.b, i64 16");
// The slow path is the old sequence: allocate, OOM check, audit note.
const slow = plain.slice(plain.indexOf("sa.slow:"));
expect(slow).toContain(" %sa.s = call ptr @scr_rt_calloc(i64 40)");
expect(slow).toContain(" call void @scr_obj_alloc_note()");
expect(plain.filter((l) => l.includes("@scr_obj_alloc_note")).length).toBe(1);
expect(plain.at(-1)).toBe(" %o = phi ptr [ %sa.blk, %sa.fast ], [ %sa.s, %sa.noted ]");
const cyc = emitObjectAlloc(h, "40", { trace: "@t", free: "@f" });
expectSingleDefinitions(cyc);
expect(cyc).toContain(" %sa.n = add i64 40, 32");
expect(cyc).toContain(" %sa.s = call ptr @scr_cyc_alloc(i64 40, ptr @t, ptr @f)");
expect(cyc).toContain(" store i64 %sa.live1, ptr @scr_cyc_live");
expect(h.decls).toContain(
"@scr_sa = external global { i64, i64, i32, [32 x ptr], [32 x ptr], [32 x ptr] }",
);
});
test("inline frees route foreign blocks and the bypassed allocator to scr_rt_free", () => {
for (const cyc of [false, true]) {
const h = host(true);
const lines = emitObjectFree(h, cyc);
expectSingleDefinitions(lines);
const blk = cyc ? "%sf.blk" : "%o";
const sys = lines.slice(lines.indexOf(lines.find((l) => l.startsWith("sf.sys:"))!));
expect(sys).toContain(" call void @scr_obj_free_note()");
expect(sys).toContain(` call void @scr_rt_free(ptr ${blk})`);
expect(lines).toContain(" call void %sf.hook(ptr %o)");
expect(lines.some((l) => l.includes("@scr_cyc_live"))).toBe(cyc);
expect(lines.at(-1)).toBe("sf.done:");
}
});
});
+316
View File
@@ -0,0 +1,316 @@
/* Emitted object allocation and deallocation for records and class
* instances.
*
* Every constructor allocates a constant-size block. The runtime's
* small-object allocator (scr_alloc.c, contract in scr_runtime.h) serves it
* from a per-size-class free list or bump pointer, but through an
* out-of-line call the size class, the zeroing length, and the
* collector-header setup are recomputed for every object. Here the fast
* paths are inlined into the emitted `new`/release helpers instead, so they
* fold to a constant class, a constant-length clear of only the bytes the
* helper does not store itself, and a handful of loads and stores.
*
* Soundness does not depend on which runtime the program links against.
* The runtime defines `scr_sa` in every build; where its allocator is
* compiled out (AddressSanitizer, the RC audit) the state stays all-zero,
* so the inline paths find no free block, no bump room and an empty span,
* and always take the slow path: the same out-of-line calls as before
* (scr_rt_calloc / scr_cyc_alloc, scr_rt_free) plus the RC-audit object
* notes. The fast paths skip those notes, which are no-ops whenever the
* allocator is live (SCR_RC_AUDIT forces it off; scr_runtime.h #errors
* otherwise). Blocks outside the reservation (system fallbacks) go back
* through scr_rt_free.
*
* Inlining is limited to the targets where the allocator can be live and
* its state is a process global: 64-bit, not WASI, not Windows, and not
* thread-instanced libraries or worker programs (whose runtime state,
* including scr_cyc_live and scr_weak_dispose_hook, is thread-local and
* whose allocator is compiled out). Elsewhere the out-of-line calls
* remain. */
/** The emitter surface this module needs (a subset of ShapeHost). */
export interface AllocHost {
declare(decl: string): void;
needOom(): void;
readonly sizeType: "i32" | "i64";
/** Inline the allocator fast paths (see the target rule above). */
readonly inlineAlloc?: boolean;
/** Emit the RC-audit object notes on the out-of-line paths (ShapeHost
* objectAudit: only --sanitize builds count them). Absent means emit. */
readonly objectAudit?: boolean;
}
const ALLOC_NOTE = ` call void @scr_obj_alloc_note()`;
const FREE_NOTE = ` call void @scr_obj_free_note()`;
/** Whether a target can use the inline allocation paths. */
export function inlineAllocSupported(options: {
pointerBits?: 32 | 64 | undefined;
wasi?: boolean | undefined;
targetTriple?: string | undefined;
threadInstances?: boolean | undefined;
}): boolean {
if (options.pointerBits === 32 || options.wasi === true || options.threadInstances === true) {
return false;
}
// An omitted triple means the host ABI. That stays sound even on a host
// whose runtime compiles the allocator out: scr_sa is defined there too
// and stays zero, so only the slow paths run.
const triple = (options.targetTriple ?? "").toLowerCase();
return !/windows|mingw|msvc|cygwin|wasm/.test(triple);
}
/* ScrSaState layout on 64-bit targets (asserted in scr_runtime.h). */
const SA_TYPE = "{ i64, i64, i32, [32 x ptr], [32 x ptr], [32 x ptr] }";
const SA_SPAN = 8;
const SA_SHIFT = 16;
const SA_FREE = 24;
const SA_BUMP = 280;
const SA_LIM = 536;
const SA_STEP_SHIFT = 4; // 16-byte classes
const SA_MAX = 512;
/** sizeof(ScrCycHdr) on 64-bit targets; trace and free_fn are its first
* two words, and every other header word starts zero. */
const CYC_HDR = 32;
function declareState(host: AllocHost): void {
host.declare(`@scr_sa = external global ${SA_TYPE}`);
host.declare(`declare i1 @llvm.expect.i1(i1, i1)`);
}
/** A collector header to install: the object's trace and teardown. */
export interface CycHeader {
trace: string;
free: string;
}
/**
* Lines that leave a zeroed object of `size` bytes (an integer constant
* expression) in `%o`, aborting on OOM, with the RC-audit allocation note
* on the out-of-line path. With `cyc`, the object carries a collector
* header (scr_cyc_alloc's contract). `stored` is the number of leading
* object bytes the caller stores unconditionally right after (at least the
* refcount word), which a recycled block need not clear. Labels use the
* `sa.` prefix; the lines end in an open block.
*/
export function emitObjectAlloc(
host: AllocHost,
size: string,
cyc: CycHeader | null,
stored = 8,
): string[] {
const lines = allocLines(host, size, cyc, stored);
if (host.objectAudit === false) return lines.filter((line) => line !== ALLOC_NOTE);
host.declare(`declare void @scr_obj_alloc_note()`);
return lines;
}
function allocLines(
host: AllocHost,
size: string,
cyc: CycHeader | null,
stored: number,
): string[] {
const S = host.sizeType;
if (host.inlineAlloc !== true) {
if (cyc) {
host.declare(`declare ptr @scr_cyc_alloc(${S}, ptr, ptr)`);
return [
` %o = call ptr @scr_cyc_alloc(${S} ${size}, ptr ${cyc.trace}, ptr ${cyc.free})`,
` call void @scr_obj_alloc_note()`,
];
}
host.declare(`declare ptr @scr_rt_calloc(${S})`);
host.needOom();
return [
` %o = call ptr @scr_rt_calloc(${S} ${size})`,
` %isnull = icmp eq ptr %o, null`,
` br i1 %isnull, label %oom, label %ok`,
`oom:`,
` call void @sc_oom()`,
` unreachable`,
`ok:`,
` call void @scr_obj_alloc_note()`,
];
}
declareState(host);
// Bytes of the block that the fast path itself stores after the clear:
// the header's trace/free_fn words, or the object's leading words.
const keep = cyc ? 16 : stored;
const lines = [
` %sa.n = add i64 ${size}, ${cyc ? CYC_HDR : 0}`,
` %sa.small = icmp ule i64 %sa.n, ${SA_MAX}`,
` br i1 %sa.small, label %sa.class, label %sa.slow`,
`sa.class:`,
` %sa.nm1 = sub i64 %sa.n, 1`,
` %sa.c = lshr i64 %sa.nm1, ${SA_STEP_SHIFT}`,
` %sa.c8 = shl i64 %sa.c, 3`,
` %sa.fo = add i64 %sa.c8, ${SA_FREE}`,
` %sa.fp = getelementptr inbounds i8, ptr @scr_sa, i64 %sa.fo`,
` %sa.b = load ptr, ptr %sa.fp`,
` %sa.hit = icmp ne ptr %sa.b, null`,
` %sa.hitx = call i1 @llvm.expect.i1(i1 %sa.hit, i1 true)`,
` br i1 %sa.hitx, label %sa.pop, label %sa.bump`,
`sa.pop: ; recycled blocks are dirty`,
` %sa.next = load ptr, ptr %sa.b`,
` store ptr %sa.next, ptr %sa.fp`,
` %sa.z = getelementptr inbounds i8, ptr %sa.b, i64 ${keep}`,
` %sa.zn = sub i64 %sa.n, ${keep}`,
` call void @llvm.memset.p0.i64(ptr align 8 %sa.z, i8 0, i64 %sa.zn, i1 false)`,
` br label %sa.fast`,
`sa.bump: ; fresh reservation memory is already zero`,
` %sa.c1 = add i64 %sa.c, 1`,
` %sa.sz = shl i64 %sa.c1, ${SA_STEP_SHIFT}`,
` %sa.bo = add i64 %sa.c8, ${SA_BUMP}`,
` %sa.bp = getelementptr inbounds i8, ptr @scr_sa, i64 %sa.bo`,
` %sa.lo = add i64 %sa.c8, ${SA_LIM}`,
` %sa.lp = getelementptr inbounds i8, ptr @scr_sa, i64 %sa.lo`,
` %sa.p = load ptr, ptr %sa.bp`,
` %sa.l = load ptr, ptr %sa.lp`,
` %sa.pi = ptrtoint ptr %sa.p to i64`,
` %sa.li = ptrtoint ptr %sa.l to i64`,
` %sa.room = sub i64 %sa.li, %sa.pi`,
` %sa.fits = icmp uge i64 %sa.room, %sa.sz`,
` %sa.fitsx = call i1 @llvm.expect.i1(i1 %sa.fits, i1 true)`,
` br i1 %sa.fitsx, label %sa.carve, label %sa.slow`,
`sa.carve:`,
` %sa.np = getelementptr inbounds i8, ptr %sa.p, i64 %sa.sz`,
` store ptr %sa.np, ptr %sa.bp`,
` br label %sa.fast`,
];
host.declare(`declare void @llvm.memset.p0.i64(ptr, i8, i64, i1)`);
if (cyc) {
host.declare(`declare ptr @scr_cyc_alloc(${S}, ptr, ptr)`);
host.declare(`@scr_cyc_live = external global i64`);
lines.push(
`sa.fast:`,
` %sa.blk = phi ptr [ %sa.b, %sa.pop ], [ %sa.p, %sa.carve ]`,
` store ptr ${cyc.trace}, ptr %sa.blk ; ScrCycHdr.trace`,
` %sa.ffp = getelementptr inbounds i8, ptr %sa.blk, i64 8`,
` store ptr ${cyc.free}, ptr %sa.ffp ; ScrCycHdr.free_fn`,
` %sa.live = load i64, ptr @scr_cyc_live`,
` %sa.live1 = add i64 %sa.live, 1`,
` store i64 %sa.live1, ptr @scr_cyc_live`,
` %sa.obj = getelementptr inbounds i8, ptr %sa.blk, i64 ${CYC_HDR}`,
` br label %sa.done`,
`sa.slow:`,
` %sa.s = call ptr @scr_cyc_alloc(i64 ${size}, ptr ${cyc.trace}, ptr ${cyc.free})`,
` call void @scr_obj_alloc_note()`,
` br label %sa.done`,
`sa.done:`,
` %o = phi ptr [ %sa.obj, %sa.fast ], [ %sa.s, %sa.slow ]`,
);
} else {
host.declare(`declare ptr @scr_rt_calloc(${S})`);
host.needOom();
lines.push(
`sa.fast:`,
` %sa.blk = phi ptr [ %sa.b, %sa.pop ], [ %sa.p, %sa.carve ]`,
` br label %sa.done`,
`sa.slow:`,
` %sa.s = call ptr @scr_rt_calloc(i64 ${size})`,
` %sa.null = icmp eq ptr %sa.s, null`,
` br i1 %sa.null, label %oom, label %sa.noted`,
`oom:`,
` call void @sc_oom()`,
` unreachable`,
`sa.noted:`,
` call void @scr_obj_alloc_note()`,
` br label %sa.done`,
`sa.done:`,
` %o = phi ptr [ %sa.blk, %sa.fast ], [ %sa.s, %sa.noted ]`,
);
}
return lines;
}
/**
* Lines that free the object `%o` after its members were released: the
* RC-audit free note, the weak-collection dispose hook, and the block —
* `scr_cyc_free` for collector-headered objects, `scr_rt_free` otherwise.
* Labels use the `sf.` prefix; the lines end in an open block.
*/
export function emitObjectFree(host: AllocHost, cyc: boolean): string[] {
const lines = freeLines(host, cyc);
if (host.objectAudit === false) return lines.filter((line) => line !== FREE_NOTE);
host.declare(`declare void @scr_obj_free_note()`);
return lines;
}
function freeLines(host: AllocHost, cyc: boolean): string[] {
if (host.inlineAlloc !== true) {
if (cyc) {
host.declare(`declare void @scr_cyc_free(ptr)`);
return [` call void @scr_obj_free_note()`, ` call void @scr_cyc_free(ptr %o)`];
}
host.declare(`declare void @scr_rt_free(ptr)`);
host.declare(`declare void @scr_weak_dispose(ptr)`);
return [
` call void @scr_obj_free_note()`,
` call void @scr_weak_dispose(ptr %o)`,
` call void @scr_rt_free(ptr %o)`,
];
}
declareState(host);
host.declare(`declare void @scr_rt_free(ptr)`);
host.declare(`@scr_weak_dispose_hook = external global ptr`);
const lines = [
` %sf.hook = load ptr, ptr @scr_weak_dispose_hook`,
` %sf.has = icmp ne ptr %sf.hook, null`,
` %sf.hasx = call i1 @llvm.expect.i1(i1 %sf.has, i1 false)`,
` br i1 %sf.hasx, label %sf.observe, label %sf.block`,
`sf.observe: ; weak collections observe the disposal`,
` call void %sf.hook(ptr %o)`,
` br label %sf.block`,
`sf.block:`,
];
if (cyc) {
host.declare(`@scr_cyc_live = external global i64`);
host.declare(`@scr_cyc_old_freed = external global i64`);
lines.push(
` %sf.live = load i64, ptr @scr_cyc_live`,
` %sf.live1 = sub i64 %sf.live, 1`,
` store i64 %sf.live1, ptr @scr_cyc_live`,
` %sf.blk = getelementptr inbounds i8, ptr %o, i64 -${CYC_HDR}`,
` %sf.genp = getelementptr inbounds i8, ptr %sf.blk, i64 22`,
` %sf.gen = load i16, ptr %sf.genp`,
` %sf.old = icmp eq i16 %sf.gen, 2`,
` br i1 %sf.old, label %sf.oldcount, label %sf.accounted`,
`sf.oldcount:`,
` %sf.freed = load i64, ptr @scr_cyc_old_freed`,
` %sf.freed1 = add i64 %sf.freed, 1`,
` store i64 %sf.freed1, ptr @scr_cyc_old_freed`,
` br label %sf.accounted`,
`sf.accounted:`,
);
}
const blk = cyc ? "%sf.blk" : "%o";
lines.push(
` %sf.base = load i64, ptr @scr_sa`,
` %sf.spanp = getelementptr inbounds i8, ptr @scr_sa, i64 ${SA_SPAN}`,
` %sf.span = load i64, ptr %sf.spanp`,
` %sf.bi = ptrtoint ptr ${blk} to i64`,
` %sf.off = sub i64 %sf.bi, %sf.base`,
` %sf.in = icmp ult i64 %sf.off, %sf.span`,
` %sf.inx = call i1 @llvm.expect.i1(i1 %sf.in, i1 true)`,
` br i1 %sf.inx, label %sf.push, label %sf.sys`,
`sf.push:`,
` %sf.shp = getelementptr inbounds i8, ptr @scr_sa, i64 ${SA_SHIFT}`,
` %sf.sh32 = load i32, ptr %sf.shp`,
` %sf.sh = zext i32 %sf.sh32 to i64`,
` %sf.c = lshr i64 %sf.off, %sf.sh`,
` %sf.c8 = shl i64 %sf.c, 3`,
` %sf.fo = add i64 %sf.c8, ${SA_FREE}`,
` %sf.fp = getelementptr inbounds i8, ptr @scr_sa, i64 %sf.fo`,
` %sf.head = load ptr, ptr %sf.fp`,
` store ptr %sf.head, ptr ${blk}`,
` store ptr ${blk}, ptr %sf.fp`,
` br label %sf.done`,
`sf.sys: ; outside the reservation, or the allocator is compiled out`,
` call void @scr_obj_free_note()`,
` call void @scr_rt_free(ptr ${blk})`,
` br label %sf.done`,
`sf.done:`,
);
return lines;
}
@@ -273,6 +273,48 @@ test("records safe local consumers without accepting duplicate or absent declara
expect(analyze(fn, leaf).locals.get("work")?.size).toBe(0);
});
test("closure locals get local facts while their parameters stay unproven", () => {
const fn = helper("closure");
fn.captures = [];
fn.locals.push(local("item"));
fn.body = [
{ kind: "varDecl", localId: "item", init: ref("value"), loc },
{ kind: "exprStmt", expr: tag("item"), loc },
];
const result = analyze(fn);
expect(result.parameters.has("closure")).toBe(false);
expect(result.borrowed.has("closure")).toBe(false);
expect(result.locals.get("closure")).toEqual(new Set(["item"]));
// A captured local is never a stack candidate inside the closure body.
fn.captures = [{ localId: "item", name: "item", type: optional }];
expect(analyze(fn).locals.get("closure")?.has("item")).toBe(false);
});
test("strict union equality and narrowed nullish reads are projections", () => {
const fn = helper("compare");
const equal: IrExpr = {
kind: "unionEq",
unionId: "optional",
negated: false,
sameValue: false,
left: ref("value"),
right: ref("value"),
type: BOOL,
loc,
};
fn.body = [{ kind: "exprStmt", expr: equal, loc }];
expect(analyze(fn).parameters.get("compare")).toEqual(new Set([0]));
const narrowed: IrExpr = { kind: "nullish", left: ref("value"), right: num(), type: F64, loc };
fn.body = [ret(narrowed)];
expect(analyze(fn).parameters.get("compare")).toEqual(new Set([0]));
// The pass-through shape returns the box itself.
fn.returnType = optional;
fn.body = [
ret({ kind: "nullish", left: ref("value"), right: ref("value"), type: optional, loc }),
];
expect(analyze(fn).parameters.has("compare")).toBe(false);
});
test("visits nested argument effects even when the outer helper is safe", () => {
const fn = helper("work"),
leaf = helper("leaf");
@@ -474,3 +516,95 @@ test("environment bodies prove their own locals but never their parameters", ()
fn.async = true;
expect(analyze(fn, leaf).locals.has("lifted")).toBe(false);
});
test("strict union equality is a borrowing use of a local operand", () => {
const fn = helper("same", ["left", "right"]);
fn.returnType = BOOL;
fn.body = [
ret({
kind: "unionEq",
unionId: "optional",
negated: false,
sameValue: false,
left: ref("left"),
right: ref("right"),
type: BOOL,
loc,
}),
];
expect(analyze(fn).parameters.get("same")).toEqual(new Set([0, 1]));
fn.body = [
ret({
kind: "unionEq",
unionId: "optional",
negated: false,
sameValue: false,
left: ref("left"),
right: {
kind: "unionWrap",
value: ref("right"),
unionId: "nested",
tag: 0,
type: optional,
loc,
},
type: BOOL,
loc,
}),
];
expect(analyze(fn).parameters.get("same")).toEqual(new Set([0]));
});
test("closure bodies get local facts but never parameter facts", () => {
const fn = helper("closure");
fn.captures = [];
fn.locals.push(local("item"));
fn.body = [
{ kind: "varDecl", localId: "item", init: ref("value"), loc },
{ kind: "exprStmt", expr: tag("item"), loc },
];
const result = analyze(fn);
expect(result.parameters.has("closure")).toBe(false);
expect(result.borrowed.has("closure")).toBe(false);
expect(result.locals.get("closure")).toEqual(new Set(["item"]));
expect(result.projectedLocals.get("closure")).toEqual(new Set(["item"]));
expect(result.bindings.get("closure")).toEqual(new Set(["item"]));
fn.locals[0]!.boxed = true;
fn.body.push({ kind: "exprStmt", expr: tag("value"), loc });
expect(analyze(fn).locals.get("closure")).toEqual(new Set(["item"]));
});
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 => ({
kind: "fieldSet",
obj: ref("this", self),
className,
field,
value,
loc,
});
const fn: IrFunction = {
name: "%Node.constructor",
loc,
returnType: { kind: "void" },
params: [
{ localId: "this", name: "this", type: self },
{ localId: "value", name: "value", type: optional },
],
locals: [local("this", self), local("value")],
body: [store("Node", "next", ref("value"))],
};
const nullable = (className: string, field: string): boolean =>
className === "Node" && field === "next";
const functions = new Map([[fn.name, fn]]);
// Ordinary union fields store (retain) the box itself.
expect(analyzeCallLifetimes(functions).parameters.get(fn.name)?.has(1)).toBeFalsy();
expect(analyzeCallLifetimes(functions, nullable).parameters.get(fn.name)).toEqual(new Set([1]));
// Another field of the same class keeps ordinary storage.
fn.body = [store("Node", "other", ref("value"))];
expect(analyzeCallLifetimes(functions, nullable).parameters.get(fn.name)?.has(1)).toBeFalsy();
// The receiver is still an ordinary use, and a second escape still rejects.
fn.body = [store("Node", "next", ref("value")), ret(ref("value"))];
expect(analyzeCallLifetimes(functions, nullable).parameters.get(fn.name)?.has(1)).toBeFalsy();
});
@@ -68,7 +68,11 @@ function eligible(local: IrLocal): boolean {
* Projection results may escape: the existing emitter retains extracted
* payloads. The enclosing union box itself must never be retained, released,
* stored, returned, captured, or passed to arbitrary runtime code. */
function collectUses(fn: IrFunction): Uses {
/** True for class fields stored as nullable instance pointers: a store
* extracts and retains the payload, so the stored union is only projected. */
export type NullableFieldTest = (className: string, field: string) => boolean;
function collectUses(fn: IrFunction, nullableField: NullableFieldTest): Uses {
const uses: Uses = {
invalid: new Set(),
written: new Set(),
@@ -101,9 +105,22 @@ function collectUses(fn: IrFunction): Uses {
case "unionIsTag":
if (node.value.kind === "varRef") return true;
break;
case "unionEq":
// Equality reads each operand's tag and payload; neither box escapes.
return [node.left, node.right].every((side) => side.kind === "varRef" || expr(side));
case "unionEq": {
// Tag compare plus a borrowed arm compare; neither box escapes.
const operand = (value: IrExpr): boolean =>
(value.kind === "varRef" && value.type.kind === "union") || expr(value);
return operand(node.left) && operand(node.right);
}
case "nullish":
// The narrowed shape only tests the tag and extracts the payload.
// The pass-through shape returns the box itself.
if (
node.left.kind === "varRef" &&
node.left.type.kind === "union" &&
node.type.kind !== "union"
)
return expr(node.right);
break;
case "fieldGet":
case "recordGet":
if (node.obj.kind === "varRef") return true;
@@ -146,6 +163,14 @@ function collectUses(fn: IrFunction): Uses {
case "varDecl":
uses.declarations.set(node.localId, (uses.declarations.get(node.localId) ?? 0) + 1);
break;
case "fieldSet":
if (
node.value.kind === "varRef" &&
node.value.type.kind === "union" &&
nullableField(node.className, node.field)
)
return expr(node.obj);
break;
case "assign":
uses.written.add(node.localId);
// Sequence expressions can rebind a previous call argument before
@@ -185,10 +210,13 @@ function collectUses(fn: IrFunction): Uses {
* do not recurse on the compiler stack or repeatedly rescan function bodies.
*
* The result is private to one emission of the finalized IR. No fact is
* serialized or reused after a compiler transformation. Only synchronous
* bodies are analyzed; parameter facts additionally require a body without
* an environment, which is the only directly called convention. */
export function analyzeCallLifetimes(functions: ReadonlyMap<string, IrFunction>): CallLifetimes {
* serialized or reused after a compiler transformation. Parameter facts
* cover synchronous bodies without environments only; closure bodies get
* local facts alone. */
export function analyzeCallLifetimes(
functions: ReadonlyMap<string, IrFunction>,
nullableField: NullableFieldTest = () => false,
): CallLifetimes {
const usesByFunction = new Map<string, Uses>();
const nodes = new Map<string, Parameter[]>();
const unsafe: Parameter[] = [];
@@ -201,8 +229,13 @@ export function analyzeCallLifetimes(functions: ReadonlyMap<string, IrFunction>)
};
for (const fn of functions.values()) {
if (fn.async || fn.generator) continue;
const uses = collectUses(fn);
const uses = collectUses(fn, nullableField);
usesByFunction.set(fn.name, uses);
// Class-capturing bodies get only local facts (computed below from
// their uses; class captures are already invalid). Binding and
// parameter facts stay limited to plain functions and closures.
if (fn.classCaptures !== undefined) continue;
const locals = new Map(fn.locals.map((local) => [local.id, local]));
const stable = (local: IrLocal): boolean =>
!local.boxed && !local.tdz && !uses.written.has(local.id);
result.bindings.set(
@@ -213,11 +246,13 @@ export function analyzeCallLifetimes(functions: ReadonlyMap<string, IrFunction>)
.map((local) => local.id),
),
);
// Environment-taking bodies are reached through closure or class
// dispatch, never a direct call. Their own locals follow the same
// proof (captures are already writes), but parameters keep ownership.
if (fn.captures !== undefined || fn.classCaptures !== undefined) continue;
const locals = new Map(fn.locals.map((local) => [local.id, local]));
if (fn.captures !== undefined) {
// A closure body is invoked through its environment trampoline, so
// its parameters keep the owned convention and no direct call can
// forward to them. Its own unboxed locals follow the same rules as
// any synchronous body; captured cells are boxed and marked invalid.
continue;
}
const borrowed = new Set<number>();
fn.params.forEach((param, index) => {
const local = locals.get(param.localId);
+39 -43
View File
@@ -26,7 +26,9 @@ import {
mangleVtInstance,
mangleVtStruct,
} from "../mangle.js";
import { emitObjectAlloc, emitObjectFree } from "./alloc.js";
import { llvmCommentText } from "./common.js";
import { NullableRefFields } from "./nullable-fields.js";
import {
FN_ATTRS,
llFieldType,
@@ -301,12 +303,18 @@ export interface ClassHost extends ShapeHost {
* at the interned unit instance; dyn fields start at native undefined;
* jsval fields (an `any` class field under --dynamic) start at the engine's
* undefined cell. */
function undefFieldInits(host: ClassHost, meta: LlClassMeta): string[] {
function undefFieldInits(
host: ClassHost,
meta: LlClassMeta,
nullable: NullableRefFields | undefined,
): string[] {
const out: string[] = [];
meta.def.fields.forEach((f, i) => {
// Error.cause uses NULL for absence; an options constructor installs
// a value only when the cause property is present.
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;
const { index } = classFieldIndex(meta, f.name);
if (f.type.kind === "jsval" || f.type.kind === "dyn") {
const undefinedFn = f.type.kind === "dyn" ? "scr_dyn_undefined" : "scr_jsval_undefined";
@@ -338,13 +346,17 @@ export function emitClassShapes(
host: ClassHost,
mod: IrModule,
metaMap: Map<string, LlClassMeta>,
nullable?: NullableRefFields,
): { typeDefs: string[]; defs: string[] } {
const typeDefs: string[] = [];
const defs: string[] = [];
const emitted = (mod.classes ?? []).filter((c) => !c.runtime);
if (emitted.length === 0) return { typeDefs, defs };
host.declare(`declare void @scr_obj_alloc_note()`);
host.declare(`declare void @scr_obj_free_note()`);
const audit = host.objectAudit !== false;
if (audit) {
host.declare(`declare void @scr_obj_alloc_note()`);
host.declare(`declare void @scr_obj_free_note()`);
}
for (const cls of emitted) {
const meta = metaMap.get(cls.name)!;
@@ -411,8 +423,15 @@ export function emitClassShapes(
const isEmitterRooted = emitterRooted(meta);
const isStreamRooted = streamRooted(meta);
const fieldIndex = (i: number): number => fieldBase(meta) + i;
// Teardown, trace and gcFree see each slot's storage type: a nullable
// class field holds the instance pointer (NULL for the unit arm), and
// every class retain/release/visit entry point is NULL-tolerant.
const indexedFields = [
...cls.fields.map((f, i) => ({ name: f.name, type: f.type, index: fieldIndex(i) })),
...cls.fields.map((f, i) => ({
name: f.name,
type: nullable?.storageType(cls.name, f.name, f.type) ?? f.type,
index: fieldIndex(i),
})),
];
const refFields = indexedFields.filter((f) => isRefCounted(f.type));
const bounded =
@@ -459,15 +478,7 @@ export function emitClassShapes(
});
if (isEmitterRooted) lines.push(...regCall("td", "scr_emitter_reg_drop", ""));
if (isStreamRooted) lines.push(...stCall("tds", "scr_stream_st_release", ""));
lines.push(` call void @scr_obj_free_note()`);
if (traced) {
host.declare(`declare void @scr_cyc_free(ptr)`);
lines.push(` call void @scr_cyc_free(ptr %o)`);
} else {
host.declare(`declare void @free(ptr)`);
host.declare(`declare void @scr_weak_dispose(ptr)`);
lines.push(` call void @scr_weak_dispose(ptr %o)`, ` call void @free(ptr %o)`);
}
lines.push(...emitObjectFree(host, traced));
};
if (meta.hierarchy) {
@@ -552,30 +563,22 @@ export function emitClassShapes(
// new: zeroed allocation, rc = 1, the vtable word on hierarchy
// members, undefined-admitting union fields at the interned unit
// instance, alloc note. Traced shapes allocate with the collector
// header (scr_cyc_alloc zeroes and aborts on OOM itself).
// instance. Traced shapes allocate with the collector header; alloc.ts
// inlines the allocator fast paths and the alloc note.
const nw: string[] = [
`define internal ptr @${mangleClassNew(cls.name)}() ${FN_ATTRS} { ; new ${cls.name}`,
`entry:`,
];
if (traced) {
host.declare(`declare ptr @scr_cyc_alloc(${host.sizeType}, ptr, ptr)`);
nw.push(
` %o = call ptr @scr_cyc_alloc(${host.sizeType} ${sizeOf}, ptr @${mangleClassTrace(cls.name)}, ptr @${mangleClassGcFree(cls.name)})`,
);
} else {
host.declare(`declare ptr @calloc(${host.sizeType}, ${host.sizeType})`);
host.needOom();
nw.push(
` %o = call ptr @calloc(${host.sizeType} 1, ${host.sizeType} ${sizeOf})`,
` %isnull = icmp eq ptr %o, null`,
` br i1 %isnull, label %oom, label %ok`,
`oom:`,
` call void @sc_oom()`,
` unreachable`,
`ok:`,
);
}
nw.push(
...emitObjectAlloc(
host,
sizeOf,
traced
? { trace: `@${mangleClassTrace(cls.name)}`, free: `@${mangleClassGcFree(cls.name)}` }
: null,
meta.hierarchy ? 16 : 8,
),
);
nw.push(` store ${host.sizeType} 1, ptr %o`);
if (meta.hierarchy) {
nw.push(
@@ -595,8 +598,8 @@ export function emitClassShapes(
` store ptr ${host.cstr(displayName)}, ptr %clsp ; EventEmitter prefix display name`,
);
}
nw.push(...undefFieldInits(host, meta));
nw.push(` call void @scr_obj_alloc_note()`, ` ret ptr %o`, `}`, ``);
nw.push(...undefFieldInits(host, meta, nullable));
nw.push(` ret ptr %o`, `}`, ``);
defs.push(...nw);
if (traced) {
@@ -639,14 +642,7 @@ export function emitClassShapes(
` call void ${releaseSym(host, f.type)}(ptr %v${i}) ; ${llvmCommentText(f.name)} (acyclic)`,
);
});
host.declare(`declare void @scr_cyc_free(ptr)`);
gf.push(
` call void @scr_obj_free_note()`,
` call void @scr_cyc_free(ptr %o)`,
` ret void`,
`}`,
``,
);
gf.push(...emitObjectFree(host, true), ` ret void`, `}`, ``);
defs.push(...gf);
}
}
@@ -7,9 +7,14 @@ import {
type IrExpr,
type IrFunction,
type IrModule,
type IrStmt,
} from "../../ir/ir.js";
import { analyzeCallLifetimes } from "./call-lifetimes.js";
import { callbackIgnoresReceiver, findConstantCallbacks } from "./constant-callbacks.js";
import {
AmbientReceiverReaders,
callbackIgnoresReceiver,
findConstantCallbacks,
} from "./constant-callbacks.js";
const loc = { file: "callbacks.ts", start: 0, end: 0 };
const callback = funcOf([F64], BOOL);
@@ -127,12 +132,160 @@ test("global closure aliases resolve without borrowing the caller's local namesp
entry.body.push({ kind: "assign", localId: "%g.callback", value: closure("predicate"), loc });
expect(facts(mod).get("consume")?.has("test")).not.toBe(true);
});
test("receiver elision admits only synchronous scalar work", () => {
test("receiver elision admits only synchronous receiver-free work", () => {
const fn = fixture().functions[0]!;
expect(callbackIgnoresReceiver(fn)).toBe(true);
// A fixed compile-fence throw cannot observe the receiver.
fn.body.unshift({ kind: "runtimeFence", code: "SC2020", message: "refused", loc });
expect(callbackIgnoresReceiver(fn)).toBe(true);
fn.body[0] = {
kind: "exprStmt",
expr: { kind: "libCall", fn: "dyn.this", args: [], type: { kind: "dyn" }, loc },
loc,
};
expect(callbackIgnoresReceiver(fn)).toBe(false);
fn.body.shift();
fn.async = true;
expect(callbackIgnoresReceiver(fn)).toBe(false);
});
function callsOf(name: string, callee: string, extra: IrStmt[] = []): IrFunction {
return {
name,
params: [],
locals: [],
returnType: VOID,
body: [
...extra,
{ kind: "exprStmt", expr: { kind: "call", callee, args: [], type: VOID, loc }, loc },
],
loc,
};
}
const thisRead: IrStmt = {
kind: "exprStmt",
expr: { kind: "libCall", fn: "dyn.this", args: [], type: { kind: "dyn" }, loc },
loc,
};
test("receiver readers propagate backwards through direct calls and recursion", () => {
const leaf = callsOf("leaf", "middle");
const middle = callsOf("middle", "leaf");
const functions = new Map([leaf, middle].map((fn) => [fn.name, fn]));
// A cycle with no reader is receiver-free.
expect(new AmbientReceiverReaders(functions).ignores(leaf)).toBe(true);
middle.body.unshift(thisRead);
const readers = new AmbientReceiverReaders(functions);
expect(readers.ignores(leaf)).toBe(false);
expect(readers.ignores(middle)).toBe(false);
// Unknown callees and async callees count as readers.
const unknown = callsOf("unknown", "missing");
expect(callbackIgnoresReceiver(unknown)).toBe(false);
const spawn = callsOf("spawn", "task");
const task: IrFunction = { ...callsOf("task", "spawn"), body: [], async: true };
expect(
new AmbientReceiverReaders(new Map([spawn, task].map((fn) => [fn.name, fn]))).ignores(spawn),
).toBe(false);
});
test("virtual calls admit every same-named method", () => {
const call: IrStmt = {
kind: "exprStmt",
expr: { kind: "virtualCall", className: "%A", method: "run", args: [], type: VOID, loc },
loc,
};
const caller: IrFunction = { ...callsOf("caller", "x"), body: [call] };
const a: IrFunction = { ...callsOf("%A.run", "x"), body: [] };
const b: IrFunction = { ...callsOf("%B.run", "x"), body: [] };
const functions = new Map([caller, a, b].map((fn) => [fn.name, fn]));
expect(new AmbientReceiverReaders(functions).ignores(caller)).toBe(true);
b.body = [thisRead];
expect(new AmbientReceiverReaders(functions).ignores(caller)).toBe(false);
});
test("synthetic array HOF helpers are constant-callback consumers", () => {
const mod = fixture();
const consumer = mod.functions[1]!;
const entry = mod.functions[2]!;
delete consumer.ownsPrototype;
consumer.name = "%arr.some.0";
const call = entry.body[0]!;
if (call.kind === "exprStmt" && call.expr.kind === "call") call.expr.callee = "%arr.some.0";
expect(facts(mod).get("%arr.some.0")?.get("test")).toBe("predicate");
});
function factory(name: string, returns: IrExpr[]): IrFunction {
return {
name,
params: [],
locals: [],
returnType: callback,
body: returns.map((value) => ({
kind: "if",
cond: { kind: "boolLit", value: true, type: BOOL, loc },
then: [{ kind: "return", value, loc }],
else_: null,
loc,
})),
loc,
};
}
test("a factory returning one function resolves through its call", () => {
const mod = fixture();
const entry = mod.functions[2]!;
mod.functions.push(factory("make", [closure("predicate")]));
const call = entry.body[0]!;
if (call.kind === "exprStmt" && call.expr.kind === "call")
call.expr.args = [{ kind: "call", callee: "make", args: [], type: callback, loc }];
expect(facts(mod).get("consume")?.get("test")).toBe("predicate");
mod.functions.push({ ...mod.functions[0]!, name: "other" });
mod.functions[3] = factory("make", [closure("predicate"), closure("other")]);
expect(facts(mod).get("consume")?.has("test")).not.toBe(true);
});
test("an immutable capture resolves at its closure creation site", () => {
const mod = fixture();
const entry = mod.functions[2]!;
const lambda: IrFunction = {
name: "%lambda",
params: [],
captures: [{ localId: "cb", name: "cb", type: callback }],
locals: [{ id: "cb", name: "cb", type: callback, mutable: false, boxed: true }],
returnType: BOOL,
body: [
{
kind: "return",
value: {
kind: "callValue",
callee: ref("cb"),
args: [{ kind: "numLit", value: 1, type: F64, loc }],
type: BOOL,
loc,
},
loc,
},
],
loc,
};
mod.functions.push(lambda);
entry.locals = [{ id: "cb", name: "cb", type: callback, mutable: false, boxed: true }];
entry.body.push(
{ kind: "varDecl", localId: "cb", init: closure("predicate"), loc },
{
kind: "exprStmt",
expr: {
kind: "closure",
fnName: "%lambda",
captures: ["cb"],
type: funcOf([], BOOL),
loc,
},
loc,
},
);
expect(facts(mod).get("%lambda")?.get("cb")).toBe("predicate");
entry.locals[0]!.mutable = true;
lambda.locals[0]!.mutable = true;
expect(facts(mod).get("%lambda")?.has("cb")).not.toBe(true);
});
@@ -1,4 +1,5 @@
import type { IrExpr, IrFunction, IrModule } from "../../ir/ir.js";
import { isBorrowSafeMath } from "../../ir/analysis.js";
import type { IrExpr, IrFunction, IrLibFn, IrModule, IrStmt } from "../../ir/ir.js";
import { everyStmtList } from "../../ir/traverse.js";
import type { CallLifetimes } from "./call-lifetimes.js";
@@ -23,7 +24,10 @@ export function findConstantCallbacks(
mod.functions
.filter(
(fn) =>
fn.ownsPrototype &&
// Authored declarations, plus the frontend's synthetic array
// HOF loops (`%arr.<method>.<n>`): those are plain direct-call
// functions, and a literal callback gets a helper of its own.
(fn.ownsPrototype || fn.name.startsWith("%arr.")) &&
!fn.async &&
!fn.generator &&
!fn.captures &&
@@ -60,14 +64,26 @@ export function findConstantCallbacks(
const { functions: _functions, ...metadata } = mod;
externalReferences(metadata);
const incoming = new Map<string, { owner: string; args: IrExpr[] }[]>();
// Where each lifted function's environment is created, for resolving its
// immutable captures in the creating scope.
const closureSites = new Map<string, { owner: string; captures: string[] }[]>();
// The initializer of each immutable function-typed local declared once:
// its only write, even when a capture box (not a stable slot) holds it.
const constDefinitions = new Map<string, Map<string, IrExpr | null>>();
for (const fn of mod.functions) {
const defs = new Map<string, IrExpr>();
const consts = new Map<string, IrExpr | null>();
const immutable = new Set(
fn.locals.filter((local) => !local.mutable && local.type.kind === "func").map((l) => l.id),
);
const callees = new Set<string>();
const stable = lifetimes.bindings.get(fn.name);
everyStmtList(fn.body, {
stmt: (stmt) => {
if (stmt.kind === "varDecl" && stmt.init?.type.kind === "func" && stable?.has(stmt.localId))
defs.set(stmt.localId, stmt.init);
if (stmt.kind === "varDecl" && immutable.has(stmt.localId))
consts.set(stmt.localId, consts.has(stmt.localId) ? null : stmt.init);
if (stmt.kind === "assign") {
if (globalIds.has(stmt.localId)) {
if (globals.has(stmt.localId)) writes.add(stmt.localId);
@@ -84,7 +100,12 @@ export function findConstantCallbacks(
}
if (expr.kind === "callValue" && expr.callee.kind === "varRef")
callees.add(expr.callee.localId);
if (expr.kind === "closure") escaped.add(expr.fnName);
if (expr.kind === "closure") {
escaped.add(expr.fnName);
const sites = closureSites.get(expr.fnName) ?? [];
sites.push({ owner: fn.name, captures: expr.captures });
closureSites.set(expr.fnName, sites);
}
if ((expr.kind === "assignExpr" || expr.kind === "incDec") && globalIds.has(expr.localId))
writes.add(expr.localId);
return true;
@@ -92,6 +113,7 @@ export function findConstantCallbacks(
});
externalReferences(fn.body);
definitions.set(fn.name, defs);
constDefinitions.set(fn.name, consts);
indirectCallees.set(fn.name, callees);
}
for (const id of writes) globals.delete(id);
@@ -104,18 +126,64 @@ export function findConstantCallbacks(
const target = functions.get(value.fnName);
return target && !target.async && !target.generator ? value.fnName : null;
}
if (value.kind === "call") {
// A factory (`compareBy(keys)`, `makeScorer(w)`) whose every return
// resolves to one function. The emitted direct call still checks the
// closure's actual code pointer first.
const factory = functions.get(value.callee);
const key = `%call:${value.callee}`;
if (!factory || factory.async || factory.generator || seen.has(key)) return null;
seen.add(key);
let target: string | null = null;
let returns = 0;
const single = everyStmtList(factory.body, {
stmt: (stmt) => {
if (stmt.kind !== "return") return true;
returns++;
const next = stmt.value ? resolve(factory.name, stmt.value, seen) : null;
if (!next || (target !== null && target !== next)) return false;
target = next;
return true;
},
expr: () => true,
});
return single && returns > 0 ? target : null;
}
if (value.kind !== "varRef" || seen.has(value.localId)) return null;
seen.add(value.localId);
// Resolve a global's initializer in global scope. Its declaring
// function's local names must never resolve in a later caller.
if (seen.size > 64) return null;
if (owner !== null && localIds.get(owner)?.has(value.localId)) {
const next = definitions.get(owner)?.get(value.localId);
return next ? resolve(owner, next, seen) : null;
}
if (owner !== null && localIds.get(owner)?.has(value.localId))
return resolveLocal(owner, value.localId, seen);
const next = globalIds.has(value.localId) ? globals.get(value.localId) : undefined;
return next ? resolve(null, next, seen) : null;
};
// A local's stable definition, or for an immutable capture the binding it
// captures at every site that creates this function's environment.
function resolveLocal(owner: string, id: string, seen: Set<string>): string | null {
const next = definitions.get(owner)?.get(id);
if (next) return resolve(owner, next, seen);
const fn = functions.get(owner);
const index = fn?.captures?.findIndex((capture) => capture.localId === id) ?? -1;
const local = fn?.locals.find((candidate) => candidate.id === id);
const sites = closureSites.get(owner);
if (index < 0 || !local || local.mutable || !sites?.length) return null;
let target: string | null = null;
for (const site of sites) {
const outer = site.captures[index];
const key = `%capture:${site.owner}:${outer}`;
if (!outer || seen.has(key) || !localIds.get(site.owner)?.has(outer)) return null;
seen.add(key);
const declared = constDefinitions.get(site.owner)?.get(outer);
const found = declared
? resolve(site.owner, declared, seen)
: resolveLocal(site.owner, outer, seen);
if (!found || (target !== null && target !== found)) return null;
target = found;
}
return target;
}
// Global targets are module facts. Resolve them once, and copy only
// entries actually used as indirect callees in each body. A module-wide
// global/function cross product would dominate large-source emission.
@@ -131,8 +199,7 @@ export function findConstantCallbacks(
const targets = new Map<string, string>();
for (const id of needed) {
if (localIds.get(fn.name)?.has(id)) {
const value = definitions.get(fn.name)?.get(id);
const target = value ? resolve(fn.name, value) : null;
const target = resolveLocal(fn.name, id, new Set());
if (target) targets.set(id, target);
} else {
const target = globalTargets.get(id);
@@ -167,50 +234,218 @@ export function findConstantCallbacks(
return result;
}
/** A known callback that cannot observe ambient this needs no receiver
* stack bracket. Follow direct calls conservatively; unknown indirect calls
* and all runtime operations except scalar arithmetic stay on the ABI path. */
export function callbackIgnoresReceiver(fn: IrFunction): boolean {
if (fn.async || fn.generator) return false;
return everyStmtList(fn.body, {
stmt: (stmt) => {
switch (stmt.kind) {
case "varDecl":
case "assign":
case "exprStmt":
case "return":
case "if":
case "while":
case "doWhile":
case "for":
case "switch":
case "block":
case "break":
case "continue":
return true;
default:
return false;
/** Expression kinds that run no user code of their own and never consult
* the ambient receiver stack. Calls are admitted here and resolved through
* the module call graph below; callValue brackets its own receiver. */
const RECEIVER_FREE_EXPRS = new Set<IrExpr["kind"]>([
"numLit",
"boolLit",
"strLit",
"unitLit",
"varRef",
"bin",
"unary",
"logical",
"ternary",
"seqExpr",
"incDec",
"fieldIncDec",
"assignExpr",
"toBool",
"toString",
"strConcat",
"strEq",
"strCmp",
"nullish",
"orDefault",
"unionWrap",
"unionNarrow",
"unionIsTag",
"unionEq",
"unionDisc",
"upcast",
"downcast",
"instanceOf",
"fieldGet",
"recordGet",
"recordLit",
"recordClone",
"arrayGet",
"arrayHas",
"arrayState",
"arrayLit",
"arrayNewLen",
"selfRef",
"closure",
"callValue",
"call",
"new",
"virtualCall",
"libCall",
"arrIntrinsic",
"strIntrinsic",
"mapIntrinsic",
"setIntrinsic",
"mapNew",
"setNew",
]);
const RECEIVER_FREE_STMTS = new Set<IrStmt["kind"]>([
"varDecl",
"assign",
"exprStmt",
"return",
"if",
"while",
"doWhile",
"for",
"switch",
"block",
"break",
"continue",
"fieldSet",
"recordSet",
"arraySet",
"arraySetUndefined",
"arraySetLength",
"throw",
"rethrow",
"tryCatch",
// The deferred compile fence throws a fixed catchable Error
// (scr_throw_error_msg_code).
"runtimeFence",
]);
/** Runtime library calls beyond scalar Math that only compute or build and
* park a native error: the f64[] folds behind Math.max/min(...xs),
* `new Error(message)` (scr_error_new), and the checked-read TypeError
* (scr_throw_node_coded). */
const RECEIVER_FREE_LIB_FNS = new Set<IrLibFn>([
"math.maxArr",
"math.minArr",
"error.new",
"error.nodeThrow",
]);
/** Typed container and string operations. Their runtime paths compare and
* copy values but never call back into compiled code, as long as no
* operand is a function or a checked/engine value. */
/** Operands of a data intrinsic (receiver first), or null when `e` is not
* one. A switch over the kinds keeps this inside the self-hosted subset,
* which has no `in` on IR expression unions. */
function dataIntrinsicOperands(e: IrExpr): IrExpr[] | null {
switch (e.kind) {
case "arrIntrinsic":
case "strIntrinsic":
case "mapIntrinsic":
case "setIntrinsic":
return [e.receiver, ...e.args];
case "mapNew":
case "setNew":
return [];
default:
return null;
}
}
function opaqueOperand(e: IrExpr): boolean {
const kind = e.type.kind;
return kind === "func" || kind === "dyn" || kind === "jsval" || kind === "caught";
}
/** The functions that may observe the ambient receiver stack when entered
* without a fresh binding: the JS `this` read (libCall dyn.this), async and
* generator creation (which snapshot it), dynamic and engine operations,
* diagnostics-channel stores, and anything outside the admitted kinds; then
* every function that can reach one through a direct call, constructor or
* virtual dispatch (a backward reachability fixpoint, so recursion such as
* a tree walker's `evaluate` resolves exactly). A known callback outside
* this set needs no receiver bracket. */
export class AmbientReceiverReaders {
private readers: Set<string> | null = null;
constructor(private readonly functions: ReadonlyMap<string, IrFunction>) {}
ignores(fn: IrFunction): boolean {
if (fn.async || fn.generator) return false;
return !this.compute().has(fn.name);
}
private compute(): Set<string> {
if (this.readers) return this.readers;
const byMethod = new Map<string, string[]>();
for (const name of this.functions.keys()) {
const dot = name.lastIndexOf(".");
if (!name.startsWith("%") || dot < 0) continue;
const method = name.slice(dot + 1);
const list = byMethod.get(method) ?? [];
list.push(name);
byMethod.set(method, list);
}
const readers = new Set<string>();
const callers = new Map<string, string[]>();
for (const fn of this.functions.values()) {
const callees = new Set<string>();
let reads = fn.async === true || fn.generator !== undefined;
const edge = (name: string): boolean => {
if (!this.functions.has(name)) return false;
callees.add(name);
return true;
};
if (!reads)
reads = !everyStmtList(fn.body, {
stmt: (stmt) => RECEIVER_FREE_STMTS.has(stmt.kind),
expr: (expr) => {
if (!RECEIVER_FREE_EXPRS.has(expr.kind)) return false;
switch (expr.kind) {
case "call":
return edge(expr.callee);
case "new":
return edge(`%${expr.className}.constructor`);
case "virtualCall": {
// Any class's override may run: admit only when every
// same-named method is itself receiver-free.
const targets = byMethod.get(expr.method) ?? [];
return targets.length > 0 && targets.every(edge);
}
case "libCall":
return isBorrowSafeMath(expr.fn) || RECEIVER_FREE_LIB_FNS.has(expr.fn);
case "toString":
return !opaqueOperand(expr.operand);
default: {
const operands = dataIntrinsicOperands(expr);
return operands === null || !operands.some(opaqueOperand);
}
}
},
});
if (reads) readers.add(fn.name);
for (const callee of callees) {
const list = callers.get(callee) ?? [];
list.push(fn.name);
callers.set(callee, list);
}
},
expr: (expr) => {
switch (expr.kind) {
case "numLit":
case "boolLit":
case "strLit":
case "unitLit":
case "varRef":
case "bin":
case "unary":
case "logical":
case "ternary":
case "seqExpr":
case "incDec":
case "assignExpr":
case "toBool":
return true;
default:
return false;
}
const work = [...readers];
while (work.length > 0) {
for (const caller of callers.get(work.pop()!) ?? []) {
if (readers.has(caller)) continue;
readers.add(caller);
work.push(caller);
}
},
});
}
this.readers = readers;
return readers;
}
}
/** A known callback that cannot observe ambient this needs no receiver
* stack bracket. Standalone form of AmbientReceiverReaders over the given
* functions (by default only the callback itself, so any call stays on the
* ABI path). */
export function callbackIgnoresReceiver(
fn: IrFunction,
functions: ReadonlyMap<string, IrFunction> = new Map([[fn.name, fn]]),
): boolean {
return new AmbientReceiverReaders(functions).ignores(fn);
}
@@ -0,0 +1,176 @@
import { expect, test } from "vitest";
import {
BOOL,
F64,
STRING,
VOID,
arrayOf,
type IrExpr,
type IrModule,
type IrStmt,
} from "../../ir/ir.js";
import { emitLlvmModule } from "./emitter.js";
const loc = { file: "dense-array-access.ts", start: 0, end: 0 };
const nums = arrayOf(F64);
const flags = arrayOf(BOOL);
const words = arrayOf(STRING);
const ref = (localId: string, type: IrExpr["type"]): IrExpr => ({
kind: "varRef",
localId,
type,
loc,
});
const index = ref("i", F64);
function module(name: string, returnType: IrExpr["type"], body: IrStmt[]): IrModule {
return {
irVersion: 15,
sourceFile: loc.file,
entry: "__main",
functions: [
{ name: "__main", params: [], returnType: VOID, locals: [], body: [], loc },
{
name,
params: [
{ name: "a", localId: "a", type: nums },
{ name: "b", localId: "b", type: flags },
{ name: "s", localId: "s", type: words },
{ name: "i", localId: "i", type: F64 },
],
returnType,
locals: [
{ id: "a", name: "a", type: nums, mutable: false },
{ id: "b", name: "b", type: flags, mutable: false },
{ id: "s", name: "s", type: words, mutable: false },
{ id: "i", name: "i", type: F64, mutable: false },
],
body,
loc,
},
],
};
}
function body(llvm: string, name: string): string {
return new RegExp(`^define internal [^\\n]*@sc_bf_${name}\\([^]*?^}`, "m").exec(llvm)![0];
}
test.each([32, 64] as const)("strict scalar reads check dense storage inline (%i-bit)", (bits) => {
const read: IrExpr = { kind: "arrayGet", arr: ref("a", nums), index, type: F64, loc };
const llvm = emitLlvmModule(module("get", F64, [{ kind: "return", value: read, loc }]), {
pointerBits: bits,
});
const fn = body(llvm, "get");
// A double index must round-trip exactly; NaN/out-of-range conversions are frozen.
expect(fn).toContain("fptosi double");
expect(fn).toContain("freeze i64");
expect(fn).toMatch(/icmp ult i64 .*/);
expect(fn).toMatch(/load i8, ptr /);
expect(fn).toMatch(/icmp eq i8 .*, 1$/m);
expect(fn).toMatch(/load double, ptr /);
expect(fn).toMatch(
/call double @scr_arr_get_f64\(.*\) cold memory\(read, inaccessiblemem: readwrite\)/,
);
if (bits === 32) expect(fn).toContain("trunc i64");
});
test("reference reads retain inline and keep a cold +1 runtime fallback", () => {
const read: IrExpr = { kind: "arrayGet", arr: ref("s", words), index, type: STRING, loc };
const llvm = emitLlvmModule(module("word", STRING, [{ kind: "return", value: read, loc }]));
const fn = body(llvm, "word");
expect(fn).toMatch(/icmp ne ptr .*, null/);
expect(fn).toContain("@scr_str_retain_v(");
// The retaining getter writes reference counts, so it gets no read-only effect.
expect(fn).toMatch(/call ptr @scr_arr_get_ref\(.*\) cold$/m);
});
test("element writes store inline, extend the length, and release replaced references", () => {
const set = (arr: IrExpr, value: IrExpr): IrStmt => ({
kind: "arraySet",
arr,
index,
value,
loc,
});
const llvm = emitLlvmModule(
module("store", VOID, [
set(ref("a", nums), { kind: "numLit", value: 2.5, type: F64, loc }),
set(ref("b", flags), { kind: "boolLit", value: true, type: BOOL, loc }),
set(ref("s", words), { kind: "strLit", value: "x", type: STRING, loc }),
]),
);
const fn = body(llvm, "store");
expect(fn).toMatch(/store double 0x4004000000000000, ptr /);
expect(fn).toMatch(/zext i1 .* to i64/);
expect(fn).toMatch(/store i8 1, ptr /);
expect(fn).toMatch(/icmp uge i64 .*/);
expect(fn).toContain("call void @scr_str_release(");
for (const acc of ["f64", "bool", "ref"]) {
expect(fn).toMatch(new RegExp(`call void @scr_arr_set_${acc}\\(.*\\) cold$`, "m"));
}
});
test.each([32, 64] as const)(
"push appends inline below the capacity and keeps a cold runtime append (%i-bit)",
(bits) => {
const push = (receiver: IrExpr, value: IrExpr): IrStmt => ({
kind: "exprStmt",
expr: { kind: "arrIntrinsic", method: "push", receiver, args: [value], type: F64, loc },
loc,
});
const llvm = emitLlvmModule(
module("append", VOID, [
push(ref("a", nums), { kind: "numLit", value: 2.5, type: F64, loc }),
push(ref("b", flags), { kind: "boolLit", value: true, type: BOOL, loc }),
push(ref("s", words), { kind: "strLit", value: "x", type: STRING, loc }),
]),
{ pointerBits: bits },
);
const fn = body(llvm, "append");
const size = bits === 32 ? "i32" : "i64";
expect(fn.match(new RegExp(`icmp ult ${size} `, "g"))).toHaveLength(3);
expect(fn).toMatch(/store double 0x4004000000000000, ptr /);
expect(fn).toMatch(/zext i1 .* to i64/);
expect(fn.match(/store i8 1, ptr /g)).toHaveLength(3);
expect(fn.match(new RegExp(`add nuw ${size} .*, 1$`, "gm"))).toHaveLength(3);
// Appends never release: the slot past the length holds no value.
expect(fn).not.toContain("_release(");
for (const acc of ["f64", "bool", "ref"]) {
expect(fn).toMatch(new RegExp(`call double @scr_arr_push_${acc}\\(.*\\) cold$`, "m"));
}
},
);
test("state and presence queries answer dense slots inline", () => {
const state: IrExpr = { kind: "arrayState", arr: ref("b", flags), index, type: F64, loc };
const has: IrExpr = { kind: "arrayHas", arr: ref("s", words), index, type: BOOL, loc };
const llvm = emitLlvmModule(
module("query", BOOL, [
{
kind: "return",
value: {
kind: "logical",
op: "&&",
left: {
kind: "bin",
op: "===",
left: state,
right: { kind: "numLit", value: 1, type: F64, loc },
type: BOOL,
loc,
},
right: has,
type: BOOL,
loc,
},
loc,
},
]),
);
const fn = body(llvm, "query");
expect(fn).toMatch(/uitofp i8 .* to double/);
expect(fn).toMatch(/icmp ne i8 .*, 0/);
expect(fn).toMatch(/call double @scr_arr_state\(.*\) cold memory\(read/);
expect(fn).toMatch(/call i1 @scr_arr_has\(.*\) cold memory\(read/);
});
@@ -0,0 +1,424 @@
/* Inline dense-storage fast paths for ordinary array element access.
*
* Every strict and optional element operation keeps its runtime entry as
* the authority: the inline path only answers the case the runtime itself
* answers from dense storage without side effects — a canonical integer
* index below both the array length and the dense capacity (and, for
* strict reads, a slot whose state is a present value). Holes, present
* `undefined`, sparse indices, noncanonical numeric properties (negative,
* fractional, NaN, infinite, 2^32-1), and out-of-range reads branch to the
* unchanged runtime call, which is marked cold. The ScrArr field indices
* match the C layout in scr_runtime.h: len 1, cap 2, data 7, present 8. */
import { isRefCounted, type IrExpr, type IrType } from "../../ir/ir.js";
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
import { exactInteger, widenInteger } from "./integer-values.js";
const SCR_ARR_VALUE = 1;
/** Call-site effects of the non-retaining read fallbacks: they only read
* module-visible memory, or trap without returning to the caller (the trap
* writes runtime-private stderr/TLS state). Declaring this lets LLVM keep
* array header loads out of loops whose only calls are these cold paths. */
const READ_FALLBACK = " cold memory(read, inaccessiblemem: readwrite)";
interface DenseGuard {
/** The checked dense offset, in the target's size type. */
offset: string;
/** The array length loaded by the guard, in the target's size type. */
len: string;
/** Dense value and state storage base pointers. */
data: string;
states: string;
}
/** Branch to `slow` unless `index` names a canonical array index inside
* dense storage. Reads also require it below the length; writes may land
* at or past the length while still inside the dense capacity (the runtime
* then extends the length over hole states exactly the same way). */
function emitDenseGuard(
host: LlvmEmitterContext,
array: string,
index: LlValue,
indexExpr: IrExpr | undefined,
slow: string,
requireBelowLen: boolean,
): DenseGuard {
const B = host.B;
const size = host.sizeType;
const integer = exactInteger(host, index, indexExpr);
let wide: string;
let integral: string | null = null;
if (integer) {
// A proven integer needs no double round-trip. Negative signed values
// compare above any length or capacity as unsigned 64-bit integers.
wide = widenInteger(host, integer);
} else {
// freeze keeps NaN/out-of-range conversions from producing poison; the
// exact round-trip comparison then rejects every value that is not an
// integer, and -0 converts to index 0 exactly like the runtime's check.
// Any frozen value that passes the round-trip and the unsigned capacity
// comparison below is the exact index.
const raw = B.tmp(),
back = B.tmp();
wide = B.tmp();
integral = B.tmp();
B.line(`${raw} = fptosi double ${index.name} to i64`);
B.line(`${wide} = freeze i64 ${raw}`);
B.line(`${back} = sitofp i64 ${wide} to double`);
B.line(`${integral} = fcmp oeq double ${back}, ${index.name}`);
}
const lenPtr = B.tmp(),
len = B.tmp(),
capPtr = B.tmp(),
cap = B.tmp();
B.line(`${lenPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 1`);
host.markMemoryPointer(lenPtr, "array:header");
B.line(`${len} = load ${size}, ptr ${lenPtr}${host.fieldAliasAttachment(lenPtr)}`);
B.line(`${capPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 2`);
host.markMemoryPointer(capPtr, "array:header");
B.line(`${cap} = load ${size}, ptr ${capPtr}${host.fieldAliasAttachment(capPtr)}`);
// Load the storage bases before branching: the header of a live array is
// always readable, and unconditional loads let LLVM hoist them out of
// loops whose only calls are read-only fallbacks.
const dataPtr = B.tmp(),
data = B.tmp(),
statesPtr = B.tmp(),
states = B.tmp();
B.line(`${dataPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 7`);
host.markMemoryPointer(dataPtr, "array:header");
B.line(`${data} = load ptr, ptr ${dataPtr}${host.fieldAliasAttachment(dataPtr)}`);
B.line(`${statesPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 8`);
host.markMemoryPointer(statesPtr, "array:header");
B.line(`${states} = load ptr, ptr ${statesPtr}${host.fieldAliasAttachment(statesPtr)}`);
const widen = (value: string): string => {
if (size === "i64") return value;
const t = B.tmp();
B.line(`${t} = zext i32 ${value} to i64`);
return t;
};
const conditions: string[] = [];
if (integral) conditions.push(integral);
const belowCap = B.tmp();
B.line(`${belowCap} = icmp ult i64 ${wide}, ${widen(cap)}`);
conditions.push(belowCap);
if (requireBelowLen) {
const belowLen = B.tmp();
B.line(`${belowLen} = icmp ult i64 ${wide}, ${widen(len)}`);
conditions.push(belowLen);
}
let ok = conditions[0]!;
for (const c of conditions.slice(1)) {
const t = B.tmp();
B.line(`${t} = and i1 ${ok}, ${c}`);
ok = t;
}
const dense = B.newLabel("arr.dense");
B.condBr(ok, dense, slow);
B.startBlock(dense);
let offset = wide;
if (size !== "i64") {
offset = B.tmp();
B.line(`${offset} = trunc i64 ${wide} to i32`);
}
return { offset, len, data, states };
}
function statePointer(host: LlvmEmitterContext, guard: DenseGuard): string {
const B = host.B;
const statePtr = B.tmp();
B.line(
`${statePtr} = getelementptr inbounds i8, ptr ${guard.states}, ${host.sizeType} ${guard.offset}`,
);
host.markMemoryPointer(statePtr, "array:present");
return statePtr;
}
function valuePointer(host: LlvmEmitterContext, guard: DenseGuard): string {
const B = host.B;
const valuePtr = B.tmp();
B.line(
`${valuePtr} = getelementptr inbounds i64, ptr ${guard.data}, ${host.sizeType} ${guard.offset}`,
);
host.markMemoryPointer(valuePtr, "array:elements");
return valuePtr;
}
function loadState(host: LlvmEmitterContext, guard: DenseGuard): string {
const ptr = statePointer(host, guard);
const state = host.B.tmp();
host.B.line(`${state} = load i8, ptr ${ptr}${host.fieldAliasAttachment(ptr)}`);
return state;
}
export type ArrayAccess = "f64" | "bool" | "ref";
/** Strict element read with the runtime getter's result contract: a
* double, an i1, or a pointer that is +1 (`retain`) or borrowed. The
* runtime getter keeps the trap for holes, present undefined, and missing
* properties; it also answers sparse indices. Null reference slots take
* the runtime path, which returns them without a retain. */
export function emitDenseArrayGet(
host: LlvmEmitterContext,
array: string,
index: LlValue,
indexExpr: IrExpr | undefined,
acc: ArrayAccess,
element: IrType,
getter: string,
retain: boolean,
): string {
const B = host.B;
const ty = acc === "f64" ? "double" : acc === "bool" ? "i1" : "ptr";
const ret = acc === "bool" ? "zeroext i1" : ty;
host.declare(`declare ${ret} @${getter}(ptr, double)`);
if (acc === "ref" && retain && !isRefCounted(element)) {
const t = B.tmp();
B.line(`${t} = call ${ty} @${getter}(ptr ${array}, double ${index.name})`);
return t;
}
const slow = B.newLabel("arr.get.slow"),
join = B.newLabel("arr.get.join");
const guard = emitDenseGuard(host, array, index, indexExpr, slow, true);
const state = loadState(host, guard);
const present = B.tmp();
B.line(`${present} = icmp eq i8 ${state}, ${SCR_ARR_VALUE}`);
const valueLabel = B.newLabel("arr.get.value");
B.condBr(present, valueLabel, slow);
B.startBlock(valueLabel);
const ptr = valuePointer(host, guard);
let fast: string;
let fastLabel = valueLabel;
if (acc === "f64") {
fast = B.tmp();
B.line(`${fast} = load double, ptr ${ptr}${host.fieldAliasAttachment(ptr)}`);
} else if (acc === "bool") {
const raw = B.tmp();
fast = B.tmp();
B.line(`${raw} = load i64, ptr ${ptr}${host.fieldAliasAttachment(ptr)}`);
B.line(`${fast} = icmp ne i64 ${raw}, 0`);
} else {
const raw = B.tmp(),
nonnull = B.tmp();
B.line(`${raw} = load ptr, ptr ${ptr}${host.fieldAliasAttachment(ptr)}`);
B.line(`${nonnull} = icmp ne ptr ${raw}, null`);
fastLabel = B.newLabel("arr.get.ref");
B.condBr(nonnull, fastLabel, slow);
B.startBlock(fastLabel);
fast = retain ? host.retainValue(raw, element) : raw;
}
B.br(join);
B.startBlock(slow);
const slowValue = B.tmp();
const attrs = acc === "ref" && retain ? " cold" : READ_FALLBACK;
B.line(`${slowValue} = call ${ty} @${getter}(ptr ${array}, double ${index.name})${attrs}`);
B.br(join);
B.startBlock(join);
const result = B.tmp();
B.line(`${result} = phi ${ty} [ ${fast}, %${fastLabel} ], [ ${slowValue}, %${slow} ]`);
return result;
}
/** `scr_arr_get_number`: a numeric element, or NaN for every missing,
* hole, or present-undefined slot (answered by the runtime). */
export function emitDenseArrayNumber(
host: LlvmEmitterContext,
array: string,
index: LlValue,
indexExpr: IrExpr | undefined,
): string {
return emitDenseArrayGet(
host,
array,
index,
indexExpr,
"f64",
{ kind: "f64" },
"scr_arr_get_number",
false,
);
}
/** `scr_arr_state` (as a double) or `scr_arr_has` (as an i1). Dense
* in-range indices answer from the state byte; everything else keeps the
* runtime query, including canonical indices at or past the length. */
export function emitDenseArrayState(
host: LlvmEmitterContext,
array: string,
index: LlValue,
indexExpr: IrExpr | undefined,
kind: "state" | "has",
): string {
const B = host.B;
const fn = kind === "state" ? "scr_arr_state" : "scr_arr_has";
const ty = kind === "state" ? "double" : "i1";
host.declare(`declare ${kind === "state" ? "double" : "zeroext i1"} @${fn}(ptr, double)`);
const slow = B.newLabel("arr.state.slow"),
join = B.newLabel("arr.state.join");
const guard = emitDenseGuard(host, array, index, indexExpr, slow, true);
const state = loadState(host, guard);
const fast = B.tmp();
if (kind === "state") B.line(`${fast} = uitofp i8 ${state} to double`);
else B.line(`${fast} = icmp ne i8 ${state}, 0`);
const fastLabel = B.newLabel("arr.state.fast");
B.br(fastLabel);
B.startBlock(fastLabel);
B.br(join);
B.startBlock(slow);
const slowValue = B.tmp();
B.line(`${slowValue} = call ${ty} @${fn}(ptr ${array}, double ${index.name})${READ_FALLBACK}`);
B.br(join);
B.startBlock(join);
const result = B.tmp();
B.line(`${result} = phi ${ty} [ ${fast}, %${fastLabel} ], [ ${slowValue}, %${slow} ]`);
return result;
}
/** Element write with `scr_arr_set_*` semantics: the array takes the
* value's reference. A dense index stores the slot, marks it present, and
* extends the length when it lands at or past it; a replaced present
* reference is released after the new edge is published (a release can
* collect cycles). Every other index takes the unchanged runtime call. */
export function emitDenseArraySet(
host: LlvmEmitterContext,
array: string,
index: LlValue,
indexExpr: IrExpr | undefined,
acc: ArrayAccess,
element: IrType,
value: string,
): void {
const B = host.B;
const ty = acc === "f64" ? "double" : acc === "bool" ? "i1" : "ptr";
const setter = `scr_arr_set_${acc}`;
host.declare(`declare void @${setter}(ptr, double, ${acc === "bool" ? "i1 zeroext" : ty})`);
const call = (attrs: string): void =>
B.line(`call void @${setter}(ptr ${array}, double ${index.name}, ${ty} ${value})${attrs}`);
if (acc === "ref" && !isRefCounted(element)) {
call("");
return;
}
const slow = B.newLabel("arr.set.slow"),
join = B.newLabel("arr.set.join");
const guard = emitDenseGuard(host, array, index, indexExpr, slow, false);
const { offset, len } = guard;
const statePtr = statePointer(host, guard);
const valuePtr = valuePointer(host, guard);
let oldState: string | null = null;
let old: string | null = null;
if (acc === "ref") {
oldState = B.tmp();
old = B.tmp();
B.line(`${oldState} = load i8, ptr ${statePtr}${host.fieldAliasAttachment(statePtr)}`);
B.line(`${old} = load ptr, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`);
}
if (acc === "f64") {
B.line(`store double ${value}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`);
} else if (acc === "bool") {
const bits = B.tmp();
B.line(`${bits} = zext i1 ${value} to i64`);
B.line(`store i64 ${bits}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`);
} else {
B.line(`store ptr ${value}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`);
}
B.line(`store i8 ${SCR_ARR_VALUE}, ptr ${statePtr}${host.fieldAliasAttachment(statePtr)}`);
// Writes at or past the length extend it over the (hole) gap, exactly
// like the runtime's dense store.
const grows = B.tmp();
B.line(`${grows} = icmp uge ${host.sizeType} ${offset}, ${len}`);
const growLabel = B.newLabel("arr.set.grow"),
storedLabel = B.newLabel("arr.set.stored");
B.condBr(grows, growLabel, storedLabel);
B.startBlock(growLabel);
const nextLen = B.tmp(),
lenPtr = B.tmp();
B.line(`${nextLen} = add ${host.sizeType} ${offset}, 1`);
B.line(`${lenPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 1`);
host.markMemoryPointer(lenPtr, "array:header");
B.line(`store ${host.sizeType} ${nextLen}, ptr ${lenPtr}${host.fieldAliasAttachment(lenPtr)}`);
B.br(storedLabel);
B.startBlock(storedLabel);
if (acc === "ref" && oldState && old) {
const hadValue = B.tmp();
B.line(`${hadValue} = icmp eq i8 ${oldState}, ${SCR_ARR_VALUE}`);
const releaseLabel = B.newLabel("arr.set.release");
B.condBr(hadValue, releaseLabel, join);
B.startBlock(releaseLabel);
host.releaseValue(old, element);
}
B.br(join);
B.startBlock(slow);
call(" cold");
B.br(join);
B.startBlock(join);
}
/** `scr_arr_push_*` (the array takes the value's reference) returning the
* new length as a double. When `len < cap` the runtime's append is exactly
* a dense store at `len` plus `len + 1`: no sparse entry can exist past the
* length, nothing is released, and the dense capacity never exceeds the
* maximum index. A full array takes the unchanged (cold) runtime call,
* which grows storage. */
export function emitDenseArrayPush(
host: LlvmEmitterContext,
array: string,
acc: ArrayAccess,
value: string,
): string {
const B = host.B;
const size = host.sizeType;
const ty = acc === "f64" ? "double" : acc === "bool" ? "i1" : "ptr";
const pusher = `scr_arr_push_${acc}`;
host.declare(`declare double @${pusher}(ptr, ${acc === "bool" ? "i1 zeroext" : ty})`);
const header = (field: number): string => {
const ptr = B.tmp();
B.line(`${ptr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 ${field}`);
host.markMemoryPointer(ptr, "array:header");
return ptr;
};
const lenPtr = header(1),
len = B.tmp(),
cap = B.tmp(),
room = B.tmp();
B.line(`${len} = load ${size}, ptr ${lenPtr}${host.fieldAliasAttachment(lenPtr)}`);
const capPtr = header(2);
B.line(`${cap} = load ${size}, ptr ${capPtr}${host.fieldAliasAttachment(capPtr)}`);
B.line(`${room} = icmp ult ${size} ${len}, ${cap}`);
const fast = B.newLabel("arr.push.dense"),
slow = B.newLabel("arr.push.slow"),
join = B.newLabel("arr.push.join");
B.condBr(room, fast, slow);
B.startBlock(fast);
const dataPtr = header(7),
data = B.tmp(),
statesPtr = header(8),
states = B.tmp();
B.line(`${data} = load ptr, ptr ${dataPtr}${host.fieldAliasAttachment(dataPtr)}`);
B.line(`${states} = load ptr, ptr ${statesPtr}${host.fieldAliasAttachment(statesPtr)}`);
const statePtr = statePointer(host, { offset: len, len, data, states });
const valuePtr = valuePointer(host, { offset: len, len, data, states });
if (acc === "f64") {
B.line(`store double ${value}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`);
} else if (acc === "bool") {
const bits = B.tmp();
B.line(`${bits} = zext i1 ${value} to i64`);
B.line(`store i64 ${bits}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`);
} else {
B.line(`store ptr ${value}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`);
}
B.line(`store i8 ${SCR_ARR_VALUE}, ptr ${statePtr}${host.fieldAliasAttachment(statePtr)}`);
const next = B.tmp(),
fastValue = B.tmp();
B.line(`${next} = add nuw ${size} ${len}, 1`);
B.line(`store ${size} ${next}, ptr ${lenPtr}${host.fieldAliasAttachment(lenPtr)}`);
B.line(`${fastValue} = uitofp ${size} ${next} to double`);
B.br(join);
B.startBlock(slow);
const slowValue = B.tmp();
B.line(`${slowValue} = call double @${pusher}(ptr ${array}, ${ty} ${value}) cold`);
B.br(join);
B.startBlock(join);
const result = B.tmp();
B.line(`${result} = phi double [ ${fastValue}, %${fast} ], [ ${slowValue}, %${slow} ]`);
return result;
}
+149
View File
@@ -892,6 +892,155 @@ export class LlDyn {
return name;
}
/* ── schema-directed JSON.parse ───────────────────────────────── */
private readonly jsonSchemas = new Map<string, string>();
private readonly jsonParsers = new Map<string, string>();
/** Targets the one-pass parser (scr_json_parse_schema) can build
* directly: number, boolean and string leaves inside arrays and plain
* records (no tuple, index signature, internal '%' slot or `__proto__`
* member; 1..64 fields). Everything else keeps the checked-dynamic
* route alone. */
private jsonSchemaSupported(t: IrType, visiting: Set<string>): boolean {
switch (t.kind) {
case "f64":
case "bool":
case "string":
return true;
case "array":
return this.jsonSchemaSupported(t.elem, visiting);
case "record": {
if (visiting.has(t.shapeId)) return true;
const shape = this.host.recordsById.get(t.shapeId);
if (!shape || shape.tuple || shape.indexValue) return false;
if (shape.fields.length === 0 || shape.fields.length > 64) return false;
visiting.add(t.shapeId);
return shape.fields.every(
(f) =>
!f.name.startsWith("%") &&
f.name !== "__proto__" &&
this.jsonSchemaSupported(f.type, visiting),
);
}
default:
return false;
}
}
/** The ScrJsonSchema constant for t (scr_runtime.h layout). Records list
* fields in declaration order — the order JSON.stringify writes — so the
* runtime's next-field guess usually matches without a scan. */
private jsonSchema(t: IrType): string {
const key = typeKey(t);
const existing = this.jsonSchemas.get(key);
if (existing) return existing;
const host = this.host;
const S = host.sizeType;
const name = `sc_jsch_${this.jsonSchemas.size}`;
this.jsonSchemas.set(key, name);
let body: string;
switch (t.kind) {
case "f64":
case "bool":
case "string": {
const kind = t.kind === "f64" ? 0 : t.kind === "bool" ? 1 : 2;
body = `${S} ${kind}, ${S} 0, ptr null, ptr null, ptr null, ptr null, ptr null`;
break;
}
case "array": {
const elem = this.jsonSchema(t.elem);
const ctor = `${name}_new`;
this.defs.push(
`define internal ptr @${ctor}(${S} %cap) ${FN_ATTRS} {`,
`entry:`,
` %a = ${arrNewCall(host, t.elem, "%cap")}`,
` ret ptr %a`,
`}`,
``,
);
body = `${S} 4, ${S} 0, ptr null, ptr @${elem}, ptr null, ptr @${ctor}, ptr ${vAdapters(host, t).release}`;
break;
}
case "record": {
const shape = host.recordsById.get(t.shapeId);
if (!shape)
throw new InternalCompilerError(
`llvm emitter bug: JSON schema of unknown shape ${t.shapeId}`,
);
const struct = mangleRecordStruct(t.shapeId);
const index = new Map(shape.fields.map((f, i) => [f.name, i + 1]));
const order = [...(shape.declaredOrder ?? []).filter((n) => index.has(n))];
for (const f of shape.fields) if (!order.includes(f.name)) order.push(f.name);
const fieldTy = `{ ptr, ${S}, ${S}, ptr }`;
const entries = order.map((fieldName) => {
const field = shape.fields[index.get(fieldName)! - 1]!;
const sub = this.jsonSchema(field.type);
const offset = `ptrtoint (ptr getelementptr (%${struct}, ptr null, i64 0, i32 ${index.get(fieldName)}) to ${S})`;
return `${fieldTy} { ptr ${host.cstr(fieldName)}, ${S} ${Buffer.byteLength(fieldName, "utf8")}, ${S} ${offset}, ptr @${sub} }`;
});
const fields = `${name}_fields`;
this.defs.push(
`@${fields} = internal constant [${entries.length} x ${fieldTy}] [${entries.join(", ")}]`,
``,
);
body = `${S} 3, ${S} ${entries.length}, ptr @${fields}, ptr null, ptr @${mangleRecordNew(t.shapeId)}, ptr null, ptr ${vAdapters(host, t).release}`;
break;
}
default:
throw new InternalCompilerError(`llvm emitter bug: JSON schema of ${t.kind}`);
}
this.defs.push(
`@${name} = internal constant { ${S}, ${S}, ptr, ptr, ptr, ptr, ptr } { ${body} }`,
``,
);
return name;
}
/** `sc_jp_<n>(ptr text) -> T` for `JSON.parse(text) as T`, or null when T
* is not a schema target. Tries the one-pass schema parser; when it
* declines (any input the checked route might reject or read
* differently), runs the checked-dynamic route — scr_json_parse plus the
* dynCheck builder — which reports the exact error. Returns +1, or null
* with the pending flag set. */
jsonParseHelper(t: IrType): string | null {
if (t.kind !== "record" && t.kind !== "array") return null;
const key = typeKey(t);
const existing = this.jsonParsers.get(key);
if (existing) return existing;
if (!this.jsonSchemaSupported(t, new Set())) return null;
const host = this.host;
const name = `sc_jp_${this.jsonParsers.size}`;
this.jsonParsers.set(key, name);
const schema = this.jsonSchema(t);
const check = this.dynCheckHelper(t);
host.declare(`declare ptr @scr_json_parse_schema(ptr, ptr)`);
host.declare(`declare ptr @scr_json_parse(ptr)`);
host.declare(`declare void @scr_dyn_release(ptr)`);
this.defs.push(
`define internal ptr @${name}(ptr %text) ${FN_ATTRS} { ; JSON.parse as ${llvmCommentText(key)}`,
`entry:`,
` %fast = call ptr @scr_json_parse_schema(ptr %text, ptr @${schema})`,
` %hit = icmp ne ptr %fast, null`,
` br i1 %hit, label %done, label %slow`,
`done:`,
` ret ptr %fast`,
`slow:`,
` %d = call ptr @scr_json_parse(ptr %text)`,
` %bad = icmp eq ptr %d, null`,
` br i1 %bad, label %fail, label %check`,
`fail:`,
` ret ptr null`,
`check:`,
` %v = call ptr @${check}(ptr %d, ptr null)`,
` call void @scr_dyn_release(ptr %d)`,
` ret ptr %v`,
`}`,
``,
);
return name;
}
/* ── dynCheckHelper (walkers.ts, ported) ──────────────────────── */
/** `sc_dc_<n>(ptr d, ptr path) -> T` — validate the checked-dynamic tree against T and
@@ -106,16 +106,40 @@ test("throwing-call count does not multiply identical scope cleanup", () => {
}
});
test("inline RC is a speed-only emission mode and leaves release IR unchanged", () => {
for (const boxed of [false, true]) {
const module = moduleFor(Array.from({ length: 4 }, call), { boxed });
const release = emitLlvmModule(module);
expect(emitLlvmModule(module, { inlineRc: false })).toBe(release);
expect(release).not.toContain("@sc_rc_");
expect(release).not.toContain("%ScrMapRc");
const speed = emitLlvmModule(module, { inlineRc: true });
const helper = boxed ? "sc_rc_release_box" : "sc_rc_release_str";
const runtime = boxed ? "scr_box_release" : "scr_str_release";
expect(release.match(new RegExp(`call void @${runtime}\\(ptr`, "g"))?.length).toBeGreaterThan(
0,
);
expect(speed).toContain(`define internal void @${helper}(ptr %o)`);
// Speed calls the runtime release only from the helper's cold slow path.
expect(speed.match(new RegExp(`call void @${runtime}\\(ptr`, "g"))).toHaveLength(1);
expect(speed.match(new RegExp(`call void @${helper}\\(ptr`, "g"))?.length).toBe(
release.match(new RegExp(`call void @${runtime}\\(ptr`, "g"))?.length,
);
}
});
test("cleanup snapshots keep locals declared after an earlier throw separate", () => {
// An owned value: a stable binding of an immortal literal needs no cleanup.
const owned: IrExpr = {
kind: "strConcat",
left: literal("cre"),
right: literal("ated"),
type: STRING,
loc,
};
const module = moduleFor([
call(),
// An owned initializer: a stable literal binding needs no cleanup.
{
kind: "varDecl",
localId: "later",
init: { kind: "strConcat", left: literal("cre"), right: literal("ated"), type: STRING, loc },
loc,
},
{ kind: "varDecl", localId: "later", init: owned, loc },
call(),
]);
const blocks = cleanupBlocks(module);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,68 @@
import { expect, test } from "vitest";
import { BOOL, F64, STRING, VOID, type IrExpr, type IrModule } from "../../ir/ir.js";
import { validateModule } from "../../ir/validate.js";
import { emitLlvmModule } from "./emitter.js";
const loc = { file: "console.ts", start: 0, end: 1 };
const text = (value: string): IrExpr => ({ kind: "strLit", value, type: STRING, loc });
const ref = (localId: string, type = STRING): IrExpr => ({ kind: "varRef", localId, type, loc });
const str = (operand: IrExpr): IrExpr => ({ kind: "toString", operand, type: STRING, loc });
const concat = (left: IrExpr, right: IrExpr): IrExpr => ({
kind: "strConcat",
left,
right,
type: STRING,
loc,
});
function logBody(name: "console.log" | "console.error", args: IrExpr[]): string {
const mod: IrModule = {
irVersion: 15,
sourceFile: loc.file,
entry: "main",
functions: [
{ name: "main", loc, params: [], locals: [], returnType: VOID, body: [] },
{
name: "report",
loc,
params: [
{ name: "label", localId: "label", type: STRING },
{ name: "n", localId: "n", type: F64 },
{ name: "ok", localId: "ok", type: BOOL },
],
locals: [
{ id: "label", name: "label", type: STRING, mutable: false },
{ id: "n", name: "n", type: F64, mutable: false },
{ id: "ok", name: "ok", type: BOOL, mutable: false },
],
returnType: VOID,
body: [{ kind: "exprStmt", expr: { kind: "intrinsic", name, args, type: VOID, loc }, loc }],
},
],
};
expect(validateModule(mod)).toEqual([]);
return /^define internal [^\n]*@sc_bf_report\([^]*?^}/m.exec(emitLlvmModule(mod))![0];
}
test("a template-literal argument passes its parts without joining them", () => {
// console.log(`${label} n=${n} ok=${ok}`, n)
const template = concat(
concat(concat(concat(ref("label"), text(" n=")), str(ref("n", F64))), text(" ok=")),
str(ref("ok", BOOL)),
);
const body = logBody("console.log", [template, ref("n", F64)]);
expect(body).toContain("call void @scr_console_log_parts(i64 6, ptr %logargs)");
expect(body).not.toContain("@scr_str_concat");
expect(body).not.toContain("@scr_f64_to_scrstr");
expect(body).not.toContain("@scr_bool_to_scrstr");
// Tags: label (string), " n=" (glued string), n (glued String() number),
// " ok=" (glued string), ok (glued bool), then n as its own argument.
const tags = [...body.matchAll(/store i32 (\d+), ptr/g)].map((m) => Number(m[1]));
expect(tags).toEqual([1, 0x101, 0x103, 0x101, 0x102, 0]);
});
test("plain arguments keep the original entry points", () => {
const body = logBody("console.error", [ref("label"), ref("n", F64), ref("ok", BOOL)]);
expect(body).toContain("call void @scr_console_error(i64 3, ptr %logargs)");
expect(body).not.toContain("_parts");
});
@@ -1,7 +1,9 @@
import { emitBorrowedInput } from "./borrowed-inputs.js";
/* Focused LLVM expression emission extracted from emitter.ts. */
import { InternalCompilerError } from "../../errors.js";
import { type IrType, isRefCounted, isUnitType, typeEquals } from "../../ir/ir.js";
import { type IrExpr, type IrType, isRefCounted, isUnitType, typeEquals } from "../../ir/ir.js";
import { stringParts } from "./string-construction.js";
import { emitStringInputs } from "./string-lifetimes.js";
import { mangleRecordNew } from "../mangle.js";
import { arrNewCall, traceAdapter, traceArg, vAdapters } from "./shapes.js";
import { LlvmUnsupportedError } from "./unsupported.js";
@@ -184,12 +186,43 @@ export function emitIntrinsicExpr(host: LlvmEmitterContext, e: ExprOf<"intrinsic
// function's max arity), tag + 8-byte union slot per argument.
// String args are BORROWED — their temps stay frame-owned and
// release at statement end, after the call.
const args = e.args.map((a) => host.emitExpr(a));
//
// A string-concatenation argument (template literal, `"a" + n`)
// passes its parts instead of the joined string: later parts carry
// the GLUE flag (no separating space), and number/boolean conversions
// pass the raw value (SCR_ARG_NUM keeps String()'s "-0" -> "0"). The
// runtime renders straight into the line it writes, so the parts'
// strings and the joined copy are never allocated. Parts evaluate in
// source order with concatenation's borrowing rules.
const plan: { expr: IrExpr; glue: boolean; num: boolean }[] = [];
for (const a of e.args) {
if (a.kind !== "strConcat") {
plan.push({ expr: a, glue: false, num: false });
continue;
}
stringParts(a).forEach((part, j) => {
const operand = part.kind === "toString" ? part.operand : undefined;
if (
operand !== undefined &&
(operand.type.kind === "f64" || operand.type.kind === "bool")
)
plan.push({ expr: operand, glue: j > 0, num: operand.type.kind === "f64" });
else plan.push({ expr: part, glue: j > 0, num: false });
});
}
const usesParts = plan.length !== e.args.length;
const args = usesParts
? emitStringInputs(
host,
plan.map((p) => p.expr),
)
: e.args.map((a) => host.emitExpr(a));
host.logArgSlots = Math.max(host.logArgSlots, Math.max(args.length, 1));
args.forEach((a, i) => {
const tagOf: Record<string, number> = { f64: 0, string: 1, bool: 2 };
const tag = tagOf[a.type.kind];
if (tag === undefined) throw new LlvmUnsupportedError(`logArg:${a.type.kind}`, e.loc);
const kind = usesParts && plan[i]!.num ? 3 : tagOf[a.type.kind];
if (kind === undefined) throw new LlvmUnsupportedError(`logArg:${a.type.kind}`, e.loc);
const tag = usesParts && plan[i]!.glue ? kind | 0x100 : kind;
const tp = B.tmp();
const vp = B.tmp();
B.line(`${tp} = getelementptr inbounds %ScrLogArg, ptr %logargs, i64 ${i}, i32 0`);
@@ -203,7 +236,9 @@ export function emitIntrinsicExpr(host: LlvmEmitterContext, e: ExprOf<"intrinsic
B.line(`store i8 ${z}, ptr ${vp}`);
}
});
const fn = e.name === "console.error" ? "scr_console_error" : "scr_console_log";
const fn =
(e.name === "console.error" ? "scr_console_error" : "scr_console_log") +
(usesParts ? "_parts" : "");
host.declare(`declare void @${fn}(${host.sizeType}, ptr)`);
B.line(`call void @${fn}(${host.sizeType} ${args.length}, ptr %logargs)`);
return { name: "", type: e.type };
@@ -278,6 +313,20 @@ export function emitSerializationExpr(
// path-annotated TypeError. The dyn temp is BORROWED; the result
// joins the frame BEFORE the pending check so an unwind releases
// the dummy harmlessly.
// `JSON.parse(text) as T` fuses into one schema-directed parse that
// falls back to exactly this route whenever the fast path declines.
const parsed = e.value;
if (!e.preserveRefs && parsed.kind === "libCall" && parsed.fn === "json.parse") {
const fused = host.dyn.jsonParseHelper(e.type);
if (fused) {
const text = emitBorrowedInput(host, parsed.args[0]!);
const r = B.tmp();
B.line(`${r} = call ptr @${fused}(ptr ${text.name})`);
const fusedOut = host.own({ name: r, type: e.type });
host.emitPendingCheck();
return fusedOut;
}
}
const dynV = emitBorrowedInput(host, e.value);
const helper = host.dyn.dynCheckHelper(e.type, e.preserveRefs);
const ty = host.llType(e.type);
@@ -1,6 +1,5 @@
/* Focused LLVM expression emission extracted from emitter.ts. */
import { InternalCompilerError } from "../../errors.js";
import { callbackIgnoresReceiver } from "./constant-callbacks.js";
import { newValueMayThrow } from "../../ir/analysis.js";
import {
isFfiCallbackParam,
@@ -82,6 +81,8 @@ export function emitCallExpr(
return result.value;
}
if (canStackUnion(a, host.unionsById)) return emitStackUnion(host, a).value;
if (host.nullableFieldGet(a))
return { name: host.emitOwnedNullableStack(a).box, type: a.type };
}
if (inputs[index]) return host.emitReadReceiver(a);
}
@@ -658,7 +659,7 @@ export function emitCallExpr(
};
let t: string;
if (direct && e.callee.kind === "closure") {
t = invoke(direct, !callbackIgnoresReceiver(known!));
t = invoke(direct, !host.receiverReaders.ignores(known!));
} else {
const fnp = B.tmp(),
fn = B.tmp();
@@ -677,7 +678,7 @@ export function emitCallExpr(
B.line(`${matches} = icmp eq ptr ${fn}, ${direct}`);
B.condBr(matches, fast, fallback);
B.startBlock(fast);
const specialized = invoke(direct, !callbackIgnoresReceiver(known!));
const specialized = invoke(direct, !host.receiverReaders.ignores(known!));
B.br(done);
B.startBlock(fallback);
const indirect = invoke(fn, true);
@@ -717,6 +718,49 @@ export function emitCallExpr(
const o = B.tmp();
B.line(`${o} = call ptr @${mangleClassNew(e.className)}()`);
const out = host.own({ name: o, type: e.type });
const ctorName = `%${e.className}.constructor`;
const borrowed = host.callLifetimes.borrowed.get(ctorName);
if (borrowed) {
// The borrowed constructor body, like a direct call: this frame
// owns the new object across the call, borrowed arguments stay
// owned by a call frame, and projection-only parameters (stores
// into nullable-pointer fields, tests) receive private stack boxes.
const projected = host.callLifetimes.parameters.get(ctorName);
const inputs = borrowableInputs(
host,
e.args,
host.referenceEffects.functions.has(ctorName),
);
host.frames.push([]);
const args = e.args.map((a, i) => {
if (borrowed.has(i + 1)) {
if (projected?.has(i + 1) && canStackUnion(a, host.unionsById))
return emitStackUnion(host, a).value;
if (projected?.has(i + 1) && host.nullableFieldGet(a))
return { name: host.emitOwnedNullableStack(a).box, type: a.type };
if (inputs[i]) return host.emitReadReceiver(a);
}
return host.emitExpr(a);
});
args.forEach((a, i) => {
if (!borrowed.has(i + 1)) host.moveTemp(a);
});
const self = borrowed.has(0)
? o
: (() => {
const r = B.tmp();
B.line(`${r} = call ptr @${mangleClassRetain(e.className)}(ptr ${o})`);
return r;
})();
const argList = [
`ptr ${self}`,
...args.map((a, i) => `${host.llType(ctor.params[i + 1]!.type)} ${a.name}`),
].join(", ");
B.line(`call void @${mangleBorrowedFunction(ctorName)}(${argList})`);
if (host.mayThrow.has(ctorName)) host.emitPendingCheck();
host.releaseFrame(host.frames.pop()!);
return out;
}
const args = e.args.map((a) => host.emitExpr(a));
for (const a of args) host.moveTemp(a);
const r = B.tmp();
@@ -24,6 +24,7 @@ import {
} from "./map-read-lifetimes.js";
import { emitBorrowedInput } from "./borrowed-inputs.js";
import { emitStringSliceRead } from "./string-slices.js";
import { emitDenseArrayNumber, emitDenseArrayPush } from "./dense-array-access.js";
export function resolveThunkFor(host: LlvmEmitterContext, inner: IrType): string {
const key = typeKey(inner);
@@ -69,13 +70,7 @@ export function arrPush(
acc: "f64" | "bool" | "ref",
value: string,
): string {
const argTy = acc === "f64" ? "double" : acc === "bool" ? "i1" : "ptr";
host.declare(
`declare double @scr_arr_push_${acc}(ptr, ${argTy === "i1" ? "i1 zeroext" : argTy})`,
);
const t = host.B.tmp();
host.B.line(`${t} = call double @scr_arr_push_${acc}(ptr ${arr}, ${argTy} ${value})`);
return t;
return emitDenseArrayPush(host, arr, acc, value);
}
/** Move evaluated values through one bounded slot buffer per call site. */
@@ -421,6 +416,8 @@ export function emitArrIntrinsic(
e.receiver.kind === "varRef"
? host.constantNumericTables.get(e.receiver.localId)
: undefined;
if (!table && elem.kind === "f64")
return { name: emitDenseArrayNumber(host, r.name, index, e.args[0]), type: e.type };
host.declare(`declare double @scr_arr_get_number(ptr, double)`);
const t = B.tmp();
B.line(
@@ -595,6 +592,20 @@ export function emitArrIntrinsic(
);
return host.own({ name: result, type: e.type });
}
case "sortValues": {
// The runtime invokes the borrowed comparator with the callValue ABI
// and stops at the first pending exception.
const count = host.emitExpr(e.args[0]!);
const comparator = emitBorrowedInput(host, e.args[1]!);
const cmp = e.args[1]!.type;
const arity = cmp.kind === "func" ? cmp.params.length : 0;
host.declare(`declare void @scr_arr_sort_values(ptr, double, ptr, i32)`);
B.line(
`call void @scr_arr_sort_values(ptr ${r.name}, double ${count.name}, ptr ${comparator.name}, i32 ${arity})`,
);
host.emitPendingCheck();
return { name: "", type: e.type };
}
case "reverse": {
// Mutates in place and returns the same receiver as a fresh +1.
host.declare(`declare ptr @scr_arr_reverse(ptr)`);
@@ -70,7 +70,7 @@ export function emitControlExpr(
return out;
}
case "toBool":
return { name: host.truthy(host.emitReadReceiver(e.operand)), type: e.type };
return { name: host.truthy(host.emitUnionProjection(e.operand)), type: e.type };
case "logical": {
// JS value semantics: the result is the deciding operand itself.
// Left evaluates once, ownership moves into the result slot; when
@@ -488,6 +488,38 @@ export function emitControlExpr(
throw new InternalCompilerError("llvm emitter bug: nullish union lacks unit arms");
const fused = emitNullishOptionalChain(host, e);
if (fused) return fused;
if (
!typeEquals(e.type, e.left.type) &&
(e.left.kind === "varRef" ||
host.isStackUnionSource(e.left) ||
host.nullableFieldGet(e.left) !== null)
) {
// Narrowed shape over a binding or a stack box (`arr[i] ?? d`,
// `map.get(k) ?? d`): test the tag and extract the payload (+1).
// The box is never retained or released. A binding is borrowed:
// no code runs between its read and the extraction, and the
// default never touches it. Bindings proven projection-only may
// hold stack boxes, which must never reach RC entry points.
const l = host.emitUnionProjection(e.left);
const ty = host.llType(e.type);
const slot = B.slot();
B.entryAllocas.push(`${slot} = alloca ${ty}`);
const isUnit = host.tagInSet(l.name, unitTags);
const lu = B.newLabel("nul.u");
const lv = B.newLabel("nul.v");
const lj = B.newLabel("nul.j");
B.condBr(isUnit, lu, lv);
B.startBlock(lu);
host.emitBranchInto(slot, e.right);
B.br(lj);
B.startBlock(lv);
B.line(`store ${ty} ${host.unionExtract(l.name, e.type)}, ptr ${slot}`);
B.br(lj);
B.startBlock(lj);
const t = B.tmp();
B.line(`${t} = load ${ty}, ptr ${slot}`);
return host.own({ name: t, type: e.type });
}
const l = host.emitExpr(e.left);
host.moveTemp(l);
const ty = host.llType(e.type);
@@ -305,7 +305,7 @@ export function emitDynamicExpr(
// Tag-UNCHECKED payload extraction: the frontend emits this only
// where tsc's control-flow narrowing proved the tag. Ref payloads
// come out +1. The receiver is consumed before any later expression.
const u = host.emitReadReceiver(e.value);
const u = host.emitUnionProjection(e.value);
const arm = e.type;
if (isUnitType(arm))
throw new InternalCompilerError(`llvm emitter bug: unionNarrow to unit arm ${arm.kind}`);
@@ -316,7 +316,7 @@ export function emitDynamicExpr(
// Shared-field read `r.kind`: switch on the runtime tag and read
// the (same-typed) field from the concretely-typed payload.
// Ref-counted results come out retained (+1), owned by this frame.
const u = host.emitReadReceiver(e.value);
const u = host.emitUnionProjection(e.value);
const def = host.unionsById.get(e.unionId);
if (!def)
throw new InternalCompilerError(
@@ -338,9 +338,15 @@ export function emitDynamicExpr(
arm.kind === "object"
? host.classFieldPtr(payload, arm.className, e.field)
: host.recordFieldPtr(payload, arm.shapeId, e.field);
const nullable =
arm.kind === "object" ? host.nullableFields.get(arm.className, e.field) : null;
const v =
arm.kind === "object" ? host.loadField(ptr, type) : host.loadRecordField(ptr, type);
const value = isRefCounted(e.type) ? host.retainValue(v, e.type) : v;
const value = nullable
? host.nullableToOwnedUnion(v, nullable)
: isRefCounted(e.type)
? host.retainValue(v, e.type)
: v;
B.line(`store ${ty} ${value}, ptr ${slot}`);
B.br(join);
},
@@ -486,7 +492,7 @@ export function emitDynamicExpr(
}
case "unionIsTag": {
// A pure tag compare — the box is borrowed, no payload is touched.
const u = host.emitReadReceiver(e.value);
const u = host.emitUnionProjection(e.value);
const tag = host.unionTag(u.name);
const t = B.tmp();
B.line(`${t} = icmp ${e.negated ? "ne" : "eq"} i32 ${tag}, ${e.tag}`);
@@ -560,7 +566,7 @@ export function emitDynamicExpr(
B.line(`${pv} = getelementptr inbounds i8, ptr ${d.name}, i64 16 ; ->v.str`);
const sv = B.tmp();
B.line(`${sv} = load ptr, ptr ${pv}`);
B.line(`${eq} = call zeroext i1 @scr_str_eq(ptr ${sv}, ptr ${s.name})`);
B.line(`${eq} = call zeroext i1 @sc_str_eq(ptr ${sv}, ptr ${s.name})`);
} else if (st.kind === "f64") {
B.line(`${pv} = getelementptr inbounds i8, ptr ${d.name}, i64 16 ; ->v.num`);
const nv = B.tmp();
@@ -734,9 +740,23 @@ export function emitDynamicExpr(
const direct = emitUnionEqWrappedScalar(host, e);
if (direct) return direct;
// Strict equality of the ARM values (tag compare + per-arm payload
// compare — the C per-union helper, inlined). Both boxes borrowed;
// a fresh operand box can live on the stack for the comparison.
const inputs = emitUnionEqInputs(host, e.left, e.right);
// compare — the C per-union helper, inlined). Both boxes borrowed.
// Stack-representable operands (wrapped literals, optional array
// reads, Map.get results) and stack-boxed locals never allocate: the
// compare only reads tags and payloads, and each payload has its own
// owner, so later operands cannot invalidate an earlier stack box.
// A borrowed nullable-pointer field read projects its pointer the
// same way; borrowableInputs already proved later operands cannot
// replace the field.
const operands = [e.left, e.right];
const borrowed = borrowableInputs(host, operands);
const inputs = operands.map((value, index) =>
borrowed[index] ||
host.isStackUnionSource(value) ||
(value.kind === "varRef" && host.isStackUnionLocal(value.localId))
? host.emitUnionProjection(value)
: host.emitExpr(value),
);
const l = inputs[0]!,
r = inputs[1]!;
const def = host.unionsById.get(e.unionId);
@@ -760,12 +780,9 @@ export function emitDynamicExpr(
B.line(`store i1 true, ptr ${slot}`);
break;
case "f64": {
host.declare(`declare double @scr_union_get_f64(ptr)`);
const a = B.tmp();
const b = B.tmp();
const a = host.unionGetF64(l.name);
const b = host.unionGetF64(r.name);
const t = B.tmp();
B.line(`${a} = call double @scr_union_get_f64(ptr ${l.name})`);
B.line(`${b} = call double @scr_union_get_f64(ptr ${r.name})`);
if (e.sameValue) {
// Object.is's f64 compare: NaN equals NaN, +0 differs
// from -0 — the runtime SameValue.
@@ -778,12 +795,9 @@ export function emitDynamicExpr(
break;
}
case "bool": {
host.declare(`declare zeroext i1 @scr_union_get_bool(ptr)`);
const a = B.tmp();
const b = B.tmp();
const a = host.unionGetBool(l.name);
const b = host.unionGetBool(r.name);
const t = B.tmp();
B.line(`${a} = call zeroext i1 @scr_union_get_bool(ptr ${l.name})`);
B.line(`${b} = call zeroext i1 @scr_union_get_bool(ptr ${r.name})`);
B.line(`${t} = icmp eq i1 ${a}, ${b}`);
B.line(`store i1 ${t}, ptr ${slot}`);
break;
@@ -793,7 +807,7 @@ export function emitDynamicExpr(
const a = host.unionPeek(l.name);
const b = host.unionPeek(r.name);
const t = B.tmp();
B.line(`${t} = call zeroext i1 @scr_str_eq(ptr ${a}, ptr ${b})`);
B.line(`${t} = call zeroext i1 @sc_str_eq(ptr ${a}, ptr ${b})`);
B.line(`store i1 ${t}, ptr ${slot}`);
break;
}
@@ -1045,19 +1059,6 @@ export function emitDynamicExpr(
}
}
/** Equality only reads both tags and payloads. Operands that would be fresh
* heap boxes use a private stack box whose payload snapshot is owned by the
* frame; other operands keep the ordinary borrowed-input rules. */
function emitUnionEqInputs(host: LlvmEmitterContext, left: IrExpr, right: IrExpr): LlValue[] {
const inputs = [left, right];
const borrowed = borrowableInputs(host, inputs);
return inputs.map((value, index) =>
borrowed[index] || host.canStackReceiver(value)
? host.emitReadReceiver(value)
: host.emitExpr(value),
);
}
/** `u === v` where one side wraps a unit, bool, or number arm value
* (`o.flag === true`, `o.n === undefined`): compare the other union's tag
* and payload directly instead of boxing the plain side. Operands keep
@@ -14,7 +14,8 @@ import { emitBorrowedInput } from "./borrowed-inputs.js";
import { exactInteger, widenInteger, integerNumber } from "./integer-values.js";
import { integerArithmeticRange } from "../../ir/integer-ranges.js";
import { emitArrayValues } from "./expr-containers.js";
import { emitSignedIntegerRemainder } from "./integer-remainder.js";
import { emitCheckedRemainder, emitSignedIntegerRemainder } from "./integer-remainder.js";
import { emitDenseArrayGet, emitDenseArrayState } from "./dense-array-access.js";
export function emitLiteralExpr(
host: LlvmEmitterContext,
@@ -245,6 +246,10 @@ export function emitOperatorExpr(
return { name: t, type: e.type };
}
}
if (e.op === "%") {
const checked = emitCheckedRemainder(host, l, r, li, ri);
if (checked) return checked;
}
if (arith[e.op] !== undefined) B.line(`${t} = ${arith[e.op]} double ${l.name}, ${r.name}`);
else B.line(`${t} = fcmp ${cmp[e.op]} double ${l.name}, ${r.name}`);
} else if (bit[e.op] !== undefined) {
@@ -419,7 +424,7 @@ export function emitStringExpr(
const [l, r] = emitStringInputs(host, [e.left, e.right]);
host.declare(`declare zeroext i1 @scr_str_eq(ptr, ptr)`);
const eq = B.tmp();
B.line(`${eq} = call zeroext i1 @scr_str_eq(ptr ${l!.name}, ptr ${r!.name})`);
B.line(`${eq} = call zeroext i1 @sc_str_eq(ptr ${l!.name}, ptr ${r!.name})`);
if (!e.negated) return { name: eq, type: e.type };
const t = B.tmp();
B.line(`${t} = xor i1 ${eq}, true`);
@@ -432,7 +437,8 @@ export function emitStringExpr(
const c = B.tmp();
const t = B.tmp();
const pred = { "<": "slt", "<=": "sle", ">": "sgt", ">=": "sge" }[e.op];
B.line(`${c} = call i32 @${fn}(ptr ${l!.name}, ptr ${r!.name})`);
// Identity short-circuits through the inlined sc_ wrapper (helperDefs).
B.line(`${c} = call i32 @${fn.replace(/^scr_/, "sc_")}(ptr ${l!.name}, ptr ${r!.name})`);
B.line(`${t} = icmp ${pred} i32 ${c}, 0`);
return { name: t, type: e.type };
}
@@ -466,21 +472,17 @@ export function emitStringExpr(
B.line(`store ptr ${host.retainValue(host.unionPeek(v.name), e.type)}, ptr ${slot}`);
break;
case "f64": {
const x = B.tmp();
const x = host.unionGetF64(v.name);
const r = B.tmp();
host.declare(`declare double @scr_union_get_f64(ptr)`);
host.declare(`declare ptr @scr_f64_to_scrstr(double)`);
B.line(`${x} = call double @scr_union_get_f64(ptr ${v.name})`);
B.line(`${r} = call ptr @scr_f64_to_scrstr(double ${x})`);
B.line(`store ptr ${r}, ptr ${slot}`);
break;
}
case "bool": {
const x = B.tmp();
const x = host.unionGetBool(v.name);
const r = B.tmp();
host.declare(`declare zeroext i1 @scr_union_get_bool(ptr)`);
host.declare(`declare ptr @scr_bool_to_scrstr(i1 zeroext)`);
B.line(`${x} = call zeroext i1 @scr_union_get_bool(ptr ${v.name})`);
B.line(`${r} = call ptr @scr_bool_to_scrstr(i1 zeroext ${x})`);
B.line(`store ptr ${r}, ptr ${slot}`);
break;
@@ -683,15 +685,20 @@ export function emitContainerExpr(
const idx = host.emitExpr(e.index);
if (e.arr.type.kind !== "array")
throw new InternalCompilerError("llvm emitter bug: arrayGet on non-array");
// Ref-element reads return +1 (the runtime retains); own registers
// the owned temp in the frame like any other.
const acc = elemAccess(e.arr.type.elem);
const accTy = acc === "f64" ? "double" : acc === "bool" ? "i1" : "ptr";
host.declare(
`declare ${acc === "bool" ? "zeroext i1" : accTy} @scr_arr_get_${acc}(ptr, double)`,
// Ref-element reads return +1 (the inline dense path retains like
// the runtime getter); own registers the owned temp in the frame.
const elem = e.arr.type.elem;
const acc = elemAccess(elem);
const t = emitDenseArrayGet(
host,
arr.name,
idx,
e.index,
acc,
elem,
`scr_arr_get_${acc}`,
true,
);
const t = B.tmp();
B.line(`${t} = call ${accTy} @scr_arr_get_${acc}(ptr ${arr.name}, double ${idx.name})`);
return host.own({ name: t, type: e.type });
}
case "arrayHas": {
@@ -699,9 +706,7 @@ export function emitContainerExpr(
const idx = host.emitExpr(e.index);
if (e.arr.type.kind !== "array")
throw new InternalCompilerError("llvm emitter bug: arrayHas on non-array");
host.declare(`declare zeroext i1 @scr_arr_has(ptr, double)`);
const t = B.tmp();
B.line(`${t} = call zeroext i1 @scr_arr_has(ptr ${arr.name}, double ${idx.name})`);
const t = emitDenseArrayState(host, arr.name, idx, e.index, "has");
return { name: t, type: e.type };
}
case "arrayState": {
@@ -709,9 +714,7 @@ export function emitContainerExpr(
const idx = host.emitExpr(e.index);
if (e.arr.type.kind !== "array")
throw new InternalCompilerError("llvm emitter bug: arrayState on non-array");
host.declare(`declare double @scr_arr_state(ptr, double)`);
const t = B.tmp();
B.line(`${t} = call double @scr_arr_state(ptr ${arr.name}, double ${idx.name})`);
const t = emitDenseArrayState(host, arr.name, idx, e.index, "state");
return { name: t, type: e.type };
}
case "arrIntrinsic":
@@ -798,6 +801,12 @@ export function emitRecordExpr(
case "fieldGet": {
const obj = host.emitReadReceiver(e.obj);
const { ptr, type } = host.classFieldPtr(obj.name, e.className, e.field);
const nullable = host.nullableFieldGet(e);
if (nullable) {
const p = B.tmp();
B.line(`${p} = load ptr, ptr ${ptr}${host.fieldAliasAttachment(ptr)}`);
return host.own({ name: host.nullableToOwnedUnion(p, nullable), type: e.type });
}
const v = host.loadField(ptr, type);
if (isRefCounted(e.type))
return host.own({ name: host.retainValue(v, e.type), type: e.type });
@@ -220,7 +220,7 @@ export function keyedRecordReadInto(
const lit = host.internLiteral(f.name);
const hit = B.tmp();
B.line(
`${hit} = call zeroext i1 @scr_str_eq(ptr ${keyName}, ptr ${lit}) ; ${llvmCommentText(f.name)}`,
`${hit} = call zeroext i1 @sc_str_eq(ptr ${keyName}, ptr ${lit}) ; ${llvmCommentText(f.name)}`,
);
const lh = B.newLabel("rkg.h");
const ln = B.newLabel("rkg.n");
@@ -341,7 +341,7 @@ export function keyedRecordReadInto(
const lit = host.internLiteral(f.name);
const hit = B.tmp();
B.line(
`${hit} = call zeroext i1 @scr_str_eq(ptr ${keyName}, ptr ${lit}) ; ${llvmCommentText(f.name)}`,
`${hit} = call zeroext i1 @sc_str_eq(ptr ${keyName}, ptr ${lit}) ; ${llvmCommentText(f.name)}`,
);
const lh = B.newLabel("rkg.h");
const ln = B.newLabel("rkg.n");
@@ -19,6 +19,42 @@ import { classFieldIndex, classStructSym } from "./classes.js";
import { emitFieldAbsentTest, llvmCommentText } from "./common.js";
import { FN_ATTRS, llFieldType, releaseSym, traceArg, vAdapters } from "./shapes.js";
import type { LlvmEmitterContext, LlStreamTypedRefAdapter } from "./expr-context.js";
import type { NullableRefField } from "./nullable-fields.js";
/** An owned (+1) union value for a nullable-pointer class field slot `p`
* (the unit arm's immortal instance, or a fresh box around a retained
* instance). The bridge releases it after boxing. */
function nullableFieldUnion(
host: LlvmEmitterContext,
B: BlockBuilder,
p: string,
nullable: NullableRefField,
): string {
const rc = vAdapters(host.shapeHost, nullable.arm);
host.declare(`declare ptr @scr_union_new_ref(i32, ptr, ptr, ptr, ptr)`);
const isNull = B.tmp(),
retained = B.tmp(),
box = B.tmp(),
out = B.tmp();
const unit = B.newLabel("nf.u"),
ref = B.newLabel("nf.r"),
join = B.newLabel("nf.j");
B.line(`${isNull} = icmp eq ptr ${p}, null`);
B.condBr(isNull, unit, ref);
B.startBlock(unit);
B.br(join);
B.startBlock(ref);
B.line(`${retained} = call ptr ${rc.retain}(ptr ${p})`);
B.line(
`${box} = call ptr @scr_union_new_ref(i32 ${nullable.refTag}, ptr ${retained}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${traceArg(host.shapeHost, nullable.arm)})`,
);
B.br(join);
B.startBlock(join);
B.line(
`${out} = phi ptr [ ${host.unitInstanceRef(nullable.unionId, nullable.unitTag)}, %${unit} ], [ ${box}, %${ref} ]`,
);
return out;
}
export function dynPromiseAdapter(host: LlvmEmitterContext, inner: IrType): string {
if (inner.kind === "dyn") {
@@ -273,6 +309,28 @@ export function streamTypedRefCommitAdapter(
` %f${index}_ptr = getelementptr inbounds %${classStructSym(t.className)}, ptr %target, i64 0, i32 ${fieldIndex}`,
` %f${index}_old = load ${fieldTy}, ptr %f${index}_ptr`,
);
const nullable = host.nullableFields.get(t.className, field.name);
if (nullable) {
// The checked union is owned here; the slot takes its retained
// instance pointer (NULL for the unit arm).
const rc = vAdapters(host.shapeHost, nullable.arm);
host.declare(`declare void @scr_union_release(ptr)`);
lines.push(
` %f${index}_tagp = getelementptr inbounds %ScrUnion, ptr %${next}, i64 0, i32 1`,
` %f${index}_tag = load i32, ptr %f${index}_tagp`,
` %f${index}_isref = icmp eq i32 %f${index}_tag, ${nullable.refTag}`,
` %f${index}_payp = getelementptr inbounds %ScrUnion, ptr %${next}, i64 0, i32 5`,
` %f${index}_pay = load ptr, ptr %f${index}_payp`,
` %f${index}_sel = select i1 %f${index}_isref, ptr %f${index}_pay, ptr null`,
` %f${index}_new = call ptr ${rc.retain}(ptr %f${index}_sel)`,
` store ptr %f${index}_new, ptr %f${index}_ptr`,
` call void @scr_union_release(ptr %${next})`,
` call void ${rc.release}(ptr %f${index}_old)`,
` br label %${after}`,
`${after}:`,
);
continue;
}
if (field.type.kind === "bool") {
lines.push(
` %f${index}_stored = zext i1 %${next} to i8`,
@@ -719,10 +777,16 @@ export function streamTypedRefMaterializeAdapter(
B.line(`${boolValue} = trunc i8 ${fieldValue} to i1`);
fieldValue = boolValue;
}
const nullable = host.nullableFields.get(t.className, field.name);
if (nullable) fieldValue = nullableFieldUnion(host, B, fieldValue, nullable);
const boxed = host.streamTypedRefBoxValue(B, field.type, fieldValue);
B.line(
`call void @scr_dyn_obj_set(ptr ${out}, ptr ${host.cstr(field.name)}, ${host.sizeType} ${Buffer.byteLength(field.name, "utf8")}, ptr ${boxed})`,
);
if (nullable) {
host.declare(`declare void @scr_union_release(ptr)`);
B.line(`call void @scr_union_release(ptr ${fieldValue})`);
}
if (afterField) {
B.br(afterField);
B.startBlock(afterField);
@@ -761,7 +825,13 @@ export function streamTypedRefMaterializeAdapter(
B.line(`${boolValue} = trunc i8 ${fieldValue} to i1`);
fieldValue = boolValue;
}
const symbolNullable = host.nullableFields.get(t.className, field.name);
if (symbolNullable) fieldValue = nullableFieldUnion(host, B, fieldValue, symbolNullable);
const value = host.streamTypedRefBoxValue(B, field.type, fieldValue);
if (symbolNullable) {
host.declare(`declare void @scr_union_release(ptr)`);
B.line(`call void @scr_union_release(ptr ${fieldValue})`);
}
host.declare(`declare void @scr_dyn_symbol_key_set(ptr, ptr, ptr)`);
host.declare(`declare void @scr_dyn_release_v(ptr)`);
B.line(`call void @scr_dyn_symbol_key_set(ptr ${out}, ptr ${key}, ptr ${value})`);
@@ -42,3 +42,108 @@ export function emitSignedIntegerRemainder(
B.line(`${result} = select i1 ${zero}, double ${signedZero}, double ${number}`);
return { name: result, type: F64 };
}
const EXACT = 2 ** 53;
/** Convert an unproven double to a candidate i64. `freeze` keeps NaN and
* out-of-range conversions from producing poison; the returned condition
* is the exact round trip, so it rejects every non-integer. Integral
* doubles inside the i64 range convert exactly; a passing value outside
* it (|x| >= 2^63) can only pair with a frozen integer of magnitude above
* 2^53. `-0` converts to 0 and passes. */
function candidateInteger(host: LlvmEmitterContext, value: string): [string, string] {
const B = host.B;
const raw = B.tmp(),
integer = B.tmp(),
back = B.tmp(),
exact = B.tmp();
B.line(`${raw} = fptosi double ${value} to i64`);
B.line(`${integer} = freeze i64 ${raw}`);
B.line(`${back} = sitofp i64 ${integer} to double`);
B.line(`${exact} = fcmp oeq double ${back}, ${value}`);
return [integer, exact];
}
/** JS `%` with an inline integer path for operands that are not both
* proven integers with a nonzero divisor. At run time the dividend must be
* an integer of magnitude <= 2^53 and the divisor a nonzero integer; then
* i64 `srem` is exact (no INT64_MIN / -1 overflow is possible), takes the
* dividend's sign like JS, and an exact zero takes the dividend's sign
* (`-4 % 2` and `-0 % 3` are -0). A divisor that passes the round trip
* with magnitude above 2^53 exceeds the dividend's, so `srem` returns the
* dividend exactly as JS does. Everything else (fractions, NaN, ±Infinity,
* zero divisors, huge dividends) takes the floating remainder, unchanged.
* Returns null when the divisor is the constant zero (always NaN). */
export function emitCheckedRemainder(
host: LlvmEmitterContext,
left: LlValue,
right: LlValue,
dividend: LlInteger | null,
divisor: LlInteger | null,
): LlValue | null {
if (divisor && divisor.range.min === 0 && divisor.range.max === 0) return null;
const B = host.B;
const conditions: string[] = [];
let x: string;
if (dividend) x = widenInteger(host, dividend);
else {
const [integer, exact] = candidateInteger(host, left.name);
const biased = B.tmp(),
inRange = B.tmp();
B.line(`${biased} = add i64 ${integer}, ${EXACT}`);
B.line(`${inRange} = icmp ule i64 ${biased}, ${2 * EXACT}`);
conditions.push(exact, inRange);
x = integer;
}
let y: string;
if (divisor) {
y = widenInteger(host, divisor);
if (divisor.range.min <= 0 && divisor.range.max >= 0) {
const nonzero = B.tmp();
B.line(`${nonzero} = icmp ne i64 ${y}, 0`);
conditions.push(nonzero);
}
} else {
const [integer, exact] = candidateInteger(host, right.name);
const nonzero = B.tmp();
B.line(`${nonzero} = icmp ne i64 ${integer}, 0`);
conditions.push(exact, nonzero);
y = integer;
}
let ok = conditions[0]!;
for (const c of conditions.slice(1)) {
const t = B.tmp();
B.line(`${t} = and i1 ${ok}, ${c}`);
ok = t;
}
host.declare("declare i1 @llvm.expect.i1(i1, i1)");
const expected = B.tmp();
B.line(`${expected} = call i1 @llvm.expect.i1(i1 ${ok}, i1 true)`);
const fast = B.newLabel("rem.int"),
slow = B.newLabel("rem.float"),
join = B.newLabel("rem.join");
B.condBr(expected, fast, slow);
B.startBlock(fast);
const remainder = B.tmp(),
number = B.tmp();
B.line(`${remainder} = srem i64 ${x}, ${y}`);
B.line(`${number} = sitofp i64 ${remainder} to double`);
let fastValue = number;
if (!dividend || dividend.range.min < 0) {
const zero = B.tmp(),
signedZero = B.tmp();
fastValue = B.tmp();
B.line(`${zero} = icmp eq i64 ${remainder}, 0`);
B.line(`${signedZero} = fmul double ${left.name}, ${f64Lit(0)}`);
B.line(`${fastValue} = select i1 ${zero}, double ${signedZero}, double ${number}`);
}
B.br(join);
B.startBlock(slow);
const floating = B.tmp();
B.line(`${floating} = frem double ${left.name}, ${right.name}`);
B.br(join);
B.startBlock(join);
const result = B.tmp();
B.line(`${result} = phi double [ ${fastValue}, %${fast} ], [ ${floating}, %${slow} ]`);
return { name: result, type: F64 };
}
@@ -1,6 +1,7 @@
import type { IrFunction, IrModule, IrType } from "../../ir/ir.js";
import { emitClassObjDefs, emitClassShapes, type ClassHost, type LlClassMeta } from "./classes.js";
import { emitRecordShapes } from "./shapes.js";
import { NullableRefFields } from "./nullable-fields.js";
export interface LlvmLayouts {
records: { typeDefs: string[]; defs: string[] };
@@ -18,9 +19,10 @@ export function emitLlvmLayouts(
classObjects: Map<string, { nameSym: string }>,
functions: Map<string, IrFunction>,
llType: (type: IrType) => string,
nullable?: NullableRefFields,
): LlvmLayouts {
const records = emitRecordShapes(host, mod);
const classShapes = emitClassShapes(host, mod, classes);
const classShapes = emitClassShapes(host, mod, classes, nullable);
const objects = emitClassObjDefs(
host,
classes,
@@ -830,3 +830,85 @@ test.each(["different index", "different array", "effectful operand", "different
expect(reads.get(value)).toBeNull();
},
);
/** Retype the fixture's element as a scalar and consume the read by an
* immediate projection (`if (a[i])` / `a[i] ?? d`) instead of a local. */
function scalarFixture(elem: IrType, consumer: "truthy" | "nullish" | "local"): IrModule {
const retype = <T>(value: T): T =>
JSON.parse(
JSON.stringify(value, (key, field: unknown) =>
field &&
typeof field === "object" &&
(field as { kind?: string }).kind === "record" &&
(field as { shapeId?: string }).shapeId === "cell"
? elem
: field,
),
) as T;
const mod = retype(fixture());
mod.unions = [{ id: "optional", arms: [elem, UNDEFINED_T] }];
const work = mod.functions[2]!;
const read: IrExpr = {
kind: "call",
callee: "read",
args: [ref("a", arrayOf(elem)), ref("i", F64)],
type: optional,
loc,
};
work.returnType = elem;
if (consumer === "truthy") {
work.returnType = BOOL;
work.body = [
{ kind: "return", value: { kind: "toBool", operand: read, type: BOOL, loc }, loc },
];
} else if (consumer === "nullish") {
const fallback: IrExpr =
elem.kind === "bool" ? { kind: "boolLit", value: true, type: BOOL, loc } : num(-1);
work.body = [
{
kind: "return",
value: { kind: "nullish", left: read, right: fallback, type: elem, loc },
loc,
},
];
} else {
work.body = [
{ kind: "varDecl", localId: "value", init: read, loc },
{
kind: "return",
value: {
kind: "unionNarrow",
unionId: "optional",
tag: 0,
value: unionValue,
type: elem,
loc,
},
loc,
},
];
}
return mod;
}
test.each([
[F64, "truthy"],
[F64, "nullish"],
[F64, "local"],
[BOOL, "truthy"],
[BOOL, "nullish"],
[BOOL, "local"],
] as const)("scalar %o optional reads consumed as %s need no heap union", (elem, consumer) => {
const mod = scalarFixture(elem, consumer);
expect(validateModule(mod)).toEqual([]);
for (const bits of [32, 64] as const) {
const ir = workBody(mod, bits);
expect(ir).not.toContain("@scr_union_new");
expect(ir).not.toContain("@scr_union_release");
expect(ir).not.toContain("@scr_union_get");
// Holes, sparse and noncanonical indices keep the runtime state check.
expect(ir).toContain("@scr_arr_state");
expect(ir).toContain(elem.kind === "f64" ? "@scr_arr_get_f64" : "@scr_arr_get_bool");
if (consumer !== "local") expect(ir).toContain("local.array.dense");
}
});
@@ -39,6 +39,12 @@ interface ArrayReadShape {
missingTag: number;
}
/** f64 and bool elements copy their slot bits into the box payload. They
* have no owner, so every lifetime proof for them is trivially satisfied. */
function isScalarElement(element: IrType): boolean {
return element.kind === "f64" || element.kind === "bool";
}
/** Recognize the state-test/read pair shared by helpers and lowered loops.
* Repeated operands must be plain bindings or numeric literals: folding an
* effectful index or receiver would change evaluation count and ordering. */
@@ -78,7 +84,13 @@ function inlineArrayRead(
return null;
const element = array.type.elem;
const arms = unions.get(value.type.unionId)?.arms;
if (!isRefCounted(element) || element.kind === "union" || !arms || arms.length !== 2) return null;
if (
(!isRefCounted(element) && !isScalarElement(element)) ||
element.kind === "union" ||
!arms ||
arms.length !== 2
)
return null;
const presentTag = arms.findIndex((arm) => typeEquals(arm, element));
const missingTag = arms.findIndex((arm) => arm.kind === "undefinedT");
if (presentTag < 0 || missingTag < 0 || then.tag !== presentTag || missing.tag !== missingTag)
@@ -242,13 +254,18 @@ export function findCallArrayReads(
/** Call-scoped owners are released by the existing argument frame on both
* normal and exceptional exits. Each use gets a distinct stack box: nested
* calls and repeated operands must never overwrite an earlier snapshot. */
export function emitCallArrayRead(host: LlvmEmitterContext, read: LocalArrayRead): LlValue {
export function emitCallArrayRead(
host: LlvmEmitterContext,
read: LocalArrayRead,
inline = read.borrow === true,
): LlValue {
const slot = host.B.slot();
host.B.entryAllocas.push(`${slot} = alloca ptr`);
// Snapshot arguments already cross a runtime ownership boundary. Keep
// their lookup compact instead of duplicating the dense fast path at
// every call site; proven borrowed reads still expose that path to LLVM.
const owner = emitLocalArrayRead(host, read, slot, read.borrow === true);
// every call site; proven borrowed reads and immediate projections
// (tests, narrowing, comparisons) still expose that path to LLVM.
const owner = emitLocalArrayRead(host, read, slot, inline);
if (owner) host.ownSlot(owner.slot, owner.type);
const value = host.B.tmp();
host.B.line(`${value} = load ptr, ptr ${slot}`);
@@ -280,6 +297,7 @@ function emitDenseReferenceArrayRead(
no: string,
slow: string,
join: string,
loadType: "ptr" | "i64" = "ptr",
): void {
const B = host.B;
const range = B.newLabel("local.array.range"),
@@ -349,7 +367,7 @@ function emitDenseReferenceArrayRead(
B.line(`${data} = load ptr, ptr ${dataPtr}${host.fieldAliasAttachment(dataPtr)}`);
B.line(`${valuePtr} = getelementptr inbounds i64, ptr ${data}, ${host.sizeType} ${offset}`);
host.markMemoryPointer(valuePtr, "array:elements");
B.line(`${raw} = load ptr, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`);
B.line(`${raw} = load ${loadType}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`);
present(raw);
B.br(join);
}
@@ -410,31 +428,81 @@ export function emitLocalArrayRead(
const slow = B.newLabel("local.array.slow");
const no = B.newLabel("local.array.missing"),
join = B.newLabel("local.array.join");
const scalar = isScalarElement(read.element);
const storeValue = (value: string): void => {
B.line(
`store ptr ${read.borrow ? value : host.retainValue(value, read.element)}, ptr ${payload}`,
);
if (scalar) {
// Slot bits for f64; a canonical 0/1 for bool, like scr_union_new_bool.
let bits = value;
if (read.element.kind === "bool") {
const nonzero = B.tmp();
bits = B.tmp();
B.line(`${nonzero} = icmp ne i64 ${value}, 0`);
B.line(`${bits} = zext i1 ${nonzero} to i64`);
}
B.line(`store i64 ${bits}, ptr ${payload}`);
} else {
B.line(
`store ptr ${read.borrow ? value : host.retainValue(value, read.element)}, ptr ${payload}`,
);
}
B.line(`store i32 ${read.presentTag}, ptr ${tag}`);
};
if (inline)
emitDenseReferenceArrayRead(host, array, index, integerIndex, storeValue, no, slow, join);
emitDenseReferenceArrayRead(
host,
array,
index,
integerIndex,
storeValue,
no,
slow,
join,
scalar ? "i64" : "ptr",
);
else B.br(slow);
B.startBlock(slow);
const value = B.tmp(),
present = B.tmp();
host.declare("declare ptr @scr_arr_peek_ref(ptr, double) memory(read)");
B.line(`${value} = call ptr @scr_arr_peek_ref(ptr ${array.name}, double ${index.name})`);
B.line(`${present} = icmp ne ptr ${value}, null`);
const slowValue = B.newLabel("local.array.slow.value");
B.condBr(present, slowValue, no);
B.startBlock(slowValue);
storeValue(value);
if (scalar) {
// Holes, present undefined, sparse and noncanonical indices: the
// runtime state decides presence, then the strict getter reads it.
const state = B.tmp(),
present = B.tmp();
host.declare(`declare double @scr_arr_state(ptr, double)`);
B.line(`${state} = call double @scr_arr_state(ptr ${array.name}, double ${index.name})`);
B.line(`${present} = fcmp oeq double ${state}, 1.0`);
B.condBr(present, slowValue, no);
B.startBlock(slowValue);
const value = B.tmp(),
bits = B.tmp();
if (read.element.kind === "f64") {
host.declare(`declare double @scr_arr_get_f64(ptr, double)`);
B.line(`${value} = call double @scr_arr_get_f64(ptr ${array.name}, double ${index.name})`);
B.line(`${bits} = bitcast double ${value} to i64`);
} else {
host.declare(`declare zeroext i1 @scr_arr_get_bool(ptr, double)`);
B.line(
`${value} = call zeroext i1 @scr_arr_get_bool(ptr ${array.name}, double ${index.name})`,
);
B.line(`${bits} = zext i1 ${value} to i64`);
}
B.line(`store i64 ${bits}, ptr ${payload}`);
B.line(`store i32 ${read.presentTag}, ptr ${tag}`);
} else {
const value = B.tmp(),
present = B.tmp();
host.declare("declare ptr @scr_arr_peek_ref(ptr, double) memory(read)");
B.line(`${value} = call ptr @scr_arr_peek_ref(ptr ${array.name}, double ${index.name})`);
B.line(`${present} = icmp ne ptr ${value}, null`);
B.condBr(present, slowValue, no);
B.startBlock(slowValue);
storeValue(value);
}
B.br(join);
B.startBlock(no);
B.line(`store ptr null, ptr ${payload}`);
B.line(`store i64 0, ptr ${payload}`);
B.line(`store i32 ${read.missingTag}, ptr ${tag}`);
B.br(join);
B.startBlock(join);
B.line(`store ptr ${box}, ptr ${localSlot}`);
return read.borrow ? null : { slot: payload, type: read.element };
return read.borrow || scalar ? null : { slot: payload, type: read.element };
}
@@ -194,9 +194,20 @@ test.each([
} else if (reason === "capture") {
fn.locals[2]!.boxed = true;
} else if (reason === "unknown source") {
fn.params.push({ localId: "other", name: "other", type: optional });
fn.locals.push({ id: "other", name: "other", type: optional, mutable: true });
fn.body[1] = { kind: "assign", localId: "item", value: ref("other"), loc };
mod.functions.push({
name: "source",
loc,
returnType: optional,
params: [],
locals: [],
body: [{ kind: "return", loc, value: missing() }],
});
fn.body[1] = {
kind: "assign",
localId: "item",
value: { kind: "call", callee: "source", args: [], type: optional, loc },
loc,
};
} else if (reason === "uninitialized") {
fn.body[0] = { kind: "varDecl", localId: "item", init: null, loc };
} else {
@@ -205,6 +216,20 @@ test.each([
expect(facts(mod).has("item")).toBe(false);
});
test("copies of another binding retain the reference payload instead of the box", () => {
const mod = fixture();
const fn = mod.functions[1]!;
fn.params.push({ localId: "other", name: "other", type: optional });
fn.locals.push({ id: "other", name: "other", type: optional, mutable: true });
fn.body[1] = { kind: "assign", localId: "item", value: ref("other"), loc };
expect(facts(mod).get("item")?.ownerType).toEqual(STRING);
for (const bits of [32, 64] as const) {
const ir = body(mod, bits);
expect(ir).toContain("@scr_str_retain");
expect(ir).not.toMatch(/@scr_union_(?:new|retain)/);
}
});
test("immediate tag and string conversions keep temporary payload ownership", () => {
const mod = fixture();
const fn = mod.functions[1]!;
@@ -226,3 +251,71 @@ test("immediate tag and string conversions keep temporary payload ownership", ()
];
expect(body(mod, 64)).not.toMatch(/@scr_union_(?:new|retain|release)/);
});
const unionEq = (left: IrExpr, right: IrExpr): IrExpr => ({
kind: "unionEq",
unionId: "optional",
negated: false,
sameValue: false,
left,
right,
type: BOOL,
loc,
});
function equalityFixture(): IrModule {
const mod = fixture();
const fn = mod.functions[1]!;
fn.returnType = BOOL;
fn.body = [
fn.body[0]!,
{
kind: "return",
loc,
value: {
kind: "logical",
op: "||",
left: unionEq(ref("item"), wrap(text("department"), 0)),
right: unionEq(wrap(text("level"), 0), ref("item")),
type: BOOL,
loc,
},
},
];
return mod;
}
test("strict equality against a wrapped literal borrows the local and stack-boxes the literal", () => {
const mod = equalityFixture();
expect(facts(mod).has("item")).toBe(true);
for (const bits of [32, 64] as const) {
const ir = body(mod, bits);
expect(ir).toContain("@sc_str_eq");
expect(ir).not.toMatch(/@scr_union_(?:new|retain|release)/);
}
});
test("closure bodies keep their own union locals on the stack", () => {
const mod = equalityFixture();
mod.functions[1]!.captures = [];
expect(facts(mod).has("item")).toBe(true);
expect(body(mod, 64)).not.toMatch(/@scr_union_(?:new|retain|release)/);
});
test("equality whose other operand captures the whole local keeps the heap path", () => {
const mod = equalityFixture();
const fn = mod.functions[1]!;
fn.body[1] = {
kind: "return",
loc,
value: unionEq(ref("item"), {
kind: "ternary",
cond: ref("choose", BOOL),
then: ref("item"),
else_: missing(),
type: optional,
loc,
}),
};
expect(facts(mod).has("item")).toBe(false);
});
@@ -17,10 +17,37 @@ export interface LocalUnionStorageProof {
ownerType: IrType | null;
}
function supportedSource(expr: IrExpr, unions: ReadonlyMap<string, IrUnionDef>): boolean {
/** A union source the local can snapshot with its own payload owner:
* wraps and map reads (existing owners), nullable-pointer field reads, and
* copies of another binding of the same union (both retain the payload). */
export type CopiedUnionSource = (expr: IrExpr) => boolean;
function isUnionCopy(expr: IrExpr, type: IrType): boolean {
return (
expr.kind === "varRef" &&
expr.type.kind === "union" &&
type.kind === "union" &&
expr.type.unionId === type.unionId
);
}
function supportedSource(
expr: IrExpr,
type: IrType,
unions: ReadonlyMap<string, IrUnionDef>,
nullableField: CopiedUnionSource,
): boolean {
if (expr.kind === "ternary")
return supportedSource(expr.then, unions) && supportedSource(expr.else_, unions);
return canStackUnion(expr, unions) || matchMapRead(expr, unions) !== null;
return (
supportedSource(expr.then, type, unions, nullableField) &&
supportedSource(expr.else_, type, unions, nullableField)
);
return (
canStackUnion(expr, unions) ||
matchMapRead(expr, unions) !== null ||
nullableField(expr) ||
isUnionCopy(expr, type)
);
}
/** A local box may change tags without escaping. Reject a binding as a
@@ -31,7 +58,9 @@ export function findLocalUnionStorage(
fn: IrFunction,
lifetimes: CallLifetimes,
unions: ReadonlyMap<string, IrUnionDef>,
nullableField: CopiedUnionSource = () => false,
): Map<string, LocalUnionStorageProof> {
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;
@@ -47,7 +76,9 @@ export function findLocalUnionStorage(
stmt: (stmt) => {
if (stmt.kind === "varDecl" || stmt.kind === "assign") {
const value = stmt.kind === "varDecl" ? stmt.init : stmt.value;
if (!value || !supportedSource(value, unions)) result.delete(stmt.localId);
const type = types.get(stmt.localId);
if (!value || !type || !supportedSource(value, type, unions, nullableField))
result.delete(stmt.localId);
}
return true;
},
@@ -112,7 +143,37 @@ export function storeLocalUnion(
}
let source: string;
let owner = "null";
if (canStackUnion(expr, host.unionsById)) {
if (host.nullableFieldGet(expr) !== null) {
const copied = host.emitOwnedNullableStack(expr);
source = copied.box;
owner = copied.owner.name;
host.moveTemp(copied.owner);
} else if (expr.kind === "varRef" && !canStackUnion(expr, host.unionsById)) {
// A copy of another binding: snapshot the tag and bits, and retain the
// reference payload (scalar and unit arms own nothing).
source = host.emitReadReceiver(expr).name;
if (storage.ownerType) {
const def = host.unionsById.get(expr.type.kind === "union" ? expr.type.unionId : "")!;
const refTag = def.arms.findIndex(isRefCounted);
// Not every retain entry point is NULL-tolerant: branch on the tag.
const tag = host.unionTag(source);
const isRef = B.tmp(),
held = B.slot();
B.entryAllocas.push(`${held} = alloca ptr`);
const yes = B.newLabel("union.copy.ref"),
join = B.newLabel("union.copy.join");
B.line(`store ptr null, ptr ${held}`);
B.line(`${isRef} = icmp eq i32 ${tag}, ${refTag}`);
B.condBr(isRef, yes, join);
B.startBlock(yes);
const retained = host.retainValue(host.unionPeek(source), storage.ownerType);
B.line(`store ptr ${retained}, ptr ${held}`);
B.br(join);
B.startBlock(join);
owner = B.tmp();
B.line(`${owner} = load ptr, ptr ${held}`);
}
} else if (canStackUnion(expr, host.unionsById)) {
const wrapped = emitStackUnion(host, expr);
source = wrapped.value.name;
if (wrapped.payload) {
@@ -0,0 +1,58 @@
import { expect, test } from "vitest";
import type { IrClassDef, IrType, IrUnionDef } from "../../ir/ir.js";
import { NullableRefFields } from "./nullable-fields.js";
const loc = { file: "nullable.ts", start: 0, end: 0 };
const node: IrType = { kind: "object", className: "Node" };
const union = (id: string): IrType => ({ kind: "union", unionId: id });
const unions = new Map<string, IrUnionDef>(
(
[
["orNull", [{ kind: "nullT" }, node]],
["orUndefined", [node, { kind: "undefinedT" }]],
["both", [{ kind: "nullT" }, { kind: "undefinedT" }, node]],
["orNumber", [node, { kind: "f64" }]],
["runtimeArm", [{ kind: "nullT" }, { kind: "object", className: "%Error" }]],
["recordArm", [{ kind: "nullT" }, { kind: "record", shapeId: "r" }]],
] as [string, IrType[]][]
).map(([id, arms]) => [id, { id, arms } as IrUnionDef]),
);
const cls = (name: string, fields: [string, IrType][], runtime = false): IrClassDef =>
({
name,
loc,
fields: fields.map(([fieldName, type]) => ({ name: fieldName, type })),
...(runtime ? { runtime: true } : {}),
}) as unknown as IrClassDef;
test("only one unit arm plus one emitted class arm uses pointer storage", () => {
const fields = new NullableRefFields(
[
cls("Node", [
["next", union("orNull")],
["prev", union("orUndefined")],
["both", union("both")],
["num", union("orNumber")],
["err", union("runtimeArm")],
["rec", union("recordArm")],
["plain", node],
["%hidden", union("orNull")],
]),
cls("%Error", [["next", union("orNull")]], true),
],
unions,
);
expect(fields.get("Node", "next")).toEqual({
unionId: "orNull",
refTag: 1,
unitTag: 0,
arm: node,
});
expect(fields.get("Node", "prev")).toMatchObject({ refTag: 0, unitTag: 1 });
for (const field of ["both", "num", "err", "rec", "plain", "%hidden", "missing"])
expect(fields.get("Node", field)).toBeNull();
// Runtime classes keep their C layouts.
expect(fields.get("%Error", "next")).toBeNull();
expect(fields.storageType("Node", "next", union("orNull"))).toEqual(node);
expect(fields.storageType("Node", "num", union("orNumber"))).toEqual(union("orNumber"));
});
@@ -0,0 +1,56 @@
import { isUnitType, type IrClassDef, type IrType, type IrUnionDef } from "../../ir/ir.js";
/** A class field typed `C | null` or `C | undefined` (exactly one unit arm
* and one emitted class arm) stores the class pointer itself: NULL is the
* unit arm, anything else is the owned (+1) instance. No union box exists
* for the field; reads build one only where a whole union value escapes,
* and projections (tag tests, narrowing, equality, nullish) read the
* pointer directly. The struct slot stays `ptr`, so layouts are unchanged. */
export interface NullableRefField {
unionId: string;
refTag: number;
unitTag: number;
arm: { kind: "object"; className: string };
}
export class NullableRefFields {
private readonly emitted = new Map<string, IrClassDef>();
private readonly cache = new Map<string, NullableRefField | null>();
constructor(
classes: readonly IrClassDef[],
private readonly unions: ReadonlyMap<string, IrUnionDef>,
) {
for (const cls of classes) if (!cls.runtime) this.emitted.set(cls.name, cls);
}
/** The representation of one declared field, or null for ordinary storage
* (runtime classes keep their C layouts). */
get(className: string, field: string): NullableRefField | null {
const key = `${className}\0${field}`;
const cached = this.cache.get(key);
if (cached !== undefined) return cached;
const cls = this.emitted.get(className);
const type = cls?.fields.find((f) => f.name === field)?.type;
const result = type && !field.startsWith("%") ? this.ofType(type) : null;
this.cache.set(key, result);
return result;
}
/** The storage form of a field type: the class arm for nullable fields. */
storageType(className: string, field: string, type: IrType): IrType {
return this.get(className, field)?.arm ?? type;
}
private ofType(type: IrType): NullableRefField | null {
if (type.kind !== "union") return null;
const def = this.unions.get(type.unionId);
if (!def || def.arms.length !== 2) return null;
const unitTag = def.arms.findIndex(isUnitType);
if (unitTag < 0) return null;
const refTag = 1 - unitTag;
const arm = def.arms[refTag]!;
if (arm.kind !== "object" || !this.emitted.has(arm.className)) return null;
return { unionId: type.unionId, refTag, unitTag, arm };
}
}
@@ -9,35 +9,42 @@ const root = join(import.meta.dirname, "../../../../..");
// Per-node emitter analyses must reach the same conclusions for an in-memory
// module and its serialized IR artifact. The sanitized bootstrap emits the
// next compiler generation from that artifact.
// next compiler generation from that artifact. Both emissions use the backend
// options `compile` derives from the build flags (object audit notes follow
// --sanitize), so any difference comes from the IR round trip.
test("sort-heavy programs emit equal LLVM from memory and from serialized IR", async () => {
const directory = mkdtempSync(join(tmpdir(), "scriptc-sort-ir-"));
try {
for (const name of [
"array-sort-runs.ts",
"uint8array-sort-domains.ts",
"2703-array-sparse-sort.ts",
]) {
const entry = join(root, "tests/corpus", name);
const options = {
outDir: directory,
backend: "llvm" as const,
optimization: "release" as const,
};
const llvmPath = join(directory, name + ".ll");
const irPath = join(directory, name + ".ir.json");
const llvm = await compile(entry, { ...options, outPath: llvmPath, outputKind: "llvm" });
expect(llvm.ok, name).toBe(true);
const ir = await compile(entry, { ...options, outPath: irPath, outputKind: "ir" });
expect(ir.ok, name).toBe(true);
const emitted = emitLlvmModuleSource(deserializeModule(readFileSync(irPath, "utf8")), {
targetTriple: process.env["SCRIPTC_TARGET"] ?? "",
pointerBits: 64,
wasi: false,
runtimeAbiMarker: false,
});
const text = typeof emitted === "string" ? emitted : emitted.join("");
expect(text === readFileSync(llvmPath, "utf8"), name).toBe(true);
for (const sanitize of [false, true]) {
for (const name of [
"array-sort-runs.ts",
"uint8array-sort-domains.ts",
"2703-array-sparse-sort.ts",
]) {
const label = `${name}${sanitize ? " (sanitize)" : ""}`;
const entry = join(root, "tests/corpus", name);
const options = {
outDir: directory,
backend: "llvm" as const,
optimization: "release" as const,
sanitize,
};
const llvmPath = join(directory, name + ".ll");
const irPath = join(directory, name + ".ir.json");
const llvm = await compile(entry, { ...options, outPath: llvmPath, outputKind: "llvm" });
expect(llvm.ok, label).toBe(true);
const ir = await compile(entry, { ...options, outPath: irPath, outputKind: "ir" });
expect(ir.ok, label).toBe(true);
const emitted = emitLlvmModuleSource(deserializeModule(readFileSync(irPath, "utf8")), {
targetTriple: process.env["SCRIPTC_TARGET"] ?? "",
pointerBits: 64,
wasi: false,
runtimeAbiMarker: false,
objectAudit: sanitize,
});
const text = typeof emitted === "string" ? emitted : emitted.join("");
expect(text === readFileSync(llvmPath, "utf8"), label).toBe(true);
}
}
} finally {
rmSync(directory, { recursive: true, force: true });
@@ -1,20 +1,33 @@
import { describe, expect, test } from "vitest";
import { RUNTIME_EMITTER_CLASS } from "../../ir/ir.js";
import { releaseSym, type ShapeHost, vAdapters } from "./shapes.js";
import { F64, type IrModule, RUNTIME_EMITTER_CLASS } from "../../ir/ir.js";
import {
boxReleaseSym,
emitInlineRcHelpers,
emitRecordShapes,
inlineRcDecls,
releaseSym,
retainSym,
type ShapeHost,
vAdapters,
} from "./shapes.js";
function declarationHost(): { host: ShapeHost; declarations: string[] } {
function declarationHost(
pointerBits: 32 | 64 = 64,
inlineRc = false,
): { host: ShapeHost; declarations: string[] } {
const declarations: string[] = [];
const host: ShapeHost = {
declare(decl) {
declarations.push(decl);
},
needOom() {},
sizeType: "i64",
cycleColorOffset: 0,
sizeType: pointerBits === 32 ? "i32" : "i64",
cycleColorOffset: pointerBits === 32 ? 12 : 16,
tracedShapes: new Set(),
tracedUnions: new Set(),
recordsById: new Map(),
recordCloneShapes: new Set(),
rcHelpers: inlineRc ? new Set() : null,
};
return { host, declarations };
}
@@ -56,4 +69,128 @@ describe("LLVM runtime RC symbols", () => {
release: "@sc_release_Widget",
});
});
test("release emission keeps every retain and release a runtime call", () => {
const { host, declarations } = declarationHost();
expect(retainSym(host, { kind: "string" })).toBe("@scr_str_retain_v");
expect(releaseSym(host, { kind: "string" })).toBe("@scr_str_release");
expect(releaseSym(host, { kind: "union", unionId: "u0" } as never)).toBe("@scr_union_release");
expect(boxReleaseSym(host)).toBe("@scr_box_release");
expect(declarations).toContain("declare void @scr_box_release(ptr)");
expect(inlineRcDecls(host)).toEqual([]);
expect(emitInlineRcHelpers(host)).toEqual([]);
});
test("routes mirrored families through inline fast-path helpers", () => {
const { host, declarations } = declarationHost(64, true);
expect(retainSym(host, { kind: "string" })).toBe("@sc_rc_retain_str");
expect(releaseSym(host, { kind: "string" })).toBe("@sc_rc_release_str");
expect(releaseSym(host, { kind: "set", elem: { kind: "f64" } } as never)).toBe(
"@sc_rc_release_map",
);
expect(releaseSym(host, { kind: "union", unionId: "u0" } as never)).toBe(
"@sc_rc_release_union",
);
// Array retains inline; array releases and dyn releases stay calls.
const array = { kind: "array", elem: { kind: "string" } } as never;
expect(retainSym(host, array)).toBe("@sc_rc_retain_arr");
expect(releaseSym(host, array)).toBe("@scr_arr_release");
expect(retainSym(host, { kind: "dyn" })).toBe("@sc_rc_retain_dyn");
expect(releaseSym(host, { kind: "dyn" })).toBe("@scr_dyn_release");
// Unmirrored families and caught snapshots keep their runtime entry points.
expect(retainSym(host, { kind: "url" })).toBe("@scr_url_retain_v");
expect(retainSym(host, { kind: "caught" })).toBe("@scr_caught_retain");
// Container function pointers keep the runtime symbols (identity tests).
expect(vAdapters(host, { kind: "dyn" }).retain).toBe("@scr_dyn_retain_v");
expect(vAdapters(host, { kind: "string" }).release).toBe("@scr_str_release_v");
expect(declarations).toContain("declare void @scr_str_release(ptr)");
expect(declarations).toContain("declare void @scr_cyc_on_release(ptr)");
expect(inlineRcDecls(host).sort()).toEqual([
"declare void @scr_cyc_on_release(ptr)",
"declare void @scr_map_release(ptr)",
"declare void @scr_str_release(ptr)",
"declare void @scr_union_release(ptr)",
]);
});
test("mirrors the C fast paths and header offsets", () => {
const { host } = declarationHost(64, true);
retainSym(host, { kind: "string" });
releaseSym(host, { kind: "string" });
retainSym(host, { kind: "array", elem: { kind: "string" } } as never);
releaseSym(host, { kind: "map", key: { kind: "string" }, value: { kind: "f64" } } as never);
releaseSym(host, { kind: "union", unionId: "u0" } as never);
retainSym(host, { kind: "promise", inner: { kind: "f64" } } as never);
const ir = emitInlineRcHelpers(host).join("\n");
const fn = (name: string): string => {
const start = ir.indexOf(`@${name}(`);
expect(start).toBeGreaterThan(-1);
return ir.slice(start, ir.indexOf("\n}", start));
};
// Strings: no NULL test on retain (scr_str_retain dereferences), no header.
expect(fn("sc_rc_retain_str")).not.toContain("null");
expect(fn("sc_rc_retain_str")).not.toContain("store i32");
expect(fn("sc_rc_release_str")).toContain("icmp eq i64 %rc, 1");
expect(fn("sc_rc_release_str")).toContain("call void @scr_str_release(ptr %o) cold");
expect(fn("sc_rc_release_str")).not.toContain("scr_cyc_on_release");
// Arrays: mark-live only when elem_trace says the array is headered.
expect(fn("sc_rc_retain_arr")).toContain(
"getelementptr inbounds %ScrArr, ptr %o, i32 0, i32 6",
);
expect(fn("sc_rc_retain_arr")).toContain("store i32 0, ptr %colorp");
// Maps: a headered map's release is the runtime's; no inline buffering.
expect(fn("sc_rc_release_map")).toContain("%ScrMapRc, ptr %o, i32 0, i32 5");
expect(fn("sc_rc_release_map")).toContain("%ScrMapRc, ptr %o, i32 0, i32 8");
expect(fn("sc_rc_release_map")).toContain("br i1 %headered, label %slow, label %dec");
expect(fn("sc_rc_release_map")).not.toContain("store i32 1");
// Unions: purple store at obj-16, then the i16 `buffered` at obj-12.
expect(fn("sc_rc_release_union")).toContain("getelementptr i8, ptr %o, i64 -16");
expect(fn("sc_rc_release_union")).toContain("store i32 1, ptr %colorp");
expect(fn("sc_rc_release_union")).toContain("getelementptr i8, ptr %o, i64 -12");
expect(fn("sc_rc_release_union")).toContain("load i16, ptr %bufp");
expect(fn("sc_rc_release_union")).toContain("call void @scr_cyc_on_release(ptr %o)");
// Promises tolerate NULL and always carry a header.
expect(fn("sc_rc_retain_promise")).toContain("icmp eq ptr %o, null");
expect(fn("sc_rc_retain_promise")).toContain("store i32 0, ptr %colorp");
});
test("uses the wasm32 header offsets", () => {
const { host } = declarationHost(32, true);
releaseSym(host, { kind: "union", unionId: "u0" } as never);
const ir = emitInlineRcHelpers(host).join("\n");
expect(ir).toContain("load i32, ptr %o");
expect(ir).toContain("getelementptr i8, ptr %o, i32 -12");
expect(ir).toContain("getelementptr i8, ptr %o, i32 -8");
});
});
describe("live-object audit notes", () => {
const mod: IrModule = {
irVersion: 15,
sourceFile: "audit.ts",
entry: "main",
functions: [],
records: [{ id: "r0", fields: [{ name: "x", type: F64 }] }],
};
const emit = (objectAudit?: boolean) => {
const { host, declarations } = declarationHost();
const defs = emitRecordShapes(
objectAudit === undefined ? host : { ...host, objectAudit },
mod,
).defs.join("\n");
return { defs, declarations };
};
test("direct emission keeps the SCR_RC_AUDIT notes by default", () => {
const { defs, declarations } = emit();
expect(defs).toContain("call void @scr_obj_alloc_note()");
expect(defs).toContain("call void @scr_obj_free_note()");
expect(declarations).toContain("declare void @scr_obj_alloc_note()");
});
test("plain builds omit the calls and their declarations", () => {
const { defs, declarations } = emit(false);
expect(defs).not.toContain("_note()");
expect(declarations.filter((decl) => decl.includes("_note()"))).toEqual([]);
});
});
+308 -41
View File
@@ -26,6 +26,7 @@ import {
mangleRecordStruct,
mangleRecordTrace,
} from "../mangle.js";
import { emitObjectAlloc, emitObjectFree } from "./alloc.js";
import { llvmCommentText } from "./common.js";
import { LlvmUnsupportedError } from "./unsupported.js";
@@ -43,6 +44,19 @@ export interface ShapeHost {
readonly tracedUnions: Set<string>;
readonly recordsById: Map<string, IrRecordShape>;
readonly recordCloneShapes: ReadonlySet<string>;
/** Emit the live-object audit notes (`scr_obj_alloc_note` /
* `scr_obj_free_note`) in emitted new/free helpers. Only runtimes built
* with SCR_RC_AUDIT (the sanitized lane) count them; release and dev
* runtime packs define them empty, so plain builds omit the calls.
* Absent means emit. */
readonly objectAudit?: boolean;
/** Inline the small-object allocator's fast paths (llvm/alloc.ts). */
readonly inlineAlloc?: boolean;
/** Inline RC fast-path helpers requested so far ("<family>:<op>"); the
* emitter defines each once through {@link emitInlineRcHelpers}. Null
* keeps every retain/release a runtime call: inline RC is a
* size-for-speed optimization of the `speed` posture only. */
readonly rcHelpers: Set<string> | null;
}
/** Every emitted function/helper carries #0 = { sanitize_address } — see
@@ -91,16 +105,25 @@ export function vAdapters(host: ShapeHost, t: IrType): { retain: string; release
}
/** The retain call target (ptr → ptr, +1 unless immortal) — the `_v`
* table above; the split exists so call sites read type-directedly. */
* table above; the split exists so call sites read type-directedly.
* Families with a mirrored fast path call the inline helper instead. */
export function retainSym(host: ShapeHost, t: IrType): string {
const stem = runtimeRcStem(t);
if (host.rcHelpers !== null && stem !== null && inlineRcFamily(stem, "retain") !== null) {
return inlineRcSym(host, stem, "retain");
}
return vAdapters(host, t).retain;
}
/** The release call target (ptr → void, NULL-tolerant). The runtime's
* typed releases are external symbols, so the direct (non-`_v`) entry
* points serve where one exists; records use their emitted helper. */
* points serve where one exists; records use their emitted helper.
* Families with a mirrored fast path call the inline helper instead. */
export function releaseSym(host: ShapeHost, t: IrType): string {
const stem = runtimeRcStem(t);
if (host.rcHelpers !== null && stem !== null && inlineRcFamily(stem, "release") !== null) {
return inlineRcSym(host, stem, "release");
}
if (stem !== null) {
// Class objects share the container adapter at direct release sites.
const suffix = t.kind === "classval" ? "_release_v" : "_release";
@@ -118,6 +141,267 @@ export function releaseSym(host: ShapeHost, t: IrType): string {
}
}
/* ── inline RC fast paths ─────────────────────────────────────────────── */
/** How a runtime family decides whether an object carries a ScrCycHdr:
* never, always, or per object through the trace slots its C retain and
* release test (`a->elem_trace`, `m->key_trace || m->val_trace`). */
type RcCycle = "none" | "always" | "arr" | "map";
interface InlineRcFamily {
readonly cycle: RcCycle;
/** The C retain tolerates NULL (the others dereference unconditionally). */
readonly retainNull: boolean;
/** The out-of-line release behind the inline fast path: it owns the
* rc == 1 → 0 transition and, for per-object (`arr`/`map`) families,
* every release of a headered object. Null keeps the release a plain
* runtime call. */
readonly release: string | null;
}
/** Runtime RC families whose fast paths the backend mirrors in IR, keyed
* by the family name without its `scr_` stem prefix. Each
* row restates the C entry points in scr_runtime.h and the runtime TUs:
* retain is `if (rc != SIZE_MAX) { rc++; mark-live if headered }`, and a
* release that leaves the object alive is `rc--`, then scr_cyc_on_release
* when headered. Everything else (destruction, RC-audit accounting, the
* collector's on_dead bookkeeping) stays in the runtime release, which
* repeats its own checks, so the fast path never duplicates it.
*
* Releases stay plain calls for:
* - dyn: scr_dyn_release buffers a surviving object only for some kinds.
* - arr: array releases are the most numerous release sites and the
* least hot (at most 1% self time across the benchmark suite), and
* inlining them roughly doubled the code growth for no measured speedup.
* Array retains are inlined. */
export const INLINE_RC_FAMILIES: Readonly<Record<string, InlineRcFamily>> = {
str: { cycle: "none", retainNull: false, release: "scr_str_release" },
bytes: { cycle: "none", retainNull: false, release: "scr_bytes_release" },
regex: { cycle: "none", retainNull: false, release: "scr_regex_release" },
bigint: { cycle: "none", retainNull: true, release: "scr_bigint_release" },
arr: { cycle: "arr", retainNull: false, release: null },
map: { cycle: "map", retainNull: false, release: "scr_map_release" },
union: { cycle: "always", retainNull: false, release: "scr_union_release" },
closure: { cycle: "always", retainNull: false, release: "scr_closure_release" },
promise: { cycle: "always", retainNull: true, release: "scr_promise_release" },
classobj: { cycle: "always", retainNull: true, release: "scr_classobj_release" },
box: { cycle: "always", retainNull: false, release: "scr_box_release" },
dyn: { cycle: "always", retainNull: false, release: null },
};
type RcOp = "retain" | "release";
/** The mirrored family for an RC operation on a runtime stem (`scr_str`),
* or null when it stays a call. */
export function inlineRcFamily(stem: string, op: RcOp): InlineRcFamily | null {
return mirroredFamily(stem.replace(/^scr_/, ""), op);
}
function mirroredFamily(family: string, op: RcOp): InlineRcFamily | null {
if (!Object.hasOwn(INLINE_RC_FAMILIES, family)) return null;
const row = INLINE_RC_FAMILIES[family]!;
return op === "retain" || row.release !== null ? row : null;
}
/** Request one inline RC helper for a runtime stem (`scr_str`) and return
* its symbol. */
function inlineRcSym(host: ShapeHost, stem: string, op: RcOp): string {
return requestRcHelper(host, stem.replace(/^scr_/, ""), op);
}
/** The release call target for a capture box: scr_box_release's fast path,
* or scr_box_release itself without inline RC. */
export function boxReleaseSym(host: ShapeHost): string {
if (host.rcHelpers === null) {
host.declare(`declare void @scr_box_release(ptr)`);
return "@scr_box_release";
}
return requestRcHelper(host, "box", "release");
}
/** Record the helper and declare the runtime symbols it calls now, so they
* land in the extern block. */
function requestRcHelper(host: ShapeHost, family: string, op: RcOp): string {
if (host.rcHelpers === null || mirroredFamily(family, op) === null) {
throw new LlvmUnsupportedError(`inline rc:${family}:${op}`);
}
host.rcHelpers.add(`${family}:${op}`);
for (const decl of rcHelperDecls(family, op)) host.declare(decl);
return `@${inlineRcName(family, op)}`;
}
function inlineRcName(family: string, op: RcOp): string {
return `sc_rc_${op}_${family}`;
}
function rcHelperDecls(family: string, op: RcOp): string[] {
const row = INLINE_RC_FAMILIES[family]!;
if (op === "retain" || row.release === null) return [];
return [
`declare void @${row.release}(ptr)`,
...(row.cycle === "always" ? [`declare void @scr_cyc_on_release(ptr)`] : []),
];
}
/** The runtime declarations the requested helpers call (deduplicated). */
/** The inline retain/release helper keys ("family:op") the program used, in
* insertion order; empty when inline RC is off. Copied into a plain array so
* callers stay inside the self-hosted subset (no `Set | []` unions). */
function rcHelperKeys(host: ShapeHost): string[] {
const keys: string[] = [];
if (host.rcHelpers) for (const key of host.rcHelpers) keys.push(key);
return keys;
}
export function inlineRcDecls(host: ShapeHost): string[] {
const decls = new Set<string>();
for (const key of rcHelperKeys(host)) {
const [family, op] = key.split(":") as [string, RcOp];
for (const decl of rcHelperDecls(family, op)) decls.add(decl);
}
return [...decls];
}
/** Branch to `yes` when a per-object family's object carries a cycle
* header (its C retain/release trace-slot test), else to `no`. */
function rcHeaderTest(cycle: "arr" | "map", yes: string, no: string): string[] {
if (cycle === "arr") {
return [
` %tracep = getelementptr inbounds %ScrArr, ptr %o, i32 0, i32 6 ; elem_trace`,
` %trace = load ptr, ptr %tracep`,
` %headered = icmp ne ptr %trace, null`,
` br i1 %headered, label %${yes}, label %${no}`,
];
}
return [
` %vtracep = getelementptr inbounds %ScrMapRc, ptr %o, i32 0, i32 5 ; val_trace`,
` %vtrace = load ptr, ptr %vtracep`,
` %ktracep = getelementptr inbounds %ScrMapRc, ptr %o, i32 0, i32 8 ; key_trace`,
` %ktrace = load ptr, ptr %ktracep`,
` %vheadered = icmp ne ptr %vtrace, null`,
` %kheadered = icmp ne ptr %ktrace, null`,
` %headered = or i1 %vheadered, %kheadered`,
` br i1 %headered, label %${yes}, label %${no}`,
];
}
function retainHelper(host: ShapeHost, family: string, row: InlineRcFamily): string[] {
const S = host.sizeType;
const markLive = [
` %colorp = getelementptr i8, ptr %o, ${S} -${host.cycleColorOffset}`,
` store i32 0, ptr %colorp ; mark live`,
` br label %done`,
];
return [
`define internal ptr @${inlineRcName(family, "retain")}(ptr %o) ${FN_ATTRS} { ; scr_${family}_retain fast path`,
`entry:`,
...(row.retainNull
? [` %isnull = icmp eq ptr %o, null`, ` br i1 %isnull, label %done, label %check`, `check:`]
: []),
` %rc = load ${S}, ptr %o`,
` %imm = icmp eq ${S} %rc, -1`,
` br i1 %imm, label %done, label %inc`,
`inc:`,
` %n = add ${S} %rc, 1`,
` store ${S} %n, ptr %o`,
...(row.cycle === "none"
? [` br label %done`]
: row.cycle === "always"
? markLive
: [...rcHeaderTest(row.cycle, "live", "done"), `live:`, ...markLive]),
`done:`,
` ret ptr %o`,
`}`,
``,
];
}
function releaseHelper(host: ShapeHost, family: string, row: InlineRcFamily): string[] {
const S = host.sizeType;
const color = host.cycleColorOffset;
const cycle = row.cycle;
return [
`define internal void @${inlineRcName(family, "release")}(ptr %o) ${FN_ATTRS} { ; ${row.release} fast path`,
`entry:`,
` %isnull = icmp eq ptr %o, null`,
` br i1 %isnull, label %done, label %check`,
`check:`,
` %rc = load ${S}, ptr %o`,
` %imm = icmp eq ${S} %rc, -1`,
` br i1 %imm, label %done, label %owned`,
`owned:`,
` %last = icmp eq ${S} %rc, 1`,
...(cycle === "arr" || cycle === "map"
? [
` br i1 %last, label %slow, label %probe`,
`probe:`,
...rcHeaderTest(cycle, "slow", "dec"),
]
: [` br i1 %last, label %slow, label %dec`]),
`slow:`,
` call void @${row.release}(ptr %o) cold ; the runtime release owns this case`,
` br label %done`,
`dec:`,
` %n = sub ${S} %rc, 1`,
` store ${S} %n, ptr %o`,
...(family === "union"
? [
// scr_union_release buffers a surviving box only when its arm can
// reach a cycle: a box whose arm_trace is NULL visits nothing.
` %atp = getelementptr i8, ptr %o, ${S} ${S === "i64" ? 32 : 16} ; ScrUnion.arm_trace`,
` %at = load ptr, ptr %atp`,
` %untraced = icmp eq ptr %at, null`,
` br i1 %untraced, label %done, label %root`,
`root:`,
]
: []),
...(cycle === "always"
? [
// scr_cyc_on_release: color = PURPLE; an already-buffered candidate
// is done, anything else is enqueued by the runtime.
` %colorp = getelementptr i8, ptr %o, ${S} -${color}`,
` store i32 1, ptr %colorp ; purple: possible cycle root`,
` %bufp = getelementptr i8, ptr %o, ${S} -${color - 4}`,
` %buf = load i16, ptr %bufp`,
` %queued = icmp ne i16 %buf, 0`,
` br i1 %queued, label %done, label %enqueue`,
`enqueue:`,
` call void @scr_cyc_on_release(ptr %o) ; buffer the candidate; may collect`,
]
: []),
` br label %done`,
`done:`,
` ret void`,
`}`,
``,
];
}
/** Definitions for every requested inline RC helper, in a stable order.
*
* retain: a NULL skip where the C retain tolerates NULL, the immortal skip,
* the increment, and for headered objects the mark-live `store i32 0` at
* obj-cycleColorOffset (scr_cyc_mark_live).
*
* release: NULL and immortal skips. rc == 1 (and any headered object of a
* per-object family) calls the runtime release, marked cold so destruction
* stays out of line. Otherwise decrement; always-headered families then
* inline scr_cyc_on_release's already-buffered case (color = PURPLE, and
* `buffered` is the i16 four bytes after color), calling the runtime only
* to enqueue a new candidate. scr_runtime.h asserts both header offsets and
* the trace-slot offsets. */
export function emitInlineRcHelpers(host: ShapeHost): string[] {
const out: string[] = [];
for (const key of rcHelperKeys(host).sort()) {
const [family, op] = key.split(":") as [string, RcOp];
const row = INLINE_RC_FAMILIES[family]!;
out.push(
...(op === "retain" ? retainHelper(host, family, row) : releaseHelper(host, family, row)),
);
}
return out;
}
/** The trace entry point for a payload/field type, or null when the type
* cannot participate in a cycle. */
export function traceAdapter(host: ShapeHost, t: IrType): string | null {
@@ -499,8 +783,11 @@ export function emitRecordShapes(
const defs: string[] = [];
const records = mod.records ?? [];
if (records.length === 0) return { typeDefs, defs };
host.declare(`declare void @scr_obj_alloc_note()`);
host.declare(`declare void @scr_obj_free_note()`);
const audit = host.objectAudit !== false;
if (audit) {
host.declare(`declare void @scr_obj_alloc_note()`);
host.declare(`declare void @scr_obj_free_note()`);
}
for (const shape of records) {
const struct = mangleRecordStruct(shape.id);
@@ -535,15 +822,7 @@ export function emitRecordShapes(
);
t++;
}
freeBody.push(` call void @scr_obj_free_note()`);
if (traced) {
host.declare(`declare void @scr_cyc_free(ptr)`);
freeBody.push(` call void @scr_cyc_free(ptr %o)`);
} else {
host.declare(`declare void @free(ptr)`);
host.declare(`declare void @scr_weak_dispose(ptr)`);
freeBody.push(` call void @scr_weak_dispose(ptr %o)`, ` call void @free(ptr %o)`);
}
freeBody.push(...emitObjectFree(host, traced));
defs.push(
...releaseBody(
host,
@@ -557,30 +836,24 @@ export function emitRecordShapes(
);
// new: zeroed allocation (+ the overflow map on index-signature
// shapes), rc = 1, alloc note. Traced shapes allocate with the
// collector header (scr_cyc_alloc zeroes and aborts on OOM itself).
// shapes), rc = 1. Traced shapes allocate with the collector header;
// alloc.ts inlines the allocator fast paths and the alloc note.
const nw: string[] = [
`define internal ptr @${mangleRecordNew(shape.id)}() ${FN_ATTRS} {`,
`entry:`,
];
if (traced) {
host.declare(`declare ptr @scr_cyc_alloc(${host.sizeType}, ptr, ptr)`);
nw.push(
` %o = call ptr @scr_cyc_alloc(${host.sizeType} ${sizeOf}, ptr @${mangleRecordTrace(shape.id)}, ptr @${mangleRecordGcFree(shape.id)})`,
);
} else {
host.declare(`declare ptr @calloc(${host.sizeType}, ${host.sizeType})`);
host.needOom();
nw.push(
` %o = call ptr @calloc(${host.sizeType} 1, ${host.sizeType} ${sizeOf})`,
` %isnull = icmp eq ptr %o, null`,
` br i1 %isnull, label %oom, label %ok`,
`oom:`,
` call void @sc_oom()`,
` unreachable`,
`ok:`,
);
}
nw.push(
...emitObjectAlloc(
host,
sizeOf,
traced
? {
trace: `@${mangleRecordTrace(shape.id)}`,
free: `@${mangleRecordGcFree(shape.id)}`,
}
: null,
),
);
nw.push(` store ${host.sizeType} 1, ptr %o`);
if (shape.indexValue) {
// The overflow map (string-keyed): value handling is type-directed
@@ -596,7 +869,7 @@ export function emitRecordShapes(
` store ptr %ovf, ptr %ovfp`,
);
}
nw.push(` call void @scr_obj_alloc_note()`, ` ret ptr %o`, `}`, ``);
nw.push(` ret ptr %o`, `}`, ``);
defs.push(...nw);
if (host.recordCloneShapes.has(shape.id)) {
@@ -659,13 +932,7 @@ export function emitRecordShapes(
` call void ${releaseSym(host, m.type)}(ptr %v${i}) ; ${llvmCommentText(m.name)} (acyclic)`,
);
});
gf.push(
` call void @scr_obj_free_note()`,
` call void @scr_cyc_free(ptr %o)`,
` ret void`,
`}`,
``,
);
gf.push(...emitObjectFree(host, true), ` ret void`, `}`, ``);
defs.push(...gf);
}
}
@@ -434,3 +434,46 @@ test("a borrowed call returns a new owner before releasing argument snapshots",
expect(unionReleaseAt).toBeLessThan(payloadReleaseAt);
expect(main.match(/call void @sc_rrelease_/g)).toHaveLength(1);
});
test.each(["nullish", "equality"])(
"projection-only parameters consumed by %s borrow a caller's stack box",
(consumer) => {
const mod = fixture(F64);
const reader = mod.functions[1]!;
reader.body = [
ret(
consumer === "nullish"
? { kind: "nullish", left: ref("value"), right: num(-1), type: F64, loc }
: {
kind: "ternary",
cond: {
kind: "unionEq",
unionId: "optional",
negated: false,
sameValue: false,
left: ref("value"),
right: wrap(num(3)),
type: BOOL,
loc,
},
then: num(1),
else_: num(0),
type: F64,
loc,
},
),
];
mod.functions[0]!.body.push(effect(call("read", [wrap(num(3))])));
for (const width of [32, 64] as const) {
const ir = emit(mod, width);
const main = body(ir, "sc_f_main");
expect(main).toContain("alloca %ScrUnion");
expect(main).not.toContain("@scr_union_new");
// The callee may receive that stack box: it must never reach RC.
const callee = body(ir, "sc_bf_read");
expect(callee).not.toContain("@scr_union_retain");
expect(callee).not.toContain("@scr_union_release");
expect(callee).not.toContain("@scr_union_new");
}
},
);
@@ -0,0 +1,37 @@
import { describe, expect, test } from "vitest";
import { literalCapWord, stringKeyHash32 } from "./string-key-hash.js";
// Shared with packages/runtime/test/test_map.c (hash_vectors): the runtime
// computes these for heap strings, the compiler bakes them into literals.
const VECTORS: ReadonlyArray<readonly [string, number]> = [
["", 0x993d6596],
["a", 0x16793313],
["q", 0x81558571],
["ab", 0x2985501f],
["abc", 0xd7f8c3ad],
["alpha", 0x837effcd],
["kalomi", 0x4ed53d7d],
["hello world!", 0xd1ecb7bb],
["\uFFFD", 0xeba2c495],
["a much longer key of 31 bytes!!", 0x1a2fe80f],
["\u00e9t\u00e9", 0x17c315c4],
];
describe("string key hash", () => {
test.each(VECTORS)("%j matches the runtime hash", (text, hash) => {
expect(stringKeyHash32(Buffer.from(text, "utf8"))).toBe(hash);
});
test("short keys of adjacent lengths do not collide through the length seed", () => {
expect(stringKeyHash32(Buffer.from("ab"))).not.toBe(stringKeyHash32(Buffer.from("abc")));
});
test("literal capacity word keeps cap = len in the low half", () => {
for (const [text, hash] of VECTORS) {
const bytes = Buffer.from(text, "utf8");
const word = BigInt.asUintN(64, BigInt(literalCapWord(bytes)));
expect(word & 0xffffffffn).toBe(BigInt(bytes.length));
expect(word >> 32n).toBe(BigInt(hash));
}
});
});
@@ -0,0 +1,66 @@
// Compile-time copy of the runtime's 32-bit string key hash (scr_key_hash in
// packages/runtime/src/scr_key.h, folded by scr_map_hash_fold in scr_map.c).
// On 64-bit little-endian targets the runtime caches this value in the high
// half of a string's capacity word; emitting it for immortal literals lets
// Map/Set lookups with literal keys skip hashing entirely. Both sides pin the
// same vectors in their tests: a mismatch would make literal keys miss.
const MASK64 = (1n << 64n) - 1n;
function mix(input: bigint): bigint {
let value = input;
value = value ^ (value >> 30n);
value = (value * 0xbf58476d1ce4e5b9n) & MASK64;
value = value ^ (value >> 27n);
value = (value * 0x94d049bb133111ebn) & MASK64;
return value ^ (value >> 31n);
}
function u32le(bytes: Uint8Array, offset: number): bigint {
return BigInt(
(bytes[offset]! |
(bytes[offset + 1]! << 8) |
(bytes[offset + 2]! << 16) |
(bytes[offset + 3]! << 24)) >>>
0,
);
}
function shortWord(bytes: Uint8Array, offset: number, length: number): bigint {
if (length >= 4) return (u32le(bytes, offset) << 32n) | u32le(bytes, offset + length - 4);
if (length === 0) return 0n;
return (
(BigInt(bytes[offset]!) << 16n) |
(BigInt(bytes[offset + Math.floor(length / 2)]!) << 8n) |
BigInt(bytes[offset + length - 1]!)
);
}
/** scr_key_hash over UTF-8 bytes (64-bit, host little-endian loads). */
export function scrKeyHash(bytes: Uint8Array): bigint {
const length = bytes.length;
let hash = (0x9e3779b97f4a7c15n * BigInt(length + 1)) & MASK64;
if (length <= 8) return mix(hash ^ shortWord(bytes, 0, length));
let i = 0;
while (length - i >= 8) {
const word = u32le(bytes, i) | (u32le(bytes, i + 4) << 32n);
hash = mix(hash ^ word);
i += 8;
}
return mix(hash ^ shortWord(bytes, i, length - i));
}
/** The cached 32-bit Map key hash (never zero; zero means "not computed"). */
export function stringKeyHash32(bytes: Uint8Array): number {
const hash = scrKeyHash(bytes);
const folded = Number((hash ^ (hash >> 32n)) & 0xffffffffn);
return folded === 0 ? 1 : folded;
}
/** The capacity word of an immortal literal on 64-bit targets: cap = len in
* the low half, the precomputed key hash in the high half, printed as the
* signed i64 LLVM expects. */
export function literalCapWord(bytes: Uint8Array): string {
const word = BigInt(bytes.length) | (BigInt(stringKeyHash32(bytes)) << 32n);
return BigInt.asIntN(64, word).toString();
}
@@ -94,7 +94,7 @@ test("string comparisons borrow parameters while owned entry adapters release th
expect(facts(compare).parameters.get("compare")).toEqual(new Set([0, 1]));
for (const bits of [32, 64] as const) {
const borrowed = body(mod(compare), "sc_bf_compare", bits);
expect(borrowed).toContain("@scr_str_eq");
expect(borrowed).toContain("@sc_str_eq");
expect(borrowed).not.toContain("@scr_str_retain");
expect(borrowed).not.toContain("@scr_str_release");
const owned = body(mod(compare), "sc_f_compare", bits);
@@ -154,7 +154,7 @@ test("string operands preserve their left-to-right snapshot when a later assignm
const f = fn("replace", ["text"], equal(ref("text"), assigned));
const ir = body(mod(f), "sc_f_replace");
expect(ir.indexOf("@scr_str_retain_v")).toBeLessThan(ir.indexOf("@scr_str_release"));
expect(ir).toContain("@scr_str_eq");
expect(ir).toContain("@sc_str_eq");
expect(facts(f).parameters.has("replace")).toBe(false);
});
@@ -390,7 +390,47 @@ test("borrowed literals keep UTF-16 string comparison without temporary owners",
loc,
});
const ir = body(mod(compare), "sc_f_compare");
expect(ir).toContain("@scr_str_cmp_u16");
expect(ir).toContain("@sc_str_cmp_u16");
expect(ir).not.toContain("@scr_str_retain_v");
expect(ir).not.toContain("@scr_str_release");
});
test("string comparisons decide identity, lengths and leading bytes before the runtime", () => {
const order = (utf16: boolean) =>
fn(`order${utf16 ? 16 : 8}`, [], {
kind: "strCmp",
op: "<",
left: str("apple"),
right: str("apricot"),
utf16,
type: BOOL,
loc,
});
const same = fn("same", [], {
kind: "strEq",
left: str("a"),
right: str("b"),
negated: false,
type: BOOL,
loc,
});
for (const bits of [32, 64] as const) {
const ir = emitLlvmModule(mod(order(false), order(true), same), { pointerBits: bits });
const helper = (name: string) =>
new RegExp(`^define internal [^\\n]*@${name}\\([^]*?^}`, "m").exec(ir)![0];
const eq = helper("sc_str_eq");
expect(eq).toContain("icmp eq ptr %a, %b");
expect(eq).toMatch(/icmp eq i(32|64) %alen, %blen/);
expect(eq).toContain("call zeroext i1 @scr_str_eq(ptr %a, ptr %b)");
const bytes = helper("sc_str_cmp");
expect(bytes).toContain("@llvm.bswap.i64");
expect(bytes).not.toContain("%ascii");
expect(bytes).toContain("call i32 @scr_str_cmp(ptr %a, ptr %b)");
const units = helper("sc_str_cmp_u16");
expect(units).toContain("%wascii = icmp eq i64 %whigh, 0");
expect(units).toContain("%tascii = icmp eq i64 %thigh, 0");
expect(bytes).toContain("%tailable = and i1 %samelen, %short");
expect(units).toContain("%ascii = icmp sge i8 %bits, 0");
expect(units).toContain("call i32 @scr_str_cmp_u16(ptr %a, ptr %b)");
}
});
@@ -82,7 +82,7 @@ test("string dispatch uses target-width byte lengths and checks equality inside
expect(body).toContain(`load i${bits}, ptr`);
expect(body).toContain(`switch i${bits}`);
expect(body).not.toContain("scr_str_utf16_len");
expect(body.match(/call zeroext i1 @scr_str_eq/g)).toHaveLength(5);
expect(body.match(/call zeroext i1 @sc_str_eq/g)).toHaveLength(5);
const dispatch = body.split("\n").find((line) => line.includes(`switch i${bits}`))!;
expect(dispatch).toContain(`i${bits} 2, label`);
expect(dispatch.match(new RegExp(`i${bits} 4, label`, "g"))).toHaveLength(1);
@@ -108,7 +108,7 @@ function emitStringSwitch(
const literal = host.internLiteral(item.value);
const hit = B.tmp();
const next = index + 1 < group.length ? B.newLabel("sw.equal") : fallback;
B.line(`${hit} = call zeroext i1 @scr_str_eq(ptr ${disc.name}, ptr ${literal})`);
B.line(`${hit} = call zeroext i1 @sc_str_eq(ptr ${disc.name}, ptr ${literal})`);
B.condBr(hit, item.label, next);
if (next !== fallback) B.startBlock(next);
}
@@ -1,4 +1,5 @@
/** Native codegen helper protocol shared by compiler hosts. */
import type { NativeOptimization } from "./optimization.js";
import type { NativeHelperSpec, NativeTargetSpec } from "./targets.js";
export const NATIVE_CODEGEN_PROTOCOL_VERSION = "1";
@@ -15,7 +16,7 @@ const MAX_PROGRAM_PARTITIONS = 8;
* count depends only on the module, so artifacts never vary with the host. */
export function nativeProgramPartitions(
target: NativeTargetSpec,
optimization: "release" | "dev",
optimization: NativeOptimization,
llvmBytes: number,
): number {
if (optimization === "dev" || target.platform === "wasi") return 1;
@@ -167,6 +167,30 @@ test("resolves a package helper, emits atomically, and caches by all native inpu
expect(secondArtifact.dependencies).toEqual(firstArtifact.dependencies);
});
test("runtime bitcode imports reach the helper and key the native cache", async () => {
const pkg = await fakePackage();
const base = request(pkg.root, pkg.packageJson, join(pkg.root, "plain.o"));
await emitNativeArtifact(base);
const imported = {
...request(pkg.root, pkg.packageJson, join(pkg.root, "imported.o")),
importBitcode: {
paths: ["/pack/a.bc", "/pack/b.bc"],
digests: ["a".repeat(64), "b".repeat(64)],
},
};
await emitNativeArtifact(imported);
await emitNativeArtifact({
...imported,
outputPath: join(pkg.root, "changed.o"),
importBitcode: { ...imported.importBitcode, digests: ["a".repeat(64), "c".repeat(64)] },
});
await emitNativeArtifact({ ...imported, outputPath: join(pkg.root, "again.o") });
const calls = (await readFile(pkg.log, "utf8")).trim().split("\n");
expect(calls).toHaveLength(3);
expect(calls[0]).not.toContain("--import-bitcode");
expect(calls[1]).toContain("--import-bitcode /pack/a.bc --import-bitcode /pack/b.bc");
});
test("chunked LLVM input preserves emitted bytes and the native cache identity", async () => {
const pkg = await fakePackage();
const first = request(pkg.root, pkg.packageJson, join(pkg.root, "chunks.o"));
@@ -83,6 +83,8 @@ export interface NativeCodegenOptions {
cacheRoot?: string | null;
/** Program partitions, written to nativePartitionPaths(outputPath). */
partitions?: number;
/** Runtime bitcode whose small functions the helper imports for inlining. */
importBitcode?: { paths: readonly string[]; digests: readonly string[] };
}
function parseJsonObject(text: string): Record<string, unknown> | null {
@@ -281,6 +283,11 @@ function cacheKey(
.update("\0")
.update(options.sourcePath)
.update((options.partitions ?? 1) > 1 ? `\0partitions:${options.partitions}` : "")
.update(
options.importBitcode === undefined
? ""
: `\0import:${options.importBitcode.digests.join(",")}`,
)
.digest("hex");
}
@@ -375,6 +382,7 @@ export async function emitNativeArtifact(
"json",
"--source-path",
options.sourcePath,
...(options.importBitcode?.paths ?? []).flatMap((path) => ["--import-bitcode", path]),
]);
for (const stage of stages) {
const emitted = await stat(stage).catch(() => null);
@@ -1,3 +1,4 @@
import type { NativeOptimization } from "./optimization.js";
import { join } from "node:path";
import type { FfiProfile } from "../ffi/ffi-manifest.js";
import {
@@ -78,7 +79,7 @@ export interface NativeLinkInfo {
version: string;
root: string;
path_base: "runtime_pack.root";
flavor: "release" | "dev";
flavor: NativeOptimization;
objects: RuntimePackArtifact[];
archives: RuntimePackArtifact[];
};
@@ -107,7 +108,7 @@ export function formatNativeLinkInfo(
root: string;
manifest: RuntimePackManifest;
selected: RuntimePackArtifacts;
flavor: "release" | "dev";
flavor: NativeOptimization;
},
): NativeLinkInfo {
const ffiLibraries = options.ffi?.libraries ?? [];
@@ -52,10 +52,10 @@ describe("native link info", () => {
});
expect(info.ffi.symbols).toEqual(["native"]);
expect(info.link.input_order).toEqual([
"/out/app.o",
"/ffi/native.a",
"/installed/runtime/base.o",
"/installed/runtime/vendor.a",
"/out/app.o",
"/ffi/native.a",
]);
expect(info.link.system_libraries).toEqual(["sqlite3", "System"]);
expect(info.link.frameworks).toEqual(["Foundation"]);
@@ -1,3 +1,4 @@
import type { NativeOptimization } from "./optimization.js";
import {
formatNativeLinkInfo,
type NativeLinkInfo,
@@ -14,7 +15,7 @@ export async function createNativeLinkInfo(options: {
target: NativeTargetSpec;
features: NativeLinkFeatures;
ffi: FfiProfile | null;
optimization?: "release" | "dev";
optimization?: NativeOptimization;
env?: NodeJS.ProcessEnv;
}): Promise<NativeLinkInfo> {
if (options.ffi?.frameworks?.length && options.target.platform !== "darwin")
@@ -1,4 +1,5 @@
/** Native tool invocation without a JavaScript host or shell command construction. */
import type { NativeOptimization } from "./optimization.js";
import { execFileSync } from "node:child_process";
import { readFileSync, statSync } from "node:fs";
import type { NativeHelperSpec, NativeTargetSpec } from "./targets.js";
@@ -38,7 +39,7 @@ export function emitNativeObject(options: {
inputPath: string;
outputPath: string;
sourcePath: string;
optimization: "release" | "dev";
optimization: NativeOptimization;
outputKind?: NativeCodegenOutputKind;
/** Program partitions, written to nativePartitionPaths(outputPath). */
partitions?: number;
@@ -1,3 +1,4 @@
import type { NativeOptimization } from "../optimization.js";
import { isMobileTarget, mobileLibraryTarget } from "../target-platform.js";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
@@ -12,7 +13,7 @@ export type NativeCacheWarmProfile = "runtime" | "tls" | "dynamic";
export interface WarmNativeCachesOptions {
/** Native object posture to seed. Defaults to the shipped release/-O2 lane. */
optimization?: "release" | "dev";
optimization?: NativeOptimization;
/** Seed ASan + RC-audit objects instead of the ordinary lane. */
sanitize?: boolean;
/** Feature families to seed. Defaults to every expensive native family. */
@@ -1,3 +1,4 @@
import type { NativeOptimization } from "../optimization.js";
import { type WindowsSubsystem } from "../targets.js";
export interface CcOptions {
@@ -17,7 +18,7 @@ export interface CcOptions {
/** Native optimization posture. Release preserves the historical -O2
* executable lane; dev selects -O0 and may compile a caller-provided LLVM
* shard set into independently cached objects before the final link. */
optimization?: "release" | "dev";
optimization?: NativeOptimization;
/** Remove symbol/debug payload from the linked executable. */
strip?: boolean;
/** PE executable subsystem; omitted and console use the driver default. */
@@ -253,7 +254,7 @@ export interface LibArchiveOptions {
cacheIdentity?: string;
sanitize?: boolean;
/** Native optimization posture: release = -O2, dev = -O0. */
optimization?: "release" | "dev";
optimization?: NativeOptimization;
/** Multi-instance library mode (the profile's abi.localize_runtime): the
* external symbols to KEEP global — every other scriptc external
* definition in the archive (the runtime's internals, the program TU's
@@ -64,6 +64,7 @@ export const EXECUTABLE_RUNTIME_SOURCES = [
"scr_crypto_async.c",
"scr_child.c",
"scr_cycle.c",
"scr_alloc.c",
] as const;
export function runtimeSrcDir(): string {
@@ -0,0 +1,30 @@
import { expect, test } from "vitest";
import {
NATIVE_OPTIMIZATIONS,
isNativeOptimization,
optimizationClass,
optimizationField,
optimizationKeyParts,
} from "./optimization.js";
test("postures parse exactly and speed shares release's optimization class", () => {
expect(NATIVE_OPTIMIZATIONS).toEqual(["release", "dev", "speed"]);
for (const value of NATIVE_OPTIMIZATIONS) expect(isNativeOptimization(value)).toBe(true);
for (const value of ["Release", "fast", "O2", "", undefined, null, 2])
expect(isNativeOptimization(value)).toBe(false);
expect(optimizationClass(undefined)).toBe("release");
expect(optimizationClass("release")).toBe("release");
expect(optimizationClass("speed")).toBe("release");
expect(optimizationClass("dev")).toBe("dev");
});
test("release keeps historical key shapes while dev and speed are keyed apart", () => {
expect(optimizationKeyParts(undefined)).toEqual([]);
expect(optimizationKeyParts("release")).toEqual([]);
expect(optimizationKeyParts("dev")).toEqual(["optimization-dev"]);
expect(optimizationKeyParts("speed")).toEqual(["optimization-speed"]);
expect(optimizationField("release")).toEqual({});
expect(optimizationField(undefined)).toEqual({});
expect(optimizationField("dev")).toEqual({ optimization: "dev" });
expect(optimizationField("speed")).toEqual({ optimization: "speed" });
});
@@ -0,0 +1,40 @@
/** Native optimization postures.
*
* - `release` (default): -O2 program code against the release runtime flavor.
* - `dev`: -O0, source line tables, and cached object shards.
* - `speed`: -O2 plus optimizations that trade binary size and build time for
* run-time speed (a size-for-speed runtime flavor, runtime bitcode import,
* inline reference counting). Release output never depends on them.
*
* Speed is an optimized posture everywhere release is: it shares -O2, release
* linker flags, and release-only program partitioning. Cache keys spell it
* out so speed and release artifacts never share an identity; release keys
* keep their historical shape (release is the absent default). */
export type NativeOptimization = "release" | "dev" | "speed";
export const NATIVE_OPTIMIZATIONS: readonly NativeOptimization[] = ["release", "dev", "speed"];
export function isNativeOptimization(value: unknown): value is NativeOptimization {
return value === "release" || value === "dev" || value === "speed";
}
/** The -O0/-O2 class of a posture: dev is unoptimized, release and speed are
* optimized identically at the C/LLVM optimization-level layer. */
export function optimizationClass(optimization: NativeOptimization | undefined): "release" | "dev" {
return optimization === "dev" ? "dev" : "release";
}
/** Cache-key parts for a posture; empty for release so historical release
* keys are unchanged. */
export function optimizationKeyParts(optimization: NativeOptimization | undefined): string[] {
return optimization === undefined || optimization === "release"
? []
: [`optimization-${optimization}`];
}
/** Posture as an optional options field: release stays absent. */
export function optimizationField(optimization: NativeOptimization | undefined): {
optimization?: "dev" | "speed";
} {
return optimization === "dev" || optimization === "speed" ? { optimization } : {};
}
@@ -1,5 +1,6 @@
/** Runtime-pack schema and selection shared by compiler hosts. Filesystem
* discovery, artifact verification and staging belong to the calling host. */
import { optimizationClass, type NativeOptimization } from "./optimization.js";
import type { NativeLinkFeatures } from "./native-link-info.js";
import { RUNTIME_ABI_MARKER, RUNTIME_ABI_VERSION } from "./runtime-abi.js";
import type { NativeTargetSpec } from "./targets.js";
@@ -18,10 +19,12 @@ export interface RuntimePackArtifact {
size: number;
}
interface RuntimePackVariant extends RuntimePackArtifact {
export interface RuntimePackVariant extends RuntimePackArtifact {
id: string;
when: Record<string, boolean>;
defines: string[];
/** LLVM bitcode of the same unit, imported by the helper for inlining. */
bitcode?: RuntimePackArtifact;
}
interface RuntimePackUnit {
@@ -39,6 +42,7 @@ export type RuntimePackMode = "executable" | "library" | "library-thread";
export type RuntimePackFlavor =
| "release"
| "dev"
| "speed"
| "library-release"
| "library-dev"
| "library-thread-release"
@@ -159,6 +163,7 @@ export function parseRuntimePackManifest(value: unknown): RuntimePackManifest {
const when = object(variant?.when);
return (
validArtifact(variantRaw) &&
(variant?.bitcode === undefined || validArtifact(variant.bitcode)) &&
typeof variant?.id === "string" &&
when !== null &&
Object.values(when).every((entry) => typeof entry === "boolean") &&
@@ -193,6 +198,7 @@ export function parseRuntimePackManifest(value: unknown): RuntimePackManifest {
![
"release",
"dev",
"speed",
"library-release",
"library-dev",
"library-thread-release",
@@ -201,6 +207,8 @@ export function parseRuntimePackManifest(value: unknown): RuntimePackManifest {
) ||
((flavors.release !== undefined || flavors.dev !== undefined) &&
(!validFlavor(flavors.release, "-O2") || !validFlavor(flavors.dev, "-O0"))) ||
// The speed flavor is an optional companion of the executable flavors.
(flavors.speed !== undefined && flavors.release === undefined) ||
Object.entries(flavors).some(
([name, flavor]) => !validFlavor(flavor, name.endsWith("dev") ? "-O0" : "-O2"),
) ||
@@ -339,17 +347,35 @@ export function validateRuntimePackIdentity(
export interface RuntimePackArtifacts {
features: RuntimeFeatureSet;
runtime: RuntimePackArtifact[];
/** Selected unit variants; each may carry its bitcode artifact. */
runtime: (RuntimePackArtifact & { bitcode?: RuntimePackArtifact })[];
archives: RuntimePackArtifact[];
systemLibraries: string[];
}
/** The manifest flavor a posture links. Speed selects the executable `speed`
* flavor when the pack ships one (size-for-speed runtime objects) and the
* release flavor otherwise; library modes have no speed flavor and link
* their release objects. */
export function runtimePackFlavorKey(
manifest: RuntimePackManifest,
optimization: NativeOptimization,
mode: RuntimePackMode = "executable",
): RuntimePackFlavor {
const optimized = optimizationClass(optimization);
if (mode === "library") return optimized === "release" ? "library-release" : "library-dev";
if (mode === "library-thread")
return optimized === "release" ? "library-thread-release" : "library-thread-dev";
if (optimization === "speed" && manifest.flavors.speed !== undefined) return "speed";
return optimized;
}
/** Selection is deterministic and independent of installation paths. Both
* hosts verify and privately stage exactly these artifacts before linking. */
export function selectRuntimePackArtifacts(
manifest: RuntimePackManifest,
requested: NativeLinkFeatures,
flavor: "release" | "dev",
flavor: NativeOptimization,
env: NodeJS.ProcessEnv = process.env,
mode: RuntimePackMode = "executable",
): RuntimePackArtifacts {
@@ -359,19 +385,8 @@ export function selectRuntimePackArtifacts(
(mode !== "executable" || features.dynamic || manifest.target.object_format === "wasm")
)
throw new RuntimePackError("worker threads require a native static executable", "unsupported");
const key: RuntimePackFlavor = mode === "executable" ? flavor : `${mode}-${flavor}`;
const selectedFlavor =
mode === "library"
? flavor === "release"
? manifest.flavors["library-release"]
: manifest.flavors["library-dev"]
: mode === "library-thread"
? flavor === "release"
? manifest.flavors["library-thread-release"]
: manifest.flavors["library-thread-dev"]
: flavor === "release"
? manifest.flavors.release
: manifest.flavors.dev;
const key = runtimePackFlavorKey(manifest, flavor, mode);
const selectedFlavor = manifest.flavors[key];
if (selectedFlavor === undefined)
throw new RuntimePackError(
`runtime pack has no ${key} flavor; reinstall the matching runtime package`,

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