训练工程与基础设施
阶段 11 · 好的训练离不开好的工程基础——理解支撑所有训练器的底层工具代码
先问一个问题
训练一个 LLM 需要:多 GPU 并行、断点续训、学习率调度、混合精度……
6 个训练器(pretrain / sft / lora / dpo / grpo / ppo)各自实现一遍吗?
当然不是。 它们共享同一套工具函数——trainer/trainer_utils.py。
本阶段的目标:搞懂这套基础设施,再读任何训练器时只需关注它的差异部分。
本章路线图
阶段 11 覆盖支撑训练的基础设施代码。先建立全局视野:
工具函数
get_model_params、Logger、余弦学习率调度、随机种子设置
检查点管理
保存与加载模式、GPU 数量适配、原子写入防损坏
模型初始化
init_model 统一入口、权重加载与设备分配
分布式训练
init_distributed_mode、DDP 包装、主进程判断
数据工具
SkipBatchSampler 支持断点续训跳过已处理 batch
奖励模型
LMForRewardModel 封装外部 RM 打分接口
基础设施总览
trainer/trainer_utils.py 提供了训练流程中的公共基础设施:
图:trainer_utils.py 基础设施与6个训练器的关系
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:上半部分一个虚线大框"trainer_utils.py 提供的基础设施",包含6个小模块:"环境初始化(init_distributed_mode/setup_seed)"、"模型管理(init_model/get_model_params)"、"训练控制(get_lr/Logger)"、"检查点(lm_checkpoint)"、"数据工具(SkipBatchSampler)"、"奖励模型(LMForRewardModel)" - 下半部分6个方框:"train_pretrain"、"train_full_sft"、"train_lora"、"train_dpo"、"train_grpo"、"train_ppo" - 各元素关系:下方6个训练器分别用箭头指向上方的基础设施模块(所有训练器都连接到前5个模块),其中train_grpo和train_ppo额外连接到"奖励模型" - 关键标注:上方模块蓝色,下方训练器绿色,额外连接用橙色虚线标注 - 配色建议:浅蓝/浅灰背景,基础设施蓝色系,训练器绿色系 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
9 步训练模式
定义:所有训练器都遵循完全相同的 9 步初始化→训练→清理流程。
白话:6 个训练器的"骨架"一模一样,就像同一个工厂流水线,只是装的"零件"不同。
以下是训练脚本的9个关键步骤,分为初始化(步骤1-5)和训练循环(步骤6-9)两个阶段。
图:所有训练器共享的9步初始化-训练-清理流程
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一个自上而下的9步流程图,用编号标注每一步 - 步骤1(蓝色):"初始化分布式和随机种子 init_distributed_mode() setup_seed(42)" - 步骤2:"配置目录、模型参数、检查ckp MiniMindConfig() lm_checkpoint(load)" - 步骤3:"设置混合精度 autocast_ctx GradScaler" - 步骤4:"配置wandb/swanlab 实验追踪" - 步骤5:"定义模型、数据、优化器 init_model() Dataset+DataLoader AdamW" - 步骤6:"从检查点恢复状态 load_state_dict()" - 步骤7:"编译和分布式包装 torch.compile() DistributedDataParallel" - 步骤8(橙色高亮):"训练循环 for epoch... for step... forward-back-update" - 步骤9(绿色):"清理分布式进程 destroy_process_group()" - 各元素关系:9个步骤从上到下用箭头依次连接 - 关键标注:步骤1-5标注"初始化阶段",步骤6-7标注"恢复阶段",步骤8标注"核心循环",步骤9标注"清理" - 配色建议:浅灰背景,步骤白色方框,步骤1蓝色,步骤8橙色,步骤9绿色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
各训练器差异
理解了 9 步框架,每个训练器你只需要关注它的差异部分:
图:6个训练器的相同框架与不同部分对比
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:左右两栏对比图 - 左栏"相同部分(9步框架)":4个灰色方框纵向排列,分别是"分布式初始化"、"混合精度"、"检查点管理"、"编译和DDP",标注"所有训练器共用" - 右栏"不同部分":6个彩色方框纵向排列,"Pretrain: PretrainDataset+CE Loss"、"SFT: SFTDataset+Label Masked CE"、"LoRA: 冻结参数+只保存LoRA"、"DPO: ref_model+DPO Loss"、"GRPO: rollout+reward+advantage"、"PPO: actor+critic+GAE" - 各元素关系:左栏统一灰色,右栏每个训练器不同颜色 - 关键标注:左栏标题旁标注"骨架一样",右栏标题旁标注"零件不同" - 配色建议:浅灰背景,相同部分灰色,不同部分各自不同颜色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
核心洞见:6 个训练器的骨架完全相同,差异仅在数据集、损失函数、特殊模型组件三处。
混合精度训练
定义:混合精度用 FP16 做前向/反向计算(快),同时维护一份 FP32 的 master weights(准)。
白话:像用草稿纸算数学题(FP16,快但不精确),但把最终答案工整抄到正式本上(FP32,精确)。
图:混合精度训练的数据流与精度转换
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一个循环流程图 - 顶部中央蓝色大框"FP32 Master Weights(主权重)" - 第1步:FP32 → "Cast to FP16" → 绿色框"FP16模型权重" - 第2步:FP16权重 → "前向传播(FP16)" → "FP16 Loss" - 第3步:FP16 Loss → "反向传播(FP16)" → "FP16 梯度" - 第4步:FP16梯度 → GradScaler(橙色,"缩放梯度防下溢")→ Cast to FP32 - 第5步:FP32梯度 → optimizer.step() → 更新FP32 Master Weights - 用颜色区分:FP32蓝色系,FP16绿色系,GradScaler橙色 - 配色建议:浅灰背景,FP32蓝色,FP16绿色,GradScaler橙色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
参数统计
定义:get_model_params() 区分 MoE 模型的"总参数"和"活跃参数"。
白话:MoE 模型像个大公司——员工很多(总参数),但每次只派一小撮人干活(活跃参数)。
代码——来自 trainer_utils.py:
def get_model_params(model, config):
total = sum(p.numel() for p in model.parameters()) / 1e6
n_routed = config.n_routed_experts # 总专家数
n_active = config.num_experts_per_tok # 每次激活几个
expert = sum(p.numel() for n, p in model.named_parameters()
if 'mlp.experts.0.' in n) / 1e6
shared_expert = sum(p.numel() for n, p in model.named_parameters()
if 'mlp.shared_experts.0.' in n) / 1e6
base = total - (expert * n_routed) - (shared_expert * n_shared)
active = base + (expert * n_active) + (shared_expert * n_shared)
if active < total:
Logger(f'Model Params: {total:.2f}M-A{active:.2f}M')
else:
Logger(f'Model Params: {total:.2f}M')
Model Params: 198.42M-A63.91M
# 总参数 198.42M,但每次推理只激活 63.91M
MoE 参数分布
MoE 模型里,不是所有参数每次都被用到——理解"总参数"和"活跃参数"的差别:
图:MoE 模型中总参数与活跃参数的对比
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一个Transformer层的横向剖面图 - 左侧画"Attention层"(蓝色实心),标注"始终激活" - 右侧画"MLP层"区域,内部:4个Routed Expert(浅灰色,其中1个橙色高亮标注"本次激活"),无 Shared Expert - 下方两个汇总条:"总参数 ~198M"完整长条(4个专家全蓝),"活跃参数 ~64M"较短长条(只有1个专家高亮,其余灰色虚线) - 配色建议:浅灰背景,始终激活蓝色,可选专家浅灰,被激活橙色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
余弦学习率
定义:get_lr() 实现余弦退火调度,让学习率在训练过程中平滑变化。
白话:起步时学习率较低(0.1倍),保证训练稳定;中段学习率最高(约0.55倍),是学习最积极的阶段;末段学习率再次降低,精细收敛不冲过头。
代码:
def get_lr(current_step, total_steps, lr):
return lr * (0.1 + 0.45 * (1 + math.cos(
math.pi * current_step / total_steps)))
图:余弦学习率公式从cos到最终lr的逐步推导
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一条从左到右的5步推导链 - 方框1:"cos(pi * step / total)",下方标注"范围 -1 到 1" - 方框2:"1 + cos(...)",下方标注"范围 0 到 2" - 方框3:"0.45 * (1 + cos(...))",下方标注"范围 0 到 0.9" - 方框4:"0.1 + ...",下方标注"范围 0.1 到 1.0" - 方框5:"乘以 base_lr",下方标注"最终学习率" - 各元素关系:方框1用箭头指向方框2指向方框3指向方框4指向方框5,箭头上标注每步的数学操作 - 关键标注:每步下方用小字标注数值范围变化 - 配色建议:浅蓝/浅灰背景,方框白色带蓝色边框,箭头橙色,范围标注灰色小字 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
图:余弦学习率随训练步数的变化曲线
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一张坐标系图,横轴"训练步数(step)"从0到total_steps,纵轴"学习率"从0到base_lr - 绘制一条余弦退火曲线:从0.1*base_lr开始(标注"起步:低学习率,稳定初始化"),平滑上升至中间达到最高点1.0*base_lr(标注"中间:高速学习"),再平滑下降回到0.1*base_lr(标注"结束:低学习率,精细收敛") - 在曲线的三个关键点画虚线标注到横轴和纵轴 - 用浅蓝色填充曲线下方区域 - 右上角标注公式:lr * (0.1 + 0.45 * (1 + cos(pi * step / total))) - 配色建议:浅灰背景,曲线深蓝色,填充浅蓝色,关键点橙色圆点 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
lr 调度详解
| 训练阶段 | cos 值 | lr 倍率 | 效果 |
|---|---|---|---|
| 开始 (step=0) | cos(0) = 1 | 0.1 x lr | 较低学习率(warmup 效果) |
| 中间 (step=N/2) | cos(pi/2) = 0 | 0.55 x lr | 中等学习率 |
| 结束 (step=N) | cos(pi) = -1 | 0.1 x lr | 回到低学习率(精细调整) |
为什么开始和结束都是低学习率?
开始时参数是随机的,大步更新容易"震飞";结束时参数接近最优,小步微调才能精细收敛。
随机种子
定义:setup_seed() 统一设置所有随机源,保证每次运行结果可复现。
白话:深度学习的每一步都有随机性(数据打乱、Dropout、采样)。固定种子就是"抽签前先洗好牌",保证下次抽到一样的。
代码:
def setup_seed(seed: int):
random.seed(seed) # Python 内置随机
np.random.seed(seed) # NumPy 随机
torch.manual_seed(seed) # PyTorch CPU 随机
torch.cuda.manual_seed(seed) # 当前 GPU 随机
torch.cuda.manual_seed_all(seed) # 所有 GPU 随机
torch.backends.cudnn.deterministic = True # cuDNN 确定性
torch.backends.cudnn.benchmark = False # 关闭自动调优
主进程日志
定义:Logger() 确保在多 GPU 训练时,日志只在主进程打印一次。
白话:4 张 GPU 跑同样的代码,不加判断的话,一条日志打印 4 遍,终端就炸了。
代码:
def is_main_process():
return not dist.is_initialized() or dist.get_rank() == 0
def Logger(content):
if is_main_process(): # 只在 rank=0 的主 GPU 上打印
print(content)
如果不加判断会怎样? 4 张 GPU 各打印一次,同一条日志出现 4 遍,终端一片混乱。
检查点生命周期
定义:lm_checkpoint() 是训练工程中最复杂的组件,负责保存/恢复训练状态。
白话:训练一个模型可能跑几天。如果中间停电崩溃了,没有检查点就只能从头来。检查点就是"游戏存档"。
图:训练检查点的保存与加载状态机
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一个状态机图,包含保存流程和加载流程两条路径 - 保存流程:黑色圆点(起始)到"保存"(标注"训练中到达save_interval")到"写入tmp"(标注"先写入.tmp临时文件")到"原子替换"(标注"os.replace()重命名")到"训练继续" - 加载流程:黑色圆点(起始)到"加载"(标注"from_resume=1启动时")到"检测文件"(标注"检查_resume.pth是否存在")到"恢复状态"(标注"加载model+optimizer+epoch+step")到"训练继续" - 两条路径在"训练继续"汇合,然后回到"保存"形成循环 - 各元素关系:保存流程蓝色箭头,加载流程绿色箭头,循环灰色箭头 - 关键标注:每个状态转换旁边标注触发条件 - 配色建议:浅灰背景,保存蓝色,加载绿色,循环灰色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
检查点内容
一个完整的检查点文件包含训练恢复所需的全部状态:
图:训练检查点文件包含的6项完整状态
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:顶部一个蓝色大框"lm_checkpoint() 保存",下方扇出6个小方框 - 方框B:"模型权重 state_dict (fp16)" - 方框C:"优化器状态 optimizer.state_dict()" - 方框D:"GradScaler状态 scaler.state_dict()" - 方框E:"训练进度 epoch, step" - 方框F:"GPU数量 world_size" - 方框G:"Wandb ID 用于恢复实验追踪" - 各元素关系:顶部大框用扇形箭头分别连接6个方框 - 关键标注:每个方框用不同颜色区分类型(模型蓝色、优化器绿色、进度橙色、配置紫色) - 配色建议:浅灰背景,顶部蓝色,子项按类型着色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
代码——保存模式的核心逻辑:
resume_data = {
'model': state_dict,
'optimizer': optimizer.state_dict(),
'epoch': epoch,
'step': step,
'world_size': dist.get_world_size(),
'wandb_id': wandb_id
}
resume_tmp = resume_path + '.tmp'
torch.save(resume_data, resume_tmp)
os.replace(resume_tmp, resume_path) # 原子操作!
原子写入
定义:原子写入指文件操作"要么全部完成,要么完全不变",不存在中间状态。
白话:先写到临时文件,写完了才把名字改成正式的。写一半断了?临时文件坏了就坏了,正式文件好好的。
图:原子写入的两步操作时序(防止断电损坏)
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一个两栏时序图,左栏"训练进程",右栏"文件系统" - 步骤1:训练进程向文件系统发送"写入model_768.pth.tmp" - 文件系统侧注释框:"如果此时断电,.tmp可能损坏,但原文件完好!" - 步骤2:训练进程向文件系统发送"os.replace(.tmp变.pth)" - 文件系统侧注释框:"rename是原子操作,要么成功要么不变" - 各元素关系:两个步骤从上到下,实线箭头表示操作,注释框标注风险和保障 - 关键标注:第一步旁标注红色警告"可能损坏临时文件",第二步旁标注绿色保障"原子操作保安全" - 配色建议:浅蓝/浅灰背景,训练进程蓝色,文件系统绿色,警告红色,安全绿色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
为什么必须原子写入? 假设你训练了 3 天,保存时写到一半 OOM 了。没有原子写入,权重文件直接损坏,3 天白练。
GPU 数量适配
定义:断点续训时如果换了 GPU 数量,需要按比例换算 step。
白话:8 张卡一起训练时,每一步吃 8 个 batch。换到 2 张卡,每步只吃 2 个。要看等量的数据,step 得翻倍。
代码:
saved_ws = ckp_data.get('world_size', 1)
current_ws = dist.get_world_size() if dist.is_initialized() else 1
if saved_ws != current_ws:
ckp_data['step'] = ckp_data['step'] * saved_ws // current_ws
Logger(f'GPU数量变化({saved_ws}→{current_ws}),'
f'step已自动转换为{ckp_data["step"]}')
图:断点续训时 GPU 数量变化的 step 换算
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一条从左到右的转换箭头图 - 左侧方框:"8卡训练到 step 1000",内部画8个小GPU图标 - 中间大箭头,标注"换到2卡" - 右侧方框:"step = 1000 x 8 / 2 = 4000",内部画2个小GPU图标 - 各元素关系:左侧通过转换公式得到右侧 - 关键标注:箭头上方标注换算公式"step_new = step_old * old_gpus / new_gpus" - 配色建议:浅灰背景,方框白色带蓝色边框,箭头橙色,GPU图标灰色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
断点续训跳过
定义:SkipBatchSampler 在断点续训时跳过已经处理过的 batch,避免数据重复。
白话:假设你在第 500 步崩了。重启后,前 500 个 batch 已经训练过了,再训一遍就是浪费。这个工具帮你跳过它们。
图:SkipBatchSampler 断点续训跳过已处理 batch 的流程
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一个从上到下的5步流程图 - 步骤1(红色):"训练到 step 500 时崩溃",带闪电图标 - 步骤2(蓝色):"从checkpoint恢复" - 步骤3(橙色):"SkipBatchSampler(skip=500)" - 步骤4(灰色):"遍历数据时跳过前500个batch" - 步骤5(绿色):"从step 501继续训练" - 各元素关系:5个步骤从上到下用箭头依次连接 - 关键标注:步骤1旁标注"崩溃点",步骤4旁标注"避免数据重复",步骤5旁标注"无缝继续" - 配色建议:浅灰背景,崩溃红色,恢复蓝色,跳过橙色,继续绿色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
代码——完整实现:
class SkipBatchSampler(Sampler):
def __init__(self, sampler, batch_size, skip_batches=0):
self.sampler = sampler
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:
skipped += 1
batch = []
continue # 跳过已处理的 batch!
yield batch
batch = []
def __len__(self):
total = (len(self.sampler) + self.batch_size - 1) // self.batch_size
return max(0, total - self.skip_batches)
断点续训:完整故事
把检查点保存、恢复、GPU适配、数据跳过串起来:
图:从训练崩溃到恢复继续的完整流程
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一个时间线图,从左到右 - 第1段"正常训练":进度条0%→60%,绿色,标注"step 0-500" - 第2段"保存检查点":60%处存档图标,标注"lm_checkpoint() 原子写入" - 第3段"崩溃":红色闪电图标,标注"OOM/断电/手动kill" - 第4段"恢复":重启图标,标注"from_resume=1",三个子步骤:"加载_resume.pth → GPU数量适配step换算 → SkipBatchSampler(skip=500)" - 第5段"继续训练":进度条60%→100%,蓝色,标注"step 501-1000,不重复数据" - 配色建议:浅灰背景,正常绿色,崩溃红色,恢复橙色,继续蓝色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
奖励模型封装
定义:LMForRewardModel 封装了外部奖励模型(如 internlm2-1_8b-reward),在 RL 训练中给模型回答打分。
白话:RL 训练需要一个"裁判"来打分。这个类就是裁判的封装——给它对话和回答,它返回一个分数。
图:LMForRewardModel 给模型回答打分的流程
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一条从左到右的4步流水线 - 方框1:"对话历史 + 模型回答" - 方框2:"LMForRewardModel"(封装层) - 方框3:"外部RM模型 (internlm2-1_8b-reward)" - 方框4:"评分: -3.0 到 +3.0" - 各元素关系:方框1用箭头指向方框2指向方框3指向方框4 - 关键标注:方框2旁标注"封装接口",方框3旁标注"第三方模型",方框4旁标注"分数范围" - 配色建议:浅灰背景,输入蓝色,封装绿色,RM模型橙色,分数紫色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
代码——完整实现:
class LMForRewardModel:
def __init__(self, model_path, device="cuda", dtype=torch.float16):
self.tokenizer = AutoTokenizer.from_pretrained(
model_path, trust_remote_code=True)
self.model = AutoModel.from_pretrained(
model_path, torch_dtype=dtype, trust_remote_code=True)
self.model = self.model.to(device).eval()
@torch.no_grad()
def get_score(self, messages, response):
# 把对话历史和新问题拼成评估输入
history_text = "\n".join(
[f"{m['role']}: {m['content']}" for m in messages[:-1]])
last_query = messages[-1]['content'] if messages else ""
eval_messages = [
{"role": "user", "content": f"{history_text}\n{last_query}"},
{"role": "assistant", "content": response}
]
score = self.model.get_score(self.tokenizer, eval_messages)
return max(min(score, 3.0), -3.0) # 裁剪到 [-3, 3]
奖励模型在 RL 中的角色
打分只是第一步——分数如何反馈给模型?
图:奖励模型在 GRPO/PPO 训练循环中的位置
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一个环形训练循环图 - 顶部蓝色"策略模型(Actor)",标注"MiniMind主模型" - 右侧绿色"生成回答(Sampling)",标注"给同一个问题生成多个回答" - 底部橙色"奖励模型(RM)",标注"LMForRewardModel.get_score() 给每个回答打分" - 左侧紫色"计算优势/回报",标注"GRPO: 组内归一化 / PPO: GAE折扣" - 从"计算优势"向上回到Actor,标注"高分的回答被强化,低分的被抑制" - 右侧灰色虚线框"参考模型(ref_model)",标注"DPO和PPO需要" - 配色建议:浅灰背景,Actor蓝色,生成绿色,奖励橙色,优势紫色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
模型初始化入口
定义:init_model() 是所有训练器创建模型的统一入口。
白话:不管哪个训练器,"造模型"这一步都调它。参数决定了是随机初始化还是加载已有权重。
代码——完整实现:
def init_model(lm_config, from_weight='pretrain',
tokenizer_path='../model', save_dir='../out', device='cuda'):
tokenizer = AutoTokenizer.from_pretrained(tokenizer_path)
model = MiniMindForCausalLM(lm_config)
if from_weight != 'none':
# 加载已有权重(微调 / 继续训练)
moe_suffix = '_moe' if lm_config.use_moe else ''
weight_path = f'{save_dir}/{from_weight}_{lm_config.hidden_size}{moe_suffix}.pth'
weights = torch.load(weight_path, map_location=device)
model.load_state_dict(weights, strict=False)
# from_weight == 'none' 时不加载,随机初始化(从头训练)
get_model_params(model, lm_config)
Logger(f'Trainable Params: '
f'{sum(p.numel() for p in model.parameters() if p.requires_grad) / 1e6:.3f}M')
return model.to(device), tokenizer
DDP 初始化流程
定义:分布式数据并行(DDP)让多个 GPU 同时训练,每个 GPU 处理不同数据,然后同步梯度。
白话:一个人搬砖慢,找 4 个人同时搬。每个人搬自己的那份,搬完对一下账(同步梯度),保证每个人拿到的结果一样。
代码:
def init_distributed_mode():
if int(os.environ.get("RANK", -1)) == -1:
return 0 # 单卡模式,不初始化 DDP
dist.init_process_group(backend="nccl") # 用 NVIDIA 的 NCCL 通信
local_rank = int(os.environ["LOCAL_RANK"])
torch.cuda.set_device(local_rank) # 绑定当前进程的 GPU
return local_rank
图:分布式数据并行(DDP)的初始化时序
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一个四栏时序图,从左到右分别是"环境变量"、"分布式模块"、"GPU 0"、"GPU 1" - 步骤1:环境变量向分布式模块发送"RANK, LOCAL_RANK, WORLD_SIZE" - 步骤2:分布式模块自调用"dist.init_process_group(backend=nccl)" - 步骤3:分布式模块向GPU 0发送"torch.cuda.set_device(0)" - 步骤4:分布式模块向GPU 1发送"torch.cuda.set_device(1)" - GPU 0和GPU 1之间的共享注释:"每个GPU运行相同代码,但处理不同数据" - 各元素关系:环境变量触发初始化,分布式模块分配GPU,两个GPU并行工作 - 关键标注:init_process_group旁标注"NCCL通信后端" - 配色建议:浅灰背景,环境变量灰色,分布式模块蓝色,GPU绿色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
DDP 数据流
图:DDP 分布式训练的完整数据流(4 GPU 示例)
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一个自上而下的数据流图 - 顶部:"完整数据集"一个大框 - 第二层:"DistributedSampler 将数据均匀分给每个GPU" - 第三层4个方框并排:"GPU 0: 数据子集 1/4"、"GPU 1: 数据子集 2/4"、"GPU 2: 数据子集 3/4"、"GPU 3: 数据子集 4/4" - 第四层(汇合):"各自前向+反向",4个GPU的箭头都汇聚到这一点 - 第五层:"All-Reduce 同步所有GPU的梯度" - 第六层:"每张GPU更新相同的参数" - 各元素关系:数据从上到下流动,数据集分割到4个GPU,各自计算后梯度同步,最终参数一致 - 关键标注:DistributedSampler旁标注"数据不重复",All-Reduce旁标注"梯度平均" - 配色建议:浅灰背景,数据集蓝色,GPU绿色,All-Reduce橙色,参数紫色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
DDP 关键概念
NCCL
NVIDIA 的 GPU 间通信库,专门为 GPU 集合通信优化,速度非常快。
All-Reduce
把所有 GPU 的梯度加起来再平均,确保每张卡拿到相同的梯度后独立更新参数。
主进程 rank=0
负责保存模型、打印日志。只有 rank=0 的进程执行这些操作,避免重复。
DistributedSampler
确保每个 GPU 看到不同的数据分片,不会重复处理同一条样本。
常见问题诊断
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| NCCL timeout | GPU 之间通信失败 | 检查网络连接,确认所有 GPU 可互相访问 |
| checkpoint 加载报错 | GPU 数量变化导致 state_dict 不匹配 | lm_checkpoint 已自动处理,检查权重文件是否完整 |
| loss 突然飙升 | 学习率太大或梯度爆炸 | 调低 lr,检查 GradScaler 是否正常工作 |
| 日志打印 N 遍 | 用了 print 而非 Logger | 把所有 print 替换成 Logger() |
| OOM 但模型很小 | 忘记 .half() 或 mixed precision 没开 | 确认 autocast 和 GradScaler 都在用 |
课后练习
练习 1
手动计算余弦学习率:假设 lr=5e-4, total_steps=1000,写一个循环打印每 100 步的学习率,画出曲线。
练习 2
故意在训练中途 kill 进程,然后用 from_resume=1 重启,观察是否从正确的 step 继续。
练习 3
写一个简单的脚本,用 LMForRewardModel 对 5 个不同的回答打分,看看分数差异。
练习 4
用 2 张 GPU 和 4 张 GPU 分别训练同一个模型,对比日志中的 step 换算是否正确。
学完本阶段后
问题 1
所有训练器共享哪 9 步流程?每一步做什么?
问题 2
为什么要原子写入检查点?如果不直接写 .pth 会怎样?
问题 3
余弦学习率的公式是什么?为什么开始和结束都是低学习率?
问题 4
DDP 训练时,4 张 GPU 分别处理什么?梯度如何同步?
问题 5
SkipBatchSampler 解决了什么问题?
参考答案
问题 1
初始化 → 配置 → 精度 → wandb → 模型/数据 → 恢复 → 编译/DDP → 训练循环 → 清理
问题 2
防止写到一半崩溃导致文件损坏。直接写 .pth 的话,写到一半断电,权重文件就废了,训练白跑。
问题 3
lr * (0.1 + 0.45 * (1 + cos(pi * step / total)))。开始低是为了稳定,结束低是为了精调。
问题 4
各处理 1/4 的数据。通过 All-Reduce 把梯度汇总平均,保证每张卡参数一致。
问题 5
断点续训时跳过已处理的 batch,避免数据重复训练。
阶段 11 完成
你已经掌握了支撑所有训练器的工程基础设施——接下来可以深入任意训练器的差异部分了