Skip to main content

vyre_driver_wgpu/runtime/cache/
tiered_cache.rs

1use crate::runtime::cache::lru::{AccessTracker, IntrusiveLru};
2use rustc_hash::FxHashMap;
3
4/// Metadata for a cached entry.
5#[derive(Clone, Copy, Debug, PartialEq, Eq)]
6#[non_exhaustive]
7pub struct CacheEntry {
8    /// Unique identifier for the entry.
9    pub key: u64,
10    /// Size of the entry in bytes.
11    pub size: u64,
12    /// Index of the tier the entry currently resides in.
13    pub tier: usize,
14}
15
16/// A single cache tier with a fixed capacity.
17///
18/// Carries its own recency LRU so eviction picks the coldest entry
19/// within the tier in O(1) instead of scanning the global
20/// `AccessTracker` looking for a key that happens to live in this
21/// tier. Before 0.6 the scan was O(N) in the global tracker size  -
22/// catastrophic when the cold key was far from the tier boundary.
23#[non_exhaustive]
24pub struct CacheTier {
25    /// Human-readable name for the tier.
26    pub name: String,
27    /// Total capacity of the tier in bytes.
28    pub capacity: u64,
29    /// Currently used bytes in the tier.
30    pub used: u64,
31    pub(crate) entries: FxHashMap<u64, CacheEntry>,
32    pub(crate) lru: IntrusiveLru<u64, ()>,
33}
34
35impl CacheTier {
36    /// Create a new empty tier.
37    #[inline]
38    pub fn new(name: impl Into<String>, capacity: u64) -> Self {
39        let name = name.into();
40        match Self::try_new(name.clone(), capacity) {
41            Ok(tier) => tier,
42            Err(error) => {
43                tracing::error!(
44                    tier = %name,
45                    capacity,
46                    error = %error,
47                    "wgpu cache tier LRU reservation failed; continuing with grow-on-use metadata"
48                );
49                Self {
50                    name,
51                    capacity,
52                    used: 0,
53                    entries: FxHashMap::default(),
54                    lru: IntrusiveLru::with_reserved_capacity(0),
55                }
56            }
57        }
58    }
59
60    /// Fallible version of [`Self::new`].
61    ///
62    /// # Errors
63    ///
64    /// Returns [`vyre_driver::BackendError`] if tier LRU metadata cannot be
65    /// reserved.
66    #[inline]
67    pub fn try_new(
68        name: impl Into<String>,
69        capacity: u64,
70    ) -> Result<Self, vyre_driver::BackendError> {
71        Ok(Self {
72            name: name.into(),
73            capacity,
74            used: 0,
75            entries: FxHashMap::default(),
76            lru: IntrusiveLru::try_with_reserved_capacity(1024)?,
77        })
78    }
79}
80
81/// Access statistics used by [`LruPolicy`] promotion decisions.
82#[non_exhaustive]
83pub struct AccessStats {
84    /// Number of recorded accesses.
85    pub frequency: u32,
86    /// Monotonic tick of the last access. Higher = more recent.
87    /// Compare two entries' ticks to determine relative recency in O(1).
88    pub last_access: u64,
89    /// Size of the entry in bytes.
90    pub size: u64,
91}
92
93/// LRU eviction policy with frequency-based promotion.
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95#[non_exhaustive]
96pub struct LruPolicy {
97    /// Minimum access frequency required for promotion.
98    pub promote_threshold: u32,
99}
100
101impl LruPolicy {
102    /// Default access threshold for promotion.
103    pub const DEFAULT_THRESHOLD: u32 = 3;
104
105    /// Create a new policy with the given promotion threshold.
106    #[inline]
107    pub fn new(promote_threshold: u32) -> Self {
108        Self { promote_threshold }
109    }
110}
111
112impl Default for LruPolicy {
113    fn default() -> Self {
114        Self::new(Self::DEFAULT_THRESHOLD)
115    }
116}
117
118impl LruPolicy {
119    fn should_promote(&self, _key: u64, stats: &AccessStats) -> bool {
120        stats.frequency >= self.promote_threshold
121    }
122
123    fn eviction_candidate_per_tier(
124        &self,
125        _tier: usize,
126        entries: &FxHashMap<u64, CacheEntry>,
127        _tracker: &AccessTracker,
128        tier_lru: &IntrusiveLru<u64, ()>,
129    ) -> Option<u64> {
130        // O(1) fast path. Walk the tier's own LRU from coldest
131        // (tail) until we find a key that still lives in `entries`.
132        // Entries and the LRU are mutated in lockstep by
133        // TieredCache, so the first iterator step almost always
134        // yields the right answer; the loop only runs when a
135        // previous eviction race left a stale LRU entry.
136        for (key, _) in tier_lru.iter_coldest() {
137            if entries.contains_key(key) {
138                return Some(*key);
139            }
140        }
141        entries.keys().copied().next()
142    }
143}
144
145/// Errors that can occur during cache operations.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147#[non_exhaustive]
148pub enum CacheError {
149    /// The requested key does not exist in the cache.
150    KeyNotFound,
151    /// The entry is too large to fit in any tier.
152    EntryTooLarge,
153    /// Tier byte accounting overflowed or underflowed.
154    CapacityAccountingOverflow,
155}
156
157impl std::fmt::Display for CacheError {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        match self {
160            Self::KeyNotFound => write!(
161                f,
162                "Key not found in cache. Fix: verify the key was inserted before operating on it."
163            ),
164            Self::EntryTooLarge => write!(
165                f,
166                "Entry size exceeds the capacity of the largest tier. Fix: reduce the buffer size or increase the tier capacity."
167            ),
168            Self::CapacityAccountingOverflow => write!(
169                f,
170                "Tiered cache byte accounting overflowed. Fix: rebuild the cache or shard entries before continuing."
171            ),
172        }
173    }
174}
175
176impl std::error::Error for CacheError {}
177
178/// Generic tiered cache for GPU buffers.
179///
180/// Tracks hot/cold buffers using the built-in [`LruPolicy`].
181/// This is the vyre primitive that helix builds inference intelligence on top of.
182#[non_exhaustive]
183pub struct TieredCache {
184    pub(crate) tiers: Vec<CacheTier>,
185    pub(crate) tracker: AccessTracker,
186    pub(crate) policy: LruPolicy,
187    /// O(1) key → tier index. Eliminates the linear tier scan in `get`.
188    index: FxHashMap<u64, usize>,
189}
190
191impl TieredCache {
192    /// Create a new cache with the given tiers and a default [`LruPolicy`].
193    #[inline]
194    pub fn new(tiers: Vec<CacheTier>) -> Self {
195        match Self::try_new(tiers) {
196            Ok(cache) => cache,
197            Err(error) => {
198                tracing::error!(
199                    error = %error,
200                    "wgpu tiered cache tracker reservation failed; continuing with grow-on-use metadata"
201                );
202                Self::with_policy(Vec::new(), LruPolicy::default())
203            }
204        }
205    }
206
207    /// Fallible version of [`Self::new`].
208    ///
209    /// # Errors
210    ///
211    /// Returns [`vyre_driver::BackendError`] if cache access metadata cannot be
212    /// reserved.
213    #[inline]
214    pub fn try_new(tiers: Vec<CacheTier>) -> Result<Self, vyre_driver::BackendError> {
215        Self::try_with_policy(tiers, LruPolicy::default())
216    }
217}
218
219impl TieredCache {
220    /// Create a new cache with a custom LRU policy.
221    #[inline]
222    pub fn with_policy(tiers: Vec<CacheTier>, policy: LruPolicy) -> Self {
223        match Self::try_with_policy(tiers, policy) {
224            Ok(cache) => cache,
225            Err(error) => {
226                tracing::error!(
227                    error = %error,
228                    "wgpu tiered cache tracker reservation failed; continuing with grow-on-use metadata"
229                );
230                Self {
231                    tiers: Vec::new(),
232                    tracker: AccessTracker::new(),
233                    policy,
234                    index: FxHashMap::default(),
235                }
236            }
237        }
238    }
239
240    /// Fallible version of [`Self::with_policy`].
241    ///
242    /// # Errors
243    ///
244    /// Returns [`vyre_driver::BackendError`] if cache access metadata cannot be
245    /// reserved.
246    #[inline]
247    pub fn try_with_policy(
248        tiers: Vec<CacheTier>,
249        policy: LruPolicy,
250    ) -> Result<Self, vyre_driver::BackendError> {
251        Ok(Self {
252            tiers,
253            tracker: AccessTracker::try_new()?,
254            policy,
255            index: FxHashMap::default(),
256        })
257    }
258
259    /// Return a reference to the entry with the given key, if it exists.
260    #[inline]
261    pub fn get(&self, key: u64) -> Option<&CacheEntry> {
262        let &tier = self.index.get(&key)?;
263        self.tiers[tier].entries.get(&key)
264    }
265
266    /// Insert a new entry into the lowest tier that can fit it.
267    ///
268    /// # Errors
269    ///
270    /// Returns [`CacheError::EntryTooLarge`] when no tier can hold the entry.
271    #[inline]
272    pub fn insert(&mut self, key: u64, size: u64) -> Result<(), CacheError> {
273        if self.get(key).is_some() {
274            self.evict(key);
275        }
276        self.tracker.set_size(key, size);
277        self.insert_into_tier(key, size, 0)
278    }
279
280    /// Record an access for the given key.
281    #[inline]
282    pub fn record_access(&mut self, key: u64) {
283        if let Some(&tier_id) = self.index.get(&key) {
284            self.tracker.record(key);
285            // Touch the per-tier recency LRU so eviction keeps the
286            // hot key at the head and the coldest key at the tail.
287            self.tiers[tier_id].lru.touch(key);
288        }
289    }
290
291    /// Promote the entry to the next faster tier if the policy allows it.
292    ///
293    /// # Errors
294    ///
295    /// Returns [`CacheError::KeyNotFound`] when the key does not exist.
296    #[inline]
297    pub fn promote(&mut self, key: u64) -> Result<(), CacheError> {
298        let entry = self.get(key).copied().ok_or(CacheError::KeyNotFound)?;
299        let stats = self.tracker.stats(key).ok_or(CacheError::KeyNotFound)?;
300        if !self.policy.should_promote(key, &stats) {
301            return Ok(());
302        }
303        let target = entry
304            .tier
305            .checked_add(1)
306            .ok_or(CacheError::CapacityAccountingOverflow)?;
307        if target >= self.tiers.len() {
308            return Ok(());
309        }
310        let size = entry.size;
311        self.remove_entry(key);
312        self.move_into_tier(key, size, target, entry.tier)
313    }
314
315    /// Demote the entry to the next slower tier.
316    ///
317    /// # Errors
318    ///
319    /// Returns [`CacheError::KeyNotFound`] when the key does not exist.
320    #[inline]
321    pub fn demote(&mut self, key: u64) -> Result<(), CacheError> {
322        let entry = self.get(key).copied().ok_or(CacheError::KeyNotFound)?;
323        if entry.tier == 0 {
324            return Ok(());
325        }
326        let target = entry.tier - 1;
327        let size = entry.size;
328        self.remove_entry(key);
329        self.move_into_tier(key, size, target, entry.tier)
330    }
331
332    fn insert_into_tier(
333        &mut self,
334        key: u64,
335        size: u64,
336        mut start: usize,
337    ) -> Result<(), CacheError> {
338        while start < self.tiers.len() {
339            if size > self.tiers[start].capacity {
340                start += 1;
341                continue;
342            }
343            if self.make_room(start, size) {
344                self.tiers[start].used = checked_tier_used_add(self.tiers[start].used, size)?;
345                self.tiers[start].entries.insert(
346                    key,
347                    CacheEntry {
348                        key,
349                        size,
350                        tier: start,
351                    },
352                );
353                // Register the key in the tier's per-tier LRU so the
354                // fast-path eviction can pop its tail in O(1).
355                self.tiers[start].lru.ensure(key);
356                self.tiers[start].lru.touch(key);
357                self.index.insert(key, start);
358                return Ok(());
359            }
360            start += 1;
361        }
362        Err(CacheError::EntryTooLarge)
363    }
364
365    fn move_into_tier(
366        &mut self,
367        key: u64,
368        size: u64,
369        target: usize,
370        fallback: usize,
371    ) -> Result<(), CacheError> {
372        if self.make_room(target, size) {
373            self.tiers[target].used = checked_tier_used_add(self.tiers[target].used, size)?;
374            self.tiers[target].entries.insert(
375                key,
376                CacheEntry {
377                    key,
378                    size,
379                    tier: target,
380                },
381            );
382            self.tiers[target].lru.ensure(key);
383            self.tiers[target].lru.touch(key);
384            self.index.insert(key, target);
385            Ok(())
386        } else {
387            self.insert_into_tier(key, size, fallback)
388        }
389    }
390
391    fn make_room(&mut self, tier: usize, size: u64) -> bool {
392        loop {
393            let used = self.tiers[tier].used;
394            let cap = self.tiers[tier].capacity;
395            if used.checked_add(size).is_some_and(|total| total <= cap) {
396                return true;
397            }
398            // O(1) fast-path eviction using the tier's own recency
399            // LRU. The default `TierPolicy::eviction_candidate_per_tier`
400            // delegates to the slow path so custom policies still work;
401            // `LruPolicy` overrides it to pop the tier LRU tail
402            // directly.
403            let candidate = {
404                let tier_ref = &self.tiers[tier];
405                self.policy.eviction_candidate_per_tier(
406                    tier,
407                    &tier_ref.entries,
408                    &self.tracker,
409                    &tier_ref.lru,
410                )
411            };
412            if let Some(key) = candidate {
413                self.evict_from_tier(key, tier);
414            } else {
415                return false;
416            }
417        }
418    }
419
420    fn remove_entry(&mut self, key: u64) -> Option<CacheEntry> {
421        let &tier_id = self.index.get(&key)?;
422        let tier = &mut self.tiers[tier_id];
423        let entry = tier.entries.remove(&key)?;
424        tier.lru.remove(&key);
425        debit_tier_used(tier, entry.size);
426        self.index.remove(&key);
427        Some(entry)
428    }
429
430    fn evict(&mut self, key: u64) -> Option<CacheEntry> {
431        let &tier_id = self.index.get(&key)?;
432        let tier = &mut self.tiers[tier_id];
433        let entry = tier.entries.remove(&key)?;
434        tier.lru.remove(&key);
435        debit_tier_used(tier, entry.size);
436        self.index.remove(&key);
437        self.tracker.remove(key);
438        Some(entry)
439    }
440
441    /// Find and remove the coldest entry from the cache.
442    ///
443    /// This follows the LRU policy across all tiers, starting from the
444    /// lowest (coldest) tier. Returns the key of the evicted entry.
445    pub fn evict_coldest(&mut self) -> Option<u64> {
446        for (tier_idx, tier) in self.tiers.iter().enumerate() {
447            if let Some(key) = self.policy.eviction_candidate_per_tier(
448                tier_idx,
449                &tier.entries,
450                &self.tracker,
451                &tier.lru,
452            ) {
453                self.evict_from_tier(key, tier_idx);
454                return Some(key);
455            }
456        }
457        None
458    }
459
460    fn evict_from_tier(&mut self, key: u64, tier: usize) -> Option<CacheEntry> {
461        let tier = &mut self.tiers[tier];
462        let entry = tier.entries.remove(&key)?;
463        tier.lru.remove(&key);
464        debit_tier_used(tier, entry.size);
465        self.index.remove(&key);
466        self.tracker.remove(key);
467        Some(entry)
468    }
469}
470
471fn checked_tier_used_add(used: u64, size: u64) -> Result<u64, CacheError> {
472    used.checked_add(size)
473        .ok_or(CacheError::CapacityAccountingOverflow)
474}
475
476fn debit_tier_used(tier: &mut CacheTier, size: u64) {
477    match tier.used.checked_sub(size) {
478        Some(used) => {
479            tier.used = used;
480        }
481        None => {
482            tracing::error!(
483                tier = %tier.name,
484                used = tier.used,
485                removed_size = size,
486                "tiered cache byte accounting underflowed; repairing from live entries. Fix: investigate mismatched cache tier metadata."
487            );
488            tier.used = recompute_tier_used(tier);
489        }
490    }
491}
492
493fn recompute_tier_used(tier: &CacheTier) -> u64 {
494    let mut total = 0_u64;
495    for entry in tier.entries.values() {
496        total = match total.checked_add(entry.size) {
497            Some(next) => next,
498            None => {
499                tracing::error!(
500                    tier = %tier.name,
501                    "tiered cache byte accounting overflowed while repairing from live entries; pinning used bytes to u64::MAX."
502                );
503                return u64::MAX;
504            }
505        };
506    }
507    total
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    #[test]
515    fn tiered_cache_repairs_used_bytes_after_underflow_instead_of_panicking() {
516        let mut cache = TieredCache::new(vec![CacheTier::new("gpu", 128)]);
517        cache.insert(1, 64).expect("Fix: test insert must fit");
518        cache.tiers[0].used = 0;
519
520        let removed = cache
521            .evict(1)
522            .expect("Fix: corrupted entry should still evict");
523
524        assert_eq!(removed.size, 64);
525        assert_eq!(cache.tiers[0].used, 0);
526        assert!(cache.get(1).is_none());
527    }
528
529    #[test]
530    fn tiered_cache_source_has_no_release_path_panic_accounting() {
531        let source = include_str!("tiered_cache.rs");
532        let production = source
533            .split("#[cfg(test)]")
534            .next()
535            .expect("Fix: tiered cache production source must precede tests");
536        assert!(
537            !production.contains(concat!("panic", "!("))
538                && !production.contains(".unwrap_or_else("),
539            "Fix: tiered cache accounting must repair or return typed errors instead of aborting."
540        );
541        assert!(
542            production.contains("debit_tier_used")
543                && production.contains("recompute_tier_used")
544                && production.contains("repairing from live entries"),
545            "Fix: tiered cache underflow must be repaired from live entry metadata with a loud diagnostic."
546        );
547    }
548}