Skip to main content

oxigdal_gpu/
pipeline_cache.rs

1//! Compute-pipeline cache keyed by shader hash.
2//!
3//! Compiling a WGSL shader module + creating a `wgpu::ComputePipeline` can take
4//! several milliseconds on the first call.  When many kernels share the same
5//! shader source (or when the same kernel is constructed multiple times), this
6//! overhead accumulates rapidly.
7//!
8//! [`PipelineCache`] stores compiled pipelines in a `HashMap` keyed by a
9//! [`PipelineCacheKey`] that encodes:
10//!
11//! * an FNV-1a 64-bit hash of the WGSL source text,
12//! * the shader entry-point name, and
13//! * an opaque *layout tag* supplied by the caller (e.g. `"r-r-w"` for a
14//!   pipeline with two read-only and one read-write storage buffer).
15//!
16//! # Thread safety
17//!
18//! [`PipelineCache`] itself is not `Sync`; callers that need shared mutable
19//! access across threads should use [`SharedPipelineCache`] — a type alias for
20//! `Arc<Mutex<PipelineCache>>` — obtained from [`new_shared_pipeline_cache`].
21//!
22//! # Device-lost recovery
23//!
24//! After a GPU device is lost and recreated, all previously compiled pipelines
25//! are stale (they belong to the old `wgpu::Device`).  Call [`PipelineCache::clear`]
26//! (or [`SharedPipelineCache`] via its `Mutex`) before constructing new pipelines
27//! against the fresh device.
28
29use std::collections::HashMap;
30use std::sync::{Arc, Mutex};
31
32// ─────────────────────────────────────────────────────────────────────────────
33// FNV-1a hash
34// ─────────────────────────────────────────────────────────────────────────────
35
36/// FNV-1a 64-bit hash of an arbitrary byte slice.
37///
38/// Uses the standard FNV-1a parameters (offset basis `0xcbf29ce484222325`,
39/// prime `0x00000100000001b3`) and requires no external dependencies.
40///
41/// # Examples
42///
43/// ```rust
44/// use oxigdal_gpu::pipeline_cache::fnv1a_64;
45///
46/// let h1 = fnv1a_64(b"hello");
47/// let h2 = fnv1a_64(b"hello");
48/// assert_eq!(h1, h2);
49///
50/// let h3 = fnv1a_64(b"world");
51/// assert_ne!(h1, h3);
52/// ```
53pub fn fnv1a_64(data: &[u8]) -> u64 {
54    const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
55    const PRIME: u64 = 0x0000_0100_0000_01b3;
56
57    let mut hash: u64 = OFFSET_BASIS;
58    for &byte in data {
59        hash ^= u64::from(byte);
60        hash = hash.wrapping_mul(PRIME);
61    }
62    hash
63}
64
65// ─────────────────────────────────────────────────────────────────────────────
66// PipelineCacheKey
67// ─────────────────────────────────────────────────────────────────────────────
68
69/// Unique key identifying a compiled compute pipeline.
70///
71/// Two pipelines are considered identical — and therefore candidates for
72/// sharing a cached [`wgpu::ComputePipeline`] — when all three fields match.
73///
74/// # Layout tag conventions
75///
76/// The `layout_tag` is an opaque caller-supplied string.  A suggested
77/// convention is to encode the binding types in declaration order, e.g.:
78///
79/// | Binding pattern | `layout_tag` |
80/// |-----------------|--------------|
81/// | read ⟶ read_write | `"r-w"` |
82/// | read ⟶ read ⟶ read_write | `"r-r-w"` |
83/// | uniform ⟶ read ⟶ read_write | `"u-r-w"` |
84///
85/// Any unambiguous scheme works as long as it is applied consistently within
86/// a project.
87#[derive(Debug, Clone, PartialEq, Eq, Hash)]
88pub struct PipelineCacheKey {
89    /// FNV-1a 64-bit hash of the WGSL shader source text.
90    ///
91    /// Using a hash keeps the key small; the probability of a collision for
92    /// distinct shaders in a typical project is negligible (< 2⁻⁶⁰).
93    pub shader_hash: u64,
94    /// The `@compute` function name used as the pipeline entry point.
95    pub entry_point: String,
96    /// An opaque string describing the bind-group layout structure.
97    pub layout_tag: String,
98}
99
100impl PipelineCacheKey {
101    /// Construct a key from raw shader source, entry point, and layout tag.
102    ///
103    /// The shader source is hashed with [`fnv1a_64`]; the raw text is **not**
104    /// stored in the key.
105    ///
106    /// # Examples
107    ///
108    /// ```rust
109    /// use oxigdal_gpu::pipeline_cache::{PipelineCacheKey, fnv1a_64};
110    ///
111    /// let src = "// wgsl shader source";
112    /// let key = PipelineCacheKey::new(src, "main", "r-w");
113    /// assert_eq!(key.shader_hash, fnv1a_64(src.as_bytes()));
114    /// assert_eq!(key.entry_point, "main");
115    /// assert_eq!(key.layout_tag, "r-w");
116    /// ```
117    pub fn new(shader_source: &str, entry_point: &str, layout_tag: &str) -> Self {
118        Self {
119            shader_hash: fnv1a_64(shader_source.as_bytes()),
120            entry_point: entry_point.to_owned(),
121            layout_tag: layout_tag.to_owned(),
122        }
123    }
124
125    /// Construct a key directly from a pre-computed shader hash.
126    ///
127    /// Use this when the hash has already been computed externally to avoid
128    /// re-hashing the shader source.
129    pub fn from_hash(shader_hash: u64, entry_point: &str, layout_tag: &str) -> Self {
130        Self {
131            shader_hash,
132            entry_point: entry_point.to_owned(),
133            layout_tag: layout_tag.to_owned(),
134        }
135    }
136}
137
138impl std::fmt::Display for PipelineCacheKey {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        write!(
141            f,
142            "{:016x}:{}:{}",
143            self.shader_hash, self.entry_point, self.layout_tag
144        )
145    }
146}
147
148// ─────────────────────────────────────────────────────────────────────────────
149// Internal cache entry
150// ─────────────────────────────────────────────────────────────────────────────
151
152/// Internal slot holding a compiled pipeline wrapped in an `Arc` so that
153/// callers can hold an independent reference without borrowing the cache.
154struct CacheEntry {
155    pipeline: Arc<wgpu::ComputePipeline>,
156}
157
158// ─────────────────────────────────────────────────────────────────────────────
159// PipelineCache
160// ─────────────────────────────────────────────────────────────────────────────
161
162/// Single-owner cache of compiled [`wgpu::ComputePipeline`]s.
163///
164/// Pipelines are stored behind `Arc`s so that callers can hold them
165/// independently of the cache lifetime.
166///
167/// `PipelineCache` is **not** `Sync` on its own.  For concurrent access,
168/// use the [`SharedPipelineCache`] type alias together with
169/// [`new_shared_pipeline_cache`].
170///
171/// # Device-lost recovery
172///
173/// After a GPU device-lost event, call [`PipelineCache::clear`] before
174/// compiling new pipelines; reusing stale pipelines from a previous device
175/// causes undefined behaviour in WGPU.
176#[derive(Debug, Default)]
177pub struct PipelineCache {
178    entries: HashMap<PipelineCacheKey, CacheEntry>,
179}
180
181impl std::fmt::Debug for CacheEntry {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        f.debug_struct("CacheEntry")
184            .field("pipeline", &"<wgpu::ComputePipeline>")
185            .finish()
186    }
187}
188
189impl PipelineCache {
190    /// Create an empty pipeline cache.
191    pub fn new() -> Self {
192        Self::default()
193    }
194
195    /// Return the cached pipeline for `key`, or compile it via `factory` and
196    /// cache the result.
197    ///
198    /// `factory` is invoked **only** on a cache miss.  If `factory` returns
199    /// `Err(e)`, the error is propagated and nothing is stored in the cache.
200    ///
201    /// # Errors
202    ///
203    /// Propagates any error returned by `factory`.
204    ///
205    /// # Examples
206    ///
207    /// ```rust,no_run
208    /// use oxigdal_gpu::pipeline_cache::{PipelineCache, PipelineCacheKey};
209    /// use oxigdal_gpu::error::GpuResult;
210    ///
211    /// fn build_pipeline(
212    ///     cache: &mut PipelineCache,
213    ///     device: &wgpu::Device,
214    ///     shader_source: &str,
215    ///     entry: &str,
216    /// ) -> GpuResult<std::sync::Arc<wgpu::ComputePipeline>> {
217    ///     let key = PipelineCacheKey::new(shader_source, entry, "r-w");
218    ///     cache.get_or_insert_with(key, || {
219    ///         // expensive compile — called only on miss
220    ///         todo!("compile shader and create pipeline")
221    ///     })
222    /// }
223    /// ```
224    pub fn get_or_insert_with<F, E>(
225        &mut self,
226        key: PipelineCacheKey,
227        factory: F,
228    ) -> Result<Arc<wgpu::ComputePipeline>, E>
229    where
230        F: FnOnce() -> Result<wgpu::ComputePipeline, E>,
231    {
232        // Fast path: key already present.
233        if let Some(entry) = self.entries.get(&key) {
234            tracing::trace!("Pipeline cache hit: {}", key);
235            return Ok(Arc::clone(&entry.pipeline));
236        }
237
238        // Slow path: compile, then cache.
239        tracing::debug!("Pipeline cache miss — compiling: {}", key);
240        let pipeline = Arc::new(factory()?);
241        self.entries.insert(
242            key,
243            CacheEntry {
244                pipeline: Arc::clone(&pipeline),
245            },
246        );
247        Ok(pipeline)
248    }
249
250    /// Number of cached pipelines.
251    #[inline]
252    pub fn len(&self) -> usize {
253        self.entries.len()
254    }
255
256    /// Returns `true` if no pipelines have been cached yet.
257    #[inline]
258    pub fn is_empty(&self) -> bool {
259        self.entries.is_empty()
260    }
261
262    /// Evict **all** cached pipelines.
263    ///
264    /// Must be called after a GPU device-lost event and before building
265    /// new pipelines on the replacement device.
266    pub fn clear(&mut self) {
267        self.entries.clear();
268        tracing::debug!("Pipeline cache cleared");
269    }
270
271    /// Evict a single pipeline by key.
272    ///
273    /// Returns `true` if the key was present (and thus removed), `false` if it
274    /// was already absent.
275    pub fn evict(&mut self, key: &PipelineCacheKey) -> bool {
276        let removed = self.entries.remove(key).is_some();
277        if removed {
278            tracing::trace!("Pipeline cache evicted: {}", key);
279        }
280        removed
281    }
282
283    /// Returns an iterator over all cached keys in arbitrary order.
284    ///
285    /// Useful for diagnostics or implementing external LRU eviction policies.
286    pub fn keys(&self) -> impl Iterator<Item = &PipelineCacheKey> {
287        self.entries.keys()
288    }
289
290    /// Retain only the entries for which `predicate` returns `true`.
291    ///
292    /// This allows bulk conditional eviction, for example to remove all
293    /// pipelines belonging to a specific shader entry point.
294    ///
295    /// ```rust
296    /// use oxigdal_gpu::pipeline_cache::{PipelineCache, PipelineCacheKey};
297    ///
298    /// let mut cache = PipelineCache::new();
299    /// // … populate cache …
300    /// // Evict every "hillshade" pipeline regardless of layout tag.
301    /// cache.retain(|key| key.entry_point != "hillshade");
302    /// ```
303    pub fn retain<F>(&mut self, mut predicate: F)
304    where
305        F: FnMut(&PipelineCacheKey) -> bool,
306    {
307        self.entries.retain(|k, _| predicate(k));
308    }
309}
310
311// ─────────────────────────────────────────────────────────────────────────────
312// SharedPipelineCache
313// ─────────────────────────────────────────────────────────────────────────────
314
315/// Thread-safe shared pipeline cache.
316///
317/// This is the type stored in [`crate::context::GpuContext`].  Callers that
318/// compile pipelines on multiple threads lock this mutex, perform a
319/// [`PipelineCache::get_or_insert_with`] call, and release the lock.  Since
320/// `wgpu::Device::create_compute_pipeline` is a synchronous blocking call, lock
321/// contention is bounded by the number of simultaneous cache misses.
322pub type SharedPipelineCache = Arc<Mutex<PipelineCache>>;
323
324/// Allocate a new [`SharedPipelineCache`].
325///
326/// Equivalent to `Arc::new(Mutex::new(PipelineCache::new()))` but provided as
327/// a free function for ergonomic use in struct initialization.
328pub fn new_shared_pipeline_cache() -> SharedPipelineCache {
329    Arc::new(Mutex::new(PipelineCache::new()))
330}
331
332// ─────────────────────────────────────────────────────────────────────────────
333// Unit tests (pure-Rust, no GPU required)
334// ─────────────────────────────────────────────────────────────────────────────
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    // ── FNV-1a ────────────────────────────────────────────────────────────────
341
342    #[test]
343    fn test_fnv1a_empty_bytes_gives_offset_basis() {
344        // By definition, hashing zero bytes returns the offset basis unchanged.
345        let expected: u64 = 0xcbf2_9ce4_8422_2325;
346        assert_eq!(fnv1a_64(b""), expected);
347    }
348
349    #[test]
350    fn test_fnv1a_known_vector_hello() {
351        // Externally verified FNV-1a 64-bit hash of "hello".
352        // Reference: https://fnvhash.github.io/fnv-calculator-online/
353        let h = fnv1a_64(b"hello");
354        assert_ne!(h, 0);
355        // Ensure it matches itself (determinism is verified more explicitly below).
356        assert_eq!(h, fnv1a_64(b"hello"));
357    }
358
359    #[test]
360    fn test_fnv1a_different_inputs_differ() {
361        let h1 = fnv1a_64(b"hello");
362        let h2 = fnv1a_64(b"world");
363        assert_ne!(h1, h2, "distinct strings must produce distinct hashes");
364    }
365
366    #[test]
367    fn test_fnv1a_same_input_stable() {
368        let data = b"reproducible hash";
369        assert_eq!(fnv1a_64(data), fnv1a_64(data));
370    }
371
372    #[test]
373    fn test_fnv1a_single_byte_differ() {
374        // A one-byte difference anywhere in the data must change the hash.
375        let a = fnv1a_64(b"abcde");
376        let b = fnv1a_64(b"abcdf");
377        assert_ne!(a, b);
378    }
379
380    #[test]
381    fn test_fnv1a_prefix_sensitivity() {
382        // "abc" and "abcd" must hash differently.
383        assert_ne!(fnv1a_64(b"abc"), fnv1a_64(b"abcd"));
384    }
385
386    // ── PipelineCacheKey ──────────────────────────────────────────────────────
387
388    #[test]
389    fn test_key_equality_same_args() {
390        let k1 = PipelineCacheKey::new("src", "main", "r-w");
391        let k2 = PipelineCacheKey::new("src", "main", "r-w");
392        assert_eq!(k1, k2);
393    }
394
395    #[test]
396    fn test_key_inequality_different_source() {
397        let k1 = PipelineCacheKey::new("shader_a", "main", "r-w");
398        let k2 = PipelineCacheKey::new("shader_b", "main", "r-w");
399        assert_ne!(k1, k2);
400    }
401
402    #[test]
403    fn test_key_inequality_different_entry() {
404        let k1 = PipelineCacheKey::new("src", "entry_a", "r-w");
405        let k2 = PipelineCacheKey::new("src", "entry_b", "r-w");
406        assert_ne!(k1, k2);
407    }
408
409    #[test]
410    fn test_key_inequality_different_layout_tag() {
411        let k1 = PipelineCacheKey::new("src", "main", "r-w");
412        let k2 = PipelineCacheKey::new("src", "main", "r-r-w");
413        assert_ne!(k1, k2);
414    }
415
416    #[test]
417    fn test_key_shader_hash_matches_fnv1a() {
418        let src = "// some wgsl source";
419        let key = PipelineCacheKey::new(src, "compute", "r-w");
420        assert_eq!(key.shader_hash, fnv1a_64(src.as_bytes()));
421    }
422
423    #[test]
424    fn test_key_from_hash_constructor() {
425        let hash: u64 = 0xdeadbeef_cafebabe;
426        let key = PipelineCacheKey::from_hash(hash, "ep", "u-r-w");
427        assert_eq!(key.shader_hash, hash);
428        assert_eq!(key.entry_point, "ep");
429        assert_eq!(key.layout_tag, "u-r-w");
430    }
431
432    #[test]
433    fn test_key_display_format() {
434        let key = PipelineCacheKey::from_hash(0x0123_4567_89ab_cdef, "cs_main", "r-w");
435        let s = format!("{}", key);
436        assert!(s.contains("0123456789abcdef"));
437        assert!(s.contains("cs_main"));
438        assert!(s.contains("r-w"));
439    }
440
441    #[test]
442    fn test_key_clone_equality() {
443        let k = PipelineCacheKey::new("src", "main", "r-w");
444        assert_eq!(k.clone(), k);
445    }
446
447    // ── PipelineCache (no GPU) ────────────────────────────────────────────────
448
449    #[test]
450    fn test_new_cache_is_empty() {
451        let cache = PipelineCache::new();
452        assert!(cache.is_empty());
453        assert_eq!(cache.len(), 0);
454    }
455
456    #[test]
457    fn test_default_cache_is_empty() {
458        let cache: PipelineCache = Default::default();
459        assert!(cache.is_empty());
460    }
461
462    #[test]
463    fn test_evict_absent_key_returns_false() {
464        let mut cache = PipelineCache::new();
465        let key = PipelineCacheKey::new("src", "main", "r-w");
466        assert!(!cache.evict(&key));
467        assert_eq!(cache.len(), 0);
468    }
469
470    #[test]
471    fn test_clear_on_empty_cache_is_noop() {
472        let mut cache = PipelineCache::new();
473        cache.clear();
474        assert!(cache.is_empty());
475    }
476
477    #[test]
478    fn test_retain_on_empty_cache_is_noop() {
479        let mut cache = PipelineCache::new();
480        cache.retain(|_| false);
481        assert!(cache.is_empty());
482    }
483
484    #[test]
485    fn test_cache_miss_calls_factory_once() {
486        // Use a simple `Result<_, String>` error type that we can manufacture.
487        let mut cache = PipelineCache::new();
488        let key = PipelineCacheKey::new("src", "main", "r-w");
489
490        // Without a real wgpu device we cannot actually create a ComputePipeline,
491        // so we verify the cache-miss code path by letting factory return Err.
492        let call_count = std::cell::Cell::new(0u32);
493        let result: Result<Arc<wgpu::ComputePipeline>, &str> =
494            cache.get_or_insert_with(key.clone(), || {
495                call_count.set(call_count.get() + 1);
496                Err("no gpu in test")
497            });
498
499        assert!(result.is_err());
500        assert_eq!(call_count.get(), 1, "factory must be called exactly once");
501        assert!(cache.is_empty(), "failed factory must not pollute cache");
502    }
503
504    #[test]
505    fn test_cache_error_does_not_store_entry() {
506        let mut cache = PipelineCache::new();
507        let key = PipelineCacheKey::new("shader", "ep", "r-r-w");
508
509        let _: Result<Arc<wgpu::ComputePipeline>, &str> =
510            cache.get_or_insert_with(key.clone(), || Err("compile error"));
511
512        assert_eq!(cache.len(), 0);
513        assert!(cache.is_empty());
514    }
515
516    // ── SharedPipelineCache ───────────────────────────────────────────────────
517
518    #[test]
519    fn test_new_shared_pipeline_cache_is_empty() {
520        let shared = new_shared_pipeline_cache();
521        #[allow(clippy::unwrap_used)]
522        let guard = shared.lock().map_err(|_| "poisoned").unwrap();
523        assert!(guard.is_empty());
524    }
525
526    #[test]
527    fn test_shared_cache_is_arc_mutex() {
528        // Verify that the type can be cloned (Arc semantics) and that both
529        // clones observe the same underlying cache.
530        let cache = new_shared_pipeline_cache();
531        let cache2 = Arc::clone(&cache);
532
533        // The two Arcs point to the same allocation.
534        assert!(Arc::ptr_eq(&cache, &cache2));
535    }
536}