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/// Launches the warmup makes however long they take: one to compile the kernel
160/// and four to settle the device.
161#[cfg(feature = "std")]
162const MIN_WARMUP_RUNS: usize = 5;
163
164/// Benchmark trait.
165pub trait Benchmark {
166    /// Benchmark input arguments.
167    type Input: Clone;
168    /// The benchmark output.
169    type Output;
170
171    /// Prepare the benchmark, run anything that is essential for the benchmark, but shouldn't
172    /// count as included in the duration.
173    ///
174    /// # Notes
175    ///
176    /// This should not include warmup, the benchmark will be run at least one time without
177    /// measuring the execution time.
178    fn prepare(&self) -> Self::Input;
179
180    /// Execute the benchmark and returns the logical output of the task executed.
181    ///
182    /// It is important to return the output since otherwise deadcode optimization might optimize
183    /// away code that should be benchmarked.
184    fn execute(&self, input: Self::Input) -> Result<Self::Output, String>;
185
186    /// Wall clock the warmup holds the device for before sampling starts.
187    ///
188    /// A device takes hundreds of milliseconds to reach the clocks it sustains,
189    /// and under half a second a GPU still samples a step low. `BENCH_WARMUP_MS`
190    /// buys the wall clock back and pays in accuracy.
191    fn warmup_budget(&self) -> Duration {
192        const DEFAULT_MS: u64 = 500;
193        #[cfg(feature = "std")]
194        {
195            Duration::from_millis(
196                std::env::var("BENCH_WARMUP_MS")
197                    .map(|val| str::parse::<u64>(&val).unwrap_or(DEFAULT_MS))
198                    .unwrap_or(DEFAULT_MS),
199            )
200        }
201
202        #[cfg(not(feature = "std"))]
203        {
204            Duration::from_millis(DEFAULT_MS)
205        }
206    }
207
208    /// Number of samples per run required to have a statistical significance.
209    fn num_samples(&self) -> usize {
210        const DEFAULT: usize = 15;
211        #[cfg(feature = "std")]
212        {
213            std::env::var("BENCH_NUM_SAMPLES")
214                .map(|val| str::parse::<usize>(&val).unwrap_or(DEFAULT))
215                .unwrap_or(DEFAULT)
216        }
217
218        #[cfg(not(feature = "std"))]
219        {
220            DEFAULT
221        }
222    }
223
224    /// Name of the benchmark, should be short and it should match the name
225    /// defined in the crate Cargo.toml
226    fn name(&self) -> String;
227
228    /// The options passed to the benchmark.
229    fn options(&self) -> Option<String> {
230        None
231    }
232
233    /// Shapes dimensions
234    fn shapes(&self) -> Vec<Vec<usize>> {
235        vec![]
236    }
237
238    /// The work one execution performs, for scoring the run against measured peak
239    /// throughput. `None` when the benchmark has no such figure to report.
240    ///
241    /// One figure rather than per-resource bounds because this crate cannot name a
242    /// throughput key. A bound builder that can, such as `roofline_bounds` in
243    /// `cubecl-std`, splits it.
244    fn work(&self) -> Option<Work> {
245        None
246    }
247
248    /// Wait for computation to complete.
249    fn sync(&self);
250
251    /// Start measuring the computation duration.
252    #[cfg(feature = "std")]
253    fn profile(&self, args: Self::Input) -> Result<ProfileDuration, String> {
254        self.profile_full(args)
255    }
256
257    /// Start measuring the computation duration. Use the full duration irregardless of whether
258    /// device duration is available or not.
259    #[cfg(feature = "std")]
260    fn profile_full(&self, args: Self::Input) -> Result<ProfileDuration, String> {
261        self.sync();
262        let start_time = Instant::now();
263        let out = self.execute(args)?;
264        self.sync();
265        core::mem::drop(out);
266        Ok(ProfileDuration::new_system_time(start_time, Instant::now()))
267    }
268
269    /// Run the benchmark a number of times.
270    #[allow(unused_variables)]
271    fn run(&self, timing_method: TimingMethod) -> Result<BenchmarkDurations, String> {
272        #[cfg(not(feature = "std"))]
273        panic!("Attempting to run benchmark in a no-std environment");
274
275        #[cfg(feature = "std")]
276        {
277            let execute = |args: &Self::Input| {
278                let profile: Result<ProfileDuration, String> = match timing_method {
279                    TimingMethod::System => self.profile_full(args.clone()),
280                    TimingMethod::Device => self.profile(args.clone()),
281                };
282                let profile = match profile {
283                    Ok(val) => val,
284                    Err(err) => return Err(err),
285                };
286                // A window that carried no measurement is a failed run here, not
287                // a fast one: this harness has an error channel, so it uses it
288                // rather than letting an absence become a zero in `durations`.
289                cubecl_environment::future::block_on(profile.resolve()).ok_or_else(|| {
290                    alloc::string::String::from("the profiled window carried no measurement")
291                })
292            };
293            let args = self.prepare();
294
295            // Compiles on the first launch, then holds the device until it
296            // answers at the clocks a sampled run will see.
297            let budget = self.warmup_budget();
298            let warmup = Instant::now();
299            let (mut warmups, mut failures) = (0, 0);
300            while warmups < MIN_WARMUP_RUNS || warmup.elapsed() < budget {
301                let warmed: Result<crate::profile::ProfileTicks, String> = execute(&args);
302
303                match warmed {
304                    Ok(_) => warmups += 1,
305                    // Stopping on one failure would leave the samples reading
306                    // cold clocks and report that as the kernel's speed.
307                    Err(_) => {
308                        failures += 1;
309
310                        if failures >= MIN_WARMUP_RUNS {
311                            break;
312                        }
313                    }
314                }
315            }
316
317            // Real execution.
318            let mut durations = Vec::with_capacity(self.num_samples());
319            for _ in 0..self.num_samples() {
320                match execute(&args) {
321                    Ok(val) => durations.push(val.duration()),
322                    Err(err) => {
323                        return Err(err);
324                    }
325                }
326            }
327
328            Ok(BenchmarkDurations {
329                timing_method,
330                durations,
331            })
332        }
333    }
334}
335
336/// Result of a benchmark run, with metadata
337#[derive(Clone)]
338pub struct BenchmarkResult {
339    /// Individual raw results of the run
340    pub raw: BenchmarkDurations,
341    /// Computed values for the run
342    pub computed: BenchmarkComputations,
343    /// Git commit hash of the commit in which the run occurred
344    pub git_hash: String,
345    /// Name of the benchmark
346    pub name: String,
347    /// Options passed to the benchmark
348    pub options: Option<String>,
349    /// Shape dimensions
350    pub shapes: Vec<Vec<usize>>,
351    /// Time just before the run
352    pub timestamp: u128,
353}
354
355impl Display for BenchmarkResult {
356    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
357        f.write_str(
358            format!(
359                "
360        Timestamp: {}
361        Git Hash: {}
362        Benchmarking - {}{}
363        ",
364                self.timestamp, self.git_hash, self.name, self.raw
365            )
366            .as_str(),
367        )
368    }
369}
370
371#[cfg(feature = "std")]
372/// Runs the given benchmark on the device and prints result and information.
373pub fn run_benchmark<BM>(benchmark: BM) -> Result<BenchmarkResult, String>
374where
375    BM: Benchmark,
376{
377    use std::string::ToString;
378
379    let timestamp = std::time::SystemTime::now()
380        .duration_since(std::time::UNIX_EPOCH)
381        .unwrap()
382        .as_millis();
383    let output = std::process::Command::new("git")
384        .args(["rev-parse", "HEAD"])
385        .output()
386        .unwrap();
387    let git_hash = String::from_utf8(output.stdout).unwrap().trim().to_string();
388    let durations = benchmark.run(TimingMethod::System)?;
389
390    Ok(BenchmarkResult {
391        raw: durations.clone(),
392        computed: BenchmarkComputations::new(&durations),
393        git_hash,
394        name: benchmark.name(),
395        options: benchmark.options(),
396        shapes: benchmark.shapes(),
397        timestamp,
398    })
399}
400
401#[cfg(test)]
402#[cfg(feature = "std")]
403mod tests {
404    use super::*;
405    use alloc::vec;
406    use core::cell::Cell;
407
408    /// A device that answers in whatever time it is given, counting the
409    /// launches it was asked for.
410    struct TimedBench {
411        per_execution: Duration,
412        executions: Cell<usize>,
413        /// Launches that fail before any of them succeed.
414        failures: Cell<usize>,
415    }
416
417    impl TimedBench {
418        fn new(per_execution: Duration) -> Self {
419            Self {
420                per_execution,
421                executions: Cell::new(0),
422                failures: Cell::new(0),
423            }
424        }
425
426        fn failing(self, launches: usize) -> Self {
427            self.failures.set(launches);
428
429            self
430        }
431    }
432
433    impl Benchmark for TimedBench {
434        type Input = ();
435        type Output = ();
436
437        fn prepare(&self) -> Self::Input {}
438
439        fn execute(&self, _input: Self::Input) -> Result<Self::Output, String> {
440            self.executions.set(self.executions.get() + 1);
441
442            if self.failures.get() > 0 {
443                self.failures.set(self.failures.get() - 1);
444
445                return Err(String::from("the launch failed"));
446            }
447
448            let start = Instant::now();
449            while start.elapsed() < self.per_execution {}
450
451            Ok(())
452        }
453
454        fn num_samples(&self) -> usize {
455            1
456        }
457
458        fn name(&self) -> String {
459            "timed".into()
460        }
461
462        fn sync(&self) {}
463    }
464
465    /// The warmup is there to lift a device off its idle clocks, which takes
466    /// hundreds of milliseconds whatever the kernel costs. Counted in launches
467    /// it would leave a fast one sampled cold.
468    #[test_log::test]
469    fn a_kernel_the_launch_count_cannot_warm_is_held_for_the_budget() {
470        let bench = TimedBench::new(Duration::ZERO);
471        let start = Instant::now();
472
473        bench.run(TimingMethod::System).expect("the bench runs");
474
475        assert!(
476            start.elapsed() >= bench.warmup_budget(),
477            "warmed {:?}",
478            start.elapsed()
479        );
480        assert!(bench.executions.get() > MIN_WARMUP_RUNS);
481    }
482
483    /// One launch failing is not the device saying no, and cutting the warmup
484    /// there would leave the samples reading idle clocks and report that as the
485    /// kernel's speed.
486    #[test_log::test]
487    fn one_failed_launch_does_not_end_the_warmup() {
488        let bench = TimedBench::new(Duration::ZERO).failing(1);
489        let start = Instant::now();
490
491        bench.run(TimingMethod::System).expect("the bench runs");
492
493        assert!(start.elapsed() >= bench.warmup_budget(), "left early");
494    }
495
496    /// A kernel that cannot run at all stops after the launches its warmup owed
497    /// it, rather than spending the whole budget failing.
498    #[test_log::test]
499    fn a_launch_that_always_fails_stops_the_warmup() {
500        let bench = TimedBench::new(Duration::ZERO).failing(usize::MAX);
501        let start = Instant::now();
502
503        assert!(bench.run(TimingMethod::System).is_err());
504        assert!(start.elapsed() < bench.warmup_budget(), "kept failing");
505    }
506
507    /// A budget that added launches to a kernel already filling it would make
508    /// every slow row pay a warmup it does not need.
509    #[test_log::test]
510    fn a_kernel_that_fills_the_budget_pays_no_extra_launch() {
511        let mut bench = TimedBench::new(Duration::ZERO);
512        bench.per_execution = bench.warmup_budget() / 4;
513
514        let durations = bench.run(TimingMethod::System).expect("the bench runs");
515
516        assert_eq!(
517            bench.executions.get(),
518            MIN_WARMUP_RUNS + durations.durations.len()
519        );
520    }
521
522    #[test_log::test]
523    fn test_min_max_median_durations_even_number_of_samples() {
524        let durations = BenchmarkDurations {
525            timing_method: TimingMethod::System,
526            durations: vec![
527                Duration::new(10, 0),
528                Duration::new(20, 0),
529                Duration::new(30, 0),
530                Duration::new(40, 0),
531                Duration::new(50, 0),
532            ],
533        };
534        let (min, max, median) = durations.min_max_median_durations();
535        assert_eq!(min, Duration::from_secs(10));
536        assert_eq!(max, Duration::from_secs(50));
537        assert_eq!(median, Duration::from_secs(30));
538    }
539
540    #[test_log::test]
541    fn test_min_max_median_durations_odd_number_of_samples() {
542        let durations = BenchmarkDurations {
543            timing_method: TimingMethod::System,
544            durations: vec![
545                Duration::new(18, 5),
546                Duration::new(20, 0),
547                Duration::new(30, 0),
548                Duration::new(40, 0),
549            ],
550        };
551        let (min, max, median) = durations.min_max_median_durations();
552        assert_eq!(min, Duration::from_nanos(18000000005_u64));
553        assert_eq!(max, Duration::from_secs(40));
554        assert_eq!(median, Duration::from_secs(30));
555    }
556
557    #[test_log::test]
558    fn test_mean_duration() {
559        let durations = BenchmarkDurations {
560            timing_method: TimingMethod::System,
561            durations: vec![
562                Duration::new(10, 0),
563                Duration::new(20, 0),
564                Duration::new(30, 0),
565                Duration::new(40, 0),
566            ],
567        };
568        let mean = durations.mean_duration();
569        assert_eq!(mean, Duration::from_secs(25));
570    }
571
572    #[test_log::test]
573    fn test_variance_duration() {
574        let durations = BenchmarkDurations {
575            timing_method: TimingMethod::System,
576            durations: vec![
577                Duration::new(10, 0),
578                Duration::new(20, 0),
579                Duration::new(30, 0),
580                Duration::new(40, 0),
581                Duration::new(50, 0),
582            ],
583        };
584        let mean = durations.mean_duration();
585        let variance = durations.variance_duration(mean);
586        assert_eq!(variance, Duration::from_secs(200));
587    }
588}