Skip to content

第 14 章:分布式训练

现代大语言模型的参数量从数十亿到数千亿不等,单张 GPU 的显存(40GB--80GB)和算力都无法独立承担训练任务。分布式训练将计算与存储分摊到多个设备上,是大模型训练的必经之路。本章从 3D 并行框架出发,逐层深入:先讲 DDP 与 NCCL 通信机制,再分别剖析数据并行、张量并行、流水线并行的原理与取舍,接着覆盖序列并行、FSDP、Packing 等进阶技术,最后介绍 HuggingFace 基础设施、Ray/Slurm 集群管理、量化与实验追踪等工程实践。


14.1 分布式训练总览:3D 并行

DP、TP、PP 三维并行的正交组合与进程组划分,构成大模型训练的基础框架。

为什么需要"3D"

业界将主流分布式策略归纳为三个正交的并行维度——数据并行(DP)、张量并行(TP)、流水线并行(PP),合称 3D 并行。三者分别回答了不同的核心问题:

并行类型切分对象通信算子核心问题
数据并行(DP)训练数据AllReduce / ReduceScatter + AllGather如何加速单步训练
张量并行(TP)单层权重矩阵AllReduce(高频)如何把一层放到多卡上
流水线并行(PP)模型层Point-to-Point Send/Recv(低频)如何把深层模型分到多机

三者的乘积等于总 GPU 数:

DP×TP×PP=Total GPUs

直觉理解

  • DP 是"同一个模型、不同数据"——每张卡拿不同的 mini-batch,算完梯度后同步。
  • TP 是"一层算子横向切开"——矩阵乘法的列或行被分摊到多卡并行计算。
  • PP 是"模型层纵向切开"——不同层在不同卡上串行执行,数据像流水线一样流过。

DDP、DeepSpeed ZeRO、FSDP 的关系

这三者本质上都是数据并行,区别在于显存管理策略:

  • DDP(Distributed Data Parallel):PyTorch 原生实现。每卡保有完整的模型参数、梯度和优化器状态的副本,梯度通过 AllReduce 同步。显存开销最大。
  • DeepSpeed ZeRO:在 DP 框架下,将参数(Stage 1)、梯度(Stage 2)、优化器状态(Stage 3)切片存储到不同 GPU 上。计算时通过 AllGather 收集参数,ReduceScatter 规约梯度。每个 GPU 逻辑上仍拥有完整模型,只是参数暂时存储在不同 GPU 上。
  • FSDP(Fully Sharded Data Parallel):继承了 ZeRO Stage 3 的设计哲学,是 PyTorch 原生的全分片数据并行实现。参数/梯度/优化器状态全部分片。

进程组划分

在 3D 并行中,每张 GPU 同时属于多个通信组:

  • FSDPprocess_group=dp_group——只在 DP 维度通信
  • TP 层内部算子用 tp_group——同一 TP 组内做 AllReduce / AllGather
  • PP 在相邻 pipeline 段之间做点对点 Send/Recv
      SP=4 (列) -->
DP=2  GPU(0,0)  GPU(0,1)  GPU(0,2)  GPU(0,3)   <- DP Group 0
(行)  GPU(1,0)  GPU(1,1)  GPU(1,2)  GPU(1,3)   <- DP Group 1

14.2 DDP 与 NCCL 通信

NCCL 通信原语与 DDP 的 Bucket 机制,实现计算与通信的重叠执行。

NCCL 集合通信算子

NCCL(NVIDIA Collective Communications Library)是 NVIDIA 为 GPU 间通信优化的底层库,提供以下核心集合操作:

算子功能典型用途
AllReduce所有节点规约(求和/均值),结果广播到所有节点DDP 梯度同步
Broadcast / Reduce单对多广播 / 多对一规约参数初始化广播
AllGather每个节点贡献一块,所有节点获得完整数据FSDP 前向参数收集
ReduceScatter先规约后分散,每节点只拿到部分结果FSDP 反向梯度分片
AlltoAll转置通信——按维度重新分配数据序列并行(Ulysses)

参考文档:https://docs.nvidia.com/deeplearning/nccl/user-guide/docs/usage/collectives.html

GPU 拓扑与通信路径

GPU 拓扑示意图

NCCL 会根据硬件拓扑自动选择最优通信路径:

单机多卡(Intra-node)

  • 有 NVLink 时,直接 P2P(Peer-to-Peer)传输,带宽高达 600--900 GB/s,完全不经过 CPU 和系统内存
  • 无 NVLink 时,走 PCIe 总线,NCCL 仍尽量利用 P2P Direct Access 绕过 CPU

多机多卡(Inter-node)

  • 数据必须离开机器,经过网卡走网络
  • GPUDirect RDMA:关键技术——RDMA(InfiniBand 或 RoCE)让网卡直接读取 GPU 显存并发送,无需先拷贝到 CPU 内存,大幅降低延迟和 CPU 占用
  • 网络带宽(100--400 Gbps)远低于机内 NVLink 速度,因此多机训练中梯度压缩、大 batch size(减少通信频率)变得更重要

AllReduce 的数学含义

在 DDP 训练中,AllReduce 的物理意义是梯度平均。设共有 P 张 GPU,第 i 张 GPU 在其数据子集上计算出梯度 g(i)

  1. NCCL 执行 AllReduce(Op=Sum):gsum=i=0P1g(i)
  2. 每卡本地除以 P(无需通信):gavg=1Pgsum

工程细节:虽然 NCCL 也有 Avg 模式,但 PyTorch 通常先做 Sum 再本地除以 P。实际算法采用 Ring-AllReduce 或 Tree-AllReduce——数据像接力赛一样通过切片轮转(Scatter-Reduce + AllGather),在传输过程中顺便完成计算,没有单一瓶颈节点。

DDP 详细执行流程

以 2 卡、2 层模型、总 batch=32 为例:

数据分发:DDP 通过 DistributedSampler 将数据切分——GPU 0 拿前 16 条(Data_0),GPU 1 拿后 16 条(Data_1)。

Forward:完全并行,无通信。每卡独立计算 Lossi=Model(Datai)

Backward + 通信重叠

时间轴GPU 计算核心(Compute Stream)NCCL 通信通道(Comm Stream)状态
T1正在算 Layer 2 梯度空闲刚开始 Backward
T2Layer 2 算完 → 提交通信请求准备启动Bucket A 满了
T3正在算 Layer 1 梯度正在传输 Layer 2 梯度Overlap(双轨并行)
T4Layer 1 算完 → 提交通信请求Layer 2 传输完成Layer 2 梯度已同步
T5计算结束,等待正在传输 Layer 1 梯度最后的通信无法掩盖
T6Optimizer 更新参数空闲全局同步完成

关键设计——Bucket(桶)机制:PyTorch 将参数按顺序装入 Bucket,一旦某个 Bucket 里的所有梯度都算好,AllReduce 立即触发。梯度是从后往前算的(Layer 2 → Layer 1),因此 Layer 2 的梯度先算好、先通信,Layer 1 的计算与 Layer 2 的通信并行执行——这就是计算与通信重叠(overlap)

最后一个 Bucket(Layer 1)的通信无法被后续计算掩盖,是 DDP 的固有开销。

参数更新:同步完成后,两卡都持有相同的 gavg,各自执行 optimizer.step()

Wnew=Woldlr×gavg

14.3 数据并行、张量并行、流水线并行详解

数据并行、张量并行、流水线并行的工作原理与选型指南。

数据并行(DP)

数据并行是最简单的并行形式——每张卡持有完整的模型副本,只是输入数据不同:

GPU 0: Data_0 → Forward → Backward → 梯度 g0
GPU 1: Data_1 → Forward → Backward → 梯度 g1
            ↓ AllReduce ↓
GPU 0 & GPU 1: g_avg → Optimizer.step()

所有卡在 Optimizer 步骤后保持参数一致。

张量并行(TP)

TP 将单层的矩阵计算"竖着切",分摊到多卡上:

  • 切分方式:对 Q,K,V 等权重矩阵按列或行切分
WRd×4d列切{W1Rd×2dGPU 0W2Rd×2dGPU 1
  • 工作流:GPU 0 算矩阵上半,GPU 1 算矩阵下半 → AllReduce 同步结果 → 进入下一层
  • 通信特征极高频、高带宽——每一层的前向和反向传播都需要通信

直觉:TP 好比把一道大菜的刀工分给两个厨师同时切,但两人之间需要频繁交换中间结果。

流水线并行(PP)

PP 将模型的层"横着切",不同层运行在不同卡上:

GPU 0: Layer  1~16  ──→ Activations ──→ GPU 1
GPU 1: Layer 17~32  ──────────────────→ Loss
  • 通信特征低频、点对点——只在切分点(层边界)传输 Activation
  • 流水线气泡(Bubble):PP 的核心损耗。当 GPU 1 在计算时,GPU 0 可能空闲等待。气泡比为:
Bubble Ratio=P1M+P1

其中 P 为 PP 度,M 为 micro-batch 数。增大 M 可以有效降低气泡比。

TP vs. PP 选择指南

场景推荐策略原因
单机双卡(NVLink 互联)TP=2利用 NVLink 高带宽,降低单次推理延迟,显存均衡,几乎没有流水线气泡
双机单卡各一(跨节点)PP=2PP 对带宽要求低,是跨机运行大模型的唯一可行方案
大规模集群DP × TP × PP三维组合——TP 放机内(利用 NVLink),PP 跨机(容忍网络延迟),DP 扩数据吞吐

关键原则:TP 要求高带宽低延迟(NVLink),PP 容忍低带宽但有气泡损耗。跨机做 TP 的高频通信会被网络延迟直接锁死。


14.4 3D 并行高级话题

张量并行的通信量分析、流水线并行的 micro-batch 策略与 3D 组合配置实例。

张量并行的通信分析

TP 的通信量随 TP 度线性增加。对于隐层维度 d、序列长度 S、batch B

  • 每个 Transformer 层需要 2 次 AllReduce(前向一次、反向一次)
  • 每次 AllReduce 的通信量约为 2×B×S×d bytes

实践约束:TP 一般不超过单机 GPU 数(4 或 8),超出则通信开销抵消并行收益。

流水线并行的 micro-batch 策略

PP 通过将 mini-batch 切分为多个 micro-batch 来减少气泡:

时间 →
GPU 0: [F0][F1][F2][F3]          [B3][B2][B1][B0]
GPU 1:      [F0][F1][F2][F3] [B3][B2][B1][B0]
GPU 2:           [F0][F1][F2][F3] ... [B0]
       |← 气泡→|                              |← 气泡→|
  • Fi:第 i 个 micro-batch 的前向传播
  • Bi:第 i 个 micro-batch 的反向传播
  • micro-batch 数 M 越大,气泡占比越小,GPU 利用率越高

组合配置示例

以 64 张 A100(8 节点,每节点 8 卡 NVLink 互联)为例:

python
# 模型有 96 层,隐层维度 12288
dp = 4    # 4 路数据并行(4 组副本处理不同数据)
tp = 4    # 每机 4 卡张量并行(机内 NVLink 高速互联)
pp = 4    # 4 级流水线(跨 4 台机器)
# 总计 dp × tp × pp = 64 张 GPU

14.5 序列并行(SP)

超长序列训练的显存瓶颈解决方案:Ring Attention 与 DeepSpeed Ulysses 的原理对比。

问题背景

注意力机制的显存消耗与序列长度的平方成正比:O(S2)。当序列长度达到 128K 乃至 1M 时(如超长上下文窗口),单卡显存完全不够存放 Activation。

序列并行(Sequence Parallelism)的核心思想:将输入序列沿长度维度切分到多个 GPU 上,每张卡只处理一个 sub-sequence。

核心论文:

  • Ring Attention: [arXiv:2105.13120] Sequence Parallelism: Long Sequence Training from System Perspective
  • DeepSpeed Ulysses: [arXiv:2309.14509] System Optimizations for Enabling Training of Extreme Long Sequence Transformer Models

Ring Attention

Ring Attention 的策略是通信换内存

  1. 序列 XRB×S×d 按序列维度切分为 N 块,每卡持有子序列并计算本地的 Qi,Ki,Vi
  2. 通过 Ring 通信(接力赛式)轮转 K,V:每一轮,每卡将自己的 K,V 发送给下一卡,并接收上一卡的 K,V
  3. 在接收 Kj,Vj 时同步计算局部注意力分数 QiKjT
  4. 经过 N1 轮后,每卡拥有完整的注意力分数,可以做 softmax 并计算输出
Attention(Qi,K,V)=softmax(QiKTdk)V

下面的代码验证了 Ring Attention 与标准 Attention 的等价性:

python
# 标准注意力
output_standard = F.softmax(Q @ K.T / scale, dim=-1) @ V

# Ring Attention 模拟:每个 device 用 Q_i 与所有 K 块逐步计算
for i in range(num_devices):
    q_local = Q_chunks[i]
    for j in range(num_devices):
        k_idx = (i - j + num_devices) % num_devices
        scores_part = torch.matmul(q_local, K_chunks[k_idx].T) * scale
        ordered_scores[k_idx] = scores_part
    all_scores = torch.cat(ordered_scores, dim=-1)
    attn_weights = F.softmax(all_scores, dim=-1)
    output_chunk = torch.matmul(attn_weights, full_V)

output_ring = torch.cat(output_chunks, dim=1)
# torch.allclose(output_standard, output_ring) == True

DeepSpeed Ulysses

DeepSpeed Ulysses(名字取自西方著名长篇小说"尤利西斯")采用不同策略:通过 All-to-All 通信在序列维度和注意力头维度之间做转置。

核心思路——两次 All-to-All

  1. 初始状态:每 GPU 持有完整序列的 1/N,所有注意力头

    • Q,K,V: [bsz, seq/N, num_heads, head_dim]
  2. gather_seq_scatter_heads(第一次 All-to-All):收集完整序列,分散注意力头

    • [bsz, seq/N, num_heads, ...] → [bsz, seq, num_heads/N, ...]
    • 每 GPU 得到完整序列但只负责部分注意力头
  3. Flash Attention 计算:每 GPU 用完整序列、部分注意力头做本地 Attention

  4. gather_heads_scatter_seq(第二次 All-to-All):收集所有头结果,恢复序列分片

    • [bsz, seq, num_heads/N, ...] → [bsz, seq/N, num_heads, ...]

Ulysses 的关键优势:MLP 层的计算是 token-level 独立投影(token 之间没有交互),因此MLP 层不需要任何额外通信,只有 Attention 层需要 2 次 All-to-All。核心复杂性全在 Attention 层。

SP 与 FSDP 的协同

并行维度优化对象主要通信算子
FSDP模型参数显存AllGather + ReduceScatter
SP(Ulysses)激活值显存All-to-All

两者可以正交组合:SP 维度内做序列切分,DP 维度内做 FSDP 参数分片。

      SP=4 (列) -->
DP=2  GPU(0,0) GPU(0,1) GPU(0,2) GPU(0,3)  <- DP Group 0
(行)  GPU(1,0) GPU(1,1) GPU(1,2) GPU(1,3)  <- DP Group 1

verl 中的 SP 实现

verl 框架通过 Monkey Patch 实现 Ulysses SP:

python
# verl/models/transformers/monkey_patch.py
# 将标准 Flash Attention 替换为 Ulysses 版本
_flash_attention_forward => _ulysses_flash_attention_forward

数据处理流程:先将输入 padding 到能被 sp_size 整除的长度,再通过 slice_input_tensor 切分。dp_size = world_size // sp_size

python
# verl 配置示例
ulysses_sequence_parallel_size = 4

# 每卡最大 token 数配置
actor_rollout_ref.actor.ppo_max_token_len_per_gpu = 8192
actor_rollout_ref.rollout.log_prob_max_token_len_per_gpu = 8192
actor_rollout_ref.ref.log_prob_max_token_len_per_gpu = 8192

SFT vs. RL 中的 SP 差异

  • SFT:对 prompt + response 全部切分(response 是监督数据,已知)
  • RL:inference engine 先完成 response 的 rollout,之后仅需对训练阶段做 SP

注意:LLaMA-Factory 官方版本不支持序列并行,需要使用 360-LLaMA-Factory(社区 fork)等方案。


14.6 FSDP 与 FSDP2

FSDP 的全分片机制、前向/反向流程、CPU Offload 权衡,以及 FSDP2 的改进。

FSDP 核心概念

FSDP 将模型的三类数据全部分片到所有 GPU:

分片对象DDP 行为FSDP 行为
模型参数(Parameters)每卡完整副本切分,每卡持有 1/N
梯度(Gradients)每卡完整梯度切分,每卡持有 1/N
优化器状态(Optimizer States)每卡完整副本切分,每卡持有 1/N

关键术语:

  • FSDP unit:被 FSDP(...) 包裹的模块,是分片的基本单元(可以是整个模型,也可以是每个 Transformer block)
  • flat param:该 unit 的所有参数被拼成一个连续大张量
  • local shard:flat param 按 world size 切分后,本 rank 持有的那一片
  • unsharded / full param:计算时临时 AllGather 回来的完整参数
  • FULL_SHARD:参数/梯度都做分片管理(ZeRO-3 风格)

FSDP 的本质是数据并行:每个 rank 都在跑完整模型的计算图,只是输入数据不同(各自的 mini-batch shard)。每个 rank 不是"只算自己那部分 FSDP unit",而是对当前要执行的 unit 先 AllGather 出完整参数(临时),然后用本 rank 的本地数据做前向/反向,结束后再 reshard。

FSDP 前向/反向流程

前向传播(对每个 FSDP unit):

  1. 拿到 local shard(cpu_offload=True 时 shard 在 CPU)
  2. CPU → GPU 传输(如有 offload)
  3. AllGather:所有 rank 贡献自己的 shard,临时拼成 full param
  4. 用 full param 执行该 unit 的前向计算
  5. 计算完成后立即 reshard(释放完整参数;offload 场景下 shard 可回 CPU)

反向传播

  1. 再次 AllGather 当前 unit 的完整参数(计算梯度需要完整参数)
  2. 计算该 unit 的梯度
  3. ReduceScatter:将梯度规约并分散,每卡只保留对应 shard 的梯度
  4. cpu_offload=True,梯度 shard 也可驻留 CPU
FSDP unit = Layer i:
  Forward:  [local_shard_i] --AllGather--> [full_param_i] --compute--> reshard
  Backward: [local_shard_i] --AllGather--> [full_param_i] --grad--> ReduceScatter --> [grad_shard_i]

CPU Offload:显存 vs. 速度的权衡

CPU Offload 将不参与计算的参数/梯度驻留到 CPU(cpu_offload=True),降低 GPU 常驻显存,但引入 CPU↔GPU 传输延迟。以 GPT-2 级别模型、2 张 GPU 的实测数据:

==================== CPU Offload = True ====================
FSDP 包装后静态显存:0.00 MB      ← 参数全在 CPU
Forward 峰值显存:4082.86 MB
平均单步时延:59.40 ms             ← 较慢(CPU↔GPU 传输开销)

==================== CPU Offload = False ====================
FSDP 包装后静态显存:1364.94 MB   ← 参数分片驻留 GPU
Forward 峰值显存:4086.73 MB
平均单步时延:5.24 ms              ← 快约 11 倍

结论:CPU Offload 在峰值显存相近的前提下,将常驻显存降至接近零,但速度代价约 10x。适合显存极度紧张、对速度不敏感的场景。

FSDP2(fully_shard)的改进

FSDP2 是 PyTorch v2.4+ 引入的新 API(fully_shard),相比 FSDP1 的主要改进:

特性FSDP1FSDP2
参数表示FlatParameter(整 unit 拼平)逐参数 DTensor
AllGather 时机进入模块时整块 AllGather算子级按需 JIT AllGather
副本存留窗口较宽更窄(快速 reshard)
重叠机会有限更多
API 风格FullyShardedDataParallel(module)fully_shard(module)
torch.compile兼容性一般更好

FSDP2 使用 PyTorch 原生的 DTensor 抽象,对每个参数独立管理分片,而非将整个 unit 拍平为一个大张量。这使得内存效率更高,与 torch.compile 的兼容性也更好。

参数/优化器 Offload 注意事项

python
actor.fsdp_config.param_offload = True
actor.fsdp_config.optimizer_offload = True

开启 offload 时需注意:部分 worker 可能将参数保留在 CPU,而其他 worker 已经分片到 GPU,导致不对称的内存布局(asymmetric memory layout),需在框架层面协调。

torchrun 多机启动

bash
# 单机多卡(--standalone 自动处理 addr/port,默认 127.0.0.1:29500)
torchrun --standalone --nproc_per_node=8 train.py

# 多机多卡(2 节点 × 8 卡)
# 节点 0:
torchrun --nnodes=2 --nproc_per_node=8 --node_rank=0 \
         --master_addr=node0_host --master_port=29500 train.py
# 节点 1:
torchrun --nnodes=2 --nproc_per_node=8 --node_rank=1 \
         --master_addr=node0_host --master_port=29500 train.py

关键参数说明:

  • master_addr:主节点(Rank 0 节点)的 IP,是分布式通信的协调中心
  • master_port:主节点上用于通信的空闲端口
  • node_rank:当前机器在集群中的全局序号
  • 所有节点的 master_addrmaster_port 必须相同

14.7 Packing 技术

去除 Padding 浪费:Packing 技术通过密集排列有效 token 消除无效计算。

问题:Padding 的浪费

批量处理时,不同长度的序列需要 padding 到同一长度,产生大量无效计算:

句子 A:[tok1, tok2, tok3, tok4, tok5, tok6, tok7, tok8]  (8 个有效 token)
句子 B:[tok1, tok2, tok3, tok4, PAD,  PAD,  PAD,  PAD]  (4 个有效 + 4 个 PAD)

即便 PAD 位置在 attention_mask 中被屏蔽,模型仍需为这些位置分配显存和做部分计算。

unpad_input / pad_input

Flash Attention 库提供了高效的 padding 去除工具:

python
from flash_attn.bert_padding import unpad_input, pad_input, index_first_axis, rearrange

# 原始输入:input_ids (batch, seqlen), attention_mask (batch, seqlen)
# 去除 padding,得到密集 token 序列
input_ids_rmpad, indices, *_ = unpad_input(
    input_ids.unsqueeze(-1), attention_mask
)
# input_ids_rmpad.shape = (total_valid_tokens,)
# indices.shape = (total_valid_tokens,)  # 记录每个有效 token 在原始张量中的位置

# 位置编码同样需要对应去除
position_ids_reshaped = rearrange(position_ids.unsqueeze(-1), "b s ... -> (b s) ...")
position_ids_unpad = index_first_axis(position_ids_reshaped, indices).squeeze(-1)

一个具体例子(使用 Qwen2.5-0.5B-Instruct):

python
prompts = ["你好,请给我介绍一下大型语言模型。", "今天天气怎么样?"]
# Tokenize 后:
# input_ids shape: (2, 8)
# attention_mask: [[1,1,1,1,1,1,1,1], [1,1,1,1,0,0,0,0]]

# unpad 后:
# input_ids_unpad shape: (12,)  ← 8+4=12 个有效 token
# position_ids_unpad: [0,1,2,3,4,5,6,7, 0,1,2,3]  ← 两个句子各自的位置

为什么 unpad 不影响正确性

  1. Flash Attention 2 原生支持变长序列flash_attn_varlen),不需要方形注意力矩阵
  2. position_ids_unpad 保留了正确的位置信息,RoPE 等位置编码可正常工作
  3. Transformer 对每个 token 的处理本质上是并行的,只要位置信息和 token 关系正确即可

实测结果验证:unpad 后的输出 logits 与标准 padded 输入的有效位置 logits 完全一致torch.allclose == True)。

最优实践

在第一个 encoder block 之前做一次 unpad,在最后一个 block 之后做一次 pad,而不是每层都 unpad/pad。这样所有中间层(LayerNorm、FFN 等)都在密集 token 上计算,效率最高。

——Flash Attention 官方建议(GitHub Issue #11)

verl 中的 Packing 配置

python
# verl 性能调优参数
use_remove_padding = True  # 开启 sequence packing(即 rmpad)

14.8 HuggingFace Transformers 基础设施

HuggingFace 模型加载配置、Base 与 Instruct 模型的区别,以及 CUDA 初始化要点。

模型加载关键参数

python
from transformers import AutoModelForCausalLM, AutoTokenizer

model = AutoModelForCausalLM.from_pretrained(
    model_name,
    torch_dtype=torch.bfloat16,                 # 使用 BF16 精度
    attn_implementation="flash_attention_2",     # 启用 Flash Attention
    device_map="auto",                           # 自动分配到多 GPU
    trust_remote_code=True,
)
  • device_map='auto':按模块粒度自动将模型层分配到多张 GPU(pipeline-style),必要时还会用 CPU/磁盘做 offload。适合推理场景,训练时更推荐用 FSDP/DDP 显式控制。
  • attn_implementation='flash_attention_2':启用 Flash Attention 2,显存效率与速度大幅提升。

Base 模型 vs. Instruct 模型

以 Qwen2.5 系列为例:

特性Base 模型Instruct 模型
用途预训练基座,用于继续训练/微调直接用于对话
Chat Template通常无,需自己写有官方 ChatML 模板,强依赖
对话稳定性能对话但不稳定,未充分 align稳定

Qwen 官方明确建议:不推荐直接使用 Base 模型进行对话。Base 模型应通过 SFT、RLHF 等后训练步骤后再投入使用。

一个有趣的观察——对 Qwen2.5-7B 分析特殊 token 的 embedding 范数:

Qwen2.5-7B-Base:
  全 vocab L2 范数:mean=0.7915, std=0.2046
  <|endoftext|> (id=151643): norm=0.9746, z-score= 0.89  ← 有意义
  <|im_start|>  (id=151644): norm=0.0000, z-score=-3.87  ← 零向量!
  <|im_end|>    (id=151645): norm=0.0000, z-score=-3.87  ← 零向量!

Qwen2.5-7B-Instruct:
  <|im_start|>  (id=151644): norm=0.0100, z-score=-3.82  ← 经 SFT 后有了值
  <|im_end|>    (id=151645): norm=0.0105, z-score=-3.82

Base 模型中 <|im_start|><|im_end|>零向量——这说明这两个特殊 token 是在 SFT 阶段才被训练的,在预训练阶段根本没被使用过。

进一步分析邻居 token:Base 模型中零向量的"邻居"全是 cos similarity=0 的随机 token($, %, #),而 Instruct 模型的邻居开始呈现有意义的模式。

Contamination 实验:Base 模型的 Benchmark 污染

Base 模型虽然缺少对话对齐,但在某些 benchmark 上可能表现出"虚高"的准确率。这是因为预训练语料中可能包含了 benchmark 数据本身(即 数据污染/contamination)。

实验设计:使用 MATH-500 数据集(HuggingFace HuggingFaceH4/MATH-500),仅向 Base 模型输入题目的前半句(prompt prefix),用 greedy decoding 观察模型能否续写出完整题目并给出答案。

python
from vllm import LLM, SamplingParams

model_id = "Qwen/Qwen2.5-7B"  # Base 模型

prompts = [
    "For how many positive integers $n>1$ is",           # MATH-500
    "Convert the point $(0,3)$ in rectangular coordinates",  # MATH-500
    "Josh decides to try flipping a house. He buys a house for $80,000 and then puts",  # GSM8K
]

sampling_params = SamplingParams(
    temperature=0,    # Greedy decoding
    top_p=1.0,
    max_tokens=1024,
)

llm = LLM(model=model_id, max_model_len=4096, trust_remote_code=True)
outputs = llm.generate(prompts, sampling_params)

关键观察——Base 模型在仅给定题目前缀时,能逐字续写出完整题目并生成正确答案

Prompt: "For how many positive integers $n>1$ is"
Generation:
  it true that $2^{24}$ is a perfect $n^{\text{th}}$ power?
  ... (完整推理过程) ...
  The final answer is \(\boxed{7}\).
  finish_reason: stop

模型不仅补全了题目的后半部分(it true that $2^{24}$ is a perfect $n^{\text{th}}$ power?),还给出了完整的解题步骤和正确答案。这强烈暗示该题目(或极相似的文本)出现在了预训练语料中。

结论:在使用 Base 模型评估 benchmark 成绩时,必须警惕 contamination 导致的虚假高分。相关讨论可参考 arXiv:2507.10532。仅凭 benchmark 分数无法区分"模型真正学会了推理"和"模型记住了答案"。

Qwen3 系列模型

Qwen3 引入了思考模式的分化:

  • Qwen3-Instruct:支持 enable_thinking=False 关闭思考模式
  • Qwen3-Instruct-2507:仅支持非思考模式,不生成 <think></think>
  • Qwen3-Thinking-2507:仅支持思考模式,默认模板自动包含 <think>

CUDA 初始化相关

bash
# 延迟 CUDA 模块加载
export CUDA_MODULE_LOADING=LAZY

在多进程分布式训练中,延迟 CUDA 初始化可以避免启动时多个进程同时抢占 GPU 资源。需注意:

操作是否触发 CUDA 初始化
import torch
torch.cuda.is_available()
from torch.distributed.device_mesh import init_device_mesh
bash
# CUDA 版本检查常用命令
python -c "import torch; print(torch.__version__)"
python -c "import torch; print(torch.version.cuda)"
python -c "import torch; print(torch.cuda.get_arch_list())"

nvidia-smi vs. nvitopnvidia-smi 是 GPU 卡级别查看工具,nvitop 是 GPU 进程级别查看工具,调试时后者更常用。

环境 Bring-up 与常见排障

分布式训练环境的首次搭建(bring-up)往往比训练本身更耗时。以下是经验总结的排障清单:

pip 安装注意事项

bash
# 使用 --no-cache-dir 避免旧缓存干扰
pip install --no-cache-dir vllm

默认情况下 pip 会缓存下载的 .whl 文件(~/.cache/pip)。当频繁切换 CUDA 版本或 PyTorch 版本时,旧缓存可能导致安装了不兼容的包。--no-cache-dir 强制重新下载,安装后不保留 .whl 文件。

CUDA Runtime 安装

bash
# 在 conda 虚拟环境中安装独立的 CUDA 运行库(不影响宿主系统)
conda install -c nvidia/label/cuda-12.6.0 cuda-toolkit
nvcc --version  # 验证安装

注意nvidia-smi 显示的是驱动支持的最高 CUDA 版本,而非实际安装版本。例如驱动显示 12.1,仍可运行 torch 2.8.0+cu128 的 GPU 程序——只要驱动版本向下兼容。

verl 安装

verl 的安装脚本位于 verl/scripts/install_vllm_sglang_mcore.sh,也可手动安装。关键依赖顺序:先安装 vLLM(耗时较长,会自动安装所需的 PyTorch 版本),再安装 verl 本身。

常见错误诊断流程

  1. CUDA OOM:先用 nvitop 确认是否有残留进程占用显存;检查 device_map 配置是否合理
  2. 版本不兼容:运行上述 CUDA 版本检查命令,确认 PyTorch 编译的 CUDA 版本与系统 CUDA 匹配
  3. 分布式通信失败:检查 NCCL_DEBUG=INFO 日志,确认网络互通、端口未被占用

14.9 Ray 集群与调试

Ray 集群的启动、资源管理与调试方法,在 verl 框架中的实践要点。

Ray 基本概念

Ray 是一个通用的分布式计算框架。在 LLM 训练中(尤其是 RLHF/verl 框架),Ray 被广泛用于:

  • 统一管理多节点 GPU 资源(通过 ray status 查看)
  • 协调 Actor(训练模型)、Rollout Engine(推理)、Reward Model 等多角色
  • 提供 remote function/actor 的任务调度

集群启动

Ray 采用 Head Node + Worker Node 架构:

bash
# Head Node(头节点)
ray start --head --port 6379 --dashboard-host 0.0.0.0

# Worker Node(工作节点)
ray start --address='head_node_ip:6379'

# 查看集群状态(所有被 Ray 统一管理的资源)
ray status

避免 RAY_ADDRESS 冲突

在共享集群上使用 verl 时,如果已有其他用户启动的 Ray service,verl 可能会连接到错误的集群:

bash
# 方案 1:清除环境变量,让 verl 启动 local ray cluster
unset RAY_ADDRESS

# 方案 2:在 verl 配置中显式指定
+ray_kwargs.ray_init.address=local

Ray Debugger

在 Slurm 集群上配合 Ray 使用调试器:

bash
# 启动 Head 节点并开启外部调试器
ray start --head --ray-debugger-external --port 6379 --dashboard-host 0.0.0.0

注意事项:

  • 检查 ray 和 debugpy 的版本兼容性(pip show ray / pip show debugpy
  • 在 Slurm 集群上,配置 Ray distributed debugger 时应使用具体计算节点的 IP,而非 127.0.0.1

14.10 Slurm 集群管理

Slurm 作业调度系统的核心概念、资源申请与监控命令。

Slurm 核心概念

Slurm 是高性能计算中心(HPC)最常用的作业调度系统。核心角色:

  • Login Node(登录节点):用户 SSH 登录的入口,不直接做 GPU 计算
  • Compute Node(计算节点):拥有 GPU 的实际计算节点(如 DGX/HGX)
  • 共享文件系统:"文件共享,环境复制,但硬件隔离"——Login Node 和 Compute Node 挂载同一 NFS/Lustre/GPFS,代码文件双向可见

当执行 srun 时,Slurm 默认会把 Login Node 当前激活的 Python 环境带到 Compute Node 上执行。

环境配置

bash
# 加载 Slurm 和 CUDA 模块
module purge
module avail slurm
module load <slurm_version>

module avail cuda
module load cuda12.6
echo $CUDA_HOME
nvcc --version

资源申请与作业提交

交互式申请(常用于调试)

bash
# 1. 申请资源("包场")
salloc --partition=gpu --qos=high --account=mylab \
       -c 32 --mem=768G --gres=gpu:4 --time=72:00:00 -N 1
# -c 即 --cpus-per-task

# 2. 进入计算节点
srun -J $JOB_ID --pty /bin/bash

# 3. 在计算节点上执行训练
# srun 会读取 salloc 创建的环境变量,将命令远程传送到计算节点执行
srun python train.py

批量提交

bash
sbatch submit_job.sh

作业监控

bash
# 查看队列
squeue -u $USER       # 当前用户所有作业
squeue -t PD          # 排队中的作业
squeue -t R           # 运行中的作业

# 查看作业详情
scontrol show job $JOBID

# 查看资源使用
seff $JOBID                                    # CPU 使用情况
sacct -j $JOBID -o JobID,AllocTRES%50          # GPU 使用情况

# 查看节点状态(推荐设置 alias)
alias sf='sinfo --Format "NodeList:|,NodeAddr:|,StateLong:|,Partition:|,Time:|,CPUs:|,CPUsState:|,CPUsLoad:|,FreeMem:|,Gres:|,GresUsed" | column -t -s "|"'

14.11 量化参数

量化的基本原理与主流格式(INT4/INT8/MXFP4),以及 BitsAndBytes 配置方法。

量化的基本原理

量化(Quantization)通过降低参数的数值精度来减少显存占用:

精度每参数字节数120B 模型所需显存
FP324 bytes480 GB
BF16 / FP162 bytes240 GB
INT81 byte120 GB
INT4 / FP40.5 bytes60 GB

计算示例:GPT-OSS 系列的 120B 模型使用 MXFP4 量化:

120B×2 bytes(BF16)÷4(4-bit 压缩比)=60 GB

理论上只需 60 GB 显存即可加载。

主流量化格式

  • INT4:最常用的量化格式。Kimi-K2.5 采用与 Kimi-K2-Thinking 相同的原生 INT4 量化方法。
  • MXFP4(Microscaling FP4):更精确的 FP4 格式,GPT-OSS 系列提供官方 MXFP4 量化版本。
  • BF16:训练常用精度,兼顾精度与效率,是大多数训练框架的默认选择。

模型加载中的量化配置

python
from transformers import AutoModelForCausalLM, BitsAndBytesConfig

# INT8 量化
quantization_config = BitsAndBytesConfig(load_in_8bit=True)

# INT4 量化(QLoRA 常用配置)
quantization_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
)

model = AutoModelForCausalLM.from_pretrained(
    model_name,
    quantization_config=quantization_config,
)

14.12 WandB 实验追踪

WandB 实验追踪的基本配置与 RLHF 训练中的关键监控指标。

基本配置

bash
# 登录 WandB
export WANDB_MODE=online
wandb login

verl 中的 WandB 集成

verl 框架内置了 WandB 支持,通过配置直接启用:

yaml
trainer:
  logger: ['console', 'wandb']
  project_name: 'my-rl-training'
  experiment_name: 'qwen2.5-7b-grpo'

关键监控指标

训练 RLHF 时,WandB 中常见需要追踪的指标:

指标含义异常信号
train/reward平均奖励应稳定上升
train/kl_divKL 散度(与 ref policy 的距离)过大则 reward hacking
train/response_len生成长度长度崩塌是训练问题的信号
train/entropy策略熵过低说明模式崩溃
train/lossActor loss需与 reward 结合判断

本章小结

  1. 3D 并行(DP/TP/PP)是大模型训练的核心框架。DP 切数据、TP 切层内算子、PP 切层——三者正交组合,按 DP×TP×PP=Total GPUs 配置。TP 放机内(利用 NVLink 高带宽),PP 跨机(容忍网络延迟),DP 扩吞吐。

  2. NCCL 提供 AllReduce、AllGather、ReduceScatter、All-to-All 等高效通信原语。DDP 通过 Bucket 机制实现计算与通信重叠——梯度从后往前算,算好的 Bucket 立即发起 AllReduce,与后续梯度计算并行执行。

  3. FSDP/FSDP2 将参数、梯度、优化器状态全部分片,显著降低单卡显存占用。FSDP2 基于 DTensor 逐参数管理,AllGather 时机更精细,内存效率更高。CPU Offload 可进一步降低常驻显存但有约 10 倍速度代价。

  4. 序列并行(Ring Attention、DeepSpeed Ulysses)解决了超长序列(>128K)训练中的显存瓶颈。Ring Attention 通过 Ring 通信轮转 K/V;Ulysses 通过两次 All-to-All 在序列维度和注意力头维度间转置,MLP 层零额外通信。

  5. Packing 技术通过去除 padding、密集排列有效 token,消除无效计算。配合 Flash Attention 的变长序列支持,在第一层 unpad、最后一层 pad,所有中间层都在密集数据上运行。

  6. Ray + Slurm 是业界主流的集群管理组合。Slurm 负责 HPC 资源调度,Ray 负责多角色任务编排。verl 框架原生集成 Ray,用 unset RAY_ADDRESSaddress=local 避免集群冲突。

  7. 量化(INT4/INT8/MXFP4)是降低显存需求的核心手段——120B 模型使用 INT4 理论仅需 60 GB 显存。BitsAndBytes 提供 HuggingFace 生态内的开箱即用量化方案。


延伸阅读