1use std::{
2 hash::Hasher,
3 io::Write,
4 path::{Path, PathBuf},
5 sync::{
6 Arc, Condvar, Mutex, MutexGuard, OnceLock, PoisonError,
7 atomic::{AtomicBool, Ordering},
8 },
9};
10
11use cranpose_ui_graphics::FxHasher;
12use web_time::{Duration, Instant};
13
14use crate::debug_toggles::DebugToggle;
15
16static DISK_CACHE: DebugToggle = DebugToggle::new("CRANPOSE_PIPELINE_DISK_CACHE");
17
18fn disk_cache_enabled() -> bool {
19 !DISK_CACHE.equals("0")
20}
21
22static HOST_FILE: OnceLock<PathBuf> = OnceLock::new();
23
24pub fn set_file_path(path: PathBuf) {
32 let _ = HOST_FILE.set(path);
33}
34
35pub(crate) fn file_path() -> Option<PathBuf> {
36 if !disk_cache_enabled() {
37 return None;
38 }
39 match crate::debug_toggles::debug_toggle_os("CRANPOSE_PIPELINE_CACHE_FILE") {
40 Some(path) if !path.is_empty() => Some(PathBuf::from(path)),
41 _ => HOST_FILE.get().cloned(),
42 }
43}
44
45pub(crate) struct Loaded {
49 pub(crate) cache: Option<wgpu::PipelineCache>,
50 pub(crate) first_screen: Vec<u64>,
51}
52
53pub(crate) fn load(device: &wgpu::Device) -> Loaded {
54 let path = file_path();
55 let file = path.as_deref().and_then(|path| match std::fs::read(path) {
56 Ok(bytes) => Some(bytes),
57 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
58 Err(error) => {
59 log::warn!("[pipeline-cache] unreadable {path:?}: {error}");
60 None
61 }
62 });
63 let contents = file.as_deref().and_then(current_contents);
64 let first_screen = contents
65 .as_ref()
66 .map_or_else(Vec::new, |contents| contents.first_screen.clone().collect());
67 if !device.features().contains(wgpu::Features::PIPELINE_CACHE) {
68 log::info!(
69 "[pipeline-cache] not offered by {:?}; compiled pipelines persist only as far \
70 as the driver's own cache does",
71 device.adapter_info().backend
72 );
73 return Loaded {
74 cache: None,
75 first_screen,
76 };
77 }
78 let data = contents
79 .as_ref()
80 .and_then(|contents| contents.blob)
81 .filter(|blob| !blob.is_empty());
82 #[expect(unsafe_code)]
85 let cache = unsafe {
86 device.create_pipeline_cache(&wgpu::PipelineCacheDescriptor {
87 label: Some("cranpose pipeline disk cache"),
88 data,
89 fallback: true,
90 })
91 };
92 match (data, &file) {
93 (Some(data), _) => log::info!("[pipeline-cache] loaded {} B from disk", data.len()),
94 (None, Some(file))
95 if contents
96 .as_ref()
97 .is_none_or(|contents| contents.blob.is_none()) =>
98 {
99 log::info!(
100 "[pipeline-cache] cold: dropped {} B compiled from other shaders",
101 file.len()
102 );
103 }
104 (None, _) => log::info!("[pipeline-cache] cold (no blob on disk)"),
105 }
106 Loaded {
107 cache: Some(cache),
108 first_screen,
109 }
110}
111
112const FILE_LAYOUT: u32 = 3;
113
114fn blob_key() -> [u8; 8] {
123 let mut hasher = FxHasher::default();
124 hasher.write_u32(FILE_LAYOUT);
125 hasher.write_u64(cranpose_ui_graphics::framework_shaders::SOURCES_KEY);
126 hasher.write(env!("CARGO_PKG_VERSION").as_bytes());
127 hasher.finish().to_le_bytes()
128}
129
130struct Contents<'a> {
131 first_screen: FirstScreenKeys<'a>,
132 blob: Option<&'a [u8]>,
133}
134
135#[derive(Clone)]
136struct FirstScreenKeys<'a>(&'a [u8]);
137
138impl Iterator for FirstScreenKeys<'_> {
139 type Item = u64;
140
141 fn next(&mut self) -> Option<u64> {
142 let (key, rest) = self.0.split_first_chunk::<8>()?;
143 self.0 = rest;
144 Some(u64::from_le_bytes(*key))
145 }
146
147 fn size_hint(&self) -> (usize, Option<usize>) {
148 (self.len(), Some(self.len()))
149 }
150}
151
152impl ExactSizeIterator for FirstScreenKeys<'_> {
153 fn len(&self) -> usize {
154 self.0.len() / 8
155 }
156}
157
158fn current_contents(file: &[u8]) -> Option<Contents<'_>> {
159 let (build, rest) = file.split_first_chunk::<8>()?;
160 let rest = rest.strip_prefix(crate::render::ShapePipelineKey::DISK_LAYOUT.as_slice())?;
161 let (count, rest) = rest.split_first_chunk::<4>()?;
162 let keys_len = usize::try_from(u32::from_le_bytes(*count))
163 .ok()?
164 .checked_mul(8)?;
165 let (keys, blob) = rest.split_at_checked(keys_len)?;
166 Some(Contents {
167 first_screen: FirstScreenKeys(keys),
168 blob: (*build == blob_key()).then_some(blob),
169 })
170}
171
172fn file_bytes(first_screen: impl ExactSizeIterator<Item = u64>, blob: &[u8]) -> Option<Vec<u8>> {
173 let count = u32::try_from(first_screen.len()).ok()?;
174 let mut bytes = Vec::with_capacity(20 + first_screen.len() * 8 + blob.len());
175 bytes.extend_from_slice(&blob_key());
176 bytes.extend_from_slice(&crate::render::ShapePipelineKey::DISK_LAYOUT);
177 bytes.extend_from_slice(&count.to_le_bytes());
178 for key in first_screen {
179 bytes.extend_from_slice(&key.to_le_bytes());
180 }
181 bytes.extend_from_slice(blob);
182 Some(bytes)
183}
184
185fn write_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
186 let mut file = std::fs::File::create(path)?;
187 file.write_all(bytes)
188}
189
190pub(crate) fn persist(cache: Option<&wgpu::PipelineCache>, path: &Path) {
191 let started = Instant::now();
192 let blob = cache
193 .and_then(wgpu::PipelineCache::get_data)
194 .unwrap_or_default();
195 let existing = std::fs::read(path).ok();
196 let previous = existing.as_deref().and_then(current_contents);
197 let first_screen = first_screen_keys();
198 let key_count = first_screen.as_ref().map_or_else(
199 || {
200 previous
201 .as_ref()
202 .map_or(0, |contents| contents.first_screen.len())
203 },
204 Vec::len,
205 );
206 let bytes = match (first_screen.as_ref(), previous) {
207 (Some(keys), _) => file_bytes(keys.iter().copied(), &blob),
208 (None, Some(previous)) => file_bytes(previous.first_screen, &blob),
209 (None, None) => file_bytes(std::iter::empty(), &blob),
210 };
211 let Some(bytes) = bytes else {
212 return;
213 };
214 if existing.is_some_and(|existing| existing == bytes) {
215 return;
216 }
217 if let Some(parent) = path.parent()
218 && let Err(error) = std::fs::create_dir_all(parent)
219 {
220 log::warn!("[pipeline-cache] create_dir_all {parent:?}: {error}");
221 return;
222 }
223 let tmp = path.with_extension("tmp");
224 let written = write_file(&tmp, &bytes).and_then(|()| std::fs::rename(&tmp, path));
225 match written {
226 Ok(()) => log::info!(
227 "[pipeline-cache] persisted {} B and {} first-screen pipelines in {:.1} ms",
228 blob.len(),
229 key_count,
230 crate::render::instant_ms(started, Instant::now()),
231 ),
232 Err(error) => log::warn!("[pipeline-cache] write {path:?}: {error}"),
233 }
234}
235
236pub(crate) const FIRST_SCREEN_SPAN: Duration = Duration::from_secs(2);
237
238static FIRST_SCREEN: Mutex<Vec<u64>> = Mutex::new(Vec::new());
241static FIRST_FRAME_DRAWN: AtomicBool = AtomicBool::new(false);
242
243pub(crate) fn note_first_screen_pipeline(key: u64) {
247 let mut keys = FIRST_SCREEN.lock().unwrap_or_else(PoisonError::into_inner);
248 if !keys.contains(&key) {
249 keys.push(key);
250 CHANGES.note_change();
251 }
252}
253
254pub(crate) fn note_frame_drawn() {
255 if !FIRST_FRAME_DRAWN.load(Ordering::Acquire) {
256 FIRST_FRAME_DRAWN.store(true, Ordering::Release);
257 CHANGES.note_change();
258 }
259}
260
261fn first_screen_keys() -> Option<Vec<u64>> {
262 FIRST_FRAME_DRAWN.load(Ordering::Acquire).then(|| {
263 FIRST_SCREEN
264 .lock()
265 .unwrap_or_else(PoisonError::into_inner)
266 .clone()
267 })
268}
269
270const PERSIST_QUIET: Duration = Duration::from_millis(500);
275
276static CHANGES: PersistSignal = PersistSignal::new();
277
278pub(crate) fn note_pipeline_built() {
280 CHANGES.note_change();
281}
282
283struct PersistSignal {
284 revision: Mutex<u64>,
285 changed: Condvar,
286}
287
288#[derive(Debug, PartialEq, Eq)]
290enum Wake {
291 Quiet(u64),
293 Stopped(u64),
295}
296
297impl PersistSignal {
298 const fn new() -> Self {
299 Self {
300 revision: Mutex::new(0),
301 changed: Condvar::new(),
302 }
303 }
304
305 fn lock(&self) -> MutexGuard<'_, u64> {
306 self.revision.lock().unwrap_or_else(PoisonError::into_inner)
307 }
308
309 fn note_change(&self) {
310 *self.lock() += 1;
311 self.changed.notify_all();
312 }
313
314 fn stop(&self, stopped: &AtomicBool) {
315 stopped.store(true, Ordering::Release);
316 let _held = self.lock();
319 self.changed.notify_all();
320 }
321
322 fn wait_for_quiet(&self, written: u64, quiet: Duration, stopped: &AtomicBool) -> Wake {
323 let is_stopped = || stopped.load(Ordering::Acquire);
324 let mut revision = self
325 .changed
326 .wait_while(self.lock(), |revision| {
327 *revision == written && !is_stopped()
328 })
329 .unwrap_or_else(PoisonError::into_inner);
330 loop {
331 if is_stopped() {
332 return Wake::Stopped(*revision);
333 }
334 let seen = *revision;
335 let (next, waited) = self
336 .changed
337 .wait_timeout_while(revision, quiet, |revision| {
338 *revision == seen && !is_stopped()
339 })
340 .unwrap_or_else(PoisonError::into_inner);
341 revision = next;
342 if waited.timed_out() {
343 return Wake::Quiet(*revision);
344 }
345 }
346 }
347}
348
349pub(crate) struct PersistWatcher {
352 stopped: Arc<AtomicBool>,
353 thread: Option<std::thread::JoinHandle<()>>,
354}
355
356impl Drop for PersistWatcher {
357 fn drop(&mut self) {
358 CHANGES.stop(&self.stopped);
359 if let Some(thread) = self.thread.take()
360 && thread.join().is_err()
361 {
362 log::warn!("[pipeline-cache] persist thread panicked during shutdown");
363 }
364 }
365}
366
367pub(crate) fn spawn_persist_watcher(cache: Option<wgpu::PipelineCache>) -> Option<PersistWatcher> {
368 let path = file_path()?;
369 let stopped = Arc::new(AtomicBool::new(false));
370 let watcher_stopped = Arc::clone(&stopped);
371 let mut written = *CHANGES.lock();
372 let spawned = std::thread::Builder::new()
373 .name("cranpose-pl-cache".into())
374 .spawn(move || {
375 loop {
376 match CHANGES.wait_for_quiet(written, PERSIST_QUIET, &watcher_stopped) {
377 Wake::Quiet(revision) => {
378 persist(cache.as_ref(), &path);
379 written = revision;
380 }
381 Wake::Stopped(revision) => {
382 if revision != written {
383 persist(cache.as_ref(), &path);
384 }
385 return;
386 }
387 }
388 }
389 });
390 match spawned {
391 Ok(thread) => Some(PersistWatcher {
392 stopped,
393 thread: Some(thread),
394 }),
395 Err(error) => {
396 log::warn!("[pipeline-cache] persist thread failed to spawn: {error}");
397 None
398 }
399 }
400}
401
402#[cfg(test)]
403#[path = "tests/pipeline_disk_cache_tests.rs"]
404mod tests;