av_denoise_core/warmup.rs
1//! Lets one process fill a cold kernel cache while the others wait.
2//!
3//! CubeCL compiles a kernel the first time it is dispatched, and writes
4//! the result to the cache [`install_compilation_cache`] points it at.
5//! The table of contents that cache reads is a snapshot taken when the
6//! GPU client is built, so a process that starts while a second process
7//! is still compiling sees an empty table and shares nothing with it.
8//! Every process in that group pays the full compilation cost and
9//! appends its own copy of the same kernels.
10//!
11//! [`WarmUp`] closes that window with a lock file next to the cache.
12//! The first process in holds it while it compiles, the rest block, and
13//! by the time they build their own client the cache has everything they
14//! need. Once a run finishes it leaves a stamp file behind, and later
15//! processes see the stamp and skip the lock entirely, so the cost is
16//! paid once rather than on every chunk.
17//!
18//! [`install_compilation_cache`]: crate::install_compilation_cache
19
20use std::collections::HashSet;
21use std::fs::{File, OpenOptions, TryLockError};
22use std::path::{Path, PathBuf};
23use std::sync::{LazyLock, Mutex};
24use std::time::{Duration, Instant};
25
26use cubecl::hash::StableHasher;
27
28use crate::cache::compilation_cache_dir;
29use crate::frame::{FrameLayout, PlaneOptions};
30
31/// How long a process waits for the one ahead of it before giving up and
32/// compiling for itself.
33///
34/// Compiling this crate's kernels takes about ten seconds on a quiet
35/// machine, and a machine running an encode is not quiet, so the limit
36/// is generous. Waiting longer than this is worse than duplicating the
37/// work, because the encoder above has nothing to do until a frame
38/// arrives.
39const WAIT_LIMIT: Duration = Duration::from_secs(180);
40
41/// How often a waiting process retries the lock.
42const POLL_INTERVAL: Duration = Duration::from_millis(100);
43
44/// The keys this process already holds a place for.
45static CLAIMED_KEYS: LazyLock<Mutex<HashSet<u128>>> = LazyLock::new(|| Mutex::new(HashSet::new()));
46
47/// Records that this process is taking the place for `key`, and reports
48/// whether it was free to take.
49fn claim_key(key: u128) -> bool {
50 CLAIMED_KEYS
51 .lock()
52 .expect("warm-up key mutex poisoned")
53 .insert(key)
54}
55
56/// Gives `key` back, so a later filter in this process can queue for it
57/// again.
58fn release_key(key: u128) {
59 CLAIMED_KEYS
60 .lock()
61 .expect("warm-up key mutex poisoned")
62 .remove(&key);
63}
64
65/// Identifies the set of kernels a denoiser compiles.
66///
67/// Radii, channel mode, depth and algorithm are all baked into the
68/// kernels at compile time, so a change to any of them produces a
69/// different set and a warm cache for one set says nothing about
70/// another. Processes that would compile different kernels have no
71/// reason to wait for each other, and a stamp left by one must not
72/// convince the other that its own kernels are cached.
73///
74/// The key is taken from the `Debug` rendering of both inputs rather
75/// than from a hand-written list of the fields that reach a kernel.
76/// A hand-written list silently stops covering a field the day someone
77/// adds one, and the failure that follows is a process trusting a stamp
78/// for kernels it has never compiled. Reading everything keeps the key
79/// correct without maintenance.
80///
81/// Including fields that only ever reach the GPU as runtime arguments,
82/// such as strength, makes the key finer than it strictly needs to be.
83/// Two runs that differ only in strength warm up separately instead of
84/// sharing. That costs one extra warm-up and is the safe direction to
85/// err in.
86///
87/// This crate's version goes into the key as well, because a release
88/// that changes a kernel changes what CubeCL caches under it, and
89/// CubeCL files its own cache under the CubeCL version on top of that.
90/// Without the version a stamp written before an upgrade would tell
91/// every process after it that a cache emptied by that upgrade is warm,
92/// and the whole first wave would compile at once again with nothing
93/// said about it. Rebuilding a kernel without changing the version is
94/// the one case this misses, and deleting the cache directory clears it.
95pub fn kernel_key(options: &PlaneOptions, layout: FrameLayout) -> u128 {
96 StableHasher::hash_one(&format!("{}|{options:?}|{layout:?}", env!("CARGO_PKG_VERSION")))
97}
98
99/// A held place in the queue to fill a cold cache.
100///
101/// Obtained from [`WarmUp::begin`] and given up with [`WarmUp::finish`]
102/// once the kernels are compiled. Dropping one without calling `finish`
103/// releases the lock without leaving a stamp, so a run that failed part
104/// way through does not convince the next process that the cache is
105/// warm.
106#[derive(Debug)]
107pub struct WarmUp {
108 lock: File,
109 stamp: PathBuf,
110 key: u128,
111}
112
113impl WarmUp {
114 /// Takes a place in the queue for the kernels `key` identifies.
115 ///
116 /// Returns `Some` while holding the lock, and the caller compiles
117 /// under it. Returns `None` when there is nothing to wait for, which
118 /// covers a cache that is already warm for these kernels, a cache
119 /// that is turned off, and a lock that could not be taken in
120 /// [`WAIT_LIMIT`]. In every one of those the caller carries on and
121 /// compiles as it always did.
122 ///
123 /// Blocks for as long as the process ahead takes to compile, so it
124 /// belongs on the path that builds a denoiser rather than on the
125 /// path that renders a frame.
126 pub fn begin(key: u128) -> Option<Self> {
127 Self::begin_in(compilation_cache_dir()?, key, WAIT_LIMIT)
128 }
129
130 /// [`WarmUp::begin`] against an explicit directory and wait limit.
131 ///
132 /// Split out so tests can drive the queue without the process-wide
133 /// cache directory, and without waiting the full [`WAIT_LIMIT`] to
134 /// see what a contended lock does.
135 fn begin_in(dir: &Path, key: u128, wait_limit: Duration) -> Option<Self> {
136 // A file lock is held by the process rather than by the handle
137 // that took it, so a second filter in this process asking for
138 // the same kernels would wait out `wait_limit` on a lock its own
139 // process already holds. One script can easily build two
140 // filters, so take the place at most once per key per process
141 // and let the second caller carry on.
142 if !claim_key(key) {
143 return None;
144 }
145
146 let held = Self::acquire(dir, key, wait_limit);
147
148 if held.is_none() {
149 release_key(key);
150 }
151
152 held
153 }
154
155 /// [`WarmUp::begin_in`] without the in-process bookkeeping, which its
156 /// caller takes care of on both the success and the failure path.
157 fn acquire(dir: &Path, key: u128, wait_limit: Duration) -> Option<Self> {
158 let stamp = dir.join(format!("warm-{key:032x}.stamp"));
159
160 if stamp.exists() {
161 return None;
162 }
163
164 let lock = open_lock_file(&dir.join(format!("warm-{key:032x}.lock")))?;
165
166 if !wait_for_lock(&lock, wait_limit) {
167 return None;
168 }
169
170 // Whoever held the lock has finished compiling by now, so the
171 // stamp answers differently than it did above. Checking it again
172 // is what turns the queue into a single warm-up rather than one
173 // per waiting process.
174 if stamp.exists() {
175 let _ = lock.unlock();
176 return None;
177 }
178
179 tracing::debug!(?stamp, "compiling kernels for a cold cache");
180 Some(Self { lock, stamp, key })
181 }
182
183 /// Records that the kernels are compiled and lets the next process
184 /// through.
185 pub fn finish(self) {
186 if let Err(err) = std::fs::write(&self.stamp, b"") {
187 // The next process reads a missing stamp as a cold cache and
188 // compiles again, which is slower but still correct.
189 tracing::debug!(stamp = ?self.stamp, %err, "cannot write the kernel warm-up stamp");
190 }
191 }
192}
193
194impl Drop for WarmUp {
195 fn drop(&mut self) {
196 let _ = self.lock.unlock();
197 release_key(self.key);
198 }
199}
200
201/// Opens the lock file, creating it when this is the first process to
202/// ask for these kernels.
203///
204/// A directory that cannot be written is reported and then ignored, the
205/// same way an uncreatable cache directory is. Denoising works without
206/// the queue, it just compiles more than once.
207fn open_lock_file(path: &Path) -> Option<File> {
208 match OpenOptions::new()
209 .create(true)
210 .read(true)
211 .write(true)
212 .truncate(false)
213 .open(path)
214 {
215 Ok(file) => Some(file),
216 Err(err) => {
217 tracing::debug!(?path, %err, "cannot open the kernel warm-up lock, compiling unqueued");
218 None
219 },
220 }
221}
222
223/// Blocks until the lock is held, giving up after `wait_limit`.
224///
225/// Returns whether the lock is held. The lock is a real advisory file
226/// lock rather than a file whose presence means "taken", so the
227/// operating system releases it when Av1an kills a worker mid-compile
228/// and the next process in line wakes up straight away.
229fn wait_for_lock(lock: &File, wait_limit: Duration) -> bool {
230 let start = Instant::now();
231
232 loop {
233 match lock.try_lock() {
234 Ok(()) => return true,
235 Err(TryLockError::WouldBlock) => {},
236 Err(TryLockError::Error(err)) => {
237 tracing::debug!(%err, "cannot take the kernel warm-up lock, compiling unqueued");
238 return false;
239 },
240 }
241
242 if start.elapsed() >= wait_limit {
243 tracing::warn!(
244 "waited {:?} for another process to compile kernels, compiling for ourselves",
245 wait_limit,
246 );
247 return false;
248 }
249
250 std::thread::sleep(POLL_INTERVAL);
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 /// A key no test shares with another, so the lock and stamp files of
259 /// one test cannot be seen by the next.
260 fn key(n: u128) -> u128 {
261 0xa5a5_0000_0000_0000_0000_0000_0000_0000 + n
262 }
263
264 /// Short enough that a contended lock fails the test quickly rather
265 /// than holding it up for the three real minutes.
266 const BRIEFLY: Duration = Duration::from_millis(50);
267
268 /// The lock is taken here rather than through a second `begin_in`,
269 /// because a file lock is held per process and a second `begin_in`
270 /// would be answered by the in-process registry instead of by the
271 /// lock this is about.
272 #[test]
273 fn a_lock_held_elsewhere_keeps_this_process_out() {
274 let dir = tempfile::tempdir().unwrap();
275 let path = dir.path().join(format!("warm-{:032x}.lock", key(1)));
276 let elsewhere = open_lock_file(&path).unwrap();
277 elsewhere.lock().unwrap();
278
279 assert!(
280 WarmUp::begin_in(dir.path(), key(1), BRIEFLY).is_none(),
281 "a caller gives up rather than compiling alongside the process ahead",
282 );
283 }
284
285 #[test]
286 fn one_process_takes_one_place_per_key() {
287 let dir = tempfile::tempdir().unwrap();
288
289 let first = WarmUp::begin_in(dir.path(), key(6), BRIEFLY);
290 assert!(first.is_some(), "the first caller compiles");
291
292 assert!(
293 WarmUp::begin_in(dir.path(), key(6), BRIEFLY).is_none(),
294 "the second caller carries on rather than waiting for itself",
295 );
296 }
297
298 #[test]
299 fn a_released_place_can_be_taken_again() {
300 let dir = tempfile::tempdir().unwrap();
301
302 drop(WarmUp::begin_in(dir.path(), key(7), BRIEFLY));
303
304 assert!(
305 WarmUp::begin_in(dir.path(), key(7), BRIEFLY).is_some(),
306 "the key is free again once the place is given up",
307 );
308 }
309
310 #[test]
311 fn a_finished_warm_up_lets_the_next_process_straight_through() {
312 let dir = tempfile::tempdir().unwrap();
313
314 WarmUp::begin_in(dir.path(), key(2), BRIEFLY).unwrap().finish();
315
316 assert!(
317 WarmUp::begin_in(dir.path(), key(2), BRIEFLY).is_none(),
318 "a warm cache needs no queue",
319 );
320 }
321
322 #[test]
323 fn an_abandoned_warm_up_leaves_the_cache_cold() {
324 let dir = tempfile::tempdir().unwrap();
325
326 drop(WarmUp::begin_in(dir.path(), key(3), BRIEFLY));
327
328 assert!(
329 WarmUp::begin_in(dir.path(), key(3), BRIEFLY).is_some(),
330 "no stamp means the kernels still need compiling",
331 );
332 }
333
334 /// A `PlaneOptions` with no accelerator named, so that this test
335 /// module builds whichever backend feature is on.
336 fn options() -> PlaneOptions {
337 PlaneOptions {
338 accelerators: Vec::new(),
339 device: crate::Device::Default,
340 intent: crate::ChannelIntent::LumaChroma,
341 mode: crate::DenoisingMode::Temporal { radius: 2 },
342 algorithm: crate::Algorithm::default(),
343 luma_strength: None,
344 chroma_strength: None,
345 luma_lambda_ht: None,
346 chroma_lambda_ht: None,
347 luma_mismatch_scale: None,
348 chroma_mismatch_scale: None,
349 }
350 }
351
352 fn layout() -> FrameLayout {
353 FrameLayout {
354 width: 1920,
355 height: 1080,
356 subsampling: crate::Subsampling::Yuv420,
357 depth: crate::Depth::Eight,
358 }
359 }
360
361 #[test]
362 fn the_same_settings_give_the_same_key() {
363 assert_eq!(kernel_key(&options(), layout()), kernel_key(&options(), layout()));
364 }
365
366 #[test]
367 fn a_different_depth_gives_a_different_key() {
368 let ten_bit = FrameLayout {
369 depth: crate::Depth::Ten,
370 ..layout()
371 };
372
373 assert_ne!(kernel_key(&options(), layout()), kernel_key(&options(), ten_bit));
374 }
375
376 #[test]
377 fn a_different_radius_gives_a_different_key() {
378 let wider = PlaneOptions {
379 mode: crate::DenoisingMode::Temporal { radius: 3 },
380 ..options()
381 };
382
383 assert_ne!(kernel_key(&options(), layout()), kernel_key(&wider, layout()));
384 }
385
386 #[test]
387 fn different_kernels_do_not_wait_for_each_other() {
388 let dir = tempfile::tempdir().unwrap();
389
390 let first = WarmUp::begin_in(dir.path(), key(4), BRIEFLY);
391 let second = WarmUp::begin_in(dir.path(), key(5), BRIEFLY);
392
393 assert!(
394 first.is_some() && second.is_some(),
395 "separate keys queue separately"
396 );
397 }
398}