KV Cache Compression › Combining Techniques

Michael BrenndoerferJanuary 9, 20267 min read
Language AI HandbookAll chapters
Part 42 of 67Inference Optimization
Chapter 316KV Cache Compression › Combining Techniques
Reading section 8 of 13 · Combining Techniques · 7 minSection 8 of 13 · 7 min

Book contents

Section 8 of 13 · 7 min read

KV Cache Compression › Combining TechniquesOne section · Sections in this chapter8 / 13 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

The most effective KV cache compression strategies combine multiple approaches to achieve multiplicative benefits. Rather than choosing between eviction and quantization, we can apply both: first reduce the number of cached tokens through intelligent eviction, then store the remaining tokens at reduced precision through quantization. This layered approach addresses memory from two complementary angles, and the benefits multiply rather than merely add.

The combination works so well because the two techniques address fundamentally different aspects of the memory problem. Eviction reduces the number of tokens we need to store, which scales with sequence length. Quantization reduces the bytes needed per token, which scales with the vector dimensions and precision. These are orthogonal dimensions of the memory tensor, and reducing both independently provides multiplicative compression. A 16x reduction in token count combined with a 2x reduction in bytes per token gives 32x total compression.

A practical configuration might use the following combination:

  1. Attention sink preservation: Keep the first 4 tokens unconditionally to maintain computational stability.
  2. H2O-style eviction: Maintain heavy hitters plus recent tokens to preserve both semantic importance and local context.
  3. INT8 quantization: Store all cached values at 8-bit precision to halve the per-token memory footprint.
In[26]:
Code
import torch


class CompressedKVCache:
    """
    Combined compression: sink preservation + H2O eviction + quantization.
    """

    def __init__(
        self,
        sink_size=4,
        recent_size=64,
        heavy_hitter_size=64,
        num_heads=32,
        head_dim=128,
        quant_bits=8,
    ):
        self.sink_size = sink_size
        self.recent_size = recent_size
        self.heavy_hitter_size = heavy_hitter_size
        self.num_heads = num_heads
        self.head_dim = head_dim

        # Quantizer for storage
        self.quantizer = PerChannelQuantizedCache(bits=quant_bits)

        # Runtime state (full precision for computation)
        self.keys = None
        self.values = None
        self.cumulative_attention = None
        self.positions = None

    @property
    def max_cache_size(self):
        return self.sink_size + self.recent_size + self.heavy_hitter_size

    def update(self, new_key, new_value, attention_weights=None):
        """Add new token with combined compression strategy."""
        if self.keys is None:
            self.keys = new_key
            self.values = new_value
            self.cumulative_attention = torch.zeros(new_key.size(0), 1)
            self.positions = torch.tensor([[0]])
            return

        # Update attention scores
        if attention_weights is not None:
            attn_update = attention_weights.mean(dim=1).squeeze(-2)
            self.cumulative_attention = self.cumulative_attention + attn_update

        # Add new token
        new_pos = self.positions.max() + 1
        self.keys = torch.cat([self.keys, new_key], dim=2)
        self.values = torch.cat([self.values, new_value], dim=2)
        self.cumulative_attention = torch.cat(
            [self.cumulative_attention, torch.zeros(new_key.size(0), 1)], dim=1
        )
        self.positions = torch.cat(
            [self.positions, torch.tensor([[new_pos]])], dim=1
        )

        # Apply eviction if needed
        if self.keys.size(2) > self.max_cache_size:
            self._evict()

    def _evict(self):
        """Evict using sink + H2O strategy."""
        current_size = self.keys.size(2)
        positions = self.positions[0]

        # 1. Always keep sink tokens (first sink_size positions)
        sink_mask = positions < self.sink_size

        # 2. Keep recent tokens
        non_sink_positions = positions.clone()
        non_sink_positions[sink_mask] = -1
        _, recent_indices = non_sink_positions.topk(self.recent_size)
        recent_mask = torch.zeros(current_size, dtype=torch.bool)
        recent_mask[recent_indices] = True

        # 3. From remaining, keep heavy hitters
        remaining_mask = ~(sink_mask | recent_mask)
        remaining_attention = self.cumulative_attention[0].clone()
        remaining_attention[~remaining_mask] = float("-inf")

        n_heavy = min(self.heavy_hitter_size, remaining_mask.sum().item())
        if n_heavy > 0:
            _, heavy_indices = remaining_attention.topk(n_heavy)
            heavy_mask = torch.zeros(current_size, dtype=torch.bool)
            heavy_mask[heavy_indices] = True
        else:
            heavy_mask = torch.zeros(current_size, dtype=torch.bool)

        # Combine masks
        keep_mask = sink_mask | recent_mask | heavy_mask

        # Apply eviction
        self.keys = self.keys[:, :, keep_mask, :]
        self.values = self.values[:, :, keep_mask, :]
        self.cumulative_attention = self.cumulative_attention[:, keep_mask]
        self.positions = self.positions[:, keep_mask]

    def get_quantized_state(self):
        """Return quantized cache state for storage."""
        self.quantizer.store(self.keys, self.values)
        return (
            self.quantizer.keys_quantized,
            self.quantizer.values_quantized,
            self.quantizer.key_params,
            self.quantizer.value_params,
        )

    def memory_stats(self):
        """Report memory usage statistics."""
        if self.keys is None:
            return {}

        batch, heads, seq_len, head_dim = self.keys.shape

        # Full precision (FP16): 2 bytes per element
        full_precision_bytes = (
            batch * heads * seq_len * head_dim * 2 * 2
        )  # keys + values

        # Quantized: bits/8 bytes per element
        quantized_bytes = (
            batch * heads * seq_len * head_dim * 2 * self.quantizer.bits / 8
        )

        return {
            "tokens_cached": seq_len,
            "max_cache_size": self.max_cache_size,
            "full_precision_mb": full_precision_bytes / (1024 * 1024),
            "quantized_mb": quantized_bytes / (1024 * 1024),
            "compression_ratio": full_precision_bytes / quantized_bytes,
        }
In[27]:
Code
import torch


def benchmark_compressed_cache(num_tokens):
    """Simulate long sequence generation with compressed cache."""
    cache = CompressedKVCache(
        sink_size=4,
        recent_size=32,
        heavy_hitter_size=28,  # Total: 4 + 32 + 28 = 64 tokens
        num_heads=32,
        head_dim=128,
        quant_bits=8,
    )

    # Simulate generation
    batch_size = 1
    for t in range(num_tokens):
        new_key = torch.randn(batch_size, 32, 1, 128)
        new_value = torch.randn(batch_size, 32, 1, 128)

        # Simulate attention weights
        if cache.keys is not None:
            current_size = cache.keys.size(2)
            attn = torch.softmax(
                torch.randn(batch_size, 32, 1, current_size), dim=-1
            )
        else:
            attn = None

        cache.update(new_key, new_value, attn)

    return cache
In[28]:
Code
num_tokens = 1000
cache = benchmark_compressed_cache(num_tokens)
stats = cache.memory_stats()

# Calculate what uncompressed would be
uncompressed_bytes = 1 * 32 * num_tokens * 128 * 2 * 2  # KV, FP16
uncompressed_mb = uncompressed_bytes / (1024 * 1024)

overall_compression = uncompressed_mb / stats["quantized_mb"]
Out[29]:
Console
Compressed KV Cache Results (1000 tokens generated):
  Without compression: 15.6 MB (1000 tokens)
  With eviction only:  1.0 MB (64 tokens)
  With eviction + INT8: 0.5 MB

  Overall compression: 31.2x

The combined approach achieves dramatic memory reduction through the multiplication of two independent compression factors. Eviction reduces the token count from 1000 to 64. This provides approximately 15.6x compression. INT8 quantization then provides an additional 2x reduction by halving the bytes per token. The total compression ratio exceeds 30x, transforming what would require large memory into a compact representation that fits easily in limited GPU memory.

Out[30]:
Visualization
Stacked bar chart showing memory reduction at each compression stage from uncompressed to eviction to quantization.
Combined compression pipeline showing how eviction and quantization contribute multiplicatively to overall memory reduction. Starting from the full uncompressed cache, eviction alone (keeping 64 of 1000 tokens) achieves over 15x reduction, while adding INT8 quantization doubles the reduction to over 30x total. The three-stage progression illustrates how orthogonal compression techniques stack multiplicatively.

This level of compression enables generation at context lengths that would otherwise exceed available memory. A configuration that might require 16GB of KV cache memory without compression can operate within 500MB using these combined techniques. For deployment scenarios where memory is constrained or costs must be minimized, such aggressive compression makes previously infeasible applications practical. Production systems serving thousands of concurrent users on shared GPU hardware routinely rely on these techniques to make the economics viable.