Skip to main content

cubecl_runtime/tune/
tuner.rs

1use alloc::boxed::Box;
2#[cfg(persistence)]
3use alloc::format;
4use alloc::sync::Arc;
5use alloc::vec::Vec;
6use cubecl_common::profile::ProfileDuration;
7use derive_more::Display;
8
9use core::time::Duration;
10
11use cubecl_environment::sync::Mutex;
12
13use alloc::string::{String, ToString};
14use cubecl_common::benchmark::{BenchmarkComputations, BenchmarkDurations};
15
16use crate::client::Client;
17use crate::config::Logger;
18#[cfg(persistence)]
19use crate::config::autotune::AutotuneLogLevel;
20use crate::device::HardwareIdentity;
21use crate::execution::{ProcessMode, TuneOutcome, TuneRegistration};
22use crate::server::LaunchError;
23#[cfg(not(target_family = "wasm"))]
24use crate::server::ServerError;
25use crate::tune::sampler::measured;
26use crate::tune::{AutotuneLoggerExt, AutotuneResult, TimeBound, TuneCache, tune_benchmark};
27use cubecl_environment::config::RuntimeConfig;
28
29use super::{
30    AutotuneKey, AutotuneOutput, TunableSet, TuneCacheResult, TuneFn, TuneInputs, TunePlan,
31};
32
33#[derive(Debug)]
34/// Runs autotune benchmarks for the devices of the same hardware and caches the results.
35///
36/// On wasm, [`check_tune`](Self::check_tune) spawns its work on the browser event loop; elsewhere
37/// it blocks inline. Either way the benchmarking itself is synchronous; only the
38/// per-sample profile resolution is awaited.
39pub struct Tuner<K: AutotuneKey> {
40    cache: Arc<Mutex<TuneCache<K>>>,
41    logger: Arc<Mutex<Logger>>,
42}
43
44/// The measured outcome for a given autotune invocation.
45#[cfg_attr(serializable, derive(serde::Serialize, serde::Deserialize))]
46#[derive(new, Debug, Clone, PartialEq, Eq)]
47pub struct AutotuneOutcome {
48    /// The name of the tunable.
49    pub name: String,
50    /// The index of the tunable.
51    pub index: usize,
52    /// The computation benchmark results.
53    pub computation: BenchmarkComputations,
54}
55
56impl core::fmt::Display for AutotuneOutcome {
57    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
58        write!(
59            f,
60            "Autotune[{}] name {} => {:?}",
61            self.index, self.name, self.computation
62        )
63    }
64}
65
66/// Error from running autotune.
67#[derive(Clone, Display)]
68#[cfg_attr(serializable, derive(serde::Serialize, serde::Deserialize))]
69pub enum AutotuneError {
70    /// An unknown error happened.
71    #[display("{name}: An unknown error happened.\n{err}")]
72    Unknown {
73        /// The name of the tunable.
74        name: String,
75        /// The unknown error,
76        err: String,
77    },
78    /// The tunable's own function returned an error, so it queued no work for this problem:
79    /// it does not serve these inputs, or failed before launching. Unlike
80    /// [`Unknown`](Self::Unknown), which the harness reports around work that was queued (a
81    /// profile it could not take or resolve, an execution that failed, memory that ran out),
82    /// this says nothing ran — so it is never a kernel to fall back on.
83    #[display("{name}: Rejected the problem.\n{reason}")]
84    Rejected {
85        /// The name of the tunable.
86        name: String,
87        /// What its function returned.
88        reason: String,
89    },
90    /// All samples are invalid.
91    #[display("{name}: All samples are invalid.")]
92    InvalidSamples {
93        /// The name of the tunable.
94        name: String,
95    },
96    /// No autotune was flagged as valid for the problem.
97    ///
98    /// # Warning
99    ///
100    /// This is an unrecoverable error and will cause a panic.
101    #[display("No autotune was flagged as valid for the problem.\n{context}")]
102    NoValidKernelFound {
103        /// The formatted context on why no valid kernel was found.
104        context: String,
105    },
106    /// A sample's profiled window carried no measurement.
107    ///
108    /// Disqualifying, and deliberately not folded into a zero: zero is the
109    /// fastest duration there is, so a candidate carrying one wins every
110    /// comparison it enters and gets cached as the best row in the table.
111    #[display("{name}: A profiled sample carried no measurement.")]
112    NotMeasured {
113        /// The name of the tunable.
114        name: String,
115    },
116    /// The autotune is skipped manually.
117    #[display("{name}: The autotune is skipped manually.")]
118    Skip {
119        /// The name of the skipped kernel.
120        name: String,
121    },
122
123    /// An error happened when launching a kernel.
124    Launch(LaunchError),
125}
126
127impl core::fmt::Debug for AutotuneError {
128    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
129        write!(f, "{self}")
130    }
131}
132
133impl From<LaunchError> for AutotuneError {
134    fn from(value: LaunchError) -> Self {
135        Self::Launch(value)
136    }
137}
138
139/// A successfully-queued benchmark: the profile futures for each sample, plus its metadata.
140struct PendingBench {
141    index: usize,
142    name: String,
143    profiles: Vec<ProfileDuration>,
144    /// Time spent launching, when steps are being logged. The samples are still unresolved at
145    /// that point, so the resolution wait is added in [`process_request`] before it is reported.
146    launch: Option<Duration>,
147}
148
149/// Everything a benchmarking strategy needs, prepared once by [`Tuner::check_tune`] and handed to
150/// whichever strategy runs. `'t` borrows the tunable set, `'i` the benchmark inputs.
151struct TuneJob<'t, 'i, K: AutotuneKey, F: TuneInputs, Out> {
152    key: K,
153    autotunables: Vec<&'t TuneFn<F, Out>>,
154    test_inputs: <F as TuneInputs>::At<'i>,
155    /// What runs before every measured sample, bound to the key and the reference inputs.
156    evictor: Option<Box<crate::tune::Evictor<'i>>>,
157    plan: TunePlan,
158    results: Vec<AutotuneResult>,
159    #[cfg(any(not(target_family = "wasm"), persistence))]
160    limit: Option<Duration>,
161    #[cfg(persistence)]
162    bounds: Option<crate::tune::Bounds>,
163    #[cfg(not(target_family = "wasm"))]
164    short_circuit: bool,
165    #[cfg(persistence)]
166    checksum: String,
167    log_context: Option<crate::tune::AutotuneLogContext>,
168    #[cfg(persistence)]
169    recording: crate::tune::record::TuneRecording<K>,
170    /// Where the tune settles once its pick is committed.
171    registration: Option<TuneRegistration>,
172    mark: PendingMark<K>,
173}
174
175impl<K: AutotuneKey, F: TuneInputs, Out> TuneJob<'_, '_, K, F, Out> {
176    fn into_request(self, pending: Vec<PendingBench>, decided: Option<usize>) -> TuneRequest<K> {
177        TuneRequest {
178            key: self.key,
179            results: self.results,
180            #[cfg(persistence)]
181            checksum: self.checksum,
182            log_context: self.log_context,
183            pending,
184            decided,
185            #[cfg(persistence)]
186            limit: self.limit,
187            #[cfg(persistence)]
188            bounds: self.bounds,
189            #[cfg(persistence)]
190            recording: self.recording,
191            registration: self.registration,
192            mark: self.mark,
193        }
194    }
195}
196
197/// A queued tuning job: all data needed to resolve samples and commit the result.
198/// Holds no references so it's trivially `Send + 'static` for the wasm spawn path.
199struct TuneRequest<K: AutotuneKey> {
200    key: K,
201    results: Vec<AutotuneResult>,
202    #[cfg(persistence)]
203    checksum: String,
204    log_context: Option<crate::tune::AutotuneLogContext>,
205    pending: Vec<PendingBench>,
206    /// The winner, when the strategy already picked one. `None` means the results are all
207    /// comparable and the fastest is whichever scores best.
208    decided: Option<usize>,
209    #[cfg(persistence)]
210    limit: Option<Duration>,
211    #[cfg(persistence)]
212    bounds: Option<crate::tune::Bounds>,
213    #[cfg(persistence)]
214    recording: crate::tune::record::TuneRecording<K>,
215    registration: Option<TuneRegistration>,
216    mark: PendingMark<K>,
217}
218
219/// A key [marked pending](TuneCache::mark_pending) for the tune holding this. Dropped before the
220/// tune settled the key, by a tune that unwinds or stops on a lost device, it unmarks the key so
221/// the next caller tunes it rather than every device of that hardware running a fallback for good.
222struct PendingMark<K: AutotuneKey> {
223    cache: Arc<Mutex<TuneCache<K>>>,
224    key: K,
225    /// The cache's generation when marked: once a switch reset it, the key may be a newer tune's.
226    #[cfg(persistence)]
227    generation: u32,
228}
229
230impl<K: AutotuneKey> Drop for PendingMark<K> {
231    fn drop(&mut self) {
232        let mut cache = self.cache.lock();
233        #[cfg(persistence)]
234        if cache.generation() != self.generation {
235            return;
236        }
237        cache.unmark_pending(&self.key);
238    }
239}
240
241#[allow(clippy::new_without_default)]
242impl<K: AutotuneKey> Tuner<K> {
243    /// Create a tuner. A key it has not settled is read from the persistent
244    /// cache when persistence is available (feature `persistence`: a file
245    /// natively, OPFS in the browser), in the table every device of `hardware`
246    /// shares.
247    pub fn new(name: &str, hardware: &HardwareIdentity) -> Self {
248        Self {
249            cache: Arc::new(Mutex::new(TuneCache::new(name, hardware))),
250            logger: Arc::new(Mutex::new(Logger::new())),
251        }
252    }
253
254    /// Fetch the fastest autotune operation index for an autotune key.
255    ///
256    /// This never reads the persistent cache, so it reports a
257    /// [`Miss`](TuneCacheResult::Miss) for keys only the store holds. It is a
258    /// fast-path probe: a miss here is expected to fall through to
259    /// [`check_tune`](Self::check_tune), which reads the store and resolves the
260    /// real state. Don't rely on it as a standalone "is this cached?" query.
261    pub fn fastest(&self, key: &K) -> TuneCacheResult {
262        #[cfg_attr(not(persistence), allow(unused_mut))]
263        let mut cache = self.cache.lock();
264        #[cfg(persistence)]
265        cache.reset_if_environment_switched();
266
267        cache.fastest(key)
268    }
269
270    /// The index settled for `key`, reading only: never starts a round, never waits on one, never
271    /// reads the persistent cache, and never resets the cache after an environment switch. It
272    /// reports nothing settled there instead, until the next [`check_tune`](Self::check_tune).
273    pub(crate) fn settled(&self, key: &K) -> Option<usize> {
274        self.cache.lock().settled(key)
275    }
276
277    /// Fetch the logger instance.
278    pub fn logger(&self) -> Arc<Mutex<Logger>> {
279        self.logger.clone()
280    }
281
282    /// Check the cache, read the store when memory holds no answer, and kick off a tuning job if
283    /// the key is still a miss. Returns the resolved cache state.
284    pub fn check_tune<'a, F: TuneInputs, Out: AutotuneOutput, Id>(
285        &self,
286        key: &K,
287        inputs: &F::At<'a>,
288        tunables: &TunableSet<K, F, Out, Id>,
289        client: &Client,
290        mut log_context: Option<crate::tune::AutotuneLogContext>,
291    ) -> TuneCacheResult
292    where
293        <F as TuneInputs>::At<'a>: Clone + Send,
294    {
295        let compiling = ProcessMode::current() == ProcessMode::CompileOnly;
296        #[cfg(persistence)]
297        let checksum;
298
299        // Where the key's tune was registered, if the override that gathered it registered it.
300        let mut gathered = None;
301        let mark = {
302            // Scoped: the rest of this function re-locks `self.cache`, and the mutex is
303            // non-reentrant.
304            let mut cache = self.cache.lock();
305            #[cfg(persistence)]
306            cache.reset_if_environment_switched();
307            // A key a `CompileOnly` override already queued the candidates of on this card answers
308            // at once, once a switched environment has dropped the ones it queued for the old one:
309            // a walk reaches the same key at every layer, and a miss would checksum the set and
310            // read the store again each time for a tune that will not run.
311            if compiling && cache.is_compiled(key, client.service_id()) {
312                return TuneCacheResult::Compiled;
313            }
314            let cur = cache.fastest(key);
315            if matches!(cur, TuneCacheResult::Hit { .. } | TuneCacheResult::Pending) {
316                return cur;
317            }
318            // Every candidate would meet the loss: the key is left to a healthy card.
319            if cache.is_lost(client.service_id()) {
320                return TuneCacheResult::Pending;
321            }
322            // A key with one candidate is answered, not tuned: nothing to read or to store.
323            if tunables.autotunables().nth(1).is_none() {
324                cache.cache_insert(key.clone(), 0);
325                return TuneCacheResult::Hit { fastest_index: 0 };
326            }
327
328            #[cfg(persistence)]
329            {
330                checksum = tunables.compute_checksum();
331                let stored = cache.look_up(key, &checksum);
332                let mut log = self.logger.lock();
333                if let AutotuneLogLevel::Full = log.log_level_autotune() {
334                    log.log_autotune(&format!(
335                        "look up key={key}, checksum={checksum}: {stored:?}"
336                    ));
337                }
338                if let TuneCacheResult::Hit { .. } = stored {
339                    return stored;
340                }
341            }
342
343            // Marked before the candidates run, so one that panics leaves the key to the pass that
344            // profiles it rather than pending for good. Each card queues the candidates on itself,
345            // since a tune compiles only what its own card queued.
346            //
347            // Its tune is registered with the open override only when no other key's candidates
348            // reached it: the pass that profiles stops a plan at its first close-enough candidate,
349            // and may never run the one that reaches a key gathered inside it. Such a key is
350            // registered if it is ever measured, as an ungathered one is.
351            if compiling {
352                cache.mark_compiled(key.clone(), client.service_id(), || {
353                    if super::gathering::CandidateGathering::active() {
354                        None
355                    } else {
356                        TuneRegistration::register()
357                    }
358                });
359                None
360            } else {
361                gathered = cache.mark_pending(key.clone());
362                // Made last under the lock: no gap follows the mark, and the guard, whose drop takes
363                // this lock, can never drop while it is held.
364                Some(PendingMark {
365                    cache: self.cache.clone(),
366                    key: key.clone(),
367                    #[cfg(persistence)]
368                    generation: cache.generation(),
369                })
370            }
371        };
372
373        log::info!("Tuning {key}");
374
375        let autotunables = tunables.autotunables().collect::<Vec<_>>();
376
377        // A `CompileOnly` override queues the candidates' kernels and measures nothing; the key
378        // stays untuned for the pass that profiles it.
379        let Some(mark) = mark else {
380            let gathering = super::gathering::CandidateGathering::new();
381            self.compile_plan(key, inputs, tunables, &autotunables);
382            core::mem::drop(gathering);
383            return TuneCacheResult::Compiled;
384        };
385        // Sure to measure now: a tune registered nowhere yet registers with the override open.
386        let registration = gathered.or_else(TuneRegistration::register);
387
388        let results: Vec<AutotuneResult> = autotunables
389            .iter()
390            .map(|a| {
391                AutotuneResult::error(AutotuneError::Skip {
392                    name: a.name.to_string(),
393                })
394            })
395            .collect();
396
397        #[cfg(persistence)]
398        let mut recording =
399            crate::tune::record::TuneRecording::new(&self.cache.lock(), key, &checksum);
400        // A recorded tune tracks its steps whether or not anything logs them:
401        // the log context is what collects the record's trials.
402        #[cfg(persistence)]
403        if recording.is_open() {
404            log_context.get_or_insert_with(Default::default);
405        }
406
407        let test_inputs = tunables.generate_inputs(key, inputs);
408        // Kernels a `CompileOnly` override queued compile here, together, before anything is
409        // timed: left to the next launch, they would all compile inside the first candidate's
410        // warmup, and its measurement would carry every candidate's compilation.
411        client.compile_queued();
412        let plan = tunables.plan(key);
413        #[cfg(persistence)]
414        if recording.is_open() {
415            let names: Vec<&str> = autotunables.iter().map(|tune| tune.name.as_str()).collect();
416            recording.plan(&plan, &names);
417        }
418        let bounds = tunables.bounds(key, inputs);
419        let limit = bounds.as_ref().and_then(|bounds| bounds.time_limit());
420
421        log_context.set_bounds(bounds.clone());
422        log_context.set_limit(limit);
423
424        // The slowest median duration still considered close enough to peak throughput.
425        // Only used on native, where a benchmark can be resolved inline to exit early.
426        #[cfg(not(target_family = "wasm"))]
427        let short_circuit = limit.is_some()
428            && tunables.is_short_circuit_enabled()
429            && !crate::config::CubeClRuntimeConfig::get()
430                .autotune
431                .disable_short_circuit;
432
433        let job = TuneJob {
434            key: key.clone(),
435            autotunables,
436            test_inputs,
437            evictor: tunables.evictor(key, inputs),
438            plan,
439            results,
440            #[cfg(any(not(target_family = "wasm"), persistence))]
441            limit,
442            #[cfg(persistence)]
443            bounds,
444            #[cfg(not(target_family = "wasm"))]
445            short_circuit,
446            #[cfg(persistence)]
447            checksum,
448            log_context,
449            #[cfg(persistence)]
450            recording,
451            registration,
452            mark,
453        };
454
455        #[cfg(not(target_family = "wasm"))]
456        if crate::config::CubeClRuntimeConfig::get()
457            .autotune
458            .bench
459            .adaptive
460        {
461            return self.tune_adaptive(job, client);
462        }
463
464        self.tune_fixed_samples(job, client)
465    }
466
467    /// Runs the candidates the plan would measure once each, on the inputs a tune generates,
468    /// so their launches queue their kernels under a `CompileOnly` override. The key is left
469    /// untuned.
470    ///
471    /// The plan is walked batch by batch and stops after the first batch in which a candidate
472    /// serves the problem, as a tune stops at the first batch with a measured candidate: a
473    /// candidate that declines returns its error without launching anything.
474    fn compile_plan<'a, F: TuneInputs, Out: AutotuneOutput, Id>(
475        &self,
476        key: &K,
477        inputs: &F::At<'a>,
478        tunables: &TunableSet<K, F, Out, Id>,
479        autotunables: &[&TuneFn<F, Out>],
480    ) where
481        <F as TuneInputs>::At<'a>: Clone + Send,
482    {
483        let test_inputs = tunables.generate_inputs(key, inputs);
484        let mut plan = tunables.plan(key);
485        loop {
486            let batch = plan.next();
487            if batch.is_empty() {
488                break;
489            }
490            // Every candidate of the batch runs, so every one of them queues its kernels.
491            let mut served = false;
492            for index in batch.indices() {
493                served |= autotunables[index].execute(test_inputs.clone()).is_ok();
494            }
495            if served {
496                break;
497            }
498        }
499    }
500
501    /// Round robin the candidates, eliminating them as the evidence allows. Native only: the
502    /// driver has to resolve samples between rounds, which it cannot do on the browser event loop.
503    #[cfg(not(target_family = "wasm"))]
504    fn tune_adaptive<'i, F: TuneInputs, Out: AutotuneOutput>(
505        &self,
506        mut job: TuneJob<'_, 'i, K, F, Out>,
507        client: &Client,
508    ) -> TuneCacheResult
509    where
510        <F as TuneInputs>::At<'i>: Clone + Send,
511    {
512        let config = crate::config::CubeClRuntimeConfig::get();
513        let mut schedule = crate::tune::schedule::Schedule {
514            config: config.autotune.bench.clone(),
515            limit: job.limit,
516            short_circuit: job.short_circuit,
517            track_steps: job.log_context.is_some(),
518            compile_parallelism: config.compilation.parallelism(),
519            evictor: job.evictor.take(),
520        };
521
522        let outcome = match schedule.run_plan(
523            &job.key,
524            &mut job.plan,
525            &job.autotunables,
526            &job.test_inputs,
527            client,
528            &mut job.results,
529        ) {
530            Ok(outcome) => outcome,
531            Err(lost) => return self.stop_on_lost_card(&job.key, client, lost),
532        };
533
534        for (name, duration) in outcome.steps {
535            job.log_context.push_tuning_step(name, duration);
536        }
537        if let Some(name) = outcome.short_circuit {
538            job.log_context.push_short_circuit(name);
539        }
540
541        let request = job.into_request(Vec::new(), outcome.decided);
542
543        cubecl_environment::future::block_on(process_request(request, &self.cache, &self.logger))
544    }
545
546    /// Answered pending, so the caller runs the first candidate that serves the problem and meets
547    /// the loss in its own reads. The card is answered so for every key of this candidate list
548    /// from now on, and the dropped job leaves this one to another card of the same hardware,
549    /// which may be healthy.
550    #[cfg(not(target_family = "wasm"))]
551    fn stop_on_lost_card(&self, key: &K, client: &Client, lost: ServerError) -> TuneCacheResult {
552        log::error!("Autotune of {key} stopped, the device is lost: {lost}");
553        self.cache.lock().mark_lost(client.service_id());
554        TuneCacheResult::Pending
555    }
556
557    /// Benchmark every candidate with a fixed sample count, resolving the samples afterwards.
558    /// This is the only strategy available on wasm, where nothing can be awaited inline.
559    fn tune_fixed_samples<'i, F: TuneInputs, Out: AutotuneOutput>(
560        &self,
561        mut job: TuneJob<'_, 'i, K, F, Out>,
562        client: &Client,
563    ) -> TuneCacheResult
564    where
565        <F as TuneInputs>::At<'i>: Clone + Send,
566    {
567        // The batch-retry check below reads this through `cfg!`, which keeps
568        // the name alive on wasm too; the assignment is native-only, so it
569        // simply stays false there.
570        #[cfg(not(target_family = "wasm"))]
571        let mut batch_success = false;
572        #[cfg(target_family = "wasm")]
573        let batch_success = false;
574
575        // Walk the plan batch by batch, launching each benchmark synchronously. A
576        // successful launch queues a `PendingBench` for the async resolver below;
577        // launch errors go straight into `results`. Retry the next batch if a whole
578        // batch failed to queue anything.
579        let mut pending = Vec::<PendingBench>::new();
580        loop {
581            let batch = job.plan.next();
582
583            if batch.is_empty() {
584                #[cfg(not(target_family = "wasm"))]
585                if let Err(lost) = cubecl_environment::future::block_on(client.sync())
586                    && lost.is_device_poisoned()
587                {
588                    return self.stop_on_lost_card(&job.key, client, lost);
589                }
590                let key = &job.key;
591                panic!(
592                    "Can't execute the autotune plan for key: {key:?}\n - plan: {:?}\n - results: {:?}",
593                    job.plan, job.results
594                );
595            }
596
597            // Every candidate is measured: a group's patience is the adaptive scheduler's alone.
598            for index in batch.indices() {
599                let op = job.autotunables[index];
600
601                let start_time = job
602                    .log_context
603                    .is_some()
604                    .then(cubecl_common::profile::Instant::now);
605
606                match tune_benchmark(
607                    op,
608                    job.test_inputs.clone(),
609                    client.clone(),
610                    job.evictor.as_deref_mut(),
611                ) {
612                    Ok(profiles) => {
613                        let bench = PendingBench {
614                            index,
615                            name: op.name.clone(),
616                            profiles,
617                            launch: start_time.map(|start| start.elapsed()),
618                        };
619
620                        #[cfg(not(target_family = "wasm"))]
621                        if job.short_circuit {
622                            let result = cubecl_environment::future::block_on(resolve_bench(bench));
623
624                            // short_circuit is only true when limit.is_some() => unwrap is fine.
625                            let close_enough = result
626                                .outcome
627                                .as_ref()
628                                .is_ok_and(|out| out.computation.median <= job.limit.unwrap());
629
630                            batch_success |= result.outcome.is_ok();
631                            job.results[index] = result;
632
633                            if let Some(start) = start_time {
634                                job.log_context
635                                    .push_tuning_step(op.name.to_string(), start.elapsed());
636                            }
637
638                            if close_enough {
639                                job.log_context.push_short_circuit(op.name.to_string());
640                                break;
641                            }
642
643                            continue;
644                        }
645
646                        // The step is reported once `process_request` has resolved the samples,
647                        // so the logged duration covers benchmarking and not just the launch.
648                        pending.push(bench);
649                    }
650                    Err(err) => {
651                        job.results[index] = AutotuneResult::error(err);
652                        if let Some(start) = start_time {
653                            job.log_context
654                                .push_tuning_step(op.name.to_string(), start.elapsed());
655                        }
656                    }
657                }
658            }
659
660            #[cfg(not(target_family = "wasm"))]
661            if !pending.is_empty() || batch_success {
662                break;
663            }
664            #[cfg(target_family = "wasm")]
665            if !pending.is_empty() {
666                break;
667            }
668        }
669
670        // Every candidate here carries the same sample count, so scoring them against each other
671        // is a fair comparison and `process_request` can make the call.
672        let request = job.into_request(pending, None);
673
674        // Resolve samples and commit the result. On wasm this runs on the browser
675        // event loop; elsewhere it blocks inline.
676        #[cfg(target_family = "wasm")]
677        {
678            let cache = self.cache.clone();
679            let logger = self.logger.clone();
680            wasm_bindgen_futures::spawn_local(async move {
681                process_request(request, &cache, &logger).await;
682            });
683
684            return TuneCacheResult::Pending;
685        }
686
687        #[cfg(not(target_family = "wasm"))]
688        cubecl_environment::future::block_on(process_request(request, &self.cache, &self.logger))
689    }
690}
691
692/// Await every sample of a single benchmark and fold them into one result.
693///
694/// The samples are resolved concurrently: a profile only submits its readback when
695/// first polled, so awaiting them one by one would serialize a device round-trip per
696/// sample.
697async fn resolve_bench(bench: PendingBench) -> AutotuneResult {
698    let PendingBench {
699        index,
700        name,
701        profiles,
702        launch: _,
703    } = bench;
704
705    let Some(first) = profiles.first() else {
706        return AutotuneResult::error(AutotuneError::Unknown {
707            name: name.to_string(),
708            err: "No profiling available".to_string(),
709        });
710    };
711    let timing_method = first.timing_method();
712
713    // One sample that is no time disqualifies the candidate, the way one failed
714    // sample does. Dropping it and averaging the rest would let a candidate
715    // whose window went untimed be judged on its remaining runs.
716    let Some(durations) =
717        futures_util::future::join_all(profiles.into_iter().map(ProfileDuration::resolve))
718            .await
719            .into_iter()
720            .map(measured)
721            .collect::<Option<Vec<Duration>>>()
722    else {
723        return AutotuneResult::error(AutotuneError::NotMeasured {
724            name: name.to_string(),
725        });
726    };
727
728    AutotuneResult::success(AutotuneOutcome::new(
729        name,
730        index,
731        BenchmarkComputations::new(&BenchmarkDurations::from_durations(
732            timing_method,
733            durations,
734        )),
735    ))
736}
737
738/// Await every profile sample, pick the fastest tunable, commit to the cache.
739async fn process_request<K: AutotuneKey>(
740    request: TuneRequest<K>,
741    cache: &Mutex<TuneCache<K>>,
742    logger: &Mutex<Logger>,
743) -> TuneCacheResult {
744    let TuneRequest {
745        key,
746        mut results,
747        #[cfg(persistence)]
748        checksum,
749        mut log_context,
750        pending,
751        decided,
752        #[cfg(persistence)]
753        limit,
754        #[cfg(persistence)]
755        bounds,
756        #[cfg(persistence)]
757        recording,
758        registration,
759        mark,
760    } = request;
761
762    // Resolved concurrently, and each benchmark timed individually rather than timing the loop:
763    // the profiles were all queued before any of them was polled, so awaiting them in turn would
764    // charge the first benchmark for draining the whole device queue and report the rest as
765    // free.
766    let resolved = futures_util::future::join_all(pending.into_iter().map(|bench| {
767        let index = bench.index;
768        let name = bench.name.clone();
769        let launch = bench.launch;
770
771        async move {
772            let started = cubecl_common::profile::Instant::now();
773            let result = resolve_bench(bench).await;
774            let step = launch.map(|launch| (name, launch + started.elapsed()));
775
776            (index, step, result)
777        }
778    }))
779    .await;
780
781    for (index, step, result) in resolved {
782        if let Some((name, duration)) = step {
783            log_context.push_tuning_step(name, duration);
784        }
785
786        results[index] = result;
787    }
788
789    // Read before the sort, which reorders `results` out of tunable order. A
790    // decided candidate whose own outcome is an error is one `Schedule::run_plan`
791    // picked with nothing measured — the tune executed but could not be timed.
792    let unmeasured = decided.is_some_and(|index| results[index].outcome.is_err());
793
794    results.sort_by(|a, b| {
795        let a = a
796            .outcome
797            .as_ref()
798            .map(|r| r.computation.score())
799            .unwrap_or(u64::MAX);
800        let b = b
801            .outcome
802            .as_ref()
803            .map(|r| r.computation.score())
804            .unwrap_or(u64::MAX);
805        a.cmp(&b)
806    });
807
808    // The sort above orders what gets logged and persisted. It does not pick the winner when the
809    // strategy already did: a scheduler that eliminates candidates leaves results built from
810    // different sample counts behind, and `score` reads a short sample set as a stable one.
811    let fastest_index = match decided {
812        Some(index) => index,
813        None => {
814            results
815                .first()
816                .expect("At least one kernel needed.")
817                .outcome
818                .as_ref()
819                .expect("At least one kernel has to succeed.")
820                .index
821        }
822    };
823
824    {
825        log_context.log_result(&mut logger.lock(), &key, &results);
826        // In-memory regardless: without it this key re-tunes on every call, and
827        // a tune that measured nothing would keep failing the same way.
828        cache.lock().cache_insert(key.clone(), fastest_index);
829        // Where the tune was registered, whether or not that override is still open.
830        let outcome = if unmeasured {
831            TuneOutcome::Failed
832        } else {
833            TuneOutcome::Measured
834        };
835        #[cfg_attr(not(persistence), allow(unused_variables, unused_mut))]
836        let mut settled = registration.map(|registration| registration.settle(outcome));
837
838        // Not on disk, though. An unmeasured decision is a guess made to keep
839        // the device thread alive, and the failures that produce one — a
840        // profiling hiccup, timestamp query sets on a busy stream — are
841        // transient. Persisting it would freeze the guess into every later
842        // process and never measure the key again; letting it expire with this
843        // one costs a re-tune and buys a real measurement.
844        #[cfg(persistence)]
845        let stored = !unmeasured
846            && cache.lock().persistent_cache_insert(
847                key,
848                checksum,
849                crate::tune::PersistentCacheValue {
850                    fastest_index,
851                    results,
852                    bounds,
853                    limit,
854                },
855            );
856
857        #[cfg(persistence)]
858        if stored && let Some(settled) = settled.as_mut() {
859            settled.record(crate::execution::TunePick::Persisted);
860        }
861
862        #[cfg(persistence)]
863        recording.finish(
864            crate::tune::record::Answer {
865                winner: fastest_index,
866                stored,
867            },
868            log_context.as_ref(),
869        );
870    }
871    core::mem::drop(mark);
872
873    TuneCacheResult::Hit { fastest_index }
874}
875
876#[cfg(feature = "autotune-checks")]
877pub(crate) fn check_autotune_outputs<O: AutotuneOutput>(
878    mut checks_outputs: Vec<(String, Result<O, AutotuneError>)>,
879) -> Vec<crate::tune::log::CheckResult> {
880    if checks_outputs.is_empty() {
881        return Vec::new();
882    }
883
884    let reference_idx = checks_outputs
885        .iter()
886        .position(|(_, res)| res.is_ok())
887        .unwrap_or(checks_outputs.len() - 1);
888    let reference = checks_outputs.remove(reference_idx);
889    let reference_result = reference.1;
890    #[cfg(std_io)]
891    let reference_name = reference.0;
892
893    let decisions_enabled = is_decisions_enabled();
894
895    #[cfg(std_io)]
896    {
897        let reference_passed = reference_result.is_ok();
898        let mut check_results = execute_checks(checks_outputs, reference_result, decisions_enabled);
899        check_results.push(crate::tune::log::CheckResult {
900            name: reference_name,
901            passed: reference_passed,
902        });
903
904        check_results
905    }
906
907    #[cfg(not(std_io))]
908    {
909        execute_checks(checks_outputs, reference_result, decisions_enabled)
910    }
911}
912
913/// Whether a mismatch should be collected rather than fatal: it can only be reported if decisions
914/// are written somewhere, so without a sink a failed check panics on the spot instead of passing
915/// silently.
916#[cfg(feature = "autotune-checks")]
917fn is_decisions_enabled() -> bool {
918    crate::config::CubeClRuntimeConfig::get()
919        .autotune
920        .decisions_enabled()
921}
922
923#[cfg(feature = "autotune-checks")]
924fn execute_checks<O: AutotuneOutput>(
925    checks_outputs: Vec<(String, Result<O, AutotuneError>)>,
926    reference_result: Result<O, AutotuneError>,
927    decisions_enabled: bool,
928) -> Vec<crate::tune::log::CheckResult> {
929    let mut check_results = Vec::new();
930
931    let Ok(reference) = reference_result else {
932        for (name, _) in checks_outputs.into_iter() {
933            check_results.push(crate::tune::log::CheckResult {
934                name,
935                passed: false,
936            });
937        }
938        return check_results;
939    };
940
941    for (name, other_result) in checks_outputs.into_iter() {
942        if let Ok(other) = other_result {
943            let passed = check_equivalence(&reference, other, decisions_enabled);
944            check_results.push(crate::tune::log::CheckResult { name, passed });
945        } else {
946            check_results.push(crate::tune::log::CheckResult {
947                name,
948                passed: false,
949            });
950        }
951    }
952
953    check_results
954}
955
956#[cfg(feature = "autotune-checks")]
957fn check_equivalence<O: AutotuneOutput>(reference: &O, other: O, decisions_enabled: bool) -> bool {
958    // When decisions are written somewhere, we catch the panic so we can collect and report every
959    // check failure. Without a sink, we let it panic immediately rather than pass silently.
960    if decisions_enabled {
961        #[cfg(std_io)]
962        {
963            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
964                reference.check_equivalence(other);
965            }))
966            .is_ok()
967        }
968        #[cfg(not(std_io))]
969        {
970            reference.check_equivalence(other);
971            true
972        }
973    } else {
974        reference.check_equivalence(other);
975        true
976    }
977}