Skip to main content

forge/bench/
mod.rs

1//! Bench subcommand: run simulations across specs and measure throughput.
2
3mod charts;
4mod history;
5pub(crate) mod report;
6mod runner;
7
8mod types;
9
10use anyhow::Result;
11use clap::Args;
12use wowlab_common::output;
13use wowlab_fs::{directory, path::PathBuf};
14
15use crate::{
16    encounter_fixture::EncounterFixture,
17    run::{FightDurationSeconds, IterationCount, RunParameters, RunParametersError},
18};
19
20const DEFAULT_ITERATIONS: u32 = 500_000;
21const DEFAULT_DURATION: u32 = 300;
22const DEFAULT_SAMPLES: u32 = 5;
23
24#[derive(Args, Debug)]
25pub(crate) struct BenchArgs {
26    /// Spec slugs to benchmark; if omitted, benchmarks all DPS specs with a rotation script.
27    #[arg(value_name = "SPEC")]
28    pub specs: Vec<String>,
29
30    /// Iterations per spec.
31    #[arg(long, short = 'n', default_value_t = DEFAULT_ITERATIONS)]
32    pub iterations: u32,
33
34    /// Encounter duration in seconds.
35    #[arg(long, short = 'd', default_value_t = DEFAULT_DURATION)]
36    pub duration: u32,
37
38    /// Number of independently restarted fixed-seed measured samples per scenario.
39    #[arg(long, default_value_t = DEFAULT_SAMPLES, value_parser = clap::value_parser!(u32).range(1..))]
40    pub samples: u32,
41
42    /// Maintained encounter fixture; omitted uses the canonical single-target Patchwerk encounter.
43    #[arg(long)]
44    pub encounter_fixture: Option<EncounterFixture>,
45
46    /// Path to the benchmark history JSON file.
47    #[arg(long, default_value = "benchmarks/history.json")]
48    pub history: PathBuf,
49
50    /// Path to write the HTML report.
51    #[arg(long, default_value = "benchmarks/report.html")]
52    pub output: PathBuf,
53
54    /// Skip writing to history (dry run).
55    #[arg(long)]
56    pub no_save: bool,
57
58    /// Only generate the report from existing history (skip running benchmarks).
59    #[arg(long)]
60    pub report_only: bool,
61
62    /// Skip thread scaling benchmarks.
63    #[arg(long)]
64    pub no_scaling: bool,
65}
66
67impl TryFrom<&BenchArgs> for RunParameters {
68    type Error = RunParametersError;
69
70    fn try_from(args: &BenchArgs) -> Result<Self, Self::Error> {
71        let fight_duration = FightDurationSeconds::try_from(args.duration)?;
72        let iterations = IterationCount::try_from(args.iterations)?;
73
74        Ok(Self::new(iterations, fight_duration))
75    }
76}
77
78pub(crate) fn run(args: &BenchArgs) -> Result<()> {
79    let mut hist = history::load(&args.history);
80
81    if !args.report_only {
82        let parameters = RunParameters::try_from(args)?;
83        let specs = runner::discover_specs(&args.specs)?;
84
85        if specs.is_empty() {
86            anyhow::bail!("No matching specs found with rotation scripts");
87        }
88
89        let (results, rt) =
90            runner::run_benchmarks(&specs, parameters, args.samples, args.encounter_fixture)?;
91
92        let scaling = if args.no_scaling {
93            vec![]
94        } else {
95            runner::run_scaling(&rt, &specs, &results, parameters, args.encounter_fixture)
96        };
97
98        let bench_run = runner::build_bench_run(results, scaling);
99
100        if args.no_save {
101            hist.runs.push(bench_run);
102        } else {
103            hist.runs.push(bench_run);
104            history::save(&args.history, &hist)?;
105        }
106    }
107
108    if hist.runs.is_empty() {
109        output::detail("No benchmark data to report.");
110
111        return Ok(());
112    }
113
114    report::generate(&args.output, &hist)?;
115    let abs_path = directory::canonicalize(&args.output).unwrap_or_else(|_| args.output.clone());
116    let file_url = format!("file://{}", abs_path.display());
117
118    output::success(&format!("Report written to {file_url}"));
119
120    Ok(())
121}