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}