# 第 9 课:Embedding、RMSNorm 与残差 Block 这一课只解决一个问题:`input_ids` 进入 MiniMind 后,如何变成 `hidden_states`,并在每个 block 里保持 `[batch, seq, hidden_size]` 这条主干 shape。 ## 目录 - [0. 本节主线](#l09-mainline) - [1. 本节要懂的 5 个原理](#l09-principles) - [2. 变量流转](#l09-flow) - [3. 原理一:Embedding 把 token id 变成向量](#l09-embedding) - [4. 原理二:主干 shape 必须保持不变](#l09-main-shape) - [5. 原理三:RMSNorm 只归一化最后一维](#l09-rmsnorm) - [6. 原理四:残差连接要求 shape 对齐](#l09-residual) - [7. 原理五:final norm 和 lm_head 完成输出转换](#l09-final-norm-lm-head) - [8. 实验验证](#l09-experiment) - [9. 本节检查](#l09-check) - [10. 下一课](#l09-next) ## 0. 本节主线 MiniMind 主干数据流是: ```text input_ids [batch, seq] -> embed_tokens -> hidden_states [batch, seq, hidden_size] -> input_layernorm -> self_attn -> residual add -> post_attention_layernorm -> mlp -> residual add -> 下一个 block -> final norm -> lm_head -> logits [batch, seq, vocab_size] ``` 所以本节的核心不是 Attention 公式,而是先看懂 Transformer block 的外壳: ```text Embedding 负责升维; RMSNorm 负责稳定每个 token 的 hidden 向量; Attention/MLP 负责变换 hidden_states; Residual 负责把变换结果加回主干; 整个 block 输入输出 shape 都保持 [batch, seq, hidden_size]。 ``` ## 1. 本节要懂的 5 个原理 | 原理 | 要理解什么 | 源码证据 | |---|---|---| | Embedding 把 token id 变成向量 | `input_ids` 是整数,进入模型后变成 `hidden_states` | `model/model_minimind.py:196-204`, `model/model_minimind.py:209-215` | | 主干 shape 必须保持不变 | block 输入输出都要是 `[batch, seq, hidden_size]`,后面才能层层相接 | `model/model_minimind.py:186-194`, `model/model_minimind.py:220-230` | | RMSNorm 只归一化最后一维 | 它不改变 batch/seq/hidden shape,只稳定 hidden 向量尺度 | `model/model_minimind.py:50-60` | | 残差连接要求 shape 对齐 | Attention 和 MLP 输出必须能加回原来的 `hidden_states` | `model/model_minimind.py:186-194` | | final norm 和 lm_head 完成输出转换 | 主干最后仍是 hidden,再映射成词表 logits | `model/model_minimind.py:230-232`, `model/model_minimind.py:245-249` | 学完本节,你应该能读懂 `MiniMindModel.forward` 的主干流程,并能说清楚为什么每个 block 都要保持同一个 hidden shape。 ## 2. 变量流转 最小 shape 流: ```text input_ids shape = [batch_size, seq_len] dtype = torch.long 含义 = token id embed_tokens(input_ids) shape = [batch_size, seq_len, hidden_size] 含义 = 每个 token 的向量表示 MiniMindBlock(hidden_states) shape = [batch_size, seq_len, hidden_size] 含义 = 被 Attention 和 MLP 改写过的 token 表示 lm_head(hidden_states) shape = [batch_size, seq_len, vocab_size] 含义 = 每个位置对下一个 token 的预测分数 ``` 这一节先记住一条规则: ```text 只要还在 Transformer 主干里,shape 基本保持 [batch, seq, hidden_size]。 ``` ## 3. 原理一:Embedding 把 token id 变成向量 ### 原理讲解 tokenizer 输出的 `input_ids` 只是整数序列,例如: ```text [1, 345, 1024, 2] ``` 这些数字本身没有连续空间里的几何意义。模型第一步要做的是查表: ```text token id -> hidden_size 维向量 ``` 这个查表矩阵就是 embedding: ```text embed_tokens.weight: [vocab_size, hidden_size] ``` 如果 `input_ids.shape = [2, 6]`,`hidden_size=64`,那么 embedding 后: ```text hidden_states.shape = [2, 6, 64] ``` 从这一刻开始,模型不再处理整数 token id,而是在处理每个 token 的向量表示。 ### 源码证据 A:embedding 矩阵在哪里创建 文件:`model/model_minimind.py:196-204` 看它是为了理解:模型如何根据 config 创建输入 embedding。 源码: ```python class MiniMindModel(nn.Module): def __init__(self, config: MiniMindConfig): super().__init__() self.config = config self.vocab_size, self.num_hidden_layers = config.vocab_size, config.num_hidden_layers self.embed_tokens = nn.Embedding(config.vocab_size, config.hidden_size) self.dropout = nn.Dropout(config.dropout) self.layers = nn.ModuleList([MiniMindBlock(l, config) for l in range(self.num_hidden_layers)]) self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps) ``` 这段代码说明: - `nn.Embedding(config.vocab_size, config.hidden_size)` 创建 token 查表矩阵。 - embedding 的行数等于词表大小。 - embedding 的列数等于模型 hidden 维度。 - `layers` 和 `norm` 都是在 embedding 后继续处理 hidden_states 的模块。 ### 源码证据 B:input_ids 如何变成 hidden_states 文件:`model/model_minimind.py:209-215` 看它是为了理解:forward 开始时变量含义如何变化。 源码: ```python def forward(self, input_ids, attention_mask=None, past_key_values=None, use_cache=False, **kwargs): 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)) ``` 这段代码说明: - 进入 forward 时,`input_ids` 只有 `[batch, seq]` 两维。 - `self.embed_tokens(input_ids)` 后,多出最后一维 `hidden_size`。 - 变量名从 `input_ids` 变成 `hidden_states`,说明后面处理的是向量表示。 ### 理解到这一步就够 你应该能说清楚: ```text input_ids 是 token 的编号; hidden_states 是 token 的向量; embedding 是编号到向量的查表。 ``` 暂时不用看: - embedding 初始化分布。 - embedding 语义空间如何形成。 - 词向量可视化。 ## 4. 原理二:主干 shape 必须保持不变 ### 原理讲解 Transformer block 是可以一层接一层堆起来的,前提是: ```text 第 N 层输出 shape = 第 N+1 层输入 shape ``` MiniMind 主干里这个 shape 是: ```text [batch_size, seq_len, hidden_size] ``` Attention 会改写每个 token 对上下文的吸收方式,MLP 会改写每个 token 自身的非线性表示,但它们最终都要回到 `hidden_size` 维。否则下一层 block 就接不上。 这就是为什么你读源码时要一直盯住这条主干: ```text hidden_states ``` 它像一条流水线:每个 block 接过来,处理完,再交给下一个 block。 ### 源码证据 A:block 输入输出都是 hidden_states 文件:`model/model_minimind.py:186-194` 看它是为了理解:一个 block 如何接收并返回同一个主干变量。 源码: ```python def forward(self, hidden_states, position_embeddings, past_key_value=None, use_cache=False, attention_mask=None): residual = hidden_states hidden_states, present_key_value = self.self_attn( self.input_layernorm(hidden_states), position_embeddings, past_key_value, use_cache, attention_mask ) hidden_states += residual hidden_states = hidden_states + self.mlp(self.post_attention_layernorm(hidden_states)) return hidden_states, present_key_value ``` 这段代码说明: - block 输入变量叫 `hidden_states`。 - Attention 的输出仍然赋值给 `hidden_states`。 - MLP 处理后仍然回到 `hidden_states`。 - block 最后返回新的 `hidden_states` 给下一层。 ### 源码证据 B:多层 block 串起来 文件:`model/model_minimind.py:220-230` 看它是为了理解:同一个主干变量如何穿过所有层。 源码: ```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) hidden_states = self.norm(hidden_states) ``` 这段代码说明: - 每一层都接收上一层的 `hidden_states`。 - 每一层都返回更新后的 `hidden_states`。 - 循环结束后再做 final RMSNorm。 ### 理解到这一步就够 你应该能画出: ```text hidden_states -> block0 -> hidden_states -> block1 -> hidden_states -> final norm ``` 暂时不用看: - `present_key_value` 的缓存内容。 - RoPE 的位置编码细节。 - Attention 内部矩阵乘法。 ## 5. 原理三:RMSNorm 只归一化最后一维 ### 原理讲解 RMSNorm 是一种归一化层。它处理的对象是每个 token 的 hidden 向量。 如果: ```text hidden_states.shape = [batch, seq, hidden_size] ``` RMSNorm 做的是沿最后一维计算均方根: ```text sqrt(mean(x^2 over hidden_size)) ``` 然后把这个 token 的 hidden 向量缩放到更稳定的尺度。 重要的是: ```text RMSNorm 不改变 shape ``` 输入是 `[batch, seq, hidden_size]`,输出仍然是 `[batch, seq, hidden_size]`。 MiniMind 使用的是 Pre-Norm 结构,也就是在两个子步骤里都先 norm,再送进对应模块。注意这不是两条并列路径,而是同一个 block 里的顺序执行: ```text hidden_states -> RMSNorm -> Attention -> residual add hidden_states -> RMSNorm -> MLP -> residual add ``` ### 源码证据 A:RMSNorm 的实现 文件:`model/model_minimind.py:50-60` 看它是为了理解:RMSNorm 为什么只影响数值尺度,不改变 shape。 源码: ```python class RMSNorm(torch.nn.Module): def __init__(self, dim: int, eps: float = 1e-5): super().__init__() self.eps = eps self.weight = nn.Parameter(torch.ones(dim)) def norm(self, x): return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps) def forward(self, x): return (self.weight * self.norm(x.float())).type_as(x) ``` 这段代码说明: - `mean(-1, keepdim=True)` 表示沿最后一维计算。 - `keepdim=True` 保留维度,所以可以广播回原 shape。 - `self.weight` 的长度是 `hidden_size`,用于每个 hidden 维度的缩放。 - `type_as(x)` 会把输出转回输入 dtype。 ### 源码证据 B:RMSNorm 放在 block 的两个位置 文件:`model/model_minimind.py:178-184` 看它是为了理解:一个 block 里为什么有两个 norm。 源码: ```python class MiniMindBlock(nn.Module): def __init__(self, layer_id: int, config: MiniMindConfig): super().__init__() self.self_attn = Attention(config) self.input_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps) self.post_attention_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps) self.mlp = FeedForward(config) if not config.use_moe else MOEFeedForward(config) ``` 这段代码说明: - `input_layernorm` 在 Attention 前。 - `post_attention_layernorm` 在 MLP 前。 - 两个 norm 的维度都是 `hidden_size`。 ### 理解到这一步就够 你应该能说清楚: ```text RMSNorm 是对每个 token 的 hidden 向量做尺度归一化; 它不会改变 batch、seq、hidden 三个维度。 ``` 暂时不用看: - RMSNorm 和 LayerNorm 的严格差异。 - 归一化为什么有助于深层训练稳定。 ## 6. 原理四:残差连接要求 shape 对齐 ### 原理讲解 残差连接就是把模块输出加回原来的主干: ```text new_hidden = module(norm(hidden)) + old_hidden ``` 它的好处是:每个模块不需要从零重写全部表示,而是在原表示上增加一个变化量。 但残差相加有一个硬要求: ```text 两个张量 shape 必须一致 ``` 所以 Attention 输出必须是 `[batch, seq, hidden_size]`,MLP 输出也必须是 `[batch, seq, hidden_size]`。这也是为什么 Attention 末尾有 `o_proj` 映射回 `hidden_size`,FFN 末尾有 `down_proj` 映射回 `hidden_size`。这些细节后面会继续展开。 ### 源码证据 A:Attention 残差 文件:`model/model_minimind.py:186-193` 看它是为了理解:Attention 输出如何加回 block 输入。 源码: ```python def forward(self, hidden_states, position_embeddings, past_key_value=None, use_cache=False, attention_mask=None): residual = hidden_states hidden_states, present_key_value = self.self_attn( self.input_layernorm(hidden_states), position_embeddings, past_key_value, use_cache, attention_mask ) hidden_states += residual ``` 这段代码说明: - `residual` 保存的是进入 Attention 前的主干。 - `self_attn(...)` 输出新的 `hidden_states`。 - `hidden_states += residual` 把 Attention 输出加回原主干。 ### 源码证据 B:MLP 残差 文件:`model/model_minimind.py:192-194` 看它是为了理解:MLP 输出如何加回 Attention 后的主干。 源码: ```python hidden_states += residual hidden_states = hidden_states + self.mlp(self.post_attention_layernorm(hidden_states)) return hidden_states, present_key_value ``` 这段代码说明: - Attention 残差后,`hidden_states` 继续作为 MLP 的输入主干。 - MLP 前先做 `post_attention_layernorm`。 - MLP 输出再次加回 `hidden_states`。 ### 理解到这一步就够 你应该能说清楚: ```text 残差连接不是拼接,也不是替换; 它是相同 shape 的张量相加。 ``` 暂时不用看: - 残差连接的梯度流数学证明。 - DeepNorm、ReZero 等残差变体。 ## 7. 原理五:final norm 和 lm_head 完成输出转换 ### 原理讲解 所有 block 处理完后,模型还没有直接输出 token 概率。 这时的主干仍然是: ```text hidden_states: [batch, seq, hidden_size] ``` MiniMind 先做一次 final RMSNorm,然后 `MiniMindForCausalLM` 用 `lm_head` 把最后一维从 `hidden_size` 映射成 `vocab_size`: ```text [batch, seq, hidden_size] -> lm_head -> [batch, seq, vocab_size] ``` 这个 `[batch, seq, vocab_size]` 就是 logits。后面 causal LM loss 会把 logits 和 labels shift 后做 cross entropy。 ### 源码证据 A:主干最后的 final norm 文件:`model/model_minimind.py:220-232` 看它是为了理解:所有 block 之后,模型返回的还是 hidden_states。 源码: ```python 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) hidden_states = self.norm(hidden_states) aux_loss = sum([l.mlp.aux_loss for l in self.layers if isinstance(l.mlp, MOEFeedForward)], hidden_states.new_zeros(1).squeeze()) return hidden_states, presents, aux_loss ``` 这段代码说明: - block 循环结束后做 final norm。 - `MiniMindModel` 返回的是 hidden_states,不是 logits。 - logits 是外层 `MiniMindForCausalLM` 继续生成的。 ### 源码证据 B:lm_head 把 hidden 映射成 logits 文件:`model/model_minimind.py:245-249` 看它是为了理解:什么时候从 hidden_size 变成 vocab_size。 源码: ```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, :]) loss = None ``` 这段代码说明: - `self.model(...)` 只负责主干 hidden 表示。 - `self.lm_head(...)` 负责变成词表 logits。 - logits 的最后一维是 `vocab_size`。 ### 理解到这一步就够 你应该能说清楚: ```text MiniMindModel 输出 hidden_states; MiniMindForCausalLM 再用 lm_head 输出 logits。 ``` 暂时不用看: - `logits_to_keep` 的推理优化用途。 - loss shift 的细节,第 10 课会集中讲。 ## 8. 实验验证 ### 实验 A:跟踪一个 tiny 模型的 block shape 这个实验验证: ```text input_ids -> embedding -> layernorm -> attention -> residual add -> layernorm -> mlp -> residual add -> final norm -> logits ``` 运行: ```bash cd /home/sun/minimind PYTHONDONTWRITEBYTECODE=1 python course/labs/trace_block_shapes.py ``` 记录: ```text input_ids.shape = embed_tokens.weight.shape = after embed_tokens + dropout shape = block input shape = after input_layernorm shape = self_attn output shape = after attention residual add shape = mlp output shape = block output shape = logits.shape = ``` 你应该看到: ```text input_ids.shape = (2, 6) after embed_tokens + dropout shape = (2, 6, 64) block input shape = (2, 6, 64) block output shape = (2, 6, 64) logits.shape = (2, 6, 6400) ``` 这说明: - embedding 把 `[batch, seq]` 升成 `[batch, seq, hidden_size]`。 - block 内部多次变换,但主干 shape 不变。 - `lm_head` 最后把 hidden 映射到 `vocab_size`。 ### 实验 B:改变 hidden_size 看主干 shape 运行: ```bash PYTHONDONTWRITEBYTECODE=1 python course/labs/trace_block_shapes.py --hidden_size 96 --num_hidden_layers 3 ``` 记录: ```text after embed_tokens + dropout shape = 每个 block output shape = logits.shape = ``` 你应该看到: ```text hidden_states 的最后一维从 64 变成 96; logits 的最后一维仍然是 vocab_size=6400。 ``` 这说明: - `hidden_size` 控制主干向量宽度。 - `vocab_size` 控制最终输出类别数。 - block 数量变多,只是主干多走几层,不改变每层输入输出 shape。 ## 9. 本节检查 如果你真懂了本节,应该能不看答案说清楚: 1. `input_ids` 和 `hidden_states` 的 shape 和含义分别是什么。 2. embedding 为什么会让张量从二维变成三维。 3. RMSNorm 沿哪一维做归一化,为什么不改变 shape。 4. 残差连接为什么要求 Attention/MLP 输出都是 `[batch, seq, hidden_size]`。 5. `MiniMindModel` 输出的是 logits 还是 hidden_states。 6. logits 的最后一维为什么是 `vocab_size`。 ## 10. 下一课 第 10 课进入 `Causal LM forward 与 loss shift`:我们会集中看 `MiniMindForCausalLM.forward`,把 `hidden_states -> logits -> shifted logits/labels -> cross entropy` 这条链路讲清楚。