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 std::{cell::Cell, time::Duration};
14
15use anyhow::{Context, Result};
16use prost::Message;
17use rayon::prelude::*;
18use wowlab_common::{output, sys, time::Instant};
19use wowlab_engine_adapter_data::{LocalCsvResolver, OverlayResolver};
20use wowlab_engine_application::{
21 ApplicationError, ChunkRun, IntentOverrides, ResolvedSetup, build_handler_from_setup,
22 resolve_setup, run_chunk,
23};
24use wowlab_engine_ports::{ChunkAssignment, NoopProgress, SpecDescriptor, content_catalog};
25use wowlab_fs::file;
26use wowlab_types::{constants::MS_PER_SECOND, game::SpecId};
27
28use super::types::{
29 BenchRun, BenchmarkBackend, BenchmarkSample, ScalingPoint, ScalingResult, SpecBenchResult,
30};
31use crate::{
32 constants::{DEFAULT_SEED, default_data_dir, engine_dir},
33 encounter_fixture::EncounterFixture,
34 provider::ComparisonConfig,
35 run::RunParameters,
36 sim,
37};
38
39const DECIMAL_BASE: f64 = 10.0;
40const THREAD_STEP_FACTOR: usize = 2;
41const DECIMALS_IPS: u32 = 1;
42const DECIMALS_MULTIPLIER: u32 = 2;
43const DECIMALS_RATIO: u32 = 3;
44const WARMUP_SAMPLES: u32 = 1;
45const NANOS_PER_MILLISECOND: f64 = 1_000_000.0;
46const ROUNDING_HALF_MILLISECOND_MICROS: u64 = 500;
47const MEDIAN_PAIR_SIZE: usize = 2;
48const MEDIAN_PAIR_DIVISOR: f64 = 2.0;
49const SQUARE_EXPONENT: i32 = 2;
50
51#[derive(Clone, Copy)]
52struct BenchExecution<'a> {
53 rt: &'a tokio::runtime::Runtime,
54 data_dir: &'a str,
55 parameters: RunParameters,
56 samples: u32,
57 encounter_fixture: Option<EncounterFixture>,
58}
59
60#[derive(Clone, Copy)]
61struct SampleContext<'a> {
62 rt: &'a tokio::runtime::Runtime,
63 catalog: &'a wowlab_engine_ports::ContentCatalog,
64 sim_config: &'a str,
65 chunk: &'a ChunkAssignment,
66 resolver: &'a wowlab_engine_ports::DynDataResolver<'a>,
67 parameters: RunParameters,
68 slug: &'a str,
69}
70
71fn round(v: f64, decimals: u32) -> f64 {
72 let factor = DECIMAL_BASE.powi(decimals as i32);
73
74 (v * factor).round() / factor
75}
76
77pub(super) fn discover_specs(filter: &[String]) -> Result<Vec<(&'static SpecDescriptor, String)>> {
78 let rotation_dir = engine_dir().join("examples/rotations");
79 let catalog = content_catalog().context("engine content catalog is unavailable")?;
80
81 let mut all_dps: Vec<_> = catalog
82 .descriptors()
83 .iter()
84 .filter(|d| d.spec_id.is_dps())
85 .collect();
86
87 all_dps.sort_by_key(|d| d.spec_id.slug());
88
89 let mut specs = Vec::new();
90
91 for desc in all_dps {
92 let slug = desc.spec_id.slug();
93
94 if !filter.is_empty() && !filter.iter().any(|f| f == slug) {
95 continue;
96 }
97
98 if crate::simc::simc_profile_path(desc.spec_id).is_none() {
101 continue;
102 }
103
104 let rotation_path = rotation_dir.join(format!("{slug}_assisted.json"));
105
106 if let Ok(script) = file::read_text(&rotation_path) {
107 specs.push((desc, script));
108 }
109 }
110
111 Ok(specs)
112}
113
114pub(super) fn run_benchmarks(
115 specs: &[(&'static SpecDescriptor, String)],
116 parameters: RunParameters,
117 samples: u32,
118 encounter_fixture: Option<EncounterFixture>,
119) -> Result<(Vec<SpecBenchResult>, tokio::runtime::Runtime)> {
120 let iterations = parameters.iterations();
121 let duration = parameters.fight_duration_secs();
122 let data_dir = std::env::var("WOWLAB_DATA_DIR").unwrap_or_else(|_| default_data_dir());
123 let rt = tokio::runtime::Runtime::new().context("failed to create tokio runtime")?;
124
125 output::header(&format!(
126 "Single-threaded benchmark: {} specs (1 warmup + {} samples, {} iterations/sample, {}s duration)",
127 specs.len(),
128 samples,
129 iterations,
130 duration,
131 ));
132 output::blank();
133
134 let mut results = Vec::new();
135 let mut failed: Vec<(String, String)> = Vec::new();
136
137 for (desc, rotation_script) in specs {
138 let slug = desc.spec_id.slug();
139
140 let execution = BenchExecution {
141 rt: &rt,
142 data_dir: &data_dir,
143 parameters,
144 samples,
145 encounter_fixture,
146 };
147
148 match bench_spec_single(&execution, desc, rotation_script) {
149 Ok(result) => {
150 output::success(&format!(
151 "{:<30} {:>10.1} median iter/s {:>8.1} std dev {:>10} DPS fingerprint {}",
152 result.slug,
153 result.median_iterations_per_sec,
154 result.population_stddev_iterations_per_sec,
155 output::fmt_number(result.mean_dps),
156 result.semantic_fingerprint,
157 ));
158 results.push(result);
159 }
160 Err(e) => {
161 output::error(&format!("{slug:<30} FAILED: {e:#}"));
162 failed.push((slug.to_string(), format!("{e:#}")));
163 }
164 }
165 }
166
167 output::blank();
168
169 if !failed.is_empty() {
170 output::kv("Skipped", &format!("{} specs failed", failed.len()));
171 }
172
173 print_summary(&results);
174
175 Ok((results, rt))
176}
177
178pub(super) fn run_scaling(
179 rt: &tokio::runtime::Runtime,
180 specs: &[(&'static SpecDescriptor, String)],
181 single_results: &[SpecBenchResult],
182 parameters: RunParameters,
183 encounter_fixture: Option<EncounterFixture>,
184) -> Vec<ScalingResult> {
185 let catalog = match content_catalog() {
186 Ok(catalog) => catalog,
187 Err(error) => {
188 output::error(&format!("engine content catalog is unavailable: {error}"));
189
190 return Vec::new();
191 }
192 };
193 let iterations = parameters.iterations();
194 let data_dir = std::env::var("WOWLAB_DATA_DIR").unwrap_or_else(|_| default_data_dir());
195 let profile = sys::os_profile();
196 let thread_counts = compute_thread_counts(&profile);
197
198 output::blank();
199 output::header(&format!(
200 "Thread scaling benchmark: {} specs, thread counts {:?}",
201 specs.len(),
202 thread_counts,
203 ));
204 output::blank();
205
206 let mut scaling_results = Vec::new();
207
208 for (desc, rotation_script) in specs {
209 let slug = desc.spec_id.slug();
210
211 if !single_results.iter().any(|r| r.slug == slug) {
212 output::detail(&format!("{slug:<30} skipped (no single-threaded baseline)"));
213 continue;
214 }
215
216 let rotation_id = format!("{slug}_assisted");
217
218 let resolver = OverlayResolver::new(LocalCsvResolver::new(data_dir.as_str()))
219 .with_rotation_script(&rotation_id, rotation_script.clone());
220 let resolver_dyn = wowlab_engine_ports::DynDataResolver::from_ref(&resolver);
221 let sim_config = match rt.block_on(bench_sim_config(
222 desc.spec_id,
223 &rotation_id,
224 parameters,
225 encounter_fixture,
226 resolver_dyn,
227 )) {
228 Ok(c) => c,
229 Err(e) => {
230 output::error(&format!("{slug:<30} FAILED: {e}"));
231 continue;
232 }
233 };
234 let setup = match rt.block_on(resolve_setup(
235 catalog,
236 &sim_config,
237 resolver_dyn,
238 &IntentOverrides::default(),
239 )) {
240 Ok(s) => s,
241 Err(e) => {
242 output::error(&format!("{slug:<30} FAILED: {e}"));
243 continue;
244 }
245 };
246
247 let baseline_ips = single_results
248 .iter()
249 .find(|r| r.slug == slug)
250 .map_or(1.0, |r| r.iterations_per_sec);
251
252 output::subheader(&format!("{slug} (baseline: {baseline_ips:.0} iter/s)"));
253
254 let mut points = Vec::with_capacity(thread_counts.len());
255 let mut prev_ips = baseline_ips;
256
257 for &n in &thread_counts {
258 let start = Instant::now();
259 let par_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
261 run_scaling_dispatch(&setup, DEFAULT_SEED, parameters, n)
262 }));
263
264 match par_result {
265 Ok(Err(e)) => {
266 output::error(&format!(" {n:>3} threads: FAILED: {e}"));
267 break;
268 }
269 Err(_panic) => {
270 output::error(&format!(" {n:>3} threads: PANICKED"));
271 break;
272 }
273 Ok(Ok(())) => {}
274 }
275
276 let elapsed_ms = start.elapsed().as_millis() as u64;
277
278 let ips = if elapsed_ms > 0 {
279 f64::from(iterations) / (elapsed_ms as f64 / MS_PER_SECOND)
280 } else {
281 0.0
282 };
283 let multiplier = ips / baseline_ips;
284 let efficiency = multiplier / n as f64;
285 let marginal_gain = if prev_ips > 0.0 {
286 (ips - prev_ips) / prev_ips
287 } else {
288 0.0
289 };
290
291 output::detail(&format!(
292 " {n:>3} threads: {:>10.0} iter/s {multiplier:>5.2}x eff {:.0}% marginal +{:.1}%",
293 ips,
294 efficiency * wowlab_types::constants::HUNDRED,
295 marginal_gain * wowlab_types::constants::HUNDRED,
296 ));
297
298 points.push(ScalingPoint {
299 threads: n,
300 elapsed_ms,
301 iterations_per_sec: round(ips, DECIMALS_IPS),
302 multiplier: round(multiplier, DECIMALS_MULTIPLIER),
303 efficiency: round(efficiency, DECIMALS_RATIO),
304 marginal_gain: round(marginal_gain, DECIMALS_RATIO),
305 is_p_core_range: n <= profile.p_cores,
306 });
307
308 prev_ips = ips;
309 }
310
311 scaling_results.push(ScalingResult {
312 slug: slug.to_string(),
313 iterations,
314 points,
315 });
316 }
317
318 scaling_results
319}
320
321fn compute_thread_counts(profile: &sys::OsProfile) -> Vec<usize> {
322 let mut counts = vec![1_usize];
323 let mut n = THREAD_STEP_FACTOR;
324
325 while n < profile.p_cores {
326 counts.push(n);
327 n *= THREAD_STEP_FACTOR;
328 }
329
330 if profile.p_cores > 1 && !counts.contains(&profile.p_cores) {
331 counts.push(profile.p_cores);
332 }
333
334 let p_plus_e = profile.p_cores + profile.e_cores;
335
336 if profile.e_cores > 0 && p_plus_e != profile.p_cores {
337 counts.push(p_plus_e);
338 }
339
340 if profile.logical_cores > p_plus_e && !counts.contains(&profile.logical_cores) {
341 counts.push(profile.logical_cores);
342 }
343
344 counts.sort_unstable();
345 counts.dedup();
346
347 counts
348}
349
350fn run_scaling_dispatch(
351 setup: &ResolvedSetup,
352 seed_base: u64,
353 parameters: RunParameters,
354 num_threads: usize,
355) -> Result<()> {
356 let iterations = parameters.iterations();
357 let pool = rayon::ThreadPoolBuilder::new()
358 .num_threads(num_threads)
359 .build()
360 .context("Failed to create thread pool")?;
361
362 let base_count = iterations / num_threads as u32;
363 let remainder = iterations % num_threads as u32;
364
365 let assignments: Vec<ChunkAssignment> = (0..num_threads)
366 .map(|t| {
367 let t_u32 = t as u32;
368 let my_count = base_count + u32::from(t_u32 < remainder);
369 let mut assignment = ChunkAssignment::single_indexed("scaling", t_u32, my_count);
370
371 assignment.chunk_id = format!("scaling-{t}");
372
373 assignment
374 })
375 .collect();
376
377 let results: Vec<Result<_, ApplicationError>> = pool.install(|| {
378 assignments
379 .par_iter()
380 .map(|assignment| {
381 run_chunk(
382 ChunkRun {
383 assignment,
384 duration_s: setup.duration_s,
385 seed_base,
386 },
387 &NoopProgress,
388 || build_handler_from_setup(setup),
389 )
390 })
391 .collect()
392 });
393
394 for r in results {
395 r.context("Worker failed")?;
396 }
397
398 Ok(())
399}
400
401async fn bench_sim_config(
402 spec: SpecId,
403 rotation_id: &str,
404 parameters: RunParameters,
405 encounter_fixture: Option<EncounterFixture>,
406 resolver: &wowlab_engine_ports::DynDataResolver<'_>,
407) -> Result<String> {
408 let config = ComparisonConfig {
409 spec,
410 parameters,
411 race: None,
412 bugs: true,
413 simc_debug: false,
414 };
415
416 crate::wowlab::wowlab_profile_config_with_fixture(
417 &config,
418 rotation_id,
419 encounter_fixture,
420 resolver,
421 )
422 .await
423}
424
425fn bench_spec_single(
426 execution: &BenchExecution<'_>,
427 desc: &SpecDescriptor,
428 rotation_script: &str,
429) -> Result<SpecBenchResult> {
430 let BenchExecution {
431 rt,
432 data_dir,
433 parameters,
434 samples,
435 encounter_fixture,
436 } = *execution;
437 let catalog = content_catalog().context("engine content catalog is unavailable")?;
438 let iterations = parameters.iterations();
439 let duration = parameters.fight_duration_secs();
440 let slug = desc.spec_id.slug();
441 let rotation_id = format!("{slug}_assisted");
442
443 let resolver = OverlayResolver::new(LocalCsvResolver::new(data_dir))
444 .with_rotation_script(&rotation_id, rotation_script.to_string());
445 let resolver_dyn = wowlab_engine_ports::DynDataResolver::from_ref(&resolver);
446 let sim_config = rt.block_on(bench_sim_config(
447 desc.spec_id,
448 &rotation_id,
449 parameters,
450 encounter_fixture,
451 resolver_dyn,
452 ))?;
453
454 let chunk_label = format!("bench-{slug}");
455 let chunk = sim::make_chunk("bench", iterations, Some(&chunk_label));
456 let sample_context = SampleContext {
457 rt,
458 catalog,
459 sim_config: &sim_config,
460 chunk: &chunk,
461 resolver: resolver_dyn,
462 parameters,
463 slug,
464 };
465
466 for _ in 0..WARMUP_SAMPLES {
467 let _ = bench_sample(&sample_context, 0)?;
468 }
469
470 let mut measured = Vec::with_capacity(samples as usize);
471
472 for sample in 1..=samples {
473 let result = bench_sample(&sample_context, sample)?;
474
475 output::detail(&format!(
476 " sample {sample}: {:.1} iter/s, {:.3}ms build, {:.3}ms reset, {} events, {} allocations, fingerprint {}",
477 result.iterations_per_sec,
478 result.build_time_ms,
479 result.reset_time_ms,
480 result.counters.events,
481 result
482 .allocations
483 .map_or(0, |allocations| allocations.count),
484 result.semantic_fingerprint,
485 ));
486 measured.push(result);
487 }
488
489 let fingerprint = measured
490 .first()
491 .map(|sample| sample.semantic_fingerprint.clone())
492 .context("benchmark produced no measured samples")?;
493
494 if let Some(mismatch) = measured
495 .iter()
496 .find(|sample| sample.semantic_fingerprint != fingerprint)
497 {
498 anyhow::bail!(
499 "semantic fingerprint drift for {slug}: expected {fingerprint}, sample {} produced {}",
500 mismatch.sample,
501 mismatch.semantic_fingerprint,
502 );
503 }
504
505 let throughputs: Vec<f64> = measured
506 .iter()
507 .map(|sample| sample.iterations_per_sec)
508 .collect();
509 let mean_throughput = mean(&throughputs);
510 let median_throughput = median(&throughputs);
511 let throughput_stddev = population_stddev(&throughputs, mean_throughput);
512 let average_dps = mean(
513 &measured
514 .iter()
515 .map(|sample| sample.mean_dps)
516 .collect::<Vec<_>>(),
517 );
518 let total_elapsed_ms = measured.iter().map(|sample| sample.elapsed_ms).sum::<f64>();
519
520 Ok(SpecBenchResult {
521 slug: slug.to_string(),
522 encounter_fixture: encounter_fixture
523 .map_or("patchwerk", EncounterFixture::slug)
524 .to_string(),
525 backend: benchmark_backend(),
526 iterations,
527 duration_secs: duration,
528 warmups: WARMUP_SAMPLES,
529 samples: measured,
530 elapsed_ms: rounded_milliseconds(total_elapsed_ms)?,
531 iterations_per_sec: round(mean_throughput, DECIMALS_IPS),
532 mean_iterations_per_sec: mean_throughput,
533 median_iterations_per_sec: median_throughput,
534 population_stddev_iterations_per_sec: throughput_stddev,
535 mean_dps: round(average_dps, DECIMALS_IPS),
536 semantic_fingerprint: fingerprint,
537 })
538}
539
540fn rounded_milliseconds(milliseconds: f64) -> Result<u64> {
541 let elapsed = Duration::try_from_secs_f64(milliseconds / MS_PER_SECOND)
542 .context("benchmark elapsed time must be finite and nonnegative")?;
543 let rounded = elapsed
544 .checked_add(Duration::from_micros(ROUNDING_HALF_MILLISECOND_MICROS))
545 .context("benchmark elapsed time exceeds Duration capacity")?;
546
547 u64::try_from(rounded.as_millis()).context("benchmark elapsed milliseconds exceed u64 capacity")
548}
549
550fn bench_sample(context: &SampleContext<'_>, sample: u32) -> Result<BenchmarkSample> {
551 let SampleContext {
552 rt,
553 catalog,
554 sim_config,
555 chunk,
556 resolver,
557 parameters,
558 slug,
559 } = *context;
560 let build_started_at = Instant::now();
561 let setup = rt
562 .block_on(resolve_setup(
563 catalog,
564 sim_config,
565 resolver,
566 &IntentOverrides::default(),
567 ))
568 .with_context(|| format!("setup resolution failed for {slug}, sample {sample}"))?;
569 let setup_time_ms = build_started_at.elapsed().as_secs_f64() * MS_PER_SECOND;
570 let handler_build_ns = Cell::new(0_u64);
571 let run_started_at = Instant::now();
572
573 let sim_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
575 run_chunk(
576 ChunkRun {
577 assignment: chunk,
578 duration_s: setup.duration_s,
579 seed_base: DEFAULT_SEED,
580 },
581 &NoopProgress,
582 || {
583 let (handler, elapsed) =
584 measure_build_operation(|| build_handler_from_setup(&setup));
585
586 handler_build_ns.set(elapsed);
587
588 if handler.is_ok() {
589 wowlab_types::bench_metrics::reset();
590 crate::GLOBAL.start();
591 }
592
593 handler
594 },
595 )
596 }));
597 let allocation_snapshot = crate::GLOBAL.stop();
598 let total_run_ms = run_started_at.elapsed().as_secs_f64() * MS_PER_SECOND;
599 let handler_time_ms = handler_build_ns.get() as f64 / NANOS_PER_MILLISECOND;
600 let elapsed_ms = (total_run_ms - handler_time_ms).max(0.0);
601 let report = match sim_result {
602 Ok(Ok(report)) => report,
603 Ok(Err(error)) => {
604 return Err(anyhow::Error::new(error)
605 .context(format!("simulation failed for {slug}, sample {sample}")));
606 }
607 Err(panic) => {
608 let message = panic
609 .downcast_ref::<String>()
610 .map(String::as_str)
611 .or_else(|| panic.downcast_ref::<&str>().copied())
612 .unwrap_or("unknown panic");
613
614 anyhow::bail!("panic during {slug}, sample {sample}: {message}");
615 }
616 };
617 let counters = wowlab_types::bench_metrics::snapshot();
618 let telemetry = sim::decode_telemetry(&report)?;
619 let iterations_per_sec = if elapsed_ms > 0.0 {
620 f64::from(parameters.iterations()) / (elapsed_ms / MS_PER_SECOND)
621 } else {
622 0.0
623 };
624
625 Ok(BenchmarkSample {
626 sample,
627 elapsed_ms,
628 iterations_per_sec,
629 mean_dps: sim::mean_dps(&telemetry),
630 semantic_fingerprint: semantic_fingerprint(&telemetry),
631 counters,
632 build_time_ms: setup_time_ms + handler_time_ms,
633 reset_time_ms: reset_time_ms(counters),
634 allocations: Some(allocation_snapshot),
635 })
636}
637
638fn measure_build_operation<T>(operation: impl FnOnce() -> T) -> (T, u64) {
639 let started_at = Instant::now();
640 let value = operation();
641 let elapsed = u64::try_from(started_at.elapsed().as_nanos()).unwrap_or(u64::MAX);
642
643 (value, elapsed)
644}
645
646const fn reset_time_ms(counters: wowlab_types::bench_metrics::BenchmarkCounters) -> f64 {
647 counters.reset_time_ns as f64 / NANOS_PER_MILLISECOND
648}
649
650fn semantic_fingerprint(telemetry: &wowlab_types::proto::ChunkTelemetry) -> String {
651 blake3::hash(&telemetry.encode_to_vec())
652 .to_hex()
653 .to_string()
654}
655
656const fn benchmark_backend() -> BenchmarkBackend {
657 if cfg!(feature = "jit") {
658 BenchmarkBackend::Jit
659 } else {
660 BenchmarkBackend::Portable
661 }
662}
663
664fn mean(values: &[f64]) -> f64 {
665 values.iter().sum::<f64>() / values.len() as f64
666}
667
668fn median(values: &[f64]) -> f64 {
669 let mut sorted = values.to_vec();
670
671 sorted.sort_by(f64::total_cmp);
672 let middle = sorted.len() / MEDIAN_PAIR_SIZE;
673
674 let upper = sorted.get(middle).copied().unwrap_or_default();
675
676 if sorted.len().is_multiple_of(MEDIAN_PAIR_SIZE) {
677 let lower = sorted
678 .get(middle.saturating_sub(1))
679 .copied()
680 .unwrap_or(upper);
681
682 (lower + upper) / MEDIAN_PAIR_DIVISOR
683 } else {
684 upper
685 }
686}
687
688fn population_stddev(values: &[f64], mean: f64) -> f64 {
689 let variance = values
690 .iter()
691 .map(|value| (value - mean).powi(SQUARE_EXPONENT))
692 .sum::<f64>()
693 / values.len() as f64;
694
695 variance.sqrt()
696}
697
698fn print_summary(results: &[SpecBenchResult]) {
699 let total_iters: u64 = results
700 .iter()
701 .map(|result| u64::from(result.iterations) * result.samples.len() as u64)
702 .sum();
703 let total_ms: u64 = results.iter().map(|r| r.elapsed_ms).sum();
704 let overall_ips = if total_ms > 0 {
705 total_iters as f64 / (total_ms as f64 / MS_PER_SECOND)
706 } else {
707 0.0
708 };
709
710 output::kv("Total iterations", &output::fmt_number(total_iters as f64));
711 output::kv("Total time", &format!("{total_ms}ms"));
712 output::kv("Overall throughput", &format!("{overall_ips:.0} iter/s"));
713}
714
715pub(super) fn build_bench_run(
716 results: Vec<SpecBenchResult>,
717 scaling: Vec<ScalingResult>,
718) -> BenchRun {
719 BenchRun {
720 engine_version: wowlab_engine::composition::EngineBuildMetadata::CURRENT
721 .version()
722 .to_string(),
723 timestamp: shell_output("date", &["-u", "+%Y-%m-%dT%H:%M:%SZ"]),
724 git_hash: shell_output("git", &["rev-parse", "--short", "HEAD"]),
725 git_branch: shell_output("git", &["rev-parse", "--abbrev-ref", "HEAD"]),
726 rustc_version: shell_output("rustc", &["--version"]),
727 features: if cfg!(feature = "jit") {
728 vec!["jit".to_string()]
729 } else {
730 Vec::new()
731 },
732 backend: benchmark_backend(),
733 system: sys::os_profile(),
734 results,
735 scaling,
736 }
737}
738
739fn shell_output(cmd: &str, args: &[&str]) -> String {
740 let output = std::process::Command::new(cmd)
741 .args(args)
742 .output()
743 .ok()
744 .and_then(|o| String::from_utf8(o.stdout).ok());
745
746 output.map_or_else(|| "unknown".to_string(), |s| s.trim().to_string())
747}
748
749#[cfg(test)]
750mod tests {
751 use googletest::{Result as GtestResult, prelude::*};
752
753 use super::*;
754
755 #[gtest]
756 fn canonical_empty_telemetry_has_pinned_blake3_fingerprint() -> GtestResult<()> {
757 let telemetry = wowlab_types::proto::ChunkTelemetry::default();
758
759 verify_that!(
760 semantic_fingerprint(&telemetry),
761 eq("af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262")
762 )
763 }
764
765 #[gtest]
766 fn fingerprint_is_stable_after_canonical_protobuf_round_trip() -> GtestResult<()> {
767 let telemetry = wowlab_types::proto::ChunkTelemetry {
768 iterations: 17,
769 mean_dps_x10: 123_456,
770 ..Default::default()
771 };
772 let bytes = telemetry.encode_to_vec();
773 let decoded = wowlab_types::proto::ChunkTelemetry::decode(bytes.as_slice()).or_fail()?;
774
775 verify_that!(
776 semantic_fingerprint(&telemetry),
777 eq(&semantic_fingerprint(&decoded))
778 )
779 }
780
781 #[gtest]
782 fn distribution_uses_population_standard_deviation_and_even_median() -> GtestResult<()> {
783 let values = [1.0, 2.0, 3.0, 4.0];
784 let average = mean(&values);
785
786 verify_that!(average, near(2.5, f64::EPSILON))?;
787 verify_that!(median(&values), near(2.5, f64::EPSILON))?;
788
789 verify_that!(
790 population_stddev(&values, average),
791 near(1.118_033_988_749_895, f64::EPSILON)
792 )
793 }
794
795 #[gtest]
796 fn benchmark_boundaries_measure_real_build_and_reset_operations() -> GtestResult<()> {
797 let (built, build_time_ns) = measure_build_operation(|| {
798 (0_u64..4_096).map(std::hint::black_box).collect::<Vec<_>>()
799 });
800
801 verify_that!(built.len(), eq(4_096))?;
802 verify_that!(build_time_ns, gt(0))?;
803
804 let mut resettable = built;
805
806 wowlab_types::bench_metrics::reset();
807 wowlab_types::bench_metrics::measure_reset(|| resettable.clear());
808
809 let counters = wowlab_types::bench_metrics::snapshot();
810
811 verify_that!(resettable, is_empty())?;
812 verify_that!(counters.resets, eq(1))?;
813 verify_that!(counters.reset_time_ns, gt(0))?;
814
815 verify_that!(reset_time_ms(counters), gt(0.0))
816 }
817}