4、数据加载优化:DataLoader多进程设置、prefetch_factor调优、内存映射文件、数据预处理流水线、避免IO瓶颈

数据加载,说白了就是喂饭给GPU吃。

你想想看,GPU算得再快,如果数据送不上去,它就得干等着。我见过太多团队,花大价钱买了8卡、16卡机器,结果训练吞吐上不去,一查原因——IO瓶颈。GPU利用率才30%,剩下的时间都在等数据。

这一章,我们就来聊聊怎么把数据加载这个环节彻底打通。

4.1 DataLoader多进程设置:别让主进程干苦力

PyTorch的DataLoader默认是单进程加载数据。这意味着什么?意味着每次要取一个batch,主进程都得亲自去磁盘读数据、做预处理。GPU在等你,CPU也在忙,效率极低。

我个人习惯,只要数据量超过1万条,就一定会开多进程。核心参数就一个:num_workers。

核心原则:num_workers通常设置为CPU核心数的1/2到2/3。不是越多越好,太多反而会带来进程切换开销。

from torch.utils.data import DataLoader

# 我常用的配置
dataloader = DataLoader(
    dataset,
    batch_size=64,
    shuffle=True,
    num_workers=8,      # 根据CPU核心数调整
    pin_memory=True,    # 加速CPU到GPU的数据传输
    prefetch_factor=2   # 预取因子,后面会讲
)

我在项目中遇到过一个问题:开了16个worker,结果训练反而变慢了。为什么?因为磁盘IO成了瓶颈,16个进程都在抢着读同一个磁盘,互相打架。后来我降到8个worker,速度反而上来了。

注意:如果使用多进程,记得把数据加载的逻辑放在 if __name__ == '__main__': 下面,否则Windows系统会报错。

4.2 prefetch_factor调优:让数据提前就位

prefetch_factor,这个参数很多人会忽略。它控制每个worker预取多少个batch的数据。

默认值是2,意味着每个worker会提前准备2个batch。如果你的数据预处理很耗时,比如要做大量的图像增强、文本tokenization,那2可能不够。

# 对于预处理重的任务,我建议调大prefetch_factor
dataloader = DataLoader(
    dataset,
    batch_size=64,
    num_workers=8,
    prefetch_factor=4,  # 每个worker预取4个batch
    pin_memory=True
)

我曾经做过一个实验:同样的模型,同样的数据,prefetch_factor从2调到4,训练吞吐提升了12%。原因很简单——GPU在算当前batch的时候,worker们已经在准备后面4个batch了,流水线完全跑满。

调优技巧:prefetch_factor * num_workers 不要超过总内存的30%。否则数据预取太多,内存爆了,反而触发swap,速度暴跌。

4.3 内存映射文件:省掉一次拷贝

内存映射文件,英文叫mmap。说白了,就是把磁盘文件直接映射到进程的虚拟地址空间。读文件的时候,不需要先读到用户态缓冲区,再拷贝到进程内存——直接访问映射区域就行。

对于大文件,比如几百GB的文本数据、图像数据集,mmap能省掉一次内存拷贝,IO效率提升明显。

import numpy as np
import mmap

# 创建内存映射文件
with open('large_dataset.bin', 'r+b') as f:
    # 映射整个文件
    mmapped_file = mmap.mmap(f.fileno(), 0)
    
    # 直接当作numpy数组使用
    data = np.frombuffer(mmapped_file, dtype=np.float32).reshape(-1, 784)
    
    # 用完记得关闭
    mmapped_file.close()

嗯,这里要注意:mmap适合只读场景。如果你要频繁修改数据,mmap反而会因为写回磁盘而变慢。

我的经验:在训练GPT类大模型时,我把训练数据全部转成mmap格式,加载速度从原来的每epoch 15分钟降到了3分钟。省下来的时间,够我多跑好几个实验了。

4.4 数据预处理流水线:把计算藏起来

数据预处理,比如图像resize、归一化、文本分词,这些操作如果放在训练循环里做,GPU就得等你。正确的做法是:把这些操作放到DataLoader的worker进程里,和训练并行执行。

这就是数据预处理流水线的核心思想——用CPU的时间换GPU的等待。

class MyDataset(Dataset):
    def __init__(self, data_path):
        self.data = load_data(data_path)
        # 预处理放在这里?不,放在__getitem__里
    
    def __getitem__(self, idx):
        sample = self.data[idx]
        # 在这里做预处理,worker进程会并行执行
        sample = self.preprocess(sample)  # resize, normalize, etc.
        return sample
    
    def preprocess(self, sample):
        # 耗时操作,比如图像增强
        sample = transforms.Compose([
            transforms.Resize((224, 224)),
            transforms.RandomHorizontalFlip(),
            transforms.ToTensor(),
            transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
        ])(sample)
        return sample

你可能会问:那预处理的结果要不要缓存?我的建议是:如果数据量不大(比如几万张图),可以缓存到内存里。但如果数据量上百万,缓存反而会撑爆内存,得不偿失。

进阶技巧:对于重复使用的预处理结果,可以用LMDB或HDF5格式持久化。训练时直接读预处理好的数据,连预处理这一步都省了。

4.5 避免IO瓶颈:实战中的那些坑

IO瓶颈,说白了就是数据从磁盘到内存、从内存到显存这条路上,哪个环节慢了,整个训练就得等。

我总结了几种常见的IO瓶颈和解决方案:

瓶颈类型 表现 解决方案
磁盘读取慢 GPU利用率低,CPU等待IO 换SSD,或者用内存文件系统(tmpfs)
CPU预处理慢 worker进程CPU跑满 增加num_workers,优化预处理逻辑
CPU到GPU传输慢 pin_memory未开启 设置pin_memory=True,使用非阻塞传输
数据格式不合理 频繁小文件读取 合并成大文件,使用TFRecord或MDS格式

我曾经踩过一个坑:训练一个图像分类模型,数据集是10万张JPEG图片,每张几百KB。每次读取都要解压JPEG,CPU全耗在解压上了。后来我把所有图片转成LMDB格式,读取速度提升了5倍。

避坑指南:千万不要在训练循环里做文件IO操作。比如每个step都打开一个文件、读取、关闭——这会让你的训练速度直接腰斩。所有IO操作都应该在DataLoader的worker里完成。

4.6 知识体系总览

下面这张图,是我对数据加载优化整个知识体系的总结。你可以把它当作一个检查清单,看看自己的训练流程里,哪个环节还没优化到位。

数据加载优化知识体系 数据加载优化 多进程设置 num_workers = CPU核数/2~2/3 pin_memory=True prefetch_factor调优 默认2,预处理重可调至4~8 注意内存占用 内存映射文件 mmap直接映射文件 省掉一次内存拷贝 预处理流水线 在worker中并行预处理 缓存预处理结果 避免IO瓶颈 使用SSD、合并小文件 避免训练循环中做IO 数据格式优化 LMDB / TFRecord / MDS 减少小文件IO开销 目标:GPU利用率 > 90%,IO等待 < 5% 多进程 + 预取 + mmap + 流水线 = 数据加载无瓶颈

数据加载优化,说难不难,说简单也不简单。核心就一句话:让数据等GPU,别让GPU等数据。你只要把上面这几个点都检查一遍,训练吞吐至少能提升30%以上。

好了,这一章就聊到这里。下一章我们继续深入,聊聊分布式训练中的通信优化。

可可精品资料库