Skip to main content

rlx_runtime/
quantized_kv.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Block-quantized K/V cache — store decode-time history as `q8_0`,
17//! `q4_0`, or `q5_0` GGUF-encoded blocks instead of f32/f16. Memory
18//! saving vs f16 is roughly:
19//!
20//! | scheme | bits/elem | ratio vs f16 |
21//! |--------|-----------|--------------|
22//! | f16    | 16        | 1.0×         |
23//! | q8_0   | 8.5       | 0.53×        |
24//! | q5_0   | 5.5       | 0.34×        |
25//! | q4_0   | 4.5       | 0.28×        |
26//!
27//! Trade-off: quantization adds noise to attention scores. q8_0 is
28//! near-lossless for most decoder LMs; q4_0 typically costs ~0.3 ppl
29//! at 4× memory savings.
30//!
31//! Layout per layer
32//! ----------------
33//!
34//! Each layer's K and V buffer is a flat `Vec<u8>` of `past_len`
35//! quantized rows. Every "row" is `kv_dim` f32 elements when
36//! dequantized; rows are stored back-to-back. `kv_dim` must be a
37//! multiple of the scheme's block size (32 for all three schemes).
38//!
39//! On read, callers materialize a window of rows to f32 via
40//! [`dequant_rows`]. On write, freshly produced f32 K/V is quantized
41//! one row at a time via [`quant_rows`] before being appended. The
42//! quantization wrappers route to the `rlx_gguf::quantize` /
43//! `dequant_*` kernels for parity with on-disk GGUF blocks.
44
45use anyhow::{Result, anyhow, bail};
46use rlx_gguf::{GgmlType, quantize};
47
48/// Quantization scheme for cache rows. Restricted to the three
49/// q-formats whose blocks are 32 elements wide and stable across
50/// llama.cpp versions. The K-quants (Q4_K etc.) require 256-element
51/// blocks, which doesn't compose cleanly with typical kv_dim values
52/// (e.g. 128 head dim) so we don't expose them here.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum KvQuant {
55    /// `f16` — lossless storage of f32→f16 (no quantization). Kept as a
56    /// useful baseline; 2 bytes per element.
57    F16,
58    Q8_0,
59    Q4_0,
60    Q5_0,
61}
62
63impl KvQuant {
64    /// On-disk block size in elements.
65    pub const fn block_elements(self) -> usize {
66        match self {
67            Self::F16 => 1,
68            Self::Q8_0 | Self::Q4_0 | Self::Q5_0 => 32,
69        }
70    }
71
72    /// On-disk block size in bytes.
73    pub const fn block_bytes(self) -> usize {
74        match self {
75            Self::F16 => 2,
76            Self::Q8_0 => 2 + 32,
77            Self::Q4_0 => 2 + 32 / 2,
78            Self::Q5_0 => 2 + 4 + 32 / 2,
79        }
80    }
81
82    fn ggml_type(self) -> Option<GgmlType> {
83        match self {
84            Self::F16 => None, // direct f16 path
85            Self::Q8_0 => Some(GgmlType::Q8_0),
86            Self::Q4_0 => Some(GgmlType::Q4_0),
87            Self::Q5_0 => Some(GgmlType::Q5_0),
88        }
89    }
90
91    /// Bytes required to store `n_elements` quantized.
92    pub fn bytes_for(self, n_elements: usize) -> Result<usize> {
93        let blk = self.block_elements();
94        if !n_elements.is_multiple_of(blk) {
95            bail!("{self:?}: element count {n_elements} not aligned to block size {blk}");
96        }
97        Ok((n_elements / blk) * self.block_bytes())
98    }
99}
100
101/// One layer's quantized K/V buffers.
102///
103/// Rows are appended back-to-back. `past_len` is the number of *logical*
104/// rows currently stored; the byte buffers carry `past_len × kv_dim`
105/// elements' worth of quantized bytes.
106#[derive(Debug, Clone)]
107pub struct QuantizedKvLayer {
108    pub k: Vec<u8>,
109    pub v: Vec<u8>,
110    pub past_len: usize,
111    pub kv_dim: usize,
112    pub scheme: KvQuant,
113}
114
115impl QuantizedKvLayer {
116    pub fn new(kv_dim: usize, scheme: KvQuant) -> Result<Self> {
117        let blk = scheme.block_elements();
118        if !kv_dim.is_multiple_of(blk) {
119            bail!("kv_dim ({kv_dim}) must be a multiple of {scheme:?} block size ({blk})");
120        }
121        Ok(Self {
122            k: Vec::new(),
123            v: Vec::new(),
124            past_len: 0,
125            kv_dim,
126            scheme,
127        })
128    }
129
130    /// Append `rows` worth of K and V (f32, row-major) — quantizing
131    /// each row independently. Caller passes interleaved K then V
132    /// blocks via separate slices.
133    pub fn append_rows(&mut self, k_rows: &[f32], v_rows: &[f32]) -> Result<()> {
134        if k_rows.len() != v_rows.len() {
135            bail!(
136                "append_rows: k len {} != v len {}",
137                k_rows.len(),
138                v_rows.len()
139            );
140        }
141        if !k_rows.len().is_multiple_of(self.kv_dim) {
142            bail!(
143                "append_rows: byte count {} not aligned to kv_dim {}",
144                k_rows.len(),
145                self.kv_dim
146            );
147        }
148        let n_rows = k_rows.len() / self.kv_dim;
149        let k_bytes = quant_rows(k_rows, self.scheme)?;
150        let v_bytes = quant_rows(v_rows, self.scheme)?;
151        self.k.extend_from_slice(&k_bytes);
152        self.v.extend_from_slice(&v_bytes);
153        self.past_len += n_rows;
154        Ok(())
155    }
156
157    /// Dequantize all stored rows back to f32 (K, V).
158    pub fn read_all(&self) -> Result<(Vec<f32>, Vec<f32>)> {
159        let k = dequant_rows(&self.k, self.scheme, self.past_len * self.kv_dim)?;
160        let v = dequant_rows(&self.v, self.scheme, self.past_len * self.kv_dim)?;
161        Ok((k, v))
162    }
163
164    /// Dequantize `count` rows starting at row `start` (K, V). Lets a caller
165    /// stream the cache a row (or small block) at a time — peak extra memory is
166    /// `count × kv_dim` floats, not the whole past.
167    pub fn read_rows(&self, start: usize, count: usize) -> Result<(Vec<f32>, Vec<f32>)> {
168        if start + count > self.past_len {
169            bail!(
170                "read_rows: [{start}, {}) out of range (past_len {})",
171                start + count,
172                self.past_len
173            );
174        }
175        let blk = self.scheme.block_elements();
176        let bytes_per_row = (self.kv_dim / blk) * self.scheme.block_bytes();
177        let start_byte = start * bytes_per_row;
178        let n = count * self.kv_dim;
179        let k = dequant_rows(&self.k[start_byte..], self.scheme, n)?;
180        let v = dequant_rows(&self.v[start_byte..], self.scheme, n)?;
181        Ok((k, v))
182    }
183
184    /// Dequantize the last `window` rows (or all rows if past_len ≤ window).
185    pub fn read_window(&self, window: usize) -> Result<(Vec<f32>, Vec<f32>)> {
186        if window >= self.past_len {
187            return self.read_all();
188        }
189        self.read_rows(self.past_len - window, window)
190    }
191
192    /// Drop the oldest `n_rows` from this layer (sliding window).
193    pub fn drop_front(&mut self, n_rows: usize) -> Result<()> {
194        let n_rows = n_rows.min(self.past_len);
195        if n_rows == 0 {
196            return Ok(());
197        }
198        let blk = self.scheme.block_elements();
199        let blocks_per_row = self.kv_dim / blk;
200        let drop_bytes = n_rows * blocks_per_row * self.scheme.block_bytes();
201        self.k.drain(..drop_bytes);
202        self.v.drain(..drop_bytes);
203        self.past_len -= n_rows;
204        Ok(())
205    }
206
207    /// Memory used by both buffers (bytes).
208    pub fn bytes(&self) -> usize {
209        self.k.len() + self.v.len()
210    }
211}
212
213/// Single-query attention directly over a **quantized** K/V layer, dequantizing
214/// one row at a time so peak extra memory is `O(kv_dim)`, not `O(past_len ×
215/// kv_dim)` — the point of quantized-KV attention vs the dequantize-the-whole-
216/// cache path. GQA-aware: `n_heads` query heads share `kv_heads` K/V heads
217/// (`group = n_heads / kv_heads`).
218///
219/// - `q`: the new token's query, `[n_heads × head_dim]`.
220/// - returns the attention output `[n_heads × head_dim]`.
221///
222/// This is the host reference for a future first-class `QuantizedAttention` op;
223/// it lets long-context decode keep the cache small without ever materializing
224/// the full f32 history.
225pub fn attend_quantized(
226    q: &[f32],
227    layer: &QuantizedKvLayer,
228    n_heads: usize,
229    kv_heads: usize,
230    head_dim: usize,
231    scale: f32,
232) -> Result<Vec<f32>> {
233    let kv_dim = kv_heads * head_dim;
234    if layer.kv_dim != kv_dim {
235        bail!(
236            "attend_quantized: layer kv_dim {} != {kv_heads}×{head_dim}",
237            layer.kv_dim
238        );
239    }
240    if q.len() != n_heads * head_dim {
241        bail!(
242            "attend_quantized: q len {} != {n_heads}×{head_dim}",
243            q.len()
244        );
245    }
246    let mut out = vec![0f32; n_heads * head_dim];
247    let past = layer.past_len;
248    if past == 0 {
249        return Ok(out);
250    }
251    let group = (n_heads / kv_heads.max(1)).max(1);
252
253    // Pass 1: stream K row-by-row, accumulate per-head scores.
254    let mut scores = vec![0f32; n_heads * past];
255    for p in 0..past {
256        let (k_row, _) = layer.read_rows(p, 1)?;
257        for h in 0..n_heads {
258            let kvh = h / group;
259            let q_h = &q[h * head_dim..(h + 1) * head_dim];
260            let k_h = &k_row[kvh * head_dim..(kvh + 1) * head_dim];
261            let dot: f32 = q_h.iter().zip(k_h).map(|(a, b)| a * b).sum();
262            scores[h * past + p] = dot * scale;
263        }
264    }
265
266    // Softmax per head over the `past` keys (numerically stable).
267    for h in 0..n_heads {
268        let row = &mut scores[h * past..(h + 1) * past];
269        let max = row.iter().fold(f32::NEG_INFINITY, |m, &v| m.max(v));
270        let mut sum = 0f32;
271        for v in row.iter_mut() {
272            *v = (*v - max).exp();
273            sum += *v;
274        }
275        let inv = 1.0 / sum.max(1e-20);
276        for v in row.iter_mut() {
277            *v *= inv;
278        }
279    }
280
281    // Pass 2: stream V row-by-row, accumulate weighted output.
282    for p in 0..past {
283        let (_, v_row) = layer.read_rows(p, 1)?;
284        for h in 0..n_heads {
285            let kvh = h / group;
286            let w = scores[h * past + p];
287            let v_h = &v_row[kvh * head_dim..(kvh + 1) * head_dim];
288            let o_h = &mut out[h * head_dim..(h + 1) * head_dim];
289            for (o, &vv) in o_h.iter_mut().zip(v_h) {
290                *o += w * vv;
291            }
292        }
293    }
294    Ok(out)
295}
296
297/// All layers of a quantized KV cache.
298#[derive(Debug, Clone)]
299pub struct QuantizedKvCache {
300    pub layers: Vec<QuantizedKvLayer>,
301}
302
303impl QuantizedKvCache {
304    pub fn new(n_layers: usize, kv_dim: usize, scheme: KvQuant) -> Result<Self> {
305        let layers = (0..n_layers)
306            .map(|_| QuantizedKvLayer::new(kv_dim, scheme))
307            .collect::<Result<Vec<_>>>()?;
308        Ok(Self { layers })
309    }
310
311    pub fn n_layers(&self) -> usize {
312        self.layers.len()
313    }
314
315    pub fn past_len(&self) -> usize {
316        self.layers.first().map(|l| l.past_len).unwrap_or(0)
317    }
318
319    /// Total bytes across all layers.
320    pub fn bytes(&self) -> usize {
321        self.layers.iter().map(|l| l.bytes()).sum()
322    }
323}
324
325// ─── quant / dequant entry points ────────────────────────────────────
326
327fn quant_rows(values: &[f32], scheme: KvQuant) -> Result<Vec<u8>> {
328    match scheme {
329        KvQuant::F16 => {
330            let mut out = Vec::with_capacity(values.len() * 2);
331            for &v in values {
332                let h = half::f16::from_f32(v);
333                out.extend_from_slice(&h.to_le_bytes());
334            }
335            Ok(out)
336        }
337        scheme => {
338            let ty = scheme
339                .ggml_type()
340                .ok_or_else(|| anyhow!("internal: missing ggml type for {scheme:?}"))?;
341            Ok(quantize(values, ty)?)
342        }
343    }
344}
345
346fn dequant_rows(bytes: &[u8], scheme: KvQuant, n: usize) -> Result<Vec<f32>> {
347    match scheme {
348        KvQuant::F16 => {
349            if bytes.len() < n * 2 {
350                bail!("F16 dequant: {} bytes < {} expected", bytes.len(), n * 2);
351            }
352            let mut out = Vec::with_capacity(n);
353            for chunk in bytes[..n * 2].chunks_exact(2) {
354                let h = half::f16::from_le_bytes([chunk[0], chunk[1]]);
355                out.push(h.to_f32());
356            }
357            Ok(out)
358        }
359        KvQuant::Q8_0 => {
360            let expected = scheme.bytes_for(n)?;
361            Ok(rlx_gguf::dequant_q8_0(&bytes[..expected], n)?)
362        }
363        KvQuant::Q4_0 => {
364            let expected = scheme.bytes_for(n)?;
365            Ok(rlx_gguf::dequant_q4_0(&bytes[..expected], n)?)
366        }
367        KvQuant::Q5_0 => {
368            // Q5_0 doesn't have a top-level `dequant_q5_0` export — call the
369            // private path via dequant_f32 by building a one-shot GgufFile?
370            // Cleaner: use the per-block helper exposed indirectly through
371            // bytes_for + a hand-written loop. For now we use the public
372            // `dequant_q8_0`-style path which is exposed; Q5_0 needs the
373            // same. Until the gguf crate exposes a public `dequant_q5_0`,
374            // route through the same block-by-block decoder lifted from
375            // ggml-quants.c.
376            decode_q5_0(bytes, n)
377        }
378    }
379}
380
381fn decode_q5_0(bytes: &[u8], n: usize) -> Result<Vec<f32>> {
382    const QK5_0: usize = 32;
383    let blk_bytes = 2 + 4 + QK5_0 / 2;
384    if !n.is_multiple_of(QK5_0) {
385        bail!("Q5_0: n={n} not divisible by {QK5_0}");
386    }
387    let nb = n / QK5_0;
388    if bytes.len() < nb * blk_bytes {
389        bail!(
390            "Q5_0: expected {} bytes, got {}",
391            nb * blk_bytes,
392            bytes.len()
393        );
394    }
395    let mut out = Vec::with_capacity(n);
396    for i in 0..nb {
397        let off = i * blk_bytes;
398        let d = half::f16::from_le_bytes([bytes[off], bytes[off + 1]]).to_f32();
399        let qh = u32::from_le_bytes([
400            bytes[off + 2],
401            bytes[off + 3],
402            bytes[off + 4],
403            bytes[off + 5],
404        ]);
405        let qs = &bytes[off + 6..off + 6 + QK5_0 / 2];
406        for j in 0..QK5_0 / 2 {
407            let xh0 = (((qh >> j) & 1) as u8) << 4;
408            let v0 = ((qs[j] & 0x0F) | xh0) as i32 - 16;
409            out.push(d * v0 as f32);
410        }
411        for j in 0..QK5_0 / 2 {
412            let xh1 = (((qh >> (j + 16)) & 1) as u8) << 4;
413            let v1 = ((qs[j] >> 4) | xh1) as i32 - 16;
414            out.push(d * v1 as f32);
415        }
416    }
417    Ok(out)
418}
419
420// ─── mmap-backed storage (feature = "mmap-kv") ───────────────────────
421//
422// Memory-mapped storage trades a Vec<u8> for a file-backed (or anonymous)
423// `MmapMut` so the OS pages quantized blocks in/out on demand. Two
424// use cases:
425//
426// 1. **Long contexts** — KV history for 100k-token decode runs can
427//    exceed RAM. With mmap, the kernel evicts cold pages to swap or
428//    the backing file; reactivating them is a page fault, not a
429//    user-space read().
430//
431// 2. **Zero-copy GPU upload** — paged memory is friendlier to
432//    `cudaHostRegister` / Metal `MTLBuffer::newBufferWithBytesNoCopy`,
433//    which can pin and DMA without an extra staging copy.
434//
435// Append semantics are emulated: we pre-allocate `capacity_rows`
436// worth of blocks and track a write head, then `set_len` on the file
437// when finalizing. For "grow as you go" the caller passes
438// `capacity_rows = max_seq_len`.
439
440#[cfg(feature = "mmap-kv")]
441pub mod mmap {
442    use super::*;
443    use memmap2::{MmapMut, MmapOptions};
444    use std::fs::OpenOptions;
445    use std::path::{Path, PathBuf};
446
447    /// File-backed quantized K/V buffer. K and V share one mapping —
448    /// V follows K in the file. Disk layout: `[K bytes][V bytes]`.
449    pub struct MmapKvLayer {
450        pub mmap: MmapMut,
451        pub past_len: usize,
452        pub capacity_rows: usize,
453        pub kv_dim: usize,
454        pub scheme: KvQuant,
455        pub bytes_per_row: usize,
456        pub k_offset: usize,
457        pub v_offset: usize,
458        pub path: Option<PathBuf>,
459    }
460
461    impl MmapKvLayer {
462        /// File-backed mapping. Creates / truncates `path` to
463        /// `2 × capacity_rows × bytes_per_row` and maps it RW.
464        pub fn open<P: AsRef<Path>>(
465            path: P,
466            kv_dim: usize,
467            scheme: KvQuant,
468            capacity_rows: usize,
469        ) -> Result<Self> {
470            let blk = scheme.block_elements();
471            if !kv_dim.is_multiple_of(blk) {
472                bail!("kv_dim ({kv_dim}) must be a multiple of {scheme:?} block size ({blk})");
473            }
474            let bytes_per_row = (kv_dim / blk) * scheme.block_bytes();
475            let total = 2 * capacity_rows * bytes_per_row;
476            let file = OpenOptions::new()
477                .read(true)
478                .write(true)
479                .create(true)
480                .truncate(true)
481                .open(&path)?;
482            file.set_len(total as u64)?;
483            let mmap = unsafe { MmapOptions::new().len(total).map_mut(&file)? };
484            Ok(Self {
485                mmap,
486                past_len: 0,
487                capacity_rows,
488                kv_dim,
489                scheme,
490                bytes_per_row,
491                k_offset: 0,
492                v_offset: capacity_rows * bytes_per_row,
493                path: Some(path.as_ref().to_path_buf()),
494            })
495        }
496
497        /// Anonymous (private) mapping. Lives in swap-backed pages;
498        /// not persisted. Use when you want OS-level paging without
499        /// keeping a file around.
500        pub fn anonymous(kv_dim: usize, scheme: KvQuant, capacity_rows: usize) -> Result<Self> {
501            let blk = scheme.block_elements();
502            if !kv_dim.is_multiple_of(blk) {
503                bail!("kv_dim ({kv_dim}) must be a multiple of {scheme:?} block size ({blk})");
504            }
505            let bytes_per_row = (kv_dim / blk) * scheme.block_bytes();
506            let total = 2 * capacity_rows * bytes_per_row;
507            let mmap = MmapOptions::new().len(total).map_anon()?;
508            Ok(Self {
509                mmap,
510                past_len: 0,
511                capacity_rows,
512                kv_dim,
513                scheme,
514                bytes_per_row,
515                k_offset: 0,
516                v_offset: capacity_rows * bytes_per_row,
517                path: None,
518            })
519        }
520
521        /// Append `n_rows` × `kv_dim` worth of K and V floats (one row
522        /// at a time, quantized in place).
523        pub fn append_rows(&mut self, k_rows: &[f32], v_rows: &[f32]) -> Result<()> {
524            if k_rows.len() != v_rows.len() {
525                bail!("append_rows: k/v length mismatch");
526            }
527            if !k_rows.len().is_multiple_of(self.kv_dim) {
528                bail!("append_rows: byte count not aligned to kv_dim");
529            }
530            let n_rows = k_rows.len() / self.kv_dim;
531            if self.past_len + n_rows > self.capacity_rows {
532                bail!(
533                    "append_rows: would exceed capacity ({} + {} > {})",
534                    self.past_len,
535                    n_rows,
536                    self.capacity_rows
537                );
538            }
539            let kb = quant_rows(k_rows, self.scheme)?;
540            let vb = quant_rows(v_rows, self.scheme)?;
541            let k_start = self.k_offset + self.past_len * self.bytes_per_row;
542            let v_start = self.v_offset + self.past_len * self.bytes_per_row;
543            self.mmap[k_start..k_start + kb.len()].copy_from_slice(&kb);
544            self.mmap[v_start..v_start + vb.len()].copy_from_slice(&vb);
545            self.past_len += n_rows;
546            Ok(())
547        }
548
549        /// Dequantize all stored rows. Reads zero-copy from the page
550        /// cache — only touched pages are faulted in.
551        pub fn read_all(&self) -> Result<(Vec<f32>, Vec<f32>)> {
552            let n = self.past_len * self.kv_dim;
553            let k_end = self.k_offset + self.past_len * self.bytes_per_row;
554            let v_end = self.v_offset + self.past_len * self.bytes_per_row;
555            let k = dequant_rows(&self.mmap[self.k_offset..k_end], self.scheme, n)?;
556            let v = dequant_rows(&self.mmap[self.v_offset..v_end], self.scheme, n)?;
557            Ok((k, v))
558        }
559
560        /// Read the last `window` rows (page-fault-lazy on dequant).
561        pub fn read_window(&self, window: usize) -> Result<(Vec<f32>, Vec<f32>)> {
562            let window = window.min(self.past_len);
563            let start_row = self.past_len - window;
564            let n = window * self.kv_dim;
565            let k_start = self.k_offset + start_row * self.bytes_per_row;
566            let v_start = self.v_offset + start_row * self.bytes_per_row;
567            let k_end = k_start + window * self.bytes_per_row;
568            let v_end = v_start + window * self.bytes_per_row;
569            let k = dequant_rows(&self.mmap[k_start..k_end], self.scheme, n)?;
570            let v = dequant_rows(&self.mmap[v_start..v_end], self.scheme, n)?;
571            Ok((k, v))
572        }
573
574        /// Hint the kernel that we're about to read `window` rows
575        /// linearly — prefetches pages into the page cache (madvise
576        /// WILLNEED on supported platforms). Best-effort: failures are
577        /// logged but don't propagate.
578        pub fn prefetch_window(&self, window: usize) {
579            let window = window.min(self.past_len);
580            if window == 0 {
581                return;
582            }
583            let start_row = self.past_len - window;
584            let k_start = self.k_offset + start_row * self.bytes_per_row;
585            let v_start = self.v_offset + start_row * self.bytes_per_row;
586            let _ = self.mmap.advise_range(
587                memmap2::Advice::WillNeed,
588                k_start,
589                window * self.bytes_per_row,
590            );
591            let _ = self.mmap.advise_range(
592                memmap2::Advice::WillNeed,
593                v_start,
594                window * self.bytes_per_row,
595            );
596        }
597
598        /// Persist any dirty pages to the backing file. No-op for
599        /// anonymous mappings.
600        pub fn flush(&self) -> Result<()> {
601            self.mmap.flush()?;
602            Ok(())
603        }
604
605        pub fn bytes(&self) -> usize {
606            2 * self.past_len * self.bytes_per_row
607        }
608    }
609
610    /// All-layer mmap-backed KV cache.
611    pub struct MmapKvCache {
612        pub layers: Vec<MmapKvLayer>,
613    }
614
615    impl MmapKvCache {
616        /// One file per layer under `dir`, named `kv_{i}.bin`.
617        pub fn open_dir<P: AsRef<Path>>(
618            dir: P,
619            n_layers: usize,
620            kv_dim: usize,
621            scheme: KvQuant,
622            capacity_rows: usize,
623        ) -> Result<Self> {
624            let dir = dir.as_ref();
625            std::fs::create_dir_all(dir)?;
626            let layers = (0..n_layers)
627                .map(|i| {
628                    MmapKvLayer::open(
629                        dir.join(format!("kv_{i}.bin")),
630                        kv_dim,
631                        scheme,
632                        capacity_rows,
633                    )
634                })
635                .collect::<Result<Vec<_>>>()?;
636            Ok(Self { layers })
637        }
638
639        pub fn anonymous(
640            n_layers: usize,
641            kv_dim: usize,
642            scheme: KvQuant,
643            capacity_rows: usize,
644        ) -> Result<Self> {
645            let layers = (0..n_layers)
646                .map(|_| MmapKvLayer::anonymous(kv_dim, scheme, capacity_rows))
647                .collect::<Result<Vec<_>>>()?;
648            Ok(Self { layers })
649        }
650
651        pub fn n_layers(&self) -> usize {
652            self.layers.len()
653        }
654
655        pub fn past_len(&self) -> usize {
656            self.layers.first().map(|l| l.past_len).unwrap_or(0)
657        }
658
659        /// Total bytes currently in use across all layers.
660        pub fn bytes(&self) -> usize {
661            self.layers.iter().map(|l| l.bytes()).sum()
662        }
663    }
664
665    #[cfg(test)]
666    mod tests {
667        use super::*;
668
669        #[test]
670        fn anonymous_q8_0_roundtrip() {
671            let kv_dim = 64;
672            let mut layer = MmapKvLayer::anonymous(kv_dim, KvQuant::Q8_0, 4).unwrap();
673            let data: Vec<f32> = (0..kv_dim).map(|i| (i as f32).sin()).collect();
674            layer.append_rows(&data, &data).unwrap();
675            let (k, v) = layer.read_all().unwrap();
676            assert_eq!(k.len(), kv_dim);
677            assert_eq!(v.len(), kv_dim);
678            // Q8_0 is high-fidelity.
679            for (a, b) in k.iter().zip(data.iter()) {
680                assert!((a - b).abs() < 0.02);
681            }
682        }
683
684        #[test]
685        fn file_backed_persists_and_reopens() {
686            let dir = tempfile::tempdir().unwrap();
687            let kv_dim = 32;
688            let path = dir.path().join("layer.bin");
689            {
690                let mut layer = MmapKvLayer::open(&path, kv_dim, KvQuant::F16, 8).unwrap();
691                let data: Vec<f32> = (0..kv_dim).map(|i| i as f32 * 0.5).collect();
692                layer.append_rows(&data, &data).unwrap();
693                layer.flush().unwrap();
694            }
695            // Re-open and verify K bytes are present (we know offset 0..len).
696            let bytes = std::fs::read(&path).unwrap();
697            assert!(!bytes.is_empty());
698            assert!(bytes.iter().any(|&b| b != 0));
699        }
700
701        #[test]
702        fn append_past_capacity_errors() {
703            let mut l = MmapKvLayer::anonymous(32, KvQuant::Q8_0, 2).unwrap();
704            let row = vec![0.5f32; 32];
705            l.append_rows(&row, &row).unwrap();
706            l.append_rows(&row, &row).unwrap();
707            assert!(l.append_rows(&row, &row).is_err());
708        }
709    }
710}
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715
716    fn cosine(a: &[f32], b: &[f32]) -> f32 {
717        let mut dot = 0.0f32;
718        let mut na = 0.0f32;
719        let mut nb = 0.0f32;
720        for (x, y) in a.iter().zip(b.iter()) {
721            dot += x * y;
722            na += x * x;
723            nb += y * y;
724        }
725        dot / (na.sqrt() * nb.sqrt() + 1e-12)
726    }
727
728    #[test]
729    fn block_size_invariants() {
730        assert_eq!(KvQuant::F16.block_bytes(), 2);
731        assert_eq!(KvQuant::Q8_0.block_bytes(), 34);
732        assert_eq!(KvQuant::Q4_0.block_bytes(), 18);
733        assert_eq!(KvQuant::Q5_0.block_bytes(), 22);
734    }
735
736    #[test]
737    fn f16_roundtrip_exact() {
738        let kv_dim = 64;
739        let mut layer = QuantizedKvLayer::new(kv_dim, KvQuant::F16).unwrap();
740        let k_row: Vec<f32> = (0..kv_dim).map(|i| (i as f32) * 0.1).collect();
741        let v_row: Vec<f32> = (0..kv_dim).map(|i| (i as f32) * 0.2).collect();
742        layer.append_rows(&k_row, &v_row).unwrap();
743        let (k, v) = layer.read_all().unwrap();
744        for i in 0..kv_dim {
745            // f16 round-trip is bounded ~1e-3 relative for small magnitudes.
746            assert!((k[i] - k_row[i]).abs() < 0.01);
747            assert!((v[i] - v_row[i]).abs() < 0.01);
748        }
749    }
750
751    #[test]
752    fn q8_0_roundtrip_high_fidelity() {
753        let kv_dim = 64;
754        let n_rows = 4;
755        let mut layer = QuantizedKvLayer::new(kv_dim, KvQuant::Q8_0).unwrap();
756        let total = n_rows * kv_dim;
757        let k_data: Vec<f32> = (0..total).map(|i| (i as f32).sin()).collect();
758        let v_data: Vec<f32> = (0..total).map(|i| (i as f32).cos()).collect();
759        layer.append_rows(&k_data, &v_data).unwrap();
760        assert_eq!(layer.past_len, n_rows);
761        let (k, v) = layer.read_all().unwrap();
762        assert!(cosine(&k, &k_data) > 0.999, "Q8_0 K cosine too low");
763        assert!(cosine(&v, &v_data) > 0.999, "Q8_0 V cosine too low");
764    }
765
766    #[test]
767    fn q4_0_roundtrip_lossy_but_close() {
768        let kv_dim = 64;
769        let mut layer = QuantizedKvLayer::new(kv_dim, KvQuant::Q4_0).unwrap();
770        let k: Vec<f32> = (0..kv_dim).map(|i| (i as f32 * 0.05).tanh()).collect();
771        let v: Vec<f32> = (0..kv_dim).map(|i| (i as f32 * 0.07).tanh()).collect();
772        layer.append_rows(&k, &v).unwrap();
773        let (kr, vr) = layer.read_all().unwrap();
774        assert!(cosine(&kr, &k) > 0.99);
775        assert!(cosine(&vr, &v) > 0.99);
776    }
777
778    #[test]
779    fn q5_0_roundtrip_better_than_q4() {
780        let kv_dim = 64;
781        let mut q4 = QuantizedKvLayer::new(kv_dim, KvQuant::Q4_0).unwrap();
782        let mut q5 = QuantizedKvLayer::new(kv_dim, KvQuant::Q5_0).unwrap();
783        let k: Vec<f32> = (0..kv_dim).map(|i| (i as f32 * 0.1).sin() * 3.0).collect();
784        let v: Vec<f32> = (0..kv_dim).map(|i| (i as f32 * 0.13).cos() * 3.0).collect();
785        q4.append_rows(&k, &v).unwrap();
786        q5.append_rows(&k, &v).unwrap();
787        let (k4, _) = q4.read_all().unwrap();
788        let (k5, _) = q5.read_all().unwrap();
789        let cos4 = cosine(&k4, &k);
790        let cos5 = cosine(&k5, &k);
791        assert!(cos5 >= cos4 - 1e-3, "Q5_0 should not be worse than Q4_0");
792    }
793
794    #[test]
795    fn sliding_window_drops_oldest() {
796        let kv_dim = 32;
797        let mut layer = QuantizedKvLayer::new(kv_dim, KvQuant::Q8_0).unwrap();
798        for r in 0..5 {
799            let v: Vec<f32> = (0..kv_dim).map(|i| (i + r * 100) as f32).collect();
800            layer.append_rows(&v, &v).unwrap();
801        }
802        assert_eq!(layer.past_len, 5);
803        layer.drop_front(2).unwrap();
804        assert_eq!(layer.past_len, 3);
805        let (k, _v) = layer.read_window(3).unwrap();
806        // First kept row is original row 2 → starts with value 200.
807        assert!((k[0] - 200.0).abs() < 1.0);
808    }
809
810    #[test]
811    fn kv_dim_must_align_to_block_size() {
812        // kv_dim=24 < 32 → not aligned for Q8_0/Q4_0/Q5_0.
813        assert!(QuantizedKvLayer::new(24, KvQuant::Q8_0).is_err());
814        assert!(QuantizedKvLayer::new(24, KvQuant::Q4_0).is_err());
815        // f16 has block 1 so any dim works.
816        assert!(QuantizedKvLayer::new(24, KvQuant::F16).is_ok());
817    }
818
819    #[test]
820    fn streaming_quantized_attention_matches_full_dequant() {
821        let (n_heads, kv_heads, head_dim) = (4usize, 2usize, 32usize);
822        let kv_dim = kv_heads * head_dim; // 64 — multiple of 32 for Q8_0
823        let past = 5usize;
824        let scale = 1.0 / (head_dim as f32).sqrt();
825
826        let mut layer = QuantizedKvLayer::new(kv_dim, KvQuant::Q8_0).unwrap();
827        for p in 0..past {
828            let k: Vec<f32> = (0..kv_dim)
829                .map(|i| ((i + p * 7) as f32 * 0.05).sin())
830                .collect();
831            let v: Vec<f32> = (0..kv_dim)
832                .map(|i| ((i + p * 3) as f32 * 0.04).cos())
833                .collect();
834            layer.append_rows(&k, &v).unwrap();
835        }
836        let q: Vec<f32> = (0..n_heads * head_dim)
837            .map(|i| (i as f32 * 0.02).sin())
838            .collect();
839
840        let out = attend_quantized(&q, &layer, n_heads, kv_heads, head_dim, scale).unwrap();
841
842        // Reference: dequantize the whole cache, run standard attention.
843        let (k_all, v_all) = layer.read_all().unwrap();
844        let group = n_heads / kv_heads;
845        let mut reference = vec![0f32; n_heads * head_dim];
846        for h in 0..n_heads {
847            let kvh = h / group;
848            let mut sc = vec![0f32; past];
849            for p in 0..past {
850                let qh = &q[h * head_dim..(h + 1) * head_dim];
851                let base = p * kv_dim + kvh * head_dim;
852                let kh = &k_all[base..base + head_dim];
853                let dot: f32 = qh.iter().zip(kh).map(|(a, b)| a * b).sum();
854                sc[p] = dot * scale;
855            }
856            let max = sc.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
857            let mut sum = 0.0;
858            for x in sc.iter_mut() {
859                *x = (*x - max).exp();
860                sum += *x;
861            }
862            for x in sc.iter_mut() {
863                *x /= sum;
864            }
865            for p in 0..past {
866                let base = p * kv_dim + kvh * head_dim;
867                let vh = &v_all[base..base + head_dim];
868                for d in 0..head_dim {
869                    reference[h * head_dim + d] += sc[p] * vh[d];
870                }
871            }
872        }
873        for (a, b) in out.iter().zip(&reference) {
874            assert!((a - b).abs() < 1e-4, "streaming {a} vs full-dequant {b}");
875        }
876    }
877
878    #[test]
879    fn cache_memory_decreases_with_quantization() {
880        let kv_dim = 128;
881        let n_layers = 4;
882        let n_rows = 16;
883        let data: Vec<f32> = (0..kv_dim).map(|i| (i as f32) * 0.01).collect();
884        let mut f16 = QuantizedKvCache::new(n_layers, kv_dim, KvQuant::F16).unwrap();
885        let mut q8 = QuantizedKvCache::new(n_layers, kv_dim, KvQuant::Q8_0).unwrap();
886        let mut q4 = QuantizedKvCache::new(n_layers, kv_dim, KvQuant::Q4_0).unwrap();
887        for _ in 0..n_rows {
888            for l in 0..n_layers {
889                f16.layers[l].append_rows(&data, &data).unwrap();
890                q8.layers[l].append_rows(&data, &data).unwrap();
891                q4.layers[l].append_rows(&data, &data).unwrap();
892            }
893        }
894        assert!(q8.bytes() < f16.bytes());
895        assert!(q4.bytes() < q8.bytes());
896    }
897}