cubecl_runtime/throughput/
benchmarker.rs1use 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
13const WARMUP_BUDGET: Duration = Duration::from_secs(2);
17
18const PLATEAU_FLOOR: Duration = Duration::from_millis(250);
21
22const SAMPLE_BUDGET: Duration = Duration::from_millis(200);
25
26const SAMPLE_PATIENCE: usize = 12;
30
31const TARGET_DURATION: Duration = Duration::from_millis(20);
35
36const RANK_SAMPLES: usize = 3;
39
40pub struct KernelConfig {
42 pub sample: Box<dyn Fn(usize) -> Duration>,
44 pub ops_count: usize,
46 pub min_iterations: usize,
49}
50
51pub struct Ranked {
54 pub value: ThroughputValue,
57 pub iterations: usize,
59}
60
61pub struct ThroughputBenchmarker {
63 cache: Cache,
64 cache_enabled: bool,
65}
66
67impl ThroughputBenchmarker {
68 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 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 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 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 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 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 pub fn rank(kernel_config: &KernelConfig, iterations: usize) -> Ranked {
155 let settling = iterations.max(kernel_config.min_iterations).max(1);
156 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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}