数据加载优化:DataLoader工作原理、num_workers设置、prefetch_factor调整、内存映射文件

训练跑得慢,很多时候不是GPU在偷懒,而是CPU没把饭喂到嘴边。

我见过太多团队,花大价钱买了A100,结果GPU利用率只有30%。一看监控,数据加载那块儿卡得死死的。说白了,数据加载就是训练管道的入口,入口堵了,后面再快也没用。

DataLoader工作原理:它到底在干嘛?

PyTorch的DataLoader,本质上就是个数据搬运工。它负责从硬盘上把数据读出来,做预处理,然后打包成batch送给模型。

它的核心逻辑其实很简单:

  • Sampler:决定取哪些样本,比如随机取还是顺序取
  • Dataset:给定一个索引,返回对应的样本数据
  • Collate_fn:把多个样本拼成一个batch

嗯,这里要注意,很多人以为DataLoader是并行工作的。其实不是。单进程模式下,它就是一个接一个地读数据,CPU在等硬盘,GPU在等CPU,整个链条都在等。

核心要点:DataLoader默认是单进程模式。想要快,必须开多进程。

num_workers设置:开几个搬运工最合适?

num_workers就是开几个子进程来帮你读数据。我个人的习惯是,先试CPU核心数,再往下调。

举个例子,一台机器有16个物理核,32个逻辑核。我会先设num_workers=8,然后看GPU利用率。如果GPU还在等,就加到12。如果CPU被打满了,就降到6。

为什么不是越多越好?

我曾经踩过一个坑。有一次我把num_workers设成了32,想着反正核多。结果训练反而变慢了。为什么呢?因为进程太多,操作系统在进程切换上花的时间比干活还多。而且每个进程都要复制数据,内存带宽成了瓶颈。

我的经验公式:

  • CPU密集型预处理(如图像增强):num_workers = CPU核心数 / 2
  • IO密集型(简单读取):num_workers = CPU核心数
  • 千万别超过CPU核心数的2倍,否则适得其反

prefetch_factor调整:提前多备几份饭

prefetch_factor这个参数,很多人不知道。它控制每个worker提前加载多少个batch。

默认值是2,意思是每个worker会提前加载2个batch的数据在内存里等着。GPU吃完一个,马上就能拿下一个。

你想想看,如果prefetch_factor=1,worker刚把数据喂给GPU,就得立刻去读下一批。万一硬盘慢了,GPU就得空转。prefetch_factor大一点,相当于给GPU建了个缓冲区。

我一般怎么调?

  • 数据在SSD上:prefetch_factor=2或3就够了
  • 数据在机械硬盘或网络存储上:我会调到4或5,给足缓冲
  • 内存够大但CPU吃紧:调大prefetch_factor,让worker少干活

注意:prefetch_factor不是越大越好。调太大,内存占用会飙升。我曾经在一个96GB内存的机器上,把prefetch_factor调到8,结果内存直接爆了。嗯,那次排查了好久才发现是它搞的鬼。

内存映射文件:让硬盘假装是内存

内存映射文件,英文叫memory-mapped file,简称mmap。这玩意儿说白了,就是把硬盘上的文件映射到虚拟内存地址空间里。程序读写这个文件,就像读写内存一样快。

为什么它能加速数据加载?

  • 省去了read()系统调用的开销
  • 操作系统会自动做缓存,热数据留在内存里
  • 多个进程可以共享同一块映射区域,省内存

我在项目中遇到过一个大模型训练任务,数据集有2TB,全是高清图片。用普通的ImageFolder加载,一个epoch要跑6个小时。后来改成mmap模式,直接把整个数据集映射到内存地址空间,加载时间降到了40分钟。

怎么用?PyTorch的Dataset里,用numpy的memmap或者直接操作文件描述符就行。代码大概长这样:

import numpy as np
import torch
from torch.utils.data import Dataset

class MmapDataset(Dataset):
    def __init__(self, data_path, label_path, shape, dtype=np.float32):
        # 内存映射文件,不实际加载到内存
        self.data = np.memmap(data_path, dtype=dtype, mode='r', shape=shape)
        self.labels = np.memmap(label_path, dtype=np.int64, mode='r')
    
    def __len__(self):
        return len(self.labels)
    
    def __getitem__(self, idx):
        # 这里读取时,操作系统会自动做页缓存
        x = torch.from_numpy(self.data[idx].copy())
        y = self.labels[idx]
        return x, y

小技巧:如果数据集太大,内存装不下,可以只映射一部分。或者用多个mmap文件,按需加载。我习惯把数据按epoch分片,每个片一个mmap文件,这样既快又省内存。

知识体系总览

下面这张图,把数据加载优化的几个关键点串起来了。你可以看到,从硬盘到GPU,每一步都有优化的空间。

数据加载优化核心流程 硬盘/存储 内存映射 (mmap) DataLoader num_workers prefetch_factor GPU训练 优化要点详解 num_workers 设置 • CPU密集型:核心数/2 • IO密集型:核心数 • 上限:不超过核心数×2 prefetch_factor 调整 • SSD场景:2~3 • 机械硬盘:4~5 • 注意内存占用 内存映射文件 • 省去系统调用开销 • 操作系统自动缓存 • 多进程共享内存 避坑指南 • 进程数过多导致切换开销 • prefetch过大撑爆内存 • mmap注意文件大小对齐

实战建议:怎么搭一套高效的数据管道?

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

from torch.utils.data import DataLoader

dataloader = DataLoader(
    dataset,
    batch_size=64,
    shuffle=True,
    num_workers=8,           # 根据CPU核心数调整
    prefetch_factor=3,       # 提前加载3个batch
    pin_memory=True,         # 锁页内存,加速CPU到GPU传输
    persistent_workers=True  # 保持worker存活,减少创建销毁开销
)

额外提醒:pin_memory=True一定要开。它把数据固定在内存里,GPU可以直接通过DMA访问,不用CPU中转。这个开关能再给你10%~20%的提速。

最后说一句,数据加载优化没有银弹。你得根据你的硬件配置、数据集大小、预处理复杂度来调。我建议你先用默认参数跑一遍,然后逐个调优,每次只改一个参数,看GPU利用率的变化。

嗯,今天就聊到这儿。数据加载这块儿,说白了就是让CPU和GPU之间的管道更通畅。管道通了,训练自然就快了。