分组查询注意力
P5613. 分组查询注意力
场次:2026年10月10日 · 来源:CodeFun2000 P5613
题目描述
分组查询注意力(GQA)是标准多头注意力的变体,核心思想是让多个 Query 头共享一组 KV 头,在保证模型效果的同时降低计算开销。请基于缩放点积注意力实现分组查询注意力。
核心数学逻辑
GQA 由基础缩放点积注意力扩展而来,关键差别是头的分组共享:
- 将 Query 矩阵拆分为 个独立的 Query 头子矩阵,记为 。
- 将 Key、Value 矩阵拆分为 个独立的 KV 头子矩阵,记为 与 。
- 必须是 的整数倍。每组包含 个 Query 头,共享 1 组 KV 头:
- 第 组: 共享 。
- 第 组: 共享 。
- 第 组: 共享 。
- 对每组内的每个 Query 头,与共享的 KV 头做基础缩放点积注意力,得到该 Query 头的输出。
- 将全部 Query 头的输出按顺序拼接,得到最终的 GQA 输出矩阵。
基础缩放点积注意力要求
单组 按下列规则计算。
输入输出
输入:、、、。
输出:。
核心公式
当 时 ,否则 。实现中 可取 。
Softmax(逐行)
计算步骤
- 转置:对 转置得到 。
- 手动矩阵乘法:。
- 缩放:。
- 掩码:若 ,则 。
- Softmax:按上述公式逐行计算注意力权重 。
- 加权求和:。
算法实现流程
步骤1:输入矩阵拆分
- 原始 的形状为 ,其中 。
- 原始 的形状为 ,其中 。
- 原始 的形状为 ,其中 。
拆分规则:
- 拆成 个子矩阵,每个形状为 。
- 拆成 个子矩阵,每个形状为 。
- 拆成 个子矩阵,每个形状为 。
步骤2:分组计算注意力
- 每组 Query 头数为 ,即 除以 后向下取整。
- 对第 组():
- 取该组 KV 头 。
- 取该组 个 Query 头 。
- 对每个 Query 头 计算 。
步骤3:拼接输出
将 按列拼接,得到最终输出矩阵,形状为 ,即序列长度 ×(Query 头数 × 单个 Value 头维度)。
输入格式
第一行: 。
字段含义依次为:序列长度、Query 头总数、Key/Value 头总数、单个 Query/Key 头维度、单个 Value 头维度。
- :Query 头总数,必须是 的整数倍。
- :Key 与 Value 的头总数,二者始终相等。
- :单个 Query 头与单个 Key 头的特征维度,二者相同。
- :单个 Value 头的特征维度。
接下来 行:原始 Query 矩阵 ,形状为 ,每行 个浮点数。
接下来 行:原始 Key 矩阵 ,形状为 ,每行 个浮点数。
接下来 行:原始 Value 矩阵 ,形状为 ,每行 个浮点数。
接下来 行:掩码矩阵,形状为 。 表示可见, 表示屏蔽;若全为 ,则表示无掩码。
输入约束:
- ,,且
- 矩阵元素取值范围:
输出格式
输出一个二维浮点数矩阵,表示分组查询注意力的计算结果。
形状:(序列长度 ×(Query 头数 × 单个 Value 头维度))。
输出格式:共 行,每行 个浮点数,每个数保留 位小数。
示例
示例 1
输入:
2 4 2 1 1
1.0 0.0 0.0 1.0
0.0 1.0 1.0 0.0
0.0 1.0
1.0 0.0
2.0 4.0
8.0 6.0
0 0
0 0输出:
6.39 5.00 5.00 4.54
5.00 6.39 4.54 5.00说明:
,,,,,故 。掩码全为 ,。
第 组 共享 ;第 组 共享 。按列拆开后:
- 两行为 , 两行为 , 两行为 , 两行为 。
- 两行为 , 两行为 , 两行为 , 两行为 。
得分相差 时,较大位置的 Softmax 权重为 ,较小位置为 。两行得分相同时,权重各为 。
- :第 行得分为 ,输出 ,保留两位小数为 ;第 行得分为 ,输出 。
- :第 行得分为 ,输出 ;第 行得分为 ,输出 。
- :第 行得分为 ,输出 ;第 行得分为 ,输出 ,保留两位小数为 。
- :第 行得分为 ,输出 ;第 行得分为 ,输出 。
四个头按列拼接,即得到输出。
示例 2
输入:
2 2 2 2 1
1.0 0.0 0.0 1.0
0.0 1.0 1.0 0.0
1.0 0.0 0.0 1.0
0.0 1.0 1.0 0.0
2.0 6.0
4.0 8.0
0 1
0 0输出:
2.00 6.00
3.34 7.34说明:
,,,,,故 。 只使用 , 只使用 。。掩码第 行第 列为 ,该位置在缩放之后改为 。
两行是 与 , 两行是 与 , 两行是 。点积矩阵两行分别为 与 ,缩放后第 行约为 ;掩码使第二项为 ,权重为 ,输出 。第 行缩放后约为 ,权重约为 ,输出 ,保留两位小数为 。
两行是 与 , 两行是 与 , 两行是 。缩放后的得分与 相同,掩码也相同,因此第 行输出 ,第 行输出 ,保留两位小数为 。
两头按列拼接,即得到输出。
解题思路
第一步:理解问题本质
先看单头缩放点积注意力在做什么:
- 的第 行是“位置 想找什么”, 的第 行是“位置 能提供什么线索”,两者的点积就是匹配分数;
- 除以 是缩放。 越大,点积的方差越大,softmax 会迅速变成“几乎只看分数最高的那一个”,梯度也随之消失;
- 表示位置 不允许看位置 ,做法是给分数加 ,让它的权重变成 ;
- softmax 是逐行做的:每个位置对所有位置分配一组和为 的权重;
- 最后用这组权重对 的各行加权求和,得到这个位置的新表示。
多头注意力把这个过程并行做 次,每个头用自己的 、、,最后把各头的输出拼起来。
分组查询注意力(GQA)只改一处:KV 头比 Q 头少。设
则第 组是 共享 ,第 组是 共享 ,依此类推。
这样做的动机很实际:推理时每个已生成的 token 都要缓存它的 、(KV cache),缓存大小正比于 KV 头数。让 个 Query 头共享一组 KV,缓存和 KV 投影的计算量都降到 ,而 Query 头数不变,表达能力基本保留。
两个极端情况可以帮助理解:
- (即 ):退化成标准多头注意力 MHA;
- :所有 Query 头共享同一组 KV,即 MQA。
本题不涉及训练,也不涉及参数的随机初始化,输入输出和每一步公式都给定,所以本质是“按维度把公式翻译成循环”。难点不在算法,而在维度对齐。
第二步:暴力解法:每个头现算
最直接的做法是不预先拆头,每次要用到某个头时再从整行里切片:
for h in range(num_q_heads):
kv = h // g # 这个 Query 头属于哪一组 KV
for i in range(n):
for j in range(n):
# 每次都要从整行里切出第 h 个头的 d_k 个分量
scores[j] = dot(Q[i][h*d_k:(h+1)*d_k], K[j][kv*d_k:(kv+1)*d_k]) / sqrt(d_k)
...这个写法结果正确,但有两个浪费:
- 切片发生在 次内层循环里,同一个头的列被反复截取;
- 同一组 KV 被 个 Query 头各用一遍,、 的列切片要重复取 次。
第三步:优化解法:先拆头,再复用 KV
把三件事提到循环外面:
1. 预拆分。 输入矩阵的每一行是一个位置的全部头拼接起来的特征,所以拆头是按列切:
q_heads = split_heads(q_mat, num_q_heads, d_k) # num_q_heads 个 (n, d_k)
k_heads = split_heads(k_mat, num_kv_heads, d_k) # num_kv_heads 个 (n, d_k)
v_heads = split_heads(v_mat, num_kv_heads, d_v) # num_kv_heads 个 (n, d_v)第 个头占据列区间 ,切完就是一张 的独立矩阵,后面所有循环都只在这个小矩阵里跑。
2. 组内复用。 第 个 Query 头对应的 KV 组号是 ,同一个组里的 个头用的是同一对 :
kv_index = h // group_size3. 跳过零权重。 被掩码屏蔽的位置权重是 ,加权求和时可以直接 continue,省掉一次长度为 的内层循环。
计算顺序也固定下来,一次成型:
点积 -> 除以 sqrt(d_k) -> 掩码置 -1e30 -> 逐行 softmax -> 乘 V第四步:完整实现细节
1. 各个矩阵的形状
| 名称 | 形状 | 说明 |
|---|---|---|
| 每行是一个位置的全部 Query 头 | ||
| 第 个 Query 头 | ||
| 每行是一个位置的全部 Key 头 | ||
| 第 个 KV 组的 Key | ||
| 每行是一个位置的全部 Value 头 | ||
| 第 个 KV 组的 Value | ||
| 、 | 分数矩阵与注意力权重矩阵 | |
| 第 个 Query 头的输出 | ||
| 最终输出 | 各头按列拼接 |
注意输出的宽度是 ,与输入的 宽度 不一定相同,因为 和 是两个独立的参数。
2. 掩码的位置
掩码是在缩放之后、softmax 之前生效的。顺序不能换:
- 先缩放再加 ,与“先加 再缩放”在结果上等价( 缩放仍是 ),但和 softmax 的先后不能错;
- 如果错写成“softmax 之后再清零权重”,分母里已经算进了被屏蔽项的指数,其余权重之和不再是 ,结果会偏小。
实现里用题目建议的 代替 :
if mask_i[j] == 1:
scores[j] = -1e30这样在减最大值、取指数之后,被屏蔽项的指数是 ,直接下溢成 ,不会出现 nan。
3. softmax 的数值稳定性
逐行减去该行最大值再取指数:
分子分母同乘 ,结果不变,但指数的输入不会超过 ,不可能上溢。
4. 全屏蔽行的行为
如果某一行的所有位置都被屏蔽,这一行的分数全是 ,减去最大值后全为 ,权重退化成 。这是用 哨兵值的必然结果(用真正的 会算出 )。本题的正常数据每行都有可见位置,这一条只作为边界认知。
5. 拼接顺序
第 个头的 个分量占据输出的第 到 列,顺序与输入中头的编号一致。拼接顺序和拆头顺序必须互为逆操作,否则投影矩阵的列会对不上。
6. 输出格式
每个数保留 位小数。浮点结果可能是 这种绝对值很小但符号为负的数,格式化前把 归零,避免输出 -0.00。
完整代码实现
import math
import sys
MASKED = -1e30 # 用题面建议的 -1e30 代替 -inf,避免 nan 传播
def softmax_row(scores):
"""对一行分数做 softmax。
先减去该行最大值:exp 的输入不会超过 0,避免上溢;
这一步只是把分子分母同时除以同一个常数,不改变结果。
"""
row_max = max(scores)
exps = [math.exp(s - row_max) for s in scores]
total = sum(exps)
return [e / total for e in exps]
def single_head_attention(q, k, v, mask, n, d_k, d_v, scale):
"""单头缩放点积注意力:Attention(Q, K, V, mask),返回 (n, d_v)。"""
output = []
for i in range(n):
qi = q[i]
mask_i = mask[i]
# 第 1、2 步:手动矩阵乘法 S[i][j] = Q[i] · K[j],再除以 sqrt(d_k)
scores = []
for j in range(n):
kj = k[j]
dot = 0.0
for t in range(d_k):
dot += qi[t] * kj[t]
scores.append(dot / scale)
# 第 4 步:掩码在缩放之后生效,被屏蔽的位置直接压成 -inf(这里用 -1e30)
for j in range(n):
if mask_i[j] == 1:
scores[j] = MASKED
# 第 5 步:逐行 softmax
weights = softmax_row(scores)
# 第 6 步:用注意力权重对 V 加权求和,得到 d_v 维输出
row = [0.0] * d_v
for j in range(n):
w = weights[j]
if w == 0.0:
continue # 被屏蔽位置的权重是 0,跳过可以省掉一次内层循环
vj = v[j]
for t in range(d_v):
row[t] += w * vj[t]
output.append(row)
return output
def split_heads(matrix, num_heads, width):
"""把 (n, num_heads * width) 的矩阵按列切成 num_heads 个 (n, width) 子矩阵。"""
return [
[row[h * width:(h + 1) * width] for row in matrix]
for h in range(num_heads)
]
def format_float(value):
"""保留 2 位小数,并把 -0.00 归一成 0.00。"""
if abs(value) < 0.005:
value = 0.0
return f"{value:.2f}"
def solve():
"""读取输入、计算分组查询注意力并按要求输出。"""
tokens = sys.stdin.buffer.read().split()
if not tokens:
return
pos = 0
n, num_q_heads, num_kv_heads, d_k, d_v = (int(x) for x in tokens[pos:pos + 5])
pos += 5
def read_matrix(rows, cols, cast=float):
"""从当前位置读取 rows x cols 的矩阵。"""
nonlocal pos
mat = []
for _ in range(rows):
mat.append([cast(x) for x in tokens[pos:pos + cols]])
pos += cols
return mat
q_total = num_q_heads * d_k # Q 每行的宽度
k_total = num_kv_heads * d_k # K 每行的宽度
v_total = num_kv_heads * d_v # V 每行的宽度
q_mat = read_matrix(n, q_total)
k_mat = read_matrix(n, k_total)
v_mat = read_matrix(n, v_total)
mask = read_matrix(n, n, cast=int)
# 按列拆头:Q 有 num_q_heads 个、K/V 各有 num_kv_heads 个
q_heads = split_heads(q_mat, num_q_heads, d_k)
k_heads = split_heads(k_mat, num_kv_heads, d_k)
v_heads = split_heads(v_mat, num_kv_heads, d_v)
scale = math.sqrt(d_k) # 缩放因子,d_k 越大点积的量级越大
group_size = num_q_heads // num_kv_heads # 每组共享同一对 KV 的 Query 头数
# 每个 Query 头独立算一次注意力,KV 头按组复用
head_outputs = []
for h in range(num_q_heads):
kv_index = h // group_size # 第 h 个 Query 头属于第 h // g 组
head_outputs.append(
single_head_attention(
q_heads[h], k_heads[kv_index], v_heads[kv_index],
mask, n, d_k, d_v, scale,
)
)
# 按头编号拼接:位置 i 的输出 = [头 0 的 d_v 维 | 头 1 的 d_v 维 | ...]
for i in range(n):
row = []
for h in range(num_q_heads):
row.extend(head_outputs[h][i])
print(" ".join(format_float(x) for x in row))
if __name__ == "__main__":
solve()NumPy 版本
以下版本使用 NumPy。只有在评测环境允许第三方库时才能提交;CodeFun2000 的普通 Python 环境不一定安装 NumPy。
import sys
import numpy as np
MASKED = -1e30 # 用题面建议的 -1e30 代替 -inf,避免 nan 传播
def gqa(q_mat, k_mat, v_mat, mask, num_q_heads, num_kv_heads, d_k, d_v):
"""分组查询注意力:一次算一整组 Query 头,返回 (n, num_q_heads * d_v)。
所有头都按「列」拆:reshape 成 (n, heads, width) 后把 heads 维提到前面,
得到 (heads, n, width) 的三维数组,后面直接做批量矩阵乘法。
"""
n = q_mat.shape[0]
q_heads = q_mat.reshape(n, num_q_heads, d_k).transpose(1, 0, 2) # (hq, n, dk)
k_heads = k_mat.reshape(n, num_kv_heads, d_k).transpose(1, 0, 2) # (hkv, n, dk)
v_heads = v_mat.reshape(n, num_kv_heads, d_v).transpose(1, 0, 2) # (hkv, n, dv)
scale = np.sqrt(d_k) # 缩放因子,d_k 越大点积的量级越大
group_size = num_q_heads // num_kv_heads # 每组共享同一对 KV 的 Query 头数
blocked = (mask == 1) # 布尔掩码,(n, n)
head_outputs = []
for m in range(num_kv_heads):
# 第 m 组的 g 个 Query 头编号连续,共享 K_m、V_m
q_group = q_heads[m * group_size:(m + 1) * group_size] # (g, n, dk)
k_group = k_heads[m] # (n, dk)
v_group = v_heads[m] # (n, dv)
# 批量打分 + 缩放:(g, n, dk) @ (dk, n) -> (g, n, n),g 个头一起算
scores = q_group @ k_group.T / scale
# 掩码在缩放之后、softmax 之前生效,被屏蔽的位置压成 -1e30
scores = np.where(blocked, MASKED, scores)
# 逐行 softmax:先减该行最大值,指数输入不会超过 0,不会上溢
scores = scores - scores.max(axis=2, keepdims=True)
weights = np.exp(scores)
weights /= weights.sum(axis=2, keepdims=True)
# 用注意力权重对 V 加权求和:(g, n, n) @ (n, dv) -> (g, n, dv)
head_outputs.append(weights @ v_group)
# 各组头的编号本身就是连续的,直接按头维拼起来再还原成按位置的行
out = np.concatenate(head_outputs, axis=0) # (hq, n, dv)
return out.transpose(1, 0, 2).reshape(n, num_q_heads * d_v)
def format_float(value):
"""保留 2 位小数,并把 -0.00 归一成 0.00。"""
if abs(value) < 0.005:
value = 0.0
return f"{value:.2f}"
def solve():
"""读取输入、计算分组查询注意力并按要求输出。"""
tokens = sys.stdin.buffer.read().split()
if not tokens:
return
pos = 0
n, num_q_heads, num_kv_heads, d_k, d_v = (int(x) for x in tokens[pos:pos + 5])
pos += 5
def read_matrix(rows, cols, dtype):
"""从当前位置读取 rows x cols 的矩阵。"""
nonlocal pos
mat = np.array(tokens[pos:pos + rows * cols], dtype=dtype).reshape(rows, cols)
pos += rows * cols
return mat
q_total = num_q_heads * d_k # Q 每行的宽度
k_total = num_kv_heads * d_k # K 每行的宽度
v_total = num_kv_heads * d_v # V 每行的宽度
q_mat = read_matrix(n, q_total, np.float64)
k_mat = read_matrix(n, k_total, np.float64)
v_mat = read_matrix(n, v_total, np.float64)
mask = read_matrix(n, n, np.int64)
output = gqa(q_mat, k_mat, v_mat, mask, num_q_heads, num_kv_heads, d_k, d_v)
for row in output:
print(" ".join(format_float(x) for x in row))
if __name__ == "__main__":
solve()这一版的三个关键点:
- 拆头用 reshape + transpose:一行里前 个数是头 0、接着 个是头 1……所以
reshape(n, heads, width)之后第 1 维就是头编号,transpose(1, 0, 2)把它换到最前面,得到 的三维数组。这一步是纯视图变换,不拷贝数据; - 批量矩阵乘法:第 组一次取 个 Query 头,
q_group @ k_group.T直接算出 的分数矩阵,weights @ v_group又把它压回 , 个头的打分与加权求和都只走一次 BLAS。掩码用np.where(blocked, MASKED, scores)广播到整组头上,softmax 用max(axis=2, keepdims=True)逐行减最大值,语义与纯 Python 版完全相同; - 拼接:各组头的编号连续,
np.concatenate(head_outputs, axis=0)得到的就是 ,再transpose(1, 0, 2).reshape(n, h_q\cdot d_v)还原成每个位置一行。顺序与纯 Python 版按列拼接一致。
另外要说明一点精度:这里的点积交给 BLAS,它可能用 FMA(乘加合并成一次舍入)并分块累加,与纯 Python 里逐个 dot += qi[t] * kj[t] 的舍入顺序不完全一样,分数可能相差 量级。这点差别经过 softmax 后仍在 量级,本题输出只保留 2 位小数,实测与纯 Python 版逐字一致。
示例推演
样例 1:4 个 Query 头共享 2 组 KV
本题 、、、、,于是 ,,掩码全为 ,输出宽度 。
按列拆头。 的两行是 与 :
| 头 | 的两行 |
|---|---|
的两行是 与 ,所以 是列 0 即 , 是列 1 即 。
的两行是 与 ,所以 是 , 是 。
第 0 组( 共享 )。 分数只差 时,较大位置的权重是
较小位置是 。
- 第 0 行:分数 与 ,权重 ,输出 ,保留两位小数为
6.39; - 第 1 行: 分量是 ,两个分数都是 ,权重各 ,输出 ;
- 第 0 行:分量是 ,输出 ;
- 第 1 行:分量是 ,分数 与 ,输出 。
第 1 组( 共享 )。 是 , 是 :
- 第 0 行:分量 ,两个分数都是 ,输出 ;
- 第 1 行:分量 ,分数 与 ,权重 ,输出 ,保留两位小数为
4.54; - 第 0 行:分量 ,输出 ;
- 第 1 行:分量 ,输出 。
按头拼接。 第 0 行依次是头 的第 0 行:
6.39 5.00 5.00 4.54第 1 行同理:
5.00 6.39 4.54 5.00样例 2:掩码如何生效
,所以 ,每个 Query 头独占一组 KV。掩码是 ,即位置 0 不能看位置 1。
的两行是 、; 的两行也是 、; 是 。
- 第 0 行:点积是 与 ,除以 得 与 。第 1 列被屏蔽,分数变 ,归一化后权重是 ,输出 。
- 第 1 行:点积 与 ,缩放后 与 ,这一行没有屏蔽。权重为
输出 ,保留两位小数为 3.34。
的列与 相反(两行是 、), 的两行也是 、, 是 。缩放后的分数与掩码情况完全一样,因此第 0 行输出 ,第 1 行输出 ,即 7.34。
拼接后就是样例输出:
2.00 6.00
3.34 7.34复杂度分析
| 步骤 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 按列拆头 | 同左 | 每个元素只搬一次 | |
| 单头打分与缩放 | 为 | ||
| 单头 softmax | 每行一次 | ||
| 单头加权求和 | 屏蔽项可跳过 | ||
| 全部 个头 | KV 被同组 个头共享,但打分仍按 Query 头各自进行 | ||
| 拼接输出 | 逐行拼列 |
、、,最坏计算量约为 次乘加,非常小。空间上最大的开销是每个头的 分数矩阵,逐头计算、算完即弃,所以峰值只有一份。
易错点总结
1. 拆头是按列切,不是按行切
输入的一行是同一个位置的所有头拼在一起。所以第 个头取的是每行的第 到 列。按行切会把不同位置的同一个头当成一个头,结果完全错乱。
2. 组号是
每组恰好 个 Query 头,且编号连续。所以第 个头用第 组 KV。写成 在 时恰好也对, 时就会错位。
3. 缩放因子是
用单个 Query/Key 头的维度 开方,不是 ,也不是 ,更不是直接除以 。示例 2 里 ,所以除 。
4. 掩码必须在 softmax 之前
被屏蔽的位置要先变成 ,softmax 之后它的权重才会是 。如果在 softmax 之后再把权重清零,分母仍然包含这些位置的指数,剩下的权重加起来小于 ,输出会整体偏小。
5. softmax 是逐行归一化
对第 个位置(也就是 的第 行)在整个序列方向上归一化,方向是列下标 。如果误按列归一化,就变成“每个 key 对所有 query 归一化”,语义完全变了。
6. 用 而不是真的
真正的 在“减最大值”时会得到 ,结果是 nan,整行输出都会坏掉。用 时减完是 ,指数是 或下溢成 ,行为稳定。
7. 拼接顺序与拆头顺序必须互逆
输出第 到 列来自第 个头。拼反了(比如按 KV 组拼接)在 时看不出来, 时结果全错。
8. 三块矩阵的宽度各不相同
每行 个数, 每行 个数, 每行 个数。三者从同一个 token 流里顺序读取,只要有一个宽度写错,后面的掩码就会整体错位,现象往往是“输出全是 0 或者每行都一样”。源码用 read_matrix(n, 宽度) 显式指定列数,就是为了避免这种串行。
9. 输出格式
保留 位小数;负零要归零,避免 -0.00。另外 与 可能不同,输出宽度是 ,不要按输入宽度输出。
扩展思考
- 三个特例: 是 MHA, 是 MQA,中间状态就是 GQA。工程上常取 或 ,在效果和显存之间折中。
- KV cache 的收益:推理时每个 token 的 KV 缓存大小是 (乘上层数)。GQA 把 缩小 倍,长上下文场景下省下的显存非常可观。
- 因果掩码:自回归模型用 的上三角掩码,保证位置 只能看到自己和左边。本题的掩码是任意给定的,逻辑上更一般。
- 不稳定的 softmax:这里逐行存下 的分数矩阵。 很大时可以分块计算、边算边累加指数和(FlashAttention 的思路),把空间从 降到 。
- 精度:本题数值范围小,双精度足够。真实模型里 softmax 的输入可能到几十,减最大值这一步不能省。