• 高性能深度学习推理引擎 OpenPPL


    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)
    

    image

    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算子的流程:
      image

    添加算子应秉持转换优先,组合次之,最后再新增后端实现的原则。这样既可以快速完成算子添加,也可以避免新添后端的功能校对和性能优化。这里通过展示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

  • 相关阅读:
    Java并发专题 带返回结果的批量任务执行
    angualejs
    Java并发编程:Callable、Future和FutureTask
    mybatis
    InitialContext和lookup
    git 常用使用命令
    junit spring 测试
    redis windows
    为何PS出的RSS总和大于实际物理内存
    32位机器的LowMemory
  • 原文地址:https://www.cnblogs.com/whiteBear/p/16418097.html
Copyright © 2020-2023  润新知