Skip to main content

cubecl_runtime/throughput/
benchmarker.rs

1use crate::{
2    config::CubeClRuntimeConfig,
3    throughput::{ThroughputCache, ThroughputError, ThroughputKey, ThroughputValue},
4};
5use alloc::boxed::Box;
6use alloc::sync::Arc;
7use cubecl_common::profile::{Duration, Instant};
8use cubecl_environment::config::RuntimeConfig;
9use cubecl_environment::sync::Mutex;
10
11type Cache = Arc<Mutex<ThroughputCache>>;
12
13/// Wall clock a warmup may spend growing its iteration count. Generous next to
14/// the tens of milliseconds a converging one needs, and the only thing bounding
15/// a probe whose timer is too coarse to ever reach the target.
16const WARMUP_BUDGET: Duration = Duration::from_secs(2);
17
18/// Wall clock a plateau must hold across before it is accepted, sized against a
19/// clock transition, which takes hundreds of milliseconds.
20const PLATEAU_FLOOR: Duration = Duration::from_millis(250);
21
22/// Wall clock one shape's peak is sampled over. A sample count would price a
23/// probe filling the duration target forty times one whose pass is microseconds.
24const SAMPLE_BUDGET: Duration = Duration::from_millis(200);
25
26/// Samples that may go by without improving before the peak is accepted. Also
27/// the floor on samples, since the first always improves on infinity and only
28/// the ones after it can go stale.
29const SAMPLE_PATIENCE: usize = 12;
30
31/// Wall clock one launch of a shape is grown or cut toward, so that a sweep
32/// times every shape over the same span and none of them wears a larger share
33/// of the fixed cost of a launch than the rest.
34const TARGET_DURATION: Duration = Duration::from_millis(20);
35
36/// Samples a ranking pass keeps the fastest of. A count and not a wall clock:
37/// under a budget a short pass draws more, and the fastest of more is faster.
38const RANK_SAMPLES: usize = 3;
39
40/// Configuration and payload for a benchmarkable compute kernel.
41pub struct KernelConfig {
42    /// A closure that executes the kernel for the given number of iterations and returns the duration.
43    pub sample: Box<dyn Fn(usize) -> Duration>,
44    /// The number of operations processed in one iteration.
45    pub ops_count: usize,
46    /// Iterations a launch must carry however quickly they turn out to run,
47    /// which a duration target cannot express.
48    pub min_iterations: usize,
49}
50
51/// What a ranking pass found: how fast the shape answered, and the iteration
52/// count it settled on for the next shape to start from.
53pub struct Ranked {
54    /// The shape's rate, measured briefly enough to order it and not to report
55    /// it.
56    pub value: ThroughputValue,
57    /// What one launch of this shape was timed over.
58    pub iterations: usize,
59}
60
61/// A marker for measuring throughput of compute kernels.
62pub struct ThroughputBenchmarker {
63    cache: Cache,
64    cache_enabled: bool,
65}
66
67impl ThroughputBenchmarker {
68    /// Creates a new `ThroughputBenchmarker` with the given cache.
69    pub fn new(cache: Cache) -> Self {
70        let cache_enabled = !CubeClRuntimeConfig::get().throughput.disable_cache;
71        Self {
72            cache,
73            cache_enabled,
74        }
75    }
76
77    /// The value already measured for `key`, where the cache is on and holds one.
78    pub fn cached(&self, key: ThroughputKey) -> Option<ThroughputValue> {
79        if !self.cache_enabled {
80            return None;
81        }
82
83        self.cache.lock().get(&key).copied()
84    }
85
86    /// The value for `key`, measured by `probe` unless the cache holds one.
87    ///
88    /// # Errors
89    ///
90    /// Whatever `probe` reports. Only a measurement is cached.
91    pub fn measure(
92        &mut self,
93        key: ThroughputKey,
94        probe: impl FnOnce() -> Result<ThroughputValue, ThroughputError>,
95    ) -> Result<ThroughputValue, ThroughputError> {
96        if self.cache_enabled
97            && let Some(cached_value) = self.cache.lock().get(&key)
98        {
99            return Ok(*cached_value);
100        }
101
102        let value = probe()?;
103
104        if self.cache_enabled {
105            self.cache.lock().insert(key, value);
106        }
107
108        Ok(value)
109    }
110
111    /// Warm one shape of a kernel up to its plateau, then keep its fastest sample.
112    pub fn sample(kernel_config: KernelConfig) -> ThroughputValue {
113        let sample = kernel_config.sample;
114
115        let iterations = Self::warmup(kernel_config.min_iterations, WARMUP_BUDGET, &sample);
116        let duration =
117            Self::sample_peak_duration(iterations, &sample, SAMPLE_BUDGET, SAMPLE_PATIENCE);
118
119        ThroughputValue {
120            ops_count: kernel_config.ops_count,
121            duration,
122        }
123    }
124
125    /// Warms the device on one shape and reports the count a sample should carry.
126    /// It is the device that is warmed, not the shape, so a sweep pays once.
127    pub fn warm(kernel_config: &KernelConfig) -> usize {
128        Self::warmup(
129            kernel_config.min_iterations,
130            WARMUP_BUDGET,
131            &kernel_config.sample,
132        )
133    }
134
135    /// Keeps the fastest sample of a shape the device is already warm on, at the
136    /// count [`warm`](Self::warm) settled.
137    pub fn sample_at(kernel_config: &KernelConfig, iterations: usize) -> ThroughputValue {
138        let iterations = iterations.max(kernel_config.min_iterations).max(1);
139        let duration = Self::sample_peak_duration(
140            iterations,
141            &kernel_config.sample,
142            SAMPLE_BUDGET,
143            SAMPLE_PATIENCE,
144        );
145
146        ThroughputValue {
147            ops_count: kernel_config.ops_count,
148            duration,
149        }
150    }
151
152    /// Times one shape briefly, to order it against the others rather than to
153    /// report its peak, and reports the count the next shape starts from.
154    pub fn rank(kernel_config: &KernelConfig, iterations: usize) -> Ranked {
155        let settling = iterations.max(kernel_config.min_iterations).max(1);
156        // A first launch is what compiling and faulting in cost, so it is spent
157        // and the count settled from a second.
158        let _ = (kernel_config.sample)(settling);
159        let took = (kernel_config.sample)(settling);
160
161        let iterations = Self::retarget(settling, took)
162            .max(kernel_config.min_iterations)
163            .max(1);
164
165        let mut fastest = Duration::MAX;
166        for _ in 0..RANK_SAMPLES {
167            fastest = fastest.min((kernel_config.sample)(iterations));
168        }
169
170        let duration = fastest / iterations as u32;
171
172        Ranked {
173            value: ThroughputValue {
174                ops_count: kernel_config.ops_count,
175                duration,
176            },
177            iterations,
178        }
179    }
180
181    /// The count that would have taken [`TARGET_DURATION`]. A timer reading zero
182    /// says nothing to scale by, so the count stands.
183    fn retarget(iterations: usize, took: Duration) -> usize {
184        let took = took.as_secs_f64();
185
186        if took <= 0.0 {
187            return iterations;
188        }
189
190        let scaled = iterations as f64 * (TARGET_DURATION.as_secs_f64() / took);
191
192        if scaled.is_finite() {
193            (scaled as usize).max(1)
194        } else {
195            iterations
196        }
197    }
198
199    /// Warms up the device by running the kernel multiple times
200    /// and estimating the number of iterations needed to reach a stable duration.
201    ///
202    /// Never returns fewer than `min_iterations`, which the kernel needs to be
203    /// measuring what it claims rather than merely to be timed accurately.
204    ///
205    /// `budget` bounds the growing, not the sampling: a timer too coarse to
206    /// ever reach the target reports the same reading at every count, which
207    /// asks for a larger one each round without converging.
208    fn warmup(
209        min_iterations: usize,
210        budget: Duration,
211        sample: impl Fn(usize) -> Duration,
212    ) -> usize {
213        const MAX_WARMUP: usize = 50;
214        const MAX_ITERATIONS: usize = 1 << 24;
215        // A timer reading zero says nothing about the pass, so doubling against
216        // it converges on nothing and stops early. An iteration is a real launch
217        // for the probe that measures launches, which pays for every one.
218        const MAX_BLIND_ITERATIONS: usize = 1 << 10;
219        const PLATEAU_TOL: f64 = 0.03;
220        const PATIENCE: usize = 3;
221        let target_ms = TARGET_DURATION.as_secs_f64() * 1000.0;
222
223        let mut best = f64::INFINITY;
224        let mut stable = 0;
225        let mut iterations = min_iterations.max(1);
226        let start = Instant::now();
227        let mut plateau_start = start;
228
229        for _ in 0..MAX_WARMUP {
230            let duration = sample(iterations).as_secs_f64() * 1000.0;
231            if duration < target_ms {
232                let (extra_iters, ceiling) = if duration > 1e-6 {
233                    let duration_per_iter = duration / iterations as f64;
234                    (
235                        ((target_ms - duration) / duration_per_iter).ceil() as usize,
236                        MAX_ITERATIONS,
237                    )
238                } else {
239                    (iterations, MAX_BLIND_ITERATIONS)
240                };
241
242                let ceiling = ceiling.max(min_iterations);
243                if iterations >= ceiling || start.elapsed() >= budget {
244                    break;
245                }
246                iterations = (iterations + extra_iters.max(1)).min(ceiling);
247                best = f64::INFINITY;
248                stable = 0;
249                continue;
250            }
251
252            let duration_per_iter = duration / iterations as f64;
253            if duration_per_iter < best * (1.0 - PLATEAU_TOL) {
254                best = duration_per_iter;
255                stable = 0;
256                // Growth clears `best`, so the window restarts at the settled count.
257                plateau_start = Instant::now();
258            } else {
259                best = best.min(duration_per_iter);
260                stable += 1;
261                if stable >= PATIENCE && plateau_start.elapsed() >= PLATEAU_FLOOR {
262                    break;
263                }
264            }
265        }
266
267        iterations
268    }
269
270    /// Sample the peak throughput of the kernel by running it multiple times
271    /// and measuring the duration of each iteration.
272    fn sample_peak_duration(
273        iterations: usize,
274        sample_once: impl Fn(usize) -> Duration,
275        budget: Duration,
276        patience: usize,
277    ) -> Duration {
278        debug_assert!(
279            iterations > 0,
280            "iterations must be positive to avoid division by zero"
281        );
282
283        const MAX_SAMPLES: usize = 200;
284        const REL_TOL: f64 = 0.01;
285
286        let mut best = f64::INFINITY;
287        let mut stale = 0;
288        // Wall clock, not the sum of what the samples report: a probe whose
289        // timer reads zero would otherwise never spend any of the budget.
290        let start = Instant::now();
291
292        for _ in 0..MAX_SAMPLES {
293            let s = sample_once(iterations).as_secs_f64();
294            if s < best * (1.0 - REL_TOL) {
295                best = s;
296                stale = 0;
297            } else {
298                best = best.min(s);
299                stale += 1;
300            }
301            if stale >= patience || start.elapsed() >= budget {
302                break;
303            }
304        }
305
306        Duration::from_secs_f64(best / iterations as f64)
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use core::cell::Cell;
314
315    fn spin(duration: Duration) {
316        let start = Instant::now();
317        while start.elapsed() < duration {}
318    }
319
320    /// A device that takes as long as it reports, at whatever rate it is asked for.
321    fn timed_device(per_iter_nanos: impl Fn() -> u64) -> impl Fn(usize) -> Duration {
322        move |iterations| {
323            let duration = Duration::from_nanos(per_iter_nanos() * iterations as u64);
324            spin(duration);
325
326            duration
327        }
328    }
329
330    /// One iteration of the launch probe is a real launch, so a device whose
331    /// timer reads zero must not be answered by doubling toward the ceiling the
332    /// duration target drives.
333    #[test]
334    fn a_timer_reading_zero_does_not_climb_to_the_duration_ceiling() {
335        let iterations = ThroughputBenchmarker::warmup(1, WARMUP_BUDGET, |_| Duration::ZERO);
336
337        assert!(iterations <= 1 << 10, "climbed to {iterations}");
338    }
339
340    /// The passes a probe needs to be measuring what it claims are not the
341    /// timer's to give away.
342    #[test]
343    fn a_blind_timer_never_cuts_below_the_passes_a_probe_needs() {
344        let needed = 1 << 20;
345
346        assert!(ThroughputBenchmarker::warmup(needed, WARMUP_BUDGET, |_| Duration::ZERO) >= needed);
347    }
348
349    /// A timer too coarse to resolve the target reports the same reading at
350    /// every count, so each round divides it by a larger number and asks for a
351    /// larger one still. The iteration ceiling alone stops that only after the
352    /// rounds it takes to reach it, which the probe pays for in real launches.
353    #[test]
354    fn a_timer_that_never_reaches_the_target_stops_growing_on_the_budget() {
355        let iterations = ThroughputBenchmarker::warmup(1, Duration::from_millis(12), |_| {
356            spin(Duration::from_millis(5));
357
358            Duration::from_millis(1)
359        });
360
361        assert!(iterations < 1 << 20, "climbed to {iterations}");
362    }
363
364    #[test]
365    fn a_timer_reading_zero_still_stops_sampling() {
366        let calls = Cell::new(0);
367        let _ = ThroughputBenchmarker::sample_peak_duration(
368            1,
369            |_| {
370                calls.set(calls.get() + 1);
371                Duration::ZERO
372            },
373            SAMPLE_BUDGET,
374            SAMPLE_PATIENCE,
375        );
376
377        assert!(calls.get() < 200, "ran {} samples", calls.get());
378    }
379
380    /// Passes at one iteration count sit microseconds apart, so a plateau of
381    /// them is evidence about a moment rather than about the device.
382    #[test]
383    fn a_clock_that_lifts_inside_the_floor_does_not_release_the_warmup() {
384        let lift = Duration::from_millis(100);
385        let clock = Instant::now();
386        let lifts_once = timed_device(move || if clock.elapsed() < lift { 3000 } else { 1000 });
387
388        let start = Instant::now();
389        ThroughputBenchmarker::warmup(1, WARMUP_BUDGET, lifts_once);
390
391        assert!(
392            start.elapsed() >= lift + PLATEAU_FLOOR,
393            "released after {:?}",
394            start.elapsed()
395        );
396    }
397
398    /// A probe runs on first use of every key, so the quiet device is the cost
399    /// every one of them pays.
400    #[test]
401    fn a_steady_device_pays_the_floor_and_nothing_more() {
402        let passes = Cell::new(0);
403        let steady = timed_device(|| {
404            passes.set(passes.get() + 1);
405            1000
406        });
407
408        let start = Instant::now();
409        ThroughputBenchmarker::warmup(1, WARMUP_BUDGET, steady);
410
411        assert!(
412            start.elapsed() >= PLATEAU_FLOOR,
413            "left after {:?}",
414            start.elapsed()
415        );
416        assert!(passes.get() <= 20, "ran {} passes", passes.get());
417    }
418
419    /// Contention that outlasts the whole warmup is measured, not rejected: a
420    /// device that is genuinely slow answers the same however long it is held.
421    #[test]
422    fn a_device_slow_for_the_whole_measurement_reports_its_slow_rate() {
423        let value = ThroughputBenchmarker::sample(KernelConfig {
424            sample: Box::new(timed_device(|| 3000)),
425            ops_count: 1,
426            min_iterations: 1,
427        });
428
429        assert!(
430            (Duration::from_nanos(2900)..Duration::from_nanos(3100)).contains(&value.duration),
431            "kept {:?}",
432            value.duration
433        );
434    }
435
436    /// A sweep ranks shapes to order them, not to report them, so it must
437    /// separate a slow shape from a fast one without paying for a measurement
438    /// of each.
439    #[test]
440    fn ranking_orders_shapes_for_less_than_one_measurement() {
441        let config = |per_iter_nanos: u64| KernelConfig {
442            sample: Box::new(timed_device(move || per_iter_nanos)),
443            ops_count: 1,
444            min_iterations: 1,
445        };
446        let (fast, slow) = (config(1000), config(3000));
447
448        let iterations = ThroughputBenchmarker::warm(&fast);
449        let start = Instant::now();
450        let fast_rate = ThroughputBenchmarker::rank(&fast, iterations)
451            .value
452            .ops_per_s();
453        let slow_rate = ThroughputBenchmarker::rank(&slow, iterations)
454            .value
455            .ops_per_s();
456
457        assert!(fast_rate > slow_rate, "{fast_rate} against {slow_rate}");
458        assert!(
459            start.elapsed() < PLATEAU_FLOOR + SAMPLE_BUDGET,
460            "ranked two shapes in {:?}",
461            start.elapsed()
462        );
463    }
464
465    /// Shapes of one sweep differ in what a pass costs, and timing a fast one
466    /// over a tenth of the span would make it wear ten times the share of a
467    /// launch's fixed cost. Each settles its own count toward the target.
468    #[test]
469    fn ranking_times_each_shape_over_the_same_span() {
470        let config = |per_iter_nanos: u64| KernelConfig {
471            sample: Box::new(move |iterations| {
472                Duration::from_nanos(per_iter_nanos * iterations as u64)
473            }),
474            ops_count: 1,
475            min_iterations: 1,
476        };
477
478        let slow = ThroughputBenchmarker::rank(&config(1000), 1000);
479        let fast = ThroughputBenchmarker::rank(&config(100), 1000);
480
481        assert_eq!(slow.iterations, 20_000);
482        assert_eq!(fast.iterations, 200_000);
483    }
484
485    /// A shape's first launch carries compiling and faulting it in, which is
486    /// not what a pass of it costs. Settling the count from that ranks a shape
487    /// the sweep has not seen before below one it has.
488    #[test]
489    fn ranking_settles_its_count_after_the_first_launch() {
490        let launches = Cell::new(0);
491        let config = KernelConfig {
492            sample: Box::new(move |iterations| {
493                launches.set(launches.get() + 1);
494                let per_iter = if launches.get() == 1 { 100_000 } else { 1_000 };
495
496                Duration::from_nanos(per_iter * iterations as u64)
497            }),
498            ops_count: 1,
499            min_iterations: 1,
500        };
501
502        let ranked = ThroughputBenchmarker::rank(&config, 1_000);
503
504        assert_eq!(ranked.iterations, 20_000);
505    }
506
507    /// Every shape of a sweep is timed over the same work, or a shape that
508    /// happened to be warmed at a different count would rank on that instead.
509    #[test]
510    fn ranking_never_carries_fewer_passes_than_a_shape_needs() {
511        let needed = 64;
512        let config = KernelConfig {
513            sample: Box::new(|iterations| Duration::from_nanos(iterations as u64)),
514            ops_count: 1,
515            min_iterations: needed,
516        };
517
518        let ranked = ThroughputBenchmarker::rank(&config, 1);
519
520        assert_eq!(ranked.value.duration, Duration::from_nanos(1));
521        assert!(ranked.iterations >= needed);
522    }
523
524    /// A working timer still drives the count to the duration target.
525    #[test]
526    fn a_pass_far_under_the_target_grows_until_it_reaches_it() {
527        let iterations = ThroughputBenchmarker::warmup(1, WARMUP_BUDGET, |iterations| {
528            Duration::from_micros(iterations as u64)
529        });
530
531        assert_eq!(iterations, 20_000);
532    }
533}