PyTorch 训练框架内核深度解析
第11章 DataLoader 与多进程数据流水线
"Our data model looks like this (queues are indicated with curly brackets):"
——
torch/utils/data/dataloader.py:814,NOTE [ Data Loader Multiprocessing Shutdown Logic ](该 NOTE 起于torch/utils/data/dataloader.py:810,v2.11.0)。紧随其后的那张 ASCII 图画的是:主进程 →{index_queue}→ worker 进程 →{worker_result_queue}→ 主进程的 pin_memory 线程 →{data_queue}→ 数据输出
本章要点
- DataLoader 是 4 件套:
Dataset(怎么读一条数据)、Sampler(决定读哪些)、collate_fn(怎么把多条合成 batch)、DataLoader(编排整个流水线) num_workers > 0启动 worker 子进程:每个 worker 跑一个独立 Python 解释器,通过multiprocessing.Queue与主进程通信- 三段 Queue 接力:
_index_queues(每个 worker 一条,主进程按 round-robin 派活)、_worker_result_queue(所有 worker 共享,回传数据)、_data_queue(pin_memory 线程的输出;不开 pin_memory 时它就是_worker_result_queue本身) pin_memory=True启动一个独立的 pin memory 线程:从 worker 收到的 CPU 数据搬到锁页内存,让后续 H2D 拷贝能 async + 跳 page faultpersistent_workers=True让 worker 进程跨 epoch 复用:避免每个 epoch 重启 worker 的固定开销- 每个 worker 的 RNG seed = base_seed + worker_id:保证多 worker 之间数据不重复但可复现
- GPU 训练里 DataLoader 不能成为瓶颈:
num_workers应让 worker 池吞吐 ≥ GPU 训练吞吐
11.1 一行 for 循环背后的系统
每个 PyTorch 用户都写过:
loader = DataLoader(dataset, batch_size=32, num_workers=4, pin_memory=True)
for batch in loader:
out = model(batch.cuda(non_blocking=True))
loss = compute_loss(out)
loss.backward()
optimizer.step()
for batch in loader 这一行,背后是 4 个 worker 子进程 + 1 个 pin_memory 线程 + 5 条 multiprocessing queue(每个 worker 一条 index queue,外加 1 条共享的结果队列)+ 1 条进程内 queue.Queue 协同工作的流水线。如果 dataset 是从磁盘读图(IO bound)或者要做 augment(CPU bound),这套机器把 数据准备并行化,让 GPU 不用等。
本章拆这套流水线。源码主要在 torch/utils/data/dataloader.py(1709 行)和 torch/utils/data/_utils/。
11.2 4 件套数据抽象
PyTorch 数据加载的核心抽象:
graph LR
Sam[Sampler<br/>决定读哪些索引]
Ds["Dataset<br/>__getitem__(idx) → 一条样本"]
Co["collate_fn<br/>list[sample] → batch"]
Dl[DataLoader<br/>编排所有]
Sam --> Dl
Ds --> Dl
Co --> Dl
style Dl fill:#fef3c7,stroke:#f59e0b,stroke-width:2px
逐个介绍:
11.2.1 Dataset
class Dataset:
def __getitem__(self, index): ...
def __len__(self): ...
Map-style Dataset:实现 __getitem__ 和 __len__。典型用例:图片数据集(每个 idx 对应一个文件)。
class ImageDataset(Dataset):
def __init__(self, root, transform):
self.files = sorted(os.path.join(root, f) for f in os.listdir(root))
self.transform = transform
def __getitem__(self, idx):
img = Image.open(self.files[idx])
return self.transform(img)
def __len__(self):
return len(self.files)
Iterable-style Dataset(IterableDataset):实现 __iter__。用于流式数据(如 Kafka stream、巨量 webdataset)。它的特殊性是不要求 __len__(可以选择实现),长度要么不知道、要么靠 worker 自己处理。
11.2.2 Sampler
Sampler 决定"按什么顺序遍历 dataset"。最常用的几种:
SequentialSampler:按顺序 0, 1, 2, ..., N-1RandomSampler:每个 epoch 随机打乱WeightedRandomSampler:按权重采样(用于不平衡数据集)BatchSampler:把 sample 索引打包成 batchDistributedSampler:多卡训练时按 rank 分片
DataLoader(shuffle=True) 实际就是用 RandomSampler 包装。DistributedSampler 是 DDP 训练的关键 —— 它保证 rank 0 拿到 [0, N, 2N, ...]、rank 1 拿到 [1, N+1, 2N+1, ...],不重复不遗漏。第 17 章 DDP 章会详细讲。
11.2.3 collate_fn
collate_fn 把 list of samples 合成一个 batch。默认 default_collate 处理常见类型:
- 张量 list → torch.stack
- numpy list → torch.as_tensor + stack
- list of tuple → 按位置转置后逐项 collate,返回的是 list(不是 tuple;namedtuple 才保留原类型)
- list of dict → dict of stacked tensors
- int / float →
torch.tensor(float 为 float64) - str / bytes → list(不堆叠,作为 batch metadata)
但变长数据(如 NLP 句子)要写自定义 collate_fn 做 padding:
def my_collate(batch):
# batch: list of (input_ids, label)
inputs = [b[0] for b in batch]
labels = torch.tensor([b[1] for b in batch])
inputs_padded = torch.nn.utils.rnn.pad_sequence(inputs, batch_first=True)
return inputs_padded, labels
collate_fn 的执行位置很关键 —— num_workers > 0 时它在 worker 进程里跑(不在主进程)。这意味着 padding / 转 tensor 等 CPU 工作能并行,主进程只接收已经合好的 batch。
11.2.4 DataLoader
DataLoader 是这 4 件套的"总编排器":
class DataLoader(Generic[_T_co]):
def __init__(
self,
dataset,
batch_size=1,
shuffle=None,
sampler=None,
num_workers=0,
collate_fn=None,
pin_memory=False,
...,
*, # 以下为仅限关键字参数
prefetch_factor=None,
persistent_workers=False,
...
):
...
源码在 torch/utils/data/dataloader.py:142。它根据 num_workers 选择两套实现:_SingleProcessDataLoaderIter(同步)或 _MultiProcessingDataLoaderIter(异步)。
11.3 单进程模式:最简实现
num_workers=0 时 DataLoader 在主进程跑:
# 简化版
class _SingleProcessDataLoaderIter:
def __next__(self):
index = next(self._sampler_iter) # 取下一个 batch 索引
data = self._dataset_fetcher.fetch(index) # 直接调 dataset.__getitem__ + collate
if self._pin_memory:
data = _utils.pin_memory.pin_memory(data, self._pin_memory_device)
return data
——拿索引 → 读数据 → 合 batch → pin → return。主进程顺序跑,下一个 batch 等当前 batch 训练完才开始读。
适用场景:
- Dataset 是内存里的张量,读取是 O(1)
- 调试时想避免多进程导致 stack trace 丢失
- 单卡 + 小数据集 + GPU 比 CPU 快得多
但绝大多数训练场景下 num_workers=0 会让 GPU 大部分时间空等。这就是多进程模式的存在意义。
11.4 多进程模式:核心架构
num_workers > 0 时 DataLoader 启动一组 worker 子进程,主进程通过 queue 与它们通信:
graph TB
Main[主进程<br/>训练循环]
Sam[Sampler]
PMT[pin_memory thread<br/>主进程内]
subgraph Workers["worker 子进程 (4 个)"]
W0[Worker 0<br/>_worker_loop]
W1[Worker 1<br/>_worker_loop]
W2[Worker 2<br/>_worker_loop]
W3[Worker 3<br/>_worker_loop]
end
IQ0["_index_queues[0]"]
IQ1["_index_queues[1]"]
IQ2["_index_queues[2]"]
IQ3["_index_queues[3]"]
DQ["_worker_result_queue<br/>(所有 worker 共享)"]
OQ["_data_queue<br/>(queue.Queue, 进程内)"]
Main -->|"取下一个 batch 索引"| Sam
Sam -->|"round-robin 派给某个 worker"| IQ0
Sam --> IQ1
Sam --> IQ2
Sam --> IQ3
IQ0 --> W0
IQ1 --> W1
IQ2 --> W2
IQ3 --> W3
W0 -->|读取 + collate| DQ
W1 --> DQ
W2 --> DQ
W3 --> DQ
DQ --> PMT
PMT -->|pin 后| OQ
OQ -->|"next() 返回"| Main
style Main fill:#fef3c7,stroke:#f59e0b,stroke-width:2px
style PMT fill:#dbeafe,stroke:#3b82f6
style Workers fill:#dcfce7,stroke:#22c55e
工作流:
- 启动时主进程创建 N 个 worker,每个跑
_worker_loop,并且为每个 worker 单独建一条index_queue(torch/utils/data/dataloader.py:1155的self._index_queues = []) - 主进程预先往这些队列里 round-robin 派满
prefetch_factor × num_workers个 batch 索引 - worker 从自己那条队列 pop 索引、调
dataset.__getitem__读取、collate、把结果 put 到共享的_worker_result_queue - 开了 pin_memory 时,主进程内的 pin 线程从
_worker_result_queue取出、pin 完再放进_data_queue(一个普通的queue.Queue);没开 pin_memory 时_data_queue直接就是_worker_result_queue(torch/utils/data/dataloader.py:1228) - 每消费一个 batch,主进程就再派一个新索引出去,维持 pipeline
注意方向不对称:主进程→worker 是 N 条队列(这样才能把任务定向派给指定 worker,也才能给指定 worker 发关闭信号),worker→主进程只有 1 条共享队列(谁先做完谁先放)。
整套机制让 worker 与训练真正并发 —— GPU 跑 step N 时 worker 在准备 step N+1、N+2 的数据。
11.5 worker 主循环
打开 torch/utils/data/_utils/worker.py:228 的 _worker_loop(精简版):
def _worker_loop(dataset_kind, dataset, index_queue, data_queue, ...):
# 1. 在 C 侧装 SIGBUS/SIGSEGV 等 handler,让致命信号的死因能被主进程拿到
signal_handling._set_worker_signal_handlers()
# 2. 单线程模式 (avoid CPU thrashing)
torch.set_num_threads(1)
# 3. 设置 RNG seed
seed = base_seed + worker_id
random.seed(seed)
torch.manual_seed(seed)
if HAS_NUMPY:
np_seed = _generate_state(base_seed, worker_id) # 不是简单相加,见 §11.5.2
np.random.seed(np_seed)
# 4. 暴露 worker info 给用户回调
global _worker_info
_worker_info = WorkerInfo(id=worker_id, num_workers=num_workers, seed=seed, dataset=dataset)
# 5. 创建 fetcher (Dataset 包装器, 处理 collate)
fetcher = _DatasetKind.create_fetcher(...)
# 6. 主循环(watchdog = ManagerWatchdog(),见 §11.29)
while watchdog.is_alive():
try:
r = index_queue.get(timeout=MP_STATUS_CHECK_INTERVAL)
except queue.Empty:
continue # 超时:回到 while,让 watchdog 再查一次父进程
if r is None:
break # 收到关闭信号
elif done_event.is_set():
continue # 正在关闭:不再干活,只等 None
idx, index = r
try:
data = fetcher.fetch(index) # 读取 + collate
except Exception:
data = ExceptionWrapper(where=f"in DataLoader worker process {worker_id}")
data_queue.put((idx, data)) # 发回主进程
几个细节:
11.5.1 torch.set_num_threads(1) 防 CPU 抢占
每个 worker 进程默认会让 PyTorch C++ 后端开多线程跑算子(如 torch.einsum),但 num_workers=4 加上每个 worker 4 个 thread = 16 thread 抢 CPU 核 → 严重 thrashing。torch.set_num_threads(1) 让每个 worker 单线程,4 个 worker 加起来正好用 4 核。
如果你的 dataset transform 用了大量 PyTorch 算子(如 vision transform),这条限制正是防止 worker 之间互相抢核。反过来说:如果你把 num_workers 设得比物理核数还多,即使有这行限制,进程之间照样会开始抢核 —— 这就是 §11.37 那条"别把 num_workers 设过头"的由来。
11.5.2 RNG seed:reproducible 但不重复
worker 0 的 seed = base_seed + 0、worker 1 = base_seed + 1,etc.。这保证:
- 不同 worker 的随机性不同(augment 不会重复)
- 每次运行一致(用同一个 base_seed 重跑能复现)
- 跨 epoch 不重复:
base_seed由主进程在每次新建 iterator 时从自己的 RNG 里现取一个,所以每个 epoch 的 worker seed 都不同。开了persistent_workers=True时 iterator 与 worker 都复用,seed 只在 worker 启动时设一次,后续 epoch 沿用同一条 RNG 流继续往下取(实测get_worker_info().seed跨 epoch 不变,随机数不重复)
一个容易记错的细节:numpy 的 seed 不是 base_seed + worker_id。torch/utils/data/_utils/worker.py:262 走的是 np_seed = _generate_state(base_seed, worker_id) —— _generate_state(torch/utils/data/_utils/worker.py:176)是从 numpy.random.SeedSequence 移植过来的哈希混合算法,目的是避免 numpy 与 Python random 拿同一个 seed —— 两者都是 MT19937,同 seed 就会产生完全相同的状态序列(引入它的提交 3b977a0d28,PR #56797,说明的就是这个动机)。torch 与 python 的 random 用的仍是简单相加。至于用户自定义的全局 RNG,PyTorch 管不到。生产代码里建议优先用 torch.rand 与 dataset 内部的 torch.Generator 实例。
11.5.3 ExceptionWrapper:跨进程异常传递
worker 抛异常时不能直接 raise(多进程间异常无法跨进程传播),用 ExceptionWrapper 把异常 + traceback 序列化成普通对象,发回主进程后由主进程 reraise。这种"序列化异常"模式让 worker 的报错在主进程能看到完整 stack trace。
11.6 pin_memory:异步 H2D 的关键
pin_memory=True 启动一个主进程内的线程(不是子进程!),把 worker 返回的 CPU tensor 搬到 pinned (page-locked) memory:
# torch/utils/data/_utils/pin_memory.py:18(简化:省略了 queue.Empty / queue.Full 的超时重试与 pin 失败时的 ExceptionWrapper)
def _pin_memory_loop(in_queue, out_queue, device_id, done_event, device):
torch.set_num_threads(1)
torch.accelerator.set_device_index(device_id)
while not done_event.is_set():
r = in_queue.get(timeout=MP_STATUS_CHECK_INTERVAL)
idx, data = r
if not isinstance(data, ExceptionWrapper):
data = pin_memory(data, device)
out_queue.put((idx, data))
pin_memory(data, device) 递归遍历 data(dict / list / tensor)调 tensor.pin_memory()。pinned memory 的好处:
- H2D 拷贝可以 async(CPU 不用等 GPU)
- CUDA driver 直接 DMA,不走 page fault
- 配合
tensor.cuda(non_blocking=True)拿到完整异步性能
为什么用线程而不是子进程?因为 pin_memory() 调用要访问 CUDA context —— 子进程要自己 init CUDA 太重。线程在主进程里直接用主进程的 CUDA context,简单高效。
这两个开关必须配对使用:只开 pin_memory=True 但 .cuda() 时没写 non_blocking=True,拷贝仍然是同步的,等于白搭一个线程;反过来只写 non_blocking=True 而内存没 pin,CUDA 会静默退化成同步拷贝。配对之后 H2D 才有机会与训练计算 overlap —— 实际能藏掉多少,取决于你的 batch 有多大、GPU 一步算多久,要自己 profile。
11.7 persistent_workers:跨 epoch 复用 worker
默认每个 epoch 结束 DataLoader 关闭所有 worker,下个 epoch 重启。重启代价:
- 重新创建 worker 进程(spawn / forkserver 下还要重新 import 依赖、把 dataset 整个 pickle 过去)
- 每个 worker 重新设 seed、重跑
worker_init_fn、重建 fetcher - 这些固定开销每个 epoch 都付一遍
如果 dataset 对象本身很大(如在内存里存着 ImageNet 1.28M 图片清单),spawn / forkserver 下每次重启都要把它重新 pickle 一遍,这个重启成本就尤其高。
persistent_workers=True 让 worker 跨 epoch 不退出:新 epoch 开始时主进程重建自己的 sampler 迭代器,再给每个 worker 发一个 _ResumeIteration 让它重建 fetcher 即可(torch/utils/data/dataloader.py 的 _MultiProcessingDataLoaderIter._reset):
loader = DataLoader(dataset, num_workers=4, persistent_workers=True)
for epoch in range(100):
for batch in loader: # 第 2 个 epoch 起 worker 不重启
...
代价是 worker 持续占着内存,dataset 里的 cached 数据(如预读到内存的 ImageNet)一直占用 RAM。生产代码里几乎所有训练循环都该开 persistent_workers,除非 worker 占内存极大。
11.8 prefetch_factor:预取深度
prefetch_factor 在 num_workers > 0 时默认 2(num_workers=0 时默认 None,显式传值会直接 ValueError),意思是每个 worker 预取 2 个 batch 在 queue 里。总 prefetch = num_workers * prefetch_factor。
为什么要预取?因为 worker 跑 dataset.__getitem__ 有一定延迟(如读图、解码、augment)。如果训练消费数据的速度比 worker 快,就会有"GPU 等数据"的 stall。预取让 queue 里始终有几个 batch 待取,吸收 worker 间的速率波动。
调优建议:
- 默认 2 适合大多数场景
- IO 抖动大(如从 S3 读)→ 提升到 4-8
- 内存紧张 → 降到 1,但要确保 worker 数足够多
11.9 BatchSampler:用 batch 索引省 sampler 开销
默认 DataLoader 内部把 Sampler + batch_size 组合成 BatchSampler。BatchSampler 一次返回 batch_size 个索引:
# 默认行为
sampler = RandomSampler(dataset) # 给单个索引
batch_sampler = BatchSampler(sampler, batch_size, drop_last) # 给 batch 索引
for indices in batch_sampler: # indices 是 list of 32 个索引
samples = [dataset[i] for i in indices]
batch = collate_fn(samples)
用户可以传自定义 batch_sampler 替代默认。常见用法:动态 batch size(如按序列长度排序后凑满 token 预算的 batch):
class DynamicBatchSampler:
"""按 token 数构造 batch, 长序列少配, 短序列多配, 让每个 batch 总 token 数接近"""
def __iter__(self):
...
这种"按 token 而非按 sample 数"在 NLP 训练里(特别是变长序列)能显著提升 GPU 利用率。
11.10 fork vs spawn:跨平台陷阱
multiprocessing 启动子进程有两种模式:
- fork(Python 3.13 及以前在 Linux 上的默认;Python 3.14 起 macOS 以外的 POSIX 平台默认改为 forkserver,见 §11.14):子进程继承父进程内存。Dataset 对象不需要 picklable,速度快
- spawn(Windows 一直是默认;macOS 自 Python 3.8 起默认):子进程从零启动 + pickle 传递所有参数。Dataset 必须 picklable(不能含 lambda / 闭包 / unpicklable 对象)
后果:在 Mac 调通的 DataLoader 在 Linux 上 hang,或者反过来 —— 都是真实碰到的工程坑。
# 强制指定启动方式
import torch.multiprocessing as mp
mp.set_start_method('spawn', force=True)
关于 CUDA 有一条经常被过度概括的说法,需要说准确:报错 "Cannot re-initialize CUDA in forked subprocess" 的触发条件是 主进程已经初始化过 CUDA,fork 出来的子进程又自己去初始化 CUDA(torch/csrc/utils/device_lazy_init.cpp 用 pthread_atfork 在子进程里打上 bad-fork 标记),而不是"用了 CUDA 就不能 fork"。所以:
- 主进程已经初始化过 CUDA、worker 只做纯 CPU 的读盘与 transform —— Linux 默认的 fork 完全能跑,这也是绝大多数训练脚本的实际情形
- worker 里出现任何 CUDA 调用(在
__getitem__里.cuda()、用 GPU 解码库、返回 CUDA 张量)—— 才会撞上这条错误,此时要么把 CUDA 操作挪回主进程,要么改用 spawn / forkserver
DataLoader 并不会替你自动切换启动方式,它用的是 multiprocessing_context(不传就是平台默认)。理解这条差异,你看到 PicklingError 时也不会困惑 —— 那是 spawn / forkserver 在序列化 Dataset 时失败了,fork 模式下则不会有这个问题。
11.11 worker shutdown 的精细同步
那段三百来行的注释 "NOTE [ Data Loader Multiprocessing Shutdown Logic ]" 在 torch/utils/data/dataloader.py:810(_MultiProcessingDataLoaderIter 类体开头),torch/utils/data/_utils/worker.py:244、torch/utils/data/_utils/pin_memory.py:47、torch/utils/data/_utils/signal_handling.py:29 三处都指回它。简化版流程:
- 开了 pin_memory 时先停 pin 线程:set
pin_memory_thread_done_event,往worker_result_queue塞一个(None, None)把它唤醒,再 join - 主进程 set
workers_done_event - 往每个 worker 的 index_queue 发
None(关闭信号) - worker 看到
done_event已 set 就不再处理任务、只等None;收到None才 break 主循环 → 进程退出(index_queue.get()每MP_STATUS_CHECK_INTERVAL超时一次,保证不会永久阻塞) - 主进程带超时 join 所有 worker,对各 index_queue 调
cancel_join_thread()+close() - 最后从 C 侧摘掉这些 worker 的 pid(SIGCHLD 检查不再管它们),仍未退出的 worker 直接
terminate()
为什么这么复杂?因为有几种异常退出场景要处理:
- 主进程被
Ctrl+C杀掉(worker 必须在父进程死亡时也死,否则成为继续占资源的孤儿进程) - worker 自己崩了(main 必须感知到、报错而不是 hang)
- IterableDataset 提前 StopIteration(必须告诉 main "我没数据了,别再发任务给我")
signal_handling._set_worker_signal_handlers()(worker.py:253)在 C 侧注册 SIGBUS / SIGSEGV(以及 SIGFPE、SIGTERM)handler —— 源码注释解释了为什么必须在 C 侧做:Python 的 signal handler 要等 C 层返回后才执行,而致命信号很可能在那之前就再次发生。ManagerWatchdog(实例化在 worker.py:314)则负责感知父进程死亡,见 §11.29。
如果你写训练代码遇到 "ctrl-c 后 python 进程 hang 不退",几乎都是 DataLoader shutdown 流程出了问题。persistent_workers=True 引入的复杂度让这套同步逻辑更微妙。
11.12 IterableDataset 的 worker 分片
Dataset(map-style)有 __len__,DataLoader 能给每个 worker 分配不同索引。但 IterableDataset 没有按索引访问,每个 worker 默认会重复跑同一份数据流 —— 4 个 worker = 4 份重复 batch!
正确做法:dataset 内部按 worker_id 分片:
class StreamDataset(IterableDataset):
def __init__(self, files):
self.files = files
def __iter__(self):
worker_info = torch.utils.data.get_worker_info()
if worker_info is None:
files = self.files # 单进程, 全部
else:
n = worker_info.num_workers
i = worker_info.id
files = self.files[i::n] # worker i 取第 i, i+n, i+2n, ... 份
for f in files:
yield from read_file(f)
torch.utils.data.get_worker_info() 在主进程返回 None,在 worker 里返回 WorkerInfo(id, num_workers, seed, dataset)。这是 IterableDataset 多 worker 训练的关键 API。
第 17 章 DDP 章会再叠一层 rank 间分片(map-style 用 DistributedSampler;IterableDataset 用不了 sampler,要在 __iter__ 里同时按 rank 与 worker 切,见 §11.28),两层分片合起来让 N rank × M worker = N×M 份数据并行加载,互不重复。
11.13 DistributedSampler:多卡训练的数据切片
第 17 章 DDP 章会把这玩意儿用到底,这里先讲它的内部机制。torch/utils/data/distributed.py:17 的 DistributedSampler(下面按语义精简):
class DistributedSampler(Sampler):
def __init__(self, dataset, num_replicas=None, rank=None,
shuffle=True, seed=0, drop_last=False):
...
def __iter__(self):
# 1. 生成全局索引序列
if self.shuffle:
g = torch.Generator()
g.manual_seed(self.seed + self.epoch) # 每个 epoch 不同 seed
indices = torch.randperm(len(self.dataset), generator=g).tolist()
else:
indices = list(range(len(self.dataset)))
# 2. padding 让总长度能被 num_replicas 整除
if not self.drop_last:
padding_size = self.total_size - len(indices)
indices += indices[:padding_size]
else:
indices = indices[:self.total_size]
# 3. 切片:rank i 拿 [i, i+N, i+2N, ...]
indices = indices[self.rank:self.total_size:self.num_replicas]
return iter(indices)
几个工程细节:
- 每个 epoch 必须调
sampler.set_epoch(epoch):否则每个 epoch shuffle 顺序一样、训练 loss 曲线会异常。这是 DDP 训练新手常见 bug - drop_last vs padding:drop_last 把不够整除的 sample 扔掉、padding 把它们重复一份补齐。padding 让 epoch 内所有 rank batch 数一致(DDP 要求),但有重复样本
- seed = 用户 seed + epoch:所有 rank 用同一个 base seed → 所有 rank 算出的全局 indices 一致 → 切片后 rank i 拿到第 i, i+N, ... 个样本,不重复不遗漏
这套机制让 N 个 rank 的训练近似等价于单 rank 训练 N×batch_size(前提:BN 用 SyncBN、学习率随总 batch 调整;padding 引入的少量重复样本除外)。是 DDP 训练正确性的基础。
11.14 forkserver:fork 与 spawn 之间的折中
§11.10 提到 fork(快但 CUDA 不安全)vs spawn(慢但安全)。还有第三种 forkserver:
mp.set_start_method('forkserver')
机制:启动一个轻量的 server 进程,每次要 fork 子进程时由 server 来 fork(而不是从主进程)。好处:
- server 是一个新启动的干净解释器(不是从主进程 fork 出来的)→ 子进程不继承主进程的 CUDA context
- 比 spawn 快(仍然是 fork,不是从零启动)
- 比 fork 安全(不继承主进程复杂状态)
代价:dataset 仍要 picklable(与 spawn 一样),不能含 lambda。
三者的成本结构(而不是具体毫秒数)是这样的:fork 只做一次 fork(2),不重新 import 任何东西,最便宜;forkserver 也是 fork,但 fork 的源头是那个"干净的" server 进程,代价是要把参数 pickle 过去;spawn 每个子进程都要从零启动一个 Python 解释器并重新 import 你的整个依赖树(import torch 本身就不便宜),所以最贵,且启动开销随依赖规模增长。这就是为什么 persistent_workers=True 在 spawn 模式下的收益远大于 fork 模式。
选择建议:worker 不碰 CUDA 时,Linux 上用 fork 即可(Python 3.14+ 默认已是 forkserver,要 fork 得显式指定);worker 必须碰 CUDA 时,优先 forkserver、回退 spawn。Windows 上没有 fork;macOS 上 fork 虽可用但不安全,默认就是 spawn。
11.15 DataPipes 与 DataLoader2:新一代实验
PyTorch 自 v1.8 起在主仓 torch/utils/data/datapipes/ 里加入 DataPipes(更丰富的 DataPipe 库放在 torchdata 仓库),思路是把数据加载用函数式管道表达:
from torchdata.datapipes.iter import FileLister, FileOpener, Mapper
dp = FileLister("/data/imagenet")
dp = dp.shuffle(buffer_size=10000)
dp = dp.sharding_filter() # 按 worker / rank 分片
dp = FileOpener(dp, mode="b")
dp = dp.map(decode_image) # 每条样本应用 decode_image
dp = dp.batch(batch_size=32)
dp = dp.collate()
每个 DataPipe 都是 lazy iterator。组合起来比 class MyDataset(IterableDataset) 更可读、复用度更高。
DataLoader2 是新版加载器(torchdata 中的版本;下面是它的用法),把 reading service(如 prefetching、distributed sharding)从 dataset 抽离:
loader = DataLoader2(
datapipe=dp,
reading_service=MultiProcessingReadingService(num_workers=4)
)
reading service 负责 worker 池、prefetching;datapipe 负责数据流水。职责分离让用户能更灵活组合(如换成 DistributedReadingService 一行切到 DDP 模式)。
实战现状要说清楚:这条路线并没有成为主流。到 v2.11,torch/utils/data/datapipes/ 仍在 PyTorch 主仓里(torch/utils/data/datapipes/datapipe.py 留着按名字发 _deprecation_warning 的钩子,但 torch/utils/data/datapipes/utils/common.py 里的 _iter_deprecated_functional_names / _map_deprecated_functional_names 两张表是空的);DataLoader2 曾以实验形式放在主仓的 torch.utils.data.dataloader_experimental 模块里(v1.10–v1.13),v2.0 起从主仓移除,此后只在 torchdata 里,而 torchdata 后来把重心转向了 StatefulDataLoader。
结论很直接:生产代码用 DataLoader(v1)。DataPipes 值得了解的是它的思想 —— 把"读文件、分片、解码、组 batch"拆成一串可组合的 lazy iterator,这个思路在 webdataset 这类库里活得很好,只是没有以 DataPipes 的形式赢下 PyTorch 生态。
11.16 timeout:worker 健康检查
DataLoader(timeout=N) 让 next(iter(loader)) 等 N 秒拿不到数据就抛 RuntimeError,避免训练 hang 在数据加载上。机制:
- 主进程从
data_queue.get(timeout=N)等待 - 超时后检查每个 worker 是否还活着(
w.is_alive(),见torch/utils/data/dataloader.py的_try_get_data) - 如果有 worker 死了 → raise
RuntimeError("DataLoader worker (pid(s) xxx) exited unexpectedly")(非 Windows 平台上 SIGCHLD handler 通常更早发现,死因会作为它的 direct cause 出现,见 §11.27) - 如果都活着但仍超时 → raise
RuntimeError("DataLoader timed out after {timeout} seconds")
默认 timeout=0 表示无限等待。生产代码建议设个保险值(如 60 秒),避免 NFS 卡死、worker silently 卡住等场景让训练 hang 几小时。
MP_STATUS_CHECK_INTERVAL 定义在 torch/utils/data/_utils/__init__.py:19,值是 5.0 秒。它被用作 worker 内部 index_queue.get(timeout=...)(torch/utils/data/_utils/worker.py:318)与 pin 线程 in_queue.get(timeout=...) 的超时 —— 每 5 秒醒一次检查"是否被叫退出",让 shutdown 不至于卡在阻塞的 get 上。
11.17 跨进程 tensor 传输:共享内存绕开 pickle
worker 把 batch 发回主进程,不是 pickle 整个 tensor 再 send。torch/multiprocessing/reductions.py 注册了 tensor 的特殊序列化逻辑:
graph LR
W[worker 进程<br/>collate 出 batch tensor]
W --> SH[把 tensor 数据放到<br/>shared memory file]
W --> META[把 metadata<br/>shape/dtype/storage_handle<br/>送到 queue]
META --> Q[multiprocessing.Queue]
Q --> M[主进程拿到 metadata]
M --> RB[根据 storage_handle 重建<br/>指向同一块 shm 的 tensor]
SH -.同一块物理内存.-> RB
style SH fill:#fef3c7,stroke:#f59e0b
style RB fill:#dbeafe,stroke:#3b82f6
机制:
- worker 里的 batch 张量直接落在共享内存:
default_collate在 worker 里把样本直接 stack 进_new_shared分配的共享存储(torch/utils/data/_utils/collate.py的collate_tensor_fn);其余张量在被 pickle 进队列时由torch/multiprocessing/reductions.py挪进共享内存。句柄怎么传由set_sharing_strategy决定:Linux 默认'file_descriptor',另一种是'file_system' - 传输:只把 storage 的 file handle / fd 通过 queue 送过去(几十字节)
- 主进程:用 file handle 打开同一块 shm,重建 tensor —— 零拷贝
为什么不直接 pickle?一个 ImageNet batch(256×3×224×224 的 fp32 就约 154MB)若走 pickle,要整块序列化、经管道写过去、再反序列化;共享内存只传一个句柄,传输开销基本与 batch 大小无关。
代价:每次创建的 shm 段都要在数据消费后释放。torch.multiprocessing 用引用计数自动清理,但进程被 SIGKILL(比如 OOM killer)打死时来不及跑清理,可能在 /dev/shm 下留下 torch_* 残留(主要是 file_system 策略;Linux 默认的 file_descriptor 策略在创建 shm 文件后立即 unlink,只靠 fd 保活)—— 这些是普通文件,rm 掉即可,不需要重启。另外 /dev/shm 在容器里默认往往只有 64MB,跑 DataLoader 时经常要用 --shm-size 调大,否则会撞上 "Bus error"。生产监控值得加一条 /dev/shm 占用告警。
11.18 Fetcher:dataset 与 sampler 的中间层
§11.5 主循环里的 fetcher.fetch(index) 是什么?源码 torch/utils/data/_utils/fetch.py:
class _MapDatasetFetcher(_BaseDatasetFetcher):
def fetch(self, possibly_batched_index):
if self.auto_collation:
if hasattr(self.dataset, "__getitems__") and self.dataset.__getitems__:
data = self.dataset.__getitems__(possibly_batched_index) # 批量读取钩子
else:
data = [self.dataset[idx] for idx in possibly_batched_index]
else:
data = self.dataset[possibly_batched_index]
return self.collate_fn(data)
class _IterableDatasetFetcher(_BaseDatasetFetcher):
def __init__(self, dataset, auto_collation, collate_fn, drop_last):
super().__init__(dataset, auto_collation, collate_fn, drop_last)
self.dataset_iter = iter(dataset)
self.ended = False
def fetch(self, possibly_batched_index):
if self.ended:
raise StopIteration
# IterableDataset 没有 __getitem__, 只能 next()
if self.auto_collation:
data = []
for _ in possibly_batched_index:
try:
data.append(next(self.dataset_iter))
except StopIteration:
self.ended = True
break
if len(data) == 0 or (
self.drop_last and len(data) < len(possibly_batched_index)
):
raise StopIteration # worker 捕获后发 _IterableDatasetStopIteration
else:
data = next(self.dataset_iter)
return self.collate_fn(data)
两种 fetcher 反映 Map vs Iterable 的本质差异:
| 特性 | _MapDatasetFetcher | _IterableDatasetFetcher |
|---|---|---|
| index 来源 | 主进程 sampler 推送 | dataset 自己迭代 |
| 是否能随机访问 | 是(__getitem__) |
否(仅 next) |
| BatchSampler 作用 | 把单个 idx 打成 batch | 仅决定 batch 大小 |
| StopIteration | 不会发生 | 数据耗尽时触发 |
理解 Fetcher 让你看到 DataLoader 一套 worker 主循环代码能同时处理两类 dataset 的工程巧思 —— 通过抽象 fetch 接口屏蔽差异。
11.19 Sample 数据类型谱与 default_collate
default_collate 处理 batch 合并的核心逻辑(torch/utils/data/_utils/collate.py:340;v2.11 里它其实是薄薄一层,真正的分派表在同文件的 default_collate_fn_map 里,下面按语义展开成 if-else 便于阅读):
def default_collate(batch):
elem = batch[0]
elem_type = type(elem)
if isinstance(elem, torch.Tensor):
return torch.stack(batch, 0)
elif elem_type.__module__ == "numpy" and ...:
return torch.as_tensor(np.stack(batch))
elif isinstance(elem, float):
return torch.tensor(batch, dtype=torch.float64)
elif isinstance(elem, int):
return torch.tensor(batch)
elif isinstance(elem, str):
return batch # 字符串保持原样
elif isinstance(elem, collections.abc.Mapping):
return {key: default_collate([d[key] for d in batch]) for key in elem}
elif isinstance(elem, tuple) and hasattr(elem, "_fields"):
return elem_type(*(default_collate(samples) for samples in zip(*batch)))
elif isinstance(elem, collections.abc.Sequence):
return [default_collate(samples) for samples in zip(*batch)]
raise TypeError(default_collate_err_msg_format.format(elem_type))
设计要点:
- 递归处理嵌套结构:
{"image": tensor, "label": int, "meta": {"path": str}}→{"image": stacked_tensor, "label": int_tensor, "meta": {"path": [strs]}} - 保持 namedtuple 类型:自定义 namedtuple 输入仍输出同类型,不退化成 tuple
- str 不堆叠:字符串作为 batch metadata,保留 list
如果你的 dataset 返回了 default_collate 不认识的类型(如自定义 dataclass),就会触发 TypeError。要么写自定义 collate_fn、要么把数据转成 dict / tuple / tensor。torch.utils.data.default_collate 本身可以被 monkey-patch 但不推荐 —— 影响其他代码的行为。
11.20 _try_put_index:prefetching 的具体实现
§11.8 讲 prefetch_factor。具体怎么实现?看 dataloader.py 的 _MultiProcessingDataLoaderIter._try_put_index:
# torch/utils/data/dataloader.py:1550
def _try_put_index(self) -> None:
max_tasks = self._prefetch_factor * self._num_workers
if self._tasks_outstanding >= max_tasks:
raise AssertionError("Number of outstanding tasks exceeded maximum allowed tasks")
try:
index = self._next_index() # sampler 给的下一个 batch idx
except StopIteration:
return
for _ in range(self._num_workers): # find the next active worker, if any
worker_queue_idx = next(self._worker_queue_idx_cycle)
if self._workers_status[worker_queue_idx]:
if self._in_order:
break
elif self._workers_num_tasks[worker_queue_idx] < max_tasks // sum(self._workers_status):
# when self._in_order is False, distribute work to a worker if it has capacity
break
else:
return # 没找到可派的 worker(都已耗尽,或 in_order=False 时都满额), 不再发任务
self._index_queues[worker_queue_idx].put((self._send_idx, index))
self._task_info[self._send_idx] = (worker_queue_idx,)
self._workers_num_tasks[worker_queue_idx] += 1
self._tasks_outstanding += 1
self._send_idx += 1
关键逻辑:
_tasks_outstanding跟踪有多少任务在 worker 那里 pending。上限 =prefetch_factor × num_workers- 轮转分配:用
itertools.cycle造的_worker_queue_idx_cycle把任务依次派给各 worker - 跳过死掉的 worker:检查
_workers_status[i];一圈都没找到活着的就直接返回 _in_order分支:DataLoader(in_order=False)时不再严格轮转,而是看_workers_num_tasks[i]是否还有余量,让快的 worker 多干活(代价是 batch 返回顺序不再确定)- 每次 next() 拿到 batch 后立刻 put 一个新 index:维持 outstanding 任务数稳定
启动时主进程会预先 put 满 prefetch_factor * num_workers 个任务。后续每消费一个 batch 就 put 一个新任务,让 queue 始终满 —— 这是 sustained throughput 的关键。
这个机制的极致:prefetch_factor=4, num_workers=8 时主进程预 put 32 个任务,worker 池始终有 32 个待执行 batch 在 pipeline 里。GPU 训练慢一点 worker 就能打满;GPU 训练快了 worker 就成瓶颈 —— outstanding 数是 GPU/CPU 速率匹配的关键参数。
11.21 Pin memory 的页锁定原理
§11.6 提到 pinned memory 让 H2D 可以 async + 跳 page fault。底层是怎么实现的?
正常 malloc 出的内存是可被换页(pageable):操作系统可能把这块内存换到 swap 区。CUDA driver 做 H2D 拷贝时:
- driver 不能直接 DMA 到可换页内存(DMA 控制器不知道虚拟地址,OS 也可能在拷贝时换页)
- driver 必须先把数据拷进自己持有的一块锁页暂存缓冲区(staging buffer),再从那里 DMA 到显存
- 这条"先暂存再 DMA"的路径对 host 是 同步阻塞 的,CPU 必须等
tensor.pin_memory() 经 CachingHostAllocator(aten/src/ATen/cuda/CachingHostAllocator.cpp)默认调 cudaHostAlloc(可配置为 cudaHostRegister)申请不会被换页的内存:
- driver 知道这块内存物理地址固定 → 可以 setup DMA 后直接返回
- CPU 立刻继续做别的事,DMA 在后台跑
- 这就是为什么"
pin_memory + non_blocking=True"才能 async
代价:pinned memory 不能被换出,占多了会挤压系统其余部分可用的物理内存。开太多 pin_memory 会失败、或拖累系统其他进程。DataLoader 默认只对 batch tensor pin,不对整个 dataset pin。
11.22 worker 内存增长:fork CoW 失效问题
Linux fork 用 Copy-on-Write:fork 时父子进程共享物理内存页,只在写入时复制。理论上 worker 启动几乎不占新内存。
但有一个坑:CPython 的引用计数会写入对象的 ob_refcnt。worker 即使只是"读"一个 Python 对象(如遍历一个 list),也会修改它的 refcnt → 触发 CoW → 该内存页整个被复制。
实战后果:dataset 里 cache 了大量 Python 对象(如 1.28M 个文件路径 list),worker 启动后内存看起来正常,跑一段时间后 worker 内存爆炸 —— 全部从父进程 CoW 过来了。
解决方法:
- 把 cache 数据用 numpy / tensor 存(refcnt 操作不会蔓延到数据本身)
- 用 spawn 启动:避免 CoW 误伤(虽然启动慢)
gc.freeze()(Python 3.7+):在 fork 前把现有对象移进永久代,GC 不再扫描它们,避免 GC 自己写对象头触发 CoW;但引用计数照样会变,只能缓解、治不了本
PyTorch 文档(docs/source/data.md 的 Multi-process data loading 一节)明确建议把这类 Python 对象换成 Pandas / NumPy / PyArrow 这种不带逐元素引用计数的表示。这是把"操作系统 + Python 实现"的细节落到使用建议的典型例子。
11.23 训练 resume:sampler 状态的保存与恢复
长跑训练(几天几周)必须支持中断恢复。光保存 model + optimizer state 不够 —— dataloader 进度也要保存,否则恢复时从头开始遍历 dataset。
这里要先纠正一个流传很广的误解:PyTorch 核心库里的 Sampler 和 DataLoader 到 v2.11 都没有 state_dict() / load_state_dict()。在 torch/utils/data/sampler.py、distributed.py、dataloader.py 里 grep state_dict 是零结果。所以下面这种写法在原生 PyTorch 上会直接 AttributeError:
checkpoint["sampler"] = sampler.state_dict() # ✗ 原生 Sampler 没有这个方法
原生能做到的只有最粗的一档:按 epoch 粒度恢复。保存 epoch 与 step,恢复时 sampler.set_epoch(epoch) 让 shuffle 顺序对上,再手动跳过前面若干个 batch:
loader.sampler.set_epoch(ckpt["epoch"])
it = iter(loader)
for _ in range(ckpt["step_in_epoch"]):
next(it) # 快进;数据仍然被真读了一遍,只是丢掉
这个办法正确但笨 —— 跳过的样本仍然要走完整的读盘 + transform。真正的解法在 torchdata 的 StatefulDataLoader:它是 DataLoader 的 drop-in 替代,多出 state_dict() / load_state_dict(),机制是让每个 worker 把自己的进度随数据一起报告回主进程,主进程汇总成一份可序列化的状态。要做 batch 级精确 resume,就得引入这个额外依赖。
实战判断:长跑训练值得做 dataloader resume,但先想清楚代价 —— 如果 epoch 本身只有几十分钟,用 set_epoch + 快进这一档往往就够了。
11.24 一次 step 的端到端时序
把所有机制合起来看 一次 for batch in loader: 实际发生了什么:
sequenceDiagram
participant T as 主进程训练循环
participant Q as output queue
participant P as pin_memory thread
participant W as worker 子进程
participant DS as Dataset
T->>+Q: next() 拿 batch N
Q-->>T: 已有 batch N (pin 完成)
T->>T: model(batch).backward()<br/>optim.step()
Note over T: GPU 训练 step N 进行中
par 同时, worker 在准备 batch N+1
W->>+DS: dataset[idx]<br/>(磁盘读 + decode + augment)
DS-->>-W: tensor
W->>P: data_queue.put((idx, tensor))
P->>P: tensor.pin_memory()
P->>Q: output_queue.put((idx, pinned))
and 主进程做 prefetch
T->>W: index_queue.put(idx N+M)
end
T->>Q: 训练完, next() 拿 batch N+1
Q-->>T: 已有 batch N+1
整套机制让 worker 数据准备 + pin memory + 主进程 GPU 训练构成 三阶段流水线。理想状态下三个阶段落在不同硬件上并发:worker 吃 CPU、pin 后的 H2D 走 PCIe DMA、训练吃 GPU。流水线打满时 GPU 端不会出现等数据的空窗 —— 能不能打满,取决于最慢的那一级。
这张时序图是 §11.37 工程经验的根本依据 —— 任何一个阶段慢了都会让 pipeline stall:
- worker 慢 → output queue 空 → 主进程 stall
- pin thread 慢(很少见)→ 数据卡在中间
- 主进程慢 → output queue 满 → worker 阻塞在 put
诊断瓶颈的标准方法:用 torch.profiler 看主进程的 "data fetching" 耗时占比、用 py-spy 看每个 worker 的 CPU 利用率。两边数据合起来精准定位。
11.25 DataLoader × 混合精度训练
第 20 章会讲 mixed precision(FP16/BF16)。DataLoader 这层有什么协作?
直觉做法是 worker 直接返回 fp16 tensor 节省传输带宽(pin 内存量减半)。但实战不推荐:
- transform 在 fp16 下精度损失大(特别是图像归一化)
- pin → cuda 后再 cast 到 fp16 比 worker 内 cast 更可控
- worker 跑 transform 时 fp16 的算子反而可能比 fp32 慢(多数 CPU 上 fp16 没有原生加速)
推荐做法:worker 输出 fp32 tensor,主进程 .cuda() 之后再 tensor.to(torch.float16)。这样传输量是 fp16 方案的 2 倍,但只要 PCIe 带宽还没吃满,这部分开销就能被计算 overlap 掉。
例外场景:图像训练 + 大 batch,PCIe 真的成了瓶颈。这时更划算的不是让 worker 输出 fp16,而是让它输出 uint8(每像素 1 字节,传输量只有 fp32 的 1/4),normalize 与 cast 全部挪到 GPU 上用一个 kernel 做完。NVIDIA DALI 走得更远,连 JPEG 解码也放到了 GPU 上。要不要这么做,先量一下 nvidia-smi dmon 里的 PCIe 收发带宽有没有接近卡的规格上限。
11.26 variable batch size 与 max_tokens
NLP 训练常见诉求:按 token 数构造 batch(变长序列)而非按 sample 数。让短句多凑、长句少凑、每 batch 总 token 数稳定。
实现方式:自定义 BatchSampler:
class TokenBudgetSampler(Sampler):
def __init__(self, lengths, max_tokens=4096):
self.lengths = lengths # 每个 sample 的 token 数
self.max_tokens = max_tokens
def __iter__(self):
# 按长度排序后, 滚动凑满 max_tokens
sorted_idx = sorted(range(len(self.lengths)), key=lambda i: self.lengths[i])
batch = []
max_len_in_batch = 0
for idx in sorted_idx:
new_max = max(max_len_in_batch, self.lengths[idx])
new_total = new_max * (len(batch) + 1)
if new_total > self.max_tokens and batch:
yield batch
batch = [idx]
max_len_in_batch = self.lengths[idx]
else:
batch.append(idx)
max_len_in_batch = new_max
if batch:
yield batch
这套 sampler 的收益来自消除 padding 浪费:固定 batch_size 时一个长句会把整批 padding 到它的长度,被算掉的 padding token 全是白算的算力;按 token 预算成批之后,每批的有效 token 占比稳定得多。省下多少完全取决于你的长度分布有多长尾。fairseq、DeepSpeed 等训练库都内置了类似实现。理解 PyTorch 的 BatchSampler 接口让你看到这些上层框架是怎么实现的 —— 没有黑魔法,就是自定义 sampler + collate。
11.27 常见报错信息逐条拆解
生产中常见的 DataLoader 报错:
1. RuntimeError: DataLoader worker (pid xxx) is killed by signal: Killed.
(Linux 上它由主进程的 SIGCHLD handler 抛出,traceback 里紧接着还会出现第 2 条的 exited unexpectedly,以它为 direct cause。)worker 被 SIGKILL 杀掉,最常见的是 OOM killer。通常是 dataset / transform 内存爆。检查:
- 单个 sample 是否占内存过大(如 4K 图片解码到 numpy)
- 是否在
__getitem__里 cache 了不释放的对象 - num_workers 是否过多(每个 worker 有独立内存)
2. RuntimeError: DataLoader worker (pid(s) xxx) exited unexpectedly
这是 worker 以任何方式死掉后主进程最终抛出的那一条,真正的死因看 traceback 里它上面的 direct cause:is killed by signal: Segmentation fault.(段错误)、is killed by signal: Killed.(第 1 条),或 exited unexpectedly with exit code N(worker 以非零码退出)。Windows 没有 SIGCHLD,只能看到这一条。段错误与异常退出通常是 C 扩展 bug:
- OpenCV
cv2.imread偶发 segfault(特定图片格式) - numba JIT 在 worker 里编译失败
- HDF5 跨进程读取的线程问题
排查:用 num_workers=0 跑相同代码看是否报同样错;或单独 import 触发 segfault 的库到主进程隔离测试。
3. RuntimeError: Cannot re-initialize CUDA in forked subprocess
§11.10 提过:fork 启动 + CUDA 不兼容。解法:mp.set_start_method('spawn') 或 'forkserver'。
4. _pickle.PicklingError: Can't pickle <function <lambda> at 0x...>: attribute lookup <lambda> on __main__ failed
(Python 3.10 实测措辞,其他版本略有差异;DataLoader 还会先发一条 "Got pickle error when attempting to start a worker Process" 的 UserWarning。)spawn / forkserver 启动需要 dataset / collate_fn / worker_init_fn 等参数 picklable。把 lambda 改成命名函数:
# 不行
loader = DataLoader(ds, collate_fn=lambda batch: ...)
# 改为
def my_collate(batch): ...
loader = DataLoader(ds, collate_fn=my_collate)
5. RuntimeError: received 0 items of ancdata
Linux 上多进程 + 大量 file descriptor 共享,触发 ulimit 限制。解法:ulimit -n 65536 提高上限,或 torch.multiprocessing.set_sharing_strategy('file_system') 切到 file system 共享(不用 fd)。
6. RuntimeError: generator raised StopIteration
IterableDataset 的 __iter__ 写成生成器时,里面直接 next(inner_iter) 耗尽了内层迭代器,StopIteration 会按 PEP 479 被改写成这个 RuntimeError,经 ExceptionWrapper 在主进程以 Caught RuntimeError in DataLoader worker process 0. 抛出。生成器里提前 return 是安全的(worker 正常收到 StopIteration、发 _IterableDatasetStopIteration 通知主进程);修复是用 for x in inner_iter: yield x 或捕获 StopIteration 后 return,而不是让它从生成器里漏出去。
逐条理解这些错误能覆盖大部分常见的 DataLoader 报错。生产代码遇到上面任意一个先按这个清单排查。
11.28 N×M 分片:DDP × 多 worker 的合作
第 17 章 DDP 章会展开,这里先给"分片合作"的全景:
graph TB
DS[全局 dataset<br/>1.28M sample]
DS --> R0[rank 0 分片<br/>320K sample]
DS --> R1[rank 1 分片<br/>320K sample]
DS --> R2[rank 2 分片<br/>320K sample]
DS --> R3[rank 3 分片<br/>320K sample]
R0 --> W00[worker 0-0]
R0 --> W01[worker 0-1]
R0 --> W02[worker 0-2]
R0 --> W03[worker 0-3]
R1 --> W10[...]
style DS fill:#fef3c7,stroke:#f59e0b
两层分片机制:
- 第一层 DistributedSampler:rank i 拿
[i, i+N, i+2N, ...]索引(§11.13) - 第二层 worker 派发:rank i 的主进程把本 rank 的 batch 按 round-robin 派给 M 个 worker(map-style 由 DataLoader 自动完成,§11.20;IterableDataset 要自己按
get_worker_info()切,§11.12)
合起来:4 rank × 4 worker = 16 个进程并行处理全局 dataset,互不重复。
特殊情况:IterableDataset + DDP。第一层不能直接套 DistributedSampler(IterableDataset 没 __getitem__),要在 dataset 内部按 (rank, num_workers) 双重分片:
class StreamDataset(IterableDataset):
def __iter__(self):
worker_info = torch.utils.data.get_worker_info()
rank = dist.get_rank() if dist.is_initialized() else 0
world = dist.get_world_size() if dist.is_initialized() else 1
n_workers = worker_info.num_workers if worker_info else 1
worker_id = worker_info.id if worker_info else 0
# 全局有 world * n_workers 个数据消费者
global_id = rank * n_workers + worker_id
global_n = world * n_workers
files = self.files[global_id::global_n]
...
这是流式数据训练(webdataset、kafka 流)必写的分片模板。理解它让你跑 DDP 时数据不会重复或遗漏。
11.29 ManagerWatchdog:父子进程死亡感知
ManagerWatchdog 在 torch/utils/data/_utils/worker.py 里按平台定义了两份:Windows 版在 line 30(用 kernel32.OpenProcess + WaitForSingleObject 等父进程句柄),非 Windows 版在 line 62,运行时实例化在 line 314。下面是非 Windows 版的完整实现:
class ManagerWatchdog:
def __init__(self) -> None:
self.manager_pid = os.getppid()
self.manager_dead = False
def is_alive(self) -> bool:
if not self.manager_dead:
self.manager_dead = os.getppid() != self.manager_pid
return not self.manager_dead
机制:worker 启动时记下父进程 PID(manager_pid)。每次 worker 主循环迭代检查 os.getppid(),如果不等于启动时记的 PID → 父进程已死、worker 自己也该退出。
为什么这样可以检测父死亡?Linux 下父进程死后子进程会被 init(PID 1,或最近的 subreaper)收养—— getppid() 返回值改变就是父死亡信号。比 signal handler 更鲁棒(信号可能被 mask、可能竞争)。
这里要澄清一个常见的张冠李戴:PyTorch 确实封装了 prctl(PR_SET_PDEATHSIG, ...)(torch/csrc/multiprocessing/init.cpp:34 的 _prctl_pr_set_pdeathsig),但它的调用点是 torch/multiprocessing/spawn.py:84,用于 mp.spawn 拉起的进程(比如 DDP 的多进程启动),传的信号是 SIGINT 而不是 SIGKILL。DataLoader 的 worker 走的不是这条路,它靠的就是上面这个轮询 getppid() 的 watchdog,加上 §11.11 讲的那套 shutdown 协议。
理解这个机制也就理解了它的边界:watchdog 是轮询的,只在每次主循环迭代(即每次 index_queue.get 超时或返回)时检查一次,所以空闲的 worker 从父进程死亡到自己退出最长会隔一个 MP_STATUS_CHECK_INTERVAL(5 秒),正在 fetch 的 worker 则要等手上这批做完。这已经足够避免孤儿 worker 堆积,但不是"父死子立刻死"。
11.30 DataLoader 演进时间线
PyTorch DataLoader 的迭代轨迹:
与其按版本号排时间线(这些日期很容易记串),不如看 v2.11 的 DataLoader.__init__ 签名本身 —— 每个参数都是某一轮生产反馈留下的年轮:
| 参数 | 解决的问题 | 本章位置 |
|---|---|---|
num_workers |
单进程读数据跟不上 GPU | §11.4 |
pin_memory / pin_memory_device |
H2D 拷贝无法异步(pin_memory_device 在 v2.11 已标为 deprecated,实际用当前 accelerator) |
§11.6、§11.21 |
persistent_workers |
每个 epoch 重启 worker 的固定开销 | §11.7 |
prefetch_factor |
缓冲深度需要按 IO 抖动调 | §11.8 |
timeout |
worker 卡死时训练无限 hang | §11.16 |
multiprocessing_context |
fork / spawn / forkserver 的平台与 CUDA 差异 | §11.10、§11.14 |
in_order |
严格轮转导致快 worker 被慢 worker 拖住 | §11.20 |
整体趋势很清楚:v1.x 阶段在磨多进程的鲁棒性(错误处理、shutdown 协议、CoW、信号处理),v2.x 阶段在磨吞吐(如 v2.6 加入的 in_order 乱序返回;stateful resume 则留在 torchdata 的 StatefulDataLoader,没进主仓)。每个参数背后都是一类真实踩过的坑,而不是设计时就想全的。
11.31 DataLoader vs DALI vs FFCV
NVIDIA DALI 与 FFCV 是 ImageNet 训练里常被提到的"替代品"。它们与 PyTorch DataLoader 的核心区别:
| 维度 | PyTorch DataLoader | NVIDIA DALI | FFCV |
|---|---|---|---|
| transform 跑在哪 | CPU(worker 进程) | GPU(CUDA kernel) | CPU(精心优化的 SIMD) |
| 数据格式 | 任意(用户写 Dataset) | jpg / mp4 / TFRecord | 自家 .beton 格式(预处理) |
| 集成成本 | 零 | 中(需重写 pipeline) | 中(需先转 .beton) |
| 主要收益来源 | — | 把解码与 augment 从 CPU 挪到 GPU | 预处理成紧凑二进制,消除解码与随机 IO |
| 灵活性 | 最高 | 中 | 低(需预处理) |
何时选谁:
- 多数训练:PyTorch DataLoader(足够好、生态最广)
- ImageNet / 视频训练 + GPU 富裕:DALI(GPU 解码 + augment 让 CPU 不再是瓶颈)
- 极致 throughput + 数据格式可控:FFCV(预处理过的二进制让 IO 接近 PCIe 极限)
PyTorch DataLoader 的核心优势是通用:Dataset 里能写任意 Python,代价是 transform 只能吃 CPU。DALI 与 FFCV 各自用"换执行位置"和"换数据格式"来绕开这个瓶颈,代价是灵活性 —— DALI 的 pipeline 要用它自己的算子表达,塞不进任意 Python 逻辑;FFCV 要求先把数据集离线转换一遍。
判断要不要切的标准只有一个:先确认 CPU 侧真的是瓶颈(§11.33 的 profiling 流程)。如果 GPU 利用率本来就接近满,换任何数据加载库都不会更快。
11.32 一段总结:DataLoader 是个并行编排引擎
把全章看到的拼起来:DataLoader 表面是"数据加载工具",骨子里是个并行编排引擎:
- worker pool(进程级并发)
- pin_memory thread(线程级 + DMA 异步)
- index queue / output queue(生产消费解耦)
- sampler 状态机(next index 决定)
- ManagerWatchdog(父进程死亡感知)
- ExceptionWrapper(异常跨进程传播)
- shared memory storage(零拷贝传输)
PyTorch 把这些工程零件封装在一个 1700 来行的文件(torch/utils/data/dataloader.py)里,对用户暴露的就是 for batch in loader。理解每个零件的存在意义、能让你在这个抽象层之外做高级优化(如自己写 streaming pipeline)也不会丢核心思想。
11.33 DataLoader 性能 profiling 实战
诊断 DataLoader 是否成为瓶颈的标准方法,按精度从粗到细:
第 1 步:nvidia-smi 看 GPU 利用率
watch -n 0.5 nvidia-smi --query-gpu=utilization.gpu --format=csv
如果数字反复跳 0% / 100% / 0% / 100%(sawtooth pattern)→ GPU 在等数据。
第 2 步:看主进程是不是 CPU 瓶颈
top -p $(pgrep -f train.py | head -1)
主进程 CPU > 90% 说明它自己处理数据成了瓶颈(如做大量 .cuda() 调用)。
第 3 步:torch.profiler 量化 data loading 耗时
with torch.profiler.profile(
activities=[torch.profiler.ProfilerActivity.CPU],
schedule=torch.profiler.schedule(wait=1, warmup=1, active=5),
on_trace_ready=torch.profiler.tensorboard_trace_handler('./log')
) as prof:
for batch in loader:
with torch.profiler.record_function("training_step"):
train_step(batch)
prof.step()
打开 TensorBoard / Perfetto 看 trace,"enumerate(DataLoader)#_MultiProcessingDataLoaderIter.__next__"(拿 batch,num_workers=0 时类名是 _SingleProcessDataLoaderIter)耗时与 "training_step" 耗时对比(下面的阈值是经验值):
- 拿 batch 耗时 > 训练耗时 50% → DataLoader 瓶颈
- 拿 batch 耗时 < 5% → DataLoader 不是瓶颈,找别处
第 4 步:py-spy 看 worker 在干啥
py-spy dump --pid <worker_pid>
看 worker stack:
- 卡在
read()/decode_jpeg()→ IO 或 decode 瓶颈 - 卡在
pickle.loads→ 数据格式问题(用了大量 Python 对象) - 卡在
numpy函数 → CPU augment 慢
第 5 步:profile transform
from time import perf_counter
def timed_getitem(self, idx):
t0 = perf_counter()
sample = self.original_getitem(idx)
print(f"getitem took {(perf_counter()-t0)*1000:.1f}ms")
return sample
逐层定位到具体瓶颈,再针对性优化(换库、加 num_workers、预处理到磁盘等)。
这套从粗到细的流程的价值在于每一步都能排除掉一大类可能,避免一上来就盲调 num_workers 碰运气。GPU 是训练里最贵的一项资源,让它闲着等数据就是直接浪费预算。
11.34 transform 库性能基准
ImageNet 训练里一张图的标准 pipeline:"JPEG 解码 → resize → crop → normalize → tensor"。不同库实现 5 步的耗时差距巨大:
| 库 | 主要差异 | 适用场景 |
|---|---|---|
| PIL(torchvision 默认后端) | 纯标量 C 实现,功能最全 | 兼容性优先 |
| PIL-SIMD | PIL 的 SIMD fork,API 完全兼容 | drop-in 替换,零代码改动 |
| OpenCV (cv2) | 解码与几何变换有 SIMD/多线程实现 | 比 PIL 快,功能面更广 |
| Albumentations | 基于 cv2,augment 链本身也做了优化 | augment 种类多、链路长时 |
| torchvision.transforms.v2 | 支持在张量上批量变换、可跑在 GPU 上 | 从 v1 迁移成本最低 |
| NVIDIA DALI | 解码与 augment 都在 GPU | CPU 是瓶颈、GPU 有余量时 |
| FFCV | 离线转成 .beton,运行时几乎不解码 | 数据集固定、长跑训练 |
升级路径建议(改动成本递增):
torchvision.transforms→transforms.v2:接口基本兼容,还能把变换挪到张量/GPU 上做- PIL → PIL-SIMD:
pip install pillow-simd,零代码改动 torchvision→Albumentations:要重写 transform 链,换来更多 augment 选择- CPU augment → DALI:要重写整条 pipeline,CPU 瓶颈严重时收益最大
- 运行时 augment → FFCV 预处理:先离线转一次数据集,长跑训练里摊销掉
这张表刻意不给具体毫秒数:单图耗时高度依赖图片分辨率、JPEG 编码参数、CPU 型号与线程配置,任何抄来的数字换台机器就不成立。正确做法是在自己的数据上跑一段 §11.33 第 5 步那样的计时,再决定升级到哪一档。
11.35 远程存储训练:NFS / S3 / 对象存储的特殊问题
云上训练 dataset 常放远程存储(NFS、Lustre、S3、GCS)。DataLoader 这层的注意点:
NFS / Lustre:
- worker 起来时每个进程都打开同一文件会触发"client cache invalidation storm" → 训练前几秒卡顿
- 解决:用
os.O_DIRECT跳过 client cache,或预热(训练前用cat dataset/* > /dev/null让 server 端加载) - 文件元信息查询慢(
os.path.getsize等),把 dataset 元数据预先存成 numpy / parquet 避免逐文件 stat
S3 / GCS / OSS(对象存储):
- 单个 GET 请求的延迟远高于本地顺序读
- num_workers 与 prefetch_factor 都要比本地盘时开大得多(具体值按实测调)才能 hide 延迟
- 用
s3fs或aiobotocore的并发下载,单 batch 内多文件并行 GET - 大数据集应该预 shard 成 webdataset / TFRecord 格式(每个文件几百 MB,少 GET 多吞吐)
三类存储的瓶颈性质不同,这决定了该怎么调:
| 存储 | 瓶颈性质 | 对应手段 |
|---|---|---|
| 本地 NVMe | 带宽有限、延迟极低 | worker 数按 CPU 核数配即可 |
| NFS / Lustre | 元数据操作慢,小文件放大 | 预热 + 元数据预先离线化 + 合并小文件 |
| S3 / GCS / OSS | 单请求延迟高,但可并发度很高 | 加大 num_workers × prefetch_factor 用并发盖延迟 + 预 shard 成大文件 |
关键区别在最后一行:对象存储的问题是延迟不是带宽,而延迟可以用并发掩盖 —— 这正是 num_workers × prefetch_factor 这两个旋钮的用武之地。所以远程存储不是 DataLoader 的死穴,但默认参数(num_workers=0;开了 worker 后 prefetch_factor 默认 2)几乎一定跑不动,必须按上表调。
11.36 一道真实排查题
最后看一类反复出现的现象:DDP 训练、已开 persistent_workers=True,第一个 epoch 的 GPU 利用率明显偏低,从第二个 epoch 起就上去了。怎么解释?
诊断思路:第一与后续 epoch 的差异。能想到的原因:
- persistent_workers=False:每个 epoch worker 重启 → 但已经开了 persistent,排除
- OS file cache 预热:第一 epoch 数据全从磁盘读 → 后续从 OS page cache 读。这是最常见原因
- CUDA kernel 预热:第一次见到的 kernel 形状要 JIT compile / autotune → 第一 batch 慢。但只影响开头,不解释整个 epoch
最常见的原因是 OS file cache 预热:只要 dataset 总体积明显小于机器空闲 RAM,第一个 epoch 就把它整个读进了 page cache,后续 epoch 实际是在从内存读。判断方法很简单 —— 看 free -m 的 buff/cache 列在第一个 epoch 里是不是一路涨到接近 dataset 大小。
修复方法:训练前 warmup(find /data -type f -exec cat {} > /dev/null \; 让 OS 提前缓存),或者降低 dataset 大小让缓存命中率高。这是远程存储或冷盘上训练的隐藏 quirk —— 看一眼 free -m 的 cached 列就能确认。
理解 DataLoader 的工作机制 + OS 文件系统行为,能让你解释训练曲线的细节、做出正确干预。这是一切性能调优的起点。
11.37 几条工程经验
实战 DataLoader 相关:
1. num_workers 经验值:通常设为 CPU 核心数的 1/2 到 1(如 16 核机器设 8-16)。再多就 thrashing
2. 监控 GPU 利用率:如果训练时 GPU 利用率明显偏低,先怀疑 DataLoader 跟不上。增加 num_workers 或 prefetch_factor
3. 第一次 epoch 慢:worker 初次启动 + 第一次 sample 触发 page fault。persistent_workers=True 后续 epoch 就快
4. pin_memory + non_blocking=True 必须配对:单开 pin_memory 但 cuda 调用没用 non_blocking,等同于没开
5. transform 应该在 worker 里跑:把 augment 写到 dataset 的 __getitem__ 里,而非主循环里。主循环只做 GPU 计算
6. NumPy in worker 的 seed 坑:fork 启动时 numpy 全局 RNG 在所有 worker 里相同(fork 复制了主进程的内存)。PyTorch 在 worker 启动时会 reseed numpy,但如果你在 worker 里 import 了某个用 np.random 的库,要确保它在 worker 启动之后 import
7. torch.utils.data.get_worker_info():在 dataset 内可以查当前 worker id / num_workers,用于"按 worker 分片"(IterableDataset 必备)
8. 子进程 dataset 状态独立:worker 是 fork 出来的,每个 worker 有独立的 dataset 副本。如果你想在 dataset 里 cache 某些计算结果(如解析 json),每个 worker 各自缓存一份,不共享
11.38 一个典型优化案例:DataLoader 跟不上 GPU
社区里反复出现的最经典 DataLoader 瓶颈案例(PyTorch forum / GitHub issue 反复讨论的问题模式):
症状:图像分类训练,GPU 利用率长期偏低
定位:
1. nvidia-smi -l 1 看到利用率呈 sawtooth(100% → 0% → 100%),是典型的 "GPU 等数据"
2. py-spy dump 到 worker,栈停在 PIL.Image.open / JPEG 解码里
3. 数一下:num_workers 远小于机器核数,且每个 worker 被 set_num_threads(1) 限成单线程
判断:
- 单个 batch 的准备时间 > 单个 step 的 GPU 计算时间,prefetch 队列被抽空
可选手段(按改动成本递增,逐个验证效果):
1. 调大 num_workers(上限是物理核数,见 §11.37 第 1 条)
2. 换更快的解码/变换后端(§11.34 的升级路径)
3. 离线预处理:把解码与 resize 提前做掉,运行时只读定长二进制
这种"先定位是哪一级最慢、再逐条验证"是 DataLoader 调优的标准流程。每一步都要用同一套指标复测,否则很容易把三个改动一起上,最后不知道是哪个起了作用。理解整章讲的"worker 池 + 三段 queue + pin_memory 线程"机制,就能知道每次该量哪一级。
11.39 跨专栏关联
- 《vLLM 推理内核深度解析》专栏第 17 章(API 服务器):vLLM 推理时的 batched request 处理与 DataLoader 的 batch 思想一致 —— 把多个独立请求合成一个 GPU 友好的 batch
- 《Tokio 源码深度解析》专栏第 13 章(channels:mpsc / broadcast / watch / oneshot):DataLoader 的 multiprocessing.Queue 是同步阻塞 channel,思想与 Tokio 的
mpsc::channel相通 —— 都是 producer-consumer 模式 - 《Rust 编译器与运行时揭秘》专栏第 17 章(增量编译):编译器的 dependency graph build 也用 worker pool + queue 处理独立任务
11.40 几条 DataLoader 设计的"通用启示"
把 DataLoader 思想抽象到任何"生产 / 消费数据流水线"系统:
第一:生产与消费独立 + queue 解耦:让两者不用互相等待。Queue 长度(prefetch_factor)调节缓冲深度
第二:worker pool 而非每次 fork:进程启动有不可忽略的固定成本,长跑任务必须 pool。persistent_workers 是这条原则的体现
第三:异常通过序列化跨进程传递:直接 throw 在多进程里没用,必须 wrap 成可序列化对象。ExceptionWrapper 是模板
第四:用 thread 处理需要主进程上下文的工作:pin_memory 用 thread 不用 process,因为它要访问 CUDA context
第五:RNG seed 每个 worker 不同 + 可复现:由主进程派发一个 base_seed、各 worker 从它派生自己的 seed,既保证不重复又保证可复现。注意派生方式要看底层 RNG 的脾气 —— PyTorch 对 torch/random 用简单相加,对 numpy 则专门做了哈希混合(§11.5.2)
第六:torch.set_num_threads(1) 防多 worker × 多 thread 抢核:任何 worker pool 都要决定每个 worker 自己开多少 thread。设错了性能崩盘
把这六条记住,写自己的多进程数据流水线(如 ETL 系统、爬虫、批处理任务)能避开几乎所有大坑。
下一章拆 TorchDynamo —— PyTorch 2.0 编译器栈的入口,看它怎么用 PEP 523 帧拦截 + 字节码分析把动态 Python 代码捕获成 FX Graph。