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