(12) BluePrint DSL:用声明式语法定义神经网络

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

上一篇我们讲了 FusedNormalization,把数据增强的最后一步收尾操作融进了一次内存遍历。从这一篇开始,我们把视线从数据侧移回模型侧,进入 Tech-Renaissance 的图编译与内存规划篇章。

训练一个深度神经网络,第一步当然是定义模型。但”定义模型”这件事看似简单,其实牵动后续一大片设计:你的模型描述是只给人类看的,还是能给编译器看?编译器能不能从中识别出可以融合的算子模式?能不能自动推导形状、分配显存、生成 CUDA Graph?如果模型定义层写得太”自由”,编译器就只能看到一堆零散的张量操作,很多全局优化就无从谈起。

这一篇我们先不讲编译器本身,而是讲 Tech-Renaissance 提供给用户的模型定义入口:BluePrint DSL

一、模型定义层:框架与用户之间的”图纸”

几乎所有的深度学习框架都需要回答同一个问题:用户应该以什么方式描述一个神经网络?

回顾历史,这条路的演变非常清晰。早期的 Caffe 直接用 Protobuf 配置文件描述网络:每一层是一个带参数的节点,层与层之间按顺序连接。这种写法非常声明式,也便于部署,但遇到 ResNet 的残差连接、Inception 的多分支结构时就显得笨拙,循环和条件分支更是难以表达。

TensorFlow 1.x 继承了声明式的思路,但把它从配置文件升级为 Python API:你先创建 tf.Graph,再用 tf.layers.conv2dtf.layers.batch_normalization 等函数把节点拼起来。这种方式比 Caffe 灵活得多,却依然要求用户在心里区分”建图阶段”和”执行阶段”。

PyTorch 则走了另一条路。它用 Python 的面向对象机制把层封装成 nn.Module,用户在 forward() 里用普通 Python 代码描述计算过程。这种命令式风格彻底抹平了”建图”与”执行”的边界,调试体验极佳,也让 PyTorch 在学术界迅速反超 TensorFlow。但代价是框架在运行时看到的不是一张完整的图,而是一段已经跑完的代码;要做全局优化,只能后期通过 torch.compile 或 TorchScript 去捕获和重建图。

后来出现的新框架则尝试融合两者。JAX 的 FlaxHaiku、Google 的 Keras Functional API,都倾向于用声明式组合描述固定结构,同时保留 Python 的灵活性处理动态逻辑。ONNX 更进一步,直接把训练好的模型导出成一种与框架无关的声明式图表示,用于推理部署。

这些路线的分歧,本质上是灵活性与可优化性的权衡。声明式写法把模型的完整结构一次性交给框架,框架因此可以:在编译期完成所有形状推导,避免运行时再临时分配张量;识别 Conv+BN+ReLU、Bottleneck 等可融合模式,调用专用融合 kernel;按全局生命周期规划显存,把权重、梯度、中间特征图、临时缓冲区分门别类地放进固定 Region;一次性捕获整个训练循环的 CUDA Graph,消除 kernel launch 开销。PyTorch 的 torch.compile 做的事情,本质上也是在动态图执行之后,努力把信息重新收集成静态图;而 Tech-Renaissance 从一开始就把静态结构暴露给用户,跳过了”捕获”这一步。

这里还有一个值得区分的概念:内部 DSL(embedded DSL)外部 DSL。Caffe 的 Protobuf 配置是外部 DSL,它需要独立的解析器和编译器;PyTorch 的 Python API 则不算 DSL,它就是 Python 本身。Tech-Renaissance 的 BluePrint 属于内部 DSL:它仍然使用 C++ 的语法和类型系统,但通过一组精心设计的工厂函数和组合算子,形成了一种专门用于描述神经网络的”子语言”。用户写的是合法 C++ 代码,却感觉像在写模型结构图。

Tech-Renaissance 的目标是在主流 CV 训练任务上做到极致性能,因此它选择了声明式:从用户写下 BluePrint 的那一刻起,框架就拿到了整张网络的”图纸”,可以在编译期做形状推导、算子融合、显存规划和 CUDA Graph 捕获。BluePrint DSL 就是这条设计路线的用户侧入口。

二、BluePrint 长什么样

先看一个最直观的例子。在 Tech-Renaissance 里定义一个 4 层 MLP,只需要这几行:

BluePrint mlp = seq(
    fc(1024, true), relu(),
    fc(512, true),  relu(),
    fc(256, true),  relu(),
    fc(10, true)
);

没有类定义,没有 forward(),没有显式的张量对象。seq 表示顺序组合,fc 是全连接层,relu 是激活函数。整个模型就是一棵层组合树

再看 VGG-16-BN 的骨干部分。PyTorch 里你需要写一堆 nn.Conv2dnn.BatchNorm2dnn.ReLU、模块嵌套;而在 Tech-Renaissance 里,它更接近你脑子里的结构图:

BluePrint vgg16bn = seq(
    // Block 1: 224x224 -> 112x112
    conv(64, 3, 1, 1), bn(), relu(),
    conv(64, 3, 1, 1), bn(), relu(),
    maxpool(2, 2, 0),

    // Block 2: 112x112 -> 56x56
    conv(128, 3, 1, 1), bn(), relu(),
    conv(128, 3, 1, 1), bn(), relu(),
    maxpool(2, 2, 0),

    // ...
    flatten(),
    fc(4096, true), relu(), dropout(0.5),
    fc(4096, true), relu(), dropout(0.5),
    fc(1000, true)
);

这种写法的好处是:你写的几乎就是网络结构本身。每个工厂函数的参数也直接对应卷积核大小、步长、填充、输出通道这些超参数,没有额外的包装层。

BluePrint 本身则是一个更轻量的门面类。在 blueprint.h 中,它只有几句话的实现:

class BluePrint {
public:
    BluePrint() = default;
    BluePrint(Layer root) : root_(std::move(root)) {}
    BluePrint& operator=(Layer root) { root_ = std::move(root); return *this; }
    bool empty() const noexcept { return !root_.valid(); }
private:
    Layer root_;
    friend class ArchPlan;
};

BluePrint 几乎不增加任何语义,它只是把一棵 Layer 树打包成”模型”的概念。用户可以像上面那样直接构造,也可以先拼好 Layer 再赋值:

Layer backbone = seq(conv(64,3,1,1), bn(), relu(), maxpool(2,2,0));
BluePrint model = backbone;

随后 DeepLearningTask::model(model) 会把它交给 ArchPlan 和 Compiler,整个过程不需要用户手动指定输入输出张量。

需要强调的是,BluePrint 只负责描述模型结构。损失函数、优化器、学习率调度、参数初始化、数据预处理这些训练配置,都不在 BluePrint 里,而是通过 DeepLearningTask 的链式 API 单独设置:

DeepLearningTask task;
task.model(vgg16bn)
    .loss(CrossEntropyLoss().label_smoothing(0.1f))
    .optimizer(SGD().momentum(0.9f).weight_decay(1e-4f))
    .scheduler(CosineAnnealingLR().base_lr(0.36f).warmup(10));

这种分离让模型结构可以复用:同一份 BluePrint 既可以配 SGD 训 ImageNet,也可以配 AdamW 做迁移学习,而模型定义本身不需要改动。

对于 ResNet-50 这种包含残差和重复堆叠的网络,BluePrint 也能用很少的代码表达。借助 block()repeat(),整个 ResNet-50 可以在十几行内写完:

BluePrint resnet50 = seq(
    conv(64, 7, 2, 3), bn(), relu(),
    maxpool(3, 2, 1),

    // stage 1: 256 channels, 3 blocks
    block(64, 256, BlockStyle::RESNET_1_3_1),
    repeat(block(64, 256, BlockStyle::RESNET_1_3_1), 2),

    // stage 2: 512 channels, 4 blocks, downsample
    block(128, 512, BlockStyle::RESNET_1_3_1_DS),
    repeat(block(128, 512, BlockStyle::RESNET_1_3_1), 3),

    // stage 3: 1024 channels, 6 blocks, downsample
    block(256, 1024, BlockStyle::RESNET_1_3_1_DS),
    repeat(block(256, 1024, BlockStyle::RESNET_1_3_1), 5),

    // stage 4: 2048 channels, 3 blocks, downsample
    block(512, 2048, BlockStyle::RESNET_1_3_1_DS),
    repeat(block(512, 2048, BlockStyle::RESNET_1_3_1), 2),

    gap(),
    fc(1000, true)
);

block() 描述一个 Bottleneck 残差块,repeat() 把它重复多次。第一个参数 mid_ch 是 Bottleneck 的中间(bottleneck)通道数,第二个参数 out_ch 是输出通道数;当输入通道数与 out_ch 相同时,ArchPlan 会自动识别为 identity shortcut,否则使用 1×1 投影 shortcut。残差连接和 downsample 都被封装在 BlockStyle 里,不需要用户手动写 add2

MobileNetV2 的写法同样非常紧凑。倒置残差块通过 mbconv(expand_ratio, out_ch, stride) 表达:

BluePrint mobilenetv2 = seq(
    conv(32, 3, 2, 1), bn(), relu6(),

    // stage 1
    mbconv(1, 16, 1),

    // stage 2
    mbconv(6, 24, 2),
    mbconv(6, 24, 1),

    // stage 3
    mbconv(6, 32, 2),
    mbconv(6, 32, 1),
    mbconv(6, 32, 1),

    // ...
    conv(1280, 1, 1, 0), bn(), relu6(),
    gap(),
    fc(1000, true)
);

每个 mbconv 在开启 AMP 融合时会被映射为 InvResidualIdentityInvResidualNoShortcut 融合层,调用后端的 INVRESIDUAL_AMP_FWD/BWD 专用 kernel。

三、Layer:一棵不可变的树

BluePrint 的底层类型是 Layer。在 include/renaissance/graph/blueprint.h 中,它的实现非常轻量:

class Layer {
public:
    Layer() = default;
    bool valid() const noexcept { return static_cast<bool>(node_); }
private:
    std::shared_ptr<const Node> node_;
    explicit Layer(std::shared_ptr<const Node> n) : node_(std::move(n)) {}
    // ...
};

struct Layer::Node {
    detail::NodeKind kind;
    detail::Payload  payload;
};

Layer 是一个 Pimpl 风格的小把手,内部持有一个 const Node 的共享指针。Node 由两部分组成:NodeKind 表示它是哪种层,Payloadstd::variant 承载的具体参数。这棵树有三个重要特性:

第一,不可变。节点一旦创建就不会被修改,父子关系在构造时确定。这消除了很多由可变状态带来的隐患:你不用担心某个层在编译过程中被悄悄改掉,也不用担心多个模型副本之间互相污染。

第二,值语义Layer 可以像整数一样被拷贝、传递、放进 std::vector,但拷贝只是共享指针,没有深层复制。组合 seq(a, b, c) 时,不会产生大量临时对象。

第三,组合优先。基础层(convbnrelufc 等)是叶子节点;组合层(seqadd2repeat)是内部节点。add2 表达残差连接,repeat 表达重复堆叠,两者配合就能描述 ResNet、MobileNet 这类复杂拓扑。

在 BluePrint 里,残差连接使用 add2(lhs, rhs) 表达,其中 lhs 通常是 shortcut 分支,rhs 是主分支。为了保证 shortcut 分支可以被编译器识别,identity() 被用作占位符,表示”直接传递输入”。例如:

Layer residual = add2(
    identity(),                                    // shortcut
    seq(conv(64,3,1,1), bn(), relu(),
        conv(64,3,1,1), bn())                      // main branch
);

展开到 ArchPlan 时,add2 会被翻译成 Add2StartAdd2ShortcutEndAdd2End 三个标记层,用来在后续构建计算图时正确插入张量加法。配合 block() 使用时,这些标记由框架自动生成,用户无需关心。

这些工厂函数都定义在头文件里,全部是 inline,因此 BluePrint DSL 是header-only的。用户只需要 #include "renaissance/graph/blueprint.h",不需要链接额外的库。

Layer::Node 内部用 std::variant 承载所有可能的参数类型——ConvParamBNParamSequentialParamBlockParam 等等。相比传统的继承体系,variant 有两个好处:一是类型安全,编译器会检查你访问的是否是正确的参数;二是没有虚函数指针开销,每个 Layer 都只是一个 shared_ptr 加一个指向不可变节点的引用,内存布局紧凑。每个工厂函数本质上就是构造一个 Layer::Node 并包装成 Layer

inline Layer conv(int out_ch, int k, int s = 1, int p = 0) {
    return Layer(std::make_shared<Layer::Node>(
        detail::NodeKind::Conv2d,
        detail::ConvParam{out_ch, k, s, p}));
}

组合函数则利用 C++ 变参模板把任意多个 Layer 收集成 std::vector<Layer>

template <typename... Args>
inline Layer seq(Args&&... args) {
    auto layers = detail::make_layers(std::forward<Args>(args)...);
    if (layers.empty()) throw std::invalid_argument("seq: empty sequential is not allowed");
    return Layer(std::make_shared<Layer::Node>(
        detail::NodeKind::Sequential,
        detail::SequentialParam{std::move(layers)}));
}

这意味着 seq 在编译期就能检查类型:你把一个不是 Layer 的东西塞进去,会直接编译报错。相比 Python 框架的运行时检查,这种静态类型约束可以把很多低级错误扼杀在编译阶段。

四、BluePrint 的核心设计原则

BluePrint DSL 的设计可以概括为三条原则,它们共同决定了 DSL 既好写又好优化。

1. 不可变与声明式

所有 Layer 对象一旦创建就不可修改,内部通过 std::shared_ptr<const Node> 共享不可变数据。用户描述的是”网络是什么”,而不是”网络怎么算”。框架根据这份声明来决定执行顺序、内存布局、算子融合策略。这为后续的静态图编译、CUDA Graph 全捕获、MemoryPlan 静态显存规划打下了基础——所有优化都建立在”编译期知道一切”的前提上。

2. 为融合而生

BluePrint 不仅提供基础层工厂函数,还内置了预融合模块(如 cbrgap_fc)和高层块(如 blockmbconv)。这些不是简单的语法糖——它们向编译器传递了明确的融合意图。当用户写 cbr(64, 3, 1, 1) 而不是 seq(conv(64, 3, 1, 1), bn(), relu()) 时,编译器不需要做模式匹配就能确定这是一个 CBR 融合节点,可以直接映射到后端的高效融合算子。

3. 贴近硬件细节

BluePrint 工厂函数的参数设计刻意地保持克制。例如 conv 不接受 bias 参数,因为 Tech-Renaissance 的卷积层统一不使用 bias:卷积后的 BN 层本身就有 beta 参数(shift),conv 的 bias 与 BN 的 beta 在数学上是冗余的。去掉 conv bias 既减少了参数量,也简化了 Conv+BN 融合时的计算逻辑。

再比如 channel_padding(),它会把当前通道数向上对齐到 8 的倍数。在 FP16 AMP 模式下,Tensor Core 对通道维度有对齐要求;虽然框架会自动处理首层 Conv/CBR 的输入 C 对齐到 4,但在网络中间某些特殊位置,用户仍可以显式插入 channel_padding() 来确保通道数满足后端 kernel 的对齐约束。下面是一段简化的形状推导伪代码:

// 输入特征图形状为 [N, H, W, C]
// 对于 2D 卷积,输出 H、W、C 按下式计算:
int out_h = (H + 2 * pad - k) / stride + 1;
int out_w = (W + 2 * pad - k) / stride + 1;
int out_c = out_ch;                       // 由 conv/cbr 的参数指定

// channel_padding 把 C 对齐到 8:
int aligned_c = (C + 7) / 8 * 8;

其中 HWC 分别是输入特征图的高、宽、通道数,kstridepad 是卷积核大小、步长和填充,out_ch 是输出通道数。

五、预融合块:把优化意图写进 DSL

BluePrint 里有一批专门用来表达”这里应该被融合“的工厂函数。最常用的是 cbr

inline Layer cbr(int out_ch, int k, int s, int p,
                 double momentum = 0.1, double eps = 1e-5) {
    return Layer(std::make_shared<Layer::Node>(
        detail::NodeKind::CBR,
        detail::CBRParam{out_ch, k, s, p,
                         static_cast<float>(momentum),
                         static_cast<float>(eps)}));
}

cbr 代表 Conv + BatchNorm + ReLU 三元融合。用户写一个 cbr(64, 3, 1, 1),编译器就知道这是一个整体,不会把它拆成三个独立层。如果用户显式写成 seq(conv(...), bn(), relu()),ArchPlan 的 step9_merge_triple 也会尝试把它们合并为 CBR 融合层,但直接写 cbr 等于把优化意图提前表达清楚,减少编译器猜测。

类似地,还有:

  • cbrp:Conv + BN + ReLU + MaxPool 四元组合。它目前在 blueprint.h 中仍作为语法糖存在,但 ArchPlan::step8_merge_quadruple 已为空实现——也就是说,当前版本里 cbrp 会被展开为四个独立层,而不会被融合成单个 kernel。保留这个工厂函数是为了让用户描述 VGG 风格的”卷积块+池化”结构时更简洁。
  • gap_fc:Global Average Pooling + FC 二元融合。在 ResNet 的 classifier 头部,GAP 将 7×7 的特征图池化为 1×1,然后接一个 FC;这两个操作可以融合成一次内核调用。
  • block(...):ResNet Bottleneck / BasicBlock 融合块。
  • mbconv(...):MobileNetV2 倒置残差块。

以 Bottleneck 为例,block(64, 256, BlockStyle::RESNET_1_3_1) 生成的是一个 NodeKind::Block 节点。在 ArchPlan::expand_tree() 中,如果当前开启了融合模式,它会调用 expand_block_impl(),把节点直接映射为 BottleneckProjectionBottleneckIdentity 这种融合层;如果关闭融合,则调用 expand_block_unfused(),把它拆成 convbnreluadd2 等原始算子序列。同一份 BluePrint,既能跑高性能融合路径,也能跑纯原语路径,便于调试或 CPU 模式运行。

这些预融合块的设计理念是:DSL 不应该只描述”有什么”,还应该暗示”怎么执行最好”。用户在高层写下的每一个 block()cbr(),都会原封不动地进入 ArchPlan,成为后端选择专用融合 kernel 的依据。

六、从 BluePrint 到可执行图

BluePrint 本身只是模型定义,不持有任何权重、不分配任何显存。真正把它变成可执行图的是 Graph 模块的编译管线。

第一步是 ArchPlan::from_blueprint()。它把 Layer 树展开成一串 ArchLayer,每个 ArchLayer 包含归一化后的层类型、参数、输入输出形状占位,以及是否首层等标记。展开过程在 src/graph/arch_plan_expand.cpp 中递归完成,核心逻辑可以用下面这段简化代码概括:

void ArchPlan::expand_tree(const Layer& root,
                           std::vector<ArchLayer>& out,
                           int& current_c, bool fuse) {
    auto& node = *root.node_;
    int src_id = static_cast<int>(out.size());   // 当前层在序列中的位置
    switch (node.kind) {
        case NodeKind::Conv2d:
        case NodeKind::ReLU:
            // 基础层直接映射到对应的 ArchLayer
            expand_primitive_impl(node, out, current_c, src_id, fuse);
            break;
        case NodeKind::Sequential:
            for (const auto& child : node.children)
                expand_tree(child, out, current_c, fuse);
            break;
        case NodeKind::Block:
            if (fuse) expand_block_impl(node, out, current_c, current_c, src_id);
            else      expand_block_unfused(node, out, current_c, current_c, src_id);
            break;
        case NodeKind::Add2:
            // 展开残差连接,插入 Add2Start / Add2ShortcutEnd / Add2End 标记
            break;
        case NodeKind::Repeat:
            for (int t = 0; t < node.times; ++t)
                expand_tree(node.body, out, current_c, fuse);
            break;
    }
}

注意 fuse 参数。它通常与 GlobalRegistry 里的 AMP 开关联动:from_blueprint() 默认传入 fuse=true,但如果此时 AMP 未开启,框架会直接抛错,因为当前融合 kernel 只在 AMP 模式下实现。开启 AMP 时,默认做融合;关闭 AMP,或显式传入 fuse=false 时,blockcbr 等节点会展开成原始算子序列。

展开之后,ArchPlan 还会做一系列归一化:重命名 BN、处理 SoftmaxCE、把 identity/flatten 标准化、推导形状、合并 Bottleneck/BasicBlock/InvResidual、合并二元/三元模式、标记首层。最终得到一份标准化的架构描述,交给 Compiler 进入五阶段编译管线:形状推导 → 最大槽位计算 → MemoryPlan 分配 → ComputationGraph 构建 → 变体共享去重。

这些步骤我们会在下一篇《ArchPlan 与编译管线》里详细讲。这里只需要记住一点:BluePrint 是用户与编译器之间的契约。它足够简洁,让人写得舒服;又足够结构化,让编译器能读懂并榨取优化空间。

七、与 PyTorch 的对比

很多人第一次看 Tech-Renaissance 的 BluePrint 会问:这和 PyTorch 的 nn.Sequential 有什么区别?

表面的相似是都用了顺序组合,但底层思路截然不同。

PyTorch 的 nn.Sequential 本质上是一组有状态的 Module 对象。每个 nn.Conv2d 内部已经持有权重张量、偏置张量、梯度张量;模型定义和参数分配是同时发生的。也正因如此,PyTorch 看到的是一堆对象,而不是一张纯粹的图。torch.compile 要花很大力气去捕获、去重、分析,才能从这些对象的执行轨迹中恢复出可优化的静态图。

Tech-Renaissance 的 LayerBluePrint无状态的抽象语法树(AST)。它只描述网络结构,不持有任何参数。你没法像 PyTorch 那样在定义后立刻 print(model.conv1.weight),因为那时候权重还没有分配。权重、梯度、中间特征图的张量描述要到 Compiler 阶段才由 MemoryPlan 根据结构统一分配。这意味着:

  1. 编译器拿到的是完整蓝图,不是执行轨迹。不需要图捕获,也不需要从 Python 动态行为中反推结构。
  2. 预融合块天然保留cbrblock 这些节点直接进入 ArchPlan,成为后端融合算子的候选,而不是事后由 pattern matcher 去识别。
  3. 多 shape 变体可以共享同一份图。因为 BluePrint 不绑定具体 batch size 和分辨率,同一张 ComputationGraph 可以服务训练高分辨率、训练低分辨率、验证等多个变体,只有 MemoryPlan 不同。

从更宏观的视角看,BluePrint 与 JAX/Flax、Keras Functional API、ONNX 同属于”声明式”阵营,但它是纯 C++ 的,没有 Python 运行时和 GIL 的束缚。模型定义完成后,编译管线可以直接在 C++ 侧完成展开、形状推导、内存规划和图构建,不需要跨语言序列化,也不需要等待 Python 解释器逐行执行。

BluePrint 也具有较好的可扩展性。如果需要支持一种新的层或新的组合模式,通常只需要做三件事:在 NodeKind 枚举中增加一个条目,在 Payload variant 中增加一个参数结构体,在 ArchPlanexpand_tree 里增加对应的展开逻辑。后端是否支持该层则通过 LayerDescriptor 注册表来管理,DSL 本身不需要关心 CUDA kernel 的具体实现。

当然,这种设计也付出了灵活性上的代价。如果你的模型需要在运行时根据输入动态改变结构(比如输入相关控制流、动态图结构),BluePrint 并不擅长。Tech-Renaissance 的目标场景是主流 CV 训练任务——ResNet、VGG、MobileNet 这类结构固定、可高度优化的网络,在这些场景下,声明式 DSL 的收益远大于代价。

八、小结

BluePrint DSL 是 Tech-Renaissance 静态图编译路线的起点。它用一组轻量的工厂函数——seqadd2repeatconvbnrelucbrblockmbconv 等——把复杂的神经网络结构表达成一棵不可变的 Layer 树。用户写的是结构,编译器看到的是优化机会。

这棵树的设计要点值得记住:

  1. 不可变、值语义的 Layer,让模型定义没有隐式状态,可以安全地拷贝和复用;
  2. 组合式语法,用顺序、残差、重复三种基本结构覆盖绝大多数经典网络;
  3. 预融合高层块,把 Conv+BN+ReLU、Bottleneck、InvResidual 等性能关键模式在 DSL 层就保留下来;
  4. 纯 C++、header-only,没有 Python 运行时和跨语言序列化的开销。

BluePrint 本身不做任何计算,它只是把”用户想训什么模型”翻译成”编译器能优化什么图”。真正把这些层变成张量、算子、CUDA Graph 的,是下一篇要讲的 ArchPlan 与编译管线。

发表回复

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

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