Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/benchmarks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,13 @@ jobs:
with:
version: '0.114.1'

- name: 💰 Large-repository fixture cache
- name: 💰 Imported fixture cache
uses: actions/cache@v6
with:
# The fixture file pins the corpus revision. Including its hash keeps
# a prior corpus out of the cache after either constant changes.
path: target/wt-perf/bench-repos
key: bench-repos-large-repository-${{ runner.os }}-${{ hashFiles('benches/large-repository-fixture') }}
key: bench-repos-imported-${{ runner.os }}-${{ hashFiles('benches/imported-fixture') }}

- name: 📊 Run benchmarks
run: cargo bench
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ syntax-highlighting = ["dep:tree-sitter-bash", "dep:tree-sitter-highlight"]
# Includes: shell wrapper tests, PTY-based approval prompts, TUI select, progressive rendering
shell-integration-tests = []
# Enable benchmarks whose fixture is too large for hosted CI: the
# large-repository prune fixture (~15 GiB of linked worktrees, cached under
# Large-repository prune fixture (multi-gigabyte linked worktrees, cached under
Comment thread
max-sixty marked this conversation as resolved.
Resolved
# target/wt-perf/bench-repos). The daily benchmarks workflow runs plain `cargo bench`
# and must never build it; opt in locally with
# `cargo bench --bench prune --features large-repository-benches`.
Expand Down
219 changes: 104 additions & 115 deletions benches/CLAUDE.md

Large diffs are not rendered by default.

103 changes: 40 additions & 63 deletions benches/alias.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,16 @@
// dramatically slower than the equivalent subcommand; these benchmarks give
// that cost a regression-free measurement harness.
//
// One group (`dispatch`), nine variants:
// One group (`dispatch`), five variants:
// - wt_version: `wt --version` startup floor (no repo discovery)
// - warm/1, warm/100, cold/1, cold/100: stub alias at 1 and 100 worktrees,
// warm and cold caches. Each worktree has its own branch, so 100
// worktrees ≈ 101 branches — this doubles as the regression guard for
// the O(1) upstream lookup from 4f9bd575a. The cold/100 variant is
// where a regression to the pre-fix bulk `for-each-ref` would hurt
// most (packed-refs scan dominates).
// - with_vars/{warm,cold}/{1,100}: same axes, but the alias body
// references several template vars that drive expensive paths inside
// `build_hook_context` (rev-parse for `commit`, `default_branch`
// detection, `primary_worktree` lookup, upstream lookup). Pairs with
// stub to bracket the dispatch curve once a future filter scopes
// context build to referenced vars only — until then with_vars tracks
// stub within noise, since context build is unconditional.
// - stub/1, stub/100: cache-independent alias at the population endpoints.
// - with_vars/{warm,cold}/100: neighbors that add rev-parse, default-branch
// detection, and primary worktree lookup, then isolate their cache cost.
//
// Run examples (Criterion takes a positional substring FILTER; no --skip):
// cargo bench --bench alias # All variants
// cargo bench --bench alias warm # Warm only (incl. with_vars/warm)
// cargo bench --bench alias dispatch/warm # Stub-warm only (excludes with_vars)
// cargo bench --bench alias dispatch/cold # Stub-cold only (excludes with_vars)
// cargo bench --bench alias stub # Population endpoints
// cargo bench --bench alias with_vars # Cache pair
// cargo bench --bench alias -- --sample-size 10 # Fast iteration

use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
Expand All @@ -37,20 +26,17 @@ use wt_perf::{
CacheState, FixtureRecipe, bench_wt, run_and_check, standard_benchmark_profile, wt_command,
};

/// Alias body is a shell builtin so the wall-clock is dominated by the
/// parent's dispatch — not by running a real subcommand.
const STUB_CONFIG: &str = "[aliases]\nstub = \": fixed fixed fixed fixed fixed fixed\"\n";
const LARGE_WORKTREE_COUNT: usize = 100;

/// Alias body references vars that drive the expensive paths in
/// `build_hook_context`: `commit` / `short_commit` (`rev-parse <branch>`
/// fork), `default_branch` (cold detection), `primary_worktree_path`
/// (lookup). `branch` and `worktree_path` are nearly free but kept so a
/// future filter sees them as "referenced". `upstream` is omitted because
/// fixture branches have no configured upstream — adding one would change
/// what the stub variant measures and break label continuity. Both aliases
/// use the same no-output builtin and argument count.
const WITH_VARS_CONFIG: &str = r#"[aliases]
with_vars = ": {{ branch }} {{ default_branch }} {{ commit }} {{ short_commit }} {{ primary_worktree_path }} {{ worktree_path }}"
/// Both alias bodies are shell builtins so the wall-clock is dominated by the
/// parent's dispatch rather than by a real subcommand. `with_vars` references
/// variables that drive the expensive paths in
/// `build_hook_context`: `commit` (rev-parse), `default_branch` (cold
/// detection), and `primary_worktree_path` (lookup). The aliases have the same
/// no-output builtin and argument count.
const ALIAS_CONFIG: &str = r#"[aliases]
stub = ": fixed fixed fixed"
with_vars = ": {{ default_branch }} {{ commit }} {{ primary_worktree_path }}"
"#;

/// Build an isolated `wt` invocation pointed at a fixture user config.
Expand All @@ -76,44 +62,35 @@ fn bench_dispatch(c: &mut Criterion) {
});
});

for total_worktrees in [1usize, 100] {
let fixture = FixtureRecipe::Minimal {
branchless_branches: 0,
for total_worktrees in [1, LARGE_WORKTREE_COUNT] {
let fixture = FixtureRecipe::Generated {
linked_worktrees: total_worktrees - 1,
branchless_branches: 0,
remote_tracking_refs: 0,
}
.create();
let stub_config = fixture.root().join("stub-config.toml");
let vars_config = fixture.root().join("with-vars-config.toml");
std::fs::write(&stub_config, STUB_CONFIG).unwrap();
std::fs::write(&vars_config, WITH_VARS_CONFIG).unwrap();
let user_config = fixture.root().join("config.toml");
std::fs::write(&user_config, ALIAS_CONFIG).unwrap();

// Stub variants keep their label structure (`dispatch/{warm,cold}/{1,100}`)
// so nightly bench history stays continuous. with_vars sits under a
// `with_vars/` prefix as new siblings.
for (id_prefix, alias_name, user_config) in [
(None, "stub", &stub_config),
(Some("with_vars"), "with_vars", &vars_config),
] {
for cache in CacheState::WARM_AND_COLD {
let cache_label = cache.label();
let id = match id_prefix {
Some(prefix) => format!("{prefix}/{cache_label}"),
None => cache_label.to_string(),
};
let mut cases = vec![("stub", "stub", CacheState::Warm)];
// Template-variable and cache effects need one shared population.
if total_worktrees == LARGE_WORKTREE_COUNT {
cases.extend([
("with_vars/warm", "with_vars", CacheState::Warm),
("with_vars/cold", "with_vars", CacheState::Cold),
]);
}

group.bench_with_input(
BenchmarkId::new(id, total_worktrees),
&total_worktrees,
|b, _| {
// Cold matters here: `build_hook_context` resolves the
// default branch, which writes `worktrunk.default-branch`
// and would otherwise be a cache hit on iters 2-N.
bench_wt(b, fixture.path(), cache, || {
wt_cmd(binary, fixture.path(), user_config, &[alias_name])
});
},
);
}
for (id, alias_name, cache) in cases {
group.bench_with_input(
BenchmarkId::new(id, total_worktrees),
&total_worktrees,
|b, _| {
bench_wt(b, fixture.path(), cache, || {
wt_cmd(binary, fixture.path(), &user_config, &[alias_name])
});
},
);
}
}

Expand Down
30 changes: 16 additions & 14 deletions benches/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
//! `main` builds the rayon pool or parses a command, so nothing here shares
//! state with an ordinary `wt` invocation.
//!
//! **One benchmark, on the `mixed` fixture `full` already uses.** Completion
//! **One benchmark, on the generated fixture `full` already uses.** Completion
//! has no phases worth timing separately: it spawns, fills its caches on one
//! set of threads, scans refs and worktrees on another, prints, exits.
//! Splitting that into a variant per dimension re-measures the same startup
Expand All @@ -16,20 +16,20 @@
//! benches/CLAUDE.md, and note that a `-vv` run skips prewarm's rev-parse
//! batch and so is not quite the run users get.
//!
//! `mixed-80-80-1400` is that one repo: 80 worktrees in four states, 80 more
//! branches forked across 200 commits of history, and 1400 remote-tracking
//! refs. It covers all three categories `BranchCompleter` distinguishes, puts
//! both candidate slow calls (`for-each-ref refs/remotes/` and `worktree list
//! --porcelain`) in the same process at a size where each is expensive, and
//! lands past the completer's 100-entry threshold — where the remote refs are
//! scanned and then discarded, as they are on a real long-lived clone.
//! The benchmark uses the same 24 linked worktrees and 120 branchless branches
//! as `full`, plus 1400 remote-tracking refs. It covers all three categories
//! `BranchCompleter` distinguishes and runs both candidate slow calls
//! (`for-each-ref refs/remotes/` and `worktree list --porcelain`) in one
//! process at a size where each is expensive. Its 145 local candidates also
//! exceed the completer's 100-entry threshold, where remote refs are scanned
//! and then discarded as they are on a long-lived clone.
//!
//! The remote-ref count is what this bench adds to the shared fixture; `full`
//! passes 0 and is unaffected.
//!
//! ```bash
//! cargo bench --bench completion
//! cargo run -p wt-perf -- setup mixed 80 80 1400 --path /tmp/clone
//! cargo run -p wt-perf -- setup generated 24 120 1400 --path target/wt-generated-completion
//! ```

use criterion::{Criterion, criterion_group, criterion_main};
Expand Down Expand Up @@ -74,15 +74,17 @@ fn assert_completion_candidates(binary: &Path, repo_path: &Path, expected: &BTre
fn bench_completion_switch(c: &mut Criterion) {
let mut group = c.benchmark_group("completion_switch");
let binary = Path::new(env!("CARGO_BIN_EXE_wt"));
let (linked_worktrees, branchless_branches) = (24, 120);

group.bench_function("mixed", |b| {
let fixture = FixtureRecipe::Mixed {
linked_worktrees: 80,
branchless_branches: 80,
group.bench_function("full_surface", |b| {
let fixture = FixtureRecipe::Generated {
linked_worktrees,
branchless_branches,
remote_tracking_refs: 1400,
}
.create();
assert_completion_candidates(binary, fixture.path(), &expected_branches(80, 80));
let expected = expected_branches(branchless_branches, linked_worktrees);
assert_completion_candidates(binary, fixture.path(), &expected);
b.iter(|| run_completion(binary, fixture.path(), &["wt", "switch", ""]));
});

Expand Down
File renamed without changes.
37 changes: 21 additions & 16 deletions benches/list.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
// Benchmarks for `wt list` command
//
// Benchmark groups:
// - skeleton: Time until the skeleton paints (1, 4, 8 worktrees; warm + cold)
// - worktree_scaling: Full execution, worktree-count scaling (1, 4, 8 worktrees; warm + cold)
// - skeleton: Time until the skeleton paints (1 and 8 worktrees; warm + cold)
// - worktree_scaling: Full execution at 1 and 8 worktrees (warm + cold)
// - full: One combined full-surface fixture — many worktrees AND many branches
// in varied states, with branch divergence spread across history depth.
// The realistic "everything at once" workload (warm + cold).
// - divergent_branches: 200 branches × 20 commits / GH #461 deep-divergence stress (warm + cold)
// - large_repository: one pinned large corpus with 8 worktrees and 50 branches
// - divergent_branches: 200 branches spread across history depth / GH #461 stress (warm + cold)
// - large_repository: one pinned upstream corpus with 8 worktrees and 50 branches
// spread through history; default list (warm + cold) and --branches (warm)
//
// Attribution: a `full` wall time can't be split by side (worktree- and
Expand All @@ -21,7 +21,7 @@
// cargo bench --bench list # All benchmarks
// cargo bench --bench list skeleton # Progressive rendering
// cargo bench --bench list full # Combined full-surface fixture
// cargo bench --bench list large_repository # Canonical large repo
// cargo bench --bench list large_repository # Large-repository workload
// cargo bench --bench list warm # Warm-cache variants (every group's warm rows)
// cargo bench --bench list skeleton/warm # Skeleton group, warm only

Expand All @@ -32,7 +32,7 @@ use wt_perf::{
CacheState, FixtureRecipe, bench_wt, run_git, standard_benchmark_profile, wt_command,
};

const WORKTREE_COUNTS: [usize; 3] = [1, 4, 8];
const WORKTREE_COUNTS: [usize; 2] = [1, 8];

/// Run `wt` with `args` in `repo_path`, on a warm or cold cache.
///
Expand Down Expand Up @@ -66,7 +66,7 @@ fn bench_skeleton(c: &mut Criterion) {
BenchmarkId::new(cache.label(), total_worktrees),
&cache,
|b, &cache| {
let fixture = FixtureRecipe::Typical { total_worktrees }.create();
let fixture = FixtureRecipe::generated(total_worktrees - 1).create();
run_benchmark(
b,
binary,
Expand All @@ -93,7 +93,7 @@ fn bench_worktree_scaling(c: &mut Criterion) {
BenchmarkId::new(cache.label(), total_worktrees),
&cache,
|b, &cache| {
let fixture = FixtureRecipe::Typical { total_worktrees }.create();
let fixture = FixtureRecipe::generated(total_worktrees - 1).create();
run_git(fixture.path(), &["status"]);
run_benchmark(b, binary, fixture.path(), cache, &["list"], None);
},
Expand Down Expand Up @@ -136,7 +136,7 @@ fn bench_large_repository(c: &mut Criterion) {
&cache,
|b, &cache| {
let fixture = fixture.get_or_init(|| {
let fixture = FixtureRecipe::LargeRepository.create();
let fixture = FixtureRecipe::Imported.create();
run_git(fixture.path(), &["status"]);
fixture
});
Expand All @@ -157,7 +157,12 @@ fn bench_divergent_branches(c: &mut Criterion) {

for cache in CacheState::WARM_AND_COLD {
group.bench_function(cache.label(), |b| {
let fixture = FixtureRecipe::SyntheticDivergence.create();
let fixture = FixtureRecipe::Generated {
linked_worktrees: 0,
branchless_branches: 200,
remote_tracking_refs: 0,
}
.create();
run_git(fixture.path(), &["status"]);
run_benchmark(
b,
Expand All @@ -178,7 +183,7 @@ fn bench_divergent_branches(c: &mut Criterion) {
/// command exercised by one fixture instead of several narrow ones. This is the
/// realistic "lots of worktrees & branches, all in various states" workload.
///
/// [`FixtureRecipe::Mixed`] builds the spread of `wt list` gates and tasks at once:
/// The generated fixture builds the spread of `wt list` gates and tasks at once:
/// clean/dirty/staged working trees, merged/ahead/diverged branches, and the
/// GH #461 deep-divergence shape (branches forking at points spread across
/// history depth, so the `git for-each-ref %(ahead-behind)` walk has real
Expand Down Expand Up @@ -206,10 +211,10 @@ fn bench_divergent_branches(c: &mut Criterion) {
/// network-touching `ci` column that `--full` would add.
fn bench_full(c: &mut Criterion) {
let mut group = c.benchmark_group("full");
// Heavy fixture (24 worktrees + 120 branches, deep history): the cold
// variant runs well over the inherited 30-sample / 15s budget, so cap
// samples at criterion's minimum and give a 20s window (≈ a few iters per
// sample), matching the other heavy groups.
// Heavy fixture (24 linked worktrees + 120 branchless branches, deep
// history): the cold variant runs well over the inherited 30-sample / 15s
// budget. Cap samples at criterion's minimum and give a 20s window (≈ a
// few iters per sample), matching the other heavy groups.
group.measurement_time(std::time::Duration::from_secs(20));
group.sample_size(10);

Expand All @@ -218,7 +223,7 @@ fn bench_full(c: &mut Criterion) {

for cache in CacheState::WARM_AND_COLD {
group.bench_function(cache.label(), |b| {
let fixture = FixtureRecipe::Mixed {
let fixture = FixtureRecipe::Generated {
linked_worktrees,
branchless_branches,
remote_tracking_refs: 0,
Expand Down
18 changes: 7 additions & 11 deletions benches/picker_preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,13 @@
// #2685 / #2704 actually pushes on.
//
// Benchmark variants:
// - picker_preview/warm/typical-8
// - picker_preview/cold/typical-8
// - picker_preview/warm/8-worktrees
// - picker_preview/cold/8-worktrees
//
// Run examples:
// cargo bench --bench picker_preview # all variants
// cargo bench --bench picker_preview warm # warm only
// cargo bench --bench picker_preview -- --exact picker_preview/warm/typical-8
// cargo bench --bench picker_preview -- --exact picker_preview/warm/8-worktrees

#[cfg(not(unix))]
fn main() {
Expand All @@ -52,12 +52,8 @@ use wt_perf::{CacheState, FixtureRecipe, bench_wt, wt_command};
#[cfg(unix)]
fn bench_picker_preview(c: &mut Criterion) {
let mut group = c.benchmark_group("picker_preview");
// The picker workload runs a few hundred ms warm on typical-8 (the
// ~1.4s median quoted in `src/commands/picker/mod.rs` is on a different
// fixture; measured ~320ms / ~370ms warm/cold here on a 14-core M-series
// box). Sticking with `sample_size(10)` per #2685's lead and budgeting
// 35s gives Criterion enough headroom to fit 10 samples without the
// "increase target time" warning under either cache mode.
// Use Criterion's minimum sample count and enough measurement time for the
// full preview workload under either cache mode.
group.sample_size(10);
group.measurement_time(std::time::Duration::from_secs(35));

Expand All @@ -66,10 +62,10 @@ fn bench_picker_preview(c: &mut Criterion) {

for cache in CacheState::WARM_AND_COLD {
group.bench_with_input(
BenchmarkId::new(cache.label(), format!("typical-{total_worktrees}")),
BenchmarkId::new(cache.label(), format!("{total_worktrees}-worktrees")),
&cache,
|b, &cache| {
let fixture = FixtureRecipe::Typical { total_worktrees }.create();
let fixture = FixtureRecipe::generated(total_worktrees - 1).create();

let make_cmd = || {
let mut cmd = wt_command(binary, fixture.path(), None);
Expand Down
Loading
Loading