自制深度学习推理框架之计算图设计

CLion2023环境搭建配置:

一、计算图

1.1 计算图定义

计算图(Computational Graph)是一种用于表示数学运算和数据流的图结构,在深度学习中,它用于描述神经网络中的操作及其依赖关系。计算图由节点和边组成,其中:

  • 节点:表示操作(如加法、乘法、激活函数等)或变量(如输入、权重、偏置等)。

  • :表示数据的流动,通常是张量(Tensor)在节点间传递。

    ../_images/simpledag.png

如上图所示,将下面的公式转为计算图表示。
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 训练阶段
../_images/graph.png

计算图在模型训练阶段主要有以下功能:

  • 前向传播:计算输入数据通过网络的前向传播,生成预测结果。在训练过程中,前向传播不仅生成输出,还保存中间结果(如激活值),为反向传播计算梯度提供基础。

  • 反向传播与梯度计算:计算损失函数相对于每个参数的梯度,以指导模型参数的更新。计算图记录了前向传播过程中每个操作的梯度计算规则,通过链式法则自动计算各个参数的梯度。

  • 参数更新:利用反向传播得到的梯度,通过优化算法(如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结构之后,模型中的一个复杂算子不仅经常会被拆分成多个细碎的算子,而且为了将这些细碎的算子拼接起来完成原有算子的功能,通常还需要一些称之为“胶水算子”的辅助算子,例如GatherUnsqueeze等。过于细碎的计算图不利于推理的优化。另外,拆分的层次过于细致,也会导致算法工程师难以将导出的模型和原始模型进行结构上的相互对应。在导出一些 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 的部分。

image-20240815181059229

1.PNNX 始终保留 PyTorch提供的算子操作

import torch
import torch.nn as nn

class 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 ):

ONNXTorchScriptPNNX
MultiheadAttention.onnxMultiheadAttention.ptimg

PNNX使用模板匹配(pattern matching)的方法将匹配到的子图(一般在TorchScript中)用对应等价的大算子替换掉,例如可以将上图子图中的多个小算子(在TorchScript中被拆分的)重新替换为MultiheadAttention算子,可以看到onnx对算子拆分得更加的细致。

2.PNNX 会保留 PyTorch 所定义的表达式。

import torch

def foo(x, y):
    return torch.sqrt((2 * x + y) / 12)
ONNXTorchScriptPNNX
math.onnxmath.ptmath.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 F

class Model(nn.Module):
    def __init__(self):
        super(Model, self).__init__()

    def forward(self, x):
        x = F.normalize(x, eps=1e-3)
        return x
ONNXTorchScriptPNNX
函数.onnxfunction.ptfunction.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(属性)等字段。

  • OperandOperand 是计算图中的边,表示数据流动。它们通常是张量(Tensor), 用于存放多维数据,作为 Operator 的输入和输出,方便数据在计算节点之间传递。

  • Layer: 计算节点中运算的具体执行者,Layer类先读取输入张量中的数据,然后对输入张量进行计算,得到的结果存放到计算节点的输出张量中,不同的算子中Layer的计算过程会不一致

PNNX计算图Linear节点属性
image-20240815204526749image-20240815204639847

上图中模型在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_featuresout_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的核心作用是管理计算图中的运算符和操作数

  1. Operator类用来表示计算图中的运算符(算子),比如Convolution, Pooling等算子;
  2. Operand类用来表示计算图中的操作数,即与一个运算符有关的输入和输出张量
  3. 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 compatibility
    std::string type;
    std::string name;

    std::vector<std::string> inputnames;
    std::map<std::string, Parameter> params;
    std::map<std::string, Attribute> attrs;
};

在PNNX中,Operator用来表示一个算子,它由以下几个部分组成:

  1. inputs:类型为std::vector<operand>, 表示这个算子在计算过程中所需要的输入操作数operand
  2. outputs:类型为std::vector<operand>, 表示这个算子在计算过程中得到的输出操作数operand
  3. typename类型均为std::string, 分别表示该运算符号的类型和名称
  4. params, 类型为std::map, 用于存放该运算符的所有参数(例如卷积运算符中的params中将存放stride, padding, kernel size等信息);
  5. 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=cp32
    int type;
    std::vector<int> shape;

    // keep std::string typed member the last for cross cxxabi compatibility
    std::string name;

    std::map<std::string, Parameter> params;

};

producercustomers, 分别表示生成该操作数的操作算子使用该操作数的操作算子列表。注意,产生这个操作数的算子只能有一个,而使用这个操作数的算子可以有很多个。

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=others
    int type;  // 用于表示 Parameter 对象的具体类型

    // value
    bool b;
    int i;
    float f;
    std::vector<int> ai;
    std::vector<float> af;

    // keep std::string typed member the last for cross cxxabi compatibility
    std::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=bool
    int type;
    std::vector<int> shape;

    std::vector<char> data;
};

以上来源于nccn中的pnnx的src。

image-20240816111323383
  • Graph 类 : 是整个计算图的控制中心,它管理着 OperatorOperand,即图中的节点和边。Graph 包含了一个 ops 向量,用来存储所有的 Operator 对象;还有一个 operands 向量,用来存储所有的 Operand 对象。

  • Operator 类 : 是计算图中的节点,代表着某种操作。每个 Operator 都有一个 inputs 向量,用来存储指向输入 Operand 的指针;还有一个 outputs 向量,用来存储指向输出 Operand 的指针。Operator 还包含 type(表示操作的类型,例如卷积、ReLU等)、name(操作的名称)、params(参数)和 attrs(属性)等字段。

  • Operand 类 : 是计算图中的边,表示模型中的操作数,代表着数据流动。它有一个 producer 指针,指向生成该 OperandOperator,还有一个 consumers 向量,存储着所有使用该 OperandOperatorOperand 还包含了 type(数据类型)、shape(张量形状)、name(操作数名称)和 params(参数)等字段。

  • Parmeter 类:表示操作符的参数,这些参数通常是一些标量或向量类型的数据,用于配置操作符的行为。例如,一个卷积操作的核大小、步幅、填充方式等都可以作为 Parameter

  • Attribute 类 : 表示操作符的权重或常量数据,这些数据通常是在训练阶段确定的,并在推理阶段保持不变。例如,卷积层的权重、偏置项等都可以作为 Attribute

小结:

Graph 组织和管理 OperatorOperand,形成完整的计算图。

Operator 通过 inputsoutputsOperand 连接,形成数据流动的路径。

Operand 通过 producerconsumers 确定数据的流向,并与多个 Operator 关联。

ParameterAttribute 在 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 void
  InitGraphAttrs(const std::map<std::string, pnnx::Attribute> &attrs,
                 const std::shared_ptr<RuntimeOperator> &runtime_operator);

  /**
   * 初始化kuiper infer计算图中的节点参数
   * @param params pnnx中的参数属性
   * @param runtime_operator 计算图节点
   */
  static void
  InitGraphParams(const std::map<std::string, pnnx::Parameter> &params,
                  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 的 OperatorOperand 结构映射到自定义的 RuntimeOperatorRuntimeOperand,并在初始化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 表示计算节点的输入或输出的数据。它可以视为计算图中节点之间连接的边,传递数据。

RuntimeOperatorRuntimeOperand关系

  • 输入输出关系:每个 RuntimeOperator 通过 input_operands 接收一个或多个 RuntimeOperand 作为输入,通过 output_operands 产生一个或多个 RuntimeOperand 作为输出。这些操作数代表了节点之间传递的数据流。

  • 数据流与计算流的联动: RuntimeOperandRuntimeOperator 的输入和输出数据。操作数的数据流(RuntimeOperand)决定了计算流(RuntimeOperator)的执行顺序和依赖关系。

  • 计算图的构建: 在 RuntimeGraph 中,这些 RuntimeOperator 通过 RuntimeOperand 连接起来,形成一个有向无环图(DAG),用于描述整个模型的计算流程。

总之,RuntimeOperandRuntimeOperator 的输入和输出,而多个 RuntimeOperator 通过 RuntimeOperand 连接,形成完整的计算图结构。

3.2 RuntimeOperator

RuntimeOperatorKuiperInfer计算图中的核心数据结构,是对PNNX::Operator的再次封装,在runtime_op文件中,它有如下的定义:

/// 计算图中的计算节点
struct RuntimeOperator {
  virtual ~RuntimeOperator();

  bool has_forward = false;
  std::string name;              /// 计算节点的名称
  std::string type;              /// 计算节点的类型
  std::shared_ptr<Layer> layer;  /// 节点对应的计算Layer

  std::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的结构体。结构体包含以下成员变量:

  1. name: 运算符节点的名称,可以用来区分一个唯一节点,例如 Conv_1, Conv_2 等;

  2. type: 运算符节点的类型,例如 Convolution, Relu 等类型;

  3. layer: 负责完成具体计算的组件,例如在 Convolution Operator 中,layer 对输入进行卷积计算,即计算其相应的卷积值;

  4. input_operandsoutput_operands 分别表示该运算符的输入和输出操作数

    如果一个运算符(RuntimeOperator)的输入大小为 (4, 3, 224, 224),那么在 input_operands 变量中,datas 数组的长度为 4,数组中每个元素的张量大小为 (3, 224, 224)

  5. params 是运算符(RuntimeOperator)的参数信息,包括卷积层的卷积核大小、步长等信息;

  6. attribute 是运算符(RuntimeOperator)的权重、偏移量信息,例如 Matmul 层或 Convolution 层需要的权重数据;

  7. 其他变量的含义可参考注释。

在这个过程中,需要先从 PNNX::Operator提取数据信息(包括 OperandOperator 结构),并依次填入到 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;

     // 初始化算子中的input
     const 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);

     // 初始化算子中的parameter
     const std::map<std::string, pnnx::Parameter> &params = 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 格式的计算图文件中读取图结构,并将其转换为适用于 RuntimeGraphRuntimeOperator 格式。**这些操作包括加载图文件、解析操作符的输入输出、初始化属性和参数等。这个函数的顺利执行是后续图推理或训练的基础。

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::InitGraphOperatorsInputRuntimeGraph::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 主要负责记录输出操作数的消费者信息,并将消费者的名称存储在 RuntimeOperatoroutput_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就是weightbias, 对于前文测试模型中的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 是一个抽象类或接口,用于表示运行时参数。在推理系统中,运行时参数通常用于表示模型中节点的配置或权重等数据。它有多个子类,分别对应不同的数据类型,如 intfloatstringbool 以及它们的数组类型。

RuntimeGraph::InitGraphParams 函数的作用是从 pnnx::Parameter 中读取节点参数数据,并将其转换为 RuntimeParameter 的具体子类对象,然后将这些参数与对应的 RuntimeOperator 关联。

void RuntimeGraph::InitGraphParams(
    const std::map<std::string, pnnx::Parameter> &params,
    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 节点都能够访问和使用其参数信息,从而在计算过程中可以依据这些参数进行操作。

  • 12
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Super.Bear

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值