Skip to main content

cranpose_render_wgpu/
pipeline_disk_cache.rs

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