静态图执行器预分析性能优化 #55299
Labels
PFCC
Paddle Framework Contributor Club,https://github.com/PaddlePaddle/community/tree/master/pfcc
status/close
已关闭
type/others
其他问题
一、任务目标
飞桨静态图执行器通过对内核选择、数据转换、图依赖分析、线程调度分析、变量生命周期分析和多流分析等工作进行静态预处理,实现了“一轮分析多轮复用”,以尽可能减少动态调度开销,提升执行性能。
虽然静态预处理只需要在模型执行过程中进行一次,但对于规模较大的模型而言,这个过程会持续较长时间,消耗较多计算资源。本任务的目标是分析静态图执行器预处理阶段的性能瓶颈,并有针对性地设计和开发优化方案,以降低执行器预处理的耗时,提升大型模型的迭代效率。
二、性能分析
飞桨的静态预处理主要负责资源精细化管理和计算图预分析。计算图预分析包括了内核选择、数据转换、依赖分析、线程调度、变量生命周期和多流分析等工作。结合 Paddle 源码分析,我们可以得出两个可以进行优化的点:
预分析结果复用
全静态选Kernel推全
接下来分别详细说明一下这两个优化点。
2.1 预分析结果复用
Paddle 在将Program输入到执行器之前会进行预排列,将其分为多个任务(Job),然后按顺序执行。每个Job对应的interpretercore在执行过程中会缓存内核选择、数据转换和图依赖分析等数据。有些任务是完全相同的,也就是说在顺序执行的过程中,相同的任务可以共享一些数据。下图展示了共享预分析数据的示意图:
其中F0、F1和F2表示相同的任务。当F0对应的interpretercore运行了之后,F1和F2便无需重复分析共享的数据。
2.2 全静态选Kernel推全
当下,新的执行器在预处理阶段采用了“一轮动态调度+缓存”的方法来实现 kernel 的“半静态化”选择。第一轮采用 for-loop 方式动态选择 kernel 运行,并缓存 kernel、context 和数据传输的信息。利用这些信息完成其他分析和调度上的优化需要到第二轮才会真正生效。选用半静态化的方式使得新的执行器在第一个批次和其他批次的行为不一致,而这种不一致进一步影响显存性能、通用性、易用性和可维护性。
在新的 Phi 函数式算子体系下,每个 kernel 的输入、输出和属性都是明确的,并在 kernel 注册时记录下来。这些关键信息被暴露给框架调度,使得全静态选 kernel 变得可能。
当前全静态选 kernel 已经实现了基础功能,并成功地在 ResNet 和 Transformer 等模型上运行。在 PR #52395 和 PR #51292 中针对无法准确静态获取输入输出信息的不规范算子进行了修改。然而,在默认开启全静态选 kernel 的情况下,部分模型的行为与不开启全静态 kernel 的情况下不一致。我们将在本任务中修复这个问题,并推动使用全静态选 kernel 替代当前的“半静态化”方案。
三、优化方案
3.1 预分析结果复用方案
预分析过程中会进行图依赖分析、事件分析、内核选择、数据转换、线程调度分析、变量生命周期分析和GC分析等工作。下面详细地分析哪些数据可以复用,如果可以复用则给出复用方案。
3.1.1 图依赖分析复用
复用信息分析:
在Paddle中,图依赖分析(op dependency)发生在
Convert
过程中,其依赖分析过程在BuildOperatorDependences
构建。图依赖分析的主要作用是分析 op 之间的依赖关系,对计算图进行解析和分析,构建出每个算子的调度关系和执行顺序,以保证正确的计算顺序和结果。对于一样的子图,图依赖分析的结果是一样的,因此可以进行复用。BuildOperatorDependences
的目标是建立 Instruction 之间的 DAG图,便于后续并行调度。Instruction 中包含了上一步中建立的 OpFuncNode 作为成员变量,它是调度的一个基础单位。 Instruction的图结构是一种邻接链表的方式存储的,每个节点存储他能到达邻居节点。BuildOperatorDependences中使用图依赖构建的部分由
dependency_builder_.Build(vec_instruction_)
进行构建。dependency_builder_是ProgramInterpreter类的成员。在dependency_builder_.Build
会将每个op的下游op存储在op_downstream_map_
中,每个op的上游op存储在op_happens_before_
当中。op依赖计数存储在dependecy_count_
当中。对于一样的子图,可以通过智能指针完成op 的依赖信息的共享。
代码实现思路:
Op 依赖的复用可以分为三个部分:
op_downstream_map_
和op_happens_before_
和dependecy_count_
对于第一点,
op_downstream_map_
和op_happens_before_
信息的共享需要给DependencyBuilder类添加ShareDependencyFrom
接口并将这两个变量使用智能指针进行管理。其核心逻辑如下:由于依赖的信息较多,在ProgramInterpreter中添加多个Share函数会让代码冗余。因此可以统一在
InterpreterBaseImpl
以及其相关子类添加功能接口void ShareBuildResultsFrom(InterpreterBaseImpl* src)
,实现共享的相关逻辑。ShareBuildResultsFrom 将共享对象传入并使用智能指针共享数据。ShareBuildResultsFrom
中不止会有共享图依赖的相关逻辑,也会有共享事件分析器、线程池调度、GC等的逻辑。其中共享图依赖的相关逻辑如下:对于第二点,可以通过
job
对象的const std::string type_
来判断。具有相同的type_
说明两个任务的类型相同,即子图等信息相同。也就说明可以共享op的依赖信息。为了快速的判断哪些job
可以进行共享,可以维护一个类变量std::map<string, int> type_to_first_id
来判断哪些job
的类型是一样的。其相关逻辑如下:对于第三点可以给
ProgramInterpreter
添加一个统一开关is_shared_results_build_
进行了共享的interpretercore将该开关设为true。添加了开关后根据开关的值跳过相关的创建逻辑。然后在构建图依赖时添加相应的跳过逻辑:
相关PR: 【静态图性能优化】图依赖信息复用 #55389
3.1.2 事件分析器复用
复用信息分析:
在Paddle中,事件分析器(event analyzer)也发生在Convert过程中。事件分析器的主要作用是为不同op的执行添加Event进行同步,便于后续并行。对于一样的子图,事件分析器分析的结果是一样的,因此可以进行复用。
事件分析器依赖于图依赖分析的结果,其核心逻辑在
stream_analyzer_.ConstructEvents
中实现。其中耗时最多的部分为根据op依赖分析出哪些op之间需要进行同步。代码实现思路:
在
stream_analyzer_.ConstructEvents
中 event 分析的结果被存储在event_info
当中。对于一样的子图,event_info
是一样的。因此可以将该变量作为类变量并使用智能指针进行共享。将事件分析结果进行共享分如下步骤:
event_info
, 即从其他 Interpretercore 中共享已经分析好的结果。对于第一步首先需要将
event_info
改写为一个类变量event_info_
,并使用智能指针std::shared_ptr
管理该变量。接下来需要给StreamAnalyzer
添加ShareEventInfoFrom
接口用于和其他 Interpretercore 中的stream_analyer
共享分析结果。其核心逻辑如下:第二步已经在图依赖分析复用中实现主体功能,在共享事件分析结果时只需要在
ShareBuildResultsFrom
中添加相关共享逻辑即可。其主要逻辑代码如下:// share event analysis stream_analyzer_.ShareEventInfoFrom(src.GetStreamAnalyzer());
对于第三步则需要在
stream_analyzer_.ConstructEvents
中添加相应判断跳过事件分析的过程,其代码如下:相关PR: 【静态图性能优化】Share event #55650
3.1.3 其他信息的复用
除了图依赖和事件分析器以外,GC与线程调度的分析结果也可以进行共享。但是考虑到当下添加共享GC和线程调度的代码可能会给New IR的推进带来麻烦,故将相关PR关闭。相关PR如下,均可以通过CI测试:
3.2 全静态选Kernel推全方案
当下,Paddle框架启用静态图模式时,由于fluid算子机制的限制,新的静态图执行器采用了一种称为“一轮动态调度+缓存”的预处理方式,以实现对kernel选择的“半静态化”。具体来说,就是在预处理阶段,通过实际执行一轮算子来动态选择kernel,并将选择结果缓存以供后续的训练轮次使用。然而这样半静态选Kernel使得新执行器第一个batch和后续其它batch行为不一致,这种不一致又进一步影响了:显存性能、通用性、易用性以及可维护性C++端每个算子针对不同的后端设备(backend)、数据类型(dtype)和数据布局(layout),特化了多个不同的计算kernel,根据KernelKey三元组做标识和索引, kernel选择即根据输入数据的信息,为每个算子选择正确的执行kernel也就是说这些信息实际上是可以在预处理阶段进行“静态化”的。 在确定硬件设备(place)和数据类型(dtype)之后,OP 需要执行的计算kernel理应就是可以确定的,可以在预处理阶段提前分析出来,也就是所谓的全静态。为了实现全静态选 Kernel 我们主要有三个任务,第一个是将影响全静态选Kernel的fluid算子迁移至PHI 下并补全注册信息,第二个任务是为已有的PHI算子添加注册信息, 第三个是为控制流算子添加静态选Kernel的相关逻辑。
3.2.1 前期工作
截止2023年8月,第一个任务和第二个任务已经完成大部分工作,相关 Tracking Issue 如下:
3.2.2 相关FLAG移除
在静态选Kernel任务推动的前期,由于很多的op没有注册信息,因此我们需要添加一个黑名单,将这些op的静态选Kernel的开关关闭。在前期工作完成后,我们可以这些FLAG移除,使得所有的op都可以进行静态选Kernel。相关PR:
在移除FLAG的过程中,我们发现了两个需要迁移的算子,分别是
shuffle_batch
和distributed_fused_lamb_init
。这两个算子的迁移工作已经在PR #56371 和PR #55993 中完成。3.2.3 控制流算子的静态选Kernel
在前期工作完成后,我们可以将所有的op都进行静态选Kernel。但是由于控制流算子的特殊性,我们需要为控制流算子添加静态选Kernel的相关逻辑。在Paddle中,控制流算子主要有三种,分别是
While
和ConditionalBlock
。我们首先需要为ConditionalBlock
添加静态选Kernel的相关逻辑,然后再为While
添加相关逻辑。为
ConditionalBlock
添加静态选Kernel的相关逻辑的主要思路是:ConditionalBlock
算子的输出添加静态选Kernel的推导逻辑1、解决子图静态选Kernel的问题
当下子图默认是关闭全静态选Kernel的,也就是说子图中的算子都是动态选Kernel的。其中控制子图的开关代码如下:
static_build_ = FLAGS_new_executor_static_build && !FLAGS_new_executor_use_cuda_graph && !execution_config.used_for_control_flow_op && interpreter::BlockCanBeStaticBuilt(block);
其中
execution_config.used_for_control_flow_op
就是控制子图的开关。我们可以通过移除该开关来解决子图静态选Kernel的问题。相关PR:移除开关后,通过CI发现子图在开启全静态选Kernel后
searchsorted
算子在跑InferMeta
推导dtype
信息时出错。在static_build.cc
中添加了相关推导逻辑后,该问题得到解决。 相关代码如下:解决子图静态选Kernel的问题后,我们可以为
ConditionalBlock
添加静态选Kernel的推导逻辑。2、为
ConditionalBlock
添加静态选Kernel的推导逻辑由于控制流算子的分支选择条件是由运行时的变量动态决定的,因此我们无法在预处理阶段就确定分支的选择。所以静态选Kernel无法完全支持控制流算子。我们可以支持的情况如下:
ConditionalBlock
的输出作为输入对于第一点我们需要在
FakeInitializeOutputsForOperatorBase
添加相关的推导逻辑。具体来说我们需要单独跑一遍子图的静态选Kernel,并记录下子图跑静态选Kernel前后的输出数据类型和Place。通过比对两次的输出数据类型和Place,如果没有发生变化则说明子图并不会改变输出的数据类型和Place。如果改变了则直接报错提示关闭静态选Kernel。相关代码如下:其中为了方便的存储输出数据类型和Place,我们创建了一个
VarMetaInfo
的结构体,其定义如下:RunPreStaticBuild
中包含了子图跑静态选Kernel的相关逻辑,其主要核心思路是构建出子图的InterpreterCore
并跑一遍静态选Kernel,详细代码见相关PR。对于第二点我们首先需要在
HandleOperatorBase
中记录下conditional_block_op
之后带有Kernel的算子的输入。其核心代码如下:然后在
FakeInitializeOutputsForOperatorBase
中添加相关的判断逻辑。如果conditional_block_op
的后续没有带有Kernel的算子了,那么就直接跳过FakeInitializeOutputsForOperatorBase
。否则就对子图的输出进行推导,如果推导出的数据类型和Place和之前的不一致首先判断不一致的这个变量是否是后续带有Kernel的算子的输入,如果是则跳过报错。如果不是则报错提示关闭静态选Kernel。相关代码如下:相关PR:
3、为
While
添加静态选Kernel的推导逻辑While算子支持静态选Kernel的情况和ConditionalBlock算子类似,因此我们可以直接复用部分ConditionalBlock算子的相关逻辑。可复用的逻辑包含:
不能复用的逻辑为构建子图并进行预静态选Kernel的逻辑。因为While算子和ConditionalBlock算子的子图构建逻辑不一样。我们在
RunWhileBlockPreStaticBuild
中实现了While算子的子图构建逻辑。并在添加如下的选择逻辑区分了While算子和ConditionalBlock算子的子图构建逻辑:相关PR:
3.2.4 从黑名单中移除batch_norm
在static_build工作的前期,我们需要将一些没有注册信息的算子加入黑名单,从而关闭这些算子的静态选Kernel。在前期工作完成后,我们可以将这些算子从黑名单中移除。其中
batch_norm
算子是其中一个。在将
batch_norm
从黑名单中移除后,我们发现batch_norm
算子在静态选Kernel的情况下会出现类型推导的错误。我们需要根据输入的数据类型来推导输出的数据类型。除了batch_norm
算子以外,还有一些其他的算子的输出类型注册错误导致单侧报错:quantize_linear_kernel
: 算子未迁移至PHI下,并需补全注册类型flatten_contiguous_range
: 中有UNUSED
的输出在静态选Kernel时被推导为错误的Place针对第一个问题,我们需要将
quantize_linear_kernel
算子迁移至PHI下,并补全注册类型。针对第二个问题,我们需要在static_build.cc
中添加相关的跳过逻辑。相关PR:
3.2.5 小结
在完成了上述的工作后,我们便可以将静态选Kernel的开关设置为默认开启。静态选Kernel的开启可以进一步提升Paddle静态图执行器的性能。
汇报视频:
The text was updated successfully, but these errors were encountered: