Skip to main content

cubecl_server/
metadata_cache.rs

1//! Cache of per-launch **metadata info buffers** (kernel shapes, strides and
2//! scalars) keyed by the exact info bytes they were built from (see
3//! [`InfoCacheKey`]).
4//!
5//! Info depends only on shapes and scalar arguments — not on the tensor data
6//! pointers, which are separate kernel arguments — so two launches with identical
7//! info, even of different kernels, can share one read-only device buffer. Caching those
8//! buffers removes a fresh allocation and a host→device copy from every launch,
9//! and it is what makes hardware graph capture clean: a stable-shape decode's
10//! launches all hit warm buffers, so nothing is allocated or copied inside the
11//! capture window (a device allocation mid-capture is illegal).
12//!
13//! [`MetadataCachePolicy`] owns every decision: whether a given metadata info is
14//! worth caching at all (small enough, and the cache enabled) and how many
15//! entries to keep before the least-recently-used one is evicted. Its two knobs,
16//! `max_entries` and `max_cached_size`, are tuned by the backend. The current
17//! [`CacheMode`] feeds into those same decisions — during graph capture the
18//! policy caches everything and evicts nothing — so the runtime never has to
19//! special-case capture: it just asks the policy and follows the answer.
20//!
21//! A captured graph records the device pointer of every info buffer its launches
22//! touch, so those buffers must outlive the graph. While a capture is recording,
23//! every entry the launch path touches — a fresh miss *or* a hit on a buffer
24//! built earlier in normal operation — is **pinned** to the graph being built.
25//! Pinned entries are never evicted, so the pointer a graph recorded stays valid
26//! for its whole life even if the cache would otherwise reclaim it.
27//! [`capture_commit`](MetadataInfoCache::capture_commit) seals those pins under
28//! the graph's id at `end_capture`, and
29//! [`graph_release`](MetadataInfoCache::graph_release) drops them when the graph
30//! is destroyed, freeing any buffer no other live graph still pins. Pins are
31//! refcounted, so an info buffer shared by two graphs survives until both are
32//! gone.
33//!
34//! A launch asks [`lookup`](MetadataInfoCache::lookup) and follows the
35//! [`Lookup`] it gets back: a hit is the buffer, and a miss says whether the
36//! one it is about to build is worth [`store`](MetadataInfoCache::store)ing.
37//! Info the policy will not cache is built and forgotten, so a value is only
38//! ever cloned into the cache when it will actually be kept, and the cache
39//! never learns a key it would not have used.
40
41use alloc::vec::Vec;
42use cubecl_environment::collections::{HashMap, HashSet};
43
44use crate::id::GraphId;
45
46/// Identifies a cached info buffer: the exact metadata words (shapes, strides,
47/// scalars) the buffer was built from — nothing else.
48///
49/// The kernel is deliberately **not** part of the key. An info buffer is
50/// read-only metadata whose bytes are fully determined by these words, so two
51/// launches (even of different kernels) with identical info want a byte-identical
52/// buffer and can safely share one. Keying on the words alone means a hit needs
53/// only the caller's borrowed slice — no owned key to clone on the hot path — and
54/// buffers are reused across kernels, not just within one.
55pub type InfoCacheKey = Vec<u64>;
56
57/// How the cache should behave for the current launch. Fed into the
58/// [`MetadataCachePolicy`], so it shapes every decision, not just a setting.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum CacheMode {
61    /// Normal operation: cache only info small enough to be worth it, and evict
62    /// the least-recently-used entry once the cache is full.
63    Normal,
64    /// Graph-capture warmup/recording: cache every info buffer regardless of
65    /// size and never evict, so the capture window finds every buffer warm and
66    /// no entry a recorded launch depends on is dropped mid-capture.
67    Capture,
68}
69
70/// Every caching decision, in one place. Given an info's size and the current
71/// [`CacheMode`], the policy decides whether that info is cached at all
72/// ([`should_cache`](Self::should_cache)) and, through its
73/// [`capacity`](Self::capacity), when the cache must evict to stay bounded. How
74/// those decisions are carried out (lookups, eviction) is the cache's job and
75/// differs per runtime; *what* to do lives here.
76///
77/// `max_entries` and `max_cached_size` are the backend-tunable knobs.
78#[derive(Debug, Clone, Copy)]
79pub struct MetadataCachePolicy {
80    max_entries: usize,
81    max_cached_size: usize,
82    mode: CacheMode,
83}
84
85impl Default for MetadataCachePolicy {
86    fn default() -> Self {
87        // 4096 entries, ~256 metadata words (2048 bytes): a handful of tensors'
88        // shapes and strides. Larger infos (many tensors / high rank) are
89        // cheaper to rebuild than to hash and look up.
90        Self::new(4096, 2048)
91    }
92}
93
94impl MetadataCachePolicy {
95    /// Build a policy in [`CacheMode::Normal`]. `max_entries` caps the number of
96    /// cached entries; `max_cached_size` (bytes) is the largest info still worth
97    /// caching in normal operation.
98    pub fn new(max_entries: usize, max_cached_size: usize) -> Self {
99        Self {
100            max_entries,
101            max_cached_size,
102            mode: CacheMode::Normal,
103        }
104    }
105
106    /// Switch the mode driving the policy's decisions (see [`CacheMode`]).
107    pub fn mode(&mut self, mode: CacheMode) {
108        self.mode = mode;
109    }
110
111    /// Whether an info of `size` bytes should go through the cache at all. In
112    /// [`CacheMode::Normal`] a too-large info (or a disabled cache) is skipped:
113    /// the runtime should create it directly, without a lookup or a store. In
114    /// [`CacheMode::Capture`] everything is cached so the capture window stays
115    /// warm.
116    pub fn should_cache(&self, size: usize) -> bool {
117        match self.mode {
118            CacheMode::Capture => true,
119            CacheMode::Normal => self.max_entries > 0 && size <= self.max_cached_size,
120        }
121    }
122
123    /// The entry bound the cache must hold to, or `None` when unbounded. In
124    /// [`CacheMode::Capture`] the cache is unbounded and never evicts (dropping
125    /// an entry could free a buffer a recorded launch still needs); in
126    /// [`CacheMode::Normal`] it is capped at `max_entries`.
127    pub fn capacity(&self) -> Option<usize> {
128        match self.mode {
129            CacheMode::Capture => None,
130            CacheMode::Normal => Some(self.max_entries),
131        }
132    }
133
134    /// Whether entries touched right now must be pinned to the graph being
135    /// captured. True in [`CacheMode::Capture`]: a graph records the device
136    /// pointer of every info buffer its launches touch, so those entries must
137    /// not be evicted for the graph's lifetime — even after the cache returns to
138    /// [`CacheMode::Normal`], and even if the buffer lives in the regular
139    /// (non-persistent) pool and so is not otherwise retained by the graph.
140    pub fn pins_entries(&self) -> bool {
141        matches!(self.mode, CacheMode::Capture)
142    }
143}
144
145#[derive(Debug)]
146struct Entry<V> {
147    value: V,
148    /// Clock tick of this entry's most recent use (insert or hit); the smallest
149    /// marks the least-recently-used entry, evicted first.
150    last_used: u64,
151    /// Number of live captured graphs that pinned this entry. While `> 0` the
152    /// entry is never evicted, so every graph that recorded this info buffer
153    /// keeps replaying against the exact buffer it captured. Dropped back toward
154    /// zero as those graphs are destroyed (see [`MetadataInfoCache::graph_release`]).
155    locks: u32,
156}
157
158/// What the cache says about an info buffer a launch is about to need.
159///
160/// Two answers, because a miss still has to say whether the result is worth
161/// keeping: an info too large for the policy is built and forgotten, and the
162/// cache never learns a key it would not have used.
163pub enum Lookup<V> {
164    /// The buffer is already cached, and this is it.
165    Hit(V),
166    /// Nothing is cached for it; build the buffer.
167    Build {
168        /// Whether to hand the result back through [`MetadataInfoCache::store`].
169        store: bool,
170    },
171}
172
173/// A cache of metadata info buffers keyed by [`InfoCacheKey`], generic over the
174/// value type `V` (a device buffer handle for a compute backend). Evicting an
175/// entry drops its `V`; when `V` is a memory handle that returns the buffer to
176/// the pool, so the cache never pins more device memory than its live entries.
177///
178/// All policy is delegated to [`MetadataCachePolicy`]; this type only carries
179/// out its decisions. See the [module docs](self) for the intended
180/// [`lookup`](MetadataInfoCache::lookup) is the entry point a launch wants;
181/// the steps it composes are public for the paths that need them apart.
182#[derive(Debug)]
183pub struct MetadataInfoCache<V> {
184    entries: HashMap<InfoCacheKey, Entry<V>>,
185    policy: MetadataCachePolicy,
186    /// Monotonic logical clock; advanced once per [`get`](Self::get).
187    clock: u64,
188    /// Keys touched during the in-progress capture, each pinned exactly once,
189    /// awaiting association with a [`GraphId`] at
190    /// [`capture_commit`](Self::capture_commit) (or release at
191    /// [`capture_discard`](Self::capture_discard) if the capture is abandoned).
192    pending: HashSet<InfoCacheKey>,
193    /// The keys each live captured graph pinned, so
194    /// [`graph_release`](Self::graph_release) can drop that graph's locks when it
195    /// is destroyed.
196    graph_locks: HashMap<GraphId, Vec<InfoCacheKey>>,
197}
198
199impl<V> MetadataInfoCache<V> {
200    /// Create a cache governed by `policy`.
201    pub fn new(policy: MetadataCachePolicy) -> Self {
202        Self {
203            entries: HashMap::new(),
204            policy,
205            clock: 0,
206            pending: HashSet::new(),
207            graph_locks: HashMap::new(),
208        }
209    }
210
211    /// Switch the [`CacheMode`] driving the policy (see
212    /// [`MetadataCachePolicy::mode`]).
213    pub fn mode(&mut self, mode: CacheMode) {
214        self.policy.mode(mode);
215    }
216
217    /// Whether an info of `size` bytes should be cached — see
218    /// [`MetadataCachePolicy::should_cache`]. When `false`, skip the cache
219    /// entirely and create the value directly.
220    pub fn should_cache(&self, size: usize) -> bool {
221        self.policy.should_cache(size)
222    }
223
224    /// Number of entries currently cached.
225    pub fn len(&self) -> usize {
226        self.entries.len()
227    }
228
229    /// Whether the cache holds no entries.
230    pub fn is_empty(&self) -> bool {
231        self.entries.is_empty()
232    }
233
234    /// Drop every cached entry (releasing every held `V`).
235    pub fn clear(&mut self) {
236        self.entries.clear();
237    }
238
239    /// Drop every entry no live graph pins (releasing their held `V`s),
240    /// keeping the pinned ones — their device pointers are recorded inside
241    /// captured graphs that will replay against them.
242    ///
243    /// The explicit-cleanup hook. Cached info buffers are live slices in the
244    /// dynamic memory pools, and a pool rebuild
245    /// ([`MemoryManagement::install_pools`](crate::memory_management::MemoryManagement::install_pools))
246    /// refuses while anything is alive in them — without this, the first
247    /// launches on a stream would make its pools permanently
248    /// un-reconfigurable.
249    pub fn clear_unpinned(&mut self) {
250        self.entries.retain(|_, entry| entry.locks > 0);
251    }
252}
253
254impl<V: Clone> MetadataInfoCache<V> {
255    /// Look up `key`, advancing the logical clock by one tick. On a hit the
256    /// entry is marked used — its recency resets — and the value is cloned out.
257    /// During a capture ([`pins_entries`](MetadataCachePolicy::pins_entries)) a
258    /// hit also pins the entry to the graph being recorded, once per capture.
259    ///
260    /// Call only when [`should_cache`](Self::should_cache) is `true`; on a miss
261    /// create the value and hand it to [`insert`](Self::insert).
262    /// What to do about the info `words`, under `mode`.
263    ///
264    /// The one entry point a launch needs: it sets the mode, asks the policy
265    /// whether this info is worth caching at all, and looks it up if so. A
266    /// caller that reached for [`mode`](Self::mode), [`should_cache`] and
267    /// [`get`] in sequence was spelling this out, and every backend spelled it
268    /// out the same way.
269    ///
270    /// The words are borrowed here so a hit clones nothing; a miss hands them
271    /// to [`store`](Self::store) by value, as the key.
272    ///
273    /// [`should_cache`]: Self::should_cache
274    /// [`get`]: Self::get
275    pub fn lookup(&mut self, mode: CacheMode, words: &[u64]) -> Lookup<V> {
276        self.mode(mode);
277        if !self.should_cache(core::mem::size_of_val(words)) {
278            return Lookup::Build { store: false };
279        }
280        match self.get(words) {
281            Some(value) => Lookup::Hit(value),
282            None => Lookup::Build { store: true },
283        }
284    }
285
286    /// Record `value` as the buffer for `words`, after a [`Lookup::Build`]
287    /// that asked for it.
288    pub fn store(&mut self, words: InfoCacheKey, value: V) {
289        self.insert(words, value);
290    }
291
292    /// Look up `key`, advancing the logical clock by one tick. On a hit the
293    /// entry is marked used — its recency resets — and the value is cloned out.
294    /// During a capture ([`pins_entries`](MetadataCachePolicy::pins_entries)) a
295    /// hit also pins the entry to the graph being recorded, once per capture.
296    ///
297    /// [`lookup`](Self::lookup) composes this with the policy check ahead of
298    /// it; reach for this directly only when that check has already been made.
299    pub fn get(&mut self, key: &[u64]) -> Option<V> {
300        self.clock += 1;
301        let clock = self.clock;
302        // Pin once per capture. Check membership first (borrowed, no alloc); only
303        // when this is a fresh pin do we materialize an owned key for `pending`.
304        // `pending`/`entries` are disjoint fields, so both borrows coexist.
305        let pin = self.policy.pins_entries() && !self.pending.contains(key);
306        let entry = self.entries.get_mut(key)?;
307        entry.last_used = clock;
308        if pin {
309            self.pending.insert(key.to_vec());
310            entry.locks += 1;
311        }
312        Some(entry.value.clone())
313    }
314
315    /// Store `value` under `key` after a [`get`](Self::get) miss. During a
316    /// capture the new entry is pinned to the graph being recorded; otherwise it
317    /// evicts the least-recently-used *unpinned* entry first if the cache is at
318    /// [capacity](MetadataCachePolicy::capacity) (a capture is unbounded and
319    /// never evicts).
320    ///
321    /// Because the runtime only reaches here on a
322    /// [`should_cache`](Self::should_cache) miss, the value it clones in is
323    /// always kept — no wasted clones.
324    pub fn insert(&mut self, key: InfoCacheKey, value: V) {
325        if let Some(capacity) = self.policy.capacity() {
326            if capacity == 0 {
327                return;
328            }
329            if self.entries.len() >= capacity {
330                self.evict_least_recently_used();
331            }
332        }
333        // A miss during capture pins the fresh entry to the graph being recorded.
334        let locks = if self.policy.pins_entries() {
335            self.pending.insert(key.clone());
336            1
337        } else {
338            0
339        };
340        self.entries.insert(
341            key,
342            Entry {
343                value,
344                last_used: self.clock,
345                locks,
346            },
347        );
348    }
349
350    /// Seal the entries pinned during the just-finished capture under `graph`,
351    /// so [`graph_release`](Self::graph_release) can drop their locks when the
352    /// graph is destroyed. Call once from `end_capture` after the graph is built.
353    pub fn capture_commit(&mut self, graph: GraphId) {
354        if !self.pending.is_empty() {
355            let keys = self.pending.drain().collect();
356            self.graph_locks.insert(graph, keys);
357        }
358    }
359
360    /// Drop the locks taken during a capture that was abandoned (never turned
361    /// into a graph), so the touched entries become evictable again. The entries
362    /// themselves stay as ordinary cached values.
363    pub fn capture_discard(&mut self) {
364        let keys: Vec<_> = self.pending.drain().collect();
365        for key in keys {
366            if let Some(entry) = self.entries.get_mut(&key) {
367                entry.locks = entry.locks.saturating_sub(1);
368            }
369        }
370    }
371
372    /// Release the entries a destroyed `graph` pinned. An entry no other live
373    /// graph still pins is removed, freeing its buffer — this is how the cache
374    /// is cleaned up when a graph is destroyed.
375    pub fn graph_release(&mut self, graph: GraphId) {
376        let Some(keys) = self.graph_locks.remove(&graph) else {
377            return;
378        };
379        for key in keys {
380            let drop_entry = match self.entries.get_mut(&key) {
381                Some(entry) => {
382                    entry.locks = entry.locks.saturating_sub(1);
383                    entry.locks == 0
384                }
385                None => false,
386            };
387            if drop_entry {
388                self.entries.remove(&key);
389            }
390        }
391    }
392
393    /// Drop the entry whose last use is oldest (largest "time since last use"),
394    /// skipping entries pinned to a live graph.
395    fn evict_least_recently_used(&mut self) {
396        let victim = self
397            .entries
398            .iter()
399            .filter(|(_, entry)| entry.locks == 0)
400            .min_by_key(|(_, entry)| entry.last_used)
401            .map(|(key, _)| key.clone());
402        if let Some(key) = victim {
403            self.entries.remove(&key);
404        }
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    fn key(n: u64) -> InfoCacheKey {
413        alloc::vec![n]
414    }
415
416    fn cache(max_entries: usize) -> MetadataInfoCache<u32> {
417        MetadataInfoCache::new(MetadataCachePolicy::new(max_entries, 64))
418    }
419
420    #[test]
421    fn normal_mode_gates_on_size() {
422        let cache = cache(8);
423        assert!(cache.should_cache(64), "within max_cached_size");
424        assert!(!cache.should_cache(65), "over max_cached_size");
425    }
426
427    /// An explicit cleanup drops every entry except the ones live graphs pin:
428    /// the unpinned buffers are what keeps the dynamic pools from ever being
429    /// rebuilt, and the pinned ones are what recorded graphs replay against.
430    #[test]
431    fn clear_unpinned_keeps_only_graph_pinned_entries() {
432        let mut cache = cache(8);
433        cache.insert(key(0), 0);
434
435        // A capture touches entry 1 (fresh) and seals it under a graph.
436        cache.mode(CacheMode::Capture);
437        cache.insert(key(1), 1);
438        cache.capture_commit(GraphId::new());
439        cache.mode(CacheMode::Normal);
440
441        cache.insert(key(2), 2);
442        assert_eq!(cache.len(), 3);
443
444        cache.clear_unpinned();
445        assert!(cache.get(&key(0)).is_none(), "unpinned entries are dropped");
446        assert!(cache.get(&key(2)).is_none(), "unpinned entries are dropped");
447        assert_eq!(
448            cache.get(&key(1)),
449            Some(1),
450            "a graph-pinned entry survives: its pointer is recorded in the graph"
451        );
452    }
453
454    #[test]
455    fn capture_mode_caches_any_size() {
456        let mut cache = cache(8);
457        cache.mode(CacheMode::Capture);
458        assert!(cache.should_cache(10_000));
459    }
460
461    #[test]
462    fn hit_returns_value_and_records_use() {
463        let mut cache = cache(8);
464        let k = key(1);
465        assert!(cache.get(&k).is_none());
466        cache.insert(k.clone(), 42);
467        assert_eq!(cache.get(&k), Some(42));
468    }
469
470    #[test]
471    fn normal_mode_evicts_least_recently_used() {
472        // Capacity 2: fill it, keep entry 0 hot so entry 1 is the LRU, then
473        // insert a third entry and expect entry 1 evicted, entry 0 kept.
474        let mut cache = cache(2);
475        cache.insert(key(0), 0);
476        cache.insert(key(1), 1);
477
478        // Touch entry 0 so entry 1 becomes least-recently-used.
479        assert_eq!(cache.get(&key(0)), Some(0));
480
481        cache.insert(key(2), 2);
482        assert_eq!(cache.len(), 2, "stayed at capacity");
483        assert_eq!(cache.get(&key(0)), Some(0), "recently used entry kept");
484        assert!(cache.get(&key(1)).is_none(), "least-recently-used evicted");
485        assert_eq!(cache.get(&key(2)), Some(2), "new entry cached");
486    }
487
488    #[test]
489    fn capture_mode_is_unbounded() {
490        let mut cache = cache(2);
491        cache.mode(CacheMode::Capture);
492        for i in 0..10 {
493            cache.insert(key(i), i as u32);
494        }
495        assert_eq!(cache.len(), 10, "capture must cache every buffer");
496    }
497
498    #[test]
499    fn zero_capacity_disables_caching() {
500        let mut cache = cache(0);
501        assert!(!cache.should_cache(8), "disabled cache never caches");
502        cache.insert(key(0), 0);
503        assert!(cache.is_empty());
504    }
505
506    /// Capture one entry into a graph, then thrash the cache well past capacity
507    /// in normal mode: the pinned entry must survive (its buffer is still
508    /// replayed against), and only after the graph is released can it be evicted.
509    #[test]
510    fn pinned_entry_survives_eviction_until_graph_released() {
511        let mut cache = cache(2);
512        let graph = GraphId::new();
513
514        // Capture pins entry 0.
515        cache.mode(CacheMode::Capture);
516        cache.insert(key(0), 0);
517        cache.capture_commit(graph);
518
519        // Back to normal: flood the cache far past capacity (get-then-insert,
520        // as the launch path does, so recency advances per entry).
521        cache.mode(CacheMode::Normal);
522        for i in 1..20 {
523            cache.get(&key(i));
524            cache.insert(key(i), i as u32);
525        }
526        assert_eq!(cache.get(&key(0)), Some(0), "pinned entry never evicted");
527
528        // Destroying the graph releases the pin and drops the entry.
529        cache.graph_release(graph);
530        assert!(cache.get(&key(0)).is_none(), "released entry cleaned up");
531    }
532
533    /// A cache hit during capture (on a buffer built earlier in normal mode)
534    /// must pin that entry — otherwise later eviction would free a buffer the
535    /// graph still replays against. This is the finding-#1 regression guard.
536    #[test]
537    fn capture_hit_on_normal_entry_pins_it() {
538        let mut cache = cache(2);
539        let graph = GraphId::new();
540
541        // Built in normal mode (e.g. regular pool), cached.
542        cache.insert(key(0), 0);
543
544        // Captured: the launch hits the existing entry, which must pin it.
545        cache.mode(CacheMode::Capture);
546        assert_eq!(cache.get(&key(0)), Some(0));
547        cache.capture_commit(graph);
548
549        cache.mode(CacheMode::Normal);
550        for i in 1..20 {
551            cache.get(&key(i));
552            cache.insert(key(i), i as u32);
553        }
554        assert_eq!(cache.get(&key(0)), Some(0), "hit-pinned entry survives");
555    }
556
557    /// An entry shared by two graphs stays until both are destroyed (refcount).
558    #[test]
559    fn pin_is_refcounted_across_graphs() {
560        let mut cache = cache(8);
561        let (g1, g2) = (GraphId::new(), GraphId::new());
562
563        cache.mode(CacheMode::Capture);
564        cache.insert(key(0), 0);
565        cache.capture_commit(g1);
566
567        // Second capture hits the same entry.
568        assert_eq!(cache.get(&key(0)), Some(0));
569        cache.capture_commit(g2);
570
571        cache.mode(CacheMode::Normal);
572        cache.graph_release(g1);
573        assert_eq!(cache.get(&key(0)), Some(0), "still pinned by g2");
574        cache.graph_release(g2);
575        assert!(cache.get(&key(0)).is_none(), "gone once both released");
576    }
577
578    /// An abandoned capture releases its pins but keeps the entries as ordinary
579    /// cached values (they become evictable again).
580    #[test]
581    fn capture_discard_unpins_without_removing() {
582        let mut cache = cache(8);
583        cache.mode(CacheMode::Capture);
584        cache.insert(key(0), 0);
585        cache.capture_discard();
586
587        // Entry still present, but now evictable: flood past capacity in normal.
588        cache.mode(CacheMode::Normal);
589        assert_eq!(cache.get(&key(0)), Some(0), "kept as a normal entry");
590        for i in 1..20 {
591            cache.get(&key(i));
592            cache.insert(key(i), i as u32);
593        }
594        assert!(cache.get(&key(0)).is_none(), "no longer pinned, evicted");
595    }
596}