1use std::path::{Path, PathBuf};
18
19static PIPELINE_CACHE_DIR: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
22
23pub fn set_pipeline_cache_dir(dir: PathBuf) {
26 if let Ok(mut slot) = PIPELINE_CACHE_DIR.lock() {
27 *slot = Some(dir);
28 }
29}
30
31pub(crate) fn cache_dir() -> Option<PathBuf> {
33 PIPELINE_CACHE_DIR.lock().ok().and_then(|slot| slot.clone())
34}
35
36const PREFIX: &str = "frus-pipelines-";
38
39const PREFIX_BEFORE_697: &str = "frus-wgpu_pipeline_cache_";
41
42const MAGIC: &[u8; 8] = b"FRUSPC01";
44
45const HEADER: usize = MAGIC.len() + 8;
47
48fn digest(bytes: &[u8]) -> u64 {
51 bytes.iter().fold(0xcbf2_9ce4_8422_2325, |hash, &byte| {
52 (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3)
53 })
54}
55
56pub(crate) fn file_name(info: &wgpu::AdapterInfo) -> Option<String> {
59 let adapter = wgpu::util::pipeline_cache_key(info)?;
60 let driver = digest(format!("{}\u{0}{}", info.driver, info.driver_info).as_bytes());
61 Some(format!(
62 "{PREFIX}{}-{adapter}-{driver:016x}",
63 env!("CARGO_PKG_VERSION")
64 ))
65}
66
67pub(crate) fn seal(data: &[u8]) -> Vec<u8> {
69 let mut sealed = Vec::with_capacity(HEADER + data.len());
70 sealed.extend_from_slice(MAGIC);
71 sealed.extend_from_slice(&digest(data).to_le_bytes());
72 sealed.extend_from_slice(data);
73 sealed
74}
75
76pub(crate) fn unseal(sealed: &[u8]) -> Option<&[u8]> {
79 let (head, data) = sealed.split_at_checked(HEADER)?;
80 let (magic, sum) = head.split_at(MAGIC.len());
81 let sum = u64::from_le_bytes(sum.try_into().ok()?);
82 (magic == MAGIC && sum == digest(data)).then_some(data)
83}
84
85pub(crate) fn load(file: &Path) -> Option<Vec<u8>> {
88 let sealed = std::fs::read(file).ok()?;
89 match unseal(&sealed) {
90 Some(data) => Some(data.to_vec()),
91 None => {
92 log::warn!(
93 "pipeline cache: {} is damaged; the pipelines are compiled again",
94 file.display()
95 );
96 let _ = std::fs::remove_file(file);
97 None
98 }
99 }
100}
101
102pub(crate) fn store(file: &Path, data: &[u8], read: Option<&[u8]>) -> bool {
107 if data.is_empty() || read == Some(data) {
108 return false;
109 }
110 let Some(dir) = file.parent() else {
111 return false;
112 };
113 let _ = std::fs::create_dir_all(dir);
114 let partial = file.with_extension("partial");
115 let written =
116 std::fs::write(&partial, seal(data)).is_ok() && std::fs::rename(&partial, file).is_ok();
117 if !written {
118 let _ = std::fs::remove_file(&partial);
119 return false;
120 }
121 prune(dir, file);
122 true
123}
124
125fn prune(dir: &Path, keep: &Path) {
129 let Ok(entries) = std::fs::read_dir(dir) else {
130 return;
131 };
132 for entry in entries.flatten() {
133 let path = entry.path();
134 let ours = path
135 .file_name()
136 .and_then(|name| name.to_str())
137 .is_some_and(|name| name.starts_with(PREFIX) || name.starts_with(PREFIX_BEFORE_697));
138 if ours && path != keep {
139 let _ = std::fs::remove_file(&path);
140 }
141 }
142}
143
144pub(crate) fn open(
148 adapter: &wgpu::Adapter,
149 device: &wgpu::Device,
150 dir: Option<PathBuf>,
151) -> Option<(wgpu::PipelineCache, PathBuf, Option<Vec<u8>>)> {
152 if !device.features().contains(wgpu::Features::PIPELINE_CACHE) {
153 return None;
154 }
155 let file = dir?.join(file_name(&adapter.get_info())?);
156 let cached = load(&file);
157 let cache = unsafe {
162 device.create_pipeline_cache(&wgpu::PipelineCacheDescriptor {
163 label: Some("frus.pipelines"),
164 data: cached.as_deref(),
165 fallback: true,
166 })
167 };
168 log::info!(
169 "pipeline cache: {}",
170 match &cached {
171 Some(data) => format!("{} KB read from the previous launch", data.len() / 1024),
172 None => "none yet: the pipelines are compiled, and kept for the next launch".into(),
173 }
174 );
175 Some((cache, file, cached))
176}
177
178pub(crate) fn keep(cache: &wgpu::PipelineCache, file: &Path, read: Option<&[u8]>) -> bool {
181 cache
182 .get_data()
183 .is_some_and(|data| store(file, &data, read))
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 fn scratch(name: &str) -> PathBuf {
192 let dir = std::env::temp_dir().join(format!(
193 "frus-pipeline-cache-test-{}-{name}",
194 std::process::id()
195 ));
196 let _ = std::fs::remove_dir_all(&dir);
197 std::fs::create_dir_all(&dir).unwrap();
198 dir
199 }
200
201 fn adapter(backend: wgpu::Backend, device: u32, driver_info: &str) -> wgpu::AdapterInfo {
202 wgpu::AdapterInfo {
203 name: "Mali-G51".into(),
204 vendor: 0x13b5,
205 device,
206 device_type: wgpu::DeviceType::IntegratedGpu,
207 device_pci_bus_id: String::new(),
208 driver: "Mali".into(),
209 driver_info: driver_info.into(),
210 backend,
211 subgroup_min_size: 4,
212 subgroup_max_size: 4,
213 transient_saves_memory: None,
214 limit_bucket: None,
215 }
216 }
217
218 #[test]
221 fn the_file_is_named_for_the_adapter_the_driver_and_frus() {
222 use wgpu::Backend::Vulkan;
223 let name = file_name(&adapter(Vulkan, 0x7212, "r18p0")).unwrap();
224 assert!(name.starts_with(PREFIX), "{name}");
225 assert!(name.contains(env!("CARGO_PKG_VERSION")), "{name}");
226 assert!(name.contains("vulkan_5045_29202"), "{name}");
227 assert_eq!(name, file_name(&adapter(Vulkan, 0x7212, "r18p0")).unwrap());
228 assert_ne!(name, file_name(&adapter(Vulkan, 0x7212, "r19p0")).unwrap());
229 assert_ne!(name, file_name(&adapter(Vulkan, 0x7213, "r18p0")).unwrap());
230 assert!(name
232 .chars()
233 .all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c)));
234 }
235
236 #[test]
238 fn no_file_where_the_backend_keeps_no_pipelines() {
239 for backend in [
240 wgpu::Backend::Dx12,
241 wgpu::Backend::Metal,
242 wgpu::Backend::Gl,
243 wgpu::Backend::BrowserWebGpu,
244 ] {
245 assert_eq!(file_name(&adapter(backend, 1, "x")), None, "{backend:?}");
246 }
247 }
248
249 #[test]
250 fn sealed_data_comes_back_whole() {
251 let data = b"pipelines".repeat(100);
252 assert_eq!(unseal(&seal(&data)), Some(data.as_slice()));
253 assert_eq!(unseal(&seal(&[])), Some(&[][..]));
254 }
255
256 #[test]
258 fn damaged_data_is_refused() {
259 let sealed = seal(&b"pipelines".repeat(100));
260 assert_eq!(unseal(&sealed[..sealed.len() - 1]), None, "cut short");
261 assert_eq!(unseal(&sealed[..HEADER - 1]), None, "no header");
262 assert_eq!(unseal(&[]), None, "empty");
263 let mut grown = sealed.clone();
264 grown.push(0);
265 assert_eq!(unseal(&grown), None, "grown");
266 let mut flipped = sealed.clone();
267 *flipped.last_mut().unwrap() ^= 1;
268 assert_eq!(unseal(&flipped), None, "flipped byte");
269 let mut foreign = sealed.clone();
270 foreign[0] = b'X';
271 assert_eq!(unseal(&foreign), None, "another mark");
272 }
273
274 #[test]
276 fn a_stored_cache_is_loaded_by_the_next_launch() {
277 let dir = scratch("roundtrip");
278 let file = dir.join("frus-pipelines-a");
279 assert_eq!(load(&file), None, "nothing yet");
280 assert!(store(&file, b"compiled", None));
281 assert_eq!(load(&file).as_deref(), Some(&b"compiled"[..]));
282 assert!(!file.with_extension("partial").exists());
283 std::fs::remove_dir_all(&dir).unwrap();
284 }
285
286 #[test]
288 fn the_folder_is_made_when_missing() {
289 let dir = scratch("missing");
290 let file = dir.join("deeper").join("frus-pipelines-a");
291 assert!(store(&file, b"compiled", None));
292 assert_eq!(load(&file).as_deref(), Some(&b"compiled"[..]));
293 std::fs::remove_dir_all(&dir).unwrap();
294 }
295
296 #[test]
298 fn an_unchanged_cache_is_not_written_again() {
299 let dir = scratch("unchanged");
300 let file = dir.join("frus-pipelines-a");
301 assert!(!store(&file, b"compiled", Some(b"compiled")));
302 assert!(!file.exists());
303 assert!(!store(&file, b"", None));
304 assert!(!file.exists());
305 assert!(store(&file, b"compiled more", Some(b"compiled")));
306 assert_eq!(load(&file).as_deref(), Some(&b"compiled more"[..]));
307 std::fs::remove_dir_all(&dir).unwrap();
308 }
309
310 #[test]
313 fn a_damaged_file_is_ignored_and_removed() {
314 let dir = scratch("damaged");
315 let file = dir.join("frus-pipelines-a");
316 for bytes in [&seal(b"compiled")[..10], b"raw wgpu data", &[0xff; 64]] {
317 std::fs::write(&file, bytes).unwrap();
318 assert_eq!(load(&file), None);
319 assert!(!file.exists(), "the damaged file is removed");
320 }
321 assert!(store(&file, b"compiled", None));
322 assert_eq!(load(&file).as_deref(), Some(&b"compiled"[..]));
323 std::fs::remove_dir_all(&dir).unwrap();
324 }
325
326 #[test]
328 fn an_unreadable_file_is_no_cache() {
329 let dir = scratch("unreadable");
330 let file = dir.join("frus-pipelines-a");
332 std::fs::create_dir_all(&file).unwrap();
333 assert_eq!(load(&file), None);
334 assert!(!store(&file, b"compiled", None));
336 assert!(!file.with_extension("partial").exists());
337 std::fs::remove_dir_all(&dir).unwrap();
338 }
339
340 #[test]
343 fn storing_removes_the_files_of_other_drivers_and_versions() {
344 let dir = scratch("prune");
345 let old_driver = dir.join("frus-pipelines-0.3.0-vulkan_1_2-00");
346 let before_697 = dir.join("frus-wgpu_pipeline_cache_vulkan_1_2");
347 let foreign = dir.join("someone-elses.bin");
348 std::fs::write(&old_driver, seal(b"old")).unwrap();
349 std::fs::write(&before_697, b"raw wgpu data").unwrap();
350 std::fs::write(&foreign, b"keep me").unwrap();
351 let file = dir.join("frus-pipelines-0.4.0-vulkan_1_2-01");
352 assert!(store(&file, b"compiled", None));
353 assert!(!old_driver.exists());
354 assert!(!before_697.exists());
355 assert!(foreign.exists());
356 assert_eq!(load(&file).as_deref(), Some(&b"compiled"[..]));
357 std::fs::remove_dir_all(&dir).unwrap();
358 }
359
360 #[test]
364 fn pipelines_built_through_a_kept_cache_where_the_device_has_one() {
365 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
366 backends: wgpu::Backends::VULKAN,
367 ..wgpu::InstanceDescriptor::new_without_display_handle()
368 });
369 let Ok(adapter) =
370 pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default()))
371 else {
372 eprintln!("no Vulkan adapter: test skipped");
373 return;
374 };
375 if !adapter.features().contains(wgpu::Features::PIPELINE_CACHE) {
376 eprintln!(
377 "no pipeline cache on {}: test skipped",
378 adapter.get_info().name
379 );
380 return;
381 }
382 let launch = |dir: &Path| {
383 let (device, queue) =
384 pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
385 required_features: wgpu::Features::PIPELINE_CACHE,
386 required_limits: wgpu::Limits::downlevel_defaults(),
387 ..Default::default()
388 }))
389 .unwrap();
390 let (cache, file, read) = open(&adapter, &device, Some(dir.to_path_buf())).unwrap();
391 let format = wgpu::TextureFormat::Rgba8Unorm;
392 let mut painters =
393 crate::compositor::Painters::new(&device, &queue, format, 1, Some(&cache));
394 painters.warm_up(&device, &queue, format);
395 let wrote = keep(&cache, &file, read.as_deref());
396 (read.is_some(), wrote)
397 };
398 let dir = scratch("device");
399 let (read, wrote) = launch(&dir);
400 assert!(!read, "nothing to read on the first launch");
401 if !wrote {
402 eprintln!("the driver gave no cache data: test ends here");
403 return;
404 }
405 let (read, _) = launch(&dir);
406 assert!(read, "the second launch reads what the first kept");
407 eprintln!(
408 "pipeline cache kept and read on {}",
409 adapter.get_info().name
410 );
411 std::fs::remove_dir_all(&dir).unwrap();
412 }
413}