KV Cache 从 0 到 1:把 nanoGPT 的 generate 加速 100 倍的关键技术

— 从"为什么 generate 这么慢"开始,到推理引擎的第一块基石
学习笔记 AI Infra 推理引擎 KV Cache nanoGPT Stage 1 / Week 1
目录
  1. 1. 引子:跑 nanoGPT 的 generate() 慢得令人发指
  2. 2. 复盘 nanoGPT 的 generate():朴素实现长啥样
  3. 3. 哪些计算在重复:把浪费量化出来
  4. 4. 关键 Insight:K、V 为什么可缓存(两条事实)
  5. 5. Q 为什么不需要 cache
  6. 6. Prefill 与 Decode:两个阶段,两套行为
  7. 7. 改 nanoGPT:手写一个 KV Cache 版 generate()
  8. 8. 显存代价:每 token 到底吃多少 KV
  9. 9. 引出 PagedAttention(下一篇的钩子)
  10. 10. ⭐ 学习过程高频卡点答疑(10 个 Q)
  11. 11. 参考资料与 Colab

1. 引子:跑 nanoGPT 的 generate() 慢得令人发指

跟着 Karpathy 的 Ep.5 把一个 GPT 训出来之后,第一件事是兴奋地调 generate() 看它"说人话"。然后你会发现一件事:

生成第 1 个 token 很快,生成第 100 个 token 越来越慢,生成第 500 个 token 几乎在爬。

如果做个 profile(或者直接打时间戳),会看到 attention 层吃掉了 80%+ 的时间,而且每生成一个新 token,attention 的耗时会随当前序列长度增长。

本文目标
把这件事的原因彻底讲清楚,并写出一个能真正运行的 KV Cache 版 generate()。这是推理引擎所有优化的第 0 块基石——后面 PagedAttention、Continuous Batching、Prefix Caching 全都基于它。

2. 复盘 nanoGPT 的 generate():朴素实现长啥样

Karpathy 在 Ep.5 里的 generate() 是这样的(精简版):

@torch.no_grad()
def generate(self, idx, max_new_tokens):
    # idx: (B, T) 当前已知的 token 序列
    for _ in range(max_new_tokens):
        # 1) 把上下文截到 block_size(位置编码上限)
        idx_cond = idx[:, -self.block_size:]

        # 2) 整段 forward:每次都从头算
        logits, _ = self(idx_cond)              # (B, T, vocab_size)

        # 3) 只取最后一个位置的预测
        logits = logits[:, -1, :]               # (B, vocab_size)
        probs  = F.softmax(logits, dim=-1)

        # 4) 采样下一个 token
        idx_next = torch.multinomial(probs, num_samples=1)  # (B, 1)
        idx = torch.cat([idx, idx_next], dim=1)              # 拼到末尾
    return idx

整个函数只有四步,但第 2 步隐藏了所有的性能问题——"整段 forward"意味着:每生成一个新 token,模型把已经算过 N 遍的历史 token 又算了一遍。

3. 哪些计算在重复:把浪费量化出来

假设当前已经生成了 3 个 token:[A, B, C]。在 self-attention 层,每个 token 都会算出自己的 Q / K / V:

位置 0 (A): Q_A, K_A, V_A 位置 1 (B): Q_B, K_B, V_B 位置 2 (C): Q_C, K_C, V_C

然后 attention 输出(causal mask 保证只看自己和过去):

out_0 = softmax(Q_A · [K_A]) · [V_A] out_1 = softmax(Q_B · [K_A, K_B]) · [V_A, V_B] out_2 = softmax(Q_C · [K_A, K_B, K_C]) · [V_A, V_B, V_C]

generate() 只拿 out_2(最后一个位置)去预测 token D,out_0 和 out_1 算了就扔。

下一步生成 D 时,输入变成 [A, B, C, D],又把 attention 跑一遍:

位置 0 (A): Q_A, K_A, V_A ← 跟上一步一模一样 位置 1 (B): Q_B, K_B, V_B ← 跟上一步一模一样 位置 2 (C): Q_C, K_C, V_C ← 跟上一步一模一样 位置 3 (D): Q_D, K_D, V_D ← 唯一新算的

把浪费量化

设序列长度 $T$、hidden_dim $d$。单步 attention 计算量 $\\mathcal{O}(T^2 \\cdot d)$(Q @ K^T 主导)。

生成 $N$ 个 token 的总计算量:

$$\\sum_{t=1}^{N} \\mathcal{O}(t^2 \\cdot d) = \\mathcal{O}(N^3 \\cdot d)$$

而真正"有用"的计算只有每步最后一个位置的 attention,也就是 $\\mathcal{O}(t \\cdot d)$ 这部分。其余 $\\mathcal{O}(t^2 - t)$ 都是冗余。

⚠ 量级感
N = 1000 时,朴素 generate 的总 attention 计算量 $\\approx 10^9 \\cdot d$,KV Cache 版只需 $\\approx 10^6 \\cdot d$,差 1000 倍。这就是为什么 LLM 推理引擎必须实现 KV Cache。

4. 关键 Insight:K、V 为什么可缓存(两条事实)

整个 KV Cache 的理论基础是两条几乎显然的事实,但必须同时成立:

事实说明
① K、V 只依赖自身位置 K_i = x_i · W_k,V_i = x_i · W_v。给定 token i 的输入 $x_i$,K 和 V 是确定值,不受未来 token 影响。
② Causal Mask 保证位置 i 的 attention 只看 ≤ i 未来 token 不会修改过去 token 的 attention 输出,因此历史 K、V 一旦算出就永远有效。

合起来:"历史 token 的 K、V 是只读的不可变数据"。这跟操作系统里"只读页可以无成本共享"是一个味道。

🔧 Infra 视角
这个观察听起来简单,但实际上是分布式系统、缓存系统、数据库优化里反复出现的一类核心定理:先识别"不可变的部分",再围绕它做共享、复用、缓存、并发。在 Kafka 里,Segment 文件只追加、永不修改,所以可以被多个 consumer 同时 mmap 读——这是同构思想。

5. Q 为什么不需要 cache

很多人第一次看 KV Cache 会问:那 Q 为什么不也缓存?答案分两层:

① Q 也是只依赖自身位置的(理论上可以缓存)

从公式看,Q_i = x_i · W_q,跟 K、V 完全对称,也是只依赖自身。所以理论上"可以"缓存。

② 但 decode 阶段每步只用一个 Q,缓存它没意义

生成第 t 个 token 时,attention 输出公式:

out_t = softmax(Q_t · [K_0, K_1, ..., K_t]^T / √d) · [V_0, V_1, ..., V_t]

只有 Q_t(当前位置的 Q)参与运算。下一步生成 t+1 时,又只用 Q_{t+1}。历史 Q 算了就扔,根本不会再读——所以缓存它纯粹浪费显存。

✓ 一句话总结
K、V 是"被多次读取的资源"(每步生成都要查所有历史 K、V),所以值得缓存。
Q 是"用一次就丢的临时量",缓存无收益。

6. Prefill 与 Decode:两个阶段,两套行为

引入 KV Cache 之后,推理过程被自然切成两个阶段。这两个阶段的计算模式完全不同,是后面理解 vLLM 调度器的关键。

Prefill:处理用户的 prompt(一次性、并行)

用户输入 100 个 token 的 prompt,这 100 个 token 一起塞进模型:

# x: (1, 100, d) ← 整段 prompt 的 embedding
Q = x @ W_q                 # (1, 100, d)  ← 100 个位置的 Q 同时算
K = x @ W_k                 # (1, 100, d)  ← 100 个位置的 K 同时算
V = x @ W_v                 # (1, 100, d)  ← 100 个位置的 V 同时算

cache_K = K                 # 全部 K 一次性塞进 cache
cache_V = V                 # 全部 V 一次性塞进 cache

# 标准 causal attention
out = softmax(Q @ K.T / √d + causal_mask) @ V

# 只用最后一个位置的输出预测第 101 个 token
关键点
Prefill 阶段 所有位置的 K、V 是一次矩阵乘法算出来的,不存在"历史 K、V 重算"这件事。所以 Prefill 阶段 KV Cache 不带来加速——它的角色是"建立 cache",不是"使用 cache"。

Decode:一个一个吐 token(串行、每步只算 1 个)

# x_new: (1, 1, d) ← 只有 1 个新 token 的 embedding
q     = x_new @ W_q          # (1, 1, d)  ← 只算 1 个 Q
k_new = x_new @ W_k          # (1, 1, d)  ← 只算 1 个 K
v_new = x_new @ W_v          # (1, 1, d)  ← 只算 1 个 V

# 把新 K、V 追加到 cache 尾部
cache_K = torch.cat([cache_K, k_new], dim=1)   # (1, T+1, d)
cache_V = torch.cat([cache_V, v_new], dim=1)   # (1, T+1, d)

# 注意:因为只有 1 个 Q,不需要 causal mask(它"过去"的全是有效的)
out = softmax(q @ cache_K.transpose(-1,-2) / √d) @ cache_V   # (1, 1, d)

两阶段对比

维度PrefillDecode
输入长度T(一整段 prompt)1(上一步刚生成的 token)
Q / K / V 计算T 个并行1 个
Attention 算法标准 causal attention1 个 Q 对全部 K、V
KV Cache 角色建立(写)使用(读 + 追加写)
计算瓶颈compute-boundmemory-bound(KV 读取)
单步耗时$\\mathcal{O}(T^2 d)$$\\mathcal{O}(T d)$
🔧 Infra 视角
Decode 是 memory-bound——单步计算量很小,但要把整段 KV Cache 从显存读出来。这意味着 LLM 推理优化的核心矛盾从"算力不够"变成"显存带宽不够"。这是后面 FlashAttention、PagedAttention、量化 KV 等所有 decode 优化的出发点。

7. 改 nanoGPT:手写一个 KV Cache 版 generate()

把上面的 idea 落到 Karpathy 的 nanoGPT 上。我们需要做三处改动:

  1. 改 CausalSelfAttention.forward,让它接受外部 cache 并能追加
  2. 改 Block.forward,把 cache 透传
  3. 改 GPT.generate,引入 prefill / decode 两阶段

7.1 改 Attention 层

class CausalSelfAttention(nn.Module):
    def __init__(self, n_embd, n_head, block_size):
        super().__init__()
        assert n_embd % n_head == 0
        self.n_head = n_head
        self.head_dim = n_embd // n_head
        self.qkv = nn.Linear(n_embd, 3 * n_embd, bias=False)
        self.proj = nn.Linear(n_embd, n_embd, bias=False)
        # 训练时还要 causal mask,这里略

    def forward(self, x, kv_cache=None):
        """
        x: (B, T_in, C)  Prefill 时 T_in = prompt 长度;Decode 时 T_in = 1
        kv_cache: None 表示 prefill;否则是 (cache_k, cache_v),每个 (B, n_head, T_past, head_dim)
        """
        B, T_in, C = x.shape
        qkv = self.qkv(x)                                 # (B, T_in, 3C)
        q, k, v = qkv.split(C, dim=2)                     # 各 (B, T_in, C)

        # reshape 成多头:(B, n_head, T_in, head_dim)
        q = q.view(B, T_in, self.n_head, self.head_dim).transpose(1, 2)
        k = k.view(B, T_in, self.n_head, self.head_dim).transpose(1, 2)
        v = v.view(B, T_in, self.n_head, self.head_dim).transpose(1, 2)

        # ====== KV Cache 核心:拼接历史 ======
        if kv_cache is not None:
            cache_k, cache_v = kv_cache                   # 每个 (B, n_head, T_past, head_dim)
            k = torch.cat([cache_k, k], dim=2)            # (B, n_head, T_past + T_in, head_dim)
            v = torch.cat([cache_v, v], dim=2)
        new_cache = (k, v)                                # 返回新的 cache

        # ====== Attention 计算 ======
        # 推理时 T_in 在 decode 阶段为 1,q 对全部 k 做注意力——自然只看到过去和当前,不需要 mask
        # Prefill 阶段 T_in > 1 且 cache 为 None,需要 causal mask
        att = (q @ k.transpose(-2, -1)) / (self.head_dim ** 0.5)   # (B, nh, T_in, T_total)
        if kv_cache is None and T_in > 1:
            mask = torch.tril(torch.ones(T_in, T_in, device=x.device))
            att = att.masked_fill(mask == 0, float('-inf'))
        att = F.softmax(att, dim=-1)
        y = att @ v                                                 # (B, nh, T_in, head_dim)
        y = y.transpose(1, 2).contiguous().view(B, T_in, C)
        return self.proj(y), new_cache

7.2 改 Block

class Block(nn.Module):
    def __init__(self, n_embd, n_head, block_size):
        super().__init__()
        self.ln1  = nn.LayerNorm(n_embd)
        self.attn = CausalSelfAttention(n_embd, n_head, block_size)
        self.ln2  = nn.LayerNorm(n_embd)
        self.ffn  = FeedForward(n_embd)   # 沿用 Ep.5

    def forward(self, x, kv_cache=None):
        attn_out, new_cache = self.attn(self.ln1(x), kv_cache=kv_cache)
        x = x + attn_out
        x = x + self.ffn(self.ln2(x))
        return x, new_cache

7.3 改 GPT.generate(核心)

@torch.no_grad()
def generate(self, idx, max_new_tokens):
    """
    idx: (B, T_prompt)  用户输入
    """
    B, T_prompt = idx.shape
    device = idx.device

    # ============ Prefill:一次性把整段 prompt 灌进去 ============
    tok_emb = self.tok_emb(idx)                         # (B, T_prompt, C)
    pos_emb = self.pos_emb(torch.arange(T_prompt, device=device))  # (T_prompt, C)
    x = tok_emb + pos_emb

    kv_caches = [None] * len(self.blocks)
    for i, block in enumerate(self.blocks):
        x, kv_caches[i] = block(x, kv_cache=None)        # 注意:prefill 时 cache 是 None

    x = self.ln_f(x)
    logits = self.lm_head(x[:, -1, :])                   # 只用最后一个位置预测
    probs = F.softmax(logits, dim=-1)
    idx_next = torch.multinomial(probs, num_samples=1)   # (B, 1)
    idx = torch.cat([idx, idx_next], dim=1)

    # ============ Decode:循环吐 token,每步只算 1 个 ============
    for step in range(max_new_tokens - 1):
        T_past = idx.shape[1] - 1                        # cache 里已有的长度

        tok_emb = self.tok_emb(idx_next)                 # (B, 1, C)
        pos_emb = self.pos_emb(torch.tensor([T_past], device=device))  # (1, C)
        x = tok_emb + pos_emb

        # 透传 cache,每个 block 更新自己的 cache
        for i, block in enumerate(self.blocks):
            x, kv_caches[i] = block(x, kv_cache=kv_caches[i])

        x = self.ln_f(x)
        logits = self.lm_head(x[:, -1, :])
        probs = F.softmax(logits, dim=-1)
        idx_next = torch.multinomial(probs, num_samples=1)
        idx = torch.cat([idx, idx_next], dim=1)

    return idx
实现要点
⚠ 实现陷阱
上面用 torch.cat 拼接 cache 是最朴素的写法,每次都会重新分配一块新显存,把旧 cache 拷过去。生产实现(包括 nanoGPT 后期的版本和 HuggingFace transformers)会预分配一个 max_len 的固定 buffer,然后只往里 in-place 写。再之后是 vLLM 的 PagedAttention——把这块 buffer 切成固定大小的 block,按需分配——这是下一篇的主题。

8. 显存代价:每 token 到底吃多少 KV

省时间不是白来的。每生成一个 token,所有层都要往 cache 写一份 K 和一份 V。

单 token 的 KV Cache 大小:

$$\\text{KV per token} = 2 \\times L \\times H \\times d_h \\times \\text{bytes}$$

其中 2 = K + V,L = 层数,H = 头数,$d_h$ = head_dim,bytes = 单元素字节数(fp16 是 2)。

用 LLaMA-7B 算一遍

项值
层数 L32
头数 H32
head_dim $d_h$128
精度fp16(2 字节)
单 token KV 大小2 × 32 × 32 × 128 × 2 = 524,288 字节 ≈ 512 KB
序列 2048 token≈ 1 GB
序列 8192 token≈ 4 GB
⚠ 数量级感
一张 24GB 的 RTX 4090,先扣掉模型权重 14GB(fp16 的 7B 模型),只剩 10GB 给 KV Cache。也就是说 4090 上跑 LLaMA-7B 同时最多支持的总 token 数大约 20K——这要分给所有并发请求。一个请求拉满 8K,你就只能再服务 1-2 个请求。

所以"减少 KV 显存"本身就是一个独立的大方向,分支出几条优化路线:

方向代表技术主要降低
结构层面MQA / GQA(多 Q 共享 KV)H 维度
量化KV Cache INT8 / FP8bytes 维度
显存管理PagedAttention消除碎片,提高利用率
共享Prefix Caching同 prompt 复用
外存KV Offload to CPU/SSD不在 GPU 上常驻

9. 引出 PagedAttention(下一篇的钩子)

我们刚刚的实现有一个隐藏问题:每个请求要预留多少 cache 显存?

这跟 1960 年代操作系统遇到的问题完全一样:每个进程要多少内存?连续分配浪费、按需分配碎片。OS 的解法是虚拟内存 + 分页。

vLLM 论文(PagedAttention,SOSP'23)做的事情:

把每个请求的 KV Cache 切成固定大小的 block(默认 16 个 token 一块) 显存上预分配一个全局 block pool 每个请求维护一张 "block table"(虚拟 → 物理映射,等价于 OS 的页表) attention kernel 接受 block table 作为间接寻址 → 物理上不连续的 block 可以服务一个逻辑上连续的序列 → 显存利用率从 ~30% 提升到 >90%
🔧 同构感知
PagedAttention 是 真的对应 OS 分页(这个类比不是硬凑——vLLM 论文一作 Woosuk Kwon 本人在 talk 里多次说过设计灵感就来自此): 下一篇博客 07《PagedAttention 源码解读 - 块管理篇》会逐行读 vllm/v1/core/block_pool.py。

10. ⭐ 学习过程高频卡点答疑(10 个 Q)

Q1:Q 为什么不需要 cache?
Decode 阶段每步只用当前位置的一个 Q(去查所有历史 K、V),历史 Q 算出来就丢,不会再被读到。而 K、V 每步都要被新的 Q 查,所以必须留下来。
Q2:Prefill 阶段为什么用不上 KV Cache 的加速?
Prefill 时 T 个 token 是一次矩阵乘法并行算出来的,本来就没有"重复算历史"这件事。KV Cache 加速的本质是消除 decode 阶段"每步重算历史 K、V"的浪费——Prefill 没有这个浪费可消。Prefill 阶段 KV Cache 的角色是"建立 cache",不是"使用 cache"。
Q3:BERT 这种双向 attention 能用 KV Cache 吗?
不能,两个原因都不满足:① 双向 attention 没有 causal 约束,每个位置的 K、V 会被未来位置查到,加新 token 时旧 token 的 attention 输出也变了;② BERT 是一次性编码整段文本(分类、抽取等任务),不做自回归生成,根本没有"一个一个吐"这件事,cache 的应用场景都不存在。
Q4:请求结束后 KV Cache 怎么处理?
必须立刻释放(归还到 block pool)。这是推理引擎调度器的核心职责之一——下下篇博客 08 讲调度器时会展开。
另外一种特殊情况是请求结束但前缀可被复用(同一个 System Prompt 多用户共享),这就是 Prefix Caching:vLLM 给每个 block 算 hash,新请求来了先查 hash,命中就直接挂载——KV Cache 的精确复用,不是粗略参照,K、V 数值必须完全一致才能用,否则 attention 会全乱。
Q5:KV Cache 拼接用 torch.cat 性能怎么样?为什么生产实现要预分配?
torch.cat 每次都重新分配显存 + 拷贝旧数据,是 $\\mathcal{O}(T)$ 的,N 步生成累加 $\\mathcal{O}(N^2)$ 的搬运量,长序列会暴露。生产实现预分配一个固定 buffer (B, n_head, max_len, head_dim),每步 in-place 写到 cache[:, :, T_past:T_past+1, :],但代价是每个请求都按 max_len 占显存,于是有了 PagedAttention 切块按需分配。
Q6:多个用户并发推理时,KV Cache 是各自独立还是共享?
默认完全独立,每个请求有自己的 cache。共享只在两种情况发生:① Prefix Caching(同前缀复用,必须前缀 token 完全一致);② Beam Search 同一请求的多个 beam 共享 prompt 部分(Copy-on-Write)。不同用户的语义 K、V 不可能"差不多就行"地复用——神经网络对数值精度敏感,错一点 attention 全错。
Q7:MQA / GQA 怎么减小 KV Cache?
标准 multi-head attention 每个头有自己的 K、V。MQA(Multi-Query Attention)让所有 Q 头共享一组 K、V → KV 显存除以 H。GQA(Grouped-Query Attention)介于两者间,比如 8 个 Q 头共享 1 组 KV → KV 显存除以 8。LLaMA-2 70B、LLaMA-3 都用 GQA。代价是模型质量略降,但工程上极其划算。
Q8:KV Cache 量化(INT8/FP8)会让模型变笨吗?
会但有限。fp16 → int8 单元素字节数减半,KV 显存直接除以 2。实测大部分场景困惑度只升高 1-3%。但有 outlier 通道问题(少数 channel 数值范围特别大,量化误差被放大),需要 per-channel 或 per-token 量化策略。这是 W8 主题(Week 7 进阶推理优化阶段)。
Q9:为什么说 decode 阶段是 memory-bound?
Decode 每步:① 算 1 个 Q、K、V(很小,~$d^2$ 量级 FLOPS);② 把整段历史 KV 从显存读出来做 attention(数据量 $\\mathcal{O}(T \\cdot d)$)。GPU 的算力远超显存带宽(A100 是 312TFLOPS vs 2TB/s),所以瓶颈在把 KV 读上来。这就是 FlashAttention 用 tile 化减少 HBM 读写、PagedAttention 通过更紧凑的内存布局提升带宽利用的根本动机。
Q10:我自己写的 KV Cache 版 nanoGPT,怎么验证它没写错?
最简单的方法:固定随机种子,同一个 prompt 同一个模型权重,跑朴素 generate 和 KV Cache generate,输出的 token 序列应该完全一致(在贪心解码或固定种子采样下)。如果对不上,99% 是位置编码的 bug——decode 阶段位置必须接续 prefill 末尾,不能从 0 开始。

11. 参考资料与 Colab


这是 Stage 1 / Week 1 的第一篇,对应"AI Infra 转型完整学习计划"的推理引擎核心阶段开端。
下一篇:博客 07 · PagedAttention 源码解读 - 块管理篇(逐行读 vllm/v1/core/block_pool.py)。