第 5 课 算子和算子注册器的设计与实现

计算节点在我们这个项目中被称之为RuntimeOperator, 具体的结构定义如下的代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct RuntimeOperator {
virtual ~RuntimeOperator();

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

std::map<std::string, std::shared_ptr<RuntimeOperand>>
input_operands; /// 节点的输入操作数
std::shared_ptr<RuntimeOperand> output_operands; /// 节点的输出操作数
std::vector<std::shared_ptr<RuntimeOperand>>
input_operands_seq; /// 节点的输入操作数,顺序排列
std::map<std::string, std::shared_ptr<RuntimeOperator>>
output_operators; /// 输出节点的名字和节点对应
...

在一个计算节点(RuntimeOperator)中,我们记录了与该节点相关的类型、名称,以及输入输出数等信息。其中最重要的是layer变量,它表示与计算节点关联的算子,也就是进行具体计算的实施者。

通过访问RuntimeOperator的输入数(input_operand),layer可以获取计算所需的输入张量数据,并根据layer派生类别中定义的计算函数(forward)对输入张量数据进行计算。计算完成后,计算结果将存储在该节点的输出数(output_operand)中。

Layer类型的定义

以下的代码位于include/abstract/layer.hpp中,它是所有算子的父类,如果要实现项目中其他的算子,都需要继承于该类作为派生类并重写其中的计算函数(forward),包括我们这节课要实现的ReLU算子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Layer {
public:
explicit Layer(std::string layer_name) : layer_name_(std::move(layer_name)) {}

virtual ~Layer() = default;

/**
* Layer的执行函数
* @param inputs 层的输入
* @param outputs 层的输出
* @return 执行的状态
*/
virtual InferStatus Forward(
const std::vector<std::shared_ptr<Tensor<float>>>& inputs,
std::vector<std::shared_ptr<Tensor<float>>>& outputs);

/**
* Layer的执行函数
* @param current_operator 当前的operator
* @return 执行的状态
*/
virtual InferStatus Forward();

以上的代码定义了Layer类的构造函数,它只需要一个layer_name变量来指定该算子的名称。我们重点关注带有参数的Forward方法,它是算子中定义的计算函数。

这个函数有两个参数,分别是inputsoutputs。它们是在计算过程中所需的输入和输出张量数组。每个算子的派生类都需要重写这个带参数的Forward方法,并在其中定义计算的具体逻辑。

1
2
3
4
5
6
class Layer {
...
...
protected:
std::weak_ptr<RuntimeOperator> runtime_operator_;
std::string layer_name_; /// Layer的名称

我们可以看到,在Layer类中有两个成员变量。一个是在构造函数中指定的算子名称 layer_name,另一个是与该算子关联的计算节点变量 RuntimeOperator。我们在之前回顾了 RuntimeOperator 的定义:

img

不难看出,RuntimeOperator与该节点对应的 Layer 相关联,而 Layer 也关联了它所属的 RuntimeOperator,因此它们之间是双向关联的关系。

现在我们来看一下 Layer 类中不带参数的 Forward 方法。这个方法是所有算子的父类方法它的作用是准备输入和输出数据,并使用这些数据调用每个派生类算子中各自实现的计算过程(上文提到的带参数的 Forward 函数)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
InferStatus Layer::Forward() {
LOG_IF(FATAL, this->runtime_operator_.expired())
<< "Runtime operator is expired or nullptr";
// 获取算子相关的计算节点
const auto& runtime_operator = this->runtime_operator_.lock();
// 准备节点layer计算所需要的输入
const std::vector<std::shared_ptr<RuntimeOperand>>& input_operand_datas = runtime_operator->input_operands_seq;
// layer的输入
std::vector<std::shared_ptr<Tensor<float>>> layer_input_datas;
for (const auto& input_operand_data : input_operand_datas) {
for (const auto& input_data : input_operand_data->datas) {
layer_input_datas.push_back(input_data);
}
}
...
...

Layer类的不带参数的Forward方法中,我们首先获取与该Layer相对应的计算节点RuntimeOperator。它们之间是双向关联的关系,一个算子对应一个计算节点(RuntimeOperator),一个计算节点对应一个算子(Layer)。

我们从计算节点中得到该节点对应的输入数input_operand_datas以及该输入数存储的张量数据layer_input_datas. 随后,我们再从计算节点中取出对应的输出数output_operand_datas.

1
2
3
4
const std::shared_ptr<RuntimeOperand>& output_operand_datas =
runtime_operator->output_operands;
InferStatus status = runtime_operator->layer->Forward(
layer_input_datas, output_operand_datas->datas);

在以上的步骤中,我们从计算节点RuntimeOperator中获取了相关的输入数和输出数,随后我们再使用对应的输入和输出张量去调用子类算子各自实现的,带参数的Forward函数

img

全局的算子注册器

KuiperInfer中算子注册机制使用了单例模式和工厂模式。首先,在全局范围内创建一个==唯一==的注册表registry,它是一个map类型的对象。这个注册表的键是算子的类型,而值是算子的初始化过程。

开发者完成一个算子的开发后,需要通过特定的注册机制将算子写入全局注册表中。这可以通过在注册表中添加键值对来实现。算子的类型作为键,算子的初始化过程作为值。这样,当需要使用某个算子时,可以根据算子的类型从全局注册表中方便地获取对应的算子。

在实现上单例模式确保了只有一个全局注册表实例,并且可以在代码的任何地方访问该注册表。工厂模式则负责根据算子的类型返回相应的算子实例。这种注册机制的设计使得推理框架能够感知到开发者已经实现的算子,并且能够方便地调用和使用这些算子。

算子类型 初始化过程
Conv ConvInstance
ReLU ReLUInstance

当所有支持的算子都被添加到注册表中后,我们可以使用registry.find(layer_type)来获取特定类型算子的初始化过程,并通过该初始化过程获取相应算子的实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class LayerRegisterer {
public:
typedef ParseParameterAttrStatus (*Creator)
(const std::shared_ptr<RuntimeOperator> &op, std::shared_ptr<Layer> &layer);

typedef std::map<std::string, Creator> CreateRegistry;
/**
* 向注册表注册算子
* @param layer_type 算子的类型
* @param creator 需要注册算子的注册表
*/
static void RegisterCreator(const std::string &layer_type, const Creator &creator);
....
}

以上代码中的creator是一个函数指针,指向某一类算子的初始化过程,不同的算子具有不同的实例化函数,但是都需要符合要求:

1
2
typedef ParseParameterAttrStatus (*Creator)
(const std::shared_ptr<RuntimeOperator> &op,std::shared_ptr<Layer> &layer);

通过以上内容,我们可以观察到不同的算子实例化函数都需要接受两个参数RuntimeOperator和待初始化的算子layer。这些函数会返回一个ParseParameterAttrStatus类型的状态值。

换句话说,这里的Creator是一个函数指针类型,用于定义某个类型算子的创建过程。当我们需要使用某个类型的算子时,可以从CreateRegistry类型的注册表中获取该算子的创建过程。

然后,我们将相应的RuntimeOperator和待初始化的Layer传递给创建过程,完成初始化并获得实例化后的算子。

注册算子的过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
LayerRegisterer::CreateRegistry& LayerRegisterer::Registry() {
static CreateRegistry* kRegistry = new CreateRegistry();
CHECK(kRegistry != nullptr) << "Global layer register init failed!";
return *kRegistry;
}

void LayerRegisterer::RegisterCreator(const std::string &layer_type,
const Creator &creator) {
CHECK(creator != nullptr);
CreateRegistry &registry = Registry();
CHECK_EQ(registry.count(layer_type), 0)
<< "Layer type: " << layer_type << " has already registered!";
registry.insert({layer_type, creator});
}

LayerRegisterer::CreateRegistry &LayerRegisterer::Registry() {
static CreateRegistry *kRegistry = new CreateRegistry();
CHECK(kRegistry != nullptr) << "Global layer register init failed!";
return *kRegistry;
}

首先,让我们来看一下Registry函数。这里使用了线程安全的懒汉式代码实现,并且利用了C++11标准中的**Magic Static(局部静态变量)**特性。在以上代码中,全局注册表registry变量是一个唯一的实例kRegistry,无论该函数被调用多少次,都会返回同一个对象。

然后,回到注册函数RegisterCreator。这个函数接受两个参数:算子的类型layer_typeCreator类型。正如前面所述,creator参数是该类算子的创建过程,它是一个函数指针。

RegistryCreator函数中,首先获取全局注册表registry,然后检查该类型的算子是否已经被注册过。如果没有被注册过,则使用.insert将其插入到全局注册表。

从注册器中取出算子

最后,我们来看一下如何使用注册表中已经注册过的创建过程来实例化一个算子。具体的过程如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
std::shared_ptr<Layer> LayerRegisterer::CreateLayer(
const std::shared_ptr<RuntimeOperator> &op) {
CreateRegistry &registry = Registry();
const std::string &layer_type = op->type;
LOG_IF(FATAL, registry.count(layer_type) <= 0)
<< "Can not find the layer type: " << layer_type;
const auto &creator = registry.find(layer_type)->second;

LOG_IF(FATAL, !creator) << "Layer creator is empty!";
std::shared_ptr<Layer> layer;
const auto &status = creator(op, layer);
LOG_IF(FATAL, status != ParseParameterAttrStatus::kParameterAttrParseSuccess)
<< "Create the layer: " << layer_type
<< " failed, error code: " << int(status);
return layer;
}

函数CreateLayer用于创建算子,它接受一个名为RuntimeOperator的参数作为输入,该参数包含了创建算子所需的所有权重和参数信息。

1
2
3
4
CreateRegistry &registry = Registry();
const std::string &layer_type = op->type;
LOG_IF(FATAL, registry.count(layer_type) <= 0)
<< "Can not find the layer type: " << layer_type;

在以上的代码中先获得全局注册表registry,再检查这个算子类型layer_type是否已经被注册到全局注册表中,如果已经被注册过,则获取到该算子类型对应的创建过程creator.

在前文中,我们说过creator是一个算子的创建过程函数,它的传入参数为包含所有参数和权重等信息的RuntimeOperator以及一个待初始化的算子layer. 回到CreateLayer函数,当creator函数指针被调用之后如果返回状态status不为succcess, 说明在创建过程中发生了一定的错误,算子初始化失败,需要再排查。

最后,我们再来回顾一下上面的整体过程。首先,我们定义了一个计算过程类型Creator和算子类型Layer。然后,我们定义了一个在注册表中注册算子的函数RegisterCreator。通过该函数,我们可以批量将算子类型和创建过程注册到全局注册表中。当需要使用某个算子时,我们可以根据算子的类型从全局注册表中获取对应的创建过程(即Creator类型的函数指针)。

然后,我们将创建时所需的参数和权重打包成RuntimeOperator类型,并传递给创建过程,类似于creator(runtime_operator)。这样我们就可以获得一个实例化后的算子层,整个过程就如同CreateLayer函数中所示。

为了更方便地注册算子

1
2
3
4
5
6
class LayerRegistererWrapper {
public:
LayerRegistererWrapper(const std::string &layer_type, const LayerRegisterer::Creator &creator) {
LayerRegisterer::RegisterCreator(layer_type, creator);
}
};

这个工具类只有一个构造函数,该构造函数接受算子的类型和该算子对应的创建过程作为参数。

LayerRegistererWrapper类的构造函数中,我们调用RegisterCreator方法来完成对该算子在注册表中的注册。关于RegisterCreator的详细讲解,我们已经在前文提及过,不再赘述。

创建第一个算子 ReLU

img

ReLU的计算过程非常简单,有如下的定义: $ReLU(x)=\max(0,x)$

正如前文所述,为了对输入数据进行计算,ReLU算子需要实现带参数的前向传播(Forwards)过程,实现详见relu.cpp.

1
2
3
4
5
6
7
8
9
10
11
12
13
InferStatus ReluLayer::Forward(
const std::vector<std::shared_ptr<Tensor<float>>>& inputs,
std::vector<std::shared_ptr<Tensor<float>>>& outputs) {
if (inputs.empty()) {
LOG(ERROR) << "The input tensor array in the relu layer is empty";
return InferStatus::kInferFailedInputEmpty;
}
if (inputs.size() != outputs.size()) {
LOG(ERROR) << "The input and output tensor array size of the relu layer do "
"not match";
return InferStatus::kInferFailedInputOutSizeMatchError;
}
...

根据公式 可以看出,ReLU算子不会改变输入张量的大小,也就是说输入和输出张量的维度应该是相同的。因此,上述代码首先检查输入数组是否为空,然后检查输入数组和输出数组中的元素(张量)个数是否相同,如果不满足该条件,程序返回并记录相关错误日志

1
2
3
4
5
6
7
8
9
10
11
12
const uint32_t batch_size = inputs.size();
for (uint32_t i = 0; i < batch_size; ++i) {
...
if (output_data != nullptr && !output_data->empty()) {
if (input_data->shapes() != output_data->shapes()) {
LOG(ERROR) << "The input and output tensor shapes of the relu "
"layer do not match "
<< i << " th";
return InferStatus::kInferFailedInputOutSizeMatchError;
}
}
}

在以上的代码中,我们对一个批次(batch size)的输入张量数据进行了检查。我们使用(output_data != nullptr && !output_data->empty())来检查输出张量是否为空指针,并且检查输出张量是否已经分配空间以存储计算结果。

1
2
3
4
for (uint32_t j = 0; j < input->size(); ++j) {
float value = input->index(j);
output->index(j) = value > 0.f ? value : 0.f;
}

在进行完以上信息检查后,我们使用一个for循环逐个处理一个大小为batch_size的输入张量数组。很明显,这个内层的for循环中,我们逐个读取input的值,并判断它与0的大小关系。如果大于0,则保留该值;否则将其置为0.

ReLU算子的注册

1
2
3
4
5
6
7
8
9
ParseParameterAttrStatus ReluLayer::GetInstance(
const std::shared_ptr<RuntimeOperator> &op,
std::shared_ptr<Layer> &relu_layer) {
CHECK(op != nullptr) << "Relu operator is nullptr";
relu_layer = std::make_shared<ReluLayer>();
return ParseParameterAttrStatus::kParameterAttrParseSuccess;
}

LayerRegistererWrapper kReluGetInstance("nn.ReLU", ReluLayer::GetInstance);

ReluLayer::GetInstanceReLU算子的初始化过程,该初始化函数符合之前Creator函数指针的参数类型、参数个数和返回值要求。该初始化函数对传入的layer进行初始化,并返回表示成功的状态码。

Creator函数指针定义如下:

1
2
typedef ParseParameterAttrStatus (*Creator)
(const std::shared_ptr<RuntimeOperator> &op, std::shared_ptr<Layer> &layer);