File size: 17,324 Bytes
d58698c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 | # 面试:预训练(Pretrain)深度
> 本仓库 `src/trainers/lm/pretrain.py`,对应 config:`configs/lm/lm_pretrain.yaml`
## 0. 整体流程
```
数据集 (pretrain_t2t_mini.jsonl)
│ 1,270,238 条纯文本
▼
PretrainDataset
├── 读取 jsonl,每条取 text 字段
├── tokenizer.encode() → input_ids
├── labels = input_ids 克隆(完整序列监督)
└── 截断 / 填充到 max_seq_len
│
▼
SkipBatchSampler → DataLoader
│ batch_size 个样本 / 组
│ SkipBatchSampler 支持断点续训跳过前 N 步
▼
Forward: model(input_ids, labels)
├── TokenEmbed → N×Block → RMSNorm → lm_head
└── CE Loss(input=logits[..., :-1, :], target=labels[..., 1:])
│
▼
Backward → 梯度累积 → 梯度裁剪 → Optimizer.step()
│
▼
Cosine LR 调度 → 定期保存 checkpoint
```
---
## Q1. PretrainDataset 怎么处理数据?
### 代码(`src/dataset/pretrain.py`)
```python
class PretrainDataset(Dataset):
def __init__(self, data_path, tokenizer, max_length=340):
self.data = self.load_data(data_path)
self.tokenizer = tokenizer
self.max_length = max_length
def load_data(self, data_path):
with open(data_path, 'r') as f:
return [json.loads(line)['text'] for line in f]
def __getitem__(self, index):
text = self.data[index]
input_ids = self.tokenizer.encode(text, max_length=self.max_length,
truncation=True, padding='max_length',
return_tensors='pt')[0]
labels = input_ids.clone()
return input_ids, labels
```
### 与 SFT 数据集的关键区别
| | PretrainDataset | SFTDataset |
|---|---|---|
| 数据格式 | 纯文本 | 多轮对话 JSON |
| label 构造 | `labels = input_ids.clone()`(全监督) | prompt 段置 `-100`,只标 assistant |
| 损失计算 | 所有 token 都参与 | 只有 assistant 回复参与 |
| 训练目标 | 下一个 token 预测 | 指令跟随回复生成 |
> 面试点:预训练时所有 token 都计算损失 vs SFT 只算 assistant 段,为什么?→ 预训练目标是学习语言分布,所有 token 都提供统计信息;SFT 是学习指令跟随能力,只需要拟合回复
---
## Q2. 损失函数具体怎么算?
### Next-Token Prediction + 标签平移
```python
# src/models/lm/model.py:63-70
def forward(self, input_ids, labels=None):
hidden_states = self.model(input_ids)
logits = self.lm_head(hidden_states) # [B, S, V]
if labels is not None:
shift_logits = logits[..., :-1, :].contiguous() # [B, S-1, V]
shift_labels = labels[..., 1:].contiguous() # [B, S-1]
loss = F.cross_entropy(
shift_logits.view(-1, shift_logits.size(-1)),
shift_labels.view(-1)
)
```
### 为什么平移?
- `logits[t]` 预测的是 `labels[t+1]`(第 t 个位置的输出预测第 t+1 个位置的 token)
- 不平移的话,模型学到的是恒等映射:`logits[t] ≈ labels[t]`
- 训练时看似 loss 很低,但生成时完全无法产生新内容
> 面试点:平移的本质是什么?→ 将语言模型建模为条件概率 P(xₜ|x₁,...,xₜ₋₁),第 t 个位置的输出对应第 t+1 个 token 的概率分布
### 辅助损失:MoE aux_loss
```python
loss = res.loss + res.aux_loss
```
当 `use_moe=1` 时,MoE 层还会产生一个 auxiliary load-balancing loss,鼓励专家负载均衡。非 MoE 模式下 `aux_loss = 0`。
---
## Q3. 训练超参如何影响模型?
### 核心参数详解
| 参数 | 默认值 | 作用 | 调大影响 | 调小影响 |
|---|---|---|---|---|
| `hidden_size` | 512 | 每 token 的表示维度 | 容量↑,速度↓ | 容量↓,速度↑ |
| `num_hidden_layers` | 8 | Transformer 层数 | 深度↑,梯度传播难 | 深度↓,表达弱 |
| `batch_size` | 32 | 每步样本数 | 梯度稳,显存↑ | 梯度噪,显存↓ |
| `accumulation_steps` | 8 | 梯度累积步数 | 等效 batch↑,速度不变 | 等效 batch↓ |
| `max_seq_len` | 340 | 最大序列长度 | 上下文↑,显存↑ | 上下文↓ |
| `learning_rate` | 5e-4 | 初始学习率 | 收敛快,可能不稳 | 收敛慢,更稳 |
| `dtype` | bfloat16 | 混合精度类型 | 精度高,速度中 | 精度低,速度快 |
### 等效 Batch Size
```
等效 batch = batch_size × accumulation_steps
```
本仓库 pretrain 默认 `batch_size=32, accumulation_steps=8` → 等效 batch = 256。
### 梯度累积实现
```python
# src/trainers/lm/pretrain.py:36-47
loss = loss / args.accumulation_steps # 归一化
scaler.scale(loss).backward() # 累积梯度
if step % args.accumulation_steps == 0: # 累积够 N 步后
scaler.unscale_(optimizer)
clip_grad_norm_(model.parameters(), grad_clip)
scaler.step(optimizer) # 更新参数
scaler.update()
optimizer.zero_grad(set_to_none=True) # 清空梯度
```
> 面试点:为什么 loss 要除以 accumulation_steps?→ 因为梯度是链式累积的,不归一化的话等效学习率会放大 accumulation_steps 倍。除以步数后,每步梯度相当于独立 batch 梯度的平均,保持学习率语义不变
---
## Q4. 学习率调度策略?
### Cosine Decay
```python
# src/utils/training.py:82-83
def get_lr(current_step, total_steps, lr):
return lr * (0.1 + 0.45 * (1 + math.cos(math.pi * current_step / total_steps)))
```
### 调度曲线
```
lr
↑
│ lr * 0.55 ─────────── 余弦下降 ──→ lr * 0.1
│ (初始) (最终)
└─────────────────────────────────────→ step
```
- 初始 LR = `lr * 0.55`(cos 从 0 开始,`1 + cos(0) = 2` → `0.1 + 0.45*2 = 1.0`... 不对)
仔细看:`current_step=0` 时 `cos(0)=1` → `0.1 + 0.45*2 = 1.0` → `lr * 1.0 = lr`
所以在 step 0 时 LR 正好等于设置的 learning_rate。
- 最终 LR = `lr * 0.1`(cos(π) = -1 → `0.1 + 0.45*0 = 0.1`)
- 即 LR 从 `lr` 余弦衰减到 `0.1 * lr`
这种"不下到 0"的调度比标准 cosine 更好,保持模型在训练后期仍有适度更新能力。
---
## Q5. 混合精度训练怎么做?
### AMP (Automatic Mixed Precision)
```python
# src/trainers/lm/pretrain.py:121-123
dtype = torch.bfloat16 if args.dtype == "bfloat16" else torch.float16
autocast_ctx = torch.cuda.amp.autocast(dtype=dtype)
```
### bf16 vs fp16
| | bf16 | fp16 |
|---|---|---|
| 指数位 | 8 位(同 fp32) | 5 位 |
| 尾数位 | 7 位 | 10 位 |
| 数值范围 | 同 fp32(~3.4e38) | 有限(~6.5e4) |
| 是否需要 GradScaler | ❌ 不需要 | ✅ 需要 |
| 精度 | 低精度(7bit 尾数) | 高精度(10bit 尾数) |
| 硬件要求 | Ampere+(3090/A100 等) | 几乎所有 GPU |
### 为什么 bf16 不需要 GradScaler?
bf16 的指数范围和 fp32 一样,不会发生梯度下溢。fp16 的指数范围只有 5 位,小梯度会直接变 0,需要用 GradScaler 放大梯度再缩小。
### 精度保留技巧
```python
# src/core/norm.py:7-8
x = x.float() # RMSNorm 内部转 fp32
x = x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)
return (self.weight * x).type_as(self.weight) # 转回 bf16/fp16
```
即使训练是 bf16,归一化层内部转 fp32 计算再转回,避免归一化精度损失。
---
## Q6. 训练时显存都花在哪了?
### 显存分布(以 30M 参数模型为例)
```
模型参数 (fp32): 120 MB
梯度 (fp32): 120 MB
Adam 状态 (fp32×2): 240 MB
─────────────────────────────
模型状态总计: 480 MB
激活值 (bf16, 8层): ~3-5 GB ← 大头
输入数据: <10 MB
CUDA 上下文: ~200 MB
─────────────────────────────
总计: ~4-6 GB(取决于 batch_size 和 seq_len)
```
### 为什么激活值占这么多?
- 反向传播需要存储每层的中间激活值
- 每层存储 Q/K/V、注意力输出、MLP 中间结果等
- 数量级:`O(batch_size × seq_len × hidden_size × num_layers × k)`,k ≈ 15-34
### 没有激活检查点
本仓库**没有**使用 `torch.utils.checkpoint`(梯度检查点)。代价是激活值全存,好处是不需要重计算,训练速度更快。
> 面试点:激活检查点 trade-off 是什么?→ 节省显存(存部分激活,反向时重算),增加约 15-20% 计算时间
---
## Q7. 分布式训练是怎么做的?
### DDP (DistributedDataParallel)
```python
# src/utils/distributed.py
def init_distributed_mode():
if int(os.environ.get("RANK", -1)) == -1:
return 0 # 单卡模式
dist.init_process_group(backend="nccl") # 多卡模式
local_rank = int(os.environ["LOCAL_RANK"])
torch.cuda.set_device(local_rank)
return local_rank
```
### 启动方式
```bash
# 单卡
python -m trainers.lm.pretrain --config configs/lm/lm_pretrain.yaml
# 多卡(torchrun)
torchrun --nproc_per_node=4 -m trainers.lm.pretrain --config configs/lm/lm_pretrain.yaml
```
### DDP 原理
- 每个 GPU 一张完整的模型副本
- 前向/反向独立计算
- 反向传播后通过 `allreduce` 同步梯度
- 每个 GPU 独立执行 optimizer.step()
### 本仓库没有使用
- ❌ ZeRO(DeepSpeed)
- ❌ FSDP
- ❌ 张量/序列并行
- ❌ torch.compile(默认关闭,`use_compile: 0`)
> 面试点:为什么小模型不用 ZeRO/FSDP?→ 模型仅 30M 参数,单卡就能装下,DDP 的梯度同步开销也很小。ZeRO/FSDP 的通信量更大,对小模型反而可能更慢
---
## Q8. Checkpoint 如何保存和恢复?
### 保存(`train_epoch` 内)
```python
# src/trainers/lm/pretrain.py:59-67
if (step % args.save_interval == 0 or step == iters) and is_main_process():
ckp = f'{args.save_dir}/{args.save_weight}_{hidden_size}.pth'
torch.save({k: v.half().cpu() for k, v in state_dict.items()}, ckp)
lm_checkpoint(lm_config, weight=args.save_weight, model=model,
optimizer=optimizer, epoch=epoch, step=step, ...)
```
### 保存两个文件
| 文件 | 内容 | 用途 |
|---|---|---|
| `pretrain_512.pth` | 模型权重 (fp16) | 推理/下游微调 |
| `pretrain_512_resume.pth` | 权重 + 优化器 + epoch + step | 断点续训 |
### 恢复训练 `from_resume=1`
```python
ckp_data = lm_checkpoint(lm_config, weight=args.save_weight, save_dir='../checkpoints')
if ckp_data:
model.load_state_dict(ckp_data['model'])
optimizer.load_state_dict(ckp_data['optimizer'])
start_epoch = ckp_data['epoch']
start_step = ckp_data.get('step', 0)
```
### 权重初始化 `from_weight`
```yaml
from_weight: none # 从头训练(随机初始化)
from_weight: pretrain # 加载 pretrain_512.pth 继续训练
from_weight: full_sft # 加载 full_sft_512.pth 继续训练
```
`init_model` 会根据 `from_weight` 在 `save_dir` 下查找对应文件:
```python
weight_path = f'{save_dir}/{from_weight}_{hidden_size}.pth'
weights = torch.load(weight_path, map_location=device)
model.load_state_dict(weights, strict=False)
```
> 面试点:`strict=False` 意味着什么?→ 允许加载的权重和模型结构部分不匹配(如只加载 encoder 不加载 lm_head),LoRA 等场景常用
---
## Q9. SkipBatchSampler 如何实现断点续训?
```python
# src/utils/training.py:177-200
class SkipBatchSampler(Sampler):
def __init__(self, sampler, batch_size, skip_batches=0):
self.sampler = sampler # 原始索引(或 DistributedSampler)
self.batch_size = batch_size
self.skip_batches = skip_batches # 跳过的步数
def __iter__(self):
batch = []
skipped = 0
for idx in self.sampler:
batch.append(idx)
if len(batch) == self.batch_size:
if skipped < self.skip_batches: # 跳过前 N 个 batch
skipped += 1
batch = []
continue
yield batch
batch = []
```
### 为什么需要跳过?
- 断点续训时已经从 checkpoint 恢复了优化器状态
- 但 DataLoader 从头开始迭代的话,会重复处理之前的数据
- `SkipBatchSampler` 通过 `skip_batches` 跳过已处理的 batch
### 使用场景
```python
skip = start_step if (epoch == start_epoch and start_step > 0) else 0
batch_sampler = SkipBatchSampler(train_sampler or indices, args.batch_size, skip)
```
---
## Q10. 随机种子和数据打乱
```python
# src/trainers/lm/pretrain.py:160
setup_seed(42 + epoch)
indices = torch.randperm(len(train_ds)).tolist()
```
### 每 epoch 重新打乱
- 每个 epoch 用不同的 `indices` 顺序
- 种子 = `42 + epoch`,保证可复现
- `DistributedSampler` 模式下,每个 GPU 拿到不同但确定性的分片
### 为什么 seed 要 + epoch?
- 每个 epoch 的数据顺序不同
- 同一 epoch 在不同运行间可复现
- 分布式下每个 rank 拿到不同的子集
---
## Q11. YAML 配置是如何生效的?
### 配置优先级
```
CLI 参数 > YAML 默认值 > Python argparse 默认值
```
### 实现机制
```python
# src/utils/training.py:25-38
def apply_config(parser, default_config=None):
pre, _ = parser.parse_known_args()
config_path = getattr(pre, 'config', None) or default_config
if config_path and os.path.exists(config_path):
defaults = _load_yaml_config(config_path)
parser.set_defaults(**defaults) # YAML 值设置为 argparse 默认值
return parser.parse_args() # CLI 显式传参仍可覆盖
```
### YAML 映射
```yaml
model:
hidden_size: 512 → args.hidden_size
train:
batch_size: 32 → args.batch_size
paths:
save_dir: ... → args.save_dir
```
YAML 的三个 section(model/train/paths)被扁平化为 argparse 参数后注入,之后任何 CLI 传参都可以覆盖。
---
## Q12. 为什么选 SwiGLU 作为激活函数?
### 公式对比
| 激活函数 | 公式 | 参数量 |
|---|---|---|
| ReLU FFN | `ReLU(xW₁)W₂` | 2×d×d_ff |
| SwiGLU | `SiLU(xW_g) * (xW_u) * W_d` | 3×d×d_ff |
SwiGLU 用三个投影(gate/up/down)替代两个,参数量增加 50%,但同等参数下效果更好。
### 仓库实现
```python
# src/core/mlp.py:16-17
def forward(self, x):
return self.down_proj(self.act_fn(self.gate_proj(x)) * self.up_proj(x))
```
### intermediate_size 的奇怪计算
```python
# src/models/lm/config.py:22
self.intermediate_size = math.ceil(hidden_size * math.pi / 64) * 64
```
为什么用 π?这是一个取整技巧:`hidden_size × π / 64` 取整再 ×64,保证 intermediate_size 是 64 的倍数,有利于 GPU 内存对齐和 Tensor Core 加速。
对于 `hidden_size=512`:`intermediate_size = ceil(512 × π / 64) × 64 = ceil(25.13) × 64 = 1664`
---
## Q13. 训练过程中 loss 的变化规律
典型 pretrain loss 曲线:
```
loss
↑
8.0 │ █
7.0 │ ██
6.0 │ ███
5.0 │ ████
4.0 │ █████
3.0 │ ██████
└────────────────────→ step
```
### 特征
1. **快速下降期**(前 5-10% 步数):loss 从 ~8.5 降到 ~5.0,模型学到基础词法/语法模式
2. **平稳下降期**:loss 稳步下降,学习更复杂的语义/知识
3. **接近收敛**:loss 下降变缓,接近理论下界
### 预估最终 loss
对于 vocab_size=6400 的随机初始化模型:
- 初始 loss ≈ log(6400) ≈ 8.76(均匀分布的交叉熵)
- 训练后 loss ≈ 2.5-3.5(取决于模型大小和数据量)
- 理论下限 ≈ 0(完美拟合数据分布,但实际上达不到)
---
## 面试高频题汇总
### 基础
1. **预训练和 SFT 的区别?** → 预训练从零学习语言分布(全 token 监督),SFT 学习指令跟随(只监督回复)
2. **为什么用 bf16 而不是 fp16?** → bf16 指数范围同 fp32,无需 GradScaler,训练更稳定
3. **梯度累积的作用?** → 显存不足时用计算换显存,等效增大 batch_size
4. **Cosine 学习率调度的优缺点?** → 平滑衰减,早期快速学习后期精细调优,但可能过早衰减
### 进阶
5. **如何估计训练时间?** → `总步数 = ceil(样本数 / batch_size) × epochs`,`总时间 = 总步数 × 每步时间`
6. **为什么 loss 除以 accumulation_steps?** → 保持梯度期望不变,等效于 "平均 N 个 mini-batch 的梯度"
7. **DDP 和 DP 的区别?** → DDP 每个 GPU 独立前反向 + allreduce 梯度,DP 是单进程多线程(GIL 限制性能)
8. **weight tying 的作用?** → 共享 embedding 和 lm_head 的权重矩阵,减少参数量(本项目默认开启)
9. **GQA 和 MHA 的区别?** → GQA 减少 KV head 数量,降低 KV cache 大小,推理时更省显存
10. **Flash Attention 为什么省显存?** → tiling 计算注意力矩阵,不显式存储 O(n²) 的 score 矩阵
|