minimind/course/11_attention_principles_and...

741 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 第 11 课:Attention 原理与源码
这一课只解决一个问题:一个 token 如何通过 Attention 从上下文 token 里选择并汇总信息,然后仍然回到 `[batch, seq, hidden_size]` 主干。
## 目录
- [0. 本节主线](#l11-mainline)
- [1. 本节要懂的 5 个层次](#l11-levels)
- [2. Attention 一次完整原理讲解](#l11-complete-principle)
- [3. Attention.forward 一次完整源码走读](#l11-forward-walkthrough)
- [4. 源码变量字典](#l11-variable-dict)
- [5. 关键源码对照](#l11-source-map)
- [6. 实验验证](#l11-experiment)
- [7. 本节检查](#l11-check)
- [8. 下一课](#l11-next)
<a id="l11-mainline"></a>
## 0. 本节主线
Attention 的完整逻辑是:
```text
每个 token 先有自己的 hidden 向量
-> 每个 hidden 向量生成 Q/K/V 三种角色
-> 当前 token 的 Q 和所有可见 token 的 K 计算匹配分数
-> causal mask 把未来 token 屏蔽掉
-> softmax 把分数变成注意力权重
-> 用注意力权重对所有可见 token 的 V 加权求和
-> 得到当前 token 汇总上下文后的新表示
-> 所有 token、所有 head 并行做这件事
-> 合并多头并用 o_proj 回到 hidden_size
```
一句话:
```text
Q/K 决定看谁,V 决定拿什么,softmax 决定拿多少,causal mask 决定不能看未来,o_proj 决定回到主干。
```
这节课分成两大块:
```text
先完整讲一遍 Attention 原理;
再完整走一遍 MiniMind 的 Attention.forward 源码。
```
<a id="l11-levels"></a>
## 1. 本节要懂的 5 个层次
| 层次 | 要理解什么 | 源码位置 |
|---|---|---|
| Attention 解决什么问题 | token 需要从上下文 token 里取信息 | `model/model_minimind.py:178-194` |
| 一次完整 Attention 怎么算 | Q/K 打分,softmax,汇总 V | `model/model_minimind.py:111-134` |
| Q/K/V 从哪里来 | 同一份 hidden_states 投影成三种角色 | `model/model_minimind.py:91-116` |
| mask 和多头怎么进来 | causal mask 禁止看未来,多头并行做 attention | `model/model_minimind.py:123-131` |
| 怎么回到主干 | repeat_kv 对齐 K/V heads,o_proj 输出回 hidden_size | `model/model_minimind.py:86-89`, `model/model_minimind.py:132-134` |
学完本节,你应该能不看源码说出一遍 Attention 的完整计算过程,然后再能对着 `Attention.forward` 逐行解释每个变量的 shape 和作用。
<a id="l11-complete-principle"></a>
## 2. Attention 一次完整原理讲解
先不要看源码。我们用一个序列来理解 Attention:
```text
位置: 0 1 2 3
token: A B C D
```
假设现在看位置 2,也就是 token `C`。
因为这是 causal language model,`C` 只能看:
```text
A, B, C
```
不能看未来的 `D`。
### 2.1 每个 token 先有 hidden 向量
第 9 课讲过,进入 block 时,每个 token 都已经有自己的 hidden 向量:
```text
h_A, h_B, h_C, h_D
```
这些向量都在同一个 hidden 空间里,比如每个都是 64 维或 768 维。
如果模型只对每个 `h_i` 单独做 MLP,它就不能直接知道别的 token 里有什么信息。Attention 要补上的就是:
```text
让 h_C 能从 h_A、h_B、h_C 里按需取信息。
```
### 2.2 为什么要有 Q/K/V
Attention 把每个 hidden 向量投影成三种角色:
```text
q_i = W_Q h_i
k_i = W_K h_i
v_i = W_V h_i
```
对 token `C` 来说:
```text
q_C:C 当前想找什么信息
k_A/k_B/k_C:A/B/C 各自提供什么可匹配的特征
v_A/v_B/v_C:A/B/C 真正能贡献给 C 的内容
```
所以 Q/K/V 不是三个不同输入,而是同一批 hidden 向量的三种投影角色:
```text
Q/K 用来决定“看谁”;
V 用来提供“拿什么”。
```
### 2.3 Q 和 K 怎么决定“看谁”
`C` 要决定看 A、B、C 各多少,就用 `q_C` 分别和它们的 key 做点积:
```text
score(C, A) = q_C · k_A / sqrt(head_dim)
score(C, B) = q_C · k_B / sqrt(head_dim)
score(C, C) = q_C · k_C / sqrt(head_dim)
```
点积越大,表示越匹配。这里的分数不是概率,只是未归一化的匹配分数。
除以 `sqrt(head_dim)` 是为了缩放分数尺度。否则 `head_dim` 越大,点积越容易变得很大,softmax 会过早变得极端。
### 2.4 causal mask 为什么出现
如果不加限制,`C` 也会和 `D` 算:
```text
score(C, D)
```
但训练语言模型时,位置 `C` 不能提前看到未来 token `D`。否则第 10 课讲的 next-token prediction 就会作弊。
所以 causal mask 会把未来位置变成:
```text
score(C, D) = -inf
```
softmax 之后:
```text
weight(C, D) = 0
```
也就是未来 token 完全不参与信息汇总。
### 2.5 softmax 后为什么乘 V
对 A、B、C 的分数经过 softmax 后,变成权重:
```text
weight(C, A), weight(C, B), weight(C, C)
```
这些权重表示:
```text
C 应该从 A/B/C 各拿多少信息。
```
然后真正汇总的是 value:
```text
new_C =
weight(C, A) * v_A
+ weight(C, B) * v_B
+ weight(C, C) * v_C
```
这就是 Attention 的核心公式:
```text
Attention(Q, K, V) = softmax(QK^T / sqrt(head_dim) + mask) V
```
### 2.6 从一个 token 推广到所有 token
上面只讲了 `C` 如何看 A/B/C。实际模型会对每个位置都做同样的事:
```text
A 看 A
B 看 A/B
C 看 A/B/C
D 看 A/B/C/D
```
所以 attention scores 会形成一个矩阵:
```text
[query_position, key_position]
```
如果有 batch 和 heads,就变成:
```text
[batch, heads, query_seq, key_seq]
```
这就是为什么实验里 `scores.shape=(2, 4, 5, 5)`。
### 2.7 多头 attention 是什么
如果只用一个 head,模型只有一种“看上下文”的方式。
多头 attention 是把 hidden 向量拆成多个子空间:
```text
hidden_size = num_heads * head_dim
```
每个 head 都独立算一套:
```text
QK 分数 -> softmax -> 加权 V
```
最后把多个 head 的结果合并,再映射回 `hidden_size`。
所以多头不是多了几层,也不是复制 batch;它是在 hidden 维度内部并行做多个 attention 视角。
### 2.8 这一节原理到此为止
到这里你应该已经有完整观念:
```text
Attention 不是神秘模块;
它就是让每个 token 用 Q 去匹配可见 token 的 K,
再按匹配权重汇总这些 token 的 V。
```
下一步再看源码,源码只是把这套过程用张量并行实现。
<a id="l11-forward-walkthrough"></a>
## 3. Attention.forward 一次完整源码走读
这一节先完整走一遍源码,不拆成零散片段。
### 源码证据 A:Attention.forward 全流程
文件:`model/model_minimind.py:111-134`
看它是为了理解:MiniMind 如何把上一节的完整 Attention 原理实现成张量计算。
源码:
```python
def forward(self, x, position_embeddings, past_key_value=None, use_cache=False, attention_mask=None):
bsz, seq_len, _ = x.shape
xq, xk, xv = self.q_proj(x), self.k_proj(x), self.v_proj(x)
xq = xq.view(bsz, seq_len, self.n_local_heads, self.head_dim)
xk = xk.view(bsz, seq_len, self.n_local_kv_heads, self.head_dim)
xv = xv.view(bsz, seq_len, self.n_local_kv_heads, self.head_dim)
xq, xk = self.q_norm(xq), self.k_norm(xk)
cos, sin = position_embeddings
xq, xk = apply_rotary_pos_emb(xq, xk, cos, sin)
if past_key_value is not None:
xk = torch.cat([past_key_value[0], xk], dim=1)
xv = torch.cat([past_key_value[1], xv], dim=1)
past_kv = (xk, xv) if use_cache else None
xq, xk, xv = (xq.transpose(1, 2), repeat_kv(xk, self.n_rep).transpose(1, 2), repeat_kv(xv, self.n_rep).transpose(1, 2))
if self.flash and (seq_len > 1) and (not self.is_causal or past_key_value is None) and (attention_mask is None or torch.all(attention_mask == 1)):
output = F.scaled_dot_product_attention(xq, xk, xv, dropout_p=self.dropout if self.training else 0.0, is_causal=self.is_causal)
else:
scores = (xq @ xk.transpose(-2, -1)) / math.sqrt(self.head_dim)
if self.is_causal: scores[:, :, :, -seq_len:] += torch.full((seq_len, seq_len), float("-inf"), device=scores.device).triu(1)
if attention_mask is not None: scores += (1.0 - attention_mask.unsqueeze(1).unsqueeze(2)) * -1e9
output = self.attn_dropout(F.softmax(scores.float(), dim=-1).type_as(xq)) @ xv
output = output.transpose(1, 2).reshape(bsz, seq_len, -1)
output = self.resid_dropout(self.o_proj(output))
return output, past_kv
```
这段源码可以分成 8 步读。
### 第 1 步:确认输入 shape
```python
bsz, seq_len, _ = x.shape
```
这里的 `x` 是 block 传进 Attention 的 hidden states,来自:
```python
self.input_layernorm(hidden_states)
```
它的 shape 是:
```text
[batch, seq, hidden_size]
```
这一步只取出 batch 和 seq 长度,为后面 reshape 做准备。
### 第 2 步:从 x 投影出 Q/K/V
```python
xq, xk, xv = self.q_proj(x), self.k_proj(x), self.v_proj(x)
```
这里对应原理里的:
```text
q_i = W_Q h_i
k_i = W_K h_i
v_i = W_V h_i
```
同一份 `x` 通过三组 Linear 变成三种角色:
```text
xq: query
xk: key
xv: value
```
### 第 3 步:把 Q/K/V reshape 成多头
```python
xq = xq.view(bsz, seq_len, self.n_local_heads, self.head_dim)
xk = xk.view(bsz, seq_len, self.n_local_kv_heads, self.head_dim)
xv = xv.view(bsz, seq_len, self.n_local_kv_heads, self.head_dim)
```
这一步把最后一维拆开:
```text
Q: [batch, seq, num_attention_heads, head_dim]
K: [batch, seq, num_key_value_heads, head_dim]
V: [batch, seq, num_key_value_heads, head_dim]
```
MiniMind 允许 Q heads 和 K/V heads 不同,这是 GQA 的设计。
### 第 4 步:Q/K norm 和 RoPE
```python
xq, xk = self.q_norm(xq), self.k_norm(xk)
cos, sin = position_embeddings
xq, xk = apply_rotary_pos_emb(xq, xk, cos, sin)
```
`q_norm` 和 `k_norm` 对每个 head 的向量做 RMSNorm,稳定 Q/K 的尺度。
RoPE 作用在 Q/K 上,是为了让 Q/K 匹配时带上位置信息。注意 V 不参与 RoPE,因为位置信息主要影响“怎么匹配”,不是“拿回什么内容”。
RoPE 细节放第 12 课,这里只记住:
```text
RoPE 修改 Q/K,不修改 V。
```
### 第 5 步:KV cache 先跳过细节
```python
if past_key_value is not None:
xk = torch.cat([past_key_value[0], xk], dim=1)
xv = torch.cat([past_key_value[1], xv], dim=1)
past_kv = (xk, xv) if use_cache else None
```
这段用于生成阶段的 KV cache。
训练时通常一次性输入整段序列,可以先不看。第 12 课会专门讲:
```text
为什么生成时要缓存历史 K/V;
为什么不用每步重算全部历史。
```
本节只要知道:
```text
cache 缓存的是 K/V,不是 Q。
```
### 第 6 步:转成 attention 计算布局,并 repeat K/V
```python
xq, xk, xv = (
xq.transpose(1, 2),
repeat_kv(xk, self.n_rep).transpose(1, 2),
repeat_kv(xv, self.n_rep).transpose(1, 2)
)
```
原来多头 shape 是:
```text
[batch, seq, heads, head_dim]
```
attention 矩阵乘法更方便的布局是:
```text
[batch, heads, seq, head_dim]
```
所以要 `transpose(1, 2)`。
K/V 先经过 `repeat_kv`,是因为 MiniMind 使用 GQA:
```text
Q heads 多;
K/V heads 少;
计算前把 K/V heads 复制到和 Q heads 一样多。
```
### 第 7 步:计算 scores、mask、softmax、加权 V
普通分支是最容易理解的:
```python
scores = (xq @ xk.transpose(-2, -1)) / math.sqrt(self.head_dim)
if self.is_causal:
scores[:, :, :, -seq_len:] += torch.full((seq_len, seq_len), float("-inf"), device=scores.device).triu(1)
if attention_mask is not None:
scores += (1.0 - attention_mask.unsqueeze(1).unsqueeze(2)) * -1e9
output = self.attn_dropout(F.softmax(scores.float(), dim=-1).type_as(xq)) @ xv
```
这几行就是 Attention 原理公式:
```text
scores = QK^T / sqrt(head_dim)
weights = softmax(scores + mask)
output = weights V
```
shape 是:
```text
xq: [batch, heads, query_seq, head_dim]
xk^T: [batch, heads, head_dim, key_seq]
scores: [batch, heads, query_seq, key_seq]
xv: [batch, heads, key_seq, head_dim]
output: [batch, heads, query_seq, head_dim]
```
causal mask 的作用是:
```text
未来 key position 的分数变成 -inf;
softmax 后未来位置权重变成 0。
```
flash attention 分支:
```python
F.scaled_dot_product_attention(...)
```
做的是同一件事,只是调用 PyTorch 优化实现。
### 第 8 步:合并 heads,o_proj 回到 hidden_size
```python
output = output.transpose(1, 2).reshape(bsz, seq_len, -1)
output = self.resid_dropout(self.o_proj(output))
return output, past_kv
```
attention 内部输出是:
```text
[batch, heads, seq, head_dim]
```
先转回:
```text
[batch, seq, heads, head_dim]
```
再合并:
```text
[batch, seq, heads * head_dim]
```
最后 `o_proj` 输出:
```text
[batch, seq, hidden_size]
```
这样 Attention 才能回到 `MiniMindBlock` 的 residual stream:
```python
hidden_states += residual
```
### 完整源码走读到此为止
你读 `Attention.forward` 时,不要把它看成很多互不相关的小技巧。它就是这条链:
```text
x
-> Q/K/V
-> 多头 shape
-> Q/K 加位置
-> K/V cache 可选拼接
-> QK 打分
-> mask
-> softmax 权重
-> 汇总 V
-> 合并 heads
-> o_proj 回主干
```
<a id="l11-variable-dict"></a>
## 4. 源码变量字典
这一节把 `Attention.forward` 里的关键变量统一列出来,方便你回看源码。
| 变量 | shape | 含义 |
|---|---|---|
| `x` | `[batch, seq, hidden_size]` | Attention 输入 hidden states |
| `xq` | `[batch, seq, q_heads, head_dim]` 后转 `[batch, q_heads, seq, head_dim]` | query,用来匹配 key |
| `xk` | `[batch, seq, kv_heads, head_dim]` 后转 `[batch, q_heads, seq, head_dim]` | key,被 query 匹配 |
| `xv` | `[batch, seq, kv_heads, head_dim]` 后转 `[batch, q_heads, seq, head_dim]` | value,被权重加权汇总 |
| `scores` | `[batch, heads, query_seq, key_seq]` | 每个 query 位置看每个 key 位置的分数 |
| `attention_mask` | 可广播到 scores | padding 等外部 mask |
| `output` | 先 `[batch, heads, seq, head_dim]`,最后 `[batch, seq, hidden_size]` | Attention 汇总后的结果 |
| `past_kv` | `(xk, xv)` | 生成时缓存历史 K/V |
<a id="l11-source-map"></a>
## 5. 关键源码对照
前面已经完整走读过源码。这里只把几个容易混的点再对照一次。
### 5.1 Q/K/V 从哪里来
文件:`model/model_minimind.py:91-116`
看它是为了理解:Q/K/V 是同一份 `x` 的三套投影,不是三个外部输入。
源码:
```python
self.q_proj = nn.Linear(config.hidden_size, config.num_attention_heads * self.head_dim, bias=False)
self.k_proj = nn.Linear(config.hidden_size, self.num_key_value_heads * self.head_dim, bias=False)
self.v_proj = nn.Linear(config.hidden_size, self.num_key_value_heads * self.head_dim, bias=False)
...
xq, xk, xv = self.q_proj(x), self.k_proj(x), self.v_proj(x)
```
这段代码说明:
- 输入都是同一个 `x`。
- Q/K/V 是三套投影参数学出来的三种表示。
- Q/K 负责匹配,V 负责被汇总。
### 5.2 为什么 scores 是 `[batch, heads, seq, seq]`
文件:`model/model_minimind.py:123-128`
看它是为了理解:query position 和 key position 如何形成二维注意力矩阵。
源码:
```python
xq, xk, xv = (xq.transpose(1, 2), repeat_kv(xk, self.n_rep).transpose(1, 2), repeat_kv(xv, self.n_rep).transpose(1, 2))
scores = (xq @ xk.transpose(-2, -1)) / math.sqrt(self.head_dim)
```
这段代码说明:
- `xq` 是 `[batch, heads, query_seq, head_dim]`。
- `xk.transpose(-2, -1)` 是 `[batch, heads, head_dim, key_seq]`。
- 相乘后得到 `[batch, heads, query_seq, key_seq]`。
### 5.3 causal mask 为什么加在 scores 上
文件:`model/model_minimind.py:128-131`
看它是为了理解:未来 token 为什么不会进入 softmax 权重。
源码:
```python
scores = (xq @ xk.transpose(-2, -1)) / math.sqrt(self.head_dim)
if self.is_causal: scores[:, :, :, -seq_len:] += torch.full((seq_len, seq_len), float("-inf"), device=scores.device).triu(1)
if attention_mask is not None: scores += (1.0 - attention_mask.unsqueeze(1).unsqueeze(2)) * -1e9
output = self.attn_dropout(F.softmax(scores.float(), dim=-1).type_as(xq)) @ xv
```
这段代码说明:
- causal mask 是在 softmax 前加到 scores 上。
- 未来位置变成 `-inf`。
- softmax 后未来位置权重为 0。
- 最后才用权重乘 V。
### 5.4 GQA 和 o_proj 如何收尾
文件:`model/model_minimind.py:86-89`, `model/model_minimind.py:132-134`
看它是为了理解:K/V heads 如何对齐 Q heads,以及多头结果如何回到主干。
源码:
```python
def repeat_kv(x: torch.Tensor, n_rep: int) -> torch.Tensor:
bs, slen, num_key_value_heads, head_dim = x.shape
if n_rep == 1: return x
return (x[:, :, :, None, :].expand(bs, slen, num_key_value_heads, n_rep, head_dim).reshape(bs, slen, num_key_value_heads * n_rep, head_dim))
output = output.transpose(1, 2).reshape(bsz, seq_len, -1)
output = self.resid_dropout(self.o_proj(output))
return output, past_kv
```
这段代码说明:
- `repeat_kv` 把较少的 K/V heads 扩到 Q heads 数量。
- 多头输出合并后经过 `o_proj`。
- 最终输出回到 `[batch, seq, hidden_size]`。
<a id="l11-experiment"></a>
## 6. 实验验证
### 实验 A:追踪完整 Attention 流程
这个实验验证:
```text
Q/K/V 来自同一个 hidden_states;
scores 是 QK 匹配矩阵;
causal mask 让未来位置权重为 0;
softmax(scores) @ V 后再 o_proj;
手算结果和模块 forward 完全一致。
```
运行:
```bash
cd /home/sun/minimind
PYTHONDONTWRITEBYTECODE=1 python course/labs/trace_attention_shapes.py
```
记录:
```text
x.shape =
q_proj(x).shape =
k_proj(x).shape =
v_proj(x).shape =
scores.shape =
attention weights for batch 0, head 0 =
future_attention_mass_for_batch0_head0 =
manual_vs_module_max_abs_diff =
```
你应该看到:
```text
x.shape=(2, 5, 64)
q_proj(x).shape=(2, 5, 64)
k_proj(x).shape=(2, 5, 32)
v_proj(x).shape=(2, 5, 32)
scores.shape=(2, 4, 5, 5)
future_attention_mass_for_batch0_head0=0.0000000000
manual_vs_module_max_abs_diff=0.000000000000
```
重点看输出的 attention weights 矩阵。它应该是下三角有效:
```text
第 0 行只能看第 0 列;
第 1 行只能看第 0、1 列;
第 2 行只能看第 0、1、2 列;
未来位置权重为 0。
```
### 实验 B:改变 head 配置
运行:
```bash
PYTHONDONTWRITEBYTECODE=1 python course/labs/trace_attention_shapes.py --hidden_size 96 --num_attention_heads 6 --num_key_value_heads 3
```
记录:
```text
head_dim =
q_proj(x).shape =
k_proj(x).shape =
xq.shape =
xk after repeat shape =
scores.shape =
```
你应该看到:
```text
head_dim=16
q_proj(x).shape=(2, 5, 96)
k_proj(x).shape=(2, 5, 48)
xq.shape=(2, 5, 6, 16)
xk after repeat shape=(2, 5, 6, 16)
scores.shape=(2, 6, 5, 5)
```
这说明:
- head 配置改变的是 Attention 内部多头形状。
- 输出仍然要回到 `[batch, seq, hidden_size]`。
<a id="l11-check"></a>
## 7. 本节检查
如果你真懂了本节,应该能不看答案说清楚:
1. Attention 要解决的核心问题是什么。
2. 从一个 token 的角度,Attention 是如何从上下文拿信息的。
3. Q/K/V 为什么要分成三种角色。
4. QK scores 的每个元素表示什么。
5. 为什么 softmax 后乘的是 V,不是 K。
6. causal mask 和第 10 课的 next-token loss 是什么关系。
7. `Attention.forward` 从 `x` 到 `output` 的完整执行顺序是什么。
8. 为什么最终必须用 `o_proj` 回到 `[batch, seq, hidden_size]`。
<a id="l11-next"></a>
## 8. 下一课
第 12 课进入 `RoPE 与 KV cache`:我们会讲位置编码如何作用在 Q/K 上,以及生成时 past key/value 如何让模型不用每一步重算全部历史。