上一篇文章《一个Job在OneFlow中的执行过程—上篇》,以bottom up的角度,简单讲解了一个Job(用户定义的训练/预测任务)在Oneflow中的调用入口、数据流转过程、从python端到c++端的代码执行流程,其中重点部分—编译期和运行时只做了简单介绍并未展开,本文将重点展开编译期过程,详细梳理编译期过程细节、代码执行流程。下一篇文章将详细梳理运行时过程!
需要特别说明的是,此系列文章仅在于描述流程、重点模块的代码,oneflow正处于快速版本更新和迭代过程,所以很多api在未来可能会有较大幅度的变动。目前在推进的工作有interface 1.0工作,包含了对齐pytorch api、multi client设计、构图编译期优化等,敬请期待!
概述
从整体上,Job编译分为两个部分:
Job逻辑图编译
Job物理图编译
其中,Job逻辑图的编译,主要基于由op节点(OpNode)构成的Job逻辑图(OpGragh),进行了一系列pass的系统优化过程,每个pass对逻辑图进行了一次图修改/重写(对逻辑图中的节点和连边进行了增删操作)。 Job的物理图编译,则是从Job逻辑图(OpGragh)编译为实际运行时的物理计算图(TaskGraph)以及生成最终运行时计划Plan的过程。
整个过程基于图(Graph)的抽象进行展开。
Graph抽象
图(Graph)作为一个基础抽象和数据结构,是所有深度学习框架里都存在的一个概念,因为Autograd往往都是建立在以图为基础的抽象之上(动态图或静态图)。同样,Graph[1]也是OneFlow中的一个重要基础抽象,OneFlow中各个重要的图相关的概念(OpGraph、LogicalGraph、TaskGraph、ChainGraph、ExecGraph...)都继承自Graph。Graph图的数据结构里保存着这个图中的所有的节点Node[2]和节点之间的连边Edge[3],基于Graph提供了一系列共用的遍历方法(普通遍历、拓扑遍历、BFS、DFS...),以及图改写(插入、删除 节点/边)图查询方法。Job的编译的过程也主要建立在OpGraph、TaskGraph等各种图相关的对象之上。
2.Job逻辑图编译
LazyJobBuildAndInferCtx::Complete()
在上一篇文章中,我们可以看到,通过在python代码中执行oneflow_api.CurJobBuildAndInferCtx_Complete()[4]即可调用c++中的LazyJobBuildAndInferCtx::Complete()[5]方法,此方法即为Job逻辑图编译的核心。
在该方法中,针对用户定义的user job,JobBuilder会基于Job的逻辑图OpGraph,对其进行一系列的pass优化,即代码中的DoPass。这些不同的pass优化有着不同的名称和作用,如用于自动混合精度的AutoMixedPrecision、用于并行场景下softmax交叉墒优化的SplitSparseSoftmaxCrossEntropyOpPass等等。
Maybe<void> LazyJobBuildAndInferCtx::Complete() {
CHECK_NOTNULL(Global<JobDesc>::Get());
Global<JobDesc>::Delete();
if (job().job_conf().has_train_conf()) {
CHECK_OR_RETURN(job().job_conf().train_conf().has_model_update_conf());
CHECK_OR_RETURN(job().job_conf().train_conf().has_primary_lr());
}
auto scope = std::make_unique<GlobalJobDescScope>(mut_job()->job_conf(), job_id());
JobPassCtx job_pass_ctx(GlobalJobDesc());
auto DoPass = [&](const std::string& pass_name "&") -> Maybe<void> {
return JobPass4Name(pass_name)(mut_job(), &job_pass_ctx);
};
if (GlobalJobDesc().Bool("__is_user_function__")) {
JUST(DoPass("CompleteOfrecordDecoder"));
JUST(DoPass("SetDefaultVariableConf"));
#ifdef WITH_CUDA
JUST(DoPass("AutoMixedPrecision"));
#endif
JUST(DoPass("OptimizerPlacementOptimizationPass"));
JUST(DoPass("DynamicLossScaleSchedulePass"));
JUST(DoPass("AutoTrainStep"));
JUST(DoPass("AutoLearningRate"));
JUST(DoPass("GenerateBackwardAndOptimizerOpConfs"));
JUST(DoPass("AddSspVariableProxy"));
JUST(DoPass("CheckpointingPass"));
JUST(DoPass("CudnnFusedNormalizationAddReluPass"));
JUST(DoPass("PruneCastToStaticShapeOpsPass"));
JUST(DoPass("FuseAddToOutputPass"));
JUST(DoPass("IndexedSlicesOptimizerRewritePass"));
JUST(DoPass("SplitSparseSoftmaxCrossEntropyOpPass"));
JUST(DoPass("DoParallelCastBeforeWideningTypeCast"));
JUST(DoPass("AddLbiDiffWatcherOpConfs"));
JUST(DoPass("FuseCastScalePass"));
JUST(DoPass("PruneParallelCastOpsPass"));
JUST(DoPass("FuseUpdateOpsPass"));
JUST(DoPass("DumpVariableInfoPass"));
}
JUST(DoPass("DumpTimeShapeAndBlobParallelConfPass"));
return Maybe<void>::Ok();
}
3.Job物理图编译
和逻辑图相对应,物理图则是包含了实际运行时需要的节点、设备、内存块等信息的,可实际执行的一种计算图。
在Job逻辑图编译完成后,会启动session然后执行Job物理图的编译和运行时计划Plan的生成过程。其中,具体地是通过python代码:oneflow_api.StartLazyGlobalSession()[6]调用了c++中的StartLazyGlobalSession()[7],从而启动session:
inline Maybe<void> StartLazyGlobalSession() {
CHECK_NOTNULL_OR_RETURN(Global<SessionGlobalObjectsScope>::Get()) << "session not found";
CHECK_OR_RETURN(Global<MachineCtx>::Get()->IsThisMachineMaster());
const JobSet& job_set = Global<LazyJobBuildAndInferCtxMgr>::Get()->job_set();
if (Global<ResourceDesc, ForSession>::Get()->enable_debug_mode()) {
TeePersistentLogStream::Create("job_set.prototxt")->Write(job_set);
}
if (job_set.job().empty()) { return Error::JobSetEmptyError() << "no function defined"; }
CHECK_ISNULL_OR_RETURN(Global<Oneflow>::Get());
Global<CtrlClient>::Get()->PushKV("session_job_set", job_set);
Global<const InterJobReuseMemStrategy>::New(job_set.inter_job_reuse_mem_strategy());
Global<Oneflow>::New();
JUST(Global<Oneflow>::Get()->Init(job_set)); # Oneflow::Init初始化
return Maybe<void>::Ok();
}
session启动后,进行了Oneflow::Init的初始化,并在其中通过CompileAndMergePlanOnMaster()[8]方法完成了Job物理图编译、Plan生成等主要过程。CompileAndMergePlanOnMaster()方法主要做了以下工作:
1.创建并添加系统级job
主要是添加一些模型io、数据输入/输出相关的系统级job至jobs vector,方便任务的开展。
2.job物理图编译&plan生成
主要是遍历job vector中的所有job、对这些job(用户定义的user job作业函数、系统级model io job、pull job、push job等)进行图编译,生成task graph,并根据物理图生成该job的plan。
3.子图plan间的融合
将所有job生成的独立的子图plan融合在一起,形成一个更大的merged plan。
4.内存复用、临界区和可重入锁
内存复用主要是job之间的内存共享及复用;临界区和可重入锁则是通过添加另一种系统级的main job并在main job中加入CriticalSection临界区以及ReentrantLock可重入锁的设计,用于不同job间执行控制,目的在于增加job间执行的overlap,增加执行效率尽可能地流水并行起来。
5.链接得到完整Plan
链接main plan和merged plan得到最终完整的运行时计划Plan。
下面,我们将分别看一下每个过程的实现。
3.1 创建并添加系统级job
首先,在编译物理图之前,我们已经对用户定义的user job做了一系列pass优化、图重写的操作,user job处理完成后,并不能立即编译运行,还需要添加一些系统级别的job作为辅助,譬如:
model io相关的job用于模型存储
push job用于用户数据输入
pull job用于结果输出
添加系统级job相关的代码主要在CompileAndMergePlanOnMaster()[9]方法的908~950行。
Maybe<void> CompileAndMergePlanOnMaster(const PbRpf<Job>& conf_jobs, Plan* plan) {
std::vector<std::shared_ptr<Job>> jobs(conf_jobs.size());
FOR_RANGE(int, i, 0, jobs.size()) { jobs.at(i).reset(new Job(conf_jobs.Get(i))); }
if (jobs.size() > 1) { CheckNonDistributeOptimizerAvailable(jobs); }
if (Global<MachineCtx>::Get()->IsThisMachineMaster()) {
//FilterOpName2ParallelBlobConf用于筛选出特定name的op,并返回ParallelBlobConf键值对
HashMap<std::string, ParallelBlobConf> var_op_name2parallel_blob_conf;
FilterOpName2ParallelBlobConf({OperatorConf::kVariableConf}, jobs,
&var_op_name2parallel_blob_conf);
auto AppendJob = [&](Job* job "&") {
JobDesc job_desc(job->job_conf(), jobs.size());
CHECK(!job_desc.Bool("__is_user_function__"));
jobs.emplace_back(new Job(*job));
};
// 创建model io存储相关的job
if (Global<const IOConf>::Get()->enable_legacy_model_io()) {
if (Global<const IOConf>::Get()->enable_model_io_v2()) {
MakeModelIoV2Jobs(jobs, var_op_name2parallel_blob_conf, AppendJob);
} else {
MakeModelIoJobs(jobs, var_op_name2parallel_blob_conf, AppendJob);
}
}
}
std::vector<std::shared_ptr<Job>> function_jobs;
function_jobs.reserve(jobs.size());
FOR_RANGE(int, i, 0, jobs.size()) {
JobDesc job_desc(jobs.at(i)->job_conf(), i);
if (job_desc.Bool("__is_user_function__")) { function_jobs.push_back(jobs.at(i)); }
}
if (Global<MachineCtx>::Get()->IsThisMachineMaster()) {
HashMap<std::string, ParallelBlobConf> push_op_name2parallel_blob_conf;
FilterOpName2ParallelBlobConf({OperatorConf::kInputConf}, function_jobs,
&push_op_name2parallel_blob_conf);
HashMap<std::string, ParallelBlobConf> pull_op_name2parallel_blob_conf;
FilterOpName2ParallelBlobConf({OperatorConf::kReturnConf}, function_jobs,
&pull_op_name2parallel_blob_conf);
//创建push job并插入jobs
for (const auto& pair : push_op_name2parallel_blob_conf) {
auto push_job = std::make_shared<Job>();
MakePushJob(std::string("System-Push-") + pair.first, pair.first, pair.second,
push_job.get());
jobs.emplace_back(push_job);
}
//创建pull job并插入jobs
for (const auto& pair : pull_op_name2parallel_blob_conf) {
auto pull_job = std::make_shared<Job>();
MakePullJob(std::string("System-Pull-") + pair.first, pair.first, pair.second,
pull_job.get());
jobs.emplace_back(pull_job);
}
}
...
}
ParallelBlobConf
上述代码中,我们看见创建各种系统级Job时,依赖FilterOpName2ParallelBlobConf()方法和HashMap对象:
HashMap<std::string, ParallelBlobConf> xx_op_name2parallel_blob_conf;
其中通过FilterOpName2ParallelBlobConf()方法,我们可以根据job内每个op的类型名称获取该op对应的ParallelBlobConf对象。之后,根据xx_op_name2parallel_blob_conf
的HashMap对象可以依次创建各种系统级Job(HashMap对象中的键为String类型的名称,表示job名称,值为protobuf对象—ParallelBlobConf
)。此ParallelBlobConf对象是在job_conf.proto里定义的,带有sbp信息、设备信息的、用于描述分布式情况下张量blob信息的一种protobuf对象:
message ParallelBlobConf {
required BlobDescProto logical_blob_desc_conf = 1;
required ParallelConf parallel_conf = 2;
required SbpParallel sbp_conf = 3;
required OptInt64 batch_axis = 4;
}
protobuf是谷歌开源的用于结构化的消息(对象)传递的一种协议技术、可以做到平台无关、语言无关、多机器间对象的高效传递(序列化反序列化),可参考官方文档:https://developers.google.com/protocol-buffers/docs/cpptutorial[10]
在oneflow项目中使用了protobuf用于job、运行时计划plan等一系列对象间的多机对象传递,在项目代码中,可以看到有很多.proto结尾的文件,其中就定义了一系列用于传递protobuf消息的对象类型,类似的还有placement.proto里定义的用于描述张量存放的设备名称、类型的ParallelConf:
message ParallelConf {
repeated string device_name = 1;
required string device_tag = 2;
}
如blob_desc.proto里定义的用于描述张量信息的BlobDescProto:
message BlobDescProto {
required StructPodProto header = 1;
required TensorPodProto body = 2;
required bool is_tensor_list = 3;
required bool is_body_disabled = 4;
required bool is_dynamic = 5;
required bool header_is_opaque = 6;
}
3.2 job物理图编译&plan生成
上面添加系统级job相关的工作还主要是在job层面,接下来,将对这些jobs进行物理图的编译,每个job生成一个TaskGraph子图和运行时计划plan,并不断融合成一个更大到merged plan,这一部分的代码主要在CompileAndMergePlanOnMaster()的955行[11]之后的CompileCurJobOnMaster()方法中。
CompileCurJobOnMaster()
CompileCurJobOnMaster()是job编译的主要方法,其作用主要用于编译单个job并生成物理图,并根据物理图创建出该job的运行时计划plan。
Maybe<void> CompileCurJobOnMaster(Job* job, Plan* improved_plan, bool need_job_complete) {
const JobDesc& job_desc = GlobalJobDesc();
Plan naive_plan;
Plan complete_plan;
double start = GetCurTime();
if (Global<MachineCtx>::Get()->IsThisMachineMaster()) {
// 编译生成naive版的naive_plan
Compiler().Compile(job, &naive_plan, need_job_complete);
LOG(INFO) << "compile time: " << GetCurTime() - start;
// 生成优化后的complete_plan
complete_plan =
*JUST(Improver().GenAndInferMemBlockIdOnly(*Global<AvailableMemDesc>::Get(), naive_plan));
if (Global<ResourceDesc, ForSession>::Get()->enable_debug_mode()) {
TeePersistentLogStream::Create("naive_plan")->Write(naive_plan);
TeePersistentLogStream::Create("complete_plan")->Write(complete_plan);
}
LOG(INFO) << "push_pull_plan:" << GetCurTime() - start;
}
if (job_desc.enable_experiment_run()) {
// 开启试跑模式
if (Global<MachineCtx>::Get()->IsThisMachineMaster()) {
PushPlan("complete_plan", complete_plan);
} else {
PullPlan("complete_plan", &complete_plan);
}
OF_SESSION_BARRIER();
// Experiment Runtime
{ Runtime experiment_run(complete_plan, job_desc.piece_num_of_experiment_phase(), true); }
// Improve
if (Global<MachineCtx>::Get()->IsThisMachineMaster()) {
TeePersistentLogStream::Create("available_mem_desc")->Write(*Global<AvailableMemDesc>::Get());
CHECK_GT(Global<AvailableMemDesc>::Get()->machine_amd_size(), 0);
*improved_plan = *JUST(Improver().Improve(
*Global<AvailableMemDesc>::Get(), naive_plan,
JoinPath(FLAGS_log_dir, ActEventLogger::experiment_act_event_bin_filename())));
OF_SESSION_BARRIER();
TeePersistentLogStream::Create("improved_plan")->Write(*improved_plan);
}
} else {
// 普通模式,直接返回complete_plan
*improved_plan = complete_plan;
}
// 生成集合通信boxing相关的plan
GenCollectiveBoxingPlan(job, improved_plan);
LOG(INFO) << "compile and improve time: " << GetCurTime() - start;
return Maybe<void>::Ok();
}
其中,将job编译为物理图并生成plan的核心主要是其中的
Compiler().Compile(job, &naive_plan, need_job_complete);
Compiler::Compile()
Compiler().Compile()[12]方法为job编译生成物理图的核心,该方法主要将单个job生成物理图TaskGraph,然后遍历TaskGraph中的每个节点TaskNode,将实际负责任务执行的最小单位TaskNode插入到运行时计划—plan中,最终完成单个job物理图编译并生成plan的主要过程。
实际上,TaskGraph的生成过程是OneFlow编译期最重要也是最精华的一部分,Task是Actor的编译期抽象,Actor是Task的运行时抽象,所以TaskGraph就描绘了整个运行时物理计算图的全貌。以下内容只展示了物理图编译的大致流程,更详细的内容请参考源码和知乎文章:《仅此一文让您掌握OneFlow框架的系统设计(中篇)[13]》
void Compiler::Compile(Job* job, Plan* plan, bool need_job_complete) const {
// 编译job生成plan的主方法
const JobDesc& job_desc = GlobalJobDesc();
if (need_job_complete) { JobCompleter().Complete(job); }
// 根据job创建OpGraph全局对象
Global<OpGraph>::New(*job);
if (Global<ResourceDesc, ForSession>::Get()->enable_debug_mode()) {
TeePersistentLogStream::Create(StrCat("optimized_job", job_desc.job_id()))->Write(*job);
Global<OpGraph>::Get()->ToDotWithFilePath("optimized_dlnet_" + std::to_string(job_desc.job_id())
+ "_op_graph.dot");
}
// 根据 job 生成逻辑图logical graph
auto logical_gph = std::make_unique<LogicalGraph>(*job);
// 根据 logical graph 生成task graph
auto task_gph = std::make_unique<TaskGraph>(std::move(logical_gph));
using std::placeholders::_1;
task_gph->ForEachNode(std::bind(&TaskNode::ProduceAllRegstsAndBindEdges, _1));
task_gph->ForEachNode(std::bind(&TaskNode::ConsumeAllRegsts, _1));
task_gph->ForEachNode(std::bind(&TaskNode::PinConsumedRegst, _1));
task_gph->TopoForEachNode(&TaskNode::Build);
task_gph->RemoveEmptyRegsts();
task_gph->MergeChainAndAddOrderingCtrlEdgeInSameChain();
if (job_desc.enable_inplace()) {
auto IsReachable = Global<OpGraph>::Get()->MakePredicatorIsOpNameDataOrCtrlReachable();
task_gph->EnableInplaceMemSharing(IsReachable);
}
task_gph->TopoForEachNode(&TaskNode::InferTimeShapeIfMeaningful);
// 遍历task graph中的每一个task node,将其添加至plan。task node为plan中实际任务执行的最小模块
task_gph->ForEachNode([&](TaskNode* task_node "&") {
if (task_node->IsMeaningLess()) { return; }
task_node->ToProto(plan->mutable_task()->Add());
});
{
auto* job_id2job_conf = plan->mutable_job_confs()->mutable_job_id2job_conf();
(*job_id2job_conf)[GlobalJobDesc().job_id()] = GlobalJobDesc().job_conf();
}
// 销毁OpGraph全局对象
Global<OpGraph>::Delete();
}
3.3 子图plan间的融合
经过上面job物理图编译和plan生成的过程后,所有的plan存放于vector中(sub_plans
),这些plan最终需要合并成为一个整体plan,融合的过程主要在CompileAndMergePlanOnMaster()[14]方法第958行[15],通过:MergeSubPlanWithoutGenNetTopo(plan, sub_plans);
完成子图plan(sub_plans
)和主plan之间的融合。
3.4 内存复用、临界区和可重入锁
这一块的设计是oneflow中较为精华的所在,不仅涉及到了多个job间的内存共享和复用,还通过CriticalSection临界区以及ReentrantLock可重入锁的设计、使得多个job间的执行尽可能流水并行起来,具体的,是通过添加了一个系统级的main job完成了这些job的执行顺序控制,更详细的设计请参考同事的文章:《仅此一文让您掌握OneFlow框架的系统设计(上篇)》[16]。
oneflow中,用户定义的训练、预测、验证等任务,我们称为user job;user job之外称为系统级job,典型的如:负责数据输入输出的push job、pull job;负责模型IO存储的model io job、内存复用job执行顺序控制的main job等
内存复用、临界区、main job相关的代码主要在CompileAndMergePlanOnMaster()[17]方法第957~971行[18]:
if (Global<MachineCtx>::Get()->IsThisMachineMaster()) {
// sub_plans子图融合融合至plan
MergeSubPlanWithoutGenNetTopo(plan, sub_plans);
// 设置内存复用相关
InterJobMemSharingUtil::MergeMemReusedChunkBetweenUserJobs(function_jobs, plan);
InterJobMemSharingUtil::MergeMemSharedInterfaceMemBlockBetweenJobs(jobs, plan);
PlanUtil::SetForceInplaceMemBlock(plan);
// 计算临界区
FinishGlobalCriticalSectionDesc(*plan, jobs.size());
Plan main_plan;
std::vector<std::string> identity_tick_op_names;
{
Job main_job;
LogicalBlobId critical_p_sink_lbi;
// 创建main job
MakeMainJob(&main_job, &identity_tick_op_names, &critical_p_sink_lbi);
AddJobName2JobId(main_job.job_conf().job_name(), jobs.size());
// 编译main job生成main plan
JUST(CompileMainJob(&main_job, critical_p_sink_lbi, sub_plans.size(), &main_plan));
}
3.5 链接得到完整Plan
将上面编译main job得到的main plan和主plan链接,得到完整的运行时计划—Plan链接的过程主要在CompileAndMergePlanOnMaster()[19]方法第972行:[20]
// 链接plan和main plan得到完整的运行时计划plan
LinkMainPlan(plan, main_plan, identity_tick_op_names);
4.运行时计划Plan的多机间同步
当主节点编译生成整个集群的运行时计划Plan后,会通过968行[21]的PushPlan``(``"merged_plan"``, *plan)
方法将Plan插入至protobuf对象,如果是分布式环境下,其余节点则会通过PullPlan``(``"``merged_plan``"``, plan)
方法从protobuf环境中获取此Plan。只有在整个编译的过程中,是存在master的概念,在之后的实际执行中则是去中心化的,各节点根据自己的运行时计划Plan,执行各自的任务。
参考资料
[1]
Graph: https://link.zhihu.com/?target=https%3A//github.com/Oneflow-Inc/oneflow/blob/v0.2.0/oneflow/core/graph/graph.h
[2]Node: https://link.zhihu.com/?target=https%3A//github.com/Oneflow-Inc/oneflow/blob/v0.2.0/oneflow/core/graph/node.h%23L74
[3]Edge: https://link.zhihu.com/?target=https%3A//github.com/Oneflow-Inc/oneflow/blob/v0.2.0/oneflow/core/graph/node.h%23L46
[4]oneflow_api.CurJobBuildAndInferCtx_Complete(): https://github.com/Oneflow-Inc/oneflow/blob/f733ab4cb43699cb4e3783032ac00b181166e8d9/oneflow/python/framework/compiler.py#L48
[5]LazyJobBuildAndInferCtx::Complete(): https://github.com/Oneflow-Inc/oneflow/blob/f733ab4cb43699cb4e3783032ac00b181166e8d9/oneflow/core/job/job_build_and_infer_ctx.cpp#L939
[6]oneflow_api.StartLazyGlobalSession(): https://github.com/Oneflow-Inc/oneflow/blob/f733ab4cb43699cb4e3783032ac00b181166e8d9/oneflow/python/framework/session_util.py#L221
[7]StartLazyGlobalSession(): https://github.com/Oneflow-Inc/oneflow/blob/f733ab4cb43699cb4e3783032ac00b181166e8d9/oneflow/api/python/session/session.h#L67
[8]CompileAndMergePlanOnMaster(): https://github.com/Oneflow-Inc/oneflow/blob/f733ab4cb43699cb4e3783032ac00b181166e8d9/oneflow/core/job/oneflow.cpp#L904
[9]CompileAndMergePlanOnMaster(): https://github.com/Oneflow-Inc/oneflow/blob/f733ab4cb43699cb4e3783032ac00b181166e8d9/oneflow/core/job/oneflow.cpp#L908
[10]https://developers.google.com/protocol-buffers/docs/cpptutorial: https://developers.google.com/protocol-buffers/docs/cpptutorial
[11]CompileAndMergePlanOnMaster()的955行: https://github.com/Oneflow-Inc/oneflow/blob/f733ab4cb43699cb4e3783032ac00b181166e8d9/oneflow/core/job/oneflow.cpp#L955
[12]Compiler().Compile(): https://github.com/Oneflow-Inc/oneflow/blob/f733ab4cb43699cb4e3783032ac00b181166e8d9/oneflow/core/job/compiler.cpp#L65
[13]仅此一文让您掌握OneFlow框架的系统设计(中篇): https://zhuanlan.zhihu.com/p/338699487
[14]CompileAndMergePlanOnMaster(): https://github.com/Oneflow-Inc/oneflow/blob/f733ab4cb43699cb4e3783032ac00b181166e8d9/oneflow/core/job/oneflow.cpp#L904
[15]958行: https://github.com/Oneflow-Inc/oneflow/blob/f733ab4cb43699cb4e3783032ac00b181166e8d9/oneflow/core/job/oneflow.cpp#L958
[16]《仅此一文让您掌握OneFlow框架的系统设计(上篇)》: https://zhuanlan.zhihu.com/p/337851255
[17]CompileAndMergePlanOnMaster(): https://github.com/Oneflow-Inc/oneflow/blob/f733ab4cb43699cb4e3783032ac00b181166e8d9/oneflow/core/job/oneflow.cpp#L904
[18]957~971行: https://github.com/Oneflow-Inc/oneflow/blob/f733ab4cb43699cb4e3783032ac00b181166e8d9/oneflow/core/job/oneflow.cpp#L957
[19]CompileAndMergePlanOnMaster(): https://github.com/Oneflow-Inc/oneflow/blob/f733ab4cb43699cb4e3783032ac00b181166e8d9/oneflow/core/job/oneflow.cpp#L904
[20]972行:: https://github.com/Oneflow-Inc/oneflow/blob/f733ab4cb43699cb4e3783032ac00b181166e8d9/oneflow/core/job/oneflow.cpp#L972
[21]968行: https://github.com/Oneflow-Inc/oneflow/blob/v0.2.0/oneflow/core/job/oneflow.cpp#L968
网页端点击阅读原文,获得更好的阅读体验