Skip to main content

gammaloop_api/commands/
bench.rs

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    /// Process reference: `#<id>`, `name:<name>`, or `<id>/<name>`
47    #[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    /// The integrand name to benchmark
56    #[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    /// Fixed x-space or momentum-space point to benchmark
65    #[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    /// Target benchmark duration
77    #[arg(long, value_name = "DURATION")]
78    pub duration: String,
79
80    /// Evaluate in arbitrary precision
81    #[arg(short = 'f', long = "use-arb-prec")]
82    pub use_arb_prec: bool,
83
84    /// Interpret the point as momentum-space coordinates
85    #[arg(short = 'm', long)]
86    pub momentum_space: bool,
87
88    /// Discrete dimensions of the fixed sample
89    #[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    /// Select a specific graph in momentum-space evaluation
100    #[arg(long = "graph-id", value_name = "GRAPH_ID")]
101    pub graph_id: Option<usize>,
102
103    /// Select a specific orientation of the selected graph
104    #[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    /// Number of batches used to estimate uncertainty
113    #[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    /// Temporarily benchmark only the raw integrand path by disabling stability rotations,
118    /// caches, generated events, additional event weights, selectors, and observables.
119    #[arg(long = "minimal-integrand")]
120    #[serde(default)]
121    pub minimal_integrand: bool,
122
123    /// Write the benchmark result as pretty JSON to this path
124    #[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            // `run` disables event generation once for the whole benchmark.
266            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                // Resolve the model and warm up once, outside every timed region,
449                // with event generation disabled for the whole benchmark.
450                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    // integrand_evaluation_time includes evaluator, event and canonical physical
627    // preparation work. Keep displayed rows disjoint by subtracting those subsets.
628    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    // Keep Total as the wall-clock time around evaluate_samples. The metadata
635    // rows below are subtracted from that wrapper timing so other/overhead
636    // captures unclassified evaluation work plus evaluate_samples overhead.
637    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        // These nested counters must be displayed once, without changing the
1038        // inclusive sampling/physical totals or the integration checkpoint ABI.
1039        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}