Skip to main content

cubecl_runtime/tune/
tuner.rs

1use alloc::format;
2use alloc::sync::Arc;
3use alloc::vec::Vec;
4use cubecl_common::profile::ProfileDuration;
5use derive_more::Display;
6
7use core::time::Duration;
8
9use alloc::string::{String, ToString};
10use cubecl_common::benchmark::{BenchmarkComputations, BenchmarkDurations};
11
12use crate::config::{Logger, autotune::AutotuneLogLevel};
13use crate::server::LaunchError;
14use crate::tune::{AutotuneResult, TimeBound, TuneCache, tune_benchmark};
15use crate::{client::ComputeClient, runtime::Runtime};
16
17use super::{AutotuneKey, AutotuneOutput, TunableSet, TuneCacheResult, TuneInputs};
18
19#[derive(Debug)]
20/// Runs autotune benchmarks for a single device and caches the results.
21///
22/// On wasm, [`tune`](Self::tune) spawns its work on the browser event loop; elsewhere
23/// it blocks inline. Either way the benchmarking itself is synchronous; only the
24/// per-sample profile resolution is awaited.
25pub struct Tuner<K: AutotuneKey> {
26    cache: Arc<spin::Mutex<TuneCache<K>>>,
27    logger: Arc<spin::Mutex<Logger>>,
28}
29
30/// The measured outcome for a given autotune invocation.
31#[cfg_attr(std_io, derive(serde::Serialize, serde::Deserialize))]
32#[derive(new, Debug, Clone, PartialEq, Eq)]
33pub struct AutotuneOutcome {
34    name: String,
35    index: usize,
36    computation: BenchmarkComputations,
37}
38
39impl core::fmt::Display for AutotuneOutcome {
40    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
41        write!(
42            f,
43            "Autotune[{}] name {} => {:?}",
44            self.index, self.name, self.computation
45        )
46    }
47}
48
49/// Error from running autotune.
50#[derive(Clone, Display)]
51#[cfg_attr(std_io, derive(serde::Serialize, serde::Deserialize))]
52pub enum AutotuneError {
53    /// An unknown error happened.
54    #[display("{name}: An unknown error happened.\n{err}")]
55    Unknown {
56        /// The name of the tunable.
57        name: String,
58        /// The unknown error,
59        err: String,
60    },
61    /// All samples are invalid.
62    #[display("{name}: All samples are invalid.")]
63    InvalidSamples {
64        /// The name of the tunable.
65        name: String,
66    },
67    /// No autotune was flagged as valid for the problem.
68    ///
69    /// # Warning
70    ///
71    /// This is an unrecoverable error and will cause a panic.
72    #[display("No autotune was flagged as valid for the problem.\n{context}")]
73    NoValidKernelFound {
74        /// The formatted context on why no valid kernel was found.
75        context: String,
76    },
77    /// The autotune is skipped manually.
78    #[display("{name}: The autotune is skipped manually.")]
79    Skip {
80        /// The name of the skipped kernel.
81        name: String,
82    },
83
84    /// An error happened when launching a kernel.
85    Launch(LaunchError),
86}
87
88impl core::fmt::Debug for AutotuneError {
89    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
90        write!(f, "{self}")
91    }
92}
93
94impl From<LaunchError> for AutotuneError {
95    fn from(value: LaunchError) -> Self {
96        Self::Launch(value)
97    }
98}
99
100/// A successfully-queued benchmark: the profile futures for each sample, plus its metadata.
101struct PendingBench {
102    index: usize,
103    name: String,
104    profiles: Vec<ProfileDuration>,
105}
106
107/// A queued tuning job: all data needed to resolve samples and commit the result.
108/// Holds no references so it's trivially `Send + 'static` for the wasm spawn path.
109struct TuneRequest<K: AutotuneKey> {
110    key: K,
111    results: Vec<AutotuneResult>,
112    #[cfg(std_io)]
113    checksum: String,
114    context_logs: Option<String>,
115    pending: Vec<PendingBench>,
116}
117
118#[allow(clippy::new_without_default)]
119impl<K: AutotuneKey> Tuner<K> {
120    /// Create a tuner. Its cache is seeded from the persistent on-disk cache when
121    /// `std_io` is enabled.
122    pub fn new(name: &str, device_id: &str) -> Self {
123        Self {
124            cache: Arc::new(spin::Mutex::new(TuneCache::new(name, device_id))),
125            logger: Arc::new(spin::Mutex::new(Logger::new())),
126        }
127    }
128
129    /// Fetch the fastest autotune operation index for an autotune key.
130    pub fn fastest(&self, key: &K) -> TuneCacheResult {
131        self.cache.lock().fastest(key)
132    }
133
134    /// Check the cache, validate checksums if needed, and kick off a tuning job if the
135    /// key is a miss. Returns the resolved cache state.
136    pub fn check_tune<'a, R: Runtime, F: TuneInputs, Out: AutotuneOutput>(
137        &self,
138        key: &K,
139        inputs: &F::At<'a>,
140        tunables: &TunableSet<K, F, Out>,
141        #[cfg_attr(not(std_io), allow(unused))] checksum: impl FnOnce() -> String + Send + Sync,
142        client: &ComputeClient<R>,
143    ) -> TuneCacheResult
144    where
145        <F as TuneInputs>::At<'a>: Clone + Send,
146    {
147        {
148            let mut cache = self.cache.lock();
149            let cur = cache.fastest(key);
150
151            #[cfg(std_io)]
152            let cur = if matches!(cur, TuneCacheResult::Unchecked) {
153                let mut log = self.logger.lock();
154                let checksum = checksum();
155                if let AutotuneLogLevel::Full = log.log_level_autotune() {
156                    log.log_autotune(&format!("validate checksum key={key}, checksum={checksum}"));
157                }
158                cache.validate_checksum(key, &checksum)
159            } else {
160                cur
161            };
162
163            match cur {
164                TuneCacheResult::Hit { .. } | TuneCacheResult::Pending => return cur,
165                TuneCacheResult::Miss | TuneCacheResult::Unchecked => {
166                    cache.mark_pending(key.clone())
167                }
168            }
169            // Scope the guard: the rest of this function re-locks `self.cache` (fast
170            // path insert, `process_request`), and `spin::Mutex` is non-reentrant.
171        }
172
173        log::info!("Tuning {key}");
174
175        let autotunables = tunables.autotunables().collect::<Vec<_>>();
176        let mut results: Vec<AutotuneResult> = autotunables
177            .iter()
178            .map(|a| {
179                AutotuneResult::error(AutotuneError::Skip {
180                    name: a.name.to_string(),
181                })
182            })
183            .collect();
184
185        #[cfg(std_io)]
186        let checksum = tunables.compute_checksum();
187
188        // Fast path: single tunable, no benchmarking needed.
189        if results.len() == 1 {
190            self.cache.lock().cache_insert(key.clone(), 0);
191            return TuneCacheResult::Hit { fastest_index: 0 };
192        }
193
194        let test_inputs = tunables.generate_inputs(key, inputs);
195        let mut plan = tunables.plan(key);
196        let mut context_logs = match self.logger.lock().log_level_autotune() {
197            AutotuneLogLevel::Full => Some(String::new()),
198            _ => None,
199        };
200
201        #[cfg(not(target_family = "wasm"))]
202        let threshold_limit = tunables
203            .bounds(key, inputs)
204            .map(|bounds| bounds.time_limit())
205            .unwrap_or_default();
206
207        // The batch-retry check below reads this through `cfg!`, which keeps
208        // the name alive on wasm too; the assignment is native-only, so it
209        // simply stays false there.
210        #[cfg(not(target_family = "wasm"))]
211        let mut batch_success = false;
212        #[cfg(target_family = "wasm")]
213        let batch_success = false;
214
215        // Walk the plan batch by batch, launching each benchmark synchronously. A
216        // successful launch queues a `PendingBench` for the async resolver below;
217        // launch errors go straight into `results`. Retry the next batch if a whole
218        // batch failed to queue anything.
219        let mut pending = Vec::<PendingBench>::new();
220        loop {
221            let tunable_indices = plan.next(context_logs.as_mut());
222
223            if tunable_indices.is_empty() {
224                panic!(
225                    "Can't execute the autotune plan for key: {key:?}\n - plan: {plan:?}\n - results: {results:?}"
226                );
227            }
228
229            for index in tunable_indices {
230                let op = autotunables[index];
231
232                match tune_benchmark(op, test_inputs.clone(), client.clone()) {
233                    Ok(profiles) => {
234                        let bench = PendingBench {
235                            index,
236                            name: op.name.clone(),
237                            profiles,
238                        };
239
240                        #[cfg(not(target_family = "wasm"))]
241                        if let Some(limit) = threshold_limit {
242                            let result = cubecl_common::future::block_on(resolve_bench(bench));
243                            let close_enough = result
244                                .outcome
245                                .as_ref()
246                                .is_ok_and(|outcome| outcome.computation.median <= limit);
247
248                            batch_success |= result.outcome.is_ok();
249                            results[index] = result;
250
251                            if close_enough {
252                                break;
253                            }
254
255                            continue;
256                        }
257
258                        pending.push(bench);
259                    }
260                    Err(err) => {
261                        results[index] = AutotuneResult::error(err);
262                    }
263                }
264            }
265
266            if !pending.is_empty() || (cfg!(not(target_family = "wasm")) && batch_success) {
267                break;
268            }
269        }
270
271        let request = TuneRequest {
272            key: key.clone(),
273            results,
274            #[cfg(std_io)]
275            checksum,
276            context_logs,
277            pending,
278        };
279
280        // Resolve samples and commit the result. On wasm this runs on the browser
281        // event loop; elsewhere it blocks inline.
282        #[cfg(target_family = "wasm")]
283        {
284            let cache = self.cache.clone();
285            let logger = self.logger.clone();
286            wasm_bindgen_futures::spawn_local(async move {
287                process_request(request, &cache, &logger).await;
288            });
289
290            return TuneCacheResult::Pending;
291        }
292
293        #[cfg(not(target_family = "wasm"))]
294        cubecl_common::future::block_on(process_request(request, &self.cache, &self.logger))
295    }
296}
297
298/// Await every sample of a single benchmark and fold them into one result.
299///
300/// The samples are resolved concurrently: a profile only submits its readback when
301/// first polled, so awaiting them one by one would serialize a device round-trip per
302/// sample.
303async fn resolve_bench(bench: PendingBench) -> AutotuneResult {
304    let PendingBench {
305        index,
306        name,
307        profiles,
308    } = bench;
309
310    let Some(first) = profiles.first() else {
311        return AutotuneResult::error(AutotuneError::Unknown {
312            name: name.to_string(),
313            err: "No profiling available".to_string(),
314        });
315    };
316    let timing_method = first.timing_method();
317
318    let durations: Vec<Duration> =
319        futures_util::future::join_all(profiles.into_iter().map(ProfileDuration::resolve))
320            .await
321            .into_iter()
322            .map(|ticks| ticks.duration())
323            .collect();
324
325    AutotuneResult::success(AutotuneOutcome::new(
326        name,
327        index,
328        BenchmarkComputations::new(&BenchmarkDurations::from_durations(
329            timing_method,
330            durations,
331        )),
332    ))
333}
334
335/// Await every profile sample, pick the fastest tunable, commit to the cache.
336async fn process_request<K: AutotuneKey>(
337    request: TuneRequest<K>,
338    cache: &spin::Mutex<TuneCache<K>>,
339    logger: &spin::Mutex<Logger>,
340) -> TuneCacheResult {
341    let TuneRequest {
342        key,
343        mut results,
344        #[cfg(std_io)]
345        checksum,
346        context_logs,
347        pending,
348    } = request;
349
350    for bench in pending {
351        let index = bench.index;
352        results[index] = resolve_bench(bench).await;
353    }
354
355    results.sort_by(|a, b| {
356        let a = a
357            .outcome
358            .as_ref()
359            .map(|r| r.computation.score())
360            .unwrap_or(u64::MAX);
361        let b = b
362            .outcome
363            .as_ref()
364            .map(|r| r.computation.score())
365            .unwrap_or(u64::MAX);
366        a.cmp(&b)
367    });
368
369    let fastest_index = results
370        .first()
371        .expect("At least one kernel needed.")
372        .outcome
373        .as_ref()
374        .expect("At least one kernel has to succeed.")
375        .index;
376
377    {
378        log_result(&mut logger.lock(), &key, &results, context_logs.as_deref());
379        cache.lock().cache_insert(key.clone(), fastest_index);
380        #[cfg(std_io)]
381        cache
382            .lock()
383            .persistent_cache_insert(key, checksum, fastest_index, results);
384    }
385
386    TuneCacheResult::Hit { fastest_index }
387}
388
389/// Emit the autotune result through the logger at the currently configured level.
390fn log_result<K: AutotuneKey>(
391    logger: &mut Logger,
392    key: &K,
393    results: &[AutotuneResult],
394    context_logs: Option<&str>,
395) {
396    match logger.log_level_autotune() {
397        AutotuneLogLevel::Minimal => {
398            let top_times = results
399                .iter()
400                .map(|r| {
401                    let time = r
402                        .outcome
403                        .as_ref()
404                        .map(|r| r.computation.median)
405                        .unwrap_or(Duration::MAX);
406
407                    let index = r.outcome.as_ref().map(|r| r.index).unwrap_or_default();
408                    (index, time)
409                })
410                .take(3)
411                .collect::<Vec<_>>();
412
413            let result = results
414                .first()
415                .expect("At least one kernel needed.")
416                .outcome
417                .as_ref()
418                .expect("At least one kernel has to succeed.");
419
420            let context = context_logs.unwrap_or("");
421            logger.log_autotune(&format!(
422                "Fastest result {}-{key}. \n Top 3 times: {top_times:?}, context: {context}",
423                result.name,
424            ));
425        }
426        AutotuneLogLevel::Full => {
427            let result = results
428                .first()
429                .expect("At least one kernel needed.")
430                .outcome
431                .as_ref()
432                .expect("At least one kernel has to succeed.");
433
434            let context = context_logs.unwrap_or("");
435            logger.log_autotune(&format!(
436                "Fastest result {}-{key}. Context: {context}",
437                result.name,
438            ));
439
440            for result in results.iter() {
441                match &result.outcome {
442                    Ok(val) => {
443                        logger.log_autotune(&format!("{val}"));
444                    }
445                    Err(err) => logger.log_autotune(&format!("{err}")),
446                }
447            }
448        }
449        AutotuneLogLevel::Disabled => {}
450    }
451}
452
453#[cfg(feature = "autotune-checks")]
454pub(crate) fn check_autotune_outputs<O: AutotuneOutput>(
455    mut checks_outputs: Vec<Result<O, AutotuneError>>,
456) {
457    let reference = checks_outputs.remove(checks_outputs.len() - 1);
458
459    if let Ok(reference) = reference {
460        for other in checks_outputs.into_iter().flatten() {
461            reference.check_equivalence(other);
462        }
463    }
464}