首页 / 文章

KV缓存是隐藏在背后的数据库,正在消耗LLM解码过程中的GPU内存。

在大语言模型推理过程中,KV缓存会悄悄消耗GPU内存:预填充与解码、KV运算、GQA与MQA、分页注意力机制、量化及驱逐策略。

发布者:REACTAPP.TOP阅读约 9 分钟2109 词

来源: The KV Cache: AI’s Unseen Database Dominating GPU Memory

大语言模型推理中的真正瓶颈

在推理过程中,GPU的计算能力往往处于闲置状态,而内存带宽则持续为不断扩大的键值缓存提供数据。每个新生成的标记都会增加K/V张量,这些张量在后续步骤中需要被重新读取。那些忽视键值缓存增长问题的系统,在浮点运算次数耗尽之前早已面临上下文限制和延迟问题。

底层原理:键值缓存的工作机制

注意力机制需要每个先前标记的键和值,将它们缓存起来可以避免在每一步都重新计算整个前缀内容。但相应的代价是内存占用与序列长度×层数×头数×维度成正比。

预填充与解码

预填充模式会先处理提示语并填满缓存;而解码模式则是逐个添加标记,每一步都需要读取整个缓存。预填充模式计算量大,解码模式则对带宽要求较高。

import torch
import torch.nn as nn

# Configuration representing a simplified LLM layer
batch_size = 1
num_heads = 8
head_dim = 64
hidden_dim = 512

# Projection layers for Keys and Values
W_k = nn.Linear(hidden_dim, num_heads * head_dim)
W_v = nn.Linear(hidden_dim, num_heads * head_dim)

# --- 1. PREFILL PHASE ---
# The prompt has 5 tokens. We process them simultaneously.
prompt_len = 5
prompt_embeddings = torch.randn(batch_size, prompt_len, hidden_dim)

# Project to K and V, then reshape to: [Batch, Heads, SeqLen, HeadDim]
kv_cache_k = W_k(prompt_embeddings).view(batch_size, prompt_len, num_heads, head_dim).transpose(1, 2)
kv_cache_v = W_v(prompt_embeddings).view(batch_size, prompt_len, num_heads, head_dim).transpose(1, 2)

print(f"Prefill complete. Cache K shape: {kv_cache_k.shape}")
# Output: [1, 8, 5, 64] (5 tokens cached)

# --- 2. DECODING PHASE ---
# We generate 3 subsequent tokens, one by one.
for step in range(3):
    # Input is only the single, newly generated token (SeqLen = 1)
    new_token_embedding = torch.randn(batch_size, 1, hidden_dim)

    # Project only the new token's K and V
    new_k = W_k(new_token_embedding).view(batch_size, 1, num_heads, head_dim).transpose(1, 2)
    new_v = W_v(new_token_embedding).view(batch_size, 1, num_heads, head_dim).transpose(1, 2)

    # Append the new token's representations to our historical cache
    # This prevents recalculating the past prompt_len + step tokens
    kv_cache_k = torch.cat([kv_cache_k, new_k], dim=2)
    kv_cache_v = torch.cat([kv_cache_v, new_v], dim=2)

    print(f"Decoding Step {step + 1} | Cache K shape: {kv_cache_k.shape}")
    # Step 1 Output: [1, 8, 6, 64]
    # Step 2 Output: [1, 8, 7, 64]
    # Step 3 Output: [1, 8, 8, 64]
def calculate_kv_cache_gb(batch_size: int, seq_len: int, layers: int, hidden_dim: int, precision_bytes: int = 2) -> float:
    # 2 represents the key and value states
    total_bytes = batch_size * seq_len * layers * hidden_dim * 2 * precision_bytes
    return total_bytes / (1024 ** 3)  # Convert to Gigabytes

# Llama 2 70B architecture parameters (assuming standard MHA)
llama_70b_gb = calculate_kv_cache_gb(
    batch_size=32,
    seq_len=8192,
    layers=80,
    hidden_dim=8192
)

print(f"KV Cache Size: {llama_70b_gb:.2f} GB")
# Output: KV Cache Size: 320.00 GB
+---------------------------------------------------------------+
|                      GPU VRAM (80 GB)                         |
+------------------------------+--------------------------------+
| Static Model Weights (Fixed) |  KV Cache (Dynamic / Variable) |
| [==========================] | [=======================>    ] |
| FP16 Weights (e.g., ~35GB/GPU| Grows linearly with:           |
| across split tensor setups)  | Batch Size * Sequence Length   |
+------------------------------+--------------------------------+
MHA (1:1 Ratio)       GQA (Grouped Ratio)     MQA (Many:1 Ratio)
[Q1][Q2][Q3][Q4]      [Q1][Q2]  [Q3][Q4]      [Q1][Q2][Q3][Q4]
  |   |   |   |         \   /     \   /         \   |   /   /
[K1][K2][K3][K4]        [ K1 ]   [ K2 ]          [   K1    ]
[V1][V2][V3][V4]        [ V1 ]   [ V2 ]          [   V1    ]
# A simple simulation of PagedAttention block mapping
class VirtualPageTable:
    def __init__(self, block_size: int):
        self.block_size = block_size
        self.logical_to_physical = {} # Maps (request_id, block_index) -> physical_block_id
        self.free_blocks = list(range(1000)) # Pool of physical memory blocks

    def allocate_block_for_token(self, request_id: int, token_index: int) -> int:
        # Determine which logical block the token falls into
        logical_block_idx = token_index // self.block_size
        key = (request_id, logical_block_idx)

        # Allocate a new physical block only when the previous one is full
        if key not in self.logical_to_physical:
            physical_block = self.free_blocks.pop(0)
            self.logical_to_physical[key] = physical_block

        return self.logical_to_physical[key]

# Instantiating a table with a block size of 4 tokens
table = VirtualPageTable(block_size=4)
# Token 5 belongs to logical block 1 (tokens 4-7). We allocate physical space on demand.
physical_id = table.allocate_block_for_token(request_id=42, token_index=5)
print(f"Logical token mapped to physical block: {physical_id}")
FP16/BF16 Value: [0.12345678]  (16 bits of precision)
     ↓ Quantization via Scaling Factor (e.g., scale = 127.0)
INT8 Value:      [15]          (8 bits of precision, 50% memory saved)
Sequence: [T1] [T2] [T3] [T4] [T5] [T6] [T7]
          └───── Window Size (4) ─────┘
          (T1 and T2 are evicted from active memory)
# Example: Initializing a vLLM engine with memory optimizations
from vllm import LLMEngine, EngineArgs

# Define engine arguments optimized for memory management
engine_args = EngineArgs(
    model="meta-llama/Llama-3-8B-Instruct",
    # 1. Quantization: Reduces KV cache footprint
    quantization="fp8",
    # 2. Paged KV Cache: Eliminates physical fragmentation
    gpu_memory_utilization=0.90,
    # 3. Dynamic Batching: Prevents static padding overhead
    max_num_seqs=256
)

# Initialize the engine with built-in memory guardrails
engine = LLMEngine.from_engine_args(engine_args)

追加机制

实现方式会为每一层存储K/V缓冲区,并在每一步写入新行。此处的错误会悄无声息地破坏注意力机制——建议使用经过实际验证的核函数。

令人警醒的内存计算

字节数 ≈ 2(K和V)× 层数 × kv_heads × head_dim × 序列长度 × 每个元素的字节数。大型模型处理长上下文时所需的内存可能超过权重本身占用的内存。

优化技术

架构重构:MHA、GQA、MQA

通过分组查询和多查询注意力机制缩小KV头数量,从而在相同序列长度下减少缓存需求。

PagedAttention

将KV视为分页虚拟内存:按需分配块,降低碎片化程度,支持更高的批量处理。

KV量化

在质量预算允许的情况下,以较低的精度存储缓存条目。

滑动窗口/淘汰机制

当任务允许的注意力范围有限时,丢弃或压缩较远的标记。

生产环境决策矩阵

在内存受限时选择GQA/MQA模型;在高并发场景下启用分页注意力机制;仅在评估完成后对KV进行量化处理;在需要本地上下文的聊天场景中使用窗口机制。

总结

KV缓存是自回归解码过程中不可见的数据库。需明确对其进行优化——包括架构、分页方式、精度设置及淘汰策略——否则将会付出GPU资源消耗和延迟增加的代价。

应同时监测每秒处理的标记数以及每个标记对应的KV字节数。那种虽能使缓存占用翻倍但速度“更快”的内核,在多租户负载下反而可能表现更差。

需为每个产品规格明确记录最大上下文长度,避免产品在仅配备8k上下文的硬件上悄悄请求128k的上下文容量。

在批量处理时,要记住解码步骤需与最长的序列保持同步;填充策略和连续批量处理的重要性不亚于原始带宽。

应同时测量每秒处理的标记数以及每个标记对应的KV字节数。那种会使缓存占用翻倍的“更快”内核,在多租户负载下反而可能表现更差。

需为每个SKU明确规定最大上下文长度,避免产品在仅配置了8k上下文的硬件上悄悄请求128k的上下文。

在批量处理时,要记住解码步骤需与最长的序列保持同步;填充策略和连续批量处理的重要性不亚于原始带宽。

应同时测量每秒处理的标记数以及每个标记对应的KV字节数。那种会使缓存占用翻倍的“更快”内核,在多租户负载下反而可能表现更差。

需为每个SKU明确规定最大上下文长度,避免产品在仅配置了8k上下文的硬件上悄悄请求128k的上下文。

在批量处理时,要记住解码步骤需与最长的序列保持同步;填充策略和连续批量处理的重要性不亚于原始带宽。

应同时测量每秒处理的标记数以及每个标记对应的KV字节数。那种会使缓存占用翻倍的“更快”内核,在多租户负载下反而可能表现更差。

需为每个SKU明确规定最大上下文长度,避免产品在仅配置了8k容量的硬件上悄悄请求128k的上下文。

在批量处理时,要记住解码步骤需与最长的序列保持同步;填充策略和连续批量处理的重要性不亚于原始带宽。

应同时测量每秒处理的标记数以及每个标记对应的KV字节数。那种会使缓存占用翻倍的“更快”内核,在多租户负载下反而可能表现更差。

需为每个SKU明确规定最大上下文长度,避免产品在仅配置了8k容量的硬件上悄悄请求128k的上下文。

在批量处理时,要记住解码步骤需与最长的序列保持同步;填充策略和连续批量处理的重要性不亚于原始带宽。

应同时测量每秒处理的标记数以及每个标记对应的KV字节数。那种会使缓存占用翻倍的“更快”内核,在多租户负载下反而可能表现更差。

需为每个SKU明确规定最大上下文长度,避免产品在仅配置了8k上下文的硬件上悄悄请求128k的上下文。

在批量处理时,要记住解码步骤需与最长的序列保持同步;填充策略和连续批量处理的重要性不亚于原始带宽。

应同时测量每秒处理的标记数以及每个标记对应的KV字节数。那种会使缓存占用翻倍的“更快”内核,在多租户负载下反而可能表现更差。

需为每个SKU明确规定最大上下文长度,避免产品在仅配置了8k上下文的硬件上悄悄请求128k的上下文。

在批量处理时,要记住解码步骤需与最长的序列保持同步;填充策略和连续批量处理的重要性不亚于原始带宽。

应同时测量每秒处理的标记数以及每个标记对应的KV字节数。那种会使缓存占用翻倍的“更快”内核,在多租户负载下反而可能表现更差。

需为每个SKU明确规定最大上下文长度,避免产品在仅配置了8k上下文的硬件上悄悄请求128k的上下文。

在批量处理时,要记住解码步骤需与最长的序列保持同步;填充策略和连续批量处理的重要性不亚于原始带宽。

应同时测量每秒处理的标记数以及每个标记对应的KV字节数。那种会使缓存占用翻倍的“更快”内核,在多租户负载下反而可能表现更差。

需为每个SKU明确规定最大上下文长度,避免产品在仅配置了8k上下文的硬件上悄悄请求128k的上下文。

在批量处理时,要记住解码步骤需与最长的序列保持同步;填充策略和连续批量处理的重要性不亚于原始带宽。

应同时测量每秒处理的标记数以及每个标记对应的KV字节数。那种会使缓存占用翻倍的“更快”内核,在多租户负载下反而可能表现更差。

需为每个SKU明确规定最大上下文长度,避免产品在仅配置了8k上下文的硬件上悄悄请求128k的上下文。

在批量处理时,要记住解码步骤需与最长的序列保持同步;填充策略和连续批量处理的重要性不亚于原始带宽。

应同时测量每秒处理的标记数以及每个标记对应的KV字节数。那种会使缓存占用翻倍的“更快”内核,在多租户负载下反而可能表现更差。

需为每个SKU明确规定最大上下文长度,避免产品在仅配置了8k上下文的硬件上悄悄请求128k的上下文。

在批量处理时,要记住解码步骤需与最长的序列保持同步;填充策略和连续批量处理的重要性不亚于原始带宽。

应同时测量每秒处理的标记数以及每个标记对应的KV字节数。那种会使缓存占用翻倍的“更快”内核,在多租户负载下反而可能表现更差。

需为每个SKU明确规定最大上下文长度,避免产品在仅配置了8k上下文的硬件上悄悄请求128k的上下文。

在批量处理时,要记住解码步骤需与最长的序列保持同步;填充策略和连续批量处理的重要性不亚于原始带宽。

应同时测量每秒处理的标记数以及每个标记对应的KV字节数。那种会使缓存占用翻倍的“更快”内核,在多租户负载下反而可能表现更差。

需为每个SKU明确规定最大上下文长度,避免产品在仅配置了8k上下文的硬件上悄悄请求128k的上下文。

在批量处理时,要记住解码步骤需与最长的序列保持同步;填充策略和连续批量处理的重要性不亚于原始带宽。

应同时测量每秒处理的标记数以及每个标记对应的KV字节数。那种会使缓存占用翻倍的“更快”内核,在多租户负载下反而可能表现不佳。

预填充与解码之间的不对称性也会影响自动扩展功能:提示语长度的突然增加会加重预填充的计算负担,而较长生成结果则会增加带宽消耗。需为这两种情况分别配置资源池。

要验证供应商关于“长上下文”的宣传,应要求其提供使用指定数据类型和批量大小时的最大长度下的KV字节数,而非仅宣传标记处理速度。

驱逐策略必须考虑产品特性:对于那些会引用之前对话内容的客服人员而言,对已丢弃的片段进行总结比直接忽略它们更为有效。

那些忽视KV数据碎片化的连续批量处理调度器仍可能导致内存不足;分页布局与数据整理机制应一同纳入升级方案中。

预填充/解码的不对称性也会影响自动扩展功能:提示词长度的骤增会加重预填充阶段的计算负担,而较长的生成过程则会消耗大量带宽。这两种情况都需要相应的容量规划。

要验证厂商关于“长上下文”的宣传,应要求其提供在最大长度、指定数据类型及批量大小下的KV字节数,而非仅用营销话术来描述。

驱逐策略必须考虑产品特性:对于那些会引用 earlier 对话内容的客服人员而言,对丢失的对话片段进行总结比直接忽略它们更为有效。

那些忽视KV数据碎片化的连续批量调度器仍可能导致内存不足;分页布局与数据整理机制应同步实现。

预填充/解码的不对称性也会影响自动扩展功能:提示词长度的骤增会加重预填充阶段的计算负担,而较长的生成过程则会消耗大量带宽。这两种情况都需要相应的容量规划。

要通过指定数据类型和批次大小来请求最大长度下的 KV 字节数,而非营销宣传中的所谓“长上下文”能力,以此验证供应商的宣称。

驱逐策略必须考虑产品特性:对于需要引用之前对话内容的客服人员而言,对丢失的片段进行总结比默默遗忘更为有效。

那些忽视 KV 碎片问题的连续批次调度器仍会导致内存不足;分页布局与碎片整理机制应同步推出。

预填充/解码的不对称性也会影响自动扩展功能:提示词长度的激增会加重预填充的计算负担,而长文本生成则会增加带宽压力。这两种情况都需要相应的容量配置。

要通过指定数据类型和批次大小来请求最大长度下的 KV 字节数,而非营销宣传中的所谓“长上下文”能力,以此验证供应商的宣称。

驱逐策略必须考虑产品特性:对于需要引用之前对话内容的客服人员而言,对丢失的片段进行总结比默默遗忘更为有效。