Skip to main content

cubecl_runtime/tune/
tune_cache.rs

1#[cfg(persistence)]
2use alloc::vec::Vec;
3
4#[cfg(persistence)]
5use cubecl_environment::persistence::StoreError;
6#[cfg(persistence)]
7use cubecl_environment::persistence::{CacheOption, Namespace, Store, StoreOptions};
8#[cfg(serializable)]
9use serde::{Deserialize, Serialize};
10
11use super::{AutotuneError, AutotuneKey, AutotuneOutcome};
12use alloc::string::String;
13use cubecl_environment::collections::HashMap;
14
15#[derive(Debug)]
16pub(crate) enum CacheEntry {
17    Done {
18        checksum: ChecksumState,
19        fastest_index: usize,
20    },
21    Pending,
22}
23
24#[derive(Debug)]
25#[allow(dead_code)] // Some variants are not created when the cache isn't saved.
26pub(crate) enum ChecksumState {
27    Match,
28    NoMatch,
29    ToBeVerified(String),
30}
31
32/// Persistent cache key
33#[cfg(persistence)]
34#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Hash)]
35pub struct PersistentCacheKey<K> {
36    /// The autotune key identifying the operation.
37    pub key: K,
38    /// The checksum of the candidate list the key was tuned under: an answer
39    /// to another list is an answer to another question.
40    pub checksum: String,
41}
42
43/// Persistent cache entry
44///
45/// Only [`fastest_index`](Self::fastest_index) is read back: hydration seeds the in-memory cache
46/// from it and nothing else. Everything below it is stored so a cache entry can be inspected after
47/// the fact — why a kernel won, against which measurements, and under which bounds — which is the
48/// question that cannot be answered from a live process once tuning is over. That is also why the
49/// type is `pub`: reading an entry back is the point.
50#[cfg(persistence)]
51#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)]
52pub struct PersistentCacheValue {
53    /// Index of the fastest candidate operation.
54    pub fastest_index: usize,
55    /// Benchmarking results for all autotune candidates.
56    pub results: Vec<AutotuneResult>,
57    /// Optional input size bounds for which the autotune result applies.
58    ///
59    /// Defaulted, so entries written before this field existed still decode. Without it every
60    /// cached key on every existing installation would fail to read and re-tune from scratch.
61    #[serde(default)]
62    pub bounds: Option<crate::tune::Bounds>,
63    /// Optional execution time limit for the autotune process.
64    ///
65    /// Defaulted for the same reason as [`bounds`](Self::bounds).
66    #[serde(default)]
67    pub limit: Option<core::time::Duration>,
68}
69
70#[cfg_attr(serializable, derive(Serialize, Deserialize))]
71#[derive(Debug, Clone)]
72/// The result of an autotune job.
73pub struct AutotuneResult {
74    /// The outcome of the benchmark.
75    pub outcome: Result<AutotuneOutcome, AutotuneError>,
76}
77
78impl AutotuneResult {
79    pub(crate) fn error(error: AutotuneError) -> Self {
80        Self {
81            outcome: Err(error),
82        }
83    }
84    pub(crate) fn success(outcome: AutotuneOutcome) -> Self {
85        Self {
86            outcome: Ok(outcome),
87        }
88    }
89}
90
91impl Eq for AutotuneResult {}
92impl PartialEq for AutotuneResult {
93    fn eq(&self, other: &Self) -> bool {
94        match (&self.outcome, &other.outcome) {
95            (Ok(lhs), Ok(rhs)) => lhs == rhs,
96            (Ok(_), Err(_)) => false,
97            (Err(_), Ok(_)) => false,
98            // We don't have to check the error
99            (Err(_), Err(_)) => true,
100        }
101    }
102}
103
104/// Use to find and reuse the best kernel for some input
105#[derive(Debug)]
106pub(crate) struct TuneCache<K> {
107    /// The single in-memory home of tuning state, keyed for the per-launch
108    /// lookup: tuned picks, in-flight tunes and checksum verdicts. Hydrated
109    /// from the store, which retains nothing itself, and rebuilt when the
110    /// environment switches.
111    in_memory_cache: HashMap<K, CacheEntry>,
112    /// Write-through persistence, or `None` when the persistent cache is
113    /// disabled, so no cache file is ever touched. Lazy: entries live in
114    /// [`Self::in_memory_cache`] once hydrated, not here.
115    #[cfg(persistence)]
116    persistent_cache: Option<Store<PersistentCacheKey<K>, PersistentCacheValue>>,
117    /// The namespace the table is stored under, whether or not it is
118    /// persisted: what a [record](super::TuneRecord) names it by.
119    #[cfg(persistence)]
120    table: String,
121    /// Whether everything the store holds has been ingested into
122    /// [`Self::in_memory_cache`]. What makes an ordinary miss cost a bool
123    /// check rather than a walk; `false` until the first sync, and again
124    /// after an environment switch.
125    #[cfg(persistence)]
126    hydrated: bool,
127    /// The environment generation [`Self::in_memory_cache`] was built under;
128    /// see [`cubecl_environment::environment::generation`].
129    #[cfg(persistence)]
130    generation: u32,
131}
132
133/// Result of the cache try
134#[derive(Debug)]
135pub enum TuneCacheResult {
136    /// An operation is found.
137    Hit {
138        /// The index of the fastest operation to execute.
139        fastest_index: usize,
140    },
141    /// The operation might be cached, but we don't know yet whether the checksum is valid.
142    Unchecked,
143    /// A tuning job is in flight for this key — the worker hasn't published a result yet.
144    /// Callers that see this fall through to running the operation rather than blocking on
145    /// the in-flight job.
146    Pending,
147    /// No operation is found yet.
148    Miss,
149}
150
151impl<K: AutotuneKey> TuneCache<K> {
152    pub(crate) fn new(
153        #[cfg_attr(not(persistence), allow(unused_variables))] name: &str,
154        #[cfg_attr(not(persistence), allow(unused_variables))] device_id: &str,
155    ) -> Self {
156        #[cfg(persistence)]
157        {
158            use crate::config::RuntimeConfig;
159            use alloc::format;
160
161            let config = crate::config::CubeClRuntimeConfig::get();
162            let namespace = Namespace::scoped("autotune", format!("{device_id}/{name}"));
163            let table = namespace.as_str().into();
164
165            if config.autotune.disable_cache {
166                return TuneCache {
167                    in_memory_cache: HashMap::new(),
168                    persistent_cache: None,
169                    table,
170                    hydrated: true,
171                    generation: cubecl_environment::environment::generation(),
172                };
173            }
174
175            // Sampled before the store opens, so a switch landing in between
176            // reads as "rebuild", never as "this state belongs to the new
177            // environment".
178            let generation = cubecl_environment::environment::generation();
179            let mut cache = TuneCache {
180                in_memory_cache: HashMap::new(),
181                persistent_cache: Some(Store::new(
182                    StoreOptions::new()
183                        .storage(namespace)
184                        .cache(CacheOption::Lazy),
185                )),
186                table,
187                hydrated: false,
188                generation,
189            };
190            log::info!("Load autotune cache ...");
191            let loaded = cache.sync_persistent();
192            log::info!("Loaded {loaded} autotune cached entries");
193
194            cache
195        }
196
197        #[cfg(not(persistence))]
198        {
199            TuneCache {
200                in_memory_cache: HashMap::new(),
201            }
202        }
203    }
204
205    pub fn fastest(&self, key: &K) -> TuneCacheResult {
206        let Some(val) = self.in_memory_cache.get(key) else {
207            return TuneCacheResult::Miss;
208        };
209
210        let CacheEntry::Done {
211            checksum,
212            fastest_index,
213        } = val
214        else {
215            // Pending: clone the receiver so the caller can subscribe to the in-flight tune.
216            let CacheEntry::Pending = val else {
217                unreachable!()
218            };
219            return TuneCacheResult::Pending;
220        };
221
222        if cfg!(persistence) {
223            match checksum {
224                ChecksumState::ToBeVerified(..) => TuneCacheResult::Unchecked, // Don't know yet.
225                ChecksumState::NoMatch => TuneCacheResult::Miss,               // Can't use this.
226                ChecksumState::Match => TuneCacheResult::Hit {
227                    fastest_index: *fastest_index,
228                },
229            }
230        } else {
231            // Clippy;
232            let _ = checksum;
233            TuneCacheResult::Hit {
234                fastest_index: *fastest_index,
235            }
236        }
237    }
238
239    #[cfg(persistence)]
240    pub fn validate_checksum(&mut self, key: &K, checksum: &str) -> TuneCacheResult {
241        let Some(val) = self.in_memory_cache.get_mut(key) else {
242            return TuneCacheResult::Miss;
243        };
244
245        if let CacheEntry::Done {
246            checksum: checksum_state,
247            ..
248        } = val
249            && let ChecksumState::ToBeVerified(checksum_expected) = checksum_state
250        {
251            if checksum_expected == checksum {
252                *checksum_state = ChecksumState::Match;
253            } else {
254                *checksum_state = ChecksumState::NoMatch;
255            }
256        }
257
258        self.fastest(key)
259    }
260
261    /// Mark a key as being tuned. Used by [`Tuner::check_tune`] under the cache mutex so that
262    /// concurrent callers see [`TuneCacheResult::Pending`] instead of starting a second job
263    /// for the same key.
264    pub(crate) fn mark_pending(&mut self, key: K) {
265        self.in_memory_cache.insert(key, CacheEntry::Pending);
266    }
267
268    pub(crate) fn cache_insert(&mut self, key: K, fastest_index: usize) {
269        self.in_memory_cache.insert(
270            key,
271            CacheEntry::Done {
272                checksum: ChecksumState::Match,
273                fastest_index,
274            },
275        );
276    }
277}
278
279#[cfg(persistence)]
280impl<K: AutotuneKey> TuneCache<K> {
281    /// Drops tuning state belonging to a previous environment, so a switch
282    /// re-hydrates and re-tunes rather than serving the old environment's
283    /// picks. One relaxed atomic load when nothing switched.
284    ///
285    /// In-flight tunes are dropped with everything else: their completion
286    /// still records a hardware-valid result, so the whole cost of the race
287    /// is one duplicate tune per switch.
288    pub(crate) fn reset_if_environment_switched(&mut self) {
289        // Persistence disabled means the tuning state is process-local and
290        // unbound, like a store without a storage: it survives switches.
291        if self.persistent_cache.is_none() {
292            return;
293        }
294
295        let generation = cubecl_environment::environment::generation();
296        if generation == self.generation {
297            return;
298        }
299
300        log::debug!("Environment switched, resetting the autotune cache");
301        self.generation = generation;
302        self.in_memory_cache.clear();
303        self.hydrated = false;
304    }
305
306    /// Ingest everything the persistent store holds into the in-memory cache,
307    /// as unverified entries.
308    ///
309    /// Runs at construction, and again whenever `hydrated` fell back to
310    /// `false` after an environment switch. Once hydrated, a miss costs one
311    /// bool check here — never a walk, and never a rescan of the database
312    /// under the tuner mutex.
313    ///
314    /// Returns how many entries the store delivered.
315    pub(crate) fn sync_persistent(&mut self) -> usize {
316        if self.hydrated {
317            return 0;
318        }
319
320        let Some(persistent_cache) = self.persistent_cache.as_mut() else {
321            return 0;
322        };
323
324        let mut delivered = 0;
325        persistent_cache.scan(|key, value| {
326            delivered += 1;
327            self.in_memory_cache
328                .entry(key.key)
329                .or_insert(CacheEntry::Done {
330                    checksum: ChecksumState::ToBeVerified(key.checksum),
331                    fastest_index: value.fastest_index,
332                });
333        });
334        self.hydrated = true;
335
336        delivered
337    }
338
339    /// The namespace the table is stored under.
340    pub(crate) fn table(&self) -> &str {
341        &self.table
342    }
343
344    /// Store an answer in the table, and say whether it took it: never when
345    /// the cache is disabled.
346    pub(crate) fn persistent_cache_insert(
347        &mut self,
348        key: K,
349        checksum: String,
350        value: PersistentCacheValue,
351    ) -> bool {
352        let Some(persistent_cache) = self.persistent_cache.as_mut() else {
353            return false;
354        };
355
356        let Err(err) = persistent_cache.insert(PersistentCacheKey { key, checksum }, value) else {
357            return true;
358        };
359        match err {
360            StoreError::DuplicatedKey {
361                key,
362                value_previous,
363                value_updated,
364            } => log::warn!(
365                "Autotune the same function multiple times for key {key:?} => old {value_previous:?}, new {value_updated:?}"
366            ),
367            // Another process sharing the cache root tuned this key first.
368            // Routine with N training processes on a cold cache, and both
369            // results are valid, so it stays quiet: warning here would
370            // print a full result payload per key on every cold start.
371            StoreError::KeyOutOfSync { key, .. } => {
372                log::debug!("Autotune result for key {key:?} was already stored concurrently")
373            }
374            StoreError::Backend { key, error } => log::warn!(
375                "Autotune result for key {key:?} could not be stored, it will be retuned: {error}"
376            ),
377        }
378        false
379    }
380}