文章目录
- pytorch模型部署方式
- 算子的"拆解和兼容"
- [PNNX 计算图](#PNNX 计算图)
pytorch模型部署方式
PyTorch 模型部署主要围绕 TorchScript 和 ONNX 两大核心路径展开,衍生出 "libtorch部署""ONNXRuntime 部署""第三方库部署" 三种主流方式,不同方式在兼容性、性能、轻量化等维度各有优劣。

libtorch 部署:直接基于pytorch底层库libtorch部署,无需中间格式转换。
- 优势:libtorch是pytorch底层实现,支持pytorch所有算子,只要模型再pytorch中运行,导出后必然可在libtorch中推理。
- 劣势:无厂商针对优化,CPU推理速度慢于OpenVINO,NVIDIA GPU推理速度慢于TensorRT。
ONNXRuntime 部署:先将 PyTorch 模型导出为 ONNX 格式(中间件),再通过 ONNX 官方 runtime(ONNXRuntime)推理。
- 优势:跨平台兼容性好,支持 TensorFlow、MXNet、Caffe 等多训练框架导出的 ONNX 模型,可统一推理 workflow。后端可对接厂商优化库(如 GPU 对接 TensorRT),平衡兼容性与性能。
- 劣势:PyTorch 部分算子在 ONNX 中无对应实现 ,可能导致导出失败。PyTorch 导出 ONNX 时,可能将简单算子拆解为复杂子图,降低推理效率。
第三方库部署:以 ONNX 为中间桥梁(少数支持 TorchScript),将 PyTorch 模型转换为第三方推理库格式(如 ncnn、TensorRT、OpenVINO、TNN)。
- 优势:针对特定硬件做深度优化 ------TensorRT 适配 NVIDIA GPU,OpenVINO 适配 Intel CPU,ncnn/TNN 适配移动端 CPU,可获得目标平台的最快推理速度。
- 劣势:PyTorch 算子可能无法导出为 ONNX,ONNX 算子又可能不被第三方库支持,仅能覆盖常规模型,复杂模型易出现 "导出 - 转换" 失败 (算子兼容性层层递减 )。------过度依赖 ONNX 中间格式,若 ONNX 子图复杂,第三方库的图优化难度会陡增。
算子的"拆解和兼容"
在深度学习模型转换中,"Lower" 是编译器核心思想,指用框架支持的多个算子,模拟实现不支持的算子或参数,本质是 "化不兼容为兼容",降低推理库的算子支持成本。
场景 1:若推理库的卷积算子不支持 padding=1,但支持独立的 pad2d 算子,则会将 "带 Padding 的卷积" 拆解为两步:先通过 pad2d 做 Padding,再通过 "无 Padding 的卷积" 完成计算,用 2 个支持算子模拟 1 个不支持的参数场景。
场景 2:多数硬件无独立的 Sigmoid 函数,会将 sigmoid(x) = 1/(1+e^(-x)) 拆解为 4 个基础算子:x 取负 → 计算指数 → 加 1 → 取倒数;若硬件不支持指数算子,还可进一步用 "级数展开" 拆解为乘法、加法,直至适配硬件能力。

基于 Lower的概念,推理库只需支持少量基础算子,即可通过 Lower 覆盖大量复杂算子。面对 Swish(x*sigmoid(x))等新算子,无需新增硬件支持,直接拆解为 "乘法 + Sigmoid" 即可兼容。不同训练框架的高层算子实现细节不同,通过 Lower 拆解为基础子图,可统一适配逻辑。
PyTorch 模型导出过程中,会经历两次 Lower,导致计算图过度细碎:PyTorch → TorchScript → ONNX 。
细碎算子无法做 "层融合" 等激进优化,浪费硬件算力 。无法从 ONNX 图反推原始模型结构 ,不利于算法工程师调试。图中包含大量 "胶水算子"(如 Gather、Unsqueeze),部分第三方库(如 ncnn)可能不支持,导致转换失败。
PNNX 计算图
既然 ONNX 中间件会导致计算图冗余、适配成本高,为何不直接从 PyTorch 生态导出干净的高层算子?基于此需求,2021 年 Q3 腾讯 ncnn 团队推出 PNNX(PyTorch Neural Network Exchange),定位为 "PyTorch 专属模型交换格式",避开 ONNX 中间商,实现 "PyTorch → PNNX → 推理库" 的直接部署。
1.直接复用 PyTorch Python API 的算子接口(如 nn.Linear、nn.Conv2d),参数名与原始 API 完全一致 ,确保 "模型可回溯"。
2.采用类 ncnn 的文本格式,人类可读、可编辑(区别于 TorchScript/ONNX 的二进制格式),方便算法工程师调试、魔改。
3.不追求跨框架兼容,专注解决 PyTorch 模型部署痛点,避免 "兼容所有框架导致的妥协"。

PNNX的部署流程:PyTorch → TorchScript → PNNX → 推理库(如 ncnn)。
选择 TorchScript 而非直接解析 Python 代码,是因为 Python 是编程语言(无结构化 IR),而 TorchScript 是 PyTorch 原生 IR,更贴近原始模型结构,便于 "捏回" 高层算子。
PNNX的优势:
1.支持 Module 合并为大算子 :针对 YOLOv5 Focus 模块等自定义结构,可通过 moduleop=Focus 参数,将 "4 个 slice + 1 个 cat" 的子图直接合并为 Focus 大算子,减少推理步骤。
2.避免计算图冗余 :在PyTorch中编写的简单算术表达式在转换为PNNX后,会保留表达式的整体结构,而不会被拆分成许多小的加减乘除算子。例如表达式add(mul(@0, @1),add(@2, @3))不会被拆分为两个add算子和一个mul算子,而是会生成一个表达式算子Expression ;
3.有大量图优化的技术,包括了算子融合,常量折叠和消除,公共表达式消除等技术。
-
算子融合优化 是一种针对深度学习神经网络的优化策略,通过将多个相邻的计算算子合并为一个算子来减少计算量和内存占用。以卷积层和批归一化层为例,我们可以把两个算子合并为一个新的算子,也就是将卷积的公式带入到批归一化层的计算公式中: C o n v = w x 1 + b B N = γ x 2 − u ^ σ 2 + ϵ + β Conv = wx_1+b\ BN=\gamma\frac{x_2-\hat{u}}{\sigma^2+\epsilon}+\beta \ Conv=wx1+b BN=γσ2+ϵx2−u^+β 其中 x 1 x_1 x1和 x 2 x_2 x2依次是卷积和批归一化层的输入, w w w是卷积层的权重, b b b是卷积层的偏移量, u ^ \hat{u} u^和 σ \sigma σ依次是样本的均值和方差, ϵ \epsilon ϵ为一个极小值。带入后有: F u s e d = γ ( w x + b ) − u ^ σ 2 + ϵ + β Fused =\gamma\frac{(wx+b)-\hat{u}}{\sigma^2+\epsilon}+\beta \ Fused=γσ2+ϵ(wx+b)−u^+β
-
常量折叠是将在编译时期间将表达式中的常量计算出来,然后将结果替换为一个等价的常量,以减少模型在运行时的计算量。
-
常量移除就是将计算图中不需要的常数(计算图推理的过程中未使用)节点删除,从而减少计算图的文件和加载后的资源占用大小。
-
公共表达式消除优化是一种针对计算图中重复计算的优化策略,它可以通过寻找并合并重复计算的计算节点,减少模型的计算量和内存占用。

PNNX计算图的格式
PNNX由图结构(Graph), 运算符(Operator)和操作数(Operand)这三种结构组成的。
- Parameter类
统一存储和管理多种类型的数据(基本类型,数据,字符串),处理灵活参数的场景,只需要传递parameter对象,再根据type字段判断内部数据类型进行相应处理。以下列举了一部分的构造函数。
| 1 | 2 | 3 |
|---|---|---|
| 默认构造函数 | Parameter( ) : type(0) {} | 初始化 type =0 (表示空类型),无数据存储。 |
| 基本类型构造函数 | Parameter(bool _b) : type(1), b(_b) {} | 接收 bool 类型,设置 type=1,并将值存入 b。 |
| 类型转换构造函数 | Parameter(double _d) : type(3), f(_d) {} | 接收 double 类型,自动转换为 float 存入 f,type=3。 |
| 字符串构造函数 | Parameter(const char* _s) : type(4), s(_s) {} | 接收 C 风格字符串 (const char*) ,转换为 std::string存入 s。 |
| 数组 / 容器构造函数 | Parameter(const std::vector< int >& _ai):type(5),ai(_ai){} | 接收std::vector< int > , 存入ai, type = 5标记为int数组 |
| 初始化列表构造函数 | Parameter(const std::initializer_list< int >&_ai):type(5), ai(_ai) {} | 支持用初始化列表(如{1,2,3})直接初始化,自动转换为std::vector |
使用的示例代码如下:
cpp
#include <iostream>
#include <vector>
#include <string>
// 假设已包含上述 Parameter 类定义
int main() {
// 1. 基本类型
Parameter p1(true); // bool 类型 (type=1)
Parameter p2(100); // int 类型 (type=2)
Parameter p3(3.14f); // float 类型 (type=3)
Parameter p4("hello"); // 字符串类型 (type=4)
// 2. 数组/容器类型
Parameter p5({1, 2, 3}); // 初始化列表 -> int数组 (type=5)
Parameter p6(std::vector<float>{1.1f, 2.2f}); // float数组 (type=6)
Parameter p7({"a", "b", "c"}); // 字符串数组 (type=7)
// 3. 类型转换(自动或手动)
Parameter p8(123456LL); // long long -> int (type=2)
Parameter p9(3.14159); // double -> float (type=3)
Parameter p10({3.1, 4.2}); // double列表 -> float数组 (type=6)
return 0;
}
- Attribute类
Attribute 类的核心是封装张量的类型信息、形状信息和原始数据,主要包含以下部分:
成员变量
- int type:数据类型标记(0 表示空,1-9 分别对应不同数据类型,如 f32、i32 等)。
- std::vector< int > shape: 存储张量的形状(维度信息,如 3,32,32 表示 3 个 32×32 的矩阵)。
- std::vector< char > data: 以字节流形式存储原始数据(通用容器,可适配任何类型的二进制数据)。
使用示例代码如下:
cpp
#include <iostream>
#include <vector>
#include <initializer_list>
// 假设已包含上述 Attribute 类定义
int main() {
// 1. 创建一个 2x3 的 float 类型 Attribute
// 形状为 {2,3},数据为 [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
std::vector<float> float_data = {1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f};
Attribute attr1({2, 3}, float_data);
// 此时:
// attr1.type = 1 (因为存储的是float,对应f32)
// attr1.shape = [2,3]
// attr1.data 存储 6个float的字节流 (共6x4=24字节)
// 2. 创建另一个 2x3 的 float 类型 Attribute
std::vector<float> float_data2 = {7.0f, 8.0f, 9.0f, 10.0f, 11.0f, 12.0f};
Attribute attr2({2, 3}, float_data2);
// 3. 沿第一个维度拼接两个 Attribute (结果形状为 [4,3])
Attribute attr3 = attr1 + attr2;
// 拼接后:
// attr3.shape = [4,3] (2+2=4行,列数保持3)
// attr3.data 包含 12个float的字节流 (1.0到12.0)
// 4. 比较两个 Attribute 是否相等
if (attr1 == attr2) {
std::cout << "attr1 和 attr2 相等" << std::endl;
} else {
std::cout << "attr1 和 attr2 不相等" << std::endl; // 此行为输出
}
return 0;
}
- Operand类
Operand 类的核心是描述计算图中一个数据节点的完整信息,主要包含以下部分:
成员变量
- 计算图关系 : (记录数据的生产和消费关系,体现计算图的依赖链)
-- Operator* producer: 指向产生该操作数的算子(生产者),表示 "谁生成了这个数据"。
-- std::vector<Operator*> consumers:存储消费该操作数的所有算子(消费者),表示"这个数据被谁使用"。- 数据属性 :(确保算子间数据类型匹配)
-- int type: 数据类型标记 ( 0 表示空, 1-12 对应不同类型, 如 f32、i32、bool 等, 扩展了复数类型cp64/cp128/cp32)。
-- std::vector shape:数据的形状(维度信息,如 2,3,4 表示三维张量)。
-- std::string name: 操作数的名称 (用于标识和调试) 。- 附加参数 :(存储附加信息,支持灵活的参数配置)
-- std::map<std::string, Parameter> params: 存储与该操作数相关的参数(键值对形式),Parameter 是之前介绍的通用参数类,可存储多种类型的值。- 成员函数
void remove_consumer(const Operator* c): 从 consumers 列表中移除指定的消费者算子,用于更新计算图关系(如删除无用依赖)。
使用示例代码如下:
cpp
int main() {
// 1. 创建两个算子: 乘法算子 (Mul) 和加法算子 (Add)
Operator mul_op;
mul_op.type = "Mul";
Operator add_op;
add_op.type = "Add";
// 2. 创建一个操作数 (mul的输出, add的输入)
Operand mul_output;
mul_output.name = "mul_result";
mul_output.type = 1; // f32 (32位浮点数)
mul_output.shape = {2, 3}; // 形状为 2x3
mul_output.params["scale"] = Parameter(0.5f); // 附加缩放参数
// 3. 建立计算图关系
mul_output.producer = &mul_op; // 生产者是乘法算子
mul_output.consumers.push_back(&add_op); // 消费者是加法算子
mul_op.outputs.push_back(&mul_output); // 乘法算子的输出包含该操作数
add_op.inputs.push_back(&mul_output); // 加法算子的输入包含该操作数
// 4. 查看关系
std::cout << mul_output.name << " 的生产者: " << mul_output.producer->type << std::endl;
std::cout << mul_output.name << " 的消费者: " << mul_output.consumers[0]->type << std::endl;
// 输出:
// mul_result 的生产者: Mul
// mul_result 的消费者: Add
// 5. 移除一个消费者 (假设加法算子不再使用该操作数)
mul_output.remove_consumer(&add_op);
std::cout << "移除后消费者数量: " << mul_output.consumers.size() << std::endl; // 输出: 0
return 0;
}
- Operator类
Operator 类的核心是描述一个计算操作的完整信息,主要包含以下部分:
- 输入输出关系 :(记录数据流向,构建计算依赖关系)
-- std::vector<Operand*> inputs: 存储当前算子的所有输入操作数(Operand 类型的指针),表示该算子需要哪些数据作为输入。
-- std::vector<Operand*> outputs: 存储当前算子的所有输出操作数,表示该算子计算后产生哪些数据。- 基本标识信息 :(标识算子的类型和身份,明确计算逻辑)
-- std::string type: 算子的类型(如 "Conv2d" 表示卷积,"Add" 表示加法,"ReLU" 表示激活函数),用于区分不同的计算逻辑。
-- std::string name: 算子的名称(如 "conv1"、"fc2"),用于唯一标识算子(尤其在复杂计算图中)。
-- std::vector< std::string > inputnames:输入操作数的名称列表,与 inputs 中的操作数一一对应,用于明确每个输入的语义(如 "weight" 表示权重,"bias" 表示偏置)。- 参数与属性 :(存储动态配置和静态张量数据)
-- std::map<std::string, Parameter> params: 存储算子的动态参数 (键值对形式) , Parameter 是通用参数类,可存储多种类型(如整数、浮点数、字符串等),例如卷积的 stride(步长)、padding(填充)。
-- std::map<std::string, Attribute> attrs: 存储算子的属性信息, Attribute 是之前介绍的张量属性类, 通常用于存储固定的张量数据(如预训练的权重参数、偏置张量等)。
使用示例代码如下:
cpp
int main() {
// 1. 创建输入输出操作数
Operand input_feat; // 输入特征图
input_feat.name = "input";
input_feat.type = 1; // f32 (32位浮点数)
input_feat.shape = {1, 3, 224, 224}; // [批量数, 通道数, 高度, 宽度]
Operand weight; // 卷积权重
weight.name = "weight";
weight.type = 1;
weight.shape = {64, 3, 7, 7}; // [输出通道, 输入通道, 卷积核高, 卷积核宽]
Operand output_feat; // 输出特征图 (卷积结果)
output_feat.name = "output";
output_feat.type = 1;
// 2. 创建卷积算子
Operator conv_op;
conv_op.type = "Conv2d"; // 算子类型: 卷积
conv_op.name = "conv1"; // 算子名称: conv1
// 设置输入输出
conv_op.inputs = {&input_feat, &weight}; // 输入: 特征图和权重
conv_op.outputs = {&output_feat}; // 输出: 卷积结果
conv_op.inputnames = {"input", "weight"}; // 输入名称 (与inputs对应)
// 4. 设置动态参数 (卷积配置)
conv_op.params["stride"] = Parameter({2, 2}); // 步长: 2x2
conv_op.params["padding"] = Parameter({3, 3}); // 填充: 3x3
conv_op.params["groups"] = Parameter(1); // 分组数: 1 (标准卷积)
// 5. 设置属性 (权重张量数据)
// 假设 weight_data 是存储卷积核数据的向量
std::vector<float> weight_data(64*3*7*7, 0.0f); // 简化: 全0初始化
conv_op.attrs["weight"] = Attribute(weight.shape, weight_data);
// 6. 打印算子信息
std::cout << "算子类型: " << conv_op.type << ", 名称: " << conv_op.name << std::endl;
std::cout << "输入名称: ";
for (const auto& name : conv_op.inputnames) {
std::cout << name << " ";
}
std::cout << "\n步长参数: " << conv_op.params["stride"].ai[0] << ","
<< conv_op.params["stride"].ai[1] << std::endl;
return 0;
}
运行结果:

这种设计在深度学习框架(如 PyTorch、TensorFlow)的模型表示、优化和部署中被广泛应用,是连接高层计算逻辑与底层硬件执行的关键桥梁。
假设物品们有一个 加法算子(Add),它需要两个输入数据(a和b),计算后输出c = a + b . 具体流程如下:
cpp
// 1. 定义两个输入数据 (Operand)
Operand a;
a.name = "tensor_a"; // 数据的名称 (不是算子名称)
a.type = 1; // 类型: f32 (32位浮点数)
a.shape = {2, 3}; // 形状: 2x3矩阵
a.data = {1.0f, 2.0f, 3.0f, 4.0f, 5.0f, 6.0f}; // 实际数据
Operand b;
b.name = "tensor_b";
b.type = 1;
b.shape = {2, 3};
b.data = {0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f}; // 实际数据
// 2. 定义加法算子 (Operator)
Operator add_op;
add_op.type = "Add"; // 算子类型
add_op.name = "add_layer"; // 算子名称 (与Operand的name无关)
add_op.inputs = {&a, &b}; // 输入是两个Operand的指针 (指向实际数据)
// 3. 算子执行时的逻辑 (伪代码)
Operand c; // 输出数据
c.shape = a.shape; // 输出形状与输入一致
c.data.resize(a.data.size()); // 分配空间
for (int i = 0; i < a.data.size(); ++i) {
c.data[i] = add_op.inputs[0]->data[i] + add_op.inputs[1]->data[i];
}
// 结果: c.data = [1.1, 2.2, 3.3, 4.4, 5.5, 6.6]
add_op.inputs 中存储的 &a 和 &b 是 Operand 指针,指向的是实际参与计算的数据(a.data 和 b.data)。
这些 Operand 包含数据的数值、类型、形状等信息,是算子计算时必须依赖的 "数据实体",而非任何算子的名称。简单说:Operand 是 "数据的容器",inputs 是算子获取 "数据容器" 的入口,算子通过它拿到实际计算所需的数据。
- Graph类
graph 表示和管理完整的计算图。整合了operator和operand,提供了创建,加载,保存计算图的核心能力。
- 核心成员变量 :(统一管理所有计算节点和数据节点)
-- std::vector<Operator*> ops: 存储计算图中所有的算子(Operator 对象指针),代表计算图中的所有计算节点。
-- std::vector<Operand*> operands:存储计算图中所有的操作数(Operand 对象指针),代表计算图中流转的所有数据节点。- 计算图的加载与保存 :(灵活构建计算图)
-- int load(const std::string& parampath, const std::string& binpath): 从参数文件 (parampath) 和二进制数据文件(binpath)加载计算图。返回值为 int 通常用于表示成功(0)或错误码(非 0)
-- int save(const std::string& parampath, const std::string& binpath): 将计算图保存到参数文件和二进制数据文件,便于持久化存储或传输。- 计算图的生成与解析 :(实现计算图的持久化)
-- int python(const std::string& pypath, const std::string& binpath): 可能用于从 Python 脚本 (pypath)生成计算图,并保存二进制数据,通常用于跨语言模型转换。
-- int parse(const std::string& param):从字符串(param)解析计算图结构,例如解析 JSON 或自定义格式的计算图描述。
cpp
#include <iostream>
#include <string>
// 假设已包含 Operator、Operand、Parameter 等类定义
namespace pnnx {
// ... 此处省略之前定义的 Operator、Operand 等类 ...
}
using namespace pnnx;
int main() {
// 1. 创建一个空计算图
Graph graph;
// 2. 创建输入输出操作数
Operand* a = graph.new_operand("a"); // 输入操作数 a
a->type = 1; // f32 类型
a->shape = {2, 2}; // 形状 2x2
Operand* b = graph.new_operand("b"); // 输入操作数 b
b->type = 1;
b->shape = {2, 2};
Operand* c = graph.new_operand("c"); // 输出操作数 c
c->type = 1;
c->shape = {2, 2};
// 3. 创建加法算子,并设置输入输出
Operator* add_op = graph.new_operator("Add", "add1");
add_op->inputs = {a, b}; // 输入为 a 和 b
add_op->outputs = {c}; // 输出为 c
add_op->inputnames = {"x", "y"}; // 输入名称
// 4. 验证计算图内容
std::cout << "计算图包含 " << graph.ops.size() << " 个算子" << std::endl; // 输出: 1
std::cout << "计算图包含 " << graph.operands.size() << " 个操作数" << std::endl; // 输出: 3
// 5. 查找操作数
Operand* found = graph.get_operand("a");
if (found) {
std::cout << "找到操作数: " << found->name << std::endl; // 输出: 找到操作数: a
}
// 6. 保存计算图 (假设成功)
int save_result = graph.save("add.param", "add.bin");
if (save_result == 0) {
std::cout << "计算图保存成功" << std::endl;
}
return 0;
}
后续内容待上传