1use std::{
2 fs,
3 io::{self, IsTerminal},
4 path::{Path, PathBuf},
5 time::{Duration, Instant},
6};
7
8use clap::Args;
9use color_eyre::eyre::{eyre, Context};
10use color_eyre::Result;
11use colored::Colorize;
12use indicatif::ProgressBar;
13use ndarray::Array2;
14use schemars::JsonSchema;
15use serde::{Deserialize, Serialize};
16use spenso::algebra::complex::Complex;
17use tabled::{
18 builder::Builder,
19 settings::{
20 object::{Columns, Object, Rows},
21 style::HorizontalLine,
22 Alignment, Modify, Style,
23 },
24};
25use tracing::info;
26
27use crate::{
28 commands::evaluate_samples::{with_warmed_integrand, EvaluateSamples},
29 completion::CompletionArgExt,
30 state::{ProcessRef, State},
31};
32use gammalooprs::{
33 integrands::{evaluation::EvaluationResultOutput, process::ProcessIntegrand},
34 model::Model,
35 settings::{runtime::StabilityLevelSetting, RuntimeSettings},
36 utils::F,
37};
38
39const BENCH_WARMUP_SAMPLES: usize = 10;
40const BENCH_DEFAULT_BATCHES: usize = 10;
41const BENCH_OTHER_RELATIVE_THRESHOLD: f64 = 5.0e-3;
42const BENCH_OTHER_ABSOLUTE_THRESHOLD_SECONDS: f64 = 1.0e-9;
43
44#[derive(Debug, Args, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Default)]
45pub struct Bench {
46 #[arg(
48 short = 'p',
49 long = "process",
50 value_name = "PROCESS",
51 completion_process_selector(crate::completion::SelectorKind::Any)
52 )]
53 pub process: Option<ProcessRef>,
54
55 #[arg(
57 short = 'i',
58 long = "integrand-name",
59 value_name = "NAME",
60 completion_integrand_selector(crate::completion::SelectorKind::Any)
61 )]
62 pub integrand_name: Option<String>,
63
64 #[arg(
66 short = 'x',
67 long = "point",
68 required = true,
69 num_args = 2..,
70 value_name = "POINT",
71 value_delimiter = ',',
72 allow_negative_numbers = true,
73 )]
74 pub point: Vec<f64>,
75
76 #[arg(long, value_name = "DURATION")]
78 pub duration: String,
79
80 #[arg(short = 'f', long = "use-arb-prec")]
82 pub use_arb_prec: bool,
83
84 #[arg(short = 'm', long)]
86 pub momentum_space: bool,
87
88 #[arg(
90 short = 'd',
91 long = "discrete-dim",
92 value_name = "DIMS",
93 num_args = 1..,
94 value_delimiter = ',',
95 conflicts_with_all = ["graph_id", "orientation_id"],
96 )]
97 pub discrete_dim: Vec<usize>,
98
99 #[arg(long = "graph-id", value_name = "GRAPH_ID")]
101 pub graph_id: Option<usize>,
102
103 #[arg(
105 long = "orientation-id",
106 value_name = "ORIENTATION_ID",
107 requires = "graph_id",
108 conflicts_with = "discrete_dim"
109 )]
110 pub orientation_id: Option<usize>,
111
112 #[arg(long = "n-batches", value_name = "N", value_parser = parse_positive_usize)]
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub n_batches: Option<usize>,
116
117 #[arg(long = "minimal-integrand")]
120 #[serde(default)]
121 pub minimal_integrand: bool,
122
123 #[arg(long = "json-output", value_name = "PATH")]
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub json_output: Option<PathBuf>,
127}
128
129#[derive(Debug, Clone, Copy, Serialize)]
130struct BenchBatchTiming {
131 parameterization: f64,
132 canonical_sampling: f64,
133 integrand: f64,
134 canonical_physical: f64,
135 event_processing: f64,
136 evaluator: f64,
137 other: f64,
138 total: f64,
139}
140
141#[derive(Debug, Clone)]
142struct BenchRow {
143 category: &'static str,
144 timings: Vec<f64>,
145}
146
147#[derive(Debug, Clone, Serialize)]
148struct BenchSummaryRow {
149 category: String,
150 mean_seconds_per_sample: f64,
151 standard_error_seconds_per_sample: f64,
152 percentage_of_total: Option<f64>,
153}
154
155#[derive(Debug, Clone, Serialize)]
156struct BenchOutput {
157 process_id: usize,
158 integrand_name: String,
159 momentum_space: bool,
160 point: Vec<f64>,
161 discrete_dim: Vec<usize>,
162 graph_name: Option<String>,
163 orientation_id: Option<usize>,
164 target_seconds: f64,
165 warmup_samples: usize,
166 warmup_seconds: f64,
167 total_samples: usize,
168 n_batches: usize,
169 minimal_integrand: bool,
170 parameterization_jacobian: Option<f64>,
171 displayed_result: Complex<f64>,
172 batches: Vec<BenchBatchTiming>,
173 summary: Vec<BenchSummaryRow>,
174}
175
176impl BenchRow {
177 fn mean_and_standard_error(&self) -> (f64, f64) {
178 mean_and_standard_error(&self.timings)
179 }
180}
181
182impl Bench {
183 fn validate_selector_mode(&self) -> Result<()> {
184 if !self.momentum_space && (self.graph_id.is_some() || self.orientation_id.is_some()) {
185 return Err(eyre!(
186 "Graph and orientation selectors are only supported in momentum-space benchmarks."
187 ));
188 }
189 Ok(())
190 }
191
192 fn resolve_graph_name(
193 &self,
194 state: &mut State,
195 process_id: usize,
196 integrand_name: &str,
197 ) -> Result<Option<String>> {
198 let Some(graph_id) = self.graph_id else {
199 return Ok(None);
200 };
201 let integrand = state
202 .process_list
203 .get_integrand_mut(process_id, integrand_name)?;
204 let graph_name = integrand.graph_name_by_id(graph_id).ok_or_else(|| {
205 eyre!(
206 "Graph id {} is out of range for integrand '{}'; it has {} graphs.",
207 graph_id,
208 integrand_name,
209 integrand.graph_count()
210 )
211 })?;
212 Ok(Some(graph_name.to_string()))
213 }
214
215 fn validate_x_space_point(
216 &self,
217 state: &mut State,
218 process_id: usize,
219 integrand_name: &str,
220 ) -> Result<()> {
221 let integrand = state
222 .process_list
223 .get_integrand_mut(process_id, integrand_name)?;
224 let expected_dimension = integrand.expected_x_space_dimension(&self.discrete_dim)?;
225 if self.point.len() != expected_dimension {
226 return Err(eyre!(
227 "Expected {} x-space coordinates for this integrand selection, got {}.",
228 expected_dimension,
229 self.point.len()
230 ));
231 }
232 Ok(())
233 }
234
235 fn repeated_point_array(&self, rows: usize) -> Result<Array2<f64>> {
236 let mut values = Vec::with_capacity(rows * self.point.len());
237 for _ in 0..rows {
238 values.extend_from_slice(&self.point);
239 }
240 Array2::from_shape_vec((rows, self.point.len()), values).map_err(Into::into)
241 }
242
243 fn repeated_discrete_dim_array(&self, rows: usize) -> Result<Array2<usize>> {
244 let mut values = Vec::with_capacity(rows * self.discrete_dim.len());
245 for _ in 0..rows {
246 values.extend_from_slice(&self.discrete_dim);
247 }
248 Array2::from_shape_vec((rows, self.discrete_dim.len()), values).map_err(Into::into)
249 }
250
251 fn evaluate_samples_request<'a>(
252 &'a self,
253 process_id: usize,
254 integrand_name: String,
255 points: &'a Array2<f64>,
256 discrete_dims: &'a Array2<usize>,
257 graph_name: &Option<String>,
258 ) -> EvaluateSamples<'a> {
259 let batch_len = points.nrows();
260 EvaluateSamples {
261 process_id: Some(process_id),
262 integrand_name: Some(integrand_name),
263 use_arb_prec: self.use_arb_prec,
264 minimal_output: false,
265 return_generated_events: None,
267 momentum_space: self.momentum_space,
268 points: points.view(),
269 integrator_weights: None,
270 discrete_dims: Some(discrete_dims.view()),
271 graph_names: graph_name
272 .as_ref()
273 .map(|name| vec![Some(name.clone()); batch_len]),
274 orientations: self
275 .orientation_id
276 .map(|orientation| vec![Some(orientation); batch_len]),
277 }
278 }
279
280 fn displayed_result_from_evaluation(
281 &self,
282 evaluation: &EvaluationResultOutput,
283 ) -> Result<(Option<f64>, Complex<f64>)> {
284 let raw_result = evaluation.integrand_result;
285 let jacobian = evaluation.parameterization_jacobian.map(|jac| jac.0);
286 let displayed_result = if self.momentum_space {
287 raw_result
288 } else {
289 let jacobian = jacobian.ok_or_else(|| {
290 eyre!("An x-space benchmark requires a parameterization jacobian, but none was returned")
291 })?;
292 raw_result.map(|entry| entry * F(jacobian))
293 };
294 Ok((jacobian, displayed_result.map(|entry| entry.0)))
295 }
296
297 fn write_json_output<T: Serialize>(&self, value: &T) -> Result<()> {
298 let Some(path) = self.json_output.as_deref() else {
299 return Ok(());
300 };
301 let path = resolve_json_output_path(path)?;
302 if let Some(parent) = path.parent() {
303 fs::create_dir_all(parent)
304 .with_context(|| format!("Could not create {}", parent.display()))?;
305 }
306 fs::write(&path, serde_json::to_vec_pretty(value)?)
307 .with_context(|| format!("Could not write benchmark JSON output {}", path.display()))?;
308 info!(
309 "Wrote benchmark JSON output to {}",
310 path.display().to_string().green()
311 );
312 Ok(())
313 }
314
315 fn run_benchmark(
316 &self,
317 integrand: &mut ProcessIntegrand,
318 model: &Model,
319 process_id: usize,
320 integrand_name: String,
321 graph_name: Option<String>,
322 ) -> Result<(Option<f64>, Complex<f64>)> {
323 let target = parse_bench_target_duration(&self.duration)?;
324 let n_batches = self.n_batches.unwrap_or(BENCH_DEFAULT_BATCHES);
325 if n_batches == 0 {
326 return Err(eyre!("bench requires at least one batch"));
327 }
328
329 let warmup_points = self.repeated_point_array(BENCH_WARMUP_SAMPLES)?;
330 let warmup_discrete_dims = self.repeated_discrete_dim_array(BENCH_WARMUP_SAMPLES)?;
331 let warmup_start = Instant::now();
332 let warmup = self
333 .evaluate_samples_request(
334 process_id,
335 integrand_name.clone(),
336 &warmup_points,
337 &warmup_discrete_dims,
338 &graph_name,
339 )
340 .run_warmed(integrand, model)?;
341 let warmup_timing = warmup_start.elapsed();
342 let warmup_samples = warmup.samples.len().max(1);
343 let warmup_per_sample = warmup_timing.as_secs_f64() / warmup_samples as f64;
344 let total_samples = if warmup_per_sample > 0.0 {
345 ((target.as_secs_f64() / warmup_per_sample).ceil() as usize).max(n_batches)
346 } else {
347 n_batches
348 };
349
350 let batch_sizes = split_samples_into_batches(total_samples, n_batches);
351 let progress_bar = bench_progress_bar(n_batches);
352 progress_bar.set_message(
353 format!(
354 "{} {}",
355 "running benchmark batches".cyan(),
356 format!("0/{n_batches}").blue()
357 )
358 .to_string(),
359 );
360
361 let batch_timings = (|| -> Result<Vec<_>> {
362 let mut batch_timings = Vec::with_capacity(n_batches);
363 for (batch_index, batch_size) in batch_sizes.into_iter().enumerate() {
364 let points = self.repeated_point_array(batch_size)?;
365 let discrete_dims = self.repeated_discrete_dim_array(batch_size)?;
366 let batch_start = Instant::now();
367 let batch = self
368 .evaluate_samples_request(
369 process_id,
370 integrand_name.clone(),
371 &points,
372 &discrete_dims,
373 &graph_name,
374 )
375 .run_warmed(integrand, model)?;
376 let total_timing = batch_start.elapsed();
377 batch_timings.push(bench_batch_timing(total_timing, &batch)?);
378 update_bench_progress(
379 &progress_bar,
380 batch_index + 1,
381 n_batches,
382 &batch_timings,
383 total_samples,
384 );
385 }
386 Ok(batch_timings)
387 })();
388 progress_bar.finish_and_clear();
389 let batch_timings = batch_timings?;
390
391 let warmup_evaluation = warmup
392 .samples
393 .first()
394 .ok_or_else(|| eyre!("bench warmup returned no sample"))?;
395 let (jacobian, displayed_result) =
396 self.displayed_result_from_evaluation(&warmup_evaluation.evaluation)?;
397 let bench_output = BenchOutput {
398 process_id,
399 integrand_name: integrand_name.clone(),
400 momentum_space: self.momentum_space,
401 point: self.point.clone(),
402 discrete_dim: self.discrete_dim.clone(),
403 graph_name: graph_name.clone(),
404 orientation_id: self.orientation_id,
405 target_seconds: target.as_secs_f64(),
406 warmup_samples,
407 warmup_seconds: warmup_timing.as_secs_f64(),
408 total_samples,
409 n_batches,
410 minimal_integrand: self.minimal_integrand,
411 parameterization_jacobian: jacobian,
412 displayed_result,
413 batches: batch_timings.clone(),
414 summary: bench_summary_rows(&batch_timings),
415 };
416 self.write_json_output(&bench_output)?;
417
418 info!(
419 "\n{}\n{}",
420 format!(
421 "Benchmark for integrand '{}' at fixed input point: target {}, warmup {} sample(s), profiled {} sample(s) in {} batch(es).",
422 integrand_name.green(),
423 format_duration_smart(target).yellow(),
424 warmup_samples.to_string().blue(),
425 total_samples.to_string().blue(),
426 n_batches.to_string().blue(),
427 ),
428 render_bench_table(&batch_timings)
429 );
430
431 Ok((jacobian, displayed_result))
432 }
433
434 pub fn run(&self, state: &mut State) -> Result<()> {
435 let (process_id, integrand_name) =
436 state.find_integrand_ref(self.process.as_ref(), self.integrand_name.as_ref())?;
437 self.validate_selector_mode()?;
438 if !self.momentum_space {
439 self.validate_x_space_point(state, process_id, &integrand_name)?;
440 }
441 let graph_name = self.resolve_graph_name(state, process_id, &integrand_name)?;
442 let integrand_name_for_restore = integrand_name.clone();
443 self.with_optional_minimal_integrand_settings(
444 state,
445 process_id,
446 &integrand_name_for_restore,
447 |state| {
448 with_warmed_integrand(
451 state,
452 process_id,
453 &integrand_name_for_restore,
454 Some(false),
455 |integrand, model| {
456 self.run_benchmark(integrand, model, process_id, integrand_name, graph_name)
457 },
458 )
459 },
460 )
461 .map(drop)
462 }
463
464 fn with_optional_minimal_integrand_settings<R>(
465 &self,
466 state: &mut State,
467 process_id: usize,
468 integrand_name: &str,
469 run: impl FnOnce(&mut State) -> Result<R>,
470 ) -> Result<R> {
471 if !self.minimal_integrand {
472 return run(state);
473 }
474
475 let original_settings = {
476 let integrand = state
477 .process_list
478 .get_integrand_mut(process_id, integrand_name)?;
479 let original_settings = integrand.get_settings().clone();
480 apply_minimal_integrand_settings(integrand.get_mut_settings());
481 original_settings
482 };
483
484 let result = run(state);
485 let integrand = state
486 .process_list
487 .get_integrand_mut(process_id, integrand_name)?;
488 *integrand.get_mut_settings() = original_settings;
489 result
490 }
491}
492
493fn apply_minimal_integrand_settings(settings: &mut RuntimeSettings) {
494 settings.general.enable_cache = false;
495 settings.general.debug_cache = false;
496 settings.general.generate_events = false;
497 settings.general.store_additional_weights_in_event = false;
498 settings.observables = Default::default();
499 settings.selectors = Default::default();
500 settings.stability.rotation_axis = Vec::new();
501 settings.stability.levels = vec![StabilityLevelSetting::default_double()];
502 settings.stability.check_on_norm = false;
503 settings.stability.escalate_if_exact_zero = false;
504 settings.stability.loop_momenta_norm_escalation_factor = -1.0;
505 settings.stability.recording = None;
506}
507
508fn parse_bench_target_duration(input: &str) -> Result<Duration> {
509 let trimmed = input.trim();
510 if trimmed.is_empty() {
511 return Err(eyre!("bench requires a non-empty duration"));
512 }
513
514 let lower = trimmed.to_ascii_lowercase();
515 let (number, multiplier) = if let Some(number) = lower.strip_suffix("microseconds") {
516 (number, 1.0e-6)
517 } else if let Some(number) = lower.strip_suffix("microsecond") {
518 (number, 1.0e-6)
519 } else if let Some(number) = lower.strip_suffix("usec") {
520 (number, 1.0e-6)
521 } else if let Some(number) = lower.strip_suffix("us") {
522 (number, 1.0e-6)
523 } else if let Some(number) = lower.strip_suffix("µs") {
524 (number, 1.0e-6)
525 } else if let Some(number) = lower.strip_suffix("milliseconds") {
526 (number, 1.0e-3)
527 } else if let Some(number) = lower.strip_suffix("millisecond") {
528 (number, 1.0e-3)
529 } else if let Some(number) = lower.strip_suffix("msec") {
530 (number, 1.0e-3)
531 } else if let Some(number) = lower.strip_suffix("ms") {
532 (number, 1.0e-3)
533 } else if let Some(number) = lower.strip_suffix("seconds") {
534 (number, 1.0)
535 } else if let Some(number) = lower.strip_suffix("second") {
536 (number, 1.0)
537 } else if let Some(number) = lower.strip_suffix("sec") {
538 (number, 1.0)
539 } else if let Some(number) = lower.strip_suffix('s') {
540 (number, 1.0)
541 } else {
542 (trimmed, 1.0)
543 };
544 let seconds = number
545 .trim()
546 .parse::<f64>()
547 .with_context(|| format!("Could not parse bench duration '{input}'"))?
548 * multiplier;
549 if !seconds.is_finite() || seconds <= 0.0 {
550 return Err(eyre!("bench duration must be positive and finite"));
551 }
552 Duration::try_from_secs_f64(seconds)
553 .map_err(|_| eyre!("bench duration is too large to represent"))
554}
555
556fn parse_positive_usize(input: &str) -> std::result::Result<usize, String> {
557 let value = input
558 .parse::<usize>()
559 .map_err(|err| format!("expected a positive integer: {err}"))?;
560 if value == 0 {
561 return Err("expected a positive integer".to_string());
562 }
563 Ok(value)
564}
565
566fn resolve_json_output_path(path: &Path) -> Result<PathBuf> {
567 if path.is_absolute() {
568 Ok(path.to_path_buf())
569 } else {
570 Ok(std::env::current_dir()?.join(path))
571 }
572}
573
574fn split_samples_into_batches(total_samples: usize, batches: usize) -> Vec<usize> {
575 let base = total_samples / batches;
576 let remainder = total_samples % batches;
577 (0..batches)
578 .map(|index| base + usize::from(index < remainder))
579 .collect()
580}
581
582fn bench_batch_timing(
583 total_timing: Duration,
584 batch: &gammalooprs::integrands::evaluation::BatchSampleEvaluationResult,
585) -> Result<BenchBatchTiming> {
586 let sample_count = batch.samples.len();
587 if sample_count == 0 {
588 return Err(eyre!("bench batch returned no samples"));
589 }
590
591 let mut parameterization = 0.0;
592 let mut canonical_sampling = 0.0;
593 let mut integrand_inclusive = 0.0;
594 let mut canonical_physical = 0.0;
595 let mut event_processing = 0.0;
596 let mut evaluator = 0.0;
597 for sample in &batch.samples {
598 let metadata = sample
599 .evaluation
600 .evaluation_metadata
601 .as_ref()
602 .ok_or_else(|| eyre!("bench requires evaluation metadata"))?;
603 if metadata.canonical_sampling_preparation_time > metadata.parameterization_time
604 || metadata.evaluator_evaluation_time
605 + metadata.event_processing_time
606 + metadata.canonical_physical_preparation_time
607 > metadata.integrand_evaluation_time
608 {
609 return Err(eyre!(
610 "bench metadata timing subsets exceed their inclusive totals"
611 ));
612 }
613 parameterization += metadata.parameterization_time.as_secs_f64();
614 canonical_sampling += metadata.canonical_sampling_preparation_time.as_secs_f64();
615 integrand_inclusive += metadata.integrand_evaluation_time.as_secs_f64();
616 canonical_physical += metadata.canonical_physical_preparation_time.as_secs_f64();
617 event_processing += metadata.event_processing_time.as_secs_f64();
618 evaluator += metadata.evaluator_evaluation_time.as_secs_f64();
619 }
620
621 let inv_sample_count = 1.0 / sample_count as f64;
622 let canonical_sampling = canonical_sampling * inv_sample_count;
623 let parameterization = (parameterization * inv_sample_count - canonical_sampling).max(0.0);
624 let canonical_physical = canonical_physical * inv_sample_count;
625 let evaluator = evaluator * inv_sample_count;
626 let event_processing = event_processing * inv_sample_count;
629 let integrand = (integrand_inclusive * inv_sample_count
630 - evaluator
631 - event_processing
632 - canonical_physical)
633 .max(0.0);
634 let total = total_timing.as_secs_f64() * inv_sample_count;
638 let known = parameterization
639 + canonical_sampling
640 + integrand
641 + canonical_physical
642 + event_processing
643 + evaluator;
644 let other = (total - known).max(0.0);
645
646 Ok(BenchBatchTiming {
647 parameterization,
648 canonical_sampling,
649 integrand,
650 canonical_physical,
651 event_processing,
652 evaluator,
653 other,
654 total,
655 })
656}
657
658fn bench_summary_rows(batch_timings: &[BenchBatchTiming]) -> Vec<BenchSummaryRow> {
659 let rows = bench_rows(batch_timings);
660 let total_values = batch_timings
661 .iter()
662 .map(|timing| timing.total)
663 .collect::<Vec<_>>();
664 let (total_mean, total_standard_error) = mean_and_standard_error(&total_values);
665
666 let mut summary = rows
667 .into_iter()
668 .map(|row| {
669 let (mean, standard_error) = row.mean_and_standard_error();
670 BenchSummaryRow {
671 category: row.category.to_string(),
672 mean_seconds_per_sample: mean,
673 standard_error_seconds_per_sample: standard_error,
674 percentage_of_total: (total_mean > 0.0).then_some(mean / total_mean * 100.0),
675 }
676 })
677 .collect::<Vec<_>>();
678 summary.push(BenchSummaryRow {
679 category: "Total".to_string(),
680 mean_seconds_per_sample: total_mean,
681 standard_error_seconds_per_sample: total_standard_error,
682 percentage_of_total: (total_mean > 0.0).then_some(100.0),
683 });
684 summary
685}
686
687fn bench_progress_bar(n_batches: usize) -> ProgressBar {
688 let bar = if io::stderr().is_terminal() {
689 ProgressBar::new(n_batches as u64)
690 } else {
691 ProgressBar::hidden()
692 };
693 bar.set_style(gammalooprs::utils::long_running_progress_style());
694 bar
695}
696
697fn update_bench_progress(
698 progress_bar: &ProgressBar,
699 completed_batches: usize,
700 n_batches: usize,
701 batch_timings: &[BenchBatchTiming],
702 total_samples: usize,
703) {
704 let total_values = batch_timings
705 .iter()
706 .map(|timing| timing.total)
707 .collect::<Vec<_>>();
708 let (total_mean, total_standard_error) = mean_and_standard_error(&total_values);
709 let projected_total = Duration::try_from_secs_f64((total_mean * total_samples as f64).max(0.0))
710 .unwrap_or(Duration::MAX);
711 let message = format!(
712 "{} {} | {} {} | {} {}",
713 "batch".cyan(),
714 format!("{completed_batches}/{n_batches}").blue(),
715 "total/sample".cyan(),
716 format_timing_with_uncertainty(total_mean, total_standard_error).yellow(),
717 "projected total".cyan(),
718 format_duration_smart(projected_total).yellow(),
719 );
720 progress_bar.set_position(completed_batches as u64);
721 progress_bar.set_message(message);
722}
723
724fn bench_rows(batch_timings: &[BenchBatchTiming]) -> Vec<BenchRow> {
725 let mut rows = vec![
726 BenchRow {
727 category: "sampling other",
728 timings: batch_timings
729 .iter()
730 .map(|timing| timing.parameterization)
731 .collect(),
732 },
733 BenchRow {
734 category: "canonical sampling",
735 timings: batch_timings
736 .iter()
737 .map(|timing| timing.canonical_sampling)
738 .collect(),
739 },
740 BenchRow {
741 category: "physical other",
742 timings: batch_timings
743 .iter()
744 .map(|timing| timing.integrand)
745 .collect(),
746 },
747 BenchRow {
748 category: "canonical physical",
749 timings: batch_timings
750 .iter()
751 .map(|timing| timing.canonical_physical)
752 .collect(),
753 },
754 BenchRow {
755 category: "event processing",
756 timings: batch_timings
757 .iter()
758 .map(|timing| timing.event_processing)
759 .collect(),
760 },
761 ];
762
763 let total_values = batch_timings
764 .iter()
765 .map(|timing| timing.total)
766 .collect::<Vec<_>>();
767 let (total_mean, _) = mean_and_standard_error(&total_values);
768 let other_values = batch_timings
769 .iter()
770 .map(|timing| timing.other)
771 .collect::<Vec<_>>();
772 let (other_mean, _) = mean_and_standard_error(&other_values);
773 if other_mean
774 > (total_mean * BENCH_OTHER_RELATIVE_THRESHOLD).max(BENCH_OTHER_ABSOLUTE_THRESHOLD_SECONDS)
775 {
776 rows.push(BenchRow {
777 category: "other/overhead",
778 timings: other_values,
779 });
780 }
781
782 rows.push(BenchRow {
783 category: "evaluators",
784 timings: batch_timings
785 .iter()
786 .map(|timing| timing.evaluator)
787 .collect(),
788 });
789 rows
790}
791
792fn render_bench_table(batch_timings: &[BenchBatchTiming]) -> String {
793 let rows = bench_rows(batch_timings);
794 let total_values = batch_timings
795 .iter()
796 .map(|timing| timing.total)
797 .collect::<Vec<_>>();
798 let (total_mean, _) = mean_and_standard_error(&total_values);
799
800 let total_row = BenchRow {
801 category: "Total",
802 timings: total_values,
803 };
804 let total_record_index = rows.len() + 1;
805
806 let mut builder = Builder::new();
807 builder.push_record([
808 "category".bold().cyan().to_string(),
809 "time / sample".bold().cyan().to_string(),
810 "% total".bold().cyan().to_string(),
811 ]);
812 for row in &rows {
813 let (mean, standard_error) = row.mean_and_standard_error();
814 builder.push_record([
815 row.category.cyan().to_string(),
816 format_timing_with_uncertainty(mean, standard_error)
817 .yellow()
818 .to_string(),
819 format_percentage_cell(mean, total_mean).blue().to_string(),
820 ]);
821 }
822
823 let (total_mean, total_standard_error) = total_row.mean_and_standard_error();
824 builder.push_record([
825 total_row.category.bold().green().to_string(),
826 format_timing_with_uncertainty(total_mean, total_standard_error)
827 .bold()
828 .green()
829 .to_string(),
830 "100.0%".bold().green().to_string(),
831 ]);
832
833 let mut table = builder.build();
834 table.with(
835 Style::rounded().horizontals([
836 (
837 1,
838 HorizontalLine::new('─')
839 .intersection('┼')
840 .left('├')
841 .right('┤'),
842 ),
843 (
844 total_record_index,
845 HorizontalLine::new('─')
846 .intersection('┼')
847 .left('├')
848 .right('┤'),
849 ),
850 ]),
851 );
852 table.with(Modify::new(Rows::first()).with(Alignment::center()));
853 table.with(Modify::new(Rows::new(1..).intersect(Columns::one(0))).with(Alignment::left()));
854 table.with(Modify::new(Rows::new(1..).intersect(Columns::new(1..))).with(Alignment::right()));
855 table.to_string()
856}
857
858fn mean_and_standard_error(values: &[f64]) -> (f64, f64) {
859 if values.is_empty() {
860 return (0.0, 0.0);
861 }
862 let mean = values.iter().sum::<f64>() / values.len() as f64;
863 if values.len() < 2 {
864 return (mean, 0.0);
865 }
866 let variance = values
867 .iter()
868 .map(|value| (value - mean).powi(2))
869 .sum::<f64>()
870 / (values.len() - 1) as f64;
871 (mean, variance.sqrt() / (values.len() as f64).sqrt())
872}
873
874fn format_percentage_cell(value: f64, total: f64) -> String {
875 if total <= 0.0 {
876 return "-".to_string();
877 }
878 format!("{:.1}%", value / total * 100.0)
879}
880
881fn format_duration_smart(duration: Duration) -> String {
882 let (scaled, unit) = scale_seconds_for_display(duration.as_secs_f64());
883 format!("{} {unit}", format_significant(scaled, 3))
884}
885
886fn format_timing_with_uncertainty(mean_seconds: f64, standard_error_seconds: f64) -> String {
887 let (scaled_mean, unit) = scale_seconds_for_display(mean_seconds);
888 let scale = unit_scale(unit);
889 let scaled_error = standard_error_seconds * scale;
890 format!(
891 "{}±{} {unit}",
892 format_significant(scaled_mean, 3),
893 format_significant(scaled_error, 2)
894 )
895}
896
897fn scale_seconds_for_display(seconds: f64) -> (f64, &'static str) {
898 let abs_seconds = seconds.abs();
899 if abs_seconds < 1.0e-6 {
900 (seconds * 1.0e9, "ns")
901 } else if abs_seconds < 1.0e-3 {
902 (seconds * 1.0e6, "µs")
903 } else if abs_seconds < 1.0 {
904 (seconds * 1.0e3, "ms")
905 } else {
906 (seconds, "s")
907 }
908}
909
910fn unit_scale(unit: &str) -> f64 {
911 match unit {
912 "ns" => 1.0e9,
913 "µs" => 1.0e6,
914 "ms" => 1.0e3,
915 "s" => 1.0,
916 _ => 1.0,
917 }
918}
919
920fn format_significant(value: f64, significant_digits: usize) -> String {
921 if !value.is_finite() {
922 return value.to_string();
923 }
924 if value == 0.0 {
925 return format!("{:.*}", significant_digits.saturating_sub(1), 0.0);
926 }
927 let decimals = (significant_digits as i32 - 1 - value.abs().log10().floor() as i32).max(0);
928 format!("{:.*}", decimals as usize, value)
929}
930
931#[cfg(test)]
932mod tests {
933 use std::time::Duration;
934
935 use gammalooprs::{
936 integrands::evaluation::{
937 BatchSampleEvaluationResult, EvaluationResult, SampleEvaluationResult,
938 },
939 observables::ObservableSnapshotBundle,
940 };
941
942 use super::{
943 bench_batch_timing, format_timing_with_uncertainty, parse_bench_target_duration,
944 split_samples_into_batches,
945 };
946
947 #[test]
948 fn bench_duration_parser_accepts_common_units() {
949 assert_eq!(
950 parse_bench_target_duration("5").unwrap(),
951 Duration::from_secs(5)
952 );
953 assert_eq!(
954 parse_bench_target_duration("250ms").unwrap(),
955 Duration::from_millis(250)
956 );
957 assert_eq!(
958 parse_bench_target_duration("10 us").unwrap(),
959 Duration::from_micros(10)
960 );
961 assert!(parse_bench_target_duration("1e300").is_err());
962 }
963
964 #[test]
965 fn bench_batches_cover_all_samples() {
966 let batches = split_samples_into_batches(23, 10);
967 assert_eq!(batches.len(), 10);
968 assert_eq!(batches.iter().sum::<usize>(), 23);
969 assert!(batches.iter().all(|size| (2..=3).contains(size)));
970 }
971
972 #[test]
973 fn bench_timing_formatter_uses_shared_unit() {
974 assert_eq!(
975 format_timing_with_uncertainty(1.234e-6, 4.5e-8),
976 "1.23±0.045 µs"
977 );
978 }
979
980 #[test]
981 fn bench_table_includes_total_row_after_separator() {
982 let table = super::render_bench_table(&[
983 super::BenchBatchTiming {
984 parameterization: 1.0e-7,
985 canonical_sampling: 0.0,
986 integrand: 4.0e-4,
987 canonical_physical: 0.0,
988 event_processing: 0.0,
989 evaluator: 1.7e-2,
990 other: 1.0e-5,
991 total: 1.74101e-2,
992 },
993 super::BenchBatchTiming {
994 parameterization: 1.2e-7,
995 canonical_sampling: 0.0,
996 integrand: 4.2e-4,
997 canonical_physical: 0.0,
998 event_processing: 0.0,
999 evaluator: 1.8e-2,
1000 other: 1.0e-5,
1001 total: 1.843012e-2,
1002 },
1003 ]);
1004
1005 assert!(table.contains("│ Total"));
1006 assert!(table.contains("100.0%"));
1007 assert!(table.lines().any(|line| line.starts_with('├')));
1008 assert!(table
1009 .lines()
1010 .last()
1011 .is_some_and(|line| line.starts_with('╰')));
1012 }
1013
1014 #[test]
1015 fn bench_timing_uses_wrapper_total_and_metadata_components() {
1016 let mut batch = BatchSampleEvaluationResult {
1017 samples: vec![
1018 sample_with_metadata(
1019 Duration::from_micros(10),
1020 Duration::from_micros(1),
1021 Duration::from_micros(5),
1022 Duration::from_micros(3),
1023 Duration::from_micros(1),
1024 ),
1025 sample_with_metadata(
1026 Duration::from_micros(14),
1027 Duration::from_micros(2),
1028 Duration::from_micros(7),
1029 Duration::from_micros(4),
1030 Duration::from_micros(2),
1031 ),
1032 ],
1033 observables: ObservableSnapshotBundle::default(),
1034 numerical_stability: None,
1035 };
1036
1037 for sample in &mut batch.samples {
1040 let metadata = sample.evaluation.evaluation_metadata.as_mut().unwrap();
1041 metadata.canonical_sampling_preparation_time = Duration::from_nanos(500);
1042 metadata.canonical_physical_preparation_time = Duration::from_nanos(250);
1043 }
1044 let timing = bench_batch_timing(Duration::from_micros(30), &batch).unwrap();
1045
1046 assert_close(timing.total, 15.0e-6);
1047 assert_close(timing.parameterization, 1.0e-6);
1048 assert_close(timing.canonical_sampling, 0.5e-6);
1049 assert_close(timing.integrand, 0.75e-6);
1050 assert_close(timing.canonical_physical, 0.25e-6);
1051 assert_close(timing.event_processing, 1.5e-6);
1052 assert_close(timing.evaluator, 3.5e-6);
1053 assert_close(timing.other, 7.5e-6);
1054 assert_close(
1055 timing.parameterization
1056 + timing.canonical_sampling
1057 + timing.integrand
1058 + timing.canonical_physical
1059 + timing.event_processing
1060 + timing.evaluator
1061 + timing.other,
1062 timing.total,
1063 );
1064 let metadata = batch.samples[0]
1065 .evaluation
1066 .evaluation_metadata
1067 .as_mut()
1068 .unwrap();
1069 metadata.canonical_sampling_preparation_time =
1070 metadata.parameterization_time + Duration::from_nanos(1);
1071 assert!(bench_batch_timing(Duration::from_micros(30), &batch).is_err());
1072 let metadata = batch.samples[0]
1073 .evaluation
1074 .evaluation_metadata
1075 .as_mut()
1076 .unwrap();
1077 metadata.canonical_sampling_preparation_time = Duration::ZERO;
1078 metadata.canonical_physical_preparation_time = metadata.integrand_evaluation_time;
1079 assert!(bench_batch_timing(Duration::from_micros(30), &batch).is_err());
1080 }
1081
1082 fn sample_with_metadata(
1083 total: Duration,
1084 parameterization: Duration,
1085 integrand: Duration,
1086 evaluator: Duration,
1087 event_processing: Duration,
1088 ) -> SampleEvaluationResult {
1089 let mut evaluation = EvaluationResult::zero();
1090 evaluation.evaluation_metadata.total_timing = total;
1091 evaluation.evaluation_metadata.parameterization_time = parameterization;
1092 evaluation.evaluation_metadata.integrand_evaluation_time = integrand;
1093 evaluation.evaluation_metadata.evaluator_evaluation_time = evaluator;
1094 evaluation.evaluation_metadata.event_processing_time = event_processing;
1095 SampleEvaluationResult {
1096 evaluation: evaluation.into_output(false),
1097 }
1098 }
1099
1100 fn assert_close(actual: f64, expected: f64) {
1101 assert!(
1102 (actual - expected).abs() <= 1.0e-12,
1103 "expected {expected:e}, got {actual:e}"
1104 );
1105 }
1106
1107 #[test]
1108 fn minimal_integrand_settings_disable_benchmark_overhead_without_touching_subtraction() {
1109 use gammalooprs::settings::runtime::{RotationSetting, StabilityRecordingSettings};
1110
1111 use super::{apply_minimal_integrand_settings, RuntimeSettings, StabilityLevelSetting};
1112
1113 let mut settings = RuntimeSettings::default();
1114 settings.general.enable_cache = true;
1115 settings.general.debug_cache = true;
1116 settings.general.generate_events = true;
1117 settings.general.store_additional_weights_in_event = true;
1118 settings.stability.rotation_axis = vec![RotationSetting::Pi2X {}, RotationSetting::Pi2Y {}];
1119 settings.stability.levels = vec![
1120 StabilityLevelSetting::default_double(),
1121 StabilityLevelSetting::default_quad(),
1122 StabilityLevelSetting::default_arb(),
1123 ];
1124 settings.stability.check_on_norm = true;
1125 settings.stability.escalate_if_exact_zero = true;
1126 settings.stability.loop_momenta_norm_escalation_factor = 2.0;
1127 settings.stability.recording = Some(StabilityRecordingSettings {
1128 record_rotated_results: true,
1129 record_all_stability_levels: true,
1130 record_loop_momenta_escalation: true,
1131 });
1132 let original_subtraction = settings.subtraction.clone();
1133
1134 apply_minimal_integrand_settings(&mut settings);
1135
1136 assert!(!settings.general.enable_cache);
1137 assert!(!settings.general.debug_cache);
1138 assert!(!settings.general.generate_events);
1139 assert!(!settings.general.store_additional_weights_in_event);
1140 assert!(settings.observables.is_empty());
1141 assert!(settings.selectors.values().all(|selector| !selector.active));
1142 assert!(settings.stability.rotation_axis.is_empty());
1143 assert_eq!(
1144 settings.stability.levels,
1145 vec![StabilityLevelSetting::default_double()]
1146 );
1147 assert!(!settings.stability.check_on_norm);
1148 assert!(!settings.stability.escalate_if_exact_zero);
1149 assert_eq!(settings.stability.loop_momenta_norm_escalation_factor, -1.0);
1150 assert_eq!(settings.stability.recording, None);
1151 assert_eq!(settings.subtraction, original_subtraction);
1152 }
1153}