数据加载优化:别让GPU饿着等数据

做深度学习训练,我最怕看到一种情况:GPU利用率像心电图一样忽高忽低。明明显存够用,计算能力也强,但就是跑不快。问题出在哪?十有八九是数据加载拖了后腿。

说白了,GPU就像一台高速运转的机器,而数据加载就是给这台机器喂原料的传送带。传送带慢了,机器再快也没用。今天我们就聊聊怎么把这条传送带调顺溜。

DataLoader工作原理:数据是怎么送到GPU的?

先搞清楚DataLoader到底在干什么。它的核心任务就一个:把硬盘上的数据,变成GPU能吃的张量。

流程大概是这样的:

  1. 从硬盘读取原始数据(图片、文本等)
  2. 做预处理(缩放、归一化、数据增强)
  3. 打包成batch
  4. 送到GPU显存里

嗯,听起来简单。但这里有个关键点——这些操作是串行的还是并行的?

默认情况下,DataLoader是在主进程里一个一个处理的。你想想看,GPU在疯狂计算,主进程却在等硬盘读数据,这不就浪费了吗?

核心瓶颈:数据加载和模型训练天然是异步的。如果加载跟不上训练,GPU就得空转等着。

num_workers调优:多几个搬运工

解决这个问题最直接的办法——多开几个子进程来干活。这就是num_workers参数的作用。

我个人的习惯是,先设成CPU核心数的一半试试。比如你机器有8核,就先设4。为什么不是越多越好?

我在项目中遇到过这种情况:把num_workers设成16,结果训练反而变慢了。排查了半天,发现是进程间通信开销太大,数据拷贝占用了大量内存带宽。

调优建议:

  • 起步值:CPU核心数的一半
  • 观察GPU利用率,如果低于80%,逐步增加
  • 上限:不要超过CPU核心数
  • 注意内存占用,每个worker都会复制一份数据

这里有个小技巧:你可以用nvidia-smi和htop同时观察GPU和CPU的状态。如果GPU利用率低但CPU没跑满,说明瓶颈不在数据加载;如果CPU跑满了GPU还在等,那就继续加worker。

prefetch_factor设置:提前准备下一批

光有多个worker还不够,还得让它们提前干活。这就是prefetch_factor的作用。

默认情况下,DataLoader是"懒加载"的——当前batch用完了,才去准备下一个。这就像你吃完饭才去洗碗,中间总有个空档期。

prefetch_factor就是让worker提前准备几个batch的数据。默认值是2,意味着每个worker会提前准备2个batch。

# 设置prefetch_factor
DataLoader(
    dataset,
    batch_size=32,
    num_workers=4,
    prefetch_factor=4,  # 每个worker提前准备4个batch
    persistent_workers=True  # worker进程不销毁,复用
)

我记得有一次调优,把prefetch_factor从2调到8,训练速度提升了15%。但要注意,这个值不是越大越好——它会占用更多内存来缓存数据。

注意:prefetch_factor和num_workers是联动的。总预取数量 = num_workers × prefetch_factor。设太大可能导致内存溢出。

内存映射(mmap)文件读取:让操作系统帮你干活

前面说的都是多进程层面的优化。但还有一个更底层的优化——文件读取方式。

传统读取文件,是先把数据从硬盘拷贝到内核缓冲区,再拷贝到用户空间。这中间有两次拷贝,很费时间。

内存映射(mmap)就不一样了。它直接把文件映射到进程的虚拟地址空间,操作系统按需加载数据。说白了,就是让操作系统帮你管理文件缓存,省去了手动拷贝的步骤。

# 使用mmap加载数据
import numpy as np
import mmap

# 传统方式
data = np.fromfile('data.bin', dtype=np.float32)

# mmap方式
with open('data.bin', 'rb') as f:
    mmapped = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
    data = np.frombuffer(mmapped, dtype=np.float32).reshape(-1, 3, 224, 224)

我曾经在一个图像分类项目里,把数据加载时间从每epoch 45秒降到了12秒。怎么做到的?就是把所有图片打包成一个二进制文件,然后用mmap读取。

mmap的优势:

  • 减少数据拷贝次数
  • 利用操作系统的页面缓存
  • 多个进程可以共享同一份内存映射
  • 按需加载,不用一次性读入全部数据

但要注意,mmap不是银弹。如果你的数据是随机访问的,而且访问模式很分散,mmap反而可能因为频繁缺页中断而变慢。

实战配置模板

说了这么多,给一个我常用的配置模板:

from torch.utils.data import DataLoader

def create_dataloader(dataset, batch_size, num_workers=None):
    # 自动检测CPU核心数
    cpu_count = os.cpu_count()
    if num_workers is None:
        num_workers = min(4, cpu_count // 2)
    
    return DataLoader(
        dataset,
        batch_size=batch_size,
        shuffle=True,
        num_workers=num_workers,
        prefetch_factor=4,
        persistent_workers=True,
        pin_memory=True,  # 锁页内存,加速CPU到GPU传输
        drop_last=True    # 丢弃最后一个不完整的batch
    )

这个配置在我经手的项目里,基本都能把GPU利用率稳定在90%以上。当然,具体数值还得根据你的硬件和数据集来调。

知识体系总览

下面这张图总结了数据加载优化的核心逻辑:

数据加载优化核心逻辑 硬盘 mmap 内存映射 DataLoader num_workers prefetch_factor GPU 优化要点 num_workers调优 • 从CPU核心数一半开始 • 观察GPU利用率逐步增加 • 注意内存占用上限 prefetch_factor设置 • 默认值2,建议4-8 • 与num_workers联动 • 避免内存溢出 mmap文件读取 • 减少数据拷贝次数 • 利用操作系统缓存 • 适合顺序访问场景 pin_memory加速 • 锁页内存加速传输 • 减少CPU到GPU延迟 • 配合persistent_workers

一句话总结:数据加载优化的目标就是让GPU永远有活干。用多进程并行加载,用预取机制提前准备,用mmap减少IO开销。这三板斧下去,大部分场景都能搞定。

想要更多资料,联系可可