Skip to main content

ezu_graph/
cache.rs

1//! Render-time intermediate cache, keyed by a content-derived hash.
2//!
3//! A bounded LRU keeps long editor sessions from growing without limit.
4//! Two limits apply together: an entry count (default 4096) and a budget
5//! on the pixel bytes the retained values hold (default
6//! [`DEFAULT_BYTE_BUDGET`]). The byte budget is the one that matters for
7//! memory: a style with dozens of layers produces dozens of full padded
8//! rasters per tile, and counting entries alone would let a single render
9//! pin all of them. Tune either via [`Cache::with_limits`].
10//!
11//! Evicting an entry mid-render is safe: the evaluator holds the values
12//! it still needs itself, so the cache only ever decides whether a *later*
13//! render gets to skip work.
14
15use std::num::NonZeroUsize;
16use std::sync::Mutex;
17
18use lru::LruCache;
19use xxhash_rust::xxh3::Xxh3;
20
21use crate::eval::{CanvasInfo, TileId};
22use crate::value::PortValue;
23
24/// 128-bit content hash. Wide enough that collisions are not a concern
25/// for our scale; narrow enough to fit four words.
26pub type Hash128 = u128;
27
28/// Compose a cache key for one node evaluation.
29///
30/// The key folds together:
31/// - the canvas (both tile axes + pad), so cached buffers always match
32///   shape — a buffer cached for one shape must never be handed to a
33///   render of another
34/// - the tile id (or omitted for world-anchored nodes)
35/// - the node's own param hash
36/// - each input's cache hash (Merkle-style chain)
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38pub struct CacheKey(pub Hash128);
39
40impl CacheKey {
41    pub fn build(
42        canvas: CanvasInfo,
43        tile: Option<TileId>,
44        params_hash: Hash128,
45        inputs: &[Hash128],
46    ) -> Self {
47        let mut h = Xxh3::new();
48        h.update(&canvas.tile_w.to_le_bytes());
49        h.update(&canvas.tile_h.to_le_bytes());
50        h.update(&canvas.pad.to_le_bytes());
51        if let Some(t) = tile {
52            h.update(&[t.z]);
53            h.update(&t.x.to_le_bytes());
54            h.update(&t.y.to_le_bytes());
55        }
56        h.update(&params_hash.to_le_bytes());
57        for i in inputs {
58            h.update(&i.to_le_bytes());
59        }
60        CacheKey(h.digest128())
61    }
62}
63
64/// Default LRU capacity. Each entry holds an `Arc<PortValue>` so the
65/// payload is shared, not duplicated; the cap bounds how many distinct
66/// intermediates the evaluator remembers, not raw bytes.
67pub const DEFAULT_CAPACITY: usize = 4096;
68
69/// Default ceiling on pixel bytes retained by cached values: 8 MB, or
70/// about seven padded 512 px rasters.
71///
72/// Values that carry no pixels (features, labels, scalars) are not
73/// charged against it, so the budget only governs how many *rasters* a
74/// finished render leaves behind for the next one to reuse. A handful
75/// covers the hot intermediates an editor session re-renders against,
76/// while keeping a memory-constrained host (a 128 MB Workers isolate,
77/// say) from paying for a whole style's worth of layers it will never
78/// ask for again — on a 68-layer basemap that difference is ~70 MB.
79pub const DEFAULT_BYTE_BUDGET: usize = 8 * 1024 * 1024;
80
81/// Shared cache of evaluated `PortValue`s. Cloning a `PortValue` is
82/// cheap (Arc-backed for the heavy variants) so cache reuse adds
83/// near-zero overhead.
84pub struct Cache {
85    inner: Mutex<Inner>,
86    byte_budget: usize,
87}
88
89/// LRU plus the running total of the pixel bytes its entries hold. Both
90/// live under one lock so the total can never drift from the contents.
91struct Inner {
92    lru: LruCache<CacheKey, PortValue>,
93    bytes: usize,
94}
95
96impl Default for Cache {
97    fn default() -> Self {
98        Self::new()
99    }
100}
101
102impl Cache {
103    pub fn new() -> Self {
104        Self::with_limits(DEFAULT_CAPACITY, DEFAULT_BYTE_BUDGET)
105    }
106
107    pub fn with_capacity(cap: usize) -> Self {
108        Self::with_limits(cap, DEFAULT_BYTE_BUDGET)
109    }
110
111    /// Cache holding at most `cap` entries and at most `byte_budget`
112    /// pixel bytes; whichever binds first evicts. A budget of `0`
113    /// effectively disables retention of pixel-carrying values, which is
114    /// what a one-tile-per-instance host wants.
115    pub fn with_limits(cap: usize, byte_budget: usize) -> Self {
116        // `cap.max(1)` guarantees the value is non-zero.
117        let cap = NonZeroUsize::new(cap.max(1)).expect("cap.max(1) is non-zero");
118        Self {
119            inner: Mutex::new(Inner {
120                lru: LruCache::new(cap),
121                bytes: 0,
122            }),
123            byte_budget,
124        }
125    }
126
127    /// Look up a cached value and refresh its LRU position.
128    pub fn get(&self, key: CacheKey) -> Option<PortValue> {
129        self.lock().lru.get(&key).cloned()
130    }
131
132    pub fn insert(&self, key: CacheKey, value: PortValue) {
133        let bytes = value.approx_bytes();
134        let mut inner = self.lock();
135        if let Some(old) = inner.lru.put(key, value) {
136            inner.bytes = inner.bytes.saturating_sub(old.approx_bytes());
137        }
138        inner.bytes += bytes;
139        // Evict oldest-first until back under budget. The entry just
140        // inserted is exempt — evicting it would make `insert` a no-op
141        // whenever one value alone exceeds the budget, and callers expect
142        // an immediate re-lookup of what they just stored to hit.
143        while inner.bytes > self.byte_budget && inner.lru.peek_lru().is_some_and(|(k, _)| *k != key)
144        {
145            let Some((_, evicted)) = inner.lru.pop_lru() else {
146                break;
147            };
148            inner.bytes = inner.bytes.saturating_sub(evicted.approx_bytes());
149        }
150    }
151
152    pub fn len(&self) -> usize {
153        self.lock().lru.len()
154    }
155
156    pub fn is_empty(&self) -> bool {
157        self.lock().lru.is_empty()
158    }
159
160    pub fn clear(&self) {
161        let mut inner = self.lock();
162        inner.lru.clear();
163        inner.bytes = 0;
164    }
165
166    /// Configured maximum entry count.
167    pub fn capacity(&self) -> usize {
168        self.lock().lru.cap().get()
169    }
170
171    /// Configured ceiling on retained pixel bytes.
172    pub fn byte_budget(&self) -> usize {
173        self.byte_budget
174    }
175
176    /// Pixel bytes currently retained.
177    pub fn bytes(&self) -> usize {
178        self.lock().bytes
179    }
180
181    /// Acquire the inner mutex. Recovers from poisoning by taking the
182    /// guard anyway — the cache holds no invariant that a panic mid-op
183    /// could break (it's just an LRU of `Arc`s).
184    fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
185        self.inner.lock().unwrap_or_else(|e| e.into_inner())
186    }
187}