从 GPT-2 到 LLaMA · EP04

分组查询注意力

GQA

上一期我们把位置编码换成了 RoPE。这一期换的零件在注意力内部:多头注意力里 K 和 V 的头数。

GPT-2 用的是标准多头注意力(MHA):有几个 Query 头,就有几个 Key 头、几个 Value 头。LLaMA-2 70B、LLaMA-3、Mistral、Qwen2 这些模型,换成了 GQA(Grouped-Query Attention,分组查询注意力):Query 头数不变,K 和 V 的头数砍到几分之一,几个 Query 头共用一组 K、V。

这一期只回答三个问题:为什么要砍 K、V 的头?砍了以后注意力还怎么算?代码里改哪几行?


一、问题从哪来:推理时最贵的不是算,是搬

推理优化篇的 KV Cache 上下两期讲过:生成第 t 个 token 时,前面所有 token 的 K 和 V 不用重算,直接从缓存里读。

这解决了"重复计算",但留下一个新问题:缓存本身很大,而且每生成一个 token 都要把它完整读一遍。

先算一下 KV Cache 有多大。每个 token、每一层,要存 K 和 V 两份,每份是 头数 × head_dim 个数:

每个 token 的 KV Cache = 2 × 层数 × KV头数 × head_dim × 每个数的字节数

拿一个 LLaMA-2 70B 规模的模型代入(80 层,64 个头,head_dim=128,fp16 每个数 2 字节):

2 × 80 × 64 × 128 × 2 字节 = 2,621,440 字节 ≈ 2.5 MB / token

一条 4096 token 的对话,光 KV Cache 就是 10 GB。并发 8 个请求,80 GB,一张 A100 80G 直接装满,模型权重都没地方放。

更要命的是速度。解码阶段每一步只算一个 token,计算量很小,但要把这 10 GB 从显存读进计算单元。这时候 GPU 不是在等算,是在等搬数据。瓶颈是显存带宽,不是算力。

所以问题变成:能不能让 KV Cache 变小?

公式里能动的几项:层数和 head_dim 动了会伤模型容量;字节数可以靠量化压;剩下的就是 KV 头数。GQA 动的就是这一项。


二、先回顾:MHA 里每个头都有自己的 K、V

原系列多头注意力那一期讲过,MHA 是这样分工的:

Q = X @ W_q    → 切成 n_heads 份
K = X @ W_k    → 切成 n_heads 份
V = X @ W_v    → 切成 n_heads 份

第 i 个头:softmax(Q_i @ K_iᵀ / √head_dim) @ V_i

每个头有自己的 Q、K、V,三者头数相同,一一对应。

这里要分清 Q 和 K、V 的角色:

  • Q 是"我想找什么",代表当前 token 提问的角度。
  • K、V 是"可供查找的内容",K 用来被匹配,V 是匹配上之后取走的东西。

多个头的意义,是让模型从不同角度提问:一个头关注语法关系,一个头关注指代,一个头关注相邻词。

那么问题来了:提问角度需要很多个,但被查的内容真的也需要每个头一份吗?


三、从 MHA 到 MQA,再到 GQA

MQA:所有头共用一份 K、V

2019 年 Noam Shazeer(Transformer 原论文作者之一)在论文 Fast Transformer Decoding: One Write-Head is All You Need 里提出了最激进的做法:Multi-Query Attention(MQA)。

Q 照样有 n_heads 个头,但 K 和 V 只保留 1 个头,所有 Query 头都拿这一份去做注意力。

KV Cache 直接缩到原来的 1/n_heads。上面那个 70B 规模的例子,64 个头变 1 个头,10 GB 变 160 MB。

代价是:所有头被迫在同一份"内容"里查找,表达能力下降。实际训练中,MQA 模型质量会有可见的损失,训练也更容易不稳定。

GQA:折中,分组共用

2023 年 Google 的 Joshua Ainslie 等人在论文 GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints 里提出了折中方案:

把 Query 头分成 G 组,每组共用一份 K、V。

MHA:  Q 头 8 个,KV 头 8 个   每个 Q 头独占一份 KV
GQA:  Q 头 8 个,KV 头 2 个   每 4 个 Q 头共用一份 KV
MQA:  Q 头 8 个,KV 头 1 个   所有 Q 头共用一份 KV

用一张图看分组关系:

MHA   Q0 Q1 Q2 Q3 Q4 Q5 Q6 Q7
      |  |  |  |  |  |  |  |
      K0 K1 K2 K3 K4 K5 K6 K7

GQA   Q0 Q1 Q2 Q3   Q4 Q5 Q6 Q7
       \  |  |  /     \  |  |  /
          K0             K1

MQA   Q0 Q1 Q2 Q3 Q4 Q5 Q6 Q7
        \  \  \  |  |  /  /  /
                 K0

(图里只画了 K,V 的分组方式完全一样。)

这里有个很漂亮的统一视角:MHA 和 MQA 都是 GQA 的特例。

  • KV 头数 = Q 头数,就是 MHA
  • KV 头数 = 1,就是 MQA
  • 介于两者之间,就是 GQA

所以 GQA 不是一个全新的结构,而是给"KV 头数"这个旋钮开放了中间档位。


四、手算:猫吃鱼上的 GQA

还是熟悉的"猫吃鱼",d_model=4。

为了能在纸上算,这一期取 4 个 Query 头、2 个 KV 头,每组 2 个 Query 头共用一份 KV。这样 head_dim = 4 / 4 = 1,每个头的 q、k、v 都只是一个数。

head_dim=1 只是为了手算方便,真实模型的 head_dim 一般是 64 或 128。计算逻辑完全一样,只是标量乘法变成向量点积。

第 1 步:输入

X: [3, 4]

猫  [1, 0, 1, 0]
吃  [0, 1, 0, 1]
鱼  [1, 1, 0, 0]

第 2 步:投影矩阵的形状变了

这是 GQA 和 MHA 在结构上唯一的区别:

              MHA        GQA
W_q 形状     [4, 4]     [4, 4]     ← 4 个 Q 头 × head_dim 1,不变
W_k 形状     [4, 4]     [4, 2]     ← 只有 2 个 KV 头
W_v 形状     [4, 4]     [4, 2]     ← 只有 2 个 KV 头

W_q 的列数是 n_heads × head_dim = 4,W_k、W_v 的列数是 n_kv_heads × head_dim = 2。

本例取的权重:

W_q: [4, 4]                W_k: [4, 2]       W_v: [4, 2]
[ 1,  0,  0,   1]          [1, 0]            [1, 0]
[ 0, -1,  0.5, 0]          [0, 1]            [0, 0]
[ 0,  0,  1,   0]          [1, 0]            [0, 1]
[ 1,  0,  0,   0]          [0, 0]            [0, 1]

第 3 步:算 Q、K、V

Q = X @ W_q: [3, 4]               4 列 = 4 个 Q 头
         头0   头1   头2   头3
猫     [ 1,    0,    1,    1  ]
吃     [ 1,   -1,    0.5,  0  ]
鱼     [ 1,   -1,    0.5,  1  ]

K = X @ W_k: [3, 2]               2 列 = 2 个 KV 头
        组0  组1
猫     [ 2,   0 ]
吃     [ 0,   1 ]
鱼     [ 1,   1 ]

V = X @ W_v: [3, 2]
        组0  组1
猫     [ 1,   1 ]
吃     [ 0,   1 ]
鱼     [ 1,   0 ]

注意 K、V 只有 2 列。KV Cache 存的就是这两张表,MHA 下它们会是 4 列。

第 4 步:分组对应关系

Q 头 0、1  →  用 KV 组 0
Q 头 2、3  →  用 KV 组 1

规则是 kv_组号 = q_头号 // (n_heads / n_kv_heads),本例 n_heads / n_kv_heads = 2。

第 5 步:算「鱼」的注意力

「鱼」是最后一个 token,因果掩码下可以看到猫、吃、鱼三个位置。它的 q 是 [1, -1, 0.5, 1],四个头各取一个数。

head_dim=1,缩放因子 √1 = 1,下面省略。

头 0(q = 1,用组 0:k = [2, 0, 1],v = [1, 0, 1])

分数    = 1 × [2, 0, 1]      = [2, 0, 1]
exp     = [7.389, 1, 2.718]   和 = 11.107
权重    = [0.665, 0.090, 0.245]
输出    = 0.665×1 + 0.090×0 + 0.245×1 = 0.910

头 1(q = -1,同样用组 0:k = [2, 0, 1],v = [1, 0, 1])

分数    = -1 × [2, 0, 1]     = [-2, 0, -1]
exp     = [0.135, 1, 0.368]   和 = 1.503
权重    = [0.090, 0.665, 0.245]
输出    = 0.090×1 + 0.665×0 + 0.245×1 = 0.335

头 2(q = 0.5,用组 1:k = [0, 1, 1],v = [1, 1, 0])

分数    = 0.5 × [0, 1, 1]    = [0, 0.5, 0.5]
exp     = [1, 1.649, 1.649]   和 = 4.297
权重    = [0.233, 0.384, 0.384]
输出    = 0.233×1 + 0.384×1 + 0.384×0 = 0.616

头 3(q = 1,同样用组 1:k = [0, 1, 1],v = [1, 1, 0])

分数    = 1 × [0, 1, 1]      = [0, 1, 1]
exp     = [1, 2.718, 2.718]   和 = 6.437
权重    = [0.155, 0.422, 0.422]
输出    = 0.155×1 + 0.422×1 + 0.422×0 = 0.578

第 6 步:拼接

「鱼」的多头输出: [0.910, 0.335, 0.616, 0.578]   形状 [4]

之后乘 W_o(形状 [4, 4]),和 MHA 完全一样,这里不再展开。

看懂这组数字

把头 0 和头 1 放在一起看:

         用的 K          用的 V        注意力权重(猫, 吃, 鱼)
头 0     [2, 0, 1]      [1, 0, 1]     [0.665, 0.090, 0.245]   → 主要看「猫」
头 1     [2, 0, 1]      [1, 0, 1]     [0.090, 0.665, 0.245]   → 主要看「吃」

K、V 完全相同,但注意力分布完全不同。 头 0 主要看「猫」,头 1 主要看「吃」,原因只有一个:它们的 q 不同。

这就是 GQA 能成立的关键:

  • 共享的是"被查的内容"(K、V)
  • 不共享的是"提问的角度"(Q)

同组的头查的是同一份资料,但各自问自己的问题,得到的答案就不一样。多头"从不同角度看"的能力保住了大部分,损失的只是"每个头还能有一份专属资料"的那部分自由度。

本例的权重是故意挑的,让头 0 和头 1 的 q 正好一正一负,对比才这么鲜明。真实模型里同组的头不一定差这么多,但原理一样:q 不同,注意力分布就不同。

第 7 步:缓存省了多少

本例 3 个 token,每层要缓存的数字个数:

MHA:  K 3×4 + V 3×4 = 24 个数
GQA:  K 3×2 + V 3×2 = 12 个数     → 1/2
MQA:  K 3×1 + V 3×1 =  6 个数     → 1/4

换回 70B 规模的例子:64 个 Q 头,GQA 取 8 个 KV 头,每组 8 个 Q 头共用,KV Cache 缩到 1/8:

MHA:  每 token 2.5 MB,4096 token 共 10 GB
GQA:  每 token 320 KB,4096 token 共 1.25 GB

这就是 LLaMA-2 70B 的实际配置:64 个 Q 头,8 个 KV 头。


五、一个类比:资料室和调查员

把注意力想成一个调查小组:

  • Q 头是调查员,每人带着自己的问题。
  • K、V 是资料室,K 是目录,V 是档案本身。

MHA:每个调查员配一间专属资料室。最灵活,但资料室数量 = 调查员数量,每次开工所有资料室都要开门搬档案。

MQA:所有调查员挤一间资料室。省地方,但资料室只能按一种方式整理,有些调查员的问题就是查不顺手。

GQA:调查员分成几组,每组一间资料室。同组的人问题各不相同,但共用一份按组整理的档案。资料室少了很多,每组的档案又还能有自己的侧重。

推理时真正的成本是"每生成一个 token 都要把所有资料室的档案搬一遍"。资料室越少,搬得越快。


六、训练好的 MHA 模型能直接改成 GQA 吗?

GQA 论文的标题后半句是 from Multi-Head Checkpoints,它的另一个贡献是:不用从头训练,可以把已有的 MHA 模型"改造"成 GQA。

做法分两步:

1. 合并 KV 头:组内取平均。 比如 8 个 KV 头要合成 2 个,就把第 03 个头的 W_k 投影取平均,作为新的第 0 个 KV 头;第 47 个头取平均,作为新的第 1 个。W_v 同理。W_q 和 W_o 不动。

2. 继续训练一小段(论文叫 uptraining)。 合并之后模型会有点"懵",再用原预训练计算量的约 5% 继续训练,让它适应新结构。

论文对比过几种合并方式:组内取平均、只保留每组第一个头、随机初始化。取平均效果最好,直觉上也合理,平均保留了组内所有头的信息。

论文在 T5 上的实验结论:8 个 KV 头的 GQA,质量接近原始 MHA,推理速度接近 MQA。这正是它想要的"两头都占一点"。

不过要说清楚:现在主流的大模型(LLaMA-3、Qwen2 等)基本是一开始就按 GQA 结构训练的,uptraining 主要是论文提出时用来证明可行性、以及低成本改造已有模型的手段。


七、为什么不直接用 MQA,省得更多?

既然 MQA 能省到 1/n_heads,为什么主流模型大多停在 8 个 KV 头左右?

1. 质量。 上面说过,MQA 质量损失更明显。GQA 的 KV 头从 1 加到几个,质量回升很快,而缓存只是线性增加。实践中 8 个左右是常见的平衡点。

2. 多卡切分。 大模型推理常用张量并行,把注意力头切到多张卡上。8 个 KV 头配 8 卡并行,每张卡正好分到 1 个 KV 头,干净利落。MQA 只有 1 个 KV 头,没法切,只能每张卡复制一份,省下的缓存在多卡场景下又被吃回去一部分。

3. 模型越大,MQA 砍得越狠。 64 个头的模型用 MQA 是砍到 1/64,信息瓶颈比 12 个头的小模型严重得多。GQA 可以按模型规模选 KV 头数,伸缩更灵活。


八、代码:改哪几行

和原系列的多头注意力相比,改动集中在三处:W_k / W_v 的输出维度、K/V 的 reshape、以及计算前把 K/V "复制"到和 Q 一样多的头。

import torch
import torch.nn as nn
import torch.nn.functional as F


def repeat_kv(x, n_rep):
    """
    把 KV 头扩展到和 Q 头一样多。
    x: [B, n_kv_heads, T, head_dim]
    返回: [B, n_kv_heads * n_rep, T, head_dim]
    排列方式: kv0, kv0, kv1, kv1, ...(同一个 KV 头连续重复)
    """
    if n_rep == 1:
        return x
    B, H_kv, T, D = x.shape
    x = x[:, :, None, :, :].expand(B, H_kv, n_rep, T, D)  # [B, H_kv, n_rep, T, D]
    return x.reshape(B, H_kv * n_rep, T, D)


class GroupedQueryAttention(nn.Module):
    def __init__(self, d_model, n_heads, n_kv_heads):
        super().__init__()
        assert d_model % n_heads == 0
        assert n_heads % n_kv_heads == 0, "Q 头数必须是 KV 头数的整数倍"

        self.n_heads = n_heads
        self.n_kv_heads = n_kv_heads
        self.head_dim = d_model // n_heads
        self.n_rep = n_heads // n_kv_heads          # 每个 KV 头被几个 Q 头共用

        self.W_q = nn.Linear(d_model, n_heads * self.head_dim, bias=False)
        self.W_k = nn.Linear(d_model, n_kv_heads * self.head_dim, bias=False)  # 变小了
        self.W_v = nn.Linear(d_model, n_kv_heads * self.head_dim, bias=False)  # 变小了
        self.W_o = nn.Linear(n_heads * self.head_dim, d_model, bias=False)

    def forward(self, x):
        B, T, _ = x.shape

        q = self.W_q(x).view(B, T, self.n_heads, self.head_dim).transpose(1, 2)
        k = self.W_k(x).view(B, T, self.n_kv_heads, self.head_dim).transpose(1, 2)
        v = self.W_v(x).view(B, T, self.n_kv_heads, self.head_dim).transpose(1, 2)
        # q: [B, n_heads,    T, head_dim]
        # k: [B, n_kv_heads, T, head_dim]
        # v: [B, n_kv_heads, T, head_dim]

        # 如果用 RoPE(上一期),在这里对 q、k 做旋转
        # 如果用 KV Cache,在这里缓存 k、v —— 注意缓存的是 n_kv_heads 个头,省显存就省在这

        k = repeat_kv(k, self.n_rep)   # [B, n_heads, T, head_dim]
        v = repeat_kv(v, self.n_rep)   # [B, n_heads, T, head_dim]

        scores = q @ k.transpose(-2, -1) / (self.head_dim ** 0.5)   # [B, n_heads, T, T]
        mask = torch.triu(torch.ones(T, T, dtype=torch.bool, device=x.device), diagonal=1)
        scores = scores.masked_fill(mask, float("-inf"))
        weights = F.softmax(scores, dim=-1)

        out = weights @ v                                            # [B, n_heads, T, head_dim]
        out = out.transpose(1, 2).contiguous().view(B, T, -1)        # [B, T, d_model]
        return self.W_o(out)

两个要点:

1. 缓存发生在 repeat_kv 之前。 KV Cache 存的是原始的 n_kv_heads 个头,复制只在计算时临时做。如果先复制再缓存,GQA 就白做了。

2. repeat_kv 这种写法会真的复制出一份大张量,教学版为了清晰这么写。生产环境里的注意力内核(比如后面要讲的 FlashAttention)会直接按分组去读 KV,不需要把它展开。

用手算的数字验证

把上面手算用的权重塞进去,看「鱼」的四个头输出是不是 [0.910, 0.335, 0.616, 0.578]:

torch.manual_seed(0)
attn = GroupedQueryAttention(d_model=4, n_heads=4, n_kv_heads=2)

X = torch.tensor([[[1., 0, 1, 0],
                   [0., 1, 0, 1],
                   [1., 1, 0, 0]]])            # [1, 3, 4]

W_q = torch.tensor([[1., 0, 0, 1],
                    [0., -1, 0.5, 0],
                    [0., 0, 1, 0],
                    [1., 0, 0, 0]])
W_k = torch.tensor([[1., 0], [0., 1], [1., 0], [0., 0]])
W_v = torch.tensor([[1., 0], [0., 0], [0., 1], [0., 1]])

# nn.Linear 存的是 [out, in],手算写的是 [in, out],要转置
with torch.no_grad():
    attn.W_q.weight.copy_(W_q.T)
    attn.W_k.weight.copy_(W_k.T)
    attn.W_v.weight.copy_(W_v.T)
    attn.W_o.weight.copy_(torch.eye(4))       # W_o 设为单位矩阵,直接看拼接结果

out = attn(X)
print(out[0, 2])   # 「鱼」的输出
# tensor([0.9100, 0.3351, 0.6157, 0.5779], ...)

W_o 设成单位矩阵,输出就等于四个头的拼接结果,可以直接和手算对照。

一个加载权重的坑:repeat 和 repeat_interleave

复制 KV 头时,顺序必须是:

正确:kv0, kv0, kv1, kv1      → Q 头 0、1 用 kv0,Q 头 2、3 用 kv1
错误:kv0, kv1, kv0, kv1      → Q 头 0、2 用 kv0,Q 头 1、3 用 kv1

repeat_kv 里的 expand 写法、或者 torch.repeat_interleave(k, n_rep, dim=1),得到的是正确的顺序。而 k.repeat(1, n_rep, 1, 1) 得到的是错误的顺序。

用错了代码不会报错,形状完全一样,但加载 LLaMA 预训练权重后,每个 Q 头都对上了别人的 KV,输出就是一堆乱码。这和原系列里 Conv1D 转置、上一期 RoPE 相邻配对 vs 前后半配对是同一类坑:形状对了不代表语义对了。

和之前每一期一样要说明:随机初始化的权重没有任何语义,上面的模块在加载预训练权重之前,输出的数字都没有意义。


九、这一期的要点

  1. GQA 解决的是推理问题。 解码阶段的瓶颈是读 KV Cache 的显存带宽,KV 头数直接决定缓存大小。
  2. 只减 K、V 的头,不减 Q 的头。 提问角度保持多样,被查的内容分组共享。同组的头 K、V 相同,但因为 q 不同,注意力分布照样不同。
  3. MHA 和 MQA 是 GQA 的两个端点。 KV 头数 = Q 头数是 MHA,= 1 是 MQA,中间档位就是 GQA。
  4. 代码改动很小。 W_k、W_v 输出维度变小,计算前用 repeat_kv 扩展;缓存要在扩展之前做;扩展顺序必须是连续重复。

下期预告

进阶篇「从 GPT-2 到 LLaMA」到这里,四个核心零件都换完了:

GPT-2                  →   现代 LLM
LayerNorm              →   RMSNorm      (EP1)
GELU FFN               →   SwiGLU       (EP2)
可学习绝对位置向量      →   RoPE         (EP3)
MHA                    →   GQA          (EP4)

下一期把它们装回一起,做一次 LLaMA 总览:一个 LLaMA Block 和 GPT-2 Block 逐行对比,看这四个零件分别装在哪个位置,再看 LLaMA 的配置文件里每个参数对应前面哪一期。


参考论文

  • Shazeer, N. (2019). Fast Transformer Decoding: One Write-Head is All You Need.
  • Ainslie, J., et al. (2023). GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints. EMNLP 2023.