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}