一句话结论:存储到计算的 I/O 路径走过四个阶段——从 read() 的 Page Cache 三次拷贝,到 Direct I/O + io_uring 绕缓存直传,再到 mmap 的按需缺页加载,最终由 GPUDirect Storage 把 NVMe 与 GPU HBM 之间压成一次 PCIe 直接 DMA。

每一代演进都在回答同一个问题:能不能少一次拷贝、少一次陷入、少一跳物理路径? 核心驱动是让数据在物理上离计算单元越来越近、离 CPU 这个「搬运工」越来越远。

本文的内核路径描述逐条核对自 Linux 内核源码(torvalds/linux master)与 NVMe Base Spec 2.0d;GDS 部分核对自 NVIDIA 官方文档。核对中发现原稿有十余处需要更正,其中四处是事实错误、一段叙述查无实据,已在文中以 ⚠️ 标注。


一、Page Cache:Linux 缓冲 I/O 的物理真相

Page Cache 是内核对磁盘文件的内存级缓存,目的是弥合存储介质与 DRAM 之间的延迟差距——DRAM 命中约 100 ns,HDD 随机寻道约 10 ms,两者差 5 个数量级。

⚠️ 一处量级要收:原稿写「NVMe SSD 随机延迟约 100 µs」。100 µs 更接近 SATA/一般 SSD——NVMe(PCIe)随机读通常在 20—100 µs,企业级可低于 20 µs。方向没错,但把 100 µs 专门标给 NVMe 偏保守。

每个被打开的文件由一个 struct address_space 描述:

1
2
3
4
5
6
struct address_space {
struct inode *host;
struct xarray i_pages; // ← xarray,不是 radix tree
...
const struct address_space_operations *a_ops;
};

⚠️ 一处措辞:i_pages 的类型是 struct xarray。它在 v4.20 从 radix_tree_root 迁移到了 xarray,所以「xarray/radix tree」二选一时,正确说法是 xarray(radix tree 是历史名)。

一次 read(fd, buf, 4096) 的完整链路:

  1. 系统调用陷入 —— SYSCALL_DEFINE3(read, ...) → ksys_read → vfs_read;
  2. VFS 校验 —— vfs_read 校验 FMODE_READ 与 access_ok(buf, count),再走 new_sync_read → f_op->read_iter(绝大多数文件系统注册的是 generic_file_read_iter);
  3. Page Cache 查找 —— generic_file_read_iter 末尾调 filemap_read,后者通过 filemap_get_pages 计算 index = ki_pos >> PAGE_SHIFT,再 filemap_get_read_batch 在 xarray 里取页;
  4. 未命中走慢路径 —— page_cache_sync_ra 触发预读、filemap_create_folio 分配 folio 并加入 page cache,进程阻塞在 folio_wait_locked_killable;NVMe 驱动写提交队列尾门铃(SQyTDBL) 通知设备取命令,设备 DMA 完成后通过 MSI-X 中断回到内核;
  5. 拷贝回用户态 —— 页面就绪后 copy_folio_to_iter 把数据从内核页拷到用户缓冲区,这是缓冲读路径的核心开销。

系统调用返回时再经历一次用户/内核上下文切换。上下文切换的直接成本(寄存器与页表基址切换)加上间接成本(CPU cache、流水线与分支预测器状态丢失)使其成为内核中最昂贵的操作之一。若文件未缓存,叠加块层中断与等待,单次 4 KB 读的总延迟可达数百微秒。

⚠️ 三处函数名要更新(原稿写的是旧版内核的调用链):

原稿 现内核(master)
SYSCALL_DEFINE3(read) → vfs_read 中间还有一层 ksys_read(vfs_read 本身仍在,二者是调用关系)
filemap_read 算 index + filemap_get_folio 查 xarray index 的计算在 filemap_get_pages 里,取页走 filemap_get_read_batch
page_cache_sync_readahead 现名 page_cache_sync_ra

另外「构建 bio 并提交块层 I/O」这一步发生在 a_ops->read_folio 指向的文件系统实现里(iomap/mpage),不在 filemap 本身——原稿把两层混在一起了。

还有个小的:xarray 查找不是严格 O(1),是 O(log₆₄ n),对常见页数近似常数。

顺序读靠自适应预读缓解——检测到顺序模式时扩大 readahead 窗口(get_next_ra_size:小窗口 ×4、中等窗口 ×2),do_page_cache_ra 批量提交。

⚠️ 删掉一个数字:原稿说「可将稳态命中率做到 90% 以上」。这个数字在内核源码与官方文档里都不存在,属自拟。


二、mmap 与缺页异常:Safetensors 秒级加载的物理底座

mmap() 把文件的一个区间映射进进程虚拟地址空间,本质是把「读文件」改写为「访问内存」。映射建立时并不读入数据,而是在首次访问对应虚拟页时由 MMU 触发缺页异常。

缺页路径:

1
handle_pte_fault → do_fault → do_read_fault → __do_fault → vma->vm_ops->fault (= filemap_fault)

⚠️ 箭头方向要改:原稿写「走 filemap_fault → do_read_fault 路径」。实际是 do_read_fault 调用 filemap_fault,不是反过来。行为描述(分配/查找 page cache 页、建立 PTE、返回用户态重执行)本身是对的,只是函数顺序写反了。

内核在 do_fault_around() 里做 fault-around:一次缺页最多建立 16 个相邻 PTE,摊薄后续缺页成本。

✅ 这个「16」是对的(我原本怀疑它)。它来自 fault_around_pages = 65536 >> PAGE_SHIFT,4K 页下正好 16 页。但参数名在新内核里已改为 fault_around_pages(旧版本叫 fault_around_bytes,更早的局部变量才叫 max_pages)——引用时注意版本。

三点限定:① 这是上限,且只映射已在 page cache 且 uptodate 的相邻页;② 不跨 VMA 边界,也不跨 PMD 边界;③ 可通过 debugfs 调 fault_around_bytes,最小 1 页(等于关闭 fault-around)。

mmap 的关键收益是取消了 read() 路径中「page cache → 用户缓冲区」的第二次拷贝——page cache 的物理页直接通过共享映射暴露给进程虚拟地址,用户代码访问的就是同一份物理内存。

⚠️ 但别写成绝对的「零拷贝」:首次从磁盘读入仍走 page cache,页未命中时仍会触发 I/O。说「取消第二次拷贝」准确。

2.1 Safetensors:把这一性质用到极致

Safetensors 的格式只有三件事:

1
8 字节小端长度 N  |  N 字节 JSON 头部(空格补齐)  |  原始张量字节

JSON 头部以张量名为 key,每个值是 {"dtype": ..., "shape": [...], "data_offsets": [BEGIN, END]}。

⚠️ 一处事实错误:原稿写「头部在写入时用空格补齐到 8 字节对齐,保证数据段起始偏移页对齐」。「页对齐」是错的——官方写入器只补齐到 8 字节:

1
2
3
4
// safetensors/src/tensor.rs
// Force alignment to 8 bytes.
let aligned_metadata_len = metadata_buf.len().next_multiple_of(N_LEN); // N_LEN = 8
metadata_buf.resize(aligned_metadata_len, b' ');

即 (8 + header_len) 是 8 的倍数,数据段起点是 8 字节对齐,不是页对齐。(规范只是「容忍」前导/尾随空白,所以别的写入器可以补到页对齐——但官方写入器不这么做。)

加载器通过 torch.UntypedStorage.from_file 把文件 mmap,让 PyTorch 自己持有映射;随后每个张量是这段字节存储上的 torch.asarray(...).view(dtype) 视图——返回的张量直接别名文件的 page cache 页,零字节拷贝。

头部的严格校验(Metadata::validate)保证零拷贝视图不越界、不重叠:

1
2
if s != start || e < s { return Err(InvalidOffset); }     // 偏移必须严格连续递增
if e - s != size { return Err(TensorInvalidInfo); } // 跨度 == shape 乘积 × dtype 位宽 ÷ 8

这就是 safetensors「秒级加载」的物理依据:

  • 避免一次性全量读取——mmap 惰性建立,张量按需触发缺页,模型未用到的部分可完全不读入;
  • 避免反序列化——数据段就是裸字节,不需要像 pickle 那样解析嵌套对象;
  • 避免中间拷贝——page cache 直接映射到进程地址空间;
  • 并行预取——safe_open.prefetch(plan) 用工作线程把计划的字节段 pread 到 pinned 暂存区,再通过 DLPack 上交 CUDA buffer(默认 8 线程,仅 framework="pt",仅 CUDA 目标)。

实测数字:

1
2
3
safetensors: 3.4873 ms
PyTorch: 172.7537 ms → 约 49.5 倍
(500 MB 权重)

⚠️ 两处出处要标清楚:

  1. 这组数字真实存在,但不在 safetensors 仓库的 README 里——它在 HuggingFace diffusers 官方文档的「Load safetensors」一节。而且官方强调这个差异只在「模型已在内存、切换模型」时明显,首次下载/首次从盘加载时不明显。
  2. safetensors 官方另有一份 benchmark(docs/source/speed.mdx,gpt2 权重)给的是 4.015 ms vs 307.46 ms(76.6×,CPU) 和 GPU 上 165 ms vs 354 ms(2.1×)——别和这个 50× 混,它们对应不同数据集与场景。

需要说明的是:mmap 到的是主机 DRAM,权重最终进 GPU 还需一次 H2D 的 cudaMemcpy;且 mmap 首访会带来 page fault 抖动,对延迟敏感场景可用 MAP_POPULATE 预填充或 madvise(MADV_WILLNEED) 预取缓解。

⚠️ 删掉一段查无实据的叙述:原稿写「DeepSeek 多线程加载曾因『在持全局驱动 rwlock 时对 pageable mmap 源触发缺页且缺页在慢存储上阻塞』而引发死锁级超时,修复方式是在工作线程内先 .clone() 把缺页拉出驱动锁临界区」。

这段找不到任何一手来源——检索了 DeepSeek 技术报告、safetensors 仓库的 issue/PR/源码、各推理框架的相关讨论,均无佐证。safetensors 源码里确有一处 .clone(),但那是因缓冲区未对齐而 clone,与「缺页/驱动锁/死锁」无关。这段已删除。


三、Direct I/O 与 io_uring

O_DIRECT 让 I/O 绕过 page cache,块设备驱动 DMA 直接以用户缓冲区为目标,消除了「磁盘 → page cache → 用户缓冲区」中的一跳。代价是对齐要求(用户缓冲区地址、长度、文件偏移都须按块大小对齐),收益是不污染缓存、延迟可预测。

io_uring(Linux 5.1,2019 年由 Jens Axboe 引入)在用户态与内核之间共享两个环形缓冲:

环 写入方 读取方 作用
SQ(Submission Queue) 用户态写 SQE 内核消费 提交 I/O 请求
CQ(Completion Queue) 内核写 CQE 用户态收割 获取 I/O 完成

⚠️ 一处措辞要精确:原稿写「两个环通过 io_uring_setup + mmap 映射进用户地址空间,因此在常规路径上提交与收割均无需陷入内核」。

收割(读 CQ)确实无需系统调用,但提交必须调 io_uring_enter(可以批量提交,把 N 次请求压成 1 次调用)。只有开启 SQPOLL 后才由内核线程轮询 SQ、免掉这个调用。原稿把「SQPOLL 的效果」提前当成了默认行为。

相比 libaio(仅支持 O_DIRECT、每次至少两次系统调用),io_uring 支持缓冲 I/O 与 IORING_OP_READV/WRITEV 等丰富操作码。

⚠️ 一处夸大要改:原稿写「把海量 I/O 的软件开销压缩到接近 SPDK 用户态驱动的水平」。这个说法不成立——有专门做这个对比的论文(CHEOPS 2023《Performance Characterization of Modern Storage Stacks: POSIX I/O, libaio, SPDK, and io_uring》,VU Amsterdam),结论是:

At high loads and scale, io_uring is more than an order of magnitude slower than SPDK

原因也清楚:SPDK 是完全用户态轮询、内核旁路,io_uring 仍然走内核栈。正确说法是「io_uring 大幅降低系统调用与拷贝开销、显著优于 libaio,但并未接近 SPDK——高负载下 SPDK 仍快 10 倍以上」。

在 GPU 训练场景中,Direct I/O + io_uring 的意义在于:模型加载、检查点写出这类大块顺序 I/O 不再重复占用 page cache(page cache 对「一次写、一次读」的 checkpoint 本来就没有复用价值),也把每 4 KB 一次的系统调用成本摊薄为环上的批量提交。

io_uring 的完整机制(阻塞/非阻塞 I/O 的演进、O_DIRECT 的严苛要求、与 Linux AIO 的差异、SQ/CQ/SQE/CQE 数据结构)见 io uring 学习,那篇已经写透,这里不重复。


四、GPUDirect Storage:NVMe 直通 GPU HBM 的物理通路

GDS 的定位非常明确:在 GPU 显存与存储之间建立直接 DMA 数据通路,彻底去掉 CPU 侧 bounce buffer。

传统检查点写路径要经过五跳——GPU HBM 读 → PCIe(GPU→CPU)→ CPU pinned 暂存 → 内核缓冲 → NVMe 驱动队列,其中第 2—4 跳是纯粹开销。NVIDIA 官方把传统路径描述为「uses a bounce buffer in system memory that hangs off of the CPU」。

对 140 GB 检查点,CPU 要搬运约 280 GB 的数据(读出 140 GB + 写入 140 GB),与本应专注于 DataLoader 与 NCCL 的其他进程争抢 DDR 带宽。

GDS 由两个组件构成:

组件 位置 职责
cuFile(libcufile.so) 用户态库 提供 cuFileRead / cuFileWrite 等类 POSIX API,接受 GPU 内存地址作为缓冲区指针
nvidia-fs.ko 内核模块 与 GPU 驱动协调 DMA 传输,数据不经 DDR

⚠️ 一处 API 名要改:原稿写 cuFileRead/cuFileWrite/**cuFileBatchIO**。不存在名为 cuFileBatchIO 的单个函数——批量接口是一族:

1
2
cuFileBatchIOSetUp / cuFileBatchIOSubmit / cuFileBatchIOGetStatus
cuFileBatchIODestroy / cuFileBatchIOCancel

调用链路为:应用 cuFileWrite(handle, gpu_buf, count, offset) → cuFile 路由 → nvidia-fs 拦截 → GPU 驱动 + PCIe DMA 引擎 → NVMe 控制器。由于 POSIX read/write 只接受 CPU 系统内存缓冲区地址,cuFile 的本质改动就是让 I/O API 直接理解 GPU 内存地址。

4.1 GDS 生效的硬件前提

BAR1 地址窗口:GPU 通过 PCIe Base Address Register 暴露设备内存(HBM),使外部设备(NVMe、NIC)可以把 GPU HBM 当作普通 DMA 目标地址。NVIDIA 文档里的对应机制是 cuFileBufRegister 通过 nvidia_p2p_get_pages 把 GPU 显存页 pin 在 BAR 空间。

对齐要求:4 KB 是页/块的基本粒度,但文档同时提到 512 KB 的存储偏移/块尺寸,且 cuFileRead/Write 支持「兼容模式」处理非对齐。

PCIe Switch / 同一根复合体:两个 PCIe 设备必须共享同一个上游根复合体才能完成 P2P 事务。内核 P2PDMA 文档原文:

until they reach a host bridge or root port. If the path includes PCIe switches … always permit P2P in these well-defined cases. However, if the P2P transaction reaches the host bridge then it might have to [be disallowed]

⚠️ 删掉一个无依据的数字:原稿说「经 PCIe Switch 或充当 Switch 的 NIC 的路径,其峰值带宽至少是绕 CPU 路径的两倍」。NVIDIA 官方没有给过「两倍」这个说法,方向对(Switch 路径确实快于 CPU 路径)但数字无出处,且「充当 Switch 的 NIC」表述含混。

NVMe 驱动的 P2PDMA 支持:Linux 内核确有 P2PDMA 子系统(provider/client 模型、pci_p2pdma_add_resource、MEMORY_DEVICE_PCI_P2PDMA)。GDS 侧对应的开关是 CUFILE_USE_PCIP2PDMA(把 IO 偏好设为 PCI P2PDMA 路径而非传统 nvidia-fs 路径)。

4.2 O_DIRECT 与缓存绕过

GDS 传输要求文件以 O_DIRECT 打开,非 O_DIRECT 模式下数据可能经 CPU 系统内存缓冲,与直接传输不兼容。NVIDIA 官方原文:

For direct data transfers between GPU memory and storage, the file must be opened in O_DIRECT mode.

Starting from CUDA Toolkit 12.2 (GDS version 1.7.x) even for files opened in non-O_DIRECT mode, the cuFile library takes the GDS driven O_DIRECT path for transfers between GPU memory and storage for page aligned buffers with aligned sizes and offsets.

这一点决定了 GDS 的适用边界:GDS 完全绕过 page cache——「一次写一次读」的 checkpoint 场景是最佳目标,而对读密集的数据管道则失去了 page cache 的复用收益,需要显式规划。GDS 也支持 CUDA stream 语义的异步 I/O(官方:“Asynchronous offloaded operations are ordered with respect to a CUDA stream”)。

4.3 实测收益

⚠️ 这组数字的出处要说准。原稿给的「8—9 倍加速」以及三个具体案例:

场景 CPU 暂存 GDS 加速
7B BF16 检查点(14 GB),H100 SXM5 + 1×Gen4 NVMe ~22 s ~2.5 s ~8.8×
70B BF16 检查点(140 GB),同上 ~4m 52s ~34 s ~8.6×
DeepSeek V3 671B 权重加载,8×H200 + 4×Gen5 NVMe ~18 min ~2m 10s ~8.3×

这组数字来自第三方 GPU 云厂商 Spheron 的技术博客,逐字一致——不是 NVIDIA 官方 benchmark。 NVIDIA 官方 GDS 博客给的是另一套更保守的数字:cuDF CSV reader 最高 8.8× 吞吐提升、延迟降低 3.8×、DGX-2 上 215 GB/s 聚合带宽——与检查点/权重加载无关。

数字本身在 Spheron 表内自洽(约 8—9×),方向可信;但引用时不能标成 NVIDIA 官方数据,尤其那个「DeepSeek V3 671B」案例不是 NVIDIA 发的。

对于「GPU 应该是数据的第一个和最后一个接触者」的全 GPU 管道(模型加载、checkpoint 恢复、KV cache 换入换出、GPU 原生分析),GDS 是直接的收益放大器。


五、为什么 safetensors + mmap 之后还必须上 GDS

前面讲了四条路径各自的机制。但有一个问题值得单独回答:safetensors 用 mmap 解决了 .bin 时代的 CPU 内存爆仓,为什么在大规模冷启动和超快 checkpoint 场景里,行业还得上 GDS?

答案是——这是两个完全不同维度的瓶颈:

mmap 解决的是「CPU 内存够不够」的容量问题;GDS 解决的是「数据要不要经过 CPU/DDR」的物理路径问题。

mmap 让单机权重加载从分钟级降到秒级,但它的数据流仍然是:

1
NVMe → host DRAM(Page Cache)→ cudaMemcpy H2D → GPU HBM

这条路径在万亿参数、万卡并发的尺度上撞到的不是软件开销,而是 DRAM 带宽墙与 PCIe 单卡带宽墙的物理极限。

5.1 mmap 之后的三个物理瓶颈

瓶颈一:数据最终仍要落入 host DRAM 的 Page Cache。

mmap 映射的是文件,page cache 的物理页驻留在 CPU 侧 DRAM。万亿参数 FP16 权重就是 2 TB;若算上优化器状态(Adam 的 fp32 master weight + momentum + variance,12 字节/参数),1T 参数的完整训练态约 16 TB(12 + 2 字节 fp16 权重 + 2 字节 fp16 梯度)。

即便 FSDP / ZeRO-3 把权重切分到每张卡,每个节点的 host DRAM 仍要承载该节点的完整 shard;万卡集群冷启动时,每个节点的 DRAM 带宽都被 checkpoint 恢复流量打满,而这条带宽本来还要留给 DataLoader、NCCL 通信缓冲、页表交换。

IBM 的研究给了这个问题一个直接的量化描述。注意出处——这不是 IBM 官方博客,而是 IBM Research 的论文《Speeding up Model Loading with fastsafetensors》(arXiv:2505.23072,IEEE CLOUD 2025):

Additionally, the use of bounce buffering in host memory introduces redundant memory footprint and copy operations.

This process results in fine-grained I/O operations and increased OS kernel load during deserialization, preventing full utilization of storage throughput, particularly in multi-GPU inference scenarios.

瓶颈二:H2D 拷贝仍需走 PCIe 单卡链路,且 CPU 深度参与调度与锁页内存分配。

mmap 之后的 cudaMemcpyAsync 依然是一次 PCIe H2D 传输,NVMe → DRAM → GPU 的两跳串行延迟无法隐藏;同时 pageable memory 上的 H2D 需要先拷到 pinned staging buffer,这又是一次 DDR 带宽消耗。

NVIDIA 官方 GDS 博客给了一组 DGX-2 上的对比数字:

On an NVIDIA DGX-2 system, additive bandwidth from system memory, local NVMe drives, and remote storage via NVMe over Fabrics or RAID cards can reach over 215 GB/s into GPU memory, far exceeding the 50 GB/s limit of CPU system memory paths.

⚠️ 两处口径要标清:

  1. 官方数字是「over 215 GB/s」,不是「逼近 200 GB/s」——写成「超过 200 GB/s(官方为 215 GB/s)」更准确。
  2. DGX-2 是 V100 时代的产品。用它论证 H100 时代的瓶颈,方向成立但量级会偏保守——引用时宜注明这是 NVIDIA 官方在 DGX-2 上的实测。

瓶颈三:万卡冷启动的「同时读」风暴。

冷启动不是一台机器在读,是一万台机器同时读。mmap 的惰性映射在并发场景下会演变成缺页异常风暴 + page cache 抖动 + DRAM 带宽争抢。

Runway 在工程博客里把这个现象命名为 thundering herd:

For a fleet of workers rolling out a new model version simultaneously, it’s a thundering herd: dozens of machines independently downloading the same bytes from the same storage backend, saturating bandwidth and inflating cold-start times for everyone.

(他们的解法是 NCCLBack——让 GPU 之间通过 NCCL broadcast 互相传权重,官方称冷启动快了 60 倍。这是另一条思路:不消除跳数,而是消除重复读取。)

⚠️ 一处数字要删:原稿写「safetensors 在网络存储(NFS)上 mmap 的性能劣化可达 30–50 倍」。这个倍数找不到一手来源——现象本身有旁证(NFS 上的 page fault 会被网络往返放大,ModelMap 论文实测 NFS-backed mmap 的平均 page-fault 延迟约 331 µs),但「30–50 倍」这个具体区间无出处。已改为定性描述。

5.2 GDS 通路:把 CPU 从数据面移除

硬件前提(§4.1 已展开):BAR1 地址窗口暴露 HBM、同一 PCIe Switch / 根复合体、NVMe 驱动的 P2PDMA 支持。DGX A100/H100 的拓扑为此专门设计——每张卡经 PCIe Switch,本地 NVMe 与 NIC 同挂 Switch 下。

Checkpoint 写出方向的收益更直接:传统路径五跳(GPU HBM 读 → PCIe → CPU pinned bounce buffer → 内核 buffer → NVMe 驱动队列),其中第 2—4 跳是纯开销。一个 70B BF16 检查点 140 GB,CPU 在 bounce buffer 上要搬运约 280 GB 内存流量——这些流量与 DataLoader、NCCL、OS 调度器争抢同一份 DDR 带宽。GDS 把这五跳压缩为一跳:数据根本不落 DDR。

5.3 量化对比

维度 safetensors + mmap GPUDirect Storage (GDS)
物理通路 NVMe → host DRAM(Page Cache)→ cudaMemcpy → GPU HBM NVMe → PCIe Switch → GPU HBM(单跳 P2P DMA)
数据是否经 CPU/DDR 是(page cache 驻留 + H2D 编排) 否(CPU 仅做控制面)
单卡带宽上限 PCIe H2D,且 NVMe→DRAM 与 H2D 串行 NVMe→GPU 直通,多盘聚合可达 215 GB/s(DGX-2 官方实测)
万卡冷启动表现 缺页风暴 + DRAM 带宽争抢 + page cache 抖动 每节点独立 P2P 直通,DDR 流量归零,NVMe 聚合带宽即系统上限
Checkpoint 写出 GPU→CPU pinned→内核 buffer→NVMe(140 GB 要中转 280 GB) GPU 直写 NVMe(0 GB 中转)
实测加速 相比 .bin 有提升,但停留在 host 路径内 约 8—9 倍(第三方实测,见下)

⚠️ 两组「70B checkpoint」数字不矛盾,但要说清硬件。它们出自同一张表(Spheron 的 GDS 指南,第三方 GPU 云厂商)的不同硬件行:

配置 CPU 暂存 GDS 加速
70B BF16(140 GB),H100 SXM5 + 1× Gen4 NVMe ~4m 52s ~34 s ~8.6×
70B checkpoint,8× H200 节点(4× Gen5 NVMe) ~72 s ~8 s ~9×

基线差 4 倍,主要来自 H200 节点有 4 条 Gen5 NVMe 并行——不是矛盾,是配置差异。两组都是第三方自测,不是 NVIDIA 官方数据。

5.4 两个真实场景的账

万卡冷启动。一万张卡同时恢复意味着一万条 NVMe → GPU 的并发流。mmap 路径下每条流都要在 host DRAM 上落地一份 page cache 副本、再走一次 H2D,集群总 DRAM 流量是模型大小的两倍以上;GDS 路径下 host DDR 流量为零,冷启动时间直接收敛到「NVMe 聚合带宽 ÷ 模型大小」这个物理下限。

超快 Checkpoint。Checkpoint 不是一次性事件,而是训练鲁棒性的基础设施:1000 次 checkpoint × 4—5 分钟的同步写出 = 67—80 小时的 GPU 空转。

⚠️ 这个账有个前提要写明:只有当 checkpoint 是同步的(写盘期间 GPU 完全空转)时「GPU 空转」才成立。现代做法(异步 / 分片 checkpoint、ZeRO-3 + 异步写、checkpoint 与计算 overlap)会大幅削减空转。所以 67—80 小时是「未做异步优化」时的上界,不是普遍现实。

GDS 把 140 GB checkpoint 的写出从 4—5 分钟压到 40 秒以内——这对依赖抢占式实例、30 秒内必须完成恢复的弹性训练策略而言,不是性能优化,而是可行性边界。

5.5 两个值得看的工程实现

fastsafetensors(IBM):把「在 host 内存实例化张量再拷到 GPU」改成「成组张量直接从文件传到 device 内存再实例化」。论文摘要原文:

Our approach first copies groups of on-disk parameters to device memory, where they are directly instantiated as tensor objects. … Experimental results show performance improvements of 4.8x to 7.5x in loading models such as Llama (7, 13, and 70 billion parameters), Falcon (40 billion parameters), and the Bloom (176 billion parameters).

项目在 foundation-model-stack/fastsafetensors,是独立实现而非 fork,已被 vLLM / SGLang 以 --load-format fastsafetensors 集成。有个细节很有意思:它明确不用 mmap——官方文档写着 “A major design difference from the original safetensors file loader is that fastsafetensors does NOT use mmap”。

论文正文还注明 4.8× 是下界(Bloom 最差,因为缺少 NUMA 感知),7.5× 是上界。

PyTorch 的 GDS checkpointing 提案:社区成员在 pytorch/rfcs 仓库提交了 RFC-0033(PR #59,2023-10-20,至今未合并)。三条关键内容逐字属实:

Therefore, bypassing the CPU can reduce system memory pressure and lead to the device directly communicating with the disk.

This feature would be added as an additional transfer type argument for both torch.load and torch.save function calls. The following list of the available transfer type alternatives: - Storage->GPU (GDS) - Storage->CPU - Storage->CPU->GPU …

We recommend using KvikIO … It provides C++ and Python bindings to cuFile.

⚠️ 三处要标清:

  1. RFC-0033 在 pytorch/rfcs 这个独立仓库,不在 pytorch/pytorch 里;
  2. 它是未合并的 PR(草稿),不是已采纳的设计——所以「PyTorch 社区已经明确提出」宜写成「社区成员提交了提案」;
  3. 真正落地的是 PyTorch 2.7 的 torch.cuda.gds(cuFile 的封装),而不是 RFC 里那套 transfer type 参数。

⚠️ 一处语境要限定:原稿写「用户侧观察到的是 CPU 在 cudaMemcpyAsync 上消耗 77% 的时间、GPU 饿死」。数字属实(出处是一篇 HackerNoon 工程博客,profiler 显示 cudaMemcpyAsync 44,084ms / 76.78%),但那个案例是训练数据(HDF5)加载,不是模型权重或 checkpoint 加载——引用时不能说成「模型加载时 77%」。

5.6 一个需要澄清的「checkpoint 恢复」

Cerebrium 有一组常被引用的数字:vLLM 完整冷启动约 50 秒,而从 9 GiB checkpoint 恢复仅需 2.25 秒(S3)或 9 秒(本地 NVMe)。

数字逐字属实(出处是 Cerebrium 官方博客),但这里的「checkpoint 恢复」用的不是 GDS——它是 CUDA 进程内存快照(改造过的 gVisor runtime + checkpoint service + containerd shim),捕获一个已经初始化好的模型进程的 CPU + GPU 内存状态再快速还原。

所以这是两件不同的事:

  • 冷启动 = 从零加载权重 + 编译 + 初始化(约 50 s);
  • 内存快照恢复 = 还原一个预热好的进程镜像(2.25 s / 9 s,差异来自 S3 与本地 NVMe 的读取速度)。

它和 GDS 解决的不是同一个问题,放在一起讲时要说明这一点。


六、四条路径对比与选型

维度 传统缓冲 read() mmap + Page Fault Direct I/O + io_uring GPUDirect Storage
数据拷贝次数(到 GPU) 3 次(NVMe→Cache、Cache→用户、用户→GPU) 2 次(NVMe→Cache、H2D) 2 次(NVMe→用户、用户→GPU) 1 次(NVMe→GPU HBM)
是否经过 CPU / DDR 是 是(缺页处理 + H2D) 是(H2D 拷贝) 否(仅 PCIe)
CPU 参与度 每次系统调用陷入返回 缺页异常 环共享,批量陷入 几乎零(仅控制面)
Page Cache 占用 占用(但共享映射) 绕开 完全绕开
首字节延迟 高 中(缺页异常) 低 低
典型场景 通用小文件、配置、依赖库 CPU 侧权重加载、跨进程共享 数据库、大文件顺序 I/O、训练数据管道 模型加载、checkpoint 读写、KV cache、GPU 原生分析

⚠️ 一处标签笔误:原稿 mmap 那列写「1—2 次(NVMe→Cache,H2D 一次)」——按「到 GPU」的口径应该是 2 次(NVMe→page cache 的 DMA,加上 cache→GPU 的 H2D);「1 次」只有在只算「到 CPU 可见」时成立。上表已统一为「到 GPU」口径。Direct I/O 那行的括注原稿写的是「(NVMe→用户、用户→GPU)」,那是读方向,但该行讲的是写——已修正。

选型判断可以归纳成一句话:

数据如果要在 CPU 上被计算,就走 page cache 体系(read 或 mmap);如果只路过 CPU,就用 Direct I/O + io_uring 把它请出去;如果根本不该进 CPU,就让 GDS 从物理上绕开它。

具体而言:

  • 模型权重 CPU 加载 → safetensors + mmap;
  • GPU 直载或 checkpoint 频繁读写 → GDS(通过 kvikio 这类 RAPIDS 高层 Python 绑定最省力);
  • 训练数据管道 → 视是否复用数据,在 io_uring Direct I/O 与 page cache 之间权衡。

七、结语:I/O 路径演进的方向感

从 read() 到 GDS,每一代演进都在回答同一个问题:能不能少一次拷贝、少一次陷入、少一跳物理路径?

  • Page Cache 用 DRAM 换磁盘延迟;
  • mmap 用虚拟内存换一次拷贝;
  • io_uring 用共享环换系统调用;
  • GDS 用 PCIe P2P DMA 换掉整个 CPU 跳板。

软件栈在一路「下沉」,直到数据从盘片直接落到计算单元的 HBM。对做 AI 基础设施的人来说,理解这条物理主线比记住任何一个 API 更有价值:它决定了模型加载是秒级还是分钟级,也决定了训练管道是 I/O bound 还是真正算力 bound。


相关阅读

核实说明:本文内核路径描述逐条核对自 torvalds/linux master(include/linux/fs.h、fs/read_write.c、mm/filemap.c、mm/readahead.c、mm/memory.c、include/linux/uio.h)与 NVMe Base Spec 2.0d;io_uring 部分核对自 kernel.dk/io_uring.pdf(Jens Axboe)、man7.org 的 io_uring(7) 与 CHEOPS 2023 存储栈对比论文(DOI 10.1145/3578353.3589545);GDS 部分核对自 NVIDIA GDS 官方 Overview / API Reference / O_DIRECT / Troubleshooting 四份指南与 NVIDIA GDS 博客;safetensors 部分核对自 huggingface/safetensors 源码(src/tensor.rs、bindings/python/src/lib.rs)与 HuggingFace diffusers 文档;DGX A100 / H100 规格核对自 NVIDIA 官方白皮书与产品页。

原稿更正汇总:io_uring 开销「接近 SPDK」属夸大(高负载下 SPDK 快 10× 以上)、GDS 的 8—9× 与 DeepSeek V3 案例来自第三方博客而非 NVIDIA 官方、H100 SXM5 是 PCIe Gen5 而非 Gen4、safetensors 的头部只补齐到 8 字节而非页对齐、「稳态命中率 90%」无依据、DeepSeek 多线程死锁一段查无实据(已删)、cuFileBatchIO 不是单个函数、SQyTDBL 是 NVMe 规范正式写法(原稿正确,保留)、「提交与收割均无需陷入内核」需限定(提交需 io_uring_enter)、read() 链路缺 ksys_read 一层、filemap_read → filemap_get_pages(非直查 filemap_get_folio)、page_cache_sync_ra(非 page_cache_sync_readahead)、缺页箭头方向(do_read_fault → filemap_fault)、i_pages 是 xarray(非 radix tree)、DGX A100 的「247—262 GB/s」无出处(官方为 600 GB/s 双向)、NVMe 随机延迟「100 µs」偏高、拷贝次数表的方向标签笔误、「Switch 路径至少两倍」无依据。