文章目录
- 一、计算图
- 1.1 计算图定义
- 1.2 计算图的生成
- 1.2.1 **静态计算图(Static Computational Graph)**
- 1.2.2 **动态计算图(Dynamic Computational Graph)**
- 1.3 计算图功能
- 1.3.1 训练阶段
- 1.3.2 推理部署阶段
- 1.4 计算图的调度(执行)
- 二、PNNX计算图
- 2.1 PNNX介绍
- 2.2 PNNX计算图结构
- 2.3 Graph图结构
- 2.4 Operator运算符
- 2.5 Operand操作数
- 2.6 Attribute与Parameter
- 三、RuntimeGraph
- 3.1 RuntimeGraph整体介绍
- 3.2 RuntimeOperator
- 3.3 RuntimeOperand
- 3.4 RuntimeAttribute
- 3.5 RuntimeParam
CLion2023环境搭建配置:
-
CLion配置工程使用外部Linux编译器编译:https://blog.csdn.net/huamu_xingkong/article/details/136944830
-
在使用Clion通过SSH远程连接Linux,在本地工程上开发远程编译出现有关C++的相关头文件找不到,如下图所示,但是可以编译成功:解决办法
一、计算图
1.1 计算图定义
计算图(Computational Graph)是一种用于表示数学运算和数据流的图结构,在深度学习中,它用于描述神经网络中的操作及其依赖关系。计算图由节点和边组成,其中:
-
节点:表示操作(如加法、乘法、激活函数等)或变量(如输入、权重、偏置等)。
-
边:表示数据的流动,通常是张量(Tensor)在节点间传递。
如上图所示,将下面的公式转为计算图表示。
Z = R e L U ( X × Y ) Z= ReLU(X \times Y) Z=ReLU(X×Y)
1.2 计算图的生成
在深度学习框架中可以生成静态图
和动态图两种计算图
。静态生成可以根据前端语言描述的神经网络拓扑结构以及参数变量等信息构建一份固定的计算图。因此静态图在执行期间可以不依赖前端语言描述,常用于神经网络模型的部署,比如移动端人脸识别场景中的应用等。动态图则需要在每一次执行神经网络模型依据前端语言描述动态生成一份临时的计算图,这意味着计算图的动态生成过程灵活可变,该特性有助于在神经网络结构调整阶段提高效率。
主流机器学习框架TensorFlow、MindSpore均支持动态图和静态图模式;PyTorch则可以通过工具将构建的动态图神经网络模型转化为静态结构,以获得高效的计算执行效率。了解两种计算图生成方式的优缺点及构建执行特点,可以针对待解决的任务需求,选择合适的生成方式调用执行神经网络模型。
1.2.1 静态计算图(Static Computational Graph)
也称为定义-运行(define-and-run)模式,静态计算图在程序开始时一次性构建
,然后在执行阶段被多次使用
。图结构固定,便于优化和加速,适合批处理任务。
- 优点
- 高效:由于图在构建时就确定,可以进行更深入的图优化,如内存优化、常量折叠等。
- 易于部署:可以将静态计算图导出为独立文件,用于生产环境中的高效推理。
- 缺点
- 不灵活:不适合处理动态变化的网络结构,特别是在处理可变长度的输入数据时。
1.2.2 动态计算图(Dynamic Computational Graph)
动态计算图在每次前向传播时动态构建,因此图的结构可以根据输入数据变化。其灵活性高,适合需要动态调整结构的任务,如循环神经网络(RNN)处理变长序列。
- 优点
- 灵活:可以处理动态结构和复杂控制流,适合实验和调试。
- 直观:图的构建与运行是同步的,易于理解和调试。
- 缺点
- 性能可能较低:由于图是动态生成的,难以进行高级优化。
- 部署复杂:动态生成的图不易导出为固定的模型格式,可能需要额外的工作来部署。
1.3 计算图功能
计算图在训练阶段和推理部署阶段的功能与实现存在显著差异。这些差异主要源于两个阶段对计算图的不同需求:训练阶段侧重于学习和优化模型参数
,而推理部署阶段则侧重于高效地应用这些参数进行预测
。
1.3.1 训练阶段
计算图在模型训练阶段主要有以下功能:
-
前向传播:计算输入数据通过网络的前向传播,生成预测结果。在训练过程中,前向传播不仅生成输出,还保存中间结果(如激活值),为反向传播计算梯度提供基础。
-
反向传播与梯度计算:计算
损失函数相对于每个参数的梯度
,以指导模型参数的更新。计算图记录了前向传播过程中每个操作的梯度计算规则,通过链式法则自动计算各个参数的梯度。 -
参数更新:利用反向传播得到的梯度,通过优化算法(如SGD、Adam)更新模型参数。计算图通常不直接涉及参数更新,但优化器在图之外使用计算得到的梯度来更新参数。
-
计算图的动态性:支持动态计算图的生成与执行,允许模型结构在训练过程中根据输入数据进行调整。如在处理变长序列或需要动态调整网络结构的任务中,动态计算图能够灵活应对不同的输入数据。
-
正则化操作:添加正则化操作(如Dropout、L2正则化),防止模型过拟合。这些操作主要用于训练阶段,在推理时通常会被移除或替换。
-
图优化:在训练过程中,计算图框架可能会进行优化以加速训练过程,如操作融合、内存优化等。虽然优化重点不同,但一些优化(如操作融合)在训练和推理中都会应用。
-
数据增强与预处理:在训练过程中,计算图框架通常支持数据增强和预处理操作(如图像翻转、归一化等),以提高模型的泛化能力。这些操作通常只在训练时进行,不会在推理部署中使用。
1.3.2 推理部署阶段
-
前向传播:在给定输入的情况下,进行高效的前向传播以生成最终的预测结果。
推理阶段只需要进行前向传播,不涉及反向传播和梯度计算,因此执行更加高效
。 -
图的冻结与优化:推理时使用冻结的计算图,去除训练相关的操作,优化执行路径以提高推理效率。冻结的计算图通常通过各种优化手段,如
常量折叠
、操作融合
、移除不必要的操作(如Dropout)
,确保推理的高效性。 -
硬件适配:根据推理平台的硬件特性(如CPU、GPU、TPU),进行图的调整和优化,以充分利用硬件加速能力。推理阶段的计算图更关注硬件加速的实现,通过图分割与调度、张量分配等技术,最大化硬件资源的利用。
-
模型量化与压缩:将模型中的
浮点数权重和激活量
化为低精度整数
,减少计算量和存储需求,提升推理速度。推理部署阶段通常会进行模型量化和剪枝,以减少模型大小,降低计算成本,适应资源受限的环境。 -
模型导出与跨平台部署:将训练好的模型导出为特定格式(PNNX或ONNX),以便在不同平台上进行部署。推理部署阶段需要确保模型在不同硬件和操作系统上的兼容性和性能。
训练阶段 | 部署阶段 | |
---|---|---|
动态性 vs. 静态性 | 可能需要处理动态计算图,允许网络结构根据输入数据实时变化 | 通常使用静态计算图,以优化后的固定结构进行高效执行 |
计算复杂度 | 需要进行前向传播、反向传播和梯度计算,计算量大,内存占用高 | 只进行前向传播 ,无需计算梯度和更新参数,计算量相对较小,内存占用也较低。 |
优化目标 | 提高模型的收敛速度和准确性,通过梯度计算和参数更新来改进模型性能 | 最大化推理速度和资源利用率,确保模型在各种环境下的高效运行 |
操作内容 | 涉及反向传播、梯度更新、正则化等训练特有的操作 | 这些训练特有的操作通常被移除,图被简化为只包含必要的前向传播操作 |
内存与硬件资源使用 | 内存使用量较大,尤其是在处理大规模模型或分布式训练时,框架需要优化内存分配和使用。 | 内存使用相对较低,更多关注硬件加速和延迟优化,以满足实时或大规模并发推理需求 |
训练阶段关注模型的学习能力和优化过程,而推理阶段则重点在于如何将已经学习到的知识快速、准确地应用到实际数据中。
1.4 计算图的调度(执行)
模型训练就是计算图调度图中算子的执行过程
。训练任务是由设定好的训练迭代次数来循环执行计算图,此时需要优化迭代训练计算图过程中数据流载入和训练(推理)执行等多个任务之间的调度策略。单次迭代需要考虑计算图内部的调度执行问题,根据计算图结构、计算依赖关系、计算控制分析算子的执行调度。优化计算图的调度和执行性能,目的是尽可能充分利用计算资源,提高计算效率,缩短模型训练和推理时间。
算子的执行调度包含两个步骤:
-
根据
拓扑排序算法
,将计算图进行拓扑排序
得到线性的算子调度序列
; -
将序列中的算子分配到
指令流进行运算
,尽可能将序列中的算子并行执行,提高计算资源的利用率。
计算图是一种由依赖边和算子构成的有向无环图,深度学习框架需要将包含这种依赖关系的算子准确地发送到计算资源,比如CPU、GPU、NPU上执行。针对有向无环图,通常使用拓扑排序来得到一串线性的序列。如下图所示一张有向无环图。
图中包含了a、b、c、d、e五个节点和a->d、b->c、c->d、d->e四条边(a->d表示d依赖于a,称为依赖边)。将图的依赖边表达成节点的入度(图论中通常指有向图中某点作为图中边的终点的次数之和),可以得到各个节点的入度信息(a:0、 b:0、 c:1、 d:2、 e:1)。拓扑排序就是不断循环将入度为0的节点取出放入队列中,直至有向无环图中的全部节点都加入到队列中,循环结束。例如,第一步将入度为0的a、b节点放入到队列中,此时有向无环图中c、d的入度需要减1,得到新的入度信息(c:0、d:1、e:1)。以此类推,将所有的节点都放入到队列中并结束排序。
生成调度序列之后,需要将序列中的算子与数据分发到指定的GPU/NPU上执行运算。根据算子依赖关系和计算设备数量,可以将无相互依赖关系的算子分发到不同的计算设备,同时执行运算,这一过程称之为并行计算
,与之相对应的按照序贯顺序在同一设备执行运算被称为串行计算
。这里就不过多讲解。
小结:计算图的基本数据结构是张量,基本运算单元是算子
。计算图是一个有向无环图,图中算子间可以存在直接依赖和间接依赖关系,或者相互关系独立,但不可以出现循环依赖关系。计算图的生成可以分为静态生成和动态生成两种方式。静态图计算效率高,内存使用效率高,但调试性能较差,可以直接用于模型部署。动态图提供灵活的可编程性和可调试性,可实时得到计算结果,在模型调优与算法改进迭代方面具有优势。利用计算图和算子间依赖关系可以解决模型中的算子执行调度问题。
二、PNNX计算图
2.1 PNNX介绍
不同的深度学习框架,如Tensorflow、PyTorch、MindSpore等,都定义了自己的模型的数据结构(计算图),推理系统需要将它们转换到统一的一种数据结构上。开发神经网络交换协议**(Open Neural Network Exchange,ONNX)正是为此目的而设计的。ONNX支持广泛的深度学习运算符集合,并提供了不同训练框架的转换器,例如TensorFlow模型到ONNX模型的转换器、PyTorch模型到ONNX模型的转换器等。模型转换
本质上是将模型这种结构化的数据**,从一种数据结构转换为另一种数据结构的过程。进行模型转换首先要分析两种数据结构的异同点,然后针对结构相同的数据做搬运;对于结构相似的数据做一一映射;对于结构差异较大的数据则需要根据其语义做合理的数据转换;更进一步如果两种数据结构上存在不兼容,则模型转换无法进行。
ONNX具有表达PyTorch模型的能力,并且它是一个开放标准。人们通常使用 ONNX 作为 PyTorch 和推理平台之间的中间表示。然而ONNX仍然存在以下致命问题:
-
ONNX 没有用户可读和可编辑的文件表示形式,这使得用户很难轻松修改计算图或添加自定义运算符。
-
ONNX 的算子定义并不完全符合 PyTorch。将训练好的模型导出为
ONNX
结构之后,模型中的一个复杂算子不仅经常会被拆分成多个细碎的算子,而且为了将这些细碎的算子拼接起来完成原有算子的功能,通常还需要一些称之为“胶水算子”
的辅助算子,例如Gather
和Unsqueeze
等。过于细碎的计算图不利于推理的优化。另外,拆分的层次过于细致,也会导致算法工程师难以将导出的模型和原始模型进行结构上的相互对应。在导出一些 PyTorch 算子时,ONNX 往往会被动添加胶水算子,这使得计算图与 PyTorch 不一致,并可能影响推理效率。 -
ONNX 中的运算符定义中有大量附加参数,这些参数增加了硬件和软件推理实现的负担。
为了解决以上问题,我们选用NCNN
推理框架的计算图格式之一PNNX(PyTorch Neural Network eXchange)
,PNNX 为 PyTorch 提供开放模型格式,PNNX 尝试定义一套与 PyTorch 的 python api 完全对接的算子以及简单易用的格式,使得 PyTorch 模型的转换和互操作更加便捷,它定义的计算图以及高级运算符,与 PyTorch 严格匹配。
通常⼀个网络模型文件从PyTorch 先经历了TorchScript(.pt文件)
的导出,然后再转换为其它模型(ONNX、PNNX),经过 PNNX 的优化可以得到最终的模型文件,这里不用管最后导出为 NCNN 的部分。
1.PNNX 始终保留 PyTorch提供的算子操作
import torch
import torch.nn as nnclass Model(nn.Module):def __init__(self):super(Model, self).__init__()self.attention = nn.MultiheadAttention(embed_dim=256, num_heads=32)def forward(self, x):x, _ = self.attention(x, x, x)return x
下面是 ONNX、TorchScript 和 PNNX 之间的 netron 可视化比较(TorchScript -->ONNX TorchScript --> PNNX ):
ONNX | TorchScript | PNNX |
---|---|---|
PNNX使用模板匹配(pattern matching
)的方法将匹配到的子图(一般在TorchScript中)用对应等价的大算子替换掉
,例如可以将上图子图中的多个小算子(在TorchScript
中被拆分的)重新替换为MultiheadAttention算子,可以看到onnx对算子拆分得更加的细致。
2.PNNX 会保留 PyTorch 所定义的表达式。
import torchdef foo(x, y):return torch.sqrt((2 * x + y) / 12)
ONNX | TorchScript | PNNX |
---|---|---|
在PyTorch
中定义表达式在转换为PNNX
之后,会保留表达式的整体结构,而不会被拆分成多个小的加减乘除算子。例如表达式sqrt(div(add(mul(@0,2),@1,1),12))
不会被拆分为两个mul
算子、一个add
算子、一个div和sqrt算子,而是会生成一个表达式算子Expression
。
3.PNNX 将 PyTorch提供的 torch 函数和 Tensor 成员函数保存为一个运算符。
import torch
import torch.nn.functional as Fclass Model(nn.Module):def __init__(self):super(Model, self).__init__()def forward(self, x):x = F.normalize(x, eps=1e-3)return x
ONNX | TorchScript | PNNX |
---|---|---|
参考资料:https://zhuanlan.zhihu.com/p/427620428、https://github.com/Tencent/ncnn/tree/master/tools/pnnx#the-pnnxparam-format
2.2 PNNX计算图结构
在 PNNX 中,计算图的核心结构包括 Graph
(图结构)、Operator
(运算符)、和 Operand
(操作数)。这些结构共同作用,构成了 PNNX 用于表示和优化神经网络模型的基础。
-
Graph
:Graph
是 PNNX 用于表示整个神经网络模型的计算图,由多个Operator
串联得到的有向无环图,规定了各个计算节点(Operator
)执行的流程和顺序。它包含了模型中的所有运算符(Operator
)和操作数(Operand
),并通过这些组件描述模型的计算流程。 -
Operator
:Operator
是计算图中的节点,表示模型中的具体操作或层次,其包含type
(表示操作的类型,例如卷积、ReLU等)、name
(操作的名称)、params
(参数)和attrs
(属性)等字段。 -
Operand
:Operand
是计算图中的边,表示数据流动。它们通常是张量(Tensor), 用于存放多维数据,作为Operator
的输入和输出,方便数据在计算节点之间传递。 -
Layer
: 计算节点中运算的具体执行者,Layer
类先读取输入张量中的数据,然后对输入张量进行计算,得到的结果存放到计算节点的输出张量中,不同的算子中Layer
的计算过程会不一致。
PNNX计算图 | Linear节点属性 |
---|---|
上图中模型在PyTorch
中的定义如下,其作用是对输入x
进行线性映射(从32维到128维),并对输出进行sigmoid
计算,从而得到最终的计算结果。
class Model(nn.Module):def __init__(self):super(Model, self).__init__()self.linear = nn.Linear(32, 128)def forward(self, x):x = self.linear(x)x = F.sigmoid(x)return x
Linear
层有#0
和#1
两个操作数(Operand),分别为输入和输出张量,形状依次为(1, 32)
和(1, 128)
;Linear
层有两个属性参数
:@weight
和@bias
,用于存储该层的权重数据信息,分别对应权重(即weight
)和偏置(即bias
)。可以看到这两个权重的形状分别为(1, 32)
和(1, 128)
,在后续过程中可以根据需要进行权重加载。Linear
层有三个属性:bias
,in_features
和out_features
,分别表示是否使用偏置项、线性连接层的输入维度和输出维度。
2.3 Graph图结构
Graph在runtime文件夹ir.h中定义的(ncnn中在tools/pnnx/src/ir.h),用于描述神经网络模型的基本数据结构和操作
。该文件定义了一个描述神经网络模型的中间表示**(IR)层次结构**。它包含了表示模型参数、属性、操作数和操作的类,以及操作这些类的方法。这些定义提供了一个抽象层,用于描述和操作神经网络模型。
class Graph
{Operator* new_operator(const std::string& type, const std::string& name);Operator* new_operator_before(const std::string& type, const std::string& name, const Operator* cur);Operand* new_operand(const torch::jit::Value* v);Operand* new_operand(const std::string& name);Operand* get_operand(const std::string& name);std::vector<Operator*> ops; // 运算符(算子)std::vector<Operand*> operands; // 操作数
};
Graph
的核心作用是管理计算图中的运算符和操作数。
Operator
类用来表示计算图中的运算符(算子),比如Convolution, Pooling等算子;Operand
类用来表示计算图中的操作数,即与一个运算符有关的输入和输出张量;Graph
类的成员函数提供了方便的接口用来创建和访问操作符和操作数,以构建和遍历计算图。同时,它也是模型中运算符(算子)和操作数的集合。
2.4 Operator运算符
PNNX
中的运算符结构Operator定义如下:
class Operator
{
public:std::vector<Operand*> inputs;std::vector<Operand*> outputs;// keep std::string typed member the last for cross cxxabi compatibilitystd::string type;std::string name;std::vector<std::string> inputnames;std::map<std::string, Parameter> params;std::map<std::string, Attribute> attrs;
};
在PNNX中,Operator
用来表示一个算子,它由以下几个部分组成:
inputs
:类型为std::vector<operand>
, 表示这个算子在计算过程中所需要的输入操作数operand
;outputs
:类型为std::vector<operand>
, 表示这个算子在计算过程中得到的输出操作数operand
;type
和name
类型均为std::string
, 分别表示该运算符号的类型和名称;params
, 类型为std::map
, 用于存放该运算符的所有参数(例如卷积运算符中的params
中将存放stride
,padding
,kernel size
等信息);attrs
, 类型为std::map
, 用于存放该运算符所需要的具体权重属性(例如卷积运算符中的attrs
中就存放着卷积的权重和偏移量,通常是一个float32
数组)。
2.5 Operand操作数
class Operand
{
public:void remove_consumer(const Operator* c);Operator* producer;std::vector<Operator*> consumers;// 0=null 1=f32 2=f64 3=f16 4=i32 5=i64 6=i16 7=i8 8=u8 9=bool 10=cp64 11=cp128 12=cp32int type;std::vector<int> shape;// keep std::string typed member the last for cross cxxabi compatibilitystd::string name;std::map<std::string, Parameter> params;};
producer
和customers
, 分别表示生成该操作数的操作算子和使用该操作数的操作算子列表。注意,产生这个操作数的算子只能有一个,而使用这个操作数的算子可以有很多个。
2.6 Attribute与Parameter
在PNNX中,**权重数据结构(Attribute)和参数数据结构(Param)**定义如下,它们通常与一个运算符相关联,例如Linear
算子的in_features
属性和weight
权重。
class Parameter
{
public:Parameter(): type(0){}static Parameter parse_from_string(const std::string& value);// 0=null 1=b 2=i 3=f 4=s 5=ai 6=af 7=as 8=othersint type; // 用于表示 Parameter 对象的具体类型// valuebool b;int i;float f;std::vector<int> ai;std::vector<float> af;// keep std::string typed member the last for cross cxxabi compatibilitystd::string s;std::vector<std::string> as;
};
class Attribute
{
public:Attribute(): type(0){}Attribute(const std::initializer_list<int>& shape, const std::vector<float>& t);// 0=null 1=f32 2=f64 3=f16 4=i32 5=i64 6=i16 7=i8 8=u8 9=boolint type;std::vector<int> shape;std::vector<char> data;
};
以上来源于nccn中的pnnx的src。
-
Graph 类 : 是整个计算图的控制中心,它管理着
Operator
和Operand
,即图中的节点和边。Graph
包含了一个ops
向量,用来存储所有的Operator
对象;还有一个operands
向量,用来存储所有的Operand
对象。 -
Operator 类 : 是计算图中的节点,代表着某种操作。每个
Operator
都有一个inputs
向量,用来存储指向输入Operand
的指针;还有一个outputs
向量,用来存储指向输出Operand
的指针。Operator
还包含type
(表示操作的类型,例如卷积、ReLU等)、name
(操作的名称)、params
(参数)和attrs
(属性)等字段。 -
Operand 类 : 是计算图中的边,表示模型中的操作数,代表着数据流动。它有一个
producer
指针,指向生成该Operand
的Operator
,还有一个consumers
向量,存储着所有使用该Operand
的Operator
。Operand
还包含了type
(数据类型)、shape
(张量形状)、name
(操作数名称)和params
(参数)等字段。 -
Parmeter 类:表示操作符的
参数
,这些参数通常是一些标量或向量类型的数据,用于配置操作符的行为。例如,一个卷积操作的核大小、步幅、填充方式等都可以作为Parameter
。 -
Attribute 类 : 表示操作符的权重或常量数据,这些数据通常是在训练阶段确定的,并在推理阶段保持不变。例如,卷积层的权重、偏置项等都可以作为
Attribute
。
小结:
Graph
组织和管理 Operator
和 Operand
,形成完整的计算图。
Operator
通过 inputs
和 outputs
与 Operand
连接,形成数据流动的路径。
Operand
通过 producer
和 consumers
确定数据的流向,并与多个 Operator
关联。
Parameter
和 Attribute
在 PNNX 中分别用于处理操作符的配置参数(卷积核大小,步长等)
和权重数据(卷积层权重,偏置)
。
三、RuntimeGraph
3.1 RuntimeGraph整体介绍
下面对PNNX中的计算图进一步封装,实现RuntimeGraph
,集成了 PNNX 的 Graph
以管理计算节点(RuntimeOperator
)和数据流(Operand
)。
/// 计算图结构,由多个计算节点和节点之间的数据流图组成
class RuntimeGraph {
public:RuntimeGraph(std::string param_path, std::string bin_path);// 计算图的初始化,会调用下面各初始化函数bool Init();private:/*** 初始化kuiper infer计算图节点中的输入操作数* @param inputs pnnx中的输入操作数* @param runtime_operator 计算图节点*/static void InitGraphOperatorsInput(const std::vector<pnnx::Operand *> &inputs,const std::shared_ptr<RuntimeOperator> &runtime_operator);/*** 初始化kuiper infer计算图节点中的输出操作数* @param outputs pnnx中的输出操作数* @param runtime_operator 计算图节点*/static void InitGraphOperatorsOutput(const std::vector<pnnx::Operand *> &outputs,const std::shared_ptr<RuntimeOperator> &runtime_operator);/*** 初始化kuiper infer计算图中的节点属性* @param attrs pnnx中的节点属性* @param runtime_operator 计算图节点*/static voidInitGraphAttrs(const std::map<std::string, pnnx::Attribute> &attrs,const std::shared_ptr<RuntimeOperator> &runtime_operator);/*** 初始化kuiper infer计算图中的节点参数* @param params pnnx中的参数属性* @param runtime_operator 计算图节点*/static voidInitGraphParams(const std::map<std::string, pnnx::Parameter> ¶ms,const std::shared_ptr<RuntimeOperator> &runtime_operator);public:
private:std::string input_name_; /// 计算图输入节点的名称std::string output_name_; /// 计算图输出节点的名称std::string param_path_; /// 计算图的结构文件std::string bin_path_; /// 计算图的权重文件std::vector<std::shared_ptr<RuntimeOperator>> operators_;std::map<std::string, std::shared_ptr<RuntimeOperator>> operators_maps_;std::unique_ptr<pnnx::Graph> graph_; /// pnnx的graph
};
RuntimeGraph
使用了 PNNX 的 Graph
作为其内部数据结构,存储了计算图的节点和边。在 RuntimeGraph
中,graph_
是一个指向 PNNX Graph
的独占指针 (std::unique_ptr<pnnx::Graph>
),用于表示整个计算图。
RuntimeGraph
将 PNNX 的 Operator
和 Operand
结构映射到自定义的 RuntimeOperator
和 RuntimeOperand
,并在初始化Init()函数中设置它们之间的输入输出关系。这些映射操作由以下函数完成:
InitGraphOperatorsInput
:初始化计算图节点中的输入操作数。InitGraphOperatorsOutput
:初始化计算图节点中的输出操作数。InitGraphAttrs
:初始化计算图节点中的属性。InitGraphParams
:初始化计算图节点中的参数
这些函数用于初始化和管理推理节点(RuntimeOperator
)的输入、输出、属性和参数,并且这些函数基于 PNNX Graph
中的数据进行操作。
PNNX的Graph和RuntimeGraph
联系:PNNX 的 Graph
主要用于表示和处理模型的计算图结构,提供了模型的结构化表示。RuntimeGraph
则专注于推理阶段,使用 PNNX Graph
提供的数据来初始化并管理推理过程中的计算节点和数据流。通过这种方式,RuntimeGraph
能够灵活地管理推理过程中的操作节点和数据流,同时充分利用 PNNX 提供的模型表示和处理能力。
在RuntimeGraph中,RuntimeOperator、RuntimeOperand、RuntimeParameter以及RuntimeAttribute的UML结构图如下:
RuntimeOperator
表示计算图中的一个操作节点,每个节点对应着一个特定的计算任务,例如卷积、激活等操作。RuntimeOperand
表示计算节点的输入或输出的数据。它可以视为计算图中节点之间连接的边,传递数据。
RuntimeOperator
与 RuntimeOperand
的关系:
-
输入输出关系:每个
RuntimeOperator
通过input_operands
接收一个或多个RuntimeOperand
作为输入,通过output_operands
产生一个或多个RuntimeOperand
作为输出。这些操作数代表了节点之间传递的数据流。 -
数据流与计算流的联动:
RuntimeOperand
是RuntimeOperator
的输入和输出数据。操作数的数据流(RuntimeOperand
)决定了计算流(RuntimeOperator
)的执行顺序和依赖关系。 -
计算图的构建: 在
RuntimeGraph
中,这些RuntimeOperator
通过RuntimeOperand
连接起来,形成一个有向无环图(DAG),用于描述整个模型的计算流程。
总之,RuntimeOperand
是 RuntimeOperator
的输入和输出,而多个 RuntimeOperator
通过 RuntimeOperand
连接,形成完整的计算图结构。
3.2 RuntimeOperator
RuntimeOperator
是KuiperInfer
计算图中的核心数据结构,是对PNNX::Operator
的再次封装,在runtime_op文件中,它有如下的定义:
/// 计算图中的计算节点
struct RuntimeOperator {virtual ~RuntimeOperator();bool has_forward = false;std::string name; /// 计算节点的名称std::string type; /// 计算节点的类型std::shared_ptr<Layer> layer; /// 节点对应的计算Layerstd::vector<std::string> output_names; /// 节点的输出节点名称std::shared_ptr<RuntimeOperand> output_operands; /// 节点的输出操作数std::map<std::string, std::shared_ptr<RuntimeOperand>> input_operands; /// 节点的输入操作数std::vector<std::shared_ptr<RuntimeOperand>> input_operands_seq; /// 节点的输入操作数,顺序排列std::map<std::string, std::shared_ptr<RuntimeOperator>> output_operators; /// 输出节点的名字和节点对应std::map<std::string, RuntimeParameter*> params; /// 算子的参数信息std::map<std::string, std::shared_ptr<RuntimeAttribute>> attribute; /// 算子的属性信息,内含权重信息
};
以上这段代码定义了一个名为RuntimeOperator
的结构体。结构体包含以下成员变量:
-
name
: 运算符节点的名称,可以用来区分一个唯一节点,例如Conv_1
,Conv_2
等; -
type
: 运算符节点的类型,例如Convolution
,Relu
等类型; -
layer
: 负责完成具体计算的组件,例如在Convolution Operator
中,layer
对输入进行卷积计算,即计算其相应的卷积值; -
input_operands
和output_operands
分别表示该运算符的输入和输出操作数。如果一个运算符(
RuntimeOperator
)的输入大小为(4, 3, 224, 224)
,那么在input_operands
变量中,datas
数组的长度为 4,数组中每个元素的张量大小为(3, 224, 224)
; -
params
是运算符(RuntimeOperator
)的参数信息,包括卷积层的卷积核大小、步长等信息; -
attribute
是运算符(RuntimeOperator
)的权重、偏移量信息,例如Matmul
层或Convolution
层需要的权重数据; -
其他变量的含义可参考注释。
在这个过程中,需要先从 PNNX::Operator
中提取数据信息(包括 Operand
和 Operator
结构),并依次填入到 KuiperInfer
对应的数据结构中。相应的代码如下所示,由于篇幅原因,在课件中省略了一部分内容,完整的代码可以在runtime_ir.cpp 文件夹中查看。
bool RuntimeGraph::Init() {if (this->bin_path_.empty() || this->param_path_.empty()) {LOG(ERROR) << "The bin path or param path is empty";return false;}this->graph_ = std::make_unique<pnnx::Graph>();int load_result = this->graph_->load(param_path_, bin_path_);if (load_result != 0) {LOG(ERROR) << "Can not find the param path or bin path: " << param_path_<< " " << bin_path_;return false;}std::vector<pnnx::Operator *> operators = this->graph_->ops;// 在for循环中依次对每个运算符进行处理for (const pnnx::Operator *op : operators) {std::shared_ptr<RuntimeOperator> runtime_operator = std::make_shared<RuntimeOperator>();// 初始化算子的名称,提取PNNX运算符中的名字(name)和类型(type).runtime_operator->name = op->name;runtime_operator->type = op->type;// 初始化算子中的inputconst std::vector<pnnx::Operand *> &inputs = op->inputs;InitGraphOperatorsInput(inputs, runtime_operator);// 记录输出operand中的名称const std::vector<pnnx::Operand *> &outputs = op->outputs;InitGraphOperatorsOutput(outputs, runtime_operator);// 初始化算子中的attribute(权重)const std::map<std::string, pnnx::Attribute> &attrs = op->attrs;InitGraphAttrs(attrs, runtime_operator);// 初始化算子中的parameterconst std::map<std::string, pnnx::Parameter> ¶ms = op->params;InitGraphParams(params, runtime_operator);this->operators_.push_back(runtime_operator);this->operators_maps_.insert({runtime_operator->name, runtime_operator});}return true;
}
RuntimeGraph::Init()
函数**负责从 PNNX 格式的计算图文件中读取图结构,并将其转换为适用于 RuntimeGraph
的 RuntimeOperator
格式。**这些操作包括加载图文件、解析操作符的输入输出、初始化属性和参数等。这个函数的顺利执行是后续图推理或训练的基础。
3.3 RuntimeOperand
/// 计算节点输入输出的操作数
struct RuntimeOperand {std::string name; /// 操作数的名称std::vector<int32_t> shapes; /// 操作数的形状std::vector<std::shared_ptr<Tensor<float>>> datas; /// 存储操作数RuntimeDataType type = RuntimeDataType::kTypeUnknown; /// 操作数的类型,一般是float
};
RuntimeOperand
是在计算图中表示操作数的数据结构,用于存储每个计算节点的输入和输出。RuntimeGraph::InitGraphOperatorsInput
和 RuntimeGraph::InitGraphOperatorsOutput
两个函数负责初始化 RuntimeOperator
中的输入和输出操作数。这两个函数在上面RuntimeGraph::Init()
中调用的,它们对 RuntimeOperand
的初始化如下:
void RuntimeGraph::InitGraphOperatorsInput(const std::vector<pnnx::Operand *> &inputs,const std::shared_ptr<RuntimeOperator> &runtime_operator) {// 遍历所有的输入张量for (const pnnx::Operand *input : inputs) {if (!input) {continue;}const pnnx::Operator *producer = input->producer;std::shared_ptr<RuntimeOperand> runtime_operand = std::make_shared<RuntimeOperand>();// 设置操作数的名称runtime_operand->name = producer->name;// 设置操作数的形状runtime_operand->shapes = input->shape;// 设置操作数的数据类型switch (input->type) {case 1:runtime_operand->type = RuntimeDataType::kTypeFloat32;break;case 0:runtime_operand->type = RuntimeDataType::kTypeUnknown;break;default:LOG(FATAL) << "Unknown input operand type: " << input->type;}// 将初始化的操作数添加到 RuntimeOperator 的输入操作数映射和顺序列表中runtime_operator->input_operands.insert({producer->name, runtime_operand});runtime_operator->input_operands_seq.push_back(runtime_operand);}
}
**这段代码的两个参数分别是来自 PNNX
中的一个运算符的所有输入操作数(Operand
)和待初始化的 RuntimeOperator
。**在以下的循环中:
for (const pnnx::Operand *input : inputs)
需要依次将每个 Operand
中的数据信息填充到新初始化的 RuntimeOperand
中,包括 type
, name
, shapes
等信息,并记录输出这个操作数(Operand
)的运算符(producer
)。然后,再将数据完备的 RuntimeOperand
插入到待初始化的 RuntimeOperator
中。
然后InitGraphOperatorsOutput初始化计算节点(RuntimeOperator
)的输出操作数。在这个函数中,虽然没有直接初始化 RuntimeOperand
,但它处理了输出操作数的关联信息:
void RuntimeGraph::InitGraphOperatorsOutput(const std::vector<pnnx::Operand *> &outputs,const std::shared_ptr<RuntimeOperator> &runtime_operator) {for (const pnnx::Operand *output : outputs) {if (!output) {continue;}const auto &consumers = output->consumers;for (const auto &c : consumers) {runtime_operator->output_names.push_back(c->name);}}
}
这段代码的两个参数分别是来自 PNNX
中的一个运算符的所有输出操作数(Operand
)和待初始化的 RuntimeOperator
。在这里,只需要记录操作数的消费者的名字(customer.name
)即可。后面,我们才会对 RuntimeOperator
中的输出操作数(RuntimeOperand
)进行构建。
RuntimeGraph::InitGraphOperatorsInput
主要负责初始化 RuntimeOperand
,包括其名称、形状和数据类型,并将其添加到对应 RuntimeOperator
的输入操作数中。
RuntimeGraph::InitGraphOperatorsOutput
主要负责记录输出操作数的消费者信息,并将消费者的名称存储在 RuntimeOperator
的 output_names
中,但不直接初始化 RuntimeOperand
。
3.4 RuntimeAttribute
RuntimeAttribute
是用来存储计算图节点(RuntimeOperator
)的属性信息的结构体,通常包含权重参数
、形状信息和数据类型。
/// 计算图节点的属性信息
struct RuntimeAttribute {std::vector<char> weight_data; /// 节点中的权重参数std::vector<int> shape; /// 节点中的形状信息RuntimeDataType type = RuntimeDataType::kTypeUnknown; /// 节点中的数据类型// 从节点中加载权重参数template <class T> //std::vector<T> get(bool need_clear_weight = true);// 清除权重void ClearWeight();
};
RuntimeGraph::InitGraphAttrs
函数则负责从 pnnx
的节点属性(pnnx::Attribute
)中初始化并填充 RuntimeAttribute
,并将这些属性关联到对应的 RuntimeOperator
中。
void RuntimeGraph::InitGraphAttrs(const std::map<std::string, pnnx::Attribute> &attrs,const std::shared_ptr<RuntimeOperator> &runtime_operator) {for (const auto &[name, attr] : attrs) {switch (attr.type) {case 1: {std::shared_ptr<RuntimeAttribute> runtime_attribute = std::make_shared<RuntimeAttribute>();// 设置属性的数据类型runtime_attribute->type = RuntimeDataType::kTypeFloat32;// 将 pnnx::Attribute 中的权重数据拷贝到 RuntimeAttribute 的 weight_data 中runtime_attribute->weight_data = attr.data;// 将 pnnx::Attribute 中的形状信息拷贝到 RuntimeAttribute 的 shape 中runtime_attribute->shape = attr.shape;// 将已初始化的 RuntimeAttribute 添加到 RuntimeOperator 的 attribute 映射中runtime_operator->attribute.insert({name, runtime_attribute});break;}default: {LOG(FATAL) << "Unknown attribute type: " << attr.type;}}}
}
这段代码的两个参数分别是来自 PNNX
中的一个运算符的所有权重数据结构(Attribute
)和待初始化的RuntimeOperator
。在以下的循环中,
for (const auto& [name, attr] : attrs)
需要依次将 Attribute
中的数据信息填充到新初始化的 RuntimeAttribute
中,包括 type
, weight_data
, shapes
等信息。然后,将数据完备的 RuntimeAttribute
插入到待初始化的 RuntimeOperator
中,同时记录该权重的名字。
在Linear
层中这里的name
就是weight
或bias
, 对于前文测试模型中的Linear
层,它的weight shape
是(32, 128),weight_data
就是32 x 128个float
数据。
3.5 RuntimeParam
/// 计算节点中的参数信息
struct RuntimeParameter { virtual ~RuntimeParameter() = default;explicit RuntimeParameter(RuntimeParameterType type = RuntimeParameterType::kParameterUnknown) : type(type) {}RuntimeParameterType type = RuntimeParameterType::kParameterUnknown;
};struct RuntimeParameterInt : public RuntimeParameter {RuntimeParameterInt() : RuntimeParameter(RuntimeParameterType::kParameterInt) {}int value = 0;
};
RuntimeParameter 是一个抽象类或接口,用于表示运行时参数。在推理系统中,运行时参数通常用于表示模型中节点的配置或权重等数据。它有多个子类,分别对应不同的数据类型,如 int
、float
、string
、bool
以及它们的数组类型。
RuntimeGraph::InitGraphParams
函数的作用是从 pnnx::Parameter
中读取节点参数数据,并将其转换为 RuntimeParameter
的具体子类对象,然后将这些参数与对应的 RuntimeOperator
关联。
void RuntimeGraph::InitGraphParams(const std::map<std::string, pnnx::Parameter> ¶ms,const std::shared_ptr<RuntimeOperator> &runtime_operator) {for (const auto &[name, parameter] : params) {const int type = parameter.type;switch (type) {// 对应不同的参数类型,根据类型创建对应的 RuntimeParameter 子类对象case int(RuntimeParameterType::kParameterUnknown): {RuntimeParameter *runtime_parameter = new RuntimeParameter;runtime_operator->params.insert({name, runtime_parameter});break;}case int(RuntimeParameterType::kParameterBool): {RuntimeParameterBool *runtime_parameter = new RuntimeParameterBool;runtime_parameter->value = parameter.b;runtime_operator->params.insert({name, runtime_parameter});break;}......case int(RuntimeParameterType::kParameterStringArray): {RuntimeParameterStringArray *runtime_parameter = new RuntimeParameterStringArray;runtime_parameter->value = parameter.as;runtime_operator->params.insert({name, runtime_parameter});break;}default: {LOG(FATAL) << "Unknown parameter type: " << type;}}}
}
通过这种方式,每个 RuntimeOperator
节点都能够访问和使用其参数信息,从而在计算过程中可以依据这些参数进行操作。