3

OneFlow源码解析:算子签名的自动推断

 1 year ago
source link: https://blog.csdn.net/OneFlow_Official/article/details/125512911
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
eb6c0f769e0063a07ea0f3ef6811808a.png

撰文 | 郑建华

OneFlow是一个原生支持分布式训练的、高性能的深度学习框架。最近读了一些OneFlow的源码、架构设计和代码实现的文章,简单梳理一下自己的理解。主要通过图形展示调用过程和类之间的关系,只对部分重要的代码作一下分析。

深度学习框架是一个复杂的系统,而用户使用最多的就是算子(op)。用户通过op构造模型,进行训练、预测。这个笔记就从op入手,看看从Python前端到C++底层,OneFlow如何执行算子的计算逻辑。

具体地说,以比较简单的Relu算子为例,分析如下代码怎么执行:

1

编译环境

在开始分析之前,需要搭建环境编译OneFlow的源码,因为有些代码是在编译构建过程中自动生成的。在分析的过程中,这些自动生成的代码也是必要的环节。

OneFlow提供了官方的编译镜像(https://hub.docker.com/r/oneflowinc/manylinux2014_x86_64_cuda11.2)。用这个镜像可以非常方便地搭建编译环境(https://github.com/Oneflow-Inc/oneflow#option-2-build-in-docker-container-recommended)。

我使用的OneFlow版本是v0.7.0。本地编译环境目录结构如下,build是

cmake的构建目录,oneflow是源码目录。

编译比较耗时,可以把两个目录mount到容器,便于后续查看build目录中生成的文件。

在cmake配置、构建过程中,会下载很多第三方源码包,如果网络状况不好容易超时,直接重试cmake/make即可。

用GDB追踪OneFlow的执行过程

王益:Use GDB to Walkthrough OneFlow Source Code(https://quip.com/JuQ0AuodVJn4

2

Python Binding

OneFlow底层是C++实现,通过pybind11实现Python Binding。月踏在从Python到C++调用过程分析对相关内容做了讲解。

2.1 Relu的Python包路径

2.2 module处理逻辑的注册

Python代码主要在python/oneflow目录,C++实现的包主要在_oneflow_internal下,pybind11的绑定代码位于init.cpp(https://github.com/Oneflow-Inc/oneflow/blob/release/0.7.0/oneflow/api/python/init.cpp):

其中OneflowModuleRegistry(https://github.com/Oneflow-Inc/oneflow/blob/release/0.7.0/oneflow/api/python/init.cpp#L106)是算子等模块的绑定;Pybind11ModuleRegistry(https://github.com/Oneflow-Inc/oneflow/blob/release/0.7.0/oneflow/api/python/init.cpp#L105)应该是自定义的、类似protobuf的配置数据结构的绑定。

从OneflowModuleRegistry开始的详细调用流程如下:

adb5dfae35c434e707438760a210ad84.png

把代码放到一起看看(https://github.com/Oneflow-Inc/oneflow/blob/release/0.7.0/oneflow/api/python/of_api_registry.cpp):

从这段代码可以看出,python module的注册逻辑都保存在SubModuleMap中。它的key是module name;value是一组函数,BuildSubModule中调用这些函数、执行module注册逻辑。

GetSubModuleMap中保存map单例,Register函数设置map的值,of_api_registry.h(https://github.com/Oneflow-Inc/oneflow/blob/release/0.7.0/oneflow/api/python/of_api_registry.h)中的宏ONEFLOW_API_PYBIND11_MODULE调用Register函数处理module注册逻辑。搜索一下可以知道Relu的注册逻辑在build/oneflow/api/python/functional/functional_api.yaml.pybind.cpp中,这个文件中注册了很多算子(user_op)。以Relu和pow为例,这个宏展开后的核心代码如下:

这段代码中的类似注册技巧,在OneFlow中的很多地方都被用到。

module注册逻辑在函数OneflowApiPythonModule9623中(9623来自宏定义中的LINE以避免名字冲突),OfApiRegistryInit在构造对象时将这个函数注册到SubModuleMap,匿名空间中的变量of_api_registry_init就是为了通过构造对象、在构造函数中调用注册逻辑(而这个对象不占用任何空间)。这样在系统加载时就通过静态对象的初始化实现了module处理逻辑的注册,再通过pybind11的调用完成对Python Binding的定义。

3

多个接口签名的自动推断

从以上代码可以看到,Relu算子被绑定到PyFunction(https://github.com/Oneflow-Inc/oneflow/blob/release/0.7.0/oneflow/api/python/functional/py_function.h#L120)这个函数执行计算逻辑,每次调用算子都会执行PyFunction这个函数。

从签名看,PyFunction是一个模版函数,给Python前端返回py::object作为算子执行结果。

Relu只有一个模版参数,pow有4个模版参数。每个模版参数表示算子支持的一种调用接口签名。OneFlow可以根据Python传过来的arguments类型,自动推断合适的签名,调用相关函数。

例如下面的代码,算子pow的指数参数既支持标量,也支持tensor:

下面就来看看OneFlow是怎么实现这个功能的。

Relu算子的签名Schema如下所示:

先看一下从PyFunction开始的的调用顺序:

0a06ced5dd40f4506efd1339f3e20f57.png

PyFunction相关的代码如下(删掉了一些与核心逻辑无关的内容)。

3.1 dispatcher: 算子接口签名的自动推断

PyFunction是一个模版函数,每个模版参数表示算子的一个接口签名。

PyFunction及其后续执行链路的最重要的功能,就是实现这些签名的自动筛选。自动筛选的实质,就是通过index_sequence逐个检查签名与PyFunction的参数args/kwargs是否匹配。函数内的静态变量dispatcher实现了这个自动筛选功能。

每个算子都会特化一个PyFunction和PyFunctionDispatcher实例,也有一个算子自己的dispatcher变量。PyFunction直接将请求转发给dispatcher.call,顺带加上一个index_sequence模版参数,正是依靠这个模版参数实现了签名的自动筛选。

在call函数中,先确定当前检查的签名类型T(例如ReluSchema_TTB),然后通过ParseArgs检查Python传过来的参数args/kwargs与签名T是否匹配。如果不匹配,就去掉当前签名T,将剩余的签名类型作为模版参数、继续递归调用call函数。

如果算子只有一个签名,就通过schema_size_ == 1通知ParseArgs(https://github.com/Oneflow-Inc/oneflow/blob/release/0.7.0/oneflow/api/python/functional/py_function.cpp#L48),校验失败时直接抛出错误信息。

3.2 ParseArgs: 签名与参数的匹配

Python的keyword arguments是类似map的结构,在C++中不方便直接用,需要转为positional arguments,同时按顺序保存到parsed_args中供后续执行使用。而这个顺序只能是签名指定的顺序,所以ParseArgs中只能按function_def的顺序循环校验。

函数的参数可能是各种类型,ParseArgs统一转为PythonArg类型,并通过PyObject*类型的成员读取Python的变量值。

参数校验不一致的情况主要包括:

  • positional与keyword参数类型冲突

  • 签名中的keyword参数名在kwargs中不存在且不接受默认值

  • 参数类型不符合PythonArgCheck规定的内部类型检查要求

  • kwargs包含function_def中未定义的参数

3.3 unpack_call: 展开算子函数的参数

在call函数中确定算子签名的Schema之后,直接调用unpack_call(https://github.com/Oneflow-Inc/oneflow/blob/release/0.7.0/oneflow/api/python/functional/unpack_call.h#L69)函数。这时已经可以确定具体的算子执行函数了,对于Relu来说就是functional::Relu,同时将Python传过来的参数都整理到args中。

unpack_call的模版参数是函数类型,例如functional::Relu,在函数体内利用function_traits推导出函数的参数个数和返回值类型。

unpack_call_dispatcher内主要是调用f,也就是functional::Relu。但还不能直接调用这个函数。因为每个算子对应函数的签名都不一样,又不能把vector args直接传给这些函数。

OneFlow通过如下步骤完成模版的特化适配:

  • 将args展开为各个PythonArg元素,通过index_sequence和变长模版参数包的展开实现;

  • 利用function_traits推导得到函数参数类型列表ArgsType;

  • As函数调用可简化为As<typename tuple_element<I, typename ArgsType>>()...核心是拿到各个参数的实际类型并交给As处理,最终调用ObjectAs实现各种内部数据类型的转换。

unpack_call_dispatcher返回的是C++内部数据类型,最后要通过CastToPyObject转为pybind11::object,主要是调用pybind11::cast函数。

3.4 签名都无效时的错误处理

以上只是讨论了Python参数合法,可以找到匹配的函数签名的情况。如果传过来的参数是非法的,根据args/kwargs找不到匹配的签名怎么办?

如之前的讨论,PyFunctionDispatcher::call(https://github.com/Oneflow-Inc/oneflow/blob/release/0.7.0/oneflow/api/python/functional/py_function.h#L58c)是递归模版参数,如果当前签名不匹配,就尝试下一个签名。如果所有签名都不匹配,就会进入call的模版参数列表为空的特化版本(https://github.com/Oneflow-Inc/oneflow/blob/release/0.7.0/oneflow/api/python/functional/py_function.h#L69)。这个函数会记录详细的错误信息。

例如,flow.pow("abc", 123)会输出如下错误信息:

而Relu这种只支持一个签名的算子,如下面看到的,参数类型错误时的提示信息体现了单个签名的特点。如上所述,这是由schema_size_ == 1提示给ParseArgs的。

3.5 yaml cpp的生成

functional_api.yaml的相关代码是在cmake构建过程中生成的,对应的cmake脚本是cmake/functional.cmake。

3.6 小结

总结一下上述几个主要组件的作用:

  • PyFunction是pybind11的def定义的入口函数,并为算子保存一个dispatcher对象用于推断合适的签名;

  • PyFunctionDispatcher通过模版函数的递归调用实现了签名的自动筛选,通过成员变量为参数校验和异常提示保存必要的信息;

  • unpack_call在编译期就确定了具体执行的算子函数类型,这一点在PyFunctionDispatcher中是无法做到的;

  • unpack_call_dispatcher的作用是将vector展开为多个元素、作为调用算子函数的参数,这在unpack_call中也是无法做到的;

  • PythonArg是Python与C++类型转换的桥梁,同时承担类型检查的职能;

  • 基于yaml生成的2组文件,yaml.pybind.cpp中调用pybind11的m.def指定模块调用的函数,并定义了函数签名的Schema结构作为PyFunction的模版参数。yaml.cpp中则定义了具体的执行函数,如Relu。将二者衔接起来的就是Schema的字段func,对于Relu算子来说,签名Schema的func字段就是函数functional:Relu。

核心是实现签名的自动校验推断,参数的统一处理以及参数的合并、展开。整个过程环环相扣、自然流畅。

4

算子Functor的注册与执行

4.1 算子Functor的注册

追踪一下functional::Relu(https://github.com/Oneflow-Inc/oneflow/blob/release/0.7.0/oneflow/core/functional/function_library.h#L40)的调用链路,容易发现最终会用到FunctionLibrary的静态map变量。先看看这个map是怎么初始化的。它在add_functor_creator(https://github.com/Oneflow-Inc/oneflow/blob/release/0.7.0/oneflow/core/functional/function_library.h#L93)中被添加元素,后者被add_functor(https://github.com/Oneflow-Inc/oneflow/blob/release/0.7.0/oneflow/core/functional/function_library.h#L63)间接调用。

搜索一下add_functor和Relu,发现在activation_functor.cpp中调用宏ONEFLOW_FUNCTION_LIBRARY(https://github.com/Oneflow-Inc/oneflow/blob/release/0.7.0/oneflow/core/functional/impl/activation_functor.cpp#L444)。宏展开后代码如下,通过定义一个静态变量来实现调用注册函数的目的。

稍微梳理一下就可以发现,FunctionLibrary的map中的value是类似下面这样的lambda:

注册的调用顺序如下:

f525c9bdbeb0920c419b907e01dca4a9.png

那么,add_functor的模版参数为何是变长的,内部又要展开呢?是因为ScalarAdd等名字对应多个Functor。

4.2 算子Functor的执行

接下来看看functional_api.yaml.cpp中的functional::Relu函数。代码经过整理后如下所示。

核心逻辑就是func_lib.find("Relu").call(x, inplace)。

获取__op并执行的调用顺序如下(忽略op的静态属性):

79a6af81aa9fc79a6292d2a87fdbcb69.png

根据上面的讨论以及调用链路容易发现,PackedFuncCreatorMap::Get内的静态map变量(https://github.com/Oneflow-Inc/oneflow/blob/release/0.7.0/oneflow/core/functional/function_library.h#L40),其value实际是一个类似如下的lambda表达式:

find返回的是it->second(),也就是调用这个lambda表达式的返回值,即PackedFunctorMaker::make的返回值,类型是PackedFunctor<F>,这就是op__的类型。其中模版参数F的类型如decltype(ReluFunctor::operator())。

PackedFunctor构造时接受如下的lambda表达式,并保存到变量impl_中:

所以__op->call(...)就是PackedFunctor<Func>::call(...),最终相当于调用impl::ReluFunctor::operator()(args)。

也就是说,Relu的操作就由impl::ReluFunctor执行。

需要注意的是,这里整个链路的分析,最关键的是模版参数的梳理和推导。模版参数确定后,整个逻辑还是比较清楚的。

4.3 小结

  • 同一个名字可能对应多个Functor。所以不能只用名字作为Functor的key,需要结合签名。

  • FunctionLibrary负责管理所有的Functor。但是单例不适合作为模版类,所以通过内嵌的PackedFuncCreatorMap保存签名各异的Functor。

  • 每种签名都会特化一个PackedFuncCreatorMap模版类,再通过名字区分不同的Functor。

那么,PackedFunctor类的作用是什么?或者换个角度,如果没有这个类,能否实现需求?答案是不能。

  • 首先,yaml生成的2个cpp文件,都没有Functor信息,只有Relu这个名字、以及Functor的签名信息。Functor是在各个模块根据名字注册的。yaml与FunctionLibrary通过名字和签名进行交互。

  • 其次,FunctionLibrary::find返回的PackedFunctor是带模版参数的(参数就是Functor签名)。find能否直接返回Functor对象呢?主要是map不便存储不同类型的Functor。即使Functor都有共同的虚基类、map的value存储指针,但不能要求所有Functor的执行接口是一致的,虚函数不满足这个场景的需求。所以find不能直接返回Functor对象。

  • PackedFunctor的作用就在于,它把真正的Functor包在自己的结构里面;它的模版参数与Functor的调用接口一致;它的call方法将Op的所有入参通过lambda转发给Functor。

  • Functor能直接作为PackedFunctor的成员变量吗?应该是可以的。PackedFunctorMaker::make的模版参数也包含Functor。但是这样每个Functor都要特化一个PackedFunctor,编译后的可执行程序容易膨胀。而现在的实现,PackedFunctor只根据Functor执行函数签名特化,代价是要做一次调用转发(编译器有优化空间?)。

参考资料

(本文经授权后发布,原文:https://segmentfault.com/a/1190000041843994)

其他人都在看

欢迎体验OneFlow v0.7.0:GitHub - Oneflow-Inc/oneflow: OneFlow is a performance-centered and open-source deep learning framework.OneFlow is a performance-centered and open-source deep learning framework. - GitHub - Oneflow-Inc/oneflow: OneFlow is a performance-centered and open-source deep learning framework.fluidicon.pnghttps://github.com/Oneflow-Inc/oneflow/


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK