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}