Merge branch 'main' into robotics

This commit is contained in:
Cheng Lai
2023-03-27 10:46:14 +08:00
committed by GitHub
20 changed files with 304 additions and 102 deletions

View File

@@ -4,7 +4,7 @@
### 单算子调度
单算子调度是相对于计算图而言算法或者模型中包含的算子通过Python语言的运行时被逐个调度执行。例如PyTorch的默认执行方式TensorFlow的eager模式以及MindSpore的PyNative模式。以如下MindSpore示例代码所示
单算子调度是相对于计算图而言算法或者模型中包含的算子通过Python语言的运行时被逐个调度执行。例如PyTorch的默认执行方式TensorFlow的eager模式以及MindSpore的PyNative模式。以MindSpore为例,如代码所示
```python
import mindspore.nn as nn
@@ -29,7 +29,7 @@ print(c)
上述脚本将所有的计算逻辑定义在Computation类的construct方法中由于在脚本开头的context中预先设置了单算子执行模式construct中的计算将被Python的运行时逐行调用执行同时可以在代码中的任意位置添加print命令以便打印中间的计算结果。
单算子执行的调用链路如 :numref:`single_op_exec`所示算子在Python侧被触发执行后会经过AI框架初始化,其中需要确定包括算子的精度,输入与输出的类型和大小以及对应的硬件设备等信息,接着框架会为该算子分配计算所需的内存,最后交给具体的硬件计算设备完成计算的执行。
单算子执行的调用链路如 :numref:`single_op_exec`所示算子在Python侧被触发执行后会经过机器学习框架初始化,其中需要确定包括算子的精度,输入与输出的类型和大小以及对应的硬件设备等信息,接着框架会为该算子分配计算所需的内存,最后交给具体的硬件计算设备完成计算的执行。
![单算子执行](../img/ch05/single_op_exec.PNG)
:width:`800px`
@@ -65,7 +65,7 @@ print(c)
异构计算图能够被正确表达的首要条件是准确标识算子执行所在的设备,例如异构计算图 :numref:`computation_graph`中所标识的CPU、GPU和Ascend
Kernel以及被标记为被Python语言运行时执行的Python
Kernel。主流框架均提供了指定算子所在运行设备的能力以MindSpore为例一段简单的异构计算代码如下所示
Kernel。主流框架均提供了指定算子所在运行设备的能力以MindSpore为例一段简单的异构计算代码如下所示
```python
import numpy as np
@@ -92,13 +92,12 @@ z = Tensor(np.ones([2, 2]).astype(np.float32))
output = compute(x, y, z)
```
上述代码片段完成了x + y -
z的计算逻辑其中Add算子被设置为在CPU上执行Sub算子被设置为在GPU上执行从而形成了CPU与GPU协同的异构计算通过类似的标签机制可以实现任意复杂的多硬件协同的异构计算表达
另外一类较为特殊的异构是Python算子Python语言的优势在于表达的灵活性和开发效率以及丰富的周边生态因此将Python算子引入到计算图中和其它异构硬件的算子协同计算对计算的灵活性会产生非常大的帮助。与CPU、GPU分别执行在不同设备上的异构不同Python算子和C++实现的CPU算子都是通过主机侧的CPU核执行差异在于Python算子是通过统一的计算图进行描述因此也需要在计算图的执行引擎中被触发执行。为了在计算图中能够表达Python算子框架需要提供相应的支持。
上述代码片段完成了x + y - z的计算逻辑其中Add算子被设置为在CPU上执行Sub算子被设置为在GPU上执行从而形成了CPU与GPU协同的异构计算通过类似的标签机制可以实现任意复杂的多硬件协同的异构计算表达。
另外一类较为特殊的异构是Python算子Python语言的优势在于表达的灵活性和开发效率以及丰富的周边生态因此将Python算子引入到计算图中和其他异构硬件的算子协同计算对计算的灵活性会产生非常大的帮助。与CPU、GPU分别执行在不同设备上的异构不同Python算子和C++实现的CPU算子都是通过主机侧的CPU核执行差异在于Python算子是通过统一的计算图进行描述因此也需要在后端运行时中触发执行。为了在计算图中能够表达Python算子框架需要提供相应的支持
完成计算图中算子对应设备的标记以后计算图已经准备好被调度与执行根据硬件能力的差异可以将异构计算图的执行分为三种模式分别是逐算子交互式执行整图下沉执行与子图下沉执行。交互式执行主要针对CPU和GPU的场景计算图中的算子按照输入和输出的依赖关系被逐个调度与执行而整图下沉执行模式主要是针对NPU芯片而言这类芯片主要的优势是能够将整个神经网络的计算图一次性下发到设备上无需借助主机的CPU能力而独立完成计算图中所有算子的调度与执行减少了主机和芯片的交互次数借助NPU的Tensor加速能力提高了计算效率和性能子图下沉执行模式是前面两种执行模式的结合由于计算图自身表达的灵活性对于复杂场景的计算图在NPU芯片上进行整图下沉执行的效率不一定能达到最优因此可以将对于NPU芯片执行效率低下的部分分离出来交给CPU或者GPU等执行效率更高的设备处理而将部分更适合NPU计算的子图下沉到NPU进行计算这样可以兼顾性能和灵活性两方面。
完成计算图中算子对应设备的标记以后计算图已经准备好被调度与执行根据硬件能力的差异可以将异构计算图的执行分为三种模式分别是逐算子交互式执行整图下沉执行与子图下沉执行。交互式执行主要针对CPU和GPU的场景计算图中的算子按照输入和输出的依赖关系被逐个调度与执行而整图下沉执行模式主要是针对NPU芯片而言这类芯片主要的优势是能够将整个神经网络的计算图一次性下发到设备上无需借助主机的CPU能力而独立完成计算图中所有算子的调度与执行减少了主机和芯片的交互次数借助NPU的张量加速能力提高了计算效率和性能子图下沉执行模式是前面两种执行模式的结合由于计算图自身表达的灵活性对于复杂场景的计算图在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执行完成后再被触发执行。
虽然在计算图上可以充分表达算子间的并发关系,在实际代码中会产生由于并发而引起的一些不预期的副作用场景,例如如下代码所示:
@@ -132,13 +131,13 @@ compute(y, z)
x = x - y
```
这段简单的计算逻辑翻译到计算图上可以表示为
这段简单的计算逻辑翻译到计算图上可以表示为 :numref:`side_effect_1`所示。
![并发算子执行](../img/ch05/side_effect_1.png)
:width:`800px`
:label:`side_effect_1`
代码中所示三行计算之间并没有依赖关系,因此这三个算子在计算图的逻辑上可以被并发执行,并发关系如 :numref:`side_effect_1`所示,然而根据代码的语义,显而易见是需要确保程序能够被顺序执行,这里引入的问题被称为副作用,副作用是指函数修改了在函数外部定义的状态变量的行为。由于副作用的引入而导致了错误并发关系的发生,一种解决方案是在计算图编译阶段通过添加算子间的依赖,将并发执行逻辑转换为顺序执行逻辑,转换后的计算图如 :numref:`side_effect_2`所示
代码中所示三行计算之间并没有依赖关系,因此这三个算子在计算图的逻辑上可以被并发执行,然而根据代码的语义,显而易见是需要确保程序能够被顺序执行,这里引入的问题被称为副作用,副作用是指修改了在函数外部定义的状态变量的行为。由于副作用的引入而导致了错误并发关系的发生,一种解决方案是在计算图编译阶段通过添加算子间的依赖,将并发执行逻辑转换为顺序执行逻辑,转换后的计算图如 :numref:`side_effect_2`所示
![消除副作用](../img/ch05/side_effect_2.png)
:width:`800px`

View File

@@ -4,25 +4,20 @@
### 通用硬件优化
通用硬件优化主要指与特定硬件类型无关的计算图优化,优化的核心是子图的等价变换:在计算图中尝试匹配特定的子图结构,找到目标子图结构后,通过等价替换方式,将其替换成对硬件更友好的子图结构。
通用硬件优化主要指与特定硬件类型无关的计算图优化,优化的核心是子图的等价变换:在计算图中尝试匹配特定的子图结构,找到目标子图结构后,通过等价替换方式,将其替换成对硬件更友好的子图结构。
以优化内存IO为例。深度学习算子按其对资源的需求可以分为两类
计算密集型算子,这些算子的时间绝大部分花在计算上,如卷积、全连接等;
访存密集型算子这些算子的时间绝大部分花在访存上他们大部分是Element-Wise算子例如
ReLU、Element-Wise Sum等
在典型的深度学习模型中,一般计算密集型和访存密集型算子是相伴出现的,最简单的例子是"Conv +
ReLU"。Conv卷积算子是计算密集型ReLU算子是访存密集型算子ReLU算子可以直接取Conv算子的计算结果进行计算因此我们可以将二者融合成一个算子来进行计算从而减少内存访问延时和带宽压力提高执行效率。
访存密集型算子这些算子的时间绝大部分花在访存上他们大部分是Element-Wise算子例如 ReLU、Element-Wise Sum等。
在典型的深度学习模型中一般计算密集型和访存密集型算子是相伴出现的最简单的例子是“Conv + ReLU”。Conv卷积算子是计算密集型ReLU算子是访存密集型算子ReLU算子可以直接取Conv算子的计算结果进行计算因此可以将二者融合成一个算子来进行计算从而减少内存访问延时和带宽压力提高执行效率
例如:"Conv + Conv + Sum +
ReLU"的融合,从 :numref:`conv_sum_relu`中我们可以看到融合后的算子减少了两个内存的读和写的操作优化了Conv的输出和Sum的输出的读和写的操作。
例如:Conv + Conv + Sum + ReLU”的融合从图\ref{fig:ch07/ch07-compiler-backend-03}中可以看到融合后的算子减少了两个内存的读和写的操作优化了Conv的输出和Sum的输出的读和写的操作。
![Elementwise算子融合](../img/ch05/conv_sum_relu.png)
:width:`800px`
:label:`conv_sum_relu`
除了上述针对特定算子类型结构的融合优化外,基于自动算子生成技术,还可以实现更灵活、更极致的通用优化。以
MindSpore
的图算融合技术为例,图算融合通过"算子拆解、算子聚合、算子重建"三个主要阶段(如图)让计算图中的计算更密集,并进一步减少低效的内存访问。
除了上述针对特定算子类型结构的融合优化外,基于自动算子生成技术,还可以实现更灵活、更极致的通用优化。以 MindSpore 的图算融合技术为例,图算融合通过“算子拆解、算子聚合、算子重建”三个主要阶段让计算图中的计算更密集,并进一步减少低效的内存访问。
![图算融合](../img/ch05/graph_kernel.png)
:width:`800px`

View File

@@ -1,9 +1,8 @@
# 编译器后端和运行时
在上一章节,我们详细讲述了一个编译器前端的主要功能,重点介绍了中间表示以及自动微分。在得到中间表示后,如何充分利用硬件资源高效地执行,是编译器后端和运行时要解决的问题。
在上一章节,详细讲述了一个AI编译器前端的主要功能,重点介绍了中间表示以及自动微分。在得到中间表示后,如何充分利用硬件资源高效地执行,是编译器后端和运行时要解决的问题。
在本章节中,
我们将会介绍编译器后端的一些基本概念,详细描述后端的计算图优化、算子选择等流程。通过对编译器前端提供的中间表示进行优化,充分发挥硬件能力,从而提高程序的执行效率。在此基础上,介绍运行时是如何对计算任务进行内存分配以及高效的调度执行。
在本章节中, 将会介绍AI编译器后端的一些基本概念详细描述后端的计算图优化、算子选择等流程。通过对编译器前端提供的中间表示进行优化充分发挥硬件能力从而提高程序的执行效率。在此基础上介绍运行时是如何对计算任务进行内存分配以及高效地调度执行。
本章的学习目标包括:
@@ -17,6 +16,8 @@
- 掌握计算图调度和执行的常用方法
- 了解目前算子编译器的基本特点以及其尚未收敛的几个问题
```toc
:maxdepth: 2
@@ -25,5 +26,6 @@ graph_optimizer
kernel_selecter
memory_allocator
compute_schedule_and_execute
op_compiler
summary
```

View File

@@ -1,10 +1,10 @@
## 算子选择
过计算图优化后需要对IR图上的每个节点进行算子选择才能生成真正在设备上执行的算子序列。由于IR图上的节点可能有后端的很多算子与其对应不同规格的算子在不同的情况下执行效率各不相同在算子选择阶段的主要任务就是如何根据IR图中的信息在众多算子中选择出最合适的一个算子去目标设备上执行。
过计算图优化后需要对IR图上的每个节点进行算子选择才能生成真正在设备上执行的算子序列。由于IR图上的节点可能有后端的很多算子与其对应不同规格的算子在不同的情况下执行效率各不相同在算子选择阶段的主要任务就是如何根据IR图中的信息在众多算子中选择出最合适的一个算子去目标设备上执行。
### 算子选择的基础概念
经历了后端的图优化后IR图中的每一个节点都有一组算子与之对应。此时的IR图中的每一个节点可以认为是用户可见的最小硬件执行单元。但是此时IR图中的一个节点代表了用户代码的一个操作,对于这个操作还没有具体生成有关设备信息的细节描述。这些信息是算子选择所选择的内容信息,我们称之为算子信息。算子信息主要包括以下内容:
经历了后端的图优化后IR图中的每一个节点都有一组算子与之对应。此时的IR图中的每一个节点可以认为是用户可见的最小硬件执行单元代表了用户代码的一个操作,对于这个操作还没有具体生成有关设备信息的细节描述。这些信息是算子选择所选择的内容信息,称之为算子信息。算子信息主要包括以下内容:
1. 针对不同特点的计算平台和不同的算子为了追求最好的性能一般都需要选择不同的数据排布格式。机器学习系统常见的数据排布格式有NCHW和NHWC等。
@@ -19,17 +19,17 @@
:width:`800px`
:label:`matmuldatalayout`
在机器学习系统中我们常见的数据格式一般有两种分别为NCHW类型和NHWC类型。其中N代表了数据输入的BatchSize大小C代表了图像的通道H和W分别代表图像输入的和宽。 :numref:`data_format`展示了BatchSize为2通道数16和长度为5\*4大小的数据逻辑示意图。
在机器学习系统中常见的数据格式一般有两种分别为NCHW类型和NHWC类型。其中N代表了数据输入的大小C代表了图像的通道H和W分别代表图像输入的和宽。:numref:`data_format`展示了BatchSize为2通道数16和大小为5\*4的数据逻辑示意图。
![常见数据格式](../img/ch05/data_format.png)
:width:`800px`
:label:`data_format`
但是计算机的存储并不能够直接将这样的矩阵放到内存中需要将其展平成1维后存储这样就涉及我们逻辑上的索引如何映射成为内存中的索引,即我们如何根据逻辑数据索引来映射到内存中的1维数据索引。
但是计算机的存储并不能够直接将这样的矩阵放到内存中需要将其展平成1维后存储这样就涉及逻辑上的索引如何映射成为内存中的索引即如何根据逻辑数据索引来映射到内存中的1维数据索引。
对于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`
@@ -49,38 +49,35 @@ $$offsetnhwc(n,h,w,c) = n*HWC + h*WC + w*C +c$$
**数据精度**
通常深度学习的系统,一般使用的是单精度floatSingle
Precision浮点表示。这种数据类型占用32位内存。还有一种精度较低的数据类型float16其内部占用了16位的内存。由于很多硬件会对float16数据类型进行优化float16半精度的计算吞吐量可以是float32的$2\sim 8$倍且float16可以占用的数据更小这样可以输入更大的BatchSize进而减少总体训练时间。接下来我们详细看一下半精度浮点数与单精度浮点数的区别。
通常深度学习的系统,使用的是单精度(float32)表示。这种数据类型占用32位内存。还有一种精度较低的数据类型为半精度(float16)其内部占用了16位的内存。由于很多硬件会对半精度数据类型进行优化半精度的计算吞吐量可以是单精度的$2\sim 8$倍,且半精度占用的内存更小,这样可以输入更大的批大小(BatchSize),进而减少总体训练时间。接下来详细看一下半精度浮点数与精度浮点数的区别。
![浮点数的二进制表示](../img/ch05/floatdtype.png)
:width:`800px`
:label:`floatdtype`
如 :numref:`floatdtype`其中sign代表符号位占1位表示了机器数的正负exponent表示指数位Mantissa为尾数位。其中float16类型的数据采用二进制的科学计数法转换为十进制的计算方式如下:
$$(-1)^{sign}\times 2^{exponent-15}\times (\frac{mantissa}{1024}+1)$$
如 :numref:`floatdtype`中Sig代表符号位占1位表示了机器数的正负Exponent表示指数位Mantissa为尾数位。其中float16类型的数据采用二进制的科学计数法转换为十进制的计算方式如式$$(-1)^{sign}\times 2^{exponent-15}\times (\frac{mantissa}{1024}+1)$$所示。
其中如果指数位全为0时且尾数位全为0时表示数字0。
如果指数位全为0尾数位不全为0则表示一个非常小的数值。
当指数全为1尾数位全为0表示根据符号位正无穷大或者负无穷大。
若指数全为1但是尾数位不为0则表示NAN。
其中bfloat16并不属于一个通用的数据类型google提出的一种特殊的类型现在一般只在一些TPU上训练使用其指数位数与float32位数保持一致可以较快的与float32进行数据转换。由于并不是一种通用类型IEEE中也并没有提出该类型的标准。
其中bfloat16并不属于一个通用的数据类型Google提出的一种特殊的类型现在一般只在一些TPU上训练使用其指数位数与float32位数保持一致可以较快的与float32进行数据转换。由于bfloat16并不是一种通用类型IEEE中也并没有提出该类型的标准。
**算子信息库**
前面我们讲述了数据格式和数据精度的概念,基于这两个概念,在不同硬件下会有不同的算子支持,此时需要有一个硬件上支持的所有算子的集合,该集合我们称之为算子信息库。算子选择过程就是从算子信息库中选择最合适算子的过程。
前面讲述了数据格式和数据精度的概念,基于这两个概念,在不同硬件下会有不同的算子支持,一个硬件上支持的所有算子的集合定义为该硬件的算子信息库。算子选择过程就是从算子信息库中选择最合适算子的过程。
### 算子选择的过程
前文介绍了算子选择主要是针对IR图中的每一个操作节点选择出最为合适的算子。其中算子信息主要包括了支持设备类型、数据类型和数据排布格式三个方面。经过编译器前端类型推导与静态分析的阶段后IR图中已经推导出了用户代码侧的数据类型。下面介绍算子选择的基本过程。
首先,选择算子执行的硬件设备。不同的硬件设备上,算子的实现、支持数据类型、执行效率通常会有所差别。这一步往往是用户自己指定的,若用户未指定,则编译器后端会为用户匹配一个默认的设备。
如图 numref:`select_kernel`所示,展示了算子选择过程。首先,选择算子执行的硬件设备。不同的硬件设备上,算子的实现、支持数据类型、执行效率通常会有所差别。这一步往往是用户自己指定的,若用户未指定,则编译器后端会为用户匹配一个默认的设备。
然后后端会根据IR图中推导出的数据类型和内存排布格式选择对应的算子。
![算子选择过程](../img/ch05/select_kernel.png)
:width:`800px`
:label:`select_kernel`
理想情况下算子选择所选择出的算子类型应该与用户预期的类型保持一致。但是由于软硬件的限制很可能算子的数据类型不能满足用户所期待的数据类型此时需要对该节点进行升精度或者降精度处理才能匹配到合适的算子。比如在MindSpore
的Ascend后端由于硬件限制导致Conv2D算子只存在float16一种数据类型。如果用户设置的整网使用的数据类型为float32数据那么只能对Conv2D算子的输入数据进行降精度处理即将输入数据类型从float32转换成float16。
理想情况下算子选择所选择出的算子类型应该与用户预期的类型保持一致。但是由于软硬件的限制很可能算子的数据类型不能满足用户所期待的数据类型此时需要对该节点进行升精度或者降精度处理才能匹配到合适的算子。比如在MindSpore 的Ascend后端由于硬件限制导致Conv2D算子只存在float16一种数据类型。如果用户设置的整网使用的数据类型为float32数据那么只能对Conv2D算子的输入数据进行降精度处理即将输入数据类型从float32转换成float16。
算子的数据排布格式转换是一个比较耗时的操作,为了避免频繁的格式转换所带来的内存搬运开销,数据应该尽可能地以同样的格式在算子之间传递,算子和算子的衔接要尽可能少的出现数据排布格式不一致的现象。另外,数据类型不同导致的降精度可能会使得误差变大,收敛速度变慢甚至不收敛,所以数据类型的选择也要结合具体算子分析。

View File

@@ -1,12 +1,11 @@
## 内存分配
:label:`ch05-sec-memory_pool`
内存在传统计算机存储器层次结构中有着重要的地位它是连接高速缓存和磁盘之间的桥梁有着比高速缓存更大的空间比磁盘更快的访问速度。随着深度学习的发展深度神经网络的模型越来越复杂AI芯片上的内存很可能无法容纳一个大型网络模型。因此对内存进行复用是一个重要的优化手段。此外通过连续内存分配和
In-Place内存分配还可以提高某些算子的执行效率。
内存在传统计算机存储器层次结构中有着重要的地位它是连接高速缓存和磁盘之间的桥梁有着比高速缓存更大的空间比磁盘更快的访问速度。随着深度学习的发展深度神经网络的模型越来越复杂AI芯片\footnote{与前文中的硬件加速器指意相同业内习惯称为AI芯片}上的内存很可能无法容纳一个大型网络模型。因此,对内存进行复用是一个重要的优化手段。此外,通过连续内存分配和 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 +13,17 @@ In-Place内存分配还可以提高某些算子的执行效率。
### 内存分配 {#内存分配-1}
内存分配模块主要负责给图中算子的输入、输出分配Device内存。用户的前端脚本经过编译器前端处理后得到中间表达后端根据中间表达进行算子选择和相关优化可以得到算子最终的输入输出Tensor的形状、数据类型Data
Type、格式Format等信息根据这些信息我们可以计算出算子输入、输出Tensor的尺寸大小。基本的计算方法为
$$size=\left (\prod_{i=0}^{dimension}shape_i\right ) * sizeof\left ( data type \right )$$
得到Tensor的尺寸大小后往往还需要对内存大小进行对齐操作。内存通常以4字节、8字节或16字节为一组进行访问如果被搬运的内存大小不是这些值的倍数内存后面会填充相应数量的空数据以使得内存长度达到这些值的倍数。因此访问非对齐的内存可能会更加耗时。
内存分配模块主要负责给图中算子的输入、输出分配Device内存。用户的前端脚本经过编译器前端处理后得到中间表达后端根据中间表达进行算子选择和相关优化可以得到算子最终的输入输出张量的形状、数据类型Data Type、格式Format等信息根据这些信息可以计算出算子输入、输出张量的尺寸大小。基本的计算方法如式$$size=\left (\prod_{i=0}^{dimension}shape_i\right ) * sizeof\left ( data type \right )$$所示。得到张量的尺寸大小后往往还需要对内存大小进行对齐操作。内存通常以4字节、8字节或16字节为一组进行访问如果被搬运的内存大小不是这些值的倍数内存后面会填充相应数量的空数据以使得内存长度达到这些值的倍数。因此访问非对齐的内存可能会更加耗时。
![内存分配示例](../img/ch05/memory_allocate.png)
:width:`800px`
:label:`memory_allocate`
下面以 :numref:`memory_allocate`为例介绍内存分配的大致流程。首先我们会给Input
Tensor、Conv2D的权重和Conv2D的输出分配内存地址。然后为BatchNorm的输入分配地址时我们发现BatchNorm的输入就是Conv2D算子的输出而该Tensor的地址已经在之前分配过了因此只需要将Conv2D算子的输出地址共享给BatchNorm的输入就可以避免内存的重复申请以及内存的冗余拷贝。以此类推可以发现整个过程中可以将待分配的内存分成三种类型一是整张图的输入Tensor二是算子的权重或者属性三是算子的输出Tensor三种类型在训练过程中的生命周期有所不同。
下面以 :numref:`memory_allocate`为例介绍内存分配的大致流程。首先给输入张量、Conv2D的权重和Conv2D的输出分配内存地址。然后为BatchNorm的输入分配地址时发现BatchNorm的输入就是Conv2D算子的输出而该张量的地址已经在之前分配过了因此只需要将Conv2D算子的输出地址共享给BatchNorm的输入就可以避免内存的重复申请以及内存的冗余拷贝。以此类推可以发现整个过程中可以将待分配的内存分成三种类型一是整张图的输入张量二是算子的权重或者属性三是算子的输出张量三种类型在训练过程中的生命周期有所不同。
在CPU上常常使用malloc函数直接申请内存这种方式申请内存好处是随时申请随时释放简单易用。然而在许多对性能要求严苛的计算场景中由于所申请内存块的大小不定频繁申请释放会降低性能。通常会使用内存池的方式去管理内存先申请一定数量的内存块留作备用当程序有内存申请需求时直接从内存池中的内存块中申请。当程序释放该内存块时内存池会进行回收并用作后续程序内存申请时使用。
在深度学习框架中,设备内存的申请也是非常频繁的,往往也是通过内存池的方式去管理设备内存,并让设备内存的生命周期与张量的生命周期保持一致。不同的深度学习框架在内存池的设计上大同小异,以:numref:`device_malloc`的MindSpore框架内存申请为例进程会从设备上申请足够大的内存然后通过双游标从两端偏移为张量分配内存。首先从申请的首地址开始进行偏移为算子权重的张量分配内存这部分张量生命周期较长往往持续整个训练过程。然后从申请设备地址的末尾开始偏移为算子的输出张量分配内存这部分内存的生命周期较短往往在该算子计算结束并且后续计算过程中无需再次使用该算子的输出的情况下其生命周期就可以结束。通过这种方式只需要从设备上申请一次足够大的内存后续算子的内存分配都是通过指针偏移进行分配减少了直接从设备申请内存的耗时。
在CPU上我们常常使用malloc函数直接申请内存这种方式申请内存好处是随时申请随时释放简单易用。然而在许多对性能要求严苛的计算场景中由于所申请内存块的大小不定频繁申请释放会降低性能。通常我们会使用内存池的方式去管理内存先申请一定数量的内存块留作备用当程序有内存申请需求时直接从内存池中的内存块中申请。当程序释放该内存块时内存池会进行回收并用作后续程序内存申请时使用。
在深度学习框架中Device内存的申请也是非常频繁的往往也是通过内存池的方式去管理Device内存并让Device内存的生命周期与Tensor的生命周期保持一致。不同的深度学习框架在内存池的设计上大同小异我们以 :numref:`device_malloc`的MindSpore框架内存申请为例进程会从Device上申请足够大的内存然后通过双游标从两端偏移为Tensor分配内存。首先从申请的首地址开始进行偏移为算子权重的Tensor分配内存这部分Tensor生命周期较长往往持续整个训练过程。然后从申请Device地址的末尾开始偏移为算子的输出Tensor分配内存这部分内存的生命周期较短往往在该算子计算结束并且后续计算过程中无需再次使用该算子的输出的情况下其生命周期就可以结束。通过这种方式我们只需要从Device上申请一次足够大的内存后续算子的内存分配都是通过指针偏移进行分配减少了直接从设备申请内存的耗时。
![双游标法分配内存](../img/ch05/device_malloc.png)
:width:`800px`
@@ -35,18 +31,17 @@ Tensor、Conv2D的权重和Conv2D的输出分配内存地址。然后为BatchNor
### 内存复用
在机器学习系统中,内存复用是指分析Tensor的生命周期,将生命周期结束的Tensor的Device内存释放回内存池并用于后续Tensor的内存分配。内存复用的目的是提高内存的利用率,让有限的设备内存容纳更大的模型。
以 :numref:`memory_allocate`为例当BatchNorm算子计算结束后output1不再被任何算子使用则该Tensor的Device内存可以被回收,并且如果output1的内存尺寸大于等于output3的内存尺寸则从output1回收的地址可以用于output3的内存分配从而达到复用output1地址的目的。
在机器学习系统中,内存复用是指分析张量的生命周期,将生命周期结束的张量的设备内存释放回内存池并用于后续张量的内存分配。内存复用的目的是提高内存的利用率,让有限的设备内存容纳更大的模型。
以 :numref:`memory_allocate`为例当BatchNorm算子计算结束后输出1不再被任何算子使用则该张量的设备内存可以被回收,并且如果输出1的内存尺寸大于等于输出3的内存尺寸则从输出1回收的地址可以用于输出3的内存分配从而达到复用输出1地址的目的。
![内存生命周期图](../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`所示,图中横坐标表示张量的生命周期,图中纵坐标表示内存大小。在生命周期内,某一个张量将一直占用某块设备内存,直至生命周期结束才会释放相应内存块。通过张量生命周期和内存大小可以构造出矩形块,而内存分配要求解的目标是在内存生命周期图中容纳更多的矩形块,问题的约束是矩形块之间无碰撞。 :numref:`combine_memory_reuse_and_no_reuse`左边是在未使用任何内存复用策略的情况下的内存生命周期图此时内存同时只能容纳T0、T1、T2、T3四个张量
内存复用策略的求解是一个NP完全的问题。许多深度学习框架通常采用贪心的策略去分配内存例如采用BestFit算法每次直接从内存池中选取可以满足条件的最小内存块然而这种贪心的策略往往会陷入局部最优解而无法求得全局最优解。为了更好地逼近内存分配策略全局最优解MindSpore框架提出了一种新的内存分配算法
SOMASSafe Optimized Memory Allocation
Solver。SOMAS将计算图并行流与数据依赖进行聚合分析得到算子间祖先关系构建张量全局生命周期互斥约束使用多种启发式算法求解最优的内存静态规划实现逼近理论极限的内存复用从而提升支持的内存大小。
SOMASSafe Optimized Memory Allocation Solver安全优化的内存分配求解器。SOMAS将计算图并行流与数据依赖进行聚合分析得到算子间祖先关系构建张量全局生命周期互斥约束使用多种启发式算法求解最优的内存静态规划实现逼近理论极限的内存复用从而提升支持的内存大小。
由 :numref:`combine_memory_reuse_and_no_reuse`右边所示经过SOMAS求解之后同样的内存大小可支持的Tensor数量达到了7个。
@@ -54,7 +49,7 @@ Solver。SOMAS将计算图并行流与数据依赖进行聚合分析得到
#### 内存融合
上述内存分配的方式,都是以单个Tensor的维度去分配的,每个Tensor分配到的Device地址往往是离散的。但是对于某些特殊的算子如AllReduce通信算子我们需要为它们分配连续的内存。通信算子的执行包含通信等待、数据搬移、计算等步骤,而在大规模分布式集群的场景下,通信的耗时往往是性能瓶颈。针对这种场景,如 :numref:`memory_fusion`所示,我们可以将多个通信算子融合成一个,为通信算子的输入分配连续的内存,从而减少通信的次数。
上述内存分配的方式,都是以单个张量的维度去分配的,每个张量分配到的设备地址往往是离散的。但是对于某些特殊的算子如AllReduce通信算子需要为它们分配连续的内存。通信算子的执行包含通信等待、数据搬移、计算等步骤而在大规模分布式集群的场景下通信的耗时往往是性能瓶颈。针对这种场景如 :numref:`memory_fusion`所示,可以将多个通信算子融合成一个,为通信算子的输入分配连续的内存,从而减少通信的次数。
又比如分布式训练中的神经网络权重初始化,通常将一个训练进程中的权重初始化,然后将该权重广播到其他进程中。当一个网络有较多权重的时候,需要多次进行广播。通常可以为所有权重分配连续的内存地址,然后广播一次,节省大量通信的耗时。
![通信算子内存融合](../img/ch05/memory_fusion.png)
@@ -63,17 +58,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
a拷贝到Tensor a'step2将Tensor b赋值给Tensor a'step3将Tensor
a'拷贝到Tensor
a。 :numref:`inplace-op`右边是算子In-Place操作的实现仅用一个步骤将Tensor
b拷贝到Tensor
a对于的位置上。对比两种实现可以发现In-Place操作节省了两次拷贝的耗时并且省去了Tensor
a'内存的申请。
在内存分配流程中会为每个算子的输入和输出都分配不同的内存。然而对很多算子而言为其分配不同的输入和输出地址会浪费内存并且影响计算性能。例如优化器算子其计算的目的就是更新神经网络的权重例如Python语法中的 +=*= 操作符,将计算结果更新到符号左边的变量中;例如 a[0]=b 语法,将 a[0] 的值更新为 b。诸如此类计算有一个特点,都是为了更新输入的值。下面以张量的 a[0]=b 操作为例介绍In-Place的优点。
:numref:`inplace-op`左边是非In-Place操作的实现step1将张量a拷贝到张量a'step2将张量b赋值给张量a'step3将张量a'拷贝到张量a。 :numref:`inplace-op`右边是算子In-Place操作的实现仅用一个步骤将张量b拷贝到张量a对应的位置上。对比两种实现可以发现In-Place操作节省了两次拷贝的耗时并且省去了张量a'内存的申请。
![In-Place算子内存分配](../img/ch05/inplace-op.png)
:width:`800px`
:label:`inplace-op`
这节我们简单介绍了Device内存的概念,内存分配的流程,和一些优化内存分配的方法。内存分配是编译器后端的最重要部分之一,内存的合理分配,不仅关系到相同芯片上能否支持更大的网络模型,也关系到模型在硬件上的执行效率。
这节简单介绍了设备内存的概念,内存分配的流程,和一些优化内存分配的方法。内存分配是编译器后端的最重要部分之一,内存的合理分配,不仅关系到相同内存容量下能否支持更大的网络模型,也关系到模型在硬件上的执行效率。

View File

@@ -0,0 +1,193 @@
## 算子编译器
算子编译器,顾名思义,即对算子进行编译优化的工具。这里所谓的"算子"可以来自于整个神经网络中的一部分也可以来自于通过领域特定语言Domain
Specific Language,
DSL实现的代码。而所谓编译通俗来说起到的是针对目标语言进行**表达**和**转换**。
从目的上来说,算子编译器致力于提高算子的**执行性能**。从工程实现上来说算子编译器的输入一般为Python等**动态语言**描述的张量计算,而输出一般为**特定AI芯片**上的可执行文件。
### 算子调度策略
算子编译器为了实现较好地优化加速,会根据现代计算机体系结构特点,将程序运行中的每个细小操作抽象为"调度策略"。
如果不考虑优化和实际中芯片的体系结构特点,只需要按照算子表达式的**计算逻辑**,把输入进来的张量全部加载进计算核心里完成计算,之后再把计算结果从计算核心里面取出并保存下来即可。这里的**计算逻辑**指的就是基本数学运算(如加、减、乘、除)以及其他函数表达式(如卷积、转置、损失函数)等。
但是 :numref:`fig:ch05/ch05-memory_architecture`向我们展示的现代计算机存储结构表明:越靠近金字塔顶尖的存储器造价越高但是访问速度越快。
![代计算机存储层次图](../img/ch05/memory_architecture.png)
:width:`800px`
:label:`fig:ch05/ch05-memory_architecture`
基于这一硬件设计的事实有局部性Locality概念
1时间局部性相对较短时间内重复访问特定内存位置。如多次访问L1高速缓存的同一位置的效率会高于多次访问L1中不同位置的效率。
2空间局部性在相对较近的存储位置进行访问。如多次访问L1中相邻位置的效率会高于来回在L1和主存跳跃访问的效率。
满足这两者任一都会有较好的性能提升。基于局部性概念,希望尽量把需要重复处理的数据放在固定的内存位置,且这一内存位置离处理器越近越好,以通过提升访存速度而进行性能提升。
另外,把传统的串行计算任务按逻辑和数据依赖关系进行分割后,有机会得到多组互不相关的数据,并把他们同时计算,如 :numref:`fig:ch05/ch05-parallel_computing`所示。
![串行计算和并行计算区别图](../img/ch05/parallel_computing.png)
:width:`800px`
:label:`fig:ch05/ch05-parallel_computing`
以上种种在程序实际运行的时候针对数据做出的特殊操作,统称为**调度Schedule**。调度定义了:
1应该在何时何处计算函数中的每个值
2数据应该储存在哪里
3每个值在多个消费者Consumer之间访存需要花费多长时间另外在何时由每个消费者独立重新计算这里的消费者指使用前序结构进行计算的值。
通俗理解,调度策略指的是:在编译阶段根据目标硬件体系结构的特点而设计出的一整套通过提升局部性和并行性而使得编译出的可执行文件在运行时性能最优的算法。这些算法并不会影响计算结果,只是干预计算过程,以达到提升运算速度的效果。
### 子策略组合优化
算子编译器的一种优化思路是将抽象出来的调度策略进行组合拼接排布出一个复杂而高效的调度集合。子策略组合优化本质上还是基于人工手动模板匹配的优化方式依赖于开发人员对于硬件架构有较深的理解。这种方式较为直接但组合出的优化策略无法调优同时对各类算子精细化的优化也带来较多的人力耗费。本文以TVM为例通过在CPU上加速优化一段实际代码简要介绍其中几种基本调度策略组成的优化算法。
我们以形式为乘累加计算的代码[\[lst:before_tvm\]](#lst:before_tvm){reference-type="ref"
reference="lst:before_tvm"}为例简要分析描述这一算法。该代码的核心计算逻辑为首先对张量C进行初始化之后将张量A与张量B相乘后结果累加到张量C中。
``` {#lst:before_tvm caption="乘累加计算代码" label="lst:before_tvm"}
for (m: int32, 0, 1024) {
for (n: int32, 0, 1024) {
C[((m*1024) + n)] = 0f32
for (k: int32, 0, 1024) {
let cse_var_2: int32 = (m*1024)
let cse_var_1: int32 = (cse_var_2 + n)
C[cse_var_1] = (C[cse_var_1] + (A[(cse_var_2 + k)]*B[((k*1024) + n)]))
}
}
}
```
假定数据类型为浮点型Float此时张量A、B、C的大小均为1024 $\times$
1024三者占用的空间共为1024 $\times$ 1024 $\times$ 3 $\times$
sizeof(float) = 12MB。这远远超出了常见缓存的大小如L1
Cache为32KB。因此按照此代码形式要将整块张量A、B、C一起计算只能放入离计算核更远的内存进行计算。其访存效率远低于缓存。
为了提升性能提出使用平铺Tile循环移序Reorder和切分Split的调度策略。由于L1缓存大小为32KB为了保证每次计算都能够放入缓存中我们选取因子Factor为32进行平铺使得平铺后的每次计算时只需要关注m.inner
$\times$
n.inner构成的小块Block即可而其他的外层循环不会影响最内层小块的访存。其占用内存大小为32
$\times$ 32 $\times$ 3 $\times$ sizeof(float) =
12KB足够放入缓存中。代码[\[lst:after_tvm\]](#lst:after_tvm){reference-type="ref"
reference="lst:after_tvm"}展示了经过该策略优化优化后的变化。
``` {#lst:after_tvm caption="子策略组合优化后的代码" label="lst:after_tvm"}
// 由for (m: int32, 0, 1024)以32为因子平铺得到外层循环
for (m.outer: int32, 0, 32) {
// 由for (n: int32, 0, 1024)以32为因子平铺得到外层循环
for (n.outer: int32, 0, 32) {
// 由for (m: int32, 0, 1024)以32为因子平铺得到内层循环
for (m.inner.init: int32, 0, 32) {
// 由for (n: int32, 0, 1024)以32为因子平铺得到内层循环
for (n.inner.init: int32, 0, 32) {
// 对应地得到相应系数
C[((((m.outer*32768) + (m.inner.init*1024)) + (n.outer*32)) + n.inner.init)] = 0f32
}
}
// 由for (k: int32, 0, 1024)以4为因子切分得到外层循环并进行了循环移序
for (k.outer: int32, 0, 256) {
// 由for (k: int32, 0, 1024)以4为因子切分得到外层循环并进行了循环移序
for (k.inner: int32, 0, 4) {
// 由for (m: int32, 0, 1024)以32为因子平铺得到内层循环
for (m.inner: int32, 0, 32) {
// 由for (n: int32, 0, 1024)以32为因子平铺得到内层循环
for (n.inner: int32, 0, 32) {
// 由n轴平铺得到的外轴系数
let cse_var_3: int32 = (n.outer*32)
// 由m轴平铺得到的外轴和内轴系数
let cse_var_2: int32 = ((m.outer*32768) + (m.inner*1024))
// 由m轴和n轴得到的外轴和内轴系数
let cse_var_1: int32 = ((cse_var_2 + cse_var_3) + n.inner)
// 这里是核心计算逻辑划分成不同层次使得每次循环计算的数据能够放入cache中
C[cse_var_1] = (C[cse_var_1] + (A[((cse_var_2 + (k.outer*4)) + n.inner)] * B[((((k.outer*4096) + (k.inner*1024)) + cse_var_3) + n.inner)]))
}
}
}
}
}
}
```
本示例参照TVM提供的"在CPU上优化矩阵乘运算的实例教程"[^1]中的第一项优化,读者可深入阅读后续优化内容。
### 调度空间算法优化
算子编译器的另外一种优化思路是:通过对调度空间搜索/求解自动生成对应算子调度。此类方案包括多面体模型编译Polyhedral
Compilation基于约束对调度空间求解和Ansor调度空间搜索等。这类方法的好处是提升了算子编译的泛化能力缺点是搜索空间过程会导致编译时间过长。
以多面体模型编译技术将代码的多层循环抽象为多维空间,将每个计算实例抽象为空间中的点,实例间的依赖关系抽象为空间中的线,主要对循环进行优化。该算法的主要思想是针对输入代码的访存特点进行建模,调整循环语句中的每一个实例的执行顺序,使得新调度下的循环代码有更好的局部性和并行性。
我们以代码[\[lst:before_poly\]](#lst:before_poly){reference-type="ref"
reference="lst:before_poly"}为例介绍该算法。
``` {#lst:before_poly caption="待优化代码" label="lst:before_poly"}
for (int i = 0; i < N; i++)
for (int j = 1; j < N; j++)
a[i+1][j] = a[i][j+1] - a[i][j] + a[i][j-1];
```
如 :numref:`fig:ch05/ch05-poly_test`所示,通过多面体模型算法先对此代码的访存结构进行建模,然后分析实例(即 :numref:`fig:ch05/ch05-poly_test`中节点)间的依赖关系(即 :numref:`fig:ch05/ch05-poly_test`中箭头)。
![示例代码的多面体模型](../img/ch05/poly_test.png)
:width:`800px`
:label:`fig:ch05/ch05-poly_test`
再进行复杂的依赖分析和调度变换之后得到一个符合内存模型的最优解。代码[\[lst:after_poly\]](#lst:after_poly){reference-type="ref"
reference="lst:after_poly"}显示了经过多面体模型优化后得到的结果。
``` {#lst:after_poly caption="多面体模型算法优化后的代码" label="lst:after_poly"}
for (int i_new = 0; i_new < N; i_new++)
for (int j_new = i+1; j_new < i+N; j_new++)
a[i_new+1][j_new-i_new] = a[i_new][j_new-i_new+1] - a[i_new][j_new-i_new] + a[i_new][j_new-i_new-1];
```
观察得到的代码,发现优化后的代码较为复杂。但是仅凭肉眼很难发现其性能优势之处。仍需对此优化后的代码进行如算法描述那样建模,并分析依赖关系后得出结论,如 :numref:`fig:ch05/ch05-poly`所示:经过算法优化后解除了原代码中的循环间的依赖关系,从而提高了并行计算的机会。即沿着 :numref:`fig:ch05/ch05-poly`中虚线方向分割并以绿色块划分后,可以实现并行计算。
该算法较为复杂,限于篇幅,在这里不再详细展开。读者可移步到笔者专门为此例写的文章-《深度学习编译之多面体模型编译------以优化简单的两层循环代码为例》详读。
![多面体模型优化结果](../img/ch05/poly.png)
:width:`800px`
:label:`fig:ch05/ch05-poly`
### 芯片指令集适配
前文讲述了算子编译器的优化方法,本小节将阐述算子编译器适配不同芯片上指令集的情况。一般意义上来说,通用编译器的设计会尽量适配多种后端。如此一来,在面临不同体系结构特点和不同编程模型的多种后端时,算子编译器承受了相当大的压力。
当下的AI芯片中常见的编程模型分为单指令多数据Single Instruction,
Multiple Data,
SIMD即单条指令一次性处理大量数据如 :numref:`fig:ch05/ch05-SIMD`所示单指令多线程Single Instruction,
Multiple Threads,
SIMT即单条指令一次性处理多个线程的数据如 :numref:`fig:ch05/ch05-SIMT`所示。前者对应的是带有向量计算指令的芯片;后者对应的是带有明显的线程分级的芯片。另外,也有一些芯片开始结合这两种编程模型的特点,既有类似线程并行计算的概念,又有向量指令的支持。针对不同的编程模型,算子编译器在进行优化(如向量化等)时的策略也会有所不同。
![单指令多数据流示意图](../img/ch05/SIMD.png)
:width:`800px`
:label:`fig:ch05/ch05-SIMD`
![单指令多线程示意图](../img/ch05/SIMT.png)
:width:`800px`
:label:`fig:ch05/ch05-SIMT`
一般来说算子编译器在具体的代码中会按照前端、中端、后端逐渐差异化的思路进行实现。即在前端设计中兼容多种不同芯片后端的指令集以帮助编译器用户即AI程序员不需要在乎芯片差异而只需要专注在AI算法逻辑上即可在中间表示IR设计中对不同芯片的体系结构进行区分从而可以实现对不同芯片进行不同的优化方法在后端的目标代码生成部分对各个芯片的不同指令集详细区分以保证生成出的目标代码能够顺利运行在目标芯片上。
### 算子表达能力
算子表达能力指的是算子编译器的前端识别输入代码并在不损失语义信息的情况下转换为中间表示的能力。算子编译器承接的前端输入往往是PyTorch等的Python形式的代码而Python中各种灵活的表达方式包括而不限于索引、View语义等对算子编译器的前端表达能力提出了较高要求。另外在检测网络中输入算子往往还有大量的控制流语句。此外还经常可以看到神经网络中存在许多的动态形状问题即网络中的算子形状会受网络迭代次数和控制流等条件的影响。这些都对算子编译器前端的表达能力提出了很高的要求。
在实际工程实践中发现大量的长尾分布般不常见但性能很差的算子后文简称为长尾算子往往是整体网络训练或推理的瓶颈点。而这些长尾算子大都是由于其出现频次低而不至于实现在计算库中。同时其语法过于灵活或存在大量的控制流语句以及动态形状问题而难以被目前的算子编译器前端充分表达出来因此也难以通过算子编译器进行优化加速。于是这些长尾算子只好以运行速度较慢的Python解释器或者虚拟机的方式执行从而成为整个网络中的性能瓶颈。此时提高算子编译器前端的表达能力就成为了重中之重。
### 相关编译优化技术
算子编译器与传统编译器在优化技术方面根出同源,但由于面对的问题不同,所以在优化思路上也有差别。两者都以前中后端的思路进行设计,都是以增强局部性和并行性为优化的理论依据。
但是前者面向的问题是AI领域中的计算问题往往在优化过程中会大量参考和借鉴高性能计算High-Performance
Computing,
HPC的优化思路这种情况称为借助专家经验进行优化。另外算子编译器面对的后端AI芯片的体系结构的不同如重点的单指令多数据和单指令多线程为代表的两种后端体系结构决定了优化过程中更多偏向于生成对单指令多数据友好的加速指令或者生成对单指令多线程友好的多线程并行计算模型。
而后者面向的问题是更加通用的标量计算行为和计算机控制命令,往往在优化中围绕寄存器的使用和分支预测准确性等进行优化。
总之,由于需要解决的问题不同,算子编译器和传统编译器在优化算法的具体实现上有着一定的区别,但是在算法设计时也有互相借鉴的机会。
[^1]: 在CPU上优化矩阵乘运算的实例教程<https://tvm.apache.org/docs/how_to/optimize_operators/opt_gemm.html>

View File

@@ -1,8 +1,8 @@
## 概述
编译器前端主要将用户代码进行解析翻译得到计算图IR并对其进行设备信息无关的优化此时我们并不考虑程序执行的底层硬件信息。编译器后端的主要职责对前端下发的IR做进一步的计算图优化让其更加贴合硬件并为IR中的计算节点选择适合在硬件上执行的算子,然后为每个算子的输入输出分配硬件内存,最终生成一个可以在硬件上执行的任务序列。
编译器前端主要将用户代码进行解析翻译得到计算图IR并对其进行设备信息无关的优化,此时的优化并不考虑程序执行的底层硬件信息。编译器后端的主要职责对前端下发的IR做进一步的计算图优化让其更加贴合硬件并为IR中的计算节点选择在硬件上执行的算子然后为每个算子的输入输出分配硬件内存最终生成一个可以在硬件上执行的任务序列。
如 :numref:`compiler-backend-architecture`所示编译器后端处于前端和硬件驱动层中间主要负责计算图优化、算子选择和内存分配的任务。首先需要根据硬件设备的特性将IR图进行等价图变换以便在硬件上能够找到对应的执行算子该过程是计算图优化的重要步骤之一。前端IR生成是解析用户代码属于一个较高的抽象层次隐藏一些底层运行的细节信息此时无法直接对应硬件上的算子算子是设备上的基本计算序列例如MatMul、ConvolutionReLU等需要将细节信息进行展开后才能映射到目标硬件上的算子。对于某些前端IR的子集来说一个算子便能够执行对应的功能此时可以将这些IR节点合并成为一个计算节点该过程称之为算子融合对于一些复杂计算后端并没有直接与之对应的算子但是可以通过几个基本运算的算子组合达到同样的计算效果此时可以将前端IR节点拆分成多个小算子。然后,我们需要进行算子选择。算子选择是在得到优化的IR图后,需要选取最合适的目标设备算子。针对用户代码所产生的IR往往可以映射成多种不同的硬件算子但是生成不同的算子执行效率往往有很大差别如何根据前端IR选择出最高效的算子是算子选择的核心问题。算子选择本质上是一个模式匹配问题。其最简单的方法就是每一个IR节点对应一个目标硬件的算子但是这种方法往往对目标硬件的资源利用比较差。目前来说对于现有的编译器一般都对每一个IR节点提供了多个候选的算子算子选择目标就是从中选择最优的一个算子作为最终执行在设备上的算子。总的来说在机器学习系统中对前端生成的IR图上的各个节点进行拆分和融合让前端所表示的高层次IR逐步转换为可以在硬件设备上执行的低层次IR。得到了这种更加贴合硬件的IR后对于每个单节点的IR可能仍然有很多种不同的选择例如可以选择不同的输入输出格式和数据类型我们需要对IR图上每个节点选择出最为合适的算子算子选择过程可以认为是针对IR图的细粒度优化过程最终生成完整的算子序列。最后遍历算子序列为每个算子分配相应的输入输出内存然后将算子加载到设备上执行计算。
如 :numref:`compiler-backend-architecture`所示编译器后端处于前端和硬件驱动层中间主要负责计算图优化、算子选择和内存分配的任务。首先需要根据硬件设备的特性将IR图进行等价图变换以便在硬件上能够找到对应的执行算子该过程是计算图优化的重要步骤之一。前端IR是通过解析用户代码生成的属于一个较高的抽象层次隐藏一些底层运行的细节信息此时无法直接对应硬件上的算子算子是设备上的基本计算序列例如MatMul、ConvolutionReLU等需要将细节信息进行展开后才能映射到目标硬件上的算子。对于某些前端IR的子集来说一个算子便能够执行对应的功能此时可以将这些IR节点合并成为一个计算节点该过程称之为算子融合对于一些复杂计算后端并没有直接与之对应的算子但是可以通过几个基本运算的算子组合达到同样的计算效果此时可以将前端IR节点拆分成多个小算子。在完成计算图优化之后,就要进行算子选择过程,为每个计算节点选择执行算子。算子选择是在得到优化的IR图后选取最合适的目标设备算子的过程。针对用户代码所产生的IR往往可以映射成多种不同的硬件算子但是这些不同硬件算子执行效率往往有很大差别如何根据前端IR选择出最高效的算子是算子选择的核心问题。算子选择本质上是一个模式匹配问题。其最简单的方法就是每一个IR节点对应一个目标硬件的算子但是这种方法往往对目标硬件的资源利用比较差。现有的编译器一般都对每一个IR节点提供了多个候选的算子算子选择目标就是从中选择最优的一个算子作为最终执行在设备上的算子。总的来说在机器学习系统中对前端生成的IR图上的各个节点进行拆分和融合让前端所表示的高层次IR逐步转换为可以在硬件设备上执行的低层次IR。得到了这种更加贴合硬件的IR后对于每个单节点的IR可能仍然有很多种不同的选择例如可以选择不同的输入输出格式和数据类型需要对IR图上每个节点选择出最为合适的算子算子选择过程可以认为是针对IR图的细粒度优化过程最终生成完整的算子序列。最后遍历算子序列为每个算子分配相应的输入输出内存然后将算子加载到设备上执行计算。
![编译器后端总体架构简图](../img/ch05/compiler-backend-architecture.png)
:width:`800px`
@@ -23,3 +23,7 @@
### 计算调度与执行
经过算子选择与内存分配之后,计算任务可以通过运行时完成计算的调度与在硬件上的执行。根据是否将算子编译为计算图,计算的调度可以分为单算子调度与计算图调度两种方式。而根据硬件提供的能力差异,计算图的执行方式又可以分为逐算子下发执行的交互式执行以及将整个计算图或者部分子图一次性下发到硬件的下沉式执行两种模式。
### 算子编译器
作为AI编译器中一个重要组成部分算子编译器把单个简单或复杂的算子经过表达和优化后编译为一个单独的可执行文件。目前业界面对算子编译器仍有许多有趣的问题尚未得出明确结论相关的处理逻辑与方法也尚未收敛。本小节希望将这些问题简单抛出并给出业界比较典型的几种处理方式。若能对业界朋友们和同学们有所启发甚至若能对这些问题起到促进收敛的作用那真是再好不过目前尚待收敛的问题包括而不限于如何通过算子编译器进行性能优化算子编译器如何兼容不同体系结构特点的芯片面对输入Python代码的灵活性以及神经网络训练时动态性的情况该如何充分将这些完美表达出来

View File

@@ -18,7 +18,10 @@
- 运行时对于算子的执行可以分为单算子调度和计算图调度两种模式,而在计算图调度模式中,根据具体硬件的能力又可以分为交互式执行和下沉式执行两种方式,交互式执行具备更多的灵活性,下沉执行可以获得更好的计算性能。
- 算子编译器是优化硬件性能的关键组件。其中,调度策略的优化和基于多面体模型算法的优化是两个关键技术。
## 扩展阅读
- 内存分配作为机器学习后端的重要部分,建议阅读 [Sublinear Memory Cost](https://arxiv.org/abs/1604.06174)、 [Dynamic Tensor Rematerialization](https://arxiv.org/abs/2006.09616)。
- 对于运行时的调度以及执行,建议阅读 [A Lightweight Parallel and Heterogeneous Task Graph Computing System](https://arxiv.org/abs/2004.10908)、 [Dynamic Control Flow in Large-Scale Machine Learning](https://arxiv.org/abs/1805.01772)、[DEEP LEARNING WITH DYNAMIC COMPUTATION GRAPHS](https://arxiv.org/abs/1702.02181)。
- 对于运行时的调度以及执行,建议阅读 [A Lightweight Parallel and Heterogeneous Task Graph Computing System](https://arxiv.org/abs/2004.10908)、 [Dynamic Control Flow in Large-Scale Machine Learning](https://arxiv.org/abs/1805.01772)、[DEEP LEARNING WITH DYNAMIC COMPUTATION GRAPHS](https://arxiv.org/abs/1702.02181)。
- 算子编译器是本书的扩展部分,建议阅读提出计算与调度分离的论文: [Halide: A Language and Compiler for Optimizing Parallelism, Locality, and Recomputation in Image Processing Pipelines](https://dl.acm.org/doi/abs/10.1145/2499370.2462176),以及介绍调度空间优化的论文 [Ansor: Generating High-Performance Tensor Programs for Deep Learning](https://arxiv.org/abs/2006.06762)和 [olly - Polyhedral optimization in LLVM](https://arxiv.org/abs/2105.04555)

View File

@@ -2,7 +2,7 @@
分布式强化学习系统是比上面介绍的单节点强化学习系统更强大的一种。它能支持多环境多模型并行处理,主要是能同时在多个实际计算机系统上对多个模型进行更新,将大大提高强化学习系统的学习速度和整体表现。我们这里介绍分布式强化学习常见的算法和系统。
异步优势行动-批判者Asynchronous Advantage Actor-CriticA3C是由DeepMind研究人员 :cite:`mnih2016asynchronous`于2016年提出的可以在多个计算设备上并行更新网络的学习算法。相比于 :numref:`ch12/ch12-rlzoo`中的单节点强化学习系统A3C通过创建一组工作者Worker并将每个工作者分配到不同的计算设备上且为他们各自创建可以交互的环境来实现并行采样和模型更新同时用一个主Master节点维护这些行动者Actor和批判者Critic网络的更新。行动者是策略网络批判者是价值网络 分别对应强化学习中的策略和价值函数。通过这样的设计,整个算法的各个工作者可以实时将所采集到样本计算出的梯度回传到主节点,来更新主节点的模型参数,并在主节点模型更新后时下发到各个工作者进行模型更新。每个工作者可以单独在一个GPU上进行运算从而整个算法可以在一个GPU集群上并行更新模型算法结构由 :numref:`ch12/ch12-a3c`所示。研究表明,分布式强化学习训练除加速模型学习之外,由于其更新梯度是由多个计算节点各自对环境采样计算得到的,还有利于稳定学习表现。
异步优势行动-批判者Asynchronous Advantage Actor-CriticA3C是由DeepMind研究人员 :cite:`mnih2016asynchronous`于2016年提出的可以在多个计算设备上并行更新网络的学习算法。相比于 :numref:`ch12/ch12-rlzoo`中的单节点强化学习系统A3C通过创建一组工作者Worker并将每个工作者分配到不同的计算设备上且为他们各自创建可以交互的环境来实现并行采样和模型更新同时用一个主Master节点维护这些行动者Actor和批判者Critic网络的更新。行动者是策略网络批判者是价值网络分别对应强化学习中的策略和价值函数。通过这样的设计整个算法的各个工作者可以实时将所采集到样本计算出的梯度回传到主节点来更新主节点的模型参数并在主节点模型更新后时下发到各个工作者进行模型更新。每个工作者可以单独在一个 GPU 上进行运算,从而整个算法可以在一个 GPU 集群上并行更新模型算法结构由 :numref:`ch12/ch12-a3c`所示。研究表明,分布式强化学习训练除加速模型学习之外,由于其更新梯度是由多个计算节点各自对环境采样计算得到的,还有利于稳定学习表现。
![A3C分布式算法架构](../img/ch12/ch12-a3c.png)
@@ -10,7 +10,7 @@
:label:`ch12/ch12-a3c`
重要性加权行动-学习者架构Importance Weighted Actor-Learner ArchitectureIMPALA) 是由Lasse Espeholt等人于2018年 :cite:`espeholt2018impala`提出的能够实现多机集群训练的强化学习框架。与A3C算法类似IMPALA能够在多个GPU上并行进行梯度计算。具体地IMPALA并行多个行动者Actor和学习者Learner每个行动者包含一个策略网络并用它来和一个环境交互收集样本。所收集到的样本轨迹由行动者发送到各自的学习者进行梯度计算。所有的学习者中有一个称为主学习者它可以和其他所有学习者通信获取他们计算的梯度从而在主学习者内部对模型进行更新随后下发到各个学习者及行动者做新一轮的采样和梯度计算。IMPALA被证明是比A3C更高效的分布式计算架构它同时得益于一个特殊设计的学习者内的梯度计算函数称为V-轨迹目标V-trace Target通过重要性加权来稳定训练。我们这里侧重对分布式强化学习结构的介绍对此不再赘述。感兴趣的读者可以参考原论文
重要性加权行动-学习者架构Importance Weighted Actor-Learner ArchitectureIMPALA) 是由Lasse Espeholt等人于2018年 :cite:`espeholt2018impala`提出的能够实现多机集群训练的强化学习框架,如:numref:`ch12/ch12-impala`所示。与 A3C 算法类似IMPALA 能够在多个 GPU 上并行进行梯度计算。具体地IMPALA 并行多个行动者Actor和学习者Learner每个行动者包含一个策略网络并用这个策略网络与一个环境进行交互,以收集样本。所收集到的样本轨迹由行动者发送到各自的学习者进行梯度计算。所有的学习者中有一个称为主学习者它可以和其他所有学习者通信获取他们计算的梯度从而在主学习者内部对模型进行更新随后下发到各个学习者及行动者做新一轮的采样和梯度计算。IMPALA 被证明是比 A3C 更高效的分布式计算架构,它同时得益于一个特殊设计的学习者内的梯度计算函数,称为 V-轨迹目标V-trace Target通过重要性加权来稳定训练。我们这里侧重对分布式强化学习结构的介绍对此不再赘述。感兴趣的读者可以参考原论文
![IMPALA分布式算法架构](../img/ch12/ch12-impala.png)
@@ -26,7 +26,8 @@
:label:`ch12/ch12-rllib`
Ray :cite:`moritz2018ray`是由伯克利大学几名研究人员发起的一个分布式计算框架基于Ray之上构建了一个专门针对强化学习的系统RLlib :cite:`liang2017ray`。RLlib是一个面向工业级应用的开源强化学习框架同时包含了强化学习的算法库,它对非强化学习专家使用也很方便。
Ray :cite:`moritz2018ray`是由伯克利大学几名研究人员发起的一个分布式计算框架基于Ray之上构建了一个专门针对强化学习的系统RLlib :cite:`liang2017ray`。RLlib 是一个面向工业级应用的开源强化学习框架,同时
包含了强化学习的算法库,没有太多强化学习经验的人也可以很方便地使用 RLlib。
![RLlib分布式训练](../img/ch12/ch12-rllib-distributed.svg)
@@ -34,8 +35,9 @@ Ray :cite:`moritz2018ray`是由伯克利大学几名研究人员发起的一个
:label:`ch12/ch12-rllib_dist`
RLlib的系统架构如 :numref:`ch12/ch12-rllib`所示系统底层是构建在Ray的分布式计算和通信的基础组建之面向强化学习的领域概念在Python层抽象了Trainer, Environment, Policy等基础组件并为各个抽象组件提供了一些常用的内置实现同时用户可以根据自己的算法场景对组件进行扩展通过这些内置以及自定义的算法组件研究人员可以方便快速地实现具体的强化学习算法。RLlib支持多种范式的分布式强化学习训练如 :numref:`ch12/ch12-rllib_dist`所示为基于同步采样的强化学习算法的分布式训练架构。其中每一个Rollout Worker为一个独立进程负责和对应的环境进行交互以完成经验采集多个Rollout Worker可以并行地完成环境交互Trainer负责Rollout Worker之间的协调策略优化以及将更新后的策略同步到Rollout Worker中。
RLlib的系统架构如 :numref:`ch12/ch12-rllib`所示,系统底层是构建在 Ray 的分布式计算和通信的基础组建之
上,面向强化学习的领域概念,在 Python 层抽象了 Trainer, Environment, Policy 等基础组件并为各个抽象组件提供了一些常用的内置实现同时用户可以根据自己的算法场景对组件进行扩展通过这些内置以及自定义的算法组件研究人员可以方便快速地实现具体的强化学习算法。RLlib支持多种范式的分布式强化学习训练如 :numref:`ch12/ch12-rllib_dist`所示为基于同步采样的强化学习算法的分布式训练架构。其中每一个 Rollout Worker 为一个独立进程,负责和对应的环境进行交互以完成经验采集,多个 Rollout Worker 可以并行地完成环境交互Trainer 负责 Rollout Worker之间的协调策略优化以及将更新后的策略同步到 Rollout Worker 中。
强化学习中的策略通常可以采用深度神经网络而基于深度神经网络的分布式强化学习训练可以采用RLlib结合PyTorch或者TensorFlow等深度学习框架协同完成深度学习框架负责策略网络的训练和更新RLlib负责强化学习的算法计算。此外RLlib支持对环境交互使用向量化Vectorized的并行方式允许外接模拟器以及可以进行离线Offline强化学习。
强化学习中的策略通常可以采用深度神经网络,而基于深度神经网络的分布式强化学习训练,可以采用 RLlib 结合 PyTorch 或者 TensorFlow 等深度学习框架协同完成深度学习框架负责策略网络的训练和更新RLlib 负责强化学习的算法计算。此外 RLlib 支持与并行的向量化Vectorized环境交互允许外接模拟器以及可以进行离线Oine强化学习。
对于分布式系统中样本回放缓冲池的管理我们会提到另一个工作Reverb :cite:`cassirer2021reverb`。回忆本章开头我们介绍了强化学习中的状态、动作、奖励等概念实际强化学习算法进行训练所使用的数据正是存放在经验缓冲池中的这些数据元组而每种数据自身的格式可能又有不同实际使用时也需要对不同的数据做不同类型的操作。常见的数据操作类型如拼接、截取、乘积、转置、部分乘积、取均值、取极值等而每种操作都可能需要对特定数据的特定维度进行这常常给现有的强化学习框架在实践中产生一定的困难。为了方便强化学习过程中灵活使用不同的数据形式Reverb设计了数据块的概念Chunks所有使用的训练数据在缓冲池中都使用数据块的格式进行管理和调用这一设计基于数据是多维张量的特点增大了数据使用的灵活性和访问速度。Acme :cite:`hoffman2020acme`是近年来由DeepMind提出的一个分布式强化学习框架同样是针对学术界的研究和工业界的应用它基于Reverb对样本缓冲池的数据管理结合分布式采样的结构给出了一个更快的分布式强化学习解决方案。Reverb帮助解决了数据管理和传输的效率问题使得Acme得以将分布式计算的效力充分发挥研究人员用Acme在大量强化学习基准测试中取得了显著的速度提升。
对于分布式系统中样本回放缓冲池的管理我们会提到另一个工作Reverb :cite:`cassirer2021reverb`。回忆本章开头我们介绍了强化学习中的状态、动作、奖励等概念实际强化学习算法进行训练所使用的数据正是存放在经验缓冲池中的这些数据元组而每种数据自身的格式可能又有不同实际使用时也需要对不同的数据做不同类型的操作。常见的数据操作类型如拼接、截取、乘积、转置、部分乘积、取均值、取极值等而每种操作都可能需要对特定数据的特定维度进行这常常给现有的强化学习框架在实践中产生一定的困难。为了方便强化学习过程中灵活使用不同的数据形式Reverb 设计了数据块的概念Chunks所有使用的训练数据在缓冲池中都使用数据块的格式进行管理和调用这一设计基于数据是多维张量的特点增大了数据使用的灵活性和访问速度。Acme :cite:`hoffman2020acme`是近年来由DeepMind提出的一个分布式强化学习框架同样是针对学术界的研究和工业界的应用它基于 Reverb 对样本缓冲池的数据管理结合分布式采样的结构给出了一个更快的分布式强化学习解决方案。Reverb 帮助解决了数据管理和传输的效率问题,使得 Acme得以将分布式计算的效力充分发挥研究人员用 Acme 在大量强化学习基准测试中取得了显著的速度提升。

View File

@@ -1,8 +1,6 @@
## 多智能体强化学习
以上所讲述的强化学习内容都为单智能体强化学习,而在近来的强化学习研究中,多智能体强化学习越来越受到研究人员关注。回想在本小节初介绍的单智能体强化学习框架 :numref:`ch12/ch12-rl-framework`,其中我们只有单个智能体产生的单个动作对环境产生影响,环境也返回单个奖励值给智能体。这里我们把单智能体强化学习扩展到多智能体强化学习,可以得到至少两种可能的多智能体强化学习框架,如 :numref:`ch12/ch12-marl`所示。 :numref:`ch12/ch12-marl`(a)为多智能体同时执行动作的情况,他们相互之间观察不到彼此的动作,他们的动作一同对环境产生影响,并各自接受自己动作所产生的奖励。 :numref:`ch12/ch12-marl`(b)为多智能体顺序执行动作的情况,后续智能体可能观察到前序智能体的动作,他们的动作一同对环境产生影响,并接受到各自的奖励值或共同的奖励值。除此之外,还有许多其他可能的多智能体框架,如更复杂的智能体间观察机制、智能体间通讯机制、多智能体合作与竞争等等。同时,这里假设多个智能体对环境的观察量都为环境的状态,这是最简单的一种,也是现实中最不可能出现的一种,实际情况下的多智能体往往对环境有各自不同的观察量。
![两种可能的多智能体强化学习框架a同步式多智能体决策b异步式多智能体决策。](../img/ch12/ch12-marl.png)
以上所讲述的强化学习内容都为单智能体强化学习,而在近来的强化学习研究中,多智能体强化学习越来越受到研究人员关注。回想在本小节初介绍的单智能体强化学习框架 :numref:`ch12/ch12-rl-framework`,其中我们只有单个智能体产生的单个动作对环境产生影响,环境也返回单个奖励值给智能体。这里我们把单智能体强化学习扩展到多智能体强化学习,可以得到至少两种可能的多智能体强化学习框架,如 :numref:`ch12/ch12-marl`所示。 :numref:`ch12/ch12-marl`(a)为多智能体同时执行动作的情况,他们相互之间观察不到彼此的动作,他们的动作一同对环境产生影响,并各自接受自己动作所产生的奖励。 :numref:`ch12/ch12-marl`(b)为多智能体顺序执行动作的情况,后续智能体可能观察到前序智能体的动作,他们的动作一同对环境产生影响,并接受到各自的奖励值或共同的奖励值。除此之外,还有许多其他可能的多智能体框架,如更复杂的智能体间观察机制、智能体间通讯机制、多智能体合作与竞争等等。同时,这里假设多个智能体对环境的观察量都为环境的状态,这是最简单的一种,也是现实中最不可能出现的一种,实际情况下的多智能体往往对环境有各自不同的观察量。![两种可能的多智能体强化学习框架a同步式多智能体决策b异步式多智能体决策。](../img/ch12/ch12-marl.png)
:width:`800px`
@@ -12,16 +10,18 @@
由上述介绍和定义可以发现多智能体强化学习是一个比单智能体强化学习更加复杂的问题。而实际上多个智能体的存在对于每个智能体的决策而言绝对不是简单的把每个单智能体决策累加的难度实际情况要比单智能体决策问题复杂很多。多智能体系统的研究实际上是门古老的学科它与博弈论Game Theory密切相关在深度强化学习盛行以前早已有大量研究和许多理论上未解的难题。其中一个典型的问题是纳什均衡在双人非零和博弈下没有多项式时间内可解的方法实际上这是一个PPADPolynomial Parity Argument, Directed version类的问题。见论文Settling the Complexity of Computing Two-Player Nash Equilibria. Xi Chen, et al.)由于篇幅限制,我们这里无法对多智能体问题做深入探讨,我们可以用一个简单例子来介绍为什么多智能体强化学习问题无法简单地用单智能体强化学习算法来解。
:剪刀-石头-布奖励值
:剪刀-石头-布奖励值
| | 剪刀 | 石头 | 布 |
| 奖励值 | 剪刀 | 石头 | 布 |
| --- | ------- | ------- | ------- |
| 剪刀 | (0,0) | (-1,+1) | (+1,-1) |
| 石头 | (+1,-1) | (0,0) | (-1,+1) |
| | (-1,+1) | (+1,-1) | (0,0) |
:label:`tab_ch12_ch12_marl`
| **剪刀** | (0,0) | (-1,+1) | (+1,-1) |
| **石头** | (+1,-1) | (0,0) | (-1,+1) |
| **布** | (-1,+1) | (+1,-1) | (0,0) |
|:label:`tab_ch12_ch12_marl`||||
我们考虑一个大家都熟悉的游戏, 剪刀-石头-布,考虑两个玩家玩这个游戏的输赢情况,我们知道有这样的输赢关系:剪刀<石头<布<剪刀...这里的“<”即前一个纯策略被后一个纯策略完全压制,我们给予奖励值-1、+1到这两个玩家当他们选择相同的纯策略时奖励值均为0。于是我们得到一个奖励值表如 :numref:`tab_ch12_ch12_marl`所示横轴为玩家1纵轴为玩家2表内的数组为玩家1和玩家2各自在相应动作下得到的奖励值。由于这个矩阵的反对称性,这个问题的纳什均衡策略对两个玩家相同,均为$(\frac{1}{3}, \frac{1}{3}, \frac{1}{3})$的策略分布,即有各$\frac{1}{3}$的概率出剪刀、石头或布。如果我们把得到这个纳什均衡策略作为多智能体学习的目标那么我们可以简单分析得到这个均衡策略无法通过简单的单智能体算法得到。考虑我们随机初始化两个玩家为任意两个纯策略比如玩家1出剪刀玩家2出石头。这时假设玩家2策略固定可以把玩家2看做固定环境的一部分于是可以使用任意单智能体强化学习算法对玩家1进行训练使其最大化自己的奖励值。于是玩家1会收敛到布的纯策略。这时再把玩家1固定训练玩家2玩家2又收敛到剪刀的纯策略。于是循环往复整个训练过程始终无法收敛玩家1和2各自在3个策略中循环却无法得到正确的纳什均衡策略。
我们考虑一个大家都熟悉的游戏, 剪刀-石头-布,考虑两个玩家玩这个游戏的输赢情况,我们知道有这样的输赢关系:剪刀<石头<布<剪刀...这里的“<”即前一个纯策略被后一个纯策略完全压制,我们给予奖励值-1、+1到这两个玩家当他们选择相同的纯策略时奖励值均为0。于是我们得到一个奖励值表如 :numref:`tab_ch12_ch12_marl`所示横轴为玩家1纵轴为玩家2表内的数组为玩家1和玩家2各自在相应动作下得到的奖励值。
由于这个矩阵的反对称性,这个问题的纳什均衡策略对两个玩家相同,均为$(\frac{1}{3}, \frac{1}{3}, \frac{1}{3})$的策略分布,即有各$\frac{1}{3}$的概率出剪刀、石头或布。如果我们把得到这个纳什均衡策略作为多智能体学习的目标那么我们可以简单分析得到这个均衡策略无法通过简单的单智能体算法得到。考虑我们随机初始化两个玩家为任意两个纯策略比如玩家1出剪刀玩家2出石头。这时假设玩家2策略固定可以把玩家2看做固定环境的一部分于是可以使用任意单智能体强化学习算法对玩家1进行训练使其最大化自己的奖励值。于是玩家1会收敛到布的纯策略。这时再把玩家1固定训练玩家2玩家2又收敛到剪刀的纯策略。于是循环往复整个训练过程始终无法收敛玩家1和2各自在3个策略中循环却无法得到正确的纳什均衡策略。
![自学习算法示意图。](../img/ch12/ch12-marl-sp.png)
@@ -30,7 +30,9 @@
:label:`ch12/ch12-marl-sp`
我们在上面这个例子中采用的学习方法其实是多智能体强化学习中最基础的一种叫自学习Selfplay如 :numref:`ch12/ch12-marl-sp`所示。自学习的方法即固定当前对首次策略,按照单智能体优化的方法最大化一侧智能体的表现,这一过程称为最佳反应策略Best Response Strategy。之后再将这一最佳反应策略作为该智能体的固定策略,再来优化另一边的智能体策略,如此循环。我们可以看到自学习在特定的任务设置下可能无法收敛到我们想要的最终目标。正是由于多智能体学习过程中有类似循环结构的出现,我们需要更复杂的训练方法,和专门针对多智能体的学习方式来达到我们想要的目标。一般来讲多智能体强化学习是比单智能体强化学习更复杂的一类对于自学习的方法而言单智能体强化学习的过程可以看做一个多智能体强化学习的子任务。从前面这一小游戏的角度来理解当玩家1策略固定时玩家1加游戏环境构成玩家2的实际学习环境由于这个环境是固定的玩家2可以通过单智能体强化学习来达到自身奖励值最大化这时再固定玩家2的策略玩家1又可以进行单智能体强化学习......这样单智能体强化学习是多智能体任务的子任务。其他算法如虚构自学习Fictitious Self-play :numref:`ch12/ch12-marl-fsp`,需要在每个单智能体强化学习的步骤中,对对手历史策略的平均策略求得最优应对策略,而对手的训练也是如此,进行循环,能够在上面剪刀-石头-布一类的游戏中保证收敛到纳什均衡策略。
我们在上面这个例子中采用的学习方法其实是多智能体强化学习中最基础的一种叫自学习Selfplay如 :numref:`ch12/ch12-marl-sp`所示。自学习的方法即固定当前玩家 1 的策略,按照单智能体优化的方法最大化一侧智能体的表现,所得策略称为最佳反应策略Best Response Strategy。之后再将这一最佳反应策略作为玩家 2 的固定策略,再来优化另一边的智能体策略,如此循环。我们可以看到自学习在特定的任务设置下可能无法收敛到我们想要的最终目标。正是由于多智能体学习过程中有类似循环结构的出现,我们需要更复杂的训练方法,和专门针对多智能体的学习方式来达到我们想要的目标。
一般来讲,多智能体强化学习是比单智能体强化学习更复杂的一类,对于自学习的方法而言,单智能体强化学习的过程可以看做一个多智能体强化学习的子任务。从前面这一小游戏的角度来理解,当玩家 1 策略固定时,玩家 1 加游戏环境构成玩家 2 的实际学习环境,由于这个环境是固定的,玩家 2 可以通过单智能体强化学习来达到自身奖励值最大化;这时再固定玩家 2 的策略,玩家 1 又可以进行单智能体强化学习...... 这样,单智能体强化学习是多智能体任务的子任务。如 :numref:`ch12/ch12-marl-fsp`其他算法如虚构自学习Fictitious Self-play需要在每个单智能体强化学习的步骤中对对手历史策略的平均策略求得最优应对策略而对手的训练也是如此进行循环能够在上面剪刀-石头-布一类的游戏中保证收敛到纳什均衡策略。
![虚构自学习算法示意图。](../img/ch12/ch12-marl-fsp.png)

View File

@@ -1,6 +1,7 @@
## 多智能体强化学习系统
上述的简单例子只是为了帮助读者理解强化学习在多智能体问题里的角色而如今前沿的多智能体强化学习算法已经能够解决相当大规模的复杂多智能体问题如星际争霸StarCraft II、Dota 2等游戏已相继被DeepMind、OpenAI等公司所研究的智能体AlphaStar :cite:`vinyals2019grandmaster`和OpenAI Five :cite:`berner2019dota`攻克达到超越人类顶级玩家的水平。国内公司如腾讯、启元世界等也提出了星际争霸游戏的多智能体强化学习解决方案TStarBot-X :cite:`han2020tstarbot`和SCC :cite:`wang2021scc`。对于这类高度复杂的游戏环境整个训练过程对分布式计算系统的要求更高而整个训练过程可能需要分为多个阶段。以AlphaStar为例它训练的智能体采用了监督学习与强化学习结合的方式。在训练早期往往先采用大量的人类专业玩家标定数据进行有监督的学习从而使智能体快速获得较好的能力随后训练会切换到强化学习过程使用前面介绍的虚构自学习的算法进行训练即自我博弈。为了得到一个表现最好的智能体算法需要充分探索整个策略空间从而在训练中不止对一个策略进行训练而是对一个策略集群League进行训练并通过类似演化算法的方式对策略集群进行筛选得到大量策略中表现最好的策略。如 :numref:`ch12/ch12-marl_train`所示,在训练过程中每个智能体往往需要和其他智能体以及剥削者Exploiter进行博弈剥削者是专门针对某一个智能体策略的最佳对手策略与之对抗可以提高策略自身的防剥削能力。通过对大量智能体策略进行训练并筛选的这类方法称为集群式训练Population-based Training/League Training是一种通过分布式训练提高策略种群多样性进而提升模型表现的方式。可见在实践中这类方法自然需要分布式系统支持来实现多个智能体的训练和相互博弈这很好地体现了多智能体强化学习对分布式计算的依赖性。
上述的简单例子只是为了帮助读者理解强化学习在多智能体问题里的角色而如今前沿的多智能体强化学习算法已经能够解决相当大规模的复杂多智能体问题如星际争霸StarCraft II、Dota 2等游戏已相继被DeepMind、OpenAI等公司所研究的智能体AlphaStar :cite:`vinyals2019grandmaster`和OpenAI Five :cite:`berner2019dota`攻克达到超越人类顶级玩家的水平。国内公司如腾讯、启元世界等也提出了星际争霸游戏的多智能体强化学习解决方案TStarBot-X :cite:`han2020tstarbot`和SCC :cite:`wang2021scc`。对于这类高度复杂的游戏环境,整个训练过程对分布式计算系统的要求更高,而整个训练过程可能需要分为多个阶段。以 AlphaStar 为例它训练的智能体采用了监督学习与强化学习结合的方式。在训练早期往往先采用大量的人类专业玩家标定数据进行有监督的学习从而使智能体快速获得较好的能力随后训练会切换到强化学习过程使用前面介绍的虚构自学习的算法进行训练即自我博弈。为了得到一个表现最好的智能体算法需要充分探索整个策略空间从而在训练中不止对一个策略进行训练而是对一个策略集群League进行训练并通过类似演化算法的方式对策略集群进行筛选得到大量策略中表现最好的策略。如 :numref:`ch12/ch12-marl_train`所示,在训练过程中每个智能体往往需要
和其他智能体以及剥削者Exploiter进行博弈剥削者是专门针对某一个智能体策略的最佳对手策略与之对抗可以提高策略自身的防剥削能力。通过对大量智能体策略进行训练并筛选的这类方法称为集群式训练Population-based Training/League Training是一种通过分布式训练提高策略种群多样性进而提升模型表现的方式。可见在实践中这类方法自然需要分布式系统支持来实现多个智能体的训练和相互博弈这很好地体现了多智能体强化学习对分布式计算的依赖性。
![集群式多智能体强化学习训练示意图](../img/ch12/ch12-marl-train.svg)
@@ -12,22 +13,26 @@
* **智能体个数带来的复杂度**从单智能体系统到多智能体系统最直接的变化就是智能体个数从1变为大于1个。对于一个各个智能体独立的$N$智能体系统而言,这种变化带来的策略空间表示复杂度是指数增加的,即$\tilde{O}(e^N)$。举个简单的例子,对于一个离散空间的单智能体系统,假设其状态空间大小为$S$, 动作空间大小为$A$,游戏步长为$H$,那么这个离散策略空间的大小为$O(HSA)$;而直接将该游戏扩展为$N$玩家游戏后,在最一般的情况下,即所有玩家有对称的动作空间动作空间大小为$A$且不共享任何结构信息,所有玩家策略的联合分布空间大小为$O(HSA^N)$。这是因为每个独立玩家的策略空间构成联合策略空间是乘积关系$\mathcal{A}=\mathcal{A}_1\times\dots\mathcal{A}_N$。而这将直接导致算法搜索复杂度提升。
* **游戏类型带来的复杂度**:从博弈论的角度,多智能系统所产生的游戏类型是复杂的。从最直接的分类角度,有竞争型、合作型、混合型。在竞争型游戏中,最典型的研究模型是二人零和博弈,如前一小结中提到的剪刀-石头-布的游戏。这类游戏中的纳什均衡策略一般为混合型策略即无法通过单一纯策略达到均衡条件。纯策略纳什均衡存在于少数零和游戏中。合作型游戏即多个智能体需要通过合作来提升整体奖励。在这类问题研究中一般采用基于值分解的思路将所有智能体得到的奖励值分配到单个智能体作为其奖励值。这一类的算法有VDN :cite:`sunehag2017value`, COMA :cite:`foerster2018counterfactual`, QMIX :cite:`rashid2018qmix`等。在混合型游戏中部分智能体之间为合作关系部分智能体或智能体的集合间为竞争关系。一般的非零和博弈且非纯合作型游戏为混合型游戏举个简单的例子如囚徒困境Prisoner's Dilemma 其奖励值表如 :numref:`tab_ch12_ch12_marl_prison`所示。囚徒困境的两个玩家各有两个动作沉默和背叛。可以用警察审查两名罪犯来理解奖励值的绝对值即他们将被判处的年数。纯所有玩家的奖励值之和非常数故其为非零和博弈型游戏。因此这一游戏不能被认为是纯竞争型或纯合作型游戏因为当他们中的一方选择沉默一方选择背叛时二者没有有效合作而一方拿到了0的奖励另一方为-3。而两者都选择沉默时是一种合作策略各自拿到-1的奖励值。尽管这一策略看起来优于其他策略但是这并不是这个游戏的纳什均衡策略因为纳什均衡策略假设玩家间策略需要单独制定无法形成联合策略分布。这实际上切断了玩家间的信息沟通和潜在合作的可能。因此囚徒困境的纳什均衡策略是两个玩家都选择背叛对方。诸如此类的博弈论游戏类型导致单智能体强化学习不能被直接用来优化多智能体系统中的个智能体的策略。单智能体强化学习一般是找极值的过程,而多智能体系统求解纳什均衡策略往往是找极大-极小值即鞍点的过程,从优化的角度看这也是不同的。复杂的关系需要更普适的系统进行表达,这也对多智能体系统的构建提出了挑战。多智能体游戏类型也有许多其他的分类角度,如单轮进行的游戏、多轮进行的游戏、多智能体同时决策的、多智能体序贯决策等等,每一类不同的游戏都有相应不同的算法。而现有的多智能体系统往往针对单一类型游戏或者单一算法,缺少普适性多智能体强化学习系统,尤其是分布式的系统
在这种情况下,原先的单智能系统,需要扩展为对多智能体策略进行优化的系统,这意味着单智能体分布式系统内的每个并行化的模块现在需要相应扩展到多智能体系统中的个智能体上。而在复杂的情况下,还需要考虑智能体之间通信过程、智能体之间的异质性等,甚至不同智能体可能需要采用不完全对称模型进行表示,以及采用不同的算法进行优化等等
* **游戏类型带来的复杂度**:从博弈论的角度,多智能系统所产生的游戏类型是复杂的。从最直接的分类角度,有竞争型、合作型、混合型。在竞争型游戏中,最典型的研究模型是二人零和博弈,如前一小节中提到的剪刀-石头-布的游戏。这类游戏中的纳什均衡策略一般为混合型策略即无法通过单一纯策略达到均衡条件。纯策略纳什均衡存在于少数零和游戏中。合作型游戏即多个智能体需要通过合作来提升整体奖励。在这类问题研究中一般采用基于值分解的思路将所有智能体得到的奖励值分配到单个智能体作为其奖励值。这一类的算法有VDN :cite:`sunehag2017value`, COMA :cite:`foerster2018counterfactual`, QMIX :cite:`rashid2018qmix`等。
在混合型游戏中部分智能体之间为合作关系部分智能体或智能体的集合间为竞争关系。一般的非零和博弈且非纯合作型游戏为混合型游戏举个简单的例子如囚徒困境Prisoner's Dilemma 其奖励值表如 :numref:`tab_ch12_ch12_marl_prison`所示。囚徒困境的两个玩家各有两个动作,沉默和背叛。可以用警察审查两名罪犯来理解,奖励值的绝对值即他们将被判处的年数。纯所有玩家的奖励值之和非常数,故其为非零和博弈型游戏。因此这一游戏不能被认为是纯竞争型或纯合作型游戏,因为当他们中的一方选择沉默一方选择背叛时,二者没有有效合作,而一方拿到了 0 的奖励,另一方为-3。而两者都选择沉默时是一种合作策略各自拿到-1 的奖励值。尽管这一策略看起来优于其他策略,但是这并不是这个游戏的纳什均衡策略,因为纳什均衡策略假设玩家间策略需要单独制定,无法形成联合策略分布。这实际上切断了玩家间的信息沟通和潜在合作的可能。因此,囚徒困境的纳什均衡策略是两个玩家都选择背叛对方。
诸如此类的博弈论游戏类型,导致单智能体强化学习不能被直接用来优化多智能体系统中的各个智能体的策略。单智能体强化学习一般是找极值的过程,而多智能体系统求解纳什均衡策略往往是找极大-极小值即鞍点的过程,从优化的角度看这也是不同的。复杂的关系需要更普适的系统进行表达,这也对多智能体系统的构建提出了挑战。多智能体游戏类型也有许多其他的分类角度,如单轮进行的游戏、多轮进行的游戏、多智能体同时决策的、多智能体序贯决策等等,每一类不同的游戏都有相应不同的算法。而现有的多智能体系统往往针对单一类型游戏或者单一算法,缺少普适性多智能体强化学习系统,尤其是分布式的系统。
:囚徒困境奖励值
| | 沉默 | 背叛 |
| --- | ------- | ------- |
| 沉默 | (-1,-1) | (-3,0) |
| 背叛 | (0,-3) | (-2,-2) |
:label:`tab_ch12_ch12_marl_prison`
| 奖励值 | 沉默 | 背叛 |
| --- | ------- | ------- |
| **沉默** | (-1,-1) | (-3,0) |
| **背叛** | (0,-3) | (-2,-2) |
|:label:`tab_ch12_ch12_marl_prison`|||
* **算法的异构**:从前面介绍的几个简单的多智能体算法,如自学习、虚构自学习等可以看出,多智能体算法有时由许多轮单智能体强化学习过程组成。而对不同的游戏类型,算法的类型也不相同。比如,对合作型游戏,许多算法是基于奖励分配Credit Assignment的思想如何将多个智能体获得的共同奖励合理分配给单个智能体是这类算法的核心。而这里面按照具体算法执行方式也可以分为集成训练统一执行的Centralized Training Centralized Execution、集成训练分别执行的Centralized Training Decentralized Execution、分别训练并分别执行Decentralized Training Decentralized Execution的几类来描述不同智能体训练过程和执行过程的统一性。对于竞争型游戏往往采用各种计算纳什均衡的近似方法如前面提到的虚构自学习、Double Oracle、Mirror Descent等等将获取单个最优策略的单智能体强化学习过程看做一个“动作”而对这些“动作”组成的元问题上进行纳什均衡近似。现有的算法在类似问题上有很大的差异性使得构建一个统一的多智能体强化学习系统比较困难。
* **算法的异构**:从前面介绍的几个简单的多智能体算法,如自学习、虚构自学习等可以看出,多智能体算法有时由许多轮单智能体强化学习过程组成。而对不同的游戏类型,算法的类型也不相同。比如,对合作型游戏,许多算法是基于功劳分配Credit Assignment的思想如何将多个智能体获得的共同奖励合理分配给单个智能体是这类算法的核心。而这里面按照具体算法执行方式也可以分为集成训练统一执行的Centralized Training Centralized Execution、集成训练分别执行的Centralized Training Decentralized Execution、分别训练并分别执行Decentralized Training Decentralized Execution的几类来描述不同智能体训练过程和执行过程的统一性。对于竞争型游戏往往采用各种计算纳什均衡的近似方法如前面提到的虚构自学习、Double Oracle、Mirror Descent 等等,将获取单个最优策略的单智能体强化学习过程看做一个“动作”,而对这些“动作”组成的元问题上进行纳什均衡近似。现有的算法在类似问题上有很大的差异性,使得构建一个统一的多智能体强化学习系统比较困难。
* **学习方法组合**在前面提到的AlphaStar :cite:`vinyals2019grandmaster`等工作中,多智能体系统中优化得到一个好的策略往往不只需要强化学习算法,还需要其他学习方法如模仿学习等的辅助。比如从一些顶级人类玩家的游戏记录中形成有标签的训练样本,来预训练智能体。由于这些大规模游戏的复杂性,这往往是一个在训练前期快速提升智能体表现的有效方式。而对于整个学习系统而言,这就需要对不同学习范式进行结合,如合理地在模仿学习和强化学习之间进行切换等。这也使得大规模多智能体系统不单一是构建强化学习系统的问题,而需要许多其他学习机制和协调机制的配合实现。
如 :numref:`ch12/ch12_marl_sys`所示为一个分布式多智能体强化学习系统。图中的两个智能体可以类似扩展到多个智能体。每个智能体包含多个行动者Actor用于采样和学习者Learner用于更新模型这些行动者和学习者可以并行处理来加速训练过程具体方法可以参考单智能体分布式系统章节介绍的A3C和IMPALA架构。训练好的模型被统一存储和管理在模型存储器中是否对各个智能体的模型分别存储取决于各个智能体是否对称。存储器中的模型可以被模型评估器用来打分从而为下一步模型选择器做准备。模型选择器根据模型评估器或者元学习者如PSRO算法 :cite:`lanctot2017unified`以及均衡求解器等进行模型选择并将选出的模型分发到各个智能体的行动者上。这一处理过程我们称为联盟型管理League-based Management。对于与环境交互的部分分布式系统可以通过一个推理服务器Inference Server对各个并行进程中的模型进行集中推理将基于观察量Observation的动作Action发送给环境。环境部分也可以是并行的。推理服务器将采集到的交互轨迹发送给各个智能体进行模型训练。以上为一个分布式多智能体系统的例子实际中根据不同的游戏类型和算法结构可能会有不同的设计。
如 :numref:`ch12/ch12_marl_sys`所示为一个分布式多智能体强化学习系统。图中的两个智能体可以类似扩展到多个智能体。每个智能体包含多个行动者Actor用于采样和学习者Learner用于更新模型这些行动者和学习者可以并行处理来加速训练过程具体方法可以参考单智能体分布式系统章节介绍的A3C和IMPALA架构。训练好的模型被统一存储和管理在模型存储器中是否对各个智能体的模型统一存储取决于各个智能体是否对称——如果不对称,需要将模型分别存储。存储器中的模型可以被模型评估器用来打分从而为下一步模型选择器做准备。模型选择器根据模型评估器或者元学习者如PSRO算法 :cite:`lanctot2017unified`以及均衡求解器等进行模型选择并将选出的模型分发到各个智能体的行动者上。这一处理过程我们称为联盟型管理League-based Management。对于与环境交互的部分分布式系统可以通过一个推理服务器Inference Server对各个并行进程中的模型进行集中推理将基于观察量Observation的动作Action发送给环境。环境部分也可以是并行的,对推理服务器传递来的动作进行并行处理后,返回观察量。推理服务器将采集到的交互轨迹发送给各个智能体进行模型训练。以上为一个分布式多智能体系统的例子,实际中根据不同的游戏类型和算法结构可能会有不同的设计。
![分布式多智能体强化学习系统](../img/ch12/ch12-marl-sys.png)

View File

@@ -1,13 +1,17 @@
## 强化学习介绍
近年来,强化学习作为机器学习的一个分支受到越来越多的关注。2013年起,DeepMind公司的研究人员提出深度Q学习 :cite:`mnih2013playing`Deep Q-learning用于学习7个不同的电子游戏中对象的操作。自此以后以DeepMind为首的科研机构推出了像AlphaGo围棋这类的引人瞩目的强化学习成果并在2016年与世界顶级围棋高手李世石的对战中取得胜利。自那以后强化学习领域连续取得了一系列成就如星际争霸游戏智能体AlphaStar、Dota 2游戏智能体OpenAI Five、多人零和博弈德州扑克的Pluribus、机器狗运动控制算法等。在这一系列科研成就的背后是整个强化学习领域算法在这些年内快速迭代进步的结果基于模拟器产生的大量数据使得对数据“饥饿”Data Hungry的深度神经网络能够表现出很好的拟合效果从而将强化学习算法的能力充分发挥出来在以上领域中达到或者超过人类专家的学习表现。目前强化学习已经从电子游戏逐步走向更广阔的应用场景如机器人控制、机械手灵巧操作、能源系统调度、网络负载分配、股票期货交易等一系列更加现实和富有意义的领域对传统控制方法和启发式决策理论发起冲击。
近年来强化学习作为机器学习的一个分支受到越来越多的关注。2013DeepMind 公司的研究人员提出深度 Q 学习 :cite:`mnih2013playing`Deep Q-learning,成功让 AI 从图像中学习玩电子游戏。自此以后,以 DeepMind 为首的科研机构推出了像 AlphaGo 围棋 AI 这类的引人瞩目的强化学习成果,并在 2016 年与世界顶级围棋高手李世石的对战中取得胜利。自那以后,强化学习领域连续取得了一系列成就,如星际争霸游戏智能体 AlphaStar、Dota 2 游戏智能体 OpenAI Five、多人零和博弈德州扑克的 Pluribus、机器狗运动控制算法等。在这一系列科研成就的背后是整个强化学习领域算法在这些年内快速迭代进步的结果基于模拟器产生的大量数据使得对数据“饥饿”Data Hungry的深度神经网络能够表现出很好的拟合效果从而将强化学习算法的能力充分发挥出来在以上领域中达到或者超过人类专家的学习表现。目前强化学习已经从电子游戏逐步走向更广阔的应用场景如机器人控制、机械手灵巧操作、能源系统调度、网络负载分配、股票期货交易等一系列更加现实和富有意义的领域对传统控制方法和启发式决策理论发起冲击。
![强化学习框架](../img/ch12/ch12-rl.png)
:width:`400px`
:label:`ch12/ch12-rl-framework`
强化学习的核心是决策,即基于某个**状态**State下的**动作**Action的选择。进行这一决策的对象我们常称为**智能体**Agent而这一决策的影响将在**环境**Environment中体现。更具体地不同的决策会影响环境的**状态转移**State Transition,以及**奖励**Reward。以上过程可以抽象为 :numref:`ch12/ch12-rl-framework`所示,这是文献中最常见的强化学习的模型描述。举例来说,当人在玩某个电子游戏的时候,需要逐渐熟悉游戏的操作以取得更好的游戏结果,那么人从刚接触到这个游戏到逐步掌握游戏技巧的这个过程为一个类似于强化学习的过程。该游戏从开始后的任一时刻,会处于一个特定的状态,而人通过观察这个状态(如观察游戏机显示屏的图像)会获得一个**观察量**Observation并基于这个观察量做出一个操作动作这一动作将改变这个游戏状态使其转移到下一个状态并且玩家可以知道当前动作的效果如产生了一个正或负的分数。这时玩家再基于下一个状态的观察量做出新的动作选择周而复始直到游戏结束。通过反复的操作和观察人能够逐步掌握这个游戏的技巧一个强化学习智能体也是如此。这里注意有几个比较关键的问题1观察量未必等于状态而通常观察量是状态的函数从状态到观察量的映射可能有一定的信息损失。对于观察量等于状态或者根据观察量能够完全恢复环境状态的情况我们称为**完全可观测**Fully Observable否则我们称为**部分可观测**Partially Observable环境2玩家的每个动作未必会产生立即反馈某个动作可能在许多步之后才产生效果强化学习模型允许这种延迟反馈的存在3这种反馈对人的学习过程而言未必是个数字但是我们对强化学习智能体所得到的反馈进行数学抽象将其转变为一个数字称为奖励值。奖励值可以是状态的函数也可以是状态和动作的函数依具体问题而定。奖励值的存在是强化学习问题的一个基本假设也是现有强化学习算法训练智能体与监督式深度学习的一个主要区别
强化学习的核心是不断地与环境交互来优化策略从而提升奖励的过程,主要表现为基于某个**状态**State下的**动作**Action的选择。进行这一决策的对象我们常称为**智能体**Agent而这一决策的影响将在**环境**Environment中体现。更具体地不同的决策会影响环境的**状态转移**State Transition**奖励**Reward。以上状态转移是环境从当前状态转移到下一状态的函数,它可以是确定性也可以是随机性的。奖励是环境对智能体动作的反馈,通常是一个标量。以上过程可以抽象为 :numref:`ch12/ch12-rl-framework`所示,这是文献中最常见的强化学习的模型描述。
举例来说,当人在玩某个电子游戏的时候,需要逐渐熟悉游戏的操作以取得更好的游戏结果,那么人从刚接触到这个游戏到逐步掌握游戏技巧的这个过程为一个类似于强化学习的过程。该游戏从开始后的任一时刻,会处于一个特定的状态,而人通过观察这个状态会获得一个**观察量**Observation如观察游戏机显示屏的图像并基于这个观察量做出一个操作动作如发射子弹这一动作将改变这个游戏下一时刻的状态使其转移到下一个状态如把怪物打败了并且玩家可以知道当前动作的效果如产生了一个正或负的分数怪物打败了则获得正分数。这时玩家再基于下一个状态的观察量做出新的动作选择周而复始直到游戏结束。通过反复的操作和观察人能够逐步掌握这个游戏的技巧一个强化学习智能体也是如此。
这里注意,有几个比较关键的问题:一是观察量未必等于状态,而通常观察量是状态的函数,从状态到观察量的映射可能有一定的信息损失。对于观察量等于状态或者根据观察量能够完全恢复环境状态的情况,我们称为**完全可观测**Fully Observable否则我们称为**部分可观测**Partially Observable环境二是玩家的每个动作未必会产生立即反馈某个动作可能在许多步之后才产生效果强化学习模型允许这种延迟反馈的存在三是这种反馈对人的学习过程而言未必是个数字但是我们对强化学习智能体所得到的反馈进行数学抽象将其转变为一个数字称为奖励值。奖励值可以是状态的函数也可以是状态和动作的函数依具体问题而定。奖励值的存在是强化学习问题的一个基本假设也是现有强化学习与监督式学习的一个主要区别
强化学习的决策过程通常由一个马尔可夫决策过程Markov Decision ProcessMDP马尔可夫决策过程即一个后续状态只依赖当前状态和动作而不依赖于历史状态的函数描述可以用一个数组$(\mathcal{S}, \mathcal{A}, R, \mathcal{T}, \gamma)$来表示。$\mathcal{S}$和$\mathcal{A}$分别是状态空间和动作空间,$R$是奖励函数,$R(s,a)$: $\mathcal{S}\times \mathcal{A}\rightarrow \mathbb{R}$为对于当前状态$s\in\mathcal{S}$和当前动作$a\in\mathcal{A}$的奖励值。从当前状态和动作到下一个状态的状态转移概率定义为$\mathcal{T}(s^\prime|s,a)$: $\mathcal{S}\times\mathcal{A}\times\mathcal{S}\rightarrow \mathbb{R}_+$。$\gamma\in(0,1)$是奖励折扣因子(折扣因子可以乘到每个后续奖励值上,从而使无穷长序列有有限的奖励值之和)。强化学习的目标是最大化智能体的期望累计奖励值$\mathbb{E}[\sum_t \gamma^t r_t]$。
@@ -17,6 +21,7 @@ $$
\mathcal{T}(s_{t+1}|s_t) = \mathcal{T}(s_{t+1}|s_0, s_1, s_2, \dots, s_t)
$$
即当前状态转移只依赖于上一时刻状态,而不依赖于整个历史。这里的状态转移函数$\mathcal{T}$中省略了动作$a$因为马尔可夫性质是独立于决策过程的环境转移过程的属性。
即当前状态转移只依赖于上一时刻状态,而不依赖于整个历史。这里的状态转移函数$\mathcal{T}$中省略了动作$a$,马尔可夫性质是环境转移过程的属性,其独立于产生动作的决策过程
基于马尔可夫性质,可以进一步推导出在某一时刻最优策略不依赖于整个决策历史,而只依赖于当前最新状态的结论。这一结论在强化学习算法设计中有着重要意义,它简化了最优策略的求解过程。

View File

@@ -1,15 +1,21 @@
## 单节点强化学习系统
前面介绍了强化学习的基本知识和在系统层面的一般需求,这里我们介绍常见的单智能体强化学习系统中较为简单的一类,即单节点强化学习系统这里我们按照是否对模型训练和更新进行并行处理,将强化学习系统分为单节点和分布式强化学习系统。其中,单节点强化学习系统可以理解为只实例化一个类对象作为智能体,与环境交互进行采样和利用所采得的样本进行更新的过程分别视为这个类内的不同函数。除此之外的更为复杂的强化学习框架都可视为分布式强化学习系统。分布式强化学习系统的具体形式有很多,这也往往依赖于所实现的算法。从最简单的情况考虑,假设我们仍在同一个计算单元上实现算法,但是将强化学习的采样过程和更新过程实现为两个并行的进程,甚至各自实现为多个进程,以满足不同计算资源间的平衡。这时就需要进程间通信来协调采样和更新过程,这是一个最基础的分布式强化学习框架。更为复杂的情况是,整个算法的运行在多个计算设备上进行(如一个多机的计算集群),智能体的函数可能需要跨机跨进程间的通信来实现。对于多智能体系统,还需要同时对多个智能体的模型进行更新,则需要更为复杂的计算系统设计。我们将逐步介绍这些不同的系统内的实现机制。
前面介绍了强化学习的基本知识,这里我们介绍常见的单智能体强化学习系统中较为简单的一类,即单节点强化学习系统这里的节点是指一个用于模型更新的计算单元。我们按照是否对模型更新的过程做并行处理,将强化学习系统分为单节点和分布式强化学习系统。其中,单节点强化学习系统可以理解为只实例化一个类对象作为智能体,与环境交互进行采样和利用所采得的样本进行更新的过程分别视为这个类内的不同函数。除此之外的更为复杂的强化学习框架都可视为分布式强化学习系统。
我们先对单节点强化学习系统进行介绍
在这里我们以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 :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强化学习和离线Off-Policy强化学习两类按照某个特定判据。这个判据是用于更新的模型和用于采样的模型是否为同一个如果是则称在线强化学习算法否则为离线强化学习算法。因而离线强化学习通常允许与环境交互所采集的样本被存储于一个较大的缓存内从而允许在许久之后再从这个缓存中抽取样本对模型进行更新。而对于在线强化学习这个“缓存”有时其实也是存在的只不过它所存储的是非常近期内采集的数据从而被更新模型和用于采样的模型可以近似认为是同一个。从而这里我们简单表示 RLzoo 的强化学习系统统一包括这个经验回放缓存模块。有了以上策略和价值网络、经验回放缓存、适配器、学习器,我们就得到了 RLzoo 中一个单节点的强化学习智能体,将这个智能体与环境实例交互,并采集数据进行模型更新,我们就得到了一个完整的单节点强化学习系统。这里的环境实例化我们允许多个环境并行采样。
![RLzoo算法库中使用的强化学习系统](../img/ch12/ch12-rlzoo.png)
:width:`800px`
:label:`ch12/ch12-rlzoo`
近来研究人员发现强化学习算法领域的发展瓶颈可能不仅在于算法本身而在于智能体采集数据的模拟器的模拟速度。Isaac Gym :cite:`makoviychuk2021isaac`是Nvidia公司于2021年推出的基于GPUGraphics Processing Unit的模拟引擎在单GPU上实现2-3倍于之前基于CPUCentral Processing Unit的模拟器的运行速度。关于GPU上运行加速我们已经在章节5中有所介绍。之所以GPU模拟能够对强化学习任务实现显著的加速效果除了GPU本身多核心的并行运算能力之外还在于这省却了CPU与GPU之间的数据传输和通信时间。传统的强化学习环境如OpenAI Gym这是一个常用的强化学习基准测试环境都是基于CPU进行的模拟计算而深度学习方法的神经网络训练通常是在GPU或TPUTensor Processing Unit上进行的。从智能体与CPU上实例化的模拟环境交互过程所收集的数据样本通常先暂时以CPU的数据格式存储在使用的时候被转移到GPU上成为具有GPU数据类型的数据如使用PyTorch时可通过tensor.to(device)的函数实现只需将device设为“cuda”即可将一个类型为torch.Tensor的tensor转移到GPU上然后来进行模型训练。同时由于模型参数是以GPU上数据的类型存储的调用模型进行前向传递的过程中也需要先将输入数据从CPU转移到GPU上并且可能需要将模型输出的GPU数据再转移回CPU类型。这一系列冗余的数据转换操作都会显著增长模型学习的时间并且也增加了算法实际使用过程中的工程量。Isaac Gym模拟器的设计从底层上解决了这一困难由于模拟器和模型双双实现在GPU上他们之间的数据通信不再需要通过CPU来实现从而绕过了CPU与GPU数据双向传输这一问题实现了对强化学习任务中模拟过程的特定加速。
近来研究人员发现,强化学习算法领域的发展瓶颈,可能不仅在于算法本身,而在于智能体在其中采集数据的模拟器的模拟速度。Isaac Gym :cite:`makoviychuk2021isaac`是Nvidia公司于2021年推出的基于GPUGraphics Processing Unit的模拟引擎在单GPU上实现2-3倍于之前基于CPUCentral Processing Unit的模拟器的运行速度。关于 GPU上运行加速我们已经在章节 5 中有所介绍。之所以 GPU 模拟能够对强化学习任务实现显著的加
速效果,除了 GPU 本身多核心的并行运算能力之外,还在于这省却了 CPU 与 GPU 之间的数据传输和通信时间。传统的强化学习环境,如 OpenAI Gym这是一个常用的强化学习基准测试环境都是基于 CPU 进行的模拟计算,而深度学习方法的神经网络训练通常是在 GPU 或TPUTensor Processing Unit 上进行的。
从智能体与 CPU 上实例化的模拟环境交互过程所收集的数据样本,通常先暂时以 CPU 的数据格式存储,在使用的时候被转移到 GPU 上成为具有 GPU 数据类型的数据(如使用 PyTorch 时可通过tensor.to(device)的函数实现只需将device设为“cuda”即可将一个类型为torch.Tensor的tensor转移到GPU上然后来进行模型训练。同时由于模型参数是以 GPU 上数据的类型存储的,调用模型进行前向传递的过程中也需要先将输入数据从 CPU 转移到 GPU 上,并且可能需要将模型输出的 GPU 数据再转移回 CPU 类型。这一系列冗余的数据转换操作都会显著增长模型学习的时间并且也增加了算法实际使用过程中的工程量。Isaac Gym 模拟器的设计从模拟器下层运行硬件上解决了这一困难,由于模拟器和模型双双实现在 GPU 上,他们之间的数据通信不再需要通过 CPU 来实现,从而绕过了 CPU 与 GPU 数据双向传输这一问题,实现了对强化学习任务中模拟过程的特定加速。

View File

@@ -1,6 +1,6 @@
## 小结
在这一章我们简单介绍了强化学习的基本概念包括单智能体和多智能体强化学习算法、单节点和分布式强化学习系统等给读者对强化学习问题的基本认识。当前强化学习是一个快速发展的深度学习分支许多实际问题都有可能通过强化学习算法的进一步发展得到解决。另一方面由于强化学习问题设置的特殊性如需要与环境交互进行采样等也使得相应算法对计算系统的要求更高如何更好地平衡样本采集和策略训练过程如何均衡CPUGPU等不同计算硬件的能力如何在大规模分布式系统上有效部署强化学习智能体等等,都需要对计算机系统的设计和使用有更好的理解。
在这一章,我们简单介绍了强化学习的基本概念,包括单智能体和多智能体强化学习算法、单节点和分布式强化学习系统等,给读者对强化学习问题的基本认识。当前,强化学习是一个快速发展的深度学习分支,许多实际问题都有可能通过强化学习算法的进一步发展得到解决。另一方面,由于强化学习问题设置的特殊性(如需要与环境交互进行采样等),也使得相应算法对计算系统的要求更高:如何更好地平衡样本采集和策略训练过程?如何均衡 CPUGPU 等不同计算硬件的能力?如何在大规模分布式系统上有效部署强化学习智能体?都需要对计算机系统的设计和使用有更好的理解。
## 参考文献

BIN
img/ch05/SIMD.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
img/ch05/SIMT.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

BIN
img/ch05/poly.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
img/ch05/poly_test.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB