Skip to main content

cubecl_server/
compiler.rs

1//! Compilation caching for a runtime: the persistent store and the in-memory
2//! cache in front of it. The [`Compiler`] contract itself lives in
3//! `cubecl-runtime` and is re-exported here.
4
5pub use cubecl_runtime::compiler::*;
6
7use crate::id::KernelId;
8use core::hash::Hash;
9use cubecl_common::hash::{StableHash, StableHasher};
10use cubecl_environment::collections::HashMap;
11#[cfg(compilation_cache)]
12use cubecl_environment::persistence::{CacheOption, Namespace, StoreOptions};
13use cubecl_environment::persistence::{Store, StoreKey, StoreValue};
14use cubecl_environment::records::{Record, RecordEffect, RecordLevel, Span};
15
16/// Platform-specific build identifier, changes on rebuild
17pub type BuildId = Option<&'static [u8]>;
18
19/// Pre-hashed build ID
20pub fn build_id_hash() -> StableHash {
21    StableHasher::hash_one(&buildid::build_id())
22}
23
24/// A store for `backend`'s compiled artifacts, or `None` when compilation
25/// caching is disabled or the target has nowhere durable to put them.
26///
27/// `fingerprint` names what the artifacts were built for — an architecture, a
28/// device — and becomes part of the namespace. Compiled code is not portable
29/// across those, so this is what keeps a bundle shipped between machines from
30/// serving the wrong binary. It needs no sanitizing: a namespace is a database
31/// column, never a path.
32pub fn compilation_store<K: StoreKey, V: StoreValue>(
33    backend: &'static str,
34    fingerprint: impl AsRef<str>,
35) -> Option<Store<K, V>> {
36    #[cfg(compilation_cache)]
37    {
38        use crate::config::RuntimeConfig;
39
40        if !crate::config::CubeClRuntimeConfig::get().compilation.cache {
41            return None;
42        }
43
44        Some(Store::new(
45            StoreOptions::new()
46                .storage(Namespace::scoped(backend, fingerprint))
47                .cache(CacheOption::Lazy),
48        ))
49    }
50
51    // No file system to persist to; the caller keeps its in-memory map.
52    #[cfg(not(compilation_cache))]
53    {
54        let _ = (backend, fingerprint);
55        None
56    }
57}
58
59/// Stores a freshly compiled artifact, logging rather than failing, and says
60/// whether the store took it.
61///
62/// A refused write is routine, not exceptional: another process sharing the
63/// environment may have written the key first, or the backing store may have
64/// declined it. The artifact was just compiled either way, so the whole cost
65/// is compiling it again next run.
66pub fn store_compiled<K: StoreKey, V: StoreValue>(
67    store: &mut Store<K, V>,
68    key: K,
69    value: V,
70) -> bool {
71    match store.insert(key, value) {
72        Ok(()) => true,
73        Err(err) => {
74            log::warn!("Unable to cache the compiled kernel: {}", err.reason());
75            false
76        }
77    }
78}
79
80/// One kernel's trip through a backend's compilation path, as the environment
81/// records it: compiled fresh, or loaded from the compilation store.
82///
83/// A hit in a server's in-memory cache is not a trip and is not recorded:
84/// nothing here runs per launch. Neither is a trip that fails: the launch
85/// error carries that account, and the environment holds nothing of it.
86#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
87pub struct CompilationRecord {
88    /// The kernel's type.
89    pub kernel: alloc::string::String,
90    /// The store entry naming the artifact: what tells two instances of one
91    /// kernel type apart.
92    pub key: KernelCacheKey,
93    /// The kernel as cubecl defined it, before the backend's compiler — the
94    /// IR's textual form, for a reader to render — at [`RecordLevel::Full`].
95    /// Only a trip that misses the store defines the kernel, so a
96    /// [`Loaded`](CompilationOutcome::Loaded) one carries none.
97    pub ir: Option<alloc::string::String>,
98    /// How the artifact was obtained.
99    pub outcome: CompilationOutcome,
100    /// What obtaining it took, from where the trip started to the artifact
101    /// loaded on the device.
102    pub duration: core::time::Duration,
103    /// The source the backend compiled, at [`RecordLevel::Full`].
104    pub source: Option<alloc::string::String>,
105}
106
107/// How a backend obtained a kernel's artifact.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
109pub enum CompilationOutcome {
110    /// Compiled from its definition: expanded, compiled by the backend's
111    /// compiler, loaded on the device.
112    Compiled,
113    /// Read from the compilation store and loaded on the device.
114    Loaded,
115    /// Expanded to a source the store already held an artifact for, under
116    /// another key: the artifact was moved under this one and loaded, and the
117    /// backend's compiler never ran.
118    Rekeyed,
119}
120
121impl Record for CompilationRecord {
122    const KIND: &'static str = "compilation";
123}
124
125/// A compilation being recorded: a backend opens one where its compilation
126/// path starts — past its in-memory cache — tells it what the trip goes
127/// through, and closes it with how the artifact was obtained. Every call is a
128/// no-op when the environment records nothing, and one dropped unclosed, by a
129/// trip that failed, records nothing.
130#[derive(Debug)]
131pub struct CompilationRecording {
132    open: Option<OpenRecording>,
133}
134
135/// What a [`CompilationRecording`] holds while the environment records.
136#[derive(Debug)]
137struct OpenRecording {
138    span: Span,
139    kernel: &'static str,
140    key: KernelCacheKey,
141    ir: Option<alloc::string::String>,
142    source: Option<alloc::string::String>,
143}
144
145impl CompilationRecording {
146    /// Start recording `kernel_id`'s trip.
147    pub fn new(kernel_id: &KernelId) -> Self {
148        let open = Span::new().map(|span| OpenRecording {
149            span,
150            kernel: kernel_id.type_name(),
151            key: KernelCacheKey::new(kernel_id, build_id_hash()),
152            ir: None,
153            source: None,
154        });
155        Self { open }
156    }
157
158    /// The kernel was defined: keep its IR, at [`RecordLevel::Full`] only.
159    /// The textual IR runs to hundreds of KB per kernel, where the compiled
160    /// artifact is tens.
161    pub fn defined(&mut self, definition: &crate::kernel::KernelDefinition) {
162        if let Some(open) = self.open.as_mut().filter(|_| keeps_code()) {
163            open.ir = Some(alloc::format!("{}", definition.body));
164        }
165    }
166
167    /// The backend's compiler produced `source`: keep it, at
168    /// [`RecordLevel::Full`] only.
169    pub fn source(&mut self, source: &str) {
170        if let Some(open) = self.open.as_mut().filter(|_| keeps_code()) {
171            open.source = Some(source.into());
172        }
173    }
174
175    /// The artifact came from the compilation store: the environment did not
176    /// change.
177    pub fn loaded(self) {
178        self.close(CompilationOutcome::Loaded, RecordEffect::Observed);
179    }
180
181    /// The artifact was compiled. `stored` is whether the store took it, as
182    /// [`store_compiled`] answers: a compile the store did not take, or with
183    /// no store to take it, changed nothing.
184    pub fn compiled(self, stored: bool) {
185        self.close(CompilationOutcome::Compiled, effect(stored));
186    }
187
188    /// The artifact was already stored under another key, and moved under
189    /// this one. `stored` is whether the store took it there.
190    pub fn rekeyed(self, stored: bool) {
191        self.close(CompilationOutcome::Rekeyed, effect(stored));
192    }
193
194    fn close(self, outcome: CompilationOutcome, effect: RecordEffect) {
195        let Some(open) = self.open else {
196            return;
197        };
198        let Some(duration) = open.span.elapsed() else {
199            return;
200        };
201        let record = CompilationRecord {
202            kernel: open.kernel.into(),
203            key: open.key,
204            ir: open.ir,
205            outcome,
206            duration,
207            source: open.source,
208        };
209        open.span.close(effect, &record);
210    }
211}
212
213/// Key for an entry in the persistent compilation cache.
214///
215/// The [id](KernelId) alone doesn't describe what a kernel does: it covers the kernel type, its
216/// comptime arguments and its launch settings, but nothing of the body. Pairing it with a hash of
217/// the expanded IR is what lets a cached artifact be invalidated when the code behind it changes.
218#[derive(
219    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
220)]
221pub struct KernelCacheKey {
222    /// Hash of the [kernel id](KernelId).
223    pub id: StableHash,
224    /// Hash of the [build id](buildid::build_id).
225    pub build_id: StableHash,
226}
227
228impl KernelCacheKey {
229    /// Create a key from a kernel id and the current build ID.
230    pub fn new(id: &KernelId, build_id: StableHash) -> Self {
231        Self {
232            id: id.stable_hash(),
233            build_id,
234        }
235    }
236}
237
238/// A server's in-memory compilation cache: the compiled artifacts it memoizes
239/// — pipelines, loaded modules — in front of a persistent [`compilation_store`].
240///
241/// Entries are dropped when the environment switches, because the map is bound
242/// to an environment exactly as the store it mirrors is. One served after a
243/// switch would describe the environment that is gone, and, worse, would never
244/// be written to the new environment's store, so a bundle exported from that
245/// environment would silently be missing that kernel. This is the same contract
246/// [`Store`] applies to itself, for the state a store cannot see — see
247/// [`cubecl_environment::environment::generation`].
248///
249/// Every accessor resets before it answers, so a backend has nothing to
250/// remember beyond using this in place of a plain map.
251#[derive(Debug)]
252pub struct CompilationCache<K, V> {
253    entries: HashMap<K, V>,
254    /// The generation the entries were built under, or `None` when the cache
255    /// mirrors no store and so is unbound.
256    generation: Option<u32>,
257}
258
259impl<K: Eq + Hash, V> CompilationCache<K, V> {
260    /// An empty cache in front of `store`, bound to the active environment
261    /// exactly when that store exists.
262    ///
263    /// Unbound otherwise: with nothing persisted, a switch changes nothing
264    /// about what the cache holds, so resetting it would only buy a redundant
265    /// compilation — the same reason the autotune cache survives a switch when
266    /// its persistent cache is off.
267    pub fn mirroring<SK: StoreKey, SV: StoreValue>(store: &Option<Store<SK, SV>>) -> Self {
268        Self {
269            entries: HashMap::new(),
270            generation: store
271                .is_some()
272                .then(cubecl_environment::environment::generation),
273        }
274    }
275
276    /// An empty cache that no environment switch ever resets, for a backend
277    /// with no persistent store to mirror.
278    pub fn unbound() -> Self {
279        Self {
280            entries: HashMap::new(),
281            generation: None,
282        }
283    }
284
285    /// The artifact compiled for `key`, if it is still valid.
286    pub fn get(&mut self, key: &K) -> Option<&V> {
287        self.reset_if_switched();
288        self.entries.get(key)
289    }
290
291    /// Whether an artifact for `key` is cached and still valid.
292    pub fn contains(&mut self, key: &K) -> bool {
293        self.reset_if_switched();
294        self.entries.contains_key(key)
295    }
296
297    /// Records a freshly compiled artifact.
298    pub fn insert(&mut self, key: K, value: V) {
299        self.reset_if_switched();
300        self.entries.insert(key, value);
301    }
302
303    /// Drops every entry when the environment switched since the last access,
304    /// adopting the new generation so one switch costs one reset.
305    fn reset_if_switched(&mut self) {
306        let Some(generation) = self.generation else {
307            return;
308        };
309
310        let current = cubecl_environment::environment::generation();
311        if current == generation {
312            return;
313        }
314
315        log::debug!("Environment switched, dropping the in-memory compilation cache");
316        self.generation = Some(current);
317        self.entries.clear();
318    }
319}
320
321/// Whether a record keeps the kernel's code, its IR and its source: code is
322/// the heaviest thing a record can carry.
323fn keeps_code() -> bool {
324    cubecl_environment::records::level() == RecordLevel::Full
325}
326
327/// An artifact the store took is the environment changing.
328fn effect(stored: bool) -> RecordEffect {
329    if stored {
330        RecordEffect::Changed
331    } else {
332        RecordEffect::Observed
333    }
334}