av_denoise/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::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}