一个运行 72 小时的作业,在拥有数万个 GPU 的集群上几乎必然遭遇节点故障。故障本身不可怕,可怕的是它发生在第 71 小时。检查点/重启(Checkpoint/Restart, C/R)机制让作业周期性保存状态,故障后从最近一次检查点恢复,把「丢失的计算」限制在可控范围内。但检查点不是免费的:它消耗存储带宽、暂停计算、拖慢作业。本文讲清检查点的类型、库、IO 优化与重启策略,以及决定「多久存一次」的量化方法。故障特征与容错架构的背景可参考 HPC 容错与检查点 。
为什么检查点是刚需
系统规模越大,单个组件的平均无故障时间(Mean Time Between Failures, MTBF)越短。若单个节点 MTBF 为 10 万小时,1 万节点的系统整体 MTBF 约 10 小时——比许多作业的运行时间还短。
系统 MTBF ≈ 单节点 MTBF / 节点数
= 100000 h / 10000 = 10 h
这带来一个基本约束:作业运行时间不能超过系统 MTBF 太多,否则大概率中途失败。检查点把「丢失的工作量」从「全部」压缩到「自上次检查点以来的部分」,是让长时作业可行的工程基础。
检查点的类型
按内容:全量、增量、差分
| 类型 | 保存内容 | 开销 | 恢复复杂度 |
|---|---|---|---|
| 全量(Full) | 全部状态 | 高 | 低 |
| 增量(Incremental) | 自上次全量以来变化的部分 | 中 | 中 |
| 差分(Differential) | 自上次任意检查点以来的变化 | 低 | 高(需链式恢复) |
增量检查点适合「内存大、每次变化少」的应用(如分子动力学的粒子位置更新)。差分检查点最省 IO,但恢复时需要按顺序重放全量 + 多个差分,链越长恢复越慢,且任一环损坏则整链失效。
按协调:协同与非协同
协同检查点(Coordinated) 要求所有进程在同一时刻冻结并写盘,逻辑简单、恢复直接,但存在全局同步开销,且对慢节点敏感(最慢的进程决定检查点完成时间)。
非协同检查点(Uncoordinated) 各进程独立决定何时保存,避免全局同步,但恢复时需处理消息日志(message logging)以重建进程间依赖,实现复杂。多数生产系统选择协同检查点 + 优化的写盘路径。
按位置:磁盘、多级、无盘
Level 1: 内存(最快,容量小,节点故障即丢)
Level 2: 节点本地 SSD / 突发缓冲(快,容量中)
Level 3: 并行文件系统(慢,容量大,持久)
多级检查点(Multilevel C/R) 把检查点先写到快层(SSD),异步刷到慢层(Lustre),兼顾速度与持久性。极端情况下有无盘检查点(Diskless):把检查点冗余存到其他进程的内存中(如邻居节点各存一份),避免任何磁盘 IO,代价是占用内存并需应对多节点同时故障。
冗余级别与可靠性
多级检查点把数据放在快层(如节点本地 SSD)以提速,但快层随节点故障一起丢失。为应对这一风险,SCR 引入冗余级别:把每个进程的检查点额外复制到若干邻居节点。
XOR 冗余:同组 N 个进程各存一份,另存一份 XOR 校验
任一组内丢失 1 个进程,可用 XOR 恢复
复制冗余:每个检查点存 2 份(本地 + 1 个邻居)
丢失 2 个非同一组的进程仍可恢复
冗余级别越高越可靠,但占用更多内存/存储。选择依据是节点故障率:在故障频繁的系统上,把检查点只放在单节点 SSD 上等于赌博——恰好那台机器坏了,最新检查点就没了。实践上常用「2 份冗余 + 周期刷到并行文件系统」的组合,兼顾速度与安全。
检查点库
SCR:可扩展检查点/重启
SCR(Scalable Checkpoint/Restart)是 LLNL 主导的库,核心思想是「尽可能存到最快、最便宜的地方」,并按冗余级别管理。
#include "scr.h"
SCR_Init();
int id;
SCR_Start_checkpoint();
/* 应用写自己的检查点文件(普通文件 IO) */
write_state_to_files(ckpt_dir, step);
SCR_Complete_checkpoint(id); /* 库负责把文件刷到缓存层并按需冗余 */
# 运行
scr_run mpirun -np 1024 ./sim
# 设置检查点层:缓存、冗余、输出目录
export SCR_CACHE_SIZE=8 # 每节点缓存检查点数
export SCR_COPY_TYPE=FILE
export SCR_FLUSH=10 # 每 10 个检查点刷一次到并行文件系统
SCR 自动管理「哪一级还留着哪个检查点」,应用只需声明检查点的开始与结束。
VeloC:多级检查点抽象
VeloC 提供跨内存/本地存储/并行文件系统的多级 C/R,接口更面向应用:
#include "veloc.h"
VELOC_Init(MPI_COMM_WORLD, "veloc.conf");
int version;
VELOC_Checkpoint("sim_state", &version); /* 保存 */
VELOC_Restart("sim_state", version); /* 恢复 */
应用级检查点
对结构复杂的应用,把「状态」交给自己管理往往比通用库更高效:应用知道哪些数据真正需要保存,可跳过派生数据、临时缓冲。典型做法是每个进程把自己的状态写成一个独立文件:
char fname[256];
snprintf(fname, sizeof(fname), "%s/ckpt.%06d.%d", dir, step, rank);
FILE *fp = fopen(fname, "wb");
fwrite(&step, sizeof(int), 1, fp);
fwrite(state, sizeof(double), state_size, fp);
fclose(fp);
优点是零依赖、完全可控;缺点是重启时文件数等于进程数,元数据压力大(百万进程即百万文件),需配合「每节点合并写」或 HDF5/ADIOS2 的并行写(见 并行 IO 栈 )。
检查点 IO 优化
检查点开销主要来自写盘。优化目标是减少写入量与重叠写入时间。
减少写入量
- 只写必要状态:跳过可由其他数据重算的派生量。
- 压缩:多数科学数据可压缩 2~5 倍,
zstd -1在速度与压缩率间平衡良好。 - 降精度:检查点用 FP32 存 FP64 状态,恢复时再升精度(需评估累积误差)。
重叠写入
异步写让应用在数据落盘时继续计算:
/* 伪代码:后台线程写盘,主线程继续计算 */
pthread_t writer;
pthread_create(&writer, NULL, async_write, &ckpt_buffer);
/* 继续计算,稍后 join */
pthread_join(writer, NULL);
SCR 的缓存层天然支持这种「先写快层、后台刷慢层」的重叠模式。
用突发缓冲
许多超算在计算节点与 Lustre 之间部署突发缓冲(Burst Buffer,如 SSD 阵列)。检查点先写突发缓冲(带宽高、延迟低),再由系统异步迁移到 Lustre,能显著缩短检查点暂停时间。
| 优化手段 | 效果 | 复杂度 |
|---|---|---|
| 写本地 SSD | 暂停时间降 5~10× | 低 |
| 突发缓冲 | 暂停时间降 10×+ | 中(需系统支持) |
| 异步/后台写 | 暂停时间→近乎 0 | 中 |
| 压缩 | IO 量降 2~5× | 低 |
| 多级 + 冗余 | 兼顾速度与可靠 | 高 |
检查点的可扩展性瓶颈
检查点写盘常以「每个进程一个文件」的形式进行,元数据操作随进程数线性增长,在百万进程规模下会成为瓶颈:
100 万进程 × 每进程 1 文件 = 100 万次文件创建
→ 并行文件系统的元数据服务器被打爆
缓解办法:
- 每节点合并:节点内先聚合到共享内存,由单个进程代表节点写一个大文件。
- 共享单文件 + 偏移:所有进程写同一个文件的不同偏移(HDF5/MPI-IO 的集合写),减少文件数。
- 突发缓冲:把元数据压力卸载到中间层。
这些手段都指向同一个原则:减少元数据操作、增大单次 IO 粒度,与并行 IO 栈中两阶段 IO 的思路一致。
重启策略
信号处理与优雅退出
作业收到调度器的终止信号(如 Slurm 的 SIGTERM)时,应保存检查点后再退出,而不是被强杀:
#include <signal.h>
volatile sig_atomic_t stop_requested = 0;
void handle_term(int sig) {
stop_requested = 1; /* 只置标志,主循环里处理 */
}
int main(int argc, char **argv) {
signal(SIGTERM, handle_term);
/* ... */
for (int step = 0; step < nsteps && !stop_requested; step++) {
compute(step);
if (step % ckpt_interval == 0) checkpoint(step);
}
if (stop_requested) checkpoint(step); /* 退出前补一次检查点 */
MPI_Finalize();
}
信号处理函数内只能做异步信号安全的操作,因此只置标志位,真正的检查点在主循环里执行。
Slurm 上的自动重启
Slurm 的 --requeue 让作业在节点故障时自动重新排队,配合 --signal 在超时前发信号:
#!/bin/bash
#SBATCH --job-name=sim
#SBATCH --time=72:00:00
#SBATCH --requeue
#SBATCH --signal=B:USR1@300 # 超时前 300s 发 SIGUSR1
#SBATCH --open-mode=append # 重启后日志追加而非覆盖
# 作业脚本内捕获信号并保存
trap 'srun kill -USR1 $SLURM_JOB_ID' USR1
# 查找最近的检查点并恢复
LATEST=$(ls -t $CKPT_DIR/ckpt.* 2>/dev/null | head -1)
if [ -n "$LATEST" ]; then
RESTART_ARG="--restart-from $LATEST"
fi
srun ./sim $RESTART_ARG
--open-mode=append 很关键:默认重启会覆盖日志,丢失故障前的记录。
应用内恢复
恢复时需校验检查点完整性(避免读到写了一半的文件),常用「先写临时文件、完成后原子改名」:
write_state(fname_tmp);
fsync(fd);
rename(fname_tmp, fname); /* 原子操作,要么旧要么新 */
若发现检查点损坏,回退到更早的版本。多级检查点的库会自动做这种回退。
与容错编程模型的结合
检查点/重启是「事后恢复」模型:故障后整作业重启,从磁盘恢复。与之互补的是运行时容错模型,在故障发生时无需重启即可继续。
ULFM:可收缩的 MPI
用户级故障缓解(User-Level Failure Mitigation, ULFM)扩展了 MPI,允许进程在通信失败时感知并重建通信子,而不是整个作业崩溃:
MPI_Comm_shrink(comm, &newcomm); /* 剔除失效进程,收缩通信子 */
MPI_Comm_revoke(comm, 0); /* 撤销通信子上的挂起操作 */
MPI_Comm_agree(comm, &flag); /* 全体一致的故障协商 */
配合检查点,ULFM 可做到「故障进程被剔除、其余进程从最近检查点重分布状态继续」,避免整作业从头重启。代价是编程复杂:应用必须处理「进程数变化后数据如何重分布」的问题。
检查点与算法级容错的取舍
| 模型 | 恢复粒度 | 编程复杂度 | 适用场景 |
|---|---|---|---|
| 检查点/重启 | 整作业 | 低 | 通用、长时作业 |
| ULFM + 检查点 | 失效进程 | 高 | 极致规模、故障频繁 |
| 算法级容错(ABFT) | 数据块 | 中 | 线性代数(如矩阵乘校验) |
| 冗余计算 | 无 | 中 | 关键路径、短作业 |
算法级容错(Algorithm-Based Fault Tolerance, ABFT)为矩阵运算加校验和,用冗余计算检测并纠正错误,恢复开销远低于重算;但它只适用于特定算法结构,通用性差。工程上最常见的组合是「检查点兜底 + 关键内核 ABFT」,在可靠性要求极高时叠加 ULFM。
检查点频率的量化
存得太少,故障时丢太多;存得太勤,IO 开销拖慢作业。Young/Daly 公式给出最优检查点间隔:
τ* = sqrt(2 · C · MTBF)
其中 C 是单次检查点耗时,MTBF 是系统平均无故障时间。推导思路:最小化「检查点总开销 + 故障时平均重算开销」。
举例:单次检查点耗时 C = 60 s,系统 MTBF = 10 h = 36000 s:
τ* = sqrt(2 × 60 × 36000) ≈ sqrt(4.32e6) ≈ 2078 s ≈ 35 min
即约每 35 分钟存一次最优。若检查点更快(写 SSD,C = 6 s),最优间隔缩短到约 11 分钟,可以更频繁地保存而总开销更低——这正是多级检查点的价值。
| 单次检查点耗时 C | MTBF | 最优间隔 τ* |
|---|---|---|
| 60 s(Lustre) | 10 h | ~35 min |
| 6 s(本地 SSD) | 10 h | ~11 min |
| 1 s(内存/无盘) | 10 h | ~4.5 min |
Young/Daly 假设故障服从指数分布且检查点开销固定;实际中检查点耗时会随系统负载波动,工程上常取 τ* 的 0.7~1.0 倍作为保守值。
实践建议
- 先测量再调优:记录单次检查点耗时与作业总时长,代入 Young/Daly 算基线间隔。
- 分级存储:内存 → 本地 SSD → 并行文件系统三级,用 SCR/VeloC 自动管理。
- 异步化:把检查点写盘放到后台,让计算不停。
- 原子改名:避免恢复时读到半成品,损坏时回退到上一版本。
- 配合调度器:
--requeue+--signal+--open-mode=append三件套,让重启自动化。 - 定期演练:主动 kill 作业验证恢复流程,别等真故障时才发现恢复脚本有 bug。
- 监控检查点健康度:记录每次检查点的耗时、大小与是否成功,异常波动往往是 IO 子系统退化的早期信号。
- 版本化状态格式:检查点文件头写入格式版本号,升级应用后仍能识别旧检查点,避免格式不兼容导致恢复失败。
检查点与并行文件系统的带宽特性密切相关,Lustre 并行文件系统 的条带化配置会直接影响检查点吞吐;而自动重启的调度细节(requeue 策略、故障节点排除)可参考 Slurm 集群调度 。
小结
检查点/重启是长时 HPC 作业的生命线。理解全量/增量/多级检查点的取舍、SCR/VeloC 等库的用法、IO 重叠与压缩优化、Young/Daly 最优间隔公式,以及 Slurm 上的自动重启机制,你就能为任意规模的作业设计出「故障后能快速回来」的韧性方案。核心权衡始终是:检查点越快越频繁,丢失的计算越少,但 IO 开销越高——用数据而非直觉来选这个平衡点。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。