😺PNNX计算图结构剖析

计算图的概念

PNNX是PyTorchNeural Network Exchange的缩写,其愿景是能够将PyTorch模型文件直接导出为高效、简洁的计算图。

  1. Operator: 深度学习计算图中的计算节点。

  2. Graph: 有多个Operator串联得到的有向无环图,规定了各个计算节点(Operator)执行的流程和顺序。

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

  4. Tensor: 用于存放多维数据的数据结构,方便数据在计算节点之间传递,同时该结构也封装矩阵乘、点积等与矩阵相关的基本操作。

ONNX的诟病

  1. ONNX不具有良好的可读性和编辑性,让用户很难修改计算图和自定义相关算子

  2. ONNX定义的算子和Pytorch并不是保持一致的,当把Pytorch模型导出为ONNX时,会出现很多额外的琐碎的算子,这无疑增加了推理的成本

  3. ONNX中有大堆额外的参数用来和众多的机器学习框架相适应,这无疑增加了模型推理时的软硬件成本

PNNX算子

可视化ONNX,TorchScript,PNNX导出的算子,Pytorch源码如下

import torch
import torch.nn as nn

class Focus(nn.Module):
    # Focus wh information into c-space
    def __init__(self, c1, c2, k=1, s=1, p=None, g=1, act=True):  # ch_in, ch_out, kernel, stride, padding, groups
        super().__init__()
        self.conv = Conv(c1 * 4, c2, k, s, p, g, act)

    def forward(self, x):  # x(b,c,w,h) -> y(b,4c,w/2,h/2)
        return self.conv(torch.cat([x[..., ::2, ::2], x[..., 1::2, ::2], x[..., ::2, 1::2], x[..., 1::2, 1::2]], 1))

PNNX 模型优化

pnnx.param的格式

  • 类型:类型名称,例如Conv2d、ReLU等

  • 名称:此运算符的名称

  • 输入计数:此运算符需要的输入操作数的数量

  • 输出计数:此运算符产生的输出操作数的数量

  • 输入操作数:所有输入blob名称的名称列表,用空格分隔

  • 输出操作数:所有输出blob名称的名称列表,用空格分隔

  • 运算符参数:键值对列表,用空格分隔,运算符权重以@符号为前缀,张量形状以#符号为前缀,输入参数键以$符号为前缀

pnnx.bin的格式

二进制权重文件是由相应的算子名称和权重名称构成

举例来说, nn.Conv2d conv_0 1 1 0 1 bias=1 dilation=(1,1) groups=1 in_channels=12 kernel_size=(3,3) out_channels=16 padding=(0,0) stride=(1,1) @bias=(16) @weight=(16,12,3,3) 会将 conv_0.weight 和 conv_0.bias 放到 pnnx.bin zip 压缩包中

辅助类

先介绍两个辅助类:StoreZipReader,StoreZipWriter分别用于压缩文件的读取和写入

取消字节对齐

__attribute__ ((__packed__))关键字,它可以做到让我们的结构体,按照紧凑排列的方式,占用内存。

显而易见,test1结构体里面没有加关键字,它采用了4字节对齐的方式,即使是一个char变量,也占用了4字节内存,int占用4字节,共占用了8字节内存,这在64位机器当中将会更大。 而test2结构体,再加上关键字之后,结构体内的变量采用内存紧凑的方式排列,char类型占用1字节,int占用4字节,总共占用了5个字节的内存。

为了让数据结构以最优的方式存储,处理,保证读写数据结构都一一对齐,我们往往采用3种方式:

1.程序作者,手动对齐,将数据按从小到大的顺序排列,尽量凑齐。

2.使用#pragma pack (n)来指定数据结构的对齐值。

3.使用 __attribute__ ((packed)) ,让编译器取消结构在编译过程中的优化对齐,按照实际占用字节数进行对齐,这样子两边都需要使用 __attribute__ ((packed))取消优化对齐,就不会出现对齐的错位现象。

相关结构体

zip格式压缩包主要由三大部分组成:数据区中央目录记录区(也有叫核心目录记录)中央目录记录尾部区

Zip格式结构图总览

CRC循环冗余校验

CRC 算法的基本思想是将传输的数据当做一个位数很长的数。将这个数除以另一个数。得到的余数作为校验数据附加到原数据后面。

实际应用时,发送方和接收方按以下方式通信:

  1. 发送方和接收方在通信前,约定好一个预设整数作为除数。

  2. 发送方在发送前根据原始数据和约定好的除数进行模二除法(按位异或)运算生成余数(即CRC码),然后将其附加到原始数据后面一起发送给接收方。

  3. 接收方收到后将其模二除以约定好的除数,当且仅当余数为0时接收方认为没有差错。

  • 示例 假设要传输的原始数据为1101011011B,发送方和接收方在通信前约定好的除数为10011B。由于除数10011B是五位数(5bit),那么假设余数(即CRC码)为四位数(4bit)。因为现在余数未知,所以在进行模二除法运算前先将余数设为0000B,即待发送的数据为11010110110000B。下面开始进行模二除法运算来确定余数(即CRC码):

可见余数(即CRC码)为1110B,因此发送方实际发送的是11010110111110B。接收方在接收后需要将其模二除以10011B来进行CRC校验:

可见余数为0,因此本次通信没有差错。

StoreZipReader

重点说下open这个函数

回顾C语言文件处理函数

  • fread函数的原型如下:

其中,ptr是指向缓冲区的指针,size是要读取的每个元素的大小(以字节为单位),count是要读取的元素个数,stream是文件指针。例如,要读取一个包含100个int类型变量的数据块,可以这样调用fread函数:

这将从文件中读取100个int类型变量到缓冲区中。

  • ftell 函数用于获取文件指针的当前位置。它的原型如下:

    • stream:文件指针,是一个指向 FILE 对象的指针,通常通过 fopen 函数获得。

    ftell 函数返回当前文件指针的位置作为一个长整数(long int)。一般情况下,返回值表示从文件的起始位置到当前文件指针的字节数偏移量。

  • fseek 函数用于设置文件指针的位置,即将文件流中的读/写位置设置到指定的位置。它的原型如下:

    • stream:文件指针,是一个指向 FILE 对象的指针,通常通过 fopen 函数获得。

    • offset:偏移量,即要设置的相对位置。正值表示向文件末尾方向移动,负值表示向文件开始方向移动。

    • origin:起始位置,可以是以下值之一:

      • SEEK_SET:从文件开始位置计算偏移,offset 必须是非负值。

      • SEEK_CUR:从当前位置计算偏移,offset 可以是负值。

      • SEEK_END:从文件末尾位置计算偏移,offset 可以是负值。

    例如,如果要将文件指针设置到文件开头,可以使用:

    这将把文件指针 fp 移动到文件的起始位置。如果要将文件指针向后移动 10 个字节,可以使用:

    如果要将文件指针设置到文件末尾前 20 个字节处,可以使用:

    在使用 fseek 函数时,需要注意的一点是,该函数可能返回一个非零值,表示操作失败。你可以使用 ftell 函数来获取当前文件指针的位置。

  • fwrite是一个用于将数据块写入文件的C标准库函数。它的声明如下:

    这个函数的作用是将位于 ptr 指向的内存块中的数据写入 stream 指向的文件中。参数说明如下:

    • ptr: 指向要写入文件的数据块的指针。

    • size: 要写入的每个数据块的字节数。

    • count: 要写入的数据块的个数。

    • stream: 指向要写入的文件的 FILE 指针。

一些标志位的含义

  • signature == 0x04034b50

用来存放本地文件头标识:0x04034b50,用于解压时候,读取判断文件头的开始

  • lfh.flag & 0x08

如果lfh.flag的第四个bit位等于1,则本地头部的crc-32、压缩大小和未压缩大小字段被设置为零

  • signature == 0x02014b50

记录核心目录文件头标识:0x02014b50,用于解压时候,查找判断是否是中央目录的开始位置

  • signature == 0x06054b50

中央目录记录尾部开头标记:0x06054b50,用于解压时,查找判断中央目录尾部的起始位置

StoreZipWriter

重点是write_fileclose这两个函数,存储元数据并定义压缩包关键字段的值

PNNX中的图结构(Graph)

Graph的核心作用是管理计算图中的运算符和操作数。下面我们将对这两个概念进行说明:

  1. Operator类用来表示计算图中的运算符(算子),比如一个模型中的Convolution, Pooling等算子;

  2. Operand类用来表示计算图中的操作数,即与一个运算符有关的输入和输出张量

  3. Graph类的成员函数提供了方便的接口用来创建和访问操作符和操作数,以构建和遍历计算图。同时,它也是模型中运算符(算子)和操作数的集合

代码解读

将元素 op 插入到容器 ops 中,插入的位置是在找到的第一个与 cur 相等的元素之前。如果没有找到相等的元素,op 就会被插入到容器的末尾。这种方式可以确保新的元素 op 被插入到容器中,并且尽量保持与 cur 相关的顺序。

  • ops 是一个容器,可以是例如std::vectorstd::list等容器类型。

  • std::find(ops.begin(), ops.end(), cur) 是一个查找算法,它在容器 ops 的开始迭代器(ops.begin())到结束迭代器(ops.end())之间寻找第一个等于 cur 的元素。这返回一个迭代器指向找到的元素,或者如果未找到,返回 ops.end()

  • ops.insert(iterator, op) 是插入算法,它在指定的位置插入元素。在这里,我们使用了 std::find 的结果作为插入位置的迭代器,即找到的元素的位置。

  • op 是要插入的元素。

这里为啥要写函数的常量版本形式???

需要注意的是,可以在非常量对象上调用常量成员函数,因为非常量对象的状态可以修改,所以调用常量成员函数不会引发冲突。而常量对象调用非常量成员函数,则会导致编译错误。

  1. 如何判断两个std::vector是否相等

在C++中,你可以使用STL提供的 std::vector== 运算符来判断两个向量是否相等。这个运算符会逐元素比较两个向量的内容。

在上面的例子中,vec1vec2 的内容相同,因此 vec1 == vec2 的比较结果为 true,而 vec1vec3 的内容不同,因此 vec1 == vec3 的比较结果为 false

需要注意的是,这种比较是逐元素进行的,所以两个向量要在每个位置上都具有相同的值才会被认为是相等。如果两个向量的大小不同,它们将被认为是不相等。如果你的向量中包含自定义类型,确保该类型有适当的 == 运算符重载或提供自定义的比较函数。

这个方法的目的是将网络结构和配置从文件中加载到内存中的 Graph 实例中。这涉及到创建运算符和操作数的实例,并根据文件中的数据设置它们的属性、形状和参数。关于load相关函数的作用见后面几个静态函数的详析。

这段代码用来保存修改后图的结构和属性

PNNX中的运算符结构(Operator)

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

  1. inputs:类型为std::vector<operand>, 表示这个算子在计算过程中所需要的输入操作数operand

  2. outputs:类型为std::vector<operand>, 表示这个算子在计算过程中得到的输出操作数operand

  3. typename类型均为std::string, 分别表示该运算符号的类型和名称

  4. inputnames:存储与 inputs 中的每个 Operand 对应的字符串名称。这些名称是用来标识和引用特定的输入操作数的。在神经网络和其他计算图中,运算符(比如一个神经网络层或一个数学操作)通常会有多个输入inputnames 提供了一种方式来命名这些输入,使得在处理或调试网络时可以更容易地引用和识别它们。

  5. params, 类型为std::map, 用于存放该运算符的所有参数(例如卷积运算符中的params中将存放stride, padding, kernel size等信息);

  6. attrs, 类型为std::map, 用于存放该运算符所需要的具体权重属性(例如卷积运算符中的attrs中就存放着卷积的权重和偏移量,通常是一个float32数组)。

PNNX中的操作数(Operand)结构

PNNX中的Attribute和Param结构

在PNNX中,**权重数据结构(Attribute)和参数数据结构(Param)**定义如下。它们通常与一个运算符相关联,例如Linear算子的in_features属性和weight权重。

代码解读

主要实现了,给定任意shape的数据t,t是float类型的vector,把t存到Attribute对象的data中

重写了+号运算符,主要将Attribute对象沿着第一个维度shape[0]进行拼接,这里Attribute对象除了第一个维度外,其他维度上都要相等。

这段代码的主要作用是解析一个字符串 value,并根据其内容向不同类型的数组中添加元素

配合load_parameter这个静态函数使用

回顾C++std::string相关操作

  1. substr 是 C++ 标准库中 std::string 类的一个成员函数,用于从一个字符串中提取子串:

    • pos:表示子串的起始位置(索引),默认为 0。

    • count:表示子串的长度(要提取的字符数),默认为 npos,即直到字符串的末尾。

    这函数返回一个新的 std::string 对象,包含原始字符串中指定位置和长度的子串。

    这行代码用于从字符串 value 中提取子串,起始位置为 1,长度为 value.size() - 2

    处理行内格式化数据: 当处理像CSV(逗号分隔值)这样格式化的字符串时,std::istringstream可以结合std::getline使用,以自定义分隔符读取数据。

  2. std::istringstream 是 C++ 标准库中的一个类,用于从字符串中提取数据。它的头文件是 <sstream>

    std::istringstream 还可以用于错误检测。如果读取失败(例如,因为数据类型不匹配),流将进入错误状态,这可以通过检查流状态或者直接在条件表达式中使用流对象来检测:

  3. std::getline 是 C++ 标准库中的一个函数,用于从输入流中读取一行数据。它的头文件是 <string>

    std::getline 可以用于从 std::istream(比如 std::cin)或者字符串流(比如 std::istringstream)中读取一行数据,并存储到一个字符串中。它可以指定一个定界符(默认是换行符 ),当读取到定界符时,输入就结束。

    以下是 std::getline 的基本用法:

    指定定界符

    可以指定一个定界符,告诉 std::getline 在何时停止读取。

    在这个例子中,std::getline 会在遇到逗号时停止读取输入。

  4. (elem[0] < '0' || elem[0] > '9') :检查字符串 elem 的第一个字符是否不是数字字符(即小于 '0' 或大于 '9')。

  5. std::string::find 是 C++ 标准库中 std::string 类的成员函数,用于在字符串中查找子字符串或字符的第一个出现位置。它返回子串在原始字符串中的位置,如果未找到,则返回 std::string::npos

    指定搜索的起始位置

    std::string::find 还允许你指定搜索的起始位置,这对于多次查找很有用:

    在这个例子用于从字符串 str 中查找所有的 "ra" 子串。每次找到一个后,更新搜索的起始位置为上一个找到的位置的下一个位置。

    1. std::string::find_last_of 是 C++ 标准库中 std::string 类提供的一个成员函数,它用于从字符串的末尾开始搜索,查找在指定的字符集中任何一个字符最后一次出现的位置。

    2. 查找单个字符: 查找单个字符最后一次出现的位置。

    3. 查找多个字符中的任何一个: 查找字符串中给定的多个字符中的任何一个最后一次出现的位置。

    4. 指定起始位置: 从字符串中的特定位置开始向前搜索**(从该位置向字符串开头方向搜索)**。

    返回值

    • 如果找到了字符,find_last_of 返回字符在字符串中的位置(一个基于 0 的索引)。

    • 如果没有找到字符,函数返回 std::string::npos,这是 size_t 类型的一个特殊值,通常定义为最大的 size_t 值。

    注意事项

    • 当处理 find_last_of 的返回值时,应该检查它是否不等于 std::string::npos,以确认是否真的找到了字符。

几个静态函数

主要作用是解析字符串 value,以更新一个特定 OperatorOperand 的类型和形状信息。

用于从压缩包中加载特定运算符的属性,包括属性的类型、形状和数据。

在运算符的输入操作数中查找一个名称与 value 匹配的操作数,并将该操作数在 op->inputnames 中的位置设置为 key。这样,可以根据 key 来引用特定的输入操作数。

参考

  1. https://zhuanlan.zhihu.com/p/655247558(墙裂推荐!!!一个十分优质的项目)

  2. https://github.com/Tencent/ncnn/tree/master/tools/pnnx

Last updated

Was this helpful?