(15) MemoryPlan与显存分区:框架的灵魂设计

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

上一篇我们讲了 ComputationGraphGraphAtlas:同一份零形状的图拓扑,如何被多个训练/验证变体共享复用。那是一个非常重要的设计,但它还只是讨论“图长什么样”。

图要跑起来,终究要落到真实的显存上。张量放在哪里、占多少字节、生命周期有多长、不同变体之间地址能不能对齐——这些问题不解决,再好的图也只是一张画在纸上的拓扑。对 Tech-Renaissance 而言,显存管理不是运行时的一件“后勤事务”,而是整个框架的灵魂设计之一。

这一篇我们就来讲 MemoryPlan,以及它背后那套把显存按语义静态分区的设计思想。

一、显存管理:被低估的性能瓶颈

深度学习训练是一个高度内存密集的任务。一个现代卷积网络,不仅需要存储模型权重,还需要为每一层的中间特征图、反向梯度、优化器动量、BN 统计量、临时缓冲区、输入数据等预留大量显存。以 ResNet-50 为例,权重本身可能只有几十 MB,但训练时的特征图、梯度和优化器状态可以轻松占到几个 GB。

在主流框架里,这些显存通常由运行时的缓存分配器按需管理。PyTorch 的 CUDA Caching Allocator 就是一个典型代表:它预先向 CUDA 驱动申请一些大段内存,然后在自己的池子里把小块切给用户,避免每次 cudaMalloc 都走驱动。这个设计在通用性上做得很好,也支撑了 PyTorch 灵活多变的动态图。

但缓存分配器并不是免费的。它有几个结构性问题:

第一,分配延迟。 虽然比 cudaMalloc 快,但高频率的小张量申请/释放仍然需要走分配器逻辑,涉及锁竞争、空闲块查找、块合并等操作。当模型很深、算子很碎时,这部分开销会累积。

第二,内存碎片。 分配器按请求大小切分内存,长期运行后容易出现“小洞”——总空闲内存足够,但没有足够大的连续块满足一次新申请。PyTorch 用户应该对下面这种报错不陌生:”CUDA out of memory. Tried to allocate 2.00 GiB (GPU 0; 15.90 GiB total capacity; 11.20 GiB already allocated; 2.30 GiB free; 14.10 GiB reserved in total by PyTorch)。” 这正是碎片的典型症状:分配器保留的显存远大于实际使用量,空闲总量也大于请求量,却拿不出一块满足请求的连续空间。

第三,地址不稳定。 缓存分配器倾向于复用之前释放的块,但复用并不保证同一块地址。对 CUDA Graph 来说,这是一个致命问题:CUDA Graph 在捕获时会把所有输入输出指针按值记录下来,重放时要求这些地址必须完全相同。如果分配器给了不同的地址,图就会重放失败,甚至静默访问非法地址。

第四,无法做全局批量优化。 运行时分配器看到的是一次一次孤立的申请请求,它不知道“这些张量都是 BN 的 running mean”,也不知道“这些梯度应该一次性清零”。它只能逐个块地管理,因此无法像编译器那样做跨张量的批量操作。

这些问题的共同根源在于:显存是在运行时被“临时决定”怎么放的。那么,如果把这件事提前到编译期决定呢?

这就是静态显存规划的基本思想:在训练开始之前,根据模型结构、优化器类型、精度模式、输入分辨率,一次性算出每个张量应该放在哪里、占多少字节、生命周期如何。运行时只按这张“图纸”访问固定偏移,不再做动态分配。类似的静态规划思想在一些工业界框架和学术研究项目中都有探索,而 Tech-Renaissance 把它做成了一个非常彻底的设计。

二、Tech-Renaissance 的显存哲学:按语义静态分区

Tech-Renaissance 对显存的核心假设可以概括为一句话:运行期不做动态显存分配

这不是说我们在运行时完全不调用 CUDA API,而是说所有张量的逻辑位置都在编译期由 MemoryPlan 确定。运行时的显存池由 ArenaKeeper 一次性分配,之后所有 DTensor 只是这个池子里的一个 (offset, stride, shape) 描述符。每个算子要访问数据时,通过 ArenaKeeper::ptr_at(rank, offset) 把偏移解析成真实指针——这个解析是 O(1) 的,且没有锁。

基于这个前提,我们进一步做了一件事:把显存按语义分区。不是按张量的大小或生命周期简单堆放,而是按张量在训练流程中的语义角色,把它们组织到不同的 Region 里。

这种分区的价值在哪里呢?

想象一下,如果你把全模型的所有 BN 偏置梯度连续放在一起,那么你就可以用一个 kernel、一次遍历,把它们全部清零,而不用管这些梯度分别属于第几层、叫什么名字。如果你把 FP32 主权重和 FP16 计算权重按完全相同的顺序排布,那么你就可以用一次 RangeOp 完成全模型的精度转换。如果你把权重区和动量区一一对应,那么优化器更新就可以按 Region 批量执行,而不是逐参数循环。

这就是“按语义分区”的精髓:同语义的张量不仅在逻辑上同类,在物理内存上也同类。它让“无视张量边界的批量操作”成为可能。

三、Region:一张详细的显存地图

include/renaissance/core/types.h 中,我们定义了显存区域枚举 Region。它的数组大小由 NUM_REGIONS 给出,当前值为 69;其中实际命名的语义 Region 为 68 个(按人类阅读习惯编号 001–068),DEFAULT 是 001 的别名,最后一个槽位是数组边界哨兵。

这些 Region 按照固定顺序从低地址排到高地址,覆盖训练流程中几乎所有可能出现的张量类型:

// 简化示意,完整定义见 include/renaissance/core/types.h
enum class Region : uint8_t {
    // B 系列:BN 统计量(001–004)
    B_PREV_MEAN  = 0,
    B_PREV_VAR,
    B_NEXT_MEAN,
    B_NEXT_VAR,

    // W 系列:主模型权重(005–012)
    W_EQ_BIAS,        // 仅当 bn_folded=true 时启用
    W_EQ_SCALE,
    W_BN_BIAS,
    W_BN_WEIGHT,
    W_FC_BIAS,
    W_FC_WEIGHT,
    W_FIRST_CONV,
    W_DEEP_CONV,

    // E 系列:EMA 权重(013–021)
    E_BN_BIAS, E_BN_WEIGHT, E_FC_BIAS, E_FC_WEIGHT,
    E_FIRST_CONV, E_DEEP_CONV,
    E_FC_WEIGHT_FP16, E_FIRST_CONV_FP16, E_DEEP_CONV_FP16,

    // A 系列:AMP FP16 计算权重(022–024)
    A_FC_WEIGHT, A_FIRST_CONV, A_DEEP_CONV,

    // G 系列:梯度(025–030 为 FP32,032–034 为 FP16)
    G_BN_BIAS, G_BN_WEIGHT, G_FC_BIAS, G_FC_WEIGHT,
    G_FIRST_CONV, G_DEEP_CONV,

    R_RESULT,                  // 031:loss/top1/top5 结果区

    G_FC_WEIGHT_FP16,
    G_FIRST_CONV_FP16,
    G_DEEP_CONV_FP16,

    // M/V/N 系列:一阶/二阶动量、LARS 范数(035–049)
    M_BN_BIAS, ..., M_DEEP_CONV,
    V_BN_BIAS, ..., V_DEEP_CONV,
    N_FC_WEIGHT, N_FIRST_CONV, N_DEEP_CONV,

    // I 系列:A/B 双缓冲输入(050–053)
    I_A_LABEL, I_A_DATA, I_B_LABEL, I_B_DATA,

    // F 系列:特征图与梯度槽(054–057)
    F_FEATURE_FP32, F_GRAD_SLOT_FP32,
    F_FEATURE_FP16, F_GRAD_SLOT_FP16,

    // S 系列:标量与掩码(058–062)
    S_SCALAR_FP32, S_SCALAR_FP16, S_SCALAR_INT32, S_SCALAR_INT8, S_MASK,

    // T 系列:临时张量(063–066)
    T_TEMP_FP32, T_TEMP_FP16, T_TEMP_INT32, T_TEMP_INT8,

    // R 系列:结果区(031, 067, 068)
    R_PREDICTED_LABEL,        // 067
    R_RESULT_ACCUMULATED,     // 068

    DEFAULT = B_PREV_MEAN,
    NUM_REGIONS = 69
};

在上面的代码里,注释中的“001、002……”是为了方便人类阅读,真正的枚举值从 0 开始。为什么要分得这么细?因为分得细,才能精确控制初始化、对齐、批量操作和跨变体一致性:

  • W_BN_WEIGHTW_BN_BIAS 分开,是因为 BN 的缩放参数通常初始化为 1.0,偏置初始化为 0.0;
  • G_FIRST_CONVG_DEEP_CONV 分开,是因为首层卷积的输入通道数通常很少(如 RGB 的 3 通道),而深层卷积通道数很大,两者在 AMP padding 和通信策略上需要区别对待;
  • G_BN_BIASG_FIRST_CONV 连续排列,构成梯度通信的“桶 2”;G_DEEP_CONV 单独作为“桶 1”,直接对应了 NCCL AllReduce 的两桶分法。

这些 Region 不是可选的装饰,而是编译器和运行时共同遵守的规则。每个 DTensor 创建时都必须声明自己属于哪个 Region,不能乱放。

四、DTensor:不持有内存的描述符

在 Tech-Renaissance 中,参与计算的基本单位不是传统意义上的 Tensor,而是 DTensor,即 DistributedTensorDTensor 的设计非常关键:它只存形状、偏移、stride、数据类型和 Region,不持有任何实际内存

这个设计在 include/renaissance/tensor/distributed_tensor.h 的注释里写得很清楚:

DTensor 是一个纯虚拟概念:只存形状/偏移量/stride,不持有内存、不存指针。同一个 DTensor 指代所有卡上相同的物理内存区域,布局完全相同,但数据可以有别。

struct DistributedTensor {
    int32_t  id      = -1;
    Shape    shape;                   // [N, H, W, C] 逻辑维度
    DType    dtype   = DType::FP32;
    Region   region  = Region::DEFAULT;
    // ... stride 缓存 ...
};

DTensoroffset_MemoryPlan::finalize() 之前是 -1,finalize 之后才写入真实的字节偏移。所有卡上的同一个 DTensor 拥有完全相同的 offset,只是数据内容可以不同——这正是“一张图纸,八卡共享”的含义。

DTensor 还有一个核心设计是 slot_bytes_:它表示这个张量在物理内存中占据的槽位大小,不一定是 nbytes()slot_bytes_ 综合考虑了通道 padding、末尾预留的 16 字节、以及 256 字节对齐。计算规则如下:

uint64_t DistributedTensor::compute_slot_bytes(
    const Shape& shape, DType dtype, Region region) noexcept
{
    // 1. 根据 dtype + region 决定 C 通道对齐因子
    uint8_t alignment = 1;
    if (dtype == DType::FP16) {
        if (region == Region::I_A_DATA || region == Region::I_B_DATA)
            alignment = 4;                         // 输入缓冲区 AMP 对齐到 4
        else if (region == Region::F_FEATURE_FP16 ||
                 region == Region::F_GRAD_SLOT_FP16)
            alignment = 8;                         // 特征图 AMP 对齐到 8(Tensor Core)
    } else if (dtype == DType::INT8 && region == Region::S_MASK) {
        alignment = 8;                             // 掩码 INT8 对齐到 8
    }

    int64_t padded_c = align_up(shape.c(), alignment);
    uint64_t elems = shape.n() * shape.h() * shape.w() * padded_c;

    // 2. 计算槽位
    if (dtype == DType::FP16) {
        return align_up_256(elems * 2 + 16);
    } else if (dtype == DType::INT8) {
        return align_up_256(elems * 1 + 16);
    } else {
        // FP32 / INT32:槽位 = 2 ×(FP16 等效槽位)
        return 2 * align_up_256(elems * 2 + 16);
    }
}

在这段伪代码中,shape 是逻辑维度(未 padding 的 NHWC),dtype 是数据类型,region 决定对齐需求。注意几个关键细节:

  • FP32 槽位是 FP16 等效槽位的两倍。这不是浪费,而是有深意的——由于 offset 和 slot 都保持严格的倍率关系,FP32 与 FP16 之间的整区类型转换可以用一个 RangeOp 完成,不需要逐层处理。这是框架速度快的一个根本原因。
  • 末尾预留 16 字节 是为了满足 XNNPACK 在 CPU 路径上的对齐要求。
  • 外部 align_up_256 保证了所有张量首地址 256 字节对齐,这是 CUDA 全局内存访问的最佳实践。
  • C 通道对齐 主要对 FP16 输入缓冲(4)、FP16 特征图/梯度槽(8)生效;S_MASK 区域的 INT8 张量在 CUDA 路径下也对齐到 8。其余情形(尤其是 FP32 与 CPU 路径)保持紧凑。

这些约束单独看有些“奇怪”,但组合在一起就形成了一套严格而一致的内存契约。

五、MemoryPlan:一遍线性累加布局引擎

MemoryPlan 是显存布局的唯一权威,定义在 include/renaissance/graph/memory_plan.h。它的核心算法非常简单:按 Region 顺序从 B_PREV_MEAN 走到最后一个槽位,在每个 Region 内部按分配顺序把 DTensor 的 slot 累加起来,finalize() 时一次性写入所有 offset。

void MemoryPlan::finalize() {
    TR_CHECK(!finalized_, ValueError, "MemoryPlan already finalized");
    validate_config();

    uint64_t cursor = 0;

    // 线性遍历所有 Region 槽位
    for (size_t ri = 0;
         ri < static_cast<size_t>(Region::NUM_REGIONS);
         ++ri) {
        auto& info = region_infos_[ri];
        info.base_offset = cursor;

        for (int32_t dt_id : region_dt_ids_[ri]) {
            auto& entry = entries_[id_to_idx_.at(dt_id)];
            entry.dt.offset_ = cursor;
            cursor += entry.dt.slot_bytes();
        }

        info.total_bytes = cursor - info.base_offset;
    }

    total_bytes_ = cursor;

    validate_region_order();
    validate_contiguity();
    validate_layer_correspondence();
    validate_alignment();

    finalized_ = true;
}

逻辑很简单:维护一个 cursor 游标,从 0 开始依次走过每个 Region。每个 Region 内部,按分配顺序排列该 Region 中的所有 DTensor。每个 DTensoroffset_ 被赋值为当前的 cursor,然后 cursor 前移 slot_bytes() 个字节。走完所有 Region 后,total_bytes_ 就是总显存需求。

这个算法之所以成立,是因为所有分配都在 finalize() 之前完成。在 PLANNING 阶段,ArchPlan 和 Compiler 会遍历所有需要分配的张量,通过 MemoryPlan 的语义化分配接口注册到对应的 Region 中。finalize() 一旦调用,布局就永久锁定,之后不能再分配任何新张量。

MemoryPlan 提供了语义化的分配接口,比如 alloc_bn_statsalloc_fc_weightalloc_momentum_first_conv 等。这些接口内部硬编码了对应的 Region,杜绝了“把 BN 偏置放到权重区”这种错误:

DTensor MemoryPlan::alloc_fc_weight(const Shape& shape) {
    return alloc_impl(shape, DType::FP32, Region::W_FC_WEIGHT);
}

DTensor MemoryPlan::alloc_momentum_first_conv(const Shape& shape) {
    return alloc_impl(shape, DType::FP32, Region::M_FIRST_CONV);
}

除了针对模型参数的语义接口,MemoryPlan 还提供了 alloc_baseline_dtensors(),一次性分配所有训练基础设施张量:输入双缓冲 I_A_LABEL/I_A_DATA/I_B_LABEL/I_B_DATA、SoftmaxCE 专用的临时标签区、学习率/loss/top1/top5/NaN 标志等标量、以及各优化器需要的 beta/beta2/weight decay 等。这些张量拥有固定的 BaselineIds,运行时可以按 ID 直接索引。

六、哪些 Region 会真正被启用?

并不是所有 68 个语义 Region 在每次训练里都会启用。MemoryPlan 通过 is_condition_enabled() 方法,根据 PlanConfigGlobalRegistry 中的全局配置决定某个 Region 是否允许分配:

Region启用条件
W_EQ_BIASW_EQ_SCALEPlanConfig::bn_folded == true(CBR 融合将 BN 折叠进卷积后产生的等价参数)
E_BN_BIASE_DEEP_CONV(FP32 EMA)PlanConfig::has_ema == true
E_FC_WEIGHT_FP16E_DEEP_CONV_FP16has_ema == true 且 AMP 开启
A_FC_WEIGHTA_DEEP_CONVAMP 开启
G_FC_WEIGHT_FP16G_DEEP_CONV_FP16AMP 开启
F_FEATURE_FP32F_GRAD_SLOT_FP32AMP 关闭
F_FEATURE_FP16F_GRAD_SLOT_FP16AMP 开启
S_SCALAR_FP16AMP 开启
M_ / V_ / N_ 系列通常始终启用,但 validate_layer_correspondence 会根据实际优化器类型检查数量是否匹配
I_ / S_MASK / T_ 系列始终启用

这套条件机制让 MemoryPlan 既能覆盖 SGD、AdamW、LARS 等不同优化器,也能在 FP32 与 AMP 模式之间切换,而不需要为每种组合写独立的布局代码。被关闭的 Region 在 finalize() 时会自然变成空区(total_bytes == 0),后续 RangeOp 也会通过 is_region_populated() 跳过它们。

七、跨变体一致性:为什么同一张图能服务多个分辨率

Compiler::compile() 的第三阶段 create_memory_plans 中,我们会为 6 个变体(train_basetrain_lasttrain_lowrestrain_lowres_lastval_baseval_last)分别创建独立的 MemoryPlan。这些变体可能有不同的 batch size 或分辨率,因此同一个逻辑张量在不同变体中的形状可能不同。

但这里有一个关键要求:同一个 DTensor ID 在不同变体中必须有相同的 offset。否则,零形状的 ComputationGraph 就无法复用——图节点里只存了 DTensor ID,如果 ID 相同但 offset 不同,图的重放就会出错。

为了解决这个问题,Compiler 在第二阶段 compute_max_slot_bytes 中会对每个 (layer, tensor) 位置跨所有变体取最大的 slot_bytes。第三阶段分配时,把这个最大值传给 MemoryPlan 的私有重载:

DTensor MemoryPlan::alloc(const Shape& shape, DType dtype,
                          Region region, uint64_t slot_bytes);
void Compiler::compute_max_slot_bytes(
    const std::vector<std::vector<std::vector<TensorDesc>>>& all_shapes,
    std::vector<std::vector<uint64_t>>& max_slots)
{
    size_t num_layers = all_shapes[0].size();
    max_slots.resize(num_layers);

    for (size_t l = 0; l < num_layers; ++l) {
        size_t num_tensors = all_shapes[0][l].size();
        max_slots[l].resize(num_tensors, 0);

        for (size_t t = 0; t < num_tensors; ++t) {
            uint64_t max_bytes = 0;
            for (size_t s = 0; s < all_shapes.size(); ++s) {
                const auto& desc = all_shapes[s][l][t];
                max_bytes = std::max(max_bytes,
                    DTensor::compute_slot_bytes(desc.shape, desc.dtype, desc.region));
            }
            max_slots[l][t] = max_bytes;
        }
    }
}

这样,即使某个变体的实际 shape 比较小,它仍然占据最大的槽位,从而保证所有变体的 offset 完全一致。这个设计的代价是少量内存浪费——小变体用了大变体的槽位——但换来的是图拓扑的完全共享,以及 CUDA Graph 捕获的稳定性。对于训练框架来说,这是一笔非常划算的买卖。

八、内存复用:生命周期管理的艺术

动态分配器需要垃圾回收或引用计数来回收不再使用的内存。MemoryPlan 不需要,因为它在编译期就知道了每个张量的完整生命周期。不过,它仍然可以做一种编译期的、安全的内存复用:特征图区和梯度槽区的复用

在训练过程中,前向传播产生的中间特征图需要在反向传播时使用,因此前向特征图不能立即释放。MemoryPlan 通过 F_FEATURE_FP32/F_FEATURE_FP16 区域存储前向特征图,通过 F_GRAD_SLOT_FP32/F_GRAD_SLOT_FP16 区域存储反向传播的梯度中间结果。

关键在于,这些临时存储是固定大小的槽位,而不是动态分配的堆MemoryPlan::alloc_grad_slot() 最多支持 4 个梯度槽(slot_idx 取 0–3)。如果请求的槽位已经被同形状、同类型的张量占用,就直接返回已有的 DTensor

DTensor MemoryPlan::alloc_grad_slot(const Shape& shape, DType dtype, int slot_idx) {
    TR_CHECK(slot_idx >= 0 && slot_idx < 4, IndexError,
             "slot_idx=" << slot_idx);

    int32_t existing = grad_slot_ids_[slot_idx];
    if (existing >= 0) {
        const auto& dt = get_dtensor(existing);
        TR_CHECK(dt.dtype == dtype, ValueError, "dtype mismatch");
        TR_CHECK(dt.shape == shape, ShapeError, "shape mismatch");
        return dt;   // 复用已有槽位
    }

    Region region = (dtype == DType::FP16)
                        ? Region::F_GRAD_SLOT_FP16
                        : Region::F_GRAD_SLOT_FP32;
    DTensor dt = alloc_impl(shape, dtype, region);
    grad_slot_ids_[slot_idx] = dt.id;
    return dt;
}

这种设计让 MemoryPlan 既能保证显存不超限,又不需要运行时的垃圾回收。它不需要追踪“这个张量是否还在使用”,因为编译期已经把所有生命周期的重叠关系都分析清楚了。

九、RangeOp:无视张量边界的批量操作

静态分区带来的最大红利之一,是可以做“Region 级批量操作”,也就是 RangeOp。在 ComputationGraph 中,节点可以是 COMPUTE(单个 DTensor 级),也可以是 RANGE(连续内存范围级)。RangeOp 不操作单个张量,而操作一段连续的内存范围,范围由 (start_region, end_region) 决定。

RangeOp 的定义在 include/renaissance/graph/op_kind.h 中,主要分为以下几类:

  • H2D 传输RANGE_H2D_COPY_ARANGE_H2D_COPY_BRANGE_H2D_COPY_DTENSOR
  • BN 统计量通信RANGE_BN_STATS_ALLREDUCE
  • 优化器 Bias 块RANGE_UPDATE_BIAS_SGDRANGE_UPDATE_BIAS_MOMENTUMRANGE_UPDATE_BIAS_NESTEROVRANGE_UPDATE_BIAS_ADAM
  • 优化器 Weight 块RANGE_UPDATE_WEIGHT_SGDRANGE_UPDATE_WEIGHT_MOMENTUMRANGE_UPDATE_WEIGHT_NESTEROVRANGE_UPDATE_WEIGHT_ADAMRANGE_UPDATE_WEIGHT_ADAMW
  • EMA 维护RANGE_EMA_PARAM_UPDATERANGE_SEMA_SWITCH
  • 通用内存操作RANGE_CLEARRANGE_D2D_COPY
  • 类型转换RANGE_CAST_FP32_TO_FP16RANGE_CAST_FP16_TO_FP32
  • 通信RANGE_SUM_ALLREDUCERANGE_MEAN_ALLREDUCE
  • NaN 检查与指标RANGE_CHECK_NANRANGE_GRAD_SCALINGRANGE_ACCUM_METRICS

例如,训练前的梯度清零可以一次性覆盖整个梯度区:

GraphNode zg_node;
zg_node.kind = GraphNode::Kind::RANGE;
zg_node.range_op = RangeOp::RANGE_CLEAR;
zg_node.output_ranges.push_back(
    memory_plan.region_range(Region::G_BN_BIAS,
                             Region::G_DEEP_CONV_FP16));
train_cg.append(GraphId::ZERO_GRAD, zg_node);

再比如优化器更新。传统框架里,优化器需要遍历每个参数,逐个调用 kernel 更新权重和动量。Tech-Renaissance 把同类型的权重、梯度、动量连续存放,于是权重更新可以用一个 RangeOp 一次性完成整区更新。以 AdamW 为例,Compiler 会构造一个覆盖 W_FC_WEIGHTW_DEEP_CONV、对应 G_/M_/V_ 范围的 RANGE 节点,在一个 kernel 里完成全模型可训练权重的更新。

这些 RangeOp 的共同特点是:它们不感知张量边界,只按 Region 范围操作。由于同 Region 的张量在物理上连续,一个 kernel 就能完成全模型同类型张量的处理,省去了大量小 kernel 的 launch 开销。所有范围在编译期写成 Region ID,在 CUDA Graph 捕获时通过 resolve_region_bounds() 解析为 (offset, size),运行时零查表开销。

十、布局锁死后的四组校验

MemoryPlan::finalize() 在写入所有 offset 之后会执行四组校验。它们不是防御性编程的点缀,而是静态图正确性的基石:

1. Region 顺序校验(validate_region_order:确保各 Region 的 base_offset 按枚举顺序严格非递减。如果代码错误地把某个 Region 的分配顺序提前或延后,这里会立即报错。

2. 连续性校验(validate_contiguity:确保需要被同一个 RangeOp 覆盖的 Region 在物理上真正相邻。例如 BN 统计量 B_PREV_MEANB_NEXT_VAR 必须连续;梯度桶 2 G_BN_BIASG_FIRST_CONV 必须连续;输入缓冲区 I_A_LABELI_B_DATA 必须连续;EMA FP16 权重 E_FC_WEIGHT_FP16E_DEEP_CONV_FP16 必须连续。

3. 层对应关系校验(validate_layer_correspondence:确保权重、梯度、一阶动量、二阶动量这几个系列中,相同层类型的张量数量一致。例如如果 W_FC_WEIGHT + W_FIRST_CONV + W_DEEP_CONV 共有 w_fp32 个张量,那么对应的 FP32 梯度、AMP FP16 梯度(如果开启 AMP)、一阶/二阶动量也必须各有相同数量。LARS 模式下还会额外检查 N_ 系列与 W_ 系列的数量匹配。

4. 对齐校验(validate_alignment:确保每个 DTensor 的 offset 都是 256 的整数倍。

这些校验把“张量放错区”“数量对不上”“地址没对齐”等错误挡在了训练开始之前。在静态图的世界里,编译期的错误比运行期的错误好排查一万倍

十一、MemoryPlan 与 CUDA Graph、分布式、确定性训练

静态显存规划不是孤立存在的,它是 Tech-Renaissance 多项核心能力的共同底座。

CUDA Graph 全捕获。 CUDA Graph 要求捕获期间所有张量地址固定,且捕获后不能动态分配。MemoryPlan 在编译期就锁定了所有地址,ArenaKeeper 在初始化时只分配一次显存池,运行时只通过固定偏移访问。因此 Tech-Renaissance 可以把 H2D 传输、前向、反向、梯度通信、优化器更新、BN 统计量同步等全部阶段都捕获成 CUDA Graph,而不需要像 PyTorch torch.compile 那样反复处理 graph break 和地址稳定性问题。

分布式训练。 在多卡数据并行中,每张卡都持有完整的模型副本,同一份 MemoryPlan 可以在所有 rank 上共享。因为每个 DTensor 的 offset 在所有 rank 上一致,梯度 AllReduce 和 BN 统计量同步可以直接按 Region 范围发起,不需要逐张量协商地址。

确定性训练。 当显存布局、张量地址、算子执行顺序都在编译期固定后,训练结果的可复现性就大大提高了。配合 Philox 计数器随机数、确定性计算引擎选择、静态调度,Tech-Renaissance 能够在相同硬件和种子下得到高度一致的结果。

这些能力都不是靠单点优化实现的,而是靠“编译期确定一切”这个架构选择串联起来的。MemoryPlan 就是这个选择的物理落点。

十二、与主流框架的对比

说到这里,可能有人会问:PyTorch 不是也能做 CUDA Graph 吗?TensorFlow/JAX 的 XLA 不是也会做内存规划吗?

是的,但它们的路径不同。

PyTorch 的 CUDA Caching Allocator 本质上是一个运行时池化分配器,它追求的是“在动态图的前提下尽量减少分配开销”。它通过各种启发式策略来缓解碎片和延迟,但无法从根本上消除动态性。这也是为什么 PyTorch 在配合 CUDA Graph 时需要额外的机制(如 private pool、CUDA Graph Trees)来保证地址稳定,而这些机制在某些复杂场景下仍可能失效。

TensorFlow/XLA 和 JAX/XLA 确实会做静态内存规划,因为 XLA 本身就是静态编译器。但 XLA 的内存规划更多是“在已经生成的 HLO 图上做张量生命周期分析和内存复用”,它的分区语义是编译器内部推导出来的,不像 Tech-Renaissance 这样把 Region 作为框架一级的显式抽象暴露给优化器、通信、初始化等模块。

Tech-Renaissance 的做法介于两者之间,又有所不同:我们选择静态图作为框架的根本范式,然后把显存按语义显式分区,让 Region 成为连接编译器、运行时、算子、优化器、通信模块的共同语言。这不是说其他框架做不到某些功能,而是说我们把这些功能建立在一套统一的内存契约之上,从而让整个系统更简单、更可预测、更易于做全局批量优化。

十三、小结

MemoryPlan 和显存分区,是 Tech-Renaissance 框架设计中最核心的基础设施之一。

它的基本思想是:显存不应该按需动态分配,而应该按语义静态规划。通过把同类型的张量(权重、梯度、动量、BN 统计量、输入缓冲、特征图等)连续排布在不同的 Region 里,我们实现了:

  • 运行期零动态分配,消除分配延迟和碎片;
  • 所有张量地址在编译期确定,满足 CUDA Graph 全捕获的要求;
  • DTensor 作为纯描述符,让一张 MemoryPlan 可以在多卡、多变体之间共享;
  • 同 Region 批量操作,把原本逐层逐参数的多个 kernel 合并成一个;
  • 跨变体 offset 一致,让零形状计算图可以真正共享;
  • 多卡布局一致,让分布式训练可以按 Region 直接通信;
  • 严格的布局校验,把错误挡在训练开始之前。

这一套设计的代价是牺牲了一些运行时的灵活性:你不能在训练中途动态改变模型结构,不能随心所欲地插入新张量,调试时也得更像编译 C++ 程序那样“先编译、再运行”。但对于一个以训练吞吐为目标的生产级框架来说,这是值得接受的 trade-off。

下一篇,我们会从 MemoryPlan 进一步下沉,专门讲 DTensor:这个“不持有内存”的描述符,如何成为分布式训练中“一张图纸,八卡共享”的关键抽象。

发表回复

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

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