KV Cache Compression › The H2O Algorithm

Michael BrenndoerferJanuary 9, 20269 min read
Language AI HandbookAll chapters
Part 42 of 67Inference Optimization
Chapter 316KV Cache Compression › The H2O Algorithm
Reading section 6 of 13 · The H2O Algorithm · 9 minSection 6 of 13 · 9 min

Book contents

Section 6 of 13 · 9 min read

KV Cache Compression › The H2O AlgorithmOne section · Sections in this chapter6 / 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

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.