Skip to main content

edgefirst_image/gl/
cache.rs

1// SPDX-FileCopyrightText: Copyright 2025 Au-Zone Technologies
2// SPDX-License-Identifier: Apache-2.0
3
4use edgefirst_tensor::{PixelFormat, Tensor, TensorTrait};
5
6/// Selects which import cache to use.
7#[derive(Debug, PartialEq)]
8pub(super) enum CacheKind {
9    Src,
10    Dst,
11}
12
13/// A cached buffer import, retained until evicted by the LRU bound.
14///
15/// Generic over the platform's owned import object `I` — an `EglImage`
16/// (DMA-BUF) on Linux, an IOSurface-backed EGL pbuffer on macOS. Dropping
17/// the entry drops `I`, which releases the platform object.
18///
19/// **`import` is the anchor, and it is load-bearing for correctness, not
20/// only for speed.** The entry deliberately outlives the tensor it was built
21/// from: a cross-package `convert()` constructs a fresh tensor over the
22/// producer's buffer, uses it, and drops it within the call, so an entry tied
23/// to that tensor's lifetime would be created and destroyed every frame and
24/// never hit (measured: 0 hits / 50 misses, against 49/1 for a native
25/// source). Outliving the tensor is safe because the platform import holds
26/// its own driver-side reference to the buffer, so a cached entry keeps the
27/// underlying object alive even after every userspace handle is closed — the
28/// system key it is identified by (a dma-buf `(st_dev, st_ino)`, an
29/// `IOSurfaceID`) therefore cannot be recycled onto a *different* buffer
30/// while the entry lives, which is what makes a stale key impossible rather
31/// than merely unlikely. Verified on hardware, both directions: on Linux the
32/// buffer's inode is still listed in `/sys/kernel/debug/dma_buf/bufinfo`
33/// after the tensor drops (Vivante/imx8mp, Mali/imx95, V3D/rpi5) and
34/// disappears the moment the cache drops; on macOS the `IOSurfaceID` still
35/// resolves after the tensor drops and stops resolving once the cache drops.
36///
37/// The consequence for any future refactor: the cache must keep owning `I`.
38/// Storing only the raw handle (`Platform::import_handle`) would keep the
39/// key-matching behaviour and silently lose the anchor.
40///
41/// **The rule a cached key must satisfy, and where each platform enforces
42/// it.** A key is safe here only when the import this entry owns keeps the
43/// keyed object alive, because that is what stops the key being recycled onto
44/// a different buffer while the entry is still findable. Each
45/// `Platform::import_buffer*` implementation reaches its platform's object
46/// through an accessor no other backing answers — `dmabuf_fd` on Linux,
47/// `iosurface_ref` on macOS, the `AHardwareBuffer*` on Android, and
48/// `d3d11_texture` on Windows — so a `Mem` or PBO tensor cannot reach any of
49/// them and `IdentityKind::HostPtr` and `Pbo` are never cached.
50///
51/// Two cached kinds are raw pointers, recyclable in principle:
52/// `IdentityKind::D3d11Texture` (an `ID3D11Texture2D*`) and
53/// `AHardwareBufferPtr`. The anchor above is what rules a stale key out for
54/// them, since the EGLImage holds its own reference to the object, which
55/// therefore cannot be freed and its address reused while the entry lives.
56///
57/// Reaching the right accessor is *not* on its own proof that the identity
58/// names that object, and on Windows the two came apart: the dynamic tensor
59/// backend answered `d3d11_texture` correctly while identifying the tensor by
60/// its recyclable `ef_tensor` handle address, and converts rendered into
61/// dropped textures. So the Windows path checks rather than assumes, through
62/// [`GlPlatform::validate_import_identity`], which refuses in every build any
63/// texture whose identity is not `D3d11Texture` over that same pointer.
64///
65/// That check runs on **every** import, cache hits included, and that is why
66/// it is a separate hook rather than a line in `Platform::import_buffer*`:
67/// those run only on a miss, so a check made there could not see this cache
68/// serving an entry for a tensor whose identity had stopped matching its
69/// buffer — the one case that produces wrong pixels rather than none. The
70/// other three platforms take the default `Ok(())`: their identities are
71/// OS-level keys the kernel cannot recycle while the import holds the object.
72///
73/// [`GlPlatform::validate_import_identity`]: super::platform::GlPlatform::validate_import_identity
74pub(super) struct CachedImport<I> {
75    pub(super) import: I,
76    /// Optional GL renderbuffer backed by this import (used by direct RGB path).
77    pub(super) renderbuffer: Option<u32>,
78    /// Monotonic access counter for LRU eviction.
79    pub(super) last_used: u64,
80}
81
82/// Per-processor zero-copy telemetry: how convert sources reached the GPU
83/// and how often a zero-copy opportunity was declined into a copy path.
84///
85/// This is deliberately SEPARATE from [`GlCacheStats`]: that struct is the
86/// steady-state import-cache equality gate (tests assert exact equality on
87/// it), while these counters change on every convert. `src_uploads` staying
88/// at 0 across a Dma-source workload is the "no silent zero-copy drop"
89/// assertion callers (and the on-device validation harness) can make —
90/// EGLImage miss counts alone are blind to uploads.
91#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
92pub struct ConvertStats {
93    /// Sources fed zero-copy: the tensor's Dma buffer became the sampled
94    /// texture's storage (EGLImage attach / IOSurface pbuffer bind).
95    pub src_imports: u64,
96    /// Sources fed from a PBO (GL-internal copy, no CPU visit).
97    pub src_pbo_uploads: u64,
98    /// Sources fed by CPU map + TexImage upload — the copy path.
99    pub src_uploads: u64,
100    /// Times a zero-copy source feed was attempted and DECLINED into a
101    /// copy path (import/attach failure). A nonzero value with Dma
102    /// sources means the platform/driver refused the fast path.
103    pub zero_copy_declines: u64,
104    /// Times a zero-copy DESTINATION import was attempted and DECLINED into
105    /// the mapped-texture readback (render to a texture, read back through
106    /// `map()` at the destination's offset and pitch). The destination-side
107    /// twin of [`zero_copy_declines`](Self::zero_copy_declines): a nonzero
108    /// value with a Dma destination means the driver refused to render into
109    /// the buffer directly and the convert still produced correct pixels,
110    /// through a copy.
111    pub dst_import_fallbacks: u64,
112}
113
114/// Buffer-import cache owned by the GL processor.
115///
116/// Uses a HashMap with a monotonic counter for LRU eviction: each access
117/// updates the entry's `last_used` timestamp, and eviction removes the entry
118/// with the smallest `last_used` value. Recency is the *only* eviction
119/// signal — entries are retained past the lifetime of the tensor that
120/// produced them, which is what lets a re-imported buffer hit (see
121/// [`CachedImport`]), so `capacity` is the sole bound.
122/// Identity + geometry that uniquely determine an imported GPU buffer
123/// (an EGLImage over a DMA-BUF on Linux; an EGL pbuffer over an IOSurface
124/// on macOS — the key fields are platform-neutral).
125///
126/// `luma_id` / `chroma_id` are the buffer identities.
127///
128/// A `view()`/`batch()` sub-region is a `glViewport`/`glScissor` ROI into its
129/// parent, **not** a distinct import: [`from_tensor`](BufferImportKey::from_tensor)
130/// keys such a tensor on its **parent** geometry (`view_origin`) with
131/// `plane_offset = 0`, so every sibling view of one buffer collapses to a single
132/// EGLImage and the per-tile offset becomes render state (the viewport). The
133/// remaining `plane_offset` use is a non-view tensor that carries a genuine
134/// foreign/multi-plane byte offset (e.g. an externally-imported buffer whose data
135/// starts past the fd origin); those still key distinctly.
136///
137/// A source rebased onto an aligned DMA-BUF base (issue #170) is keyed by its
138/// OWN `plane_offset`, not by the base it imports at, which is what keeps two
139/// views sharing one base but differing in remainder from aliasing: their
140/// imports are widened by different amounts. Keying on the base would collapse
141/// them onto one import and sample the wrong region for one of the two.
142///
143/// `width` / `height` / `row_stride` / `format` capture the geometry the
144/// EGLImage was imported with — the **parent's** for a view. A pooled buffer
145/// reused at a different size via `Tensor::configure_image` (e.g. a 128-wide pool
146/// decoding a 96-wide image) keeps the same identities but needs a fresh import:
147/// the import's pitch/dimensions/fourcc all derive from these fields. Omitting
148/// them reuses the stale-geometry EGLImage and the GPU samples the buffer at the
149/// wrong pitch — deterministically wrong single-threaded, nondeterministic in
150/// parallel, correct only on a heap source (which never takes this path).
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
152pub(super) struct BufferImportKey {
153    pub(super) luma_id: u64,
154    pub(super) chroma_id: Option<u64>,
155    pub(super) plane_offset: usize,
156    pub(super) width: usize,
157    pub(super) height: usize,
158    pub(super) row_stride: usize,
159    pub(super) format: PixelFormat,
160}
161
162impl BufferImportKey {
163    /// Build the cache key from a tensor and the format it will be imported as.
164    /// Every construction site MUST go through this so the key used to insert
165    /// an EGLImage matches the key used to look it up and to gate the texture
166    /// binding-skip.
167    pub(super) fn from_tensor<T>(img: &Tensor<T>, format: PixelFormat, for_dst: bool) -> Self
168    where
169        T: num_traits::Num + Clone + std::fmt::Debug + Send + Sync + edgefirst_tensor::Element,
170    {
171        // A DESTINATION view()/batch() sub-region keys on its PARENT so all
172        // siblings of one buffer collapse to a single import; the view's offset is
173        // the viewport, not a key. It keys on the parent's `row_stride` (from
174        // `view_origin`), NOT the view's own `effective_row_stride` — a single-row
175        // view sets a tight stride for map-span safety, which would otherwise
176        // mis-key it apart from its multi-row siblings. A SOURCE view (or a whole
177        // tensor) keys on its OWN geometry + any genuine foreign/multi-plane
178        // plane_offset — a source view imports its own region (it is sampled, not
179        // rendered into), so it must NOT collapse onto the parent key.
180        let view_origin = if for_dst { img.view_origin() } else { None };
181        let (width, height, row_stride, plane_offset) = match view_origin {
182            Some(vo) => (vo.parent_width, vo.parent_height, vo.parent_row_stride, 0),
183            None => (
184                img.width().unwrap_or(0),
185                img.height().unwrap_or(0),
186                img.effective_row_stride().unwrap_or(0),
187                img.plane_offset().unwrap_or(0),
188            ),
189        };
190        Self {
191            luma_id: img.buffer_identity().id(),
192            chroma_id: img.chroma().map(|t| t.buffer_identity().id()),
193            plane_offset,
194            width,
195            height,
196            row_stride,
197            format,
198        }
199    }
200}
201
202/// Snapshot of one EGLImage cache's hit/miss counters.
203///
204/// The counters themselves have always existed (logged at `Drop`); this
205/// snapshot makes them **assertable**: steady-state tests capture stats after
206/// warmup and after an N-frame loop and require `misses` to stay flat — any
207/// increase means a convert re-imported a buffer it should have found cached,
208/// which is the cache-behavior equality gate for GL refactors.
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub struct CacheStats {
211    pub hits: u64,
212    pub misses: u64,
213    pub entries: usize,
214    /// High-water mark of `entries`.
215    ///
216    /// Since entries are retained past the lifetime of the tensor that
217    /// produced them (the retention that `CachedImport` documents), each one
218    /// pins its buffer, so
219    /// occupancy — not hit rate — is what says how much memory a workload
220    /// holds. Below `capacity` this is the workload's true working-set size,
221    /// and the number to size `EDGEFIRST_EGL_CACHE_CAPACITY` against.
222    ///
223    /// It **saturates at `capacity`**, so on its own it cannot tell a
224    /// workload that exactly fits from one that is thrashing — both report
225    /// `peak_entries == capacity`. Read it together with [`evictions`], which
226    /// is what distinguishes them.
227    ///
228    /// [`evictions`]: Self::evictions
229    pub peak_entries: usize,
230    /// Entries evicted to stay within `capacity`.
231    ///
232    /// The signal that `capacity` is too small for the workload: every
233    /// eviction discards an import that is likely to be asked for again, so
234    /// a steady-state pipeline should reach zero. Nonzero-and-climbing with
235    /// `peak_entries == capacity` is thrashing — raise
236    /// `EDGEFIRST_EGL_CACHE_CAPACITY`, at the cost of pinning more buffers.
237    pub evictions: u64,
238}
239
240/// Combined snapshot of every EGLImage cache on the GL processor
241/// (source, destination, and the Path-B NV R8 source cache).
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub struct GlCacheStats {
244    pub src: CacheStats,
245    pub dst: CacheStats,
246    pub nv_r8: CacheStats,
247}
248
249impl GlCacheStats {
250    /// Total imports performed (cache misses) across all caches — the number
251    /// steady-state loops assert stays flat.
252    pub fn total_misses(&self) -> u64 {
253        self.src.misses + self.dst.misses + self.nv_r8.misses
254    }
255
256    /// Peak entries summed across all caches — an UPPER bound on how many
257    /// buffers were pinned at once, not an exact count: `src` and `nv_r8`
258    /// routinely name the *same* source buffer (one NV12 tensor imported
259    /// two ways), and two entries over one buffer pin that buffer once.
260    /// Use the per-cache peaks to size capacity; use this to bound memory.
261    pub fn total_peak_entries(&self) -> usize {
262        self.src.peak_entries + self.dst.peak_entries + self.nv_r8.peak_entries
263    }
264
265    /// Evictions across all caches — zero in a correctly sized steady state.
266    pub fn total_evictions(&self) -> u64 {
267        self.src.evictions + self.dst.evictions + self.nv_r8.evictions
268    }
269}
270
271pub(super) struct ImportCache<I> {
272    pub(super) entries: std::collections::HashMap<BufferImportKey, CachedImport<I>>,
273    pub(super) capacity: usize,
274    pub(super) hits: u64,
275    pub(super) misses: u64,
276    /// Monotonic counter incremented on each access for LRU tracking.
277    pub(super) access_counter: u64,
278    /// High-water mark of `entries.len()`. See [`CacheStats::peak_entries`].
279    peak_entries: usize,
280    /// Count of entries evicted to stay within `capacity`. See
281    /// [`CacheStats::evictions`].
282    evictions: u64,
283}
284
285impl<I> ImportCache<I> {
286    pub(super) fn new(capacity: usize) -> Self {
287        Self {
288            entries: std::collections::HashMap::with_capacity(capacity),
289            capacity,
290            hits: 0,
291            misses: 0,
292            access_counter: 0,
293            peak_entries: 0,
294            evictions: 0,
295        }
296    }
297
298    /// Snapshot the hit/miss counters for steady-state assertions.
299    pub(super) fn stats(&self) -> CacheStats {
300        CacheStats {
301            hits: self.hits,
302            misses: self.misses,
303            entries: self.entries.len(),
304            peak_entries: self.peak_entries,
305            evictions: self.evictions,
306        }
307    }
308
309    /// Insert a freshly created import, evicting the least recently used
310    /// entry first if the cache is at capacity.
311    ///
312    /// Every insert site goes through here so that the capacity bound and the
313    /// high-water mark cannot drift apart from each other — a site that
314    /// inserted directly would silently raise the real peak above the number
315    /// [`stats`](Self::stats) reports, which is the number capacity is tuned
316    /// against.
317    ///
318    /// Eviction happens *after* the caller has successfully created the
319    /// import: a failed import must not cost the cache a live entry.
320    pub(super) fn insert(&mut self, key: BufferImportKey, import: I, renderbuffer: Option<u32>) {
321        if self.entries.len() >= self.capacity {
322            self.evict_lru();
323        }
324        let last_used = self.next_timestamp();
325        self.entries.insert(
326            key,
327            CachedImport {
328                import,
329                renderbuffer,
330                last_used,
331            },
332        );
333        self.peak_entries = self.peak_entries.max(self.entries.len());
334    }
335
336    /// Allocate a new LRU timestamp.
337    pub(super) fn next_timestamp(&mut self) -> u64 {
338        self.access_counter += 1;
339        self.access_counter
340    }
341
342    /// Evict the least recently used entry. Returns `true` if an entry was evicted.
343    pub(super) fn evict_lru(&mut self) -> bool {
344        if let Some((&evict_id, _)) = self.entries.iter().min_by_key(|(_, entry)| entry.last_used) {
345            let evicted = self.entries.remove(&evict_id).expect("key just found");
346            self.evictions += 1;
347            if let Some(rbo) = evicted.renderbuffer {
348                unsafe { edgefirst_gl::gl::DeleteRenderbuffers(1, &rbo) };
349            }
350            return true;
351        }
352        false
353    }
354}
355
356impl<I> Drop for ImportCache<I> {
357    fn drop(&mut self) {
358        for entry in self.entries.values() {
359            if let Some(rbo) = entry.renderbuffer {
360                unsafe { edgefirst_gl::gl::DeleteRenderbuffers(1, &rbo) };
361            }
362        }
363        // peak/evictions are the two numbers the capacity story rests on, and
364        // they were previously reachable only through the API -- so a field
365        // report of "GPU memory pressure" could not be diagnosed from a log.
366        // `peak < capacity` with zero evictions means correctly sized;
367        // evictions climbing means the producer's pool is deeper than the bound.
368        log::debug!(
369            "ImportCache stats: {} hits, {} misses, {} entries remaining, \
370             peak {}/{}, {} evictions",
371            self.hits,
372            self.misses,
373            self.entries.len(),
374            self.peak_entries,
375            self.capacity,
376            self.evictions
377        );
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::BufferImportKey;
384    use edgefirst_tensor::PixelFormat;
385    use std::collections::HashMap;
386
387    fn key(
388        luma_id: u64,
389        plane_offset: usize,
390        width: usize,
391        height: usize,
392        row_stride: usize,
393        format: PixelFormat,
394    ) -> BufferImportKey {
395        BufferImportKey {
396            luma_id,
397            chroma_id: None,
398            plane_offset,
399            width,
400            height,
401            row_stride,
402            format,
403        }
404    }
405
406    /// Two source views whose byte offsets share a 64-byte-aligned base but
407    /// differ in the remainder are DIFFERENT imports after issue #170 -- one
408    /// is widened by 8 texels and the other by 4 -- so their keys must not
409    /// collide. The key holds the tensor's own `plane_offset`, which is
410    /// finer than the base, so this holds by construction; it is asserted
411    /// because a future "key on the import base instead" would silently
412    /// alias two imports and sample one region for the other.
413    #[test]
414    fn source_keys_distinguish_offsets_that_share_an_aligned_base() {
415        let a = key(1, 2048, 16, 16, 256, PixelFormat::Rgba);
416        let b = key(1, 2064, 16, 16, 256, PixelFormat::Rgba);
417        let c = key(1, 2080, 16, 16, 256, PixelFormat::Rgba);
418        assert_ne!(a, b);
419        assert_ne!(b, c);
420        assert_ne!(a, c);
421        let mut map: HashMap<BufferImportKey, u32> = HashMap::new();
422        map.insert(a, 1);
423        map.insert(b, 2);
424        map.insert(c, 3);
425        assert_eq!(map.len(), 3, "three remainders, three imports");
426    }
427
428    /// The two numbers Task 7 (capacity sizing) reads, and the LRU order they
429    /// describe. Runs without a GL context: `evict_lru` only touches GL for an
430    /// entry that carries a renderbuffer, and these carry none.
431    #[test]
432    fn peak_and_evictions_report_capacity_pressure() {
433        use super::ImportCache;
434        let k = |id| key(id, 0, 64, 64, 64, PixelFormat::Grey);
435        let mut cache: ImportCache<u32> = ImportCache::new(2);
436
437        cache.insert(k(1), 10, None);
438        cache.insert(k(2), 20, None);
439        let full = cache.stats();
440        assert_eq!((full.entries, full.peak_entries), (2, 2));
441        assert_eq!(full.evictions, 0, "a cache within capacity must not evict");
442
443        // Third distinct buffer at capacity: the LRU entry (k(1)) goes.
444        cache.insert(k(3), 30, None);
445        let pressed = cache.stats();
446        assert_eq!(pressed.entries, 2, "capacity is enforced");
447        assert_eq!(
448            pressed.evictions, 1,
449            "an over-capacity insert must record an eviction — this is the \
450             signal that separates a workload that fits from one that thrashes, \
451             since peak_entries saturates at capacity in both cases"
452        );
453        assert_eq!(pressed.peak_entries, 2, "peak saturates at capacity");
454        assert!(!cache.entries.contains_key(&k(1)), "LRU entry was evicted");
455        assert!(cache.entries.contains_key(&k(3)), "new entry was inserted");
456    }
457
458    #[test]
459    fn cache_key_distinguishes_foreign_plane_offset() {
460        // `plane_offset` no longer distinguishes view()/batch() sub-regions —
461        // those key on their parent and collapse (see
462        // `cache_key_collapses_sibling_views`). It survives ONLY for a non-view
463        // tensor carrying a genuine foreign/multi-plane byte offset (e.g. an
464        // externally-imported buffer whose data starts past the fd origin); two
465        // such imports at different offsets must remain DISTINCT entries.
466        let mut map: HashMap<BufferImportKey, u32> = HashMap::new();
467        let base = key(0xABCD, 0, 64, 64, 64, PixelFormat::Grey);
468        let at_offset = key(0xABCD, 4096, 64, 64, 64, PixelFormat::Grey);
469        map.insert(base, 1);
470        map.insert(at_offset, 2);
471        assert_eq!(
472            map.len(),
473            2,
474            "offset-distinct foreign imports must not collide"
475        );
476        assert_eq!(map.get(&base), Some(&1));
477        assert_eq!(map.get(&at_offset), Some(&2));
478
479        // Identical keys still collide (a genuine cache hit), as before.
480        map.insert(base, 3);
481        assert_eq!(map.len(), 2);
482        assert_eq!(map.get(&base), Some(&3));
483    }
484
485    #[test]
486    fn cache_key_collapses_sibling_views() {
487        // The batch-engine pivot: a view()/batch() sub-region is a
488        // glViewport/scissor ROI into its parent, so sibling views of one buffer
489        // MUST produce the SAME cache key (one shared EGLImage import) and key
490        // identically to the whole parent. The per-tile offset is render state,
491        // never part of the key.
492        use edgefirst_tensor::{Region, Tensor, TensorMemory};
493        let parent = Tensor::<u8>::image(
494            64,
495            64,
496            PixelFormat::Rgba,
497            Some(TensorMemory::Mem),
498            edgefirst_tensor::CpuAccess::ReadWrite,
499        )
500        .unwrap();
501        let a = parent.view(Region::new(0, 0, 32, 32)).unwrap();
502        let b = parent.view(Region::new(0, 32, 32, 32)).unwrap();
503        // Destinations (`for_dst = true`) collapse onto the parent key.
504        let ka = BufferImportKey::from_tensor(&a, PixelFormat::Rgba, true);
505        let kb = BufferImportKey::from_tensor(&b, PixelFormat::Rgba, true);
506        let kp = BufferImportKey::from_tensor(&parent, PixelFormat::Rgba, true);
507        assert_eq!(
508            ka, kb,
509            "sibling dst views collapse to one parent-keyed import"
510        );
511        assert_eq!(ka, kp, "a dst view keys identically to its whole parent");
512        assert_eq!(
513            ka.plane_offset, 0,
514            "a dst view contributes no offset to the key"
515        );
516        assert_eq!((ka.width, ka.height), (64, 64), "keyed on parent geometry");
517
518        // SOURCES (`for_dst = false`) key on their OWN region — a source view is
519        // imported and SAMPLED, not rendered into, so two source views of one
520        // parent must NOT collapse (they'd alias and sample the wrong region).
521        let sa = BufferImportKey::from_tensor(&a, PixelFormat::Rgba, false);
522        let sb = BufferImportKey::from_tensor(&b, PixelFormat::Rgba, false);
523        assert_ne!(sa, sb, "source views key on their own region (no collapse)");
524        assert_eq!(
525            (sa.width, sa.height),
526            (32, 32),
527            "a source view keys on its own dimensions"
528        );
529    }
530
531    #[test]
532    fn cache_key_distinguishes_geometry() {
533        // Root-cause regression guard for the pool-recycle bug: ONE buffer
534        // (same luma_id + offset) reconfigured to different geometry via
535        // `configure_image` must produce DISTINCT keys — otherwise the EGLImage
536        // imported for the first geometry is reused at the wrong pitch.
537        let mut map: HashMap<BufferImportKey, u32> = HashMap::new();
538        let g0 = key(0xBEEF, 0, 128, 96, 128, PixelFormat::Grey);
539        let g1 = key(0xBEEF, 0, 96, 128, 96, PixelFormat::Grey); // different w/h/stride
540        let g2 = key(0xBEEF, 0, 128, 96, 128, PixelFormat::Nv12); // different format
541        map.insert(g0, 1);
542        map.insert(g1, 2);
543        map.insert(g2, 3);
544        assert_eq!(
545            map.len(),
546            3,
547            "geometry/format-distinct reuses must not collide"
548        );
549
550        // Same identity + same geometry is still a genuine hit.
551        let g0_again = key(0xBEEF, 0, 128, 96, 128, PixelFormat::Grey);
552        map.insert(g0_again, 4);
553        assert_eq!(map.len(), 3);
554        assert_eq!(map.get(&g0), Some(&4));
555    }
556
557    #[test]
558    fn cache_key_distinguishes_stride() {
559        // A stride-only change (same identity/offset/w/h/format, different
560        // row_stride — e.g. a padded vs tight pool buffer) must be a DISTINCT
561        // key. Guards against dropping `row_stride` from the key, which would
562        // reintroduce the wrong-pitch stale read on a re-padded pool.
563        let mut map: HashMap<BufferImportKey, u32> = HashMap::new();
564        let tight = key(0xBEEF, 0, 128, 96, 128, PixelFormat::Grey);
565        let padded = key(0xBEEF, 0, 128, 96, 256, PixelFormat::Grey); // stride differs
566        map.insert(tight, 1);
567        map.insert(padded, 2);
568        assert_eq!(map.len(), 2, "stride-distinct imports must not collide");
569        assert_eq!(map.get(&tight), Some(&1));
570        assert_eq!(map.get(&padded), Some(&2));
571    }
572}