撰文 | 鄭建華
(資料圖片僅供參考)
OneFlow是一個原生支持分布式訓(xùn)練的、高性能的深度學(xué)習(xí)框架。最近讀了一些OneFlow的源碼、架構(gòu)設(shè)計和代碼實現(xiàn)的文章,簡單梳理一下自己的理解。主要通過圖形展示調(diào)用過程和類之間的關(guān)系,只對部分重要的代碼作一下分析。
深度學(xué)習(xí)框架是一個復(fù)雜的系統(tǒng),而用戶使用最多的就是算子(op)。用戶通過op構(gòu)造模型,進(jìn)行訓(xùn)練、預(yù)測。這個筆記就從op入手,看看從Python前端到C++底層,OneFlow如何執(zhí)行算子的計算邏輯。
具體地說,以比較簡單的Relu算子為例,分析如下代碼怎么執(zhí)行:
# import會觸發(fā)一系列初始化工作,暫時忽略import oneflow as flow# tensor的實現(xiàn)其實很復(fù)雜,因為要融合local和分布式的global tensort = flow.tensor([-1, 0, 1])r = flow.relu(t)
在開始分析之前,需要搭建環(huán)境編譯OneFlow的源碼,因為有些代碼是在編譯構(gòu)建過程中自動生成的。在分析的過程中,這些自動生成的代碼也是必要的環(huán)節(jié)。
OneFlow提供了官方的編譯鏡像(https://hub.docker.com/r/oneflowinc/manylinux2014_x86_64_cuda11.2)。用這個鏡像可以非常方便地搭建編譯環(huán)境(https://github.com/Oneflow-Inc/oneflow#option-2-build-in-docker-container-recommended)。
我使用的OneFlow版本是v0.7.0。本地編譯環(huán)境目錄結(jié)構(gòu)如下,build是
cmake的構(gòu)建目錄,oneflow是源碼目錄。
.├── build└── oneflow
編譯比較耗時,可以把兩個目錄mount到容器,便于后續(xù)查看build目錄中生成的文件。
在cmake配置、構(gòu)建過程中,會下載很多第三方源碼包,如果網(wǎng)絡(luò)狀況不好容易超時,直接重試cmake/make即可。
# docker run -itd -v $PWD/oneflow:/mnt/oneflow -v $PWD/build:/mnt/build \# manylinux2014_x86_64_cuda11.2 bashcd /mnt/buildcmake -S /mnt/oneflowcmake --build . # --parallel 8cd ../oneflow/pythonpython3 setup.py bdist_wheelpip install ./dist/oneflow-0.7.0+cpu-cp38-cp38-linux_x86_64.whl
王益:Use GDB to Walkthrough OneFlow Source Code(https://quip.com/JuQ0AuodVJn4)
CMAKE_BUILD_TYPE=Debug cmake -S /mnt/oneflowcmake --build . --parallel 8source /mnt/build/source.shgdb python3b oneflow::one::MakeLocalTensorFromDatarunimport oneflow as flowflow.Tensor([[1,2,3],[4,5,6]])
OneFlow底層是C++實現(xiàn),通過pybind11實現(xiàn)Python Binding。月踏在《從Python到C++調(diào)用過程分析》對相關(guān)內(nèi)容做了講解。
# python/oneflow/__init__.pyfrom oneflow._C import relu# python/oneflow/_C/__init__.pyfrom oneflow._oneflow_internal._C import *
Python代碼主要在python/oneflow目錄,C++實現(xiàn)的包主要在_oneflow_internal下,pybind11的綁定代碼位于init.cpp(https://github.com/Oneflow-Inc/oneflow/blob/release/0.7.0/oneflow/api/python/init.cpp):
PYBIND11_MODULE(_oneflow_internal, m) { // ... py::class_<::oneflow::cfg::Message, std::shared_ptr<::oneflow::cfg::Message>>(m, "CfgMessage"); ::oneflow::cfg::Pybind11ModuleRegistry().ImportAll(m); ::oneflow::OneflowModuleRegistry().ImportAll(m);}
其中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)應(yīng)該是自定義的、類似protobuf的配置數(shù)據(jù)結(jié)構(gòu)的綁定。
從OneflowModuleRegistry開始的詳細(xì)調(diào)用流程如下:
把代碼放到一起看看(https://github.com/Oneflow-Inc/oneflow/blob/release/0.7.0/oneflow/api/python/of_api_registry.cpp):
using SubModuleMap = std::map
從這段代碼可以看出,python module的注冊邏輯都保存在SubModuleMap中。它的key是module name;value是一組函數(shù),BuildSubModule中調(diào)用這些函數(shù)、執(zhí)行module注冊邏輯。
GetSubModuleMap中保存map單例,Register函數(shù)設(shè)置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調(diào)用Register函數(shù)處理module注冊邏輯。搜索一下可以知道Relu的注冊邏輯在build/oneflow/api/python/functional/functional_api.yaml.pybind.cpp中,這個文件中注冊了很多算子(user_op)。以Relu和pow為例,這個宏展開后的核心代碼如下:
static void OneflowApiPythonModule9623(pybind11::module&);namespace { struct OfApiRegistryInit { OfApiRegistryInit() { ::oneflow::OneflowModuleRegistry().Register("_C", &OneflowApiPythonModule9623); } }; OfApiRegistryInit of_api_registry_init;}static void OneflowApiPythonModule9623(pybind11::module & m) { m.def("relu", &functional::PyFunction
這段代碼中的類似注冊技巧,在OneFlow中的很多地方都被用到。
module注冊邏輯在函數(shù)OneflowApiPythonModule9623中(9623來自宏定義中的LINE以避免名字沖突),OfApiRegistryInit在構(gòu)造對象時將這個函數(shù)注冊到SubModuleMap,匿名空間中的變量of_api_registry_init就是為了通過構(gòu)造對象、在構(gòu)造函數(shù)中調(diào)用注冊邏輯(而這個對象不占用任何空間)。這樣在系統(tǒng)加載時就通過靜態(tài)對象的初始化實現(xiàn)了module處理邏輯的注冊,再通過pybind11的調(diào)用完成對Python Binding的定義。
從以上代碼可以看到,Relu算子被綁定到PyFunction(https://github.com/Oneflow-Inc/oneflow/blob/release/0.7.0/oneflow/api/python/functional/py_function.h#L120)這個函數(shù)執(zhí)行計算邏輯,每次調(diào)用算子都會執(zhí)行PyFunction這個函數(shù)。
從簽名看,PyFunction是一個模版函數(shù),給Python前端返回py::object作為算子執(zhí)行結(jié)果。
Relu只有一個模版參數(shù),pow有4個模版參數(shù)。每個模版參數(shù)表示算子支持的一種調(diào)用接口簽名。OneFlow可以根據(jù)Python傳過來的arguments類型,自動推斷合適的簽名,調(diào)用相關(guān)函數(shù)。
例如下面的代碼,算子pow的指數(shù)參數(shù)既支持標(biāo)量,也支持tensor:
import oneflow as flowr = flow.randn(1, 10)flow.pow(r, 2)flow.pow(r, flow.ones(1, 10))
下面就來看看OneFlow是怎么實現(xiàn)這個功能的。
Relu算子的簽名Schema如下所示:
struct ReluSchema_TTB { using FType = Maybe
先看一下從PyFunction開始的的調(diào)用順序:
PyFunction相關(guān)的代碼如下(刪掉了一些與核心邏輯無關(guān)的內(nèi)容)。
// SchemaT如 ReluSchema_TTBtemplate
PyFunction是一個模版函數(shù),每個模版參數(shù)表示算子的一個接口簽名。
PyFunction及其后續(xù)執(zhí)行鏈路的最重要的功能,就是實現(xiàn)這些簽名的自動篩選。自動篩選的實質(zhì),就是通過index_sequence逐個檢查簽名與PyFunction的參數(shù)args/kwargs是否匹配。函數(shù)內(nèi)的靜態(tài)變量dispatcher實現(xiàn)了這個自動篩選功能。
每個算子都會特化一個PyFunction和PyFunctionDispatcher實例,也有一個算子自己的dispatcher變量。PyFunction直接將請求轉(zhuǎn)發(fā)給dispatcher.call,順帶加上一個index_sequence模版參數(shù),正是依靠這個模版參數(shù)實現(xiàn)了簽名的自動篩選。
在call函數(shù)中,先確定當(dāng)前檢查的簽名類型T(例如ReluSchema_TTB),然后通過ParseArgs檢查Python傳過來的參數(shù)args/kwargs與簽名T是否匹配。如果不匹配,就去掉當(dāng)前簽名T,將剩余的簽名類型作為模版參數(shù)、繼續(xù)遞歸調(diào)用call函數(shù)。
如果算子只有一個簽名,就通過schema_size_ == 1通知ParseArgs(https://github.com/Oneflow-Inc/oneflow/blob/release/0.7.0/oneflow/api/python/functional/py_function.cpp#L48),校驗失敗時直接拋出錯誤信息。
Python的keyword arguments是類似map的結(jié)構(gòu),在C++中不方便直接用,需要轉(zhuǎn)為positional arguments,同時按順序保存到parsed_args中供后續(xù)執(zhí)行使用。而這個順序只能是簽名指定的順序,所以ParseArgs中只能按function_def的順序循環(huán)校驗。
函數(shù)的參數(shù)可能是各種類型,ParseArgs統(tǒng)一轉(zhuǎn)為PythonArg類型,并通過PyObject*類型的成員讀取Python的變量值。
參數(shù)校驗不一致的情況主要包括:
positional與keyword參數(shù)類型沖突
簽名中的keyword參數(shù)名在kwargs中不存在且不接受默認(rèn)值
參數(shù)類型不符合PythonArgCheck規(guī)定的內(nèi)部類型檢查要求
kwargs包含function_def中未定義的參數(shù)
在call函數(shù)中確定算子簽名的Schema之后,直接調(diào)用unpack_call(https://github.com/Oneflow-Inc/oneflow/blob/release/0.7.0/oneflow/api/python/functional/unpack_call.h#L69)函數(shù)。這時已經(jīng)可以確定具體的算子執(zhí)行函數(shù)了,對于Relu來說就是functional::Relu,同時將Python傳過來的參數(shù)都整理到args中。
unpack_call的模版參數(shù)是函數(shù)類型,例如functional::Relu,在函數(shù)體內(nèi)利用function_traits推導(dǎo)出函數(shù)的參數(shù)個數(shù)和返回值類型。
unpack_call_dispatcher內(nèi)主要是調(diào)用f,也就是functional::Relu。但還不能直接調(diào)用這個函數(shù)。因為每個算子對應(yīng)函數(shù)的簽名都不一樣,又不能把vector args直接傳給這些函數(shù)。
OneFlow通過如下步驟完成模版的特化適配:
將args展開為各個PythonArg元素,通過index_sequence和變長模版參數(shù)包的展開實現(xiàn);
利用function_traits推導(dǎo)得到函數(shù)參數(shù)類型列表ArgsType;
As函數(shù)調(diào)用可簡化為As
unpack_call_dispatcher返回的是C++內(nèi)部數(shù)據(jù)類型,最后要通過CastToPyObject轉(zhuǎn)為pybind11::object,主要是調(diào)用pybind11::cast函數(shù)。
class PythonArg { template
以上只是討論了Python參數(shù)合法,可以找到匹配的函數(shù)簽名的情況。如果傳過來的參數(shù)是非法的,根據(jù)args/kwargs找不到匹配的簽名怎么辦?
如之前的討論,PyFunctionDispatcher::call(https://github.com/Oneflow-Inc/oneflow/blob/release/0.7.0/oneflow/api/python/functional/py_function.h#L58c)是遞歸模版參數(shù),如果當(dāng)前簽名不匹配,就嘗試下一個簽名。如果所有簽名都不匹配,就會進(jìn)入call的模版參數(shù)列表為空的特化版本(https://github.com/Oneflow-Inc/oneflow/blob/release/0.7.0/oneflow/api/python/functional/py_function.h#L69)。這個函數(shù)會記錄詳細(xì)的錯誤信息。
例如,flow.pow("abc", 123)會輸出如下錯誤信息:
File ".../oneflow/api/python/functional/py_function.h", line 76, in call TypeError: pow(): received an invalid combination of arguments. The valid signatures are: *0: Tensor (Tensor input, Tensor exponent) *1: Tensor (Tensor input, Scalar exponent, *, Bool inplace=False) *2: Tensor (Tensor input, Scalar exponent) *3: Tensor (Scalar exponent, Tensor input)
而Relu這種只支持一個簽名的算子,如下面看到的,參數(shù)類型錯誤時的提示信息體現(xiàn)了單個簽名的特點。如上所述,這是由schema_size_ == 1提示給ParseArgs的。
flow.relu(1)TypeException: File ".../oneflow/api/python/functional/py_function.cpp", line 98, in ParseArgs TypeError: relu(): argument "x" must be tensor, not int
functional_api.yaml的相關(guān)代碼是在cmake構(gòu)建過程中生成的,對應(yīng)的cmake腳本是cmake/functional.cmake。
總結(jié)一下上述幾個主要組件的作用:
PyFunction是pybind11的def定義的入口函數(shù),并為算子保存一個dispatcher對象用于推斷合適的簽名;
PyFunctionDispatcher通過模版函數(shù)的遞歸調(diào)用實現(xiàn)了簽名的自動篩選,通過成員變量為參數(shù)校驗和異常提示保存必要的信息;
unpack_call在編譯期就確定了具體執(zhí)行的算子函數(shù)類型,這一點在PyFunctionDispatcher中是無法做到的;
unpack_call_dispatcher的作用是將vector展開為多個元素、作為調(diào)用算子函數(shù)的參數(shù),這在unpack_call中也是無法做到的;
PythonArg是Python與C++類型轉(zhuǎn)換的橋梁,同時承擔(dān)類型檢查的職能;
基于yaml生成的2組文件,yaml.pybind.cpp中調(diào)用pybind11的m.def指定模塊調(diào)用的函數(shù),并定義了函數(shù)簽名的Schema結(jié)構(gòu)作為PyFunction的模版參數(shù)。yaml.cpp中則定義了具體的執(zhí)行函數(shù),如Relu。將二者銜接起來的就是Schema的字段func,對于Relu算子來說,簽名Schema的func字段就是函數(shù)functional:Relu。
核心是實現(xiàn)簽名的自動校驗推斷,參數(shù)的統(tǒng)一處理以及參數(shù)的合并、展開。整個過程環(huán)環(huán)相扣、自然流暢。
追蹤一下functional::Relu(https://github.com/Oneflow-Inc/oneflow/blob/release/0.7.0/oneflow/core/functional/function_library.h#L40)的調(diào)用鏈路,容易發(fā)現(xiàn)最終會用到FunctionLibrary的靜態(tài)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)間接調(diào)用。
搜索一下add_functor和Relu,發(fā)現(xiàn)在activation_functor.cpp中調(diào)用宏ONEFLOW_FUNCTION_LIBRARY(https://github.com/Oneflow-Inc/oneflow/blob/release/0.7.0/oneflow/core/functional/impl/activation_functor.cpp#L444)。宏展開后代碼如下,通過定義一個靜態(tài)變量來實現(xiàn)調(diào)用注冊函數(shù)的目的。
static void _oneflow_function_library_0(FunctionLibrary & m);// 以定義一個靜態(tài)變量的方式調(diào)用注冊函數(shù)static int _oneflow_function_library_dummy_0 = []() { FunctionLibrary* library = FunctionLibrary::Global(); _oneflow_function_library_0(*library); return 0; }();void _oneflow_function_library_0(FunctionLibrary & m) { m.add_functor
稍微梳理一下就可以發(fā)現(xiàn),F(xiàn)unctionLibrary的map中的value是類似下面這樣的lambda:
[=]() { // Func如 impl::ReluFunctor Func func; // func_name來自lambda綁定,如Relu return PackedFunctorMaker
注冊的調(diào)用順序如下:
那么,add_functor的模版參數(shù)為何是變長的,內(nèi)部又要展開呢?是因為ScalarAdd等名字對應(yīng)多個Functor。
接下來看看functional_api.yaml.cpp中的functional::Relu函數(shù)。代碼經(jīng)過整理后如下所示。
Maybe
核心邏輯就是func_lib.find("Relu").call(x, inplace)。
獲取__op并執(zhí)行的調(diào)用順序如下(忽略op的靜態(tài)屬性):
根據(jù)上面的討論以及調(diào)用鏈路容易發(fā)現(xiàn),PackedFuncCreatorMap::Get內(nèi)的靜態(tài)map變量(https://github.com/Oneflow-Inc/oneflow/blob/release/0.7.0/oneflow/core/functional/function_library.h#L40),其value實際是一個類似如下的lambda表達(dá)式:
[=]() { // Func如 impl::ReluFunctor Func func; // func_name來自lambda綁定,如Relu return PackedFunctorMaker
find返回的是it->second(),也就是調(diào)用這個lambda表達(dá)式的返回值,即PackedFunctorMaker::make的返回值,類型是PackedFunctor
PackedFunctor構(gòu)造時接受如下的lambda表達(dá)式,并保存到變量impl_中:
// func是一個函數(shù)變量,類型如 impl::ReluFunctor[func](const remove_cvref_t
所以__op->call(...)就是PackedFunctor
也就是說,Relu的操作就由impl::ReluFunctor執(zhí)行。
需要注意的是,這里整個鏈路的分析,最關(guān)鍵的是模版參數(shù)的梳理和推導(dǎo)。模版參數(shù)確定后,整個邏輯還是比較清楚的。
同一個名字可能對應(yīng)多個Functor。所以不能只用名字作為Functor的key,需要結(jié)合簽名。
FunctionLibrary負(fù)責(zé)管理所有的Functor。但是單例不適合作為模版類,所以通過內(nèi)嵌的PackedFuncCreatorMap保存簽名各異的Functor。
每種簽名都會特化一個PackedFuncCreatorMap模版類,再通過名字區(qū)分不同的Functor。
那么,PackedFunctor類的作用是什么?或者換個角度,如果沒有這個類,能否實現(xiàn)需求?答案是不能。
首先,yaml生成的2個cpp文件,都沒有Functor信息,只有Relu這個名字、以及Functor的簽名信息。Functor是在各個模塊根據(jù)名字注冊的。yaml與FunctionLibrary通過名字和簽名進(jìn)行交互。
其次,F(xiàn)unctionLibrary::find返回的PackedFunctor是帶模版參數(shù)的(參數(shù)就是Functor簽名)。find能否直接返回Functor對象呢?主要是map不便存儲不同類型的Functor。即使Functor都有共同的虛基類、map的value存儲指針,但不能要求所有Functor的執(zhí)行接口是一致的,虛函數(shù)不滿足這個場景的需求。所以find不能直接返回Functor對象。
PackedFunctor的作用就在于,它把真正的Functor包在自己的結(jié)構(gòu)里面;它的模版參數(shù)與Functor的調(diào)用接口一致;它的call方法將Op的所有入?yún)⑼ㄟ^lambda轉(zhuǎn)發(fā)給Functor。
Functor能直接作為PackedFunctor的成員變量嗎?應(yīng)該是可以的。PackedFunctorMaker::make的模版參數(shù)也包含F(xiàn)unctor。但是這樣每個Functor都要特化一個PackedFunctor,編譯后的可執(zhí)行程序容易膨脹。而現(xiàn)在的實現(xiàn),PackedFunctor只根據(jù)Functor執(zhí)行函數(shù)簽名特化,代價是要做一次調(diào)用轉(zhuǎn)發(fā)(編譯器有優(yōu)化空間?)。
從Python到C++調(diào)用過程分析
https://github.com/Oneflow-Inc/oneflow/tree/release/0.7.0
(本文經(jīng)授權(quán)后發(fā)布,原文:https://segmentfault.com/a/1190000041843994)
其他人都在看
深度學(xué)習(xí)概述
一個算子在深度學(xué)習(xí)框架中的旅程
手把手推導(dǎo)分布式矩陣乘的最優(yōu)并行策略
訓(xùn)練千億參數(shù)大模型,離不開四種并行策略
解讀Pathways(二):向前一步是OneFlow
關(guān)于并發(fā)和并行,Go和Erlang之父都弄錯了?
OneFlow v0.7.0發(fā)布:全新分布式接口,LiBai、Serving等一應(yīng)俱全
歡迎體驗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.https://github.com/Oneflow-Inc/oneflow/
關(guān)鍵詞: