先说个真实经历。上半年我在调一个图像检索项目的训练流程,数据量其实不大,训练集一万多张小图,单张解码加缩放也就几毫秒。但训练时 GPU 经常饿着,某个 epoch 能卡出几十次等待。最初我第一反应就是老一套:把 DataLoader 的num_workers往上加。从 4 加到 8,非但没变快,一个 epoch 反而慢了十几秒。当时我很确定问题不在预处理算子,后来把读取、解码这些 IO 逻辑拆开单测,才发现瓶颈根本不在 CPU 计算,而在 DataLoader 多进程的“回程运输”。最后我把加载并发从 DataLoader 的 workers 挪到了 Dataset 内部,用一个线程池来读文件和解码,实测比之前开 8 个 worker 快了不少。
这篇就把这个现象掰开揉碎讲清楚:为什么 Dataset 内多线程加载,有时候比 DataLoader 里堆 workers 还快,以及在你自己项目里怎么安全复现这个收益。
1. 先弄清楚:DataLoader 的 workers 到底在优化什么
1.1 进程与线程差了哪些东西
PyTorch 的 DataLoader 设置num_workers>0时,底层是multiprocessing,每个 worker 是独立的进程。进程的好处是天然绕开 Python 的 GIL,多个 CPU 核能真正并行跑你的预处理逻辑。代价呢?每个 worker 有自己的完整 Python 运行时,有自己的内存空间,互相之间不能直接拿对方的数据,只能靠进程间通信把样本传回主进程。
我用一个厨房类比帮新手理解:DataLoader 开 4 个 worker,相当于在后厨安排了 4 个独立帮厨,每个帮厨有自己的案板、工具和食材仓库。每轮训练你要 4 道菜,就喊一嗓子“下一批”,4 个帮厨分别做好,通过一个传菜口递到主餐台。听起来不错,对吧?菜做的是快了,但传菜口和包装流程没跟上,出餐速度依旧上不去。
Dataset 内开线程池则是另一种模式:一个厨师的灶台边站着好几个切配工,同一个厨房、同一个内存空间,切好的菜直接放在手边,不用打包、不用传菜口、不用送回主餐台。哪条链路更顺?在 IO 密集场景里,通常是后者。
1.2 数据链路拆解:样本从磁盘走到 GPU 的每一步
要理解为什么“多进程 worker 有天花板”,最好把一条样本从磁盘到 GPU 的内存流程写下来。
- Sampler 生成当前 batch 的索引序列;
- 主进程把索引打包,通过
in_queue发给各个 worker 进程; - 每个 worker 收到索引,执行
Dataset.__getitem__,读取图片/音频/文本、解码、做 transform; - 处理完的结果通过
out_queue送回主进程。这一步如果是 Tensor,可以共享内存传输;是 PIL.Image、Python 对象、嵌套 dict,就要先 pickle 序列化再反序列化; - 主进程拿到这一批样本后,执行
collate_fn合并成 batch; - 如果开了
pin_memory,再把 batch 拷贝到固定内存页,供 GPU 异步拷贝。
每一步都有成本。索引分发的延迟、进程调度的抖动、pickle 打包解包、队列读写、主进程这边收集批次后再拼 batch……这些开销在样本本身处理时间很短时极其扎眼。小图场景尤其明显:单张解码 3ms,但来回通信可能就要 2ms,4 个 worker 叠加下来的加速比可能连 2 倍都不到。
很多人以为num_workers=8就一定比num_workers=4快,实际上样本回传的排队和序列化会成为新的瓶颈,甚至因为进程太多导致系统调度开销激增,整体变慢。这正是我开头那个“8 worker 比 4 worker 还慢”的原因。
2. Dataset 内线程池为什么更快
2.1 IO 密集型任务中 GIL 不卡人
Python 的 GIL,通常被认为是多线程的紧箍咒。这个说法没毛病,但要分场景。GIL 会卡住的是“纯 Python 代码的 CPU 密集运算”,比如一个百万级元素的纯 Python 循环。到了底层 IO 调用和 C 扩展操作,事情就不一样了。
文件读写、网络请求、PIL.Image.open的 JPEG 解码、libjpeg缩放、numpy 的数组操作,这些底层实现都会在适当位置释放 GIL。释放期间,线程池里的其他线程就能拿到解释器权限继续干活。也就是说,一个线程在等磁盘数据时,另一个线程正在做上一次的解码,第三个线程可能正在做归一化,CPU 被塞得满满当当。
生活化版本:IO 密集任务里,线程池像流水线上的工人,每个工人等配送原料的时候,已经能腾出手去处理手上剩下的活儿。多进程则像开多个独立小作坊,每个作坊都有一整套独立设备,传输成本自然更高。
所以结论是:如果 Dataset 的耗时主要集中在“读文件、解码、网络拉取、解压”这类让出 GIL 的操作上,多线程不但不会被 GIL 卡死,反而能获得接近多进程的并行效果,还省掉了多进程整套通信开销。
2.2 省掉了哪些隐形成本
多进程线程池这种方案最直观的好处,是省掉 DataLoader 多进程 worker 链路里的每一笔“快递费”。
- fork/exec 初始化。多进程 worker 启动时,子进程要初始化 Python 解释器、加载库、复制父进程状态。Dataset 线程池就不存在这个问题,线程在已有进程里随手拉起来就能用。
- IPC 队列传输。多进程的结果必须从 worker 进程传回主进程。Tensor 有共享内存优化能少一点拷贝,但也要走队列和共享内存索引的映射;PIL.Image、自定义对象、嵌套字典这些,pickle 序列化和反序列化一步都逃不掉。Dataset 线程池里,数据本来就在主进程的地址空间里,管道、队列、pickle 全部不存在。
- 内存叠加。每个 worker 进程都是独立 Python 运行时,库、缓存都要各自占一份。开 8 个 worker,就是 8 份解释器开销。线程池是共享同一个进程内存,多线程只多了栈空间和少量线程管理开销,样本数据也只存在一处。
- 调度成本。进程太多,操作系统调度频繁切换,CPU 上下文切换带走大量时间。线程虽然也在排队,但线程切换成本比进程切换低一个量级。
这些开销虽然单看都很小,但训练时一个 epoch 要循环几千上万次,一次多几毫秒,累计起来就是几分钟的差距。
2.3 什么时候这个优势会失效
必须有边界意识。Dataset 内线程池不是万能的,最常见的失效场景是:你的__getitem__里全是 CPU 密集的纯 Python 运算。
比如下面这些情况:
- 用纯 Python 写了一个复杂的随机增强逻辑,在几百万像素的 numpy 数组上做多层循环;
- 自定义的重型特征提取,比如对每张图做几十次随机裁剪再逐像素操作;
- 大量依赖纯 Python 的控制流和计算操作,底层没有释放 GIL 的 C 扩展支撑。
在这些情况下,多线程会被 GIL 锁得死死的,4 个线程跑起来可能比 1 个线程还慢。这时正确的做法还是回归多进程,或者用 vectorized 的 numpy/torch 操作替代纯 Python 循环,让计算发生在释放 GIL 的底层库里。
判断方法很简单:先单独测Dataset.__getitem__的总耗时代码环境,再看把 IO 部分去掉后的耗时。如果去掉 IO 后耗时依然很长,说明 CPU 计算占比高,线程池方案帮助有限;如果 IO 占大头,线程池的收益会非常明显。
3. 落地实现:从零写一个 Dataset 内线程池数据集
3.1 基础版本:用 ThreadPoolExecutor 包住单样本加载
最直接的写法,是在 Dataset 里维护一个ThreadPoolExecutor,每次__getitem__把单个样本的读取任务提交进去,然后取回结果。
from concurrent.futures import ThreadPoolExecutor from PIL import Image from torch.utils.data import Dataset class ThreadedImageDataset(Dataset): def __init__(self, paths, labels=None, transform=None, num_threads=8): self.paths = paths self.labels = labels self.transform = transform self.pool = ThreadPoolExecutor(max_workers=num_threads) def _load(self, index): img = Image.open(self.paths[index]).convert("RGB") if self.transform is not None: img = self.transform(img) label = self.labels[index] if self.labels is not None else None return img, label def __len__(self): return len(self.paths) def __getitem__(self, index): future = self.pool.submit(self._load, index) return future.result()这段代码虽然能用,但实际表现一般。因为每次__getitem__只提交一个任务,然后立刻result()等结果,提交和等待之间没有形成真正的流水线并发。只有当主进程连续快速调用__getitem__,比如 DataLoader 循环到下一个 batch 时,前面提交的线程已经在并发跑了,加速才体现出来。
3.2 进阶版本:预取式线程池,让加载提前跑起来
更稳的做法是“预取窗口”:在取当前样本的同时,把后面几个样本提前提交给线程池。这样主进程取第 N 个样本时,第 N+1 到第 N+W 个样本已经在后台解码了,流水线彻底盘活。
窗口大小prefetch一般取线程数的 2 到 4 倍,太小流水线转不起来,太大容易堆内存。下面的实现用_futures字典保存已提交但还没取回的 future。
from concurrent.futures import ThreadPoolExecutor from PIL import Image from torch.utils.data import Dataset class PrefetchThreadedDataset(Dataset): def __init__(self, paths, labels=None, transform=None, num_threads=8, prefetch=16): self.paths = paths self.labels = labels self.transform = transform self.prefetch = prefetch self.pool = ThreadPoolExecutor(max_workers=num_threads) self._futures = {} self._last_submitted = -1 def _load(self, index): img = Image.open(self.paths[index]).convert("RGB") if self.transform is not None: img = self.transform(img) label = self.labels[index] if self.labels is not None else None return img, label def __len__(self): return len(self.paths) def _fill_window(self, index): end = min(index + self.prefetch, len(self.paths)) for i in range(self._last_submitted + 1, end): self._futures[i] = self.pool.submit(self._load, i) self._last_submitted = end - 1 def _cleanup(self, index): for done_index in [k for k in self._futures if k <= index]: self._futures.pop(done_index, None) def __getitem__(self, index): self._fill_window(index) future = self._futures.pop(index) self._cleanup(index) return future.result()这里有个关键点:_futures.pop(index)如果刚好这个未来还没有完成,result()会阻塞等待。但因为预取窗口已经把后面的样本提前提交了,等着一个的同时,其他线程还在继续处理后续任务,不会白等。
再说说语义。DataLoader 开启shuffle=True时,它内部会用RandomSampler生成打乱后的索引序列,然后按这个序列逐个调用Dataset.__getitem__。上述预取逻辑接收的index就是打乱后的索引,窗口也是按这个乱序索引推进的,因此不会破坏 shuffle 的分布,只是把对应索引的读取工作并行化。
3.3 与 DataLoader 配合的两种模式
模式 A:num_workers=0,最推荐。
DataLoader 保持默认的num_workers=0,并发由 Dataset 内部线程池负责。这样整个数据链路只有主进程一个进程,样本读完直接在主进程内做 collate、转 tensor、pin_memory,数据传输路径最短。
dataset = PrefetchThreadedDataset(paths, transform=transform, num_threads=16, prefetch=32) dataloader = DataLoader(dataset, batch_size=128, shuffle=True, num_workers=0, pin_memory=True)模式 B:num_workers>0混用,谨慎操作。
理论上可以让 DataLoader 开 2 个 worker,Dataset 内再开 8 线程,形成两层并发。但这里有两个大坑:一是线程池对象在主进程创建后被 fork 进子进程,可能继承到锁状态,遇到诡异死锁;二是num_workers和线程数会形成乘法效应,2 个进程乘以 8 线程等于 16 个并发实体,容易把 CPU 调度打爆。非要混用的话,一个折中方案是每个 worker 内部只开 2 到 4 个线程,且用torch.utils.data.get_worker_info()在 worker 内部惰性初始化线程池,不要在主进程提前建好。我自己实测中,模式 B 的上限并不比模式 A 高多少,还凭空增加调试难度,所以一般不建议新手走这条路。
3.4 线程数到底定多少
没有固定答案,只能给经验范围。IO 密集场景,线程数可以超过 CPU 物理核数,因为大量线程在等 IO,并不占 CPU。我自己常用的规则是这样的:
| 数据源类型 | 建议线程数范围 | 原因 |
|---|---|---|
| 本地 SSD,小文件多 | 8 到 16 | SSD 并发读性能很好,可以多开线程吃满 |
| 本地机械硬盘 | 2 到 4 | 机械硬盘随机读并发太高,磁头反复寻道反而变慢 |
| 网络文件系统/对象存储 | 16 到 32 | 网络延迟高,需要更多线程叠加等待时间 |
| 内存缓存数据 | 4 到 8 | 基本没有 IO 等待,线程太多只增加切换开销 |
内存方面,线程数乘以预取窗口,再乘以单样本平均大小,就是你额外占用的峰值内存。比如一个样本解码后是 2MB,线程数 16、预取窗口 32,理论峰值缓存就是 64MB,完全可控。但如果你的样本是一段 30 秒的音频或高清视频帧,预取窗口就要调小,否则内存会明显涨。
4. 实测对比与瓶颈定位
4.1 一个图像分类场景的对比数据
为了让你对收益量级有直观感受,放一组我实际测试过的对比数据。测试环境是一台 8 核 16 线程的机器,数据存在本地 SSD,训练集 2 万张 JPEG,单张平均 300KB,读取后做随机裁剪到 224x224,再加归一化。统计的是单个 epoch 中数据加载和预处理的总耗时,不含模型前反向计算。
| 方案 | num_workers | Dataset 内线程数 | 平均 epoch 耗时 |
|---|---|---|---|
| 串行基线 | 0 | 0 | 约 55 秒 |
| DataLoader 多进程 | 4 | 0 | 约 17 秒 |
| DataLoader 多进程 | 8 | 0 | 约 18 秒(不升反降) |
| DataLoader 多进程 | 16 | 0 | 约 22 秒(调度开销爆炸) |
| Dataset 线程池 | 0 | 8 | 约 10 秒 |
| Dataset 线程池 | 0 | 16 | 约 7 秒 |
| Dataset 线程池混用 | 2 | 8 | 约 6 秒(上限提升有限) |
这个结果很有代表性:多进程开 4 个时候收益最大,8 个开始回落,16 个还不如 4 个。数据集线程池 8 线程已经比 4 进程快不少,16 线程还能再压一截。混用方案的 6 秒虽然最快,但和纯线程池 16 线程的 7 秒差距并不大,完全没到必须冒死锁风险去混用的地步。
4.2 如何准确测量数据加载耗时
要判断你的项目适不适合这套方案,先得把数据加载耗时从训练耗时里拆出来。最土但有效的办法是:只跑数据不训练,在训练循环外面套一个计时器。
import time from torch.utils.data import DataLoader loader = DataLoader(dataset, batch_size=128, shuffle=True, num_workers=0, pin_memory=True) # 预热几个 batch,把线程池、缓存、文件系统缓存都拉热 for _ in range(5): next(iter(loader)) start = time.perf_counter() for batch_idx, batch in enumerate(loader): if batch_idx >= 100: # 只统计 100 个 batch break elapsed = time.perf_counter() - start # 100 个 batch 共用时 / 100,就是平均每个 batch 的数据准备耗时 print(f"average batch load time: {elapsed / 100 * 1000:.2f} ms")更严谨一点,可以用 PyTorch 自带的torch.profiler盯着host侧事件,我平时还是觉得自写计时最直观。注意要做预热,否则第一次读取文件系统缓存是凉的,数据会明显偏慢。
4.3 什么时候该回到多进程方案
如果测试发现数据集线程池方案提速不明显,先别急着怀疑代码手滑,对照下面几种情况排查。
- transform 里面有大量纯 Python 计算或者复杂控制流。例如自定义的密集关键点标注逻辑,这时候 GIL 会牢牢压住多线程。解决办法是把瓶颈计算改写成 numpy/torch 的向量化形式,或者干脆回归 DataLoader 多进程。
- 你的样本很小,读取速度极快,比如直接从一个几十 MB 的元数据 list 里取一行整数。线程池的优势完全发挥不出来,还可能因为
ThreadPoolExecutor.submit的开销比读取本身还大,导致反而变慢。这种情况下不用做任何优化。 - 机器 CPU 核数本来就很少,比如 2 核 4 线程,线程池很容易互相抢资源,多进程也可能吃力。先优化算法本身,再考虑加载管线。
5. 常见问题与避坑记录
5.1 线程池在 fork 后容易出怪问题
这是最值得注意的坑。DataLoader 在 Linux 下默认使用 fork 方式创建子进程。如果 Dataset 的主进程实例先创建了ThreadPoolExecutor,再被 fork 到子进程里,线程池里的锁、条件变量、队列状态会被原样复制。而子进程里实际运行的线程并没有跟着复制过来,继续使用这个线程池时,轻则报错,重则卡死无响应。
我在早期混用方案时踩过一次,整个训练进程挂在第一个 epoch 不动,CPU 占用却是 0,排查了很久才定位到是 fork 后的线程池状态问题。所以我强烈建议:如果要用 Dataset 内线程池,DataLoader 的num_workers直接设 0。模式 B 里提到的“惰性初始化”虽然能规避,但属于进阶玩法,新手不需要给自己加难度。
5.2 多线程里随机数会互相污染
普通单进程 Dataset 里,你写random.random()决定是否翻转图片,模型运行得很正常。一旦改成多线程,多个线程会共享同一个 Python 全局随机数生成器。这就可能出现一种情况:两个线程在几乎同一时刻各取一个随机数,拿到的值又恰好一样,导致一批样本的增强模式重复度过高,等于改变了数据分布。
解决方案是给每个线程准备独立的 RNG。推荐用 Python 的threading.local给每个线程保存一个random.Random实例:
import random import threading _local_rng = threading.local() def _get_rng(): if not hasattr(_local_rng, "rng"): # 根据线程 id 生成一个稳定种子,避免每个线程都一样 seed = threading.get_ident() _local_rng.rng = random.Random(seed) return _local_rng.rng然后所有 transform 里的随机判断都改用_get_rng().random()。如果是 torchvision 的随机增强,也要注意它内部的随机源是不是线程安全,实测中最稳妥的办法是在每个线程池任务里用torch.random.fork_rng包裹,或者干脆在_load开头手动设置线程独立的随机种子。
5.3 内存泄漏的隐形坑
PrefetchThreadedDataset的_futures字典如果清理逻辑没写好,训练到一半就会发现内存越吃越满。把已经取回的 future 从字典里彻底 pop 掉很关键。我早期一个版本忘了删历史 future,训练 1000 个 epoch 后内存涨了快 2GB,最后定位就是这个字典在无限膨胀。
另一个容易忽略的点:线程池里的Image.open打开的文件句柄,如果没有及时释放,也会堆积。推荐统一用上下文管理器,或者确保Image.load()被调用后及时关闭文件。PIL 的Image.open是惰性解码,用完后文件对象还占着句柄,最稳的写法是:
def _load(self, index): with Image.open(self.paths[index]) as img: img.load() img = img.convert("RGB") ...5.4 断点调试体验反而变好
用 DataLoader 多进程时,__getitem__里加断点非常痛苦,调试器经常要跟到子进程里,vars 里全是 IPC 队列和共享内存的大对象,想看数据还要一步步找。而 Dataset 内线程池 +num_workers=0的方案下,__getitem__就是跑在主进程的线程里,断点、打印、直接pdb都很自然。
这一点我特别推荐给团队新手。很多同学排查数据预处理问题,第一步就卡在“怎么进到子进程看变量”。换成线程池后,调试成本大幅下降,因为样本加载逻辑不再被进程边界隔开。
5.5 与缓存、增强库组合时的注意事项
如果你的代码里用了带全局状态的三方库,比如某些统计特征归一化器在内部维护全局缓存,多线程并发访问时要注意是否线程安全。我遇到过一个典型问题:某个音频特征库的缓存 dict 不是线程安全的,多线程加载时偶发KeyError,由于错误发生在后台线程里,主进程根本感知不到,只会看到某个 batch 偶尔取不到结果。最后我用一个带锁的包装层把缓存访问串行化才解决。
如果项目本身允许,更省心的做法是把预处理拆成两段:线程池只做“读原始数据 + 基础格式转换”,把随机增强这类状态敏感的活放到 DataLoader 的collate_fn里,或者放到模型读取 batch 之后在 GPU 上做。这样既拿到 IO 并发的收益,又降低了并发改造对增强逻辑的影响。
写在最后:一个小建议
如果你近期也在为训练数据管线苦恼,我的建议是先花半小时把数据加载瓶颈测清楚,别盲目加 workers。像这篇里讲的,IO 密集场景优先考虑 Dataset 内线程池,DataLoader 保持num_workers=0;CPU 密集场景老老实实回多进程。两种方案不是互相替代的关系,而是根据你 Dataset 里“等的时间多”还是“算的时间多”来做取舍。个人经验里,八成以上的图片、音视频、文本读取场景,等待时间都占大头,线程池方案往往能收获惊喜。方法都已经摆在上面了,拿一个你手头最慢的数据集试试,对比一下数字,会比任何理论分析都直观。