Skip to main content

frus_gpu/
pipeline_cache.rs

1//! The compiled pipelines kept between launches (milestones 685 and 697, #217).
2//!
3//! Where the device can keep them (`wgpu::Features::PIPELINE_CACHE`: Vulkan), the renderer fills
4//! a `wgpu::PipelineCache` from the file a previous launch wrote, builds every pipeline through
5//! it, and writes it back after the warm-up frame. Everything here that touches a file is a
6//! plain function, so that what is read, written, refused and removed is tested without a GPU.
7//!
8//! - **One file per adapter, driver and frus**: its name holds the frus version, wgpu's key for
9//!   the adapter (vendor and device) and a digest of the driver's name and version. A new driver
10//!   or a new frus starts from an empty cache, and the files of the old ones are removed when
11//!   the new one is written.
12//! - **Sealed**: the file starts with a mark and the digest of what follows. wgpu
13//!   checks the header of the data it is given, but not the rest, which the driver may trust; a
14//!   file cut short or damaged on the disk is refused here, removed, and the pipelines are
15//!   compiled again. Nothing read from the disk can make the launch fail.
16
17use std::path::{Path, PathBuf};
18
19/// Where the compiled pipelines are kept between launches: set by the shell — on Android the
20/// application's cache folder — before the renderer is made.
21static PIPELINE_CACHE_DIR: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
22
23/// Keeps the renderer's compiled pipelines in `dir` between launches, where the device can: the
24/// next start reads them instead of compiling them again (Vulkan; nothing elsewhere).
25pub fn set_pipeline_cache_dir(dir: PathBuf) {
26    if let Ok(mut slot) = PIPELINE_CACHE_DIR.lock() {
27        *slot = Some(dir);
28    }
29}
30
31/// The folder set by [`set_pipeline_cache_dir`], if any.
32pub(crate) fn cache_dir() -> Option<PathBuf> {
33    PIPELINE_CACHE_DIR.lock().ok().and_then(|slot| slot.clone())
34}
35
36/// What every cache file of frus's starts with.
37const PREFIX: &str = "frus-pipelines-";
38
39/// What milestone 685's files were named, unsealed: removed like any other stale file.
40const PREFIX_BEFORE_697: &str = "frus-wgpu_pipeline_cache_";
41
42/// The mark at the head of a sealed file, with the format's number.
43const MAGIC: &[u8; 8] = b"FRUSPC01";
44
45/// The mark and the digest of the data.
46const HEADER: usize = MAGIC.len() + 8;
47
48/// FNV-1a, 64 bits: a digest that needs no dependency and that a torn write or a flipped byte
49/// changes.
50fn 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
56/// The file name of the pipelines compiled for this adapter and driver by this version of frus;
57/// `None` where the backend keeps none (all but Vulkan).
58pub(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
67/// `data` with the header that [`unseal`] checks.
68pub(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
76/// The data a sealed file holds, or `None` when it is not one of ours, cut short, too long or
77/// damaged: any of these changes the digest.
78pub(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
85/// What a previous launch kept in `file`: `None` when there is no file, it cannot be read, or it
86/// is damaged — a damaged file is removed, so that the next write starts clean.
87pub(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
102/// Writes `data` to `file` when it differs from what was `read` at the start, and removes the
103/// files other drivers and versions of frus left in the same folder: whether it wrote. The data
104/// is written aside, then moved over, so a launch that stops halfway leaves no half a file; a
105/// failure leaves nothing behind and is not an error, since the cache only saves time.
106pub(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
125/// Removes the cache files in `dir` other than `keep`: left by another driver, another adapter
126/// or another version of frus, they would never be read again. Files that are not frus's are
127/// left alone.
128fn 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
144/// The device's pipeline cache, filled from the file a previous launch wrote for this adapter
145/// and driver, where the device has one and a folder was given: the cache, its file, and what
146/// was read from it.
147pub(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    // SAFETY: the data is what `PipelineCache::get_data` gave on a previous launch, in a file
158    // named for this adapter, driver and frus, whose digest was checked above; wgpu
159    // checks its header against the device, and `fallback` starts an empty cache when it does
160    // not match.
161    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
178/// The cache written back to its file, after the warm-up frame has built what drivers build
179/// lazily: whether it wrote.
180pub(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    /// A folder of its own under the system's temporary one, emptied first.
191    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    /// The name holds frus's version, the adapter and the driver: another of any of them is
219    /// another file, the same is the same file.
220    #[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        // Nothing a file name cannot hold.
231        assert!(name
232            .chars()
233            .all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c)));
234    }
235
236    /// Only Vulkan keeps pipelines in wgpu: elsewhere there is no file, so no cache.
237    #[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    /// A file cut short, grown, with a flipped byte or another mark is refused.
257    #[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    /// What one launch stores, the next loads.
275    #[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    /// The folder is made when it is missing.
287    #[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    /// A cache unchanged since the start, or empty, is not written again.
297    #[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    /// A damaged file — torn, raw wgpu data from before the seal, or garbage — loads as nothing
311    /// and is removed; the next store writes a good one.
312    #[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    /// A folder that cannot be read from is no cache, not a failure.
327    #[test]
328    fn an_unreadable_file_is_no_cache() {
329        let dir = scratch("unreadable");
330        // A folder where the file should be: reading it fails.
331        let file = dir.join("frus-pipelines-a");
332        std::fs::create_dir_all(&file).unwrap();
333        assert_eq!(load(&file), None);
334        // And writing over it fails without leaving anything aside.
335        assert!(!store(&file, b"compiled", None));
336        assert!(!file.with_extension("partial").exists());
337        std::fs::remove_dir_all(&dir).unwrap();
338    }
339
340    /// Writing a cache removes the ones other drivers and versions left, and nothing that is
341    /// not frus's.
342    #[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    /// Through a real device, where it has a pipeline cache (Vulkan, not every software
361    /// driver): the painters build through a cache, which is kept, read back by a second
362    /// "launch" that builds through it too. Skipped where there is no such device.
363    #[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}