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