1#![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
11use 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 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 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 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}