This commit is contained in:
quantumiracle
2022-04-26 23:01:46 -04:00
9 changed files with 79 additions and 72 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、数据类型、存储格式。根据这些信息,计算输入输出数据的大小,并为输入输出分配设备上的内存,然后将算子加载到设备上才能真正执行计算。此外,为了更充分地例用设备内存资源,可以对内存进行复用,提高内存利用率。

View File

@@ -5,7 +5,7 @@
:label:`dag`
早期的机器学习框架主要为了支持基于卷积神经网络的图像分类问题。这些神经网络的拓扑结构简单神经网络层往往通过串行构建他们的拓扑结构可以用简单的配置文件来表达例如Caffe中基于Protocol
Buffer格式的模型定义。随着机器学习的进一步发展模型的拓扑日益复杂包括混合专家生成对抗网络多注意力模型。这些模型复杂拓扑(例如说,分结构带有条件的if-else结构)。而复杂的拓扑会影响模型算子的执行自动化计算梯度(一般称为自动微分)训练参数的自动化判断。为此,我们需要一个更加通用的技术来执行任意机器学习模型。因此,计算图应运而生。综合来看,计算图对于一个机器学习框架提供了以下几个关键作用:
Buffer格式的模型定义。随着机器学习的进一步发展模型的拓扑日益复杂包括混合专家生成对抗网络多注意力模型。这些模型复杂拓扑结构(例如说,分结构带有条件的if-else循环)会影响模型算子的执行自动化梯度计算(一般称为自动微分)以及训练参数的自动化判断。为此,我们需要一个更加通用的技术来执行任意机器学习模型。因此,计算图应运而生。综合来看,计算图对于一个机器学习框架提供了以下几个关键作用:
- **对于输入数据,算子和算子执行顺序的统一表达。**
机器学习框架用户可以用多种高层次编程语言PythonJulia和C++来编写训练程序。这些高层次程序需要统一的表达成框架底层C和C++算子的执行。因此,计算图的第一个核心作用是可以作为一个统一的数据结构来表达用户用不同语言编写的训练程序。这个数据结构可以准确表述用户的输入数据,模型所带有的多个算子,以及算子之间的执行顺序。

View File

@@ -157,7 +157,7 @@ def ADDiv(x, dx, y, dy, z, dz):
```
基本表达式法的优缺点显而易见,优点是实现简单直接,可为任意语言快速实现微分的库函数;而缺点是增加了用户的工作量,用户必须先手工分解程序为一些基本表达式,才能使用这些库函数进行编程,无法方便地使用语言原生的表达式。
2操作符重载法Operator Overlading,
2操作符重载法Operator Overloading,
OO依赖于现代编程语言的多态特性使用操作符重载对编程语言中的基本操作语义进行重定义封装其微分规则。每个基本操作类型及其输入关系在程序运行时会被记录在一个所谓的"tape"的数据结构里面,最后,这些"tape"会形成一个跟踪轨迹(trace)我们就可以使用链式法则沿着轨迹正向或者反向地将基本操作组成起来进行微分。以自动微分库AutoDiff为例对编程语言的基本运算操作符进行了重载
```C++
namespace AutoDiff
@@ -179,7 +179,7 @@ namespace AutoDiff
return TermBuilder.Product(numerator, TermBuilder.Power(denominator, -1));
}
}
// Tape 数据结构中的基本元素,主要包含:
// 1) 操作的运算结果
// 2) 操作的运算结果对应的导数结果
@@ -190,7 +190,7 @@ namespace AutoDiff
public double Value;
public double Adjoint;
public InputEdges Inputs;
public abstract void Eval();
public abstract void Diff();
}
@@ -229,10 +229,10 @@ F(v): {
MindSpore解析器模块首先根据Python的AST生成MindIR再经过特化模块使得中间表示中的算子可识别然后调用自动微分模块。自动微分模块的入口函数如下所示
```C++
function Grad {
Init();
Init();
MapObject(); // 实现Parameter/Primitive/FuncGraph/FreeVariable对象的映射
MapMorphism(); // 实现CNode的映射
Finish();
Finish();
Return GetKGraph(); // 获取梯度函数计算图
}
```
@@ -242,7 +242,7 @@ Grad函数先通过MapObject实现图上自由变量、Parameter和ValueNodeP
def get_bprop_relu(self):
"""Grad definition for `ReLU` operation."""
input_grad = G.ReluGrad()
def bprop(x, out, dout):
dx = input_grad(dout, out)
return (dx,)

View File

@@ -45,7 +45,7 @@
:label:`pooling`
有了卷积、池化、全连接组件就可以构建一个非常简单的卷积神经网络了, :numref:`nn_network`展示了一个卷积神经网络的模型结构。
给定输入$3 \times 64 \times 64$的彩色图片使用16个$3 \times 3$大小的卷积核做卷积,得到大小为$16 \times 64 \times 64$
给定输入$3 \times 64 \times 64$的彩色图片使用16个$3 \times 3 \times 3$大小的卷积核做卷积,得到大小为$16 \times 64 \times 64$的特征图
再进行池化操作降维,得到大小为$16 \times 32 \times 32$的特征图;
对特征图再卷积得到大小为$32 \times 32 \times 32$特征图,再进行池化操作得到$32 \times 16 \times 16$大小的特征图;
我们需要对特征图做全连接此时需要把特征图平铺成一维向量这步操作称为Flatten压平后输入特征大小为$32\times 16 \times 16 = 8192$
@@ -80,7 +80,7 @@ output = convolution(input, conv1_filters, stride=1, padding='same')
output = pooling(output, kernel_size=3, stride=2, padding='same', mode='max')
output = convolution(output, conv2_filters, stride=1, padding='same')
output = pooling(output, kernel_size=3, stride=2, padding='same', mode='max')
output=flatten(output)
output = flatten(output)
output = fully_connected(output, fc1_weights)
output = fully_connected(output, fc2_weights)
output = fully_connected(output, fc3_weights)
@@ -107,7 +107,7 @@ PyTorch提供的torch.nn.Module、torch.nn.Conv2d、torch.utils.data.Dataset。
Cell和Module是模型抽象方法也是所有网络的基类。
现有模型抽象方案有两种。
一种是抽象出两个方法分别为Layer负责单个神经网络层的参数构建和前向计算Model负责对神经网络层进行连接组合和神经网络层参数管理
另一种是将Layer和Modle抽象成一个方法该方法既能表示单层神经网络层也能表示包含多个神经网络层堆叠的模型Cell和Module就是这样实现的。
另一种是将Layer和Model抽象成一个方法该方法既能表示单层神经网络层也能表示包含多个神经网络层堆叠的模型Cell和Module就是这样实现的。
![神经网络模型构建细节](../img/ch02/model_build.svg)
:width:`800px`
@@ -127,7 +127,7 @@ Cell和Module是模型抽象方法也是所有网络的基类。
```python
# 接口定义:
全连接层接口convolution(input, filters, stride, padding)
卷积层的接口convolution(input, filters, stride, padding)
变量Variable(value, trainable=True)
高斯分布初始化方法random_normal(shape)
神经网络模型抽象方法Cell
@@ -189,5 +189,5 @@ class CNN(Cell):
return z
net = CNN()
```
上述卷积模型进行实例化,其执行将从\_\_init\_\_开始第一个是Conv2DConv2D也是Cell的子类会进入到Conv2D的\_\_init\_\_此时会将第一个Conv2D的卷积参数收集到self.\_params之后回到Conv2D将第一个Conv2D收集到self.\_cells第二个的组件是MaxPool2D因为其没有训练参数因此将MaxPool2D收集到self.\_cells依次类推分别收集第二个卷积参数和卷积层三个全连接层的参数和全连接层。实例化之后可以调用net.parameters_and_names来返回训练参数调用net.cells_and_names查看神经网络层列表。

View File

@@ -3,7 +3,9 @@
前面介绍了强化学习的基本知识和在系统层面的一般需求,这里我们介绍常见的单智能体强化学习系统中较为简单的一类,即单节点强化学习系统。这里,我们按照是否对模型训练和更新进行并行处理,将强化学习系统分为单节点和分布式强化学习系统。其中,单节点强化学习系统可以理解为只实例化一个类对象作为智能体,与环境交互进行采样和利用所采得的样本进行更新的过程分别视为这个类内的不同函数。除此之外的更为复杂的强化学习框架都可视为分布式强化学习系统。分布式强化学习系统的具体形式有很多,这也往往依赖于所实现的算法。从最简单的情况考虑,假设我们仍在同一个计算单元上实现算法,但是将强化学习的采样过程和更新过程实现为两个并行的进程,甚至各自实现为多个进程,以满足不同计算资源间的平衡。这时就需要进程间通信来协调采样和更新过程,这是一个最基础的分布式强化学习框架。更为复杂的情况是,整个算法的运行在多个计算设备上进行(如一个多机的计算集群),智能体的函数可能需要跨机跨进程间的通信来实现。对于多智能体系统,还需要同时对多个智能体的模型进行更新,则需要更为复杂的计算系统设计。我们将逐步介绍这些不同的系统内的实现机制。
我们先对单节点强化学习系统进行介绍。
在这里我们以RLzoo :cite:`ding2020efficient`为例,讲解一个单节点强化学习系统构建所需要的基本模块。如 :numref:`ch12/ch12-rlzoo`所示是RLzoo中采用的一个典型的单节点强化学习系统它包括几个基本的组成部分神经网络、适配器、策略网络和价值网络、环境实例、模型学习器、经验回放缓存Experience Replay Buffer等。我们先对前三个神经网络、适配器、策略网络和价值网络进行介绍。神经网络即一般深度学习中的神经网络用于实现基于数据的函数拟合我们在图中简单列出常见的三类神经网络全连接网络卷积网络和循环网络。策略网络和价值网络是一般深度强化学习的常见组成部分策略网络即一个由深度神经网络参数化的策略表示而价值网络为神经网络表示的状态价值State-Value或状态-动作价值State-Action Value函数。这里我们不妨称前三类神经网络为一般神经网络策略网络和价值网络为强化学习特定网络前者往往是后者的重要组成部分。在RLzoo中适配器则是为实现强化学习特定网络而选配一般神经网络的功能模块。首先根据不同的观察量类型强化学习智能体所用的神经网络头部会有不同的结构这一选择可以由一个基于观察量的适配器来实现其次根据所采用的强化学习算法类型相应的策略网络尾部需要有不同的输出类型包括确定性策略和随机性策略RLzoo中使用一个策略适配器来进行选择最后根据不同的动作输出如离散型、连续型、类别型等需要使用一个动作适配器来选择。 :numref:`ch12/ch12-rlzoo`中我们统称这三个不类型的适配器为适配器。介绍完这些我们已经有了可用的策略网络和价值网络这构成了强化学习智能体核心学习模块。除此之外还需要一个学习器Learner来更新这些学习模块更新的规则就是强化学习算法给出的损失函数。而要想实现学习模块的更新最重要的是输入的学习数据即智能体跟环境交互过程中所采集的样本。对于**离线**Off-Policy强化学习这些样本通常被存储于一个称为经验回放缓存的地方学习器在需要更新模型时从该缓存中采得一些样本来进行更新。这里说到的离线强化学习是强化学习算法中的一类强化学习算法可以分为**在线**On-Policy强化学习和离线强化学习两类按照某个特定判据。这个判据是用于更新的模型和用于采样的模型是否为同一个如果是则称在线强化学习算法否则为离线强化学习算法。因而离线强化学习通常允许与环境交互的策略采集的样本被存储于一个较大的缓存内从而允许在许久之后再从这个缓存中抽取样本对模型进行更新。而对于在线强化学习这个“缓存”有时其实也是存在的只不过它所存储的是非常近期内采集的数据从而被更新模型和用于采样的模型可以近似认为是同一个。从而这里我们简单表示RLzoo的强化学习系统统一包括这个经验回放缓存模块。有了以上策略和价值网络、经验回放缓存、适配器、学习器我们就得到了RLzoo中一个单节点的强化学习智能体将这个智能体与环境实例交互并采集数据进行模型更新我们就得到了一个完整的单节点强化学习系统。这里的环境实例化我们允许多个环境并行采样。
在这里我们以RLzoo :cite:`ding2020efficient`为例,讲解一个单节点强化学习系统构建所需要的基本模块。如 :numref:`ch12/ch12-rlzoo`所示是RLzoo中采用的一个典型的单节点强化学习系统它包括几个基本的组成部分神经网络、适配器、策略网络和价值网络、环境实例、模型学习器、经验回放缓存Experience Replay Buffer等。
我们先对前三个神经网络、适配器、策略网络和价值网络进行介绍。神经网络即一般深度学习中使用的神经网络用于实现基于数据的函数拟合特点是可以用梯度下降的方法更新。我们在图中简单列出常见的三类神经网络全连接网络卷积网络和循环网络。策略网络和价值网络是一般深度强化学习的常见组成部分分别是对策略函数和价值函数的近似表示。策略网络即一个由参数化深度神经网络表示的动作策略而价值网络为神经网络表示的状态价值State-Value或状态-动作价值State-Action Value函数。这里我们不妨称全连接网络卷积网络和循环网络为一般神经网络它们常作为基本构建模块而被用来搭建强化学习中的策略网络和价值网络。在RLzoo中适配器则是为实现强化学习特定函数近似而选配一般神经网络的功能模块每个适配器是一个根据网络输入输出格式决定的网络格式选择函数。如:numref:`ch12/ch12-rlzoo`所示RLzoo在实现中使用了三个不类型的适配器来使得强化学习算法构建过程有自适应能力。首先根据不同的观察量类型强化学习智能体所用的神经网络头部会有不同的结构这一选择可以由一个基于观察量的适配器来实现其次根据所采用的强化学习算法类型相应的策略网络尾部需要有不同的输出类型包括确定性策略和随机性策略RLzoo中使用一个策略适配器来进行选择最后根据不同的动作输出如离散型、连续型、类别型等需要使用一个动作适配器来选择。介绍完这些我们已经有了可用的策略网络和价值网络这构成了强化学习智能体核心学习模块。除此之外还需要一个学习器Learner来更新这些学习模块更新的规则就是强化学习算法给出的损失函数。而要想实现学习模块的更新最重要的是输入的学习数据即智能体跟环境交互过程中所采集的样本。对于**离线**Off-Policy强化学习这些样本通常被存储于一个称为经验回放缓存的地方学习器在需要更新模型时从该缓存中采得一些样本来进行更新。这里说到的离线强化学习是强化学习算法中的一类强化学习算法可以分为**在线**On-Policy强化学习和离线强化学习两类按照某个特定判据。这个判据是用于更新的模型和用于采样的模型是否为同一个如果是则称在线强化学习算法否则为离线强化学习算法。因而离线强化学习通常允许与环境交互的策略采集的样本被存储于一个较大的缓存内从而允许在许久之后再从这个缓存中抽取样本对模型进行更新。而对于在线强化学习这个“缓存”有时其实也是存在的只不过它所存储的是非常近期内采集的数据从而被更新模型和用于采样的模型可以近似认为是同一个。从而这里我们简单表示RLzoo的强化学习系统统一包括这个经验回放缓存模块。有了以上策略和价值网络、经验回放缓存、适配器、学习器我们就得到了RLzoo中一个单节点的强化学习智能体将这个智能体与环境实例交互并采集数据进行模型更新我们就得到了一个完整的单节点强化学习系统。这里的环境实例化我们允许多个环境并行采样。
![RLzoo算法库中使用的强化学习系统](../img/ch12/ch12-rlzoo.png)