M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
@@ -0,0 +1,808 @@
|
||||
//! Pool performance benchmark — emulates the A/B worktree pool lifecycle.
|
||||
//!
|
||||
//! Exercises the exact same primitives the production pool uses:
|
||||
//! 1. Create worktree (GitCheckout mode, like the pool fill task)
|
||||
//! 2. Warm git caches (git status, like the pool does before marking ready)
|
||||
//! 3. Sync (git reset --hard + git clean + dirty state copy, like acquire())
|
||||
//! 4. Simulate use (git status in the synced worktree)
|
||||
//! 5. Release (git reset --hard + git clean, like release())
|
||||
//! 6. Cleanup (git worktree remove, like shutdown/schedule_cleanup)
|
||||
//!
|
||||
//! Runs against a REAL repo (defaults to the current directory).
|
||||
//! Designed to be run from a large repo root to get realistic timings.
|
||||
//!
|
||||
//! Usage:
|
||||
//! cargo run --release --bin pool-perf-bench -- [--source /path/to/repo] [--iterations 3]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
|
||||
use kigi_fast_worktree::{CreationMode, WorktreeBuilder, WorktreeSync, remove_worktree};
|
||||
|
||||
// ============================================================================
|
||||
// CLI
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "pool-perf-bench")]
|
||||
#[command(about = "Benchmark worktree pool lifecycle (create/warm/sync/release/cleanup)")]
|
||||
struct Cli {
|
||||
/// Source repository path (default: current directory)
|
||||
#[arg(long, default_value = ".")]
|
||||
source: PathBuf,
|
||||
|
||||
/// Number of full lifecycle iterations
|
||||
#[arg(long, default_value = "3")]
|
||||
iterations: usize,
|
||||
|
||||
/// Number of parallel checkout workers (0 = auto)
|
||||
#[arg(long, default_value = "0")]
|
||||
parallelism: usize,
|
||||
|
||||
/// Whether to copy dirty state during sync
|
||||
#[arg(long)]
|
||||
copy_dirty: bool,
|
||||
|
||||
/// Enable verbose tracing output
|
||||
#[arg(short, long)]
|
||||
verbose: bool,
|
||||
|
||||
/// Run in A/B mode: create 2 worktrees concurrently, sync both, release both
|
||||
#[arg(long)]
|
||||
ab: bool,
|
||||
|
||||
/// Output results as JSON (for programmatic consumption)
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Timing structs
|
||||
// ============================================================================
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct PhaseTiming {
|
||||
name: String,
|
||||
duration_ms: f64,
|
||||
detail: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct IterationResult {
|
||||
iteration: usize,
|
||||
phases: Vec<PhaseTiming>,
|
||||
total_ms: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct BenchmarkResult {
|
||||
source: String,
|
||||
tracked_files: usize,
|
||||
iterations: Vec<IterationResult>,
|
||||
ab_mode: bool,
|
||||
summary: BenchmarkSummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct BenchmarkSummary {
|
||||
/// Per-phase averages across all iterations
|
||||
phase_averages: Vec<(String, f64)>,
|
||||
/// Total average
|
||||
total_avg_ms: f64,
|
||||
/// Slowest phase name and average
|
||||
bottleneck: (String, f64),
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Phase runners
|
||||
// ============================================================================
|
||||
|
||||
/// Phase 1: Create a linked worktree via GitCheckout mode (what the pool fill task does)
|
||||
fn phase_create(source: &Path, dest: &Path, parallelism: usize) -> Result<PhaseTiming> {
|
||||
let start = Instant::now();
|
||||
|
||||
WorktreeBuilder::new(source, dest)
|
||||
.creation_mode(CreationMode::GitCheckout)
|
||||
.parallelism(parallelism)
|
||||
.create()
|
||||
.context("WorktreeBuilder::create failed")?;
|
||||
|
||||
let ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
Ok(PhaseTiming {
|
||||
name: "create (GitCheckout)".into(),
|
||||
duration_ms: ms,
|
||||
detail: format!("git worktree add --detach with checkout.workers={parallelism}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Phase 2: Warm git caches (git status --porcelain) — populates fsmonitor, untracked cache
|
||||
fn phase_warm_caches(worktree: &Path) -> Result<PhaseTiming> {
|
||||
let start = Instant::now();
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(worktree)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.output()
|
||||
.context("git status for cache warming")?;
|
||||
|
||||
let ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
let detail = if output.status.success() {
|
||||
"git status --porcelain (populates fsmonitor + untracked cache)".into()
|
||||
} else {
|
||||
format!(
|
||||
"git status FAILED: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)
|
||||
};
|
||||
|
||||
Ok(PhaseTiming {
|
||||
name: "warm caches".into(),
|
||||
duration_ms: ms,
|
||||
detail,
|
||||
})
|
||||
}
|
||||
|
||||
/// Phase 2b: Second git status — measures the warm-cache speed (should be much faster)
|
||||
fn phase_warm_caches_2nd(worktree: &Path) -> Result<PhaseTiming> {
|
||||
let start = Instant::now();
|
||||
|
||||
Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(worktree)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.output()
|
||||
.context("git status (2nd run)")?;
|
||||
|
||||
let ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
Ok(PhaseTiming {
|
||||
name: "warm caches (2nd, hot)".into(),
|
||||
duration_ms: ms,
|
||||
detail: "git status --porcelain (should be fast with warm caches)".into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Phase 3: Sync — reset to source HEAD + clean + copy dirty state
|
||||
fn phase_sync(
|
||||
source: &Path,
|
||||
worktree: &Path,
|
||||
copy_dirty: bool,
|
||||
skip_clean: bool,
|
||||
) -> Result<PhaseTiming> {
|
||||
let start = Instant::now();
|
||||
|
||||
let sync = WorktreeSync::new(source, worktree);
|
||||
let report = sync
|
||||
.sync_worktree_opts(copy_dirty, skip_clean)
|
||||
.context("sync_worktree failed")?;
|
||||
|
||||
let ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
Ok(PhaseTiming {
|
||||
name: if skip_clean {
|
||||
"sync (skip_clean)".into()
|
||||
} else {
|
||||
"sync".into()
|
||||
},
|
||||
duration_ms: ms,
|
||||
detail: format!(
|
||||
"head_moved={} dirty_copied={} deleted={} staged={} clean_skipped={}",
|
||||
report.head_moved,
|
||||
report.dirty_files_copied,
|
||||
report.files_deleted,
|
||||
report.staged_entries,
|
||||
report.clean_skipped
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
/// Phase 3b: Measure git status speed AFTER sync (validates stat caches are intact)
|
||||
fn phase_post_sync_status(worktree: &Path) -> Result<PhaseTiming> {
|
||||
let start = Instant::now();
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(worktree)
|
||||
.output()
|
||||
.context("post-sync git status")?;
|
||||
|
||||
let ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
let line_count = output
|
||||
.stdout
|
||||
.split(|&b| b == b'\n')
|
||||
.filter(|l| !l.is_empty())
|
||||
.count();
|
||||
Ok(PhaseTiming {
|
||||
name: "post-sync git status".into(),
|
||||
duration_ms: ms,
|
||||
detail: format!("{line_count} dirty entries reported"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Phase 4: Simulate use — run git diff --stat (what an agent would do)
|
||||
fn phase_simulate_use(worktree: &Path) -> Result<PhaseTiming> {
|
||||
let start = Instant::now();
|
||||
|
||||
Command::new("git")
|
||||
.args(["diff", "--stat"])
|
||||
.current_dir(worktree)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.output()
|
||||
.context("git diff --stat")?;
|
||||
|
||||
let ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
Ok(PhaseTiming {
|
||||
name: "simulate use (git diff --stat)".into(),
|
||||
duration_ms: ms,
|
||||
detail: "simulates agent reading diff".into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Phase 5: Release — git reset --hard + git clean -fdx (what pool.release() does)
|
||||
fn phase_release(worktree: &Path) -> Result<PhaseTiming> {
|
||||
let start = Instant::now();
|
||||
|
||||
let r1 = Command::new("git")
|
||||
.args(["reset", "--hard", "HEAD"])
|
||||
.current_dir(worktree)
|
||||
.output()
|
||||
.context("git reset --hard")?;
|
||||
|
||||
let r2 = Command::new("git")
|
||||
.args(["clean", "-fdx"])
|
||||
.current_dir(worktree)
|
||||
.output()
|
||||
.context("git clean -fdx")?;
|
||||
|
||||
let ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
Ok(PhaseTiming {
|
||||
name: "release (reset+clean)".into(),
|
||||
duration_ms: ms,
|
||||
detail: format!(
|
||||
"reset_ok={} clean_ok={}",
|
||||
r1.status.success(),
|
||||
r2.status.success()
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
/// Phase 5b: Re-warm caches after release (what the pool does before marking .ready)
|
||||
fn phase_post_release_warm(worktree: &Path) -> Result<PhaseTiming> {
|
||||
let start = Instant::now();
|
||||
|
||||
Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(worktree)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.output()
|
||||
.context("post-release git status")?;
|
||||
|
||||
let ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
Ok(PhaseTiming {
|
||||
name: "post-release warm".into(),
|
||||
duration_ms: ms,
|
||||
detail: "git status after release to re-warm caches".into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Phase 6: Cleanup — rm -rf + deregister (fast) instead of git worktree remove (slow)
|
||||
fn phase_cleanup(_source: &Path, worktree: &Path) -> Result<PhaseTiming> {
|
||||
let start = Instant::now();
|
||||
|
||||
let report = remove_worktree(worktree).context("remove_worktree failed")?;
|
||||
|
||||
let ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
Ok(PhaseTiming {
|
||||
name: "cleanup (rm -rf + deregister)".into(),
|
||||
duration_ms: ms,
|
||||
detail: format!("btrfs={}", report.used_btrfs_delete),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// A/B mode: two worktrees concurrently
|
||||
// ============================================================================
|
||||
|
||||
fn run_ab_iteration(
|
||||
source: &Path,
|
||||
base_dir: &Path,
|
||||
iteration: usize,
|
||||
parallelism: usize,
|
||||
copy_dirty: bool,
|
||||
) -> Result<IterationResult> {
|
||||
let mut phases = Vec::new();
|
||||
let iter_start = Instant::now();
|
||||
|
||||
let wt_a = base_dir.join(format!("bench_wt_a_{iteration}"));
|
||||
let wt_b = base_dir.join(format!("bench_wt_b_{iteration}"));
|
||||
|
||||
// Phase 1: Create both worktrees (sequentially, like the fill task does)
|
||||
eprintln!(" [A/B] Creating worktree A...");
|
||||
{
|
||||
let mut p = phase_create(source, &wt_a, parallelism)?;
|
||||
p.name = "create A (GitCheckout)".into();
|
||||
phases.push(p);
|
||||
}
|
||||
|
||||
eprintln!(" [A/B] Creating worktree B...");
|
||||
{
|
||||
let mut p = phase_create(source, &wt_b, parallelism)?;
|
||||
p.name = "create B (GitCheckout)".into();
|
||||
phases.push(p);
|
||||
}
|
||||
|
||||
// Phase 2: Warm caches on both
|
||||
eprintln!(" [A/B] Warming caches A...");
|
||||
{
|
||||
let mut p = phase_warm_caches(&wt_a)?;
|
||||
p.name = "warm caches A".into();
|
||||
phases.push(p);
|
||||
}
|
||||
eprintln!(" [A/B] Warming caches B...");
|
||||
{
|
||||
let mut p = phase_warm_caches(&wt_b)?;
|
||||
p.name = "warm caches B".into();
|
||||
phases.push(p);
|
||||
}
|
||||
|
||||
// Phase 2b: Second warm (hot cache measurement)
|
||||
{
|
||||
let mut p = phase_warm_caches_2nd(&wt_a)?;
|
||||
p.name = "warm caches A (2nd, hot)".into();
|
||||
phases.push(p);
|
||||
}
|
||||
{
|
||||
let mut p = phase_warm_caches_2nd(&wt_b)?;
|
||||
p.name = "warm caches B (2nd, hot)".into();
|
||||
phases.push(p);
|
||||
}
|
||||
|
||||
// Phase 3: Sync both (this is what acquire() does after claim)
|
||||
eprintln!(" [A/B] Syncing A...");
|
||||
{
|
||||
let mut p = phase_sync(source, &wt_a, copy_dirty, /* skip_clean */ true)?;
|
||||
p.name = "sync A (skip_clean)".into();
|
||||
phases.push(p);
|
||||
}
|
||||
eprintln!(" [A/B] Syncing B...");
|
||||
{
|
||||
let mut p = phase_sync(source, &wt_b, copy_dirty, /* skip_clean */ true)?;
|
||||
p.name = "sync B (skip_clean)".into();
|
||||
phases.push(p);
|
||||
}
|
||||
|
||||
// Phase 3b: Post-sync git status (validates stat caches)
|
||||
{
|
||||
let mut p = phase_post_sync_status(&wt_a)?;
|
||||
p.name = "post-sync status A".into();
|
||||
phases.push(p);
|
||||
}
|
||||
{
|
||||
let mut p = phase_post_sync_status(&wt_b)?;
|
||||
p.name = "post-sync status B".into();
|
||||
phases.push(p);
|
||||
}
|
||||
|
||||
// Phase 4: Simulate use
|
||||
{
|
||||
let mut p = phase_simulate_use(&wt_a)?;
|
||||
p.name = "use A (git diff)".into();
|
||||
phases.push(p);
|
||||
}
|
||||
{
|
||||
let mut p = phase_simulate_use(&wt_b)?;
|
||||
p.name = "use B (git diff)".into();
|
||||
phases.push(p);
|
||||
}
|
||||
|
||||
// Phase 5: Release both
|
||||
eprintln!(" [A/B] Releasing A...");
|
||||
{
|
||||
let mut p = phase_release(&wt_a)?;
|
||||
p.name = "release A".into();
|
||||
phases.push(p);
|
||||
}
|
||||
eprintln!(" [A/B] Releasing B...");
|
||||
{
|
||||
let mut p = phase_release(&wt_b)?;
|
||||
p.name = "release B".into();
|
||||
phases.push(p);
|
||||
}
|
||||
|
||||
// Phase 5b: Post-release warm
|
||||
{
|
||||
let mut p = phase_post_release_warm(&wt_a)?;
|
||||
p.name = "post-release warm A".into();
|
||||
phases.push(p);
|
||||
}
|
||||
{
|
||||
let mut p = phase_post_release_warm(&wt_b)?;
|
||||
p.name = "post-release warm B".into();
|
||||
phases.push(p);
|
||||
}
|
||||
|
||||
// Phase 6: Cleanup both
|
||||
eprintln!(" [A/B] Cleaning up A...");
|
||||
{
|
||||
let mut p = phase_cleanup(source, &wt_a)?;
|
||||
p.name = "cleanup A".into();
|
||||
phases.push(p);
|
||||
}
|
||||
eprintln!(" [A/B] Cleaning up B...");
|
||||
{
|
||||
let mut p = phase_cleanup(source, &wt_b)?;
|
||||
p.name = "cleanup B".into();
|
||||
phases.push(p);
|
||||
}
|
||||
|
||||
let total_ms = iter_start.elapsed().as_secs_f64() * 1000.0;
|
||||
Ok(IterationResult {
|
||||
iteration,
|
||||
phases,
|
||||
total_ms,
|
||||
})
|
||||
}
|
||||
|
||||
fn run_single_iteration(
|
||||
source: &Path,
|
||||
base_dir: &Path,
|
||||
iteration: usize,
|
||||
parallelism: usize,
|
||||
copy_dirty: bool,
|
||||
) -> Result<IterationResult> {
|
||||
let mut phases = Vec::new();
|
||||
let iter_start = Instant::now();
|
||||
|
||||
let wt = base_dir.join(format!("bench_wt_{iteration}"));
|
||||
|
||||
eprintln!(" Creating worktree...");
|
||||
phases.push(phase_create(source, &wt, parallelism)?);
|
||||
|
||||
eprintln!(" Warming caches (1st)...");
|
||||
phases.push(phase_warm_caches(&wt)?);
|
||||
|
||||
eprintln!(" Warming caches (2nd, hot)...");
|
||||
phases.push(phase_warm_caches_2nd(&wt)?);
|
||||
|
||||
eprintln!(" Syncing (skip_clean=true, pool mode)...");
|
||||
phases.push(phase_sync(
|
||||
source, &wt, copy_dirty, /* skip_clean */ true,
|
||||
)?);
|
||||
|
||||
eprintln!(" Post-sync git status...");
|
||||
phases.push(phase_post_sync_status(&wt)?);
|
||||
|
||||
eprintln!(" Simulating use...");
|
||||
phases.push(phase_simulate_use(&wt)?);
|
||||
|
||||
eprintln!(" Releasing...");
|
||||
phases.push(phase_release(&wt)?);
|
||||
|
||||
eprintln!(" Post-release warm...");
|
||||
phases.push(phase_post_release_warm(&wt)?);
|
||||
|
||||
eprintln!(" Cleaning up...");
|
||||
phases.push(phase_cleanup(source, &wt)?);
|
||||
|
||||
let total_ms = iter_start.elapsed().as_secs_f64() * 1000.0;
|
||||
Ok(IterationResult {
|
||||
iteration,
|
||||
phases,
|
||||
total_ms,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
fn count_tracked_files(source: &Path) -> Result<usize> {
|
||||
kigi_fast_worktree::count_tracked_files(source)
|
||||
}
|
||||
|
||||
fn compute_summary(iterations: &[IterationResult]) -> BenchmarkSummary {
|
||||
if iterations.is_empty() {
|
||||
return BenchmarkSummary {
|
||||
phase_averages: vec![],
|
||||
total_avg_ms: 0.0,
|
||||
bottleneck: ("(none)".into(), 0.0),
|
||||
};
|
||||
}
|
||||
|
||||
// Collect all unique phase names in order from the first iteration
|
||||
let phase_names: Vec<String> = iterations[0]
|
||||
.phases
|
||||
.iter()
|
||||
.map(|p| p.name.clone())
|
||||
.collect();
|
||||
|
||||
let mut phase_averages = Vec::new();
|
||||
let mut max_phase = ("(none)".to_string(), 0.0f64);
|
||||
|
||||
for name in &phase_names {
|
||||
let mut sum = 0.0;
|
||||
let mut count = 0;
|
||||
for iter in iterations {
|
||||
for phase in &iter.phases {
|
||||
if &phase.name == name {
|
||||
sum += phase.duration_ms;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let avg = if count > 0 { sum / count as f64 } else { 0.0 };
|
||||
if avg > max_phase.1 {
|
||||
max_phase = (name.clone(), avg);
|
||||
}
|
||||
phase_averages.push((name.clone(), avg));
|
||||
}
|
||||
|
||||
let total_avg = iterations.iter().map(|i| i.total_ms).sum::<f64>() / iterations.len() as f64;
|
||||
|
||||
BenchmarkSummary {
|
||||
phase_averages,
|
||||
total_avg_ms: total_avg,
|
||||
bottleneck: max_phase,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Output
|
||||
// ============================================================================
|
||||
|
||||
fn print_iteration(result: &IterationResult) {
|
||||
println!();
|
||||
println!(
|
||||
" ┌─ Iteration {} ─────────────────────────────────────────────────",
|
||||
result.iteration + 1
|
||||
);
|
||||
for phase in &result.phases {
|
||||
let bar_len = (phase.duration_ms / 100.0).min(50.0) as usize;
|
||||
let bar: String = "█".repeat(bar_len);
|
||||
println!(
|
||||
" │ {:>8.1}ms {:<35} {}",
|
||||
phase.duration_ms, phase.name, bar
|
||||
);
|
||||
if !phase.detail.is_empty() {
|
||||
println!(" │ └─ {}", phase.detail);
|
||||
}
|
||||
}
|
||||
println!(
|
||||
" └─ Total: {:.1}ms ──────────────────────────────────────────────",
|
||||
result.total_ms
|
||||
);
|
||||
}
|
||||
|
||||
fn print_summary(result: &BenchmarkResult) {
|
||||
println!();
|
||||
println!("╔══════════════════════════════════════════════════════════════════╗");
|
||||
println!("║ BENCHMARK SUMMARY ║");
|
||||
println!("╠══════════════════════════════════════════════════════════════════╣");
|
||||
println!(
|
||||
"║ Source: {:<55} ║",
|
||||
&result.source[..result.source.len().min(55)]
|
||||
);
|
||||
println!("║ Tracked files: {:<48} ║", result.tracked_files);
|
||||
println!(
|
||||
"║ Mode: {:<57} ║",
|
||||
if result.ab_mode {
|
||||
"A/B (2 worktrees)"
|
||||
} else {
|
||||
"Single worktree"
|
||||
}
|
||||
);
|
||||
println!("║ Iterations: {:<51} ║", result.iterations.len());
|
||||
println!("╠══════════════════════════════════════════════════════════════════╣");
|
||||
println!("║ Phase Averages: ║");
|
||||
for (name, avg) in &result.summary.phase_averages {
|
||||
let pct = if result.summary.total_avg_ms > 0.0 {
|
||||
avg / result.summary.total_avg_ms * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let marker = if name == &result.summary.bottleneck.0 {
|
||||
" ◄ BOTTLENECK"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
println!("║ {:>8.1}ms ({:>4.1}%) {:<35}{}", avg, pct, name, marker);
|
||||
}
|
||||
println!("╠══════════════════════════════════════════════════════════════════╣");
|
||||
println!(
|
||||
"║ Total average: {:.1}ms{:>47} ║",
|
||||
result.summary.total_avg_ms, ""
|
||||
);
|
||||
println!(
|
||||
"║ Bottleneck: {} ({:.1}ms){} ║",
|
||||
result.summary.bottleneck.0,
|
||||
result.summary.bottleneck.1,
|
||||
" ".repeat(
|
||||
63usize
|
||||
.saturating_sub(result.summary.bottleneck.0.len())
|
||||
.saturating_sub(format!("{:.1}", result.summary.bottleneck.1).len())
|
||||
.saturating_sub(15)
|
||||
)
|
||||
);
|
||||
println!("╚══════════════════════════════════════════════════════════════════╝");
|
||||
}
|
||||
|
||||
fn print_json(result: &BenchmarkResult) {
|
||||
println!("{{");
|
||||
println!(" \"source\": {:?},", result.source);
|
||||
println!(" \"tracked_files\": {},", result.tracked_files);
|
||||
println!(" \"ab_mode\": {},", result.ab_mode);
|
||||
println!(" \"iterations\": [");
|
||||
for (i, iter) in result.iterations.iter().enumerate() {
|
||||
println!(" {{");
|
||||
println!(" \"iteration\": {},", iter.iteration);
|
||||
println!(" \"total_ms\": {:.2},", iter.total_ms);
|
||||
println!(" \"phases\": [");
|
||||
for (j, phase) in iter.phases.iter().enumerate() {
|
||||
let comma = if j + 1 < iter.phases.len() { "," } else { "" };
|
||||
println!(
|
||||
" {{ \"name\": {:?}, \"duration_ms\": {:.2}, \"detail\": {:?} }}{}",
|
||||
phase.name, phase.duration_ms, phase.detail, comma
|
||||
);
|
||||
}
|
||||
println!(" ]");
|
||||
let comma = if i + 1 < result.iterations.len() {
|
||||
","
|
||||
} else {
|
||||
""
|
||||
};
|
||||
println!(" }}{comma}");
|
||||
}
|
||||
println!(" ],");
|
||||
println!(" \"summary\": {{");
|
||||
println!(" \"total_avg_ms\": {:.2},", result.summary.total_avg_ms);
|
||||
println!(
|
||||
" \"bottleneck\": {{ \"name\": {:?}, \"avg_ms\": {:.2} }},",
|
||||
result.summary.bottleneck.0, result.summary.bottleneck.1
|
||||
);
|
||||
println!(" \"phase_averages\": [");
|
||||
for (i, (name, avg)) in result.summary.phase_averages.iter().enumerate() {
|
||||
let comma = if i + 1 < result.summary.phase_averages.len() {
|
||||
","
|
||||
} else {
|
||||
""
|
||||
};
|
||||
println!(
|
||||
" {{ \"name\": {:?}, \"avg_ms\": {:.2} }}{}",
|
||||
name, avg, comma
|
||||
);
|
||||
}
|
||||
println!(" ]");
|
||||
println!(" }}");
|
||||
println!("}}");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main
|
||||
// ============================================================================
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
// Init tracing
|
||||
if cli.verbose {
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::DEBUG)
|
||||
.with_target(false)
|
||||
.init();
|
||||
} else {
|
||||
tracing_subscriber::fmt()
|
||||
.with_max_level(tracing::Level::WARN)
|
||||
.with_target(false)
|
||||
.init();
|
||||
}
|
||||
|
||||
let source = dunce::canonicalize(&cli.source).context("source path not found")?;
|
||||
let tracked = count_tracked_files(&source).unwrap_or(0);
|
||||
|
||||
if !cli.json {
|
||||
eprintln!("╔══════════════════════════════════════════════════════════════════╗");
|
||||
eprintln!("║ Pool Performance Benchmark ║");
|
||||
eprintln!("╠══════════════════════════════════════════════════════════════════╣");
|
||||
eprintln!("║ Source: {}", source.display());
|
||||
eprintln!("║ Tracked files: {tracked}");
|
||||
eprintln!(
|
||||
"║ Mode: {}",
|
||||
if cli.ab {
|
||||
"A/B (2 worktrees)"
|
||||
} else {
|
||||
"Single worktree"
|
||||
}
|
||||
);
|
||||
eprintln!("║ Iterations: {}", cli.iterations);
|
||||
eprintln!(
|
||||
"║ Parallelism: {}",
|
||||
if cli.parallelism == 0 {
|
||||
"auto".to_string()
|
||||
} else {
|
||||
cli.parallelism.to_string()
|
||||
}
|
||||
);
|
||||
eprintln!("║ Copy dirty: {}", cli.copy_dirty);
|
||||
eprintln!("╚══════════════════════════════════════════════════════════════════╝");
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
// Create a temporary base directory for worktrees
|
||||
let bench_dir = tempfile::Builder::new()
|
||||
.prefix("pool-perf-bench-")
|
||||
.tempdir()
|
||||
.context("failed to create temp dir")?;
|
||||
|
||||
if !cli.json {
|
||||
eprintln!("Bench dir: {}", bench_dir.path().display());
|
||||
}
|
||||
|
||||
// Enable git perf features on source (like the pool does)
|
||||
if !cli.json {
|
||||
eprintln!("Enabling git perf features on source...");
|
||||
}
|
||||
for (key, val) in [("core.fsmonitor", "true"), ("core.untrackedCache", "true")] {
|
||||
Command::new("git")
|
||||
.args(["config", key, val])
|
||||
.current_dir(&source)
|
||||
.output()
|
||||
.ok();
|
||||
}
|
||||
|
||||
let mut iterations = Vec::new();
|
||||
|
||||
for i in 0..cli.iterations {
|
||||
if !cli.json {
|
||||
eprintln!("\n━━━ Iteration {}/{} ━━━", i + 1, cli.iterations);
|
||||
}
|
||||
|
||||
let result = if cli.ab {
|
||||
run_ab_iteration(
|
||||
&source,
|
||||
bench_dir.path(),
|
||||
i,
|
||||
cli.parallelism,
|
||||
cli.copy_dirty,
|
||||
)?
|
||||
} else {
|
||||
run_single_iteration(
|
||||
&source,
|
||||
bench_dir.path(),
|
||||
i,
|
||||
cli.parallelism,
|
||||
cli.copy_dirty,
|
||||
)?
|
||||
};
|
||||
|
||||
if !cli.json {
|
||||
print_iteration(&result);
|
||||
}
|
||||
|
||||
iterations.push(result);
|
||||
}
|
||||
|
||||
let summary = compute_summary(&iterations);
|
||||
|
||||
let bench_result = BenchmarkResult {
|
||||
source: source.to_string_lossy().to_string(),
|
||||
tracked_files: tracked,
|
||||
iterations,
|
||||
ab_mode: cli.ab,
|
||||
summary,
|
||||
};
|
||||
|
||||
if cli.json {
|
||||
print_json(&bench_result);
|
||||
} else {
|
||||
print_summary(&bench_result);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user