1. 为什么 dataloader 值得单独拎出来讲
搞过深度学习项目的人都有一个共识:模型结构再漂亮,如果数据管道没搭好,训练过程就是一场灾难。我见过太多人把 ResNet、Transformer 玩得飞起,结果卡在数据加载上——GPU 利用率常年 30% 以下,训练一个 epoch 要等半天,排查半天发现是 dataloader 的 num_workers 设成了 0。
torch.utils.data.DataLoader这个类,表面上看 API 简单到不行,无非就是传个 dataset、设个 batch_size、开几个 worker。但真正用起来,里面的坑一个接一个:多进程 worker 的内存爆炸、自定义 collate_fn 写错导致维度对不上、shuffle 和 sampler 冲突直接报错、pin_memory 开了反而更慢……这些问题在官方文档里往往一笔带过,只有实际踩过才知道疼。
这篇文章面向的是已经能跑通简单训练脚本、但想真正把数据管道吃透的人。不管你是做图像分类、目标检测、NLP 还是推荐系统,只要用 PyTorch,dataloader 就是绕不开的基础设施。我会从设计思路讲到实操细节,从参数含义讲到排查技巧,尽量把我在实际项目里积累的经验都倒出来。读完你至少能做到两件事:第一,知道每个参数背后的取舍逻辑;第二,遇到数据加载相关的报错和性能问题,能自己定位并解决。
2. DataLoader 的整体设计与核心思路拆解
2.1 它到底解决了什么问题
在没有 DataLoader 的世界里,训练循环大概长这样:你有一个巨大的数据集存在硬盘上,每次取一个 batch 就得手动写循环去读文件、做预处理、拼成 tensor、搬到 GPU。代码又臭又长不说,还特别容易出错。更关键的是,数据读取和模型计算是串行的——GPU 在算的时候 CPU 闲着,CPU 在读数据的时候 GPU 闲着,两边互相等,效率极低。
DataLoader 的核心价值就两个词:抽象和并行。抽象是指它把“从数据集里取一批样本”这件事封装成了一个可迭代对象,你只需要关心 dataset 怎么定义,剩下的批次组装、打乱顺序、多进程调度它全帮你管了。并行是指它通过多进程机制,让数据读取和预处理在 CPU 侧并行进行,同时主进程可以持续向 GPU 喂数据,实现计算和加载的重叠。
打个比方:Dataset 是你家冰箱,里面放着各种食材(样本);DataLoader 是一个配菜师傅团队,你告诉他“每道菜要哪些食材、怎么切”,他就安排人从冰箱里取料、切好、摆盘,然后一批一批端给你。你只管炒菜(训练模型),不用自己去冰箱里翻。
2.2 Dataset 和 DataLoader 的分工边界
很多人初学的时候会把这两者搞混。记住一个原则:Dataset 负责“单个样本怎么取”,DataLoader 负责“一批样本怎么组”。
Dataset 的核心是__getitem__和__len__两个方法。__len__告诉 DataLoader 一共有多少样本,__getitem__告诉它第 i 个样本长什么样。至于这个样本是从内存里拿、从硬盘读、还是从数据库查,Dataset 不关心,DataLoader 也不关心。
DataLoader 拿到 Dataset 之后,做的事情是:决定这一轮按什么顺序取样本(shuffle 或 sampler)、决定一次取多少个(batch_size)、决定用几个进程去取(num_workers)、决定取出来之后怎么拼成一个 batch(collate_fn)、决定要不要提前搬到固定内存(pin_memory)。
这个分工的好处是解耦。你可以写一个通用的 Dataset 处理某种数据格式,然后用不同的 DataLoader 配置来适配训练、验证、测试等不同场景。比如训练时 shuffle=True、num_workers=8,验证时 shuffle=False、num_workers=4,Dataset 代码完全不用改。
2.3 多进程加载的架构逻辑
DataLoader 的多进程机制是它最核心也最容易出问题的地方。当你设置 num_workers > 0 时,PyTorch 会启动指定数量的子进程,每个子进程持有一份 Dataset 的副本(在 Linux 上通过 fork 创建,在 Windows 上通过 spawn)。主进程负责调度和组装 batch,子进程负责实际的数据读取和预处理。
具体流程是这样的:主进程把“需要取哪些索引”的任务分发给各个 worker,每个 worker 根据自己的 Dataset 副本去取样本、做 transform,然后把处理好的样本放到共享队列里。主进程从队列里取数据,用 collate_fn 拼成 batch,返回给训练循环。
这里有个关键点:每个 worker 都会复制一份 Dataset 对象。如果你的 Dataset 在__init__里加载了很大的数据(比如把所有图片读进内存),那么 num_workers=8 就意味着内存里会有 8 份副本,直接爆炸。这是新手最容易踩的坑之一。正确的做法是把大数据放在 Dataset 外部,通过共享内存或内存映射的方式访问,或者用__getitem__懒加载。
另一个关键点是随机种子的处理。PyTorch 会为每个 worker 设置不同的随机种子,确保数据增强的随机性不会因为多进程而重复。但这个机制依赖于你正确使用了 PyTorch 的随机数生成器。如果你在 Dataset 里用了 Python 原生的random模块,可能会发现所有 worker 产生的随机变换一模一样。解决办法是在 Dataset 的__init__里用torch.initial_seed()或者手动为每个 worker 设置种子。
3. 核心参数逐个拆解与实操要点
3.1 batch_size 与 drop_last 的取舍
batch_size是最直观的参数,但它的选择不只是“显存够不够”这么简单。batch_size 影响的不只是显存占用,还有梯度估计的方差、BatchNorm 的统计量质量、训练收敛的速度。
从显存角度算,一个 batch 的显存占用大致是:batch_size × 单样本显存 × (1 + 梯度 + 优化器状态系数)。对于 Adam 优化器,优化器状态通常是参数量的 2 倍,所以总系数大概是 4 左右。你可以先用 batch_size=1 跑一下,看显存占用,然后线性估算能开多大。
但显存不是唯一约束。batch_size 太小(比如 1 或 2),BatchNorm 的统计量会非常不稳定,梯度噪声也大,训练可能不收敛。batch_size 太大,梯度估计太准,反而可能陷入 sharp minimum,泛化性能下降。实践中,图像分类任务常用 32 到 256,检测任务因为单样本显存大,常用 2 到 16。
drop_last这个参数容易被忽略。默认是 False,意味着最后一个不完整的 batch 也会被返回。这在训练时通常没问题,但在某些场景下会出问题:比如你用 BatchNorm,最后一个 batch 只有一个样本,统计量会非常离谱;或者你的 loss 计算依赖 batch 内样本间的交互(比如对比学习),不完整的 batch 会导致逻辑错误。所以训练时我一般建议设drop_last=True,验证时设 False(因为验证不需要反向传播,不完整 batch 无所谓)。
3.2 shuffle、sampler 与 batch_sampler 的关系
shuffle=True是最常用的打乱方式,但它的底层其实是创建了一个RandomSampler。这里有个规则:shuffle、sampler、batch_sampler 三者互斥。你设了 sampler,就不能设 shuffle;设了 batch_sampler,就不能设 batch_size、shuffle、sampler、drop_last。
为什么要有 sampler 这个抽象?因为有些场景下打乱方式不是简单的随机。比如:
- 分布式训练时,需要用
DistributedSampler把数据切分到各个 GPU 上,每个 GPU 只看到一部分数据。 - 类别不平衡时,需要用
WeightedRandomSampler按类别权重采样,让少数类被更频繁地抽到。 - 序列任务中,可能需要按长度分组采样(
BucketIterator的思路),减少 padding 浪费。
WeightedRandomSampler的用法值得展开说一下。假设你有 1000 个样本,类别 A 有 900 个,类别 B 有 100 个。直接随机采样的话,每个 batch 里 B 类样本很少,模型会偏向 A。用 WeightedRandomSampler,给每个样本一个权重,A 类样本权重设为 1/900,B 类设为 1/100,这样采样时 B 类被抽到的概率就大大提高了。注意num_samples参数要设成你想要的 epoch 长度,通常等于数据集大小。
3.3 num_workers 设多少才合适
这是被问得最多的问题,没有之一。答案不是固定的,取决于你的数据加载瓶颈在哪。
如果数据预处理很轻(比如数据已经在内存里,只需要简单切片),num_workers 设 0 或 1 就够了,开多了反而因为进程间通信开销而变慢。如果数据预处理很重(比如从硬盘读大图、做复杂的增强),num_workers 可以设到 CPU 核心数甚至更多。
我的一般建议是:从 4 开始试,逐步增加到 8、16,观察 GPU 利用率和每个 epoch 的耗时。如果增加 worker 数量后 GPU 利用率不再提升,说明瓶颈已经不在数据加载了,再加也没用。如果增加后反而变慢,说明进程间通信或内存拷贝成了新瓶颈。
还有一个经验值:num_workers 不要超过 CPU 物理核心数。比如你的机器是 8 核 16 线程,num_workers 设到 8 左右比较合适,设到 16 会因为超线程竞争而效率下降。另外,如果 num_workers > 0,建议在训练脚本的入口加上if __name__ == '__main__':保护,否则在 Windows 上会无限递归创建进程。
3.4 pin_memory 与 prefetch_factor 的配合
pin_memory=True的作用是把 CPU 内存中的数据提前拷贝到“固定内存”(pinned memory)中,这样从 CPU 到 GPU 的传输可以用 DMA 直接进行,速度更快。这个参数在 GPU 训练时几乎总是建议开启,代价是占用一些额外的 CPU 内存。
但 pin_memory 不是万能的。如果你的数据量很小,或者 GPU 计算本身就是瓶颈,pin_memory 带来的收益微乎其微。另外,pin_memory 只对 CUDA 张量有效,如果你用的是 CPU 训练或者 MPS 后端,开了也没用。
prefetch_factor控制每个 worker 提前取多少个 batch 的数据。默认是 2,意味着每个 worker 会提前准备好 2 个 batch 放在队列里。如果你发现 GPU 经常“等数据”,可以适当增大这个值,比如设到 4 或 6。但设太大也会占用更多内存,而且如果数据加载本身很快,设大了反而增加延迟。
这两个参数的关系是:pin_memory 优化的是“传输速度”,prefetch_factor 优化的是“供给连续性”。两者配合好了,才能让 GPU 始终有活干。
4. 自定义 Dataset 与 collate_fn 的实操细节
4.1 写一个健壮的 Dataset 类
一个标准的 Dataset 类长这样:
from torch.utils.data import Dataset class MyDataset(Dataset): def __init__(self, file_list, transform=None): self.file_list = file_list self.transform = transform def __len__(self): return len(self.file_list) def __getitem__(self, idx): path = self.file_list[idx] # 读取数据 data = self._load_data(path) # 应用变换 if self.transform: data = self.transform(data) return data, label看起来简单,但有几个细节决定了它是否健壮。
第一,__getitem__里不要做太重的初始化操作。比如每次调用都重新打开一个数据库连接、重新加载一个预训练模型,这会严重拖慢速度。应该在__init__里做好这些准备工作,__getitem__只做轻量的读取和变换。
第二,异常处理要到位。如果某个样本文件损坏了,__getitem__抛异常会导致整个训练崩溃。可以在__getitem__里加 try-except,遇到坏样本就返回一个替代样本或者随机跳到另一个索引。但要注意,如果坏样本太多,这种“跳过”策略会掩盖数据质量问题,最好还是先清洗数据。
第三,注意索引的类型。__getitem__接收的 idx 可能是 Python int,也可能是 numpy int64 或 torch tensor。如果你用 idx 去索引一个 Python list,numpy int64 是可以的,但 torch tensor 不行。保险的做法是在开头加一句idx = int(idx)。
4.2 collate_fn 的默认行为与自定义场景
默认的collate_fn做的事情很简单:把 batch 个样本的 tuple 按位置拆开,每个位置上的元素拼成一个 batch。比如每个样本返回(image, label),默认 collate 会返回(batch_images, batch_labels),其中 batch_images 是把所有 image 沿第 0 维堆叠。
但很多场景下默认行为不够用。比如:
- 目标检测中,每张图片的标注框数量不一样,没法直接堆叠,需要自定义 collate_fn 把标注整理成列表。
- NLP 中,每个句子的长度不一样,需要 padding 到同一长度,同时生成 attention mask。
- 多模态任务中,不同模态的数据需要分别处理,可能还要做对齐。
写自定义 collate_fn 的时候,最容易犯的错误是维度搞混。建议在函数开头打印一下输入的形状,确认无误后再写后续逻辑。另外,collate_fn 是在主进程里执行的(不是在 worker 里),所以如果里面有耗时操作,会阻塞整个数据流。尽量把重活放在 Dataset 的__getitem__里,collate_fn 只做组装。
4.3 数据增强与 transform 的放置位置
数据增强应该放在 Dataset 的__getitem__里,还是放在 collate_fn 里?答案是:绝大多数情况下放在__getitem__里。
原因是__getitem__是在 worker 进程中执行的,多个 worker 可以并行做增强,充分利用 CPU 多核。如果放在 collate_fn 里,就是在主进程串行执行,速度会慢很多。
但有一个例外:如果增强操作需要 batch 内样本间的交互(比如 MixUp、CutMix),那就必须放在 collate_fn 里,因为__getitem__一次只能看到一个样本。
另外,验证集和测试集的 transform 通常和训练集不一样。训练集用随机裁剪、随机翻转等增强,验证集用中心裁剪、不翻转。这个切换可以通过在 Dataset 初始化时传入不同的 transform 来实现,也可以通过dataset.transform = val_transform动态修改。我一般倾向于前者,逻辑更清晰。
5. 常见问题与排查技巧实录
5.1 多进程相关的典型报错
报错一:RuntimeError: DataLoader worker (pid xxx) is killed by signal: Bus error
这个报错通常是因为共享内存不够。Linux 系统默认的/dev/shm大小一般是内存的一半,如果你开了很多 worker,每个 worker 又往共享内存里写大量数据,就会撑爆。解决办法有两个:一是减小 num_workers,二是增大/dev/shm的大小(需要系统权限)。
报错二:TypeError: cannot pickle '_thread.lock' object
这个报错是因为 Dataset 里包含了不可序列化的对象,比如数据库连接、文件句柄、线程锁。在 Windows 上,worker 是通过 spawn 创建的,需要把 Dataset 序列化后传给子进程,所以要求 Dataset 必须可 pickle。解决办法是把这些不可序列化的对象放在__getitem__里懒加载,或者用__getstate__和__setstate__自定义序列化行为。
报错三:RuntimeError: Cowardly refusing to serialize non-leaf tensor which requires_grad
这个报错是因为 Dataset 返回了需要梯度的 tensor。DataLoader 在 worker 和主进程之间传递数据时,会尝试序列化 tensor,而需要梯度的 tensor 不能被序列化。解决办法是在 Dataset 里用torch.no_grad()包裹,或者用.detach()去掉梯度。
5.2 性能问题的排查思路
数据加载慢,怎么定位瓶颈?我的排查顺序是这样的:
第一步,把 num_workers 设为 0,测一下纯数据加载的耗时。如果 num_workers=0 时每个 epoch 要 100 秒,num_workers=8 时要 20 秒,说明多进程确实起了作用,但还可以继续优化。
第二步,用time.time()在__getitem__里打点,看看时间花在哪里。是文件 IO 慢,还是 transform 慢,还是其他操作慢。如果是文件 IO 慢,考虑把数据转成更高效的格式(比如 LMDB、HDF5、WebDataset);如果是 transform 慢,考虑用 GPU 做增强(比如 NVIDIA DALI)或者用更快的库(比如 OpenCV 替代 PIL)。
第三步,检查 GPU 利用率。用nvidia-smi -l 1或者watch -n 1 nvidia-smi观察。如果 GPU 利用率经常掉到 0,说明数据供给跟不上;如果 GPU 利用率稳定在 90% 以上,说明数据加载不是瓶颈,不用再折腾了。
第四步,检查内存和共享内存使用。用free -h看内存,用df -h /dev/shm看共享内存。如果共享内存快满了,减小 num_workers 或者减小 prefetch_factor。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| GPU 利用率低,训练慢 | num_workers 太小或为 0 | 增大 num_workers,开启 pin_memory |
| 内存爆炸 | Dataset 在__init__里加载了大数据,每个 worker 一份副本 | 改为懒加载,或用内存映射 |
| 报 Bus error | 共享内存不足 | 减小 num_workers 或增大 /dev/shm |
| 报 pickle 错误 | Dataset 含不可序列化对象 | 懒加载或自定义序列化 |
| 随机增强失效 | 用了 Python 原生 random | 改用 torch 的随机函数 |
| 最后一个 batch 报错 | drop_last=False 且 BatchNorm 遇到单样本 | 设 drop_last=True |
| shuffle 和 sampler 冲突 | 同时设了多个互斥参数 | 只保留一个 |
| Windows 上无限递归 | 没加if __name__ == '__main__' | 加上入口保护 |
5.4 几个我踩过的坑
第一个坑:在 Dataset 的__init__里做数据过滤。我曾经写过一个 Dataset,在__init__里遍历所有文件,把损坏的文件剔除掉。结果 num_workers=8 时,每个 worker 都做了一遍过滤,启动时间翻了 8 倍。后来改成在外部做好过滤,把干净的文件列表传进来,启动时间立刻降下来了。
第二个坑:在__getitem__里用random.random()做随机增强。结果发现所有 worker 产生的随机数序列一模一样,因为每个 worker 的 Python random 种子相同。改成torch.rand()之后问题解决,因为 PyTorch 会为每个 worker 设置不同的种子。
第三个坑:pin_memory=True 但 num_workers=0。这种情况下 pin_memory 是在主进程里做的,会阻塞训练循环,反而更慢。pin_memory 只有在 num_workers > 0 时才有意义,因为这时数据拷贝可以和主进程的计算重叠。
第四个坑:用IterableDataset时忘了设batch_size=None。IterableDataset是自己控制 batch 的,如果 DataLoader 再设 batch_size,会报错。正确做法是设batch_size=None,让 Dataset 自己 yield batch。
6. 进阶用法与性能调优
6.1 IterableDataset 的适用场景
前面讲的都是MapStyleDataset,也就是通过索引访问的 Dataset。PyTorch 还提供了IterableDataset,它不通过索引访问,而是直接 yield 样本。适合以下场景:
- 数据流是无限的或者非常大的,没法预先知道长度。
- 数据需要按顺序读取,比如从数据库游标、日志流、网络流中读取。
- 数据分片存储在多个文件中,需要动态决定读取顺序。
IterableDataset的多进程处理和MapStyleDataset不一样。因为每个 worker 都会独立地迭代数据,如果不做特殊处理,多个 worker 会读到重复的数据。解决办法是用worker_init_fn给每个 worker 分配不同的数据分片,或者用torch.utils.data.get_worker_info()在运行时获取 worker 编号,据此决定读哪些数据。
6.2 用 Sampler 实现分布式训练的数据切分
分布式训练时,每个 GPU 应该只看到数据集的一部分,同时保证每个 epoch 所有 GPU 合起来能看到全部数据。DistributedSampler就是干这个的。
它的工作原理是:根据当前进程的 rank 和总进程数,把数据集分成若干份,每个进程只取自己那一份。同时,每个 epoch 会重新打乱顺序,保证不同 epoch 看到的数据组合不同。
使用时要注意:DistributedSampler需要设置shuffle=True来打乱,同时 DataLoader 的 shuffle 要设为 False。另外,每个 epoch 开始前要调用sampler.set_epoch(epoch),否则每个 epoch 的打乱顺序会一样。
6.3 数据预取与缓存策略
如果数据预处理很耗时,可以考虑把处理好的数据缓存起来。最简单的做法是在__getitem__里加一个内存缓存字典,第一次读取时做预处理并缓存,后续直接返回缓存结果。但要注意缓存大小,别把内存撑爆。
更高级的做法是用磁盘缓存。比如把预处理后的数据存成 numpy 数组或 tensor 文件,下次直接读。或者用 LMDB 这样的嵌入式数据库,支持高效的随机读取。
还有一种策略是预取。除了 DataLoader 自带的 prefetch_factor,你还可以在 Dataset 层面做预取。比如在__getitem__里提前读取下一个样本的数据,利用 IO 等待的时间做其他事情。但这会增加代码复杂度,一般只在极端性能要求下才用。
6.4 性能调优的检查清单
调优数据管道时,我一般按这个清单逐项检查:
- num_workers 是否设到了合适值(观察 GPU 利用率)
- pin_memory 是否开启(GPU 训练时)
- prefetch_factor 是否足够(观察 GPU 是否有等待)
- Dataset 是否有不必要的重复初始化
- transform 是否用了高效的库(OpenCV 通常比 PIL 快)
- 数据格式是否高效(避免大量小文件,考虑打包格式)
- 是否有内存泄漏(观察长时间训练的内存变化)
- collate_fn 是否有耗时操作(应该尽量轻量)
7. 一些个人体会
DataLoader 这个组件,刚入门的时候觉得很简单,用久了才发现水深。它的每一个参数背后都有设计者的权衡,每一个报错背后都有特定的运行机制。我自己的经验是,不要等到出问题了才去查文档,而是提前把多进程、内存、序列化这些机制搞清楚,后面能省很多时间。
另外,数据管道的优化是有上限的。当 GPU 利用率稳定在 90% 以上时,再优化数据加载的收益就很小了,这时候应该把精力放在模型结构、学习率调度、正则化等更影响最终效果的地方。不要陷入“为了优化而优化”的陷阱。
最后分享一个习惯:每次搭好一个新的数据管道,我会先用小数据量跑几个 epoch,确认数据读取正确、增强效果符合预期、batch 组装没问题,然后再上全量数据。这个习惯帮我避免了很多“训练了半天才发现数据有问题”的尴尬。