diff --git a/.github/workflows/benchmark-run.yml b/.github/workflows/benchmark-run.yml index 404de10518..485685e4d4 100644 --- a/.github/workflows/benchmark-run.yml +++ b/.github/workflows/benchmark-run.yml @@ -38,6 +38,21 @@ on: required: false type: string default: "" + backfill_to: + description: "Backfill up to this commit SHA, inclusive. Lets a long range be re-measured one chunk per run." + required: false + type: string + default: "" + backfill_reset: + description: "Re-measure commits that already have results, and drop the stored ones. Use on the first chunk only." + required: false + type: boolean + default: false + timeout-minutes: + description: "Job timeout. Re-measuring a long range needs far more than the 6h default." + required: false + type: number + default: 360 branch: description: "Data branch to store results" required: false @@ -51,6 +66,7 @@ jobs: benchmark: name: Run Benchmarks runs-on: ${{ inputs.runner }} + timeout-minutes: ${{ inputs.timeout-minutes }} env: TYPESPEC_VS_CI_BUILD: true TYPESPEC_SKIP_WEBSITE_BUILD: true @@ -75,10 +91,16 @@ jobs: run: | node --max-old-space-size=6144 packages/benchmark/dist/src/cli.js backfill \ --from ${{ inputs.backfill_from }} \ + ${{ inputs.backfill_to != '' && format('--to {0}', inputs.backfill_to) || '' }} \ --specs-dir ${{ inputs.specs-dir }} \ + --results-dir ${{ inputs.results-dir }} \ --iterations ${{ inputs.iterations }} \ --warmup ${{ inputs.warmup }} \ + --noise-cv-threshold ${{ inputs.noise-cv }} \ + --max-reruns 1 \ + --rerun-iterations ${{ inputs.rerun-iterations }} \ --branch ${{ inputs.branch }} \ + ${{ inputs.backfill_reset && '--reset' || '' }} \ --push - name: Run benchmarks diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 15d55ffba8..1d8bb27b61 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -10,6 +10,20 @@ on: description: "Backfill from: commit SHA or number of recent commits. Leave empty to run a normal benchmark." required: false type: string + backfill_to: + description: "Backfill up to this commit SHA, inclusive. Lets a long range be re-measured one chunk per run." + required: false + type: string + backfill_reset: + description: "Re-measure commits that already have results, and drop the stored ones. Use on the first chunk only." + required: false + type: boolean + default: false + timeout_minutes: + description: "Job timeout in minutes. Re-measuring a long range needs far more than the 6h default." + required: false + type: string + default: "360" branch: description: "Data branch to store results (default: benchmark-data)" required: false @@ -40,6 +54,9 @@ jobs: rerun-iterations: 10 runner: ${{ github.event_name == 'workflow_dispatch' && inputs.runner || vars.BENCHMARK_RUNNER || 'ubuntu-latest' }} backfill_from: ${{ github.event_name == 'workflow_dispatch' && inputs.backfill_from || '' }} + backfill_to: ${{ github.event_name == 'workflow_dispatch' && inputs.backfill_to || '' }} + backfill_reset: ${{ github.event_name == 'workflow_dispatch' && inputs.backfill_reset || false }} + timeout-minutes: ${{ github.event_name == 'workflow_dispatch' && fromJSON(inputs.timeout_minutes) || 360 }} branch: ${{ github.event_name == 'workflow_dispatch' && inputs.branch || 'benchmark-data' }} permissions: contents: write diff --git a/packages/benchmark/README.md b/packages/benchmark/README.md index db520ed454..b68c846db2 100644 --- a/packages/benchmark/README.md +++ b/packages/benchmark/README.md @@ -9,9 +9,10 @@ Performance benchmarking tool for TypeSpec Azure compilation. Tracks compilation 3. Runtime metrics are aggregated with an outlier-resistant estimator (trimmed mean for 5+ samples, median for smaller sample sizes) 4. Per-spec variability (standard deviation and coefficient of variation) is captured from raw iterations 5. Optional noise-gating can auto-run extra iterations when variance is high -6. PR baseline can be built from a rolling window of recent `main` results instead of only `latest.json` -7. Results are stored as JSON — on CI, they're saved to the `benchmark-data` branch -8. PR comments show a comparison table highlighting performance changes +6. A frozen reference workload is measured in the same job so results from different CI runners can be compared (see [Machine calibration](#machine-calibration)) +7. PR baseline can be built from a rolling window of recent `main` results instead of only `latest.json` +8. Results are stored as JSON — on CI, they're saved to the `benchmark-data` branch +9. PR comments show a comparison table highlighting performance changes ## Local usage @@ -115,6 +116,48 @@ The backfill command: 3. Skips commits that already have results on the `benchmark-data` branch 4. Commits all new results to the `benchmark-data` branch +## Machine calibration + +CI hands out whichever runner is free, and those machines are not equally fast. +Measured across 100 commits of `main`, the same work varied by 63% depending on +the machine: spread between machines was 13.7% against 0.9% on a single machine, +so hardware outweighed code changes roughly 16 to 1. Every commit gets its own +job, so that noise lands directly between neighboring points and shows up as +jumps no code change explains. + +Machine speed scales TypeSpec workloads more or less uniformly, so it can be +divided out. Each run therefore also compiles a **frozen reference workload** on +the same machine, in the same job. Dividing by it removes the machine factor +from the comparison. + +The reference has to satisfy two competing requirements. + +It must be **frozen**, and deliberately does not use the packages being +benchmarked. If it moved with the repo, a genuine regression would slow the +reference by the same amount and cancel itself out. It lives in `calibration/` +and installs exactly pinned releases from npm. + +It must also be **representative**. A first attempt imported only +`@typespec/compiler` and compiled synthetic models; between two CI machines it +slowed 16% while the real specs slowed 34%, removing only half the machine +effect. Hardware sensitivity depends on the kind of work being done — `loader` +is a third of the real measurement and proved the most sensitive phase of all, +and a spec with no libraries to load barely exercises it. The reference is +therefore a frozen copy of the `azure-full` spec compiled against the same +pinned library stack and linter ruleset, which brings its phase mix in line with +what is actually being measured. + +Changing the reference spec or any pinned version breaks comparability with +existing points, so both are versioned by `WORKLOAD_ID` in `src/calibration.ts` +and only entries sharing the dominant workload are corrected. + +Calibration costs ~20s per commit and never fails a run: if the pinned stack +cannot be installed, the run proceeds and the point is flagged `uncalibrated`. + +Raw measurements are never rewritten. `history.json` stores the calibration +alongside them and exposes a per-entry `normalization` factor to multiply by, so +the correction stays visible and reversible. + ## What gets measured The TypeSpec compiler provides built-in `Stats` covering: diff --git a/packages/benchmark/calibration/package.json b/packages/benchmark/calibration/package.json new file mode 100644 index 0000000000..25e45eee41 --- /dev/null +++ b/packages/benchmark/calibration/package.json @@ -0,0 +1,15 @@ +{ + "name": "typespec-benchmark-calibration", + "private": true, + "type": "module", + "description": "Frozen reference workload used to measure how fast the machine is. Versions are pinned exactly and must not be changed without bumping WORKLOAD_ID in src/calibration.ts.", + "dependencies": { + "@typespec/compiler": "1.15.0", + "@typespec/http": "1.15.0", + "@typespec/rest": "0.85.0", + "@typespec/versioning": "0.85.0", + "@typespec/openapi": "1.15.0", + "@azure-tools/typespec-azure-core": "0.71.0", + "@azure-tools/typespec-azure-rulesets": "0.71.0" + } +} diff --git a/packages/benchmark/calibration/spec/main.tsp b/packages/benchmark/calibration/spec/main.tsp new file mode 100644 index 0000000000..25baf7945a --- /dev/null +++ b/packages/benchmark/calibration/spec/main.tsp @@ -0,0 +1,323 @@ +import "@typespec/http"; +import "@typespec/rest"; +import "@typespec/versioning"; +import "@azure-tools/typespec-azure-core"; + +using Http; +using Rest; +using Versioning; +using Azure.Core; +using Azure.Core.Traits; + +// ============================================ +// A comprehensive data-plane service with many models, operations, and patterns +// ============================================ + +@service(#{ title: "Contoso Benchmark Full Service" }) +@versioned(Contoso.BenchmarkFull.Versions) +@useAuth(ApiKeyAuth) +namespace Contoso.BenchmarkFull; + +enum Versions { + `2024-01-01`, +} + +// --- Catalog domain --- + +union ItemCategory { + string, + Electronics: "Electronics", + Furniture: "Furniture", + Clothing: "Clothing", + Food: "Food", + Books: "Books", + Sports: "Sports", +} + +union ItemCondition { + string, + New: "New", + Refurbished: "Refurbished", + Used: "Used", +} + +@resource("items") +model Item { + @key("itemId") + @visibility(Lifecycle.Read) + id: string; + + name: string; + description?: string; + category: ItemCategory; + condition: ItemCondition; + price: float64; + quantity: int32; + sku: string; + tags?: Record; + metadata?: Record; + createdAt: utcDateTime; + updatedAt?: utcDateTime; + ...EtagProperty; +} + +@resource("reviews") +@parentResource(Item) +model ItemReview { + @key("reviewId") + @visibility(Lifecycle.Read) + id: string; + + author: string; + rating: int32; + title?: string; + comment?: string; + helpful: int32; + createdAt: utcDateTime; +} + +@resource("images") +@parentResource(Item) +model ItemImage { + @key("imageId") + @visibility(Lifecycle.Read) + id: string; + + url: string; + altText?: string; + width: int32; + height: int32; + isPrimary: boolean; +} + +// --- Order domain --- + +@resource("orders") +model Order { + @key("orderId") + @visibility(Lifecycle.Read) + id: string; + + customerName: string; + customerEmail: string; + shippingAddress: Address; + billingAddress?: Address; + items: OrderLineItem[]; + totalAmount: float64; + currency: string; + status: OrderStatus; + createdAt: utcDateTime; + updatedAt?: utcDateTime; + ...EtagProperty; +} + +model Address { + street: string; + city: string; + state: string; + postalCode: string; + country: string; +} + +model OrderLineItem { + itemId: string; + itemName: string; + quantity: int32; + unitPrice: float64; + discount?: float64; +} + +union OrderStatus { + string, + Pending: "Pending", + Confirmed: "Confirmed", + Processing: "Processing", + Shipped: "Shipped", + Delivered: "Delivered", + Canceled: "Canceled", + Refunded: "Refunded", +} + +@resource("shipments") +@parentResource(Order) +model Shipment { + @key("shipmentId") + @visibility(Lifecycle.Read) + id: string; + + carrier: string; + trackingNumber: string; + status: ShipmentStatus; + estimatedDelivery?: utcDateTime; + shippedAt?: utcDateTime; + deliveredAt?: utcDateTime; +} + +union ShipmentStatus { + string, + Pending: "Pending", + InTransit: "InTransit", + OutForDelivery: "OutForDelivery", + Delivered: "Delivered", + Failed: "Failed", +} + +// --- Customer domain --- + +@resource("customers") +model Customer { + @key("customerId") + @visibility(Lifecycle.Read) + id: string; + + firstName: string; + lastName: string; + email: string; + phone?: string; + address?: Address; + loyaltyTier: LoyaltyTier; + createdAt: utcDateTime; + ...EtagProperty; +} + +union LoyaltyTier { + string, + Bronze: "Bronze", + Silver: "Silver", + Gold: "Gold", + Platinum: "Platinum", +} + +@resource("wishlists") +@parentResource(Customer) +model WishlistItem { + @key("wishlistItemId") + @visibility(Lifecycle.Read) + id: string; + + itemId: string; + addedAt: utcDateTime; + note?: string; +} + +// --- Analytics --- + +model SalesReport { + period: string; + totalRevenue: float64; + totalOrders: int32; + averageOrderValue: float64; + topCategories: CategorySales[]; +} + +model CategorySales { + category: ItemCategory; + revenue: float64; + unitsSold: int32; +} + +model InventoryAlert { + itemId: string; + itemName: string; + currentQuantity: int32; + threshold: int32; + severity: AlertSeverity; +} + +union AlertSeverity { + string, + Low: "Low", + Medium: "Medium", + High: "High", + Critical: "Critical", +} + +model InventoryAlertList { + alerts: InventoryAlert[]; +} + +// --- Operations --- + +alias ServiceTraits = SupportsRepeatableRequests & + SupportsConditionalRequests & + SupportsClientRequestId; + +alias Ops = Azure.Core.ResourceOperations; + +interface Items { + create is Ops.ResourceCreateWithServiceProvidedName; + get is Ops.ResourceRead; + delete is Ops.ResourceDelete; + list is Ops.ResourceList< + Item, + ListQueryParametersTrait + >; +} + +interface ItemReviews { + create is Ops.ResourceCreateWithServiceProvidedName; + get is Ops.ResourceRead; + list is Ops.ResourceList; +} + +interface ItemImages { + create is Ops.ResourceCreateWithServiceProvidedName; + get is Ops.ResourceRead; + delete is Ops.ResourceDelete; + list is Ops.ResourceList; +} + +interface Orders { + create is Ops.ResourceCreateWithServiceProvidedName; + get is Ops.ResourceRead; + list is Ops.ResourceList>; +} + +interface Shipments { + create is Ops.ResourceCreateWithServiceProvidedName; + get is Ops.ResourceRead; + list is Ops.ResourceList; +} + +interface Customers { + create is Ops.ResourceCreateWithServiceProvidedName; + get is Ops.ResourceRead; + delete is Ops.ResourceDelete; + list is Ops.ResourceList>; +} + +interface WishlistItems { + create is Ops.ResourceCreateWithServiceProvidedName; + get is Ops.ResourceRead; + delete is Ops.ResourceDelete; + list is Ops.ResourceList; +} + +@route("analytics/sales") +op getSalesReport is RpcOperation< + { + @query period: string; + }, + SalesReport, + ServiceTraits +>; + +@route("analytics/inventory-alerts") +op getInventoryAlerts is RpcOperation< + { + @query severity?: string; + }, + InventoryAlertList, + ServiceTraits +>; + +@route("service-status") +op getServiceStatus is RpcOperation< + {}, + { + status: string; + version: string; + uptime: int64; + }, + ServiceTraits +>; diff --git a/packages/benchmark/calibration/spec/tspconfig.yaml b/packages/benchmark/calibration/spec/tspconfig.yaml new file mode 100644 index 0000000000..06278d8068 --- /dev/null +++ b/packages/benchmark/calibration/spec/tspconfig.yaml @@ -0,0 +1,4 @@ +emit: [] +linter: + extends: + - "@azure-tools/typespec-azure-rulesets/data-plane" diff --git a/packages/benchmark/src/backfill.ts b/packages/benchmark/src/backfill.ts index acbd946c4b..2ddcb70587 100644 --- a/packages/benchmark/src/backfill.ts +++ b/packages/benchmark/src/backfill.ts @@ -1,11 +1,14 @@ /* eslint-disable no-console */ import { execSync } from "node:child_process"; import { + closeSync, copyFileSync, cpSync, existsSync, mkdirSync, + openSync, readdirSync, + readFileSync, rmSync, symlinkSync, writeFileSync, @@ -13,7 +16,15 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { generateHistory } from "./generate-history.js"; -import { DEFAULT_BRANCH, exec, execOk, git, gitSilent, listExistingResults } from "./utils.js"; +import { + configureGitIdentity, + DEFAULT_BRANCH, + exec, + execOk, + git, + gitSilent, + listExistingResults, +} from "./utils.js"; export interface BackfillOptions { /** Starting point: a commit SHA, or a number of recent commits to include. Defaults to 100. */ @@ -34,6 +45,22 @@ export interface BackfillOptions { specs?: string; /** Directory containing benchmark specs. Forwarded to the run command. */ specsDir?: string; + /** Coefficient of variation above which a spec is re-run. Forwarded to the run command. */ + noiseCvThreshold?: number; + /** Maximum re-runs of a noisy spec. Forwarded to the run command. */ + maxReruns?: number; + /** Iterations per re-run of a noisy spec. Forwarded to the run command. */ + rerunIterations?: number; + /** Directory on the data branch holding results. Defaults to `results`. */ + resultsDir?: string; + /** + * Re-measure every commit in range, discarding the results already stored. + * + * Measurement settings have changed over the life of the series, so the only + * way to get points that can be compared with each other is to throw the old + * ones away and measure the whole range the same way. + */ + reset?: boolean; } const DEFAULT_FROM = "100"; @@ -50,6 +77,7 @@ const TYPESPEC_PACKAGES = [ "events", "streams", "sse", + "http-client-js", ]; const AZURE_PACKAGES = [ @@ -58,8 +86,19 @@ const AZURE_PACKAGES = [ "typespec-autorest", "typespec-client-generator-core", "typespec-azure-rulesets", + "typespec-python", + "typespec-ts", + "typespec-java", ]; +// Every emitter the specs `emit:`, plus what they depend on. A spec that names +// an emitter this list forgets fails to compile outright, so it has to track +// packages/benchmark/specs/*/tspconfig.yaml. Filters that match nothing at an +// older commit are ignored by pnpm. +// +// The "..." suffix is load-bearing: it pulls in each package's workspace +// dependencies. Without it the compiler builds against a tmlanguage-generator +// that was never built and every commit dies in tsc. const BUILD_FILTER = [ "@typespec/compiler", "@azure-tools/typespec-azure-core", @@ -68,10 +107,47 @@ const BUILD_FILTER = [ "@typespec/openapi3", "@azure-tools/typespec-client-generator-core", "@azure-tools/typespec-azure-rulesets", + "@azure-tools/typespec-python", + "@typespec/http-client-js", + "@azure-tools/typespec-ts", + "@azure-tools/typespec-java", ] - .map((p) => `--filter "${p}"`) + .map((p) => `--filter "${p}..."`) .join(" "); +/** Last few lines of a log, for explaining a failure without dumping the file. */ +function tail(file: string, lines = 20): string { + try { + return readFileSync(file, "utf-8").trimEnd().split("\n").slice(-lines).join("\n"); + } catch { + return "(no output captured)"; + } +} + +function indent(text: string): string { + return text + .split("\n") + .map((line) => ` ${line}`) + .join("\n"); +} + +/** + * Run a preparation step, keeping its output. `execOk` throws it away, which + * turns any install or build failure into a bare "build failed" that cannot be + * diagnosed once the runner is gone. + */ +function execLogged(cmd: string, cwd: string, log: string): boolean { + const fd = openSync(log, "w"); + try { + execSync(cmd, { cwd, stdio: ["ignore", fd, fd] }); + return true; + } catch { + return false; + } finally { + closeSync(fd); + } +} + /** Restore the saved benchmark package into the repo with symlinks to workspace packages. */ function restoreBenchmark(repoRoot: string, savedBenchmark: string): void { const benchDir = join(repoRoot, "packages/benchmark"); @@ -80,6 +156,9 @@ function restoreBenchmark(repoRoot: string, savedBenchmark: string): void { cpSync(join(savedBenchmark, "dist"), join(benchDir, "dist"), { recursive: true }); cpSync(join(savedBenchmark, "specs"), join(benchDir, "specs"), { recursive: true }); + // The frozen calibration workload has to travel with it, or old commits would + // be measured against no reference at all. + cpSync(join(savedBenchmark, "calibration"), join(benchDir, "calibration"), { recursive: true }); copyFileSync(join(savedBenchmark, "package.json"), join(benchDir, "package.json")); // Create node_modules with symlinks to workspace packages @@ -104,7 +183,31 @@ function restoreBenchmark(repoRoot: string, savedBenchmark: string): void { } /** Resolve a commit range from the from/to options. Returns commits oldest-first. */ -function resolveCommitRange(from: string, to: string | undefined, sourceBranch: string): string[] { +/** + * Resolve the branch to read commits from. + * + * A CI checkout only creates the branch it was asked for, so `main` is not a + * local ref on any other branch -- including whichever branch a change to the + * backfill itself is being tested on. Fall back to the remote copy, fetching + * it if the checkout was shallow enough to have left it out. + */ +function resolveSourceBranch(sourceBranch: string): string { + if (execOk(`git rev-parse --verify --quiet ${sourceBranch}^{commit}`)) return sourceBranch; + + const remote = `origin/${sourceBranch}`; + if (!execOk(`git rev-parse --verify --quiet ${remote}^{commit}`)) { + gitSilent(`fetch origin ${sourceBranch}:refs/remotes/${remote}`); + } + if (!execOk(`git rev-parse --verify --quiet ${remote}^{commit}`)) { + throw new Error(`Cannot resolve source branch '${sourceBranch}' locally or on origin.`); + } + + console.log(`Source branch '${sourceBranch}' is not checked out; using ${remote}.`); + return remote; +} + +function resolveCommitRange(from: string, to: string | undefined, branch: string): string[] { + const sourceBranch = resolveSourceBranch(branch); const isNumber = /^\d+$/.test(from); if (isNumber) { @@ -128,6 +231,8 @@ export function backfill(options: BackfillOptions = {}): void { const sourceBranch = options.sourceBranch ?? "main"; const dataBranch = options.dataBranch ?? DEFAULT_BRANCH; const shouldPush = options.push ?? false; + const dataDirName = options.resultsDir ?? "results"; + const reset = options.reset ?? false; // Build flags to forward to `cli.js run` const runFlags: string[] = []; @@ -135,6 +240,15 @@ export function backfill(options: BackfillOptions = {}): void { if (options.warmup !== undefined) runFlags.push(`--warmup ${options.warmup}`); if (options.specs) runFlags.push(`--specs ${options.specs}`); if (options.specsDir) runFlags.push(`--specs-dir "${options.specsDir}"`); + // The noise gate is part of how a number is produced, so a backfilled point + // is only comparable with a live one if it was gated the same way. + if (options.noiseCvThreshold !== undefined) { + runFlags.push(`--noise-cv-threshold ${options.noiseCvThreshold}`); + } + if (options.maxReruns !== undefined) runFlags.push(`--max-reruns ${options.maxReruns}`); + if (options.rerunIterations !== undefined) { + runFlags.push(`--rerun-iterations ${options.rerunIterations}`); + } const runFlagsStr = runFlags.join(" "); const repoRoot = git("rev-parse --show-toplevel"); @@ -160,6 +274,9 @@ export function backfill(options: BackfillOptions = {}): void { cpSync(join(repoRoot, "packages/benchmark/specs"), join(savedBenchmark, "specs"), { recursive: true, }); + cpSync(join(repoRoot, "packages/benchmark/calibration"), join(savedBenchmark, "calibration"), { + recursive: true, + }); copyFileSync( join(repoRoot, "packages/benchmark/package.json"), join(savedBenchmark, "package.json"), @@ -174,7 +291,7 @@ export function backfill(options: BackfillOptions = {}): void { if (gitSilent("fetch origin " + dataBranch)) { // fetched successfully } - const existingResults = listExistingResults(dataBranch); + const existingResults = reset ? new Set() : listExistingResults(dataBranch, dataDirName); // Stash uncommitted changes let stashed = false; @@ -240,16 +357,20 @@ export function backfill(options: BackfillOptions = {}): void { continue; } - if (!execOk("pnpm install --frozen-lockfile --quiet", { cwd: repoRoot })) { - if (!execOk("pnpm install --quiet", { cwd: repoRoot })) { + const setupLog = join(resultsDir, `${sha}.setup.log`); + + if (!execLogged("pnpm install --frozen-lockfile", repoRoot, setupLog)) { + if (!execLogged("pnpm install", repoRoot, setupLog)) { console.log("install failed, skipping"); + console.log(indent(tail(setupLog))); failed++; continue; } } - if (!execOk(`pnpm -r ${BUILD_FILTER} build`, { cwd: repoRoot })) { + if (!execLogged(`pnpm -r ${BUILD_FILTER} build`, repoRoot, setupLog)) { console.log("build failed, skipping"); + console.log(indent(tail(setupLog))); failed++; continue; } @@ -264,17 +385,23 @@ export function backfill(options: BackfillOptions = {}): void { // Use --specs-dir from runFlags if provided, otherwise use the saved specs const specsFlag = options.specsDir ? "" : `--specs-dir "${defaultSpecsDir}"`; + // Keep the output: it is the only account of why a commit failed, and + // it lives on a runner that is thrown away when the job ends. + const logFd = openSync(benchLog, "w"); try { execSync( `node "${benchmarkCli}" run ${specsFlag} --output "${resultFile}" ${runFlagsStr}`.trim(), - { cwd: repoRoot, stdio: ["ignore", "ignore", "ignore"] }, + { cwd: repoRoot, stdio: ["ignore", logFd, logFd] }, ); console.log("done ✓"); succeeded++; } catch { - console.log(`benchmark failed (see ${benchLog})`); + console.log("benchmark failed"); + console.log(indent(tail(benchLog))); failed++; rmSync(resultFile, { force: true }); + } finally { + closeSync(logFd); } } } finally { @@ -288,6 +415,12 @@ export function backfill(options: BackfillOptions = {}): void { console.log(`Failed: ${failed}`); console.log(`Skipped: ${skipped}`); + // A run where nothing was measured has produced no data, so let it report as + // a failure rather than a green job with an empty result. + if (succeeded === 0 && failed > 0) { + throw new Error(`Backfill measured nothing: all ${failed} commits failed.`); + } + // Step 4: Push results to benchmark-data branch const newResults = readdirSync(resultsDir).filter((f) => f.endsWith(".json")); if (newResults.length === 0) { @@ -297,6 +430,8 @@ export function backfill(options: BackfillOptions = {}): void { console.log(`\nCommitting ${newResults.length} result(s) to ${dataBranch} branch...`); + configureGitIdentity(); + // Switch to benchmark-data branch if (gitSilent(`rev-parse --verify origin/${dataBranch}`)) { gitSilent(`checkout origin/${dataBranch} --force --quiet`); @@ -306,27 +441,36 @@ export function backfill(options: BackfillOptions = {}): void { gitSilent("rm -rf . --quiet"); } - mkdirSync("results", { recursive: true }); + if (reset) { + console.log(`Clearing ${dataDirName}/ — every commit in range was re-measured.`); + rmSync(dataDirName, { recursive: true, force: true }); + } + + mkdirSync(dataDirName, { recursive: true }); for (const file of newResults) { - copyFileSync(join(resultsDir, file), join("results", file)); + copyFileSync(join(resultsDir, file), join(dataDirName, file)); } // Update latest.json to the most recent result (by commit order, not lexicographic SHA) const resultShas = new Set(newResults.map((f) => f.replace(".json", ""))); const latestSha = [...commits].reverse().find((sha) => resultShas.has(sha)); if (latestSha) { - copyFileSync(join(resultsDir, `${latestSha}.json`), "results/latest.json"); + copyFileSync(join(resultsDir, `${latestSha}.json`), join(dataDirName, "latest.json")); } // Generate aggregated history.json - const resultsPath = join(process.cwd(), "results"); + const resultsPath = join(process.cwd(), dataDirName); const history = generateHistory({ dir: resultsPath }); writeFileSync(join(resultsPath, "history.json"), JSON.stringify(history, null, 2)); console.log("Generated history.json"); - git("add results/"); - const commitMsg = `benchmark: backfill results for ${succeeded} commits`; - gitSilent(`commit -m "${commitMsg}" --quiet`); + git(`add ${dataDirName}/`); + const commitMsg = reset + ? `benchmark: re-measure ${succeeded} commits (${dataDirName})` + : `benchmark: backfill results for ${succeeded} commits`; + // Not silent: a failure here leaves the branch unborn, and the push that + // follows then fails with an unrelated-looking "src refspec" error. + git(`commit -m "${commitMsg}" --quiet`); console.log(`Results committed to ${dataBranch} branch.`); if (shouldPush) { diff --git a/packages/benchmark/src/calibration.ts b/packages/benchmark/src/calibration.ts new file mode 100644 index 0000000000..0aa873e9bd --- /dev/null +++ b/packages/benchmark/src/calibration.ts @@ -0,0 +1,195 @@ +/* eslint-disable no-console */ +/** + * Machine calibration. + * + * Every benchmark point is measured on whatever runner CI happens to hand out, + * and those runners are not equally fast. Measured across 100 commits, the same + * code varied by 63% depending on the machine: between-machine spread was 13.7% + * against 0.9% within a machine, so hardware outweighed code changes 16 to 1. + * + * This module measures the denominator needed to divide that out: a reference + * workload that never changes, compiled on the same machine, in the same job, + * as the real benchmark. + * + * Two properties matter, and they pull in opposite directions. + * + * The reference must be **frozen**. It deliberately does not use the packages + * being benchmarked -- it installs pinned releases from npm -- because a + * reference that moved with the repo would slow down alongside a real + * regression and cancel it out. + * + * The reference must also be **representative**. A first attempt used a + * synthetic spec importing only the compiler; between two CI machines it slowed + * 16% while the real specs slowed 34%, so it removed only half the machine + * effect. Hardware sensitivity varies with the kind of work: `loader` is a + * third of the real measurement and was the most sensitive phase of all, and a + * spec with no libraries to load barely exercises it. The reference is + * therefore a frozen copy of the azure-full benchmark spec, compiled against + * the same pinned library stack and the same linter ruleset, so that it does + * the same mix of work. + */ +import { execFile, execFileSync } from "node:child_process"; +import { cpSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { median, summarize } from "./statistics.js"; +import type { CalibrationInfo } from "./types.js"; + +/** + * Identifies the frozen workload. Bump whenever the reference spec or any + * pinned version in calibration/package.json changes, so that points measured + * against different yardsticks are never compared with each other. + * + * v1: synthetic compiler-only spec. Withdrawn, it under-corrected by half. + * v2: frozen copy of the azure-full spec against the pinned library stack. + */ +const WORKLOAD_ID = "v2"; + +/** Reported alongside results so the reference in use is always identifiable. */ +const REFERENCE_COMPILER_VERSION = "1.15.0"; + +/** + * Sized so the machine-speed estimate is far more precise than the regressions + * it needs to expose, without adding meaningfully to a ~25 minute job. + */ +const CALIBRATION_WARMUP = 3; +const CALIBRATION_ITERATIONS = 25; + +/** The frozen workload, shipped in the package rather than generated. */ +const sourceDir = join(dirname(fileURLToPath(import.meta.url)), "../../calibration"); + +/** + * Loader executed from inside the calibration directory so that bare imports + * resolve to the pinned copies through normal Node resolution rather than to + * the workspace being benchmarked. + * + * Measures what `total` measures for real specs: every phase except emit. + */ +const REFERENCE_RUNNER = ` +import { compile, NodeHost, resolveCompilerOptions } from "@typespec/compiler"; +import { join } from "node:path"; + +const specDir = process.argv[2]; +const mainFile = join(specDir, "main.tsp"); +const [options] = await resolveCompilerOptions(NodeHost, { entrypoint: mainFile, cwd: specDir }); +const program = await compile(NodeHost, mainFile, { + ...options, + outputDir: join(specDir, "tsp-output"), + noEmit: true, +}); +if (program.hasError()) { + const errors = program.diagnostics + .filter((d) => d.severity === "error") + .map((d) => " " + d.message) + .join("\\n"); + throw new Error("Calibration spec failed to compile:\\n" + errors); +} +const runtime = program.stats.runtime; +const total = + (runtime.loader ?? 0) + + (runtime.resolver ?? 0) + + (runtime.checker ?? 0) + + (runtime.validation?.total ?? 0) + + (runtime.linter?.total ?? 0); +process.stdout.write(JSON.stringify({ total })); +`; + +/** Where the frozen environment is materialized. Reused across commits in a backfill job. */ +function calibrationDir(): string { + return join(tmpdir(), `typespec-benchmark-calibration-${WORKLOAD_ID}`); +} + +/** + * Materialize the pinned stack and frozen spec. Installs only when missing, so + * a backfill measuring many commits pays for it once. + */ +function prepare(dir: string): void { + const marker = join( + dir, + "node_modules", + "@azure-tools", + "typespec-azure-rulesets", + "package.json", + ); + + mkdirSync(dir, { recursive: true }); + cpSync(sourceDir, dir, { recursive: true }); + writeFileSync(join(dir, "run.mjs"), REFERENCE_RUNNER); + + if (existsSync(marker)) return; + + try { + execFileSync( + "npm", + ["install", "--no-package-lock", "--no-audit", "--no-fund", "--loglevel", "error"], + { cwd: dir, stdio: ["ignore", "ignore", "pipe"], encoding: "utf-8" }, + ); + } catch (error: any) { + const details = String(error?.stderr ?? "").trim(); + throw new Error( + `failed to install the reference stack${details ? `: ${details.split("\n")[0]}` : ""}`, + { cause: error }, + ); + } +} + +async function compileReference(dir: string): Promise { + return await new Promise((resolvePromise, reject) => { + execFile( + process.execPath, + [join(dir, "run.mjs"), join(dir, "spec")], + { cwd: dir }, + (error, stdout, stderr) => { + if (error) { + reject(new Error(stderr.trim() || error.message)); + return; + } + try { + resolvePromise((JSON.parse(stdout) as { total: number }).total); + } catch { + reject(new Error(`unexpected calibration output: ${stdout.slice(0, 200)}`)); + } + }, + ); + }); +} + +/** + * Measure this machine against the frozen reference. + * + * Returns undefined rather than throwing: a machine-speed estimate is valuable + * but not worth losing a 25 minute benchmark run over, and consumers already + * treat calibration as optional so that points measured before it existed stay + * readable. + */ +export async function measureCalibration(): Promise { + const dir = calibrationDir(); + try { + prepare(dir); + + for (let i = 0; i < CALIBRATION_WARMUP; i++) { + await compileReference(dir); + } + + const samples: number[] = []; + for (let i = 0; i < CALIBRATION_ITERATIONS; i++) { + samples.push(await compileReference(dir)); + } + + const stats = summarize(samples); + return { + compilerVersion: REFERENCE_COMPILER_VERSION, + workload: WORKLOAD_ID, + total: median(samples), + iterations: samples.length, + cv: stats.cv, + }; + } catch (error) { + console.log( + ` Calibration unavailable (${error instanceof Error ? error.message.split("\n")[0] : error}); ` + + `results will not be machine-normalized.`, + ); + return undefined; + } +} diff --git a/packages/benchmark/src/cli.ts b/packages/benchmark/src/cli.ts index 743c73184e..2ad2b33d6f 100644 --- a/packages/benchmark/src/cli.ts +++ b/packages/benchmark/src/cli.ts @@ -74,6 +74,13 @@ Backfill options: --iterations Number of measured iterations per spec (default: 5) --warmup Number of warmup iterations (default: 1) --specs Comma-separated list of specific specs to run + --results-dir Directory on the data branch to store results/history (default: results) + --noise-cv-threshold + Rerun when total-runtime coefficient of variation is above this value + --max-reruns Max rerun cycles when noise gate triggers + --rerun-iterations + Extra measured iterations per rerun + --reset Re-measure every commit in range, discarding stored results `); } @@ -211,6 +218,13 @@ function backfillCommand(args: Record): void { warmup: args["warmup"] ? parseInt(args["warmup"], 10) : undefined, specs: args["specs"], specsDir: args["specs-dir"], + noiseCvThreshold: args["noise-cv-threshold"] + ? parseFloat(args["noise-cv-threshold"]) + : undefined, + maxReruns: args["max-reruns"] ? parseInt(args["max-reruns"], 10) : undefined, + rerunIterations: args["rerun-iterations"] ? parseInt(args["rerun-iterations"], 10) : undefined, + resultsDir: args["results-dir"], + reset: args["reset"] === "true", }); } diff --git a/packages/benchmark/src/generate-history.ts b/packages/benchmark/src/generate-history.ts index 8d3867cfae..4e8317fea6 100644 --- a/packages/benchmark/src/generate-history.ts +++ b/packages/benchmark/src/generate-history.ts @@ -1,26 +1,110 @@ /* eslint-disable no-console */ -import { execSync } from "node:child_process"; import { readdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import type { BenchmarkResult, RuntimeStats, SpecBenchmarkResult } from "./types.js"; +import { median } from "./statistics.js"; +import type { + BenchmarkResult, + CalibrationInfo, + RunnerInfo, + RuntimeStats, + SpecBenchmarkResult, +} from "./types.js"; +import { DEFAULT_BRANCH, listResultBlobs, readBlobs } from "./utils.js"; + +/** + * Schema version of `history.json`. + * + * Result files gained fields over time with no way to tell which ones a given + * file predates, so consumers had to guess. This lets them check instead. + * + * 1. Metrics only. + * 2. Adds `runner` and `quality` to every entry. + * 3. Adds `calibration` and a `normalization` factor per entry. + */ +export const HISTORY_VERSION = 3; + +/** Why a point may not be comparable with the ones around it. */ +export type EntryFlag = + /** Far enough from its neighbors to be a contended runner, not a change. */ + | "outlier" + /** Averaged over too few iterations to separate signal from noise. */ + | "low-iterations" + /** Measured on a different platform than the rest of the series. */ + | "foreign-runner" + /** No machine-speed measurement, so its value still carries the runner's speed. */ + | "uncalibrated"; + +/** How much weight a single point deserves. */ +export interface EntryQuality { + /** Measured iterations, lowest across the specs in this run. */ + iterations: number; + /** Spread of `total`, highest across the specs. Null if the run predates it. */ + cv: number | null; + /** Empty when the point is directly comparable with its neighbors. */ + flags: EntryFlag[]; +} /** A single entry in the aggregated history. */ export interface HistoryEntry { commit: string; timestamp: string; + /** + * When the commit landed, when known. + * + * Points are placed and ordered by this rather than by `timestamp`, so that a + * backfill measuring a year of history in an afternoon still lands each point + * where it belongs. + */ + commitDate?: string; /** Averaged metrics across all specs */ metrics: Record; /** Per-spec metrics (spec name → flat metrics) */ specMetrics: Record>; + /** + * Machine the run was measured on. + * + * Node and runner image changes move these numbers by more than most real + * regressions do, so a point is only meaningful alongside its environment. + */ + runner?: RunnerInfo; + /** Speed of the machine this point was measured on, against a frozen reference. */ + calibration?: CalibrationInfo; + /** + * Multiply any raw metric by this to compare it across machines. + * + * CI hands out runners that differ by more than 60% in speed, which swamps + * real changes. Machine speed scales TypeSpec workloads more or less + * uniformly, so dividing by a frozen reference measured in the same job + * removes it. Absent when the point has no usable calibration; raw values are + * never rewritten, so the correction stays auditable. + */ + normalization?: number; + quality: EntryQuality; +} + +/** How raw values were made comparable across machines. */ +export interface NormalizationInfo { + /** Frozen workload the factors are anchored to. */ + workload: string; + /** Pinned compiler the reference was measured with. */ + compilerVersion: string; + /** Reference time factors are relative to; the median across calibrated entries. */ + baseline: number; + /** How many entries carry a usable calibration. */ + calibratedEntries: number; } /** The full history.json structure. */ export interface HistoryData { + /** See {@link HISTORY_VERSION}. */ + version: number; generated: string; labels: string[]; /** All spec names found across all entries */ specNames: string[]; + /** Absent when no entry carries a calibration. */ + normalization?: NormalizationInfo; entries: HistoryEntry[]; } @@ -74,15 +158,160 @@ function averageAcrossSpecs(specs: Record): Record< return avg; } -function gitShow(path: string): string | null { - try { - return execSync(`git show benchmark-data:${path}`, { - encoding: "utf-8", - maxBuffer: 50_000_000, - }); - } catch { - return null; +/** Runs averaged over fewer iterations than this are too coarse to compare. */ +const MIN_TRUSTWORTHY_ITERATIONS = 5; + +/** Neighbors weighed when deciding whether a point is an isolated spike. */ +const OUTLIER_WINDOW = 11; + +/** + * How far from its neighbors a point has to sit to be called a spike. + * + * Deliberately far beyond any plausible regression: the point of this flag is + * to catch a contended runner, not to second-guess real changes. A shift that + * persists moves the neighboring median with it and is never flagged. + */ +const OUTLIER_RATIO = 2; + +/** Summarize how much weight a run's numbers deserve. */ +function measureQuality(specs: Record): EntryQuality { + const results = Object.values(specs); + const spreads = results + .map((spec) => spec.variability?.total.cv) + .filter((cv): cv is number => cv !== undefined); + + return { + // The weakest spec sets the confidence for the run as a whole. + iterations: results.length > 0 ? Math.min(...results.map((spec) => spec.iterations ?? 0)) : 0, + cv: spreads.length > 0 ? Math.max(...spreads) : null, + flags: [], + }; +} + +/** Platform identity, ignoring the kernel build that changes constantly. */ +function platformOf(runner: RunnerInfo | undefined): string | null { + if (!runner) return null; + return `${runner.os.split("-")[0]}-${runner.arch}`; +} + +/** The platform most of the series was measured on. */ +function dominantPlatform(entries: HistoryEntry[]): string | null { + const counts = new Map(); + for (const entry of entries) { + const platform = platformOf(entry.runner); + if (platform) counts.set(platform, (counts.get(platform) ?? 0) + 1); + } + let best: string | null = null; + for (const [platform, count] of counts) { + if (best === null || count > counts.get(best)!) best = platform; + } + return best; +} + +/** + * Work out how much of each measurement was the machine rather than the code. + * + * Calibration is only meaningful against the same frozen workload, so a change + * to the reference splits the series: the yardstick most of the history was + * measured against wins, and points measured against any other are left + * uncorrected rather than silently rescaled against a different reference. + * + * Factors are expressed relative to the median calibrated machine, so + * normalized values stay in familiar milliseconds instead of a unitless ratio. + */ +function applyNormalization(entries: HistoryEntry[]): NormalizationInfo | undefined { + const byWorkload = new Map(); + for (const entry of entries) { + const calibration = entry.calibration; + if (!calibration || !(calibration.total > 0)) continue; + const key = `${calibration.compilerVersion}@${calibration.workload}`; + const group = byWorkload.get(key); + if (group) group.push(entry); + else byWorkload.set(key, [entry]); + } + + let dominant: HistoryEntry[] | undefined; + for (const group of byWorkload.values()) { + if (!dominant || group.length > dominant.length) dominant = group; + } + if (!dominant || dominant.length === 0) return undefined; + + const baseline = median(dominant.map((entry) => entry.calibration!.total)); + if (!(baseline > 0)) return undefined; + + for (const entry of dominant) { + entry.normalization = baseline / entry.calibration!.total; } + + const reference = dominant[0].calibration!; + return { + workload: reference.workload, + compilerVersion: reference.compilerVersion, + baseline, + calibratedEntries: dominant.length, + }; +} + +/** Where a point sits in history, preferring when the commit landed. */ +function orderOf(entry: HistoryEntry): number { + return new Date(entry.commitDate ?? entry.timestamp).getTime(); +} + +/** + * Mark points that cannot be read as part of the same series. + * + * A chart line implies every point was measured the same way. This history + * spans laptop runs, single-iteration runs and several runner images, so the + * entries that break that assumption are called out rather than silently + * plotted alongside the rest. + */ +function flagEntries(entries: HistoryEntry[]): void { + const expectedPlatform = dominantPlatform(entries); + const anyCalibrated = entries.some((entry) => entry.normalization !== undefined); + // Spike detection reads the same values a chart would, so that a fast or slow + // runner is not mistaken for a spike once it has been corrected for. + const totals = entries.map((entry) => { + const total = entry.metrics["total"]; + if (total === undefined) return null; + return total * (entry.normalization ?? 1); + }); + const reach = (OUTLIER_WINDOW - 1) / 2; + + entries.forEach((entry, index) => { + const flags = entry.quality.flags; + + if (entry.quality.iterations > 0 && entry.quality.iterations < MIN_TRUSTWORTHY_ITERATIONS) { + flags.push("low-iterations"); + } + + const platform = platformOf(entry.runner); + if (platform && expectedPlatform && platform !== expectedPlatform) { + flags.push("foreign-runner"); + } + + // Only worth pointing out once some of the series is corrected; a history + // with no calibration at all is uniformly uncorrected, not inconsistent. + if (anyCalibrated && entry.normalization === undefined) { + flags.push("uncalibrated"); + } + + const value = totals[index]; + if (value === null || value <= 0) return; + + const neighbors: number[] = []; + for (let i = index - reach; i <= index + reach; i++) { + if (i === index || i < 0 || i >= totals.length) continue; + const neighbor = totals[i]; + if (neighbor !== null && neighbor > 0) neighbors.push(neighbor); + } + // Too few neighbors to tell a spike from the start of a trend. + if (neighbors.length < 4) return; + + const expected = median(neighbors); + if (expected > 0 && (value > expected * OUTLIER_RATIO || value * OUTLIER_RATIO < expected)) { + flags.push("outlier"); + } + }); } interface ResultFile { @@ -90,43 +319,36 @@ interface ResultFile { content: string; } -function readFromDirectory(dir: string): ResultFile[] { +/** + * Yield result files one at a time. + * + * Every result file carries its raw per-iteration stats, which is ~95% of its + * size and of no use here, so holding all of them in memory at once costs + * hundreds of megabytes for a history this long. + */ +function* readFromDirectory(dir: string): Generator { const files = readdirSync(dir).filter( (f) => f.endsWith(".json") && f !== "latest.json" && f !== "history.json", ); console.error(`Found ${files.length} result files in ${dir}`); - const results: ResultFile[] = []; for (const file of files) { try { - const content = readFileSync(join(dir, file), "utf-8"); - results.push({ name: file, content }); + yield { name: file, content: readFileSync(join(dir, file), "utf-8") }; } catch { // skip unreadable files } } - return results; -} - -function readFromGitBranch(): ResultFile[] { - const fileList = execSync("git ls-tree --name-only benchmark-data -- results/", { - encoding: "utf-8", - }) - .trim() - .split("\n") - .filter( - (f) => f.endsWith(".json") && !f.includes("latest.json") && !f.includes("history.json"), - ); - console.error(`Found ${fileList.length} result files on benchmark-data branch`); - const results: ResultFile[] = []; - for (const file of fileList) { - const content = gitShow(file); - if (content) results.push({ name: file, content }); - } - return results; +} + +/** Yield result files from the data branch without a subprocess per file. */ +function* readFromGitBranch(branch: string): Generator { + const blobs = listResultBlobs(branch); + console.error(`Found ${blobs.length} result files on ${branch} branch`); + yield* readBlobs(blobs); } /** Generate a HistoryData object from a list of result files. */ -export function buildHistory(resultFiles: ResultFile[]): HistoryData { +export function buildHistory(resultFiles: Iterable): HistoryData { const entries: HistoryEntry[] = []; const allSpecNames = new Set(); @@ -144,15 +366,25 @@ export function buildHistory(resultFiles: ResultFile[]): HistoryData { entries.push({ commit: result.commit, timestamp: result.timestamp, + commitDate: result.commitDate, metrics, specMetrics, + runner: result.runner, + calibration: result.calibration, + quality: measureQuality(result.specs), }); } catch (e: any) { console.error(`Failed to parse ${name}: ${e.message}`); } } - entries.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + // Ordered by when each commit landed, falling back to measurement time for + // points recorded before the commit date was captured. Sorting by + // measurement time would put a re-measured commit at the end of the series + // rather than back in its place in history. + entries.sort((a, b) => orderOf(a) - orderOf(b)); + const normalization = applyNormalization(entries); + flagEntries(entries); const allLabels = new Set(); for (const entry of entries) { @@ -162,9 +394,11 @@ export function buildHistory(resultFiles: ResultFile[]): HistoryData { } return { + version: HISTORY_VERSION, generated: new Date().toISOString(), labels: [...allLabels].sort(), specNames: [...allSpecNames].sort(), + normalization, entries, }; } @@ -172,12 +406,17 @@ export function buildHistory(resultFiles: ResultFile[]): HistoryData { export interface GenerateHistoryOptions { /** Read results from a directory instead of the benchmark-data git branch. */ dir?: string; + /** Data branch to read results from when `dir` is not given. */ + branch?: string; } /** Generate history data from result files. */ export function generateHistory(options: GenerateHistoryOptions = {}): HistoryData { - const resultFiles = options.dir ? readFromDirectory(options.dir) : readFromGitBranch(); - return buildHistory(resultFiles); + return buildHistory( + options.dir + ? readFromDirectory(options.dir) + : readFromGitBranch(options.branch ?? DEFAULT_BRANCH), + ); } /** CLI entry point for generate-history. */ diff --git a/packages/benchmark/src/run.ts b/packages/benchmark/src/run.ts index e68aec1332..e2ac9aabba 100644 --- a/packages/benchmark/src/run.ts +++ b/packages/benchmark/src/run.ts @@ -6,6 +6,7 @@ import os from "os"; import { join, resolve } from "path"; import { fileURLToPath } from "url"; import { aggregateDurations } from "./aggregate.js"; +import { measureCalibration } from "./calibration.js"; import { EXTERNAL_SPEC_CONFIG, loadExternalSpecConfig, @@ -211,10 +212,13 @@ function averageRuntimeStats(runtimes: RuntimeStats[]): RuntimeStats { } function getRunnerInfo(): RunnerInfo { + const cpus = os.cpus(); return { os: `${os.platform()}-${os.release()}`, nodeVersion: process.version, arch: os.arch(), + cpu: cpus[0]?.model, + cores: cpus.length, }; } @@ -227,6 +231,25 @@ function getGitCommit(providedCommit?: string): string { } } +/** + * When the commit landed, as opposed to when it was measured. + * + * Uses the committer date rather than the author date, because that is the + * order the commits reached the branch; an author date can predate its own + * parent after a rebase. + */ +function getCommitDate(commit: string): string | undefined { + if (commit === "unknown") return undefined; + try { + return execSync(`git show -s --format=%cI ${commit}`, { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return undefined; + } +} + /** Run benchmarks for all discovered specs. */ export async function runBenchmarks(options: RunOptions): Promise { const specsDir = resolve(options.specsDir); @@ -242,6 +265,15 @@ export async function runBenchmarks(options: RunOptions): Promise = {}; const noiseCvThreshold = options.noiseCvThreshold; const maxReruns = options.maxReruns ?? 0; @@ -319,7 +351,9 @@ export async function runBenchmarks(options: RunOptions): Promise; } @@ -88,6 +102,24 @@ export interface RunnerInfo { os: string; nodeVersion: string; arch: string; + /** CPU model, which varies between CI runners and drives most of the spread. */ + cpu?: string; + /** Logical core count. */ + cores?: number; +} + +/** Measurement of a frozen reference workload, used to normalize away machine speed. */ +export interface CalibrationInfo { + /** Pinned compiler release the reference was compiled with. */ + compilerVersion: string; + /** Identifier of the frozen workload; changes invalidate cross-version comparison. */ + workload: string; + /** Median reference compile time in ms on this machine. */ + total: number; + /** Number of measured reference compiles. */ + iterations: number; + /** Coefficient of variation across those compiles. */ + cv: number; } /** A single metric comparison between baseline and current. */ diff --git a/packages/benchmark/src/utils.ts b/packages/benchmark/src/utils.ts index 43fc21743a..be21fcddb1 100644 --- a/packages/benchmark/src/utils.ts +++ b/packages/benchmark/src/utils.ts @@ -35,11 +35,20 @@ export function execOk(cmd: string, options?: { cwd?: string }): boolean { } } +/** Give git an identity to commit under, which a CI checkout does not have. */ +export function configureGitIdentity(cwd?: string): void { + git('config user.name "github-actions[bot]"', cwd); + git('config user.email "github-actions[bot]@users.noreply.github.com"', cwd); +} + /** List existing result SHAs on the benchmark-data branch. */ -export function listExistingResults(branch: string = DEFAULT_BRANCH): Set { +export function listExistingResults( + branch: string = DEFAULT_BRANCH, + dir: string = "results", +): Set { const existing = new Set(); try { - const fileList = git(`ls-tree --name-only origin/${branch} -- results/`); + const fileList = git(`ls-tree --name-only origin/${branch} -- ${dir}/`); for (const line of fileList.split("\n")) { const trimmed = line.trim(); if ( @@ -47,7 +56,7 @@ export function listExistingResults(branch: string = DEFAULT_BRANCH): Set line.trim()) + .filter( + (f) => f.endsWith(".json") && !f.includes("latest.json") && !f.includes("history.json"), + ); +} + +/** Blobs read per `git cat-file` call, balancing subprocess count against memory. */ +const BLOB_BATCH_SIZE = 50; + +/** + * Read blobs from a git branch in batches. + * + * One `git show` per file costs a process spawn per result, which dominates + * the history rebuild once the series is a few hundred runs long. `cat-file + * --batch` reads a whole group in one go, and batching keeps the decoded + * output bounded instead of materializing the entire branch at once. + */ +export function* readBlobs( + paths: string[], + branch: string = DEFAULT_BRANCH, +): Generator<{ name: string; content: string }> { + for (let start = 0; start < paths.length; start += BLOB_BATCH_SIZE) { + const batch = paths.slice(start, start + BLOB_BATCH_SIZE); + // Kept as a buffer: `cat-file` reports blob sizes in bytes, which only + // line up with string offsets while every blob happens to be ASCII. + const stdout: Buffer = execSync(`git cat-file --batch`, { + input: batch.map((path) => `${branch}:${path}`).join("\n") + "\n", + maxBuffer: 500_000_000, + }); + + // Each blob arrives as ` blob \n\n`. + let offset = 0; + for (let i = 0; i < batch.length; i++) { + const headerEnd = stdout.indexOf(0x0a, offset); + if (headerEnd === -1) return; + const size = Number(stdout.toString("utf-8", offset, headerEnd).split(" ")[2]); + if (!Number.isFinite(size)) { + // Missing object: git emits ` missing` with no body. + offset = headerEnd + 1; + continue; + } + const bodyStart = headerEnd + 1; + yield { name: batch[i], content: stdout.toString("utf-8", bodyStart, bodyStart + size) }; + offset = bodyStart + size + 1; + } + } +} diff --git a/packages/benchmark/test/generate-history.test.ts b/packages/benchmark/test/generate-history.test.ts new file mode 100644 index 0000000000..d7de4cb228 --- /dev/null +++ b/packages/benchmark/test/generate-history.test.ts @@ -0,0 +1,313 @@ +import { expect, it } from "vitest"; +import { buildHistory, HISTORY_VERSION } from "../src/generate-history.js"; +import type { + BenchmarkResult, + CalibrationInfo, + RunnerInfo, + SpecBenchmarkResult, +} from "../src/types.js"; + +interface SpecOptions { + total: number; + iterations?: number; + cv?: number; +} + +function spec({ total, iterations = 25, cv }: SpecOptions): SpecBenchmarkResult { + return { + name: "sample", + iterations, + rawIterations: [], + stats: { + complexity: { createdTypes: 0, finishedTypes: 0 }, + runtime: { + total, + loader: 0, + resolver: 0, + checker: 0, + validation: { total: 0, validators: {} }, + linter: { total: 0, rules: {} }, + emit: { total: 0, emitters: {} }, + }, + }, + ...(cv === undefined + ? {} + : { + variability: { + total: { + mean: total, + median: total, + stdDev: total * cv, + cv, + min: total, + max: total, + sampleCount: iterations, + }, + }, + }), + } as SpecBenchmarkResult; +} + +const LINUX: RunnerInfo = { os: "linux-6.11.0", nodeVersion: "v24.15.0", arch: "x64" }; + +function calibration(total: number, workload = "v1"): CalibrationInfo { + return { compilerVersion: "1.14.0", workload, total, iterations: 25, cv: 0.005 }; +} + +interface RunOptions extends SpecOptions { + day: number; + runner?: RunnerInfo; + calibration?: CalibrationInfo; + commitDate?: string; + measuredAt?: string; +} + +function run({ + day, + runner = LINUX, + calibration, + commitDate, + measuredAt, + ...specOptions +}: RunOptions): { + name: string; + content: string; +} { + const result: BenchmarkResult = { + commit: `commit-${day}`, + timestamp: measuredAt ?? new Date(Date.UTC(2026, 0, day)).toISOString(), + ...(commitDate ? { commitDate } : {}), + runner, + ...(calibration ? { calibration } : {}), + specs: { sample: spec(specOptions) }, + } as BenchmarkResult; + return { name: `commit-${day}.json`, content: JSON.stringify(result) }; +} + +/** A steady series long enough for the outlier window to have neighbors. */ +function steadySeries(count: number, total = 100) { + return Array.from({ length: count }, (_, i) => run({ day: i + 1, total })); +} + +it("buildHistory stamps the schema version", () => { + const history = buildHistory(steadySeries(3)); + expect(history.version).toBe(HISTORY_VERSION); +}); + +it("buildHistory carries the runner through to each entry", () => { + const history = buildHistory(steadySeries(3)); + expect(history.entries[0].runner).toEqual(LINUX); +}); + +it("buildHistory records iteration count and spread per entry", () => { + const history = buildHistory([run({ day: 1, total: 100, iterations: 25, cv: 0.02 })]); + expect(history.entries[0].quality.iterations).toBe(25); + expect(history.entries[0].quality.cv).toBe(0.02); +}); + +it("buildHistory reports no spread for runs measured before it was recorded", () => { + const history = buildHistory([run({ day: 1, total: 100 })]); + expect(history.entries[0].quality.cv).toBeNull(); +}); + +it("buildHistory leaves comparable entries unflagged", () => { + const history = buildHistory(steadySeries(11)); + expect(history.entries.every((entry) => entry.quality.flags.length === 0)).toBe(true); +}); + +it("buildHistory flags runs averaged over too few iterations", () => { + const history = buildHistory([run({ day: 1, total: 100, iterations: 1 })]); + expect(history.entries[0].quality.flags).toContain("low-iterations"); +}); + +it("buildHistory flags runs measured on a different platform than the rest", () => { + const files = steadySeries(10); + files.push( + run({ + day: 11, + total: 100, + runner: { os: "darwin-25.4.0", nodeVersion: "v24.15.0", arch: "arm64" }, + }), + ); + const history = buildHistory(files); + expect(history.entries.at(-1)!.quality.flags).toContain("foreign-runner"); + expect(history.entries[0].quality.flags).not.toContain("foreign-runner"); +}); + +it("buildHistory ignores kernel version differences on the same platform", () => { + const files = steadySeries(10); + files.push( + run({ + day: 11, + total: 100, + runner: { os: "linux-6.14.0", nodeVersion: "v24.17.0", arch: "x64" }, + }), + ); + const history = buildHistory(files); + expect(history.entries.at(-1)!.quality.flags).not.toContain("foreign-runner"); +}); + +it("buildHistory flags an isolated spike", () => { + const files = steadySeries(11); + files[5] = run({ day: 6, total: 900 }); + const history = buildHistory(files); + expect(history.entries[5].quality.flags).toContain("outlier"); +}); + +it("buildHistory flags an isolated dip", () => { + const files = steadySeries(11); + files[5] = run({ day: 6, total: 10 }); + const history = buildHistory(files); + expect(history.entries[5].quality.flags).toContain("outlier"); +}); + +it("buildHistory does not flag a change that persists", () => { + const files = [ + ...steadySeries(12), + ...Array.from({ length: 12 }, (_, i) => run({ day: i + 13, total: 300 })), + ]; + const history = buildHistory(files); + const flagged = history.entries.filter((entry) => entry.quality.flags.includes("outlier")); + expect(flagged).toEqual([]); +}); + +it("buildHistory does not flag when there are too few neighbors to judge", () => { + const history = buildHistory([run({ day: 1, total: 100 }), run({ day: 2, total: 900 })]); + expect(history.entries[1].quality.flags).not.toContain("outlier"); +}); + +it("buildHistory orders entries oldest first", () => { + const history = buildHistory([run({ day: 3, total: 100 }), run({ day: 1, total: 100 })]); + expect(history.entries.map((entry) => entry.commit)).toEqual(["commit-1", "commit-3"]); +}); + +it("buildHistory carries the calibration through to each entry", () => { + const history = buildHistory([run({ day: 1, total: 100, calibration: calibration(300) })]); + expect(history.entries[0].calibration).toEqual(calibration(300)); +}); + +it("buildHistory scales a fast machine's numbers up and a slow one's down", () => { + const history = buildHistory([ + run({ day: 1, total: 100, calibration: calibration(200) }), + run({ day: 2, total: 100, calibration: calibration(400) }), + run({ day: 3, total: 100, calibration: calibration(800) }), + ]); + + // The median calibrated machine anchors the series, so it is left untouched. + expect(history.entries[1].normalization).toBe(1); + // Half the reference time means a machine twice as fast, so its numbers are doubled. + expect(history.entries[0].normalization).toBe(2); + expect(history.entries[2].normalization).toBe(0.5); +}); + +it("buildHistory reports which reference the factors are anchored to", () => { + const history = buildHistory([ + run({ day: 1, total: 100, calibration: calibration(200) }), + run({ day: 2, total: 100, calibration: calibration(400) }), + ]); + + expect(history.normalization).toEqual({ + workload: "v1", + compilerVersion: "1.14.0", + baseline: 300, + calibratedEntries: 2, + }); +}); + +it("buildHistory leaves an uncalibrated history uncorrected", () => { + const history = buildHistory(steadySeries(3)); + expect(history.normalization).toBeUndefined(); + expect(history.entries[0].normalization).toBeUndefined(); + expect(history.entries[0].quality.flags).toEqual([]); +}); + +it("buildHistory never rewrites the raw measurement", () => { + const history = buildHistory([ + run({ day: 1, total: 100, calibration: calibration(200) }), + run({ day: 2, total: 100, calibration: calibration(400) }), + ]); + expect(history.entries[0].metrics["total"]).toBe(100); +}); + +it("buildHistory flags points missing a calibration the rest of the series has", () => { + const history = buildHistory([ + run({ day: 1, total: 100, calibration: calibration(300) }), + run({ day: 2, total: 100 }), + ]); + + expect(history.entries[0].quality.flags).not.toContain("uncalibrated"); + expect(history.entries[1].quality.flags).toContain("uncalibrated"); + expect(history.entries[1].normalization).toBeUndefined(); +}); + +it("buildHistory anchors to the reference most of the series was measured against", () => { + const history = buildHistory([ + ...Array.from({ length: 3 }, (_, i) => + run({ day: i + 1, total: 100, calibration: calibration(300) }), + ), + run({ day: 4, total: 100, calibration: calibration(600, "v2") }), + ]); + + expect(history.normalization?.workload).toBe("v1"); + // Rescaling against a different yardstick would invent a change that never happened. + expect(history.entries[3].normalization).toBeUndefined(); + expect(history.entries[3].quality.flags).toContain("uncalibrated"); +}); + +it("buildHistory does not call a slow runner a spike once it is corrected for", () => { + const entries = Array.from({ length: 11 }, (_, i) => + run({ day: i + 1, total: 100, calibration: calibration(300) }), + ); + // Same code on a machine three times slower: raw value looks like a spike. + entries[5] = run({ day: 6, total: 300, calibration: calibration(900) }); + + const history = buildHistory(entries); + expect(history.entries[5].quality.flags).not.toContain("outlier"); +}); + +it("buildHistory still reports a spike the machine cannot explain", () => { + const entries = Array.from({ length: 11 }, (_, i) => + run({ day: i + 1, total: 100, calibration: calibration(300) }), + ); + entries[5] = run({ day: 6, total: 300, calibration: calibration(300) }); + + const history = buildHistory(entries); + expect(history.entries[5].quality.flags).toContain("outlier"); +}); + +it("buildHistory ignores a calibration that measured nothing", () => { + const history = buildHistory([ + run({ day: 1, total: 100, calibration: calibration(0) }), + run({ day: 2, total: 100, calibration: calibration(300) }), + ]); + + expect(history.entries[0].normalization).toBeUndefined(); + expect(history.normalization?.calibratedEntries).toBe(1); +}); + +it("buildHistory places a point where its commit landed, not where it was measured", () => { + // A backfill measures old commits today, so measurement time would order the + // series backwards. + const history = buildHistory([ + run({ + day: 1, + total: 100, + commitDate: "2025-03-01T00:00:00.000Z", + measuredAt: "2026-09-07T00:00:00.000Z", + }), + run({ + day: 2, + total: 100, + commitDate: "2025-01-01T00:00:00.000Z", + measuredAt: "2026-09-08T00:00:00.000Z", + }), + ]); + + expect(history.entries.map((entry) => entry.commit)).toEqual(["commit-2", "commit-1"]); + expect(history.entries[0].commitDate).toBe("2025-01-01T00:00:00.000Z"); +}); + +it("buildHistory falls back to measurement time for points with no commit date", () => { + const history = buildHistory([run({ day: 3, total: 100 }), run({ day: 1, total: 100 })]); + expect(history.entries.map((entry) => entry.commit)).toEqual(["commit-1", "commit-3"]); +}); diff --git a/packages/benchmark/vitest.config.ts b/packages/benchmark/vitest.config.ts index e236e91da2..dfe77909d6 100644 --- a/packages/benchmark/vitest.config.ts +++ b/packages/benchmark/vitest.config.ts @@ -1,4 +1,13 @@ import { defineConfig, mergeConfig } from "vitest/config"; import { defaultTypeSpecVitestConfig } from "../../core/vitest.config.js"; -export default mergeConfig(defaultTypeSpecVitestConfig, defineConfig({})); +export default mergeConfig( + defaultTypeSpecVitestConfig, + defineConfig({ + test: { + // Running the benchmarks locally emits generated clients next to the + // specs, and those ship their own sample tests. + exclude: ["**/node_modules/**", "**/dist/**", "specs/*/tsp-output/**"], + }, + }), +);