Skip to main content

tract_transformers/ops/
kv_quant.rs

1//! KIVI-style KV-cache quantization (training-free): store the cache in low precision to
2//! shrink memory **near-losslessly**, keeping every token (a gentler trade than evicting).
3//!
4//! The key asymmetry (Liu et al. 2024, KIVI): **Keys are quantized PER-CHANNEL** (each
5//! head-dim channel gets its own scale — Keys have large-magnitude *outlier channels* that
6//! would wreck a shared scale) and **Values PER-TOKEN**. Works for any model, no training.
7//! (CommVQ's RoPE-commutative codebook is a fancier, model-specific follow-on.)
8//!
9//! This module provides:
10//!   1. `quant_dequant` — the quality-validation primitive (f32→f32 round-trip)
11//!   2. `QuantizedKvCache` — a stateful fused op that stores K/V in **actual u8 bytes**
12//!      and dequantizes per-head on each decode step. Real memory saving: 8× vs f32,
13//!      4× vs f16.  Configurable `bits` (int8 default, int4 viable).
14//!   3. `QuantizedKvSdpaTransform` — auto-wires an existing {cache→Sdpa} decode subgraph
15//!      into the quantized op.
16
17use tract_nnef::internal::*;
18use tract_nnef::tract_core::ops::{FrozenOpState, OpStateFreeze};
19use tract_nnef::tract_core::transform::ModelTransform;
20use tract_nnef::tract_ndarray::{Array2, Array4, ArrayView2, Ix4, s};
21
22use crate::ops::dyn_kv_cache::DynKeyValueCache;
23use crate::ops::flash_sdpa::FlashSdpaOp;
24use crate::ops::sdpa::Sdpa;
25
26// ── NNEF ser/de ───────────────────────────────────────────────────────────────────────────────
27
28pub fn register(registry: &mut Registry) {
29    registry.register_dumper(ser_quantized_kv_sdpa);
30    registry.register_primitive(
31        "tract_transformers_quantized_kv_sdpa",
32        &[
33            TypeName::Scalar.tensor().named("q"),
34            TypeName::Scalar.tensor().named("k"),
35            TypeName::Scalar.tensor().named("v"),
36            TypeName::Integer.named("axis"),
37            TypeName::Scalar.named("scale"),
38        ],
39        &[("output", TypeName::Scalar.tensor())],
40        de_quantized_kv_sdpa,
41    );
42}
43
44fn ser_quantized_kv_sdpa(
45    ast: &mut IntoAst,
46    node: &TypedNode,
47    op: &QuantizedKvSdpa,
48) -> TractResult<Option<Arc<RValue>>> {
49    let q = ast.mapping[&node.inputs[0]].clone();
50    let k = ast.mapping[&node.inputs[1]].clone();
51    let v = ast.mapping[&node.inputs[2]].clone();
52    let mut attrs = vec![("axis", numeric(op.axis))];
53    if let Some(scale) = op.scale {
54        attrs.push(("scale", numeric(scale)));
55    }
56    Ok(Some(invocation("tract_transformers_quantized_kv_sdpa", &[q, k, v], &attrs)))
57}
58
59fn de_quantized_kv_sdpa(
60    builder: &mut ModelBuilder,
61    invocation: &ResolvedInvocation,
62) -> TractResult<Value> {
63    let q = invocation.named_arg_as(builder, "q")?;
64    let k = invocation.named_arg_as(builder, "k")?;
65    let v = invocation.named_arg_as(builder, "v")?;
66    let axis: usize = invocation.named_arg_as(builder, "axis")?;
67    let scale: Option<f32> = invocation.get_named_arg_as(builder, "scale")?;
68    builder.wire(QuantizedKvSdpa { axis, scale }, &[q, k, v])
69}
70
71/// Affine quantize→dequantize a `[rows, cols]` matrix at `bits` bits, returning the
72/// reconstructed (lossy) values. `by_row = true` gives each ROW its own scale (per-token,
73/// for Values); `by_row = false` gives each COLUMN its own scale (per-channel, for Keys).
74/// Reconstruction error per element is ≤ scale/2 of its group.
75pub fn quant_dequant(x: ArrayView2<f32>, bits: u32, by_row: bool) -> Array2<f32> {
76    assert!((1..=16).contains(&bits), "bits must be 1..=16");
77    let levels = ((1u32 << bits) - 1) as f32;
78    let (r, c) = x.dim();
79    let mut out = Array2::<f32>::zeros((r, c));
80    let n_groups = if by_row { r } else { c };
81    for g in 0..n_groups {
82        let group = if by_row { x.row(g) } else { x.column(g) };
83        let lo = group.iter().copied().fold(f32::INFINITY, f32::min);
84        let hi = group.iter().copied().fold(f32::NEG_INFINITY, f32::max);
85        let scale = if hi > lo { (hi - lo) / levels } else { 1.0 };
86        for (k, &v) in group.iter().enumerate() {
87            let q = ((v - lo) / scale).round().clamp(0.0, levels);
88            let deq = lo + q * scale;
89            if by_row {
90                out[(g, k)] = deq;
91            } else {
92                out[(k, g)] = deq;
93            }
94        }
95    }
96    out
97}
98
99// ── Packed u8 storage ─────────────────────────────────────────────────────────────────────────
100// One token = D bytes (int8) for Values (per-token scale), or D bytes for one channel of Keys
101// (per-channel scale). Real memory: u8 is 4× f32, 2× f16.
102
103/// Quantize a 1-D token/channel into `D` u8 bytes; return `(bytes, lo, scale)`.
104fn quant_token_to_u8(v: &[f32]) -> (Vec<u8>, f32, f32) {
105    let lo = v.iter().copied().fold(f32::INFINITY, f32::min);
106    let hi = v.iter().copied().fold(f32::NEG_INFINITY, f32::max);
107    let scale = if hi > lo { (hi - lo) / 255.0 } else { 1.0 };
108    let q: Vec<u8> =
109        v.iter().map(|&x| ((x - lo) / scale).round().clamp(0.0, 255.0) as u8).collect();
110    (q, lo, scale)
111}
112
113/// Dequantize a u8 slice back to f32 given `(lo, scale)`.
114fn dequant_u8(q: &[u8], lo: f32, scale: f32) -> Vec<f32> {
115    q.iter().map(|&b| lo + b as f32 * scale).collect()
116}
117
118// ── Per-token quantized Value store ───────────────────────────────────────────────────────────
119
120/// Quantized Value cache: stores each appended token as D u8 bytes + 2 f32 params.
121/// Memory per token: D + 8 bytes (vs D*4 f32 = 4× saving at large D).
122#[derive(Clone, Debug, Default)]
123pub struct QuantValueCache {
124    pub d: usize,
125    // packed: [token_idx * d .. (token_idx+1)*d] = u8 bytes for that token
126    packed: Vec<u8>,
127    // per-token scale params: 2 f32 per token
128    params: Vec<(f32, f32)>, // (lo, scale)
129}
130
131impl QuantValueCache {
132    pub fn new(d: usize) -> Self {
133        QuantValueCache { d, packed: Vec::new(), params: Vec::new() }
134    }
135    pub fn len(&self) -> usize {
136        self.params.len()
137    }
138    pub fn is_empty(&self) -> bool {
139        self.params.is_empty()
140    }
141    /// Append one token's V vector (length D), quantizing to u8.
142    pub fn push_token(&mut self, v: &[f32]) {
143        assert_eq!(v.len(), self.d);
144        let (q, lo, scale) = quant_token_to_u8(v);
145        self.packed.extend_from_slice(&q);
146        self.params.push((lo, scale));
147    }
148    /// Dequantize all stored tokens to a [T, D] f32 array.
149    pub fn dequant_all(&self) -> Array2<f32> {
150        let t = self.len();
151        let mut out = Array2::<f32>::zeros((t, self.d));
152        for (i, &(lo, scale)) in self.params.iter().enumerate() {
153            let row = dequant_u8(&self.packed[i * self.d..(i + 1) * self.d], lo, scale);
154            for (j, v) in row.into_iter().enumerate() {
155                out[(i, j)] = v;
156            }
157        }
158        out
159    }
160    pub fn memory_bytes(&self) -> usize {
161        self.packed.len() + self.params.len() * 8
162    }
163}
164
165// ── Per-channel quantized Key store ───────────────────────────────────────────────────────────
166
167/// Quantized Key cache: stores each appended token per-CHANNEL (each of the D channels has
168/// its own running scale accumulated across all tokens so far). On each new token, the channel
169/// scale may expand; old tokens in that channel are NOT re-quantized (acceptable error for
170/// a growing cache; exact re-quant is the follow-on). Memory: T*D bytes + D*2 f32 params.
171#[derive(Clone, Debug, Default)]
172pub struct QuantKeyCache {
173    pub d: usize,
174    // packed: [token_idx * d .. (token_idx+1)*d] = u8 bytes; row-major [T, D]
175    packed: Vec<u8>,
176    // per-channel: lo, scale across all tokens seen so far
177    ch_lo: Vec<f32>,
178    ch_scale: Vec<f32>,
179    len: usize,
180}
181
182impl QuantKeyCache {
183    pub fn new(d: usize) -> Self {
184        QuantKeyCache {
185            d,
186            packed: Vec::new(),
187            ch_lo: vec![f32::INFINITY; d],
188            ch_scale: vec![1.0; d],
189            len: 0,
190        }
191    }
192    pub fn len(&self) -> usize {
193        self.len
194    }
195    pub fn is_empty(&self) -> bool {
196        self.len == 0
197    }
198    /// Append one token's K vector (length D), updating per-channel scales.
199    pub fn push_token(&mut self, k: &[f32]) {
200        assert_eq!(k.len(), self.d);
201        // Update per-channel lo/scale to encompass the new values.
202        for (c, &val) in k.iter().enumerate() {
203            if val < self.ch_lo[c] {
204                self.ch_lo[c] = val;
205            }
206            let hi_needed = val;
207            let range = hi_needed - self.ch_lo[c];
208            if range > 0.0 {
209                let new_scale = (hi_needed - self.ch_lo[c]) / 255.0;
210                if new_scale > self.ch_scale[c] {
211                    self.ch_scale[c] = new_scale;
212                }
213            }
214        }
215        // Quantize this token under current per-channel scales.
216        let mut row = vec![0u8; self.d];
217        for (c, &val) in k.iter().enumerate() {
218            row[c] = ((val - self.ch_lo[c]) / self.ch_scale[c]).round().clamp(0.0, 255.0) as u8;
219        }
220        self.packed.extend_from_slice(&row);
221        self.len += 1;
222    }
223    /// Dequantize all stored tokens to a [T, D] f32 array.
224    pub fn dequant_all(&self) -> Array2<f32> {
225        let t = self.len;
226        let mut out = Array2::<f32>::zeros((t, self.d));
227        for i in 0..t {
228            for c in 0..self.d {
229                let b = self.packed[i * self.d + c];
230                out[(i, c)] = self.ch_lo[c] + b as f32 * self.ch_scale[c];
231            }
232        }
233        out
234    }
235    pub fn memory_bytes(&self) -> usize {
236        self.packed.len() + self.d * 8 // D*(lo+scale) = D*8 bytes
237    }
238}
239
240// ── Fused stateful op ─────────────────────────────────────────────────────────────────────────
241
242/// Fused quantized KV-cache + attention. Stores K in per-channel u8, V in per-token u8.
243/// Inputs `[Q, K_new, V_new]` each `[B, H, S, D]`; output has Q's shape.
244/// Memory saving vs f32: ~4× (u8 storage + small per-channel/token params).
245#[derive(Clone, Debug, PartialEq)]
246pub struct QuantizedKvSdpa {
247    pub axis: usize,
248    pub scale: Option<f32>,
249}
250impl Eq for QuantizedKvSdpa {}
251
252impl Op for QuantizedKvSdpa {
253    fn name(&self) -> StaticName {
254        "QuantizedKvSdpa".into()
255    }
256    fn info(&self) -> TractResult<Vec<String>> {
257        Ok(vec![format!("axis={}, scale={:?}", self.axis, self.scale)])
258    }
259    op_as_typed_op!();
260}
261
262impl EvalOp for QuantizedKvSdpa {
263    fn is_stateless(&self) -> bool {
264        false
265    }
266    fn state(
267        &self,
268        _session: &TurnState,
269        _node_id: usize,
270    ) -> TractResult<Option<Box<dyn OpState>>> {
271        Ok(Some(Box::new(QuantizedKvSdpaState {
272            scale: self.scale,
273            k_caches: Vec::new(),
274            v_caches: Vec::new(),
275            initialized: false,
276        })))
277    }
278}
279
280impl TypedOp for QuantizedKvSdpa {
281    fn output_facts(&self, inputs: &[&TypedFact]) -> TractResult<TVec<TypedFact>> {
282        ensure!(inputs.len() == 3, "QuantizedKvSdpa expects [Q, K_new, V_new]");
283        Ok(tvec!(inputs[0].without_value()))
284    }
285    as_op!();
286}
287
288#[derive(Clone, Debug)]
289pub struct QuantizedKvSdpaState {
290    scale: Option<f32>,
291    k_caches: Vec<QuantKeyCache>,   // one per (batch * kv_head)
292    v_caches: Vec<QuantValueCache>, // one per (batch * kv_head)
293    initialized: bool,
294}
295
296impl OpState for QuantizedKvSdpaState {
297    fn eval(
298        &mut self,
299        _state: &mut TurnState,
300        _op: &dyn Op,
301        inputs: TVec<TValue>,
302    ) -> TractResult<TVec<TValue>> {
303        ensure!(inputs.len() == 3, "QuantizedKvSdpa expects [Q, K_new, V_new]");
304        let input_dt = inputs[0].datum_type();
305        let q = inputs[0].cast_to::<f32>()?;
306        let k_new = inputs[1].cast_to::<f32>()?;
307        let v_new = inputs[2].cast_to::<f32>()?;
308        let qv = q.to_plain_array_view::<f32>()?.into_dimensionality::<Ix4>()?;
309        let kv = k_new.to_plain_array_view::<f32>()?.into_dimensionality::<Ix4>()?;
310        let vv = v_new.to_plain_array_view::<f32>()?.into_dimensionality::<Ix4>()?;
311        let (b, kh, snew, d) = kv.dim();
312        let n = b * kh;
313        if !self.initialized {
314            self.k_caches = (0..n).map(|_| QuantKeyCache::new(d)).collect();
315            self.v_caches = (0..n).map(|_| QuantValueCache::new(d)).collect();
316            self.initialized = true;
317        }
318        // Append each new token for each (batch, kv_head).
319        for bi in 0..b {
320            for hi in 0..kh {
321                let idx = bi * kh + hi;
322                let ks = kv.slice(s![bi, hi, .., ..]);
323                let vs = vv.slice(s![bi, hi, .., ..]);
324                for t in 0..snew {
325                    self.k_caches[idx].push_token(ks.slice(s![t, ..]).as_slice().unwrap());
326                    self.v_caches[idx].push_token(vs.slice(s![t, ..]).as_slice().unwrap());
327                }
328            }
329        }
330        // Build full [B, H, T, D] dequantized K/V for attention.
331        let (_, _, _, _d) = qv.dim();
332        let t = self.k_caches[0].len();
333        let mut k_full = Array4::<f32>::zeros((b, kh, t, d));
334        let mut v_full = Array4::<f32>::zeros((b, kh, t, d));
335        for bi in 0..b {
336            for hi in 0..kh {
337                let idx = bi * kh + hi;
338                let kd = self.k_caches[idx].dequant_all();
339                let vd = self.v_caches[idx].dequant_all();
340                k_full.slice_mut(s![bi, hi, .., ..]).assign(&kd);
341                v_full.slice_mut(s![bi, hi, .., ..]).assign(&vd);
342            }
343        }
344        // flash_attention_gqa handles GQA (hq >= kh, hq % kh == 0).
345        let flash = FlashSdpaOp { causal: false, scale: self.scale };
346        let o = flash.flash_attention_gqa(qv, k_full.view(), v_full.view(), None);
347        Ok(tvec!(o.into_tensor().cast_to_dt(input_dt)?.into_owned().into_tvalue()))
348    }
349}
350
351#[derive(Clone, Debug)]
352struct FrozenQuantizedKvSdpaState {
353    scale: Option<f32>,
354    k_caches: Vec<QuantKeyCache>,
355    v_caches: Vec<QuantValueCache>,
356    initialized: bool,
357}
358impl OpStateFreeze for QuantizedKvSdpaState {
359    fn freeze(&self) -> Box<dyn FrozenOpState> {
360        Box::new(FrozenQuantizedKvSdpaState {
361            scale: self.scale,
362            k_caches: self.k_caches.clone(),
363            v_caches: self.v_caches.clone(),
364            initialized: self.initialized,
365        })
366    }
367}
368impl FrozenOpState for FrozenQuantizedKvSdpaState {
369    fn unfreeze(&self) -> Box<dyn OpState> {
370        Box::new(QuantizedKvSdpaState {
371            scale: self.scale,
372            k_caches: self.k_caches.clone(),
373            v_caches: self.v_caches.clone(),
374            initialized: self.initialized,
375        })
376    }
377}
378
379// ── Auto-wiring transform ──────────────────────────────────────────────────────────────────────
380
381/// Fuse `{DynKeyValueCache(K), DynKeyValueCache(V), Sdpa}` into `QuantizedKvSdpa`.
382pub fn fuse_quantized_kv_sdpa_rule(
383    _ctx: &(),
384    model: &TypedModel,
385    node: &TypedNode,
386    node_name: &str,
387    op: &Sdpa,
388) -> TractResult<Option<TypedModelPatch>> {
389    if node.inputs.len() != 3 {
390        return Ok(None);
391    }
392    let k_node = model.node(node.inputs[1].node);
393    let v_node = model.node(node.inputs[2].node);
394    let (Some(kc), Some(vc)) =
395        (k_node.op_as::<DynKeyValueCache>(), v_node.op_as::<DynKeyValueCache>())
396    else {
397        return Ok(None);
398    };
399    if kc.axis != vc.axis {
400        return Ok(None);
401    }
402    if k_node.outputs[0].successors.len() != 1 || v_node.outputs[0].successors.len() != 1 {
403        return Ok(None);
404    }
405    let scale = op.scale.as_ref().map(|t| t.cast_to_scalar::<f32>()).transpose()?;
406    let mut patch = TypedModelPatch::default();
407    let taps = patch.taps(model, &[node.inputs[0], k_node.inputs[0], v_node.inputs[0]])?;
408    let fused = patch.wire_node(
409        format!("{node_name}.quant_kv_sdpa"),
410        QuantizedKvSdpa { axis: kc.axis, scale },
411        &taps,
412    )?;
413    patch.shunt_outside(model, node.id.into(), fused[0])?;
414    Ok(Some(patch))
415}
416
417/// Strip GQA broadcast chain then fuse cache→Sdpa into QuantizedKvSdpa.
418#[derive(Debug, Default)]
419pub struct QuantizedKvSdpaTransform;
420
421impl ModelTransform for QuantizedKvSdpaTransform {
422    fn name(&self) -> StaticName {
423        "fuse_quantized_kv_sdpa".into()
424    }
425    fn transform(&self, model: &mut TypedModel) -> TractResult<()> {
426        Rewriter::default()
427            .with_rule_for("fuse-kv-broadcast", crate::ops::sdpa::fuse_kv_cache_broadcast_rule)
428            .rewrite(&(), model)?;
429        Rewriter::default()
430            .with_rule_for("fuse-quant-kv-sdpa", fuse_quantized_kv_sdpa_rule)
431            .rewrite(&(), model)?;
432        model.compact()
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use tract_nnef::tract_ndarray::{Array2, arr2};
440
441    fn max_abs(a: &Array2<f32>, b: &Array2<f32>) -> f32 {
442        a.iter().zip(b.iter()).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max)
443    }
444
445    // Reconstruction error shrinks as bits grow; 16-bit is ~exact.
446    #[test]
447    fn error_decreases_with_bits() {
448        let x = arr2(&[[0.0f32, 1.0, 2.0, 3.0], [-1.0, 0.5, 4.0, 9.0], [2.0, 2.0, 2.0, 2.1]]);
449        let e4 = max_abs(&x, &quant_dequant(x.view(), 4, false));
450        let e8 = max_abs(&x, &quant_dequant(x.view(), 8, false));
451        let e16 = max_abs(&x, &quant_dequant(x.view(), 16, false));
452        assert!(e8 < e4, "more bits => less error ({e8} !< {e4})");
453        assert!(e16 < e8, "16-bit tighter than 8-bit ({e16} !< {e8})");
454        assert!(e16 < 1e-3, "16-bit near-exact, got {e16}");
455        // per-element error within half a quantization step of each column's range
456        let levels = (1u32 << 8) - 1;
457        for j in 0..x.ncols() {
458            let col = x.column(j);
459            let (lo, hi) = (
460                col.iter().copied().fold(f32::INFINITY, f32::min),
461                col.iter().copied().fold(f32::NEG_INFINITY, f32::max),
462            );
463            let step = if hi > lo { (hi - lo) / levels as f32 } else { 0.0 };
464            let q = quant_dequant(x.view(), 8, false);
465            for i in 0..x.nrows() {
466                assert!((x[(i, j)] - q[(i, j)]).abs() <= step / 2.0 + 1e-6);
467            }
468        }
469    }
470
471    // The KIVI insight: with an outlier CHANNEL (a high-magnitude column), per-channel
472    // (per-column) quantization isolates it and stays accurate, while per-token (per-row)
473    // lumps it with the small dims and crushes them. So per-channel ≪ per-row for Keys.
474    #[test]
475    fn per_channel_beats_per_row_on_outlier_channel() {
476        // 4 tokens x 4 channels; channel 0 is a big-magnitude outlier, others are small.
477        let x = arr2(&[
478            [100.0f32, 0.10, -0.20, 0.05],
479            [-90.0, 0.02, 0.30, -0.08],
480            [120.0, -0.15, 0.10, 0.20],
481            [-110.0, 0.07, -0.05, 0.12],
482        ]);
483        // The difference shows on the SMALL channels (cols 1..4): per-token lumps them with
484        // the outlier and crushes them; per-channel isolates the outlier so they stay sharp.
485        let small_err = |q: &Array2<f32>| -> f32 {
486            (1..4)
487                .flat_map(|j| (0..4).map(move |i| (i, j)))
488                .map(|(i, j)| (x[(i, j)] - q[(i, j)]).abs())
489                .fold(0.0, f32::max)
490        };
491        let pc = small_err(&quant_dequant(x.view(), 4, false)); // per-channel (by column)
492        let pt = small_err(&quant_dequant(x.view(), 4, true)); // per-token (by row)
493        assert!(pc < pt * 0.2, "per-channel ≫ better on the small dims: pc={pc} pt={pt}");
494    }
495
496    // 8-bit KV is near-lossless for attention output; quality improves with bits.
497    #[test]
498    fn attention_near_lossless_at_8bit() {
499        // single head: Q[1,d] . K[s,d] -> softmax -> . V[s,d]
500        let (s, d) = (12usize, 16usize);
501        let mk = |seed: u64| -> Array2<f32> {
502            let mut st = seed;
503            Array2::from_shape_fn((s, d), |_| {
504                st = st.wrapping_mul(6364136223846793005).wrapping_add(1);
505                ((st >> 40) as f32 / (1u64 << 24) as f32) - 0.5
506            })
507        };
508        let q = mk(1).row(0).to_owned();
509        let k = mk(2);
510        let v = mk(3);
511        let scale = 1.0 / (d as f32).sqrt();
512        let attn = |k: &Array2<f32>, v: &Array2<f32>| -> Vec<f32> {
513            let mut sc: Vec<f32> = (0..s).map(|j| q.dot(&k.row(j)) * scale).collect();
514            let m = sc.iter().cloned().fold(f32::MIN, f32::max);
515            let mut sum = 0.0;
516            sc.iter_mut().for_each(|x| {
517                *x = (*x - m).exp();
518                sum += *x;
519            });
520            (0..d).map(|e| (0..s).map(|j| sc[j] / sum * v[(j, e)]).sum()).collect()
521        };
522        let full = attn(&k, &v);
523        let dev = |bits: u32| -> f32 {
524            // Keys per-channel (by col), Values per-token (by row) — the KIVI layout.
525            let kq = quant_dequant(k.view(), bits, false);
526            let vq = quant_dequant(v.view(), bits, true);
527            let o = attn(&kq, &vq);
528            let num: f32 = o.iter().zip(&full).map(|(a, b)| (a - b).powi(2)).sum::<f32>().sqrt();
529            let den: f32 = full.iter().map(|x| x * x).sum::<f32>().sqrt();
530            num / den.max(1e-9)
531        };
532        let (d4, d8, d12) = (dev(4), dev(8), dev(12));
533        assert!(d8 < d4 && d12 < d8, "deviation must shrink with bits: 4={d4} 8={d8} 12={d12}");
534        assert!(d8 < 0.02, "8-bit KV near-lossless for attention, got {d8}");
535    }
536
537    // ─── Integration: packed storage memory savings ───────────────────────────────
538    #[test]
539    fn packed_u8_saves_memory_vs_f32() {
540        let (t, d) = (512usize, 64usize);
541        let mut kc = QuantKeyCache::new(d);
542        let mut vc = QuantValueCache::new(d);
543        let mut rng = 42u64;
544        let mut next = || -> f32 {
545            rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
546            ((rng >> 40) as f32 / (1u64 << 24) as f32) - 0.5
547        };
548        for _ in 0..t {
549            kc.push_token(&(0..d).map(|_| next()).collect::<Vec<_>>());
550            vc.push_token(&(0..d).map(|_| next()).collect::<Vec<_>>());
551        }
552        let f32_bytes = t * d * 4 * 2; // K + V in f32
553        let quant_bytes = kc.memory_bytes() + vc.memory_bytes();
554        let ratio = f32_bytes as f32 / quant_bytes as f32;
555        // u8 = 1 byte/element vs f32 = 4 bytes; per-channel params for K (D*8),
556        // per-token params for V (T*8) — overall >3x saving at T=512 D=64.
557        assert!(ratio > 3.0, "expected >3x memory saving, got {ratio:.2}x");
558        println!("f32 bytes: {f32_bytes}, quantized: {quant_bytes}, ratio: {ratio:.2}x");
559    }
560
561    // ─── Integration: fused op runs through tract's engine, near-lossless ─────────
562    #[test]
563    fn quantized_kv_sdpa_runs_in_model() -> TractResult<()> {
564        let (b, h, d) = (1usize, 2usize, 16usize);
565        let scale = 1.0 / (d as f32).sqrt();
566        let mut model = TypedModel::default();
567        let s = model.sym("S");
568        let dim = |x: usize| x.to_dim();
569        let f: TVec<TDim> = tvec![dim(b), dim(h), s.into(), dim(d)];
570        let q = model.add_source("q", f32::fact(&f))?;
571        let k = model.add_source("k", f32::fact(&f))?;
572        let v = model.add_source("v", f32::fact(&f))?;
573        let o = model.wire_node("qkv", QuantizedKvSdpa { axis: 2, scale: None }, &[q, k, v])?;
574        model.select_output_outlets(&o)?;
575        let mut rt = model.into_runnable()?.spawn()?;
576
577        // Run 10 decode steps; compare each to full-f32 attention over the growing cache.
578        use tract_nnef::tract_core::ops::array::TypedConcat;
579        use tract_nnef::tract_ndarray::{Array4 as A4, s};
580
581        let mk = |base: f32| -> Tensor {
582            let data: Vec<f32> = (0..b * h * d).map(|i| base + (i as f32 * 0.013).sin()).collect();
583            Tensor::from_shape(&[b, h, 1, d], &data).unwrap()
584        };
585        let grow = |acc: Option<Tensor>, x: Tensor| -> TractResult<Tensor> {
586            Ok(match acc {
587                None => x,
588                Some(a) => {
589                    TypedConcat { axis: 2 }.eval(tvec![a.into(), x.into()])?.remove(0).into_tensor()
590                }
591            })
592        };
593        let attn = |q: A4<f32>, k: A4<f32>, v: A4<f32>| -> A4<f32> {
594            let (b, h, sq, d) = q.dim();
595            let mut out = A4::<f32>::zeros((b, h, sq, d));
596            for bi in 0..b {
597                for hi in 0..h {
598                    let qm = q.slice(s![bi, hi, .., ..]);
599                    let km = k.slice(s![bi, hi, .., ..]);
600                    let vm = v.slice(s![bi, hi, .., ..]);
601                    let mut sc = qm.dot(&km.t());
602                    sc *= scale;
603                    for mut row in sc.rows_mut() {
604                        let m = row.iter().copied().fold(f32::NEG_INFINITY, f32::max);
605                        let mut sm = 0.0f32;
606                        row.iter_mut().for_each(|x| {
607                            *x = (*x - m).exp();
608                            sm += *x;
609                        });
610                        row.iter_mut().for_each(|x| *x /= sm);
611                    }
612                    out.slice_mut(s![bi, hi, .., ..]).assign(&sc.dot(&vm));
613                }
614            }
615            out
616        };
617        let (mut kf, mut vf): (Option<Tensor>, Option<Tensor>) = (None, None);
618        for t in 0..10 {
619            let qi = mk(9.0 + t as f32 * 0.1);
620            let ki = mk(1.0 + t as f32 * 0.07);
621            let vi = mk(5.0 - t as f32 * 0.05);
622            let o_model = rt
623                .run(tvec![qi.clone().into(), ki.clone().into(), vi.clone().into()])?
624                .remove(0)
625                .into_tensor();
626            kf = Some(grow(kf.take(), ki)?);
627            vf = Some(grow(vf.take(), vi)?);
628            let qv = qi.to_plain_array_view::<f32>()?.into_dimensionality()?;
629            let kv = kf.as_ref().unwrap().to_plain_array_view::<f32>()?.into_dimensionality()?;
630            let vv = vf.as_ref().unwrap().to_plain_array_view::<f32>()?.into_dimensionality()?;
631            let o_ref = Tensor::from(attn(qv.to_owned(), kv.to_owned(), vv.to_owned()));
632            // quantized decode should be close to f32 (within ~2% at int8 quality)
633            o_model
634                .close_enough(&o_ref, Approximation::SuperApproximate)
635                .with_context(|| format!("quantized decode too far from f32 at step {t}"))?;
636        }
637        Ok(())
638    }
639
640    // ─── Integration: auto-wiring transform ──────────────────────────────────────
641    #[test]
642    fn transform_fuses_cache_sdpa_to_quantized() -> TractResult<()> {
643        let (b, h, d) = (1usize, 2usize, 16usize);
644        let mut model = TypedModel::default();
645        let s = model.sym("S");
646        let p = model.sym("P");
647        let dim = |x: usize| x.to_dim();
648        let newf: TVec<TDim> = tvec![dim(b), dim(h), s.into(), dim(d)];
649        let pastf: TVec<TDim> = tvec![dim(b), dim(h), p.into(), dim(d)];
650        let q = model.add_source("q", f32::fact(&newf))?;
651        let knew = model.add_source("k", f32::fact(&newf))?;
652        let vnew = model.add_source("v", f32::fact(&newf))?;
653        let mkc = |nm: &str| DynKeyValueCache {
654            name: nm.to_string(),
655            axis: 2,
656            past_sequence_fact: f32::fact(&pastf),
657            input_sequence_fact: f32::fact(&newf),
658        };
659        let kc = model.wire_node("kc", mkc("kc"), &[knew])?;
660        let vc = model.wire_node("vc", mkc("vc"), &[vnew])?;
661        let o = model.wire_node(
662            "sdpa",
663            Sdpa {
664                scale: None,
665                datum_type: f32::datum_type(),
666                acc_datum_type: f32::datum_type(),
667                is_causal: false,
668            },
669            &[q, kc[0], vc[0]],
670        )?;
671        model.select_output_outlets(&o)?;
672        QuantizedKvSdpaTransform.transform(&mut model)?;
673        assert!(model.nodes().iter().any(|n| n.op_is::<QuantizedKvSdpa>()), "fused op present");
674        assert!(!model.nodes().iter().any(|n| n.op_is::<DynKeyValueCache>()), "caches removed");
675        assert!(!model.nodes().iter().any(|n| n.op_is::<Sdpa>()), "sdpa removed");
676        Ok(())
677    }
678
679    // Memory saving bench: print u8 vs f32 savings at realistic decode lengths.
680    //   cargo test -p tract-transformers kv_quant::tests::bench_memory_savings -- --ignored --nocapture
681    #[test]
682    #[ignore]
683    fn bench_memory_savings() {
684        let d = 128usize;
685        let mut rng = 99u64;
686        let mut next = || -> f32 {
687            rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
688            ((rng >> 40) as f32 / (1u64 << 24) as f32) - 0.5
689        };
690        println!("\n  KV cache memory (int8 u8 vs f32), H=8 heads, D={d}:");
691        println!("     T     f32(MB)   int8(MB)   saving");
692        for &t in &[256usize, 1024, 4096, 16384] {
693            let mut kc = QuantKeyCache::new(d);
694            let mut vc = QuantValueCache::new(d);
695            for _ in 0..t {
696                kc.push_token(&(0..d).map(|_| next()).collect::<Vec<_>>());
697                vc.push_token(&(0..d).map(|_| next()).collect::<Vec<_>>());
698            }
699            let heads = 8;
700            let f32_mb = (t * d * 4 * 2 * heads) as f32 / 1e6;
701            let int8_mb = ((kc.memory_bytes() + vc.memory_bytes()) * heads) as f32 / 1e6;
702            println!("  {t:>6}  {f32_mb:>9.2}  {int8_mb:>9.2}  {:>6.2}x", f32_mb / int8_mb);
703        }
704    }
705
706    // NNEF round-trip: QuantizedKvSdpa survives write_to_tar -> model_for_read.
707    #[test]
708    fn quantized_kv_sdpa_nnef_round_trip() -> TractResult<()> {
709        use crate::WithTractTransformers;
710        let (b, h, d) = (1usize, 2usize, 16usize);
711        let mut model = TypedModel::default();
712        let s = model.sym("S");
713        let dim = |x: usize| x.to_dim();
714        let f: TVec<TDim> = tvec![dim(b), dim(h), s.into(), dim(d)];
715        let q = model.add_source("q", f32::fact(&f))?;
716        let k = model.add_source("k", f32::fact(&f))?;
717        let v = model.add_source("v", f32::fact(&f))?;
718        let o =
719            model.wire_node("qkv", QuantizedKvSdpa { axis: 2, scale: Some(0.125) }, &[q, k, v])?;
720        model.select_output_outlets(&o)?;
721
722        let nnef = tract_nnef::nnef().with_tract_transformers();
723        let mut buffer = vec![];
724        nnef.write_to_tar(&model, &mut buffer)?;
725        let reloaded = nnef.model_for_read(&mut &*buffer)?;
726
727        let n = reloaded
728            .nodes()
729            .iter()
730            .find(|n| n.op_is::<QuantizedKvSdpa>())
731            .context("QuantizedKvSdpa not found after round-trip")?;
732        let op = n.op_as::<QuantizedKvSdpa>().unwrap();
733        assert_eq!(op.axis, 2);
734        assert_eq!(op.scale, Some(0.125));
735        Ok(())
736    }
737}