Lay out the native compiler's functions by the code each kind of run executes (#2265)

This commit is contained in:
Chris Tate
2026-10-26 00:29:02 -05:00
committed by GitHub
17 changed files with 686 additions and 40 deletions
+20 -9
View File
@@ -101,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
@@ -139,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
@@ -336,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
@@ -347,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
+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, 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
Binary file not shown.
+23 -2
View File
@@ -82,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. */
@@ -67,6 +67,7 @@ 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
@@ -233,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.
@@ -948,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.
@@ -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";
@@ -499,6 +500,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. */
@@ -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;
}
+5
View File
@@ -59,6 +59,11 @@ export interface CompileBaseOptions {
* merge-profiles`). Code the profile does not describe, or describes
* from an older version of the program, is optimized as without one. */
profileUse?: string;
/** A function layout (scripts/refresh-cli-layout.mjs; gzip-compressed when
* the name ends in .gz): the listed functions are written first, by tier,
* so the code that runs together sits together in the executable. The
* native compiler's build uses it. Executables and LLVM output. */
functionLayout?: string;
/** Eligible packages compile automatically. Explicit names override automatic selection heuristics. */
npmStatic?: readonly string[] | "auto";
@@ -220,7 +220,7 @@ test("early executable cache misses on source and resolution changes", async ()
expect(await readEarlyExecutableCache(f.root, f.options)).toBeNull();
});
test("early executable keys isolate compile modes, paths, implementation, checker, and FFI manifests", async () => {
test("early executable keys isolate compile modes, paths, implementation, checker, function layouts, and FFI manifests", async () => {
const f = await fixture();
const tracker = new FrontendInputTracker();
tracker.run(() => trackedReadFile(f.source));
@@ -242,6 +242,7 @@ test("early executable keys isolate compile modes, paths, implementation, checke
{ ...f.options, cpuDispatch: true },
{ ...f.options, profile: "profile-generate:/tmp/%p.profraw" },
{ ...f.options, profile: "profile-use:digest" },
{ ...f.options, functionLayout: "layout-digest" },
{ ...f.options, checker: "tsc-ts-executable-identity" },
{ ...f.options, npmStatic: "auto" },
{ ...f.options, npmStatic: ["commander"] },
@@ -157,6 +157,8 @@ export interface EarlyExecutableCacheOptions {
/** Omitted without --profile-generate/--profile-use; otherwise the
* pattern, or the digest of the profile's bytes. */
profile?: string;
/** Omitted without a function layout; otherwise the digest of its bytes. */
functionLayout?: string;
/** Omitted for the default tsgo checker; with SCRIPTC_CHECKER=tsc-ts, the
* tsc-ts executable's file identity. */
checker?: string;
@@ -311,6 +313,7 @@ function cacheKey(options: EarlyExecutableCacheOptions): string {
...targetCpuKeyParts(options.targetCpu),
...cpuDispatchKeyParts(options.cpuDispatch),
...(options.profile !== undefined ? [options.profile] : []),
...(options.functionLayout !== undefined ? [`layout:${options.functionLayout}`] : []),
...(options.checker !== undefined ? [`checker:${options.checker}`] : []),
...(options.youngNursery ? ["young-nursery"] : []),
options.npmStatic === null
@@ -355,6 +358,7 @@ function routeKey(options: Omit<EarlyExecutableRouteOptions, "nativeEnvironment"
for (const part of targetCpuKeyParts(options.targetCpu)) hash.update(`${part}\0`);
for (const part of cpuDispatchKeyParts(options.cpuDispatch)) hash.update(`${part}\0`);
if (options.profile !== undefined) hash.update(`${options.profile}\0`);
if (options.functionLayout !== undefined) hash.update(`layout:${options.functionLayout}\0`);
if (options.checker !== undefined) hash.update(`checker:${options.checker}\0`);
if (options.youngNursery) hash.update("young-nursery\0");
hash
+38
View File
@@ -54,6 +54,7 @@ export type {
import { compilePackedLibrary } from "./backend/library-pack.js";
import { node24FetchCompatProfile } from "./compat/fetch-profile.js";
import { libFnSignatures } from "./ir/builtin-signatures.js";
import { type FunctionLayout, parseFunctionLayout } from "./backend/llvm/function-layout.js";
import { InternalCompilerError } from "./errors.js";
import { frontendProcessExecArgv } from "./frontend-process-args.js";
import { type ChildProcess, spawn } from "node:child_process";
@@ -63,6 +64,7 @@ import { copyFile, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:
import { tmpdir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { gunzipSync } from "node:zlib";
import {
clearCcCaches,
configuredTargetPlatform,
@@ -801,6 +803,37 @@ async function readNativeProfile(
return { use: { path, digest: createHash("sha256").update(bytes).digest("hex") } };
}
/** The digest of the request's function layout file, which keys the
* executable cache: the layout changes the code's order, not its frontend
* input. */
async function functionLayoutKey(opts: CompileRequestOptions): Promise<string | undefined> {
if (opts.functionLayout === undefined) return undefined;
const path = resolve(opts.functionLayout);
let bytes: Buffer;
try {
bytes = await readFile(path);
} catch (err) {
throw new Error(
`cannot read function layout ${path}: ${err instanceof Error ? err.message : String(err)}`,
);
}
return createHash("sha256").update(bytes).digest("hex");
}
/** The request's function layout. */
async function readFunctionLayout(
opts: CompileRequestOptions,
): Promise<{ functionLayout?: FunctionLayout }> {
if (opts.functionLayout === undefined) return {};
const path = resolve(opts.functionLayout);
const bytes = await readFile(path);
return {
functionLayout: parseFunctionLayout(
(path.endsWith(".gz") ? gunzipSync(bytes) : bytes).toString("utf8"),
),
};
}
function usesPrecompiledRuntimePack(opts: CompileRequestOptions, backend: "llvm"): boolean {
if (backend !== "llvm" || opts.sanitize === true) return false;
return nativeCodegenTarget() !== null;
@@ -874,6 +907,7 @@ async function prepareExecutableInput(
llvm = emitLlvmModuleSource(mod, {
targetTriple: process.env["SCRIPTC_TARGET"] ?? "",
...debugOptions,
...(await readFunctionLayout(opts)),
pointerBits: buildPlatform === "wasi" ? 32 : 64,
wasi: buildPlatform === "wasi",
runtimeAbiMarker: outputKind === "obj",
@@ -948,6 +982,7 @@ async function prepareExecutableInput(
llvmSource = emitLlvmModuleSource(mod, {
targetTriple: process.env["SCRIPTC_TARGET"] ?? "",
...debugOptions,
...(await readFunctionLayout(opts)),
pointerBits: buildPlatform === "wasi" ? 32 : 64,
wasi: buildPlatform === "wasi",
runtimeAbiMarker: useRuntimePack,
@@ -1571,9 +1606,11 @@ async function compileTracked(
platform: buildPlatform,
});
let profile: NativeProfileOptions | undefined;
let functionLayoutDigest: string | undefined;
try {
if (profileRefusal !== null) throw new Error(profileRefusal);
profile = await readNativeProfile(opts);
functionLayoutDigest = await functionLayoutKey(opts);
} catch (err) {
return {
ok: false,
@@ -1609,6 +1646,7 @@ async function compileTracked(
...targetCpuField(opts.targetCpu),
...(opts.cpuDispatch === true ? { cpuDispatch: true as const } : {}),
...(profile === undefined ? {} : { profile: nativeProfileKey(profile) }),
...(functionLayoutDigest === undefined ? {} : { functionLayout: functionLayoutDigest }),
...checkerField(),
...(youngNurseryRequested() ? { youngNursery: true as const } : {}),
npmStatic: opts.npmStatic ?? null,
@@ -0,0 +1,183 @@
import { execFileSync } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { gzipSync } from "node:zlib";
import { expect, test, vi } from "vitest";
import { compile } from "../src/index.js";
import {
functionLayoutKey,
layoutTierOf,
parseFunctionLayout,
} from "../src/backend/llvm/function-layout.js";
const source = `
function first(n: number): number { return n + 1; }
function second(n: number): number { return n * 2; }
function third(n: number): number { return n - 3; }
console.log(first(Number(process.argv.length)), second(4), third(5));
`;
async function emitLlvm(layout?: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-function-layout-"));
try {
const entry = join(dir, "main.ts");
const outPath = join(dir, "main.ll");
await writeFile(entry, source);
let functionLayout: string | undefined;
if (layout !== undefined) {
functionLayout = join(dir, "layout.gz");
await writeFile(functionLayout, gzipSync(layout));
}
const result = await compile(entry, {
outDir: dir,
outPath,
outputKind: "llvm",
...(functionLayout === undefined ? {} : { functionLayout }),
});
expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true);
return await readFile(outPath, "utf8");
} finally {
await rm(dir, { recursive: true, force: true });
}
}
/** Each defined function's name with the partition group line it follows. */
function definitions(llvm: string): { name: string; group: string }[] {
const out: { name: string; group: string }[] = [];
let group = "";
for (const line of llvm.split("\n")) {
if (line.startsWith("; scriptc-group")) group = line.slice("; scriptc-group".length).trim();
const name = /^define [^@]*@([\w.$]+)\(/.exec(line)?.[1];
if (name !== undefined) out.push({ name, group });
}
return out;
}
test("a layout writes the functions it lists first, by tier, in their tier's group", async () => {
const plain = definitions(await emitLlvm()).filter((d) => /_(first|second|third)$/.test(d.name));
expect(plain.map((d) => d.name.replace(/^sc_b?f_/, ""))).toEqual(["first", "second", "third"]);
expect(plain.every((d) => !d.group.startsWith("layout-"))).toBe(true);
const names = new Map(plain.map((d) => [d.name.replace(/^sc_b?f_/, ""), d.name]));
const laid = definitions(await emitLlvm(`2 ${names.get("first")}\n1 ${names.get("third")}\n`));
const index = (fn: string) => laid.findIndex((d) => d.name === names.get(fn));
expect(index("third")).toBeLessThan(index("first"));
expect(index("first")).toBeLessThan(index("second"));
expect(laid[index("third")]!.group).toBe("layout-1");
expect(laid[index("first")]!.group).toBe("layout-2");
expect(laid[index("second")]!.group).not.toMatch(/^layout-/);
// Listed functions precede every unlisted definition in the function section.
const firstUnlisted = laid.findIndex(
(d, i) => i > index("third") && !d.group.startsWith("layout-"),
);
expect(firstUnlisted).toBeGreaterThan(index("first"));
});
test("layout keys leave out module numbers", () => {
expect(functionLayoutKey("sc_bf__x25_m447_lowerCall")).toBe(
functionLayoutKey("sc_bf__x25_m12_lowerCall"),
);
expect(functionLayoutKey("sc_f__x25_init_253")).toBe(functionLayoutKey("sc_f__x25_init_7"));
expect(functionLayoutKey("sc_bf__x25_m3_Lexer_scan")).not.toBe(
functionLayoutKey("sc_bf__x25_m3_Lexer_peek"),
);
});
test("a function listed twice keeps its first tier, and malformed lines are skipped", () => {
const layout = parseFunctionLayout("1 sc_f_a\n2 sc_f_a\n2 sc_f_b\nnot a line\n0 sc_f_c\n\n");
expect([...layout]).toEqual([
["sc_f_a", 1],
["sc_f_b", 2],
]);
const text =
"define internal void @sc_f_b() {\n ret void\n}\n\ndefine void @sc_f_a() {\n ret void\n}";
expect(layoutTierOf(layout, text)).toBe(1);
expect(layoutTierOf(layout, "define void @sc_f_z() {\n ret void\n}")).toBeUndefined();
});
test("an executable built with a layout that lists helpers and functions runs unchanged", async () => {
const program = `
class Point { constructor(public x: number, public y: number) {} }
function norm(p: Point): number { return Math.sqrt(p.x * p.x + p.y * p.y); }
const points = [new Point(3, 4), new Point(6, 8)];
console.log(points.map(norm).join(","), points[0]!.x === 3 ? "three" : "other");
`;
const dir = await mkdtemp(join(tmpdir(), "scriptc-function-layout-exe-"));
try {
const entry = join(dir, "main.ts");
await writeFile(entry, program);
const llvmPath = join(dir, "main.ll");
const emitted = await compile(entry, { outDir: dir, outPath: llvmPath, outputKind: "llvm" });
expect(emitted.ok, JSON.stringify(emitted.diagnostics)).toBe(true);
// The generated helpers written before the functions, and one function,
// in two tiers.
const names = definitions(await readFile(llvmPath, "utf8")).map((d) => d.name);
const helpers = names.slice(
0,
names.findIndex((name) => /^sc_b?f_/.test(name)),
);
expect(helpers.length).toBeGreaterThan(0);
const norm = names.find((name) => /^sc_b?f_norm$/.test(name))!;
const layout = join(dir, "layout");
await writeFile(layout, [`1 ${norm}`, ...helpers.map((name) => `2 ${name}`)].join("\n"));
const laid = join(dir, "laid.ll");
const laidOut = await compile(entry, {
outDir: dir,
outPath: laid,
outputKind: "llvm",
functionLayout: layout,
});
expect(laidOut.ok, JSON.stringify(laidOut.diagnostics)).toBe(true);
const order = definitions(await readFile(laid, "utf8"));
expect(order.filter((d) => d.group === "layout-2").map((d) => d.name)).toEqual(helpers);
expect(order.find((d) => d.name === norm)!.group).toBe("layout-1");
const exe = join(dir, "main");
const built = await compile(entry, {
outputKind: "exe",
outDir: dir,
outPath: exe,
functionLayout: layout,
});
expect(built.ok, JSON.stringify(built.diagnostics)).toBe(true);
expect(execFileSync(exe, { encoding: "utf8" })).toBe("5,10 three\n");
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("a changed function layout misses the executable cache", async () => {
vi.stubEnv("SCRIPTC_NO_CACHE", undefined);
const dir = await mkdtemp(join(tmpdir(), "scriptc-function-layout-cache-"));
try {
vi.stubEnv("SCRIPTC_CACHE_DIR", join(dir, "cache"));
const entry = join(dir, "main.ts");
await writeFile(entry, source);
const exe = join(dir, "main");
const llvm = join(dir, "main.ll");
const build = async (functionLayout?: string) => {
const built = await compile(entry, {
outputKind: "exe",
outDir: dir,
outPath: exe,
...(functionLayout === undefined ? {} : { functionLayout }),
});
expect(built.ok, JSON.stringify(built.diagnostics)).toBe(true);
return definitions(await readFile(llvm, "utf8"));
};
const plain = await build();
const third = plain.find((d) => /^sc_b?f_third$/.test(d.name))!.name;
const first = plain.find((d) => /^sc_b?f_first$/.test(d.name))!.name;
const layout = join(dir, "layout");
await writeFile(layout, `1 ${third}\n`);
expect((await build(layout)).find((d) => d.name === third)!.group).toBe("layout-1");
// The same path with other contents is another layout.
await writeFile(layout, `1 ${first}\n`);
const edited = await build(layout);
expect(edited.find((d) => d.name === first)!.group).toBe("layout-1");
expect(edited.find((d) => d.name === third)!.group).not.toMatch(/^layout-/);
} finally {
vi.unstubAllEnvs();
await rm(dir, { recursive: true, force: true });
}
});
+14 -5
View File
@@ -25,6 +25,19 @@ export function verifyBootstrapSeed(directory, revision, sanitize) {
}
}
/** The environment a bootstrap seed builds with, over the inherited one. The
* bootstrap compares the seed's LLVM with the native compiler's own emission,
* which has no function layout. The layout only orders the functions, so the
* seed is built without it. */
export function seedBuildEnvironment(sanitize) {
return {
SCRIPTC_SAN: sanitize ? "1" : "",
SCRIPTC_NATIVE_EMIT_IR: "1",
SCRIPTC_NATIVE_OPTIMIZATION: sanitize ? "dev" : "release",
SCRIPTC_NATIVE_LAYOUT: "",
};
}
async function main() {
const directory = process.env.SCRIPTC_BOOTSTRAP_SEED_DIRECTORY;
const revision = process.env.GITHUB_SHA;
@@ -47,11 +60,7 @@ async function main() {
{
cwd: root,
stdio: "inherit",
env: {
...process.env,
SCRIPTC_NATIVE_EMIT_IR: "1",
SCRIPTC_NATIVE_OPTIMIZATION: sanitize ? "dev" : "release",
},
env: { ...process.env, ...seedBuildEnvironment(sanitize) },
},
);
writeFileSync(
+10
View File
@@ -37,6 +37,15 @@ const useProfile =
!sanitize &&
requestedProfile !== "" &&
(requestedProfile !== undefined || existsSync(committedProfile));
// Builds lay out the compiler's functions with the committed layout
// (scripts/refresh-cli-layout.mjs), or the one SCRIPTC_NATIVE_LAYOUT names;
// an empty SCRIPTC_NATIVE_LAYOUT, and instrumented builds, use none.
const committedLayout = join(root, "packages/compiler/native/cli.layout.gz");
const requestedLayout = process.env["SCRIPTC_NATIVE_LAYOUT"];
const functionLayout =
profileGenerate !== undefined || requestedLayout === ""
? undefined
: (requestedLayout ?? (existsSync(committedLayout) ? committedLayout : undefined));
let target = nativeCodegenTarget();
if (target === null || target.platform === "wasi")
throw new Error("a supported native host is required to build the compiler");
@@ -126,6 +135,7 @@ const result = await compile(join(root, "packages/compiler/src/native/cli.ts"),
sanitize,
...(profileGenerate === undefined ? {} : { profileGenerate }),
...(profileUse === undefined ? {} : { profileUse }),
...(functionLayout === undefined ? {} : { functionLayout }),
emitIr: process.env["SCRIPTC_NATIVE_EMIT_IR"] === "1",
});
if (!result.ok)
+248
View File
@@ -0,0 +1,248 @@
#!/usr/bin/env node
/* Refreshes packages/compiler/native/cli.layout.gz, the function layout that
* builds of the native compiler use (scripts/build-native-cli.mts). The
* layout lists, in two tiers, the functions the compiler runs: tier 1 for
* `--version` and rebuilds of unchanged programs, tier 2 for the other
* builds. The emitter writes the listed functions first, so the code a build
* runs sits together in the compiler's executable and a run maps fewer of its
* pages.
*
* Builds an instrumented native compiler for this Linux host (or reuses one
* with --instrumented), builds each training program cold and then again
* unchanged, and reads the functions each kind of run executed from the raw
* profiles. The training programs are the runtime benchmark workloads at
* `speed` and every seventh corpus program at the default optimization. A
* refresh takes about 15 minutes on 16 cores, most of it for the instrumented
* build. A stale layout lists some functions that no longer exist and misses
* new ones, which then keep their usual order.
*
* node scripts/refresh-cli-layout.mjs [--jobs 8] [--instrumented <compiler dir>]
*/
import { spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
appendFileSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { cpus, tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
import { constants, gzipSync, inflateSync } from "node:zlib";
const root = fileURLToPath(new URL("..", import.meta.url));
const { values } = parseArgs({
options: {
jobs: { type: "string", default: String(Math.max(1, Math.floor(cpus().length / 2))) },
output: { type: "string", default: join(root, "packages/compiler/native/cli.layout.gz") },
work: { type: "string" },
instrumented: { type: "string" },
help: { type: "boolean", default: false },
},
});
if (values.help) {
console.log(
"Usage: node scripts/refresh-cli-layout.mjs [--jobs N] [--output <layout.gz>] [--work <dir>] [--instrumented <compiler dir>]",
);
process.exit(0);
}
if (process.platform !== "linux" || process.arch !== "x64")
throw new Error("the compiler layout is collected on Linux x64");
const jobs = Number(values.jobs);
const output = resolve(values.output);
mkdirSync(dirname(output), { recursive: true });
const work = resolve(values.work ?? mkdtempSync(join(tmpdir(), "scriptc-cli-layout-")));
const raw = join(work, "raw");
const builds = join(work, "builds");
rmSync(raw, { recursive: true, force: true });
for (const dir of [raw, builds]) mkdirSync(dir, { recursive: true });
const log = join(work, "training.log");
const cli = resolve(values.instrumented ?? join(work, "cli"));
if (values.instrumented === undefined) {
console.log(`building the instrumented compiler in ${cli}`);
const built = spawnSync(
process.execPath,
[
"--max-old-space-size=8192",
"--import",
"tsx",
join(root, "scripts/build-native-cli.mts"),
cli,
],
{
cwd: root,
stdio: "inherit",
env: { ...process.env, SCRIPTC_NATIVE_PROFILE_GENERATE: join(raw, "build-%4m.profraw") },
},
);
if (built.status !== 0) throw new Error("the instrumented compiler did not build");
}
const scriptc = join(cli, "bin/scriptc");
// Phase timing shows whether a rebuild hit both caches. It is on for every
// run, so the rebuild's environment matches the first build's.
const baseEnv = { ...process.env, SCRIPTC_TIMING: "1" };
delete baseEnv["SCRIPTC_LLVM_HELPER"];
/** Runs the instrumented compiler, writing its profile to `options.profile`. */
function run(args, options) {
return new Promise((done) => {
const child = spawn(scriptc, args, {
cwd: options.cwd,
stdio: ["ignore", "pipe", "pipe"],
env: {
...baseEnv,
SCRIPTC_CACHE_DIR: options.cache,
LLVM_PROFILE_FILE: options.profile,
},
});
let text = "";
child.stdout.on("data", (chunk) => (text += chunk));
child.stderr.on("data", (chunk) => (text += chunk));
child.on("close", (code) => {
appendFileSync(log, `$ scriptc ${args.join(" ")}\n${text}exit ${code}\n`);
done({ ok: code === 0, text });
});
});
}
async function pool(tasks) {
let next = 0;
let passed = 0;
await Promise.all(
Array.from({ length: jobs }, async () => {
while (next < tasks.length) if (await tasks[next++]()) passed++;
}),
);
return passed;
}
const programNumber = (name) => Number.parseInt(name, 10);
const training = [
...readdirSync(join(root, "benchmarks/runtime/workloads"))
.filter((name) => name.endsWith(".ts"))
.map((name) => ({
file: join(root, "benchmarks/runtime/workloads", name),
optimization: "speed",
})),
...readdirSync(join(root, "tests/corpus"))
.filter((name) => /^\d+-.*\.ts$/.test(name) && programNumber(name) % 7 === 0)
.map((name) => ({ file: join(root, "tests/corpus", name), optimization: "release" })),
];
// Tier 1 collects each run that took the warm path from its own profile; a
// rebuild that missed a cache (it shows no `frontend-cache-hit` or
// `executable-cache-hit` phase) is left out. Tier 2 pools the first builds.
const warm = new Set();
const cold = new Set();
let skipped = 0;
async function warmRun(args, cache, name, cacheHits) {
const profile = join(raw, `warm-${name}.profraw`);
const { ok, text } = await run(args, { cwd: root, cache, profile });
if (ok && cacheHits.every((phase) => text.includes(`"phase":"${phase}"`)))
for (const fn of executedFunctions(profile)) warm.add(fn);
else skipped++;
rmSync(profile, { force: true });
}
const started = Date.now();
await warmRun(["--version"], join(work, "cache-version"), "version", []);
console.log(`training: ${training.length} programs, cold then unchanged, ${jobs} at a time`);
const passed = await pool(
training.map((program, index) => async () => {
const cache = join(work, `cache-${index}`);
const out = join(builds, `program-${index}`);
const args = ["build", program.file, `--optimization=${program.optimization}`, "-o", out];
const { ok } = await run(args, { cwd: root, cache, profile: join(raw, "cold-%4m.profraw") });
if (ok)
await warmRun(args, cache, String(index), ["frontend-cache-hit", "executable-cache-hit"]);
rmSync(cache, { recursive: true, force: true });
rmSync(out, { force: true });
return ok;
}),
);
console.log(
`trained in ${Math.round((Date.now() - started) / 1000)} s; ${passed} of ${training.length} programs built, ${skipped} rebuilds left out of tier 1 (see ${log})`,
);
/** Functions a raw profile (LLVM's format version 10) records as run: the
* names whose counters are not all zero, without the file prefix of
* internal functions. */
function executedFunctions(path) {
const buf = readFileSync(path);
const u64 = (offset) => Number(buf.readBigUInt64LE(offset));
if (buf.readBigUInt64LE(0) !== 0xff6c70726f667281n)
throw new Error(`${path} is not a 64-bit raw profile`);
const version = Number(buf.readBigUInt64LE(8) & 0xffffffffn);
if (version !== 10) throw new Error(`${path} has raw profile version ${version}, expected 10`);
const [binaryIdsBytes, records, beforeCounters, counters, afterCounters] = [2, 3, 4, 5, 6].map(
(field) => u64(field * 8),
);
const [bitmapBytes, afterBitmap, namesBytes] = [7, 8, 9].map((field) => u64(field * 8));
const valueKinds = u64(15 * 8) + 1;
const recordBytes = Math.ceil((6 * 8 + 4 + 2 * valueKinds + 4) / 8) * 8;
const dataStart = 16 * 8 + binaryIdsBytes;
const countersStart = dataStart + records * recordBytes + beforeCounters;
const namesStart = countersStart + counters * 8 + afterCounters + bitmapBytes + afterBitmap;
const countersDelta = buf.readBigInt64LE(10 * 8);
const names = new Map();
let at = namesStart;
const uleb = () => {
let value = 0;
let scale = 1;
for (;;) {
const byte = buf[at++];
value += (byte & 0x7f) * scale;
if ((byte & 0x80) === 0) return value;
scale *= 128;
}
};
while (at < namesStart + namesBytes) {
const plain = uleb();
const packed = uleb();
if (plain === 0) break;
const bytes =
packed === 0 ? buf.subarray(at, at + plain) : inflateSync(buf.subarray(at, at + packed));
at += packed === 0 ? plain : packed;
for (const name of bytes.toString("latin1").split("\x01"))
names.set(createHash("md5").update(name, "latin1").digest().readBigUInt64LE(0), name);
}
const ran = [];
for (let index = 0; index < records; index++) {
const record = dataStart + index * recordBytes;
const counterOffset = Number(
buf.readBigInt64LE(record + 16) - (countersDelta - BigInt(index * recordBytes)),
);
const count = buf.readUInt32LE(record + 48);
for (let c = 0; c < count; c++) {
if (buf.readBigUInt64LE(countersStart + counterOffset + c * 8) !== 0n) {
const name = names.get(buf.readBigUInt64LE(record));
if (name !== undefined) ran.push(name.slice(name.indexOf(";") + 1));
break;
}
}
}
return ran;
}
for (const name of readdirSync(raw))
if (name.startsWith("cold-")) for (const fn of executedFunctions(join(raw, name))) cold.add(fn);
const lines = [
...[...warm].sort().map((name) => `1 ${name}`),
...[...cold]
.filter((name) => !warm.has(name))
.sort()
.map((name) => `2 ${name}`),
];
writeFileSync(output, gzipSync(`${lines.join("\n")}\n`, { level: constants.Z_BEST_COMPRESSION }));
console.log(
`wrote ${output}: ${warm.size} tier-1 and ${lines.length - warm.size} tier-2 functions (${statSync(output).size} bytes)`,
);
rmSync(builds, { recursive: true, force: true });
if (values.work === undefined) rmSync(work, { recursive: true, force: true });
+17 -1
View File
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { verifyBootstrapSeed } from "../../scripts/bootstrap-seed.mjs";
import { seedBuildEnvironment, verifyBootstrapSeed } from "../../scripts/bootstrap-seed.mjs";
test("bootstrap artifacts require a complete seed from the same revision, lane, and path", () => {
const directory = mkdtempSync(join(tmpdir(), "scriptc-seed-contract-"));
@@ -36,6 +36,22 @@ test("bootstrap artifacts require a complete seed from the same revision, lane,
}
});
test("seeds build without the function layout in both lanes, shared or not", () => {
for (const sanitize of [false, true])
expect(seedBuildEnvironment(sanitize)).toEqual({
SCRIPTC_SAN: sanitize ? "1" : "",
SCRIPTC_NATIVE_EMIT_IR: "1",
SCRIPTC_NATIVE_OPTIMIZATION: sanitize ? "dev" : "release",
SCRIPTC_NATIVE_LAYOUT: "",
});
// The bootstrap test builds its own seed when CI shares none.
const driver = readFileSync(
new URL("./self-hosting-native-driver.test.ts", import.meta.url),
"utf8",
);
expect(driver).toContain("...seedBuildEnvironment(sanitize)");
});
test("CI restores each seed only into its matching instrumented bootstrap lane", () => {
const workflow = load(
readFileSync(new URL("../../.github/workflows/ci.yml", import.meta.url), "utf8"),
@@ -1,4 +1,4 @@
import { verifyBootstrapSeed } from "../../scripts/bootstrap-seed.mjs";
import { seedBuildEnvironment, verifyBootstrapSeed } from "../../scripts/bootstrap-seed.mjs";
import { execFile, spawnSync } from "node:child_process";
import {
chmodSync,
@@ -89,12 +89,7 @@ test(
// seed avoids optimizing the huge compiler module before ASan can
// exercise its complete traversal, serialization and LLVM emission.
...options,
env: {
...process.env,
SCRIPTC_SAN: sanitize ? "1" : "",
SCRIPTC_NATIVE_EMIT_IR: "1",
SCRIPTC_NATIVE_OPTIMIZATION: sanitize ? "dev" : "release",
},
env: { ...process.env, ...seedBuildEnvironment(sanitize) },
},
),
);