use std::collections::{HashMap, HashSet};
use std::sync::{LazyLock, Mutex};
use std::time::Duration;
use cubecl::benchmark::{ProfileDuration, TimingMethod};
use cubecl::client::Client;
use cubecl::features::TypeUsage;
use cubecl::prelude::*;
use cubecl::std::throughput::{measure_memory_curve, measure_peak_throughput};
use cubecl::throughput::{
self, MemoryAccess, MemoryCurve, ResourceBound, ThroughputKey, ThroughputMode, score_resources,
};
use crate::{HostData, Progress};
static TIMING_OVERRIDE: LazyLock<Option<TimingMethod>> =
LazyLock::new(|| match std::env::var("CUBEK_BENCH_TIMING").as_deref() {
Ok("device") => Some(TimingMethod::Device),
Ok("system") => Some(TimingMethod::System),
Ok(other) => panic!("CUBEK_BENCH_TIMING takes 'device' or 'system', not {other:?}"),
Err(_) => None,
});
pub fn timing_method(default: TimingMethod) -> TimingMethod {
TIMING_OVERRIDE.unwrap_or(default)
}
pub fn supports_f16_arithmetic(client: &Client) -> bool {
half::f16::supported_uses(client).contains(TypeUsage::Arithmetic)
}
pub fn profile_launch<O: Send + 'static>(
client: &Client,
scope: &str,
launch: impl FnOnce() -> Result<O, String> + Send,
) -> Result<ProfileDuration, String> {
let (launched, duration) = client
.profile(launch, scope)
.map_err(|err| format!("{err:?}"))?;
launched.map(|_| duration)
}
pub fn client() -> Client {
let device = cubecl::test_device();
device.client()
}
static PEAKS: LazyLock<Mutex<HashMap<ThroughputKey, f64>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
static CURVES: LazyLock<Mutex<HashMap<MemoryAccess, MemoryCurve>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
fn curve_ceiling(access: MemoryAccess, bytes: usize) -> f64 {
let mut curves = CURVES.lock().expect("curves mutex is not poisoned");
let curve = curves
.entry(access)
.or_insert_with(|| measure_memory_curve(&client(), access));
curve.ceiling_at(bytes as u64).unwrap_or(0.0)
}
fn peak_per_s(key: ThroughputKey) -> f64 {
let mut peaks = PEAKS.lock().expect("peaks mutex is not poisoned");
*peaks.entry(key).or_insert_with(|| {
let Ok(value) = measure_peak_throughput(&client(), key) else {
return f64::NAN;
};
match key.mode {
ThroughputMode::ComputeDirect { .. } | ThroughputMode::ComputeCmma { .. } => {
value.ops_per_s()
}
ThroughputMode::Memory(_) => value.bytes_per_s(&key),
ThroughputMode::Launch => 1.0 / value.duration_per_op().as_secs_f64(),
}
})
}
#[derive(Debug, Clone)]
pub struct ItemDescriptor {
pub id: String,
pub label: String,
}
impl ItemDescriptor {
pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
Self {
id: id.into(),
label: label.into(),
}
}
}
pub struct CatalogEntry<T> {
pub id: String,
pub label: String,
pub value: T,
}
impl<T> CatalogEntry<T> {
pub fn new(id: impl Into<String>, label: impl Into<String>, value: T) -> Self {
Self {
id: id.into(),
label: label.into(),
value,
}
}
pub fn descriptor(&self) -> ItemDescriptor {
ItemDescriptor {
id: self.id.clone(),
label: self.label.clone(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum Ceiling {
Probe(ThroughputKey),
Curve(MemoryAccess),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResourceKind {
Compute,
Read,
Write,
Launch,
}
#[derive(Debug, Clone, Copy)]
pub struct Binding {
pub resource: ResourceKind,
pub achieved_per_s: f64,
pub peak_per_s: f64,
pub fraction_of_peak: f64,
}
#[derive(Debug, Clone)]
pub struct RunSamples {
pub durations: Vec<Duration>,
pub tflops: Option<f64>,
pub binding: Option<Binding>,
}
impl RunSamples {
pub fn new(durations: Vec<Duration>) -> Self {
Self {
durations,
tflops: None,
binding: None,
}
}
pub fn with_flops(mut self, flops: f64, peak_ops_per_s: Option<f64>) -> Self {
let Some(median_secs) = self.median_secs() else {
return self;
};
let bound = ResourceBound {
amount: flops as usize,
peak_per_s: peak_ops_per_s.unwrap_or(f64::NAN),
};
let (tflops, binding) = score_bounds(median_secs, &[(ResourceKind::Compute, bound)]);
self.tflops = tflops;
self.binding = binding;
self
}
fn median_secs(&self) -> Option<f64> {
if self.durations.is_empty() {
return None;
}
let mut ns: Vec<u128> = self.durations.iter().map(|d| d.as_nanos()).collect();
ns.sort_unstable();
let median_secs = ns[ns.len() / 2] as f64 / 1e9;
(median_secs > 0.0).then_some(median_secs)
}
}
#[derive(Debug, Clone, Copy)]
pub struct CategoryWork {
pub compute: Option<ComputeWork>,
pub bytes_read: usize,
pub bytes_written: usize,
}
#[derive(Debug, Clone, Copy)]
pub struct ComputeWork {
pub ops: usize,
pub key: ThroughputKey,
}
impl ComputeWork {
pub fn direct(ops: usize, dtype: ElemType) -> Self {
Self {
ops,
key: ThroughputKey {
mode: ThroughputMode::ComputeDirect { dtype },
},
}
}
}
impl CategoryWork {
fn declared_resources(&self, timing: TimingMethod) -> Vec<(ResourceKind, usize, Ceiling)> {
let mut out = Vec::with_capacity(4);
if let Some(compute) = self.compute.filter(|c| c.ops > 0) {
out.push((
ResourceKind::Compute,
compute.ops,
Ceiling::Probe(compute.key),
));
}
if self.bytes_read > 0 {
let read = Ceiling::Curve(MemoryAccess::Read);
out.push((ResourceKind::Read, self.bytes_read, read));
}
if self.bytes_written > 0 {
let write = Ceiling::Curve(MemoryAccess::Write);
out.push((ResourceKind::Write, self.bytes_written, write));
}
if !out.is_empty() && timing == TimingMethod::System {
let launch = Ceiling::Probe(ThroughputKey {
mode: ThroughputMode::Launch,
});
out.push((ResourceKind::Launch, 1, launch));
}
out
}
fn resources(&self, timing: TimingMethod) -> Vec<(ResourceKind, ResourceBound)> {
self.declared_resources(timing)
.into_iter()
.map(|(kind, amount, ceiling)| {
let peak_per_s = match ceiling {
Ceiling::Probe(key) => peak_per_s(key),
Ceiling::Curve(access) => curve_ceiling(access, amount),
};
(kind, ResourceBound { amount, peak_per_s })
})
.collect()
}
}
fn score(mut samples: RunSamples, work: &CategoryWork, timing: TimingMethod) -> RunSamples {
let Some(median_secs) = samples.median_secs() else {
return samples;
};
let resources = work.resources(timing);
let (tflops, binding) = score_bounds(median_secs, &resources);
samples.tflops = tflops;
samples.binding = binding;
samples
}
fn score_bounds(
median_secs: f64,
resources: &[(ResourceKind, ResourceBound)],
) -> (Option<f64>, Option<Binding>) {
let bounds: Vec<ResourceBound> = resources.iter().map(|(_, bound)| *bound).collect();
let scores = score_resources(Duration::from_secs_f64(median_secs), &bounds);
let tflops = resources
.iter()
.position(|(kind, _)| *kind == ResourceKind::Compute)
.map(|idx| scores[idx].achieved_per_s / 1e12);
let binding = throughput::binding_achieved(&scores).map(|best| {
let idx = scores
.iter()
.position(|candidate| candidate == best)
.expect("binding_achieved returns an element of scores");
let (resource, bound) = resources[idx];
Binding {
resource,
achieved_per_s: best.achieved_per_s,
peak_per_s: bound.peak_per_s,
fraction_of_peak: best.fraction_of_peak,
}
});
(tflops, binding)
}
pub trait Category: Sync {
type Problem;
type Strategy;
fn id(&self) -> &'static str;
fn label(&self) -> &'static str;
fn problems(&self) -> Vec<CatalogEntry<Self::Problem>>;
fn strategies(&self) -> Vec<CatalogEntry<Self::Strategy>>;
fn bench(
&self,
strategy: &Self::Strategy,
problem: &Self::Problem,
num_samples: usize,
) -> Result<RunSamples, String>;
fn work(&self, _problem: &Self::Problem) -> Option<CategoryWork> {
None
}
fn timing_method(&self) -> TimingMethod {
crate::timing_method(TimingMethod::System)
}
fn correctness(
&self,
) -> Option<&dyn Correctness<Problem = Self::Problem, Strategy = Self::Strategy>> {
None
}
}
pub trait Correctness: Sync {
type Problem;
type Strategy;
fn kernel_result(
&self,
strategy: &Self::Strategy,
problem: &Self::Problem,
seeds: &[u64],
) -> Result<HostData, String>;
fn reference_result(
&self,
problem: &Self::Problem,
seeds: &[u64],
progress: Option<&Progress>,
) -> Result<HostData, String>;
}
pub trait BenchmarkCategory: Sync {
fn id(&self) -> &'static str;
fn label(&self) -> &'static str;
fn strategies(&self) -> Vec<ItemDescriptor>;
fn problems(&self) -> Vec<ItemDescriptor>;
fn timing_method(&self) -> TimingMethod {
TimingMethod::System
}
fn warm_peaks(&self, problem_ids: &[String]) -> usize;
fn run(
&self,
strategy_id: &str,
problem_id: &str,
num_samples: usize,
) -> Result<RunSamples, String>;
fn kernel_result(
&self,
_strategy_id: &str,
_problem_id: &str,
_seed_lhs: u64,
_seed_rhs: u64,
) -> Option<Result<HostData, String>> {
None
}
fn reference_result(
&self,
_problem_id: &str,
_seed_lhs: u64,
_seed_rhs: u64,
_progress: Option<&Progress>,
) -> Option<Result<HostData, String>> {
None
}
}
impl<C: Category> BenchmarkCategory for C {
fn id(&self) -> &'static str {
Category::id(self)
}
fn label(&self) -> &'static str {
Category::label(self)
}
fn strategies(&self) -> Vec<ItemDescriptor> {
Category::strategies(self)
.iter()
.map(CatalogEntry::descriptor)
.collect()
}
fn problems(&self) -> Vec<ItemDescriptor> {
Category::problems(self)
.iter()
.map(CatalogEntry::descriptor)
.collect()
}
fn timing_method(&self) -> TimingMethod {
Category::timing_method(self)
}
fn warm_peaks(&self, problem_ids: &[String]) -> usize {
let mut seen = HashSet::new();
for problem in Category::problems(self) {
if !problem_ids.contains(&problem.id) {
continue;
}
let Some(work) = Category::work(self, &problem.value) else {
continue;
};
for (_, amount, ceiling) in work.declared_resources(Category::timing_method(self)) {
if seen.insert(ceiling) {
match ceiling {
Ceiling::Probe(key) => peak_per_s(key),
Ceiling::Curve(access) => curve_ceiling(access, amount),
};
}
}
}
seen.len()
}
fn run(
&self,
strategy_id: &str,
problem_id: &str,
num_samples: usize,
) -> Result<RunSamples, String> {
let problems = Category::problems(self);
let problem = problems
.iter()
.find(|e| e.id == problem_id)
.ok_or_else(|| format!("unknown problem: {problem_id}"))?;
let strategies = Category::strategies(self);
let strategy = strategies
.iter()
.find(|e| e.id == strategy_id)
.ok_or_else(|| format!("unknown strategy: {strategy_id}"))?;
let samples = Category::bench(self, &strategy.value, &problem.value, num_samples)?;
Ok(match Category::work(self, &problem.value) {
Some(work) => score(samples, &work, Category::timing_method(self)),
None => samples,
})
}
fn kernel_result(
&self,
strategy_id: &str,
problem_id: &str,
seed_lhs: u64,
seed_rhs: u64,
) -> Option<Result<HostData, String>> {
let correctness = Category::correctness(self)?;
let problems = Category::problems(self);
let problem = match problems.iter().find(|e| e.id == problem_id) {
Some(p) => p,
None => return Some(Err(format!("unknown problem: {problem_id}"))),
};
let strategies = Category::strategies(self);
let strategy = match strategies.iter().find(|e| e.id == strategy_id) {
Some(s) => s,
None => return Some(Err(format!("unknown strategy: {strategy_id}"))),
};
Some(correctness.kernel_result(&strategy.value, &problem.value, &[seed_lhs, seed_rhs]))
}
fn reference_result(
&self,
problem_id: &str,
seed_lhs: u64,
seed_rhs: u64,
progress: Option<&Progress>,
) -> Option<Result<HostData, String>> {
let correctness = Category::correctness(self)?;
let problems = Category::problems(self);
let problem = match problems.iter().find(|e| e.id == problem_id) {
Some(p) => p,
None => return Some(Err(format!("unknown problem: {problem_id}"))),
};
Some(correctness.reference_result(&problem.value, &[seed_lhs, seed_rhs], progress))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn work(compute_ops: usize, bytes_read: usize, bytes_written: usize) -> CategoryWork {
CategoryWork {
compute: (compute_ops > 0)
.then(|| ComputeWork::direct(compute_ops, f32::elem_type_native())),
bytes_read,
bytes_written,
}
}
const NO_LAUNCH: TimingMethod = TimingMethod::Device;
#[test]
fn zero_work_declares_no_resources() {
let resources = work(0, 0, 0).resources(TimingMethod::System);
assert!(resources.is_empty());
let (tflops, binding) = score_bounds(1.0, &resources);
assert_eq!(tflops, None);
assert!(binding.is_none());
}
#[test]
fn read_only_work_declares_a_single_read_resource() {
let resources = work(0, 4096, 0).declared_resources(NO_LAUNCH);
assert_eq!(resources.len(), 1);
assert_eq!(resources[0].0, ResourceKind::Read);
assert_eq!(resources[0].1, 4096);
}
#[test]
fn write_only_work_declares_a_single_write_resource() {
let resources = work(0, 0, 2048).declared_resources(NO_LAUNCH);
assert_eq!(resources.len(), 1);
assert_eq!(resources[0].0, ResourceKind::Write);
assert_eq!(resources[0].1, 2048);
}
#[test]
fn mixed_work_declares_compute_read_and_write_resources() {
let resources = work(1_000_000, 4096, 1024).declared_resources(NO_LAUNCH);
assert_eq!(resources.len(), 3);
assert_eq!(
resources.iter().map(|(kind, ..)| *kind).collect::<Vec<_>>(),
vec![
ResourceKind::Compute,
ResourceKind::Read,
ResourceKind::Write
]
);
assert_eq!(resources[0].1, 1_000_000);
assert_eq!(resources[1].1, 4096);
assert_eq!(resources[2].1, 1024);
}
#[test]
fn memory_resources_share_one_curve_per_access() {
let small = work(0, 16384, 4096).declared_resources(NO_LAUNCH);
let large = work(0, 1 << 30, 1 << 30).declared_resources(NO_LAUNCH);
assert_eq!(small[0].2, Ceiling::Curve(MemoryAccess::Read));
assert_eq!(small[1].2, Ceiling::Curve(MemoryAccess::Write));
assert_eq!(small[0].2, large[0].2);
assert_eq!(small[1].2, large[1].2);
assert_ne!(small[0].2, small[1].2);
assert_eq!(small[0].1, 16384);
assert_eq!(large[0].1, 1 << 30);
}
#[test]
fn score_bounds_with_no_resources_is_unscored() {
let (tflops, binding) = score_bounds(1.0, &[]);
assert_eq!(tflops, None);
assert!(binding.is_none());
}
#[test]
fn score_bounds_read_only_binds_on_read() {
let bound = ResourceBound {
amount: 1000,
peak_per_s: 500.0,
};
let (tflops, binding) = score_bounds(2.0, &[(ResourceKind::Read, bound)]);
assert_eq!(tflops, None);
let binding = binding.expect("a usable peak binds");
assert_eq!(binding.resource, ResourceKind::Read);
assert_eq!(binding.achieved_per_s, 500.0);
assert_eq!(binding.peak_per_s, 500.0);
assert_eq!(binding.fraction_of_peak, 1.0);
}
#[test]
fn score_bounds_write_only_binds_on_write() {
let bound = ResourceBound {
amount: 300,
peak_per_s: 100.0,
};
let (_, binding) = score_bounds(1.0, &[(ResourceKind::Write, bound)]);
let binding = binding.expect("a usable peak binds");
assert_eq!(binding.resource, ResourceKind::Write);
assert_eq!(binding.fraction_of_peak, 3.0);
}
#[test]
fn score_bounds_mixed_picks_the_slower_at_peak_resource() {
let duration = 1.0;
let compute = ResourceBound {
amount: 200,
peak_per_s: 1000.0,
}; let read = ResourceBound {
amount: 900_000,
peak_per_s: 1_000_000.0,
}; let write = ResourceBound {
amount: 100_000,
peak_per_s: 200_000.0,
};
let resources = vec![
(ResourceKind::Compute, compute),
(ResourceKind::Read, read),
(ResourceKind::Write, write),
];
let (tflops, binding) = score_bounds(duration, &resources);
assert_eq!(tflops, Some(200.0 / 1e12));
let binding = binding.expect("a usable peak binds");
assert_eq!(binding.resource, ResourceKind::Read);
assert_eq!(binding.fraction_of_peak, 0.9);
}
#[test]
fn launch_is_declared_only_when_the_timing_measures_it() {
let timed = work(0, 0, 2048).declared_resources(TimingMethod::System);
assert_eq!(timed.len(), 2);
assert_eq!(timed[1].0, ResourceKind::Launch);
assert_eq!(timed[1].1, 1);
assert_eq!(
timed[1].2,
Ceiling::Probe(ThroughputKey {
mode: ThroughputMode::Launch
})
);
let untimed = work(0, 0, 2048).declared_resources(TimingMethod::Device);
assert_eq!(untimed.len(), 1);
assert_eq!(untimed[0].0, ResourceKind::Write);
}
#[test]
fn score_bounds_binds_on_launch_for_a_run_that_barely_outlasts_it() {
let write = ResourceBound {
amount: 16_384,
peak_per_s: 20e9,
}; let launch = ResourceBound {
amount: 1,
peak_per_s: 125_000.0,
};
let resources = vec![(ResourceKind::Write, write), (ResourceKind::Launch, launch)];
let (_, binding) = score_bounds(10e-6, &resources);
let binding = binding.expect("a usable peak binds");
assert_eq!(binding.resource, ResourceKind::Launch);
assert!((binding.fraction_of_peak - 0.8).abs() < 1e-9);
}
#[test]
fn score_bounds_ignores_launch_once_the_run_outgrows_it() {
let write = ResourceBound {
amount: 2_000_000_000,
peak_per_s: 20e9,
}; let launch = ResourceBound {
amount: 1,
peak_per_s: 125_000.0,
};
let resources = vec![(ResourceKind::Write, write), (ResourceKind::Launch, launch)];
let (_, binding) = score_bounds(0.1, &resources);
let binding = binding.expect("a usable peak binds");
assert_eq!(binding.resource, ResourceKind::Write);
assert_eq!(binding.fraction_of_peak, 1.0);
}
}