Skip to main content

cubecl_runtime/tune/
tune_cache.rs

1use alloc::vec;
2use alloc::vec::Vec;
3
4use cubecl_common::device::ServiceId;
5#[cfg(persistence)]
6use cubecl_environment::persistence::StoreError;
7#[cfg(persistence)]
8use cubecl_environment::persistence::{CacheOption, Namespace, Store, StoreOptions};
9#[cfg(serializable)]
10use serde::{Deserialize, Serialize};
11
12use super::{AutotuneError, AutotuneKey, AutotuneOutcome};
13use crate::device::HardwareIdentity;
14#[cfg(persistence)]
15use crate::execution::TuneOutcome;
16use crate::execution::TuneRegistration;
17#[cfg(persistence)]
18use crate::namespace_version::NamespaceVersion;
19#[cfg(persistence)]
20use alloc::string::String;
21use cubecl_environment::collections::HashMap;
22
23/// Bumped when tables are named another way.
24#[cfg(persistence)]
25static TABLES: NamespaceVersion = NamespaceVersion::new(ROOT, LAYOUT_PREFIX, 1);
26
27#[derive(Debug)]
28pub(crate) enum CacheEntry {
29    Done {
30        fastest_index: usize,
31    },
32    Pending,
33    /// A `CompileOnly` override queued the kernels of the key's candidates on `services` and
34    /// decided nothing: a miss to anything that tunes, and done to the next `CompileOnly` override
35    /// on one of `services`. Never persisted. `registration` is the key's tune as the override that
36    /// gathered it first registered it, settled once its pick commits; `None` when it registered
37    /// none. Replacing the entry other than through [`TuneCache::mark_pending`] or
38    /// [`TuneCache::look_up`] drops the registration, which counts it abandoned.
39    Compiled {
40        registration: Option<TuneRegistration>,
41        services: Vec<ServiceId>,
42    },
43}
44
45/// Persistent cache key
46#[cfg(persistence)]
47#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Hash)]
48pub struct PersistentCacheKey<K> {
49    /// The autotune key identifying the operation.
50    pub key: K,
51    /// The checksum of the candidate list the key was tuned under: an answer
52    /// to another list is an answer to another question.
53    pub checksum: String,
54}
55
56/// Persistent cache entry
57///
58/// Only [`fastest_index`](Self::fastest_index) is read back: a look-up seeds the in-memory cache
59/// from it and nothing else. Everything below it is stored so a cache entry can be inspected after
60/// the fact — why a kernel won, against which measurements, and under which bounds — which is the
61/// question that cannot be answered from a live process once tuning is over. That is also why the
62/// type is `pub`: reading an entry back is the point.
63#[cfg(persistence)]
64#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)]
65pub struct PersistentCacheValue {
66    /// Index of the fastest candidate operation.
67    pub fastest_index: usize,
68    /// Benchmarking results for all autotune candidates.
69    pub results: Vec<AutotuneResult>,
70    /// Optional input size bounds for which the autotune result applies.
71    ///
72    /// Defaulted, so entries written before this field existed still decode. Without it every
73    /// cached key on every existing installation would fail to read and re-tune from scratch.
74    #[serde(default)]
75    pub bounds: Option<crate::tune::Bounds>,
76    /// Optional execution time limit for the autotune process.
77    ///
78    /// Defaulted for the same reason as [`bounds`](Self::bounds).
79    #[serde(default)]
80    pub limit: Option<core::time::Duration>,
81}
82
83#[cfg_attr(serializable, derive(Serialize, Deserialize))]
84#[derive(Debug, Clone)]
85/// The result of an autotune job.
86pub struct AutotuneResult {
87    /// The outcome of the benchmark.
88    pub outcome: Result<AutotuneOutcome, AutotuneError>,
89}
90
91impl AutotuneResult {
92    pub(crate) fn error(error: AutotuneError) -> Self {
93        Self {
94            outcome: Err(error),
95        }
96    }
97    pub(crate) fn success(outcome: AutotuneOutcome) -> Self {
98        Self {
99            outcome: Ok(outcome),
100        }
101    }
102}
103
104impl Eq for AutotuneResult {}
105impl PartialEq for AutotuneResult {
106    fn eq(&self, other: &Self) -> bool {
107        match (&self.outcome, &other.outcome) {
108            (Ok(lhs), Ok(rhs)) => lhs == rhs,
109            (Ok(_), Err(_)) => false,
110            (Err(_), Ok(_)) => false,
111            // We don't have to check the error
112            (Err(_), Err(_)) => true,
113        }
114    }
115}
116
117/// Use to find and reuse the best kernel for some input
118#[derive(Debug)]
119pub(crate) struct TuneCache<K> {
120    /// The single in-memory home of tuning state, keyed for the per-launch
121    /// lookup: tuned picks and in-flight tunes. Cleared when the environment
122    /// switches.
123    in_memory_cache: HashMap<K, CacheEntry>,
124    /// The cards a tune found lost. Kept across environment switches: a card stays lost.
125    lost: Vec<ServiceId>,
126    /// Write-through persistence, or `None` when the persistent cache is
127    /// disabled, so no cache file is ever touched. Lazy: an entry is read
128    /// when [`Self::in_memory_cache`] misses, then lives there.
129    #[cfg(persistence)]
130    persistent_cache: Option<Store<PersistentCacheKey<K>, PersistentCacheValue>>,
131    /// The namespace the table is stored under, whether or not it is
132    /// persisted: what a [record](super::TuneRecord) names it by.
133    #[cfg(persistence)]
134    table: String,
135    /// The environment generation [`Self::in_memory_cache`] was built under;
136    /// see [`cubecl_environment::environment::generation`].
137    #[cfg(persistence)]
138    generation: u32,
139}
140
141/// Result of the cache try
142#[derive(Debug)]
143pub enum TuneCacheResult {
144    /// An operation is found.
145    Hit {
146        /// The index of the fastest operation to execute.
147        fastest_index: usize,
148    },
149    /// A tuning job is in flight for this key — the worker hasn't published a result yet.
150    /// Callers that see this fall through to running the operation rather than blocking on
151    /// the in-flight job. A card a tune found lost answers this for every key it has not settled.
152    Pending,
153    /// A [`CompileOnly`](crate::execution::ProcessMode::CompileOnly) override queued the kernels of
154    /// the key's candidates, and measured and decided nothing. Callers run the first candidate that
155    /// serves the problem, which only queues its kernels too.
156    Compiled,
157    /// No operation is found yet.
158    Miss,
159}
160
161/// The root of every tuner's table namespace.
162pub const ROOT: &str = "autotune";
163
164/// What the segment numbering a table layout starts with, as in `layout-v1`.
165pub const LAYOUT_PREFIX: &str = "layout-v";
166
167/// What names the table one tuner stores its answers in on one hardware.
168#[cfg(persistence)]
169#[derive(Clone, Copy, Debug)]
170pub struct TuneTableName<'a> {
171    /// The hardware the answers were measured on, as its [`HardwareIdentity`] displays.
172    pub hardware: &'a str,
173    /// The tuner, named by the code that declared it.
174    pub tuner: &'a str,
175}
176
177#[cfg(persistence)]
178impl TuneTableName<'_> {
179    /// `autotune/<version>/layout-v<layout>/<hardware>/<tuner>`.
180    pub fn namespace(&self) -> Namespace {
181        let Self { hardware, tuner } = self;
182        TABLES.namespace(&alloc::format!("{hardware}/{tuner}"))
183    }
184}
185
186impl<K: AutotuneKey> TuneCache<K> {
187    pub(crate) fn new(
188        #[cfg_attr(not(persistence), allow(unused_variables))] name: &str,
189        #[cfg_attr(not(persistence), allow(unused_variables))] hardware: &HardwareIdentity,
190    ) -> Self {
191        #[cfg(persistence)]
192        {
193            use crate::config::RuntimeConfig;
194            use alloc::string::ToString;
195
196            let config = crate::config::CubeClRuntimeConfig::get();
197            let namespace = TuneTableName {
198                hardware: &hardware.to_string(),
199                tuner: name,
200            }
201            .namespace();
202            let table = namespace.as_str().into();
203
204            if config.autotune.disable_cache {
205                return TuneCache {
206                    in_memory_cache: HashMap::new(),
207                    lost: Vec::new(),
208                    persistent_cache: None,
209                    table,
210                    generation: cubecl_environment::environment::generation(),
211                };
212            }
213
214            // Sampled before the store opens, so a switch landing in between
215            // reads as "rebuild", never as "this state belongs to the new
216            // environment".
217            let generation = cubecl_environment::environment::generation();
218            TuneCache {
219                in_memory_cache: HashMap::new(),
220                lost: Vec::new(),
221                persistent_cache: Some(Store::new(
222                    StoreOptions::new()
223                        .storage(namespace)
224                        .cache(CacheOption::Lazy),
225                )),
226                table,
227                generation,
228            }
229        }
230
231        #[cfg(not(persistence))]
232        {
233            TuneCache {
234                in_memory_cache: HashMap::new(),
235                lost: Vec::new(),
236            }
237        }
238    }
239
240    pub fn fastest(&self, key: &K) -> TuneCacheResult {
241        match self.in_memory_cache.get(key) {
242            Some(CacheEntry::Done { fastest_index }) => TuneCacheResult::Hit {
243                fastest_index: *fastest_index,
244            },
245            Some(CacheEntry::Pending) => TuneCacheResult::Pending,
246            // Nothing was decided: a tune of the key is still owed.
247            Some(CacheEntry::Compiled { .. }) | None => TuneCacheResult::Miss,
248        }
249    }
250
251    /// The index settled for `key`, reading only: `None` for anything but a settled result, and
252    /// for every key once the environment has switched away from the one this cache was built
253    /// under. The reset that switch owes is left to the next lookup that tunes.
254    pub(crate) fn settled(&self, key: &K) -> Option<usize> {
255        #[cfg(persistence)]
256        if self.environment_switched() {
257            return None;
258        }
259
260        match self.fastest(key) {
261            TuneCacheResult::Hit { fastest_index } => Some(fastest_index),
262            TuneCacheResult::Pending | TuneCacheResult::Compiled | TuneCacheResult::Miss => None,
263        }
264    }
265
266    /// Mark a key as being tuned. Used by [`Tuner::check_tune`] under the cache mutex so that
267    /// concurrent callers see [`TuneCacheResult::Pending`] instead of starting a second job
268    /// for the same key. Returns where the key's tune was registered, if the override that
269    /// gathered it registered it.
270    pub(crate) fn mark_pending(&mut self, key: K) -> Option<TuneRegistration> {
271        match self.in_memory_cache.insert(key, CacheEntry::Pending) {
272            Some(CacheEntry::Compiled { registration, .. }) => registration,
273            _ => None,
274        }
275    }
276
277    /// Drop the [mark](Self::mark_pending) of a tune that stopped before it settled, so the next
278    /// caller tunes the key.
279    pub(crate) fn unmark_pending(&mut self, key: &K) {
280        if let Some(CacheEntry::Pending) = self.in_memory_cache.get(key) {
281            self.in_memory_cache.remove(key);
282        }
283    }
284
285    /// Mark a key whose candidates' kernels a `CompileOnly` override is queueing on `service`, in
286    /// place of a tune: nothing is decided. The first service to queue them registers the key's
287    /// tune with `register`, and the others share that registration.
288    pub(crate) fn mark_compiled(
289        &mut self,
290        key: K,
291        service: ServiceId,
292        register: impl FnOnce() -> Option<TuneRegistration>,
293    ) {
294        if let Some(CacheEntry::Compiled { services, .. }) = self.in_memory_cache.get_mut(&key) {
295            services.push(service);
296            return;
297        }
298        self.in_memory_cache.insert(
299            key,
300            CacheEntry::Compiled {
301                registration: register(),
302                services: vec![service],
303            },
304        );
305    }
306
307    /// Whether a `CompileOnly` override already queued the kernels of `key`'s candidates on
308    /// `service`.
309    pub(crate) fn is_compiled(&self, key: &K, service: ServiceId) -> bool {
310        matches!(
311            self.in_memory_cache.get(key),
312            Some(CacheEntry::Compiled { services, .. }) if services.contains(&service)
313        )
314    }
315
316    pub(crate) fn mark_lost(&mut self, service: ServiceId) {
317        if !self.lost.contains(&service) {
318            self.lost.push(service);
319        }
320    }
321
322    pub(crate) fn is_lost(&self, service: ServiceId) -> bool {
323        self.lost.contains(&service)
324    }
325
326    pub(crate) fn cache_insert(&mut self, key: K, fastest_index: usize) {
327        self.in_memory_cache
328            .insert(key, CacheEntry::Done { fastest_index });
329    }
330}
331
332#[cfg(persistence)]
333impl<K: AutotuneKey> TuneCache<K> {
334    /// Drops tuning state belonging to a previous environment, so a switch
335    /// reads the new environment's store and tunes rather than serving the
336    /// old environment's picks. One relaxed atomic load when nothing switched.
337    ///
338    /// In-flight tunes are dropped with everything else: their completion
339    /// still records a hardware-valid result, so the whole cost of the race
340    /// is one duplicate tune per switch. A key a `CompileOnly` override
341    /// gathered for the old environment is dropped too, and its registration
342    /// settles as abandoned: the key is tuned, if at all, for the new
343    /// environment, under a registration of its own.
344    pub(crate) fn reset_if_environment_switched(&mut self) {
345        if !self.environment_switched() {
346            return;
347        }
348
349        log::debug!("Environment switched, resetting the autotune cache");
350        self.generation = cubecl_environment::environment::generation();
351        self.in_memory_cache.clear();
352    }
353
354    /// The environment generation this cache was built under, or last reset to.
355    pub(crate) fn generation(&self) -> u32 {
356        self.generation
357    }
358
359    /// Whether the environment switched since this cache was built. Never with persistence
360    /// disabled: the tuning state is then process-local and unbound, like a store without a
361    /// storage, and survives switches.
362    fn environment_switched(&self) -> bool {
363        self.persistent_cache.is_some()
364            && cubecl_environment::environment::generation() != self.generation
365    }
366
367    /// The store's answer for `key` under the set's `checksum`, read once memory missed. A miss
368    /// costs one read of the store, under the tuner mutex, and a key read counts as loaded.
369    pub(crate) fn look_up(&mut self, key: &K, checksum: &str) -> TuneCacheResult {
370        let Some(persistent_cache) = self.persistent_cache.as_mut() else {
371            return TuneCacheResult::Miss;
372        };
373        let stored = PersistentCacheKey {
374            key: key.clone(),
375            checksum: checksum.into(),
376        };
377        let Some(value) = persistent_cache.remove(&stored) else {
378            return TuneCacheResult::Miss;
379        };
380        let gathered = match self.in_memory_cache.remove(key) {
381            Some(CacheEntry::Compiled { registration, .. }) => registration,
382            _ => None,
383        };
384        if let Some(registration) = gathered.or_else(TuneRegistration::register) {
385            registration.settle(TuneOutcome::Loaded);
386        }
387        self.cache_insert(key.clone(), value.fastest_index);
388        TuneCacheResult::Hit {
389            fastest_index: value.fastest_index,
390        }
391    }
392
393    /// The namespace the table is stored under.
394    pub(crate) fn table(&self) -> &str {
395        &self.table
396    }
397
398    /// Store an answer in the table, and say whether it took it: never when
399    /// the cache is disabled.
400    pub(crate) fn persistent_cache_insert(
401        &mut self,
402        key: K,
403        checksum: String,
404        value: PersistentCacheValue,
405    ) -> bool {
406        let Some(persistent_cache) = self.persistent_cache.as_mut() else {
407            return false;
408        };
409
410        let Err(err) = persistent_cache.insert(PersistentCacheKey { key, checksum }, value) else {
411            return true;
412        };
413        match err {
414            StoreError::DuplicatedKey {
415                key,
416                value_previous,
417                value_updated,
418            } => log::warn!(
419                "Autotune the same function multiple times for key {key:?} => old {value_previous:?}, new {value_updated:?}"
420            ),
421            // Another process sharing the cache root tuned this key first.
422            // Routine with N training processes on a cold cache, and both
423            // results are valid, so it stays quiet: warning here would
424            // print a full result payload per key on every cold start.
425            StoreError::KeyOutOfSync { key, .. } => {
426                log::debug!("Autotune result for key {key:?} was already stored concurrently")
427            }
428            StoreError::Backend { key, error } => log::warn!(
429                "Autotune result for key {key:?} could not be stored, it will be re-tuned: {error}"
430            ),
431        }
432        false
433    }
434}