use super::KvCache;
use candle_core::{DType, Result as CandleResult, Tensor, D};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum QuantizationGranularity {
#[default]
PerToken,
PerHead,
PerTensor,
}
#[derive(Debug)]
pub struct QuantizedCache {
k_quantized: Option<Tensor>,
v_quantized: Option<Tensor>,
k_scales: Option<Tensor>,
v_scales: Option<Tensor>,
#[allow(dead_code)]
num_kv_heads: usize,
#[allow(dead_code)]
head_dim: usize,
granularity: QuantizationGranularity,
dtype: Option<DType>,
}
impl QuantizedCache {
pub fn new(num_kv_heads: usize, head_dim: usize, granularity: QuantizationGranularity) -> Self {
Self {
k_quantized: None,
v_quantized: None,
k_scales: None,
v_scales: None,
num_kv_heads,
head_dim,
granularity,
dtype: None,
}
}
fn quantize(&self, tensor: &Tensor) -> CandleResult<(Tensor, Tensor)> {
let dtype = tensor.dtype();
let device = tensor.device();
let abs_tensor = tensor.abs()?;
let max_vals = match self.granularity {
QuantizationGranularity::PerToken => {
abs_tensor.max_keepdim(D::Minus1)?
},
QuantizationGranularity::PerHead => {
abs_tensor.max_keepdim(D::Minus1)?.max_keepdim(D::Minus2)?
},
QuantizationGranularity::PerTensor => {
let max_val = abs_tensor.max_all()?;
max_val.reshape((1, 1, 1, 1))?
},
};
let eps = Tensor::new(&[1e-8f32], device)?
.broadcast_as(max_vals.shape())?
.to_dtype(dtype)?;
let max_vals = max_vals.maximum(&eps)?;
let scale = (&max_vals / 127.0)?;
let scaled = tensor.broadcast_div(&scale)?;
let offset = (scaled + 128.0)?;
let clamped = offset.clamp(0.0, 255.0)?;
let quantized = clamped.round()?.to_dtype(DType::U8)?;
Ok((quantized, scale))
}
fn dequantize(
&self,
quantized: &Tensor,
scales: &Tensor,
dtype: DType,
) -> CandleResult<Tensor> {
let float_vals = quantized.to_dtype(dtype)?;
let unoffset = (float_vals - 128.0)?;
unoffset.broadcast_mul(scales)
}
}
impl KvCache for QuantizedCache {
fn append(&mut self, k: &Tensor, v: &Tensor) -> CandleResult<()> {
if self.dtype.is_none() {
self.dtype = Some(k.dtype());
}
let (k_quant, k_scale) = self.quantize(k)?;
let (v_quant, v_scale) = self.quantize(v)?;
let (k_quantized, k_scales) = match (&self.k_quantized, &self.k_scales) {
(Some(prev_k), Some(prev_scale)) => {
let k = Tensor::cat(&[prev_k, &k_quant], 2)?;
let scale = Tensor::cat(&[prev_scale, &k_scale], 2)?;
(k, scale)
},
_ => (k_quant, k_scale),
};
let (v_quantized, v_scales) = match (&self.v_quantized, &self.v_scales) {
(Some(prev_v), Some(prev_scale)) => {
let v = Tensor::cat(&[prev_v, &v_quant], 2)?;
let scale = Tensor::cat(&[prev_scale, &v_scale], 2)?;
(v, scale)
},
_ => (v_quant, v_scale),
};
self.k_quantized = Some(k_quantized);
self.k_scales = Some(k_scales);
self.v_quantized = Some(v_quantized);
self.v_scales = Some(v_scales);
Ok(())
}
fn seq_len(&self) -> usize {
self.k_quantized.as_ref().map(|t| t.dims()[2]).unwrap_or(0)
}
fn clear(&mut self) {
self.k_quantized = None;
self.v_quantized = None;
self.k_scales = None;
self.v_scales = None;
self.dtype = None;
}
fn memory_bytes(&self) -> usize {
let k_mem = self
.k_quantized
.as_ref()
.map(|t| t.elem_count())
.unwrap_or(0);
let v_mem = self
.v_quantized
.as_ref()
.map(|t| t.elem_count())
.unwrap_or(0);
let k_scale_mem = self
.k_scales
.as_ref()
.map(|t| t.elem_count() * t.dtype().size_in_bytes())
.unwrap_or(0);
let v_scale_mem = self
.v_scales
.as_ref()
.map(|t| t.elem_count() * t.dtype().size_in_bytes())
.unwrap_or(0);
k_mem + v_mem + k_scale_mem + v_scale_mem
}
fn get_kv(&self) -> CandleResult<Option<(Tensor, Tensor)>> {
let dtype = self.dtype.unwrap_or(DType::BF16);
match (
&self.k_quantized,
&self.k_scales,
&self.v_quantized,
&self.v_scales,
) {
(Some(k_q), Some(k_s), Some(v_q), Some(v_s)) => {
let k = self.dequantize(k_q, k_s, dtype)?;
let v = self.dequantize(v_q, v_s, dtype)?;
Ok(Some((k, v)))
},
_ => Ok(None),
}
}
fn supports_fused_attention(&self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use candle_core::Device;
#[test]
fn test_quantized_cache_empty() {
let cache = QuantizedCache::new(2, 64, QuantizationGranularity::PerToken);
assert_eq!(cache.seq_len(), 0);
assert_eq!(cache.memory_bytes(), 0);
assert!(cache.get_kv().unwrap().is_none());
}
#[test]
fn test_quantized_cache_append() -> CandleResult<()> {
let mut cache = QuantizedCache::new(4, 64, QuantizationGranularity::PerToken);
let device = Device::Cpu;
let k1 = Tensor::randn(0.0f32, 1.0, (1, 4, 10, 64), &device)?;
let v1 = Tensor::randn(0.0f32, 1.0, (1, 4, 10, 64), &device)?;
cache.append(&k1, &v1)?;
assert_eq!(cache.seq_len(), 10);
let k2 = Tensor::randn(0.0f32, 1.0, (1, 4, 5, 64), &device)?;
let v2 = Tensor::randn(0.0f32, 1.0, (1, 4, 5, 64), &device)?;
cache.append(&k2, &v2)?;
assert_eq!(cache.seq_len(), 15);
let (k, v) = cache.get_kv()?.unwrap();
assert_eq!(k.dims(), &[1, 4, 15, 64]);
assert_eq!(v.dims(), &[1, 4, 15, 64]);
Ok(())
}
#[test]
fn test_quantization_accuracy() -> CandleResult<()> {
let cache = QuantizedCache::new(2, 8, QuantizationGranularity::PerToken);
let device = Device::Cpu;
let data: Vec<f32> = (0..128).map(|i| (i as f32 - 64.0) / 64.0).collect();
let tensor = Tensor::from_vec(data, (1, 2, 8, 8), &device)?;
let (quantized, scales) = cache.quantize(&tensor)?;
let recovered = cache.dequantize(&quantized, &scales, DType::F32)?;
let orig: Vec<f32> = tensor.flatten_all()?.to_vec1()?;
let recv: Vec<f32> = recovered.flatten_all()?.to_vec1()?;
for (o, r) in orig.iter().zip(recv.iter()) {
let error = (o - r).abs();
assert!(
error < 0.02,
"Error too large: {} vs {} (error: {})",
o,
r,
error
);
}
Ok(())
}
#[test]
fn test_memory_savings() -> CandleResult<()> {
let mut cache = QuantizedCache::new(8, 128, QuantizationGranularity::PerToken);
let device = Device::Cpu;
let k = Tensor::randn(0.0f32, 1.0, (1, 8, 100, 128), &device)?.to_dtype(DType::BF16)?;
let v = Tensor::randn(0.0f32, 1.0, (1, 8, 100, 128), &device)?.to_dtype(DType::BF16)?;
cache.append(&k, &v)?;
let quant_mem = cache.memory_bytes();
let full_mem = 2 * 1 * 8 * 100 * 128 * 2;
let savings = full_mem as f32 / quant_mem as f32;
println!(
"Memory: quantized={}, full={}, savings={:.2}x",
quant_mem, full_mem, savings
);
assert!(savings > 1.5, "Expected at least 1.5x savings");
Ok(())
}
}