minimind/course/12_rope_and_kv_cache.md

887 lines
26 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.

# 第 12 课:RoPE 与 KV cache
这一课只解决一个问题:MiniMind 如何给 Q/K 注入位置信息,并在生成时复用历史 K/V,做到每步只计算新 token 但仍然和整段 forward 对齐。
## 目录
- [0. 本节主线](#l12-mainline)
- [1. 本节要懂的 6 个层次](#l12-levels)
- [2. RoPE 与 KV cache 一次完整原理讲解](#l12-complete-principle)
- [3. 从 generate 到 Attention 的完整源码走读](#l12-source-walkthrough)
- [4. 源码变量字典](#l12-variable-dict)
- [5. 关键源码对照](#l12-source-evidence)
- [6. 实验验证](#l12-experiment)
- [7. 本节检查](#l12-check)
- [8. 下一课](#l12-next)
<a id="l12-mainline"></a>
## 0. 本节主线
RoPE 与 KV cache 的完整逻辑是:
```text
hidden_states
-> 投影成 Q/K/V
-> 对 Q/K 做 norm
-> 按当前位置取出 cos/sin
-> 用 RoPE 旋转 Q/K,让 attention 打分带上位置信息
-> Attention 用带位置的 Q/K 算分数,用 V 汇总内容
-> use_cache=True 时保存当前层的 K/V
-> 下一步生成只输入新 token
-> 用历史 cache 长度得到 start_pos
-> 新 token 的 Q/K 用正确位置的 RoPE
-> 新 K/V 拼到旧 K/V 后继续 attention
```
一句话:
```text
RoPE 解决“当前位置怎么进入 attention 打分”,KV cache 解决“历史 token 的 K/V 不要每步重算”,二者靠 start_pos 对齐。
```
这节课不是单独背 RoPE 公式,也不是单独看 cache 变量。你要形成一个完整观念:
```text
如果生成第 100 个 token,只输入这个新 token,
它的 RoPE 位置仍然必须是 99,
并且它要和 cache 里的 0..98 位置 K/V 一起做 attention。
```
<a id="l12-levels"></a>
## 1. 本节要懂的 6 个层次
| 层次 | 要理解什么 | 源码位置 |
|---|---|---|
| 位置为什么进 attention | 语言模型不仅要知道 token 是什么,还要知道 token 在哪里 | `model/model_minimind.py:111-124` |
| RoPE 表怎么预先算好 | 每个位置、每个 head 维度都有对应的 cos/sin | `model/model_minimind.py:62-78`, `model/model_minimind.py:205-207` |
| RoPE 怎么作用到 Q/K | RoPE 旋转 Q/K,不旋转 V | `model/model_minimind.py:80-84`, `model/model_minimind.py:117-119` |
| cache 时怎么选正确位置 | `start_pos` 来自历史 K 的长度 | `model/model_minimind.py:211-219` |
| KV cache 存什么 | 每层保存已经算好的 K/V,下一步和新 K/V 拼接 | `model/model_minimind.py:120-123`, `model/model_minimind.py:221-232` |
| generate 如何使用 cache | 每轮只把未缓存的新 token 送进 forward | `model/model_minimind.py:257-288`, `eval_llm.py:82-87` |
学完本节,你应该能说明:RoPE 为什么必须跟 cache 长度配合;`past_key_values[0][0].shape[1]` 代表什么;为什么 cache 里存 K/V 而不是存所有 hidden_states。
<a id="l12-complete-principle"></a>
## 2. RoPE 与 KV cache 一次完整原理讲解
先不要看源码。先把两个问题分开:
```text
RoPE 解决的问题:
Attention 怎么知道 token 的位置关系?
KV cache 解决的问题:
生成时为什么不用每一步重复计算所有历史 token?
```
它们是两件事,但在生成阶段必须配合:
```text
RoPE 负责位置正确;
KV cache 负责计算复用;
start_pos 负责让“只输入新 token”时的位置仍然正确。
```
### 2.1 Attention 为什么需要位置
Attention 的核心打分是:
```text
score(i, j) = q_i · k_j / sqrt(head_dim)
```
这个公式只说:
```text
位置 i 的 query 和位置 j 的 key 有多匹配。
```
但如果 Q/K 里没有位置信息,模型很难知道:
```text
A 在 C 前面两个位置
A 在 C 前面十个位置
A 在 C 后面一个位置
```
这些不是同一件事。语言模型不只需要知道 token 是什么,还要知道 token 的顺序和距离。比如:
```text
我 喜欢 吃 苹果
苹果 喜欢 吃 我
```
这两句话 token 很接近,但位置关系变了,意思就变了。
一种做法是在 embedding 上加位置向量。MiniMind 用的是 RoPE:不直接给 `hidden_states` 加位置,而是在 attention 打分前,把位置信息放进 Q/K。
### 2.2 RoPE 是什么
RoPE 是 Rotary Position Embedding,中文可以理解成“旋转位置编码”。
它做的事情很具体:
```text
给不同位置的 Q/K 做不同角度的旋转。
```
先看二维向量:
```text
x = [a, b]
```
如果 token 在位置 `pos`,RoPE 会根据 `pos` 得到一个角度 `theta_pos`,然后把 `[a, b]` 旋转成:
```text
a' = a * cos(theta_pos) - b * sin(theta_pos)
b' = a * sin(theta_pos) + b * cos(theta_pos)
```
高维向量也是同样逻辑。一个 head 里的向量维度是 `head_dim`,RoPE 会把它拆成多组维度,每组按不同频率旋转:
```text
低频维度:适合表达较长距离的位置变化
高频维度:适合表达较短距离的位置变化
```
所以 RoPE 不是给 token 附加一个位置编号,而是让 Q/K 的方向随着位置变化。
MiniMind 代码里的批量写法是:
```text
x_rope = x * cos + rotate_half(x) * sin
```
这就是上面二维旋转公式的向量化实现。
### 2.3 RoPE 干了什么
第 11 课讲过,Attention 是用 Q/K 点积决定“看谁”:
```text
score(i, j) = q_i · k_j / sqrt(head_dim)
```
加了 RoPE 之后,真正参与点积的不是原始 Q/K,而是旋转后的 Q/K:
```text
q_i_rope = R(i) q_i
k_j_rope = R(j) k_j
score(i, j) = q_i_rope · k_j_rope / sqrt(head_dim)
= (R(i) q_i) · (R(j) k_j) / sqrt(head_dim)
```
这里的 `R(i)` 可以理解成“位置 i 对应的旋转矩阵”。
关键点在这里:
```text
(R(i) q_i) · (R(j) k_j)
等价于
q_i 和 k_j 做点积时,中间带上了 i 与 j 的相对位置关系。
```
直观说:
```text
位置 i 的 token 要看位置 j 的 token 时,
attention 分数不只取决于 q_i 和 k_j 的内容,
还取决于 i 和 j 隔多远、方向如何。
```
这就是 RoPE 最重要的作用:
```text
RoPE 让 Q/K 的匹配分数带有相对位置信息。
```
不用死记矩阵推导,但要记住这个结论。它比“RoPE 把向量旋转一下”更重要。
### 2.4 为什么 RoPE 只作用在 Q/K 上
Attention 里三种角色是:
```text
Q:当前位置想找什么
K:每个位置提供什么可匹配特征
V:每个位置真正贡献什么内容
```
位置最直接影响的是“匹配关系”:
```text
当前位置 i 的 Q,要和历史位置 j 的 K 做匹配。
匹配分数应该知道 i 和 j 的相对位置。
```
所以 MiniMind 对 Q/K 做 RoPE:
```text
xq, xk = apply_rotary_pos_emb(xq, xk, cos, sin)
```
但 V 不旋转。
原因是:
```text
Q/K 负责决定“看谁、看多少”;
V 负责提供“拿回来什么内容”。
```
先按 Q/K 算出注意力权重,再用权重去加权 V。位置主要影响权重,不直接改 V 的内容。
### 2.5 KV cache 是什么
现在换到生成阶段。
假设 prompt 是:
```text
位置: 0 1 2
token: A B C
```
模型第一次 forward 处理 `A B C`,这一步通常叫 prefill。
在每一层 Attention 里,模型会算出:
```text
Q_A, Q_B, Q_C
K_A, K_B, K_C
V_A, V_B, V_C
```
其中 Q 是当前 token 做 query 时才用的。prefill 结束后,如果下一个 token 是 `D`,下一步只需要:
```text
D 的 Q
历史 A/B/C 的 K/V
```
历史 A/B/C 的 Q 不再需要,因为 A/B/C 作为 query 的输出已经算完了。
所以 KV cache 存的是每一层的历史 K/V:
```text
past_key_values[layer] = (past_k, past_v)
```
形状是:
```text
past_k: [batch, past_seq, num_key_value_heads, head_dim]
past_v: [batch, past_seq, num_key_value_heads, head_dim]
```
在 MiniMind 里要注意:
```text
cache 里的 K 是已经做过 RoPE 的 K;
cache 里的 V 是没有做 RoPE 的 V。
```
因为源码顺序是:
```text
Q/K 做 RoPE
-> 拼接历史 K/V
-> 保存 past_kv
```
### 2.6 KV cache 干了什么
不用 cache 时,每生成一步都把完整序列重新送进模型:
```text
已知 A B C,要预测 D:
forward(A B C)
生成 D 后,要预测 E:
forward(A B C D)
生成 E 后,要预测 F:
forward(A B C D E)
```
问题是:`A B C` 在后面每一步都会被重复计算。
用 cache 时,时间线变成:
```text
prefill:
输入 A B C
算出 A/B/C 的 K/V cache
用 C 位置 logits 预测 D
decode 1:
把 D 追加到 input_ids
本轮只输入 D
复用 A/B/C 的 K/V cache
只计算 D 的 Q/K/V
用 D 位置 logits 预测 E
decode 2:
把 E 追加到 input_ids
本轮只输入 E
复用 A/B/C/D 的 K/V cache
只计算 E 的 Q/K/V
用 E 位置 logits 预测 F
```
所以 KV cache 的作用不是改变模型会说什么,而是改变计算方式:
```text
不使用 cache:每步重复算整个历史序列
使用 cache:历史 K/V 复用,每步只算新 token
```
### 2.7 为什么 RoPE 和 cache 必须靠 start_pos 对齐
这里是本节最容易错的地方。
prefill 处理 `A B C` 后,cache 里保存的是:
```text
K_A(pos0), K_B(pos1), K_C(pos2)
V_A, V_B, V_C
```
现在生成出新 token `D`,下一轮 decode 只输入 `D`:
```text
本轮 input_ids 形状看起来是 [batch, 1]
seq_length = 1
```
但 `D` 在完整序列里的真实位置不是 0,而是 3:
```text
位置: 0 1 2 3
token: A B C D
```
所以给 `D` 算 RoPE 时,不能用位置 0 的 cos/sin,必须用位置 3 的 cos/sin。
MiniMind 用 cache 长度得到这个位置:
```text
start_pos = past_key_values[0][0].shape[1]
```
如果历史 cache 长度是 3,那么:
```text
start_pos = 3
position_embeddings = freqs[3:4]
```
这样新 token `D` 的 Q/K 才会带上位置 3。
如果错误地用位置 0,那么:
```text
D 会被当成一个新序列的第 0 个 token
它和 A/B/C 的相对位置关系会错
cache 生成就不再等价于整段 forward
```
### 2.8 判断 cache 是否正确的标准
正确的 KV cache 不应该改变模型输出。它只是省计算。
所以可以用下面这个等价关系检查:
```text
不用 cache:
forward(A B C D)
-> 取 D 位置 logits 预测 E
使用 cache:
forward(A B C, use_cache=True)
-> 得到 cache(A,B,C)
forward(D, past_key_values=cache, use_cache=True)
-> 取 D 位置 logits 预测 E
```
如果 RoPE 的 `start_pos` 正确,K/V 拼接正确,那么两边 D 位置的 logits 应该非常接近。
本节可以先记住这句话:
```text
RoPE 让 Q/K 打分知道相对位置;
KV cache 保存历史 K/V,避免重复计算;
start_pos 让只输入新 token 时仍然使用真实位置。
```
实验会验证这一点。
<a id="l12-source-walkthrough"></a>
## 3. 从 generate 到 Attention 的完整源码走读
这一节沿着真实执行路径走,不先拆小片段。
```text
eval_llm.py
-> model.generate(...)
-> MiniMindForCausalLM.generate
-> MiniMindForCausalLM.forward
-> MiniMindModel.forward
-> MiniMindBlock.forward
-> Attention.forward
```
### 第 1 步:CLI 推理调用 generate
File: `eval_llm.py:82-87`
Read this to understand: CLI 推理时不是手写循环,而是把 prompt 的 `input_ids` 交给模型自己的 `generate`。
Code/config/template excerpt:
```python
generated_ids = model.generate(
inputs=inputs["input_ids"], attention_mask=inputs["attention_mask"],
max_new_tokens=args.max_new_tokens, do_sample=True, streamer=streamer,
pad_token_id=tokenizer.pad_token_id, eos_token_id=tokenizer.eos_token_id,
top_p=args.top_p, temperature=args.temperature, repetition_penalty=1
)
```
This code shows:
- `inputs["input_ids"]` 是 prompt token。
- `generate` 负责逐 token 生成。
- 默认是否用 cache 不在这里显式传,因为 `MiniMindForCausalLM.generate` 的 `use_cache=True` 是默认值。
### 第 2 步:generate 初始化 input_ids 和 past_key_values
File: `model/model_minimind.py:257-263`
Read this to understand: 生成循环开始前,模型准备好完整 prompt、空 cache 和结束标记。
Code/config/template excerpt:
```python
def generate(..., use_cache=True, ...):
input_ids = kwargs.pop("input_ids", inputs).repeat(num_return_sequences, 1)
attention_mask = attention_mask.repeat(num_return_sequences, 1) if attention_mask is not None else None
past_key_values = kwargs.pop("past_key_values", None)
finished = torch.zeros(input_ids.shape[0], dtype=torch.bool, device=input_ids.device)
if streamer: streamer.put(input_ids.cpu())
```
This code shows:
- `past_key_values` 初始通常是 `None`。
- `use_cache=True` 是默认生成方式。
- `input_ids` 会一直增长,包含 prompt 和已经生成出的 token。
### 第 3 步:每一轮只把未缓存部分送进 forward
File: `model/model_minimind.py:263-266`
Read this to understand: KV cache 真正省计算的入口是 `input_ids[:, past_len:]`。
Code/config/template excerpt:
```python
for _ in range(max_new_tokens):
past_len = past_key_values[0][0].shape[1] if past_key_values else 0
outputs = self.forward(input_ids[:, past_len:], attention_mask, past_key_values, use_cache=use_cache, **kwargs)
attention_mask = torch.cat([attention_mask, attention_mask.new_ones(attention_mask.shape[0], 1)], -1) if attention_mask is not None else None
```
This code shows:
- 第一次循环 `past_key_values=None`,所以 `past_len=0`,整段 prompt 会进入 forward。
- 后面循环 `past_len` 等于 cache 中已经保存的序列长度。
- `input_ids[:, past_len:]` 只取新 token,历史 token 不再重复送入模型。
- `attention_mask` 仍然随着完整序列增长,因为新 query 要能看到完整 key/value 序列。
### 第 4 步:CausalLM forward 把任务交给 MiniMindModel
File: `model/model_minimind.py:245-253`
Read this to understand: `MiniMindForCausalLM.forward` 主要负责调用 backbone、接 lm_head、返回 cache。
Code/config/template excerpt:
```python
def forward(self, input_ids, attention_mask=None, past_key_values=None, use_cache=False, logits_to_keep=0, labels=None, **kwargs):
hidden_states, past_key_values, aux_loss = self.model(input_ids, attention_mask, past_key_values, use_cache, **kwargs)
slice_indices = slice(-logits_to_keep, None) if isinstance(logits_to_keep, int) else logits_to_keep
logits = self.lm_head(hidden_states[:, slice_indices, :])
...
return MoeCausalLMOutputWithPast(..., logits=logits, past_key_values=past_key_values, hidden_states=hidden_states)
```
This code shows:
- cache 是从 `self.model(...)` 返回的。
- `lm_head` 只负责把 hidden_states 变成 logits,不负责位置和 cache。
- 返回对象里的 `past_key_values` 会被 `generate` 下一轮继续使用。
### 第 5 步:MiniMindModel 用 cache 长度计算 start_pos
File: `model/model_minimind.py:209-219`
Read this to understand: RoPE 与 KV cache 在这里真正接上。
Code/config/template excerpt:
```python
batch_size, seq_length = input_ids.shape
if hasattr(past_key_values, 'layers'): past_key_values = None
past_key_values = past_key_values or [None] * len(self.layers)
start_pos = past_key_values[0][0].shape[1] if past_key_values[0] is not None else 0
hidden_states = self.dropout(self.embed_tokens(input_ids))
...
position_embeddings = (self.freqs_cos[start_pos:start_pos + seq_length], self.freqs_sin[start_pos:start_pos + seq_length])
```
This code shows:
- `seq_length` 是本次 forward 输入的长度,不一定是完整上下文长度。
- `start_pos` 是历史 cache 的长度。
- `position_embeddings` 从 `start_pos` 开始切,保证新 token 用它在完整序列中的位置。
这是本节最关键的一段源码。
### 第 6 步:每层 block 接收自己的 past_key_value
File: `model/model_minimind.py:221-232`
Read this to understand: cache 是逐层保存的,不是全模型只存一份。
Code/config/template excerpt:
```python
presents = []
for layer, past_key_value in zip(self.layers, past_key_values):
hidden_states, present = layer(
hidden_states,
position_embeddings,
past_key_value=past_key_value,
use_cache=use_cache,
attention_mask=attention_mask
)
presents.append(present)
...
return hidden_states, presents, aux_loss
```
This code shows:
- 每个 transformer layer 都有自己的 K/V cache。
- `past_key_value` 是当前层的历史 K/V。
- `present` 是当前层更新后的 K/V。
- `presents` 会返回给 `generate`,成为下一轮的 `past_key_values`。
### 第 7 步:Attention 里先算 Q/K/V,再对 Q/K 做 RoPE
File: `model/model_minimind.py:111-119`
Read this to understand: RoPE 进入 attention 的位置在 Q/K projection 之后、attention 打分之前。
Code/config/template excerpt:
```python
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)
```
This code shows:
- RoPE 作用在 reshape 成 head 之后的 Q/K 上。
- `xv` 没有进入 `apply_rotary_pos_emb`。
- `position_embeddings` 是上一步按 `start_pos` 切出来的 cos/sin。
### 第 8 步:Attention 把历史 K/V 和新 K/V 拼起来
File: `model/model_minimind.py:120-124`
Read this to understand: KV cache 的主体就是拼接历史 K/V 和当前 K/V。
Code/config/template excerpt:
```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
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))
```
This code shows:
- `dim=1` 是序列长度维度。
- 拼接后的 `xk/xv` 包含历史 token 和当前 token。
- `past_kv` 保存的是 repeat 之前的 K/V,shape 仍然是 `[batch, seq, num_key_value_heads, head_dim]`。
- `repeat_kv` 是为了 GQA,让 K/V head 数对齐 attention head 数。
### 第 9 步:generate 更新 cache 并追加 next_token
File: `model/model_minimind.py:267-288`
Read this to understand: 每轮生成后,`input_ids` 变长,cache 也变长。
Code/config/template excerpt:
```python
logits = outputs.logits[:, -1, :] / temperature
...
next_token = torch.multinomial(torch.softmax(logits, dim=-1), num_samples=1) if do_sample else torch.argmax(logits, dim=-1, keepdim=True)
...
input_ids = torch.cat([input_ids, next_token], dim=-1)
past_key_values = outputs.past_key_values if use_cache else None
...
return input_ids
```
This code shows:
- 每轮只用最后一个位置的 logits 选出 `next_token`。
- `input_ids` 追加新 token。
- `past_key_values` 更新为本轮 forward 返回的新 cache。
- 下一轮的 `past_len` 会更大,所以只会处理更新后的新 token。
<a id="l12-variable-dict"></a>
## 4. 源码变量字典
| 变量 | 形状或类型 | 含义 |
|---|---|---|
| `freqs_cos` | `[max_position_embeddings, head_dim]` | 每个位置、每个 head 维度对应的 cos |
| `freqs_sin` | `[max_position_embeddings, head_dim]` | 每个位置、每个 head 维度对应的 sin |
| `position_embeddings` | `(cos_slice, sin_slice)` | 当前 forward 需要的 RoPE 位置片段 |
| `start_pos` | int | 当前输入在完整序列中的起始位置,来自 cache 长度 |
| `xq` | `[batch, seq, num_attention_heads, head_dim]` | 当前输入 token 的 query |
| `xk` | `[batch, seq, num_key_value_heads, head_dim]` | 当前输入 token 的 key,RoPE 后会进入 cache |
| `xv` | `[batch, seq, num_key_value_heads, head_dim]` | 当前输入 token 的 value |
| `past_key_values` | list | 每层一个 `(past_k, past_v)` |
| `past_key_value` | tuple or None | 当前层的历史 K/V |
| `present` | tuple or None | 当前层更新后的 K/V |
| `past_len` | int | `generate` 里已经缓存的 token 数 |
| `input_ids[:, past_len:]` | tensor | 本轮还没有进入 cache 的新 token |
两个维度最容易混:
```text
seq_length: 本次 forward 输入长度
past_len/start_pos: 之前已经缓存的历史长度
```
不用 cache 时:
```text
seq_length = 完整上下文长度
start_pos = 0
```
用 cache 解码时:
```text
seq_length = 1
start_pos = 历史上下文长度
```
<a id="l12-source-evidence"></a>
## 5. 关键源码对照
这里不是重新走完整源码,而是把几个关键点单独拎出来,方便复习时定位。
### 5.1 RoPE 的配置来自模型 config
File: `model/model_minimind.py:27-39`
Read this to understand: RoPE 的最大位置、频率底数和推理外推配置都在 config 里。
Code/config/template excerpt:
```python
self.max_position_embeddings = kwargs.get("max_position_embeddings", 32768)
self.rms_norm_eps = kwargs.get("rms_norm_eps", 1e-6)
self.rope_theta = kwargs.get("rope_theta", 1e6)
self.tie_word_embeddings = kwargs.get("tie_word_embeddings", True)
self.inference_rope_scaling = kwargs.get("inference_rope_scaling", False)
self.rope_scaling = {
"beta_fast": 32,
"beta_slow": 1,
"factor": 16,
"original_max_position_embeddings": 2048,
"attention_factor": 1.0,
"type": "yarn"
} if self.inference_rope_scaling else None
```
This code shows:
- `max_position_embeddings` 决定预计算 RoPE 表的长度。
- `rope_theta` 影响不同维度的旋转频率。
- `inference_rope_scaling` 是推理外推相关,先知道它会影响 RoPE 表即可。
### 5.2 预计算 cos/sin 表
File: `model/model_minimind.py:62-78`
Read this to understand: MiniMind 不是每次 attention 临时算 RoPE 表,而是在模型初始化时预先算出 cos/sin。
Code/config/template excerpt:
```python
def precompute_freqs_cis(dim: int, end: int = int(32 * 1024), rope_base: float = 1e6, rope_scaling: dict = None):
freqs, attn_factor = 1.0 / (rope_base ** (torch.arange(0, dim, 2)[: (dim // 2)].float() / dim)), 1.0
...
t = torch.arange(end, device=freqs.device)
freqs = torch.outer(t, freqs).float()
freqs_cos = torch.cat([torch.cos(freqs), torch.cos(freqs)], dim=-1) * attn_factor
freqs_sin = torch.cat([torch.sin(freqs), torch.sin(freqs)], dim=-1) * attn_factor
return freqs_cos, freqs_sin
```
This code shows:
- `dim` 对应 `head_dim`。
- `end` 对应最大位置数量。
- `torch.outer(t, freqs)` 得到每个位置、每个频率的角度。
- `freqs_cos/freqs_sin` 最终扩展到 `[end, head_dim]`。
### 5.3 RoPE 表注册成 buffer
File: `model/model_minimind.py:205-207`
Read this to understand: RoPE 表不是训练参数,但会跟着模型移动设备。
Code/config/template excerpt:
```python
freqs_cos, freqs_sin = precompute_freqs_cis(dim=config.head_dim, end=config.max_position_embeddings, rope_base=config.rope_theta, rope_scaling=config.rope_scaling)
self.register_buffer("freqs_cos", freqs_cos, persistent=False)
self.register_buffer("freqs_sin", freqs_sin, persistent=False)
```
This code shows:
- RoPE 表由 `config.head_dim` 和 `config.max_position_embeddings` 决定。
- `register_buffer` 表示它属于模型状态,但不是可训练参数。
- `persistent=False` 表示它不作为普通权重持久保存。
### 5.4 apply_rotary_pos_emb 的关键公式
File: `model/model_minimind.py:80-84`
Read this to understand: MiniMind 用 `rotate_half` 实现批量旋转。
Code/config/template excerpt:
```python
def apply_rotary_pos_emb(q, k, cos, sin, unsqueeze_dim=1):
def rotate_half(x): return torch.cat((-x[..., x.shape[-1] // 2:], x[..., : x.shape[-1] // 2]), dim=-1)
q_embed = ((q * cos.unsqueeze(unsqueeze_dim)) + (rotate_half(q) * sin.unsqueeze(unsqueeze_dim))).to(q.dtype)
k_embed = ((k * cos.unsqueeze(unsqueeze_dim)) + (rotate_half(k) * sin.unsqueeze(unsqueeze_dim))).to(k.dtype)
return q_embed, k_embed
```
This code shows:
- 输入是 Q/K,不包含 V。
- `cos.unsqueeze(1)` 让 cos/sin 从 `[seq, head_dim]` 变成可以广播到 `[batch, seq, heads, head_dim]`。
- 返回的 `q_embed/k_embed` shape 不变,只是数值带上了位置旋转。
### 5.5 已下载 transformers 模型也带 use_cache 配置
File: `minimind-3/config.json:23-35`
Read this to understand: transformers 格式模型配置里也记录了位置长度、RoPE 参数和默认 cache 行为。
Code/config/template excerpt:
```json
"max_position_embeddings": 32768,
"num_attention_heads": 8,
"num_hidden_layers": 8,
"num_key_value_heads": 4,
"rope_scaling": null,
"rope_theta": 1000000.0,
"use_cache": true,
```
This code shows:
- 下载好的 `minimind-3` 模型也使用 RoPE 参数。
- `use_cache: true` 说明推理时默认倾向于使用 KV cache。
- 这里是 transformers 配置;本节主要读的是项目自定义 `model/model_minimind.py` 的实现。
<a id="l12-experiment"></a>
## 6. 实验验证
本节实验不需要真实权重。它用 tiny 随机模型验证三个事实:
```text
1. RoPE cos/sin 的 shape 和 Q/K 对齐。
2. KV cache 的 K/V 长度会随着生成增长。
3. 整段 forward 的最后 token logits 与 cache 增量 forward 的 logits 接近一致。
```
命令:
```bash
cd /home/sun/minimind
python course/labs/trace_rope_kv_cache.py
```
重点记录这些输出:
```text
freqs_cos.shape =
q_after_rope.shape =
prefix_layer0_k.shape =
step_layer0_k.shape =
full_vs_incremental_last_logits_max_abs_diff =
cache_and_no_cache_generate_match =
```
你应该看到:
```text
q_after_rope.shape 和 q_before_rope.shape 一样
prefix_layer0_k 的 seq 维度等于 prefix 长度
step_layer0_k 的 seq 维度等于 prefix 长度 + 1
full_vs_incremental_last_logits_max_abs_diff 非常接近 0
cache_and_no_cache_generate_match=True
```
如果 `full_vs_incremental_last_logits_max_abs_diff` 很大,通常说明:
```text
start_pos 错了;
RoPE 位置切片错了;
cache 拼接维度错了;
或者生成时没有正确传 past_key_values。
```
<a id="l12-check"></a>
## 7. 本节检查
1. RoPE 为什么作用在 Q/K 上,而不是作用在 V 上?
2. `freqs_cos[start_pos:start_pos + seq_length]` 里的 `start_pos` 为什么不能总是 0?
3. `past_key_values[0][0].shape[1]` 表示什么?
4. `generate` 里为什么用 `input_ids[:, past_len:]`,而不是每轮都把完整 `input_ids` 送进 forward?
5. KV cache 为什么存 K/V,而不是存 Q/K/V?
6. 使用 KV cache 应该改变模型输出结果,还是只改变计算效率?为什么?
<a id="l12-next"></a>
## 8. 下一课
第 13 课进入 `FFN 与 MoE`。
Attention 负责让 token 从上下文中取信息;FFN 负责对每个 token 的 hidden 向量做非线性变换。MiniMind 里还有 dense FFN 和 MoE FFN 两条路径,下一课会先讲普通 FFN,再看 MoE 的 router、expert 和 aux loss。