spark_runtime/
kv_cache.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Paged KV cache block allocator.
4//!
5//! Manages a pool of fixed-size blocks for attention KV storage.
6//! Each block holds `block_size` token positions for all KV heads.
7
8use crate::gpu::DevicePtr;
9use anyhow::{Result, bail};
10
11pub(crate) const NVFP4_GROUP_SIZE: usize = 16;
12
13/// KV cache quantization dtype.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum KvCacheDtype {
16    /// 2 bytes per element.
17    Bf16,
18    /// 1 byte per element (FP8 E4M3 with per-tensor scale).
19    Fp8,
20    /// 0.5 bytes data + per-group FP8 scale (E2M1 packed nibbles).
21    Nvfp4,
22    /// 4-bit WHT + Lloyd-Max quantization (TurboQuant). Same byte layout as NVFP4
23    /// but with Walsh-Hadamard rotation and optimal Gaussian codebook for ~2x
24    /// lower MSE at the same bit rate.
25    Turbo4,
26    /// 3-bit WHT + Lloyd-Max (8 levels). 22% smaller than turbo4.
27    Turbo3,
28    /// 2-bit WHT + Lloyd-Max (4 levels). 6.4x compression vs bf16 (3 bits/elem
29    /// total: 2 b data + 0.5 b scale + 0.5 b layout overhead). Full write +
30    /// paged-decode + chunked-prefill kernel coverage. 2-bit keys cannot
31    /// sustain tool-grammar constrained decoding with the standard boundary
32    /// policy; requires the higher auto high-precision-layer default (see
33    /// `auto_high_precision_layers`) validated on the GB10 flagship.
34    Turbo2,
35    /// WHT + FP8 E4M3. Same memory as FP8 but with outlier suppression.
36    /// Enables FP8-level memory for models with large RMS norm weights.
37    Turbo8,
38    /// TurboQuant+ asymmetric: K stored at turbo4 (4-bit), V at turbo3 (3-bit).
39    /// K dominates attention score precision; V tolerates lower precision per
40    /// turboquant_plus/docs/papers/asymmetric-kv-compression.md. Saves ~14%
41    /// bandwidth at decode (4.5 b/elem K + 3.375 b/elem V vs 4.5 + 4.5
42    /// symmetric turbo4). Decode kernel dispatch needs a new
43    /// `paged_decode_attn_turbo4k_turbo3v` variant; write kernel forks
44    /// `reshape_and_cache_flash_turbo4` for K and `..._turbo3` for V on the
45    /// same launch.
46    Turbo4KTurbo3V,
47    /// K=turbo4, V=turbo8. K=4-bit codebook; V=FP8. ~11% bandwidth saving vs
48    /// pure turbo8 symmetric. Same dispatch-table follow-up applies.
49    Turbo4KTurbo8V,
50    /// K=turbo3, V=turbo8. Smallest K (3-bit) with V=FP8 retention.
51    Turbo3KTurbo8V,
52    /// TurboQuant+ safer-asym: K stored at BF16 baseline (full precision), V
53    /// compressed to turbo4 4-bit codebook. Preserves K's attention-score
54    /// fidelity completely while compressing V which dominates KV bandwidth
55    /// at long context.
56    Bf16KTurbo4V,
57    /// K=bf16, V=turbo3 (3-bit). Aggressive V compression with full-precision K.
58    Bf16KTurbo3V,
59    /// K=fp8 (1 byte/elem with per-tensor scale), V=turbo4. K kept at the
60    /// usual fp8 quality; V at 4-bit codebook. Middle ground between bf16/turbo
61    /// and pure turbo8.
62    Fp8KTurbo4V,
63    /// K=fp8, V=turbo3. Smallest combo retaining fp8 K precision.
64    Fp8KTurbo3V,
65    /// K=bf16 baseline, V=turbo2 (2-bit). Most aggressive V compression with
66    /// full-precision K. Per asymmetric-kv-compression.md: symmetric turbo2/
67    /// turbo2 collapses quality (+58.5% PPL); this asym preserves K and only
68    /// pays the +9.5% V-side cost — 6× better quality at the same V compression.
69    Bf16KTurbo2V,
70    /// K=fp8, V=turbo2. The canonical "asymmetric rescue" config (analog of
71    /// llama-cpp-turboquant's `q8_0/turbo2`). Best compression-to-quality
72    /// ratio for turbo2 V on tested models.
73    Fp8KTurbo2V,
74}
75
76impl std::fmt::Display for KvCacheDtype {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        // Delegates to the catalogue so the canonical spelling exists in ONE
79        // match — see `catalog::name` for why that match must stay exhaustive.
80        f.write_str(self.name())
81    }
82}
83
84impl KvCacheDtype {
85    /// Returns the (K_dtype, V_dtype) pair. For symmetric variants both
86    /// elements are identical. For asymmetric variants the pair differs.
87    pub fn kv_pair(self) -> (KvCacheDtype, KvCacheDtype) {
88        match self {
89            KvCacheDtype::Turbo4KTurbo3V => (KvCacheDtype::Turbo4, KvCacheDtype::Turbo3),
90            KvCacheDtype::Turbo4KTurbo8V => (KvCacheDtype::Turbo4, KvCacheDtype::Turbo8),
91            KvCacheDtype::Turbo3KTurbo8V => (KvCacheDtype::Turbo3, KvCacheDtype::Turbo8),
92            KvCacheDtype::Bf16KTurbo4V => (KvCacheDtype::Bf16, KvCacheDtype::Turbo4),
93            KvCacheDtype::Bf16KTurbo3V => (KvCacheDtype::Bf16, KvCacheDtype::Turbo3),
94            KvCacheDtype::Fp8KTurbo4V => (KvCacheDtype::Fp8, KvCacheDtype::Turbo4),
95            KvCacheDtype::Fp8KTurbo3V => (KvCacheDtype::Fp8, KvCacheDtype::Turbo3),
96            KvCacheDtype::Bf16KTurbo2V => (KvCacheDtype::Bf16, KvCacheDtype::Turbo2),
97            KvCacheDtype::Fp8KTurbo2V => (KvCacheDtype::Fp8, KvCacheDtype::Turbo2),
98            other => (other, other),
99        }
100    }
101
102    /// True for the symmetric turbo dtypes whose cache contents are stored
103    /// in the WHT-rotated basis (the write path applies `wht_bf16_inplace`
104    /// before quantizing). Gates the WHT(Q) / iWHT(out) attention bookends —
105    /// call on the K or V side of `kv_pair()`, not on the combined variant.
106    /// Turbo2 is rotated by the write path like the rest; omitting it here
107    /// is what desynced the decode bookends from the write path.
108    pub fn is_wht_rotated(self) -> bool {
109        matches!(
110            self,
111            KvCacheDtype::Turbo2
112                | KvCacheDtype::Turbo3
113                | KvCacheDtype::Turbo4
114                | KvCacheDtype::Turbo8
115        )
116    }
117
118    /// True if K and V use different storage layouts.
119    pub fn is_asymmetric(self) -> bool {
120        matches!(
121            self,
122            KvCacheDtype::Turbo4KTurbo3V
123                | KvCacheDtype::Turbo4KTurbo8V
124                | KvCacheDtype::Turbo3KTurbo8V
125                | KvCacheDtype::Bf16KTurbo4V
126                | KvCacheDtype::Bf16KTurbo3V
127                | KvCacheDtype::Fp8KTurbo4V
128                | KvCacheDtype::Fp8KTurbo3V
129                | KvCacheDtype::Bf16KTurbo2V
130                | KvCacheDtype::Fp8KTurbo2V
131        )
132    }
133}
134
135impl std::str::FromStr for KvCacheDtype {
136    type Err = anyhow::Error;
137    fn from_str(s: &str) -> Result<Self> {
138        match s {
139            "bf16" => Ok(KvCacheDtype::Bf16),
140            "fp8" => Ok(KvCacheDtype::Fp8),
141            "nvfp4" => Ok(KvCacheDtype::Nvfp4),
142            "turbo4" => Ok(KvCacheDtype::Turbo4),
143            "turbo3" => Ok(KvCacheDtype::Turbo3),
144            "turbo2" => Ok(KvCacheDtype::Turbo2),
145            "turbo8" => Ok(KvCacheDtype::Turbo8),
146            "turbo4k_turbo3v" | "turbo4k3v" => Ok(KvCacheDtype::Turbo4KTurbo3V),
147            "turbo4k_turbo8v" | "turbo4k8v" => Ok(KvCacheDtype::Turbo4KTurbo8V),
148            "turbo3k_turbo8v" | "turbo3k8v" => Ok(KvCacheDtype::Turbo3KTurbo8V),
149            "bf16k_turbo4v" | "bf16k4v" => Ok(KvCacheDtype::Bf16KTurbo4V),
150            "bf16k_turbo3v" | "bf16k3v" => Ok(KvCacheDtype::Bf16KTurbo3V),
151            "fp8k_turbo4v" | "fp8k4v" => Ok(KvCacheDtype::Fp8KTurbo4V),
152            "fp8k_turbo3v" | "fp8k3v" => Ok(KvCacheDtype::Fp8KTurbo3V),
153            "bf16k_turbo2v" | "bf16k2v" => Ok(KvCacheDtype::Bf16KTurbo2V),
154            "fp8k_turbo2v" | "fp8k2v" => Ok(KvCacheDtype::Fp8KTurbo2V),
155            other => bail!(
156                "Unsupported --kv-cache-dtype '{other}'. Symmetric: 'bf16', 'fp8', 'nvfp4', 'turbo4', 'turbo3', 'turbo8'. \
157                Asymmetric (TQ+): turbo*_turbo*v, bf16k_turbo[34]v (safer asym: K baseline, V compressed), fp8k_turbo[34]v."
158            ),
159        }
160    }
161}
162
163/// Configuration for the paged KV cache.
164pub struct KvCacheConfig {
165    /// Tokens per block.
166    pub block_size: usize,
167    /// Number of KV heads.
168    pub num_kv_heads: usize,
169    /// Dimension per head.
170    pub head_dim: usize,
171    /// Number of attention layers (only full_attention layers have KV cache).
172    pub num_layers: usize,
173    /// Quantization dtype for cache storage (uniform fallback).
174    pub dtype: KvCacheDtype,
175    /// Per-layer KV cache dtype override. When non-empty, `layer_dtypes[i]`
176    /// specifies the dtype for attention layer `i`. When empty, all layers
177    /// use the uniform `dtype` field (backward compatible).
178    pub layer_dtypes: Vec<KvCacheDtype>,
179    /// Per-layer (num_kv_heads, head_dim) overrides for heterogeneous
180    /// attention models (e.g. Gemma-4 with sliding 16×256 vs full 4×512).
181    /// When non-empty, `layer_dims[i]` specifies the (nkv, hd) for layer
182    /// `i`; allocation and kernel stride computations use these per-layer
183    /// values so writes/reads land at the correct offsets. When empty,
184    /// all layers use the uniform `num_kv_heads`/`head_dim` (backward
185    /// compatible — homogeneous models need no change).
186    pub layer_dims: Vec<(usize, usize)>,
187    /// `--high-speed-swap` HBM-shrink knob (Phase 6.1). When `Some(N)`,
188    /// each sequence is capped at `N` HBM-resident blocks; older blocks
189    /// are evicted to disk via `HighSpeedSwap` and read back on demand.
190    /// `None` (default) preserves the existing behavior — sequences hold
191    /// every block in HBM forever and rely on `--swap-space-gb` for
192    /// admission control. The `try_evict_oldest_for_seq` helper below is
193    /// only valid when this is `Some`.
194    pub cache_blocks_per_seq: Option<u32>,
195}
196
197impl KvCacheConfig {
198    /// Resolve the effective dtype for a given attention layer index.
199    pub fn dtype_for_layer(&self, layer_idx: usize) -> KvCacheDtype {
200        if layer_idx < self.layer_dtypes.len() {
201            self.layer_dtypes[layer_idx]
202        } else {
203            self.dtype
204        }
205    }
206
207    /// Bytes per block (K or V, not both) for a specific dtype and
208    /// (num_kv_heads, head_dim) pair. Per-layer callers pass their layer's
209    /// actual dimensions; homogeneous callers pass the global values.
210    fn block_bytes_dims(&self, dtype: KvCacheDtype, nkv: usize, hd: usize) -> usize {
211        let elems = self.block_size * nkv * hd;
212        match dtype {
213            KvCacheDtype::Bf16
214            | KvCacheDtype::Bf16KTurbo4V
215            | KvCacheDtype::Bf16KTurbo3V
216            | KvCacheDtype::Bf16KTurbo2V => elems * 2,
217            KvCacheDtype::Fp8
218            | KvCacheDtype::Fp8KTurbo4V
219            | KvCacheDtype::Fp8KTurbo3V
220            | KvCacheDtype::Fp8KTurbo2V => elems,
221            KvCacheDtype::Nvfp4
222            | KvCacheDtype::Turbo4
223            | KvCacheDtype::Turbo4KTurbo3V
224            | KvCacheDtype::Turbo4KTurbo8V => {
225                // Both NVFP4 and Turbo4 use 4-bit data + FP8 per-group scales.
226                // Same byte layout, different codebook (E2M1 vs Lloyd-Max).
227                let data = elems / 2; // 2 nibbles per byte
228                let num_groups = elems / NVFP4_GROUP_SIZE;
229                data + num_groups // +1 FP8 scale byte per group
230            }
231            KvCacheDtype::Turbo3 | KvCacheDtype::Turbo3KTurbo8V => {
232                // 3-bit WHT + Lloyd-Max (8 levels). Packed: 8 values in 3 bytes.
233                let data = elems * 3 / 8;
234                let num_groups = elems / NVFP4_GROUP_SIZE;
235                data + num_groups
236            }
237            KvCacheDtype::Turbo2 => {
238                // 2-bit WHT + Lloyd-Max (4 levels). Packed: 4 values per byte.
239                let data = elems / 4;
240                let num_groups = elems / NVFP4_GROUP_SIZE;
241                data + num_groups
242            }
243            KvCacheDtype::Turbo8 => {
244                // WHT + FP8 E4M3 data + per-group BF16 scales.
245                // 2026-04-28: scales upgraded from FP8 (1 byte) to BF16 (2 bytes)
246                // because FP8's ~12% per-scale relative error compounds
247                // catastrophically across MiniMax M2.7's 58 Turbo8 layers
248                // (gibberish output). BF16 scales (~0.4% relative error)
249                // keep compounding tractable. ~6% extra cache memory for
250                // a 256× precision improvement on the per-group scaling.
251                let num_groups = elems / NVFP4_GROUP_SIZE;
252                elems + num_groups * 2 // 1 byte data + BF16 scale per group
253            }
254        }
255    }
256
257    /// V-side bytes per block for asymmetric dtypes; equals block_bytes_dims
258    /// for symmetric dtypes. Use this when allocating the V pool separately
259    /// from K. Once real asym kernels land, callers should switch to:
260    ///   k_size = block_bytes_dims(kv_pair().0, ...)
261    ///   v_size = block_bytes_dims(kv_pair().1, ...)
262    /// and allocate the two pools independently.
263    #[allow(dead_code)]
264    pub fn v_block_bytes_dims(&self, dtype: KvCacheDtype, nkv: usize, hd: usize) -> usize {
265        let (_, v) = dtype.kv_pair();
266        self.block_bytes_dims(v, nkv, hd)
267    }
268
269    /// K-side bytes per block for a specific (asym-aware) dtype and dims.
270    /// For symmetric dtypes, returns the same value as `block_bytes_dims`.
271    /// For asymmetric, returns the K-component (e.g. Bf16KTurbo3V → bf16 bytes).
272    #[allow(dead_code)]
273    pub fn k_block_bytes_dims(&self, dtype: KvCacheDtype, nkv: usize, hd: usize) -> usize {
274        let (k, _) = dtype.kv_pair();
275        self.block_bytes_dims(k, nkv, hd)
276    }
277
278    /// K-side bytes per block for a specific attention layer.
279    /// Replaces the legacy single-stride view for asym dtypes by routing
280    /// through the K component of the dtype pair.
281    pub fn k_block_bytes_for_layer(&self, layer_idx: usize) -> usize {
282        let (nkv, hd) = self.dims_for_layer(layer_idx);
283        let (k, _) = self.dtype_for_layer(layer_idx).kv_pair();
284        self.block_bytes_dims(k, nkv, hd)
285    }
286
287    /// V-side bytes per block for a specific attention layer.
288    /// For symmetric dtypes this equals `k_block_bytes_for_layer`.
289    pub fn v_block_bytes_for_layer(&self, layer_idx: usize) -> usize {
290        let (nkv, hd) = self.dims_for_layer(layer_idx);
291        let (_, v) = self.dtype_for_layer(layer_idx).kv_pair();
292        self.block_bytes_dims(v, nkv, hd)
293    }
294
295    /// Legacy name: bytes per block using global dims and a given dtype.
296    /// Used when the caller has a uniform KV geometry; prefer
297    /// `block_bytes_for_layer` for layer-aware paths.
298    fn block_bytes_for_dtype(&self, dtype: KvCacheDtype) -> usize {
299        self.block_bytes_dims(dtype, self.num_kv_heads, self.head_dim)
300    }
301
302    /// Bytes per block per layer (K or V, not both), using the uniform dtype.
303    pub fn block_bytes(&self) -> usize {
304        self.block_bytes_for_dtype(self.dtype)
305    }
306
307    /// (num_kv_heads, head_dim) for a specific attention layer.
308    /// Falls back to the global values when no per-layer override is set.
309    pub fn dims_for_layer(&self, layer_idx: usize) -> (usize, usize) {
310        if layer_idx < self.layer_dims.len() {
311            self.layer_dims[layer_idx]
312        } else {
313            (self.num_kv_heads, self.head_dim)
314        }
315    }
316
317    /// Bytes per block for a specific attention layer (K or V, not both).
318    /// Uses per-layer (nkv, hd) from `layer_dims` when set — this lets
319    /// heterogeneous layers (Gemma-4 sliding vs full) allocate tight,
320    /// correctly-sized pools so the kernel's per-layer stride matches
321    /// the allocation layout.
322    pub fn block_bytes_for_layer(&self, layer_idx: usize) -> usize {
323        let (nkv, hd) = self.dims_for_layer(layer_idx);
324        self.block_bytes_dims(self.dtype_for_layer(layer_idx), nkv, hd)
325    }
326
327    /// Bytes per block per layer (K + V combined).
328    pub fn block_bytes_kv(&self) -> usize {
329        self.block_bytes() * 2
330    }
331
332    /// Sum of K+V block bytes across all layers for one block slot.
333    /// Accounts for mixed dtypes when layer_dtypes is set AND asym K/V splits.
334    pub fn block_bytes_kv_all_layers(&self) -> usize {
335        (0..self.num_layers)
336            .map(|i| self.k_block_bytes_for_layer(i) + self.v_block_bytes_for_layer(i))
337            .sum()
338    }
339
340    /// Cache stride in elements (for FP8/BF16 kernels).
341    /// Elements per block = block_size * num_kv_heads * head_dim.
342    pub fn cache_stride_elements(&self) -> usize {
343        self.block_size * self.num_kv_heads * self.head_dim
344    }
345
346    /// NVFP4 data section bytes per block (packed E2M1 nibbles).
347    pub fn nvfp4_data_bytes(&self) -> usize {
348        self.block_size * self.num_kv_heads * self.head_dim / 2
349    }
350
351    /// NVFP4 scale section bytes per block (FP8 per-group scales).
352    pub fn nvfp4_scale_bytes(&self) -> usize {
353        self.block_size * self.num_kv_heads * self.head_dim / NVFP4_GROUP_SIZE
354    }
355
356    /// Turbo4 data section bytes (same layout as NVFP4: 4-bit packed).
357    pub fn turbo4_data_bytes(&self) -> usize {
358        self.nvfp4_data_bytes()
359    }
360
361    /// Turbo4 scale section bytes (same layout as NVFP4: FP8 per-group).
362    pub fn turbo4_scale_bytes(&self) -> usize {
363        self.nvfp4_scale_bytes()
364    }
365
366    /// Turbo3 data section bytes (3-bit packed: 8 values in 3 bytes).
367    pub fn turbo3_data_bytes(&self) -> usize {
368        let elems = self.block_size * self.num_kv_heads * self.head_dim;
369        elems * 3 / 8
370    }
371
372    /// Turbo3 scale section bytes (FP8 per-group, same as turbo4).
373    pub fn turbo3_scale_bytes(&self) -> usize {
374        self.nvfp4_scale_bytes()
375    }
376
377    /// Turbo2 data section bytes (2-bit packed: 4 values per byte).
378    pub fn turbo2_data_bytes(&self) -> usize {
379        let elems = self.block_size * self.num_kv_heads * self.head_dim;
380        elems / 4
381    }
382
383    /// Turbo2 scale section bytes (FP8 per-group, same as turbo3/turbo4).
384    pub fn turbo2_scale_bytes(&self) -> usize {
385        self.nvfp4_scale_bytes()
386    }
387
388    /// Turbo8 data section bytes (FP8 E4M3: 1 byte per element).
389    pub fn turbo8_data_bytes(&self) -> usize {
390        self.block_size * self.num_kv_heads * self.head_dim
391    }
392
393    /// Turbo8 scale section bytes — **BF16 per-group scales** (2 bytes
394    /// each, vs the 1-byte FP8 scales NVFP4/Turbo3/Turbo4 use). The
395    /// BF16 upgrade is what makes Turbo8 viable across many-layer models
396    /// like MiniMax M2.7 (58 Turbo8 layers under auto HP=2). Returns
397    /// `(num_groups) * 2` bytes total.
398    pub fn turbo8_scale_bytes(&self) -> usize {
399        // num_groups = elems / GROUP_SIZE; each scale is 2 bytes (BF16).
400        self.nvfp4_scale_bytes() * 2
401    }
402}
403
404/// Per-layer KV cache pool.
405struct LayerPool {
406    k_pool: DevicePtr,
407    v_pool: DevicePtr,
408    /// Stride between K blocks in bytes (may differ from V for asym dtypes).
409    k_block_stride: usize,
410    /// Stride between V blocks in bytes (may differ from K for asym dtypes).
411    v_block_stride: usize,
412    /// Effective dtype for this layer.
413    dtype: KvCacheDtype,
414}
415
416/// Paged KV cache across all attention layers.
417pub struct PagedKvCache {
418    layers: Vec<LayerPool>,
419    num_blocks: usize,
420    free_blocks: Vec<u32>,
421    /// Per-block reference count. Enables shared blocks (prefix caching).
422    /// Default: 1 on alloc, freed when decremented to 0.
423    block_ref_counts: Vec<u32>,
424    config: KvCacheConfig,
425    /// Per-block refcount event history (`ATLAS_KV_TRACE=1`; inert otherwise).
426    trace: block_trace::BlockTrace,
427}
428
429mod block_trace;
430mod catalog;
431mod paged_impl;
432/// Release both pools of every layer.
433///
434/// Each layer allocates its K and V pools separately, so freeing per layer is
435/// correct. The block bookkeeping (`free_blocks`, `block_ref_counts`) is host
436/// state indexing into those pools — cleared with them so a released cache
437/// cannot hand out a block into freed memory.
438impl atlas_core::scope::ModelResource<dyn crate::gpu::GpuBackend> for PagedKvCache {
439    fn label(&self) -> &'static str {
440        "kv cache"
441    }
442
443    fn release(&mut self, gpu: &dyn crate::gpu::GpuBackend) -> anyhow::Result<()> {
444        let mut first_error = None;
445        for layer in self.layers.drain(..) {
446            for ptr in [layer.k_pool, layer.v_pool] {
447                if let Err(e) = gpu.free(ptr)
448                    && first_error.is_none()
449                {
450                    first_error = Some(e);
451                }
452            }
453        }
454        self.free_blocks.clear();
455        self.block_ref_counts.clear();
456        self.num_blocks = 0;
457        match first_error {
458            Some(e) => Err(e),
459            None => Ok(()),
460        }
461    }
462}
463
464#[cfg(test)]
465mod tests;
466
467#[cfg(test)]
468mod tests_tq_plus;