第一次把一个卷积网络跑起来的时候,我盯着终端里那行每秒只跳十几个 batch 的进度条,一度怀疑是显卡不够用,后来把训练循环里的耗时打点拆开才发现,GPU 有将近七成的时间在干等——等 CPU 把图片读进来、解码、做增强、拼成 batch。真正拖慢训练的并不是模型,而是数据供给这条管道。这也是我把PyTorch入门系列讲到第 8 讲时,一定要单独拎出DataLoader来讲的原因:前面几讲你把网络搭出来、把 loss 算明白,但只要数据喂不进去、喂得慢、喂得乱,模型再好也白搭。这一讲要聊的就是Dataset和DataLoader这套组合拳——它们能帮你把零散的样本文件变成一条可迭代、可并行、可复现的数据流,解决的是训练流程里最容易被忽视却最影响效率的"输血管道"问题。适合刚跑通 MNIST 想上手真实数据集的新手,也适合已经能训练但总感觉"跑不满显卡"的同学对照排查。
1. 为什么我不建议你手写 for 循环取数据
1.1 从一次训练卡顿说起:数据供给才是隐形的瓶颈
很多人入门时的第一份训练代码,长这样:把所有图片路径塞进一个 list,然后用切片paths[i:i+batch]手动取一批,循环里for p in batch: img = Image.open(p)逐张读。这个写法在几百张小图上完全没问题,但它有一个致命的结构问题——读盘、解码、增强、转张量、拼 batch 这五件事全部串行发生在训练主进程里,GPU 在这一整段时间内处于空闲状态。
我做过一个粗略的量化:读一张 224×224 的 JPEG 并解码,在普通机械盘上大约 3~8ms,SSD 上 1~3ms;随后一次随机裁剪加颜色抖动,CPU 单核大概再花 1~2ms;转成 tensor 再归一化,0.3ms 左右。也就是说,一张图从文件到能进网络,单核串行大概要 5~12ms。batch size 取 32,一个 batch 就要 160~380ms 的准备时间,而一张 224 图的 batch 在主流显卡上前向反向可能只需要 30~60ms。算下来 GPU 利用率不到两成,剩下八成时间都在等你读文件。
这就是为什么我说数据管道不是"可有可无的封装",而是训练速度的分水岭。DataLoader解决的正是这件事:它把取数据这件事挪到独立的子进程里并行做,主进程只管拿现成的 batch,两边通过队列交接,形成流水线。你不需要自己写多进程、自己管队列、自己处理进程退出,这些它都替你打理好了。
还有一层新手容易忽略的价值:可复现性。手写循环时,随机增强用的是全局随机状态,你一旦改了循环里某个操作的顺序,或者中途加了行打印,随机流就变了,两次训练的曲线就对不上。DataLoader配Sampler和generator这套机制,能把"每个 epoch 的样本顺序"和"每张图的增强参数"都纳入可控范围,这对调参和复现实验结果的意义,比省那点时间更大。
1.2 DataLoader 到底替你做了什么
把DataLoader拆开看,它其实是一个四段式的流水线:索引生成 → 样本获取 → 样本拼装 → 结果交付。每一段都有明确的职责边界和可替换的接口,理解这四段是理解所有参数的前提。
第一段是索引生成,由Sampler负责。它决定这一轮迭代按什么顺序、取哪些下标。shuffle=True只是换了个RandomSampler,drop_last=True只是外面套了个BatchSampler做截断。第二段是样本获取,由Dataset.__getitem__负责,输入一个下标,输出一个样本,这是你唯一必须自己写的部分。第三段是样本拼装,由collate_fn负责,把一批独立的样本拼成一个 batch 张量,默认实现是torch.stack,遇到变长序列就得你自己接管。第四段是交付,也就是迭代时next(iter(loader))拿到的那个 batch,同时由num_workers控制前面几段是否并行。
理解了这套分工,你就不会再问"为什么我改了 batch_size 但速度没变"——因为瓶颈可能根本不在拼装阶段,而在第二段的磁盘 IO 上;也不会再问"为什么 shuffle 和 sampler 一起用会报错",因为它们本来就共用第一段这个位置。
提示:
DataLoader只负责"取",不负责"存"和"变换逻辑"。数据增强写在Dataset里,模型内部的标准化写在 forward 里,别混。
1.3 三个核心角色:Dataset、Sampler、collate_fn 的分工
我习惯用一句话给新手概括这三个角色的边界:Dataset 回答"第 i 个样本长什么样",Sampler 回答"这一轮按什么顺序看",collate_fn 回答"凑成一桌该怎么摆盘"。三者职责不重叠,改动任何一处都不会牵连另外两处,这是 PyTorch 数据接口设计得最舒服的地方。
举个实际的例子。你有一批长度不一的文本,训练时希望按长度分组以减少 padding 浪费。这时候正确的做法是:Dataset.__getitem__老老实实返回单条文本的 token id 列表,不做任何 pad;然后写一个collate_fn,在这个函数里做动态 padding 到本 batch 的最大长度。如果你把 padding 提前塞进Dataset里,要么 pad 到全局最大长度造成大量浪费,要么每个样本 pad 长度不一致导致torch.stack直接报错。
再比如你希望"每个 epoch 都重新打乱,但同一 epoch 内每个 worker 拿到的顺序不重复"。这是Sampler+num_workers配合的事,Dataset完全不用感知。很多新手试图在__getitem__里用随机数做"伪打乱",结果同一个样本在一个 epoch 里被取到多次、另一些一次也没取到,训练曲线自然难看。
| 角色 | 接口 | 你负责写 | 典型职责 |
|---|---|---|---|
| Dataset | __len__/__getitem__ | 必须 | 路径解析、读取、单样本变换 |
| Sampler | __iter__/__len__ | 少数情况 | 顺序控制、分布式切分、长度分桶 |
| collate_fn | 可调用对象 | 变长/多模态时 | 批内 padding、拼装、类型转换 |
| DataLoader | 组装 | 只传参 | 并行调度、预取、迭代协议 |
2. 先把 Dataset 写对:Map 式与 Iterable 式的选型逻辑
2.1 Map 式 Dataset 的三个必须实现的方法
绝大多数场景用的都是 map-style,也就是"有下标、可随机访问"的数据集。它的契约只有两条:__len__返回样本总数,__getitem__(index)返回第 index 个样本。这两条写对了,shuffle、多进程、sampler全都自动可用,因为底层有了"按下标随机取"的能力。
但"写对"和"能跑"是两回事,我在带新人的过程中见过不少隐患,集中在三点。第一是__len__必须反映真实样本数,不能为了省事返回一个估算值,因为BatchSampler是按它来切 batch 和计算drop_last的,长度错了会导致最后一个 batch 越界或者有样本永远取不到。第二是__getitem__的开销要控制住,不要在初始化__init__里预加载所有图片到内存——很多人图省事把整个数据集读进 list,小数据集没问题,上了几万张就内存爆炸;反过来也不能每次取样本都重新扫一遍目录,路径索引应该在__init__里建好一次。第三是返回值必须是"叶子级"的独立对象,别返回共享的类成员引用,否则多进程下你可能拿到被其他样本改过的对象。
下面是我常用的骨架,注意路径索引和实际数据分离这个结构:
from torch.utils.data import Dataset from PIL import Image import os class ImageFolderDataset(Dataset): def __init__(self, root, transform=None): self.root = root self.transform = transform # 只在初始化时扫一次目录,构建索引 self.samples = [] for label_name in sorted(os.listdir(root)): label_dir = os.path.join(root, label_name) if not os.path.isdir(label_dir): continue for fname in sorted(os.listdir(label_dir)): if fname.lower().endswith(('.jpg', '.jpeg', '.png')): self.samples.append( (os.path.join(label_dir, fname), int(label_name)) ) def __len__(self): return len(self.samples) def __getitem__(self, index): path, label = self.samples[index] img = Image.open(path).convert('RGB') if self.transform is not None: img = self.transform(img) return img, label这段代码里有几个细节值得说。sorted()不是装饰,它保证同一份数据在不同机器、不同文件系统下索引顺序一致,否则实验不可复现。convert('RGB')是为了处理灰度图和带 alpha 通道的 PNG,避免在 collate 阶段因为通道数不一致而报维度错误。Image.open是惰性打开的,不会立即解码,真正的解码发生在convert或 transform 时,这一点和很多人直觉不同。
注意:
Image.open返回的文件句柄在极端情况下不会立即释放,如果数据集里混有大量损坏文件,建议在__getitem__里包一层try/except并记录坏样本路径,而不是让整个训练直接崩掉。这不是纵容脏数据,而是让排查过程可控。
2.2 IterableDataset 适合什么场景
有些数据天生不适合按下标访问:超大压缩包、数据库游标、实时日志流、分片存储在多个文件里的 TB 级语料。这类数据你没法"跳到第 500 万条",只能从头顺次读。这时候用 map-style 就是把文件全解析一遍建索引,纯属自找麻烦,应该切换到IterableDataset。
它的接口只有一个:实现__iter__,在里面 yield 样本。DataLoader 拿到它之后不会再调__len__,也不再支持shuffle(因为它没有下标可打乱),batch 是由迭代出来的顺序自然切分。代价是你要自己处理"epoch 边界"——map-style 每轮迭代会自动重新遍历一遍,而IterableDataset的__iter__每调用一次就是一次全新遍历,这一点在写for循环时是自动的。
多进程下有个大坑必须提前说清楚:当num_workers > 0时,每个 worker 都会独立调用一次__iter__,各自从头遍历完整数据流。如果你不做分片,训练 3 个 epoch 实际就相当于把数据重复学了num_workers遍,loss 曲线会好看得莫名其妙。正确的做法是在__iter__里读torch.utils.data.get_worker_info(),拿到worker_id和num_workers,按 worker 数量做切片或者按行号取模:
from torch.utils.data import IterableDataset, get_worker_info class ShardedTextDataset(IterableDataset): def __init__(self, file_paths): self.file_paths = file_paths def __iter__(self): info = get_worker_info() if info is None: files = self.file_paths wid, num = 0, 1 else: files = self.file_paths[info.id::info.num_workers] wid, num = info.id, info.num_workers for fp in files: with open(fp, 'r', encoding='utf-8') as f: for idx, line in enumerate(f): # 行级别的二次分片,保证同一文件被多 worker 读时不重复 if idx % num != wid: continue yield line.strip()这段的分片逻辑是双层的:文件级别用[id::num_workers]跳采,行级别用取模兜底,防止某个大文件被切给单个 worker 造成负载不均。这个写法我在处理几十 GB 的文本语料时一直在用,效果比先切文件再切行更均匀。
2.3 数据增强放在哪一层
位置选择上有三种写法,各有适用场景,选错了会带来性能或正确性问题。
写在Dataset.__getitem__里是最常见的。优点是每个样本独立增强,多 worker 下天然并行,GPU 完全不用管;缺点是在子进程里做 CPU 密集的增强会占用 worker 的时间,如果增强很重(比如大尺寸随机裁剪再 resize),就得靠调num_workers来补。这也是我默认推荐的位置。
写在collate_fn里适合批级别的操作,比如 MixUp、CutMix、批内随机遮挡。这类操作需要看到整个 batch 才能决定怎么混合,放在单样本层是无解的。但它有个隐含代价:collate_fn也在 worker 里执行,如果混合逻辑涉及大张量拼接,会加重每个 worker 的单次负担。
写在模型 forward 里适合 GPU 上才划算的操作,比如某些基于插值的缩放、或者需要与模型参数联动的增强。但要注意,一旦写进 forward,验证和推理阶段必须能通过开关关掉,否则你会发现评估指标怎么调都对不上。
提示:
torchvision.transforms里的随机类操作(RandomCrop、RandomHorizontalFlip、ColorJitter)依赖随机数状态。DataLoader 会为每个 worker 设置 torch 的种子为base_seed + worker_id,所以同一个 epoch 内不同 worker 的随机流是错开的,这点不用担心。但如果你在增强里用了 numpy 的随机或者 python 的random,必须自己在worker_init_fn里补上种子,否则每个 epoch 的增强序列完全一致。
3. DataLoader 关键参数逐个拆解与取值计算
3.1 batch_size、shuffle、drop_last 的配合逻辑
batch_size是显存和梯度质量的权衡。经验做法是从"显存能装下的最大值的 60%~70%"起步,留出余量给偶发的长尾样本和验证阶段。以 224×224 图像分类为例,8GB 显存下 ResNet 类模型通常能跑到 64~128,取 64 比较稳妥;如果你开了混合精度,可以再往上翻一倍左右。
shuffle=True在训练集上基本是无条件开启,验证集和测试集必须关掉。原因不只是"随机性有助于收敛",更实际的是:验证集的指标需要可复现、可对比,样本顺序一变,某些依赖顺序的指标(如 top-k 的并列处理)就可能出现微小差异,给排查徒增干扰。
drop_last是个容易被忽视但很关键的小开关。训练时如果最后一个 batch 只剩 1 个样本,而你的网络里有BatchNorm,会直接抛错,因为 BN 在 batch 维度上算统计量,单样本的方差无意义。这种情况要么drop_last=True丢掉最后那几个样本,要么把 batch_size 调成能整除数据集大小的值。验证阶段我一般设drop_last=False,因为评估要覆盖全量样本,丢样本会让指标有偏差。
| 参数 | 训练集建议 | 验证/测试建议 | 主要影响 |
|---|---|---|---|
| batch_size | 显存上限的 60%~70% | 可放大 2~4 倍 | 显存占用、梯度噪声 |
| shuffle | True | False | 收敛速度、结果可复现 |
| drop_last | True(有 BN 时) | False | 避免单样本 batch |
| num_workers | 按 CPU 核数实测 | 同训练或略低 | 数据吞吐 |
| pin_memory | True(GPU 训练) | True | 主机到显存拷贝速度 |
3.2 num_workers 到底设多少
这是被问得最多的一个参数,也是玄学感最强的一个。我给的经验公式是:IO 密集型任务(大量小文件、机械盘、网络存储)取 CPU 物理核数的 2~4 倍;CPU 密集型任务(重增强、大图解码)取物理核数的 0.5~1 倍。注意是物理核数不是逻辑核数,超线程在数据加载这种场景下收益很低。
但公式只是起点,最终一定要实测。我常用的测法是固定跑 100 个 batch,记录总耗时,num_workers从 0 扫到 16,画出曲线,拐点就是你的最优值。下面这段可以直接抄:
import time from torch.utils.data import DataLoader def benchmark(loader, max_batches=100): # 先预热,避免第一次迭代的进程创建开销污染结果 it = iter(loader) for _ in range(min(5, max_batches)): next(it) start = time.perf_counter() count = 0 for batch in it: count += 1 if count >= max_batches: break cost = time.perf_counter() - start return cost, cost / max(count, 1) for nw in [0, 2, 4, 8, 12, 16]: loader = DataLoader(dataset, batch_size=32, shuffle=True, num_workers=nw, drop_last=True) total, per_batch = benchmark(loader) print(f"workers={nw:2d} total={total:.2f}s per_batch={per_batch*1000:.1f}ms")实测中有两个现象很典型。一是num_workers=0时反而比num_workers=2快,这通常说明你的数据集很小、全部在系统缓存里,启动子进程和进程间通信的开销超过了并行收益,这时候就用 0。二是曲线在 8 之后基本走平甚至下降,说明已经撞到了磁盘带宽或 CPU 的上限,继续加 worker 只会带来内存占用上升和上下文切换开销。我曾经在一个 4 核的机器上把 worker 设到 16,结果内存里同时存在 16 份解码后的图像缓冲,直接把机器压到频繁换页,速度反而比设 4 慢了将近一倍。
3.3 pin_memory、persistent_workers 与 prefetch_factor 的取舍
pin_memory=True的作用是让 DataLoader 把 batch 放进锁页内存(pinned memory),这样从主机内存拷贝到显存时可以用异步 DMA,不必经过一次中转。只有在 GPU 训练时才有意义,纯 CPU 训练打开它反而是负担,因为锁页内存是操作系统层面不可换出的物理页,用多了会影响系统整体表现。开启后你会看到.to(device, non_blocking=True)配合使用时拷贝时间明显缩短,这是我把这个组合当作标配的原因。
persistent_workers=True解决的是每个 epoch 结束时 worker 被销毁、下个 epoch 重新创建的问题。worker 启动时要重新 fork 进程、重新执行__init__里的索引构建,如果你的数据集初始化有几百毫秒的扫描开销,几十个 epoch 下来就是十几秒的纯浪费。设成 True 之后 worker 会跨 epoch 存活,代价是内存会在整个训练期间保持占用。我在数据集较大、epoch 数较多时会打开它,做短实验时懒得管。
prefetch_factor控制每个 worker 提前准备多少个 batch,默认是 2,也就是总预取量约等于num_workers × 2个 batch。这个值的调整逻辑很直白:预取太少,GPU 算完一个 batch 后要等下一个,流水线断档;预取太多,内存里堆着大量已解码数据,容易 OOM。我一般按"预取总字节数不超过可用内存的 10%"来估算:
单个 batch 占用 ≈ batch_size × 单样本张量字节数 例如 batch_size=32、单样本 3×224×224 的 float32 张量 ≈ 32 × 3×224×224×4 ≈ 19.3 MB 预取总量 = num_workers × prefetch_factor × 19.3 MB 8 个 worker、prefetch_factor=2 时约 309 MB,属于安全区间
如果单样本是 512×512 的大图,或者你在__getitem__里返回了多尺度、多裁剪的结果,上面的数字要成倍放大,这时候就要相应降低prefetch_factor或者num_workers。
3.4 自定义 collate_fn:变长序列与多模态样本
默认的collate_fn做的事很简单:对 batch 里每个字段用torch.stack拼起来。这要求所有样本形状完全一致,一旦有变长就报维度不匹配。变长序列的正确做法是在collate_fn里做批内动态 padding,padding 到本 batch 的最大长度即可,不要 padding 到全局最大长度。
import torch def pad_collate(batch): # batch: List[(ids: LongTensor, label: int)] ids_list, labels = zip(*batch) lengths = torch.tensor([len(x) for x in ids_list], dtype=torch.long) max_len = int(lengths.max()) padded = torch.zeros(len(ids_list), max_len, dtype=torch.long) mask = torch.zeros(len(ids_list), max_len, dtype=torch.bool) for i, ids in enumerate(ids_list): padded[i, :len(ids)] = ids mask[i, :len(ids)] = True return { 'input_ids': padded, 'attention_mask': mask, 'lengths': lengths, 'labels': torch.tensor(labels, dtype=torch.long), }这里面attention_mask是关键,只做 padding 不给 mask,模型会把 pad token 当成真实内容参与注意力计算,短句子的表示会被稀释。另外要注意 pad 用的 id 必须是模型词表里定义的 pad id,别随手用 0——如果 0 恰好是某个真实 token,训练会学到莫名其妙的模式。
多模态样本的思路一样,只是字段更多、类型更杂。比如图文对,返回的 dict 里可能有图像张量、文本 id、文本 mask、以及一个变长的检测框列表。检测框这种"每个样本数量不同"的字段,要么 padding 到批内最大数量并附带有效数量,要么干脆在collate_fn里拼成大张量加一个 batch 索引列(类似torch_geometric的 concatenate 风格)。前者实现简单,后者内存效率高,看你下游怎么消费。
注意:
collate_fn在 worker 进程内执行,如果里面用了 CUDA 相关操作会直接报错,因为子进程里没有初始化 CUDA 上下文。所有张量操作留在 CPU 上,搬运算交给主循环。
4. 完整实操:从零搭一个可跑通的数据管道
4.1 目录结构与样本索引构建
我习惯的目录结构是按类别分文件夹,这也是ImageFolder的默认约定:
data/ ├── train/ │ ├── class_a/ │ │ ├── 0001.jpg │ │ └── ... │ └── class_b/ │ └── ... └── val/ ├── class_a/ └── class_b/自己做索引而不是直接用datasets.ImageFolder,主要是为了加两个能力:一是过滤掉损坏文件,二是能保存一份索引清单方便复现。索引构建我一般单独跑一次,存成 json,训练时直接读,避免每次启动都扫目录:
import json, os def build_index(root, out_json): classes = sorted([d for d in os.listdir(root) if os.path.isdir(os.path.join(root, d))]) cls_to_idx = {c: i for i, c in enumerate(classes)} items = [] for c in classes: cdir = os.path.join(root, c) for f in sorted(os.listdir(cdir)): if f.lower().endswith(('.jpg', '.jpeg', '.png')): items.append({'path': os.path.join(cdir, f), 'label': cls_to_idx[c]}) with open(out_json, 'w', encoding='utf-8') as fp: json.dump({'classes': classes, 'items': items}, fp, ensure_ascii=False) print(f'indexed {len(items)} samples, {len(classes)} classes') build_index('data/train', 'train_index.json')这样做的额外好处是,训练集和验证集的类别映射可以对齐——用同一份classes列表,避免出现训练集 class_a 是 0、验证集 class_a 是 1 这种灾难。
4.2 Dataset 实现与单元自检
写完之后不要急着塞进 DataLoader,先做单样本自检。我踩过最冤的坑是:模型结构、损失函数、优化器全都对,训练 loss 就是不动,最后发现 transform 里 Normalize 的均值和标准差写反了,导致输入分布完全错位。单样本自检能在几秒钟内暴露这类问题:
ds = ImageFolderDataset('data/train', transform=train_tf) print('len:', len(ds)) img, label = ds[0] print('img:', img.shape, img.dtype, img.min().item(), img.max().item()) print('label:', label, type(label)) # 期望:img 是 torch.float32,shape 为 (3, 224, 224), # 归一化后数值大致在 [-2.5, 2.5] 区间如果 min/max 落在 0~1 之间,说明 Normalize 没生效;如果落在 0~255,说明连 ToTensor 都漏了;如果 shape 是 (224, 224, 3),说明 transform 顺序错了,ToTensor 没放在最后。这三条几乎覆盖了八成的 transform 书写错误。
再补一个样本一致性检查:同一个 index 连续取两次,如果开了随机增强,两次结果应该不同;把 transform 换成无随机的版本,两次结果应该完全相同。这能帮你确认增强到底有没有生效。
4.3 DataLoader 组装与一个 epoch 跑通
组装阶段我把参数分成两组:性能组(num_workers、pin_memory、persistent_workers)和语义组(batch_size、shuffle、drop_last、collate_fn)。语义组决定训练正确性,先定;性能组在正确性验证通过之后再来压榨。
import torch from torch.utils.data import DataLoader train_ds = ImageFolderDataset('data/train', transform=train_tf) val_ds = ImageFolderDataset('data/val', transform=val_tf) train_loader = DataLoader( train_ds, batch_size=64, shuffle=True, drop_last=True, num_workers=8, pin_memory=True, persistent_workers=True, prefetch_factor=2, ) val_loader = DataLoader( val_ds, batch_size=128, shuffle=False, drop_last=False, num_workers=4, pin_memory=True, ) device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') for epoch in range(3): model.train() for step, (imgs, labels) in enumerate(train_loader): imgs = imgs.to(device, non_blocking=True) labels = labels.to(device, non_blocking=True) loss = criterion(model(imgs), labels) optimizer.zero_grad(set_to_none=True) loss.backward() optimizer.step() if step % 50 == 0: print(f'epoch {epoch} step {step} loss {loss.item():.4f}')set_to_none=True是个小优化,把梯度置为 None 而不是填零,省一次写操作,新版本里默认就是这个行为。non_blocking=True必须和pin_memory=True配合才有意义,单独用没有效果。
4.4 性能验证:量出瓶颈在哪
跑通之后,下一步是搞清楚时间花在哪。我通常在训练循环里插两个打点:一个在取完 batch 之后,一个在 step 之后,分别累计数据时间和计算时间:
import time data_time, step_time = 0.0, 0.0 end = time.perf_counter() for step, (imgs, labels) in enumerate(train_loader): data_time += time.perf_counter() - end imgs = imgs.to(device, non_blocking=True) labels = labels.to(device, non_blocking=True) loss = criterion(model(imgs), labels) optimizer.zero_grad(set_to_none=True) loss.backward() optimizer.step() step_time += time.perf_counter() - end end = time.perf_counter() if step >= 100: break print(f'data: {data_time:.2f}s step: {step_time:.2f}s ' f'ratio: {data_time/(data_time+step_time)*100:.1f}%')判读规则很直接:数据时间占比超过 20%,说明管道跟不上,优先加num_workers、开persistent_workers、检查是不是在读网络盘;占比低于 5%,说明 GPU 是瓶颈,可以试着加 batch_size 或者上混合精度;卡在 10% 上下,通常是 CPU 增强太重,考虑把重增强换成轻量的、或者用更快的解码库。
5. 常见问题与排查实录
5.1 worker 报错、卡死与内存暴涨的排查路径
多进程相关问题最烦人的地方是报错信息往往指向不到真正的原因。我整理了几条固定的排查顺序,基本能覆盖九成情况。
第一类,进程直接挂掉且没有 traceback。这通常是子进程里发生了段错误,最常见诱因是 OpenCV 和 PyTorch 的多线程冲突,或者某个 C 扩展在 fork 后状态损坏。我的做法是先设num_workers=0跑一遍确认逻辑本身没问题,再逐个加 worker 定位。如果确认是库冲突,可以在训练入口处限制线程数:
import cv2, torch cv2.setNumThreads(0) # 避免 OpenCV 内部线程与 DataLoader worker 抢核 torch.set_num_threads(1) # 单 worker 内不再并行的算子并行第二类,卡在第一轮迭代不动。多半是__getitem__里有死锁或者阻塞操作,比如在 worker 里开了multiprocessing的子进程、或者在等一个永远不会来的网络请求。也可能是日志库的多进程写入冲突,这个问题在 Windows 上尤其常见,表现是所有 worker 都卡住。解决办法是把打印挪到主进程,或者给 worker 传独立的日志文件句柄。
第三类,内存随 epoch 线性上涨。首先是persistent_workers=True加上数据集在__init__里预加载了大对象,导致 worker 常驻内存;其次是__getitem__里把结果 append 到类成员 list 上(以为是做缓存,实际是无界增长);还有一种隐蔽情况是图像文件句柄没有关闭,句柄泄漏到一定程度后系统拒绝新打开。定位方法是用tracemalloc在单个 worker 里采样,或者干脆把__getitem__里所有非必要的缓存语句删掉试一遍。
提示:在 Linux 上,
file_descriptor这类信号能帮你快速判断是不是句柄泄漏。看到RuntimeError: received 0 items of ancdata之类的报错,八成是共享内存或者句柄资源耗尽,降到num_workers=0先让训练跑起来,再慢慢查。
5.2 随机性不可复现的四个来源
"同样的代码跑两次结果不一样"是另一个高频困扰。DataLoader 涉及随机性的地方比我最初以为的多,一共四处,必须全部覆盖才能做到可复现。
第一处是样本顺序,由shuffle用的RandomSampler决定,它走的是 DataLoader 自己的generator。要固定就得显式传一个带种子的 generator,光设torch.manual_seed不够。第二处是每个 worker 的增强随机流,PyTorch 会自动把 torch 的种子设为base_seed + worker_id,base_seed由主进程随机生成,所以还需要通过worker_init_fn统一设定 numpy 和 python random 的种子。第三处是验证集,虽然关了 shuffle,但如果验证时的 transform 里混进了随机操作(复制训练 transform 时最容易带上),指标照样会抖。第四处是 cuDNN 的非确定性算法,某些卷积和注意力实现在默认配置下每次的累加顺序不同,需要额外开启确定性模式。
import random, numpy as np, torch def seed_everything(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False def worker_init_fn(worker_id): seed = torch.initial_seed() % 2**32 np.random.seed(seed) random.seed(seed) g = torch.Generator() g.manual_seed(42) train_loader = DataLoader( train_ds, batch_size=64, shuffle=True, num_workers=8, worker_init_fn=worker_init_fn, generator=g, )torch.initial_seed()在每个 worker 里返回的正是base_seed + worker_id,所以用它可以派生出互不相同但可复现的 numpy 种子。关掉benchmark会损失一点速度,做对照实验时值得,正式训练时我会把它改回 True。
5.3 常见问题速查表
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| 第一个 batch 特别慢,之后正常 | worker 冷启动、索引扫描 | 开 persistent_workers,索引预构建 |
| 训练 loss 长期不动 | transform 未生效或归一化写反 | 单样本自检,打印 min/max |
| 报错 stack expects 形状不一致 | 样本尺寸不统一或未 ToTensor | 检查 transform 链,确认 Resize 在前 |
| BatchNorm 报单样本错误 | 最后一批只有 1 条 | 训练集 drop_last=True |
| 多进程下 OOM | prefetch 总量过大 | 降 num_workers 或 prefetch_factor |
| 每个 epoch 数据顺序相同 | generator 种子固定且未推进 | 每个 epoch 前重设或复用同一 generator |
| IterableDataset 重复训练 | worker 间未分片 | get_worker_info 按 id 切片 |
| 验证指标每次有微小差异 | 验证 transform 含随机成分 | 复制时剥离所有随机操作 |
这张表里的每一条我基本都亲身踩过一遍,尤其是第三条,形状不一致的报错信息往往指向collate_fn,让很多人误以为是拼装逻辑写错了,实际是上游Dataset返回的尺寸就没对齐。
6. 我在实际使用中攒下的几点体会
第一点关于调试顺序。遇到数据管道的问题,永远先把num_workers=0、batch_size=2、shuffle=False这三个降级开关打上,把小规模的两三个 batch 人工打印出来看形状和数值范围,确认逻辑无误后再逐项恢复。我见过太多人一上来就在 8 个 worker 的环境里查问题,结果连报错来自哪个 worker 都分不清。
第二点关于预取和内存的关系。数据管道的性能提升不是越多越好,它有一个明确的资源边界。我的习惯是先把num_workers定在实测拐点,再把prefetch_factor从 2 开始上下各试一档,观察 GPU 利用率和内存占用的变化,找到那个"GPU 等数据的时间低于 5%、同时内存留有余量"的点就停手,不要追求极限。
第三点,别过早优化。如果你的数据集只有几千张小图,全部能被系统缓存住,那num_workers=4和num_workers=16的差别可能只有几个百分点,把时间花在模型和损失函数上的收益要大得多。数据管道的优化应该在确认它真的是瓶颈之后再做,而确认的方法就是上面那段打点代码,几行就能给出答案,比凭直觉调参数可靠得多。
最后分享一个小习惯:我习惯在Dataset.__getitem__的开头加一行可选的调试输出,用一个环境变量控制开关,只在需要时打开。这样做的好处是排查时不用改代码逻辑,关掉开关就是零开销,比事后往回删print干净得多。数据管道这部分代码一旦写稳定,后面半年都不用再动,前期多花两小时把它写对、量清楚,后面每一次训练都在替你省时间。