fix chapter 6 (#216)

* 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>
This commit is contained in:
ADNRs
2022-03-27 09:21:54 +08:00
committed by GitHub
parent 3f359047aa
commit 83e0f9896d
5 changed files with 64 additions and 59 deletions

View File

@@ -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_1Assign_2Sub_1的顺序串行执行与代码原本的语义保持一致。
图中虚线箭头表达了算子之间的依赖关系添加依赖关系后算子会按照Assign_1Assign_2Sub_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算子复杂控制流等场景下会面临较大的技术挑战。

View File

@@ -9,12 +9,12 @@
以优化内存IO为例。深度学习算子按其对资源的需求可以分为两类
计算密集型算子,这些算子的时间绝大部分花在计算上,如卷积、全连接等;
访存密集型算子这些算子的时间绝大部分花在访存上他们大部分是Element-Wise算子例如
ReLUElement-Wise Sum等。
ReLUElement-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
broadcastreducetransform等并在这基础上归纳出不同的通用计算规则
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`
:label:`transdata`

View File

@@ -6,20 +6,20 @@
经历了后端的图优化后IR图中的每一个节点都有一组算子与之对应。此时的IR图中的每一个节点可以认为是用户可见的最小硬件执行单元。但是此时IR图中的一个节点代表了用户代码的一个操作对于这个操作还没有具体生成有关设备信息的细节描述。这些信息是算子选择所选择的内容信息我们称之为算子信息。算子信息主要包括以下内容
1. 针对不同特点的计算平台和不同的算子为了追求最好的性能一般都需要选择不同的数据排布格式。机器学习系统常见的数据排布格式有NCHW,NHWC等。
1. 针对不同特点的计算平台和不同的算子为了追求最好的性能一般都需要选择不同的数据排布格式。机器学习系统常见的数据排布格式有NCHWNHWC等。
2. 对于不同的硬件支持不同的计算精度例如float32float16int32等。算子选择需要在所支持各种数据类型的算子中选择出用户所设定的数据类型最为相符的算子。
2. 对于不同的硬件支持不同的计算精度例如float32float16int32等。算子选择需要在所支持各种数据类型的算子中选择出用户所设定的数据类型最为相符的算子。
**数据排布格式**
机器学习系统中很多运算都会转换成为矩阵的乘法,例如卷积运算。我们知道矩阵乘法$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=8C=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)$$
算子的数据排布格式转换是一个比较耗时的操作,为了避免频繁的格式转换所带来的内存搬运开销,数据应该尽可能地以同样的格式在算子之间传递,算子和算子的衔接要尽可能少的出现数据排布格式不一致的现象。另外,数据类型不同导致的降精度可能会使得误差变大,收敛速度变慢甚至不收敛,所以数据类型的选择也要结合具体算子分析。
总的来说,一个好的算子选择算法应该尽可能的保持数据类型与用户设置的数据类型一致,且尽可能少的出现数据格式转换。
总的来说,一个好的算子选择算法应该尽可能的保持数据类型与用户设置的数据类型一致,且尽可能少的出现数据格式转换。

View File

@@ -6,7 +6,7 @@ In-Place内存分配还可以提高某些算子的执行效率。
### Device内存概念
在深度学习体系结构中我们通常将与硬件加速器如GPUAI芯片等相邻的内存称之为设备(Device)内存而与CPU相邻的内存称之为主机(Host)内存。如 :numref:`host-device-memory`所示CPU可以合法地访问主机上的内存而无法直接访问设备上的内存同理AI芯片可以访问设备上的内存却无法访问主机上的内存。因此在网络训练过程中我们往往需要从磁盘加载数据到主机内存中然后在主机内存中做数据处理再从主机内存拷贝到设备内存中最后设备才能合法地访问数据。算子全部计算完成后用户要获取训练结果又需要把数据从设备内存拷贝到主机内存中。
在深度学习体系结构中我们通常将与硬件加速器如GPUAI芯片等相邻的内存称之为设备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将计算图并行流与数据依赖进行聚合分析得到算子间祖先关系构建张量全局生命周期互斥约束使用多种启发式算法求解最优的内存静态规划实现逼近理论极限的内存复用从而提升支持的内存大小。
SOMASSafe 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'内存的申请。

View File

@@ -2,7 +2,7 @@
编译器前端主要将用户代码进行解析翻译得到计算图IR并对其进行设备信息无关的优化此时我们并不考虑程序执行的底层硬件信息。编译器后端的主要职责对前端下发的IR做进一步的计算图优化让其更加贴合硬件并为IR中的计算节点选择适合在硬件上执行的算子然后为每个算子的输入输出分配硬件内存最终生成一个可以在硬件上执行的任务序列。
:numref:`compiler-backend-architecture`所示编译器后端处于前端和硬件驱动层中间主要负责计算图优化、算子选择和内存分配的任务。首先需要根据硬件设备的特性将IR图进行等价图变换以便在硬件上能够找到对应的执行算子该过程是计算图优化的重要步骤之一。前端IR生成是解析用户代码属于一个较高的抽象层次隐藏一些底层运行的细节信息此时无法直接对应硬件上的算子算子是设备上的基本计算序列例如MatMulConvolutionReLU等需要将细节信息进行展开后才能映射到目标硬件上的算子。对于某些前端IR的子集来说一个算子便能够执行对应的功能此时可以将这些IR节点进行合并成为一个计算节点该过程称之为算子融合对于一些复杂计算后端并没有直接与之对应的算子但是可以通过几个基本运算的算子组合达到同样的计算效果此时可以将前端IR节点拆分成多个小算子。然后我们需要进行算子选择。算子选择是在得到优化的IR图后需要选取最合适的目标设备算子。针对用户代码所产生的IR往往可以映射成多种不同的硬件算子但是生成不同的算子执行效率往往有很大的差别如何根据前端IR选择出最高效的算子是算子选择的核心问题。算子选择本质上是一个模式匹配问题。其最简单的方法就是每一个IR节点对应一个目标硬件的算子但是这种方法往往对目标硬件的资源利用比较差。目前来说对于现有的编译器一般都对每一个IR节点提供了多个候选的算子算子选择目标就是从中选择最优的一个算子作为最终执行在设备上的算子。总的来说在机器学习系统中对前端生成的IR图上的各个节点进行拆分和融合让前端所表示的高层次IR逐步转换为可以在硬件设备上执行的低层次IR。得到了这种更加贴合硬件的IR后对于每个单节点的IR可能仍然有很多种不同的选择例如可以选择不同的输入输出格式和数据类型我们需要对IR图上每个节点选择出最为合适的算子算子选择过程可以认为是针对IR图的细粒度优化过程最终生成完整的算子序列。最后遍历算子序列为每个算子分配相应的输入输出内存然后将算子加载到设备上执行计算。
如 :numref:`compiler-backend-architecture`所示编译器后端处于前端和硬件驱动层中间主要负责计算图优化、算子选择和内存分配的任务。首先需要根据硬件设备的特性将IR图进行等价图变换以便在硬件上能够找到对应的执行算子该过程是计算图优化的重要步骤之一。前端IR生成是解析用户代码属于一个较高的抽象层次隐藏一些底层运行的细节信息此时无法直接对应硬件上的算子算子是设备上的基本计算序列例如MatMulConvolutionReLU等需要将细节信息进行展开后才能映射到目标硬件上的算子。对于某些前端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)、数据类型、存储格式。根据这些信息,计算输入输出数据的大小,并为输入输出分配设备上的内存,然后将算子加载到设备上才能真正执行计算。此外,为了更充分地例用设备内存资源,可以对内存进行复用,提高内存利用率。
经过计算图优化和算子选择之后我们可以得到IR图中每个算子的输入输出的形状Shape、数据类型、存储格式。根据这些信息,计算输入输出数据的大小,并为输入输出分配设备上的内存,然后将算子加载到设备上才能真正执行计算。此外,为了更充分地例用设备内存资源,可以对内存进行复用,提高内存利用率。