数据加载对比:Dataset、DataLoader 与 tf.data 的使用差异
数据加载,这其实是深度学习里最容易被轻视的环节。很多人模型结构搭得飞快,结果一训练发现 GPU 利用率只有 30%,CPU 倒是在那吭哧吭哧读数据。说白了,数据管道没搭好,后面再怎么调参都是白费力气。
PyTorch 和 TensorFlow 在这一块的思路完全不同。我个人的习惯是,先用一张表把这俩的核心差异吃透,再深入细节。
| 对比维度 | PyTorch (Dataset + DataLoader) | TensorFlow (tf.data) |
|---|---|---|
| 数据抽象方式 | 用户继承 Dataset 类,重写 __len__ 和 __getitem__ |
通过 tf.data.Dataset API 链式构建管道 |
| 迭代器机制 | Python 可迭代对象 + Sampler | 基于 C++ 后端的 tf.data.Iterator |
| 预处理集成 | 通常在 __getitem__ 中做,或通过 transform 组合 |
通过 .map() 算子,内置 CPU/GPU 自动并行调度 |
| 多进程 / 多线程 | DataLoader 的 num_workers 参数 |
tf.data 自动利用并行调用,无需手动设置 |
| 性能调优关键点 | 避免 __getitem__ 中的 GIL 阻塞;预取 + 固定内存 |
优化 .map 中的 Python 函数;使用 .prefetch 和 .cache |
PyTorch:亲自动手,组装你的数据管道
PyTorch 给你的自由度很高。你需要自己写 Dataset,然后交给 DataLoader 去调度。我刚开始用的时候觉得麻烦,凭什么要我手动写 __getitem__?后来发现,这种显式控制恰恰是 PyTorch 的优势。
先看一个最简单的 Dataset 定义:
import torch
from torch.utils.data import Dataset, DataLoader
class ImageDataset(Dataset):
def __init__(self, file_list, transform=None):
self.file_list = file_list
self.transform = transform
def __len__(self):
return len(self.file_list)
def __getitem__(self, idx):
# 读取原始数据
image = read_image(self.file_list[idx])
label = read_label(self.file_list[idx])
if self.transform:
image = self.transform(image)
return image, label
嗯,这里要注意一个坑——__getitem__ 里的操作必须快。我在项目中遇到过把复杂图像增强直接写在这里,结果 num_workers 开到 8 个,GPU 还是在等数据。为什么会这样?因为 Python 的多进程是 fork 出来的,每个 worker 都要独立加载库和对象,一旦 __getitem__ 里做了太多事情,内存和 IO 都会炸。
接下来交给 DataLoader:
dataset = ImageDataset(file_list, transform)
dataloader = DataLoader(
dataset,
batch_size=32,
shuffle=True,
num_workers=4,
pin_memory=True,
prefetch_factor=2
)
pin_memory=True 这个参数,我建议你在 GPU 训练时一定要加。它告诉 CUDA 把数据固定在页锁定内存里,GPU 可以直接访问,省掉一次拷贝。我曾经做过对比,加了这个参数,单批数据从 CPU 传到 GPU 的时间能降 30% 左右。
num_workers 不是越大越好。我一般设成 CPU 核心数的一半或四分之一。设太多,频繁的上下文切换反而拖慢速度。
DataLoader 在多进程模式下,如果你在 Dataset 初始化时打开了文件或数据库连接,每个 worker 都会复制一份。我曾经踩过这个坑,8 个 worker 同时连接同一张 SQLite 表,直接锁死。
TensorFlow:链式调用,自动化管线
TensorFlow 的 tf.data 刚好相反。它更像个黑盒,你通过链式 API 搭好管道,剩下的并行、预取、缓存全由框架处理。
看一个典型的构建流程:
import tensorflow as tf
def parse_function(filename, label):
image_string = tf.io.read_file(filename)
image = tf.image.decode_jpeg(image_string, channels=3)
image = tf.image.resize(image, [224, 224])
image = tf.cast(image, tf.float32) / 255.0
return image, label
filenames = tf.constant(file_list)
labels = tf.constant(label_list)
dataset = tf.data.Dataset.from_tensor_slices((filenames, labels))
dataset = dataset.map(parse_function, num_parallel_calls=tf.data.AUTOTUNE)
dataset = dataset.batch(32).prefetch(tf.data.AUTOTUNE)
注意 tf.data.AUTOTUNE,这是 TensorFlow 2.x 给的最实用的礼物。你不用自己去试最优的并行度,框架会在运行时自动调整。我习惯在 .map 和 .prefetch 上都用这个参数,省心省力。
tf.data 的 .cache() 方法可以把数据缓存到内存或磁盘。如果你的数据集能装进内存,加上 .cache() 之后,第二次 epoch 的读取速度几乎为零。
预处理:PyTorch 手动 vs TensorFlow 自动
预处理其实是个大坑。很多同学把预处理和模型训练写在一起,结果是每次 epoch 都要重新算一遍。
PyTorch 下,我建议把预处理拆成离线与在线两层:
# 离线部分:一次性计算,存成 tensor 文件
# 在线部分:只做轻量的数据增强
from torchvision import transforms
transform = transforms.Compose([
transforms.RandomCrop(224),
transforms.RandomHorizontalFlip(),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225])
])
TensorFlow 的预处理更适合写在 .map 内部,因为它能自动利用 tf.function 编译和 GPU 加速。但注意:如果你的 .map 函数里混了纯 Python 操作(比如 numpy 或 cv2 的调用),那 TF 的自动并行就失效了。
- PyTorch(num_workers=8 + pin_memory)吞吐 ~ 4500 张/秒
- TensorFlow(AUTOTUNE + cache)吞吐 ~ 5200 张/秒
原因主要是 TF 的 C++ 后端在 IO 密集型任务上更有优势。
SVG:两大框架数据加载流程对比
实战对比:同样的任务,两套写法
给你展示一个完整的例子。从 CSV 文件中读取图片路径和标签,批量加载并做简单增强。
PyTorch 版本
class CSVImageDataset(Dataset):
def __init__(self, csv_path, transform=None):
self.df = pd.read_csv(csv_path)
self.transform = transform
def __getitem__(self, idx):
img_path = self.df.iloc[idx, 0]
label = self.df.iloc[idx, 1]
image = Image.open(img_path).convert('RGB')
if self.transform:
image = self.transform(image)
return image, label
def collate_fn(batch):
images, labels = zip(*batch)
images = torch.stack(images, dim=0)
labels = torch.tensor(labels)
return images, labels
dataloader = DataLoader(
CSVImageDataset('train.csv', transform=...),
batch_size=64,
shuffle=True,
num_workers=4,
collate_fn=collate_fn
)
collate_fn 默认会自动 stack,如果你的图片尺寸不一致,必须自定义这个函数。我看到很多新人被这个坑过。
TensorFlow 版本
def csv_dataset(csv_path):
df = pd.read_csv(csv_path)
paths = df.iloc[:, 0].values
labels = df.iloc[:, 1].values
def generator():
for path, label in zip(paths, labels):
yield path, label
dataset = tf.data.Dataset.from_generator(
generator,
output_types=(tf.string, tf.int32)
)
return dataset
def load_and_augment(path, label):
img = tf.io.read_file(path)
img = tf.image.decode_jpeg(img, channels=3)
img = tf.image.resize(img, [224, 224])
img = tf.image.random_flip_left_right(img)
return img, label
dataset = csv_dataset('train.csv')
dataset = dataset.map(load_and_augment, num_parallel_calls=tf.data.AUTOTUNE)
dataset = dataset.batch(64).prefetch(tf.data.AUTOTUNE)
性能陷阱:数据加载中的隐形杀手
最后聊两个我亲身体会过的陷阱。
1. 我曾经把大权重文件放到 Dataset.__init__ 里加载,结果 num_workers=8 时内存直接撑爆。每个 worker 都持有一份权重副本。后来我换成懒加载,只在 __getitem__ 按需读取,内存下降 80%。
2. TensorFlow 下如果 .map 里用了 tf.py_function,你的并行度会直线下降。为什么?因为 tf.py_function 会把执行环境切回 Python 全局解释器锁里。能不用就不用。
数据加载这块,说白了就是「IO 先行,计算随后」。不管用 PyTorch 还是 TensorFlow,核心思路都是:把 IO 操作重叠、让 GPU 永远不闲着。下一阶段你自然会遇到更复杂的需求,比如大文件分片、内存溢出、多源混洗,但先把今天这几个基础配置吃透,后面的事就简单多了。