跳到主要内容

L1.3 现代 AI 存储架构优化

三维坐标 layer: L1(硬件架构)level: Seniorpillar: 硬件架构

上一篇我们站在算力与显存视角看硬件。本篇把镜头转向最容易被忽视却最常拖垮大规模训练的环节——存储 I/O。当模型参数突破万亿、训练集群扩展到万卡,「把数据搬进 GPU」和「把状态写回磁盘」这两件事本身就成了瓶颈。我们要讲清两件硬核工程:用 GPUDirect Storage 绕开 CPU bounce buffer,以及用异步多级 checkpoint 化解万节点同时写盘的「存储崩溃风暴」。

学习目标

  • 前置知识:读过 L0 全层(尤其 L0.2 三大物理墙:算力墙/显存墙/带宽墙的概念)与 L1.1 单卡微架构(知道 HBM 显存、PCIe/DMA 的存在);写过基础 Python;用过 torch.save 存过模型即可。无需 CUDA 编程或并行文件系统运维经验。
  • 学完产出:① 能画出 GDS 与传统 POSIX I/O 两条读路径,并说清 bounce buffer 为什么让 CPU 内存带宽成为瓶颈;② 能用「把不可避免的开销藏到关键路径之外」一句话讲清异步 checkpoint 的本质,并解释为什么工业方案优先落本地 NVMe 而非纯内存暂存;③ 能说清「checkpoint 风暴」的成因,并组合「异步 + 多级缓存 + sharded + 去重」四手段设计削峰方案;④ 能分析 GDS 的收益边界——为什么大块顺序读收益显著、小文件随机读却会被 IOPS/PCIe 事务开销吃掉;⑤ 能从 RTO/RPO 角度推演故障恢复的真实代价,算清「拉远端 2TB」与「读本地 NVMe 副本」的数量级差距。
  • 阅读姿势:盯住一条主线——「存储 I/O 优化的所有招式,都是在和『数据搬运比计算慢得多』这堵带宽墙较劲」。读路径(GDS 绕开 CPU 中转)和写路径(异步多级 checkpoint 削峰)看似两件事,本质都是同一个动作:把慢速搬运从训练关键路径上摘出去——要么缩短路径,要么把它藏到计算背后。

背景与现状

在传统认知里,存储是「冷数据的归宿」,离热路径很远。但在大模型训练场景下,这个假设彻底崩塌:

  • 数据加载侧:训练一个多模态大模型,dataloader 每秒要从 NVMe / 对象存储拉取数 GB 的样本喂给 GPU。一旦 I/O 跟不上,价值数百万美元的 GPU 集群就会空转等数据(GPU utilization 掉到 50% 以下是常态)。
  • 状态保存侧:万卡训练动辄跑数周,硬件故障是统计必然而非偶然。要靠 checkpoint(检查点) 周期性保存模型权重、优化器状态、RNG 状态,才能在节点挂掉后从最近一次快照恢复,而不是从头再来。

问题在于,一个 175B 模型的 checkpoint(含 FP32 优化器状态)可达 2~3 TB;万节点训练若同时落盘,瞬时写入压力可达 数十 TB/s,足以瞬间打垮任何共享存储集群——这就是业界俗称的 「checkpoint 风暴(checkpoint storm)」

业界信号(截至 2026 年中):NVIDIA 在 DGX / SuperPOD 参考架构中把 GPUDirect Storage(GDS) 列为标配,Meta 在 OPT-175B 训练日志里直言 checkpoint 写入是最大运维痛点之一,PyTorch 也已将 异步 / 分片 checkpoint(Distributed Checkpoint, DCP) 提升为一等公民。进入 2025–2026,这条链路又有三处新动向:① PCIe Gen5 已全面普及(Blackwell、MI350 标配),PCIe Gen6(64 GT/s)规范定稿、2026 起首批平台落地,进一步抬高单卡 I/O 上限;② CXL 内存扩展/池化(CXL 2.0 落地、3.x 规范支持 fabric)开始在「显存不够、用 CXL 内存兜底」的场景渐进部署;③ 故障恢复走向「秒级」——字节 ByteCheckpoint、PyTorch DCP、以及弹性训练框架 torchft 等把 checkpoint 开销与恢复时间压到极低,因为万卡集群单次故障间隔可短至数小时,恢复效率直接决定有效算力利用率(goodput)。这些信号共同指向一个事实:存储 I/O 已从「后台杂务」升级为大规模训练的胜负手

本篇聚焦两条主线:读路径(GDS 让数据从 NVMe 直达显存)与写路径(多级异步 checkpoint 化解风暴)。

原理与架构

2.1 GPUDirect Storage:绕过 CPU bounce buffer 的直达路径

理解 GDS,关键是先看清传统 I/O 路径的「绕远路」问题。当 GPU 需要从 NVMe SSD 读取数据时,标准 POSIX I/O 走的是一条「先回 CPU 再下 GPU」的弯路:

对比两条路径

维度传统 POSIX I/OGPUDirect Storage (GDS)
数据路径NVMe → CPU bounce buffer → GPUNVMe → GPU 显存(DMA 直达)
内存拷贝至少 1 次 CPU 内存中转拷贝0 次中转拷贝
CPU 占用高(拷贝消耗 CPU 周期与内存带宽)极低(仅下发控制指令)
APIread() / pread() + cudaMemcpycuFile API(cuFileRead / cuFileWrite)
实测带宽受 CPU 内存带宽与拷贝瓶颈限制可逼近 NVMe + PCIe 物理上限

值得注意的是:bounce buffer 不仅多一次拷贝,更致命的是它让 CPU 内存带宽成为整条 I/O 链路的瓶颈——当多块 GPU 同时拉数据时,CPU 内存总线被打满,GPU 反而饿死。GDS 通过 PCIe Peer-to-Peer DMA,让 NVMe 控制器的 DMA 引擎直接把数据写入 GPU 的 BAR 映射显存区,CPU 只参与 control plane(提交 I/O 请求),不碰 data plane。NVIDIA 公开数据中,GDS 在大块顺序读场景下可带来 2~8 倍有效带宽提升、显著降低 CPU 利用率。

GDS 落地依赖一套软硬件栈:支持 P2P 的 NVMe / NVMe-oF 存储 + PCIe 拓扑(GPU 与 NVMe 挂在同一 PCIe switch 下最优) + NVIDIA nvidia-fs 内核模块 + cuFile 用户态 API(包含在 CUDA Toolkit 的 libcufile 中)。

2.2 Checkpoint 写路径:从「风暴」到多级异步缓存

读路径解决「喂得快」,写路径要解决「存得稳」。核心矛盾是:checkpoint 必须周期性写,而同步写会冻结整个训练。

朴素做法 torch.save(state_dict, path)同步阻塞的:所有 GPU 先把状态 D2H 拷回 CPU,再串行写盘,期间整个训练 step 完全停滞。万节点同时这么干,瞬时写入洪峰直接打垮共享文件系统。工业级解法是多级异步缓存流水线

这套流水线背后有四个相互配合的优化手段:

优化手段原理解决的问题
异步 checkpoint状态拷到 pinned host memory / 本地 NVMe 后训练立即继续,写盘在后台线程进行消除训练主循环的阻塞停顿
多级缓存先写本地高速 NVMe(热),后台再上传对象存储(冷)把瞬时洪峰平摊到后续多个 step,削峰
Sharded checkpoint(分片)每个 rank 只写自己负责的那一片参数(配合 ZeRO / FSDP 分片)避免单点聚合,写入天然并行、总量不变但分散
去重 / 增量只保存与上一次 checkpoint 的差异,或对相同张量去重减少实际落盘字节数与网络传输量

要点在于:异步 checkpoint 的本质是用一块本地 NVMe 把「慢的远端写」从训练关键路径上摘出去。只要 D2H 拷贝 + 落本地 NVMe 的时间,远小于一个 checkpoint 周期内的训练时间,写盘就被完美「隐藏」在计算之后——这与 GPU kernel 用 stream overlap 隐藏通信是同一种思想:把不可避免的开销藏到关键路径之外。

2.3 页缓存(Page Cache)对顺序写的双刃剑效应

还有一个常被忽视的内核层细节:操作系统的 page cache。Linux 默认会把文件写入先缓存在内存(write-back),稍后批量刷盘;读取时也会预读(read-ahead)后续块。这对 checkpoint 有两面性:

  • 正面:顺序大块写入时,page cache 充当写缓冲,让上层 write() 调用快速返回,内核在后台合并刷盘,吞吐更高
  • 负面:(1) 写大 checkpoint 会挤占宝贵的 host 内存(与 dataloader 抢内存),甚至触发 OOM 或换页;(2) 计时实验若不清缓存,冷/热缓存差异会让测量结果失真——同一文件第二次读可能直接命中内存,根本没碰磁盘。

这正是下面实验要亲手验证的现象。

动手实践:极简代码实操

实验目标对比开启 / 关闭页缓存预读对 checkpoint 写入与读取耗时的影响,并亲手测出同步 torch.save vs 后台线程异步写的耗时差异。本实验纯 CPU + 本地磁盘即可完成,无需 GPU;末尾补充 GPU / GDS(cuFile)路径说明。产出物:一组同步 vs 异步、冷缓存 vs 热缓存的耗时对照数据。

3.1 环境准备

# 推荐 Python 3.11;用 uv 或 venv 隔离
python3 -m venv .venv && source .venv/bin/activate
pip install torch --index-url https://download.pytorch.org/whl/cpu # CPU 路径默认
# 若有 NVIDIA GPU 想试 GDS,需额外安装 nvidia-fs 内核模块与含 libcufile 的 CUDA Toolkit

3.2 代码:同步 vs 异步 checkpoint 写入,冷 vs 热缓存读取

import os
import time
import threading
import torch

CKPT = "/tmp/ckpt_demo.pt"
# 构造一个 ~2GB 的大 tensor 模拟 checkpoint(按内存调整规模)
big = torch.randn(512, 1024, 1024) # 512 * 1M * 4B ≈ 2GB

def timeit(label, fn):
t0 = time.perf_counter()
fn()
dt = time.perf_counter() - t0
print(f"[{label}] {dt:.3f} s")
return dt

# ---------- ① 同步写:阻塞直到落盘 ----------
def sync_save():
torch.save(big, CKPT)
# 强制刷到磁盘,排除 page cache 的「假完成」
fd = os.open(CKPT, os.O_RDWR)
os.fsync(fd)
os.close(fd)

# ---------- ② 异步写:后台线程写盘,主线程立即返回 ----------
def async_save():
# 真实场景应先拷到 pinned host buffer,这里用 clone 模拟「快照已暂存」
snapshot = big.clone()
def _bg():
torch.save(snapshot, CKPT + ".async")
fd = os.open(CKPT + ".async", os.O_RDWR); os.fsync(fd); os.close(fd)
th = threading.Thread(target=_bg, daemon=True)
th.start()
return th # 主线程「训练」可继续,稍后 join

timeit("sync_save (阻塞)", sync_save)

t_main0 = time.perf_counter()
th = async_save()
print(f"[async_save 主线程返回] {time.perf_counter()-t_main0:.3f} s ← 训练可立即继续")
th.join() # 演示用:等后台写完

# ---------- ③ 冷缓存 vs 热缓存读取 ----------
def read_all(advise_dontneed=False):
fd = os.open(CKPT, os.O_RDONLY)
if advise_dontneed:
# 主动告诉内核「这块数据用不上」,逼其放弃缓存 → 模拟冷读
os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
data = b""
while chunk := os.read(fd, 64 * 1024 * 1024):
data += chunk
os.close(fd)

# 冷读(建议丢弃缓存):真实磁盘 I/O
timeit("read 冷缓存 (FADV_DONTNEED)", lambda: read_all(advise_dontneed=True))
# 热读(数据已在 page cache):几乎全内存命中
timeit("read 热缓存 (page cache 命中)", lambda: read_all(advise_dontneed=False))

3.3 运行与观察

python ckpt_io_lab.py

预期你会观测到三组现象:

  • 异步写主线程几乎瞬时返回(拷贝快照后即放手),而同步写要等完整落盘——这就是异步 checkpoint「把写盘藏到训练之后」的直观体现。
  • 冷缓存读明显慢于热缓存读POSIX_FADV_DONTNEED 让内核丢弃了该文件的 page cache,第二次不带该 flag 的读则大概率直接命中内存,耗时可能只有冷读的几分之一甚至更低。这证明了 page cache 对顺序读的加速作用,也警示你做 I/O 基准必须控制缓存状态
  • 若想更彻底地清缓存做冷读基准,可在 root 下执行(会清空全系统页缓存,生产慎用):
sync && echo 3 | sudo tee /proc/sys/vm/drop_caches

GPU / GDS(cuFile)路径补充:在有 NVIDIA GPU 且配齐 nvidia-fs + libcufile 的环境里,可用 cuFile API 把上面的「读文件 → 拷进 GPU」替换为直达显存的 DMA。核心调用形如:

# 伪代码示意 cuFile 直达路径(需 kvikio / cufile 绑定)
import cupy as cp
import kvikio # RAPIDS 的 cuFile Python 封装

f = kvikio.CuFile(CKPT, "r") # 打开支持 GDS 的文件句柄
buf = cp.empty(big.numel(), dtype=cp.float32) # 直接分配 GPU 显存
f.read(buf) # NVMe → GPU 显存 DMA,绕过 CPU bounce buffer
f.close()

对照 CPU 路径的「os.read → host buffer → cudaMemcpy」,cuFile 省掉了 host 中转拷贝,在大块顺序读时带宽与 CPU 占用优势明显。

踩坑预警 (Gotchas)

  • fsync 就计时 = 测了个寂寞torch.save / write() 返回可能只是写进了 page cache,数据尚未落盘。基准里必须 os.fsync(),否则你测的是「写内存」而非「写磁盘」。
  • 冷读不清缓存 = 自欺欺人:第二次读命中 page cache 会快得离谱。要测真实磁盘 I/O,必须用 POSIX_FADV_DONTNEEDdrop_caches,且先冷后热对照。
  • 异步 checkpoint 的隐藏成本是内存:拷到 pinned host buffer / clone 快照会额外占用一份内存。host 内存吃紧时,异步快照可能与 dataloader 抢内存触发 OOM——这正是为什么工业方案优先落本地 NVMe 而非纯内存暂存。
  • GDS 不是装上就生效:cuFile 会在不满足条件(文件系统不支持、PCIe 拓扑不佳、未加载 nvidia-fs)时静默回退到 compatibility mode(仍走 CPU bounce buffer)。务必用 gdscheck -p 校验 GDS 是否真正启用,别以为调了 cuFile 就一定走了直达路径。
  • data += chunk 仅为教学:示例里的字符串累加在大文件上会产生大量内存拷贝,真实基准请用 readinto 到预分配 buffer。

深入思考

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

思考题 1:万卡 checkpoint 风暴的削峰设计

你的万卡集群每 30 分钟做一次全量 checkpoint,共享 Lustre 文件系统在每次落盘瞬间被打满、训练卡顿。结合 2.2 节的「多级异步缓存」与「sharded checkpoint」,设计一套削峰方案——本地 NVMe 容量、后台上传带宽、checkpoint 周期三者之间如何权衡?如果再叠加 staggered(错峰)落盘,会引入什么新的一致性风险?

展开参考答案(含削峰流水时间线图 + 算一遍)

结论:风暴的根因是「万节点在同一瞬间把数 TB 状态同步推向共享文件系统」。削峰的核心动作是用本地 NVMe 把写盘从训练关键路径摘出去(异步),用 sharded 让写入天然并行不聚合,再用「后台上传带宽 ≥ 平均产生速率」这条不等式把瞬时洪峰平摊到整个 checkpoint 周期里——只要本地 NVMe 容量能容下「上传还没追上」的积压量,就不会卡顿。

三者权衡的核心不等式:设单节点 checkpoint 分片大小为 S、checkpoint 周期为 P、后台上传带宽为 B。要让上传在下个周期到来前追平,须满足 B ≥ S / P(平均上传速率不低于状态产生速率);本地 NVMe 容量须能容下「上传尚未追平的积压」,保守取 ≥ 2×S(保留当前 + 上一份,防上传未完成时下一次又来)。

算一遍(数量级估算,规格以实际集群为准):

  1. 一个 175B 模型含 FP32 优化器状态约 2~3 TB;用 FSDP/ZeRO sharded 后均摊到 1024 个 rank,每 rank 分片 ≈ 2~3 GB
  2. 同步全量落共享盘:1024 节点 × 2.5 GB 在同一瞬间推向 Lustre ≈ 2.5 TB 瞬时洪峰,直接打满。
  3. 改异步:每 rank 先写本地 NVMe(顺序写 ~3 GB/s,约 1 秒完成),训练 1 秒后即恢复。
  4. 后台上传:周期 P=30 min=1800 s,分片 S=2.5 GB,则只需 B ≥ 2.5/1800 ≈ 1.4 MB/s 每节点——洪峰被平摊了三个数量级,共享盘平稳吸收。
  5. 本地 NVMe 备 2×2.5 = 5 GB 即够周转,成本可忽略。

staggered(错峰)落盘的新风险:让不同 rank 在不同时刻落盘能进一步压平瞬时带宽,但会破坏「全局一致快照」——各分片对应的训练 step 不再相同,恢复时拼出的状态可能是跨 step 的不一致组合(part 来自 step N、part 来自 step N+1)。对策:要么用 barrier 约束所有分片对应同一 step(牺牲一点错峰幅度),要么记录每分片的 step 号、恢复时回退到所有分片都覆盖的最近公共 step。一致性与削峰幅度本身就是一对需要权衡的量。

思考题 2:GDS 的收益边界

GDS 在「大块顺序读」上收益显著,但并非万能。结合 2.1 节的 P2P DMA 路径,分析:在小文件随机读(如海量小图片样本)场景下,GDS 的优势为何会被削弱甚至消失?这与 NVMe 的 IOPS / 队列深度、PCIe 事务开销有什么关系?此时该从存储格式(如打包成 WebDataset / tar shard)层面如何补救?

展开参考答案(含大块顺序 vs 小文件随机对比图 + 算一遍)

结论:GDS 省掉的是「CPU 中转拷贝」这块固定开销,它在大块顺序读里占比小、相对收益大;而小文件随机读的瓶颈根本不在拷贝,而在「每次 I/O 的固定开销(IOPS 上限 + PCIe 事务 + 软件栈调用)」——文件越小、这块固定开销摊到每字节上越贵,GDS 再怎么直达也救不了被 IOPS 卡死的吞吐。补救之道是从源头消灭「小而多」:把海量小文件打包成大 shard 顺序读。

为什么小文件随机读会被削弱:每一次 I/O 都有一份与数据量无关的固定开销——NVMe 的命令提交/完成、PCIe 事务往返、cuFile/驱动软件栈调用。这块开销摊到「64 MB 大块」上可忽略,摊到「4 KB 小图」上就主导了一切。此时吞吐被 IOPS(每秒 I/O 次数) 而非带宽卡住,而 GDS 优化的是「拷贝」,不改变 IOPS 上限,所以相对收益被吞掉。

算一遍(数量级估算):

  1. 假设一块 NVMe 随机读 IOPS 上限约 100 万 IOPS,单次 4 KB。
  2. 读 100 万张 4 KB 小图:理论 1,000,000 / 1,000,000 = 1 秒 内只能完成约 100 万次 I/O ——有效吞吐 ≈ 4 KB × 1M = 4 GB/s,且已打满 IOPS。
  3. 同一块盘做大块顺序读,轻松跑到 7 GB/s 接近 PCIe Gen4/Gen5 物理上限,且 IOPS 远未饱和。
  4. 把 100 万张 4 KB 小图(约 3.8 GB)打包成 约 60 个 64 MB shard:I/O 次数从百万级降到两位数级,固定开销几乎消失,顺序吞吐拉满,GDS 直达显存的收益此时才真正兑现

补救手段:用 WebDataset / tar shard / TFRecord 把海量小样本打包成 64 MB ~ 1 GB 量级的大文件,dataloader 顺序流式读取、在内存里再切分单样本。这样既把「随机小读」转成「顺序大读」喂饱 IOPS 与带宽,又让 GDS 的 P2P DMA 直达显存重新变得划算——先治『小而多』,再谈直达。

思考题 3:故障恢复的真实代价(RTO/RPO)

很多人以为「有 checkpoint 就万无一失」。结合 2.2 节的多级缓存,从**恢复时间(RTO)与数据丢失窗口(RPO)**角度推演:一个 2TB checkpoint 存在远端对象存储,节点挂掉后从对象存储拉回 2TB 要多久?这段恢复时间里整个集群都在空转吗?多级缓存里的「本地 NVMe 副本」如何把恢复从「拉远端」变成「读本地」,从而把 RTO 砍掉一到两个数量级?

展开参考答案(含恢复路径对比时序图 + 算一遍)

结论:「有 checkpoint」只保证「能恢复」,不保证「恢复得快」。RTO 由「把 2TB 状态重新搬回所有 GPU 显存」的搬运时间决定,而这段时间里整个万卡集群全部空转、烧钱。多级缓存的本地 NVMe 副本把恢复数据源从「慢且共享的远端对象存储」换成「快且本地的 NVMe」,正是把 RTO 从分钟级砍到秒级的关键;checkpoint 周期则决定 RPO——崩溃时最多丢掉「上次 checkpoint 之后那段训练」。

算一遍(数量级估算,带宽以实际链路为准):

  1. 从远端对象存储拉回:万卡恢复要把约 2 TB 状态重新分发到各节点。若受共享网络/对象存储有效聚合带宽约 10 GB/s 限制,则 2 TB / 10 GB/s ≈ 200 秒 ≈ 3~4 分钟。这 3~4 分钟里整个集群全部空转——按万卡每小时数千美元的成本,每次故障恢复都在直接烧钱。
  2. 从本地 NVMe 副本读回:sharded 后每节点只需读回自己那份 ~2 GB 分片,本地 NVMe 顺序读 ~3 GB/s,则 2 GB / 3 GB/s ≈ 0.7 秒,各节点并行 → 整体 秒级RTO 直接砍掉约两个数量级(注意口径:远端按全集群聚合带宽拉 2 TB,本地按各节点并行读各自 2 GB 分片)。
  3. RPO(数据丢失窗口):与 RTO 正交,由 checkpoint 周期决定。周期 30 min → 崩溃时最多丢失最近 30 min 训练;想缩小 RPO 就得更频繁 checkpoint,但这又会加重写路径压力——RPO 与写开销是一对权衡,正好回到思考题 1 的削峰设计。

工程结论:本地 NVMe 副本既是写路径的「削峰缓冲」(思考题 1),又是读路径恢复的「快速数据源」(本题)——一份本地副本同时优化了 RTO 和写洪峰,这正是 2.2 节多级缓存设计的精髓。真正的高可用训练系统,追求的是 RTO(恢复多快)与 RPO(丢多少)的联合最优,而非「存了就行」。

延伸阅读

1. 核心 Paper / 官方文档

  • NVIDIA GPUDirect Storage Design Guide & cuFile API Reference — 理解 GDS 软硬件栈、P2P DMA 与 compatibility mode 回退机制的权威来源。
  • Check-N-Run: A Checkpointing System for Training Deep Learning Recommendation Models(Meta,2020)— 工业级增量 / 去重 checkpoint 的经典实践。
  • Reducing Activation Recomputation in Large Transformer Models(NVIDIA,2022)— 旁证显存与状态保存如何共同构成训练的「容量墙」。

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

  • pytorch/pytorch — 看 torch/distributed/checkpoint/(Distributed Checkpoint / 异步与分片 checkpoint 的一等公民实现)。
  • pytorch/torchft — PyTorch 弹性/容错训练框架,理解 2025–2026 万卡训练「故障自愈 + 秒级恢复」的工程实现。
  • rapidsai/kvikio — cuFile / GDS 的 Python 高层封装,入门 GDS 编程的最短路径。
  • NVIDIA/Megatron-LM — 看其 dist_checkpointing 模块如何在万卡规模做 sharded checkpoint。

3. 优质博客 / 视频

  • NVIDIA 技术博客「GPUDirect Storage: A Direct Path Between Storage and GPU Memory」原始公告与基准数据。
  • PyTorch 官方博客「Asynchronous Checkpointing in PyTorch」系列,讲透 DCP 的设计取舍。

下一篇L1.4 资源调度与编排:从「单机存储 I/O」放大到「集群资源调度」,讲清如何把这些算力、显存、存储资源在万卡集群上高效编排起来。