Skip to main content

kopitiam_runtime/
kv_cache.rs

1//! The key/value cache: what makes autoregressive decoding `O(1)` in
2//! attention width per new token instead of `O(n)`.
3//!
4//! # Why this exists at all
5//!
6//! Without a cache, generating token `N` means re-running the *entire*
7//! prefix `[0..N)` through every layer's attention from scratch, because
8//! attention at position `N` needs the keys and values of every earlier
9//! position. Generating a `T`-token completion this way costs `O(T^2)`
10//! total attention work. A KV cache instead remembers each layer's
11//! already-computed keys and values across calls, so decoding step `N`
12//! only computes the *new* token's K/V and reuses the rest — `O(T)` total.
13//! This is the difference between a usable CPU-side generation loop and one
14//! that is quadratically, unusably slow past a few dozen tokens.
15//!
16//! # Design: one growable tensor per layer, not a fixed ring buffer
17//!
18//! [`KvCache::append`] concatenates a layer's new K/V onto what is already
19//! cached ([`kopitiam_tensor::Tensor::concat`]), which reallocates and
20//! copies the whole accumulated tensor on every call. That is the
21//! "correct before fast" choice this crate's brief calls for: a
22//! pre-sized ring buffer that writes new positions in place without
23//! reallocating is a real optimization (and the natural next step once a
24//! scheduler/memory-manager crate exists to own buffer reuse — see the
25//! parent epic's Phase 2), but it is a performance change with no effect
26//! on *what* gets computed, so it does not belong in the first working
27//! version. [`KvCache::max_context`] is still enforced up front so a
28//! caller finds out generation has hit the model's context window as a
29//! clean error rather than silent truncation or an out-of-memory crash.
30
31use kopitiam_core::{Error, Result};
32use kopitiam_tensor::Tensor;
33
34/// One layer's accumulated keys and values, each shaped
35/// `[n_kv_heads, cached_len, head_dim]`. `None` until the first
36/// [`KvCache::append`] for that layer.
37struct LayerCache {
38    k: Option<Tensor>,
39    v: Option<Tensor>,
40}
41
42/// Per-layer, growable key/value cache for one generation session.
43///
44/// A fresh [`KvCache`] holds zero cached positions. Every call to
45/// [`crate::traits::Model::forward`] appends that call's new positions
46/// to every layer's cache (via [`KvCache::append`], one call per layer) and
47/// reads back the full accumulated K/V for attention. The cache is specific
48/// to one generation session — call [`KvCache::new`] again (or
49/// [`KvCache::clear`]) to start a new, unrelated prompt.
50pub struct KvCache {
51    layers: Vec<LayerCache>,
52    max_context: usize,
53}
54
55impl KvCache {
56    /// A fresh, empty cache for a model with `n_layers` transformer blocks
57    /// and a `max_context`-token context window (from
58    /// [`crate::config::QwenConfig::max_context`]).
59    pub fn new(n_layers: usize, max_context: usize) -> Self {
60        let layers = (0..n_layers).map(|_| LayerCache { k: None, v: None }).collect();
61        Self { layers, max_context }
62    }
63
64    /// Number of positions currently cached (the same for every layer,
65    /// since every [`KvCache::append`] call for a given forward pass
66    /// appends the same number of new positions to each layer in turn).
67    /// `0` for a fresh cache.
68    pub fn len(&self) -> usize {
69        self.layers
70            .first()
71            .and_then(|l| l.k.as_ref())
72            .map(|k| k.shape().dims()[1])
73            .unwrap_or(0)
74    }
75
76    pub fn is_empty(&self) -> bool {
77        self.len() == 0
78    }
79
80    pub fn max_context(&self) -> usize {
81        self.max_context
82    }
83
84    /// Drops every layer's cached K/V, returning this cache to its
85    /// freshly-[`KvCache::new`]d state so it can be reused for a new,
86    /// unrelated prompt without reallocating the outer `Vec`.
87    pub fn clear(&mut self) {
88        for layer in &mut self.layers {
89            layer.k = None;
90            layer.v = None;
91        }
92    }
93
94    /// Appends `new_k`/`new_v` (each `[n_kv_heads, new_len, head_dim]`) to
95    /// `layer`'s cache and returns the full accumulated `(k, v)`, each
96    /// `[n_kv_heads, cached_len + new_len, head_dim]`.
97    ///
98    /// # Errors
99    ///
100    /// [`Error::IndexOutOfBounds`] if appending would make the cache exceed
101    /// [`KvCache::max_context`] positions — the closest fit among
102    /// `kopitiam-core`'s error variants (this crate cannot add a new one;
103    /// see this crate's brief) for "position is past the end of the
104    /// context window", read as `index` = the position that would have
105    /// been reached and `len` = the window size that rejected it.
106    pub(crate) fn append(&mut self, layer: usize, new_k: Tensor, new_v: Tensor) -> Result<(Tensor, Tensor)> {
107        let new_len = new_k.shape().dims()[1];
108        let existing_len = self.layers[layer].k.as_ref().map(|k| k.shape().dims()[1]).unwrap_or(0);
109        let total_len = existing_len + new_len;
110        if total_len > self.max_context {
111            return Err(Error::IndexOutOfBounds { dim: 1, index: total_len, len: self.max_context });
112        }
113
114        let (full_k, full_v) = match (&self.layers[layer].k, &self.layers[layer].v) {
115            (Some(prev_k), Some(prev_v)) => {
116                (Tensor::concat(&[prev_k.clone(), new_k], 1)?, Tensor::concat(&[prev_v.clone(), new_v], 1)?)
117            }
118            _ => (new_k, new_v),
119        };
120
121        self.layers[layer].k = Some(full_k.clone());
122        self.layers[layer].v = Some(full_v.clone());
123        Ok((full_k, full_v))
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    fn kv(seq: usize, fill: f32) -> Tensor {
132        // shape [n_kv_heads=1, seq, head_dim=1] for simplicity.
133        Tensor::from_f32(vec![fill; seq], [1, seq, 1]).unwrap()
134    }
135
136    #[test]
137    fn a_fresh_cache_is_empty() {
138        let cache = KvCache::new(2, 128);
139        assert_eq!(cache.len(), 0);
140        assert!(cache.is_empty());
141    }
142
143    #[test]
144    fn append_accumulates_across_calls_and_reports_the_new_length() {
145        let mut cache = KvCache::new(1, 128);
146        let (k, _v) = cache.append(0, kv(3, 1.0), kv(3, 2.0)).unwrap();
147        assert_eq!(k.shape().dims(), &[1, 3, 1]);
148        assert_eq!(cache.len(), 3);
149
150        let (k, v) = cache.append(0, kv(1, 9.0), kv(1, 9.0)).unwrap();
151        assert_eq!(k.shape().dims(), &[1, 4, 1]);
152        assert_eq!(cache.len(), 4);
153        // The earlier three positions are still there, unmodified, ahead
154        // of the newly appended one.
155        assert_eq!(k.to_vec_f32().unwrap(), vec![1.0, 1.0, 1.0, 9.0]);
156        assert_eq!(v.to_vec_f32().unwrap(), vec![2.0, 2.0, 2.0, 9.0]);
157    }
158
159    #[test]
160    fn exceeding_max_context_is_rejected() {
161        let mut cache = KvCache::new(1, 4);
162        cache.append(0, kv(4, 1.0), kv(4, 1.0)).unwrap();
163        assert!(matches!(
164            cache.append(0, kv(1, 1.0), kv(1, 1.0)),
165            Err(Error::IndexOutOfBounds { .. })
166        ));
167    }
168
169    #[test]
170    fn clear_resets_every_layer_to_empty() {
171        let mut cache = KvCache::new(2, 128);
172        cache.append(0, kv(3, 1.0), kv(3, 1.0)).unwrap();
173        cache.append(1, kv(3, 1.0), kv(3, 1.0)).unwrap();
174        cache.clear();
175        assert_eq!(cache.len(), 0);
176        // A fresh append after clear() starts from zero again, not from 3.
177        let (k, _) = cache.append(0, kv(2, 1.0), kv(2, 1.0)).unwrap();
178        assert_eq!(k.shape().dims(), &[1, 2, 1]);
179    }
180
181    #[test]
182    fn different_layers_are_independent() {
183        let mut cache = KvCache::new(2, 128);
184        cache.append(0, kv(3, 1.0), kv(3, 1.0)).unwrap();
185        assert_eq!(cache.len(), 3); // len() reads layer 0
186        // Layer 1 has never been appended to; appending 2 there should
187        // start from zero, independent of layer 0's length.
188        let (k, _) = cache.append(1, kv(2, 5.0), kv(2, 5.0)).unwrap();
189        assert_eq!(k.shape().dims(), &[1, 2, 1]);
190    }
191}