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}