01 PyTorch 训练底层¶
00 前言¶
这份文档适合想要开始学习 LLM 算法,但目前只具备基础 Python 知识、还没有真正训练过模型的人。这里的内容都是我在学习过程中逐步积累下来的笔记:不仅记录“一个概念是什么”,也尽量把它放回真实的训练链路中,去理解它为什么存在、输入输出是什么、最终又会影响到哪里。
全文的带学路径为:
- 定义清楚当前阶段的学习目标,先知道这一章到底要解决什么问题;
- 熟悉必要的前置概念,避免读源码时被 Tensor、Parameter、计算图、梯度等名词打断;
- 结合源码理解原理讲解中容易被省略、但工程中一定会遇到的细节;
- 通过手写练习把“看懂了”转换成“自己能写出来”;
- 最后通过经典问题和吃透标准,检查自己是否真的建立了完整认识。
这条路径的重点不是背下所有 API,也不是第一次就读懂源码里的所有优化分支,而是逐渐建立一条稳定的主线:模型如何从 input_ids 得到 logits,如何从 logits 得到 loss,又如何通过 backward() 和 optimizer.step() 真正改变参数。只要这条主线能够讲通,后面学习 SFT、DPO、PPO、GRPO 时,就不会只看到一堆彼此分散的公式。
怎么使用这份文档¶
- 第一遍先顺着主流程阅读,不必停下来记住每个源码细节;
- 第二遍重点跟 Shape,给每个关键张量标出
[B, T, H]、[B, T, V]等维度; - 第三遍自己手写练习,再对照文档检查 Shift、Mask、Dtype 和梯度更新顺序;
- 最后尝试脱离笔记回答文末问题,回答不清楚的地方再回到对应章节补齐。
写在开始之前¶
千里之行,始于足下。
这一部分第一次学起来有些慢是正常的。训练底层同时涉及线性代数、计算图、模型结构和优化器状态,很难只看一遍就全部连起来。遇到 Shape 对不上、分不清 backward() 和 step()、或者读源码时频繁断线,都不代表学不会,只说明这些概念还没有在脑中形成一条完整链路。
不要急着追求一次吃透。先确保今天比昨天多讲清楚一个环节:可能是 Labels Shift,可能是 .grad 的来源,也可能只是终于能解释为什么要先 log_softmax 再 gather。这些局部理解最终会连在一起。等你能独立把一次训练更新从前向传播讲到参数更新时,后面的后训练算法会明显更容易理解。
本阶段要解决的问题¶
这一阶段只解决一个底层问题:一次训练到底发生了什么。
必须能从 input_ids -> logits -> logprob/loss -> loss.backward() -> parameter.grad -> optimizer.step() 完整讲下来。后面所有 SFT、DPO、PPO、GRPO,本质上都是把 loss 换掉,把数据来源换掉,把训练系统变复杂,但底层更新参数的机制仍然是这条链。
源码锚点¶
核心概念¶
一些概念¶
包含:
- 1、Tensor
- 至少包含张量值、grad、shape、dtype、device、requires_grad、grad_fn
- 2、Parameters :
- 一种特殊Tensor , 关键区别在于 , 挂到nn.Module里后 , 会自动出现在model.parameters()里 , 从而可以被传给optimizer管理
- 通常表示模型中需要训练的参数
- 一般满足 : requires_grad = True , is_leaf = True , grad_fn = None , backward 后 p.grad 不为None
- 3、Module:
- 可以包含Parameter、buffer、sub Module 、 forward逻辑
- 4、Optimizer
- 负责保存参数引用、保存超参数、保存优化器状态、读取p.grad、更新参数p
串一遍流程¶
流程:
- 1、logits labels
- 某个位置的hidden state.shape = [H]
- lm_head.weight.shape = [V, H]
- logits = W · h
- (token k 对应的是 W_vocab[k, : ]这一行 , 这一行和h越接近 , 点积越大 , 那么token k 的logit 就越高
- W_vocab 是一个共享的词表解码器 , h负责表示当前位置的上下文语义是什么 , W_vocab 负责判断这个语义状态更像哪个 next token)
- 2、shift
- shift_logits.shape = [B, T-1, V]
- shift_labels.shape = [B, T-1]
- 对于某一个位置 (b , t) , shift_logits[b, t, :]是一个长度为V的向量
- (表示模型在这个位置对整个词表 V 个 token 的预测分数)
- 3、log_softmax
- log_probs = F.log_softmax(shift_logits, dim = -1)
- 得到log_probs.shape = [B, T-1, V]
- (表示每个位置、每个vocab token的log prob)
- 4、gather
selected_logps = torch.gather(log_probs, dim=-1, index=shift_labels.unsqueeze(-1)).squeeze(-1)- 得到selected_logps.shape = [B, T-1]
- (表示每个位置 给正确的预测token的分数)
- 5、loss scalar
- per_token_loss.shape = [B, T-1]
- (每条样本 , 每个预测位置, 都有一个loss , 但训练需要一个标量)
- 1️⃣没有mask时
- loss = per_token_loss.mean()
- 2️⃣有mask时
- 公式见脑海:D
- 然后得到了一个标量loss
- 6、backward
- 它对每个参数元素 , 都有一个偏导
- 然后 , 对每个参与当前 loss 计算的可训练参数张量 , 计算一个和它shape一致的.grad
- 比如对logits的梯度(见笔记本) , 继续传播到 lm_head、Transformer blocks、embedding等参数上
- 1️⃣lm_head.weight.grad 只负责更新输出词表头
- 2️⃣dL / dh 会继续进入到Transformer , 然后一层一层往前传
- (对于后面的传播都是todo , 要细细研究)
分界线(以下是琐碎的点)¶
PyTorch calculation graph params¶
包含:
- 1、requires_grad
- 表示是否需要计算梯度
- 2、grad_fn
- 表示该 tensor 由哪个可导操作产生
- 用户直接创建出来的叶子张量的 grad_fn 为 None
- 3、is_leaf
- 计算图的起点 , 通常是模型参数
- 4、.grad
- 反向传播后保存梯度的地方
- 对于中间张量 , 默认不会保存梯度 , 如果要看的话 , 需要使用.retain_grad()
- 5、detach()
- 保留数值 , 但是切断计算图 , 后续的 loss 不会通过 old_logps 反传给原来的模型参数
- 6、with torch.no_grad():
- 这段 forward 不构建计算图 , 不保存中间激活 , 不能backward , 省显存
logits and log probability¶
logits 是模型最后一层 hidden_states @ W_vocab^T 得到的词表分数,还没有经过 softmax,也不是 log 之后的概率。
对第 t 个位置:
- hidden state:
h_t,shape[hidden_size] - vocab head:
W_vocab,shape[vocab_size, hidden_size] - logits:
z_t = W_vocab h_t,shape[vocab_size] - probability:
p_t = softmax(z_t) - logprob:
log p_t = log_softmax(z_t)
后训练里经常说“拿到 token 的 logprob”,实际过程是:
- forward 得到所有位置的 logits。
- 对 logits 做
log_softmax。 - 用真实生成 token 的 id 去
gather对应位置的 logprob。 - mask 掉 prompt、padding、无效位置。
Adam¶
维护 “历史梯度方向” 和 “历史梯度大小” 的滑动平均 。 假设当前第 t 次 optimizer update 时 :
- 1、W 的梯度g_t 存在 W.grad 里
- 2、一阶动量 :
- 历史梯度的指数滑动平均
- 公式: m_t = beta1 * m_{t-1} + (1-beta1) * g_t
- 用途 : 不要让一个mini-batch的噪声梯度把更新方向带偏
- 3、二阶动量:
- 历史梯度平方的指数滑动平均
- 公式 : v_t = beta2 * v_{t-1} + (1 - beta2) * g_t ^ 2
- 用途 : 如果某个参数方向上的梯度一直很大 , 会自动把这维的步子缩小一点 ( by 1 / sqrt(v_i) )
- 4、对于反向传播可以展开讲讲
- loss是一个标量 : L(theta)
- 反向传播得到的是 : dL/dtheta = [dL/dtheta_1 , dL/dtheta_2, ... , dL/dtheta_N]
- 某一维的梯度很大指的是 : 某一个 theta_i 稍微变一点 , loss变化就很大
- 每个 theta 都有自己的小方向信号 , optimizer 根据这些信号同时更新整个参数张量
- 5、Adam 核心更新
- theta_i = theta_i - lr * m_i / ( sqrt(v_i) + eps )
- 6、gradient accumulation 时 , 是怎么step的
- 先积累grad , 后面做一次.step()
loss 是标量,梯度是每个参数上的方向信号¶
训练时不是直接“看 loss 大小就知道每个 token 怎么改”,而是:
- loss 是一个标量。
- autograd 沿计算图反向求导。
- 每个参数
W得到dL/dW。 - optimizer 用
W = W - lr * dL/dW或 AdamW 的变体更新。
如果某个 token 的 loss 希望提高目标 token 概率,反向传播会让该 token 的目标词 logits 梯度方向与其他词不同。真正发生变化的不是 token 本身,而是产生该 token logits 的模型参数。
梯度下降是什么意思¶
梯度 dL/dW 表示:如果参数 W 增大一点,loss 会怎么变。
dL/dW > 0:增大W会让 loss 变大,所以更新时减小W。dL/dW < 0:增大W会让 loss 变小,所以更新时增大W。
所以参数更新常写成:
AdamW 不是简单 SGD,但核心仍然是利用梯度方向,只是加了动量、二阶矩估计和 decoupled weight decay。
Cross entropy¶
交叉熵损失的标准写法 , 在NTP任务下的写法 (todo)。 真实情况下的计算 用 log_softmax 来实现:
- 1、为什么不先softmax再log
- 因为直接softmax可能存在很大/小的logit导致overflow/underflow
- 而log_softmax会做一个稳定化处理 , like: (todo)
源码走读任务¶
tips: 读源码只关注以下几点:
- 1️⃣训练底层 , AdamW怎么从 p.grad 更新参数
- 2️⃣causallm , input_ids 怎么变成 logits , labels 怎么变成
- 3️⃣后训练 logprob , logits 怎么通过 log_softmax + gather 变成 token logprobs
- 4️⃣工程诊断
任务 1:从 AdamW 源码理解 optimizer.step()¶
1、认知¶
loss.backward() 把梯度写到每个参数的 .grad -> optimizer.step() 遍历 optimizer 管理的参数 , 找到 p.grad is not None 的参数 ->读取 p.grad , 结合AdamW维护的 exp_avg、exp_avg_sq、step 来进行参数更新
2、AdamW 继承于 Adam¶
初始化时把 decoupled_weight_decay = True 传给父类 -> 只是在 weight decay 语义上做了解耦
- step : 一个计数器 , 记录这个参数已经被optimizer更新过几次 .
- bias correction : 前几次滑动平均会偏小 , 所以要校正
- AdamW 为什么叫 decoupled weight decay。
- 一阶动量、二阶动量
3、step() 主流程¶
def step(self):
# optimizer里的参数组 , 可以给不同参数设置不同的学习率
for group in self.param_groups:
# 只有grad is not None的参数才会被更新
params_with_grad = []
# 每个参数的梯度
grads = []
exp_avgs = []
exp_avg_sqs = []
state_steps = []
self._init_group(
group,
params_with_grad,
grads,
exp_avgs,
exp_avg_sqs,
...,
state_steps,
)
adam(
params_with_grad,
grads,
exp_avgs,
exp_avg_sqs,
...,
state_steps,
lr=group["lr"],
weight_decay=group["weight_decay"],
betas=group["betas"],
# ...
)
4、_init_group收集梯度¶
for p in group["params"]:
if p.grad is not None:
params_with_grad.append(p)
grads.append(p.grad)
state = self.state[p]
if len(state) == 0:
state["step"] = 0
state["exp_avg"] = torch.zeros_like(p)
state["exp_avg_sq"] = torch.zeros_like(p)
exp_avgs.append(state["exp_avg"])
exp_avg_sqs.append(state["exp_avg_sq"])
state_steps.append(state["step"])
5、p.grad 变化¶
刚创建时 , 为None ->执行完backward() 后 , p.grad 变为和 p 同维的 tensor ->optimizer.zero_grad(set_to_none = True) 后 , 设置为None ->set_to_none = False 后 , 设置为全0 tensor , 但下一次optimizer仍然会把它视为有grad的参数
标准顺序一般为:
optimizer.zero_grad(set_to_none = True)
outputs = model(**batch)
loss = outputs.loss
loss.backward() # 计算 loss 对参与当前计算的可训练 leaf parameter 的梯度 , 并累积到p.grad
optimizer.step() # 只读 p.grad 然后依据 optimizer state 去更新参数
总结 :
optimizer.step() 遍历 optimizer 的 parameter groups , 找到 p.grad is not None 的参数 , 把这些参数、对应的 p.grad 以及 AdamW 的一阶动量、二阶动量、step 等状态组织起来 , 然后执行 AdamW 更新
AdamW 和 Adam 的关键区别在于:
weight decay不进入一阶动量和二阶动量 , 而是独立对参数做衰减
具体来说, weight decay就是让参数不要变得太大,把参数往0的方向拉.
但是Adam会把它加到梯度里,它会被动量 & 自适应学习率影响
AdamW独立做weight decay,然后再做梯度更新
任务 2:从 CausalLM 源码理解 labels shift¶
1、LlamaForCausalLM源码¶
"""
1、config项
backbone , 不带语言模型输出头的主体结构 , 负责:
input_ids
-> token embedding
-> Transformer decoder layers
-> final hidden_states
lm_head , 把hidden_states投影到vocab_size:
hidden_states[B, T, H]
lm_head H->V
logits[B, T, V]
"""
self.model = LlamaModel(config)
self.lm_head = nn.Linear(config.hidden_size, config.vocab_size , bias = False)
"""
2、forward
outputs的输入:
input_ids : [B, T] , 索引
attention_mask : [B, T] , 参与注意力的token为1
position_ids : [B, T] , 位置编号
past_key_values : KV Cache
inputs_embeds : 可以直接传 embedding 向量
labels : [B, T] , 传了后 , 会算 CausalLM loss
use_cache : 是否返回 KV Cache
logits_to_keep : 训练时通常需要全序列的 logits , 推理时只需要最后一个位置的 logits
**kwargs
"""
#这个model是LlamaModel(config),即没有lm_head的主体
#同一个forward,可以用来推理 / 训练
#推理:入参不需要labels / outputs.loss = None
#训练:入参需要labels / outputs.loss 有值
#一个输出对象,有多个字段: last_hidden_state、past_key_values、hidden_states、attentions (hidden_states表示每一层的hidden_state的集合)
outputs = self.model(
....
)
#先拿到hidden_states
#logits_to_keep 控制 lm_head 对哪些序列位置计算 logits,选出一部分token位置,送进lm_head,得到每个位置的logits
#slice_indices : 由 logits_to_keep 得到, slice_indices = slice(-logits_to_keep, None)
hidden_states = outputs.last_hidden_state
logits = self.lm_head(hidden_states[:, slice_indices, :])
loss = None
if labels is not None:
loss = self.loss_function(logits = logits , labels = labels, vocab_size = ...)
return CausalLMOutputWithPast(
loss = loss,
logits = logits,
past_key_values = outputs.past_key_values,
hidden_states = outputs.hidden_states,
attentions = outputs.attentions
)
2、logits_to_keep 、attention_mask 和 labels=-100 的区别¶
- 1️⃣attention_mask:
- 控制 attention 里哪些 token 可见。
- (它不一定让这些位置完全不计算 hidden state , 而是让其余 token 不去 attention 这些位置)
- 2️⃣labels=-100 / loss_mask:
- 控制哪些位置参与 loss。
- 3️⃣logits_to_keep:
- 控制哪些位置过 lm_head 计算 logits。
3、整个流程串起来过一遍¶
1️⃣init , 从 config 中载入 model、lm_head、vocab_size , 并做 post_init
2️⃣forward
第一步, 调用backbone
input_ids
-> embedding
-> position / RoPE
-> causal mask + attention_mask
-> 多层 decoder layer
-> final norm
-> last_hidden_state
第二步, 取最后的 hidden_state
第三步, 决定保留哪些logits位置
#整数->保留最后logits_to_keep个位置
#index/tensor -> 按给定位置取
slice_indices = slice(-logits_to_keep,None) if isinstance(logits_to_keep,int) else logits_to_keep
第四步, 通过 lm_head 得到 logits 第五步, 有labels , 就计算loss 第六步, 返回结构化输出
任务 3:从后训练 logprob 逻辑理解 gather¶
1、后训练为什么需要 logprob¶
- 后训练时 , 是把 prompt + completion 喂给模型, 让模型在 teacher forcing 模式下计算:
- 每个completion token在它对应的 prefix 下的 logprob
SFT:
需要 assistant token 的 logprob
loss = - mean(logprob)
DPO:
需要 chosen / rejected response 的 sequence logprob
比较 policy 相对 reference 是否更偏 chosen
PPO / GRPO:
需要 completion token 的 per-token logprob
用 current / old / ref logprob 构造 ratio、KL、policy loss
2、后训练 logprob 的完整计算链路¶
forward
-> logits
-> F.log_softmax(logits, dim = -1)
-> log_probs
-> torch.gather(log_probs, dim = -1 , index = labels.unsqueeze(-1)).squeeze(-1)
-> selected_logps
3、三类mask¶
- attention mask:
- forward时backbone内计算注意力时 , 哪些token可以被看见
- labels = -100:
- 控制计算CE loss 时哪些位置可以忽略
- completion mask:
- 在后训练时 , 控制哪些 token 属于response / completion
- (主要是控制哪些token的logprob被统计、哪些token的loss被计算、哪些token用于 advantage / reward / KL / ratio)
4、TRL 的 memory-efficient selective_log_softmax 源码¶
- 一句话:
- selective_log_softmax 解决的是 [不需要所有 token 的 logprob , 只需要已生成 token 那一列的 logprob]
- 简单来说:
- todo
- 朴素写法:
# 存在的问题: 产生两个大张量
# 核心优化目标: 不要完整保存 logits.log_softmax(dim=-1)的结果
log_probs = logits.log_softmax(dim = -1)
selected_logps = torch.gather(
log_probs,
dim = -1,
index = index.unsqueeze(-1),
).squeeze(-1)
- 我们先熟悉一个数学概念:
- log_softmax(z_y) = logit(z_y) - logsumexp(z)
- 所以其实可以不用先算完整的 logits.log_softmax(-1)
- 而是可以先算:
- selected_logits = gather(logits, index)
- normalizer = logsumexp(logits, dim = -1)
- selected_logps = selected_logits - normalizer
- 对比一下两种:
- 朴素版: logits [B,T,V] -> log_probs [B,T,V] -> gather -> [B,T]
- 改进后: logits [B,T,V] -> selected_logits [B,T,1] -> logsumexp [B,T] -> selected_logps [B,T]
- 有哪些缺陷:
- 1️⃣ 目前只对 float32 / float64 , 因为低精度下 logsumexp
- 原因: 低精度下 , 大量小数相加会产生明显误差,
- 2️⃣ 为什么朴素版的没有?
- 原因: 因为朴素版用 F.log_softmax , 是一个高度优化的基础算子 , 但改进版是手动算
- 它会去做:
- max-subtraction ( 先把最大的logit取出来 , 所有的logits去减掉它, 可行因为 softmax对整体平移不敏感)
- 稳定的 reduction ( 首先,这是聚合操作,在取max logit,求sum_exp时都会用,但是V很大时误差会累积,所以可以先分块求局部max,再合并得到全局max,再分块求sum,总之有更稳定的方法)
- 合理的 dtype / accumulation 处理 (精度变换)
- 避免真的先 softmax 再 log (先softmax得到的概率可能会很小,再取log可能得到-inf,这个问题两种都解决了)
- 1️⃣ 目前只对 float32 / float64 , 因为低精度下 logsumexp
5、在 GRPOTrainer 中看 selected logprob 如何进入 old/ref/current logprobs、ratio、KL、loss¶
(todo 等学了后面的后训练算法后再看这部分)
手写练习¶
练习 1:写 masked_mean¶
要求支持 [batch, seq] 的值和 mask:
import torch
def masked_mean(values, mask, dim=None, eps=1e-8):
"""
values:
Tensor,常见 shape:
[B, L] -> 每个 token 一个值
[B, L, H] -> 每个 token 一个向量
mask:
Tensor,常见 shape:
[B, L]
1 表示有效位置,0 表示无效位置
dim:
None: 对所有有效位置求均值
1: 对序列维求均值,返回 [B] 或 [B, H]
eps:
防止 mask 全 0 时除以 0
"""
#确保在一个device, 并可以相乘
mask = mask.to(device=values.device, dtype=values.dtype)
while mask.ndim < values.ndim:
mask = mask.unsqueeze(-1)
mask = mask.expand_as(values)
masked_values = values * mask
#如果一个batch全是无效的token,那么分母为0,直接除会NaN,所以clamp_min
if dim is None:
numerator = masked_values.sum()
denominator = mask.sum().clamp_min(eps)
else:
numerator = masked_values.sum(dim = dim)
denominator = mask.sum(dim=dim).clamp_min(eps)
return numerator / denominator
验收:
- mask 全 0 不出现 NaN。
- dim 为
None和1都能工作。 - 能解释 SFT、DPO、GRPO 中哪里会用到它。
练习 2:写 selective_log_softmax¶
要求:
def selective_log_softmax(logits,labels,ignore_index=-100):
"""
logits : [B, L, V]
labels : [B, L]
return:
selected_logps : [B, L]
labels 为 ignore_index 的位置返回 0
"""
#不等于 ignore_index 的 labels 位置置为 True
valid_mask = labels.ne(ignore_index)
#取反,那么ignore_index的位置就会被置换成0 , 因为-100不是一个合法id,为了做索引操作,进行一步转换
safe_labels = labels.masked_fill(~valid_mask,0)
log_probs = F.log_softmax(logits,dim=-1)
selected_logps = torch.gather(
log_probs,
dim = -1,
index = safe_labels.unsqueeze(-1)
).squeeze(-1)
#通常情况下,会把ignore_index的位置的logps设置为不影响后续聚合的中性值
selected_logps = selected_logps.masked_fill(~valid_mask,0.0)
return selected_logps
- 输入 logits 和 token ids。
- 输出每个位置所选 token 的 logprob。
- 不对 prompt token 计算 loss。
面试追问:
- 为什么不能先 softmax 再 log。
- 因为数值不稳定 , softmax中有 exp(logits) , 很大时会 overflow , 很小时会underflow ; log_softmax用更稳定的等价形式
log_softmax为什么数值更稳定。- (todo)
- vocab 很大时这一步显存瓶颈在哪里。
- 完整的 materialize log_probs 会带来很大的峰值显存
- (本来 logits 很大 , 又生成了一个同样很大的 log_probs , 显存爆炸 , 这也就是peak memory)
- 因为后训练里 , 通常只需要被采样 or 被监督 token 的 logprob
- 所以TRL用memory-efficient selective log softmax 来减少峰值显存
练习 3:写一个Sequence logprob¶
后训练语境里基本≈Response logprob , 这个 Sequence 特指 response / completion 部分:
def sequence_logprobs(logits,labels,response_mask,ignore_index=-100):
"""
logits : [B,L,V]
labels : [B,L]
response_mask : [B,L]
0 表示 prompt / padding token
1 表示 response / completion token
return :
token_logps : [B,L-1]
seq_logps : [B]
"""
# contiguous() 把 tensor 在内存里整理成连续存放的格式,只改变底层内存布局
# 因为切片得到的是 view,可能是指向原 Tensor 的部分区域,而不是连续内存
shift_logits = logits[:,:-1,:].contiguous()
shift_labels = labels[:,1:].contiguous()
shift_mask = response_mask[:,1:].contiguous()
token_logps = selective_log_softmax(
shift_logits,
shift_labels,
ignore_index = ignore_index,
)
# 两个条件 : label不能是ignore_index & 必须属于 response/completion
# 同时满足 labels 不是 -100 、此位置属于 response , 才为 True
valid_mask = shift_labels.ne(ignore_index) & shift_mask.bool()
# 把 mask 转成浮点类型,然后 * 就可以把 false 位置的logps变为0.0
token_logps = token_logps * valid_mask.to(token_logps.dtype)
# seq prob = token prob 连乘 , log 后变成 sum
seq_logps = token_logps.sum(dim=-1)
return token_logps,seq_logps
练习 4:写一个最小 CausalLM train_step¶
不要求训练大模型,但要求接口像真实训练:
def train_step(model, batch, optimizer,max_grad_norm=None):
"""
batch:
input_ids : [B,L]
attention_mask : [B,L]
labels : [B,L]
return:
loss.item()
grad_norm
整个过程:
参数 θ
→ hidden states
→ logits
→ loss
→ backward 得到梯度
→ 更新参数 θ
→ 下一次 forward 时 logits 发生变化
"""
# 切换到训练模式 : 1️⃣Dropout 2️⃣BatchNorm(会更新)
model.train()
# 把梯度清成 None , 而不是 0 , 更省显存、速度更快
optimizer.zero_grad(set_to_none=True)
# forward
outputs = model(
input_ids = batch["input_ids"],
# 有的话取出来,没有的话返回None
attention_mask = batch.get("attention_mask",None),
labels = batch.get("labels",None),
)
# 如果算了 loss, 就返回 outputs 里的 loss
if hasattr(outputs,"loss") and outputs.loss is not None:
loss = outputs.loss
else:
#如果outputs有logits属性,就取outputs.logits; 否则就把outputs本身
logits = outputs.logits if hasattr(outputs,"logits") else outputs
#手动算loss
loss = causal_lm_loss_from_logits(logits,batch["labels"])
loss.backward()
#初始化梯度范数
#norm_type : L1(绝对值求和,更多用于诊断梯度总绝对量、稀疏分析: 可以分析梯度质量是否分散、梯度是不是稀疏集中在一些位置)、L2(平方求和再开方,和“欧式步长”更对应,并且对大梯度更敏感,参数变化量:-lr*g, L2距离衡量:lr*||g|| , 限制max L2, 就是限制这一步参数的移动距离)
#global(把模型所有梯度加起来算)、per_layer(每一层分开算)、per_parameter(每个参数张量分开算)
grad_norm = None
#梯度裁剪,按比例缩小,不超过max_grad_norm
#更新时: param = param - lr * grad 可能出现情况: 1️⃣loss出现inf/nan 2️⃣梯度异常大导致loss爆炸 3️⃣参数更新过猛,参数移动距离大,会去往另一个区间
if max_grad_norm is not None:
grad_norm = torch.nn.utils.clip_grad_norm_(
model.parameters(),
max_grad_norm,
)
optimizer.step()
# 这里是打日志,切出autograd计算图 (避免显存无法释放、OOM)
return {
"loss": float(loss.detach().cpu()),
"grad_norm": None if grad_norm is None else float(grad_norm.detach().cpu())
}
验收:
zero_grad -> forward -> loss -> backward -> grad_norm -> step顺序正确。- 能说清楚每一步如果漏掉会发生什么。
- 能把这个 train_step 映射到 SFTTrainer 的训练流程。
练习 5:手撕 Transformer¶
〇、前置¶
- 定位:
- Llama-style Transformer
- 流程:
- PreNorm RMSNorm
- -> Self-Attention with RoPE and possibly GQA
- -> Residual add
- -> RMSNorm
- -> SwiGLU MLP
- -> Residual add
- 和经典Transformer差异(前经典 , 后Llama):
- Norm: LayerNorm / RMSNorm
- Norm位置: post-norm / pre-norm
- 位置编码: 绝对位置编码 / RoPE
- Attention: MHA / 支持GQA & MQA
- FFN: Linear -> ReLU/GELU -> Linear / SwiGLU
- outputs: encoder & decoder / decoder-only CausalLM
- shape:
- B = batch size
- T = seq_len
- H = hidden_size
- V = vocab_size
- Nh = num_attention_heads
- Nkv = num_key_value_heads
- D = head_dim = H / Nh
- I = intermediate_size
- 完整的 forward:
- input_ids [B,T]
- -> embed_tokens -> hidden_states [B,T,H]
- -> LlamaDecoderLayer ✖️ N -> hidden_states [B,T,H]
- -> final RMSNorm -> hidden_states [B,T,H]
- -> lm_head -> logits [B,T,V]
- 单层Decoder内部 (每层内都做了残差连接):
- q_proj -> [B,T,Nh * D] -> view / transpose -> q : [B,Nh,T,D]
- k_proj -> [B,T,Nkv * D] -> view / transpose -> k : [B,Nkv,T,D]
- v_proj -> [B,T,Nkv * D] -> view / transpose -> v : [B,Nkv,T,D]
- RoPE(q,k)
- repeat_kv(k,v) if GQA
- attention score : [B,Nh,T,T]
- attention output : [B,Nh,T,D]
- transpose/reshape -> [B,T,H]
- o_proj -> [B,T,H]
- 一些概念:
- view: 同一块内存用新shape来解释 , 内存布局不兼容时需要先contiguous
- (作为对象时 , 表示共享storage , 但是 metadata 不同的两个tensor)
- transpose: 交换两个维度 , 只会改 stride , 不改真实 storage
- reshape: 能不复制 , 就返回 view ; 如果内存不连续 , 就复制一份新内存
- contiguous: 把一个 tensor 在内存中整理成“连续存放”的形式
- tips: (一个 tensor 由 真实数据内存 ➕ 可解释元信息 组成 , 其中元信息包括 : 1️⃣shape / size 2️⃣ stride : 表示某个维度上索引➕1 , 需要在内存上跳多少个元素3️⃣storage_offset4️⃣dtype5️⃣device6️⃣requires_grad . 常见的可能只改变 metadata 的操作: transpose、permute、view、reshape、unsqueeze、squeeze、expand , 如果后面需要做 storage上的操作 , 就需要 contiguous 操作)
一、RMSNorm¶
- Norm:
- 针对什么问题:
- 在一个 block 中 , x -> attention/MLP -> residual add -> 下一层
- 每一层的输出都可能让 hidden state 的尺度变大 or 变小
- 造成尺度变换的原因 : 1️⃣Linear proj 2️⃣Residual add 3️⃣MLP激活 4️⃣层数累积
- 方法:
- 对每个 token 的 hidden 向量 , 在 hidden_size 维度上做归一化
- 数学:
- 先取 hidden 向量的均值 : μ = (1 / H) * Σ x_i
- 再算方差 : σ² = (1 / H) * Σ (x_i - μ)²
- 然后归一化 : γ_i * (x_i - μ) / sqrt(σ² + ε) + β_i
- (1️⃣ γ_i 可学习缩放系数 2️⃣ β_i 可学习偏置参数 3️⃣ ε 防止除0)
- 针对什么问题:
- 原理:
- 只用均方根做归一化 , 不减均值 (分子、分母都不减均值)
- 同时因为不减均值 , 所以不用 偏置参数
- 公式:
- y_i = γ_i * x_i / sqrt(mean(x_i^2) + ε)
- 代码:
class LlamaRMSNormMini(nn.Module):
def __init__(self,hidden_size: int,eps: float = 1e-6):
super().__init__()
# 可学习缩放系数 γ_i
self.weight = nn.Parameter(torch.ones(hidden_size))
self.eps = eps
def forward(self,hidden_states: torch.Tensor) -> torch.Tensor:
input_dtype = hidden_states.dtype
# 用高精度,避免数值风险
x = hidden_states.to(torch.float32)
# 求平方平均,保持[B,T,1],方便后面广播相乘
variance = x.pow(2).mean(dim=-1,keepdim=True) #[B,T,1]
# rsqrt() = 1 / sqrt()
x = x * torch.rsqrt(variance + self.eps) # [B,T,H]
return (self.weight.float() * x).to(input_dtype) # [B,T,H] , 注意精度回归一下
二、RoPE¶
- 传统做法:
- 原理:
- 把 position embedding 加到 hidden_states 上
- 缺陷:
- 1️⃣表达的是绝对位置 , 不直接显式体现相对关系 , 但是自然语言里很多关系由相对位置决定
- 2️⃣泛化到超出训练长度的长上下文通常比较差
- 假如训练时只学到最大长度 2048 , 后续推理中如果长度扩展到 4096 or 更大 , 尤其是现在长 cot、代码生成、数学推理中 , 往往上下文变长是肯定的
- (可以用插值、随机初始化来解决 , 但是模型因为没有经过专门的训练学习 , 所以效果不好)
- 3️⃣位置和语义相加在同一个hidden_state中 , 二者会直接耦合
- 原理:
- 原理:
- 把位置信息通过旋转注入到 query_states、key_states中
- 具体原理:
- Attention 分数计算 :
- score(i,j) = Q_i @ K_j , 表示 第 i 个 token 对第 j 个 token的关注程度
- 让位置直接融入 Q、K:
- 这样可以直接感知相对位置
- 看一个最简单的二维旋转 :
- q_embed = q * cos + rotate_half(q) * sin
- k_embed = k * cos + rotate_half(k) * sin
- 扩展到高维:
- q.shape = [B,Nh,T,D] k.shape = [B,Nkv,T,D]
- 把最后一维两两分组 , 但是不同组用不同频率 , 这样就可以对短距离、长距离位置变化都进行感知
- 频率:
- 每一组都有一个频率 freq_i , 通过频率来决定不同的 theta
- freq_i = 1 / base ^ (2i / head_dim)
- 其中 base一般为10000 , i 表示第几组 , head_dim 为每个 attention_head的维度
- 这样设置后:
- 1️⃣较高频率组 , pos 变化一点 , 角度就变化很多 (用来捕捉局部、短距离差异)
- 2️⃣较低频率组 (用来捕捉长距离、全局位置关系)
- 某一组视角下的score计算:
- (q1 cosθ_i - q2 sinθ_i) * (k1 cosθ_j - k2 sinθ_j)
-
- (q2 cosθ_i + q1 sinθ_i) * (k2 cosθ_j + k1 sinθ_j)
- 变形后:
- (q1 k1 + q2 k2) * cos(θ_i - θ_j) + (q1 k2 - q2 k1) * sin(θ_i - θ_j)
- 自然而然 ! 相对位置就体现出来了
- 改进后的切半版:
- 为什么:
- todo
- 前情提要:
- q = q_proj(hidden_states)
- k = k_proj(hidden_states)
- reshape -> [B,Nh,T,D] [B,Nkv,T,D]
- RoPE 作用在 D 上
- 假设某个 head 的一个向量 x = [x0,x1,...,x7]
- 做法:
- 前半 : [x0,x1,x2,x3] 后半: [x4,x5,x6,x7]
- rotate_half (x) = [-x4,-x5,-x6,-x7,x0,x1,x2,x3]
- 这样的切分 , 和 freq 的 cat 严格匹配 , 不用改很多
- 布局 : (x0,xD/2) , (x1,xD/2+1), ...
- 具体的见代码
- 为什么:
- 代码:
- Attention 分数计算 :
def rotate_half(x:torch.Tensor) -> torch.Tensor:
# 前面保持,只对最后一维切片
x1 = x[...,:x.shape[-1] // 2]
x2 = x[...,x.shape[-1] // 2 :]
return torch.cat((-x2,x1),dim=-1)
def build_rope_cos_sin(
position_ids: torch.Tensor,
head_dim: int,
base: float = 10000.0,
dtype=torch.float32,
):
"""
position_ids: [B,T]
return:
cos: [B,T,D]
sin: [B,T,D]
"""
device = position_ids.device
# 取从0到head_dim以step为2的tensor , 然后归一化范围 , 然后作为base的指数
# 最后得到 [1/10000^0 , 1/10000^0.25 , 1/10000^0.5 , 1/10000^0.75]
inv_freq = 1.0 / (
base ** (
torch.arange(0, head_dim, 2 , device = device, dtype = torch.float32)
/ head_dim
)
) # [D/2]
# 每个pos、每组的θ = 每pos ✖️ 每组的inv_freq_i
# [B,T,D/2] = [B,T,1] ✖️ [1,1,D/2]
# -1 表示这一维大小自动推断
freqs = position_ids.float().unsqueeze(-1) * inv_freq.view(1,1,-1)
#[B,T,D/2] cat [B,T,D/2] = [B,T,D]
emb = torch.cat([freqs,freqs],dim=-1)
# 把 θ 变成 cosθ、sinθ
cos = emb.cos().to(dtype)
sin = emb.sin().to(dtype)
return cos,sin
def apply_rotary_pos_emb(q,k,cos,sin,unsqueeze_dim=1):
"""
q : [B,Nh,T,D]
k : [B,Nkv,T,D]
cos : [B,T,D]
sin : [B,T,D]
return:
q_embed same shape
k_embed same shape
"""
cos = cos.unsqueeze(unsqueeze_dim)
sin = sin.unsqueeze(unsqueeze_dim)
q_embed = q * cos + rotate_half(q) * sin
k_embed = k * cos + rotate_half(k) * sin
return q_embed, k_embed
三、GQA 等等¶
- MHA、MQA、GQA
- MHA:
- num_q_heads= num_k_heads = num_v_heads
- MQA:
- 所有 q heads 共享一组 k/v heads
- GQA:
- q heads 分组 , 每个组的 q_heads 共享一组 k/v heads
- MHA:
- repeat_kv
- 把 [B,Nkv,T,D] 扩展成 [B,Nq,T,D]
- 代码:
def repeat_kv(hidden_states: torch.Tensor,n_rep: int) -> torch.Tensor:
"""
hidden_states: [B,Nkv,T,D]
n_rep: Nh // Nkv
return:
[B,Nh,T,D]
"""
if n_rep == 1:
return hidden_states
B,Nkv,T,D = hidden_states.shape
hidden_states = hidden_states[:,:,None,:,:] #新增一维 , 因为expand只能把大小为1的维度扩展成更大的
hidden_states = hidden_states.expand(B,Nkv,n_rep,T,D)
# expand 本身不copy;后续 reshape 是否复制取决于内存布局
return hidden_states.reshape(B,Nkv*n_rep,T,D)
- 通用类Attention:
class MiniLlamaAttention(nn.Module):
def __init__(
self,
hidden_size: int,
num_attention_heads: int,
# MHA、MQA、GQA 对应着只用改 num_key_value_heads
num_key_value_heads: int,
attention_dropout: float = 0.0,
attention_bias: bool = False,
):
super().__init__()
assert hidden_size % num_attention_heads == 0
assert num_attention_heads % num_key_value_heads == 0
self.hidden_size = hidden_size
self.num_attention_heads = num_attention_heads
self.num_key_value_heads = num_key_value_heads
self.head_dim = hidden_size // num_attention_heads
self.num_key_value_groups = num_attention_heads // num_key_value_heads
#缩放因子 和 dropout比例
self.scaling = self.head_dim ** -0.5
self.attention_dropout = attention_dropout
self.q_proj = nn.Linear(
hidden_size,
num_attention_heads * self.head_dim,
bias = attention_bias,
)
self.k_proj = nn.Linear(
hidden_size,
num_key_value_heads * self.head_dim,
bias = attention_bias,
)
self.v_proj = nn.Linear(
hidden_size,
num_key_value_heads * self.head_dim,
bias = attention_bias,
)
self.o_proj = nn.Linear(
num_attention_heads * self.head_dim,
hidden_size,
bias = attention_bias,
)
def forward(
self,
hidden_states: torch.Tensor,
attention_mask: torch.Tensor | None = None,
):
"""
hidden_states: [B,T,H]
attention_mask: None or [B,1,T,T]
return:
attn_output: [B,T,H]
attn_weights: [B,Nh,T,T]
"""
B,T,H = hidden_states.shape
#投影得到 q/k/v
query_states = self.q_proj(hidden_states) # [B,T,Nh*D]
key_states = self.k_proj(hidden_states) # [B,T,Nkv*D]
value_states = self.v_proj(hidden_states) # [B,T,Nkv*D]
#拆 head, 并把 head 维移到前
query_states = query_states.view(
B,T,self.num_attention_heads,self.head_dim
).transpose(1,2) # [B,T,Nh,D] -> [B,Nh,T,D]
key_states = key_states.view(
B,T,self.num_key_value_heads,self.head_dim
).transpose(1,2) # [B,T,Nkv,D] -> [B,Nkv,T,D]
value_states = value_states.view(
B,T,self.num_key_value_heads,self.head_dim
).transpose(1,2) # [B,T,Nkv,D] -> [B,Nkv,T,D]
# 在这里加 RoPE (todo)
key_states = repeat_kv(key_states,self.num_key_value_groups) # [B,Nh,T,D]
value_states = repeat_kv(value_states,self.num_key_value_groups) # [B,Nh,T,D]
# [B,Nh,T,D] @ [B,Nh,D,T] = [B,Nh,T,T]
attn_weights = torch.matmul(
query_states,
key_states.transpose(2,3),
) * self.scaling
#加 causal/padding mask , 先加再做 softmax , 因为 mask 位置通常是 -inf or 很大负数, softmax后概率就为 0
#在这里加 causal_mask (todo)
if attention_mask is not None:
attn_weights = attn_weights + attention_mask
#softmax 得到注意力权重
attn_weights = F.softmax(
attn_weights,
dim = -1,
dtype = torch.float32,
).to(query_states.dtype)
#dropout
#一般是以概率p把部分attn_weights元素置为0 , 其余元素一般除以 1-p 做一个缩放
attn_weights = F.dropout(
attn_weights,
p = self.attention_dropout,
training = self.training, # nn.Module自带的bool属性,True表示训练模式,False推理
)
# [B,Nh,T,T] @ [B,Nh,T,D] = [B,Nh,T,D]
attn_output = torch.matmul(attn_weights,value_states)
# 合并 heads
attn_output = attn_output.transpose(1,2).contiguous().view(B,T,H)
# 输出投影
attn_output = self.o_proj(attn_output)
return attn_output,attn_weights
四、causal mask¶
- 原理:
- 第 t 个 token 不能看未来 token
- mask 矩阵就为
- 链路:
- HF中 LlamaModel.forward 使用 create_causal_mask(...) 生成 causal mask , 然后传给每一层 decoder layer
- 代码:
def make_causal_mask(
batch_size: int,
seq_len: int,
attention_mask: torch.Tensor | None,
dtype: torch.dtype,
device: torch.device,
):
"""
return:
causal_mask: [B,1,T,T] (和head无关 , 为 1 即可)
允许位置为0,被 mask 的位置为 dtype 最小值
"""
# finfo(dtype) 返回某个浮点类型的数值范围信息, 取其中的min属性
neg_inf = torch.finfo(dtype).min
#初始化
mask = torch.zeros((seq_len,seq_len), dtype = dtype, device = device)
#上三角的未来token位置设置为 -inf
# triu(..., diagonal=1) 取上三角,表示从主对角线的上一条对角线开始保留,其余位置置为0
# torch.ones(...) 设置一个形状为T*T的bool类型的矩阵,都设置为True
# mask.masked_fill(...) 把mask 在 future_mask为True的地方设置为neg_inf, False位置保持mask原有的 0
future_mask = torch.triu(
torch.ones((seq_len,seq_len), dtype = torch.bool, device = device),
diagonal=1,
)
mask = mask.masked_fill(future_mask,neg_inf)
# [1,1,T,T] -> [B,1,T,T]
mask = mask[None,None,:,:].expand(batch_size,1,seq_len,seq_len)
# 在 causal_mask 基础上,把 padding token 也mask掉
# attention_mask : 1 表示真实 token,0 表示 padding token
if attention_mask is not None:
padding_mask = attention_mask[:,None,None,:].eq(0) # [B,T] -> [B,1,1,T] , 并转成bool矩阵
mask = mask.masked_fill(padding_mask,neg_inf)
return mask
五、Self-Attention¶
- 原理:
- Q = x @ Wq 、K = x @ Wk、V = x @ Wv
- score = Q @ K^T / sqrt (head_dim)
- score = score + mask
- weights = softmax( score )
- output = weights @ V
- output = output @ Wo
- shape变化:
- Q : x [B,T,H] ->
- 代码:
class LlamaAttentionMini(nn.Module):
def __init__(
self,
hidden_size: int,
num_attention_heads: int,
num_key_value_heads: int,
attention_dropout: float = 0.0,
attention_bias: bool = False,
):
super().__init__()
assert hidden_size % num_attention_heads == 0
assert num_attention_heads % num_key_value_heads == 0
self.hidden_size = hidden_size
self.num_attention_heads = num_attention_heads
self.num_key_value_heads = num_key_value_heads
self.head_dim = hidden_size // num_attention_heads
self.num_key_value_groups = num_attention_heads // num_key_value_heads
self.scaling = self.head_dim ** -0.5
self.attention_dropout = attention_dropout
self.q_proj = nn.Linear(
hidden_size,
num_attention_heads * self.head_dim,
bias = attention_bias
)
self.k_proj = nn.Linear(
hidden_size,
num_key_value_heads * self.head_dim,
bias = attention_bias,
)
self.v_proj = nn.Linear(
hidden_size,
num_key_value_heads * self.head_dim,
bias = attention_bias,
)
self.o_proj = nn.Linear(
num_attention_heads * self.head_dim,
hidden_size,
bias = attention_bias,
)
def forward(
self,
hidden_states: torch.Tensor,
attention_mask: torch.Tensor,
position_embeddings: tuple[torch.Tensor,torch.Tensor],
):
"""
hidden_states: [B,T,H]
attention_mask: [B,1,T,T]
position_embeddings: (cos,sin) each[B,T,D]
return:
attn_output: [B,T,H]
"""
B,T,H = hidden_states.shape
q = self.q_proj(hidden_states)
k = self.k_proj(hidden_states)
v = self.v_proj(hidden_states)
q = q.view(B,T,self.num_attention_heads,self.head_dim).transpose(1,2)
k = k.view(B,T,self.num_key_value_heads,self.head_dim).transpose(1,2)
v = v.view(B,T,self.num_key_value_heads,self.head_dim).transpose(1,2)
# RoPE
cos,sin = position_embeddings
q,k = apply_rotary_pos_emb(q,k,cos,sin)
# repeatKV
k = repeat_kv(k,self.num_key_value_groups)
v = repeat_kv(v,self.num_key_value_groups)
# score -> mask -> softmax -> dropout -> weights
attn_scores = torch.matmul(q,k.transpose(-2,-1)) * self.scaling
attn_scores = attn_scores + attention_mask
attn_weights = F.softmax(
attn_scores,
dim = -1,
dtype = torch.float32,
).to(q.dtype)
attn_weights = F.dropout(
attn_weights,
p = self.attention_dropout,
training = self.training,
)
attn_output = torch.matmul(attn_weights,v)
attn_output = attn_output.transpose(1,2).contiguous().view(B,T,H)
attn_output = self.o_proj(attn_output)
return attn_output
- tips:
- RoPE只作用于 q、k , 不包括 v
- causal_mask 在 softmax 之前
- GQA、MQA 需要 repeatKV 让 Nkv -> Nh
六、MLP ( SwiGLU )¶
- MLP
- 原理:
- 对每个 token 自己的 hidden 向量做非线性特征变换 , 不负责交互 , 逐 token 独立处理
- 每个 token 用同一套 MLP 参数
- 公式:
- MLP ( x ) = W_down ( activation ( W_up ( x ) ))
- 先升维 , 然后做非线性变换 , 然后降维
- 形状变化:
- shape
- x [B,T,H]
- -> W_up -> W_up x [B,T,I]
- -> activation φ(h)
- -> W_down -> out [B,T,H]
- 过程
- 对每个 token 独立做 : x[b,t, : ] -> [I] -> activation -> [H]
- shape
- activation:
- 原理:
- hidden 向量里可能已经包含 1️⃣语法特征2️⃣实体特征3️⃣推理状态4️⃣格式特征5️⃣上下文依赖特征 , up_proj 后生成一组更加丰富的中间特征 , activation 对这些 feature 进行非线性调制 , 压小 / 放大 / 置 0 / 平滑放缩
- 常见:
- 普通形式 :
- 原理:
- 对应改动 MLP (x) = W_down ( φ (W_up x)) 里的 φ , activation 作用在 W_up x 上 (h = W_up x)
- ReLU (硬开关)
- ReLU ( h ) = max ( 0 , h )
- 1️⃣h > 0 保留 2️⃣h <= 0 变成 0
- 优点 : 简单、快、稀疏、正通过负关闭
- 缺点 : 负区间梯度为 0 ,可能出现 deadReLU
- 场景 : 早期 CNN、普通MLP、非大模型场景
- GELU (平滑筛选)
- 原笔记图片占位:
Pasted image 20260711163349.png - GELU (h) = h * Φ(h)
- 1️⃣ h 很大 -> 接近 h 2️⃣ h 很小 -> 接近 0 3️⃣ h 在 0 附近 -> 平滑过渡
- 优点: 梯度更连续
- 缺点: 稍贵
- 场景: BERT、GPT早期
- 原笔记图片占位:
- SiLU (自门控激活)
- 自门控 , 门控系数由 h 产生
- SiLU (h) = h * sigmoid (h) , 其中 sigmoid (h) = 1 / ( 1 + exp ( -h ) )
- 1️⃣ h 很大 -> sig ≈ 1 , 即 接近 x 2️⃣ h 接近 0 -> sig ≈ 0.5 3️⃣ h 很负 -> sig ≈ 0
- 优点: 输入自己生成门控 , 平滑、可自调
- 缺点: 不产生强稀疏
- 场景: 现代LLM的MLP子层
- 原理:
- 变种形式:
- 原理:
- W_up x 既要生成候选特征、又要被激活筛选 , 内容生成和门控判断耦合 , 不如解耦 , 解耦有如下好处:
- 1️⃣职责解耦
- 2️⃣(W_up x ) * φ(W_gate x) 乘性交互后 , 出现各种二阶交互项 , 表达能力远强于 φ(W_gate x)
- 3️⃣虽然所有 token 共享一套 W_gate参数 , 但是因为输入的 x_bt 不同 , 经过W_gate得到的 gate_bt 也不同 , 所以不同的 token 通过由它自己决定的不同的门控信号
- GLU ( 显式门控 )
- GLU (x) = A (x) * sigmoid(B(x))
- 完整 : MLP_GLU(x) = W_down ( (W_up x) * sigmoid(W_gate x) )
- 优点: 动态门控(指的是每个token的gate不同)、0~1相对稳定
- 缺点: 1️⃣sigmoid 易饱和 , 极大/极小时梯度变小 (导数在极大极小时均为0 , W_gate就不容易继续学习); 2️⃣0~1决定压制/通过 , 无法放大 ; 3️⃣参数量更大
- 场景: 稳定场景
- SwiGLU
- SwiGLU (x) = A (x) * SiLU(B(x))
- 完整 : MLP_SwiGLU(x) = W_down ((W_up x) * SiLU(W_gate x))
- 优点: 平滑自门控、sigmoid软门控+W_gate x 的幅度信息
- 缺点: 计算更多、数值调制更自由、对训练稳定性要求高
- 场景: 现代大模型 decoder block、追求 FFN 表达能力的场景
- GEGLU
- GEGLU (x) = A (x) * GELU(B(x))
- 完整 : MLP_GEGLU(x) = W_down((W_up x) * GELU(W_gate x))
- 优点: 更平滑、gate不局限于0~1
- 缺点: 计算复杂、直觉上不干净
- 场景: 早一批 gated FFN 变体中常见
- 原理:
- 普通形式 :
- 原理:
- 代码
- 原理:
class LlamaMLPMini(nn.Module):
def __init__(
self,
hidden_size: int,
intermediate_size: int,
mlp_bias: bool = False,
):
super().__init__()
self.gate_proj = nn.Linear(hidden_size,intermediate_size,bias=mlp_bias)
self.up_proj = nn.Linear(hidden_size,intermediate_size,bias=mlp_bias)
self.down_proj = nn.Linear(intermediate_size,hidden_size,bias=mlp_bias)
def forward(
self,
x: torch.Tensor,
) -> torch.Tensor:
"""
x: [B,T,H]
return: [B,T,H]
"""
gated = F.silu(self.gate_proj(x)) #[B,T,I]
up = self.up_proj(x) #[B,T,I]
x = gated * up #[B,T,I] 逐元素乘
x = self.down_proj(x) #[B,T,H]
return x
七、LlamaDecoderLayer¶
- 链路位置
- pre-norm residual : 先 norm , 再子层 , 再残差连接
- residual = hidden_states
- hidden_states = input_layernorm(hidden_states)
- hidden_states = self_attn(hidden_states)
- hidden_states = hidden_states + residual
- 两种主流结构:
- 链路位置
- Post-Norm 「 Norm ( Sublayer(x) + x )」 残差后
- x -> sublayer -> residual add -> norm
- 优点: 输出尺度规整
- 缺点: 深层模型里 , 要穿过很多 norm 和 sublayer , 稳定性一般
- 场景: 现在不怎么用
- Pre-Norm 「x + Sublayer( Norm (x))」 子层前
- x -> norm -> sublayer -> residual add
- 优点: 显然求导更简单 , 更稳定
- 缺点: residual主干得到的产物 , 其实一直是没有norm的 , norm的一直是attn 和 mlp的输入 , 所以在最后送入 lm_head 前 , 要做一次 Final-Norm
- 场景: 主流
- Final-Norm 「Norm ( last_hidden_states )」 所有层之后 , lm_head之前
- x -> sublayer -> residual add -> ... -> norm
- 优点: 无
- 缺点: 无
- 场景: 其实通常结合 Pre-Norm 来用
- Post-Norm 「 Norm ( Sublayer(x) + x )」 残差后
- 维度划分
- BatchNorm
- 对某个特征 / channel , 在batch维度上统计均值和方差 , 常见于 CNN
- LLM里 , batch size、seq len、padding、生成方式变化很大 , 不方便依赖batch统计
- LayerNorm
- 在每一个layer中 , 对每个 token 的 hidden 向量做 norm , 沿 hidden 维度来统计均值和方差 , 不跨batch , 也不跨token
- RMSNorm
- LayerNorm 一种去中心化的变体
- BatchNorm
- 链路位置
- tips
- 1️⃣Encoder-Decoder 架构里往往有 self-attn 和 cross-attn , 但是Llama / Qwen / GPT这类主流的Decoder-only架构中 , 每层主要是 masked-self-attn
- 2️⃣Cross-attn (这里可以深入 todo)
- Decoder 在生成当前某个 token 时 , 要去看 Encoder 编码好的信息
- 即: decoder token 作为 query , 去 encoder 输出里计算 query @ key ^T
- 代码
- 一些点:
- 1️⃣这些子模块一般定义在类外自己的类中 , DecoderLayer类只负责把这些组装起来
- 一些点:
- pre-norm residual : 先 norm , 再子层 , 再残差连接
class LlamaDecoderLayerMini(nn.Module):
def __init__(
self,
hidden_size: int,
intermediate_size: int,
num_attention_heads: int,
num_key_value_heads: int,
rms_norm_eps: float = 1e-6,
attention_dropout: float = 0.0
):
super().__init__()
# 第一个 norm , 给 Attention 用 , 让 Attention 看到尺寸稳定的输入
self.input_layernorm = LlamaRMSNormMini(hidden_size,eps=rms_norm_eps)
self.self_attn = LlamaAttentionMini(
hidden_size = hidden_size,
num_attention_heads = num_attention_heads,
num_key_value_heads = num_key_value_heads,
attention_dropout = attention_dropout,
)
#在 Attention 残差连接之后 , 尺寸又变了 , 进入 MLP 前再 norm 一次
self.post_attention_layernorm = LlamaRMSNormMini(
hidden_size = hidden_size,
eps = rms_norm_eps,
)
self.mlp = LlamaMLPMini(
hidden_size = hidden_size,
intermediate_size = intermediate_size,
)
def forward(
self,
hidden_states: torch.Tensor,
attention_mask: torch.Tensor,
position_embeddings: tuple[torch.Tensor,torch.Tensor],
) -> torch.Tensor:
"""
做的事:
input_layernorm
-> self_attn
-> post_attention_layernorm
-> mlp
-> output
hidden_states : [B,T,H]
return : [B,T,H]
"""
residual = hidden_states
hidden_states = self.input_layernorm(hidden_states)
attn_output = self.self_attn(
hidden_states = hidden_states,
attention_mask = attention_mask,
position_embeddings = position_embeddings,
)
hidden_states = residual + attn_output
residual = hidden_states
hidden_states = self.post_attention_layernorm(hidden_states)
mlp_output = self.mlp(hidden_states)
hidden_states = residual + mlp_output
return hidden_states
八、LlamaModel¶
- 流程串讲
- 学到这了 , 还是先巩固一下认知 :D , 过一下完整的 forward 流程
- (前提: input_ids 由 tokenizer 得到)
- input_ids / attention_mask / position_ids / labels
- -> Embedding -> input_ids [B,T] -> hidden_states [B,T,H]
- -> 提前构造: causal & padding mask + RoPE 的 cos & sin
- -> N 个 DecoderLayer
- -> Final RMSNorm
- -> lm_head [B,T,H] -> [B,T,V]
- -> logits
- -> with labels compute cross entropy -> scalar loss
- tips
- 1️⃣mask 构造 & RoPE 并不需要 hidden_states , 但为了能对齐 device 和 dtype , HF Llama中通常是在Embedding input_ids 后再构造这两者
- 2️⃣forward 做: Embedding、DecoderLayer (attn、mlp、norm、residual add)、lm_head、compute loss、scalar loss
- backward 做: 在CE -> logits -> lm_head -> Final Norm -> each DecoderLayer 过程中 , 计算每个可训练参数的梯度 , 并累积到 param.grad
- 3️⃣embed_tokens.weight 和 lm_head.weight 的形状是一致的 , 并且可选权重共享 , 默认不选 , 选了后的好处 :
- (1) 少一张矩阵, 参数量减少
- (2) 输入token表示和输出token表示共用一套语义基底 细品!!!!!!!!!!!!!!!!!!!!!!!
- ( token 的输入 embedding 同时充当输出分类器中的 token 向量 , 训练中 : 前缀表示 h_t 与正确的下一个 token 对应的 E[y] 点积增大、与错误token的点积减小 , 从而让输入表示空间和输出预测空间使用同一套学习到的几何结构 )
- (3) 同一个权重同时处理输入和输出 , 有一定正则化效果
- 但其实两个矩阵的任务不同
- Embedding -> 取第 i 行作为输入向量
- lm_head -> hidden_state @ lm_heads.weight.T -> 每个token的logit
- 为什么默认 false
- (1) 要兼容pre-train , 两张矩阵在预训练里是分别训练的 , 如果突然绑定 , 会丢弃其中一张
- (2)可以独立优化
- 代码
- 学到这了 , 还是先巩固一下认知 :D , 过一下完整的 forward 流程
class LlamaModelMini(nn.Module):
def __init__(
self,
vocab_size : int,
hidden_size : int,
intermediate_size : int,
num_attention_heads : int,
num_key_value_heads : int,
num_hidden_layers : int,
rms_norm_eps : float = 1e-6,
rope_theta : float = 10000.0,
pad_token_id: int | None = None,
):
"""
只对应backbone , 不包含 lm_head
"""
super().__init__()
self.vocab_size = vocab_size
self.hidden_size = hidden_size
self.head_dim = hidden_size // num_attention_heads
self.rope_theta = rope_theta
...
self.embed_tokens = nn.Embedding(
vocab_size,
hidden_size,
padding_idx = pad_token_id,
)
self.layers = nn.ModuleList([
LlamaDecoderLayerMini(
hidden_size = hidden_size,
intermediate_size = intermediate_size,
num_attention_heads = num_attention_heads,
num_key_value_heads = num_key_value_heads,
rms_norm_eps = rms_norm_eps,
)
for _ in range(num_hidden_layers)
])
# 独立子模块 , 不是Layer中的 , 负责 lm_head 前 norm
self.norm = LlamaRMSNormMini(hidden_size,eps = rms_norm_eps)
def forward(
self,
input_ids: torch.LongTensor,
attention_mask: torch.Tensor | None = None,
position_ids: torch.LongTensor | None = None,
) -> torch.Tensor:
"""
input_ids: [B,T]
attention_mask: [B,T]
return:
hidden_states: [B,T,H]
"""
B,T = input_ids.shape
device = input_ids.device
hidden_states = self.embed_tokens(input_ids)
# position_ids 为 None 时,生成一个[B,T],每个[b,:] = [0,1,2,...,T-1]
if position_ids is None:
position_ids = torch.arange(T,device = device).unsqueeze(0).expand(B,T)
causal_mask = make_causal_mask(
batch_size = B,
seq_len = T,
attention_mask = attention_mask,
dtype = hidden_states.dtype,
device = device,
)
cos,sin = build_rope_cos_sin(
position_ids = position_ids,
head_dim = self.head_dim,
base = self.rope_theta,
dtype = hidden_states.dtype,
)
position_embeddings = (cos,sin)
for layer in self.layers:
hidden_states = layer(
hidden_states = hidden_states,
attention_mask = causal_mask,
position_embeddings = position_embeddings,
)
hidden_states = self.norm(hidden_states)
return hidden_states
九、LlamaForCausalLM¶
- 链路位置
- 这里主要是讲清楚 3 类 mask 的作用时期
- self.model = LlamaModel(config)
- self.lm_head = Linear(hidden_size,vocab_size,bias=False)
- forward:
- outputs = self.model() #这里经过了causal_mask和attention_padding_mask(两者都会作用于注意力分数,一个负责因果约束、一个负责padding部分)
- hidden_states = output.last_hidden_state
- logits = self.lm_head(hidden_states[:,slice_indices,:]) #这里是可选优化(推理只要最后一位的logits、RLlogprob重算,只需要completion位置、训练时都需要)
- if labels is not None: #这里经过了completion/padding_mask(不参与损失计算)
- loss = self.loss_function(...)
- return CausalLMOutputWithPast(...)
- 我们走一个具体例子 , 来深刻认知一下三类mask
"""
假设序列为 input_ids = [P0, P1, A0 , A1, PAD]
(其中 P 代表 prompt、A代表 assistant)
一、Causal mask
位置: Attention内, score -> weight期间
规则: query 位置 i 不能关注 key 位置 j>i , 本质由triu获得, 为bool矩阵
矩阵:
[[0,-∞,-∞,-∞,-∞],
[0, 0,-∞,-∞,-∞],
[0, 0, 0,-∞,-∞],
...]
控制: 每个 token 能看到哪些历史位置
二、Padding attention mask
位置: 同一 , 会和Causal mask相加 , 一般是 0/1形式 , 通过eq(0)转换成 bool 矩阵
...
三、Completion Loss mask
位置: lm_head 得到 logits 后, 计算 loss 时
规则: prompt、padding 不参与 loss 计算 , 一般为 -100
tips、slice before lm_head
位置: lm_head 前
规则: 不同的场景,需要计算logit的位置不同
一个小思考: 为什么要在计算出 score 后再进行 masked_fill ?
答: 我们最开始拿到[B,T], 然后Embedding + QK投影后, 得到[B,Nh,T,D]、[B,Nkv,T,D] , 这时候没有相关性, 只有 Q @ K^T 后得到[B,Nh,T,T], 才有 q-k关系 , 而 mask 屏蔽的正是 q-k 关系, 只有在[T_query , T_key] 的矩阵上逐格屏蔽
"""
- 代码
class LlamaCausalLMMini(nn.Module):
def __init__(
self,
vocab_size,
hidden_size,
intermediate_size,
num_hidden_layers,
num_attention_heads,
num_key_value_heads,
pad_token_ids
):
super().__init__()
self.model = LlamaModelMini(
vocab_size,
hidden_size,
...
)
self.lm_head = nn.Linear(hidden_size,vocab_size,bias=False)
def forward(
self,
input_ids,
attention_mask: torch.Tensor,
labels: torch.Tensor | None = None,
):
#这里self.model , 会先调用子模块LlamaModelMini的各类hooks方法,再执行子模块的forward
hidden_states = self.model(
input_ids = input_ids,
attention_mask = attention_mask,
)
logits = self.lm_head(hidden_states)
loss = None
if labels is not None:
loss = self.causal_lm_loss(logits,labels)
return {
"loss" : loss,
"logits" : logits,
}
@staticmethod
def causal_lm_loss(logits,labels,ignore_index=-100):
B,T,V = logits.shape
shift_labels = labels[:,1:].contiguous()
shift_logits = logits[:,:-1,:].contiguous()
loss = F.cross_entropy(
shift_logits.view(-1,V),
shift_labels.view(-1),
ignore_index = ignore_index,
)
return loss
十、我们这次练习, 忽略了哪些?¶
- 真实推理时,会使用past_key_values、logits_to_keep
- 没有配置sdpa、flash attention、flex attention
- 没有写Dynamic RoPE scaling
一些经典问题¶
这部分不只是面试题,也可以用来做自测。回答时不要只给一句定义,最好能同时讲清楚:它在完整训练链路中的位置、输入输出 Shape、对梯度或显存的影响,以及写代码时容易出现的问题。
前向传播与 Loss¶
- logits、prob、logprob 的区别是什么?
- 需要说明三者之间通过
softmax、log_softmax建立的关系; - 需要说明它们在词表维度上的 Shape,以及为什么 logits 还不是概率;
- 进一步说明为什么训练和后训练通常更喜欢直接使用 logprob。
- 需要说明三者之间通过
- cross entropy 为什么等价于目标 token 的负 logprob?
- 从单个位置的 \(-\log p_y\) 讲到 Batch 内有效 Token 的聚合;
- 能解释目标 Token 和非目标 Token 对应的 Logit 梯度方向为什么不同;
- 能说明
ignore_index=-100和 Loss Mask 会在哪一步生效。
- CausalLM 为什么需要 Labels Shift?
- 说明位置 \(t\) 的 Hidden State 为什么应该预测位置 \(t+1\) 的 Token;
- 写出
shift_logits与shift_labels的切片方式和 Shape; - 能排查没有 Shift、Shift 方向写反或 Mask 没有同步移动的问题。
- 为什么获取目标 Token 的 logprob 时需要
gather?- 说明
[B,T,V]的 Log Probability Tensor 如何根据 Token ID 变成[B,T]; - 说明
gather只是选择目标位置,并不重新计算或改变概率分布; - 能解释
selective_log_softmax为什么可以降低峰值显存。
- 说明
反向传播与参数更新¶
loss.backward()后到底更新了什么?- 要明确
backward()只负责计算并累积梯度,不会直接更新参数; - 能沿着
loss -> logits -> lm_head -> decoder layers -> embedding说明梯度如何传播; - 能解释为什么某些参数的
.grad仍然可能是None。
- 要明确
requires_grad=False的参数会发生什么?- 说明该参数本身不会积累梯度,也不会通过普通梯度更新发生变化;
- 区分“这个参数被冻结”和“整条计算图被
detach()切断”; - 能联系到冻结 Backbone、只训练 LoRA 或只训练部分模块的场景。
- 为什么训练前要执行
optimizer.zero_grad()?- 说明 PyTorch 的梯度默认执行累积,而不是覆盖;
- 区分正常的 Gradient Accumulation 和忘记清梯度导致的意外累积;
- 说明
set_to_none=True与把梯度清零之间的区别。
- Adam 和 AdamW 的区别是什么?
- 能说明一阶矩、二阶矩和 Bias Correction 分别在做什么;
- 重点解释 AdamW 为什么要让 Weight Decay 与梯度更新解耦;
- 能指出
optimizer.step()消费的是参数.grad和 Optimizer State。
- Gradient Accumulation 会怎样改变一次
optimizer.step()?- 说明多个 Micro-Batch 的梯度如何累积到同一份
.grad; - 说明为什么通常要按 Accumulation Steps 缩放 Loss;
- 能区分 Micro-Step、Optimizer Step 和 Learning Rate Scheduler Step。
- 说明多个 Micro-Batch 的梯度如何累积到同一份
- 梯度裁剪裁剪的是什么,为什么 RL 后训练经常需要它?
- 区分按值裁剪和按全局范数裁剪,重点解释
clip_grad_norm_; - 说明裁剪发生在
backward()之后、optimizer.step()之前; - 能解释 Reward、Advantage 或 Ratio 波动为什么可能产生异常梯度。
- 区分按值裁剪和按全局范数裁剪,重点解释
CausalLM 与 Transformer 结构¶
- Causal Mask、Padding Attention Mask 和 Loss Mask 有什么区别?
- 分别指出它们作用在 Attention Score、Padding Key 和 Loss 聚合的哪个阶段;
- 说明“某个 Token 不参与 Loss”不等于“它不参与前向计算”;
- 能根据 Prompt、Completion、Padding 的具体序列手画三种 Mask。
- RoPE 为什么只作用于 Query 和 Key,而不直接作用于 Value?
- 从 \(QK^T\) 决定注意力权重这一点解释位置关系应该注入哪里;
- 能说明旋转后内积如何显式包含相对位置差;
- 能结合
[B,Nh,T,D]说明 RoPE 实际作用的维度。
- MHA、MQA 和 GQA 的区别是什么?
- 比较 Query Head 与 Key/Value Head 的数量关系;
- 说明
repeat_kv为什么只扩展 K/V Head; - 能分析 GQA 在表达能力、KV Cache 和推理效率之间的折中。
- RMSNorm、Pre-Norm、Residual Connection 分别解决什么问题?
- 说明 RMSNorm 沿 Hidden Size 维度做什么;
- 能手画一层 Decoder 中 Norm、Attention、MLP 和残差连接的顺序;
- 说明为什么 Pre-Norm 架构通常还需要 Final Norm。
精度、显存与工程排查¶
- bf16 和 fp16 的训练稳定性差异是什么?
- 比较两者的指数位、尾数位和可表示范围;
- 解释为什么 FP16 更容易 Overflow/Underflow,以及 Loss Scaling 的作用;
- 同时说明 BF16 动态范围更大不代表所有计算都不会出现数值问题。
- 为什么 Loss 正常下降,生成效果却可能没有变好?
- 检查 Loss 是否只统计了正确的 Completion Token;
- 检查数据、Mask、截断、过拟合和训练目标是否与实际评测目标一致;
- 区分 Token-Level Loss 下降与完整 Sequence 质量提升。
- 训练出现 NaN、Gradient Norm 突增或 OOM 时,应该怎样排查?
- 从数据 Batch、有效 Token 数、Dtype、Learning Rate、Logits 和 Loss 开始逐层定位;
- 检查是否错误保留计算图、物化完整
[B,T,V]Tensor 或遗漏detach(); - 能使用日志中的 Loss、Grad Norm、Tokens、显存和吞吐变化缩小问题范围。
吃透标准¶
“吃透”不是把这一章看完,也不是能够复述几个名词,而是能够在没有现成答案的情况下,把概念、公式、Shape、代码和排查思路连起来。可以按下面几个层次检查。
1、主链路能够独立讲清楚¶
- 能脱离笔记手画
input_ids -> embeddings -> hidden_states -> logits -> logprobs -> loss -> grad -> step; - 能给每个关键 Tensor 标出 Shape,并解释每次切片、转置、
gather和聚合改变了什么; - 能明确区分 Forward、
backward()和optimizer.step()各自负责的事情; - 能把 SFT、DPO、PPO、GRPO 放回这条主链路,说明它们主要改变了数据、Loss 或采样方式,而不是改变 Autograd 的基本机制。
2、核心概念能够解释到原理¶
- 能解释 logits、probability、logprob 和 cross entropy 之间的数学关系;
- 能解释 loss 对 logits 求导后,目标 Token 和非目标 Token 的更新方向为什么不同;
- 能区分
requires_grad、grad_fn、is_leaf、.grad、detach()和torch.no_grad(); - 能解释 AdamW 的一阶矩、二阶矩、Bias Correction 和 Decoupled Weight Decay;
- 能区分 Causal Mask、Padding Attention Mask、Completion Mask 与
labels=-100的作用阶段。
3、关键代码能够独立手写¶
- 能手写
masked_mean,并处理 Mask 全 0、Broadcast 和不同 Reduction Dimension; - 能手写
selective_log_softmax,正确处理 Token ID、ignore_index和输出 Shape; - 能手写 Sequence/Response Log Probability,正确完成 Shift、Mask 和 Token Logprob 求和;
- 能手写最小
train_step,保证zero_grad -> forward -> loss -> backward -> clip -> step顺序正确; - 能不照抄源码写出 RMSNorm、RoPE、GQA Attention、SwiGLU MLP 和 Decoder Layer 的主结构。
4、源码能够找到关键路径¶
- 在 PyTorch AdamW 源码中,能够找到参数组、
.grad、Optimizer State 和实际更新函数之间的关系; - 在
LlamaForCausalLM.forward()中,能够找到 Backbone、lm_head、logits_to_keep和 Loss Function; - 能找到 CausalLM Labels Shift 的真正实现位置,而不是只停留在模型 Forward 的表面调用;
- 在 TRL 代码中看到 Current/Old/Reference Logprob 时,能够判断它们的来源、Shape 和是否需要梯度。
5、出现问题时能够定位¶
- Shape 报错时,能从
[B,T,H]、[B,T,V]、[B,T-1]逐步检查,而不是只尝试随机reshape; - Loss 异常时,会检查 Labels Shift、
ignore_index、Completion Mask、有效 Token 数和 Reduction; - Gradient Norm 异常时,会检查 Learning Rate、Dtype、Loss Scale、异常 Batch 和梯度累积;
- OOM 时,能够区分参数、Optimizer State、Activation、Gradient 和临时 Log Probability Tensor 的显存来源。
6、能够把知识讲给别人¶
- 能在 5~10 分钟内,不看代码讲清楚“一次 CausalLM 训练更新发生了什么”;
- 面对文末经典问题,不只给结论,还能补充 Shape、公式或一段最小代码;
- 对暂时不确定的源码细节,知道应该去哪个文件、哪个函数继续确认,而不是依赖模糊记忆;
- 能把
p.grad、AdamW 参数更新与大模型后训练中的 Policy Update 联系起来。
如果上面的内容还不能一次全部完成,也不需要急着否定自己的学习效果。优先保证主链路、Labels Shift、Mask、Gradient 和 AdamW 五部分能够讲清楚,再逐步补齐源码与工程细节。当你能够发现自己的错误、定位它发生在哪个阶段,并知道下一步去哪里验证时,就已经不只是“看懂”,而是在真正建立训练能力。