(8) 数据加载管线:一套抽象,兼容 MNIST、CIFAR 与 ImageNet

——“一个人用AI如何写出比PyTorch更快的自研深度学习框架”系列文章之八

前面我们已经讲过张量布局、静态图编译和框架的整体架构。静态图确实给框架带来了全局优化的空间,但有一张”全局地图”我还没展开——那就是数据。再精巧的计算图、再极致的 CUDA Kernel,如果 GPU 拿不到足够快的数据,也只能空转。训练框架里常常出现一种尴尬:算子优化了半天,profiler 一开,发现时间全耗在 cudaMemcpyH2D 或者 CPU 预处理上。

这一篇,我们把镜头对准训练管线的最前端,讲讲 Tech-Renaissance 的数据加载管线(Data Loading Pipeline)。它的核心目标很简单:用同一套抽象,把 MNIST、CIFAR、ImageNet 这三类规模、格式、存储方式完全不同的数据集,统一装进训练流程;同时让 CPU 解码、预处理、组 batch、H2D 传输这几个阶段高效衔接,不让 GPU 挨饿。

一、数据管线是训练框架的”厨房”

先回到常识。一个深度学习训练迭代,数据通常要经历这么一段旅程:

  1. 从磁盘读取原始样本(可能是 ubyte、bin、JPEG,也可能是自定义打包格式);
  2. 解码成原始像素(如果是压缩图像);
  3. 做数据增强与预处理(裁剪、缩放、翻转、归一化、类型转换等);
  4. 按 batch 组织样本,把标签和图像数据排列整齐;
  5. 通过 PCIe/NVLink 从主机内存异步拷贝到 GPU 显存;
  6. GPU 拿到张量,开始真正的前向、反向计算。

如果第 1~5 步跟不上第 6 步的速度,GPU 就会处于”数据饥饿(data starvation)”状态。对于 MNIST 这种小数据集,数据几乎可以全部塞进内存,问题不大;但对于 ImageNet 这种上百万张 JPEG 的训练集,单线程读盘加解码再预处理,速度根本不够看。一块 A100 处理一个 256 样本的 batch 可能只要二三十毫秒,而 CPU 侧光解码一张 224×224 的 JPEG 就要好几毫秒,单线程跑完一个 batch 几百毫秒就过去了。

所以现代框架都会在这一层下功夫。PyTorch 的做法大家都很熟悉:torch.utils.data.Dataset 负责单样本读取,DataLoader 通过 num_workers 拉起多个子进程并行做预处理,再用 pin_memory=True 把 CPU 内存锁页,方便后续异步 H2D 传输。TensorFlow 则主推 TFRecord,把数据预先打包成顺序读取的二进制记录,减少小文件随机 IO。此外,Caffe 时代常用的 LMDB(Lightning Memory-Mapped Database)也是一种键值存储方案,利用内存映射文件实现高效的随机读取。这些方案的共同思路是:把磁盘上的随机读取(打开文件、seek、read)变成顺序读取,大幅减少 IO 开销。但代价是:每次修改数据增强策略,可能都需要重新生成这些二进制文件,灵活性受限。

Tech-Renaissance 面对的挑战并没有不同,但我们选择了一条更”框架内聚”的路线:不是把数据加载做成一个独立的外部组件,而是把它纳入整个静态图编译与运行时体系,让 DataLoader、Preprocessor、TransferStation 三个模块紧密配合,形成一条零拷贝、可复现、可扩展的管线。

二、DataLoader:统一接口,后端随意换

数据管线的第一层抽象是 DataLoader。在 include/renaissance/data/data_loader.h 中,它被定义为一个抽象基类:

class DataLoader {
public:
    virtual void configure(
        int num_load_workers,
        int num_preproc_workers,
        const std::string& train_path,
        const std::string& val_path,
        bool shuffle_train = true,
        bool shuffle_val = false,
        bool skip_first = false,
        bool verify_crc = false) = 0;

    virtual void begin_epoch(int epoch_id, bool is_train) = 0;
    virtual void end_epoch() = 0;

    virtual bool get_next_sample(
        int preproc_worker_id,
        int32_t& label,
        const uint8_t*& data_ptr,
        size_t& data_size) = 0;

    virtual void set_train_mode(LoadMode mode) = 0;
    virtual void set_val_mode(LoadMode mode) = 0;

    virtual bool verify_dts_crc(const std::string& file_path) const = 0;
    virtual void download(const std::string& save_path) = 0;
    // ...
};

所有具体加载器——MnistLoaderRawMnistLoaderDtsCifarLoaderRawCifarLoaderDtsImageNetLoaderRawImageNetLoaderDts,以及用于部署的 SampleLoader——都继承自这个接口。设计上有几个关键约定:

  • 单例模式:每个具体 Loader 都是全局单例,避免训练与验证、不同 epoch 之间重复创建;
  • 线程安全:多个 Preprocessor worker 可以同时调用 get_next_sample(),Loader 内部保证互不干扰;
  • 零拷贝get_next_sample() 返回的 data_ptr 指向 Loader 内部缓冲区,外部只读,不复制。Preprocessor 拿到指针后原地解码、原地处理,处理完的数据再交给 TransferStation 走 H2D 传输。整条链路中,同一个样本的原始数据在内存中只有一份,没有中间拷贝;
  • 生命周期明确begin_epoch() / end_epoch() 用来切换 epoch,重置 shuffle 状态,区分训练/验证模式。

用户其实很少直接操作 DataLoader。通常只需要在 PREPROCESSOR_SETTING 里写一行 .dataset("imagenet", "/data/imagenet")Preprocessor 在内部就会根据数据集名称和是否启用 DTS 格式,自动选择对应的具体加载器:

switch (dataset_type_) {
    case DatasetType::mnist:
        current_dataloader_ = dts_format ? &MnistLoaderDts::instance()
                                         : &MnistLoaderRaw::instance();
        break;
    case DatasetType::cifar_10:
    case DatasetType::cifar_100:
        current_dataloader_ = dts_format ? &CifarLoaderDts::instance()
                                         : &CifarLoaderRaw::instance();
        break;
    case DatasetType::imagenet:
        current_dataloader_ = dts_format ? &ImageNetLoaderDts::instance()
                                         : &ImageNetLoaderRaw::instance();
        break;
}

这就是标题里说的”一套抽象”。对上层 Preprocessor 来说,MNIST 的 60,000 张 28×28 灰度图、CIFAR 的 50,000 张 32×32 彩色图、ImageNet 的 128 万张高分辨率 JPEG,获取方式都是同一个 get_next_sample()。后端的差异被彻底封在 Loader 内部。

三、RAW 与 DTS:两种加载路径

Tech-Renaissance 为每个内置数据集都提供了两种加载器:RAW 和 DTS,总共六种实现:

数据集RAW 格式 LoaderDTS 格式 Loader
MNISTMnistLoaderRawMnistLoaderDts
CIFAR-10/100CifarLoaderRawCifarLoaderDts
ImageNetImageNetLoaderRawImageNetLoaderDts

RAW 加载器读取数据集的公开原始格式。MNIST 对应官方的 train-images-idx3-ubyte / train-labels-idx1-ubyte;CIFAR 对应 data_batch_1.bin ~ data_batch_5.bin / test_batch.bin;ImageNet 对应原始 JPEG 文件夹,外加一个预生成的 summary.bin 索引文件,记录每张图片的路径、标签、文件大小等元信息。RAW 的优点是无需额外转换,拿到数据集就能跑;缺点是小文件随机 IO 和解码开销较大。

DTS 加载器读取 Tech-Renaissance 自研的 .dts 打包格式。DTS 会在下一篇文章里详细讲,这里只说它对 Loader 接口的影响:DTS 文件有固定的 header、CRC-32 校验、可选压缩,并且按照 Block/Slot 的方式组织数据。对 Loader 而言,DTS 路径只需要跳过 header、按索引读取块即可,逻辑比解析 JPEG 更简单。MNIST/CIFAR 的 DTS 文件同样只有 256 字节 header,后面紧跟全部样本数据;ImageNet 的 DTS 则按 16MB 的 Block 组织,支持 FULLY 全量加载和 PARTIAL 流式加载两种策略。

两种路径通过 using_dts_format(true, compression_level) 一键切换:

PREPROCESSOR_SETTING
    .dataset("imagenet", "/data/imagenet")
    .using_dts_format(true, 3)   // 启用 DTS,压缩级别 3
    .fully_mode(false)           // PARTIAL 流式加载
    .commit();

同一个 DataLoader 抽象,让 RAW 和 DTS 可以无缝互换。这是框架层抽象的价值:上层不需要关心数据是以什么物理形式存在,只需要保证接口语义一致。

四、FULLY 与 PARTIAL:小数据集全进内存,大数据集流式加载

LoadMode 是数据加载的另一个重要维度,定义在 include/renaissance/core/global_config.h

enum class LoadMode {
    AUTO,    // 自动选择
    FULLY,   // 全量加载到内存
    PARTIAL  // 环形缓冲区循环加载
};

对于 MNIST 和 CIFAR,Tech-Renaissance 会强制使用 FULLY 模式。原因很简单:训练集只有几十 MB 到几百 MB,一次性读进内存后,后续每个 epoch 都不再触碰磁盘,shuffle 也可以在内存中完成,速度最快。FULLY 模式下,第一个 epoch 加载完成后,Loader 会构建一份 SampleInfo 表,记录每个样本的标签、数据指针、大小;从第二个 epoch 开始,worker 直接按表领取,几乎是纯内存访问。

MNIST 的 FULLY 加载尤为简单:解析 .ubyte 文件头(magic number + 维度信息)后,将图像和标签分别存入 images_regionlabels_region 两块连续内存中。CIFAR 的 FULLY 加载则多一步格式转换:官方二进制文件中图像是 CHW 格式(通道–高–宽),而框架统一使用 NHWC 布局。这个格式转换必须做,但关键问题是什么时候做。如果做在预处理阶段,每次取样本都要重新排列一次,是重复计算。Tech-Renaissance 的选择是:做在加载阶段CifarLoaderRaw 在加载时一次性完成 CHW→HWC 转换,之后所有访问拿到的都是 NHWC 格式,保证了后续链路中的格式一致性。此外,CIFAR-10 和 CIFAR-100 的文件结构完全相同,只是 class 数量不同,CifarLoaderRaw 通过自动检测类别数来共用同一套代码,不需要两个独立的 Loader 类。

对于 ImageNet,默认使用 PARTIAL 模式。因为 128 万张原始 JPEG 全塞进内存可能要占用数百 GB,不是每台机器都受得了。PARTIAL 模式采用双缓冲/环形缓冲思想:Loader 每次只把一部分数据读入内存缓冲区,Preprocessor 消费完一块后,再异步加载下一块。ImageNet 的 RAW 版本按 64MB 的 Part Slot 切分(共 16 个 PART),DTS 版本按 16MB 的 Block 切分,两者都支持后台 IO 线程持续填缓冲区,前台 Preprocessor 持续消费,实现 IO 与预处理的重叠。

这里值得一提的是,FULLY/PARTIAL 不是简单地在”快但费内存”和”慢但省内存”之间二选一。PARTIAL 模式下的关键优化是静态领取:每个 Preprocessor worker 知道自己该取第几个样本,不需要抢一个全局原子计数器,从而把并发竞争降到最低。

五、Epoch 边界与 Shuffle:确定性随机

每个 epoch 开始时,数据需要重新打乱。这是深度学习训练的基本操作——如果每个 epoch 都按相同顺序喂数据,模型容易学到”顺序相关”的 bias,影响泛化。Tech-Renaissance 的 shuffle 机制分为两级:

第一级:全局 shuffle。在 begin_epoch() 中,框架用 Philox 随机数生成器对样本索引进行全局打乱。Philox 是基于计数器的——给定相同的 seed 和 epoch_id,生成的随机序列完全一致,无论用多少个线程、在什么平台上运行。这就是”确定性训练”的基石之一。

以 MNIST 为例,begin_epoch() 的核心流程是:先调用 perform_global_shuffle() 将 60000 个样本的 SampleInfo 数组打乱,然后调用 distribute_to_threads() 将打乱后的索引均匀分配给 M 个 Preprocessor worker:

void MnistLoaderRaw::begin_epoch(int epoch_id, bool is_train) {
    // 全局 shuffle
    perform_global_shuffle(global_info, epoch_id);
    // 分配给 M 个 worker
    distribute_to_threads(global_info, thread_info);
}

第二级:静态领取。分配之后,每个 worker 拿到的是自己独享的一份索引列表。Worker i 的第 k 次调用 get_next_sample(),就读取自己列表中的第 k 个元素。这种设计被称作”静态样本领取机制”——每个 worker 负责固定间隔的样本(i + k×M),不存在竞争。Worker 之间不需要任何锁或同步——它们各自拿着自己的索引列表往前走,互不干扰。这是 Tech-Renaissance 多线程预处理架构的核心设计,由第九篇详细展开。但它的根基——索引的分配——就发生在 begin_epoch() 这个阶段。

epoch 边界处理get_next_sample() 返回 false 时,表示当前 epoch 的所有样本已被消费完毕。此时 Preprocessor 会调用 end_epoch(),然后进入下一个 begin_epoch(),开始新一轮 shuffle+分配。对于 PARTIAL 模式(ImageNet),每个 epoch 的 shuffle 是在”虚拟索引”层面完成的——shuffled_locations 数组记录了所有样本的访问顺序,DataLoader 按这个顺序去对应的 Buffer 和 Slot 中取数据。

configure() 中还有一个 skip_first 参数。当设置为 true 时,第一个 epoch 不执行 shuffle——这在 warmup 阶段很有用,可以快速验证数据管线是否正常工作,而不需要等待 shuffle 的计算开销。

六、标签管理:容易被忽视的细节

标签管理看似简单,但有几个容易踩坑的细节,Tech-Renaissance 在设计时都做了处理。

标签类型统一为 int32get_next_sample() 返回的标签固定为 int32_t 类型。Tech-Renaissance 的 DType 系统只有 FP32/FP16/INT8/INT32(见第七篇),标签统一为 int32 既与框架内 top-1/top-5 指标算子的期望类型一致,也比 int64 减半标签传输量。PyTorch 的 nn.CrossEntropyLoss 在处理类别索引(硬标签)时强制要求标签为 int64(LongTensor),在本框架中我们通过统一 int32 避免了这一约束带来的额外带宽开销。

class_to_idx 映射的一致性。对于 ImageNet 这类文件夹结构的数据集,框架在扫描目录时自动构建 label_to_folderfolder_to_label 两个映射表。训练集和验证集共用同一个映射表,确保标签空间一致——不会出现”训练集里 n01440764 是第 0 类,但验证集里它变成了第 1 类”这种隐性错误。这是一个在 PyTorch 社区中反复被问到的问题——很多人在 ImageNet 上训练时发现验证准确率很低,排查到最后发现是 class_to_idx 映射不一致导致的。

CRC-32 完整性校验。DTS 格式的 Loader 支持 verify_dts_crc() 方法,对 DTS 文件进行 CRC-32 校验。DTS 文件的文件头中存储了 CRC-32 校验码,加载后会重新计算并与文件头中的值比对。这保证了数据在传输和存储过程中没有损坏——标签和图像数据因此不会出现”静默错误”。

七、Preprocessor:把数据集配置翻译成一条流水线

DataLoader 负责把数据从磁盘搬到内存,Preprocessor 则负责把原始样本变成 GPU 能直接消费的 batch。它通过一个流畅的 Setup 构建器对外暴露配置:

PREPROCESSOR_SETTING
    .dataset("imagenet", "/data/imagenet")
    .preprocess_workers(128)
    .normalization(NormMode::IMAGENET)
    .train_transforms(
        RandomResizedCrop(224, 0.08f, 1.0f),
        RandomHorizontalFlip(),
        ColorJitter(0.2f, 0.2f, 0.2f, 0.1f),
        RandomErasing(0.25f, {0.02f, 0.33f}, {0.3f, 3.3f})
    )
    .val_transforms(
        Resize(256),
        CenterCrop(224)
    )
    .commit();

这个配置做了几件事:指定数据集、设置预处理 worker 总数、选择归一化预设、分别定义训练与验证的变换链。commit() 会按照一个内部状态机依次完成:选择 DataLoader、下载/解压/校验、配置 Loader 模式、计算 batch/分辨率/缓冲区大小、分配 staging memory、注入归一化、注册变换链、计算 steps_per_epoch。

注意其中的 normalization(NormMode::IMAGENET)。框架不允许用户把 NormalizeFusedNormalization 直接写进 train_transforms,而是强制通过 NormMode 来指定。原因是 Tech-Renaissance 会把 Normalize / 除以 255 / 数据类型转换 这些步骤在 PO 链末尾统一合并成一次 FusedNormalization,避免中间结果反复读写。这是数据侧的一项重要优化,第十篇文章会专门讲它,这里先埋下伏笔。

所有具体变换都继承自 PreprocessOperation

virtual void execute(
    const uint8_t* input_ptr,
    int32_t input_width, int32_t input_height, size_t input_stride,
    uint8_t* output_ptr,
    int32_t& output_width, int32_t& output_height, size_t& output_stride,
    Generator* rng = nullptr,
    bool execute_from_full = false,
    bool forced_compact_output = true) = 0;

每个 Preprocessor worker 在启动时都会克隆一份自己的 PO 副本,拥有独立的输入/输出缓冲区和 TurboJPEG 句柄。worker 之间不共享状态,也就没有 false sharing 和锁竞争。这种”无共享”设计,是把预处理扩展到 128 甚至 200+ 线程的基础。

八、TransferStation:CPU 与 GPU 之间的双缓冲驿站

预处理完成后,数据还需要到达 GPU。Tech-Renaissance 没有让用户手动调用 tensor.to(device),而是把 H2D 传输也封装进了框架内部,核心就是 TransferStation

TransferStation 是每个 GPU Engine 一个的双缓冲管理器。它在 configure() 阶段从 GlobalRegistry 的 staging memory pool 里拿到两块已经分配好的锁页内存,每块被划分为标签区和图像数据区,并且都按 256 字节对齐:

std::atomic<int> current_buffer_{0};
int32_t* buffer_labels_[2] = {nullptr, nullptr};
uint8_t*  buffer_data_[2]  = {nullptr, nullptr};
std::atomic<bool> buffer_0_is_readable_{false};
std::atomic<bool> buffer_1_is_readable_{false};
std::atomic<bool> buffer_0_is_writeable_{true};
std::atomic<bool> buffer_1_is_writeable_{true};

工作方式很直观:

  • Preprocessor worker 把处理好的样本写入当前 buffer 的对应位置;
  • 当当前 buffer 被一个 batch 的样本填满后,TransferStation 把它标记为 readable,并通知 GPU 侧读取;
  • 同时切换到另一块 buffer,继续让 Preprocessor 写入;
  • GPU 读取完毕后,把用过的 buffer 重新标记为 writeable,等待下一轮填充。

这样 CPU 预处理、CPU→GPU 异步拷贝、GPU 计算这三个阶段就可以流水起来:当 GPU 在计算 buffer A 对应的 batch 时,CPU 正在往 buffer B 里填下一个 batch。双缓冲消除了 CPU 等待 GPU、GPU 等待 CPU 的串行空闲。

对齐要求也不容忽视。单区大小计算为:

label_aligned_ = align_up_256(local_batch_size * sizeof(int32_t) + 16);
data_aligned_  = align_up_256(local_batch_size * max_sample_bytes + 16);
bytes_per_transfer_ = label_aligned_ + data_aligned_;

之所以加 16 字节再向上对齐到 256 字节,是为了与 GPU 端 DTensor 的内存布局兼容,确保 H2D 异步传输后不需要再做数据重排。这个细节看起来很小,但它体现了 Tech-Renaissance 一贯的作风:数据侧的任何布局都要和显存侧的布局对齐,否则性能会一点点漏掉。

九、与 PyTorch DataLoader 的几点区别

写到这里,可能有人会问:这不就是一个 C++ 版的 PyTorch DataLoader 吗?功能上确实有相似之处,但设计定位完全不同。

第一,worker 总数的语义不同。PyTorch 的 num_workers 通常是每张 GPU 的进程数;而 Tech-Renaissance 的 preprocess_workers跨所有 GPU 的总线程数。例如 8 卡云服务器、PyTorch 每张卡 16 个 worker,总共是 128 个进程;在 Tech-Renaissance 里直接写 preprocess_workers(128)。框架内部再通过 engine_id = worker_id % world_size 把样本划分到各个 GPU。这个设计让多卡场景下的 CPU 资源分配更统一,也便于做跨卡的 NUMA/CPU 绑核。

第二,数据格式原生支持。PyTorch 依赖用户自己写 Dataset 来读原始文件或自定义二进制;Tech-Renaissance 内置了 RAW 和 DTS 两条路径,ImageNet RAW 还需要预生成 summary.bin 索引。DTS 不是简单的打包,它会把下一篇文章要讲的 Block 索引、CRC 校验、压缩策略都考虑进去,让 Loader 可以按块随机访问。

第三,GPU 传输内聚。PyTorch DataLoader 把预处理后的 tensor 传回主进程,用户再自己 to(device) 发起 H2D;Tech-Renaissance 的 TransferStation 直接和深度学习引擎共享 staging memory,H2D 由运行时的 CUDA Graph 子图统一调度,不需要 Python 侧参与。

第四,可复现性贯穿全程。Tech-Renaissance 的 shuffle、数据增强、worker 领取顺序都基于 Philox 计数器随机数,保证同样种子下每次运行结果一致。这一点在第 22 篇会专门展开,但在数据管线里它已经体现为”静态领取”和”确定性 shuffle”的设计。

十、小结:数据管线是性能的第一道关卡

数据加载管线看起来不如算子融合、CUDA Graph 那么”硬核”,但它决定了 GPU 能不能满负荷运转。Tech-Renaissance 在这一层的设计思路可以概括为三句话:

  1. 统一抽象:用 DataLoader 一个接口覆盖 MNIST、CIFAR、ImageNet,RAW 与 DTS 随意切换,共六种 Loader 实现;
  2. 按需加载:小数据集全进内存(FULLY),大数据集流式双缓冲(PARTIAL),CIFAR 的 CHW→HWC 转换做在加载阶段而非预处理阶段;
  3. 零拷贝贯通:从 Loader 内部缓冲区到 Preprocessor worker,再到 TransferStation 的 staging memory,最后通过 H2D 进入 GPU 计算图,中间尽量少做内存复制。

这套管线的下一环,就是 DTS 格式本身。为什么我们要自研数据格式?DTS 的文件头、Block 索引、CRC 校验、FULLY/PARTIAL 策略到底长什么样?它又是怎么把磁盘布局的精心设计转化为内存带宽节省的?这些问题,我们下一篇《DTS 数据格式:为高速训练定制的存储方案》里接着聊。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

ICP备案号:京ICP备2025133467号-1