use crate::{
config::CubeClRuntimeConfig,
throughput::{ThroughputCache, ThroughputError, ThroughputKey, ThroughputValue},
};
use alloc::boxed::Box;
use alloc::sync::Arc;
use cubecl_common::profile::{Duration, Instant};
use cubecl_environment::config::RuntimeConfig;
use cubecl_environment::sync::Mutex;
type Cache = Arc<Mutex<ThroughputCache>>;
const WARMUP_BUDGET: Duration = Duration::from_secs(2);
const PLATEAU_FLOOR: Duration = Duration::from_millis(250);
const SAMPLE_BUDGET: Duration = Duration::from_millis(200);
const SAMPLE_PATIENCE: usize = 12;
const TARGET_DURATION: Duration = Duration::from_millis(20);
const RANK_SAMPLES: usize = 3;
pub struct KernelConfig {
pub sample: Box<dyn Fn(usize) -> Duration>,
pub ops_count: usize,
pub min_iterations: usize,
}
pub struct Ranked {
pub value: ThroughputValue,
pub iterations: usize,
}
pub struct ThroughputBenchmarker {
cache: Cache,
cache_enabled: bool,
}
impl ThroughputBenchmarker {
pub fn new(cache: Cache) -> Self {
let cache_enabled = !CubeClRuntimeConfig::get().throughput.disable_cache;
Self {
cache,
cache_enabled,
}
}
pub fn measure(
&mut self,
key: ThroughputKey,
probe: impl FnOnce() -> Result<ThroughputValue, ThroughputError>,
) -> Result<ThroughputValue, ThroughputError> {
if self.cache_enabled
&& let Some(cached_value) = self.cache.lock().get(&key)
{
return Ok(*cached_value);
}
let value = probe()?;
if self.cache_enabled {
self.cache.lock().insert(key, value);
}
Ok(value)
}
pub fn sample(kernel_config: KernelConfig) -> ThroughputValue {
let sample = kernel_config.sample;
let iterations = Self::warmup(kernel_config.min_iterations, WARMUP_BUDGET, &sample);
let duration =
Self::sample_peak_duration(iterations, &sample, SAMPLE_BUDGET, SAMPLE_PATIENCE);
ThroughputValue {
ops_count: kernel_config.ops_count,
duration,
}
}
pub fn warm(kernel_config: &KernelConfig) -> usize {
Self::warmup(
kernel_config.min_iterations,
WARMUP_BUDGET,
&kernel_config.sample,
)
}
pub fn sample_at(kernel_config: &KernelConfig, iterations: usize) -> ThroughputValue {
let iterations = iterations.max(kernel_config.min_iterations).max(1);
let duration = Self::sample_peak_duration(
iterations,
&kernel_config.sample,
SAMPLE_BUDGET,
SAMPLE_PATIENCE,
);
ThroughputValue {
ops_count: kernel_config.ops_count,
duration,
}
}
pub fn rank(kernel_config: &KernelConfig, iterations: usize) -> Ranked {
let settling = iterations.max(kernel_config.min_iterations).max(1);
let _ = (kernel_config.sample)(settling);
let took = (kernel_config.sample)(settling);
let iterations = Self::retarget(settling, took)
.max(kernel_config.min_iterations)
.max(1);
let mut fastest = Duration::MAX;
for _ in 0..RANK_SAMPLES {
fastest = fastest.min((kernel_config.sample)(iterations));
}
let duration = fastest / iterations as u32;
Ranked {
value: ThroughputValue {
ops_count: kernel_config.ops_count,
duration,
},
iterations,
}
}
fn retarget(iterations: usize, took: Duration) -> usize {
let took = took.as_secs_f64();
if took <= 0.0 {
return iterations;
}
let scaled = iterations as f64 * (TARGET_DURATION.as_secs_f64() / took);
if scaled.is_finite() {
(scaled as usize).max(1)
} else {
iterations
}
}
fn warmup(
min_iterations: usize,
budget: Duration,
sample: impl Fn(usize) -> Duration,
) -> usize {
const MAX_WARMUP: usize = 50;
const MAX_ITERATIONS: usize = 1 << 24;
const MAX_BLIND_ITERATIONS: usize = 1 << 10;
const PLATEAU_TOL: f64 = 0.03;
const PATIENCE: usize = 3;
let target_ms = TARGET_DURATION.as_secs_f64() * 1000.0;
let mut best = f64::INFINITY;
let mut stable = 0;
let mut iterations = min_iterations.max(1);
let start = Instant::now();
let mut plateau_start = start;
for _ in 0..MAX_WARMUP {
let duration = sample(iterations).as_secs_f64() * 1000.0;
if duration < target_ms {
let (extra_iters, ceiling) = if duration > 1e-6 {
let duration_per_iter = duration / iterations as f64;
(
((target_ms - duration) / duration_per_iter).ceil() as usize,
MAX_ITERATIONS,
)
} else {
(iterations, MAX_BLIND_ITERATIONS)
};
let ceiling = ceiling.max(min_iterations);
if iterations >= ceiling || start.elapsed() >= budget {
break;
}
iterations = (iterations + extra_iters.max(1)).min(ceiling);
best = f64::INFINITY;
stable = 0;
continue;
}
let duration_per_iter = duration / iterations as f64;
if duration_per_iter < best * (1.0 - PLATEAU_TOL) {
best = duration_per_iter;
stable = 0;
plateau_start = Instant::now();
} else {
best = best.min(duration_per_iter);
stable += 1;
if stable >= PATIENCE && plateau_start.elapsed() >= PLATEAU_FLOOR {
break;
}
}
}
iterations
}
fn sample_peak_duration(
iterations: usize,
sample_once: impl Fn(usize) -> Duration,
budget: Duration,
patience: usize,
) -> Duration {
debug_assert!(
iterations > 0,
"iterations must be positive to avoid division by zero"
);
const MAX_SAMPLES: usize = 200;
const REL_TOL: f64 = 0.01;
let mut best = f64::INFINITY;
let mut stale = 0;
let start = Instant::now();
for _ in 0..MAX_SAMPLES {
let s = sample_once(iterations).as_secs_f64();
if s < best * (1.0 - REL_TOL) {
best = s;
stale = 0;
} else {
best = best.min(s);
stale += 1;
}
if stale >= patience || start.elapsed() >= budget {
break;
}
}
Duration::from_secs_f64(best / iterations as f64)
}
}
#[cfg(test)]
mod tests {
use super::*;
use core::cell::Cell;
fn spin(duration: Duration) {
let start = Instant::now();
while start.elapsed() < duration {}
}
fn timed_device(per_iter_nanos: impl Fn() -> u64) -> impl Fn(usize) -> Duration {
move |iterations| {
let duration = Duration::from_nanos(per_iter_nanos() * iterations as u64);
spin(duration);
duration
}
}
#[test]
fn a_timer_reading_zero_does_not_climb_to_the_duration_ceiling() {
let iterations = ThroughputBenchmarker::warmup(1, WARMUP_BUDGET, |_| Duration::ZERO);
assert!(iterations <= 1 << 10, "climbed to {iterations}");
}
#[test]
fn a_blind_timer_never_cuts_below_the_passes_a_probe_needs() {
let needed = 1 << 20;
assert!(ThroughputBenchmarker::warmup(needed, WARMUP_BUDGET, |_| Duration::ZERO) >= needed);
}
#[test]
fn a_timer_that_never_reaches_the_target_stops_growing_on_the_budget() {
let iterations = ThroughputBenchmarker::warmup(1, Duration::from_millis(12), |_| {
spin(Duration::from_millis(5));
Duration::from_millis(1)
});
assert!(iterations < 1 << 20, "climbed to {iterations}");
}
#[test]
fn a_timer_reading_zero_still_stops_sampling() {
let calls = Cell::new(0);
let _ = ThroughputBenchmarker::sample_peak_duration(
1,
|_| {
calls.set(calls.get() + 1);
Duration::ZERO
},
SAMPLE_BUDGET,
SAMPLE_PATIENCE,
);
assert!(calls.get() < 200, "ran {} samples", calls.get());
}
#[test]
fn a_clock_that_lifts_inside_the_floor_does_not_release_the_warmup() {
let lift = Duration::from_millis(100);
let clock = Instant::now();
let lifts_once = timed_device(move || if clock.elapsed() < lift { 3000 } else { 1000 });
let start = Instant::now();
ThroughputBenchmarker::warmup(1, WARMUP_BUDGET, lifts_once);
assert!(
start.elapsed() >= lift + PLATEAU_FLOOR,
"released after {:?}",
start.elapsed()
);
}
#[test]
fn a_steady_device_pays_the_floor_and_nothing_more() {
let passes = Cell::new(0);
let steady = timed_device(|| {
passes.set(passes.get() + 1);
1000
});
let start = Instant::now();
ThroughputBenchmarker::warmup(1, WARMUP_BUDGET, steady);
assert!(
start.elapsed() >= PLATEAU_FLOOR,
"left after {:?}",
start.elapsed()
);
assert!(passes.get() <= 20, "ran {} passes", passes.get());
}
#[test]
fn a_device_slow_for_the_whole_measurement_reports_its_slow_rate() {
let value = ThroughputBenchmarker::sample(KernelConfig {
sample: Box::new(timed_device(|| 3000)),
ops_count: 1,
min_iterations: 1,
});
assert!(
(Duration::from_nanos(2900)..Duration::from_nanos(3100)).contains(&value.duration),
"kept {:?}",
value.duration
);
}
#[test]
fn ranking_orders_shapes_for_less_than_one_measurement() {
let config = |per_iter_nanos: u64| KernelConfig {
sample: Box::new(timed_device(move || per_iter_nanos)),
ops_count: 1,
min_iterations: 1,
};
let (fast, slow) = (config(1000), config(3000));
let iterations = ThroughputBenchmarker::warm(&fast);
let start = Instant::now();
let fast_rate = ThroughputBenchmarker::rank(&fast, iterations)
.value
.ops_per_s();
let slow_rate = ThroughputBenchmarker::rank(&slow, iterations)
.value
.ops_per_s();
assert!(fast_rate > slow_rate, "{fast_rate} against {slow_rate}");
assert!(
start.elapsed() < PLATEAU_FLOOR + SAMPLE_BUDGET,
"ranked two shapes in {:?}",
start.elapsed()
);
}
#[test]
fn ranking_times_each_shape_over_the_same_span() {
let config = |per_iter_nanos: u64| KernelConfig {
sample: Box::new(move |iterations| {
Duration::from_nanos(per_iter_nanos * iterations as u64)
}),
ops_count: 1,
min_iterations: 1,
};
let slow = ThroughputBenchmarker::rank(&config(1000), 1000);
let fast = ThroughputBenchmarker::rank(&config(100), 1000);
assert_eq!(slow.iterations, 20_000);
assert_eq!(fast.iterations, 200_000);
}
#[test]
fn ranking_settles_its_count_after_the_first_launch() {
let launches = Cell::new(0);
let config = KernelConfig {
sample: Box::new(move |iterations| {
launches.set(launches.get() + 1);
let per_iter = if launches.get() == 1 { 100_000 } else { 1_000 };
Duration::from_nanos(per_iter * iterations as u64)
}),
ops_count: 1,
min_iterations: 1,
};
let ranked = ThroughputBenchmarker::rank(&config, 1_000);
assert_eq!(ranked.iterations, 20_000);
}
#[test]
fn ranking_never_carries_fewer_passes_than_a_shape_needs() {
let needed = 64;
let config = KernelConfig {
sample: Box::new(|iterations| Duration::from_nanos(iterations as u64)),
ops_count: 1,
min_iterations: needed,
};
let ranked = ThroughputBenchmarker::rank(&config, 1);
assert_eq!(ranked.value.duration, Duration::from_nanos(1));
assert!(ranked.iterations >= needed);
}
#[test]
fn a_pass_far_under_the_target_grows_until_it_reaches_it() {
let iterations = ThroughputBenchmarker::warmup(1, WARMUP_BUDGET, |iterations| {
Duration::from_micros(iterations as u64)
});
assert_eq!(iterations, 20_000);
}
}