Skip to main content

cubecl_runtime/tune/
tune_cache.rs

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