KV Cache Compression: Eviction, Quantization & H2O Algorithm

Michael BrenndoerferJanuary 9, 202656 min read

Part of Language AI Handbook

Covers KV cache compression techniques including eviction strategies, attention sinks, the H2O algorithm, and INT8 quantization for efficient LLM inference.

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

KV Cache Compression

As we explored in the previous chapters on KV cache fundamentals and memory analysis, the cache grows linearly with sequence length and can consume tens of gigabytes for long contexts. A model generating a 32,768-token response might accumulate several gigabytes of cached key and value tensors across all layers and attention heads. Paged attention addresses memory fragmentation and allocation overhead, but the total memory footprint of the cache itself remains substantial. KV cache compression tackles this problem directly by reducing the amount of data stored, either through selective eviction of less important cache entries or by quantizing the cached values to lower numerical precision.

The key insight driving compression research is that not all cached tokens contribute equally to generation quality. Attention patterns in transformers are highly non-uniform: certain tokens receive concentrated attention while others are nearly ignored across hundreds of generation steps. This sparsity creates an opportunity. If we can identify and preserve the high-value cache entries while discarding the rest, we can dramatically reduce memory usage with minimal impact on output quality. The central challenge is doing this identification accurately and efficiently, without expensive per-step computation that would offset the memory savings.

Think of the KV cache as a research library. When a scholar writes a paper, they do not continuously consult every book they have ever read. Instead, they repeatedly return to a small set of highly relevant sources, occasionally glance at recent acquisitions, and largely ignore the large middle ground of books that happened to be present on the shelves. A librarian who understood the scholar's research patterns could remove most of those rarely-consulted books without affecting the quality of the scholar's work. KV cache compression is the process of being that librarian: identifying which cached tokens are the books our model keeps returning to and discarding the rest.

This chapter covers the three main approaches to KV cache compression: eviction-based strategies that remove entire token representations from the cache, attention sink preservation that protects necessary initial tokens from eviction, and cache quantization that reduces the numerical precision of stored values. We examine the H2O (Heavy-Hitter Oracle) algorithm in detail as a representative eviction method, then explore how these techniques combine for maximum compression. We also analyze the quality implications of each approach and discuss the practical trade-offs involved in deploying compressed caches in production.

Understanding these techniques requires familiarity with attention mechanics from Part XIII and the KV cache fundamentals covered earlier in this inference optimization section. We will also build on the attention sink discussion from Part XVIII to explain why naive eviction strategies fail and how the research community solved this problem. By the end of this chapter, you will have a thorough understanding of how to reduce KV cache memory by more than 30x while preserving generation quality for the large majority of practical use cases.

Historical Context

KV cache compression emerged as a practical research area around 2023, when the deployment of large language models at scale made memory efficiency a pressing concern. The StreamingLLM paper (Xiao et al., 2023) formalized the concept of attention sinks and introduced the sink-plus-window eviction strategy. This enables to long sequences long generation within bounded memory. The H2O paper (Zhang et al., 2023) appeared around the same time. This provides the heavy-hitter oracle formulation and showing that tracking cumulative attention significantly outperforms pure recency-based eviction. Cache quantization has deeper roots in the general model quantization literature, with GPTQ (Frantar et al., 2022) and similar works motivating the application of post-training quantization to activation caches rather than just weights. These concurrent developments reflect how memory pressure became the dominant constraint for LLM deployment at the frontier, shifting research focus from pure accuracy improvements to efficiency without quality sacrifice.

The Compression Opportunity

Before diving into specific techniques, we need to understand why compression is feasible in the first place. The basic question is this: can we discard information from the KV cache without significantly harming generation quality? To answer this, consider the attention patterns in a typical language model generating the 500th token. The query for this new token computes attention scores against all 499 previous key vectors. In a perfectly uniform distribution, each previous token would receive 1/499≈0.2%1/499 \approx 0.2\% of the attention. In practice, attention is far from uniform, and this non-uniformity is precisely what makes compression possible.

The magnitude of this non-uniformity is striking. Empirical studies of transformer attention patterns consistently find that a small fraction of tokens, often 10-20%, account for 80-90% of the total attention weight. The remaining 80-90% of tokens share only 10-20% of the attention budget. This is reminiscent of the Pareto principle in economics: a small minority of inputs drives the majority of the output. For KV caches, this means that a compressed cache containing only the high-attention minority of tokens can approximate the full attention computation remarkably well, as long as we correctly identify which tokens belong to that necessary minority.

Studies of transformer attention patterns reveal several consistent phenomena that explain why some tokens matter far more than others:

  • Local focus: Recent tokens often receive disproportionate attention, especially in lower layers. This makes intuitive sense because language exhibits strong local coherence, with adjacent words being grammatically and semantically related.
  • Anchor tokens: Semantically important tokens such as subjects and verbs, along with tokens naming key entities, attract attention regardless of distance. These tokens carry the core meaning of a passage and remain relevant throughout generation.
  • Attention sinks: As we covered in Part XVIII, initial tokens accumulate attention even when semantically irrelevant. This curious phenomenon appears to serve a computational purpose rather than a semantic one.
  • Sparse activation: Many tokens receive near-zero attention and contribute minimally to the output. These tokens are prime candidates for removal without significant quality degradation.
Out[3]:
Visualization
Bar chart showing attention weights across token positions with high weights at beginning, end, and two intermediate positions.
Simulated attention distribution showing characteristic non-uniformity. Initial tokens act as attention sinks, recent tokens receive local focus, and specific semantic positions (15, 28) act as anchor tokens. Many intermediate tokens receive near-zero attention, showing the compression opportunity and the basis for selective eviction strategies.

This non-uniformity means that a cache storing only the high-attention tokens might preserve most of the information needed for accurate generation. The attention distribution is not merely slightly skewed; it is often dramatically concentrated on a small subset of tokens. The challenge lies in identifying these important tokens efficiently, without requiring expensive computation at each generation step. We need methods that can predict which tokens will be important for future generation steps, not just which tokens have been important in the past. As we will see, historical attention patterns turn out to be a surprisingly reliable predictor of future importance, at least for the tokens that truly matter.

The Key Insight

The feasibility of KV cache compression rests on a simple empirical observation: transformer attention is highly concentrated on a small subset of tokens, and this concentration is consistent across generation steps. A token that has received high attention in the past is very likely to receive high attention in the future. This temporal consistency of attention patterns is what makes eviction-based compression viable. If attention patterns were random, no eviction strategy could work reliably. The fact that they are structured and persistent is what turns compression from a theoretical possibility into a practical technique.

Window-Based Eviction

The simplest eviction strategy maintains a fixed-size sliding window, keeping only the most recent kk tokens in the cache. This approach has clear appeal for several reasons. It is trivial to implement, requires no analysis of attention patterns, and guarantees constant memory usage regardless of sequence length. The underlying assumption is that recent context is the most relevant context, which holds true for many language modeling scenarios. The local attention patterns we observed earlier provide empirical support for this assumption: recent tokens reliably attract a significant portion of the attention budget.

Window-based eviction is analogous to a student who only reads the last paragraph they wrote before continuing. For tasks with strong local dependencies, this works well. For tasks requiring integration of information from across the entire document, it fails catastrophically. Understanding exactly where this boundary lies is important for knowing when to use window-based approaches and when to adopt more sophisticated strategies.

The implementation is straightforward: we simply maintain a buffer of the last kk key-value pairs and discard older entries as new tokens are generated. No attention tracking, no score computation, and no complex data structures are required. This simplicity makes it attractive for latency-sensitive deployment scenarios where the overhead of more sophisticated methods would be unacceptable.

In[4]:
Code
def sliding_window_cache(keys, values, window_size):
    """
    Maintains only the most recent window_size tokens in cache.

    Args:
        keys: (batch, heads, seq_len, head_dim)
        values: (batch, heads, seq_len, head_dim)
        window_size: Number of recent tokens to keep

    Returns:
        Truncated keys and values
    """
    seq_len = keys.size(2)

    if seq_len <= window_size:
        return keys, values

    # Keep only the last window_size tokens
    return keys[:, :, -window_size:, :], values[:, :, -window_size:, :]
In[5]:
Code
import torch

# Demonstrate the sliding window
batch_size, num_heads, seq_len, head_dim = 1, 8, 100, 64
keys = torch.randn(batch_size, num_heads, seq_len, head_dim)
values = torch.randn(batch_size, num_heads, seq_len, head_dim)

window_size = 32
k_truncated, v_truncated = sliding_window_cache(keys, values, window_size)
memory_reduction_pct = (1 - window_size / seq_len) * 100
Out[6]:
Console
Original cache: 100 tokens
Window size: 32
Truncated cache: 32 tokens
Memory reduction: 68.0%

Truncating to 32 tokens reduces cache memory by 68% in this example. The implementation is straightforward: we simply slice the tensor to keep only the final positions. However, this simplicity comes with a basic limitation: the approach discards all context beyond the window, including potentially important information from the beginning of the sequence.

Consider the practical implications through a concrete example. Imagine generating a response to a question. The question might appear at token positions 0-50, followed by supporting context at positions 51-400, with generation starting at position 401. A sliding window of 100 tokens would retain only tokens 301-400, losing the original question entirely. The model would have no memory of what it was asked, potentially creating irrelevant or incoherent responses. This illustrates why pure window-based approaches often fail for tasks requiring long-range dependencies. The approach works well for streaming applications or local language modeling, but breaks down when the task requires referencing distant context.

Attention-Based Eviction

A more sophisticated approach selects tokens based on their historical attention scores rather than their recency. The intuition behind this method is straightforward: tokens that have received high attention in the past are likely to be important for future generation. These tokens have already proven their value, so preserving them makes sense. By tracking cumulative attention and evicting low-attention tokens, we can maintain a compact cache of the most relevant context while adapting to the specific content being processed.

Think of attention-based eviction as a hotel concierge who tracks which guests request the most services. Guests who never ring the service bell can safely be checked out early to free up rooms. Guests who constantly request assistance clearly need to stay. The analogy breaks down slightly because we cannot know in advance which guests will become high-demand, but historical patterns give us a reliable signal.

The key insight here is that attention scores provide a signal of token importance. When the model attends heavily to a particular token, it extracts information from that token to inform its output. Tokens that consistently receive high attention across multiple generation steps are carrying information that the model repeatedly needs. These "frequently consulted" tokens are prime candidates for preservation, while tokens that receive negligible attention can likely be removed without consequence. The cumulative nature of this tracking is important: a token that receives a small amount of attention at each of 100 steps has accumulated substantial importance, even if no single step stands out as particularly high.

In[7]:
Code
import torch


class AttentionBasedCache:
    """
    Maintains a cache of fixed size by evicting tokens with lowest
    cumulative attention scores.
    """

    def __init__(self, max_cache_size, num_heads, head_dim):
        self.max_cache_size = max_cache_size
        self.num_heads = num_heads
        self.head_dim = head_dim

        # Initialize empty cache
        self.keys = None  # (batch, heads, cache_size, head_dim)
        self.values = None
        self.attention_scores = None  # Cumulative attention per token

    def update(self, new_key, new_value, attention_weights):
        """
        Add new token to cache and evict if necessary.

        Args:
            new_key: (batch, heads, 1, head_dim)
            new_value: (batch, heads, 1, head_dim)
            attention_weights: (batch, heads, 1, seq_len) - attention to all cached tokens
        """
        batch_size = new_key.size(0)

        if self.keys is None:
            # First token - initialize cache
            self.keys = new_key
            self.values = new_value
            # Initialize with the attention this token receives (from itself)
            self.attention_scores = attention_weights.mean(dim=1).squeeze(
                -2
            )  # (batch, 1)
            return

        # Append new token
        self.keys = torch.cat([self.keys, new_key], dim=2)
        self.values = torch.cat([self.values, new_value], dim=2)

        # Update cumulative attention scores for existing tokens
        # Average attention across heads, then add to cumulative scores
        current_attention = attention_weights.mean(dim=1).squeeze(
            -2
        )  # (batch, seq_len)

        # Existing tokens get their attention updated (all but last position)
        old_scores = self.attention_scores + current_attention[:, :-1]
        # New token starts with its self-attention score
        new_score = current_attention[:, -1:]

        self.attention_scores = torch.cat([old_scores, new_score], dim=1)

        # Evict if over capacity
        if self.keys.size(2) > self.max_cache_size:
            self._evict_lowest_attention()

    def _evict_lowest_attention(self):
        """Remove the token with lowest cumulative attention."""
        # Find index of minimum attention score
        _, min_idx = self.attention_scores.min(dim=1)

        batch_size = self.keys.size(0)

        # Create mask for tokens to keep (all except min_idx)
        keep_mask = torch.ones(batch_size, self.keys.size(2), dtype=torch.bool)
        for b in range(batch_size):
            keep_mask[b, min_idx[b]] = False

        # Apply mask
        # For simplicity, we'll handle batch_size=1 case
        self.keys = self.keys[:, :, keep_mask[0], :]
        self.values = self.values[:, :, keep_mask[0], :]
        self.attention_scores = self.attention_scores[:, keep_mask[0]]

This approach adapts dynamically to the content being processed. Important tokens accumulate high attention scores over time and remain in the cache, while less relevant tokens get evicted as their scores remain low. The method naturally identifies semantically meaningful tokens like subjects, key entities, and important verbs that the model needs to reference repeatedly. Unlike the sliding window approach, attention-based eviction can retain a token from position 5 while evicting tokens from positions 50 through 200 if those middle tokens have not been useful.

However, the approach introduces computational overhead for tracking scores and, more critically, has a basic flaw that we must address. The flaw relates to a peculiar attention pattern we covered earlier: attention sinks. We will explore this issue and its solution in the next section.

Attention Sink Preservation

As we discussed in Part XVIII, Chapter 5 on attention sinks, transformers exhibit a curious behavior that complicates attention-based eviction: initial tokens in a sequence accumulate disproportionate attention even when they carry no semantic importance. These "attention sinks" appear to act as computational anchors. This provides a stable target for attention heads to discharge probability mass when no relevant context exists. The phenomenon emerges from training dynamics: the model learns to use early tokens as a default attention target. This creates a dependency that persists even when those tokens are semantically meaningless.

To understand why this creates a problem for eviction, consider what happens during long generation. Attention-based eviction is supposed to preserve high-attention tokens because they carry important information. But initial tokens receive high attention for a completely different reason: they serve as a computational escape valve. If we sort all tokens by cumulative attention and discard the lowest-scoring ones, we will correctly identify many low-importance middle tokens for eviction. But we might also accidentally evict tokens that had middling cumulative attention scores while the true attention sinks at positions 0-3 consume a large fraction of the attention budget and dominate the preservation list.

When we evict tokens based purely on historical attention scores, we risk removing tokens that should stay while incorrectly preserving tokens that are sinks rather than semantically important. Without attention sinks available, models may produce incoherent outputs, fall into repetition loops, or exhibit generally degraded quality. The attention mechanism loses its computational anchor, disrupting the learned patterns that depend on its presence. This is not a minor quality degradation; for some models and tasks, removing attention sinks completely breaks generation.

This creates a paradox for attention-based eviction strategies. The tokens receiving highest attention (the sinks) may not be semantically important, while truly important content tokens may receive only moderate attention because they share the attention budget with the sinks. Pure attention-score-based eviction conflates two distinct sources of high attention: semantic importance and computational anchoring. These require different treatment in a principled eviction policy.

The solution is to protect initial tokens from eviction regardless of their attention scores. By unconditionally preserving the first few tokens, we ensure the attention sinks remain available while still allowing the eviction policy to remove low-importance tokens from the rest of the sequence. This separation lets the score-based eviction logic focus on semantic importance without interference from the sink phenomenon.

In[8]:
Code
import torch


class StreamingLLMCache:
    """
    Implements the StreamingLLM approach: preserve initial sink tokens
    plus a sliding window of recent tokens.

    Reference: "Efficient Streaming Language Models with Attention Sinks"
    """

    def __init__(self, sink_size, window_size, num_heads, head_dim):
        self.sink_size = sink_size  # Number of initial tokens to always keep
        self.window_size = window_size  # Number of recent tokens to keep
        self.num_heads = num_heads
        self.head_dim = head_dim

        self.keys = None
        self.values = None
        self.total_tokens_seen = 0

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

    def update(self, new_key, new_value):
        """
        Add new token, maintaining sink tokens and recent window.

        Args:
            new_key: (batch, heads, 1, head_dim)
            new_value: (batch, heads, 1, head_dim)
        """
        self.total_tokens_seen += 1

        if self.keys is None:
            self.keys = new_key
            self.values = new_value
            return

        # Append new token
        self.keys = torch.cat([self.keys, new_key], dim=2)
        self.values = torch.cat([self.values, new_value], dim=2)

        current_size = self.keys.size(2)

        # If we exceed capacity, keep sinks + recent window
        if current_size > self.max_cache_size:
            # Keep first sink_size tokens and last window_size tokens
            sink_keys = self.keys[:, :, : self.sink_size, :]
            sink_values = self.values[:, :, : self.sink_size, :]

            window_keys = self.keys[:, :, -self.window_size :, :]
            window_values = self.values[:, :, -self.window_size :, :]

            self.keys = torch.cat([sink_keys, window_keys], dim=2)
            self.values = torch.cat([sink_values, window_values], dim=2)

    def get_cache(self):
        """Return current key-value cache."""
        return self.keys, self.values
In[9]:
Code
import torch


# Demonstrate StreamingLLM cache behavior
def simulate_generation(cache, num_tokens):
    """Simulate generating num_tokens and track cache state."""
    batch_size = 1

    history = []
    for i in range(num_tokens):
        # Simulate a new key-value pair
        new_key = torch.randn(batch_size, cache.num_heads, 1, cache.head_dim)
        new_value = torch.randn(batch_size, cache.num_heads, 1, cache.head_dim)

        cache.update(new_key, new_value)

        if cache.keys is not None:
            history.append(cache.keys.size(2))

    return history
In[10]:
Code
cache = StreamingLLMCache(sink_size=4, window_size=32, num_heads=8, head_dim=64)
history = simulate_generation(cache, 100)
Out[11]:
Console
Configuration: 4 sink tokens + 32 window
Maximum cache size: 36 tokens

Cache size over generation:
  After 10 tokens: 10
  After 36 tokens: 36
  After 50 tokens: 36
  After 100 tokens: 36

The StreamingLLM approach maintains exactly sink_size + window_size tokens once the cache is full. This provides predictable memory usage while preserving the necessary attention sinks. The cache size grows linearly during the initial phase, then plateaus at the maximum size and remains constant regardless of how many additional tokens are generated. This bounded memory behavior enables to long sequences long streaming generation without memory growth, which is the defining property that gives this approach its name.

Out[12]:
Visualization
Line plot comparing cache sizes over generation steps for different eviction strategies.
Cache size comparison between StreamingLLM and sliding window approaches over 100 generated tokens. StreamingLLM (sink + window) reaches a fixed maximum of 36 tokens and stays there, letting unbounded generation. The shaded region at the bottom shows the 4 sink tokens that are always preserved. A sliding window plateaus earlier but discards all initial context, while an unbounded cache grows without limit.

Research has shown that just 4 sink tokens are typically sufficient to maintain generation quality, making this a highly efficient solution. The small sink budget adds minimal memory overhead while giving the computational anchoring the model requires. Combined with a modest window size, the approach achieves substantial compression while maintaining coherent generation for streaming applications. The trade-off is that middle context (between the sinks and the recent window) is entirely lost, which can degrade quality on tasks requiring long-range reasoning.

The H2O Algorithm

Heavy-Hitter Oracle (H2O) represents a more sophisticated eviction policy that extends beyond simple recency or sink preservation. The algorithm combines attention-based selection with sink preservation, addressing the limitations of both pure window-based and pure attention-based approaches. The key insight motivating H2O is that tokens exhibit different temporal importance patterns. Some tokens are "heavy hitters," consistently receiving high attention across many generation steps because they carry information the model repeatedly needs. Other tokens are transient, relevant only briefly before becoming obsolete as generation proceeds.

The name "oracle" is somewhat aspirational: a true oracle would know with certainty which future tokens would be needed and preserve exactly those. H2O approximates this oracle by using historical attention patterns as a proxy for future importance. This approximation works well in practice because semantic importance tends to be persistent. A sentence's subject noun remains important throughout the sentences that follow. A key fact mentioned in the introduction remains relevant when the conclusion references it. The oracle approximation is not perfect, but it is far better than ignoring historical patterns entirely.

Consider how these patterns manifest in practice. A subject noun at the beginning of a paragraph might be a heavy hitter, receiving attention throughout the paragraph as the model maintains coherence. A transition word, by contrast, might receive attention only from the immediately following tokens before becoming irrelevant. H2O captures this distinction by tracking cumulative attention over time and using it to identify tokens worthy of long-term preservation. The algorithm maintains two complementary categories of preserved tokens:

  1. Recent tokens: A sliding window of the most recent krecentk_{\text{recent}} tokens. These provide immediate local context and ensure the model can maintain coherent generation based on what was just produced.
  2. Heavy hitters: Tokens with the highest cumulative attention scores, capped at kheavyk_{\text{heavy}} tokens. These represent semantically important content that the model has repeatedly consulted, suggesting continued relevance.

The algorithm dynamically balances these sets throughout generation. This keeps both recency and historical importance are considered when making eviction decisions. Tokens can transition between categories: a recent token that accumulates high attention becomes a heavy hitter candidate, while a former heavy hitter that stops receiving attention may eventually be evicted as new heavy hitters emerge. This fluidity is a key advantage over static approaches that fix which tokens to preserve at the start of generation.

In[13]:
Code
import torch


class H2OCache:
    """
    Heavy-Hitter Oracle (H2O) KV cache compression.

    Maintains recent tokens plus heavy-hitters (tokens with highest
    cumulative attention scores).

    Reference: "H2O: Heavy-Hitter Oracle for Efficient Generative Inference"
    """

    def __init__(self, recent_size, heavy_hitter_size, num_heads, head_dim):
        self.recent_size = recent_size
        self.heavy_hitter_size = heavy_hitter_size
        self.num_heads = num_heads
        self.head_dim = head_dim

        # Cache storage
        self.keys = None  # (batch, heads, cache_size, head_dim)
        self.values = None
        self.cumulative_attention = None  # (batch, cache_size)
        self.token_positions = None  # Original position of each token

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

    def update(self, new_key, new_value, attention_weights):
        """
        Add new token and update heavy-hitter statistics.

        Args:
            new_key: (batch, heads, 1, head_dim)
            new_value: (batch, heads, 1, head_dim)
            attention_weights: (batch, heads, 1, current_cache_size)
        """
        batch_size = new_key.size(0)

        if self.keys is None:
            # Initialize with first token
            self.keys = new_key
            self.values = new_value
            self.cumulative_attention = torch.zeros(batch_size, 1)
            self.token_positions = torch.tensor([[0]])
            return

        # Update cumulative attention for existing tokens
        # Average across heads for simplicity
        attn_update = attention_weights.mean(dim=1).squeeze(
            -2
        )  # (batch, cache_size)
        self.cumulative_attention = self.cumulative_attention + attn_update

        # Add new token
        new_position = self.token_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(batch_size, 1)], dim=1
        )
        self.token_positions = torch.cat(
            [self.token_positions, torch.tensor([[new_position]])], dim=1
        )

        # Evict if necessary
        current_size = self.keys.size(2)
        if current_size > self.max_cache_size:
            self._evict()

    def _evict(self):
        """
        Evict tokens to maintain cache size.
        Keep: recent_size most recent + heavy_hitter_size highest attention
        """
        batch_size = self.keys.size(0)
        current_size = self.keys.size(2)

        # Identify recent tokens (by position)
        positions = self.token_positions[0]  # (cache_size,)
        _, recent_indices = positions.topk(self.recent_size)
        recent_mask = torch.zeros(current_size, dtype=torch.bool)
        recent_mask[recent_indices] = True

        # Among non-recent tokens, find heavy hitters
        non_recent_mask = ~recent_mask
        non_recent_attention = self.cumulative_attention[0].clone()
        non_recent_attention[recent_mask] = float("-inf")  # Exclude recent

        # Get top heavy hitters from non-recent tokens
        _, heavy_hitter_indices = non_recent_attention.topk(
            min(self.heavy_hitter_size, non_recent_mask.sum())
        )
        heavy_hitter_mask = torch.zeros(current_size, dtype=torch.bool)
        heavy_hitter_mask[heavy_hitter_indices] = True

        # Keep tokens that are either recent or heavy hitters
        keep_mask = recent_mask | heavy_hitter_mask

        # Apply mask
        self.keys = self.keys[:, :, keep_mask, :]
        self.values = self.values[:, :, keep_mask, :]
        self.cumulative_attention = self.cumulative_attention[:, keep_mask]
        self.token_positions = self.token_positions[:, keep_mask]
In[14]:
Code
import torch
import torch.nn.functional as F


def demonstrate_h2o(heavy_hitter_positions):
    """Show H2O cache behavior with varying attention patterns."""
    batch_size = 1
    num_heads = 8
    head_dim = 64

    cache = H2OCache(
        recent_size=8,
        heavy_hitter_size=8,
        num_heads=num_heads,
        head_dim=head_dim,
    )

    # Simulate 50 tokens with varying attention patterns
    for t in range(50):
        new_key = torch.randn(batch_size, num_heads, 1, head_dim)
        new_value = torch.randn(batch_size, num_heads, 1, head_dim)

        if cache.keys is not None:
            current_size = cache.keys.size(2)
            # Create attention weights
            attn = torch.rand(batch_size, num_heads, 1, current_size)

            # Give extra attention to heavy hitter positions if they're in cache
            for pos_idx, pos in enumerate(cache.token_positions[0].tolist()):
                if pos in heavy_hitter_positions:
                    attn[:, :, :, pos_idx] += 2.0  # Boost attention

            # Normalize
            attn = F.softmax(attn, dim=-1)
            cache.update(new_key, new_value, attn)
        else:
            cache.update(new_key, new_value, None)

    return cache
In[15]:
Code
heavy_hitter_positions = {5, 10}
cache = demonstrate_h2o(heavy_hitter_positions)
Out[16]:
Console
After 50 tokens:
  Cache size: 16 tokens
  Token positions in cache: [0, 1, 2, 3, 4, 5, 6, 10, 42, 43, 44, 45, 46, 47, 48, 49]

Cumulative attention scores:
  Position  0: 4.059
  Position  1: 3.039
  Position  2: 2.462
  Position  3: 2.214
  Position  4: 1.966
  Position  5: 12.586 (heavy hitter)
  Position  6: 1.736
  Position 10: 10.055 (heavy hitter)
  Position 42: 0.254
  Position 43: 0.213
  Position 44: 0.161
  Position 45: 0.146
  Position 46: 0.120
  Position 47: 0.070
  Position 48: 0.034
  Position 49: 0.000

Notice how positions 5 and 10 are retained in the cache despite not being among the most recent tokens. Their high cumulative attention scores qualify them as heavy hitters, preserving important context while still maintaining recency through the recent token budget. The algorithm has automatically identified these tokens as carrying important information that the model repeatedly needs, and it protects them from eviction even as dozens of newer tokens have been generated.

Out[17]:
Visualization
Bar chart showing cumulative attention scores for retained tokens with heavy hitters and recent tokens color-coded.
H2O cache composition showing token categorization and retention based on cumulative attention. Heavy hitters at positions 5 and 10 are preserved despite distance, while the most recent tokens form a sliding window to maintain local context. Numbers above bars show original token positions. This dual strategy ensures both semantic importance and recency are represented in the bounded cache.

The cumulative attention scores reveal the distinction between heavy hitters and ordinary tokens. Positions 5 and 10 have accumulated substantially higher scores than other retained positions because the simulated attention pattern consistently attended to them. In a real model, these high-attention tokens would typically correspond to semantically important content: the subject of a sentence, a key entity being discussed, or a necessary fact that informs subsequent generation. The visualization makes it clear why H2O outperforms pure sliding windows: important early context is retained automatically, without any manual specification of what to keep.

Worked Example: H2O Eviction Step-by-Step

Let's trace through a concrete numerical example to see exactly how H2O makes eviction decisions. Suppose our cache has recent_size = 2 and heavy_hitter_size = 2, giving a maximum cache size of 4. We have just generated 5 tokens (positions 0-4), so the cache has grown to 5 entries and must evict one.

After 5 generation steps, the cumulative attention scores for the 5 cached tokens are:

  • Position 0: cumulative attention = 3.8 (this is an anchor token, repeatedly consulted)
  • Position 1: cumulative attention = 0.4 (barely consulted)
  • Position 2: cumulative attention = 2.1 (moderately important)
  • Position 3: cumulative attention = 0.3 (barely consulted, just added to window)
  • Position 4: cumulative attention = 0.0 (brand new, no history yet)

The algorithm proceeds as follows. First, identify the recent_size = 2 most recent tokens by position. Positions 3 and 4 are the two most recent, so they enter the recent set unconditionally. Second, among the remaining tokens (positions 0, 1, 2), identify the heavy_hitter_size = 2 tokens with highest cumulative attention. Position 0 has score 3.8 and position 2 has score 2.1, making them the heavy hitters. Position 1 with score 0.4 is the eviction candidate. Third, the final cache contains positions {0, 2, 3, 4}, preserving the anchor token and the recent window while discarding the low-importance middle token at position 1.

This example illustrates the core logic: recency and importance are evaluated independently, and both contribute tokens to the final cache. A token can survive eviction for either reason: being recent or being an established heavy hitter. Only tokens that are neither recent nor historically important get evicted.

Cache Quantization

Orthogonal to eviction strategies, cache quantization reduces memory by storing key and value vectors at lower numerical precision. While model weights typically use FP16 or BF16 during inference, the cached activations can often tolerate even lower precision without significant quality loss. This approach complements eviction: rather than removing tokens entirely, we keep all tokens but represent each one using fewer bits.

Think of quantization as converting high-resolution photographs to compressed JPEGs. The compressed versions are smaller and may have barely perceptible artifacts, but they preserve the needed content. For our purposes, the "needed content" of a cached key vector is its direction in high-dimensional space, not its precise magnitude. Attention computation depends on dot products, which are dominated by directional alignment. Modest rounding errors in the vector components rarely change which tokens receive the highest attention.

The mathematical foundation of quantization is straightforward. We need to map continuous floating-point values to a discrete set of integers that can be stored compactly. The simplest form is uniform quantization, which divides the value range into equally-spaced bins and maps each floating-point value to the nearest bin.

To derive the quantization formula, suppose we have a floating-point value xx drawn from the range [xmin⁡,xmax⁡][x_{\min}, x_{\max}]. We want to represent it as an integer qq in the range [0,2b−1][0, 2^b - 1], where bb is the bit width. The linear mapping that achieves this is:

q=round(x−xmin⁡xmax⁡−xmin⁡⋅(2b−1))q = \text{round}\left(\frac{x - x_{\min}}{x_{\max} - x_{\min}} \cdot (2^b - 1)\right)

where:

  • qq: the resulting quantized integer index, which will be stored in place of the original floating-point value
  • xx: the original floating-point value that we wish to compress
  • xmin⁡x_{\min}: the minimum value in the group being quantized, establishing the lower bound of the value range
  • xmax⁡x_{\max}: the maximum value in the group being quantized, establishing the upper bound of the value range
  • bb: the target bit width (e.g., 8 for INT8 or 4 for INT4), determining how many discrete levels are available
  • 2b−12^b - 1: the total number of discrete quantization levels, which equals the maximum integer value representable with bb bits

This transformation accomplishes its goal through a sequence of intuitive steps. First, it normalizes the original value xx to the range [0,1][0, 1] by computing the fraction x−xmin⁡xmax⁡−xmin⁡\frac{x - x_{\min}}{x_{\max} - x_{\min}}. This fraction represents where xx falls within the overall range of values. Second, it scales this normalized value to the full integer range [0,2b−1][0, 2^b - 1] by multiplication. Finally, it rounds to the nearest integer to obtain a discrete representation that can be stored efficiently. This approach preserves the relative ordering and approximate magnitudes of values while dramatically reducing storage requirements.

When we need to use the cached values for computation, we must reverse this process. Dequantization recovers an approximation of the original value:

x^=q2b−1⋅(xmax⁡−xmin⁡)+xmin⁡\hat{x} = \frac{q}{2^b - 1} \cdot (x_{\max} - x_{\min}) + x_{\min}

where:

  • x^\hat{x}: the reconstructed floating-point value, which approximates the original xx
  • qq: the stored quantized integer retrieved from memory
  • bb: the target bit width used during quantization
  • xmin⁡x_{\min}: the minimum value from the original group, serving as the offset that restores the correct baseline
  • xmax⁡−xmin⁡x_{\max} - x_{\min}: the dynamic range of the original values, used to restore the correct magnitude

The reconstruction process precisely reverses the quantization steps. It first converts the integer qq back to a proportion in [0,1][0, 1] by dividing by the maximum integer value. It then maps that proportion onto the original dynamic range by multiplying by (xmax⁡−xmin⁡)(x_{\max} - x_{\min}). Finally, it adds the offset xmin⁡x_{\min} to restore the correct baseline. The reconstructed value x^\hat{x} will differ from the original xx by at most half a quantization step, introducing a small but bounded error. With 8 bits, this maximum error is approximately xmax⁡−xmin⁡510\frac{x_{\max} - x_{\min}}{510}, which for a typical value range of [−3,3][-3, 3] is about 0.012, far smaller than the typical variation in key vectors.

In[18]:
Code
import torch


class QuantizedCache:
    """
    KV cache with quantized storage.
    Stores keys and values at reduced precision.
    """

    def __init__(self, bits=8):
        self.bits = bits
        self.max_val = 2**bits - 1

        # Quantized storage
        self.keys_quantized = None
        self.values_quantized = None

        # Quantization parameters (per-tensor)
        self.key_min = None
        self.key_scale = None
        self.value_min = None
        self.value_scale = None

    def quantize(self, tensor):
        """Quantize tensor to self.bits precision."""
        t_min = tensor.min()
        t_max = tensor.max()
        scale = (t_max - t_min) / self.max_val

        # Avoid division by zero
        if scale == 0:
            scale = torch.tensor(1.0)

        quantized = torch.round((tensor - t_min) / scale).to(torch.uint8)
        return quantized, t_min, scale

    def dequantize(self, quantized, t_min, scale):
        """Recover approximate float tensor."""
        return quantized.float() * scale + t_min

    def store(self, keys, values):
        """Store key-value pairs in quantized format."""
        self.keys_quantized, self.key_min, self.key_scale = self.quantize(keys)
        self.values_quantized, self.value_min, self.value_scale = self.quantize(
            values
        )

    def retrieve(self):
        """Retrieve dequantized key-value pairs."""
        keys = self.dequantize(
            self.keys_quantized, self.key_min, self.key_scale
        )
        values = self.dequantize(
            self.values_quantized, self.value_min, self.value_scale
        )
        return keys, values

    def memory_usage(self, original_dtype=torch.float16):
        """Compare memory usage."""
        if self.keys_quantized is None:
            return 0, 0

        # Quantized: uint8 = 1 byte per element
        quantized_bytes = (
            self.keys_quantized.numel() + self.values_quantized.numel()
        )

        # Original: 2 bytes for FP16
        original_bytes = (
            self.keys_quantized.numel() + self.values_quantized.numel()
        ) * 2

        return quantized_bytes, original_bytes
In[19]:
Code
import torch.nn.functional as F


def evaluate_quantization_error(keys, values, bits_list=[8, 4, 2]):
    """Measure reconstruction error at different bit widths."""
    results = []

    for bits in bits_list:
        cache = QuantizedCache(bits=bits)
        cache.store(keys, values)

        keys_recovered, values_recovered = cache.retrieve()

        # Compute mean squared error
        key_mse = F.mse_loss(keys_recovered, keys).item()
        value_mse = F.mse_loss(values_recovered, values).item()

        # Compute cosine similarity (important for attention)
        key_cos = F.cosine_similarity(
            keys.flatten(), keys_recovered.flatten(), dim=0
        ).item()

        quant_bytes, orig_bytes = cache.memory_usage()

        results.append(
            {
                "bits": bits,
                "key_mse": key_mse,
                "value_mse": value_mse,
                "key_cosine_sim": key_cos,
                "compression_ratio": orig_bytes / quant_bytes
                if quant_bytes > 0
                else 0,
            }
        )

    return results
In[20]:
Code
import torch

# Test with realistic cache data
batch_size, num_heads, seq_len, head_dim = 1, 32, 512, 128
keys = torch.randn(batch_size, num_heads, seq_len, head_dim)
values = torch.randn(batch_size, num_heads, seq_len, head_dim)

results = evaluate_quantization_error(keys, values, bits_list=[8, 4, 2])
Out[21]:
Console
Quantization Analysis:
-----------------------------------------------------------------
  Bits      Key MSE    Value MSE   Key Cosine  Compression
-----------------------------------------------------------------
     8     0.000121     0.000125       1.0000         2.0x
     4     0.034811     0.036124       0.9833         2.0x
     2     1.031245     1.070498       0.8037         2.0x

INT8 quantization achieves 2x compression with minimal error, maintaining cosine similarity above 0.99. This high cosine similarity is particularly important because attention operates on dot products between queries and keys. The dot product is fundamentally a measure of similarity, and if the quantized keys preserve high cosine similarity with their original values, the resulting attention scores will be nearly identical to what they would have been without quantization. The model will attend to approximately the same tokens in approximately the same proportions, which is exactly what we need for quality preservation.

Out[22]:
Visualization
Histogram of INT8 quantization errors centered tightly around zero, showing minimal reconstruction error.
INT8 quantization error distribution showing tight concentration around zero, confirming high fidelity reconstruction at 2x compression. The narrow spread indicates that individual element errors are very small relative to typical value magnitudes.
Histogram of INT4 quantization errors with moderate spread around zero, showing increased reconstruction error.
INT4 quantization error distribution showing moderate spread around zero, representing the accuracy trade-off at 4x compression. The wider distribution compared to INT8 reflects the reduced number of discrete levels available.
Histogram of INT2 quantization errors with wide spread around zero, showing substantial reconstruction error.
INT2 quantization error distribution showing substantial spread around zero, illustrating severe precision loss at 8x compression. With only 4 discrete levels, large reconstruction errors are unavoidable for most values.

Even INT4 quantization, which provides 4x compression, often preserves sufficient similarity for acceptable generation quality. However, quality degradation becomes noticeable for precision-sensitive tasks at this level. The trade-off between compression and accuracy depends heavily on the specific application: chat applications may tolerate INT4 well, while code generation or mathematical reasoning may require the precision of INT8 or higher.

Per-Channel Quantization

The per-tensor quantization approach we examined computes global statistics across the entire tensor, using a single minimum and scale value for all elements. This approach is simple but can be suboptimal when different attention heads or dimensions have varying value ranges. If one head has values concentrated in a narrow range while another has a wide range, using global statistics forces the narrow-range head to use only a fraction of the available quantization levels, wasting precision.

Think of it this way: if you use a ruler calibrated in meters to measure both the height of a building and the width of a fingernail, you will get very imprecise measurements for the fingernail. Per-channel quantization is like giving each head its own appropriately-scaled ruler.

Per-channel quantization improves accuracy by computing separate scales for each head or channel. Each attention head gets its own minimum and scale values, letting it to use the full range of quantization levels regardless of what other heads are doing. This independence is particularly valuable because different attention heads often specialize in different types of patterns and may exhibit quite different value distributions. A head that specializes in syntactic dependencies may produce keys with very different statistical properties than a head that tracks coreference chains.

The additional overhead of per-channel quantization is modest: instead of storing two scalar parameters (min and scale) per tensor, we store two vectors with one entry per head. For a typical 32-head model, this is 64 additional float values per layer, negligible compared to the tensor itself. The quality improvement, however, can be substantial, particularly at aggressive bit widths where efficient use of the quantization range makes the difference between acceptable and unacceptable quality.

In[23]:
Code
import torch


class PerChannelQuantizedCache:
    """
    KV cache with per-head quantization for better accuracy.
    """

    def __init__(self, bits=8):
        self.bits = bits
        self.max_val = 2**bits - 1

        self.keys_quantized = None
        self.values_quantized = None
        self.key_params = None  # (min, scale) per head
        self.value_params = None

    def quantize_per_head(self, tensor):
        """
        Quantize with separate parameters per attention head.
        tensor shape: (batch, heads, seq_len, head_dim)
        """
        batch_size, num_heads, seq_len, head_dim = tensor.shape

        # Compute min/max per head
        # Reshape to (batch * heads, seq_len * head_dim) for per-head stats
        reshaped = tensor.view(batch_size * num_heads, -1)
        t_min = reshaped.min(dim=1, keepdim=True)[0]
        t_max = reshaped.max(dim=1, keepdim=True)[0]

        scale = (t_max - t_min) / self.max_val
        scale = torch.where(scale == 0, torch.ones_like(scale), scale)

        # Quantize
        quantized = torch.round((reshaped - t_min) / scale)
        quantized = quantized.clamp(0, self.max_val).to(torch.uint8)
        quantized = quantized.view(batch_size, num_heads, seq_len, head_dim)

        return quantized, (t_min, scale)

    def dequantize_per_head(self, quantized, params):
        """Dequantize using per-head parameters."""
        t_min, scale = params
        batch_size, num_heads, seq_len, head_dim = quantized.shape

        reshaped = quantized.view(batch_size * num_heads, -1).float()
        dequantized = reshaped * scale + t_min

        return dequantized.view(batch_size, num_heads, seq_len, head_dim)

    def store(self, keys, values):
        self.keys_quantized, self.key_params = self.quantize_per_head(keys)
        self.values_quantized, self.value_params = self.quantize_per_head(
            values
        )

    def retrieve(self):
        keys = self.dequantize_per_head(self.keys_quantized, self.key_params)
        values = self.dequantize_per_head(
            self.values_quantized, self.value_params
        )
        return keys, values
In[24]:
Code
import torch.nn.functional as F

# Compare per-tensor vs per-channel quantization
cache_per_tensor = QuantizedCache(bits=4)
cache_per_channel = PerChannelQuantizedCache(bits=4)

cache_per_tensor.store(keys, values)
cache_per_channel.store(keys, values)

keys_pt, values_pt = cache_per_tensor.retrieve()
keys_pc, values_pc = cache_per_channel.retrieve()

mse_pt = F.mse_loss(keys_pt, keys).item()
mse_pc = F.mse_loss(keys_pc, keys).item()

cos_pt = F.cosine_similarity(keys.flatten(), keys_pt.flatten(), dim=0).item()
cos_pc = F.cosine_similarity(keys.flatten(), keys_pc.flatten(), dim=0).item()

mse_reduction_pct = (1 - mse_pc / mse_pt) * 100
Out[25]:
Console
INT4 Quantization Comparison:
  Per-tensor:  MSE = 0.034811, Cosine = 0.9833
  Per-channel: MSE = 0.027155, Cosine = 0.9871

  Per-channel reduces MSE by 22.0%

Per-channel quantization significantly reduces error at the same bit width because each attention head can use its full quantization range independently. The improvement is particularly pronounced at lower bit widths where the limited number of quantization levels makes efficient range utilization necessary. This technique allows INT4 per-channel quantization to approach the accuracy of INT8 per-tensor quantization in many cases, effectively doubling the compression while maintaining similar quality.

Combining Techniques

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.

Compression Quality Analysis

Compression introduces approximation error that can degrade generation quality, and understanding the nature of this error is needed for making informed trade-offs. Different compression techniques produce qualitatively different error patterns, which affects how they impact the attention mechanism and ultimately the generated text. Characterizing these error profiles helps you choose the right compression strategy for each deployment scenario.

The most important dimension along which the error profiles differ is their locality. Quantization introduces globally distributed errors: every retained token's representation is slightly perturbed, but none are completely lost. Eviction introduces locally catastrophic errors: the evicted tokens are completely gone, but retained tokens are perfectly represented. These different error distributions lead to different failure modes that are worth understanding in depth.

Let's visualize how different compression strategies affect the attention computation:

In[31]:
Code
import numpy as np

# Simulate a realistic attention pattern
np.random.seed(42)
seq_len = 64
query_len = 1

# Create attention weights with structure:
# - High attention to initial tokens (sinks)
# - Local attention to recent tokens
# - Sparse attention to intermediate tokens
attention_logits = np.random.randn(query_len, seq_len) * 0.5

# Add attention sink
attention_logits[:, :4] += 2.0

# Add local attention
attention_logits[:, -8:] += 1.5

# Add some heavy hitter tokens
attention_logits[:, 15] += 1.8
attention_logits[:, 28] += 2.0


# Softmax to get attention weights
def softmax(x):
    exp_x = np.exp(x - x.max(axis=-1, keepdims=True))
    return exp_x / exp_x.sum(axis=-1, keepdims=True)


original_attention = softmax(attention_logits)[0]

# Simulate quantization error (small random noise)
quant_noise = np.random.randn(seq_len) * 0.01
quantized_attention = softmax(attention_logits + quant_noise.reshape(1, -1))[0]

# Simulate eviction: only keep sink (4) + heavy hitters (4) + recent (8)
keep_mask = np.zeros(seq_len, dtype=bool)
keep_mask[:4] = True  # Sink
keep_mask[-8:] = True  # Recent
keep_mask[15] = True  # Heavy hitter
keep_mask[28] = True  # Heavy hitter

evicted_attention = np.zeros(seq_len)
evicted_logits = attention_logits.copy()
evicted_logits[:, ~keep_mask] = -1e9  # Mask evicted tokens
evicted_attention_computed = softmax(evicted_logits)[0]
evicted_attention[keep_mask] = evicted_attention_computed[keep_mask]
Out[32]:
Visualization
Heatmap of original attention weights across 64 token positions showing non-uniform distribution with peaks at sink and anchor positions.
Original attention weights showing characteristic non-uniform distribution across 64 token positions. Sink tokens (0-3) and anchor tokens (15 and 28) draw concentrated attention, while recent tokens at the end receive a local attention boost.
Heatmap of absolute INT8 quantization errors across 64 token positions showing uniformly small values.
Absolute error from INT8 quantization showing small, uniformly distributed perturbations across all token positions. The error magnitude is very small relative to the original weights, confirming that quantization preserves the attention structure.
Heatmap of eviction errors across 64 token positions showing zero error at retained tokens and non-zero error at evicted positions.
Absolute error from eviction, showing complete information loss at removed token positions and zero error at retained positions. The binary nature of this error profile contrasts sharply with the smooth, distributed quantization error.

The visualization reveals the distinct error profiles of each compression technique, illustrating a basic difference in how they affect the attention computation. Quantization introduces small, distributed errors across all positions, preserving the overall attention pattern while adding noise uniformly. The relative importance of tokens remains intact, and the model can still attend to the information it needs, albeit with slight imprecision. This graceful degradation makes quantization a relatively safe compression technique.

Eviction, by contrast, creates complete information loss for removed tokens. The error is not small and distributed but rather binary: retained tokens have zero error while evicted tokens have error equal to their original attention weight. The key to successful eviction is so that evicted tokens would have received near-zero attention anyway. When this condition holds, the total attention error is minimal because we are only losing tokens that contributed negligibly. When eviction removes a token that the model would have attended to significantly, generation quality suffers because that information is permanently lost.

The H2O algorithm's tracking of cumulative attention helps identify which tokens can be safely removed by giving evidence of their historical importance. Tokens that have consistently received minimal attention are unlikely to suddenly become necessary, making them safe eviction candidates. However, this heuristic is not perfect: a token that received little attention during the first 500 generation steps might become important at step 501 if the generated content shifts to reference that earlier context. This is the basic limitation of history-based eviction that no algorithm can fully overcome without knowledge of future generation steps.

Limitations and Practical Considerations

KV cache compression involves basic trade-offs that require careful consideration before deployment. Understanding these limitations is not merely academic: getting compression wrong can cause visible quality degradation that harms user experience or produces incorrect outputs in high-stakes applications. The limitations fall into several distinct categories, each requiring different mitigation strategies.

The most significant limitation is task sensitivity. Some tasks are highly sensitive to long-range dependencies that may be evicted or distorted by compression. Summarization requires synthesizing information from across the entire input document, meaning that evicting middle-context tokens can omit important facts from the summary. Question answering over long documents requires retaining the specific passage that answers the question, which may not have received high attention during the reading phase. Multi-hop reasoning requires preserving intermediate conclusions that serve as anchors for later steps. For all of these tasks, aggressive eviction can cause catastrophic failures where the model simply does not have access to the information it needs to produce a correct answer.

Eviction strategies face the challenge of predicting future importance from past attention. A token that received little attention during the first 500 generation steps might become important at step 501, but if it was evicted, that information is permanently lost. This temporal mismatch is particularly problematic for tasks where relevance depends on the specific query or generated content. In conversational settings, a user might refer back to something said many turns earlier, and the model's ability to respond accurately depends on whether that old context survived eviction. Heavy hitter detection helps, but cannot anticipate all future needs, particularly for out-of-distribution or unexpected queries.

Quantization interacts with numerical precision in subtle ways that can be difficult to predict in advance. While INT8 typically works well, INT4 can cause issues for models that rely on precise attention scores. Grouped query attention and multi-query attention, as we covered in Part XXIX, already reduce KV cache memory by sharing key and value heads across multiple query heads. Combining these architectural choices with aggressive quantization requires careful validation because the reduced dimensionality means each cached value carries more semantic load, which makes it more sensitive to quantization error.

Cache compression also complicates batched inference in non-obvious ways. Different sequences in a batch may have different important tokens, requiring either per-sequence eviction decisions (which is complex and adds overhead) or a union of important tokens across sequences (which is less aggressive compression and may exceed memory budgets). When one sequence in a batch has important tokens at positions 50-100 while another has them at positions 200-300, a shared eviction policy cannot serve both optimally. PagedAttention, covered in the previous chapter, handles memory allocation elegantly with non-contiguous blocks, but the combination of dynamic eviction with paged allocation adds implementation complexity that most production systems find challenging.

Finally, the computational overhead of compression must be measured rather than assumed negligible. Tracking cumulative attention scores requires gathering attention weights at each step, averaging across heads, and performing a running update. Identifying heavy hitters requires a partial sort or top-k operation on the score vector. Performing quantization requires computing per-tensor or per-channel statistics plus element-wise arithmetic. For latency-sensitive applications serving interactive users, the overhead from these operations may offset a significant fraction of the memory savings. Simple approaches like StreamingLLM's sink-plus-window strategy often provide the best latency-memory trade-off because they require no attention tracking and no score computation: the eviction policy is purely positional and runs in constant time. When throughput matters more than latency, the additional computation for sophisticated eviction is easier to justify because memory savings translate directly into larger batch sizes and higher tokens per second.

A practical guideline for deploying compression is to start with INT8 quantization alone, since it provides 2x memory reduction with minimal quality impact and nearly zero computational overhead on modern hardware with INT8 tensor core support. Then, if memory remains insufficient, add StreamingLLM-style eviction with a generous window to avoid long-range dependency failures. Only consider more aggressive H2O-style eviction after measuring quality on your specific task and confirming that the quality degradation is acceptable. This incremental approach avoids deploying compression that is more aggressive than necessary, keeping quality impact bounded while still achieving substantial memory reduction.

Key Parameters

The key parameters for KV cache compression are:

  • sink_size: Number of initial tokens to always preserve in the cache. These attention sinks are necessary for stability and typically only 4 tokens are needed.
  • window_size (or recent_size): Number of most recent tokens to keep. Ensures local context is available for coherent generation.
  • heavy_hitter_size: Number of high-attention tokens to preserve based on historical importance. Larger values reduce eviction-induced quality loss at the cost of memory.
  • bits: Target bit width for quantization (e.g., 4 or 8). Lower bits save more memory but may increase error. INT8 is almost always safe; INT4 requires validation.

The interaction between these parameters determines the memory-quality trade-off. A larger sink plus window budget allows more context but reduces compression. A higher bit width preserves more precision but uses more memory per token. Finding the right balance requires empirical evaluation on representative workloads rather than relying purely on theoretical analysis.

Summary

KV cache compression addresses the memory bottleneck of long-sequence generation through two complementary approaches: reducing the number of cached tokens through eviction, and reducing the precision of stored values through quantization.

Window-based eviction provides the simplest implementation but sacrifices all long-range context. Attention sink preservation recognizes that initial tokens serve as computational anchors and must be protected from eviction to maintain generation coherence. The H2O algorithm extends this by tracking cumulative attention to identify and preserve "heavy hitter" tokens that consistently receive high attention, balancing recency with historical importance. The key insight across all eviction strategies is that attention patterns are highly non-uniform and temporally persistent, which makes historical attention a reliable proxy for future importance.

Cache quantization offers orthogonal memory savings without removing any tokens from the cache. INT8 quantization typically achieves 2x compression with negligible quality impact. INT4 provides 4x compression but requires per-channel quantization and careful validation, particularly for precision-sensitive tasks. The basic mechanism is the same as general quantization: represent continuous values with discrete integers, accepting bounded approximation error in exchange for dramatic storage reduction.

Combining eviction with quantization can achieve compression ratios exceeding 30x for long sequences, letting use cases that would otherwise require prohibitive memory. The two techniques combine multiplicatively because they address orthogonal dimensions: eviction reduces token count while quantization reduces bytes per token. The choice of compression strategy depends on the specific use case. Tasks requiring precise long-range reasoning may tolerate only light compression, while streaming applications or tasks with primarily local dependencies can use aggressive compression. Understanding these trade-offs enables you to select appropriate configurations that balance memory efficiency with generation quality.

Quiz

Ready to test your understanding? Take this quick quiz to reinforce what you've learned about KV cache compression techniques.

Comments

No comments yet. Be the first to share your thoughts!

Reference

Citation details

Cite or share this article.

BIBTEXAcademic
@misc{brenndoerfer2026kvcache-3, author = {Michael Brenndoerfer}, title = {KV Cache Compression: Eviction, Quantization & H2O Algorithm}, year = {2026}, url = {https://mbrenndoerfer.com/writing/kv-cache-compression-eviction-quantization-h2o-algorithm}, organization = {mbrenndoerfer.com}, note = {Accessed: 2026-09-30} }
APAAcademic
Michael Brenndoerfer (2026). KV Cache Compression: Eviction, Quantization & H2O Algorithm. Retrieved from https://mbrenndoerfer.com/writing/kv-cache-compression-eviction-quantization-h2o-algorithm
MLAAcademic
Michael Brenndoerfer. "KV Cache Compression: Eviction, Quantization & H2O Algorithm." 2026. Web. September 30, 2026. <https://mbrenndoerfer.com/writing/kv-cache-compression-eviction-quantization-h2o-algorithm>.
CHICAGOAcademic
Michael Brenndoerfer. "KV Cache Compression: Eviction, Quantization & H2O Algorithm." Accessed September 30, 2026. https://mbrenndoerfer.com/writing/kv-cache-compression-eviction-quantization-h2o-algorithm.
HARVARDAcademic
Michael Brenndoerfer (2026) 'KV Cache Compression: Eviction, Quantization & H2O Algorithm'. Available at: https://mbrenndoerfer.com/writing/kv-cache-compression-eviction-quantization-h2o-algorithm (Accessed: September 30, 2026).
SimpleBasic
Michael Brenndoerfer (2026). KV Cache Compression: Eviction, Quantization & H2O Algorithm. https://mbrenndoerfer.com/writing/kv-cache-compression-eviction-quantization-h2o-algorithm

About the author

Continue with the full handbook

This chapter is part of Language AI Handbook. Use the handbook page to browse the complete table of contents and continue reading in sequence.

Explore Language AI Handbook
Newsletter

Stay up to date

Get articles, book updates, and news delivered to your inbox.

No spam, unsubscribe anytime.

or

Join the community

Sign in to remove popups, track your reading progress, and join the discussion.