cubecl_environment/environment.rs
1//! Named environments.
2//!
3//! An environment is one named local store: a single database holding every
4//! namespace this machine has warmed. Exactly one is active at a time, and
5//! every [`Store`] bound to it goes to it.
6//!
7//! Like `std`, the environment is a namespace rather than a value: a set of
8//! functions over one global state, not an `Environment` struct to pass
9//! around. [`store`] creates stores in it, [`bundle`] captures it for
10//! shipping, and [`activate`]/[`set_root`]/[`load`] switch it.
11//!
12//! Naming them makes it possible to keep several side by side, which is what
13//! you want when the same checkout targets more than one machine or you want a
14//! throwaway environment for an experiment:
15//!
16//! ```ignore
17//! cubecl_environment::environment::activate("h100");
18//! ```
19//!
20//! Switching is dynamic: every store bound to the environment detects the
21//! switch and resets — the in-memory cache is dropped and the storage is
22//! reopened against the new environment. Detection is one atomic load on the
23//! store's read path, so an environment that never switches costs nothing.
24
25use crate::sync::{AtomicU32, Ordering};
26use alloc::string::{String, ToString};
27#[cfg(std_io)]
28use alloc::vec::Vec;
29
30use crate::persistence::{StoreKey, StoreValue};
31use crate::sync::{Arc, Lazy, Mutex};
32
33pub use crate::persistence::{CacheOption, Namespace, Store, StoreOptions};
34
35/// The environment used when none is chosen.
36pub const DEFAULT: &str = "default";
37
38/// The extension of an environment's database file.
39#[cfg(std_io)]
40pub const EXTENSION: &str = "db";
41
42/// The active environment: its name, and where environments are kept.
43///
44/// Both live here rather than being passed per store, because an environment
45/// *is* the store: letting one cache be opened under a different root would
46/// make "a single environment" untrue. Anything that needs both reads them
47/// through a single [`active_state`] snapshot, so a concurrent [`activate`] or
48/// [`set_root`] can never be observed half-applied.
49#[derive(Debug, Clone)]
50struct Active {
51 /// Shared rather than owned per reader: [`active`] is called on every path
52 /// that opens a store, and the name is immutable once [`activate`] set it,
53 /// so handing out a handle costs a refcount bump instead of an allocation.
54 name: Arc<str>,
55 #[cfg(std_io)]
56 root: Option<std::path::PathBuf>,
57 /// An explicit database file mounted by [`load`], overriding
58 /// `<root>/<name>.db`. Cleared by [`activate`] and [`set_root`], which
59 /// select named environments again.
60 #[cfg(std_io)]
61 file: Option<std::path::PathBuf>,
62}
63
64static ACTIVE: Lazy<Mutex<Active>> = Lazy::new(|| {
65 Mutex::new(Active {
66 name: Arc::from(DEFAULT),
67 #[cfg(std_io)]
68 root: None,
69 #[cfg(std_io)]
70 file: None,
71 })
72});
73
74/// Bumped on every switch. Stores bound to the environment record the value
75/// they were opened under and compare on access, which is what lets a switch
76/// reach stores that already exist without any registry of them: a mismatch
77/// reads as "reset before serving".
78static GENERATION: AtomicU32 = AtomicU32::new(0);
79
80/// An opaque token that changes on every environment switch.
81///
82/// Record it when deriving state from the environment — an index built over a
83/// [`Store`], a map hydrated from one — and compare on access: a different
84/// value means the derived state describes an environment that is no longer
85/// active and must be rebuilt. This is the same mechanism [`Store`] uses to
86/// reset itself, exposed for state the stores can't see. The load is relaxed
87/// and costs nothing on hot paths.
88pub fn generation() -> u32 {
89 GENERATION.load(Ordering::Relaxed)
90}
91
92/// Called under the [`ACTIVE`] lock by everything that switches, so a store
93/// can never observe the new generation with the old location.
94fn switched() {
95 GENERATION.fetch_add(1, Ordering::Relaxed);
96}
97
98/// A consistent snapshot of both fields. Only the paths that need the root
99/// take it; [`active`] reads the name directly rather than allocating a
100/// `PathBuf` it would discard.
101#[cfg(std_io)]
102fn active_state() -> Active {
103 ACTIVE.lock().clone()
104}
105
106/// Makes `name` the active environment.
107///
108/// Takes effect immediately: stores bound to the previous environment reset
109/// on their next access and reopen against this one.
110///
111/// A switch is not free to repeat. Backends also drop the compiled artifacts
112/// they memoized for the old environment, and on CUDA and HIP the modules those
113/// entries named stay resident, because nothing can safely unload a module a
114/// stream may still have queued work against. Switching a handful of times at
115/// startup costs a bounded amount of device memory and one recompilation per
116/// kernel; switching per request grows resident modules without bound.
117pub fn activate<N: AsRef<str>>(name: N) {
118 let name = sanitize(name.as_ref());
119 log::debug!("Activating environment '{name}'");
120
121 let mut active = ACTIVE.lock();
122 active.name = name.into();
123 #[cfg(std_io)]
124 {
125 active.file = None;
126 }
127 switched();
128}
129
130/// A stable identity for the active environment, distinguishing one from
131/// another for backends that key by it rather than by a file path.
132///
133/// The database backend already isolates environments by their file path; the
134/// in-memory fallback ([`MemoryStorage`](crate::persistence::MemoryStorage))
135/// has no file, so it scopes its process-wide entries by this instead, and a
136/// switch reaches it the same way. On targets with a file system the identity
137/// is the database path, which folds in the name, the root and any mounted
138/// file; elsewhere the name is the whole identity, since [`load`]/[`set_root`]
139/// don't exist there.
140#[cfg(std_io)]
141pub(crate) fn scope() -> String {
142 path().display().to_string()
143}
144
145#[cfg(not(std_io))]
146pub(crate) fn scope() -> String {
147 active().to_string()
148}
149
150/// The active environment.
151///
152/// The returned handle derefs to `str`, and cloning it is a refcount bump, so
153/// this is cheap enough to call wherever a store is opened.
154pub fn active() -> Arc<str> {
155 // Deliberately not through `active_state`: that snapshots the root too,
156 // which would allocate a `PathBuf` this caller never looks at.
157 ACTIVE.lock().name.clone()
158}
159
160/// Sets the directory environments are kept in.
161///
162/// Like [`activate`], this takes effect immediately for every bound store.
163#[cfg(std_io)]
164pub fn set_root<P: Into<std::path::PathBuf>>(root: P) {
165 let root = root.into();
166 log::debug!("Environments rooted at {root:?}");
167
168 let mut active = ACTIVE.lock();
169 active.root = Some(root);
170 active.file = None;
171 switched();
172}
173
174/// Mounts the database at `file` as the active environment.
175///
176/// This is how a shipped [`BundleFormat::Sqlite`](crate::bundle::BundleFormat)
177/// bundle is used in place: a bundle file carries the same schema as an
178/// environment, so loading it makes its entries the ones every bound store
179/// serves, with nothing copied. Stores reset on their next access, exactly as
180/// with [`activate`].
181///
182/// The file stays the environment until [`activate`] or [`set_root`] selects
183/// a named one again. Writes (newly tuned keys, freshly compiled kernels) land
184/// in it like in any environment; if its location is read-only, they degrade
185/// to in-memory persistence as usual.
186#[cfg(std_io)]
187pub fn load<P: Into<std::path::PathBuf>>(file: P) {
188 let file = file.into();
189 log::debug!("Loading environment from {file:?}");
190
191 let mut active = ACTIVE.lock();
192 active.file = Some(file);
193 switched();
194}
195
196/// The directory environments are kept in, defaulting to the standard cache
197/// root.
198#[cfg(std_io)]
199pub fn root() -> std::path::PathBuf {
200 active_state().root_or_default()
201}
202
203/// The database file of the active environment: the file mounted by
204/// [`load`], or `<root>/<name>.db`.
205///
206/// Everything comes from one snapshot, so this never mixes the name from one
207/// configuration with the root from another.
208#[cfg(std_io)]
209pub fn path() -> std::path::PathBuf {
210 let active = active_state();
211
212 match active.file.clone() {
213 Some(file) => file,
214 None => {
215 let name = active.name.clone();
216 active.root_or_default().join(file_name(&name))
217 }
218 }
219}
220
221#[cfg(std_io)]
222impl Active {
223 /// Where environments are kept, falling back to the standard cache root.
224 fn root_or_default(self) -> std::path::PathBuf {
225 self.root
226 .unwrap_or_else(|| crate::persistence::CacheConfig::default().root())
227 }
228}
229
230/// The file name holding the active environment inside a cache root.
231#[cfg(std_io)]
232pub fn file_name(name: &str) -> String {
233 alloc::format!("{}.{EXTENSION}", sanitize(name))
234}
235
236/// An environment name reduced to something safe to use as a file name.
237///
238/// Anything outside `[A-Za-z0-9._-]` becomes `_`, and an empty or
239/// dot-only name falls back to [`DEFAULT`], so a name can never escape the
240/// cache root.
241fn sanitize(name: &str) -> String {
242 let cleaned: String = name
243 .chars()
244 .map(|c| {
245 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
246 c
247 } else {
248 '_'
249 }
250 })
251 .collect();
252
253 if cleaned.is_empty() || cleaned.chars().all(|c| c == '.') {
254 return DEFAULT.to_string();
255 }
256
257 cleaned
258}
259
260/// Every environment that exists, sorted by name.
261#[cfg(std_io)]
262pub fn list() -> Vec<String> {
263 let Ok(entries) = std::fs::read_dir(root()) else {
264 return Vec::new();
265 };
266
267 let mut names: Vec<String> = entries
268 .filter_map(|entry| {
269 let path = entry.ok()?.path();
270 if path.extension()? != EXTENSION {
271 return None;
272 }
273 Some(path.file_stem()?.to_string_lossy().to_string())
274 })
275 .collect();
276
277 names.sort();
278 names
279}
280
281/// A [`Store`] created from the options, bound to the active environment
282/// whenever the options name a storage.
283///
284/// ```ignore
285/// let store: Store<Key, Value> = cubecl_environment::environment::store(
286/// StoreOptions::new()
287/// .storage(Namespace::new("cuda/ptx"))
288/// .cache(CacheOption::Lazy),
289/// );
290/// ```
291pub fn store<K: StoreKey, V: StoreValue>(options: StoreOptions) -> Store<K, V> {
292 Store::new(options)
293}
294
295/// The active environment, captured for shipping.
296///
297/// [`save`](Bundle::save) is the whole API: it exports what the environment
298/// holds into a bundle file another machine can [`load`] or
299/// [`import`](crate::bundle::import).
300#[cfg(native_cache)]
301#[derive(Debug, Clone)]
302pub struct Bundle {
303 /// The database file the environment lived in when captured.
304 source: std::path::PathBuf,
305 /// The environment's name, which becomes the bundle's default name.
306 name: String,
307}
308
309/// Captures the active environment; see [`Bundle`].
310#[cfg(native_cache)]
311pub fn bundle() -> Bundle {
312 Bundle {
313 source: path(),
314 name: active().to_string(),
315 }
316}
317
318#[cfg(native_cache)]
319impl Bundle {
320 /// Exports the captured environment to `out` in `format`.
321 ///
322 /// A thin front for [`bundle::export`](crate::bundle::export) over this
323 /// one environment; use `export` directly to merge several roots or
324 /// restrict the namespaces.
325 pub fn save<P: AsRef<std::path::Path>>(
326 &self,
327 out: P,
328 format: crate::bundle::BundleFormat,
329 ) -> Result<crate::bundle::BundleManifest, crate::bundle::BundleError> {
330 let options = crate::bundle::ExportOptions {
331 name: self.name.clone(),
332 format,
333 ..Default::default()
334 };
335
336 crate::bundle::export(&[&self.source], out, &options)
337 }
338}
339
340/// What the active environment currently holds, one row per namespace.
341///
342/// This is what you consult before bundling, to see which namespaces are warm
343/// and worth shipping.
344#[cfg(std_io)]
345pub fn namespaces() -> Vec<crate::persistence::NamespaceSummary> {
346 #[cfg(native_cache)]
347 match crate::persistence::Database::open_active() {
348 Some(database) => database.summary(),
349 // No database means nothing was ever written to disk; whatever this
350 // process warmed is in memory.
351 None => crate::persistence::MemoryStorage::namespaces(),
352 }
353
354 // Without a persistence backend there is nothing durable to report on.
355 #[cfg(not(native_cache))]
356 crate::persistence::MemoryStorage::namespaces()
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 #[test]
364 fn a_name_can_never_escape_the_cache_root() {
365 assert_eq!(sanitize("../../etc/passwd"), ".._.._etc_passwd");
366 assert_eq!(sanitize("a/b"), "a_b");
367 assert_eq!(sanitize(""), DEFAULT);
368 assert_eq!(sanitize(".."), DEFAULT);
369 assert_eq!(sanitize("h100-linux"), "h100-linux");
370 }
371}