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 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 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 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 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 pub fn rank(kernel_config: &KernelConfig, iterations: usize) -> Ranked {
146 let settling = iterations.max(kernel_config.min_iterations).max(1);
147 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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}