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