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}