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    /// The index settled for `key`, reading only: `None` for anything but a verified result, and
262    /// for every key once the environment has switched away from the one this cache was built
263    /// under — the reset that switch owes is left to the next lookup that tunes.
264    pub(crate) fn settled(&self, key: &K) -> Option<usize> {
265        #[cfg(persistence)]
266        if self.environment_switched() {
267            return None;
268        }
269
270        match self.fastest(key) {
271            TuneCacheResult::Hit { fastest_index } => Some(fastest_index),
272            TuneCacheResult::Unchecked | TuneCacheResult::Pending | TuneCacheResult::Miss => None,
273        }
274    }
275
276    /// Mark a key as being tuned. Used by [`Tuner::check_tune`] under the cache mutex so that
277    /// concurrent callers see [`TuneCacheResult::Pending`] instead of starting a second job
278    /// for the same key.
279    pub(crate) fn mark_pending(&mut self, key: K) {
280        self.in_memory_cache.insert(key, CacheEntry::Pending);
281    }
282
283    pub(crate) fn cache_insert(&mut self, key: K, fastest_index: usize) {
284        self.in_memory_cache.insert(
285            key,
286            CacheEntry::Done {
287                checksum: ChecksumState::Match,
288                fastest_index,
289            },
290        );
291    }
292}
293
294#[cfg(persistence)]
295impl<K: AutotuneKey> TuneCache<K> {
296    /// Drops tuning state belonging to a previous environment, so a switch
297    /// re-hydrates and re-tunes rather than serving the old environment's
298    /// picks. One relaxed atomic load when nothing switched.
299    ///
300    /// In-flight tunes are dropped with everything else: their completion
301    /// still records a hardware-valid result, so the whole cost of the race
302    /// is one duplicate tune per switch.
303    pub(crate) fn reset_if_environment_switched(&mut self) {
304        if !self.environment_switched() {
305            return;
306        }
307
308        log::debug!("Environment switched, resetting the autotune cache");
309        self.generation = cubecl_environment::environment::generation();
310        self.in_memory_cache.clear();
311        self.hydrated = false;
312    }
313
314    /// Whether the environment switched since this cache was built. Never with persistence
315    /// disabled: the tuning state is then process-local and unbound, like a store without a
316    /// storage, and survives switches.
317    fn environment_switched(&self) -> bool {
318        self.persistent_cache.is_some()
319            && cubecl_environment::environment::generation() != self.generation
320    }
321
322    /// Ingest everything the persistent store holds into the in-memory cache,
323    /// as unverified entries.
324    ///
325    /// Runs at construction, and again whenever `hydrated` fell back to
326    /// `false` after an environment switch. Once hydrated, a miss costs one
327    /// bool check here — never a walk, and never a rescan of the database
328    /// under the tuner mutex.
329    ///
330    /// Returns how many entries the store delivered.
331    pub(crate) fn sync_persistent(&mut self) -> usize {
332        if self.hydrated {
333            return 0;
334        }
335
336        let Some(persistent_cache) = self.persistent_cache.as_mut() else {
337            return 0;
338        };
339
340        let mut delivered = 0;
341        persistent_cache.scan(|key, value| {
342            delivered += 1;
343            self.in_memory_cache
344                .entry(key.key)
345                .or_insert(CacheEntry::Done {
346                    checksum: ChecksumState::ToBeVerified(key.checksum),
347                    fastest_index: value.fastest_index,
348                });
349        });
350        self.hydrated = true;
351
352        delivered
353    }
354
355    /// The namespace the table is stored under.
356    pub(crate) fn table(&self) -> &str {
357        &self.table
358    }
359
360    /// Store an answer in the table, and say whether it took it: never when
361    /// the cache is disabled.
362    pub(crate) fn persistent_cache_insert(
363        &mut self,
364        key: K,
365        checksum: String,
366        value: PersistentCacheValue,
367    ) -> bool {
368        let Some(persistent_cache) = self.persistent_cache.as_mut() else {
369            return false;
370        };
371
372        let Err(err) = persistent_cache.insert(PersistentCacheKey { key, checksum }, value) else {
373            return true;
374        };
375        match err {
376            StoreError::DuplicatedKey {
377                key,
378                value_previous,
379                value_updated,
380            } => log::warn!(
381                "Autotune the same function multiple times for key {key:?} => old {value_previous:?}, new {value_updated:?}"
382            ),
383            // Another process sharing the cache root tuned this key first.
384            // Routine with N training processes on a cold cache, and both
385            // results are valid, so it stays quiet: warning here would
386            // print a full result payload per key on every cold start.
387            StoreError::KeyOutOfSync { key, .. } => {
388                log::debug!("Autotune result for key {key:?} was already stored concurrently")
389            }
390            StoreError::Backend { key, error } => log::warn!(
391                "Autotune result for key {key:?} could not be stored, it will be retuned: {error}"
392            ),
393        }
394        false
395    }
396}