use std::{io::Write, path::PathBuf};
use clap::{Parser, Subcommand};
use crate::{
internal,
jobs::{self, Jobs},
output::Output,
registry,
result::Checkpoint,
utils::fmt::{Banner, Indent},
};
fn check_debug_mode(allow_debug: bool) -> anyhow::Result<()> {
if cfg!(any(test, debug_assertions)) && !allow_debug {
anyhow::bail!(
"Benchmarking in debug mode produces misleading performance results.\n\
Please compile in release mode or use the --allow-debug flag to bypass this check."
);
}
Ok(())
}
#[derive(Debug, Subcommand)]
pub enum Commands {
Inputs {
describe: Option<String>,
},
Benchmarks {},
Skeleton,
Run {
#[arg(long = "input-file")]
input_file: PathBuf,
#[arg(long = "output-file")]
output_file: PathBuf,
#[arg(long, action)]
dry_run: bool,
#[arg(long, action)]
allow_debug: bool,
},
#[command(subcommand)]
Check(Check),
}
#[derive(Debug, Subcommand)]
pub enum Check {
Skeleton,
Tolerances {
describe: Option<String>,
},
Verify {
#[arg(long = "tolerances")]
tolerances: PathBuf,
#[arg(long = "input-file")]
input_file: PathBuf,
},
Run {
#[arg(long = "tolerances")]
tolerances: PathBuf,
#[arg(long = "input-file")]
input_file: PathBuf,
#[arg(long = "before")]
before: PathBuf,
#[arg(long = "after")]
after: PathBuf,
#[arg(long = "output-file")]
output_file: Option<PathBuf>,
},
}
#[derive(Debug, Parser)]
pub struct App {
#[command(subcommand)]
command: Commands,
}
impl App {
pub fn parse() -> Self {
<Self as clap::Parser>::parse()
}
pub fn try_parse_from<I, T>(itr: I) -> anyhow::Result<Self>
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
{
Ok(<Self as clap::Parser>::try_parse_from(itr)?)
}
pub fn from_commands(command: Commands) -> Self {
Self { command }
}
pub fn run(
&self,
inputs: ®istry::Inputs,
benchmarks: ®istry::Benchmarks,
mut output: &mut dyn Output,
) -> anyhow::Result<()> {
match &self.command {
Commands::Inputs { describe } => {
if let Some(describe) = describe {
if let Some(input) = inputs.get(describe) {
let repr = jobs::Unprocessed::format_input(input)?;
writeln!(
output,
"The example JSON representation for \"{}\" is:",
describe
)?;
writeln!(output, "{}", serde_json::to_string_pretty(&repr)?)?;
return Ok(());
} else {
writeln!(output, "No input found for \"{}\"", describe)?;
}
return Ok(());
}
writeln!(output, "Available input kinds are listed below:")?;
let mut tags: Vec<_> = inputs.tags().collect();
tags.sort();
for i in tags.iter() {
writeln!(output, " {}", i)?;
}
}
Commands::Benchmarks {} => {
writeln!(output, "Registered Benchmarks:")?;
for (name, description) in benchmarks.names() {
write!(output, " {name}:")?;
if description.is_empty() {
writeln!(output)?;
} else {
writeln!(output)?;
write!(output, "{}", Indent::new(&description, 8))?;
}
}
}
Commands::Skeleton => {
writeln!(output, "Skeleton input file:")?;
writeln!(output, "{}", Jobs::example()?)?;
}
Commands::Run {
input_file,
output_file,
dry_run,
allow_debug,
} => {
let run = Jobs::load(input_file, inputs)?;
for job in run.jobs().iter() {
const MAX_METHODS: usize = 3;
if let Err(mismatches) = benchmarks.debug(job, MAX_METHODS) {
let repr = serde_json::to_string_pretty(&job.serialize()?)?;
writeln!(
output,
"Could not find a match for the following input:\n\n{}\n",
repr
)?;
writeln!(output, "Closest matches:\n")?;
for (i, mismatch) in mismatches.into_iter().enumerate() {
writeln!(output, " {}. \"{}\":", i + 1, mismatch.method(),)?;
writeln!(output, "{}", Indent::new(mismatch.reason(), 8),)?;
}
writeln!(output)?;
return Err(anyhow::Error::msg(
"could not find a benchmark for all inputs",
));
}
}
if *dry_run {
writeln!(
output,
"Success - skipping running benchmarks because \"--dry-run\" was used."
)?;
return Ok(());
}
check_debug_mode(*allow_debug)?;
let mut results = Vec::<serde_json::Value>::new();
let jobs = run.jobs();
let serialized = jobs
.iter()
.map(|job| {
serde_json::to_value(jobs::Unprocessed::new(
job.tag().into(),
job.serialize()?,
))
})
.collect::<Result<Vec<_>, serde_json::Error>>()?;
for (i, job) in jobs.iter().enumerate() {
let prefix: &str = if i != 0 { "\n\n" } else { "" };
writeln!(
output,
"{}{}",
prefix,
Banner::new(&format!("Running Job {} of {}", i + 1, jobs.len()))
)?;
let checkpoint = Checkpoint::new(&serialized, &results, output_file)?;
let r = benchmarks.call(job, checkpoint, output)?;
results.push(r);
Checkpoint::new(&serialized, &results, output_file)?.save()?;
}
}
Commands::Check(check) => return self.check(check, inputs, benchmarks, output),
};
Ok(())
}
fn check(
&self,
check: &Check,
inputs: ®istry::Inputs,
benchmarks: ®istry::Benchmarks,
mut output: &mut dyn Output,
) -> anyhow::Result<()> {
match check {
Check::Skeleton => {
let message = "Skeleton tolerance file.\n\n\
Each tolerance is paired with an input that is structurally\n\
matched with an entry in the corresponding `--input-file`.\n\n\
This allow a single tolerance entry to be applied to multiple\n\
benchmark runs as long as this structural mapping is unambiguous.\n";
writeln!(output, "{}", message)?;
writeln!(output, "{}", internal::regression::Raw::example())?;
Ok(())
}
Check::Tolerances { describe } => {
let tolerances = benchmarks.tolerances();
match describe {
Some(name) => match tolerances.get(&**name) {
Some(registered) => {
let repr = internal::regression::RawInner::new(
jobs::Unprocessed::new(
"".to_string(),
serde_json::Value::Object(Default::default()),
),
jobs::Unprocessed::format_input(registered.tolerance)?,
);
write!(
output,
"The example JSON representation for \"{}\" is shown below.\n\
Populate the \"input\" field with a compatible benchmark input.\n\
Matching will be performed by partial structural map on the input.\n\n",
name
)?;
writeln!(output, "{}", serde_json::to_string_pretty(&repr)?)?;
Ok(())
}
None => {
writeln!(output, "No tolerance input found for \"{}\"", name)?;
Ok(())
}
},
None => {
writeln!(output, "Available tolerance kinds are listed below.")?;
let mut keys: Vec<_> = tolerances.keys().collect();
keys.sort();
for k in keys {
let registered = &tolerances[k];
writeln!(output, " {}", registered.tolerance.tag())?;
for pair in registered.regressions.iter() {
writeln!(
output,
" - \"{}\" => \"{}\"",
pair.input_tag(),
pair.name(),
)?;
}
}
Ok(())
}
}
}
Check::Verify {
tolerances,
input_file,
} => {
let benchmarks = benchmarks.tolerances();
let _ =
internal::regression::Checks::new(tolerances, input_file, inputs, &benchmarks)?;
Ok(())
}
Check::Run {
tolerances,
input_file,
before,
after,
output_file,
} => {
let registered = benchmarks.tolerances();
let checks =
internal::regression::Checks::new(tolerances, input_file, inputs, ®istered)?;
let jobs = checks.jobs(before, after)?;
jobs.run(output, output_file.as_deref())?;
Ok(())
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
ffi::OsString,
path::{Path, PathBuf},
};
use crate::{registry, ux};
const ENV: &str = "POCKETBENCH_TEST";
const STDIN: &str = "stdin.txt";
const STDOUT: &str = "stdout.txt";
const INPUT_FILE: &str = "input.json";
const OUTPUT_FILE: &str = "output.json";
const TOLERANCES_FILE: &str = "tolerances.json";
const REGRESSION_INPUT_FILE: &str = "regression_input.json";
const CHECK_OUTPUT_FILE: &str = "checks.json";
const ALL_GENERATED_OUTPUTS: [&str; 2] = [OUTPUT_FILE, CHECK_OUTPUT_FILE];
fn read_to_string<P: AsRef<Path>>(path: P, ctx: &str) -> String {
match std::fs::read_to_string(path.as_ref()) {
Ok(s) => ux::normalize(s),
Err(err) => panic!(
"failed to read {} {:?} with error: {}",
ctx,
path.as_ref(),
err
),
}
}
fn overwrite() -> bool {
match std::env::var(ENV) {
Ok(v) => {
if v == "overwrite" {
true
} else {
panic!(
"Unknown value for {}: \"{}\". Expected \"overwrite\"",
ENV, v
);
}
}
Err(std::env::VarError::NotPresent) => false,
Err(std::env::VarError::NotUnicode(_)) => {
panic!("Value for {} is not unicode", ENV);
}
}
}
struct Test {
dir: PathBuf,
overwrite: bool,
}
impl Test {
fn new(dir: &Path) -> Self {
Self {
dir: dir.into(),
overwrite: overwrite(),
}
}
fn parse_stdin(&self, tempdir: &Path) -> Vec<App> {
let path = self.dir.join(STDIN);
let stdin = read_to_string(&path, "standard input");
let output: Vec<App> = stdin
.lines()
.filter_map(|line| {
if line.starts_with('#') || line.is_empty() {
None
} else {
Some(self.parse_line(line, tempdir))
}
})
.collect();
if output.is_empty() {
panic!("File \"{}/stdin.txt\" has no command!", self.dir.display());
}
output
}
fn parse_line(&self, line: &str, tempdir: &Path) -> App {
let args: Vec<OsString> = line
.split_whitespace()
.map(|v| -> OsString { self.resolve(v, tempdir).into() })
.collect();
App::try_parse_from(std::iter::once(OsString::from("test-app")).chain(args)).unwrap()
}
fn resolve(&self, s: &str, tempdir: &Path) -> PathBuf {
match s {
"$INPUT" => self.dir.join(INPUT_FILE),
"$OUTPUT" => tempdir.join(OUTPUT_FILE),
"$TOLERANCES" => self.dir.join(TOLERANCES_FILE),
"$REGRESSION_INPUT" => self.dir.join(REGRESSION_INPUT_FILE),
"$CHECK_OUTPUT" => tempdir.join(CHECK_OUTPUT_FILE),
_ => s.into(),
}
}
fn run(&self, tempdir: &Path) {
let apps = self.parse_stdin(tempdir);
let mut inputs = registry::Inputs::new();
crate::test::register_inputs(&mut inputs).unwrap();
let mut benchmarks = registry::Benchmarks::new();
crate::test::register_benchmarks(&mut benchmarks);
let mut buffer = crate::output::Memory::new();
for (i, app) in apps.iter().enumerate() {
let is_last = i + 1 == apps.len();
let mut b: &mut dyn crate::Output = if is_last {
&mut buffer
} else {
&mut crate::output::Sink::new()
};
if let Err(err) = app.run(&inputs, &benchmarks, b) {
if is_last {
write!(b, "{:?}", err).unwrap();
} else {
panic!(
"App {} of {} failed with error: {:?}",
i + 1,
apps.len(),
err
);
}
}
}
let stdout: String =
ux::normalize(ux::strip_backtrace(buffer.into_inner().try_into().unwrap()));
let stdout = ux::scrub_path(stdout, tempdir, "$TEMPDIR");
let output = self.dir.join(STDOUT);
if self.overwrite {
std::fs::write(output, stdout).unwrap();
} else {
let expected = read_to_string(&output, "expected standard output");
if stdout != expected {
panic!("Got:\n--\n{}\n--\nExpected:\n--\n{}\n--", stdout, expected);
}
}
for file in ALL_GENERATED_OUTPUTS {
self.check_output_file(tempdir, file);
}
}
fn check_output_file(&self, tempdir: &Path, filename: &str) {
let generated_path = tempdir.join(filename);
let was_generated = generated_path.is_file();
let expected_path = self.dir.join(filename);
let is_expected = expected_path.is_file();
if self.overwrite {
if was_generated {
println!(
"Moving generated file {:?} to {:?}",
generated_path, expected_path
);
if let Err(err) = std::fs::rename(&generated_path, &expected_path) {
panic!(
"Moving generated file {:?} to expected location {:?} failed: {}",
generated_path, expected_path, err
);
}
} else if is_expected {
println!("Removing outdated file {:?}", expected_path);
if let Err(err) = std::fs::remove_file(&expected_path) {
panic!("Failed removing outdated file {:?}: {}", expected_path, err);
}
}
} else {
match (was_generated, is_expected) {
(true, true) => {
let output_contents = read_to_string(generated_path, "generated");
let expected_contents = read_to_string(expected_path, "expected");
if output_contents != expected_contents {
panic!(
"{}: Got:\n\n{}\n\nExpected:\n\n{}\n",
filename, output_contents, expected_contents
);
}
}
(true, false) => {
let output_contents = read_to_string(generated_path, "generated");
panic!(
"{} was generated when none was expected. Contents:\n\n{}",
filename, output_contents
);
}
(false, true) => {
panic!("{} was not generated when it was expected", filename);
}
(false, false) => { }
}
}
}
}
fn run_specific_test(test_dir: &Path) {
println!("running test in {:?}", test_dir);
let temp_dir = tempfile::tempdir().unwrap();
Test::new(test_dir).run(temp_dir.path());
}
fn run_all_tests_in(dir: &str) {
let dir: PathBuf = format!("{}/tests/{}", env!("CARGO_MANIFEST_DIR"), dir).into();
for entry in std::fs::read_dir(dir).unwrap() {
let entry = entry.unwrap();
if let Ok(file_type) = entry.file_type() {
if file_type.is_dir() {
run_specific_test(&entry.path());
}
} else {
panic!("couldn't get file type for {:?}", entry.path());
}
}
}
#[test]
fn benchmark_tests() {
run_all_tests_in("benchmark");
}
#[test]
fn regression_tests() {
run_all_tests_in("regression");
}
}