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, the Path-B NV R8 source cache, and its chroma-plane
242/// view for hardware-filtered NV resizes).
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244pub struct GlCacheStats {
245 pub src: CacheStats,
246 pub dst: CacheStats,
247 pub nv_r8: CacheStats,
248 pub nv_uv: CacheStats,
249}
250
251impl GlCacheStats {
252 /// Total imports performed (cache misses) across all caches — the number
253 /// steady-state loops assert stays flat.
254 pub fn total_misses(&self) -> u64 {
255 self.src.misses + self.dst.misses + self.nv_r8.misses + self.nv_uv.misses
256 }
257
258 /// Peak entries summed across all caches — an UPPER bound on how many
259 /// buffers were pinned at once, not an exact count: `src`, `nv_r8` and
260 /// `nv_uv` routinely name the *same* source buffer (one NV12 tensor
261 /// imported several ways), and entries over one buffer pin it once.
262 /// Use the per-cache peaks to size capacity; use this to bound memory.
263 pub fn total_peak_entries(&self) -> usize {
264 self.src.peak_entries
265 + self.dst.peak_entries
266 + self.nv_r8.peak_entries
267 + self.nv_uv.peak_entries
268 }
269
270 /// Evictions across all caches — zero in a correctly sized steady state.
271 pub fn total_evictions(&self) -> u64 {
272 self.src.evictions + self.dst.evictions + self.nv_r8.evictions + self.nv_uv.evictions
273 }
274}
275
276pub(super) struct ImportCache<I> {
277 pub(super) entries: std::collections::HashMap<BufferImportKey, CachedImport<I>>,
278 pub(super) capacity: usize,
279 pub(super) hits: u64,
280 pub(super) misses: u64,
281 /// Monotonic counter incremented on each access for LRU tracking.
282 pub(super) access_counter: u64,
283 /// High-water mark of `entries.len()`. See [`CacheStats::peak_entries`].
284 peak_entries: usize,
285 /// Count of entries evicted to stay within `capacity`. See
286 /// [`CacheStats::evictions`].
287 evictions: u64,
288}
289
290impl<I> ImportCache<I> {
291 pub(super) fn new(capacity: usize) -> Self {
292 Self {
293 entries: std::collections::HashMap::with_capacity(capacity),
294 capacity,
295 hits: 0,
296 misses: 0,
297 access_counter: 0,
298 peak_entries: 0,
299 evictions: 0,
300 }
301 }
302
303 /// Snapshot the hit/miss counters for steady-state assertions.
304 pub(super) fn stats(&self) -> CacheStats {
305 CacheStats {
306 hits: self.hits,
307 misses: self.misses,
308 entries: self.entries.len(),
309 peak_entries: self.peak_entries,
310 evictions: self.evictions,
311 }
312 }
313
314 /// Insert a freshly created import, evicting the least recently used
315 /// entry first if the cache is at capacity.
316 ///
317 /// Every insert site goes through here so that the capacity bound and the
318 /// high-water mark cannot drift apart from each other — a site that
319 /// inserted directly would silently raise the real peak above the number
320 /// [`stats`](Self::stats) reports, which is the number capacity is tuned
321 /// against.
322 ///
323 /// Eviction happens *after* the caller has successfully created the
324 /// import: a failed import must not cost the cache a live entry.
325 pub(super) fn insert(&mut self, key: BufferImportKey, import: I, renderbuffer: Option<u32>) {
326 if self.entries.len() >= self.capacity {
327 self.evict_lru();
328 }
329 let last_used = self.next_timestamp();
330 self.entries.insert(
331 key,
332 CachedImport {
333 import,
334 renderbuffer,
335 last_used,
336 },
337 );
338 self.peak_entries = self.peak_entries.max(self.entries.len());
339 }
340
341 /// Allocate a new LRU timestamp.
342 pub(super) fn next_timestamp(&mut self) -> u64 {
343 self.access_counter += 1;
344 self.access_counter
345 }
346
347 /// Evict the least recently used entry. Returns `true` if an entry was evicted.
348 pub(super) fn evict_lru(&mut self) -> bool {
349 if let Some((&evict_id, _)) = self.entries.iter().min_by_key(|(_, entry)| entry.last_used) {
350 let evicted = self.entries.remove(&evict_id).expect("key just found");
351 self.evictions += 1;
352 if let Some(rbo) = evicted.renderbuffer {
353 unsafe { edgefirst_gl::gl::DeleteRenderbuffers(1, &rbo) };
354 }
355 return true;
356 }
357 false
358 }
359}
360
361impl<I> Drop for ImportCache<I> {
362 fn drop(&mut self) {
363 for entry in self.entries.values() {
364 if let Some(rbo) = entry.renderbuffer {
365 unsafe { edgefirst_gl::gl::DeleteRenderbuffers(1, &rbo) };
366 }
367 }
368 // peak/evictions are the two numbers the capacity story rests on, and
369 // they were previously reachable only through the API -- so a field
370 // report of "GPU memory pressure" could not be diagnosed from a log.
371 // `peak < capacity` with zero evictions means correctly sized;
372 // evictions climbing means the producer's pool is deeper than the bound.
373 log::debug!(
374 "ImportCache stats: {} hits, {} misses, {} entries remaining, \
375 peak {}/{}, {} evictions",
376 self.hits,
377 self.misses,
378 self.entries.len(),
379 self.peak_entries,
380 self.capacity,
381 self.evictions
382 );
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::BufferImportKey;
389 use edgefirst_tensor::PixelFormat;
390 use std::collections::HashMap;
391
392 fn key(
393 luma_id: u64,
394 plane_offset: usize,
395 width: usize,
396 height: usize,
397 row_stride: usize,
398 format: PixelFormat,
399 ) -> BufferImportKey {
400 BufferImportKey {
401 luma_id,
402 chroma_id: None,
403 plane_offset,
404 width,
405 height,
406 row_stride,
407 format,
408 }
409 }
410
411 /// Two source views whose byte offsets share a 64-byte-aligned base but
412 /// differ in the remainder are DIFFERENT imports after issue #170 -- one
413 /// is widened by 8 texels and the other by 4 -- so their keys must not
414 /// collide. The key holds the tensor's own `plane_offset`, which is
415 /// finer than the base, so this holds by construction; it is asserted
416 /// because a future "key on the import base instead" would silently
417 /// alias two imports and sample one region for the other.
418 #[test]
419 fn source_keys_distinguish_offsets_that_share_an_aligned_base() {
420 let a = key(1, 2048, 16, 16, 256, PixelFormat::Rgba);
421 let b = key(1, 2064, 16, 16, 256, PixelFormat::Rgba);
422 let c = key(1, 2080, 16, 16, 256, PixelFormat::Rgba);
423 assert_ne!(a, b);
424 assert_ne!(b, c);
425 assert_ne!(a, c);
426 let mut map: HashMap<BufferImportKey, u32> = HashMap::new();
427 map.insert(a, 1);
428 map.insert(b, 2);
429 map.insert(c, 3);
430 assert_eq!(map.len(), 3, "three remainders, three imports");
431 }
432
433 /// The two numbers Task 7 (capacity sizing) reads, and the LRU order they
434 /// describe. Runs without a GL context: `evict_lru` only touches GL for an
435 /// entry that carries a renderbuffer, and these carry none.
436 #[test]
437 fn peak_and_evictions_report_capacity_pressure() {
438 use super::ImportCache;
439 let k = |id| key(id, 0, 64, 64, 64, PixelFormat::Grey);
440 let mut cache: ImportCache<u32> = ImportCache::new(2);
441
442 cache.insert(k(1), 10, None);
443 cache.insert(k(2), 20, None);
444 let full = cache.stats();
445 assert_eq!((full.entries, full.peak_entries), (2, 2));
446 assert_eq!(full.evictions, 0, "a cache within capacity must not evict");
447
448 // Third distinct buffer at capacity: the LRU entry (k(1)) goes.
449 cache.insert(k(3), 30, None);
450 let pressed = cache.stats();
451 assert_eq!(pressed.entries, 2, "capacity is enforced");
452 assert_eq!(
453 pressed.evictions, 1,
454 "an over-capacity insert must record an eviction — this is the \
455 signal that separates a workload that fits from one that thrashes, \
456 since peak_entries saturates at capacity in both cases"
457 );
458 assert_eq!(pressed.peak_entries, 2, "peak saturates at capacity");
459 assert!(!cache.entries.contains_key(&k(1)), "LRU entry was evicted");
460 assert!(cache.entries.contains_key(&k(3)), "new entry was inserted");
461 }
462
463 #[test]
464 fn cache_key_distinguishes_foreign_plane_offset() {
465 // `plane_offset` no longer distinguishes view()/batch() sub-regions —
466 // those key on their parent and collapse (see
467 // `cache_key_collapses_sibling_views`). It survives ONLY for a non-view
468 // tensor carrying a genuine foreign/multi-plane byte offset (e.g. an
469 // externally-imported buffer whose data starts past the fd origin); two
470 // such imports at different offsets must remain DISTINCT entries.
471 let mut map: HashMap<BufferImportKey, u32> = HashMap::new();
472 let base = key(0xABCD, 0, 64, 64, 64, PixelFormat::Grey);
473 let at_offset = key(0xABCD, 4096, 64, 64, 64, PixelFormat::Grey);
474 map.insert(base, 1);
475 map.insert(at_offset, 2);
476 assert_eq!(
477 map.len(),
478 2,
479 "offset-distinct foreign imports must not collide"
480 );
481 assert_eq!(map.get(&base), Some(&1));
482 assert_eq!(map.get(&at_offset), Some(&2));
483
484 // Identical keys still collide (a genuine cache hit), as before.
485 map.insert(base, 3);
486 assert_eq!(map.len(), 2);
487 assert_eq!(map.get(&base), Some(&3));
488 }
489
490 #[test]
491 fn cache_key_collapses_sibling_views() {
492 // The batch-engine pivot: a view()/batch() sub-region is a
493 // glViewport/scissor ROI into its parent, so sibling views of one buffer
494 // MUST produce the SAME cache key (one shared EGLImage import) and key
495 // identically to the whole parent. The per-tile offset is render state,
496 // never part of the key.
497 use edgefirst_tensor::{Region, Tensor, TensorMemory};
498 let parent = Tensor::<u8>::image(
499 64,
500 64,
501 PixelFormat::Rgba,
502 Some(TensorMemory::Mem),
503 edgefirst_tensor::CpuAccess::ReadWrite,
504 )
505 .unwrap();
506 let a = parent.view(Region::new(0, 0, 32, 32)).unwrap();
507 let b = parent.view(Region::new(0, 32, 32, 32)).unwrap();
508 // Destinations (`for_dst = true`) collapse onto the parent key.
509 let ka = BufferImportKey::from_tensor(&a, PixelFormat::Rgba, true);
510 let kb = BufferImportKey::from_tensor(&b, PixelFormat::Rgba, true);
511 let kp = BufferImportKey::from_tensor(&parent, PixelFormat::Rgba, true);
512 assert_eq!(
513 ka, kb,
514 "sibling dst views collapse to one parent-keyed import"
515 );
516 assert_eq!(ka, kp, "a dst view keys identically to its whole parent");
517 assert_eq!(
518 ka.plane_offset, 0,
519 "a dst view contributes no offset to the key"
520 );
521 assert_eq!((ka.width, ka.height), (64, 64), "keyed on parent geometry");
522
523 // SOURCES (`for_dst = false`) key on their OWN region — a source view is
524 // imported and SAMPLED, not rendered into, so two source views of one
525 // parent must NOT collapse (they'd alias and sample the wrong region).
526 let sa = BufferImportKey::from_tensor(&a, PixelFormat::Rgba, false);
527 let sb = BufferImportKey::from_tensor(&b, PixelFormat::Rgba, false);
528 assert_ne!(sa, sb, "source views key on their own region (no collapse)");
529 assert_eq!(
530 (sa.width, sa.height),
531 (32, 32),
532 "a source view keys on its own dimensions"
533 );
534 }
535
536 #[test]
537 fn cache_key_distinguishes_geometry() {
538 // Root-cause regression guard for the pool-recycle bug: ONE buffer
539 // (same luma_id + offset) reconfigured to different geometry via
540 // `configure_image` must produce DISTINCT keys — otherwise the EGLImage
541 // imported for the first geometry is reused at the wrong pitch.
542 let mut map: HashMap<BufferImportKey, u32> = HashMap::new();
543 let g0 = key(0xBEEF, 0, 128, 96, 128, PixelFormat::Grey);
544 let g1 = key(0xBEEF, 0, 96, 128, 96, PixelFormat::Grey); // different w/h/stride
545 let g2 = key(0xBEEF, 0, 128, 96, 128, PixelFormat::Nv12); // different format
546 map.insert(g0, 1);
547 map.insert(g1, 2);
548 map.insert(g2, 3);
549 assert_eq!(
550 map.len(),
551 3,
552 "geometry/format-distinct reuses must not collide"
553 );
554
555 // Same identity + same geometry is still a genuine hit.
556 let g0_again = key(0xBEEF, 0, 128, 96, 128, PixelFormat::Grey);
557 map.insert(g0_again, 4);
558 assert_eq!(map.len(), 3);
559 assert_eq!(map.get(&g0), Some(&4));
560 }
561
562 #[test]
563 fn cache_key_distinguishes_stride() {
564 // A stride-only change (same identity/offset/w/h/format, different
565 // row_stride — e.g. a padded vs tight pool buffer) must be a DISTINCT
566 // key. Guards against dropping `row_stride` from the key, which would
567 // reintroduce the wrong-pitch stale read on a re-padded pool.
568 let mut map: HashMap<BufferImportKey, u32> = HashMap::new();
569 let tight = key(0xBEEF, 0, 128, 96, 128, PixelFormat::Grey);
570 let padded = key(0xBEEF, 0, 128, 96, 256, PixelFormat::Grey); // stride differs
571 map.insert(tight, 1);
572 map.insert(padded, 2);
573 assert_eq!(map.len(), 2, "stride-distinct imports must not collide");
574 assert_eq!(map.get(&tight), Some(&1));
575 assert_eq!(map.get(&padded), Some(&2));
576 }
577}