ONNC Open Neural Network Compiler 开源神经网络编译器

github

gitee

gitee博文

ONNC是一个集合了开源、模块化、可重用编译器算法和工具链的库,专门针对深度学习加速器(DLA)。ONNC从底层开始构建,旨在将ONNX中间表示(IR)翻译成专有DLA代码。 其软件架构设计强调可移植性和可重用性,从而简化了开发新后端的工作。上图展示了ONNC软件栈的顶层框图。 软件栈从导入ONNX计算图模型到输出相应的硬件二进制文件,展示了各个功能块。 除了利用LLVM后端,ONNC还定义了ONNC IR,这是一种与ONNX IR具有一对一映射关系的中间表示,为专有DLA执行ONNX模型提供了另一条快速通道。 在深度学习系统中,另外两个流行的编译框架TVM和Glow在其软件栈的顶层构建了LLVM后端。在映射到硬件操作符时,LLVM的中间表示具有比ONNC IR更细的粒度。 对于使用粗粒度操作符(如卷积)构建的加速器,修改LLVM后端需要更多的移植工作。 许多DLA设计,如Nvidia的NVDLA和Bitman的Sophon BM168X系列,更喜欢使用粗粒度操作符而不是LLVM操作符。 在这些情况下,ONNC提供了更直接的方式,使用其自身的Vanilla后端将ONNX模型转换为目标二进制文件,从而加快编译器向新硬件的移植速度。 为了快速移植,用户只需将Vanilla后端作为模板复制,最少重写两个软件管道,添加可选的优化传递,框架将像魔术一样处理其余的工作。

ONNC框架主要包含三部分内容:

前端输入:ONNC支持的前端模型是ONNX 模型

ONNC IR: ONNC自定义 IR,即ONNX IR -> ONNC IR

后端支持:

ONNX IR -> ONNC IR -> LLVM IR cpu后端

ONNX IR -> ONNC IR -> DLA Machine codes npu后端

官方示例

开发准备

# 获取开发镜像

docker pull onnc/onnc-community

# 获取onnc源代码

git clone https://github.com/ONNC/onnc.git

# 启动开发镜像

// Use the interactive mode to enter the Docker prompt.

$ docker run -ti --rm -v :/onnc/onnc onnc/onnc-community

# 主机 onnc源代码

# /onnc/onnc 镜像中的挂载点

编译流程

包括5个流程:

addTensorSel 添加张量属性? onnxIR->onncIR

ONNC IR 优化 optimization

ONNC IR 执行 调度 顺序优化 batter schedule the exec order of ONNC IR

内存分配阶段 为输入数据、激活值、权重值分配内存

后端代码生成和优化 codeemit

ONNC IR 架构

扩展 onnx 算子 成 支持特定目标的运算符

onnc IR 包含了两种:

通用IR generic operators:

例如 卷积convolution, 激活ReLU, 池化max pooling等,是neural network models神经网络模型中常用的算子。

源代码位于: include/onnc/IR/Compute, 与 ONNX 算子一一对应, 算子详情可参考 onnx官网

class ATen : public ComputeOperator

class Abs : public ComputeOperator

class BatchNormalization : public ComputeOperator

class Conv : public ComputeOperator

class DepthToSpace : public ComputeOperator

class GlobalAveragePool : public ComputeOperator

class InstanceNormalization : public ComputeOperator

class Sigmoid : public ComputeOperator

class Softmax : public ComputeOperator

class Transpose : public ComputeOperator

特定目标的IR target-specific operators,特殊用途的算子

融合运算符:

与通用运算符不同,特定目标运算符是为定制的DLA硬件设计量身定制的运算符。一个很好的例子是一个融合运算 fused operator符,它先进行卷积操作,然后进行ReLU操作。某些DLA硬件(如NVDLA,http://nvdla.org/)提供了一种特殊模式,可以将这两个运算符融合在一起,并在一个流水线上执行它们。融合模式可以提高性能并降低功耗,因为卷积输出被重定向到ReLU计算单元中,而无需通过DLA外部的系统内存进行昂贵的传递。换句话说,消除了对内存访问的需求。为了利用这种模式,您需要为新型复合运算符扩展ONNC IR,以便ONNC优化过程可以针对支持的硬件特性进行调整。例如,如果卷积和ReLU融合在一起,则无需为卷积输出分配内存。

特殊加载和保存操作:

另一种情况是加载和存储运算符。一些DLA硬件包含像CPU区域中的暂存存储器这样的内部存储器(近核缓存)。提供了显式的加载/存储运算符来控制内部和外部系统存储器之间的数据移动。它们也是一类常见的特定目标运算符。

通常,如果目标硬件支持任何无法用通用运算符描述的 命令/功能/模式,则应在ONNC IR级别实现 特定目标的运算符 以进行 优化或后端代码 生成。

支持 TSO 算子,有两个难点:

将模型映射到包含特定目标运算符的计算图computation graph中。将新运算符集成到ONNC的优化opt pass过程中。

在ONNC中,首先将onnx模型转换为ONNC IR。ONNC IR是通过图的数据结构进行表示的,其中节点代表运算符op,边代表运算符之间的数据依赖关系。 最初,每个节点都是一个通用运算符。任何特定目标信息的注入都是对初始图进行后处理的步骤。 简而言之,就是删除不必要的通用运算符节点,并在图中插入特定目标运算符节点。ONNC框架提供了API来简化模型的编辑。

扩展的特定目标运算符要么融合多个通用运算符,要么引入新的硬件功能。 在任一情况下,我们都需要将模型转换为包含新运算符的计算图。对于运算符融合,ONNC框架提供了API来简化图的重写。对于新的硬件功能,用户需要定义一个新的IR,并修改从模型到ONNC计算图的转换。

此外,还必须修改一些优化过程以识别新运算符。例如,代码发射code emit过程将每个ONNC IR运算符转换为低级机器代码,需要添加新函数来翻译新运算符。可能还有其他依赖于IR设计的优化过程。始终建议检查所有正在使用的过程,以便为IR扩展进行适当的修改。此外,ONNC框架试图通过采用适当的设计模式来简化扩展任务,这将在后面介绍。

卷积核激活融合算子添加示例

说明如何将融合卷积和ReLU的复合运算符添加到ONNC IR中。

安装开发环境镜像 docker pull onnc/onnc-community运行镜像 docker run -ti --rm onnc/onnc-community bash编辑融合算子源代码

融合ir定义 Compute/X86ConvRelu.cpp Compute/X86ConvRelu.h

所有算子需要继承自 ComputeOperator,算子 使用 input, output, and operator attributes 来记录算子的特殊信息

// Compute/X86ConvRelu.h

class X86ConvRelu : public ComputeOperator

{

public:

static char ID;

public:

X86ConvRelu(Conv &pConv, Relu &pRelu)

: ComputeOperator("X86ConvRelu", ID), m_Conv(pConv), m_Relu(pRelu) {

}

virtual ~X86ConvRelu() { }

void printAttributes(std::ostream& pOS) const override;

void accept(ComputeVisitor& pV) override;

void accept(ComputeVisitor& pV) const override;

static bool classof(const ComputeOperator* pOp);

Conv m_Conv;

Relu m_Relu;

};

} // namespace of onnc

ONNC采用了访问者设计模式来支持运算符。对于从ComputeOperator类派生的每个新运算符,都必须实现accept()和classof()方法。出于调试目的,还需要实现printAttributes()函数。

#include "X86ConvRelu.h"

// Initially assign a zero value as an indication of invalidity.

// The ONNC framework will re-assign a valid ID.

char X86ConvRelu::ID = 0; // 算子标识符 id

// 属性打印

void X86ConvRelu::printAttributes(std::ostream& pOS) const

{

m_Conv.printAttributes(pOS); // 调用 Conv 算子的

m_Relu.printAttributes(pOS); // 调用 Relu 算子的

}

// accept()在优化过程中被调用,用于执行针对该运算符的特定优化。

// 具体来说,这里利用了访问者设计模式

// 该方法的输入是一个所谓的访问者对象。访问者在调用时以运算符X86ConvRelu本身作为输入,因此实际上是由于C++中的函数重载.

// 调用了访问者对象的visit(X86ConvRelu& op)方法。

// 那个visit()方法包含了针对X86ConvRelu运算符的优化算法。请注意,在访问者中,每个运算符都有其关于特定优化的visit()方法。

// 例如,代码发射过程包含一个访问者,用于实现每个运算符的代码生成。

// Method ‘accept’ comes from the visitor design pattern.

// The input parameter is a visitor object.

void X86ConvRelu::accept(ComputeVisitor &pV)

{

X86ComputeVisitor* visitor = dyn_cast(&pV);

if (nullptr != visitor)

visitor->visit(*this);

}

void X86ConvRelu::accept(ComputeVisitor &pV) const

{

X86ComputeVisitor* visitor = dyn_cast(&pV);

if (nullptr != visitor)

visitor->visit(*this);

}

// classof 用于检查输入运算符是否为X86ConvRelu类型

// A member function for checking if a computeOperator is of the X86ConvRelu type.

bool X86ConvRelu::classof(const ComputeOperator* pOp)

{

if (nullptr == pOp)

return false;

return (pOp->getID() == &ID);

}

融合ir优化pass 插入计算图 X86FuseConvRelu.cpp X86FuseConvRelu.h

所有的优化pass类 继承自 CustomPass 模板类,已动态多态CRTP方式继承,提高效率

每个子类需要重写 runOnModule 方法,实现 计算图的优化(匹配+替换)

// 静态多态 CRTP 奇异递归模板 子类继承父类同时作为父类的模板参数传入

class X86FuseConvRelu : public CustomPass

{

public:

ReturnType runOnModule(Module& pModule) override;

.....

};

优化pass类继承自runOnModule 方法,需要至少重写 runOnModule 成员方法, 该函数的输入时一个表示模型的计算图 ComputeGraph对象 ,带有一一映射到ONNC IR 的节点node信息。

该pass的目标是找到 所有的 Conv -> ReLU 子图,并使用新的融合节点 X86ConvRelu node来替换他们。

Pass::ReturnType X86FuseConvRelu::runOnComputeGraph(ComputeGraph& pCG)

{

// ...

// Loop over every operator of a given model.

for (nodeIt = pCG.begin(); nodeIt != nEnd; ++nodeIt) {

ComputeOperator* node = nodeIt;

// Check if a convolution followed by a ReLU happens.

// Conv -> ReLU 子图 匹配

if (!isFusible(*node))

continue;

// Yes, a convolution + ReLU case happens.

// Get those two generic operators and prepare to replace them.

// 获取这两个节点

Conv& conv = *(Conv *)node;

Relu& relu = *(Relu *)conv.getOutput(0)->getUses()[0].getUser();

// After the call, the new operator appears in the model.

// 生成融合节点,并插入计算图

mergeConvRelu(pCG, conv, relu);

// Remove the two unused generic operators from the model.

// 删除老节点

pCG.erase(conv);

pCG.erase(relu);

//...

}

}

子图匹配实现 Conv -> ReLU

bool X86FuseConvRelu::isFusible(ComputeOperator& pNode)

{

if (!isa(&pNode))

// 当前节点是 Conv

return false;

Value* outv = pNode.getOutput(0);// 输出值

// if Conv's result has more than one users, we can't fuse it.

if (outv->getUses().size() > 1)

// 输出值 只能 有是一个使用者

return false;

// 当前 节点 Conv 输出值的使用节点

ComputeOperator* userNode = outv->getUses()[0].getUser();

if (!isa(userNode))

// 当前 节点 Conv 的唯一后继是 Relu 节点

return false;

return true;

}

子图替换

X86ConvRelu* X86FuseConvRelu::mergeConvRelu(ComputeGraph& pCG,

Conv& pConv, Relu& pRelu)

{

// Remove the edges between the two old operators

// because after merging, those edges no longer have meaning.

// conv -> rule ==> -> ConvRelu ->

Value* outv = pRelu.getOutput(0); // relu的输出

Value* out_conv = pConv.getOutput(0);

pConv.replaceOutput(0, *outv); // Conv输出连接到rule的后继节点 ?? 为了不关联relu吧

pCG.erase(*out_conv); // 删除 原Conv输出

// 创建新的融合节点 X86ConvRelu

// Create a new compound operator X86ConvRelu.

X86ConvRelu* newOp = pCG.addOperator(pConv, pRelu);

Value* emptyV = new Value;

// Reconnect input edges from the old operators to the new operator.

// 原conv的输入链接到 新的融合节点

for (unsigned i = 0; i < pConv.getNumOfInputs(); ++i) {

newOp->addInput(*pConv.getInput(i));

pConv.replaceInput(i, *emptyV);// 原conv节点输入设置为空

}

pRelu.replaceInput(0, *emptyV);// 原relu节点输入设置为空

// Reconnect output edges to the new operator.

outv->clearDefine(); // 原 relu节点的输出取消定义(取消生成该值的关联)

newOp->addOutput(*outv); // 新融合节点 输出 连接到原 relu节点的后继

return newOp;

}

// 优化pass类实例化

X86FuseConvRelu* onnc::CreateX86FuseConvReluPass()

{

return new X86FuseConvRelu(); // 返回指针

}

优化pass调度执行

代码位于: lib/Target//Backend.cpp

lib/Target/X86/X86Backend.cpp

一系列的pass被创建,并加入 PassManager 中,穿件加入的顺序,会影响pass执行的顺序。

addTensorSel 是onnc四个阶段的第一个阶段,完成onnx转换到onnc ir,

其他三个:addTensorSched(), addMemAlloc(), and addCodeEmit().

#include "X86FuseConvRelu.h"

// This method is for collecting passes related to lowering ONNX IR into ONNC IR.

void X86Backend::addTensorSel(PassManager& pPM)

{

// This ONNC built-in pass translates a model in ONNX into ONNC IR operators

// based on only generic operators without knowledge of target-specific operators.

// 内建的pass完成onnx到onnc的转换

// 不考虑 特殊算子

addStandardTensorSel(pPM, *this);

// 针对特殊算子的变化pass

if (EnableX86FuseConvRelu) {

// Target-specific operators are inserted within this user-provided pass.

pPM.add(CreateX86FuseConvReluPass());

}

}

更新构建脚本

lib/Target/X86 下 需要更新 CMakeLists.txt 以及 Makefile.am

add_libonnc_src(

Compute/X86ConvRelu.cpp

X86FuseConvRelu.cpp

...)

ONNC_TARGET_SOURCES += \

Target/X86/Compute/X86ConvRelu.cpp \

Target/X86/X86FuseConvRelu.cpp \

...

代码生成 Code Emitting Pass

每种类型算子的代码生成操作,在 对应后端 的 代码生产类中的 visit方法,根据参数类型调用对应算子的生成方法。

代码生成阶段的目的是为目标硬件生成可执行的机器代码,或者为基于NVDLA的设计生成一个中间文件,如可加载文件。代码生成阶段的实现强烈依赖于目标硬件的规格,这是一项非比寻常的工作。为了帮助开发者实现代码生成阶段,ONNC框架内置了许多功能,以最大程度地减少编码工作量。

利用访问者设计模式是最重要的一项功能,具体细节将在下一节中介绍。

访问者设计模式是行为设计模式之一。当我们需要在一组相似类型的对象上执行操作时,就可以使用它。借助访问者模式,我们可以将操作逻辑从对象中移动到中央类。ONNC在很多场合都利用了访问者设计模式,代码生成阶段就是其中的一个例子。我们鼓励用户尽可能使用访问者设计模式,以减少编码工作量。

在ONNC框架中,名为CodeEmit的阶段是用于生成编译器输出的入口点。在ONNC软件架构中,CodeEmit阶段是整个流程中的最后阶段,它在实现过程中利用了访问者设计模式。整体概念如上图所示。

上图展示了与代码生成阶段相关的数据结构和伪代码。图中有两个部分。下半部分是ONNC框架的一部分,可以针对任何后端重复使用。上半部分是针对后端特定的实现。

ComputeVisitor 是一个抽象类,用于声明对所有可访问类类型的访问操作。CodeEmitVisitor 包含了所有必须实现的访问方法,用于处理各种类型的操作符。每个从 ComputeOperator 类派生的操作符类都是一个可访问类,提供了 accept() 方法来接受访问者。

后端开发者实现代码生成(上图中的上半部分),需要分两步:

为你的后端添加一个对应的CodeEmitVisitor

在CodeEmit阶段中,ONNC会遍历模型中的每个操作符,并调用每个操作符的accept()方法,将CodeEmitVisitor的的实例作为输入参数传递。随后,每个操作符的accept()方法会调用对应操作符的visit()方法以执行代码生成。

实现CodeEmitVisitor类,以处理每个被支持的操作符的代码生成。

在实际操作中,CodeEmitVisitor类中的每个操作符都有一个对应的 访问方法。例如,卷积操作符具有 visit(Conv& pConv)方法, 而最大池化操作符具有visit(MaxPool& pMaxPool)方法。所有操作符都具有相同的方法名,但参数类型不同。 通过这种方式,访问者设计模式显著提高了代码生成阶段的可读性和可扩展性。

本应用说明的其余部分将描述Vanilla后端中显示的详细实现。

添加 CodeEmitVisitor 到 pass管理器中

// lib/Target/Vanilla/VanillaBackend.cpp

void VanillaBackend::addCodeEmit(PassManager& pPM, const Path& pOutput)

{

// Remember to add code generation codes in class CodeEmitVisitor.

static vanilla::CodeEmitVisitor ceVisitor;

// Remember to pass CodeEmitVisitor as input argument.

pPM.add(CreateCodeEmitPass(ceVisitor));

}

为每个算子实现代码生成 Implementing Code Generation for Each Operator

// lib/Target/Vanilla/CodeEmitVisitor.h

class CodeEmitVisitor : public ComputeVisitor

{

...

void visit(Conv& pConv); // code generation for convolution

void visit(Relu& pRelu); // code generation for ReLU

};

NVDLA backend 代码生成

void visit(const Conv& pConv) override;

void visit(const Reshape& pReshape) override;

void visit(const Relu& pRelu) override;

void visit(const LRN& pLRN) override;

void visit(const MaxPool& pMaxPool) override;

void visit(const AveragePool& pAveragePool) override;

void visit(const Gemm& pGemm) override;

void visit(const Softmax& pSoftmax) override;

void visit(const Concat& pConcat) override;

void visit(const Sum& pSum) override;

具体实现

// 算子 visit 实现宏

#define PP_DEFINE_VISIT(type, op) \

void CodeEmitVisitor::visit(const type& op) \

{ \

DEBUG_STMTS(outs() << op << "\n";); \

visitImpl(op); \

} \

void CodeEmitVisitor::visitImpl(const type& op)

namespace nvdla {

// 对应算子转换成虚拟机操作码

#include "CodeEmitVisitor/Add.inc"

#include "CodeEmitVisitor/Mul.inc"

...

// 代码发射,指令生成,虚拟机操作序列生成

AddressListEntryId CodeEmitVisitor::issueEmuAddr(MemoryListEntryId mid) {}

void CodeEmitVisitor::issueEmuOp(NvDlaEmuOperation* op){

m_pMeta.m_EMUOperationList.push_back(op); // 记录操作符

m_pMeta.appendOperationMeta(); // 记录操作数

}

AddressListEntryId CodeEmitVisitor::issueDlaAddr(){}

AddressListEntryId CodeEmitVisitor::issueSDPOperand(){}

void CodeEmitVisitor::issueDlaOp(NvDlaDlaOperation* op, NvDlaDlaOperation* op_fuse, NvDlaDlaOperation* op_prev){}

void CodeEmitVisitor::emitSdp(const ComputeOperator& op, const Tensor& first, const Tensor& second, const Tensor& output){}

}

}

Add.inc

// Add.inc

PP_DEFINE_VISIT(Add, pAdd) { emitSdp(pAdd, *pAdd.getA(), *pAdd.getB(), *pAdd.getOutput(0)); }

Mul.inc

// Mul.inc

PP_DEFINE_VISIT(Mul, pMul) { emitSdp(pMul, *pMul.getA(), *pMul.getB(), *pMul.getOutput(0)); }

Relu.inc

PP_DEFINE_VISIT(Relu, pOp)

{

// 创建输入 CUBE X_cube

const Tensor* input_X_t = pOp.getInput(0);

int32_t input_X_ndim = input_X_t->getNumOfDimensions();

int32_t input_X_dims[4] = {1, 1, 1, 1};

for (int i = 0; i < input_X_ndim; ++i)

input_X_dims[i] = input_X_t->dimension(i);

NvDlaCubeInfo X_cube(*this, NVDLA_CUBE_FEATURE, input_X_dims[0], input_X_dims[1], input_X_dims[2], input_X_dims[3]);

// 创建输出CUBE Y_cube

const Tensor* output_Y_t = pOp.getOutput(0);

int32_t output_Y_ndim = output_Y_t->getNumOfDimensions();

int32_t output_Y_dims[4] = {1, 1, 1, 1};

for (int i = 0; i < output_Y_ndim; ++i)

output_Y_dims[i] = output_Y_t->dimension(i);

NvDlaCubeInfo Y_cube(*this, NVDLA_CUBE_FEATURE, output_Y_dims[0], output_Y_dims[1], output_Y_dims[2],

output_Y_dims[3]);

// 创建算子操作

NvDlaDlaOperation* relu_op = new NvDlaDlaOperation();

relu_op->op_dep.op_type = DLA_OP_SDP;

// 算子描述

struct dla_sdp_op_desc* relu_desc = (struct dla_sdp_op_desc*)(&(relu_op->op_desc));

relu_desc->src_precision = DLA_PRECISION;

relu_desc->dst_precision = DLA_PRECISION;

relu_desc->lut_index = -1;

relu_desc->conv_mode = 0;

relu_desc->out_cvt.scale = 1;

relu_desc->out_cvt.truncate = 0;

relu_desc->out_cvt.enable = 1;

relu_desc->out_cvt.offset = 0;

relu_desc->conv_mode = CONV_MODE_DIRECT;

relu_desc->batch_num = 1;

relu_desc->batch_stride = 0;

relu_desc->x1_op.enable = 1;

relu_desc->x1_op.alu_type = SDP_ALU_OP_SUM;

relu_desc->x1_op.type = SDP_OP_NONE;

relu_desc->x1_op.mode = SDP_OP_PER_LAYER;

relu_desc->x1_op.act = ACTIVATION_RELU;

relu_desc->x1_op.shift_value = 0;

relu_desc->x1_op.truncate = 0;

relu_desc->x1_op.precision = DLA_PRECISION;

relu_desc->x1_op.alu_operand = 0;

relu_desc->x1_op.mul_operand = 1;

relu_desc->x1_op.cvt.alu_cvt.scale = 0;

relu_desc->x1_op.cvt.alu_cvt.truncate = 0;

relu_desc->x1_op.cvt.alu_cvt.enable = 0;

relu_desc->x1_op.cvt.alu_cvt.offset = 0;

relu_desc->x1_op.cvt.mul_cvt.scale = 0;

relu_desc->x1_op.cvt.mul_cvt.truncate = 0;

relu_desc->x1_op.cvt.mul_cvt.enable = 0;

relu_desc->x1_op.cvt.mul_cvt.offset = 0;

// 算子输入输出描述

struct dla_sdp_surface_desc* relu_surf = (struct dla_sdp_surface_desc*)(&(relu_op->op_surf));

relu_surf->src_data.type = DLA_MEM_MC;

relu_surf->src_data.address = issueDlaAddr(*input_X_t, X_cube);

relu_surf->src_data.size = m_pMeta.getMemoryListEntrySize(*input_X_t);

relu_surf->src_data.width = X_cube.dim_w;

relu_surf->src_data.height = X_cube.dim_h;

relu_surf->src_data.channel = X_cube.dim_c;

relu_surf->src_data.line_stride = X_cube.stride_line;

relu_surf->src_data.surf_stride = X_cube.stride_surface;

relu_surf->src_data.plane_stride = X_cube.stride_plane;

relu_surf->dst_data.type = DLA_MEM_MC;

relu_surf->dst_data.address = issueDlaAddr(*output_Y_t, Y_cube);

relu_surf->dst_data.size = m_pMeta.getMemoryListEntrySize(*output_Y_t);

relu_surf->dst_data.width = Y_cube.dim_w;

relu_surf->dst_data.height = Y_cube.dim_h;

relu_surf->dst_data.channel = Y_cube.dim_c;

relu_surf->dst_data.line_stride = Y_cube.stride_line;

relu_surf->dst_data.surf_stride = Y_cube.stride_surface;

relu_surf->dst_data.plane_stride = Y_cube.stride_plane;

// 发射该算子,生成操作序列

issueDlaOp(relu_op, NULL, m_pMeta.m_pPrevOp);

}

优化 遍历遍 pass 管理器 ONNC Pass Manager

ONNC继承了LLVM基础设施中的 Pass 管理概念,Pass 管理器也是ONNC中最重要的特性之一。

在ONNC框架中,任何对目标程序的分析或转换都可以实现为一个pass。 ONNC pass 管理器背后的设计哲学不仅是为了支持LLVM的 pass 管理概念,还为了能够在ONNC中实现自动迭代编译。 LLVM的 pass 管理器在任何一个传递中遇到失败时会立即停止,并依赖于用户调整参数并重新尝试。 然而,在神经网络模型编译中,编译失败的情况比在传统(例如C/C++)编译中更为常见。 因此,支持迭代编译并将其设计嵌入到 pass 管理器中是ONNC的一个重要特性。

ONNC框架负责pass管理器的大部分功能,包括自动pass调度和pass间依赖关系。在本应用说明中,我们更关注传递的实现,而不是传递管理器的内部机制,因为大多数ONNC用户将精力集中在设计新的传递上,而不是修改传递管理器。

所有pass需要继承自 CustomPass 抽象基类,他提供了几个方法:

PassResult doInitialization(Module&); pass初始化调用函数,默认方法,啥都不做PassResult runOnModule(Module&); 实现pass具体的功能,改动模块计算图onnc irPassResult doFinalization(Module&); pass最后调用的函数,清理资源,为下次运行做准备等,默认返回 kModuleNoChanged

pass处理的结果类型 PassResult,包括:

kModuleNoChanged 模块无变化 kModuleChanged 模块改变,调用成功 kPassRetry 需要重新执行 kPassFailure 执行失败

重写 runOnModule 方法,实现具体的pass变换

// CRTP 静态多态,奇异递归模板

class MyPass : public CustomPass {

public:

ReturnType runOnModule(Module& module) override {

// do something here

return kModuleChanged;

}

};

定义pass依赖 重写 getAnalysisUsage 方法

当前pass执行前,需要先执行 依赖 的pass

class Foo: public CustomPass { /* implementation goes here */ };

class Bar: public CustomPass { /* implementation goes here */ };

class MyPass : public CustomPass {

public:

/* other code here */

void getAnalysisUsage(AnalysisUsage& usage) const override {

// MyPass 依赖 Foo 和 Bar pass

usage.addRequired();

usage.addRequired();

}

};

pass Manager 管理器

pass管理器 负责管理pass实例并负责实例的执行

// 用户只需要关心注册pass即可

PassManager manager;

// template add(Args&&...);

manager.add(); // provide constructor arguments and let PassManager create pass by its own

ONNC Backend Developer 后端架构

onnc 提供了脚本来生成 新后端的框架代码

# 进入镜像环境

$ docker run -ti --rm -v ~/work/onnc_projects/onnc:/onnc/onnc onnc/onnc-community

$ cd /onnc/onnc

// Run the script to create a new backend called WuKong.

$ ./scripts/create-new-backend.sh WuKong

会生成 /onnc/onnc/lib/Target/WuKong, 拷贝了 /onnc/onnc/lib/Target/Vanilla 仅修改了名称

重新编译

// Do the following within the Docker prompt.

$ cd /onnc/onnc-umbrella/build-normal/

// Use “-j8” to invoke 8 CPU cores to do the parallel compilation.

$ smake -j8 install

在新后端中执行模型

// Do the following within the Docker prompt.

$ cd /onnc/onnc-umbrella/build-normal/

// Invoke the new backend WuKong.

$ ./tools/onnc/onnc /models/bvlc_alexnet/model.onnx -mquadruple wukong

# -mquadruple wukong 调用新后端,名称全部小写字母

%conv1_w_0[96, 3, 11, 11] = Initializer()

%conv1_b_0[96] = Initializer()

%conv2_w_0[256, 48, 5, 5] = Initializer()

%conv2_b_0[256] = Initializer()

%conv3_w_0[384, 256, 3, 3] = Initializer()

%conv3_b_0[384] = Initializer()

%conv4_w_0[384, 192, 3, 3] = Initializer()

%conv4_b_0[384] = Initializer()

%conv5_w_0[256, 192, 3, 3] = Initializer()

%conv5_b_0[256] = Initializer()

%fc6_w_0[4096, 9216] = Initializer()

%fc6_b_0[4096] = Initializer()

%fc7_w_0[4096, 4096] = Initializer()

%fc7_b_0[4096] = Initializer()

%fc8_w_0[1000, 4096] = Initializer()

%fc8_b_0[1000] = Initializer()

%OC2_DUMMY_1[2] = Initializer()

%data_0[1, 3, 224, 224] = InputOperator()

%conv1_1[1, 96, 54, 54] = Conv(%data_0[1, 3, 224, 224], %conv1_w_0[96, 3, 11, 11], %conv1_b_0[96])

%conv2_1[1, 256, 26, 26] = Conv(%pool1_1[1, 96, 26, 26], %conv2_w_0[256, 48, 5, 5], %conv2_b_0[256])

%conv3_1[1, 384, 12, 12] = Conv(%pool2_1[1, 256, 12, 12], %conv3_w_0[384, 256, 3, 3], %conv3_b_0[384])

%conv4_1[1, 384, 12, 12] = Conv(%conv3_2[1, 384, 12, 12], %conv4_w_0[384, 192, 3, 3], %conv4_b_0[384])

%conv5_1[1, 256, 12, 12] = Conv(%conv4_2[1, 384, 12, 12], %conv5_w_0[256, 192, 3, 3], %conv5_b_0[256])

= OutputOperator(%prob_1[1, 1000]

新后端相关源文件

FooBackend.cpp & .h 新后端的主要接口文件. 开发者需要在其中新增优化pass optimization passes. CodeEmitVisitor.cpp & .h 实现了新后端的代码生成,开发者需要修改,为每一个算子operator开发对应的代码生成代码 TargetInfo/WuKongTargetInfo.cpp & .h 注册新后端到 ONNC framework 框架 TargetInfo/WuKongTargetMemInfo.cpp & .h 新后端针对模型的内存大小,内存对齐,数据类型等,开发者需要针对模板硬件属性来优化内存分配 optimize memory allocation. CMakeLists.txt CMake building system 构建脚本 Makefile.am Autotools building system 构建脚本

自定义新算子,参考前面 X86ConvRelu 算子开发过程代码生成,参考前一章节新增优化pass

void WuKongBackend::addOnncIrOptimization(PassManager& pPM, OptimizationOptions& options)

{

TargetBackend::addOnncIrOptimization(pPM, options);

// One example of adding your optimization pass.

// 添加针对新后端的pass

pPM.add();

}

pass 流程

Method Input Output Description

1. addTensorSel ONNX IR 转 ONNC IR

ONNX 格式模型 转换成 ONNC IR.

2. addOnncIrOptimization ONNC IR ONNC IR in optimized order

ONNC IR 优化

3. addTensorSched ONNC IR in optimized order ONNC IR in optimized order

ONNC IR 调度执行优化

4. addMemAlloc ONNC IR in optimized order ONNC IR with addresses

内存分配 allocating memory space of input data, weights, and activation data.

生存期分析 addStandardCreateLiveIntervals ->

内存分配 ddStandardMemoryAllocation ->

生成内存分配操作 addStandardSetMemOperands

5. addCodeEmit ONNC IR with address Machine codes

后端代码生成和优化

cpu 后端

我们很可能希望直接在机器上运行可执行文件来进行模型推理,而不是通过TensorFlow或PyTorch等训练平台调用API。ONNC提供了C后端来帮助生成这样的可执行文件。

C后端,顾名思义,可以生成一个C文件。该文件提供了一个函数,可以对给定模型进行推理计算。这个后端与ONNC的ARM Cortex-M后端类似,两者都可以生成C代码。区别在于,Cortex-M后端在生成的C函数中使用了CMSIS-NN库来执行许多神经网络操作的计算。然而,C后端不需要依赖任何特定于硬件的库(尽管如果需要,它可以依赖Intel MKLDNN库来加速Intel CPU上的计算)。ONNC为大多数神经网络操作器提供了一组纯C代码的函数实现,没有使用任何与特定于硬件的指令相关的技巧。

架构如下所示。高亮部分表示ONNC框架,包括一个编译器(ONNC C后端)和一个库(libonnc-rt.a)。该库提供了许多神经网络操作器的函数实现。编译器可以编译给定的ONNX格式模型,然后生成一个包含model_main()函数的C文件,该函数进一步调用库中定义的操作器API来执行特定的推理。

最后,用户需要实现main()函数,根据应用程序的需要进行一些设置工作,最重要的是调用model_main()函数来执行推理。

编译模型

$ onnc -mquadruple clang /models/bvlc_alexnet/model.onnx -o ./onnc-runtime-service.c

CLang is invoked

[Clang] created model weight file: ./onnc-runtime-service.weight

// Check if the compilation was really done successfully.

$ ls -1 onnc-runtime-service.*

onnc-runtime-service.c

onnc-runtime-service.weight

编译模型到c后端会生成;两个文件,一个是主调c文件,一个是模型权重.weight文件

手动编写主调文件

读取图片 Read input images.读取模型权重 Read weights.调用模型前向 Call model_main()

可调用的工程文件夹参考 /onnc/onnc/example/runtime/

tree /onnc/onnc/example/runtime/

├── CMakeLists.txt

├── bin

├── include

| └── onnc-runtime.h: declares functions in the runtime library.

└── src

├── CMakeLists.txt

├── client-app.c: defines main().

├── client-lib.c: provides utilities like input parser.

└── onnc-runtime-core.c: defines model_main().

编译测试工程

$ cd /onnc/onnc/example/runtime

$ mv /onnc/onnc-umbrella/build-normal/onnc-runtime-service.c src

$ mkdir build

$ mv /onnc/onnc-umbrella/build-normal/onnc-runtime-service.weight build

$ cd build

$ cmake .. && make

# 会生成可执行文件在 /onnc/onnc/example/runtime/build/src

生成测试数据

// Generate input image for alexnet

$ ../bin/pb2t /models/bvlc_alexnet/test_data_set_0/input_0.pb ./bvlc_alexnet.input

执行模型前向推理

// Run inference.

$ ./src/inference bvlc_alexnet.input onnc-runtime-service.weight

[0.000043, 0.000046, 0.000024, 0.000011, 0.000114,

...

0.000148, 0.000964, 0.000134, 0.001431, 0.000448, ]

文章来源

评论可见,请评论后查看内容,谢谢!!!
 您阅读本篇文章共花了: