开始前,会这些就够了
- 知道 Q、K、V 在注意力中的基本作用
- 知道 Softmax 与向量点积
- 能读嵌套列表和索引
- 需要补基础时:先把 Q、K、V 算清楚
- 需要补基础时:再补多头注意力与掩码
遇到不熟悉的词,先看这 2 个解释
打开完整术语小抄 ↗两个读者查同一份索引、看同一份资料,仍可能得到不同重点,因为他们的问题不同。分组查询注意力也是如此:多个查询头共用 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 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
同样大小的列表,也可能接错头
本文采用连续分组约定:四个 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 时拒绝无法整除的连续等组配置。三个可见位置都视为当前查询允许读取的前缀,本例没有另加因果遮罩。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""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 或做速度比较。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""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,怎样证明你的缓存布局与头映射一致?
- 为每组 V 加不同的明显标记,使接错组容易发现。
- 分别实现按组索引与连续显式展开两个版本。
- 有意交换两个 K
/V组但不交换映射,观察哪些头变化。 - 再同步交换映射,验证恢复原输出。
做到这些,就算完成
- 每个头的输出逐项比较,而不只比较整体均值。
- 说明 Q 头排列、K
/V组排列及映射约定。 - 缓存预算独立于实际性能报告。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints (新标签页)↗
GQA 的分组共享定义以及 MHA、GQA、MQA 的关系。
- Fast Transformer Decoding: One Write-Head is All You Need (新标签页)↗
保持查询头、共享 K
/V的 MQA 结构与缓存访问动机。 - PyTorch 2.14:scaled_dot_product_attention (新标签页)↗
enable_gqa的repeat_interleave语义与头数约束。 - PyTorch 2.9:SDPA(此次 CPU 实测对应版本) (新标签页)↗
额外对照的
enable_gqa、repeat_interleave、头数约束、MATH 控制、dropout 与实验性支持说明。 - PyTorch v2.9.1:SDPA math 分支的固定版本源码 (新标签页)↗
核对 math 分支传递
enable_gqa,以及按头维连续展开 K/V的实现。