cargo_bench_history/lib.rs
1#![cfg_attr(all(coverage_nightly, test), feature(coverage_attribute))]
2#![cfg_attr(docsrs, feature(doc_cfg))]
3#![expect(
4 clippy::exhaustive_enums,
5 clippy::exhaustive_structs,
6 reason = "this crate's `pub` items form a handoff boundary between lib.rs and \
7 the in-crate binary plus integration tests, used only inside this \
8 workspace rather than as a stable public API, so exhaustive \
9 construction and matching by those in-workspace consumers is intended"
10)]
11
12//! Maintain a long-lived history of benchmark results and analyze it for trends
13//! that snapshot-only tooling cannot see.
14//!
15//! Most benchmark tooling only reports the current run, or at best compares
16//! against the previous local run. `cargo-bench-history` instead stores *every*
17//! run as an immutable result set — on the local filesystem or in an Azure Blob
18//! container — and reconstructs each benchmark's series
19//! in git first-parent commit order, so historical trends become analyzable
20//! ("benchmark X has been getting incrementally slower over the past 12 months",
21//! "scenario Y regressed after commit Z, visible only in hindsight against noisy
22//! data").
23//!
24//! # Quick start
25//!
26//! ```text
27//! cargo bench-history install # write a starter .cargo/bench_history.toml
28//! cargo bench-history collect --local=./bench-history # bench the current commit and store the results
29//! cargo bench-history backfill --local=./bench-history <from> <to> # bootstrap history from past commits
30//! cargo bench-history analyze --local=./bench-history # report regressions and drift over history
31//! ```
32//!
33//! `collect` benches the current commit and stores the results in the selected
34//! storage; `backfill` benches a range of past commits so there is a trend to
35//! analyze (a single `collect` on its own has nothing to compare against); `analyze`
36//! reads the accumulated history back and reports what changed. Run these from the
37//! repository whose benchmarks you are tracking.
38//!
39//! Storage is selected at run time: `--local=<path>` (or a bare `--local`, taking
40//! the path from `CARGO_BENCH_HISTORY_STORAGE`) selects local filesystem storage,
41//! while omitting `--local` uses the optional Azure backend configured in
42//! `.cargo/bench_history.toml`. `install` writes that starter config; a local path
43//! is machine-dependent, so it is supplied per command rather than stored in the
44//! shared file.
45//!
46//! # How results are stored
47//!
48//! Each run is stored as one immutable result set per benchmark engine. The
49//! storage is partitioned *only* by the factors that make two results
50//! fundamentally incomparable — the **discriminant set**:
51//!
52//! * **engine** — the benchmark system (`callgrind`, `criterion`, `alloc_tracker`,
53//! `all_the_time`), since their numbers are not comparable to each other.
54//! * **target triple** — for example `x86_64-unknown-linux-gnu`.
55//! * **machine key** — a hardware fingerprint of the host the benchmark ran on.
56//! Every engine is partitioned by it (Callgrind instruction counts and
57//! `alloc_tracker` allocations included), because even those figures vary with
58//! the hardware in practice — libraries dispatch to different code paths on
59//! different microarchitectures — so results from different machines are never
60//! mixed.
61//!
62//! Everything else — toolchain version, OS, commit, CI provider — is recorded as
63//! metadata rather than forking history, so its effect stays *visible* as a step
64//! in the timeline. Results are keyed by commit: a **clean** run (committed tree)
65//! writes one deterministic object per commit, while a **dirty** run (uncommitted
66//! changes present) is stored separately as an ephemeral snapshot that analysis
67//! only admits on the branch you are working on.
68//!
69//! # Commands
70//!
71//! ## `collect`
72//!
73//! Executes the workspace benches with `cargo bench`, harvests every supported
74//! engine's machine-readable output, and stores one result set per engine. There
75//! is nothing to configure about engines: the run enables the combined
76//! environment the engines need and detects each engine from the output
77//! it produces (off Linux the Callgrind benches compile to no-ops, so only the
78//! host-runnable engines are stored). Re-running the same clean commit is refused
79//! as a duplicate unless `--overwrite` replaces the stored result; `--no-store`
80//! harvests and reports without writing. `--best-of N` reruns the whole suite `N`
81//! times and keeps the minimum value per metric — a noise-reduction pass for
82//! jittery runners, where interference only ever makes a case slower. A run is
83//! positioned on the timeline by where its commit sits in git history
84//! (first-parent topology), resolved live at analyze time — never by when the
85//! benchmarks happened to execute.
86//!
87//! ## `install`
88//!
89//! Generates a starter `.cargo/bench_history.toml` if absent, printing its path
90//! and next steps (including how to `backfill` history for an existing
91//! repository); an existing file is never overwritten.
92//!
93//! ## `backfill`
94//!
95//! Replays `collect` across the inclusive commit range `<from> <to>`, bootstrapping
96//! history for a repository that adopted the tool late. Each commit is checked out
97//! in a dedicated git **worktree** (the primary checkout is never touched, so a
98//! dirty working tree is fine) and benched there, taking its timeline position
99//! from where the commit sits in git history. The range `<from> <to>` only needs
100//! to form a first-parent chain; it does not have to lie on the current branch.
101//! The range is processed newest commit first, so a run cut short has filled the
102//! most recent — and most comparison-relevant — gaps. Each commit is built by the
103//! toolchain its own checkout selects, so a `rust-toolchain.toml` in history
104//! governs its own build and a `cargo +nightly run … backfill` does not impose
105//! `nightly` on it. Commits that already have a stored result for this host's
106//! target triple and machine key are skipped (so backfill is resumable and cheap
107//! to re-run); `--overwrite` re-benches the whole range. A commit that fails to
108//! build or bench stops the run unless `--ignore-errors` continues past it.
109//!
110//! ## `analyze`
111//!
112//! Reconstructs a timeline from git history and reports notable patterns. It
113//! requires a repository. Commits up to the merge-base with the base branch
114//! contribute clean runs only, while commits unique to the analyzed branch also
115//! contribute dirty snapshots unless `--no-dirty` is given. Pass one or more
116//! benchmark-id prefixes (matched against the qualified
117//! `<package>/<group>/<case>/<value>` identity) to scope the analysis to a subset
118//! of benchmarks. Findings are *advisory*: the exit code reflects only whether the
119//! analysis ran, never what it found. Downstream automation reads the
120//! machine-readable signal from the `json` report — `mode`, the named `outcome`,
121//! the backward-compatible boolean `notable` (any finding survived), each finding's
122//! `direction`, and the full per-finding `series` for charting. `--outcome <path>`
123//! writes just the stable outcome wire name for lightweight automation. See
124//! [Analyze modes](#analyze-modes) below.
125//!
126//! The optional `[ignore].benchmarks` array in `.cargo/bench_history.toml` excludes
127//! literal, case-sensitive benchmark-ID prefixes from analysis. It applies to all
128//! metrics of matching IDs, including exact-current collections, and cannot be
129//! overridden by positional analysis subjects. Collection and raw inspection remain
130//! unchanged. Reports disclose ignored series outside the in-scope coverage denominator;
131//! an entirely ignored selection has outcome `nothing_in_scope`, not `clean`.
132//! Removing a prefix restores eligibility of the stored history.
133//!
134//! ## `list`
135//!
136//! Previews, without analyzing, the data a matching `analyze` would consume. The
137//! subject is a bare word:
138//!
139//! * `list runs` — the run, series, and per-commit counts of the selected runs,
140//! per discriminant set.
141//! * `list discriminants` — the discriminant sets present in storage (no
142//! repository required), for discovering which engines, triples, and machine
143//! keys have data before scoping an analysis. This is a discovery catalog, so it
144//! lists *every* stored partition regardless of the current machine; pass a discriminant
145//! to narrow it.
146//! * `list blessings` — the blessings recorded at the current commit, or — with
147//! `--all` — the most recent blessing of every benchmark across the window.
148//!
149//! ## `prune`
150//!
151//! Deletes a chosen portion of the stored data, using the same selection pipeline
152//! as `analyze`/`list`. An action is required: `--dirty` removes ephemeral
153//! uncommitted-tree snapshots, `--clean` removes clean runs, and `--all` removes
154//! both. `--include-blessings` additionally deletes blessing sidecars in the
155//! selected range — including orphans on commits with no recorded run — and may be
156//! given on its own; pruning runs otherwise never removes a blessing. Pruning
157//! preserves base-branch history — only
158//! the context branch's own commits (those after the merge-base with `--base`) are
159//! removed. When the context resolves onto the base branch itself, the whole
160//! selection *is* base-branch history, so the deletion is refused unless
161//! `--prune-base` confirms it. Narrow the selection with a discriminant, a `<commit>`
162//! argument, or `--since`. `--dry-run` previews without deleting.
163//!
164//! ## `examine`
165//!
166//! A drill-down sibling of `list runs` over the same `analyze`/`list` selection: it
167//! pivots the small chart `analyze` draws into a per-commit listing. Given the two
168//! required options `--benchmark <qualified-id>` and `--metric <name>` — the one
169//! command that names a metric, meant to be pasted from an `analyze` finding — it
170//! lists every commit from the earliest one carrying that series in any matching
171//! discriminant set up to the analyzed tip, in git first-parent order and once per
172//! set, pairing the value with the short commit id and the first 50 characters of the
173//! commit's title (JSON keeps the full title and full-precision value). Every set
174//! shares that range, so their listings cover the same commits; a commit with no data
175//! point reads `n/a` (null in JSON), and one with several observations contributes a
176//! row per observation (clean before dirty, each flagged). It requires a repository
177//! and runs no detection or re-baselining. An unknown metric name is rejected. An
178//! empty pivot is explained by one of two hints: when no run enters the selection at
179//! all, the same "matched no runs" hint `analyze` gives; when runs enter but none
180//! carry the named `(benchmark, metric)` pair, a distinct hint pointing at the
181//! unmatched benchmark id or metric name.
182//!
183//! ## `bless` / `unbless`
184//!
185//! `bless` accepts an intentional performance change on the base branch so history
186//! analysis stops re-flagging it. Pass one or more benchmark-id prefixes to accept
187//! (matched against the qualified `<package>/<group>/<case>/<value>` identity, e.g.
188//! `bless all_the_time/read_cell`), or `--all` to accept every benchmark identity.
189//! A blessing re-baselines the benchmark's history from the blessed
190//! commit forward, so the accepted step is no longer reported while earlier points
191//! stay on the chart for context. Blessing prefers the base branch and an existing
192//! recorded run at the blessed commit, but neither is required: blessing off the base
193//! branch warns (the blessing only takes effect once the commit joins the base's
194//! first-parent history), and blessing a commit with no recorded run also warns.
195//! Omitted discriminants are unrestricted: acceptance persists across current and
196//! future partitions, even without a measurement at the anchor. Explicit discriminants
197//! restrict only the named axes; benchmark prefixes remain independent.
198//! By default `bless`/`unbless` act on
199//! `HEAD`; `--context <ref>` blesses or unblesses another commit instead. `unbless`
200//! removes whole records within its scope at that commit; a narrower scope that
201//! overlaps a broader record is rejected. With no discriminants, all scopes are selected.
202//! `list blessings --context <ref>` audits actual persisted scopes.
203//! Note that any blessings defined
204//! at *later* commits remain in force, so the timeline may stay blessed past the
205//! unblessed commit.
206//!
207//! ## `machine-key`
208//!
209//! Prints this machine's hardware fingerprint — the machine key every engine
210//! partitions its history by — to standard output as a single clean line, and exits.
211//! It probes only the host's hardware: no repository, git, config, or storage is
212//! touched. This is the key `collect` stamps every result with, so CI captures it and
213//! threads the exact keys a collection produced into the matching `analyze` selection
214//! (see the per-push and per-PR workflows). Under `--verbose` the individual factors
215//! behind the fingerprint (processor count, memory regions, processor models, and the
216//! factor-set version tag) are written to standard error, so a change in the key can be
217//! traced to the specific factor that moved.
218//!
219//! ## `setup-azure`
220//!
221//! Provisions an Azure history store and one managed identity for history-branch and
222//! pull-request workflows. The identity has account-scoped Storage Blob Data Contributor
223//! access; an optional custom user or group receives the same role. `--current-user`
224//! grants access to the Azure CLI signed-in user in the chosen subscription's tenant. Run
225//! `cargo bench-history setup-azure --help` for the explicit deployment inputs.
226//! Execution requires Azure CLI, PowerShell 7.6 or later, installed Bicep and an
227//! authenticated subscription. Repeated deployments preserve existing storage
228//! properties and data.
229//!
230//! `cargo bench-history setup-azure --out-dir ./azure-history` instead exports a
231//! self-contained deployment bundle without invoking tools or checking credentials.
232//!
233//! # Selecting data: options shared by the query commands
234//!
235//! `analyze`, `list`, `prune`, and `examine` share one selection model, organized
236//! in `--help` into named groups:
237//!
238//! * **Discriminant selection** (`--engine`, `--target-triple`, `--machine-key`) —
239//! chooses which discriminant sets to operate on. Each discriminant is repeatable
240//! (union of values) and defaults to the current machine's value when omitted
241//! (`--engine` defaults to every engine; `list discriminants` is a catalog and
242//! defaults to every partition). The literal `all` removes the filter
243//! for that dimension, e.g. `--machine-key all` spans every machine.
244//! * **Commit selection** (`--context`, `--base`, `--since`) — `--context` is the
245//! ref whose history is analyzed (default `HEAD`); `--base` is the ref it branched
246//! from (default: the configured or detected default branch), which determines the
247//! merge-base split. Because `--context` already anchors the newest edge of the
248//! timeline, `--since` is a one-sided cutoff: it bounds only the oldest commit to
249//! include by that commit's **committer date** (read from git history) and accepts
250//! an RFC 3339 timestamp, a `YYYY-MM-DD` date, or a relative duration such as
251//! `6 months ago`.
252//! * **Data filtering** (`--no-dirty`) — exclude dirty snapshots.
253//!
254//! # Analyze modes
255//!
256//! `analyze` runs in one of two modes, chosen automatically from the git topology
257//! (there is no flag to force a mode):
258//!
259//! * **history** — long-range trend watch over the base branch (selected when you
260//! analyze a clean checkout of the base branch). It detects sustained
261//! change-points and slow drifts, defaults `--since` to the last six months, and
262//! reports only **regressions** (steady improvement over time is expected).
263//! * **branch** — "how does my feature compare" (selected for a feature branch, or
264//! a dirty base checkout). It judges the branch by its **tip commit** versus the
265//! base — the intermediate commits are ignored, since only the tip lands in the
266//! base on merge — reporting both regressions and improvements.
267//!
268//! # Configuration
269//!
270//! `install` writes a starter `.cargo/bench_history.toml`. It carries an optional
271//! `[project]` id (used to namespace stored data; defaults to the workspace
272//! directory name) and an optional `[storage.<kind>]` **cloud** backend
273//! (`[storage.azure]` today). Local filesystem storage is **not** configured here:
274//! a local path is machine-dependent and this file is shared, so local storage is
275//! selected per command with `--local`:
276//!
277//! ```toml
278//! # [project]
279//! # id = "my-project"
280//! # default_branch = "main" # base branch for `analyze`; auto-detected by default
281//!
282//! # Optional: store results in an Azure Blob container. Omit entirely to use
283//! # local storage via --local instead.
284//! # [storage.azure]
285//! # account = "mystorageaccount"
286//! # container = "bench-history"
287//! ```
288//!
289//! Storage is resolved per command: `--local=<path>` selects local filesystem
290//! storage (a bare `--local` takes the path from `CARGO_BENCH_HISTORY_STORAGE`),
291//! otherwise the configured cloud backend is used; with neither, a storage-backed
292//! command errors (except `collect --no-store`, which stores nothing). Every command
293//! also accepts `--config PATH` to point at a non-default file, `--repo
294//! PATH` to resolve git state from another directory, and `--verbose` to emit a
295//! step-by-step diagnostic trail to standard error.
296//!
297//! ## Azure Blob storage
298//!
299//! The Azure backend authenticates with **Microsoft Entra ID** (OAuth): it stores
300//! no secret, and every request carries a short-lived token over an HTTPS endpoint
301//! (the default `https://<account>.blob.core.windows.net`). The account may — and
302//! should — have shared-key access disabled entirely.
303//!
304//! Use [`setup-azure`](#setup-azure) to provision an Entra-ID-backed store or export
305//! its standalone deployment bundle.
306//!
307//! For a manually managed alternative:
308//!
309//! 1. **Deploy a Storage account** reachable over HTTPS. Entra-only accounts
310//! (shared-key access disabled) are supported and preferred — there is then no
311//! account key to leak. The `bench-history` container does not need to pre-exist;
312//! `collect` creates it on first use.
313//! 2. **Grant collection and administration identities the `Storage Blob Data Contributor`
314//! role** on the account. Read-only queries need only `Storage Blob Data Reader`
315//! scoped to the existing history container.
316//! The contributor role covers both the blob read/write
317//! the tool performs and the container creation `collect` does on first use; the
318//! broader `Storage Blob Data Owner` is not needed for a flat blob container.
319//! Locally, that identity is your `az login` user; in CI it is the federated
320//! managed identity below.
321//! 3. **For CI, authenticate with GitHub OIDC workload identity federation** instead
322//! of a stored secret: create a user-assigned managed identity, add a federated
323//! credential whose subject matches the workflow's OIDC token, and whose audience
324//! is `api://AzureADTokenExchange`, then grant it the role from step 2. The subject
325//! must match **each event that runs the tool**, so register one credential per
326//! event: a run on the default branch presents
327//! `repo:<owner>/<repo>:ref:refs/heads/main`, while a pull-request-triggered run
328//! (for example a workflow that benchmarks a PR) presents
329//! `repo:<owner>/<repo>:pull_request` and needs its own credential with that
330//! subject. The PR subject cannot distinguish fork heads: workflow policy must
331//! restrict privileged identity use to **same-repository** pull requests.
332//! Run the tool from a job that has
333//! `permissions: { id-token: write }`, with the managed identity's client ID and
334//! your Entra tenant ID exported as the `AZURE_CLIENT_ID` and `AZURE_TENANT_ID`
335//! environment variables. The tool then mints a fresh OIDC assertion straight from
336//! GitHub's per-job token endpoint for each Entra token exchange, so it stays
337//! authenticated even across the hourly access-token refresh of a multi-hour
338//! benchmark run — no `azure/login` step and no stored secret are involved. (When
339//! those variables are absent — locally, or in a short job that runs `azure/login`
340//! — the tool instead picks up the ambient Azure CLI session.)
341//!
342//! The Folo-specific wrapper for the same standalone deployment bundle lives at
343//! <https://github.com/folo-rs/folo/tree/main/infra/azure-bench-history-prod> and a
344//! separate test account/identity at
345//! <https://github.com/folo-rs/folo/tree/main/infra/azure-bench-history-test>, with the
346//! account name baked into the committed `.cargo/bench_history.toml`, the per-push
347//! consumer at <https://github.com/folo-rs/folo/blob/main/.github/workflows/bench-history.yml>,
348//! and the per-pull-request consumer (which collects and compares a PR against `main`,
349//! and relies on the identity's `pull_request` federated credential) at
350//! <https://github.com/folo-rs/folo/blob/main/.github/workflows/pr-bench-history.yml>.
351
352mod commands;
353mod config_writer;
354mod dispatch;
355mod errors;
356mod outcome;
357mod output;
358mod output_destination;
359
360pub use cbh_analyze::{AnalysisOutcome, AutoDiscriminants};
361pub use cbh_cli::{Cli, EarlyExit};
362pub use cbh_command::{
363 AnalyzeOptions, BackfillOptions, BlessOptions, CacheSelection, CollectOptions, Command,
364 CustomPrincipalType, ExamineOptions, ImportOptions, InstallOptions, ListOptions, ListSubject,
365 LocalStorageSelection, MachineKeyOptions, PruneOptions, SetupAzureOptions, UnblessOptions,
366};
367pub use cbh_config::default_template;
368pub(crate) use cbh_model as model;
369pub use cbh_storage::StorageOverride;
370pub(crate) use cbh_storage::finish_with_flush;
371pub use dispatch::{Overrides, run, run_with_overrides};
372pub(crate) use errors::*;
373pub use model::{
374 BenchmarkId, BenchmarkIdPrefix, BenchmarkResult, EnvironmentInfo, EnvironmentProvider, GitInfo,
375 Metric, MetricKind, Run, RunContext, SCHEMA_VERSION, ToolchainInfo,
376};
377pub use outcome::RunOutcome;
378
379#[cfg(test)]
380::testing::set_allocator!();