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}