spark_runtime/kv_cache/
paged_impl.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//
3// `PagedKvCache` impl block. Split from `kv_cache.rs` so the parent file
4// keeps the public types small enough to read at a glance. The struct
5// definition lives in the parent.
6
7use anyhow::{Result, bail};
8
9use super::block_trace::BlockTrace;
10use super::{KvCacheConfig, KvCacheDtype, LayerPool, PagedKvCache};
11use crate::gpu::{DevicePtr, GpuBackend};
12
13impl PagedKvCache {
14    /// Allocate the KV cache pool on the GPU.
15    pub fn new(config: KvCacheConfig, num_blocks: usize, gpu: &dyn GpuBackend) -> Result<Self> {
16        let mut layers = Vec::with_capacity(config.num_layers);
17        let mut total_bytes: usize = 0;
18        for i in 0..config.num_layers {
19            // Per-side block_bytes: for symmetric dtypes both are equal; for
20            // asymmetric (e.g. Bf16KTurbo3V) the K pool is allocated bf16-sized
21            // and the V pool is allocated turbo3-sized — avoids the 4× V over-
22            // allocation that would result from a single MAX-sized stride.
23            let k_block_bytes = config.k_block_bytes_for_layer(i);
24            let v_block_bytes = config.v_block_bytes_for_layer(i);
25            let k_pool_bytes = num_blocks * k_block_bytes;
26            let v_pool_bytes = num_blocks * v_block_bytes;
27            let k_pool = gpu.alloc(k_pool_bytes)?;
28            let v_pool = gpu.alloc(v_pool_bytes)?;
29            total_bytes += k_pool_bytes + v_pool_bytes;
30            layers.push(LayerPool {
31                k_pool,
32                v_pool,
33                k_block_stride: k_block_bytes,
34                v_block_stride: v_block_bytes,
35                dtype: config.dtype_for_layer(i),
36            });
37        }
38
39        let free_blocks: Vec<u32> = (0..num_blocks as u32).rev().collect();
40        let block_ref_counts = vec![0u32; num_blocks];
41
42        let has_mixed = !config.layer_dtypes.is_empty()
43            && config.layer_dtypes.iter().any(|d| *d != config.dtype);
44        if has_mixed {
45            let hp_count = config
46                .layer_dtypes
47                .iter()
48                .filter(|d| **d != config.dtype)
49                .count();
50            tracing::info!(
51                "KV cache: {} blocks × {} layers ({} high-precision) = {:.1} GB total (mixed dtype)",
52                num_blocks,
53                config.num_layers,
54                hp_count,
55                total_bytes as f64 / (1024.0 * 1024.0 * 1024.0),
56            );
57        } else {
58            tracing::info!(
59                "KV cache: {} blocks × {} layers × {} bytes/block = {:.1} GB total",
60                num_blocks,
61                config.num_layers,
62                config.block_bytes_kv(),
63                (num_blocks * config.num_layers * config.block_bytes_kv()) as f64
64                    / (1024.0 * 1024.0 * 1024.0),
65            );
66        }
67
68        Ok(Self {
69            layers,
70            num_blocks,
71            free_blocks,
72            block_ref_counts,
73            config,
74            trace: BlockTrace::new(num_blocks),
75        })
76    }
77
78    /// Allocate a free block. Returns block index.
79    #[track_caller]
80    pub fn alloc_block(&mut self) -> Result<u32> {
81        let idx = self
82            .free_blocks
83            .pop()
84            .ok_or_else(|| anyhow::anyhow!("KV cache exhausted: no free blocks"))?;
85        self.block_ref_counts[idx as usize] = 1;
86        if self.trace.is_on() {
87            self.trace
88                .record(idx as usize, "alloc", 1, std::panic::Location::caller());
89        }
90        Ok(idx)
91    }
92
93    /// Zero all KV data in a block across all layers.
94    /// Prevents stale KV data from previous sequences from leaking into
95    /// new sequences via paged attention reads beyond the current seq_len.
96    pub fn zero_block(
97        &self,
98        block_idx: u32,
99        gpu: &dyn crate::gpu::GpuBackend,
100        stream: u64,
101    ) -> anyhow::Result<()> {
102        for layer in &self.layers {
103            let k_offset = block_idx as usize * layer.k_block_stride;
104            let v_offset = block_idx as usize * layer.v_block_stride;
105            gpu.memset_async(
106                layer.k_pool.offset(k_offset),
107                0,
108                layer.k_block_stride,
109                stream,
110            )?;
111            gpu.memset_async(
112                layer.v_pool.offset(v_offset),
113                0,
114                layer.v_block_stride,
115                stream,
116            )?;
117        }
118        Ok(())
119    }
120
121    /// DIAGNOSTIC (ATLAS_KV_POISON): fill a freshly-allocated block with 0xFF
122    /// (a NaN bit-pattern in both bf16 `0xFFFF` and fp8-e4m3 `0xFF`) instead of
123    /// zero. Any KV region that decode/attention reads but prefill never wrote
124    /// then yields deterministic NaN rather than plausible-but-wrong zeros.
125    /// Used to falsify the "unwritten fresh tail block" hypothesis: if cache-ON
126    /// output goes NaN under poison while cache-OFF stays clean, a fresh block
127    /// is being read unwritten; if both stay clean, fresh KV is fully written
128    /// and the run-to-run nondeterminism originates elsewhere (scratch/scan).
129    pub fn poison_block(
130        &self,
131        block_idx: u32,
132        gpu: &dyn crate::gpu::GpuBackend,
133        stream: u64,
134    ) -> anyhow::Result<()> {
135        for layer in &self.layers {
136            let k_offset = block_idx as usize * layer.k_block_stride;
137            let v_offset = block_idx as usize * layer.v_block_stride;
138            gpu.memset_async(
139                layer.k_pool.offset(k_offset),
140                0xFF,
141                layer.k_block_stride,
142                stream,
143            )?;
144            gpu.memset_async(
145                layer.v_pool.offset(v_offset),
146                0xFF,
147                layer.v_block_stride,
148                stream,
149            )?;
150        }
151        Ok(())
152    }
153
154    /// Try to allocate a free block without failing. Returns None if exhausted.
155    #[track_caller]
156    pub fn try_alloc_block(&mut self) -> Option<u32> {
157        let idx = self.free_blocks.pop()?;
158        self.block_ref_counts[idx as usize] = 1;
159        if self.trace.is_on() {
160            self.trace
161                .record(idx as usize, "try_alloc", 1, std::panic::Location::caller());
162        }
163        Some(idx)
164    }
165
166    /// Increment reference count on a block (for prefix cache sharing).
167    #[track_caller]
168    pub fn inc_ref(&mut self, block_idx: u32) {
169        debug_assert!((block_idx as usize) < self.num_blocks);
170        self.block_ref_counts[block_idx as usize] += 1;
171        if self.trace.is_on() {
172            let after = self.block_ref_counts[block_idx as usize];
173            self.trace.record(
174                block_idx as usize,
175                "inc",
176                after,
177                std::panic::Location::caller(),
178            );
179        }
180    }
181
182    /// Decrement reference count. Returns true if block was freed (count hit 0).
183    #[track_caller]
184    pub fn dec_ref(&mut self, block_idx: u32) -> bool {
185        let idx = block_idx as usize;
186        debug_assert!(idx < self.num_blocks);
187        // Saturating, not wrapping: `debug_assert` is compiled out in release and
188        // the workspace sets no `overflow-checks`, so a 0-ref decrement would wrap
189        // to u32::MAX and pin that block for the process lifetime — a silent,
190        // unrecoverable pool leak. Log loudly and refuse instead; an over-release
191        // is a refcount bug worth seeing, not worth crashing or corrupting for.
192        debug_assert!(
193            self.block_ref_counts[idx] > 0,
194            "dec_ref on block with 0 refs"
195        );
196        if self.block_ref_counts[idx] == 0 {
197            let caller = std::panic::Location::caller();
198            tracing::error!(
199                "dec_ref on block {block_idx} with 0 refs (from {caller}) — refcount bug \
200                 (ignoring; would otherwise wrap to u32::MAX and pin the block){}",
201                if self.trace.is_on() {
202                    format!("\n  history: {}", self.trace.dump(idx))
203                } else {
204                    String::from(" [set ATLAS_KV_TRACE=1 for this block's ref history]")
205                }
206            );
207            return false;
208        }
209        self.block_ref_counts[idx] -= 1;
210        if self.trace.is_on() {
211            let after = self.block_ref_counts[idx];
212            self.trace
213                .record(idx, "dec", after, std::panic::Location::caller());
214        }
215        if self.block_ref_counts[idx] == 0 {
216            self.free_blocks.push(block_idx);
217            true
218        } else {
219            false
220        }
221    }
222
223    /// Free a previously allocated block (decrements ref, frees if count hits 0).
224    #[track_caller]
225    pub fn free_block(&mut self, block_idx: u32) {
226        self.dec_ref(block_idx);
227    }
228
229    /// Free all blocks in a block table.
230    #[track_caller]
231    pub fn free_blocks(&mut self, block_table: &[u32]) {
232        for &idx in block_table {
233            self.free_block(idx);
234        }
235    }
236
237    /// Return a block to the free pool directly, bypassing ref counting.
238    /// Used by eviction: the radix tree already removed its reference.
239    #[track_caller]
240    pub fn return_evicted_block(&mut self, block_idx: u32) {
241        let idx = block_idx as usize;
242        debug_assert!(idx < self.num_blocks);
243        // Release exactly the prefix cache's OWN reference (the "+1" a cached
244        // block carries per sequence.rs `inc_ref` when the radix node is
245        // inserted), not every reference. Force-zeroing here freed blocks that
246        // an active sequence still held in its block_table whenever the radix
247        // ref-count and the KV pool ref-count had desynced (e.g. a warm prefill
248        // that matched a prefix then failed to allocate its suffix, or the
249        // sliding-window partial-cache path). alloc_block would then re-hand
250        // that still-live block to a new prefill and zero_block would memset it
251        // under a concurrent decode → aliased KV pointer → CUDA_ERROR_ILLEGAL_
252        // ADDRESS (700). Decrementing keeps a still-referenced block alive.
253        //
254        // Push to the free list ONLY on a real 1->0 transition. A block already
255        // at 0 refs is already ON the free list, so pushing again duplicates the
256        // entry and two subsequent `alloc_block`s hand the SAME physical block to
257        // two sequences: they interleave writes into each other's KV, and when
258        // both later free it the second `dec_ref` underflows. That is reachable
259        // whenever the cache returns a block it never took a ref on — notably a
260        // `partial_suffix` block, which `insert` stores but `cache_sequence`
261        // never `inc_ref`s, and which `evict` nevertheless hands back.
262        if self.block_ref_counts[idx] == 0 {
263            tracing::warn!(
264                "return_evicted_block({block_idx}) with 0 refs (from {}) — the prefix cache \
265                 returned a block it holds no reference on; ignoring (re-pushing it would \
266                 duplicate a free-list entry and alias the block across sequences){}",
267                std::panic::Location::caller(),
268                if self.trace.is_on() {
269                    format!("\n  history: {}", self.trace.dump(idx))
270                } else {
271                    String::new()
272                }
273            );
274            return;
275        }
276        self.block_ref_counts[idx] -= 1;
277        if self.trace.is_on() {
278            let after = self.block_ref_counts[idx];
279            self.trace
280                .record(idx, "evict_return", after, std::panic::Location::caller());
281        }
282        if self.block_ref_counts[idx] == 0 {
283            self.free_blocks.push(idx as u32);
284        }
285    }
286
287    /// Current reference count for a block.
288    pub fn ref_count(&self, block_idx: u32) -> u32 {
289        self.block_ref_counts[block_idx as usize]
290    }
291
292    /// Number of free blocks.
293    pub fn num_free_blocks(&self) -> usize {
294        self.free_blocks.len()
295    }
296
297    /// Get K cache pointer for a layer and block.
298    pub fn k_cache_ptr(&self, layer_idx: usize, block_idx: u32) -> DevicePtr {
299        let layer = &self.layers[layer_idx];
300        layer
301            .k_pool
302            .offset(block_idx as usize * layer.k_block_stride)
303    }
304
305    /// Get V cache pointer for a layer and block.
306    pub fn v_cache_ptr(&self, layer_idx: usize, block_idx: u32) -> DevicePtr {
307        let layer = &self.layers[layer_idx];
308        layer
309            .v_pool
310            .offset(block_idx as usize * layer.v_block_stride)
311    }
312
313    /// DEBUG: decode a BF16 KV block buffer into (sum, ssq, sabs) reductions.
314    /// Each element is 2 bytes (BF16): top 16 bits of an f32. Used by
315    /// `debug_kv_checksum` to fingerprint K/V without cancellation hiding a
316    /// localized per-element divergence.
317    fn bf16_reductions(buf: &[u8]) -> (f64, f64, f64) {
318        let (mut sum, mut ssq, mut sabs) = (0f64, 0f64, 0f64);
319        for c in buf.chunks_exact(2) {
320            let bits = u16::from_le_bytes([c[0], c[1]]);
321            let v = f32::from_bits((bits as u32) << 16) as f64;
322            sum += v;
323            ssq += v * v;
324            sabs += v.abs();
325        }
326        (sum, ssq, sabs)
327    }
328
329    /// DEBUG (env-gated): PER-LAYER K and V fingerprint over `blocks`, emitting
330    /// (sum, ssq, sabs) for each attention layer so a localized divergence
331    /// can't cancel in a global sum. Splits the block list at `boundary_idx`:
332    /// blocks `[0, boundary_idx)` are the REUSED-PREFIX region (carried over
333    /// from a prior turn's prefill) and `[boundary_idx, end)` are the
334    /// RECOMPUTED-SUFFIX region. Each region gets its own per-layer line so we
335    /// can localize the FIRST layer/region where chained (ON) differs from cold
336    /// (OFF). Only valid for BF16 KV (the experiment uses `--kv-cache-dtype
337    /// bf16`); non-BF16 layers are skipped with a one-shot warning.
338    pub fn debug_kv_checksum_per_layer(
339        &self,
340        blocks: &[u32],
341        boundary_idx: usize,
342        gpu: &dyn crate::gpu::GpuBackend,
343        stream: u64,
344        tag: &str,
345    ) {
346        gpu.synchronize(stream).ok();
347        let boundary = boundary_idx.min(blocks.len());
348        let regions: [(&str, &[u32]); 2] = [
349            ("prefix", &blocks[..boundary]),
350            ("suffix", &blocks[boundary..]),
351        ];
352        for (li, layer) in self.layers.iter().enumerate() {
353            if layer.dtype != super::KvCacheDtype::Bf16 {
354                if li == 0 {
355                    tracing::warn!(
356                        "ATLAS_KV_CKSUM[{tag}] layer 0 dtype={:?} != bf16 — probe \
357                         only decodes BF16; skipping",
358                        layer.dtype
359                    );
360                }
361                continue;
362            }
363            // BF16-only probe: K and V strides are equal for symmetric dtypes.
364            let nbytes = layer.k_block_stride;
365            for (rname, rblocks) in &regions {
366                let (mut k_sum, mut k_ssq, mut k_sabs) = (0f64, 0f64, 0f64);
367                let (mut v_sum, mut v_ssq, mut v_sabs) = (0f64, 0f64, 0f64);
368                for &blk in *rblocks {
369                    let mut kb = vec![0u8; nbytes];
370                    let mut vb = vec![0u8; nbytes];
371                    if gpu.copy_d2h(self.k_cache_ptr(li, blk), &mut kb).is_err()
372                        || gpu.copy_d2h(self.v_cache_ptr(li, blk), &mut vb).is_err()
373                    {
374                        continue;
375                    }
376                    let (ks, kq, ka) = Self::bf16_reductions(&kb);
377                    let (vs, vq, va) = Self::bf16_reductions(&vb);
378                    k_sum += ks;
379                    k_ssq += kq;
380                    k_sabs += ka;
381                    v_sum += vs;
382                    v_ssq += vq;
383                    v_sabs += va;
384                }
385                tracing::warn!(
386                    "ATLAS_KV_CKSUM[{tag}] L{li} {rname} nblk={} \
387                     k_sum={k_sum:.4} k_ssq={k_ssq:.4} k_sabs={k_sabs:.4} \
388                     v_sum={v_sum:.4} v_ssq={v_ssq:.4} v_sabs={v_sabs:.4}",
389                    rblocks.len(),
390                );
391            }
392        }
393    }
394
395    /// DEBUG (env-gated): per-LOGICAL-BLOCK K/V fingerprint for ONE layer,
396    /// walking `blocks` in block_table order. Emits (logical_idx,
397    /// physical_block, k_ssq, v_ssq) per block so a per-position aliasing /
398    /// reordering bug (identical region SUM but wrong block→position mapping)
399    /// is visible. BF16 only.
400    pub fn debug_kv_per_block(
401        &self,
402        layer_idx: usize,
403        blocks: &[u32],
404        gpu: &dyn crate::gpu::GpuBackend,
405        stream: u64,
406        tag: &str,
407    ) {
408        gpu.synchronize(stream).ok();
409        let layer = &self.layers[layer_idx];
410        if layer.dtype != super::KvCacheDtype::Bf16 {
411            return;
412        }
413        // BF16-only probe: K and V strides are equal for symmetric dtypes.
414        let nbytes = layer.k_block_stride;
415        for (li, &blk) in blocks.iter().enumerate() {
416            let mut kb = vec![0u8; nbytes];
417            let mut vb = vec![0u8; nbytes];
418            if gpu
419                .copy_d2h(self.k_cache_ptr(layer_idx, blk), &mut kb)
420                .is_err()
421                || gpu
422                    .copy_d2h(self.v_cache_ptr(layer_idx, blk), &mut vb)
423                    .is_err()
424            {
425                continue;
426            }
427            let (_, k_ssq, _) = Self::bf16_reductions(&kb);
428            let (_, v_ssq, _) = Self::bf16_reductions(&vb);
429            tracing::warn!(
430                "ATLAS_KVBLK[{tag}] L{layer_idx} logical={li} phys={blk} \
431                 k_ssq={k_ssq:.4} v_ssq={v_ssq:.4}"
432            );
433        }
434    }
435
436    /// Get the full K cache pool pointer for a layer (for paged decode kernel).
437    pub fn k_pool_ptr(&self, layer_idx: usize) -> DevicePtr {
438        self.layers[layer_idx].k_pool
439    }
440
441    /// Get the full V cache pool pointer for a layer.
442    pub fn v_pool_ptr(&self, layer_idx: usize) -> DevicePtr {
443        self.layers[layer_idx].v_pool
444    }
445
446    /// Cache stride in elements (for FP8/BF16 kernels that need explicit stride).
447    /// Same for all layers (element count is dtype-independent).
448    pub fn cache_stride(&self) -> usize {
449        self.config.cache_stride_elements()
450    }
451
452    /// Block stride in bytes (for NVFP4 kernels), using the uniform dtype.
453    pub fn block_stride_bytes(&self) -> usize {
454        self.config.block_bytes()
455    }
456
457    /// Block stride in bytes for a specific attention layer.
458    /// For symmetric dtypes returns the K stride (which equals V).
459    /// For asymmetric dtypes returns the K-side stride; use
460    /// `v_block_stride_bytes_for_layer` for the V-side stride explicitly.
461    pub fn block_stride_bytes_for_layer(&self, layer_idx: usize) -> usize {
462        self.layers[layer_idx].k_block_stride
463    }
464
465    /// K-side block stride in bytes for a specific attention layer.
466    /// Same as `block_stride_bytes_for_layer`; named for clarity in asym call sites.
467    pub fn k_block_stride_bytes_for_layer(&self, layer_idx: usize) -> usize {
468        self.layers[layer_idx].k_block_stride
469    }
470
471    /// V-side block stride in bytes for a specific attention layer.
472    /// Differs from K-side only for asymmetric KV cache dtypes.
473    pub fn v_block_stride_bytes_for_layer(&self, layer_idx: usize) -> usize {
474        self.layers[layer_idx].v_block_stride
475    }
476
477    /// NVFP4 data section size in bytes per block (uniform).
478    pub fn nvfp4_data_bytes(&self) -> usize {
479        self.config.nvfp4_data_bytes()
480    }
481
482    /// Turbo4 data section bytes (same layout as NVFP4: 4-bit packed).
483    pub fn turbo4_data_bytes(&self) -> usize {
484        self.config.turbo4_data_bytes()
485    }
486
487    /// Turbo3 data section bytes (3-bit packed).
488    pub fn turbo3_data_bytes(&self) -> usize {
489        self.config.turbo3_data_bytes()
490    }
491
492    /// Turbo2 data section bytes (2-bit packed).
493    pub fn turbo2_data_bytes(&self) -> usize {
494        self.config.turbo2_data_bytes()
495    }
496
497    /// Turbo8 data section bytes (FP8 E4M3 per element).
498    pub fn turbo8_data_bytes(&self) -> usize {
499        self.config.turbo8_data_bytes()
500    }
501
502    /// Turbo4 scale section bytes (same layout as NVFP4: FP8 per-group).
503    pub fn turbo4_scale_bytes(&self) -> usize {
504        self.config.turbo4_scale_bytes()
505    }
506
507    /// Cache configuration (read-only). Used by attention layers to query
508    /// the `--high-speed-swap` HBM-shrink cap (`cache_blocks_per_seq`).
509    pub fn config(&self) -> &KvCacheConfig {
510        &self.config
511    }
512
513    /// Effective KV cache dtype for a specific attention layer.
514    pub fn dtype_for_layer(&self, layer_idx: usize) -> KvCacheDtype {
515        self.layers[layer_idx].dtype
516    }
517
518    pub fn block_size(&self) -> usize {
519        self.config.block_size
520    }
521
522    pub fn num_blocks(&self) -> usize {
523        self.num_blocks
524    }
525
526    pub fn dtype(&self) -> KvCacheDtype {
527        self.config.dtype
528    }
529
530    /// Number of attention layers.
531    pub fn num_layers(&self) -> usize {
532        self.config.num_layers
533    }
534
535    /// Read K and V data for one block at one layer from GPU to host.
536    ///
537    /// Returns `(k_data, v_data)` sized to each side's block stride
538    /// (which may differ for asymmetric dtypes).
539    pub fn read_block(
540        &self,
541        layer_idx: usize,
542        block_idx: u32,
543        gpu: &dyn GpuBackend,
544    ) -> Result<(Vec<u8>, Vec<u8>)> {
545        let k_stride = self.layers[layer_idx].k_block_stride;
546        let v_stride = self.layers[layer_idx].v_block_stride;
547        let k_ptr = self.k_cache_ptr(layer_idx, block_idx);
548        let v_ptr = self.v_cache_ptr(layer_idx, block_idx);
549
550        let mut k_data = vec![0u8; k_stride];
551        let mut v_data = vec![0u8; v_stride];
552        gpu.copy_d2h(k_ptr, &mut k_data)?;
553        gpu.copy_d2h(v_ptr, &mut v_data)?;
554
555        Ok((k_data, v_data))
556    }
557
558    /// Write K and V data for one block at one layer from host to GPU.
559    pub fn write_block(
560        &self,
561        layer_idx: usize,
562        block_idx: u32,
563        k_data: &[u8],
564        v_data: &[u8],
565        gpu: &dyn GpuBackend,
566    ) -> Result<()> {
567        let k_ptr = self.k_cache_ptr(layer_idx, block_idx);
568        let v_ptr = self.v_cache_ptr(layer_idx, block_idx);
569        gpu.copy_h2d(k_data, k_ptr)?;
570        gpu.copy_h2d(v_data, v_ptr)?;
571        Ok(())
572    }
573
574    /// Compute how many blocks can fit given available GPU memory.
575    /// Accounts for mixed dtypes when layer_dtypes is set.
576    pub fn compute_num_blocks(config: &KvCacheConfig, available_bytes: usize) -> Result<usize> {
577        let bytes_per_block = config.block_bytes_kv_all_layers();
578        if bytes_per_block == 0 {
579            bail!("KV cache block size is zero");
580        }
581        Ok(available_bytes / bytes_per_block)
582    }
583}