diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index 786cf2c63..9b6e88628 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -2,7 +2,7 @@ title: "CLI Reference" --- -The `scriptc` command has four subcommands: `build`, `run`, `coverage`, and `cache warm`. `scriptc --help` prints this summary and the options below, and `scriptc --version` prints the installed version. +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. ```console $ scriptc --help @@ -23,6 +23,13 @@ Usage: contract sidecar JSON beside the archive scriptc cache warm [runtime|tls…] verify and load precompiled runtime families for the current compiler/SDK/target + scriptc merge-profiles -o [--sparse] + merge the raw profiles that + --profile-generate runs wrote (or + earlier merged profiles) into one + profile for --profile-use; + --sparse leaves out functions + that never ran (a smaller file) ``` ## scriptc build @@ -85,6 +92,16 @@ Analyzes the program without building it and reports what compiles and what bloc 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. +## scriptc merge-profiles + +Merges the raw profiles that runs of a `--profile-generate` executable wrote, and any earlier merged profiles, into one profile for `--profile-use`. See [Profile-guided optimization](#profile-guided-optimization). + +```console +$ scriptc merge-profiles -o app.profdata profiles/*.profraw +``` + +With `--sparse`, the profile leaves out the functions the runs never reached. The file is smaller, typically by about two thirds, and those functions are optimized as without a profile instead of as cold code. + ## Options
@@ -118,6 +135,12 @@ Verifies and loads the installed precompiled runtime objects and vendored librar
--detached-exit
On Linux, when the program calls process.exit(), hand the release of its memory to a helper process so the parent sees the exit sooner. The kernel otherwise frees the whole address space before the exit is reported, which takes tens of milliseconds per gigabyte. Opt in only where the trade-offs are acceptable: for up to about 100 ms after the exit, the helper keeps the executable mapped (overwriting the binary in place fails with "text file busy"), its memory stays charged to the cgroup, and the teardown CPU time no longer appears in the parent's resource usage. Valid only for executable builds; it has no effect on other platforms.
+
--profile-generate <pattern>
+
On Linux, build an instrumented executable for profile-guided optimization. Each run writes a raw profile to the path pattern, where %p expands to the process ID; the LLVM_PROFILE_FILE 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 --optimization=release or speed.
+ +
--profile-use <file.profdata>
+
Optimize the executable with a profile from scriptc merge-profiles: 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. Valid only for executable builds with --optimization=release or speed.
+
--npm-static <pkg[,pkg…]|auto>
Name npm packages to compile even if they don't ship their own type declarations (repeatable). A trailing ..., as in fs-extra..., also names every package that package depends on. auto uses the default automatic selection. Packages that can't compile produce diagnostics. See npm Dependencies.
@@ -189,6 +212,19 @@ $ file fib-linux fib-linux: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, for GNU/Linux 2.0.0, with debug_info, not stripped ``` +## Profile-guided optimization + +A profile records how often each branch and call ran in representative runs of the program. With it, the optimizer inlines the hot calls, keeps hot paths together, and moves cold code out of the way. Profiling takes three steps: + +```console +$ scriptc build app.ts --optimization=speed --profile-generate profiles/%p.profraw -o app-instrumented +$ ./app-instrumented typical-input.json +$ scriptc merge-profiles -o app.profdata profiles/*.profraw +$ scriptc build app.ts --optimization=speed --profile-use app.profdata +``` + +Train on workloads like the ones the program will run: code the training never reached is optimized as without a profile. The profile can be committed and reused as the program changes. Functions whose control flow changed since the profile was collected lose their profile, and the rest keep theirs, so regenerate it when much of the program has changed. + ## LLVM backend scriptc generates LLVM IR and compiles it to native code. Executable builds link the result with the matching precompiled runtime pack. Use --emit=llvm to inspect the generated code. diff --git a/native/llvm-codegen/CMakeLists.txt b/native/llvm-codegen/CMakeLists.txt index a0d11d574..07abb35ec 100644 --- a/native/llvm-codegen/CMakeLists.txt +++ b/native/llvm-codegen/CMakeLists.txt @@ -80,6 +80,7 @@ add_executable(scriptc-llvm-codegen src/emit.cpp src/main.cpp src/partition.cpp + src/profile.cpp src/runtime-import.cpp src/runtime-unit.cpp src/target.cpp @@ -180,7 +181,7 @@ if(SCRIPTC_STATIC_LINUX_BUILD) endif() endfunction() - set(SCRIPTC_LLVM_LINK_ROOTS ${SCRIPTC_LLVM_TARGET_LIBS} BitReader BitWriter IRReader LTO Linker Passes TransformUtils) + set(SCRIPTC_LLVM_LINK_ROOTS ${SCRIPTC_LLVM_TARGET_LIBS} BitReader BitWriter IRReader LTO Linker Passes ProfileData TransformUtils) foreach(component IN LISTS SCRIPTC_LLVM_LINK_ROOTS) if(TARGET "LLVM${component}") scriptc_collect_llvm_target("LLVM${component}") @@ -209,6 +210,7 @@ else() LTO Linker Passes + ProfileData Support Target TransformUtils diff --git a/native/llvm-codegen/src/emit.cpp b/native/llvm-codegen/src/emit.cpp index a5f87e4b6..6b73797b4 100644 --- a/native/llvm-codegen/src/emit.cpp +++ b/native/llvm-codegen/src/emit.cpp @@ -30,6 +30,9 @@ #include "llvm/PassInfo.h" #include "llvm/PassRegistry.h" #include "llvm/Passes/PassBuilder.h" +#include "llvm/ProfileData/InstrProfReader.h" +#include "llvm/Support/PGOOptions.h" +#include "llvm/Support/VirtualFileSystem.h" #include "llvm/Support/Caching.h" #include "llvm/Support/FileSystem.h" #include "llvm/Support/MemoryBuffer.h" @@ -82,11 +85,18 @@ std::optional parseEmitOptions(int Argc, char **Argv) { Options.ImportBitcode.push_back(Value.str()); else if (Arg == "--cache-dir") Options.CacheDirectory = Value.str(); + else if (Arg == "--profile-generate") + Options.ProfileGenerate = Value.str(); + else if (Arg == "--profile-use") + Options.ProfileUse = Value.str(); else return std::nullopt; } if (Options.Input.empty() || Options.Outputs.empty()) return std::nullopt; + // A build either collects a profile or uses one. + if (!Options.ProfileGenerate.empty() && !Options.ProfileUse.empty()) + return std::nullopt; return Options; } @@ -123,6 +133,39 @@ PipelineTuningOptions tuningFor(OptimizationLevel Level) { return Tuning; } +// The profile-guided optimization a build asks for: IR instrumentation that +// writes raw profiles to the ProfileGenerate pattern, or annotation from the +// indexed ProfileUse profile. Functions the profile does not describe, or +// whose control flow changed since it was collected, are optimized as without +// one, so a stale profile loses only its gain. +std::optional profileOptions(const EmitOptions &Options) { + if (!Options.ProfileGenerate.empty()) + return PGOOptions(Options.ProfileGenerate, "", "", "", + PGOOptions::IRInstr); + if (!Options.ProfileUse.empty()) + return PGOOptions(Options.ProfileUse, "", "", "", PGOOptions::IRUse); + return std::nullopt; +} + +// Reads the profile's header once, before any partition needs it: it must +// hold IR-level counts. +std::optional inspectProfile(const EmitOptions &Options) { + if (Options.ProfileUse.empty()) + return std::nullopt; + Expected> Reader = + IndexedInstrProfReader::create(Options.ProfileUse, + *vfs::getRealFileSystem()); + if (!Reader) + return EmitFailure{"profile_read_failed", + Options.ProfileUse + ": " + + toString(Reader.takeError())}; + if (!(*Reader)->isIRLevelProfile()) + return EmitFailure{"profile_read_failed", + Options.ProfileUse + + ": not an IR-level instrumentation profile"}; + return std::nullopt; +} + // Owns the analysis managers a pipeline needs for one module. struct Pipeline { LoopAnalysisManager LAM; @@ -131,8 +174,9 @@ struct Pipeline { ModuleAnalysisManager MAM; PassBuilder PB; - Pipeline(TargetMachine &Machine, OptimizationLevel Level) - : PB(&Machine, tuningFor(Level)) { + Pipeline(TargetMachine &Machine, OptimizationLevel Level, + std::optional Profile = std::nullopt) + : PB(&Machine, tuningFor(Level), std::move(Profile)) { PB.registerModuleAnalyses(MAM); PB.registerCGSCCAnalyses(CGAM); PB.registerFunctionAnalyses(FAM); @@ -319,9 +363,9 @@ std::optional publish(StringRef TemporaryPath, std::optional emitModule(Module &M, TargetMachine &Machine, OptimizationLevel Level, - CodeGenFileType Type, StringRef OutputPath, - std::vector> &Temporaries) { - Pipeline P(Machine, Level); + std::optional Profile, CodeGenFileType Type, + StringRef OutputPath, std::vector> &Temporaries) { + Pipeline P(Machine, Level, std::move(Profile)); P.PB.buildPerModuleDefaultPipeline(Level).run(M, P.MAM); if (std::optional Failure = verify(M, "post_optimization_verification_failed")) @@ -462,7 +506,7 @@ compileUnoptimizedPartitions(const EmitOptions &Options, if (!Mod) return Failures.set({"partition_failed", toString(Mod.takeError())}); // The O0 pipeline still lowers coroutines and always-inline calls. - Pipeline P(*Machine, OptimizationLevel::O0); + Pipeline P(*Machine, OptimizationLevel::O0, profileOptions(Options)); P.PB.buildPerModuleDefaultPipeline(OptimizationLevel::O0) .run(**Mod, P.MAM); raw_svector_ostream Output(Objects[I]); @@ -522,7 +566,9 @@ compileOptimizedPartitions(const EmitOptions &Options, SmallVector Bitcode; { raw_svector_ostream Stream(Bitcode); - Pipeline P(*Machine, Level); + // Profile instrumentation or annotation happens here, before the thin + // link; the post-link optimization reads the annotated weights. + Pipeline P(*Machine, Level, profileOptions(Options)); ModulePassManager MPM = P.PB.buildThinLTOPreLinkDefaultPipeline(Level); MPM.addPass(BitcodeWriterPass(Stream, /*ShouldPreserveUseListOrder=*/false, /*EmitSummaryIndex=*/true, @@ -622,6 +668,10 @@ compileOptimizedPartitions(const EmitOptions &Options, Names[I] = "partition-" + std::to_string(I); lto::LTO Link(std::move(Config), lto::createInProcessThinBackend(hardware_concurrency())); + // Profile instrumentation gives every partition its own weak copy of the + // variables the profile runtime reads (the profile format and file name). + // One copy prevails and stays visible so the runtime's defaults do not. + StringSet<> ProfileSymbols; for (size_t I = 0; I < Count; ++I) { Expected> Input = lto::InputFile::create( MemoryBufferRef(Summaries[I]->getBuffer(), Names[I])); @@ -635,7 +685,10 @@ compileOptimizedPartitions(const EmitOptions &Options, // external before partitioning are visible to the runtime objects // linked beside them; the rest may become internal again. bool Public = Plan.PublicSymbols.contains(Symbol.getIRName()); - Resolution.Prevailing = true; + bool Profile = Symbol.getIRName().starts_with("__llvm_profile_"); + if (Profile) + Public = ProfileSymbols.insert(Symbol.getIRName()).second; + Resolution.Prevailing = !Profile || Public; Resolution.VisibleToRegularObj = Public; Resolution.FinalDefinitionInLinkageUnit = !Public; } @@ -672,7 +725,19 @@ compilePartitions(const EmitOptions &Options, const PartitionPlan &Plan, // helper build; source content and partition count arrive per module. std::string Salt = hashParts({"scriptc-partition-v1", LLVM_VERSION_STRING, Options.Target, Options.OptLevel, - Options.RelocationModel, Options.SourcePath}); + Options.RelocationModel, Options.SourcePath, + "profile-generate", Options.ProfileGenerate}); + // A profile changes every partition it describes, so its bytes key them. + if (!Options.ProfileUse.empty()) { + ErrorOr> Profile = + MemoryBuffer::getFile(Options.ProfileUse, /*IsText=*/false, + /*RequiresNullTerminator=*/false); + if (!Profile) + return EmitFailure{"profile_read_failed", + Options.ProfileUse + ": " + + Profile.getError().message()}; + Salt = hashParts({Salt, "profile-use", (*Profile)->getBuffer()}); + } for (const std::string &Path : Options.ImportBitcode) { ErrorOr> Bitcode = MemoryBuffer::getFile(Path, /*IsText=*/false, @@ -805,6 +870,9 @@ int emit(const EmitOptions &Options) { return reportError("invalid_import", "runtime bitcode import requires an optimized build", Options.DiagnosticFormat); + if (std::optional Failure = inspectProfile(Options)) + return reportError(Failure->Code, Failure->Message, + Options.DiagnosticFormat); if (Options.Outputs.size() > 1) return emitPartitions(Options); @@ -864,8 +932,9 @@ int emit(const EmitOptions &Options) { ? CodeGenFileType::ObjectFile : CodeGenFileType::AssemblyFile; std::vector> Temporaries; - std::optional Failure = emitModule( - *Mod, *Machine, Level, Type, Options.Outputs[0], Temporaries); + std::optional Failure = + emitModule(*Mod, *Machine, Level, profileOptions(Options), Type, + Options.Outputs[0], Temporaries); if (!Failure) Failure = publish(Temporaries[0], Options.Outputs[0]); if (Failure) { diff --git a/native/llvm-codegen/src/emit.h b/native/llvm-codegen/src/emit.h index 444716a86..39b8c2463 100644 --- a/native/llvm-codegen/src/emit.h +++ b/native/llvm-codegen/src/emit.h @@ -20,6 +20,12 @@ struct EmitOptions { std::vector ImportBitcode; // Private directory for reusable partition artifacts; empty disables reuse. std::string CacheDirectory; + // Instruments the program for IR profiling. The instrumented executable + // writes raw profiles to this path pattern (LLVM_PROFILE_FILE overrides it + // at run time). Empty: no instrumentation. + std::string ProfileGenerate; + // Indexed profile (.profdata) that guides optimization. Empty: none. + std::string ProfileUse; }; std::optional parseEmitOptions(int Argc, char **Argv); diff --git a/native/llvm-codegen/src/main.cpp b/native/llvm-codegen/src/main.cpp index 657f80bc8..57416106a 100644 --- a/native/llvm-codegen/src/main.cpp +++ b/native/llvm-codegen/src/main.cpp @@ -1,5 +1,6 @@ #include "diagnostics.h" #include "emit.h" +#include "profile.h" #include "runtime-unit.h" #include "target.h" @@ -77,6 +78,8 @@ int main(int Argc, char **Argv) { return scriptc::runtimeUnit(Argc, Argv); if (Argc >= 2 && StringRef(Argv[1]) == "prune-import") return scriptc::pruneImport(Argc, Argv); + if (Argc >= 2 && StringRef(Argv[1]) == "merge-profiles") + return scriptc::mergeProfiles(Argc, Argv); if (Argc >= 2 && StringRef(Argv[1]) == "emit") { std::optional Options = scriptc::parseEmitOptions(Argc, Argv); @@ -86,10 +89,11 @@ int main(int Argc, char **Argv) { "emit requires --input --output and accepts " "--filetype --target --opt-level <0|1|2|3|s|z> " "--relocation-model pic --diagnostic-format json " - "--source-path --import-bitcode --cache-dir "); + "--source-path --import-bitcode --cache-dir " + "--profile-generate --profile-use "); return scriptc::emit(*Options); } return scriptc::reportError("usage", - "expected 'version --format=json', 'emit', 'runtime-unit', or " - "'prune-import'"); + "expected 'version --format=json', 'emit', 'runtime-unit', " + "'prune-import', or 'merge-profiles'"); } diff --git a/native/llvm-codegen/src/profile.cpp b/native/llvm-codegen/src/profile.cpp new file mode 100644 index 000000000..6f65d4263 --- /dev/null +++ b/native/llvm-codegen/src/profile.cpp @@ -0,0 +1,88 @@ +#include "profile.h" +#include "diagnostics.h" + +#include "llvm/ADT/SmallString.h" +#include "llvm/ADT/StringRef.h" +#include "llvm/ProfileData/InstrProfReader.h" +#include "llvm/ProfileData/InstrProfWriter.h" +#include "llvm/Support/FileSystem.h" +#include "llvm/Support/VirtualFileSystem.h" +#include "llvm/Support/raw_ostream.h" + +#include +#include + +using namespace llvm; + +namespace scriptc { + +int mergeProfiles(int Argc, char **Argv) { + std::string Output; + bool Sparse = false; + std::vector Inputs; + for (int I = 2; I < Argc; ++I) { + StringRef Arg(Argv[I]); + if (Arg == "--output" && I + 1 < Argc) + Output = Argv[++I]; + else if (Arg == "--sparse") + Sparse = true; + else if (!Arg.starts_with("--")) + Inputs.push_back(Arg.str()); + else + return reportError("usage", "merge-profiles requires --output " + "and one or more input profiles, and " + "accepts --sparse"); + } + if (Output.empty() || Inputs.empty()) + return reportError("usage", "merge-profiles requires --output and " + "one or more input profiles"); + + // Sparse profiles leave out the functions that never ran, which then + // optimize as without a profile instead of as cold. + InstrProfWriter Writer(Sparse); + IntrusiveRefCntPtr FS = vfs::getRealFileSystem(); + for (const std::string &Path : Inputs) { + Expected> Reader = + InstrProfReader::create(Path, *FS); + if (!Reader) + return reportError("profile_read_failed", + Path + ": " + toString(Reader.takeError())); + if (Error E = Writer.mergeProfileKind((*Reader)->getProfileKind())) + return reportError("profile_merge_failed", + Path + ": " + toString(std::move(E))); + std::string RecordError; + for (NamedInstrProfRecord &Record : **Reader) { + Writer.addRecord(std::move(Record), /*Weight=*/1, [&](Error E) { + if (RecordError.empty()) + RecordError = toString(std::move(E)); + else + consumeError(std::move(E)); + }); + } + if ((*Reader)->hasError()) + return reportError("profile_read_failed", + Path + ": " + toString((*Reader)->getError())); + if (!RecordError.empty()) + return reportError("profile_merge_failed", Path + ": " + RecordError); + } + + // Publish through a private sibling file, as emission does. + SmallString<256> Temporary(Output); + Temporary += ".tmp"; + { + std::error_code EC; + raw_fd_ostream Stream(Temporary, EC, sys::fs::OF_None); + if (EC) + return reportError("output_open_failed", EC.message()); + if (Error E = Writer.write(Stream)) + return reportError("profile_write_failed", toString(std::move(E))); + } + if (std::error_code EC = sys::fs::rename(Temporary, Output)) { + sys::fs::remove(Temporary); + return reportError("output_publish_failed", EC.message()); + } + outs() << "{\"ok\":true}\n"; + return 0; +} + +} // namespace scriptc diff --git a/native/llvm-codegen/src/profile.h b/native/llvm-codegen/src/profile.h new file mode 100644 index 000000000..1cad1c54b --- /dev/null +++ b/native/llvm-codegen/src/profile.h @@ -0,0 +1,10 @@ +#pragma once + +namespace scriptc { + +// `merge-profiles --output [--sparse] ...`: merges raw +// (.profraw) or indexed (.profdata) IR profiles into one indexed profile. +// --sparse leaves out functions that never ran. +int mergeProfiles(int Argc, char **Argv); + +} // namespace scriptc diff --git a/packages/cli/src/bootstrap.ts b/packages/cli/src/bootstrap.ts index 1466707d6..1a319f98d 100644 --- a/packages/cli/src/bootstrap.ts +++ b/packages/cli/src/bootstrap.ts @@ -66,6 +66,10 @@ async function tryFastPath(): Promise { values["emit-ir"] || values.lib || values["provenance-sources"] || + // The full compiler keys these builds by the profile's bytes. + values["profile-generate"] !== undefined || + values["profile-use"] !== undefined || + values.sparse || (values["external-types"] ?? []).length > 0 ) return null; diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 711fba4d7..99d0f78e3 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -6,6 +6,7 @@ import { analyzeCached, compile, compileLibrary, + mergeProfiles, resolveProvenanceSources, sourceTargetPlatform, warmNativeCaches, @@ -33,6 +34,7 @@ process.exitCode = await runCli(process.argv.slice(2), { resolveProvenanceSources, sourceTargetPlatform, warmNativeCaches, + mergeProfiles, run: (binary) => new Promise((resolveExit) => { let child; diff --git a/packages/cli/test/output-options.test.ts b/packages/cli/test/output-options.test.ts index ead3280c0..21c5726c7 100644 --- a/packages/cli/test/output-options.test.ts +++ b/packages/cli/test/output-options.test.ts @@ -48,6 +48,9 @@ describe("output option compatibility", () => { [{ emit: "llvm", strip: true }, /strip/], [{ emit: "obj", detachedExit: true }, /detached-exit/], [{ emit: "llvm", detachedExit: true }, /detached-exit/], + [{ 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/], ] as const)("rejects %j", (override, message) => { const result = resolveOutputOptions("build", { ...BASE, ...override }); expect(result).toEqual({ ok: false, message: expect.stringMatching(message) }); diff --git a/packages/cli/test/profile.test.ts b/packages/cli/test/profile.test.ts new file mode 100644 index 000000000..24c759e92 --- /dev/null +++ b/packages/cli/test/profile.test.ts @@ -0,0 +1,120 @@ +import { execFile } from "node:child_process"; +import { createRequire } from "node:module"; +import { mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +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 tsxLoader = join(dirname(require.resolve("tsx/package.json")), "dist/loader.mjs"); + +const PROGRAM = `const n = Number(process.argv[2] ?? "10"); +let total = 0; +for (let i = 0; i < n; i++) total += i % 3 === 0 ? i : 1; +console.log(\`total \${total}\`); +if (process.argv[3] === "exit") process.exit(3); +// Never runs in the profiled runs. +function depth(k: number): number { + return k <= 0 ? 0 : 1 + depth(k - 1); +} +if (process.argv[3] === "depth") console.log(depth(n)); +`; + +async function scriptc(args: string[], env: NodeJS.ProcessEnv) { + return execFileAsync(process.execPath, ["--import", tsxLoader, cliEntry, ...args], { + env, + maxBuffer: 4 * 1024 * 1024, + }); +} + +test.skipIf(process.platform !== "linux")( + "instrumented runs write profiles that merge and optimize the program, stale or not", + async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-profile-")); + const env = { ...process.env, SCRIPTC_CACHE_DIR: join(dir, "cache") }; + const entry = join(dir, "main.ts"); + const raw = join(dir, "raw"); + try { + await writeFile(entry, PROGRAM); + const instrumented = join(dir, "instrumented"); + await scriptc( + ["build", entry, "--profile-generate", join(raw, "%p.profraw"), "-o", instrumented], + env, + ); + expect((await execFileAsync(instrumented, ["1000"])).stdout).toBe("total 167499\n"); + // process.exit leaves through _Exit, which skips the profile runtime's + // atexit writer; the runtime writes the profile itself. + await expect(execFileAsync(instrumented, ["1000", "exit"])).rejects.toMatchObject({ + code: 3, + stdout: "total 167499\n", + }); + const profiles = (await readdir(raw)).filter((name) => name.endsWith(".profraw")); + expect(profiles).toHaveLength(2); + + const merged = join(dir, "app.profdata"); + const merge = await scriptc( + ["merge-profiles", "-o", merged, ...profiles.map((name) => join(raw, name))], + env, + ); + expect(merge.stdout).toBe(`${merged}\n`); + + const optimized = join(dir, "optimized"); + await scriptc(["build", entry, "--profile-use", merged, "-o", optimized], env); + expect((await execFileAsync(optimized, ["1000"])).stdout).toBe("total 167499\n"); + + // A sparse profile leaves out the runtime functions the runs never + // reached. + const sparse = join(dir, "sparse.profdata"); + await scriptc( + ["merge-profiles", "--sparse", "-o", sparse, ...profiles.map((name) => join(raw, name))], + env, + ); + expect((await stat(sparse)).size).toBeLessThan((await stat(merged)).size); + 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. + await writeFile(entry, PROGRAM + 'console.log("edited");\n'); + await scriptc(["build", entry, "--profile-use", merged, "-o", optimized], env); + expect((await execFileAsync(optimized, ["1000"])).stdout).toBe("total 167499\nedited\n"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + 300_000, +); + +test("profile options are refused where they cannot apply", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-profile-refusal-")); + const env = { ...process.env, SCRIPTC_CACHE_DIR: join(dir, "cache") }; + const entry = join(dir, "main.ts"); + try { + await writeFile(entry, PROGRAM); + for (const [args, message] of [ + [["build", entry, "--emit=llvm", "--profile-use", "a.profdata"], "--emit=exe"], + [ + ["build", entry, "--profile-generate", "%p.profraw", "--profile-use", "a.profdata"], + "cannot be combined", + ], + [["coverage", entry, "--profile-use", "a.profdata"], "build/run option"], + [["merge-profiles", "a.profraw"], "merge-profiles takes -o"], + [["merge-profiles", "-o", "out.profdata"], "merge-profiles takes -o"], + [["build", entry, "--sparse"], "--sparse is a merge-profiles option"], + [ + ["build", entry, "--optimization=dev", "--profile-use", join(dir, "a.profdata")], + "optimized build", + ], + [["build", entry, "--profile-use", join(dir, "missing.profdata")], "cannot read"], + ] as const) { + await expect(scriptc([...args], env)).rejects.toMatchObject({ + stderr: expect.stringContaining(message), + }); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } +}, 120_000); diff --git a/packages/compiler/src/backend/link-plan-core.ts b/packages/compiler/src/backend/link-plan-core.ts index 2e213d825..de5da1657 100644 --- a/packages/compiler/src/backend/link-plan-core.ts +++ b/packages/compiler/src/backend/link-plan-core.ts @@ -7,6 +7,7 @@ import { type NativeTargetSpec, type WindowsSubsystem, } from "./targets.js"; +import { profileGenerateLinkerArgs } from "./native-profile.js"; export interface ExecutableLinkInputs { inputs: string[]; @@ -28,6 +29,8 @@ export function executableLinkInputs(options: { optimization: NativeOptimization; strip?: boolean; windowsSubsystem?: WindowsSubsystem; + /** Instrumented for profile-guided optimization. */ + profileGenerate?: boolean; }): ExecutableLinkInputs { if (options.ffiFrameworks.length !== 0 && options.target.platform !== "darwin") throw new Error("FFI frameworks require a Darwin target"); @@ -63,6 +66,7 @@ export function executableLinkInputs(options: { ...executableOptimizationLinkerArgs(options.target.platform, options.optimization), ...executableStripLinkerArgs(options.target.platform, options.strip ?? false), ...windowsSubsystemLinkerArgs(options.target.platform, options.windowsSubsystem), + ...profileGenerateLinkerArgs(options.profileGenerate ?? false), ], }; } diff --git a/packages/compiler/src/backend/link-plan.ts b/packages/compiler/src/backend/link-plan.ts index baf2f516b..f0be965be 100644 --- a/packages/compiler/src/backend/link-plan.ts +++ b/packages/compiler/src/backend/link-plan.ts @@ -40,6 +40,7 @@ export async function createNativeLinkPlan(options: { optimization: NativeOptimization; strip?: boolean; windowsSubsystem?: WindowsSubsystem; + profileGenerate?: boolean; programObjectDependencies?: readonly NativeArtifactDependency[]; env?: NodeJS.ProcessEnv; resolver?: (specifier: string) => string; @@ -72,6 +73,7 @@ export async function createNativeLinkPlan(options: { ...(options.windowsSubsystem === undefined ? {} : { windowsSubsystem: options.windowsSubsystem }), + ...(options.profileGenerate === true ? { profileGenerate: true } : {}), }), dependencyPaths: [...runtimePack.dependencyPaths, ...(options.ffi?.libraries ?? [])], programObjectDependencies: [...(options.programObjectDependencies ?? [])], diff --git a/packages/compiler/src/backend/native-codegen.test.ts b/packages/compiler/src/backend/native-codegen.test.ts index 16704cbc8..21c74e51c 100644 --- a/packages/compiler/src/backend/native-codegen.test.ts +++ b/packages/compiler/src/backend/native-codegen.test.ts @@ -192,6 +192,34 @@ test("runtime bitcode imports reach the helper and key the native cache", async expect(calls[1]).toContain("--import-bitcode /pack/a.bc --import-bitcode /pack/b.bc"); }); +test("profiles reach the helper and key the native cache by their bytes", async () => { + const pkg = await fakePackage(); + const base = request(pkg.root, pkg.packageJson, join(pkg.root, "plain.o")); + await emitNativeArtifact(base); + await emitNativeArtifact({ + ...base, + outputPath: join(pkg.root, "instrumented.o"), + profile: { generate: "/profiles/%p.profraw" }, + }); + const used = { + ...base, + outputPath: join(pkg.root, "used.o"), + profile: { use: { path: "/profiles/a.profdata", digest: "a".repeat(64) } }, + }; + await emitNativeArtifact(used); + await emitNativeArtifact({ + ...used, + outputPath: join(pkg.root, "changed.o"), + profile: { use: { path: "/profiles/a.profdata", digest: "b".repeat(64) } }, + }); + await emitNativeArtifact({ ...used, outputPath: join(pkg.root, "again.o") }); + const calls = (await readFile(pkg.log, "utf8")).trim().split("\n"); + expect(calls).toHaveLength(4); + expect(calls[0]).not.toContain("--profile-"); + expect(calls[1]).toContain("--profile-generate /profiles/%p.profraw"); + expect(calls[2]).toContain("--profile-use /profiles/a.profdata"); +}); + 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")); diff --git a/packages/compiler/src/backend/native-codegen.ts b/packages/compiler/src/backend/native-codegen.ts index 6aab5880a..ecfe360f9 100644 --- a/packages/compiler/src/backend/native-codegen.ts +++ b/packages/compiler/src/backend/native-codegen.ts @@ -27,6 +27,11 @@ import { type NativeTargetSpec, } from "./targets.js"; import { compilerReleaseVersion } from "../library/sidecar.js"; +import { + nativeProfileHelperArgs, + nativeProfileKey, + type NativeProfileOptions, +} from "./native-profile.js"; import { writeTextParts } from "../text-parts.js"; import { @@ -86,6 +91,8 @@ export interface NativeCodegenOptions { partitions?: number; /** Runtime bitcode whose small functions the helper imports for inlining. */ importBitcode?: { paths: readonly string[]; digests: readonly string[] }; + /** Profile-guided optimization: instrumentation or an indexed profile. */ + profile?: NativeProfileOptions; } /** Writes LLVM text in large batches; see `writeTextParts`. */ @@ -298,6 +305,7 @@ function cacheKey( ? "" : `\0import:${options.importBitcode.digests.join(",")}`, ) + .update(options.profile === undefined ? "" : `\0${nativeProfileKey(options.profile)}`) .digest("hex"); } @@ -319,6 +327,31 @@ async function installVerifiedCache(source: string, destination: string): Promis } } +/** `scriptc merge-profiles`: merges raw or indexed IR profiles into one + * indexed profile for `--profile-use`. */ +export async function mergeNativeProfiles( + outputPath: string, + inputPaths: readonly string[], + sparse = false, +): Promise { + const target = nativeCodegenTarget(); + if (target === null) { + throw new NativeCodegenError( + "SC3002", + nativeCodegenTargetRefusal() ?? "profile merging is unsupported for this target", + "unsupported_target", + ); + } + const helper = await resolveHelper(target); + await invoke(helper.binaryPath, [ + "merge-profiles", + "--output", + outputPath, + ...(sparse ? ["--sparse"] : []), + ...inputPaths, + ]); +} + export async function emitNativeArtifact( options: NativeCodegenOptions, ): Promise { @@ -411,6 +444,7 @@ export async function emitNativeArtifact( options.sourcePath, ...(options.importBitcode?.paths ?? []).flatMap((path) => ["--import-bitcode", path]), ...(partitionCache === null ? [] : ["--cache-dir", partitionCache]), + ...nativeProfileHelperArgs(options.profile), ]); for (const stage of stages) { const emitted = await stat(stage).catch(() => null); diff --git a/packages/compiler/src/backend/native-profile.test.ts b/packages/compiler/src/backend/native-profile.test.ts new file mode 100644 index 000000000..590c39eef --- /dev/null +++ b/packages/compiler/src/backend/native-profile.test.ts @@ -0,0 +1,73 @@ +import { expect, test } from "vitest"; +import { executableLinkInputs } from "./link-plan-core.js"; +import { + nativeProfileHelperArgs, + nativeProfileKey, + nativeProfileRefusal, + profileGenerateLinkerArgs, +} from "./native-profile.js"; +import { NATIVE_TARGETS } from "./targets.js"; + +const BASE = { + outputKind: "exe", + optimization: "speed", + sanitize: false, + platform: "linux", +} as const; + +test("profiles are accepted for optimized executables", () => { + expect(nativeProfileRefusal(BASE)).toBeNull(); + expect(nativeProfileRefusal({ ...BASE, profileGenerate: "/p/%p.profraw" })).toBeNull(); + expect(nativeProfileRefusal({ ...BASE, profileUse: "/p/a.profdata" })).toBeNull(); + expect( + nativeProfileRefusal({ ...BASE, optimization: "release", profileUse: "/p/a.profdata" }), + ).toBeNull(); + expect( + nativeProfileRefusal({ ...BASE, platform: "darwin", profileUse: "/p/a.profdata" }), + ).toBeNull(); +}); + +test.each([ + [{ profileGenerate: "/p/%p.profraw", profileUse: "/p/a.profdata" }, /cannot be combined/], + [{ profileUse: "/p/a.profdata", outputKind: "obj" }, /--profile-use is only supported/], + [{ profileGenerate: "/p/%p.profraw", sanitize: true }, /--sanitize/], + [{ profileUse: "/p/a.profdata", optimization: "dev" }, /optimized build/], + [{ profileGenerate: "/p/%p.profraw", platform: "darwin" }, /Linux executables/], + [{ profileGenerate: "/p/%p.profraw", platform: "wasi" }, /Linux executables/], +] as const)("refuses %j", (override, message) => { + expect(nativeProfileRefusal({ ...BASE, ...override })).toMatch(message); +}); + +test("helper arguments and cache keys name the profile", () => { + expect(nativeProfileHelperArgs(undefined)).toEqual([]); + expect(nativeProfileKey(undefined)).toBe(""); + expect(nativeProfileHelperArgs({ generate: "/p/%p.profraw" })).toEqual([ + "--profile-generate", + "/p/%p.profraw", + ]); + expect(nativeProfileKey({ generate: "/p/%p.profraw" })).toBe("profile-generate:/p/%p.profraw"); + const use = { use: { path: "/p/a.profdata", digest: "d".repeat(64) } }; + expect(nativeProfileHelperArgs(use)).toEqual(["--profile-use", "/p/a.profdata"]); + expect(nativeProfileKey(use)).toBe(`profile-use:${"d".repeat(64)}`); +}); + +test("only the instrumented link adds the profile runtime", () => { + expect(profileGenerateLinkerArgs(false)).toEqual([]); + expect(profileGenerateLinkerArgs(true)).toEqual(["-fprofile-generate"]); + const target = NATIVE_TARGETS.find((candidate) => candidate.platform === "linux")!; + const inputs = { + target, + programObject: "/p/program.o", + ffiLibraries: [], + ffiSystemLibraries: [], + ffiFrameworks: [], + runtimeObjects: [], + runtimeArchives: [], + runtimeSystemLibraries: [], + optimization: "speed", + } as const; + expect(executableLinkInputs(inputs).driverFlags).not.toContain("-fprofile-generate"); + expect(executableLinkInputs({ ...inputs, profileGenerate: true }).driverFlags).toContain( + "-fprofile-generate", + ); +}); diff --git a/packages/compiler/src/backend/native-profile.ts b/packages/compiler/src/backend/native-profile.ts new file mode 100644 index 000000000..4cd766319 --- /dev/null +++ b/packages/compiler/src/backend/native-profile.ts @@ -0,0 +1,57 @@ +/** Profile-guided optimization for helper-compiled executables. + * + * An instrumented build (`--profile-generate`) counts how often each branch + * and call runs and writes the counts when the program exits. `scriptc + * merge-profiles` turns those raw profiles into one indexed profile, and an + * optimized build (`--profile-use`) lays out, inlines, and splits code by it. + * The helper applies both in the pre-link pipeline of every partition. + */ +import type { NativeOptimization } from "./optimization.js"; + +export interface NativeProfileOptions { + /** Raw profile path pattern written by the instrumented executable. */ + generate?: string; + /** Indexed profile and the digest of its bytes, which key every cache. */ + use?: { path: string; digest: string }; +} + +/** Why a request cannot use profiles, or null when it can. */ +export function nativeProfileRefusal(options: { + profileGenerate?: string; + profileUse?: string; + outputKind: string; + optimization: NativeOptimization; + sanitize: boolean; + platform: string; +}): string | null { + const generate = options.profileGenerate !== undefined; + const use = options.profileUse !== undefined; + if (!generate && !use) return null; + const flag = generate ? "--profile-generate" : "--profile-use"; + if (generate && use) return "--profile-generate and --profile-use cannot be combined"; + if (options.outputKind !== "exe") return `${flag} is only supported for executable output`; + if (options.sanitize) return `${flag} cannot be combined with --sanitize`; + if (options.optimization === "dev") + return `${flag} needs an optimized build (--optimization=release or speed)`; + if (generate && options.platform !== "linux") + return "--profile-generate is only supported for Linux executables"; + return null; +} + +export function nativeProfileHelperArgs(profile: NativeProfileOptions | undefined): string[] { + if (profile?.generate !== undefined) return ["--profile-generate", profile.generate]; + if (profile?.use !== undefined) return ["--profile-use", profile.use.path]; + return []; +} + +/** 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}`; + if (profile?.use !== undefined) return `profile-use:${profile.use.digest}`; + return ""; +} + +/** The instrumented link adds the compiler driver's profile runtime. */ +export function profileGenerateLinkerArgs(generate: boolean): string[] { + return generate ? ["-fprofile-generate"] : []; +} diff --git a/packages/compiler/src/backend/native-tools.ts b/packages/compiler/src/backend/native-tools.ts index d9d47ba9f..2f1b88ed0 100644 --- a/packages/compiler/src/backend/native-tools.ts +++ b/packages/compiler/src/backend/native-tools.ts @@ -8,6 +8,7 @@ import { validateNativeCodegenVersion, type NativeCodegenOutputKind, } from "./native-codegen-core.js"; +import { nativeProfileHelperArgs, type NativeProfileOptions } from "./native-profile.js"; export function runNativeTool( executable: string, @@ -45,6 +46,8 @@ export function emitNativeObject(options: { partitions?: number; /** Private directory where the helper reuses partition artifacts. */ partitionCache?: string; + /** Profile-guided optimization: instrumentation or an indexed profile. */ + profile?: NativeProfileOptions; }): string[] { verifyNativeHelper(options); const outputs = nativePartitionPaths(options.outputPath, options.partitions ?? 1); @@ -68,11 +71,33 @@ export function emitNativeObject(options: { ...(options.partitionCache === undefined || outputs.length < 2 ? [] : ["--cache-dir", options.partitionCache]), + ...nativeProfileHelperArgs(options.profile), ]); for (const output of outputs) requireNativeArtifact(output); return outputs; } +/** Merges raw or indexed IR profiles into one indexed profile. */ +export function mergeNativeProfiles(options: { + executable: string; + packageRoot: string; + compilerVersion: string; + target: NativeTargetSpec; + helper: NativeHelperSpec; + outputPath: string; + inputPaths: readonly string[]; + sparse: boolean; +}): void { + verifyNativeHelper(options); + runNativeTool(options.executable, [ + "merge-profiles", + "--output", + options.outputPath, + ...(options.sparse ? ["--sparse"] : []), + ...options.inputPaths, + ]); +} + /** Verify installed identity even when a caller can reuse a cached object. */ export function verifyNativeHelper(options: { executable: string; diff --git a/packages/compiler/src/cli/command.ts b/packages/compiler/src/cli/command.ts index 79442f19e..03160c6fa 100644 --- a/packages/compiler/src/cli/command.ts +++ b/packages/compiler/src/cli/command.ts @@ -95,6 +95,9 @@ async function main(args: string[], host: CliHost): Promise { values.strip || values["windows-subsystem"] !== undefined || values["detached-exit"] || + values["profile-generate"] !== undefined || + values["profile-use"] !== undefined || + values.sparse || (values["npm-static"] ?? []).length > 0 || values["provenance-sources"] || externalTypeArgs.length > 0 || @@ -135,6 +138,50 @@ async function main(args: string[], host: CliHost): Promise { } return 0; } + if (command === "merge-profiles") { + const inputs = positionals.slice(1); + const others = + values.lib || + values.backend !== undefined || + values.emit !== undefined || + values.print !== undefined || + values["fail-on"] !== undefined || + values.optimization !== undefined || + values.ffi !== undefined || + values.profile !== undefined || + values.strip || + values.sanitize || + values["windows-subsystem"] !== undefined || + values["detached-exit"] || + values["profile-generate"] !== undefined || + values["profile-use"] !== undefined || + (values["npm-static"] ?? []).length > 0 || + values["provenance-sources"] || + externalTypeArgs.length > 0 || + (values.wasm ?? []).length > 0 || + values["emit-ir"] || + values.tier !== undefined || + values["explain-dynamic"] !== undefined || + !values["keep-llvm"]; + if (values.out === undefined || inputs.length === 0 || others) { + fail( + `scriptc merge-profiles takes -o , optionally --sparse, and one or more profiles\n\n${USAGE}`, + ); + } + const outputPath = resolve(values.out); + try { + await host.mergeProfiles( + outputPath, + inputs.map((input) => resolve(input)), + values.sparse, + ); + } catch (error) { + fail(`scriptc: ${error instanceof Error ? error.message : String(error)}`); + } + process.stdout.write(`${outputPath}\n`); + return 0; + } + if (values.sparse) fail(`--sparse is a merge-profiles option\n\n${USAGE}`); if (command !== "build" && command !== "run" && command !== "coverage") { fail(`unknown command "${command}"\n\n${USAGE}`); } @@ -162,13 +209,15 @@ async function main(args: string[], host: CliHost): Promise { values.ffi !== undefined || values["windows-subsystem"] !== undefined || values["detached-exit"] || + values["profile-generate"] !== undefined || + values["profile-use"] !== undefined || (values["npm-static"] ?? []).length > 0 || externalTypeArgs.length > 0 || values.tier !== undefined || values["explain-dynamic"] !== undefined ) { fail( - "scriptc build --lib takes no --backend/--emit/--print/--optimization/--strip/--windows-subsystem/--detached-exit/--npm-static/--ffi/--external-types/--tier/--explain-dynamic: the profile pins the emission and optimization, npm imports are judged automatically, outbound FFI belongs to executable builds, external type mappings belong to coverage, and library artifacts are static-tier only", + "scriptc build --lib takes no --backend/--emit/--print/--optimization/--strip/--windows-subsystem/--detached-exit/--profile-generate/--profile-use/--npm-static/--ffi/--external-types/--tier/--explain-dynamic: the profile pins the emission and optimization, npm imports are judged automatically, outbound FFI belongs to executable builds, external type mappings belong to coverage, and library artifacts are static-tier only", ); } const profilePath = resolve(profileArg); @@ -214,6 +263,12 @@ async function main(args: string[], host: CliHost): Promise { if (command === "coverage" && values["detached-exit"]) { fail(`--detached-exit is a build/run option\n\n${USAGE}`); } + if (command === "coverage" && values["profile-generate"] !== undefined) { + fail(`--profile-generate is a build/run option\n\n${USAGE}`); + } + if (command === "coverage" && values["profile-use"] !== undefined) { + fail(`--profile-use is a build/run option\n\n${USAGE}`); + } if ( values.print !== undefined && values.print !== "native-link-info" && @@ -323,6 +378,10 @@ async function main(args: string[], host: CliHost): Promise { ...(values.optimization === undefined ? {} : { optimization: values.optimization }), strip: values.strip, detachedExit: values["detached-exit"], + ...(values["profile-generate"] === undefined + ? {} + : { profileGenerate: values["profile-generate"] }), + ...(values["profile-use"] === undefined ? {} : { profileUse: values["profile-use"] }), ...(windowsSubsystem === undefined ? {} : { windowsSubsystem }), ...(values.ffi === undefined ? {} : { ffi: values.ffi }), }); @@ -425,6 +484,14 @@ async function main(args: string[], host: CliHost): Promise { ...(values.strip ? { strip: true } : {}), ...(windowsSubsystem !== undefined ? { windowsSubsystem } : {}), ...(values["detached-exit"] ? { detachedExit: true } : {}), + // The instrumented program resolves a relative pattern against its own + // working directory; resolve it here, where the user wrote it. + ...(values["profile-generate"] === undefined + ? {} + : { profileGenerate: resolve(values["profile-generate"]) }), + ...(values["profile-use"] === undefined + ? {} + : { profileUse: resolve(values["profile-use"]) }), ...(npmStatic !== undefined ? { npmStatic } : {}), ...(ffiProfilePath !== undefined ? { ffiProfilePath } : {}), ...(printNativeLinkInfo ? { nativeLinkInfo: true } : {}), diff --git a/packages/compiler/src/cli/host.ts b/packages/compiler/src/cli/host.ts index cb83e95d5..7a927ee39 100644 --- a/packages/compiler/src/cli/host.ts +++ b/packages/compiler/src/cli/host.ts @@ -27,5 +27,8 @@ export interface CliHost { cacheRoot: string; profiles: { profile: NativeCacheWarmProfile; elapsedMs: number }[]; }>; + /** Merges raw or indexed profiles into one indexed profile; a sparse one + * leaves out functions that never ran. */ + mergeProfiles: (outputPath: string, inputPaths: string[], sparse: boolean) => Promise; run: (binary: string) => Promise; } diff --git a/packages/compiler/src/cli/output-options.ts b/packages/compiler/src/cli/output-options.ts index 938777526..eb8f9a8e6 100644 --- a/packages/compiler/src/cli/output-options.ts +++ b/packages/compiler/src/cli/output-options.ts @@ -11,6 +11,8 @@ export interface OutputOptionValues { strip?: boolean; windowsSubsystem?: string; detachedExit?: boolean; + profileGenerate?: string; + profileUse?: string; ffi?: string; } @@ -73,6 +75,15 @@ export function resolveOutputOptions( if (values.detachedExit && emit !== "exe") { return { ok: false, message: `--detached-exit is only supported with --emit=exe` }; } + if (values.profileGenerate !== undefined && values.profileUse !== undefined) { + return { ok: false, message: `--profile-generate and --profile-use cannot be combined` }; + } + if (values.profileGenerate !== undefined && emit !== "exe") { + return { ok: false, message: `--profile-generate is only supported with --emit=exe` }; + } + if (values.profileUse !== undefined && emit !== "exe") { + return { ok: false, message: `--profile-use is only supported with --emit=exe` }; + } if (emit === "ir" && backend !== undefined) { return { ok: false, diff --git a/packages/compiler/src/cli/usage.ts b/packages/compiler/src/cli/usage.ts index 26b439167..aa7408d2a 100644 --- a/packages/compiler/src/cli/usage.ts +++ b/packages/compiler/src/cli/usage.ts @@ -15,6 +15,13 @@ Usage: contract sidecar JSON beside the archive scriptc cache warm [runtime|tls…] verify and load precompiled runtime families for the current compiler/SDK/target + scriptc merge-profiles -o [--sparse] + merge the raw profiles that + --profile-generate runs wrote (or + earlier merged profiles) into one + profile for --profile-use; + --sparse leaves out functions + that never ran (a smaller file) Options: -o, --out primary output path (default: .scriptc/) @@ -46,6 +53,19 @@ Options: a helper process so the parent sees the exit sooner. The helper keeps the executable mapped and its memory charged for up to ~100 ms after exit (opt in; --emit=exe only) + --profile-generate + Linux: instrument the executable for profile-guided + optimization. Each run writes a raw profile to the + pattern (%p expands to the process id; the + LLVM_PROFILE_FILE environment variable overrides it). + Instrumented programs run slower (--emit=exe only) + --profile-use + optimize with a merged profile (scriptc merge-profiles): + code is laid out, inlined and split by how the profiled + runs used it. Code the profile does not describe, or + describes from an older version of the program, is + optimized as without one (release or speed; --emit=exe + only) --keep-llvm keep generated LLVM IR beside the executable (default) --no-keep-llvm delete generated LLVM IR after compiling --emit-ir also write IR beside an executable or library archive; @@ -99,6 +119,9 @@ export const CLI_OPTIONS = { strip: { type: "boolean", default: false }, "windows-subsystem": { type: "string" }, "detached-exit": { type: "boolean", default: false }, + "profile-generate": { type: "string" }, + "profile-use": { type: "string" }, + sparse: { type: "boolean", default: false }, "keep-llvm": { type: "boolean", default: true }, "emit-ir": { type: "boolean", default: false }, sanitize: { type: "boolean", default: false }, diff --git a/packages/compiler/src/compile-types.ts b/packages/compiler/src/compile-types.ts index 2773af71b..9ecd75974 100644 --- a/packages/compiler/src/compile-types.ts +++ b/packages/compiler/src/compile-types.ts @@ -36,6 +36,15 @@ export interface CompileBaseOptions { * executable mapped and the memory charged for up to ~100 ms after exit. * Executables only; no effect on other platforms. */ detachedExit?: boolean; + /** Instrument the executable for profile-guided optimization. Each run + * writes a raw profile to this path pattern (`%p` expands to the process + * id; `LLVM_PROFILE_FILE` overrides the pattern at run time). Linux + * executables only. */ + profileGenerate?: string; + /** Optimize the executable with this indexed profile (`scriptc + * merge-profiles`). Code the profile does not describe, or describes + * from an older version of the program, is optimized as without one. */ + profileUse?: string; /** Eligible packages compile automatically. Explicit names override automatic selection heuristics. */ npmStatic?: readonly string[] | "auto"; diff --git a/packages/compiler/src/executable/executable-cache.test.ts b/packages/compiler/src/executable/executable-cache.test.ts index ae3c3b7eb..1271eadd2 100644 --- a/packages/compiler/src/executable/executable-cache.test.ts +++ b/packages/compiler/src/executable/executable-cache.test.ts @@ -223,6 +223,8 @@ test("early executable keys isolate compile modes, paths, implementation, checke { ...f.options, optimization: "dev" }, { ...f.options, optimization: "speed" }, { ...f.options, detachedExit: true }, + { ...f.options, profile: "profile-generate:/tmp/%p.profraw" }, + { ...f.options, profile: "profile-use:digest" }, { ...f.options, checker: "tsc-ts-executable-identity" }, { ...f.options, npmStatic: "auto" }, { ...f.options, npmStatic: ["commander"] }, diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index 8f7014dad..f9d4688ae 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -130,6 +130,9 @@ export interface EarlyExecutableCacheOptions { windowsSubsystem?: "gui"; /** Omitted unless --detached-exit is set. */ detachedExit?: true; + /** Omitted without --profile-generate/--profile-use; otherwise the + * pattern, or the digest of the profile's bytes. */ + profile?: string; /** Omitted for the default tsgo checker; with SCRIPTC_CHECKER=tsc-ts, the * tsc-ts executable's file identity. */ checker?: string; @@ -254,6 +257,7 @@ function cacheKey(options: EarlyExecutableCacheOptions): string { ...(options.strip ? ["strip"] : []), ...(options.windowsSubsystem === "gui" ? ["windows-subsystem-gui"] : []), ...(options.detachedExit ? ["detached-exit"] : []), + ...(options.profile !== undefined ? [options.profile] : []), ...(options.checker !== undefined ? [`checker:${options.checker}`] : []), ...(options.youngNursery ? ["young-nursery"] : []), options.npmStatic === null @@ -295,6 +299,7 @@ function routeKey(options: Omit[0]["onArtifactReady"]>, programPartitions: readonly string[] = [], + profileGenerate = false, ): Promise { const programIsObject = /\.(?:o|obj)$/.test(llvmPath); const runtimePackTarget = programIsObject && !sanitize ? nativeCodegenTarget() : null; @@ -492,6 +500,7 @@ async function compileExecutableNative( optimization: features.optimization ?? "release", ...(strip ? { strip: true } : {}), ...(windowsSubsystem === undefined ? {} : { windowsSubsystem }), + ...(profileGenerate ? { profileGenerate: true } : {}), programObjectDependencies, }); const cacheableLinker = @@ -603,6 +612,7 @@ async function emitNativeProgramObject( opts: CompileRequestOptions, llvm: string | readonly string[], features: EarlyExecutableNativeFeatures, + profile: NativeProfileOptions | undefined, ): Promise { const stem = basename(entryPath).replace(/\.(ts|mts|cts|js|mjs|cjs)$/, ""); const artifactPath = join(opts.outDir, `${stem}.helper.o`); @@ -648,6 +658,7 @@ async function emitNativeProgramObject( ...(runtimeBitcode === null ? {} : { importBitcode: { paths: runtimeBitcode.paths, digests: runtimeBitcode.digests } }), + ...(profile === undefined ? {} : { profile }), }); return { linkPath, @@ -670,6 +681,25 @@ async function removeNativeProgramObject( ); } +/** The request's profile. An indexed profile is read here so every cache keys + * its bytes. */ +async function readNativeProfile( + opts: CompileRequestOptions, +): Promise { + if (opts.profileGenerate !== undefined) return { generate: opts.profileGenerate }; + if (opts.profileUse === undefined) return undefined; + const path = resolve(opts.profileUse); + let bytes: Buffer; + try { + bytes = await readFile(path); + } catch (err) { + throw new Error( + `cannot read --profile-use file ${path}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + return { use: { path, digest: createHash("sha256").update(bytes).digest("hex") } }; +} + function usesPrecompiledRuntimePack(opts: CompileRequestOptions, backend: "llvm"): boolean { if (backend !== "llvm" || opts.sanitize === true) return false; return nativeCodegenTarget() !== null; @@ -1047,6 +1077,26 @@ async function compileTracked( sourceTexts: new Map(), }; } + const profileRefusal = nativeProfileRefusal({ + ...opts, + outputKind, + optimization: opts.optimization ?? "release", + sanitize: opts.sanitize ?? false, + platform: buildPlatform, + }); + let profile: NativeProfileOptions | undefined; + try { + if (profileRefusal !== null) throw new Error(profileRefusal); + profile = await readNativeProfile(opts); + } catch (err) { + return { + ok: false, + diagnostics: [ + nativeCodegenDiag("SC3002", err instanceof Error ? err.message : String(err), entryPath), + ], + sourceTexts: new Map(), + }; + } const cacheRoot = outputKind === "exe" && provenanceSources() === null ? await prepareBuildCacheRoot(buildCacheRoot()) @@ -1075,6 +1125,7 @@ async function compileTracked( ...(opts.strip ? { strip: true as const } : {}), ...(opts.windowsSubsystem === "gui" ? { windowsSubsystem: "gui" as const } : {}), ...(opts.detachedExit === true ? { detachedExit: true as const } : {}), + ...(profile === undefined ? {} : { profile: nativeProfileKey(profile) }), ...checkerField(), ...(youngNurseryRequested() ? { youngNursery: true as const } : {}), npmStatic: opts.npmStatic ?? null, @@ -1148,6 +1199,7 @@ async function compileTracked( opts, await readFile(earlyHit.llvmPath, "utf8"), earlyHit.native, + profile, ); nativeInputPath = nativeProgramObject.linkPath; } catch (err) { @@ -1181,6 +1233,7 @@ async function compileTracked( }); }, nativeProgramObject?.partitionPaths, + profile?.generate !== undefined, ); if (nativeProgramObject !== null && opts.nativeProgramObject === true) { await rename(nativeProgramObject.linkPath, nativeProgramObject.artifactPath); @@ -1260,6 +1313,7 @@ async function compileTracked( opts, llvmSource, nativeFeatures, + profile, ); timing("native-object"); } catch (err) { @@ -1295,6 +1349,7 @@ async function compileTracked( publishedExecutable = true; }, nativeProgramObject?.partitionPaths, + profile?.generate !== undefined, ); timing("native-link"); if (nativeProgramObject !== null && opts.nativeProgramObject === true) { diff --git a/packages/compiler/src/native/cli.ts b/packages/compiler/src/native/cli.ts index 4acb818a7..d7520ae0d 100644 --- a/packages/compiler/src/native/cli.ts +++ b/packages/compiler/src/native/cli.ts @@ -8,6 +8,7 @@ import { selectNativeRuntimePack, stageNativeRuntimeSelection, } from "../backend/runtime-pack-native.js"; +import { mergeNativeProfiles } from "../backend/native-tools.js"; import { setDeclarationRoot } from "../frontend/dts-paths.js"; import { FrontendServices } from "../frontend/services.js"; import { createNativeTs7Api } from "../frontend/ts7/native-api.js"; @@ -121,6 +122,19 @@ async function main(): Promise { } return { cacheRoot: cache.root, profiles }; }, + mergeProfiles: async (outputPath, inputPaths, sparse) => { + const toolchain = getCompiler().toolchain; + mergeNativeProfiles({ + executable: toolchain.helperExecutable, + packageRoot: toolchain.helperPackageRoot, + compilerVersion: toolchain.compilerVersion, + target: toolchain.target, + helper: toolchain.helper, + outputPath, + inputPaths, + sparse, + }); + }, run: async (binary) => { const toolchain = getCompiler().toolchain; let executable = binary; diff --git a/packages/compiler/src/native/compiler.ts b/packages/compiler/src/native/compiler.ts index 63e015f98..d4487aba4 100644 --- a/packages/compiler/src/native/compiler.ts +++ b/packages/compiler/src/native/compiler.ts @@ -57,6 +57,11 @@ import { archiveNativeLibrary, localizeNativeLibrary, linkNativeWasmLibrary } fr import type { NativeToolchain } from "./toolchain.js"; import { evaluateNativeComptime } from "./comptime.js"; import { contentDigest, type NativeCache } from "./cache.js"; +import { + nativeProfileKey, + nativeProfileRefusal, + type NativeProfileOptions, +} from "../backend/native-profile.js"; import { splitLlvmLibraryProgram } from "../backend/llvm/split.js"; import { prepareNativeExecutable, prepareNativeLibrary } from "./prepare.js"; import { buildSanitizedRuntime, sanitizerDriver, sanitizerFlags } from "./sanitizer.js"; @@ -78,6 +83,22 @@ function failure(error: unknown, entry: string, sources: Map): C }; } +/** The request's profile. An indexed profile is read here so every cache keys + * its bytes. */ +function nativeProfile(options: CompileRequestOptions): NativeProfileOptions | undefined { + if (options.profileGenerate !== undefined) return { generate: options.profileGenerate }; + if (options.profileUse === undefined) return undefined; + const path = resolve(options.profileUse); + try { + return { use: { path, digest: contentDigest(readFileSync(path)) } }; + } catch (error) { + throw new NativeCodegenError( + "SC3002", + `cannot read --profile-use file ${path}: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + /** Shared frontend semantics with native processes for code generation and linking. */ export class NativeCompiler { private readonly frontend: FrontendFactory; @@ -146,6 +167,7 @@ export class NativeCompiler { optimization: NativeOptimization, outputKind: "obj" | "asm", partitions = 1, + profile?: NativeProfileOptions, ): string[] { const toolchain = this.toolchain; const outputs = nativePartitionPaths(output, partitions); @@ -173,6 +195,7 @@ export class NativeCompiler { identity: readFileSync(join(toolchain.helperPackageRoot, "package.json"), "utf8"), version: toolchain.compilerVersion, ...(partitions > 1 ? { partitions } : {}), + ...(profile === undefined ? {} : { profile: nativeProfileKey(profile) }), }), ); const cached = outputs.map((_, index) => @@ -207,6 +230,7 @@ export class NativeCompiler { outputKind, partitions, ...(partitionCache === null ? {} : { partitionCache }), + ...(profile === undefined ? {} : { profile }), }); if (this.cache !== null && key !== null) { try { @@ -228,6 +252,7 @@ export class NativeCompiler { optimization: NativeOptimization, stage: string, library: boolean, + profile?: NativeProfileOptions, ): string[] { // Library archives merge dev shards relocatably; executables link the // helper's program partitions directly. @@ -238,7 +263,7 @@ export class NativeCompiler { const split = llvm === null ? null : splitLlvmLibraryProgram(llvm); if (split === null) { const partitions = library ? 1 : nativeProgramPartitions(statSync(input).size); - return this.emitObject(input, output, source, optimization, "obj", partitions); + return this.emitObject(input, output, source, optimization, "obj", partitions, profile); } const objects: string[] = []; for (const [index, shard] of split.shards.entries()) { @@ -292,6 +317,15 @@ export class NativeCompiler { ); } if (options.sanitize) sanitizerDriver(toolchain); + const profileRefusal = nativeProfileRefusal({ + ...options, + outputKind, + optimization, + sanitize: options.sanitize ?? false, + platform: target.platform, + }); + if (profileRefusal !== null) throw new NativeCodegenError("SC3002", profileRefusal); + const profile = nativeProfile(options); let ffi: FfiProfile | null = null; if (options.ffiProfilePath !== undefined) { const loaded = loadFfiProfile(resolve(options.ffiProfilePath)); @@ -372,6 +406,7 @@ export class NativeCompiler { optimization, stage, false, + profile, ); else this.emitObject( @@ -432,6 +467,7 @@ export class NativeCompiler { ...(options.windowsSubsystem === undefined ? {} : { windowsSubsystem: options.windowsSubsystem }), + ...(profile?.generate === undefined ? {} : { profileGenerate: true }), }); const linkArgs = [ ...(options.sanitize ? ["-fsanitize=address"] : toolchain.linkerArgs), diff --git a/packages/compiler/src/native/executable-cache.ts b/packages/compiler/src/native/executable-cache.ts index a575e9b8d..a122fb759 100644 --- a/packages/compiler/src/native/executable-cache.ts +++ b/packages/compiler/src/native/executable-cache.ts @@ -265,6 +265,9 @@ export function openNativeExecutableCache( cache === null || toolchain.target.platform !== "darwin" || options.sanitize || + // The key names a profile by path, not by its bytes. + options.profileGenerate !== undefined || + options.profileUse !== undefined || process.env["SCRIPTC_LINKER"] !== undefined || process.env["SCRIPTC_LLVM_HELPER"] !== undefined || toolchain.linkerArgs.length !== 0 || diff --git a/packages/compiler/test/native-profile.test.ts b/packages/compiler/test/native-profile.test.ts new file mode 100644 index 000000000..bb51e31a9 --- /dev/null +++ b/packages/compiler/test/native-profile.test.ts @@ -0,0 +1,283 @@ +import { execFile } from "node:child_process"; +import { createRequire } from "node:module"; +import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, test } from "vitest"; +import { nativeCodegenTarget, nativeHelperForTarget } from "../src/backend/targets.js"; + +const execFileAsync = promisify(execFile); +const target = nativeCodegenTarget(); +const helperSpec = target === null ? null : nativeHelperForTarget(target); +const helper = (() => { + if (helperSpec === null || process.platform === "win32") return null; + try { + const packageJson = createRequire(import.meta.url).resolve( + `${helperSpec.packageName}/package.json`, + ); + return join(dirname(packageJson), "bin", "scriptc-llvm-codegen"); + } catch { + return null; + } +})(); +const dirs: string[] = []; + +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +/** clang links the LLVM profile runtime into instrumented executables; hosts + * without it (the no-clang lanes) skip the cases that run one. */ +const profileLinker = await (async () => { + if (helper === null) return null; + const dir = await mkdtemp(join(tmpdir(), "scriptc-profile-probe-")); + try { + await writeFile(join(dir, "probe.c"), "int main(void) { return 0; }\n"); + await execFileAsync("clang", [ + "-fprofile-generate", + join(dir, "probe.c"), + "-o", + join(dir, "probe"), + ]); + return "clang"; + } catch { + return null; + } finally { + await rm(dir, { recursive: true, force: true }); + } +})(); + +const FUNCTIONS = 12; + +/** A program whose functions branch on their argument's parity, so a profile + * records which side each takes. `edit` changes one function's constant. */ +function program(edit = 0): string { + const lines = ["; profiled program"]; + for (let index = 0; index < FUNCTIONS; index++) { + const factor = index === 4 ? index + 3 + edit : index + 3; + lines.push( + `define internal i64 @f${index}(i64 %n) noinline {`, + "entry:", + " %bit = and i64 %n, 1", + " %odd = icmp ne i64 %bit, 0", + " br i1 %odd, label %scaled, label %shifted", + "scaled:", + ` %product = mul i64 %n, ${factor}`, + " br label %done", + "shifted:", + ` %sum = add i64 %n, ${index}`, + " br label %done", + "done:", + " %result = phi i64 [ %product, %scaled ], [ %sum, %shifted ]", + " ret i64 %result", + "}", + ); + } + lines.push( + "define i32 @main(i32 %argc, ptr %argv) {", + "entry:", + " %wide = sext i32 %argc to i64", + " %limit = mul i64 %wide, 100", + " br label %loop", + "loop:", + " %i = phi i64 [ 0, %entry ], [ %next, %loop ]", + " %total = phi i64 [ 0, %entry ], [ %grown, %loop ]", + ...Array.from({ length: FUNCTIONS }, (_, index) => [ + ` %v${index} = call i64 @f${index}(i64 %i)`, + ` %t${index} = add i64 ${index === 0 ? "%total" : `%t${index - 1}`}, %v${index}`, + ]).flat(), + ` %grown = add i64 %t${FUNCTIONS - 1}, 0`, + " %next = add i64 %i, 1", + " %again = icmp ult i64 %next, %limit", + " br i1 %again, label %loop, label %exit", + "exit:", + " %low = urem i64 %grown, 251", + " %code = trunc i64 %low to i32", + " ret i32 %code", + "}", + ); + return lines.join("\n") + "\n"; +} + +function expectedExit(argc: number, edit = 0): number { + let total = 0n; + for (let i = 0n; i < BigInt(argc * 100); i++) + for (let index = 0; index < FUNCTIONS; index++) { + const factor = BigInt(index === 4 ? index + 3 + edit : index + 3); + total += i % 2n === 1n ? i * factor : i + BigInt(index); + } + return Number(total % 251n); +} + +type EmitOptions = { + outputs: number; + name: string; + generate?: string; + use?: string; + cache?: string; +}; + +async function emit(dir: string, source: string, options: EmitOptions): Promise { + const input = join(dir, `${options.name}.ll`); + await writeFile(input, source); + const outputs = Array.from({ length: options.outputs }, (_, index) => + join(dir, `${options.name}.${index}.o`), + ); + await execFileAsync(helper!, [ + "emit", + "--input", + input, + ...outputs.flatMap((output) => ["--output", output]), + "--filetype", + "obj", + "--target", + target!.llvmTriple, + "--opt-level", + "2", + "--relocation-model", + "pic", + "--diagnostic-format", + "json", + "--source-path", + "/src/program.ts", + ...(options.generate === undefined ? [] : ["--profile-generate", options.generate]), + ...(options.use === undefined ? [] : ["--profile-use", options.use]), + ...(options.cache === undefined ? [] : ["--cache-dir", options.cache]), + ]); + return outputs; +} + +async function link(dir: string, objects: string[], name: string, instrumented = false) { + const executable = join(dir, name); + await execFileAsync(instrumented ? profileLinker! : (process.env["CC"] ?? "cc"), [ + ...(instrumented ? ["-fprofile-generate"] : []), + ...objects, + "-o", + executable, + ]); + return executable; +} + +/** Runs the executable with `argc` arguments and returns its exit code. */ +async function run(executable: string, argc: number, env: NodeJS.ProcessEnv = {}) { + try { + await execFileAsync( + executable, + Array.from({ length: argc - 1 }, () => "x"), + { + env: { ...process.env, ...env }, + }, + ); + return 0; + } catch (error) { + return (error as { code: number }).code; + } +} + +async function merge(output: string, inputs: string[]) { + return execFileAsync(helper!, ["merge-profiles", "--output", output, ...inputs]); +} + +/** An instrumented build of `source` run with 1 and 3 arguments, its raw + * profiles merged into one indexed profile. */ +async function collect(dir: string, source: string, outputs: number, name: string) { + const raw = join(dir, `${name}-raw`); + const objects = await emit(dir, source, { outputs, name, generate: join(raw, "%p.profraw") }); + const executable = await link(dir, objects, name, true); + expect(await run(executable, 1)).toBe(expectedExit(1)); + expect(await run(executable, 3)).toBe(expectedExit(3)); + const profiles = (await readdir(raw)).map((file) => join(raw, file)); + expect(profiles).toHaveLength(2); + const profile = join(dir, `${name}.profdata`); + await merge(profile, profiles); + return { profile, raws: profiles }; +} + +async function cacheEntries(cache: string): Promise> { + const entries = new Set(); + for (const bucket of await readdir(cache).catch(() => [])) + for (const name of await readdir(join(cache, bucket)).catch(() => [])) entries.add(name); + return entries; +} + +describe.runIf(helper !== null)("LLVM profile-guided optimization", () => { + test("a profile and instrumentation together are a usage error", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-profile-usage-")); + dirs.push(dir); + await expect( + emit(dir, program(), { + outputs: 1, + name: "both", + generate: join(dir, "%p.profraw"), + use: join(dir, "missing.profdata"), + }), + ).rejects.toMatchObject({ stderr: expect.stringContaining('"code":"usage"') }); + }); + + test("merge-profiles reports an input that is not a profile", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-profile-merge-error-")); + dirs.push(dir); + await writeFile(join(dir, "not-a-profile"), "text\n"); + await expect( + merge(join(dir, "out.profdata"), [join(dir, "not-a-profile")]), + ).rejects.toMatchObject({ stderr: expect.stringContaining("profile_read_failed") }); + }); + + describe.runIf(profileLinker !== null)("with the profile runtime", () => { + for (const outputs of [1, 4]) { + test(`${outputs === 1 ? "one module" : "partitions"}: the merged profile guides emission`, async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-profile-use-")); + dirs.push(dir); + const { profile } = await collect(dir, program(), outputs, "train"); + + const objects = await emit(dir, program(), { outputs, name: "guided", use: profile }); + const executable = await link(dir, objects, "guided"); + expect(await run(executable, 2)).toBe(expectedExit(2)); + + // The profile changes the generated code. Partitions each carry the + // profile variables, and a build whose variables lost to the profile + // runtime's defaults would record a profile emission cannot use. + const plain = await emit(dir, program(), { outputs, name: "plain" }); + const control = await emit(dir, program(), { outputs, name: "control" }); + const bytes = (paths: string[]) => Promise.all(paths.map((path) => readFile(path))); + expect(await bytes(control)).toEqual(await bytes(plain)); + expect(await bytes(objects)).not.toEqual(await bytes(plain)); + }); + } + + test("merge-profiles combines raw and indexed profiles", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-profile-merge-")); + dirs.push(dir); + const { profile, raws } = await collect(dir, program(), 1, "train"); + const combined = join(dir, "combined.profdata"); + await merge(combined, [profile, raws[0]!]); + const objects = await emit(dir, program(), { outputs: 1, name: "guided", use: combined }); + expect(await run(await link(dir, objects, "guided"), 1)).toBe(expectedExit(1)); + }); + + test("a profile of an older program still builds the current one", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-profile-stale-")); + dirs.push(dir); + const { profile } = await collect(dir, program(), 4, "train"); + // f4's constant changed since the profile was collected. + const objects = await emit(dir, program(7), { outputs: 4, name: "edited", use: profile }); + expect(await run(await link(dir, objects, "edited"), 2)).toBe(expectedExit(2, 7)); + }); + + test("the profile keys the partition cache", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-profile-cache-")); + dirs.push(dir); + const { profile } = await collect(dir, program(), 4, "train"); + const cache = join(dir, "cache"); + await emit(dir, program(), { outputs: 4, name: "plain", cache }); + const plain = await cacheEntries(cache); + await emit(dir, program(), { outputs: 4, name: "guided", cache, use: profile }); + const guided = await cacheEntries(cache); + expect(guided.size).toBeGreaterThan(plain.size); + // The same profile again reuses every partition. + await emit(dir, program(), { outputs: 4, name: "again", cache, use: profile }); + expect(await cacheEntries(cache)).toEqual(guided); + }); + }); +}); diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index ab0cc03bb..976847049 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -2100,6 +2100,14 @@ static void scr_detach_teardown(void) { } #endif +#if defined(__linux__) +/* LLVM's profile writer. Instrumented builds (--profile-generate) link the + * profile runtime, which writes the profile from an atexit handler that the + * _Exit below skips, so the exit writes it first. Every other build leaves the + * weak reference null. */ +extern int __llvm_profile_write_file(void) __attribute__((weak)); +#endif + void scr_process_exit(double code) { /* process.exit(code) assigns process.exitCode first, so a code that is * not a safe integer throws its RangeError and the program continues. */ @@ -2123,6 +2131,7 @@ void scr_process_exit(double code) { * expected). scr_init's flush-at-exit is also skipped — flush here. */ fflush(stdout); #if defined(__linux__) + if (__llvm_profile_write_file != NULL) (void)__llvm_profile_write_file(); scr_detach_teardown(); #endif _Exit((int)code);