Skip to main content

combs_models/
kv.rs

1//! KV cache abstraction.
2//!
3//! Phase 2 moves attention *behind* the cache: [`KVCache::attention`] appends
4//! new K/V for a layer and computes causal attention against the full cached
5//! window in one call, so the cache implementation owns the K/V layout.
6//!
7//! Two implementations ship:
8//! - [`ContiguousKVCache`] — per-layer contiguous K/V tensors, `cat`-extended
9//!   each step (Phase 1 behavior, kept as the cross-validation baseline).
10//! - [`PagedKVCache`] — MLC-style paged arena: fixed-size pages per layer, a
11//!   page table and a free-page allocator. Steady-state decode writes one
12//!   page slot and gathers the active pages; no per-token O(seq) rewrite of
13//!   the whole cache.
14
15use burn::tensor::ops::AttentionModuleOptions;
16use burn::tensor::{Bool, Device, Int, Tensor, TensorData, activation::softmax, backend::Backend};
17
18use crate::matmul::safe_matmul;
19
20/// Whether to prefer burn's fused (flash) attention kernel over the manual
21/// scores→mask→softmax→matmul path. Controlled by `COMBS_ATTN=flash|manual`
22/// (default `flash`); read once per process.
23fn flash_enabled() -> bool {
24    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
25    *ENABLED.get_or_init(|| {
26        std::env::var("COMBS_ATTN").map(|v| v != "manual").unwrap_or(true)
27    })
28}
29
30/// Which [`KVCache`] implementation to instantiate.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum CacheKind {
33    /// Per-layer contiguous K/V, `cat` per step (baseline).
34    Contiguous,
35    /// Paged arena with a page table (default).
36    Paged,
37}
38
39/// Configuration for a KV cache instance.
40#[derive(Debug, Clone, Copy)]
41pub struct CacheConfig {
42    /// Maximum number of cached positions (arena capacity).
43    pub max_seq_len: usize,
44    /// Tokens per page (paged cache only).
45    pub page_size: usize,
46    /// Implementation to use.
47    pub kind: CacheKind,
48}
49
50impl CacheConfig {
51    /// Default page size (MLC uses 16 as well).
52    pub const DEFAULT_PAGE_SIZE: usize = 16;
53
54    /// Paged cache with the default page size.
55    pub fn paged(max_seq_len: usize) -> Self {
56        CacheConfig {
57            max_seq_len,
58            page_size: Self::DEFAULT_PAGE_SIZE,
59            kind: CacheKind::Paged,
60        }
61    }
62
63    /// Contiguous (baseline) cache.
64    pub fn contiguous(max_seq_len: usize) -> Self {
65        CacheConfig {
66            max_seq_len,
67            page_size: Self::DEFAULT_PAGE_SIZE,
68            kind: CacheKind::Contiguous,
69        }
70    }
71
72    /// Number of pages in the arena.
73    pub fn num_pages(&self) -> usize {
74        self.max_seq_len.div_ceil(self.page_size)
75    }
76}
77
78/// Per-layer key/value storage that owns the attention computation.
79///
80/// Tensors are 4-D `[batch=1, heads, seq, head_dim]`; `q` has `n_q` heads
81/// while `k`/`v` have `n_kv` heads (GQA expansion happens inside the
82/// implementation, as does causal masking).
83pub trait KVCache<B: Backend>: Send {
84    /// Appends `seq` new positions of K/V for `layer` and computes attention
85    /// of `q` against the full cached window (past + new).
86    ///
87    /// `pos` is the absolute position of the first new token and must equal
88    /// [`KVCache::seq_len`] on entry (dense contiguous appends). `scale` is
89    /// the attention logit scale (`1/sqrt(head_dim)`). Returns the attention
90    /// output `[1, n_q, seq, head_dim]`.
91    fn attention(
92        &mut self,
93        layer: usize,
94        q: Tensor<B, 4>,
95        k: Tensor<B, 4>,
96        v: Tensor<B, 4>,
97        pos: usize,
98        scale: f64,
99    ) -> Tensor<B, 4>;
100
101    /// Total cached sequence length.
102    fn seq_len(&self) -> usize;
103
104    /// Rolls back the last `n` cached tokens, returning how many were
105    /// actually dropped. Caches that cannot roll back (the contiguous
106    /// baseline) return 0 — callers gate prefix reuse on a nonzero result.
107    fn popn(&mut self, n: usize) -> usize {
108        let _ = n;
109        0
110    }
111
112    /// Drops all cached state (session reset).
113    fn reset(&mut self);
114
115    /// Pages currently allocated to the sequence (paged cache only).
116    fn pages_used(&self) -> Option<usize> {
117        None
118    }
119}
120
121/// Repeats each KV head `n_rep` times consecutively (GQA → MHA expansion):
122/// `[b, nkv, s, d] -> [b, nkv * n_rep, s, d]`.
123fn repeat_kv<B: Backend>(x: Tensor<B, 4>, n_rep: usize) -> Tensor<B, 4> {
124    if n_rep == 1 {
125        return x;
126    }
127    let [b, nkv, s, d] = x.dims();
128    x.unsqueeze_dim::<5>(2)
129        .expand([b, nkv, n_rep, s, d])
130        .reshape([b, nkv * n_rep, s, d])
131}
132
133/// Standard scaled dot-product causal attention over a fully materialized
134/// K/V window.
135///
136/// `q`: `[1, n_q, seq, d]`; `k`/`v`: `[1, n_kv, total, d]`; `pos` is the
137/// absolute position of the first query token. Returns `[1, n_q, seq, d]`.
138///
139/// Prefers burn's fused flash-attention kernel (one kernel, no materialized
140/// `[seq, total]` scores matrix) when the scale is the default
141/// `1/sqrt(head_dim)`; the causal mode is bottom-right aligned, which is
142/// exactly the `pos`-offset masking the manual path applies, so chunked
143/// prefill (`pos > 0`) is covered as well. Set `COMBS_ATTN=manual` to force
144/// the reference path.
145fn attend<B: Backend>(
146    q: Tensor<B, 4>,
147    k: Tensor<B, 4>,
148    v: Tensor<B, 4>,
149    pos: usize,
150    scale: f64,
151) -> Tensor<B, 4> {
152    let device = q.device();
153    let [_, n_q, seq, d] = q.dims();
154    let [_, n_kv, total, _] = k.dims();
155    let n_rep = n_q / n_kv;
156    let k = repeat_kv(k, n_rep);
157    let v = repeat_kv(v, n_rep);
158
159    let default_scale = 1.0 / (d as f64).sqrt();
160    if flash_enabled() && (scale - default_scale).abs() < 1e-12 {
161        return burn::tensor::module::attention(
162            q,
163            k,
164            v,
165            None,
166            None,
167            AttentionModuleOptions {
168                scale: None,
169                softcap: None,
170                // Decode (seq == 1) needs no mask: a single query at the end
171                // of the window attends to everything cached.
172                is_causal: seq > 1,
173            },
174        );
175    }
176
177    // Reference path: explicit scores, causal mask, softmax, P@V.
178    let scores = q.matmul(k.transpose()).mul_scalar(scale);
179    let scores = if seq > 1 {
180        // Causal mask: query at global position p attends keys <= p.
181        let q_pos =
182            Tensor::<B, 1, Int>::arange((pos as i64)..((pos + seq) as i64), &device)
183                .reshape([seq, 1]);
184        let k_pos = Tensor::<B, 1, Int>::arange(0..(total as i64), &device).reshape([1, total]);
185        let forbidden: Tensor<B, 2, Bool> = k_pos.greater(q_pos);
186        let mask = forbidden
187            .unsqueeze_dims::<4>(&[0, 1])
188            .expand([1, n_q, seq, total]);
189        scores.mask_fill(mask, -1e30f32)
190    } else {
191        scores // single query attends to everything cached
192    };
193
194    // `safe_matmul` for the P@V product: with a >= 512-token window this
195    // shape (M = seq, K = total) enters the broken wgpu/Metal matmul region.
196    safe_matmul(softmax(scores, 3), v)
197}
198
199/// Simple contiguous cache: stores one K and one V tensor per layer and
200/// concatenates along the sequence dimension every step.
201///
202/// Cost: an O(seq) copy per token per layer — kept as the correctness
203/// baseline; the paged arena is the production default.
204pub struct ContiguousKVCache<B: Backend> {
205    layers: Vec<Option<(Tensor<B, 4>, Tensor<B, 4>)>>,
206    seq_len: usize,
207}
208
209impl<B: Backend> ContiguousKVCache<B> {
210    /// Creates an empty cache for `num_layers` layers.
211    pub fn new(num_layers: usize) -> Self {
212        ContiguousKVCache {
213            layers: (0..num_layers).map(|_| None).collect(),
214            seq_len: 0,
215        }
216    }
217}
218
219impl<B: Backend> KVCache<B> for ContiguousKVCache<B> {
220    fn attention(
221        &mut self,
222        layer: usize,
223        q: Tensor<B, 4>,
224        k: Tensor<B, 4>,
225        v: Tensor<B, 4>,
226        pos: usize,
227        scale: f64,
228    ) -> Tensor<B, 4> {
229        let slot = &mut self.layers[layer];
230        let (k_full, v_full) = match slot.take() {
231            Some((k_old, v_old)) => (
232                Tensor::cat(vec![k_old, k], 2),
233                Tensor::cat(vec![v_old, v], 2),
234            ),
235            None => (k, v),
236        };
237        self.seq_len = k_full.dims()[2];
238        let out = attend(q, k_full.clone(), v_full.clone(), pos, scale);
239        *slot = Some((k_full, v_full));
240        out
241    }
242
243    fn seq_len(&self) -> usize {
244        self.seq_len
245    }
246
247    fn reset(&mut self) {
248        for slot in &mut self.layers {
249            *slot = None;
250        }
251        self.seq_len = 0;
252    }
253}
254
255/// Free-page allocator: a stack of physical page ids.
256#[derive(Debug)]
257struct PageAllocator {
258    free: Vec<usize>,
259}
260
261impl PageAllocator {
262    fn new(num_pages: usize) -> Self {
263        // Reversed so page 0 is allocated first (deterministic tests).
264        PageAllocator {
265            free: (0..num_pages).rev().collect(),
266        }
267    }
268
269    fn alloc(&mut self) -> Option<usize> {
270        self.free.pop()
271    }
272
273    fn free_page(&mut self, id: usize) {
274        self.free.push(id);
275    }
276
277    fn num_free(&self) -> usize {
278        self.free.len()
279    }
280
281    fn reset(&mut self, num_pages: usize) {
282        *self = PageAllocator::new(num_pages);
283    }
284}
285
286/// MLC-style paged KV cache.
287///
288/// Per layer, K and V live in fixed arena tensors of shape
289/// `[num_pages, n_kv, page_size, head_dim]`, allocated lazily on the layer's
290/// first use. A single-sequence page table maps logical pages to physical
291/// page ids drawn from a free-page allocator (the struct is shaped so
292/// per-sequence tables can be added later).
293///
294/// `attention()` writes the new K/V into page slots (one `slice_assign` per
295/// touched page), gathers the active pages into a contiguous
296/// `[1, n_kv, total, head_dim]` window and runs the standard matmul path.
297/// Steady-state decode therefore writes a single slot and gathers — the
298/// Phase 1 O(seq) `cat`-rewrite per token is gone. (A fused no-gather
299/// CubeCL kernel is a later task.)
300pub struct PagedKVCache<B: Backend> {
301    config: CacheConfig,
302    allocator: PageAllocator,
303    /// Page table: logical page index -> physical page id (single sequence).
304    table: Vec<usize>,
305    seq_len: usize,
306    arenas: Vec<Option<(Tensor<B, 4>, Tensor<B, 4>)>>,
307    device: Option<Device<B>>,
308}
309
310impl<B: Backend> PagedKVCache<B> {
311    /// Creates an empty paged cache for `num_layers` layers. Arena tensors
312    /// are allocated lazily on first use of each layer.
313    pub fn new(num_layers: usize, config: CacheConfig) -> Self {
314        PagedKVCache {
315            allocator: PageAllocator::new(config.num_pages()),
316            config,
317            table: Vec::new(),
318            seq_len: 0,
319            arenas: (0..num_layers).map(|_| None).collect(),
320            device: None,
321        }
322    }
323
324    /// Number of free pages in the arena.
325    pub fn num_free_pages(&self) -> usize {
326        self.allocator.num_free()
327    }
328
329    /// Ensures the page table covers `total` positions.
330    fn ensure_pages(&mut self, total: usize) -> usize {
331        let pages_needed = total.div_ceil(self.config.page_size);
332        while self.table.len() < pages_needed {
333            let page = self
334                .allocator
335                .alloc()
336                .expect("page allocator exhausted (max_seq_len exceeded)");
337            self.table.push(page);
338        }
339        pages_needed
340    }
341
342    /// Gathers the first `pages` page-table entries of `arena`
343    /// (`[num_pages, n_kv, page_size, head_dim]`) into a contiguous
344    /// `[1, n_kv, total, head_dim]` window.
345    fn gather_window(
346        &self,
347        arena: Tensor<B, 4>,
348        pages: usize,
349        total: usize,
350    ) -> Tensor<B, 4> {
351        let [_, n_kv, page_size, head_dim] = arena.dims();
352        let ids: Vec<i32> = self.table[..pages].iter().map(|&p| p as i32).collect();
353        let device = self
354            .device
355            .as_ref()
356            .expect("device set on first attention call");
357        let indices = Tensor::<B, 1, Int>::from_data(TensorData::new(ids, [pages]), device);
358        arena
359            .select(0, indices) // [pages, n_kv, page_size, head_dim]
360            .swap_dims(0, 1) // [n_kv, pages, page_size, head_dim]
361            .reshape([1, n_kv, pages * page_size, head_dim])
362            .narrow(2, 0, total)
363    }
364}
365
366impl<B: Backend> KVCache<B> for PagedKVCache<B> {
367    fn attention(
368        &mut self,
369        layer: usize,
370        q: Tensor<B, 4>,
371        k: Tensor<B, 4>,
372        v: Tensor<B, 4>,
373        pos: usize,
374        scale: f64,
375    ) -> Tensor<B, 4> {
376        let [_, n_kv, seq, head_dim] = k.dims();
377        let total = pos + seq;
378        // Layer 0 of each forward pass advances the sequence; all layers of
379        // the pass see the same pos/seq, so later layers find seq_len
380        // already at `total`.
381        if layer == 0 {
382            assert_eq!(
383                pos, self.seq_len,
384                "paged cache expects dense contiguous appends (pos == seq_len)"
385            );
386            self.seq_len = total;
387        } else {
388            debug_assert_eq!(total, self.seq_len);
389        }
390        assert!(
391            total <= self.config.max_seq_len,
392            "paged cache capacity exceeded: {total} > {}",
393            self.config.max_seq_len
394        );
395
396        if self.device.is_none() {
397            self.device = Some(k.device());
398        }
399        if self.arenas[layer].is_none() {
400            let device = k.device();
401            let shape = [self.config.num_pages(), n_kv, self.config.page_size, head_dim];
402            self.arenas[layer] = Some((
403                Tensor::zeros(shape, &device),
404                Tensor::zeros(shape, &device),
405            ));
406        }
407
408        let pages = self.ensure_pages(total);
409        let page_size = self.config.page_size;
410
411        // Write the new K/V into page slots: one slice_assign per touched
412        // page (1 per steady-state decode step, seq/page_size per chunk).
413        let (mut arena_k, mut arena_v) = self.arenas[layer].take().expect("arena initialized");
414        let mut written = 0;
415        while written < seq {
416            let global = pos + written;
417            let slot = global % page_size;
418            let run = (page_size - slot).min(seq - written);
419            let phys = self.table[global / page_size];
420            let range = [phys..phys + 1, 0..n_kv, slot..slot + run, 0..head_dim];
421            arena_k = arena_k.slice_assign(range.clone(), k.clone().narrow(2, written, run));
422            arena_v = arena_v.slice_assign(range, v.clone().narrow(2, written, run));
423            written += run;
424        }
425
426        let k_full = self.gather_window(arena_k.clone(), pages, total);
427        let v_full = self.gather_window(arena_v.clone(), pages, total);
428        self.arenas[layer] = Some((arena_k, arena_v));
429
430        attend(q, k_full, v_full, pos, scale)
431    }
432
433    fn seq_len(&self) -> usize {
434        self.seq_len
435    }
436
437    /// Rolls back the last `n` cached tokens, freeing trailing pages that
438    /// become fully unused. K/V content of popped positions is left in the
439    /// arena but is never read (writes always cover `seq_len..` densely).
440    fn popn(&mut self, n: usize) -> usize {
441        let n = n.min(self.seq_len);
442        self.seq_len -= n;
443        let keep = self.seq_len.div_ceil(self.config.page_size);
444        while self.table.len() > keep {
445            let page = self.table.pop().expect("table nonempty");
446            self.allocator.free_page(page);
447        }
448        n
449    }
450
451    fn reset(&mut self) {
452        self.table.clear();
453        self.allocator.reset(self.config.num_pages());
454        self.seq_len = 0;
455        // Arena tensors are kept (capacity reuse); stale content is never
456        // read because writes always cover seq_len.. densely.
457    }
458
459    fn pages_used(&self) -> Option<usize> {
460        Some(self.table.len())
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[test]
469    fn allocator_alloc_in_order_and_exhaust() {
470        let mut a = PageAllocator::new(3);
471        assert_eq!(a.num_free(), 3);
472        assert_eq!(a.alloc(), Some(0));
473        assert_eq!(a.alloc(), Some(1));
474        assert_eq!(a.alloc(), Some(2));
475        assert_eq!(a.alloc(), None);
476        assert_eq!(a.num_free(), 0);
477    }
478
479    #[test]
480    fn allocator_free_and_realloc_lifo() {
481        let mut a = PageAllocator::new(2);
482        let p0 = a.alloc().unwrap();
483        let p1 = a.alloc().unwrap();
484        a.free_page(p1);
485        a.free_page(p0);
486        assert_eq!(a.num_free(), 2);
487        // LIFO stack: most recently freed page comes back first.
488        assert_eq!(a.alloc(), Some(p0));
489        assert_eq!(a.alloc(), Some(p1));
490    }
491
492    #[test]
493    fn allocator_reset_restores_all_pages() {
494        let mut a = PageAllocator::new(4);
495        a.alloc();
496        a.alloc();
497        a.reset(4);
498        assert_eq!(a.num_free(), 4);
499        assert_eq!(a.alloc(), Some(0));
500    }
501
502    #[test]
503    fn cache_config_num_pages_rounds_up() {
504        assert_eq!(CacheConfig::paged(16).num_pages(), 1);
505        assert_eq!(CacheConfig::paged(17).num_pages(), 2);
506        assert_eq!(CacheConfig::paged(1).num_pages(), 1);
507    }
508
509    // Page-table bookkeeping without touching tensors: PagedKVCache only
510    // allocates arena tensors lazily inside `attention`, so popn/reset paths
511    // can be exercised on the NdArray backend without a GPU.
512    type TestBackend = burn::backend::NdArray<f32>;
513
514    fn cache(max_seq_len: usize, page_size: usize) -> PagedKVCache<TestBackend> {
515        PagedKVCache::new(
516            2,
517            CacheConfig {
518                max_seq_len,
519                page_size,
520                kind: CacheKind::Paged,
521            },
522        )
523    }
524
525    /// Simulates page-table growth without tensors (mirrors ensure_pages).
526    fn grow(c: &mut PagedKVCache<TestBackend>, total: usize) {
527        c.ensure_pages(total);
528        c.seq_len = total;
529    }
530
531    #[test]
532    fn popn_frees_only_fully_unused_pages() {
533        let mut c = cache(64, 16);
534        grow(&mut c, 40); // pages 0,1,2 (page 2 holds slots 32..39)
535        assert_eq!(c.pages_used(), Some(3));
536        assert_eq!(c.num_free_pages(), 1);
537
538        c.popn(9); // seq 31 -> page 2 fully unused, freed
539        assert_eq!(c.seq_len(), 31);
540        assert_eq!(c.pages_used(), Some(2));
541        assert_eq!(c.num_free_pages(), 2);
542
543        c.popn(15); // seq 16 -> page 1 still needed (slots 16..31)
544        assert_eq!(c.pages_used(), Some(1));
545        c.popn(1); // seq 15 -> page 0 still needed
546        assert_eq!(c.pages_used(), Some(1));
547
548        c.popn(1000); // clamps to seq_len
549        assert_eq!(c.seq_len(), 0);
550        assert_eq!(c.pages_used(), Some(0));
551        assert_eq!(c.num_free_pages(), 4);
552    }
553
554    #[test]
555    fn popn_boundary_exact_page_edge() {
556        let mut c = cache(64, 16);
557        grow(&mut c, 32); // exactly 2 pages
558        c.popn(16); // seq 16 -> 1 page
559        assert_eq!(c.pages_used(), Some(1));
560        assert_eq!(c.num_free_pages(), 3);
561        c.popn(16);
562        assert_eq!(c.pages_used(), Some(0));
563        assert_eq!(c.num_free_pages(), 4);
564    }
565
566    #[test]
567    fn regrowth_after_popn_reuses_freed_pages() {
568        let mut c = cache(64, 16);
569        grow(&mut c, 40);
570        c.popn(9); // frees page for slots 32..48
571        grow(&mut c, 33); // needs a page again -> reuses the freed one
572        assert_eq!(c.pages_used(), Some(3));
573        assert_eq!(c.num_free_pages(), 1);
574    }
575
576    #[test]
577    fn reset_releases_all_pages() {
578        let mut c = cache(64, 16);
579        grow(&mut c, 40);
580        c.reset();
581        assert_eq!(c.seq_len(), 0);
582        assert_eq!(c.pages_used(), Some(0));
583        assert_eq!(c.num_free_pages(), 4);
584    }
585}