OpenPPL

OpenPPL是商汤基于自研高性能算字库的开源深度学习推理平台,能够让人工智能应用高效可靠地运行在现有的CPU/GPU等计算平台上,为云端场景提供人工智能推理服务

OpenPPL基于全自研高性能算子库,拥有极致调优的性能,同时提供云原生环境下的 AI模型多后端部署能力,并支持OpenMMLab等胜读学习模型的高效部署。

  1. 高性能

    设计微架构友好的任务/数据/指令等多级并行策略,自研NV GPU/X86 CPU计算库,满足部署场景对神经网络推理/常用图像处理的性能需求
  • 支持GPU T4 平台FP16推理
  • 支持CPU X86平台FP32推理
  • 核心算子优化,平均性能领先业界
  1. OpenMMLab部署

    支持OpenMMLab检测/分类/分割/超分等系列前沿模型,同时提供模型前后处理所需图像处理算子
  • 遵循ONNX开放标准,提供ONNX转换支持
  • 支持网络动态特性
  • 提供MMCV算子高性能实现
  1. 云上多后端部署

    面向云端异构推理场景,支持多平台部署
  • 支持X86 FMA & AVX512/NV Turing架构
  • 支持异构设备并行推理

git网址:

PS:以上来源于:https://cloud.tencent.com/developer/article/1854542

OpenPPL操作流程

python版

  1. Creating Engines

    在PPLNN中,引擎Engine指的是运行在特定设备如CPU或INVIDIA GPU上op实现的集合.

    For Example

    构建一个X86引擎工厂:
x86_options = pplnn.x86.EngineOptions()
x86_engine = pplnn.x86.EngineFactory.Create(x86_options)

这样就创建了一个能够运行在兼容X86(X86-compatible)的CPU上

或者

cuda_options = pplnn.cuda.EngineOptions()
cuda_engine = pplnn.cuda.EngineFactory.Create(cuda_options)

创建可以运行在NVIDIA GPU上的引擎

  1. Creating an OnnxRuntimeBuilder

    使用
runtime_builder = pplnn.onnx.RuntimeBuilderFactory.Create()

创建一个onnx.RuntimeBuilder,它将用于创建Runtime实例

  1. Creating a Runtime Instance

    实现从指定文件中加载ONNX模型
onnx_model_file = "/path/to/onnx_model_file"
status = runtime_builder.LoadModelFromFile(onnx_model_file)

创建RuntimeBuderResource,并将前面创建的引擎工厂赋给它.

resources = RuntimeBuilderResources()
resources.engines = [x86_engine] # or = [cuda_engine]
runtime_builder.SetResources(resources)

PPLNN在同一个模型中也支持多个引擎

例如:

resources.engines = [x86_engine, cuda_engine]
status = runtime_builder.SetResources(resources)

模型将被分成多个部分,自动将不同的操作(OP)分配到不同的引擎上.

在创建Runtime实例之前要做些准备

status = runtime_builder.Preprocess()

创建runtime实例

runtime = runtime_builder.CreateRuntime()
  1. Filling Inputs

    我们可以通过下面的Runtime的函数获取图输入
input_count = runtime.GetInputCount()
tensor = runtime.GetInputTensor(idx)

并且,填充输入数据(在此代码段中使用随机数据)

for i in range(runtime.GetInputCount()):
tensor = runtime.GetInputTensor(i)
dims = GenerateRandomDims(tensor.GetShape()) in_data = np.random.uniform(-1.0, 1.0, dims)
status = tensor.ConvertFromHost(in_data)
if status != pplcommon.RC_SUCCESS:
logging.error("copy data to tensor[" + tensor.GetName() + "] failed: " +
pplcommon.GetRetCodeStr(status))
sys.exit(-1)
  1. Evaluating the Model
ret_code = runtime.Run()
  1. Getting Results
for i in range(runtime.GetOutputCount()):
tensor = runtime.GetOutputTensor(i)
shape = tensor.GetShape()
tensor_data = tensor.ConvertToHost()
out_data = np.array(tensor_data, copy=False)

C++版

加载命名空间

using namespace ppl::nn

  1. Creating engines

    使用内建x86::EngineFactory函数:
Engine* x86::EngineFactory::Create(const x86::EngineOptions&);

创建可运行在兼容x86 CPU的引擎:

x86::EngineOptions x86_options;
Engine* x86_engine = x86::EngineFactory::Create(x86_options);

或 可运行在INVIDIA GPUS上的引擎:

cuda::EngineOptions cuda_options;
// ... set options
Engine* cuda::EngineFactory::Create(cuda_options);
  1. Registering Build-in Op Implementations(optional)

    使用x86::RegisterBuiltinOpImpls()加载内建op实现

cuda引擎可以调用cuda::RegisterBuiltinOpImpls()

  1. Creating an ONNX RuntimeBuilder

    使用下面的函数来创建onnx::RuntimeBuilder:
onnx::RuntimeBuilder* onnx::RuntimeBuilderFactory::Create();

从buffer或者文件中加载模型:

ppl::common::RetCode LoadModel(const char* model_file);
ppl::common::RetCode LoadModel(const char* model_buf, uint64_t buf_len);

然后设置用于处理的资源

struct Resources final {
Engine** engines;
uint32_t engine_num;
}; ppl::common::RetCode SetResources(const Resources&)

Resource下的engines是我们创建好的x86_engine:

vector<Engine*> engine_ptrs;
engines.push_back(x86_engine);
resources.engines = engine_ptrs.data();

注: 必须保证在Runtime对象的生命周期中,engines元素是有效的

在同一个模型中,PPLNN也支持多个引擎:

例如:

Engine* x86_engine = x86::EngineFactory::Create(x86::EngineOptions());
Engine* cuda_engine = cuda::EngineFactory::Create(cuda::EngineOptions()); vector<unique_ptr<Engine>> engines;
engines.emplace_back(unique_ptr<Engine>(x86_engine));
engines.emplace_back(unique_ptr<Engine>(cuda_engine));
// TODO add other engines // use x86 and cuda engines to run this model
vector<Engine*> engine_ptrs = {x86_engine, cuda_engine}; Resources resources;
resources.engines = engine_ptrs.data();
resources.engine_num = engine_ptrs.size();
...
  1. Creating a Runtime

    在创建Runtime实例之前,需要做些准备:
builder->Preprocess();

然后使用:

Runtime* RuntimeBuilder::CreateRuntime();

创建Runtime实例

  1. Filling Inputs

    使用Runtime下的函数,获取图输入:
uint32_t Runtime::GetInputCount() const;
Tensor* Runtime::GetInputTensor(uint32_t idx) const;

并填入数据:

for (uint32_t c = 0; c < runtime->GetInputCount(); ++c) {
auto t = runtime->GetInputTensor(c);
auto shape = t->GetShape(); auto nr_element = shape->CalcBytesIncludingPadding() / sizeof(float);
vector<float> buffer(nr_element); // fill random input data
std::default_random_engine eng;
std::uniform_real_distribution<float> dis(-1.0f, 1.0f);
for (uint32_t i = 0; i < nr_element; ++i) {
buffer[i] = dis(eng);
} auto status = t->ReallocBuffer();
if (status != RC_SUCCESS) {
// ......
} // our random data is treated as NDARRAY
TensorShape src_desc = *t->GetShape();
src_desc.SetDataFormat(DATAFORMAT_NDARRAY); // input tensors may require different data format
status = t->ConvertFromHost((const void*)buffer.data(), src_desc);
if (status != RC_SUCCESS) {
// ......
}
}
  1. Evaluating the Compute Graph

使用Runtime::Run():

RetCode status = runtime->Run();
  1. 获取结果

    迭代每一次输出:
for (uint32_t c = 0; c < runtime->GetOutputCount(); ++c) {
auto t = runtime->GetOutputTensor(c); const TensorShape* dst_desc = t->GetShape();
dst_desc->SetDataFormat(DATAFORMAT_NDARRAY);
auto bytes = dst_desc->CalcBytesIncludingPadding();
vector<char> buffer(bytes); auto status = t->ConvertToHost((void*)buffer.data(), *dst_desc);
// ......
}

其他详细的API可访问:https://github.com/openppl-public/ppl.nn

开发指导

如何添加新的Engines/Ops,在不同平台如x86/cuda如何支持op操作

添加新的Engines和Ops

如何添加新的引擎

  1. Define and Implement a Class Inherited from EngineImpl

    EngineImpl(在 src/ppl/nn/engines/engine_impl.h中定义了)定义了PPLNN所需的接口

    通过Runtime实例创建一个EngineContext
EngineContext* EngineImpl::CreateEngineContext();

判断此引擎是否可以运行节点指定的操作

bool EngineImpl::Supports(const ir::Node* node) const;

优化graph并将结果填入info

ppl::common::RetCode EngineImpl::ProcessGraph(const utils::SharedResource&, ir::Graph* graph,
RuntimePartitionInfo* info);

其中RuntimePartitionInfo定义如下:

struct RuntimePartitionInfo final {
std::map<edgeid_t, RuntimeConstantInfo> constants;
std::map<nodeid_t, std::unique_ptr<OptKernel>> kernels;
};

constants是只读的,由多个Runtime实例使用

kernelsOptKernel的集合,用于创建KernelImp实例

创建与该引擎相同类型的实例

EngineImpl* Create() const;

  1. Define and Implement a Class Inherited from EngineContext

EngineContext(在 src/ppl/nn/engines/engine_context.h中定义),EngineContext只被Runtime使用

返回EngineContext的名字

const char* EngineContext::GetName() const;

通过Runtime 返回设备实例

Device* EngineContext::GetDevice();

在调用RuntimeImpl::Run()之前,调用

ppl::common::RetCode EngineContext::BeforeRun();
  1. Define and Implement Op Classes Inherited from OptKernel

    OptKernel(定义在 src/ppl/nn/runtime/opt_kernel.h)储存计算一个OP的所有数据.它能创建多个KernelImpl实例

    创建KernelImpl实例,用于运行时
KernelImpl* OptKernel::CreateKernelImpl() const;
  1. Define and Implement Op Classes Inherited from KernelImpl

    KernelImpl(定义在src/ppl/nn/runtime/kernel_impl.h)是用于计算一个Op的主要类,它由OptKernel创建.每一个KernelImpl实例只能被一个Runtime实例使用
ppl::common::RetCode KernelImpl::Execute(KernelExecContext* ctx);

如何给已有引擎体添加新的Op操作

X86 Engine为例,添加一个新的ONNX op

add a Parameter Parser

ParmParserManger(定义在 src/ppl/nn/models/onnx/param_parser_manager.h)有一个Register()函数

void ParamParserManager::Register(const std::string& domain, const std::string& op_type,
const ParserInfo&);

它可以为新的Ops注册分析器例程

typedef void* (*CreateParamFunc)();
typedef ppl::common::RetCode (*ParseParamFunc)(const ::onnx::NodeProto&, const ParamParserExtraArgs&, ir::Node*, void*);
typedef void (*DeleteParamFunc)(void* param); struct ParserInfo final {
CreateParamFunc create_param;
ParseParamFunc parse_param;
DeleteParamFunc destroy_param;
};

如果没有任何参数,ParserInfo成员应该是nullptr

  1. Add a New Class Inherited from OptKernel

    这依赖于引擎提供的策略.在X86Engine,一个OptKernelCreatorManager'单例用来管理OptKernel`创建者函数
typedef X86OptKernel* (*OptKernelCreator)(const ir::Node*);

ppl::common::RetCode OptKernelCreatorManager::Register(
const std::string& domain, const std::string& type, const utils::VersionRange versions,
OptKernelCreator);

用于注册创建OptKernel实例的函数

  1. Add a New Class Inherited from KernelImpl

    这块和怎样添加新引擎是一样的

X86

支持的算子(Ops)和平台

X86:supported-ops-and-platforms

自定义算子添加

添加自定义算子需要先添加算子定义及其在不同架构上的实现。本节仅介绍在x86架构上添加算子的实现细节。

  1. 概述

    PPLNN在x86架构上添加自定义算子的步骤如下:

  2. 添加算子参数定义与解析(如果算子无需参数,或参数定义与解析已添加,则跳过此步骤)

  3. 添加算子定义,包括数据类型推断、维度计算、数据排布选择等

  4. 添加算子调用接口

  5. 添加kernel函数

几点约定:

  • 本章中自定义算子的名称将以来代替。
  • 本章将以框架中已实现的LeakyReLU为例,来帮助理解添加自定义算子的流程。在介绍时会根据需要省略一部分代码,完整代码请直接参考源码。
  • LeakyReLU算子的定义可参见onnx文档:https://github.com/onnx/onnx/blob/master/docs/Operators.md#LeakyRelu
  1. 添加算子参数定义与解析

    若算子参数定义与解析已添加,则跳过此步

1.1. 添加参数定义

若算子无参数,则跳过此步骤

在ppl.nn/src/ppl/nn/params目录下创建<domain_name>/_param.h,用于定义参数结构体。 定义参数结构体时需要重载==运算符来支持框架上的图优化操作。

以LeakyReLU为例,其参数定义在ppl.nn/src/ppl/nn/params/onnx/leaky_relu_param.h:

struct LeakyReLUParam {
float alpha; // LeakyReLU仅需要一个参数alpha
bool operator==(const LeakyReLUParam& p) const { return this->alpha == p.alpha; } // 对==运算符进行重载
};

LeakyReLUParam定义了LeakyReLU算子所需的参数。参考onnx的LeakyReLU定义可知,该算子仅需一个float型的参数alpha。另外LeakyReLUParam重载了运算符==,用于判断两个参数对象是否相等。

1.2. 添加 参数解析函数

若算子无需参数,则跳过此步骤

在ppl.nn/src/ppl/nn/models/onnx/parsers目录下创建parse_param.h和parse_param.cc,将参数解析函数放在其中。

以LeakyReLU为例,其参数解析函数实现在ppl.nn/src/ppl/nn/models/onnx/parsers/parse_leaky_relu_param.cc内:

ppl::common::RetCode ParseLeakyReLUParam(const ::onnx::NodeProto& pb_node, void* arg, ir::Node*, ir::GraphTopo*) {
auto param = static_cast<ppl::common::LeakyReLUParam*>(arg);
param->alpha = utils::GetNodeAttrByKey<float>(pb_node, "alpha", 0.01); // 从onnx中解析alpha字段,如果不存在则使用默认值0.01
return ppl::common::RC_SUCCESS;
}

函数ParseLeakyReLUParam读入onnx node信息pb_node,通过GetNodeAttrByKey解析其中的alpha字段,放入上文定义的参数结构体中,完成参数解析。

1.3. 注册参数和参数解析函数

添加好参数结构体和参数解析函数后,需要将其注册在ppl.nn/src/ppl/nn/models/onnx/param_parser_manager.cc的ParamParserManager()函数中。

注册时,有两个宏可以使用:

  • PPL_REGISTER_OP_WITHOUT_PARAM:用于注册无需参数的算子
  • PPL_REGISTER_OP_WITH_PARAM:用于注册需要参数的算子

    以LeakyReLU为例:
PPL_REGISTER_OP_WITH_PARAM("", "LeakyRelu", ppl::nn::common::LeakyReLUParam, ParseLeakyReLUParam);

其中第一个参数为domain_name,必须跟onnx模型中的domain保持一致;第二个参数"LeakyRelu"为op_type,必须跟onnx模型中的op_type保持一致;第三、四个参数分别为上文定义的参数结构体和解析函数。

  1. 添加算子定义

    在ppl.nn/src/ppl/nn/engines/x86/optimizer/<domain_name>目录下添加_op.h和_op.cc,用于定义和实现算子定义。

例如LeakyReLU,其算子定义类放在ppl.nn/src/ppl/nn/engines/x86/optimizer/ops/onnx/leaky_relu_op.h内:

class LeakyReluOp final : public X86OptKernel {
public:
LeakyReluOp(const ir::Node* node)
: X86OptKernel(node) {}
ppl::common::RetCode Init(const OptKernelOptions& options) override; // 初始化(必需)
KernelImpl* CreateKernelImpl() const override; // 创建调用接口(必需)
ppl::common::RetCode SelectFormat(const InputOutputInfo& info, // 排布选择(可选)
std::vector<ppl::common::dataformat_t>* selected_input_formats,
std::vector<ppl::common::dataformat_t>* selected_output_formats) override; private:
std::shared_ptr<ppl::common::LeakyReLUParam> param_; // 参数结构体指针(可选,若算子无需参数则无需此变量)
};
  • Init(必需):进行一些初始化操作,如加载参数、注册数据类型推断函数、注册维度计算函数等
  • CreateKernelImpl(必需):创建算子调用借口对象
  • SelectFormat(可选):用于数据排布选择
  • param_(可选):上文定义的参数结构体指针,若算子无需参数则无需此变量

可根据实际需要,添加或删除部分成员函数和成员变量。

2.1. 注册数据类型推断函数

数据类型推断函数用于根据输入的数据类型,推断出输出的数据类型。

需要在Init函数中将数据类型推断函数注册到infer_type_func_infer_type_func_是一个std::function对象,输入InputOutputInfo*,返回void。 可以用函数、lambda表达式来定义数据类型推断函数,再将其赋值给infer_type_func_即可完成注册。

例如LeakyReLU,其在ppl.nn/src/ppl/nn/engines/x86/optimizer/ops/onnx/leaky_relu_op.cc的Init函数中注册了数据类型推断函数:

infer_type_func_ = GenericInferType;

GenericInferType是一个框架预定义的数据类型推导函数,其代码在ppl.nn/src/ppl/nn/engines/x86/optimizer/opt_kernel.h:

static void GenericInferType(InputOutputInfo* info) {   // 所有输出的数据类型和第一个输入的数据类型保持一致
auto& in_shape0 = info->GetInput<TensorImpl>(0)->GetShape();
for (uint32_t i = 0; i < info->GetOutputCount(); ++i) {
auto out_shape = &info->GetOutput<TensorImpl>(i)->GetShape();
out_shape->SetDataType(in_shape0.GetDataType());
}
}

可根据自定义算子的需要,注册预定义的数据类型推断函数或自定义的函数。

2.2. 注册维度计算函数

维度计算函数用于根据输入的数据维度,推断输出的数据维度.

需要在Init函数中将维度计算函数注册到infer_dims_func_.

infer_dims_func_是一个std::function对象,输入InputOutputInfo*,返回ppl::common::RetCode.可以用函数/lambda表达式来定义维度计算函数,再将其赋值给infer_dims_func_即可完成注册

例如LeakyReLu,其在ppl.nn/src/ppl/nn/engines/x86/optimizer/ops/onnx/leaky_relu_op.cc的Init函数中注册了维度计算函数:

infer_dims_func_ = [](InputOutputInfo* info) -> RetCode {
return oputils::ReshapeLeakyReLU(info, nullptr);
};

其中,ReshapeLeakyReLU代码在ppl.nn/src/ppl/nn/oputils/onnx/reshape_leaky_relu.cc中:

RetCode ReshapeLeakyReLU(InputOutputInfo* info, const void*) {
if (info->GetInputCount() != 1 || info->GetOutputCount() != 1) {
return RC_INVALID_VALUE;
}
const TensorShape& in_shape0 = info->GetInput<TensorImpl>(0)->GetShape();
auto out_shape0 = &info->GetOutput<TensorImpl>(0)->GetShape();
if (in_shape0.IsScalar()) {
out_shape0->ReshapeAsScalar();
} else {
out_shape0->Reshape(in_shape0.GetDims(), in_shape0.GetDimCount());
}
return RC_SUCCESS;
}

在注册自定义算子维度计算函数时,既可以像LeakyReLU一样在ppl.nn/src/ppl/nn/oputils下写单独的Reshape函数,也可以直接在Init函数中写完所有逻辑。

2.3. 编写数据排布选择函数

数组排布选择函数SelectFormat更加参数/输入的数据类型/排布/维度/算子底层支持的排布等信息,选择该算子需要的输入数据排布,以及输出的数据排布

算子的输入排布和算子需要的输入排布可以不同.

算子的输入排布是指在本算子进行排布选择之前,输入数据的真实排布(通常是由上一个算子的输出或网络的输入决定);

算子需要的输入排布是指算子根据参数、输入的数据类型、排布、维度、算子底层支持的排布等信息,选择出该算子所需要的输入排布。 当两者不同时,框架会自动插入一个排布转换算子,用于转换不同的数据排布。

目前,x86 架构支持的数据排布有:

  • NDARRY(对于4维来说,即为NCHW)
  • N16CX(对于4维来说,即为N16CHW,或为N16CHWc16,NCHWc16)

以LeakyReLU为例,其排布选择函数在ppl.nn/src/ppl/nn/engines/x86/optimizer/ops/onnx/leaky_relu_op.cc内:

RetCode LeakyReluOp::SelectFormat(const InputOutputInfo& info,
vector<dataformat_t>* selected_input_formats, // 需要的输入数据排布,默认全为NDARRAY
vector<dataformat_t>* selected_output_formats // 输出数据排布,默认全为NDARRAY
) {
if (info.GetInput<TensorImpl>(0)->GetShape().GetDataFormat() == DATAFORMAT_N16CX) { // 算子输入的数据排布
selected_input_formats->at(0) = DATAFORMAT_N16CX;
selected_output_formats->at(0) = DATAFORMAT_N16CX;
}
return RC_SUCCESS;
}

selected_input_formats是算子需要的输入数据排布,selected_output_formats是算子输出的数据排布,默认值全为NDARRAY.由于LeakyReLU实现了NDRRAY和N16CX两个排布的版本,因此当算子的输入排布为N16CX时,算子u需要的输入排布和输出排布选择N16CX; 当输入为NDARRAY格式时,需要的输入排布和输出排布选择默认值NDARRAY,函数不做任何动作.

在添加自定义算子时,如果算子仅实现了NDARRAY版本(输入输出仅支持NDARRAY排布),SelectFormat函数可以不写,这时会使用基类的默认排布选择函数,需要的输入和输出排布会选择NDARRAY.

2.4. 添加CreateKernelImpl

CreateKernelImpl函数用于创建算子调用借口,根据算子是否需要参数,可使用两种函数:

  • CreateKernelImplWIthoutParam:用于无需参数的算子
  • CreateKernelImplWithParam:用于需要参数的算子,需要传入参数结构体的指针

LeakyReLU使用的是带参数的版本,其实现在ppl.nn/src/ppl/nn/engines/x86/optimizer/ops/onnx/leaky_relu_op.cc:

KernelImpl* LeakyReluOp::CreateKernelImpl() const {
return CreateKernelImplWithParam<LeakyReluKernel>(param_.get()); // 需要传入参数结构体的指针
}

2.5. 注册算子定义

完成算子定义后,需要使用宏REGISTER_OPT_KERNEL_CREATOR将其注册在ppl.nn/src/ppl/nn/engines/x86/optimizer/opt_kernel_creator_manager.cc的OptKernelCreatorManager()函数中。

以LeakyReLU为例:

REGISTER_OPT_KERNEL_CREATOR("", "LeakyRelu", LeakyReluOp);

第一个参数为domain;第二个参数是为op_type;第三个参数为上文定义的算子定义类的名称.

  1. 添加算子调用接口

    在ppl.nn/src/ppl/nn/engines/x86/kernels/<domain_name>目录下添加_kernel.h和_kernel.cc,用于定义和实现算子调用接口。

例如LeakyReLU,其算子调用接口定义在ppl.nn/src/ppl/nn/engines/x86/kernels/onnx/leaky_relu_kernel.h:

class LeakyReluKernel : public X86Kernel {
public:
LeakyReluKernel(const ir::Node* node) : X86Kernel(node) {}
void SetParam(const ppl::nn::common::LeakyReLUParam* p) { param_ = p; } // 若算子无参数则无需此函数
private:
ppl::common::RetCode DoExecute(KernelExecContext*) override; // 算子调用函数
private:
const ppl::nn::common::LeakyReLUParam* param_ = nullptr; // 若算子无参数则无需此函数
};

若算子无参数的话,SetParam和param_无需添加,只需构造函数和DoExecute函数即可。

3.1. 添加DoExecute

DoExcute函数读入算子输入,调用kernel函数,得到算子输出.

以LeakyReLU为例,其DoExecute函数在ppl.nn/src/ppl/nn/engines/x86/kernels/onnx/leaky_relu_kernel.cc:

ppl::common::RetCode LeakyReluKernel::DoExecute(KernelExecContext* ctx) {
auto x = ctx->GetInput<TensorImpl>(0); // 输入tensor
auto y = ctx->GetOutput<TensorImpl>(0); // 输出tensor PPLNN_X86_DEBUG_TRACE("Op: %s\n", GetName().c_str()); // 输出调试信息,仅在Debug模式下生效
PPLNN_X86_DEBUG_TRACE("Input [x]:\n");
PPL_X86_TENSOR_PRINT_DEBUG_MSG(x);
PPLNN_X86_DEBUG_TRACE("Output [y]:\n");
PPL_X86_TENSOR_PRINT_DEBUG_MSG(y);
PPLNN_X86_DEBUG_TRACE("alpha: %f\n", param_->alpha);
PPLNN_X86_DEBUG_TRACE("isa: %u\n", GetISA()); const auto data_type = x->GetShape().GetDataType(); if (data_type == ppl::common::DATATYPE_FLOAT32) { // 数据类型判断
if (MayUseISA(ppl::common::ISA_X86_AVX)) { // 判断是否支持avx指令集
return kernel::x86::leaky_relu_fp32_avx(&y->GetShape(), x->GetBufferPtr<float>(), // avx kernel函数
param_->alpha, y->GetBufferPtr<float>());
} else if (MayUseISA(ppl::common::ISA_X86_SSE)) { // 判断是否支持sse指令集
return kernel::x86::leaky_relu_fp32_sse(&y->GetShape(), x->GetBufferPtr<float>(), // sse kernel函数
param_->alpha, y->GetBufferPtr<float>());
} else {
LOG(ERROR) << "get unsupported isa " << GetISA();
}
} else {
LOG(ERROR) << "unsupported data type: " << ppl::common::GetDataTypeStr(data_type) << ".";
} return ppl::common::RC_UNSUPPORTED;
}

PPLNN_X86_DEBUG_TRACEPPL_X86_TENSOR_PRINT_DEBUG_MSG用于调试信息,仅在编译Debug模式下生效

MayUseISA函数用于判断当前环境是否可以执行指定的ISA,从而调用不同的kernel函数.常用的ISA有:AVX512、FMA、AVX、SSE。当使用标量代码时,可不做此判断。

3.2. 添加CanDoExcute

CanDoExcute函数执行在DoExecute之前,用于判断是否可以执行DoExecute

大部分算子使用基类的实现(ppl.nn/src/ppl/nn/engines/x86/kernel.cc):

bool X86Kernel::CanDoExecute(const KernelExecContext& ctx) const {  // 如果输入中存在空tensor,则返回false,否则返回true
for (uint32_t i = 0; i < ctx.GetInputCount(); ++i) {
auto tensor = ctx.GetInput<TensorImpl>(i);
if (!tensor || tensor->GetShape().CalcBytesIncludingPadding() == 0) {
return false;
}
}
return true;
}

绝大多数情况不需要重载此函数。如果需要使用跟基类不同的行为,则需要重载此函数。

  1. 添加kernel函数

    x86的kernel函数是最底层的计算函数,放在ppl.nn/src/ppl/nn/engines/x86/impls目录下。

由于kernel函数跟上层框架间的耦合度较低,因此可根据自定义算子的特点,自由的安排代码结构。这里仅给出通用的编写kernel函数的规范参考,可不必严格按照本章的方式编写。

4.1. kernel函数声明

kernel函数的接口声明统一放在ppl.nn/src/ppl/nn/engines/x86/impls/include/ppl/kernel/x86目录下,按照数据类型放在不同的子目录下。建议的文件路径为ppl.nn/src/ppl/nn/engines/x86/impls/include/ppl/kernel/x86/<data_type>/.h

函数输入参数可根据需要自行定义,返回一个ppl::common::RetCode用于指示函数是否执行成功。

函数命名建议:<data_format><特化描述><data_type><isa_type>

例如LeakyReLU的fp32 kernel函数接口声明在ppl.nn/src/ppl/nn/engines/x86/impls/include/ppl/kernel/x86/fp32/leaky_relu.h下,其函数命名为leaky_relu_fp32_avx和leaky_relu_fp32_sse:

ppl::common::RetCode leaky_relu_fp32_avx(   // avx kernel函数声明
const ppl::nn::TensorShape *src_shape,
const float *src,
const float alpha,
float *dst); ppl::common::RetCode leaky_relu_fp32_sse( // sse kernel函数声明
const ppl::nn::TensorShape *src_shape,
const float *src,
const float alpha,
float *dst);

这里不使用<data_format>字段是因为LeakyReLU的实现支持任意数据格式。

4.2 Kernel函数实现

kernel函数的实现放在ppl.nn/src/ppl/nn/engines/x86/impls/src/ppl/kernel/x86目录下,按照数据类型放在不同的子目录下。由于kernel函数的实现可能需要多个文件,因此建议每个算子单独建立一个目录。 不同ISA架构的代码应该放在不同的.cpp文件内,并在文件命名时以ISA架构名来区分。

建议的文件路径为ppl.nn/src/ppl/nn/engines/x86/impls/src/ppl/kernel/x86/<data_type>//<data_type><isa_type>.cpp。这种文件命名方式会被ppl.nn/src/ppl/nn/engines/x86/impls/CMakeLists.txt所识别并自动加上对应ISA的编译指令。

以LeakyReLU为例,该算子实现了fp32的avx和sse版本,因此在ppl.nn/src/ppl/nn/engines/x86/impls/src/ppl/kernel/x86/fp32/leaky_relu目录下有leaky_relu_fp32_avx.cpp和leaky_relu_fp32_sse.cpp两个文件,分别实现了leaky_relu_fp32_avx和leaky_relu_fp32_sse函数。

  1. 几点说明

    5.1 编译

    添加自定义算子后,由于有新增的.cpp文件,需要将CMakeCache.txt删除后重新运行cmake,否则会提示找不到符号的问题。

若kernel的文件命名格式不能被ppl.nn/src/ppl/nn/engines/x86/impls/CMakeLists.txt识别,则会报ISA指令不支持的错误。

X86 Benchmask工具

详细参考:https://github.com/openppl-public/ppl.nn/blob/master/docs/cn/x86-doc/benchmark_tool.md

CUDA

自定义算子添加

  1. 概述

    PPLNN算子cuda实现步骤如下:

  2. 添加算子定义(若本身存在参数,则需要导入算子参数)

  3. 添加算子输入输出的数据类型

  4. 添加算子维度计算

  5. 添加算子数据排布

  6. 添加算子调用接口

  7. 添加算子的cuda后端的kernel实现

  8. 注册算子

  9. 添加算子定义

    添加算子类型名称以MyOp为例。

首先,在ppl/nn/engines/cuda/optimizer/ops文件下创建一个新的文件夹,如MyOps。在该文件下通过创建MyOp.h文件来添加自定义算子。

若算子存在参数,在MyOp.h中添加参数的实例用于保存相关参数;同时,在src/ppl/common/params/ppl文件下创建myop_param.h,用于添加算子参数的定义。

MyOp.h结构如下:

class MyOp final : public CudaOptKernel {
public:
MyOp(const ir::Node* node) : CudaOptKernel(node) {}
KernelImpl* CreateKernelImpl() const override;
ppl::common::RetCode Init(const OptKernelOptions&) override;
ppl::common::RetCode Finalize(const OptKernelOptions& options) override;
private:
ppl::common::MyOpParam param_;
};
  1. 添加算子输入输出的数据类型

    算子输入输出的数据类型通过ppl::nn::cuda::MyOp::init()中用自定义函数infer_type_func设置。目前有几个通用的函数可以调用:
InferDefaultType(info, type);  // 将所有input output设置为指定的type类型
InferHighestType(info, mask); // 选择input中精度最高的类型,将所有input output设置为该类型
InferInheritedType(info); // input保持上层输入的类型,并将output设置为input0的数据类型

用户可以选择合适的函数或者根据实际需求设计独立的函数。指定类型调用示例 (参数type在没有外部指定的情况下,默认为DATATYPE_UNKNOWN)

infer_type_func_ = [this] (InputOutputInfo* info, datatype_t type) -> RetCode {
if (type == ppl::common::DATATYPE_UNKNOWN)
type = ppl::common::DATATYPE_FLOAT16;
return InferDefaultType(info, type);
};
  1. 添加算子维度计算

    算子形状推断通过ppl::nn::cuda::MyOp::init()中以自定义函数infer_dims_func设置。目前框架支持的所有onnx算子的形状推断在ppl/nn/oputils文件夹下,可用于参考。

    输出形状继承输入形状的示例:
infer_dims_func_ = [this] (InputOutputInfo* info) -> RetCode {
auto& in_shape0 = info->GetInput<TensorImpl>(0)->GetShape();
info->GetOutput<TensorImpl>(0)->GetShape().Reshape
(in_shape0.GetDims(), in_shape0.GetRealDimCount());
return RC_SUCCESS;
};
  1. 添加算子的数据排布

    目前框架支持两种排布,NDARRAY (NCHW) NHWC,根据这两种排布目前将所有算子的输入输出排布分为四种,新增算子具体使用那种排布可以在ppl/nn/engines/cuda/algos/algo_normal.h文件中设置。

默认设置为算子所有的输入输出都固定为NDARRAY,若自定义算子也使用NDARRAY输入输出的排布,则无需添加任何内容,其它情况操作遵循如下规则:

  1. 若自定义算子仅支持输入输出为NHWC格式,则需要将自定义算子的名称写入nhwc_set_中;
  2. 若自定义算子的输出与输入排布保持一致(类似Add算子),则需要将自定义算子的名称写入inherited_set_中;
  3. 若自定义算子可以接受所有排布的输入并且固定以NDARRAY作为输出(类似Shape算子),则需要将自定义算子的名称写入arbitrary_set_中

此外,当自定义算子包含多种算法实现时,框架支持在预处理阶段选算法,统计每种算法的时间并将最优结果记录下来,让算子可以在执行过程中执行计算效率最高的算法.

添加选算法需要在ppl/nn/engines/cuda/algos文件夹下添加algo_myop.cc和algo_myop.h两个文件,并在algo_filter_manager.cc中进行注册。选算法实现过程可以借鉴alog_conv.h文件中的TuringHMMAImpgemm::ExcuteTimer()函数。

  1. 添加算子调用接口

    在ppl/nn/engines/cuda/kernels文件夹中添加一个新的文件夹MyKernels。在MyKernels中添加自定义算子的调用接口。常规算子只需要构造函数和DoExcute()两个函数;当自定义算子包含参数时,需要添加SetParam()函数。默认的CanDoExcute()函数不允许有空tensor作为输入。当自定义算子支持空tensor输入时需要重写CanDoExcute()函数(例如Resize算子)

调用接口声明示例:

class MyOpKernel : public CudaKernel {
public:
MyOpKernel(const ir::Node* node) : CudaKernel(node) {}
void SetParam(const ppl::common::MyOpParam* p) { param_ = p; }
private:
ppl::common::RetCode DoExecute(KernelExecContext*) override;
private:
const ppl::common::MyOpParam* param_ = nullptr;
};

用户需要手动完成DoExcute()函数,调用自定义算子的cuda实现并将结果写入输出tensor。

  1. 添加cuda实现

    添加cuda算子的流程:

添加算子应秉持转换优先,组合次之,最后再新增后端实现的原则。这样既可以快速完成算子添加,也可以避免新添后端的功能校对和性能优化。这里通过展示OpenPPL中已有算子的实现来介绍三种添加算子的方法。

7.1 算子转换

如果新增算子的功能是已有若干个算子的功能交集,则可以特化已有算子的子功能作为新增算子的特定模式,ONNX.DepthToSpace即是这种情况。在ppl.kernel.cuda/src/nn/depth_to_space.cu中可以看到,DepthToSpace的DCR(depth-column-row)模式可以通过Resahpe+Transpose来实现,而CDR (column-row-depth)和已有算子SubpixelUp的功能是一致的,直接调用即可。

7.2 算子组合

对于无法通过转换实现的算子,可以试图分解算子的计算步骤,然后在每一步骤利用已有算子实现。ONNX.Softmax就可以通过这种方式实现,具体可见ppl.kernel.cuda/src/nn/softmax.cu。可以看到Softmax可以分解为Reduce+Sub+Exp+Reduce+Div五个子步骤,每个步骤都可以在已有算子中找到对应的实现。值得注意的是,为了在不同步骤之间传输数据,需要申请临时存储空间。申请临时空间的接口是ppl.nn/cuda/kernels中的CalcTmpBufferSize(const KernelExecContext& ctx),空间的大小由算子的计算步骤决定。

7.3 新增后端

对于无法通过转换和组合实现的算子,可以新增该算子的CUDA实现。新增后端实现涉及ppl.kernel.cuda中的两个文件,分别是include中的头文件my_kernel.h和src中的实现文件my_kernel.cu。算子接口的命名和形参列表的顺序及类型都应参考已有接口的定义。CMakeLists.txt中已经实现了src目录下的文件搜索,所以无需改动。

8 注册算子

最后需要在ppl/nn/engines/cuda/optimizer/opt_kernel_creator_manager.h中注册自定义算子,domain为域名,默认为空,type为算子类型名称,最后为MyOp.h中定义的算子类型。

若新增域名,BridgeOp需同时加入到新域名中。注册示例:

REGISTER_OPT_KERNEL_CREATOR("new_domain", "Bridge", BridgeOp);
REGISTER_OPT_KERNEL_CREATOR("new_domain", "MyOp", MyOp)

其他的如RISCV/ARM等可参考:https://github.com/openppl-public/ppl.nn

高性能深度学习推理引擎 -- OpenPPL的更多相关文章

  1. NVIDIA TensorRT高性能深度学习推理

    NVIDIA TensorRT高性能深度学习推理 NVIDIA TensorRT 是用于高性能深度学习推理的 SDK.此 SDK 包含深度学习推理优化器和运行时环境,可为深度学习推理应用提供低延迟和高 ...

  2. TX2之多线程读取视频及深度学习推理

    背景 一般在TX2上部署深度学习模型时,都是读取摄像头视频或传入视频文件进行推理,从视频中抽取帧进行目标检测等任务.对于大点的模型,推理的速度是赶不上摄像头或视频的帧率的,如果我们使用单线程进行处理, ...

  3. 【ARM-Linux开发】【CUDA开发】【深度学习与神经网络】Jetson Tx2安装相关之三

    JetPack(Jetson SDK)是一个按需的一体化软件包,捆绑了NVIDIA®Jetson嵌入式平台的开发人员软件.JetPack 3.0包括对Jetson TX2 , Jetson TX1和J ...

  4. TensorRT深度学习训练和部署图示

    TensorRT深度学习训练和部署 NVIDIA TensorRT是用于生产环境的高性能深度学习推理库.功率效率和响应速度是部署的深度学习应用程序的两个关键指标,因为它们直接影响用户体验和所提供服务的 ...

  5. 由微软打造的深度学习开放联盟ONNX成立

    导读 如今的微软已经一跃成为全球市值最高的高科技公司之一.2018年11月底,微软公司市值曾两次超越了苹果,成为全球市值最高的公司,之后也一直处于与苹果胶着的状态.市场惊叹微软是一家有能力改造自己并取 ...

  6. 阿里开源首个移动AI项目,淘宝同款推理引擎

    淘宝上用的移动AI技术,你也可以用在自己的产品中了. 刚刚,阿里巴巴宣布,开源自家轻量级的深度神经网络推理引擎MNN(Mobile Neural Network),用于在智能手机.IoT设备等端侧加载 ...

  7. 深度学习的异构加速技术(一):AI 需要一个多大的“心脏”?

    欢迎大家前往腾讯云社区,获取更多腾讯海量技术实践干货哦~ 作者:kevinxiaoyu,高级研究员,隶属腾讯TEG-架构平台部,主要研究方向为深度学习异构计算与硬件加速.FPGA云.高速视觉感知等方向 ...

  8. 时间序列深度学习:seq2seq 模型预测太阳黑子

    目录 时间序列深度学习:seq2seq 模型预测太阳黑子 学习路线 商业中的时间序列深度学习 商业中应用时间序列深度学习 深度学习时间序列预测:使用 keras 预测太阳黑子 递归神经网络 设置.预处 ...

  9. MLPerf结果证实至强® 可有效助力深度学习训练

    MLPerf结果证实至强 可有效助力深度学习训练 核心与视觉计算事业部副总裁Wei Li通过博客回顾了英特尔这几年为提升深度学习性能所做的努力. 目前根据英特尔 至强 可扩展处理器的MLPerf结果显 ...

  10. 阿里开源!轻量级深度学习端侧推理引擎 MNN

    阿里妹导读:近日,阿里正式开源轻量级深度学习端侧推理引擎“MNN”. AI科学家贾扬清如此评价道:“与 Tensorflow.Caffe2 等同时覆盖训练和推理的通用框架相比,MNN 更注重在推理时的 ...

随机推荐

  1. 记录-C#给图片增加文字

    业务需要动态给图片增加文字(书本的封面图片),修改字体大小.字体.颜色.控制位置 测试代码: 1 string path = @"E:\cover.png"; 2 3 Bitmap ...

  2. Matlab转python的索引问题

    python 中numpy库可以实现类似matlab多维数组的运算.但两者在索引方式上存在一些差异.这是需要注意的.例如: % 定义一个4*4矩阵 A=1:16; A=reshape(A,[4,4]) ...

  3. RCE_STUDY

    概念 RCE(Remote code execution)远程代码执行漏洞,RCE又分命令执行和代码执行. RCE-远程代码执行:远程执行PHP代码 RCE-远程命令执行:远程执行Linux或者Win ...

  4. WebScoket-服务器客户端双向通信

    WebScoket学习笔记 1. 消息推送常用方式介绍 轮询 浏览器以指定的时间间隔向服务器发出HTTP请求,服务器实时返回数据给浏览器. 长轮询 浏览器发出ajax请求,服务器端接收到请求后,会阻塞 ...

  5. ERROR: Unexpected bus error encountered in worker. This might be caused by insufficient shared memory (shm).

    报错 ERROR: Unexpected bus error encountered in worker. This might be caused by insufficient shared me ...

  6. linux安装python centos

    下载安装包 可以到官网 ftp 地址,复制指定 python 版本源码安装包下载链接 https://www.python.org/ftp/python/ 或者到官网 downloads, 复制指定 ...

  7. Git工作流介绍

    前言 工作流其实不是一个初级主题,背后的本质问题其实是有效的项目流程管理和高效的开发协同约定,不仅是Git或SVN等SCM工具的使用. 集中式工作流 如果你的开发团队成员已经很熟悉Subversion ...

  8. 《HelloGitHub》第 108 期

    兴趣是最好的老师,HelloGitHub 让你对开源感兴趣! 简介 HelloGitHub 分享 GitHub 上有趣.入门级的开源项目. github.com/521xueweihan/HelloG ...

  9. Quartz.NET - 教程 12: Quartz 的其他特性

    译者注: 目录在这 Quartz.NET 3.x 教程 原文在这 Lesson 12: Miscellaneous Features of Quartz 插件 Quartz 提供了一个用于插入附加功能 ...

  10. SpringBoot校验请求Json参数

    spring boot 校验请求json参数 在后端开发中,通过接口和参数传输来进行与前端交互,才可以让一个项目成型. 由于全后端隔离的方式,所以有时候需要不那么信任前端,也就是在验证当前操作是否有权 ...