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}