#[cfg(std_io)]
use alloc::format;
use alloc::sync::Arc;
use alloc::vec::Vec;
use cubecl_common::profile::ProfileDuration;
use derive_more::Display;
use core::time::Duration;
use cubecl_environment::sync::Mutex;
use alloc::string::{String, ToString};
use cubecl_common::benchmark::{BenchmarkComputations, BenchmarkDurations};
use crate::config::Logger;
#[cfg(std_io)]
use crate::config::autotune::AutotuneLogLevel;
use crate::server::LaunchError;
use crate::tune::{AutotuneLoggerExt, AutotuneResult, TimeBound, TuneCache, tune_benchmark};
use crate::{client::ComputeClient, runtime::Runtime};
use cubecl_environment::config::RuntimeConfig;
use super::{
AutotuneKey, AutotuneOutput, TunableSet, TuneCacheResult, TuneFn, TuneInputs, TunePlan,
};
#[derive(Debug)]
pub struct Tuner<K: AutotuneKey> {
cache: Arc<Mutex<TuneCache<K>>>,
logger: Arc<Mutex<Logger>>,
}
#[cfg_attr(autotune_persistence, derive(serde::Serialize, serde::Deserialize))]
#[derive(new, Debug, Clone, PartialEq, Eq)]
pub struct AutotuneOutcome {
pub name: String,
pub index: usize,
pub computation: BenchmarkComputations,
}
impl core::fmt::Display for AutotuneOutcome {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(
f,
"Autotune[{}] name {} => {:?}",
self.index, self.name, self.computation
)
}
}
#[derive(Clone, Display)]
#[cfg_attr(autotune_persistence, derive(serde::Serialize, serde::Deserialize))]
pub enum AutotuneError {
#[display("{name}: An unknown error happened.\n{err}")]
Unknown {
name: String,
err: String,
},
#[display("{name}: All samples are invalid.")]
InvalidSamples {
name: String,
},
#[display("No autotune was flagged as valid for the problem.\n{context}")]
NoValidKernelFound {
context: String,
},
#[display("{name}: The autotune is skipped manually.")]
Skip {
name: String,
},
Launch(LaunchError),
}
impl core::fmt::Debug for AutotuneError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "{self}")
}
}
impl From<LaunchError> for AutotuneError {
fn from(value: LaunchError) -> Self {
Self::Launch(value)
}
}
struct PendingBench {
index: usize,
name: String,
profiles: Vec<ProfileDuration>,
launch: Option<Duration>,
}
struct TuneJob<'t, 'i, K: AutotuneKey, F: TuneInputs, Out> {
key: K,
autotunables: Vec<&'t TuneFn<F, Out>>,
test_inputs: <F as TuneInputs>::At<'i>,
plan: TunePlan,
results: Vec<AutotuneResult>,
#[cfg(any(not(target_family = "wasm"), autotune_persistence))]
limit: Option<Duration>,
#[cfg(autotune_persistence)]
bounds: Option<crate::tune::Bounds>,
#[cfg(not(target_family = "wasm"))]
short_circuit: bool,
#[cfg(autotune_persistence)]
checksum: String,
log_context: Option<crate::tune::AutotuneLogContext>,
}
impl<K: AutotuneKey, F: TuneInputs, Out> TuneJob<'_, '_, K, F, Out> {
fn into_request(self, pending: Vec<PendingBench>, decided: Option<usize>) -> TuneRequest<K> {
TuneRequest {
key: self.key,
results: self.results,
#[cfg(autotune_persistence)]
checksum: self.checksum,
log_context: self.log_context,
pending,
decided,
#[cfg(autotune_persistence)]
limit: self.limit,
#[cfg(autotune_persistence)]
bounds: self.bounds,
}
}
}
struct TuneRequest<K: AutotuneKey> {
key: K,
results: Vec<AutotuneResult>,
#[cfg(autotune_persistence)]
checksum: String,
log_context: Option<crate::tune::AutotuneLogContext>,
pending: Vec<PendingBench>,
decided: Option<usize>,
#[cfg(autotune_persistence)]
limit: Option<Duration>,
#[cfg(autotune_persistence)]
bounds: Option<crate::tune::Bounds>,
}
#[allow(clippy::new_without_default)]
impl<K: AutotuneKey> Tuner<K> {
pub fn new(name: &str, device_id: &str) -> Self {
Self {
cache: Arc::new(Mutex::new(TuneCache::new(name, device_id))),
logger: Arc::new(Mutex::new(Logger::new())),
}
}
pub fn fastest(&self, key: &K) -> TuneCacheResult {
#[cfg_attr(not(autotune_persistence), allow(unused_mut))]
let mut cache = self.cache.lock();
#[cfg(autotune_persistence)]
cache.reset_if_environment_switched();
cache.fastest(key)
}
pub fn logger(&self) -> Arc<Mutex<Logger>> {
self.logger.clone()
}
pub fn check_tune<'a, R: Runtime, F: TuneInputs, Out: AutotuneOutput>(
&self,
key: &K,
inputs: &F::At<'a>,
tunables: &TunableSet<K, F, Out>,
#[cfg_attr(not(autotune_persistence), allow(unused))] checksum: impl FnOnce() -> String
+ Send
+ Sync,
client: &ComputeClient<R>,
mut log_context: Option<crate::tune::AutotuneLogContext>,
) -> TuneCacheResult
where
<F as TuneInputs>::At<'a>: Clone + Send,
{
{
let mut cache = self.cache.lock();
#[cfg(autotune_persistence)]
cache.reset_if_environment_switched();
let cur = cache.fastest(key);
#[cfg(autotune_persistence)]
let cur = if matches!(cur, TuneCacheResult::Miss) {
cache.sync_persistent();
cache.fastest(key)
} else {
cur
};
#[cfg(autotune_persistence)]
let cur = if matches!(cur, TuneCacheResult::Unchecked) {
let mut log = self.logger.lock();
let checksum = checksum();
if let AutotuneLogLevel::Full = log.log_level_autotune() {
log.log_autotune(&format!("validate checksum key={key}, checksum={checksum}"));
}
cache.validate_checksum(key, &checksum)
} else {
cur
};
match cur {
TuneCacheResult::Hit { .. } | TuneCacheResult::Pending => return cur,
TuneCacheResult::Miss | TuneCacheResult::Unchecked => {
cache.mark_pending(key.clone())
}
}
}
log::info!("Tuning {key}");
let autotunables = tunables.autotunables().collect::<Vec<_>>();
let results: Vec<AutotuneResult> = autotunables
.iter()
.map(|a| {
AutotuneResult::error(AutotuneError::Skip {
name: a.name.to_string(),
})
})
.collect();
#[cfg(autotune_persistence)]
let checksum = tunables.compute_checksum();
if results.len() == 1 {
self.cache.lock().cache_insert(key.clone(), 0);
return TuneCacheResult::Hit { fastest_index: 0 };
}
let test_inputs = tunables.generate_inputs(key, inputs);
let plan = tunables.plan(key);
let bounds = tunables.bounds(key, inputs);
let limit = bounds.as_ref().and_then(|bounds| bounds.time_limit());
log_context.set_bounds(bounds.clone());
log_context.set_limit(limit);
#[cfg(not(target_family = "wasm"))]
let short_circuit = limit.is_some()
&& tunables.is_short_circuit_enabled()
&& !crate::config::CubeClRuntimeConfig::get()
.autotune
.disable_short_circuit;
let job = TuneJob {
key: key.clone(),
autotunables,
test_inputs,
plan,
results,
#[cfg(any(not(target_family = "wasm"), autotune_persistence))]
limit,
#[cfg(autotune_persistence)]
bounds,
#[cfg(not(target_family = "wasm"))]
short_circuit,
#[cfg(autotune_persistence)]
checksum,
log_context,
};
#[cfg(not(target_family = "wasm"))]
if crate::config::CubeClRuntimeConfig::get()
.autotune
.bench
.adaptive
{
return self.tune_adaptive(job, client);
}
self.tune_fixed_samples(job, client)
}
#[cfg(not(target_family = "wasm"))]
fn tune_adaptive<'i, R: Runtime, F: TuneInputs, Out: AutotuneOutput>(
&self,
mut job: TuneJob<'_, 'i, K, F, Out>,
client: &ComputeClient<R>,
) -> TuneCacheResult
where
<F as TuneInputs>::At<'i>: Clone + Send,
{
let schedule = crate::tune::schedule::Schedule {
config: crate::config::CubeClRuntimeConfig::get()
.autotune
.bench
.clone(),
limit: job.limit,
short_circuit: job.short_circuit,
track_steps: job.log_context.is_some(),
};
let outcome = schedule.run_plan(
&job.key,
&mut job.plan,
&job.autotunables,
&job.test_inputs,
client,
&mut job.results,
);
for (name, duration) in outcome.steps {
job.log_context.push_tuning_step(name, duration);
}
if let Some(name) = outcome.short_circuit {
job.log_context.push_short_circuit(name);
}
let request = job.into_request(Vec::new(), outcome.decided);
cubecl_environment::future::block_on(process_request(request, &self.cache, &self.logger))
}
fn tune_fixed_samples<'i, R: Runtime, F: TuneInputs, Out: AutotuneOutput>(
&self,
mut job: TuneJob<'_, 'i, K, F, Out>,
client: &ComputeClient<R>,
) -> TuneCacheResult
where
<F as TuneInputs>::At<'i>: Clone + Send,
{
#[cfg(not(target_family = "wasm"))]
let mut batch_success = false;
#[cfg(target_family = "wasm")]
let batch_success = false;
let mut pending = Vec::<PendingBench>::new();
loop {
let tunable_indices = job.plan.next();
if tunable_indices.is_empty() {
let key = &job.key;
panic!(
"Can't execute the autotune plan for key: {key:?}\n - plan: {:?}\n - results: {:?}",
job.plan, job.results
);
}
for index in tunable_indices {
let op = job.autotunables[index];
let start_time = job
.log_context
.is_some()
.then(cubecl_common::profile::Instant::now);
match tune_benchmark(op, job.test_inputs.clone(), client.clone()) {
Ok(profiles) => {
let bench = PendingBench {
index,
name: op.name.clone(),
profiles,
launch: start_time.map(|start| start.elapsed()),
};
#[cfg(not(target_family = "wasm"))]
if job.short_circuit {
let result = cubecl_environment::future::block_on(resolve_bench(bench));
let close_enough = result
.outcome
.as_ref()
.is_ok_and(|out| out.computation.median <= job.limit.unwrap());
batch_success |= result.outcome.is_ok();
job.results[index] = result;
if let Some(start) = start_time {
job.log_context
.push_tuning_step(op.name.to_string(), start.elapsed());
}
if close_enough {
job.log_context.push_short_circuit(op.name.to_string());
break;
}
continue;
}
pending.push(bench);
}
Err(err) => {
job.results[index] = AutotuneResult::error(err);
if let Some(start) = start_time {
job.log_context
.push_tuning_step(op.name.to_string(), start.elapsed());
}
}
}
}
#[cfg(not(target_family = "wasm"))]
if !pending.is_empty() || batch_success {
break;
}
#[cfg(target_family = "wasm")]
if !pending.is_empty() {
break;
}
}
let request = job.into_request(pending, None);
#[cfg(target_family = "wasm")]
{
let cache = self.cache.clone();
let logger = self.logger.clone();
wasm_bindgen_futures::spawn_local(async move {
process_request(request, &cache, &logger).await;
});
return TuneCacheResult::Pending;
}
#[cfg(not(target_family = "wasm"))]
cubecl_environment::future::block_on(process_request(request, &self.cache, &self.logger))
}
}
async fn resolve_bench(bench: PendingBench) -> AutotuneResult {
let PendingBench {
index,
name,
profiles,
launch: _,
} = bench;
let Some(first) = profiles.first() else {
return AutotuneResult::error(AutotuneError::Unknown {
name: name.to_string(),
err: "No profiling available".to_string(),
});
};
let timing_method = first.timing_method();
let durations: Vec<Duration> =
futures_util::future::join_all(profiles.into_iter().map(ProfileDuration::resolve))
.await
.into_iter()
.map(|ticks| ticks.duration())
.collect();
AutotuneResult::success(AutotuneOutcome::new(
name,
index,
BenchmarkComputations::new(&BenchmarkDurations::from_durations(
timing_method,
durations,
)),
))
}
async fn process_request<K: AutotuneKey>(
request: TuneRequest<K>,
cache: &Mutex<TuneCache<K>>,
logger: &Mutex<Logger>,
) -> TuneCacheResult {
let TuneRequest {
key,
mut results,
#[cfg(autotune_persistence)]
checksum,
mut log_context,
pending,
decided,
#[cfg(autotune_persistence)]
limit,
#[cfg(autotune_persistence)]
bounds,
} = request;
let resolved = futures_util::future::join_all(pending.into_iter().map(|bench| {
let index = bench.index;
let name = bench.name.clone();
let launch = bench.launch;
async move {
let started = cubecl_common::profile::Instant::now();
let result = resolve_bench(bench).await;
let step = launch.map(|launch| (name, launch + started.elapsed()));
(index, step, result)
}
}))
.await;
for (index, step, result) in resolved {
if let Some((name, duration)) = step {
log_context.push_tuning_step(name, duration);
}
results[index] = result;
}
results.sort_by(|a, b| {
let a = a
.outcome
.as_ref()
.map(|r| r.computation.score())
.unwrap_or(u64::MAX);
let b = b
.outcome
.as_ref()
.map(|r| r.computation.score())
.unwrap_or(u64::MAX);
a.cmp(&b)
});
let fastest_index = match decided {
Some(index) => index,
None => {
results
.first()
.expect("At least one kernel needed.")
.outcome
.as_ref()
.expect("At least one kernel has to succeed.")
.index
}
};
{
log_context.log_result(&mut logger.lock(), &key, &results);
cache.lock().cache_insert(key.clone(), fastest_index);
#[cfg(autotune_persistence)]
cache.lock().persistent_cache_insert(
key,
checksum,
crate::tune::PersistentCacheValue {
fastest_index,
results,
bounds,
limit,
},
);
}
TuneCacheResult::Hit { fastest_index }
}
#[cfg(feature = "autotune-checks")]
pub(crate) fn check_autotune_outputs<O: AutotuneOutput>(
mut checks_outputs: Vec<(String, Result<O, AutotuneError>)>,
) -> Vec<crate::tune::log::CheckResult> {
if checks_outputs.is_empty() {
return Vec::new();
}
let reference_idx = checks_outputs
.iter()
.position(|(_, res)| res.is_ok())
.unwrap_or(checks_outputs.len() - 1);
let reference = checks_outputs.remove(reference_idx);
let reference_result = reference.1;
#[cfg(std_io)]
let reference_name = reference.0;
let is_recording = is_recording_enabled();
#[cfg(std_io)]
{
let reference_passed = reference_result.is_ok();
let mut check_results = execute_checks(checks_outputs, reference_result, is_recording);
check_results.push(crate::tune::log::CheckResult {
name: reference_name,
passed: reference_passed,
});
check_results
}
#[cfg(not(std_io))]
{
execute_checks(checks_outputs, reference_result, is_recording)
}
}
#[cfg(feature = "autotune-checks")]
fn is_recording_enabled() -> bool {
crate::config::CubeClRuntimeConfig::get()
.autotune
.recording_enabled()
}
#[cfg(feature = "autotune-checks")]
fn execute_checks<O: AutotuneOutput>(
checks_outputs: Vec<(String, Result<O, AutotuneError>)>,
reference_result: Result<O, AutotuneError>,
is_recording: bool,
) -> Vec<crate::tune::log::CheckResult> {
let mut check_results = Vec::new();
let Ok(reference) = reference_result else {
for (name, _) in checks_outputs.into_iter() {
check_results.push(crate::tune::log::CheckResult {
name,
passed: false,
});
}
return check_results;
};
for (name, other_result) in checks_outputs.into_iter() {
if let Ok(other) = other_result {
let passed = check_equivalence(&reference, other, is_recording);
check_results.push(crate::tune::log::CheckResult { name, passed });
} else {
check_results.push(crate::tune::log::CheckResult {
name,
passed: false,
});
}
}
check_results
}
#[cfg(feature = "autotune-checks")]
fn check_equivalence<O: AutotuneOutput>(reference: &O, other: O, is_recording: bool) -> bool {
if is_recording {
#[cfg(std_io)]
{
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
reference.check_equivalence(other);
}))
.is_ok()
}
#[cfg(not(std_io))]
{
reference.check_equivalence(other);
true
}
} else {
reference.check_equivalence(other);
true
}
}