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(¶ms_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}