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}