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/// Only DMA-BUF/IOSurface-backed tensors ever reach here — both
42/// `Platform::import_buffer*` implementations reject anything else — so the
43/// process-local identity kinds that genuinely could be recycled
44/// (`IdentityKind::HostPtr`, `Pbo`) are never cached.
45pub(super) struct CachedImport<I> {
46    pub(super) import: I,
47    /// Optional GL renderbuffer backed by this import (used by direct RGB path).
48    pub(super) renderbuffer: Option<u32>,
49    /// Monotonic access counter for LRU eviction.
50    pub(super) last_used: u64,
51}
52
53/// Per-processor zero-copy telemetry: how convert sources reached the GPU
54/// and how often a zero-copy opportunity was declined into a copy path.
55///
56/// This is deliberately SEPARATE from [`GlCacheStats`]: that struct is the
57/// steady-state import-cache equality gate (tests assert exact equality on
58/// it), while these counters change on every convert. `src_uploads` staying
59/// at 0 across a Dma-source workload is the "no silent zero-copy drop"
60/// assertion callers (and the on-device validation harness) can make —
61/// EGLImage miss counts alone are blind to uploads.
62#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
63pub struct ConvertStats {
64    /// Sources fed zero-copy: the tensor's Dma buffer became the sampled
65    /// texture's storage (EGLImage attach / IOSurface pbuffer bind).
66    pub src_imports: u64,
67    /// Sources fed from a PBO (GL-internal copy, no CPU visit).
68    pub src_pbo_uploads: u64,
69    /// Sources fed by CPU map + TexImage upload — the copy path.
70    pub src_uploads: u64,
71    /// Times a zero-copy source feed was attempted and DECLINED into a
72    /// copy path (import/attach failure). A nonzero value with Dma
73    /// sources means the platform/driver refused the fast path.
74    pub zero_copy_declines: u64,
75}
76
77/// Buffer-import cache owned by the GL processor.
78///
79/// Uses a HashMap with a monotonic counter for LRU eviction: each access
80/// updates the entry's `last_used` timestamp, and eviction removes the entry
81/// with the smallest `last_used` value. Recency is the *only* eviction
82/// signal — entries are retained past the lifetime of the tensor that
83/// produced them, which is what lets a re-imported buffer hit (see
84/// [`CachedImport`]), so `capacity` is the sole bound.
85/// Identity + geometry that uniquely determine an imported GPU buffer
86/// (an EGLImage over a DMA-BUF on Linux; an EGL pbuffer over an IOSurface
87/// on macOS — the key fields are platform-neutral).
88///
89/// `luma_id` / `chroma_id` are the buffer identities.
90///
91/// A `view()`/`batch()` sub-region is a `glViewport`/`glScissor` ROI into its
92/// parent, **not** a distinct import: [`from_tensor`](BufferImportKey::from_tensor)
93/// keys such a tensor on its **parent** geometry (`view_origin`) with
94/// `plane_offset = 0`, so every sibling view of one buffer collapses to a single
95/// EGLImage and the per-tile offset becomes render state (the viewport). The
96/// remaining `plane_offset` use is a non-view tensor that carries a genuine
97/// foreign/multi-plane byte offset (e.g. an externally-imported buffer whose data
98/// starts past the fd origin); those still key distinctly.
99///
100/// `width` / `height` / `row_stride` / `format` capture the geometry the
101/// EGLImage was imported with — the **parent's** for a view. A pooled buffer
102/// reused at a different size via `Tensor::configure_image` (e.g. a 128-wide pool
103/// decoding a 96-wide image) keeps the same identities but needs a fresh import:
104/// the import's pitch/dimensions/fourcc all derive from these fields. Omitting
105/// them reuses the stale-geometry EGLImage and the GPU samples the buffer at the
106/// wrong pitch — deterministically wrong single-threaded, nondeterministic in
107/// parallel, correct only on a heap source (which never takes this path).
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
109pub(super) struct BufferImportKey {
110    pub(super) luma_id: u64,
111    pub(super) chroma_id: Option<u64>,
112    pub(super) plane_offset: usize,
113    pub(super) width: usize,
114    pub(super) height: usize,
115    pub(super) row_stride: usize,
116    pub(super) format: PixelFormat,
117}
118
119impl BufferImportKey {
120    /// Build the cache key from a tensor and the format it will be imported as.
121    /// Every construction site MUST go through this so the key used to insert
122    /// an EGLImage matches the key used to look it up and to gate the texture
123    /// binding-skip.
124    pub(super) fn from_tensor<T>(img: &Tensor<T>, format: PixelFormat, for_dst: bool) -> Self
125    where
126        T: num_traits::Num + Clone + std::fmt::Debug + Send + Sync + edgefirst_tensor::Element,
127    {
128        // A DESTINATION view()/batch() sub-region keys on its PARENT so all
129        // siblings of one buffer collapse to a single import; the view's offset is
130        // the viewport, not a key. It keys on the parent's `row_stride` (from
131        // `view_origin`), NOT the view's own `effective_row_stride` — a single-row
132        // view sets a tight stride for map-span safety, which would otherwise
133        // mis-key it apart from its multi-row siblings. A SOURCE view (or a whole
134        // tensor) keys on its OWN geometry + any genuine foreign/multi-plane
135        // plane_offset — a source view imports its own region (it is sampled, not
136        // rendered into), so it must NOT collapse onto the parent key.
137        let view_origin = if for_dst { img.view_origin() } else { None };
138        let (width, height, row_stride, plane_offset) = match view_origin {
139            Some(vo) => (vo.parent_width, vo.parent_height, vo.parent_row_stride, 0),
140            None => (
141                img.width().unwrap_or(0),
142                img.height().unwrap_or(0),
143                img.effective_row_stride().unwrap_or(0),
144                img.plane_offset().unwrap_or(0),
145            ),
146        };
147        Self {
148            luma_id: img.buffer_identity().id(),
149            chroma_id: img.chroma().map(|t| t.buffer_identity().id()),
150            plane_offset,
151            width,
152            height,
153            row_stride,
154            format,
155        }
156    }
157}
158
159/// Snapshot of one EGLImage cache's hit/miss counters.
160///
161/// The counters themselves have always existed (logged at `Drop`); this
162/// snapshot makes them **assertable**: steady-state tests capture stats after
163/// warmup and after an N-frame loop and require `misses` to stay flat — any
164/// increase means a convert re-imported a buffer it should have found cached,
165/// which is the cache-behavior equality gate for GL refactors.
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub struct CacheStats {
168    pub hits: u64,
169    pub misses: u64,
170    pub entries: usize,
171    /// High-water mark of `entries`.
172    ///
173    /// Since entries are retained past the lifetime of the tensor that
174    /// produced them (the retention that `CachedImport` documents), each one
175    /// pins its buffer, so
176    /// occupancy — not hit rate — is what says how much memory a workload
177    /// holds. Below `capacity` this is the workload's true working-set size,
178    /// and the number to size `EDGEFIRST_EGL_CACHE_CAPACITY` against.
179    ///
180    /// It **saturates at `capacity`**, so on its own it cannot tell a
181    /// workload that exactly fits from one that is thrashing — both report
182    /// `peak_entries == capacity`. Read it together with [`evictions`], which
183    /// is what distinguishes them.
184    ///
185    /// [`evictions`]: Self::evictions
186    pub peak_entries: usize,
187    /// Entries evicted to stay within `capacity`.
188    ///
189    /// The signal that `capacity` is too small for the workload: every
190    /// eviction discards an import that is likely to be asked for again, so
191    /// a steady-state pipeline should reach zero. Nonzero-and-climbing with
192    /// `peak_entries == capacity` is thrashing — raise
193    /// `EDGEFIRST_EGL_CACHE_CAPACITY`, at the cost of pinning more buffers.
194    pub evictions: u64,
195}
196
197/// Combined snapshot of every EGLImage cache on the GL processor
198/// (source, destination, and the Path-B NV R8 source cache).
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub struct GlCacheStats {
201    pub src: CacheStats,
202    pub dst: CacheStats,
203    pub nv_r8: CacheStats,
204}
205
206impl GlCacheStats {
207    /// Total imports performed (cache misses) across all caches — the number
208    /// steady-state loops assert stays flat.
209    pub fn total_misses(&self) -> u64 {
210        self.src.misses + self.dst.misses + self.nv_r8.misses
211    }
212
213    /// Peak entries summed across all caches — an UPPER bound on how many
214    /// buffers were pinned at once, not an exact count: `src` and `nv_r8`
215    /// routinely name the *same* source buffer (one NV12 tensor imported
216    /// two ways), and two entries over one buffer pin that buffer once.
217    /// Use the per-cache peaks to size capacity; use this to bound memory.
218    pub fn total_peak_entries(&self) -> usize {
219        self.src.peak_entries + self.dst.peak_entries + self.nv_r8.peak_entries
220    }
221
222    /// Evictions across all caches — zero in a correctly sized steady state.
223    pub fn total_evictions(&self) -> u64 {
224        self.src.evictions + self.dst.evictions + self.nv_r8.evictions
225    }
226}
227
228pub(super) struct ImportCache<I> {
229    pub(super) entries: std::collections::HashMap<BufferImportKey, CachedImport<I>>,
230    pub(super) capacity: usize,
231    pub(super) hits: u64,
232    pub(super) misses: u64,
233    /// Monotonic counter incremented on each access for LRU tracking.
234    pub(super) access_counter: u64,
235    /// High-water mark of `entries.len()`. See [`CacheStats::peak_entries`].
236    peak_entries: usize,
237    /// Count of entries evicted to stay within `capacity`. See
238    /// [`CacheStats::evictions`].
239    evictions: u64,
240}
241
242impl<I> ImportCache<I> {
243    pub(super) fn new(capacity: usize) -> Self {
244        Self {
245            entries: std::collections::HashMap::with_capacity(capacity),
246            capacity,
247            hits: 0,
248            misses: 0,
249            access_counter: 0,
250            peak_entries: 0,
251            evictions: 0,
252        }
253    }
254
255    /// Snapshot the hit/miss counters for steady-state assertions.
256    pub(super) fn stats(&self) -> CacheStats {
257        CacheStats {
258            hits: self.hits,
259            misses: self.misses,
260            entries: self.entries.len(),
261            peak_entries: self.peak_entries,
262            evictions: self.evictions,
263        }
264    }
265
266    /// Insert a freshly created import, evicting the least recently used
267    /// entry first if the cache is at capacity.
268    ///
269    /// Every insert site goes through here so that the capacity bound and the
270    /// high-water mark cannot drift apart from each other — a site that
271    /// inserted directly would silently raise the real peak above the number
272    /// [`stats`](Self::stats) reports, which is the number capacity is tuned
273    /// against.
274    ///
275    /// Eviction happens *after* the caller has successfully created the
276    /// import: a failed import must not cost the cache a live entry.
277    pub(super) fn insert(&mut self, key: BufferImportKey, import: I, renderbuffer: Option<u32>) {
278        if self.entries.len() >= self.capacity {
279            self.evict_lru();
280        }
281        let last_used = self.next_timestamp();
282        self.entries.insert(
283            key,
284            CachedImport {
285                import,
286                renderbuffer,
287                last_used,
288            },
289        );
290        self.peak_entries = self.peak_entries.max(self.entries.len());
291    }
292
293    /// Allocate a new LRU timestamp.
294    pub(super) fn next_timestamp(&mut self) -> u64 {
295        self.access_counter += 1;
296        self.access_counter
297    }
298
299    /// Evict the least recently used entry. Returns `true` if an entry was evicted.
300    pub(super) fn evict_lru(&mut self) -> bool {
301        if let Some((&evict_id, _)) = self.entries.iter().min_by_key(|(_, entry)| entry.last_used) {
302            let evicted = self.entries.remove(&evict_id).expect("key just found");
303            self.evictions += 1;
304            if let Some(rbo) = evicted.renderbuffer {
305                unsafe { edgefirst_gl::gl::DeleteRenderbuffers(1, &rbo) };
306            }
307            return true;
308        }
309        false
310    }
311}
312
313impl<I> Drop for ImportCache<I> {
314    fn drop(&mut self) {
315        for entry in self.entries.values() {
316            if let Some(rbo) = entry.renderbuffer {
317                unsafe { edgefirst_gl::gl::DeleteRenderbuffers(1, &rbo) };
318            }
319        }
320        // peak/evictions are the two numbers the capacity story rests on, and
321        // they were previously reachable only through the API -- so a field
322        // report of "GPU memory pressure" could not be diagnosed from a log.
323        // `peak < capacity` with zero evictions means correctly sized;
324        // evictions climbing means the producer's pool is deeper than the bound.
325        log::debug!(
326            "ImportCache stats: {} hits, {} misses, {} entries remaining, \
327             peak {}/{}, {} evictions",
328            self.hits,
329            self.misses,
330            self.entries.len(),
331            self.peak_entries,
332            self.capacity,
333            self.evictions
334        );
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::BufferImportKey;
341    use edgefirst_tensor::PixelFormat;
342    use std::collections::HashMap;
343
344    fn key(
345        luma_id: u64,
346        plane_offset: usize,
347        width: usize,
348        height: usize,
349        row_stride: usize,
350        format: PixelFormat,
351    ) -> BufferImportKey {
352        BufferImportKey {
353            luma_id,
354            chroma_id: None,
355            plane_offset,
356            width,
357            height,
358            row_stride,
359            format,
360        }
361    }
362
363    /// The two numbers Task 7 (capacity sizing) reads, and the LRU order they
364    /// describe. Runs without a GL context: `evict_lru` only touches GL for an
365    /// entry that carries a renderbuffer, and these carry none.
366    #[test]
367    fn peak_and_evictions_report_capacity_pressure() {
368        use super::ImportCache;
369        let k = |id| key(id, 0, 64, 64, 64, PixelFormat::Grey);
370        let mut cache: ImportCache<u32> = ImportCache::new(2);
371
372        cache.insert(k(1), 10, None);
373        cache.insert(k(2), 20, None);
374        let full = cache.stats();
375        assert_eq!((full.entries, full.peak_entries), (2, 2));
376        assert_eq!(full.evictions, 0, "a cache within capacity must not evict");
377
378        // Third distinct buffer at capacity: the LRU entry (k(1)) goes.
379        cache.insert(k(3), 30, None);
380        let pressed = cache.stats();
381        assert_eq!(pressed.entries, 2, "capacity is enforced");
382        assert_eq!(
383            pressed.evictions, 1,
384            "an over-capacity insert must record an eviction — this is the \
385             signal that separates a workload that fits from one that thrashes, \
386             since peak_entries saturates at capacity in both cases"
387        );
388        assert_eq!(pressed.peak_entries, 2, "peak saturates at capacity");
389        assert!(!cache.entries.contains_key(&k(1)), "LRU entry was evicted");
390        assert!(cache.entries.contains_key(&k(3)), "new entry was inserted");
391    }
392
393    #[test]
394    fn cache_key_distinguishes_foreign_plane_offset() {
395        // `plane_offset` no longer distinguishes view()/batch() sub-regions —
396        // those key on their parent and collapse (see
397        // `cache_key_collapses_sibling_views`). It survives ONLY for a non-view
398        // tensor carrying a genuine foreign/multi-plane byte offset (e.g. an
399        // externally-imported buffer whose data starts past the fd origin); two
400        // such imports at different offsets must remain DISTINCT entries.
401        let mut map: HashMap<BufferImportKey, u32> = HashMap::new();
402        let base = key(0xABCD, 0, 64, 64, 64, PixelFormat::Grey);
403        let at_offset = key(0xABCD, 4096, 64, 64, 64, PixelFormat::Grey);
404        map.insert(base, 1);
405        map.insert(at_offset, 2);
406        assert_eq!(
407            map.len(),
408            2,
409            "offset-distinct foreign imports must not collide"
410        );
411        assert_eq!(map.get(&base), Some(&1));
412        assert_eq!(map.get(&at_offset), Some(&2));
413
414        // Identical keys still collide (a genuine cache hit), as before.
415        map.insert(base, 3);
416        assert_eq!(map.len(), 2);
417        assert_eq!(map.get(&base), Some(&3));
418    }
419
420    #[test]
421    fn cache_key_collapses_sibling_views() {
422        // The batch-engine pivot: a view()/batch() sub-region is a
423        // glViewport/scissor ROI into its parent, so sibling views of one buffer
424        // MUST produce the SAME cache key (one shared EGLImage import) and key
425        // identically to the whole parent. The per-tile offset is render state,
426        // never part of the key.
427        use edgefirst_tensor::{Region, Tensor, TensorMemory};
428        let parent = Tensor::<u8>::image(
429            64,
430            64,
431            PixelFormat::Rgba,
432            Some(TensorMemory::Mem),
433            edgefirst_tensor::CpuAccess::ReadWrite,
434        )
435        .unwrap();
436        let a = parent.view(Region::new(0, 0, 32, 32)).unwrap();
437        let b = parent.view(Region::new(0, 32, 32, 32)).unwrap();
438        // Destinations (`for_dst = true`) collapse onto the parent key.
439        let ka = BufferImportKey::from_tensor(&a, PixelFormat::Rgba, true);
440        let kb = BufferImportKey::from_tensor(&b, PixelFormat::Rgba, true);
441        let kp = BufferImportKey::from_tensor(&parent, PixelFormat::Rgba, true);
442        assert_eq!(
443            ka, kb,
444            "sibling dst views collapse to one parent-keyed import"
445        );
446        assert_eq!(ka, kp, "a dst view keys identically to its whole parent");
447        assert_eq!(
448            ka.plane_offset, 0,
449            "a dst view contributes no offset to the key"
450        );
451        assert_eq!((ka.width, ka.height), (64, 64), "keyed on parent geometry");
452
453        // SOURCES (`for_dst = false`) key on their OWN region — a source view is
454        // imported and SAMPLED, not rendered into, so two source views of one
455        // parent must NOT collapse (they'd alias and sample the wrong region).
456        let sa = BufferImportKey::from_tensor(&a, PixelFormat::Rgba, false);
457        let sb = BufferImportKey::from_tensor(&b, PixelFormat::Rgba, false);
458        assert_ne!(sa, sb, "source views key on their own region (no collapse)");
459        assert_eq!(
460            (sa.width, sa.height),
461            (32, 32),
462            "a source view keys on its own dimensions"
463        );
464    }
465
466    #[test]
467    fn cache_key_distinguishes_geometry() {
468        // Root-cause regression guard for the pool-recycle bug: ONE buffer
469        // (same luma_id + offset) reconfigured to different geometry via
470        // `configure_image` must produce DISTINCT keys — otherwise the EGLImage
471        // imported for the first geometry is reused at the wrong pitch.
472        let mut map: HashMap<BufferImportKey, u32> = HashMap::new();
473        let g0 = key(0xBEEF, 0, 128, 96, 128, PixelFormat::Grey);
474        let g1 = key(0xBEEF, 0, 96, 128, 96, PixelFormat::Grey); // different w/h/stride
475        let g2 = key(0xBEEF, 0, 128, 96, 128, PixelFormat::Nv12); // different format
476        map.insert(g0, 1);
477        map.insert(g1, 2);
478        map.insert(g2, 3);
479        assert_eq!(
480            map.len(),
481            3,
482            "geometry/format-distinct reuses must not collide"
483        );
484
485        // Same identity + same geometry is still a genuine hit.
486        let g0_again = key(0xBEEF, 0, 128, 96, 128, PixelFormat::Grey);
487        map.insert(g0_again, 4);
488        assert_eq!(map.len(), 3);
489        assert_eq!(map.get(&g0), Some(&4));
490    }
491
492    #[test]
493    fn cache_key_distinguishes_stride() {
494        // A stride-only change (same identity/offset/w/h/format, different
495        // row_stride — e.g. a padded vs tight pool buffer) must be a DISTINCT
496        // key. Guards against dropping `row_stride` from the key, which would
497        // reintroduce the wrong-pitch stale read on a re-padded pool.
498        let mut map: HashMap<BufferImportKey, u32> = HashMap::new();
499        let tight = key(0xBEEF, 0, 128, 96, 128, PixelFormat::Grey);
500        let padded = key(0xBEEF, 0, 128, 96, 256, PixelFormat::Grey); // stride differs
501        map.insert(tight, 1);
502        map.insert(padded, 2);
503        assert_eq!(map.len(), 2, "stride-distinct imports must not collide");
504        assert_eq!(map.get(&tight), Some(&1));
505        assert_eq!(map.get(&padded), Some(&2));
506    }
507}