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::{
15 debug_toggles::DebugToggle,
16 pipeline_recorder::PipelineRecorder,
17 pipeline_records::{self, PipelineRecords, Recorded, ShaderPipelineRecord},
18};
19
20static DISK_CACHE: DebugToggle = DebugToggle::new("CRANPOSE_PIPELINE_DISK_CACHE");
21
22fn disk_cache_enabled() -> bool {
23 !DISK_CACHE.equals("0")
24}
25
26static HOST_FILE: OnceLock<PathBuf> = OnceLock::new();
27
28pub fn set_file_path(path: PathBuf) {
36 let _ = HOST_FILE.set(path);
37}
38
39pub(crate) fn file_path() -> Option<PathBuf> {
40 if !disk_cache_enabled() {
41 return None;
42 }
43 match crate::debug_toggles::debug_toggle_os("CRANPOSE_PIPELINE_CACHE_FILE") {
44 Some(path) if !path.is_empty() => Some(PathBuf::from(path)),
45 _ => HOST_FILE.get().cloned(),
46 }
47}
48
49pub(crate) struct Loaded {
53 pub(crate) cache: Option<wgpu::PipelineCache>,
54 pub(crate) records: Arc<PipelineRecords>,
55 pub(crate) updated: bool,
58 pub(crate) holds_recorded: bool,
61}
62
63pub(crate) fn load(device: &wgpu::Device) -> Loaded {
64 let path = file_path();
65 let file = path.as_deref().and_then(|path| match std::fs::read(path) {
66 Ok(bytes) => Some(bytes),
67 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
68 Err(error) => {
69 log::warn!("[pipeline-cache] unreadable {path:?}: {error}");
70 None
71 }
72 });
73 let contents = file.as_deref().and_then(current_contents);
74 let updated = contents
75 .as_ref()
76 .is_some_and(|contents| !contents.this_build);
77 let data = contents
78 .as_ref()
79 .and_then(|contents| contents.blob)
80 .filter(|blob| !blob.is_empty());
81 let cache = driver_cache(device, data, file.as_deref(), updated);
82 Loaded {
83 holds_recorded: data.is_some() && cache.is_some(),
84 cache,
85 records: Arc::new(contents.map(Contents::into_records).unwrap_or_default()),
86 updated,
87 }
88}
89
90pub fn seed(path: &Path, mut bytes: Vec<u8>, same_driver: bool) {
96 if path.exists() {
97 return;
98 }
99 if !same_driver && let Some(build) = bytes.get_mut(..blob_key().len()) {
100 build.fill(0);
101 }
102 if let Some(parent) = path.parent()
103 && let Err(error) = std::fs::create_dir_all(parent)
104 {
105 log::warn!("[pipeline-cache] create_dir_all {parent:?}: {error}");
106 return;
107 }
108 match write_file(path, &bytes) {
109 Ok(()) => log::info!(
110 "[pipeline-cache] seeded {} B from the app{}",
111 bytes.len(),
112 if same_driver { "" } else { ", records only" },
113 ),
114 Err(error) => log::warn!("[pipeline-cache] seeding {path:?}: {error}"),
115 }
116}
117
118fn driver_cache(
121 device: &wgpu::Device,
122 data: Option<&[u8]>,
123 file: Option<&[u8]>,
124 updated: bool,
125) -> Option<wgpu::PipelineCache> {
126 if !device.features().contains(wgpu::Features::PIPELINE_CACHE) {
127 log::info!(
128 "[pipeline-cache] not offered by {:?}; compiled pipelines persist only as far \
129 as the driver's own cache does",
130 device.adapter_info().backend
131 );
132 return None;
133 }
134 #[expect(unsafe_code)]
137 let cache = unsafe {
138 device.create_pipeline_cache(&wgpu::PipelineCacheDescriptor {
139 label: Some("cranpose pipeline disk cache"),
140 data,
141 fallback: true,
142 })
143 };
144 match (data, file) {
145 (Some(data), _) => log::info!("[pipeline-cache] loaded {} B from disk", data.len()),
146 (None, Some(file)) if updated => log::info!(
147 "[pipeline-cache] cold: dropped {} B compiled from other shaders",
148 file.len()
149 ),
150 (None, _) => log::info!("[pipeline-cache] cold (no blob on disk)"),
151 }
152 Some(cache)
153}
154
155const FILE_LAYOUT: u32 = 4;
156
157fn blob_key() -> [u8; 8] {
166 let mut hasher = FxHasher::default();
167 hasher.write_u32(FILE_LAYOUT);
168 hasher.write_u64(cranpose_ui_graphics::framework_shaders::SOURCES_KEY);
169 hasher.write(env!("CARGO_PKG_VERSION").as_bytes());
170 hasher.finish().to_le_bytes()
171}
172
173struct Contents<'a> {
174 first_screen: FirstScreenKeys<'a>,
175 records: PipelineRecords,
176 this_build: bool,
178 blob: Option<&'a [u8]>,
179}
180
181impl Contents<'_> {
182 fn into_records(self) -> PipelineRecords {
184 let mut records = self.records;
185 records.shapes.splice(
186 0..0,
187 self.first_screen.map(|key| Recorded {
188 entry: key,
189 age: 0,
190 first_screen: true,
191 }),
192 );
193 records
194 }
195}
196
197#[derive(Clone)]
198struct FirstScreenKeys<'a>(&'a [u8]);
199
200impl Iterator for FirstScreenKeys<'_> {
201 type Item = u64;
202
203 fn next(&mut self) -> Option<u64> {
204 let (key, rest) = self.0.split_first_chunk::<8>()?;
205 self.0 = rest;
206 Some(u64::from_le_bytes(*key))
207 }
208
209 fn size_hint(&self) -> (usize, Option<usize>) {
210 (self.len(), Some(self.len()))
211 }
212}
213
214impl ExactSizeIterator for FirstScreenKeys<'_> {
215 fn len(&self) -> usize {
216 self.0.len() / 8
217 }
218}
219
220fn current_contents(file: &[u8]) -> Option<Contents<'_>> {
221 let (build, rest) = file.split_first_chunk::<8>()?;
222 let rest = rest.strip_prefix(crate::render::ShapePipelineKey::DISK_LAYOUT.as_slice())?;
223 let (count, rest) = rest.split_first_chunk::<4>()?;
224 let keys_len = usize::try_from(u32::from_le_bytes(*count))
225 .ok()?
226 .checked_mul(8)?;
227 let (keys, rest) = rest.split_at_checked(keys_len)?;
228 let (records, blob) = pipeline_records::decode(rest).map_or_else(
231 || (PipelineRecords::default(), None),
232 |(records, blob)| (records, Some(blob)),
233 );
234 let this_build = *build == blob_key();
235 Some(Contents {
236 first_screen: FirstScreenKeys(keys),
237 records,
238 this_build,
239 blob: blob.filter(|_| this_build),
240 })
241}
242
243fn file_bytes<'a>(
246 shapes: impl Iterator<Item = Recorded<u64>> + Clone,
247 shaders: impl Iterator<Item = Recorded<&'a ShaderPipelineRecord>>,
248 fixed: impl Iterator<Item = Recorded<&'a str>>,
249 blob: &[u8],
250) -> Option<Vec<u8>> {
251 let mut bytes = Vec::with_capacity(4096 + blob.len());
252 bytes.extend_from_slice(&blob_key());
253 bytes.extend_from_slice(&crate::render::ShapePipelineKey::DISK_LAYOUT);
254 let first_screen = shapes.clone().filter(|shape| shape.first_screen);
255 bytes.extend_from_slice(
256 &u32::try_from(first_screen.clone().count())
257 .ok()?
258 .to_le_bytes(),
259 );
260 for shape in first_screen {
261 bytes.extend_from_slice(&shape.entry.to_le_bytes());
262 }
263 pipeline_records::encode(
264 shapes.filter(|shape| !shape.first_screen),
265 shaders,
266 fixed,
267 &mut bytes,
268 )?;
269 bytes.extend_from_slice(blob);
270 Some(bytes)
271}
272
273fn write_file(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
274 let mut file = std::fs::File::create(path)?;
275 file.write_all(bytes)
276}
277
278pub(crate) fn persist(
279 cache: Option<&wgpu::PipelineCache>,
280 path: &Path,
281 previous: &PipelineRecords,
282 recorder: &PipelineRecorder,
283) {
284 let started = Instant::now();
285 let blob = cache
286 .and_then(wgpu::PipelineCache::get_data)
287 .unwrap_or_default();
288 let existing = std::fs::read(path).ok();
289 let (bytes, key_count) = if recorder.frame_drawn() {
291 let drawn = recorder.drawn();
292 let shapes = recent(
293 drawn.shapes.iter().copied(),
294 previous.shapes.iter().copied(),
295 );
296 let shaders = recent(
297 drawn.shaders.iter().map(Recorded::as_ref),
298 previous.shaders.iter().map(Recorded::as_ref),
299 );
300 let fixed = recent(
301 drawn.fixed.iter().copied(),
302 previous
303 .fixed
304 .iter()
305 .map(|label| label.as_ref().map(String::as_str)),
306 );
307 let count = shapes.clone().count() + shaders.clone().count() + fixed.clone().count();
308 (file_bytes(shapes, shaders, fixed, &blob), count)
309 } else {
310 (
311 file_bytes(
312 previous.shapes.iter().copied(),
313 previous.shaders.iter().map(Recorded::as_ref),
314 previous
315 .fixed
316 .iter()
317 .map(|label| label.as_ref().map(String::as_str)),
318 &blob,
319 ),
320 previous.shapes.len() + previous.shaders.len() + previous.fixed.len(),
321 )
322 };
323 let Some(bytes) = bytes else {
324 return;
325 };
326 if existing.is_some_and(|existing| existing == bytes) {
327 return;
328 }
329 if let Some(parent) = path.parent()
330 && let Err(error) = std::fs::create_dir_all(parent)
331 {
332 log::warn!("[pipeline-cache] create_dir_all {parent:?}: {error}");
333 return;
334 }
335 let tmp = path.with_extension("tmp");
336 let written = write_file(&tmp, &bytes).and_then(|()| std::fs::rename(&tmp, path));
337 match written {
338 Ok(()) => log::info!(
339 "[pipeline-cache] persisted {} B and {} recorded pipelines in {:.1} ms",
340 blob.len(),
341 key_count,
342 crate::render::instant_ms(started, Instant::now()),
343 ),
344 Err(error) => log::warn!("[pipeline-cache] write {path:?}: {error}"),
345 }
346}
347
348const RECORDED_LAUNCHES: u8 = 4;
351const MAX_RECORDS: usize = 128;
353
354fn recent<T: Copy + PartialEq>(
358 session: impl Iterator<Item = Recorded<T>> + Clone,
359 previous: impl Iterator<Item = Recorded<T>> + Clone,
360) -> impl Iterator<Item = Recorded<T>> + Clone {
361 let drawn = session.clone();
362 session
363 .chain(previous.filter_map(move |record| {
364 let unseen = !drawn.clone().any(|drawn| drawn.entry == record.entry);
365 (unseen && record.age + 1 < RECORDED_LAUNCHES).then_some(Recorded {
366 age: record.age + 1,
367 first_screen: false,
368 ..record
369 })
370 }))
371 .take(MAX_RECORDS)
372}
373
374const PERSIST_QUIET: Duration = Duration::from_millis(500);
379
380static CHANGES: PersistSignal = PersistSignal::new();
381
382pub(crate) fn note_change() {
385 CHANGES.note_change();
386}
387
388struct PersistSignal {
389 revision: Mutex<u64>,
390 changed: Condvar,
391}
392
393#[derive(Debug, PartialEq, Eq)]
395enum Wake {
396 Quiet(u64),
398 Stopped(u64),
400}
401
402impl PersistSignal {
403 const fn new() -> Self {
404 Self {
405 revision: Mutex::new(0),
406 changed: Condvar::new(),
407 }
408 }
409
410 fn lock(&self) -> MutexGuard<'_, u64> {
411 self.revision.lock().unwrap_or_else(PoisonError::into_inner)
412 }
413
414 fn note_change(&self) {
415 *self.lock() += 1;
416 self.changed.notify_all();
417 }
418
419 fn stop(&self, stopped: &AtomicBool) {
420 stopped.store(true, Ordering::Release);
421 let _held = self.lock();
424 self.changed.notify_all();
425 }
426
427 fn wait_for_quiet(&self, written: u64, quiet: Duration, stopped: &AtomicBool) -> Wake {
428 let is_stopped = || stopped.load(Ordering::Acquire);
429 let mut revision = self
430 .changed
431 .wait_while(self.lock(), |revision| {
432 *revision == written && !is_stopped()
433 })
434 .unwrap_or_else(PoisonError::into_inner);
435 loop {
436 if is_stopped() {
437 return Wake::Stopped(*revision);
438 }
439 let seen = *revision;
440 let (next, waited) = self
441 .changed
442 .wait_timeout_while(revision, quiet, |revision| {
443 *revision == seen && !is_stopped()
444 })
445 .unwrap_or_else(PoisonError::into_inner);
446 revision = next;
447 if waited.timed_out() {
448 return Wake::Quiet(*revision);
449 }
450 }
451 }
452}
453
454pub(crate) struct PersistWatcher {
457 stopped: Arc<AtomicBool>,
458 thread: Option<std::thread::JoinHandle<()>>,
459}
460
461impl Drop for PersistWatcher {
462 fn drop(&mut self) {
463 CHANGES.stop(&self.stopped);
464 if let Some(thread) = self.thread.take()
465 && thread.join().is_err()
466 {
467 log::warn!("[pipeline-cache] persist thread panicked during shutdown");
468 }
469 }
470}
471
472pub(crate) fn spawn_persist_watcher(
473 cache: Option<wgpu::PipelineCache>,
474 previous: Arc<PipelineRecords>,
475 recorder: PipelineRecorder,
476) -> Option<PersistWatcher> {
477 let path = file_path()?;
478 let stopped = Arc::new(AtomicBool::new(false));
479 let watcher_stopped = Arc::clone(&stopped);
480 let mut written = *CHANGES.lock();
481 let spawned = std::thread::Builder::new()
482 .name("cranpose-pl-cache".into())
483 .spawn(move || {
484 loop {
485 match CHANGES.wait_for_quiet(written, PERSIST_QUIET, &watcher_stopped) {
486 Wake::Quiet(revision) => {
487 persist(cache.as_ref(), &path, &previous, &recorder);
488 written = revision;
489 }
490 Wake::Stopped(revision) => {
491 if revision != written {
492 persist(cache.as_ref(), &path, &previous, &recorder);
493 }
494 return;
495 }
496 }
497 }
498 });
499 match spawned {
500 Ok(thread) => Some(PersistWatcher {
501 stopped,
502 thread: Some(thread),
503 }),
504 Err(error) => {
505 log::warn!("[pipeline-cache] persist thread failed to spawn: {error}");
506 None
507 }
508 }
509}
510
511#[cfg(test)]
512#[path = "tests/pipeline_disk_cache_tests.rs"]
513mod tests;