Skip to main content

cubecl_common/
benchmark.rs

1use alloc::format;
2use alloc::string::String;
3use alloc::vec;
4use alloc::vec::Vec;
5use core::fmt::Display;
6use core::time::Duration;
7
8pub use crate::profile::{Instant, TimingMethod};
9
10use crate::work::Work;
11
12#[cfg(feature = "std")]
13pub use crate::profile::ProfileDuration;
14
15/// Results of a benchmark run.
16#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
17#[derive(new, Debug, Clone)]
18pub struct BenchmarkDurations {
19    /// How these durations were measured.
20    pub timing_method: TimingMethod,
21    /// All durations of the run, in the order they were benchmarked
22    pub durations: Vec<Duration>,
23}
24
25impl BenchmarkDurations {
26    /// Construct from a list of durations.
27    pub fn from_durations(timing_method: TimingMethod, durations: Vec<Duration>) -> Self {
28        Self {
29            timing_method,
30            durations,
31        }
32    }
33
34    /// Returns a tuple of durations: (min, max, median)
35    fn min_max_median_durations(&self) -> (Duration, Duration, Duration) {
36        let mut sorted = self.durations.clone();
37        sorted.sort();
38        let min = *sorted.first().unwrap();
39        let max = *sorted.last().unwrap();
40        let median = *sorted.get(sorted.len() / 2).unwrap();
41        (min, max, median)
42    }
43
44    /// Returns the median duration among all durations
45    pub(crate) fn mean_duration(&self) -> Duration {
46        self.durations.iter().sum::<Duration>() / self.durations.len() as u32
47    }
48
49    /// Returns the variance durations for the durations
50    pub(crate) fn variance_duration(&self, mean: Duration) -> Duration {
51        self.durations
52            .iter()
53            .map(|duration| {
54                let tmp = duration.as_secs_f64() - mean.as_secs_f64();
55                Duration::from_secs_f64(tmp * tmp)
56            })
57            .sum::<Duration>()
58            / self.durations.len() as u32
59    }
60}
61
62impl Display for BenchmarkDurations {
63    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
64        let computed = BenchmarkComputations::new(self);
65        let BenchmarkComputations {
66            mean,
67            median,
68            variance,
69            min,
70            max,
71        } = computed;
72        let num_sample = self.durations.len();
73        let timing_method = self.timing_method;
74
75        f.write_str(
76            format!(
77                "
78―――――――― Result ―――――――――
79  Timing      {timing_method}
80  Samples     {num_sample}
81  Mean        {mean:.3?}
82  Variance    {variance:.3?}
83  Median      {median:.3?}
84  Min         {min:.3?}
85  Max         {max:.3?}
86―――――――――――――――――――――――――"
87            )
88            .as_str(),
89        )
90    }
91}
92
93/// Computed values from benchmark durations.
94#[cfg_attr(
95    feature = "serde",
96    derive(serde::Serialize, serde::Deserialize, PartialEq, Eq)
97)]
98#[derive(Debug, Default, Clone)]
99pub struct BenchmarkComputations {
100    /// Mean of all the durations.
101    pub mean: Duration,
102    /// Median of all the durations.
103    pub median: Duration,
104    /// Variance of all the durations.
105    pub variance: Duration,
106    /// Minimum duration amongst all durations.
107    pub min: Duration,
108    /// Maximum duration amongst all durations.
109    pub max: Duration,
110}
111
112impl BenchmarkComputations {
113    /// Compute duration values and return a `BenchmarkComputations` struct
114    pub fn new(durations: &BenchmarkDurations) -> Self {
115        let mean = durations.mean_duration();
116        let (min, max, median) = durations.min_max_median_durations();
117        Self {
118            mean,
119            median,
120            min,
121            max,
122            variance: durations.variance_duration(mean),
123        }
124    }
125
126    /// Returns the score of the current benchmark.
127    pub fn score(&self) -> u64 {
128        // How much optimism we have regarding the benchmark.
129        //
130        // The higher the value, the more we prioritize the fastest run regardless of variation.
131        const ALPHA: f64 = 0.8;
132
133        let min_ns = self.min.as_nanos() as f64;
134        let median_ns = self.median.as_nanos() as f64;
135        let variance_ns = self.variance.as_nanos() as f64;
136        let mean_ns = self.mean.as_nanos() as f64;
137
138        // The base score is based on the fastest run and the median duration.
139        let base_score = (min_ns * ALPHA) + (median_ns * (1.0 - ALPHA));
140
141        // If the standard deviation is high relative to the mean,
142        // we inflate the score (making it less desirable).
143        let std_dev = num_traits::Float::sqrt(variance_ns);
144
145        // Lower is better
146        let coefficient_of_variation = 1.0
147            + (std_dev
148                / (
149                    // The `1.0` is only for numerical stability with small numbers.
150                    // Since we work with nanos, this is negligible.
151                    1.0 + mean_ns
152                ));
153
154        // Return score (Lower is better)
155        (base_score * coefficient_of_variation) as u64
156    }
157}
158
159/// Benchmark trait.
160pub trait Benchmark {
161    /// Benchmark input arguments.
162    type Input: Clone;
163    /// The benchmark output.
164    type Output;
165
166    /// Prepare the benchmark, run anything that is essential for the benchmark, but shouldn't
167    /// count as included in the duration.
168    ///
169    /// # Notes
170    ///
171    /// This should not include warmup, the benchmark will be run at least one time without
172    /// measuring the execution time.
173    fn prepare(&self) -> Self::Input;
174
175    /// Execute the benchmark and returns the logical output of the task executed.
176    ///
177    /// It is important to return the output since otherwise deadcode optimization might optimize
178    /// away code that should be benchmarked.
179    fn execute(&self, input: Self::Input) -> Result<Self::Output, String>;
180
181    /// Number of samples per run required to have a statistical significance.
182    fn num_samples(&self) -> usize {
183        const DEFAULT: usize = 15;
184        #[cfg(feature = "std")]
185        {
186            std::env::var("BENCH_NUM_SAMPLES")
187                .map(|val| str::parse::<usize>(&val).unwrap_or(DEFAULT))
188                .unwrap_or(DEFAULT)
189        }
190
191        #[cfg(not(feature = "std"))]
192        {
193            DEFAULT
194        }
195    }
196
197    /// Name of the benchmark, should be short and it should match the name
198    /// defined in the crate Cargo.toml
199    fn name(&self) -> String;
200
201    /// The options passed to the benchmark.
202    fn options(&self) -> Option<String> {
203        None
204    }
205
206    /// Shapes dimensions
207    fn shapes(&self) -> Vec<Vec<usize>> {
208        vec![]
209    }
210
211    /// The work one execution performs, for scoring the run against measured
212    /// peak throughput. `None` when the benchmark has no such figure to report.
213    /// Coarse by necessity: this crate cannot name a throughput key, so
214    /// `calculate_bounds` (in `cubecl-runtime`) is what turns this single
215    /// figure into per-resource bounds, and a caller wanting the achieved
216    /// rate against each of those scores them at the client layer, which
217    /// does have keys.
218    fn work(&self) -> Option<Work> {
219        None
220    }
221
222    /// Wait for computation to complete.
223    fn sync(&self);
224
225    /// Start measuring the computation duration.
226    #[cfg(feature = "std")]
227    fn profile(&self, args: Self::Input) -> Result<ProfileDuration, String> {
228        self.profile_full(args)
229    }
230
231    /// Start measuring the computation duration. Use the full duration irregardless of whether
232    /// device duration is available or not.
233    #[cfg(feature = "std")]
234    fn profile_full(&self, args: Self::Input) -> Result<ProfileDuration, String> {
235        self.sync();
236        let start_time = Instant::now();
237        let out = self.execute(args)?;
238        self.sync();
239        core::mem::drop(out);
240        Ok(ProfileDuration::new_system_time(start_time, Instant::now()))
241    }
242
243    /// Run the benchmark a number of times.
244    #[allow(unused_variables)]
245    fn run(&self, timing_method: TimingMethod) -> Result<BenchmarkDurations, String> {
246        #[cfg(not(feature = "std"))]
247        panic!("Attempting to run benchmark in a no-std environment");
248
249        #[cfg(feature = "std")]
250        {
251            let execute = |args: &Self::Input| {
252                let profile: Result<ProfileDuration, String> = match timing_method {
253                    TimingMethod::System => self.profile_full(args.clone()),
254                    TimingMethod::Device => self.profile(args.clone()),
255                };
256                let profile = match profile {
257                    Ok(val) => val,
258                    Err(err) => return Err(err),
259                };
260                Ok(cubecl_environment::future::block_on(profile.resolve()))
261            };
262            let args = self.prepare();
263
264            // Triggers JIT-compilation and perform a Warmup
265            //
266            // We are using 5 iterations, where the first one probably triggers the JIT-compilation
267            // and it is then followed by 4 warmup executions.
268            for _ in 0..5 {
269                let _duration: Result<crate::profile::ProfileTicks, _> = execute(&args);
270            }
271
272            // Real execution.
273            let mut durations = Vec::with_capacity(self.num_samples());
274            for _ in 0..self.num_samples() {
275                match execute(&args) {
276                    Ok(val) => durations.push(val.duration()),
277                    Err(err) => {
278                        return Err(err);
279                    }
280                }
281            }
282
283            Ok(BenchmarkDurations {
284                timing_method,
285                durations,
286            })
287        }
288    }
289}
290
291/// Result of a benchmark run, with metadata
292#[derive(Clone)]
293pub struct BenchmarkResult {
294    /// Individual raw results of the run
295    pub raw: BenchmarkDurations,
296    /// Computed values for the run
297    pub computed: BenchmarkComputations,
298    /// Git commit hash of the commit in which the run occurred
299    pub git_hash: String,
300    /// Name of the benchmark
301    pub name: String,
302    /// Options passed to the benchmark
303    pub options: Option<String>,
304    /// Shape dimensions
305    pub shapes: Vec<Vec<usize>>,
306    /// Time just before the run
307    pub timestamp: u128,
308}
309
310impl Display for BenchmarkResult {
311    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
312        f.write_str(
313            format!(
314                "
315        Timestamp: {}
316        Git Hash: {}
317        Benchmarking - {}{}
318        ",
319                self.timestamp, self.git_hash, self.name, self.raw
320            )
321            .as_str(),
322        )
323    }
324}
325
326#[cfg(feature = "std")]
327/// Runs the given benchmark on the device and prints result and information.
328pub fn run_benchmark<BM>(benchmark: BM) -> Result<BenchmarkResult, String>
329where
330    BM: Benchmark,
331{
332    use std::string::ToString;
333
334    let timestamp = std::time::SystemTime::now()
335        .duration_since(std::time::UNIX_EPOCH)
336        .unwrap()
337        .as_millis();
338    let output = std::process::Command::new("git")
339        .args(["rev-parse", "HEAD"])
340        .output()
341        .unwrap();
342    let git_hash = String::from_utf8(output.stdout).unwrap().trim().to_string();
343    let durations = benchmark.run(TimingMethod::System)?;
344
345    Ok(BenchmarkResult {
346        raw: durations.clone(),
347        computed: BenchmarkComputations::new(&durations),
348        git_hash,
349        name: benchmark.name(),
350        options: benchmark.options(),
351        shapes: benchmark.shapes(),
352        timestamp,
353    })
354}
355
356#[cfg(test)]
357#[cfg(feature = "std")]
358mod tests {
359    use super::*;
360    use alloc::vec;
361
362    #[test_log::test]
363    fn test_min_max_median_durations_even_number_of_samples() {
364        let durations = BenchmarkDurations {
365            timing_method: TimingMethod::System,
366            durations: vec![
367                Duration::new(10, 0),
368                Duration::new(20, 0),
369                Duration::new(30, 0),
370                Duration::new(40, 0),
371                Duration::new(50, 0),
372            ],
373        };
374        let (min, max, median) = durations.min_max_median_durations();
375        assert_eq!(min, Duration::from_secs(10));
376        assert_eq!(max, Duration::from_secs(50));
377        assert_eq!(median, Duration::from_secs(30));
378    }
379
380    #[test_log::test]
381    fn test_min_max_median_durations_odd_number_of_samples() {
382        let durations = BenchmarkDurations {
383            timing_method: TimingMethod::System,
384            durations: vec![
385                Duration::new(18, 5),
386                Duration::new(20, 0),
387                Duration::new(30, 0),
388                Duration::new(40, 0),
389            ],
390        };
391        let (min, max, median) = durations.min_max_median_durations();
392        assert_eq!(min, Duration::from_nanos(18000000005_u64));
393        assert_eq!(max, Duration::from_secs(40));
394        assert_eq!(median, Duration::from_secs(30));
395    }
396
397    #[test_log::test]
398    fn test_mean_duration() {
399        let durations = BenchmarkDurations {
400            timing_method: TimingMethod::System,
401            durations: vec![
402                Duration::new(10, 0),
403                Duration::new(20, 0),
404                Duration::new(30, 0),
405                Duration::new(40, 0),
406            ],
407        };
408        let mean = durations.mean_duration();
409        assert_eq!(mean, Duration::from_secs(25));
410    }
411
412    #[test_log::test]
413    fn test_variance_duration() {
414        let durations = BenchmarkDurations {
415            timing_method: TimingMethod::System,
416            durations: vec![
417                Duration::new(10, 0),
418                Duration::new(20, 0),
419                Duration::new(30, 0),
420                Duration::new(40, 0),
421                Duration::new(50, 0),
422            ],
423        };
424        let mean = durations.mean_duration();
425        let variance = durations.variance_duration(mean);
426        assert_eq!(variance, Duration::from_secs(200));
427    }
428}