diff --git a/RELEASING.md b/RELEASING.md index 6cafe84a3..b6c5239ee 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -16,6 +16,8 @@ CI (`.github/workflows/release.yml`) compares the version in `packages/cli/packa The release job builds each pinned LLVM helper, precompiled runtime pack, and native CLI on its matching host. The native CLI bundles its TypeScript checker, LLVM helper, runtime pack, declarations, and comptime evaluator. npm postinstall installs the matching native executable; Node is needed for npm installation but not native compilation or execution. The user's toolchain performs the final platform link. Sanitized builds also use it to instrument program LLVM and compile the C runtime. Platform packages publish ahead of `@scriptc/compiler` and `scriptc`. The GitHub release attaches standalone compiler archives, their SHA-256 checksums, and the surface manifest after npm publication succeeds. +Release builds of the native CLI optimize the compiler with the committed profile `packages/compiler/native/cli.profdata` on every platform. Code the profile does not describe, or describes from older compiler sources, is optimized as without a profile. Refresh the profile on a quiet Linux x64 host when compiler code has changed substantially since the last refresh: `node scripts/refresh-cli-profile.mjs --tsc-ts ` 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. Commit the new profile 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 diff --git a/packages/compiler/native/cli.profdata.gz b/packages/compiler/native/cli.profdata.gz new file mode 100644 index 000000000..55523e765 Binary files /dev/null and b/packages/compiler/native/cli.profdata.gz differ diff --git a/scripts/build-native-cli.mts b/scripts/build-native-cli.mts index c8a14adbd..e539cea1d 100644 --- a/scripts/build-native-cli.mts +++ b/scripts/build-native-cli.mts @@ -1,8 +1,9 @@ /** Build the production CLI seed and its relocatable compiler assets. */ import { execFileSync } from "node:child_process"; -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { basename, dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { gunzipSync } from "node:zlib"; import { compile } from "../packages/compiler/src/index.js"; import { NATIVE_TARGETS, nativeCodegenTarget } from "../packages/compiler/src/backend/targets.js"; import { tsgoExecutable } from "../packages/compiler/src/frontend/ts7/rpc-api.js"; @@ -11,12 +12,29 @@ import type { NativeToolchainManifest } from "../packages/compiler/src/native/to const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const output = resolve(process.argv[2] ?? join(root, ".scriptc/native-cli")); +const optimization = process.env["SCRIPTC_NATIVE_OPTIMIZATION"] === "dev" ? "dev" : "release"; +const sanitize = process.env["SCRIPTC_SAN"] === "1"; +// SCRIPTC_NATIVE_PROFILE_GENERATE= builds a compiler that writes a +// raw profile per run (scripts/refresh-cli-profile.mjs). Optimized builds +// otherwise use the committed profile, or the indexed profile that +// SCRIPTC_NATIVE_PROFILE_USE names; an empty SCRIPTC_NATIVE_PROFILE_USE +// builds without one. +const profileGenerate = process.env["SCRIPTC_NATIVE_PROFILE_GENERATE"] || undefined; +const committedProfile = join(root, "packages/compiler/native/cli.profdata.gz"); +const requestedProfile = process.env["SCRIPTC_NATIVE_PROFILE_USE"]; +const useProfile = + profileGenerate === undefined && + optimization !== "dev" && + !sanitize && + requestedProfile !== "" && + (requestedProfile !== undefined || existsSync(committedProfile)); let target = nativeCodegenTarget(); if (target === null || target.platform === "wasi") throw new Error("a supported native host is required to build the compiler"); // Release executables use the runtime pack's libc baseline, even when the -// build host has a newer libc. Sanitizer builds use the host's sanitizer SDK. -if (target.name.endsWith("-gnu") && process.env["SCRIPTC_SAN"] !== "1") { +// build host has a newer libc. Sanitizer builds use the host's sanitizer SDK, +// and instrumented builds the host compiler driver's profile runtime. +if (target.name.endsWith("-gnu") && !sanitize && profileGenerate === undefined) { const architecture = target.architecture === "x64" ? "x86_64" : "aarch64"; process.env["SCRIPTC_TARGET"] = `${architecture}-linux-gnu.2.34`; target = nativeCodegenTarget()!; @@ -25,6 +43,14 @@ const bin = join(output, "bin"); const lib = join(output, "lib"); const seed = join(dirname(output), ".scriptc", basename(output) + "-seed"); for (const dir of [bin, lib, seed]) mkdirSync(dir, { recursive: true }); +let profileUse: string | undefined; +if (useProfile) { + profileUse = requestedProfile; + if (profileUse === undefined) { + profileUse = join(seed, "cli.profdata"); + writeFileSync(profileUse, gunzipSync(readFileSync(committedProfile))); + } +} const compiler = process.env["SCRIPTC_CC"] ?? target.defaultLinker; const compilerArgs = process.env["SCRIPTC_CC"] === undefined ? [...target.defaultLinkerArgs] : []; // Zig's C debug mode otherwise inserts UBSan calls into these objects. They @@ -84,10 +110,12 @@ const result = await compile(join(root, "packages/compiler/src/native/cli.ts"), outDir: seed, outPath: executable, backend: "llvm", - optimization: process.env["SCRIPTC_NATIVE_OPTIMIZATION"] === "dev" ? "dev" : "release", + optimization, strip: process.env["SCRIPTC_NATIVE_KEEP_SYMBOLS"] !== "1", ffiProfilePath: ffi, - sanitize: process.env["SCRIPTC_SAN"] === "1", + sanitize, + ...(profileGenerate === undefined ? {} : { profileGenerate }), + ...(profileUse === undefined ? {} : { profileUse }), emitIr: process.env["SCRIPTC_NATIVE_EMIT_IR"] === "1", }); if (!result.ok) diff --git a/scripts/refresh-cli-profile.mjs b/scripts/refresh-cli-profile.mjs new file mode 100644 index 000000000..e9554e911 --- /dev/null +++ b/scripts/refresh-cli-profile.mjs @@ -0,0 +1,205 @@ +#!/usr/bin/env node +/* Refreshes packages/compiler/native/cli.profdata.gz, the profile that release + * builds of the native compiler optimize with (scripts/build-native-cli.mts). + * + * Builds an instrumented native compiler for this Linux host, runs it on the + * training builds below, and merges the raw profiles it writes into one + * sparse indexed profile. The training builds are tsc-ts (a cold `speed` + * build, then a rebuild after an edit), every seventh corpus program at the + * default optimization and a further slice in `dev`, and the runtime + * benchmark workloads. Run it on a quiet Linux x64 host after changes that + * move much compiler code; a stale profile only loses part of its gain. A + * refresh takes about 11 minutes on 16 cores. + * + * node scripts/refresh-cli-profile.mjs --tsc-ts [--jobs 8] + */ +import { spawn, spawnSync } from "node:child_process"; +import { + appendFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { cpus, tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; +import { constants, gzipSync } from "node:zlib"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const { values } = parseArgs({ + options: { + "tsc-ts": { type: "string" }, + jobs: { type: "string", default: String(Math.max(1, Math.floor(cpus().length / 2))) }, + output: { type: "string", default: join(root, "packages/compiler/native/cli.profdata.gz") }, + work: { type: "string" }, + instrumented: { type: "string" }, + help: { type: "boolean", default: false }, + }, +}); +if (values.help || values["tsc-ts"] === undefined) { + console.log( + "Usage: node scripts/refresh-cli-profile.mjs --tsc-ts [--jobs N] [--output ] [--work ] [--instrumented ]", + ); + process.exit(values.help ? 0 : 2); +} +if (process.platform !== "linux" || process.arch !== "x64") + throw new Error("the compiler profile is collected on Linux x64"); +const tscTs = resolve(values["tsc-ts"]); +const tscTsPackage = join(tscTs, "packages/tsc-ts"); +if (!existsSync(join(tscTsPackage, "src/main.ts"))) + throw new Error(`${tscTs} is not a tsc-ts checkout with installed dependencies`); +const jobs = Number(values.jobs); +const work = resolve(values.work ?? mkdtempSync(join(tmpdir(), "scriptc-cli-profile-"))); +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"); + +// Every run merges its counters into one of four pooled raw profiles. +const profileFile = join(raw, "cli-%4m.profraw"); +// --instrumented reuses a compiler an earlier run built in /cli. +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: profileFile }, + }, + ); + if (built.status !== 0) throw new Error("the instrumented compiler did not build"); +} +const scriptc = join(cli, "bin/scriptc"); +// The compiler trains with the helper it bundles, so a helper override in +// the environment does not apply. +const trainingEnv = { ...process.env, LLVM_PROFILE_FILE: profileFile }; +delete trainingEnv["SCRIPTC_LLVM_HELPER"]; + +/** Runs the instrumented compiler; a failing build still profiles the + * paths it took. */ +function run(args, options) { + return new Promise((done) => { + const child = spawn(scriptc, args, { + cwd: options.cwd, + stdio: ["ignore", "pipe", "pipe"], + env: { ...trainingEnv, SCRIPTC_CACHE_DIR: options.cache }, + }); + let output = ""; + child.stdout.on("data", (chunk) => (output += chunk)); + child.stderr.on("data", (chunk) => (output += chunk)); + child.on("close", (code) => { + appendFileSync(log, `$ scriptc ${args.join(" ")}\n${output}exit ${code}\n`); + done(code === 0); + }); + }); +} + +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 corpus = readdirSync(join(root, "tests/corpus")) + .filter((name) => /^\d+-.*\.ts$/.test(name)) + .sort((a, b) => programNumber(a) - programNumber(b)); +const training = [ + ...corpus + .filter((name) => programNumber(name) % 7 === 0) + .map((name) => ({ file: join(root, "tests/corpus", name), optimization: "release" })), + ...corpus + .filter((name) => programNumber(name) % 14 === 7) + .map((name) => ({ file: join(root, "tests/corpus", name), optimization: "dev" })), + ...readdirSync(join(root, "benchmarks/runtime/workloads")) + .filter((name) => name.endsWith(".ts")) + .map((name) => ({ + file: join(root, "benchmarks/runtime/workloads", name), + optimization: "release", + })), +]; + +const started = Date.now(); +console.log("training: tsc-ts, cold and after an edit"); +const tscCache = join(work, "cache-tsc-ts"); +const tscArgs = [ + "build", + "src/main.ts", + "--optimization=speed", + "--detached-exit", + "-o", + join(builds, "tsc"), +]; +await run(tscArgs, { cwd: tscTsPackage, cache: tscCache }); +const edited = join(tscTsPackage, "src/main.ts"); +const source = readFileSync(edited, "utf8"); +try { + writeFileSync(edited, source + "\n// profile training edit\n"); + await run(tscArgs, { cwd: tscTsPackage, cache: tscCache }); +} finally { + writeFileSync(edited, source); +} + +console.log(`training: ${training.length} programs, ${jobs} at a time`); +const passed = await pool( + training.map((program, index) => () => { + const cache = join(work, `cache-${index}`); + return run( + [ + "build", + program.file, + `--optimization=${program.optimization}`, + "-o", + join(builds, `program-${index}`), + ], + { cwd: root, cache }, + ).then((ok) => { + rmSync(cache, { recursive: true, force: true }); + rmSync(join(builds, `program-${index}`), { force: true }); + return ok; + }); + }), +); +console.log( + `trained in ${Math.round((Date.now() - started) / 1000)} s; ${passed} of ${training.length} programs built (failures still profile; see ${log})`, +); + +const profiles = readdirSync(raw) + .filter((name) => name.endsWith(".profraw")) + .map((name) => join(raw, name)); +rmSync(builds, { recursive: true, force: true }); +const indexed = join(work, "cli.profdata"); +const merged = spawnSync(scriptc, ["merge-profiles", "--sparse", "-o", indexed, ...profiles], { + stdio: "inherit", + env: { ...trainingEnv, LLVM_PROFILE_FILE: join(work, "merge.profraw") }, +}); +if (merged.status !== 0) throw new Error("merge-profiles failed"); +// The indexed profile compresses to about an eighth of its size. +const output = resolve(values.output); +writeFileSync(output, gzipSync(readFileSync(indexed), { level: constants.Z_BEST_COMPRESSION })); +console.log( + `wrote ${output} (${statSync(output).size} bytes; ${statSync(indexed).size} uncompressed)`, +); +// A work directory the caller named stays for inspection. +if (values.work === undefined) rmSync(work, { recursive: true, force: true });