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::PathBuf;
36
37use cubecl::config::cache::CacheConfig;
38use cubecl::config::{CubeClRuntimeConfig, RuntimeConfig};
39
40/// The environment variable that overrides where compiled kernels are
41/// cached, or turns caching off.
42pub const COMPILATION_CACHE_ENV: &str = "AV_DENOISE_COMPILATION_CACHE";
43
44/// The directory name this crate uses inside the user's cache directory.
45const CACHE_DIR_NAME: &str = "av-denoise";
46
47/// The values of [`COMPILATION_CACHE_ENV`] that turn caching off.
48///
49/// Compared without regard to case. `off` is the documented spelling and
50/// the others are here so that a reasonable guess does not silently
51/// create a directory named `0`.
52const DISABLE_WORDS: [&str; 4] = ["off", "0", "false", "none"];
53
54/// The CubeCL global config was already set up before this helper ran,
55/// so the override can no longer be installed.
56#[derive(Debug, thiserror::Error)]
57#[error(
58    "CubeCL global config already initialized. Call install_compilation_cache() before any Denoiser::create"
59)]
60pub struct CacheAlreadyInitialisedError;
61
62/// Where compiled kernels go.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub(crate) enum CacheLocation {
65    /// Nothing is cached and every run recompiles.
66    Disabled,
67    /// Compiled kernels are written under this directory.
68    Dir(PathBuf),
69}
70
71/// Decides where compiled kernels go, from the environment alone.
72///
73/// Kept separate from [`install_compilation_cache`] because installing
74/// the choice writes to a global that can only be set once per process,
75/// while the choice itself is worth testing over many inputs.
76///
77/// `env` is the raw value of [`COMPILATION_CACHE_ENV`]. An unset or
78/// empty value takes the default, one of [`DISABLE_WORDS`] turns caching
79/// off, and anything else is used as the directory.
80///
81/// The default is `$XDG_CACHE_HOME/av-denoise`, or the platform's cache
82/// directory under `$HOME` when `XDG_CACHE_HOME` is unset. With no home
83/// directory to fall back on there is nowhere sensible to write, so
84/// caching is off.
85pub(crate) fn resolve_cache_location(
86    env: Option<&OsStr>,
87    xdg_cache_home: Option<&OsStr>,
88    home: Option<&OsStr>,
89    is_macos: bool,
90) -> CacheLocation {
91    if let Some(raw) = env {
92        let text = raw.to_string_lossy();
93        let trimmed = text.trim();
94        if !trimmed.is_empty() {
95            if DISABLE_WORDS.iter().any(|w| trimmed.eq_ignore_ascii_case(w)) {
96                return CacheLocation::Disabled;
97            }
98            return CacheLocation::Dir(PathBuf::from(raw));
99        }
100    }
101
102    if let Some(xdg) = xdg_cache_home.filter(|x| !x.is_empty()) {
103        return CacheLocation::Dir(PathBuf::from(xdg).join(CACHE_DIR_NAME));
104    }
105
106    let Some(home) = home.filter(|h| !h.is_empty()) else {
107        return CacheLocation::Disabled;
108    };
109    let base = if is_macos {
110        PathBuf::from(home).join("Library").join("Caches")
111    } else {
112        PathBuf::from(home).join(".cache")
113    };
114    CacheLocation::Dir(base.join(CACHE_DIR_NAME))
115}
116
117/// Points CubeCL's compilation and autotune caches at a directory.
118///
119/// Returns `Ok(Some(path))` with the directory in use, or `Ok(None)`
120/// when caching is off. Caching is off when
121/// [`COMPILATION_CACHE_ENV`] says so, when there is no home directory to
122/// derive a default from, or when the directory cannot be created.
123///
124/// A directory that cannot be created is reported through `tracing` and
125/// then ignored. Denoising works without a cache, so failing to write
126/// one is not a reason to refuse to run.
127///
128/// Returns `Err` if something else has already read the global config,
129/// which usually means a CubeCL client was created first.
130pub fn install_compilation_cache() -> Result<Option<PathBuf>, CacheAlreadyInitialisedError> {
131    let location = resolve_cache_location(
132        std::env::var_os(COMPILATION_CACHE_ENV).as_deref(),
133        std::env::var_os("XDG_CACHE_HOME").as_deref(),
134        std::env::var_os("HOME").as_deref(),
135        cfg!(target_os = "macos"),
136    );
137
138    let CacheLocation::Dir(path) = location else {
139        return Ok(None);
140    };
141
142    if let Err(err) = std::fs::create_dir_all(&path) {
143        tracing::warn!(
144            ?path,
145            %err,
146            "cannot create the kernel cache directory, continuing without a cache"
147        );
148        return Ok(None);
149    }
150
151    let mut cfg = CubeClRuntimeConfig::from_current_dir().override_from_env();
152    cfg.compilation.cache = Some(CacheConfig::File(path.clone()));
153    cfg.autotune.cache = CacheConfig::File(path.clone());
154
155    // `RuntimeConfig::set` panics if the singleton is already set up.
156    // Catching that turns an abort into a typed error for the caller.
157    //
158    // CubeCL does not expose a fallible version of this call, so the
159    // panic is the only signal available.
160    std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
161        CubeClRuntimeConfig::set(cfg);
162    }))
163    .map_err(|_| CacheAlreadyInitialisedError)?;
164
165    Ok(Some(path))
166}
167
168#[cfg(test)]
169mod tests {
170    use std::ffi::OsString;
171
172    use super::*;
173
174    fn os(text: &str) -> OsString {
175        OsString::from(text)
176    }
177
178    fn resolve(env: Option<&str>, xdg: Option<&str>, home: Option<&str>) -> CacheLocation {
179        let env = env.map(os);
180        let xdg = xdg.map(os);
181        let home = home.map(os);
182        resolve_cache_location(env.as_deref(), xdg.as_deref(), home.as_deref(), false)
183    }
184
185    /// With nothing set, the cache lands under the XDG cache directory,
186    /// which is where a compiler cache belongs and where a container can
187    /// mount over it.
188    #[test]
189    fn the_default_is_the_xdg_cache_directory() {
190        assert_eq!(
191            resolve(None, Some("/home/u/.cache"), Some("/home/u")),
192            CacheLocation::Dir(PathBuf::from("/home/u/.cache/av-denoise")),
193        );
194    }
195
196    /// `XDG_CACHE_HOME` is frequently unset on machines that still have
197    /// a perfectly good home directory, so the fallback matters more
198    /// than the primary path does.
199    #[test]
200    fn without_xdg_the_default_sits_under_the_home_directory() {
201        assert_eq!(
202            resolve(None, None, Some("/home/u")),
203            CacheLocation::Dir(PathBuf::from("/home/u/.cache/av-denoise")),
204        );
205    }
206
207    /// macOS keeps caches somewhere else, and this crate builds there
208    /// through its `metal` feature.
209    #[test]
210    fn macos_uses_its_own_cache_directory() {
211        let home = os("/Users/u");
212        assert_eq!(
213            resolve_cache_location(None, None, Some(home.as_os_str()), true),
214            CacheLocation::Dir(PathBuf::from("/Users/u/Library/Caches/av-denoise")),
215        );
216    }
217
218    /// An explicit path wins over both defaults. This is the case CI
219    /// runs and containers use.
220    #[test]
221    fn an_explicit_path_overrides_every_default() {
222        assert_eq!(
223            resolve(Some("/mnt/cache"), Some("/home/u/.cache"), Some("/home/u")),
224            CacheLocation::Dir(PathBuf::from("/mnt/cache")),
225        );
226    }
227
228    /// Benchmarking needs the compilation cost a first run pays, and a
229    /// warm cache hides it.
230    #[test]
231    fn the_disable_words_turn_caching_off() {
232        for word in ["off", "OFF", "Off", "0", "false", "FALSE", "none", " off "] {
233            assert_eq!(
234                resolve(Some(word), Some("/home/u/.cache"), Some("/home/u")),
235                CacheLocation::Disabled,
236                "{word} should disable caching",
237            );
238        }
239    }
240
241    /// A path that merely looks like a disable word is still a path.
242    /// Nothing here should turn `/tmp/offsite` into "off".
243    #[test]
244    fn a_path_containing_a_disable_word_is_still_a_path() {
245        assert_eq!(
246            resolve(Some("/tmp/offsite"), None, Some("/home/u")),
247            CacheLocation::Dir(PathBuf::from("/tmp/offsite")),
248        );
249    }
250
251    /// An empty variable reads as "not set" rather than as "off",
252    /// matching what an unset variable does.
253    #[test]
254    fn an_empty_variable_takes_the_default() {
255        assert_eq!(
256            resolve(Some(""), Some("/home/u/.cache"), Some("/home/u")),
257            CacheLocation::Dir(PathBuf::from("/home/u/.cache/av-denoise")),
258        );
259        assert_eq!(
260            resolve(Some("   "), Some("/home/u/.cache"), Some("/home/u")),
261            CacheLocation::Dir(PathBuf::from("/home/u/.cache/av-denoise")),
262        );
263    }
264
265    /// An empty `XDG_CACHE_HOME` is not a usable directory, so the home
266    /// directory answers instead.
267    #[test]
268    fn an_empty_xdg_falls_through_to_the_home_directory() {
269        assert_eq!(
270            resolve(None, Some(""), Some("/home/u")),
271            CacheLocation::Dir(PathBuf::from("/home/u/.cache/av-denoise")),
272        );
273    }
274
275    /// With no home directory there is nowhere sensible to write, and
276    /// picking the working directory would scatter caches wherever the
277    /// binary happened to run.
278    #[test]
279    fn no_home_directory_means_no_cache() {
280        assert_eq!(resolve(None, None, None), CacheLocation::Disabled);
281        assert_eq!(resolve(None, None, Some("")), CacheLocation::Disabled);
282    }
283}