tiny_bench/
output.rs

1pub(crate) mod analysis;
2pub(crate) mod disk;
3pub(crate) mod ser;
4
5#[cfg(feature = "bench")]
6use crate::benching::SamplingData;
7#[cfg(feature = "bench")]
8use crate::output::analysis::criterion::{
9    calculate_p_value, calculate_t_value, resample, BenchmarkConfig, SamplingDataSimpleAnalysis,
10};
11#[cfg(feature = "bench")]
12use crate::output::analysis::sample_data::simple_analyze_sampling_data;
13#[cfg(feature = "timer")]
14use crate::timing::TimingData;
15
16/// Percentage increase which is deemed to be big enough to matter.
17/// Only used for highlighting output
18#[cfg(feature = "timer")]
19const TIMING_NOISE_THRESHOLD: f64 = 5.0;
20
21/// Percentage increase which is deemed to be big enough to matter.
22/// Only used for highlighting output
23#[cfg(feature = "bench")]
24const NOISE_THRESHOLD: f64 = 1.0;
25
26/// p-value under which a result is deemed significant enough to matter.
27/// Only used for highlighting output
28#[cfg(feature = "bench")]
29const SIGNIFICANCE_LEVEL: f64 = 0.05;
30
31#[cfg(feature = "timer")]
32pub(crate) struct LabeledOutput<Output> {
33    label: &'static str,
34    out: Output,
35}
36
37#[cfg(feature = "timer")]
38impl<O> LabeledOutput<O> {
39    pub(crate) fn new(label: &'static str, out: O) -> Self {
40        Self { label, out }
41    }
42}
43
44#[cfg(feature = "timer")]
45impl<O> LabeledOutput<O>
46where
47    O: Output,
48{
49    pub(crate) fn dump(&self, data: TimingData) {
50        self.out.dump_timing_data(self.label, data);
51    }
52}
53
54pub(crate) trait Output {
55    #[cfg(feature = "timer")]
56    fn dump_timing_data(&self, label: &'static str, data: TimingData);
57
58    #[cfg(feature = "bench")]
59    fn dump_sampling_data(
60        &self,
61        label: &'static str,
62        sampling_data: &SamplingData,
63        cfg: &BenchmarkConfig,
64        total_iters: u128,
65    );
66}
67
68/// Just prints the results straight to stdout
69pub struct SimpleStdout;
70
71impl Output for SimpleStdout {
72    #[cfg(feature = "timer")]
73    fn dump_timing_data(&self, label: &'static str, data: TimingData) {
74        print_timer_header(label, &data);
75        let mean = data.elapsed as f64 / data.iterations as f64;
76        timer_print_elapsed(data.min_nanos as f64, mean, data.max_nanos as f64);
77    }
78
79    #[cfg(feature = "bench")]
80    fn dump_sampling_data(
81        &self,
82        label: &'static str,
83        sampling_data: &SamplingData,
84        cfg: &BenchmarkConfig,
85        total_iters: u128,
86    ) {
87        let analysis = simple_analyze_sampling_data(sampling_data);
88        print_sample_header(label, total_iters, analysis.elapsed, cfg.num_samples as u64);
89        print_analysis(&analysis);
90    }
91}
92
93/// Checks if there has previously been any results dumped to target and compares with those
94pub struct ComparedStdout;
95
96impl Output for ComparedStdout {
97    #[cfg(feature = "timer")]
98    fn dump_timing_data(&self, label: &'static str, data: TimingData) {
99        let mean = data.elapsed as f64 / data.iterations as f64;
100        let maybe_old = disk::try_read_last_results(label);
101        print_timer_header(label, &data);
102        timer_print_elapsed(data.min_nanos as f64, mean, data.max_nanos as f64);
103        match maybe_old {
104            Ok(Some(old)) => {
105                let min_change = (data.min_nanos as f64 / old.min_nanos as f64 - 1f64) * 100f64;
106                let max_change = (data.max_nanos as f64 / old.max_nanos as f64 - 1f64) * 100f64;
107                let mean_change =
108                    (mean / (old.elapsed as f64 / old.iterations as f64) - 1f64) * 100f64;
109                let mean_comparison = if mean_change >= TIMING_NOISE_THRESHOLD {
110                    MeanComparison::new(mean_change, Comparison::Better)
111                } else if mean_change <= -TIMING_NOISE_THRESHOLD {
112                    MeanComparison::new(mean_change, Comparison::Worse)
113                } else {
114                    MeanComparison::new(mean_change, Comparison::Same)
115                };
116                print_cmp(
117                    min_change,
118                    &mean_comparison,
119                    max_change,
120                    "p=? single sample",
121                );
122            }
123            Err(e) => {
124                println!(
125                    "{}, cause {e}",
126                    wrap_high_insensity_red("Failed to read last results")
127                );
128            }
129            _ => {}
130        }
131        disk::try_write_results(label, data);
132    }
133
134    #[cfg(feature = "bench")]
135    fn dump_sampling_data(
136        &self,
137        label: &'static str,
138        sampling_data: &SamplingData,
139        cfg: &BenchmarkConfig,
140        total_iters: u128,
141    ) {
142        let analysis = simple_analyze_sampling_data(sampling_data);
143        print_sample_header(label, total_iters, analysis.elapsed, cfg.num_samples as u64);
144        print_analysis(&analysis);
145        match disk::try_read_last_simpling(label) {
146            Ok(Some(last)) => {
147                let old_analysis = simple_analyze_sampling_data(&last);
148                let min_change = (analysis.min / old_analysis.min - 1f64) * 100f64;
149                let max_change = (analysis.max / old_analysis.max - 1f64) * 100f64;
150                let mean_change = (analysis.average / old_analysis.average - 1f64) * 100f64;
151                let t = calculate_t_value(
152                    &analysis.per_sample_average,
153                    &old_analysis.per_sample_average,
154                );
155                let t_distribution = resample(
156                    &analysis.per_sample_average,
157                    &old_analysis.per_sample_average,
158                    cfg.num_resamples,
159                );
160                let p = calculate_p_value(t, &t_distribution);
161                let mean_change = if mean_change.abs() >= NOISE_THRESHOLD && p <= SIGNIFICANCE_LEVEL
162                {
163                    if mean_change > 0.0 {
164                        MeanComparison::new(mean_change, Comparison::Worse)
165                    } else if mean_change < 0.0 {
166                        MeanComparison::new(mean_change, Comparison::Better)
167                    } else {
168                        MeanComparison::new(mean_change, Comparison::Same)
169                    }
170                } else {
171                    MeanComparison::new(mean_change, Comparison::Same)
172                };
173                print_cmp(min_change, &mean_change, max_change, &format!("p = {p:.2}"));
174            }
175            Err(e) => {
176                println!(
177                    "{}, cause {e}",
178                    wrap_high_insensity_red("Failed to read last sample")
179                );
180            }
181            _ => {}
182        }
183
184        disk::try_write_last_simpling(label, sampling_data);
185    }
186}
187
188#[cfg(feature = "timer")]
189pub(crate) fn print_timer_header(label: &'static str, data: &TimingData) {
190    println!(
191        "{} [{} iterations in {}]:",
192        wrap_bold_green(label),
193        fmt_num(data.iterations as f64),
194        fmt_time(data.elapsed as f64)
195    );
196}
197
198#[cfg(feature = "bench")]
199pub(crate) fn print_sample_header(
200    label: &'static str,
201    total_iterations: u128,
202    total_elapsed: u128,
203    num_samples: u64,
204) {
205    println!(
206        "{} [{} iterations in {} with {} samples]:",
207        wrap_bold_green(label),
208        fmt_num(total_iterations as f64),
209        fmt_time(total_elapsed as f64),
210        fmt_num(num_samples as f64)
211    );
212}
213
214#[cfg(feature = "bench")]
215pub(crate) fn print_analysis(analysis: &SamplingDataSimpleAnalysis) {
216    // Variance has the unit T-squared,
217    println!(
218        "\telapsed\t[{} {} {}]:\t[{} {} {}] (sample data: med = {}, var = {}², stddev = {})",
219        wrap_gray("min"),
220        wrap_high_intensity_white("mean"),
221        wrap_gray("max"),
222        wrap_gray(&fmt_time(analysis.min)),
223        wrap_high_intensity_white(&fmt_time(analysis.average)),
224        wrap_gray(&fmt_time(analysis.max)),
225        fmt_time(analysis.median),
226        fmt_time(analysis.variance),
227        fmt_time(analysis.stddev),
228    );
229}
230
231#[cfg(feature = "timer")]
232pub(crate) fn timer_print_elapsed(min: f64, mean: f64, max: f64) {
233    // Variance has the unit T-squared,
234    println!(
235        "\telapsed\t[{} {} {}]:\t[{} {} {}]",
236        wrap_gray("min"),
237        wrap_high_intensity_white("mean"),
238        wrap_gray("max"),
239        wrap_gray(&fmt_time(min)),
240        wrap_high_intensity_white(&fmt_time(mean)),
241        wrap_gray(&fmt_time(max)),
242    );
243}
244
245pub(crate) struct MeanComparison {
246    mean: f64,
247    comparison: Comparison,
248}
249
250impl MeanComparison {
251    pub(crate) fn new(mean: f64, comparison: Comparison) -> Self {
252        Self { mean, comparison }
253    }
254
255    pub(crate) fn format(&self) -> String {
256        match self.comparison {
257            Comparison::Worse => wrap_high_insensity_red(&fmt_change(self.mean)),
258            Comparison::Same => wrap_high_intensity_white(&fmt_change(self.mean)),
259            Comparison::Better => wrap_high_intensity_green(&fmt_change(self.mean)),
260        }
261    }
262}
263
264pub enum Comparison {
265    Worse,
266    Same,
267    Better,
268}
269
270pub(crate) fn print_cmp(min: f64, mean: &MeanComparison, max: f64, reliability_comment: &str) {
271    println!(
272        "\tchange\t[{} {} {}]:\t[{} {} {}] ({reliability_comment})",
273        wrap_gray("min"),
274        wrap_high_intensity_white("mean"),
275        wrap_gray("max"),
276        wrap_gray(&fmt_change(min)),
277        mean.format(),
278        wrap_gray(&fmt_change(max)),
279    );
280}
281
282const NANO_LIMIT: f64 = 1000f64;
283const MICRO_LIMIT: f64 = NANO_LIMIT * 1000f64;
284const MILLI_LIMIT: f64 = MICRO_LIMIT * 1000f64;
285
286pub(crate) fn wrap_bold_green(text: &str) -> String {
287    format!("\x1b[1;32m{text}\x1b[0m")
288}
289
290pub(crate) fn wrap_high_intensity_green(text: &str) -> String {
291    format!("\x1b[0;92m{text}\x1b[0m")
292}
293
294pub(crate) fn wrap_yellow(text: &str) -> String {
295    format!("\x1b[0;93m{text}\x1b[0m")
296}
297
298pub(crate) fn wrap_high_insensity_red(text: &str) -> String {
299    format!("\x1b[0;91m{text}\x1b[0m")
300}
301
302pub(crate) fn wrap_gray(text: &str) -> String {
303    format!("\x1b[0;37m{text}\x1b[0m")
304}
305
306pub(crate) fn wrap_high_intensity_white(text: &str) -> String {
307    format!("\x1b[0;97m{text}\x1b[0m")
308}
309
310pub(crate) fn fmt_time(time: f64) -> String {
311    // Nanos
312    if time < NANO_LIMIT {
313        format!("{time:.2}ns")
314    } else if time < MICRO_LIMIT {
315        format!("{:.2}µs", time / NANO_LIMIT)
316    } else if time < MILLI_LIMIT {
317        format!("{:.2}ms", time / MICRO_LIMIT)
318    } else {
319        format!("{:.2}s", time / MILLI_LIMIT)
320    }
321}
322
323fn fmt_change(change: f64) -> String {
324    format!("{change:.4}%")
325}
326
327pub(crate) fn fmt_num(num: f64) -> String {
328    if num < NANO_LIMIT {
329        format!("{num:.1}")
330    } else if num < MICRO_LIMIT {
331        format!("{:.1} thousand", num / NANO_LIMIT)
332    } else if num < MILLI_LIMIT {
333        format!("{:.1}M", num / MICRO_LIMIT)
334    } else {
335        format!("{:.1}B", num / MILLI_LIMIT)
336    }
337}
338
339/// Some illegal filename symbols, not meant to be exhaustive but good enough
340const ILLEGAL: [char; 10] = [
341    // Linux
342    '/', '\0', // Windows
343    ':', '<', '>', '"', '\\', '|', '?', '*',
344];
345
346#[cfg_attr(test, derive(Eq, PartialEq, Debug))]
347pub(crate) enum LabelValidationResult {
348    Valid,
349    Invalid(&'static str),
350}
351
352pub(crate) fn fallback_to_anonymous_on_invalid_label(label: &'static str) -> &'static str {
353    if let LabelValidationResult::Invalid(reason) = validate_label(label) {
354        println!(
355            "{} falling back to 'anonymous'.",
356            wrap_high_insensity_red(reason)
357        );
358        "anonymous"
359    } else {
360        label
361    }
362}
363
364fn validate_label(label: &'static str) -> LabelValidationResult {
365    for ch in ILLEGAL {
366        if label.contains(ch) {
367            return LabelValidationResult::Invalid("Label contains illegal character {ch}");
368        }
369    }
370    for ch in 0..32 {
371        let ascii_ctrl = char::from(ch);
372        if label.contains(ascii_ctrl) {
373            return LabelValidationResult::Invalid(
374                "Label contains illegal ascii-control character number {ch}",
375            );
376        }
377    }
378    if label.ends_with('.') {
379        return LabelValidationResult::Invalid("Label cannot end with dot");
380    }
381    if label.ends_with(' ') {
382        return LabelValidationResult::Invalid("Label cannot end with a space");
383    }
384    LabelValidationResult::Valid
385}
386
387#[cfg(test)]
388mod tests {
389    use crate::output::{fmt_change, fmt_num, fmt_time, validate_label, LabelValidationResult};
390
391    #[test]
392    fn validates_label() {
393        assert_eq!(LabelValidationResult::Valid, validate_label("Hello!"));
394        assert_eq!(
395            LabelValidationResult::Valid,
396            validate_label("Some,weird_name_but.okay.png")
397        );
398        assert!(matches!(
399            validate_label("."),
400            LabelValidationResult::Invalid(_)
401        ));
402        assert!(matches!(
403            validate_label("hello!."),
404            LabelValidationResult::Invalid(_)
405        ));
406        assert!(matches!(
407            validate_label("hello! "),
408            LabelValidationResult::Invalid(_)
409        ));
410        assert!(matches!(
411            validate_label("bad/label"),
412            LabelValidationResult::Invalid(_)
413        ));
414        assert!(matches!(
415            validate_label("bad:label"),
416            LabelValidationResult::Invalid(_)
417        ));
418        assert!(matches!(
419            validate_label("bad>label"),
420            LabelValidationResult::Invalid(_)
421        ));
422        assert!(matches!(
423            validate_label("bad<label"),
424            LabelValidationResult::Invalid(_)
425        ));
426        assert!(matches!(
427            validate_label("bad\0label"),
428            LabelValidationResult::Invalid(_)
429        ));
430        assert!(matches!(
431            validate_label("bad\\label"),
432            LabelValidationResult::Invalid(_)
433        ));
434        assert!(matches!(
435            validate_label("bad\"label"),
436            LabelValidationResult::Invalid(_)
437        ));
438        assert!(matches!(
439            validate_label("bad|label"),
440            LabelValidationResult::Invalid(_)
441        ));
442        assert!(matches!(
443            validate_label("bad?label"),
444            LabelValidationResult::Invalid(_)
445        ));
446        assert!(matches!(
447            validate_label("bad*label"),
448            LabelValidationResult::Invalid(_)
449        ));
450    }
451
452    #[test]
453    fn formats_time() {
454        assert_eq!("5.15ns", &fmt_time(5.15));
455        assert_eq!("1.50µs", &fmt_time(1500.0));
456        assert_eq!("3.33ms", &fmt_time(3_330_000.0));
457        assert_eq!("5.79s", &fmt_time(5_790_000_000.0));
458        assert_eq!("68.00s", &fmt_time(68_000_000_000.0));
459    }
460
461    #[test]
462    fn formats_number() {
463        assert_eq!("5.1", &fmt_num(5.1));
464        assert_eq!("35.0 thousand", &fmt_num(35_000.0));
465        assert_eq!("97.0M", &fmt_num(97_000_000.0));
466        assert_eq!("7.9B", &fmt_num(7_900_000_000.0));
467    }
468
469    #[test]
470    fn formats_change() {
471        assert_eq!("5.1973%", &fmt_change(5.1973));
472    }
473}