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