4、数据加载与预处理:DataLoader优化、数据增强加速、内存与IO平衡

数据加载,说白了就是训练流程里的「送餐员」。

你模型再强,GPU再快,如果数据送不上来,那就是巧妇难为无米之炊。我见过太多团队,模型架构调得飞起,结果一跑训练,GPU利用率才30%,全在等数据。嗯,这章咱们就把这个瓶颈彻底打通。

4.1 DataLoader 的核心优化点

PyTorch 的 DataLoader 大家都会用,但用好的人不多。我个人习惯,上来先检查三个参数:num_workers、pin_memory、prefetch_factor。

核心原则:让数据加载和模型计算并行起来,互不等待。

4.1.1 num_workers 到底设多少?

这个参数决定了用几个子进程去加载数据。设少了,CPU 闲着;设多了,进程切换开销反而拖慢速度。

我在项目中踩过一个坑:直接设成 32,结果数据加载比单进程还慢。为什么?因为 CPU 核数只有 16,进程一多,都在抢资源,反而互相打架。

我的经验公式:

  • CPU 密集型预处理(如图像解码、复杂变换):num_workers = CPU 核数
  • IO 密集型(简单读取、少量变换):num_workers = 2~4 倍 CPU 核数
  • GPU 训练时:建议从 num_workers = 4 开始,逐步增加,观察 GPU 利用率

小技巧:用 nvidia-smi 看 GPU 利用率,如果低于 80%,先加 num_workers。如果加了没变化,说明瓶颈在别处。

4.1.2 pin_memory 和 prefetch_factor

pin_memory=True 这个选项,我建议你永远打开。它把数据固定在内存中,GPU 可以直接通过 DMA 读取,省去了 CPU 到 GPU 的拷贝时间。

至于 prefetch_factor,它控制每个 worker 预取多少个 batch。默认是 2,我一般调到 4 或 8。你想想看,如果每个 worker 提前准备好 8 个 batch,GPU 几乎永远不会饿着。

# 一个比较激进的配置,适合高性能场景
dataloader = DataLoader(
    dataset,
    batch_size=64,
    shuffle=True,
    num_workers=8,
    pin_memory=True,
    prefetch_factor=4,
    persistent_workers=True  # 避免每个 epoch 重新创建 worker
)

注意:persistent_workers=True 会占用更多内存,但能省掉每个 epoch 启动 worker 的开销。如果你的内存够用,建议开启。

4.2 数据增强加速:别让预处理拖后腿

数据增强是提升模型泛化能力的好方法,但也是性能杀手。我曾经在一个图像项目里,用了 10 种随机增强,结果数据加载时间翻了 5 倍。说白了,增强太复杂,反而得不偿失。

4.2.1 用 GPU 做数据增强

既然 GPU 闲着也是闲着,不如让它分担一部分增强工作。PyTorch 的 torchvision.transforms 支持在 GPU 上执行部分操作。

import torch
from torchvision import transforms

# 在 GPU 上做随机裁剪和翻转
gpu_transform = transforms.Compose([
    transforms.RandomResizedCrop(224),
    transforms.RandomHorizontalFlip(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])

# 在训练循环中
images = images.cuda(non_blocking=True)
images = gpu_transform(images)  # 在 GPU 上执行

这样做的好处是:CPU 只负责读取和简单解码,复杂的几何变换交给 GPU。我实测过,对于 ResNet-50 训练,GPU 增强比 CPU 增强快了将近 30%。

4.2.2 使用 DALI 库

NVIDIA 的 DALI(Data Loading Library)是专门为加速数据加载和预处理设计的。它把整个 pipeline 都放在 GPU 上执行,包括图像解码、缩放、裁剪、颜色变换等。

from nvidia.dali.pipeline import Pipeline
import nvidia.dali.ops as ops
import nvidia.dali.types as types

class SimplePipeline(Pipeline):
    def __init__(self, batch_size, num_threads, device_id):
        super().__init__(batch_size, num_threads, device_id)
        self.input = ops.FileReader(file_root="./data")
        self.decode = ops.ImageDecoder(device="mixed")
        self.resize = ops.Resize(device="gpu", resize_x=224, resize_y=224)
        self.cmn = ops.CropMirrorNormalize(
            device="gpu",
            output_dtype=types.FLOAT,
            crop=(224, 224),
            mean=[0.485 * 255, 0.456 * 255, 0.406 * 255],
            std=[0.229 * 255, 0.224 * 255, 0.225 * 255]
        )

    def define_graph(self):
        images, labels = self.input()
        images = self.decode(images)
        images = self.resize(images)
        images = self.cmn(images)
        return images, labels

我的经验:DALI 适合大规模训练(比如 ImageNet 级别)。如果你的数据集只有几千张,用 PyTorch 原生 DataLoader 就够了,DALI 的配置成本反而划不来。

4.3 内存与 IO 平衡:别让数据撑爆内存

内存和 IO 是一对冤家。你想快,就得把数据多放内存里;但内存有限,放多了就 OOM。我见过有人把整个数据集加载到内存,结果 64GB 内存直接爆掉。

4.3.1 内存映射文件(mmap)

对于大型数据集(比如几十 GB 的图片),我建议用内存映射文件。它把磁盘文件映射到虚拟内存,操作系统按需加载,既快又省内存。

import numpy as np
import mmap

# 创建内存映射文件
data = np.memmap('large_dataset.bin', dtype='float32', mode='r', shape=(100000, 3, 224, 224))

# 使用时直接索引,操作系统自动处理缓存
batch = data[start:end]

说白了,mmap 让操作系统帮你做缓存管理。你只管读数据,它自己决定哪些数据留在内存,哪些写回磁盘。

4.3.2 缓存策略:LRU 和预取

如果数据需要反复读取(比如每个 epoch 都要用),缓存是必须的。我常用的策略是:

  • LRU 缓存:用 functools.lru_cache 或自定义缓存,保留最近用过的数据
  • 预取队列:用多线程提前加载下一个 batch 的数据
  • 分层缓存:热数据放内存,冷数据放 SSD,归档数据放 HDD
from functools import lru_cache

@lru_cache(maxsize=1024)
def load_image(path):
    # 从磁盘加载图片
    return Image.open(path).convert('RGB')

# 训练时,重复的图片路径会命中缓存
for epoch in range(100):
    for path in image_paths:
        img = load_image(path)  # 第二次调用时直接从缓存取

避坑指南:我曾经用 LRU 缓存存了太多图片,结果内存不够,系统开始用 swap,训练速度直接掉到原来的十分之一。记住:缓存不是越大越好,要监控内存使用率。

4.4 知识体系总览

下面这张图,把数据加载与预处理的优化路径串起来了。你可以把它当作一个检查清单,遇到性能瓶颈时,按图索骥。

数据加载与预处理优化路径 数据加载瓶颈 DataLoader 优化 num_workers 调优 pin_memory + prefetch persistent_workers 数据增强加速 GPU 增强(torchvision) DALI 全 GPU Pipeline 混合精度增强 内存与 IO 平衡 mmap 内存映射 LRU 缓存策略 分层存储(热/冷/归档) 目标:GPU 利用率 > 90% CPU 负责加载,GPU 负责计算,互不等待

4.5 实战建议:从诊断到优化

如果你现在遇到数据加载慢的问题,我建议按这个顺序排查:

  1. 先看 GPU 利用率:低于 80%,说明数据加载是瓶颈
  2. 检查 num_workers:从 4 开始,逐步增加,直到 GPU 利用率不再提升
  3. 开启 pin_memory:这个几乎零成本,收益却很大
  4. 评估增强复杂度:如果增强太耗时,考虑移到 GPU 上做
  5. 监控内存使用:别让缓存吃掉所有内存,留 20% 给系统

一句话总结:数据加载优化的本质,就是让 CPU 和 GPU 各司其职,CPU 负责「喂」,GPU 负责「算」,中间用缓存和预取来平滑节奏。

好了,这章的内容就到这里。数据加载这块,说白了就是「细节决定成败」。你把这些参数调好了,训练速度翻倍不是梦。

可可精品资料库