检查点与重启策略

检查点是让长时作业在故障后「快速回来」的核心机制。本文讲清全量/增量/多级检查点的取舍、SCR 与 VeloC 等库的用法、检查点 IO 优化与冗余级别、ULFM 运行时容错、Young/Daly 最优间隔公式,以及 Slurm 上的自动重启与信号处理实践。

一个运行 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 分钟,可以更频繁地保存而总开销更低——这正是多级检查点的价值。

单次检查点耗时 CMTBF最优间隔 τ*
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 倍作为保守值。

实践建议

  1. 先测量再调优:记录单次检查点耗时与作业总时长,代入 Young/Daly 算基线间隔。
  2. 分级存储:内存 → 本地 SSD → 并行文件系统三级,用 SCR/VeloC 自动管理。
  3. 异步化:把检查点写盘放到后台,让计算不停。
  4. 原子改名:避免恢复时读到半成品,损坏时回退到上一版本。
  5. 配合调度器:--requeue + --signal + --open-mode=append 三件套,让重启自动化。
  6. 定期演练:主动 kill 作业验证恢复流程,别等真故障时才发现恢复脚本有 bug。
  7. 监控检查点健康度:记录每次检查点的耗时、大小与是否成功,异常波动往往是 IO 子系统退化的早期信号。
  8. 版本化状态格式:检查点文件头写入格式版本号,升级应用后仍能识别旧检查点,避免格式不兼容导致恢复失败。

检查点与并行文件系统的带宽特性密切相关,Lustre 并行文件系统 的条带化配置会直接影响检查点吞吐;而自动重启的调度细节(requeue 策略、故障节点排除)可参考 Slurm 集群调度 。

小结

检查点/重启是长时 HPC 作业的生命线。理解全量/增量/多级检查点的取舍、SCR/VeloC 等库的用法、IO 重叠与压缩优化、Young/Daly 最优间隔公式,以及 Slurm 上的自动重启机制,你就能为任意规模的作业设计出「故障后能快速回来」的韧性方案。核心权衡始终是:检查点越快越频繁,丢失的计算越少,但 IO 开销越高——用数据而非直觉来选这个平衡点。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「hpc」更多文章

  1. FPGA 可重构加速
  2. 互连拓扑设计:Fat-tree、Torus 与 Dragonfly
  3. 并行调试与死锁诊断工具链