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}