Paddle OP to OpenVINO贡献指南
1.前言
暑假期间通过PaddleHackathon活动有幸给OpenVINO贡献过一点代码,主要是为OpenVINO实现Paddle算子的转换映射,使得我们可以很方便地将Paddle模型转换为OpenVINO模型,以此实现加速推理、减少占用的效果。在此记录下贡献的全流程,希望能够帮助到对OpenVINO贡献感兴趣的小伙伴们,也希望更多小伙伴能够参与到OpenVINO的社区建设当中来。
2.介绍
2.1. OpenVINO
熟悉深度学习的小伙伴们一定都知道深度学习训练框架的重要性,它集成封装了一系列深度学习常用组件,可以让你更加方便迅速地进行开发,而不是在手撸算子上花费太多不必要的精力。
然而模型的训练和推理过程差别很大,在实际的生产部署中还有很大的提升空间,于是为了榨干处理器的性能,进一步推理速度,各家厂商都发布了自己的推理框架和工具库。而OpenVINO作为一个由英特尔针对自家硬件平台开发的深度学习推理部署框架,可以用于快速开发应用程序和解决方案,支持计算机视觉的CNN网络结构超过200余种。之前还使用过OpenVINO部署模型参加比赛,在轻薄本的英特尔低压CPU上速度也很快,非常好用~
2.2. 任务说明
本次PaddleHackathon中的OpenVINO 项目贡献任务,需要将 Paddle 的算子映射转换到 OpenVINO 的算子,将对应的转换及测试代码提交至OpenVINO仓库,经过Review和通过CI即可合入。
3. 开发过程
3.1. 开发准备
3.1.1. 搭建开发环境
在正式开发前,我们首先需要搭建开发环境,通过下列命令我们可以很方便地搭建开发环境和安装依赖
git clone https://github.com/openvinotoolkit/openvino.git
cd openvino
git submodule update --init --recursive
chmod +x install_build_dependencies.sh
./install_build_dependencies.sh
3.1.2. 通过源码编译
接着通过源码进行编译。在此处可以找到编译的文档,按照文档即可顺利编译,有时可能会遇到一些网络问题,需要挂梯子或者手动下载,可以通过下列命令进行编译,非常方便~
export OPENVINO_BASEDIR=`pwd`
mkdir build
cd build
cmake \
-DCMAKE_BUILD_TYPE= Release -DCMAKE_INSTALL_PREFIX="${OPENVINO_BASEDIR}/openvino_dist" \
-DPYTHON_EXECUTABLE=$(which python3) \
-DENABLE_MYRIAD=OFF \
-DENABLE_VPU=OFF \
-DENABLE_PYTHON=ON \
-DNGRAPH_PYTHON_BUILD_ENABLE=ON \
-DENABLE_DEBUG_CAPS=ON \
-DENABLE_CPU_DEBUG_CAPS=ON \
-DENABLE_TESTS=ON \
..
make -j$(nproc); make install
3.1.3 文档和参考代码
对于本任务,需要时刻打开Paddle算子库文档和OpenVINO的算子库文档,以及对应OP的参考代码。
3.2. 了解样例
接下来我们通过paddle官方提供的Topk_v2样例来对开发有一个基本了解,以下分析仅个人理解,无法保证绝对准确。
// Copyright (C) 2018-2021 Intel Corporation
// SPDX-License-Identifier: Apache-2.0
#include "default_opset.hpp"
#include "openvino/frontend/paddle/node_context.hpp"
namespace ov {
namespace frontend {
namespace paddle {
namespace op {
NamedOutputs top_k_v2(const NodeContext& node) {
auto x = node.get_input("X");
Output<Node> k_expected_node;
if (node.has_input("K")) {
auto k_variable = node.get_input("K");
auto k_var_node = std::make_shared<default_opset::Convert>(k_variable, element::i32);
k_expected_node = std::make_shared<default_opset::Squeeze>(k_var_node);
} else {
const auto k_expected = node.get_attribute<int>("k", 1);
k_expected_node = default_opset::Constant::create(element::i32, {}, {k_expected});
}
auto axis = node.get_attribute<int32_t>("axis", -1);
bool sorted = node.get_attribute<bool>("sorted", true);
bool largest = node.get_attribute<bool>("largest", true);
std::string sort_type = sorted ? "value" : "none";
std::string mode = largest ? "max" : "min";
auto node_topk = std::make_shared<default_opset::TopK>(x, k_expected_node, axis, mode, sort_type);
NamedOutputs named_outputs;
named_outputs["Out"] = OutputVector{node_topk->output(0)};
named_outputs["Indices"] = OutputVector{node_topk->output(1)};
return named_outputs;
}
} // namespace op
} // namespace paddle
} // namespace frontend
} // namespace ov
3.2.1. 代码编写位置
OP转换的代码需要写在src/frontends/paddle/src/op/目录下,并在src/frontends/paddle/src/op_table.cpp中进行注册。
单测代码需要写在src/core/tests/frontend/paddle/test_models/gen_scripts目录中,并在src/core/tests/frontend/paddle/op_fuzzy.cpp中进行注册。
3.2.2. 相关类
3.2.2.1. Output<Node>
每个OP都可以映射为一个图结构,数据根据图结构在不同的计算节点之间流通和计算,而Node便定义了图结构中的数据节点,通过实现每一个Node,便可以通过组合实现更多的算子。
另外通过代码可以注意到Output<Node>可以作为基本单元,上述代码中的auto都可以替换为Output<Node>。
3.2.2.2. NodeContext
通过代码中可以看到,输入是一个类型为NodeContext的引用,NodeContext中包含了传入的Tensor和attribute的一些信息,其中,input相当于Tensor,实际上在OpenVINO中类型为Node,attribute则是OP的一些属性。
通过src/frontends/paddle/include/openvino/frontend/paddle/node_context.hpp中的源码可以了解到NodeContext的一些方法:
方法名 | 作用 |
bool has_input(const std::string& name) | 判断是否存在对应名称的输入Tensor |
Output<Node> get_input(const std::string& name) | 获取对应名称的输入 |
OutputVector get_ng_inputs(const std::string& name) | 获取对应名称的输入vector,适用于list(Tensor)的输入情况 |
size_t get_input_size(const std::string& name) | 获取输入的size |
3.2.2.3. OutputVector
源代码如下:
using OutputVector = std::vector<Output<Node>>;
没啥好说的,可以这么用:
OutputVector inputs = Node.get_ng_inputs(name);
3.2.2.4. NamedOutputs
源代码如下:
using NamedOutputs = std::map<OutPortName, OutputVector>;
3.2.3. 获取name
那么问题来了,这里input和attribute的name由什么定义呢,我们该如何得到输入和参数的name呢? 这里需要在Paddle中topk的代码中进行查看是如何在Python层面调用对应OP的:
helper = LayerHelper("top_k_v2", **locals())
inputs = {"X": [x]}
attrs = {}
if isinstance(k, Variable):
inputs['K'] = [k]
else:
attrs = {'k': k}
attrs['largest'] = largest
attrs['sorted'] = sorted
if axis is not None:
attrs['axis'] = axis
如此以来便能得知输入name为X了。通过上述代码可以看出,只要输入类型为Tensor(Variable),就会作为Inputs,否则都是attribute。 同样的,输出的Name可以在Paddle对应的代码中找到:
helper.append_op(type="top_k_v2",
inputs=inputs,
outputs={
"Out": [values],
"Indices": [indices]
},
attrs=attrs)
可以得到输出的name即为Out和Indices。
3.2.4. 调用OP
既然通过name得到了输入和参数,那么该如何调用OP进行组合呢? 观察上述代码,发现可以使用如下方式进行调用:
std::make_shared<default_opset::OP_NAME>(*args);
其中OP_NAME即为需要调用OP的名称,具体参数和使用方法可以通过官方文档查询。 创建常量时,可以使用如下方式:
default_opset::Constant::create(element::i32, {}, {value});
参数分别为数据类型、形状和数值。
3.2.5 输出
通过代码可以看出,返回值应该是一个NamedOutputs类型,对于有多个返回值的情况,我们可以仿照样例的方法:
NamedOutputs named_outputs;
named_outputs["Out"] = OutputVector{node_topk->output(0)};
named_outputs["Indices"] = OutputVector{node_topk->output(1)};
return named_outputs;
对于单个返回值的情况,我们可以使用如下方式:
return node.default_single_output_mapping({VALUE}, {NAME});
其中的VALUE为返回值,NAME为其对应的名称。
3.2.6. 注册
编写完成后需要在src/frontends/paddle/src/op_table.cpp文件中注册,首先需要添加OP_CONVERTER,如:
OP_CONVERTER(top_k_v2);
再在下面的get_supported_ops添加一个键值对映射:
{"top_k_v2", op::top_k_v2},
这里为什么这样添加不能确定,个人认为是前者表示Paddle中OP的名称,同样可以在paddle的相关代码中看到:
helper = LayerHelper("top_k_v2", **locals())
后者则表示自己所编写的OP:
NamedOutputs top_k_v2(const NodeContext& node)
3.2.7. 单测
单测代码的位置则写在src/core/tests/frontend/paddle/test_models/gen_scripts目录中,之所以叫gen,是表示这些文件在make阶段只是根据代码生成Outputs,并保存在特定的目录中,在后续可以通过测试脚本进行测试,如下:
cd bin/intel64/Release
./paddle_tests --gtest_filter=PaddleFuzzyOpTest/FrontEndFuzzyOpTest.testOpFuzzy/*
表示通配符,通常情况下只测试自己编写的代码,将替换成对应的文件名即可,如下:
cd bin/intel64/Release
./paddle_tests --gtest_filter=PaddleFuzzyOpTest/FrontEndFuzzyOpTest.testOpFuzzy/top_k_v2*
这里的名称需要在单测文件中进行确定,如:
def main():
data = np.random.random([8, 9, 10]).astype("float32")
top_k_v2("top_k_v2_test_1", data, k=5, axis=-2, largest=True, sorted=True)
top_k_v2("top_k_v2_test_2", data, k=6, axis=-1, largest=True, sorted=True)
然后将其注册在src/core/tests/frontend/paddle/op_fuzzy.cpp文件中, 注意名称需要保持一致:
std::string("top_k_v2_test_1"),
std::string("top_k_v2_test_2"),
这样我们便能通过top_k_v2*来进行测试了。 另外,保存Paddle OP输出时针对动态图和静态图分别可以使用exportModel和saveModel,这里可以参考一些其他单测进行使用即可。
3.2.8. 代码格式
代码格式需要满足一定要求,在提交前需要进行对应的风格检查和格式化,C++使用Clang fotmat,如果你使用vscode可以全选代码右键选择格式化选定内容。 另外变量命名也需要满足一定的规范,如只有类名才使用大写。
3.3. p_norm
下面我选取了我所完成的几个任务中偏难的一个,来进行讲解——p_norm。 在paddle中,p_norm可以对给定输入在给定轴上进行p范数的计算,p范数的公式定义如下:
举个简单的例子,对给定的n个数计算其2范数,首先对每个数取绝对值并平方,将得到的结果求和,再对求和的结果开方即可得到最终结果。对于输入是多维的情况,如果axis没有指定,则表示对所有数据求p范数,若指定,则表示在给定的维度上进行计算。
在实际开发前,我们应该拥有实现该算子的基本思路,因此我们需要进行一定的调研,首先在Paddle的算子库文档中找到p_norm,不过可惜的是并未找到该算子的文档,这里需要批评一下PaddlePaddle,文档不全,并且还有些描述错误的,因此推荐大家通过C++源代码的逻辑来进行实现。 直接在Paddle的仓库中进行搜索,可以找到p_norm对应的kernel位置,通过代码,我们可以分析出其实现逻辑:
- 获取输入和对应参数。
- 处理axis为负数的情况,将其转换为正数。
- 根据所选定的axis,将对应axis之前和之后的数据都进行flatten,得到一个3-d Tensor。
- 在第一个维度上进行计算,即对应axis的维度,reduce sum之后便得到了一个2-d Tensor。
- 对于一些特殊的p值,使用不同的计算逻辑,如p=0时,返回对应axis的非0数的数量等。
- 后续再reshape为对应的输出形状即可。
- keepdim参数即表示是否需要保留对应axis的维度为1,否则这个维度便直接消失了。
确定使用OP
经过分析我们发现显然不可照搬上述逻辑,但在这之前,我们需要确定使用哪些OpenVINO的OP。通过p_norm计算逻辑的分析,可以得出一定需要绝对值、次方开方和求和这几个OP,通过OpenVINO的算子库文档可以找到这些OP:Abs、ReduceSum和Power,这里次方和开方运算都可以使用Power OP来完成;再通过上述的Paddle源码分析,对于p的一些边界值,则需要能求最大值最小值的OP:ReduceMax、ReduceMin。
当然这不一定是全部所要用到的OP,也可能在开发的过程中发现需要其他的OP,这时候还是需要在OpenVINO的算子库文档中进行搜寻。
确定计算逻辑
于是我们可以大致确定我们的实现逻辑:
- 无需处理axis,因为OpenVINO中对应的OP都支持负数的axis。
- 基本无需处理keepdim,原因和上一点相同,ReduceOP都支持该参数。
- 由于对应的Reduce算子都拥有指定axis的功能,因此我们不需要flatten后再reshape。
- 首先需要使用Abs算子取绝对值,在针对不同p值的情况对应处理
- p==0时,需要返回指定axis上的非0数的数量,考虑使用NotEqual,再使用ReduceSum
- p为正无穷时,使用ReduceMax
- p为负无穷时,使用ReduceMin
- 其他情况,根据p_norm的公式进行计算,需要使用Pow和ReduceSum
这样我们可以写出实现代码:
获取输入和参数
auto data = node.get_input("X");
const auto p = node.get_attribute<float>("porder", 2.0);
const auto axis = node.get_attribute<int32_t>("axis", -1);
const auto keepdim = node.get_attribute<bool>("keepdim", false);
const auto absNode = std::make_shared<default_opset::Abs>(data);
const auto axisNode = default_opset::Constant::create(ov::element::i32, {1}, {axis});
处理p为正无穷
if (p == std::numeric_limits<float>::infinity()) {
return node.default_single_output_mapping(
{std::make_shared<default_opset::ReduceMax>(absNode, axisNode, keepdim)},
{"Out"});
}
处理p为负无穷
else if (p == -std::numeric_limits<float>::infinity()) {
return node.default_single_output_mapping(
{std::make_shared<default_opset::ReduceMin>(absNode, axisNode, keepdim)},
{"Out"});
}
处理p为0
else if (p == 0.0) {
const auto input_dtype = data.get_element_type();
const auto zero = default_opset::Constant::create(input_dtype, {1}, {0});
const auto non_zero = std::make_shared<default_opset::NotEqual>(absNode, zero);
const auto converted_non_zero = std::make_shared<default_opset::Convert>(non_zero, input_dtype);
const auto reduce_sum = std::make_shared<default_opset::ReduceSum>(converted_non_zero, axisNode, keepdim);
return node.default_single_output_mapping({reduce_sum}, {"Out"});
}
其他情况
else {
const auto power_factor = default_opset::Constant::create(ov::element::f32, Shape{1}, {p});
const auto powNode = std::make_shared<default_opset::Power>(absNode, power_factor);
const auto reduce_sum = std::make_shared<default_opset::ReduceSum>(powNode, axisNode, keepdim);
const auto extract_factor = default_opset::Constant::create(ov::element::f32, Shape{1}, {1.0 / p});
return node.default_single_output_mapping({std::make_shared<default_opset::Power>(reduce_sum, extract_factor)},
{"Out"});
当然,这不是最终代码,提交PR后会有Reviewer给出评审意见,不断迭代优化直至合入即可。
我完成任务的PR链接如下,希望能对小伙伴们有所帮助:
- p_norm
- where_index
- elementwise_mod
- box_coder
4.总结
本文简单地介绍了一下为OpenVINO实现Paddle算子转换映射任务的大致流程,任务的难度适中,对于简单的算子转换如where_index等来说,逻辑简单,可以十分快速地上手。对于更难一点的任务,在熟悉相关算子和明确计算逻辑后,也可以较为轻松地搞定。在熟悉相关算子这一环节,官方提供的英文文档可能不太容易理解,可以结合相关代码和使用案例进行理解。提交PR之后会有专业的Reviewer进行评审和指导,帮助你更快更好地完成任务,所以尽管大胆提交PR吧!