AKEJYO
← All recordsRECORD / 018
LLM

What is KV Cache

Show me the real process, enough meaningless formula!

LLM8 min read

KV caching is related to the attention mechanism, so let us begin with attention.

Given an input XX, we project it into queries, keys, and values:

  • Q=XWQQ=XW_Q
  • K=XWKK=XW_K
  • V=XWVV=XW_V

The attention output is:

Attention(Q,K,V)=softmax(QKTdk)VAttention(Q,K,V)=\text{softmax}(\frac{QK^T}{\sqrt{d_k}})V

Here, dkd_k is the dimensionality of the key vectors. It is used in the scaling factor to stabilize gradients.

Example:

Suppose we are generating a sentence. At the first step, we have only the start-of-sequence token <sos>, with embedding vector x1=[1,0]x_1=[1,0].

  • d=2d=2, dk=2\sqrt{d_k}=\sqrt{2}
  • Vocabulary: ”玛“: [1,0][1,0], ”恩“: [0,1][0,1], ”纳“: [0.5,0.5][0.5,0.5]
  • Assume I=WQ=WK=WVI=W_Q=W_K=W_V, which means:
    • qi=ki=vi=xiq_i=k_i=v_i=x_i

Attention score: e11=q1⋅k12≈0.707e_{11}=\frac{q_1\cdot k_1}{\sqrt{2}}\approx 0.707 Softmax: α11=1\alpha_{11}=1 Output: z1=α11v1=x1=[1,0]z_1=\alpha_{11}v_1=x_1=[1,0] Predicting the next token:

  • Use z1z_1 to compute the logits:
    • "玛": [1,0]⋅[1,0]=1[1,0]\cdot [1,0]=1
    • "恩": [1,0]⋅[0,1]=0[1,0]\cdot [0,1] =0
    • “纳”: [1,0]⋅[0.5,0.5]=0.5[1,0]\cdot [0.5,0.5]=0.5
  • Apply softmax to obtain the probabilities:
    • "玛": 0.5060.506
    • "恩": 0.1860.186
    • “纳”: 0.3070.307
  • Generate the token "玛", whose embedding vector is x2=[1,0]x_2=[1,0].

For simplicity, we assume that the start-of-sequence token and "玛" happen to share the same embedding vector. Positional information is omitted in this toy example.

Also, here we don't care the position of the token.

At the second step, attention is computed as follows (the scaling factor is omitted to keep the notation readable):

Attentionstep2(Q,K,V)=softmax([Q2K1TQ2K2T])[V1→V2→]=[[softmax(Q2K1T,Q2K2T)]1V→1+[softmax(Q2K1T,Q2K2T)]2(Q2K2T)V→2]=[a21V1→+a22V2→]=Attention2\begin{aligned}Attention_{step2}(Q,K,V)&=\text{softmax}( \begin{bmatrix} Q_2K_1^T & Q_2K_2^T \end{bmatrix}) \begin{bmatrix} \overrightarrow{V_1}\\ \overrightarrow{V_2} \end{bmatrix} \\ &= \begin{bmatrix} [\text{softmax}(Q_2K_1^T,Q_2K_2^T)]_1 \overrightarrow V_1 + [\text{softmax}(Q_2K_1^T,Q_2K_2^T)]_2(Q_2K_2^T) \overrightarrow V_2 \end{bmatrix} \\ &= \begin{bmatrix} a_{21}\overrightarrow{V_1}+a_{22}\overrightarrow{V_2} \end{bmatrix}\\ &= Attention_2 \end{aligned}

Notice that in the computation of Attention2Attention_2, K1K_1 and K2K_2, along with V1→\overrightarrow{V_1} and V2→\overrightarrow {V_2}, are used again. At step kk, we only need to use QkQ_k together with the cached K1...Kk−1K_1...K_{k-1} and V1→...Vk−1→\overrightarrow{V_1}...\overrightarrow{V_{k-1}}, plus the new KkK_k and Vk→\overrightarrow{V_k}, to compute attention.

Now let us look at the GPT2 code. In class GPT2Attention(nn.Module):, we can see how the KV cache is used. The key parts to focus on are:

if past_key_values is not None:
    if isinstance(past_key_values, EncoderDecoderCache):
        is_updated = past_key_values.is_updated.get(self.layer_idx)
        #......
        #......
        curr_past_key_values = past_key_values.self_attention_cache
    else:
        curr_past_key_values = past_key_values
#......
#......
query_states, key_states, value_states = self.c_attn(hidden_states).split(self.split_size, dim=2)
shape_kv = (*key_states.shape[:-1], -1, self.head_dim)
key_states = key_states.view(shape_kv).transpose(1, 2)
value_states = value_states.view(shape_kv).transpose(1, 2)
#......
#......
if (past_key_values is not None and not is_cross_attention) or (
    past_key_values is not None and is_cross_attention and not is_updated
):
    key_states, value_states = curr_past_key_values.update(key_states, value_states, self.layer_idx)

We can see that it attempts to retrieve the past keys and values directly from the cache, then concatenate the current keys and values with them. This update() method can be found in class Cache in cache_utils.py:

    def update(
        self, key_states: torch.Tensor, value_states: torch.Tensor, layer_idx: int, *args, **kwargs
    ) -> tuple[torch.Tensor, torch.Tensor]:
        """
        Updates the cache with the new `key_states` and `value_states` for the layer `layer_idx`.

        Parameters:
            key_states (`torch.Tensor`):
                The new key states to cache.
            value_states (`torch.Tensor`):
                The new value states to cache.
            layer_idx (`int`):
                The index of the layer to cache the states for.

        Return:
            A tuple containing the updated key and value states.
        """
        # In this case, the `layers` were not provided, and we must append as much as `layer_idx`
        if self.layer_class_to_replicate is not None:
            while len(self.layers) <= layer_idx:
                self.layers.append(self.layer_class_to_replicate())

        if self.offloading:
            # Wait for the stream to finish if needed, and start prefetching the next layer
            torch.cuda.default_stream(key_states.device).wait_stream(self.prefetch_stream)
            self.prefetch(layer_idx + 1, self.only_non_sliding)

        keys, values = self.layers[layer_idx].update(key_states, value_states, *args, **kwargs)

        if self.offloading:
            self.offload(layer_idx, self.only_non_sliding)

        return keys, values

First, create a cache-layer object for the current Transformer layer:

if self.layer_class_to_replicate is not None:
    while len(self.layers) <= layer_idx:
        self.layers.append(self.layer_class_to_replicate())

How it works: self.layers is a list in which each element represents a cache object for one layer. If the current index is beyond the end of the list, self.layer_class_to_replicate (a callable class, such as CacheLayer) is used to create and append new cache-layer instances. This allows layers to be added dynamically while the model runs, without preallocating all layers.

Next, update the KV cache for the current layer:

keys, values = self.layers[layer_idx].update(
    key_states, value_states, *args, **kwargs
)

There is also code to prefetch data for the next layer:

if self.offloading:
    torch.cuda.default_stream(key_states.device).wait_stream(self.prefetch_stream)
    self.prefetch(layer_idx + 1, self.only_non_sliding)

Wait for prefetching to complete: prefetch_stream is a CUDA stream used for asynchronous data loading. wait_stream ensures that the current default stream waits for the prefetch stream to finish, preventing data races.

Prefetch the next layer: Calling self.prefetch(layer_idx + 1, ...) loads the cached data needed by the next layer from the CPU to the GPU ahead of time, preparing for subsequent steps and reducing GPU idle time.

The implementation of layer.update() varies depending on the type of layer. The clearest example I found is DynamicLayer (link):

def update(
    self, key_states: torch.Tensor, value_states: torch.Tensor, *args, **kwargs
) -> tuple[torch.Tensor, torch.Tensor]:
    """
    Update the key and value caches in-place, and return the necessary keys and value states.

    Args:
        key_states (`torch.Tensor`): The new key states to cache.
        value_states (`torch.Tensor`): The new value states to cache.

    Returns:
        tuple[`torch.Tensor`, `torch.Tensor`]: The key and value states.
    """
    # Lazy initialization
    if not self.is_initialized:
        self.lazy_initialization(key_states, value_states)

    self.keys = torch.cat([self.keys, key_states], dim=-2)
    self.values = torch.cat([self.values, value_states], dim=-2)
    return self.keys, self.values

Here, we can clearly see the cat operation: it appends the new keys and values to the existing ones and returns the results.

End of record / 018
← All records
READ NEXTInteresting puzzle found in neocities