brunch/bench.rs
1/*!
2# Brunch: Bench
3*/
4
5use crate::{
6 BrunchError,
7 History,
8 MIN_SAMPLES,
9 Stats,
10 util,
11};
12use dactyl::{
13 NiceU32,
14 traits::SaturatingFrom,
15};
16use std::{
17 fmt,
18 hint::black_box,
19 num::NonZeroU32,
20 time::{
21 Duration,
22 Instant,
23 },
24};
25
26
27
28/// # Default Sample Count.
29const DEFAULT_SAMPLES: NonZeroU32 = NonZeroU32::new(2500).unwrap();
30
31/// # Default Timeout.
32const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
33
34/// # Markup for No Change "Value".
35const NO_CHANGE: &str = "\x1b[2m---\x1b[0m";
36
37
38
39#[derive(Debug, Default)]
40/// # Benchmarks.
41///
42/// This holds a collection of benchmarks. You don't need to interact with this
43/// directly when using the [`benches`](crate::benches) macro, but can if you
44/// want complete control over the whole process.
45///
46/// ## Examples
47///
48/// ```no_run
49/// use brunch::{Bench, Benches};
50/// use std::time::Duration;
51///
52/// fn main() {
53/// // You can do set up, etc., here.
54/// eprintln!("Starting benchmarks!");
55///
56/// // Start a Benches instance.
57/// let mut benches = Benches::default();
58///
59/// // Each Bench needs to be pushed one at a time.
60/// benches.push(
61/// Bench::new("2_usize.checked_add(2)")
62/// .run(|| 2_usize.checked_add(2))
63/// );
64///
65/// // Maybe you want to pause between each benchmark to let the CPU cool?
66/// std::thread::sleep(Duration::from_secs(3));
67///
68/// // Add another Bench.
69/// benches.push(
70/// Bench::new("200_usize.checked_mul(3)")
71/// .run(|| 200_usize.checked_mul(3))
72/// );
73///
74/// // After the last Bench has been added, call `finish` to crunch the
75/// // stats and print a summary.
76/// benches.finish();
77///
78/// // You can do other stuff afterward if you want.
79/// eprintln!("Done!");
80/// }
81/// ```
82pub struct Benches(Vec<Bench>);
83
84impl Extend<Bench> for Benches {
85 /// # Extend.
86 ///
87 /// Insert [`Bench`]es en-masse.
88 ///
89 /// ## Examples
90 ///
91 /// ```no_run
92 /// use brunch::{Benches, Bench};
93 ///
94 /// let mut benches = Benches::default();
95 /// benches.extend([
96 /// Bench::new("String::len").run(|| "Hello World".len()),
97 /// Bench::spacer(),
98 /// ]);
99 /// benches.finish();
100 /// ```
101 fn extend<T: IntoIterator<Item=Bench>>(&mut self, iter: T) {
102 for b in iter { self.push(b); }
103 }
104}
105
106impl Benches {
107 /// # Add Benchmark.
108 ///
109 /// Use this method to push a benchmark to your `Benches` collection. Each
110 /// benchmark should be pushed before running [`Benches::finish`].
111 ///
112 /// ## Examples
113 ///
114 /// ```no_run
115 /// use brunch::{Benches, Bench};
116 ///
117 /// let mut benches = Benches::default();
118 /// benches.push(Bench::new("String::len").run(|| "Hello World".len()));
119 /// // Repeat push as needed.
120 /// benches.finish();
121 /// ```
122 pub fn push(&mut self, mut b: Bench) {
123 if ! b.is_spacer() && self.has_name(&b.name) {
124 b.stats.replace(Err(BrunchError::DupeName));
125 }
126
127 self.0.push(b);
128 }
129
130 /// # Finish.
131 ///
132 /// Crunch and print the data!
133 ///
134 /// This method should only be called after all benchmarks have been pushed
135 /// to the set.
136 ///
137 /// ## Examples
138 ///
139 /// ```no_run
140 /// use brunch::{Benches, Bench};
141 ///
142 /// let mut benches = Benches::default();
143 /// benches.push(Bench::new("String::len").run(|| "Hello World".len()));
144 /// benches.finish();
145 /// ```
146 pub fn finish(&self) {
147 // If there weren't any benchmarks, just print an error.
148 if self.0.is_empty() {
149 eprintln!(
150 "\x1b[1;91mError:\x1b[0m {}",
151 BrunchError::NoBench
152 );
153 return;
154 }
155
156 // Build the summaries.
157 let mut history = History::default();
158 let mut summary = Table::default();
159 let names: Vec<Vec<char>> = self.0.iter()
160 .filter_map(|b|
161 if b.is_spacer() { None }
162 else { Some(b.name.chars().collect()) }
163 )
164 .collect();
165 for b in &self.0 {
166 summary.push(b, &names, &history);
167 }
168
169 // Update the history.
170 self.finish_history(&mut history);
171
172 eprintln!("{summary}");
173 }
174
175 /// # Finish: Update History.
176 fn finish_history(&self, history: &mut History) {
177 // Copy over the values.
178 for b in &self.0 {
179 if let Some(Ok(s)) = b.stats {
180 history.insert(&b.name, s);
181 }
182 }
183
184 // Save it.
185 history.save();
186 }
187}
188
189impl Benches {
190 /// # Has Name.
191 fn has_name(&self, name: &str) -> bool {
192 self.0.iter().any(|b| b.name == name)
193 }
194}
195
196
197
198#[derive(Debug)]
199/// # Benchmark.
200///
201/// This struct holds a single "bench" you wish to run. See the main crate
202/// documentation for more information.
203pub struct Bench {
204 /// # Benchmark Name.
205 name: String,
206
207 /// # Sample Limit.
208 samples: NonZeroU32,
209
210 /// # Timeout Limit.
211 timeout: Duration,
212
213 /// # Collected Stats.
214 stats: Option<Result<Stats, BrunchError>>,
215}
216
217impl Bench {
218 #[must_use]
219 /// # New.
220 ///
221 /// Instantiate a new benchmark with a name. The name can be anything, but
222 /// is intended to represent the method call itself, like `foo::bar(10)`.
223 ///
224 /// Note: the names should be unique across all benchmarks, as they serve
225 /// as the key used when pulling "history". If you have two totally
226 /// different benchmarks named the same thing, the run-to-run change
227 /// reporting won't make any sense. ;)
228 ///
229 /// ## Examples
230 ///
231 /// ```no_run
232 /// use brunch::Bench;
233 /// use dactyl::{NiceU8, NiceU16};
234 ///
235 /// brunch::benches!(
236 /// Bench::new("dactyl::NiceU8::from(0)")
237 /// .run(|| NiceU8::from(0_u8)),
238 /// );
239 /// ```
240 ///
241 /// ## Panics
242 ///
243 /// This method will panic if the name is empty.
244 pub fn new<S>(name: S) -> Self
245 where S: AsRef<str> {
246 let name = name.as_ref().trim();
247 assert!(! name.is_empty(), "Name is required.");
248
249 // Compact and normalize whitespace, but otherwise pass whatever the
250 // name is on through.
251 let mut ws = false;
252 let name: String = name.chars()
253 .filter_map(|c|
254 if c.is_whitespace() {
255 if ws { None }
256 else {
257 ws = true;
258 Some(' ')
259 }
260 }
261 else {
262 ws = false;
263 Some(c)
264 }
265 )
266 .collect();
267
268 assert!(name.len() <= 65535, "Names cannot be longer than 65,535.");
269
270 Self {
271 name,
272 samples: DEFAULT_SAMPLES,
273 timeout: DEFAULT_TIMEOUT,
274 stats: None,
275 }
276 }
277
278 #[must_use]
279 /// # Spacer.
280 ///
281 /// This will render as a linebreak when printing results, useful if you
282 /// want to add visual separation between two different benchmarks.
283 ///
284 /// ## Examples
285 ///
286 /// ```no_run
287 /// use brunch::Bench;
288 /// use dactyl::{NiceU8, NiceU16};
289 ///
290 /// brunch::benches!(
291 /// Bench::new("dactyl::NiceU8::from(0)")
292 /// .run(|| NiceU8::from(0_u8)),
293 ///
294 /// Bench::spacer(),
295 ///
296 /// Bench::new("dactyl::NiceU16::from(0)")
297 /// .run(|| NiceU16::from(0_u16)),
298 /// );
299 /// ```
300 pub const fn spacer() -> Self {
301 Self {
302 name: String::new(),
303 samples: DEFAULT_SAMPLES,
304 timeout: DEFAULT_TIMEOUT,
305 stats: None,
306 }
307 }
308
309 /// # Is Spacer?
310 const fn is_spacer(&self) -> bool { self.name.is_empty() }
311
312 #[must_use]
313 /// # With Time Limit.
314 ///
315 /// By default, benches stop after reaching 2500 samples or 10 seconds,
316 /// whichever comes first.
317 ///
318 /// This method can be used to override the time limit portion of that
319 /// equation.
320 ///
321 /// Note: the minimum cutoff time is half a second.
322 ///
323 /// ## Examples
324 ///
325 /// ```no_run
326 /// use brunch::Bench;
327 /// use dactyl::NiceU8;
328 /// use std::time::Duration;
329 ///
330 /// brunch::benches!(
331 /// Bench::new("dactyl::NiceU8::from(0)")
332 /// .with_timeout(Duration::from_secs(1))
333 /// .run(|| NiceU8::from(0_u8))
334 /// );
335 /// ```
336 pub const fn with_timeout(mut self, timeout: Duration) -> Self {
337 if timeout.as_millis() < 500 {
338 self.timeout = Duration::from_millis(500);
339 }
340 else { self.timeout = timeout; }
341 self
342 }
343
344 #[expect(clippy::missing_panics_doc, reason = "Value is checked.")]
345 #[must_use]
346 /// # With Sample Limit.
347 ///
348 /// By default, benches stop after reaching 2500 samples or 10 seconds,
349 /// whichever comes first.
350 ///
351 /// This method can be used to override the sample limit portion of that
352 /// equation.
353 ///
354 /// Generally the default is a good sample size, but if your bench takes a
355 /// while to complete, you might want to use this method to shorten it up.
356 ///
357 /// Note: the minimum number of samples is 100, but you should aim for at
358 /// least 150-200, because that minimum is applied _after_ outliers have
359 /// been removed from the set.
360 ///
361 /// ## Examples
362 ///
363 /// ```no_run
364 /// use brunch::Bench;
365 /// use dactyl::NiceU8;
366 ///
367 /// brunch::benches!(
368 /// Bench::new("dactyl::NiceU8::from(0)")
369 /// .with_samples(50_000)
370 /// .run(|| NiceU8::from(0_u8))
371 /// );
372 /// ```
373 pub const fn with_samples(mut self, samples: u32) -> Self {
374 if samples < MIN_SAMPLES.get() { self.samples = MIN_SAMPLES; }
375 else {
376 // The compiler should optimize this out. MIN_SAMPLES is non-zero
377 // so samples must be too.
378 self.samples = NonZeroU32::new(samples).unwrap();
379 }
380 self
381 }
382}
383
384impl Bench {
385 #[must_use]
386 /// # Run Benchmark!
387 ///
388 /// Use this method to execute a benchmark for a callback that does not
389 /// require any external arguments.
390 ///
391 /// ## Examples
392 ///
393 /// ```no_run
394 /// use brunch::Bench;
395 /// use dactyl::NiceU8;
396 ///
397 /// brunch::benches!(
398 /// Bench::new("dactyl::NiceU8::from(0)")
399 /// .run(|| NiceU8::from(0_u8))
400 /// );
401 /// ```
402 pub fn run<F, O>(mut self, mut cb: F) -> Self
403 where F: FnMut() -> O {
404 if self.is_spacer() { return self; }
405
406 let mut times: Vec<Duration> = Vec::with_capacity(usize::saturating_from(self.samples.get()));
407 let now = Instant::now();
408
409 for _ in 0..self.samples.get() {
410 let now2 = Instant::now();
411 let _res = black_box(cb());
412 times.push(now2.elapsed());
413
414 if self.timeout <= now.elapsed() { break; }
415 }
416
417 self.stats.replace(Stats::try_from(times));
418
419 self
420 }
421
422 #[must_use]
423 /// # Run Seeded Benchmark!
424 ///
425 /// Use this method to execute a benchmark for a callback seeded with the
426 /// provided value.
427 ///
428 /// For seeds that don't implement `Clone`, use [`Bench::run_seeded_with`]
429 /// instead.
430 ///
431 /// ## Examples
432 ///
433 /// ```no_run
434 /// use brunch::Bench;
435 /// use dactyl::NiceU8;
436 ///
437 /// brunch::benches!(
438 /// Bench::new("dactyl::NiceU8::from(13)")
439 /// .run_seeded(13_u8, |v| NiceU8::from(v))
440 /// );
441 /// ```
442 pub fn run_seeded<F, I, O>(mut self, seed: I, mut cb: F) -> Self
443 where F: FnMut(I) -> O, I: Clone {
444 if self.is_spacer() { return self; }
445
446 let mut times: Vec<Duration> = Vec::with_capacity(usize::saturating_from(self.samples.get()));
447 let now = Instant::now();
448
449 for _ in 0..self.samples.get() {
450 let seed2 = seed.clone();
451 let now2 = Instant::now();
452 let _res = black_box(cb(seed2));
453 times.push(now2.elapsed());
454
455 if self.timeout <= now.elapsed() { break; }
456 }
457
458 self.stats.replace(Stats::try_from(times));
459
460 self
461 }
462
463 #[must_use]
464 /// # Run Callback-Seeded Benchmark!
465 ///
466 /// Use this method to execute a benchmark for a callback seeded with the
467 /// result of the provided method.
468 ///
469 /// For seeds that implement `Clone`, use [`Bench::run_seeded`] instead.
470 ///
471 /// ## Examples
472 ///
473 /// ```no_run
474 /// use brunch::Bench;
475 /// use dactyl::NiceU8;
476 ///
477 /// fn make_num() -> u8 { 13_u8 }
478 ///
479 /// brunch::benches!(
480 /// Bench::new("dactyl::NiceU8::from(13)")
481 /// .run_seeded_with(make_num, |v| NiceU8::from(v))
482 /// );
483 /// ```
484 pub fn run_seeded_with<F1, F2, I, O>(mut self, mut seed: F1, mut cb: F2) -> Self
485 where F1: FnMut() -> I, F2: FnMut(I) -> O {
486 if self.is_spacer() { return self; }
487
488 let mut times: Vec<Duration> = Vec::with_capacity(usize::saturating_from(self.samples.get()));
489 let now = Instant::now();
490
491 for _ in 0..self.samples.get() {
492 let seed2 = seed();
493 let now2 = Instant::now();
494 let _res = black_box(cb(seed2));
495 times.push(now2.elapsed());
496
497 if self.timeout <= now.elapsed() { break; }
498 }
499
500 self.stats.replace(Stats::try_from(times));
501
502 self
503 }
504}
505
506
507
508#[derive(Debug, Clone)]
509/// # Benchmarking Results.
510///
511/// This table holds the results of all the benchmarks so they can be printed
512/// consistently.
513struct Table(Vec<TableRow>);
514
515impl Default for Table {
516 fn default() -> Self {
517 Self(vec![
518 TableRow::Normal(
519 "\x1b[1;95mMethod".to_owned(),
520 "Mean".to_owned(),
521 "Samples\x1b[0m".to_owned(),
522 "\x1b[1;95mChange\x1b[0m".to_owned(),
523 ),
524 TableRow::Spacer,
525 ])
526 }
527}
528
529impl fmt::Display for Table {
530 #[expect(clippy::many_single_char_names, reason = "Consistency is preferred.")]
531 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
532 // Maximum column widths.
533 let (w1, w2, w3, mut w4) = self.lens();
534 let changes = self.show_changes();
535 let width =
536 if changes { w1 + w2 + w3 + w4 + 12 }
537 else {
538 w4 = 0;
539 w1 + w2 + w3 + 8
540 };
541
542 // Pre-generate padding as we'll be slicing lots of things to fit.
543 let pad_len = w1.max(w2).max(w3).max(w4);
544 let mut pad = String::with_capacity(pad_len);
545 for _ in 0..pad_len { pad.push(' '); }
546
547 // Pre-generate the spacer too.
548 let mut spacer = String::with_capacity(10 + width);
549 spacer.push_str("\x1b[35m");
550 for _ in 0..width { spacer.push('-'); }
551 spacer.push_str("\x1b[0m\n");
552
553 // Print each line!
554 for v in &self.0 {
555 let (c1, c2, c3, c4) = v.lens();
556 match v {
557 TableRow::Normal(a, b, c, d) if changes => writeln!(
558 f, "{}{} {}{} {}{} {}{}",
559 a, &pad[..w1 - c1],
560 &pad[..w2 - c2], b,
561 &pad[..w3 - c3], c,
562 &pad[..w4 - c4], d,
563 )?,
564 TableRow::Normal(a, b, c, _) => writeln!(
565 f, "{}{} {}{} {}{}",
566 a, &pad[..w1 - c1],
567 &pad[..w2 - c2], b,
568 &pad[..w3 - c3], c,
569 )?,
570 TableRow::Error(a, b) => writeln!(
571 f,
572 "{}{} \x1b[38;5;208m{}\x1b[0m",
573 a,
574 &pad[..w1 - c1],
575 b,
576 )?,
577 TableRow::Spacer => f.write_str(&spacer)?,
578 }
579 }
580
581 Ok(())
582 }
583}
584
585impl Table {
586 /// # Add Row.
587 fn push(&mut self, src: &Bench, names: &[Vec<char>], history: &History) {
588 if src.is_spacer() { self.0.push(TableRow::Spacer); }
589 else {
590 let name = format_name(src.name.chars().collect(), names);
591 match src.stats.unwrap_or(Err(BrunchError::NoRun)) {
592 Ok(s) => {
593 let time = s.nice_mean();
594 let diff = history.get(&src.name)
595 .and_then(|h| s.is_deviant(h))
596 .unwrap_or_else(|| NO_CHANGE.to_owned());
597 let (valid, total) = s.samples();
598 let samples = format!(
599 "\x1b[2m{}\x1b[0;35m/\x1b[0;2m{}\x1b[0m",
600 NiceU32::from(valid),
601 NiceU32::from(total),
602 );
603
604 self.0.push(TableRow::Normal(name, time, samples, diff));
605 },
606 Err(e) => {
607 self.0.push(TableRow::Error(name, e));
608 }
609 }
610 }
611 }
612
613 /// # Has Changes?
614 ///
615 /// Returns true if any of the Change columns have a value.
616 fn show_changes(&self) -> bool {
617 self.0.iter().skip(2).any(|v|
618 if let TableRow::Normal(_, _, _, c) = v { c != NO_CHANGE }
619 else { false }
620 )
621 }
622
623 /// # Widths.
624 fn lens(&self) -> (usize, usize, usize, usize) {
625 self.0.iter()
626 .fold((0, 0, 0, 0), |acc, v| {
627 let v = v.lens();
628 (
629 acc.0.max(v.0),
630 acc.1.max(v.1),
631 acc.2.max(v.2),
632 acc.3.max(v.3),
633 )
634 })
635 }
636}
637
638
639
640#[derive(Debug, Clone)]
641/// # Table Row.
642///
643/// This holds the data for a single row. There are a few different variations,
644/// but it's pretty straight-forward.
645enum TableRow {
646 /// # Normal Row.
647 Normal(String, String, String, String),
648
649 /// # An Error.
650 Error(String, BrunchError),
651
652 /// # A Spacer.
653 Spacer,
654}
655
656impl TableRow {
657 /// # Lengths (Widths).
658 ///
659 /// Return the (approximate) printable widths for each column.
660 fn lens(&self) -> (usize, usize, usize, usize) {
661 match self {
662 Self::Normal(a, b, c, d) => (
663 util::width(a),
664 util::width(b),
665 util::width(c),
666 util::width(d),
667 ),
668 Self::Error(a, _) => (util::width(a), 0, 0, 0),
669 Self::Spacer => (0, 0, 0, 0),
670 }
671 }
672}
673
674
675
676/// # Format Name.
677///
678/// Style up a benchmark name by dimming common portions, and highlighting
679/// unique ones.
680///
681/// This approach won't scale well, but the bench count for any given set
682/// should be relatively low.
683fn format_name(mut name: Vec<char>, names: &[Vec<char>]) -> String {
684 let len = name.len();
685
686 // Find the first unique char occurrence.
687 let mut pos: usize = names.iter()
688 .filter_map(|other|
689 if name.eq(other) { None }
690 else {
691 name.iter()
692 .zip(other.iter())
693 .position(|(l, r)| l != r)
694 .or_else(|| Some(len.min(other.len())))
695 }
696 )
697 .max()
698 .unwrap_or_default();
699
700 if 0 < pos && pos < len && ! matches!(name[pos], ':' | '(') {
701 // Let's rewind the marker to the position before the last : or (.
702 if let Some(pos2) = name[..pos].iter().rposition(|c| matches!(c, ':' | '(')) {
703 pos = name[..pos2].iter()
704 .rposition(|c| ! matches!(c, ':' | '('))
705 .map_or(0, |p| p + 1);
706 }
707 // Before the last _ or space?
708 else if let Some(pos2) = name[..pos].iter().rposition(|c| matches!(c, '_' | ' ')) {
709 pos = name[..pos2].iter()
710 .rposition(|c| ! matches!(c, '_' | ' '))
711 .map_or(0, |p| p + 1);
712 }
713 // Remove the marker entirely.
714 else { pos = 0; }
715 }
716
717 if pos == 0 {
718 "\x1b[94m".chars()
719 .chain(name)
720 .chain("\x1b[0m".chars())
721 .collect()
722 }
723 else if pos == len {
724 "\x1b[34m".chars()
725 .chain(name)
726 .chain("\x1b[0m".chars())
727 .collect()
728 }
729 else {
730 let b = name.split_off(pos);
731 "\x1b[34m".chars()
732 .chain(name)
733 .chain("\x1b[94m".chars())
734 .chain(b)
735 .chain("\x1b[0m".chars())
736 .collect()
737 }
738}