From d47df7c9c6165b0923c14115cd3d96b041dc54ac Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 23:20:02 -0500 Subject: [PATCH] Weigh the runtime profile's staleness by its counts The runtime-unit helper now lists the functions the profile annotated and the ones whose control flow changed since it was collected, each with the sum of its counters in the profile. The Linux x64 pack manifest records the counts on matched and on changed functions (flavors.speed.runtime_profile annotated_counts and mismatched_counts) and names the changed functions with the most counts. A function that one unit variant matches counts as matched in the other variant too. The runtime profile test fails when changed functions hold more than 5% of the counts. RELEASING.md adds refreshing the runtime profile to the release steps. --- RELEASING.md | 5 +- native/llvm-codegen/src/emit.cpp | 91 +++++++++++++++++-- native/llvm-codegen/src/emit.h | 8 +- native/llvm-codegen/src/runtime-unit.cpp | 17 +++- .../runtime-pack-common/scripts/build.mjs | 30 ++++++ .../test/runtime-profile.test.ts | 12 +++ 6 files changed, 152 insertions(+), 11 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index a32971f46..e3e77d512 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -10,7 +10,8 @@ To prepare a release: 4. Wrap the new entry in `` and `` markers; this marked block is also the GitHub release body 5. Remove the `` and `` markers from the previous release entry; only the latest release should have markers 6. With Zig on `PATH`, run `SCRIPTC_CROSS=1 pnpm exec vitest run tests/harness/library-cross.test.ts` and require the cross-target library conformance lane to pass -7. Commit to `main` +7. Refresh the runtime profile on a quiet Linux x64 host: `node scripts/refresh-runtime-profile.mjs --tsc-ts --project :tsconfig.bench.json --project :tsconfig.bench.json --project /src:tsconfig.bench.json`, and include the new `packages/runtime-pack-common/profiles/linux-x64-gnu.profdata.gz` (see the runtime profile paragraph below) +8. Commit to `main` CI (`.github/workflows/release.yml`) compares the version in `packages/cli/package.json` to what `scriptc` has on npm. If it differs, it builds platform packages on matching macOS, Linux, and Windows runners, verifies the version spine, and publishes the runtime, helper, compiler, and CLI packages in dependency order. After the publish succeeds, a separate job creates the git tag `v` and the GitHub release with the marked changelog entry as its body, and attaches `surface-manifest.json` — the machine-readable listing of the surface the static tier compiles at that version (stable per-entry ids, so two releases diff mechanically; see `packages/compiler/src/coverage/surface-manifest.ts` for the schema). The job regenerates the manifest from the tree and fails on any byte difference from the committed file before attaching, so the asset is always the manifest of the code being released. The same file ships inside the `@scriptc/compiler` package as `@scriptc/compiler/surface-manifest.json`. @@ -20,6 +21,8 @@ Release builds of the native CLI optimize the compiler with a profile collected Builds of the native compiler also order its functions by the committed layout `packages/compiler/native/cli.layout.gz`: the functions that `--version` and rebuilds of unchanged programs run come first, then the ones other builds run, ordered by the share of the layout's training builds that run each, so a run maps fewer pages of the executable. Functions the layout does not list keep their usual order. Function names of closures and generated helpers change with nearby edits, so the `cli-profile` job also collects a layout on the release commit (`scripts/refresh-cli-layout.mjs`), and the CLI builds use it (`SCRIPTC_NATIVE_LAYOUT`). Instrumented builds use the same layout as optimized builds, because a function laid out in a different partition than in the instrumented build no longer matches its profile record. The job therefore collects the layout first and then builds the instrumented compiler for the profile with that layout. Refresh the committed layout the same way, with `node scripts/refresh-cli-layout.mjs`, before the profile, and commit each on its own. +The Linux x64 runtime pack optimizes its speed-flavor units with the committed runtime profile `packages/runtime-pack-common/profiles/linux-x64-gnu.profdata.gz`. Runtime functions whose code changed since the profile was collected are optimized as without a profile. The pack manifest records how the profile matched (`flavors.speed.runtime_profile`): the matched and changed functions, and the share of the profile's counts on changed functions. `packages/runtime-pack-common/test/runtime-profile.test.ts` fails when changed functions hold more than 5% of the counts, and names the functions with the most counts. Refresh the profile in each release commit (step 7), and between releases when that test fails. + 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/native/llvm-codegen/src/emit.cpp b/native/llvm-codegen/src/emit.cpp index ee9e6fae0..b7e0014a5 100644 --- a/native/llvm-codegen/src/emit.cpp +++ b/native/llvm-codegen/src/emit.cpp @@ -34,6 +34,7 @@ #include "llvm/PassInfo.h" #include "llvm/PassRegistry.h" #include "llvm/Passes/PassBuilder.h" +#include "llvm/ProfileData/InstrProf.h" #include "llvm/ProfileData/InstrProfReader.h" #include "llvm/Support/CommandLine.h" #include "llvm/Support/PGOOptions.h" @@ -285,7 +286,9 @@ struct ProfileMatch { // context's default handling. class ProfileMatchHandler : public DiagnosticHandler { public: - explicit ProfileMatchHandler(ProfileMatch &Match) : Match(Match) {} + explicit ProfileMatchHandler( + ProfileMatch &Match, std::vector *MismatchedNames = nullptr) + : Match(Match), MismatchedNames(MismatchedNames) {} bool handleDiagnostics(const DiagnosticInfo &Info) override { if (Info.getKind() != DK_PGOProfile) @@ -294,9 +297,14 @@ public: raw_string_ostream Stream(Message); DiagnosticPrinterRawOStream Printer(Stream); Info.print(Printer); - if (StringRef(Message).contains("hash mismatch")) + if (StringRef(Message).contains("hash mismatch")) { ++Match.Mismatched; - else if (StringRef(Message).contains_insensitive("no profile data")) + // "...(hash mismatch) Hash = " + if (MismatchedNames != nullptr) { + StringRef Name = StringRef(Message).split("(hash mismatch) ").second; + MismatchedNames->push_back(Name.split(" Hash = ").first.str()); + } + } else if (StringRef(Message).contains_insensitive("no profile data")) ++Match.Missing; else return false; @@ -305,6 +313,7 @@ public: private: ProfileMatch &Match; + std::vector *MismatchedNames; }; // The annotation warns about functions the profile does not describe only @@ -403,6 +412,22 @@ struct Pipeline { Count += annotatedFunctions(**M); }); } + + // Adds to Names the profile names of the functions the annotation gave + // counts to, as soon as that pass has run. + void collectAnnotated(std::vector &Names) { + Callbacks.registerAfterPassCallback( + [&Names](StringRef PassID, Any IR, const PreservedAnalyses &) { + if (PassID != "PGOInstrumentationUse") + return; + if (const auto *M = llvm::any_cast(&IR)) + for (const Function &F : **M) { + std::optional Entry = F.getEntryCount(); + if (!F.isDeclaration() && Entry && !Entry->isSynthetic()) + Names.push_back(getIRPGOFuncName(F)); + } + }); + } }; std::optional verify(Module &M, StringRef Code) { @@ -1371,6 +1396,44 @@ emitSplitCodegen(const EmitOptions &Options, Module &M, TargetMachine &Machine, return Failure; } +// Lists the annotated and the mismatched functions of one module in +// `Result`, each with the sum of its counters in the profile. `ProfileNames` +// maps the module's symbol names, which the mismatch warnings use, to +// profile names. +std::optional +weighProfileMatch(const std::string &ProfileUse, + const StringMap &ProfileNames, + const std::vector &AnnotatedNames, + const std::vector &MismatchedNames, + ModuleProfileMatch &Result) { + Expected> Reader = + IndexedInstrProfReader::create(ProfileUse, *vfs::getRealFileSystem()); + if (!Reader) + return ProfileUse + ": " + toString(Reader.takeError()); + StringMap Counts; + for (const NamedInstrProfRecord &Record : **Reader) { + uint64_t Sum = 0; + for (uint64_t Count : Record.Counts) + Sum += Count; + Counts[Record.Name] += Sum; + } + if (Error E = (*Reader)->getError()) + return ProfileUse + ": " + toString(std::move(E)); + auto CountsOf = [&Counts](StringRef Name) -> uint64_t { + auto It = Counts.find(Name); + return It == Counts.end() ? 0 : It->second; + }; + for (const std::string &Name : AnnotatedNames) + Result.AnnotatedCounts.emplace_back(Name, CountsOf(Name)); + for (const std::string &Name : MismatchedNames) { + auto It = ProfileNames.find(Name); + std::string ProfileName = It == ProfileNames.end() ? Name : It->second; + uint64_t Sum = CountsOf(ProfileName); + Result.MismatchedCounts.emplace_back(std::move(ProfileName), Sum); + } + return std::nullopt; +} + } // namespace std::optional optimizeModule(Module &M, TargetMachine &Machine, @@ -1393,18 +1456,34 @@ std::optional optimizeModule(Module &M, TargetMachine &Machine, } bool Annotating = Profile && Profile->Action == PGOOptions::IRUse; ProfileMatch Match; - if (Annotating) + // The annotation reports a mismatched function by its symbol name; the + // profile names local functions after their source file. + StringMap ProfileNames; + std::vector MismatchedNames; + std::vector AnnotatedNames; + if (Annotating) { + for (const Function &F : M) + if (!F.isDeclaration()) + ProfileNames[F.getName()] = getIRPGOFuncName(F); M.getContext().setDiagnosticHandler( - std::make_unique(Match)); + std::make_unique(Match, &MismatchedNames)); + } { Pipeline P(Machine, Level, std::move(Profile)); - if (Annotating) + if (Annotating) { P.countAnnotated(Match.Annotated); + P.collectAnnotated(AnnotatedNames); + } P.PB.buildPerModuleDefaultPipeline(Level).run(M, P.MAM); } Result.Annotated = Match.Annotated.load(); Result.Mismatched = Match.Mismatched.load(); Result.Missing = Match.Missing.load(); + if (Annotating) { + if (std::optional Failure = weighProfileMatch( + ProfileUse, ProfileNames, AnnotatedNames, MismatchedNames, Result)) + return Failure; + } if (std::optional Failure = verify(M, "post_optimization_verification_failed")) return Failure->Message; diff --git a/native/llvm-codegen/src/emit.h b/native/llvm-codegen/src/emit.h index dd02cfd98..a605d1ec7 100644 --- a/native/llvm-codegen/src/emit.h +++ b/native/llvm-codegen/src/emit.h @@ -3,6 +3,7 @@ #include #include #include +#include #include namespace llvm { @@ -44,11 +45,16 @@ int emit(const EmitOptions &Options); // How a profile matched the functions of one optimized module: functions // that received its counts, functions whose control flow changed since it -// was collected, and functions it has no record of. +// was collected, and functions it has no record of. The annotated and the +// mismatched functions are also listed by profile name, with the sum of the +// profile's counters for each, so a changed hot function can weigh more than +// a changed cold one. struct ModuleProfileMatch { uint64_t Annotated = 0; uint64_t Mismatched = 0; uint64_t Missing = 0; + std::vector> AnnotatedCounts; + std::vector> MismatchedCounts; }; // Optimizes `M` with the per-module default pipeline at `OptLevel`, as `emit` diff --git a/native/llvm-codegen/src/runtime-unit.cpp b/native/llvm-codegen/src/runtime-unit.cpp index d97638c84..f38833dd6 100644 --- a/native/llvm-codegen/src/runtime-unit.cpp +++ b/native/llvm-codegen/src/runtime-unit.cpp @@ -276,9 +276,20 @@ int runtimeUnit(int Argc, char **Argv) { Options->ProfileGeneratePattern, Options->ProfileUse, Match)) return fail("optimization_failed", *Failure); if (!Options->ProfileUse.empty()) { - json::Object Profile{{"annotated", Match.Annotated}, - {"mismatched", Match.Mismatched}, - {"missing", Match.Missing}}; + // Each matched function's counters in the profile, by profile name. + auto Counts = + [](const std::vector> &List) { + json::Object Object; + for (const auto &[Name, Sum] : List) + Object[Name] = Sum; + return Object; + }; + json::Object Profile{ + {"annotated", Match.Annotated}, + {"mismatched", Match.Mismatched}, + {"missing", Match.Missing}, + {"annotated_counts", Counts(Match.AnnotatedCounts)}, + {"mismatched_counts", Counts(Match.MismatchedCounts)}}; json::Object Response{{"ok", true}, {"profile", std::move(Profile)}}; outs() << formatv("{0}\n", json::Value(std::move(Response))); } else if (Options->ProfileGenerate) { diff --git a/packages/runtime-pack-common/scripts/build.mjs b/packages/runtime-pack-common/scripts/build.mjs index 07de35369..97e03b189 100644 --- a/packages/runtime-pack-common/scripts/build.mjs +++ b/packages/runtime-pack-common/scripts/build.mjs @@ -387,6 +387,7 @@ async function build() { annotated: profileMatches.reduce((sum, match) => sum + match.annotated, 0), mismatched: profileMatches.reduce((sum, match) => sum + match.mismatched, 0), missing: profileMatches.reduce((sum, match) => sum + match.missing, 0), + ...profileWeights(profileMatches), }, }), }; @@ -586,3 +587,32 @@ async function build() { } } await withBuildLock(join(packageRoot, ".runtime-pack-build.lock"), build); + +/** The profile's counters on the functions the speed units' annotation + * matched (annotated_counts) and on the functions whose control flow changed + * since the profile was collected (mismatched_counts), from helpers that + * report them per function. A function the profile describes for one unit + * variant (default or workers) only reads as mismatched in the other one, so + * a name annotated in any unit counts as annotated. `stale` names the + * mismatched functions with the most counts. */ +function profileWeights(matches) { + if (!matches.every((match) => match.annotated_counts !== undefined)) return {}; + const annotated = new Map(); + const mismatched = new Map(); + for (const match of matches) { + for (const [name, counts] of Object.entries(match.annotated_counts)) + annotated.set(name, counts); + for (const [name, counts] of Object.entries(match.mismatched_counts)) + mismatched.set(name, counts); + } + for (const name of annotated.keys()) mismatched.delete(name); + const sum = (map) => [...map.values()].reduce((total, counts) => total + counts, 0); + return { + annotated_counts: sum(annotated), + mismatched_counts: sum(mismatched), + stale: [...mismatched] + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([name]) => name), + }; +} diff --git a/packages/runtime-pack-common/test/runtime-profile.test.ts b/packages/runtime-pack-common/test/runtime-profile.test.ts index 0b2bf19a4..f57a4b10b 100644 --- a/packages/runtime-pack-common/test/runtime-profile.test.ts +++ b/packages/runtime-pack-common/test/runtime-profile.test.ts @@ -28,6 +28,9 @@ const built = // whose control flow changed since it was collected. More than this share of // the functions it describes means it is due for scripts/refresh-runtime-profile.mjs. const MAX_MISMATCHED_SHARE = 0.1; +// The same by the profile's counters: a changed hot function loses more of +// the profile's gain than a changed cold one. +const MAX_MISMATCHED_COUNTS_SHARE = 0.05; test.skipIf(!built)("the committed runtime profile still matches the runtime's speed units", () => { const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); @@ -39,6 +42,15 @@ test.skipIf(!built)("the committed runtime profile still matches the runtime's s match.mismatched / described, `${match.mismatched} of the ${described} runtime functions the profile describes changed since it was collected; run node scripts/refresh-runtime-profile.mjs`, ).toBeLessThanOrEqual(MAX_MISMATCHED_SHARE); + // Helpers that list the matched functions also report their counters. + if (match.annotated_counts !== undefined) { + const counts = match.annotated_counts + match.mismatched_counts; + expect(counts).toBeGreaterThan(0); + expect( + match.mismatched_counts / counts, + `runtime functions that changed since the profile was collected hold ${((100 * match.mismatched_counts) / counts).toFixed(1)}% of its counts (most: ${match.stale.join(", ")}); run node scripts/refresh-runtime-profile.mjs`, + ).toBeLessThanOrEqual(MAX_MISMATCHED_COUNTS_SHARE); + } }); // The committed profile holds the runtime's records only. Training programs'