Skip to main content

forge/bench/
runner.rs

1// #t(file: rust_alloc_in_loop) CLI binary, allocations are fine for readability.
2// #t(file: rust_clone_in_loop) CLI binary, cloning strings in display loops is fine.
3
4#![expect(
5    clippy::cast_possible_truncation,
6    clippy::cast_possible_wrap,
7    clippy::cast_precision_loss,
8    reason = "benchmark durations, iteration counts, and thread indices are bounded by CLI inputs and serialized report fields"
9)]
10
11//! Benchmark execution: discover specs, run simulations, collect results.
12
13use anyhow::{Context, Result};
14use rayon::prelude::*;
15use wowlab_common::{output, sys, time::Instant};
16use wowlab_engine_adapter_data::{LocalCsvResolver, OverlayResolver};
17use wowlab_engine_application::{
18    ApplicationError, ChunkRun, IntentOverrides, ResolvedSetup, build_handler_from_setup,
19    resolve_setup, run_chunk, simulate_intent,
20};
21use wowlab_engine_ports::{ChunkAssignment, NoopProgress, SpecDescriptor, content_catalog};
22use wowlab_fs::file;
23use wowlab_types::{constants::MS_PER_SECOND, game::SpecId};
24
25use super::types::{BenchRun, ScalingPoint, ScalingResult, SpecBenchResult};
26use crate::{
27    constants::{DEFAULT_SEED, default_data_dir, engine_dir},
28    provider::ComparisonConfig,
29    run::RunParameters,
30    sim,
31};
32
33const DECIMAL_BASE: f64 = 10.0;
34const THREAD_STEP_FACTOR: usize = 2;
35const DECIMALS_IPS: u32 = 1;
36const DECIMALS_MULTIPLIER: u32 = 2;
37const DECIMALS_RATIO: u32 = 3;
38
39fn round(v: f64, decimals: u32) -> f64 {
40    let factor = DECIMAL_BASE.powi(decimals as i32);
41
42    (v * factor).round() / factor
43}
44
45pub(super) fn discover_specs(filter: &[String]) -> Result<Vec<(&'static SpecDescriptor, String)>> {
46    let rotation_dir = engine_dir().join("examples/rotations");
47    let catalog = content_catalog().context("engine content catalog is unavailable")?;
48
49    let mut all_dps: Vec<_> = catalog
50        .descriptors()
51        .iter()
52        .filter(|d| d.spec_id.is_dps())
53        .collect();
54
55    all_dps.sort_by_key(|d| d.spec_id.slug());
56
57    let mut specs = Vec::new();
58
59    for desc in all_dps {
60        let slug = desc.spec_id.slug();
61
62        if !filter.is_empty() && !filter.iter().any(|f| f == slug) {
63            continue;
64        }
65
66        // Skip specs without comparison-profile gear; gearless throughput is not comparable.
67
68        if crate::simc::simc_profile_path(desc.spec_id).is_none() {
69            continue;
70        }
71
72        let rotation_path = rotation_dir.join(format!("{slug}_assisted.json"));
73
74        if let Ok(script) = file::read_text(&rotation_path) {
75            specs.push((desc, script));
76        }
77    }
78
79    Ok(specs)
80}
81
82pub(super) fn run_benchmarks(
83    specs: &[(&'static SpecDescriptor, String)],
84    parameters: RunParameters,
85) -> Result<(Vec<SpecBenchResult>, tokio::runtime::Runtime)> {
86    let iterations = parameters.iterations();
87    let duration = parameters.fight_duration_secs();
88    let data_dir = std::env::var("WOWLAB_DATA_DIR").unwrap_or_else(|_| default_data_dir());
89    let rt = tokio::runtime::Runtime::new().context("failed to create tokio runtime")?;
90
91    output::header(&format!(
92        "Single-threaded benchmark: {} specs ({} iterations, {}s duration)",
93        specs.len(),
94        iterations,
95        duration,
96    ));
97    output::blank();
98
99    let mut results = Vec::new();
100    let mut failed: Vec<(String, String)> = Vec::new();
101
102    for (desc, rotation_script) in specs {
103        let slug = desc.spec_id.slug();
104
105        match bench_spec_single(&rt, desc, rotation_script, &data_dir, parameters) {
106            Ok(result) => {
107                output::success(&format!(
108                    "{:<30} {:>10.0} iter/s  {:>10} DPS  ({}ms)",
109                    result.slug,
110                    result.iterations_per_sec,
111                    output::fmt_number(result.mean_dps),
112                    result.elapsed_ms,
113                ));
114                results.push(result);
115            }
116            Err(e) => {
117                output::error(&format!("{slug:<30} FAILED: {e}"));
118                failed.push((slug.to_string(), e.to_string()));
119            }
120        }
121    }
122
123    output::blank();
124
125    if !failed.is_empty() {
126        output::kv("Skipped", &format!("{} specs failed", failed.len()));
127    }
128
129    print_summary(&results);
130
131    Ok((results, rt))
132}
133
134pub(super) fn run_scaling(
135    rt: &tokio::runtime::Runtime,
136    specs: &[(&'static SpecDescriptor, String)],
137    single_results: &[SpecBenchResult],
138    parameters: RunParameters,
139) -> Vec<ScalingResult> {
140    let catalog = match content_catalog() {
141        Ok(catalog) => catalog,
142        Err(error) => {
143            output::error(&format!("engine content catalog is unavailable: {error}"));
144
145            return Vec::new();
146        }
147    };
148    let iterations = parameters.iterations();
149    let data_dir = std::env::var("WOWLAB_DATA_DIR").unwrap_or_else(|_| default_data_dir());
150    let profile = sys::os_profile();
151    let thread_counts = compute_thread_counts(&profile);
152
153    output::blank();
154    output::header(&format!(
155        "Thread scaling benchmark: {} specs, thread counts {:?}",
156        specs.len(),
157        thread_counts,
158    ));
159    output::blank();
160
161    let mut scaling_results = Vec::new();
162
163    for (desc, rotation_script) in specs {
164        let slug = desc.spec_id.slug();
165
166        if !single_results.iter().any(|r| r.slug == slug) {
167            output::detail(&format!("{slug:<30} skipped (no single-threaded baseline)"));
168            continue;
169        }
170
171        let rotation_id = format!("{slug}_assisted");
172
173        let resolver = OverlayResolver::new(LocalCsvResolver::new(data_dir.as_str()))
174            .with_rotation_script(&rotation_id, rotation_script.clone());
175        let resolver_dyn = wowlab_engine_ports::DynDataResolver::from_ref(&resolver);
176        let sim_config = match rt.block_on(bench_sim_config(
177            desc.spec_id,
178            &rotation_id,
179            parameters,
180            resolver_dyn,
181        )) {
182            Ok(c) => c,
183            Err(e) => {
184                output::error(&format!("{slug:<30} FAILED: {e}"));
185                continue;
186            }
187        };
188        let setup = match rt.block_on(resolve_setup(
189            catalog,
190            &sim_config,
191            resolver_dyn,
192            &IntentOverrides::default(),
193        )) {
194            Ok(s) => s,
195            Err(e) => {
196                output::error(&format!("{slug:<30} FAILED: {e}"));
197                continue;
198            }
199        };
200
201        let baseline_ips = single_results
202            .iter()
203            .find(|r| r.slug == slug)
204            .map_or(1.0, |r| r.iterations_per_sec);
205
206        output::subheader(&format!("{slug} (baseline: {baseline_ips:.0} iter/s)"));
207
208        let mut points = Vec::with_capacity(thread_counts.len());
209        let mut prev_ips = baseline_ips;
210
211        for &n in &thread_counts {
212            let start = Instant::now();
213            // PANIC-BOUNDARY: isolate one scaling run so the CLI can report the failed spec.
214            let par_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
215                run_scaling_dispatch(&setup, DEFAULT_SEED, parameters, n)
216            }));
217
218            match par_result {
219                Ok(Err(e)) => {
220                    output::error(&format!("  {n:>3} threads: FAILED: {e}"));
221                    break;
222                }
223                Err(_panic) => {
224                    output::error(&format!("  {n:>3} threads: PANICKED"));
225                    break;
226                }
227                Ok(Ok(())) => {}
228            }
229
230            let elapsed_ms = start.elapsed().as_millis() as u64;
231
232            let ips = if elapsed_ms > 0 {
233                f64::from(iterations) / (elapsed_ms as f64 / MS_PER_SECOND)
234            } else {
235                0.0
236            };
237            let multiplier = ips / baseline_ips;
238            let efficiency = multiplier / n as f64;
239            let marginal_gain = if prev_ips > 0.0 {
240                (ips - prev_ips) / prev_ips
241            } else {
242                0.0
243            };
244
245            output::detail(&format!(
246                "  {n:>3} threads: {:>10.0} iter/s  {multiplier:>5.2}x  eff {:.0}%  marginal +{:.1}%",
247                ips,
248                efficiency * wowlab_types::constants::HUNDRED,
249                marginal_gain * wowlab_types::constants::HUNDRED,
250            ));
251
252            points.push(ScalingPoint {
253                threads: n,
254                elapsed_ms,
255                iterations_per_sec: round(ips, DECIMALS_IPS),
256                multiplier: round(multiplier, DECIMALS_MULTIPLIER),
257                efficiency: round(efficiency, DECIMALS_RATIO),
258                marginal_gain: round(marginal_gain, DECIMALS_RATIO),
259                is_p_core_range: n <= profile.p_cores,
260            });
261
262            prev_ips = ips;
263        }
264
265        scaling_results.push(ScalingResult {
266            slug: slug.to_string(),
267            iterations,
268            points,
269        });
270    }
271
272    scaling_results
273}
274
275fn compute_thread_counts(profile: &sys::OsProfile) -> Vec<usize> {
276    let mut counts = vec![1_usize];
277    let mut n = THREAD_STEP_FACTOR;
278
279    while n < profile.p_cores {
280        counts.push(n);
281        n *= THREAD_STEP_FACTOR;
282    }
283
284    if profile.p_cores > 1 && !counts.contains(&profile.p_cores) {
285        counts.push(profile.p_cores);
286    }
287
288    let p_plus_e = profile.p_cores + profile.e_cores;
289
290    if profile.e_cores > 0 && p_plus_e != profile.p_cores {
291        counts.push(p_plus_e);
292    }
293
294    if profile.logical_cores > p_plus_e && !counts.contains(&profile.logical_cores) {
295        counts.push(profile.logical_cores);
296    }
297
298    counts.sort_unstable();
299    counts.dedup();
300
301    counts
302}
303
304fn run_scaling_dispatch(
305    setup: &ResolvedSetup,
306    seed_base: u64,
307    parameters: RunParameters,
308    num_threads: usize,
309) -> Result<()> {
310    let iterations = parameters.iterations();
311    let pool = rayon::ThreadPoolBuilder::new()
312        .num_threads(num_threads)
313        .build()
314        .context("Failed to create thread pool")?;
315
316    let base_count = iterations / num_threads as u32;
317    let remainder = iterations % num_threads as u32;
318
319    let assignments: Vec<ChunkAssignment> = (0..num_threads)
320        .map(|t| {
321            let t_u32 = t as u32;
322            let my_count = base_count + u32::from(t_u32 < remainder);
323            let mut assignment = ChunkAssignment::single_indexed("scaling", t_u32, my_count);
324
325            assignment.chunk_id = format!("scaling-{t}");
326
327            assignment
328        })
329        .collect();
330
331    let results: Vec<Result<_, ApplicationError>> = pool.install(|| {
332        assignments
333            .par_iter()
334            .map(|assignment| {
335                run_chunk(
336                    ChunkRun {
337                        assignment,
338                        duration_s: setup.duration_s,
339                        seed_base,
340                    },
341                    &NoopProgress,
342                    || build_handler_from_setup(setup),
343                )
344            })
345            .collect()
346    });
347
348    for r in results {
349        r.context("Worker failed")?;
350    }
351
352    Ok(())
353}
354
355async fn bench_sim_config(
356    spec: SpecId,
357    rotation_id: &str,
358    parameters: RunParameters,
359    resolver: &wowlab_engine_ports::DynDataResolver<'_>,
360) -> Result<String> {
361    let config = ComparisonConfig {
362        spec,
363        parameters,
364        race: None,
365        bugs: true,
366        simc_debug: false,
367    };
368
369    crate::wowlab::wowlab_profile_config(&config, rotation_id, resolver).await
370}
371
372fn bench_spec_single(
373    rt: &tokio::runtime::Runtime,
374    desc: &SpecDescriptor,
375    rotation_script: &str,
376    data_dir: &str,
377    parameters: RunParameters,
378) -> Result<SpecBenchResult> {
379    let catalog = content_catalog().context("engine content catalog is unavailable")?;
380    let iterations = parameters.iterations();
381    let duration = parameters.fight_duration_secs();
382    let slug = desc.spec_id.slug();
383    let rotation_id = format!("{slug}_assisted");
384
385    let resolver = OverlayResolver::new(LocalCsvResolver::new(data_dir))
386        .with_rotation_script(&rotation_id, rotation_script.to_string());
387    let resolver_dyn = wowlab_engine_ports::DynDataResolver::from_ref(&resolver);
388    let sim_config = rt.block_on(bench_sim_config(
389        desc.spec_id,
390        &rotation_id,
391        parameters,
392        resolver_dyn,
393    ))?;
394
395    let chunk_label = format!("bench-{slug}");
396    let chunk = sim::make_chunk("bench", iterations, Some(&chunk_label));
397
398    let start = Instant::now();
399    // PANIC-BOUNDARY: isolate one benchmark so the remaining spec results stay reportable.
400    let sim_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
401        rt.block_on(simulate_intent(
402            catalog,
403            &sim_config,
404            &chunk,
405            DEFAULT_SEED,
406            resolver_dyn,
407            &NoopProgress,
408        ))
409    }));
410    let report = match sim_result {
411        Ok(Ok(r)) => r,
412        Ok(Err(e)) => anyhow::bail!("simulation error for {slug}: {e}"),
413        Err(panic) => {
414            let msg = panic
415                .downcast_ref::<String>()
416                .map(String::as_str)
417                .or_else(|| panic.downcast_ref::<&str>().copied())
418                .unwrap_or("unknown panic");
419
420            anyhow::bail!("panic during {slug}: {msg}");
421        }
422    };
423    let elapsed_ms = start.elapsed().as_millis() as u64;
424
425    let telemetry = sim::decode_telemetry(&report)?;
426    let mean_dps = sim::mean_dps(&telemetry);
427    let iters_per_sec = if elapsed_ms > 0 {
428        f64::from(iterations) / (elapsed_ms as f64 / MS_PER_SECOND)
429    } else {
430        0.0
431    };
432
433    Ok(SpecBenchResult {
434        slug: slug.to_string(),
435        iterations,
436        duration_secs: duration,
437        elapsed_ms,
438        iterations_per_sec: round(iters_per_sec, DECIMALS_IPS),
439        mean_dps: round(mean_dps, DECIMALS_IPS),
440    })
441}
442
443fn print_summary(results: &[SpecBenchResult]) {
444    let total_iters: u64 = results.iter().map(|r| u64::from(r.iterations)).sum();
445    let total_ms: u64 = results.iter().map(|r| r.elapsed_ms).sum();
446    let overall_ips = if total_ms > 0 {
447        total_iters as f64 / (total_ms as f64 / MS_PER_SECOND)
448    } else {
449        0.0
450    };
451
452    output::kv("Total iterations", &output::fmt_number(total_iters as f64));
453    output::kv("Total time", &format!("{total_ms}ms"));
454    output::kv("Overall throughput", &format!("{overall_ips:.0} iter/s"));
455}
456
457pub(super) fn build_bench_run(
458    results: Vec<SpecBenchResult>,
459    scaling: Vec<ScalingResult>,
460) -> BenchRun {
461    BenchRun {
462        engine_version: wowlab_engine::composition::EngineBuildMetadata::CURRENT
463            .version()
464            .to_string(),
465        timestamp: shell_output("date", &["-u", "+%Y-%m-%dT%H:%M:%SZ"]),
466        git_hash: shell_output("git", &["rev-parse", "--short", "HEAD"]),
467        git_branch: shell_output("git", &["rev-parse", "--abbrev-ref", "HEAD"]),
468        rustc_version: shell_output("rustc", &["--version"]),
469        system: sys::os_profile(),
470        results,
471        scaling,
472    }
473}
474
475fn shell_output(cmd: &str, args: &[&str]) -> String {
476    let output = std::process::Command::new(cmd)
477        .args(args)
478        .output()
479        .ok()
480        .and_then(|o| String::from_utf8(o.stdout).ok());
481
482    output.map_or_else(|| "unknown".to_string(), |s| s.trim().to_string())
483}