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;