KuiperInfer跟学——第三讲 计算图的定义
一、PNNX
pnnx是一种类似于onnx的模型中间表示结构。KuiperInfer计算图的设计参照了pnnx。
pnnx介绍:https://zhuanlan.zhihu.com/p/427620428
一个计算图中包含了操作符、操作数。
下面是计算图这个类的定义
class Graph
{
public:
Graph();
~Graph();
int load(const std::string& parampath, const std::string& binpath);
int save(const std::string& parampath, const std::string& binpath);
int python(const std::string& pypath, const std::string& binpath);
int parse(const std::string& param);
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);
Operator* new_operator_after(const std::string& type, const std::string& name, const Operator* cur);
#if BUILD_PNNX
Operand* new_operand(const torch::jit::Value* v);
#endif
Operand* new_operand(const std::string& name);
Operand* get_operand(const std::string& name);
const Operand* get_operand(const std::string& name) const;
std::vector<Operator*> ops;
std::vector<Operand*> operands;
private:
Graph(const Graph& rhs);
Graph& operator=(const Graph& rhs);
};
首先说一下load函数
int Graph::load(const std::string& parampath, const std::string& binpath)
{
std::ifstream is(parampath, std::ios::in | std::ios::binary);
if (!is.good())
{
fprintf(stderr, "open failed\n");
return -1;
}
StoreZipReader szr;
if (szr.open(binpath) != 0)
{
fprintf(stderr, "open failed\n");
return -1;
}
int magic = 0;
{
std::string line;
std::getline(is, line);
std::istringstream iss(line);
iss >> magic;
}
int operator_count = 0;
int operand_count = 0;
{
std::string line;
std::getline(is, line);
std::istringstream iss(line);
iss >> operator_count >> operand_count;
}
for (int i = 0; i < operator_count; i++)
{
std::string line;
std::getline(is, line);
std::istringstream iss(line);
std::string type;
std::string name;
int input_count = 0;
int output_count = 0;
iss >> type >> name >> input_count >> output_count;
Operator* op = new_operator(type, name);
for (int j = 0; j < input_count; j++)
{
std::string operand_name;
iss >> operand_name;
Operand* r = get_operand(operand_name);
r->consumers.push_back(op);
op->inputs.push_back(r);
}
for (int j = 0; j < output_count; j++)
{
std::string operand_name;
iss >> operand_name;
Operand* r = new_operand(operand_name);
r->producer = op;// 操作符的生产者只有一个,而消费者可以有多个
op->outputs.push_back(r);
}
// key=value
while (!iss.eof())
{
std::string param;
iss >> param;
std::string key;
std::string value;
std::istringstream pss(param);
std::getline(pss, key, '=');
std::getline(pss, value);
if (key[0] == '@')
{
// attribute
load_attribute(op, key.substr(1), value, szr);
}
else if (key[0] == '$')
{
// operand input key
load_input_key(op, key.substr(1), value);
}
else if (key[0] == '#')
{
// operand shape
load_shape(op, key.substr(1), value);
}
else
{
// parameter
load_parameter(op, key, value);
}
}
}
return 0;
}
load函数的整体思路是:首先从.param文件中读取操作符和操作数,这个文件中包含了majic数,用于文件或者版本标识;包含了操作符和操作数的总个数,其中输入和输出算作操作符;然后就是每个操作数的类型、名称、输入和输出的相关信息,这些信息是为了构建操作符而服务,param文件中只包含了搭建模型的相关信息,不包含具体的权重值,具体权重值在bin文件中。load函数根据这两个文件的信息,构建一个完整的模型计算图;
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;
};
inputs
:算子的输入,是一个Operand类型的数组,因为可能有多个输入
outputs
:算子的输出,也是一个Operand类型的数组,因为算子也有可能有多个输出;
type
和name
:类型是操作符的类型,比如卷积conv、全连接linear;名字是算子的唯一标识符,比如conv1、linear1。二者可以类比成类和对象的关系;
inputnames
:这个参数目前存疑,代码中似乎没有用到
params
:params是指操作符的相关信息,而不是具体的权重数值,用map类型来存储。比如linear算子中会指出in_features:32;这里key=in_features,对应的value=32;
attrs
:是指算子的权重值,用一个map类型来存储,比如一个linear算子的in_features=32,out_features=128,那么它的attrs中会包含key=weights,value等于一个32*128的矩阵;
Parameter类,参数类,用于操作符和操作数中,来说明其中参数的值;比如卷积核的尺寸等;一般和map结合使用,std::map<std::string, Parameter> params;
class Parameter
{
public:
Parameter()
: type(0)
{
}
Parameter(bool _b)
: type(1), b(_b)
{
}
Parameter(int _i)
: type(2), i(_i)
{
}
Parameter(long _l)
: type(2), i(_l)
{
}
Parameter(long long _l)
: type(2), i(_l)
{
}
Parameter(float _f)
: type(3), f(_f)
{
}
Parameter(double _d)
: type(3), f(_d)
{
}
Parameter(const char* _s)
: type(4), s(_s)
{
}
Parameter(const std::string& _s)
: type(4), s(_s)
{
}
Parameter(const std::initializer_list<int>& _ai)
: type(5), ai(_ai)
{
}
Parameter(const std::initializer_list<int64_t>& _ai)
: type(5)
{
for (const auto& x : _ai)
ai.push_back((int)x);
}
Parameter(const std::vector<int>& _ai)
: type(5), ai(_ai)
{
}
Parameter(const std::initializer_list<float>& _af)
: type(6), af(_af)
{
}
Parameter(const std::initializer_list<double>& _af)
: type(6)
{
for (const auto& x : _af)
af.push_back((float)x);
}
Parameter(const std::vector<float>& _af)
: type(6), af(_af)
{
}
Parameter(const std::initializer_list<const char*>& _as)
: type(7)
{
for (const auto& x : _as)
as.push_back(std::string(x));
}
Parameter(const std::initializer_list<std::string>& _as)
: type(7), as(_as)
{
}
Parameter(const std::vector<std::string>& _as)
: type(7), as(_as)
{
}
#if BUILD_PNNX
Parameter(const torch::jit::Node* value_node);
Parameter(const torch::jit::Value* value);
#endif // BUILD_PNNX
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;
// 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;
};
这里面并非所有的数据成员都有意义,只有相应类型的数据成员才有意义。
Attribute类,权重类,用于存储操作符中的权重值,比如卷积权重,偏置权重。使用也是通常和map结合,std::map<std::string, Attribute> attrs;
class Attribute
{
public:
Attribute()
: type(0)
{
}
#if BUILD_PNNX
Attribute(const at::Tensor& t);
#endif // BUILD_PNNX
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;
};
值得注意的是,Attribute类的data成员采用char类型的列表,也就是采用字节序列来存储,这样保证了所有类型的数据都采用统一的格式进行存储,后续使用权重的时候,根据type和shape来组织数据。
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;
};
题外话:keep std::string typed member the last for cross cxxabi compatibility
把string类型放在最后,可以提高兼容性;
producer
:该操作数的生产者,也就是该操作数是producer的输出,一个操作数明显只能有一个生产者;
consumer
: 操作数的消费者,也就是该操作数是consumer的输入,一个操作数可以有多个消费者;
type
:该操作数的数据类型;
shape
:该操作数的数据形状;
name
:该操作数的名字,用作唯一标识符;
params
:用于标记该操作数的一个额外信息,由于目前还没遇到具体的过程,所以这里先不举例说明;
二、Kuiperinfer对PNNX计算图的封装
核心是要保持KuiperInfer::Runtime Graph和pnnx::Graph的完全一致,包括结构和参数权重。
1、UML结构图
注:虚线加空心箭头表示实现,是一种类与接口的关系,箭头指向接口;但实际代码中,RuntimeParameter和RuntimeParameterInt、RuntimeParameterString、RuntimeParameterFloat是继承关系。
实线加实心箭头表示关联关系,即has-a关系;
2、关键类讲解说明
2.1 RuntimeOperator
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中的一些具体设计,比如设计输出节点,里面不是能完全理解,估计得继续往下面学才能懂为何这样设计。
name:
该计算节点的名称,也是其在计算图中的唯一标识;比如我们conv_1就是一个计算节点的名称;
type:
该运算节点的类型,比如Convolution、Linear;
layer
: 负责实施计算的组件,目前课程还没将layer的实现;
其他的一些成员的含义,参考注释即可;
2.2 PNNX中的operator是如何到KuiperInfer::RuntimeOperator中的
pnnx可以看作类似于onnx的一种中间模型表示,训练好的模型保存为pnnx格式,然后推理框架从pnnx模型文件中读取信息,进行推理,所以就涉及到pnnx中的operator是如何传递到KuiperInfer::RuntimeOperator中的,也就是RuntimeOperator是如何读取pnnx中operator的信息并化为己用的;
因为推理都是直接读取整个pnnx文件,然后转为推理计算图的,所以这里有必要看一下RuntimeGraph的内容
/// 计算图结构,由多个计算节点和节点之间的数据流图组成
class RuntimeGraph {
public:
/**
* 初始化计算图
* @param param_path 计算图的结构文件
* @param bin_path 计算图中的权重文件
*/
RuntimeGraph(std::string param_path, std::string bin_path);
/**
* 设置权重文件
* @param bin_path 权重文件路径
*/
void set_bin_path(const std::string &bin_path);
/**
* 设置结构文件
* @param param_path 结构文件路径
*/
void set_param_path(const std::string ¶m_path);
/**
* 返回结构文件
* @return 返回结构文件
*/
const std::string ¶m_path() const;
/**
* 返回权重文件
* @return 返回权重文件
*/
const std::string &bin_path() const;
/**
* 计算图的初始化
* @return 是否初始化成功
*/
bool Init();
const std::vector<std::shared_ptr<RuntimeOperator>> &operators() const;
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> ¶ms,
const std::shared_ptr<RuntimeOperator> &runtime_operator);
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
};
input_name_:
计算图的输入节点名称;
output_name_:
计算图的输入出节点名称;
param_path_:
计算图的结构文件路径,这个文件告诉我们计算图是如何搭建起来的,数据是如何在计算图中进行流转的。
bin_path_:
计算图的权重文件,这个文件告诉我们计算图中各个节点的权重值;
这里面的很多方法其实都是在pnnx::Graph的基础上进一步封装,就是能调用pnnx::Graph的函数就调用,不要自己再去造轮子;
RuntimeGraph::InitGraphAttrs
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;
runtime_attribute->weight_data = attr.data;
runtime_attribute->shape = attr.shape;
runtime_operator->attribute.insert({name, runtime_attribute});
break;
}
default: {
LOG(FATAL) << "Unknown attribute type: " << attr.type;
}
}
}
}
把每个计算节点的权重信息给赋予给运行时计算节点(RuntimeOperator)