Merge main

This commit is contained in:
Chris Tate
2026-10-26 03:53:02 -05:00
471 changed files with 22088 additions and 1322 deletions
+93
View File
@@ -0,0 +1,93 @@
name: LLVM distribution
# Builds the LLVM that the Linux release helpers link
# (native/llvm-codegen/llvm-dist.mjs): LLVM from its release sources with
# ThinLTO and native/llvm-codegen/llvm.profdata.gz, for each Linux
# architecture. Each job builds a helper against it and one against LLVM's
# release archive, requires the two to give identical objects on this
# checkout's helper inputs (scripts/check-llvm-dist.mjs), prints both
# helpers' times, and attaches the archive to the LLVM_DIST_TAG release.
# The arm64 build uses the profile collected on x64; its job log reports how
# many functions the profile no longer matches. Run it after a profile refresh
# or an LLVM version change, with a new LLVM_DIST_TAG.
on:
workflow_dispatch:
permissions:
contents: read
concurrency: ${{ github.workflow }}
jobs:
build:
name: Build LLVM (${{ matrix.arch }})
if: github.repository == 'vercel-labs/scriptc'
strategy:
fail-fast: false
matrix:
include:
- arch: x64
runner: ubuntu-24.04
llvm_asset: LLVM-22.1.8-Linux-X64
- arch: arm64
runner: ubuntu-24.04-arm
llvm_asset: LLVM-22.1.8-Linux-ARM64
runs-on: ${{ matrix.runner }}
# About an hour for LLVM on 4 cores, and 5 minutes for each helper link.
timeout-minutes: 240
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 11.1.3
- uses: actions/setup-node@v4
with:
node-version: "24"
cache: pnpm
- name: Install LLVM's release archive and sources
run: |
sudo apt-get update
sudo apt-get install --yes ninja-build zlib1g-dev libzstd-dev
base=https://github.com/llvm/llvm-project/releases/download/llvmorg-22.1.8
curl -fL --retry 3 -o "$RUNNER_TEMP/llvm.tar.xz" "$base/${{ matrix.llvm_asset }}.tar.xz"
tar -xJf "$RUNNER_TEMP/llvm.tar.xz" -C "$RUNNER_TEMP"
mv "$RUNNER_TEMP/${{ matrix.llvm_asset }}" "$RUNNER_TEMP/llvm-22.1.8"
curl -fL --retry 3 -o "$RUNNER_TEMP/llvm-src.tar.xz" "$base/llvm-project-22.1.8.src.tar.xz"
tar -xJf "$RUNNER_TEMP/llvm-src.tar.xz" -C "$RUNNER_TEMP"
rm "$RUNNER_TEMP/llvm.tar.xz" "$RUNNER_TEMP/llvm-src.tar.xz"
echo "$RUNNER_TEMP/llvm-22.1.8/bin" >> "$GITHUB_PATH"
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @scriptc/compiler --filter scriptc build
- name: Build the distribution
run: >-
node scripts/build-llvm-dist.mjs --source "$RUNNER_TEMP/llvm-project-22.1.8.src"
--prefix "$RUNNER_TEMP/dist/scriptc-llvm-22.1.8-linux-${{ matrix.arch }}"
--archive "$RUNNER_TEMP/scriptc-llvm-22.1.8-linux-${{ matrix.arch }}.tar.xz"
--work "$RUNNER_TEMP/llvm-dist-work"
- name: Build a helper against each LLVM
env:
HELPER: llvm-linux-${{ matrix.arch }}-gnu
run: |
LLVM_DIR="$RUNNER_TEMP/llvm-22.1.8/lib/cmake/llvm" pnpm --filter "@scriptc/$HELPER" build:native
cp "packages/$HELPER/bin/scriptc-llvm-codegen" "$RUNNER_TEMP/helper-release-archive"
LLVM_DIR="$RUNNER_TEMP/dist/scriptc-llvm-22.1.8-linux-${{ matrix.arch }}/lib/cmake/llvm" \
SCRIPTC_LLVM_THINLTO=1 pnpm --filter "@scriptc/$HELPER" build:native
cp "packages/$HELPER/bin/scriptc-llvm-codegen" "$RUNNER_TEMP/helper-profiled"
- name: Compare the helpers
run: >-
node scripts/check-llvm-dist.mjs --baseline "$RUNNER_TEMP/helper-release-archive"
--candidate "$RUNNER_TEMP/helper-profiled" --rounds 3 --work "$RUNNER_TEMP/llvm-check"
- name: Attach the archive to the release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ARCHIVE: ${{ runner.temp }}/scriptc-llvm-22.1.8-linux-${{ matrix.arch }}.tar.xz
run: |
tag=$(node --input-type=module -e "import { LLVM_DIST_TAG } from './native/llvm-codegen/llvm-dist.mjs'; console.log(LLVM_DIST_TAG)")
# Both architectures' jobs create the release if it is missing.
gh release view "$tag" > /dev/null 2>&1 ||
gh release create "$tag" --target "$GITHUB_SHA" --title "$tag" --latest=false \
--notes "LLVM 22.1.8 built with ThinLTO and the helper profile, for the Linux LLVM helpers (scripts/build-llvm-dist.mjs)." ||
gh release view "$tag" > /dev/null
gh release upload "$tag" "$ARCHIVE" "$ARCHIVE.sha256" --clobber
+117 -17
View File
@@ -83,6 +83,7 @@ jobs:
spawnSync('git', ['diff', '--quiet', run.head_sha, 'HEAD', '--', '.',
':(exclude).github/workflows/release.yml', ':(exclude)scripts/verify-native-cli.mjs',
':(exclude)scripts/package-native-cli.mjs', ':(exclude)tests/harness/native-cli-packaging.test.ts',
':(exclude)scripts/wait-npm-available.mjs', ':(exclude)tests/harness/npm-available.test.ts',
]).status !== 0) continue;
const artifacts = api(`repos/${repo}/actions/runs/${run.id}/artifacts?per_page=100`).artifacts
.filter((artifact) => !artifact.expired && artifact.size_in_bytes > 0);
@@ -100,11 +101,11 @@ jobs:
if (receiptRun) appendFileSync(process.env.GITHUB_OUTPUT, `receipt_run=${receiptRun}\n`);
NODE
# Collects the native compiler's profile on this commit
# (scripts/refresh-cli-profile.mjs), so every platform's CLI build optimizes
# with a profile of the code it compiles. The committed profile falls behind
# main within a day or two. If this job fails, the CLI builds use the
# committed profile.
# Collects the native compiler's profile and function layout on this commit
# (scripts/refresh-cli-profile.mjs, scripts/refresh-cli-layout.mjs), so every
# platform's CLI build optimizes and lays out the code it compiles. The
# committed ones fall behind main within a day or two. If this job fails,
# the CLI builds use the committed profile and layout.
cli-profile:
name: Collect the native compiler profile
needs: check-release
@@ -138,11 +139,17 @@ jobs:
- name: Collect the profile
run: >-
node scripts/refresh-cli-profile.mjs --tsc-ts "$RUNNER_TEMP/tsc-ts" --jobs 4
--output "$RUNNER_TEMP/cli-profile/cli.profdata.gz"
--output "$RUNNER_TEMP/cli-profile/cli.profdata.gz" --work "$RUNNER_TEMP/cli-profile-work"
- name: Collect the function layout
run: >-
node scripts/refresh-cli-layout.mjs --jobs 4 --instrumented "$RUNNER_TEMP/cli-profile-work/cli"
--output "$RUNNER_TEMP/cli-profile/cli.layout.gz"
- uses: actions/upload-artifact@v4
with:
name: cli-profile
path: ${{ runner.temp }}/cli-profile/cli.profdata.gz
path: |
${{ runner.temp }}/cli-profile/cli.profdata.gz
${{ runner.temp }}/cli-profile/cli.layout.gz
retention-days: 7
if-no-files-found: error
@@ -173,6 +180,7 @@ jobs:
runtime: runtime-linux-x64-gnu
cli: cli-linux-x64-gnu
llvm_asset: LLVM-22.1.8-Linux-X64
llvm_dist: x64
use_zig: true
- platform: linux-arm64
runner: ubuntu-24.04-arm
@@ -180,6 +188,7 @@ jobs:
runtime: runtime-linux-arm64-gnu
cli: cli-linux-arm64-gnu
llvm_asset: LLVM-22.1.8-Linux-ARM64
llvm_dist: arm64
use_zig: true
- platform: windows-x64
runner: windows-2022
@@ -195,6 +204,7 @@ jobs:
seed_helper: llvm-linux-x64-gnu
cli_target: x86_64-linux-musl
llvm_asset: LLVM-22.1.8-Linux-X64
llvm_dist: x64
use_zig: true
- platform: linux-arm64-musl
runner: ubuntu-24.04-arm
@@ -204,12 +214,14 @@ jobs:
seed_helper: llvm-linux-arm64-gnu
cli_target: aarch64-linux-musl
llvm_asset: LLVM-22.1.8-Linux-ARM64
llvm_dist: arm64
use_zig: true
- platform: wasm32-wasi
runner: ubuntu-24.04
helper: llvm-linux-x64-gnu
runtime: runtime-wasm32-wasi
llvm_asset: LLVM-22.1.8-Linux-X64
llvm_dist: x64
use_zig: true
runs-on: ${{ matrix.runner }}
# The Intel macOS build took 24 minutes for 0.3.2 and passed 30 for 0.3.3.
@@ -240,6 +252,28 @@ jobs:
mv "$RUNNER_TEMP/${{ matrix.llvm_asset }}" "$RUNNER_TEMP/llvm-22.1.8"
echo "LLVM_DIR=$RUNNER_TEMP/llvm-22.1.8/lib/cmake/llvm" >> "$GITHUB_ENV"
echo "$RUNNER_TEMP/llvm-22.1.8/bin" >> "$GITHUB_PATH"
# The Linux helpers link LLVM built with ThinLTO and a profile of the
# helper's code generation (native/llvm-codegen/llvm-dist.mjs), from a
# release of this repository. Without that archive they link LLVM's
# release archive, which gives the same objects more slowly. The
# wasm32-wasi job also uploads the x64 GNU helper, so it selects the same.
- name: Select the profiled LLVM distribution (Linux)
if: matrix.llvm_dist != ''
env:
ARCH: ${{ matrix.llvm_dist }}
run: |
tag=$(node --input-type=module -e "import { LLVM_DIST_TAG } from './native/llvm-codegen/llvm-dist.mjs'; console.log(LLVM_DIST_TAG)")
asset=$(node --input-type=module -e "import { llvmDistAsset } from './native/llvm-codegen/llvm-dist.mjs'; console.log(llvmDistAsset(process.env.ARCH))")
base="https://github.com/$GITHUB_REPOSITORY/releases/download/$tag"
if curl -fsSL --retry 3 -o "$RUNNER_TEMP/$asset" "$base/$asset" &&
curl -fsSL --retry 3 -o "$RUNNER_TEMP/$asset.sha256" "$base/$asset.sha256" &&
(cd "$RUNNER_TEMP" && sha256sum --check "$asset.sha256"); then
tar -xJf "$RUNNER_TEMP/$asset" -C "$RUNNER_TEMP"
echo "SCRIPTC_HELPER_LLVM_DIR=$RUNNER_TEMP/${asset%.tar.xz}/lib/cmake/llvm" >> "$GITHUB_ENV"
echo "The helper links $asset from release $tag"
else
echo "::warning::$asset is not available from release $tag; the helper links LLVM's release archive"
fi
- uses: vercel-labs/setup-zig@v1
if: matrix.use_zig == true
with:
@@ -278,7 +312,15 @@ jobs:
- name: Build native packages (POSIX)
if: matrix.platform != 'windows-x64'
run: |
pnpm --filter @scriptc/${{ matrix.helper }} build:native
if [ -n "${SCRIPTC_HELPER_LLVM_DIR:-}" ]; then
LLVM_DIR="$SCRIPTC_HELPER_LLVM_DIR" SCRIPTC_LLVM_THINLTO=1 pnpm --filter @scriptc/${{ matrix.helper }} build:native || {
echo "::warning::the helper did not build against the profiled LLVM; it links LLVM's release archive"
rm -rf node_modules/.cache/scriptc-${{ matrix.helper }}
pnpm --filter @scriptc/${{ matrix.helper }} build:native
}
else
pnpm --filter @scriptc/${{ matrix.helper }} build:native
fi
if [ "${{ matrix.use_zig }}" = true ]; then
CC=zig AR=zig pnpm --filter @scriptc/${{ matrix.runtime }} build:native
else
@@ -300,8 +342,8 @@ jobs:
with:
name: cli-profile
path: ${{ runner.temp }}/cli-profile
# Without the artifact, SCRIPTC_NATIVE_PROFILE_USE stays unset and
# build-native-cli.mts uses the committed profile.
# Without the artifact, SCRIPTC_NATIVE_PROFILE_USE and SCRIPTC_NATIVE_LAYOUT
# stay unset and build-native-cli.mts uses the committed profile and layout.
- name: Select the native compiler profile
if: matrix.cli != ''
shell: bash
@@ -311,6 +353,11 @@ jobs:
if [ -n "$COLLECTED" ]; then
echo "SCRIPTC_NATIVE_PROFILE_USE=$COLLECTED" >> "$GITHUB_ENV"
echo "The native CLI builds with the profile collected on $GITHUB_SHA"
layout="$(dirname "$COLLECTED")/cli.layout.gz"
if [ -f "$layout" ]; then
echo "SCRIPTC_NATIVE_LAYOUT=$layout" >> "$GITHUB_ENV"
echo "The native CLI builds with the function layout collected on $GITHUB_SHA"
fi
else
echo "::warning::No profile was collected on this commit; the native CLI builds with the committed profile"
fi
@@ -394,6 +441,8 @@ jobs:
contents: read
actions: read
id-token: write
outputs:
uploads: ${{ steps.publish.outputs.uploads }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -413,7 +462,7 @@ jobs:
VERSION: ${{ needs.check-release.outputs.version }}
RECEIPT_RUN: ${{ needs.check-release.outputs.receipt_run }}
run: |
touch "$RUNNER_TEMP/npm-accepted.txt"
touch "$RUNNER_TEMP/npm-accepted.txt" "$RUNNER_TEMP/npm-accepted-times.txt"
# Include this run so re-running only failed jobs restores its receipts.
for run in "$GITHUB_RUN_ID" "$RECEIPT_RUN"; do
if [ -z "$run" ]; then continue; fi
@@ -422,6 +471,7 @@ jobs:
if [ "$count" -eq 0 ]; then continue; fi
gh run download "$run" --repo "$GITHUB_REPOSITORY" --pattern "npm-accepted-$VERSION-*" --dir "$RUNNER_TEMP/npm-receipts/$run"
find "$RUNNER_TEMP/npm-receipts/$run" -name npm-accepted.txt -exec cat {} + >> "$RUNNER_TEMP/npm-accepted.txt"
find "$RUNNER_TEMP/npm-receipts/$run" -name npm-accepted-times.txt -exec cat {} + >> "$RUNNER_TEMP/npm-accepted-times.txt"
done
- name: Setup pnpm
@@ -471,6 +521,7 @@ jobs:
done
- name: Publish to npm
id: publish
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -490,10 +541,14 @@ jobs:
# Upload acceptance and public availability are separate states.
# Save acceptance immediately so retries never depend on registry lag.
# npm-release.txt lists every package of the release, and
# npm-accepted-times.txt the time npm accepted each upload, for the
# npm-available job.
publish_dir() {
dir="$1"
packed="${2:-}"
name=$(node -p "require('./$dir/package.json').name")
echo "$name@$VERSION" >> "$RUNNER_TEMP/npm-release.txt"
if grep -Fxq "$name@$VERSION" "$RUNNER_TEMP/npm-accepted.txt"; then
echo "$name@$VERSION upload already accepted, skipping"
return 0
@@ -510,6 +565,7 @@ jobs:
publish_log="$RUNNER_TEMP/npm-publish.log"
if npm publish "$packed" $PROVENANCE --access public 2>&1 | tee "$publish_log"; then
echo "$name@$VERSION" >> "$RUNNER_TEMP/npm-accepted.txt"
echo "$name@$VERSION $(date +%s)" >> "$RUNNER_TEMP/npm-accepted-times.txt"
return 0
else
publish_status=${PIPESTATUS[0]}
@@ -533,7 +589,8 @@ jobs:
# pnpm pack resolves workspace dependencies; npm handles OIDC.
# Publish dependencies first. Registry propagation after an accepted
# upload must not block the remaining packages or the GitHub release.
# upload must not block the remaining packages; the npm-available
# job waits for it.
publish_dir packages/runtime
publish_dir packages/runtime-darwin-arm64
publish_dir packages/llvm-darwin-arm64
@@ -561,23 +618,66 @@ jobs:
publish_dir packages/cli
echo "All package uploads accepted; npm registry propagation may continue after this job." >> "$GITHUB_STEP_SUMMARY"
# Each package of the release with the time its upload was accepted,
# when a run recorded it.
{
echo "uploads<<NPM_UPLOADS"
awk 'FILENAME == ARGV[1] { accepted[$1] = $2; next } { print $1, accepted[$1] }' \
"$RUNNER_TEMP/npm-accepted-times.txt" "$RUNNER_TEMP/npm-release.txt"
echo "NPM_UPLOADS"
} >> "$GITHUB_OUTPUT"
- name: Save accepted upload receipts
if: always()
uses: actions/upload-artifact@v4
with:
name: npm-accepted-${{ needs.check-release.outputs.version }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/npm-accepted.txt
path: |
${{ runner.temp }}/npm-accepted.txt
${{ runner.temp }}/npm-accepted-times.txt
if-no-files-found: ignore
retention-days: 90
# Publish standalone native distributions and the surface manifest after
# npm publishing succeeds. The release body comes from the marked changelog.
github-release:
name: Create GitHub Release
# Waits until every package of the release resolves on the npm registry,
# and reports each package's time from upload to available. Fails with the
# packages that are still missing after 2 hours. It only reads from the
# registry; the next release run retries it, and publishing skips uploads
# npm already accepted.
npm-available:
name: Wait for npm availability
needs: [check-release, publish]
if: >-
!cancelled() && needs.check-release.outputs.should_release == 'true' && needs.publish.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 135
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Wait for every package to resolve
env:
UPLOADS: ${{ needs.publish.outputs.uploads }}
run: |
printf '%s\n' "$UPLOADS" > "$RUNNER_TEMP/npm-uploads.txt"
node scripts/wait-npm-available.mjs "$RUNNER_TEMP/npm-uploads.txt"
# Publish standalone native distributions and the surface manifest after
# every npm package resolves. The release body comes from the marked
# changelog.
github-release:
name: Create GitHub Release
needs: [check-release, publish, npm-available]
if: >-
!cancelled() && needs.check-release.outputs.should_release == 'true' && needs.npm-available.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
+2
View File
@@ -18,6 +18,8 @@ The release job builds each pinned LLVM helper, precompiled runtime pack, and na
Release builds of the native CLI optimize the compiler with a profile collected on the release commit. The `cli-profile` job runs `node scripts/refresh-cli-profile.mjs` on Linux x64, which builds an instrumented compiler, runs it on tsc-ts, a slice of the corpus, and the runtime benchmark workloads, and writes the merged sparse profile. Every platform's CLI build uses that profile (`SCRIPTC_NATIVE_PROFILE_USE`), and the GitHub release attaches it as `scriptc-<version>-cli.profdata.gz`. Code the profile does not describe, or describes from older compiler sources, is optimized as without a profile. When the profile job fails, the CLI builds use the committed profile `packages/compiler/native/cli.profdata.gz`, which local and CI builds of the native compiler also use. Refresh the committed profile when compiler code has changed substantially since the last refresh: run `node scripts/refresh-cli-profile.mjs --tsc-ts <tsc-ts checkout>` on a quiet Linux x64 host and commit the new profile on its own.
Builds of the native compiler also order its functions by the committed layout `packages/compiler/native/cli.layout.gz`: the functions that `--version` and rebuilds of unchanged programs run come first, then the ones other builds run, ordered by the share of the layout's training builds that run each, so a run maps fewer pages of the executable. Functions the layout does not list keep their usual order. Function names of closures and generated helpers change with nearby edits, so the `cli-profile` job also collects a layout on the release commit (`scripts/refresh-cli-layout.mjs`, reusing the job's instrumented compiler), and the CLI builds use it (`SCRIPTC_NATIVE_LAYOUT`). Refresh the committed layout the same way as the profile, with `node scripts/refresh-cli-layout.mjs`, and commit it on its own.
Publishing uses npm trusted publishing (OIDC) — there is no npm token secret.
Each published package must have a GitHub Actions trusted publisher for
`release.yml` and the `Release` environment. A missing configuration fails
+1
View File
@@ -16,6 +16,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea
| `dynamic-methods` | cpu | untyped JavaScript calling builtin methods on values it does not type: string `slice`/`indexOf`/`toUpperCase`/`padStart`, array `push`/`pop`/`includes`, `Map` and `Set`, and an object's own methods |
| `dynamic-properties` | cpu | untyped JavaScript reading and writing properties of values it does not type: records built by object literals in two key orders, nested records, and instances of a class and its subclass |
| `functional-pipeline` | cpu | `filter`/`map`/`reduce`/`flatMap` chains, closures, small records |
| `handler-tables` | cpu | dispatch tables of functions: `Record<string, fn>` read as `t[k]!(…)`, `t[k]?.(…)` and through a checked local, a `Map<string, fn>`, an array of handlers, and an object literal of methods; some handlers declare fewer parameters than the table type |
| `intern-table`, `intern-table-map`, `intern-table-1`, `intern-table-map-1` | cpu | worker threads interning the same type-like objects under string keys and records under number keys, then hits and misses: one shared `InternTable` per kind of key, or a module-level `Map` per thread (the `-map` entries), with 8 threads or 1 (the `-1` entries); under Node.js every thread has its own tables |
| `json-records` | cpu | `JSON.stringify`, typed `JSON.parse`, Map aggregation |
| `json-unions` | cpu | `JSON.parse` into discriminated unions: an event log with eight kinds and nested expression trees with nine, evaluated after the cast |
@@ -0,0 +1,6 @@
{
"entry": "workloads/handler-tables.ts",
"args": [
"4"
]
}
@@ -0,0 +1,108 @@
// Dispatch tables of functions: a `Record<string, fn>` of opcode handlers
// called as `ops[name]!(…)`, `ops[name]?.(…)` and through a checked local,
// a `Map<string, fn>` of the same handlers, an array of handlers indexed by
// opcode number, and an object literal of methods. Some handlers declare
// fewer parameters than the table's function type.
type Op = (acc: number, arg: number) => number;
const ops: Record<string, Op> = {
add: (acc, arg) => acc + arg,
sub: (acc, arg) => acc - arg,
mul: (acc, arg) => (acc * arg) % 1000003,
neg: (acc) => -acc,
half: (acc) => Math.floor(acc / 2),
mix: (acc, arg) => (acc ^ arg) & 0xffff,
};
const byName = new Map<string, Op>(Object.entries(ops));
const byCode: Op[] = Object.values(ops);
const names = Object.keys(ops);
const methods = {
add(acc: number, arg: number): number {
return acc + arg;
},
sub(acc: number, arg: number): number {
return acc - arg;
},
mul(acc: number, arg: number): number {
return (acc * arg) % 1000003;
},
};
interface Insn {
name: string;
code: number;
arg: number;
}
function program(length: number): Insn[] {
const out: Insn[] = [];
let seed = 12345;
for (let i = 0; i < length; i++) {
seed = (seed * 1103515245 + 12345) % 2147483648;
const code = seed % (names.length + 1);
// One in seven instructions names an opcode no table holds.
out.push({ name: code < names.length ? names[code]! : "nop", code, arg: (seed >> 8) % 97 });
}
return out;
}
function runAsserted(insns: Insn[]): number {
let acc = 1;
for (const insn of insns) if (insn.name !== "nop") acc = ops[insn.name]!(acc, insn.arg);
return acc;
}
function runOptional(insns: Insn[]): number {
let acc = 1;
for (const insn of insns) acc = ops[insn.name]?.(acc, insn.arg) ?? acc + 1;
return acc;
}
function runChecked(insns: Insn[]): number {
let acc = 1;
for (const insn of insns) {
const handler = ops[insn.name];
if (handler) acc = handler(acc, insn.arg);
}
return acc;
}
function runMap(insns: Insn[]): number {
let acc = 1;
for (const insn of insns) {
const handler = byName.get(insn.name);
if (handler !== undefined) acc = handler(acc, insn.arg);
}
return acc;
}
function runArray(insns: Insn[]): number {
let acc = 1;
for (const insn of insns) if (insn.code < byCode.length) acc = byCode[insn.code]!(acc, insn.arg);
return acc;
}
function runMethods(insns: Insn[]): number {
let acc = 1;
for (const insn of insns) {
if (insn.code === 0) acc = methods.add(acc, insn.arg);
else if (insn.code === 1) acc = methods.sub(acc, insn.arg);
else if (insn.code === 2) acc = methods["mul"](acc, insn.arg);
}
return acc;
}
const rounds = Number(process.argv[2] ?? "4");
const insns = program(200_000);
const totals = [0, 0, 0, 0, 0, 0];
for (let round = 0; round < rounds * 10; round++) {
totals[0] = (totals[0]! + runAsserted(insns)) % 1e9;
totals[1] = (totals[1]! + runOptional(insns)) % 1e9;
totals[2] = (totals[2]! + runChecked(insns)) % 1e9;
totals[3] = (totals[3]! + runMap(insns)) % 1e9;
totals[4] = (totals[4]! + runArray(insns)) % 1e9;
totals[5] = (totals[5]! + runMethods(insns)) % 1e9;
}
console.log(totals.join(" "));
+65 -2
View File
@@ -2,7 +2,7 @@
title: "CLI Reference"
---
The `scriptc` command has five subcommands: `build`, `run`, `coverage`, `cache warm`, and `merge-profiles`. `scriptc --help` prints this summary and the options below, and `scriptc --version` prints the installed version.
The `scriptc` command has six subcommands: `build`, `run`, `coverage`, `types`, `cache warm`, and `merge-profiles`. `scriptc --help` prints this summary and the options below, and `scriptc --version` prints the installed version.
```console
$ scriptc --help
@@ -21,6 +21,13 @@ Usage:
exporting the profile symbols; a profile
with a sidecar section also gets the
contract sidecar JSON beside the archive
scriptc types patch <package> <selector> <type> [--range <r>] [--dry-run]
fix a wrong declaration of an installed
package: shows what the selector
matches and adds the type patch to
package.json ("| undefined" widens)
scriptc types list the project's type patches and what
they match
scriptc cache warm [runtime|tls…] verify and load precompiled runtime families
for the current compiler/SDK/target
scriptc merge-profiles -o <out.profdata> [--sparse] <profile…>
@@ -88,6 +95,24 @@ $ scriptc run fib.ts
Analyzes the program without building it and reports what compiles and what blocks the rest. See [Coverage Reports](/docs/coverage).
## scriptc types
Writes and lists [type patches](/docs/dependencies#fix-a-packages-types), the project's fixes for wrong declarations of npm packages.
<dl>
<dt><code>scriptc types patch &lt;package&gt; &lt;selector&gt; &lt;type&gt;</code></dt>
<dd>Finds the package installed for the working directory, prints every declaration the selector matches in its declaration files, its JSDoc and its <code>@types/</code> package with the type before and after, and adds the patch to the nearest <code>package.json</code>. The patch joins an entry whose range covers the installed version, or gets the range <code>^&lt;installed version&gt;</code>. Nothing is written when the selector matches nothing. <code>--range &lt;range&gt;</code> chooses the range, and <code>--dry-run</code> only prints.</dd>
<dt><code>scriptc types list</code></dt>
<dd>Prints each type patch in the nearest <code>package.json</code>, whether it applies to the installed version, and what it matches.</dd>
</dl>
```console
$ scriptc types list
commander@^15.0.0
HelpContext.helpWidth: | undefined
node_modules/commander/lib/command.js:2481 number -> number | undefined
```
## scriptc cache warm
Verifies and loads the installed precompiled runtime objects and vendored libraries, and prints the cache directory. Pass `runtime`, `tls`, or both to select which to warm. Ordinary builds don't need this step. With `--sanitize`, it builds the instrumented runtime objects using a C toolchain.
@@ -138,11 +163,14 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
<dt><code>--target-cpu &lt;baseline|x86-64-v2|x86-64-v3|x86-64-v4|native&gt;</code></dt>
<dd>On x86-64 targets, compile the program's code for an x86-64 microarchitecture level. The default, <code>baseline</code>, runs on any x86-64 CPU. A higher level lets LLVM use more instructions, and the executable then runs only on CPUs that support that level. <code>native</code> uses the highest level the build machine supports. Valid for executable, assembly, and object output on x86-64 targets. See <a href="#target-cpu">Target CPU</a>.</dd>
<dt><code>--cpu-dispatch</code></dt>
<dd>On x86-64 targets, compile the program's code twice, for the baseline and for <code>x86-64-v3</code>, and choose the copy when the program starts. The executable runs on any x86-64 CPU and uses the <code>x86-64-v3</code> code on CPUs that support it. Not used with a <code>--target-cpu</code> level above <code>baseline</code>. Valid only for executable builds. See <a href="#runtime-cpu-dispatch">Runtime CPU dispatch</a>.</dd>
<dt><code>--profile-generate &lt;pattern&gt;</code></dt>
<dd>On Linux, build an instrumented executable for profile-guided optimization. Each run writes a raw profile to the path pattern, where <code>%p</code> expands to the process ID; the <code>LLVM_PROFILE_FILE</code> environment variable overrides the pattern at run time. A relative pattern resolves from the current working directory. Instrumented programs run slower. Valid only for executable builds with <code>--optimization=release</code> or <code>speed</code>.</dd>
<dt><code>--profile-use &lt;file.profdata&gt;</code></dt>
<dd>Optimize the executable with a profile from <code>scriptc merge-profiles</code>: code is laid out, inlined, and split by how the profiled runs used it. Code the profile does not describe, or describes from an older version of the program, is optimized as without a profile, so a stale profile only loses part of its gain. A new or changed profile compiles every partition of the program again. Valid only for executable builds with <code>--optimization=release</code> or <code>speed</code>.</dd>
<dd>Optimize the executable with a profile from <code>scriptc merge-profiles</code>: code is laid out, inlined, and split by how the profiled runs used it. Code the profile does not describe, or describes from an older version of the program, is optimized as without a profile, so a stale profile only loses part of its gain. A new or changed profile compiles every partition of the program again. <code>scriptc build</code> prints how the profile matched the program's functions on stderr; see <a href="#profile-guided-optimization">Profile-guided optimization</a>. Valid only for executable builds with <code>--optimization=release</code> or <code>speed</code>.</dd>
<dt><code>--npm-static &lt;pkg[,pkg…]|auto&gt;</code></dt>
<dd>Name npm packages to compile even if they don't ship their own type declarations (repeatable). A trailing <code>...</code>, as in <code>fs-extra...</code>, also names every package that package depends on. <code>auto</code> uses the default automatic selection. Packages that can't compile produce diagnostics. See <a href="/docs/dependencies#explicit-package-selection">npm Dependencies</a>.</dd>
@@ -234,6 +262,25 @@ $ scriptc build app.ts --optimization=speed --profile-use app.profdata
Train on workloads like the ones the program will run: code the training never reached is optimized as without a profile. The profile can be committed and reused as the program changes. Functions whose control flow changed since the profile was collected lose their profile, and the rest keep theirs, so regenerate it when much of the program has changed. [Build parallelism](/docs/how-it-works#build-parallelism) describes what a profile adds to build time.
`scriptc build --profile-use` prints one line on stderr with how the profile matched the program's functions:
```
scriptc: profile app.profdata: 9170 functions annotated, 0 mismatched, 28636 without a record
```
- **annotated:** functions optimized with the profile's counts.
- **mismatched:** functions whose control flow changed since the profile was collected. They are optimized without counts.
- **without a record:** functions the profile does not describe: new functions, and functions the profiled runs never reached.
- When the build reuses cached partitions whose counts were not recorded, the line ends with the number of those partitions.
When more than 10% of the functions the profile describes (annotated and mismatched) are mismatched, the build also prints a warning:
```
scriptc: warning: 3739 of the 8947 functions app.profdata describes (41.8%) changed since it was collected and are optimized without their counts; collect the profile again
```
The build still succeeds. Collecting the profile again with the current program and scriptc version restores the counts.
## Target CPU
By default, x86-64 executables run on any x86-64 CPU, so the compiler uses only the baseline instruction set (SSE2). `--target-cpu` compiles the program's code for a higher x86-64 microarchitecture level. Each level includes the ones below it:
@@ -271,6 +318,22 @@ An executable built for a level runs only on CPUs that support that level. On an
The level applies to the program's own code. The runtime pack keeps its baseline code. Builds for different levels never share cached artifacts, and `baseline` builds are the same as builds without the option.
### Runtime CPU dispatch
`--cpu-dispatch` keeps the executable portable and still uses the higher level where the CPU has it. The program's functions are compiled for the baseline and for `x86-64-v3`. At startup, the executable checks the CPU and the operating system's register support, and then runs the `x86-64-v3` code on CPUs that support every feature of that level. Calls between the program's functions stay within the chosen copy.
```console
$ scriptc build app.ts --optimization=speed --cpu-dispatch -o app
```
The second copy makes the executable larger and the first build of the program slower, since LLVM compiles the program's code twice. Unchanged rebuilds are not affected. Both copies compute the same results: neither contracts floating-point operations into fused multiply-adds.
The `SCRIPTC_CPU_LEVEL` environment variable caps the level the executable uses, for example to test the baseline code on a newer machine. It takes a level from `0` to `4`, `baseline`, or a level name such as `x86-64-v3`; any other value selects the baseline code.
```console
$ SCRIPTC_CPU_LEVEL=baseline ./app
```
## LLVM backend
scriptc generates LLVM IR and compiles it to native code. Executable builds link the result with the matching precompiled runtime pack. Use <code>--emit=llvm</code> to inspect the generated code.
+18 -1
View File
@@ -145,7 +145,24 @@ A JavaScript package can contain operations scriptc can't compile, often in func
scriptc trusts declared types: a package's `.d.ts` files, its `@types/` package, and the JSDoc types in its JavaScript. A value that contradicts its declared type throws a `TypeError` instead of flowing on, so a package whose declarations are wrong can stop a program that runs under Node.js. A common case is a member declared `string` or `number` that is sometimes `undefined`.
Fix such a declaration in your project with a type patch in `package.json`:
When the type comes from a package's declarations, the error names the declaration and the patch that would admit the value:
```text
Uncaught TypeError: expected string at $, got undefined
profilekit@3.1.0 declares Profile.email as string (index.d.ts:7)
if that declaration is wrong, patch it: scriptc types patch profilekit "Profile.email" "| undefined"
```
Fix such a declaration in your project with a type patch. `scriptc types patch` shows what a declaration path matches in the installed package and adds the patch to your `package.json`:
```console
$ scriptc types patch commander "HelpContext.helpWidth" "| undefined"
commander 15.0.0 (node_modules/commander)
lib/command.js:2481 number -> number | undefined
wrote package.json scriptc.typePatches["commander@^15.0.0"].declarations["HelpContext.helpWidth"]
```
The patch it writes looks like this:
```json:package.json
{
@@ -4,7 +4,13 @@
When an untyped value reaches `NodeJS.ReadableStream` or `NodeJS.WritableStream`, those two interfaces hold any value, so JavaScript stream objects (constructor-function streams, plain objects with `on` and `pipe`) pass through TypeScript functions typed with them and come back to JavaScript as the same objects.
Member calls on such a value use the native stream when it is one and call the object's own members otherwise; `readable.pipe()` into a JavaScript destination follows Node's pipe protocol.
The first `pipeline` stage may arrive as an untyped value that is a native stream, a `Cipheriv` or `Decipheriv` value, or an iterable: a string, an array, a generator object, or any value with a `Symbol.asyncIterator` or `Symbol.iterator` method, which the pipeline pumps into the next stage as Node does.
Later `pipeline` stages and `for await` sources that arrive as untyped values must be native streams, or `Cipheriv` and `Decipheriv` values, at run time; other values throw a not-supported error.
It may also be a source function, which the pipeline calls with `{ signal }` and whose iterable result it pumps, or a `{ readable, writable }` pair, whose readable side it pumps.
Later `pipeline` stages that arrive as untyped values may be native streams, `Cipheriv` and `Decipheriv` values, functions, and iterables.
A function is called with the previous stage and `{ signal }` as a function stage: before the last stage it must return an async iterable, and as the last stage it may return a promise, whose value resolves the pipeline, or an async iterable, which feeds the PassThrough that `pipeline()` returns.
An untyped source function, function stage, or destination that returns a stream is unsupported.
An iterable becomes `Duplex.from` of it, which the next stage reads from while the stages before it are not read, as in Node.
A pipeline branches at run time on whether each of its first three untyped stages after the first is a function; a function in a later untyped stage, an untyped web stream, and a `{ readable, writable }` pair after the first stage throw a not-supported error.
`for await` sources that arrive as untyped values must be native streams, or `Cipheriv` and `Decipheriv` values, at run time; other values throw a not-supported error.
Native streams expose no `emit` to JavaScript, so a JavaScript stream whose own `pipe()` emits on its destination (the legacy `Stream` pipe) cannot pipe into one.
`Readable.from` accepts strings, Buffers, arrays, Sets, Maps, Map and Set iterators, generators, async generators, and class iterables and async iterables, and in JavaScript any iterable or async iterable.
In JavaScript, a sync iterator value with a `then` method that is not a promise, and an object whose `[Symbol.asyncIterator]` or `[Symbol.iterator]` method is a generator method, are unsupported.
+12 -6
View File
@@ -2,12 +2,18 @@ Node-API (N-API) and V8 `.node` addons depend on Node.js itself, so compiled pro
Direct `createRequire` calls to local addons are rejected at compile time.
A package that requires an addon needs a JavaScript implementation that compiles, or a replacement through native FFI.
A package that looks for an optional addon at run time compiles with `--npm-static` when the lookup is inside a `try` block.
There, a `createRequire` require call with a computed specifier, and a `require.resolve` or `require.resolve.paths` call with a computed request, evaluate their arguments and then throw an error.
Its `require.resolve` read as a value is a function that throws when called.
Coverage lists these as deferred to runtime.
The package's `catch` clause then runs as it does in Node.js when the addon is missing.
Outside a `try` block, the same uses keep the package refused.
A package that looks for an optional addon at run time compiles with `--npm-static`.
In JavaScript dependencies, a `createRequire` require call with a computed specifier, and `require.resolve` called with a computed request or read as a value, resolve the specifier with the Node.js algorithm when the program runs.
`require.resolve` returns the path Node.js returns.
When nothing resolves, both throw the `MODULE_NOT_FOUND` error that Node.js throws, so a package that probes for a missing addon takes the same branch as in Node.js.
When the specifier of a require call resolves to a native addon, a builtin, or a file that is not part of the compiled program, the program prints an SC2020 error that names it and exits with status 1, because Node.js would load that module.
A `catch` clause around the call does not run in that case.
Resolution steps that the runtime does not reproduce, such as package `imports` specifiers, `require.resolve` options, and subpaths that a package does not export, end the program the same way.
Coverage lists these calls as deferred to runtime.
Other computed uses compile when they are inside a `try` block: a require call with spread arguments, a `require.resolve` call with another argument count, and `require.resolve.paths` called with a computed request evaluate their arguments and then throw an error, and `require.resolve.paths` read as a value is a function that throws when called.
Coverage lists these as deferred to runtime as well, and the package's `catch` clause then runs as it does in Node.js when the addon is missing.
Outside a `try` block, these uses keep the package refused.
This applies until compiled programs can load addons through Node.js.
Use [Native FFI](/docs/ffi#replacing-a-node-api-addon) to call a plain C ABI function linked from an object or static archive.
The guide includes a replacement for a small Node-API helper.
+33
View File
@@ -17,6 +17,14 @@ set(SCRIPTC_HELPER_ARCH "arm64" CACHE STRING
# Linux packages ship this process-isolated helper as a fully static host
# executable so it runs independently of the destination libc and libstdc++.
option(SCRIPTC_STATIC_LINUX_BUILD "Build a static Linux helper executable" OFF)
# The release Linux helpers link an LLVM built with ThinLTO and a profile of
# the helper's code generation (llvm-dist.mjs). Its archives hold bitcode, so
# the helper compiles and links with ThinLTO too, which needs clang and lld.
option(SCRIPTC_LLVM_THINLTO "Compile and link the helper with ThinLTO" OFF)
set(SCRIPTC_LLVM_PROFILE_USE "" CACHE FILEPATH
"Indexed profile the helper's own sources are optimized with")
option(SCRIPTC_LLVM_PROFILE_GENERATE
"Instrument the helper's sources to collect that profile" OFF)
# These must be cache variables before project() enables the Apple toolchain.
# A target property named OSX_DEPLOYMENT_TARGET is not recognized by CMake and
@@ -219,6 +227,31 @@ else()
endif()
target_link_libraries(scriptc-llvm-codegen PRIVATE ${SCRIPTC_LLVM_LIBS} ${SCRIPTC_LLVM_SYSTEM_LIBS})
if(SCRIPTC_LLVM_THINLTO)
target_compile_options(scriptc-llvm-codegen PRIVATE -flto=thin)
# The thin link optimizes and compiles all of LLVM's code the helper
# reaches; the cache makes a relink after a helper edit incremental.
target_link_options(scriptc-llvm-codegen PRIVATE
-flto=thin
-fuse-ld=lld
"LINKER:--thinlto-jobs=all"
"LINKER:--thinlto-cache-dir=${CMAKE_BINARY_DIR}/thinlto-cache"
)
endif()
if(SCRIPTC_LLVM_PROFILE_USE)
# The profile covers LLVM's sources and the helper's. Functions it does
# not cover, or whose control flow changed since, are optimized without it.
target_compile_options(scriptc-llvm-codegen PRIVATE
"-fprofile-use=${SCRIPTC_LLVM_PROFILE_USE}"
-Wno-profile-instr-unprofiled
-Wno-profile-instr-out-of-date
)
endif()
if(SCRIPTC_LLVM_PROFILE_GENERATE)
target_compile_options(scriptc-llvm-codegen PRIVATE -fprofile-generate)
target_link_options(scriptc-llvm-codegen PRIVATE -fprofile-generate)
endif()
if(APPLE)
# Homebrew's static LLVM archives contain broad component object files.
# Export only the process entry point so archive-global LLVM symbols do not
+18 -1
View File
@@ -37,7 +37,7 @@ Repeating `--output` divides the program into that many partitions, which are co
WebAssembly code generation differs from LLVM's default pipeline in two ways that do not change its output. The pass that gives multiple-entry loops a single entry runs only on functions whose control flow is irreducible; its reachability analysis grows with the square of a function's block count even when it has nothing to change. Object emission also leaves out the module pass that collects external symbols for assembly declarations, so each function's machine code is freed once it is printed instead of when the whole module is compiled.
`--cache-dir <dir>` names a private directory the caller owns for one helper build. Simplified partitions are stored under the hash of their exact source and objects under the hash of the exact module after importing, each with a trailing digest and published by rename, so a rebuild recompiles only the partitions an edit can affect.
`--cache-dir <dir>` names a private directory the caller owns for one helper build. Simplified partitions are stored under the hash of their exact source and objects under the hash of the exact module after importing, each with a trailing digest and published by rename, so a rebuild recompiles only the partitions an edit can affect. Partitions drop the names of their struct types before the thin link, which would otherwise number same-named types from different partitions in an order that any edit can shift, changing the module after importing in partitions the edit did not touch.
`--import-bitcode <unit.bc>`, given once per runtime-pack unit that ships bitcode, imports runtime functions for inlining; scriptc's program builds do not pass it. 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.
@@ -58,3 +58,20 @@ scriptc-llvm-codegen prune-import --input unit.opt.bc --output unit.bc
```
Both commands keep only what a program build can import: the importable bodies within the size limit at their call distance from any public function, and the constants they copy. Every other definition becomes a declaration, so a program imports exactly what it would from the whole unit while reading a fraction of it. Unpromoted unit-local symbols stay local, so release bitcode offers only bodies that need none of them.
## Linux release builds
The Linux helper packages are released linked against LLVM 22.1.8 built from its release sources with ThinLTO and with `llvm.profdata.gz`, a profile of the helper's own code generation (`llvm-dist.mjs`). The helper's code generation is 5-9% faster with it than when the helper links LLVM's release archive, and its objects are identical. The release workflow downloads that build from the `LLVM_DIST_TAG` release of this repository; when the archive is missing, the helper links LLVM's release archive instead.
To build and use it locally, with clang, lld and llvm-profdata 22.1.8 on `PATH` (LLVM's release archive for the host has them):
```console
$ node scripts/build-llvm-dist.mjs --source llvm-project-22.1.8.src --prefix <dir>
$ LLVM_DIR=<dir>/lib/cmake/llvm SCRIPTC_LLVM_THINLTO=1 pnpm --filter @scriptc/llvm-linux-x64-gnu build:native
```
`SCRIPTC_LLVM_THINLTO=1` compiles and links the helper with clang, lld and ThinLTO, since that LLVM's archives hold bitcode, and optimizes the helper's own sources with the same profile. The link takes about 4 minutes on 4 cores.
The `llvm-dist` workflow (run by hand) builds the archive for x64 and arm64, checks that a helper linked against it gives the same objects as one linked against LLVM's release archive (`scripts/check-llvm-dist.mjs`), and attaches it to the release. The arm64 build uses the profile collected on x64, and its log reports how many functions the profile does not match there.
The profile depends on LLVM's sources and the kind of code scriptc emits, not on scriptc's commits. `scripts/refresh-llvm-profile.mjs` collects a new one after an LLVM version change: it builds LLVM with IR instrumentation and an instrumented helper, runs that helper on tsc-ts, five Effect fixtures, the runtime benchmark workloads and every 50th corpus program (`scripts/llvm-helper-inputs.mjs`), and merges the result. A new profile or LLVM version gets a new `LLVM_DIST_TAG`.
+79
View File
@@ -0,0 +1,79 @@
/* The static Linux helper build shared by the four Linux helper packages,
* scripts/refresh-llvm-profile.mjs and scripts/check-llvm-dist.mjs. */
import { execFileSync } from "node:child_process";
import {
chmodSync,
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { helperLlvmDistArgs } from "./llvm-dist.mjs";
import { nativeTargetCmakeArgs } from "./targets.mjs";
const DATA_LAYOUTS = {
x64: "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-i128:128-f80:128-n8:16:32:64-S128",
arm64:
"e-m:e-p270:32:32-p271:32:32-p272:64:64-i8:8:32-i16:16:32-i64:64-i128:128-n32:64-S128-Fn32",
};
/** Configures and builds the helper for `arch` (`x64` or `arm64`) and `libc`
* (`gnu` or `musl`) in `build` against LLVM_DIR, and writes the stripped
* executable to `output`. musl builds take the compilers from CC and CXX. */
export function buildLinuxHelper({ arch, libc, version, build, output }) {
const triple = `${arch === "x64" ? "x86_64" : "aarch64"}-unknown-linux-${libc}`;
// CMake drops the other definitions when a reconfigure changes the
// compiler, so a build directory configured for another LLVM or build
// kind starts over.
const kind = JSON.stringify({
llvm: process.env.LLVM_DIR ?? null,
thinLTO: process.env.SCRIPTC_LLVM_THINLTO === "1",
profileGenerate: process.env.SCRIPTC_LLVM_PROFILE_GENERATE === "1",
cc: process.env.CC ?? null,
cxx: process.env.CXX ?? null,
});
const stamp = join(build, "scriptc-helper-build.json");
if (existsSync(build) && (!existsSync(stamp) || readFileSync(stamp, "utf8") !== kind))
rmSync(build, { recursive: true, force: true });
mkdirSync(build, { recursive: true });
writeFileSync(stamp, kind);
execFileSync(
"cmake",
[
"-S",
fileURLToPath(new URL(".", import.meta.url)),
"-B",
build,
"-G",
"Ninja",
...(libc === "musl"
? [
`-DCMAKE_C_COMPILER=${process.env.CC ?? "cc"}`,
`-DCMAKE_CXX_COMPILER=${process.env.CXX ?? "c++"}`,
]
: []),
`-DLLVM_DIR=${process.env.LLVM_DIR ?? "/usr/lib/llvm-22/lib/cmake/llvm"}`,
`-DSCRIPTC_PACKAGE_VERSION=${version}`,
`-DSCRIPTC_DEFAULT_TARGET=${triple}`,
`-DSCRIPTC_DEFAULT_DATA_LAYOUT=${DATA_LAYOUTS[arch]}`,
...nativeTargetCmakeArgs,
"-DSCRIPTC_STATIC_LINUX_BUILD=ON",
"-DCMAKE_EXE_LINKER_FLAGS=-static",
"-DCMAKE_BUILD_TYPE=Release",
// Later definitions win, so these can select clang for musl builds.
...helperLlvmDistArgs(build),
],
{ stdio: "inherit" },
);
execFileSync("cmake", ["--build", build, "--target", "scriptc-llvm-codegen"], {
stdio: "inherit",
});
mkdirSync(dirname(output), { recursive: true });
copyFileSync(join(build, "scriptc-llvm-codegen"), output);
execFileSync("strip", [output], { stdio: "inherit" });
chmodSync(output, 0o755);
}
+96
View File
@@ -0,0 +1,96 @@
/* The LLVM distribution the Linux helpers link in releases: LLVM built from
* the release sources with ThinLTO and with a profile of the helper's own
* code generation (llvm.profdata.gz). scripts/build-llvm-dist.mjs builds it,
* the llvm-dist workflow attaches it to the LLVM_DIST_TAG release of this
* repository, and scripts/refresh-llvm-profile.mjs collects the profile. */
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { gunzipSync } from "node:zlib";
export const LLVM_VERSION = "22.1.8";
/** The release of this repository that holds the distribution archives. A
* new profile or LLVM version gets a new tag. */
export const LLVM_DIST_TAG = "llvm-22.1.8-pgo.1";
export const llvmProfile = fileURLToPath(new URL("./llvm.profdata.gz", import.meta.url));
/** The archive name for a Linux architecture (`x64` or `arm64`). */
export function llvmDistAsset(arch) {
return `scriptc-llvm-${LLVM_VERSION}-linux-${arch}.tar.xz`;
}
/** Writes the uncompressed profile into `dir` and returns its path. */
export function writeLlvmProfile(dir, profile = llvmProfile) {
mkdirSync(dir, { recursive: true });
const path = join(dir, "llvm.profdata");
writeFileSync(path, gunzipSync(readFileSync(profile)));
return path;
}
/** CMake arguments for an LLVM build with the helper's targets, static,
* without tools or tests. `profile` builds with that indexed profile and
* ThinLTO; `instrumented` builds the IR-instrumented libraries the profile
* is collected with. clang and lld come from PATH and must be LLVM_VERSION,
* since the profile's format and hashes follow the compiler. */
export function llvmCmakeArgs({ source, build, prefix, profile, instrumented }) {
const version = execFileSync("clang", ["--version"], { encoding: "utf8" });
if (!version.includes(`clang version ${LLVM_VERSION}`))
throw new Error(`the LLVM distribution is built with clang ${LLVM_VERSION}; found ${version}`);
return [
"-S",
join(source, "llvm"),
"-B",
build,
"-G",
"Ninja",
"-DCMAKE_BUILD_TYPE=Release",
`-DCMAKE_INSTALL_PREFIX=${prefix}`,
"-DCMAKE_C_COMPILER=clang",
"-DCMAKE_CXX_COMPILER=clang++",
"-DLLVM_USE_LINKER=lld",
"-DLLVM_TARGETS_TO_BUILD=X86;AArch64;WebAssembly",
"-DLLVM_ENABLE_PROJECTS=",
"-DLLVM_ENABLE_ASSERTIONS=OFF",
"-DLLVM_ENABLE_ZLIB=FORCE_ON",
"-DLLVM_ENABLE_ZSTD=FORCE_ON",
"-DLLVM_USE_STATIC_ZSTD=ON",
"-DLLVM_ENABLE_TERMINFO=OFF",
"-DLLVM_ENABLE_LIBXML2=OFF",
"-DLLVM_ENABLE_LIBEDIT=OFF",
"-DLLVM_ENABLE_LIBPFM=OFF",
"-DLLVM_INCLUDE_TESTS=OFF",
"-DLLVM_INCLUDE_EXAMPLES=OFF",
"-DLLVM_INCLUDE_BENCHMARKS=OFF",
"-DLLVM_INCLUDE_DOCS=OFF",
"-DLLVM_BUILD_TOOLS=OFF",
...(instrumented ? ["-DLLVM_BUILD_INSTRUMENTED=IR", "-DLLVM_BUILD_RUNTIME=OFF"] : []),
...(profile ? ["-DLLVM_ENABLE_LTO=Thin", `-DLLVM_PROFDATA_FILE=${profile}`] : []),
];
}
/** Extra CMake arguments for a Linux helper package build. With
* SCRIPTC_LLVM_THINLTO=1, LLVM_DIR names a distribution from
* scripts/build-llvm-dist.mjs: its archives hold ThinLTO bitcode, so the
* helper compiles and links with clang, lld and ThinLTO, and its own sources
* use the same profile. SCRIPTC_LLVM_PROFILE_GENERATE=1 instruments the
* helper's sources against an instrumented LLVM. */
export function helperLlvmDistArgs(build) {
const args = [];
if (process.env.SCRIPTC_LLVM_THINLTO === "1")
args.push(
"-DCMAKE_C_COMPILER=clang",
"-DCMAKE_CXX_COMPILER=clang++",
"-DSCRIPTC_LLVM_THINLTO=ON",
`-DSCRIPTC_LLVM_PROFILE_USE=${writeLlvmProfile(build)}`,
);
else args.push("-DSCRIPTC_LLVM_THINLTO=OFF", "-DSCRIPTC_LLVM_PROFILE_USE=");
if (process.env.SCRIPTC_LLVM_PROFILE_GENERATE === "1")
args.push(
"-DCMAKE_C_COMPILER=clang",
"-DCMAKE_CXX_COMPILER=clang++",
"-DSCRIPTC_LLVM_PROFILE_GENERATE=ON",
);
else args.push("-DSCRIPTC_LLVM_PROFILE_GENERATE=OFF");
return args;
}
Binary file not shown.
+18
View File
@@ -173,6 +173,23 @@ void limitProfileImports() {
(void)Limited;
}
// Drops the names of a partition's struct types before its summary is
// written. Every partition declares the program's types under the same
// names, and a thin link loads the importing partition and the partitions
// it imports from into one context, which tells same-named types apart by
// appending a counter that runs across the whole link. An edit that adds a
// type anywhere shifts the counter, so the module after importing, the
// object cache key, changed in partitions whose code had not. Unnamed types
// carry no such suffix; importing still matches them by their bodies.
struct UnnameStructTypes : PassInfoMixin<UnnameStructTypes> {
PreservedAnalyses run(Module &M, ModuleAnalysisManager &) {
for (StructType *Type : M.getIdentifiedStructTypes())
Type->setName("");
return PreservedAnalyses::all();
}
static bool isRequired() { return true; }
};
// How the --profile-use profile matched the functions it annotated, summed
// over partitions: functions that received its counts, functions whose
// control flow changed since it was collected (hash mismatch), and functions
@@ -733,6 +750,7 @@ compileOptimizedPartitions(const EmitOptions &Options,
if (Annotating)
P.countAnnotated(PartitionMatch.Annotated);
ModulePassManager MPM = P.PB.buildThinLTOPreLinkDefaultPipeline(Level);
MPM.addPass(UnnameStructTypes());
MPM.addPass(BitcodeWriterPass(Stream, /*ShouldPreserveUseListOrder=*/false,
/*EmitSummaryIndex=*/true,
/*EmitModuleHash=*/true));
+1
View File
@@ -49,6 +49,7 @@ export function relocateToolchain(manifest, sourceDirectory, destinationDirector
"declarations",
"comptime",
"wasi_node_runner",
"lib_diagnostics",
]) {
if (typeof relocated[name] === "string") relocated[name] = pathFrom(relocated[name]);
}
+2
View File
@@ -141,6 +141,7 @@ async function tryFastPath(): Promise<number | null> {
...(optimization === "dev" || optimization === "speed" ? { optimization } : {}),
...(values.strip ? { strip: true as const } : {}),
...(values["detached-exit"] ? { detachedExit: true as const } : {}),
...(values["cpu-dispatch"] === true ? { cpuDispatch: true as const } : {}),
...(checker === undefined ? {} : { checker }),
npmStatic,
ffiProfile: ffiPath === null ? null : { path: ffiPath, bytes: ffiBytes! },
@@ -220,6 +221,7 @@ async function coverageFastPath(
values.strip ||
values["detached-exit"] ||
values["target-cpu"] !== undefined ||
values["cpu-dispatch"] !== undefined ||
values.lib ||
values.profile !== undefined ||
values.ffi !== undefined ||
+2
View File
@@ -10,6 +10,7 @@ import {
mergeProfiles,
resolveProvenanceSources,
sourceTargetPlatform,
typePatches,
warmNativeCaches,
} from "@scriptc/compiler";
import { runCli } from "./command.js";
@@ -39,6 +40,7 @@ process.exitCode = await runCli(process.argv.slice(2), {
sourceTargetPlatform,
warmNativeCaches,
mergeProfiles,
typePatches,
run: (binary) =>
new Promise<number>((resolveExit) => {
let child;
+82
View File
@@ -0,0 +1,82 @@
import { execFile } from "node:child_process";
import { createRequire } from "node:module";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import { expect, test } from "vitest";
const execFileAsync = promisify(execFile);
const require = createRequire(import.meta.url);
const repoRoot = join(import.meta.dirname, "../../..");
const cliEntry = join(repoRoot, "packages/cli/src/main.ts");
const bootstrapEntry = join(repoRoot, "packages/cli/dist/bootstrap.js");
const tsxLoader = join(dirname(require.resolve("tsx/package.json")), "dist/loader.mjs");
const program = `function mix(a: Float64Array, b: Float64Array, k: number): number {
let s = 0;
for (let i = 0; i < a.length; i++) s += a[i]! * k + b[i]! / (i + 1);
return s;
}
const a = new Float64Array(4096);
const b = new Float64Array(4096);
for (let i = 0; i < a.length; i++) {
a[i] = ((i * 7919) % 1000) / 7 - 50;
b[i] = 1 / (i + 3);
}
let total = 0;
for (let r = 0; r < 32; r++) total += mix(a, b, r * 0.1);
console.log(total, [3, 1, 2].map((x) => mix(a.subarray(x), b, x)).join(" "));
`;
test.runIf(process.platform === "linux" && process.arch === "x64")(
"--cpu-dispatch builds one executable that matches Node at every level",
async () => {
const dir = await mkdtemp(join("/tmp", "scriptc-cpu-dispatch-"));
const entry = join(dir, "main.ts");
const outPath = join(dir, "program");
const llvmPath = join(dir, "main.ll");
const env = { ...process.env, SCRIPTC_CACHE_DIR: join(dir, "cache") };
const build = async (args: string[], bootstrap = false): Promise<string> => {
await execFileAsync(
process.execPath,
[
...(bootstrap ? [bootstrapEntry] : ["--import", tsxLoader, cliEntry]),
"build",
entry,
"-o",
outPath,
...args,
],
{ env, maxBuffer: 4 * 1024 * 1024 },
);
return readFile(llvmPath, "utf8");
};
const run = async (level?: string): Promise<string> =>
(
await execFileAsync(outPath, [], {
env: { ...process.env, ...(level === undefined ? {} : { SCRIPTC_CPU_LEVEL: level }) },
})
).stdout;
try {
await writeFile(entry, program);
const expected = `${(await execFileAsync(process.execPath, [entry])).stdout}`;
const plain = await build(["--optimization=speed"]);
expect(plain).not.toContain("@sc_cpu_level");
const dispatching = await build(["--optimization=speed", "--cpu-dispatch"]);
expect(dispatching).toMatch(/^define internal double @sc_bf_mix\.v3\(/m);
for (const level of [undefined, "0", "baseline", "3", "x86-64-v3"])
expect(await run(level), `SCRIPTC_CPU_LEVEL=${level}`).toBe(expected);
// The bootstrap's cached route keeps the two builds apart.
expect(await build(["--optimization=speed"], true)).toBe(plain);
expect(await build(["--optimization=speed", "--cpu-dispatch"], true)).toBe(dispatching);
expect(await build(["--cpu-dispatch"])).toContain("@sc_cpu_level_init()");
expect(await run("0")).toBe(expected);
expect(await build(["--target-cpu=x86-64-v3", "--cpu-dispatch"])).not.toContain(
"@sc_cpu_level",
);
} finally {
await rm(dir, { recursive: true, force: true });
}
},
240_000,
);
+4
View File
@@ -53,6 +53,7 @@ function fixture() {
dsymutil: "dsymutil",
comptime: "../lib/comptime",
wasi_node_runner: "../lib/wasi/cli/wasi-runner.js",
lib_diagnostics: "../lib/lib-diagnostics.json",
};
writeFileSync(binary + ".json", JSON.stringify(toolchain));
const helpers = [
@@ -93,6 +94,9 @@ test("installs a direct executable and relocatable references to platform assets
expect(resolve(dirname(installed), manifest.wasi_node_runner)).toBe(
join(platform, "wasi/cli/wasi-runner.js"),
);
expect(resolve(dirname(installed), manifest.lib_diagnostics)).toBe(
join(platform, "lib-diagnostics.json"),
);
expect(resolve(dirname(installed), manifest.runtime_packs[0].path)).toBe(
join(platform, "runtime"),
);
+2 -2
View File
@@ -72,7 +72,7 @@ describe.runIf(supported)("macOS arm64 native link info", () => {
minimum_os: "14.0",
},
program: { object, entry_symbol: "main" },
runtime_abi: { version: 45, marker: "scr_runtime_abi_v45" },
runtime_abi: { version: 46, marker: "scr_runtime_abi_v46" },
runtime_pack: {
kind: "precompiled",
package: "@scriptc/runtime-darwin-arm64",
@@ -165,6 +165,6 @@ describe.runIf(supported)("macOS arm64 native link info", () => {
() => null,
(failure: { stderr?: string }) => failure,
);
expect(error?.stderr).toContain("scr_runtime_abi_v45");
expect(error?.stderr).toContain("scr_runtime_abi_v46");
});
});
+1
View File
@@ -48,6 +48,7 @@ describe("output option compatibility", () => {
[{ emit: "llvm", strip: true }, /strip/],
[{ emit: "obj", detachedExit: true }, /detached-exit/],
[{ emit: "llvm", detachedExit: true }, /detached-exit/],
[{ emit: "asm", cpuDispatch: true }, /cpu-dispatch/],
[{ emit: "obj", profileGenerate: "/tmp/%p.profraw" }, /profile-generate/],
[{ emit: "llvm", profileUse: "/tmp/a.profdata" }, /profile-use/],
[{ profileGenerate: "/tmp/%p.profraw", profileUse: "/tmp/a.profdata" }, /cannot be combined/],
+16 -3
View File
@@ -63,8 +63,16 @@ test.skipIf(process.platform !== "linux")(
expect(merge.stdout).toBe(`${merged}\n`);
const optimized = join(dir, "optimized");
await scriptc(["build", entry, "--profile-use", merged, "-o", optimized], env);
const fresh = await scriptc(["build", entry, "--profile-use", merged, "-o", optimized], env);
expect((await execFileAsync(optimized, ["1000"])).stdout).toBe("total 167499\n");
// The build reports how the profile matched the program's functions.
expect(fresh.stderr).toMatch(
/^scriptc: profile .*: [1-9]\d* functions annotated, 0 mismatched, \d+ without a record\n$/,
);
expect(fresh.stderr).toContain(`scriptc: profile ${merged}: `);
// A build from the cache reports the same match.
const again = await scriptc(["build", entry, "--profile-use", merged, "-o", optimized], env);
expect(again.stderr).toBe(fresh.stderr);
// A sparse profile leaves out the runtime functions the runs never
// reached.
@@ -77,10 +85,15 @@ test.skipIf(process.platform !== "linux")(
await scriptc(["build", entry, "--profile-use", sparse, "-o", optimized], env);
expect((await execFileAsync(optimized, ["1000"])).stdout).toBe("total 167499\n");
// A profile from an older version of the program still builds it.
// A profile from an older version of the program still builds it. The
// edited function no longer matches its counts, and the build warns.
await writeFile(entry, PROGRAM + 'console.log("edited");\n');
await scriptc(["build", entry, "--profile-use", merged, "-o", optimized], env);
const stale = await scriptc(["build", entry, "--profile-use", merged, "-o", optimized], env);
expect((await execFileAsync(optimized, ["1000"])).stdout).toBe("total 167499\nedited\n");
expect(stale.stderr).toMatch(/ [1-9]\d* mismatched, /);
expect(stale.stderr).toMatch(
/\nscriptc: warning: [1-9]\d* of the \d+ functions .* describes \([\d.]+%\) changed since it was collected and are optimized without their counts; collect the profile again\n$/,
);
} finally {
await rm(dir, { recursive: true, force: true });
}
+10
View File
@@ -171,6 +171,16 @@ declare module "node:fs" {
): void;
export function rmdirSync(path: string | URL): void;
export function readdirSync(path: string | URL): string[];
/* The recursive form: every entry below the path, relative to it. */
export function readdirSync(
path: string | URL,
options: {
recursive?: boolean;
withFileTypes?: false;
encoding?: "utf8" | "utf-8";
[option: string]: unknown;
},
): string[];
/* The withFileTypes form: Dirent rows (name + parentPath + the type
* probes — the honest subset of Node's Dirent surface). The type is
* interned explicitly in type-mapper.ts (the record's hidden %dtype field
Binary file not shown.
+26 -3
View File
@@ -56,7 +56,9 @@ void scr_collect_cycles_phase(void);
* frontend's syntax trees and program state refer to each other, so
* reference counting frees almost none of them when the frontend is
* disposed, and the collector's schedule alone may not run a full pass
* before the build ends. */
* before the build ends. The sweep then returns the freed pages to the
* system (scr_heap_trim), so code generation does not grow on top of the
* frontend's heap. */
void scriptc_native_collect_cycles(void) {
scr_collect_cycles_phase();
}
@@ -80,9 +82,30 @@ void scriptc_native_unlock(void) {}
#include <sys/file.h>
#include <sys/wait.h>
int32_t scriptc_native_fork(void) { return (int32_t)fork(); }
#if defined(__linux__)
/* LLVM's profile runtime, linked into instrumented builds of the compiler
* (SCRIPTC_NATIVE_PROFILE_GENERATE); other builds leave these null. A worker
* starts with zeroed counters and writes its profile before _exit, which
* skips the runtime's atexit writer, so the profile counts the worker's work
* once. */
extern void __llvm_profile_reset_counters(void) __attribute__((weak));
extern int __llvm_profile_write_file(void) __attribute__((weak));
#endif
void scriptc_native_exit(int32_t code) { _exit(code); }
int32_t scriptc_native_fork(void) {
pid_t pid = fork();
#if defined(__linux__)
if (pid == 0 && __llvm_profile_reset_counters != NULL) __llvm_profile_reset_counters();
#endif
return (int32_t)pid;
}
void scriptc_native_exit(int32_t code) {
#if defined(__linux__)
if (__llvm_profile_write_file != NULL) (void)__llvm_profile_write_file();
#endif
_exit(code);
}
/* The worker's exit status, 128 plus the signal for a worker a signal
* ended, or -1 when waitpid fails. */
@@ -1,6 +1,10 @@
#ifndef _WIN32
#define _POSIX_C_SOURCE 200809L
#endif
/* macOS declares mkdtemp only with the Darwin extensions visible. */
#if defined(__APPLE__) && !defined(_DARWIN_C_SOURCE)
#define _DARWIN_C_SOURCE 1
#endif
#include "ts7-process.h"
#include <assert.h>
#include <errno.h>
+1 -1
View File
@@ -40,7 +40,7 @@
},
"dependencies": {
"@scriptc/runtime": "workspace:*",
"tsc-ts": "0.0.9",
"tsc-ts": "0.0.10",
"typescript": "7.0.2",
"typescript5": "npm:typescript@5.9.3"
},
@@ -31,12 +31,14 @@ export function executableLinkFeatures(mod: IrModule): NativeLinkFeatures {
...(features.streamDyn ? { streamDyn: true } : {}),
...(features.handleProps ? { handleProps: true } : {}),
...(features.declAdopt ? { declAdopt: true } : {}),
...(features.typeOrigins ? { typeOrigins: true } : {}),
...(features.dynForAwait ? { dynForAwait: true } : {}),
...(features.eventsOnceSignal ? { eventsOnceSignal: true } : {}),
...(features.mime ? { mime: true } : {}),
...(features.utilTiming ? { utilTiming: true } : {}),
...(features.dynBuffer ? { dynBuffer: true } : {}),
...(features.builtinValues ? { builtinValues: true } : {}),
...(features.computedRequire ? { computedRequire: true } : {}),
events: features.processEvents,
emitter: features.emitter,
symbol: features.symbol,
@@ -166,6 +166,7 @@ export async function compilePackedLibrary(
options.programPublicSymbols,
stem,
target.platform,
{ inProcessElf: true },
);
} else {
program = await emit(
@@ -0,0 +1,20 @@
import { expect, test } from "vitest";
import { systemLibraryLinkerArgs } from "./link-plan-core.js";
import { MACOS_ARM64_TARGET, LINUX_ARM64_GNU_TARGET, LINUX_X64_GNU_TARGET } from "./targets.js";
test("Linux links libm as needed and other system libraries as given", () => {
for (const target of [LINUX_X64_GNU_TARGET, LINUX_ARM64_GNU_TARGET]) {
expect(systemLibraryLinkerArgs(target, ["m"])).toEqual([
"-Wl,--as-needed",
"-lm",
"-Wl,--no-as-needed",
]);
expect(systemLibraryLinkerArgs(target, ["z", "m"])).toEqual([
"-lz",
"-Wl,--as-needed",
"-lm",
"-Wl,--no-as-needed",
]);
}
expect(systemLibraryLinkerArgs(MACOS_ARM64_TARGET, ["System"])).toEqual(["-lSystem"]);
});
@@ -70,3 +70,19 @@ export function executableLinkInputs(options: {
],
};
}
/** The linker arguments for an executable's system libraries. On Linux, libm
* links as needed: the runtime defines the libm functions most programs
* reach (scr_glibc_math.c) and Math's transcendentals (scr_ieee754.c), so a
* program that calls no other libm function, such as pow, starts without
* loading libm.so. */
export function systemLibraryLinkerArgs(
target: Pick<NativeTargetSpec, "platform">,
names: readonly string[],
): string[] {
return names.flatMap((name) =>
name === "m" && target.platform === "linux"
? ["-Wl,--as-needed", "-lm", "-Wl,--no-as-needed"]
: [`-l${name}`],
);
}
+2 -1
View File
@@ -22,6 +22,7 @@ import { type NativeArtifactDependency } from "./native/contracts.js";
import { RuntimePackError, stageRuntimePackArtifacts } from "./runtime-pack.js";
import type { NativeLinkPlan } from "./link-plan.js";
import { lldLinkerArgs } from "./lld.js";
import { systemLibraryLinkerArgs } from "./link-plan-core.js";
import {
createDarwinDebugSymbols,
installDarwinDebugSymbols,
@@ -248,7 +249,7 @@ export async function linkNativeExecutable(
...lld,
...plan.driverFlags,
...plan.inputs.map((input) => staged.replacements.get(input) ?? input),
...plan.systemLibraries.map((name) => `-l${name}`),
...systemLibraryLinkerArgs(plan.target, plan.systemLibraries),
"-o",
privateOut,
];
@@ -0,0 +1,77 @@
/* Typed-array header fields read once at a speculative loop's entry.
*
* A view's length, data pointer and shared storage are set at construction
* and never change, and a view held by a const local or a parameter that
* the loop does not rebind is the same view in every iteration. The entry
* reads those fields once, and every element access in the loop's copies
* (the ordinary copy, the fast copy and the copy a failed check resumes in)
* uses the values. No header read is then left inside the copies for LLVM's
* load PRE to move ahead of the fast copy's exits: such a move keeps the
* fast loop's first iteration in the loop, with the checks that one
* iteration of it makes redundant. */
import type { IrExpr, IrStmt } from "../../ir/ir.js";
import { everyStmt } from "../../ir/traverse.js";
import { emitBytesData, emitBytesHeaderLength, emitBytesSharedStorage } from "./expr-bytes.js";
import type { LlEmitter } from "./emitter.js";
export interface BytesHeader {
length: string;
data: string;
/** The view's shared storage (worker programs only). */
shared: string | null;
}
export interface BytesHeaderScope {
/** The function the scope's values belong to. */
fn: string;
locals: ReadonlyMap<string, BytesHeader>;
/** The header of each receiver value read from one of `locals`. */
values: Map<string, BytesHeader>;
}
/** Reads the header fields of the views `loop` accesses through a const
* local or a parameter it does not rebind, at the current block, which must
* dominate every copy of the loop. Null when there are none. */
export function emitLoopBytesHeaders(
emitter: LlEmitter,
loop: IrStmt,
parameters: ReadonlySet<string>,
): BytesHeaderScope | null {
const receivers = new Set<string>();
const rebound = new Set<string>();
const receiver = (e: IrExpr): void => {
if (e.kind === "varRef") receivers.add(e.localId);
};
everyStmt(loop, {
expr: (e) => {
if (e.kind === "bytesIntrinsic") receiver(e.receiver);
else if (e.kind === "assignExpr" || e.kind === "incDec") rebound.add(e.localId);
return true;
},
stmt: (s) => {
if (s.kind === "bytesSet") receiver(s.arr);
else if (s.kind === "assign" || s.kind === "forOf" || s.kind === "varDecl")
rebound.add(s.localId);
else if (s.kind === "tryCatch" && s.catchLocalId !== null) rebound.add(s.catchLocalId);
return true;
},
});
const locals = new Map<string, BytesHeader>();
for (const id of receivers) {
if (rebound.has(id)) continue;
const local = emitter.currentLocals.get(id);
if (local === undefined || local.boxed === true || local.type.kind !== "bytes") continue;
if (local.mutable && !parameters.has(id)) continue;
if (emitter.integerLoopBindings.has(id) || emitter.int32Locals.has(id)) continue;
const B = emitter.B;
const view = B.tmp();
B.line(`${view} = load ptr, ptr ${emitter.binding(id).slot}${emitter.slotAlias(id)}`);
locals.set(id, {
length: emitBytesHeaderLength(emitter, view),
data: emitBytesData(emitter, view),
shared: emitter.mod.workers ? emitBytesSharedStorage(emitter, view) : null,
});
}
if (locals.size === 0) return null;
return { fn: emitter.currentFunctionName, locals, values: new Map() };
}
@@ -320,16 +320,17 @@ test("worker programs test each receiver's shared storage once in the guard", ()
const fast = /^bytes\.version\.fast\d+:$[^]*?^bytes\.version\.fast\.shared\d+:$/m.exec(body)![0];
expect(fast).not.toContain("bytes.shared");
expect(fast).not.toMatch(/(load|store) atomic double/);
// The checked copy keeps the shared-memory path for each access, and so
// does the copy for shared views whose bounds hold.
// The checked copy keeps the shared-memory path for each access. The copy
// for shared views whose bounds hold is a counted loop that polls between
// blocks, so its accesses at the counter are plain.
const slow = body.slice(body.search(/^bytes\.version\.slow\d+:$/m), body.indexOf(fast));
expect(slow).toContain("load atomic double");
expect(slow).toContain("store atomic double");
const shared = /^bytes\.version\.fast\.shared\d+:$[^]*?^bytes\.version\.done\d+:$/m.exec(
body,
)![0];
expect(shared).toContain("load atomic double");
expect(shared).toContain("store atomic double");
expect(shared).toMatch(/^loop\.poll\d+:$/m);
expect(shared).not.toMatch(/(load|store) atomic double/);
});
test("index temps of a version's counter are integers in the fast copy", () => {
@@ -101,6 +101,13 @@ export class CheckedNarrows {
}
}
/** A call to a checked extraction whose helper borrows its argument, so
* emitCheckedNarrowReceiver can lend the payload instead of calling it. */
export function borrowsCheckedNarrow(host: LlvmEmitterContext, e: IrExpr): boolean {
if (e.kind !== "call" || host.checkedNarrows.get(e) === null) return false;
return host.callLifetimes.borrowed.get(e.callee)?.has(0) === true && host.mayThrow.has(e.callee);
}
/** A checked extraction consumed at once by a field or tag load. The
* successful arm borrows the payload from the union, which itself borrows
* from its source: a dense array read keeps the element in the array slot,
@@ -113,7 +120,7 @@ export function emitCheckedNarrowReceiver(
narrow: CheckedNarrowCall,
): LlValue | null {
const callee = narrow.call.callee;
if (!host.callLifetimes.borrowed.get(callee)?.has(0) || !host.mayThrow.has(callee)) return null;
if (!borrowsCheckedNarrow(host, narrow.call)) return null;
const B = host.B;
const read = host.optionalArrayReads.get(narrow.value);
const union = read
+11 -3
View File
@@ -573,13 +573,21 @@ export function classIdLoadLines(
* 64-bit word. The count is the i32 at offset 0. */
export const CLASS_ID_OFFSET = 4;
/** The bound on layout ids (scr_rc.h SCR_RC_LAYOUT_ID_LIMIT): the high
* half of a 64-bit rc word keeps the layout id in its low 30 bits, beside
* the program-lifetime and frozen bits. A class id is the layout id of its
* class's canonical layout. */
export const LAYOUT_ID_LIMIT = 0x3fffffff;
/** The rc word an LlClassMeta.idInRc instance is created with: the class
* id, its preorder number plus 1, in the high 32 bits, and in the low 32 bits
* a count of 1, or for a program-lifetime instance the midpoint of the 32-bit
* count (as programLifetimeRc is of the 32-bit word). The id is never 0,
* 0x80000000 or 0xffffffff, the high halves of other objects' rc words, so
* the runtime can tell an object with a class id (scr_rc.h scr_rc_class_id). */
* count (as programLifetimeRc is of the 32-bit word). The id is a layout id
* (LAYOUT_ID_LIMIT), so it never sets the program-lifetime or frozen bit
* and the runtime reads it back (scr_rc.h scr_rc_layout_id). */
export function classIdRcWord(meta: LlClassMeta, lifetime: boolean): string {
if (meta.pre + 1 >= LAYOUT_ID_LIMIT)
throw new InternalCompilerError(`class id ${meta.pre + 1} is not a layout id`);
return (BigInt(meta.pre + 1) * 2n ** 32n + (lifetime ? 2n ** 31n : 1n)).toString();
}
@@ -277,6 +277,10 @@ export function emitFieldAbsentTest(B: BlockBuilder, value: string, undefinedTag
return absent;
}
/** ScrClosure.function_kind's SCR_FN_RECEIVER_FREE bit (scr_closure.h):
* the body never observes the ambient receiver. */
export const CLOSURE_RECEIVER_FREE = 16;
/** The right-hand side of a JS function-identity comparison: two closures
* are one function when their identity roots match (a signature adapter
* stands for the function it adapts). Both operands must be closures. */
@@ -299,6 +299,47 @@ test("receiver readers propagate backwards through direct calls and recursion",
).toBe(false);
});
test("signature adapters that only forward the receiver ignore it with their original", () => {
const forward = (receiver: IrExpr): IrStmt => ({
kind: "return",
value: {
kind: "callValue",
callee: ref("f.0"),
receiver,
args: [{ kind: "varRef", localId: "a.0", type: F64, loc }],
type: BOOL,
loc,
},
loc,
});
const ambient: IrExpr = { kind: "libCall", fn: "dyn.this", args: [], type: { kind: "dyn" }, loc };
const adapter: IrFunction = {
name: "%fn.adapt.x.impl",
params: [{ localId: "a.0", name: "a", type: F64 }],
captures: [{ localId: "f.0", name: "f", type: callback }],
locals: [
{ id: "f.0", name: "f", type: callback, mutable: false, boxed: true },
{ id: "a.0", name: "a", type: F64, mutable: false },
],
returnType: BOOL,
body: [forward(ambient)],
loc,
};
const readers = (): AmbientReceiverReaders =>
new AmbientReceiverReaders(new Map([[adapter.name, adapter]]));
// The adapter itself reads the receiver, only to forward it.
expect(readers().ignores(adapter)).toBe(false);
expect(readers().forwardsOnly(adapter)).toBe(true);
// Any other read of the receiver, or forwarding something else, does not.
adapter.body = [thisRead, forward(ambient)];
expect(readers().forwardsOnly(adapter)).toBe(false);
adapter.body = [forward(ref("a.0", F64))];
expect(readers().forwardsOnly(adapter)).toBe(false);
adapter.body = [forward(ambient)];
adapter.captures = [];
expect(readers().forwardsOnly(adapter)).toBe(false);
});
test("virtual calls admit every same-named method", () => {
const call: IrStmt = {
kind: "exprStmt",
@@ -0,0 +1,109 @@
import { expect, test } from "vitest";
import { BOOL, F64, type IrExpr, type IrStmt } from "../../ir/ir.js";
import { matchConstantChoice } from "./constant-choices.js";
const loc = { file: "choices.ts", start: 0, end: 0 };
const bytes = { kind: "bytes", elem: "u8" } as const;
const num = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc });
const ref = (localId: string, type: IrExpr["type"] = F64): IrExpr => ({
kind: "varRef",
localId,
type,
loc,
});
const is = (left: IrExpr, right: IrExpr): IrExpr => ({
kind: "bin",
op: "===",
left,
right,
type: BOOL,
loc,
});
const store = (value: IrExpr, index: IrExpr = ref("i"), arr = "a"): IrStmt => ({
kind: "bytesSet",
arr: ref(arr, bytes),
index,
value,
loc,
});
const branch = (cond: IrExpr, then: IrStmt[], else_: IrStmt[] | null): IrStmt => ({
kind: "if",
cond,
then,
else_,
loc,
});
/** `if (x === c0) a[i] = r0; else if ... else a[i] = fallback`. */
function chain(cases: [number, number][], fallback: IrStmt[] | null, subject = "x"): IrStmt {
let rest = fallback;
for (let k = cases.length - 1; k >= 0; k--) {
const [value, result] = cases[k]!;
rest = [branch(is(ref(subject), num(value)), [store(num(result))], rest)];
}
return rest![0]!;
}
test("a chain of literal stores to one element is a constant choice", () => {
const choice = matchConstantChoice(
chain(
[
[97, 0],
[116, 1],
[99, 2],
],
[store(num(3))],
),
);
expect(choice?.cases).toEqual([
{ value: 97, result: 0 },
{ value: 116, result: 1 },
{ value: 99, result: 2 },
]);
expect(choice?.fallback).toBe(3);
expect(choice?.stores).toHaveLength(4);
});
test("a literal on the left, -0 and a repeated value keep strict equality's first arm", () => {
const first: IrStmt = branch(
is(num(-0), ref("x")),
[store(num(5))],
[
chain(
[
[1, 6],
[0, 7],
[2, 8],
],
[store(num(9))],
),
],
);
expect(matchConstantChoice(first)?.cases).toEqual([
{ value: 0, result: 5 },
{ value: 1, result: 6 },
{ value: 2, result: 8 },
]);
});
test("chains that are not one choice of literals keep their arms", () => {
const three: [number, number][] = [
[1, 10],
[2, 20],
[3, 30],
];
// Two values, no final else, a fraction, or cases spanning more than 256 values.
expect(matchConstantChoice(chain(three.slice(0, 2), [store(num(0))]))).toBeNull();
expect(matchConstantChoice(chain(three, null))).toBeNull();
expect(matchConstantChoice(chain([...three, [1.5, 40]], [store(num(0))]))).toBeNull();
expect(matchConstantChoice(chain([...three, [256, 40]], [store(num(0))]))).not.toBeNull();
expect(matchConstantChoice(chain([...three, [257, 40]], [store(num(0))]))).toBeNull();
// A final else that is not a literal, or that stores elsewhere.
expect(matchConstantChoice(chain(three, [store(ref("y"))]))).toBeNull();
expect(matchConstantChoice(chain(three, [store(num(0), ref("j"))]))).toBeNull();
expect(matchConstantChoice(chain(three, [store(num(0), ref("i"), "b")]))).toBeNull();
// An arm with a second statement.
expect(matchConstantChoice(chain(three, [store(num(0)), store(num(1))]))).toBeNull();
// A second subject.
const mixed = branch(is(ref("y"), num(0)), [store(num(1))], [chain(three, [store(num(0))])]);
expect(matchConstantChoice(mixed)).toBeNull();
});
@@ -0,0 +1,241 @@
import { createHash } from "node:crypto";
import { InternalCompilerError } from "../../errors.js";
import type { IrExpr, IrStmt } from "../../ir/ir.js";
import { exactInteger, widenInteger } from "./integer-values.js";
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
import { f64Lit } from "./common.js";
type BytesSet = Extract<IrStmt, { kind: "bytesSet" }>;
/** An if/else-if chain that stores one of several numeric literals to the
* same typed-array element, choosing it by comparing one number local with
* integer literals:
*
* if (x === 97) a[i] = 0; else if (x === 116) a[i] = 1; ... else a[i] = 3;
*
* The chain is emitted as one store of a value read from a constant table,
* with no branch on the comparisons. Branches on unpredictable input (the
* bases of a DNA sequence, the bytes of a text) mispredict about half the
* time; LLVM keeps them, because sparse cases (97, 99, 116) are too far
* apart for its own switch tables. */
export interface ConstantChoice {
subject: IrExpr;
/** The first arm for each distinct value, in chain order. */
cases: readonly { value: number; result: number }[];
fallback: number;
/** Every arm's store, the final else's last. */
stores: readonly BytesSet[];
}
/** At least this many distinct values: shorter chains predict as well as a
* table load. */
const MIN_CASES = 3;
/** The table covers the subject's values from the smallest case (or the
* subject's proven minimum) up to this many entries. */
const MAX_TABLE = 256;
function integerLiteral(expr: IrExpr): number | null {
if (expr.kind !== "numLit" || !Number.isSafeInteger(expr.value)) return null;
// `x === -0` and `x === 0` select the same values.
return expr.value === 0 ? 0 : expr.value;
}
function sameVarRef(a: IrExpr, b: IrExpr): boolean {
return a.kind === "varRef" && b.kind === "varRef" && a.localId === b.localId;
}
/** A local read or a number literal: reading it has no effect. */
function sameOperand(a: IrExpr, b: IrExpr): boolean {
if (a.kind === "numLit" && b.kind === "numLit") return Object.is(a.value, b.value);
return sameVarRef(a, b);
}
/** The store of a single-statement arm, when it stores a number literal to
* an element named by a local and a local or a literal index (so evaluating
* them once, after the comparisons, reads what every arm would). */
function literalStore(body: readonly IrStmt[] | null): BytesSet | null {
if (body === null || body.length !== 1) return null;
const s = body[0]!;
if (s.kind !== "bytesSet" || s.value.kind !== "numLit") return null;
if (s.arr.kind !== "varRef" || (s.index.kind !== "varRef" && s.index.kind !== "numLit"))
return null;
if (s.arr.type.kind !== "bytes" || s.index.type.kind !== "f64") return null;
return s;
}
export function matchConstantChoice(s: IrStmt): ConstantChoice | null {
if (s.kind !== "if") return null;
let subject: IrExpr | null = null;
const cases: { value: number; result: number }[] = [];
const seen = new Set<number>();
const stores: BytesSet[] = [];
let current: IrStmt = s;
for (;;) {
if (current.kind !== "if") return null;
const cond = current.cond;
if (cond.kind !== "bin" || cond.op !== "===") return null;
const [local, literal] =
cond.left.kind === "varRef" ? [cond.left, cond.right] : [cond.right, cond.left];
if (local.kind !== "varRef" || local.type.kind !== "f64") return null;
if (subject === null) subject = local;
else if (!sameVarRef(subject, local)) return null;
const value = integerLiteral(literal);
if (value === null) return null;
const store = literalStore(current.then);
if (store === null) return null;
stores.push(store);
// A repeated value never reaches its later arm.
if (!seen.has(value)) {
seen.add(value);
cases.push({ value, result: (store.value as IrExpr & { kind: "numLit" }).value });
}
const rest: readonly IrStmt[] | null = current.else_;
if (rest === null || rest.length !== 1) return null;
if (rest[0]!.kind === "if") {
current = rest[0]!;
continue;
}
const last = literalStore(rest);
if (last === null) return null;
stores.push(last);
break;
}
const first = stores[0]!;
for (const store of stores) {
if (!sameVarRef(store.arr, first.arr) || !sameOperand(store.index, first.index)) return null;
if (store.arr.type.kind !== "bytes" || first.arr.type.kind !== "bytes") return null;
if (store.arr.type.elem !== first.arr.type.elem) return null;
}
if (cases.length < MIN_CASES) return null;
const values = cases.map((c) => c.value);
if (Math.max(...values) - Math.min(...values) >= MAX_TABLE) return null;
const fallback = (stores[stores.length - 1]!.value as IrExpr & { kind: "numLit" }).value;
return { subject: subject!, cases, fallback, stores };
}
function int32(value: number): boolean {
return (
Number.isInteger(value) && value >= -2147483648 && value <= 2147483647 && !Object.is(value, -0)
);
}
/** The chosen number: the table entry for the subject's value, or the final
* else's literal for a value no case names (including NaN, a fraction and
* every value outside the table). */
function emitChoice(host: LlvmEmitterContext, choice: ConstantChoice): LlValue {
const B = host.B;
const subject = host.emitExpr(choice.subject);
const results = [...choice.cases.map((c) => c.result), choice.fallback];
const integral = results.every(int32);
const element = integral ? "i32" : "double";
const literal = (value: number): string => (integral ? String(value) : f64Lit(value));
const proven = exactInteger(host, subject, choice.subject);
const values = choice.cases.map((c) => c.value);
let low = Math.min(...values);
let high = Math.max(...values);
// A proven range that fits a table needs no test: every value it allows
// has an entry.
const covered =
proven !== null && proven.range.max - proven.range.min < MAX_TABLE ? proven : null;
if (covered !== null) {
low = covered.range.min;
high = covered.range.max;
}
const size = high - low + 1;
const entries: string[] = [];
for (let value = low; value <= high; value++) {
const hit = choice.cases.find((c) => c.value === value);
entries.push(`${element} ${literal(hit === undefined ? choice.fallback : hit.result)}`);
}
const table = `@sc_choice_${createHash("sha256")
.update(`${element}:${entries.join(",")}`)
.digest("hex")
.slice(0, 16)}`;
host.declare(
`${table} = private unnamed_addr constant [${size} x ${element}] [${entries.join(", ")}]`,
);
let index: string;
let ok: string | null = null;
if (covered !== null) {
const wide = widenInteger(host, covered);
index = B.tmp();
B.line(`${index} = sub i64 ${wide}, ${low}`);
} else {
// freeze keeps NaN and out-of-range conversions from producing poison;
// the round trip then rejects every value that is not an exact integer
// (-0 converts to 0, which `=== 0` selects too).
const raw = B.tmp();
const selector = B.tmp();
const restored = B.tmp();
const exact = B.tmp();
const offset = B.tmp();
const inside = B.tmp();
ok = B.tmp();
index = B.tmp();
B.line(`${raw} = fptosi double ${subject.name} to i64`);
B.line(`${selector} = freeze i64 ${raw}`);
B.line(`${restored} = sitofp i64 ${selector} to double`);
B.line(`${exact} = fcmp oeq double ${subject.name}, ${restored}`);
B.line(`${offset} = sub i64 ${selector}, ${low}`);
B.line(`${inside} = icmp ult i64 ${offset}, ${size}`);
B.line(`${ok} = and i1 ${exact}, ${inside}`);
B.line(`${index} = select i1 ${ok}, i64 ${offset}, i64 0`);
}
const slot = B.tmp();
const entry = B.tmp();
B.line(
`${slot} = getelementptr inbounds [${size} x ${element}], ptr ${table}, i64 0, i64 ${index}`,
);
B.line(`${entry} = load ${element}, ptr ${slot}`);
let chosen = entry;
if (ok !== null) {
chosen = B.tmp();
B.line(
`${chosen} = select i1 ${ok}, ${element} ${entry}, ${element} ${literal(choice.fallback)}`,
);
}
if (!integral) return { name: chosen, type: choice.stores[0]!.value.type };
const number = B.tmp();
B.line(`${number} = sitofp i32 ${chosen} to double`);
return {
name: number,
type: choice.stores[0]!.value.type,
uint32: chosen,
integer: {
name: chosen,
type: "i32",
signed: Math.min(...results) < 0,
range: { min: Math.min(...results), max: Math.max(...results) },
},
};
}
/** Emits a matched chain as one store of the chosen literal. The element
* write is the arms' own: every arm names the same receiver and index. */
export function emitConstantChoice(host: LlvmEmitterContext, choice: ConstantChoice): void {
const first = choice.stores[0]!;
if (first.arr.type.kind !== "bytes")
throw new InternalCompilerError("llvm emitter bug: constant choice on non-bytes");
// An arm's index carries the ranges proven inside that arm: an index that
// is the compared local holds the arm's value there. The one store runs
// for every value, so such an index reads the local through the first
// comparison's operand, whose range holds before the chain branches.
const indexExpr = sameVarRef(first.index, choice.subject) ? choice.subject : first.index;
const arr = host.emitStableReceiver(first.arr, [indexExpr]);
const index = host.emitExpr(indexExpr);
const value = emitChoice(host, choice);
const scopes = choice.stores.map((store) => host.bytesAliasScopes.get(store));
host.emitBytesSet(
first.arr.type.elem,
arr.name,
index,
value,
indexExpr,
undefined,
choice.stores.every((store) => host.bytesBounds.has(store)),
choice.stores.every((store) => host.bytesOwned.has(store)),
scopes.every((scope) => scope === scopes[0]) ? (scopes[0] ?? "") : "",
);
}
@@ -0,0 +1,330 @@
/* Runtime CPU dispatch for x86-64 executables (cloneForCpuDispatch): the
* program's functions get a copy compiled for x86-64-v3, chosen once per
* call by the CPU level the executable detects at startup. */
import type { TargetCpu } from "../target-cpu.js";
/** The levels a dispatching executable carries a copy of its program's
* functions for, above the baseline. */
export const CPU_DISPATCH_TIERS: readonly TargetCpu[] = ["x86-64-v3"];
/** The CPU level the executable runs at: 1 (the x86-64 baseline) or 3
* (x86-64-v3), set by @sc_cpu_level_init before the program runs. */
const LEVEL = "@sc_cpu_level";
/** The attribute groups of the x86-64-v3 copies: #0's and #2's (see
* emit-module.ts functionAttributes) with the level's `target-cpu`. */
const COPY_GROUPS: ReadonlyMap<string, string> = new Map([
["0", "3"],
["2", "4"],
]);
export function cpuDispatchAttributes(asan: string): string[] {
return [
`attributes #3 = {${asan} "target-cpu"="x86-64-v3" }`,
`attributes #4 = { noinline${asan} "target-cpu"="x86-64-v3" }`,
];
}
/** Bits of CPUID leaf 1's ECX that x86-64-v3 needs: SSE3 (0), SSSE3 (9),
* FMA (12), CMPXCHG16B (13), SSE4.1 (19), SSE4.2 (20), MOVBE (22), POPCNT
* (23), XSAVE (26), OSXSAVE (27), AVX (28) and F16C (29). */
const LEAF1_ECX = 0x3cd83201;
/** Leaf 7's EBX: BMI1 (3), AVX2 (5) and BMI2 (8). */
const LEAF7_EBX = 0x128;
/** Leaf 0x80000001's ECX: LAHF/SAHF (0) and LZCNT (5). */
const EXT1_ECX = 0x21;
/** `SCRIPTC_CPU_LEVEL` values and the level each caps the executable at. */
const LEVEL_NAMES: readonly [string, number][] = [
["0", 0],
["1", 1],
["2", 2],
["3", 3],
["4", 4],
["baseline", 1],
["v1", 1],
["v2", 2],
["v3", 3],
["v4", 4],
["x86-64", 1],
["x86-64-v2", 2],
["x86-64-v3", 3],
["x86-64-v4", 4],
];
/** @sc_cpu_level and @sc_cpu_level_init, which main calls first:
* - CPUID reports every x86-64-v3 feature, and XGETBV that the operating
* system saves the SSE and AVX registers, for level 3; otherwise 1;
* - `SCRIPTC_CPU_LEVEL` (a level from 0 to 4, `baseline` or an `x86-64-vN`
* name) caps the level; another value caps it at 0. */
export function cpuLevelDefs(declare: (line: string) => void): string[] {
declare(`declare ptr @getenv(ptr)`);
declare(`declare i32 @strcmp(ptr, ptr)`);
const bytes = (text: string): string => `[${text.length + 1} x i8] c"${text}\\00"`;
const lines = [
`${LEVEL} = internal global i32 1`,
`@sc_cpu_level_env = private unnamed_addr constant ${bytes("SCRIPTC_CPU_LEVEL")}`,
...LEVEL_NAMES.map(
([name], i) => `@sc_cpu_level_name_${i} = private unnamed_addr constant ${bytes(name)}`,
),
`define internal void @sc_cpu_level_init() #0 {`,
`entry:`,
` %max = call { i32, i32, i32, i32 } asm "cpuid", "={ax},={bx},={cx},={dx},{ax},{cx}"(i32 0, i32 0)`,
` %max.leaf = extractvalue { i32, i32, i32, i32 } %max, 0`,
` %has.leaf7 = icmp uge i32 %max.leaf, 7`,
` br i1 %has.leaf7, label %leaf1, label %detected`,
`leaf1:`,
` %l1 = call { i32, i32, i32, i32 } asm "cpuid", "={ax},={bx},={cx},={dx},{ax},{cx}"(i32 1, i32 0)`,
` %l1.ecx = extractvalue { i32, i32, i32, i32 } %l1, 2`,
` %l1.bits = and i32 %l1.ecx, ${LEAF1_ECX}`,
` %l1.ok = icmp eq i32 %l1.bits, ${LEAF1_ECX}`,
` br i1 %l1.ok, label %leaf7, label %detected`,
`leaf7:`,
` %l7 = call { i32, i32, i32, i32 } asm "cpuid", "={ax},={bx},={cx},={dx},{ax},{cx}"(i32 7, i32 0)`,
` %l7.ebx = extractvalue { i32, i32, i32, i32 } %l7, 1`,
` %l7.bits = and i32 %l7.ebx, ${LEAF7_EBX}`,
` %l7.ok = icmp eq i32 %l7.bits, ${LEAF7_EBX}`,
` %ext = call { i32, i32, i32, i32 } asm "cpuid", "={ax},={bx},={cx},={dx},{ax},{cx}"(i32 -2147483648, i32 0)`,
` %ext.max = extractvalue { i32, i32, i32, i32 } %ext, 0`,
` %ext.has = icmp uge i32 %ext.max, -2147483647`,
` %l7.ext = and i1 %l7.ok, %ext.has`,
` br i1 %l7.ext, label %ext1, label %detected`,
`ext1:`,
` %e1 = call { i32, i32, i32, i32 } asm "cpuid", "={ax},={bx},={cx},={dx},{ax},{cx}"(i32 -2147483647, i32 0)`,
` %e1.ecx = extractvalue { i32, i32, i32, i32 } %e1, 2`,
` %e1.bits = and i32 %e1.ecx, ${EXT1_ECX}`,
` %e1.ok = icmp eq i32 %e1.bits, ${EXT1_ECX}`,
` br i1 %e1.ok, label %xcr0, label %detected`,
// Leaf 1 reported OSXSAVE, so XGETBV is available.
`xcr0:`,
` %xcr = call { i32, i32 } asm "xgetbv", "={ax},={dx},{cx}"(i32 0)`,
` %xcr.lo = extractvalue { i32, i32 } %xcr, 0`,
` %xcr.bits = and i32 %xcr.lo, 6`,
` %xcr.ok = icmp eq i32 %xcr.bits, 6`,
` %v3 = select i1 %xcr.ok, i32 3, i32 1`,
` br label %detected`,
`detected:`,
` %level = phi i32 [ 1, %entry ], [ 1, %leaf1 ], [ 1, %leaf7 ], [ 1, %ext1 ], [ %v3, %xcr0 ]`,
` %env = call ptr @getenv(ptr @sc_cpu_level_env)`,
` %has.env = icmp ne ptr %env, null`,
` br i1 %has.env, label %cap, label %store`,
`cap:`,
];
// The matching name's level, or 0 when none matches.
let cap = "0";
LEVEL_NAMES.forEach(([, level], i) => {
lines.push(
` %name.${i} = call i32 @strcmp(ptr %env, ptr @sc_cpu_level_name_${i})`,
` %is.${i} = icmp eq i32 %name.${i}, 0`,
` %cap.${i} = select i1 %is.${i}, i32 ${level}, i32 ${cap}`,
);
cap = `%cap.${i}`;
});
lines.push(
` %capped.below = icmp ult i32 ${cap}, %level`,
` %capped = select i1 %capped.below, i32 ${cap}, i32 %level`,
` br label %store`,
`store:`,
` %final = phi i32 [ %level, %detected ], [ %capped, %cap ]`,
` store i32 %final, ptr ${LEVEL}`,
` ret void`,
`}`,
``,
);
return lines;
}
/** The call main makes before anything else. */
export const CPU_LEVEL_INIT_CALL = ` call void @sc_cpu_level_init()`;
/** A function definition's header, split around its name and parameters. */
interface Header {
/** `define` and the linkage, up to the return type. */
linkage: string;
/** The return type with its attributes. */
ret: string;
name: string;
/** The parameter list between the parentheses. */
params: string;
/** Function attributes before the group (`noinline`, `inlinehint`). */
attrs: string;
group: string;
/** What follows the group: a personality, then `{` and a comment. */
rest: string;
}
const HEADER_START = /^define ((?:internal |private )?)(.*?)@([\w.$-]+)\(/;
const HEADER_END = /^((?: [a-z_]+)*) #(\d+)( .*)$/;
const RETURN_ATTRIBUTES =
/\b(?:zeroext|signext|noalias|nonnull|noundef|inreg|dereferenceable(?:_or_null)?\(\d+\)|align \d+)\s+/g;
function parseHeader(line: string): Header | null {
const start = HEADER_START.exec(line);
if (start === null) return null;
const open = start[0].length - 1;
let depth = 0;
let close = open;
for (; close < line.length; close++) {
const c = line.charAt(close);
if (c === "(") depth++;
else if (c === ")" && --depth === 0) break;
}
const end = HEADER_END.exec(line.slice(close + 1));
if (end === null) return null;
return {
linkage: `define ${start[1]!}`,
ret: start[2]!.trim(),
name: start[3]!,
params: line.slice(open + 1, close),
attrs: end[1]!,
group: end[2]!,
rest: end[3]!,
};
}
/** The parameters split at their top-level commas. */
function splitParams(params: string): string[] {
const out: string[] = [];
let depth = 0;
let from = 0;
for (let i = 0; i < params.length; i++) {
const c = params.charAt(i);
if (c === "(" || c === "{" || c === "[" || c === "<") depth++;
else if (c === ")" || c === "}" || c === "]" || c === ">") depth--;
else if (c === "," && depth === 0) {
out.push(params.slice(from, i).trim());
from = i + 1;
}
}
if (params.trim() !== "") out.push(params.slice(from).trim());
return out;
}
/** A definition the pass copies: one in an attribute group it has a copy
* group for (coroutine bodies, #1, keep their one body), with named
* parameters, no variable arguments and no debug locations. */
function cloneable(header: Header, text: string): boolean {
if (!COPY_GROUPS.has(header.group)) return false;
if (text.includes("!dbg")) return false;
return splitParams(header.params).every((param) => /%[\w.$-]+$/.test(param));
}
/** The dispatching definition under the function's own name: it tail-calls
* the x86-64-v3 copy at level 3 or higher and the baseline copy otherwise. */
function dispatcher(header: Header): string {
const args = splitParams(header.params).join(", ");
const type = header.ret.replace(RETURN_ATTRIBUTES, "").trim();
const call = (tier: string, result: string): string[] =>
type === "void"
? [` musttail call void @${header.name}.${tier}(${args})`, ` ret void`]
: [
` ${result} = musttail call ${header.ret} @${header.name}.${tier}(${args})`,
` ret ${type} ${result}`,
];
return [
`${header.linkage}${header.ret} @${header.name}(${header.params}) #0 {`,
`entry:`,
` %sc.cpu.level = load i32, ptr ${LEVEL}`,
` %sc.cpu.v3 = icmp uge i32 %sc.cpu.level, 3`,
` br i1 %sc.cpu.v3, label %sc.cpu.v3.call, label %sc.cpu.base.call`,
`sc.cpu.v3.call:`,
...call("v3", "%sc.cpu.v3.result"),
`sc.cpu.base.call:`,
...call("base", "%sc.cpu.base.result"),
`}`,
].join("\n");
}
/** A definition split at its header line and its closing brace. */
interface Definition {
header: Header;
/** The lines after the header, through the closing `}`. */
body: string;
/** Text after the closing brace, kept as it is. */
after: string;
}
/** Rewrites the program's function definitions in `parts` for runtime
* dispatch. A definition is a part, or a `define` chunk of a part, that
* follows a partition group line (emit-module.ts partitionGroupLines);
* runtime helpers and the module's entry points stay as they are. Each
* cloneable function `@f` becomes:
* - `@f.base`, its body as before;
* - `@f.v3`, the same body in an x86-64-v3 attribute group;
* - `@f`, a dispatcher with the same signature, which every reference to
* the function as a value and every call from outside the program's
* functions still names; a function with no such reference gets none.
* Direct calls between cloned functions go to the caller's own tier, so the
* level is tested once on entry into the program's code. */
export function cloneForCpuDispatch(parts: readonly string[]): string[] {
const program: { index: number; chunks: string[] }[] = [];
for (let i = 1; i < parts.length; i++)
if (parts[i - 1]!.startsWith("; scriptc-group") && parts[i]!.startsWith("define "))
program.push({ index: i, chunks: parts[i]!.split(/\n(?=define )/) });
const defs = new Map<string, Definition>();
for (const { chunks } of program)
for (const chunk of chunks) {
const newline = chunk.indexOf("\n");
const header = newline < 0 ? null : parseHeader(chunk.slice(0, newline));
if (header === null) continue;
const close = chunk.indexOf("\n}", newline);
if (close < 0) continue;
const body = chunk.slice(newline + 1, close + 2);
if (!cloneable(header, body)) continue;
defs.set(header.name, { header, body, after: chunk.slice(close + 2) });
}
if (defs.size === 0) return [...parts];
const callee = /@([\w.$-]+)\(/g;
const retarget = (body: string, tier: string): string =>
body.replace(callee, (all, name: string) => (defs.has(name) ? `@${name}.${tier}(` : all));
const named = namedFunctions(parts, program, defs, (body) => retarget(body, "base"));
const out = [...parts];
for (const { index, chunks } of program)
out[index] = chunks
.map((chunk) => {
const header = HEADER_START.exec(chunk);
const def = header === null ? undefined : defs.get(header[3]!);
if (def === undefined) return chunk;
const h = def.header;
const copy = (tier: string, group: string): string =>
`define internal ${h.ret} @${h.name}.${tier}(${h.params})${h.attrs} #${group}${h.rest}\n${retarget(def.body, tier)}`;
return (
[
copy("base", h.group),
copy("v3", COPY_GROUPS.get(h.group)!),
...(named.has(h.name) ? [dispatcher(h)] : []),
].join("\n") + def.after
);
})
.join("\n");
return out;
}
/** The cloned functions whose own name the module still uses once the
* copies call each other: as a value, or in a call from code that is not
* copied (main, runtime helpers, coroutine bodies). Only these get a
* dispatcher. A function without one keeps the callers it had, so LLVM
* inlines and removes its copies as it would the original. */
function namedFunctions(
parts: readonly string[],
program: readonly { index: number; chunks: string[] }[],
defs: ReadonlyMap<string, Definition>,
retargeted: (body: string) => string,
): Set<string> {
const named = new Set<string>();
const scan = (text: string): void => {
for (const m of text.matchAll(/@([\w.$-]+)/g)) if (defs.has(m[1]!)) named.add(m[1]!);
};
const inProgram = new Set(program.map(({ index }) => index));
parts.forEach((part, i) => {
if (!inProgram.has(i)) scan(part);
});
for (const { chunks } of program)
for (const chunk of chunks) {
const header = HEADER_START.exec(chunk);
const def = header === null ? undefined : defs.get(header[3]!);
if (def === undefined) scan(chunk);
else scan(retargeted(def.body) + def.after);
}
return named;
}
@@ -83,6 +83,7 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
emitter.unwindCleanups.clear();
emitter.jumpTargets = [];
emitter.currentLocals = new Map(fn.locals.map((l) => [l.id, l]));
emitter.currentParameters = new Set(fn.params.map((p) => p.localId));
emitter.lazyCaptureBoxes.clear();
emitter.lazyStackBoxes.clear();
// An unchanged captured parameter keeps its value in a plain slot; the
@@ -65,7 +65,9 @@ import { STRING_READ_HELPERS, stringScanDecl, stringScanInline } from "./string-
import { emitLibraryIdentityLines } from "../library-identity-markers.js";
import { InternalCompilerError } from "../../errors.js";
import { targetCpuAttribute } from "../target-cpu.js";
import { CPU_LEVEL_INIT_CALL, cpuDispatchAttributes, cpuLevelDefs } from "./cpu-dispatch.js";
import type { IrFunction } from "../../ir/ir.js";
import { type FunctionLayout, layoutTierOf } from "./function-layout.js";
/** The function bodies emitted in `chunks` contiguous ranges, each by its
* own emitter (LlEmitter.chunkEmitter), then merged in function order: the
@@ -198,7 +200,8 @@ function readRange(dir: string, range: number): { result: ChunkResult; texts: st
* #1 for coroutine bodies and #2 for out-of-line record clones. Each carries
* the build's `--target-cpu` level, and `sanitize_address` in sanitized
* builds. A group needs one attribute: without either, #0 holds a string
* attribute that LLVM ignores. */
* attribute that LLVM ignores. Dispatching builds add the x86-64-v3 copies'
* #3 and #4 (cpu-dispatch.ts). */
function functionAttributes(emitter: LlEmitter, hasNoInlineRecordClone: boolean): string[] {
const cpu = targetCpuAttribute(emitter.targetCpu);
const asan = emitter.sanitizeAttributes ? " sanitize_address" : "";
@@ -207,6 +210,7 @@ function functionAttributes(emitter: LlEmitter, hasNoInlineRecordClone: boolean)
`attributes #0 = {${base} }`,
...(emitter.usesCoroutines ? [`attributes #1 = {${asan} presplitcoroutine${cpu} }`] : []),
...(hasNoInlineRecordClone ? [`attributes #2 = { noinline${asan}${cpu} }`] : []),
...(emitter.cpuDispatch ? cpuDispatchAttributes(asan) : []),
];
}
@@ -230,6 +234,61 @@ function partitionGroupLines(functions: readonly IrFunction[]): string[] {
/** Ends the group of the definitions that follow. */
const PARTITION_GROUP_END = "; scriptc-group";
/** Writes the generated helpers, then the function definitions with their
* group lines. With a layout, the definitions it lists, helpers included,
* move after the unlisted helpers and before the other functions, by tier,
* each in its tier's group (`layout-<tier>`). The helper compiles each
* partition's definitions in text order, so listed ones come first in their
* partitions, and it places a group's definitions in the same partitions.
* Everything else keeps its order and its source-file group. */
function writeDefinitions(
out: string[],
layout: FunctionLayout | undefined,
helperTexts: readonly (readonly string[])[],
fnDefs: readonly string[],
groups: readonly string[],
): void {
const tierLine = (tier: number) => `${PARTITION_GROUP_END} layout-${tier}`;
const listed = new Map<number, string[]>();
const listIn = (tier: number, lines: readonly string[]) => {
const list = listed.get(tier) ?? [];
if (!listed.has(tier)) listed.set(tier, list);
for (const line of lines) list.push(line);
};
if (layout === undefined || layout.size === 0) {
for (const texts of helperTexts) for (const text of texts) out.push(text);
} else {
// A helper's text can span several entries (one per line), so the
// definitions are found in the joined text. A listed definition moves
// through its closing brace; what follows it stays in place.
const joined: string[] = [];
for (const texts of helperTexts) for (const text of texts) joined.push(text);
for (const part of joined.join("\n").split(/\n(?=define )/)) {
const tier = part.startsWith("define ") ? layoutTierOf(layout, part) : undefined;
const close = tier === undefined ? -1 : part.search(/\n}(\n|$)/);
if (tier === undefined || close < 0) {
out.push(part);
continue;
}
listIn(tier, [tierLine(tier), part.slice(0, close + 2)]);
const rest = part.slice(close + 3);
if (rest !== "") out.push(rest);
}
}
const unlisted: number[] = [];
fnDefs.forEach((text, i) => {
const tier = layout === undefined || layout.size === 0 ? undefined : layoutTierOf(layout, text);
if (tier === undefined) unlisted.push(i);
else listIn(tier, ["", tierLine(tier), text]);
});
for (const tier of [...listed.keys()].sort((a, b) => a - b))
for (const line of listed.get(tier)!) out.push(line);
for (const [k, i] of unlisted.entries()) {
if (k > 0 || listed.size > 0) out.push("");
out.push(groups[i] ?? PARTITION_GROUP_END, fnDefs[i]!);
}
}
export function emitModuleParts(emitter: LlEmitter): string[] {
// Function bodies first (the literal/unit/fn-value tables fill as they
// emit), then the file assembles around them — the runtime ABI’s order.
@@ -585,6 +644,7 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
...(emitter.publishWalkersCache?.defs() ?? []),
...emitter.publishedClassNamesDefs(),
...(emitter.regions?.moduleDefs() ?? []),
...(emitter.cpuDispatch ? cpuLevelDefs((line) => emitter.declare(line)) : []),
];
if (emitter.constantNumericTables.size > 0)
emitter.declare(`declare double @scr_arr_get_number(ptr, double)`);
@@ -944,21 +1004,24 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
);
}
if (globals.length > 0) out.push(``);
for (const line of helpers) out.push(line);
for (const line of ffiCallbacks.defs) out.push(line);
for (const line of shapes.defs) out.push(line);
for (const line of classShapes.defs) out.push(line);
for (const line of classObjDefs) out.push(line);
for (const line of emitter.walkers.defs) out.push(line);
for (const line of emitter.dyn.defs) out.push(line);
for (const line of wrappers) out.push(line);
for (const line of asyncDefs) out.push(line);
for (const line of emitter.resolveThunkDefs) out.push(line);
const groups = partitionGroupLines(emitter.mod.functions);
for (let i = 0; i < fnDefs.length; i++) {
if (i > 0) out.push("");
out.push(groups[i] ?? PARTITION_GROUP_END, fnDefs[i]!);
}
writeDefinitions(
out,
emitter.targetOptions.functionLayout,
[
helpers,
ffiCallbacks.defs,
shapes.defs,
classShapes.defs,
classObjDefs,
emitter.walkers.defs,
emitter.dyn.defs,
wrappers,
asyncDefs,
emitter.resolveThunkDefs,
],
fnDefs,
partitionGroupLines(emitter.mod.functions),
);
if (fnDefs.length === 0) out.push("");
out.push(PARTITION_GROUP_END, "");
// WebAssembly modules translated into this program, and the registry.
@@ -1043,6 +1106,7 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
`define i32 @${emitter.mod.workers ? "sc_context_entry" : emitter.wasi ? "__main_argc_argv" : "main"}(i32 %argc, ptr %argv) ${FN_ATTRS} {`,
`entry:`,
...(emitter.runtimeAbiMarker ? [` call void @${RUNTIME_ABI_MARKER}()`] : []),
...(emitter.cpuDispatch && !emitter.mod.workers ? [CPU_LEVEL_INIT_CALL] : []),
...(emitter.mod.workers ? [` call void @scr_runtime_workers_v8()`] : []),
` call void @scr_init()`,
// Compressed fields hold addresses in the program-lifetime region, which
@@ -1201,6 +1265,8 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
`}`,
`define i32 @main(i32 %argc, ptr %argv) ${FN_ATTRS} {`,
`entry:`,
// Worker threads enter at sc_worker_entry and read the level main set.
...(emitter.cpuDispatch ? [CPU_LEVEL_INIT_CALL] : []),
` %result = call i32 @sc_context_entry(i32 %argc, ptr %argv)`,
` call void @scr_context_cleanup()`,
` ret i32 %result`,
@@ -1,4 +1,6 @@
/* LlEmitter's statement emission: the statement switch, try/catch, switch, loop copies, speculative writes and string self-concatenation. */
import { emitLoopBytesHeaders } from "./bytes-headers.js";
import { noteBytesReceiver } from "./expr-bytes.js";
import {
type LlEmitter,
type LlScopeEntry,
@@ -81,6 +83,7 @@ import {
planProvenIntegerLoop,
planSpeculativeLoop,
type ProvenLoopPlan,
type SpeculativeLoopPlan,
speculativeReads,
speculativeWriteTarget,
} from "./speculative-integers.js";
@@ -93,6 +96,7 @@ import {
import { emitStringInputs } from "./string-lifetimes.js";
import { LOOPS } from "./loop-state.js";
import { loopAccessesTypedArrays } from "../loop-polls.js";
import { emitConstantChoice, matchConstantChoice } from "./constant-choices.js";
/** A callback loop version copies the loop; keep the copies small. */
const MAX_CALLBACK_VERSION_COST = 400;
@@ -576,6 +580,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
// IrBytesElem is static, so never rediscover it through the
// generic runtime switch in a hot loop.
const arr = emitter.emitStableReceiver(s.arr, [s.index, s.value]);
noteBytesReceiver(emitter, s.arr, arr.name);
const idx = emitter.emitExpr(s.index);
const v = emitter.emitExpr(s.value);
if (s.arr.type.kind !== "bytes")
@@ -837,6 +842,13 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
break;
}
case "if": {
// A chain storing one of several literals to one element stores a
// table entry instead (debug builds keep the arms to step through).
const choice = emitter.debug === null ? matchConstantChoice(s) : null;
if (choice !== null) {
emitConstantChoice(emitter, choice);
break;
}
const cond = emitter.emitCondition(s.cond);
const trueLabel = B.newLabel("if.t");
const joinLabel = B.newLabel("if.j");
@@ -1265,6 +1277,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
if (stripMined) {
const counter: StripMinedCounter = countedLoop
? {
localId: countedLoop.localId,
slot: integerSlot!,
type: "i64",
signed: true,
@@ -1276,6 +1289,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
limit: () => countedLimit!,
}
: {
localId: integerLoop!.localId,
slot: integerSlot!,
type: emitter.sizeType,
signed: false,
@@ -2371,6 +2385,22 @@ export function emitLoopCopies(
else ordinary(exit);
return;
}
const outer = emitter.bytesHeaders;
emitter.bytesHeaders = emitLoopBytesHeaders(emitter, loop, emitter.currentParameters) ?? outer;
try {
emitSpeculativeLoop(emitter, plan, exit, emitCopy);
} finally {
emitter.bytesHeaders = outer;
}
}
/** The copies of a speculative loop (emitLoopCopies). */
function emitSpeculativeLoop(
emitter: LlEmitter,
plan: SpeculativeLoopPlan,
exit: string,
emitCopy: (exit: string) => void,
): void {
const B = emitter.B;
const entries = new Map<string, string>();
let ok = "true";
@@ -2705,10 +2735,11 @@ function boundedLoopRuns(emitter: LlEmitter, loop: IrStmt): number {
return trips * (1 + nested);
}
/** The counter of a strip-mined loop: its slot, the LLVM integer type and
* signedness of its values, its step, the counters stepped beside it, and
* the limit, emitted in the outer header before each block. */
/** The counter of a strip-mined loop: its binding, its slot, the LLVM
* integer type and signedness of its values, its step, the counters stepped
* beside it, and the limit, emitted in the outer header before each block. */
interface StripMinedCounter {
localId: string;
slot: string;
type: string;
signed: boolean;
@@ -2800,7 +2831,7 @@ function emitStripMinedCountedLoop(
const after = runSlot === null ? poll : B.newLabel("loop.end");
B.condBr(inBlock, lb, after);
B.startBlock(lb);
emitter.jumpTargets.push({
const target: LlEmitter["jumpTargets"][number] = {
kind: "loop",
brkLabel: exit,
contLabel: lu,
@@ -2808,8 +2839,19 @@ function emitStripMinedCountedLoop(
frameDepth: emitter.frames.length,
scopeDepth: emitter.scopes.length,
finallyDepth: emitter.finallyStack.length,
});
emitter.emitBlock(s.body);
};
emitter.jumpTargets.push(target);
// The counter steps by a nonzero constant and the body does not assign
// it, so an element at the counter is a different element on every
// iteration: its plain load or store is not merged with another
// iteration's or moved out of the loop.
const outer = emitter.plainSharedLoop;
emitter.plainSharedLoop = { counter: counter.localId, target, depth: emitter.loopDepth };
try {
emitter.emitBlock(s.body);
} finally {
emitter.plainSharedLoop = outer;
}
emitter.jumpTargets.pop();
B.br(lu);
B.startBlock(lu);
+113 -20
View File
@@ -1,4 +1,5 @@
import type { TargetCpu } from "../target-cpu.js";
import type { FunctionLayout } from "./function-layout.js";
import { relieveHeapPressure } from "../../heap-pressure.js";
import { wasmBoundsChecks } from "./wasm-modules.js";
import { jsTruthy } from "./jsval.js";
@@ -63,6 +64,7 @@ import { captureFreeFunctions, observesStacks } from "../stack-observations.js";
import { findInitializedByteLoopBindings } from "./byte-windows.js";
import { matchBytesLoopVersion } from "./bytes-loop-versions.js";
import { globalSlotAlias } from "./bytes-alias.js";
import type { BytesHeaderScope } from "./bytes-headers.js";
import type { IntegerCountedForLoop, IntegerPairedForLoop } from "../../ir/integer-loops.js";
import {
materializeSplitPiece,
@@ -147,6 +149,7 @@ import {
bridgePlainUnwindCalls,
unwindAbiFunctions,
} from "./unwind-abi.js";
import { cloneForCpuDispatch } from "./cpu-dispatch.js";
import {
mangleAsyncInlineAwaitCall,
mangleAsyncSpawn,
@@ -183,14 +186,14 @@ import {
unionBoxBits,
} from "./inline-unions.js";
import { OptionalArrayReads, type LocalArrayRead } from "./local-array-reads.js";
import { CheckedNarrows } from "./checked-narrows.js";
import { borrowsCheckedNarrow, CheckedNarrows } from "./checked-narrows.js";
import {
emitDenseArraySet,
emitDenseSlotUnlink,
SCR_ELEM_I32,
type ArrayAccess,
} from "./dense-array-access.js";
import { matchMapRead, type MapReadLifetimes } from "./map-read-lifetimes.js";
import { matchMapRead, pointerMapGet, type MapReadLifetimes } from "./map-read-lifetimes.js";
import { type MapEntryFusion } from "./map-entry-fusion.js";
import { emitBorrowedFieldSequence, guardedValue } from "./borrowed-receivers.js";
import { emitBorrowedInputs } from "./borrowed-inputs.js";
@@ -213,7 +216,13 @@ import {
} from "./string-slices.js";
import { LazyCaptures } from "./lazy-captures.js";
import { StackCaptures } from "./stack-captures.js";
import { emitFieldAbsentTest, f64Lit, SCR_ARR_FIELD, SCR_ARR_FROZEN } from "./common.js";
import {
CLOSURE_RECEIVER_FREE,
emitFieldAbsentTest,
f64Lit,
SCR_ARR_FIELD,
SCR_ARR_FROZEN,
} from "./common.js";
import { ffiExtendsNarrowIntegers } from "../targets.js";
import {
emitLiteralExpr,
@@ -285,7 +294,12 @@ import {
emitBytesSet,
emitBytesIntrinsic,
} from "./expr-bytes.js";
import { emitRegexIntrinsic, emitRecordKeyGet, keyedRecordReadInto } from "./expr-records.js";
import {
emitRecordKeyGet,
emitRegexIntrinsic,
keyedRecordReadInto,
pointerRecordKeyGet,
} from "./expr-records.js";
import {
dynPromiseAdapter,
streamTypedRefCommitAdapter,
@@ -487,6 +501,8 @@ export interface LlvmTargetOptions {
/** The host's process primitives, where it can fork (the native
* compiler's). */
forkPrimitives?: ForkPrimitives | undefined;
/** Functions to write first, by tier (function-layout.ts). */
functionLayout?: FunctionLayout;
/** Exact frontend sources for dev-build locations and variable metadata. */
debugSources?: ReadonlyMap<string, string>;
/** Pointer width of the target C ABI. Native targets are 64-bit today. */
@@ -533,6 +549,11 @@ export interface LlvmTargetOptions {
* every emitted function carries it as its `target-cpu`. Unset, code
* runs on any x86-64 CPU. */
targetCpu?: TargetCpu;
/** Give an x86-64 executable without a `targetCpu` an x86-64-v3 copy of
* each program function, which it runs on CPUs at that level
* (cpu-dispatch.ts). Ignored for other targets, libraries and builds
* with debug information. */
cpuDispatch?: boolean;
}
/** LlvmTargetOptions.lowRegion for an executable: Linux targets without
@@ -1277,6 +1298,8 @@ export class LlEmitter {
readonly targetCpu: TargetCpu | undefined;
/** Emitted functions carry `sanitize_address` (LlvmTargetOptions). */
readonly sanitizeAttributes: boolean;
/** LlvmTargetOptions.cpuDispatch, where it applies. */
readonly cpuDispatch: boolean;
readonly wasmBoundsChecks: boolean;
/** Interned string literals: UTF-8 text → { symbol, byte length } —
* first-use order, the runtime ABI’s determinism discipline. */
@@ -1804,11 +1827,26 @@ export class LlEmitter {
/** Worker programs: typed-array accesses whose views an enclosing version
* guard proved thread-owned. */
bytesOwned: ReadonlySet<IrExpr | IrStmt> = new Set();
/** Worker programs: the strip-mined counted loop whose body is being
* emitted (emitStripMinedCountedLoop), with its counter, its jump target
* and its loopDepth. An element access of a view that may be shared, at
* the counter and directly in that loop's body, is a plain load or store
* (plainSharedElement in expr-bytes.ts). */
plainSharedLoop: {
counter: string;
target: LlEmitter["jumpTargets"][number];
depth: number;
} | null = null;
/** The alias-scope attachment of each typed-array element read and write
* in the current loop version's fast copy (emitVersionedLoop), and the
* one the element access being emitted carries (bytesElementAlias). */
bytesAliasScopes: ReadonlyMap<IrExpr | IrStmt, string> = new Map();
bytesAccessScope = "";
/** Header fields a speculative loop's entry read for its views
* (bytes-headers.ts), while its copies are emitted. */
bytesHeaders: BytesHeaderScope | null = null;
/** The parameters of the function being emitted. */
currentParameters: ReadonlySet<string> = new Set();
/** Loop versions with alias scopes in the current function; it names
* their scopes. */
aliasScopedVersions = 0;
@@ -1980,6 +2018,13 @@ export class LlEmitter {
this.detachedExit = options.detachedExit === true;
this.targetCpu = options.targetCpu;
this.sanitizeAttributes = options.sanitizeAttributes !== false;
this.cpuDispatch =
options.cpuDispatch === true &&
options.targetCpu === undefined &&
!this.wasi &&
mod.lib === undefined &&
this.debug === null &&
/^(x86_64|amd64|x64)\b/.test(options.targetTriple || process.arch);
this.wasmBoundsChecks =
options.wasmBoundsChecks === true ||
wasmBoundsChecks({
@@ -2486,7 +2531,8 @@ export class LlEmitter {
}
emitParts(): string[] {
const parts = emitParts(this);
return this.unwindAbi.size > 0 ? bridgePlainUnwindCalls(parts) : parts;
const bridged = this.unwindAbi.size > 0 ? bridgePlainUnwindCalls(parts) : parts;
return this.cpuDispatch ? cloneForCpuDispatch(bridged) : bridged;
}
/** The dispose-observer gates the emitted frees and record writers test
@@ -2560,7 +2606,7 @@ export class LlEmitter {
ret === "void" ? ` ${call}` : ` %r = ${call}`,
ret === "void" ? ` ret void` : ` ret ${ret} %r`,
`}`,
`@${mangleFnClosure(name)} = internal ${this.mod.workers === true || this.mod.lib?.threadInstances === true ? "thread_local " : ""}global %ScrClosure { ${this.sizeType} -1, ptr @${mangleWrapper(name)}, ${this.sizeType} 0, ptr null, i32 ${(fn.generator ? 1 : 0) + (fn.async ? 2 : 0) + (fn.ownsPrototype ? 4 : 0)}, i32 0, ptr null, ptr ${lending ? `@${mangleBorrowingWrapper(name)}` : "null"} }`,
`@${mangleFnClosure(name)} = internal ${this.mod.workers === true || this.mod.lib?.threadInstances === true ? "thread_local " : ""}global %ScrClosure { ${this.sizeType} -1, ptr @${mangleWrapper(name)}, ${this.sizeType} 0, ptr null, i32 ${this.closureFunctionKind(fn)}, i32 0, ptr null, ptr ${lending ? `@${mangleBorrowingWrapper(name)}` : "null"} }`,
``,
);
if (lending)
@@ -3112,12 +3158,13 @@ export class LlEmitter {
// ── plumbing (frame and scope ownership) ──────
/** A symbol derived from the constant's text rather than its first-use
* index, so an edit that interns one more string leaves every other
* constant's symbol, and the code referencing it, unchanged. This lets
* native builds reuse the compiled partitions an edit does not touch. A
* collision retries with a counter, still in first-use order. */
private contentSymbol(prefix: string, text: string): string {
/** A symbol derived from a constant's text, or a helper's key, rather
* than its first-use index, so an edit that interns one more string or
* helper leaves every other symbol, and the code referencing it,
* unchanged. This lets native builds reuse the compiled partitions an
* edit does not touch. A collision retries with a counter, still in
* first-use order. */
contentSymbol(prefix: string, text: string): string {
for (let attempt = 0; ; attempt++) {
const digest = createHash("sha256")
.update(attempt === 0 ? text : `${attempt}\0${text}`)
@@ -3243,7 +3290,7 @@ export class LlEmitter {
const key = `${unionId}:${tag}`;
let sym = this.unitInstances.get(key);
if (!sym) {
sym = `sc_unit_${this.unitInstances.size}`;
sym = this.contentSymbol("sc_unit_", key);
this.unitInstances.set(key, sym);
}
this.journal?.entry("unit", key, sym);
@@ -3700,6 +3747,9 @@ export class LlEmitter {
passThrough?: { name: string; type: IrType },
poll = false,
guard?: string,
/** Runs at the start of the cold unwind block, before the unwind (a
* package-declared check's origin note). */
onUnwind?: () => void,
): void {
const through =
passThrough !== undefined &&
@@ -3724,6 +3774,7 @@ export class LlEmitter {
const lk = B.newLabel("exc.k");
B.condBr(p, lu, lk);
B.startBlock(lu);
onUnwind?.();
this.emitUnwind(through);
B.startBlock(lk);
if (skip !== undefined) {
@@ -3800,6 +3851,7 @@ export class LlEmitter {
B.condBr(cold, lu, lk);
}
B.startBlock(lu);
onUnwind?.();
this.emitUnwind(through);
B.startBlock(lk);
if (skip !== undefined) {
@@ -5449,8 +5501,15 @@ export class LlEmitter {
case "retainWrapper":
addEntry(this.nullableUnions.retainWrappers, key, value as string);
return;
case "unit":
return addNumbered(this.unitInstances, key, value as string, "sc_unit_", names);
case "unit": {
let own = this.unitInstances.get(key);
if (own === undefined) {
own = this.contentSymbol("sc_unit_", key);
this.unitInstances.set(key, own);
}
if (own !== value) names.set(value as string, own);
return;
}
case "absent":
return addNumbered(this.absentInstances, key, value as string, "sc_absent_", names);
case "retype":
@@ -5458,9 +5517,9 @@ export class LlEmitter {
case "slots":
return addNumbered(this.threadSlotsBlocks, key, value as string, "@sc_slots_", names);
case "regex":
return addNumberedSymbol(this.regexInstances, key, value as never, "sc_re_", names);
return this.addContentEntry(this.regexInstances, key, value as never, "sc_re_", names);
case "regexReplacer":
return addNumberedSymbol(this.regexReplacers, key, value as never, "sc_rr_", names);
return this.addContentEntry(this.regexReplacers, key, value as never, "sc_rr_", names);
case "templateStrings":
return addNumberedSymbol(
this.templateStringsInstances,
@@ -5737,7 +5796,10 @@ export class LlEmitter {
unwrittenRead(e: IrExpr, writes: Writes): boolean {
const rw = this.referenceWrites;
if (rw === null) return false;
const keys = rw.readKeys(e, (call) => this.borrowedReturnKeys(call.callee));
// A checked extraction reads nothing besides the union it narrows.
const keys = rw.readKeys(e, (call) =>
borrowsCheckedNarrow(this, call) ? [] : this.borrowedReturnKeys(call.callee),
);
return keys !== null && !rw.conflicts(keys, writes);
}
@@ -6014,7 +6076,21 @@ export class LlEmitter {
}
case "fieldGet":
case "recordGet":
case "recordKeyGet":
return this.borrowsWithoutOwning(e) && this.unownedWalk(e.obj);
case "mapIntrinsic": {
// A Map binding's own pointer (maps are not plainReference types).
if (!this.borrowsWithoutOwning(e) || e.receiver.kind !== "varRef") return false;
const binding = this.binding(e.receiver.localId);
return (
binding.kind === "global" ||
(binding.kind === "local" &&
binding.local !== undefined &&
!binding.local.boxed &&
!binding.local.tdz &&
!this.localUnionStorage.has(e.receiver.localId))
);
}
case "unionNarrow":
case "downcast":
case "upcast":
@@ -7272,6 +7348,11 @@ export class LlEmitter {
return this.nullableFieldGet(e) === null;
case "recordGet":
return true;
// A keyed read whose hits are all the result's own pointer.
case "recordKeyGet":
return pointerRecordKeyGet(this, e);
case "mapIntrinsic":
return pointerMapGet(this, e);
// emitReadReceiver lends a required element of a borrowed array when
// computing the index removes no edge.
case "arrayGet":
@@ -7417,8 +7498,20 @@ export class LlEmitter {
return wrapEmitterListener(this, target, adapterFn);
}
unwrapNullableClosure(u: string, funcTag: number): string {
return unwrapNullableClosure(this, u, funcTag);
/** ScrClosure.function_kind for a closure over `fn`: generator 1, async
* 2, own prototype 4, and SCR_FN_RECEIVER_FREE (16) when its body never
* observes the ambient receiver, so indirect callers skip the bracket. */
closureFunctionKind(fn: IrFunction): number {
return (
(fn.generator ? 1 : 0) +
(fn.async ? 2 : 0) +
(fn.ownsPrototype ? 4 : 0) +
(this.receiverReaders.ignores(fn) ? CLOSURE_RECEIVER_FREE : 0)
);
}
unwrapNullableClosure(u: string, unionId: string, funcTag: number): string {
return unwrapNullableClosure(this, u, unionId, funcTag);
}
closeBindThunkFor(cbUnion: IrType, retServer: boolean): string {
@@ -372,6 +372,21 @@ export function emitSerializationExpr(
// 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.
// A package-declared target: the cold path names the declaration in
// the pending TypeError (scr_type_origin.c).
// Library archives and reactors link a fixed runtime set without the
// note unit: their checks keep the plain message.
const origin =
e.origin === undefined || host.mod.lib !== undefined
? undefined
: host.mod.declOrigins?.[e.origin];
const note =
origin === undefined
? undefined
: (): void => {
host.declare("declare void @scr_type_origin_note(ptr)");
B.line(`call void @scr_type_origin_note(ptr ${host.cstr(origin)})`);
};
const parsed = e.value;
if (!e.preserveRefs && parsed.kind === "libCall" && parsed.fn === "json.parse") {
const fused = host.dyn.jsonParseHelper(e.type);
@@ -380,7 +395,7 @@ export function emitSerializationExpr(
const r = B.tmp();
B.line(`${r} = call ptr @${fused}(ptr ${text.name})`);
const fusedOut = host.own({ name: r, type: e.type });
host.emitPendingCheck();
host.emitPendingCheck(undefined, false, undefined, note);
return fusedOut;
}
}
@@ -394,14 +409,14 @@ export function emitSerializationExpr(
}
const dynV = emitBorrowedInput(host, e.value);
const helper = host.dyn.dynCheckHelper(e.type, e.preserveRefs);
if (isDynTypedRefType(e.type)) return emitClassCheck(host, e.type, dynV.name, helper);
if (isDynTypedRefType(e.type)) return emitClassCheck(host, e.type, dynV.name, helper, note);
const ty = host.llType(e.type);
const t = B.tmp();
B.line(
`${t} = call ${ty === "i1" ? "zeroext i1" : ty} @${helper}(ptr ${dynV.name}, ptr null)`,
);
const out = host.own({ name: t, type: e.type });
host.emitPendingCheck();
host.emitPendingCheck(undefined, false, undefined, note);
return out;
}
default: {
@@ -420,6 +435,7 @@ function emitClassCheck(
type: IrType & { kind: "object" },
value: string,
helper: string,
note?: () => void,
): LlValue {
const B = host.B;
const slot = B.slot();
@@ -460,7 +476,7 @@ function emitClassCheck(
B.line(`${result} = call ptr @${helper}(ptr ${value}, ptr null)`);
B.line(`store ptr ${result}, ptr ${slot}`);
// A throwing check returns null, so the unwind has nothing to release.
host.emitPendingCheck();
host.emitPendingCheck(undefined, false, undefined, note);
B.br(done);
B.startBlock(done);
const out = B.tmp();
@@ -1,6 +1,7 @@
/* Focused LLVM expression emission extracted from emitter.ts. */
import { InternalCompilerError } from "../../errors.js";
import { emitBorrowedInput } from "./borrowed-inputs.js";
import type { BytesHeader } from "./bytes-headers.js";
import { BYTES_ELEMENT_SIZE, F64, type IrBytesElem, type IrExpr } from "../../ir/ir.js";
import type { LlvmEmitterContext, LlValue } from "./expr-context.js";
import { F64_INF, exactIndexLines, f64Lit, sizeToDouble } from "./common.js";
@@ -160,6 +161,22 @@ export function emitBytesIndex(
return idx;
}
/** Records that `value` is the view `receiver` reads, so the header reads of
* an access through it use the values a loop entry read (bytes-headers.ts). */
export function noteBytesReceiver(host: LlvmEmitterContext, receiver: IrExpr, value: string): void {
const scope = host.bytesHeaders;
if (scope === null || receiver.kind !== "varRef" || scope.fn !== host.currentFunctionName) return;
const header = scope.locals.get(receiver.localId);
if (header !== undefined) scope.values.set(value, header);
}
/** The header fields a loop entry read for the view `receiver`, if any. */
function cachedHeader(host: LlvmEmitterContext, receiver: string): BytesHeader | undefined {
const scope = host.bytesHeaders;
if (scope === null || scope.fn !== host.currentFunctionName) return undefined;
return scope.values.get(receiver);
}
/** The element count of a typed array, in the target's size type. `value`:
* the program reads the count itself (bytesHeaderValueAlias), rather than
* only bounding an access. */
@@ -168,6 +185,8 @@ export function emitBytesHeaderLength(
receiver: string,
value = false,
): string {
const cached = cachedHeader(host, receiver);
if (cached !== undefined) return cached.length;
const p = host.B.tmp();
const len = host.B.tmp();
const alias = value ? bytesHeaderValueAlias(host) : bytesHeaderAlias(host);
@@ -177,6 +196,8 @@ export function emitBytesHeaderLength(
}
export function emitBytesData(host: LlvmEmitterContext, receiver: string): string {
const cached = cachedHeader(host, receiver);
if (cached !== undefined) return cached.data;
const p = host.B.tmp();
const data = host.B.tmp();
host.B.line(`${p} = getelementptr inbounds %ScrBytes, ptr ${receiver}, i64 0, i32 3`);
@@ -203,6 +224,8 @@ export function emitBytesLength(
/** A view's shared backing (null for a buffer this thread owns). It is
* set at construction, before script code can see the view. */
export function emitBytesSharedStorage(host: LlvmEmitterContext, receiver: string): string {
const cached = cachedHeader(host, receiver)?.shared;
if (cached !== undefined && cached !== null) return cached;
const B = host.B;
const slot = B.tmp(),
storage = B.tmp();
@@ -303,7 +326,7 @@ function emitScopedBytesGet(
const signed = elem === "i8" || elem === "i16" || elem === "i32";
B.line(`${number} = ${signed ? "sitofp" : "uitofp"} i32 ${u32} to double`);
B.line(`${out} = select i1 ${valid}, double ${number}, double ${f64Lit(NaN)}`);
return { name: out, type: F64, uint32: u32 };
return { name: out, type: F64, uint32: u32, ordered: valid };
}
function emitBytesLoad(
@@ -317,7 +340,7 @@ function emitBytesLoad(
owned: boolean,
): LlValue {
// `owned`: an enclosing version guard proved the view thread-owned.
if (host.mod.workers && !owned) {
if (host.mod.workers && !owned && !plainSharedElement(host, elem, expr)) {
const idx = emitBytesIndex(host, receiver, index, expr, invalid, inBounds);
return emitSharedOrLocalLoad(host, elem, receiver, idx);
}
@@ -328,6 +351,31 @@ function emitBytesLoad(
return emitBytesLoadAt(host, elem, data, idx);
}
/** Worker programs: whether an element access at `index` of a view that
* may be shared is a plain load or store, like a thread-owned view's. It is
* when the index is the counter of the strip-mined counted loop whose body
* holds the access, outside any loop nested in that body (its condition and
* update included: a nested loop that waits on the element reads it on
* every iteration, so the read stays atomic): every iteration then reaches
* a different element, so LLVM cannot merge an element's reads across
* iterations or move them out of the loop, and the loop can be vectorized. A racing write from another thread can still be seen or
* missed within one iteration, the documented gap of non-atomic shared
* accesses. Float16 elements keep their runtime path. */
function plainSharedElement(
host: LlvmEmitterContext,
elem: IrBytesElem,
index: IrExpr | undefined,
): boolean {
const loop = host.plainSharedLoop;
if (loop === null || elem === "f16" || index?.kind !== "varRef") return false;
// A nested loop raises loopDepth before its condition is emitted.
if (index.localId !== loop.counter || host.loopDepth !== loop.depth) return false;
let innermost: (typeof host.jumpTargets)[number] | undefined;
for (let i = host.jumpTargets.length - 1; i >= 0 && innermost === undefined; i--)
if (host.jumpTargets[i]!.kind === "loop") innermost = host.jumpTargets[i];
return innermost === loop.target;
}
/** Worker programs: the element at a checked index of a view that may be
* shared. An integer element joins as its 32-bit value and converts to a
* number after the join, as the local load does, so compares and
@@ -666,7 +714,7 @@ function emitScopedBytesSet(
ty = "i32";
operand = stored!;
}
if (host.mod.workers && !owned) {
if (host.mod.workers && !owned && !plainSharedElement(host, elem, indexExpr)) {
// A shared view's element takes a relaxed atomic store of its width.
const local = emitSharedBytesSplit(host, receiver);
const p = B.tmp();
@@ -788,6 +836,7 @@ export function emitBytesIntrinsic(
const r = directElementAccess
? host.emitStableReceiver(e.receiver, e.args)
: emitBorrowedInput(host, e.receiver);
if (directElementAccess) noteBytesReceiver(host, e.receiver, r.name);
// Byte operations borrow every input; views and identity results return
// independent owners, while writes retain no operand beyond the call.
const args = e.args.map((a) => emitBorrowedInput(host, a));
@@ -8,7 +8,7 @@ import {
typeEquals,
typeKey,
} from "../../ir/ir.js";
import { mangleGenResThunk, mangleRecordNew, mangleRecordStruct } from "../mangle.js";
import { mangleRecordNew, mangleRecordStruct } from "../mangle.js";
import { FN_ATTRS, llFieldType, releaseSym, retainSym, traceArg, vAdapters } from "./shapes.js";
import { LlvmUnsupportedError } from "./unsupported.js";
import type { LlvmEmitterContext } from "./expr-context.js";
@@ -54,7 +54,7 @@ export function raceAdapterFor(host: LlvmEmitterContext, from: IrType, to: IrTyp
const key = `race:${typeKey(from)}=>${typeKey(to)}`;
let sym = host.resolveThunks.get(key);
if (sym) return sym;
sym = `sc_race_${host.resolveThunks.size}`;
sym = host.contentSymbol("sc_race_", key);
host.resolveThunks.set(key, sym);
host.journal?.allocated(sym, "raceAdapterFor", [from, to]);
const toArms = to.kind === "union" ? (host.unionsById.get(to.unionId)?.arms ?? []) : [];
@@ -217,7 +217,7 @@ export function genResultThunkFor(
const key = `gr:${typeKey(genT)}`;
let sym = host.resolveThunks.get(key);
if (sym) return sym;
sym = mangleGenResThunk(host.resolveThunks.size);
sym = host.contentSymbol("sc_gres_", key);
host.resolveThunks.set(key, sym);
host.journal?.allocated(sym, "genResultThunkFor", [genT, recT]);
const shape = host.recordsById.get(recT.shapeId);
@@ -474,7 +474,7 @@ export function childExitThunkFor(host: LlvmEmitterContext, param: IrType): stri
const key = `cx:${param.unionId}`;
let sym = host.resolveThunks.get(key);
if (sym) return sym;
sym = `sc_cx_${host.resolveThunks.size}`;
sym = host.contentSymbol("sc_cx_", key);
host.resolveThunks.set(key, sym);
host.journal?.allocated(sym, "childExitThunkFor", [param]);
const def = host.unionsById.get(param.unionId);
@@ -519,7 +519,7 @@ export function childExitSignalThunkFor(
const key = `cx2:${codeParam.unionId}+${sigParam.unionId}`;
let sym = host.resolveThunks.get(key);
if (sym) return sym;
sym = `sc_cx_${host.resolveThunks.size}`;
sym = host.contentSymbol("sc_cx_", key);
host.resolveThunks.set(key, sym);
host.journal?.allocated(sym, "childExitSignalThunkFor", [codeParam, sigParam]);
const codeDef = host.unionsById.get(codeParam.unionId);
@@ -580,7 +580,7 @@ export function execFileThunkFor(host: LlvmEmitterContext, cbT: IrType & { kind:
const key = `exec:${typeKey(cbT)}`;
let sym = host.resolveThunks.get(key);
if (sym) return sym;
sym = `sc_exec_${host.resolveThunks.size}`;
sym = host.contentSymbol("sc_exec_", key);
host.resolveThunks.set(key, sym);
host.journal?.allocated(sym, "execFileThunkFor", [cbT]);
const errorParam = cbT.params[0];
@@ -652,7 +652,7 @@ export function ipcMessageThunkFor(
const key = `ipcmsg:${typeKey(cbT)}`;
let sym = host.resolveThunks.get(key);
if (sym) return sym;
sym = `sc_ipc_msg_${host.resolveThunks.size}`;
sym = host.contentSymbol("sc_ipc_msg_", key);
host.resolveThunks.set(key, sym);
host.journal?.allocated(sym, "ipcMessageThunkFor", [cbT]);
const param = cbT.params[0];
@@ -703,7 +703,7 @@ export function ipcSendThunkFor(host: LlvmEmitterContext, cbT: IrType & { kind:
const key = `ipcsend:${typeKey(cbT)}`;
let sym = host.resolveThunks.get(key);
if (sym) return sym;
sym = `sc_ipc_send_${host.resolveThunks.size}`;
sym = host.contentSymbol("sc_ipc_send_", key);
host.resolveThunks.set(key, sym);
host.journal?.allocated(sym, "ipcSendThunkFor", [cbT]);
const param = cbT.params[0];
@@ -764,7 +764,7 @@ export function childDataThunkFor(host: LlvmEmitterContext, param: IrType): stri
const key = `cd:${param.unionId}`;
let sym = host.resolveThunks.get(key);
if (sym) return sym;
sym = `sc_cd_${host.resolveThunks.size}`;
sym = host.contentSymbol("sc_cd_", key);
host.resolveThunks.set(key, sym);
host.journal?.allocated(sym, "childDataThunkFor", [param]);
const def = host.unionsById.get(param.unionId);
@@ -799,7 +799,7 @@ export function childDataDynThunkFor(host: LlvmEmitterContext, chunk: "bytes" |
const key = `cdd:${chunk}`;
let sym = host.resolveThunks.get(key);
if (sym) return sym;
sym = `sc_cdd_${host.resolveThunks.size}`;
sym = host.contentSymbol("sc_cdd_", key);
host.resolveThunks.set(key, sym);
host.journal?.allocated(sym, "childDataDynThunkFor", [chunk]);
const box = chunk === "bytes" ? "scr_dyn_new_buffer" : "scr_dyn_new_str";
@@ -834,7 +834,7 @@ export function emitterFixedAdapter(
const key = `ee:${typeKey(cbT)}`;
let sym = host.resolveThunks.get(key);
if (sym) return { fn: sym, shim };
sym = `sc_ee_ad_${host.resolveThunks.size}`;
sym = host.contentSymbol("sc_ee_ad_", key);
host.resolveThunks.set(key, sym);
host.journal?.allocated(sym, "emitterFixedAdapter", [cbT]);
host.declare(`declare ptr @scr_box_get_ref(ptr)`);
@@ -905,7 +905,7 @@ export function emitterOnceAdapter(
: `eeOnce:${fire}:${typeKey(boxerT)}`;
let sym = host.resolveThunks.get(key);
if (sym) return { fn: sym, shim };
sym = `sc_ee_once_${host.resolveThunks.size}`;
sym = host.contentSymbol("sc_ee_once_", key);
host.resolveThunks.set(key, sym);
host.journal?.allocated(sym, "emitterOnceAdapter", [boxerT, fire]);
host.declare(`declare ptr @scr_box_get_ref(ptr)`);
@@ -974,10 +974,20 @@ export function wrapEmitterListener(
export function unwrapNullableClosure(
host: LlvmEmitterContext,
u: string,
unionId: string,
funcTag: number,
): string {
const B = host.B;
host.declare(`declare ptr @scr_closure_retain_v(ptr)`);
const nullable = host.nullableUnions.get(unionId);
if (nullable !== null) {
// The union is the closure pointer itself (NULL for the unit arm).
if (nullable.repr !== "single" || nullable.nullTag >= 0 || nullable.refTag !== funcTag)
throw new InternalCompilerError("llvm emitter bug: nullable callback union shape");
const r = B.tmp();
B.line(`${r} = call ptr ${retainSym(host.shapeHost, { kind: "union", unionId })}(ptr ${u})`);
return r;
}
const slot = B.slot();
B.entryAllocas.push(`${slot} = alloca ptr`);
B.line(`store ptr null, ptr ${slot}`);
@@ -1015,7 +1025,7 @@ export function closeBindThunkFor(
const key = `ncb:${cbUnion.unionId}:${retServer ? "srv" : "void"}`;
let sym = host.resolveThunks.get(key);
if (sym) return sym;
sym = `sc_ncb_${host.resolveThunks.size}`;
sym = host.contentSymbol("sc_ncb_", key);
host.resolveThunks.set(key, sym);
host.journal?.allocated(sym, "closeBindThunkFor", [cbUnion, retServer]);
const def = host.unionsById.get(cbUnion.unionId);
@@ -1026,9 +1036,9 @@ export function closeBindThunkFor(
"llvm emitter bug: bound-close callback union lacks its func arm",
);
const oneParam = funcArm.params.length === 1;
const cbNullable = host.nullableUnions.get(cbUnion.unionId);
host.declare(`declare ptr @scr_box_get_ref(ptr)`);
host.declare(`declare void @scr_closure_release(ptr)`);
host.declare(`declare void @scr_union_release(ptr)`);
host.declare(`declare ptr @scr_closure_retain_v(ptr)`);
host.declare(`declare void @scr_net_server_close_direct(ptr, ptr)`);
let trampoline: string | null = null;
@@ -1072,13 +1082,11 @@ export function closeBindThunkFor(
` %capsp = getelementptr inbounds %ScrClosure, ptr %self, i64 1`,
` %bx = load ptr, ptr %capsp`,
` %srv = call ptr @scr_box_get_ref(ptr %bx) ; +1`,
` %tagp = getelementptr inbounds %ScrUnion, ptr %cbu, i64 0, i32 1`,
` %tag = load i32, ptr %tagp`,
...unionTagLines(cbNullable, "%cbu", "%tag"),
` %isfn = icmp eq i32 %tag, ${funcTag}`,
` br i1 %isfn, label %fn, label %go`,
`fn:`,
` %pp = getelementptr inbounds %ScrUnion, ptr %cbu, i64 0, i32 5`,
` %pv = load ptr, ptr %pp`,
...unionPeekLines(cbNullable, "%cbu", "%pv"),
];
if (oneParam) {
d.push(
@@ -1097,7 +1105,7 @@ export function closeBindThunkFor(
` br label %go`,
`go:`,
` %regv = load ptr, ptr %regslot`,
` call void @scr_union_release(ptr %cbu) ; the callee owns its +1 param`,
` call void ${releaseSym(host.shapeHost, cbUnion)}(ptr %cbu) ; the callee owns its +1 param`,
` call void @scr_net_server_close_direct(ptr %srv, ptr %regv) ; reg moves`,
);
if (retServer) {
@@ -1121,7 +1129,7 @@ export function closeOverrideWrapFor(
const key = `ncw:${cbUnion.unionId}:${retServer ? "srv" : "void"}`;
let sym = host.resolveThunks.get(key);
if (sym) return sym;
sym = `sc_ncw_${host.resolveThunks.size}`;
sym = host.contentSymbol("sc_ncw_", key);
host.resolveThunks.set(key, sym);
host.journal?.allocated(sym, "closeOverrideWrapFor", [cbUnion, retServer]);
const def = host.unionsById.get(cbUnion.unionId);
@@ -1169,7 +1177,7 @@ export function settledStoreFor(
const key = `settled:${typeKey(from)}=>${typeKey(to)}`;
let sym = host.resolveThunks.get(key);
if (sym) return sym;
sym = `sc_settled_${host.resolveThunks.size}`;
sym = host.contentSymbol("sc_settled_", key);
host.resolveThunks.set(key, sym);
host.journal?.allocated(sym, "settledStoreFor", [from, to]);
const def = host.unionsById.get(to.unionId);
+102 -13
View File
@@ -42,7 +42,13 @@ import {
} from "./classes.js";
import { LlvmUnsupportedError } from "./unsupported.js";
import type { LlvmEmitterContext, ExprOf, LlValue } from "./expr-context.js";
import { f64Lit, ffiNativeTypeLl, ffiNativeParamLl, ffiNativeReturnLl } from "./common.js";
import {
CLOSURE_RECEIVER_FREE,
f64Lit,
ffiNativeTypeLl,
ffiNativeParamLl,
ffiNativeReturnLl,
} from "./common.js";
import { canStackUnion, emitStackUnion } from "./stack-unions.js";
import { emitStackRecordKeyGet, scalarRecordKeyGet } from "./expr-records.js";
import { emitCallArrayRead } from "./local-array-reads.js";
@@ -761,8 +767,7 @@ export function emitCallExpr(
`${c} = call ptr @scr_closure_new(ptr @${host.callTarget(e.fnName)}, ${host.sizeType} ${e.captures.length})`,
);
const out = host.own({ name: c, type: e.type });
const functionKind =
(target.generator ? 1 : 0) + (target.async ? 2 : 0) + (target.ownsPrototype ? 4 : 0);
const functionKind = host.closureFunctionKind(target);
if (functionKind) {
const kindPtr = B.tmp();
B.line(`${kindPtr} = getelementptr inbounds %ScrClosure, ptr ${c}, i64 0, i32 4`);
@@ -800,6 +805,25 @@ export function emitCallExpr(
: 0;
host.declare(`declare void @scr_closure_adopt_identity(ptr, ptr, i32)`);
B.line(`call void @scr_closure_adopt_identity(ptr ${c}, ptr ${original}, i32 ${length})`);
if (host.receiverReaders.forwardsOnly(target)) {
// The adapter forwards the ambient receiver to its original,
// so it ignores the receiver exactly when the original does.
const fromPtr = B.tmp(),
from = B.tmp(),
bit = B.tmp(),
toPtr = B.tmp(),
to = B.tmp(),
kind = B.tmp();
B.line(
`${fromPtr} = getelementptr inbounds %ScrClosure, ptr ${original}, i64 0, i32 4`,
);
B.line(`${from} = load i32, ptr ${fromPtr}`);
B.line(`${bit} = and i32 ${from}, ${CLOSURE_RECEIVER_FREE}`);
B.line(`${toPtr} = getelementptr inbounds %ScrClosure, ptr ${c}, i64 0, i32 4`);
B.line(`${to} = load i32, ptr ${toPtr}`);
B.line(`${kind} = or i32 ${to}, ${bit}`);
B.line(`store i32 ${kind}, ptr ${toPtr}`);
}
}
});
return out;
@@ -813,7 +837,18 @@ export function emitCallExpr(
const ft = e.callee.type;
if (e.args.length !== ft.params.length)
throw new LlvmUnsupportedError("callValue:arity", e.loc);
const callee = host.stackCallbacks.emit(host, e.callee) ?? emitBorrowedInput(host, e.callee);
// A receiver that reads the ambient `this` binds what the callee
// would see without a binding: the call forwards it unchanged and
// needs no receiver bracket (signature adapters forward this way).
const forwardsReceiver =
e.receiver?.kind === "libCall" &&
e.receiver.fn === "dyn.this" &&
e.receiver.args.length === 0;
const callee =
host.stackCallbacks.emit(host, e.callee) ??
(lendsCallee(host, e, forwardsReceiver)
? host.emitReadReceiver(e.callee)
: emitBorrowedInput(host, e.callee));
// A class instance bound as the callee's `this` (`h.fn(x)`) is boxed
// only if the callee reads it: the receiver window records the
// instance (owned by this frame for the whole call) with its typed-ref
@@ -825,7 +860,10 @@ export function emitCallExpr(
!host.shapeHost.isErrorClass(e.receiver.value.type.className)
? host.emitExpr(e.receiver.value)
: null;
const receiver = e.receiver === undefined || lazyReceiver ? null : host.emitExpr(e.receiver);
const receiver =
e.receiver === undefined || lazyReceiver || forwardsReceiver
? null
: host.emitExpr(e.receiver);
const pushReceiver = (): void => {
if (lazyReceiver) {
const t = lazyReceiver.type;
@@ -865,7 +903,7 @@ export function emitCallExpr(
? borrowableInputs(host, e.args, false, host.referenceWrites?.summary(known!.name))
: [];
let dynamic = false;
if (!direct && e.receiver === undefined) {
if (!direct && (e.receiver === undefined || forwardsReceiver)) {
const references = new Set<number>();
ft.params.forEach((param, index) => {
if (isRefCounted(param)) references.add(index);
@@ -898,16 +936,51 @@ export function emitCallExpr(
lent.has(i) ? { name: host.retainFrame(a.name, a.type), type: a.type } : a,
)
: args;
const invoke = (target: string, needsReceiver: boolean, values = args): string => {
// An entry through the closure's own pointer brackets the call only
// when the closure may observe the receiver (SCR_FN_RECEIVER_FREE).
const invoke = (
target: string,
bracket: boolean,
values = args,
indirect = false,
): string => {
const needsReceiver = bracket && !forwardsReceiver;
let observes: string | null = null;
if (needsReceiver) {
host.declare("declare void @scr_dyn_this_pop()");
pushReceiver();
if (indirect) {
const kindPtr = B.tmp(),
kind = B.tmp(),
free = B.tmp();
observes = B.tmp();
B.line(
`${kindPtr} = getelementptr inbounds %ScrClosure, ptr ${callee.name}, i64 0, i32 4`,
);
B.line(`${kind} = load i32, ptr ${kindPtr}`);
B.line(`${free} = and i32 ${kind}, ${CLOSURE_RECEIVER_FREE}`);
B.line(`${observes} = icmp eq i32 ${free}, 0`);
const push = B.newLabel("receiver.push"),
pushed = B.newLabel("receiver.call");
B.condBr(observes, push, pushed);
B.startBlock(push);
pushReceiver();
B.br(pushed);
B.startBlock(pushed);
} else pushReceiver();
}
const result = e.type.kind === "void" ? "" : B.tmp();
B.line(
`${result ? `${result} = ` : ""}call ${host.llType(e.type)} ${target}(${argListOf(values)})`,
);
if (needsReceiver) B.line("call void @scr_dyn_this_pop()");
if (observes !== null) {
const pop = B.newLabel("receiver.pop"),
popped = B.newLabel("receiver.done");
B.condBr(observes, pop, popped);
B.startBlock(pop);
B.line("call void @scr_dyn_this_pop()");
B.br(popped);
B.startBlock(popped);
} else if (needsReceiver) B.line("call void @scr_dyn_this_pop()");
return result;
};
const lending =
@@ -953,11 +1026,11 @@ export function emitCallExpr(
B.line(`${has} = icmp ne ptr ${entry}, null`);
B.condBr(has, borrowing, owning);
B.startBlock(borrowing);
const lentResult = invoke(entry, true);
const lentResult = invoke(entry, true, args, true);
const borrowingEnd = B.currentLabel();
B.br(done);
B.startBlock(owning);
const ownedResult = invoke(fn, true, ownedArgs());
const ownedResult = invoke(fn, true, ownedArgs(), true);
const owningEnd = B.currentLabel();
B.br(done);
B.startBlock(done);
@@ -966,7 +1039,7 @@ export function emitCallExpr(
B.line(
`${t} = phi ${host.llType(e.type)} [ ${lentResult}, %${borrowingEnd} ], [ ${ownedResult}, %${owningEnd} ]`,
);
} else if (!direct) t = invoke(fn, true, ownedArgs());
} else if (!direct) t = invoke(fn, true, ownedArgs(), true);
else {
// A global initializer may not have run, or a callback may enter
// through a boundary the census cannot prove. Validate the actual
@@ -984,7 +1057,7 @@ export function emitCallExpr(
const fastEnd = B.currentLabel();
B.br(done);
B.startBlock(fallback);
const indirect = invoke(fn, true, ownedArgs());
const indirect = invoke(fn, true, ownedArgs(), true);
const fallbackEnd = B.currentLabel();
B.br(done);
B.startBlock(done);
@@ -1358,6 +1431,22 @@ function ctorArguments(
* walk consumes without owning it (LlEmitter.canBorrowReceiver proved every
* reference argument borrowable and every argument edge-preserving). The
* result stays reachable from the borrowed arguments until the consumer. */
/** A closure value read from a location the call cannot write (a keyed
* table entry, a field, a checked extraction of either) is entered without
* a reference of its own: the location keeps the closure alive until the
* call returns. Plain stable bindings already lend (emitBorrowedInput). */
function lendsCallee(
host: LlvmEmitterContext,
e: ExprOf<"callValue">,
forwardsReceiver: boolean,
): boolean {
const writes = host.referenceWrites;
if (writes === null || host.canBorrowCallArgument(e.callee) || !host.canBorrowReceiver(e.callee))
return false;
const later = e.receiver === undefined || forwardsReceiver ? e.args : [e.receiver, ...e.args];
return borrowableInputs(host, [e.callee, ...later], false, writes.ownWrites(e))[0] === true;
}
export function emitBorrowedResultCall(host: LlvmEmitterContext, e: ExprOf<"call">): LlValue {
const callee = host.fnByName.get(e.callee);
if (!callee || !host.borrowedReturns.has(e.callee))
@@ -4,7 +4,6 @@ import { arraySearchHelperName, arraySearchKind } from "./array-search.js";
import { InternalCompilerError } from "../../errors.js";
import { isStableReceiverOperand, undefinedArmTag } from "../../ir/analysis.js";
import { F64, type IrExpr, type IrType, isRefCounted, typeEquals, typeKey } from "../../ir/ir.js";
import { mangleResolveThunk } from "../mangle.js";
import {
arrOps,
elemAccess,
@@ -76,7 +75,7 @@ export function resolveThunkFor(host: LlvmEmitterContext, inner: IrType): string
const key = typeKey(inner);
let sym = host.resolveThunks.get(key);
if (!sym) {
sym = mangleResolveThunk(host.resolveThunks.size);
sym = host.contentSymbol("sc_rslv_", key);
host.resolveThunks.set(key, sym);
host.journal?.allocated(sym, "resolveThunkFor", [inner]);
const v = vAdapters(host.shapeHost, inner);
@@ -21,6 +21,10 @@ export interface LlValue {
* its absolute value is below `below` (a zero may be -0). Index consumers
* use it in place of a round trip (integer-values.ts `indexInteger`). */
wideInteger?: WideInteger;
/** An i1 SSA value (or `true`) that is true exactly when this number is
* not NaN: set by NaN-coded integer element reads and the wrapping sums
* of them, so ToInt32 consumers need no floating compare. */
ordered?: string;
slot?: boolean;
/** A sequence local held in a capture box; release the box itself. */
boxed?: boolean;
@@ -499,10 +499,7 @@ export function emitOptionalChainReceiver(
// needs no reference of its own while the body cannot remove an edge
// (so nothing can overwrite the projection before the body finishes).
const borrowReceiver =
!multiple &&
isRefCounted(narrowed) &&
host.canBorrowReceiver(e.receiver) &&
host.referenceEffects.preserves(e.body);
!multiple && isRefCounted(narrowed) && borrowsChainReceiver(host, e.receiver, e.body);
// An optional array read of a nullable-pointer union is the element
// pointer itself, read inline and owned by the statement frame until
// the body finishes; the bind slot borrows it like an emitExpr value.
@@ -562,6 +559,16 @@ export function emitOptionalChainReceiver(
};
}
/** An optional chain's receiver can lend its payload to the bind slot when
* the body leaves the receiver's locations alone: it removes no edge at
* all, or it writes none of the locations the receiver reads. */
function borrowsChainReceiver(host: LlvmEmitterContext, receiver: IrExpr, body: IrExpr): boolean {
if (!host.canBorrowReceiver(receiver)) return false;
if (host.referenceEffects.preserves(body)) return true;
const writes = host.referenceWrites;
return writes !== null && host.unwrittenRead(receiver, writes.expr(body));
}
/** `a?.b ?? d` narrowed to the chain body's present arm. The body wraps a
* value of the result type in a non-unit arm, so the default runs exactly
* when the receiver is nullish and the intermediate result box is never
@@ -588,16 +595,21 @@ function emitNullishOptionalChain(host: LlvmEmitterContext, e: ExprOf<"nullish">
if (unitTags.length === 0 || present.length !== 1) return null;
const B = host.B;
const narrowed = receiverDef.arms[present[0]!]!;
const r = host.canStackReceiver(chain.receiver)
? host.emitReadReceiver(chain.receiver)
: host.emitExpr(chain.receiver);
// A receiver read from a location the body cannot write lends its
// payload to the bind slot for the whole body (`table[k]?.(…) ?? d`).
const borrowed =
isRefCounted(narrowed) && borrowsChainReceiver(host, chain.receiver, chain.body.value);
const r =
borrowed || host.canStackReceiver(chain.receiver)
? host.emitReadReceiver(chain.receiver)
: host.emitExpr(chain.receiver);
const bindTy = host.llType(narrowed);
const bind = B.slot();
B.entryAllocas.push(`${bind} = alloca ${bindTy}`);
B.line(
`store ${bindTy} ${bindTy === "ptr" ? "null" : bindTy === "double" ? f64Lit(0) : "false"}, ptr ${bind}`,
);
host.ownSlot(bind, narrowed);
if (!borrowed) host.ownSlot(bind, narrowed);
const isUnit = host.tagInSet(r.name, receiverDef.id, unitTags);
const ty = host.llType(e.type);
const slot = B.slot();
@@ -610,7 +622,10 @@ function emitNullishOptionalChain(host: LlvmEmitterContext, e: ExprOf<"nullish">
host.emitBranchInto(slot, e.right);
B.br(lj);
B.startBlock(lb);
B.line(`store ${bindTy} ${host.unionExtract(r.name, receiverDef.id, narrowed)}, ptr ${bind}`);
const payload = borrowed
? host.unionPeek(r.name, receiverDef.id)
: host.unionExtract(r.name, receiverDef.id, narrowed);
B.line(`store ${bindTy} ${payload}, ptr ${bind}`);
host.chainSlots.set(chain.id, { name: bind, type: narrowed, slot: true });
host.emitBranchInto(slot, chain.body.value);
host.chainSlots.delete(chain.id);
@@ -11,7 +11,7 @@ import { bindLiveArrayOps } from "./expr-stream-bridges.js";
/* Focused LLVM expression emission extracted from emitter.ts. */
import { InternalCompilerError } from "../../errors.js";
import { emitNullableIsTag, emitNullablePresent } from "./union-repr.js";
import { isObjectArm } from "./nullable-unions.js";
import { isPointerIdentityArm } from "./nullable-unions.js";
import { streamTypedRefEligible } from "../../ir/analysis.js";
import {
DYN,
@@ -908,13 +908,13 @@ export function emitDynamicExpr(
if (!def)
throw new InternalCompilerError(`llvm emitter bug: equality of unknown union ${e.unionId}`);
const nullable = host.nullableUnions.get(def.id);
if (nullable && isObjectArm(nullable)) {
if (nullable && isPointerIdentityArm(nullable)) {
// Object identity, and NULL === NULL for the unit arm: one compare.
const eq = B.tmp();
B.line(`${eq} = icmp ${e.negated ? "ne" : "eq"} ptr ${l.name}, ${r.name}`);
return { name: eq, type: e.type };
}
if (nullable) {
if (nullable && nullable.repr === "single" && nullable.arm.kind === "string") {
// String arm: identical pointers are equal (the same string or the
// same unit encoding); otherwise equal only when both hold strings
// with the same bytes.
@@ -278,6 +278,18 @@ test("sums that only feed a bitwise conversion of int32 operands add in 32 bits"
returning("float_read_add", bin("|", bin("+", length, read("floats")), zero)),
returning("product", bin("|", bin("*", length, length), zero)),
returning("number_add", bin("+", length, read("bytes"))),
returning(
"read_products",
bin(
">>>",
bin(
"+",
bin("*", read("bytes"), { kind: "numLit", value: 77, type: F64, loc }),
bin("*", read("bytes"), { kind: "numLit", value: 150, type: F64, loc }),
),
zero,
),
),
];
const llvm = emitLlvmModule(mod);
const body = (name: string): string =>
@@ -292,8 +304,10 @@ test("sums that only feed a bitwise conversion of int32 operands add in 32 bits"
expect(body(name), name).not.toContain("uint32.coerce");
}
// A NaN-coded read converts to 0 through the sum: the result selects 0
// unless every NaN-coded operand is ordered.
expect(body("read_add")).toMatch(/ = fcmp ord double .*, 0\.0$/m);
// unless every NaN-coded operand is ordered. A read's own validity bit
// tells, so the number is not compared.
expect(body("read_add")).toMatch(/ = phi i1 \[ true, /);
expect(body("read_add")).not.toMatch(/fcmp ord/);
expect(body("read_add")).toMatch(/ = add i32 /);
expect(body("read_add")).toMatch(/ = select i1 .*, i32 .*, i32 0$/m);
expect(body("read_chain")).toMatch(/ = and i1 /);
@@ -309,6 +323,12 @@ test("sums that only feed a bitwise conversion of int32 operands add in 32 bits"
expect(body(name), name).not.toMatch(/ = (add|mul) i32 /);
}
expect(body("number_add")).not.toMatch(/ = add i32 |fcmp ord/);
// Products of NaN-coded reads and constants are exact integers or NaN, so
// their sum adds in 32 bits too, selected by the reads' validity bits.
expect(body("read_products")).toMatch(/ = mul i32 /);
expect(body("read_products")).toMatch(/ = add i32 /);
expect(body("read_products")).not.toContain("uint32.coerce");
expect(body("read_products")).not.toMatch(/fcmp ord/);
});
test.each([32, 64] as const)(
@@ -47,7 +47,7 @@ import {
type ArrayAccess,
} from "./dense-array-access.js";
import { emitWrappingSum, integerOrNaNRange, markWrappingOperands } from "./wrapping-sums.js";
import { emitPowerOfTwoQuotient, isPowerOfTwoCall } from "./lib-filesystem.js";
import { emitPowF64, emitPowerOfTwoQuotient, isPowerOfTwoCall } from "./lib-filesystem.js";
/** The signed integer predicates of the number compares. */
const INTEGER_COMPARES: Record<string, string> = {
@@ -305,7 +305,6 @@ export function emitOperatorExpr(
"===": "oeq",
"!==": "une",
};
const libm: Record<string, string> = { "**": "pow" };
const bit: Record<string, "and" | "or" | "xor" | "shl" | "ashr" | "lshr"> = {
"&": "and",
"|": "or",
@@ -367,8 +366,8 @@ export function emitOperatorExpr(
`${result} = ${e.op === "+" ? "add" : e.op === "-" ? "sub" : "mul"} nsw i64 ${left}, ${right}`,
);
const number = integerNumber(host, result, range, e.type);
const uint32 = emitWrappingSum(host, e, l, r, li, ri);
return uint32 === undefined ? number : { ...number, uint32 };
const wrapped = emitWrappingSum(host, e, l, r, li, ri);
return wrapped === undefined ? number : { ...number, ...wrapped };
}
if (e.op === "%" && li.range.min >= 0 && ri.range.min > 0) {
const range = { min: 0, max: Math.min(li.range.max, ri.range.max - 1) };
@@ -411,12 +410,12 @@ export function emitOperatorExpr(
return { name: emitPowerOfTwoQuotient(host, l.name, r.name), type: e.type };
if (arith[e.op] !== undefined) {
B.line(`${t} = ${arith[e.op]} double ${l.name}, ${r.name}`);
const uint32 = emitWrappingSum(host, e, l, r, li, ri);
const wrapped = emitWrappingSum(host, e, l, r, li, ri);
// The exact integer beside the double, for index consumers.
const wideInteger = wideArithmetic(host, e.op, l, li, r, ri);
if (uint32 !== undefined || wideInteger) {
if (wrapped !== undefined || wideInteger) {
const value: LlValue = { name: t, type: e.type };
if (uint32 !== undefined) value.uint32 = uint32;
if (wrapped !== undefined) Object.assign(value, wrapped);
if (wideInteger) value.wideInteger = wideInteger;
return value;
}
@@ -471,32 +470,8 @@ export function emitOperatorExpr(
B.line(`${t} = ${e.op === ">>>" ? "uitofp" : "sitofp"} i32 ${result} to double`);
return { name: t, type: e.type, uint32: result };
} else {
const fn = libm[e.op];
if (fn === undefined) throw new LlvmUnsupportedError(`bin:${e.op}`, e.loc);
host.declare(`declare double @${fn}(double, double)`);
// libm's pow answers 1 for pow(±1, ±Infinity) and pow(1, NaN), where
// `**` answers NaN (and LLVM folds pow(1.0, x) to 1.0): an inline
// guard selects NaN for a base of ±1 with a non-finite exponent and
// keeps the direct pow call. A finite literal exponent, or a literal
// base other than ±1, cannot meet those cases and needs no guard.
if (fn === "pow" && !libmPowAgrees(e.left, e.right)) {
host.declare(`declare double @llvm.fabs.f64(double)`);
const power = B.tmp();
B.line(`${power} = call double @pow(double ${l.name}, double ${r.name})`);
const base = B.tmp();
const unit = B.tmp();
const magnitude = B.tmp();
const finite = B.tmp();
const reject = B.tmp();
B.line(`${base} = call double @llvm.fabs.f64(double ${l.name})`);
B.line(`${unit} = fcmp oeq double ${base}, 1.0`);
B.line(`${magnitude} = call double @llvm.fabs.f64(double ${r.name})`);
B.line(`${finite} = fcmp olt double ${magnitude}, 0x7FF0000000000000`);
B.line(`${reject} = select i1 ${finite}, i1 false, i1 ${unit}`);
B.line(`${t} = select i1 ${reject}, double 0x7FF8000000000000, double ${power}`);
} else {
B.line(`${t} = call double @${fn}(double ${l.name}, double ${r.name})`);
}
if (e.op !== "**") throw new LlvmUnsupportedError(`bin:${e.op}`, e.loc);
return { name: emitPowF64(host, l.name, r.name, e.right), type: e.type };
}
return { name: t, type: e.type };
}
@@ -649,7 +624,7 @@ export function regexLiteralTemplate(
let re = host.regexInstances.get(key);
if (!re) {
re = {
sym: `sc_re_${host.regexInstances.size}`,
sym: host.contentSymbol("sc_re_", key),
src: host.internLiteral(e.pattern),
fl: host.internLiteral(e.flags),
pattern: e.pattern,
@@ -673,7 +648,7 @@ export function regexLiteralReplacer(
let rr = host.regexReplacers.get(key);
if (!rr) {
rr = {
sym: `sc_rr_${host.regexReplacers.size}`,
sym: host.contentSymbol("sc_rr_", key),
pattern: e.pattern,
flags: e.flags,
replacement,
@@ -1473,19 +1448,3 @@ export function emitRecordLitFields(
host.initField(ptr, type, inline, v);
}
}
/** Whether libm pow matches `**` for these operands: every case except a
* base of ±1 with a NaN or infinite exponent agrees. */
function libmPowAgrees(base: IrExpr, exponent: IrExpr): boolean {
const power = numericLiteral(exponent);
if (power !== null && Number.isFinite(power)) return true;
const value = numericLiteral(base);
return value !== null && !Number.isNaN(value) && Math.abs(value) !== 1;
}
function numericLiteral(expr: IrExpr): number | null {
if (expr.kind === "numLit") return expr.value;
if (expr.kind === "unary" && expr.op === "-" && expr.operand.kind === "numLit")
return -expr.operand.value;
return null;
}
@@ -10,6 +10,7 @@ import { regexLiteralReplacer, regexLiteralTemplate } from "./expr-primitives.js
import { canReplaceWith, regexLiteralCompiles } from "./regex-matchers.js";
import { isScalarUnionDef, loadInlineUnion, presentBits } from "./inline-unions.js";
import { preservesRegexInputs } from "./reference-effects.js";
import { emitNumberMapSlot, emitStringMapEntry } from "./map-number-lookup.js";
/** Widen one tagged read. The source is borrowed unless the overflow map
* supplied an owned value. Each selected reference payload is retained
@@ -417,8 +418,8 @@ export function emitRecordKeyGet(
e: IrExpr & { kind: "recordKeyGet" },
): LlValue {
const B = host.B;
const obj = host.emitExpr(e.obj);
const k = host.emitExpr(e.key);
// The read runs no user code: both inputs lend for its duration.
const [obj, k] = emitBorrowedInputs(host, [e.obj, e.key]) as [LlValue, LlValue];
const ty = host.llType(e.type);
const slot = B.slot();
B.entryAllocas.push(`${slot} = alloca ${ty}`);
@@ -440,6 +441,80 @@ export function emitRecordKeyGet(
return host.own({ name: t, type: e.type });
}
/** A keyed read whose every hit is the result's own pointer: the result
* is a nullable-pointer union (nullable-unions.ts) with an undefined arm,
* and each declared field and the overflow value hold exactly the result
* type or its reference arm in a plain (non-inline) slot. A miss is the
* immortal undefined encoding. Such a read can lend the stored pointer:
* borrowedRecordKeyGet reads it without a reference. */
export function pointerRecordKeyGet(host: LlvmEmitterContext, e: IrExpr): boolean {
if (e.kind !== "recordKeyGet" || e.missNaN === true || e.type.kind !== "union") return false;
const resultType = e.type;
const nullable = host.nullableUnions.single(resultType.unionId);
if (nullable === null || undefinedArmTag(resultType, host.unionsById) < 0) return false;
const exact = (t: IrType): boolean => typeEquals(t, resultType) || typeEquals(t, nullable.arm);
const shape = host.recordShape(e.shapeId);
if (e.overflowOnly !== true) {
for (const f of shape.fields)
if (host.recordFieldInline(e.shapeId, f.name) || !exact(f.type)) return false;
}
return shape.indexValue === undefined || typeEquals(shape.indexValue, nullable.arm);
}
/** A pointerRecordKeyGet read without a reference: the declared field or
* overflow entry the key names, or the undefined encoding for a miss. The
* record (and through it the stored value) must stay unchanged and alive
* until the consumer is done with the pointer (canBorrowReceiver). */
export function emitBorrowedRecordKeyGet(
host: LlvmEmitterContext,
e: IrExpr & { kind: "recordKeyGet" },
): LlValue {
const B = host.B;
if (e.type.kind !== "union")
throw new InternalCompilerError("llvm emitter bug: borrowed keyed read of a non-union");
const resultType = e.type;
const obj = host.emitReadReceiver(e.obj);
const key = host.canBorrowReceiver(e.key) ? host.emitReadReceiver(e.key) : host.emitExpr(e.key);
const slot = B.slot();
B.entryAllocas.push(`${slot} = alloca ptr`);
const join = B.newLabel("rkb.j");
const shape = host.recordShape(e.shapeId);
for (const f of e.overflowOnly === true ? [] : shape.fields) {
const hit = B.tmp();
B.line(
`${hit} = call zeroext i1 ${host.literalEquals(f.name)}(ptr ${key.name}) ; ${llvmCommentText(f.name)}`,
);
const lh = B.newLabel("rkb.h");
const ln = B.newLabel("rkb.n");
B.condBr(hit, lh, ln);
B.startBlock(lh);
const { ptr, type } = host.recordFieldPtr(obj.name, e.shapeId, f.name);
B.line(`store ptr ${host.loadRecordField(ptr, type, false)}, ptr ${slot}`);
B.br(join);
B.startBlock(ln);
}
if (shape.indexValue) {
const ovf = host.recordOvfPtr(obj.name, e.shapeId);
const entry = emitStringMapEntry(host, ovf, key.name);
const found = B.tmp();
B.line(`${found} = icmp ne ptr ${entry}, null`);
const lh = B.newLabel("rkb.h");
const ln = B.newLabel("rkb.n");
B.condBr(found, lh, ln);
B.startBlock(lh);
B.line(`store ptr ${emitNumberMapSlot(host, entry, "ptr")}, ptr ${slot}`);
B.br(join);
B.startBlock(ln);
}
const undefTag = undefinedArmTag(resultType, host.unionsById);
B.line(`store ptr ${host.unitInstanceRef(resultType.unionId, undefTag)}, ptr ${slot}`);
B.br(join);
B.startBlock(join);
const t = B.tmp();
B.line(`${t} = load ptr, ptr ${slot}`);
return { name: t, type: e.type };
}
/** A keyed read whose result is a scalar union (`r[k]` on a
* `Record<string, number>`, at `number | undefined`) and whose every hit
* is a number or boolean arm of it, or an inline field of the same union.
@@ -735,27 +810,17 @@ export function keyedRecordReadInto(
B.br(join);
B.startBlock(ln);
} else {
host.declare(`declare ptr @scr_map_get_str_ref(ptr, ptr)`);
// The entry lookup filters absent keys inline; a hit retains the
// stored value with its own type's retain (map-number-lookup.ts). A
// nullable value's NULL is a stored unit arm: presence decides.
const lh = B.newLabel("rkg.h");
const ln = B.newLabel("rkg.n");
let raw: string;
if (iv.kind === "union" && host.nullableUnions.has(iv.unionId)) {
// NULL is a stored unit arm of a nullable value: presence decides.
host.declare(`declare zeroext i1 @scr_map_has_str(ptr, ptr)`);
const has = B.tmp();
B.line(`${has} = call zeroext i1 @scr_map_has_str(ptr ${ovf}, ptr ${keyName})`);
B.condBr(has, lh, ln);
B.startBlock(lh);
raw = B.tmp();
B.line(`${raw} = call ptr @scr_map_get_str_ref(ptr ${ovf}, ptr ${keyName})`);
} else {
raw = B.tmp();
const isnull = B.tmp();
B.line(`${raw} = call ptr @scr_map_get_str_ref(ptr ${ovf}, ptr ${keyName})`);
B.line(`${isnull} = icmp eq ptr ${raw}, null`);
B.condBr(isnull, ln, lh);
B.startBlock(lh);
}
const entry = emitStringMapEntry(host, ovf, keyName);
const found = B.tmp();
B.line(`${found} = icmp ne ptr ${entry}, null`);
B.condBr(found, lh, ln);
B.startBlock(lh);
const raw = host.retainHeap(emitNumberMapSlot(host, entry, "ptr"), iv);
B.line(`store ${ty} ${surface(iv, raw, true)}, ptr ${slot}`);
B.br(join);
B.startBlock(ln);
@@ -13,7 +13,7 @@ export function streamDataAdapter(
const key = `eed:${typeKey(cbT)}`;
let sym = host.resolveThunks.get(key);
if (sym) return sym;
sym = `sc_ee_dad_${host.resolveThunks.size}`;
sym = host.contentSymbol("sc_ee_dad_", key);
host.resolveThunks.set(key, sym);
host.journal?.allocated(sym, "streamDataAdapter", [cbT]);
const p = cbT.params[0];
@@ -221,7 +221,7 @@ export function streamDoneFnFor(
const key = `sd:${kind}:${typeKey(doneT)}`;
let sym = host.resolveThunks.get(key);
if (sym) return sym;
sym = `sc_sd_${host.resolveThunks.size}`;
sym = host.contentSymbol("sc_sd_", key);
host.resolveThunks.set(key, sym);
host.journal?.allocated(sym, "streamDoneFnFor", [kind, doneT]);
const errT = doneT.params[0];
@@ -365,7 +365,7 @@ export function fsRenameThunkFor(host: LlvmEmitterContext, cbT: IrType & { kind:
const key = `fsren:${typeKey(cbT)}`;
let sym = host.resolveThunks.get(key);
if (sym) return sym;
sym = `sc_fsren_${host.resolveThunks.size}`;
sym = host.contentSymbol("sc_fsren_", key);
host.resolveThunks.set(key, sym);
host.journal?.allocated(sym, "fsRenameThunkFor", [cbT]);
const param = cbT.params[0]!;
@@ -454,7 +454,7 @@ export function cryptoIntThunkFor(
const key = `cryptoint:${typeKey(cbT)}`;
let sym = host.resolveThunks.get(key);
if (sym) return sym;
sym = `sc_cryptoint_${host.resolveThunks.size}`;
sym = host.contentSymbol("sc_cryptoint_", key);
host.resolveThunks.set(key, sym);
host.journal?.allocated(sym, "cryptoIntThunkFor", [cbT]);
const d: string[] = [
@@ -516,7 +516,7 @@ export function zlibBytesThunkFor(
const key = `${family}bytes:${arrayBuffer ? "arraybuffer:" : ""}${mode}${typeKey(cbT)}`;
let sym = host.resolveThunks.get(key);
if (sym) return sym;
sym = `sc_${family}bytes_${host.resolveThunks.size}`;
sym = host.contentSymbol(`sc_${family}bytes_`, key);
host.resolveThunks.set(key, sym);
host.journal?.allocated(sym, "zlibBytesThunkFor", [cbT, arrayBuffer, family, valueMode]);
const d: string[] = [
@@ -644,7 +644,7 @@ export function streamCbThunkFor(
const key = `scb:${kind}:${typeKey(cbT)}`;
let sym = host.resolveThunks.get(key);
if (sym) return sym;
sym = `sc_scb_${host.resolveThunks.size}`;
sym = host.contentSymbol("sc_scb_", key);
host.resolveThunks.set(key, sym);
host.journal?.allocated(sym, "streamCbThunkFor", [kind, cbT]);
const runtimeParams =
@@ -0,0 +1,41 @@
/** Function layouts: the functions that runs of a program execute, in tiers
* (scripts/refresh-cli-layout.mjs writes the native compiler's). The emitter
* writes a listed function before the others, in a partition group of its
* tier, so the code that runs together sits together in the executable. */
/** Layout key → tier (1 first). */
export type FunctionLayout = ReadonlyMap<string, number>;
/** The layout key of an LLVM symbol: module numbers (`%m12.`, `%init.12`),
* which change when modules are added, are left out. */
export function functionLayoutKey(symbol: string): string {
return symbol.replace(/_x25_m\d+_/g, "_x25_m_").replace(/_x25_init_\d+$/, "_x25_init_");
}
/** Reads a layout's lines, each `<tier> <symbol>`. A function listed in two
* tiers keeps the first. */
export function parseFunctionLayout(text: string): FunctionLayout {
const layout = new Map<string, number>();
for (const line of text.split("\n")) {
const space = line.indexOf(" ");
if (space < 1) continue;
const tier = Number(line.slice(0, space));
const key = functionLayoutKey(line.slice(space + 1).trim());
if (Number.isInteger(tier) && tier > 0 && !layout.has(key)) layout.set(key, tier);
}
return layout;
}
/** The lowest tier of the functions `text` defines, or undefined when the
* layout lists none of them. */
export function layoutTierOf(layout: FunctionLayout, text: string): number | undefined {
let tier: number | undefined;
for (let at = text.indexOf("define "); at >= 0; at = text.indexOf("\ndefine ", at + 1)) {
const name = text.indexOf("@", at);
const open = text.indexOf("(", name);
if (name < 0 || open < 0) break;
const listed = layout.get(functionLayoutKey(text.slice(name + 1, open)));
if (listed !== undefined && (tier === undefined || listed < tier)) tier = listed;
}
return tier;
}
@@ -17,7 +17,7 @@ function connectAdapter(host: LlvmEmitterContext, cb: LlValue, h2: boolean): str
const key = `connect:${h2}:${typeKey(type)}`;
const previous = host.resolveThunks.get(key);
if (previous !== undefined) return previous;
const symbol = `sc_connect_${host.resolveThunks.size}`;
const symbol = host.contentSymbol("sc_connect_", key);
host.resolveThunks.set(key, symbol);
host.journal?.allocated(symbol, "connectAdapter", [cb, h2]);
const retain = h2 ? "scr_http_res_retain_v" : "scr_net_sock_retain_v";
@@ -19,7 +19,7 @@ function messageAdapter(host: LlvmEmitterContext, type: IrType): string {
const key = "dgram:" + type.shapeId;
const previous = host.resolveThunks.get(key);
if (previous !== undefined) return previous;
const symbol = `sc_dgram_message_${host.resolveThunks.size}`;
const symbol = host.contentSymbol("sc_dgram_message_", key);
host.resolveThunks.set(key, symbol);
host.journal?.allocated(symbol, "messageAdapter", [type]);
const shape = host.recordsById.get(type.shapeId);
@@ -68,7 +68,7 @@ function lookupAdapter(host: LlvmEmitterContext, cb: LlValue): string {
const key = `dns:${error.unionId}:${arity}`;
const previous = host.resolveThunks.get(key);
if (previous !== undefined) return previous;
const symbol = `sc_dns_lookup_${host.resolveThunks.size}`;
const symbol = host.contentSymbol("sc_dns_lookup_", key);
host.resolveThunks.set(key, symbol);
host.journal?.allocated(symbol, "lookupAdapter", [cb]);
host.declare("declare ptr @scr_error_new(i32, ptr)");
@@ -17,6 +17,7 @@ import type { LlvmEmitterContext, LibCallExpr, LibCallPrefix, LlValue } from "./
import {
ASSERT_VERDICT_LIB_FNS,
LIB_FN_SYMS,
PURE_MATH_LIB_FNS,
mockTimerSym,
USES_TIMERS_LIB_FNS,
} from "./lib-shared.js";
@@ -671,11 +672,13 @@ export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll
// which also takes those streams.
e.fn === "stream.unboxDyn" && host.usesDynBuiltinStream
? "scr_dyn_builtin_stream_unbox"
: // for await over a checked-dynamic value iterates a native
// stream behind it (scr_dyn_for_await_stream.c)
e.fn === "dyn.asyncIteratorOpen" && host.dynForAwaitStreams
? "scr_stream_dyn_async_iterator_open"
: LIB_FN_SYMS[e.fn]);
: e.fn === "stream.unboxDynStage" && host.usesDynBuiltinStream
? "scr_dyn_builtin_stream_unbox_stage"
: // for await over a checked-dynamic value iterates a native
// stream behind it (scr_dyn_for_await_stream.c)
e.fn === "dyn.asyncIteratorOpen" && host.dynForAwaitStreams
? "scr_stream_dyn_async_iterator_open"
: LIB_FN_SYMS[e.fn]);
if (sym === undefined) throw new LlvmUnsupportedError(`libCall:${e.fn}`, e.loc);
// The runtime's Date functions take and answer time values: a Date
// operand passes its time value, read at once from the borrowed object,
@@ -777,7 +780,8 @@ function emitSymbolLibCall(
return { name: "", type: e.type };
}
const t = B.tmp();
B.line(`${t} = call ${retDecl} @${sym}(${argList})`);
const attrs = PURE_MATH_LIB_FNS.has(e.fn) ? " nounwind willreturn memory(none)" : "";
B.line(`${t} = call ${retDecl} @${sym}(${argList})${attrs}`);
// The result joins its frame BEFORE the pending check so an unwind
// releases the dummy (NULL for refcounted returns) harmlessly.
const out = host.own({ name: t, type: e.type });
@@ -1196,7 +1200,7 @@ function allTupleAdapterFor(host: LlvmEmitterContext, fnName: string, tuple: IrT
const key = `allTuple:${fnName}`;
let adapter = host.resolveThunks.get(key);
if (adapter !== undefined) return adapter;
adapter = `sc_all_tuple_${host.resolveThunks.size}`;
adapter = host.contentSymbol("sc_all_tuple_", key);
host.resolveThunks.set(key, adapter);
host.journal?.allocated(adapter, "allTupleAdapterFor", [fnName, tuple]);
const rc = vAdapters(host.shapeHost, tuple);
@@ -1027,6 +1027,54 @@ function emitPowerOfTwo(host: LlvmEmitterContext, n: string): string {
return out;
}
/** `base ** exponent` and `Math.pow(base, exponent)` as V8's math::pow
* answers them: a literal exponent 2 is `base * base`, a literal 0.5 is
* `sqrt(base + 0)` (+Infinity for an infinite base), and every other
* exponent calls scr_math_pow, which applies V8's other special cases
* before the platform pow. The platform pow is never called by its libm
* name, which LLVM would rewrite (pow(2, x) to exp2(x), pow(x, -1) to 1/x)
* into answers that can differ from V8's. */
export function emitPowF64(
host: LlvmEmitterContext,
base: string,
exponent: string,
exponentExpr: IrExpr,
): string {
const B = host.B;
const literal =
exponentExpr.kind === "numLit"
? exponentExpr.value
: exponentExpr.kind === "unary" &&
exponentExpr.op === "-" &&
exponentExpr.operand.kind === "numLit"
? -exponentExpr.operand.value
: null;
const out = B.tmp();
if (literal === 2) {
B.line(`${out} = fmul double ${base}, ${base}`);
return out;
}
if (literal === 0.5) {
host.declare(`declare double @llvm.sqrt.f64(double)`);
host.declare(`declare double @llvm.fabs.f64(double)`);
const plus = B.tmp(),
root = B.tmp(),
magnitude = B.tmp(),
infinite = B.tmp();
B.line(`${plus} = fadd double ${base}, ${f64Lit(0)}`);
B.line(`${root} = call double @llvm.sqrt.f64(double ${plus})`);
B.line(`${magnitude} = call double @llvm.fabs.f64(double ${base})`);
B.line(`${infinite} = fcmp oeq double ${magnitude}, 0x7FF0000000000000`);
B.line(`${out} = select i1 ${infinite}, double 0x7FF0000000000000, double ${root}`);
return out;
}
host.declare(`declare double @scr_math_pow(double, double)`);
B.line(
`${out} = call double @scr_math_pow(double ${base}, double ${exponent}) nounwind willreturn`,
);
return out;
}
/** `Math.pow(2, n)`, the divisor emitPowerOfTwoQuotient takes. */
export function isPowerOfTwoCall(e: IrExpr): boolean {
return (
@@ -1099,6 +1147,11 @@ export function emitPrimitiveLibCall(host: LlvmEmitterContext, e: LibCallExpr):
const v = host.emitExpr(e.args[1]!);
return { name: emitPowerOfTwo(host, v.name), type: e.type };
}
if (e.fn === "math.pow") {
const base = host.emitExpr(e.args[0]!);
const exponent = host.emitExpr(e.args[1]!);
return { name: emitPowF64(host, base.name, exponent.name, e.args[1]!), type: e.type };
}
if (e.fn === "math.floor" || e.fn === "math.ceil") {
const intr = e.fn === "math.floor" ? "floor" : "ceil";
const v = host.emitExpr(e.args[0]!);
@@ -22,7 +22,7 @@ function sniAnswer(host: LlvmEmitterContext, type: IrType): string {
const key = "sni:" + typeKey(type);
const previous = host.resolveThunks.get(key);
if (previous !== undefined) return previous;
const symbol = `sc_sni_answer_${host.resolveThunks.size}`;
const symbol = host.contentSymbol("sc_sni_answer_", key);
host.resolveThunks.set(key, symbol);
host.journal?.allocated(symbol, "sniAnswer", [type]);
const params = ["ptr %self"],
@@ -95,11 +95,12 @@ export function sniCallbackArgs(host: LlvmEmitterContext, cb: LlValue): AbiArgum
let type = cb.type,
value = cb.name;
if (type.kind === "union") {
const arms = host.unionsById.get(type.unionId)?.arms ?? [];
const unionId = type.unionId;
const arms = host.unionsById.get(unionId)?.arms ?? [];
const tag = arms.findIndex((arm) => arm.kind === "func");
if (tag < 0) throw new InternalCompilerError("SNI callback union lacks its function arm");
type = arms[tag]!;
value = host.unwrapNullableClosure(value, tag);
value = host.unwrapNullableClosure(value, unionId, tag);
} else host.toHeap(cb);
if (type.kind !== "func" || type.params[1] === undefined)
throw new InternalCompilerError("SNI callback lacks its answer function");
@@ -37,7 +37,7 @@ function lookupAnswer(host: LlvmEmitterContext, answer: IrType & { kind: "func"
const key = "lookup:" + typeKey(answer);
let symbol = host.resolveThunks.get(key);
if (symbol === undefined) {
symbol = `sc_lookup_answer_${host.resolveThunks.size}`;
symbol = host.contentSymbol("sc_lookup_answer_", key);
host.resolveThunks.set(key, symbol);
host.journal?.allocated(symbol, "lookupAnswer", [answer]);
for (const declaration of [
@@ -165,7 +165,7 @@ export function emitNetworkHttpLibCall(host: LlvmEmitterContext, e: LibCallExpr)
const funcTag = def ? def.arms.findIndex((a) => a.kind === "func") : -1;
if (funcTag < 0)
throw new InternalCompilerError(`llvm emitter bug: ${e.fn} union lacks its func arm`);
cb = host.unwrapNullableClosure(args[cbIndex]!.name, funcTag);
cb = host.unwrapNullableClosure(args[cbIndex]!.name, cbT.unionId, funcTag);
}
}
const valueCount = reusePort ? 5 : 4;
@@ -497,7 +497,7 @@ function boundCallbackThunk(host: LlvmEmitterContext, cbT: IrType & { kind: "fun
const key = `alsbound:${typeKey(cbT)}`;
let sym = host.resolveThunks.get(key);
if (sym) return sym;
sym = `sc_alsbound_${host.resolveThunks.size}`;
sym = host.contentSymbol("sc_alsbound_", key);
host.resolveThunks.set(key, sym);
host.journal?.allocated(sym, "boundCallbackThunk", [cbT]);
host.declare("declare ptr @scr_als_bound_target(ptr)");
@@ -35,6 +35,31 @@ export const ASSERT_VERDICT_LIB_FNS: ReadonlyMap<string, number | null> = new Ma
["assert.partialDeepResult", null],
]);
/** The Math functions that call scr_ieee754.c. They read and write no
* memory, so calls with the same argument fold and loops may hoist them. */
export const PURE_MATH_LIB_FNS: ReadonlySet<string> = new Set([
"math.sin",
"math.sinh",
"math.cos",
"math.cosh",
"math.tan",
"math.tanh",
"math.asin",
"math.asinh",
"math.acos",
"math.acosh",
"math.atan",
"math.atanh",
"math.cbrt",
"math.exp",
"math.expm1",
"math.log",
"math.log1p",
"math.log2",
"math.log10",
"math.atan2",
]);
export const LIB_FN_SYMS: Record<string, string> = {
"assert.expectsErrDyn": "scr_assert_expects_err_dyn",
"assert.throwsValidate": "scr_assert_throws_validate",
@@ -118,27 +143,29 @@ export const LIB_FN_SYMS: Record<string, string> = {
"str.codePoints": "scr_str_code_points",
"num.fromString": "scr_string_to_number",
"math.round": "scr_math_round",
"math.sin": "sin",
"math.sinh": "sinh",
"math.cos": "cos",
"math.cosh": "cosh",
"math.tan": "tan",
"math.tanh": "tanh",
"math.asin": "asin",
"math.asinh": "asinh",
"math.acos": "acos",
"math.acosh": "acosh",
"math.atan": "atan",
"math.atanh": "atanh",
"math.cbrt": "cbrt",
"math.exp": "exp",
"math.expm1": "expm1",
// Math's transcendental functions are V8's (scr_ieee754.c), so their
// results are the ones Node prints.
"math.sin": "scr_ieee754_sin",
"math.sinh": "scr_ieee754_sinh",
"math.cos": "scr_ieee754_cos",
"math.cosh": "scr_ieee754_cosh",
"math.tan": "scr_ieee754_tan",
"math.tanh": "scr_ieee754_tanh",
"math.asin": "scr_ieee754_asin",
"math.asinh": "scr_ieee754_asinh",
"math.acos": "scr_ieee754_acos",
"math.acosh": "scr_ieee754_acosh",
"math.atan": "scr_ieee754_atan",
"math.atanh": "scr_ieee754_atanh",
"math.cbrt": "scr_ieee754_cbrt",
"math.exp": "scr_ieee754_exp",
"math.expm1": "scr_ieee754_expm1",
"math.sqrt": "sqrt",
"math.log": "log",
"math.log1p": "log1p",
"math.log2": "log2",
"math.log10": "log10",
"math.atan2": "atan2",
"math.log": "scr_ieee754_log",
"math.log1p": "scr_ieee754_log1p",
"math.log2": "scr_ieee754_log2",
"math.log10": "scr_ieee754_log10",
"math.atan2": "scr_ieee754_atan2",
"math.pow": "scr_math_pow",
// encodeURIComponent throws URIError on a lone surrogate
// (MAY_THROW_LIB_FNS); decodeUriComponent is with the throwing tier below.
@@ -286,6 +313,8 @@ export const LIB_FN_SYMS: Record<string, string> = {
"module.id": "scr_module_id",
"module.path": "scr_module_path",
"module.paths": "scr_module_paths",
"module.requireComputed": "scr_require_computed",
"module.resolveComputed": "scr_require_resolve",
"module.children": "scr_module_children",
"module.parent": "scr_module_parent",
"module.loaded": "scr_module_loaded",
@@ -394,6 +423,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"fs.rmRetrySync": "scr_fs_rm_opts_retry",
"fs.rmdirSync": "scr_fs_rmdir",
"fs.readdirSync": "scr_fs_readdir",
"fs.readdirRecursiveSync": "scr_fs_readdir_recursive",
"fs.globSync": "scr_fs_glob_strings",
"fs.callbackGlob": "scr_fs_glob_callback",
"fsp.globStrings": "scr_fsp_glob_strings",
@@ -818,6 +848,9 @@ export const LIB_FN_SYMS: Record<string, string> = {
"stream.stdioWritable": "scr_stream_stdio_writable",
"stream.unboxDyn": "scr_stream_unbox_dyn",
"stream.dynIterableSource": "scr_stream_dyn_iterable_source",
"stream.dynSourceValue": "scr_stream_dyn_source_value",
"stream.unboxDynStage": "scr_stream_unbox_dyn_stage",
"stream.dynTransformResult": "scr_stream_dyn_transform_result",
"stream.handleStream": "scr_stream_handle",
"stream.pipeDyn": "scr_stream_pipe_dyn",
"stream.unpipeDyn": "scr_stream_unpipe_dyn",
@@ -1683,6 +1716,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"dyn.objEntries": "scr_dyn_obj_entries",
"dyn.structuredClone": "scr_structured_clone",
"dyn.declAdopt": "scr_dyn_decl_adopt",
"dyn.adoptShared": "scr_dyn_adopt_shared",
"dyn.cloneMissing": "scr_structured_clone_missing",
"dyn.cloneTransferFail": "scr_structured_clone_transfer_fail",
"dyn.cloneTransferFailWith": "scr_structured_clone_transfer_fail_with",
@@ -743,7 +743,7 @@ export function emitStreamLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlV
const helpers = packed(5 + n + k, helperCount);
let at = 5 + n + k + helperCount;
const take = (present: boolean): string => (present ? args[at++]!.name : "null");
const box = take(((flags >> 1) & 3) === 2);
const box = take(((flags >> 1) & 3) >= 2);
const signal = take((flags & 32) !== 0);
const abort = take((flags & 32) !== 0);
const head =
@@ -12,13 +12,18 @@ import type { CallLifetimes } from "./call-lifetimes.js";
import type { ReferenceEffects } from "./reference-effects.js";
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
import {
emitCompactMapSlot,
emitNumberMapGetRef,
emitNumberMapGetScalar,
emitNumberMapSlot,
emitStringMapEntry,
emitStringMapGetScalar,
emitStringMapGetRef,
compactMapValues,
noteNumberMapKey,
} from "./map-number-lookup.js";
import { InternalCompilerError } from "../../errors.js";
import { undefinedArmTag } from "../../ir/analysis.js";
import { mapKeyAccess, mapKeyParamType } from "./shapes.js";
import { rememberMapEntry } from "./map-entry-fusion.js";
@@ -490,3 +495,62 @@ export function emitStackMapRead(host: LlvmEmitterContext, read: LocalMapRead):
B.line(`store i32 ${selected}, ptr ${tag}`);
return { value: { name: box, type: read.type }, owner };
}
/** A string-keyed Map.get whose result is a nullable-pointer union over the
* map's own value type (`Map<string, F>` read as `F | undefined`): a hit is
* the stored pointer itself and a miss the immortal undefined encoding, so
* the read can lend the stored value (emitBorrowedMapGet). */
export function pointerMapGet(host: LlvmEmitterContext, e: IrExpr): boolean {
if (
e.kind !== "mapIntrinsic" ||
e.method !== "get" ||
e.args.length !== 1 ||
e.type.kind !== "union" ||
e.receiver.type.kind !== "map" ||
e.receiver.type.key.kind !== "string" ||
!borrowsMapReadInputs(e)
)
return false;
const nullable = host.nullableUnions.single(e.type.unionId);
return (
nullable !== null &&
typeEquals(nullable.arm, e.receiver.type.value) &&
undefinedArmTag(e.type, host.unionsById) >= 0
);
}
/** A pointerMapGet read without a reference: the map (and through it the
* stored value) must stay unchanged and alive until the consumer is done
* with the pointer (canBorrowReceiver). */
export function emitBorrowedMapGet(
host: LlvmEmitterContext,
e: IrExpr & { kind: "mapIntrinsic" },
): LlValue {
const B = host.B;
const mapType = e.receiver.type;
if (e.type.kind !== "union" || mapType.kind !== "map")
throw new InternalCompilerError("llvm emitter bug: borrowed Map.get shape");
const receiver = host.emitReadReceiver(e.receiver);
const keyExpr = e.args[0]!;
const key = host.canBorrowReceiver(keyExpr)
? host.emitReadReceiver(keyExpr)
: host.emitExpr(keyExpr);
const compact = compactMapValues(host, mapType.key, mapType.value);
const entry = emitStringMapEntry(host, receiver.name, key.name, compact);
const found = B.tmp();
B.line(`${found} = icmp ne ptr ${entry}, null`);
const hit = B.newLabel("mapb.hit"),
miss = B.newLabel("mapb.miss"),
join = B.newLabel("mapb.join");
B.condBr(found, hit, miss);
B.startBlock(hit);
const value = compact ? emitCompactMapSlot(host, entry) : emitNumberMapSlot(host, entry, "ptr");
B.br(join);
B.startBlock(miss);
B.br(join);
B.startBlock(join);
const absent = host.unitInstanceRef(e.type.unionId, undefinedArmTag(e.type, host.unionsById));
const result = B.tmp();
B.line(`${result} = phi ptr [ ${value}, %${hit} ], [ ${absent}, %${miss} ]`);
return { name: result, type: e.type };
}
@@ -7,7 +7,7 @@ import {
type IrType,
type IrUnionDef,
} from "../../ir/ir.js";
import { NullableUnions } from "./nullable-unions.js";
import { isObjectArm, isPointerIdentityArm, NullableUnions } from "./nullable-unions.js";
const loc = { file: "nullable-unions.ts", start: 0, end: 0 };
const a: IrType = { kind: "record", shapeId: "a" };
@@ -111,3 +111,22 @@ test("library, lifetime-region and debug builds keep record unions boxed", () =>
expect(new NullableUnions(module(unions, { lifetimeRegions: true })).get("ab")).toBeNull();
expect(new NullableUnions(module(unions)).shapes("ab")).not.toBeNull();
});
test("function arms are nullable pointers compared by identity root", () => {
const fn: IrType = { kind: "func", params: [F64], ret: F64 };
const unions = new NullableUnions(
module([
{ id: "fu", arms: [fn, UNDEFINED_T] },
{ id: "fnu", arms: [NULL_T, fn, UNDEFINED_T] },
{ id: "ff", arms: [fn, F64] },
]),
);
expect(unions.single("fu")).toMatchObject({ refTag: 0, unitTag: 1, nullTag: -1, arm: fn });
expect(unions.single("fnu")).toMatchObject({ refTag: 1, unitTag: 2, nullTag: 0 });
expect(unions.get("ff")).toBeNull();
const single = unions.single("fu")!;
// Always truthy, but `===` compares adapters by the function they adapt.
expect(isObjectArm(single)).toBe(true);
expect(isPointerIdentityArm(single)).toBe(false);
expect(unions.armTypes().get("fu")).toEqual(fn);
});
@@ -89,21 +89,30 @@ export const NULLABLE_ABSENT = "@sc_nullable_absent";
/** The reference arms whose instances are never NULL. Class and record
* entry points all tolerate NULL; strings, Stats snapshots, arrays, maps and
* sets retain through a NULL-skipping wrapper (shapes.ts). Object arms are
* always truthy and compare by identity; string arms are truthy when
* non-empty and compare bytes. */
* sets retain through a NULL-skipping wrapper (shapes.ts), as do closures
* (function arms). Object arms are always truthy and compare by identity
* (function arms by identity root); string arms are truthy when non-empty
* and compare bytes. */
export type NullableRefArm =
| { kind: "object"; className: string }
| { kind: "record"; shapeId: string }
| { kind: "string" }
| { kind: "stats" }
| (IrType & { kind: "array" | "map" | "set" });
| (IrType & { kind: "array" | "map" | "set" | "func" });
/** The arm is a JS object: always truthy, compared by identity. */
/** The arm is a JS object: always truthy. */
export function isObjectArm(nullable: PointerUnion): boolean {
return nullable.repr === "shapes" || nullable.arm.kind !== "string";
}
/** The arm compares by pointer identity. Function arms compare identity
* roots instead: a signature adapter is the function it adapts. */
export function isPointerIdentityArm(nullable: PointerUnion): boolean {
return (
nullable.repr === "shapes" || (nullable.arm.kind !== "string" && nullable.arm.kind !== "func")
);
}
/** The representation decision for every union of one module. It is a
* pure function of the module, so serialized-IR and in-memory emission
* agree. */
@@ -264,7 +273,8 @@ function nullableShape(
return { ...base, arm: { kind: "record", shapeId: arm.shapeId } };
if (arm.kind === "string") return { ...base, arm: { kind: "string" } };
if (arm.kind === "stats") return { ...base, arm: { kind: "stats" } };
if (arm.kind === "array" || arm.kind === "map" || arm.kind === "set") return { ...base, arm };
if (arm.kind === "array" || arm.kind === "map" || arm.kind === "set" || arm.kind === "func")
return { ...base, arm };
return null;
}
@@ -11,14 +11,24 @@ import type { LlValue } from "./expr-context.js";
import { guardedValue } from "./borrowed-receivers.js";
import { InternalCompilerError } from "../../errors.js";
import { emitBorrowedArrayRead, emitProjectedArrayRead } from "./local-array-reads.js";
import { emitStackMapRead, matchMapRead } from "./map-read-lifetimes.js";
import {
emitBorrowedMapGet,
emitStackMapRead,
matchMapRead,
pointerMapGet,
} from "./map-read-lifetimes.js";
import { emitBorrowedResultCall, emitCallExpr } from "./expr-calls.js";
import { emitCheckedNarrowReceiver } from "./checked-narrows.js";
import { borrowsCheckedNarrow, emitCheckedNarrowReceiver } from "./checked-narrows.js";
import { canStackUnion, emitStackUnion } from "./stack-unions.js";
import { emitDowncastGuard, emitUnionNarrowGuard } from "./narrow-guards.js";
import { emitScalarOptional, isScalarOptionalSource } from "./local-union-storage.js";
import { borrowableInputs } from "./borrowed-inputs.js";
import { emitStackRecordKeyGet, scalarRecordKeyGet } from "./expr-records.js";
import {
emitBorrowedRecordKeyGet,
emitStackRecordKeyGet,
pointerRecordKeyGet,
scalarRecordKeyGet,
} from "./expr-records.js";
import { type WalkBorrowHost, analyzeWalks } from "./walk-borrows.js";
/** Read a receiver whose value is consumed immediately by a field or tag
@@ -92,6 +102,10 @@ export function emitReadReceiver(emitter: LlEmitter, e: IrExpr): LlValue {
return emitBorrowedResultCall(emitter, e);
const arrayRead = emitter.optionalArrayReads.get(e);
if (arrayRead) return emitProjectedArrayRead(emitter, arrayRead);
if (e.kind === "recordKeyGet" && emitter.canBorrowReceiver(e))
return emitBorrowedRecordKeyGet(emitter, e);
if (e.kind === "mapIntrinsic" && emitter.canBorrowReceiver(e))
return emitBorrowedMapGet(emitter, e);
const narrow = emitter.checkedNarrows.get(e);
const narrowed = narrow ? emitCheckedNarrowReceiver(emitter, narrow) : null;
if (narrowed) return narrowed;
@@ -294,7 +308,27 @@ export function canBorrowReceiver(emitter: LlEmitter, e: IrExpr): boolean {
// snapshot, so neither the callee nor a later argument may replace
// what it was read from: a callee that preserves every edge cannot,
// and any other one must leave the argument's locations unwritten.
// A keyed read lends the stored pointer while the record keeps it.
case "recordKeyGet":
return (
pointerRecordKeyGet(emitter, e) &&
emitter.canBorrowReceiver(e.obj) &&
emitter.referenceEffects.preserves(e.key)
);
// So does a Map.get while the map keeps it.
case "mapIntrinsic":
return (
pointerMapGet(emitter, e) &&
emitter.canBorrowReceiver(e.receiver) &&
emitter.referenceEffects.preserves(e.args[0]!)
);
case "call": {
// A checked extraction lends the payload of the union it narrows
// (emitCheckedNarrowReceiver).
if (borrowsCheckedNarrow(emitter, e)) {
const narrow = emitter.checkedNarrows.get(e)!;
return emitter.canBorrowReceiver(narrow.value);
}
if (!emitter.borrowedReturns.has(e.callee)) return false;
if (!e.args.every((arg) => emitter.referenceEffects.preserves(arg))) return false;
const preserving = emitter.referenceEffects.functions.has(e.callee);
@@ -97,3 +97,46 @@ test("an array that is read back or escapes keeps its element writes", () => {
const input = fill("input", [store("input", element("input"))]);
expect(writes(input).conflicts(["a"], writes(input).summary("input"))).toBe(true);
});
test("a keyed record read depends on every field and keyed entry of records", () => {
const handler: IrType = { kind: "func", params: [F64], ret: F64 };
const table: IrType = { kind: "record", shapeId: "t" };
const key: IrExpr = { kind: "strLit", value: "k", type: { kind: "string" }, loc };
const read: IrExpr = {
kind: "recordKeyGet",
obj: ref("table", table),
shapeId: "t",
key,
type: { kind: "union", unionId: "hu" },
loc,
};
const keyedWrite: IrStmt = {
kind: "recordKeySet",
obj: ref("table", table),
shapeId: "t",
key,
value: ref("h", handler),
loc,
};
const fn = (name: string, body: IrStmt[]): IrFunction => ({
name,
loc,
returnType: F64,
params: [{ localId: "table", name: "table", type: table }],
locals: [
{ id: "table", name: "table", type: table, mutable: false },
{ id: "h", name: "h", type: handler, mutable: false },
],
body,
});
const records = [{ id: "t", fields: [{ name: "add", type: handler }], indexValue: handler }];
const quiet = fn("quiet", []);
const writer = fn("writer", [keyedWrite]);
const functions = new Map([quiet, writer].map((f) => [f.name, f]));
const rw = new ReferenceWrites(functions, [], () => false, new Set(), { records });
rw.enter(quiet);
const keys = rw.readKeys(read);
expect(keys).toEqual(expect.arrayContaining(["r:*", "r:add"]));
expect(rw.conflicts(keys!, rw.summary("quiet"))).toBe(false);
expect(rw.conflicts(keys!, rw.summary("writer"))).toBe(true);
});
@@ -92,6 +92,8 @@ const INERT_LIB_PREFIXES = [
];
const INERT_LIB_CALLS = new Set([
"dyn.compare",
// Reads the ambient receiver (a fresh reference to the bound value).
"dyn.this",
"error.new",
"error.nodeThrow",
"dyn.typedRefIs",
@@ -923,6 +925,17 @@ export class ReferenceWrites {
case "arrayGet":
out.push(ARRAY_KEY);
return this.expr(node.index) === null && walk(node.arr);
case "recordKeyGet":
// Any declared field or overflow entry the key may name.
out.push(ANY_RECORD_KEY);
for (const f of this.records.get(node.shapeId)?.fields ?? []) out.push(`r:${f.name}`);
return this.expr(node.key) === null && walk(node.obj);
case "mapIntrinsic":
// A typed Map.get: any entry of any map.
if (node.method !== "get" || node.args.length !== 1 || !borrowsMapReadInputs(node))
return false;
out.push(MAP_KEY);
return this.expr(node.args[0]!) === null && walk(node.receiver);
case "unionNarrow":
case "upcast":
case "downcast":
@@ -0,0 +1,80 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile, deserializeModule } from "../../index.js";
import { validateModule } from "../../ir/validate.js";
import { emitLlvmModule } from "./emitter.js";
async function llvmFor(source: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-shape-union-tags-"));
try {
const entry = join(dir, "main.ts"),
output = join(dir, "main.ir.json");
await writeFile(entry, source);
const result = await compile(entry, { outDir: dir, outPath: output, outputKind: "ir" });
if (!result.ok)
throw new Error(result.diagnostics.map((item) => `${item.code}: ${item.message}`).join("\n"));
const module = deserializeModule(await readFile(output, "utf8"));
expect(validateModule(module)).toEqual([]);
return emitLlvmModule(module);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
function tagHelpers(ir: string): string[] {
return [...ir.matchAll(/^define internal i32 @sc_stag\.[^\n]*\{[^]*?^}/gm)].map(
(match) => match[0],
);
}
test("a union whose arms span few shape numbers switches on the shape word", async () => {
const ir = await llvmFor(`
type Num = { kind: "num"; value: number };
type Neg = { kind: "neg"; operand: Node };
type Add = { kind: "add"; left: Node; right: Node };
type Node = Num | Neg | Add;
function evaluate(node: Node): number {
switch (node.kind) {
case "num": return node.value;
case "neg": return -evaluate(node.operand);
case "add": return evaluate(node.left) + evaluate(node.right);
}
}
const one: Node = { kind: "num", value: 1 };
console.log(evaluate({ kind: "add", left: one, right: { kind: "neg", operand: one } }));
`);
expect(ir).not.toMatch(/@sc_stagtab\./);
const helpers = tagHelpers(ir);
expect(helpers.length).toBeGreaterThan(0);
for (const helper of helpers) expect(helper).toMatch(/switch i32 %w/);
});
test("a union whose arms span many shape numbers reads the arm from its table", async () => {
const arms = Array.from({ length: 40 }, (_, i) => i);
const ir = await llvmFor(`
${arms.map((i) => `type K${i} = { kind: "k${i}"; v${i}: number };`).join("\n")}
type Any = ${arms.map((i) => `K${i}`).join(" | ")};
function value(item: Any): number {
switch (item.kind) {
${arms.map((i) => ` case "k${i}": return item.v${i};`).join("\n")}
}
}
const items: Any[] = [${arms.map((i) => `{ kind: "k${i}", v${i}: ${i} }`).join(", ")}];
let total = 0;
for (const item of items) total += value(item);
console.log(total);
`);
// One byte per shape number from the arms' lowest to highest; 255 names no arm.
const table =
/^(@sc_stagtab\.\S+) = private unnamed_addr constant \[(\d+) x i8\] \[([^\]]*)\]$/m.exec(ir);
expect(table, "arm table").not.toBeNull();
const entries = table![3]!.split(", ").map((entry) => Number(entry.replace("i8 ", "")));
expect(entries).toHaveLength(Number(table![2]));
expect(entries.filter((entry) => entry !== 255).sort((a, b) => a - b)).toEqual(arms);
const helper = tagHelpers(ir).find((text) => text.includes(table![1]!));
expect(helper, "tag helper").toBeDefined();
expect(helper).toContain(`getelementptr inbounds [${table![2]} x i8], ptr ${table![1]}`);
expect(helper).not.toMatch(/switch i32 %w/);
});
+60 -14
View File
@@ -313,19 +313,46 @@ function shapeUnionRc(host: ShapeHost, union: ShapeUnion): { retain: string; rel
};
}
/** The widest span of shape numbers a union's arms may cover for its tag
* helper to switch on the shape word (emitShapeUnionHelpers). */
const SHAPE_TAG_SWITCH_SPAN = 32;
/** The tag, release and trace helpers of every shape union
* (nullable-unions.ts). The tag helper maps a value to its arm: NULL and
* the null sentinel are the unit arms, anything else a tagged record whose
* shape word names its arm (the switch folds to a lookup table). */
* shape word names its arm.
* - Arms within SHAPE_TAG_SWITCH_SPAN shape numbers: the helper switches on
* the shape word. Inlined, the switch folds into the caller's branch on
* the arm, one indirect jump with a jump table of at most that many
* entries per site.
* - Wider unions: the shape word indexes the union's arm table, one entry
* per shape number from the arms' lowest to highest, and callers branch
* on the small arm tag. Folded switches would give every site of such a
* union a jump table spanning all of its arms' shape numbers. */
export function emitShapeUnionHelpers(host: ShapeHost): string[] {
const out: string[] = [];
const S = host.sizeType;
for (const union of host.nullableUnions.allShapeUnions()) {
const header = shapeWordHeader(S);
const tagFn = shapeTagFn(union.unionId);
const cases = union.arms
.map((arm) => `i32 ${host.nullableUnions.shapeNumber(arm.shapeId)}, label %a${arm.tag}`)
.join(" ");
const numbers = union.arms.map((arm) => host.nullableUnions.shapeNumber(arm.shapeId));
const lo = Math.min(...numbers);
const span = Math.max(...numbers) - lo + 1;
const byTable = span > SHAPE_TAG_SWITCH_SPAN;
// Entries are bytes, or 16-bit words for a union of 255 arms or more.
// A shape number that names no arm maps to the all-ones entry, which is
// above every arm tag, so callers take their path for no arm as they do
// for the -1 of a shape number outside the table.
const wide = union.arms.some((arm) => arm.tag >= 255);
const entry = wide ? "i16" : "i8";
const none = wide ? 65535 : 255;
const table = new Array<number>(span).fill(none);
union.arms.forEach((arm, i) => (table[numbers[i]! - lo] = arm.tag));
const tableName = tagFn.replace("@sc_stag.", "@sc_stagtab.");
if (byTable)
out.push(
`${tableName} = private unnamed_addr constant [${span} x ${entry}] [${table.map((t) => `${entry} ${t}`).join(", ")}]`,
);
out.push(
`define internal i32 ${tagFn}(ptr %u) alwaysinline ${FN_ATTRS} { ; shape union tag`,
`entry:`,
@@ -343,16 +370,35 @@ export function emitShapeUnionHelpers(host: ShapeHost): string[] {
` ret i32 ${union.nullTag}`,
`rec:`,
);
out.push(
` %wp = getelementptr inbounds ${header}, ptr %u, i32 0, i32 ${SHAPE_WORD_INDEX}`,
` %w = load i32, ptr %wp`,
` switch i32 %w, label %bad [ ${cases} ]`,
...union.arms.flatMap((arm) => [`a${arm.tag}:`, ` ret i32 ${arm.tag}`]),
`bad:`,
` ret i32 -1`,
`}`,
``,
);
const cases = union.arms.map((arm, i) => `i32 ${numbers[i]}, label %a${arm.tag}`).join(" ");
if (!byTable)
out.push(
` %wp = getelementptr inbounds ${header}, ptr %u, i32 0, i32 ${SHAPE_WORD_INDEX}`,
` %w = load i32, ptr %wp`,
` switch i32 %w, label %bad [ ${cases} ]`,
...union.arms.flatMap((arm) => [`a${arm.tag}:`, ` ret i32 ${arm.tag}`]),
`bad:`,
` ret i32 -1`,
`}`,
``,
);
else
out.push(
` %wp = getelementptr inbounds ${header}, ptr %u, i32 0, i32 ${SHAPE_WORD_INDEX}`,
` %w = load i32, ptr %wp`,
` %i = sub i32 %w, ${lo}`,
` %inrange = icmp ult i32 %i, ${span}`,
` br i1 %inrange, label %look, label %bad`,
`look:`,
` %tp = getelementptr inbounds [${span} x ${entry}], ptr ${tableName}, i32 0, i32 %i`,
` %t8 = load ${entry}, ptr %tp`,
` %tag = zext ${entry} %t8 to i32`,
` ret i32 %tag`,
`bad:`,
` ret i32 -1`,
`}`,
``,
);
// Release and trace: one definition for the unions over the same
// record arms (helperOwner). Unit encodings are NULL or the immortal
// null sentinel; the shape word picks the arm.
@@ -32,10 +32,12 @@ export class StackCallbacks {
const count = B.tmp();
B.line(`${count} = getelementptr inbounds %ScrClosure, ptr ${closure}, i32 0, i32 2`);
B.line(`store ${host.sizeType} ${value.captures.length}, ptr ${count}`);
if (this.functions.get(value.fnName)?.ownsPrototype) {
const target = this.functions.get(value.fnName);
const functionKind = target ? host.closureFunctionKind(target) : 0;
if (functionKind !== 0) {
const kind = B.tmp();
B.line(`${kind} = getelementptr inbounds %ScrClosure, ptr ${closure}, i32 0, i32 4`);
B.line(`store i32 4, ptr ${kind}`);
B.line(`store i32 ${functionKind}, ptr ${kind}`);
}
value.captures.forEach((localId, index) => {
const box = host.retainBox(host.captureBox(localId));
@@ -283,3 +283,43 @@ test("calls that cannot unwind are marked nounwind", () => {
expect(out).toContain("%r = call ptr %b(ptr %b) nounwind");
expect(out).toContain(" call void @scr_unwind_raise()\n");
});
test("functions without a plain call of a raising function change only their call lines", () => {
const raising = [
"define internal void @sc_bf_raises() #0 {",
"entry:",
" call void @scr_unwind_raise()",
" unreachable",
"}",
].join("\n");
const other = [
"define internal void @sc_bf_raises.copy() #0 {",
"entry:",
" ret void",
"}",
].join("\n");
const plain = [
"define internal double @sc_f_plain(ptr %p) #0 {",
"entry: ; call sites below",
" %a = call double @sc_f_other(ptr %p)",
" %b = tail call double @sc_f_other(ptr %p) ; mentions @ and @sc_bf_raises.copy",
" store ptr @sc_bf_raises.copy, ptr %p",
" ret double %a",
"}",
].join("\n");
const parts = [`${raising}\n${other}`, plain, "@table = internal constant ptr @sc_f_plain"];
const out = bridgePlainUnwindCalls(parts);
expect(out[0]).toBe(parts[0]);
expect(out[1]).toBe(
plain
.replace("@sc_f_other(ptr %p)\n", "@sc_f_other(ptr %p) nounwind\n")
.replace("@sc_f_other(ptr %p) ;", "@sc_f_other(ptr %p) nounwind ;"),
);
expect(out[2]).toBe(parts[2]);
expect(() =>
bridgePlainUnwindCalls([
raising,
plain.replace("store ptr @sc_bf_raises.copy", "store ptr @sc_bf_raises"),
]),
).toThrow(UnwindAbiViolation);
});
@@ -301,6 +301,46 @@ function markNounwind(line: string): string {
: `${line.slice(0, close + 1)} nounwind${line.slice(close + 1)}`;
}
/** The functions of `part`: its text split before each `define` line
* (`part.split(/\n(?=define )/)`), found with indexOf. */
function splitFunctions(part: string): string[] {
const chunks: string[] = [];
let start = 0;
for (let at = part.indexOf("\ndefine "); at >= 0; at = part.indexOf("\ndefine ", at + 1)) {
chunks.push(part.slice(start, at));
start = at + 1;
}
chunks.push(part.slice(start));
return chunks;
}
/** Whether `code` can be part of a symbol name (`[\w.$%-]`). */
function isNameCode(code: number): boolean {
return (
(code >= 97 && code <= 122) ||
(code >= 65 && code <= 90) ||
(code >= 48 && code <= 57) ||
code === 95 /* _ */ ||
code === 46 /* . */ ||
code === 36 /* $ */ ||
code === 37 /* % */ ||
code === 45 /* - */
);
}
/** Calls `each` with every line of `chunk` that holds `call `, by its start
* and end offsets, once per line and in order. Lines without `call ` match
* neither CALL_LINE nor PLAIN_CALL. */
function eachCallLine(chunk: string, each: (start: number, end: number) => void): void {
for (let at = chunk.indexOf("call "); at >= 0;) {
const start = chunk.lastIndexOf("\n", at) + 1;
let end = chunk.indexOf("\n", at);
if (end < 0) end = chunk.length;
each(start, end);
at = chunk.indexOf("call ", end);
}
}
/** The value a bridged call yields on its landing pad: the dummy a
* pending-flag callee returns with an exception pending. */
function dummyValue(type: string): string {
@@ -318,7 +358,7 @@ function dummyValue(type: string): string {
* these functions that emitDirectCall lowered are invokes already. */
export function bridgePlainUnwindCalls(parts: string[]): string[] {
const raisers = new Set<string>();
const split = parts.map((part) => part.split(/\n(?=define )/));
const split = parts.map(splitFunctions);
for (const chunks of split)
for (const chunk of chunks) {
const m = DEFINE_NAME.exec(chunk);
@@ -359,20 +399,29 @@ export function bridgePlainUnwindCalls(parts: string[]): string[] {
}
}
for (const name of invoked) if (!raisers.has(name) && testsFlag.has(name)) violations.add(name);
// Every mention of a raising function outside its `define` line, other
// than as the callee of a call or invoke, found with indexOf.
for (const chunks of split)
for (const chunk of chunks)
for (const line of chunk.split("\n")) {
if (line.startsWith("define ")) continue;
for (const m of line.matchAll(/@([\w.$%-]+)/g)) {
if (!raisers.has(m[1]!)) continue;
const at = m.index!;
if (
/(?:call|invoke) [^@]*$/.test(line.slice(0, at)) &&
line.charAt(at + m[0].length) === "("
)
continue;
violations.add(m[1]!);
for (let at = chunk.indexOf("@"); at >= 0;) {
let end = at + 1;
while (end < chunk.length && isNameCode(chunk.charCodeAt(end))) end++;
const next = chunk.indexOf("@", end);
if (end === at + 1) {
at = next;
continue;
}
const name = chunk.slice(at + 1, end);
if (raisers.has(name)) {
const start = chunk.lastIndexOf("\n", at) + 1;
const line = chunk.slice(start, at);
if (
!line.startsWith("define ") &&
!(/(?:call|invoke) [^@]*$/.test(line) && chunk.charAt(end) === "(")
)
violations.add(name);
}
at = next;
}
if (violations.size > 0) throw new UnwindAbiViolation(violations);
let personality = false;
@@ -381,6 +430,34 @@ export function bridgePlainUnwindCalls(parts: string[]): string[] {
chunks
.map((chunk) => {
if (!DEFINE_NAME.test(chunk) || !chunk.includes("call ")) return chunk;
// A function without a plain call of a raising function only marks
// its calls nounwind: rewrite just its call lines.
let bridged = false;
const marks: { start: number; end: number; line: string }[] = [];
eachCallLine(chunk, (start, end) => {
if (bridged) return;
const line = chunk.slice(start, end);
const m = PLAIN_CALL.exec(line);
const callee = m === null ? undefined : (m[4] ?? m[7]);
if (m !== null && callee !== undefined && raisers.has(callee)) {
bridged = true;
return;
}
if (!CALL_LINE.test(line)) return;
const marked = markNounwind(line);
if (marked !== line) marks.push({ start, end, line: marked });
});
if (!bridged) {
if (marks.length === 0) return chunk;
const pieces: string[] = [];
let from = 0;
for (const mark of marks) {
pieces.push(chunk.slice(from, mark.start), mark.line);
from = mark.end;
}
pieces.push(chunk.slice(from));
return pieces.join("");
}
const lines = chunk.split("\n");
const resume = resumers.has(DEFINE_NAME.exec(chunk)![1]!);
const result: string[] = [];
@@ -22,8 +22,9 @@ export function markWrappingOperands(host: LlvmEmitterContext, ...operands: IrEx
/** The values an f64 expression can have when each one is an exact integer
* (never -0) or NaN: proven ranges, safe integer literals, NaN-coded reads
* of integer typed-array elements and of Int32[] elements, `charCodeAt`,
* and sums and differences of those whose every integer result is still
* exact in a double. */
* and sums, differences and products of those whose every integer result
* is a safe integer, which the double holds exactly. A product that can be
* -0 (a zero times a negative) has no range. */
export function integerOrNaNRange(host: LlvmEmitterContext, e: IrExpr): IntegerRange | null {
if (e.type.kind !== "f64") return null;
const range = host.integerRanges.get(e);
@@ -51,7 +52,7 @@ export function integerOrNaNRange(host: LlvmEmitterContext, e: IrExpr): IntegerR
// A UTF-16 code unit, or NaN past either end.
if (e.kind === "strIntrinsic" && e.method === "charCodeAt") return { min: 0, max: 65535 };
if (e.kind === "varRef") return host.integerOrNaNLocals.get(e.localId) ?? null;
if (e.kind === "bin" && (e.op === "+" || e.op === "-")) {
if (e.kind === "bin" && (e.op === "+" || e.op === "-" || e.op === "*")) {
const left = integerOrNaNRange(host, e.left);
const right = left && integerOrNaNRange(host, e.right);
return right ? integerArithmeticRange(e.op, left!, right) : null;
@@ -113,9 +114,14 @@ function wrappingOperand(
return { low, valid: null };
}
// The number is an exact integer or NaN, and its ToUint32 view is the
// integer's low 32 bits or 0. An ordered compare tells the two apart.
const valid = B.tmp();
B.line(`${valid} = fcmp ord double ${value.name}, 0.0`);
// integer's low 32 bits or 0. Its ordered bit, or an ordered compare,
// tells the two apart.
let valid: string | null = value.ordered ?? null;
if (valid === "true") valid = null;
else if (valid === null) {
valid = B.tmp();
B.line(`${valid} = fcmp ord double ${value.name}, 0.0`);
}
if (value.uint32 !== undefined) return { low: value.uint32, valid };
// Without a view, convert once. The conversion of NaN is poison, but the
// result only selects the sum when every operand is ordered.
@@ -137,8 +143,9 @@ function wrappingOperand(
* low halves is the converted exact result; a NaN operand makes the result
* NaN, which converts to 0. A product is exact when the operands' magnitude
* bounds multiply to at most 2^53; it may be -0 (`0 * -1`), which converts
* to 0 like the integer 0. Returns undefined, emitting nothing, for any
* other expression. */
* to 0 like the integer 0. Returns the converted result with its ordered
* bit (`true` when no operand can be NaN), or undefined, emitting nothing,
* for any other expression. */
export function emitWrappingSum(
host: LlvmEmitterContext,
e: IrExpr & { kind: "bin" },
@@ -146,7 +153,7 @@ export function emitWrappingSum(
r: LlValue,
li: LlInteger | null,
ri: LlInteger | null,
): string | undefined {
): { uint32: string; ordered: string } | undefined {
if ((e.op !== "+" && e.op !== "-" && e.op !== "*") || !host.wrappingSums.has(e)) return undefined;
const leftRange = li ? li.range : integerOrNaNRange(host, e.left);
const rightRange = leftRange && (ri ? ri.range : integerOrNaNRange(host, e.right));
@@ -165,14 +172,14 @@ export function emitWrappingSum(
`${sum} = ${e.op === "+" ? "add" : e.op === "-" ? "sub" : "mul"} i32 ${left.low}, ${right.low}`,
);
let valid = left.valid ?? right.valid;
if (valid === null) return sum;
if (valid === null) return { uint32: sum, ordered: "true" };
if (left.valid && right.valid) {
valid = B.tmp();
B.line(`${valid} = and i1 ${left.valid}, ${right.valid}`);
}
const out = B.tmp();
B.line(`${out} = select i1 ${valid}, i32 ${sum}, i32 0`);
return out;
return { uint32: out, ordered: valid };
}
/** Whether every product of a value in `left` and one in `right` is an
-9
View File
@@ -221,15 +221,6 @@ export function mangleGenDrop(fnName: string): string {
export function mangleGenSourceClose(fnName: string): string {
return `sc_gc_${sanitize(fnName)}`;
}
/** Emitted ref-kind resolve thunk, interned per inner-type key. */
export function mangleResolveThunk(n: number): string {
return `sc_rslv_${n}`;
}
/** Emitted generator-resume result builder (the IteratorResult record),
* interned per generator-type key. */
export function mangleGenResThunk(n: number): string {
return `sc_gres_${n}`;
}
/** Emitted Promise.race fulfillment adapter (entry inner type → result
* inner type), interned per type pair. */
export function mangleRaceThunk(n: number): string {
@@ -1,4 +1,4 @@
import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, expect, test } from "vitest";
@@ -26,6 +26,7 @@ async function fakePackage(
emptyOutput?: boolean;
missingOutput?: boolean;
changePackageDuringEmit?: boolean;
profileReport?: string;
} = {},
) {
const root = await mkdtemp(join(tmpdir(), "scriptc-native-helper-test-"));
@@ -72,6 +73,7 @@ ${
? ":"
: 'for each in $outputs; do cp "$input" "$each"; done'
}
${options.profileReport === undefined ? "" : `printf '%s\\n' '${options.profileReport}'`}
`,
);
await chmod(bin, 0o755);
@@ -196,6 +198,41 @@ test("profiles reach the helper and key the native cache by their bytes", async
expect(calls[2]).toContain("--profile-use /profiles/a.profdata");
});
test("a cached artifact keeps the profile match of the build that produced it", async () => {
const pkg = await fakePackage({
profileReport:
'{"ok":true,"profile":{"annotated":120,"mismatched":7,"missing":40,"unknown_partitions":0}}',
});
const used = {
...request(pkg.root, pkg.packageJson, join(pkg.root, "used.o")),
profile: { use: { path: "/profiles/a.profdata", digest: "a".repeat(64) } },
};
const match = { annotated: 120, mismatched: 7, missing: 40, unknownPartitions: 0 };
expect((await emitNativeArtifact(used)).profileMatch).toEqual(match);
const again = await emitNativeArtifact({ ...used, outputPath: join(pkg.root, "again.o") });
expect(again.profileMatch).toEqual(match);
expect((await readFile(pkg.log, "utf8")).trim().split("\n")).toHaveLength(1);
// Without the cached match, the cached partition's counts are unknown.
const cache = join(pkg.root, "cache", "native-codegen-v1");
const [shard] = await readdir(cache);
for (const name of await readdir(join(cache, shard!)))
if (name.includes(".profile-match")) await rm(join(cache, shard!, name));
const unknown = await emitNativeArtifact({ ...used, outputPath: join(pkg.root, "unknown.o") });
expect(unknown.profileMatch).toEqual({
annotated: 0,
mismatched: 0,
missing: 0,
unknownPartitions: 1,
});
// Builds without a profile carry no match, from the helper or the cache.
const plain = request(pkg.root, pkg.packageJson, join(pkg.root, "plain.o"));
expect((await emitNativeArtifact(plain)).profileMatch).toBeUndefined();
expect(
(await emitNativeArtifact({ ...plain, outputPath: join(pkg.root, "plain-again.o") }))
.profileMatch,
).toBeUndefined();
});
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"));
@@ -2,7 +2,17 @@ import { execFile } from "node:child_process";
import { createHash, type Hash } from "node:crypto";
import { closeSync, constants, openSync, readSync } from "node:fs";
import { createRequire } from "node:module";
import { access, chmod, copyFile, mkdir, readFile, rename, rm, stat } from "node:fs/promises";
import {
access,
chmod,
copyFile,
mkdir,
readFile,
rename,
rm,
stat,
writeFile,
} from "node:fs/promises";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import {
@@ -436,7 +446,10 @@ export async function emitNativeArtifact(
dependencies: helper.dependencies,
outputPaths: outputs,
} satisfies NativeCodegenArtifact;
if (await installCachedPartitions(cached, outputs)) return artifact;
if (await installCachedPartitions(cached, outputs)) {
if (options.profile?.use === undefined) return artifact;
return { ...artifact, profileMatch: await cachedProfileMatch(cached[0]!, outputs.length) };
}
// Partitioned programs also reuse individual partition artifacts, so an
// edit recompiles only the partitions it affects. The directory names the
// exact helper build and target; the helper keys entries by content.
@@ -459,6 +472,7 @@ export async function emitNativeArtifact(
// The helper reads a private copy: the caller's .ll may be shared with a
// concurrent build of another entry that has the same output stem.
const input = privateSiblingPath(options.outputPath, "native-input");
const matchStage = privateSiblingPath(options.outputPath, "native-profile-match");
try {
if (isLlvmFile(options.llvm)) await copyFile(options.llvm.file, input);
else await writeLlvmSource(input, options.llvm, 0o600);
@@ -493,6 +507,8 @@ export async function emitNativeArtifact(
}
await chmod(stage, artifactMode());
}
const profileMatch =
options.profile?.use === undefined ? null : parseNativeProfileMatch(stdout);
// Cache publication is an optimization boundary. The helper has already
// produced a valid caller artifact, so a read-only/full cache must not
// discard it or turn an otherwise successful build into an exception.
@@ -500,23 +516,68 @@ export async function emitNativeArtifact(
root !== null &&
(await nativeArtifactDependenciesStillMatch(helper.dependencies).catch(() => false))
) {
// The match goes in first: a reader that finds every partition finds it too.
if (profileMatch !== null) {
await writeFile(
matchStage,
`${JSON.stringify({ profile: helperProfileMatch(profileMatch) })}\n`,
{
mode: 0o600,
},
)
.then(() => publishCachedFile(matchStage, cachedProfileMatchPath(cached[0]!)))
.catch(() => undefined);
}
for (let index = 0; index < stages.length; index++)
await publishCachedFile(stages[index]!, cached[index]!).catch(() => undefined);
}
for (let index = 0; index < stages.length; index++)
await rename(stages[index]!, outputs[index]!);
await pruneBuildCache(root);
const profileMatch =
options.profile?.use === undefined ? null : parseNativeProfileMatch(stdout);
return profileMatch === null ? artifact : { ...artifact, profileMatch };
} finally {
await Promise.all([
...stages.map((stage) => rm(stage, { force: true }).catch(() => undefined)),
rm(input, { force: true }).catch(() => undefined),
rm(matchStage, { force: true }).catch(() => undefined),
]);
}
}
/** The cache entry beside a cached artifact's first partition that holds the
* profile match of the build that produced it. */
function cachedProfileMatchPath(firstPartition: string): string {
return `${firstPartition}.profile-match`;
}
/** The match in the helper's own JSON form, as parseNativeProfileMatch reads it. */
function helperProfileMatch(match: NativeProfileMatch): Record<string, number> {
return {
annotated: match.annotated,
mismatched: match.mismatched,
missing: match.missing,
unknown_partitions: match.unknownPartitions,
};
}
/** The profile match of a cached artifact. Without a valid entry, every
* partition's counts are unknown. */
async function cachedProfileMatch(
firstPartition: string,
partitions: number,
): Promise<NativeProfileMatch> {
const path = cachedProfileMatchPath(firstPartition);
const text = (await validCachedFile(path)) ? await readFile(path, "utf8").catch(() => "") : "";
return (
parseNativeProfileMatch(text) ?? {
annotated: 0,
mismatched: 0,
missing: 0,
unknownPartitions: partitions,
}
);
}
/** Install a cached artifact only when every partition is present and valid. */
async function installCachedPartitions(
cached: readonly (string | null)[],
@@ -55,6 +55,7 @@ export interface NativeLinkFeatures {
* Absent (false) for programs without them. */
handleProps?: boolean;
declAdopt?: boolean;
typeOrigins?: boolean;
/** for await over checked-dynamic values: scr_dyn_for_await.c. Absent
* (false) for programs without it. */
dynForAwait?: boolean;
@@ -71,6 +72,9 @@ export interface NativeLinkFeatures {
/** Builtin functions read as values (Buffer.byteLength, Buffer.alloc,
* fs.realpathSync) link scr_builtin_values.c; absent (false) otherwise. */
builtinValues?: boolean;
/** createRequire's computed require and require.resolve link
* scr_require.c; absent (false) otherwise. */
computedRequire?: boolean;
events: boolean;
emitter: boolean;
symbol: boolean;
@@ -3,6 +3,7 @@ import { executableLinkInputs } from "./link-plan-core.js";
import {
parseNativeProfileMatch,
nativeProfileHelperArgs,
renderNativeProfileMatch,
nativeProfileKey,
nativeProfileRefusal,
profileGenerateLinkerArgs,
@@ -84,3 +85,49 @@ test("the helper's profile match is read from its output", () => {
expect(parseNativeProfileMatch('{"ok":true}\n')).toBeNull();
expect(parseNativeProfileMatch('{"ok":true,"profile":{"annotated":-1}}\n')).toBeNull();
});
test("the build reports the match, and warns when the profile is stale", () => {
expect(
renderNativeProfileMatch("app.profdata", {
annotated: 9170,
mismatched: 0,
missing: 28636,
unknownPartitions: 0,
}),
).toBe(
"scriptc: profile app.profdata: 9170 functions annotated, 0 mismatched, 28636 without a record\n",
);
// 10% of the described functions changed: at the threshold, no warning.
expect(
renderNativeProfileMatch("app.profdata", {
annotated: 90,
mismatched: 10,
missing: 5,
unknownPartitions: 1,
}),
).toBe(
"scriptc: profile app.profdata: 90 functions annotated, 10 mismatched, 5 without a record, 1 partition without counts\n",
);
expect(
renderNativeProfileMatch("app.profdata", {
annotated: 5208,
mismatched: 3739,
missing: 28859,
unknownPartitions: 2,
}),
).toBe(
"scriptc: profile app.profdata: 5208 functions annotated, 3739 mismatched, 28859 without a record, 2 partitions without counts\n" +
"scriptc: warning: 3739 of the 8947 functions app.profdata describes (41.8%) changed since it was collected and are optimized without their counts; collect the profile again\n",
);
// Nothing described: no share to warn about.
expect(
renderNativeProfileMatch("app.profdata", {
annotated: 0,
mismatched: 0,
missing: 12,
unknownPartitions: 0,
}),
).toBe(
"scriptc: profile app.profdata: 0 functions annotated, 0 mismatched, 12 without a record\n",
);
});
@@ -83,6 +83,32 @@ export function parseNativeProfileMatch(stdout: string): NativeProfileMatch | nu
return null;
}
/** The share of the functions a profile describes whose control flow changed
* since it was collected, above which `scriptc build` warns that the profile
* is stale. A freshly collected profile has none. */
export const STALE_PROFILE_SHARE = 0.1;
/** `scriptc build --profile-use`'s report on stderr: one line with the match,
* and a warning line when more than STALE_PROFILE_SHARE of the functions the
* profile describes changed since it was collected. */
export function renderNativeProfileMatch(profile: string, match: NativeProfileMatch): string {
const unknown =
match.unknownPartitions === 0
? ""
: `, ${match.unknownPartitions} partition${match.unknownPartitions === 1 ? "" : "s"} without counts`;
const lines = [
`scriptc: profile ${profile}: ${match.annotated} functions annotated, ${match.mismatched} mismatched, ${match.missing} without a record${unknown}`,
];
const described = match.annotated + match.mismatched;
if (described > 0 && match.mismatched / described > STALE_PROFILE_SHARE) {
const percent = ((match.mismatched / described) * 100).toFixed(1);
lines.push(
`scriptc: warning: ${match.mismatched} of the ${described} functions ${profile} describes (${percent}%) changed since it was collected and are optimized without their counts; collect the profile again`,
);
}
return `${lines.join("\n")}\n`;
}
/** Cache-key part; empty without a profile so other keys are unchanged. */
export function nativeProfileKey(profile: NativeProfileOptions | undefined): string {
if (profile?.generate !== undefined) return `profile-generate:${profile.generate}`;
@@ -24,6 +24,7 @@ import {
compileLibArchive,
executableSectionEliminationFlags,
executableNativeEnvironmentFingerprint,
implicitDependencyIncludeDirective,
implicitDependencyProbeIncludes,
parseLinkTraceFiles,
resolveBuildCacheRoot,
@@ -624,6 +625,46 @@ test("implicit dependency seeds include separately compiled vendor system header
expect(includes).toContain("<stdatomic.h>");
});
test.skipIf(clangExecutable === undefined)(
"the implicit toolchain probe guards target-specific compiler headers",
async () => {
// clang ships its x86 intrinsic headers (<emmintrin.h>, ...) for every
// target, so __has_include finds them on arm64, where including them is
// an #error, and the same holds for the ARM headers on x86. The probe
// must guard them by target. Only the compiler's own freestanding
// headers are visible here, so the result does not depend on the host's
// C library.
const includes = (await implicitDependencyProbeIncludes(runtimeSrcDir())).filter(
(include) =>
include !== "<intrin.h>" && /intrin\.h>$|^<cpuid\.h>$|^<arm_(neon|acle)\.h>$/.test(include),
);
expect(includes).toContain("<emmintrin.h>");
const resourceDir = execFileSync(clangExecutable!, ["-print-resource-dir"], {
encoding: "utf8",
}).trim();
const source = includes.map(implicitDependencyIncludeDirective).join("");
for (const target of ["aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu"]) {
const result = spawnSync(
clangExecutable!,
[
`--target=${target}`,
"-ffreestanding",
"-nostdinc",
"-isystem",
join(resourceDir, "include"),
"-fsyntax-only",
"-x",
"c",
"-",
],
{ input: source, encoding: "utf8" },
);
expect(result.stderr, target).toBe("");
expect(result.status, target).toBe(0);
}
},
);
test.skipIf(
process.platform === "win32" ||
clangExecutable === undefined ||
@@ -26,7 +26,10 @@ export {
export { CcCompileError, subprocessFailureDetail } from "./native/process.js";
export { compileLibArchive } from "./native/library.js";
export { localizeLibraryObjects } from "./native/object-merge.js";
export { implicitDependencyProbeIncludes } from "./native/compiler-fingerprint.js";
export {
implicitDependencyIncludeDirective,
implicitDependencyProbeIncludes,
} from "./native/compiler-fingerprint.js";
export { parseLinkTraceFiles, nativeLinkerDependencyPaths } from "./native/linker-fingerprint.js";
export { clearCcCaches, stageRuntimeObjects } from "./native/runtime-objects.js";
export {
+12 -4
View File
@@ -8,7 +8,12 @@ import {
validateNativeCodegenVersion,
type NativeCodegenOutputKind,
} from "./native-codegen-core.js";
import { nativeProfileHelperArgs, type NativeProfileOptions } from "./native-profile.js";
import {
nativeProfileHelperArgs,
parseNativeProfileMatch,
type NativeProfileMatch,
type NativeProfileOptions,
} from "./native-profile.js";
export function runNativeTool(
executable: string,
@@ -48,10 +53,10 @@ export function emitNativeObject(options: {
partitionCache?: string;
/** Profile-guided optimization: instrumentation or an indexed profile. */
profile?: NativeProfileOptions;
}): string[] {
}): { outputs: string[]; profileMatch: NativeProfileMatch | null } {
verifyNativeHelper(options);
const outputs = nativePartitionPaths(options.outputPath, options.partitions ?? 1);
runNativeTool(options.executable, [
const stdout = runNativeTool(options.executable, [
"emit",
"--input",
options.inputPath,
@@ -74,7 +79,10 @@ export function emitNativeObject(options: {
...nativeProfileHelperArgs(options.profile),
]);
for (const output of outputs) requireNativeArtifact(output);
return outputs;
return {
outputs,
profileMatch: options.profile?.use === undefined ? null : parseNativeProfileMatch(stdout),
};
}
/** Merges raw or indexed IR profiles into one indexed profile. */
@@ -195,15 +195,25 @@ export async function implicitDependencyProbeIncludes(rtDir: string): Promise<st
return [...includes].sort();
}
function implicitDependencyIncludeDirective(include: string): string {
const targetCondition =
include === "<cpuid.h>" || include === "<immintrin.h>"
? " && (defined(__i386__) || defined(__x86_64__) || defined(_M_IX86) || defined(_M_X64))"
: include === "<intrin.h>"
? " && defined(_WIN32)"
: include === "<arm64_neon.h>" || include === "<arm_acle.h>" || include === "<arm_neon.h>"
? " && (defined(__arm__) || defined(__aarch64__) || defined(_M_ARM) || defined(_M_ARM64))"
: "";
/** Compiler-provided x86 headers: clang ships them for every target, so
* __has_include finds them on arm64 too, where including them is an
* #error. Covers the SSE/AVX intrinsic families (<emmintrin.h>,
* <immintrin.h>, ...), <x86intrin.h>, <x86gprintrin.h> and <cpuid.h>. */
function x86OnlyHeader(include: string): boolean {
return include === "<cpuid.h>" || /^<(x86|x86gpr|[a-z]*mm)intrin\.h>$/.test(include);
}
/** One guarded line of the implicit-toolchain probe source: the header is
* included only when the compiler can find it and it belongs to the target
* being probed. */
export function implicitDependencyIncludeDirective(include: string): string {
const targetCondition = x86OnlyHeader(include)
? " && (defined(__i386__) || defined(__x86_64__) || defined(_M_IX86) || defined(_M_X64))"
: include === "<intrin.h>"
? " && defined(_WIN32)"
: include === "<arm64_neon.h>" || include === "<arm_acle.h>" || include === "<arm_neon.h>"
? " && (defined(__arm__) || defined(__aarch64__) || defined(_M_ARM) || defined(_M_ARM64))"
: "";
return `#if __has_include(${include})${targetCondition}\n#include ${include}\n#endif\n`;
}
@@ -126,6 +126,7 @@ export interface CcOptions {
/** Own properties JavaScript adds to children: compiles scr_dyn_handle_props.c. */
handleProps?: boolean;
declAdopt?: boolean;
typeOrigins?: boolean;
/** for await over checked-dynamic values: compiles scr_dyn_for_await.c. */
dynForAwait?: boolean;
/** events.once with options (events.onceSignal): compiles
@@ -138,6 +139,9 @@ export interface CcOptions {
/** Buffer.prototype methods called on checked-dynamic values:
* compiles scr_bytes_dyn.c. */
dynBuffer?: boolean;
/** createRequire's computed require and require.resolve: compiles
* scr_require.c. */
computedRequire?: boolean;
/** Builtin functions read as values: compiles scr_builtin_values.c. */
builtinValues?: boolean;
/** The program constructs EventTarget/Event/CustomEvent values or
@@ -191,6 +191,8 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
unit((u) => u.opts.dynBuffer === true && u.opts.dynInvoke, "scr_bytes_dyn.c"),
// Builtin functions read as values in JavaScript dependencies.
unit((u) => u.opts.builtinValues === true, "scr_builtin_values.c"),
// createRequire's computed require and require.resolve.
unit((u) => u.opts.computedRequire === true, "scr_require.c"),
unit((u) => u.opts.dc, "scr_dc.c"),
unit((u) => u.opts.dynTier, "scr_js_value.c", "scr_js_object.c"),
unit((u) => u.opts.eventTarget || u.nativeFetch, "scr_event_target.c"),
@@ -302,6 +304,7 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
unit((u) => u.opts.handleProps === true, "scr_dyn_handle_props.c"),
// The declaration-typed boundary's adoption.
unit((u) => u.opts.declAdopt === true, "scr_decl_adopt.c"),
unit((u) => u.opts.typeOrigins === true, "scr_type_origin.c"),
// for await over checked-dynamic values.
unit((u) => u.opts.dynForAwait === true, "scr_dyn_for_await.c"),
// for await over checked-dynamic values that may hold native streams.
@@ -1516,6 +1519,8 @@ export async function compileCInternal(
[],
programPublicSymbols,
`${stem}.program`,
undefined,
{ inProcessElf: true },
);
const publishable = entries.filter((entry) => entry.missed);
if (publishable.length > 0) {
@@ -517,6 +517,8 @@ export async function compileLibArchive(opts: LibArchiveOptions): Promise<void>
[],
programPublicSymbols!,
`${stem}.program`,
undefined,
{ inProcessElf: true },
);
// Keep the canonical archive member spelling. The fact that native
// compilation used shards is an implementation detail; consumers and
@@ -5,6 +5,17 @@ import { localizeElfObject, mergeAndLocalizeCoffObjects } from "../object-locali
import { type CcDriver, targetPlatform } from "./driver.js";
import { execFileAsync, subprocessFailureDetail } from "./process.js";
/* Demotes in process (localizeElfObject), rewriting the object in place. */
async function localizeInProcess(object: string, keepSymbols: readonly string[]): Promise<void> {
try {
await writeFile(object, localizeElfObject(await readFile(object), new Set(keepSymbols)));
} catch (err) {
throw new Error(
`ELF symbol localization failed while localizing the library archive's runtime symbols (abi.localize_runtime).\n\n${(err as Error).message}`,
);
}
}
/* Multi-instance library mode's localization step: combine the program
* object with exactly the runtime/vendor members it reaches into ONE
* relocatable object, then demote every scriptc external definition except
@@ -46,7 +57,11 @@ import { execFileAsync, subprocessFailureDetail } from "./process.js";
* multi-archive link from discarding one archive's copies),
* then binutils objcopy --keep-global-symbols localizes every
* other DEFINED global (objcopy leaves undefined symbols
* global by its own rule). The exact historical recipe.
* global by its own rule). The exact historical recipe. With
* `inProcessElf` (program-shard merges), localizeElfObject
* demotes instead of objcopy: a sanitized dev program merges
* into an object with hundreds of thousands of sections, which
* objcopy takes minutes over and localizeElfObject seconds.
* ELF, cross triples from any host — `zig cc -target <triple> -r`
* merges (zig is already the cross driver's hard requirement),
* then localizeElfObject demotes in process and resolves
@@ -68,6 +83,7 @@ export async function localizeLibraryObjects(
keepSymbols: readonly string[],
stem: string,
platform = targetPlatform(driver),
options: { inProcessElf?: boolean } = {},
): Promise<string> {
const combined = join(buildDir, `${stem}.localized.o`);
const staging = join(buildDir, `${stem}.localize-staging.a`);
@@ -79,7 +95,7 @@ export async function localizeLibraryObjects(
const stderr = subprocessFailureDetail(err);
throw new Error(
`${argv[0]} failed while localizing the library archive's runtime symbols (abi.localize_runtime).\n` +
`Runtime symbol localization needs the ${platform === "darwin" ? "host toolchain's ld" : driver.target === null ? "host toolchain's ld and objcopy" : "cross driver's relocatable link"} beside the C compiler.\n\n${stderr}`,
`Runtime symbol localization needs the ${platform === "darwin" || (driver.target === null && options.inProcessElf === true) ? "host toolchain's ld" : driver.target === null ? "host toolchain's ld and objcopy" : "cross driver's relocatable link"} beside the C compiler.\n\n${stderr}`,
);
}
};
@@ -122,7 +138,6 @@ export async function localizeLibraryObjects(
keepFile,
]);
} else if (platform === "linux" && driver.target === null) {
await writeFile(keepFile, keepSymbols.map((s) => `${s}\n`).join(""));
await run([
"ld",
"-r",
@@ -132,7 +147,12 @@ export async function localizeLibraryObjects(
"-o",
combined,
]);
await run(["objcopy", `--keep-global-symbols=${keepFile}`, combined]);
if (options.inProcessElf === true) {
await localizeInProcess(combined, keepSymbols);
} else {
await writeFile(keepFile, keepSymbols.map((s) => `${s}\n`).join(""));
await run(["objcopy", `--keep-global-symbols=${keepFile}`, combined]);
}
} else if (platform === "linux") {
// Cross ELF: the cross driver's own lld performs the relocatable merge
// (with the staging archive's member semantics); demotion and section-
@@ -152,13 +172,7 @@ export async function localizeLibraryObjects(
"-o",
combined,
]);
try {
await writeFile(combined, localizeElfObject(await readFile(combined), new Set(keepSymbols)));
} catch (err) {
throw new Error(
`ELF symbol localization failed while localizing the library archive's runtime symbols (abi.localize_runtime).\n\n${(err as Error).message}`,
);
}
await localizeInProcess(combined, keepSymbols);
} else {
throw new InternalCompilerError(
`runtime symbol localization (abi.localize_runtime) has no ${platform} arm; compileLibrary admits darwin, linux, and win32 builds only`,
@@ -52,13 +52,13 @@ export async function resolveProgramShardMergeIdentity(driver: CcDriver): Promis
}
if (platform === "linux") {
if (driver.target === null) {
const [ld, objcopy] = await Promise.all([resolvedTool("ld"), resolvedTool("objcopy")]);
return ld === null || objcopy === null
// ld -r merges; localizeElfObject demotes in process.
const ld = await resolvedTool("ld");
return ld === null
? null
: rememberFingerprintDependencies(
`program-shard-merge-linux-v1\0${ld.cacheIdentity}\0${objcopy.cacheIdentity}`,
[ld.canonicalPath, objcopy.canonicalPath],
);
: rememberFingerprintDependencies(`program-shard-merge-linux-v2\0${ld.cacheIdentity}`, [
ld.canonicalPath,
]);
}
const arch = driver.target.split("-", 1)[0];
if (arch !== "x86_64" && arch !== "aarch64") return null;
@@ -74,6 +74,8 @@ export const EXECUTABLE_RUNTIME_SOURCES = [
"scr_slots.c",
"scr_alloc.c",
"scr_cipher_info.c",
"scr_ieee754.c",
"scr_glibc_math.c",
] as const;
export function runtimeSrcDir(): string {
@@ -28,7 +28,10 @@ const SHF_EXECINSTR = 0x4n;
const SHF_GROUP = 0x200n;
const STB_GLOBAL = 1;
const STB_WEAK = 2;
const SHT_SYMTAB_SHNDX = 18;
const SHN_LORESERVE = 0xff00;
const SHN_COMMON = 0xfff2;
const SHN_XINDEX = 0xffff;
interface ElfSymbolSpec {
name: string;
@@ -53,7 +56,11 @@ function buildStrtab(names: string[]): { data: Uint8Array; offsets: Map<string,
/** Assemble an ET_REL ELF64LE: caller supplies content sections; the
* builder appends .symtab, .strtab, .shstrtab (their indices are
* sections.length, +1, +2). Symbols must list locals first. */
* sections.length, +1, +2). Symbols must list locals first. With
* SHN_LORESERVE or more sections the builder uses extended section
* numbering, appending .symtab_shndx (index sections.length + 3) for symbol
* section indices from SHN_LORESERVE up; reserved symbol indices are then
* limited to SHN_COMMON. */
function buildElf(
sections: ElfSectionSpec[],
symbols: ElfSymbolSpec[],
@@ -62,11 +69,19 @@ function buildElf(
const strtab = buildStrtab(symbols.map((s) => s.name).filter((n) => n !== ""));
const symData = new Uint8Array((symbols.length + 1) * 24);
const symView = new DataView(symData.buffer);
const shndxData = new Uint8Array((symbols.length + 1) * 4);
let usesShndx = false;
symbols.forEach((sym, i) => {
const at = (i + 1) * 24;
symView.setUint32(at, sym.name === "" ? 0 : strtab.offsets.get(sym.name)!, true);
symView.setUint8(at + 4, (sym.binding << 4) | 0);
symView.setUint16(at + 6, sym.shndx, true);
if (sym.shndx >= SHN_LORESERVE && sym.shndx !== SHN_COMMON) {
symView.setUint16(at + 6, SHN_XINDEX, true);
new DataView(shndxData.buffer).setUint32((i + 1) * 4, sym.shndx, true);
usesShndx = true;
} else {
symView.setUint16(at + 6, sym.shndx, true);
}
symView.setBigUint64(at + 8, sym.value ?? 0n, true);
});
const all: ElfSectionSpec[] = [
@@ -82,9 +97,22 @@ function buildElf(
},
{ name: ".strtab", type: SHT_STRTAB, data: strtab.data },
{ name: ".shstrtab", type: SHT_STRTAB },
...(usesShndx
? [
{
name: ".symtab_shndx",
type: SHT_SYMTAB_SHNDX,
data: shndxData,
link: sections.length + 1,
entsize: 4n,
},
]
: []),
];
const shstrndx = sections.length + 3;
const extended = all.length >= SHN_LORESERVE;
const shstr = buildStrtab(all.map((s) => s.name).filter((n) => n !== ""));
all[all.length - 1]!.data = shstr.data;
all[shstrndx]!.data = shstr.data;
let offset = 64;
const offsets = all.map((s) => {
const data = s.data ?? new Uint8Array(0);
@@ -106,8 +134,8 @@ function buildElf(
view.setBigUint64(40, BigInt(shoff), true);
view.setUint16(52, 64, true);
view.setUint16(58, 64, true);
view.setUint16(60, all.length, true);
view.setUint16(62, all.length - 1, true);
view.setUint16(60, extended ? 0 : all.length, true);
view.setUint16(62, shstrndx >= SHN_LORESERVE ? SHN_XINDEX : shstrndx, true);
all.forEach((s, i) => {
const at = shoff + i * 64;
const data = s.data ?? new Uint8Array(0);
@@ -116,8 +144,8 @@ function buildElf(
view.setUint32(at + 4, s.type, true);
view.setBigUint64(at + 8, s.flags ?? 0n, true);
view.setBigUint64(at + 24, BigInt(s.type === 0 ? 0 : offsets[i]!), true);
view.setBigUint64(at + 32, BigInt(data.length), true);
view.setUint32(at + 40, s.link ?? 0, true);
view.setBigUint64(at + 32, BigInt(i === 0 && extended ? all.length : data.length), true);
view.setUint32(at + 40, i === 0 && shstrndx >= SHN_LORESERVE ? shstrndx : (s.link ?? 0), true);
view.setUint32(at + 44, s.info ?? 0, true);
view.setBigUint64(at + 48, s.addralign ?? 1n, true);
view.setBigUint64(at + 56, s.entsize ?? 0n, true);
@@ -142,8 +170,10 @@ interface ReadElf {
function readElf(bytes: Uint8Array): ReadElf {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const shoff = Number(view.getBigUint64(40, true));
const shnum = view.getUint16(60, true);
const shstrndx = view.getUint16(62, true);
let shnum = view.getUint16(60, true);
let shstrndx = view.getUint16(62, true);
if (shnum === 0) shnum = Number(view.getBigUint64(shoff + 32, true));
if (shstrndx === SHN_XINDEX) shstrndx = view.getUint32(shoff + 40, true);
const raw = [] as {
nameOff: number;
type: number;
@@ -182,12 +212,16 @@ function readElf(bytes: Uint8Array): ReadElf {
const symtabIdx = raw.findIndex((s) => s.type === SHT_SYMTAB);
const symtab = raw[symtabIdx]!;
const strtab = raw[symtab.link]!;
const shndxTable = raw.find((s) => s.type === SHT_SYMTAB_SHNDX && s.link === symtabIdx);
const symbols = [] as ReadElf["symbols"];
for (let at = symtab.offset; at < symtab.offset + symtab.size; at += 24) {
let shndx = view.getUint16(at + 6, true);
if (shndx === SHN_XINDEX)
shndx = view.getUint32(shndxTable!.offset + ((at - symtab.offset) / 24) * 4, true);
symbols.push({
name: name(strtab, view.getUint32(at, true)),
binding: view.getUint8(at + 4) >> 4,
shndx: view.getUint16(at + 6, true),
shndx,
});
}
return { sections, symbols, symtabInfo: symtab.info };
@@ -241,6 +275,65 @@ describe("localizeElfObject", () => {
expect(info & 0xffffffffn).toBe(2n);
});
test("keeps extended section numbering when the object has SHN_LORESERVE or more sections", () => {
// A sanitized dev program merges into an object with more sections than
// e_shnum and st_shndx can index: section 0 carries the count and the
// section-name table's index, and .symtab_shndx the symbols' indices.
const count = SHN_LORESERVE + 400;
const texts: ElfSectionSpec[] = Array.from({ length: count }, (_, i) => ({
name: `.text.f${i}`,
type: SHT_PROGBITS,
flags: SHF_ALLOC | SHF_EXECINSTR,
data: new Uint8Array(4),
}));
const rela = new Uint8Array(24);
new DataView(rela.buffer).setBigUint64(8, (2n << 32n) | 2n, true); // sym 2 (high)
const object = buildElf(
[
...texts,
{
name: ".rela.text.last",
type: SHT_RELA,
data: rela,
link: count + 2,
info: count,
entsize: 24n,
},
],
[
{ name: "low", binding: STB_GLOBAL, shndx: 1 },
{ name: "high", binding: STB_GLOBAL, shndx: count },
{ name: "main", binding: STB_GLOBAL, shndx: count - 1 },
{ name: "libc_ref", binding: STB_GLOBAL, shndx: 0 },
{ name: "shared_common", binding: STB_GLOBAL, shndx: SHN_COMMON, value: 8n },
],
0,
);
const output = localizeElfObject(object, new Set(["main"]));
const header = new DataView(output.buffer, output.byteOffset, output.byteLength);
expect(header.getUint16(60, true)).toBe(0);
expect(header.getUint16(62, true)).toBe(SHN_XINDEX);
const localized = readElf(output);
expect(localized.sections.length).toBe(count + 6);
expect(localized.sections.filter((s) => s.type === SHT_SYMTAB_SHNDX)).toHaveLength(1);
const byName = new Map(localized.symbols.map((s) => [s.name, s]));
expect(byName.get("low")!.binding).toBe(0);
expect(byName.get("high")!.binding).toBe(0);
expect(localized.sections[byName.get("high")!.shndx]!.name).toBe(`.text.f${count - 1}`);
expect(byName.get("main")!.binding).toBe(STB_GLOBAL);
expect(localized.sections[byName.get("main")!.shndx]!.name).toBe(`.text.f${count - 2}`);
expect(byName.get("libc_ref")!.shndx).toBe(0);
expect(byName.get("shared_common")!.shndx).toBe(SHN_COMMON);
const relaOut = localized.sections.find((s) => s.name === ".rela.text.last")!;
expect(localized.sections[relaOut.info]!.name).toBe(`.text.f${count - 1}`);
const info = new DataView(relaOut.data.buffer, relaOut.data.byteOffset).getBigUint64(8, true);
expect(localized.symbols[Number(info >> 32n)]!.name).toBe("high");
// The output reads back as the same object.
expect(readElf(localizeElfObject(output, new Set(["main"]))).symbols).toEqual(
localized.symbols,
);
});
test("resolves section groups: SHT_GROUP dropped, SHF_GROUP cleared, members and indices intact", () => {
const group = new Uint8Array(8);
const groupView = new DataView(group.buffer);
@@ -84,9 +84,13 @@ interface ElfSymbol {
name: string;
info: number;
other: number;
/** Resolved section index (SHT_SYMTAB_SHNDX applied), or a reserved
* SHN_* value below/at SHN_LORESERVE semantics. */
/** Section index (SHT_SYMTAB_SHNDX applied), or the reserved SHN_* value
* when `reserved`. */
shndx: number;
/** st_shndx holds a reserved value (SHN_ABS, SHN_COMMON, ...), not a
* section index. An index from SHT_SYMTAB_SHNDX can itself be at or above
* SHN_LORESERVE. */
reserved: boolean;
}
/** Demote every defined global/weak symbol not named in `keep` to a local
@@ -143,8 +147,8 @@ export function localizeElfObject(object: Uint8Array, keep: ReadonlySet<string>)
if (strtab === undefined) fail("ELF symbol table names an invalid string table");
// SHT_SYMTAB_SHNDX (only present when section indices overflow st_shndx):
// resolve the real indices up front and drop the section — the rebuilt
// object's section count only shrinks, so inline indices always fit.
// resolve the real indices up front and drop the section. The rebuilt
// object writes a new one when its own section count still overflows.
const shndxSection = sections.find(
(s) => s.type === SHT_SYMTAB_SHNDX && sections[s.link] === symtab,
);
@@ -170,9 +174,11 @@ export function localizeElfObject(object: Uint8Array, keep: ReadonlySet<string>)
for (let i = 0; i < symCount; i++) {
const at = i * 24;
let shndx: number = symView.getUint16(at + 6, true);
let reserved = shndx >= SHN_LORESERVE;
if (shndx === SHN_XINDEX) {
if (shndxView === null) fail("ELF symbol uses SHN_XINDEX without a SHT_SYMTAB_SHNDX table");
shndx = shndxView.getUint32(i * 4, true);
reserved = false;
}
symbols.push({
nameOffset: symView.getUint32(at, true),
@@ -180,6 +186,7 @@ export function localizeElfObject(object: Uint8Array, keep: ReadonlySet<string>)
info: symView.getUint8(at + 4),
other: symView.getUint8(at + 5),
shndx,
reserved,
});
}
@@ -212,11 +219,11 @@ export function localizeElfObject(object: Uint8Array, keep: ReadonlySet<string>)
const symbolKept: boolean[] = [];
for (let i = 0; i < symCount; i++) {
const sym = symbols[i]!;
const regular = sym.shndx > SHN_UNDEF && sym.shndx < SHN_LORESERVE;
const regular = !sym.reserved && sym.shndx !== SHN_UNDEF;
symbolKept.push(!(regular && sectionKept[sym.shndx] !== true));
if (i === 0) continue;
const binding = sym.info >> 4;
const defined = sym.shndx !== SHN_UNDEF && sym.shndx !== SHN_COMMON;
const defined = sym.shndx !== SHN_UNDEF && !(sym.reserved && sym.shndx === SHN_COMMON);
if (binding !== STB_LOCAL && defined && !keep.has(sym.name)) {
sym.info = (STB_LOCAL << 4) | (sym.info & 0xf);
}
@@ -236,6 +243,16 @@ export function localizeElfObject(object: Uint8Array, keep: ReadonlySet<string>)
symbolMap[oldIndex] = newIndex;
});
// Extended section numbering: with SHN_LORESERVE or more sections, an
// index from SHN_LORESERVE up does not fit st_shndx, e_shnum or
// e_shstrndx. Such a symbol's st_shndx is SHN_XINDEX and its index goes in
// a SHT_SYMTAB_SHNDX section (one entry per symbol, appended after the
// kept sections); the counts go in section 0 (sh_size, sh_link).
let keptCount = 0;
for (const keptSection of sectionKept) if (keptSection) keptCount++;
const extended = keptCount >= SHN_LORESERVE;
const shndxOut = extended ? new Uint8Array(symbolOrder.length * 4) : null;
const shndxOutView = shndxOut === null ? null : new DataView(shndxOut.buffer);
const newSymtabData = new Uint8Array(symbolOrder.length * 24);
const newSymtabView = new DataView(newSymtabData.buffer);
symbolOrder.forEach((oldIndex, newIndex) => {
@@ -244,9 +261,14 @@ export function localizeElfObject(object: Uint8Array, keep: ReadonlySet<string>)
newSymtabView.setUint32(at, sym.nameOffset, true);
newSymtabView.setUint8(at + 4, sym.info);
newSymtabView.setUint8(at + 5, sym.other);
const shndx =
sym.shndx > SHN_UNDEF && sym.shndx < SHN_LORESERVE ? sectionMap[sym.shndx]! : sym.shndx;
newSymtabView.setUint16(at + 6, shndx, true);
const regular = !sym.reserved && sym.shndx !== SHN_UNDEF;
const shndx = regular ? sectionMap[sym.shndx]! : sym.shndx;
if (regular && shndx >= SHN_LORESERVE) {
newSymtabView.setUint16(at + 6, SHN_XINDEX, true);
shndxOutView!.setUint32(newIndex * 4, shndx, true);
} else {
newSymtabView.setUint16(at + 6, shndx, true);
}
newSymtabView.setBigUint64(at + 8, symView.getBigUint64(oldIndex * 24 + 8, true), true);
newSymtabView.setBigUint64(at + 16, symView.getBigUint64(oldIndex * 24 + 16, true), true);
});
@@ -278,6 +300,33 @@ export function localizeElfObject(object: Uint8Array, keep: ReadonlySet<string>)
// Rebuild. sh_link is a section index in every use this object can carry;
// sh_info is one only on relocation sections.
const kept = sections.filter((_, i) => sectionKept[i] === true);
const newSymtabIndex = sectionMap[symtabIndex]!;
if (shndxOut !== null) {
// The extended index table goes last, named in the section-name table.
const shstrtab = sections[shstrndx]!;
let nameOffset = shndxSection?.nameOffset;
if (nameOffset === undefined) {
const name = textEncoder.encode(".symtab_shndx\0");
const grown = new Uint8Array(shstrtab.data.length + name.length);
grown.set(shstrtab.data);
grown.set(name, shstrtab.data.length);
nameOffset = shstrtab.data.length;
shstrtab.data = grown;
}
kept.push({
nameOffset,
type: SHT_SYMTAB_SHNDX,
flags: 0n,
addr: 0n,
size: BigInt(shndxOut.length),
link: symtabIndex,
info: 0,
addralign: 4n,
entsize: 4n,
data: shndxOut,
});
}
const newShstrndx = sectionMap[shstrndx]!;
let offset = 64;
const offsets: number[] = [];
for (const section of kept) {
@@ -301,8 +350,8 @@ export function localizeElfObject(object: Uint8Array, keep: ReadonlySet<string>)
outView.setBigUint64(40, BigInt(shoffOut), true);
outView.setUint16(54, 0, true); // e_phentsize
outView.setUint16(56, 0, true); // e_phnum
outView.setUint16(60, kept.length, true);
outView.setUint16(62, sectionMap[shstrndx]!, true);
outView.setUint16(60, extended ? 0 : kept.length, true);
outView.setUint16(62, newShstrndx >= SHN_LORESERVE ? SHN_XINDEX : newShstrndx, true);
kept.forEach((section, i) => {
if (section.type !== SHT_NULL && section.type !== SHT_NOBITS) {
out.set(section.data, offsets[i]!);
@@ -313,12 +362,27 @@ export function localizeElfObject(object: Uint8Array, keep: ReadonlySet<string>)
outView.setBigUint64(at + 8, section.flags, true);
outView.setBigUint64(at + 16, section.addr, true);
outView.setBigUint64(at + 24, BigInt(offsets[i]!), true);
if (i === 0) {
// Section 0 carries the counts that overflow the ELF header.
outView.setBigUint64(at + 32, extended ? BigInt(kept.length) : 0n, true);
outView.setUint32(at + 40, newShstrndx >= SHN_LORESERVE ? newShstrndx : 0, true);
outView.setUint32(at + 44, 0, true);
return;
}
outView.setBigUint64(
at + 32,
section.type === SHT_NOBITS ? section.size : BigInt(section.data.length),
true,
);
outView.setUint32(at + 40, section.link === 0 ? 0 : (sectionMap[section.link] ?? 0), true);
outView.setUint32(
at + 40,
section.type === SHT_SYMTAB_SHNDX
? newSymtabIndex
: section.link === 0
? 0
: (sectionMap[section.link] ?? 0),
true,
);
outView.setUint32(
at + 44,
section.type === SHT_RELA || section.type === SHT_REL

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