编排与数据基础设施
一次前沿运行要让数千张加速器步调一致地连续工作数周,而它底下的硬件却按自己的节奏在失效。计算工作,也就是矩阵乘法与集合通信,看起来是最难的那部分,但决定一次运行能否跑完的并不是它。在数千个节点连续运行数周这样的规模上,故障是常态,不是例外:一张 GPU 掉出总线,一块网卡抖动,一台主机 OOM,而集合通信是成组调度(gang-scheduled,作业里的所有节点要么一起被调度,要么都不调度)的,只要有一个参与者死掉,整个作业就被拖停。这里讨论的运维层,正是在底层硬件不断损坏的同时,让这样一次运行持续推进并保持正确:检查点、数据层与可观测性。
这三项机制也划清了本章与相邻章节的边界。这层运维所依托的加速器与互连,留给 第 62 章;它所保存、所供给的并行布局,留给 第 10 章。这里的问题是步与步之间、以及跨越故障时发生的事:多久保存一次,哪些状态必须保存,数据顺序如何跨过重启,以及操作员如何在损失曲线给出误导之前发现故障。
把故障当作常态,自然引出三项义务。运行必须是可恢复的:节点失效时,从近期保存的状态继续,而不是从头再来。数据加载层必须让加速器持续有数据可用,按加速器消耗词元的速率把词元送上去,而且要可复现地送。运行还必须足够可观测,让一次故障从遥测里被当场抓住,而不是数天之后才从一条悄悄走偏的损失曲线里被发现。三者都属运维,也都不显眼,却都决定着昂贵的计算工作究竟划不划算。这三项义务确定之后,调度器与成本取舍会重新回到视野里。
从故障中恢复:检查点与重启
对付故障的核心机制是检查点:周期性地把模型参数、优化器状态(优化器为每个权重保存的运行统计量)、学习率调度的位置和数据采样器状态序列化下来,使运行能从那份快照重建。写入量主要来自优化器状态,因为 AdamW 为每个参数都带着两个矩估计,这正是 第 10 章 沿数据并行组切分的那份状态 (Rajbhandari et al. 2019)。
朴素的检查点做法是停下每个 rank、写入存储、再恢复,把整个写入延迟压在关键路径上。这是最显而易见的初始设计:暂停,把一切写进共享文件系统,再恢复。当模型还能宽裕地装下、集群也不大时,这尚可忍受,但它扩展性很差。要写的状态和故障率都随运行规模一起涨,于是同步写入从一个可忽略的零头变成了一笔看得见的税。有两个想法能把写入从关键路径上挪走。异步检查点把张量拷到主机内存或暂存缓冲区,让训练继续往前,同时由一个后台写入者把它们慢慢排空到存储;CheckFreq 清楚地展示了这个做法,它让快照和写入流水化,把开销压得足够低,短频率因此变得用得起,检查点也就从一桩粗粒度、昂贵的事件变成了一桩例行公事 (Mohan et al. 2021)。切分或分布式检查点让每个 rank 只并行地写自己那一份状态分片,于是写入时间随每 rank 的状态而非总状态增长;这顺着完全切分数据并行的布局而来,那里的状态本就已经沿集群切开 (Zhao et al. 2023)。
频率是一个经济决策
频率本身是个优化问题,不是个常数。检查点太稀,一次故障就回滚掉大量工作;检查点太密,写入本身又压榨存储带宽、偷走时间。最优值要在每次故障预期损失的工作量和单次写入的成本之间取平衡,所以平均故障间隔越低的集群,越想要更短的频率。一个有用的初步估算是这样:若故障以速率 到来,单个检查点的写入成本为 ,那么让总浪费时间最小的间隔 大致按 缩放。这里 越大表示故障越频繁, 越大表示保存越贵;平方根说明两边都不会线性支配最优间隔。这就是保存成本与重放成本之间那个熟悉的平方根权衡。频繁的检查点缩小了每次故障损失的工作量,却压榨存储带宽、还可能拖停运行;稀疏的检查点写起来便宜,但故障一来代价高昂。异步与切分检查点正是让短频率从一开始就用得起的前提,而最优值取决于集群的 MTBF 和写入成本,并不取决于某个固定的步数。
写入成本和故障率给定后,数值最优值会从平方根公式里落到 U 形浪费曲线的谷底。
import numpy as np
import matplotlib.pyplot as plt
C = 30.0 # 检查点写入成本,秒
mtbf = 6 * 3600 # 平均故障间隔,秒
lam = 1.0 / mtbf # 故障率
T = np.linspace(60, 36000, 2000) # 检查点间隔,秒
waste = C / T + lam * T / 2 # 写入开销 + 期望重放,单位时间内
T_star_numeric = T[np.argmin(waste)]
T_star_formula = np.sqrt(2 * C / lam)
print("数值最优 T*:", round(T_star_numeric), "秒")
print("公式 sqrt(2C/lambda):", round(T_star_formula), "秒")
plt.plot(T / 60, waste, label="总浪费率")
plt.axvline(T_star_formula / 60, color="C1", ls="--", label="T* = sqrt(2C/lambda)")
plt.xlabel("检查点间隔 T(分钟)")
plt.ylabel("浪费时间占比")
plt.title("浪费随检查点频率呈 U 形")
plt.legend(); plt.grid(alpha=0.3); plt.show()
检查点频率是从下层被定下的。第 62 章 里加速器与互连的平均故障间隔,决定了运行会被中断多频繁,而真正固定那个经济的保存间隔的,是这个故障率,不是模型或训练配方的任何性质。硬件更不稳定的集群必须更频繁地做检查点,这抬高了存储带宽需求,也压低了有效吞吐。一个看似只是可调参数的运维选择,其实由它下面那一层的可靠性决定。
恢复,以及那些不会显式崩溃的故障
恢复是另一半。弹性重启会检测到失效节点,把它从集合通信里隔离出去,再从最近的检查点恢复其余进程,最好是落到预热好的备用容量上,这样作业就不必等人来处理、也不必等失效主机重新上线。更棘手的是那些不会显式崩溃的故障:一个跑得慢的掉队节点会把每一次集合通信都拖到它的步调;而静默数据损坏(SDC),也就是不崩溃却悄悄写出错误数字的静默数据损坏,会让运行照常进行,这时任何检查点都无济于事,因为检查点忠实地把那份损坏一并记了下来。
图 65.2 里的这道分叉正是设计要点:一次可检测的崩溃走的是检测、隔离、在预热备用容量上恢复的快路径;而那些根本不触发崩溃检测器的故障,只能靠可观测性来抓,要么干脆无从恢复。
检查点放在哪里,以及哪些仍有争议
把检查点写到存储底座,持久、能承受全集群级的事件,但慢;暂存在主机内存、或在对等节点之间复制,写得快、恢复也快,可一旦集群同时宕掉的部分足够多就会丢失。这个选择是拿持久性去换检查点成本,多数运行会把一个频繁的低成本层和一个偶发的持久层组合起来用。持有预热的备用节点,能让一次弹性重启在数秒而非数分钟内恢复,但那些节点已经付费却闲置着,直到某次故障用上它们:这份预留是保险,保费就是利用率。
在前沿规模上该怎么做检查点,至今没有定论。一派坚持把持久写入留在存储底座,再用异步、切分的快照设法把它做得便宜。另一派则主张:这个规模上存储太慢,根本没法做得足够频繁,答案在于内存内或对等复制的检查点,把近期状态留在主机内存里、或沿冗余节点切开,让一次重启能在数秒内从内存恢复,只有遇上相关性故障才回退到持久存储。两条路意味着不同的成本结构,也意味着对相关性故障的不同容忍度,各前沿技术栈之间并没有一个已成定论的默认选择。图 65.3 把这两派沿着区分它们的几条轴并排摆出来。
供给加速器:数据层
第二根支柱是数据供给。语料被分词后切成存储底座上的许多分片,底座是一个并行文件系统或 NVMe-over-Fabrics 层,规模足以用每秒数 TB 的速度供给集群,前面通常还配一个本地 NVMe 缓存。每个 rank 读取分给自己的分片,趁当前批训练时预取并流水化下一批,让加速器不必为 I/O 等待;一次全局打乱则让任何单个分片的局部相关性都不至于让某一步产生偏差。加载顺序服从来自 第 6 章 的配比权重,权重设定了每个数据源被抽到的频率;在整个运行里守住这些比例,便是配比契约。
这一层从映射式数据集演化而来。映射式数据集索引一份驻留内存的语料,如今则变成从不把整个语料物化出来的流式、分片流水线,因为数万亿词元的语料既装不下,也没法廉价地随机索引。
可复现性把数据层系回检查点
把数据层系回检查点的那条性质,是可复现性。一次恢复必须续上运行在没出故障时本会走的那条数据顺序,分毫不差。这就意味着采样器与打乱的状态,即排列中的位置、轮次、各数据源的抽取计数器,都要进检查点,而不是在重启时重新生成,如 图 65.4 所示。少了这一步,一次恢复就会重喂或跳过数据,悄悄破坏配比契约。
可复现性这条要求是随规模一起来的。小规模上,一次非确定的恢复只是个小麻烦;可到了前沿规模,恢复频繁、配比契约又是关键约束,把采样器状态写进检查点就成了没得商量的事。它并非没有成本:要做到数据顺序的精确可复现,就得付出确定性的开销,因为采样器状态必须进检查点、打乱也必须可重放,这就限制了为 I/O 效率重排序时能有多激进。放弃它能换来一些吞吐,却也丢掉了干净利落地恢复的能力,以及把一段损失不连续追溯到成因的能力。
知道运行是否健康:可观测性
第三根支柱的设计源自一个事实:单看损失曲线,并不能表明系统这一侧的工作对不对。一次运行可能很慢、受通信拖累、或悄悄退化,而损失照样在降。所以这层运维去检测步本身:每步的实际耗时及其分解,暴露的通信与重叠的通信,还有那些能足够早抓住发散或尖峰、好让人来得及动手的梯度范数(梯度的整体大小,它的骤然跃升可能让训练失稳)与损失遥测。诊断的单位是一条每步的时间线或剖析,不是瞥一眼损失。
那些逃过崩溃检测器的故障,正是在这里被抓住。一个掉队节点,会在每步分解里现形为一个慢下来的 rank;一次发散,会在损失明显转向之前先出现一个梯度范数尖峰。静默数据损坏是连良好的可观测性也吃力的那一类,因为那些数字虽然错,却并不反常,这也正是它落在 图 65.2 不可恢复那一端的原因。
调度器如何连接三者
成本与调度坐落在这三根支柱之下。集合通信是成组调度的,作业要么同时拥有所有节点,要么一个节点都推不动进度,所以调度器把这一整组预留下来,并让备用容量保持预热,好在不整体重启的情况下吸收一次故障。检查点频率、备用节点的供给、存储底座带宽,是运维上的几个成本参数,它们直接和运行的吞吐相互权衡。每根支柱都对应其中一种成本:更高频率换来可恢复性,备用容量换来快速重启,存储带宽则同时支撑一个持久检查点层和一个持续供给数据的数据层。而让单次预留把这一切集中管理起来的,正是调度器。
运维层的组装
实践中,这层运维是组装出来的,不是整套购置的。模型与优化器的切分来自并行框架,但检查点、数据、可观测性这几层往往是自研的,因为它们的频率、切分与可恢复性,必须准确对上某次运行的确切布局。
这个规模上的一个检查点不是单个文件。每个 rank 序列化它那一份参数与优化器状态分片,外加那些小而关键的标量:步计数、学习率调度位置、数据采样器状态。这份契约可以写成如下草图:
state = {
"model_shard": shard_of(model.parameters()),
"optim_shard": shard_of(optimizer.state), # 主导项
"step": step,
"lr_schedule_pos": scheduler.position(),
"sampler_state": loader.sampler.state_dict(), # 使恢复可复现
}
async_write(state, path_for(rank, step)) # 移出关键路径
有两个失效模式来得晚,也来得安静。一次节点故障,若没有近期检查点、也没有快速检测,就会把运行回滚到上一次保存,那段间隔全成了纯粹的浪费,这正是为什么检测、隔离与一份新近的检查点是同一个系统,而不是三个。重启时数据顺序不可复现,根子在一个没被写进检查点的采样器状态,它会重喂或跳过数据,破坏来自 第 6 章 的配比契约;它表现为每次恢复时一段无法解释的损失不连续,而它明明是个运维 bug,却很容易被误读成训练配方的问题。这两者都被这层运维装上的可观测性抓住,也都能通过把采样器状态和恢复路径当作检查点的一等部分来预防。
延伸阅读
- Mohan et al., “CheckFreq: Frequent, Fine-Grained DNN Checkpointing” (asynchronous, low-overhead checkpointing), 2021. usenix.org
- Zhao et al., “PyTorch FSDP: Experiences on Scaling Fully Sharded Data Parallel” (sharded state that distributed checkpointing persists), 2023. arXiv:2304.11277PyTorch FSDP 是一个工业级全分片数据并行(FSDP)训练系统,通过跨 GPU 分片模型参数,在大模型上实现近线性 TFLOPS 扩展,同时在小模型上达到与 DDP 相当的性能。
- Rajbhandari et al., “ZeRO: Memory Optimizations Toward Training Trillion Parameter Models” (sharded optimizer state, the dominant checkpoint term), 2019. arXiv:1910.02054ZeRO(零冗余优化器)通过跨数据并行(DP)进程划分优化器状态、梯度和参数来消除显存冗余,使超过 100B 参数的模型训练成为可能,并实现超线性吞吐量扩展。
评论
登录后评论