Skip to main content

cranpose_render_wgpu/
pipeline_disk_cache.rs

1use std::{
2    path::{Path, PathBuf},
3    sync::OnceLock,
4};
5
6use web_time::Instant;
7
8use crate::debug_toggles::DebugToggle;
9
10static DISK_CACHE: DebugToggle = DebugToggle::new("CRANPOSE_PIPELINE_DISK_CACHE");
11
12fn disk_cache_enabled() -> bool {
13    !DISK_CACHE.equals("0")
14}
15
16static HOST_FILE: OnceLock<PathBuf> = OnceLock::new();
17
18/// Names the file the driver's compiled pipelines are kept in between runs.
19///
20/// This crate renders and does not know where an application may write, so the
21/// platform backend passes the path in once, before the first device is
22/// created. Without it the cache lives and dies with the process, and every
23/// launch pays the driver's compile again. A later call is ignored: the
24/// loaded blob and the watcher that writes it back must name one file.
25pub fn set_file_path(path: PathBuf) {
26    let _ = HOST_FILE.set(path);
27}
28
29pub(crate) fn file_path() -> Option<PathBuf> {
30    if !disk_cache_enabled() {
31        return None;
32    }
33    match crate::debug_toggles::debug_toggle_os("CRANPOSE_PIPELINE_CACHE_FILE") {
34        Some(path) if !path.is_empty() => Some(PathBuf::from(path)),
35        _ => HOST_FILE.get().cloned(),
36    }
37}
38
39pub(crate) fn load(device: &wgpu::Device) -> Option<wgpu::PipelineCache> {
40    if !device.features().contains(wgpu::Features::PIPELINE_CACHE) {
41        log::info!(
42            "[pipeline-cache] not offered by {:?}; compiled pipelines persist only as far \
43             as the driver's own cache does",
44            device.adapter_info().backend
45        );
46        return None;
47    }
48    let path = file_path();
49    let data = path.as_deref().and_then(|path| match std::fs::read(path) {
50        Ok(bytes) => Some(bytes),
51        Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
52        Err(error) => {
53            log::warn!("[pipeline-cache] unreadable {path:?}: {error}");
54            None
55        }
56    });
57    let loaded = data.as_ref().map(Vec::len);
58    // SAFETY: `data` is `persist`'s own `get_data` output, and `fallback:
59    // true` has wgpu validate the header and fall back to an empty cache.
60    #[allow(unsafe_code)]
61    let cache = unsafe {
62        device.create_pipeline_cache(&wgpu::PipelineCacheDescriptor {
63            label: Some("cranpose pipeline disk cache"),
64            data: data.as_deref(),
65            fallback: true,
66        })
67    };
68    match loaded {
69        Some(bytes) => log::info!("[pipeline-cache] loaded {bytes} B from disk"),
70        None => log::info!("[pipeline-cache] cold (no blob on disk)"),
71    }
72    Some(cache)
73}
74
75pub(crate) fn persist(cache: &wgpu::PipelineCache, path: &Path) {
76    let started = Instant::now();
77    let Some(data) = cache.get_data() else {
78        return;
79    };
80    if let Ok(existing) = std::fs::read(path)
81        && existing == data
82    {
83        return;
84    }
85    if let Some(parent) = path.parent()
86        && let Err(error) = std::fs::create_dir_all(parent)
87    {
88        log::warn!("[pipeline-cache] create_dir_all {parent:?}: {error}");
89        return;
90    }
91    let tmp = path.with_extension("tmp");
92    let written = std::fs::write(&tmp, &data).and_then(|()| std::fs::rename(&tmp, path));
93    match written {
94        Ok(()) => log::info!(
95            "[pipeline-cache] persisted {} B in {:.1} ms",
96            data.len(),
97            crate::render::instant_ms(started, Instant::now()),
98        ),
99        Err(error) => log::warn!("[pipeline-cache] write {path:?}: {error}"),
100    }
101}
102
103/// Decides, once a tick, whether the cache should be written: after the
104/// pipeline count has grown since the last write and then held still for a
105/// whole tick, so a burst of compiles is written once, after its last one,
106/// and a variant first reached late in a session reaches the disk too.
107#[derive(Default)]
108struct PersistWatch {
109    persisted: u64,
110    seen: u64,
111}
112
113impl PersistWatch {
114    fn observe(&mut self, created: u64) -> bool {
115        let quiet = created == self.seen;
116        self.seen = created;
117        if quiet && created != self.persisted {
118            self.persisted = created;
119            return true;
120        }
121        false
122    }
123}
124
125const PERSIST_TICK: std::time::Duration = std::time::Duration::from_secs(2);
126
127pub(crate) fn spawn_persist_watcher(cache: wgpu::PipelineCache) {
128    let Some(path) = file_path() else {
129        return;
130    };
131    let spawned = std::thread::Builder::new()
132        .name("cranpose-pl-cache".into())
133        .spawn(move || {
134            let mut watch = PersistWatch::default();
135            loop {
136                std::thread::sleep(PERSIST_TICK);
137                if watch.observe(
138                    crate::render::pipelines_created()
139                        + crate::render::pipelines_created_off_frame(),
140                ) {
141                    persist(&cache, &path);
142                }
143            }
144        });
145    if let Err(error) = spawned {
146        log::warn!("[pipeline-cache] persist thread failed to spawn: {error}");
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::PersistWatch;
153
154    #[test]
155    fn a_burst_of_pipelines_persists_once_after_it_goes_quiet() {
156        let mut watch = PersistWatch::default();
157        assert!(!watch.observe(0));
158        assert!(!watch.observe(3), "still growing");
159        assert!(!watch.observe(5), "still growing");
160        assert!(watch.observe(5), "quiet for a tick with new pipelines");
161        assert!(!watch.observe(5), "written already");
162    }
163
164    #[test]
165    fn a_pipeline_reached_late_in_a_session_persists_too() {
166        let mut watch = PersistWatch::default();
167        watch.observe(19);
168        assert!(watch.observe(19));
169        for _ in 0..20 {
170            assert!(!watch.observe(19));
171        }
172        assert!(!watch.observe(20));
173        assert!(watch.observe(20));
174    }
175}