2. 数据加载优化

数据加载,说白了就是给GPU喂饭。喂得太慢,GPU就得饿着等,算力白白浪费。我见过太多项目,模型写得花里胡哨,结果一跑起来,GPU利用率才30%,瓶颈全在数据加载上。今天咱们就把这块彻底聊透。

DataLoader的核心参数调优

PyTorch的DataLoader,我几乎每个项目都在用。但很多人只是无脑设个batch_size就完事了。其实它的参数门道不少。

batch_size:这个不用多说,直接影响显存占用。我习惯先设一个较小的值(比如8或16),跑一个batch看看显存占用,再逐步往上加。注意,不是越大越好——batch太大,模型收敛反而变慢。

num_workers:这是多进程加载的核心参数。它控制着同时有几个子进程在预处理数据。我个人的经验是:

  • CPU核心数的一半到三分之二,是个不错的起点
  • 如果CPU资源充足,可以设为核心数-1(留一个给系统)
  • 设得太多反而会因进程切换开销导致性能下降
小技巧: 我在项目中遇到过,num_workers设成0(即主进程加载)时,训练速度慢得让人抓狂。改成4之后,速度直接翻倍。但如果你在Windows上跑,num_workers设成0反而更稳定——这是PyTorch在Windows上的一个已知问题。

pin_memory机制:让数据传输更快

pin_memory=True,这个参数我建议你养成习惯加上。为什么?

正常情况下,CPU内存中的数据是分页的,GPU要读取时,得先拷贝到固定内存区域,再传输到GPU。这个过程有额外开销。而pin_memory=True,相当于告诉操作系统:这块内存别动它,固定住。这样GPU可以直接通过DMA(直接内存访问)读取,速度提升明显。

我曾经在一个图像分类项目里做过对比:

配置 每步耗时 GPU利用率
pin_memory=False 0.32s 65%
pin_memory=True 0.21s 88%

看到没?就改一个参数,性能提升超过30%。而且这几乎不消耗额外资源,属于白捡的优化。

注意: pin_memory=True必须配合non_blocking=True使用,否则效果打折扣。在将数据搬到GPU时,记得加上:data = data.cuda(non_blocking=True)

prefetch_factor:预取多少才合适

prefetch_factor这个参数,很多人可能都没注意过。它控制着每个worker预先加载多少个batch的数据。

默认值是2,意味着每个worker会预取2个batch。如果你的数据预处理很耗时(比如图像增强、文本tokenization),适当增大这个值能让GPU一直有活干。

我建议的调优思路:

  • 先设成2,跑一个epoch看看GPU利用率
  • 如果GPU经常空闲,逐步增加到4、8、16
  • 注意内存占用——prefetch_factor越大,占用的内存越多

举个例子,我有一个NLP项目,每个样本都要做复杂的tokenization。默认prefetch_factor=2时,GPU利用率只有60%。我改成8之后,利用率直接飙到95%。代价是内存从8GB涨到了14GB——但值得。

多进程加载策略

多进程加载,说白了就是让多个CPU核心同时干活。但这里有个坑:不是所有场景都适合多进程。

什么时候用多进程?

  • 数据预处理是CPU密集型(如图像resize、数据增强)
  • 数据量很大,单进程加载跟不上GPU速度
  • 你有足够的CPU核心和内存

什么时候别用?

  • 数据预处理很简单(比如只是读个numpy数组)
  • 数据量很小,加载不是瓶颈
  • 内存紧张(多进程会复制数据,内存占用翻倍)

我曾经在一个视频处理项目里踩过坑。视频帧的预处理特别耗时,我一开始设了8个worker,结果内存直接爆了——因为每个worker都在缓存视频帧。后来我改成4个worker,配合prefetch_factor=1,才稳定下来。

实战配置模板

下面是我常用的DataLoader配置模板,你可以直接拿去用:

from torch.utils.data import DataLoader

dataloader = DataLoader(
    dataset,
    batch_size=32,
    shuffle=True,
    num_workers=4,           # CPU核心数的一半
    pin_memory=True,         # 固定内存,加速GPU传输
    prefetch_factor=4,       # 每个worker预取4个batch
    persistent_workers=True, # 训练完一个epoch不销毁worker
    drop_last=True           # 丢弃最后一个不完整的batch
)

这里有个参数我特别想提一下:persistent_workers=True。默认情况下,每个epoch结束后,worker进程会被销毁,下一个epoch再重新创建。这有额外开销。设为True后,worker会一直存活,省去了反复创建销毁的时间。我实测过,能节省5%-10%的训练时间。

知识体系总览

下面这张图,把数据加载优化的核心逻辑串起来了:

数据加载优化核心逻辑 CPU 数据预处理 num_workers 控制并行度 prefetch_factor 控制预取量 多进程加载策略 内存缓冲 pin_memory=True 固定内存页 DMA 直接传输 GPU 计算 模型训练 前向/反向 梯度更新 核心目标:让GPU永远有数据可算 CPU预处理速度 ≥ GPU消费速度 → 利用率接近100%

嗯,这张图其实把整个流程说清楚了。数据从CPU预处理,经过内存缓冲,最后到GPU计算。每个环节都有优化空间,而我们的目标就是让这三个环节流水线起来,不让任何一个环节成为瓶颈。

总结一下: 数据加载优化,说白了就是让CPU和GPU并行工作。CPU在预处理下一个batch的同时,GPU在算当前batch。pin_memory加速传输,prefetch_factor保证缓冲不断,num_workers确保CPU火力全开。这三板斧用好了,GPU利用率轻松上90%。