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