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