Skip to main content

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