mmap 洗牌读取,在网络文件系统上为何比顺序 read 慢 50 倍?
训练数据管线里,mmap=True 几乎是默认姿势。Hugging Face datasets 的 Arrow 后端、safetensors、pyarrow 的 memory_map,都把「打开文件」变成了「把文件映射进进程地址空间」。零拷贝、按需加载、免解析,每一项听起来都在为训练加速。直到你在 Lustre 上打开 DataLoader 的 shuffle,训练步慢到像是集群出了故障。
1. 先拆直觉:文件连续 ≠ 读取连续,同一份 1 GiB 洗牌为何要 94 秒?
ml-engineering 的作者 Stas Bekman 在 2026 年 9 月把这笔账算清楚了。他用 1 GiB 文本(拆成 32,768 行、每行 32 KiB,落在三个 Arrow shard 里),在每次计时前用 posix_fadvise(POSIX_FADV_DONTNEED) 清空页缓存,然后分别在本地 NVMe 和共享 Lustre 挂载上跑三种读法:按 1 MiB 块顺序 read() 整个文件、for row in ds 顺序遍历映射、以及按 np.random.permutation 打乱的索引洗牌访问。这是公开发布的脚本(storage/mmap-io-bench.py),你可以在自己的挂载点上复跑;它也是单次 wall-clock 测量、没有误差棒,所以下面的数字当数量级锚点用,别当可复现 benchmark。StasBench
看这张表,先把第一组印象钉在墙上:

图注:5 列 4 行的数据表,三组读法在两种存储上的耗时与放慢比例。Lustre 上顺序
read()1.87 秒、mmap 顺序遍历 3.11 秒(+66%)、mmap 洗牌 94.2 秒(+4,937%);本地 NVMe 对应 0.14 / 0.53 / 0.78 秒。放慢比例只在各文件系统内部、相对该盘自己的顺序read()计算(t / t_sequential − 1)。StasBench
先指出这个数字的一种错误读法:4,937% 不是统计分布,不是「大多数洗牌读都慢成这样」。它是 94.2 ÷ 1.87 − 1,即 Lustre 这一行实验里洗牌 mmap 相对顺序 read 的单个比值。X 推文标题里的「457% 到 4,937%」只是同一行数据在本地盘和 Lustre 两个单元格上的两个取值。
更有意思的是对照本身。同样用 mmap,顺序遍历只比 read() 慢 66%,洗牌访问却慢到 4,937%?系统调用没变、文件没变、映射没变,变的只有一件事:先触碰哪一页。文件在磁盘上的布局是连续的,但训练索引触碰页面的次序完全不连续。真正决定存储负载的,不是文件的布局连续性,而是访问轨迹(access trace)。mmap 把「样本 i」翻译成「文件中第 i 行的几个页」,shuffle 之后相邻两次访问的页在文件里相隔十万八千里。
这一个文件上发生的事情,放大到整个库就是一个工程经验法则:ml-engineering 的存储一章用一句话总结了反复踩坑后的结论,本地存储优于云端存储(local beats cloud)。这个法则背后的机制,到第四章会变成方案。ml-engineering
为什么「触碰顺序」能主导一切?把镜头拉远到 1968 年。Denning 在那年的 CACM 上给「工作集」(working set)下了定义:进程在最近 τ 秒内引用过的页的集合,记作 W(t, τ)。顺序遍历时,工作集是一条随时间滑动的窄窗口,页缓存装得下近期的部分;洗牌访问时,任何一个 τ 窗口里触碰过的页都散落在整个文件的页集合上,工作集约等于全部 1 GiB。页缓存装不下,缺页(page fault)率就支配了运行时间,一次缺页就是一次从后端取页的动作。Denning 的原文里明确写过,在 random、FIFO、LRU、working-set 四种页淘汰策略中,random 带来最高的页流量。训练洗牌正是往页集合上做 random。Denning 1968
概念锚图放这儿:

图注:时间轴示意图。进程时间线上有一段标 τ 的区间,区间内被引用过的页构成 W(t, τ),虚线右端是当前时刻 t。工作集是「最近 τ 秒内触碰过的页集合」,不是 batch size,也不是采样窗口。Denning 1968
所以 94.2 秒不是 Lustre 上的一桩怪事,它是可预测的虚拟内存行为:随机工作集撑爆页缓存后,缺页率接管一切。先于任何参数和调优,样本触碰页面的次序已经决定了网络存储的命运。同一个文件,换一种读法就差一个数量级。这个数量级是怎么从一次缺页里长出来的?下一章进内核看。
2. 一次冷缺页的旅程:内核怎么把大块顺序读碎成按页远程请求?
先把三个术语说清楚。缺页(page fault)是访问的页不在页缓存里、内核必须去后端把它取回来的动作;页缓存(page cache)是近期读过的页驻留内存、命中它就不用碰后端的地方;预读(readahead)是内核按「接下来还会按顺序读」的假设、提前把后面的页批量拉进缓存的机制。这三件事合起来,就是「顺序读为什么快」的全部秘密。
内核的 on-demand readahead 是一个只有三个数的状态机,存在 struct file_ra_state 里:ra->start 是当前窗口第一页,ra->size 是窗口总页数,ra->async_size 是异步段的长度。进程读到窗口末段的某个标记页(PG_readahead,就标在 async 段第一页上)时,内核在后台为下一个窗口发起预取,进程不必停下等 I/O。顺序读时窗口按 4 倍、2 倍的阶梯涨到 ra->ra_pages 上限。readahead.c
窗口长什么样,看这张自绘示意图:

图注:readahead 窗口的几何结构。24 页的窗口分成 16 页同步段(蓝)和 8 页异步段(橙),
PG_readahead标记落在异步段首页;进程读到这一页时,内核为下一个窗口发起异步预取。size=24、async_size=8 是说明性数字,不是内核默认值,实际窗口上限由后端设备的read_ahead_kb决定。readahead.c
这套优雅的机制建立在一条假设上:访问是顺序往前的。洗牌访问把这条假设直接踩碎。page_cache_sync_ra() 里有一句判定:当 index − prev_index > 1,也就是这次的页码和上次不相邻,内核认为这是一次独立的随机读,把窗口塌缩回一个小探测值,而且不再为它武装异步预取。更极端的情况走彻底短路:文件被 POSIX_FADV_RANDOM 标记成 FMODE_RANDOM 时,page_cache_sync_ra() 里 do_forced_ra 为真,请求直接进 force_page_cache_ra() 的按页强制路径;mmap 区域被 MADV_RANDOM 标记成 VM_RAND_READ 时,缺页路径直接跳过预读,同样是逐页取页——两条路径都不参与窗口增长。readahead.c
窗口塌缩的因果,下面这张图一次讲完:

图注:沿时间轴的四个状态。顺序读把窗口从 4 页长到 8 页;跳到第 100 页时,index − prev_index = 89 页 > 1,窗口塌缩到 2 页;再跳到第 220 页,只剩 1 页且无异步预取。p0 到 p250 是示意页号,不是实测 trace。readahead.c
到这里,机制链条已经连上了一半:随机索引关掉顺序预读,大块读被碎成单页请求。对 mmap 路径尤其残酷,因为每次缺页的 req_count 天生就是 1 页,而 read() 路径至少可以让应用一次要 1 MiB,由内核按请求大小发 I/O。于是网络文件系统的客户端看到的是 4 KiB 量级的远程请求流。有一点要说清楚:Lustre 客户端可能仍会把相邻几页合并进同一次 RPC(由客户端的 max_pages_per_rpc 决定),但合并上限和顺序读的预读窗口完全不是一个量级。这些远程小请求就是 94.2 秒的微观来源。
远程小请求为什么这么贵?网络文件系统每一次远程读都有固定的往返延迟,这个延迟没法靠「读得多」来摊薄,只能靠「同时读得多」。NIO Bench 在 Ceph 集群上测到了同样现象的具体数字:read 的 P95 延迟只有 0.03 毫秒,均值却被少数 cache miss 拉到 0.41 毫秒,差 13 倍。绝大多数读命中了页缓存、极快,少数 miss 慢得多,把均值整个拖高。洗牌 mmap 恰好让每一次触碰都变成 miss。注意这是 Ceph 上的现象,不直接外推到 Lustre 或 NFS,但机制同源。NIO Bench

图注:按系统调用类型分组、每组两根柱(mean 与 P95)。read 组的均值柱明显高于 P95 柱,约 13 倍;write、lseek、fstat、close、openat 的两根柱基本齐平。这是 Ceph 单一后端上的测量。NIO Bench
最后一个把 13 倍放大成 50 倍的旋钮是队列深度(queue depth):存储设备控制器上能同时排队等待的 I/O 请求数。设备有一定数量的 workers(多 chip、多 server),当请求数远低于 workers 时,每个请求完成之后设备就空转,等下一个请求自己找上门。洗牌 mmap 的请求一次只来一个,有效队列深度撑不开,每个请求的固定延迟都要被完整地付一次,没法用并发摊薄。ml-engineering
所以这章的结论是:慢不是 mmap 的玄学,是 readahead 状态机的必然。随机索引等于当场关掉内核的顺序预读,大块读被碎成单页远程请求,队列深度撑不开。但这条机制链条目前还只是推演,要锁定「元凶是组合而非 mmap」,还需要一步对照实验。
3. 元凶是 mmap 还是组合?两条独立证据线锁定同一个答案
第一章那张表其实就是一次对照实验,这次我们把变量读明白。三组读法只在两个维度上变化:映射动作(mmap 还是 read())和访问次序(顺序还是洗牌)。顺序 read() 是基线;顺序 mmap 只动了映射,访问次序没动;洗牌 mmap 两个维度都动了。Lustre 上顺序 mmap 只比基线慢 66%,洗牌 mmap 却慢 4,937%。如果元凶是 mmap 本身,顺序 mmap 应该一样惨。真正爆炸的是 mmap 与随机访问、冷缓存的组合,而组合爆炸的前提是远程文件系统:NVMe 上同样的组合只有 457%。所以正确表述是:元凶是 mmap × 随机访问 × 冷缓存 × 远程 FS 的组合,不是 mmap 这个系统调用本身。StasBench
一组对照实验还不足以定案,同一波修复浪潮提供了第二条独立证据线。PyTorch 的 Distributed Checkpoint(DCP)有一个 consolidation 流程,要把多个 shard 合并成一个 checkpoint 文件。同一个脚本,本地磁盘跑约 7 秒,Meta 内部的远程挂载 Manifuse 上要约 2 小时。PR #175762 的改动只有三处:把 mmap 换成显式 f.seek + f.read,把文件句柄从内层循环抬到外层缓存,再把 metadata 解析提前做一次。PR#175762

图注:PR 里的 timing-results 表。6 列(Optimization applied? / Read / Write / Threads / Time / Py-spy Results),按 3b 与 70b 两组模型排列,每组含本地磁盘与 Manifuse 远程挂载两条记录、每条记录有优化前后两行;Manifuse 远程 3b 未优化行是 116 / 115 / 117 分钟三次重复,Threads 恒为 16。这是 PR 作者在 Meta 内部挂载上的工程测量,不是公开 benchmark。PR#175762
结果:Manifuse 上 3b 模型从 116 分钟量级降到 34 秒,70b 模型从 408 分钟降到 6.9 分钟。PR 作者把根因写得非常直白:问题出在 mmap 上,每次读取一个 tensor shard 都触发 page fault,页面必须走网络读回,于是在网络上堆积了大量读取。但要如实承认实验的粗糙:这组数字不是单变量因果。PR 同时改了三个地方,mmap 换成显式 f.seek + f.read、文件句柄从内层循环抬到外层缓存、metadata 解析提前做一次,三个变量一起动,分不清哪一项贡献了多少,所以它不能拿来证明「只替换 mmap 就能复现这个加速」。这组数字真正值得被当作证据的是另一面:当远程链路上碎片化的按页 I/O 被成体系地整改之后,合并产物做了 byte-level diff、字节语义完全不变,时间却掉了近两个数量级。它表明远程碎片 I/O 路径就是值得复查的疑点,与第一条证据线的机制判断方向一致。代价边界要守住:Manifuse 是 Meta 内部远程文件系统,这组数字不能外推成「任何远程 FS 都能拿到 2 小时到 35 秒」。
两条证据线,一条是训练数据(1 GiB Arrow 洗牌,干净的对照实验),一条是 checkpoint 合并(几十到几百 GB 的 DCP shard,成体系的工程整改),负载完全不同,指向的疑点是同一处:远程链路上的碎片化按页 I/O。学术侧还有一句现成的背书:MLSys 2025 的 YOUMU 论文在对比现有数据管线方案时,把 mmap 列为三种缺陷方案之一,原话是它在 OS 内核里引发 heavy cache thrashing,导致 I/O 吞吐糟糕。YOUMU
换一种负载、换一个工程,同一处失败形态再次浮现。组合由第一条证据线的干净对照锁定,修复方向就应该从机制里推出来,而不是从参数表里挑一个 readahead 值调大。第四章就是机制到方案的这一步。
4. 方案从机制里长出来:随机性放内存、远程只看到顺序流,但有个边界
机制的因果链是:随机访问 → 远程小请求 → 队列深度撑不开 → 延迟单次付全款。解法顺理成章:把随机性从远程链路上挪走。ml-engineering 给训练数据洗牌列了三个选项,全都围绕这一个原则。ml-engineering
第一,全局随机性是刚需时,先把数据 stage 到节点本地 NVMe,再在本地映射访问。第二,不 stage 时,预先按 epoch 洗牌成大的 shard,训练时按 shard 顺序流式读取,随机性由「shard 的生成顺序」承担。第三,流式读取 + 有限大小内存缓冲,在缓冲内做洗牌,让每块样本的随机范围等于缓冲容量。三个方案的共同结构是同一句话:随机性被约束在内存或本地 NVMe,远程存储始终看到可预取的大块顺序流。
有个修补方案值得点破它的边界。在洗牌循环之前 cat shard > /dev/null 把整个文件预热进页缓存,洗牌路径可以从 98.24 秒降到 1.86 秒预热加 1.03 秒循环。但这只在 shard 装得下页缓存时成立,它是「装得下的小数据集的修补方案」,不是「stage 到本地 NVMe」的替代品。同理,MAP_POPULATE 预取整个映射、MADV_SEQUENTIAL 加大预读窗口,都属于这一类:它们都不改变「随机索引仍然触发页级缺页」这个事实。
为什么 MADV_SEQUENTIAL 救不了洗牌?回到第二章的源码事实:它做的事只是把 ra->ra_pages 加倍(POSIX_FADV_SEQUENTIAL 同理),FMODE_RANDOM / VM_RAND_READ 的短路分支仍然生效。随机访问走的是强制按页路径,窗口根本不会被双倍预读碰到。更实际的一点:pyarrow 的 memory_map 连这两个 hint 都不传,Arrow 用户压根拿不到这个优化。
「把随机性放进内存缓冲」落成系统是什么样?YOUMU 这个 MLSys 2025 的系统给出了参考实现,它不设大缓存、只保留固定大小 buffer,把 I/O 粒度统一到 Parquet page 级别(page-level I/O),每个新 page 进入 buffer 后立刻再洗牌一次(aggressive buffer shuffle)。索引翻译走 control plane,实际取页走 data plane,两者解耦。YOUMU

图注:YOUMU 系统总览,四层结构 App / API / Youmu / Storage。Youmu 层内部左右分两组:索引侧的 Index Translator 与 Global Page Index Store,数据侧的 Page Decoder 与 Page Fetcher;实心箭头是控制路径(Control Path),空心箭头是数据路径(Data Path)。YOUMU
到这里方案已经成型,但还有一个边界必须画出来:mmap 不是银弹,它连「该不该用」都取决于你站在哪一侧的硬件上。Ingestion Tax(2026 年 8 月的 arXiv 论文)把同一段 mmap 代码在两类 GPU 内存拓扑上跑了一遍。在 unified memory(Apple M5 Max)上,mapped 路径的中位数带宽 516 GB/s,达到 resident(常驻内存)级的带宽,是货真价实的零拷贝加速;在 discrete PCIe(RTX 5070 Ti)上,同一段代码反而比 stock loader 慢 39 倍,因为每次缺页都要跨 PCIe 总线做一次 host 往返。论文给出一条按拓扑定案的部署规则:只在 GPU 已经能直接读的地方采用 file pages(adopt file pages only where the GPU can already read them)。Ingestion Tax
排名反转的视觉证据就是这张图:

图注:同一份代码的三种字节策略在两种内存架构下的对比,纵轴为 GB/s(对数刻度)。unified(M5 Max)三根柱约 60 / 550 / 550 GB/s,mapped(中灰)达到 resident(黑)水平;discrete(5070 Ti)三根柱约 60 / 55 / 850 GB/s,mapped 跌到 bus 水平。两组排名完全反转。柱高只能作量级参考,精确数字以正文为准。Ingestion Tax
同一类 page-fault-driven 机制也在 GPU 统一内存(UVM)侧复现:AutoUVM 的测量里,10 个 LLM 在 UVM 超额订阅下执行时间与 page fault 数强相关,按张量级做 policy-driven 预取能拿到平均 3.1 倍加速。它说明「按页触发把可合并请求碎成序列化小请求」这条失败模式,从网络文件系统一路延伸到 GPU 内存管理。AutoUVM
这章的方法论收口:方案不是调大预读窗口,而是把随机访问挪到随机访问便宜的地方,也就是内存、本地 NVMe、以及 GPU 能直读的拓扑,让远程和 PCIe 这类高延迟链路只承接大块顺序流。方案在你自己的环境里到底成不成立,不能靠感觉,这就是最后一章评测单位的事。
5. 怎么评测才不被文件格式骗了?把单位换成端到端访问轨迹
为什么说文件格式会骗人?Arrow、Parquet 这种「大文件」在顺序读时快得惊人,于是被市场当成「加载快的格式」。但第一章已经证明,同样一份 Arrow 数据,洗牌访问比顺序读慢 50 倍。格式不决定性能,访问轨迹才决定。所以评测单位要从「文件格式」换成「端到端访问轨迹」,至少覆盖四档:冷缓存、顺序遍历、训练洗牌、多 worker 放大。
怎么测访问轨迹?NIO Bench 的方法学是现成模板,核心是双层 trace。单层 instrumentation 有个致命盲区:PyTorch DataLoader 开了 num_workers>0 之后,主进程里的 Python IOHooks 看不到 worker 子进程的 read 系统调用。他们用 Ceph 集群上的 ViT 训练做例子:Python 层只看到约 13 个文件操作,strace 却揭示 ViT 整个训练累计约 1200 万个 syscall(约 600 万 read 加 400 万 lseek,来自 DataLoader workers 每 epoch 读 11.8 万张 JPEG)。所以要用两层:第一层 monkey-patch Python 的 open / torch.load / torch.save 等,记语义和阶段标签;第二层用 strace -f 包裹整个进程,覆盖全部文件相关 syscall,再用同步标记对齐两层时间戳。这个双层结构本身就是你可以复用到自家集群的评测骨架。NIO Bench
指标里有两根支柱。第一根是读尾延迟:第二章那 13 倍(read 均值 0.41 ms vs P95 0.03 ms)说明 cache miss 是主瓶颈,评测只报均值会被大量命中掩盖。第二根是字节访问的极端集中:Lorenz 曲线显示底部 70% 的文件几乎不贡献字节,顶部 10% 的文件占了 90% 以上的字节。这对工程的含义是 hot-file tiering:把少数 hot 文件(checkpoint、大模型导出、Arrow shard)优先放到最快的存储层;其余文件可以放进成本更低的层,但放之前仍要按启动、恢复和尾延迟要求逐一验证,不能因为字节占比低就断言它们放在哪都无所谓。NIO Bench

图注:Lorenz 曲线,横轴按字节量升序累计文件百分比,纵轴为字节累计占比。底部 70% 的文件几乎贴着 0%,曲线随后陡升,顶部 10% 的文件贡献 90% 以上的字节。这是跨模型聚合的结果,不是某个特定模型的文件大小分布。NIO Bench
指标落地之后,修复面长什么样?PyTorch PR #194881 是一个现成标本:它保留 mmap 和 safetensors 字节流不动,在 DCP 的 FsspecReader 上加了一个调度层,把碎片化的 range 请求用批量 fs.cat_ranges 合并成大块,再用双缓冲让网络 I/O 和 CPU 反序列化在时间上重叠。8 ranks 加载 44.88 GiB 的 GCS checkpoint,read_data 从 27.62 秒降到 10.03 秒(2.75 倍),Model Parallel 布局从 23.69 秒降到 12.02 秒(1.97 倍)。注意这个 PR 截至 2026 年 9 月仍是 Open 状态,数字是草稿阶段的自报结果。PR#194881

图注:调度层结构示意。ReadItem Plan 进入 Batcher(max_batch_size=64),Buffer A/B 双缓冲乒乓切换,单线程 I/O Executor 调
fs.cat_ranges(paths, starts, ends)发给 GCS / fsspec 后端;Buffer B 侧由 CPU Deserialize Pool 反序列化输出 Loaded Tensors。图中没有性能数值,它只表达请求合并与网络/CPU 重叠的结构。
那评测清单到底盯哪几项?机制决定了指标:major faults(缺页数)必须进清单,AutoUVM 的测量证明执行时间与缺页数跨模型强相关;然后是请求尺寸和队列深度,它们决定了远程链路能否摊薄延迟;最后是 GPU 等待时间,这是训练视角的最终账。四档轨迹配四个指标,就是你在自家集群验证第一到第四章全部论证的钥匙。工具的最后一环是第一章那个脚本:mmap-io-bench.py 的三组读法正是四档访问轨迹里的三档,跑一遍你就有自己挂载点上的对照数字。StasBench
所以回到整篇文章的起点:真正摧毁网络文件系统吞吐的,不是 mmap 这个系统调用,而是它把训练中的随机样本访问隐式翻译成按页触发的远程缺页,让存储无法用大块顺序读摊薄网络延迟。方案只有一句话:把随机性放对地方,内存、本地 NVMe、GPU 能直读的拓扑都行,远程存储只让它看到可预取的大块顺序流。评测也只有一句话:先量化访问轨迹,再谈其它。
参考文献
本专题引用的核心资料如下,建议深入学习时阅读原文:
- Stas Bekman: mmap could be 457% to 4,937% slower than sequential contiguous read — X 推文与 ml-engineering 数据表
- ml-engineering: Storage — File Systems and IO — GitHub 仓库
- PyTorch PR #175762: Optimize DCP consolidation I/O for remote mounts — GitHub Pull Request
- PyTorch PR #194881: [DCP] Optimize FsspecReader with Batched cat_ranges and Async Prefetching — GitHub Pull Request
- Linux kernel mm/readahead.c + posix_fadvise / madvise man pages — 内核源码 与 man-pages
- Denning: The Working Set Model for Program Behavior (CACM, 1968) — 原文 PDF
- YOUMU: Efficient Columnar Data Pipeline for LLM Training (MLSys 2025) — MLSys Proceedings
- The Ingestion Tax: Adopting File-Backed Weights in Tensor Frameworks — arXiv:2608.12114
- AutoUVM: Automated Prefetching Framework for LLMs under UVM Oversubscription — arXiv:2609.06172
- Benchmarking Storage Systems for Machine Learning Workloads Using NIO Bench — arXiv:2609.05418