☰
PyTorch DataLoader从入门到实践:核心参数、多进程加载与踩坑实录
2026/10/18 11:17:36 网站建设 项目流程

如果你跟我一样,刚开始自己手写训练循环时被数据加载折腾得够呛,那么切换到 PyTorch 的 DataLoader 那一刻,应该会明显感觉整个世界清静了。不用自己维护索引打乱,不用手动 stack batch,数据预处理逻辑也能从训练代码里拆出去。这篇文章就围绕 DataLoader 的用法展开,把 Dataset、Sampler、collate_fn、多进程加载这些绕不开的概念讲透,也会讲讲我踩过的内存暴涨、随机种子、卡死这些坑。适合刚接触训练管线的人,也适合用了很久但一直在默认参数里打转的朋友。

1. 先搞清楚 DataLoader 到底接管了数据流水线的哪一段

1.1 没有 DataLoader 时,训练循环会膨胀成什么样

很多人在刚开始写训练代码时,都会经历一个手搓数据加载的阶段。比如我最早写的训练循环大概是这样的:

for epoch in range(epochs): idx_list = list(range(len(train_dataset))) random.shuffle(idx_list) for start in range(0, len(idx_list), batch_size): batch_idx = idx_list[start:start + batch_size] batch_x = torch.stack([train_dataset[i][0] for i in batch_idx]) batch_y = torch.stack([train_dataset[i][1] for i in batch_idx]) optimizer.zero_grad() loss = compute_loss(model(batch_x), batch_y) loss.backward() optimizer.step()

这段代码看起来很直白,但它把好几件事混在了一起:epoch 级 shuffle、小批次划分、单线程样本读取、手动 stack、以及可能存在的 padding 逻辑。一旦业务复杂一点,比如样本里有变长序列,或者某个样本读取失败需要重试,这段循环就会迅速变成几百行的面条代码。

更麻烦的是性能问题。数据集稍微大一点,train_dataset[i]里如果涉及图像解码、文本 tokenize、SQL 查询这类耗时操作,这个单线程循环会让 GPU 一直空转等数据。我用过一段时间这种写法之后,很明显地感觉到训练一个模型的大部分时间都在等 CPU 喂数据,而不是等 GPU 算梯度。

1.2 DataLoader 本质上是 Dataset 和采样策略之间的调度器

后来我切到torch.utils.data.DataLoader,第一感受是代码量下来了,但真正让我觉得值得的是它把数据加载切分成了更清晰的阶段。一个标准的 DataLoader 迭代过程,内部大概是这样的:先由 Sampler 生成索引顺序,然后拿着索引去 Dataset 里取一个或多个样本,再用 collate_fn 把样本整理成一个 batch,最后交给训练循环。

DataLoader不是一个存储数据的容器,数据在 Dataset 里;DataLoader更多像一个调度器,它不关心你的图片从哪里来,只关心按什么顺序取、一次取多少、取完怎么拼、要不要在后台多进程并行加载,以及要不要预取一批到内存里等着用。

这一点非常重要,因为很多人在自定义的时候会找错地方。比如想让模型每次看到类别均衡的数据,你不需要去改 Dataset 的读法,而是改 Sampler;想让变长文本能拼成 batch,你不需要把 padding 写死在 Dataset 的__getitem__里,而是改 collate_fn。这就是为什么我把标题定为 DataLoader 的用法,但实际要理解的是它背后这一整套分工机制。

1.3 Dataset / Sampler / DataLoader 的分工,先记在脑子里

我整理了一张简单的分工表,后面所有内容都是围绕这张表展开的:

组件核心职责典型实现
Dataset给定索引返回一条样本,定义数据从哪里读、怎么预处理Map-style Dataset、IterableDataset
Sampler决定索引的迭代顺序,也就是取数策略SequentialSampler、RandomSampler、WeightedRandomSampler
DataLoader负责批量取数、拼接 batch、多进程加载、预取、固定随机种子torch.utils.data.DataLoader

如果你只是做一个常规的图像分类任务,shuffle=True就够用了,不用碰 Sampler。但如果你开始做类别均衡采样、分布式分片、流式数据读取,就需要理解上表里的每一层。否则你会发现自己明明改了 Dataset,数据顺序却完全没变,原因就是采样逻辑压根不在 Dataset 里。

2. 核心参数逐个拆解:默认值经常把你的性能偷走

2.1 batch_size 和 drop_last:最后一个不完整 batch 的隐患

很多人设置batch_size只看显存,觉得放得下就调大,放不下就调小,却忽略了drop_last这个参数。默认情况下,DataLoader会返回最后一个不完整的 batch,也就是说如果你的训练集大小不是 batch_size 的整数倍,最后一个 batch 的样本数会偏少。

在训练阶段,这个不完整 batch 会带来两类问题。第一类是模型里有BatchNorm层时,如果最后一个 batch 只有 1 个样本,BN 层的均值和方差统计会非常不稳定,某些 PyTorch 版本甚至直接报错;第二类是 loss 本身对 batch 内样本数量敏感,比如一些对比学习 loss、triplet loss,最后一个小 batch 的分布会和正常训练差很多,影响训练稳定性。

所以我常用的配置是训练集drop_last=True,验证集drop_last=False。验证集每个 epoch 都想看到全部样本,丢掉任何一条都可能影响指标的完整性;训练集丢掉最后几条样本通常影响不大,但能避免很多奇怪的 runtime error。如果你的任务是分割、检测这类对样本覆盖要求高的场景,也可以不丢,但在 loss 里对最后一个小 batch 做好峰度之类的统计保护。

2.2 shuffle=True 和 sampler 参数:为什么不能同时出现

第一次看到DataLoader的shuffle参数时,我下意识以为它是 Dataset 里的随机操作。后来翻源码才意识到,shuffle=True只不过是在内部帮你套了一个RandomSampler而已。如果此时你额外传入了sampler参数,DataLoader 源码会直接抛异常,因为采样器冲突了。

这个细节在调试的时候很关键。比如你想固定复现某次训练的数据顺序,可以设shuffle=False,然后用一个自定义的索引列表来控制采样;又比如你想做按权重采样,必须通过WeightedRandomSampler来指定sampler,同时把shuffle保持默认的 False。

顺带提一个调试技巧:在代码没跑通之前,先把shuffle=False打开,这样每次拿到的 batch 都是固定顺序,方便你对模型输出、loss 数值、梯度。等逻辑确认没问题了,再切回shuffle=True,千万不要让随机性干扰你排查 bug。

2.3 num_workers、prefetch_factor、pin_memory:性能三件套

训练速度慢的时候,我第一件事就是看 GPU 利用率。如果 GPU 利用率长期低于 50%,而 CPU 占用又拉满,大概率问题出在数据加载环节。这时会优先调三个参数:num_workers、prefetch_factor、pin_memory。

num_workers控制 DataLoader 启动多少个 worker 子进程去并行读数据和做预处理。默认是 0,也就是在主进程里同步加载,简单场景没问题,但遇到图像解码一类耗时操作,GPU 基本在空转。我一般从 4 开始试,根据训练速度和任务内存占用逐步调整。注意它并不是越大越好,因为每个 worker 都有独立进程上下文,系统内存和文件句柄都会被吃紧,开太多反而可能触发资源竞争。

prefetch_factor控制每个 worker 预取多少个 batch,默认是 2。数据读取延迟抖动的场景下,调高它可以让 DataLoader 提前准备更多 batch,但代价是内存占用上升。pin_memory=True则适合 GPU 训练,它把数据放到锁页内存,后续从 CPU 拷贝到 GPU 时效率明显更高,代价是锁页内存在系统内存紧张时可能更难以分配。

下面是我个人常用的起点配置表:

参数常用值使用要点
batch_size32 或 64看显存和 loss 对 batch 的敏感度
shuffleTrue(训练)会与 sampler 冲突
num_workers4 起步根据 CPU 核心数和内存调
pin_memoryTrueGPU 训练建议开启
drop_lastTrue(训练)避免最后不完整 batch
prefetch_factor4数据抖动时调整

3. 自定义 Dataset 的写法,决定了 DataLoader 跑得顺不顺

3.1 一个标准的 Map-style Dataset 长什么样

Dataset 的自定义是 DataLoader 用法里最常碰到的部分。一个最基本的 Map-style Dataset,只要实现__len__和__getitem__两个方法就行。__len__告诉 DataLoader 数据集一共多大,__getitem__根据索引返回一条样本。

拿图像分类举例,我通常这样写:

import os from PIL import Image from torch.utils.data import Dataset, DataLoader from torchvision import transforms class ImageFolderDataset(Dataset): def __init__(self, file_list, label_list, transform=None): self.file_list = file_list self.label_list = label_list self.transform = transform def __len__(self): return len(self.file_list) def __getitem__(self, idx): image = Image.open(self.file_list[idx]).convert("RGB") if self.transform is not None: image = self.transform(image) label = self.label_list[idx] return image, label

写的时候有几个容易忽略的细节。第一,transform最好在初始化时通过参数传进来,不要硬编码在__getitem__里,这样你能复用同一个 Dataset 为训练集做随机增强、为验证集只做标准化。第二,__getitem__的返回值最好统一成 Tensor 或统一成 PIL 等类型,否则默认 collate_fn 在 stack 时会报错。第三,图像读取后主动convert("RGB")可以避免某些数据集出现单通道灰度图和四通道 PNG 导致的批量形状不一致。

3.2getitem里的重活,能缓存就缓存

__getitem__是每个样本读取时都会被调用的方法,它的执行效率直接决定了 DataLoader 跑得多快。很多人喜欢把所有预处理都塞进去,比如实时访问数据库、下载远端图片、做大尺寸resize,结果每个 epoch 都在重复做同样的耗时操作。

我的习惯是:能离线算的不要在线算。图像可以先离线缩放到一个小尺寸再存成压缩格式,能节省大量解码时间;文本可以先在预处理阶段统一 tokenize,再存成 id 列表;如果样本总量不大,甚至可以直接把所有特征一次性转成 Tensor 放进内存,让 DataLoader 的__getitem__只做一次索引查表和返回。项目里我做过一个简单的对比:同样的数据,数据量不大的情况下全部缓存在内存里之后,训练吞吐提升了近一倍,改动也就十几行。

另外注意,__getitem__里的随机操作最好依赖 PyTorch、Python random、numpy random 三个库各自维护的随机数状态,不要自己在对象里创建重复种子。否则多进程下不同 worker 很容易产生完全相同的增强结果,这点在下一部分详细展开。

3.3 collate_fn:变长数据拼 batch 的真正用武之地

默认的collate_fn做的事情很简单:拿到一个 batch 的样本列表,然后把 Tensor 类型的样本用torch.stack拼起来,把其他类型转换成 Tensor。这在图像和定长特征上没什么问题,但遇到自然语言处理里常见的变长序列,默认collate_fn直接失败,因为每个样本长度不一样,没法直接 stack。

处理方式是自己写一个 padding 逻辑,放在collate_fn里:

def pad_collate(batch): xs, ys = zip(*batch) max_len = max(len(x) for x in xs) padded_xs = [] masks = [] for x in xs: padded_xs.append(x + [0] * (max_len - len(x))) masks.append([1] * len(x) + [0] * (max_len - len(x))) return ( torch.tensor(padded_xs), torch.tensor(masks), torch.tensor(ys), )

padding 放在collate_fn里而不是__getitem__里,是我比较推荐的写法。一方面长度信息是 batch 级的,放进 collate_fn 代码更内聚;另一方面,DataLoader 在多进程模式下会让 worker 负责一批样本的收集和 collate,逻辑上比每个样本在__getitem__里先把长度补好再回去拼更不容易出错。

4. 多进程加载的踩坑实录:内存、卡死和随机种子

4.1 内存暴涨:fork 的写时复制与“偷偷修改共享对象”

把num_workers从 0 改成 4 后,我遇到过内存直接翻了三四倍的情况。开始以为是数据量太大,后来定位到问题出在 worker 内存模型的细节上。Linux 下 DataLoader 默认用 fork 启动子进程,子进程会继承父进程的内存页,并且以写时复制的方式共享。也就是说,理论上 Dataset 里那些只读的大数组,多个 worker 是共享同一份物理内存的,不会每个进程都复制一份。

但如果你在__getitem__里修改了 Dataset 的成员变量,或者修改了一个已经存在的 numpy 数组,比如写self.cache[idx] = processed,就会触发写时复制,把这一页内存复制到当前 worker 的地址空间。多个 worker 同时写,内存占用就可能线性叠加。更隐蔽的是,有些库内部会做 in-place 操作,位移或归一化时不小心改动了输入数组,也会引发同样的问题。

所以我的建议是:Dataset 内部能被多个 worker 读取的数据尽量设计成只读结构,不要把__getitem__当成缓存写入的入口。如果确实需要一个预处理缓存,在 Dataset 初始化阶段一次性算好,或者放到单独的进程里统一管理。

4.2 迭代器卡死:DataLoader 生命周期没有管理好

另一个高频坑是程序在训练过程中突然卡死,尤其是手动构造迭代器的时候。我曾经为了一个 epoch 里分多次处理数据,写了类似下面这种代码:

it = iter(train_loader) first_batch = next(it) # ... some logic for batch in it: train(batch)

这种写法本身不算错,但如果在使用迭代器后没有及时清理,或者迭代器还没耗尽就跳出了循环,某些平台下 worker 子进程可能不会自动结束,导致程序退出时挂住。安全起见,我现在的习惯是尽量用for batch in dataloader,让 DataLoader 自己管理迭代器的创建和清理。如果非要手动iter(),记得在结束之后del it,必要时再调用一下gc.collect()。

此外,如果出现了诡异的多进程超时,可以先检查是不是persistent_workers=True和某个读取函数死锁。persistent_workers=True会复用 worker,减少每个 epoch 启动和销毁的开销,坏处是 Dataset 如果有状态更新,worker 里的旧状态可能不会同步。你要是踩到这种卡死,可以把persistent_workers暂时关掉验证一下。

4.3 随机数种子:为什么多进程后数据增强“不随机”了

我踩过最隐蔽的一个坑是:num_workers=0时一切正常,num_workers=4后,同一个 epoch 里大量样本的增强方式完全相同,数据多样性肉眼可见下降。原因很简单,fork 出来的子进程继承了父进程的随机数状态,导致每个 worker 一开始的随机序列可能是相同的。如果__getitem__里用 Python random 或 numpy random 做随机增强,就会产生重复结果。

解决办法是给 DataLoader 写一个worker_init_fn,在每个 worker 初始化时根据 worker_id 重新播种:

import random import numpy import torch def worker_init_fn(worker_id): seed = torch.initial_seed() % 2**32 random.seed(seed) numpy.random.seed(seed)

把worker_init_fn传给 DataLoader 之后,每个 worker 的随机状态就来自 PyTorch 在子进程里初始化的种子,互相独立。这里要注意,numpy.random.seed和random.seed都必须设置,因为有些人会在__getitem__里混用不同库的随机函数。

如果你的目标还包括完全复现整个训练过程,那还需要在 DataLoader 上再传一个generator=torch.Generator().manual_seed(seed),并且对每个 epoch 重置一次种子。单纯靠worker_init_fn只能做到 worker 内随机,不保证 epoch 间 shuffle 顺序完全可复现。

4.4 多进程报错难排查时,先把 num_workers 设成 0

worker 进程里抛出的异常有时候会被包装成“DataLoader worker exited unexpectedly”这种让人摸不着头脑的信息,真正的堆栈可能丢失。我的排查流程是:先设num_workers=0复现一次,确认 Dataset 和 collate_fn 的逻辑没有问题;再开多进程,缩小出错范围。

如果发现只在多进程下出错,优先检查三件事。第一,Dataset 里有没有 lambda 表达式或者局部函数,它们可能导致序列化失败;第二,是否有全局变量被修改,多个 worker 之间共享全局状态极容易出现并发问题;第三,是否访问了不能被多进程安全共享的资源,比如数据库连接、文件对象、socket。通常这三类问题是多进程报错的根源,定位出来后把代码改成都市模块级、只读结构,问题就会消失。

5. 进阶用法:Sampler、分布式训练和 DataLoader 提速

5.1 自定义 Sampler 控制取数逻辑:类别不均衡的解法

shuffle=True只解决随机访问顺序,解决不了类别不均衡问题。当你的数据里 90% 是负样本、10% 是正样本时,普通随机采样会让模型在绝大多数 batch 里几乎看不到正样本,训练容易偏向多数类。

遇到这种情况,WeightedRandomSampler是性价比最高的解法。你可以按类别设计样本权重,让少数类被抽到的概率更高:

from torch.utils.data import WeightedRandomSampler sample_weights = [0.2 if label == 0 else 1.0 for label in labels] sampler = WeightedRandomSampler(sample_weights, num_samples=len(labels), replacement=True) train_loader = DataLoader(train_dataset, batch_size=32, sampler=sampler)

这里有两个容易忽略的点。第一,一旦传入sampler,shuffle就必须是 False,因为采样顺序已经由 sampler 完全接管。第二,replacement=True表示有放回抽样,适合希望每个 epoch 少数类可以重复出现的情况;如果设replacement=False,num_samples不能超过原始样本总数,并且实现上更像一次重新排列,灵活性低不少。

5.2 分布式训练:DistributedSampler 与 set_epoch 的必要性

如果你的训练任务开始用多卡分布式,DataLoader 又有一个专门的采样器需要学习:DistributedSampler。它负责把数据集按进程编号和总进程数切成不同的分片,每个进程只看到一份数据,保证整个 epoch 里所有数据不会被重复训练,也不会被漏掉。

示例用法很简单:

from torch.utils.data.distributed import DistributedSampler sampler = DistributedSampler( train_dataset, num_replicas=world_size, rank=rank, shuffle=True, ) train_loader = DataLoader(train_dataset, batch_size=64, sampler=sampler, num_workers=4) for epoch in range(epochs): sampler.set_epoch(epoch) for x, y in train_loader: train(x, y)

很多人在 DDP 里漏掉sampler.set_epoch(epoch),结果每个 epoch 拿到的是完全一样的顺序,数据增强随机性也会被打折扣。因为DistributedSampler内部用 epoch 作为随机种子的一部分,不更新它,shuffle 顺序就会固化。这是我实际项目里踩过的一个坑,加一行set_epoch之后指标和稳定性都明显改善。

5.3 让 DataLoader 跑得更快的几个实测调整

如果已经确认数据加载是瓶颈,除了调参数,我还会按顺序做下面几件事。

先跑一个纯遍历 DataLoader 的小脚本,不执行模型,只看每秒能出多少 batch,作为数据管线的基线速度。然后用persistent_workers=True配合prefetch_factor=4,减少每个 epoch 启动 worker 的额外开销。如果数据总量不是特别大,就把预处理后的特征缓存成 Tensor 列表,让 Dataset 退化成纯内存查表。图像类任务还可以考虑提前把原始图片缩放到网络输入附近的分辨率,再存成压缩格式,减少每次读取和 resize 的耗时。

我见过一个很典型的案例:某个训练任务 GPU 利用率只有 30%,大部分时间都在做图像解码和随机裁剪。后来把 num_workers 从 2 调到 8,再配合pin_memory=True,GPU 利用率直接拉到 85% 以上,训练时长缩短了近一半。软件工程里没有银弹,但 DataLoader 的参数组合确实是性价比最高的切入点。

5.4 IterableDataset:处理流式数据时的注意事项

对于超大文件、流式数据、无法随机访问的场景,IterableDataset是更合适的选择。它只需要实现__iter__,用循环 yield 数据。但要注意,DataLoader 对 IterableDataset 的处理逻辑和 Map-style Dataset 完全不同,它不再使用 Sampler,这意味着shuffle参数没有任何效果。

更麻烦的是多进程。当num_workers>0时,每个 worker 都会拿到同一个 IterableDataset 实例,如果每个 worker 都从第一个样本开始迭代,那么一个 epoch 的数据会被重复多次。解决办法是在__iter__里通过torch.utils.data.get_worker_info()拿到当前位置和总 worker 数,对不同 worker 分配不同的数据区间:

class StreamingDataset(IterableDataset): def __init__(self, total_samples): self.total_samples = total_samples def __iter__(self): worker_info = torch.utils.data.get_worker_info() if worker_info is None: start, end = 0, self.total_samples else: per_worker = (self.total_samples + worker_info.num_workers - 1) // worker_info.num_workers start = worker_info.id * per_worker end = min(start + per_worker, self.total_samples) for i in range(start, end): yield self.read(i)

这样每个 worker 只处理自己负责的区间,多个 worker 合起来才是完整数据集。流式数据不能打乱顺序,这是 IterableDataset 的固有限制;如果一定需要随机采样,通常自己维护一个索引队列,或改用支持随机访问的 Map-style Dataset。

最后分享一个我目前最常用的 DataLoader 初始化片段,不是标准答案,但作为起点很稳:

train_loader = DataLoader( dataset, batch_size=64, shuffle=True, num_workers=4, pin_memory=True, drop_last=True, prefetch_factor=4, persistent_workers=True, )

调试阶段我会把num_workers改成 0,persistent_workers关掉,等代码跑通之后再把性能参数打开。DataLoader 的用法并不神秘,关键是理解它每一层在做什么。遇到性能问题先定位是不是数据加载瓶颈,遇到多进程问题先想到 worker 生命周期和随机种子,大部分坑都能顺着这条思路查出来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询