Skip to main content

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}