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 with a weak reference to the source tensor's guard.
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.
18pub(super) struct CachedImport<I> {
19    pub(super) import: I,
20    /// Weak reference to the source Tensor's BufferIdentity guard.
21    pub(super) guard: std::sync::Weak<()>,
22    /// Optional GL renderbuffer backed by this import (used by direct RGB path).
23    pub(super) renderbuffer: Option<u32>,
24    /// Monotonic access counter for LRU eviction.
25    pub(super) last_used: u64,
26}
27
28/// Per-processor zero-copy telemetry: how convert sources reached the GPU
29/// and how often a zero-copy opportunity was declined into a copy path.
30///
31/// This is deliberately SEPARATE from [`GlCacheStats`]: that struct is the
32/// steady-state import-cache equality gate (tests assert exact equality on
33/// it), while these counters change on every convert. `src_uploads` staying
34/// at 0 across a Dma-source workload is the "no silent zero-copy drop"
35/// assertion callers (and the on-device validation harness) can make —
36/// EGLImage miss counts alone are blind to uploads.
37#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
38pub struct ConvertStats {
39    /// Sources fed zero-copy: the tensor's Dma buffer became the sampled
40    /// texture's storage (EGLImage attach / IOSurface pbuffer bind).
41    pub src_imports: u64,
42    /// Sources fed from a PBO (GL-internal copy, no CPU visit).
43    pub src_pbo_uploads: u64,
44    /// Sources fed by CPU map + TexImage upload — the copy path.
45    pub src_uploads: u64,
46    /// Times a zero-copy source feed was attempted and DECLINED into a
47    /// copy path (import/attach failure). A nonzero value with Dma
48    /// sources means the platform/driver refused the fast path.
49    pub zero_copy_declines: u64,
50}
51
52/// Buffer-import cache owned by the GL processor.
53///
54/// Uses a HashMap with a monotonic counter for LRU eviction: each access
55/// updates the entry's `last_used` timestamp, and eviction removes the entry
56/// with the smallest `last_used` value.
57/// Identity + geometry that uniquely determine an imported GPU buffer
58/// (an EGLImage over a DMA-BUF on Linux; an EGL pbuffer over an IOSurface
59/// on macOS — the key fields are platform-neutral).
60///
61/// `luma_id` / `chroma_id` are the buffer identities.
62///
63/// A `view()`/`batch()` sub-region is a `glViewport`/`glScissor` ROI into its
64/// parent, **not** a distinct import: [`from_tensor`](BufferImportKey::from_tensor)
65/// keys such a tensor on its **parent** geometry (`view_origin`) with
66/// `plane_offset = 0`, so every sibling view of one buffer collapses to a single
67/// EGLImage and the per-tile offset becomes render state (the viewport). The
68/// remaining `plane_offset` use is a non-view tensor that carries a genuine
69/// foreign/multi-plane byte offset (e.g. an externally-imported buffer whose data
70/// starts past the fd origin); those still key distinctly.
71///
72/// `width` / `height` / `row_stride` / `format` capture the geometry the
73/// EGLImage was imported with — the **parent's** for a view. A pooled buffer
74/// reused at a different size via `Tensor::configure_image` (e.g. a 128-wide pool
75/// decoding a 96-wide image) keeps the same identities but needs a fresh import:
76/// the import's pitch/dimensions/fourcc all derive from these fields. Omitting
77/// them reuses the stale-geometry EGLImage and the GPU samples the buffer at the
78/// wrong pitch — deterministically wrong single-threaded, nondeterministic in
79/// parallel, correct only on a heap source (which never takes this path).
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
81pub(super) struct BufferImportKey {
82    pub(super) luma_id: u64,
83    pub(super) chroma_id: Option<u64>,
84    pub(super) plane_offset: usize,
85    pub(super) width: usize,
86    pub(super) height: usize,
87    pub(super) row_stride: usize,
88    pub(super) format: PixelFormat,
89}
90
91impl BufferImportKey {
92    /// Build the cache key from a tensor and the format it will be imported as.
93    /// Every construction site MUST go through this so the key used to insert
94    /// an EGLImage matches the key used to look it up and to gate the texture
95    /// binding-skip.
96    pub(super) fn from_tensor<T>(img: &Tensor<T>, format: PixelFormat, for_dst: bool) -> Self
97    where
98        T: num_traits::Num + Clone + std::fmt::Debug + Send + Sync,
99    {
100        // A DESTINATION view()/batch() sub-region keys on its PARENT so all
101        // siblings of one buffer collapse to a single import; the view's offset is
102        // the viewport, not a key. It keys on the parent's `row_stride` (from
103        // `view_origin`), NOT the view's own `effective_row_stride` — a single-row
104        // view sets a tight stride for map-span safety, which would otherwise
105        // mis-key it apart from its multi-row siblings. A SOURCE view (or a whole
106        // tensor) keys on its OWN geometry + any genuine foreign/multi-plane
107        // plane_offset — a source view imports its own region (it is sampled, not
108        // rendered into), so it must NOT collapse onto the parent key.
109        let view_origin = if for_dst { img.view_origin() } else { None };
110        let (width, height, row_stride, plane_offset) = match view_origin {
111            Some(vo) => (vo.parent_width, vo.parent_height, vo.parent_row_stride, 0),
112            None => (
113                img.width().unwrap_or(0),
114                img.height().unwrap_or(0),
115                img.effective_row_stride().unwrap_or(0),
116                img.plane_offset().unwrap_or(0),
117            ),
118        };
119        Self {
120            luma_id: img.buffer_identity().id(),
121            chroma_id: img.chroma().map(|t| t.buffer_identity().id()),
122            plane_offset,
123            width,
124            height,
125            row_stride,
126            format,
127        }
128    }
129}
130
131/// Snapshot of one EGLImage cache's hit/miss counters.
132///
133/// The counters themselves have always existed (logged at `Drop`); this
134/// snapshot makes them **assertable**: steady-state tests capture stats after
135/// warmup and after an N-frame loop and require `misses` to stay flat — any
136/// increase means a convert re-imported a buffer it should have found cached,
137/// which is the cache-behavior equality gate for GL refactors.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub struct CacheStats {
140    pub hits: u64,
141    pub misses: u64,
142    pub entries: usize,
143}
144
145/// Combined snapshot of every EGLImage cache on the GL processor
146/// (source, destination, and the Path-B NV R8 source cache).
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub struct GlCacheStats {
149    pub src: CacheStats,
150    pub dst: CacheStats,
151    pub nv_r8: CacheStats,
152}
153
154impl GlCacheStats {
155    /// Total imports performed (cache misses) across all caches — the number
156    /// steady-state loops assert stays flat.
157    pub fn total_misses(&self) -> u64 {
158        self.src.misses + self.dst.misses + self.nv_r8.misses
159    }
160}
161
162pub(super) struct ImportCache<I> {
163    pub(super) entries: std::collections::HashMap<BufferImportKey, CachedImport<I>>,
164    pub(super) capacity: usize,
165    pub(super) hits: u64,
166    pub(super) misses: u64,
167    /// Monotonic counter incremented on each access for LRU tracking.
168    pub(super) access_counter: u64,
169}
170
171impl<I> ImportCache<I> {
172    pub(super) fn new(capacity: usize) -> Self {
173        Self {
174            entries: std::collections::HashMap::with_capacity(capacity),
175            capacity,
176            hits: 0,
177            misses: 0,
178            access_counter: 0,
179        }
180    }
181
182    /// Snapshot the hit/miss counters for steady-state assertions.
183    pub(super) fn stats(&self) -> CacheStats {
184        CacheStats {
185            hits: self.hits,
186            misses: self.misses,
187            entries: self.entries.len(),
188        }
189    }
190
191    /// Allocate a new LRU timestamp.
192    pub(super) fn next_timestamp(&mut self) -> u64 {
193        self.access_counter += 1;
194        self.access_counter
195    }
196
197    /// Evict the least recently used entry. Returns `true` if an entry was evicted.
198    pub(super) fn evict_lru(&mut self) -> bool {
199        if let Some((&evict_id, _)) = self.entries.iter().min_by_key(|(_, entry)| entry.last_used) {
200            let evicted = self.entries.remove(&evict_id).expect("key just found");
201            if let Some(rbo) = evicted.renderbuffer {
202                unsafe { edgefirst_gl::gl::DeleteRenderbuffers(1, &rbo) };
203            }
204            return true;
205        }
206        false
207    }
208
209    /// Sweep dead entries (tensor dropped, Weak is dead).
210    /// Returns `true` if any entries were removed.
211    pub(super) fn sweep(&mut self) -> bool {
212        let before = self.entries.len();
213        self.entries.retain(|_id, entry| {
214            let alive = entry.guard.upgrade().is_some();
215            if !alive {
216                if let Some(rbo) = entry.renderbuffer {
217                    unsafe { edgefirst_gl::gl::DeleteRenderbuffers(1, &rbo) };
218                }
219            }
220            alive
221        });
222        let swept = before - self.entries.len();
223        if swept > 0 {
224            log::debug!("ImportCache: swept {swept} dead entries");
225        }
226        swept > 0
227    }
228}
229
230impl<I> Drop for ImportCache<I> {
231    fn drop(&mut self) {
232        for entry in self.entries.values() {
233            if let Some(rbo) = entry.renderbuffer {
234                unsafe { edgefirst_gl::gl::DeleteRenderbuffers(1, &rbo) };
235            }
236        }
237        log::debug!(
238            "ImportCache stats: {} hits, {} misses, {} entries remaining",
239            self.hits,
240            self.misses,
241            self.entries.len()
242        );
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::BufferImportKey;
249    use edgefirst_tensor::PixelFormat;
250    use std::collections::HashMap;
251
252    fn key(
253        luma_id: u64,
254        plane_offset: usize,
255        width: usize,
256        height: usize,
257        row_stride: usize,
258        format: PixelFormat,
259    ) -> BufferImportKey {
260        BufferImportKey {
261            luma_id,
262            chroma_id: None,
263            plane_offset,
264            width,
265            height,
266            row_stride,
267            format,
268        }
269    }
270
271    #[test]
272    fn cache_key_distinguishes_foreign_plane_offset() {
273        // `plane_offset` no longer distinguishes view()/batch() sub-regions —
274        // those key on their parent and collapse (see
275        // `cache_key_collapses_sibling_views`). It survives ONLY for a non-view
276        // tensor carrying a genuine foreign/multi-plane byte offset (e.g. an
277        // externally-imported buffer whose data starts past the fd origin); two
278        // such imports at different offsets must remain DISTINCT entries.
279        let mut map: HashMap<BufferImportKey, u32> = HashMap::new();
280        let base = key(0xABCD, 0, 64, 64, 64, PixelFormat::Grey);
281        let at_offset = key(0xABCD, 4096, 64, 64, 64, PixelFormat::Grey);
282        map.insert(base, 1);
283        map.insert(at_offset, 2);
284        assert_eq!(
285            map.len(),
286            2,
287            "offset-distinct foreign imports must not collide"
288        );
289        assert_eq!(map.get(&base), Some(&1));
290        assert_eq!(map.get(&at_offset), Some(&2));
291
292        // Identical keys still collide (a genuine cache hit), as before.
293        map.insert(base, 3);
294        assert_eq!(map.len(), 2);
295        assert_eq!(map.get(&base), Some(&3));
296    }
297
298    #[test]
299    fn cache_key_collapses_sibling_views() {
300        // The batch-engine pivot: a view()/batch() sub-region is a
301        // glViewport/scissor ROI into its parent, so sibling views of one buffer
302        // MUST produce the SAME cache key (one shared EGLImage import) and key
303        // identically to the whole parent. The per-tile offset is render state,
304        // never part of the key.
305        use edgefirst_tensor::{Region, Tensor, TensorMemory};
306        let parent = Tensor::<u8>::image(
307            64,
308            64,
309            PixelFormat::Rgba,
310            Some(TensorMemory::Mem),
311            edgefirst_tensor::CpuAccess::ReadWrite,
312        )
313        .unwrap();
314        let a = parent.view(Region::new(0, 0, 32, 32)).unwrap();
315        let b = parent.view(Region::new(0, 32, 32, 32)).unwrap();
316        // Destinations (`for_dst = true`) collapse onto the parent key.
317        let ka = BufferImportKey::from_tensor(&a, PixelFormat::Rgba, true);
318        let kb = BufferImportKey::from_tensor(&b, PixelFormat::Rgba, true);
319        let kp = BufferImportKey::from_tensor(&parent, PixelFormat::Rgba, true);
320        assert_eq!(
321            ka, kb,
322            "sibling dst views collapse to one parent-keyed import"
323        );
324        assert_eq!(ka, kp, "a dst view keys identically to its whole parent");
325        assert_eq!(
326            ka.plane_offset, 0,
327            "a dst view contributes no offset to the key"
328        );
329        assert_eq!((ka.width, ka.height), (64, 64), "keyed on parent geometry");
330
331        // SOURCES (`for_dst = false`) key on their OWN region — a source view is
332        // imported and SAMPLED, not rendered into, so two source views of one
333        // parent must NOT collapse (they'd alias and sample the wrong region).
334        let sa = BufferImportKey::from_tensor(&a, PixelFormat::Rgba, false);
335        let sb = BufferImportKey::from_tensor(&b, PixelFormat::Rgba, false);
336        assert_ne!(sa, sb, "source views key on their own region (no collapse)");
337        assert_eq!(
338            (sa.width, sa.height),
339            (32, 32),
340            "a source view keys on its own dimensions"
341        );
342    }
343
344    #[test]
345    fn cache_key_distinguishes_geometry() {
346        // Root-cause regression guard for the pool-recycle bug: ONE buffer
347        // (same luma_id + offset) reconfigured to different geometry via
348        // `configure_image` must produce DISTINCT keys — otherwise the EGLImage
349        // imported for the first geometry is reused at the wrong pitch.
350        let mut map: HashMap<BufferImportKey, u32> = HashMap::new();
351        let g0 = key(0xBEEF, 0, 128, 96, 128, PixelFormat::Grey);
352        let g1 = key(0xBEEF, 0, 96, 128, 96, PixelFormat::Grey); // different w/h/stride
353        let g2 = key(0xBEEF, 0, 128, 96, 128, PixelFormat::Nv12); // different format
354        map.insert(g0, 1);
355        map.insert(g1, 2);
356        map.insert(g2, 3);
357        assert_eq!(
358            map.len(),
359            3,
360            "geometry/format-distinct reuses must not collide"
361        );
362
363        // Same identity + same geometry is still a genuine hit.
364        let g0_again = key(0xBEEF, 0, 128, 96, 128, PixelFormat::Grey);
365        map.insert(g0_again, 4);
366        assert_eq!(map.len(), 3);
367        assert_eq!(map.get(&g0), Some(&4));
368    }
369
370    #[test]
371    fn cache_key_distinguishes_stride() {
372        // A stride-only change (same identity/offset/w/h/format, different
373        // row_stride — e.g. a padded vs tight pool buffer) must be a DISTINCT
374        // key. Guards against dropping `row_stride` from the key, which would
375        // reintroduce the wrong-pitch stale read on a re-padded pool.
376        let mut map: HashMap<BufferImportKey, u32> = HashMap::new();
377        let tight = key(0xBEEF, 0, 128, 96, 128, PixelFormat::Grey);
378        let padded = key(0xBEEF, 0, 128, 96, 256, PixelFormat::Grey); // stride differs
379        map.insert(tight, 1);
380        map.insert(padded, 2);
381        assert_eq!(map.len(), 2, "stride-distinct imports must not collide");
382        assert_eq!(map.get(&tight), Some(&1));
383        assert_eq!(map.get(&padded), Some(&2));
384    }
385}