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}