use std::{
fs::File,
io::stdout,
num::{NonZeroU8, NonZeroU32, NonZeroU64},
path::PathBuf,
};
use clap::{
ArgGroup, Parser, ValueEnum,
builder::{
Styles,
styling::{AnsiColor, Effects},
},
};
use crossterm::tty::IsTty;
use tokio::sync::{mpsc, watch};
use tokio_util::sync::CancellationToken;
use crate::{
baseline::{self, BaselineName, RegressionMetric, Verdict},
clock::Clock,
collector::{ReportCollector, SilentCollector, TuiCollector},
reporter::{BenchReporter, JsonReporter, TextReporter},
runner::{BenchOpts, BenchSuite, Runner},
};
#[derive(Debug, Clone)]
pub struct RegressionError {
pub verdict: Verdict,
pub baseline: String,
}
impl std::fmt::Display for RegressionError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Performance regression detected: {} (baseline: {})",
self.verdict, self.baseline
)
}
}
impl std::error::Error for RegressionError {}
const DEFAULT_REGRESSION_METRICS: &[RegressionMetric] = &[
RegressionMetric::ItersRate,
RegressionMetric::LatencyMean,
RegressionMetric::LatencyP90,
RegressionMetric::LatencyP99,
RegressionMetric::SuccessRatio,
];
#[derive(Parser, Clone, Debug)]
#[clap(
styles(Styles::styled()
.header(AnsiColor::Yellow.on_default() | Effects::BOLD)
.usage(AnsiColor::Yellow.on_default() | Effects::BOLD)
.literal(AnsiColor::Green.on_default() | Effects::BOLD)
.placeholder(AnsiColor::Cyan.on_default())
),
group = ArgGroup::new("baseline_source").args(["baseline", "baseline_file"]),
)]
#[allow(missing_docs)]
pub struct BenchCli {
#[clap(long, short = 'c', default_value = "1")]
pub concurrency: NonZeroU32,
#[clap(long, short = 'n')]
pub iterations: Option<NonZeroU64>,
#[clap(long, short = 'd')]
pub duration: Option<humantime::Duration>,
#[clap(long, short = 'w', default_value_t = 0)]
pub warmup: u64,
#[cfg(feature = "rate_limit")]
#[clap(long, short = 'r')]
pub rate: Option<NonZeroU32>,
#[clap(long, short = 'q')]
pub quiet: bool,
#[clap(long, value_enum, ignore_case = true)]
pub collector: Option<Collector>,
#[clap(long, default_value = "32")]
pub fps: NonZeroU8,
#[clap(long)]
pub quit_manually: bool,
#[clap(short, long, value_enum, default_value_t = ReportFormat::Text, ignore_case = true)]
pub output: ReportFormat,
#[clap(long, short = 'O')]
pub output_file: Option<PathBuf>,
#[clap(long)]
pub save_baseline: Option<BaselineName>,
#[clap(long, conflicts_with = "baseline_file")]
pub baseline: Option<BaselineName>,
#[clap(long, conflicts_with = "baseline")]
pub baseline_file: Option<PathBuf>,
#[clap(long)]
pub baseline_dir: Option<PathBuf>,
#[clap(long, default_value = "1.0", value_parser = parse_noise_threshold)]
pub noise_threshold: f64,
#[clap(long, requires = "baseline_source")]
pub fail_on_regression: bool,
#[clap(long, value_delimiter = ',', default_values_t = DEFAULT_REGRESSION_METRICS)]
pub regression_metrics: Vec<RegressionMetric>,
}
impl BenchCli {
pub(crate) fn bench_opts(&self, clock: Clock) -> BenchOpts {
BenchOpts {
clock,
concurrency: self.concurrency.get(),
iterations: self.iterations.map(|n| n.get()),
duration: self.duration.map(|d| d.into()),
warmups: self.warmup,
#[cfg(feature = "rate_limit")]
rate: self.rate,
}
}
pub fn collector(&self) -> Collector {
match self.collector {
Some(collector) => collector,
None if self.quiet || !stdout().is_tty() => Collector::Silent,
_ => Collector::Tui,
}
}
}
fn parse_noise_threshold(s: &str) -> Result<f64, String> {
let v: f64 = s.parse().map_err(|e| format!("{e}"))?;
if !v.is_finite() {
return Err("noise threshold must be a finite number".to_string());
}
if v < 0.0 {
return Err("noise threshold must be non-negative".to_string());
}
Ok(v)
}
#[derive(Copy, Clone, Debug, ValueEnum)]
pub enum Collector {
Tui,
Silent,
}
#[derive(Copy, Clone, Debug, ValueEnum)]
pub enum ReportFormat {
Text,
Json,
}
pub async fn run<BS>(cli: BenchCli, bench_suite: BS) -> anyhow::Result<()>
where
BS: BenchSuite + Send + 'static,
BS::WorkerState: Send + 'static,
{
let baseline_dir = baseline::resolve_baseline_dir(cli.baseline_dir.as_deref());
let baseline = match (&cli.baseline, &cli.baseline_file) {
(Some(name), _) => Some(baseline::load(&baseline_dir, name)?),
(None, Some(path)) => Some(baseline::load_file(path)?),
(None, None) => None,
};
baseline.as_ref().map(|b| b.validate(&cli)).transpose()?;
let (res_tx, res_rx) = mpsc::unbounded_channel();
let (pause_tx, pause_rx) = watch::channel(false);
let cancel = CancellationToken::new();
let opts = cli.bench_opts(Clock::new_paused());
let runner = Runner::new(bench_suite, opts.clone(), res_tx, pause_rx, cancel.clone());
let mut collector: Box<dyn ReportCollector> = match cli.collector() {
Collector::Tui => Box::new(TuiCollector::new(
opts,
cli.fps,
res_rx,
pause_tx,
cancel,
!cli.quit_manually,
)?),
Collector::Silent => Box::new(SilentCollector::new(opts, res_rx, cancel)),
};
let report = tokio::spawn(async move { collector.run().await });
runner.run().await?;
let report = report.await??;
let cmp = baseline.map(|b| baseline::compare(&report, &b, cli.noise_threshold, &cli.regression_metrics));
let mut output: Box<dyn std::io::Write> = match cli.output_file {
Some(ref path) => Box::new(File::create(path)?),
None => Box::new(stdout()),
};
match cli.output {
ReportFormat::Text => TextReporter.print(&mut output, &report, cmp.as_ref())?,
ReportFormat::Json => JsonReporter.print(&mut output, &report, cmp.as_ref())?,
}
if let Some(ref name) = cli.save_baseline {
baseline::save(&baseline_dir, name, &report, &cli)?;
if matches!(cli.output, ReportFormat::Text) {
eprintln!();
eprintln!(
"Baseline '{}' saved to {}",
name,
baseline_dir.join(format!("{}.json", name)).display()
);
}
}
if cli.fail_on_regression
&& let Some(ref cmp) = cmp
&& matches!(cmp.verdict, Verdict::Regressed | Verdict::Mixed)
{
return Err(RegressionError { verdict: cmp.verdict, baseline: cmp.baseline_name.clone() }.into());
}
Ok(())
}
#[macro_export]
macro_rules! bench_cli {
($name:ident, { $($field:tt)* }) => {
#[derive(::clap::Parser, Clone)]
pub struct $name {
$($field)*
/// Embed standard BenchCli options into this CLI struct.
#[command(flatten)]
pub bench_opts: ::rlt::cli::BenchCli,
}
};
}
#[macro_export]
macro_rules! bench_cli_run {
($bench_suite:ty) => {{
let b = <$bench_suite>::parse();
::rlt::cli::run(b.bench_opts.clone(), b)
}};
}