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}