数据瓶颈型训练提速

训练数据加载方面优化总结(

好久没写记录了,也不知道这些花费了我一两天功夫的知识,未来会不会都被ai取代了。
一句话背景:这是个小模型(显存只占 7~9GB/80GB),瓶颈 100% 在数据加载,不是算力。所以全部优化都围绕”怎么把数据更快喂进 GPU”。


第一章 · 认清Pod 资源(区分 8 卡 pod 与单卡 pod)

我们申请到的资源池有不同类型,有的是单卡的,有的是多卡的。卡之间也有差异。我下面列出了需要关注的几种资源

1.1 资源清单对照

资源 8 卡 pod(实测) 单卡 pod(实测) 说明
GPU 8× H100 80GB HBM3(SXM) 1× H100 80GB HBM3 显存 HBM ~3.35TB/s;HBM:GPU 计算核心和它自己显存之间每秒能搬多少字节
GPU 互联 全互联 NVLink(topo NV18)+ NVSwitch,任意两卡 ≈ 450GB/s 双向 无(单卡) DDP 的 all-reduce 走 NVLink 不走 PCIe → 单机内多卡梯度通信极便宜
NUMA 2 个:GPU0–3→NUMA0,GPU4–7→NUMA1 4+4 分卡恰好压在 NUMA 边界,亲和最优
CPU Intel Xeon 8480+,224 逻辑核 224 逻辑核(但和别人共享) DataLoader worker 在这跑
RAM 2015 GB(~2TB),buff/cache ~1.3TB 2015 GB(但和别人共享) page cache 容得下单任务整个 working set(~600–800GB)
本地盘 / xfs ~11TB(pod 临时盘,销毁即清空) / overlay ~11TB (但和别人共享) 唯一本地盘;要本地化需 rsync 过去
/dev/shm ~2 TB(tmpfs = RAM 装扮的盘) 2.0 TB (但和别人共享) 可放常驻 RAM 的特征副本
数据盘 /code /data /share_data 全 NFS(datastore.eng-zoom.local,680T,~满) 同左 数据集源头,走网络
网卡 单口 10GbE,无 bond 单口 10GbE,无 bond (但和别人共享) 每节点共享,是硬天花板
NFS 挂载 NFSv3, rsize=1MB, nconnect=4 NFSv3, rsize=1MB, nconnect=16 NFSv3=协议版本(无状态、元数据每次都要确认);rsize=1MB=每次读请求的块大小(越大往返越少);nconnect=开几条并行 TCP 喂网卡(越多越易打满 10GbE,但打不破 10GbE 硬上限)。详见 A8

1.2 存储速度阶梯(越往下越慢)

1
2
3
4
5
6
7
8
9
10
flowchart TB
GPU["🎮 GPU 显存 HBM3 · 80GB<br/>~3 TB/s · 模型/激活/计算在此"]:::gpu
RAM["🧠 RAM / DRAM · 2015GB<br/>~100 GB/s · 工作内存(含 page cache)"]:::ram
LOCAL["💽 本地盘 xfs · ~11TB<br/>~GB/s · 无网络(pod 临时盘)"]:::disk
NFS["🌐 NFS 网络盘 · 680TB<br/>≤1.25 GB/s · 每次访问=一次网络往返(最慢)"]:::net
GPU --> RAM --> LOCAL --> NFS
classDef gpu fill:#90EE90,stroke:#333,stroke-width:2px,color:#000
classDef ram fill:#87CEEB,stroke:#333,stroke-width:2px,color:#000
classDef disk fill:#FFD700,stroke:#333,stroke-width:2px,color:#000
classDef net fill:#FFB6C1,stroke:#DC143C,stroke-width:2px,color:#000

注意 RAM 内部分三块(cache 不是独立硬件,是 RAM 的一种用途):

1
2
3
4
5
6
7
8
9
10
11
flowchart TB
subgraph RAM["🧠 RAM 2015GB"]
HEAP["进程堆(匿名内存)<br/>free 里算 used<br/>np.load 出来的数组在此<br/>preload f32 → 1.37TB/rank"]:::heap
PC["page cache(文件缓存)<br/>free 里算 buff/cache · 可回收<br/>整节点共享,缓存读过的文件"]:::pc
SHM["tmpfs · /dev/shm 2TB<br/>长得像盘、其实在 RAM"]:::shm
FREE["free · 纯空闲"]:::free
end
classDef heap fill:#87CEEB,stroke:#333,stroke-width:2px,color:#000
classDef pc fill:#E6E6FA,stroke:#333,stroke-width:2px,color:#000
classDef shm fill:#FFD700,stroke:#333,stroke-width:2px,color:#000
classDef free fill:#F5F5F5,stroke:#333,stroke-width:2px,color:#000

1.3 NFS 读带宽实测(关键 —— 两类 pod 聚合一致)

场景 8 卡 pod 单卡 pod 说明
网卡物理上限 10GbE ≈ 1.25 GB/s 整机所有进程共享,硬天花板
冷读·单流(direct I/O 裸读) ~520 MB/s ~114 MB/s(受训练争用压低,干净时更高) 仅决定单线程 preload,不决定训练吞吐
冷读·并发(多 worker) ~1.1 GB/s ~1293 MB/s 两边都打满 10GbE → 聚合一致
暖读·page cache 命中 ~2.8 GB/s ~1.4 GB/s 走 RAM,与网卡无关;page cache 整节点共享
每 GPU 冷启动带宽 1.2 GB/s ÷ 8 ≈ ~150 MB/s 1.2 GB/s ÷ 1 ≈ ~1.2 GB/s 多卡按 GPU 摊更挤
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
flowchart LR
subgraph POD8["8 卡 pod"]
G0["GPU0..7 的<br/>128 个 worker"] --> E8["🌐 单口 eth0<br/>10GbE ≈ 1.25GB/s"]
end
subgraph POD1["单卡 pod"]
G1["GPU0 的<br/>worker"] --> E1["🌐 单口 eth0<br/>10GbE ≈ 1.25GB/s"]
end
E8 --> NFS["🌐 NFS datastore.eng-zoom.local"]
E1 --> NFS
E8 -. "÷8 GPU → 每卡~150MB/s" .-> NOTE8["按 GPU 更挤"]
E1 -. "÷1 GPU → 整条独享~1.2GB/s" .-> NOTE1["最宽松"]
classDef n fill:#FFB6C1,stroke:#DC143C,color:#000
classDef y fill:#87CEEB,stroke:#333,color:#000
class E8,E1,NFS n
class G0,G1 y

两类 pod 聚合带宽一样(都卡在单口 10GbE);多卡没有 8 条网卡,只是把同一条管道按 GPU 摊薄。

三条最重要的结论:

  1. NFS 带宽是「每节点」属性(网卡+服务器决定,与 GPU 数无关)。多卡 pod 没有 8 条网卡,读盘聚合不会更快,按每 GPU 算还更挤(1/N)。但是单卡pod 需要和别人共同争抢这部分NFS带宽。
  2. 别用”单流冷读”下结论。训练用的是几十个并行 worker,两边都会并发打满同一条 10GbE。
  3. GPU 互联很便宜:8 卡全 NVLink 450GB/s,模型小(梯度才几十 MB),实测 barrier_sync ~23ms。所以多卡瓶颈是 barrier 木桶 + 数据/CPU 争抢,不是通信带宽。


第二章 · 代码里真正耗时的环节

把”读一个 batch”拆开看,从磁盘到 GPU 一路上的开销(按是否 preload 分两条路径):

2.1 磁盘 / 网络访问(NFS)

  • 冷读(page cache 未命中)open 要 2 趟网络往返(LOOKUP + GETATTR 确认属性)+ read 传字节。秒级尖刺。
  • 暖读(page cache 命中):内容不走网络了,open() 仍要 1 趟 GETATTR 确认文件是否最新。2.8 万文件 + shuffle 下属性缓存基本都过期,所以这趟 GETATTR 几乎每次都付 —— 这就是 nfs_data_io.md 强调的”把每个文件的 stat/open/getattr 当昂贵网络往返”。

读图:每一趟网络往返都画成两条线——实线 是发出去的请求,虚线 是等回来的响应;一来一回 = 一趟往返(一次网络延迟)。编号 ①②③ 数的就是往返次数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
sequenceDiagram
participant W as ⚙️ Worker
participant K as 🧠 内核/page cache
participant N as 🌐 NFS 服务器

rect rgb(255,230,230)
Note over W,N: 冷读(page cache 未命中)= open 付 2 趟往返 + read 1 趟 = 共 3 趟网络往返
W->>K: open(path)
K->>N: ①→ LOOKUP 请求(路径名 → 文件句柄)
N-->>K: ①← 句柄
K->>N: ②→ GETATTR 请求(取/确认属性)
N-->>K: ②← 属性
K-->>W: open 返回 fd
W->>K: read()
K->>N: ③→ READ 请求(要内容)
N-->>K: ③← 文件字节(顺手存入 page cache)
K-->>W: 字节
end

rect rgb(230,240,255)
Note over W,N: 暖读(page cache 命中)= open 仍付 1 趟 GETATTR;内容不再走网络
W->>K: open(path)
K->>N: ①→ GETATTR 请求(确认文件没变)
N-->>K: ①← 未变,缓存有效
K-->>W: open 返回 fd
W->>K: read()
K-->>W: 命中 page cache,直接从 RAM 给(零网络)
end

冷读 = 3 趟往返(秒级尖刺);暖读 = 1 趟往返(只剩 open 的 GETATTR,内容白嫖 RAM)。两种情况之后,下游处理完全相同:np.load 解析 → 整份 astype(f32) 碰所有页 → pickle/IPC → PCIe H2D(见 §2.2)。

对照:开了 --feature_preload 后,文件早在 RAM dict 里,getitem 根本不碰文件系统 —— 零网络往返、零 GETATTR、零缺页:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sequenceDiagram
participant W as ⚙️ Worker 进程<br/>(getitem + collate)
participant R as 🧠 RAM dict<br/>(进程堆 · preload 已载入)
participant S as 📦 共享内存 /dev/shm<br/>+ 管道 (IPC)
participant P as 🧵 主进程<br/>(收批 + H2D)
participant G as 🎮 GPU

Note over W,R: preload 后:文件内容已在进程堆,getitem 只做内存取值 + 切片
W->>R: info['whisper'] 取值 + 切窗口(纯内存,~100ns 级)
R-->>W: numpy 数组
Note over W,R: ❌ 无 open ❌ 无 LOOKUP/GETATTR ❌ 无 READ ❌ 无缺页 → 网络彻底消失
Note over W: collate_fn 就在 worker 里把 256 个样本 stack 成 batch<br/>(nw>0 时被 N 个 worker 并行分担,不回主进程串行)
W->>S: batch 的 tensor 数据落共享内存;仅"句柄"pickle 过管道<br/>(仅 num_workers>0 才有此 IPC,实测 ~278ms)
S->>P: 主进程按句柄重建 batch tensor(可选 pin_memory)
P->>G: 🔌 PCIe H2D(.to(cuda))—— 只有主进程连 GPU
Note over W,P: num_workers=0 时 W 与 P 塌缩为同一进程<br/>→ 无共享内存/无管道,getitem+collate+H2D 一条龙(省掉这段 IPC)

三张图并排看:冷读 3 趟、暖读 1 趟、preload 0 趟网络往返 —— preload 用”启动时一次性全读进 RAM”换掉了热路径里每个文件每一步的网络往返。代价是 RAM 占用(§2.5)和单流冷读慢(见上方带宽表 + §A8 的 nconnect);多卡场景见 §2.4 / nfs_data_io.md

2.2 内存 / CPU 上的数据处理(这是暖缓存下的真瓶颈)

  • np.load 解析 → 在进程堆里生成 numpy 数组。
  • **整份 astype(np.float32)**:把**整份**特征转 f32 → **碰到所有页**。这是"只把np.load` 改 mmap 不够”的原因:整份 astype 仍会 fault 全部页,mmap 白改。
  • 读放大:不 preload 的懒加载里,w2v 文件 28MB,每个样本读整份只为切 15 帧 → batch128 ≈ 3.5GB/iter 的无效读。mmap 只读 ~15/1525 ≈ **1%**。
  • motion .pkl 反序列化:每样本 unpickle,受 GIL 限制。.pkl 不能 mmap。
  • collate / padding / reshape / emotion 处理:batch128 在主进程拼 batch 的 CPU 开销。

暖缓存稳态下 data_time ~0.16s 的真正构成 = getitem 的 CPU + collate(motion unpickle、astype、padding),与数据在本地还是 NFS 无关,local/mmap 都改不动。这是下一步的杠杆。

2.3 DataLoader / worker(num_workers 的知识)

worker 是独立进程、各有自己的 GIL,它能并行两类东西,并和 GPU 计算重叠:

  1. I/O 延迟重叠:GETATTR/READ 是”等网络响应”的延迟操作,N 个 worker 让 N 个往返并发在飞 → 不 preload(冷/暖读)都吃这个。
  2. getitem 的 CPU 并行(关键、最易被忽略).pkl unpickle(受 GIL 限制)、astype、reshape、padding、collate 都是 CPU 活。nw=0 时它们全部串行挤在主进程、卡在关键路径上,GPU 干等;nw>0 时分到 N 个进程真并行(绕开 GIL)、并 prefetch 下一批 → 与当前 batch 的 GPU 计算重叠。§2.2 说的瓶颈(unpickle/astype/padding ~0.16s)正属此类,所以这恰恰是 worker 该上场的场景 —— 这才是 §3.3 推荐的 NFS+mmap 不 preload 配 48 worker 成立的真正原因(不只是 I/O,更是 CPU/GIL 并行)。

那 preload 这一档 nw=0 会不会反而更快? 曾经这样推理过:preload 把第 2 类重活预先做掉(motion 已 unpickle 成数组 :483:610、特征已 astype(f32) :463 存进 RAM dict),getitem 只剩”dict 取值 + 切窗口 + collate”这点轻 CPU,于是”省下的 CPU < 搬 batch 过进程边界的 IPC”,nw=0 该赢。⚠️ 但这个先验被实测推翻了(见 §2.6 的 §2bis 表):preload+nw8 的 data=198ms 反而略快于 preload+nw0 的 215ms。** 原因有二:① getitem 就算变轻,残余 CPU 仍非零(切窗口、collate 拼 256 样本、astype/padding/gather),nw=0 时它们全串行卡在关键路径、GPU 干等;② DataLoader 的 prefetch 让下一批的 IPC 在后台和当前 GPU 计算重叠,那个”~278ms IPC”根本不在关键路径上,不会让 nw>0 变慢。即便 preload,仍需一定并行 —— nw>0 ≥ nw0。**

  • 一句话判据(修正后):worker 值不值 = “getitem 并行省的 CPU + prefetch 重叠” vs “多出来的 per-worker 固定开销/内存”。实测在本工作负载上,preload 与非 preload 两档 nw>0 都不亏(preload 档 nw8 略优于 nw0,mmap 档更靠多 worker)。别再默认”preload → nw=0”;两档都实测 nw=0 / 4 / 8 / 16 取最优。 nw 的唯一硬约束是内存(§3.1bis:大堆 + 高 nw 时 per-worker 固定开销可能顶穿),不是速度。
  • PCIe H2D:最后把 batch 拷进显存,所有路径都有。

2.4 多卡(DDP)额外的时间

accelerate launch --multi_gpu 是数据并行,多出来的成本都落在”隐藏区”:

单卡 N 卡 DDP
每步样本 b b×N(全局 batch)
梯度 直接用 每步 all-reduce 跨卡平均
同步点 wait_for_everyone() barrier,等最慢 rank
worker num_workers num_workers × N(抢 CPU/NFS)
  • 本节点 NVLink 极快,实测 barrier_sync ~23ms(单卡恒 0)→ 通信不是瓶颈
  • 多卡的价值是放大有效 batch / 总吞吐不是让单个 iter 更快。数据受限场景下多卡 wall/iter 可能和单卡差不多。
  • 致命点:多卡 + --feature_preload每个 rank 各载全量(代码不按 rank 分片)→ N× RAM。8 卡 × 1.37TB = ~11TB,2TB RAM 必爆。

2.5 三档优化(一图总览)

1
2
3
4
5
6
7
8
9
flowchart LR
A["🌐 不 preload<br/>每步走网络盘<br/>冷:传内容 · 暖:仍要 GETATTR"]:::slow
B["🧠 preload(网络没了)<br/>nw>0 与 nw=0 同一档<br/>实测 nw8=198ms 略快于 nw0=215ms<br/>IPC 被 prefetch 重叠,不在关键路径"]:::fast
FLOOR["⛰️ 剩余地板 = 数据体量<br/>gather+astype+H2D ~35MB/batch<br/>≈0.14s,调 nw 改不动(§2.6)"]:::mid
A -->|preload 干掉网络往返| B
B -. "受限于(真瓶颈)" .-> FLOOR
classDef slow fill:#FFB6C1,stroke:#DC143C,stroke-width:2px,color:#000
classDef mid fill:#FFD700,stroke:#333,stroke-width:2px,color:#000
classDef fast fill:#90EE90,stroke:#333,stroke-width:2px,color:#000
模式 网络往返 内存访问 IPC 每步瓶颈
不 preload·冷 open+read 2 趟 堆(临时) 网络(秒级尖刺)
不 preload·暖 open 1 趟 GETATTR page cache 读 网络确认延迟 + getitem CPU
preload·workers>0 RAM dict 切片 有(prefetch 重叠,不在关键路径) 数据体量(§2.6),nw8 略快
preload·workers=0 RAM dict 切片 数据体量 + 串行 getitem(略慢于 nw8)

2.6 batch_index_mode:把”逐样本组装”塌缩成一次 gather(对抗 getitem 的 CPU 开销)

前面 §2.2/§2.3 说清了暖缓存/preload 下的真瓶颈是 getitem 的 CPU:每步要跑 256 次 Python __getitem__(每次十几个小 op + 建对象)+ collate--batch_index_mode 就是专门冲这块来的第四种模式。

机制

  • 建立在 preload 之上(硬依赖 --feature_preload)。init 时把 data_dict 里各 clip 的变长数组沿时间轴拼成 3 个连续大 tensorbt_M(motion, fp32)、bt_W(whisper/w2v, fp16)、bt_E(emotion, fp32) + bt_start/bt_len 偏移表。
  • 每步 sample_batch():按和 __getitem__ 相同的随机规则构造散列下标 idx[256, window],然后 每模态一次 gatherM[idx]/W[idx]/E[idx])+ 向量化建 padding_mask/ref/no_prev,直接产出 8 元组 batch。
  • 训练循环把 next(data_loader) 换成 bt_dataset.sample_batch(bs),**train_step 不动**。

为什么能对抗 CPU 开销(三点):

  1. 塌缩 Python 解释器开销:5000+ 个细碎逐样本 op → 几个向量化大 op(构造 idx + 3 次 gather + 向量化 mask)。
  2. 单进程、不 fork:本身就是 num_workers=0 路径 → 无 fork/COW(§3.1bis)、无 per-worker 固定开销
  3. 无 IPC:在进程内直接拼 batch → 省掉 §2.3 那条”共享内存 + 管道 + 重建”的 ~278ms。

一图看懂”砍掉了什么、又为什么还有搬运成本”: 把每步组装 batch 的成本拆成两层——层A(Python 组装) 被 bt_mode 塌缩到 ≈0,但 层B(数据体量搬运) 两条路都躲不掉,而它恰恰才是主成本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
flowchart TB
HEAD["每步组装一个 batch<br/>b128 · window15 · w2v 9216维"]:::head

subgraph A["🧩 层A · Python 组装开销"]
direction TB
A1["256× __getitem__<br/>dict取值 / 切片 / 建对象"]
A2["collate 拼 batch"]
A3["IPC 共享内存+管道<br/>nw>0,~278ms"]
A1 --> A2 --> A3
end

subgraph B["📦 层B · 数据体量搬运"]
direction TB
B1["从 ~18GB 的 bt_W 里<br/>散列 gather:128×15×9216 ≈ 35MB(fp16)"]
B2["PCIe H2D 拷进显存"]
B1 --> B2
end

HEAD --> A --> B
A -. "bt_mode:5000+ 细碎 op → 几个向量化大 op" .-> AZ["层A ≈ 0 ✅ 被塌缩"]:::gone
B --> BC["层B ≈ 0.14s ❌ 躲不掉 = 真正主成本"]:::bad

NAIVE["❌ 旧估:把它当'90MB 顺序 memcpy'<br/>@~100GB/s → ≈1ms"]:::naive
REAL["✅ 实际:随机散列 gather(cache 不友好,碰散落各处的页)<br/>+ 跨 PCIe H2D @~25GB/s → 实测 ~0.14s"]:::real
NAIVE -. 纠正 .-> REAL
BC -.-> NAIVE

classDef head fill:#87CEEB,stroke:#333,stroke-width:2px,color:#000
classDef gone fill:#90EE90,stroke:#333,stroke-width:2px,color:#000
classDef bad fill:#FFB6C1,stroke:#DC143C,stroke-width:2px,color:#000
classDef naive fill:#F5F5F5,stroke:#999,stroke-width:1px,color:#000
classDef real fill:#FFD700,stroke:#333,stroke-width:2px,color:#000

为什么”之前说搬运不高”是错的:§2 原估把这步当成”90MB 连续内存顺序拷贝”,按 RAM 带宽 100GB/s 算就是 ~1ms。但 bt_mode 实际做的是 W[idx] 散列 gather —— idx 指向 256 个随机 clip × 随机起点,在 ~18GB 的大 tensor 里东一块西一块地取(随机访问、cache 命中差、要碰散落各处的物理页),再把这 35MB 跨 **PCIe(25GB/s,比 RAM 慢 4 倍)** H2D 进显存。两者叠加,实测就是 ~0.14s 而非 ~1msbt_mode 砍的是层A(Python),治不了层B(体量) —— 这就是它只快 ~25–30% 的根因。

⚠️ 实测修正(2026-06-24,来自 big_tensor_design.md §2bis)——“0.3s→几ms”是高估:

配置:feature=w2v(9216 维)、whisper fp16、-b128 -n10 -p5(window=15)、单卡、与训练共用 GPU/CPU。四档每步 prof total / data(ms):

配置 prof total data 相对
基线 NFS + nw48 212–296 182–254
preload + nw0 244 215 ~持平
preload + nw8 226 198 ~持平
bt_mode(本设计) ~178 ~140 快 ~25–30%

结论:bt_mode 确实最快,但只快 ~25–30%,远不是预期的 5×。 因为真瓶颈不是 Python 解释器开销,而是 w2v 9216 维的数据体量 —— 每个 batch 的 whisper 是 128×15×9216 ≈ 35MB(fp16),要从 18GB 的 bt_W 里散列 gather + H2D。bt_mode 砍掉了 128× Python getitem(0.2→0.14s,30%),但躲不开搬这 35MB 的体量成本。这也解释了为什么 §2.3 的 getitem/workers/preload/bt 全卡在 0.14–0.2s 一个量级:搬的字节一样多。

  • fp16 是前提:whisper 全程 fp16 已把这块体量砍半;若 fp32,bt_W 翻倍、gather+H2D 会到 ~0.28s → bt_mode 反而更慢
  • 真要大幅提速的唯一杠杆 = 减小 w2v 特征体量(离线把 9216 维投影/聚合到更小维度),但那会改模型输入,属另一个工程。

适用范围: 要求 --feature_preload y + --predumped_motion + rational_obj=disabled,且非 cond_identity/avg_condition/ood_motion_filter/load_gt_images;仅单卡 —— 多卡会自动禁用并回退 DataLoader(自定义采样器绕过了 DistributedSampler,各 rank 不分片会训到相同 batch)。正确性已按”gather 逐位对齐 + 采样分布等价”校验通过(big_tensor_design.md §7)。

一句话定位:bt_mode 用”单进程一次 gather”取代 DataLoader,是 preload 路线在 CPU 侧的进一步榨取——在小/浅特征(Python 占比高)上收益更大;在 w2v 9216 维上被数据体量淹没,只剩 ~25–30%。它对抗的是 CPU,治不了体量。注意它和”preload + nw>0”是同一档的两种实现(§2bis 实测两者都在 0.14–0.2s 一个量级),该实测对比后取优,而不是先验认定谁快。


第三章 · 结论:什么规模用 preload,什么规模该本地化

### 3.1 num_workers↔RAM · COW 机制

🔬 “纸面装得下” ≠ “跑得安全”(4 个原因)

  1. 1.6T 不是峰值 —— COW 会把它顶上去:48 worker fork 后本该共享,但 Python 的 refcount/GC 一访问就写对象头 → 按页 COW 复制 → 实测 free 掉到 37G、近 OOM(机制见下方图)。
  2. 就算 nw=0,余量也太薄:1.6T 顶着 2.015T 只剩 ~400G,还要喂 preload 的冗余 page cache、CUDA context/pinned 缓冲、模型+优化器、Python 开销,且这是宿主机共享内存,邻居 pod 也在抢。
  3. 这 1.6T 是 fp32 的可避免浪费:盘上 fp16 仅 685G,涨一倍只为提前转类型,而模型在 GPU 上本就 fp16→fp32。
  4. 收益还小:暖缓存下 NFS+mmap 已 ~4.2min/1000it,瓶颈是 getitem 的 CPU(preload 音频特征修不了 motion unpickle)。大 RAM 风险换 ≈0 提速。

num_workers ↔ RAM:worker 是”COW 的乘数”

每 worker 的 RAM 成本 nw 调大的后果
不 preload 小(读文件 → 拼小 batch → 送走即丢) RAM 几乎不涨 → 48 worker 没问题
preload(大堆) fork 后本应共享,refcount 弄脏 → 按 nw 倍 COW 复制 RAM ~随 nw 线性涨 → COW 灾难

⚠️ 上表是旧的 COW 模型,已被下方”🔬 实测修正”推翻(worker 并不按 nw 倍复制数据缓冲)。保留于此仅为对照。修正后的正确说法:nw 吃的不是”COW 复制的数据”,而是 per-worker 固定开销(预取缓冲 + 页表 + 解释器,GB×N 不是 T×N)。所以 preload 时也不该默认 nw=0 —— 速度上 nw>0 通常更优(§2bis / §2.3);只有当”preload 大堆已贴近内存顶 + 高 nw”时,才需为省内存调低 nw。不 preload 没大堆 → nw 随便开。(worker 的”值不值”另见 §2.3。)

COW 逐页机制(图解)

① 一次 fork + 一次”只读访问”发生了什么 —— 对比 mmap:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sequenceDiagram
participant P as 🧩 主进程堆 1.37T(preload)
participant K as 🐧 内核 · 页表/COW
participant W as ⚙️ Worker(fork 的子进程)

Note over P,W: fork 瞬间:不拷内存,只复制页表 + 把页标记"只读+COW" → 父子共享同一批物理页
K-->>W: 继承页表(共享 1.37T,RAM 不涨)✅

Note over W: worker 循环 dataset[idx]:info = data_dict[key] · arr = info['whisper'] · 切片
W->>W: 逻辑上"只读",但 Py_INCREF 写了对象头的 ob_refcnt
W->>K: 写一个只读页 → page fault 🚨
K->>K: 分配新 4KB 页 + memcpy → 该页变 worker 私有(RAM +4KB)
Note over K: 该页若还混着 numpy 缓冲字节 → 数据被一起复制("溅射")
Note over W: GC 周期性扫描容器对象、写 GC 头 → 又一轮弄脏

Note over P,W: × 48 worker × 每 epoch(persistent_workers=False 还重 fork)→ RAM 滚向 1.37T×N → 近 OOM

② 因果链 + 三条规避路径:

1
2
3
4
5
6
7
8
9
10
flowchart TD
F["fork 48 worker<br/>页表共享 · 只读+COW<br/>(此刻 RAM 不涨)"] --> T{"worker 访问<br/>data_dict / 数组包装对象 / motion?"}
T -->|"Py_INCREF/DECREF 写 refcount<br/>+ GC 扫描写头标记"| D["弄脏只读页<br/>→ COW 复制整张 4KB"]
D --> S["小对象与 numpy 缓冲<br/>交错在同页 → 连数据也被复制(溅射)"]
S --> B["× 48 worker:私有副本累加<br/>RAM 1.6T → 逼近 2T → 近 OOM/换页<br/>首批 794s · 稳态 ~0.8 it/s"]:::bad
F -.规避.-> E1["✅ num_workers=0:不 fork → 根本无 COW<br/>(堆只此一份;getitem 已变轻)"]:::ok
F -.规避.-> E2["✅ gc.freeze()+gc.disable():<br/>冻结对象,GC 不再写头标记"]:::ok
F -.规避.-> E3["✅ 少对象+连续大块 / mmap:<br/>没大堆可 fork;mmap 走内核 page cache=真共享"]:::ok
classDef bad fill:#FFB6C1,stroke:#DC143C,stroke-width:2px,color:#000
classDef ok fill:#90EE90,stroke:#333,stroke-width:2px,color:#000

关键直觉fork 给的是”共享只读页”的好意;Python 每次访问对象都去写它的 refcount(GC 还周期性写头标记),把好意一页页拆成私有副本;数据又和小对象交错同页,于是复制量从”对象开销”滚向”整个 1.37T”。worker 越多滚得越狠。 真要并行,别丢给它”一个全是小 Python 对象的大堆”去 fork —— 给它 mmap 的 page cache(内核级真共享,不受 refcount 影响)

🔬 实测修正:worker 多出的私有内存到底多大(上面①②图的”溅射/滚向整个 1.37T”是高估)

日期:2026-06-24。用 os.fork() + /proc/self/smaps_rollupPrivate_Dirty(= 子进程被迫私有复制的页)直接量了一下,结论:fork 出来的 worker 既不复制数据缓冲,对象头 churn 也只有几十 MB —— 根本不是”×N 份 1.37T”。

实测场景 每 worker 私有多出(Private_Dirty) 含义
父进程持 2.15GB fp16 大数组,子进程 sum() 读完全部数据页 3 MB(其余 2.11GB 全 Shared 读 numpy 数据缓冲不触发复制 → 缓冲区跨 worker 共享
父进程建 5万 clipdata_dict(每条挂 9216-fp16 数组 + str key + 小字段),子进程随机访问 3 个 epoch 19 MB refcount/GC 弄脏的只是对象头那些页,几万对象也就几十 MB

为什么原来的”溅射”模型不成立:numpy 的数据缓冲是一整块独立分配(单独 malloc/mmap 的大区),而 PyObject 头(ob_refcnt,~50B)在 Python 自己的对象 arena 里——两者不在同一页。所以 Py_INCREF 弄脏的是”对象头页”,不会把旁边的数据缓冲一起复制(①图里”数据和小对象交错同页→溅射”对 numpy 数组基本不发生)。对象头总量 = 对象个数级别 ≈ 几十 MB/worker,不随数据体积走

那 48-worker 跌到 free 37G 的真凶是什么(既然不是 COW 复制数据):

  1. 基数 1.6T 本就贴着 2T 顶(只剩 ~400G),任何额外开销都容易顶穿;
  2. 每 worker 的固定开销(随 nw 线性涨,但量级是 GB×N 不是 T×N):独立 Python 解释器 + import 的 torch/numpy(每个几百 MB)、DataLoader 预取缓冲nw × prefetch_factor 个 batch,fp32 下 B=16 一个 batch 的 w2v ≈ 737MB)、页表(1.6T 映射若无 THP,每进程 ~3.2GB,×48 ≈ 150G);
  3. 宿主机共享,邻居 pod 也在抢这 2T(见 A6)。

修正后的结论(实践不变、机制变了)

  • preload + 多 worker 仍然危险,但根因是**”基数贴顶 + 每 worker 固定开销累加”**,不是”每 worker 把整个堆 COW 复制一份”。
  • 因此 fp16 把基数从 1.6T 砍到 0.8T 后(见下方音频特征 fp16 化),离 2T 有 ~1.2T 余量 → 那几十 GB 的 per-worker 固定开销塞得下,w2v2 + nw=8 实际可用、不再爆。这也解释了为什么当年 whisper(preload 基数仅 ~0.22T,2560 维)nw=8 一直没事——基数小、离顶远,同样的 per-worker 开销根本不痛。
  • 规避手段不变且依然最稳:preload 时 num_workers=0(无 fork、无预取膨胀),或 batch_index_mode(3 个连续大张量、单进程、不 fork)。

复现方法(任意机器可跑):os.fork() 后在子进程里读 /proc/self/smaps_rollupPrivate_Dirty / Shared_Clean;父进程先持有大 numpy 数组或大 dict,子进程分别”读全部数据”与”随机访问条目”,对比两者的 Private_Dirty

3.2 本地化适用范围(其实很窄 —— mmap 才是真杠杆)

实测三种方式稳态对比(full data, n10p5, b128, 48worker):

配置 每 1000iter 相对
本地盘 + mmap ~3.7 min 最快基准
NFS + mmap ~4.2 min(15k 长稳态) +14%
旧 NFS 无 mmap ~5.0 min +35%
全量 preload + 48w COW 卡死(首批 794s)

核心认知(颠覆了直觉):

  1. 暖缓存下”数据在本地还是 NFS”几乎无差 —— 都从 page cache(RAM)读。两边 data_time 几乎相同(0.16 vs 0.18s)。本地盘只在 真·冷启动首 epoch / 工作集超 2TB 内存被换出 时才赢。
  2. 真正的杠杆是 mmap,不是本地盘np.load(mmap_mode='r') + 把整份 astype 推迟到截 15 帧之后 → 每样本只读 ~1%,既避开 preload 的 RAM/COW,又避开懒加载的读放大。从 5.0min → 4.2min 的提升主要是 mmap 的功劳。
  3. mmap 与 NFS 的关系(修正”mmap 不能用于 NFS”的旧说法):mmap 的收益绑定”数据在 page cache”,不是”在本地盘”。冷 NFS + mmap = 慢(每页一次网络往返);暖 NFS + mmap = 快(缺页命中 RAM)。本地盘的真正价值 = 躲掉冷启动的按页网络读惩罚 + 不怕被换出。

3.3 最终推荐配置

NFS + --mmap_audio y(不 preload,48 worker) 是当前最优且最省事的方案:稳定、可接受(~4.2min/1000iter,15k iter 零报错/零 NaN),省掉 ~1TB 本地拷贝与维护。本地盘容量够、且要榨那 ~14%,再迁本地;不够时 NFS+mmap 是稳妥退路。

决策速查:

场景 推荐
单卡 + 小特征(≤几百 GB) + RAM 够 preload(nw 实测取优,通常 nw≈8;或 batch_index_mode
单卡 + 全量 w2v NFS + mmap(preload 会 COW)
多卡全量 NFS + mmap,绝不 preload(N× RAM 爆)
想消除冷启动首遍 / 防换出 本地盘 + mmap,或 /dev/shm 暂存
多卡要”只存一份 RAM” 不 preload 吃整节点共享 page cache(A 方案,零改动)
1
2
3
4
5
6
7
8
9
10
11
12
13
flowchart TD
START{"特征全量体积 × rank 数<br/>能塞进 2TB RAM?"}
START -->|否(多卡 / 全量 w2v 1.37TB×N)| MMAP
START -->|是(单卡 + 小特征/浅层)| PRELOAD
PRELOAD["✅ preload<br/>+ num_workers=0<br/>干掉网络+IPC,只剩计算"]:::fast
MMAP{"在意冷启动首 epoch<br/>/ 怕被换出?"}
MMAP -->|否| NFSMMAP["✅ NFS + --mmap_audio y<br/>不 preload,48 worker<br/>最优且最省事(~4.2min/1000it)"]:::fast
MMAP -->|是 + 本地盘容量够| LOCAL["本地盘 + mmap<br/>再榨 ~14%(~3.7min)<br/>代价 ~1TB 拷贝"]:::mid
PRELOADBAD["❌ 全量 preload<br/>1.6TB f32 + 48w fork<br/>→ COW/近OOM/卡死(794s)"]:::slow
START -.绝不.-> PRELOADBAD
classDef fast fill:#90EE90,stroke:#333,stroke-width:2px,color:#000
classDef mid fill:#FFD700,stroke:#333,stroke-width:2px,color:#000
classDef slow fill:#FFB6C1,stroke:#DC143C,stroke-width:2px,color:#000

3.4 下一步杠杆(与存储层无关)

稳态 data_time ~0.16s 的真正构成是 getitem 的 CPU:motion .pkl 的 unpickle(GIL)+ collate。本地/NFS/mmap 都改不动这一块。**真正的下一步 = motion .pkl.npy/.npz**(去掉每样本 unpickle、可 mmap)。

3.5 起训自检 × 训练需求 → 读取方案对照(怎么选)

因为资源全是 host 级共享、无 cgroup 兜底(§A6/§A9),起训前先扫一眼再选方案。

第 1 步 · 起训自检(每项对应一个决策):

自检项 命令 读数 → 推向
CPU 争用 uptime load avg ≪ 224 核 → 可多 worker;逼近/超 224(邻居在抢)→ 单进程路线、少 worker
RAM 余量 free -h availablepreload 体积 × rank 数 × ~1.3(留余量) → 可 preload;否则只能 mmap(swap=0+overcommit=1,贴顶=直接 OOM 不是变慢,§A9)
网络 eth0 RX 快照 / nfsiostat 空闲 → 冷读快、首 epoch 不痛;被邻居占满 → 冷读掉到百 MB 级,倾向 preload(一次性读)/本地化
本地盘余量 df -h / 要本地化时必查:剩余 ≥ 数据集体积(w2v fp16 ~685G / whisper ~380G) + 余量 才能 rsync;不够就别本地化(rsync 中途撑爆盘 → 训练/进程崩)。⚠️ 盘是共享 + 临时(§A6):邻居可能占、pod 重建即清空,所以要”临起临查”,别假设上次的余量还在
GPU 邻居 nvidia-smi 确认”单卡 pod 同机是否有人”(§A6) + 自己卡上有无残留进程

口诀:RAM 决定能不能 preload;本地盘余量决定能不能本地化;CPU/网络决定 worker 数与冷启动代价。

第 2 步 · 需求 + 资源门槛 → 方案:

# 训练需求 关键资源门槛(自检) → 读取方案 关键理由
1 单卡 · 小/浅特征(≤几百G) · 多 epoch available ≥ 特征体积×1.3;CPU 不挤 preload + batch_index_mode(单进程) Python 占比高 → bt 榨得动(§2.6);RAM 够
2 单卡 · 全量 w2v(f32 1.37T) available < 1.37T+余量 / 贴顶 NFS 或本地 + mmap(48w) preload 会 COW/OOM(§3.1bis/§A9);mmap 走可回收 cache
3 单卡 · 全量 w2v · 想再榨(fp16 基数~0.8T 且离顶远) available ≫ 0.8T preload(fp16)+nw 取优 或 bt 只快 ~25–30%,体量瓶颈,谨慎(§2.6)
4 多卡 · 全量 任何(N× 必爆) NFS + mmap,绝不 preload N×RAM 爆;bt_mode 多卡自动禁用(§2.6)
5 任意 · CPU 被邻居抢(load ≈/> 核数) load 高 压低 num_workers(或 bt 单进程) CPU 已被邻居占满,多 worker 抢不到时隙、还徒增 per-worker 内存开销;非”必须归零”,按实测降到不亏为止
6 在意冷启动首遍 / 防换出 df -h / 剩余 ≥ 数据集体积 + 余量 本地盘 + mmap 躲按页网络读 + 不怕被换出(§3.2);盘不够则退回 NFS+mmap

第 3 步 · 兼容性矩阵(哪些能叠、哪些互斥):

preload mmap bt_mode 本地化
preload ❌ 互斥 ✅ bt 依赖它 ✅ 正交
mmap ❌ 互斥 ❌ 互斥(bt 要 preload) ✅ 正交
bt_mode ✅ 需要 preload ❌ 互斥 ✅ 正交
本地化
  • preload ↔ mmap = 互斥:同一轴(“getitem 怎么拿特征”)的反向策略——全进 RAM 匿名堆 vs 懒映射只读切片。二选一。
  • bt_mode → 依赖 preload:它把 preload 好的数组拼成大 tensor(§2.6),所以能叠 preload、不能叠 mmap
  • 本地化 ⊥ 一切:只改冷读数据源(本地盘 vs NFS),不改 RAM 策略 → 可叠加在方案 1/2/3/6 任意一套上,只用来躲冷启动 + 防换出。

→ 实际可选组合就四套:① mmap(不 preload,当前推荐 §3.3) ② preload + nw 取优(通常 nw>0)preload + bt_mode(单卡单进程榨 CPU) ④ 啥都不开(NFS 懒加载,最慢基准);每套都可选择性叠”本地化”。


附:最新 run ..._n10p5_nfsmmap 已跑到 iter 24000(笔记记录到 15000)。data_time 从 7–15k 的 0.16s 轻微上漂到 21–24k 的 0.23–0.30s,需再观察是 NFS 争用抖动还是”工作集超 page cache 渐慢”的真趋势。


附录 · 名词答疑(不影响正文,按需查)

阅读正文第一章硬件指标时常卡住的几个名词,集中解释在这。

A1. 显存 HBM ~3.35TB/s 是什么指标

  • HBM = High Bandwidth Memory,H100 那 80GB 显存的物理介质;3.35 TB/s 是它的带宽——GPU 计算核心和它自己显存之间每秒能搬多少字节。
  • 区分两个”速度”:带宽=每秒搬多少(吞吐),延迟=搬一次等多久(几百 ns)。这里说的是带宽。
  • 它是卡内部 GPU↔自己显存 的速度,不是卡↔卡(那是 NVLink),也不是卡↔CPU内存(那是 PCIe)。
  • 意义:GPU 算矩阵乘要反复读写显存,HBM 把带宽堆到 TB/s 才能喂饱算力。对本任务不是瓶颈(小模型,远没吃满)。

A2. 什么情况会触到 HBM 的边界

两个边界分开看:

  • 撞带宽(3.35TB/s 喂不过来):当瓶颈是”搬数据”而非”算”。判断看计算访存比——读一个字节能顺带做多少次运算,低=memory-bound。典型:大 batch/长序列的 注意力、LayerNorm、softmax、逐元素激活推理自回归解码(每个 token 读一遍全部权重);反向传播(反复读写激活/梯度)。
  • 不撞带宽(撞算力):**大矩阵乘/卷积(GEMM)**——数据复用多(O(N³) 算 vs O(N²) 读),卡在 Tensor Core,HBM 还有余量。
  • 撞容量(80GB 装不下):权重+激活+优化器状态(Adam ×2 动量)+梯度 超 80GB → OOM。大模型靠 ZeRO/offload/梯度检查点/张量并行解决。
  • 本任务:容量(占 7~9GB)和带宽(单步才几十 ms)两个边界都离得很远 → HBM 是背景家底,不是优化目标。
  • 是的(仅限单机),实测 barrier_sync ~23ms
  • 背景:DDP 下每张卡各算梯度,每步要把 N 张卡的梯度加起来求平均(=all-reduce),保持模型一致——这就是”梯度通信”。
  • 关键看走哪条路:PCIe(通用总线,几十 GB/s,绕 CPU,慢)vs NVLink(GPU 直连,本机 ~450GB/s 双向,快十几倍,不绕 CPU)。
  • 便宜的两个原因叠加:① 走 NVLink 不走 PCIe(管道宽);② 模型小、梯度才几十 MB(要传的本就少)。几十 MB ÷ 450GB/s = 微秒级。
  • ⚠️ 仅限单机 8 卡跨机器多节点时梯度走网络(RDMA 网卡),通信可能变成大头,这句就不成立。

A4. NUMA 是什么,”4+4 压在 NUMA 边界,亲和最优”

  • NUMA = Non-Uniform Memory Access:这台机器物理上是 2 颗 CPU(2 socket),每颗管一半核+一半内存+一半 GPU,即 NUMA0 / NUMA1。CPU 访问自己这半内存快,访问另一半(跨 socket)慢——访问速度”不统一”。
  • 本机布局:NUMA0 = GPU0–3 + 半数核 + 半数内存NUMA1 = GPU4–7 + 半数核 + 半数内存
  • 4+4 分卡(任务A =0,1,2,3、任务B =4,5,6,7)恰好沿 NUMA 边界切:每个任务的 GPU、它的 DataLoader worker(CPU)、读进的数据(内存)全在同一个 NUMA → CPU 取数喂卡都是近距离访问,最快。这叫亲和(affinity)最优
  • 所以 4+4 是”免费午餐”:为分卡(不抢 SM)做的切分,顺带满足了 NUMA 亲和。乱分(如 0,1,4,5 横跨两 NUMA)会引入跨 socket 慢访问。

A5. 单卡 pod 的 224 核/内存 和那 1 张卡 有亲和吗

  • 几乎没有需要操心的亲和——只有 1 张卡,不存在”选哪个 NUMA 离它近”的分卡决策。
  • 物理上 224 核这种规格大概率仍是 2-socket/2-NUMA,”近内存/远内存”差异客观存在;那张卡也只插在某一个 NUMA 上。但实际可忽略,三个原因:
    1. 没有分卡决策(NUMA 价值在多卡 4+4 对齐,单卡用不上);
    2. 你是数据受限——跨 socket 那点纳秒级延迟,相比 NFS 往返(几十~几百 ms)、getitem(0.16s) 小到看不见;
    3. preload 的 1.37TB 数组 / 几百 GB page cache 本就横跨两个 NUMA,内核不会替你对齐,对单卡训练无所谓。
  • 只有做到”数据全在 RAM + num_workers=0 + 纯比内存带宽”且想 numactl --cpunodebind 绑核时才需在意——对当前 NFS+mmap、data_time 还有 0.16s 的场景属过度优化

A6. 这些资源(CPU/RAM/盘/shm/NFS/网卡)单卡和多卡是一样的吗?单卡是不是和别人共享?

  • 全是「宿主机(物理节点)级」资源,不是「卡级」。 一个 pod 就是跑在某台物理机上的容器,看到的 CPU/RAM/盘/网卡就是那台宿主机的家底。所以单卡 pod 报出的 224 核 / 2015GB / 11TB / 2TB shm 和 8 卡几乎一样,不是巧合——同机型。

  • 唯一真正按卡分的是 GPU:pod 被授予几张就用几张。所谓”单卡 pod”极可能是一台多 GPU 机切出来、只给 1 张 GPU 的容器,同机其他 pod 在用剩下的 GPU。

  • 这些 host 级资源都不是独享,而是和同机其他租户共享(笔记 memory.max=max = 无 cgroup 上限,能看到也能用满整机,但邻居也能抢):

    资源 归属 单卡独享?
    CPU 224 核 / RAM 2015GB / 本地盘 11TB / shm 2TB 宿主机 ❌ 与同机 pod 共享
    NFS + 10GbE 网卡 节点 ❌ 每节点共享(硬天花板)
    GPU 卡级 ✅ 按授予张数分
  • ⚠️ 两边 NFS nconnect 不同(单卡 16 / 8 卡 4)→ 二者未必同一台物理机,更可能是同机型的两台不同节点;但结论不变:CPU/RAM/盘/网卡都是 host 级、与邻居共享。

  • 实践含义:单卡 pod ≠ 清净的专属小机器。起训前 nvidia-smi + uptime 看有没有邻居在抢 → 呼应 §8 checklist。

🔬 A6 实测佐证(一台单卡 pod 现场量的,2026-07-01)

在一台单卡 H100 pod上直接量了一遍”共享/独占”和”当下 NFS 速度”,全部对上 A6 的结论,并补两个只有现场才看得到的证据。

① 两个铁证:容器无上限 + 负载来自看不见的邻居

  • memory.max = maxcpu.max = max 100000无 cgroup 配额)→ 能用满整机,但邻居也没上限,纯靠抢。
  • uptimeload average ≈ 121(224 逻辑核),可 ps 在自己 PID namespace 里只看得到 27 个 root 进程(本训练 1×python + 一堆 pt_data_worker,合计才 ~5–6 核)→ 那 ~121 的负载绝大部分来自我根本看不见的邻居进程。这就是”CPU 是 host 级共享”最直观的一幕:承受其争用,却连是谁都看不到

② 共享 vs 独占(现场读数)

资源 本 pod 实测 与别人共享?
GPU 1× H100 80GB(已用 5.9G,util 0%) 独占(唯一独占的)
CPU 224 逻辑核 / 2 socket(NUMA0=0-55,112-167,NUMA1=56-111,168-223 ❌ 共享,且此刻在被抢(load 121)
RAM 2.0 TiB(host 视角 used 310G / cache 656G / avail 1.6T) ❌ 共享,free 的 used 含邻居
/dev/shm 2.0 TB tmpfs(用 16G) ❌ 共享(吃 shm=吃 RAM)
本地盘 / overlay ❌ 共享 + 重建即清
NFS + 网卡 eth0 **10GbE(10000Mb/s)**;/code//data//share_datavers=3,rsize=1M,nconnect=16datastore.eng-zoom.local 每节点共享,硬顶 ~1.25GB/s

→ 唯一真正独占的就是那 1 张 GPU;nconnect=16 也和 A6”单卡 16”对上。

③ 当下 NFS 读速(/code 上一个 92MB 文件实测)

场景 实测 说明
冷读·单流(dd iflag=direct,绕 cache 真走网络) ~0.32 → 0.65 → 0.74 GB/s(逐次升高) nconnect=16 + 顺序 readahead 把请求铺开
暖读·单流(命中 page cache) ~4.0 → 6.2 GB/s 走 RAM,与网卡无关
网卡硬上限 10GbE ≈ 1.25 GB/s 整节点共享
当下 eth0 实际入向 ≈ 0 MB/s 关键:此刻几乎没人在读网络

关键解读(为何和 §1.3”单卡冷读~114MB/s”不矛盾):§1.3 那个 114MB/s 是**”训练正猛读 NFS、被争用压低”时的数;而现场 eth0≈0(网络空闲)——虽 load 高达 121、同 pod 有 pt_data_worker 在跑,但 GPU util=0、网卡 0,说明它们这会儿在吃暖 page cache / 卡 CPU,没走网络没人抢网络 → 单流冷读就能冲到 0.7GB/s(逼近 10GbE 一半多)。所以”NFS 速度”没有单一答案,取决于此刻谁在抢那条 10GbE**:网络空闲→单流 0.3–0.7GB/s、并发可顶 ~1.25GB/s;有人猛拉→压到百 MB 级;数据已在暖 cache→4–6GB/s 走 RAM(与网络无关)。

④ 落到实践:这台的瓶颈信号很典型——网络空闲(冷读能到 0.7GB/s)但 CPU 被抢得凶(load 121),正好印证正文”数据受限的真瓶颈在 getitem 的 CPU/争用,不在 NFS 带宽本身”(§2.2 / §3.4)。起训前 uptime+nvidia-smi 看一眼邻居,才知道今天分到的是宽松还是拥挤那种。

A7. “RAM 2015GB,buff/cache ~1.3TB” 是什么意思?2T 里包含了 1.3T cache 吗?

  • 是的,1.3TB cache 包含在那 2TB 之内,不是额外多出来的。free 的模型看:

    1
    总物理 RAM (2015GB) = used(进程真正占的) + buff/cache(借去做文件缓存, 此刻~1.3TB) + free(纯空闲)
  • 2015GB = 物理内存总量,固定。buff/cache ~1.3TB = 这 2TB 当中此刻被内核拿去做 page cache(缓存读过的 NFS 文件)的那部分——是个状态切片,不是另一块内存。

  • 关键:page cache 可回收(弹性中间层)。 它算”用掉了”,但进程一要内存内核立刻还回去 → 实际可用 ≈ free + buff/cache,不是只有 free

  • 对本任务的意义:这 1.3TB 正是文档说的”暖缓存”——首个 epoch 把 NFS 文件读进来后缓存在此,之后每步从 RAM 命中,不再走网络。这就是 NFS+mmap 稳态能追平本地盘的原因:数据其实都在这 1.3TB page cache 里,与它在 NFS 还是本地盘无关。

A8. NFS 挂载参数:NFSv3 / rsize=1MB / nconnect 是什么

挂载 NFS 时给的配置,决定客户端怎么跟远端存储服务器通信。

  • NFSv3 = 协议版本。相对 v4 更老更简单,无状态:服务器不记”谁开了哪个文件”,每个请求自带全部信息。代价是每次访问都要重新确认元数据 → 这就是”暖读也躲不掉 GETATTR 网络往返”的协议根源。v4 的目录委托/复合操作能省元数据开销,但这台挂的是 v3,所以 per-file open/stat/getattr 成本省不掉,只能靠”少碰文件 / preload / mmap 少读”绕开。
  • rsize=1MB = 单次 READ 请求一次最多拿多少字节。NFS 读大文件是切成一个个 READ 请求发,块越大 → 同样大小文件需要的往返越少 → 吞吐越高。1MB 已较大(默认常 128/256KB)。例:28MB 的 w2v 文件按 1MB 块 ≈ 28 个 READ。
  • nconnect=N = 客户端到该 NFS 服务器开几条并行 TCP。默认只 1 条,单条 TCP 喂不满 10GbE;开 N 条让请求分散 → 更易打满网卡(尤其多 worker 并发读)。所以单卡 nconnect=16 比 8 卡 nconnect=4 更容易、更快逼近网卡上限(单卡并发裸读 ~1293MB/s 就靠它)。
  • ⚠️ **nconnect 提高的是”能不能打满网卡”,不是”网卡上限”**。硬天花板仍是单口 10GbE ≈ 1.25GB/s,nconnect 再大也突破不了 → 两边聚合带宽仍一致,只是单卡更快逼近。

A9. preload 往哪放?匿名内存 vs page cache,以及”共享 RAM 不够时”会怎样

承接 §3.1 / §3.1bis 的”preload 会 COW/近 OOM”。这里补底层机制:preload 把数据放进「不可回收的匿名内存」,在共享节点上先挤掉别人的缓存、真顶到墙时直接被 OOM 杀,而不是报”内存不够”。 关键内核参数已在本 pod 实测确认(2026-07-01):swap=0vm.overcommit_memory=1、cgroup memory.max=maxmemory.low/min=0

先分清两种 RAM 占用(回收性天差地别)

是什么 free 里算 能被回收吗
匿名内存(anonymous) np.load/astype 出来的数组、进程堆 used ❌ 只能 swap out;本机 swap=0 → 一旦占上就钉死,不可回收
page cache(文件缓存) 读过的 NFS/本地文件、mmap 的文件页 buff/cache ✅ 内核随时可丢(内容在盘上,重读即可)

preload 灌的是匿名内存(§2.2:astype(f32) 碰所有页把它实体化);mmap 走的是 page cache。这一条决定了下面的全部差异。

共享节点上 preload 撑大匿名堆的三个阶段

1
2
3
4
5
6
7
8
flowchart TD
P1["阶段1 · free 还够<br/>直接分配 → 邻居无感"]:::ok
P2["阶段2 · free 见底<br/>内核回收 page cache(LRU)<br/>= 连邻居正在暖用的缓存一起丢"]:::mid
P3["阶段3 · cache 也榨干<br/>匿名内存无处可去(swap=0)<br/>→ 全局 OOM Killer 杀进程"]:::bad
P1 --> P2 --> P3
classDef ok fill:#90EE90,stroke:#333,stroke-width:2px,color:#000
classDef mid fill:#FFD700,stroke:#333,stroke-width:2px,color:#000
classDef bad fill:#FFB6C1,stroke:#DC143C,stroke-width:2px,color:#000
  • 阶段2「别人的缓存被挤掉了吗?」→ 会,且不分你我。 page cache 是整节点共享的弹性层,memory.low/min=0没有任何租户的 cache 受保护。你的匿名堆一涨,内核按 LRU 回收 page cache,包括邻居正在暖用的文件缓存 → 邻居下次读从”暖读命中 RAM(4–6GB/s)”跌回”冷读走 NFS(百 MB 级 + 每文件 GETATTR 往返)”。你把邻居拖慢了,却收不到任何报错(隐形拖累)。你自己被 preload 那些文件的 cache 也一起丢——但无所谓,数据已在堆里(§3.1「preload 后那些 cache 纯冗余可回收」)。
  • 阶段3「RAM 不够时 preload 怎么放?」→ 不会优雅失败,而是被 OOM 杀。 因为 overcommit_memory=1
    1. 分配阶段永远成功malloc/mmap/np.empty 无论物理内存够不够都立刻返回指针 → Python **拿不到干净的 MemoryError**,照常往下跑。
    2. 死在”碰页”那一刻:物理页惰性提交,astype(f32) 逐页写才真正吃内存;当 free + 可回收 cache 都归零、又没 swap,内核在某次缺页凑不出页 → 触发 OOM Killer
    3. 杀谁不可控memory.max=max(无 cgroup 上限)→ OOM 是宿主机级全局,按 oom_score(≈总 RSS×调整) 挑最肥的杀。1.6TB 的 preload 进程通常就是靶子(多半杀你自己),但邻居更肥就杀邻居。

结论(为什么 mmap 是共享节点上的稳妥解)

  • preload 大特征 = 往不可回收的匿名堆灌数据 → 先无声吃掉整节点(含邻居)的 page cache,真顶到墙时因 swap=0 + overcommit=1 直接 OOM 杀进程(而非报错软着陆)。
  • mmap(§3.2/§3.3)走可回收的 page cache,是内核级真共享,不受 refcount/COW 影响;内存紧张时最多被回收→变慢(回退冷读),不会 OOM。这就是”共享节点上要么 preload+num_workers=0、要么干脆 NFS+mmap”的底层原因。
  • 实测锚点(2026-07-01,本 pod):当前训练 job 真实占用(PSS)≈ 244GB,其中匿名 ≈ 233GB —— 远不是满量 w2v f32 preload 的 1.37T(那是理论峰值)。同机 used 才 311G、cache 668G,离 2T 天花板尚宽松;但正因 swap=0 + overcommit=1 + 无 cgroup 保护越过 2T 的瞬间不是变慢而是 OOM,所以 §3.1bis”1.6T<2T 仍不推荐”成立。