Skip to main content

cubecl_runtime/tune/
tuner.rs

1#[cfg(std_io)]
2use alloc::format;
3use alloc::sync::Arc;
4use alloc::vec::Vec;
5use cubecl_common::profile::ProfileDuration;
6use derive_more::Display;
7
8use core::time::Duration;
9
10use cubecl_environment::sync::Mutex;
11
12use alloc::string::{String, ToString};
13use cubecl_common::benchmark::{BenchmarkComputations, BenchmarkDurations};
14
15use crate::config::Logger;
16#[cfg(std_io)]
17use crate::config::autotune::AutotuneLogLevel;
18use crate::server::LaunchError;
19use crate::tune::{AutotuneLoggerExt, AutotuneResult, TimeBound, TuneCache, tune_benchmark};
20use crate::{client::ComputeClient, runtime::Runtime};
21use cubecl_environment::config::RuntimeConfig;
22
23use super::{
24    AutotuneKey, AutotuneOutput, TunableSet, TuneCacheResult, TuneFn, TuneInputs, TunePlan,
25};
26
27#[derive(Debug)]
28/// Runs autotune benchmarks for a single device and caches the results.
29///
30/// On wasm, [`tune`](Self::tune) spawns its work on the browser event loop; elsewhere
31/// it blocks inline. Either way the benchmarking itself is synchronous; only the
32/// per-sample profile resolution is awaited.
33pub struct Tuner<K: AutotuneKey> {
34    cache: Arc<Mutex<TuneCache<K>>>,
35    logger: Arc<Mutex<Logger>>,
36}
37
38/// The measured outcome for a given autotune invocation.
39#[cfg_attr(autotune_persistence, derive(serde::Serialize, serde::Deserialize))]
40#[derive(new, Debug, Clone, PartialEq, Eq)]
41pub struct AutotuneOutcome {
42    /// The name of the tunable.
43    pub name: String,
44    /// The index of the tunable.
45    pub index: usize,
46    /// The computation benchmark results.
47    pub computation: BenchmarkComputations,
48}
49
50impl core::fmt::Display for AutotuneOutcome {
51    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
52        write!(
53            f,
54            "Autotune[{}] name {} => {:?}",
55            self.index, self.name, self.computation
56        )
57    }
58}
59
60/// Error from running autotune.
61#[derive(Clone, Display)]
62#[cfg_attr(autotune_persistence, derive(serde::Serialize, serde::Deserialize))]
63pub enum AutotuneError {
64    /// An unknown error happened.
65    #[display("{name}: An unknown error happened.\n{err}")]
66    Unknown {
67        /// The name of the tunable.
68        name: String,
69        /// The unknown error,
70        err: String,
71    },
72    /// All samples are invalid.
73    #[display("{name}: All samples are invalid.")]
74    InvalidSamples {
75        /// The name of the tunable.
76        name: String,
77    },
78    /// No autotune was flagged as valid for the problem.
79    ///
80    /// # Warning
81    ///
82    /// This is an unrecoverable error and will cause a panic.
83    #[display("No autotune was flagged as valid for the problem.\n{context}")]
84    NoValidKernelFound {
85        /// The formatted context on why no valid kernel was found.
86        context: String,
87    },
88    /// The autotune is skipped manually.
89    #[display("{name}: The autotune is skipped manually.")]
90    Skip {
91        /// The name of the skipped kernel.
92        name: String,
93    },
94
95    /// An error happened when launching a kernel.
96    Launch(LaunchError),
97}
98
99impl core::fmt::Debug for AutotuneError {
100    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
101        write!(f, "{self}")
102    }
103}
104
105impl From<LaunchError> for AutotuneError {
106    fn from(value: LaunchError) -> Self {
107        Self::Launch(value)
108    }
109}
110
111/// A successfully-queued benchmark: the profile futures for each sample, plus its metadata.
112struct PendingBench {
113    index: usize,
114    name: String,
115    profiles: Vec<ProfileDuration>,
116    /// Time spent launching, when steps are being logged. The samples are still unresolved at
117    /// that point, so the resolution wait is added in [`process_request`] before it is reported.
118    launch: Option<Duration>,
119}
120
121/// Everything a benchmarking strategy needs, prepared once by [`Tuner::check_tune`] and handed to
122/// whichever strategy runs. `'t` borrows the tunable set, `'i` the benchmark inputs.
123struct TuneJob<'t, 'i, K: AutotuneKey, F: TuneInputs, Out> {
124    key: K,
125    autotunables: Vec<&'t TuneFn<F, Out>>,
126    test_inputs: <F as TuneInputs>::At<'i>,
127    plan: TunePlan,
128    results: Vec<AutotuneResult>,
129    #[cfg(any(not(target_family = "wasm"), autotune_persistence))]
130    limit: Option<Duration>,
131    #[cfg(autotune_persistence)]
132    bounds: Option<crate::tune::Bounds>,
133    #[cfg(not(target_family = "wasm"))]
134    short_circuit: bool,
135    #[cfg(autotune_persistence)]
136    checksum: String,
137    log_context: Option<crate::tune::AutotuneLogContext>,
138}
139
140impl<K: AutotuneKey, F: TuneInputs, Out> TuneJob<'_, '_, K, F, Out> {
141    fn into_request(self, pending: Vec<PendingBench>, decided: Option<usize>) -> TuneRequest<K> {
142        TuneRequest {
143            key: self.key,
144            results: self.results,
145            #[cfg(autotune_persistence)]
146            checksum: self.checksum,
147            log_context: self.log_context,
148            pending,
149            decided,
150            #[cfg(autotune_persistence)]
151            limit: self.limit,
152            #[cfg(autotune_persistence)]
153            bounds: self.bounds,
154        }
155    }
156}
157
158/// A queued tuning job: all data needed to resolve samples and commit the result.
159/// Holds no references so it's trivially `Send + 'static` for the wasm spawn path.
160struct TuneRequest<K: AutotuneKey> {
161    key: K,
162    results: Vec<AutotuneResult>,
163    #[cfg(autotune_persistence)]
164    checksum: String,
165    log_context: Option<crate::tune::AutotuneLogContext>,
166    pending: Vec<PendingBench>,
167    /// The winner, when the strategy already picked one. `None` means the results are all
168    /// comparable and the fastest is whichever scores best.
169    decided: Option<usize>,
170    #[cfg(autotune_persistence)]
171    limit: Option<Duration>,
172    #[cfg(autotune_persistence)]
173    bounds: Option<crate::tune::Bounds>,
174}
175
176#[allow(clippy::new_without_default)]
177impl<K: AutotuneKey> Tuner<K> {
178    /// Create a tuner. Its cache is seeded from the persistent cache when
179    /// persistence is available (disk on native, browser storage on wasm with
180    /// the `browser-cache` feature).
181    pub fn new(name: &str, device_id: &str) -> Self {
182        Self {
183            cache: Arc::new(Mutex::new(TuneCache::new(name, device_id))),
184            logger: Arc::new(Mutex::new(Logger::new())),
185        }
186    }
187
188    /// Fetch the fastest autotune operation index for an autotune key.
189    ///
190    /// This resets the cache when the environment switched but does not
191    /// re-hydrate it from persistence, so right after a switch it reports a
192    /// [`Miss`](TuneCacheResult::Miss) even for keys the new environment has
193    /// cached. It is a fast-path probe: a miss here is expected to fall through
194    /// to [`check_tune`](Self::check_tune), which hydrates and resolves the
195    /// real state. Don't rely on it as a standalone "is this cached?" query.
196    pub fn fastest(&self, key: &K) -> TuneCacheResult {
197        #[cfg_attr(not(autotune_persistence), allow(unused_mut))]
198        let mut cache = self.cache.lock();
199        #[cfg(autotune_persistence)]
200        cache.reset_if_environment_switched();
201
202        cache.fastest(key)
203    }
204
205    /// Fetch the logger instance.
206    pub fn logger(&self) -> Arc<Mutex<Logger>> {
207        self.logger.clone()
208    }
209
210    /// Check the cache, validate checksums if needed, and kick off a tuning job if the
211    /// key is a miss. Returns the resolved cache state.
212    pub fn check_tune<'a, R: Runtime, F: TuneInputs, Out: AutotuneOutput>(
213        &self,
214        key: &K,
215        inputs: &F::At<'a>,
216        tunables: &TunableSet<K, F, Out>,
217        #[cfg_attr(not(autotune_persistence), allow(unused))] checksum: impl FnOnce() -> String
218        + Send
219        + Sync,
220        client: &ComputeClient<R>,
221        mut log_context: Option<crate::tune::AutotuneLogContext>,
222    ) -> TuneCacheResult
223    where
224        <F as TuneInputs>::At<'a>: Clone + Send,
225    {
226        {
227            let mut cache = self.cache.lock();
228            #[cfg(autotune_persistence)]
229            cache.reset_if_environment_switched();
230            let cur = cache.fastest(key);
231
232            // Browser hydration is asynchronous, so persistent entries may
233            // have arrived after construction. Ingest them before starting a
234            // redundant tune.
235            #[cfg(autotune_persistence)]
236            let cur = if matches!(cur, TuneCacheResult::Miss) {
237                cache.sync_persistent();
238                cache.fastest(key)
239            } else {
240                cur
241            };
242
243            #[cfg(autotune_persistence)]
244            let cur = if matches!(cur, TuneCacheResult::Unchecked) {
245                let mut log = self.logger.lock();
246                let checksum = checksum();
247                if let AutotuneLogLevel::Full = log.log_level_autotune() {
248                    log.log_autotune(&format!("validate checksum key={key}, checksum={checksum}"));
249                }
250                cache.validate_checksum(key, &checksum)
251            } else {
252                cur
253            };
254
255            match cur {
256                TuneCacheResult::Hit { .. } | TuneCacheResult::Pending => return cur,
257                TuneCacheResult::Miss | TuneCacheResult::Unchecked => {
258                    cache.mark_pending(key.clone())
259                }
260            }
261            // Scope the guard: the rest of this function re-locks `self.cache` (fast
262            // path insert, `process_request`), and the mutex is non-reentrant.
263        }
264
265        log::info!("Tuning {key}");
266
267        let autotunables = tunables.autotunables().collect::<Vec<_>>();
268        let results: Vec<AutotuneResult> = autotunables
269            .iter()
270            .map(|a| {
271                AutotuneResult::error(AutotuneError::Skip {
272                    name: a.name.to_string(),
273                })
274            })
275            .collect();
276
277        #[cfg(autotune_persistence)]
278        let checksum = tunables.compute_checksum();
279
280        // Fast path: single tunable, no benchmarking needed.
281        if results.len() == 1 {
282            self.cache.lock().cache_insert(key.clone(), 0);
283            return TuneCacheResult::Hit { fastest_index: 0 };
284        }
285
286        let test_inputs = tunables.generate_inputs(key, inputs);
287        let plan = tunables.plan(key);
288        let bounds = tunables.bounds(key, inputs);
289        let limit = bounds.as_ref().and_then(|bounds| bounds.time_limit());
290
291        log_context.set_bounds(bounds.clone());
292        log_context.set_limit(limit);
293
294        // The slowest median duration still considered close enough to peak throughput.
295        // Only used on native, where a benchmark can be resolved inline to exit early.
296        #[cfg(not(target_family = "wasm"))]
297        let short_circuit = limit.is_some()
298            && tunables.is_short_circuit_enabled()
299            && !crate::config::CubeClRuntimeConfig::get()
300                .autotune
301                .disable_short_circuit;
302
303        let job = TuneJob {
304            key: key.clone(),
305            autotunables,
306            test_inputs,
307            plan,
308            results,
309            #[cfg(any(not(target_family = "wasm"), autotune_persistence))]
310            limit,
311            #[cfg(autotune_persistence)]
312            bounds,
313            #[cfg(not(target_family = "wasm"))]
314            short_circuit,
315            #[cfg(autotune_persistence)]
316            checksum,
317            log_context,
318        };
319
320        #[cfg(not(target_family = "wasm"))]
321        if crate::config::CubeClRuntimeConfig::get()
322            .autotune
323            .bench
324            .adaptive
325        {
326            return self.tune_adaptive(job, client);
327        }
328
329        self.tune_fixed_samples(job, client)
330    }
331
332    /// Round robin the candidates, eliminating them as the evidence allows. Native only: the
333    /// driver has to resolve samples between rounds, which it cannot do on the browser event loop.
334    #[cfg(not(target_family = "wasm"))]
335    fn tune_adaptive<'i, R: Runtime, F: TuneInputs, Out: AutotuneOutput>(
336        &self,
337        mut job: TuneJob<'_, 'i, K, F, Out>,
338        client: &ComputeClient<R>,
339    ) -> TuneCacheResult
340    where
341        <F as TuneInputs>::At<'i>: Clone + Send,
342    {
343        let schedule = crate::tune::schedule::Schedule {
344            config: crate::config::CubeClRuntimeConfig::get()
345                .autotune
346                .bench
347                .clone(),
348            limit: job.limit,
349            short_circuit: job.short_circuit,
350            track_steps: job.log_context.is_some(),
351        };
352
353        let outcome = schedule.run_plan(
354            &job.key,
355            &mut job.plan,
356            &job.autotunables,
357            &job.test_inputs,
358            client,
359            &mut job.results,
360        );
361
362        for (name, duration) in outcome.steps {
363            job.log_context.push_tuning_step(name, duration);
364        }
365        if let Some(name) = outcome.short_circuit {
366            job.log_context.push_short_circuit(name);
367        }
368
369        let request = job.into_request(Vec::new(), outcome.decided);
370
371        cubecl_environment::future::block_on(process_request(request, &self.cache, &self.logger))
372    }
373
374    /// Benchmark every candidate with a fixed sample count, resolving the samples afterwards.
375    /// This is the only strategy available on wasm, where nothing can be awaited inline.
376    fn tune_fixed_samples<'i, R: Runtime, F: TuneInputs, Out: AutotuneOutput>(
377        &self,
378        mut job: TuneJob<'_, 'i, K, F, Out>,
379        client: &ComputeClient<R>,
380    ) -> TuneCacheResult
381    where
382        <F as TuneInputs>::At<'i>: Clone + Send,
383    {
384        // The batch-retry check below reads this through `cfg!`, which keeps
385        // the name alive on wasm too; the assignment is native-only, so it
386        // simply stays false there.
387        #[cfg(not(target_family = "wasm"))]
388        let mut batch_success = false;
389        #[cfg(target_family = "wasm")]
390        let batch_success = false;
391
392        // Walk the plan batch by batch, launching each benchmark synchronously. A
393        // successful launch queues a `PendingBench` for the async resolver below;
394        // launch errors go straight into `results`. Retry the next batch if a whole
395        // batch failed to queue anything.
396        let mut pending = Vec::<PendingBench>::new();
397        loop {
398            let tunable_indices = job.plan.next();
399
400            if tunable_indices.is_empty() {
401                let key = &job.key;
402                panic!(
403                    "Can't execute the autotune plan for key: {key:?}\n - plan: {:?}\n - results: {:?}",
404                    job.plan, job.results
405                );
406            }
407
408            for index in tunable_indices {
409                let op = job.autotunables[index];
410
411                let start_time = job
412                    .log_context
413                    .is_some()
414                    .then(cubecl_common::profile::Instant::now);
415
416                match tune_benchmark(op, job.test_inputs.clone(), client.clone()) {
417                    Ok(profiles) => {
418                        let bench = PendingBench {
419                            index,
420                            name: op.name.clone(),
421                            profiles,
422                            launch: start_time.map(|start| start.elapsed()),
423                        };
424
425                        #[cfg(not(target_family = "wasm"))]
426                        if job.short_circuit {
427                            let result = cubecl_environment::future::block_on(resolve_bench(bench));
428
429                            // short_circuit is only true when limit.is_some() => unwrap is fine.
430                            let close_enough = result
431                                .outcome
432                                .as_ref()
433                                .is_ok_and(|out| out.computation.median <= job.limit.unwrap());
434
435                            batch_success |= result.outcome.is_ok();
436                            job.results[index] = result;
437
438                            if let Some(start) = start_time {
439                                job.log_context
440                                    .push_tuning_step(op.name.to_string(), start.elapsed());
441                            }
442
443                            if close_enough {
444                                job.log_context.push_short_circuit(op.name.to_string());
445                                break;
446                            }
447
448                            continue;
449                        }
450
451                        // The step is reported once `process_request` has resolved the samples,
452                        // so the logged duration covers benchmarking and not just the launch.
453                        pending.push(bench);
454                    }
455                    Err(err) => {
456                        job.results[index] = AutotuneResult::error(err);
457                        if let Some(start) = start_time {
458                            job.log_context
459                                .push_tuning_step(op.name.to_string(), start.elapsed());
460                        }
461                    }
462                }
463            }
464
465            #[cfg(not(target_family = "wasm"))]
466            if !pending.is_empty() || batch_success {
467                break;
468            }
469            #[cfg(target_family = "wasm")]
470            if !pending.is_empty() {
471                break;
472            }
473        }
474
475        // Every candidate here carries the same sample count, so scoring them against each other
476        // is a fair comparison and `process_request` can make the call.
477        let request = job.into_request(pending, None);
478
479        // Resolve samples and commit the result. On wasm this runs on the browser
480        // event loop; elsewhere it blocks inline.
481        #[cfg(target_family = "wasm")]
482        {
483            let cache = self.cache.clone();
484            let logger = self.logger.clone();
485            wasm_bindgen_futures::spawn_local(async move {
486                process_request(request, &cache, &logger).await;
487            });
488
489            return TuneCacheResult::Pending;
490        }
491
492        #[cfg(not(target_family = "wasm"))]
493        cubecl_environment::future::block_on(process_request(request, &self.cache, &self.logger))
494    }
495}
496
497/// Await every sample of a single benchmark and fold them into one result.
498///
499/// The samples are resolved concurrently: a profile only submits its readback when
500/// first polled, so awaiting them one by one would serialize a device round-trip per
501/// sample.
502async fn resolve_bench(bench: PendingBench) -> AutotuneResult {
503    let PendingBench {
504        index,
505        name,
506        profiles,
507        launch: _,
508    } = bench;
509
510    let Some(first) = profiles.first() else {
511        return AutotuneResult::error(AutotuneError::Unknown {
512            name: name.to_string(),
513            err: "No profiling available".to_string(),
514        });
515    };
516    let timing_method = first.timing_method();
517
518    let durations: Vec<Duration> =
519        futures_util::future::join_all(profiles.into_iter().map(ProfileDuration::resolve))
520            .await
521            .into_iter()
522            .map(|ticks| ticks.duration())
523            .collect();
524
525    AutotuneResult::success(AutotuneOutcome::new(
526        name,
527        index,
528        BenchmarkComputations::new(&BenchmarkDurations::from_durations(
529            timing_method,
530            durations,
531        )),
532    ))
533}
534
535/// Await every profile sample, pick the fastest tunable, commit to the cache.
536async fn process_request<K: AutotuneKey>(
537    request: TuneRequest<K>,
538    cache: &Mutex<TuneCache<K>>,
539    logger: &Mutex<Logger>,
540) -> TuneCacheResult {
541    let TuneRequest {
542        key,
543        mut results,
544        #[cfg(autotune_persistence)]
545        checksum,
546        mut log_context,
547        pending,
548        decided,
549        #[cfg(autotune_persistence)]
550        limit,
551        #[cfg(autotune_persistence)]
552        bounds,
553    } = request;
554
555    // Resolved concurrently, and each benchmark timed individually rather than timing the loop:
556    // the profiles were all queued before any of them was polled, so awaiting them in turn would
557    // charge the first benchmark for draining the whole device queue and report the rest as
558    // free.
559    let resolved = futures_util::future::join_all(pending.into_iter().map(|bench| {
560        let index = bench.index;
561        let name = bench.name.clone();
562        let launch = bench.launch;
563
564        async move {
565            let started = cubecl_common::profile::Instant::now();
566            let result = resolve_bench(bench).await;
567            let step = launch.map(|launch| (name, launch + started.elapsed()));
568
569            (index, step, result)
570        }
571    }))
572    .await;
573
574    for (index, step, result) in resolved {
575        if let Some((name, duration)) = step {
576            log_context.push_tuning_step(name, duration);
577        }
578
579        results[index] = result;
580    }
581
582    // Read before the sort, which reorders `results` out of tunable order. A
583    // decided candidate whose own outcome is an error is one `Schedule::run_plan`
584    // picked with nothing measured — the tune executed but could not be timed.
585    #[cfg(autotune_persistence)]
586    let unmeasured = decided.is_some_and(|index| results[index].outcome.is_err());
587
588    results.sort_by(|a, b| {
589        let a = a
590            .outcome
591            .as_ref()
592            .map(|r| r.computation.score())
593            .unwrap_or(u64::MAX);
594        let b = b
595            .outcome
596            .as_ref()
597            .map(|r| r.computation.score())
598            .unwrap_or(u64::MAX);
599        a.cmp(&b)
600    });
601
602    // The sort above orders what gets logged and persisted. It does not pick the winner when the
603    // strategy already did: a scheduler that eliminates candidates leaves results built from
604    // different sample counts behind, and `score` reads a short sample set as a stable one.
605    let fastest_index = match decided {
606        Some(index) => index,
607        None => {
608            results
609                .first()
610                .expect("At least one kernel needed.")
611                .outcome
612                .as_ref()
613                .expect("At least one kernel has to succeed.")
614                .index
615        }
616    };
617
618    {
619        log_context.log_result(&mut logger.lock(), &key, &results);
620        // In-memory regardless: without it this key re-tunes on every call, and
621        // a tune that measured nothing would keep failing the same way.
622        cache.lock().cache_insert(key.clone(), fastest_index);
623
624        // Not on disk, though. An unmeasured decision is a guess made to keep
625        // the device thread alive, and the failures that produce one — a
626        // profiling hiccup, timestamp query sets on a busy stream — are
627        // transient. Persisting it would freeze the guess into every later
628        // process and never measure the key again; letting it expire with this
629        // one costs a re-tune and buys a real measurement.
630        #[cfg(autotune_persistence)]
631        if !unmeasured {
632            cache.lock().persistent_cache_insert(
633                key,
634                checksum,
635                crate::tune::PersistentCacheValue {
636                    fastest_index,
637                    results,
638                    bounds,
639                    limit,
640                },
641            );
642        }
643    }
644
645    TuneCacheResult::Hit { fastest_index }
646}
647
648#[cfg(feature = "autotune-checks")]
649pub(crate) fn check_autotune_outputs<O: AutotuneOutput>(
650    mut checks_outputs: Vec<(String, Result<O, AutotuneError>)>,
651) -> Vec<crate::tune::log::CheckResult> {
652    if checks_outputs.is_empty() {
653        return Vec::new();
654    }
655
656    let reference_idx = checks_outputs
657        .iter()
658        .position(|(_, res)| res.is_ok())
659        .unwrap_or(checks_outputs.len() - 1);
660    let reference = checks_outputs.remove(reference_idx);
661    let reference_result = reference.1;
662    #[cfg(std_io)]
663    let reference_name = reference.0;
664
665    let is_recording = is_recording_enabled();
666
667    #[cfg(std_io)]
668    {
669        let reference_passed = reference_result.is_ok();
670        let mut check_results = execute_checks(checks_outputs, reference_result, is_recording);
671        check_results.push(crate::tune::log::CheckResult {
672            name: reference_name,
673            passed: reference_passed,
674        });
675
676        check_results
677    }
678
679    #[cfg(not(std_io))]
680    {
681        execute_checks(checks_outputs, reference_result, is_recording)
682    }
683}
684
685/// Whether a mismatch should be collected rather than fatal: it can only be reported if something
686/// is recording the results, so with no recorder a failed check panics on the spot instead of
687/// passing silently.
688#[cfg(feature = "autotune-checks")]
689fn is_recording_enabled() -> bool {
690    crate::config::CubeClRuntimeConfig::get()
691        .autotune
692        .recording_enabled()
693}
694
695#[cfg(feature = "autotune-checks")]
696fn execute_checks<O: AutotuneOutput>(
697    checks_outputs: Vec<(String, Result<O, AutotuneError>)>,
698    reference_result: Result<O, AutotuneError>,
699    is_recording: bool,
700) -> Vec<crate::tune::log::CheckResult> {
701    let mut check_results = Vec::new();
702
703    let Ok(reference) = reference_result else {
704        for (name, _) in checks_outputs.into_iter() {
705            check_results.push(crate::tune::log::CheckResult {
706                name,
707                passed: false,
708            });
709        }
710        return check_results;
711    };
712
713    for (name, other_result) in checks_outputs.into_iter() {
714        if let Ok(other) = other_result {
715            let passed = check_equivalence(&reference, other, is_recording);
716            check_results.push(crate::tune::log::CheckResult { name, passed });
717        } else {
718            check_results.push(crate::tune::log::CheckResult {
719                name,
720                passed: false,
721            });
722        }
723    }
724
725    check_results
726}
727
728#[cfg(feature = "autotune-checks")]
729fn check_equivalence<O: AutotuneOutput>(reference: &O, other: O, is_recording: bool) -> bool {
730    // When the results are being recorded, we catch the panic so we can collect and report every
731    // check failure. With nothing recording, we let it panic immediately rather than pass silently.
732    if is_recording {
733        #[cfg(std_io)]
734        {
735            std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
736                reference.check_equivalence(other);
737            }))
738            .is_ok()
739        }
740        #[cfg(not(std_io))]
741        {
742            reference.check_equivalence(other);
743            true
744        }
745    } else {
746        reference.check_equivalence(other);
747        true
748    }
749}