qinghuan
← 回到知识探索

AI 直觉 / 可下载小项目

几个注意力头共用 K/V,为什么它们仍给出不同答案?

手算四个查询头与两组 K/V,识别形状完全正确却分组错位的实现,再单独计算缓存数据量。

展开本文目录
  1. 下载完整练习包
  2. 共享资料,保留不同问题
  3. 同样大小的列表,也可能接错头
  4. 先用显式展开作容易检查的参照
  5. 缓存少一半,只是这份数据量的计算
  6. 同时检查数值和头布局
  7. 可选:与一次真实 SDPA 调用逐头对照
  8. 形状之外,还要核对头编号的含义
  9. 记住这些要点
  10. 别把例子用过头
  11. 自己试一小步
  12. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
分组共享 K/V
几个查询头查同一份索引和资料,但各自保留自己的查询,因此仍可能关注不同位置。
头到组的映射
明确规定每个查询头应该读取哪一组 K/V;形状相同也可能接错资料。
打开完整术语小抄 ↗

两个读者查同一份索引、看同一份资料,仍可能得到不同重点,因为他们的问题不同。分组查询注意力也是如此:多个查询头共用 K/V,并不意味着查询向量也被合并。

我们只做一层中一个位置的注意力计算:四个 Q 头、两组 K/V、三个可见位置。除了展示共享后的不同输出,还故意把连续分组写成循环分组,观察一种形状检查发现不了的错误。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Windows / Python 3.14.0。可选框架脚本需要已有 PyTorch;本机 2.9.1+cpu 的 CPU MATH 配置实测成功,不能保证所有版本/后端支持相同 GQA 调用。基础脚本仍无第三方依赖。下方命令运行基础 Python 标准库示例;可选对照的环境和命令见对应 README。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 11 个文件
包里有什么
  • gqa_groups.py:完整可运行示例
  • README.md:运行说明与独立练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和产物验证记录
  • SHA256SUMS.txt:内容摘要
  • gqa_torch_cpu.py:可选框架对照配套文件
  • README-gqa-framework.md:可选框架对照配套文件
  • observed-gqa.stdout.txt:可选框架对照配套文件
  • gqa-torch-report.json:可选框架对照配套文件
  • framework-gqa-verification.json:可选框架对照配套文件
  • supplement-verification.json:附加实现的独立复跑记录

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B gqa_groups.py

解压到独立练习文件夹,在含 gqa_groups.py 的目录打开终端运行。基础为固定 Q/K/V 的标准库逐头注意力、组映射与逻辑缓存预算;额外在 PyTorch 2.9.1+cpu 的 CPU MATH 配置核对 SDPA 输出。

未加载模型、未执行 GPU、未测缓存分配或吞吐。 压缩包还附可选 PyTorch 对照;请按 README-*-framework.md 使用已安装 PyTorch 的解释器运行。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

共享资料,保留不同问题

普通多头注意力 MHA 通常让每个查询头拥有自己的 K/V 头;MQA 让所有查询头共用一组 K/V;GQA 位于两者之间,把查询头划成组,每组共用一组 K/V。这里讨论的是头之间的参数与缓存关系。

对每个查询头,仍独立计算 softmax(Q_h K_gᵀ/√d) V_g。即使组号 g 相同,只要 Q_h 不同,权重和输出就可能不同。本例不包含输出投影、旋转位置编码、dropout 或模型训练,只把这一段算清楚。

四个查询向量依次为 [1,0]、[0,1]、[1,1]、[−1,1]。每组 K/V 都有三个二维向量,完整数值在源码里。头 0 与头 1 共用组 0,但第一个重点看第一维,第二个重点看第二维。

同样大小的列表,也可能接错头

本文采用连续分组约定:四个 Q 头、两个 K/V 头,每组包含两个 Q 头,映射为 [0,0,1,1]。公式为 group(h)=floor(h/(Hq/Hkv))。前提是头数均为正,且 Hq 能被 Hkv 整除。

一个常见写错方式是 h%Hkv,得到 [0,1,0,1]。两种映射都产生四份合法的 K/V 引用,向量维度也完全一样,但头 1 与头 2 换了资料。程序打印的输出距离差分别约为 10.693375、11.364179;头 0 与头 3 因为刚好没换组而不变。

这不是说循环分组在所有架构中都不合法,而是它不符合本例与所对照接口的头布局。若模型权重与头排序同时按另一种约定一致地设计,可以有不同布局;错误是只改消费端映射,却沿用原来的权重与排列。

先用显式展开作容易检查的参照

为了核对,我们按组顺序把每组 K/V 连续重复两次,得到与四个查询头一一对应的四份引用,再逐头执行普通注意力。结果必须与按 group(h) 取 K/V 的实现相同。

PyTorch scaled_dot_product_attention 的 enable_gqa 说明代码展示了沿头维 repeat_interleave 的语义,以及查询头数和 K/V 头数的约束。基础实验用 Python 列表展开作参照;后面的可选框架对照另外实际调用了 2.9.1+cpu 的该接口。两种对照都没有测量实际缓存分配,不能据此断言优化内核必须复制缓存。

同组的头 0 输出约为 [0.996063,0.708020],头 1 为 [0.993020,1.255235]。它们共用 K/V 但输出不同,这直接反驳“两个查询头共用 K/V 后就等于只剩一个查询头”的误读。

缓存少一半,只是这份数据量的计算

设批大小 B、缓存长度 L、每头维度 D、K/V 头数 Hkv,每个元素 b 字节。一层 K 加 V 的逻辑数据量是 2×B×Hkv×L×D×b。前面的 2 对应 K 和 V,不是查询头数。

本例预算取 B=2、L=128、D=64、FP16 每元素 2 字节。四个 K/V 头的 MHA 为 262144 字节,两个 K/V 头的 GQA 为 131072 字节,一个 K/V 头的 MQA 为 65536 字节。保持其他量不变,GQA 这部分恰好是 MHA 的一半。

这里没有计入模型权重、查询激活、注意力临时量、缓存块元数据、内存对齐或分配器预留,也没有统计所有层。因此不能把一半的 K/V 数据量写成总显存减半,更不能直接写成速度翻倍。

同时检查数值和头布局

保存为 gqa_groups.py,运行 python -B gqa_groups.py。它打印每个头的注意力权重、输出、错误映射距离和三种逻辑缓存数据量,核对显式展开等价性,并把报告写入 report.json。

边界检查包含 Hkv=Hq 时每头独立,Hkv=1 时所有头共用,以及 Hq=5、Hkv=2 时拒绝无法整除的连续等组配置。三个可见位置都视为当前查询允许读取的前缀,本例没有另加因果遮罩。

展开收起代码 · 小实验python · 71 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""One-query grouped attention: explicit KV grouping and logical cache payload."""
import json
import math
from pathlib import Path


def attention(query, keys, values):
    if not keys or len(keys) != len(values):
        raise ValueError('K/V lengths must match and be nonempty.')
    if any(len(key) != len(query) for key in keys):
        raise ValueError('Q/K dimensions must match.')
    scores = [sum(q*k for q,k in zip(query,key))/math.sqrt(len(query)) for key in keys]
    exp_scores = [math.exp(score-max(scores)) for score in scores]
    weights = [score/sum(exp_scores) for score in exp_scores]
    output = [sum(w*value[d] for w,value in zip(weights,values)) for d in range(len(values[0]))]
    return {'weights':weights,'output':output}


def groups(query_heads, kv_heads):
    if query_heads <= 0 or kv_heads <= 0 or query_heads % kv_heads:
        raise ValueError('Positive query head count must be divisible by KV head count.')
    return [head//(query_heads//kv_heads) for head in range(query_heads)]


def cache_payload(batch, kv_heads, length, head_dimension, element_bytes):
    return 2 * batch * kv_heads * length * head_dimension * element_bytes


def main():
    queries = [[1,0],[0,1],[1,1],[-1,1]]
    keys = [[[1,0],[0,1],[-1,0]], [[0,1],[1,0],[0,-1]]]
    values = [[[1,0],[0,2],[3,1]], [[10,0],[0,20],[30,10]]]
    mapping = groups(4,2)
    wrong_mapping = [head%2 for head in range(4)]
    outputs = [attention(q,keys[g],values[g]) for q,g in zip(queries,mapping)]
    wrong = [attention(q,keys[g],values[g]) for q,g in zip(queries,wrong_mapping)]
    expanded_keys = [key for key in keys for _ in range(2)]
    expanded_values = [value for value in values for _ in range(2)]
    repeated = [attention(q,k,v) for q,k,v in zip(queries,expanded_keys,expanded_values)]
    assert outputs == repeated
    assert outputs[0]['output'] != outputs[1]['output']
    print(f'contiguous KV mapping={mapping}; cyclic mapping={wrong_mapping}')
    changed = []
    for head,(correct,incorrect) in enumerate(zip(outputs,wrong)):
        delta = math.dist(correct['output'],incorrect['output'])
        if delta > 1e-12:
            changed.append(head)
        print(f'head {head}: weights=' + ','.join(f'{v:.6f}' for v in correct['weights'])
              + '; output=' + ','.join(f'{v:.6f}' for v in correct['output']) + f'; cyclic error={delta:.6f}')
        assert math.isclose(sum(correct['weights']),1.0,abs_tol=1e-12)
    assert changed == [1,2]
    payload = {name:cache_payload(2,heads,128,64,2) for name,heads in [('MHA',4),('GQA',2),('MQA',1)]}
    print('logical K+V payload, B=2 L=128 D=64 FP16: '+', '.join(f'{k}={v} bytes' for k,v in payload.items()))
    print(f"GQA/MHA payload={payload['GQA']/payload['MHA']:.6f}; no speed measurement")
    assert groups(4,4) == [0,1,2,3]
    assert groups(4,1) == [0,0,0,0]
    try:
        groups(5,2)
    except ValueError:
        pass
    else:
        raise AssertionError('Non-divisible groups must fail.')
    report = {'mapping':mapping,'cyclic_mapping':wrong_mapping,'heads':outputs,'cyclic_heads':wrong,
        'heads_changed_by_wrong_mapping':changed,'logical_payload_bytes':payload,
        'scope':'Fixed Q/K/V and one query per head; no trained model, GPU kernel, full cache allocator or speed benchmark.'}
    Path('report.json').write_text(json.dumps(report,indent=2,allow_nan=False)+'\n',encoding='utf-8')
    print('checks passed: expanded-KV equivalence, distinct Q outputs, grouping counterexample, boundary counts')


if __name__ == '__main__':
    main()

怎样运行:在独立练习文件夹中执行 python -B gqa_groups.py。2026-10-17 已在 Windows、Python 3.14.0 运行此份源码并通过自带检查。固定 Q/K/V 的逐头注意力、组映射反例与逻辑缓存预算;未加载模型、未运行框架或 GPU、未测吞吐。

运行后对照结果
contiguous KV mapping=[0, 0, 1, 1]; cyclic mapping=[0, 1, 0, 1]
head 0: weights=0.575975,0.283995,0.140029; output=0.996063,0.708020; cyclic error=0.000000
head 1: weights=0.248255,0.503490,0.248255; output=0.993020,1.255235; cyclic error=10.693375
head 2: weights=0.445808,0.445808,0.108383; output=7.709586,10.000000; cyclic error=11.364179
head 3: weights=0.672842,0.163579,0.163579; output=11.635791,4.907373; cyclic error=0.000000
logical K+V payload, B=2 L=128 D=64 FP16: MHA=262144 bytes, GQA=131072 bytes, MQA=65536 bytes
GQA/MHA payload=0.500000; no speed measurement
checks passed: expanded-KV equivalence, distinct Q outputs, grouping counterexample, boundary counts

可选:与一次真实 SDPA 调用逐头对照

我们另用已有的 PyTorch 2.9.1+cpu 实际执行 scaled_dot_product_attention(enable_gqa=True),显式选择 CPU、float64、单线程和 MATH 后端。Q 形状为 [1,4,1,2],K/V 形状为 [1,2,3,2];仍是四个查询头、两组资料、一个查询位置与三个可见位置。

本次 GQA 输出与连续 repeat_interleave 后的普通 SDPA,以及显式矩阵公式逐项一致,观察到的最大绝对差为 0。故意改用循环组序后,仍只改变头 1 与头 2。这里的零差异是这次固定输入的结果,不是跨硬件、精度或内核都逐位相同的承诺。

2.9 官方文档将 GQA 标为实验性,并在支持说明中列出 CUDA 限制;本机这个 2.9.1+cpu 构建的固定调用实际成功。我们把文档约定与本地观察分别记录,不把一次成功扩展成普遍的 CPU 支持保证。

进一步核读 v2.9.1 源码,math 分支会把 enable_gqa 继续传下去,按查询头与 K/V 头数量的比例连续重复 K/V,再计算点积、Softmax 和加权和。这为本次观察提供了实现层面的解释;某个版本的代码分支并不替代官方支持范围或其他平台的实测。

gqa_torch_cpu.py 可放在基础脚本旁边,用已有 PyTorch 的解释器执行,另写 gqa-torch-report.json。它明确设 dropout_p=0、is_causal=False;没有加载模型、生成文本、测量真实缓存分配、执行 GPU 或做速度比较。

展开收起代码 · 小实验python · 52 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Small actual CPU SDPA comparison for a fixed contiguous GQA head layout."""
import json
import math
import platform
import sys
from pathlib import Path
import torch
from torch.nn import functional as F
from torch.nn.attention import SDPBackend, sdpa_kernel

torch.set_num_threads(1)
DTYPE = torch.float64


def main():
    q = torch.tensor([[[[1.,0.]],[[0.,1.]],[[1.,1.]],[[-1.,1.]]]],dtype=DTYPE,device='cpu')
    k = torch.tensor([[[[1.,0.],[0.,1.],[-1.,0.]],[[0.,1.],[1.,0.],[0.,-1.]]]],dtype=DTYPE,device='cpu')
    v = torch.tensor([[[[1.,0.],[0.,2.],[3.,1.]],[[10.,0.],[0.,20.],[30.,10.]]]],dtype=DTYPE,device='cpu')
    expanded_k = k.repeat_interleave(2,dim=1)
    expanded_v = v.repeat_interleave(2,dim=1)
    manual = torch.softmax(q @ expanded_k.transpose(-2,-1)/math.sqrt(2),dim=-1) @ expanded_v
    with sdpa_kernel(backends=[SDPBackend.MATH]):
        grouped = F.scaled_dot_product_attention(q,k,v,dropout_p=0.0,is_causal=False,enable_gqa=True)
        expanded = F.scaled_dot_product_attention(q,expanded_k,expanded_v,dropout_p=0.0,is_causal=False)
        cyclic_index = torch.tensor([0,1,0,1],device='cpu')
        cyclic = F.scaled_dot_product_attention(q,k[:,cyclic_index],v[:,cyclic_index],dropout_p=0.0,is_causal=False)
    manual_error = (grouped-manual).abs().max().item()
    expanded_error = (grouped-expanded).abs().max().item()
    assert manual_error < 1e-12 and expanded_error < 1e-12
    per_head = torch.linalg.vector_norm((grouped-cyclic).squeeze(0).squeeze(1),dim=-1)
    changed = [i for i,value in enumerate(per_head.tolist()) if value > 1e-12]
    assert changed == [1,2]
    assert not torch.allclose(grouped[:,0],grouped[:,1],rtol=1e-12,atol=1e-12)
    print(f'Python {platform.python_version()}; torch {torch.__version__}; device=cpu; dtype=float64; backend=MATH; threads=1')
    print(f'Q shape={list(q.shape)}; K/V shape={list(k.shape)}')
    for head,values in enumerate(grouped.squeeze(0).squeeze(1).tolist()):
        print(f'head {head}: output='+','.join(f'{value:.6f}' for value in values)
              + f'; cyclic error={per_head[head].item():.6f}')
    print(f'GQA vs explicit repeat max error={expanded_error:.3e}; vs manual formula={manual_error:.3e}')
    print('checks passed: actual enable_gqa CPU call, explicit-repeat equivalence, distinct shared-KV heads, cyclic counterexample')
    report = {'python':sys.version,'executable':sys.executable,'torch':torch.__version__,
        'torch_path':torch.__file__,'device':'cpu','dtype':'float64','backend_requested':'SDPBackend.MATH',
        'threads':torch.get_num_threads(),'q_shape':list(q.shape),'kv_shape':list(k.shape),
        'grouped':grouped.tolist(),'expanded':expanded.tolist(),'manual':manual.tolist(),'cyclic':cyclic.tolist(),
        'manual_max_error':manual_error,'expanded_max_error':expanded_error,'cyclic_head_errors':per_head.tolist(),
        'changed_heads':changed,
        'scope':'Actual CPU PyTorch SDPA with enable_gqa on fixed Q/K/V, one query, no mask and zero dropout; no weights, generation, GPU kernel, cache allocation measurement or speed benchmark.'}
    Path('gqa-torch-report.json').write_text(json.dumps(report,indent=2,allow_nan=False)+'\n',encoding='utf-8')


if __name__ == '__main__':
    main()

怎样运行:python -B gqa_torch_cpu.py

运行后对照结果
Python 3.14.0; torch 2.9.1+cpu; device=cpu; dtype=float64; backend=MATH; threads=1
Q shape=[1, 4, 1, 2]; K/V shape=[1, 2, 3, 2]
head 0: output=0.996063,0.708020; cyclic error=0.000000
head 1: output=0.993020,1.255235; cyclic error=10.693375
head 2: output=7.709586,10.000000; cyclic error=11.364179
head 3: output=11.635791,4.907373; cyclic error=0.000000
GQA vs explicit repeat max error=0.000e+00; vs manual formula=0.000e+00
checks passed: actual enable_gqa CPU call, explicit-repeat equivalence, distinct shared-KV heads, cyclic counterexample

形状之外,还要核对头编号的含义

接入实际模型时,Q/K/V 投影权重如何排列、分组扩展沿哪一维、缓存记录了哪些头、位置编码如何应用,都需要与模型约定一致。只检查维度和不出现异常,无法证明注意力语义正确。

基础实验运行固定向量的标准库计算,后面的可选对照补充了一次固定张量的 PyTorch CPU 算子验证。没有加载 Transformer 权重、运行 GPU 内核或测量吞吐与模型质量。原论文讨论的转换训练和实际速度结果不属于本例已经复现的内容。

记住这些要点

  • GQA 共享 K/V,查询头仍各自计算权重和输出。
  • 头数和张量形状正确,也可能把组映射写错。
  • 连续展开的参照实现有助于核对布局语义。
  • K/V 逻辑数据量下降不等于总显存或耗时同比下降。

别把例子用过头

  • 共用 K/V 就把同组的查询或输出也平均掉。
  • 把 floor(h/group_size) 误写成 h%Hkv。
  • 把教学中的显式重复当成实际优化内核必须复制缓存。
  • 仅据逻辑缓存公式宣称某模型推理提速。

自己试一小步

把六个查询头分给三组 K/V,怎样证明你的缓存布局与头映射一致?

  1. 为每组 V 加不同的明显标记,使接错组容易发现。
  2. 分别实现按组索引与连续显式展开两个版本。
  3. 有意交换两个 K/V 组但不交换映射,观察哪些头变化。
  4. 再同步交换映射,验证恢复原输出。

做到这些,就算完成

  • 每个头的输出逐项比较,而不只比较整体均值。
  • 说明 Q 头排列、K/V 组排列及映射约定。
  • 缓存预算独立于实际性能报告。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。