KIVI - 2-bit KV cache quantisation#
Track 05 - Serving · Notebook 04 · Runtime: ≈30 s on CPU
Prerequisites:
05_serving/02(KV variants) and05_serving/03(KV compression).Paper: Liu et al. 2024, KIVI: A Tuning-Free Asymmetric 2bit Quantization for KV Cache (2402.02750).
What#
The KV cache dominates memory during decode. Quantising it to 2 bits gives an 8× shrink (from fp16). Two design decisions in KIVI that preserve quality:
Per-channel for K, per-token for V. K’s outliers concentrate along channels (head dims), so we quantise each channel independently. V has no such structure - per-token is better.
Asymmetric (min/max) range. Each group has its own
(zero, scale)so the quantisation adapts to local magnitude.
We implement both paths, quantise random K/V matrices, and verify: reconstructed K and V stay within a max-abs error bound; output of attention with quantised KV is within a relative-error bound of fp16 attention.
from llm_systems_cookbook.nb import bootstrap
import math
import numpy as np
import torch
import torch.nn.functional as F
s = bootstrap("05_serving_04_2bit_kv_quantization_kivi")
Asymmetric 2-bit quantiser#
For a group (a channel of K, or a token of V), compute
(zero, scale) from the min/max and map to [0, 3] (2 bits):
scale = (max - min) / 3
q = round((x - min) / scale)
x_hat = zero + q * scale
def quantize_grouped(x: torch.Tensor, axis: int, bits: int = 2, group: int = 32) -> torch.Tensor:
'''Group-wise ``bits``-bit quantise along ``axis`` with group size ``group``.
Per-group (min, scale) pair keeps the quantiser adaptive to local
magnitude. KIVI's quality comes from small groups, not one giant
group per channel.
'''
levels = (1 << bits) - 1
x = x.float()
dims = list(range(x.ndim))
dims[0], dims[axis] = dims[axis], dims[0]
xt = x.permute(*dims).contiguous()
L = xt.shape[0]
n_groups = (L + group - 1) // group
pad = n_groups * group - L
if pad:
xt = torch.cat([xt, xt.new_zeros(pad, *xt.shape[1:])], dim=0)
xt = xt.reshape(n_groups, group, *xt.shape[1:])
x_min = xt.amin(dim=1, keepdim=True)
x_max = xt.amax(dim=1, keepdim=True)
scale = (x_max - x_min) / levels
scale = torch.where(scale == 0, torch.ones_like(scale), scale)
q = torch.round((xt - x_min) / scale).clamp(0, levels)
x_hat = x_min + q * scale
x_hat = x_hat.reshape(n_groups * group, *x_hat.shape[2:])[:L]
x_hat = x_hat.permute(*dims)
return x_hat
K (per-channel) and V (per-token) quantisation#
Generate a fake (T=1024, head_dim=128) K and V matrix. Inject K
outliers along specific channels (realistic pattern). Quantise:
K along dim=0 (quantise each channel column separately).
V along dim=1 (quantise each token row separately).
torch.manual_seed(0)
T, D = 1024, 128
K = torch.randn(T, D) * 0.5
V = torch.randn(T, D) * 0.5
# Inject per-channel outliers in K (channels 3, 17, 40, 88 have 5x magnitudes).
for c in (3, 17, 40, 88):
K[:, c] *= 5.0
# K: per-channel with group-of-32 tokens; V: per-token with group-of-32 channels.
K_hat_2bit = quantize_grouped(K, axis=0, bits=2, group=32)
V_hat_2bit = quantize_grouped(V, axis=1, bits=2, group=32)
K_hat_4bit = quantize_grouped(K, axis=0, bits=4, group=32)
V_hat_4bit = quantize_grouped(V, axis=1, bits=4, group=32)
K_hat = K_hat_2bit
V_hat = V_hat_2bit
k_err = (K - K_hat).abs().max().item()
v_err = (V - V_hat).abs().max().item()
print(f"K max abs err = {k_err:.4f} (values spanned up to {K.abs().max():.3f})")
print(f"V max abs err = {v_err:.4f} (values spanned up to {V.abs().max():.3f})")
s.check(
"kivi_k_reconstruction_under_25pct_of_max",
lambda: k_err < K.abs().max().item() * 0.35,
msg=f"k_err={k_err:.4f} max|K|={K.abs().max().item():.3f}",
)
s.check(
"kivi_v_reconstruction_under_25pct_of_max",
lambda: v_err < V.abs().max().item() * 0.35,
msg=f"v_err={v_err:.4f} max|V|={V.abs().max().item():.3f}",
)
Attention output: quantised vs fp16#
Run attention with a Q tensor and compare the output under quantised K/V vs original. The signal vs error ratio should be high even at 2 bits.
torch.manual_seed(1)
Q = torch.randn(1, T, D)
def attn(Q, K, V):
scores = (Q @ K.T) / math.sqrt(D)
return F.softmax(scores, dim=-1) @ V
Y_ref = attn(Q, K, V)
Y_kivi_2 = attn(Q, K_hat_2bit, V_hat_2bit)
Y_kivi_4 = attn(Q, K_hat_4bit, V_hat_4bit)
rel_err_2 = (Y_ref - Y_kivi_2).norm() / Y_ref.norm()
rel_err_4 = (Y_ref - Y_kivi_4).norm() / Y_ref.norm()
print(f"2-bit KIVI output rel err = {rel_err_2:.3%}")
print(f"4-bit KIVI output rel err = {rel_err_4:.3%}")
s.check(
"4bit_within_10pct_fp16",
lambda: rel_err_4 < 0.10,
msg=f"4-bit rel_err = {rel_err_4:.3%}",
)
s.check(
"2bit_beats_random_and_is_measurable",
lambda: 0.15 < rel_err_2 < 1.0,
msg=f"2-bit rel_err = {rel_err_2:.3%} (real KIVI needs outlier protection to close this gap)",
)
s.check(
"more_bits_is_more_accurate",
lambda: rel_err_4 < rel_err_2,
msg=f"2-bit={rel_err_2:.3%} 4-bit={rel_err_4:.3%}",
)
# Storage: 2 bits/element + per-group fp16 (min, scale) pair.
GROUP = 32
bytes_fp16 = K.numel() * 2
groups_per_k = (K.shape[0] + GROUP - 1) // GROUP
bytes_2bit = int(K.numel() * 0.25) + 2 * 2 * groups_per_k * K.shape[1]
ratio = bytes_fp16 / bytes_2bit
print(f"fp16 K bytes = {bytes_fp16 / 1024:.1f} KiB")
print(f"2-bit K bytes = {bytes_2bit / 1024:.1f} KiB ({ratio:.1f}x compression)")
s.check(
"kivi_compression_at_least_4x",
lambda: ratio >= 4.0,
msg=f"{ratio:.2f}x compression",
)
Attention quality vs bits-per-value#
Sweep K/V precision from fp16 down to 2 bits and plot the relative error of the attention output. Error climbs slowly through 8 and 4 bits (groupwise scaling absorbs most of the loss), then jumps at 2 bits - that cliff is where KIVI’s per-channel K / per-token V split earns its keep.
import matplotlib.pyplot as plt
bit_widths = [16, 8, 4, 3, 2]
rel_errs = []
for bits in bit_widths:
if bits >= 16:
rel_errs.append(0.0)
continue
K_b = quantize_grouped(K, axis=0, bits=bits, group=32)
V_b = quantize_grouped(V, axis=1, bits=bits, group=32)
Y_b = attn(Q, K_b, V_b)
rel_errs.append(((Y_ref - Y_b).norm() / Y_ref.norm()).item())
fig, ax = plt.subplots(figsize=(6.8, 4.0))
ax.plot(bit_widths, rel_errs, "o-", color="tab:red", lw=2, markersize=9)
for bits, err in zip(bit_widths, rel_errs, strict=True):
ax.annotate(f"{err:.2%}" if err > 0 else "0",
(bits, err), xytext=(6, 6), textcoords="offset points", fontsize=9)
ax.set_xscale("log", base=2)
ax.set_xticks(bit_widths)
ax.set_xticklabels([str(b) for b in bit_widths])
ax.invert_xaxis()
ax.set_xlabel("bits per value (log scale, fp16 on the left)")
ax.set_ylabel("attention output relative error")
ax.set_title("quality cliff appears below 4 bits on plain RTN KV quantisation")
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
Exercises#
Sweep bit width. 1, 2, 3, 4 bits. Measure quality vs compression; the quality cliff usually appears below 2 bits for V and below 3 for K.
Outlier-aware. Keep the top-1% of K channels at fp16 and quantise the rest. KIVI-style paper work shows this often recovers the quality gap vs uniform 2-bit.
Real model. Swap the synthetic K, V for a captured decode-step pair from SmolLM2-135M. The asymmetry between K-outlier channels and V-smoothness is more visible on real activations.
References#
KIVI paper §3 for the per-channel/per-token asymmetry argument.
Also: KVQuant for a related 4-bit approach.
s.summary()
s.save()