XRT为不同的后端引擎提供了统一的上层功能和接口抽象,这些功能和接口包括:
统一的DAG计算图表示
统一的子图表达、切分和折叠过程
统一的JIT子图编译接口和缓存机制
统一的Executable Launch接口
得益于上层统一的抽象和模块化的设计,后端引擎只需要处理一些差异化的接口,并且这些差异化通常只体现在子图的编译和executable
launch接口的具体实现上。
我们把XRT的每个子图都看成是一个function,function包含输入和输出参数,以及对应的函数体(DAG表示的计算图),比如下面表示的是只包含一个relu节点的XRT子图,其中node表示计算节点,input和output分别表示子图的输入和输出。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 function { input { name: "_xrt_entry_0" value: "_MyGraph_0_input.0.0_2/out" } output { name: "_xrt_return_0" value: "relu-0/y_0" } node { name: "relu-0" device_tag: "cuda" user_conf { op_type_name: "relu" input { key: "x" value { s: "_MyGraph_0_input.0.0_2/out" } } output { key: "y" value { s: "relu-0/y_0" } } } } }
在runtime阶段function首先需要被编译成executable,执行function实际上就是feed对应的输入参数去launch这个编译好的executable,同时得到执行的结果,即function的返回值。
在XRT框架下每个后端引擎都有一个与之相对应的executable(比如XLA的XlaExecutable和TensorRT的TrtExecutable),和将function编译成对应executable的compiler(比如XLA的XlaGraphCompiler和TensorRT的TrtGraphCompiler),因此添加一个新的后端引擎,通常只需要添加一个对应的executable和compiler。下面以添加一个自定义的后端引擎Toy为例,详细介绍在XRT框架下支持新的后端引擎的具体过程。
首先在xrt.proto文件中XrtEngine下增加一个Toy引擎字段。
1 2 3 4 5 6 7 enum XrtEngine { DEFAULT = 1 ; XLA = 2 ; TENSORRT = 3 ; TVM = 4 ; TOY = 5 ; }
如果Toy引擎针对的硬件不在XrtDevice中,则需要在XrtDevice中增加对应的设备字段。这里我们假设自定义的Toy引擎只支持GPU_CUDA,因此就不需要修改XrtDevice了。
接下来,与XLA和TensorRT一样,我们在oneflow_xrt/compiler
目录下创建一个toy目录,其余所有与Toy引擎相关的代码都将放在该目录下。
Toy Executable
在增加任何一个后端引擎之前,我们都需要仔细考虑该后端引擎所需的最小执行环境,一个最简单的执行环境包括输入输出、中间结果以及执行具体计算逻辑的硬件代码,这个代码可以是通过codegen自动生成的,也可以是手工实现的。
接下来我们给自定义的Toy引擎增加一个对应的ToyExecutable。在oneflow_xrt/compiler/toy
目录下,我们创建文件toy_executable.h和toy_executable.cpp。
toy_executable.h中定义ToyExecutable,ToyExecutable必须继承自Executable,并实现Run接口。为了尽可能简单,ToyExecutable只包含输出outputs、中间结果tmp_buffers和编排好的函数调用列表func_codes,以及每个函数的输入输出参数对应的buffer序号func_args_。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 #ifndef ONEFLOW_XRT_COMPILER_TOY_TOY_EXECUTABLE_H_ #include "oneflow_xrt/compiler/executable.h" #include "oneflow_xrt/compiler/parameter.h" #include <vector> #include <functional> namespace oneflow {namespace xrt {typedef std::function<void (const std::vector<Parameter> &, const std::vector<Parameter> &)> FuncCode; struct FuncArgumentIndices { std::vector<int > inputs; std::vector<int > outputs; }; class ToyExecutable : public Executable { public : ToyExecutable (const std::string &name, const int num_inputs, const std::vector<Parameter> &outputs, const std::vector<Parameter> &temp_buffers, const std::vector<FuncCode> &func_codes, const std::vector<FuncArgumentIndices> &func_args); bool Run (const std::vector<Parameter> &inputs, const ExecutableRunOptions &run_options, bool block_until_done = true ) override ; private : int num_inputs_; std::vector<Parameter> outputs_; std::vector<Parameter> temp_buffers_; std::vector<FuncCode> func_codes_; std::vector<FuncArgumentIndices> func_args_; }; } } #endif
在toy_executable.cpp中实现Run方法,这里我们只是简单的顺序执行编排好的函数func_codes。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 #include "oneflow_xrt/compiler/toy/toy_executable.h" namespace oneflow {namespace xrt {ToyExecutable::ToyExecutable (const std::string &name, const int num_inputs, const std::vector<Parameter> &outputs, const std::vector<Parameter> &temp_buffers, const std::vector<FuncCode> &func_codes, const std::vector<FuncArgumentIndices> &func_args) : Executable (name, XrtEngine::TOY), num_inputs_ (num_inputs), outputs_ (outputs), temp_buffers_ (temp_buffers), func_codes_ (func_codes), func_args_ (func_args) {} bool ToyExecutable::Run (const std::vector<Parameter> &inputs, const ExecutableRunOptions &run_options, bool block_until_done) { auto PullArgs = [&](const std::vector<int > &indices) { std::vector<Parameter> args; for (int idx : indices) { if (idx < num_inputs_) { args.push_back (inputs[idx]); } else if (idx < num_inputs_ + outputs_.size ()) { args.push_back (outputs_[idx - num_inputs_]); } else { idx -= (num_inputs_ + outputs_.size ()); CHECK_GE (idx, 0 ); CHECK_LT (idx, temp_buffers_.size ()); args.push_back (temp_buffers_[idx]); } } return std::move (args); }; CHECK_EQ (inputs.size (), num_inputs_); for (int i = 0 ; i < func_codes_.size (); ++i) { auto in_args = PullArgs (func_args_[i].inputs); auto out_args = PullArgs (func_args_[i].outputs); func_codes_[i](in_args, out_args); } if (block_until_done) { } this ->results_ = run_options.return_params; return true ; } } }
目前为止我们已经完成了一个最简单的运行时executable,这个executable甚至有点类似其他框架中提供的最简单的图执行器(graph
executor)。接下来我们要介绍如何将一个XRT的子图编译成上面的ToyExecutable。
Toy Compiler
每个后端引擎都对应一个compiler,当我们希望使用某个后端引擎来执行一个XRT子图时,就需要有一个对应的compiler将该子图编译成后端引擎对应的executable。Compiler通常都非常注重编译产物的执行性能,而性能以外的关切点也导致了不同的技术路线,比如对算法通用性、跨平台有高度关切的TVM和XLA采用了LLVM传统编译器的路线,而对于过分看重性能但硬件平台单一的TensorRT更多的则是采用手工优化和tuning相结合的策略。不过这两种技术路线并不是完全对立的,也是在不断地相互借鉴和融合。
在XRT中,所有这些技术方案都是可以被兼容的,你可以根据实际情况自由切换,你也可以把XRT当成实验场所,实现一个自定义的compiler,并在同一套框架下对比不同compiler、不同技术方案的优劣。
回到本文的主题,我们现在需要实现一个ToyExecutable对应的compiler,我们也把该compiler叫做ToyGraphCompiler。
首先在oneflow_xrt/compiler/toy
目录下新建两个文件toy_graph_compiler.h和toy_graph_compiler.cpp。在toy_graph_compiler.h文件中定义类ToyGraphCompiler,ToyGraphCompiler必须继承自类GraphCompiler::Impl,并实现对应的Compile接口。
1 2 3 4 5 6 7 8 9 10 11 12 13 class ToyGraphCompiler : public GraphCompiler::Impl { public : explicit ToyGraphCompiler (const std::string &name) : GraphCompiler::Impl(name) { } virtual ~ToyGraphCompiler () = default ; std::shared_ptr<Executable> Compile ( const XrtGraph *graph, const std::vector<Parameter> &entry_params, const std::vector<Parameter> &return_params, const std::vector<InputOutputAlias> &aliases) override ;};
在toy_graph_compiler.cpp中实现Compile接口,并注册一个新的graph
compiler。在动手实现该接口之前,有必要先解释一下该接口的参数列表,graph表示的是function子图,entry_params表示子图的输入,return_params表示子图的输出,aliases通常在包含模型更新操作时会用到,表明输出和输入是一对别名关系。被alias的输入将生命期延长到了整个子图,并且与对应的输出共享内存,因此也就间接实现了inplace计算的目的。
我们按拓扑顺序遍历子图中的每个节点(或op),依次将节点编译成具体的执行代码,并在合适的位置插入临时buffer。为了方便处理不同类型的op,我们在下面的代码中引入了ToyOpContext和ToyOpKernel的概念。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 REGISTER_GRAPH_COMPILER (XrtEngine::TOY, ToyGraphCompiler);std::shared_ptr<Executable> ToyGraphCompiler::Compile ( const XrtGraph *graph, const std::vector<Parameter> &entry_params, const std::vector<Parameter> &return_params, const std::vector<InputOutputAlias> &aliases) { std::vector<Parameter> temp_buffers; std::vector<FuncCode> func_codes; std::vector<FuncArgumentIndices> func_args; std::unordered_map<std::string, int > indices; std::unordered_map<std::string, Parameter> all_params; for (auto param : entry_params) { indices.emplace (param.name (), indices.size ()); all_params[param.name ()] = param; } for (auto param : return_params) { indices.emplace (param.name (), indices.size ()); all_params[param.name ()] = param; } algorithm::TopologyVisit (*graph, [&](const XrtNode *node) { if (node->IsNoOpNode ()) { return ; } ToyOpContext op_context (node, all_params); auto op_kernel = BuildToyOpKernel (node->type ()); op_kernel->Compile (&op_context); func_codes.push_back (op_context.func_code_); const auto &buffers = op_context.tmp_buffers_; for (auto it = buffers.begin (); it != buffers.end (); ++it) { all_params[it->first] = it->second; temp_buffers.push_back (it->second); indices.emplace (it->first, indices.size ()); } FuncArgumentIndices arg_indices; for (const auto &arg : op_context.input_args_) { arg_indices.inputs.push_back (indices.at (arg)); } for (const auto &arg : op_context.output_args_) { arg_indices.outputs.push_back (indices.at (arg)); } func_args.push_back (std::move (arg_indices)); }); return std::make_shared <ToyExecutable>(this ->name_, entry_params.size (), return_params, temp_buffers, func_codes, func_args); }
ToyOpContext临时存储编译需要的元信息和编译结果,为ToyOpKernel提供必要的接口,ToyOpKernel则根据op类型完成单个op的编译过程。上述代码中我们实现了一个将XRT子图编译成ToyExecutable的最简单的graph
compiler,下面我们将以ReLU
op为例,介绍ToyOpContext和ToyOpKernel是如何对op进行编译的。
Toy Kernels
我们回过头再仔细研究一下ToyGraphCompiler的Compile实现,ToyOpContext接受两个输入,node和当前所有已经创建过的parameters,经过OpKernel编译后输出函数代码(func_code_)、中间buffer(tmp_buffers_),以及函数代码输入和输出对应的parameter
names。因此在这个例子中,ToyOpContext被设计成如下形式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class ToyOpContext { public : ToyOpContext (const XrtNode *node, const std::unordered_map<std::string, Parameter> &all_params) : node_ (node), all_params_ (all_params) {} public : const XrtNode *node_; const std::unordered_map<std::string, Parameter> &all_params_; std::function<void (const std::vector<Parameter>&, const std::vector<Parameter>&)> func_code_; std::vector<std::string> input_args_; std::vector<std::string> output_args_; std::unordered_map<std::string, Parameter> tmp_buffers_; };
对于ToyOpKernel,为了处理不同类型的op,我们采用工厂注册模式,并且这种模式还有另一个用处,就是在XRT划分子图时可以用来判断该引擎是否支持某个类型的op。XRT已经将kernel注册接口封装成了一个辅助类OpKernelRegistrar,但同时也要求ToyOpKernel必须继承基类OpKernel。
1 2 3 4 class ToyOpKernel : public OpKernel<ToyOpContext> { public : virtual void Compile (ToyOpContext *ctx) = 0 ; };
使用OpKernelRegistrar定义一个用来注册ToyOpKernel的宏。
1 2 3 4 5 6 7 8 #define REGISTER_TOY_OP_KERNEL(OpName, KernelType) \ static auto _toy_op_kernel_##OpName##_ __attribute__((unused)) = \ OpKernelRegistrar(#OpName) \ .SetEngine(XrtEngine::TOY) \ .SetDevice({XrtDevice::GPU_CUDA}) \ .SetFactory([]() -> OpKernelBase * { \ return new KernelType; \ })
最后我们实现一个Relu的OpKernel,填充ToyOpContext的func_code_、tmp_buffers_以及输入输出arguments。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 void ComputeRelu (const Parameter &input, const Parameter &output) { } class ToyReluOpKernel : public ToyOpKernel { public : void Compile (ToyOpContext *ctx) override { ctx->func_code_ = [](const std::vector<Parameter> &inputs, const std::vector<Parameter> &outputs) { CHECK_EQ (inputs.size (), 1 ); CHECK_EQ (outputs.size (), 1 ); ComputeRelu (inputs[0 ], outputs[0 ]); }; for (const XrtEdge *edge : ctx->node_->in_edges ()) { const auto &name = edge->argument ().name (); CHECK_GT (ctx->all_params_.count (name), 0 ); ctx->input_args_.push_back (name); } for (const XrtEdge *edge : ctx->node_->out_edges ()) { const auto &name = edge->argument ().name (); ctx->output_args_.push_back (name); if (ctx->all_params_.count (name) == 0 && ctx->tmp_buffers_.count (name) == 0 ) { auto param = CreateParameter (name , edge->argument ().shape (), edge->argument ().data_type ()); ctx->tmp_buffers_[name] = std::move (param); } } } };
最后将ToyReluOpKernel注册到Toy引擎对应的OpKernel工厂下。
1 2 3 REGISTER_TOY_OP_KERNEL (relu, ToyReluOpKernel) .EnableTrainPhase () .Finalize ();
EnableTrainPhase表示该op支持训练,OpKernelRegistrar也提供了其他一些接口,比如设置支持的device列表,mutable
variables(inplace更新)和是否是model update op(model update
op会影响子图划分)。
CMake编译
在CMakeList.txt中添加一个BUILD_TOY的选项,并在oneflow_xrt/CMakeLists.txt中添加如下toy引擎模块的编译代码,
1 2 3 4 5 6 7 8 9 10 11 12 13 if (BUILD_TOY) file (GLOB_RECURSE XRT_TOY_SRCS compiler/toy/*.cpp) add_library (oneflow_xrt_toy ${XRT_TOY_SRCS} ) add_dependencies ( oneflow_xrt_toy ${XRT_THIRD_PARTY_LIBRARIES} ) target_link_libraries ( oneflow_xrt_toy oneflow_xrt ${XRT_THIRD_PARTY_LIBRARIES} ) target_include_directories ( oneflow_xrt_toy PRIVATE ${ONEFLOW_INCLUDE_DIR} ) endif ()
之后在oneflow_xrt/python目录中添加导出Python模块的代码toy_stub.cpp,
1 2 3 4 #include <pybind11/pybind11.h> #include <pybind11/stl.h> PYBIND11_MODULE (_oneflow_xrt_toy_internal, m) {}
并在oneflow_xrt/python/CMakeLists.txt中增加如下代码,
1 2 3 if (BUILD_TOY) oneflow_xrt_add_stub(oneflow_xrt_toy toy_stub.cpp) endif ()
编译和安装Python wheel包
修改setup.py文件,新增一个toy
extension的编译,并在build_ext函数中开启BUILD_TOY选项,
1 2 3 4 5 setup_extension( "oneflow_xrt_toy" , cmake_args=["-DBUILD_TOY=ON" ], description=("oneflow_xrt's toy extension" ), )
执行命令python3 setup.py install
完成wheel包的编译和安装,最后执行如下代码测试添加的toy引擎是否可以正常执行,
1 2 3 4 5 6 7 8 9 10 11 12 13 import oneflow as flowimport oneflow_xrt as flowrtclass ReluGraph (flow.nn.Graph): def __init__ (self ): super ().__init__() def build (self, input ): return flow.nn.functional.relu(input ) m = flowrt.XRTModule(ReluGraph(), engine="toy" ) x = flow.randn(2 , 3 , device="cuda" ) print (m(x))