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;
19use crate::precision::{to_f32, to_float};
20
21/// Whether to prefer burn's fused (flash) attention kernel over the manual
22/// scores→mask→softmax→matmul path. Controlled by `COMBS_ATTN=flash|manual`
23/// (default `flash`); read once per process.
24fn flash_enabled() -> bool {
25    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
26    *ENABLED.get_or_init(|| {
27        std::env::var("COMBS_ATTN").map(|v| v != "manual").unwrap_or(true)
28    })
29}
30
31/// Which [`KVCache`] implementation to instantiate.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum CacheKind {
34    /// Per-layer contiguous K/V, `cat` per step (baseline).
35    Contiguous,
36    /// Paged arena with a page table (default).
37    Paged,
38}
39
40/// Configuration for a KV cache instance.
41#[derive(Debug, Clone, Copy)]
42pub struct CacheConfig {
43    /// Maximum number of cached positions (arena capacity).
44    pub max_seq_len: usize,
45    /// Tokens per page (paged cache only).
46    pub page_size: usize,
47    /// Implementation to use.
48    pub kind: CacheKind,
49    /// Store global-layer KV pages int8-quantized (group-32 along
50    /// head_dim, packed 4 bytes per int element) — ~3.5× less KV memory
51    /// on the f32 build at near-lossless int8 fidelity. Sliding-window
52    /// layers stay in float (they hold at most `w-1` tokens). Set from
53    /// `COMBS_KV_QUANT=1` by the engine.
54    pub quantize_kv: bool,
55}
56
57impl CacheConfig {
58    /// Default page size (MLC uses 16 as well).
59    pub const DEFAULT_PAGE_SIZE: usize = 16;
60
61    /// Paged cache with the default page size.
62    pub fn paged(max_seq_len: usize) -> Self {
63        CacheConfig {
64            max_seq_len,
65            page_size: Self::DEFAULT_PAGE_SIZE,
66            kind: CacheKind::Paged,
67            quantize_kv: false,
68        }
69    }
70
71    /// Contiguous (baseline) cache.
72    pub fn contiguous(max_seq_len: usize) -> Self {
73        CacheConfig {
74            max_seq_len,
75            page_size: Self::DEFAULT_PAGE_SIZE,
76            kind: CacheKind::Contiguous,
77            quantize_kv: false,
78        }
79    }
80
81    /// Number of pages in the arena.
82    pub fn num_pages(&self) -> usize {
83        self.max_seq_len.div_ceil(self.page_size)
84    }
85}
86
87/// Per-layer key/value storage that owns the attention computation.
88///
89/// Tensors are 4-D `[batch=1, heads, seq, head_dim]`; `q` has `n_q` heads
90/// while `k`/`v` have `n_kv` heads (GQA expansion happens inside the
91/// implementation, as does causal masking).
92pub trait KVCache<B: Backend>: Send {
93    /// Appends `seq` new positions of K/V for `layer` and computes attention
94    /// of `q` against the full cached window (past + new).
95    ///
96    /// `pos` is the absolute position of the first new token and must equal
97    /// [`KVCache::seq_len`] on entry (dense contiguous appends). `scale` is
98    /// the attention logit scale (`1/sqrt(head_dim)`). Returns the attention
99    /// output `[1, n_q, seq, head_dim]`.
100    fn attention(
101        &mut self,
102        layer: usize,
103        q: Tensor<B, 4>,
104        k: Tensor<B, 4>,
105        v: Tensor<B, 4>,
106        pos: usize,
107        scale: f64,
108    ) -> Tensor<B, 4> {
109        self.attention_opts(layer, q, k, v, pos, scale, None)
110    }
111
112    /// [`KVCache::attention`] with an optional sliding-window span (Gemma
113    /// local layers): when `Some(w)`, query at absolute position p attends
114    /// only keys in `(p - w, p]` — older keys stay cached but are masked
115    /// out. `None` = full causal attention (Llama-family behavior).
116    fn attention_opts(
117        &mut self,
118        layer: usize,
119        q: Tensor<B, 4>,
120        k: Tensor<B, 4>,
121        v: Tensor<B, 4>,
122        pos: usize,
123        scale: f64,
124        window: Option<usize>,
125    ) -> Tensor<B, 4>;
126
127    /// Total cached sequence length.
128    fn seq_len(&self) -> usize;
129
130    /// Rolls back the last `n` cached tokens, returning how many were
131    /// actually dropped. Caches that cannot roll back (the contiguous
132    /// baseline) return 0 — callers gate prefix reuse on a nonzero result.
133    fn popn(&mut self, n: usize) -> usize {
134        let _ = n;
135        0
136    }
137
138    /// Drops all cached state (session reset).
139    fn reset(&mut self);
140
141    /// Pages currently allocated to the sequence (paged cache only).
142    fn pages_used(&self) -> Option<usize> {
143        None
144    }
145
146    /// Page-table snapshot for observability (paged cache only). Cheap:
147    /// reads counters, never touches device memory.
148    fn page_stats(&self) -> Option<PageStats> {
149        None
150    }
151}
152
153/// A paged cache's page-table state at a point in time (for monitoring).
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub struct PageStats {
156    /// Pages holding live KV entries for this sequence.
157    pub pages_used: usize,
158    /// Pages still available in this cache's arena.
159    pub pages_free: usize,
160    /// Total pages in the arena (`pages_used + pages_free` when healthy).
161    pub num_pages: usize,
162    /// Tokens per page.
163    pub page_size: usize,
164    /// Cached sequence length in tokens.
165    pub seq_len: usize,
166    /// Layers whose arena tensor is actually allocated. Arenas are lazy —
167    /// a layer materializes on its first write — so this rises through
168    /// the first prefill and stays below `layers_total` for models whose
169    /// sliding layers never touch the paged arena at all.
170    pub layers_materialized: usize,
171    /// Layers in this cache.
172    pub layers_total: usize,
173    /// Layers holding a rolling sliding-window store instead of pages.
174    pub layers_sliding: usize,
175}
176
177/// Repeats each KV head `n_rep` times consecutively (GQA → MHA expansion):
178/// `[b, nkv, s, d] -> [b, nkv * n_rep, s, d]`.
179fn repeat_kv<B: Backend>(x: Tensor<B, 4>, n_rep: usize) -> Tensor<B, 4> {
180    if n_rep == 1 {
181        return x;
182    }
183    let [b, nkv, s, d] = x.dims();
184    x.unsqueeze_dim::<5>(2)
185        .expand([b, nkv, n_rep, s, d])
186        .reshape([b, nkv * n_rep, s, d])
187}
188
189/// Standard scaled dot-product causal attention over a fully materialized
190/// K/V window.
191///
192/// `q`: `[1, n_q, seq, d]`; `k`/`v`: `[1, n_kv, total, d]`; `pos` is the
193/// absolute position of the first query token. Returns `[1, n_q, seq, d]`.
194///
195/// Prefers burn's fused flash-attention kernel (one kernel, no materialized
196/// `[seq, total]` scores matrix) when the scale is the default
197/// `1/sqrt(head_dim)`; the causal mode is bottom-right aligned, which is
198/// exactly the `pos`-offset masking the manual path applies, so chunked
199/// prefill (`pos > 0`) is covered as well. Set `COMBS_ATTN=manual` to force
200/// the reference path.
201fn attend<B: Backend>(
202    q: Tensor<B, 4>,
203    k: Tensor<B, 4>,
204    v: Tensor<B, 4>,
205    pos: usize,
206    scale: f64,
207    window: Option<usize>,
208) -> Tensor<B, 4> {
209    let device = q.device();
210    // Attention scores + softmax run in f32 for f16 stability (exp overflows
211    // f16); no-op in f32 builds. The heavy weight matmuls + KV stay f16.
212    let out_dtype = q.dtype();
213    let q = to_f32(q);
214    let k = to_f32(k);
215    let v = to_f32(v);
216    let [_, n_q, seq, d] = q.dims();
217    let [_, n_kv, total, _] = k.dims();
218    let n_rep = n_q / n_kv;
219    let k = repeat_kv(k, n_rep);
220    let v = repeat_kv(v, n_rep);
221
222    let default_scale = 1.0 / (d as f64).sqrt();
223    if flash_enabled() && window.is_none() && (scale - default_scale).abs() < 1e-12 {
224        let out = burn::tensor::module::attention(
225            q,
226            k,
227            v,
228            None,
229            None,
230            AttentionModuleOptions {
231                scale: None,
232                softcap: None,
233                // Decode (seq == 1) needs no mask: a single query at the end
234                // of the window attends to everything cached.
235                is_causal: seq > 1,
236            },
237        );
238        return to_float(out, out_dtype);
239    }
240
241    // Reference path: explicit scores, causal (+ optional sliding-window)
242    // mask, softmax, P@V.
243    let scores = q.matmul(k.transpose()).mul_scalar(scale);
244    let scores = if seq > 1 || window.is_some() {
245        // Causal mask: query at global position p attends keys <= p; a
246        // sliding window w further forbids keys <= p - w.
247        let q_pos =
248            Tensor::<B, 1, Int>::arange((pos as i64)..((pos + seq) as i64), &device)
249                .reshape([seq, 1]);
250        let k_pos = Tensor::<B, 1, Int>::arange(0..(total as i64), &device).reshape([1, total]);
251        let mut forbidden: Tensor<B, 2, Bool> = k_pos.clone().greater(q_pos.clone());
252        if let Some(w) = window {
253            let too_old = k_pos
254                .add_scalar(w as i64 - 1)
255                .lower(q_pos);
256            forbidden = forbidden.bool_or(too_old);
257        }
258        let mask = forbidden
259            .unsqueeze_dims::<4>(&[0, 1])
260            .expand([1, n_q, seq, total]);
261        scores.mask_fill(mask, -1e30f32)
262    } else {
263        scores // single query attends to everything cached
264    };
265
266    // `safe_matmul` for the P@V product: with a >= 512-token window this
267    // shape (M = seq, K = total) enters the broken wgpu/Metal matmul region.
268    to_float(safe_matmul(softmax(scores, 3), v), out_dtype)
269}
270
271/// Values per KV quantization group (along head_dim; one f32 scale each).
272const KV_QUANT_GROUP: usize = 32;
273
274/// Quantizes `x: [b, h, s, d]` to int8 with a per-32-value group scale
275/// (absmax/127), packing 4 signed bytes per int element.
276///
277/// Packing scheme (exact i32 fit): lanes 0..2 are stored offset-binary
278/// (`q+128 ∈ [1, 255]`, since q is clamped to ±127), lane 3 signed as-is:
279/// `p = l0 + l1·2⁸ + l2·2¹⁶ + q3·2²⁴`. The extreme corner
280/// (l0=l1=l2=255, q3=127) is exactly `i32::MAX` — no overflow — and the
281/// backend Int element is at least i32, so pack/unpack round-trips are
282/// integer-exact on every backend.
283///
284/// Returns `(packed [b,h,s,d/4], scales [b,h,s,d/32])`.
285fn kv_quantize<B: Backend>(x: Tensor<B, 4>) -> (Tensor<B, 4, Int>, Tensor<B, 4>) {
286    let [b, h, s, d] = x.dims();
287    debug_assert_eq!(d % KV_QUANT_GROUP, 0);
288    let groups = d / KV_QUANT_GROUP;
289    // Scales are computed in f32 for a stable absmax, but returned in the
290    // input's native dtype: the arena stores them next to native-dtype
291    // tensors, and the unfused f16 backend miscomputes mixed-dtype ops
292    // (an f16 × f32-broadcast mul returns power-of-two-wrong values).
293    let native = x.dtype();
294    let g = to_f32(x).reshape([b, h, s, groups, KV_QUANT_GROUP]);
295    let scale = g
296        .clone()
297        .abs()
298        .max_dim(4) // [b, h, s, groups, 1]
299        .div_scalar(127.0)
300        .clamp_min(1e-8);
301    let q = g
302        .div(scale.clone().expand([b, h, s, groups, KV_QUANT_GROUP]))
303        .round()
304        .clamp(-127.0, 127.0)
305        .int()
306        .reshape([b, h, s, d / 4, 4]);
307    let lane = |i: usize| q.clone().narrow(4, i, 1).reshape([b, h, s, d / 4]);
308    let packed = lane(0).add_scalar(128)
309        + lane(1).add_scalar(128).mul_scalar(256)
310        + lane(2).add_scalar(128).mul_scalar(65536)
311        + lane(3).mul_scalar(16777216);
312    (packed, to_float(scale.reshape([b, h, s, groups]), native))
313}
314
315/// Inverse of [`kv_quantize`]: unpack the four lanes (floor-division
316/// emulated exactly on the truncating int div: the high lane's remainder
317/// is sign-corrected, the rest are non-negative) and apply the group
318/// scales. Returns `[b, h, s, d]` float.
319fn kv_dequantize<B: Backend>(
320    packed: Tensor<B, 4, Int>,
321    scales: Tensor<B, 4>,
322    d: usize,
323) -> Tensor<B, 4> {
324    let [b, h, s, _] = packed.dims();
325    let groups = d / KV_QUANT_GROUP;
326    let t = packed.clone().div_scalar(16777216);
327    let r3 = packed - t.clone().mul_scalar(16777216);
328    let neg = r3.clone().lower_elem(0);
329    let q3 = t.clone().mask_where(neg.clone(), t.sub_scalar(1));
330    let r = r3.clone().mask_where(neg, r3.add_scalar(16777216));
331    let l2 = r.clone().div_scalar(65536);
332    let r = r - l2.clone().mul_scalar(65536);
333    let l1 = r.clone().div_scalar(256);
334    let l0 = r - l1.clone().mul_scalar(256);
335    // Stack along a new trailing lane axis → [b, h, s, d/4, 4]; a plain
336    // reshape then restores the original d-ordering (lane i holds value
337    // 4·g+i, exactly how the pack side split them).
338    let q = Tensor::stack::<5>(
339        vec![
340            l0.sub_scalar(128),
341            l1.sub_scalar(128),
342            l2.sub_scalar(128),
343            q3,
344        ],
345        4,
346    )
347    .reshape([b, h, s, d]);
348    let g = q.float().reshape([b, h, s, groups, KV_QUANT_GROUP]);
349    // Force the scales onto g's dtype before the broadcast multiply — the
350    // unfused f16 backend silently corrupts mixed-dtype ops.
351    let scales = to_float(scales, g.dtype());
352    g.mul(
353        scales
354            .reshape([b, h, s, groups, 1])
355            .expand([b, h, s, groups, KV_QUANT_GROUP]),
356    )
357    .reshape([b, h, s, d])
358}
359
360/// Simple contiguous cache: stores one K and one V tensor per layer and
361/// concatenates along the sequence dimension every step.
362///
363/// Cost: an O(seq) copy per token per layer — kept as the correctness
364/// baseline; the paged arena is the production default.
365pub struct ContiguousKVCache<B: Backend> {
366    layers: Vec<Option<(Tensor<B, 4>, Tensor<B, 4>)>>,
367    seq_len: usize,
368}
369
370impl<B: Backend> ContiguousKVCache<B> {
371    /// Creates an empty cache for `num_layers` layers.
372    pub fn new(num_layers: usize) -> Self {
373        ContiguousKVCache {
374            layers: (0..num_layers).map(|_| None).collect(),
375            seq_len: 0,
376        }
377    }
378}
379
380impl<B: Backend> KVCache<B> for ContiguousKVCache<B> {
381    fn attention_opts(
382        &mut self,
383        layer: usize,
384        q: Tensor<B, 4>,
385        k: Tensor<B, 4>,
386        v: Tensor<B, 4>,
387        pos: usize,
388        scale: f64,
389        window: Option<usize>,
390    ) -> Tensor<B, 4> {
391        let slot = &mut self.layers[layer];
392        let (k_full, v_full) = match slot.take() {
393            Some((k_old, v_old)) => (
394                Tensor::cat(vec![k_old, k], 2),
395                Tensor::cat(vec![v_old, v], 2),
396            ),
397            None => (k, v),
398        };
399        self.seq_len = k_full.dims()[2];
400        let out = attend(q, k_full.clone(), v_full.clone(), pos, scale, window);
401        *slot = Some((k_full, v_full));
402        out
403    }
404
405    fn seq_len(&self) -> usize {
406        self.seq_len
407    }
408
409    fn reset(&mut self) {
410        for slot in &mut self.layers {
411            *slot = None;
412        }
413        self.seq_len = 0;
414    }
415}
416
417/// Free-page allocator: a stack of physical page ids.
418#[derive(Debug)]
419struct PageAllocator {
420    free: Vec<usize>,
421}
422
423impl PageAllocator {
424    fn new(num_pages: usize) -> Self {
425        // Reversed so page 0 is allocated first (deterministic tests).
426        PageAllocator {
427            free: (0..num_pages).rev().collect(),
428        }
429    }
430
431    fn alloc(&mut self) -> Option<usize> {
432        self.free.pop()
433    }
434
435    fn free_page(&mut self, id: usize) {
436        self.free.push(id);
437    }
438
439    fn num_free(&self) -> usize {
440        self.free.len()
441    }
442
443    fn reset(&mut self, num_pages: usize) {
444        *self = PageAllocator::new(num_pages);
445    }
446}
447
448/// MLC-style paged KV cache.
449///
450/// Per layer, K and V live in fixed arena tensors of shape
451/// `[num_pages, n_kv, page_size, head_dim]`, allocated lazily on the layer's
452/// first use. A single-sequence page table maps logical pages to physical
453/// page ids drawn from a free-page allocator (the struct is shaped so
454/// per-sequence tables can be added later).
455///
456/// `attention()` writes the new K/V into page slots (one `slice_assign` per
457/// touched page), gathers the active pages into a contiguous
458/// `[1, n_kv, total, head_dim]` window and runs the standard matmul path.
459/// Steady-state decode therefore writes a single slot and gathers — the
460/// Phase 1 O(seq) `cat`-rewrite per token is gone. (A fused no-gather
461/// CubeCL kernel is a later task.)
462/// A global layer's page arena: float, or int8-quantized (packed 4/int +
463/// per-group scales) when `CacheConfig::quantize_kv` is set.
464enum Arena<B: Backend> {
465    Fp {
466        k: Tensor<B, 4>,
467        v: Tensor<B, 4>,
468    },
469    Quant {
470        k_packed: Tensor<B, 4, Int>,
471        k_scales: Tensor<B, 4>,
472        v_packed: Tensor<B, 4, Int>,
473        v_scales: Tensor<B, 4>,
474    },
475}
476
477pub struct PagedKVCache<B: Backend> {
478    config: CacheConfig,
479    allocator: PageAllocator,
480    /// Page table: logical page index -> physical page id (single sequence).
481    table: Vec<usize>,
482    seq_len: usize,
483    arenas: Vec<Option<Arena<B>>>,
484    /// Per-layer sliding-window override (transformers' layer-typed cache):
485    /// `Some(w)` layers keep only the most recent `w-1` tokens in a rolling
486    /// tensor and never touch the paged arena — for gemma's 5 local : 1
487    /// global pattern this skips the arena allocation for ~5/6 of layers.
488    /// Empty ⇒ every layer is global (the llama case).
489    layer_windows: Vec<Option<usize>>,
490    /// Rolling K/V for sliding layers, `[1, n_kv, ≤w-1, head_dim]`. Keys
491    /// are stored post-RoPE with their absolute positions baked in; only
492    /// the attention mask is re-based when old tokens are evicted.
493    sliding: Vec<Option<(Tensor<B, 4>, Tensor<B, 4>)>>,
494    device: Option<Device<B>>,
495}
496
497impl<B: Backend> PagedKVCache<B> {
498    /// Creates an empty paged cache for `num_layers` layers (all global).
499    /// Arena tensors are allocated lazily on first use of each layer.
500    pub fn new(num_layers: usize, config: CacheConfig) -> Self {
501        Self::new_with_windows(num_layers, config, vec![None; num_layers])
502    }
503
504    /// Creates a paged cache with a per-layer sliding-window assignment
505    /// (`windows[i] = Some(w)` ⇒ layer `i` stores at most `w-1` past
506    /// tokens). `w >= 2` — a window of 1 would leave decode steps with no
507    /// past context at all.
508    pub fn new_with_windows(
509        num_layers: usize,
510        config: CacheConfig,
511        windows: Vec<Option<usize>>,
512    ) -> Self {
513        assert_eq!(windows.len(), num_layers, "one window entry per layer");
514        for w in windows.iter().flatten() {
515            assert!(*w >= 2, "sliding window must be >= 2, got {w}");
516        }
517        PagedKVCache {
518            allocator: PageAllocator::new(config.num_pages()),
519            config,
520            table: Vec::new(),
521            seq_len: 0,
522            arenas: (0..num_layers).map(|_| None).collect(),
523            layer_windows: windows,
524            sliding: (0..num_layers).map(|_| None).collect(),
525            device: None,
526        }
527    }
528
529    /// Number of free pages in the arena.
530    pub fn num_free_pages(&self) -> usize {
531        self.allocator.num_free()
532    }
533
534    /// Page-table snapshot (see [`KVCache::page_stats`]).
535    pub fn page_stats_inner(&self) -> PageStats {
536        PageStats {
537            pages_used: self.table.len(),
538            pages_free: self.allocator.num_free(),
539            num_pages: self.config.num_pages(),
540            page_size: self.config.page_size,
541            seq_len: self.seq_len,
542            layers_materialized: self.arenas.iter().filter(|a| a.is_some()).count(),
543            layers_total: self.arenas.len(),
544            layers_sliding: self.sliding.iter().filter(|s| s.is_some()).count(),
545        }
546    }
547
548    /// Ensures the page table covers `total` positions.
549    fn ensure_pages(&mut self, total: usize) -> usize {
550        let pages_needed = total.div_ceil(self.config.page_size);
551        while self.table.len() < pages_needed {
552            let page = self
553                .allocator
554                .alloc()
555                .expect("page allocator exhausted (max_seq_len exceeded)");
556            self.table.push(page);
557        }
558        pages_needed
559    }
560
561    /// Sliding-layer attention (transformers `DynamicSlidingWindowLayer`):
562    /// concat the rolling store with the new K/V, attend over the full
563    /// concat, persist only the trailing `w-1` tokens.
564    ///
565    /// The stored keys carry their original RoPE (absolute positions);
566    /// after eviction the first stored key is at absolute position
567    /// `kv_offset = pos + seq - full_len`, so the causal/window mask is
568    /// evaluated with the queries re-based by that offset — RoPE is never
569    /// re-applied or re-indexed.
570    fn sliding_attention(
571        &mut self,
572        layer: usize,
573        q: Tensor<B, 4>,
574        k: Tensor<B, 4>,
575        v: Tensor<B, 4>,
576        pos: usize,
577        scale: f64,
578        w: usize,
579    ) -> Tensor<B, 4> {
580        let seq = k.dims()[2];
581        let slot = &mut self.sliding[layer];
582        let (k_full, v_full) = match slot.take() {
583            Some((k_old, v_old)) => (
584                Tensor::cat(vec![k_old, k], 2),
585                Tensor::cat(vec![v_old, v], 2),
586            ),
587            None => (k, v),
588        };
589        let full_len = k_full.dims()[2];
590        let kv_offset = pos + seq - full_len;
591        let out = attend(
592            q,
593            k_full.clone(),
594            v_full.clone(),
595            pos - kv_offset,
596            scale,
597            Some(w),
598        );
599        // Keep `w-1` past tokens: the next decode step appends one, giving
600        // exactly `w` visible keys (transformers' `-window + 1` rule). A
601        // prefill chunk longer than the window attends over its full concat
602        // first (context within the chunk is never lost), then truncates.
603        let keep = full_len.min(w - 1);
604        *slot = Some((
605            k_full.narrow(2, full_len - keep, keep),
606            v_full.narrow(2, full_len - keep, keep),
607        ));
608        out
609    }
610
611    /// Page-table indices for the first `pages` logical pages, on-device.
612    fn page_indices(&self, pages: usize) -> Tensor<B, 1, Int> {
613        let ids: Vec<i32> = self.table[..pages].iter().map(|&p| p as i32).collect();
614        let device = self
615            .device
616            .as_ref()
617            .expect("device set on first attention call");
618        Tensor::<B, 1, Int>::from_data(TensorData::new(ids, [pages]), device)
619    }
620
621    /// Gathers the first `pages` page-table entries of `arena`
622    /// (`[num_pages, n_kv, page_size, last]`) into a contiguous
623    /// `[1, n_kv, total, last]` window.
624    fn gather_window(
625        &self,
626        arena: Tensor<B, 4>,
627        pages: usize,
628        total: usize,
629    ) -> Tensor<B, 4> {
630        let [_, n_kv, page_size, last] = arena.dims();
631        arena
632            .select(0, self.page_indices(pages)) // [pages, n_kv, page_size, last]
633            .swap_dims(0, 1) // [n_kv, pages, page_size, last]
634            .reshape([1, n_kv, pages * page_size, last])
635            .narrow(2, 0, total)
636    }
637
638    /// [`Self::gather_window`] for the packed int arenas.
639    fn gather_window_int(
640        &self,
641        arena: Tensor<B, 4, Int>,
642        pages: usize,
643        total: usize,
644    ) -> Tensor<B, 4, Int> {
645        let [_, n_kv, page_size, last] = arena.dims();
646        arena
647            .select(0, self.page_indices(pages))
648            .swap_dims(0, 1)
649            .reshape([1, n_kv, pages * page_size, last])
650            .narrow(2, 0, total)
651    }
652}
653
654impl<B: Backend> KVCache<B> for PagedKVCache<B> {
655    fn attention_opts(
656        &mut self,
657        layer: usize,
658        q: Tensor<B, 4>,
659        k: Tensor<B, 4>,
660        v: Tensor<B, 4>,
661        pos: usize,
662        scale: f64,
663        window: Option<usize>,
664    ) -> Tensor<B, 4> {
665        let [_, n_kv, seq, head_dim] = k.dims();
666        let total = pos + seq;
667        // Layer 0 of each forward pass advances the sequence; all layers of
668        // the pass see the same pos/seq, so later layers find seq_len
669        // already at `total`.
670        if layer == 0 {
671            assert_eq!(
672                pos, self.seq_len,
673                "paged cache expects dense contiguous appends (pos == seq_len)"
674            );
675            self.seq_len = total;
676        } else {
677            debug_assert_eq!(total, self.seq_len);
678        }
679        assert!(
680            total <= self.config.max_seq_len,
681            "paged cache capacity exceeded: {total} > {}",
682            self.config.max_seq_len
683        );
684
685        if self.device.is_none() {
686            self.device = Some(k.device());
687        }
688        // Sliding layers bypass the paged arena entirely (the caller's
689        // `window` argument and this cache's per-layer assignment come from
690        // the same AttentionPattern, so the cache's own value is used).
691        if let Some(w) = self.layer_windows.get(layer).copied().flatten() {
692            return self.sliding_attention(layer, q, k, v, pos, scale, w);
693        }
694        let quant = self.config.quantize_kv && head_dim % KV_QUANT_GROUP == 0;
695        if self.arenas[layer].is_none() {
696            let device = k.device();
697            let np = self.config.num_pages();
698            let ps = self.config.page_size;
699            self.arenas[layer] = Some(if quant {
700                Arena::Quant {
701                    k_packed: Tensor::zeros([np, n_kv, ps, head_dim / 4], &device),
702                    k_scales: Tensor::zeros([np, n_kv, ps, head_dim / KV_QUANT_GROUP], &device),
703                    v_packed: Tensor::zeros([np, n_kv, ps, head_dim / 4], &device),
704                    v_scales: Tensor::zeros([np, n_kv, ps, head_dim / KV_QUANT_GROUP], &device),
705                }
706            } else {
707                let shape = [np, n_kv, ps, head_dim];
708                Arena::Fp {
709                    k: Tensor::zeros(shape, &device),
710                    v: Tensor::zeros(shape, &device),
711                }
712            });
713        }
714
715        let pages = self.ensure_pages(total);
716        let page_size = self.config.page_size;
717
718        // Write the new K/V into page slots (one slice_assign per touched
719        // page: 1 per steady-state decode step, seq/page_size per chunk),
720        // then gather the full logical window and attend.
721        let (k_full, v_full) = match self.arenas[layer].take().expect("arena initialized") {
722            Arena::Fp { k: mut arena_k, v: mut arena_v } => {
723                let mut written = 0;
724                while written < seq {
725                    let global = pos + written;
726                    let slot = global % page_size;
727                    let run = (page_size - slot).min(seq - written);
728                    let phys = self.table[global / page_size];
729                    let range = [phys..phys + 1, 0..n_kv, slot..slot + run, 0..head_dim];
730                    arena_k =
731                        arena_k.slice_assign(range.clone(), k.clone().narrow(2, written, run));
732                    arena_v = arena_v.slice_assign(range, v.clone().narrow(2, written, run));
733                    written += run;
734                }
735                let k_full = self.gather_window(arena_k.clone(), pages, total);
736                let v_full = self.gather_window(arena_v.clone(), pages, total);
737                self.arenas[layer] = Some(Arena::Fp { k: arena_k, v: arena_v });
738                (k_full, v_full)
739            }
740            Arena::Quant {
741                mut k_packed,
742                mut k_scales,
743                mut v_packed,
744                mut v_scales,
745            } => {
746                // Quantize the incoming chunk once, then place the packed
747                // bytes + scales with the same page-slot arithmetic. Each
748                // token's groups quantize independently, so nothing is ever
749                // re-quantized.
750                let (kq, ks) = kv_quantize(k);
751                let (vq, vs) = kv_quantize(v);
752                let dp = head_dim / 4;
753                let dg = head_dim / KV_QUANT_GROUP;
754                let mut written = 0;
755                while written < seq {
756                    let global = pos + written;
757                    let slot = global % page_size;
758                    let run = (page_size - slot).min(seq - written);
759                    let phys = self.table[global / page_size];
760                    let rp = [phys..phys + 1, 0..n_kv, slot..slot + run, 0..dp];
761                    let rs = [phys..phys + 1, 0..n_kv, slot..slot + run, 0..dg];
762                    k_packed =
763                        k_packed.slice_assign(rp.clone(), kq.clone().narrow(2, written, run));
764                    k_scales =
765                        k_scales.slice_assign(rs.clone(), ks.clone().narrow(2, written, run));
766                    v_packed = v_packed.slice_assign(rp, vq.clone().narrow(2, written, run));
767                    v_scales = v_scales.slice_assign(rs, vs.clone().narrow(2, written, run));
768                    written += run;
769                }
770                let k_full = kv_dequantize(
771                    self.gather_window_int(k_packed.clone(), pages, total),
772                    self.gather_window(k_scales.clone(), pages, total),
773                    head_dim,
774                );
775                let v_full = kv_dequantize(
776                    self.gather_window_int(v_packed.clone(), pages, total),
777                    self.gather_window(v_scales.clone(), pages, total),
778                    head_dim,
779                );
780                self.arenas[layer] = Some(Arena::Quant {
781                    k_packed,
782                    k_scales,
783                    v_packed,
784                    v_scales,
785                });
786                (k_full, v_full)
787            }
788        };
789
790        attend(q, k_full, v_full, pos, scale, window)
791    }
792
793    fn seq_len(&self) -> usize {
794        self.seq_len
795    }
796
797    /// Rolls back the last `n` cached tokens, freeing trailing pages that
798    /// become fully unused. K/V content of popped positions is left in the
799    /// arena but is never read (writes always cover `seq_len..` densely).
800    ///
801    /// Sliding layers can only roll back while nothing has been evicted
802    /// from their window: once eviction starts, the tokens a rollback
803    /// would re-expose are gone, so the whole cache refuses (`0`) and the
804    /// caller rebuilds from scratch — the same "prefix caching disables
805    /// under sliding windows" rule HF applies. All-or-nothing: state is
806    /// only mutated when the full rollback is possible.
807    fn popn(&mut self, n: usize) -> usize {
808        let n = n.min(self.seq_len);
809        if n == 0 {
810            return 0;
811        }
812        for w in self.layer_windows.iter().flatten() {
813            if self.seq_len > w - 1 {
814                return 0; // eviction already happened in this layer
815            }
816        }
817        for slot in self.sliding.iter_mut() {
818            if let Some((k, v)) = slot.take() {
819                // Un-evicted invariant: stored length == seq_len, so the
820                // rollback is a plain tail truncation.
821                let len = k.dims()[2];
822                let keep = len.saturating_sub(n);
823                if keep > 0 {
824                    *slot = Some((k.narrow(2, 0, keep), v.narrow(2, 0, keep)));
825                }
826            }
827        }
828        self.seq_len -= n;
829        let keep = self.seq_len.div_ceil(self.config.page_size);
830        while self.table.len() > keep {
831            let page = self.table.pop().expect("table nonempty");
832            self.allocator.free_page(page);
833        }
834        n
835    }
836
837    fn reset(&mut self) {
838        self.table.clear();
839        self.allocator.reset(self.config.num_pages());
840        self.seq_len = 0;
841        for slot in &mut self.sliding {
842            *slot = None;
843        }
844        // Arena tensors are kept (capacity reuse); stale content is never
845        // read because writes always cover seq_len.. densely.
846    }
847
848    fn pages_used(&self) -> Option<usize> {
849        Some(self.table.len())
850    }
851
852    fn page_stats(&self) -> Option<PageStats> {
853        Some(self.page_stats_inner())
854    }
855}
856
857#[cfg(test)]
858mod tests {
859    use super::*;
860
861    type TB = burn::backend::NdArray<f32>;
862
863    /// Deterministic non-degenerate K/V for token `i`: `[1, n_kv, 1, d]`.
864    fn kv_tok(i: usize, n_kv: usize, d: usize) -> (Tensor<TB, 4>, Tensor<TB, 4>) {
865        let dev = Default::default();
866        let mk = |salt: usize| {
867            let data: Vec<f32> = (0..n_kv * d)
868                .map(|j| ((i * 7 + j * 3 + salt) % 13) as f32 / 13.0 - 0.5)
869                .collect();
870            Tensor::<TB, 4>::from_data(TensorData::new(data, [1, n_kv, 1, d]), &dev)
871        };
872        (mk(0), mk(5))
873    }
874
875    /// Deterministic query for token `i`: `[1, n_q, 1, d]`.
876    fn q_tok(i: usize, n_q: usize, d: usize) -> Tensor<TB, 4> {
877        let dev = Default::default();
878        let data: Vec<f32> = (0..n_q * d)
879            .map(|j| ((i * 11 + j * 5) % 17) as f32 / 17.0 - 0.5)
880            .collect();
881        Tensor::<TB, 4>::from_data(TensorData::new(data, [1, n_q, 1, d]), &dev)
882    }
883
884    fn assert_close4(a: Tensor<TB, 4>, b: Tensor<TB, 4>, what: &str) {
885        let av: Vec<f32> = a.into_data().to_vec().unwrap();
886        let bv: Vec<f32> = b.into_data().to_vec().unwrap();
887        assert_eq!(av.len(), bv.len(), "{what}: shape");
888        for (i, (x, y)) in av.iter().zip(bv.iter()).enumerate() {
889            assert!((x - y).abs() < 1e-5, "{what}[{i}]: {x} vs {y}");
890        }
891    }
892
893    /// The int8 pack/unpack must be integer-exact: craft values that are
894    /// exact multiples of the group scale (absmax = 63.5 ⇒ scale = 0.5) so
895    /// the quantize → dequantize round-trip reproduces the input bit-for-
896    /// bit, across all four byte lanes including negative extremes.
897    #[test]
898    fn kv_quant_roundtrip_exact_on_grid_values() {
899        let dev = Default::default();
900        let d = 64usize;
901        // q values sweep [-127, 127] across positions; x = q * 0.5.
902        let data: Vec<f32> = (0..2 * 3 * d)
903            .map(|i| {
904                let q = ((i * 37) % 255) as i64 - 127; // covers all lanes
905                q as f32 * 0.5
906            })
907            .collect();
908        // Force absmax = 63.5 per 32-group: overwrite one slot per group.
909        let mut data = data;
910        for g in 0..(2 * 3 * d) / 32 {
911            data[g * 32] = 63.5;
912        }
913        let x = Tensor::<TB, 4>::from_data(TensorData::new(data.clone(), [1, 2, 3, d]), &dev);
914        let (packed, scales) = kv_quantize(x);
915        let back: Vec<f32> = kv_dequantize(packed, scales, d)
916            .into_data()
917            .to_vec()
918            .unwrap();
919        for (i, (a, b)) in data.iter().zip(back.iter()).enumerate() {
920            assert!((a - b).abs() < 1e-6, "[{i}]: {a} vs {b} (must be exact)");
921        }
922    }
923
924    /// On arbitrary values the dequantization error is bounded by half a
925    /// quantization step (scale/2 = absmax/254) per element.
926    #[test]
927    fn kv_quant_error_bounded_by_half_step() {
928        let dev = Default::default();
929        let d = 64usize;
930        let data: Vec<f32> = (0..1 * 2 * 5 * d)
931            .map(|i| ((i * 7919) % 1000) as f32 / 250.0 - 2.0)
932            .collect();
933        let x = Tensor::<TB, 4>::from_data(TensorData::new(data.clone(), [1, 2, 5, d]), &dev);
934        let (packed, scales) = kv_quantize(x);
935        let back: Vec<f32> = kv_dequantize(packed, scales, d)
936            .into_data()
937            .to_vec()
938            .unwrap();
939        for (g, chunk) in data.chunks(32).enumerate() {
940            let absmax = chunk.iter().fold(0f32, |m, v| m.max(v.abs()));
941            let half_step = absmax / 254.0 + 1e-6;
942            for (j, (a, b)) in chunk
943                .iter()
944                .zip(back[g * 32..g * 32 + 32].iter())
945                .enumerate()
946            {
947                assert!(
948                    (a - b).abs() <= half_step,
949                    "group {g} elem {j}: |{a} - {b}| > {half_step}"
950                );
951            }
952        }
953    }
954
955    /// A quantized paged cache must track the fp cache within int8 noise
956    /// across prefill chunks, page boundaries, and decode steps — and its
957    /// popn/page semantics are unchanged.
958    #[test]
959    fn quantized_paged_cache_matches_fp_within_tolerance() {
960        let (n_kv, n_q, d) = (2usize, 4usize, 32usize);
961        let mut cfg_q = CacheConfig::paged(64);
962        cfg_q.quantize_kv = true;
963        let cfg_f = CacheConfig::paged(64);
964        let scale = 1.0 / (d as f64).sqrt() * 0.9;
965        let mut fp = PagedKVCache::<TB>::new(1, cfg_f);
966        let mut qn = PagedKVCache::<TB>::new(1, cfg_q);
967
968        // Prefill 20 (crosses a page boundary at 16), then decode to 30.
969        let ks: Vec<_> = (0..20).map(|i| kv_tok(i, n_kv, d)).collect();
970        let k20 = Tensor::cat(ks.iter().map(|(k, _)| k.clone()).collect(), 2);
971        let v20 = Tensor::cat(ks.iter().map(|(_, v)| v.clone()).collect(), 2);
972        let q20 = Tensor::cat((0..20).map(|i| q_tok(i, n_q, d)).collect(), 2);
973        let a = fp.attention_opts(0, q20.clone(), k20.clone(), v20.clone(), 0, scale, None);
974        let b = qn.attention_opts(0, q20, k20, v20, 0, scale, None);
975        let av: Vec<f32> = a.into_data().to_vec().unwrap();
976        let bv: Vec<f32> = b.into_data().to_vec().unwrap();
977        for (i, (x, y)) in av.iter().zip(bv.iter()).enumerate() {
978            assert!((x - y).abs() < 1e-2, "prefill[{i}]: {x} vs {y}");
979        }
980        for i in 20..30 {
981            let (k, v) = kv_tok(i, n_kv, d);
982            let q = q_tok(i, n_q, d);
983            let a = fp.attention_opts(0, q.clone(), k.clone(), v.clone(), i, scale, None);
984            let b = qn.attention_opts(0, q, k, v, i, scale, None);
985            let av: Vec<f32> = a.into_data().to_vec().unwrap();
986            let bv: Vec<f32> = b.into_data().to_vec().unwrap();
987            for (j, (x, y)) in av.iter().zip(bv.iter()).enumerate() {
988                assert!((x - y).abs() < 1e-2, "decode {i}[{j}]: {x} vs {y}");
989            }
990        }
991        assert_eq!(qn.pages_used(), fp.pages_used());
992        assert_eq!(qn.popn(5), 5, "quantized rollback works (no sliding)");
993        assert_eq!(qn.seq_len(), 25);
994    }
995
996    /// The divine invariant: a sliding layer (evict + offset-rebased mask)
997    /// must produce the same attention output as a global layer that keeps
998    /// everything and enforces the window purely via masking. Exercises
999    /// pre-eviction, eviction, GQA expansion, and a prefill chunk longer
1000    /// than the window.
1001    #[test]
1002    fn sliding_layer_matches_masked_global() {
1003        let (n_kv, n_q, d, w) = (2usize, 4usize, 4usize, 5usize);
1004        let cfg = CacheConfig::paged(64);
1005        let scale = 1.0 / (d as f64).sqrt() * 0.9; // off-default: force mask path
1006        let mut global = PagedKVCache::<TB>::new(1, cfg);
1007        let mut sliding = PagedKVCache::<TB>::new_with_windows(1, cfg, vec![Some(w)]);
1008
1009        // Prefill chunk of 7 (> window) at pos 0.
1010        let ks: Vec<_> = (0..7).map(|i| kv_tok(i, n_kv, d)).collect();
1011        let k7 = Tensor::cat(ks.iter().map(|(k, _)| k.clone()).collect(), 2);
1012        let v7 = Tensor::cat(ks.iter().map(|(_, v)| v.clone()).collect(), 2);
1013        let q7 = Tensor::cat((0..7).map(|i| q_tok(i, n_q, d)).collect(), 2);
1014        let a = global.attention_opts(0, q7.clone(), k7.clone(), v7.clone(), 0, scale, Some(w));
1015        let b = sliding.attention_opts(0, q7, k7, v7, 0, scale, Some(w));
1016        assert_close4(a, b, "prefill chunk");
1017
1018        // Decode steps 7..14 — eviction active well past the window.
1019        for i in 7..14 {
1020            let (k, v) = kv_tok(i, n_kv, d);
1021            let q = q_tok(i, n_q, d);
1022            let a = global.attention_opts(0, q.clone(), k.clone(), v.clone(), i, scale, Some(w));
1023            let b = sliding.attention_opts(0, q, k, v, i, scale, Some(w));
1024            assert_close4(a, b, &format!("decode step {i}"));
1025        }
1026
1027        // The sliding cache never touched the paged arena.
1028        assert_eq!(sliding.pages_used(), Some(0), "sliding layers use no pages");
1029        assert!(global.pages_used().unwrap() > 0);
1030    }
1031
1032    /// Rollback before any eviction behaves exactly like a fresh cache
1033    /// replaying the truncated stream.
1034    #[test]
1035    fn sliding_popn_before_eviction_matches_replay() {
1036        let (n_kv, n_q, d, w) = (2usize, 2usize, 4usize, 8usize);
1037        let cfg = CacheConfig::paged(64);
1038        let scale = 0.4;
1039        let mut cache = PagedKVCache::<TB>::new_with_windows(1, cfg, vec![Some(w)]);
1040        for i in 0..4 {
1041            let (k, v) = kv_tok(i, n_kv, d);
1042            cache.attention_opts(0, q_tok(i, n_q, d), k, v, i, scale, Some(w));
1043        }
1044        assert_eq!(cache.popn(2), 2, "un-evicted rollback succeeds");
1045        assert_eq!(cache.seq_len(), 2);
1046
1047        let mut fresh = PagedKVCache::<TB>::new_with_windows(1, cfg, vec![Some(w)]);
1048        for i in 0..2 {
1049            let (k, v) = kv_tok(i, n_kv, d);
1050            fresh.attention_opts(0, q_tok(i, n_q, d), k, v, i, scale, Some(w));
1051        }
1052        // Same next token after rollback vs replay must agree.
1053        let (k, v) = kv_tok(9, n_kv, d);
1054        let a = cache.attention_opts(0, q_tok(9, n_q, d), k.clone(), v.clone(), 2, scale, Some(w));
1055        let b = fresh.attention_opts(0, q_tok(9, n_q, d), k, v, 2, scale, Some(w));
1056        assert_close4(a, b, "post-rollback step");
1057    }
1058
1059    /// Once a sliding layer has evicted, rollback must refuse entirely
1060    /// (all-or-nothing) and leave state untouched.
1061    #[test]
1062    fn sliding_popn_after_eviction_refuses() {
1063        let (n_kv, n_q, d, w) = (1usize, 1usize, 4usize, 4usize);
1064        let cfg = CacheConfig::paged(64);
1065        let mut cache = PagedKVCache::<TB>::new_with_windows(1, cfg, vec![Some(w)]);
1066        for i in 0..6 {
1067            let (k, v) = kv_tok(i, n_kv, d);
1068            cache.attention_opts(0, q_tok(i, n_q, d), k, v, i, 0.5, Some(w));
1069        }
1070        assert_eq!(cache.popn(1), 0, "evicted sliding layer refuses rollback");
1071        assert_eq!(cache.seq_len(), 6, "refused rollback leaves state intact");
1072        assert_eq!(cache.popn(0), 0);
1073    }
1074
1075    #[test]
1076    fn allocator_alloc_in_order_and_exhaust() {
1077        let mut a = PageAllocator::new(3);
1078        assert_eq!(a.num_free(), 3);
1079        assert_eq!(a.alloc(), Some(0));
1080        assert_eq!(a.alloc(), Some(1));
1081        assert_eq!(a.alloc(), Some(2));
1082        assert_eq!(a.alloc(), None);
1083        assert_eq!(a.num_free(), 0);
1084    }
1085
1086    #[test]
1087    fn allocator_free_and_realloc_lifo() {
1088        let mut a = PageAllocator::new(2);
1089        let p0 = a.alloc().unwrap();
1090        let p1 = a.alloc().unwrap();
1091        a.free_page(p1);
1092        a.free_page(p0);
1093        assert_eq!(a.num_free(), 2);
1094        // LIFO stack: most recently freed page comes back first.
1095        assert_eq!(a.alloc(), Some(p0));
1096        assert_eq!(a.alloc(), Some(p1));
1097    }
1098
1099    #[test]
1100    fn allocator_reset_restores_all_pages() {
1101        let mut a = PageAllocator::new(4);
1102        a.alloc();
1103        a.alloc();
1104        a.reset(4);
1105        assert_eq!(a.num_free(), 4);
1106        assert_eq!(a.alloc(), Some(0));
1107    }
1108
1109    #[test]
1110    fn cache_config_num_pages_rounds_up() {
1111        assert_eq!(CacheConfig::paged(16).num_pages(), 1);
1112        assert_eq!(CacheConfig::paged(17).num_pages(), 2);
1113        assert_eq!(CacheConfig::paged(1).num_pages(), 1);
1114    }
1115
1116    // Page-table bookkeeping without touching tensors: PagedKVCache only
1117    // allocates arena tensors lazily inside `attention`, so popn/reset paths
1118    // can be exercised on the NdArray backend without a GPU.
1119    type TestBackend = burn::backend::NdArray<f32>;
1120
1121    fn cache(max_seq_len: usize, page_size: usize) -> PagedKVCache<TestBackend> {
1122        PagedKVCache::new(
1123            2,
1124            CacheConfig {
1125                max_seq_len,
1126                page_size,
1127                kind: CacheKind::Paged,
1128                quantize_kv: false,
1129            },
1130        )
1131    }
1132
1133    /// Simulates page-table growth without tensors (mirrors ensure_pages).
1134    fn grow(c: &mut PagedKVCache<TestBackend>, total: usize) {
1135        c.ensure_pages(total);
1136        c.seq_len = total;
1137    }
1138
1139    #[test]
1140    fn popn_frees_only_fully_unused_pages() {
1141        let mut c = cache(64, 16);
1142        grow(&mut c, 40); // pages 0,1,2 (page 2 holds slots 32..39)
1143        assert_eq!(c.pages_used(), Some(3));
1144        assert_eq!(c.num_free_pages(), 1);
1145
1146        c.popn(9); // seq 31 -> page 2 fully unused, freed
1147        assert_eq!(c.seq_len(), 31);
1148        assert_eq!(c.pages_used(), Some(2));
1149        assert_eq!(c.num_free_pages(), 2);
1150
1151        c.popn(15); // seq 16 -> page 1 still needed (slots 16..31)
1152        assert_eq!(c.pages_used(), Some(1));
1153        c.popn(1); // seq 15 -> page 0 still needed
1154        assert_eq!(c.pages_used(), Some(1));
1155
1156        c.popn(1000); // clamps to seq_len
1157        assert_eq!(c.seq_len(), 0);
1158        assert_eq!(c.pages_used(), Some(0));
1159        assert_eq!(c.num_free_pages(), 4);
1160    }
1161
1162    #[test]
1163    fn popn_boundary_exact_page_edge() {
1164        let mut c = cache(64, 16);
1165        grow(&mut c, 32); // exactly 2 pages
1166        c.popn(16); // seq 16 -> 1 page
1167        assert_eq!(c.pages_used(), Some(1));
1168        assert_eq!(c.num_free_pages(), 3);
1169        c.popn(16);
1170        assert_eq!(c.pages_used(), Some(0));
1171        assert_eq!(c.num_free_pages(), 4);
1172    }
1173
1174    #[test]
1175    fn regrowth_after_popn_reuses_freed_pages() {
1176        let mut c = cache(64, 16);
1177        grow(&mut c, 40);
1178        c.popn(9); // frees page for slots 32..48
1179        grow(&mut c, 33); // needs a page again -> reuses the freed one
1180        assert_eq!(c.pages_used(), Some(3));
1181        assert_eq!(c.num_free_pages(), 1);
1182    }
1183
1184    #[test]
1185    fn reset_releases_all_pages() {
1186        let mut c = cache(64, 16);
1187        grow(&mut c, 40);
1188        c.reset();
1189        assert_eq!(c.seq_len(), 0);
1190        assert_eq!(c.pages_used(), Some(0));
1191        assert_eq!(c.num_free_pages(), 4);
1192    }
1193
1194    // ---- GPU parity (ignored by default; run with `-- --ignored`) ----
1195    //
1196    // The NdArray<f32> tests above cannot see dtype-mismatch bugs: on the
1197    // f16 build the arena tensors are f16 while kv_quantize computes its
1198    // scales in f32. These run the same checks on the production
1199    // `combs_core::CombsBackend` of the current build.
1200
1201    /// Deterministic K/V/Q builders for any backend.
1202    fn kv_tok_on<B: Backend>(
1203        dev: &B::Device,
1204        i: usize,
1205        n_kv: usize,
1206        d: usize,
1207    ) -> (Tensor<B, 4>, Tensor<B, 4>) {
1208        let mk = |salt: usize| {
1209            let data: Vec<f32> = (0..n_kv * d)
1210                .map(|j| ((i * 7 + j * 3 + salt) % 13) as f32 / 13.0 - 0.5)
1211                .collect();
1212            Tensor::<B, 4>::from_data(TensorData::new(data, [1, n_kv, 1, d]), dev)
1213        };
1214        (mk(0), mk(5))
1215    }
1216
1217    fn q_tok_on<B: Backend>(dev: &B::Device, i: usize, n_q: usize, d: usize) -> Tensor<B, 4> {
1218        let data: Vec<f32> = (0..n_q * d)
1219            .map(|j| ((i * 11 + j * 5) % 17) as f32 / 17.0 - 0.5)
1220            .collect();
1221        Tensor::<B, 4>::from_data(TensorData::new(data, [1, n_q, 1, d]), dev)
1222    }
1223
1224    /// Quantize→dequantize on the given backend must return finite values
1225    /// near the input — the f16-build corruption shows up as NaN/garbage.
1226    fn quant_roundtrip_on<B: Backend>(dev: &B::Device) {
1227        let d = 64usize;
1228        let data: Vec<f32> = (0..2 * d)
1229            .map(|j| ((j * 5) % 251) as f32 / 251.0 - 0.5)
1230            .collect();
1231        let x = Tensor::<B, 4>::from_data(TensorData::new(data.clone(), [1, 2, 1, d]), dev);
1232        let (packed, scales) = kv_quantize(x);
1233        let y = kv_dequantize(packed, scales, d);
1234        let yv: Vec<f32> = y.into_data().convert::<f32>().to_vec().unwrap();
1235        for (i, (orig, got)) in data.iter().zip(yv.iter()).enumerate() {
1236            assert!(got.is_finite(), "dequant[{i}] not finite: {got}");
1237            assert!(
1238                (orig - got).abs() < 0.01,
1239                "dequant[{i}]: {orig} vs {got}"
1240            );
1241        }
1242    }
1243
1244    /// The fp-vs-quantized attend parity check on the given backend.
1245    fn quant_parity_on<B: Backend>(dev: &B::Device, tol: f32) {
1246        let (n_kv, n_q, d) = (2usize, 4usize, 32usize);
1247        let mut cfg_q = CacheConfig::paged(64);
1248        cfg_q.quantize_kv = true;
1249        let cfg_f = CacheConfig::paged(64);
1250        let scale = 1.0 / (d as f64).sqrt() * 0.9;
1251        let mut fp = PagedKVCache::<B>::new(1, cfg_f);
1252        let mut qn = PagedKVCache::<B>::new(1, cfg_q);
1253        for i in 0..24 {
1254            let (k, v) = kv_tok_on::<B>(dev, i, n_kv, d);
1255            let q = q_tok_on::<B>(dev, i, n_q, d);
1256            let a = fp.attention_opts(0, q.clone(), k.clone(), v.clone(), i, scale, None);
1257            let b = qn.attention_opts(0, q, k, v, i, scale, None);
1258            let av: Vec<f32> = a.into_data().convert::<f32>().to_vec().unwrap();
1259            let bv: Vec<f32> = b.into_data().convert::<f32>().to_vec().unwrap();
1260            for (j, (x, y)) in av.iter().zip(bv.iter()).enumerate() {
1261                assert!(y.is_finite(), "step {i}[{j}] not finite: {y}");
1262                assert!((x - y).abs() < tol, "step {i}[{j}]: {x} vs {y}");
1263            }
1264        }
1265    }
1266
1267    #[test]
1268    #[ignore = "gpu"]
1269    fn kv_quant_roundtrip_on_production_backend() {
1270        quant_roundtrip_on::<combs_core::CombsBackend>(&Default::default());
1271    }
1272
1273    #[test]
1274    #[ignore = "gpu"]
1275    fn quantized_paged_cache_matches_fp_on_production_backend() {
1276        quant_parity_on::<combs_core::CombsBackend>(&Default::default(), 5e-2);
1277    }
1278}