ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

PyTorch DataLoader加速指南:数据加载瓶颈排查与参数优化实践

PyTorch DataLoader加速指南:数据加载瓶颈排查与参数优化实践 搞深度学习的人应该都体会过这种场景GPU利用率在30%徘徊显卡风扇懒洋洋地转训练一个epoch的时间全花在等数据上。模型本身没问题算法也没问题但就是快不起来。我早期做图像分类的时候以为是显卡太弱后来把数据加载链路翻了个底朝天才发现瓶颈根本不在算力而在PyTorch的数据加载器DataLoader身上。把这条链路优化好之后同样的硬件训练速度直接翻了将近三倍。这篇东西不是讲理论是把我实际调过的方案、踩过的坑、试过有效的配置全部整理出来。无论你是在做图像分类、目标检测还是跑LSTM、Transformer这类序列模型只要你的训练脚本里用到DataLoader这篇文章里的思路应该都能帮上忙。我会从瓶颈定位开始讲然后逐个拆解DataLoader的关键参数再到Dataset层面的硬核优化最后附上可以直接抄的配置模板和排查经验。1. 慢在哪儿先把数据瓶颈揪出来再动手1.1 数据加载链路里谁在拖后腿很多人觉得数据加载就是把图片从硬盘搬到内存再塞给GPU听上去很简单。实际上这条链路比你想象的长得多磁盘读取文件、解码图像或者解析文本、做数据增强和归一化、把numpy数组转成Tensor、按batch拼接、从CPU内存拷贝到GPU显存。每一个环节都可能是瓶颈而且不同项目瓶颈位置完全不同。我见过最夸张的一个例子有人用机械硬盘直接读几万张小图每张图几十KB训练时硬盘灯狂闪GPU空闲率超过70%。问题不在解码也不在预处理就是小文件随机读取太慢。机械硬盘的随机IOPS只有几十到一百出头你一个epoch要读几万次光寻道时间就把训练拖死了。还有人是卡在图像解码上CPU单线程解JPEG速度远远跟不上GPU吞数据的速度。默认情况下DataLoader的num_workers0这意味着数据加载完全在主进程里执行和模型训练串行跑。你在GPU算前向反向的时候CPU闲着等GPU算完了CPU才开始读下一批数据然后GPU又闲着。一进一出整个训练过程充满了等待。这是绝大多数人训练慢的第一个隐藏原因。1.2 用肉眼和命令判断瓶颈想确认瓶颈是不是在数据加载最简单的办法是看GPU利用率。训练时开一个终端跑nvidia-smi dmon或者用nvtop实时观察。如果GPU利用率长时间低于90%同时CPU利用率也不高那大概率就是训练代码本身有同步等待最常见的就是数据供给不上。更严谨一点的做法是做一个对照实验写一个假的DataLoader或者直接在训练循环里用同一批随机数据反复训练看看跑一个step要多久。拿这个时间和真实数据训练的时间对比如果差异非常明显那就说明数据加载确实在拖后腿。我自己的习惯是先用随机Tensor跑100个step记录耗时再切回真实DataLoader跑100个step两者一对比瓶颈在哪立刻心里有数。还有一种情况容易被忽略数据加载没问题但GPU也没用满。这时候要检查是不是模型太小、batch太小或者训练循环里有频繁的同步操作。比如每次step都调用.item()、.cpu()往Python里同步数据也会让GPU频繁等待CPU。这种问题不属于数据加载但表现很像排查的时候要心里有数。2. DataLoader自带的加速开关参数调到极致2.1 num_workers多进程并行加载的正确姿势num_workers应该是大家最熟悉的参数了但怎么设才合理很多人是靠玄学。我见过有人直接把num_workers设成CPU核心数结果训练反而更慢因为他们忽略了多进程之间也有协作开销以及内存带宽的瓶颈。先讲原理DataLoader在num_workers0时会启动多个worker进程每个worker独立地从Dataset里取数据、做预处理。主进程只负责等worker把数据准备好然后用collate_fn拼成batch。这样数据加载的耗时和GPU计算就能重叠起来GPU算着当前batchworker们已经在准备下一个batch了。设置多少合适我的经验公式是先看机器物理核心数然后设为物理核心数的四分之一到二分之一。比如一台8核16线程的机器设4个worker通常就够用了你要是用16核处理器设8个左右。注意是物理核心不是逻辑线程。配置太高会导致多个进程争抢CPU预处理本身并没有更快反而把宝贵的CPU时间浪费在线程切换上。还有一个关键点num_workers不是越大越好它受限于数据预处理的计算量。如果预处理很轻量比如只做归一化和ToTensor那么2到4个worker就能喂饱GPU如果预处理很重比如图像随机裁剪、旋转、色彩抖动全套上那可能需要8个甚至更多worker才能把数据准备速度提上来。我自己调参的习惯是每次翻倍试观察GPU利用率和训练速度的变化如果翻倍后没有明显提升就回退到上一档。2.2 pin_memory与prefetch_factor把数据提前备好pin_memoryTrue是很多人忽略的一个参数它解决的是CPU到GPU拷贝慢的问题。默认情况下数据从CPU内存拷贝到GPU显存需要经过一次中间缓冲而开启pin_memory后数据会被放到锁页内存里GPU可以直接通过DMA方式访问拷贝速度明显提升。这个参数在数据量小的时候感觉不出来但当你把batch size调大或者图片分辨率高的时候差距非常明显。光设置pin_memoryTrue还不够你要配合.to(device, non_blockingTrue)使用。在训练循环里把batch batch.to(device)改成batch batch.to(device, non_blockingTrue)。这行代码的含义是让数据拷贝操作不阻塞当前线程这样GPU在前向计算的同时数据拷贝可以和计算重叠。如果你忘记写non_blockingTruepin_memory的效果会打折扣。prefetch_factor是控制每个worker预取多少批数据的参数默认是2意味着每个worker在训练过程中会提前准备2个batch的数据。如果你的机器内存足够大这个值可以调到4甚至8。预取越多worker就越不容易因为等待主进程消费而闲置GPU拿到数据的间隔就越短。但注意预取会占用内存每个batch的数据都会被保存在内存里内存吃紧的时候要适当调低。2.3 persistent_workers与timeout别让worker反复启动PyTorch从1.7开始支持persistent_workersTrue。这个参数解决的痛点是每个epoch结束时默认行为会关闭所有worker进程下一个epoch再重新启动它们。启动进程是有开销的如果你的数据集比较小每个epoch只需几十秒这个重启开销会占掉不少时间。我做过一个测试用同一个数据量很小的数据集开启persistent_workersTrue之后每个epoch的加载时间从原来的3秒左右降到了1秒出头。数据集大的时候这个收益不那么明显因为worker本身在持续工作重启次数少。但加上它总归是白赚的前提是你的代码没有在epoch之间改变Dataset的状态。如果你的Dataset里有__len__依赖随机种子之类的东西每次epoch需要重新初始化那就不能开这个参数。timeout参数则用于控制等待worker返回数据的最大时间默认是0表示无限等待。如果你的文件系统偶尔卡顿某个worker因为IO抖动没能及时返回数据主进程就会一直等着。这种情况下可以把timeout设成30到60秒让主进程超时后跳过或者重试避免整个训练卡死。3. 再进一步Dataset和IO层的硬核优化3.1 预处理别在训练时重复做很多人把图像缩放、裁剪、归一化这些操作全部写在__getitem__里导致每个epoch都要重复计算一遍。如果这些操作每次结果都一样那就应该离线做好训练时直接读处理好的结果。举个例子你有一批固定尺寸的图片离线把所有图片resize成256x256并保存成npy或者Tensor格式训练时__getitem__只需要读文件转Tensor几乎不消耗CPU。我优化过一个图像分类项目原来__getitem__里做了读图、resize、归一化三步每张图耗时约15毫秒。离线预处理之后每张图只需读取一个已经处理好的张量耗时降到不到2毫秒。一个epoch一万张图光这部分就从150秒降到了20秒。当然如果你的数据增强是随机的那这部分计算省不掉但你依然可以把静态部分提前算好运行时只做随机部分。这里有个技巧很多人不知道用torch.load加载整个预处理后的数据集到内存或者用numpy的memmap形式打开。中小型数据集比如几万张图完全可以直接全部加载进RAM训练时__getitem__就是一次内存数组索引比任何磁盘优化都快。我第一次这么干的时候感觉像打开了新世界的大门数据加载彻底变成零成本。3.2 小文件是性能杀手打包成紧凑格式前文提到了小文件随机读取的问题这是数据加载最常见的隐形杀手。磁盘读一个4KB的小文件和读一个100MB的大文件耗时差别可能只有几倍但IOPS差距巨大。机械硬盘随机读小文件每秒只能读几十个SSD也不过几千个而你的训练每秒可能需要上千张图。解决方案是把所有小文件打包成少量大文件。常用的格式有HDF5、LMDB、TFRecord或者干脆用webdataset把样本封装成tar包。打包之后磁盘从随机IO变成了顺序IO读取速度提升一个数量级。我的一个目标检测项目原始图片两万多张打包成HDF5之后数据加载耗时降了差不多六倍。注意HDF5的读取也不是完全没有开销你要合理设置chunk缓存否则频繁随机访问时性能可能还不如原始文件。还有一个更简单的方案把图片统一转成numpy数组格式npy存储然后使用np.load(..., mmap_moder)映射到内存。这样读取时操作系统会按需加载页面既能享受内存级访问速度又不会真的把整个文件占满RAM。我用这个方案处理过一个几十GB的数据集效果很稳定。3.3 自定义Sampler和collate_fn的降本增效DataLoader默认的Sampler是随机采样这在大多数场景下没问题。但在分布式训练或某些特殊任务里默认行为不够用。比如多卡训练时你需要用DistributedSampler来保证每个进程看到的数据不重叠且被均匀打散。这是DDP训练里必须处理的一环很多人直接在DDP里用默认的随机采样器结果每个卡都读到了相同的数据训练效果一团糟。collate_fn同样值得关注。默认的collate行为是把一堆样本堆叠成batch对不同长度的序列数据默认堆叠会失败或者强制padding。一个高效的collate_fn可以帮你在数据准备阶段就完成padding和mask生成而不是把工作留给训练循环。比如NLP任务里标准的做法是在collate里按batch最大长度做padding这不仅方便还能配合长度分桶采样减少无效padding计算。再分享一个进阶思路如果你有多个数据源要做混合采样比如图像和文本联合训练可以使用WeightedRandomSampler或者自定义采样器控制每个数据源的采样比例。我试过用这个方式平衡类别不均衡的数据集训练收敛速度和最终指标都比直接在Dataset里重复采样要好。4. 实战配置一套可以直接抄的模板4.1 单卡训练通用配置下面这个配置是我在单卡训练里用得最多的一套模板适合大多数图片和文本任务dataloader DataLoader( dataset, batch_size64, shuffleTrue, num_workers8, # 物理核心数的一半实测调整 pin_memoryTrue, prefetch_factor4, persistent_workersTrue, drop_lastTrue, )配合训练循环里的写法for batch in dataloader: inputs batch[pixel_values].to(device, non_blockingTrue) labels batch[labels].to(device, non_blockingTrue) outputs model(inputs) loss criterion(outputs, labels)这套组合背后的逻辑是num_workers8保证CPU并行预处理能力足够pin_memory和非阻塞拷贝让数据上GPU的路径最短prefetch_factor4让worker提前备好几个batch的货persistent_workers省去epoch间重启的开销。drop_lastTrue在数据量不能被batch整除时能防止最后一个小batch引起显存抖动。4.2 多卡DDP训练的数据加载配置DDP训练下每个进程都要维护一个独立的DataLoader通过DistributedSampler保证数据在各卡间均匀分配。配置上要特别留意worker总数量一台机器有N张卡如果你每个进程设置8个worker那整个节点就会有8N个worker进程可能瞬间把CPU打满。我通常的做法是每张卡分配2到4个worker优先保证GPU利用率而不是盲目堆worker。sampler DistributedSampler( dataset, num_replicasworld_size, rankrank, shuffleTrue, seedseed, ) dataloader DataLoader( dataset, batch_sizebatch_size_per_gpu, samplersampler, num_workers4, pin_memoryTrue, prefetch_factor4, persistent_workersTrue, )要注意在训练循环的每个epoch开头调用sampler.set_epoch(epoch)否则每个epoch虽然会打乱数据但打乱的随机序列是相同的影响训练效果。4.3 验证瓶颈到底卡在哪里的profile脚本我优化数据加载时用的办法很简单直接给__getitem__里的每个环节打点计时。不要靠猜用数据说话。下面的脚本可以快速定位耗时大头import time from tqdm import tqdm def profile_dataset(dataset, num_samples1000): times {read: 0, preprocess: 0, to_tensor: 0} for i in tqdm(range(num_samples)): t0 time.time() sample dataset.load_raw(i) # 假设这是读文件 t1 time.time() sample dataset.preprocess(sample) # 假设这是预处理 t2 time.time() tensor dataset.to_tensor(sample) # 假设这是转Tensor t3 time.time() times[read] t1 - t0 times[preprocess] t2 - t1 times[to_tensor] t3 - t2 print(times)用这个脚本跑一遍如果read占了大头就去优化IO比如打包格式或者换固态盘如果preprocess占了大头就考虑离线预处理或者用GPU做增强如果to_tensor慢检查是不是图像数据格式转换里有隐式拷贝。5. 常见坑与排查方法实录5.1 worker设多了反而更慢遇到过不止一次把num_workers从4调到16训练速度不进反退。排查后发现两个原因一是机器内存带宽有限多个worker同时解码和拷贝数据内存总线被占满CPU反而在等待二是磁盘IO并发抢占机械硬盘尤其严重多进程同时读不同位置的文件磁头来回寻道比单进程还慢。解决方案是安装htop和iotop训练时观察CPU和磁盘IO情况。如果你看到整体CPU占用不到100%但训练更慢了多半是内存带宽或者磁盘IO到达上限。这时候减小num_workers反而能提升速度。还有一个容易被忽略的点num_workers增加后内存占用线性上升如果系统开始用swap交换分区那速度会断崖式下跌这个在服务器上特别常见。5.2 CUDA out of memory和数据加载的纠缠显存溢出有时候和数据加载配置有直接关系。prefetch_factor调大会让DataLoader在内存里保存更多batch数据虽然用的是CPU内存但当batch_size很大或者单样本很大时CPU内存也会吃紧系统进入swap最终拖垮整个训练。更隐蔽的是pin_memory会锁定物理内存页不可被swap大量锁页内存会压缩其他程序可用的内存空间。我建议内存小于32GB的机器慎开prefetch_factor4加num_workers8的组合。如果出现奇怪的卡顿或者偶尔的cuda OOM先试着把prefetch_factor降回2把num_workers减半很多时候问题就消失了。另外pin_memory占用的锁页内存在多进程场景下是每worker累加的算内存的时候要把这个算进去。5.3 Windows和WSL下的隐藏差异Windows下DataLoader的多进程机制和Linux不同Windows用spawn方式启动worker每次启动都要重新导入主模块开销比Linux的fork大很多。如果你在Windows上做深度学习num_workers设得过高反而可能拖慢训练。建议Windows上保持num_workers2到4并且把训练代码放进if __name__ __main__:块里否则会无限递归启动进程。如果你是在WSL2里面跑PyTorch要特别注意把数据集放在Linux文件系统下而不是/mnt/c、/mnt/d这些挂载的Windows目录。WSL2跨文件系统访问性能非常差小文件读写可能比原生Linux慢几十倍。我之前在WSL2里遇到过类似问题同样的数据集放在Linux主目录下IO速度正常放在/mnt/d下训练速度直接掉了六成。这个坑隐藏得很深因为数据加载慢的表现和GPU占用低会被误判成环境没配好。最后再分享一个我后来经常用的土办法如果数据集总大小在内存能装下的范围内直接用脚本把所有样本读进RAM做成一个list训练时__getitem__就是一次list索引加数据增强。这个办法粗暴但非常有效省掉了所有IO相关的烦恼。我处理过的一个文本分类任务数据集大概8GB内存有64GB全量加载到内存之后数据加载时间从每epoch接近40秒直接变成2秒出头训练效率提升立竿见影。数据加载这件事很多时候不是你机器不够好是数据供应的方式没匹配上你的硬件配置。多花一点时间把这条链路调到最优远比换一张更贵的显卡划算。
返回列表