跳到主要内容

L3.4 万卡集群弹性容错

三维坐标 layer: L3(训练)level: Senior/Architectpillar: 训推框架

万卡集群训练的核心矛盾不是「算得快不快」,而是**「在硬件持续抖动下能不能活到收敛」。当 GPU 数量从千卡跨到万卡,单卡平均无故障时间(MTBF)被乘上一万倍稀释——一次训练里几乎必然**会有卡掉、网断、ECC 翻位。本文先下沉到 NCCL / 昇腾 HCCL 集合通信底层,看清 All-Reduce 的物理耗时从何而来,再上浮到 弹性训练(elastic training) 的故障剔除—热备拉起—无重启热恢复全链路。

学习目标

  • 前置知识:读过 L3.1 3D 并行(知道 TP/PP/DP 怎么切,world size 为何对并行策略敏感)、L3.2 ZeRO 显存优化(知道优化器状态/梯度如何分片)、L3.3 框架实践(跑过 torchrun 起多进程任务);并了解 L1.3 AI 存储架构里的 checkpoint 落盘路径(GDS / 本地 NVMe + 远端对象存储分层)。无需 NCCL 源码经验。
  • 学完产出:① 能写出 Ring All-Reduce 的耗时公式 T_ring ≈ 2·(N-1)/N·S/BW,并解释 (N-1)/N 因子为何让带宽量与卡数几乎无关;② 能区分 Ring / Tree / Double Binary Tree 三种 All-Reduce 在「带宽 vs. 延迟」上的取舍,说清 NCCL_ALGO 改了为什么大小消息表现相反;③ 能画出「故障检测 → 秒级剔除 → 热备拉起 / 缩容 → 重组 → 无重启续训」的容错状态机,并指出 NCCL 通信组「半死不活」这个最致命的坑;④ 能算清「真死 vs. 慢」误判、「热备 vs. 缩容」成本、「checkpoint 频率最优解」三笔账,理解为什么 TP 维度几乎不能缩容;⑤ 亲手用 torchrun 跑通弹性训练,kill 掉一个 worker 后观测到 rendezvous 重组 + 从 checkpoint 续训。
  • 阅读姿势:盯住一条主线——「万卡训练的胜负手不是峰值算力,而是『在硬件持续抖动下能不能活到收敛』」。从集合通信底层(故障最终都暴露在 All-Reduce 这一环)一路上浮到弹性恢复全链路,每个机制都在回答同一个问题:单卡可靠、万卡必坏,如何让一次故障的代价从「小时级全量重启」压到「秒级无重启热恢复」。

背景与现状

万卡集群弹性容错(Large-Cluster Elastic Fault Tolerance) 解决的是一个朴素却致命的工程现实:单卡可靠,万卡不可靠。假设单张 GPU 的日故障率是 0.1%,那么一个 1 万卡集群在一天内至少坏一张卡的概率高达 99.99%——这意味着没有容错能力的训练任务,跑不过一个晚上就会因为单点故障而全军覆没

故障来源是物理性的、不可消除的:

  • 硬件层:HBM 的 ECC(纠错码) 不可纠错错误、SM 计算单元降频、NVLink 链路降速。
  • 网络层:RDMA/RoCE 的网络丢包、光模块(optical module)抖动、交换机端口 flap。
  • 系统层:进程 OOM、节点宕机、NCCL 通信在环死锁(hang)——这是最隐蔽的一类,所有 rank 卡在同一个 All-Reduce 上,GPU 利用率 100% 却没有任何进展。

业界的演进可以概括为三个阶段:

  • 静态训练时代(2020 前):world size 固定,任意一卡挂掉 → 整个 job 失败 → 从上一个 checkpoint 全量重启。万卡场景下,一次重启的代价可能是数小时算力
  • 弹性训练登场(2019–2021):PyTorch 于 2019 年推出 TorchElastic(2021 年随 PyTorch 1.9 并入主干成为 torchrun),引入 c10d rendezvous(集合点) 机制,允许 world size 动态伸缩,单点故障后仅重组幸存 worker 而非全量重启。
  • 无重启热恢复(2023 至今):Meta、字节、阿里等团队推动热备卡(hot spare)动态拉起进程级热恢复,目标是把单次故障的恢复代价从「小时级」压到「秒级 / 分钟级」,让万卡 MFU(Model FLOPs Utilization)不被容错开销吃掉。

业界信号:Meta 在 OPT-175B 训练日志中坦承,2 个月里经历了 100+ 次硬件故障与手动重启;Llama 3 的 405B 训练在 16K H100 集群上平均每 3 小时一次中断。这说明:在万卡尺度,容错不是「锦上添花」,而是「能不能训出来」的胜负手

原理与架构

要理解弹性容错,必须先理解**「故障发生在哪里」——而 90% 的训练故障,最终都暴露在集合通信(collective communication)** 这一环。

2.1 通信库底层:NCCL 与昇腾 HCCL

NCCL(NVIDIA Collective Communications Library) 是 NVIDIA GPU 之间集合通信的事实标准;华为昇腾的对应物是 HCCL(Huawei Collective Communication Library)。两者的源码组织高度相似:都把 All-Reduce / All-Gather / Reduce-Scatter / Broadcast 等原语,映射到底层物理拓扑(NVLink / PCIe / RDMA 网卡)上的一系列点对点(P2P)传输

以最核心的 All-Reduce(梯度同步)为例,NCCL 会根据节点规模与拓扑,在三种算法间自动选择:

算法拓扑结构适用规模通信轮数
Ring All-Reduce环形中等规模、带宽敏感2(N-1)
Tree All-Reduce二叉树大规模、延迟敏感2·log(N)
Double Binary Tree双二叉树超大规模(NCCL 默认)~2·log(N),且带宽利用率接近 Ring

Ring All-Reduce 的物理耗时公式是理解一切通信优化的基石。设 N 为参与卡数、S 为待规约数据量(字节)、BW 为单链路带宽(字节/秒),则一次 Ring All-Reduce 的理论传输耗时约为:

T_ring ≈ 2 · (N-1)/N · S / BW

关键洞察藏在 (N-1)/N 这个因子里:当 N → ∞,该因子 → 1,意味着 Ring All-Reduce 的通信量与卡数 N 几乎无关,只取决于数据量 S 与带宽 BW——这正是 Ring 算法在大规模下「带宽最优」的数学根源。但它的步数 2(N-1) 随 N 线性增长,因此万卡尺度下延迟(latency)会被放大,NCCL 才转而用 Double Binary Tree 把步数压到 log(N) 级别。

源码必读路径:NCCL 的算法选择逻辑在 src/transport/src/graph/(拓扑探测)、src/collectives/(原语实现);HCCL 的对应实现在 CANN 的 hccl/ 目录,其 AllReduceOperator 与 NCCL 的 ncclAllReduce 一一对应。理解这一层,你才能解释「为什么改了 NCCL_ALGO=Tree 后小消息变快、大消息变慢」

2.2 长周期训练容错:故障剔除—热备拉起—无重启热恢复

弹性训练的核心抽象是 rendezvous(集合点):所有 worker 在启动时向一个中心化的 KV 存储(c10d store,底层常用 etcd / TCPStore) 注册,达成对「当前 world size 与各自 rank」的共识。当某个 worker 故障,rendezvous 会触发重组(re-rendezvous),幸存 worker 重新分配 rank 并从 checkpoint 续训。

整个容错闭环可以建模成一个状态机

这个状态机里有三个架构师必须吃透的设计点:

  • 单点秒级剔除:依赖 NCCL watchdogTORCH_NCCL_ENABLE_MONITORING)与 agent 心跳,把「检测到故障」的时延从分钟级压到秒级。难点在于区分「真死」与「慢」——误判会导致健康节点被无谓踢出。
  • 热备卡动态拉起:集群预留一批 hot spare 节点,故障时由 agent/调度器即时拉起替补 worker 加入 rendezvous,保持 world size 不变——这对依赖固定 world size 的并行策略(如 3D 并行的 TP/PP 切分)至关重要。
  • 无重启热恢复:恢复 = 从最新 checkpoint 加载 + 重建通信组(new NCCL communicator),而非杀掉整个 job 重来。关键是 checkpoint 频率与异步保存(如 异步 / 分层 checkpoint)的权衡:存太频 → I/O 拖慢训练;存太疏 → 故障回滚损失大。

这里的关键在于:弹性恢复最致命的坑是 NCCL 通信组的「半死不活」——故障 rank 已退出,但幸存 rank 仍卡在旧 communicator 的 All-Reduce 上在环死锁。正确的恢复必须销毁旧通信组、重建新通信组,否则会出现「rendezvous 重组成功,但训练 step 永久 hang」的诡异现象。这是区分「能跑 demo」与「能扛万卡」的分水岭。

动手实践:极简代码实操

实验目标:用 torchrun 跑一个弹性训练任务,中途 kill 掉一个 worker 进程,亲眼观测 rendezvous 重组从 checkpoint 恢复续训 的全过程——验证「弹性恢复脚本能否真正续训」。产出物:一份包含「故障前 step → kill → 重组 → 续训」完整时间线的日志。

3.1 环境准备

# 推荐 Python 3.11;用 venv 隔离
python3 -m venv .venv && source .venv/bin/activate
# CPU 路径(Mac / 无 GPU 默认,用 gloo 后端即可跑通弹性逻辑)
pip install torch --index-url https://download.pytorch.org/whl/cpu
# 若有 NVIDIA GPU,改用官方 CUDA wheel(走 nccl 后端,可观测真实通信组重建):
# pip install torch

3.2 代码:可恢复的弹性训练脚本

# elastic_train.py
import os, time, torch, torch.distributed as dist

CKPT = "/tmp/elastic_ckpt.pt"

def setup():
use_cuda = torch.cuda.is_available()
backend = "nccl" if use_cuda else "gloo"
dist.init_process_group(backend=backend)
rank = dist.get_rank()
world = dist.get_world_size()
print(f"[rank {rank}] joined, world_size={world}, backend={backend}", flush=True)
return rank, world, use_cuda

def load_step():
# 无重启热恢复的核心:从最新 checkpoint 续训,而非从 0 开始
if os.path.exists(CKPT):
step = torch.load(CKPT)["step"]
print(f"[resume] 从 checkpoint 恢复,续训 step={step}", flush=True)
return step
return 0

def main():
rank, world, use_cuda = setup()
device = torch.device("cuda", rank % torch.cuda.device_count()) if use_cuda else torch.device("cpu")
step = load_step()
MAX_STEPS = 60
while step < MAX_STEPS:
g = torch.ones(1, device=device) * (rank + 1)
dist.all_reduce(g, op=dist.ReduceOp.SUM) # nccl 后端必须用 GPU 张量
if rank == 0 and step % 5 == 0:
torch.save({"step": step}, CKPT) # rank0 周期 checkpoint
print(f"[ckpt] step={step} saved, allreduce_sum={g.item():.0f}", flush=True)
step += 1
time.sleep(1) # 放慢节奏,方便手动 kill
print(f"[rank {rank}] DONE at step={step}", flush=True)
dist.destroy_process_group()

if __name__ == "__main__":
main()

3.3 运行与观察弹性恢复

torchrun 启动弹性任务,关键参数是 --max-restarts(允许重启次数)与弹性 world size 区间

# 单机模拟 4 个 worker 的弹性训练
# --nnodes=1:1 表示节点数弹性区间;--nproc-per-node=4 起 4 个 worker
# --max-restarts=3 允许 rendezvous 重组最多 3 次
# --rdzv-backend=c10d 使用 c10d rendezvous(集合点)
torchrun \
--nnodes=1:1 \
--nproc-per-node=4 \
--max-restarts=3 \
--rdzv-backend=c10d \
--rdzv-endpoint=localhost:29500 \
elastic_train.py

注入故障并观察:训练跑起来后,在另一个终端找到任意一个 worker 进程并 kill

# 找到 elastic_train.py 的某个子进程 PID 并 kill(模拟单点故障)
pkill -f elastic_train.py -n # 杀掉最新启动的那个 worker
# 或精确 kill:ps aux | grep elastic_train → kill -9 <某个 worker PID>

预期观测到的现象(这就是弹性容错在工作):

  • kill 瞬间,存活 worker 的 All-Reduce 会报错或超时(NCCL/gloo 检测到 peer 掉线)。
  • torchrun 的 agent 捕获到 worker 退出 → 触发 re-rendezvous(重组)
  • 所有 worker 重新 init_process_group,打印新的 world_size,并通过 load_step() 从最新 checkpoint 续训——日志里能看到 [resume] 从 checkpoint 恢复,续训 step=XX,而不是从 step=0 重来

快速冒烟(单卡 demo,ELASTIC_DEMO_STEPS=8

bash run_elastic_demo.sh
# [rank 0] joined, world_size=1, backend=nccl
# [ckpt] step=0 saved, allreduce_sum=1
# [ckpt] step=5 saved, allreduce_sum=1
# [rank 0] DONE at step=8

踩坑预警 (Gotchas)

  • 没有 checkpoint = 假弹性torchrun --max-restarts 只负责重启进程,它不会帮你恢复训练状态。如果脚本里没有 load_step() 从 checkpoint 续训,重组后会从头训练——看起来「恢复了」,实则白跑。弹性容错 = rendezvous 重组 + 状态恢复,缺一不可
  • NCCL 通信组「半死」导致永久 hang:GPU 路径下,故障后若不销毁旧通信组再重建,幸存 rank 会卡在旧 communicator 的 All-Reduce 上在环死锁。设 export TORCH_NCCL_ASYNC_ERROR_HANDLING=1(异步错误处理)与 TORCH_NCCL_ENABLE_MONITORING=1(watchdog),让 NCCL 能主动抛错而非死等。
  • --max-restarts 用尽即整体失败:重组次数超过上限后,整个 job 退出。万卡生产环境需配合热备卡拉起补足 world size,并把上限设得足够大。
  • nccl 后端必须用 GPU 张量backend=nccl 时 All-Reduce 的张量必须在 CUDA 上(torch.ones(1, device="cuda")),否则报 No backend type associated with device type cpu。gloo 后端则相反,张量须在 CPU。
  • rendezvous 端口冲突--rdzv-endpoint 的端口(29500)被占用会导致启动失败,换端口或 lsof -i:29500 清理残留进程。

深入思考

下面三题每题先给题干,再用 <details> 折叠一份图文并茂的参考答案。建议先合上答案自己想 3 分钟,再展开对照。

思考题 1:单一超时阈值为何在万卡尺度必然失效

你的万卡集群把 NCCL watchdog 超时设为 10 秒以求「秒级剔除」,结果发现某些正常但慢的 step(如大 batch 的 All-Gather)被误判为 hang,导致健康节点被频繁踢出、训练反而更不稳。结合 2.2 节的「真死 vs. 慢」难点,设计一套判别策略——可以从心跳、step 时长分布、通信量预估哪几个维度入手?为什么单一超时阈值在万卡尺度必然失效?

展开参考答案(含「真死 vs. 慢」多维判别图 + 算一遍)

结论:单一超时阈值假设「正常 step 时长是一个常数」,但万卡尺度下 step 时长本身就是一个带长尾的分布——通信量、拓扑路径、网络抖动都会让正常 step 偶尔变慢;只有把「绝对超时」换成「相对基线的多维证据投票」,才能把『真死』和『暂时慢』区分开。

为什么单一阈值必然失效(算一遍):设正常 step 时长中位数 1.0 秒,但 P99 因为 All-Gather 大消息可达 3.0 秒。

  1. 阈值设 10 秒「保守」:真死要等满 10 秒才被发现 → 万卡每 3 小时一次中断,每次多等 10 秒看似小,但叠加每次重组本身的几十秒,容错损耗被检测延迟拉高
  2. 阈值设 2 秒「灵敏」:P99 的正常慢 step(3.0 秒 > 2 秒)被误杀 → 健康节点频繁踢出 → 每次误踢触发一次全集群 re-rendezvous(数十秒)→ 训练比不容错还慢
  3. 万卡尺度下「正常但慢」的尾部事件几乎每个 step 都在某个 rank 上发生(1 万个 rank,哪怕单 rank P99.99 命中率 0.01%,每 step 期望 1 次命中),单一阈值无论怎么取都同时踩中两个坑。

多维判别策略:① 心跳维度——agent 进程级心跳还在 = 进程没死,多半是「慢」而非「真死」,最强的真死信号是心跳断 + 退出码非 0;② step 时长分布维度——维护每个 rank 的 step 时长滑动窗口,用「超过自身基线 P99 的 K 倍且持续 M 个窗口」代替绝对秒数;③ 通信量预估维度——根据当前 step 的 All-Reduce/All-Gather 数据量 S 与带宽 BW 预估理论耗时(呼应 2.1 节 T_ring 公式),对大通信 step 动态放宽阈值。三个维度投票 + 复查一个窗口再剔除,把误判率压下来。

思考题 2:热备 vs. 缩容,与「TP 维度为何不能缩容」

故障发生时有两种恢复策略——拉起热备卡保持 world size 不变,或缩容续训(world size 减小)。前者需要常驻空闲算力(钱),后者会打乱 3D 并行的切分。结合 2.2 节的「热备卡动态拉起」设计点,分析:在一个 8192 卡、TP=8 / PP=16 / DP=64 的训练任务里,掉 1 张卡时这两种策略各自的代价与可行性?为什么 TP 维度的卡几乎不能缩容?

展开参考答案(含 3D 并行切分对故障的敏感度图 + 对比表)

结论:掉 1 张卡只会打掉某个 DP 副本里的一张 TP 分片,TP 组是「一份模型被横切成 8 块、缺一块就算不出一层」的强耦合整体,所以不能缩容;可行的恢复是要么用热备卡顶上那一张保持 world size,要么以 DP 副本为单位整组下线(缩容 DP),绝不能在 TP/PP 维度上少一张卡硬跑。

两种策略对比:

维度热备卡顶上(保持 world size)缩容续训(整组下线 DP 副本)
算力代价需常驻空闲热备(如预留 1%~3% 算力,钱)不需预留,但损失 1 个 DP 副本算力(128 卡)
并行切分影响无:world size 不变,TP/PP/DP 切分原样DP64→DP63,全局 batch / 梯度累积需重算
恢复速度快:替补 worker 加入 rendezvous 即可较快,但要重配 DP 通信组与数据切分
可行性约束要求热备卡与故障卡同拓扑域(同 PP/TP 摆位)只能按 DP 副本整组缩,不能缩 TP/PP

算一遍:8192 = 64 × 16 × 8。掉 1 张卡 → 命中某个 DP 副本(128 卡)里某段 PP(8 卡 TP 组)里的 1 张。

  • TP=8 为何不能缩容:TP 把一层的权重矩阵按列/行横切成 8 份,前向/反向每一层都要在这 8 张卡间做 All-Reduce/All-Gather 拼回完整结果。缺 1 张 → 这一层的矩阵乘根本拼不出来,不是「慢一点」而是「算错/算不出」。所以 TP 组是不可分割的原子单位。
  • 可行恢复:要么热备卡补上这 1 张(world size 仍 8192,TP=8 完整);要么把这 1 张所在的整个 DP 副本(128 卡)下线,DP64→DP63,剩 8064 卡继续——损失一份数据副本但模型并行结构完好。这正是 2.2 节强调「热备保持 world size 对 3D 并行至关重要」的原因。

思考题 3:checkpoint 频率的最优解与异步分层

设单次故障的期望回滚损失正比于 checkpoint 间隔 Δt,而 checkpoint 本身的 I/O 开销会拖慢训练。结合 2.2 节的 checkpoint 频率权衡,给定集群故障率 λ(次/小时)与单次 checkpoint 耗时 c,推导最优 checkpoint 间隔 Δt* 的直觉形式,并解释为什么异步 / 分层 checkpoint能同时改善这两项,从而把万卡 MFU 的容错损耗压到 1% 以内。

展开参考答案(含同步 vs. 异步分层 checkpoint 时间线图 + 算一遍)

结论:总浪费时间 = checkpoint I/O 开销 + 故障回滚损失,前者随间隔变长而减少、后者随间隔变长而增大,两者相加在 Δt* ≈ √(2c/λ) 处取最小;异步分层 checkpoint 把同步 I/O 从关键路径挪走,等于同时把 c 压到接近 0,于是最优间隔可取得很短、回滚损失也很小,两头都赢。

推导 Δt*(直觉形式):在一段时长 T 内,

  1. checkpoint 次数 ≈ T / Δt,每次开销 c → I/O 总开销 ≈ c · T / Δt
  2. 故障次数 ≈ λ · T,每次故障平均回滚半个间隔 Δt/2 → 回滚损失 ≈ λ · T · Δt/2
  3. 单位时间总浪费 W(Δt) = c/Δt + λ·Δt/2。对 Δt 求导置零:-c/Δt² + λ/2 = 0Δt* = √(2c/λ)

这与「经济订货批量(EOQ)」同形:最优间隔随单次开销 c 增大而拉长、随故障率 λ 增大而缩短

算一遍:设 c = 60 秒(同步全量 checkpoint),λ = 1/3 次/小时 ≈ 0.33 次/小时(呼应 Llama 3「每 3 小时一次中断」)。换算单位(c 转小时 = 1/60 小时):

  • Δt* = √(2 · (1/60) / 0.33) ≈ √(0.101) ≈ 0.318 小时 ≈ 19 分钟
  • 此时单位时间浪费 W = c/Δt* + λ·Δt*/2,两项相等 ≈ 2·√(c·λ/2),代入 ≈ 2·√((1/60)·0.33/2) = 2·√0.00275 ≈ 2·0.0524 ≈ 10.5%——同步 checkpoint 下容错损耗已超过 10%,远超 1% 目标。

异步 / 分层为何两头都赢:把「同步全量写远端」拆成「先快照到本地 NVMe(毫秒~秒级,几乎不停训练)+ 后台异步刷远端对象存储」。从公式看,关键路径上的 c 从 60 秒骤降到接近 0:

  • c → ~1 秒后,Δt* = √(2·(1/3600)/0.33) ≈ 0.041 小时 ≈ 2.5 分钟,间隔可取得很短;
  • 总浪费 ≈ 2·√(c·λ/2)c 开方下降,c 降 60 倍 → 损耗降约 √60 ≈ 7.7 倍 → 从 10.5% 压到 约 1.4%(已逼近 1% 目标,再配合降低 λ/提高带宽即可进一步压到 1% 以内)。

这就是 2.2 节强调「异步 / 分层 checkpoint」的工程价值:它不是让你存得更勤,而是把存的开销移出关键路径,让「存得勤」与「不拖慢训练」不再互斥——把万卡 MFU 的容错损耗真正压到 1% 以内。

延伸阅读

1. 核心 Paper

  • Bandwidth Optimal All-reduce Algorithms for Clusters of Workstations(Patarasuk & Yuan, 2009)— Ring All-Reduce 带宽最优性的原始证明,理解 2(N-1)/N 因子的数学根源。
  • TorchElastic / TorchRun 设计文档(PyTorch)— 弹性训练 rendezvous 与 c10d store 的设计动机。
  • Llama 3 / OPT-175B 训练技术报告 — 万卡训练真实故障率与容错工程的第一手数据。

2. 相关高 Star 仓库与源码必读路径

  • NVIDIA/nccl — 看 src/graph/(拓扑探测)、src/collectives/(All-Reduce 原语)、src/transport/(Ring/Tree 算法选择)。
  • pytorch/pytorch — 弹性核心在 torch/distributed/elastic/(agent、rendezvous)、torch/distributed/c10d(store 与通信组),NCCL watchdog 在 torch/csrc/distributed/c10d/ProcessGroupNCCL.cpp
  • 华为昇腾 CANN / HCCL 官方文档与仓库 — HCCL 的 AllReduceOperator 与昇腾拓扑映射,对照 NCCL 理解异构实现。

3. 优质博客 / 视频

  • NVIDIA 技术博客「Doubling all2all Performance with NVIDIA Collective Communication Library」与 NCCL 调优系列。
  • Meta Engineering 关于 Llama 训练基础设施与容错(hardware reliability at scale)的工程分享。
  • 字节 / 阿里 关于「万卡训练稳定性」与「无重启热恢复」的技术公开分享。

下一篇L3.5 硬核编译开发实战:LLVM Pass 与 MLIR Dialect:从「让模型活到收敛」上浮到「让算子跑得更快」,深入 LLVM/MLIR 编译栈如何把模型代码榨干硬件性能。