当前位置:网站首页>OneFlow源码解析:算子签名的自动推断
OneFlow源码解析:算子签名的自动推断
2022-07-01 05:35:00 【OneFlow深度学习框架】

撰文 | 郑建华
OneFlow是一个原生支持分布式训练的、高性能的深度学习框架。最近读了一些OneFlow的源码、架构设计和代码实现的文章,简单梳理一下自己的理解。主要通过图形展示调用过程和类之间的关系,只对部分重要的代码作一下分析。
深度学习框架是一个复杂的系统,而用户使用最多的就是算子(op)。用户通过op构造模型,进行训练、预测。这个笔记就从op入手,看看从Python前端到C++底层,OneFlow如何执行算子的计算逻辑。
具体地说,以比较简单的Relu算子为例,分析如下代码怎么执行:
# import会触发一系列初始化工作,暂时忽略import oneflow as flow# tensor的实现其实很复杂,因为要融合local和分布式的global tensort = flow.tensor([-1, 0, 1])r = flow.relu(t)
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是源码目录。
.├── build└── oneflow
编译比较耗时,可以把两个目录mount到容器,便于后续查看build目录中生成的文件。
在cmake配置、构建过程中,会下载很多第三方源码包,如果网络状况不好容易超时,直接重试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
用GDB追踪OneFlow的执行过程
王益: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]])
2
Python Binding
OneFlow底层是C++实现,通过pybind11实现Python Binding。月踏在《从Python到C++调用过程分析》对相关内容做了讲解。
2.1 Relu的Python包路径
# python/oneflow/__init__.pyfrom oneflow._C import relu# python/oneflow/_C/__init__.pyfrom oneflow._oneflow_internal._C import *
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):
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)应该是自定义的、类似protobuf的配置数据结构的绑定。
从OneflowModuleRegistry开始的详细调用流程如下:

把代码放到一起看看(https://github.com/Oneflow-Inc/oneflow/blob/release/0.7.0/oneflow/api/python/of_api_registry.cpp):
using SubModuleMap = std::map<std::string, std::vector<std::function<void(pybind11::module&)>>>;SubModuleMap* GetSubModuleMap() {static SubModuleMap sub_module_map;return &sub_module_map;}// 修改map,执行注册void OneflowModuleRegistry::Register(std::string module_path,std::function<void(pybind11::module&)> BuildModule) {(*GetSubModuleMap())[module_path].emplace_back(BuildModule);}void OneflowModuleRegistry::ImportAll(pybind11::module& m) {for (const auto& pair : (*GetSubModuleMap())) {for (const auto& BuildModule : pair.second) { BuildSubModule(pair.first, m, BuildModule); }}}void OneflowModuleRegistry::BuildSubModule(const std::string& module_path, pybind11::module& m,const std::function<void(pybind11::module&)>& BuildModule) {// ...BuildModule(m);// ...}
从这段代码可以看出,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为例,这个宏展开后的核心代码如下:
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<functional::ReluSchema_TTB>);m.def("pow", &functional::PyFunction<functional::PowSchema_TTT, functional::ScalarPowSchema_TTScB,functional::ScalarPowSchema_TTSc, functional::ScalarReversePowSchema_TScT>);}
这段代码中的类似注册技巧,在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:
import oneflow as flowr = flow.randn(1, 10)flow.pow(r, 2)flow.pow(r, flow.ones(1, 10))
下面就来看看OneFlow是怎么实现这个功能的。
Relu算子的签名Schema如下所示:
struct ReluSchema_TTB {using FType = Maybe<one::Tensor> (const std::shared_ptr<one::Tensor>& x, bool inplace);using R = Maybe<one::Tensor>;static constexpr FType* func = &functional::Relu;static constexpr size_t max_args = 2;static constexpr size_t max_pos_args = 2;static constexpr char const* signature = "Tensor (Tensor x, Bool inplace=False)";static FunctionDef function_def;};
先看一下从PyFunction开始的的调用顺序:

PyFunction相关的代码如下(删掉了一些与核心逻辑无关的内容)。
// SchemaT如 ReluSchema_TTBtemplate<typename... SchemaT>class PyFunctionDispatcher {public:// schema_t是第I个签名template<size_t I>using schema_t = typename std::tuple_element<I, std::tuple<SchemaT...>>::type;// schema_size_是签名个数,比如relu是1,pow是4PyFunctionDispatcher() : schema_size_(sizeof...(SchemaT)) {signatures_.resize(schema_size_);InitSignatures(std::make_index_sequence<sizeof...(SchemaT)>{});}template<size_t I0, size_t... I>py::object call(const py::args& args, const py::kwargs& kwargs,std::index_sequence<I0, I...>) const {// T是当前检查的签名,比如 ReluSchema_TTBusing T = schema_t<I0>;std::vector<PythonArg> parsed_args(T::max_args);if (ParseArgs(args, kwargs, &parsed_args, T::function_def, T::max_pos_args,/*raise_exception*/ schema_size_ == 1)) {return detail::unpack_call(*T::func, parsed_args);}return call(args, kwargs, std::index_sequence<I...>{});}py::object call(const py::args& args, const py::kwargs& kwargs, std::index_sequence<>) const {// throw error ...return py::none();}private:template<size_t... I>void InitSignatures(std::index_sequence<I...>) {__attribute__((__unused__)) int dummy[] = {((void)(signatures_[I] = schema_t<I>::signature), 0)...};}private:size_t schema_size_;std::vector<const char*> signatures_;};// SchemaT如 ReluSchema_TTBtemplate<typename... SchemaT>inline py::object PyFunction(const py::args& args, const py::kwargs& kwargs) {static PyFunctionDispatcher<SchemaT...> dispatcher;return dispatcher.call(args, kwargs, std::make_index_sequence<sizeof...(SchemaT)>{});}// py module注册static void OneflowApiPythonModule9623(pybind11::module & m) {m.def("relu", &functional::PyFunction<functional::ReluSchema_TTB>);m.def("pow", &functional::PyFunction<functional::PowSchema_TTT, functional::ScalarPowSchema_TTScB,functional::ScalarPowSchema_TTSc, functional::ScalarReversePowSchema_TScT>);}
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函数。
class PythonArg {template<typename T>T As() const {return ObjectAsHelper<oneflow::detail::remove_cvref_t<T>>()(this).GetOrThrow();}};template<typename F, typename R>struct unpack_call_dispatcher {template<size_t... I>static R apply(const F& f, const std::vector<PythonArg>& args, std::index_sequence<I...>) {// 这里适当改写了一下,把ArgsType抽出来using ArgsType = function_traits<F>::args_type;return f(args[I].As<oneflow::detail::remove_cvref_t<typename std::tuple_element<I, typename ArgsType>::type>>()...);}};template<typename F>py::object unpack_call(const F& f, const std::vector<PythonArg>& args) {constexpr size_t nargs = function_traits<F>::nargs;using R = typename function_traits<F>::return_type;return CastToPyObject(unpack_call_dispatcher<F, R>::apply(f, args, std::make_index_sequence<nargs>{}));}
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)会输出如下错误信息:
File ".../oneflow/api/python/functional/py_function.h", line 76, in callTypeError: 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这种只支持一个签名的算子,如下面看到的,参数类型错误时的提示信息体现了单个签名的特点。如上所述,这是由schema_size_ == 1提示给ParseArgs的。
flow.relu(1)TypeException:File ".../oneflow/api/python/functional/py_function.cpp", line 98, in ParseArgsTypeError: relu(): argument 'x' must be tensor, not int
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)。宏展开后代码如下,通过定义一个静态变量来实现调用注册函数的目的。
static void _oneflow_function_library_0(FunctionLibrary & m);// 以定义一个静态变量的方式调用注册函数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<impl::ReluFunctor>("Relu");};
稍微梳理一下就可以发现,FunctionLibrary的map中的value是类似下面这样的lambda:
[=]() {// Func如 impl::ReluFunctorFunc func;// func_name来自lambda绑定,如Relureturn PackedFunctorMaker<func_type>::make(func_name, func);}
注册的调用顺序如下:

那么,add_functor的模版参数为何是变长的,内部又要展开呢?是因为ScalarAdd等名字对应多个Functor。
4.2 算子Functor的执行
接下来看看functional_api.yaml.cpp中的functional::Relu函数。代码经过整理后如下所示。
Maybe<one::Tensor> Relu(const std::shared_ptr<one::Tensor>& x, bool inplace) {static thread_local const auto& __op = CHECK_JUST(FunctionLibrary::Global()->find<Maybe<one::Tensor>,const std::shared_ptr<one::Tensor>&,bool> ("Relu"));return __op->call(x, inplace);}
核心逻辑就是func_lib.find("Relu").call(x, inplace)。
获取__op并执行的调用顺序如下(忽略op的静态属性):

根据上面的讨论以及调用链路容易发现,PackedFuncCreatorMap::Get内的静态map变量(https://github.com/Oneflow-Inc/oneflow/blob/release/0.7.0/oneflow/core/functional/function_library.h#L40),其value实际是一个类似如下的lambda表达式:
[=]() {// Func如 impl::ReluFunctorFunc func;// func_name来自lambda绑定,如Relureturn PackedFunctorMaker<func_type>::make(func_name, func);}
find返回的是it->second(),也就是调用这个lambda表达式的返回值,即PackedFunctorMaker::make的返回值,类型是PackedFunctor<F>,这就是op__的类型。其中模版参数F的类型如decltype(ReluFunctor::operator())。
PackedFunctor构造时接受如下的lambda表达式,并保存到变量impl_中:
// func是一个函数变量,类型如 impl::ReluFunctor[func](const remove_cvref_t<Args>&... args) -> R {return func(std::forward<const remove_cvref_t<Args>&>(args)...);}
所以__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执行函数签名特化,代价是要做一次调用转发(编译器有优化空间?)。
参考资料
从Python到C++调用过程分析 https://github.com/Oneflow-Inc/oneflow/tree/release/0.7.0

本文分享自微信公众号 - OneFlow(OneFlowTechnology)。
如有侵权,请联系 [email protected] 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。
边栏推荐
- 3D建模與處理軟件簡介 劉利剛 中國科技大學
- Rust hello-word
- LRU cache for leveldb source code analysis
- HCM 初学 ( 一 ) - 简介
- Trust guessing numbers game
- eBPF Cilium实战(2) - 底层网络可观测性
- Mongodb學習篇:安裝後的入門第一課
- 3D建模与处理软件简介 刘利刚 中国科技大学
- [Yugong series] February 2022 Net architecture class 005 ABP vNext Net core web application getting started configuration
- mysql 将毫秒数转为时间字符串
猜你喜欢

Deeply understand the underlying implementation principle of countdownlatch in concurrent programming

轻松上手Fluentd,结合 Rainbond 插件市场,日志收集更快捷

CockroachDB: The Resilient Geo-Distributed SQL Database 论文阅读笔记

Understand several related problems in JVM - JVM memory layout, class loading mechanism, garbage collection

Chapitre d'apprentissage mongodb: Introduction à la première leçon après l'installation

Fluentd is easy to use. Combined with the rainbow plug-in market, log collection is faster

【考研高数 武忠祥+880版 自用】高数第二章基础阶段思维导图

Use and principle of reentrantlock

Unity项目心得总结

Educational administration management system (free source code)
随机推荐
数据治理:数据治理框架(第一篇)
Ssgssrcsr differences
Is it safe for a novice to open a securities account?
Leetcode top 100 questions 1 Sum of two numbers
移动端常用解决方案
JDBC常见面试题
【考研高数 武忠祥+880版 自用】高数第二章基础阶段思维导图
Trust guessing numbers game
json数据比较器
Numeric amount plus comma; JS two methods of adding three digits and a comma to numbers; JS data formatting
POL8901 LVDS转MIPI DSI 支持旋转图像处理芯片
[RootersCTF2019]babyWeb
tese_ Time_ 2h
Use and principle of Park unpark
【QT】qt加减乘除之后,保留小数点后两位
Use and principle of reentrantlock
Unity project experience summary
Series of improving enterprise product delivery efficiency (1) -- one click installation and upgrade of enterprise applications
Intelligent operation and maintenance: visual management system based on BIM Technology
Chapitre d'apprentissage mongodb: Introduction à la première leçon après l'installation