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范数的公式定义如下:
∣ ∣ x ∣ ∣ p = ( ∑ i = 1 n ∣ x i ∣ p ) 1 p ||x||_p=(\sum_{i=1}^n|x_i|^p)^{\frac{1}{p}} ∣∣xp=(i=1nxip)p1

举个简单的例子,对给定的n个数计算其2范数,首先对每个数取绝对值并平方,将得到的结果求和,再对求和的结果开方即可得到最终结果。对于输入是多维的情况,如果axis没有指定,则表示对所有数据求p范数,若指定,则表示在给定的维度上进行计算。

在实际开发前,我们应该拥有实现该算子的基本思路,因此我们需要进行一定的调研,首先在Paddle的算子库文档中找到p_norm,不过可惜的是并未找到该算子的文档,这里需要批评一下PaddlePaddle,文档不全,并且还有些描述错误的,因此推荐大家通过C++源代码的逻辑来进行实现。
直接在Paddle的仓库中进行搜索,可以找到p_norm对应的kernel位置,通过代码,我们可以分析出其实现逻辑:

  1. 获取输入和对应参数。
  2. 处理axis为负数的情况,将其转换为正数。
  3. 根据所选定的axis,将对应axis之前和之后的数据都进行flatten,得到一个3-d Tensor。
  4. 在第一个维度上进行计算,即对应axis的维度,reduce sum之后便得到了一个2-d Tensor。
  5. 对于一些特殊的p值,使用不同的计算逻辑,如p=0时,返回对应axis的非0数的数量等。
  6. 后续再reshape为对应的输出形状即可。
  7. keepdim参数即表示是否需要保留对应axis的维度为1,否则这个维度便直接消失了。

确定使用OP

经过分析我们发现显然不可照搬上述逻辑,但在这之前,我们需要确定使用哪些OpenVINO的OP。通过p_norm计算逻辑的分析,可以得出一定需要绝对值、次方开方和求和这几个OP,通过OpenVINO的算子库文档可以找到这些OP:Abs、ReduceSum和Power,这里次方和开方运算都可以使用Power OP来完成;再通过上述的Paddle源码分析,对于p的一些边界值,则需要能求最大值最小值的OP:ReduceMax、ReduceMin。

当然这不一定是全部所要用到的OP,也可能在开发的过程中发现需要其他的OP,这时候还是需要在OpenVINO的算子库文档中进行搜寻。

确定计算逻辑

于是我们可以大致确定我们的实现逻辑:

  1. 无需处理axis,因为OpenVINO中对应的OP都支持负数的axis。
  2. 基本无需处理keepdim,原因和上一点相同,ReduceOP都支持该参数。
  3. 由于对应的Reduce算子都拥有指定axis的功能,因此我们不需要flatten后再reshape。
  4. 首先需要使用Abs算子取绝对值,在针对不同p值的情况对应处理
    1. p==0时,需要返回指定axis上的非0数的数量,考虑使用NotEqual,再使用ReduceSum
    2. p为正无穷时,使用ReduceMax
    3. p为负无穷时,使用ReduceMin
    4. 其他情况,根据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链接如下,希望能对小伙伴们有所帮助:

4.总结

本文简单地介绍了一下为OpenVINO实现Paddle算子转换映射任务的大致流程,任务的难度适中,对于简单的算子转换如where_index等来说,逻辑简单,可以十分快速地上手。对于更难一点的任务,在熟悉相关算子和明确计算逻辑后,也可以较为轻松地搞定。在熟悉相关算子这一环节,官方提供的英文文档可能不太容易理解,可以结合相关代码和使用案例进行理解。提交PR之后会有专业的Reviewer进行评审和指导,帮助你更快更好地完成任务,所以尽管大胆提交PR吧!

此文章为搬运
[原项目链接].(https://aistudio.baidu.com/aistudio/projectdetail/5297897)

Logo

学大模型,用大模型上飞桨星河社区!每天8点V100G算力免费领!免费领取ERNIE 4.0 100w Token >>>

更多推荐