ARTICLE DETAIL

资讯详情

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

PyTorch数据加载器超快优化:从参数到缓存让GPU利用率拉满

PyTorch数据加载器超快优化:从参数到缓存让GPU利用率拉满 我见过太多PyTorch项目模型结构改来改去学习率调得很勤GPU利用率却常年卡在百分之五六十。打开监控一看显存是有占用SM核心却有大把时间在空转。这时候问题大概率不在模型而在训练管线的入口——数据加载器。标题里的“PyTorch数据加载器超快”其实就是想让DataLoader喂数据的速度追得上GPU的算力。这么多年我调过不少训练脚本一个很深的体会是数据加载优化往往是最划算的优化。它不动模型结构不伤训练精度改动集中在Dataset、DataLoader参数和存储设计上收益却经常是1.5倍起步资源充足时做到3倍也不稀奇。这篇文章适合两类人。一类是已经把PyTorch训练脚本跑通、但发现GPU一直吃不饱的开发者另一类是准备做大规模训练想从一开始就把数据管线搭对的工程师。我会先讲清楚数据到底从哪慢起再给可执行的参数组合最后分享一些踩过的坑和排查思路。1. 先定位瓶颈数据加载器到底卡在哪个环节1.1 一条样本从硬盘到显存要经过哪些工序很多人对DataLoader的印象停留在“把数据一批一批送进去”但实际上一条样本从硬盘到显存中间至少要经过四段路磁盘I/O、解码与预处理、跨进程传输、GPU拷贝。每一段都可能成为瓶颈而且不同场景下的瓶颈位置完全不同。以图像任务为例单个样本的完整旅程是这样的Dataset的__getitem__里先读取图片文件到内存。如果是散落在磁盘上的jpg这里消耗的是磁盘随机读取的IOPS和带宽。接着做解码。jpg解码默认由Pillow完成单张图片几百毫秒太夸张但几十毫秒很常见。解码是纯CPU计算。然后做resize、归一化、数据增强这些仍然是CPU操作计算量比解码小一些但次数多。样本被放进队列由子进程通过IPC传递回主进程。主进程把多个样本用collate_fn拼成batch再拷贝到GPU显存。DataLoader能加速的核心原因就是靠多个worker子进程同时执行前三步把CPU端的开销藏到GPU跑上一次迭代的时间里。优化数据加载本质上就是让这个流水线的每一站都能平稳、快速地衔接起来。1.2 用插桩和profiler量化每一环耗时别凭感觉调参我见过很多人拿到DataLoader调优问题第一反应是把num_workers调大然后看GPU利用率有没有变化。这样做不是不行但属于摸黑走路。正确的做法是先量化每一环的耗时找到真正的瓶颈再针对性下手。最简单的插桩方法是在自定义Dataset里分段计时import time from torch.utils.data import Dataset class TimedDataset(Dataset): def __init__(self, dataset): self.dataset dataset self.io_time 0.0 self.process_time 0.0 self.count 0 def __getitem__(self, idx): t0 time.time() raw self.dataset.read_raw(idx) # 模拟磁盘读取 t1 time.time() sample self.dataset.preprocess(raw) # 模拟解码预处理 t2 time.time() self.io_time (t1 - t0) self.process_time (t2 - t1) self.count 1 return sample如果io_time占比高说明磁盘访问策略有问题如果process_time占比高说明解码、resize这些CPU操作是罪魁祸首。条件允许的话配合torch.profiler或者简单的cProfile再确认一遍主进程侧的开销几轮下来基本就能判断该往哪个方向使劲。2. 四个参数决定DataLoader性能worker、prefetch、pin_memory、persistent怎么搭2.1 num_workers与CPU核心数不是越多越快把num_workers调大确实能让多个样本并行读取但它不是越高越好而且很多人亲手把它的效果调反过。每个worker是一个独立的Python进程理论上可以绕过GIL并行执行。但实际能跑多快取决于CPU核心数、磁盘I/O能力和任务类型。如果你的预处理是CPU密集型的worker数量一般设置为物理核心数到两倍之间比较稳妥。纯I/O密集型任务worker可以稍微多一些因为CPU大部分时间在等待磁盘。我的经验是先设成os.cpu_count() // 2跑几个batch观察GPU利用率再每次翻倍找到拐点。num_workers0不建议在正式训练中使用它让主进程同步加载数据GPU会被迫等待但调试小样本集、排查Dataset逻辑时反而方便。需要注意的是worker数量一旦超过CPU可用核心数CPU会开始频繁切换进程性能反而下降。如果机器本身还在跑推理服务或者做数据预处理更要预留核心余量。2.2 prefetch_factor让worker提前干活但要付内存账prefetch_factor是一个被很多人忽视的参数。默认值是2含义是每个worker最多预取2个batch的数据。预取的本质是让worker不空等当前一个batch被主进程取走后worker提前把后面两个batch准备好。配合num_workers预取总量近似等于预取样本数 ≈ num_workers * prefetch_factor * batch_size假设num_workers8prefetch_factor2batch_size64那么内存里最多会堆着1024个样本的中间结果。如果每张图预处理后的Tensor是几MB这个内存开销就很可观了。调高prefetch_factor能明显掩盖CPU端的抖动前提是内存充足。一般来说内存能扛得住的前提下从2调到4或8对训练吞吐有帮助。但如果内存本身紧张过高的prefetch会触发swap结果还不如默认值。2.3 pin_memory和non_blocking的配合逻辑pin_memoryTrue会让DataLoader在内存中分配锁页内存。锁页内存的物理地址固定GPU可以直接通过DMA访问省去了一次内存拷贝因此从CPU到GPU的传输会快不少。这个参数最典型的用法是配合non_blockingTruefor batch in dataloader: x, y batch x x.cuda(non_blockingTrue) y y.cuda(non_blockingTrue)non_blockingTrue的意思是拷贝操作不阻塞当前进程主进程可以继续干别的事。但只有数据原本就在锁页内存里这个异步拷贝才有意义如果pin_memoryFalse非阻塞拷贝在后台仍然需要先做一次页缓冲拷贝收益很小。这里的代价是内存占用。锁页内存不容易被系统换出内存小的机器开pin_memoryTrue加上高prefetch_factor很容易把内存吃爆。遇到这种情况优先降低预取数量而不是急着关掉pin_memory。2.4 persistent_workers每个epoch重启worker的隐性成本默认情况下每个epoch结束时DataLoader会关闭所有worker进程下个epoch再重新启动。重启进程不是零成本它要重新导入Python环境、重新初始化Dataset和依赖库严重时一个epoch之间的空白期能有几十秒。persistent_workersTrue让worker进程在epoch之间保持存活跳过了重启这一步。对于一次训练动辄几十上百个epoch的场景这个参数常常能省出可观的墙钟时间。但它有一个容易踩的坑worker复用时随机数状态可能不会像普通重启动那样被重新初始化导致多个epoch的数据增强序列完全相同。模型在这种数据上反复训练轻则收敛变慢重则精度下降。解决办法是配合worker_init_fn在每个epoch重新设置随机种子后面第5部分会给出代码示例。3. 把Dataset和预处理重构一遍收益比调参数大得多3.1 别再让__getitem__每次做解码和重活文件的存储格式很重要先把最扎心的一句话放在前面如果你的Dataset每次__getitem__都从磁盘随机读一个小文件那调DataLoader参数只是杯水车薪。随机读大量小文件是磁盘最不擅长的工作SSD还能扛一扛机械盘基本就废了。我做过一次很典型的对比实验同样是1万张256×256图片用散文件存储时每次读取和预处理一个样本大约耗时30毫秒改成LMDB数据库后读取耗时掉到之前的四分之一左右。如果再进一步把预处理后的结果直接缓存成npy文件整体耗时能再降一个量级。常见的优化方向有把所有小文件打包成WebDataset或LMDB用顺序读替代随机读。如果坚持用目录存放图片至少先压缩成tar包再读取。对超大文件使用支持内存映射的格式比如np.load(..., mmap_moder)避免一次性把整个文件载入内存。这里提一下WebDataset。它的思路是把样本打包成tar包训练时按顺序读取同时自己维护打乱逻辑。这种做法对高带宽的顺序读非常友好也天然适合多进程worker。开源社区里很多大规模视觉训练脚本已经转向了这种方式。3.2 用磁盘缓存和内存缓存让同一份数据不被重复加工一个更朴素但收益极高的思路是不要每个epoch都重复做完整预处理。如果你的数据集不会动态变化那么第一次把解码、resize、归一化之后的结果存下来后续训练直接读缓存CPU占用会瞬间降下来。这个思路实现起来也不复杂。我常用的一个CacheDataset长这样import numpy as np import torch from pathlib import Path from torch.utils.data import Dataset class CachedDataset(Dataset): def __init__(self, raw_dataset, cache_dir): self.raw raw_dataset self.cache_dir Path(cache_dir) self.cache_dir.mkdir(parentsTrue, exist_okTrue) def __getitem__(self, idx): cache_path self.cache_dir / f{idx}.npz if cache_path.exists(): data np.load(cache_path, allow_pickleTrue) return torch.from_numpy(data[data]), int(data[label]) # 第一次训练前先把预处理结果写进缓存 sample self.raw[idx] data sample[0].numpy().astype(np.float16) np.savez(cache_path, datadata, labelsample[1]) return sample def __len__(self): return len(self.raw)有几个细节值得说明缓存内容用float16存储精度对大多数训练任务够用容量却少一半。如果训练时使用的是float32读取缓存后再转一下精度即可。多进程环境下多个worker同时写同一个缓存文件可能产生竞态。最简单的方法是在正式训练前用单进程把所有缓存文件预生成一遍缓存存在后训练脚本只读不写。如果数据集恰好能全部放进内存可以用lru_cache或者内存映射把预处理结果直接放内存。但要注意DataLoader的worker是独立进程普通Python缓存不会跨进程共享。想要真正的进程间共享缓存要么用共享内存要么用外部缓存服务。对小规模数据集直接用系统磁盘缓存反而更省事。3.3 collate_fn默认实现不慢但你的batch拼接方式可能很慢collate_fn的任务是把一批样本拼成一个batch。默认的default_collate对tensor列表做stack性能尚可但它做了很多类型检查和分支判断。当batch里每个样本都是大的图像Tensor时这些Python层循环会成为实际开销尤其是在worker数量多、batch又大的情况下。如果你确定整个Dataset输出的shape和dtype都是统一的完全可以直接写一个简洁的fast_collateimport numpy as np import torch def fast_collate(batch): images np.stack([b[0] for b in batch]) labels torch.tensor([b[1] for b in batch]) return torch.from_numpy(images), labels这里把数据先转到numpy再统一转torch是为了减少Python层逐个张量stack的开销。要注意的是collate_fn是在worker子进程里执行还是主进程执行取决于数据传递方式。PyTorch里collate一般由主进程完成因此过重的collate逻辑会直接占用主进程时间影响整体流水。所以原则上collate里只做拼装不做任何解码、resize、padding之类的重活。4. 数据加载的进阶提速从多进程调度到GPU增强4.1 监控磁盘、CPU、内存的数据流找到新瓶颈参数调完后瓶颈通常会转移。有时候你发现num_workers已经很高了GPU利用率还是上不去这时候需要从系统层面观察资源使用情况。我通常会同时打开三类监控GPU侧watch -n 1 nvidia-smi关注GPU利用率和显存占用。CPU侧top -H -p 训练进程pid看看进程里有没有大量worker在等待I/O。磁盘侧iostat -x 1关注%util和await。await高说明磁盘响应慢%util接近100说明磁盘带宽已经打满。判断逻辑很简单GPU利用率低、CPU忙成一片说明预处理慢GPU和CPU都不忙但内存占用还在涨多半是预取队列和IPC传输出现了堵点如果CPU空着、磁盘await很高那就是I/O瓶颈应该从存储方案和使用顺序读的角度去优化。4.2 数据增强从CPU搬到GPU图像任务的一种更快玩法CPU端的解码和resize很难避免但有一部分增强操作是纯数值计算完全可以搬到GPU上跑。常见的有随机翻转、随机裁剪、色彩抖动、归一化。如果用的是新版torchvisiontorchvision.transforms.v2已经支持直接输入CUDA Tensor很多变换逻辑本身就是CUDA实现。大致的用法是import torchvision.transforms.v2 as T transform T.Compose([ T.RandomResizedCrop((224, 224)), T.RandomHorizontalFlip(p0.5), ]) # 假设x已经被移动到GPU x transform(x)这样做的好处很明显把CPU从繁重的增强计算里解放出来让worker主要只干读取和解码的活儿。但代价是显存占用会上升因为增强过程需要在显存里开临时Tensor。显存紧张时可以把一部分增强留在CPU只把最重的resize、翻转这类操作放到GPU。需要提醒是transforms.v2的GPU支持和版本有关系。老版本的torchvision可能没有v2接口这种情况可以考虑用Kornia它天然基于torch Tensor适合做GPU上的可微数据增强。4.3 分布式场景下DDP采样与数据加载的协同分布式训练时DataLoader的使用跟单机单卡有一些本质差异。每个GPU卡通常由不同的rank进程控制如果每个rank都从完整数据集里随机采样等于大家都重复加载同一批数据浪费吞吐。正确做法是用DistributedSampler来划分数据每个rank只负责自己的分片from torch.utils.data.distributed import DistributedSampler sampler DistributedSampler(dataset, shuffleTrue) dataloader DataLoader(dataset, batch_size64, samplersampler, num_workers8)需要注意的是分布式训练里每个epoch要手动让sampler切换顺序否则所有epoch的打乱状态都一样for epoch in range(epochs): sampler.set_epoch(epoch) for batch in dataloader: ...多机场景下数据管线和存储位置也很关键。最好的情况是每台机器能从本地磁盘读取数据避免多个节点同时挤一个共享存储。如果实在只能用共享文件系统尽量让数据读取走顺序读的压缩包格式减少网络I/O抖动。5. 常见问题与排查实录加速过程中容易踩的坑5.1 一张表速查症状、原因、解法把高频问题整理成一张表实际排查的时候对着看就行。症状可能原因建议解法GPU利用率波动剧烈像锯齿状worker不足、预取太少调大num_workers、prefetch_factor开启pin_memory每个epoch开始有明显停顿worker进程重新创建设置persistent_workersTrue内存占用缓慢上涨直到卡死预取队列堆叠、缓存未释放降低prefetch_factor和num_workers检查缓存是否有泄漏CPU占用很高但GPU仍然饿死预处理太重或I/O等待严重用缓存和存储格式优化部分增强移到GPU每个epoch数据顺序完全一样随机种子没有随epoch重置使用worker_init_fn重新设置随机种子出现CUDA OOM但显存占用不高pin_memory或预取占用了大量内存关闭pin_memory降低预取倍数5.2 几次真实排障记录与避坑心得第一次严重翻车当时在一台24核机器上做目标检测训练觉得num_workers16没什么问题结果GPU利用率从70%掉到了30%。我盯着iostat看了一会才发现所有worker都在抢同一块机械盘的小文件随机读磁盘await高到离谱。解决办法是把数据集从散文件改成顺序读的tar包同时换到SSD上再把num_workers降到4吞吐反而翻了近两倍。这次之后我养成了先看磁盘再调worker的习惯。第二次是persistent_workers的种子问题。开了persistent_workersTrue之后训练到第10个epoch左右验证精度开始异常波动。打印同一张图片在不同epoch的增强结果发现竟然一模一样。原因就是worker没有重启随机状态没有更新。修复方式是在训练脚本里给每个epoch传入新的种子然后通过worker_init_fn设置def worker_init_fn(worker_id): epoch dataset.epoch seed torch.initial_seed() epoch * 1000 np.random.seed(seed % 2**32)然后每个epoch开始时同步更新dataset.epoch。这个坑比较隐蔽因为模型最终精度可能只是略降不认真对比根本发现不了。第三次是pin_memoryTrue引发的连锁反应。当时服务器只有32GB内存训练集预处理后比较大我开了pin_memory和高prefetch_factor结果系统开始疯狂swap整个训练比不开还慢。后来把prefetch_factor降回2pin_memory也只在batch真正要进GPU前才开内存压力才缓和下来。说一个我自己反复验证过的经验数据加载优化一定要用可量化的指标来验收不要看一两步的体感。固定随机种子取同样的模型和数据跑20个step统计平均epoch时间连续测试三次波动小且时间下降才是真的有效。如果某个改动带来了10%以上的提升就保留没带来明显变化果断回滚。这个主题里可以扩展的方向还有很多比如把缓存进一步做成跨epoch增量更新的形式或者把Dataset的读取过程拆成独立的IO线程池。但无论怎么扩展核心思维是一致的先定位瓶颈再动手优化最后用量化指标确认收益。
返回列表