(11) FusedNormalization:把数据增强的最后一步融进一次内存遍历

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

前面三篇文章我们把数据引擎讲了个大概:第 7 篇介绍了统一的数据加载抽象,第 8 篇深入了 DTS 自定义数据格式,第 9 篇讲了多线程预处理、NUMA 感知与异步双缓冲。如果把训练管线比作一条高速公路,DTS 解决的是”源头收费站”的问题,多线程和双缓冲解决的是”车道数量和并行调度”的问题,而今天要讲的 FusedNormalization,解决的是预处理流水线内部最后一个拥堵点——数据增强的最后几步能不能不要反复读写内存。

这个问题看似不起眼,但对于 ImageNet 这种每个 epoch 要处理 128 万张图片的训练任务来说,ToTensor、Normalize、RandomHorizontalFlip、RandomErasing 这些操作的排列组合方式,直接决定了 CPU 预处理到底能不能喂饱 GPU。Tech-Renaissance 的做法是把它们融进一次内存遍历:一张 uint8 图片进去,一张已经归一化好、可能已经水平翻转、可能已经随机擦除的 float/FP16 图片出来。没有中间张量,没有反复拷贝,没有 Python 层的函数调用开销。

一、数据增强:训练前的”标准化加工”

在进入实现细节之前,先回顾一下图像分类任务里常见的预处理流程。以 ImageNet 训练为例,一张原始 JPEG 图片从 DTS 文件中被读取出来后,通常会经历以下步骤:

  1. 解码:把 JPEG 字节流解压成 uint8 的像素数组(H×W×C)。
  2. 随机裁剪/缩放RandomResizedCrop 把图片裁到 224×224。
  3. 随机水平翻转:以 50% 概率左右翻转。
  4. 类型转换ToTensor 把 uint8 的 [0,255] 映射到 float 的 [0,1]。
  5. 归一化Normalize 按每个通道的 mean/std 做 (x - mean) / std
  6. 随机擦除:按一定概率把图片某个矩形区域清零(RandomErasing)。

这些步骤在 PyTorch 里通常写成:

transforms.Compose([
    RandomResizedCrop(224),
    RandomHorizontalFlip(),
    ToTensor(),
    Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
    RandomErasing(p=0.5)
])

这个写法清晰、模块化,每个变换负责一件事。但从性能角度看,它有一个隐形成本:每经过一个变换,就要产生一份中间结果ToTensor 输出一份 float 张量,Normalize 在上面再做一份,RandomErasing 又要再读写一遍。对于 224×224×3 的图片,一张图就是 602 KB 的 float 数据;一个 batch 256 张就是 150 MB;一个 epoch 128 万张就是约 750 GB 的中间内存流量。这还没算水平翻转、擦除等操作对同一份数据的反复访问。

更微妙的地方在于缓存。每个操作独立地把整张图从内存加载到 CPU 缓存,处理完再写回;下一个操作又从内存重新加载。如果数据量超出 L2/L3 容量,同一个像素会被反复驱逐和重载。对于 CPU 侧预处理来说,内存带宽和缓存利用率往往是真正的瓶颈。

二、主流框架如何处理这个问题

2.1 PyTorch torchvision:灵活但独立遍历

torchvision 的 v1 transforms 把每个操作封装成独立的 Python callable,主要面向 PIL Image 设计,对 torch.Tensor 的支持并不统一完整。好处是接口简单、易于组合、调试方便;坏处是每个 transform 都是一次独立的内存遍历,每次都要经过 Python 派发、张量创建和内存分配,变换之间很难做跨步骤优化。

v2 transforms 真正改为以 torch.Tensor 为核心实现,并扩展支持了 bbox、mask 等结构化数据,也引入了一些内部融合(例如对边界框的 SanitizeBoundingBoxConvertBoundingBox 等),对性能有一定改善。但只要仍然以独立的 transform 对象组合,就难以在语义上把 ToTensor、Normalize、Flip、Erase 合并成一次像素级遍历。

2.2 NVIDIA DALI:把预处理搬到 GPU

NVIDIA DALI 是另一条路线:JPEG 解码、resize、normalize、flip 全部放到 GPU 上做。它的 fn.crop_mirror_normalize 可以把类型转换、归一化、裁剪、翻转合并在一次 CUDA kernel 里完成,性能非常出色。但 DALI 是 NVIDIA 专用方案,主要面向 Linux,且对训练框架的侵入性较强——你需要用 DALI 的 pipeline 替代原有数据加载器。

2.3 TensorFlow tf.data:图级优化

TensorFlow 的 tf.data 管线会把多个 map 操作尽量融合。它主要解决的是 Python 函数调用开销和并行度问题,对于具体的 ToTensor+Normalize 这种数值级融合,优势不如手写 SIMD kernel 明显。

2.4 融合的通用价值

无论哪种框架,大家其实都在做同一件事:减少中间张量、减少内存遍历、减少数据搬移。在 CPU 侧,内存带宽往往是瓶颈;在 GPU 侧,kernel launch 和显存读写是瓶颈。把多个小操作合并成一次遍历,是系统优化里最基本也最有效的手段之一。Tech-Renaissance 的 FusedNormalization 走的就是这条路,只不过它发生在 CPU 预处理阶段,为后续的 H2D 传输和 GPU 计算准备数据。

三、FusedNormalization 在 Tech-Renaissance 中的定位

Tech-Renaissance 的预处理管线由 Preprocessor 统一管理。用户在 Setup 里配置训练集和验证集的 transforms:

PREPROCESSOR_SETTING
    .dataset("IMAGENET", data_path)
    .normalization(NormMode::IMAGENET)
    .train_transforms(
        RandomResizedCrop(224),
        RandomHorizontalFlip(),
        RandomErasing(0.5f)
    )
    .val_transforms(
        Resize(256),
        CenterCrop(224)
    )
    .commit();

注意这里 RandomHorizontalFlipRandomErasingNormalize 的语义被框架接管了Normalize 甚至不允许直接出现在 .train_transforms() 里——用户必须通过 .normalization(NormMode::...) 来配置归一化参数。真正干活的是一个在 PO(PreprocessOperation)链末尾自动注入FusedNormalization 对象。

这个设计有两个关键含义:

第一,FusedNormalization 是框架内部类,用户不可直接构造。它被硬编码为 PO 链的最后一个操作,因为至少 ToTensor(uint8 → float/FP16)这一步无法省略。

第二,RandomHorizontalFlip 和 RandomErasing 被降级为”参数记录类”。它们只负责把参数传给 FusedNormalization,自己的 execute() 不会被调用。这避免了独立执行带来的额外内存拷贝。RandomHorizontalFlip::execute()RandomErasing::execute() 都直接抛出 TR_NOT_IMPLEMENTED,明确告知开发者这些操作已经被融合进 FusedNormalization。

四、代码层面的自动注入机制

PO 链的构造发生在 Preprocessor::set_train_transforms() 中。框架遍历用户传入的所有 transforms,把三个记录类(RandomErasingRandomHorizontalFlipNormalize)的参数提取出来,其他真实 PO(如 RandomResizedCropColorJitter)保留在 filtered_ops 中:

// src/data/preprocessor.cpp
bool flip_enabled = false;
bool erase_enabled = false;
float erase_p = 0.0f;
float erase_scale_min = 0.02f;
float erase_scale_max = 0.33f;
float erase_ratio_min = 0.3f;
float erase_ratio_max = 3.3f;
NormMode norm_mode = NormMode::NO_NORM;

std::vector<std::unique_ptr<PreprocessOperation>> filtered_ops;
for (auto& op : temp_ops) {
    if (auto* re = dynamic_cast<RandomErasing*>(op.get())) {
        erase_enabled = true;
        erase_p = re->get_p();
        erase_scale_min = re->scale_min();
        erase_scale_max = re->scale_max();
        erase_ratio_min = re->ratio_min();
        erase_ratio_max = re->ratio_max();
    } else if (auto* rhf = dynamic_cast<RandomHorizontalFlip*>(op.get())) {
        flip_enabled = true;
    } else if (auto* norm = dynamic_cast<Normalize*>(op.get())) {
        norm_mode = norm->mode();
    } else {
        filtered_ops.push_back(std::move(op));
    }
}

框架提取出三个记录类的参数后,把真实的 resize/crop 等操作保留在 filtered_ops 中,然后在 PO 链末尾注入 FusedNormalization

// src/data/preprocessor.cpp
NormalizePreset preset = NormalizePreset::NO_NORM;
switch (norm_mode) {
    case NormMode::NO_NORM:  preset = NormalizePreset::NO_NORM;  break;
    case NormMode::MNIST:    preset = NormalizePreset::MNIST;    break;
    case NormMode::CIFAR:    preset = NormalizePreset::CIFAR;    break;
    case NormMode::IMAGENET: preset = NormalizePreset::IMAGENET; break;
    case NormMode::MLPERF:   preset = NormalizePreset::MLPERF;   break;
}

bool use_amp = GlobalRegistry::instance().using_amp();

auto fused_norm = std::make_unique<FusedNormalization>(
    preset, use_amp, flip_enabled, erase_enabled,
    erase_p, erase_scale_min, erase_scale_max,
    erase_ratio_min, erase_ratio_max, 0
);
train_ops_template_.push_back(std::move(fused_norm));

对验证集的处理类似,但 erase_enabled 强制为 false,因为 RandomErasing 只允许用于训练。

五、FusedNormalization 融合了什么

FusedNormalization 要做的事情是这样的:

// include/renaissance/data/fused_normalization.h
/**
 * @class FusedNormalization
 * @brief 融合归一化操作:ToTensor + RandomHorizontalFlip + Normalize + RandomErasing
 *
 * 这是TR4框架最重要的预处理操作,它将PyTorch中四个独立的预处理步骤融合为一次内存遍历:
 * - ToTensor:uint8_t[0,255] → float[0,1](除以255)
 * - RandomHorizontalFlip:50%概率水平翻转(可选,flip_enabled_控制)
 * - Normalize:(x - mean) / stddev,支持ImageNet/MNIST/CIFAR/MLPerf四种预设
 * - RandomErasing:随机矩形区域擦除(可选,erase_enabled_控制)
 */

也就是说,一张 uint8 图片进入 execute() 后,经过一次统一的 execute() 调用,就得到了最终要传给 GPU 的 float 或 FP16 数据。具体来说:

  • ToTensor + RandomHorizontalFlip + Normalize 在同一次像素遍历中完成:每读入一个像素,先决定从镜像位置还是原位置读取,然后做 ToTensor 和 Normalize,最后直接写入输出缓冲区。
  • RandomErasing 作为同一次 execute() 调用内的轻量后续步骤完成:由于擦除区域只占图像的一小部分,FusedNormalization 在主遍历结束后对输出缓冲区的对应区域做一次 memset 清零,而不是再对整张图做一次完整遍历。

不需要先转 float 再 normalize 再 flip 再 erase,所有决策和计算都在同一个函数调用内完成。

具体执行入口是 execute()

// src/data/fused_normalization.cpp
void FusedNormalization::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, ...
) {
    output_width = input_width;
    output_height = input_height;

    TR_CHECK(output_stride == 0, ValueError,
             "FusedNormalization does not support external output_stride setting. "
             "Its output stride is determined solely by output_size, num_channels, and AMP mode.");
    output_stride = compact_output_stride_;

    bool do_flip = false;
    if (flip_enabled_) {
        do_flip = (uniform(0.0f, 1.0f, rng) < 0.5f);
    }

    EraseRect erase_rect;
    if (erase_enabled_) {
        erase_rect = generate_erase_rect(input_height, input_width, rng);
    }
    // ... 主遍历完成 ToTensor + Flip + Normalize,随后按需 apply_erase
}

这里有两个随机决策点:是否水平翻转、是否以及在哪里擦除。两个决策都在遍历开始前完成,随后进入统一的像素处理循环。

六、FP32 与 FP16 双精度输出

FusedNormalization 支持两种输出格式:

  • FP32:每个像素 num_channels 个 float,无通道 padding。
  • FP16/AMP:每个像素固定 4 个 uint16_t,有效通道不足 4 时用 0 填充。

FP16 路径是为了配合框架的混合精度训练。AMP 模式下,GPU 拿到的就是已经压缩好的 FP16 数据,H2D 传输量直接减半。而且 FusedNormalization 在 CPU 端做 FP32→FP16 的转换,调用的是 F16C 扩展的 _mm_cvtps_ph 指令(常见支持 AVX2 的 CPU 均支持 F16C),比 GPU 端做转换更省显存带宽。

// src/data/fused_normalization.cpp
inline std::uint16_t fp32_to_half(float f) noexcept {
    __m128 v32 = _mm_set_ss(f);
    __m128i v16 = _mm_cvtps_ph(v32, 0);
    return static_cast<std::uint16_t>(_mm_cvtsi128_si32(v16));
}

七、SIMD 与 F16C:一次处理多个像素

FusedNormalization 的 FP16 路径借助 SSE 向量化与 F16C 硬件扩展做了专门优化。以 RGB 三通道为例,核心函数 simd_process_2pixels_c3 一次读取 8 字节(两个像素,6 个有效通道 + 2 字节填充),把它扩展成 8 个 32 位整数,再转成 8 个 float,乘上预计算的 mul = 1/(255*std)、减去 sub = mean/std,最后用 _mm_cvtps_ph 打包成 8 个 FP16:

// src/data/fused_normalization.cpp
inline void simd_process_2pixels_c3(const std::uint8_t* p, std::uint16_t* dst,
                                     __m128 mul_v, __m128 sub_v) noexcept {
    __m128i u8x8 = _mm_loadl_epi64(reinterpret_cast<const __m128i*>(p));
    __m128i i32_0 = _mm_cvtepu8_epi32(u8x8);
    __m128 f0 = _mm_cvtepi32_ps(i32_0);
    f0 = _mm_mul_ps(f0, mul_v);      // 普通标量广播乘法
    f0 = _mm_sub_ps(f0, sub_v);
    __m128i h0 = _mm_cvtps_ph(f0, 0);

    __m128i shifted = _mm_srli_si128(u8x8, 3);
    __m128i i32_1 = _mm_cvtepu8_epi32(shifted);
    __m128 f1 = _mm_cvtepi32_ps(i32_1);
    f1 = _mm_mul_ps(f1, mul_v);
    f1 = _mm_sub_ps(f1, sub_v);
    __m128i h1 = _mm_cvtps_ph(f1, 0);

    __m128i h01 = _mm_unpacklo_epi64(h0, h1);
    _mm_storeu_si128(reinterpret_cast<__m128i*>(dst), h01);
}

翻转路径 simd_process_2pixels_c3_flip 在此基础上把结果写到目标行的镜像位置,实现”边转换边翻转”,不需要先 flip 再 normalize:

// src/data/fused_normalization.cpp
inline void simd_process_2pixels_c3_flip(...) {
    // ... 与 noflip 相同的转换逻辑 ...
    __m128i h01_swapped = _mm_shuffle_epi32(h01, _MM_SHUFFLE(1, 0, 3, 2));
    std::uint16_t* pos = dst + (W - 1 - (w + 1)) * 4;
    _mm_storeu_si128(reinterpret_cast<__m128i*>(pos), h01_swapped);
}

单通道 MNIST 则有专门的 simd_process_4pixels_c1,一次处理 4 个灰度像素,输出 4 个 FP16 值并 padding 到 4 通道:

// src/data/fused_normalization.cpp
inline void simd_process_4pixels_c1(...) {
    int v; std::memcpy(&v, p, sizeof(v));
    __m128i u8x4 = _mm_cvtsi32_si128(v);
    __m128i i32 = _mm_cvtepu8_epi32(u8x4);
    __m128 f = _mm_cvtepi32_ps(i32);
    f = _mm_mul_ps(f, mul_v);
    f = _mm_sub_ps(f, sub_v);
    __m128i h = _mm_cvtps_ph(f, 0);

    __m128i zero = _mm_setzero_si128();
    __m128i t0 = _mm_unpacklo_epi16(h, zero);
    _mm_storeu_si128(reinterpret_cast<__m128i*>(dst),      _mm_unpacklo_epi32(t0, zero));
    _mm_storeu_si128(reinterpret_cast<__m128i*>(dst + 8),  _mm_unpackhi_epi32(t0, zero));
}

非 AVX2 平台则回退到 Eigen3 实现 FP32 路径,AMP 在非 AVX2 环境下直接抛出 TR_NOT_IMPLEMENTED

// src/data/fused_normalization.cpp
#if !defined(__AVX2__)
    if (use_amp_) {
        TR_NOT_IMPLEMENTED(
            "AMP=ON (FP16 output) is not supported in non-AVX2 mode. "
            "FusedNormalization requires AVX2 for AMP/FP16 support."
        );
    }
#endif

八、4 通道 padding:为了 GPU 友好

FP16 路径下,即使原始图片是 3 通道 RGB,FusedNormalization 也会把每个像素 padding 到 4 通道:

// src/data/fused_normalization.cpp
if (use_amp_) {
    compact_output_stride_ = static_cast<size_t>(output_size_) * 4 * sizeof(uint16_t);
}

224×224 的 RGB 图片,FP32 输出 stride 是 224 × 3 × 4 = 2688 字节;FP16 输出 stride 是 224 × 4 × 2 = 1792 字节。这个 4 通道对齐有几个好处:

  1. 每个像素 8 字节,天然 64 位对齐,符合 GPU 全局内存合并访问的偏好。
  2. H2D 拷贝和后续 CUDA kernel 读取更简单,可以用统一的 (h, w, 4) 索引。
  3. 与 Tensor Core 的输入格式更契合:AMP 训练通常需要 FP16 输入,4 通道对齐有助于后续卷积等算子高效读取。

九、五种归一化预设

FusedNormalization 内置了五种数据集预设:

// src/data/fused_normalization.cpp
Params get_params(NormalizePreset preset) noexcept {
    switch (preset) {
        case NormalizePreset::NO_NORM:
            return { {0.0f, 0.0f, 0.0f}, {1.0f, 1.0f, 1.0f}, 3 };
        case NormalizePreset::MNIST:
            return { {0.1307f, 0.0f, 0.0f}, {0.3081f, 1.0f, 1.0f}, 1 };
        case NormalizePreset::CIFAR:
            return { {0.4914f, 0.4822f, 0.4465f}, {0.2470f, 0.2435f, 0.2616f}, 3 };
        case NormalizePreset::IMAGENET:
            return { {0.485f, 0.456f, 0.406f}, {0.229f, 0.224f, 0.225f}, 3 };
        case NormalizePreset::MLPERF:
            return { {123.68f/255.0f, 116.78f/255.0f, 103.94f/255.0f},
                     {1.0f/255.0f, 1.0f/255.0f, 1.0f/255.0f}, 3 };
    }
    return {};
}

NO_NORM 等价于只除以 255,不做 mean/std 调整;MLPERF 是 ImageNet 的另一种常用表达形式,把 mean 和 std 都先除以 255,这样 ToTensor 和 Normalize 可以合并成一次乘减。

十、可复现的随机决策

FusedNormalization 里的随机性来自两个地方:水平翻转和随机擦除。两者都通过 Generator 获取 Philox 计数器随机数:

// src/data/fused_normalization.cpp
float FusedNormalization::uniform(float min_val, float max_val, Generator* rng) const {
    uint64_t offset = rng->next_offset(1);
    float rand_val = detail::philox_uniform_float(rng->seed(), offset);
    return min_val + rand_val * (max_val - min_val);
}

int FusedNormalization::randint(int min_val, int max_val, Generator* rng) const {
    return rng->random_int(min_val, max_val);
}

Philox 是一种基于计数器的伪随机数生成器:给定 (seed, offset) 就能唯一确定一个随机数,不需要维护状态向量。这意味着即使 200 个预处理线程并发执行,只要 seed 和 offset 序列是确定的,最终结果就是可复现的。我们后面第 22 篇会专门讲 Philox 和确定性训练,这里只需要知道:融合没有牺牲可复现性

十一、与 PreprocessWorker 和 TransferStation 的衔接

PreprocessWorker 在运行 PO 链时,会把 FusedNormalization 作为最后一个操作,输出直接写到 TransferStation 的锁页内存槽位:

// src/data/preprocess_worker.cpp
if (op_id == num_ops - 1) {  // 最后一个操作(FusedNormalization),输出到最终位置
    dest_ptr = final_output_ptr;
    output_stride = 0;  // 触发自动计算(紧凑布局)
    compact_layout = true;
}

final_output_ptr 来自 request_transfer_station_slot(),指向的就是 H2D 异步拷贝的源内存。因此 FusedNormalization 的输出 layout 必须与 TransferStation 的期望完全一致。calculate_stride() 在这里起到了关键作用:

// src/data/fused_normalization.cpp
size_t FusedNormalization::calculate_stride() {
    if (use_amp_) {
        compact_output_stride_ = static_cast<size_t>(output_size_) * 4 * sizeof(uint16_t);
    } else {
        compact_output_stride_ = static_cast<size_t>(output_size_)
                               * static_cast<size_t>(num_channels_) * sizeof(float);
    }
    output_stride_ = compact_output_stride_;
    return output_stride_;
}

这也是为什么 FusedNormalization 拒绝外部传入 output_stride——它的 stride 由数据类型严格决定,不允许调用方用 uint8 的思维传入一个错误的值。

注意,此处 output_stride_ 是为底层 TransferStation 异步拷贝准备的行跨度字节数(bytes per row),这区别于 DTensor 类中用于逻辑索引的以元素个数为单位的 stride。这种解耦让传输层只需关心物理内存边界。

十二、随机擦除的实现

RandomErasing 在训练时按概率把图片某个矩形区域清零。FusedNormalization 里的实现分两步:先按对数均匀分布采样擦除区域的高和宽,再在整个像素遍历结束后对输出做 memset:

// src/data/fused_normalization.cpp
FusedNormalization::EraseRect FusedNormalization::generate_erase_rect(int H, int W, Generator* rng) const {
    if (!erase_enabled_ || erase_p_ <= 0.0f || uniform(0.0f, 1.0f, rng) >= erase_p_) {
        return {};
    }
    const float img_area = static_cast<float>(H) * static_cast<float>(W);
    for (int attempt = 0; attempt < 10; ++attempt) {
        float target_area_ratio = erase_scale_min_ + uniform(0.0f, 1.0f, rng)
                                  * (erase_scale_max_ - erase_scale_min_);
        float erase_area = target_area_ratio * img_area;

        float log_aspect_ratio = log_erase_ratio_min_
                               + uniform(0.0f, 1.0f, rng)
                               * (log_erase_ratio_max_ - log_erase_ratio_min_);
        float aspect_ratio = std::exp(log_aspect_ratio);

        int eh = static_cast<int>(std::round(std::sqrt(erase_area * aspect_ratio)));
        int ew = static_cast<int>(std::round(std::sqrt(erase_area / aspect_ratio)));

        if (eh > 0 && ew > 0 && eh < H && ew < W) {
            int i = randint(0, H - eh, rng);
            int j = randint(0, W - ew, rng);
            return {i, j, eh, ew, true};
        }
    }
    return {};
}

采样逻辑与 torchvision 的 RandomErasing 保持一致:面积比例在 [0.02, 0.33] 之间均匀采样,宽高比在对数空间 [0.3, 3.3] 之间均匀采样,最多尝试 10 次找到合法区域。然后 apply_erase_fp32apply_erase_fp16 把对应区域清零:

// src/data/fused_normalization.cpp
void FusedNormalization::apply_erase_fp32(float* data, int W, int C, const EraseRect& rect) const {
    if (!rect.enabled) return;
    for (int y = 0; y < rect.eh; ++y) {
        float* row_start = data + (static_cast<size_t>(rect.i + y) * W + rect.j) * C;
        std::memset(row_start, 0, static_cast<size_t>(rect.ew) * C * sizeof(float));
    }
}

注意擦除是在 normalize 之后进行的,所以擦除值是 0(即 normalize 后的”零值”),与 PyTorch 行为一致。

十三、数值正确性验证

为了保证 FP32 和 FP16 两条路径的数值一致性,框架里有一个专门的矫正测试 tests/correction/test_fused_normalization.cpp。它对 MNIST 大小的 28×28 灰度图分别跑 FP32 和 AMP 版本,然后计算 MSE 和最大相对误差:

// tests/correction/test_fused_normalization.cpp
double max_relative_error = 0.0;
int relative_error_count = 0;
const double epsilon = 1e-6f;

for (int i = 0; i < total_pixels; ++i) {
    double abs_val = std::abs(static_cast<double>(output_fp32[i]));
    if (abs_val > epsilon) {
        double rel_error = std::abs(static_cast<double>(output_fp32[i] - output_amp[i])) / abs_val;
        if (rel_error > max_relative_error) {
            max_relative_error = rel_error;
        }
        relative_error_count++;
    }
}

const double mse_tolerance = 1e-5;
const double rel_error_tolerance = 1e-3;
bool pass = (mse < mse_tolerance) && (max_relative_error < rel_error_tolerance);

这个测试确保 FP16 引入的量化误差在可接受范围内,不会因为融合而牺牲训练正确性。

十四、小结

FusedNormalization 是 Tech-Renaissance 数据引擎里”少即是多”的典型案例。它没有引入新的算法,也没有改变数据增强的语义,只是把 PyTorch 中四个独立的预处理步骤合并成了一次内存遍历。但正是这次合并,消除了中间张量的反复读写,让 CPU 预处理阶段的内存带宽得到了更高效的利用。

核心要点:

  1. PO 链自动注入:用户在 Setup 中配置 .normalization()RandomHorizontalFlip()RandomErasing(),框架在 PO 链末尾自动构造 FusedNormalization
  2. 单次像素遍历完成核心转换:ToTensor、RandomHorizontalFlip、Normalize 在同一次遍历中完成;RandomErasing 作为同一次 execute() 调用内的轻量 memset 后续步骤完成,无需额外的完整遍历或中间张量。
  3. 双精度输出:FP32 保持原始通道数,FP16/AMP 固定 4 通道 padding,直接服务混合精度训练。
  4. SSE/F16C SIMD 加速:FP16 路径用 SSE4.1 + F16C 指令一次处理多个像素,翻转也在 SIMD 内完成(以 __AVX2__ 编译宏作为启用开关,因为支持 AVX2 的 CPU 均支持 F16C)。
  5. 可复现随机性:Philox 计数器随机数保证高并发下结果可复现。
  6. 数值验证:专门的矫正测试确保 FP32 与 FP16 路径的误差在可控范围内。

预处理层面的融合做完了,数据已经以最紧凑的格式躺在 TransferStation 的锁页内存里,等待 H2D 传输。接下来我们就要回到框架的核心——图编译与内存规划,看看 Tech-Renaissance 是如何用静态图和显存分区把 GPU 侧的效率也推到极致的。

发表回复

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

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