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//! `av-denoise` inside the user's cache directory, which turns the ten
10//! seconds into a cost paid once per machine rather than once per run.
11//! A warm cache takes the 53-frame reference clip from 11.8 s to 1.3 s.
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//! ```no_run
24//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
25//! // Call this at the top of `main`, before any denoiser exists.
26//! match av_denoise_core::install_compilation_cache()? {
27//!     Some(path) => println!("caching compiled kernels in {}", path.display()),
28//!     None => println!("kernel caching is off, every run recompiles"),
29//! }
30//! # Ok(())
31//! # }
32//! ```
33
34use std::ffi::OsStr;
35use std::path::{Path, PathBuf};
36use std::sync::{Once, OnceLock};
37
38use cubecl::config::cache::CacheConfig;
39use cubecl::config::{CubeClRuntimeConfig, RuntimeConfig};
40
41/// The environment variable that overrides where compiled kernels are
42/// cached, or turns caching off.
43pub const COMPILATION_CACHE_ENV: &str = "AV_DENOISE_COMPILATION_CACHE";
44
45/// Where compiled kernels are cached, once an install has settled it.
46static CACHE_DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
47
48/// The directory name this crate uses inside the user's cache directory.
49const CACHE_DIR_NAME: &str = "av-denoise";
50
51/// The values of [`COMPILATION_CACHE_ENV`] that turn caching off.
52///
53/// Compared without regard to case. `off` is the documented spelling and
54/// the others are here so that a reasonable guess does not silently
55/// create a directory named `0`.
56const DISABLE_WORDS: [&str; 4] = ["off", "0", "false", "none"];
57
58/// The CubeCL global config was already set up before this helper ran,
59/// so the override can no longer be installed.
60#[derive(Debug, thiserror::Error)]
61#[error(
62    "CubeCL global config already initialized. Call install_compilation_cache() before any Denoiser::create"
63)]
64pub struct CacheAlreadyInitialisedError;
65
66/// Where compiled kernels go.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub(crate) enum CacheLocation {
69    /// Nothing is cached and every run recompiles.
70    Disabled,
71    /// Compiled kernels are written under this directory.
72    Dir(PathBuf),
73}
74
75/// Decides where compiled kernels go, from the environment alone.
76///
77/// Kept separate from [`install_compilation_cache`] because installing
78/// the choice writes to a global that can only be set once per process,
79/// while the choice itself is worth testing over many inputs.
80///
81/// `env` is the raw value of [`COMPILATION_CACHE_ENV`]. An unset or
82/// empty value takes the default, one of [`DISABLE_WORDS`] turns caching
83/// off, and anything else is used as the directory.
84///
85/// The default is `$XDG_CACHE_HOME/av-denoise`, or the platform's cache
86/// directory under `$HOME` when `XDG_CACHE_HOME` is unset. With no home
87/// directory to fall back on there is nowhere sensible to write, so
88/// caching is off.
89pub(crate) fn resolve_cache_location(
90    env: Option<&OsStr>,
91    xdg_cache_home: Option<&OsStr>,
92    home: Option<&OsStr>,
93    is_macos: bool,
94) -> CacheLocation {
95    if let Some(raw) = env {
96        let text = raw.to_string_lossy();
97        let trimmed = text.trim();
98        if !trimmed.is_empty() {
99            if DISABLE_WORDS.iter().any(|w| trimmed.eq_ignore_ascii_case(w)) {
100                return CacheLocation::Disabled;
101            }
102            return CacheLocation::Dir(PathBuf::from(raw));
103        }
104    }
105
106    if let Some(xdg) = xdg_cache_home.filter(|x| !x.is_empty()) {
107        return CacheLocation::Dir(PathBuf::from(xdg).join(CACHE_DIR_NAME));
108    }
109
110    let Some(home) = home.filter(|h| !h.is_empty()) else {
111        return CacheLocation::Disabled;
112    };
113    let base = if is_macos {
114        PathBuf::from(home).join("Library").join("Caches")
115    } else {
116        PathBuf::from(home).join(".cache")
117    };
118    CacheLocation::Dir(base.join(CACHE_DIR_NAME))
119}
120
121/// Points CubeCL's compilation and autotune caches at a directory.
122///
123/// Returns `Ok(Some(path))` with the directory in use, or `Ok(None)`
124/// when caching is off. Caching is off when
125/// [`COMPILATION_CACHE_ENV`] says so, when there is no home directory to
126/// derive a default from, or when the directory cannot be created.
127///
128/// A directory that cannot be created is reported through `tracing` and
129/// then ignored. Denoising works without a cache, so failing to write
130/// one is not a reason to refuse to run.
131///
132/// Returns `Err` if something else has already read the global config,
133/// which usually means a CubeCL client was created first.
134pub fn install_compilation_cache() -> Result<Option<PathBuf>, CacheAlreadyInitialisedError> {
135    let path = install()?;
136
137    // Only an install that reached the config has a directory worth
138    // recording. `install` also answers `Ok(None)` for a directory it
139    // could not create, and latching that would leave
140    // `compilation_cache_dir` saying `None` for the rest of the process
141    // even if a later call succeeded.
142    if let Some(path) = &path {
143        let _ = CACHE_DIR.set(Some(path.clone()));
144    }
145
146    Ok(path)
147}
148
149/// Points CubeCL at a cache the first time it runs, and reports where.
150///
151/// Written for callers that are not a `main`, such as the VapourSynth
152/// plugin, where filter creation is the earliest hook there is and runs
153/// once per filter rather than once per process.
154///
155/// A failure to install is reported through `tracing` and then ignored,
156/// because a plugin that refuses to denoise is worse than one that
157/// recompiles. Failing here means something else configured CubeCL
158/// first, which may well have pointed it at a cache of its own. What is
159/// lost is knowing where that cache is, which is why this answers `None`
160/// rather than guessing.
161pub fn install_compilation_cache_once() -> Option<&'static Path> {
162    static ONCE: Once = Once::new();
163
164    ONCE.call_once(|| match install_compilation_cache() {
165        Ok(Some(path)) => tracing::info!(?path, "caching compiled kernels"),
166        Ok(None) => tracing::info!("kernel caching is off, every run recompiles"),
167        Err(err) => tracing::warn!(
168            %err,
169            "something else configured CubeCL first, leaving its kernel cache alone"
170        ),
171    });
172
173    compilation_cache_dir()
174}
175
176/// The directory compiled kernels are cached in.
177///
178/// `None` until an install succeeds, and `None` for good when caching is
179/// off. Callers that want to sit alongside the cache, such as
180/// [`WarmUp`](crate::WarmUp), have nowhere to put their own files until
181/// this answers.
182pub fn compilation_cache_dir() -> Option<&'static Path> {
183    CACHE_DIR.get()?.as_deref()
184}
185
186/// The install itself, split from the bookkeeping that records where
187/// the cache landed.
188fn install() -> Result<Option<PathBuf>, CacheAlreadyInitialisedError> {
189    let location = resolve_cache_location(
190        std::env::var_os(COMPILATION_CACHE_ENV).as_deref(),
191        std::env::var_os("XDG_CACHE_HOME").as_deref(),
192        std::env::var_os("HOME").as_deref(),
193        cfg!(target_os = "macos"),
194    );
195
196    let CacheLocation::Dir(path) = location else {
197        return Ok(None);
198    };
199
200    if let Err(err) = std::fs::create_dir_all(&path) {
201        tracing::warn!(
202            ?path,
203            %err,
204            "cannot create the kernel cache directory, continuing without a cache"
205        );
206        return Ok(None);
207    }
208
209    let mut cfg = CubeClRuntimeConfig::from_current_dir().override_from_env();
210    cfg.compilation.cache = Some(CacheConfig::File(path.clone()));
211    cfg.autotune.cache = CacheConfig::File(path.clone());
212
213    // `RuntimeConfig::set` panics if the singleton is already set up.
214    // Catching that turns an abort into a typed error for the caller.
215    //
216    // CubeCL does not expose a fallible version of this call, so the
217    // panic is the only signal available.
218    std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
219        CubeClRuntimeConfig::set(cfg);
220    }))
221    .map_err(|_| CacheAlreadyInitialisedError)?;
222
223    Ok(Some(path))
224}
225
226#[cfg(test)]
227mod tests {
228    use std::ffi::OsString;
229
230    use super::*;
231
232    fn os(text: &str) -> OsString {
233        OsString::from(text)
234    }
235
236    fn resolve(env: Option<&str>, xdg: Option<&str>, home: Option<&str>) -> CacheLocation {
237        let env = env.map(os);
238        let xdg = xdg.map(os);
239        let home = home.map(os);
240        resolve_cache_location(env.as_deref(), xdg.as_deref(), home.as_deref(), false)
241    }
242
243    /// With nothing set, the cache lands under the XDG cache directory,
244    /// which is where a compiler cache belongs and where a container can
245    /// mount over it.
246    #[test]
247    fn the_default_is_the_xdg_cache_directory() {
248        assert_eq!(
249            resolve(None, Some("/home/u/.cache"), Some("/home/u")),
250            CacheLocation::Dir(PathBuf::from("/home/u/.cache/av-denoise")),
251        );
252    }
253
254    /// `XDG_CACHE_HOME` is frequently unset on machines that still have
255    /// a perfectly good home directory, so the fallback matters more
256    /// than the primary path does.
257    #[test]
258    fn without_xdg_the_default_sits_under_the_home_directory() {
259        assert_eq!(
260            resolve(None, None, Some("/home/u")),
261            CacheLocation::Dir(PathBuf::from("/home/u/.cache/av-denoise")),
262        );
263    }
264
265    /// macOS keeps caches somewhere else, and this crate builds there
266    /// through its `metal` feature.
267    #[test]
268    fn macos_uses_its_own_cache_directory() {
269        let home = os("/Users/u");
270        assert_eq!(
271            resolve_cache_location(None, None, Some(home.as_os_str()), true),
272            CacheLocation::Dir(PathBuf::from("/Users/u/Library/Caches/av-denoise")),
273        );
274    }
275
276    /// An explicit path wins over both defaults. This is the case CI
277    /// runs and containers use.
278    #[test]
279    fn an_explicit_path_overrides_every_default() {
280        assert_eq!(
281            resolve(Some("/mnt/cache"), Some("/home/u/.cache"), Some("/home/u")),
282            CacheLocation::Dir(PathBuf::from("/mnt/cache")),
283        );
284    }
285
286    /// Benchmarking needs the compilation cost a first run pays, and a
287    /// warm cache hides it.
288    #[test]
289    fn the_disable_words_turn_caching_off() {
290        for word in ["off", "OFF", "Off", "0", "false", "FALSE", "none", " off "] {
291            assert_eq!(
292                resolve(Some(word), Some("/home/u/.cache"), Some("/home/u")),
293                CacheLocation::Disabled,
294                "{word} should disable caching",
295            );
296        }
297    }
298
299    /// A path that merely looks like a disable word is still a path.
300    /// Nothing here should turn `/tmp/offsite` into "off".
301    #[test]
302    fn a_path_containing_a_disable_word_is_still_a_path() {
303        assert_eq!(
304            resolve(Some("/tmp/offsite"), None, Some("/home/u")),
305            CacheLocation::Dir(PathBuf::from("/tmp/offsite")),
306        );
307    }
308
309    /// An empty variable reads as "not set" rather than as "off",
310    /// matching what an unset variable does.
311    #[test]
312    fn an_empty_variable_takes_the_default() {
313        assert_eq!(
314            resolve(Some(""), Some("/home/u/.cache"), Some("/home/u")),
315            CacheLocation::Dir(PathBuf::from("/home/u/.cache/av-denoise")),
316        );
317        assert_eq!(
318            resolve(Some("   "), Some("/home/u/.cache"), Some("/home/u")),
319            CacheLocation::Dir(PathBuf::from("/home/u/.cache/av-denoise")),
320        );
321    }
322
323    /// An empty `XDG_CACHE_HOME` is not a usable directory, so the home
324    /// directory answers instead.
325    #[test]
326    fn an_empty_xdg_falls_through_to_the_home_directory() {
327        assert_eq!(
328            resolve(None, Some(""), Some("/home/u")),
329            CacheLocation::Dir(PathBuf::from("/home/u/.cache/av-denoise")),
330        );
331    }
332
333    /// With no home directory there is nowhere sensible to write, and
334    /// picking the working directory would scatter caches wherever the
335    /// binary happened to run.
336    #[test]
337    fn no_home_directory_means_no_cache() {
338        assert_eq!(resolve(None, None, None), CacheLocation::Disabled);
339        assert_eq!(resolve(None, None, Some("")), CacheLocation::Disabled);
340    }
341}