use std::{
env,
path::Path,
time::{Duration, Instant},
};
use serde_json::Value;
use super::{
fixture::{PerfFixture, base_command},
profile::sample_from_trace,
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum NegativeControl {
None,
Latency,
FullScan,
SubtreeSkip,
EagerPackIndex,
DuplicateOpen,
}
impl NegativeControl {
pub(super) fn from_env() -> Self {
match env::var("HEDDLE_PERF_NEGATIVE_CONTROL").as_deref() {
Ok("latency") => Self::Latency,
Ok("full-scan") => Self::FullScan,
Ok("subtree-skip") => Self::SubtreeSkip,
Ok("eager-pack-index") => Self::EagerPackIndex,
Ok("duplicate-open") => Self::DuplicateOpen,
Ok(value) => panic!("unknown HEDDLE_PERF_NEGATIVE_CONTROL `{value}`"),
Err(_) => Self::None,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum CaseKind {
Version,
Help,
StatusClean,
StatusDirty,
CaptureOne,
}
impl CaseKind {
pub(super) fn name(self) -> &'static str {
match self {
Self::Version => "version",
Self::Help => "help",
Self::StatusClean => "status_clean",
Self::StatusDirty => "status_one_dirty",
Self::CaptureOne => "capture_one",
}
}
fn args(self) -> &'static [&'static str] {
match self {
Self::Version => &["--version"],
Self::Help => &["help"],
Self::StatusClean | Self::StatusDirty => &["--output", "json", "status"],
Self::CaptureOne => &["--output", "json", "capture", "-m", "perf sample"],
}
}
pub(super) fn expected_repo_opens(self) -> u64 {
match self {
Self::Version | Self::Help => 0,
Self::StatusClean | Self::StatusDirty => 1,
Self::CaptureOne => 2,
}
}
}
#[derive(Clone, Debug, Default)]
pub(super) struct Sample {
pub wall_ms: f64,
pub profile_total_ms: f64,
pub startup_ms: f64,
pub warm_repository_ms: f64,
pub repository_open_ms: f64,
pub current_state_ms: f64,
pub verification_ms: f64,
pub worktree_status_ms: f64,
pub thread_summary_ms: f64,
pub snapshot_ms: f64,
pub snapshot_tree_walk_ms: f64,
pub snapshot_blob_prep_ms: f64,
pub snapshot_blob_write_ms: f64,
pub snapshot_tree_write_ms: f64,
pub snapshot_state_ref_oplog_ms: f64,
pub snapshot_thread_metadata_ms: f64,
pub monitor_ms: f64,
pub rendering_ms: f64,
pub network_ms: f64,
pub counters: Counters,
}
#[derive(Clone, Debug, Default)]
pub(super) struct Counters {
pub directories_scanned: u64,
pub directories_skipped: u64,
pub files_hashed: u64,
pub monitor_changed_paths: u64,
pub object_decodes: u64,
pub ref_reads: u64,
pub oplog_reads: u64,
pub repository_opens: u64,
pub network_client_initialized: bool,
pub ancestors_visited: u64,
pub history_objects_decoded: u64,
}
impl Sample {
fn combine(mut self, other: Self) -> Self {
self.wall_ms += other.wall_ms;
self.profile_total_ms += other.profile_total_ms;
self.startup_ms += other.startup_ms;
self.warm_repository_ms += other.warm_repository_ms;
self.repository_open_ms += other.repository_open_ms;
self.current_state_ms += other.current_state_ms;
self.verification_ms += other.verification_ms;
self.worktree_status_ms += other.worktree_status_ms;
self.thread_summary_ms += other.thread_summary_ms;
self.snapshot_ms += other.snapshot_ms;
self.snapshot_tree_walk_ms += other.snapshot_tree_walk_ms;
self.snapshot_blob_prep_ms += other.snapshot_blob_prep_ms;
self.snapshot_blob_write_ms += other.snapshot_blob_write_ms;
self.snapshot_tree_write_ms += other.snapshot_tree_write_ms;
self.snapshot_state_ref_oplog_ms += other.snapshot_state_ref_oplog_ms;
self.snapshot_thread_metadata_ms += other.snapshot_thread_metadata_ms;
self.monitor_ms += other.monitor_ms;
self.rendering_ms += other.rendering_ms;
self.network_ms += other.network_ms;
self.counters.add(&other.counters);
self
}
}
impl Counters {
fn add(&mut self, other: &Self) {
self.directories_scanned += other.directories_scanned;
self.directories_skipped += other.directories_skipped;
self.files_hashed += other.files_hashed;
self.monitor_changed_paths += other.monitor_changed_paths;
self.object_decodes += other.object_decodes;
self.ref_reads += other.ref_reads;
self.oplog_reads += other.oplog_reads;
self.repository_opens += other.repository_opens;
self.network_client_initialized |= other.network_client_initialized;
self.ancestors_visited += other.ancestors_visited;
self.history_objects_decoded += other.history_objects_decoded;
}
}
pub(super) struct CaseResult {
pub kind: CaseKind,
pub path_count: usize,
pub samples: Vec<Sample>,
}
impl CaseResult {
pub(super) fn metric(&self, select: impl Fn(&Sample) -> f64) -> Stats {
Stats::from_values(self.samples.iter().map(select).collect())
}
pub(super) fn counter(&self, select: impl Fn(&Counters) -> u64) -> Stats {
Stats::from_values(
self.samples
.iter()
.map(|sample| select(&sample.counters) as f64)
.collect(),
)
}
pub(super) fn print(&self) {
let wall = self.metric(|sample| sample.wall_ms);
println!(
"RESULT case={} mode={} paths={} samples={} wall_ms={} total_ms={} startup_ms={} warm_repo_ms={} repo_open_ms={} current_state_ms={} verification_ms={} worktree_status_ms={} thread_summary_ms={} snapshot_ms={} snapshot_tree_walk_ms={} snapshot_blob_prep_ms={} snapshot_blob_write_ms={} snapshot_tree_write_ms={} snapshot_state_ref_oplog_ms={} snapshot_thread_metadata_ms={} monitor_ms={} render_ms={} network_ms={}",
self.kind.name(),
if self.path_count == 0 {
"cold_process"
} else {
"warm_repo"
},
self.path_count,
self.samples.len(),
wall,
self.metric(|sample| sample.profile_total_ms),
self.metric(|sample| sample.startup_ms),
self.metric(|sample| sample.warm_repository_ms),
self.metric(|sample| sample.repository_open_ms),
self.metric(|sample| sample.current_state_ms),
self.metric(|sample| sample.verification_ms),
self.metric(|sample| sample.worktree_status_ms),
self.metric(|sample| sample.thread_summary_ms),
self.metric(|sample| sample.snapshot_ms),
self.metric(|sample| sample.snapshot_tree_walk_ms),
self.metric(|sample| sample.snapshot_blob_prep_ms),
self.metric(|sample| sample.snapshot_blob_write_ms),
self.metric(|sample| sample.snapshot_tree_write_ms),
self.metric(|sample| sample.snapshot_state_ref_oplog_ms),
self.metric(|sample| sample.snapshot_thread_metadata_ms),
self.metric(|sample| sample.monitor_ms),
self.metric(|sample| sample.rendering_ms),
self.metric(|sample| sample.network_ms),
);
println!(
"COUNTERS case={} paths={} dirs_scanned={} dirs_skipped={} files_hashed={} monitor_paths={} object_decodes={} ref_reads={} oplog_reads={} repo_opens={} network_initialized={} ancestors_visited={} history_objects_decoded={}",
self.kind.name(),
self.path_count,
self.counter(|value| value.directories_scanned),
self.counter(|value| value.directories_skipped),
self.counter(|value| value.files_hashed),
self.counter(|value| value.monitor_changed_paths),
self.counter(|value| value.object_decodes),
self.counter(|value| value.ref_reads),
self.counter(|value| value.oplog_reads),
self.counter(|value| value.repository_opens),
self.samples
.iter()
.any(|sample| sample.counters.network_client_initialized),
self.counter(|value| value.ancestors_visited),
self.counter(|value| value.history_objects_decoded),
);
}
}
#[derive(Clone, Copy, Debug)]
pub(super) struct Stats {
pub p50: f64,
pub p95: f64,
pub p99: f64,
pub max: f64,
}
impl Stats {
fn from_values(mut values: Vec<f64>) -> Self {
values.sort_by(f64::total_cmp);
Self {
p50: percentile(&values, 0.50),
p95: percentile(&values, 0.95),
p99: percentile(&values, 0.99),
max: *values.last().expect("performance case has samples"),
}
}
}
impl std::fmt::Display for Stats {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"p50={:.3},p95={:.3},p99={:.3},max={:.3}",
self.p50, self.p95, self.p99, self.max
)
}
}
pub(super) fn measure_case(
binary: &Path,
mut fixture: Option<&mut PerfFixture>,
kind: CaseKind,
sample_count: usize,
negative: NegativeControl,
) -> CaseResult {
let path_count = fixture.as_ref().map_or(0, |value| value.path_count);
let cwd = fixture
.as_ref()
.map_or_else(std::env::temp_dir, |value| value.root().to_path_buf());
let mut samples = Vec::with_capacity(sample_count);
for index in 0..sample_count {
if let Some(value) = fixture.as_deref_mut() {
if kind == CaseKind::CaptureOne {
value.prepare_capture_sample();
}
if negative == NegativeControl::FullScan {
value.prepare_full_scan_sample();
}
}
let mut sample = run_profiled(binary, kind.args(), &cwd, negative);
if negative == NegativeControl::DuplicateOpen {
sample = sample.combine(run_profiled(binary, kind.args(), &cwd, negative));
}
assert!(
sample.wall_ms.is_finite(),
"sample {index} should be finite"
);
samples.push(sample);
}
CaseResult {
kind,
path_count,
samples,
}
}
fn run_profiled(binary: &Path, args: &[&str], cwd: &Path, negative: NegativeControl) -> Sample {
let start = Instant::now();
if negative == NegativeControl::Latency {
std::thread::sleep(Duration::from_millis(50));
}
let mut command = base_command(binary, cwd);
command.env("HEDDLE_PROFILE", "jsonl");
if negative == NegativeControl::SubtreeSkip {
command.env("HEDDLE_PERF_DISABLE_SUBTREE_SKIP", "1");
}
if negative == NegativeControl::EagerPackIndex {
command.env("HEDDLE_PERF_FORCE_EAGER_PACK_INDEX", "1");
}
let output = command.args(args).output().expect("run profiled command");
let wall_ms = start.elapsed().as_secs_f64() * 1_000.0;
assert!(
output.status.success(),
"timed {args:?} failed; stdout={} stderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8(output.stderr).expect("profile stderr is utf8");
let trace: Value = stderr
.lines()
.find_map(|line| serde_json::from_str(line).ok())
.unwrap_or_else(|| panic!("profile trace missing from stderr: {stderr}"));
sample_from_trace(wall_ms, &trace)
}
fn percentile(values: &[f64], quantile: f64) -> f64 {
let index = ((values.len() as f64 * quantile).ceil() as usize)
.saturating_sub(1)
.min(values.len() - 1);
values[index]
}