Skip to main content

alux_bench_direct/
measure.rs

1//! Measures a stated bench by running it.
2
3use alux_bench::{
4    BenchAlg, BenchCase, BenchCases, BenchGroup, BenchRounds, BenchRoutine, BenchSampling, BenchSentRoutine,
5    BenchStated, MeasureBenchAlg,
6};
7use core::time::Duration;
8use derive_new::new as New;
9use std::io::{IsTerminal, Write as _, stderr};
10use std::thread;
11use std::time::Instant;
12
13/// The fewest rounds a sample runs.
14const LEAST: BenchRounds = BenchRounds::new(1);
15
16/// What a case is padded to while a run is in progress, where the longest name is not known yet.
17const COLUMN: usize = 32;
18
19/// What a line of progress is padded to, so replacing a longer line leaves nothing of it behind.
20const SAID: usize = 96;
21
22/// Colors progress, which is said while a run is going rather than kept.
23const SAYING: &str = "\x1b[36m";
24
25/// Ends a colored line.
26const PLAIN: &str = "\x1b[0m";
27
28/// What one case measured: the rounds a sample ran, and every sample, in the order it was taken.
29#[derive(Clone, Debug, Eq, PartialEq)]
30pub struct BenchMeasured {
31    case: BenchCase,
32    rounds: BenchRounds,
33    samples: Vec<Duration>,
34}
35
36impl BenchMeasured {
37    /// Returns the case measured.
38    pub const fn case(&self) -> BenchCase {
39        self.case
40    }
41
42    /// Writes what this case measured to stdout, taking back the line progress was said on.
43    pub fn said(&self) {
44        say_no_more();
45        if let Some(line) = line(self, COLUMN) {
46            println!("{line}");
47        }
48    }
49
50    /// Returns how many rounds each sample ran.
51    pub const fn rounds(&self) -> BenchRounds {
52        self.rounds
53    }
54
55    /// Returns every sample, in the order it was taken. A sample is all the rounds it ran.
56    pub fn samples(&self) -> &[Duration] {
57        &self.samples
58    }
59
60    /// Returns the shortest, the median and the longest round, of a case with any sample.
61    ///
62    /// Each sample is divided by the rounds it ran, so all three are one round.
63    pub fn spread(&self) -> Option<(Duration, Duration, Duration)> {
64        let mut sorted = self.samples.iter().map(|sample| *sample / self.per_round()).collect::<Vec<_>>();
65        sorted.sort_unstable();
66        let shortest = *sorted.first()?;
67        let longest = *sorted.last()?;
68
69        Some((shortest, sorted[sorted.len() / 2], longest))
70    }
71
72    /// How many rounds one sample is divided by, which is at least one.
73    fn per_round(&self) -> u32 {
74        u32::try_from(self.rounds.count()).unwrap_or(u32::MAX).max(1)
75    }
76}
77
78/// Measures cases by running them over the samples `sampling` states.
79///
80/// What a case measured is said as soon as it is measured and then let go, so an interrupted run
81/// has said everything it finished and a long one keeps nothing.
82#[derive(Clone, Debug, New)]
83pub struct DirectBench {
84    sampling: BenchSampling,
85    #[new(default)]
86    only: Option<String>,
87}
88
89impl DirectBench {
90    /// Measures the sampling stated, filtered by the first argument that is not a flag.
91    ///
92    /// `cargo bench -- "30 sec"` measures the cases naming it. Arguments starting with `-`, which
93    /// is what `cargo bench` passes of its own, are skipped.
94    pub fn from_args(sampling: BenchSampling) -> Self {
95        let only = std::env::args().skip(1).find(|argument| !argument.starts_with('-'));
96
97        only.map_or_else(|| Self::new(sampling), |only| Self::new(sampling).only(only))
98    }
99
100    /// Measures only the cases whose group or subject contains `only`, and skips the rest.
101    #[must_use]
102    pub fn only(mut self, only: impl Into<String>) -> Self {
103        self.only = Some(only.into());
104
105        self
106    }
107
108    /// Measures one case on this thread. `None` for a case the filter skips, which is never run.
109    ///
110    /// What it measured is answered rather than said, since a group measuring its cases at the
111    /// same time says them in the order it stated them rather than the order they finish.
112    fn measuring(&self, case: BenchCase, routine: &mut dyn FnMut(BenchRounds) -> Duration) -> Option<BenchMeasured> {
113        if !self.stating(case) {
114            return None;
115        }
116
117        let each = self.rounds_of_a_sample(routine);
118        let samples = (0..self.sampling.samples()).map(|_| routine(each)).collect();
119        Some(BenchMeasured { case, rounds: each, samples })
120    }
121
122    /// Answers whether this case is one the run measures, which a filter can narrow.
123    fn stating(&self, case: BenchCase) -> bool {
124        self.only.as_ref().is_none_or(|only| name(case).contains(only.as_str()))
125    }
126
127    /// Answers how many rounds one sample runs, which is one unless a spend is stated.
128    ///
129    /// A stated spend is divided by one round run first and not kept, as a warm-up round is. The
130    /// divisor is what that round cost, not what it measured, so a round setting up off the clock
131    /// counts what it costs. A round longer than the spend runs once.
132    fn rounds_of_a_sample(&self, routine: &mut dyn FnMut(BenchRounds) -> Duration) -> BenchRounds {
133        let Some(spend) = self.sampling.spend() else {
134            return LEAST;
135        };
136        let warming = Instant::now();
137        let _ = routine(LEAST);
138        let cost = warming.elapsed();
139        let Ok(fits) = u64::try_from(spend.as_nanos() / cost.as_nanos().max(1)) else {
140            return LEAST;
141        };
142
143        BenchRounds::new(fits.max(1))
144    }
145}
146
147impl BenchAlg for DirectBench {
148    type Bench = BenchStated<'static>;
149
150    /// States the bench as the value it is, which measuring folds later.
151    fn nothing(&self) -> Self::Bench {
152        BenchStated::new()
153    }
154
155    fn group(&self, group: &'static str, cases: BenchCases<'static>) -> Self::Bench {
156        BenchStated::new().group(BenchGroup::new(group, cases))
157    }
158
159    fn then(&self, first: Self::Bench, next: Self::Bench) -> Self::Bench {
160        first.then(next)
161    }
162}
163
164impl MeasureBenchAlg for DirectBench {
165    type Bench = BenchStated<'static>;
166
167    /// Measures every group in the order stated, each case saying what it measured as it has it.
168    fn measure(&mut self, bench: Self::Bench) {
169        for group in bench.into_groups() {
170            let named = group.group();
171            match group.cases() {
172                BenchCases::OneAtATime(cases) => self.one_at_a_time(named, cases),
173                BenchCases::Together(cases) => self.together(named, cases),
174            }
175        }
176
177        say_no_more();
178    }
179}
180
181impl DirectBench {
182    /// Measures the cases of one group, one after another, saying each as it is measured.
183    fn one_at_a_time(&self, group: &'static str, cases: Vec<(&'static str, BenchRoutine<'static>)>) {
184        for (subject, mut routine) in cases {
185            let case = BenchCase::new(group, subject);
186            if !self.stating(case) {
187                continue;
188            }
189            say(&format!("measuring {}", name(case)));
190            if let Some(measured) = self.measuring(case, &mut routine) {
191                measured.said();
192            }
193        }
194    }
195
196    /// Measures the cases of one group on a thread of its own each, joined in the order stated.
197    ///
198    /// A thread rather than a task, because an IO-bound routine blocks rather than yielding. The
199    /// cases are filtered before anything is spawned, so a narrowed run neither measures what it
200    /// skips nor counts it as still being measured.
201    fn together(&self, group: &'static str, cases: Vec<(&'static str, BenchSentRoutine)>) {
202        let cases = cases
203            .into_iter()
204            .map(|(subject, routine)| (BenchCase::new(group, subject), routine))
205            .filter(|(case, _)| self.stating(*case))
206            .collect::<Vec<_>>();
207        if cases.is_empty() {
208            return;
209        }
210
211        let stated = cases.len();
212        say(&measuring_at_once(stated, group));
213        thread::scope(|running| {
214            let running = cases
215                .into_iter()
216                .map(|(case, mut routine)| running.spawn(move || self.measuring(case, &mut routine)))
217                .collect::<Vec<_>>();
218
219            // Waited for in the order the group states, so what is said is said in that order as
220            // soon as the case before it is done, rather than in the order the threads finish.
221            for (done, case) in running.into_iter().enumerate() {
222                if let Some(measured) = case.join().expect("the case measured") {
223                    measured.said();
224                }
225
226                // Saying what a case measured takes the progress line back, so the cases still
227                // running say themselves again. Otherwise the longest of them waits in silence.
228                let waiting = stated - done - 1;
229                if waiting > 0 {
230                    say(&measuring_at_once(waiting, group));
231                }
232            }
233        });
234    }
235}
236
237/// Says how many cases of one group are still being measured at the same time.
238fn measuring_at_once(cases: usize, group: &str) -> String {
239    let many = if cases == 1 { "case" } else { "cases" };
240
241    format!("measuring {cases} {many} of {group} at the same time")
242}
243
244/// Says where a run is, on the one line of stderr progress is said on.
245///
246/// A terminal takes the line back and says this one in its place, and the cursor rests where the
247/// line starts rather than past its padding. Anything else, such as a log, keeps each line: there
248/// is nothing to take a line back with.
249fn say(saying: &str) {
250    if stderr().is_terminal() {
251        eprint!("\r{SAYING}{saying:SAID$}{PLAIN}\r");
252        let _ = stderr().flush();
253    } else {
254        eprintln!("{saying}");
255    }
256}
257
258/// Takes back the line progress was said on, leaving stderr as it was found.
259fn say_no_more() {
260    if stderr().is_terminal() {
261        eprint!("\r{:SAID$}\r", "");
262        let _ = stderr().flush();
263    }
264}
265
266/// Writes what one case measured, padded to `width`. `None` for a case with no sample.
267fn line(measured: &BenchMeasured, width: usize) -> Option<String> {
268    let (shortest, median, longest) = measured.spread()?;
269    let samples = measured.samples().len();
270    let each = measured.rounds().count();
271    let case = name(measured.case());
272
273    Some(format!("{case:width$}  {samples:>3} x {each:<6} rounds  {median:>12.4?}  [{shortest:.4?} {longest:.4?}]"))
274}
275
276/// Names a case the way a report reads it: the group, then the subject.
277fn name(case: BenchCase) -> String {
278    format!("{}/{}", case.group(), case.subject())
279}