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