From 83e0f9896de64f1fdd27983e9e414c2aaa35feb2 Mon Sep 17 00:00:00 2001 From: ADNRs <70401959+ADNRs@users.noreply.github.com> Date: Sun, 27 Mar 2022 09:21:54 +0800 Subject: [PATCH] fix chapter 6 (#216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix chapter 6 * correct punctuation * a,b,c → a、b、c * , → , * fix equation 6.4.1 * dimention → dimension * add a pair of parentheses to avoid misinterpreting * remove redundant words * 图 :numref:`...` → :numref:`...` * fix table numbering problem in chapter 6 Co-authored-by: Dalong <39682259+eedalong@users.noreply.github.com> --- .../compute_schedule_and_execute.md | 51 ++++++++++--------- .../graph_optimizer.md | 22 ++++---- .../kernel_selecter.md | 18 +++---- .../memory_allocator.md | 28 +++++----- chapter_backend_and_runtime/overview.md | 4 +- 5 files changed, 64 insertions(+), 59 deletions(-) diff --git a/chapter_backend_and_runtime/compute_schedule_and_execute.md b/chapter_backend_and_runtime/compute_schedule_and_execute.md index 38893e5..9eb3268 100644 --- a/chapter_backend_and_runtime/compute_schedule_and_execute.md +++ b/chapter_backend_and_runtime/compute_schedule_and_execute.md @@ -29,7 +29,7 @@ print(c) 上述脚本将所有的计算逻辑定义在Computation类的construct方法中,由于在脚本开头的context中预先设置了单算子执行模式,construct中的计算将被Python的运行时逐行调用执行,同时可以在代码中的任意位置添加print命令以便打印中间的计算结果。 -单算子执行的调用链路如图 :numref:`single_op_exec`所示,算子在Python侧被触发执行后,会经过AI框架初始化,其中需要确定包括算子的精度,输入与输出的类型和大小以及对应的硬件设备等信息,接着框架会为该算子分配计算所需的内存,最后交给具体的硬件计算设备完成计算的执行。 +单算子执行的调用链路如 :numref:`single_op_exec`所示,算子在Python侧被触发执行后,会经过AI框架初始化,其中需要确定包括算子的精度,输入与输出的类型和大小以及对应的硬件设备等信息,接着框架会为该算子分配计算所需的内存,最后交给具体的硬件计算设备完成计算的执行。 ![单算子执行](../img/ch05/single_op_exec.PNG) :width:`800px` @@ -39,13 +39,13 @@ print(c) ### 计算图调度 -虽然单算子调度具有如上所述的优点,其缺点也很明显。一方面是难于进行计算性能的优化,原因是由于缺乏计算图的全局信息,单算子执行时无法根据上下文完成算子融合,代数化简等优化;另一方面由于缺乏计算的拓扑关系,整个计算只能串行调度执行,即无法通过运行时完成并行计算。例如上述示例代码的计算逻辑可以表达为图 :numref:`graph_exec`所示。由该计算图可以看出,其中乘法和减法之间并没有依赖关系,因此这两个计算可以并行执行,而这样的并行执行信息只有将计算表达为计算图后才能完成分析,这也是计算图调度相对于单算子调度的优势之一。 +虽然单算子调度具有如上所述的优点,其缺点也很明显。一方面是难于进行计算性能的优化,原因是由于缺乏计算图的全局信息,单算子执行时无法根据上下文完成算子融合,代数化简等优化;另一方面由于缺乏计算的拓扑关系,整个计算只能串行调度执行,即无法通过运行时完成并行计算。例如上述示例代码的计算逻辑可以表达为 :numref:`graph_exec`所示。由该计算图可以看出,其中乘法和减法之间并没有依赖关系,因此这两个计算可以并行执行,而这样的并行执行信息只有将计算表达为计算图后才能完成分析,这也是计算图调度相对于单算子调度的优势之一。 ![计算图](../img/ch05/graph_exec.png) :width:`800px` :label:`graph_exec` -下面我们开始介绍计算图的调度方式,在一个典型的异构计算环境中,主要存在CPU、GPU以及NPU等多种计算设备,因此一张计算图可以由运行在不同设备上的算子组成为异构计算图。图 :numref:`computation_graph`展示了一个典型的由异构硬件共同参与的计算图。 +下面我们开始介绍计算图的调度方式,在一个典型的异构计算环境中,主要存在CPU、GPU以及NPU等多种计算设备,因此一张计算图可以由运行在不同设备上的算子组成为异构计算图。 :numref:`computation_graph`展示了一个典型的由异构硬件共同参与的计算图。 ![异构硬件计算图](../img/ch05/computation_graph.png) :width:`800px` @@ -58,7 +58,7 @@ print(c) - **GPU算子**:以英伟达GPU芯片为例,通过在主机侧将GPU Kernel逐个下发到GPU设备上,由GPU芯片执行算子的计算逻辑,由于芯片上具备大量的并行执行单元,可以为高度并行的算法提供强大的加速能力。 -- **NPU算子**:以华为Ascend芯片为例, +- **NPU算子**:以华为Ascend芯片为例, Ascend是一个高度集成的SoC芯片,NPU的优势是支持将部分或整个计算图下沉到芯片中完成计算,计算过程中不与Host发生交互,因此具备较高的计算性能。 - **Python算子**:在执行模式上与CPU算子类似,都是由主机上的CPU执行计算,区别在于计算逻辑是由Python语言的运行时通过Python解释器解释执行。 @@ -98,7 +98,7 @@ z的计算逻辑,其中Add算子被设置为在CPU上执行,Sub算子被设 完成计算图中算子对应设备的标记以后,计算图已经准备好被调度与执行,根据硬件能力的差异,可以将异构计算图的执行分为三种模式,分别是逐算子交互式执行,整图下沉执行与子图下沉执行。交互式执行主要针对CPU和GPU的场景,计算图中的算子按照输入和输出的依赖关系被逐个调度与执行;而整图下沉执行模式主要是针对NPU芯片而言,这类芯片主要的优势是能够将整个神经网络的计算图一次性下发到设备上,无需借助主机的CPU能力而独立完成计算图中所有算子的调度与执行,减少了主机和芯片的交互次数,借助NPU的Tensor加速能力,提高了计算效率和性能;子图下沉执行模式是前面两种执行模式的结合,由于计算图自身表达的灵活性,对于复杂场景的计算图在NPU芯片上进行整图下沉执行的效率不一定能达到最优,因此可以将对于NPU芯片执行效率低下的部分分离出来,交给CPU或者GPU等执行效率更高的设备处理,而将部分更适合NPU计算的子图下沉到NPU进行计算,这样可以兼顾性能和灵活性两方面。 -上述异构计算图可以实现两个目的,一个是异构硬件加速,将特定的计算放置到合适的硬件上执行;第二个是实现算子间的并发执行,从计算图上可以看出,kernel_1和kernel_2之间没有依赖关系,kernel_3和kernel_4之间也没有依赖关系,因此这两组CPU和GPU算子在逻辑上可以被框架并发调用,而kernel_5依赖kernel_3和kernel_4的输出作为输入,因此kernel_5需要等待kernel_3和kernel_4执行完成后再被触发执行。 +上述异构计算图可以实现两个目的,一个是异构硬件加速,将特定的计算放置到合适的硬件上执行;第二个是实现算子间的并发执行,从计算图上可以看出,Kernel_1和Kernel_2之间没有依赖关系,Kernel_3和Kernel_4之间也没有依赖关系,因此这两组CPU和GPU算子在逻辑上可以被框架并发调用,而Kernel_5依赖Kernel_3和Kernel_4的输出作为输入,因此Kernel_5需要等待Kernel_3和Kernel_4执行完成后再被触发执行。 虽然在计算图上可以充分表达算子间的并发关系,在实际代码中会产生由于并发而引起的一些不预期的副作用场景,例如如下代码所示: @@ -138,14 +138,14 @@ compute(y, z) :width:`800px` :label:`side_effect_1` -代码中所示三行计算之间并没有依赖关系,因此这三个算子在计算图的逻辑上可以被并发执行,并发关系如图 :numref:`side_effect_1`所示,然而根据代码的语义,显而易见是需要确保程序能够被顺序执行,这里引入的问题被称为副作用,副作用是指函数修改了在函数外部定义的状态变量的行为。由于副作用的引入而导致了错误并发关系的发生,一种解决方案是在计算图编译阶段通过添加算子间的依赖,将并发执行逻辑转换为顺序执行逻辑,转换后的计算图如图 :numref:`side_effect_2`所示: +代码中所示三行计算之间并没有依赖关系,因此这三个算子在计算图的逻辑上可以被并发执行,并发关系如 :numref:`side_effect_1`所示,然而根据代码的语义,显而易见是需要确保程序能够被顺序执行,这里引入的问题被称为副作用,副作用是指函数修改了在函数外部定义的状态变量的行为。由于副作用的引入而导致了错误并发关系的发生,一种解决方案是在计算图编译阶段通过添加算子间的依赖,将并发执行逻辑转换为顺序执行逻辑,转换后的计算图如 :numref:`side_effect_2`所示: ![消除副作用](../img/ch05/side_effect_2.png) :width:`800px` :label:`side_effect_2` -图中虚线箭头表达了算子之间的依赖关系,添加依赖关系后,算子会按照Assign_1,Assign_2,Sub_1的顺序串行执行,与代码原本的语义保持一致。 +图中虚线箭头表达了算子之间的依赖关系,添加依赖关系后,算子会按照Assign_1、Assign_2、Sub_1的顺序串行执行,与代码原本的语义保持一致。 ### 交互式执行 @@ -157,7 +157,7 @@ compute(y, z) :width:`800px` :label:`graph_exec_1` -如图 :numref:`graph_exec_1`是一张非异构计算图,计算图上全部Kernel均为GPU算子,执行方式一般分为串行执行和并行执行: +如 :numref:`graph_exec_1`是一张非异构计算图,计算图上全部Kernel均为GPU算子,执行方式一般分为串行执行和并行执行: ![串行执行](../img/ch05/graph_exec_2.png) :width:`800px` @@ -167,18 +167,20 @@ compute(y, z) :width:`800px` :label:`graph_exec_3` -- **串行执行**:将计算图展开为执行序列,按照执行序逐个串行执行,如图 :numref:`graph_exec_2`所示。其特点为执行顺序固定,单线程执行,对系统资源要求相对较低。 +- **串行执行**:将计算图展开为执行序列,按照执行序逐个串行执行,如 :numref:`graph_exec_2`所示。其特点为执行顺序固定,单线程执行,对系统资源要求相对较低。 -- **并行执行**:将计算图按照算子之间的依赖关系展开,有依赖关系的算子通过输入依赖保证执行顺序,没有依赖关系的算子则可以并行执行,如图 :numref:`graph_exec_3`所示,Kernel_1和Kernel_2没有依赖可以并行执行,Kernel_3和Kernel_4没有依赖可以并行执行。其特点为执行顺序不固定,每轮执行的算子顺序大概率不一样,多线程执行,对系统资源要求相关较高。 +- **并行执行**:将计算图按照算子之间的依赖关系展开,有依赖关系的算子通过输入依赖保证执行顺序,没有依赖关系的算子则可以并行执行,如 :numref:`graph_exec_3`所示,Kernel_1和Kernel_2没有依赖可以并行执行,Kernel_3和Kernel_4没有依赖可以并行执行。其特点为执行顺序不固定,每轮执行的算子顺序大概率不一样,多线程执行,对系统资源要求相关较高。 -串行执行和并行执行各有优点和缺点,总结对比见表5.1。 +串行执行和并行执行各有优点和缺点,总结对比见 :numref:`serial_vs_parallel`。 +:串行执行和并行执行之对比 - | 执行方式 | 串行执行 | 并行执行 | - |--------------|----------|------| - |算子执行顺序 | 固定 | 不固定 | - |算子执行线程 |单线程 | 多线程 | - |所需执行资源 | 较低 | 较高 | +| 执行方式 | 串行执行 | 并行执行 | +|--------------|----------|------| +|算子执行顺序 | 固定 | 不固定 | +|算子执行线程 |单线程 | 多线程 | +|所需执行资源 | 较低 | 较高 | +:label:`serial_vs_parallel` 2、异构计算图的执行方式 @@ -186,8 +188,8 @@ compute(y, z) :width:`800px` :label:`graph_exec_4` -如图 :numref:`graph_exec_4`是一张异构计算图,其中Kernel_1、Kernel_2、Kernel_5、Kernel_9为CPU算子,Kernel_6为python算子(执行也是在CPU上),Kernel_3和Kernel_4为GPU算子,Kernel_7和Kernel_8为GPU算子。 -一般来说计算图的优化都是基于非异构计算图来实现的,要求计算图中的算子为同一设备上的,方便算子间的融合替换等优化操作,因此需要将一张异构计算图切分为多个非异构计算图,这里切分就比较灵活了,可以定义各种切分规则,一般按照产生尽量少的子图的切分规则来切分,尽量将多的同一设备上的算子放在一张子图中,如图 :numref:`graph_exec_5`所示,最后产生5张子图:Graph_1\_CPU、Graph_2\_GPU、Graph_3\_CPU、Graph_4\_Ascend、Graph_5\_CPU。 +如 :numref:`graph_exec_4`是一张异构计算图,其中Kernel_1、Kernel_2、Kernel_5、Kernel_9为CPU算子,Kernel_6为python算子(执行也是在CPU上),Kernel_3和Kernel_4为GPU算子,Kernel_7和Kernel_8为GPU算子。 +一般来说计算图的优化都是基于非异构计算图来实现的,要求计算图中的算子为同一设备上的,方便算子间的融合替换等优化操作,因此需要将一张异构计算图切分为多个非异构计算图,这里切分就比较灵活了,可以定义各种切分规则,一般按照产生尽量少的子图的切分规则来切分,尽量将多的同一设备上的算子放在一张子图中,如 :numref:`graph_exec_5`所示,最后产生5张子图:Graph_1\_CPU、Graph_2\_GPU、Graph_3\_CPU、Graph_4\_Ascend、Graph_5\_CPU。 ![异构计算图切分](../img/ch05/graph_exec_5.png) :width:`800px` @@ -195,9 +197,9 @@ compute(y, z) 将一张异构计算图切分为多个子计算图后,执行方式一般分为子图拆分执行和子图合并执行: -- **子图拆分执行**:将切分后的多个子图分开执行,即一个子图执行完再执行另一个子图,如图 :numref:`graph_exec_6`所示,上一个子图的输出数据会传输给下一个子图的输入数据,并且下一个子图需要对输入数据拷贝为本图的device数据,如Graph_2\_GPU需要将Graph_1\_CPU的输出数据从CPU拷贝到GPU,反过来Graph_3\_CPU需要将Graph2GPU的输出数据从GPU拷贝到CPU,子图之间互相切换执行有一定的开销。 +- **子图拆分执行**:将切分后的多个子图分开执行,即一个子图执行完再执行另一个子图,如 :numref:`graph_exec_6`所示,上一个子图的输出数据会传输给下一个子图的输入数据,并且下一个子图需要对输入数据拷贝为本图的device数据,如Graph_2\_GPU需要将Graph_1\_CPU的输出数据从CPU拷贝到GPU,反过来Graph_3\_CPU需要将Graph2GPU的输出数据从GPU拷贝到CPU,子图之间互相切换执行有一定的开销。 -- **子图合并执行**:将切分后的多个子图进行合并,合并为一个整体大的DAG执行,如图 :numref:`graph_exec_7`所示,通过算子的设备属性来插入拷贝算子以实现不同设备上的算子数据传输,并且拷贝算子也是进入整图中的,从而形成一个大的整图执行,减少子图之间的切换执行开销。 +- **子图合并执行**:将切分后的多个子图进行合并,合并为一个整体大的DAG执行,如 :numref:`graph_exec_7`所示,通过算子的设备属性来插入拷贝算子以实现不同设备上的算子数据传输,并且拷贝算子也是进入整图中的,从而形成一个大的整图执行,减少子图之间的切换执行开销。 ![子图拆分](../img/ch05/graph_exec_6.png) :width:`800px` @@ -207,18 +209,21 @@ compute(y, z) :width:`800px` :label:`graph_exec_7` -由于子图合并执行能够减少子图之间的切换执行开销,因此一般来说子图合并执行性能较高,总结对比见表5.2。 +由于子图合并执行能够减少子图之间的切换执行开销,因此一般来说子图合并执行性能较高,总结对比见 :numref:`partitioning_vs_merging`。 + +:子图拆分和子图合并之对比 | 执行方式 | 子图拆分 | 子图合并| | --------------|------------------|--------------| | 异构数据传输 | 子图之间拷贝 | 算子之间拷贝| | 执行额外开销 | 子图切换执行开销 | 无| | 执行并发粒度 | 子图并发 | 算子原生并发| +:label:`partitioning_vs_merging` 3、异构计算图的执行加速 -前面讲述了非异构计算图的两种执行方式和异构计算图的两种执行方式,其中异构计算图又是在非异构计算图的基础之上,因此异构计算图按照两两组合共有四种执行方式,以MindSpore为例,采用的是子图合并并行执行,示例图如图 :numref:`graph_exec_5`所示,首先是作为一张整图来执行可以避免子图切换的执行开销,然后在整图内并行执行,可以最大粒度的发挥并发执行优势,达到最优的执行性能。 +前面讲述了非异构计算图的两种执行方式和异构计算图的两种执行方式,其中异构计算图又是在非异构计算图的基础之上,因此异构计算图按照两两组合共有四种执行方式,以MindSpore为例,采用的是子图合并并行执行,示例图如 :numref:`graph_exec_5`所示,首先是作为一张整图来执行可以避免子图切换的执行开销,然后在整图内并行执行,可以最大粒度的发挥并发执行优势,达到最优的执行性能。 ![异构硬件加速](../img/ch05/graph_exec_8.png) :width:`800px` @@ -226,6 +231,6 @@ compute(y, z) ### 下沉式执行 -下沉式执行是通过专用芯片的SoC架构,将整个或部分计算图一次性调度到芯片上以完成全量数据的计算。例如对于Ascend芯片,多个Ascend算子组成的计算图可以在执行前被编译成为一个Task,通过Ascend驱动程序提供的接口,将包含多个算子的Task一次性下发到硬件上调度执行。因此上例中可以将Ascend的算子Kernel_7和Kernel_8优化为一个子图Graph_4\_Ascend,再将该子图编译成为一个Task,并下沉到Ascend上执行,如图 :numref:`graph_exec_8`所示。 +下沉式执行是通过专用芯片的SoC架构,将整个或部分计算图一次性调度到芯片上以完成全量数据的计算。例如对于Ascend芯片,多个Ascend算子组成的计算图可以在执行前被编译成为一个Task,通过Ascend驱动程序提供的接口,将包含多个算子的Task一次性下发到硬件上调度执行。因此上例中可以将Ascend的算子Kernel_7和Kernel_8优化为一个子图Graph_4\_Ascend,再将该子图编译成为一个Task,并下沉到Ascend上执行,如 :numref:`graph_exec_8`所示。 下沉式执行由于避免了在计算过程中主机侧和设备侧的交互,因此可以获得更好的整体计算性能。然而下沉式执行也存在一些局限,例如在动态shape算子,复杂控制流等场景下会面临较大的技术挑战。 diff --git a/chapter_backend_and_runtime/graph_optimizer.md b/chapter_backend_and_runtime/graph_optimizer.md index b6428af..1d8bb27 100644 --- a/chapter_backend_and_runtime/graph_optimizer.md +++ b/chapter_backend_and_runtime/graph_optimizer.md @@ -9,12 +9,12 @@ 以优化内存IO为例。深度学习算子按其对资源的需求可以分为两类: 计算密集型算子,这些算子的时间绝大部分花在计算上,如卷积、全连接等; 访存密集型算子,这些算子的时间绝大部分花在访存上,他们大部分是Element-Wise算子,例如 -ReLU,Element-Wise Sum等。 +ReLU、Element-Wise Sum等。 在典型的深度学习模型中,一般计算密集型和访存密集型算子是相伴出现的,最简单的例子是"Conv + ReLU"。Conv卷积算子是计算密集型,ReLU算子是访存密集型算子,ReLU算子可以直接取Conv算子的计算结果进行计算,因此我们可以将二者融合成一个算子来进行计算,从而减少内存访问延时和带宽压力,提高执行效率。 -例如:"Conv + Conv + Sum + -ReLU"的融合,从图 :numref:`conv_sum_relu`中我们可以看到融合后的算子减少了两个内存的读和写的操作,优化了Conv的输出和Sum的输出的读和写的操作。 +例如:"Conv + Conv + Sum + +ReLU"的融合,从 :numref:`conv_sum_relu`中我们可以看到融合后的算子减少了两个内存的读和写的操作,优化了Conv的输出和Sum的输出的读和写的操作。 ![Elementwise算子融合](../img/ch05/conv_sum_relu.png) :width:`800px` @@ -28,12 +28,12 @@ MindSpore :width:`800px` :label:`graph_kernel` -图 :numref:`graph_kernel`中,算子拆解阶段(Expander)将计算图中一些复杂算子(composite -op, 图中Op1、Op3、Op4)展开为计算等价的基本算子组合( + :numref:`graph_kernel`中,算子拆解阶段(Expander)将计算图中一些复杂算子(composite +op,图中Op1、Op3、Op4)展开为计算等价的基本算子组合( 图中虚线正方形框包围着的部分);在算子聚合阶段(Aggregation),将计算图中将基本算子(basic -op, 如图中Op2)、拆解后的算子(expanded -op)组合融合,形成一个更大范围的算子组合;在算子重建阶段(Reconstruction)中,按照输入tensor到输出tensor的仿射关系将基本算子进行分类:elemwise, -broadcast, reduce, transform等,并在这基础上归纳出不同的通用计算规则(如 +op,如图中Op2)、拆解后的算子(expanded +op)组合融合,形成一个更大范围的算子组合;在算子重建阶段(Reconstruction)中,按照输入tensor到输出tensor的仿射关系将基本算子进行分类:elemwise、 +broadcast、reduce、transform等,并在这基础上归纳出不同的通用计算规则(如 elemwise + reduce 规则:elemwise + reduce在满足一定条件后可以高效执行),根据这些计算规则不断地从这个大的算子组合上进行分析、筛选,最终重新构建成新的算子(如图中虚线正方形包围的两个算子 New Op1 和 New @@ -45,7 +45,7 @@ Op2)。图算融合通过对计算图结构的拆解和聚合,可以实现 1、硬件指令限制 -在一些特定的硬件上,IR中计算节点没有直接对应的硬件算子,只能通过子图的变换来达到子图中所有算子在对应的硬件上的存在。例如在MindSpore中,昇腾芯片上的Concat算子,只支持有限的输入个数(63个),因此当前端IR上的输入个数大于限制输入的时候,需要将该计算节点拆分成等价的多个Concat节点,如图 :numref:`concat`所示: +在一些特定的硬件上,IR中计算节点没有直接对应的硬件算子,只能通过子图的变换来达到子图中所有算子在对应的硬件上的存在。例如在MindSpore中,昇腾芯片上的Concat算子,只支持有限的输入个数(63个),因此当前端IR上的输入个数大于限制输入的时候,需要将该计算节点拆分成等价的多个Concat节点,如 :numref:`concat`所示: 当Concat有100个输入时,单个算子只支持最多63个输入,此时会将该计算节点拆分成两个Concat节点,分别为63个输入和37个输入的两个算子。 ![Concat算子拆分](../img/ch05/concat.png) @@ -54,8 +54,8 @@ Op2)。图算融合通过对计算图结构的拆解和聚合,可以实现 2、数据排布格式的限制 -针对不同特点的计算平台和不同的算子,为了追求最好的性能,一般都需要选择不同的数据排布格式(Format),而这些排布格式可能跟框架缺省的排布格式是不一样的。在这种情况下,一般的做法是算子在执行完成后对输出插入一个格式转换操作,把排布格式转换回框架的缺省排布格式,这就引入了额外的内存操作。以下图 :numref:`transdata`为例,在昇腾平台上Conv算子在输入和输出的内存排布为5HD时是性能最优的,所以可以看到Conv算子输出结果的格式是5HD,然后通过一个转换操作转回了框架缺省的NCHW,紧接着,后面又是一个Conv算子,它需要5HD的输入,所以又做了一个NCHW到5HD的转换。我们很容易看出,虚线框内的两个转换操作互为逆操作,可以相互抵消。通过对计算图的模式匹配,可以将该类型的操作消除。 +针对不同特点的计算平台和不同的算子,为了追求最好的性能,一般都需要选择不同的数据排布格式(Format),而这些排布格式可能跟框架缺省的排布格式是不一样的。在这种情况下,一般的做法是算子在执行完成后对输出插入一个格式转换操作,把排布格式转换回框架的缺省排布格式,这就引入了额外的内存操作。以 :numref:`transdata`为例,在昇腾平台上Conv算子在输入和输出的内存排布为5HD时是性能最优的,所以可以看到Conv算子输出结果的格式是5HD,然后通过一个转换操作转回了框架缺省的NCHW,紧接着,后面又是一个Conv算子,它需要5HD的输入,所以又做了一个NCHW到5HD的转换。我们很容易看出,虚线框内的两个转换操作互为逆操作,可以相互抵消。通过对计算图的模式匹配,可以将该类型的操作消除。 ![数据排布格式转换消除](../img/ch05/transdata.png) :width:`800px` -:label:`transdata` \ No newline at end of file +:label:`transdata` diff --git a/chapter_backend_and_runtime/kernel_selecter.md b/chapter_backend_and_runtime/kernel_selecter.md index 6289f46..91f3387 100644 --- a/chapter_backend_and_runtime/kernel_selecter.md +++ b/chapter_backend_and_runtime/kernel_selecter.md @@ -6,20 +6,20 @@ 经历了后端的图优化后,IR图中的每一个节点都有一组算子与之对应。此时的IR图中的每一个节点可以认为是用户可见的最小硬件执行单元。但是此时IR图中的一个节点代表了用户代码的一个操作,对于这个操作还没有具体生成有关设备信息的细节描述。这些信息是算子选择所选择的内容信息,我们称之为算子信息。算子信息主要包括以下内容: -1. 针对不同特点的计算平台和不同的算子,为了追求最好的性能,一般都需要选择不同的数据排布格式。机器学习系统常见的数据排布格式有NCHW,NHWC等。 +1. 针对不同特点的计算平台和不同的算子,为了追求最好的性能,一般都需要选择不同的数据排布格式。机器学习系统常见的数据排布格式有NCHW和NHWC等。 -2. 对于不同的硬件支持不同的计算精度,例如float32,float16,int32等。算子选择需要在所支持各种数据类型的算子中选择出用户所设定的数据类型最为相符的算子。 +2. 对于不同的硬件支持不同的计算精度,例如float32、float16和int32等。算子选择需要在所支持各种数据类型的算子中选择出用户所设定的数据类型最为相符的算子。 **数据排布格式** 机器学习系统中很多运算都会转换成为矩阵的乘法,例如卷积运算。我们知道矩阵乘法$A\times B = C$ -,是以A的一行乘以B的一列求和后得到C的一个元素。以图 :numref:`matmuldatalayout`为例,在图 :numref:`matmuldatalayout`的上方,矩阵数据的存储是按照行优先来进行存储,虽然B在存储时是按照行存储,但是读取数据时却按照列进行读取,假如我们能把B的格式进行转换转换为列存储,例如图 :numref:`matmuldatalayout`下方所示,这样就可以通过访问连续内存的方式加快数据访问速度进而提升运算速度。由此可见不同的数据排布方式对性能有很大影响。 +是以A的一行乘以B的一列求和后得到C的一个元素。以 :numref:`matmuldatalayout`为例,在 :numref:`matmuldatalayout`的上方,矩阵数据的存储是按照行优先来进行存储,虽然B在存储时是按照行存储,但是读取数据时却按照列进行读取,假如我们能把B的格式进行转换转换为列存储,例如 :numref:`matmuldatalayout`下方所示,这样就可以通过访问连续内存的方式加快数据访问速度进而提升运算速度。由此可见不同的数据排布方式对性能有很大影响。 ![矩阵乘法数据排布示意图](../img/ch05/matmuldatalayout.png) :width:`800px` :label:`matmuldatalayout` -在机器学习系统中我们常见的数据格式一般有两种,分别为NCHW类型和NHWC类型。其中N代表了数据输入的BatchSize大小,C代表了图像的通道,H和W分别代表图像输入的长和宽。图 :numref:`data_format`展示了BatchSize为2,通道数16和长度为5\*4大小的数据逻辑示意图。 +在机器学习系统中我们常见的数据格式一般有两种,分别为NCHW类型和NHWC类型。其中N代表了数据输入的BatchSize大小,C代表了图像的通道,H和W分别代表图像输入的长和宽。 :numref:`data_format`展示了BatchSize为2,通道数16和长度为5\*4大小的数据逻辑示意图。 ![常见数据格式](../img/ch05/data_format.png) :width:`800px` @@ -29,7 +29,7 @@ 对于NCHW的数据是先取W轴方向数据,再取H轴方向数据,再取C轴方向,最后取N轴方向。其中物理存储与逻辑存储的之间的映射关系为 $$offsetnchw(n,c,h,w) = n*CHW + c*HW + h*W +w$$ -如图 :numref:`nchw`所示,这种格式中,是按照最低维度W轴方向进行展开,W轴相邻的元素在内存排布中同样是相邻的。如果需要取下一个图片上的相同位置的元素,就必须跳过整个图像的尺寸($C*H*W$)。比如我有8张32\*32的RGB图像,此时$N=8,C=3,H=32,W=32$。在内存中存储们需要先按照w轴方向进行展开,然后按照h轴排列,这样之后便完成了一个通道的处理,之后按照同样的方式处理下一个通道。处理完全部通道后,处理下一张图片。PyTorch和MindSpore框架默认使用NCHW格式。 +如 :numref:`nchw`所示,这种格式中,是按照最低维度W轴方向进行展开,W轴相邻的元素在内存排布中同样是相邻的。如果需要取下一个图片上的相同位置的元素,就必须跳过整个图像的尺寸($C*H*W$)。比如我有8张32\*32的RGB图像,此时$N=8,C=3,H=32,W=32$。在内存中存储们需要先按照W轴方向进行展开,然后按照H轴排列,这样之后便完成了一个通道的处理,之后按照同样的方式处理下一个通道。处理完全部通道后,处理下一张图片。PyTorch和MindSpore框架默认使用NCHW格式。 ![RGB图片下的NHWC数据格式](../img/ch05/nchw.png) :width:`800px` @@ -37,13 +37,13 @@ $$offsetnchw(n,c,h,w) = n*CHW + c*HW + h*W +w$$ 类似的NHWC数据格式是先取C方向数据,再取W方向,然后是H方向,最后取N方向。NHWC是Tensorflow默认的数据格式。这种格式在PyTorch中称为Chanel-Last。 $$offsetnchw(n,c,h,w) = n*HWC + h*HW + w*C +c$$ -图 :numref:`nchwandnhwc`展示了不同数据格式下逻辑排布到内存物理侧数据排布的映射。\[x:1\]代表从最内侧维度到最下一维度的索引变换。比如\[a:1\]表示当前行W轴结束后,下一个H轴排布。\[b:1\]表示最内侧C轴排布完成后进行按照W轴进行排列。 + :numref:`nchwandnhwc`展示了不同数据格式下逻辑排布到内存物理侧数据排布的映射。\[x:1\]代表从最内侧维度到最下一维度的索引变换。比如\[a:1\]表示当前行W轴结束后,下一个H轴排布。\[b:1\]表示最内侧C轴排布完成后进行按照W轴进行排列。 ![NHWC与NHWC数据存储格式](../img/ch05/nchwandnhwc.png) :width:`800px` :label:`nchwandnhwc` -上述的数据存储格式具有很大的灵活性,很多框架都采用上述的两种格式作为默认的数据排布格式。但是在硬件上对数据操作时,此时的数据排布可能还不是最优的。在机器学习系统中,用户输入的数据往往会远远大于计算部件一次性计算所能容纳的最大范围,所以此时必须将输入的数据进行切片分批送到运算部件中进行运算。为了加速运算很多框架又引入了一些块布局格式来进行进一步的优化,这种优化可以使用一些硬件的加速指令,对数据进行搬移和运算。比如OneDnn上的nChw16c +上述的数据存储格式具有很大的灵活性,很多框架都采用上述的两种格式作为默认的数据排布格式。但是在硬件上对数据操作时,此时的数据排布可能还不是最优的。在机器学习系统中,用户输入的数据往往会远远大于计算部件一次性计算所能容纳的最大范围,所以此时必须将输入的数据进行切片分批送到运算部件中进行运算。为了加速运算很多框架又引入了一些块布局格式来进行进一步的优化,这种优化可以使用一些硬件的加速指令,对数据进行搬移和运算。比如oneDNN上的nChw16c 和nChw8c 格式,以及Ascend芯片的5HD等格式。这种特殊的数据格式与硬件更为贴合,可以快速的将矩阵向量化,并且极大的利用片内缓存。 @@ -56,7 +56,7 @@ Precision)浮点表示。这种数据类型占用32位内存。还有一种精 :width:`800px` :label:`floatdtype` -如图 :numref:`floatdtype`其中sign代表符号位,占1位,表示了机器数的正负,exponent表示指数位,Mantissa为尾数位。其数据计算采用二进制的科学计数法转换为十进制的计算方式如下: +如 :numref:`floatdtype`其中sign代表符号位,占1位,表示了机器数的正负,exponent表示指数位,Mantissa为尾数位。其数据计算采用二进制的科学计数法转换为十进制的计算方式如下: $$(-1)^{sign}\times 2^{exponent-15}\times (\frac{mantissa}{1024}+1)$$ 其中如果指数位全为0时,且尾数位全为0时表示数字0。 如果指数位全为0,尾数位不全为0则表示一个非常小的数值。 @@ -84,4 +84,4 @@ $$(-1)^{sign}\times 2^{exponent-15}\times (\frac{mantissa}{1024}+1)$$ 算子的数据排布格式转换是一个比较耗时的操作,为了避免频繁的格式转换所带来的内存搬运开销,数据应该尽可能地以同样的格式在算子之间传递,算子和算子的衔接要尽可能少的出现数据排布格式不一致的现象。另外,数据类型不同导致的降精度可能会使得误差变大,收敛速度变慢甚至不收敛,所以数据类型的选择也要结合具体算子分析。 -总的来说,一个好的算子选择算法应该尽可能的保持数据类型与用户设置的数据类型一致,且尽可能少的出现数据格式转换。 \ No newline at end of file +总的来说,一个好的算子选择算法应该尽可能的保持数据类型与用户设置的数据类型一致,且尽可能少的出现数据格式转换。 diff --git a/chapter_backend_and_runtime/memory_allocator.md b/chapter_backend_and_runtime/memory_allocator.md index 3b26293..09ce17f 100644 --- a/chapter_backend_and_runtime/memory_allocator.md +++ b/chapter_backend_and_runtime/memory_allocator.md @@ -6,7 +6,7 @@ In-Place内存分配还可以提高某些算子的执行效率。 ### Device内存概念 -在深度学习体系结构中,我们通常将与硬件加速器(如GPU,AI芯片等)相邻的内存称之为设备(Device)内存,而与CPU相邻的内存称之为主机(Host)内存。如图 :numref:`host-device-memory`所示,CPU可以合法地访问主机上的内存,而无法直接访问设备上的内存;同理,AI芯片可以访问设备上的内存,却无法访问主机上的内存。因此,在网络训练过程中,我们往往需要从磁盘加载数据到主机内存中,然后在主机内存中做数据处理,再从主机内存拷贝到设备内存中,最后设备才能合法地访问数据。算子全部计算完成后,用户要获取训练结果,又需要把数据从设备内存拷贝到主机内存中。 +在深度学习体系结构中,我们通常将与硬件加速器(如GPU、AI芯片等)相邻的内存称之为设备(Device)内存,而与CPU相邻的内存称之为主机(Host)内存。如 :numref:`host-device-memory`所示,CPU可以合法地访问主机上的内存,而无法直接访问设备上的内存;同理,AI芯片可以访问设备上的内存,却无法访问主机上的内存。因此,在网络训练过程中,我们往往需要从磁盘加载数据到主机内存中,然后在主机内存中做数据处理,再从主机内存拷贝到设备内存中,最后设备才能合法地访问数据。算子全部计算完成后,用户要获取训练结果,又需要把数据从设备内存拷贝到主机内存中。 ![主机内存和设备内存](../img/ch05/host-device-memory.png) :width:`800px` @@ -14,20 +14,20 @@ In-Place内存分配还可以提高某些算子的执行效率。 ### 内存分配 {#内存分配-1} -内存分配模块主要负责给图中算子的输入、输出分配Device内存。用户的前端脚本经过编译器前端处理后得到中间表达,后端根据中间表达进行算子选择和相关优化,可以得到算子最终的输入输出Tensor的形状、数据类型(Data -Type)、格式(Format)等信息,根据这些信息我们可以计算出算子输入、输出Tensor的尺寸大小。基本的计算方法为: -$$size=\prod_{i=0}^{dimention}shape_i * sizeof\left ( data type \right )$$ +内存分配模块主要负责给图中算子的输入、输出分配Device内存。用户的前端脚本经过编译器前端处理后得到中间表达,后端根据中间表达进行算子选择和相关优化,可以得到算子最终的输入输出Tensor的形状、数据类型(Data +Type)、格式(Format)等信息,根据这些信息我们可以计算出算子输入、输出Tensor的尺寸大小。基本的计算方法为: +$$size=\left (\prod_{i=0}^{dimension}shape_i\right ) * sizeof\left ( data type \right )$$ 得到Tensor的尺寸大小后,往往还需要对内存大小进行对齐操作。内存通常以4字节、8字节或16字节为一组进行访问,如果被搬运的内存大小不是这些值的倍数,内存后面会填充相应数量的空数据以使得内存长度达到这些值的倍数。因此,访问非对齐的内存可能会更加耗时。 ![内存分配示例](../img/ch05/memory_allocate.png) :width:`800px` :label:`memory_allocate` -下面以图 :numref:`memory_allocate`为例介绍内存分配的大致流程。首先我们会给Input +下面以 :numref:`memory_allocate`为例介绍内存分配的大致流程。首先我们会给Input Tensor、Conv2D的权重和Conv2D的输出分配内存地址。然后为BatchNorm的输入分配地址时,我们发现BatchNorm的输入就是Conv2D算子的输出,而该Tensor的地址已经在之前分配过了,因此只需要将Conv2D算子的输出地址共享给BatchNorm的输入,就可以避免内存的重复申请以及内存的冗余拷贝。以此类推,可以发现整个过程中可以将待分配的内存分成三种类型:一是整张图的输入Tensor,二是算子的权重或者属性,三是算子的输出Tensor,三种Tensor在训练过程中的生命周期有所不同。 在CPU上我们常常使用malloc函数直接申请内存,这种方式申请内存好处是随时申请随时释放,简单易用。然而在许多对性能要求严苛的计算场景中,由于所申请内存块的大小不定,频繁申请释放会降低性能。通常我们会使用内存池的方式去管理内存,先申请一定数量和大小的内存块留作备用,当程序有内存申请需求时,直接从内存池中的内存块中申请。当程序释放该内存块时,内存池会进行回收并用作后续程序内存申请时使用。 -在深度学习框架中,Device内存的申请也是非常频繁的,往往也是通过内存池的方式去管理Device内存,并让Device内存的生命周期与Tensor的生命周期保持一致。不同的深度学习框架在内存池的设计上大同小异,我们以图 :numref:`device_malloc`的MindSpore框架内存申请为例,进程会从Device上申请足够大的内存,然后通过双游标从两端偏移为Tensor分配内存。首先从申请的首地址开始进行偏移,为算子权重的Tensor分配内存,这部分Tensor生命周期较长,往往持续整个训练过程。然后从申请Device地址的末尾开始偏移,为算子的输出Tensor分配内存,这部分内存的生命周期较短,往往在该算子计算结束并且后续计算过程中无需再次使用该算子的输出的情况下,其生命周期就可以结束。通过这种方式,我们只需要从Device上申请一次足够大的内存,后续算子的内存分配都是通过指针偏移进行分配,减少了直接从设备申请内存的耗时。 +在深度学习框架中,Device内存的申请也是非常频繁的,往往也是通过内存池的方式去管理Device内存,并让Device内存的生命周期与Tensor的生命周期保持一致。不同的深度学习框架在内存池的设计上大同小异,我们以 :numref:`device_malloc`的MindSpore框架内存申请为例,进程会从Device上申请足够大的内存,然后通过双游标从两端偏移为Tensor分配内存。首先从申请的首地址开始进行偏移,为算子权重的Tensor分配内存,这部分Tensor生命周期较长,往往持续整个训练过程。然后从申请Device地址的末尾开始偏移,为算子的输出Tensor分配内存,这部分内存的生命周期较短,往往在该算子计算结束并且后续计算过程中无需再次使用该算子的输出的情况下,其生命周期就可以结束。通过这种方式,我们只需要从Device上申请一次足够大的内存,后续算子的内存分配都是通过指针偏移进行分配,减少了直接从设备申请内存的耗时。 ![双游标法分配内存](../img/ch05/device_malloc.png) :width:`800px` @@ -36,19 +36,19 @@ Tensor、Conv2D的权重和Conv2D的输出分配内存地址。然后为BatchNor ### 内存复用 在机器学习系统中,内存复用是指分析Tensor的生命周期,将生命周期结束的Tensor的Device内存释放回内存池并用于后续Tensor的内存分配。内存复用的目的是提高内存的利用率,让有限的设备内存容纳更大的模型。 -以图 :numref:`memory_allocate`为例,当BatchNorm算子计算结束后,output1不再被任何算子使用,则该Tensor的Device内存可以被回收,并且如果output1的内存尺寸大于等于output3的内存尺寸,则从output1回收的地址可以用于output3的内存分配,从而达到复用output1地址的目的。 +以 :numref:`memory_allocate`为例,当BatchNorm算子计算结束后,output1不再被任何算子使用,则该Tensor的Device内存可以被回收,并且如果output1的内存尺寸大于等于output3的内存尺寸,则从output1回收的地址可以用于output3的内存分配,从而达到复用output1地址的目的。 ![内存生命周期图](../img/ch05/combine_memory_reuse_and_no_reuse.png) :width:`800px` :label:`combine_memory_reuse_and_no_reuse` -为了更好地描述内存复用问题,我们通过内存生命周期图来辅助理解。如图 :numref:`combine_memory_reuse_and_no_reuse`所示,图中横坐标表示Tensor的生命周期,图中纵坐标表示内存大小。在生命周期内,某一个Tensor将一直占用某块Device内存,直至生命周期结束才会释放相应内存块。通过Tensor生命周期和内存大小可以构造出矩形块,而内存分配要求解的目标是在内存生命周期图中容纳更多的矩形块,问题的约束是矩形块之间无碰撞。图 :numref:`combine_memory_reuse_and_no_reuse`左边是在未使用任何内存复用策略的情况下的内存生命周期图,此时内存同时只能容纳T0、T1、T2、T3四个Tensor。 +为了更好地描述内存复用问题,我们通过内存生命周期图来辅助理解。如 :numref:`combine_memory_reuse_and_no_reuse`所示,图中横坐标表示Tensor的生命周期,图中纵坐标表示内存大小。在生命周期内,某一个Tensor将一直占用某块Device内存,直至生命周期结束才会释放相应内存块。通过Tensor生命周期和内存大小可以构造出矩形块,而内存分配要求解的目标是在内存生命周期图中容纳更多的矩形块,问题的约束是矩形块之间无碰撞。 :numref:`combine_memory_reuse_and_no_reuse`左边是在未使用任何内存复用策略的情况下的内存生命周期图,此时内存同时只能容纳T0、T1、T2、T3四个Tensor。 内存复用策略的求解是一个NP完全的问题。许多深度学习框架通常采用贪心的策略去分配内存,例如采用BestFit算法,每次直接从内存池中选取可以满足条件的最小内存块,然而这种贪心的策略往往会陷入局部最优解,而无法求得全局最优解。为了更好地逼近内存分配策略全局最优解,MindSpore框架提出了一种新的内存分配算法 -SOMAS(Safe Optimized Memory Allocation -Solver)。SOMAS将计算图并行流与数据依赖进行聚合分析,得到算子间祖先关系,构建张量全局生命周期互斥约束,使用多种启发式算法求解最优的内存静态规划,实现逼近理论极限的内存复用,从而提升支持的内存大小。 +SOMAS(Safe Optimized Memory Allocation +Solver)。SOMAS将计算图并行流与数据依赖进行聚合分析,得到算子间祖先关系,构建张量全局生命周期互斥约束,使用多种启发式算法求解最优的内存静态规划,实现逼近理论极限的内存复用,从而提升支持的内存大小。 -由图 :numref:`combine_memory_reuse_and_no_reuse`右边可知,经过SOMAS求解之后,同样的内存大小,可支持的Tensor数量达到了7个。 +由 :numref:`combine_memory_reuse_and_no_reuse`右边可知,经过SOMAS求解之后,同样的内存大小,可支持的Tensor数量达到了7个。 ### 常见的内存分配优化手段 @@ -63,11 +63,11 @@ Solver)。SOMAS将计算图并行流与数据依赖进行聚合分析,得到 #### In-Place算子 -在前面的内存分配流程中,我们会为每个算子的输入和输出都分配不同的内存。然而对很多算子而言,为其分配不同的输入和输出地址,会浪费内存并且影响计算性能。例如优化器算子,其计算的目的就是更新神经网络的权重;例如Python语法中的'+='和'\*='操作符,将计算结果更新到符号左边的变量中;例如'a\[0\]=b'语法,将'a\[0\]'的值更新为'b'。诸如此类计算有一个特点,都是为了更新输入的值。下面以Tensor的'a\[0\]=b'操作为例介绍In-Place的优点。 -图 :numref:`inplace-op`左边是非In-Place操作的实现,step1将Tensor +在前面的内存分配流程中,我们会为每个算子的输入和输出都分配不同的内存。然而对很多算子而言,为其分配不同的输入和输出地址,会浪费内存并且影响计算性能。例如优化器算子,其计算的目的就是更新神经网络的权重;例如Python语法中的'+='和'\*='操作符,将计算结果更新到符号左边的变量中;例如'a\[0\]=b'语法,将'a\[0\]'的值更新为'b'。诸如此类计算有一个特点,都是为了更新输入的值。下面以Tensor的'a\[0\]=b'操作为例介绍In-Place的优点。 + :numref:`inplace-op`左边是非In-Place操作的实现,step1将Tensor a拷贝到Tensor a',step2将Tensor b赋值给Tensor a',step3将Tensor a'拷贝到Tensor -a。图 :numref:`inplace-op`右边是算子In-Place操作的实现,仅用一个步骤将Tensor +a。 :numref:`inplace-op`右边是算子In-Place操作的实现,仅用一个步骤将Tensor b拷贝到Tensor a对于的位置上。对比两种实现,可以发现In-Place操作节省了两次拷贝的耗时,并且省去了Tensor a'内存的申请。 diff --git a/chapter_backend_and_runtime/overview.md b/chapter_backend_and_runtime/overview.md index c4f5fd8..ef96e54 100644 --- a/chapter_backend_and_runtime/overview.md +++ b/chapter_backend_and_runtime/overview.md @@ -2,7 +2,7 @@ 编译器前端主要将用户代码进行解析翻译得到计算图IR并对其进行设备信息无关的优化,此时我们并不考虑程序执行的底层硬件信息。编译器后端的主要职责对前端下发的IR做进一步的计算图优化,让其更加贴合硬件,并为IR中的计算节点选择适合在硬件上执行的算子,然后为每个算子的输入输出分配硬件内存,最终生成一个可以在硬件上执行的任务序列。 -如图 :numref:`compiler-backend-architecture`所示,编译器后端处于前端和硬件驱动层中间,主要负责计算图优化、算子选择和内存分配的任务。首先,需要根据硬件设备的特性将IR图进行等价图变换,以便在硬件上能够找到对应的执行算子,该过程是计算图优化的重要步骤之一。前端IR生成是解析用户代码,属于一个较高的抽象层次,隐藏一些底层运行的细节信息,此时无法直接对应硬件上的算子(算子是设备上的基本计算序列,例如MatMul,Convolution,ReLU等),需要将细节信息进行展开后,才能映射到目标硬件上的算子。对于某些前端IR的子集来说,一个算子便能够执行对应的功能,此时可以将这些IR节点进行合并成为一个计算节点,该过程称之为算子融合;对于一些复杂计算,后端并没有直接与之对应的算子,但是可以通过几个基本运算的算子组合达到同样的计算效果,此时可以将前端IR节点拆分成多个小算子。然后,我们需要进行算子选择。算子选择是在得到优化的IR图后,需要选取最合适的目标设备算子。针对用户代码所产生的IR往往可以映射成多种不同的硬件算子,但是生成不同的算子执行效率往往有很大的差别,如何根据前端IR选择出最高效的算子,是算子选择的核心问题。算子选择本质上是一个模式匹配问题。其最简单的方法就是每一个IR节点对应一个目标硬件的算子,但是这种方法往往对目标硬件的资源利用比较差。目前来说对于现有的编译器一般都对每一个IR节点提供了多个候选的算子,算子选择目标就是从中选择最优的一个算子作为最终执行在设备上的算子。总的来说,在机器学习系统中,对前端生成的IR图上的各个节点进行拆分和融合,让前端所表示的高层次IR逐步转换为可以在硬件设备上执行的低层次IR。得到了这种更加贴合硬件的IR后,对于每个单节点的IR可能仍然有很多种不同的选择,例如可以选择不同的输入输出格式和数据类型,我们需要对IR图上每个节点选择出最为合适的算子,算子选择过程可以认为是针对IR图的细粒度优化过程,最终生成完整的算子序列。最后,遍历算子序列,为每个算子分配相应的输入输出内存,然后将算子加载到设备上执行计算。 +如 :numref:`compiler-backend-architecture`所示,编译器后端处于前端和硬件驱动层中间,主要负责计算图优化、算子选择和内存分配的任务。首先,需要根据硬件设备的特性将IR图进行等价图变换,以便在硬件上能够找到对应的执行算子,该过程是计算图优化的重要步骤之一。前端IR生成是解析用户代码,属于一个较高的抽象层次,隐藏一些底层运行的细节信息,此时无法直接对应硬件上的算子(算子是设备上的基本计算序列,例如MatMul、Convolution和ReLU等),需要将细节信息进行展开后,才能映射到目标硬件上的算子。对于某些前端IR的子集来说,一个算子便能够执行对应的功能,此时可以将这些IR节点进行合并成为一个计算节点,该过程称之为算子融合;对于一些复杂计算,后端并没有直接与之对应的算子,但是可以通过几个基本运算的算子组合达到同样的计算效果,此时可以将前端IR节点拆分成多个小算子。然后,我们需要进行算子选择。算子选择是在得到优化的IR图后,需要选取最合适的目标设备算子。针对用户代码所产生的IR往往可以映射成多种不同的硬件算子,但是生成不同的算子执行效率往往有很大的差别,如何根据前端IR选择出最高效的算子,是算子选择的核心问题。算子选择本质上是一个模式匹配问题。其最简单的方法就是每一个IR节点对应一个目标硬件的算子,但是这种方法往往对目标硬件的资源利用比较差。目前来说对于现有的编译器一般都对每一个IR节点提供了多个候选的算子,算子选择目标就是从中选择最优的一个算子作为最终执行在设备上的算子。总的来说,在机器学习系统中,对前端生成的IR图上的各个节点进行拆分和融合,让前端所表示的高层次IR逐步转换为可以在硬件设备上执行的低层次IR。得到了这种更加贴合硬件的IR后,对于每个单节点的IR可能仍然有很多种不同的选择,例如可以选择不同的输入输出格式和数据类型,我们需要对IR图上每个节点选择出最为合适的算子,算子选择过程可以认为是针对IR图的细粒度优化过程,最终生成完整的算子序列。最后,遍历算子序列,为每个算子分配相应的输入输出内存,然后将算子加载到设备上执行计算。 ![编译器后端总体架构简图](../img/ch05/compiler-backend-architecture.jpeg) :width:`800px` @@ -18,4 +18,4 @@ ### 内存分配 -经过计算图优化和算子选择之后,我们可以得到IR图中每个算子的输入输出的形状(Shape)、数据类型、存储格式。根据这些信息,计算输入输出数据的大小,并为输入输出分配设备上的内存,然后将算子加载到设备上才能真正执行计算。此外,为了更充分地例用设备内存资源,可以对内存进行复用,提高内存利用率。 \ No newline at end of file +经过计算图优化和算子选择之后,我们可以得到IR图中每个算子的输入输出的形状(Shape)、数据类型、存储格式。根据这些信息,计算输入输出数据的大小,并为输入输出分配设备上的内存,然后将算子加载到设备上才能真正执行计算。此外,为了更充分地例用设备内存资源,可以对内存进行复用,提高内存利用率。