Skip to main content

tract_transformers/ops/
quant_dyn_kv_cache.rs

1//! Storage-only quantized KV cache: a drop-in replacement for `DynKeyValueCache` that keeps the
2//! resident cache in int8/int4 and dequantizes back to the input dtype on read. It stays a
3//! cache-shaped op, so it still resolves the past-length symbol via `init_tensor_fact` /
4//! `resolve_symbols`; downstream position/RoPE/mask/attention are untouched.
5//!
6//! KIVI layout (Liu et al. 2024): Keys are quantized **per channel** (each head-dim channel has
7//! its own scale — Keys have large-magnitude outlier channels a shared scale would crush), Values
8//! **per token**. To stay consistent in a streaming cache, Keys are quantized in fixed-size blocks
9//! whose per-channel scales are frozen once a block is full; the current partial block is kept in
10//! f32 until it fills.
11
12use std::str::FromStr;
13
14use tract_nnef::internal::*;
15use tract_nnef::prelude::tract_itertools::Itertools;
16use tract_nnef::ser::{datum_type, tdims};
17use tract_nnef::tract_core::ops::array::MultiBroadcastTo;
18use tract_nnef::tract_core::ops::cast::Cast;
19use tract_nnef::tract_core::ops::change_axes::AxisOp;
20use tract_nnef::tract_core::transform::ModelTransform;
21use tract_nnef::tract_ndarray::{Array2, Ix4, s};
22
23use crate::ops::apply_rope::ApplyRope;
24use crate::ops::dyn_kv_cache::DynKeyValueCache;
25use crate::ops::sdpa::Sdpa;
26
27pub fn register(registry: &mut Registry) {
28    registry.register_dumper(ser_quant_dyn_kv_cache);
29    registry.register_primitive(
30        "tract_transformers_quantized_dyn_kv_cache",
31        &[
32            TypeName::Scalar.tensor().named("input"),
33            TypeName::String.named("name"),
34            TypeName::Integer.named("axis"),
35            TypeName::Integer.named("bits"),
36            TypeName::Integer.named("per_channel"),
37            TypeName::String.named("datum_type"),
38            TypeName::Integer.array().named("past_sequence_shape"),
39            TypeName::Integer.array().named("input_sequence_shape"),
40        ],
41        &[("output", TypeName::Scalar.tensor())],
42        de_quant_dyn_kv_cache,
43    );
44}
45
46// ── affine int8 / int4 packing ──────────────────────────────────────────────────────────────────
47
48/// Max code value at `bits` bits (`levels - 1`). `bits` is 4 or 8.
49#[inline]
50fn max_code(bits: u32) -> f32 {
51    ((1u32 << bits) - 1) as f32
52}
53
54/// Bytes needed to store `d` codes at `bits` bits/code, packed.
55#[inline]
56fn row_bytes(d: usize, bits: u32) -> usize {
57    (d * bits as usize).div_ceil(8)
58}
59
60/// Pack `codes` (each `<= max_code(bits)`) at `bits` bits/code, appending to `out`.
61/// bits=8: one code per byte. bits=4: two codes per byte (even index → low nibble).
62fn pack_codes(codes: &[u8], bits: u32, out: &mut Vec<u8>) {
63    match bits {
64        8 => out.extend_from_slice(codes),
65        4 => {
66            for pair in codes.chunks(2) {
67                let lo = pair[0] & 0x0F;
68                let hi = pair.get(1).map(|c| c & 0x0F).unwrap_or(0);
69                out.push(lo | (hi << 4));
70            }
71        }
72        _ => unreachable!("bits must be 4 or 8"),
73    }
74}
75
76/// Read the `c`-th code from a packed row `bytes` at `bits` bits/code.
77#[inline]
78fn unpack_code(bytes: &[u8], c: usize, bits: u32) -> u8 {
79    match bits {
80        8 => bytes[c],
81        4 => {
82            let b = bytes[c >> 1];
83            if c & 1 == 0 { b & 0x0F } else { b >> 4 }
84        }
85        _ => unreachable!("bits must be 4 or 8"),
86    }
87}
88
89// The first tokens act as attention sinks and carry outsized weight, so they are kept in full
90// precision rather than quantized — a large accuracy gain for a constant, tiny cost.
91const SINK_TOKENS: usize = 4;
92
93// ── per-token Value store ───────────────────────────────────────────────────────────────────────
94
95/// Values quantized per token: each token gets one `(lo, scale)` pair over its `D` channels. The
96/// first `SINK_TOKENS` are kept in f32.
97#[derive(Clone, Debug, Default)]
98struct PerTokenStore {
99    d: usize,
100    bits: u32,
101    row_bytes: usize,
102    sink: Vec<f32>, // first SINK_TOKENS rows, f32
103    packed: Vec<u8>,
104    params: Vec<(f32, f32)>,
105}
106
107impl PerTokenStore {
108    fn with_bits(d: usize, bits: u32) -> Self {
109        PerTokenStore { d, bits, row_bytes: row_bytes(d, bits), ..Default::default() }
110    }
111    fn n_sink(&self) -> usize {
112        self.sink.len() / self.d
113    }
114    fn len(&self) -> usize {
115        self.n_sink() + self.params.len()
116    }
117    fn push_token(&mut self, v: &[f32]) {
118        if self.n_sink() < SINK_TOKENS {
119            self.sink.extend_from_slice(v);
120            return;
121        }
122        let lv = max_code(self.bits);
123        let lo = v.iter().copied().fold(f32::INFINITY, f32::min);
124        let hi = v.iter().copied().fold(f32::NEG_INFINITY, f32::max);
125        let scale = if hi > lo { (hi - lo) / lv } else { 1.0 };
126        let codes: Vec<u8> =
127            v.iter().map(|&x| ((x - lo) / scale).round().clamp(0.0, lv) as u8).collect();
128        pack_codes(&codes, self.bits, &mut self.packed);
129        self.params.push((lo, scale));
130    }
131    fn dequant_all(&self) -> Array2<f32> {
132        let (d, rb, bits) = (self.d, self.row_bytes, self.bits);
133        let ns = self.n_sink();
134        let mut out = Array2::<f32>::zeros((self.len(), d));
135        for t in 0..ns {
136            for c in 0..d {
137                out[(t, c)] = self.sink[t * d + c];
138            }
139        }
140        for (i, &(lo, scale)) in self.params.iter().enumerate() {
141            let src = &self.packed[i * rb..i * rb + rb];
142            for c in 0..d {
143                out[(ns + i, c)] = lo + unpack_code(src, c, bits) as f32 * scale;
144            }
145        }
146        out
147    }
148}
149
150// ── block-wise per-channel Key store ────────────────────────────────────────────────────────────
151
152const KEY_BLOCK: usize = 32;
153
154/// Keys quantized per channel in blocks of `KEY_BLOCK` tokens. Each finalized block stores `D`
155/// `lo`/`scale` values computed over that block and frozen, so every code dequantizes against the
156/// exact scale it was encoded with. The first `SINK_TOKENS` and the current partial block are held
157/// in f32.
158#[derive(Clone, Debug, Default)]
159struct BlockChannelStore {
160    d: usize,
161    bits: u32,
162    row_bytes: usize,
163    sink: Vec<f32>,        // first SINK_TOKENS rows, f32
164    packed: Vec<u8>,       // finalized blocks, [n_finalized * KEY_BLOCK, row_bytes]
165    block_lo: Vec<f32>,    // per finalized block: D lo values (block * D + c)
166    block_scale: Vec<f32>, // per finalized block: D scale values
167    residual: Vec<f32>,    // current partial block, row-major [n_res, D]
168    n_res: usize,
169    n_finalized: usize,
170}
171
172impl BlockChannelStore {
173    fn with_bits(d: usize, bits: u32) -> Self {
174        BlockChannelStore { d, bits, row_bytes: row_bytes(d, bits), ..Default::default() }
175    }
176    fn n_sink(&self) -> usize {
177        self.sink.len() / self.d
178    }
179    fn len(&self) -> usize {
180        self.n_sink() + self.n_finalized * KEY_BLOCK + self.n_res
181    }
182    fn push_token(&mut self, k: &[f32]) {
183        if self.n_sink() < SINK_TOKENS {
184            self.sink.extend_from_slice(k);
185            return;
186        }
187        self.residual.extend_from_slice(k);
188        self.n_res += 1;
189        if self.n_res == KEY_BLOCK {
190            self.finalize_block();
191        }
192    }
193    fn finalize_block(&mut self) {
194        let (d, bits) = (self.d, self.bits);
195        let lv = max_code(bits);
196        let mut lo = vec![f32::INFINITY; d];
197        let mut hi = vec![f32::NEG_INFINITY; d];
198        for t in 0..self.n_res {
199            for c in 0..d {
200                let v = self.residual[t * d + c];
201                lo[c] = lo[c].min(v);
202                hi[c] = hi[c].max(v);
203            }
204        }
205        let scale: Vec<f32> =
206            (0..d).map(|c| if hi[c] > lo[c] { (hi[c] - lo[c]) / lv } else { 1.0 }).collect();
207        let mut codes = vec![0u8; d];
208        for t in 0..self.n_res {
209            for c in 0..d {
210                codes[c] =
211                    ((self.residual[t * d + c] - lo[c]) / scale[c]).round().clamp(0.0, lv) as u8;
212            }
213            pack_codes(&codes, bits, &mut self.packed);
214        }
215        self.block_lo.extend_from_slice(&lo);
216        self.block_scale.extend_from_slice(&scale);
217        self.residual.clear();
218        self.n_res = 0;
219        self.n_finalized += 1;
220    }
221    fn dequant_all(&self) -> Array2<f32> {
222        let (d, rb, bits) = (self.d, self.row_bytes, self.bits);
223        let ns = self.n_sink();
224        let mut out = Array2::<f32>::zeros((self.len(), d));
225        for t in 0..ns {
226            for c in 0..d {
227                out[(t, c)] = self.sink[t * d + c];
228            }
229        }
230        for bi in 0..self.n_finalized {
231            let lo = &self.block_lo[bi * d..bi * d + d];
232            let sc = &self.block_scale[bi * d..bi * d + d];
233            for ti in 0..KEY_BLOCK {
234                let g = bi * KEY_BLOCK + ti;
235                let src = &self.packed[g * rb..g * rb + rb];
236                for c in 0..d {
237                    out[(ns + g, c)] = lo[c] + unpack_code(src, c, bits) as f32 * sc[c];
238                }
239            }
240        }
241        let base = ns + self.n_finalized * KEY_BLOCK;
242        for ti in 0..self.n_res {
243            for c in 0..d {
244                out[(base + ti, c)] = self.residual[ti * d + c];
245            }
246        }
247        out
248    }
249}
250
251/// Per-head quantized store, dispatched on the KIVI layout (Keys per-channel, Values per-token).
252#[derive(Clone, Debug)]
253enum HeadCache {
254    Key(BlockChannelStore),
255    Value(PerTokenStore),
256}
257
258impl HeadCache {
259    fn new(per_channel: bool, d: usize, bits: u32) -> Self {
260        if per_channel {
261            HeadCache::Key(BlockChannelStore::with_bits(d, bits))
262        } else {
263            HeadCache::Value(PerTokenStore::with_bits(d, bits))
264        }
265    }
266    fn push(&mut self, row: &[f32]) {
267        match self {
268            HeadCache::Key(c) => c.push_token(row),
269            HeadCache::Value(c) => c.push_token(row),
270        }
271    }
272    fn dequant_all(&self) -> Array2<f32> {
273        match self {
274            HeadCache::Key(c) => c.dequant_all(),
275            HeadCache::Value(c) => c.dequant_all(),
276        }
277    }
278}
279
280// ── op ──────────────────────────────────────────────────────────────────────────────────────────
281
282/// Quantized replacement for `DynKeyValueCache`. `per_channel` selects the KIVI layout
283/// (Keys: true, Values: false); `bits` is 4 or 8. The sequence axis must be `rank-2` with the head
284/// dim last (the standard `[B, H, S, D]` decode-cache layout).
285#[derive(Clone, Debug, PartialEq, Eq)]
286pub struct QuantizedDynKeyValueCache {
287    pub name: String,
288    pub axis: usize,
289    pub bits: u32,
290    pub per_channel: bool,
291    pub past_sequence_fact: TypedFact,
292    pub input_sequence_fact: TypedFact,
293}
294
295impl Op for QuantizedDynKeyValueCache {
296    fn name(&self) -> StaticName {
297        "QuantizedDynKeyValueCache".to_string().into()
298    }
299    fn info(&self) -> TractResult<Vec<String>> {
300        Ok(vec![format!(
301            "bits={}, per_channel={}, axis={}",
302            self.bits, self.per_channel, self.axis
303        )])
304    }
305    op_as_typed_op!();
306}
307
308impl EvalOp for QuantizedDynKeyValueCache {
309    not_out_of_plan!();
310    fn state(&self, _ctx: &EvalContext) -> TractResult<Option<Box<dyn OpState>>> {
311        Ok(Some(Box::new(QuantizedDynKvCacheState {
312            name: self.name.clone(),
313            axis: self.axis,
314            bits: self.bits,
315            per_channel: self.per_channel,
316            past_sequence_fact: self.past_sequence_fact.clone(),
317            caches: Vec::new(),
318            lead_shape: tvec!(),
319            d: 0,
320            len: 0,
321        })))
322    }
323}
324
325impl TypedOp for QuantizedDynKeyValueCache {
326    fn output_facts(&self, inputs: &[&TypedFact]) -> TractResult<TVec<TypedFact>> {
327        ensure!(inputs.len() == 1);
328        let mut fact = inputs[0].without_value();
329        fact.shape.set(
330            self.axis,
331            self.past_sequence_fact.shape.dims()[self.axis].clone()
332                + self.input_sequence_fact.shape.dims()[self.axis].clone(),
333        );
334        Ok(tvec!(fact))
335    }
336
337    fn cost(&self, _inputs: &[&TypedFact]) -> TractResult<TVec<(Cost, TDim)>> {
338        let values_per_token = self
339            .past_sequence_fact
340            .shape
341            .iter()
342            .enumerate()
343            .filter(|(axis, _)| *axis != self.axis)
344            .map(|(_, d)| d)
345            .product::<TDim>();
346        // Resident bytes per token: `bits/8` per value, plus small per-token (Values) or
347        // amortized per-block (Keys) scale params — reported as a fraction of the value count.
348        let resident_bytes = values_per_token.clone() * self.bits.max(1) as i64 / 8;
349        Ok(tvec!(
350            (Cost::Custom(false, "KVCacheValuesPerToken".to_string()), values_per_token),
351            (Cost::Custom(false, "KVCacheQuantBytesPerToken".to_string()), resident_bytes),
352        ))
353    }
354
355    as_op!();
356}
357
358#[derive(Clone, Debug)]
359struct QuantizedDynKvCacheState {
360    name: String,
361    axis: usize,
362    bits: u32,
363    per_channel: bool,
364    past_sequence_fact: TypedFact,
365    caches: Vec<HeadCache>,
366    lead_shape: TVec<usize>, // dims before the seq axis (e.g. [batch, heads])
367    d: usize,                // head dim (last axis)
368    len: usize,              // accumulated sequence length
369}
370
371impl QuantizedDynKvCacheState {
372    /// Bind the single unresolved symbol in `past_sequence_fact` (the past length) to `len`.
373    fn bind_past(&self, state: &mut TurnState, len: usize) -> TractResult<()> {
374        let unresolved = self
375            .past_sequence_fact
376            .shape
377            .iter()
378            .filter_map(|symb| match symb {
379                TDim::Sym(s) if state.resolved_symbols.get(s).is_none() => Some(s),
380                _ => None,
381            })
382            .collect_vec();
383        if unresolved.is_empty() {
384            return Ok(());
385        }
386        ensure!(unresolved.len() == 1);
387        let sym = unresolved[0];
388        state.resolved_symbols.set(sym, len as i64);
389        if state.scenario.is_none() {
390            state.scenario = sym.scope().unwrap().guess_scenario(&state.resolved_symbols)?;
391        }
392        Ok(())
393    }
394
395    /// Append every new token of `f32in` (`[B, H, S, D]`, seq axis = 2) to the packed caches.
396    fn ingest(&mut self, f32in: &Tensor) -> TractResult<()> {
397        let view = f32in.to_plain_array_view::<f32>()?;
398        ensure!(view.ndim() == 4, "quantized KV cache supports rank-4 [B, H, S, D] caches");
399        ensure!(self.axis == 2, "seq axis must be 2 ([B, H, S, D])");
400        let view = view.into_dimensionality::<Ix4>()?;
401        let (b, h, s, d) = view.dim();
402        if self.caches.is_empty() {
403            self.d = d;
404            self.lead_shape = tvec!(b, h);
405            self.caches =
406                (0..b * h).map(|_| HeadCache::new(self.per_channel, d, self.bits)).collect();
407        }
408        ensure!(
409            d == self.d && self.lead_shape.as_slice() == [b, h],
410            "cache shape changed between steps"
411        );
412        for bi in 0..b {
413            for hi in 0..h {
414                let cache = &mut self.caches[bi * h + hi];
415                for t in 0..s {
416                    cache.push(view.slice(s![bi, hi, t, ..]).as_slice().unwrap());
417                }
418            }
419        }
420        self.len += s;
421        Ok(())
422    }
423
424    /// Reconstruct the full `[B, H, T, D]` f32 cache from the packed per-head stores.
425    fn dequantized(&self) -> TractResult<Tensor> {
426        let (t, d) = (self.len, self.d);
427        let leading: usize = self.lead_shape.iter().product();
428        let mut data = vec![0f32; leading * t * d];
429        for (idx, cache) in self.caches.iter().enumerate() {
430            let deq = cache.dequant_all();
431            let base = idx * t * d;
432            for ti in 0..t {
433                for di in 0..d {
434                    data[base + ti * d + di] = deq[(ti, di)];
435                }
436            }
437        }
438        let mut shape: Vec<usize> = self.lead_shape.to_vec();
439        shape.push(t);
440        shape.push(d);
441        Tensor::from_shape(&shape, &data)
442    }
443}
444
445impl OpState for QuantizedDynKvCacheState {
446    // Declaring the past-sequence fact as this op's state makes the past-length symbol resolvable
447    // (like `DynKeyValueCache`), so downstream shapes that depend on it plan and bind correctly.
448    fn init_tensor_fact(&self) -> Option<(String, TypedFact)> {
449        Some((self.name.clone(), self.past_sequence_fact.clone()))
450    }
451    fn has_init_tensor_fact(&self) -> bool {
452        true
453    }
454
455    fn load_from(
456        &mut self,
457        state: &mut TurnState,
458        states: &mut dyn Iterator<Item = TValue>,
459    ) -> TractResult<()> {
460        let init = states.next().context("Not enough state initializers")?;
461        self.bind_past(state, init.shape()[self.axis])?;
462        let f32init = init.cast_to::<f32>()?;
463        self.ingest(&f32init)?;
464        Ok(())
465    }
466
467    fn save_to(&self, states: &mut Vec<TValue>) -> TractResult<()> {
468        states.push(self.dequantized()?.into_tvalue());
469        Ok(())
470    }
471
472    fn resolve_symbols(&mut self, state: &mut TurnState) -> TractResult<()> {
473        self.bind_past(state, self.len)
474    }
475
476    fn eval(
477        &mut self,
478        _ctx: &EvalContext,
479        _op: &dyn Op,
480        inputs: TVec<TValue>,
481    ) -> TractResult<TVec<TValue>> {
482        let input = args_1!(inputs);
483        let input_dt = input.datum_type();
484        let f32in = input.cast_to::<f32>()?;
485        self.ingest(&f32in)?;
486        Ok(tvec!(self.dequantized()?.cast_to_dt(input_dt)?.into_owned().into_tvalue()))
487    }
488
489    fn reset_lanes(&mut self, _lanes: &[LaneId]) -> TractResult<()> {
490        bail!("QuantizedDynKvCache is not lane-aware: the cache has no lane axis")
491    }
492}
493
494// ── NNEF ser/de ─────────────────────────────────────────────────────────────────────────────────
495
496fn ser_quant_dyn_kv_cache(
497    ast: &mut IntoAst,
498    node: &TypedNode,
499    op: &QuantizedDynKeyValueCache,
500) -> TractResult<Option<Arc<RValue>>> {
501    let input = ast.mapping[&node.inputs[0]].clone();
502    Ok(Some(invocation(
503        "tract_transformers_quantized_dyn_kv_cache",
504        &[input],
505        &[
506            ("name", string(&op.name)),
507            ("axis", numeric(op.axis)),
508            ("bits", numeric(op.bits)),
509            ("per_channel", numeric(op.per_channel as i64)),
510            ("datum_type", datum_type(op.past_sequence_fact.datum_type)),
511            ("past_sequence_shape", tdims(op.past_sequence_fact.shape.dims())),
512            ("input_sequence_shape", tdims(op.input_sequence_fact.shape.dims())),
513        ],
514    )))
515}
516
517fn de_quant_dyn_kv_cache(
518    builder: &mut ModelBuilder,
519    invocation: &ResolvedInvocation,
520) -> TractResult<Value> {
521    let input = invocation.named_arg_as(builder, "input")?;
522    let name: String = invocation.named_arg_as(builder, "name")?;
523    let axis: usize = invocation.named_arg_as(builder, "axis")?;
524    let bits: i64 = invocation.named_arg_as(builder, "bits")?;
525    let per_channel: i64 = invocation.named_arg_as(builder, "per_channel")?;
526    let dt = DatumType::from_str(&invocation.named_arg_as::<String>(builder, "datum_type")?)?;
527    let past_sequence_shape: TVec<TDim> = builder
528        .allowing_new_symbols(|builder| invocation.named_arg_as(builder, "past_sequence_shape"))?;
529    let input_sequence_shape: TVec<TDim> = builder
530        .allowing_new_symbols(|builder| invocation.named_arg_as(builder, "input_sequence_shape"))?;
531    builder.wire(
532        QuantizedDynKeyValueCache {
533            name,
534            axis,
535            bits: bits as u32,
536            per_channel: per_channel != 0,
537            past_sequence_fact: dt.fact(&*past_sequence_shape),
538            input_sequence_fact: dt.fact(&*input_sequence_shape),
539        },
540        &[input],
541    )
542}
543
544// ── transform ───────────────────────────────────────────────────────────────────────────────────
545
546/// Walk an Sdpa K/V input back through cache-read plumbing (`MultiBroadcastTo` / `AxisOp` / `Cast`
547/// / on-read `ApplyRope`) to the `DynKeyValueCache` node; return its id. Every hop must be
548/// single-consumer so the cache can be replaced safely.
549fn walk_to_cache_node(model: &TypedModel, start: OutletId) -> Option<usize> {
550    let mut outlet = start;
551    loop {
552        let n = model.node(outlet.node);
553        if n.outputs[outlet.slot].successors.len() != 1 {
554            return None;
555        }
556        if n.op_is::<DynKeyValueCache>() {
557            return Some(n.id);
558        } else if n.op_is::<ApplyRope>()
559            || n.op_is::<MultiBroadcastTo>()
560            || n.op_is::<AxisOp>()
561            || n.op_is::<Cast>()
562        {
563            outlet = n.inputs[0];
564        } else {
565            return None;
566        }
567    }
568}
569
570fn replace_with_quant_cache(
571    patch: &mut TypedModelPatch,
572    model: &TypedModel,
573    cache_node_id: usize,
574    per_channel: bool,
575    bits: u32,
576) -> TractResult<()> {
577    let cnode = model.node(cache_node_id);
578    let dkv = cnode.op_as::<DynKeyValueCache>().context("expected DynKeyValueCache")?;
579    let tap = patch.taps(model, &[cnode.inputs[0]])?;
580    let new = patch.wire_node(
581        format!("{}.quant", cnode.name),
582        QuantizedDynKeyValueCache {
583            name: dkv.name.clone(),
584            axis: dkv.axis,
585            bits,
586            per_channel,
587            past_sequence_fact: dkv.past_sequence_fact.clone(),
588            input_sequence_fact: dkv.input_sequence_fact.clone(),
589        },
590        &tap,
591    )?;
592    patch.shunt_outside(model, cache_node_id.into(), new[0])?;
593    Ok(())
594}
595
596/// For each `Sdpa`, replace its two upstream KV caches with quantized ones: the K cache (input 1)
597/// per-channel and the V cache (input 2) per-token (KIVI). Attention/RoPE/mask are left untouched.
598fn quantize_kv_storage_rule(
599    ctx: &u32,
600    model: &TypedModel,
601    node: &TypedNode,
602    _node_name: &str,
603    _op: &Sdpa,
604) -> TractResult<Option<TypedModelPatch>> {
605    if node.inputs.len() != 3 && node.inputs.len() != 4 {
606        return Ok(None);
607    }
608    let (Some(k_cache), Some(v_cache)) =
609        (walk_to_cache_node(model, node.inputs[1]), walk_to_cache_node(model, node.inputs[2]))
610    else {
611        return Ok(None);
612    };
613    if k_cache == v_cache {
614        return Ok(None);
615    }
616    let mut patch = TypedModelPatch::default();
617    replace_with_quant_cache(&mut patch, model, k_cache, true, *ctx)?;
618    replace_with_quant_cache(&mut patch, model, v_cache, false, *ctx)?;
619    Ok(Some(patch))
620}
621
622/// Replace the KV caches feeding each attention with storage-quantized ones (int8/int4). Keeps a
623/// cache-shaped op so the past-length symbol still resolves; attention math is unchanged. `bits`
624/// selects int8 (default) or int4.
625#[derive(Debug, Clone, Copy)]
626pub struct QuantizeKvStorageTransform {
627    pub bits: u32,
628}
629
630impl Default for QuantizeKvStorageTransform {
631    fn default() -> Self {
632        QuantizeKvStorageTransform { bits: 8 }
633    }
634}
635
636impl ModelTransform for QuantizeKvStorageTransform {
637    fn name(&self) -> StaticName {
638        "quantize_kv_storage".into()
639    }
640    fn transform(&self, model: &mut TypedModel) -> TractResult<()> {
641        ensure!(self.bits == 4 || self.bits == 8, "KV quantization bits must be 4 or 8");
642        // Pre-passes so the rule sees a single ApplyRope and an internalized cache (idempotent if
643        // an earlier stage already ran them).
644        crate::rewriter::ApplyRopeTransform.transform(model)?;
645        crate::rewriter::KeyValueCacheTransform.transform(model)?;
646        Rewriter::default()
647            .with_rule_for("quantize-kv-storage", quantize_kv_storage_rule)
648            .rewrite(&self.bits, model)?;
649        model.compact()
650    }
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656    use tract_nnef::tract_core::ops::array::TypedConcat;
657
658    fn model_with_cache(bits: u32, per_channel: bool) -> TractResult<TypedModel> {
659        let mut model = TypedModel::default();
660        let s = model.sym("S");
661        let p = model.sym("P");
662        let (b, h, d) = (1usize, 2usize, 8usize);
663        let inf: TVec<TDim> = tvec![b.to_dim(), h.to_dim(), s.into(), d.to_dim()];
664        let pf: TVec<TDim> = tvec![b.to_dim(), h.to_dim(), p.into(), d.to_dim()];
665        let input = model.add_source("input", f32::fact(&inf))?;
666        let op = QuantizedDynKeyValueCache {
667            name: "kv0".to_string(),
668            axis: 2,
669            bits,
670            per_channel,
671            past_sequence_fact: f32::fact(&pf),
672            input_sequence_fact: f32::fact(&inf),
673        };
674        let out = model.wire_node("kv", op, &[input])?;
675        model.select_output_outlets(&out)?;
676        Ok(model)
677    }
678
679    // The quantized cache accumulates like a plain concat and dequantizes near-losslessly at int8.
680    #[test]
681    fn quant_cache_accumulates_near_lossless_int8() -> TractResult<()> {
682        for per_channel in [false, true] {
683            let mut rt = model_with_cache(8, per_channel)?.into_runnable()?.spawn()?;
684            let (b, h, d) = (1usize, 2usize, 8usize);
685            let mut st = 3u64;
686            let mut nf = || {
687                st = st.wrapping_mul(6364136223846793005).wrapping_add(1);
688                ((st >> 40) as f32 / (1u64 << 24) as f32) - 0.5
689            };
690            let mut acc: Option<Tensor> = None;
691            for _ in 0..40 {
692                let step = Tensor::from_shape(
693                    &[b, h, 1, d],
694                    &(0..b * h * d).map(|_| nf()).collect::<Vec<f32>>(),
695                )?;
696                let out = rt.run(tvec![step.clone().into()])?.remove(0).into_tensor();
697                acc = Some(match acc.take() {
698                    None => step,
699                    Some(a) => TypedConcat { axis: 2 }
700                        .eval(&EvalContext::out_of_plan(), tvec![a.into(), step.into()])?
701                        .remove(0)
702                        .into_tensor(),
703                });
704                acc.as_ref().unwrap().close_enough(&out, Approximation::SuperApproximate)?;
705            }
706        }
707        Ok(())
708    }
709
710    // Block-wise per-channel Keys stay consistent even when a channel's range grows late — the
711    // case a running per-channel scale would mis-dequantize.
712    #[test]
713    fn block_per_channel_key_consistent_over_growing_range() {
714        let d = 16usize;
715        let mut kc = BlockChannelStore::with_bits(d, 8);
716        let mut rows = Vec::new();
717        for t in 0..80usize {
718            let row: Vec<f32> = (0..d)
719                .map(|c| {
720                    if c == 0 { t as f32 * 2.0 } else { (((t * 7 + c) as f32) * 0.013).sin() * 0.1 }
721                })
722                .collect();
723            kc.push_token(&row);
724            rows.push(row);
725        }
726        assert_eq!(kc.len(), 80);
727        let deq = kc.dequant_all();
728        let mut maxerr = 0f32;
729        for t in 0..80 {
730            for c in 0..d {
731                maxerr = maxerr.max((deq[(t, c)] - rows[t][c]).abs());
732            }
733        }
734        assert!(maxerr < 1.0, "block-consistent int8 dequant, got {maxerr}");
735    }
736
737    #[test]
738    fn quant_cache_nnef_round_trip() -> TractResult<()> {
739        use crate::WithTractTransformers;
740        let model = model_with_cache(4, true)?;
741        let nnef = tract_nnef::nnef().with_tract_transformers();
742        let mut buffer = vec![];
743        nnef.write_to_tar(&model, &mut buffer)?;
744        let reloaded = nnef.model_for_read(&mut &*buffer)?;
745        let n = reloaded
746            .nodes()
747            .iter()
748            .find_map(|n| n.op_as::<QuantizedDynKeyValueCache>())
749            .context("QuantizedDynKeyValueCache missing after round-trip")?;
750        assert_eq!(n.bits, 4);
751        assert!(n.per_channel);
752        assert_eq!(n.axis, 2);
753        Ok(())
754    }
755}