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 they run even inside a dry run:
105    // that mode exists to skip the *workload*, not the tuning it is there to
106    // provoke. The guard covers the warm-up too, since a candidate measured
107    // without one is measured on its slowest run.
108    //
109    // It has to live here rather than around the `exclusive` call in
110    // `tune_benchmark`: the guard is thread-local, and `exclusive` runs this
111    // body on the device thread, which is where the launches below are issued
112    // from.
113    let _real_run = crate::dry_run::RealRun::new();
114
115    warmup(operation, inputs.clone(), client.clone())?;
116
117    // The same budget the adaptive scheduler reads. This pass takes the ceiling: with no
118    // elimination, there is nothing for a smaller budget to buy, and a candidate that stops early
119    // here would just be measured on less evidence than its rivals.
120    let (_, num_samples) = crate::config::CubeClRuntimeConfig::get()
121        .autotune
122        .bench
123        .samples();
124    let mut durations = Vec::new();
125
126    for _ in 0..num_samples {
127        // A candidate that fails once is disqualified regardless of how the remaining samples
128        // go, so the loop stops on the first error and hands it back untouched. Sampling on
129        // would only pay more device round trips to reach the same verdict, with the reason
130        // for the failure replaced by `InvalidSamples`.
131        durations.push(operation.sample_once(inputs.clone(), &client, evictor.as_deref_mut())?);
132    }
133
134    if durations.is_empty() {
135        Err(AutotuneError::InvalidSamples {
136            name: operation.name.to_string(),
137        })
138    } else {
139        Ok(durations)
140    }
141}
142
143fn warmup<'a, F: TuneInputs, Out: AutotuneOutput>(
144    operation: &TuneFn<F, Out>,
145    inputs: <F as TuneInputs>::At<'a>,
146    client: Client,
147) -> Result<(), AutotuneError> {
148    let num_warmup = 3;
149
150    let mut errors = Vec::with_capacity(num_warmup);
151    // We make sure the server is in a correct state.
152    let _errs = client.flush();
153
154    for _ in 0..num_warmup {
155        let inputs = inputs.clone();
156        let profiled = client.profile(move || operation.execute(inputs), &operation.name);
157
158        match profiled {
159            // The tunable rejected its own configuration, which it will do identically on
160            // every call, so the remaining warmups and the whole sampling loop are skipped.
161            // The error is propagated as-is to keep the reason it was rejected.
162            Ok((Err(err), _)) => return Err(err),
163            Ok(_) => {}
164            Err(err) => errors.push(err),
165        }
166    }
167
168    if errors.len() < num_warmup {
169        Ok(())
170    } else {
171        let msg = alloc::format!("{:?}", errors.remove(num_warmup - 1));
172        Err(AutotuneError::Unknown {
173            name: operation.name.to_string(),
174            err: msg,
175        })
176    }
177}