Skip to main content

av_denoise_core/
cache.rs

1//! Where CubeCL keeps its compiled kernels.
2//!
3//! Compiling this crate's kernels takes about ten seconds, and CubeCL
4//! caches nothing on its own. Its cache setting defaults to `None`, so
5//! every run recompiles from scratch unless something points it at a
6//! directory.
7//!
8//! [`install_compilation_cache`] points it at one. By default, that is
9//! [`default_cache_dir`], `av-denoise` inside the platform's cache
10//! directory, which turns the ten seconds into a cost paid once per
11//! machine rather than once per run.
12//!
13//! The `AV_DENOISE_COMPILATION_CACHE` environment variable overrides the
14//! location, which is what CI runs and containers use to put the cache
15//! on a mounted volume. Setting it to `off` disables caching entirely,
16//! which is what benchmarking wants, because a warm cache hides the
17//! compilation cost that a first run pays.
18//!
19//! [`install_compilation_cache`] has to run before the first
20//! [`Denoiser`](crate::Denoiser) is created, because building a CubeCL
21//! client locks the global config.
22//!
23//! If using `av-denoise` as a library you may want to specify the cache directory
24//! path yourself via [`install_compilation_cache_at`] instead.
25//!
26//! ```no_run
27//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
28//! // Call this at the top of `main`, before any denoiser exists.
29//! match av_denoise_core::install_compilation_cache()? {
30//!     Some(path) => println!("caching compiled kernels in {}", path.display()),
31//!     None => println!("kernel caching is off, every run recompiles"),
32//! }
33//! # Ok(())
34//! # }
35//! ```
36
37use std::ffi::OsStr;
38use std::path::{Path, PathBuf};
39use std::sync::{Once, OnceLock};
40
41use cubecl::config::cache::CacheConfig;
42use cubecl::config::{CubeClRuntimeConfig, RuntimeConfig};
43use etcetera::base_strategy::{BaseStrategy, choose_base_strategy};
44
45/// The environment variable that overrides where compiled kernels are
46/// cached, or turns caching off.
47pub const COMPILATION_CACHE_ENV: &str = "AV_DENOISE_COMPILATION_CACHE";
48
49/// Where compiled kernels are cached, once an install has settled it.
50static CACHE_DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
51
52/// The directory name this crate uses inside the user's cache directory.
53const CACHE_DIR_NAME: &str = "av-denoise";
54
55/// The values of [`COMPILATION_CACHE_ENV`] that turn caching off.
56///
57/// Compared without regard to case. `off` is the documented spelling and
58/// the others are here so that a reasonable guess does not silently
59/// create a directory named `0`.
60const DISABLE_WORDS: [&str; 4] = ["off", "0", "false", "none"];
61
62/// Something went wrong installing the kernel cache.
63#[derive(Debug, thiserror::Error)]
64pub enum CacheError {
65    /// The CubeCL global config was already set up before this helper
66    /// ran, so the cache directory can no longer be installed.
67    #[error("CubeCL global config already initialized. Install the cache before any Denoiser::create")]
68    AlreadyInitialised,
69    /// The cache directory does not exist and could not be created.
70    #[error("cannot create the kernel cache directory {path}", path = path.display())]
71    Create {
72        path: PathBuf,
73        #[source]
74        source: std::io::Error,
75    },
76}
77
78/// Where compiled kernels go.
79///
80/// [`Disabled`](CacheLocation::Disabled) is reachable only when
81/// [`COMPILATION_CACHE_ENV`] names one of [`DISABLE_WORDS`]. Every
82/// platform has a default directory, so nothing else produces it.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub(crate) enum CacheLocation {
85    /// Nothing is cached and every run recompiles.
86    Disabled,
87    /// Compiled kernels are written under this directory.
88    Dir(PathBuf),
89}
90
91/// The directory compiled kernels are cached in when nothing overrides it.
92pub fn default_cache_dir() -> PathBuf {
93    let platform_cache = choose_base_strategy().ok().map(|s| s.cache_dir());
94    if platform_cache.is_none() {
95        tracing::warn!("no platform cache directory available, falling back to the temporary directory");
96    }
97    resolve_default_dir(platform_cache, std::env::temp_dir())
98}
99
100fn resolve_default_dir(platform_cache: Option<PathBuf>, temp: PathBuf) -> PathBuf {
101    platform_cache.unwrap_or(temp).join(CACHE_DIR_NAME)
102}
103
104/// Decides where compiled kernels go, from a default and the environment
105/// override alone.
106pub(crate) fn resolve_cache_location(
107    env: Option<&OsStr>,
108    default: impl FnOnce() -> PathBuf,
109) -> CacheLocation {
110    if let Some(raw) = env {
111        let text = raw.to_string_lossy();
112        let trimmed = text.trim();
113        if !trimmed.is_empty() {
114            if DISABLE_WORDS.iter().any(|w| trimmed.eq_ignore_ascii_case(w)) {
115                return CacheLocation::Disabled;
116            }
117            // `raw.to_str()` fails only when the value is not UTF-8, in
118            // which case it cannot be trimmed portably, so it is used
119            // unchanged rather than dropped.
120            let dir = match raw.to_str() {
121                Some(s) => PathBuf::from(s.trim()),
122                None => PathBuf::from(raw),
123            };
124            return CacheLocation::Dir(dir);
125        }
126    }
127
128    CacheLocation::Dir(default())
129}
130
131/// Points CubeCL's compilation and autotune caches at `dir`, creating it
132/// if it does not exist.
133///
134/// This is the entry point for a caller using this crate directly.
135pub fn install_compilation_cache_at(dir: &Path) -> Result<(), CacheError> {
136    if let Err(source) = std::fs::create_dir_all(dir) {
137        return Err(CacheError::Create {
138            path: dir.to_path_buf(),
139            source,
140        });
141    }
142
143    set_runtime_config(dir)?;
144    let _ = CACHE_DIR.set(Some(dir.to_path_buf()));
145    Ok(())
146}
147
148/// Installs the cache at [`default_cache_dir`], or the directory
149/// [`COMPILATION_CACHE_ENV`] names, unless that variable turns caching off.
150///
151/// Returns `Ok(None)` only when the variable disables caching.
152///
153/// A directory that cannot be created is reported through `tracing` logs and then
154/// ignored, because denoising works without a cache. A caller that wants a
155/// creation failure reported should use [`install_compilation_cache_at`].
156pub fn install_compilation_cache() -> Result<Option<PathBuf>, CacheError> {
157    let location = resolve_cache_location(
158        std::env::var_os(COMPILATION_CACHE_ENV).as_deref(),
159        default_cache_dir,
160    );
161
162    let CacheLocation::Dir(path) = location else {
163        return Ok(None);
164    };
165
166    if let Err(err) = std::fs::create_dir_all(&path) {
167        tracing::warn!(
168            ?path,
169            %err,
170            "cannot create the kernel cache directory, continuing without a cache"
171        );
172        return Ok(None);
173    }
174
175    set_runtime_config(&path)?;
176
177    // Only an install that reached the config has a directory worth
178    // recording. A directory that could not be created has already
179    // answered `Ok(None)` above, and latching that would leave
180    // `compilation_cache_dir` saying `None` for the rest of the process
181    // even if a later call succeeded.
182    let _ = CACHE_DIR.set(Some(path.clone()));
183
184    Ok(Some(path))
185}
186
187/// Points CubeCL at a cache the first time it runs, and reports where.
188///
189/// Written for callers that are not a `main`, such as the VapourSynth
190/// plugin, where filter creation is the earliest hook there is and runs
191/// once per filter rather than once per process.
192///
193/// A failure to install is reported through `tracing` and then ignored,
194/// because a plugin that refuses to denoise is worse than one that
195/// recompiles. Failing here means something else configured CubeCL
196/// first, which may well have pointed it at a cache of its own. What is
197/// lost is knowing where that cache is, which is why this answers `None`
198/// rather than guessing.
199pub fn install_compilation_cache_once() -> Option<&'static Path> {
200    static ONCE: Once = Once::new();
201
202    ONCE.call_once(|| match install_compilation_cache() {
203        Ok(Some(path)) => tracing::info!(?path, "caching compiled kernels"),
204        Ok(None) => tracing::info!("kernel caching is off, every run recompiles"),
205        Err(err) => tracing::warn!(
206            %err,
207            "something else configured CubeCL first, leaving its kernel cache alone"
208        ),
209    });
210
211    compilation_cache_dir()
212}
213
214/// The directory compiled kernels are cached in.
215///
216/// `None` until an install succeeds, and `None` for good when caching is
217/// off. Callers that want to sit alongside the cache, such as
218/// [`WarmUp`](crate::WarmUp), have nowhere to put their own files until
219/// this answers.
220pub fn compilation_cache_dir() -> Option<&'static Path> {
221    CACHE_DIR.get()?.as_deref()
222}
223
224/// Points the CubeCL global config at `path`.
225///
226/// Split out because both entry points build the same config once they
227/// have settled on a directory that exists.
228fn set_runtime_config(path: &Path) -> Result<(), CacheError> {
229    let mut cfg = CubeClRuntimeConfig::from_current_dir().override_from_env();
230    cfg.compilation.cache = Some(CacheConfig::File(path.to_path_buf()));
231    cfg.autotune.cache = CacheConfig::File(path.to_path_buf());
232
233    // `RuntimeConfig::set` panics if the singleton is already set up.
234    // Catching that turns an abort into a typed error for the caller.
235    //
236    // CubeCL does not expose a fallible version of this call, so the
237    // panic is the only signal available.
238    std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
239        CubeClRuntimeConfig::set(cfg);
240    }))
241    .map_err(|_| CacheError::AlreadyInitialised)
242}
243
244#[cfg(test)]
245mod tests {
246    use std::ffi::OsString;
247
248    use super::*;
249
250    fn os(text: &str) -> OsString {
251        OsString::from(text)
252    }
253
254    fn resolve(env: Option<&str>, default: &str) -> CacheLocation {
255        let env = env.map(os);
256        let default = PathBuf::from(default);
257        resolve_cache_location(env.as_deref(), || default)
258    }
259
260    #[test]
261    fn with_nothing_set_the_default_wins() {
262        assert_eq!(
263            resolve(None, "/home/u/.cache/av-denoise"),
264            CacheLocation::Dir(PathBuf::from("/home/u/.cache/av-denoise")),
265        );
266    }
267
268    #[test]
269    fn an_explicit_path_overrides_the_default() {
270        assert_eq!(
271            resolve(Some("/mnt/cache"), "/home/u/.cache/av-denoise"),
272            CacheLocation::Dir(PathBuf::from("/mnt/cache")),
273        );
274    }
275
276    #[test]
277    fn the_disable_words_turn_caching_off() {
278        for word in ["off", "OFF", "Off", "0", "false", "FALSE", "none", " off "] {
279            assert_eq!(
280                resolve(Some(word), "/home/u/.cache/av-denoise"),
281                CacheLocation::Disabled,
282                "{word} should disable caching",
283            );
284        }
285    }
286
287    #[test]
288    fn a_path_containing_a_disable_word_is_still_a_path() {
289        assert_eq!(
290            resolve(Some("/tmp/offsite"), "/home/u/.cache/av-denoise"),
291            CacheLocation::Dir(PathBuf::from("/tmp/offsite")),
292        );
293    }
294
295    #[test]
296    fn an_empty_variable_takes_the_default() {
297        assert_eq!(
298            resolve(Some(""), "/home/u/.cache/av-denoise"),
299            CacheLocation::Dir(PathBuf::from("/home/u/.cache/av-denoise")),
300        );
301        assert_eq!(
302            resolve(Some("   "), "/home/u/.cache/av-denoise"),
303            CacheLocation::Dir(PathBuf::from("/home/u/.cache/av-denoise")),
304        );
305    }
306
307    #[test]
308    fn a_padded_explicit_path_is_trimmed() {
309        assert_eq!(
310            resolve(Some(" /mnt/cache "), "/home/u/.cache/av-denoise"),
311            CacheLocation::Dir(PathBuf::from("/mnt/cache")),
312        );
313    }
314
315    /// A non-UTF-8 value cannot be trimmed portably, so it is used unchanged.
316    #[cfg(unix)]
317    #[test]
318    fn a_non_utf8_override_is_carried_through_untrimmed() {
319        use std::ffi::OsString;
320        use std::os::unix::ffi::OsStringExt;
321
322        // `0xFF` is not valid UTF-8 in any position, so `bytes` never
323        // decodes to a `&str`.
324        let bytes = vec![b'/', b'm', b'n', b't', b'/', 0xFF, b'x'];
325        let env = OsString::from_vec(bytes.clone());
326        assert_eq!(
327            resolve_cache_location(Some(&env), || PathBuf::from("/home/u/.cache/av-denoise")),
328            CacheLocation::Dir(PathBuf::from(OsString::from_vec(bytes))),
329        );
330    }
331
332    #[test]
333    fn resolve_default_dir_joins_the_platform_cache_directory() {
334        assert_eq!(
335            resolve_default_dir(Some(PathBuf::from("/home/u/.cache")), PathBuf::from("/tmp"),),
336            PathBuf::from("/home/u/.cache/av-denoise"),
337        );
338    }
339
340    #[test]
341    fn resolve_default_dir_falls_back_to_the_temporary_directory() {
342        assert_eq!(
343            resolve_default_dir(None, PathBuf::from("/tmp")),
344            PathBuf::from("/tmp/av-denoise"),
345        );
346    }
347}