Skip to main content

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}