(13) ArchPlan与编译管线:从模型定义到可执行图

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

上一篇我们讲了 BluePrint DSL:用户可以用 seqcbrblockgap_fc 这些工厂函数,用十几行代码搭出 ResNet-50 或者 MobileNet 这样的复杂网络。但 BluePrint 本质上只是一棵描述”模型长什么样”的树,它距离真正能在 GPU 上高效执行的代码还很远。

把高层模型定义转换成可执行图,这正是编译管线要解决的问题。在 Tech-Renaissance 里,这条管线由两个核心组件承载:ArchPlan 负责把 BluePrint 展开、规范化、融合成标准化的架构描述;Compiler 负责在这个架构上做形状推导、显存规划、计算图构建和变体去重。本文就沿着这条链路,看看一张图到底是怎么从”声明式语法”一步步变成”可执行计划”的。

一、深度学习框架为什么需要编译

很多人在第一次接触深度学习框架时会有个错觉:框架不就是调用 cuDNN、cuBLAS 这些库吗?我写 torch.nn.Conv2d,框架帮我调 cudnnConvolutionForward,这算什么编译?

如果只是跑一个孤立算子,确实谈不上编译。但现代神经网络往往包含几十到几百个算子,这些算子之间有数据依赖、有中间张量、有反向传播、有权重更新。框架如果只做一个”算子一个算子地调用”的调度器,就会面临几个问题:

第一,中间张量反复读写显存。 比如 Conv → BN → ReLU 三个算子如果独立执行,Conv 的输出要先写到显存,BN 读出来再写回去,ReLU 再读一次。如果能把它们合并成一个 kernel,中间结果就可以留在寄存器或共享内存里,显存带宽直接省掉一大半。

第二,全局内存布局无法预先规划。 动态图框架通常由运行时分配器按需申请显存,这会带来分配延迟、内存碎片,以及地址不稳定。而静态编译可以在运行前就算出每个张量应该放在哪里、什么时候可以复用别人的位置。

第三,kernel launch 和 Python 派发开销。 每个算子都走一遍 dispatcher、生成一次 CUDA kernel、做一次 host-device 同步,小算子密集时 CPU 调度本身就会成为瓶颈。把一整段计算捕获成 CUDA Graph 重放,是消除这类开销的有效手段。

所以,编译是框架从”能跑”走向”跑得快”的关键一步。它不一定需要生成汇编或 LLVM IR,而是要在运行前把算子、张量、执行顺序、内存布局、调度策略都确定下来。打个比方:用户给的是建筑草图,ArchPlan 做结构分析与规范化,Compiler 画施工图并标出每根钢筋、每块混凝土的位置,ComputationGraph 加 CUDA Graph 捕获才是最终交付的可执行建筑。

二、主流框架的编译方案

不同框架处理这个问题的思路差异很大。

PyTorch Eager 是典型的”边执行边建图”模式,每个算子独立调度。它的优势是灵活、调试方便,但代价是上面提到的三点 overhead。为了弥补性能,PyTorch 2.0 引入了 torch.compile:TorchDynamo 捕获 FX 图,AOTAutograd 处理前向和反向,Inductor 做算子融合与 kernel 生成,并尝试 CUDA Graph 捕获。这是一个在动态图之上叠加 JIT 编译器的方案,但 graph break、动态形状、地址稳定性等问题也让它时不时回退到 eager。

TensorFlow 从 1.x 时代就走静态图路线,tf.Graph + tf.Session 的模式把整个计算图交给框架编译。TensorFlow 2.x 默认 eager,但 @tf.function 可以把 Python 函数 trace 成静态图交给 XLA 优化。XLA 是一个专门面向线性代数的编译器,能做算子融合、布局优化、内存复用等全局优化。

JAX 的设计哲学是”Python 写动态逻辑,jit 编译成静态 XLA 图”。jax.jit 会用 XLA 把函数编译成针对特定设备的可执行程序,适合需要高阶导数、函数变换的前沿研究。

ONNX Runtime 则是一种部署侧编译思路:把模型导出成 ONNX 中间表示,再由运行时做图优化、算子选择、内存规划。它的优化空间取决于 ONNX 图本身的信息完整度。

可以看到,不管是静态框架还是动态框架,最终都在往”图级编译”这个方向靠拢。差别只在于:静态框架是”先定义再编译”,动态框架是”先运行再捕获”。Tech-Renaissance 属于前者,并且把编译这件事做得很重。

这里有一个值得专门提一下的概念:中间表示(Intermediate Representation,IR)。编译器通常不会直接把源代码翻译成机器码,而是先翻译成一种介于源语言和目标语言之间的表示。IR 的好处是既能保留高层语义,又便于做各种分析和变换。在 Tech-Renaissance 里,ArchPlan 就是一种高层 IR:它保留了层的概念、参数、连接关系,但已经去掉了 BluePrint 中那些为了用户友好而引入的语法糖。Compiler 在这个 IR 上做形状推导、算子展开、内存分配,最终生成低层的 ComputationGraph。没有 IR 这个中间层,前端 DSL 的每一次语法变化都可能要求后端重新适配;有了 IR,前端和后端就可以各自独立演化。

三、Tech-Renaissance 的编译全景

在 Tech-Renaissance 中,一个模型从用户代码到可执行图的完整旅程是:

BluePrint (Layer 树)
    ↓ ArchPlan::from_blueprint
ArchPlan (ArchLayer 序列)
    ↓ ArchPlan::build
标准化 + 融合的 ArchPlan
    ↓ Compiler::compile
MemoryPlan[6] + ComputationGraph ×2
    ↓ GraphAtlas::build
GraphAtlas (变体 × 子图映射)
    ↓ CapturedGraph::capture
CUDA Graph / CPU fallback

这个流程有几个关键设计:

  1. BluePrint 只负责描述模型拓扑,不关心设备、形状、精度。
  2. ArchPlan 是编译中间表示(IR),把树形 DSL 展开成线性层序列,并做规范化与融合。
  3. Compiler 五阶段把 ArchPlan 转换成显存布局(MemoryPlan)和零形状计算图(ComputationGraph)。
  4. ComputationGraph 与形状解耦,同一份图可以服务多个 shape-only 变体。
  5. 最终由 GraphAtlas 管理变体与子图的映射,CapturedGraph 完成 CUDA Graph 捕获。

下面两节分别展开 ArchPlan 和 Compiler。

四、ArchPlan:十步管线的中间表示

ArchPlan 是 BluePrint 之后的第一站。它的任务是把用户友好的 DSL 树,转换成后端能够理解的、规范化过的层序列。类的注释把它说得很清楚:

// include/renaissance/graph/arch_plan.h:4-5
/**
 * @brief 架构规划:层描述、编译管线和序列化
 * @details 从 BluePrint 接收模型定义,经 10 步管线生成标准化、全融合的架构描述
 */

注意这里说是 10 步,但公开 API 只暴露了 step2 到 step10。step1 对应的是 from_blueprint 里调用的 expand_tree,不算在 build() 内部。我们按实际执行顺序来看。

4.1 Step 1:树展开

ArchPlan::from_blueprint() 接收 BluePrintInputSpec,递归遍历 Layer 树:

// src/graph/arch_plan_expand.cpp:470-489
ArchPlan ArchPlan::from_blueprint(const BluePrint& bp, const InputSpec& input, bool fuse) {
    if (fuse && !GlobalRegistry::instance().using_amp()) {
        TR_VALUE_ERROR(
            "ArchPlan::from_blueprint: operator fusion (fuse=true) requires AMP mode."
        );
    }

    ArchPlan arch;
    arch.input_ = Shape(1, input.h, input.w, input.c);
    arch.fuse_ = fuse;

    int current_c = input.c;
    expand_tree(bp.root_, arch.layers_, current_c, fuse);
    return arch;
}

expand_tree 会把 SequentialRepeatAdd2Block 这些高层节点全部展开成 ArchLayer。例如一个 ResNet Bottleneck 块:

block(64, 256, RESNET_1_3_1)

如果 fuse=true,它会直接生成一个 LayerKind::BottleneckIdentityBottleneckProjection 的 ArchLayer;如果 fuse=false,它会展开成 Add2Start、shortcut、Add2ShortcutEnd、主干 Conv-BN-ReLU、主干 Conv-BN-ReLU、主干 Conv-BNAdd2EndReLU 等原始序列。

CBR 工厂函数在 fuse=true 时也会保持为单个 LayerKind::CBR,而不是拆成 Conv+BN+ReLU。这保证了后端融合算子不会被编译器前端破坏。

4.2 Step 2:BN 重命名

// src/graph/arch_plan_normalize.cpp:53-73
void ArchPlan::step2_rename_bn() {
    for (size_t i = 0; i < layers_.size(); ++i) {
        if (layers_[i].kind != LayerKind::Bn2d) continue;
        LayerKind prev = LayerKind::Conv;
        for (int j = static_cast<int>(i) - 1; j >= 0; --j) {
            auto k = layers_[j].kind;
            if (k == LayerKind::ReLU || k == LayerKind::Tanh || k == LayerKind::Identity
                || k == LayerKind::ChannelPadding) continue;
            if (k == LayerKind::Add2ShortcutEnd || k == LayerKind::Add2Start) {
                prev = LayerKind::Conv; break;
            }
            prev = k; break;
        }
        bool is_1d = (prev == LayerKind::FC || prev == LayerKind::Flatten || prev == LayerKind::GAP);
        layers_[i].kind = is_1d ? LayerKind::Bn1d : LayerKind::Bn2d;
    }
}

这一步向后看 BN 的前驱算子,跳过 ReLU、Tanh、Identity 以及 ChannelPadding 等不改变张量语义的层;如果遇到 Add2StartAdd2ShortcutEnd,则按卷积场景处理。如果 BN 前面是 FC、Flatten 或 GAP,就标记为 Bn1d;否则保持 Bn2d。这样后端可以为 1D 和 2D 场景选择不同的 kernel。

4.3 Step 3:SoftmaxCE 规范化

SoftmaxCE 在 BluePrint 里可以出现在任意位置,但 Tech-Renaissance 强制把它放到最后,并统一参数:

// src/graph/arch_plan_normalize.cpp:75-80
void ArchPlan::step3_normalize_softmax_ce(int num_classes) {
    layers_.erase(std::remove_if(layers_.begin(), layers_.end(),
        [](const ArchLayer& l) { return l.kind == LayerKind::SoftmaxCE; }),
        layers_.end());
    layers_.push_back({LayerKind::SoftmaxCE, SoftmaxCELayerParams{num_classes}, "softmax_ce"});
}

这样编译器就知道损失函数一定在链尾,不需要处理分支或多输出 loss。

4.4 Step 4:Identity 规范化

add2 在 BluePrint 中表示残差连接。展开后会产生 Add2StartAdd2ShortcutEndAdd2End 三个标记层。这一步确保 shortcut 分支至少有一个 Identity 占位:

// src/graph/arch_plan_normalize.cpp:82-105
void ArchPlan::step4_normalize_identity() {
    layers_.erase(std::remove_if(..., Identity), layers_.end());
    for (size_t i = 0; i < layers_.size(); ++i) {
        if (layers_[i].kind != LayerKind::Add2Start) continue;
        size_t sc_end = find_add2_boundary(i + 1, LayerKind::Add2ShortcutEnd);
        if (sc_end == i + 1) {
            layers_.insert(layers_.begin() + sc_end,
                {LayerKind::Identity, EmptyParams{}, "identity"});
        }
        // ...
    }
}

4.5 Step 5:Flatten 规范化

Flatten 在 BluePrint 里是可选的。编译器会根据实际需要决定是否插入:如果 FC 前面已经是 (1,1,C)C % 8 == 0,就不需要 Flatten;否则自动在 FC 或 SoftmaxCE 前插入 Flatten。

// src/graph/arch_plan_normalize.cpp:149-199
void ArchPlan::step5_normalize_flatten() {
    layers_.erase(std::remove_if(..., Flatten), layers_.end());
    // ... 根据 in_shape 判断是否需要插入 Flatten
    if (needs_flatten) {
        layers_.insert(layers_.begin() + insert_at,
            {LayerKind::Flatten, EmptyParams{}, "flatten"});
        recompute_shapes_from(insert_at);
    }
}

4.6 Step 6:形状推导

build() 里,形状推导先于 Flatten 规范化执行,因为 Flatten 是否插入本身需要知道形状:

// src/graph/arch_plan_normalize.cpp:35-51
void ArchPlan::build(int num_classes) {
    step2_rename_bn();
    step3_normalize_softmax_ce(num_classes);
    step4_normalize_identity();
    align_amp_input_channels_for_first_conv();  // AMP 首层 C 对齐到 4
    step6_deduce_shapes();      // 先推导实际 H/W/C
    step5_normalize_flatten();  // 再决定 Flatten 是否真正需要
    if (fuse_) {
        step7_merge_blocks();
        step8_merge_quadruple();
        step9_merge_triple();
        step10_merge_binary_and_mark();
    } else {
        mark_first_layer();
    }
}

step6_deduce_shapes() 就是按 NHWC 格式链式计算每层输出形状。卷积、池化、GAP、Flatten、Bottleneck、InvResidual 都有各自的推导公式。

4.7 Step 7:Block 合并

这是 ArchPlan 中最重要的融合步骤之一。编译器扫描 Add2Start ... Add2End 区间,尝试匹配三种经典残差模式:

  • Bottleneck:Conv-BN-ReLU-Conv-BN-ReLU-Conv(7 层主干;第 3 个 Conv 后通常仍跟有 BN,但模式识别只校验这 7 层)
  • BasicBlock:Conv-BN-ReLU-Conv-BN
  • InvResidual(MobileNetV2):Conv-BN-ReLU-Conv-BN-ReLU-Conv-BN
// src/graph/arch_plan_merge.cpp:162-204
void ArchPlan::step7_merge_blocks() {
    // ... 扫描 Add2 区间 ...
    if (try_merge_bottleneck(i, sc_end, add2_end, result)) { /* ... */ }
    if (try_merge_basic_block(i, sc_end, add2_end, result)) { /* ... */ }
    if (try_merge_inv_residual(i, sc_end, add2_end, result)) { /* ... */ }
}

匹配成功后,原本十几个 ArchLayer 会被压缩成一个 BottleneckProjection/IdentityBasicBlockProjection/IdentityInvResidualNoShortcut/Identity。这些融合层在后端有专门的 AMP kernel,可以一次性完成整个残差块的前向和反向。

4.8 Step 8-10:二元/三元融合

// src/graph/arch_plan_merge.cpp:268-300
void ArchPlan::step8_merge_quadruple() {
    // CBRP fusion removed
}

void ArchPlan::step9_merge_triple() {
    if (!GlobalRegistry::instance().using_amp()) return;
    merge_pattern_triple(LayerKind::Conv, LayerKind::Bn2d, LayerKind::ReLU,
                         LayerKind::CBR, build_cbr);
}

void ArchPlan::step10_merge_binary_and_mark() {
    merge_pattern_binary(LayerKind::GAP, LayerKind::FC,
        LayerKind::GapFC, build_gapfc);
    mark_first_layer();
}
  • step8:四元融合(Conv+BN+ReLU+MaxPool)当前已移除,保持独立层。
  • step9:在 AMP 模式下把未进入 Block 的 Conv+BN+ReLU 三元组融合成 CBR
  • step10:把 GAP+FC 融合成 GapFC,并标记首层。

最终输出的 ArchPlan 是一个规范化的、尽可能融合的层序列,每一层都带有输入/输出形状、是否首层标记、参数等完整信息。

4.9 从 BluePrint 到 ArchLayer:一个 Bottleneck 块的旅程

为了更直观地理解 ArchPlan 在做什么,我们来看一个 ResNet-50 中常见的 Bottleneck 块。用户在 BluePrint 里写:

seq(
    conv(64, 1, 1, 0), bn(), relu(),
    conv(64, 3, 1, 1), bn(), relu(),
    conv(256, 1, 1, 0), bn()
)

或者更简洁地:

block(64, 256, RESNET_1_3_1)

fuse=true 的 AMP 模式下,expand_tree 会直接把它映射成一个 ArchLayer,其 kindBottleneckIdentityBottleneckProjection,参数中记录 bottleneck 通道数、输出通道数、stride。

但如果用户写成未融合的原始序列,或者 fuse=falseexpand_tree 会把它展开成更长的序列:

Add2Start
Identity (或 conv+bn 下采样)
Add2ShortcutEnd
Conv + Bn2d + ReLU
Conv + Bn2d + ReLU
Conv + Bn2d
Add2End
ReLU

然后 step7_merge_blocks 会反向扫描这个区间,识别出”7 层主干 Conv-BN-ReLU-Conv-BN-ReLU-Conv + shortcut”的 Bottleneck 模式,再把它压缩回一个 BottleneckIdentityBottleneckProjection

这个”展开—再合并”看似绕路,其实很有必要。BluePrint 允许用户用各种方式表达同一个结构:既可以用 block(),也可以手写 cbr 序列,还可以混合 add2。ArchPlan 的任务就是把所有这些等价写法归一化到同一种后端友好的表示。后端只需要为 BottleneckIdentityBottleneckProjectionCBRGapFC 等少数几种融合算子实现高性能 kernel,而不需要处理用户可能写出的无穷多种排列组合。

4.10 YAML 序列化:让编译结果可检查

ArchPlan 还提供了 to_yaml()from_yaml(),可以把规范化后的架构导出成人类可读的 YAML 文件。这对于调试非常有用:你可以看到模型经过融合后到底长什么样,每一层的输入输出形状是什么,首层在哪里,哪些块被合并了。

这个功能也体现了 ArchPlan 作为”编译中间表示”的定位——它不仅是给 Compiler 消费的内部结构,也是一个可以被检查、被持久化、被版本控制的模型描述层。

五、Compiler:五阶段编译编排器

ArchPlan 完成后,真正的”图编译”才刚刚开始。Compiler::compile() 的注释把五个阶段写得很清楚:

// include/renaissance/graph/compiler.h:38-42
/**
 * Phase 1: derive_all_shapes      — 对所有 CompileSpec 调用 LayerDescriptor::infer_tensors
 * Phase 2: compute_max_slot_bytes — 逐 (layer, tensor) 跨变体取 max slot_bytes
 * Phase 3: create_memory_plans    — 用 max_slot_bytes 构造 6 个 MemoryPlan
 * Phase 4: build_computation_graph — 遍历 LayerDescriptor 序列构建 GraphNode 拓扑
 * Phase 5: share_or_clone         — shape-only 变体共享指针,graph-change 变体独立 new
 */

5.1 CompileSpec 与六变体

在进入 Compiler 之前,先理解 CompileSpec。它描述单个编译变体的形状相关参数:

// include/renaissance/graph/compile_spec.h:35-41
struct CompileSpec {
    bool amp_enabled = false;        // 是否启用混合精度
    int  max_sample_resolution = 0;  // MemoryPlan 最大槽位预留
    int  actual_resolution     = 0;  // 该变体的实际分辨率
    int  batch_size            = 0;  // batch 大小
    int  num_color_channels    = 0;  // 颜色通道数
    bool freeze_first_layer = false; // 运行时标志
};

六变体分别是:

  • variants[0]:train_base — 标准训练 batch × 起始分辨率
  • variants[1]:train_last — 训练最后一个不完整 batch
  • variants[2]:train_lowres — 标准 batch × 结束分辨率(如渐进 resize)
  • variants[3]:train_lowres_last
  • variants[4]:val_base — 验证标准 batch
  • variants[5]:val_last — 验证最后一个 batch

这些变体共享同一份计算图拓扑,但各自有独立的 MemoryPlan。

5.2 Phase 1:形状推导与跨变体一致性约束

// src/graph/compiler.cpp:568-612
void Compiler::derive_all_shapes(...) {
    for (size_t s = 0; s < specs.size(); ++s) {
        Shape cur_shape;
        for (size_t l = 0; l < arch.layers().size(); ++l) {
            const auto& layer = arch.layers()[l];
            const LayerDescriptor& descriptor = get_layer_descriptor(layer.kind);
            // ...
            all_shapes[s][l] = descriptor.infer_tensors(input, op_params, ctx);
            if (!all_shapes[s][l].empty()) {
                cur_shape = get_output_shape(layer.kind, all_shapes[s][l]);
            }
        }
    }
}

每个 LayerKind 对应一个不可变的 LayerDescriptor,它提供 infer_tensorsbuild_forwardbuild_backwardbuild_inference 四个函数指针。infer_tensors 返回该层在三种模式下所需张量的并集。

// include/renaissance/graph/layer_descriptor.h:107-120
struct LayerDescriptor {
    using InferFn = std::vector<TensorDesc> (*)(const Shape& input,
                                                  const OpParams& params,
                                                  const InferContext& ctx);
    using BuildFn = SubgraphPattern (*)(const OpParams& params,
                                         const std::vector<TensorDesc>& descs);

    InferFn infer_tensors;    // 返回三模式(train fwd/bwd + inf)张量并集
    BuildFn build_forward;    // 构建前向子图模式
    BuildFn build_backward;   // 构建反向子图模式
    BuildFn build_inference;  // 构建推理子图模式
};

get_layer_descriptor(LayerKind) 是一个集中式的 switch 注册表,定义在 src/graph/layer_descriptor_registry.cpp 中,每个 LayerKind 对应一个静态 LayerDescriptor。这种设计让 Compiler 不必知道每个算子的内部实现细节,只需要调用统一的函数指针。

例如,一个 LayerKind::Convinfer_tensors 会返回:输入特征图、权重、输出特征图、权重梯度槽、输出梯度槽等;build_forward 会返回一个包含 CONV_AMP_FWDCONV_FP32_FWD 算子的 SubgraphPattern。Compiler 把这些模式中的张量索引替换成真实的 DTensor ID,就得到了可执行的 GraphNode

这种”描述符 + 模式”的设计是 Compile 阶段与 Backend 解耦的关键。如果未来要支持新的算子或新的硬件后端,只需要新增 LayerKind 和对应的 LayerDescriptor,不需要改动 Compiler 的主流程。

这一步会验证跨变体一致性。代码中显式检查三条:

  1. 张量数量一致:同一层在每个变体下返回的张量个数必须相同;
  2. 张量名称一致:同一位置的张量名称必须相同;
  3. 张量所属 Region 一致:同一位置的张量必须属于同一个 Region。

张量的顺序由 std::vector 下标天然保证;而形状和 dtype 正是允许不同(响应 batch、resolution、AMP 的变化),所以不会被强制检查。

// src/graph/compiler.cpp:657-699
void Compiler::validate_tensor_consistency(...) {
    // 显式检查:每层张量数量、region、name 跨 spec 一致
}

这些一致性约束是后续跨变体共享计算图和 MemoryPlan offset 对齐的基础。

5.3 Phase 2:跨变体取最大 slot

不同变体的 batch 或分辨率不同,同一层同一位置的张量大小也不同。为了让大家共用同一份计算图,又各自有独立的显存布局,Compiler 会对每个 (layer, tensor) 位置跨变体取最大字节数:

// src/graph/compiler.cpp:618-651
void Compiler::compute_max_slot_bytes(...) {
    for (size_t l = 0; l < num_layers; ++l) {
        for (size_t t = 0; t < num_tensors; ++t) {
            uint64_t max_bytes = 0;
            for (size_t s = 0; s < all_shapes.size(); ++s) {
                uint64_t bytes = DTensor::compute_slot_bytes(desc.shape, desc.dtype, desc.region);
                max_bytes = std::max(max_bytes, bytes);
            }
            max_slots[l][t] = max_bytes;
        }
    }
}

这样每个变体的 MemoryPlan 在该位置分配同样大小的槽位,从而保证 DTensor ID 和 offset 跨变体一致。

5.4 Phase 3:创建 MemoryPlan

// src/graph/compiler.cpp:705-977
void Compiler::create_memory_plans(...) {
    for (size_t s = 0; s < all_shapes.size(); ++s) {
        memory_plans[s] = std::make_unique<MemoryPlan>(plan_config);
        // ... 分配 baseline dtensors ...
        for (size_t l = 0; l < num_layers; ++l) {
            for (size_t t = 0; t < tensors.size(); ++t) {
                DTensor dt = memory_plans[s]->alloc(alloc_shape, desc.dtype, desc.region, slot_bytes);
                // ...
            }
            // 条件分配 M/V/N 系列
        }
        memory_plans[s]->finalize();
    }
}

每个变体得到独立的 MemoryPlan。第 14 篇会专门讲 MemoryPlan 的 Region 分区(68 个命名语义区 + 1 个边界哨兵,共 69 个槽位),这里只需要知道:Compiler 在这一步把所有张量、梯度、动量、EMA、临时缓冲区、标量的 offset 都确定下来。

值得一提的是,Compiler 还会根据 GlobalRegistry 中当前的优化器类型覆盖 PlanConfig 的相关标志:

// src/graph/compiler.cpp:2454-2462
auto opt = GlobalRegistry::instance().optimizer_kind();
pc.use_momentum = (opt != OptimizerKind::SGD);
pc.use_adam     = (opt == OptimizerKind::ADAM || opt == OptimizerKind::ADAMW);
pc.use_lars     = (opt == OptimizerKind::LARS || opt == OptimizerKind::LARS_NESTEROV);

也就是说,动量缓冲区、Adam 二阶矩、LARS 范数区的分配不是硬编码的,而是由当前优化器自动决定;调用者传入的 PlanConfig 中对应字段会被覆盖。

5.5 Phase 4:构建 ComputationGraph

这是把 ArchPlan 变成可执行图的核心步骤。build_computation_graph 做三件事:

  1. 正向遍历:对每层调用 build_forward,生成前向节点,注入跨层数据链;
  2. 反向遍历:对每层调用 build_backward,生成反向节点,注入梯度链;
  3. 辅助图:构建 TRANSFER_A/BZERO_GRADFIRST_COMM/DEEP_COMMSTATS_COMMOPTIMIZER(含 LARS 时的 LARS_*_OPT)、EMA_UPDATE、AMP 类型转换(CAST_*)、NAN_CHECK_AND_GRAD_SCALINGUPDATE_STATSUPDATE_BN_INF_PARAMSCLEAR_METRICSACCUM_METRICS_*VAL_RESULT_COMM 等图;
  4. 推理图:再正向遍历一次,构建 INF_MAIN_A/BINF_EMA_A/B 等 ID 虽已预留,但当前 Compiler 阶段尚未填充。

反向遍历比前向更复杂,因为需要处理梯度链和 in-place 写回。Compiler 用 layer_input_ids 记录了每层前向输入 X 的 DTensor ID,反向时把这些 ID 作为 dX 的输出目标。例如对于 Conv 的反向:

// src/graph/compiler.cpp:1375-1397
if (is_conv_bwd_op(gn.compute_op) || is_cbr_bwd_op(gn.compute_op)) {
    if (layer.is_first_layer) {
        // 首层:dX 写回 I_A_DATA / I_B_DATA
        gn_a.output_ids.insert(gn_a.output_ids.begin(), b.data_a);
        train_cg.append(GraphId::FIRST_LAYER_BWD_A, gn_a);
        gn_b.output_ids.insert(gn_b.output_ids.begin(), b.data_b);
        train_cg.append(GraphId::FIRST_LAYER_BWD_B, gn_b);
    } else {
        // 非首层:dX 写回前一层输出(即本层输入 X)
        gn.output_ids.insert(gn.output_ids.begin(), it->second);
    }
}

SoftmaxCE 的反向会注入 scalinglabel_smcelabel_smoothing 等基线标量;BN 的反向会追加原始输入 X 并把 dX 写回 X。这些细节保证了前向和反向在数据依赖上的完全对应。

辅助图中包含大量 RANGE 节点,它们不操作单个 DTensor,而是一次性操作整个 Region。比如 ZERO_GRAD 图会清空所有梯度区:

// src/graph/compiler.cpp:1304-1311
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);

这种”Region 级批量操作”是 Tech-Renaissance 显存分区设计带来的红利,我们在第 14 篇会深入展开。

ComputationGraph零形状信息的纯拓扑容器:

// include/renaissance/graph/computation_graph.h:239-246
/**
 * @brief 纯算子拓扑容器
 * 核心特征:零形状信息
 *   - 节点中只存 (GraphNode, OpParams, tensor_id)
 *   - Shape / DType / Region 全部从 MemoryPlan 的 DTensor 获取
 *   - 一份图供多个 shape-only 变体共享
 */

节点分为两类:COMPUTE 节点使用 DTensor ID;RANGE 节点使用预计算的内存范围。这样同一份图既可以用在 base 变体,也可以用在 last/lowres 变体,只要它们的拓扑结构相同。

Compiler 还为每个子图做了职责划分:

// include/renaissance/graph/computation_graph.h:73-108
enum class GraphId : uint8_t {
    TRANSFER_A, TRANSFER_B,
    FIRST_LAYER_FWD_A, FIRST_LAYER_FWD_B,
    DEEP_FWD_BWD,
    ZERO_GRAD,
    FIRST_LAYER_BWD_A, FIRST_LAYER_BWD_B,
    FIRST_COMM, DEEP_COMM,
    CAST_DEEP_GRAD_FP16_TO_FP32,
    CAST_FIRST_GRAD_FP16_TO_FP32,
    NAN_CHECK_AND_GRAD_SCALING,
    STATS_COMM, UPDATE_STATS,
    OPTIMIZER, EMA_UPDATE,
    INF_MAIN_A, INF_MAIN_B,
    INF_EMA_A, INF_EMA_B,
    CAST_MAIN_FP32_TO_FP16, CAST_EMA_FP32_TO_FP16,
    ACCUM_METRICS, ACCUM_METRICS_TRAIN_LAST, ACCUM_METRICS_VAL_LAST,
    VAL_RESULT_COMM, CLEAR_METRICS,
    SIMPLE_TASK_GRAPH,
    LARS_FC_OPT, LARS_FIRST_CONV_OPT, LARS_DEEP_CONV_OPT,
    UPDATE_BN_INF_PARAMS,
    COUNT              // = 33
};

GraphId::COUNT = 33,意味着整个 ComputationGraph 内部按 33 个桶来组织子图。当前 Compiler 实际填充了训练前向/反向、数据搬运、通信、优化器更新、AMP 类型转换、梯度 NaN 检查、统计量更新、指标累积、推理主体等子图;INF_EMA_A/B 等 ID 虽已预留,但尚未在此阶段填充。其中 shape 无关的子图(如 TRANSFERCOMMOPTIMIZEREMA_UPDATE)在所有变体间共享;shape 相关的子图(如 FIRST_LAYER_FWDDEEP_FWD_BWD)按 ShapeId 去重捕获。

5.6 Phase 5:变体共享

// src/graph/compiler.cpp:2348-2367
void Compiler::share_or_clone(Result& result, ...) {
    for (size_t i = 0; i < result.variants.size(); ++i) {
        auto& v = result.variants[i];
        if (v.name.find("val_") == 0) {
            v.train = nullptr;        // 验证变体不需要训练图
        } else {
            v.train = &train_cg;
        }
        v.inference = &infer_cg;      // 所有变体共享推理图
    }
}

Result 只持有两份 ComputationGraph(train 和 infer),六个变体通过指针共享它们,各自持有独立的 MemoryPlan。这避免了为每个变体重复建图。函数名 share_or_clone 虽然暗示了”共享或克隆”两种可能,但当前实现只走共享分支。

六、关键设计思想:图与形状解耦

Tech-Renaissance 编译管线的核心设计可以概括为一句话:把”图拓扑”和”形状/内存布局”解耦

  • ArchPlan 有形状信息,但它是规范化后的层序列,不是执行图。
  • ComputationGraph 没有形状信息,只保存算子拓扑和 DTensor ID。
  • MemoryPlan 保存每个 DTensor 的形状、dtype、offset、stride。
  • CompileSpec 只描述形状相关的参数。

这种解耦的好处是:

  1. 同一份图服务多个变体。train_base 和 train_last 的 batch 不同,但图拓扑一样,直接共享。
  2. 形状变化不需要重新建图。渐进式 resize 训练中,分辨率变化只影响 MemoryPlan 和捕获参数,不影响 ComputationGraph。
  3. 便于 CUDA Graph 捕获。图拓扑固定,地址由 MemoryPlan 固定,捕获和重放都稳定。
  4. 便于分布式扩展。同一 MemoryPlan 图纸可以在多张 GPU 上共享执行。

回顾整个管线,你还会发现另一个特点:多轮规划、层层递进。BluePrint 的 Block 宏为 ArchPlan 的融合保留了”意图”;ArchPlan 的形状推导为 Compiler 的显存分配提供了”尺寸”;Compiler 的 MemoryPlan 为 ComputationGraph 提供了”地址”;ComputationGraph 又为 CUDA Graph 捕获提供了”指令序列”。每一步只做自己该做的事,产出清晰地传递给下一步。这种分层带来的可测试性、可扩展性和确定性,是静态图框架的典型优势。

七、与 PyTorch torch.compile 的对比

写到这里,可能有人会问:Tech-Renaissance 的编译和 PyTorch torch.compile 有什么不同?

最大的区别在于:Tech-Renaissance 从设计之初就是静态的,而 torch.compile 是在动态图之上事后补编译。

  • 图捕获方式:Tech-Renaissance 的图由 ArchPlan + Compiler 显式构建;torch.compile 需要 Dynamo 在运行时捕获 Python 执行痕迹。
  • 形状处理:Tech-Renaissance 在编译期就处理多变体;torch.compile 遇到动态形状会重新编译或 specialization。
  • 内存地址:Tech-Renaissance 通过 MemoryPlan 静态分区保证地址稳定;torch.compile 需要额外的 CUDA Graph Trees 等机制来维护地址稳定性。
  • 灵活性:torch.compile 可以处理任意 Python 控制流(虽然会 graph break);Tech-Renaissance 目前不支持数据依赖控制流和运行期动态修改模型。

这再次说明了一个道理:没有完美的框架,只有面向特定场景的取舍。Tech-Renaissance 牺牲了部分灵活性,换取了全局优化空间和运行期确定性。

八、小结

ArchPlan 和 Compiler 是 Tech-Renaissance 从”模型定义”到”可执行图”的桥梁。ArchPlan 用十步管线把 BluePrint 树展开、规范化、融合成标准化的层序列;Compiler 用五阶段把层序列转换成 MemoryPlan 和零形状的 ComputationGraph,并实现多变体共享。

核心要点:

  1. ArchPlan 是编译 IR:负责 Block 合并、CBR 融合、GapFC 融合、BN 重命名、形状推导等架构级变换。
  2. Compiler 是五阶段编排器:形状推导 → 最大槽位计算 → MemoryPlan 创建 → 计算图构建 → 变体共享。
  3. 三条一致性约束(数量、名称、Region)保证跨变体张量描述一致,是图共享和 offset 对齐的基础。
  4. 图与形状解耦:ComputationGraph 零形状,MemoryPlan 管形状和地址,一份图服务多份变体。
  5. 静态编译换取全局优化空间:这是后续 CUDA Graph 全捕获、静态显存规划、确定性训练的共同底座。

下一篇,我们将专门讲 ComputationGraphGraphAtlas 的设计——一份图纸,如何被多处复用。

发表回复

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

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