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 { edgefirst_gl::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 { edgefirst_gl::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 { edgefirst_gl::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}