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 MAX_ITERATIONS: usize = 1 << 24;
40
41const RANK_SAMPLES: usize = 3;
44
45pub struct KernelConfig {
47 pub sample: Box<dyn Fn(usize) -> Duration>,
49 pub ops_count: usize,
51 pub min_iterations: usize,
54}
55
56pub struct Ranked {
59 pub value: ThroughputValue,
62 pub iterations: usize,
64}
65
66pub struct ThroughputBenchmarker {
68 cache: Cache,
69 cache_enabled: bool,
70}
71
72impl ThroughputBenchmarker {
73 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 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 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 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 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 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 pub fn rank(kernel_config: &KernelConfig, iterations: usize) -> Ranked {
160 let settling = iterations.max(kernel_config.min_iterations).max(1);
161 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 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 fn warmup(
215 min_iterations: usize,
216 budget: Duration,
217 sample: impl Fn(usize) -> Duration,
218 ) -> usize {
219 const MAX_WARMUP: usize = 50;
220 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}