Skip to main content

cubecl_runtime/tune/
tune_benchmark.rs

1use super::{AutotuneError, Evictor, TuneFn, TuneInputs};
2use crate::client::Client;
3use alloc::string::ToString;
4use alloc::vec::Vec;
5use cubecl_common::profile::ProfileDuration;
6use cubecl_environment::config::RuntimeConfig;
7
8/// The trait to be implemented by an autotune output.
9pub trait AutotuneOutput: Send + 'static {
10    #[cfg(feature = "autotune-checks")]
11    /// Checks if the output of an autotune operation is the same as another one on the same
12    /// problem.
13    fn check_equivalence(&self, other: Self);
14}
15
16impl AutotuneOutput for () {
17    #[cfg(feature = "autotune-checks")]
18    fn check_equivalence(&self, _other: Self) {
19        //
20    }
21}
22
23/// Benchmark how long this operation takes for a number of samples.
24///
25/// Returns at least one duration, otherwise an error is returned.
26pub fn tune_benchmark<'a, F: TuneInputs, Out: AutotuneOutput>(
27    operation: &TuneFn<F, Out>,
28    inputs: <F as TuneInputs>::At<'a>,
29    client: Client,
30    evictor: Option<&mut Evictor<'_>>,
31) -> Result<Vec<ProfileDuration>, AutotuneError> {
32    // `scoped` holds exclusive device access for the whole benchmark loop and
33    // accepts non-`'static` closures.
34    client
35        .clone()
36        .exclusive(move || profile_exclusive(operation, inputs, client, evictor))
37        .map_err(|err| AutotuneError::Unknown {
38            name: operation.name.to_string(),
39            err: err.to_string(),
40        })?
41}
42
43impl<F: TuneInputs, Out: AutotuneOutput> TuneFn<F, Out> {
44    /// Run the operation once without measuring it, to trigger compilation.
45    ///
46    /// Expects to already hold exclusive device access; the adaptive driver takes it once for
47    /// the whole round robin rather than once per candidate.
48    pub(crate) fn warmup_once<'a>(
49        &self,
50        inputs: <F as TuneInputs>::At<'a>,
51        client: &Client,
52    ) -> Result<(), AutotuneError> {
53        // We make sure the server is in a correct state.
54        let _errs = client.flush();
55
56        // The profile is dropped without being resolved: a warmup only exists to surface a
57        // failure to compile or launch, which is what the error carries — so nothing is
58        // evicted for it, either.
59        self.sample_once(inputs, client, None).map(|_| ())
60    }
61
62    /// Queue a single measured execution. See [`Self::warmup_once`] for the locking expectation.
63    ///
64    /// `evictor` runs first, outside the profiled region, so the sample reads what a real
65    /// call reads rather than what the previous launch left in cache ([`Eviction`]). An
66    /// eviction that fails is logged and the sample taken warm: the measurement is still
67    /// worth more than none, and the failure is the eviction's, not the candidate's.
68    ///
69    /// [`Eviction`]: super::Eviction
70    pub(crate) fn sample_once<'a>(
71        &self,
72        inputs: <F as TuneInputs>::At<'a>,
73        client: &Client,
74        evictor: Option<&mut Evictor<'_>>,
75    ) -> Result<ProfileDuration, AutotuneError> {
76        if let Some(evict) = evictor
77            && let Err(err) = evict()
78        {
79            log::error!(
80                "The eviction before a sample of `{}` failed, so the sample is measured on a warm cache.\n{err}",
81                self.name
82            );
83        }
84        // The output is returned so dead code elimination can't drop the work being profiled.
85        let profiled = client.profile(move || self.execute(inputs), &self.name);
86
87        match profiled {
88            Ok((Ok(_), duration)) => Ok(duration),
89            Ok((Err(err), _)) => Err(err),
90            Err(err) => Err(AutotuneError::Unknown {
91                name: self.name.to_string(),
92                err: err.to_string(),
93            }),
94        }
95    }
96}
97
98fn profile_exclusive<'a, F: TuneInputs, Out: AutotuneOutput>(
99    operation: &TuneFn<F, Out>,
100    inputs: <F as TuneInputs>::At<'a>,
101    client: Client,
102    mut evictor: Option<&mut Evictor<'_>>,
103) -> Result<Vec<ProfileDuration>, AutotuneError> {
104    // These launches are the measurement, so their stream executes whatever
105    // the process mode drops: it exists to skip the *workload*, not the tuning it is
106    // there to provoke. The guard covers the warm-up too, since a candidate
107    // measured without one is measured on its slowest run.
108    //
109    // Inside the `exclusive` call, so the stream switches to executing only
110    // while this measurement holds the device. `exclusive` runs the body
111    // under the issuing thread's stream, so the client resolves the same
112    // stream here as there.
113    let measuring =
114        crate::execution::StreamModeOverride::new(crate::execution::StreamMode::Execute, &client);
115
116    warmup(operation, inputs.clone(), client.clone())?;
117
118    // The same budget the adaptive scheduler reads. This pass takes the ceiling: with no
119    // elimination, there is nothing for a smaller budget to buy, and a candidate that stops early
120    // here would just be measured on less evidence than its rivals.
121    let (_, num_samples) = crate::config::CubeClRuntimeConfig::get()
122        .autotune
123        .bench
124        .samples();
125    let mut durations = Vec::new();
126
127    for _ in 0..num_samples {
128        // A candidate that fails once is disqualified regardless of how the remaining samples
129        // go, so the loop stops on the first error and hands it back untouched. Sampling on
130        // would only pay more device round trips to reach the same verdict, with the reason
131        // for the failure replaced by `InvalidSamples`.
132        durations.push(operation.sample_once(inputs.clone(), &client, evictor.as_deref_mut())?);
133    }
134    core::mem::drop(measuring);
135
136    if durations.is_empty() {
137        Err(AutotuneError::InvalidSamples {
138            name: operation.name.to_string(),
139        })
140    } else {
141        Ok(durations)
142    }
143}
144
145fn warmup<'a, F: TuneInputs, Out: AutotuneOutput>(
146    operation: &TuneFn<F, Out>,
147    inputs: <F as TuneInputs>::At<'a>,
148    client: Client,
149) -> Result<(), AutotuneError> {
150    let num_warmup = 3;
151
152    let mut errors = Vec::with_capacity(num_warmup);
153    // We make sure the server is in a correct state.
154    let _errs = client.flush();
155
156    for _ in 0..num_warmup {
157        let inputs = inputs.clone();
158        let profiled = client.profile(move || operation.execute(inputs), &operation.name);
159
160        match profiled {
161            // The tunable rejected its own configuration, which it will do identically on
162            // every call, so the remaining warmups and the whole sampling loop are skipped.
163            // The error is propagated as-is to keep the reason it was rejected.
164            Ok((Err(err), _)) => return Err(err),
165            Ok(_) => {}
166            Err(err) => errors.push(err),
167        }
168    }
169
170    if errors.len() < num_warmup {
171        Ok(())
172    } else {
173        let msg = alloc::format!("{:?}", errors.remove(num_warmup - 1));
174        Err(AutotuneError::Unknown {
175            name: operation.name.to_string(),
176            err: msg,
177        })
178    }
179}