Mini-Infer (24): 动态形状支持 — 运行时形状推理引擎
Mini-Infer (24): 动态形状支持 — 运行时形状推理引擎
1. 为什么需要运行时推理?
在传统的静态图中,形状推导(Shape Inference)通常只在模型加载时做一次。但在动态场景下,形状推导必须变成一个运行时 (Runtime) 行为。
想象一个简单的网络:Input -> Conv -> ReLU -> Output。
如果 Input 变了,Conv 的输出形状 Hout=(Hin+2P−K)/S+1H_{out} = (H_{in} + 2P - K)/S + 1Hout=(Hin+2P−K)/S+1 也必须跟着变。
我们不能每次都重新构建整个 Graph,那样太慢了。我们需要一个轻量级的引擎,快速遍历一遍图,只更新 Shape,不碰 Data。
2. 核心架构:拓扑顺序传播
ShapeInferenceEngine 的工作原理就像推多米诺骨牌:
设置起点:用户提供新的输入形状(例如 Input: [1, 3, 512, 512])。
拓扑遍历:按照依赖顺序访问每个节点。
收集输入:对于节点 NNN,收集它所有输入张量的当前形 ...
Mini-Infer (23): 内存优化的黑魔法 — 静态内存规划与贪心着色
Mini-Infer (23): 内存优化的黑魔法 — 静态内存规划
1. 核心架构:内存规划的四个步骤
我们的内存规划器 (MemoryPlanner) 并不是在运行时动态申请释放内存,而是在推理之前(编译期) 对图进行静态分析,计算出一份最优的内存分配方案。
这个过程分为四个严谨的步骤:
生命周期分析 (Liveness Analysis):确定每个 Tensor 何时生、何时死。
构建冲突图 (Interference Graph):确定哪些 Tensor 绝对不能共用内存。
贪心着色 (Greedy Coloring):分配内存池 ID,让不冲突的 Tensor 复用同一个 ID。
生成方案 (Plan Generation):输出最终的内存池配置。
2. 第一步:生命周期分析 (LivenessAnalyzer)
要复用内存,首先要知道 Tensor 的有效期。
出生 (Birth): 生产该 Tensor 的算子开始执行的时间点。
死亡 (Death): 最后一个消费该 Tensor 的算子执行结束的时间点。
我们定义了 TensorLifetime 结构体 ...
Mini-Infer (22): 架构重构 — 链接器的魔法与“副作用”驱动的自动注册
Mini-Infer (22): 架构重构 — 链接器的魔法与“副作用”驱动的自动注册
1. 核心思想:从“拉 (Pull)”到“推 (Push)”
在此之前的架构中,我们采用的是 “显式触发” 模式:
代码逻辑:Main 函数 -> 调用 Init() -> Init 调用 RegisterConv(), RegisterRelu()…
缺点:耦合度高。每增加一个算子,都要修改 Init 函数。
现在,我们转向 “副作用驱动 (Side-Effect Driven)” 模式:
代码逻辑:Conv.cpp 定义一个静态全局变量 -> 程序启动 -> 加载 Conv.o -> 触发静态变量构造函数 -> 构造函数执行 Register()。
优点:高度解耦。Main 函数根本不需要知道 Conv 的存在,注册行为是加载库文件带来的“副作用”。
2. 隐形的大坑:静态链接的“死亡剔除” (Dead Code Stripping)
如果你直接在 .cpp 里写一个静态变量,然后把这个文件编译进静态库 (.a 或 .lib),你会发现:注册代码 ...
Mini-Infer (21): 图优化实战 — TensorRT 风格的 `FusionPass` 与延迟删除
Mini-Infer (21): 图优化实战 — TensorRT 风格的 FusionPass 与延迟删除
1. 设计哲学:为什么是“TensorRT 风格”?
在实现算子融合时,通常有两种流派:
替换流 (Replacement):发现 Conv + ReLU,删掉两个节点,创建一个新的 ConvReLU 算子节点。
缺点:会导致算子数量爆炸(ConvReLU, ConvSigmoid, ConvTanh…)。
属性流 (Attribute/TensorRT-style):发现 Conv + ReLU,保留 Conv 节点,只是给它设置一个 activation 属性,然后删掉 ReLU 节点。
优点:保持了算子库的简洁。Conv2D 算子内部根据 activation 属性决定是否在输出前执行激活逻辑。
Mini-Infer 坚定地选择了 TensorRT 风格。我们的 FusionPass 不会创建新节点,而是对现有节点进行“微创手术”。
2. 核心逻辑:图的“外科手术”与安全性
在对图进行修改(特别是删除节点)时,最容易犯的错误就是迭代器失效 (Iter ...
Mini-Infer (20): 优化器的骨架 — `Pass Manager` 架构设计
Mini-Infer (20): 优化器的骨架 — Pass Manager 架构设计
1. 设计哲学:像编译器一样思考
在编译器领域(如 LLVM),优化通常被组织成一系列的 Pass (遍)。每一个 Pass 只做一件特定的事情(比如“死代码消除”),然后优化器(Pass Manager)按顺序执行这些 Pass。
Mini-Infer 借鉴了这种设计,同时也参考了 TensorRT 的 IOptimizationProfile 概念。
我们需要两个核心组件:
OptimizationPass: 定义“怎么优化”的接口(策略模式)。
GraphOptimizer: 定义“何时优化”的管理器(执行管线)。
2. 定义契约:OptimizationPass
首先,我们定义所有优化算法必须遵守的基类。
1234567891011121314151617181920// mini_infer/graph/graph_optimizer.hclass OptimizationPass {public: explicit OptimizationPass(const std:: ...
Mini-Infer (19): 内置算子导入实战 — Conv, ReLU, Pooling 与 Flatten
Mini-Infer (19): 内置算子导入实战 — Conv, ReLU, Pooling 与 Flatten
1. 核心逻辑:OperatorImporter 的标准范式
在实现具体算子之前,我们总结出一套标准的导入流程(范式):
解析属性 (Parse Attributes):使用 AttributeHelper 获取 strides, pads 等参数。
获取输入 (Get Inputs):从 ImporterContext 中查找输入 Tensor。如果是权重(Initializer),需要特殊处理。
创建算子 (Create Operator):构建 Mini-Infer 的 Operator 对象(如 Conv2D)。
构建节点 (Build Node):创建 Graph::Node,并将 Operator 绑定上去。
连接边 (Connect Edges):将输入 Tensor 连接到当前 Node。
注册输出 (Register Output):创建输出 Tensor 并注册到 Context,供后续节点使用。
所有算子的导入器都遵循这个“六步走”战略。
...
Mini-Infer (18): 编排导入流程 — `ModelImporter` 与 `AttributeHelper`
Mini-Infer (18): 编排导入流程 — ModelImporter 与 AttributeHelper
引言:从架构到实现
在之前的博客中,我们设计了 OnnxParser 的顶层入口,定义了 ImporterContext 和 OperatorRegistry 的接口,并实现了 WeightImporter 来解析数据。
现在,我们需要将这些组件真正“运转”起来。
本篇,我们将实现两个核心组件:
AttributeHelper:一个极其使用的工具类,用于解决 ONNX Protobuf 属性访问繁琐的问题。
ModelImporter:整个导入过程的“总指挥”。它负责按照正确的顺序(权重 -> 输入 -> 节点 -> 输出)编排导入流程,并将解析任务分发给注册表。
1. AttributeHelper:优雅地解析属性
ONNX 的 NodeProto 使用 Key-Value 的列表来存储算子属性(如卷积的 strides, pads)。使用原生的 Protobuf API 来查找和读取这些属性非常啰嗦且容易出错。
我们需要一个包装器来简化这 ...
Mini-Infer (17): 深入字节流 — `WeightImporter` 与权重加载
Mini-Infer (17): 深入字节流 — WeightImporter 与权重加载
1. ONNX 的数据存储格式
ONNX 在存储权重时有两种模式:
Raw Data (二进制流):这是最常用、最高效的模式。所有数据被打包成一个字节流 (std::string) 存储在 raw_data 字段中。这需要我们进行 memcpy。
Typed Data (类型化数组):对于较小的张量,ONNX 可能会直接使用 Protobuf 的重复字段(如 float_data, int32_data)。这需要我们遍历并逐个赋值。
WeightImporter 必须能无缝处理这两种情况。
2. WeightImporter 核心逻辑
A. 数据类型转换
首先,我们需要将 ONNX 的数据类型(onnx::TensorProto::FLOAT)映射到 Mini-Infer 的类型(core::DataType::FLOAT32)。
123456core::DataType WeightImporter::convert_data_type(int onnx_dtype, std::s ...
Mini-Infer (16): 模型导入的核心 — `ImporterContext` 与 `OperatorRegistry`
Mini-Infer (16): 模型导入的核心 — ImporterContext 与 OperatorRegistry
引言:从 Protobuf 到 Graph
在上一篇中,我们实现了 OnnxParser,它能将 .onnx 文件反序列化为 Protobuf 对象。
但这仅仅是第一步。onnx::ModelProto 是一棵复杂的语法树,充满了 Node、Initializer 和 ValueInfo。我们需要一个强大的机制将这些“死数据”转化为 Mini-Infer 中“活的” Graph 对象。
本篇,我们将构建模型导入的两个核心组件:
ImporterContext: 一个“共享黑板”,用于在导入过程中追踪所有的 Tensor 和权重,解决 ONNX 基于名字的连接问题。
OperatorRegistry: 一个“算子工厂”,负责根据 ONNX 的 op_type(如 “Conv”)找到对应的导入逻辑。
1. ImporterContext: 连接一切的桥梁
ONNX 的图结构是基于名字 (String) 的,而 Mini-Infer 的图结构是基于指针 (P ...
Mini-Infer (15): `OnnxParser` 架构设计
Mini-Infer (15): OnnxParser 架构设计
1. 为什么是 ONNX?
ONNX (Open Neural Network Exchange) 是目前 AI 行业的“通用语”。PyTorch, TensorFlow, Keras 等所有主流训练框架都能导出为 ONNX。
ONNX 文件的本质是一个 Protocol Buffers (Protobuf) 序列化对象。
文件结构:ModelProto -> GraphProto -> NodeProto (算子) / TensorProto (权重)。
我们的任务就是编写一个“翻译器”,将 ONNX 的这些 Proto 对象,翻译成 Mini-Infer 的 Graph 和 Node 对象。
2. 架构蓝图:模仿 TensorRT
TensorRT 的 ONNX Parser 架构非常优秀,我们将借鉴它的设计思想:注册机制 (Registration) 与 导入器 (Importer)。
我们不希望写一个巨大的 switch-case 来处理所有 ONNX 算子。我们希望每一个 ONNX 算子 ...
Mini-Infer (14): 迈向 ONNX — `Flatten` 算子与零拷贝视图
Mini-Infer (14): 迈向 ONNX — Flatten 算子与零拷贝视图
1. 为什么需要 Flatten?
在 CNN 网络(如 LeNet-5, VGG)中,数据流通常是这样的: Conv/Pool (4D Tensor) -> Flatten -> Linear (2D Matrix)
Linear 层(全连接层)通常期望输入是一个二维矩阵 [Batch, Features]。而卷积层的输出是四维张量 [Batch, Channel, Height, Width]。
Flatten 的作用就是把 [N, C, H, W] “拍扁” 成 [N, C*H*W]。
2. 算子定义:对齐 ONNX 标准
ONNX 对 Flatten 的定义非常灵活:它有一个 axis 参数。
输入:张量 T
参数:axis (默认为 1)
输出:一个 2D 张量。
维度 0:输入张量从维度 0 到 axis-1 的乘积。
维度 1:输入张量从维度 axis 到最后的乘积。
举例:输入 [2, 3, 4, 5],axis=1。
输出维度 0:2 (只有维度 0) ...
Mini-Infer (13): 端到端验证 — LeNet-5 实战与 PyTorch 对齐
Mini-Infer (13): 端到端验证 — LeNet-5 实战与 PyTorch 对齐
1. 为什么需要端到端测试?
单元测试(Unit Test)只能保证单个算子(如 Conv2D)在特定输入下是正确的。但当几十个算子串联成一个网络时,微小的误差(如 Padding 处理、NCHW vs NHWC 布局差异、float 精度累积)可能会被放大,导致最终分类错误。
端到端测试的目标:
权重加载:验证我们能否正确读取 PyTorch 导出的二进制权重。
计算精度:验证 Mini-Infer 的输出 logits 与 PyTorch 的差异是否在允许范围内(如 1e-5)。
流程打通:验证从图片预处理到最终分类的整个链路。
2. 训练与导出:PyTorch 侧准备 (lenet5_model.py & train_lenet5.py)
首先,我们需要一个“标准答案”。我们在 PyTorch 中定义并训练一个经典的 LeNet-5。
关键细节:
模型定义:我们严格遵循 Conv -> ReLU -> MaxPool 的顺序,这与我们在 Mini-I ...
Mini-Infer (12): 特征提取的收缩 — `Pooling` 算子与架构复用
Mini-Infer (12): 特征提取的收缩 — Pooling 算子与架构复用
引言:不仅仅是卷积
在卷积神经网络(CNN)中,如果说 Conv2D 是“提取特征”的画家,那么 Pooling(池化)就是“提炼精华”的编辑。
没有池化层,特征图(Feature Map)的尺寸会一直保持不变(或仅缓慢减小),这将导致计算量爆炸,且网络难以学习到具有“平移不变性”的高层特征。
1. 定义池化:PoolingParam 与 TensorRT 对齐
1234567struct PoolingParam : public OpParam { PoolingType type; // MAX or AVERAGE int kernel_h, kernel_w; int stride_h, stride_w; int padding_h, padding_w; // ...};
这里有两个值得注意的设计选择:
支持非对称参数:kernel_h vs kernel_w,padding_h vs padding_w。很多简单的框架只支持正方形核, ...
Mini-Infer (11): 下采样利器 — `Pooling` 算子与架构复用之美
Mini-Infer (11): 下采样利器 — Pooling 算子与架构复用之美
1. 架构红利:零成本的扩展性
回想我们在 Blog 7.6 中付出的努力——我们重构了内核注册表,引入了模板元编程。现在,回报来了。
我们要添加 MaxPool 和 AvgPool,不需要重写任何注册逻辑。只需要几行宏定义:
123456789101112// mini_infer/kernels/pooling.h// 1. 定义函数签名template<typename T>using MaxPool2DFunc = void (*)(...);template<typename T>using AvgPool2DFunc = void (*)(...);// 2. 【核心】一键生成注册表!DEFINE_REGISTRY_ALIAS(MaxPool2DRegistry, MaxPool2DFunc);DEFINE_REGISTRY_ALIAS(AvgPool2DRegistry, AvgPool2DFunc);
这就完成了!我们瞬间拥有了两个支持自动后端分发(CPU/ ...
Mini-Infer (10): 卷积的终极形态 - `Conv2D` 实现与 `BiasKernel` 集成
Mini-Infer (10): 卷积的终极形态 - Conv2D 实现与 BiasKernel 集成
1. 最后一块拼图:BiasKernel
在卷积操作 Output = Conv(Input, Weight) + Bias 中,加上偏置(Bias)是最后一步。虽然它计算量不大,但对于内存带宽要求很高。
为了保持架构的一致性,我们将 Bias 加法也封装为一个可调度、可优化的 Kernel。
bias.h: 接口定义
我们继续沿用 TensorRT 风格的注册表模式:
1234567891011121314151617181920212223242526272829303132333435363738// mini_infer/kernels/bias.hnamespace mini_infer {namespace kernels {// 定义函数签名:output += biastemplate<typename T>using BiasFunc = void(*)(T* output, const T* bias, int batch, int channe ...
Mini-Infer (9): 打造高性能算子的基石 — RAII `Buffer` 与 `noexcept` 极致优化
Mini-Infer (9): 打造高性能算子的基石 — RAII Buffer 与 noexcept 极致优化
1. 技术深潜:noexcept 的奥义
在构建高性能 C++ 库时,我们经常看到 noexcept 这个关键字。它不仅仅是一个装饰,它是性能优化的关键开关。
noexcept 是 C++11 引入的一个关键字,它的作用非常明确:告诉编译器(和读代码的人),这个函数保证不会抛出任何异常。
1.1 核心作用:向编译器“承诺”不抛异常
当你把函数声明为 noexcept 时:
123void myFunc() noexcept { // ...}
你是在立下一个“军令状”:“我保证这里面代码无论发生什么,都不会让异常飞出这个函数体。”
如果违背了誓言会怎样? 如果一个被标记为 noexcept 的函数真的抛出了异常,C++ 运行时不会尝试去捕获它,也不会进行“栈展开”(Stack Unwinding,即不会去析构局部对象)。程序会立即调用 std::terminate(),直接粗暴地崩溃(Crash)。
这意味着:noexcept 里的异常是无法被外部的 try-c ...




