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/// **The rule a cached key must satisfy, and where each platform enforces
42/// it.** A key is safe here only when the import this entry owns keeps the
43/// keyed object alive, because that is what stops the key being recycled onto
44/// a different buffer while the entry is still findable. Each
45/// `Platform::import_buffer*` implementation reaches its platform's object
46/// through an accessor no other backing answers — `dmabuf_fd` on Linux,
47/// `iosurface_ref` on macOS, the `AHardwareBuffer*` on Android, and
48/// `d3d11_texture` on Windows — so a `Mem` or PBO tensor cannot reach any of
49/// them and `IdentityKind::HostPtr` and `Pbo` are never cached.
50///
51/// Two cached kinds are raw pointers, recyclable in principle:
52/// `IdentityKind::D3d11Texture` (an `ID3D11Texture2D*`) and
53/// `AHardwareBufferPtr`. The anchor above is what rules a stale key out for
54/// them, since the EGLImage holds its own reference to the object, which
55/// therefore cannot be freed and its address reused while the entry lives.
56///
57/// Reaching the right accessor is *not* on its own proof that the identity
58/// names that object, and on Windows the two came apart: the dynamic tensor
59/// backend answered `d3d11_texture` correctly while identifying the tensor by
60/// its recyclable `ef_tensor` handle address, and converts rendered into
61/// dropped textures. So the Windows path checks rather than assumes, through
62/// [`GlPlatform::validate_import_identity`], which refuses in every build any
63/// texture whose identity is not `D3d11Texture` over that same pointer.
64///
65/// That check runs on **every** import, cache hits included, and that is why
66/// it is a separate hook rather than a line in `Platform::import_buffer*`:
67/// those run only on a miss, so a check made there could not see this cache
68/// serving an entry for a tensor whose identity had stopped matching its
69/// buffer — the one case that produces wrong pixels rather than none. The
70/// other three platforms take the default `Ok(())`: their identities are
71/// OS-level keys the kernel cannot recycle while the import holds the object.
72///
73/// [`GlPlatform::validate_import_identity`]: super::platform::GlPlatform::validate_import_identity
74pub(super) struct CachedImport<I> {
75 pub(super) import: I,
76 /// Optional GL renderbuffer backed by this import (used by direct RGB path).
77 pub(super) renderbuffer: Option<u32>,
78 /// Monotonic access counter for LRU eviction.
79 pub(super) last_used: u64,
80}
81
82/// Per-processor zero-copy telemetry: how convert sources reached the GPU
83/// and how often a zero-copy opportunity was declined into a copy path.
84///
85/// This is deliberately SEPARATE from [`GlCacheStats`]: that struct is the
86/// steady-state import-cache equality gate (tests assert exact equality on
87/// it), while these counters change on every convert. `src_uploads` staying
88/// at 0 across a Dma-source workload is the "no silent zero-copy drop"
89/// assertion callers (and the on-device validation harness) can make —
90/// EGLImage miss counts alone are blind to uploads.
91#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
92pub struct ConvertStats {
93 /// Sources fed zero-copy: the tensor's Dma buffer became the sampled
94 /// texture's storage (EGLImage attach / IOSurface pbuffer bind).
95 pub src_imports: u64,
96 /// Sources fed from a PBO (GL-internal copy, no CPU visit).
97 pub src_pbo_uploads: u64,
98 /// Sources fed by CPU map + TexImage upload — the copy path.
99 pub src_uploads: u64,
100 /// Times a zero-copy source feed was attempted and DECLINED into a
101 /// copy path (import/attach failure). A nonzero value with Dma
102 /// sources means the platform/driver refused the fast path.
103 pub zero_copy_declines: u64,
104}
105
106/// Buffer-import cache owned by the GL processor.
107///
108/// Uses a HashMap with a monotonic counter for LRU eviction: each access
109/// updates the entry's `last_used` timestamp, and eviction removes the entry
110/// with the smallest `last_used` value. Recency is the *only* eviction
111/// signal — entries are retained past the lifetime of the tensor that
112/// produced them, which is what lets a re-imported buffer hit (see
113/// [`CachedImport`]), so `capacity` is the sole bound.
114/// Identity + geometry that uniquely determine an imported GPU buffer
115/// (an EGLImage over a DMA-BUF on Linux; an EGL pbuffer over an IOSurface
116/// on macOS — the key fields are platform-neutral).
117///
118/// `luma_id` / `chroma_id` are the buffer identities.
119///
120/// A `view()`/`batch()` sub-region is a `glViewport`/`glScissor` ROI into its
121/// parent, **not** a distinct import: [`from_tensor`](BufferImportKey::from_tensor)
122/// keys such a tensor on its **parent** geometry (`view_origin`) with
123/// `plane_offset = 0`, so every sibling view of one buffer collapses to a single
124/// EGLImage and the per-tile offset becomes render state (the viewport). The
125/// remaining `plane_offset` use is a non-view tensor that carries a genuine
126/// foreign/multi-plane byte offset (e.g. an externally-imported buffer whose data
127/// starts past the fd origin); those still key distinctly.
128///
129/// `width` / `height` / `row_stride` / `format` capture the geometry the
130/// EGLImage was imported with — the **parent's** for a view. A pooled buffer
131/// reused at a different size via `Tensor::configure_image` (e.g. a 128-wide pool
132/// decoding a 96-wide image) keeps the same identities but needs a fresh import:
133/// the import's pitch/dimensions/fourcc all derive from these fields. Omitting
134/// them reuses the stale-geometry EGLImage and the GPU samples the buffer at the
135/// wrong pitch — deterministically wrong single-threaded, nondeterministic in
136/// parallel, correct only on a heap source (which never takes this path).
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
138pub(super) struct BufferImportKey {
139 pub(super) luma_id: u64,
140 pub(super) chroma_id: Option<u64>,
141 pub(super) plane_offset: usize,
142 pub(super) width: usize,
143 pub(super) height: usize,
144 pub(super) row_stride: usize,
145 pub(super) format: PixelFormat,
146}
147
148impl BufferImportKey {
149 /// Build the cache key from a tensor and the format it will be imported as.
150 /// Every construction site MUST go through this so the key used to insert
151 /// an EGLImage matches the key used to look it up and to gate the texture
152 /// binding-skip.
153 pub(super) fn from_tensor<T>(img: &Tensor<T>, format: PixelFormat, for_dst: bool) -> Self
154 where
155 T: num_traits::Num + Clone + std::fmt::Debug + Send + Sync + edgefirst_tensor::Element,
156 {
157 // A DESTINATION view()/batch() sub-region keys on its PARENT so all
158 // siblings of one buffer collapse to a single import; the view's offset is
159 // the viewport, not a key. It keys on the parent's `row_stride` (from
160 // `view_origin`), NOT the view's own `effective_row_stride` — a single-row
161 // view sets a tight stride for map-span safety, which would otherwise
162 // mis-key it apart from its multi-row siblings. A SOURCE view (or a whole
163 // tensor) keys on its OWN geometry + any genuine foreign/multi-plane
164 // plane_offset — a source view imports its own region (it is sampled, not
165 // rendered into), so it must NOT collapse onto the parent key.
166 let view_origin = if for_dst { img.view_origin() } else { None };
167 let (width, height, row_stride, plane_offset) = match view_origin {
168 Some(vo) => (vo.parent_width, vo.parent_height, vo.parent_row_stride, 0),
169 None => (
170 img.width().unwrap_or(0),
171 img.height().unwrap_or(0),
172 img.effective_row_stride().unwrap_or(0),
173 img.plane_offset().unwrap_or(0),
174 ),
175 };
176 Self {
177 luma_id: img.buffer_identity().id(),
178 chroma_id: img.chroma().map(|t| t.buffer_identity().id()),
179 plane_offset,
180 width,
181 height,
182 row_stride,
183 format,
184 }
185 }
186}
187
188/// Snapshot of one EGLImage cache's hit/miss counters.
189///
190/// The counters themselves have always existed (logged at `Drop`); this
191/// snapshot makes them **assertable**: steady-state tests capture stats after
192/// warmup and after an N-frame loop and require `misses` to stay flat — any
193/// increase means a convert re-imported a buffer it should have found cached,
194/// which is the cache-behavior equality gate for GL refactors.
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub struct CacheStats {
197 pub hits: u64,
198 pub misses: u64,
199 pub entries: usize,
200 /// High-water mark of `entries`.
201 ///
202 /// Since entries are retained past the lifetime of the tensor that
203 /// produced them (the retention that `CachedImport` documents), each one
204 /// pins its buffer, so
205 /// occupancy — not hit rate — is what says how much memory a workload
206 /// holds. Below `capacity` this is the workload's true working-set size,
207 /// and the number to size `EDGEFIRST_EGL_CACHE_CAPACITY` against.
208 ///
209 /// It **saturates at `capacity`**, so on its own it cannot tell a
210 /// workload that exactly fits from one that is thrashing — both report
211 /// `peak_entries == capacity`. Read it together with [`evictions`], which
212 /// is what distinguishes them.
213 ///
214 /// [`evictions`]: Self::evictions
215 pub peak_entries: usize,
216 /// Entries evicted to stay within `capacity`.
217 ///
218 /// The signal that `capacity` is too small for the workload: every
219 /// eviction discards an import that is likely to be asked for again, so
220 /// a steady-state pipeline should reach zero. Nonzero-and-climbing with
221 /// `peak_entries == capacity` is thrashing — raise
222 /// `EDGEFIRST_EGL_CACHE_CAPACITY`, at the cost of pinning more buffers.
223 pub evictions: u64,
224}
225
226/// Combined snapshot of every EGLImage cache on the GL processor
227/// (source, destination, and the Path-B NV R8 source cache).
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229pub struct GlCacheStats {
230 pub src: CacheStats,
231 pub dst: CacheStats,
232 pub nv_r8: CacheStats,
233}
234
235impl GlCacheStats {
236 /// Total imports performed (cache misses) across all caches — the number
237 /// steady-state loops assert stays flat.
238 pub fn total_misses(&self) -> u64 {
239 self.src.misses + self.dst.misses + self.nv_r8.misses
240 }
241
242 /// Peak entries summed across all caches — an UPPER bound on how many
243 /// buffers were pinned at once, not an exact count: `src` and `nv_r8`
244 /// routinely name the *same* source buffer (one NV12 tensor imported
245 /// two ways), and two entries over one buffer pin that buffer once.
246 /// Use the per-cache peaks to size capacity; use this to bound memory.
247 pub fn total_peak_entries(&self) -> usize {
248 self.src.peak_entries + self.dst.peak_entries + self.nv_r8.peak_entries
249 }
250
251 /// Evictions across all caches — zero in a correctly sized steady state.
252 pub fn total_evictions(&self) -> u64 {
253 self.src.evictions + self.dst.evictions + self.nv_r8.evictions
254 }
255}
256
257pub(super) struct ImportCache<I> {
258 pub(super) entries: std::collections::HashMap<BufferImportKey, CachedImport<I>>,
259 pub(super) capacity: usize,
260 pub(super) hits: u64,
261 pub(super) misses: u64,
262 /// Monotonic counter incremented on each access for LRU tracking.
263 pub(super) access_counter: u64,
264 /// High-water mark of `entries.len()`. See [`CacheStats::peak_entries`].
265 peak_entries: usize,
266 /// Count of entries evicted to stay within `capacity`. See
267 /// [`CacheStats::evictions`].
268 evictions: u64,
269}
270
271impl<I> ImportCache<I> {
272 pub(super) fn new(capacity: usize) -> Self {
273 Self {
274 entries: std::collections::HashMap::with_capacity(capacity),
275 capacity,
276 hits: 0,
277 misses: 0,
278 access_counter: 0,
279 peak_entries: 0,
280 evictions: 0,
281 }
282 }
283
284 /// Snapshot the hit/miss counters for steady-state assertions.
285 pub(super) fn stats(&self) -> CacheStats {
286 CacheStats {
287 hits: self.hits,
288 misses: self.misses,
289 entries: self.entries.len(),
290 peak_entries: self.peak_entries,
291 evictions: self.evictions,
292 }
293 }
294
295 /// Insert a freshly created import, evicting the least recently used
296 /// entry first if the cache is at capacity.
297 ///
298 /// Every insert site goes through here so that the capacity bound and the
299 /// high-water mark cannot drift apart from each other — a site that
300 /// inserted directly would silently raise the real peak above the number
301 /// [`stats`](Self::stats) reports, which is the number capacity is tuned
302 /// against.
303 ///
304 /// Eviction happens *after* the caller has successfully created the
305 /// import: a failed import must not cost the cache a live entry.
306 pub(super) fn insert(&mut self, key: BufferImportKey, import: I, renderbuffer: Option<u32>) {
307 if self.entries.len() >= self.capacity {
308 self.evict_lru();
309 }
310 let last_used = self.next_timestamp();
311 self.entries.insert(
312 key,
313 CachedImport {
314 import,
315 renderbuffer,
316 last_used,
317 },
318 );
319 self.peak_entries = self.peak_entries.max(self.entries.len());
320 }
321
322 /// Allocate a new LRU timestamp.
323 pub(super) fn next_timestamp(&mut self) -> u64 {
324 self.access_counter += 1;
325 self.access_counter
326 }
327
328 /// Evict the least recently used entry. Returns `true` if an entry was evicted.
329 pub(super) fn evict_lru(&mut self) -> bool {
330 if let Some((&evict_id, _)) = self.entries.iter().min_by_key(|(_, entry)| entry.last_used) {
331 let evicted = self.entries.remove(&evict_id).expect("key just found");
332 self.evictions += 1;
333 if let Some(rbo) = evicted.renderbuffer {
334 unsafe { edgefirst_gl::gl::DeleteRenderbuffers(1, &rbo) };
335 }
336 return true;
337 }
338 false
339 }
340}
341
342impl<I> Drop for ImportCache<I> {
343 fn drop(&mut self) {
344 for entry in self.entries.values() {
345 if let Some(rbo) = entry.renderbuffer {
346 unsafe { edgefirst_gl::gl::DeleteRenderbuffers(1, &rbo) };
347 }
348 }
349 // peak/evictions are the two numbers the capacity story rests on, and
350 // they were previously reachable only through the API -- so a field
351 // report of "GPU memory pressure" could not be diagnosed from a log.
352 // `peak < capacity` with zero evictions means correctly sized;
353 // evictions climbing means the producer's pool is deeper than the bound.
354 log::debug!(
355 "ImportCache stats: {} hits, {} misses, {} entries remaining, \
356 peak {}/{}, {} evictions",
357 self.hits,
358 self.misses,
359 self.entries.len(),
360 self.peak_entries,
361 self.capacity,
362 self.evictions
363 );
364 }
365}
366
367#[cfg(test)]
368mod tests {
369 use super::BufferImportKey;
370 use edgefirst_tensor::PixelFormat;
371 use std::collections::HashMap;
372
373 fn key(
374 luma_id: u64,
375 plane_offset: usize,
376 width: usize,
377 height: usize,
378 row_stride: usize,
379 format: PixelFormat,
380 ) -> BufferImportKey {
381 BufferImportKey {
382 luma_id,
383 chroma_id: None,
384 plane_offset,
385 width,
386 height,
387 row_stride,
388 format,
389 }
390 }
391
392 /// The two numbers Task 7 (capacity sizing) reads, and the LRU order they
393 /// describe. Runs without a GL context: `evict_lru` only touches GL for an
394 /// entry that carries a renderbuffer, and these carry none.
395 #[test]
396 fn peak_and_evictions_report_capacity_pressure() {
397 use super::ImportCache;
398 let k = |id| key(id, 0, 64, 64, 64, PixelFormat::Grey);
399 let mut cache: ImportCache<u32> = ImportCache::new(2);
400
401 cache.insert(k(1), 10, None);
402 cache.insert(k(2), 20, None);
403 let full = cache.stats();
404 assert_eq!((full.entries, full.peak_entries), (2, 2));
405 assert_eq!(full.evictions, 0, "a cache within capacity must not evict");
406
407 // Third distinct buffer at capacity: the LRU entry (k(1)) goes.
408 cache.insert(k(3), 30, None);
409 let pressed = cache.stats();
410 assert_eq!(pressed.entries, 2, "capacity is enforced");
411 assert_eq!(
412 pressed.evictions, 1,
413 "an over-capacity insert must record an eviction — this is the \
414 signal that separates a workload that fits from one that thrashes, \
415 since peak_entries saturates at capacity in both cases"
416 );
417 assert_eq!(pressed.peak_entries, 2, "peak saturates at capacity");
418 assert!(!cache.entries.contains_key(&k(1)), "LRU entry was evicted");
419 assert!(cache.entries.contains_key(&k(3)), "new entry was inserted");
420 }
421
422 #[test]
423 fn cache_key_distinguishes_foreign_plane_offset() {
424 // `plane_offset` no longer distinguishes view()/batch() sub-regions —
425 // those key on their parent and collapse (see
426 // `cache_key_collapses_sibling_views`). It survives ONLY for a non-view
427 // tensor carrying a genuine foreign/multi-plane byte offset (e.g. an
428 // externally-imported buffer whose data starts past the fd origin); two
429 // such imports at different offsets must remain DISTINCT entries.
430 let mut map: HashMap<BufferImportKey, u32> = HashMap::new();
431 let base = key(0xABCD, 0, 64, 64, 64, PixelFormat::Grey);
432 let at_offset = key(0xABCD, 4096, 64, 64, 64, PixelFormat::Grey);
433 map.insert(base, 1);
434 map.insert(at_offset, 2);
435 assert_eq!(
436 map.len(),
437 2,
438 "offset-distinct foreign imports must not collide"
439 );
440 assert_eq!(map.get(&base), Some(&1));
441 assert_eq!(map.get(&at_offset), Some(&2));
442
443 // Identical keys still collide (a genuine cache hit), as before.
444 map.insert(base, 3);
445 assert_eq!(map.len(), 2);
446 assert_eq!(map.get(&base), Some(&3));
447 }
448
449 #[test]
450 fn cache_key_collapses_sibling_views() {
451 // The batch-engine pivot: a view()/batch() sub-region is a
452 // glViewport/scissor ROI into its parent, so sibling views of one buffer
453 // MUST produce the SAME cache key (one shared EGLImage import) and key
454 // identically to the whole parent. The per-tile offset is render state,
455 // never part of the key.
456 use edgefirst_tensor::{Region, Tensor, TensorMemory};
457 let parent = Tensor::<u8>::image(
458 64,
459 64,
460 PixelFormat::Rgba,
461 Some(TensorMemory::Mem),
462 edgefirst_tensor::CpuAccess::ReadWrite,
463 )
464 .unwrap();
465 let a = parent.view(Region::new(0, 0, 32, 32)).unwrap();
466 let b = parent.view(Region::new(0, 32, 32, 32)).unwrap();
467 // Destinations (`for_dst = true`) collapse onto the parent key.
468 let ka = BufferImportKey::from_tensor(&a, PixelFormat::Rgba, true);
469 let kb = BufferImportKey::from_tensor(&b, PixelFormat::Rgba, true);
470 let kp = BufferImportKey::from_tensor(&parent, PixelFormat::Rgba, true);
471 assert_eq!(
472 ka, kb,
473 "sibling dst views collapse to one parent-keyed import"
474 );
475 assert_eq!(ka, kp, "a dst view keys identically to its whole parent");
476 assert_eq!(
477 ka.plane_offset, 0,
478 "a dst view contributes no offset to the key"
479 );
480 assert_eq!((ka.width, ka.height), (64, 64), "keyed on parent geometry");
481
482 // SOURCES (`for_dst = false`) key on their OWN region — a source view is
483 // imported and SAMPLED, not rendered into, so two source views of one
484 // parent must NOT collapse (they'd alias and sample the wrong region).
485 let sa = BufferImportKey::from_tensor(&a, PixelFormat::Rgba, false);
486 let sb = BufferImportKey::from_tensor(&b, PixelFormat::Rgba, false);
487 assert_ne!(sa, sb, "source views key on their own region (no collapse)");
488 assert_eq!(
489 (sa.width, sa.height),
490 (32, 32),
491 "a source view keys on its own dimensions"
492 );
493 }
494
495 #[test]
496 fn cache_key_distinguishes_geometry() {
497 // Root-cause regression guard for the pool-recycle bug: ONE buffer
498 // (same luma_id + offset) reconfigured to different geometry via
499 // `configure_image` must produce DISTINCT keys — otherwise the EGLImage
500 // imported for the first geometry is reused at the wrong pitch.
501 let mut map: HashMap<BufferImportKey, u32> = HashMap::new();
502 let g0 = key(0xBEEF, 0, 128, 96, 128, PixelFormat::Grey);
503 let g1 = key(0xBEEF, 0, 96, 128, 96, PixelFormat::Grey); // different w/h/stride
504 let g2 = key(0xBEEF, 0, 128, 96, 128, PixelFormat::Nv12); // different format
505 map.insert(g0, 1);
506 map.insert(g1, 2);
507 map.insert(g2, 3);
508 assert_eq!(
509 map.len(),
510 3,
511 "geometry/format-distinct reuses must not collide"
512 );
513
514 // Same identity + same geometry is still a genuine hit.
515 let g0_again = key(0xBEEF, 0, 128, 96, 128, PixelFormat::Grey);
516 map.insert(g0_again, 4);
517 assert_eq!(map.len(), 3);
518 assert_eq!(map.get(&g0), Some(&4));
519 }
520
521 #[test]
522 fn cache_key_distinguishes_stride() {
523 // A stride-only change (same identity/offset/w/h/format, different
524 // row_stride — e.g. a padded vs tight pool buffer) must be a DISTINCT
525 // key. Guards against dropping `row_stride` from the key, which would
526 // reintroduce the wrong-pitch stale read on a re-padded pool.
527 let mut map: HashMap<BufferImportKey, u32> = HashMap::new();
528 let tight = key(0xBEEF, 0, 128, 96, 128, PixelFormat::Grey);
529 let padded = key(0xBEEF, 0, 128, 96, 256, PixelFormat::Grey); // stride differs
530 map.insert(tight, 1);
531 map.insert(padded, 2);
532 assert_eq!(map.len(), 2, "stride-distinct imports must not collide");
533 assert_eq!(map.get(&tight), Some(&1));
534 assert_eq!(map.get(&padded), Some(&2));
535 }
536}