Merge branch 'openmlsys:main' into main
9
.github/workflows/main.yml
vendored
@@ -11,9 +11,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- uses: s-weigand/setup-conda@v1
|
||||
- name: Set up Python 3.8
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: '3.8'
|
||||
- run: conda config --append channels conda-forge
|
||||
- run: python3 -m pip install -r requirements.txt
|
||||
- run: sudo apt install -y pandoc
|
||||
- run: conda install -y pandoc==2.17
|
||||
|
||||
- run: |
|
||||
git clone https://github.com/openmlsys/d2l-book.git
|
||||
|
||||
36
.github/workflows/update_docs.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
|
||||
jobs:
|
||||
if_merged:
|
||||
if: github.event.pull_request.merged == true
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: s-weigand/setup-conda@v1
|
||||
- run: conda config --append channels conda-forge
|
||||
- run: python3 -m pip install -r requirements.txt
|
||||
- run: conda install -y pandoc==2.17
|
||||
- run: pip install sphinx-mathjax-offline
|
||||
|
||||
- run: |
|
||||
git clone https://github.com/openmlsys/d2l-book.git
|
||||
cd d2l-book
|
||||
python3 -m pip install .
|
||||
- run: sh build_html.sh
|
||||
- run: cd ..
|
||||
- run: git clone https://github.com/openmlsys/openmlsys.github.io.git
|
||||
- run: cp -r openmlsys-zh/_build/html/* openmlsys.github.io/docs/
|
||||
- run: |
|
||||
cd openmlsys.github.io
|
||||
git add .
|
||||
git commit -m 'update docs'
|
||||
git push
|
||||
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
|
||||
🔥 **书籍网页版:** [机器学习系统:设计和实现](https://openmlsys.github.io/)
|
||||
|
||||
🔥 **书籍PDF:** 将在勘误后,四月底发布
|
||||
🔥 **书籍PDF:** 将在勘误后,2022年中发布。
|
||||
|
||||
## 发布
|
||||
|
||||
- 27/06/2022: OpenMLSys社区发布通俗易懂的高性能AI算子开发教程,助力学生和工程师60分钟理解算子性能优化的关键知识点。相应的[技术博客](https://zhuanlan.zhihu.com/p/531498210)和[复现代码](https://github.com/openmlsys/openmlsys-cuda)都已免费公开。感谢@[Jie Ren](https://github.com/JieRen98) 和 @[Wenteng Liang](https://github.com/Went-Liang) 的贡献!🔥
|
||||
- 17/03/2022: 本书处于勘误阶段。如发现文字和图片错误,可创建Issue并@[章节编辑](info/editors.md)。我们非常欢迎社区提交PR直接勘误。
|
||||
|
||||
## 适用读者
|
||||
|
||||
9
build_html.sh
Normal file
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
rm -rf _build/rst _build/html
|
||||
d2lbook build rst
|
||||
cp static/frontpage.html _build/rst/
|
||||
d2lbook build html
|
||||
cp -r static/image/* _build/html/_images/
|
||||
@@ -1,12 +1,12 @@
|
||||
## 加速器基本组成原理
|
||||
|
||||
上节主要介绍了加速器的意义以及设计思路,了解到加速器与通用处理器在设计上的区别,因此加速器的硬件结构与CPU的硬件结构有着根本的不同,通常都是由多种片上缓存以及多种运算单元组成。本章节主要通过GPU的Volta架构作为样例进行介绍。
|
||||
上节主要介绍了加速器的意义以及设计思路,讲述了加速器与通用处理器在设计上的区别,可以看到加速器的硬件结构与CPU的硬件结构有着根本的不同,通常都是由多种片上缓存以及多种运算单元组成。本章节主要以GPU的Volta架构作为样例进行介绍。
|
||||
|
||||
### 硬件加速器的架构
|
||||
|
||||
现代GPU在十分有限的面积上实现了极强的计算能力和极高的储存器以及IO带宽。一块高端的GPU中,晶体管数量已经达到主流CPU的两倍,而且显存已经达到了16GB以上,工作频率也达到了1GHz。GPU的体系架构由两部分组成,分别是流处理阵列和存储器系统,两部分通过一个片上互联网络连接。流处理器阵列和存储器系统都可以单独扩展,规格可以根据产品的市场定位单独裁剪。如GV100的组成 :cite:`2017NVIDIA`如 :numref:`gv100`所示:
|
||||
现代GPU在十分有限的面积上实现了极强的计算能力和极高的储存器以及IO带宽。在一块高端的GPU中,晶体管数量已经达到主流CPU的两倍,而且显存已经达到了16GB以上,工作频率也达到了1GHz。GPU的体系架构由两部分组成,分别是流处理阵列和存储器系统,两部分通过一个片上互联网络连接。流处理器阵列和存储器系统都可以单独扩展,规格可以根据产品的市场定位单独裁剪。如GV100的组成 :cite:`2017NVIDIA`如 :numref:`gv100`所示:
|
||||
|
||||

|
||||

|
||||
:width:`800px`
|
||||
:label:`gv100`
|
||||
|
||||
@@ -17,69 +17,70 @@
|
||||
- 64个32位浮点运算单元
|
||||
- 64个32位整数运算单元
|
||||
- 32个64位浮点运算单元
|
||||
- 8个张量核
|
||||
- 8个张量计算核心
|
||||
- 4个纹理单元
|
||||
- 8个512-bit内存控制器
|
||||
- 8个512位内存控制器
|
||||
|
||||
一个完整的GV100 GPU含有84个SM,5376个32位浮点运算单元,5376个32位整型运算单元,2688个64位浮点运算单元,672个张量运算单元和336个纹理单元。一对内存控制器控制一个HBM2 DRAM堆栈。 :numref:`gv100`中展示的为带有84个SM的GV100 GPU(不同的厂商可以使用不同的配置),Tesla V100则含有80个SM。
|
||||
|
||||
### 硬件加速器的存储单元
|
||||
|
||||
与传统的CPU模型相似,从一个计算机系统主内存DRAM中获取数据的速度相对于处理器的运算速度较慢。对于加速器而言,如果没有缓存进行快速存取,DRAM的带宽非常不足。如果无法快速的在DRAM上获取程序和数据,加速器将因空置而降低利用率。为了缓解DRAM的带宽问题,GPU提供了不同层次的若干区域供程序员存放数据,每块区域的内存都有自己的最大带宽以及延迟。
|
||||
与传统的CPU模型相似,从一个计算机系统主内存DRAM中获取数据的速度相对于处理器的运算速度较慢。对于加速器而言,如果没有缓存进行快速存取,DRAM的带宽非常不足。如果无法快速地在DRAM上获取程序和数据,加速器将因空置而降低利用率。为了缓解DRAM的带宽问题,GPU提供了不同层次的若干区域供程序员存放数据,每块区域的内存都有自己的最大带宽以及延迟。开发者需根据不同存储器之间的存储速度的数量级的变化规律,选用适当类型的内存以及最大化地利用它们,从而发挥硬件的最大算力,减少计算时间。
|
||||
|
||||
- **寄存器文件(Register File)**:片上最快的存储器,但与CPU不同,GPU的每个SM(流多处理器)有上万个寄存器。但当每个线程使用过多的寄存器时,SM中能够调度的线程块数量就会受到限制,可执行的线程总数量会因此受到限制,可执行的线程数量过少会造成硬件无法充分的利用,性能急剧下降。所以要根据算法的需求合理使用寄存器。
|
||||
- **寄存器文件(Register File)**:片上最快的存储器,但与CPU不同,GPU的每个SM(流多处理器)有上万个寄存器。尽管如此当每个线程使用过多的寄存器时,SM中能够调度的线程块数量就会受到限制,可执行的线程总数量会因此受到限制,可执行的线程数量过少会造成硬件无法充分的利用,性能急剧下降。所以要根据算法的需求合理使用寄存器。
|
||||
|
||||
- **共享内存(Shared Memory)**:共享内存实际上是用户可控的一级缓存,每个SM(流多处理器)中有128KB的一级缓存, 开发者可根据应用程序需要配置最大96KB的一级缓存作为共享内存。共享内存的访存延迟极低,只有几十个时钟周期。共享内存具有高达1.5TB/s的带宽,远远高于全局内存的峰值带宽900GB/s。共享内存的使用对于高性能计算工程师来说是一个必须要掌握的概念。
|
||||
|
||||
- **共享内存(Shared Memory)**:共享内存实际上是用户可控的一级缓存,每个SM(流多处理器)中有128KB的一级缓存, 开发者可根据应用程序需要配置最大96KB的一级缓存作为共享内存。共享内存的延迟极低,只有几十个时钟周期,几乎与寄存器相当。共享内存具有高达1.5TB/s的带宽,远远高于全局内存的峰值带宽900GB/s。所以说,共享内存的使用对于一个高性能计算工程师来说是一个必须要掌握的一个概念。
|
||||
- **全局内存(Global Memory)**:全局内存之所以称为全局,是因为GPU与CPU都可以对它进行读写操作。全局内存对于GPU中的每个线程都是可见的,都可以直接对全局内存进行读写操作。CPU等其他设备可以通过PCI-E总线对其进行读写操作。全局内存也是GPU中容量最大的一块内存,可达16GB之多。同时也是延迟最大的内存,通常有高达上百个时钟周期的访存延迟。
|
||||
|
||||
- **全局内存(Global Memory)**:全局内存之所以称为全局,是因为GPU与CPU都可以对它进行读写操作。全局内存对于GPU中的每个线程都是可见的,都可以直接对全局内存进行读写操作。CPU等其他设备可以通过PCI-E总线对其进行读写操作。全局内存也是GPU中容量最大的一块内存,可达16GB之多。同时也是延迟最大的内存,
|
||||
- **常量内存(Constant Memory)**:常量内存其实只是全局内存的一种虚拟地址形式,并没有真正的物理硬件内存块。常量内存有两个特性,一个是高速缓存,另一个更重要的特性是它支持将某个单个值广播到线程束中的每个线程中。
|
||||
|
||||
- **常量内存(Constant Memory)**:常量内存其实只是全局内存的一种虚拟地址形式,并没有真正的物理硬件内存块。常量内存有两个特性,一个高速缓存,另一个更重要的特性是它支持将某个单个值广播到线程束中的每个线程中。
|
||||
- **纹理内存(Texture Memory)**:纹理内存是全局内存的一个特殊形态。当全局内存被绑定为纹理内存时,执行读写操作将通过专用的纹理缓存来加速。在早期的GPU上没有缓存,因此每个SM上的纹理内存为设备提供了唯一真正缓存数据的方法。然而随着硬件的升级,一级缓存和二级缓存的出现使得纹理缓存的这项优势已经荡然无存。纹理内存的另外一个特性,也是最有用的特性就是当访问存储单元时,允许GPU实现硬件相关的操作。比如说使用纹理内存,可以通过归一化的地址对数组进行访问,获取的数据可以通过硬件进行自动插值,从而达到快速处理数据的目的。此外对于二维数组和三维数组,支持硬件级的双线性插值与三线性插值。纹理内存另一个实用的特性是可以根据数组的索引自动处理边界条件,不需要对特殊边缘进行处理即可完成数组内元素操作,从而防止线程中分支的产生。
|
||||
|
||||
- **纹理内存(Texture Memory)**:纹理内存是全局内存的一个特殊形态。当全局内存被绑定为纹理内存时,执行读写操作将通过专用的纹理缓存来加速。在早期的GPU上没有缓存,因此每个SM(流多处理器)上的纹理内存为设备提供了唯一真正缓存数据的方法。然而随着硬件的升级,一级缓存和二级缓存的出现,纹理缓存的这项优势已经荡然无存。纹理内存的另外一个特性,也是最有用的特性就是当访问存储单元时,允许GPU实现硬件相关的操作。比如说使用纹理内存,可以通过归一化的地址对数组进行访问,获取的数据可以通过硬件进行自动插值,从而达到快速处理数据的目的。此外对于二维数组和三维数组,支持硬件级的双线性插值与三线性插值。纹理内存另一个实用的特性是可以根据数组的索引自动处理边界条件,不需要对特殊边缘进行处理即可完成数组内元素操作,从而防止线程中分支的产生。
|
||||
|
||||
由于寄存器的高速读取特性,因此每次计算都离不开寄存器的参与。接着是一级缓存和共享内存,然后是常量内存、纹理内存、全局内存,最后则是主机端内存。根据不同存储器之间的存储速度的数量级的变化规律,选用适当类型的内存以及最大化地利用它们,从而发挥硬件的最大算力,减少计算时间。
|
||||
|
||||
### 硬件加速器的计算单元
|
||||
:label:`compute-unit-title`
|
||||
|
||||
为了支持不同的神经网络模型,加速器会提供以下几种计算单元,不同的网络层可以根据需要选择使用对应的计算单元。如 :numref:`compute-unit`所示
|
||||
为了支持不同的神经网络模型,加速器会提供以下几种计算单元,不同的网络层可以根据需要选择使用合适的计算单元,如 :numref:`compute-unit`所示
|
||||
|
||||
- **标量计算单元**:与标准的精简指令运算集(Reduced Instruction Set Computer,RISC)相似,一次计算一个标量元素。
|
||||
|
||||
- **一维向量计算单元**:一次可以完成多个元素的计算,与传统的CPU和GPU架构中单指令多数据(SIMD)相似,已广泛应用于高性能计算(High Performance Computing,HPC)和信号处理中。
|
||||
- **一维向量计算单元**:一次可以完成多个元素的计算,与传统的CPU和GPU架构中单指令多数据(SIMD)相似,已广泛应用于高性能计算(High Performance Computing,HPC)和信号处理中。
|
||||
|
||||
- **二维向量计算单元**:一次运算可以完成一个矩阵与向量的内积,或向量的外积。利用数据重复使用这一特性,降低数据通信成本与存储空间,更高效的提高矩阵乘法性能。
|
||||
|
||||
- **三维向量计算单元**:一次完成一个矩阵的乘法,专为神经网络应用设计的计算单元,更充分利用数据重复特性,隐藏数据通信带宽与数据计算的差距。
|
||||
|
||||

|
||||

|
||||
:width:`800px`
|
||||
:label:`compute-unit`
|
||||
|
||||
GPU计算单元主要由标量计算单元和三维向量计算单元组成。如 :numref:`SM`所示,对于每个SM,其中64个32位浮点运算单元、64个32位整数运算单元、32个64位浮点运算单元均为标量计算单元。而8个张量核则是专为神经网络应用设计的三维向量计算单元。
|
||||
GPU计算单元主要由标量计算单元和三维向量计算单元组成。如 :numref:`SM`所示,对于每个SM,其中64个32位浮点运算单元、64个32位整数运算单元、32个64位浮点运算单元均为标量计算单元。而8个张量计算核心则是专为神经网络应用设计的三维向量计算单元。
|
||||
|
||||

|
||||

|
||||
:width:`800px`
|
||||
:label:`SM`
|
||||
|
||||
张量核(Tensor Core)每个时钟周期完成一次$4\times4$的矩阵乘累加计算,如 :numref:`tensorcore`:
|
||||
张量计算核心每个时钟周期完成一次$4\times4$的矩阵乘累加计算,如 :numref:`tensorcore`所示:
|
||||
|
||||
```cpp
|
||||
D = A * B + C
|
||||
```
|
||||
|
||||

|
||||

|
||||
:width:`800px`
|
||||
:label:`tensorcore`
|
||||
|
||||
其中A,B,C和D都是$4\times4$的矩阵,矩阵乘累加的输入矩阵A和B是FP16的矩阵,累加矩阵C和D可以是FP16也可以是FP32。 V100的张量核是可编程的矩阵乘法和累加计算单元,可以提供多达125 Tensor TFLOPS(Tera Floating-point Operations Per Second)的训练和推理应用。相比于普通的FP32计算单元可以提速10倍以上。
|
||||
其中A,B,C和D都是$4\times4$的矩阵,矩阵乘累加的输入矩阵A和B是FP16的矩阵,累加矩阵C和D可以是FP16也可以是FP32。 V100的张量计算核心是可编程的矩阵乘法和累加计算单元,可以提供多达125 Tensor TFLOPS(Tera Floating-point Operations Per Second)的训练和推理应用。相比于普通的FP32计算单元可以提速10倍以上。
|
||||
|
||||
### DSA芯片架构
|
||||
|
||||
为了满足飞速发展的深度神经网络对芯片算力的需求,业界也纷纷推出了特定领域架构DSA芯片设计。以华为公司昇腾系列AI处理器为例,本质上是一个片上系统(System on Chip,SoC),主要应用在图像、视频、语音、文字处理相关的场景。主要的架构组成部件包括特制的计算单元、大容量的存储单元和相应的控制单元。该芯片由以下几个部分构成:芯片系统控制CPU(Control CPU),AI计算引擎(包括AI Core和AI CPU),多层级的片上系统缓存(Cache)或缓冲区(Buffer),数字视觉预处理模块(Digital Vision Pre-Processing,DVPP)等。
|
||||
为了满足飞速发展的深度神经网络对芯片算力的需求,业界也纷纷推出了特定领域架构DSA芯片设计。以华为公司昇腾系列AI处理器为例,本质上是一个片上系统(System on Chip,SoC),主要应用在图像、视频、语音、文字处理相关的场景。主要的架构组成部件包括特制的计算单元、大容量的存储单元和相应的控制单元。该芯片由以下几个部分构成:芯片系统控制CPU(Control CPU)、AI计算引擎(包括AI Core和AI CPU)、多层级的片上系统缓存(Cache)或缓冲区(Buffer)、数字视觉预处理模块(Digital Vision Pre-Processing,DVPP)等。
|
||||
|
||||
昇腾AI芯片的计算核心主要由AI Core构成,负责执行标量、向量和张量相关的计算密集型算子。AI Core采用了达芬奇架构 :cite:`2021Ascend`,基本结构如 :numref:`davinci_architecture`所示,从控制上可以看成是一个相对简化的现代微处理器基本架构。它包括了三种基础计算单元:矩阵计算单元(Cube Unit)、向量计算单元(Vector Unit)和标量计算单元(Scalar Unit)。这三种计算单元分别对应了张量、向量和标量三种常见的计算模式,在实际的计算过程中各司其职,形成了三条独立的执行流水线,在系统软件的统一调度下互相配合达到优化计算效率的目的。
|
||||
同GPU类似,在矩阵乘加速设计上,在AICore中也提供了矩阵计算单元作为昇腾AI芯片的核心计算模块,意图高效解决矩阵计算的瓶颈问题。矩阵计算单元提供强大的并行乘加计算能力,可以用一条指令完成两个$16\times16$矩阵的相乘运算,等同于在极短时间内进行了$16\times16\times16=4096$个乘加运算,并且可以实现FP16的运算精度。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`davinci_architecture`
|
||||
|
||||
昇腾AI芯片的计算核心主要由AI Core构成,负责执行标量、向量和张量相关的计算密集型算子。AI Core采用了达芬奇架构 :cite:`2021Ascend`,基本结构如 :numref:`davinci_architecture`所示,从控制上可以看成是一个相对简化的现代微处理器基本架构。它包括了三种基础计算单元:矩阵计算单元(Cube Unit)、向量计算单元(Vector Unit)和标量计算单元(Scalar Unit)。这三种计算单元分别对应了张量、向量和标量三种常见的计算模式,在实际的计算过程中各司其职,形成了三条独立的执行流水线,在系统软件的统一调度下互相配合达到优化计算效率的目的。 同GPU类似,在矩阵乘加速设计上,在AICore中也提供了矩阵计算单元作为昇腾AI芯片的核心计算模块,意图高效解决矩阵计算的瓶颈问题。矩阵计算单元提供强大的并行乘加计算能力,可以用一条指令完成两个$16\times16$矩阵的相乘运算,等同于在极短时间内进行了$16\times16\times16=4096$个乘加运算,并且可以实现FP16的运算精度。
|
||||
|
||||
@@ -2,17 +2,16 @@
|
||||
|
||||
### 硬件加速器设计的意义
|
||||
|
||||
未来人工智能发展的三大核心要素是数据、算法和算力。目前,人工智能系统算力大都构建在CPU+GPU之上,主体多是GPU。随着神经网络层数的增多,模型体量的增大,算法复杂度的上升,CPU和GPU很难再满足新型网络对于算力的需求。例如,2015年谷歌的AlphaGo与[樊麾](https://baike.baidu.com/item/樊麾)对弈时,用了1202个CPU和176个GPU,每盘棋需要消耗上千美元的电费,而与之对应的是樊麾的功耗仅为20瓦。
|
||||
未来人工智能发展的三大核心要素是数据、算法和算力。目前,人工智能系统算力大都构建在CPU和GPU之上且主体多是GPU。随着神经网络的层增多,模型体量增大,算法趋于复杂,CPU和GPU很难再满足新型网络对于算力的需求。例如,2015年谷歌的AlphaGo用了1202个CPU和176个GPU打败了人类职业选手,每盘棋需要消耗上千美元的电费,而与之对应的是人类选手的功耗仅为20瓦。
|
||||
|
||||
虽然GPU在面向向量、矩阵以及张量的计算上,引入许多新颖的优化设计,但由于GPU需要支持的计算类型复杂,芯片规模大、能耗高,人们开始将更多的精力转移到深度学习硬件加速器的设计上来。和传统CPU和GPU芯片相比,深度学习硬件加速器有更高的性能和更低的能耗。未来随着人们真正进入智能时代,智能应用的普及会越来越广泛,到那时每台服务器、每台智能手机和每个智能摄像头,都需要使用深度学习加速器。
|
||||
|
||||
虽然GPU在面向向量、矩阵以及张量的计算上,引入许多新颖的优化设计,但由于GPU需要支持的计算类型复杂,芯片规模大、能耗高,人们开始将更多的精力转移到深度学习硬件加速器的设计上来。和传统CPU和GPU芯片相比,新型深度学习加速器会有更高的性能,以及更低的能耗。未来随着人们真正进入智能时代,智能应用的普及会越来越广泛,到那时每台服务器、每台智能手机、每个智能摄像头,都需要使用加速器。
|
||||
|
||||
### 硬件加速器设计的思路
|
||||
:label:`accelerator-design-title`
|
||||
|
||||
近些年来,计算机体系结构的研究热点之一就是深度学习硬件加速器的设计。在体系结构的研究中,能效和通用性是两个重要的衡量指标。能效关注单位能耗下基本计算的次数,通用性主要指芯片能够覆盖的任务种类。
|
||||
近些年来,计算机体系结构的研究热点之一是深度学习硬件加速器的设计。在体系结构的研究中,能效和通用性是两个重要的衡量指标。其中能效关注单位能耗下基本计算的次数,通用性主要指芯片能够覆盖的任务种类。以两类特殊的芯片为例:一种是较为通用的通用处理器(如CPU),该类芯片理论上可以完成各种计算任务,但是其能效较低大约只有0.1TOPS/W。另一种是专用集成电路(Application Specific Integrated Circuit, ASIC),其能效更高,但是支持的任务相对而言就比较单一。对于通用的处理器而言,为了提升能效,在芯片设计上引入了许多加速技术,例如:超标量技术、单指令多数据(Single Instruction Multiple Data,SIMD)技术以及单指令多线程(Single Instruction Multiple Threads,SIMT)技术等。
|
||||
|
||||
以两类特殊的芯片为例:一种是我们较为熟悉的通用处理器(如CPU),该类芯片理论上可以完成各种计算任务,但是其能效较低大约只有0.1TOPS/W;另一种是专用集成电路(Application Specific Integrated Circuit, ASIC),其能效更高,但是支持的任务相对而言就比较单一。对于通用的处理器而言,为了提升能效,在芯片设计上有许多加速技术的引入,例如:超标量技术、单指令多数据(Single Instruction Multiple Data,SIMD)技术以及单指令多线程(Single Instruction Multiple Threads,SIMT)技术等。
|
||||
对于不同的加速器设计方向,业界也有不同的硬件实现。针对架构的通用性,NVIDIA持续在GPU芯片上发力,先后推出了Volta、 Turing、 Ampere等架构,并推出用于加速矩阵计算的张量计算核心(Tensor Core),以满足深度学习海量算力的需求。
|
||||
|
||||
对于不同的加速器设计方向,业界也有不同的硬件实现。针对架构的通用性,NVIDIA持续在其GPU芯片上发力,先后推出了Volta, Turing, Ampere架构,并推出用于加速矩阵计算的张量核(Tensor Core),以满足深度学习海量算力的需求。
|
||||
|
||||
对于偏定制化的硬件架构,面向深度学习计算任务,业界提出了特定领域架构(Domain Specific Architecture)。 Google公司推出了TPU芯片,专门用于加速深度学习计算任务,其使用脉动阵列(Systolic Array)来优化矩阵乘法和卷积运算,可以充分地利用数据局部性,降低对内存的访问次数。华为也推出了自研的昇腾AI处理器,旨在为用户提供更高能效的算力和易用的开发、部署体验,其中的CUBE运算单元,就用于加速矩阵乘法的计算。
|
||||
对于偏定制化的硬件架构,面向深度学习计算任务,业界提出了特定领域架构(Domain Specific Architecture, DSA)。Google公司推出了TPU芯片,专门用于加速深度学习计算任务,其使用脉动阵列(Systolic Array)来优化矩阵乘法和卷积运算,可以充分地利用数据局部性,降低对内存的访问次数。华为也推出了自研昇腾AI处理器,旨在为用户提供更高能效的算力和易用的开发、部署体验,其中的CUBE运算单元,就用于加速矩阵乘法的计算。
|
||||
|
||||
289
chapter_accelerator/accelerator_practise.md
Normal file
@@ -0,0 +1,289 @@
|
||||
## 加速器实践
|
||||
|
||||
在本节中会通过具体的CUDA代码向读者介绍如何编写一个并行计算的广义矩阵乘法程序,通过提高计算强度、使用共享内存、优化内存读取流水线等方法最终取得接近硬件加速器性能峰值的实现。虽然在以上章节介绍了张量计算核心相关的内容,但由于篇幅限制,在本节中不使用此硬件结构。而是通过使用更为基本的CUDA代码实现FP32的广义矩阵乘法,来讲解若干实用优化策略。
|
||||
### 环境
|
||||
|
||||
本节的实践有以下的软件环境依赖:
|
||||
|
||||
* Eigen:Eigen是一个线性代数C++模板库,用户可以只使用几条语句完成多线程线性代数运算。
|
||||
* OpenMP(可选):OpenMP是用于共享内存并行系统的多处理器程序设计的一套指导性编译处理方案,可以使用OpenMP对Eigen的计算进行加速。
|
||||
* CUDA Toolkit:CUDA Toolkit是英伟达发布的CUDA工具包,其包含了CUDA编译器(NVCC),CUDA线性代数库(cuBLAS)等组件。
|
||||
本节的实践都是在CPU Intex Xeon E5-2650 v3,GPU Nvidia Geforce RTX 3080;系统Ubuntu 18.04版本,CUDA Toolkit 11.1进行的。
|
||||
|
||||
安装相关依赖如下:
|
||||
|
||||
* Eigen:Eigen的安装可以通过使用包管理器安装(如使用指令`apt install libeigen3-dev`),也可以从[官网](https://eigen.tuxfamily.org/index.php?title=Main_Page)下载。
|
||||
* OpenMP(可选):通常会被大多数编译器默认支持,如果没有被支持的话可以使用包管理器安装(如使用指令`apt install libomp-dev`)。
|
||||
* CUDA Toolkit:CUDA Toolkit的安装建议按照[官方的提示](https://developer.nvidia.com/cuda-downloads)安装,也可以通过使用包管理器安装(如使用指令`apt install cuda`)。
|
||||
|
||||
### 广义矩阵乘法的朴素实现
|
||||
|
||||
:label:`sec-accelerator-naive`
|
||||
|
||||
依照算法:label:`algo-accelerator-gemm`,编写CPU代码如下所示:
|
||||
```c++
|
||||
float A[M][K];
|
||||
float B[K][N];
|
||||
float C[M][N];
|
||||
float alpha, beta;
|
||||
|
||||
for (unsigned m = 0; m < M; ++m) {
|
||||
for (unsigned n = 0; n < N; ++n) {
|
||||
float c = 0;
|
||||
for (unsigned k = 0; k < K; ++k) {
|
||||
c += A[m][k] * B[k][n];
|
||||
}
|
||||
C[m][n] = alpha * c + beta * C[m][n];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
可以看到,矩阵$C$ 中各个元素的计算是独立的。可以利用GPU的大量线程去分别计算矩阵$C$ 中相应的元素,以达到并行计算的目的,GPU核函数将如下所示:
|
||||
|
||||
```c++
|
||||
__global__ void gemmKernel(const float * A,
|
||||
const float * B, float * C,
|
||||
float alpha, float beta, unsigned M, unsigned N,
|
||||
unsigned K) {
|
||||
unsigned int m = threadIdx.x + blockDim.x * blockIdx.x;
|
||||
unsigned int n = threadIdx.y + blockDim.y * blockIdx.y;
|
||||
if (m >= M || n >= N)
|
||||
return;
|
||||
float c = 0;
|
||||
for (unsigned k = 0; k < K; ++k) {
|
||||
c += A[m * K + k] * B[k * N + n];
|
||||
}
|
||||
c = c * alpha;
|
||||
float result = c;
|
||||
if (beta != 0) {
|
||||
result = result + C[m * N + n] * beta;
|
||||
}
|
||||
C[m * N + n] = result;
|
||||
}
|
||||
```
|
||||
|
||||
其可视化结构如 :numref:`cuda_naive_gemm`所示,矩阵$C$中每一个元素由一个线程计算,在GPU Kernel的第5和6行计算该线程对应矩阵$C$中的元素行号$m$及列号$n$,然后在第9到11行该线程利用行号与列号读取矩阵$A$和矩阵$B$中相应的行列向量元素并计算向量内积,最后在第17行将结果写回$C$矩阵。
|
||||
|
||||

|
||||
:width:` 800px`
|
||||
:label:`cuda_naive_gemm`
|
||||
|
||||
使用以下代码启动核函数:
|
||||
|
||||
```c++
|
||||
void gemmNaive(const float *A, const float *B, float *C,
|
||||
float alpha, float beta, unsigned M,
|
||||
unsigned N, unsigned K) {
|
||||
dim3 block(16, 16);
|
||||
dim3 grid((M - 1) / block.x + 1, (N - 1) / block.y + 1);
|
||||
|
||||
gemmKernel<<<grid, block>>>(A, B, C, alpha, beta, M, N, K);
|
||||
}
|
||||
```
|
||||
|
||||
在这里令每个线程块处理矩阵$C$中$16\times16$个元素,因此开启$(M - 1) / 16 + 1 \times (N - 1) / 16 + 1$个线程块用于计算整个矩阵$C$。
|
||||
|
||||
|
||||
使用Eigen生成数据并计算得到CPU端的广义矩阵乘法结果,同时实现了GPU端计算结果的误差计算、时间测试的代码,详情见[first_attempt.cu](https://github.com/openmlsys/openmlsys-cuda/blob/main/first_attempt.cu),编译及执行得到输出结果为:
|
||||
|
||||
|
||||
```
|
||||
Average Time: 48.961 ms
|
||||
Max Error: 0.000092
|
||||
```
|
||||
|
||||
可以使用以下公式粗略的计算GPU的峰值吞吐量:2$\times$频率$\times$单精度计算单元数量 ,其中单精度计算单元数量等于GPU中流多处理器(SM)数量乘每个流多处理器中单精度计算单元数量,计算可以得到以下结果:
|
||||
|
||||
```
|
||||
FP32 peak throughput 29767.680 GFLOPS
|
||||
Average Throughput: 185.313 GFLOPS
|
||||
```
|
||||
可以发现目前的代码距离设备峰值性能仍有较大的差距。在整个计算过程中计算密集最大的过程为矩阵乘法$A\times B$,其时间复杂度为$O(M*N*K)$,而整个计算过程时间复杂度为$O(M*N*K+2*M*N)$,因此对矩阵乘法的优化是提升性能的关键。
|
||||
|
||||
|
||||
### 提高计算强度
|
||||
|
||||
计算强度(Compute Intensity)指计算指令数量与访存指令数量的比值,在现代GPU中往往有大量计算单元但只有有限的访存带宽,程序很容易出现计算单元等待数据读取的问题,因此提高计算强度是提升程序性能的一条切实有效的指导思路。对于之前实现的GPU核函数,可以粗略计算其计算强度:在$K$次循环的内积计算中,对矩阵$A$与矩阵$B$的每次读取会计算一次浮点乘法与浮点加法,因此计算强度为1——两次浮点运算除以两次数据读取。之前的版本是每个线程负责处理矩阵$C$的一个元素——计算矩阵$A$的一行与矩阵$B$的一列的内积,可以通过使每个线程计算$C$更多的元素——计算矩阵$A$的多行与矩阵$B$的多列的内积——从而提升计算强度。具体地,如果在$K$次循环的内积计算中一次读取矩阵$A$中的$m$个元素和矩阵$B$中的$n$个元素,那么访存指令为$m+n$条,而计算指令为$2mn$条,所以计算强度为$\frac{2mn}{m+n}$,因此可以很容易发现提高$m$和$n$会带来计算强度的提升。
|
||||
|
||||
在上一小节中对全局内存的访问与存储都是借助 `float` 指针完成的,具体到硬件指令集上实际是使用指令 `LDG.E` 与 `STG.E` 完成的。可以使用128位宽指令`LDG.E.128` 与 `STG.E.128` 一次读取多个 `float` 数。使用宽指令的好处是一方面简化了指令序列,使用一个宽指令代替四个标准指令可以节省十几个指令的发射周期,这可以为计算指令的发射争取到额外的时间;另一方面128比特正好等于一个cache line的长度,使用宽指令也有助于提高cache line的命中率。但并不提倡在一切代码中过度追求宽指令的使用,开发者应当将更多的时间关注并行性设计和局部数据复用等更直接的优化手段。
|
||||
|
||||
具体的实现如下,由于每个 `float` 类型大小为32个比特,可以将4个 `float` 堆叠在一起构成一个128比特的 `float4` 类,对 `float4` 的访存将会是使用宽指令完成。其具体代码实现见[util.cuh](https://github.com/openmlsys/openmlsys-cuda/blob/main/util.cuh)中。
|
||||
|
||||
在实现GPU核函数过程中要注意,每个线程需要从原本各读取矩阵$A$和矩阵$B$中一个 `float` 数据变为各读取4个 `float` 数据,这就要求现在每个线程负责处理矩阵$C$中$4\times 4$的矩阵块,称之为 `thread tile` 。如图:numref:`use_float4`所示,每个线程从左到右、从上到下分别读取矩阵$A$和矩阵$B$的数据并运算,最后写入到矩阵$C$中。
|
||||
|
||||
|
||||

|
||||
:width:` 800px`
|
||||
:label:`use_float4`
|
||||
|
||||
|
||||
完整代码见[gemm_use_128.cu](https://github.com/openmlsys/openmlsys-cuda/blob/main/gemm_use_128.cu)。我们可以进一步让每个线程处理更多的数据,从而进一步提升计算强度,如图:numref:`use_tile`所示。完整代码见[gemm_use_tile.cu](https://github.com/openmlsys/openmlsys-cuda/blob/main/gemm_use_tile.cu)。
|
||||
|
||||

|
||||
:width:` 800px`
|
||||
:label:`use_tile`
|
||||
|
||||
测试得到以下结果:
|
||||
|
||||
```
|
||||
Max Error: 0.000092
|
||||
Average Time: 6.232 ms, Average Throughput: 1378.317 GFLOPS
|
||||
```
|
||||
|
||||
使用分析工具Nsight Compute分析取得性能提升的具体原因。Nsight Compute是英伟达发布的主要针对GPU核函数的性能分析工具,它通过劫持驱动的方式对GPU底层数据采样和输出。可以使用以下指令进行性能分析:
|
||||
|
||||
```bash
|
||||
bash
|
||||
ncu --set full -o <profile_output_file> <profile_process>
|
||||
```
|
||||
`--set full` 代表采样所有数据, `-o` 代表以文件的形式输出结果; `<profile_output_file>` 填输出文件名但注意不要加后缀名, `<profile_process>` 填待分析的可执行文件及其参数。
|
||||
比如需要分析 `first_attempt` ,将输出结果命名为 `first_attepmt_prof_result` 可以使用以下指令:
|
||||
|
||||
```c++
|
||||
ncu --set full -o first_attepmt_prof_result ./first_attempt
|
||||
```
|
||||
如果提示权限不足可以使在指令前加`sudo` 。
|
||||
在得到输出文件之后,可以使用 `nv-nsight-cu` 查看文件。对改动的GPU核函数与上一版本的GPU核函数进行对比分析,发现:
|
||||
|
||||
首先 `LDG` 指令数量下降了84%,且指标 `Stall LG Throttle` 下降33%,说明使用宽指令增加计算密度确实可以通过减少全局内存访问的指令数目而减少发射等待时间。最后指标 `Arithmetic Intensity` 的提升也和之前的关于计算强度的分析相吻合。
|
||||
|
||||
我们对`gemm_use_tile.cu`测试得到以下结果:
|
||||
|
||||
```
|
||||
Max Error: 0.000092
|
||||
Average Time: 3.188 ms, Average Throughput: 2694.440 GFLOPS
|
||||
```
|
||||
|
||||
使用Nsight Compute分析发现:类似地,本次优化在 `Stall LG Throttle` 等指标上取得了进一步的提升。
|
||||
|
||||
### 使用共享内存缓存复用数据
|
||||
|
||||
:label:`sec-accelerator-use-smem`
|
||||
|
||||
虽然令一个线程一次读取更多的数据能取得计算强度的提升进而带来性能的提升,但是这种令单个线程处理数据增多的设计会导致开启总的线程数量减少,进而导致并行度下降,因此需要使用其他硬件特性在尽可能不影响并行度的前提下取得性能提升。在之前的代码中,开启若干个线程块,每个线程块处理矩阵$C$中的一个或多个矩阵块。在 :numref:`duplicated_data` 中,可以观察到,处理矩阵$C$同一行的线程$x, y$会读取矩阵$A$中相同的数据,可以借助共享内存让同一个线程块中不同的线程读取不重复的数据而提升程序吞吐量。
|
||||
|
||||

|
||||
:width:` 800px`
|
||||
:label:`duplicated_data`
|
||||
|
||||
具体地,需要对代码进行如下改造:首先此前代码在计算内积过程是进行$K$次循环读取数据并累加计算,在此设定下每次循环中处理矩阵$C$中相同行的线程会读取相同的矩阵$A$的数据,处理矩阵$C$中相同列的线程会读取相同的矩阵$B$的数据。可以通过将此$K$次循环拆解成两层循环,外层循环$\frac{K}{tileK}$次,每次外层循环的迭代读取一整块数据,内层循环$tileK$次进行累加数据。数据从全局内存向共享内存的搬运过程如图 :numref:`use_smem_store` 所示,每次内层循环开始前将矩阵$A$和矩阵$B$中一整个 `tile` 读取到共享内存中;数据从共享内存到寄存器的搬运如图 :numref:`use_smem_load` 所示,每次内层循环循环从共享内存读取数据并计算。这种设计带来的好处是,可以让每个线程不必独自从全局内存读取所有需要的数据,整个线程块将共同需要的数据从全局内存中读取并写入到共享内存中,此后每个线程在计算过程中只需要从共享内存中读取所需要的数据即可。
|
||||
|
||||
|
||||

|
||||
:width:` 800px`
|
||||
:label:`use_smem_store`
|
||||
|
||||

|
||||
:width:` 800px`
|
||||
:label:`use_smem_load`
|
||||
|
||||
完整代码见[gemm_use_smem.cu](https://github.com/openmlsys/openmlsys-cuda/blob/main/gemm_use_smem.cu)。
|
||||
|
||||
测试得到以下结果:
|
||||
|
||||
```
|
||||
Max Error: 0.000092
|
||||
Average Time: 0.617 ms, Average Throughput: 13925.168 GFLOPS
|
||||
```
|
||||
|
||||
通过使用Nsight Compute对核函数分析并与上一个核函数进行对比,可以观察到一些主要的变化:首先 `LDG` 指令数量下降了97%,与此前设计相吻合。同时观察到 `SM Utilization` 提升了218%也可以侧面证实使用共享内存减少了内存访问延迟从而提升了利用率,此外还可以观察到各项指标如 `Pipe Fma Cycles Active` 等都有显著提升,这都能充分解释了使用共享内存的改进是合理且有效的。
|
||||
|
||||
### 减少寄存器使用
|
||||
可以注意到在向共享内存中存储矩阵$A$的数据块是按照行优先的数据排布进行的,而对此共享内存的读取是逐行读取的。可以将矩阵$A$的数据块在共享内存中数据按照列优先的形式排布,这样可以减少循环及循环变量从而带来寄存器使用数量减少进而带来性能提升。
|
||||
|
||||
完整代码见[gemm_transpose_smem.cu](https://github.com/openmlsys/openmlsys-cuda/blob/main/gemm_transpose_smem.cu)。
|
||||
|
||||
测试得到以下结果:
|
||||
|
||||
```
|
||||
Max Error: 0.000092
|
||||
Average Time: 0.610 ms, Average Throughput: 14083.116 GFLOPS
|
||||
```
|
||||
使用Nsight Compute分析有以下观察发现主要的变化:`Occupancy` 提升1.3%,而带来此提升的原因是寄存器使用111个,相比上一个GPU核函数使用128个寄存器减少了17个,从而带来了性能提升。但这个变化会因为GPU架构不同导致有不同的变化,同时可以观察到 `STS` 指令数量提升且带来一些 bank confilct ,因此在其他GPU架构上此改动可能不会带来正面影响。
|
||||
|
||||
### 隐藏共享内存读取延迟
|
||||
|
||||
在GPU中使用指令 `LDS` 读取共享内存中的数据,在这条指令发出后并不会等待数据读取到寄存器后再执行下一条语句,只有执行到依赖 `LDS` 指令读取的数据的指令时才会等待读取的完成。而在上一小节中,在内层$tileK$次循环中,每次发射完读取共享内存的指令之后就会立即执行依赖于读取数据的数学运算,这样就会导致计算单元等待数据从共享内存的读取,如 :numref:`use_smem_pipeline` 所示。事实上,对共享内存的访问周期能多达几十个时钟周期,而计算指令的执行往往只有几个时钟周期,因此通过一定方式隐藏对共享内存的访问会取得不小的收益。可以通过重新优化流水线隐藏一定的数据读取延迟。如图 :numref:`hide_smem_latency` 所示,可以在内层的$tileK$次循环中每次循环开始时读取发射下一次内层循环数据的读取指令。由于在执行本次运算时计算指令并不依赖于下一次循环的数据,因此计算过程不会等待之前发出的读取下一次内层循环数据的指令。
|
||||
|
||||

|
||||
:width:` 800px`
|
||||
:label:`use_smem_pipeline`
|
||||
|
||||

|
||||
:width:` 800px`
|
||||
:label:`hide_smem_latency`
|
||||
|
||||
完整代码见[gemm_hide_smem_latency.cu](https://github.com/openmlsys/openmlsys-cuda/blob/main/gemm_hide_smem_latency.cu)。
|
||||
|
||||
测试得到以下结果:
|
||||
|
||||
```
|
||||
Max Error: 0.000092
|
||||
Average Time: 0.585 ms, Average Throughput: 14686.179 GFLOPS
|
||||
```
|
||||
|
||||
使用Nsight Compute观察发现:相比上一个GPU核函数,指标 `Stall Short Scoreboard` 减少了67%。而此前提过GPU内存读写指令发出后并不会等待数据读取到寄存器后再执行下一条语句,但是会在Scoreboard设置符号并在完成读取后置回符号,等到之后有数据依赖的指令执行前会等待Scoreboard中符号的置回。所以这里 `Stall Short Scoreboard` 的减少充分说明了内存延迟是有效的。
|
||||
|
||||
### 隐藏全局内存读取延迟
|
||||
|
||||
上一小节中介绍了对共享内存读取流水线优化的方法,事实上,GPU再读取全局内存中使用的指令 `LDG` 也有与共享内存读取指令 `LDS` 类似的行为特性。因此类似的在$\frac{K}{tileK}$次外层循环中每次循环开始时发出下一次外层循环需要的矩阵$A$中的数据块的读取指令,而本次外循环的整个内层循环过程中不依赖下一次外循环的数据,因此本次外循环的内循环过程中不会等待对下一次外层循环需要的矩阵$A$中的数据块的读取指令完成,从而实现隐藏全局内存读取延迟的目的。具体流水线可视化见 :numref:`hide_global_latency` 。
|
||||
|
||||
上一小节中介绍了对共享内存读取流水线优化的方法,事实上,GPU在读取全局内存中使用的指令 `LDG` 也有与共享内存读取指令 `LDS` 类似的行为特性。因此类似的在$\frac{K}{tileK}$次外层循环中每次循环开始时发出下一次外层循环需要的矩阵$A$中的数据块的读取指令,而本次外循环的整个内层循环过程中不依赖下一次外循环的数据,因此本次外循环的内循环过程中不会等待对下一次外层循环需要的矩阵$A$中的数据块的读取指令完成,从而实现隐藏全局内存读取延迟的目的。此外,可以让内层循环先执行$tileK - 1$次,在最后一次执行前将 `buffer` 中的数据写入 `tile` ,其后再执行内层循环的最后一次迭代,这样能更进一步隐藏向 `tile` 写入的内存延迟。具体流水线可视化见图 :numref:`hide_global_latency` 。
|
||||
|
||||

|
||||
:width:` 800px`
|
||||
:label:`hide_global_latency`
|
||||
|
||||
完整代码见[gemm_final.cu](https://github.com/openmlsys/openmlsys-cuda/blob/main/gemm_final.cu)。
|
||||
|
||||
测试得到以下结果:
|
||||
|
||||
```
|
||||
Max Error: 0.000092
|
||||
Average Time: 0.542 ms, Average Throughput: 15838.302 GFLOPS
|
||||
```
|
||||
|
||||
使用Nsight Compute分析可以观察到指标 `Stall Long Scoreboard` 减少了67%,与上一小结的 `Stall Short Scoreboard` 概念相对应,`Stall Long Scoreboard` 主要是针对全局内存的指标。该指标的显著减少充分说明预取数据可以在一定程度上隐藏全局内存的读取。
|
||||
|
||||
### 与cuBLAS对比
|
||||
|
||||
按照节 :numref:`sec-accelerator-use-cublas` 中介绍的cuBLAS的接口使用方法,可以很容易地写出代码使用cuBLAS完成矩阵乘法,如代码 :numref:`practise-cublas` 所示。
|
||||
|
||||
|
||||
```c++
|
||||
void cublasGemm(const float *A, const float *B, float *C, float alf, float bet, int M, int N, int K) {
|
||||
int lda = N, ldb = K, ldc = N;
|
||||
const float *alpha = &alf;
|
||||
const float *beta = &bet;
|
||||
cublasHandle_t handle;
|
||||
cublasCreate(&handle);
|
||||
cublasSgemm(handle, CUBLAS_OP_N, CUBLAS_OP_N, N, M, K, alpha, B, lda, A, ldb, beta, C, ldc);
|
||||
cublasDestroy(handle);
|
||||
}
|
||||
```
|
||||
:label:`practise-cublas`
|
||||
|
||||
需要注意的是cuBLAS默认矩阵在GPU中是按列优先存储的,而我们的矩阵是按行优先存储的,而两者可以通过转置相互转换,所以$A\times B = (B^T\times A^T)^T$,因此在输入时需要调整矩阵的顺序,即可保证输出结果仍是行优先矩阵。
|
||||
|
||||
测试得到以下结果:
|
||||
|
||||
```
|
||||
Max Error: 0.000092
|
||||
Average Time: 0.613 ms, Throughput: 14002.600 GFLOPS
|
||||
```
|
||||
|
||||
使用Nsight Compute分析发现 `LDG` 和 `STS` 等指令使用较多,导致指令发射压力较大,具体体现在 `Stall Wait` 与 `Stall Dispatch Stall` 指标相比较差。但其他指标诸如 `Stall Long Scoreboard` 等cuBLAS更优,但总体上我们略胜一筹。
|
||||
尽管我们的代码相比cuBLAS已经取得了一定的性能提升,但是需要强调的是cuBLAS内部为各种不同的矩阵尺寸以及不同的设备实现了若干不同的GPU核函数,我们实现的核函数在其他尺寸或其他设备设备上性能可能无法取得此加速比。
|
||||
|
||||
|
||||
### 小结
|
||||
|
||||
要实现一个高性能算子需要依照硬件特性适应性进行若干优化。本节优化策略可总结为以下几点:
|
||||
|
||||
- 并行资源映射——提高并行性:将多层级的并行资源(`block` 、`warp` 、`thread` )与对应需要计算和搬移的数据建立映射关系,提高程序并行性。将可并行的计算和数据搬移操作映射到并行资源上,对于广义矩阵乘法实例,在节~\ref{sec-accelerator-naive`朴素实现的例子中,令每个`block` 与矩阵$C$中的一个矩阵块建立映射关系,每个`thread` 与矩阵块中的一个元素建立映射关系。
|
||||
|
||||
- 优化内存结构——减小访存延迟:观察计算过程中同一个`block` 中数据复用的情况,将复用的数据被如共享内存、寄存器等高性能体系结构存储下来,以此提高吞吐量。如在节 :numref`sec-accelerator-naive` 中将矩阵$A$与矩阵$B$中会被同一个 `block` 内不同 `thread` 共同访问的数据缓存到共享内存中。
|
||||
|
||||
- 优化指令执行——减小指令发射开销:使用 `#pragma unroll` 功能进行循环展开来提升指令级并行,减少逻辑判断;使用向量化加载指令以提高带宽等,对于Ampere架构,最大向量化加载指令为 `LDG.E.128` ,可以采用 `float4` 类型的数据进行读取。
|
||||
|
||||
- 优化访存流水线——隐藏访存延迟:在进行内存结构变化(矩阵数据搬移)时,可以优化访存流水线,在数据搬移的间隔执行计算操作以隐藏数据搬移的延迟。
|
||||
@@ -1,7 +1,7 @@
|
||||
## 加速器基本编程原理
|
||||
:label:`accelerator-program-title`
|
||||
|
||||
本章前两节主要介绍了硬件加速器设计的意义、思路以及基本组成原理。软硬件协同优化作为构建高效AI系统的一个重要指导思想,需要软件算法/软件栈和硬件架构在神经网络应用中互相影响、紧密耦合。为了最大限度地发挥加速器的优势,要求能够基于硬件系统架构提供易用、高效的编程方法。因此,在本节中将着重介绍加速器的可编程性,包括编程接口直接调用方式及算子编译器优化方式。最后,通过示例介绍如何通过编程使能加速器,提升神经网络算子的计算效率。
|
||||
本章前两节主要介绍了这些硬件加速器设计的意义、思路以及基本组成原理。软硬件协同优化作为构建高效AI系统的一个重要指导思想,需要软件算法/软件栈和硬件架构在神经网络应用中互相影响、紧密耦合。为了最大限度地发挥加速器的优势,要求能够基于硬件系统架构设计出一套较为匹配的指令或编程方法。因此,本节将着重介绍加速器的可编程性,以及如何通过编程使能加速器,提升神经网络算子的计算效率。
|
||||
|
||||
### 硬件加速器的可编程性
|
||||
:label:`accelerator-programable-title`
|
||||
@@ -10,36 +10,36 @@
|
||||
|
||||
#### 编程接口使能加速器
|
||||
|
||||
硬件加速器出于计算效率和易用性等方面考虑,将编程接口使能方式分为不同等级,一般包括:算子库层级,编程原语层级,以及指令层级。为了更具象的解释上述层级的区别,我们以Volta架构的Tensor Core加速器为例,由高层至底层对比介绍这三种不同编程方式:
|
||||
硬件加速器出于计算效率和易用性等方面考虑,将编程使能方式分为不同等级,一般包括:算子库层级,编程原语层级,以及指令层级。为了更具象的解释上述层级的区别,仍以Volta架构的张量计算核心为例,由高层至底层对比介绍这三种不同编程方式:
|
||||
|
||||
- **算子库层级**:如cuBLAS基本矩阵与向量运算库,cuDNN深度学习加速库,均通过Host端调用算子库提供的核函数使能TensorCore;
|
||||
- **算子库层级**:如cuBLAS基本矩阵与向量运算库,cuDNN深度学习加速库,均通过Host端调用算子库提供的核函数使能张量计算核心;
|
||||
|
||||
- **编程原语层级**:如基于CUDA的WMMA API编程接口。同算子库相比,需要用户显式调用计算各流程,如矩阵存取至TensorCore、TensorCore执行矩阵乘累加运算、TensorCore累加矩阵数据初始化操作等;
|
||||
- **编程原语层级**:如基于CUDA的WMMA API编程接口。同算子库相比,需要用户显式调用计算各流程,如矩阵存取至寄存器、张量计算核心执行矩阵乘累加运算、张量计算核心累加矩阵数据初始化操作等;
|
||||
|
||||
- **指令层级**:如PTX ISA MMA指令集,提供更细粒度的mma指令,便于用户组成更多种形状的接口,通过CUDA Device端内联编程使能TensorCore。
|
||||
- **指令层级**:如PTX ISA MMA指令集,提供更细粒度的mma指令,便于用户组成更多种形状的接口,通过CUDA Device端内联编程使能张量计算核心。
|
||||
|
||||
#### 算子编译器使能加速器
|
||||
|
||||
DSA架构的多维度AI加速器通常提供了更多的指令选择(3D-Matrix/2D-Vector/1D-Scalar),以及更加复杂的数据流处理,通过提供接口调用的方式对程序开发人员带来较大的挑战。此外,由于调度、切分的复杂度增加,直接提供算子库的方式由于缺少根据目标shape调优的能力,往往无法在所有shape下均得到最优的性能。因此,对于DSA加速器,业界通常采用算子编译器的解决方案。
|
||||
DSA架构的多维度AI加速器通常提供了更多的指令选择(三维向量计算指令、二维向量计算指令、一维向量计算指令),以及更加复杂的数据流处理,通过提供接口调用的方式对程序开发人员带来较大的挑战。此外,由于调度、切分的复杂度增加,直接提供算子库的方式由于缺少根据目标形状(Shape)调优的能力,往往无法在所有形状下均得到最优的性能。因此,对于DSA加速器,业界通常采用算子编译器的解决方案。
|
||||
|
||||
随着深度学习模型的迭代更新及各类AI芯片的层出不穷,基于人工优化算子的方式给算子开发团队带来沉重的负担。因此,开发一种能够将High-level的算子表示编译成目标硬件可执行代码的算子编译器,逐渐成为学术界及工业界的共识。算子编译器前端通常提供了特定领域描述语言(DSL),用于定义算子的计算范式;类似于传统编译器,算子编译器也会将算子计算表示转换为中间表示,如HalideIR :cite:`ragan2013halide`、TVM :cite:`chen2018tvm`的TIR、Schedule Tree :cite:`verdoolaege2010isl`等,基于模板(手动)、搜索算法或优化求解算法(自动)等方式完成循环变换、循环切分等调度相关优化,以及硬件指令映射、内存分配、指令流水等后端pass优化,最后通过codegen模块将IR转换为DSA加速器可执行的kernel。
|
||||
随着深度学习模型的迭代更新及各类AI芯片的层出不穷,基于人工优化算子的方式给算子开发团队带来沉重的负担。因此,开发一种能够将High-level的算子表示编译成目标硬件可执行代码的算子编译器,逐渐成为学术界及工业界的共识。算子编译器前端通常提供了特定领域描述语言(DSL),用于定义算子的计算范式;类似于传统编译器,算子编译器也会将算子计算表示转换为中间表示,如HalideIR :cite:`ragan2013halide`、TVM :cite:`chen2018tvm`的TIR、Schedule Tree :cite:`verdoolaege2010isl`等,基于模板(手动)、搜索算法或优化求解算法(自动)等方式完成循环变换、循环切分等调度相关优化,以及硬件指令映射、内存分配、指令流水等后端pass优化,最后通过代码生成模块将IR转换为DSA加速器可执行的设备端核函数。
|
||||
|
||||
当前业界的算子编译器/编译框架主要有TVM/Ansor :cite:`zheng2020ansor`、MLIR :cite:`lattner2020mlir`、以及华为Ascend芯片上的TBE/AKG :cite:`zhao2021akg`等。
|
||||
当前业界的算子编译器/编译框架主要有TVM/Ansor :cite:`zheng2020ansor`、MLIR :cite:`lattner2020mlir`、以及华为昇腾芯片上的TBE/AKG :cite:`zhao2021akg`等。
|
||||
|
||||
- **TVM/Ansor**
|
||||
|
||||
TVM是陈天奇博士等人开发的开源深度学习编译框架,提供了端到端的编译优化(图优化/算子优化)能力,在工业界应用较广。在架构上,主要包括relay和tir两层。通过relay导入推理模型,进行算子融合等图层优化,通过tir生成融合算子。在算子编译方面,TVM采用了计算和调度分离的技术,为不同的算子提供了不同的模板,同时支持自定义模板,优化特定算子类型调度。为了更进一步优化算子性能,TVM支持对算子进行自动tuning,来生成较优的切分参数。此外,为了简化用户开发模板的工作,TVM在0.8版本后提供了自动调度能力Ansor,通过搜索的方式,为目标算子生成调度及切分参数。
|
||||
TVM是陈天奇博士等人开发的开源深度学习编译框架,提供了端到端的编译优化(图优化/算子优化)能力,在工业界应用较广。在架构上,主要包括Relay和TIR两层。通过Relay导入推理模型,进行算子融合等图层优化,通过TIR生成融合算子。在算子编译方面,TVM采用了计算和调度分离的技术,为不同的算子提供了不同的模板,同时支持自定义模板,优化特定算子类型调度。为了更进一步优化算子性能,TVM支持对算子进行自动调优,来生成较优的切分参数。此外,为了简化用户开发模板的工作,TVM在0.8版本后提供了自动调度能力Ansor,通过搜索的方式,为目标算子生成调度及切分参数。如 :numref:`tvm`所示:
|
||||
|
||||

|
||||

|
||||
:width:`800px`
|
||||
:label:`tvm`
|
||||
|
||||
|
||||
- **MLIR**
|
||||
|
||||
前面的章节介绍过,Google开发的MLIR并不是一个单一的算子编译器,而是一套编译器基础设施,提供了工具链的组合与复用能力。基于MLIR,DSA加速器厂商可以快速的搭建其定制化算子编译器。如Google论文 :cite:`vasilache2022composable`中所述,当前的算子编译器大多提供了一整套自顶向下的编译优化pass,包括调度优化、切分优化、窥孔优化、后端优化、指令生成等,彼此之间大多无法复用,导致新的场景中通常又得从头开发。而在MLIR中,将功能相近的IR优化pass封装为方言(Dialect),并且提供了多个代码生成相关的基础方言,如vector、memref、tensor、scf、affine、linalg等。硬件厂商可以基于这些Dialect,快速构建一整套lower优化及codegen流程。如 :numref:`MLIR_Lowing`所示,利用scf、affine、linalg等方言,对结构化的计算IR完成循环并行优化、切分、向量化等,最后基于LLVM完成指令映射。
|
||||
前面的章节介绍过,Google开发的MLIR并不是一个单一的算子编译器,而是一套编译器基础设施,提供了工具链的组合与复用能力。基于MLIR,DSA加速器厂商可以快速的搭建其定制化算子编译器。如Google论文 :cite:`vasilache2022composable`中所述,当前的算子编译器大多提供了一整套自顶向下的编译优化pass,包括调度优化、切分优化、窥孔优化、后端优化、指令生成等,彼此之间大多无法复用,导致新的场景中通常又得从头开发。而在MLIR中,将功能相近的IR优化pass封装为方言(Dialect),并且提供了多个代码生成相关的基础方言,如vector、memref、tensor、scf、affine、linalg等。硬件厂商可以基于这些方言,快速构建一整套lower优化及codegen流程。如 :numref:`MLIR_Lowing`所示,利用scf、affine、linalg等方言,对结构化的计算IR完成循环并行优化、切分、向量化等,最后基于LLVM完成指令映射。
|
||||
|
||||

|
||||

|
||||
:width:`800px`
|
||||
:label:`MLIR_Lowing`
|
||||
|
||||
@@ -47,25 +47,25 @@
|
||||
|
||||
- **华为TBE/AKG**
|
||||
|
||||
TBE(Tensor Boost Engine)是华为的Ascend芯片及其CANN软件栈基于TVM 开发的一套算子编译优化工具,用于对Ascend芯片进行调度优化、指令映射、及后端pass优化等。不仅提供了一个优化过的神经网络标准算子库,同时还提供了算子开发能力及融合能力。通过TBE提供的API和自定义算子编程开发界面可以完成相应神经网络算子的开发,帮助用户较容易的去使能硬件加速器上的AI_CORE 相关指令,以实现高性能的神经网络计算。为了简化算子开发流程,TBE还实现了一个Auto Schedule工具,开放了自定义算子编程DSL,用于自动完成复杂算子的调度生成。此外,TBE还实现了端到端的动态shape算子编译能力。
|
||||
张量加速引擎(Tensor Boost Engine,TBE)是华为的Ascend芯片及其CANN软件栈基于TVM 开发的一套算子编译优化工具,用于对Ascend芯片进行调度优化、指令映射、及后端pass优化等。如 :numref:`tbe`所示。不仅提供了一个优化过的神经网络标准算子库,同时还提供了算子开发能力及融合能力。通过TBE提供的API和自定义算子编程开发界面可以完成相应神经网络算子的开发,帮助用户较容易的去使能硬件加速器上的AI Core指令,以实现高性能的神经网络计算。为了简化算子开发流程,TBE还实现了一个Auto
|
||||
Schedule工具,开放了自定义算子编程DSL,用于自动完成复杂算子的调度生成。此外,TBE还实现了端到端的动态形状算子编译能力。
|
||||
|
||||

|
||||

|
||||
:width:`800px`
|
||||
:label:`tbe`
|
||||
|
||||
AKG则是MindSpore社区的开源算子编译工具。与上述介绍的算子编译器不同,AKG基于Polyhedral多面体编译技术 :cite:`bastoul2004code`,支持在CPU/GPU/Ascend多硬件上自动生成满足并行性与数据局部性的调度。Polyhedral编译技术的核心思想是将程序中循环的迭代空间映射为高维空间多面体,通过分析语句读写依赖关系,将循环调度优化问题转换为整数规划求解问题。
|
||||
AKG的编译流程如 :numref:`akg`所示,主要包含程序规范化、自动调度优化、指令生成、后端优化几个模块。AKG同样基于TVM实现,支持TVM compute/Hybrid DSL编写的算子表示,以及MindSpore图算融合模块优化后的融合子图。通过IR规范化,将DSL/子图IR转换为polyhedral编译的调度树。在polyhedral模块中,利用其提供的调度算法,实现循环的自动融合、自动重排等变换,为融合算子自动生成满足并行性、数据局部性的初始调度。为了能够快速适配不同的硬件后端,我们在poly模块内将优化pass识别为硬件无关的通用优化与硬件相关的特定优化,编译时按照硬件特征拼接组合,实现异构硬件后端的快速适配。
|
||||
AKG则是MindSpore社区的开源算子编译工具。与上述介绍的算子编译器不同,AKG基于Polyhedral多面体编译技术 :cite:`bastoul2004code`,支持在CPU、GPU和Ascend多种硬件上自动生成满足并行性与数据局部性的调度。Polyhedral编译技术的核心思想是将程序中循环的迭代空间映射为高维空间多面体,通过分析语句读写依赖关系,将循环调度优化问题转换为整数规划求解问题。 AKG的编译流程如 :numref:`akg`所示,主要包含规范化、自动调度优化、指令映射、后端优化几个模块。AKG同样基于TVM实现,支持TVM compute/Hybrid DSL编写的算子表示,以及MindSpore图算融合模块优化后的融合子图。通过IR规范化,将DSL/子图IR转换为Polyhedral编译的调度树。在Poly模块中,利用其提供的调度算法,实现循环的自动融合、自动重排等变换,为融合算子自动生成满足并行性、数据局部性的初始调度。为了能够快速适配不同的硬件后端,在Poly模块内将优化pass识别为硬件无关的通用优化与硬件相关的特定优化,编译时按照硬件特征拼接组合,实现异构硬件后端的快速适配。
|
||||
|
||||

|
||||

|
||||
:width:`800px`
|
||||
:label:`akg`
|
||||
|
||||
在polyhedral模块中,实现了算子的自动调度生成、自动切分以及自动数据搬移。为了进一步提升算子的性能,我们针对不同硬件后端开发了相应的优化pass,如Ascend后端中实现数据对齐、指令映射,GPU后端中实现向量化存取,插入同步指令等,最终生成相应平台代码。
|
||||
在Poly模块中,实现了算子的自动调度生成、自动切分以及自动数据搬移。为了进一步提升算子的性能,针对不同硬件后端开发了相应的优化pass,如Ascend后端中实现数据对齐、指令映射,GPU后端中实现向量化存取,插入同步指令等,最终生成相应平台代码。
|
||||
|
||||
### 硬件加速器的多样化编程方法
|
||||
:label:`diversified-programming-title`
|
||||
|
||||
矩阵乘法运算作为深度学习网络中占比最大的计算,对其进行优化是十分必要的。因此本节将统一以矩阵乘法$D[M, N] = C[M, N] + A[M, K] \times B[K, N]$为实例,对比介绍如何通过不同编程方式使能加速器。
|
||||
矩阵乘法运算作为深度学习网络中占比最大的计算,对其进行优化是十分必要的。因此本节将统一以广义矩阵乘法为实例,对比介绍如何通过不同编程方式使能加速器。广义矩阵乘法指GEMM(General Matrix Multiplication),即$\bm{C} = \alpha \bm{A}\times \bm{B} + \beta \bm{C}$,其中$\bm{A}\in\mathbb{R}^{M\times K}, \bm{B}\in\mathbb{R}^{K\times N}, \bm{C}\in\mathbb{R}^{M\times N}$。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
@@ -75,8 +75,11 @@
|
||||
|
||||
- **算子库层级**
|
||||
|
||||
在上述不同层级的编程方式中,直接调用算子加速库使能加速器无疑是最快捷高效的方式。NVIDIA提供了cuBLAS/cuDNN两类算子计算库,cuBLAS提供了使能Tensor Core单元的接口,用以加速矩阵乘法(GEMM)运算,cuDNN提供了对应接口加速卷积(CONV)运算等。
|
||||
以 :numref:`accelerator-programable-title`小节的GEMM运算为例,与常规CUDA调用cuBLAS算子库相似,通过cuBLAS加速库使能Tensor Core步骤包括:
|
||||
:label:`sec-accelerator-use-cublas`
|
||||
|
||||
在上述不同层级的编程方式中,直接调用算子加速库使能加速器无疑是最快捷高效的方式。NVIDIA提供了cuBLAS/cuDNN两类算子计算库,cuBLAS提供了使能张量计算核心的接口,用以加速矩阵乘法(GEMM)运算,cuDNN提供了对应接口加速卷积(CONV)运算等。
|
||||
以 :numref:`accelerator-programable-title`小节的GEMM运算为例,与常规CUDA调用cuBLAS算子库相似,通过cuBLAS加速库使能张量计算核心步骤包括:
|
||||
|
||||
1. 创建cuBLAS对象句柄且设置对应数学计算模式
|
||||
|
||||
```cpp
|
||||
@@ -115,14 +118,14 @@ cudaFree(devPtrA);
|
||||
cudaDestroy(handle);
|
||||
```
|
||||
|
||||
当然,由于加速器一般会有矩阵形状、数据类型、排布方式等限制,因此在调用句柄和函数接口时要多加注意。如本例中,cuBLAS计算模式必须设置为$CUBLAS\_TENSOR\_OP\_MATH$,步长必须设置为8的倍数,输入数据类型必须为$CUDA\_R\_16F$等。按照如上方式即可通过cuBLAS算子库对 :numref:`accelerator-programable-title`实例使能Tensor Core加速器,通过NVIDIA官方数据可知,该方式对于不同矩阵乘法计算规模,平均有4~10倍的提升,且矩阵规模越大,加速器提升效果越明显。
|
||||
当然,由于加速器一般会受到矩阵形状、数据类型、排布方式等限制,因此在调用句柄和函数接口时要多加注意。如本例中,cuBLAS计算模式必须设置为$CUBLAS\_TENSOR\_OP\_MATH$,步长必须设置为8的倍数,输入数据类型必须为$CUDA\_R\_16F$等。按照如上方式即可通过cuBLAS算子库对 :numref:`accelerator-programable-title`实例使能张量计算核心,通过NVIDIA官方数据可知,该方式对于不同矩阵乘法计算规模,平均有4~10倍的提升,且矩阵规模越大,加速器提升效果越明显。
|
||||
|
||||
该方式由于能够隐藏体系结构细节,易用性较好,且一般官方提供的算子库吞吐量较高。但与此同时,这种算子颗粒度的库也存在一些问题,如不足以应对复杂多变的网络模型导致的算子长尾问题(虽然常规形式算子占据绝大多数样本,但仍有源源不断的新增算子,因其出现机会较少,算子库未对其进行有效优化。),以及错失了较多神经网络框架优化(如算子融合)的机会。
|
||||
|
||||
- **编程原语层级**
|
||||
|
||||
第二种加速器编程方式为编程原语使能加速器,如通过在Device端调用CUDA WMMA (Warp Matrix Multiply Accumulate) API接口。以线程束(即Warp,是调度的基本单位)为操纵对象,使能多个Tensor Core单元。该方式在CUDA 9.0中被公开,程序员可通过添加API头文件的引用和命名空间定义来使用上述API接口。基于软硬件协同设计的基本思想,该层级编程API的设计多与架构绑定,如WMMA操纵的总是$16\times16$大小的矩阵块,并且操作一次跨两个TensorCore进行处理,本质是与TensorCore如何集成进SM中强相关的。针对Float16输入数据类型,NVIDIA官方提供了三种不同矩阵规模的WMMA乘累加计算接口,分别为$16\times16\times16$,$32\times8\times16$,$8\times32\times16$。
|
||||
该API接口操纵的基本单位为Fragment,是一种指明了矩阵含义(乘法器/累加器)、矩阵形状($WMMA\_M, WMMA\_N, WMMA\_K$)、数据类型(Half/ Float)、排布方式($row\_major/ col\_major$)等信息的模板类型,包括如下:
|
||||
第二种加速器编程方式为编程原语使能加速器,如通过在Device端调用CUDA WMMA (Warp Matrix Multiply Accumulate) API接口。以线程束(即{Warp},是调度的基本单位)为操纵对象,使能多个张量计算核心。该方式在CUDA 9.0中被公开,程序员可通过添加API头文件的引用和命名空间定义来使用上述API接口。基于软硬件协同设计的基本思想,该层级编程API的设计多与架构绑定,如在Volta架构中WMMA操纵的总是$16\times16$大小的矩阵块,并且操作一次跨两张量计算核心进行处理,本质是与张量计算核心如何集成进SM中强相关的。在Volta架构下,针对FP16输入数据类型,NVIDIA官方提供了三种不同矩阵规模的WMMA乘累加计算接口,分别为$16\times16\times16$,$32\times8\times16$,$8\times32\times16$。
|
||||
该API接口操纵的基本单位为Fragment,是一种指明了矩阵含义(乘法器/累加器)、矩阵形状($WMMA\_M, WMMA\_N, WMMA\_K$)、数据类型(FP16/FP32)、排布方式($row\_major/ col\_major$)等信息的模板类型,包括如下:
|
||||
|
||||
```cpp
|
||||
wmma::fragment<wmma::matrix_a, WMMA_M, WMMA_N, WMMA_K, half, wmma::row_major> a_frag;
|
||||
@@ -130,21 +133,37 @@ wmma::fragment<wmma::matrix_b, WMMA_M, WMMA_N, WMMA_K, half, wmma::col_major> b_
|
||||
wmma::fragment<wmma::accumulator, WMMA_M, WMMA_N, WMMA_K, float> acc_frag;
|
||||
wmma::fragment<wmma::accumulator, WMMA_M, WMMA_N, WMMA_K, float> c_frag;
|
||||
```
|
||||
使用时,我们需要将待执行乘法操作矩阵块的数据,作为Fragment,由寄存器加载至TensorCore,在将累加Fragment初始化/清零操作后,通过TensorCore单元执行乘累加运算,最后将运算结果的Fragment存回寄存器或其他内存区域。与上述操作对应的,NVIDIA提供了$wmma.load\_matrix\_sync(), wmma.store\_matrix\_sync()$接口用于将参与计算的子矩阵块写入/载出Fragment片段;$wmma.fill\_fragment()$接口用于初始化对应Fragment的数据;$wmma.mma\_sync()$接口用于对Fragment进行乘累加运算。
|
||||
使用时,需要将待执行乘法操作矩阵块的数据加载到寄存器,作为Fragment,在将累加Fragment初始化/清零操作后,通过张量计算核心执行乘累加运算,最后将运算结果的Fragment存回到内存。与上述操作对应的,NVIDIA提供了$wmma.load\_matrix\_sync(), wmma.store\_matrix\_sync()$接口用于将参与计算的子矩阵块写入/载出Fragment片段;$wmma.fill\_fragment()$接口用于初始化对应Fragment的数据;$wmma.mma\_sync()$接口用于对Fragment进行乘累加运算。
|
||||
|
||||
- **指令层级**
|
||||
|
||||
在NVIDIA PTX ISA (Instruction Set Architecture)中提供了另一个编程接口,如Volta架构中的$mma.sync.m8n8k4$指令,它使用$M=8, N=8, K=4$的形状配置执行乘累加操作。具体地,它由线程组(黑色椭圆表示)或octet执行 :cite:`2018Modeling`,如 :numref:`PTX`显示了线程和数据的映射关系。每个线程组由四个连续的线程组成,使用不同颜色的圆圈表示。图中还指出了一个octet里面的线程在线程束内的分布,Float16乘法器A或B的四个连续元素(使用具有相同颜色的块表示),以及Float32累加器C或D的八个分散元素(同样使用相同颜色的块表示)。彩色块上的数字代表对应的线程ID。
|
||||
在NVIDIA PTX ISA (Instruction Set Architecture)中提供了另一个编程接口,如Volta架构中的$mma.sync.m8n8k4$指令,它使用$M=8, N=8, K=4$的形状配置执行乘累加操作。该API接口操纵的基本单位为数据元素,除了需要指明矩阵尺寸(即修饰符$.m8n8k4$),还需要指明数据的排布类型(用修饰符$.row$或$.col$)以及输入累加器D、矩阵A、矩阵B及输出累加器C的数据格式(使用修饰符$.f32$或$.f16$等)。如要使用PTX指令集,还需要参考官方文档按照相应的语法规则编写,如代码所示。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`PTX`
|
||||
```cpp
|
||||
half_t *a, *b;
|
||||
float *C, *D;
|
||||
unsigned const* A = reinterpret_cast<unsigned const*>(a);
|
||||
unsigned const* B = reinterpret_cast<unsigned const*>(b);
|
||||
|
||||
作为一个更细粒度的指令,mma可以组成更加多样化形状的Warp范围的WMMA API接口,可以控制线程束内线程与数据的映射关系,并允许AI编译器自动/手动显式地管理内存层次结构之间的矩阵分解,因此相比于直接应用NVCUDA::WMMA API具有更好的灵活性。
|
||||
asm volatile(
|
||||
"mma.sync.aligned.m8n8k4.row.row.f32.f16.f16.f32 "
|
||||
"{%0,%1,%2,%3,%4,%5,%6,%7}, {%8,%9}, {%10,%11}, "
|
||||
"{%12,%13,%14,%15,%16,%17,%18,%19};\n"
|
||||
: "=f"(D[0]), "=f"(D[1]), "=f"(D[2]), "=f"(D[3]), "=f"(D[4]),
|
||||
"=f"(D[5]), "=f"(D[6]), "=f"(D[7])
|
||||
: "r"(A[0]), "r"(A[1]), "r"(B[0]), "r"(B[1]), "f"(C[0]),
|
||||
"f"(C[1]), "f"(C[2]), "f"(C[3]), "f"(C[4]), "f"(C[5]),
|
||||
"f"(C[6]), "f"(C[7]));
|
||||
);
|
||||
```
|
||||
|
||||
使用时,直接将数据元素作为输入传入(对于FP16的数据元素作为$unsigned$类型传入),与上述操作对应的,NVIDIA提供了$ldmatrix$指令用于从共享内存中加载数据到Fragment。
|
||||
|
||||
作为一个更细粒度的指令,mma指令可以组成更加多样化形状的Warp范围的WMMA API接口,可以控制线程束内线程与数据的映射关系,并允许AI编译器自动/手动显式地管理内存层次结构之间的矩阵分解,因此相比于直接应用NVCUDA::WMMA API具有更好的灵活性。
|
||||
|
||||
#### 算子编译器编程使能加速器
|
||||
|
||||
基于算子编译器使能加速器实现矩阵乘的流程则对用户更加友好。以在Ascend中使用TBE为例,用户只需基于python定义矩阵乘的tensor信息(数据类型及形状等),调用对应TBE接口即可。如下所示:
|
||||
基于算子编译器使能加速器实现矩阵乘的流程则对用户更加友好。以在Ascend中使用TBE为例,用户只需基于python定义矩阵乘的tensor信息(数据类型及形状等),调用对应TBE接口即可。如代码所示:
|
||||
|
||||
```python
|
||||
a_shape = (1024, 256)
|
||||
@@ -157,37 +176,3 @@ tensor_b = tvm.placeholder(b_shape, name='tensor_b', dtype=in_dtype)
|
||||
tensor_bias = tvm.placeholder(bias_shape, name='tensor_bias', dtype=dst_dtype)
|
||||
res = te.lang.cce.matmul(tensor_a, tensor_b, False, False, False, dst_dtype=dst_dtype, tensor_bias=tensor_bias)
|
||||
```
|
||||
|
||||
### 硬件加速器高性能编程实例
|
||||
|
||||
本节 :numref:`accelerator-program-title`前几个小节主要介绍了硬件加速器的不同层级的多样化编程方法。调用计算库的方式留给程序员的优化空间较少,合理利用硬件加速器不同层级的编程,可以实现更好的性能优化。 为了更好的让读者理解硬件加速器的使用,本节会继续 :numref:`accelerator-programable-title`节中的GEMM运算,仍以WMMA API使能Tensor Core加速单元为例,介绍如何通过矩阵分块、资源映射等方式更高效的利用硬件加速器。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`gemm-tensor-core-algorith`
|
||||
|
||||
若要得到高性能CUDA程序,提高并行性、增大吞吐量、优化指令执行是至关重要的三个优化目标。针对该实例,具体地实现和优化方案列出如下,对应到具体实例伪代码如 :numref:`gemm-tensor-core-algorith`所示:
|
||||
|
||||
1. **优化内存结构------增大吞吐量**:将原始大规模矩阵根据不同阈值切分成不同层级的子矩阵块,使得子矩阵块能被如共享内存、寄存器等高性能体系结构存储下来,以此提高吞吐量。设置切分参数为$BlockTile[Ms, Ns, Ks]$和$WarpTile[Mw, Nw, Kw]$,对应的将BlockTile下的矩阵由全局内存搬移至共享内存,以提高全局内存合并访问和数据局部性,如 :numref:`GEMM-BlockTile`所示;再将内层WarpTile下的矩阵由共享内存搬移至寄存器中,如 :numref:`GEMM-WarpTile`所示,以备Tensor Core加速器数据存取。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`GEMM-BlockTile`
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`GEMM-WarpTile`
|
||||
|
||||
2. **并行资源映射------提高并行性**:将多层级的并行资源(Block、Warp、Thread)与对应需要计算/搬移的数据建立映射关系,提高程序并行性。将可并行的计算/数据搬移操作映射到并行资源上,对于GEMM实例,M/N轴即为可并行轴,将数据搬移操作中的循环指令映射分配到Block层级(即 :numref:`gemm-tensor-core-algorith`中的2-4行$For$循环),将内层循环指令映射分配到Warp层级(即 :numref:`gemm-tensor-core-algorith`中的8-9行$For$循环)。(前文介绍,线程束Warp作为调度的基本单位,且是WMMA API操纵的基本层级,因此对Warp层级进行数据映射比Thread层级映射更为合适)
|
||||
|
||||
3. **Warp统一的Tensor Core数据交互------增大吞吐量**:根据 :numref:`diversified-programming-title`节中介绍的编程方法,除调用算子库外,均需要使用或将指令封装成WMMA接口形式统一进行Warp层级的数据存取和计算。如 :numref:`GEMM-TensorCore`所示,Tensor Core加速器需要从局部内存/寄存器中读取数据,存于虚拟Fragment数据结构中,对应使用$wmma.load\_matrix\_sync()$接口,将累加Fragment $C$ 通过$wmma.fill\_fragment()$接口进行初始化后,使用$wmma.mma\_sync()$使能加速器进行乘累加运算,后将结果Fragment $D$通过调用$wmma.store\_matrix\_sync()$接口拷贝至目标内存地址。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`GEMM-TensorCore`
|
||||
|
||||
4. **优化数据访存------提高并行性**:在进行内存结构变化(矩阵数据搬移)时,需要注意全局内存的合并访问、共享内存的存储体冲突等常见性能瓶颈点。
|
||||
|
||||
5. **资源负载均衡------增大吞吐量**:调整平衡每个线程处理的数据量、共享内存使用量、寄存器使用量,以获得更高的SM占用率。一般在实际程序中BlockTile和WarpTile的选取至关重要。
|
||||
|
||||
6. **优化指令执行**:使用\#unroll功能进行循环展开来提升指令级并行,如 :numref:`gemm-tensor-core-algorith`中13行;使用向量化加载指令以提高带宽等,对于GPU Volta架构,最大向量化加载指令为ldg128,即128比特带宽,对于 :numref:`gemm-tensor-core-algorith`中5-6行数据由全局内存加载至共享内存时,即可采用Float4\*类型指针进行内存读取。
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# 硬件加速器
|
||||
|
||||
上一章节,我们详细讨论了计算图的基本组成,生成和执行等关键设计。当前主流深度学习模型大多基于神经网络实现,无论是训练还是推理,都会产生海量的计算任务,尤其是涉及矩阵乘法这种高计算任务的算子。另一方面,通用处理器芯片如CPU,在执行这类算子时通常耗时较大,难以满足训练/推理任务的需求。因此工业界和学术界都将目光投向特定领域的加速器芯片设计,希望以此来解决算力资源不足的问题。
|
||||
上一章节详细讨论了后端的计算图优化、算子选择以及内存分配。当前主流深度学习模型大多基于神经网络实现,无论是训练还是推理,都会产生海量的计算任务,尤其是涉及矩阵乘法这种高计算任务的算子。然而,通用处理器芯片如CPU在执行这类算子时通常耗时较大,难以满足训练和推理任务的需求。因此工业界和学术界都将目光投向特定领域的加速器芯片设计,希望以此来解决算力资源不足的问题。
|
||||
|
||||
本章将会着重介绍加速器的基本组成原理,并且以矩阵乘法为例,介绍在加速器上的编程方式及优化方法。最后,介绍由异构算子组成的异构计算图表达与执行方式。
|
||||
|
||||
本章将会着重介绍加速器的基本组成原理,并且以矩阵乘法为例,介绍在加速器上的编程方式及优化方法。
|
||||
|
||||
本章的学习目标包括:
|
||||
|
||||
@@ -12,13 +13,12 @@
|
||||
|
||||
- 理解编程API的设计理念
|
||||
|
||||
- 理解异构硬件加速的表达与执行
|
||||
|
||||
```toc
|
||||
:maxdepth: 2
|
||||
|
||||
accelerator_introduction
|
||||
accelerator_architecture
|
||||
accelerator_programming
|
||||
accelerator_practise
|
||||
summary
|
||||
```
|
||||
|
||||
@@ -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侧被触发执行后,会经过机器学习框架初始化,其中需要确定包括算子的精度,输入与输出的类型和大小以及对应的硬件设备等信息,接着框架会为该算子分配计算所需的内存,最后交给具体的硬件计算设备完成计算的执行。
|
||||
|
||||

|
||||
: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`所示。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`side_effect_1`
|
||||
|
||||
代码中所示三行计算之间并没有依赖关系,因此这三个算子在计算图的逻辑上可以被并发执行,并发关系如 :numref:`side_effect_1`所示,然而根据代码的语义,显而易见是需要确保程序能够被顺序执行,这里引入的问题被称为副作用,副作用是指函数修改了在函数外部定义的状态变量的行为。由于副作用的引入而导致了错误并发关系的发生,一种解决方案是在计算图编译阶段通过添加算子间的依赖,将并发执行逻辑转换为顺序执行逻辑,转换后的计算图如 :numref:`side_effect_2`所示:
|
||||
代码中所示三行计算之间并没有依赖关系,因此这三个算子在计算图的逻辑上可以被并发执行,然而根据代码的语义,显而易见是需要确保程序能够被顺序执行,这里引入的问题被称为副作用,副作用是指修改了在函数外部定义的状态变量的行为。由于副作用的引入而导致了错误并发关系的发生,一种解决方案是在计算图编译阶段通过添加算子间的依赖,将并发执行逻辑转换为顺序执行逻辑,转换后的计算图如 :numref:`side_effect_2`所示。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
|
||||
@@ -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的输出的读和写的操作。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`conv_sum_relu`
|
||||
|
||||
除了上述针对特定算子类型结构的融合优化外,基于自动算子生成技术,还可以实现更灵活、更极致的通用优化。以
|
||||
MindSpore
|
||||
的图算融合技术为例,图算融合通过"算子拆解、算子聚合、算子重建"三个主要阶段(如图)让计算图中的计算更密集,并进一步减少低效的内存访问。
|
||||
除了上述针对特定算子类型结构的融合优化外,基于自动算子生成技术,还可以实现更灵活、更极致的通用优化。以 MindSpore 的图算融合技术为例,图算融合通过“算子拆解、算子聚合、算子重建”三个主要阶段让计算图中的计算更密集,并进一步减少低效的内存访问。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -1,10 +1,10 @@
|
||||
## 算子选择
|
||||
|
||||
经过计算图优化后,需要对IR图上的每个节点进行算子选择,才能生成真正在设备上执行的算子序列。由于IR图上的节点可能有后端的很多算子与其对应,不同规格的算子在不同的情况下执行效率各不相同,在算子选择阶段的主要任务就是如何根据IR图中的信息在众多算子中选择出最合适的一个算子去目标设备上执行。
|
||||
过计算图优化后,需要对IR图上的每个节点进行算子选择,才能生成真正在设备上执行的算子序列。由于IR图上的节点可能有后端的很多算子与其对应,不同规格的算子在不同的情况下执行效率各不相同,在算子选择阶段的主要任务就是如何根据IR图中的信息在众多算子中选择出最合适的一个算子去目标设备上执行。
|
||||
|
||||
### 算子选择的基础概念
|
||||
|
||||
经历了后端的图优化后,IR图中的每一个节点都有一组算子与之对应。此时的IR图中的每一个节点可以认为是用户可见的最小硬件执行单元。但是此时IR图中的一个节点代表了用户代码的一个操作,对于这个操作还没有具体生成有关设备信息的细节描述。这些信息是算子选择所选择的内容信息,我们称之为算子信息。算子信息主要包括以下内容:
|
||||
经历了后端的图优化后,IR图中的每一个节点都有一组算子与之对应。此时的IR图中的每一个节点可以认为是用户可见的最小硬件执行单元,代表了用户代码的一个操作,对于这个操作还没有具体生成有关设备信息的细节描述。这些信息是算子选择所选择的内容信息,称之为算子信息。算子信息主要包括以下内容:
|
||||
|
||||
1. 针对不同特点的计算平台和不同的算子,为了追求最好的性能,一般都需要选择不同的数据排布格式。机器学习系统常见的数据排布格式有NCHW和NHWC等。
|
||||
|
||||
@@ -19,23 +19,23 @@
|
||||
: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的数据逻辑示意图。
|
||||
|
||||

|
||||
: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格式。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`nchw`
|
||||
|
||||
类似的NHWC数据格式是先取C方向数据,再取W方向,然后是H方向,最后取N方向。NHWC是Tensorflow默认的数据格式。这种格式在PyTorch中称为Chanel-Last。
|
||||
类似的NHWC数据格式是先取C方向数据,再取W方向,然后是H方向,最后取N方向。NHWC是Tensorflow默认的数据格式。这种格式在PyTorch中称为Channel-Last。
|
||||
$$offsetnhwc(n,h,w,c) = n*HWC + h*WC + w*C +c$$
|
||||
:numref:`nchwandnhwc`展示了不同数据格式下逻辑排布到内存物理侧数据排布的映射。\[x:1\]代表从最内侧维度到最下一维度的索引变换。比如\[a:1\]表示当前行W轴结束后,下一个H轴排布。\[b:1\]表示最内侧C轴排布完成后进行按照W轴进行排列。
|
||||
|
||||
@@ -49,38 +49,35 @@ $$offsetnhwc(n,h,w,c) = n*HWC + h*WC + w*C +c$$
|
||||
|
||||
**数据精度**
|
||||
|
||||
通常深度学习的系统,一般使用的是单精度float(Single
|
||||
Precision)浮点表示。这种数据类型占用32位内存。还有一种精度较低的数据类型float16,其内部占用了16位的内存。由于很多硬件会对float16数据类型进行优化,float16半精度的计算吞吐量可以是float32的$2\sim 8$倍,且float16可以占用的数据更小,这样可以输入更大的BatchSize,进而减少总体训练时间。接下来我们详细看一下半精度浮点数与单精度浮点数的区别。
|
||||
通常深度学习的系统,使用的是单精度(float32)表示。这种数据类型占用32位内存。还有一种精度较低的数据类型为半精度(float16),其内部占用了16位的内存。由于很多硬件会对半精度数据类型进行优化,半精度的计算吞吐量可以是单精度的$2\sim 8$倍,且半精度占用的内存更小,这样可以输入更大的批大小(BatchSize),进而减少总体训练时间。接下来详细看一下半精度浮点数与精度浮点数的区别。
|
||||
|
||||

|
||||
: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图中推导出的数据类型和内存排布格式选择对应的算子。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`select_kernel`
|
||||
|
||||
理想情况下算子选择所选择出的算子类型,应该与用户预期的类型保持一致。但是由于软硬件的限制,很可能算子的数据类型不能满足用户所期待的数据类型,此时需要对该节点进行升精度或者降精度处理才能匹配到合适的算子。比如在MindSpore
|
||||
的Ascend后端由于硬件限制导致Conv2D算子只存在float16一种数据类型。如果用户设置的整网使用的数据类型为float32数据,那么只能对Conv2D算子的输入数据进行降精度处理,即将输入数据类型从float32转换成float16。
|
||||
理想情况下算子选择所选择出的算子类型,应该与用户预期的类型保持一致。但是由于软硬件的限制,很可能算子的数据类型不能满足用户所期待的数据类型,此时需要对该节点进行升精度或者降精度处理才能匹配到合适的算子。比如在MindSpore 的Ascend后端由于硬件限制导致Conv2D算子只存在float16一种数据类型。如果用户设置的整网使用的数据类型为float32数据,那么只能对Conv2D算子的输入数据进行降精度处理,即将输入数据类型从float32转换成float16。
|
||||
|
||||
算子的数据排布格式转换是一个比较耗时的操作,为了避免频繁的格式转换所带来的内存搬运开销,数据应该尽可能地以同样的格式在算子之间传递,算子和算子的衔接要尽可能少的出现数据排布格式不一致的现象。另外,数据类型不同导致的降精度可能会使得误差变大,收敛速度变慢甚至不收敛,所以数据类型的选择也要结合具体算子分析。
|
||||
|
||||
|
||||
@@ -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芯片可以访问设备上的内存,却无法访问主机上的内存。因此,在网络训练过程中,往往需要从磁盘加载数据到主机内存中,然后在主机内存中做数据处理,再从主机内存拷贝到设备内存中,最后设备才能合法地访问数据。算子全部计算完成后,用户要获取训练结果,又需要把数据从设备内存拷贝到主机内存中。
|
||||
|
||||

|
||||
: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字节为一组进行访问,如果被搬运的内存大小不是这些值的倍数,内存后面会填充相应数量的空数据以使得内存长度达到这些值的倍数。因此,访问非对齐的内存可能会更加耗时。
|
||||
|
||||

|
||||
: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上申请一次足够大的内存,后续算子的内存分配都是通过指针偏移进行分配,减少了直接从设备申请内存的耗时。
|
||||
|
||||

|
||||
: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地址的目的。
|
||||
|
||||

|
||||
: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框架提出了一种新的内存分配算法
|
||||
SOMAS(Safe Optimized Memory Allocation
|
||||
Solver)。SOMAS将计算图并行流与数据依赖进行聚合分析,得到算子间祖先关系,构建张量全局生命周期互斥约束,使用多种启发式算法求解最优的内存静态规划,实现逼近理论极限的内存复用,从而提升支持的内存大小。
|
||||
SOMAS(Safe 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`所示,可以将多个通信算子融合成一个,为通信算子的输入分配连续的内存,从而减少通信的次数。
|
||||
又比如分布式训练中的神经网络权重初始化,通常将一个训练进程中的权重初始化,然后将该权重广播到其他进程中。当一个网络有较多权重的时候,需要多次进行广播。通常可以为所有权重分配连续的内存地址,然后广播一次,节省大量通信的耗时。
|
||||
|
||||

|
||||
@@ -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'内存的申请。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`inplace-op`
|
||||
|
||||
这节我们简单介绍了Device内存的概念,内存分配的流程,和一些优化内存分配的方法。内存分配是编译器后端的最重要部分之一,内存的合理分配,不仅关系到相同芯片上能否支持更大的网络模型,也关系到模型在硬件上的执行效率。
|
||||
这节简单介绍了设备内存的概念,内存分配的流程,和一些优化内存分配的方法。内存分配是编译器后端的最重要部分之一,内存的合理分配,不仅关系到相同内存容量下能否支持更大的网络模型,也关系到模型在硬件上的执行效率。
|
||||
|
||||
193
chapter_backend_and_runtime/op_compiler.md
Normal file
@@ -0,0 +1,193 @@
|
||||
## 算子编译器
|
||||
|
||||
算子编译器,顾名思义,即对算子进行编译优化的工具。这里所谓的"算子"可以来自于整个神经网络中的一部分,也可以来自于通过领域特定语言(Domain
|
||||
Specific Language,
|
||||
DSL)实现的代码。而所谓编译,通俗来说起到的是针对目标语言进行**表达**和**转换**。
|
||||
|
||||
从目的上来说,算子编译器致力于提高算子的**执行性能**。从工程实现上来说,算子编译器的输入一般为Python等**动态语言**描述的张量计算,而输出一般为**特定AI芯片**上的可执行文件。
|
||||
|
||||
### 算子调度策略
|
||||
|
||||
算子编译器为了实现较好地优化加速,会根据现代计算机体系结构特点,将程序运行中的每个细小操作抽象为"调度策略"。
|
||||
|
||||
如果不考虑优化和实际中芯片的体系结构特点,只需要按照算子表达式的**计算逻辑**,把输入进来的张量全部加载进计算核心里完成计算,之后再把计算结果从计算核心里面取出并保存下来即可。这里的**计算逻辑**指的就是基本数学运算(如加、减、乘、除)以及其他函数表达式(如卷积、转置、损失函数)等。
|
||||
|
||||
但是 :numref:`fig:ch05/ch05-memory_architecture`向我们展示的现代计算机存储结构表明:越靠近金字塔顶尖的存储器造价越高但是访问速度越快。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`fig:ch05/ch05-memory_architecture`
|
||||
|
||||
|
||||
基于这一硬件设计的事实,有局部性(Locality)概念:
|
||||
|
||||
(1)时间局部性,相对较短时间内重复访问特定内存位置。如多次访问L1高速缓存的同一位置的效率会高于多次访问L1中不同位置的效率。
|
||||
|
||||
(2)空间局部性,在相对较近的存储位置进行访问。如,多次访问L1中相邻位置的效率会高于来回在L1和主存跳跃访问的效率。
|
||||
|
||||
满足这两者任一都会有较好的性能提升。基于局部性概念,希望尽量把需要重复处理的数据放在固定的内存位置,且这一内存位置离处理器越近越好,以通过提升访存速度而进行性能提升。
|
||||
|
||||
另外,把传统的串行计算任务按逻辑和数据依赖关系进行分割后,有机会得到多组互不相关的数据,并把他们同时计算,如 :numref:`fig:ch05/ch05-parallel_computing`所示。
|
||||
|
||||

|
||||
: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`中箭头)。
|
||||
|
||||

|
||||
: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`中虚线方向分割并以绿色块划分后,可以实现并行计算。
|
||||
该算法较为复杂,限于篇幅,在这里不再详细展开。读者可移步到笔者专门为此例写的文章-《深度学习编译之多面体模型编译------以优化简单的两层循环代码为例》详读。
|
||||
|
||||
|
||||

|
||||
: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`所示。前者对应的是带有向量计算指令的芯片;后者对应的是带有明显的线程分级的芯片。另外,也有一些芯片开始结合这两种编程模型的特点,既有类似线程并行计算的概念,又有向量指令的支持。针对不同的编程模型,算子编译器在进行优化(如向量化等)时的策略也会有所不同。
|
||||
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`fig:ch05/ch05-SIMD`
|
||||
|
||||

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

|
||||
:width:`800px`
|
||||
@@ -23,3 +23,7 @@
|
||||
### 计算调度与执行
|
||||
|
||||
经过算子选择与内存分配之后,计算任务可以通过运行时完成计算的调度与在硬件上的执行。根据是否将算子编译为计算图,计算的调度可以分为单算子调度与计算图调度两种方式。而根据硬件提供的能力差异,计算图的执行方式又可以分为逐算子下发执行的交互式执行以及将整个计算图或者部分子图一次性下发到硬件的下沉式执行两种模式。
|
||||
|
||||
### 算子编译器
|
||||
|
||||
作为AI编译器中一个重要组成部分,算子编译器把单个简单或复杂的算子经过表达和优化后编译为一个单独的可执行文件。目前业界面对算子编译器仍有许多有趣的问题尚未得出明确结论,相关的处理逻辑与方法也尚未收敛。本小节希望将这些问题简单抛出,并给出业界比较典型的几种处理方式。若能对业界朋友们和同学们有所启发甚至若能对这些问题起到促进收敛的作用,那真是再好不过!目前尚待收敛的问题包括而不限于:如何通过算子编译器进行性能优化?算子编译器如何兼容不同体系结构特点的芯片?面对输入Python代码的灵活性以及神经网络训练时动态性的情况,该如何充分将这些完美表达出来?
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
## 计算图的设计背景和作用
|
||||
|
||||

|
||||

|
||||
:width:`800px`
|
||||
:label:`dag`
|
||||
|
||||
早期的机器学习框架主要为了支持基于卷积神经网络的图像分类问题。这些神经网络的拓扑结构简单(神经网络层往往通过串行构建),他们的拓扑结构可以用简单的配置文件来表达(例如Caffe中基于Protocol
|
||||
Buffer格式的模型定义)。随着机器学习的进一步发展,模型的拓扑日益复杂(包括混合专家,生成对抗网络,多注意力模型)。这些复杂的模型拓扑结构(例如:分支结构,带有条件的if-else循环)会影响模型算子的执行、自动化梯度计算(一般称为自动微分)以及训练参数的自动化判断。为此,我们需要一个更加通用的技术来执行任意机器学习模型,计算图应运而生。综合来看,计算图对于一个机器学习框架提供了以下几个关键作用:
|
||||
早期机器学习框架主要针对全连接和卷积神经网络设计,这些神经网络的拓扑结构简单,神经网络层之间通过串行连接。因此,它们的拓扑结构可以用简易的配置文件表达(例如Caffe基于Protocol Buffer格式的模型定义)。
|
||||
|
||||
- **对于输入数据、算子和算子执行顺序的统一表达。**
|
||||
机器学习框架用户可以用多种高层次编程语言(Python,Julia和C++)来编写训练程序。这些高层次程序需要统一的表达成框架底层C和C++算子的执行。因此,计算图的第一个核心作用是可以作为一个统一的数据结构来表达用户用不同语言编写的训练程序。这个数据结构可以准确表述用户的输入数据、模型所带有的多个算子,以及算子之间的执行顺序。
|
||||
现代机器学习模型的拓扑结构日益复杂,显著的例子包括混合专家模型、生成对抗网络、注意力模型等。复杂的模型结构(例如带有分支的循环结构等)需要机器学习框架能够对模型算子的执行依赖关系、梯度计算以及训练参数进行快速高效的分析,便于优化模型结构、制定调度执行策略以及实现自动化梯度计算,从而提高机器学习框架训练复杂模型的效率。因此,机器学习系统设计者需要一个通用的数据结构来理解、表达和执行机器学习模型。为了应对这个需求,如:numref:`dag`所示基于计算图的机器学习框架应运而生,框架延续前端语言与后端语言分离的设计。从高层次来看,计算图实现了以下关键功能:
|
||||
|
||||
- **定义中间状态和模型状态。**
|
||||
在一个用户训练程序中,用户会生成中间变量(神经网络层之间传递的激活值和梯度)来完成复杂的训练过程。而这其中,只有模型参数需要最后持久化,从而为后续的模型推理做准备。通过计算图,机器学习框架可以准确分析出中间状态的生命周期(一个中间变量何时生成,以及何时销毁),从而帮助框架更好的管理内存。
|
||||
- **统一的计算过程表达。**
|
||||
在编写机器学习模型程序的过程中,用户希望使用高层次编程语言(如Python、Julia和C++)。然而,硬件加速器等设备往往只提供了C和C++编程接口,因此机器学习系统的实现通常需要基于C和C++。用不同的高层次语言编写的程序因此需要被表达为一个统一的数据结构,从而被底层共享的C和C++系统模块执行。这个数据结构(即计算图)可以表述用户的输入数据、模型中的计算逻辑(通常称为算子)以及算子之间的执行顺序。
|
||||
|
||||
- **自动化计算梯度。**
|
||||
用户给定的训练程序仅仅包含了一个机器学习模型如何将用户输入(一般为训练数据)转化为输出(一般为损失函数)的过程。而为了训练这个模型,机器学习框架需要分析任意机器学习模型和其中的算子,找出自动化计算梯度的方法。计算图的出现让自动化分析模型定义和自动化计算梯度成为可能。
|
||||
用户的模型训练程序接收训练数据集的数据样本,通过神经网络前向计算,最终计算出损失值。根据损失值,机器学习系统为每个模型参数计算出梯度来更新模型参数。考虑到用户可以写出任意的模型拓扑和损失值计算方法,计算梯度的方法必须通用并且能实现自动运行。计算图可以辅助机器学习系统快速分析参数之间的梯度传递关系,实现自动化计算梯度的目标。
|
||||
|
||||
- **分析模型变量生命周期。**
|
||||
在用户训练模型的过程中,系统会通过计算产生临时的中间变量,如前向计算中的激活值和反向计算中的梯度。前向计算的中间变量可能与梯度共同参与到模型的参数更新过程中。通过计算图,系统可以准确分析出中间变量的生命周期(一个中间变量生成以及销毁时机),从而帮助框架优化内存管理。
|
||||
|
||||
- **优化程序执行。**
|
||||
用户给定的模型程序往往是"串行化"地连接起来多个神经网络层。通过利用计算图来分析模型中算子的执行关系,机器学习框架可以更好地发现将算子进行异步执行的机会,从而以更快的速度完成模型程序的执行。
|
||||
用户给定的模型程序具备不同的网络拓扑结构。机器学习框架利用计算图来分析模型结构和算子执行依赖关系,并自动寻找算子并行计算的策略,从而提高模型的执行效率。
|
||||
@@ -1,94 +1,105 @@
|
||||
## 计算图的基本构成
|
||||
|
||||
计算图是用来表示深度学习网络模型在训练与推理过程中计算逻辑与状态的工具。计算框架在后端会将前端语言构建的神经网络模型前向计算与反向梯度计算以计算图的形式来进行表示。计算图由基本数据结构:张量(Tensor)和基本运算单元:算子(Operator)构成。在计算图中通常使用节点来表示算子,节点间的有向线段来表示张量状态,同时也描述了计算间的依赖关系。如 :numref:`simpledag`所示,将$\boldsymbol{Z}=relu(\boldsymbol{X}*\boldsymbol{Y})$转化为计算图表示,数据流将根据图中流向与算子进行前向计算和反向梯度计算来更新图中张量状态,以此达到训练模型的目的。
|
||||
计算图由基本数据结构张量(Tensor)和基本运算单元算子构成。在计算图中通常使用节点来表示算子,节点间的有向边(Directed Edge)来表示张量状态,同时也描述了计算间的依赖关系。如 :numref:`simpledag`所示,将$\boldsymbol{Z}=ReLU(\boldsymbol{X}\times\boldsymbol{Y})$转化为计算图表示。
|
||||
|
||||

|
||||

|
||||
:width:`300px`
|
||||
:label:`simpledag`
|
||||
### 张量和算子
|
||||
|
||||
在计算框架中,基础组件包含张量和算子,张量是基础数据结构,算子是基本运算单元。在数学中定义张量是基于向量与矩阵的推广,涵盖标量、向量与矩阵的概念。可以将标量理解为零阶张量,向量为一阶张量,我们熟悉的RGB彩色图像即为三阶张量。在计算框架中张量不仅存储数据,还存储数据类型、数据形状、维度或秩以及梯度传递状态等多个属性,如:numref:`tensor_attr`所示,列举了主要的属性和功能。可以通过[代码示例](https://github.com/openmlsys/openmlsys-pytorch/blob/master/chapter_computational_graph/tensor.py)查看张量的属性和部分操作展示
|
||||
在数学中定义张量是基于标量与向量的推广。在机器学习领域内将多维数据称为张量,使用秩来表示张量的轴数或维度。如:numref:`tensor`所示,标量为零秩张量,包含单个数值,没有轴;向量为一秩张量,拥有一个轴;拥有RGB三个通道的彩色图像即为三秩张量,包含三个轴。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`tensor`
|
||||
|
||||
在机器学习框架中张量不仅存储数据,还需要存储张量的数据类型、数据形状、秩以及梯度传递状态等多个属性,如:numref:`tensor_attr`所示,列举了主要的属性和功能。
|
||||
|
||||
:张量属性
|
||||
|
||||
|张量属性|功能|
|
||||
|:-: |:-:|
|
||||
| 形状(shape) | 存储张量的每个维度的长度,如[3,3,3] |
|
||||
| 维度或秩(dim) | 表示张量维度的数量,标量为0,向量为1、矩阵为2|
|
||||
| 数据类型(dtype) |表示存储的数据类型,如bool、int8、int16、float32、float64等|
|
||||
| 存储位置(device) | 创建张量时可以指定存储的设备位置,如CPU、GPU等|
|
||||
| 名字(name) | 张量的标识符 |
|
||||
| 张量属性 | 功能 |
|
||||
| -------------- | -------|
|
||||
| 形状(shape) | 存储张量的每个维度的长度,如[3,3,3] |
|
||||
| 秩或维数(dim) | 表示张量的轴数或者维数,标量为0,向量为1。 |
|
||||
| 数据类型(dtype) | 表示存储的数据类型,如bool、uint8、int16、float32、float64等 |
|
||||
| 存储位置(device) | 创建张量时可以指定存储的设备位置,如CPU、GPU等 |
|
||||
| 名字(name) | 张量的标识符 |
|
||||
:label:`tensor_attr`
|
||||
|
||||
张量的形状是一个重要的属性,它记录了每个轴的长度,也就是张量每个维度的元素数量。秩则代表张量的轴数或者阶数。张量中通常可以保存布尔类型、浮点数、整型数以及复数和字符串数据。每一个张量都具有唯一的数据类型,在计算过程中会对所有参与运算的张量进行类型检查,当发现类型不匹配时就会报错。部分特殊的计算则必须使用指定的数据类型,比如逻辑运算应为布尔类型。在部分计算框架中张量的属性中包含可以指明张量存储的设备位置,比如存储于CPU、GPU等。张量数据的存储状态可以分为可变和不可变两种,不可变张量一般用于用户初始化的数据或者网络模型输入的数据;而可变张量则存储网络权重参数,根据梯度信息更新自身数据。
|
||||
以图像数据为例来具体说明张量属性的作用。当机器学习框架读取一张高为96像素、宽为96像素的RGB三通道图像,并将图像数据转换为张量存储时。该张量的形状属性则为[96,96,3]分别代表高、宽、通道的数量,秩即为3。原始RGB图像每个像素上的数据以0-255的无符号整数来表示色彩,因此图像张量存储时会将数据类型属性设置为uint8格式。将图像数据传输给卷积网络模型进行网络训练前,会对图像数据进行归一化处理,此时数据类型属性会重新设置为float32格式,因为通常机器学习框架在训练模型时默认采用float32格式。
|
||||
|
||||
如 :numref:`tensor`,标量就是一个零阶张量,包含单个数值但没有轴信息。向量即为一阶张量,具有一个轴。二阶张量具有两个轴即秩为二。
|
||||
机器学习框架在训练时需要确定在CPU、GPU或其他硬件上执行计算,数据和权重参数也应当存放在对应的硬件内存中才能正确被调用,张量存储位置属性则用来指明存储的设备位置。存储位置属性通常由机器学习框架根据硬件环境自动赋予张量。在模型训练过程中,张量数据的存储状态可以分为可变和不可变两种,可变张量存储神经网络模型权重参数,根据梯度信息更新自身数据,如参与卷积运算的卷积核张量;不可变张量用于用户初始化的数据或者输入模型的数据,如上文提到的图像数据张量。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`tensor`
|
||||
通常我们使用的张量是"整齐"的,每个轴上的具有相同的元素个数,就像一个"矩形"或者"立方体"。在特定的环境中,也会使用特殊类型的张量,比如不规则张量和稀疏张量,如 :numref:`tensorclass`中所示。不规则张量在某个轴上可能具有不同的元素个数,它们支持存储和处理包含非均匀形状的数据,在自然语言处理领域,不规则张量可以存储不同长度文本的信息。稀疏张量则通常应用于图数据与图神经网络中,采用特殊的存储格式如坐标表格式(Coordinate
|
||||
List, COO),可以高效存储稀疏数据,节省存储空间。
|
||||
那么在机器学习场景下的张量一般长什么样子呢?上文提到的图像数据张量以及卷积核张量,形状一般是“整齐”的。即每个轴上的具有相同的元素个数,就像一个“矩形”或者“立方体”。在特定的环境中,也会使用特殊类型的张量,比如不规则张量和稀疏张量。如 :numref:`tensorclass`中所示,不规则张量在某个轴上可能具有不同的元素个数,它们支持存储和处理包含非均匀形状的数据,如在自然语言处理领域中不同长度文本的信息;稀疏张量则通常应用于图数据与图神经网络中,采用特殊的存储格式如坐标表格式(Coordinate List,COO),可以高效存储稀疏数据节省存储空间。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`tensorclass`
|
||||
算子是构成神经网络的基本计算单元。算子按照功能可以分为张量操作、神经网络操作、数据流操作和控制流操作等。
|
||||
|
||||
- **张量操作**:包括张量的结构操作和张量的数学运算。张量结构操作有:张量创建、索引切片、维度变换和合并分割等。张量的数学运算包含标量运算、向量运算和矩阵运算。标量运算符的特点是对张量实施逐元素运算。向量运算符只在一个特定轴上运算,将一个向量映射到一个标量或者另外一个向量。矩阵运算包括矩阵乘法、矩阵范数、矩阵行列式、矩阵求特征值、矩阵分解等运算。
|
||||
算子是构成神经网络的基本计算单元,对张量数据进行加工处理,实现了多种机器学习中常用的计算逻辑,包括数据转换、条件控制、数学运算等。为了便于梳理算子类别,按照功能将算子分类为张量操作算子、神经网络算子、数据流算子和控制流算子等。
|
||||
|
||||
- **神经网络操作**:包括特征提取、激活函数、损失函数、优化算法等。特征提取是机器学习中的常见操作,核心是提取比原输入更具代表性的张量,常见的卷积操作就是特征提取算子。激活函数(Activation
|
||||
Function)负责将神经网络层的输入映射到输出端。引入激活函数是为了增加神经网络模型的非线性,没有激活函数的每层都相当于矩阵相乘。常见的激活函数包括S型生长曲线(Sigmoid)、线性矫正单元(Rectified Linear Unit, ReLU)等。损失函数(Loss Function)是用来估量模型的预测值与真实值之间的不一致程度。优化算法基于梯度采用不同策略更新参数权值来最小化损失函数,常见的优化算法有随机梯度下降法(Stochastic Gradient Descent, SGD)、自适应矩估计(Adaptive Moment Estimation, Adam)等。
|
||||
- **张量操作算子**:包括张量的结构操作和数学运算。张量的结构操作通常用于张量的形状、维度调整以及张量合并等,比如在卷积神经网络中可以选择图像数据以通道在前或者通道在后的格式来进行计算,调整图像张量的通道顺序就需要结构操作。张量相关的数学运算算子,例如矩阵乘法、计算范数、行列式和特征值计算,在机器学习模型的梯度计算中经常被使用到。
|
||||
|
||||
- **数据流操作**:包含数据的预处理与数据载入相关算子,数据预处理算子主要是针对图像数据和文本数据的裁剪填充、归一化、数据增强等操作。数据载入通常会对数据集进行随机乱序(Shuffle)、分批次载入(Batch)以及预载入(Prefetch)等操作。数据流操作主要功能是对原始数据进行处理后,转换为计算框架本身支持的数据格式,并且按照迭代次数输入给网络进行训练或者推理,提升数据载入速度,减少内存占用空间,降低网络训练等待时间。
|
||||
- **神经网络算子**:包括特征提取、激活函数、损失函数、优化算法等,是构建神经网络模型频繁使用的核心算子。常见的卷积操作就是特征提取算子,用来提取比原输入更具代表性的特征张量。激活函数能够增加神经网络模型非线性能力,帮助模型表达更加复杂的数据特征关系。损失函数和优化算法则与模型参数训练更新息息相关。
|
||||
|
||||
- **控制流操作**:可以控制计算图中的数据流向,当表示灵活复杂的模型时需要控制流。使用频率比较高的控制流算子有条件运算符和循环运算符。控制流操作一般分为两类,计算框架本身提供的控制流操作符和前端语言控制流操作符。控制流操作不仅会影响神经网络模型前向运算的数据流向,也会影响反向梯度运算的数据流向。
|
||||
- **数据流算子**:包含数据的预处理与数据载入相关算子,数据预处理算子主要是针对图像数据和文本数据的裁剪填充、归一化、数据增强等操作。数据载入算子通常会对数据集进行随机乱序(Shuffle)、分批次载入(Batch)以及预载入(Pre-fetch)等操作。数据流操作主要功能是对原始数据进行处理后,转换为机器学习框架本身支持的数据格式,并且按照迭代次数输入给网络进行训练或者推理,提升数据载入速度,减少内存占用空间,降低网络训练数据等待时间。
|
||||
|
||||
- **控制流算子**:可以控制计算图中的数据流向,当表示灵活复杂的模型时需要控制流。使用频率比较高的控制流算子有条件运算符和循环运算符。控制流操作一般分为两类,机器学习框架本身提供的控制流操作符和前端语言控制流操作符。控制流操作不仅会影响神经网络模型前向运算的数据流向,也会影响反向梯度运算的数据流向。
|
||||
|
||||
### 计算依赖
|
||||
|
||||
在计算图中,算子之间存在依赖关系,而这种依赖关系影响了算子的执行顺序与并行情况。此外在深度学习算法模型中,计算图是一个有向无环图,也即在计算图中造成循环依赖的数据流向是不被允许的。为了理解计算依赖关系并且分析计算图中循环与循环依赖之间的区别,下面将对计算图中的计算节点依赖关系进行讲解。
|
||||
在计算图中,算子之间存在依赖关系,而这种依赖关系影响了算子的执行顺序与并行情况。机器学习算法模型中,计算图是一个有向无环图,即在计算图中造成循环依赖(Circular Dependency)的数据流向是不被允许的。循环依赖会形成计算逻辑上的死循环,模型的训练程序将无法正常结束,而流动在循环依赖闭环上的数据将会趋向于无穷大或者零成为无效数据。为了分析计算执行顺序和模型拓扑设计思路,下面将对计算图中的计算节点依赖关系进行讲解。
|
||||
|
||||
如 :numref:`dependence`中所示,在此计算图中,若将Matmul1算子移除则该节点无输出,导致后续的激活函数无法得到输入,从而计算图中的数据流动中断,这表明计算图中的算子间具有依赖关系并且存在传递性。
|
||||
|
||||

|
||||
:width:`400px`
|
||||
:label:`dependence`
|
||||
|
||||
如 :numref:`dependence`中所示,在此简单的计算图中,若将$\mathbf{Matmul1}$算子移除则该节点无输出,导致后续的激活函数无法得到输入,从而计算图中的数据流动中断,这表明计算图中的算子间具有依赖关系并且存在传递性。我们对依赖关系进行区分如下:
|
||||
将依赖关系进行区分如下:
|
||||
|
||||
- **直接依赖**:节点$\mathbf{ReLU1}$直接依赖于节点$\mathbf{Matmul1}$,即如果节点$\mathbf{ReLU1}$要执行运算,必须接受直接来自节点$\mathbf{Matmul1}$的输出数据;
|
||||
- **直接依赖**:节点ReLU1直接依赖于节点Matmul1,即如果节点ReLU1要执行运算,必须接受直接来自节点Matmul1的输出数据;
|
||||
|
||||
- **间接依赖**:节点$\mathbf{Add}$间接依赖于节点$\mathbf{Matmul1}$,即节点$\mathbf{Matmul1}$的数据并未直接传输给节点$\mathbf{Add}$,而是经过了某个或者某些中间节点进行处理后再传输给节点$\mathbf{Add}$,而这些中间节点可能是节点$\mathbf{Add}$的直接依赖节点,也可能是间接依赖节点;
|
||||
- **间接依赖**:节点Add间接依赖于节点Matmul1,即节点Matmul1的数据并未直接传输给节点Add,而是经过了某个或者某些中间节点进行处理后再传输给节点Add,而这些中间节点可能是节点Add的直接依赖节点,也可能是间接依赖节点;
|
||||
|
||||
- **相互独立**:在计算图中节点$\mathbf{Matmul1}$与节点$\mathbf{Matmul2}$之间并无数据输入输出依赖关系,所以这两个节点间相互独立。
|
||||
- **相互独立**:在计算图中节点Matmul1与节点Matmul2之间并无数据输入输出依赖关系,所以这两个节点间相互独立。
|
||||
|
||||
掌握依赖关系后,分析 :numref:`recurrent`可以得出节点$\mathbf{Add}$间接依赖于节点$\mathbf{Matmul}$,而节点$\mathbf{Matmul}$直接依赖于节点$\mathbf{Add}$,此时两个节点互相等待对方计算完成输出数据,将无法执行计算任务。若我们手动同时给两个节点赋予输入,计算将持续不间断进行,模型训练将无法停止造成死循环。循环依赖产生正反馈数据流,被传递的数值可能在正方向上无限放大,导致数值上溢,或者负方向上放大导致数值下溢,也可能导致数值无限逼近于0,这些情况都会致使模型训练无法得到预期结果。在构建深度学习模型时,应避免算子间产生循环依赖。
|
||||
掌握依赖关系后,分析 :numref:`recurrent`可以得出节点Add间接依赖于节点Matmul,而节点Matmul直接依赖于节点Add,此时两个节点互相等待对方计算完成输出数据,将无法执行计算任务。若我们手动同时给两个节点赋予输入,计算将持续不间断进行,模型训练将无法停止造成死循环。循环依赖产生正反馈数据流,被传递的数值可能在正方向上无限放大,导致数值上溢,或者负方向上放大导致数值下溢,也可能导致数值无限逼近于0,这些情况都会致使模型训练无法得到预期结果。在构建深度学习模型时,应避免算子间产生循环依赖。
|
||||
|
||||

|
||||
:width:`300px`
|
||||
:label:`recurrent`
|
||||
|
||||
在深度学习计算框架中,表示循环关系通常是以**展开**机制(Unrolling)来实现。当需要实现循环关系时,循环体的计算子图按照迭代次数进行复制,将代表相邻迭代轮次的子图进行串联,相邻迭代轮次的计算子图之间就是直接依赖关系。循环三次的计算图进行展开如 :numref:`unroll`。在计算图中,每一个张量和运算符都具有独特的标识符,即使是相同的操作运算,在参与不同计算任务时都具有不同的标识符。区分循环关系和循环依赖的关键在于,是否两个独特标识符之间的运算互相具有直接依赖和相互依赖。循环关系在展开复制计算子图的时候会给复制的所有张量和运算符赋予新的标识符,区分被复制的原始子图,以避免形成循环依赖。
|
||||
在机器学习框架中,表示循环关系(Loop Iteration)通常是以**展开**机制(Unrolling)来实现。循环三次的计算图进行展开如 :numref:`unroll`,循环体的计算子图按照迭代次数进行复制3次,将代表相邻迭代轮次的子图进行串联,相邻迭代轮次的计算子图之间是直接依赖关系。在计算图中,每一个张量和运算符都具有独特的标识符,即使是相同的操作运算,在参与循环不同迭代中的计算任务时具有不同的标识符。区分循环关系和循环依赖的关键在于,具有两个独特标识符的计算节点之间是否存在相互依赖关系。循环关系在展开复制计算子图的时候会给复制的所有张量和运算符赋予新的标识符,区分被复制的原始子图,以避免形成循环依赖。
|
||||
|
||||

|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`unroll`
|
||||
|
||||
### 控制流
|
||||
|
||||
控制流能够设定特定的顺序执行计算任务。若计算图中无控制流,则每个节点只执行一次,当所有节点按照顺序执行完时,计算图即完成计算。加入控制流后可以让计算图中某些节点循环执行任意次数,也可以根据条件判断选择某些节点不执行,控制流使得我们可以构建更加灵活和复杂的模型。许多机器学习模型依赖控制流进行训练和推理,特别是基于递归神经网络和强化学习的模型就依赖于循环递归关系和依据数据的条件执行。
|
||||
控制流能够设定特定的顺序执行计算任务,帮助构建更加灵活和复杂的模型。在模型中引入控制流后可以让计算图中某些节点循环执行任意次数,也可以根据条件判断选择某些节点不执行。许多深度学习模型依赖控制流进行训练和推理,基于递归神经网络和强化学习的模型就依赖于循环递归关系和依据输入数据状态条件执行计算。
|
||||
|
||||
为了提高性能、可扩展性和表达能力,计算框架必须支持控制流。目前主流的计算框架中通常使用两种方式来提供控制流:
|
||||
目前主流的机器学习框架中通常使用两种方式来提供控制流:
|
||||
|
||||
- **计算框架控制原语**:计算框架在内部设计了低级别细粒度的控制原语运算符,通过原语运算符的结合使用来实现控制流,这种实现方式也被称为图内方法(In-graph approach)。此类方法的代表就是TensorFlow中的Switch、Merge、Enter、Exit、NextIteration五个原语。TensorFlow通过组合五个原语提供*tf.cond()*和*tf.while_loop()*来实现条件控制和循环控制。
|
||||
- **前端语言控制流**:通过Python语言控制流语句来进行计算图中的控制决策。使用前端语言控制流构建模型结构简便快捷,但是由于机器学习框架的数据计算运行在后端硬件,造成控制流和数据流之间的分离,计算图不能完整运行在后端计算硬件上。因此这类实现方式也被称为图外方法(Out-of-Graph Approach)
|
||||
|
||||
- **前端语言控制流**:通过高级语言Python、C++的控制流语句来进行计算图中的控制决策,这类实现方式也被称为图外方法(Out-of-graph approach)。计算框架PyTorch、MindSpore中就直接使用Python的控制流,将控制流和数据流之间保持了严格的分离。
|
||||
- **机器学习框架控制原语**:机器学习框架在内部设计了低级别细粒度的控制原语运算符。低级别控制原语运算符能够执行在计算硬件上,与模型结构结合使用可将整体计算图在后端运算,这种实现方式也被称为图内方法(In-Graph Approach)。
|
||||
|
||||
图内方法控制流采用框架原语实现,在进行模型编译、优化与运行时都具备优势,并且可以准确的判定机器学习模型中计算梯度时需要缓存的变量,提高运行效率,同时由于不依赖外部语言便于部署到不同环境中去。但由于控制原语缺乏进一步的抽象,对于用户不友好,需要掌握控制原语的使用方法,结合前端语言使用才能描述复杂模型结构。
|
||||
为什么机器学习框架会采用两种不同的原理来实现控制流呢?为了解决这个疑问,首先了解两种方法在实现上的区别。
|
||||
|
||||
相对于图内方法,图外方法直接使用前端语言控制流则相对更加灵活易用,用户编写模型控制时更加便捷直观,其缺点在于若要将模型进行优化部署,则需要在编译阶段将前端语言的控制流转化为框架原语描述。
|
||||
使用Python语言编程的用户对于图外方法较为熟悉。图外方法允许用户直接使用if-else、while和for这些Python命令来构建控制流。该方法使用时灵活易用便捷直观。
|
||||
|
||||
目前在主流的深度学习计算框架中,均提供图外方法和图内方法支持。为了便于理解控制流对前向计算与反向计算的影响,后续的讲解均使用**图外方法**实现控制流。常见的控制流包括条件分支与循环两种。当模型包含控制流操作时,梯度在反向传播经过控制流时,需要在反向梯度计算图中也构造生成相应的控制流,才能够正确计算参与运算的张量梯度。
|
||||
而图内方法相比于图外方法则较为烦琐。TensorFlow中可以使用图内方法控制流算子(如tf.cond条件控制、tf.while\_loop循环控制和tf.case分支控制等)来构建模型控制流,这些算子是使用更加低级别的原语运算符组合而成。图内方法的控制流表达与用户常用的编程习惯并不一致,牺牲部分易用性换取的是计算性能提升。
|
||||
|
||||
下面这段代码描述了简单的条件控制,我们使用*matmul*表示矩阵乘法算子:
|
||||
图外方法虽然易用,但后端计算硬件可能无法支持前端语言的运行环境,导致无法直接执行前端语言控制流。而图内方法虽然编写烦琐,但可以不依赖前端语言环境直接在计算硬件上执行。在进行模型编译、优化与运行时都具备优势,提高运行效率。
|
||||
|
||||
因此两种控制流的实现方式其实对应着不同的使用场景。当需要在计算硬件上脱离前端语言环境执行模型训练、推理和部署等任务,需要采用图内方法来构建控制流。用户使用图外方法方便快速将算法转化为模型代码,方便验证模型构造的合理性。
|
||||
|
||||
目前在主流的机器学习框架中,均提供图外方法和图内方法支持。鉴于前端语言控制流使用频繁为人熟知,为了便于理解控制流对前向计算与反向计算的影响,后续的讲解均使用图外方法实现控制流。常见的控制流包括条件分支与循环两种。当模型包含控制流操作时,梯度在反向传播经过控制流时,需要在反向梯度计算图中也构造生成相应的控制流,才能够正确计算参与运算的张量梯度。
|
||||
|
||||
下面这段代码描述了简单的条件控制,matmul表示矩阵乘法算子:
|
||||
```python
|
||||
def control(A, B, C, conditional = True):
|
||||
if conditional:
|
||||
@@ -97,11 +108,13 @@ def control(A, B, C, conditional = True):
|
||||
y = matmul(A, C)
|
||||
return y
|
||||
```
|
||||

|
||||
|
||||
:numref:`if`描述上述代码的前向计算图和反向计算图。对于具有if条件的模型,梯度计算需要知道采用了条件的哪个分支,然后将梯度计算逻辑应用于该分支。在前向计算图中张量$\boldsymbol{C}$经过条件控制不参与计算,在反向计算时同样遵守控制流决策,不会计算关于张量$\boldsymbol{C}$的梯度。
|
||||
|
||||

|
||||
:width:`600px`
|
||||
:label:`if`
|
||||
|
||||
:numref:`if`描述上述代码的前向计算图和反向计算图。对于具有if-条件的模型,梯度计算需要知道采用了条件的哪个分支,然后将梯度逻辑应用于该分支。在前向计算图中张量${C}$经过条件控制不参与计算,在反向计算时同样遵守控制流决策,不会计算关于张量$C$的梯度。
|
||||
|
||||
当模型中有循环控制时,循环中的操作可以执行零次或者多次。此时采用展开机制,对每一次操作都赋予独特的运算标识符,以此来区分相同运算操作的多次调用。每一次循环都直接依赖于前一次循环的计算结果,所以在循环控制中需要维护一个张量列表,将循环迭代的中间结果缓存起来,这些中间结果将参与前向计算和梯度计算。下面这段代码描述了简单的循环控制,将其展开得到等价代码后,可以清楚的理解需要维护张量$\boldsymbol{X_i}$和$\boldsymbol{W_i}$的列表。
|
||||
```python
|
||||
@@ -116,36 +129,74 @@ def recurrent_control(X : Tensor, W : Sequence[Tensor]):
|
||||
Y = matmul(X2, W2)
|
||||
return Y
|
||||
```
|
||||
如 :numref:`while`描述了上述代码的前向计算图和反向计算图,循环控制的梯度同样也是一个循环,它与前向循环相迭代次数相同,执行循环体的梯度计算。循环体输出的梯度值作为下一次梯度计算的初始值,直至循环结束。
|
||||
如 :numref:`while`描述了上述代码的前向计算图和反向计算图,循环控制的梯度同样也是一个循环,它与前向循环的迭代次数相同。执行循环体的梯度计算中,循环体当前迭代计算输出的梯度值作为下一次迭代中梯度计算的输入值,直至循环结束。
|
||||
|
||||

|
||||

|
||||
:width:`600px`
|
||||
:label:`while`
|
||||
|
||||
### 基于链式法则计算梯度
|
||||
|
||||
在上一小节循环展开的例子中,当神经网络接收输入张量$\boldsymbol{Y}$后,输入数据根据计算图逐层进行计算并保存中间结果变量,直至经过多层的计算后最终产生输出$\boldsymbol{Y_3}$,这个过程我们称之为**前向传播**(Forward
|
||||
propagation)。在深度神经网络模型训练过程中,前向传播的输出结果与标签值可以产生一个损失函数结果。模型将来自损失函数的数据信息通过计算图反向流动,执行梯度计算来进行更新训练参数,这个过程我们称之为**反向传播**(Back
|
||||
propagation)。在神经网络模型中,反向传播通常使用损失函数关于参数的梯度来进行更新,也可以使用其他信息进行反向传播,在这里我们仅讨论一般情况。
|
||||
propagation)。在深度神经网络模型训练过程中,前向传播的输出结果与标签值通过计算产生一个损失函数结果。模型将来自损失函数的数据信息通过计算图反向传播,执行梯度计算来更新训练参数。在神经网络模型中,反向传播通常使用损失函数关于参数的梯度来进行更新,也可以使用其他信息进行反向传播,在这里仅讨论一般情况。
|
||||
|
||||
在这里我们简单回忆一下复合函数的链式法则公式。链式法则是微积分中的求导法则,用于求解复合函数中的导数。复合函数的导数是构成复合有限个函数在相应点的导数乘积。假设*f*和*g*是关于实数*x*的映射函数,设$y=g(x)$并且$z=f(y)=f(g(x))$,则*z*对*x*的导数即为:
|
||||
反向传播过程中,使用链式法则来计算参数的梯度信息。链式法则是微积分中的求导法则,用于求解复合函数中的导数。复合函数的导数是构成复合有限个函数在相应点的导数乘积。假设*f*和*g*是关于实数*x*的映射函数,设$y=g(x)$并且$z=f(y)=f(g(x))$,则*z*对*x*的导数即为:
|
||||
|
||||
$$
|
||||
\frac{dz}{dx}=\frac{dz}{dy}\frac{dy}{dx}$$
|
||||
\frac{dz}{dx}=\frac{dz}{dy}\frac{dy}{dx}~~~~~~~~~~~(3.1)$$
|
||||
|
||||
神经网络的反向传播是根据反向计算图的特定运算顺序来执行链式法则的算法。由于神经网络的输入通常为三维张量,输出为一维向量。因此将上述复合函数关于标量的梯度法则进行推广和扩展。假设$\boldsymbol{X}$是*m*维张量,$\boldsymbol{Y}$为*n*维张量,$\boldsymbol{z}$为一维向量,$\boldsymbol{Y}=g(\boldsymbol{X})$并且$\boldsymbol{z}=f(\boldsymbol{Y})$,则$\boldsymbol{z}$关于$\boldsymbol{X}$每一个元素的偏导数即为:
|
||||
|
||||
$$
|
||||
\frac{\partial z}{\partial x_i}=\sum_j\frac{\partial z}{\partial y_j}\frac{\partial y_j}{\partial x_i}$$
|
||||
\frac{\partial z}{\partial x_i}=\sum_j\frac{\partial z}{\partial y_j}\frac{\partial y_j}{\partial x_i}~~~~~~~~~~~(3.2)$$
|
||||
|
||||
上述公式可以等价的表示为:
|
||||
|
||||
$$
|
||||
\nabla_{\boldsymbol{X}}\boldsymbol{z} = (\frac{\partial \boldsymbol{Y}}{\partial \boldsymbol{X}})^{\top}\nabla_{\boldsymbol{Y}}\boldsymbol{z}$$
|
||||
\nabla_{\boldsymbol{X}}\boldsymbol{z} = (\frac{\partial \boldsymbol{Y}}{\partial \boldsymbol{X}})^{\top}\nabla_{\boldsymbol{Y}}\boldsymbol{z}~~~~~~~~~~~(3.3)$$
|
||||
|
||||
其中$\nabla_{\boldsymbol{X}}\boldsymbol{z}$表示$\boldsymbol{z}$关于$\boldsymbol{X}$的梯度矩阵。
|
||||
|
||||
上一小节中简单的循环控制模型前向传播可以表示为$\boldsymbol{Y}=\boldsymbol{W_2}(\boldsymbol{W_1}(\boldsymbol{W}(\boldsymbol{X})))$。在反向传播的过程中可以将前向计算等价为$\boldsymbol{Y}=\boldsymbol{W_2}\boldsymbol{X_2}$,首先得到参数$\boldsymbol{W_2}$的梯度表示。再接着根据$\boldsymbol{X_2}=\boldsymbol{W_1}\boldsymbol{X_1}$得到$\boldsymbol{W_1}$的梯度表示,按照层级即可推导得出$\boldsymbol{W}$的梯度表示:
|
||||
|
||||
为了便于理解链式法则在神经网络模型中的运用,给出如:numref:`chain`所示前向和反向结合的简单计算图。这个神经网络模型经过两次矩阵相乘得到预测值$\boldsymbol{Y}$,然后根据输出与标签值之间的误差值进行反向梯度传播,以最小化误差值的目的来更新参数权重,模型中需要更新的参数权重包含$\boldsymbol{W}$和$\boldsymbol{W_1}$。
|
||||
|
||||

|
||||
:width:`600px`
|
||||
:label:`chain`
|
||||
|
||||
假设选取均方误差为损失函数,那么损失值是怎样通过链式法则将梯度信息传递给图中的$\boldsymbol{W}$和$\boldsymbol{W_1}$呢?又为什么要计算非参数数据$\boldsymbol{X}$和$\boldsymbol{X_1}$的梯度呢?为了解决上述两个疑问,要详细思考前向传播和反向传播的计算过程。首先通过前向传播来计算损失值三个步骤:(1)$\boldsymbol{X_1}=\boldsymbol{XW}$;(2)$\boldsymbol{Y}=\boldsymbol{X_1W_1}$;(3)Loss=$\frac{1}{2}$($\boldsymbol{Y}$-Label)$^2$, 此处Label即为标签值。
|
||||
|
||||
得到损失函数之后,目的是最小化预测值和标签值间的差异。为此根据链式法则利用公式3.4和公式3.5来进行反向传播,来求解损失函数关于参数$\boldsymbol{W}$和$\boldsymbol{W_1}$的梯度值:
|
||||
|
||||
$$
|
||||
\frac{\partial {\rm Loss}}{\partial \boldsymbol{W_1}}=\frac{\partial \boldsymbol{Y}}{\partial \boldsymbol{W_1}}\frac{\partial {\rm Loss}}{\partial \boldsymbol{Y}} ~~~~~~~~~~~(3.4)
|
||||
$$
|
||||
|
||||
$$
|
||||
\frac{\partial {\rm Loss}}{\partial \boldsymbol{W}}=\frac{\partial \boldsymbol{X_1}}{\partial \boldsymbol{W}}\frac{\partial {\rm Loss}}{\partial \boldsymbol{Y}}\frac{\partial \boldsymbol{Y}}{\partial \boldsymbol{X_1}} ~~~~~~~~~~~(3.5)
|
||||
$$
|
||||
|
||||
可以看出公式3.4和公式3.5都计算了$\frac{\partial {\rm Loss}}{\partial \boldsymbol{Y}}$对应图3.10中的grad $\boldsymbol{Y}$。公式3.5中的$\frac{\partial {\rm Loss}}{\partial \boldsymbol{Y}}\frac{\partial \boldsymbol{Y}}{\partial \boldsymbol{X_1}}$对应:numref:`chain`中的grad $\boldsymbol{X_1}$,为了便于计算模型参数$\boldsymbol{W}$的梯度信息,需要计算中间结果$\boldsymbol{X_1}$的梯度信息。这也就解决了前面提出的第二个疑问,计算非参数的中间结果梯度是为了便于计算前序参数的梯度值。
|
||||
|
||||
接着将$\boldsymbol{X_1}=\boldsymbol{XW}$、$\boldsymbol{Y}=\boldsymbol{X_1W_1}$和Loss=$\frac{1}{2}$($\boldsymbol{Y}$-Label)$^2$代入公式3.4和公式3.5展开为公
|
||||
式3.6和公式3.7,可以分析机器学习框架在利用链式法则构建反向计算图时,变量是如何具体参与到梯度计算中的。
|
||||
|
||||
$$
|
||||
\frac{\partial {\rm Loss}}{\partial \boldsymbol{W_1}}=\frac{\partial \boldsymbol{Y}}{\partial \boldsymbol{W_1}}\frac{\partial {\rm Loss}}{\partial \boldsymbol{Y}}=\boldsymbol{X_1}^\top(\boldsymbol{Y}-{\rm Label}) ~~~~~~~~~~~(3.6)
|
||||
$$
|
||||
|
||||
$$
|
||||
\frac{\partial {\rm Loss}}{\partial \boldsymbol{W}}=\frac{\partial \boldsymbol{X_1}}{\partial \boldsymbol{W}}\frac{\partial {\rm Loss}}{\partial \boldsymbol{Y}}\frac{\partial \boldsymbol{Y}}{\partial \boldsymbol{X_1}}=\boldsymbol{X}^\top(\boldsymbol{Y}-{\rm Label})\boldsymbol{W_1}^\top ~~~~~~~~~~~(3.7)
|
||||
$$
|
||||
|
||||
公式3.6在计算$\boldsymbol{W_1}$的梯度值时使用到了前向图中的中间结果$\boldsymbol{X_1}$。公式3.7中不仅使用输入数据$\boldsymbol{X}$来进行梯度计算,参数$\boldsymbol{W_1}$也参与了参数$\boldsymbol{W}$的梯度值计算。因此可以回答第一个疑问,参与计算图中参数的梯度信息计算过程的不仅有后序网络层传递而来的梯度信息,还包含有前向计算中的中间结果和参数数值。
|
||||
|
||||
通过分析:numref:`chain`和公式3.4、3.5、3.6、3.7解决了两个疑问后,可以发现计算图在利用链式法则构建反向计算图时,会对计算过程进行分析保存模型中的中间结果和梯度传递状态,通过占用部分内存复用计算结果达到提高反向传播计算效率的目的。
|
||||
|
||||
将上述的链式法则推导推广到更加一般的情况,结合控制流的灵活构造,机器学习框架均可以利用计算图快速分析出前向数据流和反向梯度流的计算过程,正确的管理中间结果内存周期,更加高效的完成计算任务。
|
||||
|
||||
|
||||
<!-- 上一小节中简单的循环控制模型前向传播可以表示为$\boldsymbol{Y}=\boldsymbol{W_2}(\boldsymbol{W_1}(\boldsymbol{W}(\boldsymbol{X})))$。在反向传播的过程中可以将前向计算等价为$\boldsymbol{Y}=\boldsymbol{W_2}\boldsymbol{X_2}$,首先得到参数$\boldsymbol{W_2}$的梯度表示。再接着根据$\boldsymbol{X_2}=\boldsymbol{W_1}\boldsymbol{X_1}$得到$\boldsymbol{W_1}$的梯度表示,按照层级即可推导得出$\boldsymbol{W}$的梯度表示:
|
||||
|
||||
$$
|
||||
\begin{aligned}
|
||||
@@ -160,7 +211,7 @@ $$
|
||||
|
||||
根据链式法则,相应位置的导数乘积即可将网络得到的损失函数梯度信息传播到每一个权重参数,应用优化器的参数权重更新规则,即可达到神经网络模型参数训练迭代的目的。
|
||||
|
||||
根据上述公式我们可以得出循环控制的反向梯度计算过程如下,在下面代码中伪变量的前缀*grad*代表变量梯度变量,*transpose*代表矩阵转置算子。
|
||||
根据上述公式我们可以得出循环控制的反向梯度计算过程如下,在下面代码中变量的前缀*grad*代表变量梯度变量,*transpose*代表矩阵转置算子。
|
||||
```python
|
||||
grad_X2 = matmul(grad_Y, transpose(W2))
|
||||
grad_W2 = matmul(transpose(X2), grad_Y)
|
||||
@@ -175,4 +226,4 @@ grad_W = matmul(transpose(X), grad_X1)
|
||||
:width:`600px`
|
||||
:label:`chain`
|
||||
|
||||
在深度学习计算框架中,控制流可以进行嵌套,比如多重循环和循环条件控制,计算图会对复杂控制流进行准确的描述,以便于执行正确的计算调度与执行任务。可以通过[代码示例](https://github.com/openmlsys/openmlsys-pytorch/blob/master/chapter_computational_graph/control_flow.py)查看在条件控制和循环控制下,前向和反向计算的数据流。
|
||||
在深度学习计算框架中,控制流可以进行嵌套,比如多重循环和循环条件控制,计算图会对复杂控制流进行准确的描述,以便于执行正确的计算调度与执行任务。可以通过[代码示例](https://github.com/openmlsys/openmlsys-pytorch/blob/master/chapter_computational_graph/control_flow.py)查看在条件控制和循环控制下,前向和反向计算的数据流。 -->
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
## 计算图的生成
|
||||
计算框架执行深度学习模型训练时,会根据模型结构生成计算图,通过调度计算图完成模型计算。在计算框架中可以生成静态图和动态图两种计算图。静态图对应声明式编程范式,动态图对应命令式编程范式。静态生成可以根据前端语言描述的神经网络拓扑结构以及参数变量等信息构建一份固定的计算图,因此静态图在执行期间可以不依赖前端语言描述,常用于神经网络模型的部署,比如移动端人脸识别场景中的应用等。动态图则需要在每一次执行神经网络模型依据前端语言描述动态生成一份临时的计算图,这意味着计算图的动态生成过程灵活可变,该特性有助于我们在神经网络结构调整阶段提高效率。主流计算框架TensorFlow、MindSpore均支持动态图和静态图模式;PyTorch则可以通过工具将构建的动态图神经网络模型转化为静态结构,以获得高效的计算执行效率。了解两种计算图生成方式的优缺点及构建执行特点,可以针对待解决的任务需求,选择合适的生成方式调用执行神经网络模型。
|
||||
在了解计算图的基本构成后,那么下一个问题就是:计算图要如何自动化生成呢?在机器学习框架中可以生成静态图和动态图两种计算图。静态生成可以根据前端语言描述的神经网络拓扑结构以及参数变量等信息构建一份固定的计算图。因此静态图在执行期间可以不依赖前端语言描述,常用于神经网络模型的部署,比如移动端人脸识别场景中的应用等。
|
||||
|
||||
动态图则需要在每一次执行神经网络模型依据前端语言描述动态生成一份临时的计算图,这意味着计算图的动态生成过程灵活可变,该特性有助于在神经网络结构调整阶段提高效率。主流机器学习框架TensorFlow、MindSpore均支持动态图和静态图模式;PyTorch则可以通过工具将构建的动态图神经网络模型转化为静态结构,以获得高效的计算执行效率。了解两种计算图生成方式的优缺点及构建执行特点,可以针对待解决的任务需求,选择合适的生成方式调用执行神经网络模型。
|
||||
|
||||
### 静态生成
|
||||
|
||||
静态图的生成与执行原理如 :numref:`static`所示,采用先编译后执行的方式,该模式将计算图的定义和执行进行分离。在静态图模式下使用前端语言定义模型形成完整的程序表达后,并不使用前端语言解释器进行执行,而是将前端描述的完整模型交给计算框架。框架在执行模型计算之前会首先对神经网络模型进行分析,获取网络层之间的连接拓扑关系以及参数变量设置、损失函数等信息,接着用一种特殊的静态数据结构来描述拓扑结构及其他神经网络模型组件,这种特殊的静态数据结构通常被称为静态计算图。静态计算图可以通过优化策略转换成等价的更加高效的结构。当进行模型训练或者推理过程时,静态计算图接收数据并通过相应硬件调度执行图中的算子来完成任务。
|
||||
静态图的生成与执行原理如 :numref:`static`所示,采用先编译后执行的方式,该模式将计算图的定义和执行进行分离。
|
||||
|
||||

|
||||

|
||||
:width:`800px`
|
||||
:label:`static`
|
||||
|
||||
以构建并执行下列伪代码,来详细讲解静态图的生成与执行,*matmul*表示矩阵乘法算子,*relu*表示线性矫正单元算子。在部分计算框架中如TensorFlow进行前端定义时,需要声明并编写包含数据占位符、损失函数、优化函数、网络编译、执行环境以及网络执行器等在内的预定义配置项,此外还需要使用图内控制流算子编写控制语句,代码较为繁琐并缺乏可读性。随着计算框架设计的改进与发展,框架提供的编程接口和模型构建模式呈现出更加统一和友好的趋势,比如MindSpore提供动静态统一的前端编程表达。因此为了便于理解静态生成的过程与原理,此处使用更加简洁的语言逻辑描述模型。
|
||||
|
||||
使用前端语言定义模型形成完整的程序表达后,机器学习框架首先对神经网络模型进行分析,获取网络层之间的连接拓扑关系以及参数变量设置、损失函数等信息。然后机器学习框架会将完整的模型描述编译为可被后端计算硬件调用执行的固定代码文本,这种固定代码文本通常被称为静态计算图。当使用静态计算图进行模型训练或者推理过程时,无需编译前端语言模型。静态计算图直接接收数据并通过相应硬件调度执行图中的算子来完成任务。静态计算图可以通过优化策略转换成等价的更加高效的结构,提高后端硬件的计算效率。
|
||||
|
||||
以构建并执行下列伪代码,来详细讲解静态图的生成与执行。在部分机器学习框架中进行前端定义时,需要声明并编写包含数据占位符、损失函数、优化函数、网络编译和执行环境以及网络执行器等在内的预定义配置项,此外还需要使用图内控制流算子编写控制语句。随着机器学习框架设计的改进与发展,框架趋向于提供的友好的编程接口和统一的模型构建模式,比如MindSpore提供动静态统一的前端编程表达。因此为了便于理解静态生成的过程与原理,此处使用更加简洁的语言逻辑描述模型。
|
||||
|
||||
```python
|
||||
def model(X, flag):
|
||||
if flag>0:
|
||||
@@ -20,51 +26,89 @@ def model(X, flag):
|
||||
Y = relu(Y)
|
||||
return Y
|
||||
```
|
||||
完成前端语言的模型完整构建表达后,执行模型运算时不会直接接收输入数据进行计算,而是使用计算框架的编译器对模型进行编译。由于在进行静态生成编译时并不读取输入数据,此时需要一种特殊的张量来表示输入数据辅助构建完整的计算图,这种特殊张量就被称之为"数据占位符"。在上述的伪代码中输入数据**X**需要使用占位符在静态图中表示。构造伪代码中的条件控制时,由于在静态图模式下构建网络并没有执行任何计算,对于条件控制在编译阶段并不会进行逻辑运算完成判断,因此需要将条件控制算子以及所有的分支计算子图加入计算图中。在执行阶段网络接受数据流入,调度条件控制算子时进行逻辑判断,控制数据流入不同的分支计算子图中进行后续计算。由于控制流和静态生成的特殊性,在部分计算框架中前端语言Python的控制流不能够被正确编译为等价的静态图结构,因此需要使用复杂的图内方法实现控制流。
|
||||
机器学习框架在进行静态生成编译时并不读取输入数据,此时需要一种特殊的张量来表示输入数据辅助构建完整的计算图,这种特殊张量就被称为:数据占位符(Placeholder )。在代码第1行中输入数据$\boldsymbol{X}$需要使用占位符在静态图中表示。由于静态生成时模型无数据输入,因此代码第2行中的条件控制,也无法进行逻辑计算,条件控制在编译阶段并不会完成判断,因此需要将条件控制算子以及所有的分支计算子图加入计算图中。在静态计算图执行计算阶段网络接收数据流入,调度条件控制算子根据输入数据进行逻辑判断,控制数据流入不同的分支计算子图中进行后续计算。在部分机器学习框架中前端语言Python的控制流不能够被正确编译为等价的静态图结构,因此需要机器学习框架的控制原语来实现控制流。
|
||||
|
||||
在后续的章节中我们会继续深入了解计算框架静态生成图结构的过程。静态生成的过程是采用计算框架编译器将代码编译为中间表示。计算框架编译器受传统编译器方案启发,设计体系结构包含两部分:编译器前端和编译器后端。中间表示承上启下贯穿前端和后端,是前端源代码和目标硬件代码之间的中间数据格式。在计算框架编译器中中间表示以计算图形式存在,编译器会根据前端神经网络模型自动构建完整的前向计算图和反向计算图。
|
||||
|
||||

|
||||

|
||||
:width:`800px`
|
||||
:label:`staticgen`
|
||||
|
||||
经过编译后获取完整的计算图,能够根据全局信息完成图优化策略,进行编译优化形成与模型完全等价的静态图。编译器前端负责完成计算图与硬件无关的转换和优化,比如算子融合将网络中的两个或多个细粒度的算子融合为一个粗粒度算子,比如 :numref:`staticgen`中将*add*算子与*relu*合并为一个操作,可节省中间计算结果的存储、读取等过程,降低框架底层算子调度的开销,从而提升执行性能和效率。编译器后端负责与硬件相关的计算图优化、代码指令生成和编译,优化手段包括硬件算子选择、内存分配、内存复用等,提高算子执行效率和内存利用效率,降低内存开销。编译器后端因此使用静态图模型运行往往能够获取更好的性能和更少的内存占用。在后续章节中将详细介绍更多编译器前端和编译器后端的优化策略。
|
||||
静态计算图具有两大优势:计算性能与直接部署。静态图经过机器学习框架编译时能够获取模型完整的图拓扑关系。机器学习框架掌控全局信息便更容易制定计算图的优化策略,比如算子融合将网络中的两个或多个细粒度的算子融合为一个粗粒度算子,比如 :numref:`staticgen`中将Add算子与ReLU合并为一个操作,可节省中间计算结果的存储、读取等过程,降低框架底层算子调度的开销,从而提升执行性能和效率,降低内存开销。因此使用静态图模型运行往往能够获取更好的性能和更少的内存占用。在后续章节中将详细介绍更多关于机器学习框架在编译方面的优化策略。
|
||||
|
||||
优化完成的计算图通过编译器后端根据计算硬件来生成适配的执行代码。在执行阶段,调用执行器接受输入数据,依据计算图调度算子执行训练或者推理任务。在训练任务调度算子执行时,由于在执行阶段已经编译获取模型整体结构,计算框架可以利用自动并行算法制定合理的模型切分与并行策略,进一步提高计算效率。
|
||||
在部署模型进行应用时,可以将静态计算图序列化保存。在模型推理阶段,执行序列化的模型即可,无需重新编译前端语言源代码。机器学习框架可以将静态计算图转换为支持不同计算硬件直接调用的代码。结合计算图序列化和计算图转硬件代码两种特性,静态图模型可以直接部署在不同的硬件上面,提供高效的推理服务。
|
||||
|
||||
使用静态图构建模型,编译构建完整的计算图后,计算图可以进行序列化保存,再次执行时允许使用序列化模型直接进行训练或推理,无需重新编译前端语言源代码。得益于编译器前端、中间表示、编译器后端多级的计算框架编译器体系结构,编译器后端可以将神经网络模型中间表示转换为不同硬件代码。结合计算图序列化和计算图可转换成多种硬件代码两种特性,静态图模型可以直接部署在不同的硬件上面,提供高效的推理服务。
|
||||
尽管静态图具备强大的执行计算性能与直接部署能力,但是在部分机器学习框架中静态图模式下,编写神经网络模型以及定义模型训练过程代码较为烦琐。如下面代码所示,将本小节前面的代码改写为以TensorFlow机器学习框架静态图模式要求的代码, 代码第10行使用图内控制流算子来实现条件控制。静态图模式下的代码编写和阅读对于机器学习入门者都有一定门槛。
|
||||
|
||||
```python
|
||||
import tensorflow as tf
|
||||
import numpy as np
|
||||
|
||||
x = tf.placeholder(dtype=tf.float32, shape=(5,5)) #数据占位符
|
||||
w1 = tf.Variable(tf.ones([5,5]),name='w1')
|
||||
w2 = tf.Variable(tf.zeros([5,5]),name='w2')
|
||||
b = tf.Variable(tf.zeros([5,]),name='b')
|
||||
def f1(): return tf.matmul(w1,x)
|
||||
def f2(): return tf.matmul(w2,x)
|
||||
y1 = tf.cond(flag > 0, f1, f2) #图内条件控制算子
|
||||
y2 = tf.add(y1, b)
|
||||
output = tf.relu(y2)
|
||||
with tf.Session() as sess:
|
||||
sess.run(tf.global_variables_initializer()) #静态图变量初始化
|
||||
random_array = np.random.rand(5,5)
|
||||
sess.run(output, feed_dict = {x:random_array, flag: [1.0]}) #静态图执行
|
||||
```
|
||||
|
||||
前端语言构建的神经网络模型经过编译后,计算图结构便固定执行阶段不再改变,并且经过优化用于执行的静态图代码与原始代码有较大的差距。代码执行过程中发生错误时,机器学习框架会返回错误在优化后的静态图代码位置。用户难以直接查看优化后的代码,因此无法定位原始代码错误位置,增加了代码调试难度。比如在代码中,若add算子和relu算子经给优化合并为一个算子,执行时合并算子报错,用户可能并不知道错误指向的是add算子错误 还是relu算子错误。
|
||||
|
||||
此外在神经网络模型开发迭代环节,不能即时打印中间结果。若在源码中添加输出中间结果的代码,则需要将源码重新编译后,再调用执行器才能获取相关信息,降低了代码调试效率。对比之下,动态图模式则相比较灵活,接下来讲解动态生成机制。
|
||||
|
||||
尽管静态图具备强大的执行计算性能与直接部署能力,但是在部分计算框架中静态图模式下,使用前端语言编写神经网络模型以及定义模型训练过程代码较为繁琐,尤其掌握图内控制流方法具备一定的学习难度,因此熟练掌握并使用静态图模式对于初学者并不友好。其次,静态生成采用先编译后执行的方式,编译阶段和执行阶段分离,前端语言构建的神经网络模型经过编译后,计算图结构便固定执行阶段不再改变,并且经过优化用于执行的计算图结构与原始代码有较大的差距,导致代码中的错误难以定位到准确位置,增加了代码调试难度。此外在神经网络模型开发迭代环节,不能即时打印中间结果。若在源码中添加输出中间结果的代码,则需要将源码重新编译后,再调用执行器才能获取相关信息,降低了代码调试效率。而动态图模式则更加灵活,接下来讲解动态生成机制。
|
||||
|
||||
### 动态生成
|
||||
|
||||
动态图原理如 :numref:`dynamic`所示,采用解析式的执行方式,其核心特点是编译与执行同时发生。动态图采用前端语言自身的解释器对代码进行解析,利用计算框架本身的算子分发功能,算子会即刻执行并输出结果。动态图模式采用用户友好的命令式编程范式,使用前端语言构建神经网络模型更加简洁。
|
||||
动态图原理如 :numref:`dynamic`所示,采用解析式的执行方式,其核心特点是编译与执行同时发生。动态图采用前端语言自身的解释器对代码进行解析,利用机器学习框架本身的算子分发功能,算子会即刻执行并输出结果。动态图模式采用用户友好的命令式编程范式,使用前端语言构建神经网络模型更加简洁,深受广大深度学习研究者青睐。
|
||||
|
||||

|
||||

|
||||
:width:`600px`
|
||||
:label:`dynamic`
|
||||
|
||||
由于动态图模式的编程友好性,动态图深受广大深度学习研究者青睐。接下来使用上一小节的伪代码来讲解动态生成和静态生成的区别。
|
||||
接下来使用上一小节的伪代码来讲解动态生成和静态生成的区别。
|
||||
|
||||
尽管静态图和动态图在前端语言表达上略有差异,但本质的区别在于静态生成和动态生成的编译执行过程不同。使用前端语言构建完成模型表达后,动态生成并不采用计算框架编译器生成完整的静态计算图,而是采用前端语言的解释器Python API调用计算框架,框架利用自身的算子分发功能,将Python调用的算子在相应的硬件如CPU、GPU、NPU等上进行加速计算,然后再将计算结果返回给前端。该过程并不产生静态的计算图,而是按照前端语言描述模型结构,按照计算依赖关系进行调度执行,动态生成临时的图拓扑结构。
|
||||
静态图和动态图除了在前端语言表达上略有差异,本质的区别在于编译执行过程。使用前端语言构建完成模型表达后,动态生成并不采用机器学习框架编译器生成完整的静态计算图,而是采用前端语言的解释器Python API调用机器学习框架,框架利用自身的算子分发功能,将Python调用的算子在相应的硬件如CPU、GPU、NPU等上进行加速计算,然后再将计算结果返回给前端。该过程并不产生静态的计算图,而是按照前端语言描述模型结构,按照计算依赖关系进行调度执行,动态生成临时的图拓扑结构。
|
||||
|
||||

|
||||
|
||||
如:numref:`dynamicgen`中所示动态生成流程。
|
||||
|
||||

|
||||
:width:`700px`
|
||||
:label:`dynamicgen`
|
||||
|
||||
如 :numref:`dynamicgen`中所示,神经网络前向计算按照模型声明定义的顺序进行执行。当模型接收输入数据$\boldsymbol{X}$后,计算框架开始动态生成图拓扑结构,添加输入节点并准备将数据传输给后续节点。模型中存在条件控制时,动态图模式下会即刻得到逻辑判断结果并确定数据流向,因此在图中假设判断结果为真的情况下,图结构中仅会添加关于张量$\boldsymbol{W1}$的*matmul*算子节点。按照代码制定的模型计算顺序与算子依赖关系,计算框架会依次添加*add*算子节点和*ReLU*算子节点。计算框架会在添加节点的同时完成算子分发计算并返回计算结果,同时做好准备向后续添加的节点传输数据。当模型再次进行前向计算时,动态生成的图结构则失效,并再次根据输入和控制条件生成新的图结构。相比于静态生成,可以发现动态生成的图结构并不能完整表示前端语言描述的模型结构,需要即时根据控制条件和数据流向产生图结构。由于计算框架无法通过动态生成获取完整的图结构,因此动态图模式下难以进行图结构优化以提高计算效率。
|
||||
神经网络前向计算按照模型声明定义的顺序进行执行。当模型接收输入数据$\boldsymbol{X}$后,机器学习框架开始动态生成图拓扑结构,添加输入节点并准备将数据传输给后续节点。模型中存在条件控制时,动态图模式下会即刻得到逻辑判断结果并确定数据流向,因此在图中假设判断结果为真的情况下,图结构中仅会添加关于张量$\boldsymbol{W1}$的Matmul算子节点。按照代码制定的模型计算顺序与算子依赖关系,机器学习框架会依次添加Add算子节点和ReLU算子节点。机器学习框架会在添加节点的同时完成算子分发计算并返回计算结果,同时做好准备向后续添加的节点传输数据。当模型再次进行前向计算时,动态生成的图结构则失效,并再次根据输入和控制条件生成新的图结构。相比于静态生成,可以发现动态生成的图结构并不能完整表示前端语言描述的模型结构,需要即时根据控制条件和数据流向产生图结构。由于机器学习框架无法通过动态生成获取完整的模型结构,因此动态图模式下难以进行模型优化以提高计算效率。
|
||||
|
||||
在静态生成环节,由于已经获取完整的神经网络模型定义,因此可以同时构建出完整的前向计算图和反向计算图。而在动态生成中,由于边解析边执行的特性,反向梯度计算的构建随着前向计算调用而进行。在执行前向过程中,计算框架根据前向算子的调用信息,记录对应的反向算子信息以及参与梯度计算的张量信息。前向计算完毕之后,反向算子与张量信息随之完成记录,计算框架会根据前向动态图拓扑结构,将所有反向过程串联起来形成整体反向计算图。最终,将反向图在计算硬件上执行计算得到梯度用于参数更新。
|
||||
在静态生成方式下,由于已经获取完整的神经网络模型定义,因此可以同时构建出完整的前向计算图和反向计算图。而在动态生成中,由于边解析边执行的特性,反向梯度计算的构建随着前向计算调用而进行。在执行前向过程中,机器学习框架根据前向算子的调用信息,记录对应的反向算子信息以及参与梯度计算的张量信息。前向计算完毕之后,反向算子与张量信息随之完成记录,机器学习框架会根据前向动态图拓扑结构,将所有反向过程串联起来形成整体反向计算图。最终,将反向图在计算硬件上执行计算得到梯度用于参数更新。
|
||||
|
||||
对应于 :numref:`dynamicgen`中,当调用到关于张量$\boldsymbol{W1}$的*matmul*算子节点时,框架会执行两个操作:调用*matmul*算子,计算关于输入$\boldsymbol{X}$和$\boldsymbol{W1}$的乘积结果,同时根据反向计算过程$\boldsymbol{Grad\_W1}=\boldsymbol{Grad\_Y}*\boldsymbol{X}$,记录下需要参与反向计算的算子和张量$\boldsymbol{X}$。计算框架依照算子调度顺序记录参与反向计算的算子和张量。当前向计算执行完毕,计算框架根据动态生成的前向计算图结构拓扑关系,利用记录的反向计算算子和张量动态生成反向计算图,最终完成神经网络模型的梯度计算和参数更新。
|
||||
对应于 :numref:`dynamicgen`中,当调用到关于张量$\boldsymbol{W1}$的Matmul算子节点时,框架会执行两个操作:调用Matmul算子,计算关于输入$\boldsymbol{X}$和$\boldsymbol{W1}$的乘积结果,同时根据反向计算过程Grad\_$\boldsymbol{W1}$=Grad\_$\boldsymbol{Y}*\boldsymbol{X}$,记录下需要参与反向计算的算子和张量$\boldsymbol{X}$,机器学习框架依据收集的信息完成前向计算和反向图构建。
|
||||
|
||||
尽管动态生成中完整的网络结构在执行前是未知的,不能使用静态图中的图优化技术来提高计算执行性能。但其即刻算子调用与计算的能力,使得模型代码在运行的时候,每执行一句就会立即进行运算并会返回具体的值,方便开发者在模型构建优化过程中进行错误分析、结果查看等调试工作,为研究和实验提供了高效的助力。
|
||||
|
||||
此外得益于动态图模式灵活的执行计算特性,动态生成可以使用前端语言的原生控制流,充分发挥前端语言的编程友好性特性。解决了静态图中代码难调试、代码编写繁琐以及控制流复杂等问题,对于初学者更加友好,提高了算法开发迭代效率和神经网络模型改进速率。
|
||||
此外得益于动态图模式灵活的计算执行特性,动态生成可以使用前端语言的原生控制流,充分发挥前端语言的编程友好性特性。解决了静态图中代码难调试、代码编写烦琐以及控制流复杂等问题,对于初学者更加友好,提高了算法开发迭代效率和神经网络模型改进速率。
|
||||
|
||||
### 动态和静态生成的比较
|
||||
|
||||
静态生成和动态生成的过程各有利弊。从使用者的角度可以直观的感受到静态图不能实时获取中间结果、代码调试困难以及控制流编写复杂,而动态图可以实时获取结果、调试简单、控制流符合编程习惯。虽然静态图的编写、生成过程复杂,但是相应的执行性能却超过动态图,我们用一个简单的例子来说明在性能和内存占用方面静态图的优势。
|
||||
静态生成和动态生成的过程各有利弊。为了方便读者对比,将静态图和动态图特性总结见:numref:`cmp_dynamic_static`。
|
||||
|
||||
:静态图和动态图对比
|
||||
|
||||
| 特性| 静态图 | 动态图 |
|
||||
| --- | ------- | ------- |
|
||||
| 即时获取中间结果| 否 | 是 |
|
||||
| 代码调试难易 | 难 | 易 |
|
||||
| 控制流实现方式 | 特定的语法 | 前端语言语法 |
|
||||
| 性能 | 优化策略多,性能更佳 | 图优化受限,性能较差 |
|
||||
| 内存占用 | 内存占用少 | 内存占用相对较多 |
|
||||
| 内存占用 | 可直接部署 | 不可直接部署 |
|
||||
:label:`cmp_dynamic_static`
|
||||
|
||||
|
||||
从使用者的角度可以直观的感受到静态图不能实时获取中间结果、代码调试困难以及控制流编写复杂,而动态图可以实时获取结果、调试简单、控制流符合编程习惯。虽然静态图的编写、生成过程复杂,但是相应的执行性能却超过动态图,下面用一个简单的代码来说明在性能和内存占用方面静态图的优势。
|
||||
```python
|
||||
def model(X1, X2):
|
||||
Y1 = matmul(X1, W1)
|
||||
@@ -73,27 +117,28 @@ def model(X1, X2):
|
||||
output = relu(Y)
|
||||
return output
|
||||
```
|
||||
在静态生成过程中,计算框架获取完整的计算图可以分析出计算$\boldsymbol{Y_1}$和$\boldsymbol{Y_2}$的过程相对独立,可以将其进行自动并行计算,加快计算效率。而动态生成的过程中,若无手动配置并行策略,计算框架无法获取图结构不能分析出算子之间的独立性,则只能按照代码顺序执行。模型在输出结果之前执行了*add*和*relu*算子操作,在静态生成过程中利用计算图优化策略中的算子融合方法,可以将这两个算子融合为一个算子执行,这样减少了中间变量$\boldsymbol{Y}$的存储与读取过程,加快了计算效率,减少了内存占用。而动态生成过程则需要按照顺序执行*add*和*relu*两步操作,需要存储变量$\boldsymbol{Y}$。除此之外,由于静态生成能够同时分析重构出前向计算图和反向计算图,可以提前确定反向计算中需要保存的前向中间变量信息。而动态生成则在完成前向计算后才能构建出反向计算图,为了保证反向计算效率需要保存更多的前向计算中间变量信息,相比之下静态生成的过程更加节省内存占用。
|
||||
若对代码进行静态生成,机器学习框架可以构建完整的计算图。分析可知,计算$\boldsymbol{Y_1}$和$\boldsymbol{Y_2}$的过程相对独立,可以将其进行自动并行计算,加快计算效率。在静态生成过程中还可以利用计算图优化策略中的算子融合方法,将Add和ReLU两个算子融合为一个算子执行,这样减少了中间变量$\boldsymbol{Y}$的存储与读取过程,加快了计算效率,减少了内存占用。而动态生成的过程中,若无手动配置并行策略,机器学习框架无法获取图结构不能分析出算子之间的独立性,则只能按照代码顺序执行Add和ReLU两步操作,且需要存储变量$\boldsymbol{Y}$。除此之外,由于静态生成能够同时分析重构出前向计算图和反向计算图,可以提前确定反向计算中需要保存的前向中间变量信息。而动态生成则在完成前向计算后才能构建出反向计算图,为了保证反向计算效率需要保存更多的前向计算中间变量信息,相比之下静态生成的过程更加节省内存占用。
|
||||
|
||||
为了方便读者对比,将静态图和动态图特性总结见 :numref:`cmp_dynamic_static`。
|
||||
|
||||
:静态图和动态图对比
|
||||
|
||||
|特性|静态图|动态图|
|
||||
|:---:| :---: | :---: |
|
||||
| 即时获取中间结果 | 否 | 是 |
|
||||
| 代码调试难易 | 难 | 简单 |
|
||||
| 控制流实现方式 | 特定的语法 | 前端语言语法 |
|
||||
| 性能 | 优化策略多,性能更佳 | 图优化受限,性能较差 |
|
||||
| 内存占用 | 内存占用少 | 内存占用相对较多 |
|
||||
| 部署能力 |可直接部署 | 不可直接部署|
|
||||
:label:`cmp_dynamic_static`
|
||||
|
||||
针对两种模式的特性,结合任务需求选择合适的模式可以事半功倍,学术科研以及模型开发调试阶段,为了快速验证思想和迭代更新模型结构可以选择动态图模式进行构建算法;网络模型确定,为了加速训练过程或者为硬件部署模型,可以选择静态图模式。
|
||||
|
||||
### 动态图与静态图的转换和融合
|
||||
|
||||
动态图模式下拥有简洁的接口和编程体验,具备友好的调试交互机制。代码按照编写顺序即时执行,符合我们在编写模型的直观感受和习惯。可以快速将算法思想转化为实际代码。静态图模式下可以分离前后端语言,编译解析前端语言构建的整体网络结构,并进行优化后以高效后端语言执行,可以直接用于部署。为了兼顾动态图易用性和静态图部署性能两方面优势,目前TensorFlow、MindSpore、PyTorch、PaddlePaddle等主流计算框架均具备动态图转静态图的功能,支持使用动态图编写代码,框架自动转换为静态图网络结构。
|
||||
动态图便于调试,适用于模型构建实验阶段;静态图执行高效,节省模型训练时间,那么有没有办法可以让机器学习框架结合两种模式的优势呢?事实上,目前TensorFlow、MindSpore、PyTorch、PaddlePaddle等主流机器学习框架为了兼顾动态图易用性和静态图执行性能高效两方面优势,均具备动态图转静态图的功能,支持使用动态图编写代码,框架自动转换为静态图网络结构执行计算。
|
||||
|
||||
将各框架中支持源码转换和追踪转换技术的接口梳理如:numref:`dynamic_static_switch`所示。
|
||||
|
||||
:主流框架动态图转换静态图支持
|
||||
|
||||
| 框架 | 动态图转静态图 |
|
||||
| :-----------------:| :--------------------------------------------------: |
|
||||
| TensorFlow |@tf_function追踪算子调度构建静态图,<br>其中AutoGraph机制可以自动转换控制流为静态表达 |
|
||||
| MindSpore | context.set_context(mode=context.PYNATIVE_MODE)动态图模式,<br>context.set_context(mode=context.GRAPH_MODE) 静态图模式,<br>@ms_function支持基于源码转换 |
|
||||
| PyTorch | torch.jit.script()支持基于源码转换,<br>torch.jit.trace()支持基于追踪转换 |
|
||||
| PaddlePaddle | paddle.jit.to_static()支持基于源码转换,<br>paddle.jit.TracedLayer.trace()支持基于追踪转换 |
|
||||
|
||||
:label:`dynamic_static_switch`
|
||||
|
||||
|
||||
动态图转换为静态图的实现方式有两种:
|
||||
|
||||
@@ -101,17 +146,21 @@ def model(X1, X2):
|
||||
|
||||
- **基于源码转换**:分析前端代码来将动态图代码自动转写为静态图代码,并在底层自动帮用户使用静态图执行器运行。
|
||||
|
||||
**基于追踪转换**的原理相对简单,当使用动态图模式构建好网络后,使用追踪(Tracing)进行转换将分为两个阶段。第一个阶段计算框架会创建一个新的计算图,此时以动态图模式执行代码,计算框架会自动追踪数据流的流动以及算子的调度,将所有的操作捕获并根据调度顺序构建静态图模型。第二个阶段,当执行完一次动态图后,计算框架已生成静态图,当再次调用相同的模型时,计算框架会自动指向静态图模型,以高效的性能执行计算。追踪技术只是记录第一次执行动态图时调度的算子,但若是模型中存在依赖于中间结果的条件分支控制流,只能追踪到根据第一次执行时触发的分支。此时构建的静态图模型并不是完整的,缺失了数据未流向的其他分支。在后续的调用中,因为静态模型已无法再改变,若计算过程中数据流向缺失分支会导致模型运行错误。同样的,依赖于中间数据结果的循环控制也无法追踪到全部的迭代状态。
|
||||
**基于追踪转换**的原理相对简单,当使用动态图模式构建好网络后,使用追踪进行转换将分为两个阶段。第一个阶段与动态生成原理相同,机器学习框架创建并运行动态图代码,自动追踪数据流的流动以及算子的调度,将所有的算子捕获并根据调度顺序构建静态图模型。与动态生成不同的地方在于机器学习框架并不会销毁构建好的图,而是将其保存为静态图留待后续执行计算。第二个阶段,当执行完一次动态图后,机器学习框架已生成静态图,当再次调用相同的模型时,机器学习框架会自动指向静态图模型执行计算。追踪技术只是记录第一次执行动态图时调度的算子,但若是模型中存在依赖于中间结果的条件分支控制流,只能追踪到根据第一次执行时触发的分支。此时构建的静态图模型并不是完整的,缺失了数据未流向的其他分支。在后续的调用中,因为静态模型已无法再改变,若计算过程中数据流向缺失分支会导致模型运行错误。同样的,依赖于中间数据结果的循环控制也无法追踪到全部的迭代状态。
|
||||
|
||||
动态图基于前端语言自身的解释器进行模型代码的解析执行。比如当Python作为前端语言,采取原生Python边运行边解释的特性,配合框架提供的数据处理/算子分发的功能计算,即可实现动态图的即时执行特性。而且静态图则采用计算框架自带的图编译器,对神经网络模型进行建图后,再调用图结构进行计算。动态图代码与静态图代码之间存在差异,不能直接使用静态图编译器,因此基于源码转换的方法需要将动态图代码转换为静态图代码描述。
|
||||
动态图基于前端语言的解释器进行模型代码的解析执行,而静态图模式下需要经过机器学习框架自带的图编译器对模型进行建图后,再执行静态计算图。由于图编译器所支持编译的静态图代码与动态图代码之间存在差异,因此需要基于源码转换的方法将动态图代码转换为静态图代码描述,然后经过图编译器生成静态计算图。
|
||||
|
||||
**基于源码转换**的方式则能够改善基于追踪转换的缺陷。如 :numref:`ast`中所示,基于源码转换的流程经历两个阶段。第一个阶段,对动态图模式下的代码扫描进行词法分析,通过词法分析器分析源代码中的所有字符,对代码进行分割并移除空白符、注释等,将所有的单词或字符都转化成符合规范的语法单元列表。接着进行语法分析即解析器,将得到的语法单元列表转换成树形式,并对语法进行检查避免错误。第二阶段,动态图转静态图的核心部分就是对抽象语法树进行转写,计算框架中对每一个需要转换的语法都预设有转换器,每一个转换器对语法树进行扫描改写,将动态图代码语法映射为静态图代码语法。其中最为重要的前端语言控制流,会在这一阶段分析转换为静态图接口进行实现。转写完毕之后,将新的语法树再还原回静态图代码,就可以使用静态生成执行。使用该方式可以避免基于追踪转换中控制流表达缺失的情况。
|
||||
**基于源码转换**的方式则能够改善基于追踪转换的缺陷。如 :numref:`ast`中所示,基于源码转换的流程经历两个阶段。第一个阶段,对动态图模式下的代码扫描进行词法分析,通过词法分析器分析源代码中的所有字符,对代码进行分割并移除空白符、注释等,将所有的单词或字符都转化成符合规范的词法单元列表。接着进行语法分析即解析器,将得到的词法单元列表转换成树形式,并对语法进行检查避免错误。第二阶段,动态图转静态图的核心部分就是对抽象语法树进行转写,机器学习框架中对每一个需要转换的语法都预设有转换器,每一个转换器对语法树进行扫描改写,将动态图代码语法映射为静态图代码语法。其中最为重要的前端语言控制流,会在这一阶段分析转换为静态图接口进行实现,也就避免了基于追踪转换中控制流缺失的情况。转写完毕之后,即可从新的语法树还原出可执行的静态图代码。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`ast`
|
||||
|
||||
在使用上述功能的过程中,可以将整体模型动态图代码全部转换为静态图代码,提高计算效率并用于硬件部署。同时也可以将整体模型中的部分函数转化为局部静态子图,静态子图会被计算框架视为一个完整的算子并嵌入动态图中。执行整体动态图时,当计算到对应的函数会自动调用静态子图。使用该方式在一定程度上既保留代码调试改进的灵活性,又提高了计算效率。
|
||||
在使用上述功能的过程中,可以将整体模型动态图代码全部转换为静态图代码,提高计算效率并用于硬件部署。同时也可以将整体模型中的部分函数转化为局部静态子图,静态子图会被机器学习框架视为一个完整的算子并嵌入动态图中。执行整体动态图时,当计算到对应的函数会自动调用静态子图。使用该方式既提高了计算效率,又在一定程度上保留代码调试改进的灵活性。
|
||||
|
||||
下面代码中模型整体可以采用动态生成,而@ms\_function可以使用基于源码转换的技术将模块add\_and\_relu的转化为静态图结构。与动态生成中代码执行相同,模型接收输入按照模型定义的计算顺序进行调度执行,并生成临时动态图结构,当执行语句Y=add\_and\_relu(Y,b)时,机器学习框架会自动调用该模块静态生成的图结构执行计算,通过动态图和静态图的混合执行提高计算能力。此外,动静态转换的技术常用于模型部署阶段。部署动态图模型时除了需要训练完成的参数文件,还须根据前端语言编写的模型代码构建拓扑关系。这使得动态图部署受到局限性,部署硬件中往往难以提供支持前端语言运行的执行环境。因此当使用动态图模式训练完模型参数后,可以将整体网络结构转换为静态图格式,将神经网络模型和参数文件进行序列化保存,与前端代码完全解耦,扩大模型部署的硬件支持范围。
|
||||
|
||||
|
||||
```python
|
||||
@ms_function #mindspore中基于源码转换的函数装饰器,可以将该函数转换为静态图
|
||||
def add_and_relu(Y, b):
|
||||
@@ -127,17 +176,5 @@ def model(X, flag):
|
||||
Y = add_and_relu(Y, b)
|
||||
return Y
|
||||
```
|
||||
代码中模型整体可以采用动态生成,而\@ms\_function可以使用基于源码转换的技术将模块*add_and_relu*的转化为静态图结构。与动态生成中代码执行相同,模型接受输入按照模型定义的计算顺序进行调度执行,并生成临时图结构,当执行语句*Y=add_and_relu(Y,b)* 时,计算框架会自动调用该模块静态生成的图结构执行计算。模块*add_and_relu* 可以利用静态图中的优化技术来提高计算性能,实现动态图和静态图的混合执行。此外,动静态转换的技术常用于模型部署阶段,动态图预测部署时除了需要已经训练完成的参数文件,还须提供最初的模型组网前端代码,这使得动态图部署受到局限性,部署硬件中往往难以提供支持前端语言执行环境。因此当使用动态图模式训练完成模型参数后,可以将整体网络结构转换为静态图格式,将神经网络模型和参数文件进行序列化保存,与前端代码完全解耦,扩大模型部署的硬件支持范围。
|
||||
|
||||
主流的计算框架TensorFlow、MindSpore等均提供动静态相互转换与融合执行的技术,我们将各框架中支持源码转换和追踪转换技术的接口梳理如 :numref:`dynamic_static_switch`所示。可以通过[代码示例](https://github.com/openmlsys/openmlsys-pytorch/blob/master/chapter_computational_graph/generate_static_graph.py)查看PyTorch计算框架中是如何将动态图模型转化为静态图模型,并且展示静态图结构信息。
|
||||
|
||||
:主流框架动态图转换静态图支持
|
||||
|
||||
|框架|动态图转静态图|
|
||||
| :-:| :---: |
|
||||
| TensorFlow |@tf_function追踪算子调度构建静态图,<br>其中AutoGraph机制可以自动转换控制流为静态表达 |
|
||||
| MindSpore | context.set_context(mode=context.PYNATIVE_MODE) 动态图模式,<br> context.set_context(mode=context.GRAPH_MODE) 静态图模式,<br>\@ms_function支持基于源码转换 |
|
||||
| PyTorch | torch.jit.script()支持基于源码转换,<br>torch.jit.trace()支持基于追踪转换 |
|
||||
| PaddlePaddle | paddle.jit.to_static()支持基于源码转换,<br>paddle.jit.TracedLayer.trace()支持基于追踪转换 |
|
||||
:label:`dynamic_static_switch`
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
# 计算图
|
||||
|
||||
在上一章节中,我们展示了用户利用机器学习框架所编写的程序。这些用户程序包含了对于训练数据,模型和训练过程的定义。然而为了运行这些程序,机器学习系统依然需要解决诸多问题,包括:如何高效执行一个复杂的机器学习模型?如何识别出机器学习模型中需要训练的参数?如何自动计算更新模型所需的梯度?为了解决这些问题,现代机器学习框架实现了*计算图*(Computational
|
||||
graph)这一技术。在本章中,我们详细讨论计算图的基本组成,生成和执行等关键设计。本章的学习目标包括:
|
||||
上一章节展示了如何高效编写机器学习程序,那么下一个问题就是:机器学习系统如何高效地在硬件上执行这些程序呢?这一核心问题又能被进一步拆解为:如何对机器学习程序描述的模型调度执行?如何使得模型调度执行更加高效?如何自动计算更新模型所需的梯度?解决这些问题的关键是计算图(Computational Graph)技术。为了讲解这一技术,本章将详细讨论计算图的基本组成、自动生成和高效执行中所涉及的方法。
|
||||
|
||||
本章的学习目标包括:
|
||||
- 掌握计算图的基本构成。
|
||||
|
||||
- 掌握计算图静态生成和动态生成两种方法。
|
||||
|
||||
- 掌握计算图静态生成和动态生成方法。
|
||||
- 掌握计算图的常用执行方法。
|
||||
|
||||
```toc
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
## 计算图的调度
|
||||
|
||||
模型训练就是计算图调度图中算子的执行过程。宏观来看训练任务是由设定好的训练迭代次数来循环执行计算图,此时我们需要优化迭代训练计算图过程中数据流载入和模型训练(推理)等多个任务之间的调度执行。微观上单次迭代需要考虑计算图内部的调度执行问题,根据计算图、计算依赖关系、计算控制分析算子的任务调度队列。优化计算图的调度和执行性能,目的是为了尽可能充分利用计算资源,提高计算效率,缩短模型训练和推理时间。接下来会详细介绍计算图的调度和执行。
|
||||
模型训练就是计算图调度图中算子的执行过程。宏观来看训练任务是由设定好的训练迭代次数来循环执行计算图,此时需要优化迭代训练计算图过程中数据流载入和训练(推理)执行等多个任务之间的调度策略。微观上单次迭代需要考虑计算图内部的调度执行问题,根据计算图结构、计算依赖关系、计算控制分析算子的执行调度。优化计算图的调度和执行性能,目的是尽可能充分利用计算资源,提高计算效率,缩短模型训练和推理时间。接下来会详细介绍计算图的调度和执行。
|
||||
|
||||
### 算子调度执行
|
||||
|
||||
算子的执行调度包含两个步骤,第一个,根据拓扑排序算法,将计算图进行拓扑排序得到线性的算子调度序列;第二步,将序列中的算子分配到执行流进行运算。算子调度执行的目标是根据计算图中算子依赖关系,确定算子调度序列,尽可能将序列中的算子并行执行,提高计算资源的利用率。
|
||||
算子的执行调度包含两个步骤,第一步,根据拓扑排序算法,将计算图进行拓扑排序得到线性的算子调度序列;第二步,将序列中的算子分配到指令流进行运算,尽可能将序列中的算子并行执行,提高计算资源的利用率。
|
||||
|
||||
计算图中依赖边和算子构成了一张有向无环图(Directed Acyclic Graph),计算框架后端需要将包含这种依赖关系的算子准确地发送到计算资源,比如GPU、NPU上执行。因此,就要求算子需要按照一定的顺序排列好再发送给GPU/NPU执行。针对有向无环图,我们通常使用拓扑排序来得到一串线性的序列。
|
||||
计算图是一种由依赖边和算子构成的有向无环图,机器学习框架后端需要将包含这种依赖关系的算子准确地发送到计算资源,比如GPU、NPU上执行。针对有向无环图,通常使用拓扑排序来得到一串线性的序列。
|
||||
|
||||
如 :numref:`schedule`所示,左边是一张有向无环图。图中包含了a,b,c,d,e五个节点和a-\>d,b-\>c,c-\>d,d-\>e四条边(a-\>d表示d依赖于a,称之为依赖边)。将图的依赖边表达成节点的入度(图论中通常指有向图中某点作为图中边的终点的次数之和),可以得到各个节点的入度信息(a:0, b:0, c:1, d:2, e:1)。拓扑排序就是不断循环将入度为0的节点取出放入队列中,直至所有有向无环图中的节点都加入到队列中,循环结束。例如,第一步将入度为0的a,b节点放入到队列中,此时有向无环图中c,d的入度需要减1,得到新的入度信息(c:0, d:1, e:1)。以此类推,将所有的将所有的节点都放入到队列中并结束排序。
|
||||
如 :numref:`schedule`所示,左边是一张有向无环图。图中包含了a、b、c、d、e五个节点和a->d、b->c、c->d、d->e四条边(a->d表示d依赖于a,称为依赖边)。将图的依赖边表达成节点的入度(图论中通常指有向图中某点作为图中边的终点的次数之和),可以得到各个节点的入度信息(a:0、 b:0、 c:1、 d:2、 e:1)。拓扑排序就是不断循环将入度为0的节点取出放入队列中,直至有向无环图中的全部节点都加入到队列中,循环结束。例如,第一步将入度为0的a、b节点放入到队列中,此时有向无环图中c、d的入度需要减1,得到新的入度信息(c:0、d:1、e:1)。以此类推,将所有的节点都放入到队列中并结束排序。
|
||||
|
||||

|
||||
:width:`700px`
|
||||
:label:`schedule`
|
||||
|
||||
生成调度序列之后,需要将序列中的算子与数据分发到指定的GPU/NPU上执行运算。根据算子依赖关系和计算设备数量,可以将无相互依赖关系的算子分发到不同的计算设备,同时执行运算,这一过程称之为并行计算,与之相对应的按照序贯顺序在同一设备执行运算被称之为串行计算。在深度学习中,当数据集和参数量的规模越来越大,我们在分发数据与算子时通信消耗会随之而增加,计算设备会在数据传输的过程中处于闲置状态,此时采用同步与异步的任务调度机制可以更好的协调通信与训练任务,提高通信模块与计算设备的使用率,在后续的小节中将详细介绍串行与并行、同步与异步的概念。
|
||||
生成调度序列之后,需要将序列中的算子与数据分发到指定的GPU/NPU上执行运算。根据算子依赖关系和计算设备数量,可以将无相互依赖关系的算子分发到不同的计算设备,同时执行运算,这一过程称之为并行计算,与之相对应的按照序贯顺序在同一设备执行运算被称为串行计算。在深度学习中,当数据集和参数量的规模越来越大在分发数据与算子时通信消耗会随之而增加,计算设备会在数据传输的过程中处于闲置状态。此时采用同步与异步的任务调度机制可以更好的协调通信与训练任务,提高通信模块与计算设备的使用率,在后续的小节中将详细介绍串行与并行、同步与异步的概念。
|
||||
|
||||
### 串行与并行
|
||||
|
||||
@@ -24,13 +24,13 @@
|
||||
|
||||
- **并行**:队列中的任务可以同时进行调度执行,加快执行效率。
|
||||
|
||||
首先我们从微观上来分析计算图内部的串行调度。计算图中大多数算子之间存在直接依赖或者间接依赖关系,具有依赖关系的算子间任务调度则必定存在执行前后的时间顺序。如 :numref:`order`,计算图接受输入数据进行前向计算得到预测值,计算损失函数进行反向梯度计算,整体代码流程后序算子的计算有赖于前序算子的输出。此时算子的执行队列只能以串行的方式进行调度,保证算子都能正确接受到输入数据,才能完成计算图的一次完整执行。
|
||||
首先从微观上来分析计算图内部的串行调度。计算图中大多数算子之间存在直接依赖或者间接依赖关系,具有依赖关系的算子间任务调度则必定存在执行前后的时间顺序。如 :numref:`order`,计算图接受输入数据进行前向计算得到预测值,计算损失函数进行反向梯度计算,整体代码流程后序算子的计算有赖于前序算子的输出。此时算子的执行队列只能以串行的方式进行调度,保证算子都能正确接受到输入数据,才能完成计算图的一次完整执行。
|
||||
|
||||

|
||||

|
||||
:width:`800px`
|
||||
:label:`order`
|
||||
|
||||
宏观上来看迭代训练之间,每一轮迭代中计算图必须读取训练数据,执行完整的前向计算和反向梯度计算,将图中所有参数值更新完毕后,才能开始下一轮的计算图迭代计算更新。所以"数据载入-数据处理-模型训练"的计算图整体任务调度是以串行方式进行的。
|
||||
宏观上来看迭代训练之间,每一轮迭代中计算图必须读取训练数据,执行完整的前向计算和反向梯度计算,将图中所有参数值更新完毕后,才能开始下一轮的计算图迭代计算更新。所以“数据载入-数据预处理-模型训练”的计算图整体任务调度是以串行方式进行的。
|
||||
|
||||
在分析计算图内部算子依赖关系时,除了直接依赖和间接依赖之外,存在算子间相互独立的情况。如 :numref:`para`中op1和op2之间相互独立,此时可以将两个算子分配到两个硬件上进行并行计算。对比串行执行,并行计算可以同时利用更多的计算资源来缩短执行时间。
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
:width:`800px`
|
||||
:label:`para`
|
||||
|
||||
并行包括算子并行、模型并行以及数据并行。算子并行不仅可以在相互独立的算子间执行,同时也可以将单个算子合理的切分为相互独立的两个子操作,进一步提高并行性。模型并行就是将整体计算图进行合理的切分,分配到不同设备上进行并行计算,缩短单次计算图迭代训练时间。数据并行则同时以不同的数据训练多个相同结构的计算图,缩短训练迭代次数,加快训练效率。这三种并行方式将在后续章节中进行详细讲解。
|
||||
并行包括算子并行、模型并行以及数据并行。算子并行不仅可以在相互独立的算子间实现,同时也可以将单个算子合理的切分为相互独立的多个子操作,进一步提高并行性。模型并行就是将整体计算图进行合理的切分,分配到不同设备上进行并行计算,缩短单次计算图迭代训练时间。数据并行则同时以不同的数据训练多个相同结构的计算图,减少训练迭代次数,加快训练效率。这三种并行方式将在后续章节中进行详细讲解。
|
||||
|
||||
### 数据载入同步与异步机制
|
||||
|
||||
@@ -48,18 +48,19 @@
|
||||
|
||||
- **异步**:当前任务完成后,不需要等待后续任务的执行情况,可继续执行当前任务下一轮迭代。
|
||||
|
||||
以同步机制来执行计算图训练时,如 :numref:`synchronization`所示,每一轮迭代中,数据读取后进行数据预处理操作,然后传输给计算图进行训练。每一个环节执行完当前迭代中的任务后,会一直等待后续环节的处理,直至计算图完成一次迭代训练更新参数值后,才会进行下一轮迭代的数据读取、数据处理以及网络训练。当进行数据载入时,数据处理、模型训练处于等待的状态,相反模型处于训练时,数据载入的I/O通道处于空闲,同步机制造成计算资源和通信资源的浪费。
|
||||
以同步机制来执行计算图训练时,如 :numref:`synchronization`所示,每一轮迭代中,数据载入后进行数据预处理操作,然后传输给计算图进行训练。每一个环节执行完当前迭代中的任务后,会一直等待后续环节的处理,直至计算图完成一次迭代训练更新参数值后,才会进行下一轮迭代的数据载入、数据预处理以及网络训练。当进行数据载入时,数据预处理、模型训练处于等待的状态;同样的,模型处于训练时,数据载入的I/O通道处于空闲,同步机制造成计算资源和通信资源的浪费。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`synchronization`
|
||||
|
||||
以异步机制来执行计算图训练时,如 :numref:`asynchronous`所示,在迭代训练中,当数据通道将数据读取后交给后续的数据与处理环节后,不需要等待计算图训练迭代完成,直接读取下一批次的数据。对比同步机制,异步机制的引入减少了数据载入、数据预处理、网络训练三个环节的空闲等待时间,能够大幅度缩短循环训练的整体时间,提高任务执行效率。
|
||||
以异步机制来执行计算图训练时,如 :numref:`asynchronous`所示,在迭代训练中,当数据通道载入数据后交给后续的数据预处理环节后,不需要等待计算图训练迭代完成,直接读取下一批次的数据。对比同步机制,异步机制的引入减少了数据载入、数据预处理、网络训练三个环节的空闲等待时间,能够大幅度缩短迭代训练的整体时间,提高任务执行效率。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`asynchronous`
|
||||
当我们将异步机制与并行计算结合在一起,如 :numref:`asyn_para`所示,利用丰富的计算资源可以进一步提高计算图训练效率,缩短训练时间。
|
||||
|
||||
将异步机制与并行计算结合在一起,如 :numref:`asyn_para`所示,一方面异步机制减少模型等待数据载入和预处理的时间,另一方面并行计算增加了单轮模型训练接受的数据量。相比于不采用异步机制和同步计算,机器学习框架可以利用丰富的计算资源更快速的遍历训练完数据集,缩短训练时间提高计算效率。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
## 总结
|
||||
|
||||
- 为了兼顾编程的灵活性和计算的高效性,设计了基于计算图的深度学习框架。
|
||||
- 为了兼顾编程的灵活性和计算的高效性,设计了基于计算图的机器学习框架。
|
||||
|
||||
- 计算图的基本数据结构是张量,基本运算单元是算子。
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
- 动态图提供灵活的可编程性和可调试性,可实时得到计算结果,在模型调优与算法改进迭代方面具有优势。
|
||||
|
||||
- 利用计算图和算子间依赖关系可以进行模型中的算子执行调度问题。
|
||||
- 利用计算图和算子间依赖关系可以解决模型中的算子执行调度问题。
|
||||
|
||||
- 根据计算图可以找到相互独立的算子进行并发调度,提高计算的并行性。而存在依赖关系的算子则必须依次调度执行。
|
||||
|
||||
@@ -24,6 +24,6 @@
|
||||
|
||||
## 扩展阅读
|
||||
|
||||
- 计算图是计算框架的核心理念之一,了解主流计算框架的设计思想,有助于深入掌握这一概念,建议阅读 [TensorFlow 设计白皮书](https://arxiv.org/abs/1603.04467)、 [PyTorch计算框架设计论文](https://arxiv.org/abs/1912.01703)、[MindSpore技术白皮书](https://mindspore-website.obs.cn-north-4.myhuaweicloud.com/white_paper/MindSpore_white_paperV1.1.pdf)。
|
||||
- 计算图是机器学习框架的核心理念之一,了解主流机器学习框架的设计思想,有助于深入掌握这一概念,建议阅读 [TensorFlow 设计白皮书](https://arxiv.org/abs/1603.04467)、 [PyTorch计算框架设计论文](https://arxiv.org/abs/1912.01703)。
|
||||
- 图外控制流直接使用前端语言控制流,熟悉编程语言即可掌握这一方法,而图内控制流则相对较为复杂,建议阅读[TensorFlow控制流](http://download.tensorflow.org/paper/white_paper_tf_control_flow_implementation_2017_11_1.pdf)论文。
|
||||
- 动态图和静态图设计理念与实践,建议阅读[TensorFlow Eager 论文](https://arxiv.org/pdf/1903.01855.pdf)、[TensorFlow Eager Execution](https://tensorflow.google.cn/guide/eager?hl=zh-cn)示例、[TensorFlow Graph](https://tensorflow.google.cn/guide/intro_to_graphs?hl=zh-cn)理念与实践、[MindSpore动静态图](https://www.mindspore.cn/docs/programming_guide/zh-CN/r1.6/design/dynamic_graph_and_static_graph.html)概念。
|
||||
@@ -1,6 +1,6 @@
|
||||
# 数据处理框架
|
||||
|
||||
在前两个章节中,我们介绍了编译器前后端的相关内容,详细地阐述了源程序到目标程序的转换优化过程。除了让芯片在训练/推理过程中高性能地运行,我们还需要将数据高效地发送给芯片,以实现全流程的性能最优。机器学习模型训练和推理需要从存储设备(如本地磁盘和内存、远端的存储系统等)中加载数据集,对数据集进行一系列处理变换,将处理结果发送到到GPU或者华为昇腾Ascend等加速器中完成模型计算,该流程的任何一个步骤出现性能问题都会对训练和推理的吞吐率造成负面影响。本章我们将核心介绍如何设计、并实现一个面向机器学习场景的数据系统,以帮助用户轻松构建各种复杂的数据处理流水线(Data
|
||||
在前两个章节中,我们介绍了编译器前后端的相关内容,详细地阐述了源程序到目标程序的转换优化过程。除了让芯片在训练/推理过程中高性能地运行,我们还需要将数据高效地发送给芯片,以实现全流程的性能最优。机器学习模型训练和推理需要从存储设备(如本地磁盘和内存、远端的存储系统等)中加载数据集,对数据集进行一系列处理变换,将处理结果发送到GPU或者华为昇腾Ascend等加速器中完成模型计算,该流程的任何一个步骤出现性能问题都会对训练和推理的吞吐率造成负面影响。本章我们将核心介绍如何设计、并实现一个面向机器学习场景的数据系统,以帮助用户轻松构建各种复杂的数据处理流水线(Data
|
||||
Pipeline),同时我们的数据系统要有足够高的执行性能,以确保数据预处理步骤不会成为模型训练和推理的性能瓶颈。
|
||||
|
||||
本章主要从易用性、高效性和保序性三个维度展开介绍机器学习系统中的数据模块。在前两个小节中,我们首先讨论如何构建一个易用的数据模块。包括如何设计编程抽象,使得用户通过短短几行代码便可以描述一个复杂的预处理过程;以及如何做到既内置丰富算子提升易用性,又可以灵活支持用户使用自定义算子覆盖长尾需求。用户构建好数据处理流程后,数据模块需要负责高效的调度执行数据流水线,以达到最优的数据处理吞吐率。高效的执行数据流水线是一个具有挑战性的任务,我们既要面临数据读取部分的I/O性能问题,又要解决数据处理部分的计算性能问题。针对上述挑战,我们将分别介绍面向高吞吐率读取性能的数据文件格式设计,以及能够充分发挥多核CPU算力的并行架构设计。不仅如此,和常规数据并行计算任务不同的是,大部分机器学习场景对于数据的输入输出顺序有着特殊的`保序性`的要求,我们将会使用一节的内容来介绍什么是保序性,以及如何在数据模块的并行架构中设计相应组件计来满足该特性需求。学习了上述的内容后,读者将会对如何构建一个面向机器学习场景高效易用的数据模块有深刻的理解。最后,作为拓展内容,我们将以目前学术界和业界的一些实践经验来介绍当单机处理性能达不到要求时,该如何去扩展我们的数据处理模块以满足训练性能需求。本章学习目标包括:
|
||||
|
||||
@@ -1,50 +1,223 @@
|
||||
## 集合通讯
|
||||
## 集合通信
|
||||
|
||||
接下来,我们会讲解常见的大型深度模型训练的系统实现。这一类系统往往部署在商用的数据中心(Data Centers),以及如何在数据中心中高效实现集合通讯,从而让分布式训练系统免于网络瓶颈。
|
||||
作为并行计算中的一个重要概念,集合通信算子经常会被用来构建单程序流/多数据流编程环境(single program-multiple data, SPMD)中的许多交互模式。近年来,该领域无论是在对不同硬件架构的支持还是算法性能的发展上都成果颇丰,而因SPMD在大型深度学习系统中与数据并行的深厚联系,这些框架也在其中受益匪浅。因此,相比点对点 (Point-to-Point, p2p) 通信,我们有更大的兴趣去探讨如何高效地在数据中心(Data Centers)中实现这些集合通信范式。首先,我们会介绍一些集合通信中常见的算子,一个经典的利用All算法解决分布式训练系统中网络瓶颈的示例,探讨该算法在不同网络拓扑结构下的差异性以及一些重要指标(算法带宽,总线带宽)的计算方法,最后简略介绍现有机器学习系统对不同集合通信算法的支持。
|
||||
|
||||
### 常见算子
|
||||
|
||||
在分布式内存模型(Distributed Memory Model)中,一些常见的进程间数据交互模式由硬件支持和并行算法的内在性质而涌现。因此,主流的并行计算架构标准(例如MPI)和机器学习系统的底层集合通信库(例如gloo,NCCL)通常会支持数个经典的算子并针对其做优化,一般包括Broadcast,Reduce,AllGather,ReduceScatter 和 AllReduce。在一个基于 :cite:`Sanders2019-cq` 的简化理论模型下,可以对这些算子的特性进行简单的介绍并探讨具体的实现方法和计算开销。
|
||||
|
||||
#### 基本定义
|
||||
|
||||
首先,假定一个简化后的分布式内存模型:存在p个随机存取存储器(Random Access Machines, RAM)作为基础的处理单元(Processing Element, PE),并由一个网络来连接所有的机器。每个处理单元有自己的独立内存,并且所有的处理单元间的通信都通过网络传输。同时,每个处理单元都知道自己的编号$i$,通常在$1$到$p$之间。
|
||||
网络之间的通信在最底层的情况下均为点对点的全双工通信(full-duplex point-to-point communication):
|
||||
|
||||
* 每次通信有且仅有一个发送者(sender)和一个接收者(receiver)。
|
||||
* 在某个特定时刻,每个处理单元仅能至多发送或接收一个信息。但是,在网络中可以同时传输多个信息。每个处理单元也可以在发送一个信息的同时接收一个信息。
|
||||
* 传输一个长度为l的信息会花费$a+bl$的时间,其中$a$代表延迟(latency),即单位信息通过网络从一个处理单元出发到达另一个处理单元所需的时间;$b$代表传输延迟(transmission delay),即把单位信息从处理单元中放到网络通信单元所需的时间。前者的大小一般取决于两个处理单元间的物理距离(同一个机架,同一个数据中心,横跨全球等),而后者的大小一般取决于通信网络的带宽。在这个模型下,假定所有处理单元之间的a和b均为恒定值。
|
||||
* 通信可以指定一个发送者或者一个接收者:由于每个存储单元都有相对应的编号,我们可以定义两个函数send(i,l) 和receive(i,l)。其中send函数会把信息l从当前的处理单元发送至编号为i的处理单元,而receive函数会从编号为i的处理单元接收信息l。在调用send函数时,处理单元必须同时调用receive来保证编号为i的处理单元收到了该信息。因此,也可以说send和receive 同步(synchronize)了发送者和接收者。
|
||||
* 作为拓展,我们也可以定义上述函数的一个变种:i = send(m) 和 i = receive(m),即在传输信息时不规定发送者或接收者。这种情况下,网络中的任意一个处理单元都可以发送或接收该信息,而最终完成传输的处理单元的编号会作为函数的返回值。
|
||||
* 虽然在现实生活中错误(fault)时常发生,但是在这个模型里,暂不考虑通信丢失(dropped message)和通信毁坏(corrupted message)的情况。
|
||||
|
||||
分布式内存模型中对于通信同步和传输的结合使得在这个理论模型下开发的代码更好维护。额外的,由于这个框架下提出的算法往往会产生一些很有规律的,包含了网络中所有处理单元的交互模式,通常会在最基础的点对点通信上维护一个算子库,用来归纳总结这些高效且更易于理解的算法,我们将其称为集合通信算子。
|
||||
|
||||
#### Broadcast
|
||||
在SPMD中,最常见的一个交互模式经常是把一个位于处理单元i的信息发送到全部其他的节点,用于同步某种全局的变量或者参数。为此Broadcast算子可以定义为从编号为$i$的处理单元发送长度为$l$的信息给全部剩余的$p-1$个处理单元。在这里,一种简单的方法是在一个循环中使用$p-1$次send/receive来实现Broadcast,但这并不能很好地利用通信可并行化的特质(该算法只有$(a+bl)(p-1)$的线性时间复杂度)。为此,我们可以利用分治思想(divide-and-conquer)来对上述算法进行优化。假设所有的处理单元可以重新对编号进行排列,使得Broadcast的发送者为编号为$1$的处理单元。同时,为了简化计算过程,假设对于某个自然数$n$,$p = 2^n$。 现在,我们可以通过从1 向 $p/2$ 发送一次信息来把问题转化为两个大小为$p/2$的子问题:编号为1的处理单元对1到$p/2-1$ 的Broadcast,以及编号为$p/2$的处理单元对$p/2$到$p$的Broadcast。我们便可以通过在这两个子问题上进行递归来完成这个算法,并把临界条件定义为编号为i的处理单元在$[i,i]$这个区间里的Broadcast。此时,由于i本身已经拥有该信息,我们不需要做任何操作便可直接完成Broadcast。这个优化后的算法有$(a+bl)\log p$ 时间复杂度,因为在算法的每一阶段$t$,我们有$2^t$个计算单元在并行运行Broadcast算子。同时,算法一定会在$\log p$ 步之内结束。
|
||||
|
||||
#### Reduce
|
||||
除了Broadcast,另一个常见的交互模式为程序试图概述在部分处理单元上得到的中间值。这时候,对于一个符合结合律(associative property)的算子$f$,我们可以定义Reduce算子,即将所有处理单元上的某个值两两配对重复应用该算子,并把最终结果储存在编号为$i$的计算单元上。常见的应用于Reduce中的算子有加和,乘积,最大值,最小值和平均值等。一个简易的Reduce的优化实现同样可以用分治思想来实现,即把$1$到$p/2-1$的Reduce结果存到编号为$1$的处理单元中,然后把$p/2$到$p$的Reduce结果存到$p/2$上。最后,我们可以把$p/2$的结果发送至$1$,执行$f$,并把最后的结果存至$i$。假设$f$的运行时间复杂度为常数并不改变其输出信息的长度$l$,Reduce的时间复杂度仍然为$(a+bl)\log p$。
|
||||
|
||||
#### AllReduce
|
||||
AllReduce算子为Reduce的一个变种,即将f的结果存至所有处理单元上。在这里,我们给出一个简化版的AllReduce 实现方式,即首先把最终值通过Reduce存到编号为$1$的处理单元,再将该值通过Broadcast广播到所有的处理单元上。在两个子算子都使用上述的算法情况下,AllReduce的时间复杂度仍为$(a+bl)\log p。$
|
||||
|
||||
#### Gather
|
||||
Gather算子尝试将每个处理单元上的信息全部聚合到编号为$i$的处理单元上,通常用于组装散落在每个处理单元上的独立信息。在聚合函数符合结合律的情况下,可以通过将其设为Reduce算子中的$f$来实现Gather算子。但是,在这种情况下,无论是基于链表还是数组的实现,在每一步的Reduce子问题中$f$的时间复杂度或输出长度$l$都发生了改变。因此,Gather并不具有先前Reduce或者Broadcast的时间复杂度,而是$a \log p + (p-1) bl$。这是因为在算法的每一阶段t,我们传输的信息长度为$l 2^t$。
|
||||
|
||||
#### AllGather
|
||||
相比起Gather,AllGather 算子会把聚合的结果存到所有的处理单元上。在这里,一个简单的做法是使用Gather和Broadcast把聚合结果先存到编号为1的处理单元中,再将其广播到剩余的处理单元上。这会产生一个$a \log p + (p-1) bl + (a+plb) \log p$的时间复杂度,因为在Broadcast时如果忽略链表/数组实现所带来的额外空间开销,每次通信的长度为$pl$而不是$l$。简化后,我们得到了一个$a \log p + plb \log p$ 的时间复杂度。在一个基于超立方体的算法下,我们可以将其进一步优化到和Gather一样的$a \log p + (p-1) bl$ (:cite:`Sanders2019-cq`),然而由于篇幅问题便不再赘述。
|
||||
|
||||
#### Scatter
|
||||
Scatter算子可以被视作Gather的逆运算:把一个存在于编号为$i$的处理单元上,长度为$p$(信息长度为$pl$)的链式数据结构L中的值分散到每个处理单元上,使得编号为i的处理单元会得到$L[i]$。我们可以通过模仿Gather算法来设计一个简易的Scatter实现:每一步的运算中,与其是聚集一半处理单元的结果,我们把现在的子链继续对半切分,并把前半段和后半段作为子问题进行递归。这时候,在算法的每一阶段$t$,我们传输的信息长度为$l 2^(m-t)$,其中m是算法总共运行的步骤,不会超过$\log p$ (见Broadcast)。最终,Scatter算子的检疫实现和Gather一样都有$a \log p + (p-1) bl$ 时间复杂度。在机器学习系统中,相比于链式数据结构,Scatter经常同时被用于可切分的数据结构,例如张量(tensor)在一个维度上的p等分等。
|
||||
|
||||
#### ReduceScatter
|
||||
ReduceScatter算子可以视为Reduce 和 Scatter算子的组合体,即对于每个处理单元上分别拥有的一个链式/可切分数据结构,在通过f 概述后再重新分散到各个单元中。虽然我们已经知道了Reduce 和Scatter 各自的时间复杂度,但是在对ReduceScatter做时间复杂度分析时需要注意两部之间信息长度的变化:假设每个处理单元上的数据结构所需通信长度为$pl$,第一阶段的Reduce算法需要$(a+plb)\log p$ 时间复杂度。参照Scatter的分析,第二阶段的算子则需要 $a \log p + (p-1) bl$ 时间复杂度。综合下来,ReduceScatter 需要 $a \log p + plb \log p$ 的时间复杂度,和AllGather相同。同时,运行ReduceScatter 和 AllGather的效果等同于运行一次AllReduce。
|
||||
|
||||
在SPMD中,通常还有一些额外的集合通信算子,如Prefix Sum,Barrier,All-to-All等,但由于篇幅限制以及与机器学习系统的有限联系,便不再赘述。最后,由于该模型下通信网络的拓扑结构较为简单,上文中呈现二叉树形的递归树也可以达到很好的实际运行速度。所有关于时间复杂度的分析也是基于这些相对简化的假设情况。后文中,我们将会用AllReduce举例介绍如何在更复杂的拓扑结构下设计不同的集合通信算子变种,并在时间复杂度之外去关注实际的通信量和运算时间。
|
||||
|
||||
### 在数据中心的梯度计算
|
||||
|
||||
接下来,我们将用一个示例来阐释集合通信在机器学习系统中发挥的重要作用。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`ch10-datacentre`
|
||||
|
||||
:numref:`ch10-datacentre` 描述了一个典型的用于深度学习模型训练的数据中心。数据中心中的训练服务器一般会有多个设备。如需增加服务器,我们会将多个训练服务器放置在一个机柜(Rack)上,同时接入一个架顶交换机(Top of Rack Switch)来连接多个服务器。当一个机柜满的时候,我们可以通过在架顶交换机之间增加骨干交换机(Spine Switch),接入新的机柜。通过这种方式,我们可以在数据中心内不断增加服务器,从而为神经网络的训练提供海量的算力和内存。目前的商用数据中心可能拥有超过一百万台服务器。
|
||||
:numref:`ch10-datacentre` 描述了一个典型的用于深度学习模型训练的数据中心。数据中心中的训练服务器一般会有多个设备。如需增加服务器,我们会将多个训练服务器放置在一个机柜(Rack)上,同时接入一个架顶交换机(Top of Rack Switch)将其连接。在现有机柜满载的情况下,可以通过在架顶交换机间增加骨干交换机(Spine Switch)来接入新的机柜。通过这种方式,可以在数据中心内不断增加服务器,从而为神经网络的训练提供海量的算力和内存。目前的商用数据中心可拥有近百万台服务器。
|
||||
|
||||
在数据中心中训练大型神经网络的首要挑战是:如何高效计算大量的平均梯度。假设给定一个千亿级别参数的神经网络(GPT-3模型含有1750亿参数),如果用32位浮点数来表达每一个参数,那么每一步训练中,一个数据并行模式下的模型副本(Model replica)就需要生成700GB的本地梯度数据(即 175G $\times$ 4 bytes = 700GB)。假如我们有3个模型副本,那么至少需要传输1.4TB(即,700GB $\times$ $(3-1)$)的本地梯度(这是因为$N$个副本中,我们只需要传送$N-1$梯度来完成平均梯度计算)。当平均梯度计算完成后,我们需要进一步将平均梯度广播到全部的模型副本(即1.4TB的数据),更新本地参数,从而确保模型副本不会偏离(Diverge)。
|
||||
在数据中心中训练大型神经网络的首要挑战是如何高效计算大量的平均梯度。假设给定一个千亿级别参数的神经网络(比如OpenAI 发布的大型语言模型GPT-3 :cite:`gpt-3` 有将近1750亿参数),如果用32位浮点数来表达每一个参数,那么每一步训练中,一个数据并行模式下的模型副本(Model Replica)则需要生成700GB的本地梯度数据(即 175G $\times$ 4 bytes = 700GB)。假如有3个模型副本,那么至少需要传输1.4TB(即,700GB $\times$ $(3-1)$)的本地梯度数据(因为对于$N$个副本,只需传送其中的$N-1$个副本来完成计算)。当平均梯度计算完成后,需要进一步将其广播(Broadcast)到全部的模型副本(即1.4TB的数据)并更新其中的本地参数,从而确保模型副本不会偏离(Diverge)主模型中的参数。
|
||||
|
||||
当前的数据中心往往使用以太网(Ethernet)构建网络。主流的商用以太网链路带宽一般是10Gbps和25Gbps。利用以太网传输海量梯度会产生严重的传输延迟,从而降低模型训练的速度。新型深度学习训练集群(如英伟达的DGX系列机器)往往配置有更快的Inifiband。单个InfiniBand链路可以提供100Gbps和200Gbps的带宽。即使拥有这种高速网络,传输TB级别的本地梯度依然需要大量延迟(1TB的数据需要在200Gbps的链路上传输25秒)。
|
||||
当前的数据中心一般使用以太网(Ethernet)构建不同机柜之间的网络。主流的商用以太网链路带宽一般在10Gbps到25Gbps之间。利用以太网传输海量梯度会产生严重的传输延迟,从而降低模型训练的速度。新型深度学习训练集群(如英伟达的DGX系列机器)往往配置有更快的Inifiband。单个InfiniBand链路可以提供100Gbps或200Gbps的带宽。即使拥有这种高速网络,传输TB级别的本地梯度依然需要大量延迟(即使忽略网络延迟,1TB的数据在200Gbps的链路上传输也需要至少40秒)。
|
||||
|
||||
为了避免通过网络传输数据,现代深度学习服务器一般都会配备多个计算设备(例如说,DGX-3机器会被配备8个A100 GPU),而在一个服务器内的多个设备可以通过高速机内网络互联(如NVLink)。这种高速机内网络可以提供高达400GB/s的带宽,从而让传输TB级别数成为可能。然而,受限于单个服务器的散热,成本和硬件故障等需求,在一个服务器内我们无法无限制的持续增加设备,大型深度学习模型的训练最终还是需要多个服务器共同完成。因此,计算平均梯度需要同时借助以太网或者是InfiniBand,以及服务器内部的NVLink等机内网络。
|
||||
为了避免通过机间网络传输数据,现代深度学习服务器一般都会配备多个加速器(例如说,英伟达的DGX-3服务器会配备8个A100 GPU),而在一个服务器内的多个设备可以通过高速机内网络互联(如NVLink)。这种高速机内网络可以提供高达400GBps的带宽,从而让传输TB级别的数据成为可能。然而,受限于单个服务器的散热,成本和硬件等限制,通常无法在一个服务器内无限制的持续增加设备。因此,大型深度学习模型的训练仍需要多个服务器共同完成。在计算平均梯度时,服务器需要同时借助机间网络通信接口(以太网或InfiniBand)和机内通信接口(NVLink)。
|
||||
|
||||
### Allreduce算法
|
||||
### 基于AllReduce的梯度平均算法
|
||||
|
||||
为了在数据中心中高效完成梯度平均的操作,我们往往会实现Allreduce算法。这个算法诞生的背景是:传统计算平均梯度的方法往往是在集群中找出一个设备来收集本地梯度,计算平均梯度,然后再将平均梯度广播到全部的设备。这种做法易于实现,但是其引入了两个问题。首先,多设备共同给这个聚合设备发送数据的时候,在聚合设备上往往会产生严重的带宽不足和网络拥塞。其次,单设备需要负担大量的梯度平均的计算,而受限于单设备上的有限算力,这种平均计算会受限于算力瓶颈。
|
||||
我们将讨论如何利用AllReduce算子来实现数据中心中的高效梯度平均。首先,参照前文的分析,可以考虑一种简单的计算平均梯度的方法:在集群中分配一个设备来收集本地梯度,并在计算平均梯度后再将其广播到全部的设备。这种做法易于实现,但是引入了两个问题。首先,多台设备同时给该聚合设备发送数据时,聚合设备会因严重的带宽不足产生网络拥塞。其次,单台设备需要负担大量的梯度平均计算,而受限于单台设备上的有限算力,这种计算往往会受限于算力瓶颈。
|
||||
|
||||

|
||||

|
||||
:width:`800px`
|
||||
:label:`ch10-allreduce-state`
|
||||
:label:`ch10-AllReduce-state`
|
||||
|
||||
为了解决上述问题,人们设计了Allreduce算法。该算法的核心设计思路是:让全部的节点参与进来平均梯度的网络通信和平均计算中,从而将巨大的网络和算力开销均摊给全部节点,从而解决使用单个梯度聚合节点的问题。假设我们有$M$个设备,每个设备有一个模型副本,该模型由$N$个参数构成。那么按照Allreduce算法要求,我们需要首先将全部的参数按照设备数量切分成$M$个分区(Partition),每个分区具有$N/M$个参数。为了讲解Allreduce的过程,我们首先给出这个算法的初始和终止状态。如 :numref:`ch10-allreduce-state` 所示,该例子含有3个设备,每个设备有一个模型副本,这个副本有3个参数。那么按照Allreduce的分区方法,参数会被划分成3个分区(3个设备),而每一个分区有1个参数($N/M$,N代表3个参数,M代表3个设备)。在这个例子中,假定设备1拥有参数2,4,6,设备2拥有参数1,2,3,设备3拥有参数4,8,12,那么Allreduce结束后,全部的设备都拥有梯度相加后的结果7,14,21,其中分区1的结果7是由3个设备中分区1的初始结果相加而成(7 = 1 + 2 + 4)。为了计算平均梯度,每个设备只需要在最后将梯度之和除以设备数量即可(分区1的最终结果为7除以3)。
|
||||
为了解决上述问题,可以引入AllReduce算子的Reduce-Broadcast实现来优化算法,其设计思路是:通过让全部的节点参与到梯度的网络通信和平均计算中,将巨大的网络和算力开销均摊给全部节点。这种做法可以解决先前单个梯度聚合节点的问题。假设有$M$个设备,每个设备存有一个模型副本,该模型由$N$个参数/梯度构成。那么按照AllReduce算子的要求,需要先将全部的参数按照设备数量切分成$M$个分区(Partition),使得每个分区具有$N/M$个参数。我们首先给出这个算法的初始和终止状态。如 :numref:`ch10-AllReduce-state` 所示,该例子含有3个设备。在每个设备有一个模型副本的情况下,这个副本有3个参数。那么按照AllReduce的分区方法,参数会被划分成3个分区(3个设备),而每一个分区则有1个参数($N/M$,N代表3个参数,M代表3个设备)。在这个例子中,假定设备1拥有参数2,4,6,设备2拥有参数1,2,3,设备3拥有参数4,8,12,那么在使用AllReduce算子进行计算过后,全部的设备都将拥有梯度相加后的结果7,14,21,其中分区1的结果7是由3个设备中分区1的初始结果相加而成(7 = 1 + 2 + 4)。为了计算平均梯度,每个设备只需要在最后将梯度之和除以设备数量即可(分区1的最终结果为7除以3)。
|
||||
|
||||

|
||||

|
||||
:width:`800px`
|
||||
:label:`ch10-allreduce-process`
|
||||
:label:`ch10-AllReduce-process`
|
||||
|
||||
Allreduce算法会把梯度的加和计算拆分成$M-1$个Reduce步骤和$M-1$个Broadcast步骤(其中$M$是节点的数量)。Reduce步骤是为了计算出梯度的和(Summation),Broadcast步骤是为了把梯度之和广播给全部的节点。为了说明这些步骤的执行过程,我们利用 :numref:`ch10-allreduce-process` 。Allreduce算法由Reduce步骤开始,在第一个Reduce步骤中,Allreduce算法会对全部节点进行配对(Pairing),让他们共同完成梯度相加的操作。在 :numref:`ch10-allreduce-process` 的第一个Reduce步骤中,设备1和设备2进行了配对共同对分区1的数据相加。其中,设备2把本地的梯度数据1发送给设备1,设备将接收到1和本地的分区1内的梯度数据:2进行相加,计算出中间(intermediate)梯度相加的结果:3。于此同时,设备1和设备3进行配对,共同完成对分区3的数据相加。而设备3和设备2进行配对,共同完成对于分区2的数据相加。
|
||||
AllReduce算子会把梯度的计算拆分成$M-1$个Reduce算子和$M-1$个Broadcast算子(其中$M$是节点的数量)。其中,Reduce算子用于计算出梯度的和(Summation),Broadcast算子用于把梯度之和广播给全部的节点。为了说明这些算子的执行过程,可以参照 :numref:`ch10-AllReduce-process` 。AllReduce算子由Reduce算子开始,在第一个Reduce算子中,AllReduce算子会对全部节点进行配对(Pairing),让他们共同完成梯度相加的操作。在 :numref:`ch10-AllReduce-process` 的第一个Reduce算子中,设备1和设备2进行了配对共同对分区1的数据相加。其中,设备2把本地的梯度数据1发送给设备1,设备将接收到1和本地的分区1内的梯度数据:2进行相加,计算出中间(intermediate)梯度相加的结果:3。与此同时,设备1和设备3进行配对,共同完成对分区3的数据相加。而设备3和设备2进行配对,共同完成对于分区2的数据相加。
|
||||
|
||||
在上述Reduce的步骤中,梯度的计算实现了以下几个特性:
|
||||
在上述Reduce的算子中,梯度的计算实现了以下几个特性:
|
||||
|
||||
- **网络优化:**
|
||||
全部设备都同时在接收和发送数据,利用起了每个设备的入口(Ingress)和出口(Egress)带宽。因此Allreduce过程中可利用的带宽是$M \times B$,其中$M$是节点数量,$B$是节点带宽,从而让系统实现网络带宽上的可扩展性。
|
||||
全部设备都同时在接收和发送数据,利用起了每个设备的入口(Ingress)和出口(Egress)带宽。因此AllReduce过程中可利用的带宽是$M \times B$,其中$M$是节点数量,$B$是节点带宽,从而让系统实现网络带宽上的可扩展性。
|
||||
|
||||
- **算力优化:**
|
||||
全部设备的处理器都参与了梯度相加的计算。因此Allreduce过程中可利用的处理器是$M \times P$,其中$M$是节点数量,$P$是处理器数量,从而让系统实现计算上的可扩展性。
|
||||
全部设备的处理器都参与了梯度相加的计算。因此AllReduce过程中可利用的处理器是$M \times P$,其中$M$是节点数量,$P$是处理器数量,从而让系统实现计算上的可扩展性。
|
||||
|
||||
- **负载均衡:**
|
||||
由于数据分区是平均划分的,因此每次设备分摊到的通讯和计算开销是相等的。
|
||||
|
||||
在接下来的Reduce步骤中,Allreduce算法会对不同数据分区选择另外的配对方法。例如说,在 :numref:`ch10-allreduce-process` 的第二个Reduce步骤中,Allreduce算法会将:设备1和设备3进行配对,负责分区1的数据相加。将设备1和设备2进行配对,负责分区2。将设备2和设备3进行配对,负责分区3。在一个3个节点的Allreduce集群里,在2个Reduce步骤完成后,我们就计算出了每个分区的数据相加结果(分区1的结果7此时在设备3上,分区2的结果14此时在设备1上,分区3的结果21此时在设备2上)。
|
||||
在接下来的Reduce算子中,AllReduce算法会对不同数据分区选择另外的配对方法。例如说,在 :numref:`ch10-AllReduce-process` 的第二个Reduce算子中,AllReduce算法会将:设备1和设备3进行配对,负责分区1的数据相加。将设备1和设备2进行配对,负责分区2。将设备2和设备3进行配对,负责分区3。在一个3个节点的AllReduce集群里,在2个Reduce算子完成后,我们就计算出了每个分区的数据相加结果(分区1的结果7此时在设备3上,分区2的结果14此时在设备1上,分区3的结果21此时在设备2上)。
|
||||
|
||||
接下来,Allreduce算法将进入Broadcast阶段。这一阶段的过程和Reduce步骤类似,核心区别是节点进行配对后,他们不再进行数据相加,而是将Reduce的计算结果进行广播。在 :numref:`ch10-allreduce-process` 中的第一个Broadcast步骤中,设备1会将分区2的结果14直接写入设备3的分区2中。设备2会讲分区3的结果21直接写入设备1中。设备3会将分区1的结果直接写入设备2中。在一个3个节点的Allreduce集群中,我们会重复2次Broadcast步骤来将每个分区的Reduce结果告知全部的节点。
|
||||
接下来,AllReduce算法将进入Broadcast阶段。这一阶段的过程和Reduce算子类似,核心区别是节点进行配对后,他们不再进行数据相加,而是将Reduce的计算结果进行广播。在 :numref:`ch10-AllReduce-process` 中的第一个Broadcast算子中,设备1会将分区2的结果14直接写入设备3的分区2中。设备2会讲分区3的结果21直接写入设备1中。设备3会将分区1的结果直接写入设备2中。在一个3个节点的AllReduce集群中,我们会重复2次Broadcast算子来将每个分区的Reduce结果告知全部的节点。
|
||||
|
||||
Allreduce算法已经被常见的分布式训练框架(包括Horovod, KungFu, TensorFlow distributed, PyTorch distributed)等支持。当用户选择使用数据并行模式的过程,其底层会默认触发。
|
||||
### 带宽计算
|
||||
|
||||
在讨论集合通信算子的性能时,人们经常会使用一些数值化指标去量化不同的算法实现,其中一个重要概念为带宽(Bandwidth)。在文献(:cite:`nvidia-nccl`)中,通常有两种主流的对带宽的计算方法,分别为算法带宽(Algorithm Bandwidth)与总线带宽(Bus Bandwidth)。
|
||||
|
||||
#### 算法带宽
|
||||
前文提到,在计算点对点通信所需的时间是,会在信息长度之上乘以一个系数b。这个系数就是算法带宽,泛指单位时间内执行操作(通信,计算等)的数量。一般计算公式为$b = s/t$,其中$s$代指操作的大小,$t$指操作指定的两个端点之间所经过的时间。以点到点通信举例,我们可以通过衡量一个大小已知的信息$m$在执行send函数时所花的时间来确定两个处理单元之间网络的带宽。
|
||||
|
||||
#### 总线带宽
|
||||
虽然算法带宽的计算方法既简单又高效,但很难将其拓展至对于集合通信算子的带宽计算。这是因为,取决于具体算子和算法实现的不同,一个集合通信算子在执行过程中测得的算法带宽往往会远小于硬件本身的最高带宽。在实际运行相应的测试中,经常能观测到随着处理单元增加,算法带宽呈下降趋势。为了解决这一问题,NCCL提出了总线带宽这一概念,通过对于每个集合通信算子的分析来对测得的算法带宽乘以一个校正系数(correction factor),来减轻处理单元数量对于测量带宽的影响并给出一个更贴近实际硬件表现的带宽值。下面列出了一些常见算子的校正系数,以及背后的简略推导。
|
||||
|
||||
* AllReduce:$2(p-1)/p$ 对于在处理单元$n_1, n_2 \cdots n_p$ 上的值 $v_1, v_2 \cdots v_p$ 计算 $v_1 (op) v_2 \cdots (op) v_p$(其中$op$为符合结合律的算子),再存回每个处理单元中。在不考虑实际实现算法和网络拓扑的情况下,这个操作理论上只需要 $2(p-1)$ 次数据传输,其中包含在每个处理单元上分开进行的 $n-1$ 次 op的运算,以及最后 $n$ 次最终数据值的广播,再减去第一个处理单元的运算和最后一个处理单元的广播的影响。假设每个处理单元对于外界所有信息处理的带宽为$B$,我们可以得出对于S个在不同处理单元上的数据运行AllReduce是能得到的最优情况下的运行时间:$t = (2S(p-1)) / (pB)$,进行简化后可得 $B = (S/t)(2(p-1)/p) = b (2(p-1)/p)$。这里的 $2(p-1)/p$便是我们的校正系数。
|
||||
* ReduceScatter:$(p-1)/p$ 对于每个处理单元来说,可以把ReduceScatter理解为只执行AllReduce中的聚合部分。对此,我们只需要考虑上文分析中的$n-1$次$op$的运算,整理后可得$B = (S/t)((p-1)/p) = b ((p-1)/p)$。
|
||||
* AllGather:$(p-1)/p$ 同理,对于每个处理单元来说,可以把AllGather理解为只执行AllReduce中的广播部分。我们同理可得$B = (S/t)((p-1)/p) = b ((p-1)/p)$。
|
||||
* Broadcast:$1$ 与AllReduce不同的是,Broadcast中所有数据需要从算子本身的发送者发出。即使在上文的分治情况下,我们也需要等待所有子问题运行结束才能确保Broadcast算子本身的正确性。因此,在计算带宽时瓶颈仍为发送者对于外界所有信息处理的带宽,所以 $B = S/t$,即校正系数为$1$。
|
||||
* Reduce:$1$ 同Broadcast,Reduce需要将所有数据送往算子的接收者,因此校正系数同样为$1$。
|
||||
|
||||
由于Gather和Scatter的带宽计算与实际聚合/分散时的数据结构相关性更高,故不给出特定的校正系数。
|
||||
|
||||
### 样例分析
|
||||
|
||||
针对不同的集群性质,现代机器学习系统往往会灵活应用不同集合通信算子的组合来最大化通信效率。这里,我们提供了两个具体的案例分析,分别为微软的ZeRO 以及 OpenAI 的 DALL—E。
|
||||
|
||||
#### ZeRO
|
||||
ZeRO (:cite:`rajbhandari2020zero`)是微软提出的神经网络优化器,可用于训练千亿级参数的神经网络,也在实践中成功训练了当时世界上最大的语言模型(为高达170亿参数的transformer)。在训练这个级别的神经网络时主要遇到的问题是巨量参数对于加速器内存的占用,其中包括优化器本身的参数,反向传播时的梯度,以及模型参数本身。通过简易的计算不难得出,170亿参数的模型在32位浮点表示情况下会占用至少680GB的内存,远超于现在内存最高的深度学习加速器A100 (最高内存80GB)。于是,我们需要考虑如何高效的把模型切成数份存储在不同的加速器上,以及如何高效的通过使用集合通信算子来进行模型训练和推理。ZeRO对此提出了多个优化方法,这里例举了三个典型的例子:
|
||||
1. 首先,可以发现在现代集群中,节点内部加速器的带宽往往比节点之间的带宽要大很多。这在某种程度上偏离了上文中的理论框架。为此,我们需要尽量减少节点间的通信,尽量保证大部分通信仅存在于节点内部的加速器之间。在观察模型切分时,不难看出模型本身前馈和反向传播时需要大量的在不同切片之间通信,相比下来不同模型拷贝之间的梯度聚合反而具有相对较少的通信量。针对这一特性,ZeRO选择了将单一模型的全部切片存储到同一节点内部,从而大大提高了训练效率。
|
||||
2. 进一步地,假设模型中的参数在层的细粒度上呈线性,便可将其从前到后分别存储到不同加速其中。在前馈时,可以注意到某一层的计算仅依赖于其相邻层的参数。对此,与其是手动设计点到点通信,我们可以对所有包含模型参数的加速器进行一次AllGather计算,用来提取每一层之后一层的参数,以及计算该层本身的激活值。为了节约内存,我们在AllGather结束后立即丢弃除了该层以外其他层的参数。
|
||||
3. 同理,在反向传播时我们只需要前一层的参数来计算本层的激活值和梯度,因此我们只需要再次使用AllGather来完成每个加速器上的梯度计算。同时,我们注意到在聚集梯度后,对于每个加速器我们仅需要在内存中的层数的梯度。对此,我们可以使用ReduceScatter算子来在平均后直接把相应的梯度存到编号为i的加速器上,而不是通常情况下的AllReduce。
|
||||
|
||||
#### DALL-E
|
||||
DALL-E (:cite:`ramesh2021zero`)是OpenAI提出的一个基于文字的图片生成模型,模型同样拥有高达120亿参数。在训练时,除了运用到ZeRO所使用的AllGather + ReduceScatter 技巧,OpenAI团队在细节上做了进一步的优化,以达到更快的训练速度。这里,我们简略介绍以下和集合通信相关的两点:
|
||||
1. 我们注意到,集合通信算子的运行速度和通信本身的长度正相关。在模型训练中,这代表了模型参数本身的大小。对此,DALL-E 选择用矩阵分解(matrix factorization)的方法先把高维张量调整为一个二维矩阵,通过分解后分开用集合通信算子进行传输,从而大大减少了通信量。
|
||||
2. 另一个减少通信量的方法在于数据类型本身。一个显然的做法是使用16位的半精度浮点数,相比正常的32位参数表示可以节省近一倍的通信量。但是,在实践中发现低精度的数据类型会使得模型收敛不稳定,往往导致最终训练效果大打折扣。为此,OpenAI分析了DALL—E 的模型结构,并把其中的参数根据对数据类型精度的敏感性分为了多个类。其中对精度最敏感的一类照常使用32位浮点表示并只通过AllReduce来同步,而最不敏感的参数则照常通过矩阵分解进行压缩和传输。对于比较敏感的一类,例如Adam 优化其中的动能(moments)和方差(variance)参数,OpenAI 基于 IEEE 754 标准实现了两个全新的数据类型:1-6-9和0-6-10(其中第一表示正负所需的位数,第二表示指数所需的位数,第三表示有效数字所需的位数),在节省空间和保持收敛性能之间找到了一个平衡。
|
||||
|
||||
### 集合通信与机器学习系统
|
||||
|
||||
最后,集合通信已经被深度集成到了整个机器学习系统之中,以至于一些在库级别以上的开发者很难意识到系统在训练和推理时的一些步骤是由底层逻辑实现的。
|
||||
一般来说,不同的机器学习系统对于集合通信一般提供了两个级别的抽象,分别是更与硬件耦合的,可以直接调用集合通信算子的库,和更偏向神经网络实现的,通过内部调用集合通信算子来实现分布式训练和推理的深度学习框架。作为算法工程师,通常会接触到后者的抽象(包括Horovod, KungFu, TensorFlow distributed等),而作为集群的维护者,往往需要深入了解前者的运行原理和具体的调试方法。以深度学习框架 PyTorch 举例,在torch.distributed 命名空间(namespace)下实现了一系列方便开发者使用的分布式模型训练和推理函数。在其内部,会根据实际运行的集群调用更底层的集合通信算子库,例如MPI,NCCL(前文中已有介绍,适用于GPU分布式训练),gloo(适用于CPU分布式训练)等。我们来具体对比PyTorch distributed 中对于AllReduce 的应用和 NCCL 的差异性:下面两段代码中,前者(:cite:`li2022ddp`)通过PyTorch自带的分布式数据并行(Distributed Data Parallel)方法完成了一次简易的深度学习模型计算,后者则通过gloo的Python 接口pygloo和Ray(:cite:`moritz2018ray`)完成了一个二维张量的AllReduce计算。
|
||||
|
||||
```python
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
import torch.nn as nn
|
||||
import torch.optim as optim
|
||||
import torch.multiprocessing as mp
|
||||
|
||||
from torch.nn.parallel import DistributedDataParallel as DDP
|
||||
|
||||
def setup(rank, world_size):
|
||||
os.environ['MASTER_ADDR'] = 'localhost'
|
||||
os.environ['MASTER_PORT'] = '12355'
|
||||
dist.init_process_group("gloo", rank=rank, world_size=world_size)
|
||||
|
||||
class ToyModel(nn.Module):
|
||||
def __init__(self):
|
||||
super(ToyModel, self).__init__()
|
||||
self.net1 = nn.Linear(10, 10)
|
||||
self.relu = nn.ReLU()
|
||||
self.net2 = nn.Linear(10, 5)
|
||||
|
||||
def forward(self, x):
|
||||
return self.net2(self.relu(self.net1(x)))
|
||||
|
||||
def demo_basic(rank, world_size):
|
||||
setup(rank, world_size)
|
||||
|
||||
model = ToyModel().to(rank)
|
||||
# 通过调用DDP将模型在每个处理器上完成初始化
|
||||
ddp_model = DDP(model, device_ids=[rank])
|
||||
|
||||
loss_fn = nn.MSELoss()
|
||||
optimizer = optim.SGD(ddp_model.parameters(), lr=0.001)
|
||||
|
||||
optimizer.zero_grad()
|
||||
outputs = ddp_model(torch.randn(20, 10))
|
||||
labels = torch.randn(20, 5).to(rank)
|
||||
|
||||
# 在反向传播时,框架内部会执行AllReduce算法
|
||||
loss_fn(outputs, labels).backward()
|
||||
optimizer.step()
|
||||
|
||||
def run_demo(demo_fn, world_size):
|
||||
mp.spawn(demo_fn,
|
||||
args=(world_size,),
|
||||
nprocs=world_size,
|
||||
join=True)
|
||||
|
||||
if __name__ == "__main__":
|
||||
n_gpus = torch.cuda.device_count()
|
||||
assert n_gpus >= 2, f"Requires at least 2 GPUs to run, but got {n_gpus}"
|
||||
run_demo(demo_basic, n_gpus)
|
||||
```
|
||||
|
||||
```python
|
||||
import os
|
||||
import ray
|
||||
import pygloo
|
||||
import numpy as np
|
||||
import multiprocessing
|
||||
|
||||
@ray.remote(num_cpus=1)
|
||||
def test_allreduce(rank, world_size, fileStore_path):
|
||||
context = pygloo.rendezvous.Context(rank, world_size)
|
||||
attr = pygloo.transport.tcp.attr("localhost")
|
||||
dev = pygloo.transport.tcp.CreateDevice(attr)
|
||||
fileStore = pygloo.rendezvous.FileStore(fileStore_path)
|
||||
store = pygloo.rendezvous.PrefixStore(str(world_size), fileStore)
|
||||
|
||||
context.connectFullMesh(store, dev)
|
||||
|
||||
sendbuf = np.array([[1,2,3],[1,2,3]], dtype=np.float32)
|
||||
recvbuf = np.zeros_like(sendbuf, dtype=np.float32)
|
||||
sendptr = sendbuf.ctypes.data
|
||||
recvptr = recvbuf.ctypes.data
|
||||
|
||||
# 标明发送者和者并直接调用AllReduce
|
||||
pygloo.allreduce(context, sendptr, recvptr,
|
||||
sendbuf.size, pygloo.glooDataType_t.glooFloat32,
|
||||
pygloo.ReduceOp.SUM, pygloo.allreduceAlgorithm.RING)
|
||||
|
||||
if __name__ == "__main__":
|
||||
ray.init()
|
||||
world_size = multiprocessing.cpu_count()
|
||||
fileStore_path = f"{ray.worker._global_node.get_session_dir_path()}" + "/collective/gloo/rendezvous"
|
||||
os.makedirs(fileStore_path)
|
||||
ray.get([test_allreduce.remote(rank, world_size, fileStore_path) for rank in range(world_size)])
|
||||
```
|
||||
|
||||
可以注意到,前者并没有显式的调用集合通信算子,而是通过DistributedDataParallel将分布式训练和正常训练之间的不同隐藏了起来。如果我们需要在不同集群上运行这段代码,只需要在setup 函数内相对的更改PyTorch使用的底层集合通信库即可。在backward函数被调用时,才会真正的使用AllReduce算法。相比下来,如果想要直接使用gloo,不仅需要使用一步一步的创建通信所需要的数据结构,同时也很难和现有的模型训练框架无缝连接。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
随着机器学习的进一步发展,科学家们设计出更大型,更多功能的机器学习模型(例如说,GPT-3)。这种模型含有大量参数,需要复杂的计算以及处理海量的数据。单个机器上有限的资源无法满足训练大型机器学习模型的需求。因此,我们需要设计分布式训练系统,从而将一个机器学习模型任务拆分成多个子任务,并将子任务分发给多个计算节点,解决资源瓶颈。
|
||||
|
||||
在本章节中,我们会引入分布式机器学习系统的相关概念,设计挑战,系统实现和实例研究。我们会首先讨论分布式训练系统的定义,设计动机和好处。进一步,我们会讨论常见的分布式训练方法:数据并行,模型并行和流水线并行。在实际中,这些分布式训练方法会被参数服务器(Parameter Servers),或者是集合通讯库(Collective Communication Libraries)实现。不同的系统实现具有各自的优势和劣势。我们会用大型预训练模型和大型深度学习推荐系统作为实例来探讨不同系统实现的利与弊。
|
||||
在本章节中,我们会引入分布式机器学习系统的相关概念,设计挑战,系统实现和实例研究。我们会首先讨论分布式训练系统的定义,设计动机和好处。进一步,我们会讨论常见的分布式训练方法:数据并行,模型并行和流水线并行。在实际中,这些分布式训练方法会被参数服务器(Parameter Servers),或者是集合通信库(Collective Communication Libraries)实现。不同的系统实现具有各自的优势和劣势。我们会用大型预训练模型和大型深度学习推荐系统作为实例来探讨不同系统实现的利与弊。
|
||||
|
||||
本章的学习目标包括:
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
- 掌握常见的分布式训练方法:数据并行,模型并行和流水线并行
|
||||
|
||||
- 掌握常见的分布式训练框架实现:参数服务器和集合通讯
|
||||
- 掌握常见的分布式训练框架实现:参数服务器和集合通信
|
||||
|
||||
- 理解常见分布式训练的实例,和采用不同实现方法的利弊。
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
:width:`800px`
|
||||
:label:`ch10-single-node`
|
||||
|
||||
分布式训练系统的设计目标是:将单节点训练系统转化成**等价的**并行训练系统,从而在不影响模型精度的条件下完成训练过程的加速。一个单节点训练系统往往如 :numref:`ch10-single-node`所示。一个训练过程会由多个数据小批次(mini-batch)完成。在图中,一个数据小批次被标示为**数据**。训练系统会利用数据小批次来生成梯度,提升模型精度。这个过程由一个训练**程序**实现。在实际中,这个程序往往实现了一个多层神经网络的执行过程。该神经网络的执行由一个计算图(Computational Graph)表达。这个图有多个相互连接的算子(Operator),每个算子会拥有计算参数。每个算子往往会实现一个神经网络层(Neural Network Layer),而参数则代表了这个层在训练中所更新的的权重(Weights)。
|
||||
分布式训练系统的设计目标是:将单节点训练系统转化成**等价的**并行训练系统,从而在不影响模型精度的条件下完成训练过程的加速。一个单节点训练系统往往如 :numref:`ch10-single-node`所示。一个训练过程会由多个数据小批次(mini-batch)完成。在图中,一个数据小批次被标示为**数据**。训练系统会利用数据小批次来生成梯度,提升模型精度。这个过程由一个训练**程序**实现。在实际中,这个程序往往实现了一个多层神经网络的执行过程。该神经网络的执行由一个计算图(Computational Graph)表达。这个图有多个相互连接的算子(Operator),每个算子会拥有计算参数。每个算子往往会实现一个神经网络层(Neural Network Layer),而参数则代表了这个层在训练中所更新的权重(Weights)。
|
||||
|
||||
为了更新参数,计算图的执行会分为**前向**传播和**反向**传播两个阶段。前向传播的第一步会将数据读入第一个算子,该算子会根据当前的参数,计算出传播给下一个算子的数据。算子依次重复这个前向传播的过程(算子1 -\> 算子2 -\> 算子3),直到最后一个算子结束。最后的算子随之马上开始反向传播。反向传播中,每个算子依次计算出梯度(梯度3 -\> 梯度2 -\> 梯度1),并利用梯度更新本地的参数。反向传播最终在第一个算子结束。反向传播的结束也标志本次数据小批次的结束,系统随之读取下一个小批次,继续更新模型。
|
||||
|
||||
@@ -30,9 +30,9 @@
|
||||
:width:`800px`
|
||||
:label:`ch10-data-parallel`
|
||||
|
||||
数据并行往往可以解决单节点的算力不足。这种并行方式在人工智能框架中最为常见,具体实现包括:TensorFlow DistributedStrategy,PyTorch Distributed,Horovod DistributedOptimizer等。在一个数据并行系统中,假设用户给定一个训练批大小$N$,并且希望使用$M$个并行设备来加速训练。那么,该训练批大小会被分为$M$个分区,每个设备会分配到$N/M$个训练样本。这些设备共享一个训练程序的副本,在不同数据分区上独立执行,计算梯度。不同的设备(假设设备编号为$i$)会根据本地的训练样本估计出梯度$G_i$。为了确保训练程序参数的一致性,本地梯度$G_i$需要聚合,计算出平均梯度$(\sum_{i=1}^{N} G_i) / N$。最终,训练程序利用平均梯度修正模型参数,完成小批量的训练。
|
||||
数据并行往往可以解决单节点的算力不足。这种并行方式在人工智能框架中最为常见,具体实现包括:TensorFlow DistributedStrategy,PyTorch Distributed,Horovod DistributedOptimizer等。在一个数据并行系统中,假设用户给定一个训练批大小$N$,并且希望使用$M$个并行设备来加速训练。那么,该训练批大小会被分为$M$个分区,每个设备会分配到$N/M$个训练样本。这些设备共享一个训练程序的副本,在不同数据分区上独立执行,计算梯度。不同的设备(假设设备编号为$i$)会根据本地的训练样本估计出梯度$G_i$。为了确保训练程序参数的一致性,本地梯度$G_i$需要聚合,计算出平均梯度$(\sum_{i=1}^{M} G_i) / M$。最终,训练程序利用平均梯度修正模型参数,完成小批量的训练。
|
||||
|
||||
:numref:`ch10-data-parallel`展示了2个设备构成的数据并行例子。假设用户给定的批大小(Batch Size)是64,那么每个设备会分配到32个训练样本,并且具有相同的神经网络参数(程序副本)。本地的训练样本会依次通过这个程序副本中的算子,完成前向传播和反向传播。在反向传播的过程中,程序副本会生成局部梯度。不同设备上对应的局部梯度(如设备1和设备2上各自的梯度1)会进行聚合,从而计算平均梯度。这个聚合的过程往往由集合通讯库(Collective Communication)的Allreduce操作来完成。
|
||||
:numref:`ch10-data-parallel`展示了2个设备构成的数据并行例子。假设用户给定的批大小(Batch Size)是64,那么每个设备会分配到32个训练样本,并且具有相同的神经网络参数(程序副本)。本地的训练样本会依次通过这个程序副本中的算子,完成前向传播和反向传播。在反向传播的过程中,程序副本会生成局部梯度。不同设备上对应的局部梯度(如设备1和设备2上各自的梯度1)会进行聚合,从而计算平均梯度。这个聚合的过程往往由集合通信库(Collective Communication)的Allreduce操作来完成。
|
||||
|
||||
### 模型并行
|
||||
|
||||
@@ -58,4 +58,4 @@
|
||||
:width:`800px`
|
||||
:label:`ch10-hybrid-parallel`
|
||||
|
||||
在训练大型人工智能模型中,我们往往会同时面对算力不足和内存不足。因此,我们需要混合使用数据并行和模型并行,这种方法被称为混合并行。 :numref:`ch10-hybrid-parallel`提供了一个由4个设备实现的混合并行的例子。在这个例子中,我们首先实现算子间并行来解决训练程序内存开销过大的问题:该训练程序的算子1和算子2被分摊到了设备1和设备2上。进一步,我们通过数据并行来添加3和设备4,提升系统算力。为了达到这一点,我们对训练数据进行分区(数据分区1和数据分区2),并将模型(算子1和算子2)分配复制到设备3和设备4上生成可以并行执行的程序副本。在前向计算的过程中,设备1和设备3上的算子1副本同时开始,计算结果分别发送(Send)给设备2和设备4完成算子2副本的计算。在反向计算中,设备2和设备4同时开始计算梯度,本地梯度通过Allreduce进行平均。反向计算传递到设备1和设备3上的算子1副本结束。
|
||||
在训练大型人工智能模型中,我们往往会同时面对算力不足和内存不足。因此,我们需要混合使用数据并行和模型并行,这种方法被称为混合并行。 :numref:`ch10-hybrid-parallel`提供了一个由4个设备实现的混合并行的例子。在这个例子中,我们首先实现算子间并行来解决训练程序内存开销过大的问题:该训练程序的算子1和算子2被分摊到了设备1和设备2上。进一步,我们通过数据并行来添加3和设备4,提升系统算力。为了达到这一点,我们对训练数据进行分区(数据分区1和数据分区2),并将模型(算子1和算子2)分配复制到设备3和设备4上生成可以并行执行的程序副本。在前向计算的过程中,设备1和设备3上的算子1副本同时开始,计算结果分别发送(Send)给设备2和设备4完成算子2副本的计算。在反向计算中,设备2和设备4同时开始计算梯度,本地梯度通过Allreduce进行平均。反向计算传递到设备1和设备3上的算子1副本结束。
|
||||
|
||||
@@ -27,10 +27,26 @@
|
||||
|
||||
- 分布式机器学习系统:[综述](https://dl.acm.org/doi/abs/10.1145/3377454)
|
||||
|
||||
- 利用集合通讯支持并行训练的实践:[Horovod](https://arxiv.org/abs/1802.05799)
|
||||
- 利用集合通信支持并行训练的实践:[Horovod](https://arxiv.org/abs/1802.05799)
|
||||
|
||||
- AllReduce的工程实现细节:[树形结构](https://developer.nvidia.com/blog/massively-scale-deep-learning-training-nccl-2-4/),[环形结构](https://github.com/baidu-research/baidu-allreduce),[二维环面结构](https://arxiv.org/abs/1811.05233),以及[CollNet算法](https://github.com/NVIDIA/nccl/issues/320)
|
||||
|
||||
- 流水线并行的实践:[gPipe](https://arxiv.org/abs/1811.06965)
|
||||
|
||||
- 在大规模数据并行下的实践:[Accurate, Large Minibatch SGD: Training ImageNet in 1 Hour](https://arxiv.org/abs/1706.02677)
|
||||
|
||||
- 模型并行在超大模型上的实践:[ZeRO](https://arxiv.org/abs/1910.02054)
|
||||
|
||||
- 最后,在讨论集合通信时,经常可以看到一些关于底层通信接口的专业术语,例如以太网,Infiniband 等。这里给出一些常见术语的具体定义:
|
||||
|
||||
* [以太网(Ethernet)](https://web.archive.org/web/20181222184046/http://www.mef.net/Assets/White_Papers/Metro-Ethernet-Services.pdf)
|
||||
* [NVLink](https://devblogs.nvidia.com/parallelforall/how-nvlink-will-enable-faster-easier-multi-gpu-computing/)
|
||||
* [AWS Elastic Fabric Adapter (EFA)](https://aws.amazon.com/cn/hpc/efa/)
|
||||
* [Infiniband](https://www.infinibandta.org/about-infiniband/)
|
||||
* [RDMA](http://reports.ias.ac.in/report/12829/understanding-the-concepts-and-mechanisms-of-rdma)
|
||||
* [RoCE](https://www.roceinitiative.org/about-overview/)
|
||||
* [IPoIB](https://www.ibm.com/docs/en/aix/7.2?topic=protocol-internet-over-infiniband-ipoib)
|
||||
|
||||
## 参考文献
|
||||
|
||||
:bibliography:`../references/distributed.bib`
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
## 可解释AI定义
|
||||
|
||||
按DARPA(美国国防部先进研究项目局)的描述,如图 :numref:`xai_concept`所示,
|
||||
按DARPA(美国国防部先进研究项目局)的描述,如 :numref:`xai_concept`所示,
|
||||
可解释AI的概念在于:区别于现有的AI系统,可解释AI系统可以解决用户面对黑盒模型时遇到的问题,使得用户知其然并知其所以然。
|
||||
|
||||
|
||||
@@ -41,13 +41,12 @@
|
||||
|
||||
- 可解释AI是确保人类可以轻松理解和信任人工智能代理做出的决策的一组方法。
|
||||
|
||||
可见,关注点在于对模型的理解、黑盒模型白盒化以及模型的可信任。
|
||||
|
||||
我们根据自身的实践经验和理解,将可解释AI定义为:一套面向机器学习(主要是深度神经网络)的技术合集,包括可视化、数据挖掘、逻辑推理、知识图谱等,目的是通过此技术合集,使深度神经网络呈现一定的可理解性,以满足相关使用者对模型及应用服务产生的信息诉求(如因果或背景信息),从而为使用者对人工智能服务建立认知层面的信任。
|
||||
|
||||
## 可解释AI算法现状介绍
|
||||
|
||||
随着可解释AI概念的提出,可解释AI越来越受到学术界及工业界的关注,下图展示了人工智能领域顶级学术会议中可解释AI关键字的趋势。为了让读者更好的对现有可解释AI算法有一个整体认知,我们这里参考 :cite:`2020tkde_li`总结归纳了可解释AI的算法类型,如图 :numref:`XAI_methods`所示。
|
||||
随着可解释AI概念的提出,可解释AI越来越受到学术界及工业界的关注,下图展示了人工智能领域顶级学术会议中可解释AI关键字的趋势。为了让读者更好的对现有可解释AI算法有一个整体认知,我们这里参考 :cite:`2020tkde_li`总结归纳了可解释AI的算法类型,如 :numref:`XAI_methods`所示。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
@@ -61,27 +60,28 @@
|
||||
|
||||
全局方法旨在提供对模型逻辑的理解以及所有预测的完整推理,基于对其特征、学习到的组件和结构的整体视图等等。有几个方向可以探索全局可解释性。为了便于理解,我们将它们分为以下三个子类:
|
||||
(i)
|
||||
模型提取——从原始黑盒模型中提取出一个可解释的模型,比如通过模型蒸馏的方式将原有黑盒模型蒸馏到可解释的决策树,从而使用决策树中的规则解释该原始模型;
|
||||
模型提取——从原始黑盒模型中提取出一个可解释的模型,比如通过模型蒸馏的方式将原有黑盒模型蒸馏到可解释的决策树 :cite:`frosst2017distilling` :cite:`zhang2019interpreting`,从而使用决策树中的规则解释该原始模型;
|
||||
(ii)
|
||||
基于特征的方法——估计特征的重要性或相关性,如图 :numref:`xai_global_feature_importance`所示,
|
||||
该类型解释可提供如“信用逾期记录是模型依赖的最重要特征”的解释,从而协助判定模型是否存在偏见
|
||||
(iii) 透明模型设计——修改或重新设计黑盒模型以提高其可解释性。
|
||||
基于特征的方法——估计特征的重要性或相关性,如 :numref:`xai_global_feature_importance`所示,
|
||||
该类型解释可提供如“信用逾期记录是模型依赖的最重要特征”的解释,从而协助判定模型是否存在偏见. 一种典型的全局特征解释方法是SHAP(其仅能针对树模型输出全局解释):cite:`lundberg2017unified`。
|
||||
(iii) 透明模型设计——修改或重新设计黑盒模型以提高其可解释性。这类方法目前也逐渐成为探索热点,近期的相关工作包括ProtoPNet :cite:`chen2019looks`, Interpretable CNN :cite:`zhang2018interpretable`, ProtoTree :cite:`nauta2021neural`等。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`xai_global_feature_importance`
|
||||
|
||||
|
||||
全局解释可以提供黑盒模型的整体认知。但由于黑盒模型的高复杂性,在实践中往往很难通过模型提取/设计得到与原模型行为相近的简单透明模型,也往往很难对整个数据集抽象出统一的特征重要性。此外,在为单个观察生成解释时,全局解释也缺乏局部保真度,因为全局重要的特征可能无法准确解释单个样例的决定。因此,局部方法成为了近些年领域内重要的研究方向。局部方法尝试为单个实例或一组实例检验模型行为的合理性。当仅关注局部行为时,复杂模型也可以变得简单,因此即使是简单的函数也有可以为局部区域提供可信度高的解释。基于获得解释的过程,局部方法可以分为两类:局部近似和基于梯度传播的方法。
|
||||
全局解释可以提供黑盒模型的整体认知。但由于黑盒模型的高复杂性,在实践中往往很难通过模型提取/设计得到与原模型行为相近的简单透明模型,也往往很难对整个数据集抽象出统一的特征重要性。此外,在为单个观察生成解释时,全局解释也缺乏局部保真度,因为全局重要的特征可能无法准确解释单个样例的决定。因此,局部方法成为了近些年领域内重要的研究方向。局部方法尝试为单个实例或一组实例检验模型行为的合理性。当仅关注局部行为时,复杂模型也可以变得简单,因此即使是简单的函数也有可以为局部区域提供可信度高的解释。基于获得解释的过程,局部方法可以分为两类:局部近似和基于传播的方法。
|
||||
|
||||
局部近似是通过在样本近邻区域模拟黑盒模型的行为生成可理解的子模型。相比于全局方法中的模型提取,局部近似仅需关注样本临近区域,因此更容易获得精确描述局部行为的子模型。如图 :numref:`xai_lime`所示,通过在关注数据点$x$附近生成$m$个数据点$(x_i^\prime, f(x_i^\prime)), for\ i=1,2, ...m$(这里$f$为黑盒模型决策函数),用线性拟合这些数据点,可以得到一个线性模型$g=\sum_i^k w_ix^i$,这里$k$表示数据的特征维度。那么线性模型中的权重$w_i$即可用于表示数据$x$中第$i$个特征对于模型$f$的重要性。
|
||||
局部近似是通过在样本近邻区域模拟黑盒模型的行为生成可理解的子模型。相比于全局方法中的模型提取,局部近似仅需关注样本临近区域,因此更容易获得精确描述局部行为的子模型。如 :numref:`xai_lime`所示,通过在关注数据点$x$附近生成$m$个数据点$(x_i^\prime, f(x_i^\prime)), for\ i=1,2, ...m$(这里$f$为黑盒模型决策函数),用线性拟合这些数据点,可以得到一个线性模型$g=\sum_i^k w_ix^i$,这里$k$表示数据的特征维度。那么线性模型中的权重$w_i$即可用于表示数据$x$中第$i$个特征对于模型$f$的重要性。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`xai_lime`
|
||||
|
||||
基于梯度传播的方法通常是用梯度传播直接定位相关特征,这些方法封装了基于反向传播的方法和基于前向传播的方法。基于反向传播的方法将输出的贡献归因于输入特征。如图 :numref:`xai_gradient_based`所示,通过梯度回传,计算模型输出对输入的梯度$\frac{d(f(x)}{dx}$ 作为模型解释。
|
||||
而基于前向传播的方法通过扰动特征后的输出差异来量化输出与特征的相关性。
|
||||
基于传播的方法通常是传播某些信息直接定位相关特征,这些方法包含了基于反向传播的方法和基于前向传播的方法。基于反向传播的方法通过梯度回传将输出的贡献归因于输入特征。如 :numref:`xai_gradient_based`所示,通过梯度回传,计算模型输出对输入的梯度$\frac{d(f(x))}{dx}$ 作为模型解释。常见的基于梯度传播的方法有基本Gradient方法,GuidedBackprop :cite:`zeiler2014visualizing`, GradCAM :cite:`selvaraju2017grad`等.
|
||||
而基于前向传播的方法通过扰动特征后, 进行前向推理的输出差异来量化输出与特征的相关性。其中,常见的几种方法有RISE :cite:`petsiuk2018rise`,ScoreCAM :cite:`wang2020score`等。
|
||||
|
||||
|
||||

|
||||
:width:`800px`
|
||||
@@ -110,28 +110,28 @@ TCAV就可以通过计算类$k$的具有正$S_{C,k,l}$’s的样本的比率来
|
||||
|
||||
$$\textbf{TCAV}_{Q_{C,k,l}}=\frac{\vert \{\mathbf{x}\in X_{k}:S_{C,k,l}(\mathbf{x})>0\}\vert}{\vert X_{k}\vert}
|
||||
\label{eq:TCAV}$$
|
||||
结合$$t$$-分布假设方法,如果$$\textbf{TCAV}_{Q_{C,k,l}}$$大于0.5,则表明概念$$C$$对类$$k$$有重大影响。
|
||||
结合$t$-分布假设方法,如果$\textbf{TCAV}_{Q_{C,k,l}}$大于0.5,则表明概念$C$对类$k$有重大影响。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`xai_tcav`
|
||||
|
||||
人类的知识可以是主观的,而KB可以是客观的。在当前研究中,KB通常被建模为知识图谱(KG)。以下以MindSpore支持的可解释推荐模型TB-Net为例,讲解如何使用知识图谱构建可解释模型。知识图谱可以捕捉实体之间丰富的语义关系。TB-Net的目的之一就是确定哪一对实体(即,物品-物品)对用户产生最重大的影响,并通过什么关系和关键节点进行关联。不同于现有的基于KG嵌入的方法(RippleNet使用KG补全方法预测用户与物品之间的路径),TB-Net提取真实路径,以达到推荐结果的高准确性和和优越的可解释性。
|
||||
人类的知识可以是主观的,而KB可以是客观的。在当前研究中,KB通常被建模为知识图谱(KG)。以下以MindSpore支持的可解释推荐模型TB-Net为例,讲解如何使用知识图谱构建可解释模型。知识图谱可以捕捉实体之间丰富的语义关系。TB-Net的目的之一就是确定哪一对实体(即,物品-物品)对用户产生最重大的影响,并通过什么关系和关键节点进行关联。不同于现有的基于KG嵌入的方法(RippleNet使用KG补全方法预测用户与物品之间的路径),TB-Net提取真实路径,以达到推荐结果的高准确性和优越的可解释性。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`tb_net`
|
||||
|
||||
TB-Net的框架如图 :numref:`tb_net`所示:其中,$i_c$代表待推荐物品,$h_n$代表历史记录中用户交互的物品,$r$和$e$代表图谱中的关系(relation)和实体(entity),它们的向量化表达拼接在一起形成关系矩阵和实体矩阵。首先,TB-Net通过$i_c$和$h_n$的相同特征值来构建用户$u$的子图谱,每一对$i_c$和$h_n$都由关系和实体所组成的路径来连接。然后,TB-Net的路径双向传导方法将物品、实体和关系向量的计算从路径的左侧和右侧分别传播到中间节点,即计算左右两个流向的向量汇集到同一中间实体的概率。该概率用于表示用户对中间实体的喜好程度,并作为解释的依据。最后,TB-Net识别子图谱中关键路径(即关键实体和关系),输出推荐结果和具有语义级别的解释。
|
||||
TB-Net的框架如 :numref:`tb_net`所示:其中,$i_c$代表待推荐物品,$h_n$代表历史记录中用户交互的物品,$r$和$e$代表图谱中的关系(relation)和实体(entity),它们的向量化表达拼接在一起形成关系矩阵和实体矩阵。首先,TB-Net通过$i_c$和$h_n$的相同特征值来构建用户$u$的子图谱,每一对$i_c$和$h_n$都由关系和实体所组成的路径来连接。然后,TB-Net的路径双向传导方法将物品、实体和关系向量的计算从路径的左侧和右侧分别传播到中间节点,即计算左右两个流向的向量汇集到同一中间实体的概率。该概率用于表示用户对中间实体的喜好程度,并作为解释的依据。最后,TB-Net识别子图谱中关键路径(即关键实体和关系),输出推荐结果和具有语义级别的解释。
|
||||
|
||||
以游戏推荐为场景,随机对一个用户推荐新的游戏,如图 :numref:`xai_kg_recommendation`所示,其中Half-Life, DOTA 2, Team Fortress 2等为游戏名称。关系属性中,game.year 代表游戏发行年份,game.genres代表游戏属性,game.developer代表游戏的开发商,game.categories代表游戏分类。属性节点中,MOBA代表多人在线战术竞技游戏,Valve代表威尔乌游戏公司,Action代表动作类,Multi-player代表多人游戏,Valve Anti-Cheat enabled代表威尔乌防作弊类,Free代表免费,Cross-Platform代表跨平台。右边的游戏是用户历史记录中玩过的游戏。而测试数据中正确推荐的游戏是“Team Fortress 2”。
|
||||
以游戏推荐为场景,随机对一个用户推荐新的游戏,如 :numref:`xai_kg_recommendation`所示,其中Half-Life, DOTA 2, Team Fortress 2等为游戏名称。关系属性中,game.year 代表游戏发行年份,game.genres代表游戏属性,game.developer代表游戏的开发商,game.categories代表游戏分类。属性节点中,MOBA代表多人在线战术竞技游戏,Valve代表威尔乌游戏公司,Action代表动作类,Multi-player代表多人游戏,Valve Anti-Cheat enabled代表威尔乌防作弊类,Free代表免费,Cross-Platform代表跨平台。右边的游戏是用户历史记录中玩过的游戏。而测试数据中正确推荐的游戏是“Team Fortress 2”。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`xai_kg_recommendation`
|
||||
|
||||
在图 :numref:`xai_kg_recommendation`中,有两个突出显示的相关概率(38.6%, 21.1%),它们是在推荐过程中模型计算的关键路径被激活的概率。红色箭头突出显示从“Team Fortress 2”到历史项目“Half-Life”之间的关键路径。它表明TB-Net能够通过各种关系连接向用户推荐物品,并找出关键路径作为解释。因此,将“Team Fortress 2”推荐给用户的解释可以翻译成固定话术:“Team Fortress 2”是游戏公司“Valve”开发的一款动作类、多人在线、射击类电子游戏。这与用户历史玩过的游戏“Half-Life”有高度关联。
|
||||
在 :numref:`xai_kg_recommendation`中,有两个突出显示的相关概率(38.6%, 21.1%),它们是在推荐过程中模型计算的关键路径被激活的概率。红色箭头突出显示从“Team Fortress 2”到历史项目“Half-Life”之间的关键路径。它表明TB-Net能够通过各种关系连接向用户推荐物品,并找出关键路径作为解释。因此,将“Team Fortress 2”推荐给用户的解释可以翻译成固定话术:“Team Fortress 2”是游戏公司“Valve”开发的一款动作类、多人在线、射击类电子游戏。这与用户历史玩过的游戏“Half-Life”有高度关联。
|
||||
|
||||
## 可解释AI系统及实践
|
||||
|
||||
@@ -163,7 +163,7 @@ pip install mindspore-xai
|
||||
|
||||
```python
|
||||
|
||||
rom mindspore_xai.explainer import GradCAM
|
||||
from mindspore_xai.explainer import GradCAM
|
||||
|
||||
# 通常指定最后一层的卷积层
|
||||
grad_cam = GradCAM(net, layer="layer4")
|
||||
@@ -179,13 +179,13 @@ saliency = grad_cam(boat_image, targets=3)
|
||||
:width:`400px`
|
||||
:label:`correct_correct`
|
||||
|
||||
上图预测标签是“bird”,解释结果给出依据的关键特征在鸟身上,说明这个分类判断依据是合理的, 可以初步判定模型为可信的。
|
||||
上图预测标签是“bicycle”,解释结果给出依据的关键特征 在车轮上,说明这个分类判断依据是合理的, 可以初步判定模型为可信的。
|
||||
|
||||

|
||||
:width:`400px`
|
||||
:label:`correct_wrong`
|
||||
|
||||
原图中,有人,在预测标签中有1个标签是“person”,这个结果是对的;但是解释的时候,高亮区域在马头的上,那么这个关键特征依据很可能是错误的, 这个模型的可靠性还需进一步验证。
|
||||
上图在预测标签中有1个标签是“person”,这个结果是对的;但是解释的时候,高亮区域在马头的上,那么这个关键特征依据很可能是错误的, 这个模型的可靠性还需进一步验证。
|
||||
|
||||

|
||||
:width:`400px`
|
||||
|
||||
29
chapter_frontend_and_ir/ai_compiler_design_principle.md
Normal file
@@ -0,0 +1,29 @@
|
||||
AI编译器设计原理
|
||||
----
|
||||
|
||||
无论是传统编译器还是AI编译器,它们的输入均为用户的编程代码,输出也机器执行的高效代码。进阶篇将用两个章节详细介绍AI编译器,里面的很多概念借用了通用编译器中的概念,如AOT(Ahead of Time提前编译)、JIT(Just in time 即时)、IR(Intermediate Representation中间表示)、PASS优化、AST(Abstract Trees)、副作用、闭包等概念,和编译器教材中对应概念的定义相同,对编译器相关概念感兴趣的读者可以翻阅相关的编译原理教材,本书会将讨论重点放在机器学习编译器相较于传统编译器的独特设计与功能上。
|
||||
|
||||
AI编译器的设计受到了主流编译器(如LLVM)的影响。为了方便理解AI编译器,首先通过 :numref:`LLVM_basic_struc`展示LLVM编译器的架构。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`LLVM_basic_struc`
|
||||
|
||||
LLVM包含了前端、IR和后端三个部分。前端将高级语言转换成IR,后端将IR转换成目标硬件上的机器指令,IR作为桥梁在前后端之间进行基于IR的各种优化。这样无论是新增硬件的支持,还是新增前端的支持,都可以尽可能地复用IR相关的部分。IR可以是单层的,也可以是多层的, LLVM IR是典型的单层IR,其前后端优化都基于相同的LLVM IR进行。
|
||||
|
||||
AI编译器一般采用多层级IR设计。 :numref:`TF_multi_ir`展示了TensorFlow利用MLIR实现多层IR设计的例子(被称为TensorFlow-MLIR)。其包含了三个层次的IR,即TensorFlow Graph IR, XLA(Accelerated Linear Algebra,加速线性代数)、HLO(High Level Operations,高级运算)以及特定硬件的LLVM IR 或者TPU IR,下面就不同的层级IR和其上的编译优化做一个简要介绍。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`TF_multi_ir`
|
||||
|
||||
计算图中涉及的编译优化一般称为图编译优化。Graph IR主要实现整图级别的优化和操作,如图优化、图切分等,比较适合静态图的执行模式。由于整图级别的IR缺少相应的硬件信息,难以进行硬件相关的优化,所以在中间层次就出现了硬件相关的通用编译优化,比如XLA、Tensor RT、MindSpore的图算融合等,它们能够针对不同的硬件进行算子融合等优化,提升不同网络在特定硬件上的执行性能。
|
||||
本书“编译器后端”章节的硬件通用优化中有一个小节专门介绍图算融合编译器的相关设计。
|
||||
最后一个层次的IR是特定硬件加速器专有的IR,一般由硬件厂商自带的编译器提供,如Ascend硬件自带的TBE编译器就是基于TVM的Halide IR生成高效的执行算子。
|
||||
|
||||
多层级IR的优势是IR表达上更加地灵活,可以在不同层级的IR上进行合适的PASS优化,更加便捷和高效。
|
||||
但是多层级IR也存在一些劣势。首先,多层级IR需要进行不同IR之间的转换,而IR转换要做到完全兼容是非常困难的,工程工作量很大,还可能带来信息的损失。上一层IR优化掉某些信息之后,下一层需要考虑其影响,因此IR转换对优化执行的顺序有着更强的约束。其次,多层级IR有些优化既可以在上一层IR进行,也可以在下一层IR进行,让框架开发者很难选择。最后,不同层级IR定义的算子粒度大小不同,可能会给精度带来一定的影响。为了解决这一问题,机器学习框架如MindSpore采用统一的IR设计(MindIR)。 :numref:`MS_Compiler`展示了MindSpore的AI编译器内部的运行流程。其中,编译器前端主要指图编译和硬件无关的优化,编译器后端主要指硬件相关优化、算子选择等。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`MS_Compiler`
|
||||
@@ -1,15 +1,12 @@
|
||||
# 编译器前端
|
||||
在上一章节中,我们详细讨论了计算图的生成和调度,在进阶部分的介绍中简单介绍了深度学习编译器的作用。定义深度学习模型、计算图使用系统为用户提供的高级编程API,我们将用户使用高级编程API编写的程序称为源程序,将与硬件相关的程序称为目标程序,深度学习编译器需要理解输入的源程序并将其映射到目标机。为了实现这两项任务,编译器的设计被分解为两个主要部分:前端和后端。传统编译器的前端专注于理解源程序,后端则专注于将功能映射到目标机。为了将前后端相连接,我们需要一种结构来表示转换后的源代码,这就是中间表示(Intermediate
|
||||
Representation, IR)。
|
||||
# AI编译器和前端技术
|
||||
编译器作为计算机系统的核心组件,在机器学习框架设计中也扮演着重要的角色,并衍生出了一个专门的编译器种类:AI编译器。AI编译器既要对上承接模型算法的变化,满足算法开发者不断探索的研究诉求,又要对下在最终的二进制输出上满足多样性硬件的诉求,满足不同部署环境的资源要求。既要满足框架的通用普适性,又要满足易用性的灵活性要求,还要满足性能的不断优化诉求。AI编译器保证了机器学习算法的便捷表达和高效执行,日渐成为了机器学习框架设计的重要一环。
|
||||
|
||||
:numref:`compiler_frontend_structure`展示了机器学习编译器的前端的流程。其中,对源程序的解析过程与传统编译器是大致相同的,本章节不对这部分进行更细致的讨论。机器学习框架的编译器前端的独特之处主要在于自动微分功能的支持。为了满足自动微分功能带来的新需求,机器学习框架需要在传统中间表示的基础上设计新的中间表示结构。因此,本章节的介绍重点会放在中间表示以及自动微分这两个部分。最后,我们会简要探讨类型系统,静态分析和前端优化等编译器基础概念。
|
||||
|
||||

|
||||
:width:`1000px`
|
||||
:label:`compiler_frontend_structure`
|
||||
本章将先从AI编译器的整体框架入手, 介绍AI编译器的基础结构。接下来,本章会详细讨论编译器前端的设计,并将重点放在中间表示以及自动微分两个部分。有关AI编译器后端的详细知识, 将会在后续的第五章进行讨论。
|
||||
|
||||
本章的学习目标包括:
|
||||
|
||||
- 理解AI编译器的基本设计原理
|
||||
|
||||
- 理解中间表示的基础概念,特点和实现方法
|
||||
|
||||
- 理解自动微分的基础概念,特点和实现方法
|
||||
@@ -22,6 +19,7 @@ Representation, IR)。
|
||||
```toc
|
||||
:maxdepth: 2
|
||||
|
||||
ai_compiler_design_principle
|
||||
overview_of_frontend
|
||||
intermediate_representation
|
||||
ad
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
概述
|
||||
AI编译器前端技术概述
|
||||
----
|
||||
|
||||
本章节将讨论重点放在中间表示与自动微分章节。自动微分作为机器学习框架的编译器的独有功能,其实现需要满足其需求的中间表示的支持。在讨论完这两部分后,我们会简要介绍类型系统,静态分析和前端编译优化等编译器基础概念。
|
||||
:numref:`compiler_frontend_struc`展示了机器学习编译器前端的基础结构。其中,对源程序的解析过程与传统编译器是大致相同的,本章节不对这部分进行更细致的讨论。机器学习框架的编译器前端的独特之处主要在于对自动微分功能的支持。为了满足自动微分功能带来的新需求,机器学习框架需要在传统中间表示的基础上设计新的中间表示结构。因此,本章节的介绍重点会放在中间表示和自动微分这两个部分,随后会简要探讨类型系统、静态分析和前端优化等编译器的基础概念。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`compiler_frontend_struc`
|
||||
|
||||
### 中间表示
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ Damas)对HM类型推导方法进行了详尽的分析和证明 :cite:`1982Prin
|
||||
|
||||
类型推导:在抽象释义的基础上,编译器推断出程序中变量或表达式的抽象类型,方便后续利用类型信息进行编译优化。
|
||||
|
||||
泛型特化:泛型特化的前提是编译器在编译期间可以进行类型推导,提供类型的上下文。在编译期间,编译器通过类型推导确定调用函数函数时的类型,然后,编译器会通过泛型特化,进行类型取代,为每个类型生成一个对应的函数方法。
|
||||
泛型特化:泛型特化的前提是编译器在编译期间可以进行类型推导,提供类型的上下文。在编译期间,编译器通过类型推导确定调用函数时的类型,然后,编译器会通过泛型特化,进行类型取代,为每个类型生成一个对应的函数方法。
|
||||
|
||||
接下来以MindSpore框架为例,简要介绍一下静态分析模块的具体实现。MindSpore采用抽象释义的方法,对抽象值做不确定的抽象语义的解释执行,函数图中每个节点的抽象值是所期望得到的程序静态信息。基本的抽象释义方法流程可以理解为,从MindIR的顶层函数图入口开始解释执行,将函数图中所有节点进行拓扑排序,根据节点的语义递归推导各节点的抽象值。当遇到函数子图时,递归进入函数子图进行解释执行,最后返回顶层函数输出节点的抽象值。根据抽象释义方法流程,MindSpore的静态分析模块主要分为抽象域模块、缓存模块、语义推导模块和控制流处理模块。
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
## 适用读者
|
||||
|
||||
本书由浅入深地讨论机器学习系统的设计原理和实现经验。其读者包括:
|
||||
|
||||
- **学生:**
|
||||
本书将帮助学生获得大量机器学习系统的设计原则和一手实践经验。从而帮助其更全面理解机器学习算法的实践挑战和理论优劣。
|
||||
|
||||
- **科研人员:**
|
||||
本书将帮助科研人员学习到机器学习落地实践中遇到的种种挑战,引导设计出能解决大规模实际问题的下一代机器学习算法。
|
||||
|
||||
- **开发人员:**
|
||||
本书将帮助开发人员深刻理解机器学习系统的内部架构,从而帮助其优化系统性能,调试问题,并且根据业务需求对机器学习系统进行定制。
|
||||
18
chapter_introduction/applications.md
Normal file
@@ -0,0 +1,18 @@
|
||||
## 机器学习应用
|
||||
|
||||
通俗来讲,机器学习是指从数据中学习出有用知识的技术。以学习模式分类,机器学习可以分为监督学习(Supervised Learning)、无监督学习(Unsupervised Learning)和强化学习(Reinforcement Learning)等。
|
||||
|
||||
* 监督学习是已知输入和输出的对应关系下的机器学习场景。比如给定输入图像和它对应的离散标签。
|
||||
* 无监督学习是只有输入数据但不知道输出标签下的机器学习场景。比如给定一堆猫和狗的图像,自主学会猫和狗的分类,这种无监督分类也称为聚类(Clustering)。
|
||||
* 强化学习则是给定一个学习环境和任务目标,算法自主地去不断改进自己以实现任务目标。比如 AlphaGo围棋就是用强化学习实现的,给定的环境是围棋的规则,而目标则是胜利得分。
|
||||
|
||||
从应用领域上划分,机器学习应用包括计算机视觉、自然语言处理和智能决策等。
|
||||
狭义上来讲,基于图像的应用都可归为计算机视觉方面的应用,典型的应用有人脸识别、物体识别、目标跟踪、人体姿态估计、图像理解等。
|
||||
计算机视觉方法广泛应用于自动驾驶、智慧城市、智慧安防等领域。
|
||||
|
||||
自然语言处理涉及文本或者语音方面的应用,典型的应用包括语言翻译、文本转语音、语音转文本、文本理解、图片风格变换等。
|
||||
计算机视觉和自然语言处理有很多交集,如图像的文本描述生成、基于文本的图像生成、基于文本的图像处理等应用都同时涉及语言和图像两种数据类型。
|
||||
|
||||
智能决策的应用往往通过结合计算机视觉、自然语言处理、强化学习、控制论等技术手段,实现决策类任务。智能决策方法广泛用于机器人、自动驾驶、游戏、推荐系统、智能工厂、智能电网等领域。
|
||||
|
||||
不同的机器学习应用底层会应用不同的机器学习算法,如支持向量机(Support Vector Machine,SVM)、逻辑回归(Logistic Regression)、朴素贝叶斯(Naive Bayes)算法等。近年来,得益于海量数据的普及,神经网络(Neural Networks)算法的进步和硬件加速器的成熟,深度学习(Deep Learning)开始蓬勃发展。虽然机器学习算法很多,但无论是经典算法还是深度学习算法的计算往往以向量和矩阵运算为主体,因此本书主要通过神经网络为例子展开机器学习系统的介绍。
|
||||
31
chapter_introduction/architecture.md
Normal file
@@ -0,0 +1,31 @@
|
||||
## 机器学习框架的基本组成原理
|
||||
|
||||
一个完整的机器学习框架一般具有如图 :numref:`framework-architecture` 所示的基本架构。
|
||||
|
||||

|
||||
:width:`600px`
|
||||
:label:`framework-architecture`
|
||||
|
||||
- **编程接口:**
|
||||
考虑到机器学习开发人员背景的多样性,机器学习框架首先需要提供以高层次编程语言(如Python)为主的编程接口。同时,机器学习框架为了优化运行性能,需要支持以低层次编程语言(如C和C++)为主的系统实现,从而实现操作系统(如线程管理和网络通讯等)和各类型硬件加速器的高效使用。
|
||||
|
||||
- **计算图:**
|
||||
利用不同编程接口实现的机器学习程序需要共享一个运行后端。实现这一后端的关键技术是计算图技术。计算图定义了用户的机器学习程序,其包含大量表达计算操作的算子节点(Operator Node),以及表达算子之间计算依赖的边(Edge)。
|
||||
|
||||
- **编译器前端:**
|
||||
机器学习框架往往具有AI编译器来构建计算图,并将计算图转换为硬件可以执行的程序。这个编译器首先会利用一系列编译器前端技术实现对程序的分析和优化。编译器前端的关键功能包括实现中间表示、自动微分、类型推导和静态分析等。
|
||||
|
||||
- **编译器后端和运行时:**
|
||||
完成计算图的分析和优化后,机器学习框架进一步利用编译器后端和运行时实现针对不同底层硬件的优化。常见的优化技术包括分析硬件的L2/L3缓存大小和指令流水线长度,优化算子的选择或者调度顺序。
|
||||
|
||||
- **异构处理器:**
|
||||
机器学习应用的执行由中央处理器(Central Processing Unit,CPU)和硬件加速器(如英伟达GPU、华为Ascend和谷歌TPU)共同完成。其中,非矩阵操作(如复杂的数据预处理和计算图的调度执行)由中央处理器完成。矩阵操作和部分频繁使用的机器学习算子(如Transformer算子和Convolution算子)由硬件加速器完成。
|
||||
|
||||
- **数据处理:**
|
||||
机器学习应用需要对原始数据进行复杂预处理,同时也需要管理大量的训练数据集、验证数据集和测试数据集。这一系列以数据为核心的操作由数据处理模块(例如TensorFlow的tf.data和PyTorch的DataLoader)完成。
|
||||
|
||||
- **模型部署:**
|
||||
在完成模型训练后,机器学习框架下一个需要支持的关键功能是模型部署。为了确保模型可以在内存有限的硬件上执行,会使用模型转换、量化、蒸馏等模型压缩技术。同时,也需要实现针对推理硬件平台(例如英伟达Orin)的模型算子优化。最后,为了保证模型的安全(如拒绝未经授权的用户读取),还会对模型进行混淆设计。
|
||||
|
||||
- **分布式训练:**
|
||||
机器学习模型的训练往往需要分布式的计算节点并行完成。其中,常见的并行训练方法包括数据并行、模型并行、混合并行和流水线并行。这些并行训练方法通常由远端程序调用(Remote Procedure Call, RPC)、集合通信(Collective Communication)或者参数服务器(Parameter Server)实现。
|
||||
@@ -1,35 +0,0 @@
|
||||
## 基本组成
|
||||
|
||||
一个完整的机器学习系统往往具有如 :numref:`framework_architecture`所示的基本架构。
|
||||
|
||||

|
||||
:width:`600px`
|
||||
:label:`framework_architecture`
|
||||
|
||||
- **编程接口:** 为了支持广泛的开发者,机器学习框架的编程接口不仅需要高层次简易编程(例如,Python,Julia和Java),同时也需要支持低层次高性能编程(利用C和C++函数调用操作系统和硬件加速器)。
|
||||
|
||||
- **计算图:**
|
||||
利用不同编程接口实现的机器学习程序需要共享一个运行后端。实现这一后端的关键技术是:应用无关的计算图。计算图包含计算节点,节点之间的边表达计算依赖。计算图可以被同步和异步执行。
|
||||
|
||||
- **编译器前端:**
|
||||
给定一个计算图,机器学习框架会对计算图做一系列优化。和硬件无关的优化由编译器前端实现。编译器前端实现包括:中间表达,自动微分,类型推导和静态分析等等。
|
||||
|
||||
- **编译器后端和运行时:**
|
||||
机器学习框架利用编译器后端对计算图可以进一步针对硬件的特性(例如说,L2/L3大小,指令流水线长度)进行性能优化。最终优化后的计算图通过运行时执行在通用处理器(CPU)或者是硬件加速器之上。运行时需要实现算子选择和内存分配等技术。
|
||||
|
||||
- **硬件加速器:**
|
||||
现代硬件加速器提供了丰富的编程接口。在本书中,我们将会介绍硬件加速器的基本组成原理和编程接口。我们同时会给出一个硬件加速器使用案例来从0到1讲述如何高效使用加速器。
|
||||
|
||||
- **数据处理:**
|
||||
机器学习系统拥有专门的数据处理框架来实现数据读取,存储和预处理的功能由数据处理模块(例如,TensorFlow的tf.data和PyTorch的DataLoader)。这一框架需要针对机器学习应用实现易用性,保序性和高效性等设计目标。
|
||||
|
||||
- **模型部署:**
|
||||
在模型完成训练后,下一个常用的系统功能是:模型部署。为了确保模型可以在内存有限的硬件上执行,我们会使用模型转换,量化,蒸馏等模型压缩技术。同时,我们也需要实现针对推理硬件平台(例如,英伟达Jetson)的模型算子优化。最后,为了保证模型的安全(不被黑客窃取),实践者还会对模型进行混淆设计。
|
||||
|
||||
- **分布式训练:**
|
||||
分布式训练日渐成为一个机器学习框架的核心组件。本书将介绍常见的分布式训练方法(数据并行,模型并行,混合并行和流水线并行)。同时我们会深入介绍这些方法的高效系统实现(包括集合通讯库和参数服务器)。
|
||||
|
||||
- **拓展模块:**
|
||||
机器学习系统的广泛部署使得许多的扩展模块陆续出现。本书将会介绍得到大量实践部署的拓展模块:深度学习推荐系统,联邦学习系统,强化学习系统,可解释性AI系统和机器人系统。
|
||||
|
||||
机器学习算法相关的理论知识是本书的预备知识,本书不做深入讨论。基础的机器学习理论知识可以在附录中找到。
|
||||
37
chapter_introduction/design.md
Normal file
@@ -0,0 +1,37 @@
|
||||
## 机器学习框架的设计目标
|
||||
|
||||
为了支持在不同应用中高效开发机器学习算法,人们设计和实现了**机器学习框架**(如TensorFlow、PyTorch、MindSpore等)。广义来说,这些框架实现了以下共性的设计目标:
|
||||
|
||||
- **神经网络编程:**
|
||||
深度学习的巨大成功使得神经网络成为了许多机器学习应用的核心。根据应用的需求,人们需要定制不同的神经网络,如卷积神经网络(Convolutional Neural Networks)和自注意力神经网络(Self-Attention Neural Networks)等。这些神经网络需要一个共同的系统软件进行开发、训练和部署。
|
||||
|
||||
- **自动微分:**
|
||||
训练神经网络会具有模型参数。这些参数需要通过持续计算梯度(Gradients)迭代改进。梯度的计算往往需要结合训练数据、数据标注和损失函数(Loss
|
||||
Function)。考虑到大多数开发人员并不具备手工计算梯度的知识,机器学习框架需要根据开发人员给出的神经网络程序,全自动地计算梯度。这一过程被称之为自动微分。
|
||||
|
||||
- **数据管理和处理:**
|
||||
机器学习的核心是数据。这些数据包括训练、验证、测试数据集和模型参数。因此,需要系统本身支持数据读取、存储和预处理(例如数据增强和数据清洗)。
|
||||
|
||||
- **模型训练和部署:**
|
||||
为了让机器学习模型达到最佳的性能,需要使用优化方法(例如Mini-Batch SGD)来通过多步迭代反复计算梯度,这一过程称之为训练。训练完成后,需要将训练好的模型部署到推理设备。
|
||||
|
||||
- **硬件加速器:**
|
||||
神经网络的相关计算往往通过矩阵计算实现。这一类计算可以被硬件加速器(例如,通用图形处理器-GPU)加速。因此,机器学习系统需要高效利用多种硬件加速器。
|
||||
|
||||
- **分布式执行:**
|
||||
随着训练数据量和神经网络参数量的上升,机器学习系统的内存用量远远超过了单个机器可以提供的内存。因此,机器学习框架需要天然具备分布式执行的能力。
|
||||
|
||||
在设计机器学习框架之初,开发者曾尝试通过传统的**神经网络开发库**(如Theano和Caffe)、以及**数据处理框架**(如Apache Spark和Google Pregel)等方式达到以上设计目标。可是他们发现,
|
||||
神经网络库虽然提供了神经网络开发、自动微分和硬件加速器的支持,但缺乏管理和处理大型数据集、模型部署和分布式执行的能力,无法满足当今产品级机器学习应用的开发任务。
|
||||
另一方面,虽然并行数据计算框架具有成熟的分布式运行和数据管理能力,但缺乏对神经网络、自动微分和加速器的支持,并不适合开发以神经网络为核心的机器学习应用。
|
||||
|
||||
考虑到上述已有软件系统的种种不足,许多公司开发人员和大学研究人员开始从头设计和实现针对机器学习的软件框架。在短短数年间,机器学习框架如雨后春笋般出现(较为知名的例子包括TensorFlow、PyTorch、MindSpore、MXNet、PaddlePaddle、OneFlow、CNTK等),极大推进了人工智能在上下游产业中的发展。表 :numref:`comparison_of_ml_frameworks` 总结了机器学习框架和相关系统的区别。
|
||||
|
||||
:机器学习框架和相关系统的区别
|
||||
|
||||
| 方式 | 神经网络 | 自动微分 | 数据管理 | 训练和部署 | 硬件加速器 | 分布式执行 |
|
||||
|:-: |:-:| :-: |:-:|:-: |:-:|:-:|
|
||||
| 神经网络库 | 是 | 是 | 否 | 否 | 是 | 否 |
|
||||
| 大数据框架 | 否 | 否 | 是 | 否 | 否 | 是 |
|
||||
| 机器学习框架 | 是 | 是 | 是 | 是 | 是 | 是 |
|
||||
:label:`comparison_of_ml_frameworks`
|
||||
36
chapter_introduction/ecosystem.md
Normal file
@@ -0,0 +1,36 @@
|
||||
## 机器学习系统生态
|
||||
|
||||
以机器学习框架为核心,人工智能社区创造出了庞大的**机器学习系统**生态。广义来说,机器学习系统是指实现和支持机器学习应用的各类型软硬件系统的泛称。图 :numref:`system-ecosystem` 总结了各类型的机器学习系统。
|
||||
|
||||

|
||||
:width:`600px`
|
||||
:label:`system-ecosystem`
|
||||
|
||||
- **联邦学习:**
|
||||
随着用户隐私保护和数据保护法的出现,许多机器学习应用无法直接接触用户数据完成模型训练。因此这一类应用需要通过机器学习框架实现联邦学习(Federated Learning)。
|
||||
|
||||
- **推荐系统:**
|
||||
将机器学习(特别是深度学习)引入推荐系统在过去数年取得了巨大的成功。相比于传统基于规则的推荐系统,深度学习推荐系统能够有效分析用户的海量特征数据,从而实现在推荐准确度和推荐时效性上的巨大提升。
|
||||
|
||||
- **强化学习:**
|
||||
强化学习具有数据收集和模型训练方法的特殊性。因此,需要基于机器学习框架进一步开发专用的强化学习系统。
|
||||
|
||||
- **可解释AI:**
|
||||
随着机器学习在金融、医疗和政府治理等关键领域的推广,基于机器学习框架进一步开发的可解释性AI系统正得到日益增长的重视。
|
||||
|
||||
- **机器人:**
|
||||
机器人是另一个开始广泛使用机器学习框架的领域。相比于传统的机器人视觉方法,机器学习方法在特征自动提取、目标识别、路径规划等多个机器人任务中获得了巨大成功。
|
||||
|
||||
- **图学习:**
|
||||
图(Graph)是最广泛使用的数据结构之一。许多互联网数据(如社交网络、产品关系图)都由图来表达。机器学习算法已经被证明是行之有效的分析大型图数据的方法。这种针对图数据的机器学习系统被称之为图学习系统(Graph Learning System)。
|
||||
|
||||
- **科学计算:**
|
||||
科学计算覆盖许多传统领域(如电磁仿真、图形学、天气预报等),这些领域中的许多大规模问题都可以有效利用机器学习方法求解。因此,针对科学计算开发机器学习系统变得日益普遍。
|
||||
|
||||
- **机器学习集群调度:**
|
||||
机器学习集群一般由异构处理器、异构网络甚至异构存储设备构成。同时,机器学习集群中的计算任务往往具有共同的执行特点(如基于集合通信算子AllReduce迭代进行)。因此,针对异构设备和任务特点,机器学习集群往往具有特定的调度方法设计。
|
||||
|
||||
- **量子计算:**
|
||||
量子计算机一般通过混合架构实现。其中,量子计算由量子计算机完成,而量子仿真由传统计算机完成。由于量子仿真往往涉及到大量矩阵计算,许多量子仿真系统(如TensorFlow Quantum和MindQuantum)都基于机器学习框架实现。
|
||||
|
||||
本书受限于篇幅,将不会对所有机器学习系统进行深入讲解。目前,本书会从系统设计者的角度出发,对应用在联邦学习、推荐系统、强化学习、可解释AI和机器人中的相关核心系统进行讲解。
|
||||
@@ -1,14 +1,13 @@
|
||||
# 导论
|
||||
|
||||
本章第一部分首先介绍机器学习的概貌,以及其在系统角度的共性问题。
|
||||
本章第二部分介绍系统设计的需求和目标,让读者从宏观上来了解系统需要满足的内容,对系统设计的主要用例、设计规格有一个清晰的了解。
|
||||
本章最后部分介绍机器学习系统的组成原理,让读者对系统整体的实现有一个初步的、宏观的理解。
|
||||
本章将会介绍机器学习应用,梳理出机器学习系统的设计目标,总结出机器学习系统的基本组成原理,让读者对机器学习系统有自顶而下的全面了解。
|
||||
|
||||
```toc
|
||||
:maxdepth: 2
|
||||
|
||||
machine_learning_applications
|
||||
requirements_for_machine_learning_systems
|
||||
components_of_machine_learning_systems
|
||||
applicable_readers
|
||||
applications
|
||||
design
|
||||
architecture
|
||||
ecosystem
|
||||
readers
|
||||
```
|
||||
@@ -1,23 +0,0 @@
|
||||
## 机器学习应用
|
||||
|
||||
通俗来讲,机器学习是指从数据中学习出有用知识的技术。从学习模式来说,机器学习可以分为监督学习(Supervised
|
||||
Learning)、无监督学习(Unsupervised Learning)、强化学习(Reinforcement
|
||||
Learning)等:
|
||||
|
||||
* 监督学习是已知输入输出对应关系情况下的学习,比如: 给定输入图像和它对应的内容标签,学习图像分类(Classification)。
|
||||
* 无监督学习是只有输入数据但不知道输出标签情况下的学习,比如:给定一堆猫和狗的图像,自主学会猫和狗的分类,这种无监督分类也称为聚类(Clustering)。
|
||||
* 强化学习则是给定一个学习环境和任务目标,算法自主地去不断尝试、改进自己、以实现任务目标 ,比如: AlphaGo围棋就是用强化学习实现的,给定的环境是围棋的规则、而目标则是胜利得分。
|
||||
|
||||
从应用领域上划分,主要包括计算机视觉、自然语言处理和智能决策这三大部分,而且这三大部分之间也有交集。
|
||||
狭义上来讲基于图像的应用都可归为计算机视觉方面的应用,典型的应用有人脸识别、物体识别、目标跟踪、人体姿态估计、以及图像的理解、修复、分割与检测等等。
|
||||
计算机视觉方法广泛应用于自动驾驶、智慧城市、智慧安防等领域。
|
||||
自然语言处理涉及文本或者语音方面的应用,典型的应用包括语言翻译、文本转语音、语音转文本、以及文本理解、分类、风格变换与纠错等等。
|
||||
计算机视觉和自然语言处理有很多交集,例如图像的文本描述生成、基于文本的图像生成、基于文本的图像处理等应用都同时涉及到了语言和图像两种数据类型。
|
||||
智能决策方面,往往通过结合计算机视觉、自然语言处理、强化学习、控制论等技术手段,实现决策类任务,广泛用于机器人、自动驾驶、游戏、推荐系统、智能工厂、智能电网等领域。
|
||||
|
||||
经典的机器学习算法有支持向量机(Support Vector
|
||||
Machine,SVM)、逻辑回归(Logistic Regression)、朴素贝叶斯(Naive
|
||||
Bayes)
|
||||
等方法。然而得力于大数据互联网和计算机性能的提升,以深度学习(Deep
|
||||
Learning)为代表的方法得到了广泛的研究和应用。
|
||||
虽然机器学习算法很多,但无论是经典算法还是深度学习算法的计算往往以向量、矩阵运算为主体的,因此本书主要通过深度神经网络为例子展开机器学习系统的介绍。下面我们来快速了解一下机器学习系统的设计需求、实现目标以及组成原理。
|
||||
14
chapter_introduction/readers.md
Normal file
@@ -0,0 +1,14 @@
|
||||
## 图书结构和读者
|
||||
|
||||
本书由浅入深地讨论机器学习系统的设计原理和实现经验。其中,**基础篇**覆盖编程接口设计和计算图等框架使用者需要了解的核心概念。**进阶篇**覆盖编译器前端、编译器后端、数据管理等框架设计者需要了解的核心概念。最后,**拓展篇**覆盖重要的机器学习系统类别(如联邦学习和推荐系统等),从而为各领域的机器学习爱好者提供统一的框架使用和设计入门教学。
|
||||
|
||||
本书的常见读者包括:
|
||||
|
||||
- **学生:**
|
||||
本书将帮助学生获得大量机器学习系统的设计原则和一手实践经验,从而帮助其更全面理解机器学习算法的实践挑战和理论优劣。
|
||||
|
||||
- **科研人员:**
|
||||
本书将帮助科研人员解决机器学习落地实践中面临的种种挑战,引导设计出能解决大规模实际问题的下一代机器学习算法。
|
||||
|
||||
- **开发人员:**
|
||||
本书将帮助开发人员深刻理解机器学习系统的内部架构,从而帮助其开发应用新功能、调试系统性能,并且根据业务需求对机器学习系统进行定制。
|
||||
@@ -1,44 +0,0 @@
|
||||
## 设计目标
|
||||

|
||||
:width:`600px`
|
||||
:label:`framework_position`
|
||||
|
||||
开发者需要设计和实现机器学习系统来满足以下目标(如 :numref:`framework_position`所示):
|
||||
|
||||
- **支持多种神经网络:**
|
||||
深度学习的巨大成功使得神经网络成为了机器学习应用的核心。不同应用需要不同的神经网络,例如,卷积神经网络(Convolutional
|
||||
Neural Networks),图神经网络(Graph Neural
|
||||
Networks),自注意力神经网络(Self-Attention Neural
|
||||
Networks)等。这些神经网络需要一个共同的系统软件来进行开发和运行。
|
||||
|
||||
- **支持自动微分:**
|
||||
为了训练神经网络,我们需要利用数据、标注(Label)和目标损失函数(Loss
|
||||
Function)来计算梯度(Gradients)。因此,机器学习通系统需要有一个通用的方法来**自动化**计算梯度(这一过程被称之为自动微分)。
|
||||
|
||||
- **支持数据管理和处理:**
|
||||
机器学习的核心是数据。这些数据包括训练、评估、测试数据集和模型参数。因此,我们需要系统本身支持数据读取、存储和预处理(例如,数据增强和数据清洗)。
|
||||
|
||||
- **支持模型的训练和部署:**
|
||||
为了让机器学习模型达到最佳的性能,人们需要使用优化方法(例如,Mini-Batch
|
||||
SGD)来通过多步迭代反复计算梯度(这一过程称之为训练)。训练完成后,系统需要将训练好的模型部署推理设备。
|
||||
|
||||
- **高效使用硬件加速器:**
|
||||
神经网络的相关计算往往通过矩阵计算实现。这一类计算可以被硬件加速器(例如,通用图形处理器-GPU)加速。因此,机器学习系统需要高效利用多种硬件加速器。
|
||||
|
||||
- **分布式计算:**
|
||||
随着训练数据量和神经网络参数量的上升,机器学习系统的内存用量远远超过了单个机器可以提供的内存。因此,机器学习框架需要天然具备分布式执行的能力。
|
||||
|
||||
在设计机器学习系统之初,开发者曾尝试拓展**神经网络开发库**(如Theano和Caffe)和**大数据计算框架**(如Apache
|
||||
Spark和Google
|
||||
Pregel)来达到以上目标。可是他们发现(如 :numref:`comparison_of_ml_frameworks`所示),
|
||||
神经网络库虽然提供了神经网络开发、自动微分和硬件加速器的支持,但是其缺乏管理和处理大型数据集、模型部署和分布式执行的能力,无法满足产品级机器学习应用的开发任务。
|
||||
另一方面,虽然大数据计算框架具有成熟的分布式执行和数据管理能力,但是其缺乏对神经网络、自动微分和加速器的支持,使得其并不适合开发以神经网络为核心的机器学习应用。因此,业界设计出了包括MindSpore、PaddlePaddle、TensorFlow,PyTorch等一系列新型机器学习系统(框架)。
|
||||
|
||||
:机器学习框架和相关系统的比较
|
||||
|
||||
| | 神经网络 | 自动微分 | 数据管理 | 训练和部署 | 加速器 | 分布式 |
|
||||
|:-: |:-:| :-: |:-:|:-: |:-:|:-:|
|
||||
| 神经网络库 | 是 | 是 | 否 | 否 | 是 | 否 |
|
||||
| 大数据框架 | 否 | 否 | 是 | 否 | 否 | 是 |
|
||||
| 机器学习框架 | 是 | 是 | 是 | 是 | 是 | 是 |
|
||||
:label:`comparison_of_ml_frameworks`
|
||||
@@ -1,8 +1,8 @@
|
||||
# 模型部署
|
||||
|
||||
在前面的章节中,我们讲述了机器学习模型训练系统的基本组成,这一章节我们将讲述模型部署的相关知识。模型部署是将训练好的模型部署到运行环境中进行推理的过程,模型部署的过程中需要解决训练模型到推理模型的转换,硬件资源对模型的限制,模型推理的时延、功耗、内存占用等指标对整个系统的影响以及模型的安全等一系列的问题。
|
||||
前面的章节讲述了机器学习模型训练系统的基本组成,这一章节将重点讲述模型部署的相关知识。模型部署是将训练好的模型部署到运行环境中进行推理的过程,模型部署的过程中需要解决训练模型到推理模型的转换,硬件资源对模型的限制,模型推理的时延、功耗、内存占用等指标对整个系统的影响以及模型的安全等一系列的问题。
|
||||
|
||||
本章将主要介绍机器学习模型部署的主要流程,包括训练模型到推理模型的转换、适应硬件限制的模型压缩技术、模型推理及性能优化以及模型的安全保护,最后我们会给出一个模型部署端到端的实践用例。
|
||||
本章将主要介绍机器学习模型部署的主要流程,包括训练模型到推理模型的转换、适应硬件限制的模型压缩技术、模型推理及性能优化以及模型的安全保护。
|
||||
|
||||
本章的学习目标包括:
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
## 模型压缩
|
||||
:label:`ch08-sec-model_compression`
|
||||
|
||||
在上一小节中,我们简要介绍了模型转换的目的,并重点讲述了模型部署时的一些常用的模型优化手段。考虑到不同场景的硬件对模型的要求不同,比如部署在手机上,对于模型的大小比较敏感,一般在兆级别。因此,对于一些较大的模型,我们往往需要通过一些模型压缩的技术,使其能满足不同计算硬件的要求。
|
||||
上一小节简要介绍了模型转换的目的,并重点讲述了模型部署时的一些常用的模型优化手段。考虑到不同场景的硬件对模型的要求不同,比如部署在手机上,对于模型的大小比较敏感,一般在兆级别。因此,对于一些较大的模型,往往需要通过一些模型压缩的技术,使其能满足不同计算硬件的要求。
|
||||
|
||||
### 量化
|
||||
|
||||
模型量化是指以较低的推理精度损失将连续取值(通常为FP32或者大量可能的离散值)的浮点型权重或者通过各个算子的数据定点近似(通常为INT8)为有限多个离散值的过程,如 :numref:`ch08-fig-quant_minmax`,T是量化前的数据范围。通过以更少的位数表示浮点数据,模型量化可以减少模型尺寸,进而减少在推理时的内存消耗,并且在一些低精度运算较快的处理器上可以增加推理速度。
|
||||
模型量化是指以较低的推理精度损失将连续取值(通常为float32或者大量可能的离散值)的浮点型权重近似为有限多个离散值(通常为int8)的过程,如图 :numref:`ch08-fig-quant_minmax`,T是量化前的数据范围。通过以更少的位数表示浮点数据,模型量化可以减少模型尺寸,进而减少在推理时的内存消耗,并且在一些低精度运算较快的处理器上可以增加推理速度。
|
||||
|
||||

|
||||
:width:`300px`
|
||||
@@ -13,13 +13,13 @@
|
||||
|
||||
计算机中不同数据类型的占用比特数及其表示的数据范围各不相同。可以根据实际业务需求将原模型量化成不同比特数的模型,一般深度神经网络的模型用单精度浮点数表示,如果能用有符号整数来近似原模型的参数,那么被量化的权重参数存储大小就可以降到原先的四分之一,用来量化的比特数越少,量化后的模型压缩率越高。工业界目前最常用的量化位数是8比特,低于8比特的量化被称为低比特量化。1比特是模型压缩的极限,可以将模型压缩为1/32,在推理时也可以使用高效的XNOR和BitCount位运算来提升推理速度。
|
||||
|
||||
另外,根据量化数据表示的原始数据范围是否均匀,还可以将量化方法分为线性量化和非线性量化。实际的深度神经网络的权重和激活值通常是不均匀的,因此理论上使用非线性量化能够达到更高的精度,但在实际推理中非线性量化的计算复杂度较高,通常使用线性量化。下面着重介绍线性量化的原理。
|
||||
另外,根据量化数据表示的原始数据范围是否均匀,还可以将量化方法分为线性量化和非线性量化。实际的深度神经网络的权重和激活值通常是不均匀的,因此理论上使用非线性量化导致的精度损失更小,但在实际推理中非线性量化的计算复杂度较高,通常使用线性量化。下面着重介绍线性量化的原理。
|
||||
|
||||
假设r表示量化前的浮点数,量化后的整数q可以表示为:
|
||||
|
||||
$$q=clip(round(\frac{r}{s}+z),q_{min},q_{max})$$
|
||||
|
||||
$round(\cdot)$和$clip(\cdot)$分别表示取整和截断操作,$q_{min}$和$q_{max}$是量化后的最小值和最大值。$s$是数据量化的间隔,$z$是表示数据偏移的偏置,$z$为0的量化被称为对称(Symmetric)量化,不为0的量化称为非对称(Asymmetric)量化。对称量化可以避免量化算子在推理中计算z相关的部分,降低推理时的计算复杂度;非对称量化可以根据实际数据的分布确定最小值和最小值,可以更加充分的利用量化数据信息,使得计算精度更高。
|
||||
$round(\cdot)$和$clip(\cdot)$分别表示取整和截断操作,$q_{min}$和$q_{max}$是量化后的最小值和最大值。$s$是数据量化的间隔,$z$是表示数据偏移的偏置,$z$为0的量化被称为对称(Symmetric)量化,不为0的量化称为非对称(Asymmetric)量化。对称量化可以避免量化算子在推理中计算z相关的部分,降低推理时的计算复杂度;非对称量化可以根据实际数据的分布确定最小值和最小值,可以更加充分的利用量化数据信息,使得量化导致的损失更低。
|
||||
|
||||
根据量化参数$s$和$z$的共享范围,量化方法可以分为逐层量化和逐通道量化。逐层量化以一层网络为量化单位,每层网络的一组量化参数;逐通道量化以一层网络的每个量化通道为单位,每个通道单独使用一组量化参数。逐通道量化由于量化粒度更细,能获得更高的量化精度,但计算也更复杂。
|
||||
|
||||
@@ -27,23 +27,21 @@ $round(\cdot)$和$clip(\cdot)$分别表示取整和截断操作,$q_{min}$和$q
|
||||
|
||||
#### 量化感知训练
|
||||
|
||||
量化感知训练是在训练过程中模拟量化,利用伪量化节点将量化带来的精度变化计入训练误差,使得优化器能在训练过程中尽量减少量化误差,得到更高的模型精度。量化感知训练的具体流程如下:
|
||||
量化感知训练是在训练过程中模拟量化,利用伪量化算子将量化带来的精度损失计入训练误差,使得优化器能在训练过程中尽量减少量化误差,得到更高的模型精度。量化感知训练的具体流程如下:
|
||||
|
||||
- 初始化:设置权重和激活值的范围$q_{min}$和$q_{max}$的初始值;
|
||||
|
||||
- 构建模拟量化网络:在需要量化的权重和激活值后插入伪量化节点;
|
||||
- 构建模拟量化网络:在需要量化的权重和激活值后插入伪量化算子;
|
||||
|
||||
- 量化训练:重复执行以下步骤直到网络收敛,计算量化网络层的权重和激活值的范围$q_{min}$和$q_{max}$,前向计算反向传播更新网络权重参数;
|
||||
- 量化训练:重复执行以下步骤直到网络收敛,计算量化网络层的权重和激活值的范围$q_{min}$和$q_{max}$,并根据该范围将量化损失带入到前向推理和后向参数更新的过程中;
|
||||
|
||||
- 导出量化网络:获取$q_{min}$和$q_{max}$,并计算量化参数$s$和$z$;
|
||||
根据公式计算权重的量化整数值,并替换对应网络层的参数和数据类型;
|
||||
删除伪量化节点,在量化网络层前后分别插入量化和反量化算子。
|
||||
- 导出量化网络:获取$q_{min}$和$q_{max}$,并计算量化参数$s$和$z$;将量化参数代入量化公式中,转换网络中的权重为量化整数值;删除伪量化算子,在量化网络层前后分别插入量化和反量化算子。
|
||||
|
||||
#### 训练后量化
|
||||
|
||||
训练后量化也可以分成两种,权重量化和全量化。权重量化仅量化模型的权重以压缩模型的大小,在推理时将权重反量化为原始的FP32数据,后续推理流程与普通的FP32模型一致。权重量化的好处是不需要校准数据集,不需要实现量化算子,且模型的精度误差较小,由于实际推理使用的仍然是FP32算子,所以推理性能不会提高。全量化不仅会量化模型的权重,还会量化模型的激活值,在模型推理时执行量化算子来加快模型的推理速度。为了量化激活值,需要用户提供一定数量的校准数据集用于统计每一层激活值的分布,并对量化后的算子做校准。校准数据集可以来自训练数据集或者真实场景的输入数据,需要数量通常非常小。在做训练后量化时会以校准数据集为输入,执行推理流程然后统计每层激活值的数据分布并得到相应的量化参数,具体的操作流程如下:
|
||||
训练后量化也可以分成两种,权重量化和全量化。权重量化仅量化模型的权重以压缩模型的大小,在推理时将权重反量化为原始的float32数据,后续推理流程与普通的float32模型一致。权重量化的好处是不需要校准数据集,不需要实现量化算子,且模型的精度误差较小,由于实际推理使用的仍然是float32算子,所以推理性能不会提高。全量化不仅会量化模型的权重,还会量化模型的激活值,在模型推理时执行量化算子来加快模型的推理速度。为了量化激活值,需要用户提供一定数量的校准数据集用于统计每一层激活值的分布,并对量化后的算子做校准。校准数据集可以来自训练数据集或者真实场景的输入数据,需要数量通常非常小。在量化激活值时会以校准数据集为输入,执行推理流程然后统计每层激活值的数据分布并得到相应的量化参数,具体的操作流程如下:
|
||||
|
||||
- 使用直方图统计的方式得到原始FP32数据的统计分布$P_f$;
|
||||
- 使用直方图统计的方式得到原始float32数据的统计分布$P_f$;
|
||||
|
||||
- 在给定的搜索空间中选取若干个$q_{min}$和$q_{max}$分别对激活值量化,得到量化后的数据$Q_q$;
|
||||
|
||||
@@ -51,7 +49,7 @@ $round(\cdot)$和$clip(\cdot)$分别表示取整和截断操作,$q_{min}$和$q
|
||||
|
||||
- 计算每个$Q_q$与$P_f$的统计分布差异,并找到差异性最低的一个对应的$q_{min}$和$q_{max}$来计算相应的量化参数,常见的用于度量分布差异的指标包括KL散度(Kullback-Leibler Divergence)、对称KL散度(Symmetric Kullback-Leibler Divergence)和JS散度(Jenson-Shannon Divergence)。
|
||||
|
||||
除此之外,由于量化存在固有误差,还需要校正量化误差。以矩阵乘为例,$a=\sum_{i=1}^Nw_ix_i+b$,w表示权重,x表示激活值,b表示偏置。首先需要对量化的均值做校正,对fp32算子和量化算子输出的每个通道求平均,假设某个通道i的fp32算子输出均值为$a_i$,量化算子反量化输出均值为$a_{qi}$,将这个通道两个均值的差$a_i-a_q$加到对应的通道上即可使得最终的输出均值和fp32一致。另外还需要保证量化后的分布和量化前是一致的,设某个通道权重数据的均值、方差为$E(w_c)$、$||w_c-E(w_c)||$,量化后的均值和方差为$E(\hat{w_c})$、$||\hat{w_c}-E(\hat{w_c})||$,对权重做如下校正:
|
||||
除此之外,由于量化存在固有误差,还需要校正量化误差。以矩阵乘为例,$a=\sum_{i=1}^Nw_ix_i+b$,w表示权重,x表示激活值,b表示偏置。首先需要对量化的均值做校正,对float32算子和量化算子输出的每个通道求平均,假设某个通道i的float32算子输出均值为$a_i$,量化算子反量化输出均值为$a_{qi}$,将这个通道两个均值的差$a_i-a_q$加到对应的通道上即可使得最终的输出均值和float32一致。另外还需要保证量化后的分布和量化前是一致的,设某个通道权重数据的均值、方差为$E(w_c)$、$||w_c-E(w_c)||$,量化后的均值和方差为$E(\hat{w_c})$、$||\hat{w_c}-E(\hat{w_c})||$,对权重如下校正:
|
||||
$$\hat{w_c}\leftarrow\zeta_c(\hat{w_c}+u_c)$$
|
||||
$$u_c=E(w_c)-E(\hat{w_c})$$
|
||||
$$\zeta_c=\frac{||w_c-E(w_c)||}{||\hat{w_c}-E(\hat{w_c})||}$$
|
||||
@@ -64,23 +62,23 @@ $$\zeta_c=\frac{||w_c-E(w_c)||}{||\hat{w_c}-E(\hat{w_c})||}$$
|
||||
|
||||
#### 模型稀疏的动机
|
||||
|
||||
因为卷积神经网络中的卷积计算可以被看作输入数据和卷积核中权重的加权线性组合,所以细小的权重对输出数据就具有相对较小的影响。对模型进行稀疏操作的合理性主要来源于两方面的假设:
|
||||
因为卷积神经网络中的卷积计算可以被看作输入数据和卷积核权重的加权线性组合,所以通常绝对值小的权重对输出数据具有相对较小的影响。对模型进行稀疏操作的合理性主要来源于两方面的假设:
|
||||
|
||||
- 其一,针对权重参数来说,当前许多神经网络模型存在过参数化(Over-parameterized)的现象,动辄具有几千万甚至数亿规模的参数量。
|
||||
|
||||
- 其二,针对模型推理过程中生成的激活值特征图,对于许多检测、分类、分割等视觉任务来说激活值特征图中能利用的有效信息相对于整张图仅占较小的比例。
|
||||
|
||||
根据以上描述按照模型稀疏性来源的不同,主要分为权重稀疏和激活值稀疏,它们的目的都是为了减少模型当中的冗余成分来达到降低计算量和模型存储的需求。具体来说,对模型进行稀疏就是根据模型的连接强弱程度(一般根据权重或激活的绝对值大小),对一些强度较弱的连接进行剪枝(将权重参数或激活值置为0)来达到模型稀疏并提高模型推理性能的目的。特别地,我们将模型权重或激活值张量中0值所占的比例称为模型稀疏度。一般而言,模型稀疏度越高带来的模型准确率下降越大,因此我们的目标是尽可能在提高模型稀疏度的同时保证模型准确率下降较小。
|
||||
根据以上描述按照模型稀疏性来源的不同,主要分为权重稀疏和激活值稀疏,它们的目的都是为了减少模型当中的冗余成分来达到降低计算量和模型存储的需求。具体来说,对模型进行稀疏就是根据模型的连接强弱程度(一般根据权重或激活的绝对值大小),对一些强度较弱的连接进行剪枝(将权重参数或激活值置为0)来达到模型稀疏并提高模型推理性能的目的。特别地,将模型权重或激活值张量中0值所占的比例称为模型稀疏度。一般而言,模型稀疏度越高带来的模型准确率下降越大,因此模型稀疏的目标是尽可能在提高模型稀疏度的同时保证模型准确率下降较小。
|
||||
|
||||
实际上,如同神经网络本身的发明受到了神经生物学启发一样,神经网络模型稀疏方法同样受到了神经生物学的启发。在一些神经生物学的发现中,人类以及大多数哺乳动物的大脑都会出现一种叫做突触修剪的活动。突触修剪即神经元的轴突和树突发生衰退和完全死亡,这一活动发生在哺乳动物的婴幼儿时期,然后一直持续到成年以后。这种突触修剪机制不断简化和重构哺乳动物大脑的神经元连接,使得哺乳动物的大脑能以更低的能量获得更高效的工作方式。
|
||||
|
||||
#### 结构与非结构化稀疏
|
||||
|
||||
首先我们考虑权重稀疏,对于权重稀疏来说,按照稀疏模式的不同,主要分为结构化和非结构化稀疏。简单来讲,结构化稀疏就是在通道或者卷积核层面对模型进行剪枝。这种稀疏方式能够得到规则且规模更小的权重矩阵,因此比较适合CPU和GPU进行加速计算。但与此同时,结构化稀疏是一种粗粒度的稀疏方式,将会对模型的推理准确率造成较大的下降。
|
||||
首先考虑权重稀疏,对于权重稀疏来说,按照稀疏模式的不同,主要分为结构化和非结构化稀疏。简单来讲,结构化稀疏就是在通道或者卷积核层面对模型进行剪枝。这种稀疏方式能够得到规则且规模更小的权重矩阵,因此比较适合CPU和GPU进行加速计算。但与此同时,结构化稀疏是一种粗粒度的稀疏方式,将会对模型的推理准确率造成较大的下降。
|
||||
|
||||
而非结构化稀疏,可以对权重张量中任意位置的权重进行裁剪,因此这种稀疏方式属于细粒度的稀疏。这种稀疏方式相对于结构化稀疏,造成的模型准确率下降较小。但是也正是因为这种不规则的稀疏方式,导致稀疏后的模型难以利用硬件获得较高的加速比。其背后原因主要有以下几点:
|
||||
|
||||
- 不规则排布的模型权重矩阵会带来大量的控制流指令,比如由于大量0值的存在,我们会不可避免地引入大量if-else分支判断指令,因此会降低指令层面的并行度。
|
||||
- 不规则排布的模型权重矩阵会带来大量的控制流指令,比如由于大量0值的存在,会不可避免地引入大量if-else分支判断指令,因此会降低指令层面的并行度。
|
||||
|
||||
- 权重矩阵的不规则内存排布会造成线程发散和负载不均衡,而不同卷积核往往是利用多线程进行计算的,因此这也影响了线程层面的并行度。
|
||||
|
||||
@@ -90,20 +88,19 @@ $$\zeta_c=\frac{||w_c-E(w_c)||}{||\hat{w_c}-E(\hat{w_c})||}$$
|
||||
|
||||
#### 稀疏策略
|
||||
|
||||
明确了模型稀疏的对象之后,我们需要确定模型稀疏的具体策略,具体来说我们需要决定何时对模型进行稀疏以及如何对模型进行稀疏。目前最常见模型稀疏的一般流程为:预训练、剪枝、微调。具体而言,我们首先需要训练得到一个收敛的稠密模型,然后在此基础上进行稀疏和微调。选择在预训练之后进行稀疏动作的原因基于这样一个共识,即预训练模型的参数蕴含了学习到的知识,继承这些知识然后进行稀疏得到的模型效果要比从头开始训练好。除了基于预训练模型进行进一步修剪之外,训练和剪枝交替进行也是一种常用的策略。相比于一步修剪的方法,这种逐步的修剪方式,使得训练和剪枝紧密结合,可以更有效地发现冗余的卷积核,被广泛采用于现代神经网络剪枝方法中。
|
||||
明确了模型稀疏的对象之后,下一步需要确定模型稀疏的具体策略,具体来说,就是需要决定何时对模型进行稀疏以及如何对模型进行稀疏。目前最常见模型稀疏的一般流程为:预训练、剪枝、微调。具体而言,首先需要训练得到一个收敛的稠密模型,然后在此基础上进行稀疏和微调。选择在预训练之后进行稀疏动作的原因基于这样一个共识,即预训练模型的参数蕴含了学习到的知识,继承这些知识然后进行稀疏要比直接从初始化模型进行稀疏效果更好。除了基于预训练模型进行进一步修剪之外,训练和剪枝交替进行也是一种常用的策略。相比于一步修剪的方法,这种逐步的修剪方式,使得训练和剪枝紧密结合,可以更有效地发现冗余的卷积核,被广泛采用于现代神经网络剪枝方法中。
|
||||
|
||||
以下通过一个具体实例(Deep Compression([@han2015deep]))
|
||||
来说明如何进行网络修剪:如 :numref:`ch08-fig-deepcomp`所示,在去掉大部分的权值之后,深度卷积神经网络的精度将会低于其原始的精度。对剪枝后稀疏的神经网络进行微调,可以进一步提升压缩后网络的精度。剪枝后的模型可以进一步进行量化,使用更低比特的数据来表示权值;此外,结合霍夫曼(Huffman)编码可以进一步地降低深度神经网络的存储。
|
||||
以下通过一个具体实例(Deep Compression([@han2015deep])) 来说明如何进行网络修剪:如 :numref:`ch08-fig-deepcomp`所示,在去掉大部分的权值之后,深度卷积神经网络的精度将会低于其原始的精度。对剪枝后稀疏的神经网络进行微调,可以进一步提升压缩后网络的精度。剪枝后的模型可以进一步进行量化,使用更低比特的数据来表示权值;此外,结合霍夫曼(Huffman)编码可以进一步地降低深度神经网络的存储。
|
||||
|
||||
![Deep Compression([@han2015deep])](../img/ch08/deepcomp.png)
|
||||
:width:`800px`
|
||||
:label:`ch08-fig-deepcomp`
|
||||
|
||||
除了直接去除冗余的神经元之外,基于字典学习的方法也可以用来去掉深度卷积神经网络中无用的权值([@bagherinezhad2017lcnn])。通过学习一系列卷积核的基,可以把原始卷积核变换到系数域上并且它们稀疏。比如,Bagherinezhad等人([@bagherinezhad2017lcnn])将原始卷积核分解成卷积核的基和稀疏系数的加权线性组合。
|
||||
除了直接去除冗余的神经元之外,基于字典学习的方法也可以用来去掉深度卷积神经网络中无用的权值。通过学习一系列卷积核的基,可以把原始卷积核变换到系数域上并且它们稀疏。比如,Bagherinezhad等人([@bagherinezhad2017lcnn])将原始卷积核分解成卷积核的基和稀疏系数的加权线性组合。
|
||||
|
||||
### 知识蒸馏
|
||||
|
||||
知识蒸馏,也被称为教师-学生神经网络学习算法,已经受到业界越来越多的关注。大型深度模型在实践中往往会获得良好的性能,因为当考虑新数据时,过度参数化会提高泛化性能。在知识蒸馏中,小模型(学生模型)通常是由一个大模型(教师模型)监督,算法的关键问题是如何从老师模型转换的知识传授给学生模型。通过把一个全新的更深的更窄结构的深度神经网络当作学生神经网络,然后把一个预先训练好的神经网络模型当作教师神经网络。利用这个教师神经网络模型来帮助学生神经网络模型的算法是当下的一个研究热点。
|
||||
知识蒸馏,也被称为教师-学生神经网络学习算法,已经受到业界越来越多的关注。大型深度网络在实践中往往会获得良好的性能,因为当考虑新数据时,过度参数化会提高泛化性能。在知识蒸馏中,小网络(学生网络)通常是由一个大网络(教师网络)监督,算法的关键问题是如何将教师网络的知识传授给学生网络。通常把一个全新的更深的更窄结构的深度神经网络当作学生神经网络,然后把一个预先训练好的神经网络模型当作教师神经网络。
|
||||
|
||||
Hinton等人([@Distill])首先提出了教师神经网络-学生神经网络学习框架,通过最小化两个神经网络之间的差异来学习一个更窄更深的神经网络。记教师神经网络为$\mathcal{N}_{T}$,它的参数为$\theta_T$,同时记学生神经网络为$\mathcal{N}_{S}$,相应的参数为$\theta_S$。一般而言,学生神经网络相较于教师神经网络具有更少的参数。
|
||||
|
||||
@@ -113,7 +110,7 @@ $$\mathcal{L}_{KD}(\theta_S) = \mathcal{H}(o_S,\mathbf{y}) +\lambda\mathcal{H}(\
|
||||
|
||||
其中,$\mathcal{H}(\cdot,\cdot)$是交叉熵函数,$o_S$和$o_T$分别是学生网络和教师网络的输出,$\mathbf{y}$是标签。公式 :eqref:`ch08-equ-c2Fcn_distill`中的第一项使得学生神经网络的分类结果接近预期的真实标签,而第二项的目的是提取教师神经网络中的有用信息并传递给学生神经网络,$\lambda$是一个权值参数用来平衡两个目标函数。$\tau(\cdot)$是一个软化(soften)函数,将网络输出变得更加平滑。
|
||||
|
||||
公式 :eqref:`ch08-equ-c2Fcn_distill`仅仅从教师神经网络分类器输出的数据中提取有价值的信息,并没有从其它中间层去将教师神经网络的信息进行挖掘。因此,Romero等人[@FitNet])进一步地开发了一种学习轻型学生神经网络的方法,该算法可以从教师神经网络中任意的一层来传递有用的信息给学生神经网络。此外,事实上,并不是所有的输入数据对卷积神经网络的计算和完成后续的任务都是有用的。例如,在一张包含一个动物的图像中,对分类和识别结果比较重要的是动物所在的区域,而不是那些无用的背景信息。所以,有选择性地从教师神经网络的特征图中提取信息是一个更高效的方式。于是,Zagoruyko和Komodakis([@attentionTS])提出了一种基于感知(attention)损失函数的学习方法来提升学生神经网络的性能,公式 :eqref:`ch08-equ-c2Fcn_distill`仅仅从教师神经网络分类器输出的数据中提取有价值的信息,并没有从其它中间层去将教师神经网络的信息进行挖掘。因此,Romero等人[@FitNet])进一步地开发了一种学习轻型学生神经网络的方法,该算法可以从教师神经网络中任意的一层来传递有用的信息给学生神经网络。此外,事实上,并不是所有的输入数据对卷积神经网络的计算和完成后续的任务都是有用的。例如,在一张包含一个动物的图像中,对分类和识别结果比较重要的是动物所在的区域,而不是那些无用的背景信息。所以,有选择性地从教师神经网络的特征图中提取信息是一个更高效的方式。于是,Zagoruyko和Komodakis([@attentionTS])提出了一种基于感知(attention)损失函数的学习方法来提升学生神经网络的性能,如 :numref:`ch08-fig-distillation`所示。该算法在学习学生神经网络的过程中,引入了感知模块Attention,选择性地将教师神经网络中的信息传递给学生神经网络,并帮助其进行训练。感知图可以识别输入图像不同位置对最终分类结果的重要性,并从教师网络传递到学生网络。
|
||||
公式 :eqref:`ch08-equ-c2Fcn_distill`仅仅从教师神经网络分类器输出的数据中提取有价值的信息,并没有从其他中间层去将教师神经网络的信息进行挖掘。因此,Romero等人[@FitNet])进一步地开发了一种学习轻型学生神经网络的方法,该算法可以从教师神经网络中任意的一层来传递有用的信息给学生神经网络。此外,事实上,并不是所有的输入数据对卷积神经网络的计算和完成后续的任务都是有用的。例如,在一张包含一个动物的图像中,对分类和识别结果比较重要的是动物所在的区域,而不是那些无用的背景信息。所以,有选择性地从教师神经网络的特征图中提取信息是一个更高效的方式。于是,Zagoruyko和Komodakis([@attentionTS])提出了一种基于感知(Attention)损失函数的学习方法来提升学生神经网络的性能,该方法在学习学生神经网络的过程中,引入了感知模块(Attention),选择性地将教师神经网络中的信息传递给学生神经网络,并帮助其进行训练。感知图用来表达输入图像不同位置对最终分类结果的重要性。感知模块从教师网络生成感知图,并迁移到学生网络,如图
|
||||
|
||||

|
||||
:width:`500px`
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
### 模型转换
|
||||
|
||||
前面我们提到过,不同的训练框架(Tensorflow、PyTorch、MindSpore、MXNet、CNTK等)都定义了自己的模型的数据结构,推理系统需要将它们转换到统一的一种数据结构上。Open Neural Network Exchange(ONNX)正是为此目的而设计的。ONNX支持广泛的机器学习运算符集合,并提供了不同训练框架的转换器,例如TensorFlow模型到ONNX模型的转换器、PyTorch模型到ONNX模型的转换器等。
|
||||
模型转换本质上是将模型这种结构化的数据,从一种数据结构转换为另一种数据结构的过程。进行模型转换首先要分析两种数据结构的异同点,然后针对结构相同的数据做搬运;对于结构相似的数据做一一映射;对于结构差异较大的数据则需要根据其语义做合理的数据转换;更进一步如果两种数据结构上存在不兼容,则模型转换无法进行。ONNX的一个优势就在于其强大的表达能力,从而大多数业界框架的模型都能够转换到ONNX的模型上来而不存在不兼容的情况.
|
||||
前面章节提到过,不同的训练框架,如Tensorflow、PyTorch、MindSpore、MXNet、CNTK等,都定义了自己的模型的数据结构,推理系统需要将它们转换到统一的一种数据结构上。开发神经网络交换协议(Open Neural Network Exchange,ONNX)正是为此目的而设计的。ONNX支持广泛的机器学习运算符集合,并提供了不同训练框架的转换器,例如TensorFlow模型到ONNX模型的转换器、PyTorch模型到ONNX模型的转换器等。
|
||||
模型转换本质上是将模型这种结构化的数据,从一种数据结构转换为另一种数据结构的过程。进行模型转换首先要分析两种数据结构的异同点,然后针对结构相同的数据做搬运;对于结构相似的数据做一一映射;对于结构差异较大的数据则需要根据其语义做合理的数据转换;更进一步如果两种数据结构上存在不兼容,则模型转换无法进行。ONNX的一个优势就在于其强大的表达能力,从而大多数业界框架的模型都能够转换到ONNX的模型上来而不存在不兼容的情况。
|
||||
|
||||
模型可以抽象为一种图,从而模型的数据结构可以解构为以下两个要点:
|
||||
|
||||
- 模型拓扑表达:从图的角度来说,就是图的边;从模型的角度来说,就是模型中的数据流和控制流等,模型数据流和控制流的定义又可以引申出子图的表达形式、模型输入输出的表达形式、控制流结构的表达形式等。比如Tensorflow1.x中的控制流表达为一种有环图,通过Enter、Exit、Switch、LoopCond、NextIteration等算子来解决成环,而ONNX通过Loop,If等算子来表达控制流,从而避免引入了有环,所以在将Tensorflow1.x的控制流模型转化为ONNX模型时,需要将Tensorflow模型中的控制流图结构融合成ONNX的While或者If算子。
|
||||
- 模型拓扑连接:从图的角度来说,就是图的边;从AI模型的角度来说,就是AI模型中的数据流和控制流等。模型数据流和控制流的定义又可以引申出子图的表达形式、模型输入输出的表达形式、控制流结构的表达形式等。比如Tensorflow1.x中的控制流表达为一种有环图,通过Enter、Exit、Switch、LoopCond、NextIteration等算子来解决成环,而ONNX通过Loop,If等算子来表达控制流,从而避免引入了有环,所以在将Tensorflow1.x的控制流模型转化为ONNX模型时,需要将Tensorflow模型中的控制流图结构融合成ONNX的While或者If算子。
|
||||
|
||||
- 算子原型定义:从图的角度来说,就是图的顶点;从模型角度来说,就是模型中的数据处理节点或者控制流节点。算子原型包括但不限于算子类型、算子输入输出的定义、算子属性的定义等。比如Caffe的slice算子和ONNX的slice算子的语义其实是不一致的,Caffe的slice算子应该映射到ONNX的Split算子,所以在将Caffe模型转换成ONNX模型时,需要将Caffe的Slice算子映射到ONNX的Split算子。比如Tensorflow中的中的FusedBatchNorm算子在Caffe中找不到相同语义的算子,需要将Caffe的BatchNorm算子和Scale算子组合起来才能表达相同的语义。
|
||||
通常模型转换的过程也就是转换模型中的拓扑关系和映射模型中的算子原型。
|
||||
- 算子原型定义:从图的角度来说,就是图的顶点;从AI模型角度来说,就是AI模型中的数据处理节点或者控制流节点。算子原型包括但不限于算子类型、算子输入输出的定义、算子属性的定义等。比如Caffe的slice算子和ONNX的slice算子的语义其实是不一致的,Caffe的slice算子应该映射到ONNX的Split算子,所以在将Caffe模型转换成ONNX模型时,需要将Caffe的Slice算子映射到ONNX的Split算子。比如Tensorflow中的中的FusedBatchNorm算子在Caffe中找不到相同语义的算子,需要将Caffe的BatchNorm算子和Scale算子组合起来才能表达相同的语义。
|
||||
|
||||
在完成模型转换之后,通常地,我们会将一些不依赖于输入的工作提前去完成。这些工作包括了如常量折叠、算子融合、算子替换、算子重排等一些优化手段。这些优化手段的概念在前面的章节其实已经提及到,比如在编译器前端阶段,通常也会做常量折叠;在编译器后端阶段,通常会根据后端的硬件支持程度,对算子进行融合和拆分。但是有些优化工作只有在部署阶段才能进行或者彻底进行。
|
||||
在完成模型转换之后,通常地,框架会将一些不依赖于输入的工作提前去完成。这些工作包括了如常量折叠、算子融合、算子替换、算子重排等一些优化手段。这些优化手段的概念在前面的章节其实已经提及到,比如在编译器前端阶段,通常也会做常量折叠;在编译器后端阶段,通常会根据后端的硬件支持程度,对算子进行融合和拆分。但是有些优化工作只有在部署阶段才能进行或者彻底进行。
|
||||
|
||||
### 算子融合
|
||||
:label:`ch08-sec-fusion`
|
||||
@@ -22,13 +22,13 @@
|
||||
:width:`150px`
|
||||
:label:`ch08-fig-storage`
|
||||
|
||||
算子融合带来的性能上的收益主要来自两个方面,一是通过融合,充分利用寄存器和缓存,避免多个算子运算时,数据在CPU和内存之间的存储和读取的耗时。如 :numref:`ch08-fig-storage`,可以看到计算机的储存系统,从最靠近cpu的寄存器L1、L2等多级缓存,到内存、硬盘,其存储的容量越来越大,但读取数据的耗时也越来越大。融合后,前一次计算的结果可以先暂存在CPU的寄存器(Register)或者缓存(Cache)中,下一次计算直接从寄存器或者缓存中读取,减少了内存读写的IO次数。二是通过融合,可以将一些计算量提前完成,避免了前向推理时的冗余计算或者循环冗余计算。
|
||||
算子融合带来的性能上的收益主要来自两个方面,一是通过融合,充分利用寄存器和缓存,避免多个算子运算时,数据在CPU和内存之间的存储和读取的耗时。如 :numref:`ch08-fig-storage`,可以看到计算机的储存系统,从最靠近cpu的寄存器L1(Level1)、L2(Level2)等多级缓存,到内存、硬盘,其存储的容量越来越大,但读取数据的耗时也越来越大。融合后,前一次计算的结果可以先暂存在CPU的寄存器或者缓存中,下一次计算直接从寄存器或者缓存中读取,减少了内存读写的IO次数。二是通过融合,可以将一些计算量提前完成,避免了前向推理时的冗余计算或者循环冗余计算。
|
||||
|
||||

|
||||
:width:`500px`
|
||||
:label:`ch08-fig-conv_bn_fusion`
|
||||
|
||||
如 :numref:`ch08-fig-conv_bn_fusion`,我们以Convolution算子和Batchnorm算子的融合为例,阐述算子融合的基本原理,图中蓝色框表示算子,黄色框表示融合后新增或者改变的算子,白色框表示算子中的权重或者常数张量。其融合的过程是一个计算表达式简化的过程,Convolution算子的计算过程可以等效为一个矩阵乘,其公式可以表达为 :eqref:`ch08-equ-conv_equation`。
|
||||
如 :numref:`ch08-fig-conv_bn_fusion`,以Convolution算子和Batchnorm算子的融合为例,阐述算子融合的基本原理,图中蓝色框表示算子,黄色框表示融合后新增或者改变的算子,白色框表示算子中的权重或者常数张量。其融合的过程是一个计算表达式简化的过程,Convolution算子的计算过程可以等效为一个矩阵乘,其公式可以表达为 :eqref:`ch08-equ-conv_equation`。
|
||||
|
||||
$$\pmb{Y_{conv}}=\pmb{W_{conv}}*\pmb{X_{conv}}+\pmb{B_{conv}}$$
|
||||
:eqlabel:`ch08-equ-conv_equation`
|
||||
@@ -40,18 +40,18 @@ Batchnorm算子的计算过程如公式 :eqref:`ch08-equ-bn_equation`所示。
|
||||
$$\pmb{Y_{bn}}=\gamma\frac{\pmb{X_{bn}}-\mu_{\mathcal{B}}}{\sqrt{{\sigma_{\mathcal{B}}}^{2}+\epsilon}}+\beta$$
|
||||
:eqlabel:`ch08-equ-bn_equation`
|
||||
|
||||
同样,这里我们不需要理解batchnorm中的所有参数的含义,只需要了解公式 :eqref:`ch08-equ-bn_equation`是$\pmb{Y_{bn}}$关于$\pmb{X_{bn}}$的,其他符号均表示常量。
|
||||
同样,这里不需要理解Batchnorm中的所有参数的含义,只需要了解式 :eqref:`ch08-equ-bn_equation`是$\pmb{Y_{bn}}$关于$\pmb{X_{bn}}$的,其他符号均表示常量。
|
||||
|
||||
如 :numref:`ch08-fig-conv_bn_fusion`,当Convlution算子的输出作为Batchnorm输入时,最终Batchnorm算子的计算公式也就是要求$\pmb{Y_{bn}}$关于$\pmb{X_{conv}}$的计算公式,我们将$\pmb{Y_{conv}}$代入到$\pmb{X_{bn}}$,然后将常数项合并提取后,可以得到公式 :eqref:`equ:conv-bn-equation-3`。
|
||||
|
||||
$$\pmb{Y_{bn}}=\pmb{A}*\pmb{X_{conv}}+\pmb{B}$$
|
||||
:eqlabel:`ch08-equ-conv_bn_equation_3`
|
||||
|
||||
其中$\pmb{A}$和$\pmb{B}$为两个矩阵。可以看到,公式 :eqref:`ch08-equ-conv_bn_equation_3`其实就是一个Convolution的计算公式。这个结果表明,在模型部署时,我们可以将Convolution和Batchnorm两个算子的计算等价为一个Convolution算子。我们将上述以计算公式的合并和简化为基础的算子融合称为计算公式融合。
|
||||
其中$\pmb{A}$和$\pmb{B}$为两个矩阵。可以看到,公式 :eqref:`ch08-equ-conv_bn_equation_3`其实就是一个Convolution的计算公式。这个结果表明,在模型部署时,可以将Convolution和Batchnorm两个算子的计算等价为一个Convolution算子。将上述以计算公式的合并和简化为基础的算子融合称为计算公式融合。
|
||||
|
||||
在Convolution算子和Batchnorm算子融合的前后,网络结构相当于减少了一个Batchnorm算子,相应的网络中的参数量和网络所需的计算量都减少了;同时由于算子数量的减少,访存次数也相应地减少了。综合来看,该融合Pattern优化了模型部署时的功耗、性能,同时对于模型的体积大小也有少许收益。
|
||||
|
||||
在融合过程中,Convolution计算公式和Batchnorm计算公式中被认为是常量的符号在训练时均为参数,并不是常量。训练阶段如果进行该融合会导致模型参数的缺失。从该融合Pattern的结果来看,融合后网络中减少了一个Batchnorm算子,减少了一个Batchnorm算子的参数量,其实就是改变了深度神经网络的算法,会影响到网络的准确率,这是不可接受的。所以Convolution算子与Batchnorm算子的融合一般是在部署阶段特有的一种优化手段,其优化效果我们以MinsSpore Lite为例,构造了包含一个Convolution和一个Batchnorm的sample网络,分别以样例网络和mobilenet-v2网络为例,在华为Mate30手机上,以两线程运行模型推理,取3000轮推理的平均时耗作为模型推理性能的指标,对比融合前后该指标的变化。从表 :numref:`ch08-tab-conv_bn_fusion`可以看到,对于sample网络和mobilenet-v2网络,融合后分别获得了8.5%和11.7%的推理性能提升,这个性能提升非常可观。并且这个性能提升没有带来任何的副作用,也没有对于硬件或算子库的提出额外要求。
|
||||
在融合过程中,Convolution计算公式和Batchnorm计算公式中被认为是常量的符号在训练时均为参数,并不是常量。训练阶段如果进行该融合会导致模型参数的缺失。从该融合Pattern的结果来看,融合后网络中减少了一个Batchnorm算子,减少了一个Batchnorm算子的参数量,其实就是改变了深度神经网络的算法,会影响到网络的准确率,这是不可接受的。所以Convolution算子与Batchnorm算子的融合一般是在部署阶段特有的一种优化手段,其优化效果以MindSpore Lite为例,构造了包含一个Convolution和一个Batchnorm的sample网络,分别以样例网络和mobilenet-v2网络为例,在华为Mate30手机上,以两线程运行模型推理,取3000轮推理的平均时耗作为模型推理性能的指标,对比融合前后该指标的变化。从表 :numref:`ch08-tab-conv_bn_fusion`可以看到,对于sample网络和mobilenet-v2网络,融合后分别获得了8.5%和11.7%的推理性能提升,这个性能提升非常可观。并且这个性能提升没有带来任何的副作用,也没有对于硬件或算子库的提出额外要求。
|
||||
|
||||
::: {#tab:ch08-tab-conv_bn_fusion}
|
||||
网络 sample mobilenet-v2
|
||||
@@ -65,7 +65,7 @@ $$\pmb{Y_{bn}}=\pmb{A}*\pmb{X_{conv}}+\pmb{B}$$
|
||||
|
||||
### 算子替换
|
||||
|
||||
算子替换,即将模型中某些算子替换计算逻辑一致但对于在线部署更友好的算子。算子替换的原理是通过合并同类项、提取公因式等数学方法,将算子的计算公式加以简化,并将简化后的计算公式映射到某类算子上。算子替换可以达到降低计算量、降低模型大小的效果。
|
||||
算子替换,即将模型中某些算子替换计算逻辑一致但对于在线部署更友好的算子。算子替换的原理是通过合并同类项、提取公因式等数学方法,将算子的计算公式加以简化,并将简化后的计算公式映射到某类算子上。算子替换可以达到降低计算量、降低模型大小的效果。。
|
||||
|
||||

|
||||
:width:`500px`
|
||||
@@ -76,7 +76,7 @@ $$\pmb{Y_{bn}}=\pmb{A}*\pmb{X_{conv}}+\pmb{B}$$
|
||||
$$\pmb{Y_{bn}}=scale*\pmb{X_{bn}}+offset$$
|
||||
:eqlabel:`ch08-equ-replace_scale`
|
||||
|
||||
其中scale和offset为两个标量。可以看到,计算公式简化后,我们可以将其映射到一个Scale算子。
|
||||
其中scale和offset为两个标量。可以看到,计算公式简化后,可以将其映射到一个Scale算子。
|
||||
|
||||
在Batchnorm算子被替换为Scale算子的前后,网络中的参数量、计算量都减少了,该算子替换策略可以优化模型部署时的功耗和性能。同理,该算子替换优化策略只能在部署阶段才能进行,因为一方面在部署阶段Batchnorm计算公式中被认为是常量的符号,在训练时是参数并非常量。另一方面该优化策略会降低模型的参数量,改变模型的结构,降低模型的表达能力,影响训练收敛时模型的准确率。
|
||||
|
||||
@@ -88,6 +88,6 @@ $$\pmb{Y_{bn}}=scale*\pmb{X_{bn}}+offset$$
|
||||
:width:`500px`
|
||||
:label:`ch08-fig-crop_reorder`
|
||||
|
||||
如 :numref:`ch08-fig-crop_reorder`,Crop算子是从输入的feature map中裁取一部分作为输出,经过Crop算子后,feature map的size就降低了。如果我们将这个裁切的过程前移,提前对feature map进行裁切,那么后续算子的计算量也会相应地减少,从而提高模型部署时的推理性能。Crop算子前移带来的性能提升跟Crop算子的参数有关。但是Crop算子一般只能沿着element wise类算子前移。
|
||||
如 :numref:`ch08-fig-crop_reorder`,Crop算子是从输入的特征图中裁取一部分作为输出,经过Crop算子后,特征图的大小就降低了。如果将这个裁切的过程前移,提前对特征图进行裁切,那么后续算子的计算量也会相应地减少,从而提高模型部署时的推理性能。Crop算子前移带来的性能提升跟Crop算子的参数有关。但是Crop算子一般只能沿着的element wise类算子前移。
|
||||
|
||||
通过前面的实验数据我们可以看到,通过推理前的模型优化,可以为推理的时延、功耗、内存占用带来极大的收益。
|
||||
通过前面的实验数据可以看到,通过推理前的模型优化,可以为推理的时延、功耗、内存占用带来极大的收益。
|
||||
|
||||
@@ -2,15 +2,13 @@
|
||||
|
||||
模型完成训练后,需要将模型及参数持久化成文件,不同的训练框架导出的模型文件中存储的数据结构不同,这给模型的推理系统带来了不便。推理系统为了支持不同的训练框架的模型,需要将模型文件中的数据转换成统一的数据结构。此外,在训练模型转换成推理模型的过程中,需要进行一些如算子融合、常量折叠等模型的优化以提升推理的性能。
|
||||
|
||||
推理模型部署到不同的场景,需要满足不同的硬件设备的限制,例如,在具有强大算力的计算中心或数据中心的服务器上可以部署大规模的模型,而在边缘侧服务器、个人电脑以及智能手机上算力和内存则相对有限,部署的模型的规模就相应地要降低。在超低功耗的微控制器上,则只能部署非常简单的机器学习模型。此外,不同硬件对于不同数据类型(如FP32、FP16、BF16、INT8等)的支持程度也不相同。为了满足这些硬件的限制,在有些场景下需要对训练好的模型进行压缩,降低模型的复杂度或者数据的精度,减少模型的参数,以适应硬件的限制。
|
||||
推理模型部署到不同的场景,需要满足不同的硬件设备的限制,例如,在具有强大算力的计算中心或数据中心的服务器上可以部署大规模的模型,而在边缘侧服务器、个人电脑以及智能手机上算力和内存则相对有限,部署的模型的规模就相应地要降低。在超低功耗的微控制器上,则只能部署非常简单的机器学习模型。此外,不同硬件对于不同数据类型(如float32、float16、bfloat16、int8等)的支持程度也不相同。为了满足这些硬件的限制,在有些场景下需要对训练好的模型进行压缩,降低模型的复杂度或者数据的精度,减少模型的参数,以适应硬件的限制。
|
||||
|
||||
模型部署到运行环境中执行推理,推理的时延、内存占用、功耗等是影响用户使用的关键因素,优化模型推理的方式有两种,一是设计专有的机器学习的芯片,相对于通用的计算芯片,这些专有芯片一般在能效比上具有很大的优势。二是通过软硬协同最大程度地发挥硬件的能力。对于第二种方式,以CPU为例,如何切分数据块以满足cache大小,如何对数据进行重排以便计算时可以连续访问,如何减少计算时的数据依赖以提升硬件流水线的并行,如何使用扩展指令集以提升计算性能,这些都需要针对不同的CPU架构进行设计和优化。
|
||||
|
||||
对于一个企业来讲,模型是属于重要的资产,因此,在模型部署到运行环境以后,保护模型的安全至关重要。本章节会介绍如模型混淆等一些常见的机器学习模型的安全保护手段。
|
||||
|
||||
- **模型压缩**
|
||||
|
||||
通过量化、剪枝等手段减小模型体积以及计算复杂度的技术,可以分为需要重训的压缩技术和不需要重训的压缩技术两类。
|
||||
针对上述模型部署时的挑战,业界有一些常见的方法:
|
||||
|
||||
- **算子融合**
|
||||
|
||||
@@ -20,9 +18,13 @@
|
||||
|
||||
将符合条件的算子在离线阶段提前完成前向计算,从而降低模型的计算复杂度和模型的体积。常量折叠的条件是算子的所有输入在离线阶段均为常量。
|
||||
|
||||
- **模型压缩**
|
||||
|
||||
通过量化、剪枝等手段减小模型体积以及计算复杂度的技术,可以分为需要重训的压缩技术和不需要重训的压缩技术两类。
|
||||
|
||||
- **数据排布**
|
||||
|
||||
根据后端算子库支持程度和硬件限制,搜索网络中每层的最优数据排布格式,并进行数据重排或者插入数据重排算子,从而降低部署时的推理时延。
|
||||
根据后端算子库支持程度和硬件限制,搜索网络中每层的最优数据排布格式,并进行数据重排或者插入数据重排算子,从而降低部署时的推理时延
|
||||
|
||||
- **模型混淆**
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
#### 前处理
|
||||
|
||||
前处理主要完成数据预处理,在现实问题中,我们得到的原始数据往往非常混乱,机器学习模型无法识别并从中提取信息。数据预处理的目的是将原始数据例如图片、语音、文本等,处理成适合网络输入的tensor数据,并消除其中无关的信息,恢复有用的真实信息,增强有关信息的可检测性,最大限度地简化数据,从而改进模型的特征抽取、图像分割、匹配和识别等可靠性。
|
||||
前处理主要完成数据预处理,在现实问题中,原始数据往往非常混乱,机器学习模型无法识别并从中提取信息。数据预处理的目的是将原始数据例如图片、语音、文本等,处理成适合网络输入的tensor数据,并消除其中无关的信息,恢复有用的真实信息,增强有关信息的可检测性,最大限度地简化数据,从而改进模型的特征抽取、图像分割、匹配和识别等可靠性。
|
||||
|
||||
常见的数据预处理手段有:
|
||||
|
||||
@@ -43,18 +43,17 @@
|
||||
|
||||
为方便算子并行计算,同时避免频繁创建销毁线程的开销,推理框架一般会使用线程池机制。业界有两种较为通用的做法:
|
||||
|
||||
- 使用OpenMp编程接口:OpenMP(Open Multi-Processing)是一套支持跨平台共享内存方式的多线程并发的编程API,如算子并行最常用的接口\"parallel for\",实现for循环体的代码被多线程并行执行。
|
||||
- 推理框架实现针对算子并行计算的线程池,相对OpenMp提供的接口会更有针对性,性能会更高,且更轻量。
|
||||
- 使用OpenMP编程接口:OpenMP(Open Multi-Processing,一套支持跨平台共享内存方式的多线程并发的编程API)提供如算子并行最常用的接口\"parallel for\",实现for循环体的代码被多线程并行执行。
|
||||
- 推理框架实现针对算子并行计算的线程池,相对OpenMP提供的接口会更有针对性,性能会更高,且更轻量。
|
||||
|
||||
### 算子优化
|
||||
:label:`ch08-sec-kernel_optimization`
|
||||
|
||||
在部署AI模型时,我们期望模型执行训练或推理的时间尽可能地短,以获得更优越的性能。对于一个固定的深度学习网络,框架调度的时间占比往往很小,性能的瓶颈就在算子的执行。下面从硬件指令和算法角度介绍一些算子优化的方法。
|
||||
在部署AI模型时,用户通常期望模型执行训练或推理的时间尽可能地短,以获得更优越的性能。对于深度学习网络,框架调度的时间占比往往很小,性能的瓶颈就在算子的执行。下面从硬件指令和算法角度介绍一些算子优化的方法。
|
||||
|
||||
#### 硬件指令优化
|
||||
|
||||
绝大多数的设备上都有CPU,因此算子在CPU上的时间尤为重要,下面介绍一下在ARM
|
||||
CPU硬件指令优化的方法。
|
||||
绝大多数的设备上都有CPU,因此算子在CPU上的时间尤为重要,下面介绍一下在ARM CPU硬件指令优化的方法。
|
||||
|
||||
1\. 汇编语言
|
||||
|
||||
@@ -72,7 +71,7 @@ ARMv8系列的CPU上有32个NEON寄存器v0-v31,如 :numref:`ch08-fig-register
|
||||
:width:`500px`
|
||||
:label:`ch08-fig-register`
|
||||
|
||||
针对该处理器,可以采用SIMD(Single Instruction,Multiple Data,单指令、多数据)提升数据存取计算的速度。相比于单数据操作指令,NEON指令可以一次性操作NEON寄存器的多个数据。例如:对于浮点数的fmla指令,用法为fmla v0.4s, v1.4s, v2.4s,如 :numref:`ch08-fig-fmla`所示,用于将v1和v2两个寄存器中相对应的float值相乘累加到v0的值上。
|
||||
针对该处理器,可以采用SIMD(Single Instruction,Multiple Data,单指令、多数据)提升数据存取计算的速度。相比于单数据操作指令,NEON指令可以一次性操作NEON寄存器的多个数据。例如:对于浮点数的fmla指令,用法为fmla v0.4s, v1.4s, v2.4s,如图 :numref:`ch08-fig-fmla`所示,用于将v1和v2两个寄存器中相对应的float值相乘累加到v0的值上。
|
||||
|
||||

|
||||
:width:`600px`
|
||||
@@ -80,7 +79,7 @@ ARMv8系列的CPU上有32个NEON寄存器v0-v31,如 :numref:`ch08-fig-register
|
||||
|
||||
3\. 汇编语言优化
|
||||
|
||||
对于已知功能的汇编语言程序来说,计算类指令通常是固定的,性能的瓶颈就在非计算指令上。如 :numref:`ch08-fig-storage`所示,计算机各存储设备类似于一个金字塔结构,最顶层空间最小,但是速度最快,最底层速度最慢,但是空间最大。L1-L3统称为cache(高速缓冲存储器),CPU访问数据时,会首先访问位于CPU内部的cache,没找到再访问CPU之外的主存,此时引入了缓存命中率的概念来描述在cache中完成数据存取的占比。要想提升程序的性能,缓存命中率要尽可能的高。
|
||||
对于已知功能的汇编语言程序来说,计算类指令通常是固定的,性能的瓶颈就在非计算指令上。如图 :numref:`ch08-fig-storage`所示,计算机各存储设备类似于一个金字塔结构,最顶层空间最小,但是速度最快,最底层速度最慢,但是空间最大。L1-L3统称为cache(高速缓冲存储器),CPU访问数据时,会首先访问位于CPU内部的cache,没找到再访问CPU之外的主存,此时引入了缓存命中率的概念来描述在cache中完成数据存取的占比。要想提升程序的性能,缓存命中率要尽可能的高。
|
||||
|
||||
下面简单列举一些提升缓存命中率、优化汇编性能的手段:
|
||||
|
||||
@@ -97,23 +96,24 @@ ARMv8系列的CPU上有32个NEON寄存器v0-v31,如 :numref:`ch08-fig-register
|
||||
#### 算法优化
|
||||
|
||||
多数AI模型的推理时间主要耗费在卷积、矩阵乘算子的计算上,占到了整网百分之九十甚至更多的时间。本小节主要介绍卷积算子算法方面的优化手段,可以应用到各种硬件设备上。
|
||||
卷积的计算可以转换为两个矩阵相乘,在前述5.3.3小节中,已经详细介绍了矩阵乘GEMM运算的优化。对于不同的硬件,确定合适的矩阵分块,优化数据访存与指令并行,可以最大限度的发挥硬件的算力,提升推理性能。
|
||||
|
||||
卷积的计算可以转换为两个矩阵相乘,在前述 :numref:`ch08-sec-parallel_inference`小节中,已经详细介绍了矩阵乘运算的优化。对于不同的硬件,确定合适的矩阵分块,优化数据访存与指令并行,可以最大限度的发挥硬件的算力,提升推理性能。
|
||||
|
||||
1.Img2col
|
||||
|
||||
将卷积的计算转换为矩阵乘,一般采用Img2col的方法实现。在常见的神经网络中,卷积的输入通常都是4维的,默认采用的数据排布方式为NHWC,如 :numref:`ch08-fig-conv_nhwc`所示,是一个卷积示意图。输入维度为(1,IH,IW,IC),卷积核维度为(OC,KH,KW,IC),输出维度为(1,OH,OW,OC)。
|
||||
将卷积的计算转换为矩阵乘,一般采用Img2col的方法实现。在常见的神经网络中,卷积的输入通常都是4维的,默认采用的数据排布方式为NHWC,如图 :numref:`ch08-fig-conv_nhwc`所示,是一个卷积示意图。输入维度为(1,IH,IW,IC),卷积核维度为(OC,KH,KW,IC),输出维度为(1,OH,OW,OC)。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`ch08-fig-conv_nhwc`
|
||||
|
||||
对卷积的Img2col规则如下。如 :numref:`ch08-fig-img2col_input`所示,对该输入做重排,得到的矩阵见右侧,行数对应输出的OH\*OW的个数;每个行向量里,先排列计算一个输出点所需要输入上第一个通道的KH\*KW个数据,再按次序排列之后的通道,直到通道IC。
|
||||
对卷积的Img2col规则如下。如图 :numref:`ch08-fig-img2col_input`所示,对该输入做重排,得到的矩阵见右侧,行数对应输出的OH*OW的个数;对于每个行向量,就对应到一个输出点。计算每个输出点,需要IC*KH*KW个输入数据,Img2col先排列第一个输入通道的KH*KW个输入数据,再排列第二个输入通道的数据,直到第IC个通道。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`ch08-fig-img2col_input`
|
||||
|
||||
如 :numref:`ch08-fig-img2col_weight`所示,对权重数据做重排。将1个卷积核展开为权重矩阵的一列,因此共有OC列,每个列向量上先排列第一个输入通道上KH\*KW的数据,再依次排列后面的通道直到IC。通过重排,卷积的计算就可以转换为两个矩阵相乘的求解。在实际实现时,Img2col和GEMM的数据重排会同时进行,以节省运行时间。
|
||||
如 :numref:`ch08-fig-img2col_weight`所示,对权重数据做重排。将1个卷积核展开为权重矩阵的一列,因此共有OC列,每个列向量上先排列第一个输入通道上KH*KW的数据,再依次排列后面的通道直到IC。通过重排,卷积的计算就可以转换为两个矩阵相乘的求解。在实际实现时,Img2col和GEMM的数据重排会同时进行,以节省运行时间。
|
||||
|
||||

|
||||
:width:`600px`
|
||||
@@ -121,14 +121,14 @@ ARMv8系列的CPU上有32个NEON寄存器v0-v31,如 :numref:`ch08-fig-register
|
||||
|
||||
2.Winograd算法
|
||||
|
||||
卷积计算归根到底是矩阵乘法,两个二维矩阵相乘的时间复杂度是$O(n^3)$。我们可以使用Winograd来降低矩阵乘法的复杂度。
|
||||
卷积计算归根到底是矩阵乘法,两个二维矩阵相乘的时间复杂度是$O(n^3)$。Winograd算法可以降低矩阵乘法的复杂度。
|
||||
|
||||
以一维卷积运算为例,记为F(m,r),其中,m代表输出的个数,r为卷积核的个数。输入为$d=[d_0 \ d_1 \ d_2 \ d_3]$,卷积核为$g=[g_0 \ g_1 \ g_2]^T$,该卷积计算可以写成矩阵形式如公式 :eqref:`ch08-equ-conv_matmul_one_dimension`所示,需要6次乘法和4次加法。
|
||||
以一维卷积运算为例,记为\textit{\textbf{F}}($m$,$r$),其中,$m$代表输出的个数,$r$为卷积核的个数。输入为$\textit{\textbf{d}}=[d_0 \ d_1 \ d_2 \ d_3]$,卷积核为$g=[g_0 \ g_1 \ g_2]^{\rm T}$,该卷积计算可以写成矩阵形式如式 :eqref:`ch08-equ-conv_matmul_one_dimension`所示,需要6次乘法和4次加法。
|
||||
|
||||
$$F(2, 3)=\left[ \begin{matrix} d_0 & d_1 & d_2 \\ d_1 & d_2 & d_3 \end{matrix} \right] \left[ \begin{matrix} g_0 \\ g_1 \\ g_2 \end{matrix} \right]=\left[ \begin{matrix} y_0 \\ y_1 \end{matrix} \right]$$
|
||||
:eqlabel:`ch08-equ-conv_matmul_one_dimension`
|
||||
|
||||
可以观察到,卷积运算转换为矩阵乘法时输入矩阵中存在着重复元素$d_1$和$d_2$,因此,卷积转换的矩阵乘法相对一般的矩阵乘有了优化空间。可以通过计算中间变量$m_0-m_3$得到矩阵乘的结果,见公式 :eqref:`ch08-equ-conv-2-winograd`:
|
||||
可以观察到,卷积运算转换为矩阵乘法时输入矩阵中存在着重复元素$d_1$和$d_2$,因此,卷积转换的矩阵乘法相对一般的矩阵乘有了优化空间。可以通过计算中间变量$m_0-m_3$得到矩阵乘的结果,见式 :eqref:`ch08-equ-conv-2-winograd`:
|
||||
|
||||
$$F(2, 3)=\left[ \begin{matrix} d_0 & d_1 & d_2 \\ d_1 & d_2 & d_3 \end{matrix} \right] \left[ \begin{matrix} g_0 \\ g_1 \\ g_2 \end{matrix} \right]=\left[ \begin{matrix} m_0+m_1+m_2 \\ m_1-m_2+m_3 \end{matrix} \right]$$
|
||||
:eqlabel:`ch08-equ-conv-2-winograd`
|
||||
@@ -138,9 +138,9 @@ $$F(2, 3)=\left[ \begin{matrix} d_0 & d_1 & d_2 \\ d_1 & d_2 & d_3 \end{matrix}
|
||||
$$\begin{aligned}m_0=(d_0-d_2)*g_0 \\m_1=(d_1+d_2)*(\frac{g_0+g_1+g_2}{2}) \\m_2=(d_0-d_2)*(\frac{g_0-g_1+g_2}{2}) \\m_2=(d_1-d_3)*g_2\end{aligned}$$
|
||||
:eqlabel:`ch08-equ-winograd-param`
|
||||
|
||||
通过$m_0-m_3$间接计算r1,r2,需要的运算次数包括:输入d的4次加法;输出m的4次乘法和4次加法。在推理阶段,权重的数值是常量,因此卷积核上的运算可以在图编译阶段计算,不计入在线的run时间。所以总的运算次数为4次乘法和8次加法,与直接运算的6次乘法和4次加法相比,乘法次数减少,加法次数增加。在计算机中,乘法一般比加法慢,通过减少乘法次数,增加少量加法,可以实现加速。
|
||||
通过$m_0-m_3$间接计算r1,r2,需要的运算次数包括:输入$d$的4次加法;输出$m$的4次乘法和4次加法。在推理阶段,权重的数值是常量,因此卷积核上的运算可以在图编译阶段计算,不计入在线的run时间。所以总的运算次数为4次乘法和8次加法,与直接运算的6次乘法和4次加法相比,乘法次数减少,加法次数增加。在计算机中,乘法一般比加法慢,通过减少乘法次数,增加少量加法,可以实现加速。
|
||||
|
||||
计算过程写成矩阵形式如公式 :eqref:`ch08-equ-winograd-matrix`所示,其中,⊙为对应位置相乘,A、B、G都是常量矩阵。这里写成矩阵计算是为了表达清晰,实际使用时,按照公式 :eqref:`ch08-equ-winograd-param`手写展开的计算速度更快。
|
||||
计算过程写成矩阵形式如式 :eqref:`ch08-equ-winograd-matrix`所示,其中,⊙为对应位置相乘,A、B、G都是常量矩阵。这里写成矩阵计算是为了表达清晰,实际使用时,按照公式 :eqref:`ch08-equ-winograd-param`手写展开的计算速度更快。
|
||||
|
||||
|
||||
$$\mathbf{Y}=\mathbf{A^T}(\mathbf{G}g)*(\mathbf{B^T}d)$$
|
||||
@@ -167,6 +167,6 @@ Winograd算法的整个计算过程在逻辑上可以分为4步,如 :numref:`c
|
||||
:width:`500px`
|
||||
:label:`ch08-fig-winograd`
|
||||
|
||||
针对任意的输出大小,要使用F(2x2,3x3)的Winograd算法,需要将输出切分成2x2的块,找到对应的输入,按照上述的四个步骤,就可以求出对应的输出值。当然,Winograd算法并不局限于求解F(2x2,3x3),针对任意的F(m\*m,r\*r),都可以找到适当的常量矩阵A、B、G,通过间接计算的方式减少乘法次数。但是随着m、r的增大,输入、输出涉及的加法以及常量权重的乘法次数都在增加,那么乘法次数带来的计算量下降会被加法和常量乘法所抵消。因此,在实际使用场景中,还需要根据Winograd的实际收益来选择。
|
||||
针对任意的输出大小,要使用\textit{\textbf{F}}(2$\times$2,3$\times$3)的Winograd算法,需要将输出切分成2$\times$2的块,找到对应的输入,按照上述的四个步骤,就可以求出对应的输出值。当然,Winograd算法并不局限于求解\textit{\textbf{F}}(2$\times$2,3$\times$3),针对任意的\textit{\textbf{F}}($m$$\times$$m$,$r$$\times$$r$),都可以找到适当的常量矩阵\textit{\textbf{A}}、\textit{\textbf{B}}、\textit{\textbf{G}},通过间接计算的方式减少乘法次数。但是随着$m$、$r$的增大,输入、输出涉及的加法以及常量权重的乘法次数都在增加,那么乘法次数带来的计算量下降会被加法和常量乘法所抵消。因此,在实际使用场景中,还需要根据Winograd的实际收益来选择。
|
||||
|
||||
本小节主要介绍了模型推理时的数据处理和性能优化手段。选择合适的数据处理方法,可以更好地提取输入特征,处理输出结果。并行计算以及算子级别的硬件指令与算法优化可以最大限度的发挥硬件的算力。除此之后,内存的占用及访问速率也是影响推理性能的重要因素,因此推理时需要设计合理的内存复用策略,关于内存复用的策略我们在编译器后端章节已经做了阐述。
|
||||
本小节主要介绍了模型推理时的数据处理和性能优化手段。选择合适的数据处理方法,可以更好地提取输入特征,处理输出结果。并行计算以及算子级别的硬件指令与算法优化可以最大限度的发挥硬件的算力。除此之后,内存的占用及访问速率也是影响推理性能的重要因素,因此推理时需要设计合理的内存复用策略,内存复用的策略已经在编译器后端章节已经做了阐述。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
AI服务提供商在本地完成模型训练和调优后,将模型部署到第三方外包平台上(如终端设备、边缘设备和云服务器)来提供推理服务。由于AI模型的设计和训练需要投入大量时间、数据和算力,如何保护模型的知识产权(包括模型结构和参数等信息),防止模型在部署过程中的传输、存储以及运行环节被窃取,已经成为服务/模型提供商最为关心的问题之一。
|
||||
|
||||
### 概述
|
||||
模型的安全保护可以分为静态保护和动态保护两个方面。静态保护指的是模型在传输和存储时的保护,目前业界普遍采用的是基于文件加密的模型保护方案,AI模型文件以密文形态传输和存储,执行推理前在内存中解密。在整个推理过程中,模型在内存中始终是明文的,存在被敌手从内存中转储的风险。动态保护指的是模型在运行时的保护,目前业界已有的模型运行时保护方案主要有以下三个技术路线:一是基于TEE(Trusted execution environment)的模型保护方案,TEE通常指的是通过可信硬件隔离出来的一个“安全区”,AI模型文件在非安全区加密存储和传输,在安全区中解密运行。该方案在CPU上的推理时延较小,但依赖特定可信硬件,有一定的部署难度。此外,受硬件资源约束,难以保护大规模深度模型,且目前仍无法有效支持异构硬件加速。二是基于密态计算的保护方案,该方案基于密码学方法(如同态加密、多方安全计算等),保证模型在传输、存储和运行过程中始终保持密文状态。该方案不依赖特定硬件,但面临非常大的计算或通信开销问题,且无法保护模型结构信息。三是基于混淆的模型保护方案,该方案主要通过对模型的计算逻辑进行加扰,使得敌手即使能获取到模型也无法理解。与前两种技术路线相比,该方案仅带来较小的性能开销,且精度损失很低,同时,不依赖特定硬件,可支持大模型的保护。下面将重点介绍基于混淆的模型保护技术。
|
||||
模型的安全保护可以分为静态保护和动态保护两个方面。静态保护指的是模型在传输和存储时的保护,目前业界普遍采用的是基于文件加密的模型保护方案,AI模型文件以密文形态传输和存储,执行推理前在内存中解密。在整个推理过程中,模型在内存中始终是明文的,存在被敌手从内存中转储的风险。动态保护指的是模型在运行时的保护,目前业界已有的模型运行时保护方案主要有以下三个技术路线:一是基于TEE(Trusted Execution Environment)的模型保护方案,TEE通常指的是通过可信硬件隔离出来的一个“安全区”,AI模型文件在非安全区加密存储和传输,在安全区中解密运行。该方案在CPU上的推理时延较小,但依赖特定可信硬件,有一定的部署难度。此外,受硬件资源约束,难以保护大规模深度模型,且目前仍无法有效支持异构硬件加速。二是基于密态计算的保护方案,该方案基于密码学方法(如同态加密、多方安全计算等),保证模型在传输、存储和运行过程中始终保持密文状态。该方案不依赖特定硬件,但面临非常大的计算或通信开销问题,且无法保护模型结构信息。三是基于混淆的模型保护方案,该方案主要通过对模型的计算逻辑进行加扰,使得敌手即使能获取到模型也无法理解。与前两种技术路线相比,该方案仅带来较小的性能开销,且精度损失很低,同时,不依赖特定硬件,可支持大模型的保护。下面将重点介绍基于混淆的模型保护技术。
|
||||
|
||||
### 模型混淆
|
||||
模型混淆技术可以自动混淆明文AI模型的计算逻辑,使得攻击者即使在传输和存储时获取到模型也无法理解;且支持模型混淆态执行,保证模型运行时的机密性。同时不影响模型原本的推理结果、仅带来较小的推理性能开销。模型混淆技术主要包含以下几个步骤:
|
||||
@@ -21,9 +21,11 @@ AI服务提供商在本地完成模型训练和调优后,将模型部署到第
|
||||
|
||||
通过图压缩和图增广等技术,对计算图中节点与节点之间的依赖关系进行加扰,达到隐藏模型真实计算逻辑的效果。其中,图压缩通过整图检查来匹配原网络中的关键子图结构,这些子图会压缩并替换为单个新的计算节点。对于压缩后的计算图,图增广通过在网络结构中加入新的输入/输出边,进一步隐藏节点间的真实依赖关系。新增的输入/输出边可以来自/指向图中现已有的节点,也可以来自/指向本步骤新增的混淆节点。
|
||||
|
||||
> 加扰是指在计算图中添加扰动,来达到模型混淆的目的,常用的加扰手段有添加冗余的节点和边、融合部分子图等等。
|
||||
|
||||
(3) 对计算图的节点匿名化
|
||||
|
||||
遍历步骤(2)处理后的计算图,筛选出需要保护的节点。对于图中的每个需要保护的节点,将节点标识、节点算子类型以及其它能够描述节点计算逻辑的属性替换为无语义信息的符号。对于节点标识匿名化,本步骤保证匿名化后的节点标识仍然是唯一的,以区分不同的节点。对于算子类型匿名化,为了避免大规模计算图匿名化导致的算子类型爆炸问题,可以将计算图中算子类型相同的节点划分为若干不相交的集合,同一个集合中节点的算子类型替换为相同的匿名符号。步骤(5)将保证节点匿名化后,模型仍然是可被识别和执行的。
|
||||
遍历步骤(2)处理后的计算图,筛选出需要保护的节点。对于图中的每个需要保护的节点,将节点标识、节点算子类型以及其他能够描述节点计算逻辑的属性替换为无语义信息的符号。对于节点标识匿名化,本步骤保证匿名化后的节点标识仍然是唯一的,以区分不同的节点。对于算子类型匿名化,为了避免大规模计算图匿名化导致的算子类型爆炸问题,可以将计算图中算子类型相同的节点划分为若干不相交的集合,同一个集合中节点的算子类型替换为相同的匿名符号。步骤(5)将保证节点匿名化后,模型仍然是可被识别和执行的。
|
||||
|
||||
(4) 对计算图的参数权重加扰
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
- 不同的模型部署场景下,通常对于模型大小、运行时内存占用、推理时延和推理功耗等指标有限制。
|
||||
|
||||
- 针对模型大小指标,通常在离线阶段通过模型压缩技术来优化,比如量化技术、剪枝技术、知识蒸馏技术等,除此之外,一部分模型优化技术,比如融合技术 :numref:`ch08-sec-fusion`等,也有助于模型轻量化,不过其效果比较微弱。
|
||||
- 针对模型大小指标,通常在离线阶段通过模型压缩技术来优化,比如量化技术、剪枝技术、知识蒸馏技术等,除此之外,一部分模型优化技术,比如融合技术等,也有助于模型轻量化,不过其效果比较微弱。
|
||||
|
||||
- 针对运行时内存指标,主要有三方面的优化:优化模型大小、优化部署框架包大小以及优化运行时临时内存。模型大小的优化手段在上一点中已经说明;部署框架包大小主要通过精简框架代码、框架代码模块化等方式来优化。运行时临时内存主要通过内存池实现内存之间的复用来优化,这部分可以参见 :numref:`ch05-sec-memory_pool`。
|
||||
- 针对运行时内存指标,主要有三方面的优化:优化模型大小、优化部署框架包大小以及优化运行时临时内存。模型大小的优化手段在上一点中已经说明;部署框架包大小主要通过精简框架代码、框架代码模块化等方式来优化。运行时临时内存主要通过内存池实现内存之间的复用来优化。
|
||||
|
||||
- 针对模型的推理时延指标,主要有两方面的优化,一方面是离线时通过模型优化技术 :numref:`ch08-sec-fusion`和模型压缩技术 :numref:`ch08-sec-model_compression`尽可能降低模型推理所需的计算量;另一方面是通过加大推理的并行力度 :numref:`ch08-sec-parallel_inference`和优化算子实现 :numref:`ch08-sec-kernel_optimization`来充分挖掘硬件的计算潜力。值得注意的是,除了考虑计算量和算力,推理时的访存开销也是一个重要的影响因素,这一点在 :numref:`ch08-sec-fusion`小节和 :numref:`ch08-sec-kernel_optimization`小节中进行了相关优化。
|
||||
- 针对模型的推理时延指标,主要有两方面的优化,一方面是离线时通过模型优化技术和模型压缩技术尽可能降低模型推理所需的计算量;另一方面是通过加大推理的并行力度和优化算子实现来充分挖掘硬件的计算潜力。值得注意的是,除了考虑计算量和算力,推理时的访存开销也是一个重要的影响因素。
|
||||
|
||||
- 针对模型的推理功耗,主要的优化思路是降低模型的计算量,这与针对模型推理时延的优化手段有重合之处,可以参考离线的模型优化技术 :numref:`ch08-sec-fusion`和模型压缩技术 :numref:`ch08-sec-model_compression`。
|
||||
- 针对模型的推理功耗,主要的优化思路是降低模型的计算量,这与针对模型推理时延的优化手段有重合之处,可以参考离线的模型优化技术和模型压缩技术。
|
||||
|
||||
- 本章除了介绍优化模型部署的各方面指标的优化技术以外,还介绍了安全部署相关的技术,如模型混淆、模型加密等。部署安全一方面可以保护企业的重要资产,另一方面可以防止黑客通过篡改模型从而入侵攻击部署环境。
|
||||
|
||||
@@ -19,3 +19,9 @@
|
||||
- 诺亚高精度剪枝算法 [剪枝](https://arxiv.org/abs/2010.10732)
|
||||
|
||||
- 针对多核处理器的自动图并行调度框架 [性能优化](https://proceedings.mlsys.org/paper/2021/file/a5e00132373a7031000fd987a3c9f87b-Paper.pdf)
|
||||
|
||||
- 诺亚量子启发的低比特量化算法 [量化](https://arxiv.org/abs/2009.08695)
|
||||
|
||||
- 诺亚GhostNet极简骨干网络 [网络结构替换](https://arxiv.org/abs/1911.11907)
|
||||
|
||||
- 诺亚加法神经网络 [网络结构替换](https://arxiv.org/abs/1912.13200)
|
||||
|
||||
@@ -1,50 +1,51 @@
|
||||
# 序言
|
||||
# 前言
|
||||
|
||||
## 缘起
|
||||
|
||||
我在2020年来到了爱丁堡大学信息学院,爱丁堡大学是AI研究的发源地之一,很多学生慕名而来学习机器学习技术。因此,我们拥有许多出色的AI课程(自然语言处理,计算机视觉,计算神经学等),同时也拥有一系列关于计算机系统的基础课程(操作系统,编程语言,编译器,计算机体系架构等)。但是当我在教学的过程中问起学生:机器学习是如何利用计算机系统来做到计算加速和大规模部署的?许多学生都会报来疑惑的眼神。而这也促使我思考在爱丁堡大学乃至于其他世界顶尖大学的教学大纲里,我们是不是缺乏了一门课程来衔接机器学习技术和计算机系统知识。
|
||||
我在2020年来到了爱丁堡大学信息学院,爱丁堡大学是AI(Artificial Intelligence, 人工智能)研究的发源地之一,很多学生慕名而来学习机器学习技术。爱丁堡大学拥有许多出色的机器学习课程(如自然语言处理、计算机视觉、计算神经学等),同时也拥有一系列关于计算机系统的基础课程(如操作系统、编程语言、编译器、计算机体系架构等)。但是当我在教学的过程中问起学生:机器学习是如何利用计算机系统实现计算加速和部署?许多学生会投来疑惑的眼神。这促使我思考在爱丁堡大学乃至于其他世界顶尖大学的教学大纲中,是不是缺一门衔接机器学习和计算机系统的课程。
|
||||
|
||||
我第一反应是寻找一门已有的课程来借鉴。当其时,加州伯克利大学的AI Systems较为知名。这门课描述了机器学习系统的不同研究方向,内容以研读论文为主。可惜的是,许多论文已经无法经受住时间的检验。更重要的是:这门课缺乏对于知识的整体梳理,形成完整的知识体系架构。学习完这个课程,学生并没有明确的思路可以从头搭建起来一个机器学习框架。而将目光投向其他地方,华盛顿大学曾短期开过Deep Learning Systems课程,这门课程讲述了机器学习程序的编译过程,其受限于服务TVM的目的,对于机器学习系统缺乏完整的解读。另外,斯坦福大学的Machine Learning Systems Design因为课程设计人是数据库背景,因此课程专注数据清洗,数据管理,数据标注等数据专题。
|
||||
我的第一反应是基于一门已有的课程来进行拓展。那时,加州大学伯克利分校的AI Systems(人工智能系统)课程较为知名。这门课描述了机器学习系统的不同研究方向,内容以研读论文为主。可惜的是,许多论文已经无法经受住时间的检验。更重要的是,这门课缺乏对于知识的整体梳理,未能形成完整的知识体系架构。学习完这门课程,学生未能对于从头搭建机器学习系统有明确的思路。我将目光投向其他学校,华盛顿大学曾短期开过Deep Learning Systems(深度学习系统)课程,这门课程讲述了机器学习程序的编译过程。而由于这门课程以讲述Apache TVM深度学习编译器为主要目的,对于机器学习系统缺乏完整的教学。另外,斯坦福大学的课程Machine Learning Systems Design(机器学习系统设计)因为课程设计人的研究领域以数据库为主,因此该课程专注于数据清洗、数据管理、数据标注等主题。
|
||||
|
||||
当时觉得比较合适的是微软亚洲研究院的AI Systems。这门课程在研读论文的同时,一定程度上讲述了AI框架背后的设计理念。但是当我准备将其教授给本科生的时候,我发现这门课对于机器学习系统核心设计理念讲解很浅,同时也要求学生具有大量的背景知识,实际上更适合给博士生授课。抛开内容不谈,上述的全部课程共同的核心问题是:它们给学生的阅读材料都是高深,零散甚至过时的论文,而不是一本全面,注重基础,语言通熟易懂的面向本科生和工程师的教科书,这给机器学习系统相关知识的传播造成了极大的困难。
|
||||
当时觉得比较合适的是微软亚洲研究院的AI Systems课程。这门课程讲述了机器学习系统背后的设计理念。但是当我准备将其教授给本科生的时候,我发现这门课对于机器学习系统核心设计理念讲解得很浅,同时要求学生有大量计算机系统的背景知识,实际上它更适合教授给博士生。上述的课程共同问题是:其课程结构都以研读相关论文为主,因此教授的内容都是高深和零散的,而不是通俗易懂,知识脉络清晰的教科书,这给学习机器学习系统造成了极大的困难。
|
||||
|
||||
回首2020年的世界,我们已经拥有了优秀的操作系统,数据库,分布式系统等基础性教材。在机器学习领域,我们也拥有了一系列机器学习算法的教材。然而,无论是英语世界还是中文世界,我竟找不到任何一本系统性讲述机器学习系统的教材。而这本教材的缺乏,让许多公司和高校实验室不得不花费大量的人力和物力从头培养学生和工程师对于机器学习基础架构的认识,这已经制约了高校培养出符合业界,学界和时代发展的人才了!因此,我开始思考:我们学界和业界是不是需要一本机器学习系统的教科书了呢?
|
||||
回首2020年,我们已经拥有了优秀的操作系统、数据库、分布式系统等基础性教材。同时,在机器学习相关算法方面也有了一系列教材。然而,无论是国内外,我很难找到一本系统性讲述机器学习系统的教材。因此,许多公司和高校实验室不得不花费大量的人力和物力从头培养学生和工程师,使他们加强对于机器学习底层基础设施的认识。这类教材的缺乏已经制约了高校的人才培养,不利于高校培养出符合业界学界和时代发展的人才了。因此,我开始思考:我们是不是应该推出一本机器学习系统的教科书了呢?
|
||||
|
||||
## 开端
|
||||
|
||||
带着写书的构想,我开始和身边的朋友沟通。几乎全部人都非常认可这本书的巨大价值,但是现实的情况是:没有人愿意做这么一件吃力不讨好的事情。我当时的博士后导师也劝我:我现在处在助理教授的关键阶段,追求高影响力的学术论文是当务之急,写一本书要耗费3-4年的精力,最后可能也无法面世。而当我和同行交流时也发现:人们更愿意改进世面上已经有的教科书,做有迹可循的事情,而不是摸着石头过河,做从无到有的事情。特别是对于机器学习系统这个快速发展,依然在试错的领域,能不能写出一本能够经受住时间检验的书也是一个巨大的未知数。
|
||||
带着写书的构想,我开始和朋友沟通。大家都非常认可编写这类书的巨大价值,但是现实的情况是:很少有人愿意做这么一件费力的事情。我当时的博士后导师也劝我:你现在处在教职生涯的初期,追求高影响力的学术论文是当务之急,写一本书要耗费大量的时间和精力,最后可能也无法出版面世。而我和同行交流时也发现:他们更愿意改进市面上已经有的教科书,即做有迹可循的事情,而不是摸着石头过河,做从无到有的事情。特别是对于机器学习系统这个快速发展,频繁试错的领域,能不能写出经受时间检验的书也是一个未知数。
|
||||
|
||||
我因此不得不将这个想法暂时藏在了心里了数月,直到一次探亲回国和朋友聊天。这个朋友就是MindSpore的架构师金雪锋。和雪锋的相识是在疫情前的最后一个圣诞节左右,雪锋来伦敦访问,他正在领导MindSpore的开发(当时1.0还没有发布)。而在世界的另一端,我在2018年也和好友一起试图从头搭建一个AI框架,虽然最终资源不足,无疾而终,不过许多的思考成就了我之后发表的多篇AI系统论文。和雪锋聊起来,我们都对AI框架开发之难深有同感。我们共同的感慨就是:找到懂AI框架开发的人太难了。现今的学生们都一心学习机器学习算法,很多学生对于底层的运作原理理解很粗浅。而当他们在真实世界中应用机器学习意识到系统的重要性,想去学习的时候,却没有了在学校中最充沛的学习时间。我因此对雪锋苦笑到:我是准备写一本机器学习系统教材的,但是可能还要等个3-4年。雪锋这时候说:我也有这个想法啊,你要是写的话,我能帮助到你吗?
|
||||
考虑到写作的巨大挑战,我将写书的想法藏于心中,直到一次回国和MindSpore的架构师金雪锋聊天。和雪锋的相识大约是在2019年的圣诞节,雪锋来伦敦访问,他正在领导MindSpore的开发(当时MindSpore 1.0还没有发布)。而对于机器学习系统的开发,我也有很深的兴趣。我在2018年也和好友一起从头搭建一个机器学习框架(类似于PyTorch),虽然最终资源不足无疾而终,不过许多的思考成就了我之后发表的多篇机器学习系统论文。和雪锋聊起来,我们都对AI系统开发之难深有同感。我们共同的感慨就是:找到懂机器学习系统开发的人太难了。现今的学生都一心学习机器学习算法,很多学生对于底层的运作原理理解得很浅。而当他们在实际中应用机器学习技术时才意识到系统的重要性,那时想去学习,却没有了充沛的学习时间。我对雪锋苦笑道:“我是准备写一本机器学习系统教材的,但是可能还要等3,4年才能完成。” 雪锋说:“我也有这个想法啊。你要是写的话,我能帮助到你吗?”
|
||||
|
||||
雪锋这句话其实点醒了我。传统的书籍写作,往往是依赖于1-2个教授将学科十余年的发展慢慢总结,整理出书。这种模式类似于传统软件开发的瀑布流方式。可是,科技的世界已经变了!软件的发展从传统的瀑布流进化到如今的开源,敏捷开发。而书籍的写作为什么还要停留在传统方式呢?MXNet团队构建开源社区来编写的专注于深度学习算法的书籍《Deep Dive into Deep Learning》就是一个很好的例子啊。我因此马上找到当年一起创立TensorLayer开源社区的小伙伴北京大学的董豪,我们一拍即合,说干就干。雪锋也很高兴我和董豪愿意开始做这件事,也邀请了他的同事干志良进来帮助我们。我们终于开始书籍的写作了!
|
||||
这句话点醒了我。传统的图书写作,往往依赖于一,两个教授将学科十余年的发展慢慢总结整理成书。这种模式类似于传统软件开发的瀑布流方式。可是,在科技的世界,这已经变了!软件的发展从传统的瀑布流进化到如今的开源敏捷开发。而图书的写作为什么还要停留在传统方式呢?MXNet开源社区编写的专注于深度学习算法的图书*Deep Dive into Deep Learning*就是一个很好的例子啊。我因此马上找到当年一起创立TensorLayer开源社区的小伙伴:北京大学的董豪,我们一拍即合,说干就干。雪锋也很高兴我和董豪愿意开始做这件事,也邀请了他的同事干志良来帮忙。我们终于开始图书的写作了!
|
||||
|
||||
经过几轮的讨论,我们将书籍的名字定为《机器学习系统:设计和实现》。我们希望这本书能教给学生经受住时间检验的机器学习系统设计原理,同时也提供大量的系统实现经验分享,让他们将来工作,科研中遇到实际问题知道该如何分析和解决。
|
||||
经过几轮的讨论,我们将书名定为《机器学习系统:设计和实现》。我们希望通过教给学生机器学习系统设计原理,同时也为学生提供大量的系统实现经验分享,让他们在将来工作和科研中遇到实际问题知道该如何分析和解决。
|
||||
|
||||
## 社区的构建
|
||||
|
||||
考虑到机器学习系统本身就是一个依然在发展,试错,并且频繁孕育细分领域的学科。我从一开始就在思考:如何设计一个高度可扩展(Scalable)的社区架构来保证这本书的可持续发展呢?因为我是专注于大规模软件系统的老师,我决定借鉴几个分布式系统的设计要点来构建社区:
|
||||
考虑到机器学习系统本身就是一个不断发展并且孕育细分领域的学科。我从一开始就在思考:如何设计一个可扩展(Scalable)的社区架构保证这本书的可持续发展呢?因为我专注于大规模软件系统,故决定借鉴几个分布式系统的设计要点构建社区:
|
||||
|
||||
* 预防单点瓶颈:现代分布式系统往往采用控制层和数据层分离的设计来避免单点故障和瓶颈。那么我们在设计高度可扩展的写作社区的时候,也要如此。因此,我们设计了如下分布式机制:编辑(类似于分布式系统的Leader)决定花最大的时间来寻找每个章节最优秀,主动,负责任的章节负责人(Local leader)。而章节负责人可以进一步寻找其他作者(Follower)共同协作。而章节负责人和章节作者进行密切的沟通,按照给定时间节点,全速异步推进。而编辑和章节负责人设定了每隔1周的讨论来同步(Synchronise)写作的进展,确保并行完成的章节质量能够持续符合编辑和社区的整体预期。
|
||||
* **预防单点故障和瓶颈**:现代分布式系统往往采用控制层和数据层分离的设计避免单点故障和瓶颈。那么我们在设计高度可扩展的写作社区的时候也要如此。因此,我们设计了如下分布式机制:编辑决定花最多的时间来寻找优秀的、主动的、负责任的书稿章节负责人。章节负责人可以进一步寻找其他作者共同协作。章节负责人和章节作者进行密切的沟通,按照给定时间节点,全速异步推进。编辑和章节负责人设定了每周讨论同步写作的进展,确保并行完成的章节内容质量能够持续符合编辑和社区的整体预期。
|
||||
|
||||
* 迭代式改进:深度学习的优化算法随机梯度下降本质上是在复杂问题中利用局部梯度进行海量迭代,最终找到优秀的局部最优解。我因此利用了同样的思路来设计书籍质量的迭代提高。我们首先在Overleaf上写作好书籍的初版(类似于初始参数,Initial Weights)。接下来,我们进一步将书籍的内容做成标准的Git代码仓库(Book as code)。建立机制鼓励开源社区和广大读者开启Issue和PR,频繁改进书籍(相当于梯度,Gradients),而我们设置好完善的书籍构建工具,持续集成工具,贡献者讨论会,标准化的Issue和PR合并流程等等,就可以让书籍的质量持续提高实现随机梯度下降(Stochastic Gradient Descent)一样的最终最优性。
|
||||
* **迭代式改进**:深度学习的优化算法随机梯度下降本质上是在复杂问题中利用局部梯度进行海量迭代,最终找到局部最优解。因此我利用了同样的思路设计图书质量的迭代提高。我们首先在Overleaf上写作好书籍的初版(类似于初始参数)。接下来,将图书的内容做成标准的Git代码仓库。建立机制鼓励开源社区和广大读者开启GitHub问题(Issue)和拉取请求(Pull Request,PR),持续改进图书质量,而我们设置好完善的书籍构建工具、持续集成工具、贡献者讨论会等,就可以让图书的质量持续提高实现随机梯度下降(Stochastic Gradient Descent)一样的结果最优性。
|
||||
|
||||
* 高可用性:我们要有7x24小时在线的平台,让书籍可以在全球任何时区,任何语言平台下都能参与开发,倾听社区的反馈。因此我们将Git仓库放置在Github上,并准备之后在Gittee做好镜像。这样,我们就搭建了一套高可用的写作平台了。
|
||||
* **高可用性**:构建7 $\times$ 24小时在线的写作平台,让图书参与者可以在全球任何时区、任何语言平台下都能参与开发图书,倾听社区的反馈。因此将Git仓库放置在GitHub上,并准备之后在Gitee做好镜像。这样,就搭建了一套高可用的写作平台了。
|
||||
|
||||
* 内容中立:一个分布式系统要能长久运行,其中的每一个节点我们要同等对待,遇到故障才能用统一的办法来进行故障恢复。考虑到书籍写作中的故障(设计无法经受时间检验,写作人中途不得不退出等等)可以来源于方方面面,我们让不同背景的参与者共同完成每一个章节,确保写出中立,客观,包容各类型观点的书籍内容,并且写作不会因为故障而中断。
|
||||
* **内容中立**:一个分布式系统要能长久运行,其中的每一个节点都要同等对待,遇到故障才能用统一的办法进行故障恢复。考虑到图书写作中的故障(设计无法经受时间检验,写作人中途不得不退出等)可能来源于方方面面,我们让不同背景的参与者共同完成每一个章节,确保写出中立、客观、包容的内容,并且写作不会因为故障而中断。
|
||||
|
||||
## 现状和未来
|
||||
|
||||
机制一旦建立好,写作就自动化地跑起来了,同行人也越来越多,我带过的学生袁秀龙、丁子涵、符尧也很用心参与,董豪邀请了鹏城实验室的韩佳容和赖铖,志良邀请了许多MindSpore的小伙伴进来贡献,许多资深的AI框架的设计者也和我们在各个渠道展开讨论,提供了非常多宝贵的写作建议。另外,学界的教授(Peter Pietzuch老师,陈雷老师等)也持续给我们内容提供详细的反馈。
|
||||
机制一旦建立好,写作就自动化地跑起来了,参与者也越来越多,我带过的学生袁秀龙、丁子涵、符尧、任杰、梁文腾也很用心参与编写,董豪邀请了鹏城实验室的韩佳容和赖铖,志良邀请了许多MindSpore的小伙伴进来做贡献,许多资深的机器学习系统设计者也和我们在各个渠道展开讨论,提供了非常多宝贵的写作建议。另外,学界和产业界的反响也很热烈。海外很多优秀的学生(斯坦福大学的孙建凯、卡耐基梅隆大学的廖培元、剑桥大学的王瀚宸、爱丁堡大学的穆沛),产业界的朋友(英国葛兰素史克公司机器学习团队的肖凯严)都加入了我们的写作。同时,学界的教授(英国伦敦帝国理工学院的Peter Pietzuch教授、香港科技大学的陈雷教授等)也持续给我们提供了写作意见,改进了图书质量。
|
||||
|
||||
充分发动了“分布式系统”的力量后,书籍的内容得以持续高质量的合并了进来。当我们开源了书籍以后,书籍的受众快速增长,GitHub上的关注度增长让我们受宠若惊。在社区的推动下,书籍的中文版,英文版,阿拉伯语版都已经开始推进。这么多年来,我第一次意识到我在分布式系统和机器学习里面学习到的知识,在解决现实复杂问题的时候是如此的有用!
|
||||
充分发动了“分布式系统”的力量后,图书的内容得以持续高质量地添加。当我们开源了图书以后,图书的受众快速增长,GitHub上关注度的增长让我们受宠若惊。在社区的推动下,图书的中文版、英文版、阿拉伯语版都已经开始推进。这么多年来,我第一次意识到我在分布式系统和机器学习中学习到的知识,在解决现实复杂问题的时候是如此的有用!
|
||||
|
||||
很多时候,当我们面对未知而巨大的困难,个人的力量真的渺小。而和朋友,社区一起,就变成了强大的力量,让我们鼓起勇气,走出了最关键的第一步!希望我的一些思考,能给其他复杂问题的求解带来一些小小的启发。
|
||||
很多时候,当我们面对未知而巨大的困难时,个人的力量真的很渺小。而和朋友、社区一起就变成了强大的力量,让我们鼓起勇气,走出了最关键的第一步!希望我的一些思考,能给其他复杂问题的求解带来一些小小的启发。
|
||||
|
||||
最后,我们非常欢迎新成员的加入来帮助书籍提升质量,扩展内容。感兴趣的读者可以通过书籍的GitHub社区:https://github.com/openmlsys/ 联系到我们,我们非常期待和大家一起努力,写出世界上第一本机器学习系统的书籍!
|
||||
截止2022年5月,本书已经拥有了以下贡献者参与了各章节的编写:**导论**(麦络、董豪、干志良)、**编程模型**(赖铖、麦络、董豪)、**计算图**(韩佳容、麦络、董豪)、**AI编译器和前端技术**(梁志博、张清华、黄炳坚、余坚峰、干志良)、**AI编译器后端和运行时**(褚金锦、穆沛、蔡福璧)、**硬件加速器**(张任伟、任杰、梁文腾、刘超、陈钢、黎明奇)、**数据处理**(袁秀龙)、**模型部署**(韩刚强、唐业辉、翟智强、李姗妮)、**分布式训练**(麦络、廖培元)、**联邦学习系统**(吴天诚、王瀚宸)、**推荐系统**(符尧、裴贝、麦络)、**强化学习系统**(丁子涵)、**可解释AI系统**(李昊阳、李小慧)、**机器人系统**(孙建凯、肖凯严)。
|
||||
|
||||
最后,我们非常欢迎新成员的加入以提升书籍质量,扩展内容。感兴趣的读者可以通过书籍的[OpenMLSys社区](https://github.com/openmlsys/) 联系我们。我们非常期待和大家一起努力,编写出一本推动业界发展的机器学习系统图书!
|
||||
|
||||
麦络
|
||||
|
||||
写于英国爱丁堡
|
||||
英国爱丁堡
|
||||
|
||||
2022年5月4日
|
||||
2022年5月4日
|
||||
|
||||
@@ -1,50 +1,3 @@
|
||||
# 第二部分:进阶篇
|
||||
|
||||
第一部分基础篇介绍了机器学习系统的基础,把机器学习系统作为一个黑盒,介绍了机器学习系统的使用场景和主要需求,提供给用户的编程接口。介绍了典型场景下使用机器学习系统进行计算图构建、生成和调度的流程和关键技术。第二部分进阶篇,将站在系统设计的角度,思考在设计现代机器学习系统中需要考虑的问题和思路,详细的技术方案介绍在后面的章节会具体展开,本节重点介绍机器学习系统的总体架构以及进阶篇各章节之间的关系。
|
||||
机器学习系统面临的挑战可以从如下四个方面的变化趋势来看,如图所示,给出了AI框架系统主要的干系人,横向表示的是算法开发者需要使用AI框架系统进行算法研究,生产到部署的场景,纵向表示的是AI系统支撑的模型算法应用以及支撑AI系统运行的AI硬件:
|
||||
|
||||
1. 面向算法开发者:怎样提高算法开发的效率并兼顾运行性能?怎样让算法开发者减少学习成本随心所欲的表达算法思想?等一系列问题是机器学习系统需要考虑的问题。按照构建计算图的方式,AI框架分为了静态图和动态图两种类型:静态图在执行前先进行构图和编译优化,需要使用系统提供的构图API进行构图,其表达受限于API的灵活和丰富程度,但由于静态图先编译后执行,执行期性能较高;动态图则是边执行边构图,符合算法开发人员的使用习惯,由于直接使用Python生态,可以做到会Python编程语言就会写算法;动态图逐语句执行,调试方便,算法开发效率很高,但动态图的执行效率一般情况下不如静态图。机器学习系统就需要如何支持静态图和动态图的策略和方案。
|
||||
2. 面向部署:怎样将AI模型算法部署到每个设备、每个应用、每个行业?由于各个设备的算力和资源属性差别巨大,尤其是端侧和边缘侧的某些设备硬件资源极其受限,机器学习系统本身和模型大小和能效比有严苛的要求,以智能手表为例,其要求框架严格限制在100KB以内,能效/功耗比要能支持较长的待机时间要求。而另一方面,AI模型越来越大,即使部署在端侧的模型也动辄几十MB,占用大量的内存、计算和通信资源。因此机器学习系统需要解决跨系统、跨设备、极致轻量化部署的问题。
|
||||
3. 面向算法和数据:从计算规模看,AI模型规模呈现指数级增长,2021年模型规模已经达到十万亿参数,预计很快会增长到百万亿参数,怎么应对模型越来越大的挑战;从计算范式看,如何处理不断涌现的新的计算范式。机器学习系统过去是由机器学习、深度学习为主的,未来会不会支持其他的负载,比如:科学计算、数据分析、图形计算等,这样可以最大化的利用资源和提高开发易用性。
|
||||
4. 面向硬件:如果把AI加速器按照GPU、多核/众核、DataFlow架构来进行分类的话,DataFlow的架构占比一半左右,Dataflow架构虽然在能效比上有很大的优势,但是加大了可编程性和性能优化的难度,如何充分发挥芯片的性能进行极致的性能优化?
|
||||
|
||||

|
||||
|
||||
上述四个方面的不同需求,在设计AI框架系统的时候,需要基于场景充分的予以考虑,另外还需要考虑:
|
||||
|
||||
通用性:也可以称为泛化性,是不是所有的模型算法同一套代码,没有针对某个网络的特殊定制代码? 是不是所有硬件同一套机制,在机器学习系统中针对特定硬件版本的定制只存在于硬件相关层?上面提到的不同环境下部署要求千差万别,是同一套方案还是几套方案来支持呢?
|
||||
|
||||
易用性:对新用户而言,易用性关注更多的是入门的门槛,能不能一键式的安装、升级和运行常见的模型;对深度用户,如:算法研究人员而言,是不是能够轻松的表达算法、调试算法和部署算法模型是易用性的重点。另外,生态兼容性是易用性的一个重要考量,方便的使用常用的工具、第三方库兼容和对接,支持更多的硬件进行训练和部署是重要的因素。
|
||||
|
||||
性能:追求性价比、性能功耗比、大规模集群的性能加速比是机器学习系统性能的主要衡量指标,MLPerf、AIPerf榜单主要比拼的是典型网络在不同系统上的综合性能指标,机器学习系统中提供的系统优化机制对性能起到了重要作用。
|
||||
|
||||
在机器学习系统的架构设计中,采用三层架构来应对这些挑战和需要考虑的问题。 下图是MindSpore的系统架构图,就是以AI编译器为核心的三层架构设计。
|
||||
第一层MindExpression表示层,提供灵活的模型/算法表示,考虑到生态兼容性,采用Python作为主要的编程语言,后续也可以提供其他编程语言的扩展如:Julia、仓颉等编程语言。支持常见的第三库如:Numpy、Scipy等,数据处理支持常用的数据处理库,如:OpenCV的API等;
|
||||
第二层MindCompiler编译优化层,负责机器学习系统的编译优化,包括自动微分、硬件无关的优化,硬件相关的优化,分布式系统相关的优化等都在AI编译器层完成,经过这一层后,用户表达的算法和模型转换成特定硬件上的高效执行的机器代码;前端负责静态分析、类型推导以及自动微分、分布式并行子图拆分等PASS优化;后端负责硬件相关的优化,如:内存优化、图算融合等。在第二层可以看到有MindData数据处理框架,主要是因为在系统中所处的层次和MindCompiler相同,关系也比较密切。数据处理框架主要是提供数据处理格式、数据处理加速和数据处理相关的保序、分布式缓存等操作。由于数据处理和AI训练是在同一套硬件集群系统中,需要根据训练和数据处理的负载消耗情况来进行资源分配,有时候也会把数据处理相关操作在AI计算中构成一张DAG图进行调度。
|
||||
第三层MindRT运行时,提供不同部署环境下通用的分布式异构并行调度机制、内存分配等,对于不同的硬件使用不同的backend的设计方式。详细的介绍在后面的章节中会展开讨论,在本部分重点介绍设计思想和章节之间的关系。
|
||||
|
||||

|
||||
|
||||
既要对上承接模型算法的变化,满足算法开发者研究不断探索的诉求, 又要在最终的二进制输出上满足多样性硬件的诉求,满足不同部署环境的资源要求。既要满足系统的通用,也要满足易用性的灵活性要求,还要满足性能的不断优化诉求,这里引入编译器的概念再合适不过了。 编译器概念可以很好抽象上面提到的挑战和问题,编译器输入的是用户编程代码,输出的是机器执行的高效代码,编译器的作用主要是转换和优化,这和机器学习系统的输入输出,机器学习系统的目标是完全一致的。所以在进阶篇我们将用两个章节详细介绍AI编译器,里面的很多概念是和通用编译器中的概念是相同的,比如AOT(Ahead of Time提前编译)、JIT(Just in time)、IR(中间表示)、PASS优化、AST(Abstract Struct Trees)、副作用、闭包等概念和编译器中相关定义相同,对编译器相关概念需要了解的读者可以翻阅相关的编译原理教材了解。
|
||||
|
||||
AI编译器是一种相对较新的概念和工具,一个强大的AI编译器将让算法科学家和开发人员享受其带来的益处,包括表达的便捷和执行的高性能,是AI框架设计的核心。为了更好的理解AI编译器架构,先从如下图传统编译器LLVM架构说起,在这里可以把LLVM编译器分成三个部分:前端、IR和后端。前端将高级语言转换成IR,后端将IR转换成目标硬件上的机器指令,IR作为桥梁在前后端之间进行基于IR的各种优化。这样无论是新增硬件的支持,还是新的前端支持都可以尽可能的复用IR相关的部分。IR可以是单层的IR,也可以是多层的IR, LLVM IR是典型的单层的IR,前后端优化都基于相同的LLVM IR进行。
|
||||
|
||||

|
||||
|
||||
在AI编译器领域往往采用多层级逐步优化的方式,对应的IR也是多层级IR的。TensorFlow等AI框架就是采用多层级IR来进行设计的,MindSpore早期版本也是使用多层次IR的,下图就是MLIR官方材料中给出的TensorFlow框架当前的IR现状,中间至少有三个层次的IR,即:TensorFlow Graph IR, XLA (Accelerated Linear Algebra)HLO、 以及特定硬件的LLVM IR 或者TPU IR,下面就不同的层级IR和其上的编译优化做一个简要介绍。在前一章节计算图部分提到的图优化,也称为:图编译优化,主要实现整图级别的优化和操作,如: 图优化、图切分,往往是基于Graph IR这个层次进行的,比较适合静态图的执行模式;由于整图级别的IR缺少相应的硬件信息,难以进行硬件相关的优化,所以在中间层次就出现了相关的硬件通用编译优化,如:XLA、TensorRT、MindSpore的图算融合等都属于这一类编译器,来针对不同的硬件进行算子融合等优化,提升不同网络在特定硬件上的执行性能。在本书的第五章节编译器后端中硬件通用优化中有一个小节来介绍图算融合编译器的相关设计。最后一个层次的IR是特定硬件加速器专有的IR,一般由硬件厂商自带的编译器提供,如:Ascend硬件自带的TBE编译器就是基于TVM IR生成高效的执行算子,在本书的第6章加速器章节也会有相应部分来介绍算子编译器。
|
||||
|
||||

|
||||
|
||||
多层级IR的优势是IR表达上更加地灵活,可以在不同层级的IR上进行合适的PASS优化,更加方便,优化算法也更加地高效;但是多层级IR也有一些劣势:首先多层级IR需要进行不同IR之间的转换,IR转换要做到完全兼容非常困难,工程工作量很大;IR的转换除了工程上的工作量比较大以外,可能会带来信息的损失, 由于上一层IR的优化掉某些信息,给下一层优化带来困难,考虑到优化会造成信息丢失的影响,因此对优化执行的顺序也有更强的约束。其次多层级IR有些优化既可以在上一层IR进行,也可以在下一层IR进行,让系统开发者很难选择。最后不同层级IR定义的算子粒度大小不同,可能给精度带来一定的影响。所以在MindSpore的架构设计中采用了统一的MindIR设计,如下图所示详细给出了AI编译器内部的运行流程和分工,在本书中编译器前端主要是指图编译和硬件无关的优化,编译器后端主要是指硬件相关优化、算子选择等。
|
||||
|
||||

|
||||
|
||||
性能在AI领域有一个很重要的需求就是要尽可能的充分利用AI硬件的设计,发挥AI硬件的算力。当前AI硬件除了常见的CPU、GPU外,各种DSA架构的加速器硬件如:华为推出的Ascend昇腾AI加速器是专门针对神经网络训练和推理进行设计的加速器。在第6章加速器部分,首先对加速器硬件的架构进行介绍,并站在系统编程的角度,介绍了加速器的编程方式,掌握这部分的内容将可以更好的在机器学习系统中使用加速器。
|
||||
|
||||
数据是机器学习的生命线,据统计,在一次训练计算中有30%左右的计算时间用于数据处理,如果算上一个完整的机器学习模型训练,包括从公开的数据集中预处理数据等占比时间比例更高。由于原始数据的类型千变万化,模型中对数据处理的操作灵活多样,机器学习系统中数据处理功能需要提供灵活的数据处理能力,包括数据处理接口的设计和自定义算子的能力,也需要高性能的数据处理,包括数据处理加速和分布式数据处理等,在本书第7章节数据处理框架部分进行了介绍。
|
||||
|
||||
训练后的模型部署到各种形态的端边云设备中,将智慧赋能到各行各业。当前AI部署到企业的比例还很低,有报告统计还不到20%,模型部署主要存在着两大方面的挑战:一方面资源受限设备的CPU处理能力不强,内存和硬盘空间都很小,同时对模型执行的功耗、精度和时延开销又有严苛的指标要求,在此场景下,模型部署遇到了性能、功耗和大小的挑战;另一方面超大模型部署对算力消耗较大,需要进行分布式部署。而很多大企业往往采用统一的模型部署的云服务,需要一套服务接口提供对外服务,机器学习系统需要能适配不同的模型,不同的数据处理方法,对接不同的硬件后端,提升云端部署的效率、成本和吞吐率。本书第8章节模型部署将从模型部署的一般流程出发,详细的介绍在模型训练完以后,部署到手机等设备上需要进行的模型转换、模型优化和模型压缩的过程和相关技术。对于分布式场景下的模型部署本章也进行了相关的介绍。值得说明的是,模型在部署前可以进行算子融合、代数化简以及硬件相关的优化,如果共享AI编译器相关的基础设施,采用和训练相同的IR,不仅可以很好的复用部署和训练的算子,还由于算子定义粒度相一致,可以减少训练到模型部署的精度损失。
|
||||
|
||||
机器学习系统中单机系统包括编译优化、加速器加速、模型部署和数据处理执行流程,如下图所示。随着模型的越来越大,AI训练单机系统已经不能满足需求,大型模型的训练需要使用到AI集群来进行训练,2021年鹏城实验室联合华为发布盘古大模型,就提到使用了2048个训练节点的超大规模的昇腾计算集群。在超大模型训练中,如果Scale out模型训练到集群上,需要机器学习系统支持数据并行、模型并行、流水线并行、优化器并行的能力,同时允许多种策略灵活的组合使用。由于大规模集群的系统配置的复杂度很高,无论对于算法人员还是系统人员,配置这些策略的门槛都比较高,需要机器学习系统提供半自动和自动的策略支持,适配不同模型、不同规模的集群的灵活的分布式并行策略,这些技术将在第9章节分布式训练进行介绍。
|
||||
|
||||

|
||||
下面本书将重点讲解 AI 编译器的基本构成,以及 AI 编译器前端、后端和运行时中的关键技术。本书也将对于硬件加速器、数据处理、模型部署和分布式训练分别进行深入解读,从而为开发者提供从 0 到 1 构建机器学习框架所需的核心知识和实践经验。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
## C/C++编程接口
|
||||
|
||||
在上述小节中,我们讨论了开发者如何利用Python来定义机器学习的整个工作流,以及如何定义复杂的深度神经网络。然而,在很多时候,用户也需要添加自定义的算子来帮助实现新的模型,优化器,数据处理函数等。这些自定义算子需要通过C和C++实现,从而获得最优性能。但是为了帮助这些算子被用户使用,他们也需要暴露为Python函数,从而方便用户整合入已有的Python为核心编写的工作流和模型。在这一小节中,我们讨论这一过程是如何实现的。
|
||||
在2.2和2.3节中,分别讨论了开发者如何利用Python来定义机器学习的整个工作流,以及如何定义复杂的深度神经网络。然而,在很多时候,开发者也需要添加自定义的算子来帮助实现新的模型,优化器,数据处理函数等。这些自定义算子需要通过C和C++实现,从而获得最优性能。但是为了帮助这些算子被开发者使用,他们也需要暴露为Python函数,从而方便开发者整合入已有的Python为核心编写的工作流和模型。在这一小节中,我们讨论这一过程是如何实现的。
|
||||
|
||||
### 在Python中调用C/C++函数的原理
|
||||
|
||||
|
||||
@@ -4,23 +4,18 @@
|
||||
:width:`800px`
|
||||
:label:`img_framedh`
|
||||
|
||||
随着机器学习系统的诞生,如何设计易用且高性能的编程接口就一直成为了框架设计者首要解决的问题。在早期的机器学习框架中(如 :numref:`img_framedh`所示),人们选择用Lua(Torch)和Python(Theano)等高层次编程语言来编写机器学习程序。这些早期的机器学习框架提供了机器学习必须的模型定义,自动微分等功能,其适用于编写小型和科研为导向的机器学习应用。
|
||||
随着机器学习系统的诞生,如何设计易用且高性能的API接口就一直成为了系统设计者首要解决的问题。在早期的机器学习框架中(如 :numref:`img_framedh`所示),人们选择用Lua(Torch)和Python(Theano)等高层次编程语言来编写机器学习程序。这些早期的机器学习框架提供了机器学习必须的模型定义,自动微分等功能,其适用于编写小型和科研为导向的机器学习应用。
|
||||
|
||||
在2011年,深度神经网络快速崛起,并很快在各个AI应用领域(计算机视觉,语音识别,自然语言处理等)取得了最先进的性能。训练深度神经网络需要消耗大量的算力,而这些算力无法被以Lua和Python所主导开发的Torch和Theano所满足。与此同时,计算加速卡(如英伟达GPU)的通用编程接口(例如CUDA
|
||||
C)日趋成熟,而构建于CPU多核技术之上的多线程库(POSIX
|
||||
Threads)也被广大开发者所接受。因此,许多的机器学习用户希望基于C和C++来开发高性能的深度学习应用。这一类需求被Caffe等一系列以C和C++作为核心编程接口的框架所满足。
|
||||
深度神经网络在2011年来快速崛起,很快在各个AI应用领域(计算机视觉、语音识别、自然语言处理等)取得了突破性的成绩。训练深度神经网络需要消耗大量的算力,而以Lua和Python为主导开发的Torch和Theano无法发挥这些算力的最大性能。与此同时,计算加速卡(如英伟达GPU)的通用API接口(例如CUDA C)日趋成熟,且构建于CPU多核技术之上的多线程库(POSIX Threads)也被广大开发者所接受。因此,许多的机器学习用户希望基于C和C++来开发高性能的深度学习应用。这一类需求被Caffe等一系列以C和C++作为核心API的框架所满足。
|
||||
|
||||
然而,机器学习模型往往需要针对部署场景,数据类型,识别任务等需求进行深度定制,而这类定制任务需要被广大的AI应用领域的开发者所实现。这类开发者的背景多样,其往往不具有熟练使用C和C++的背景,因此Caffe这一类库与C和C++深度绑定的编程模型快速成为了制约这一类框架快速推广的巨大瓶颈。
|
||||
然而,机器学习模型往往需要针对部署场景、数据类型、识别任务等需求进行深度定制,而这类定制任务需要被广大的AI应用领域开发者所实现。这类开发者的背景多样,往往不能熟练使用C和C++。因此Caffe这一类与C和C++深度绑定的编程框架,成为了制约框架快速推广的巨大瓶颈。
|
||||
|
||||
在2016年,谷歌率先推出了TensorFlow。相比于传统的Caffe,Torch和Theano,TensorFlow提出利用高层次编程语言:Python作为面向用户的主要前端语言,而利用C和C++实现高性能后端。大量基于Python的前端API确保了TensorFlow可以被大量的数据科学家和机器学习科学家接受,同时帮助TensorFlow能够快速融入Python为主导的大数据生态(大量的大数据开发库如Numpy,Pandas,SciPy,
|
||||
Matplotlib和PySpark)。同时,Python具有出色的和C语言的互操作性,这种互操作性已经在多个Python库中得到验证。因此,TensorFlow兼有Python的灵活性和生态,同时也通过C/C++后端得以实现高性能。这种设计在日后崛起的PyTorch,MXNet和CNTK的机器学习框架得到传承。
|
||||
在2015年底,谷歌率先推出了TensorFlow。相比于传统的Torch,TensorFlow提出前后端分离相对独立的设计,利用高层次编程语言Python作为面向用户的主要前端语言,而利用C和C++实现高性能后端。大量基于Python的前端API确保了TensorFlow可以被大量的数据科学家和机器学习科学家接受,同时帮助TensorFlow能够快速融入Python为主导的大数据生态(大量的大数据开发库如Numpy、Pandas、SciPy、Matplotlib和PySpark)。同时,Python具有出色的和C/C++语言的互操作性,这种互操作性已经在多个Python库中得到验证。因此,TensorFlow兼有Python的灵活性和生态,同时也通过C/C++后端得以实现高性能。这种设计在日后崛起的PyTorch、MindSpore和PaddlePaddle等机器学习框架得到传承。
|
||||
|
||||
随着多个机器学习框架的出现,Keras和TensorLayer等高层次机器学习开发库提供了更高层次的Python
|
||||
API从而可以快速导入已有的模型,
|
||||
这些高层次API进一步屏蔽了底层框架的实现细节,因此Keras和TensorLayer可以运行在不同的机器学习框架之上。
|
||||
随着各国大型企业开源机器学习框架的出现,为了更高效地开发机器学习应用,基于开源机器学习框架为后端的高层次库Keras和TensorLayerX应运而生,它们提供Python API 可以快速导入已有的模型,这些高层次API进一步屏蔽了机器学习框架的实现细节,因此Keras和TensorLayerX可以运行在不同的机器学习框架之上。
|
||||
|
||||
随着深度神经网络的进一步发展,对于机器学习框架编程接口的挑战也日益增长。因此在2020年前后,新型的机器学习框架如MindSpore和JAX进一步出现。其中,MindSpore在继承了TensorFlow,PyTorch的Python和C/C++的混合接口的基础上,进一步拓展了机器学习编程模型从而可以高效支持多种AI后端芯片(如华为Ascend,英伟达GPU和ARM芯片),实现了机器学习应用在海量异构设备上的快速部署。
|
||||
随着深度神经网络的进一步发展,对于机器学习框架编程接口的挑战也日益增长。因此在2020年前后,新型的机器学习框架如MindSpore和JAX进一步出现。其中,MindSpore在继承了TensorFlow、PyTorch的Python和C/C++的混合接口的基础上,进一步拓展了机器学习编程模型从而可以高效支持多种AI后端芯片(如华为Ascend、英伟达GPU和ARM芯片),实现了机器学习应用在海量异构设备上的快速部署。
|
||||
|
||||
同时,超大型数据集和超大型深度神经网络崛起让分布式执行成为了机器学习框架编程模型的核心设计需求。为了实现分布式执行,TensorFlow和PyTorch的使用者需要进行大量编程来将数据集和神经网络分配到分布式节点上,而大量的AI开发人员并不具有分布式编程的能力。因此MindSpore进一步完善了机器学习框架的分布式编程模型的能力,从而让单节点的MindSpore程序可以无缝地运行在海量节点上。
|
||||
同时,超大型数据集和超大型深度神经网络崛起让分布式执行成为了机器学习编程框架的核心设计需求。为了实现分布式执行,TensorFlow和PyTorch的使用者需要花费大量代码来将数据集和神经网络分配到分布式节点上,而大量的AI开发人员并不具有分布式编程的能力。因此MindSpore进一步完善了机器学习框架的分布式编程模型的能力,从而让单节点的MindSpore程序可以无缝地运行在海量节点上。
|
||||
|
||||
在本小节中,我们将以MindSpore作为例子讲解一个现代机器学习框架的Python前端API和C/C++后端API的设计原则。这些设计原则和PyTorch,TensorFlow相似。
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 编程接口
|
||||
|
||||
现代机器学习框架包含大量的组件。这些组件使得用户得以高效开发机器学习算法,处理数据,部署模型,性能调优和使用硬件加速器。在设计这些组件的编程接口时,一个核心的诉求是:如何平衡框架性能和易用性?为了达到最优的性能,开发者需要利用硬件亲和的编程语言如:C和C++来进行开发。这是因为:C和C++的使用使得机器学习框架可以高效调用硬件的底层API,从而最大限度发挥硬件性能。同时,现代操作系统(如Linux和Windows)提供丰富的基于C和C++的编程接口(如文件系统,网络编程,多线程管理等),通过直接调用操作系统API,可以降低框架运行的开销。
|
||||
现代机器学习框架包含大量的组件,辅助用户高效开发机器学习算法、处理数据、部署模型、性能调优和调用硬件加速器。在设计这些组件的应用编程接口(Application Programming Interface,API)时,一个核心的诉求是:如何平衡框架性能和易用性?为了达到最优的性能,开发者需要利用硬件亲和的编程语言如:C和C++来进行开发。这是因为C和C++可以帮助机器学习框架高效地调用硬件底层API,从而最大限度发挥硬件性能。同时,现代操作系统(如Linux和Windows)提供丰富的基于C和C++的API接口(如文件系统、网络编程、多线程管理等),通过直接调用操作系统API,可以降低框架运行的开销。
|
||||
|
||||
从易用性的角度分析,机器学习框架的使用者往往具有丰富的行业背景(如数据科学家,生物学家,化学家,物理学家等)。他们常用的编程语言是高层次脚本语言:Python,Matlab,R和Julia。相比于C和C++,这些语言在提供编程的易用性的同时,丧失了C和C++对底层硬件和操作系统进行深度优化的能力。因此,机器学习框架的核心设计目标是:其要具有易用编程接口来支持用户用高层次语言如Python来实现机器学习算法,同时其也要具备以C和C++为核心的低层次编程接口,使得框架开发者可以用C和C++实现大量高性能组件,从而在硬件上高效执行。在本章中,我们将会讲述如何达到这个设计目标。
|
||||
从易用性的角度分析,机器学习框架的使用者往往具有丰富的行业背景(如数据科学家、生物学家、化学家、物理学家等)。他们常用的编程语言是高层次脚本语言:Python、Matlab、R和Julia。相比于C和C++,这些语言在提供编程易用性的同时,丧失了C和C++对底层硬件和操作系统进行深度优化的能力。因此,机器学习框架的核心设计目标是:具有易用的编程接口来支持用户使用高层次语言,如Python实现机器学习算法;同时也要具备以C和C++为核心的低层次编程接口来帮助框架开发者用C和C++实现大量高性能组件,从而在硬件上高效执行。在本章中,将讲述如何达到这个设计目标。
|
||||
|
||||
本章的学习目标包括:
|
||||
|
||||
@@ -21,5 +21,6 @@ development_history
|
||||
ml_workflow
|
||||
neural_network_layer
|
||||
c_python_interaction
|
||||
ml_programming_paradigm
|
||||
summary
|
||||
```
|
||||
55
chapter_programming_interface/ml_programming_paradigm.md
Normal file
@@ -0,0 +1,55 @@
|
||||
## 机器学习框架的编程范式
|
||||
### 机器学习框架编程需求
|
||||
机器学习的训练是其任务中最为关键的一步,训练依赖于优化器算法来描述。目前大部分机器学习任务都使用一阶优化器,因为一阶方法简单易用。随着机器学习的高速发展,软硬件也随之升级,越来越多的研究者开始探索收敛性能更好的高阶优化器。常见的二阶优化器如牛顿法、拟牛顿法、AdaHessians,均需要计算含有二阶导数信息的Hessian矩阵,Hessian矩阵的计算带来两方面的问题,一方面是计算量巨大如何才能高效计算,另一方面是高阶导数的编程表达。
|
||||
|
||||
同时,近年来,工业界发布了非常多的大模型,从2020年OpenAI GTP-3 175B参数开始,到2021年盘古大模型100B、鹏程盘古-$\alpha$ 200B、谷歌switch transformer 1.6T、智源悟道 1.75T参数,再到2022年百度ERNIE3.0 280M、Facebook NLLB-200 54B,越来越多的超大规模模型训练需求使得单纯的数据并行难以满足,而模型并行需要靠人工来模型切分耗时耗力,如何自动并行成为未来机器学习框架所面临的挑战。最后,构建机器学习模型本质上是数学模型的表示,如何简洁表示机器学习模型也成为机器学习框架编程范式的设计的重点。
|
||||
|
||||
为了解决机器学习框架在实际应用中的一些困难,研究人员发现函数式编程能很好地提供解决方案。在计算机科学中,函数式编程是一种编程范式,它将计算视为数学函数的求值,并避免状态变化和数据可变,这是一种更接近于数学思维的编程模式。神经网络由连接的节点组成,每个节点执行简单的数学运算。通过使用函数式编程语言,开发人员能够用一种更接近运算本身的语言来描述这些数学运算,使得程序的读取和维护更加容易。同时,函数式语言的函数都是相互隔离的,使得并发性和并行性更容易管理。
|
||||
|
||||
因此,机器学习框架使用函数式编程设计具有以下优势:
|
||||
- 支持高效的科学计算和机器学习场景。
|
||||
- 易于开发并行。
|
||||
- 简洁的代码表示能力。
|
||||
|
||||
### 机器学习框架编程范式现状
|
||||
本小节将从目前主流机器学习框架发展历程来看机器学习框架对函数式编程的支持现状。谷歌在2015年发布了TensorFlow1.0其代表的编程特点包括计算图(Computational Graphs)、会话(Session)、张量(Tensor)它是一种声明式编程风格。2017年Facebook发布了PyTorch其编程特点为即时执行,它是一种命令式编程风格。2018年谷歌发布了JAX它不是存粹为了机器学习而编写的框架,而是针对GPU和TPU做高性能数据并行计算的框架;与传统的机器学习框架相比其核心能力是神经网络计算和数值计算的融合,在接口上兼容了NumPy、Scipy等Python原生的数据科学接口,而且在此基础上扩展分布式、向量化、高阶求导、硬件加速,其编程风格是函数式,主要体现在无副作用、Lambda闭包等。2020年华为发布了MindSpore,其函数式可微分编程架构可以让用户聚焦机器学习模型数学的原生表达。2022年PyTorch推出functorch,受到谷歌JAX的极大启发,functorch是一个向PyTorch添加可组合函数转换的库,包括可组合的vmap(向量化)和autodiff转换,可与PyTorch模块和PyTorch autograd一起使用,并具有良好的渴望模式(Eager-Mode)性能,functorch可以说是弥补了PyTorch静态图的分布式并行需求。
|
||||
|
||||
从主流的机器学习框架发展历程来看,未来机器学习框架函数式编程风格将会日益得到应用,因为函数式编程能更直观地表达机器学习模型,同时对于自动微分、高阶求导、分布式实现也更加方便。另一方面,未来的机器学习框架在前端接口层次也趋向于分层解耦,其设计不直接为了机器学习场景,而是只提供高性能的科学计算和自动微分算子,更高层次的应用如机器学习模型开发则是通过封装这些高性能算子实现。
|
||||
|
||||
### 函数式编程案例
|
||||
在上一小节介绍了机器学习框架编程范式的现状,不管是JAX、MindSpore还是functorch都提到了函数式编程,其在科学计算、分布式方面有着独特的优势。然而在实际应用中纯函数式编程几乎没有能够成为主流开发范式,而现代编程语言几乎不约而同的选择了接纳函数式编程特性。以MindSpore为例,MindSpore选择将函数式和面向对象编程融合,兼顾用户习惯,提供易用性最好,编程体验最佳的混合编程范式。MindSpore采用混合编程范式道理也很简单,纯函数式会让学习曲线陡增,易用性变差;面向对象构造神经网络的编程范式深入人心。
|
||||
|
||||
下面中提供了使用MindSpore编写机器学习模型训练的全流程。其网络构造,满足面向对象编程习惯,函数式编程主要体现在模型训练的反向传播部分;MindSpore使用函数式,将前向计算构造成function,然后通过函数变换,获得grad function,最后通过执行grad function获得权重对应的梯度。
|
||||
|
||||
```python
|
||||
# Class definition
|
||||
class Net(nn.Cell):
|
||||
def __init__(self):
|
||||
......
|
||||
def construct(self, inputs):
|
||||
......
|
||||
|
||||
# Object instantiation
|
||||
net = Net() # network
|
||||
loss_fn = nn.CrossEntropyLoss() # loss function
|
||||
optimizer = nn.Adam(net.trainable_params(), lr) # optimizer
|
||||
|
||||
# define forward function
|
||||
def forword_fn(inputs, targets):
|
||||
logits = net(inputs)
|
||||
loss = loss_fn(logits, targets)
|
||||
return loss, logits
|
||||
|
||||
# get grad function
|
||||
grad_fn = value_and_grad(forward_fn, None, optim.parameters, has_aux=True)
|
||||
|
||||
# define train step function
|
||||
def train_step(inputs, targets):
|
||||
(loss, logits), grads = grad_fn(inputs, targets) # get values and gradients
|
||||
optimizer(grads) # update gradient
|
||||
return loss, logits
|
||||
|
||||
for i in range(epochs):
|
||||
for inputs, targets in dataset():
|
||||
loss = train_step(inputs, targets)
|
||||
```
|
||||
@@ -1,19 +1,19 @@
|
||||
## 机器学习工作流
|
||||
|
||||
机器学习系统编程模型的首要设计目标是:对开发者的整个工作流进行完整的编程支持。一个常见的机器学习任务一般包含如 :numref:`img_workflow`所示的流程。这个工作流完成了训练数据集的读取,模型的训练,测试和调试。通过归纳,我们可以将这一工作流中用户所需要自定义的部分通过定义以下API来支持(我们这里假设用户的高层次API以Python函数的形式提供):
|
||||
机器学习系统编程模型的首要设计目标是:对开发者的整个工作流进行完整的编程支持。一个常见的机器学习任务一般包含如 :numref:`img_workflow`所示的工作流。这个工作流完成了训练数据集的读取,模型的训练,测试和调试。通过归纳,我们可以将这一工作流中用户所需要自定义的部分通过定义以下API来支持(我们这里假设用户的高层次API以Python函数的形式提供):
|
||||
|
||||
- **数据处理:**
|
||||
首先,用户需要数据处理API来支持将数据集从磁盘读入。进一步,用户需要对读取的数据进行预处理,从而可以将数据输入后续的机器学习模型中。
|
||||
|
||||
- **模型结构:**
|
||||
完成数据的读取后,用户需要模型定义API来定义机器学习模型。这些模型带有模型参数,可以对给定的数据进行推理。
|
||||
- **模型定义:**
|
||||
完成数据的预处理后,用户需要模型定义API来定义机器学习模型。这些模型带有模型参数,可以对给定的数据进行推理。
|
||||
|
||||
- **损失函数和优化算法:**
|
||||
- **优化器定义:**
|
||||
模型的输出需要和用户的标记进行对比,这个对比差异一般通过损失函数(Loss
|
||||
function)来进行评估。因此,优化器定义API允许用户定义自己的损失函数,并且根据损失来引入(Import)和定义各种优化算法(Optimisation
|
||||
algorithms)来计算梯度(Gradient),完成对模型参数的更新。
|
||||
|
||||
- **训练过程:**
|
||||
- **训练:**
|
||||
给定一个数据集,模型,损失函数和优化器,用户需要训练API来定义一个循环(Loop)从而将数据集中的数据按照小批量(mini-batch)的方式读取出来,反复计算梯度来更新模型。这个反复的过程称为训练。
|
||||
|
||||
- **测试和调试:**
|
||||
@@ -120,7 +120,7 @@ net = MLPNet()
|
||||
**平均绝对误差**(Mean Absolute Error,MAE)是计算估算值与真实值差值的绝对值求和再求平均。
|
||||
**交叉熵**(Cross Entropy,CE)是分类问题中常用的,衡量已知数据分布情况下,计算输出分布和已知分布的差值。
|
||||
|
||||
有了损失函数,我们就可以通过损失值利用**优化器**对参数进行训练更新。对于优化的目标函数$f(x)$;先求解其梯度$\nabla$$f(x)$,然后将训练参数$W$沿着梯度的负方向更新,更新公式为:$W_t = W_{t-1} - \alpha\nabla(W_{t-1})$,其中$\alpha$是学习率,$W$是训练参数,$\alpha\nabla(W_{t-1})$是方向。
|
||||
有了损失函数,我们就可以通过损失值利用**优化器**对参数进行训练更新。对于优化的目标函数$f(x)$;先求解其梯度$\nabla$$f(x)$,然后将训练参数$W$沿着梯度的负方向更新,更新公式为:$W_t = W_{t-1} - \alpha\nabla(W_{t-1})$,其中$\alpha$是学习率,$W$是训练参数,$\nabla(W_{t-1})$是方向。
|
||||
神经网络的优化器种类很多,一类是学习率不受梯度影响的随机梯度下降(Stochastic Gradient Descent)及SGD的一些改进方法,如带有Momentum的SGD;另一类是自适应学习率如AdaGrad、RMSProp、Adam等。
|
||||
|
||||
**SGD**的更新是对每个样本进行梯度下降,因此计算速度很快,但是单样本更新频繁,会造成震荡;为了解决震荡问题,提出了带有Momentum的SGD,该方法的参数更新不仅仅由梯度决定,也和累计的梯度下降方向有关,使得增加更新梯度下降方向不变的维度,减少更新梯度下降方向改变的维度,从而速度更快也减少震荡。
|
||||
@@ -175,7 +175,7 @@ train_net(args, model, train_epoch, mnist_path, dataset_size, ckpoint, False)
|
||||
|
||||
### 测试和验证
|
||||
|
||||
测试是模型运行测试数据集得到的结果,通常在训练过程中,每训练一定的数据量后就会测试一次,以验证模型的泛化能力。MindSpore使用model.eval接口读入测试数据集。
|
||||
测试是将测试数据集输入到模型,运行得到输出的过程。通常在训练过程中,每训练一定的数据量后就会测试一次,以验证模型的泛化能力。MindSpore使用model.eval接口读入测试数据集。
|
||||
```python
|
||||
def test_net(model, data_path):
|
||||
"""定义验证的方法"""
|
||||
@@ -205,4 +205,4 @@ load_param_into_net(net, param_dict)
|
||||
output = model.predict(Tensor(data['image']))
|
||||
# 输出预测分类与实际分类
|
||||
print(f'Predicted: "{predicted[0]}", Actual: "{labels[0]}"')
|
||||
```
|
||||
```
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
## 定义深度神经网络
|
||||
|
||||
在上一节我们使用MindSpore构建了一个多层感知机的网络结构,随着深度神经网络的飞速发展,各种深度神经网络结构层出不穷,但是不管结构如何复杂,神经网络层数量如何增加,构建深度神经网络结构始终遵循最基本的规则:1.承载计算的节点;2.可变化的节点权重(节点权重可训练);3.允许数据流动的节点连接。因此在机器学习编程库中神经网络是以层为核心,它提供了各类神经网络层基本组件;将神经网络层组件按照网络结构进行堆叠、连接就能构造出神经网络模型。
|
||||
在上一节我们使用MindSpore构建了一个多层感知机的网络结构,随着深度神经网络的飞速发展,各种深度神经网络结构层出不穷,但是不管结构如何复杂,神经网络层数量如何增加,构建深度神经网络结构始终遵循最基本的元素:1.承载计算的节点;2.可变化的节点权重(节点权重可训练);3.允许数据流动的节点连接。因此在机器学习编程库中深度神经网络是以层为核心,它提供了各类深度神经网络层基本组件;将神经网络层组件按照网络结构进行堆叠、连接就能构造出神经网络模型。
|
||||
|
||||
### 以层为核心定义神经网络
|
||||
|
||||
神经网络层包含构建机器学习网络结构的基本组件,如计算机视觉领域常用到卷积(Convolution)、池化(Pooling)、全连接(Fully Connected);自然语言处理常用到循环神经网络(Recurrent Neural Network,RNN);为了加速训练,防止过拟合通常用到批标准化(BatchNorm)、Dropout等。
|
||||
|
||||
**全连接**是将当前层每个节点都和上一层节点一一连接,本质上是特征空间的线性变换;可以将数据从高维映射到低维,也能从低维映射到高维度。
|
||||
:numref:`fc_layer`展示了全连接的过程,对输入的n个数据变换到另一个大小为m的特征空间,再从大小为m的特征空间变换到大小为p的特征空间;可见全连接层的参数量巨大,两次变换所需的参数大小为$n \times m$和$m \times p$。
|
||||
:numref:`fc_layer`展示了全连接的过程,对输入的n个数据变换到大小为m的特征空间,再从大小为m的特征空间变换到大小为p的特征空间;可见全连接层的参数量巨大,两次变换所需的参数大小为$n \times m$和$m \times p$。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
@@ -56,7 +56,7 @@
|
||||
:width:`800px`
|
||||
:label:`nn_network`
|
||||
|
||||
有了上述基础知识,我们对卷积神经网络所需组件接口和模型构建使用伪代码描述如下:
|
||||
有了上述基础知识,对卷积神经网络模型构建过程使用伪代码描述如下:
|
||||
```python
|
||||
# 构建卷积神经网络的组件接口定义:
|
||||
全连接层接口:fully_connected(input, weights)
|
||||
@@ -94,26 +94,15 @@ Transformer又是BERT模型架构的重要组成。随着深度神经网络的
|
||||
|
||||
### 神经网络层的实现原理
|
||||
|
||||
2.3.1中使用伪代码定义了一些卷积神经网络接口和模型构建过程,整个构建过程,需要创建训练变量和构建连接过程;
|
||||
随着网络层数的增加,手动管理训练变量是一个繁琐的过程,因此2.3.1中描述的接口在机器学习库中属于低级API。
|
||||
机器学习编程库大都提供了更高级用户友好的API,它将神经网络层抽象成一个基类,所有的神经网络层实现都继承基类调用低级API。
|
||||
如MindSpore提供的mindspore.nn.Cell、mindspore.nn.Conv2d、mindspore.dataset;
|
||||
PyTorch提供的torch.nn.Module、torch.nn.Conv2d、torch.utils.data.Dataset。
|
||||
2.3.1中使用伪代码定义了一些卷积神经网络接口和模型构建过程,整个构建过程需要创建训练变量和构建连接过程。随着网络层数的增加,手动管理训练变量是一个繁琐的过程,因此2.3.1中描述的接口在机器学习库中属于低级API。机器学习编程库大都提供了更高级用户友好的API,它将神经网络层抽象出一个基类,所有的神经网络层都继承基类来实现,如MindSpore提供的mindspore.nn.Cell;PyTorch提供的torch.nn.Module。基于基类他们都提供了高阶API,如MindSpore 提供的mindspore.nn.Conv2d、mindspore.nn.MaxPool2d、mindspore.dataset;PyTorch提供的torch.nn.Conv2d、torch.nn.MaxPool2d、torch.utils.data.Dataset。
|
||||
|
||||
:numref:`model_build`描述了神经网络构建过程中的基本细节。
|
||||
神经网络层需要的功能有该层的训练参数(变量,包括初始化方法和训练状态)以及计算过程;
|
||||
神经网络模型需要的功能是对神经网络层管理和神经网络层参数的管理。
|
||||
在机器学习编程库中,承担此功能有MindSpore的Cell、PyTorch的Module。
|
||||
Cell和Module是模型抽象方法也是所有网络的基类。
|
||||
现有模型抽象方案有两种。
|
||||
一种是抽象出两个方法分别为Layer(负责单个神经网络层的参数构建和前向计算),Model(负责对神经网络层进行连接组合和神经网络层参数管理);
|
||||
另一种是将Layer和Model抽象成一个方法,该方法既能表示单层神经网络层也能表示包含多个神经网络层堆叠的模型,Cell和Module就是这样实现的。
|
||||
:numref:`model_build`描述了神经网络构建过程中的基本细节。基类需要初始化训练参数、管理参数状态以及定义计算过程;神经网络模型需要实现对神经网络层和神经网络层参数管理的功能。在机器学习编程库中,承担此功能有MindSpore的Cell、PyTorch的Module。Cell和Module是模型抽象方法也是所有网络的基类。现有模型抽象方案有两种,一种是抽象出两个方法分别为Layer(负责单个神经网络层的参数构建和前向计算),Model(负责对神经网络层进行连接组合和神经网络层参数管理);另一种是将Layer和Model抽象成一个方法,该方法既能表示单层神经网络层也能表示包含多个神经网络层堆叠的模型,Cell和Module就是这样实现的。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`model_build`
|
||||
|
||||
:numref:`cell_abs`展示了设计神经网络层抽象方法的通用表示。通常在构造器会选择使用Python中collections模块的OrderedDict来初始化神经网络层和神经网络层参数的存储;它的输出是一个有序的,相比与Dict更适合深度学习这种模型堆叠的模式。参数和神经网络层的管理是在\_\_setattr\_\_中实现的,当检测到属性是属于神经网络层及神经网络层参数时就记录起来。神经网络模型比较重要的是计算连接过程,可以在\_\_call\_\_里重载,实现神经网络层时在这里定义计算过程。训练参数的返回接口是为了给优化器传所有训练参数。神经网络层返回为了遍历各层神经网络得到各个神经网络层的参数。这里只列出了一些重要的方法,在自定义方法中,通常需要实现参数插入删除方法、神经网络层插入删除、神经网络模型信息等。
|
||||
:numref:`cell_abs`展示了设计神经网络层抽象方法的通用表示。通常在构造器会选择使用Python中collections模块的OrderedDict来初始化神经网络层和神经网络层参数的存储;它的输出是一个有序的,相比与Dict更适合深度学习这种模型堆叠的模式。参数和神经网络层的管理是在\_\_setattr\_\_中实现的,当检测到属性是属于神经网络层及神经网络层参数时就记录起来。神经网络模型比较重要的是计算连接过程,可以在\_\_call\_\_里重载,实现神经网络层时在这里定义计算过程。训练参数的返回接口给优化器传所有训练参数,这些参数是基类遍历了所有网络层后得到的。这里只列出了一些重要的方法,在自定义方法中,通常需要实现参数插入删除、神经网络层插入删除、神经网络模型信息返回等方法。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
@@ -123,7 +112,7 @@ Cell和Module是模型抽象方法也是所有网络的基类。
|
||||
|
||||
### 自定义神经网络层
|
||||
|
||||
2.3.1中使用伪代码定义机器学习库中低级API,有了实现的神经网络基类抽象方法,那么就可以设计更高层次的接口解决手动管理参数的繁琐。假设已经有了神经网络模型抽象方法Cell,构建Conv2D将继承Cell,并重构\_\_init\_\_和\_\_call\_\_方法,在\_\_init\_\_里初始化训练参数和输入参数,在\_\_call\_\_里调用低级API实现计算逻辑。同样使用伪代码接口描述自定义卷积层的过程。
|
||||
2.3.1中使用伪代码定义机器学习库中低级API,有了实现的神经网络基类抽象方法,那么就可以设计更高层次的接口解决手动管理参数的繁琐。假设已经有了神经网络模型抽象方法Cell,构建Conv2D将继承Cell,并重构\_\_init\_\_和\_\_call\_\_方法,在\_\_init\_\_里初始化训练参数和输入参数,在\_\_call\_\_里调用低级API实现计算逻辑。同样使用伪代码描述自定义卷积层的过程。
|
||||
|
||||
```python
|
||||
# 接口定义:
|
||||
@@ -153,7 +142,7 @@ conv = Conv2D(in_channel=10, out_channel=20, filter_size=3, stride=2, padding=0)
|
||||
output = conv(input)
|
||||
```
|
||||
|
||||
其执行过程为,在初始化Conv2D时,\_\_setattr\_\_会判断属性,属于Cell把神经网络层Conv2D记录到self.\_cells,filters属于parameter把参数记录到self.\_params。查看神经网络层参数使用conv.parameters_and_names;查看神经网络层列表使用conv.cells_and_names;执行操作使用conv(input)。
|
||||
在执行过程中,初始化Conv2D时,\_\_setattr\_\_会判断属性,属于Cell把神经网络层Conv2D记录到self.\_cells,属于parameter的filters记录到self.\_params。查看神经网络层参数使用conv.parameters_and_names;查看神经网络层列表使用conv.cells_and_names;执行操作使用conv(input)。
|
||||
|
||||
### 自定义神经网络模型
|
||||
|
||||
@@ -191,4 +180,4 @@ class CNN(Cell):
|
||||
net = CNN()
|
||||
```
|
||||
|
||||
上述卷积模型进行实例化,其执行将从\_\_init\_\_开始,第一个是Conv2D,Conv2D也是Cell的子类,会进入到Conv2D的\_\_init\_\_,此时会将第一个Conv2D的卷积参数收集到self.\_params,之后回到Conv2D,将第一个Conv2D收集到self.\_cells;第二个的组件是MaxPool2D,因为其没有训练参数,因此将MaxPool2D收集到self.\_cells;依次类推,分别收集第二个卷积参数和卷积层,三个全连接层的参数和全连接层。实例化之后可以调用net.parameters_and_names来返回训练参数;调用net.cells_and_names查看神经网络层列表。
|
||||
上述卷积模型进行实例化,其执行将从\_\_init\_\_开始,第一个是Conv2D,Conv2D也是Cell的子类,会进入到Conv2D的\_\_init\_\_,此时会将第一个Conv2D的卷积参数收集到self.\_params,之后回到Conv2D,将第一个Conv2D收集到self.\_cells;第二个的组件是MaxPool2D,因为其没有训练参数,因此将MaxPool2D收集到self.\_cells;依次类推,分别收集第二个卷积层的参数和层信息以及三个全连接层的参数和层信息。实例化之后可以调用net.parameters_and_names来返回训练参数;调用net.cells_and_names查看神经网络层列表。
|
||||
|
||||
@@ -11,7 +11,7 @@ Merlin是英伟达公司开发的一个开源推荐系统框架,帮助使用
|
||||
3. 推理服务:类似地,推理服务器在接到一批用户的推荐请求后,从参数服务器拉去相应的嵌入项和稠密神经网络参数来响应用户的请求。推荐系统的推理服务对延迟十分敏感,例如脸书公司的DLRM :cite:`naumov2019deep`基准在MLPerf评测中的服务器延迟限定在30ms[^1]。因此如何在限定延迟(latency-bounded)的情况下尽可能提升吞吐(throughput)是推理服务面临的关键问题。在GPU推理场景下,常见的优化手段有:请求动态合批处理、核融合、低精度部署等 :cite:`10.1145/3437801.3441578,wang-etal-2021-lightseq`. Triton提供了请求调度的功能并且支持多种不同的机器学习框架作为后端。
|
||||
|
||||
|
||||
在工业界,为了提升系统在发生故障的情况下的可用性,以上介绍的各个组件在实际中部署中都应该具备基本的容灾和故障恢复能力。以推理服务为例,在线服务中的深度学习推荐模型通常都采用多副本分布式部署。同一个模型的多个副本通常会被部署在至少两个不同的地理区域内的多个数据中心中,如 :numref:`ch10-recommendation-systems`,以应对大面积停电或者网络中断而导致整个地区的所有副本都不可用。除了容错方面的考虑,部署多个副本还有其他几点优势。首先,将模型部署在靠近用户的云服务器上可以提升响应速度。其次,部署多份副本也可以拓展模型推理服务的吞吐率。
|
||||
在工业界,为了提升系统在发生故障的情况下的可用性,以上介绍的各个组件在实际中部署中都应该具备基本的容灾和故障恢复能力。以推理服务为例,在线服务中的深度学习推荐模型通常都采用多副本分布式部署。同一个模型的多个副本通常会被部署在至少两个不同的地理区域内的多个数据中心中,如图 :numref:`ch10-recommendation-systems`所示,以应对大面积停电或者网络中断而导致整个地区的所有副本都不可用。除了容错方面的考虑,部署多个副本还有其他几点优势。首先,将模型部署在靠近用户的云服务器上可以提升响应速度。其次,部署多份副本也可以拓展模型推理服务的吞吐率。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
|
||||
@@ -20,5 +20,3 @@ Lyapunov来将安全性纳入考虑。
|
||||
|
||||
:label:`safe\_learning\_control`
|
||||
|
||||
:bibliography:`../mlsys.bib`
|
||||
|
||||
|
||||
113
chapter_rl_sys/control_code_ex.md
Normal file
@@ -0,0 +1,113 @@
|
||||
## 控制系统案例
|
||||
|
||||
在上一章节中,我们初步了解了机器人的控制系统,同时也知道了机器学习在机器人控制系统这个领域有着很多有趣和有前景的研究方向。
|
||||
只不过由于控制系统的复杂性和这些研究的前瞻性,它们不太适合用来作为简单的案例。
|
||||
|
||||
与此同时,ROS作为一个成熟的机器人框架,它已经包含了很多成熟稳定的经典控制组件。
|
||||
这些控制组件和其他的成熟的功能模块一起组成了更大规模的功能模组,来完成更复杂的任务。
|
||||
|
||||
在这些更大规模的功能模组中,**Nav2**和**MoveIt2**可能是最常用的两个。
|
||||
|
||||
从名字上就可以看出来,这两个功能模组各自都是它们ROS1版本的继承者。
|
||||
Nav2是ROS Navigation Stack在ROS2中的继承者,专注于移动机器人的导航相关的功能,例如定位,路径规划等,并致力于用安全的方式将机器人从一点移动到另一点。
|
||||
MoveIt2是ROS MoveIt在ROS2中的继承者,致力于打造一个容易使用的机器人操纵平台。带机械臂的机器人都基本离不开它。
|
||||
|
||||
这两个模组都成熟,可靠,和容易使用。使用ROS框架开发机器人是基本上都会直接使用它们或者在它们已有功能的基础上做适合自己的自定义修改,以避免重复造轮子。
|
||||
|
||||
因此,在本章节中,我们将以Nav2为案例,来带领大家初步了解怎样使用一个大型的ROS2功能模组。
|
||||
|
||||
本章节的内容很大程度参考了Nav2的[英文官方文档](https://navigation.ros.org/),尤其是“Getting Started”这一章。对自己英文有信心的读者可以尝试阅读官方文档以了解更多细节。
|
||||
|
||||
本章没有额外的代码案例。
|
||||
|
||||
### 安装
|
||||
|
||||
首先,让我们通过Ubuntu的库管理器来安装Nav2相关的程序库。
|
||||
|
||||
```shell
|
||||
sudo apt install ros-foxy-navigation2 ros-foxy-nav2-bringup
|
||||
```
|
||||
|
||||
其中`ros-foxy-navigation2`是Nav2的核心程序库,而`ros-foxy-nav2-bringup`则是Nav2的一个启动案例。
|
||||
这个案例十分灵活,很多时候我们可以将其稍加修改后放到自己的项目中使用。
|
||||
|
||||
接下来让我们安装`turtlebot3`相关的一系列程序库。
|
||||
turtlebot系列是一个很成功的入门级移动机器人系列。
|
||||
而这一系列程序库则提供了和turtlebot3机器人相关的组件,其中包含了在模拟环境中使用虚拟turtlebot3机器人的相关功能组件。
|
||||
|
||||
```shell
|
||||
sudo apt install "ros-foxy-turtlebot3*"
|
||||
```
|
||||
|
||||
### 运行
|
||||
|
||||
在安装好上面的那些程序库后,我们就可以尝试使用Nav2了。
|
||||
|
||||
首先,让我们新开一个终端窗口,并执行以下命令。这些命令分别导入了ROS2框架,并设定好了我们要使用哪个Turtlebot3模型和在哪儿搜索虚拟世界(Gazebo)需要的模型。
|
||||
|
||||
```shell
|
||||
source /opt/ros/foxy/setup.bash
|
||||
export TURTLEBOT3_MODEL=waffle
|
||||
export GAZEBO_MODEL_PATH=$GAZEBO_MODEL_PATH:/opt/ros/foxy/share/turtlebot3_gazebo/models
|
||||
```
|
||||
|
||||
现在,我们一切就绪,可以下面这行命令来运行一个Nav2的演示程序。
|
||||
|
||||
```shell
|
||||
ros2 launch nav2_bringup tb3_simulation_launch.py
|
||||
```
|
||||
|
||||
其中`ros2 launch`命令是用来执行一个launch文件,而后者则是将很多需要启动的ROS2组件集合到一起来按计划启动的一个说明文件。
|
||||
一个机器人项目经常需要启动很多个不同的组件来配合完成任务。
|
||||
而如果每个组件都要新开一个窗口执行命令的话,整个机器人的启动将会变得十分繁琐。
|
||||
launch文件和`ros2 launch`命令就是来解决这个问题的。
|
||||
我们可以把整个ROS2项目想象成一个交响乐团,其中每个组件分别代表一个乐器。
|
||||
而launch文件就像是乐团的指挥,负责调配每个乐器应该在什么时候启动。
|
||||
总而言之,这是ROS2中一个非常使用的特性。
|
||||
|
||||
关于`ros2 launch`命令和launch文件的更多细节,感兴趣的读者可以查阅[官方英文文档](https://docs.ros.org/en/foxy/Tutorials/Launch/Creating-Launch-Files.html)。
|
||||
|
||||
成功运行上述命令之后,我们应该会看到两个新开的GUI窗口,分别对应`RViz`和`Gazebo`程序。
|
||||
其中`RViz`是ROS2框架的可视化接口,我们稍后将通过它来控制我们的虚拟机器人。
|
||||
而`Gazebo`则是一个用过创建和运行虚拟世界的软件。
|
||||
它独立于ROS2框架,但两者又互相紧密合作。
|
||||
|
||||
在`Gazebo`窗口中(如下图所示),我们应该能够看到一个三维的类六边形虚拟世界。
|
||||
这个世界中还有一个虚拟的Turtlebot3机器人。
|
||||
这个机器人发射出很多蓝色的射线。
|
||||
这些射线代表了机器人的激光雷达的读数射线。
|
||||
而激光雷达的读数则被Nav2用来在环境中定位机器人。
|
||||
|
||||

|
||||
|
||||
在`RViz`窗口中(如下图所示),我们应该能够看到虚拟世界的一个二维地图。
|
||||
地图上的白色部分是机器人可以到达的部分,而黑色则是检测到的障碍物或墙。
|
||||
如果你在左侧看到有红色的`Global Status: Error`错误的话,你的机器人并没有在RViz(即ROS2框架)中正确的定位。
|
||||
请在工具栏选择`2D Pose Estimate`并在RViz地图上机器人应该在的位置(以Gazebo中机器人的位置为准)更新好机器人的姿态。
|
||||
|
||||

|
||||
|
||||
更新好机器人的姿态后,RViz应该和下图比较相似。
|
||||
|
||||

|
||||
|
||||
这样一来,我们的机器人就准备好在虚拟事件中移动了。
|
||||
|
||||
请在RViz的工具栏中选择`Navigation2 Goal`按钮,并在地图上选择你想要Turtlebot3机器人最终所到达的位置和姿态。
|
||||
一旦选好了,你将会看到机器人开始向目标位置移动并最终到达目标。
|
||||
|
||||
RViz还提供了很多其它的Nav2功能的按钮,你可以通过Nav2和ROS2的官方英文文档来了解更多使用方法。
|
||||
|
||||
恭喜,你现在初步了解了怎样使用ROS2框架内的大型功能模组!
|
||||
|
||||
#### 章节附录:在WSL中使用Nav2
|
||||
|
||||
有些读者可能是通过Windows下的WSL(Windows Subsystem for Linux)来运行ROS2的。
|
||||
如果是这种情况,这一章节中的图形界面程序,如RViz和Gazebo,可能会造成问题。
|
||||
这是因为WSL默认并不能打开图形界面程序。
|
||||
|
||||
幸运的是,我们可以更改设置来达到在WSL中运行图形界面程序这一点。
|
||||
[这篇笔记](https://github.com/rhaschke/lecture/wiki/WSL-install)介绍了其作者是如何在WSL中运行ROS2和图形界面的。其中第二点尤为值得注意。
|
||||
而[这篇笔记](https://github.com/cascadium/wsl-windows-toolbar-launcher#firewall-rules)则更为细致的介绍了在一般情况下怎样在WSL中运行图形界面程序。
|
||||
|
||||
这两篇笔记应该可以给读者足够的信息来解决上述所说的和RViz还有Gazebo相关的问题。唯一的缺点就是这两篇笔记都是英文的,对读者的英语水平有一定要求。
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
在本章中,我们介绍机器学习的一个重要分支——机器人及其在系统方面的知识。本章的学习目标包括:
|
||||
|
||||
- 掌握机器人学习基本知识。
|
||||
- 掌握机器人系统基本知识。
|
||||
|
||||
- 掌握通用机器人操作系统。
|
||||
|
||||
@@ -13,8 +13,13 @@
|
||||
|
||||
rl_sys_intro
|
||||
ros
|
||||
ros_code_ex
|
||||
perception
|
||||
perception_code_ex
|
||||
planning
|
||||
planning_code_ex
|
||||
control
|
||||
control_code_ex
|
||||
robot_safety
|
||||
summary
|
||||
```
|
||||
|
||||
212
chapter_rl_sys/perception_code_ex.md
Normal file
@@ -0,0 +1,212 @@
|
||||
## 感知系统案例
|
||||
|
||||
在之前[机器人操作系统(ROS)的入门案例](./ros_code_ex.md)这一章节中,我们学习了怎样创建一个ROS2项目以及怎样使用ROS2框架下的节点,服务,动作等。然后,我们又在上一章节中初步了解了机器人的感知系统。
|
||||
在这一章节中,我们将通过一个简单的案例来演示怎样结合ROS2和深度学习框架PyTorch来完成一个我们设想的感知系统中的一个基本功能。
|
||||
|
||||
### 案例背景
|
||||
|
||||
假设我们想要帮某果园设计一款全自动摘菠萝机器人。
|
||||
这个机器人可能需要有一个智能移动底盘来负责在果园中移动,若干传感器(包括一个RGB摄像头)来检测菠萝,以及一个机械臂来负责摘取动作。
|
||||
在这个机器人需要完成的一长列各种功能中,它的感知系统必然需要能检测摄像头传感器的画面中央是否有一个菠萝。
|
||||
检测到了菠萝才会进入到摘取的环节。
|
||||
|
||||
这个检测在图像中央是否有菠萝存在的功能,就是我们机器人的感知系统中基础但必要的一个基本功能。
|
||||
幸运的是,随着现代卷积神经网络的发展,我们可以利用已存在的深度学习框架,如PyTorch,来快速的完成这个功能。
|
||||
而且一个简单的使用ImageNet进行预训练的AlexNet就以及足够了。
|
||||
|
||||
在之前的案例中,我们都是使用的ROS2框架下的程序库。在这个例子中,我们将开始了解如何在ROS2中使用框架外的Python库。
|
||||
|
||||
和之前的案例类似,本章节的案例所使用的代码可以在本书相关的[ROS2案例代码库](https://github.com/openmlsys/openmlsys-ros2)中的`src/object_detector`文件夹内找到。
|
||||
|
||||
### 项目搭建
|
||||
|
||||
让我们沿用之前已经搭建好的ROS2项目框架。
|
||||
我们只需在其中增加一个ROS2的Python库来实现我们想要的功能即可。
|
||||
因此,让我们回到`src`目录下并创建此Python库。
|
||||
|
||||
```shell
|
||||
cd openmlsys-ros2/src
|
||||
ros2 pkg create --build-type ament_python --node-name object_detector_node object_detector --dependencies rclpy std_msgs sensor_msgs cv_bridge opencv-python torch torchvision torchaudio
|
||||
```
|
||||
|
||||
在创建好Python库后,别忘了将`package.xml`和`setup.py`中的`version`,`maintainer`,`maintainer_email`,`description`和`license`项都更新好。
|
||||
|
||||
紧接着,我们需要安装ROS2框架下的`image_publisher`库。
|
||||
这个库能帮助我们将一张图片模拟成像摄像头视频一样的图片流。
|
||||
在开发真是机器人时,我们可能可以在实机上检测我们的程序,但是对于这个案例,我们只能使用这个`image_publisher`库和若干选择好的图片来测试我们的程序。
|
||||
实际上,就算是开发实际机器人的功能,也最好在实际测试之前使用图片来做这个功能的单元测试。
|
||||
|
||||
我们只需通过ubuntu的`apt`来安装这个`image_publisher`库,因为作为一个常用的ROS2框架下的程序库,它已经被打包好以便通过ubuntu的包管理器来安装。
|
||||
|
||||
```shell
|
||||
sudo apt install ros-foxy-image-publisher
|
||||
```
|
||||
|
||||
关于`image_publisher`这个库更多的信息和使用方法,可以查看[它的文档](http://wiki.ros.org/image_publisher)。这是个针对早期ROS1版本的文档,但是因为这个库之后没有任何变化,文档中所有的功能都和我们所使用的ROS2版本中的一样。
|
||||
|
||||
接下来,让我们在ROS2项目的Python虚拟环境中安装`opencv-python`,`torch`,`torchvision`和`torchaudio`。
|
||||
例如使用`pipenv`的用户可能会执行`pipenv install opencv-python torch torchvision torchaudio`这条命令。
|
||||
|
||||
最后,让我们把下面这两张菠萝和苹果的图片保存在`openmlsys-ros2/data`下。
|
||||
我们将使用这两张图片来检测我们的程序可以检测到菠萝并且不会把菠萝当成苹果。
|
||||
|
||||

|
||||
|
||||
:width:`256px`
|
||||
|
||||
:label:`ros2-pineapple`
|
||||
|
||||

|
||||
|
||||
:width:`256px`
|
||||
|
||||
:label:`ros2-apple`
|
||||
|
||||
### 添加代码
|
||||
|
||||
之前创建Python库的命令应该已经帮我们创建好了`src/object_detector/object_detector/object_detector_node.py`这个文件。现在让我们用以下内容来替换掉此文件中已有的内容。
|
||||
|
||||
```Python
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
|
||||
from std_msgs.msg import Bool
|
||||
from sensor_msgs.msg import Image
|
||||
import cv2
|
||||
from cv_bridge import CvBridge
|
||||
|
||||
import torch
|
||||
import torchvision.models as models
|
||||
from torchvision import transforms
|
||||
|
||||
|
||||
class ObjectDetectorNode(Node):
|
||||
|
||||
PINEAPPLE_CLASS_ID = 953
|
||||
|
||||
def __init__(self):
|
||||
super().__init__('object_detector_node')
|
||||
self.detection_publisher = self.create_publisher(Bool, 'object_detected', 10)
|
||||
self.camera_subscriber = self.create_subscription(
|
||||
Image, 'camera_topic', self.camera_callback, 10,
|
||||
)
|
||||
self.alex_net = models.alexnet(pretrained=True)
|
||||
self.alex_net.eval()
|
||||
self.preprocess = transforms.Compose([
|
||||
transforms.ToPILImage(),
|
||||
transforms.Resize(256),
|
||||
transforms.CenterCrop(224),
|
||||
transforms.ToTensor(),
|
||||
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
|
||||
])
|
||||
self.cv_bridge = CvBridge()
|
||||
self.declare_parameter('detection_class_id', self.PINEAPPLE_CLASS_ID)
|
||||
self.get_logger().info(f'Detector node is ready.')
|
||||
|
||||
def camera_callback(self, msg: Image):
|
||||
self.get_logger().info(f'Received an image, ready to detect!')
|
||||
detection_class_id = self.get_parameter('detection_class_id').get_parameter_value().integer_value
|
||||
img = self.cv_bridge.imgmsg_to_cv2(msg)
|
||||
input_batch = self.preprocess(img).unsqueeze(0)
|
||||
img_output = self.alex_net(input_batch)[0]
|
||||
detection = Bool()
|
||||
detection.data = torch.argmax(img_output).item() == detection_class_id
|
||||
self.detection_publisher.publish(detection)
|
||||
self.get_logger().info(f'Detected: "{detection.data}", target class id: {detection_class_id}')
|
||||
|
||||
|
||||
def main(args=None):
|
||||
rclpy.init(args=args)
|
||||
object_detector_node = ObjectDetectorNode()
|
||||
rclpy.spin(object_detector_node)
|
||||
object_detector_node.destroy_node()
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
```
|
||||
|
||||
可能有些细心的读者已经发现了,这段代码和我们之前创建ROS2节点的代码非常像。
|
||||
实际上这段代码就是创建了一个新的节点类来完成我们的功能。
|
||||
|
||||
这个节点类的实例将被赋予名字`object_detector_node`,同时它将订阅`camera_topic`这个主题并发布`Bool`类型的信息至`object_detected`主题。
|
||||
其中,`camera_topic`主题的内容是机器人的摄像头传感器所接收到的视频流,而我们将用`image_publisher`库和之前的图片来模拟这个视频流。
|
||||
而`object_detected`主题则将包含我们的检测结果以供机器人逻辑链的后续节点使用。
|
||||
如果我们检测到菠萝,则我们将发布`True`信息,否则就发布`False`信息。
|
||||
|
||||
下面,让我们关注这个新节点类中的一些新细节。
|
||||
|
||||
首先,我们引入了`cv_bridge.CvBridge`这个类。
|
||||
这个类是ROS2框架内的一个功能类,主要帮我们把图片在`opencv`/`numpy`格式和ROS2自己的`sensor_msgs.msg.Image`信息格式之间进行转换。
|
||||
在我们新的节点类中我们可以看到它的具体用法(即`self.cv_bridge = CvBridge()`和`img = self.cv_bridge.imgmsg_to_cv2(msg)`)。
|
||||
|
||||
然后,在新的节点类`ObjectDetectorNode`中,我们使用了`PINEAPPLE_CLASS_ID`这个类成员变量来保存我们想要识别的物体在ImageNet中的类别ID(class id)。这里`953`是菠萝在ImageNet中的具体类别ID。
|
||||
|
||||
再之后,我们通过PyTorch实例化了一个预训练好的AlexNet,并将其设置到`eval`状态。
|
||||
同时,我们声明了`detection_class_id`这个参数,以方便再运行时修改需要识别物体的类别ID(虽然这并不常用)。
|
||||
|
||||
最后,在`camera_topic`主题的回调函数`camera_callback`中,我们将收到的`Image`类型信息传换成`numpy`格式,然后调用AlexNet来进行物体识别,最后将识别结果以`Bool`的形式发布到`object_detected`主题上去,并进行日志记录。
|
||||
|
||||
至此,一个使用PyTorch和AlexNet来识别摄像头中是否有菠萝的节点类就完成了。
|
||||
|
||||
### 运行及检测
|
||||
|
||||
下面,让我们尝试运行我们新写好的节点类并用菠萝和苹果的图片来检测这个节点类是否运行正常。
|
||||
|
||||
首先,让我们编译这个新写的Python库。
|
||||
|
||||
```shell
|
||||
cd openmlsys-ros2
|
||||
colcon build --symlink-install
|
||||
```
|
||||
|
||||
在成功编译之后,我们可以新开一个终端窗口并执行下面的命令来运行一个节点类实例。
|
||||
记住,你可能需要先运行`source install/local_setup.zsh`来引入我们自己的ROS2项目。
|
||||
|
||||
```shell
|
||||
ros2 run object_detector object_detector_node --ros-args -r camera_topic:=image_raw
|
||||
```
|
||||
|
||||
如果你遇到`ModuleNotFoundError: No module named 'cv2'`这之类的问题,则代表ROS2命令没有成功识别你Python虚拟环境中的程序库。这时候你可以尝试进入你所用的虚拟环境后执行下面这个命令。
|
||||
|
||||
```shell
|
||||
PYTHONPATH="$(dirname $(which python))/../lib/python3.8/site-packages:$PYTHONPATH" ros2 run object_detector object_detector_node --ros-args -r camera_topic:=image_raw
|
||||
```
|
||||
|
||||
这个命令前面`PYTHONPATH="$(dirname $(which python))/../lib/python3.8/site-packages:$PYTHONPATH"`这一串的作用是将你目前Python环境的库添加到`PYTHONPATH`,这样ROS2命令的Python就可以找到你目前Python环境(即ROS2项目所对应的Python虚拟环境)中的Python库了。
|
||||
|
||||
当这个ROS2命令成功运行时,你应该能看到这行信息:`[INFO] [1655172977.491378700] [object_detector_node]: Detector node is ready.`。
|
||||
|
||||
另外,在这个ROS2命令中,我们使用了`--ros-args -r camera_topic:=image_raw`这一系列参数。
|
||||
这些参数是用来告诉ROS2将我们新节点类所使用的`camera_topic`主题重映射(remap)到`image_raw`这个主题上。
|
||||
这样一来,我们新节点类所有使用`camera_topic`主题的场合实际上都是在使用`image_raw`这个主题。
|
||||
使用主题名字重映射的好处是在于解耦合。
|
||||
对于每个新的ROS2程序库或者每个新的节点类,我们都可以自由的命名我们要使用的主题的名字,然后当它需要和其它组件组合起来发挥作用时,只需要使用重映射将两个不同组件所使用的不同主题名字连接起来,就可以达到数据在两个组件之间正常流通的效果。
|
||||
这实际上是ROS2框架的一个很实用的特性。
|
||||
|
||||
如果你想更深入的了解重映射相关的细节,可以阅读[这篇官方介绍](https://design.ros2.org/articles/static_remapping.html)。
|
||||
|
||||
在我们成功运行新节点后,让我们在一个新终端窗口中运行下面这行命令来测试它是否能检测到菠萝。同样的,你可能需要先运行`source install/local_setup.zsh`来引入我们自己的ROS2项目。
|
||||
|
||||
```shell
|
||||
PYTHONPATH="$(dirname $(which python))/../lib/python3.8/site-packages:$PYTHONPATH" ros2 run image_publisher image_publisher_node data/ros-pineapple.jpg --ros-args -p publish_rate:=1.0
|
||||
```
|
||||
|
||||
上面这行命令将会使用`image_publisher`库和它的节点来以1Hz的频率将之前准备好的菠萝图片发布到`image_raw`这个主题上去。
|
||||
当这个`image_publisher_node`节点成功运行后,我们应当能在`object_detector_node`节点运行的终端窗口中看到类似`[INFO] [1655174212.930385900] [object_detector_node]: Detected: "True", target class id: 953`这样的信息来证明我们的节点类能够检测到菠萝。
|
||||
|
||||
接着让我们在`image_publisher_node`节点的窗口中使用`Ctrl+C`结束掉节点,然后使用下面这行命令来发布准备好的苹果图片。
|
||||
|
||||
```shell
|
||||
PYTHONPATH="$(dirname $(which python))/../lib/python3.8/site-packages:$PYTHONPATH" ros2 run image_publisher image_publisher_node data/ros-apple.jpg --ros-args -p publish_rate:=1.0
|
||||
```
|
||||
|
||||
现在,在`object_detector_node`节点运行的终端窗口中我们应该看到的是类似`[INFO] [1655171989.912783400] [object_detector_node]: Detected: "False", target class id: 953`这样的信息来证明我们的节点类不会把苹果识别成菠萝。
|
||||
|
||||
### 小结
|
||||
|
||||
恭喜,你已经成功了解如何在ROS2项目中使用ROS2框架外的Python库了!
|
||||
如果你使用Python虚拟环境,你可能需要额外的设置`PYTHONPATH`环境变量。
|
||||
另外,主题名字重映射(Name Remapping)是一个很有用的ROS2特性。
|
||||
你在以后的项目中很可能会经常用到它。
|
||||
208
chapter_rl_sys/planning_code_ex.md
Normal file
@@ -0,0 +1,208 @@
|
||||
## 规划系统案例
|
||||
|
||||
在上一章节中,我们初步了解了机器人的规划系统。
|
||||
这一章节中,我们将通过一个简单的案例来演示怎样结合ROS2和机器学习框架scikit-learn来完成一个我们设想的规划系统中的一个基本功能。
|
||||
我们将使用和[感知系统案例](./perception_code_ex.md)这一章节类似的方法和结构来讲解本章节。
|
||||
|
||||
### 案例背景
|
||||
|
||||
假设我们想要帮某花园设计一款打理鸢尾花的园丁机器人。
|
||||
很“碰巧”的是,这个小花园里面正好只有经典的[鸢尾花数据集](https://scikit-learn.org/stable/auto_examples/datasets/plot_iris_dataset.html)中的那三种鸢尾花,而且已经有人帮我们完成了一个“魔术般的”ROS2感知组件来自动的检测目标鸢尾花的花萼长和宽以及花瓣长和宽(Sepal Length, Sepal Width, Petal Length and Petal Width:鸢尾花数据集所需要的4个输入维度)。
|
||||
同时因为机器人的性能限制,我们不能使用比较复杂的模型(例如神经网络)。
|
||||
这种情况下,我们可以尝试使用经典的机器学习模型,例如决策树,来接受感知组件的结果并识别鸢尾花的类别,然后用一个映射表(mapping table)来查找出我们应该为机器人规划怎样的行为去执行。
|
||||
当季节或情况改变时,花园的技术团队可以更新映射表来更改机器人的规划系统逻辑。
|
||||
|
||||
当然,上面的案例背景和解决方案都是为了生成一个简单的案例而设计的“非现实”的例子。
|
||||
大家在现实项目中遇到的案例应该会复杂的多。
|
||||
不过,我们任然希望这样一个简单的案例可以为大家带来些许价值。
|
||||
|
||||
让我们回到我们刚刚介绍的解决方案中。
|
||||
在之前的感知系统的案例中,我们选择使用ROS2节点类来处理感知任务。
|
||||
这是因为机器人会不断的接收到传感器的信号,而我们希望尽可能多的处理收到的信号。
|
||||
而对于我们这一章节的案例来说,因为我们不一定需要不间断的进行新的规划,同时每一次规划我们都期待有一个结果,所以使用ROS2服务可能会是一个更好的选择。
|
||||
|
||||
和之前的案例类似,本章节的案例所使用的代码可以在本书相关的[ROS2案例代码库](https://github.com/openmlsys/openmlsys-ros2)中的`src/action_decider`文件夹内找到。
|
||||
|
||||
### 项目搭建
|
||||
|
||||
让我们继续沿用之前已经搭建好的ROS2项目框架。
|
||||
和感知系统案例类似,我们只需在其中增加一个ROS2的Python库来实现我们想要的功能即可。
|
||||
因此,让我们回到`src`目录下并创建此Python库。
|
||||
|
||||
```shell
|
||||
cd openmlsys-ros2/src
|
||||
ros2 pkg create --build-type ament_python --node-name action_decider_node action_decider --dependencies rclpy std_msgs scikit-learn my_interfaces
|
||||
```
|
||||
|
||||
我们将`my_interfaces`添加为依赖项是因为我们需要为新的ROS2服务创建对应的消息类型接口。
|
||||
|
||||
在创建好Python库后,别忘了将`package.xml`和`setup.py`中的`version`,`maintainer`,`maintainer_email`,`description`和`license`项都更新好。
|
||||
|
||||
接下来,让我们在ROS2项目的Python虚拟环境中安装`scikit-learn`。
|
||||
例如使用`pipenv`的用户可能会执行`pipenv install scikit-learn`这条命令。
|
||||
|
||||
### 添加消息类型接口
|
||||
|
||||
我们将要编写的新ROS2服务需要有它自己的服务消息接口。
|
||||
让我们借用已有的`my_interfaces`库来放置这个新接口。
|
||||
|
||||
首先,让我们在`openmlsys-ros2/src/my_interfaces/srv`中新建一个名为`IrisData.srv`的文件并用下面的内容填充它。
|
||||
|
||||
```text
|
||||
float32 sepal_length
|
||||
float32 sepal_width
|
||||
float32 petal_length
|
||||
float32 petal_width
|
||||
---
|
||||
string action
|
||||
```
|
||||
|
||||
我们可以看到,新的ROS2服务将会接受4个浮点值作为输入。
|
||||
这4个浮点值分别为鸢尾花的花萼的长和宽还有花瓣的长和宽。
|
||||
当规划完成后,服务会返回一个字符串。
|
||||
这个字符串将会是机器人需要执行的动作的名称。
|
||||
|
||||
我们还需要在`my_interfaces`库的`CMakeLists.txt`文件中的相应位置(`rosidl_generate_interfaces`函数的参数部分)添加一行新的内容:
|
||||
|
||||
```cmake
|
||||
"srv/IrisData.srv"
|
||||
```
|
||||
|
||||
最后,别忘了在ROS2项目的根目录下执行`colcon build --packages-select my_interfaces`来重新编译`my_interfaces`这个库。
|
||||
|
||||
### 添加代码
|
||||
|
||||
之前创建Python库的命令应该已经帮我们创建好了`src/action_decider/action_decider/action_decider_node.py`这个文件。现在让我们用以下内容来替换掉此文件中已有的内容。
|
||||
|
||||
```Python
|
||||
import os
|
||||
import pickle
|
||||
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
|
||||
from std_msgs.msg import String
|
||||
from my_interfaces.srv import IrisData
|
||||
|
||||
from sklearn.datasets import load_iris
|
||||
from sklearn import tree
|
||||
|
||||
|
||||
def main(args=None):
|
||||
rclpy.init(args=args)
|
||||
action_decider_service = ActionDeciderService()
|
||||
rclpy.spin(action_decider_service)
|
||||
action_decider_service.destroy_node()
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
class ActionDeciderService(Node):
|
||||
|
||||
IRIS_CLASSES = ['setosa', 'versicolor', 'virginica']
|
||||
|
||||
IRIS_ACTION_MAP = {
|
||||
'setosa': 'fertilise',
|
||||
'versicolor': 'idle',
|
||||
'virginica': 'prune',
|
||||
}
|
||||
|
||||
DEFAULT_MODEL_PATH = f'{os.path.dirname(__file__)}/../../../data/iris_model.pickle'
|
||||
|
||||
def get_iris_classifier(self, model_path):
|
||||
if os.path.isfile(model_path):
|
||||
with open(model_path, 'rb') as model_file:
|
||||
return pickle.load(model_file)
|
||||
self.get_logger().info(f"Cannot find trained model at '{model_path}', will train a new model.")
|
||||
iris = load_iris()
|
||||
X, y = iris.data, iris.target
|
||||
clf = tree.DecisionTreeClassifier()
|
||||
clf = clf.fit(X, y)
|
||||
with open(model_path, 'wb') as model_file:
|
||||
pickle.dump(clf, model_file)
|
||||
return clf
|
||||
|
||||
def __init__(self):
|
||||
super().__init__('iris_action_decider_service')
|
||||
self.srv = self.create_service(IrisData, 'iris_action_decider', self.decide_iris_action_callback)
|
||||
self.iris_classifier = self.get_iris_classifier(self.DEFAULT_MODEL_PATH)
|
||||
self.get_logger().info('Iris action decider service is ready.')
|
||||
|
||||
def decide_iris_action_callback(self, request, response):
|
||||
iris_data = [request.sepal_length, request.sepal_width, request.petal_length, request.petal_width]
|
||||
iris_class_idx = self.iris_classifier.predict([iris_data])[0]
|
||||
iris_class = self.IRIS_CLASSES[iris_class_idx]
|
||||
response.action = self.IRIS_ACTION_MAP[iris_class]
|
||||
self.get_logger().info(
|
||||
f'Incoming request\nsepal_length: {request.sepal_length}\nsepal_width: {request.sepal_width}'
|
||||
f'\npetal_length: {request.petal_length}\npetal_width: {request.petal_width}'
|
||||
f'\niris class: {iris_class}'
|
||||
f'\ndecided action: {response.action}'
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
```
|
||||
|
||||
细心的读者可能已经发现了,这段代码和我们之前创建的使用ROS2服务的服务端节点类的代码非常像。
|
||||
实际上这段代码就是使用了同样的服务端节点类框架和一个新的服务来完成我们想要的功能。
|
||||
|
||||
这个服务端节点类的实例将被赋予名字`iris_action_decider_service`,它将提供一个名为`iris_action_decider`的服务并且这个服务期待`IrisData`格式的服务请求(即我们之前定义的消息类型接口的请求部分)。
|
||||
当服务计算完成后,它将把结果返回给请求发起方。
|
||||
这个结果是规划好的行为的名字并被封装到`IrisData`格式的服务结果中去(即我们之前定义的消息类型接口的结果部分)。
|
||||
|
||||
下面,让我们关注这个新节点类中的一些新细节。
|
||||
|
||||
首先,我们在新的服务端节点类`ActionDeciderService`中声明了三个类成员变量`IRIS_CLASSES`,`IRIS_ACTION_MAP`和`DEFAULT_MODEL_PATH`。
|
||||
它们分别表示鸢尾花的类别标签,鸢尾花类别至机器人行动名称的映射表,和默认存放训练好的决策树模型的路径。
|
||||
|
||||
当我们的服务端节点类初始化时,它将调用`get_iris_classifier()`来读取训练好的决策树模型。
|
||||
如果模型文件缺失,则会重新训练一个模型并保存。
|
||||
这里我们把训练模型的代码放到了同一个节点内。
|
||||
实际上,对于大型项目或大型模型,我们可以把模型训练和模型使用分开到不同的组件中去,并且它们可能在不同的时机运行。
|
||||
|
||||
当服务的回调函数`decide_iris_action_callback()`被调用时,服务将会使用训练好的模型和接收到的鸢尾花信息来预测鸢尾花的类别,然后通过查找映射表来决定机器人需要执行的动作。最后服务返回结果并进行日志记录。
|
||||
|
||||
至此,一个使用scikit-learn和决策树的简易“玩具级”规划组件就完成了。
|
||||
|
||||
### 运行及检测
|
||||
|
||||
下面,让我们尝试运行新写好的服务端节点类并检测它是否能正常运行。
|
||||
|
||||
首先,让我们编译这个新写的Python库。
|
||||
|
||||
```shell
|
||||
cd openmlsys-ros2
|
||||
colcon build --symlink-install
|
||||
```
|
||||
|
||||
在成功编译之后,我们可以新开一个终端窗口并执行下面的命令来运行一个节点类实例。
|
||||
记住,你可能需要先运行`source install/local_setup.zsh`来引入我们自己的ROS2项目。
|
||||
|
||||
```shell
|
||||
ros2 run action_decider action_decider_node
|
||||
```
|
||||
|
||||
如果你使用了Python虚拟环境,则可以尝试下面这条命令,而不是上面那条。背后具体的原因已在之前的案例章节叙述过。
|
||||
|
||||
```shell
|
||||
PYTHONPATH="$(dirname $(which python))/../lib/python3.8/site-packages:$PYTHONPATH" ros2 run action_decider action_decider_node
|
||||
```
|
||||
|
||||
当这个ROS2命令成功运行时,你应该能看到这行信息:`[INFO] [1655253519.693893500] [iris_action_decider_service]: Iris action decider service is ready.`。
|
||||
|
||||
在我们成功运行新的服务端节点后,让我们在一个新终端窗口中运行下面这行命令来测试新的服务是否能正常运行。同样的,你可能需要先运行`source install/local_setup.zsh`来引入我们自己的ROS2项目。
|
||||
|
||||
```shell
|
||||
ros2 service call /iris_action_decider my_interfaces/srv/IrisData "{sepal_length: 1.0, sepal_width: 2.0, petal_length: 3.0, petal_width: 4.0}"
|
||||
```
|
||||
|
||||
这里,我们用的`ros2 service call`命令是专门用来通过命令行调用一个ROS2服务的命令。其中服务请求的数据应该是字符串化的YAML格式数据。这个命令更多的信息可以通过`ros2 service call -h`来查阅。
|
||||
|
||||
一切顺利的话,执行完命令后不久,你应该就能在新窗口中很快看到类似这样的信息了:`response: my_interfaces.srv.IrisData_Response(action='prune')`。
|
||||
|
||||
### 小结
|
||||
|
||||
恭喜,你已经成功了解如何在ROS2项目中使用scikit-learn这样库并训练一个模型了!
|
||||
@@ -5,8 +5,7 @@
|
||||
近些年,随着机器学习的兴起,经典机器人技术出现和机器学习技术结合的趋势,称为机器人学习(Robot
|
||||
Learning)。机器人学习包含了计算机视觉、自然语言处理、语音处理、强化学习和模仿学习等人工智能技术在机器人上的应用,让机器人通过学习,自主地执行各种决策控制任务。
|
||||
|
||||
机器人学习系统(Robot Learning
|
||||
System)是一个较新的概念。作为系统和机器人学习的交叉方向,仿照机器学习系统的概念,我们把机器人学习系统定义为"支持机器人模型训练和部署的系统"。按照涉及的机器人数量,可以划分为单机器人学习系统和多机器人学习系统。多机器人学习系统协作和沟通中涉及的安全和隐私问题,也会是一个值得研究的方向。最近机器人学习系统在室内自主移动 :cite:`9123682,huang2018navigationnet`,道路自动驾驶 :cite:`pmlr-v155-huang21a,pmlr-v155-sun21a,Sun2022SelfSupervisedTA`,机械臂工业操作等行业场景得到充分应用和发展。一些机器人学习基础设施项目也在进行中,如具备从公开可用的互联网资源、计算机模拟和
|
||||
机器人系统按照涉及的机器人数量,可以划分为单机器人系统和多机器人系统。多机器人系统协作和沟通中涉及的安全和隐私问题,也会是一个值得研究的方向。最近机器人系统在室内自主移动 :cite:`9123682,huang2018navigationnet`,道路自动驾驶 :cite:`pmlr-v155-huang21a,pmlr-v155-sun21a,Sun2022SelfSupervisedTA`,机械臂工业操作等行业场景得到充分应用和发展。一些机器人基础设施项目也在进行中,如具备从公开可用的互联网资源、计算机模拟和
|
||||
真实机器人试验中学习能力的大规模的计算系统RobotBrain。在自动驾驶领域,受联网的自动驾驶汽车
|
||||
(CAV) 对传统交通运输行业的影响,"车辆计算"(Vehicle Computing)
|
||||
(如 :numref:`vehicle-computing`)概念引起广泛关注,并激发了如何让计算能力有限使用周围的CAV计算平台来执行复杂的计算任务的研究。最近,有很多自动驾驶系统的模拟器,代表性的比如CARLA,支持安全RL、MARL、真实地图数据导入、泛化性测试等任务的MetaDrive :cite:`li2021metadrive`,还有CarSim和
|
||||
|
||||
70
chapter_rl_sys/robot_safety.md
Normal file
@@ -0,0 +1,70 @@
|
||||
## 在机器人项目中安全的应用机器学习
|
||||
|
||||
机器人和机器学习都是有广阔前景和令人兴奋的前沿领域,而当它们结合在一起后,会变得更加迷人,并且有远大于1+1>2的效果。
|
||||
因此,当我们在机器人项目中应用机器学习时,我们很容易过于兴奋,尝试着用机器学习去做很多之前只能幻想的成果。
|
||||
然而,在机器人中应用机器学习和直接使用机器学习有着很多不同。
|
||||
其中很重要的一点不同就是,一般的机器学习系统更多的是在虚拟世界中造成直接影响,而机器人中的机器学习系统很容易通过机器人对物理世界造成直接影响。
|
||||
因此,**当我们在机器人项目中应用机器学习时,我们必须时刻关注系统的安全性**,保证无论是在产品开发时还是在产品上市后的使用期,开发者和用户的安全性都能得到可靠的保证。
|
||||
而且不仅商业项目要考虑安全性,开发个人项目是也需要确保安全性。
|
||||
没有人想因为安全性上的疏忽而对自己或朋友/同事造成无法挽回的遗憾。
|
||||
|
||||
以上这些并不是危言耸听,让我们设想以下这些情况。
|
||||
|
||||
假设你正在为你们公司开发一个物流仓库内使用的移动货运机器人,它被设计为和工人在同一工作环境内运行,以便在需要时及时帮工人搬运货物至目的地。
|
||||
这个机器人有一个视觉的行人识别系统,以便识别前方是否有人。
|
||||
当机器人在前进的过程中遇到障碍物的话,这个行人识别系统会参与决定机器人的行为。
|
||||
如果有人的话,机器人会选择绕大弯来避开行进道路上的行人障碍物;而如果没人的话,机器人可以绕小弯来避障。
|
||||
可是,如果某次这个行人识别系统检测失误,系统没有检测到前方的障碍物是一个正在梯子上整理货物的工人,所以选择小弯避障。
|
||||
而当机器人靠近时,工人才突然发现有个机器人正在靠近他,并因此受到惊吓跌落至机器人行进的正前方。如果我们考虑到物流仓库的货运机器人自重加载重一般至少是几百公斤,我们就知道万一真的因此发生碰撞,后果是不堪设想。
|
||||
如果真的发生这种情况,这个机器人产品的商业前景会毁于一旦,公司和负责人也会被追究相应责任(甚至法律意义上的责任)。更重要的是,对受害者所造成的伤害和自己心里的内疚会对双方的一生都造成严重的影响。
|
||||
|
||||
不仅是商业项目,假设你正在开发一个小型娱乐机械臂来尝试帮你完成桌面上的一些小任务,例如移动茶杯或打开关闭开关。
|
||||
你的这个机械臂也依赖于一个物体识别系统来识别任务目标。
|
||||
某次在移动茶杯时,机械臂没有识别到规划路线中有一个接线板,因此茶杯不小心摔倒并且水泼到接线板里引起短路。
|
||||
幸运的话可能只需要换一个接线板,而不幸的时候甚至可能会引起火灾或电击。
|
||||
我相信,没有人会想遇到这类突发事件。
|
||||
|
||||
因此,无论是在怎样的机器人项目中应用机器学习,我们都必须时刻关注和确保系统的安全性。
|
||||
|
||||
### 确保安全性的办法:谨慎的风险评估和独立的安全系统
|
||||
|
||||
#### 谨慎的风险评估
|
||||
|
||||
为了能够确保机器人和机器学习系统的安全性,我们首先要知道可能有哪些危险。
|
||||
我们可以通过风险评估(risk assessment)来做到这一点。
|
||||
|
||||
怎样完成一份风险评估网上已经有很多文章了,我们在这里就不过多的介绍。
|
||||
我们想要强调的是,对于发现的风险,我们需要尽可能的给出一个避免风险的方案(risk mitigation)。
|
||||
更重要的时,我们需要确保这些方案的具体执行,而不仅仅是流于表面的给出方案就完事。
|
||||
一份没有执行的方案等于没有方案。
|
||||
|
||||
#### 独立的安全系统
|
||||
|
||||
在了解了可能有哪些风险之后,我们可以通过设计一个独立的安全系统来规避掉风险中和机器人系统相关的那一部分。
|
||||
|
||||
具体来讲,这个安全系统应该独立于机器学习系统,并且处于机器人架构的底层和拥有足够或最高等级的优先级。
|
||||
实际上,这个安全系统不应该只针对机器学习系统,而是应该针对整个机器人的方方面面。
|
||||
或者换句话来说,当开发机器人项目时,必须要有一个足够安全且独立的安全系统。
|
||||
而针对于机器学习系统的安全性只是这个独立安全系统“足够安全”的部分体现罢了。
|
||||
|
||||
还是以之前的那个物流仓库移动货运机器人为例。
|
||||
如果机器人的轮子是有独立安全回路并且断电自动刹车的轮子,而机器人又有一个严格符合安全标准且也有安全回路的激光雷达来检测障碍物,同时这个激光雷达的安全回路直接连接至轮子的安全回路。
|
||||
这样一来,不管机器人是否检测到前方有人或突然有一个人闯入机器人行进路线,激光雷达都会检测到有异物,直接通过独立的安全回路将轮子断电并刹车,以确保不会发生碰撞。
|
||||
这样一个配置完全独立于任何控制逻辑,从而不受任何上层系统的影响。
|
||||
**而对于开发者来说,当我们有了一个可靠独立的安全系统,我们也可以放心的去使用最新的突破性技术,而不用担心新技术是否会造成不可预期的后果。**
|
||||
|
||||
### 机器学习系统的伦理问题
|
||||
|
||||
除了上述讨论到的最根本的安全性问题,机器学习系统的伦理问题也会对机器人的使用造成影响。
|
||||
|
||||
例如训练数据集中人种类型不平衡这一类经典的伦理问题。
|
||||
让我们还是以之前的那个物流仓库移动货运机器人为例。
|
||||
如果我们的训练数据集只有亚洲人的图片,那么当我们想要开拓海外市场时,我们的海外用户很有可能会发现我们的机器人并不能很好的识别他们的工人。
|
||||
虽然独立的安全系统可以避免事故的发生,但是急停在工人面前肯定不是一个很好的用户体验。
|
||||
我们机器人的海外销量也会受到影响。
|
||||
|
||||
机器学习系统的伦理问题是目前比较火热的一个讨论领域。作为行业相关人员,我们需要了解这个方向上的最新进展。一方面是在系统设计的初期就把这些问题考虑进去,另一方面也是希望我们的成果能够给更多人带来幸福,而不是带去困扰。
|
||||
|
||||
### 小结
|
||||
|
||||
在这一章节中,我们稍微讨论了下怎样在机器人项目中安全的应用机器学习。我们确认了执行一份谨慎的风险检测和设计一个独立的安全系统是一个不错的办法。最后我们也稍微探讨了下机器学习系统的伦理问题。希望大家都能安全的在机器人项目中运用最新的机器学习技术!
|
||||
@@ -31,7 +31,19 @@ Service),即数据分发服务,如 :numref:`ROS2\_arch`所示。
|
||||
|
||||
ROS2依赖于使用shell环境组合工作区。"工作区"(Workspace)是一个ROS术语,表示使用ROS2进行开发的系统位置。核心ROS2
|
||||
工作区称为Underlay。随后的工作区称为Overlays。使用ROS2
|
||||
进行开发时,通常会同时有多个工作区处于活动状态。接下来我们详细介绍一下ROS2的核心概念(这一部分我们参考了文献 [^1])。
|
||||
进行开发时,通常会同时有多个工作区处于活动状态。
|
||||
|
||||
#### ROS还是ROS2
|
||||
|
||||
简单来说,除非你的项目已经搭建于ROS之上,或者你需要使用某个只在ROS框架下存在的功能库,否则大多数情况下都应该直接选择ROS2。
|
||||
|
||||
ROS是一个很好的框架。它有着成熟活跃的社区,种类繁多的功能库,同时也有十分成熟的商业项目(例如Mobile Industrial Robots公司的MiR robot)。
|
||||
但是,ROS也有很多缺陷,例如工业项目中ROS的稳定性一直是个问题,必须进行额外的系统设计来增强到能够接受的程度。
|
||||
这也是为什么ROS2会对框架的很多部分都进行重新设计的原因,因此ROS2也能更好的支持工业机器人项目。
|
||||
除此之外,ROS的社区也在积极拥抱ROS2。官方和社区都提供了详细的教程来帮助开发者将ROS的库迁移到ROS2的框架内。
|
||||
同时,官方也宣布了ROS Noetic Ninjemys是最后一版ROS,并且预计于2025年五月停止官方支持(End of Life date)。
|
||||
|
||||
本书将跟随官方的建议,使用ROS2作为讲解的框架。接下来我们详细介绍一下ROS2的核心概念(这一部分我们参考了文献 [^1])。
|
||||
|
||||
### ROS2 Nodes
|
||||
|
||||
@@ -64,7 +76,7 @@ ROS 2 中,单个可执行文件(C++ 程序、Python
|
||||
### ROS2 Topics
|
||||
|
||||
ROS2将复杂系统分解为许多模块化节点。主题(Topics)是 ROS
|
||||
Graph的重要元素,它充当节点交换消息的总线。一个节点可以向任意数量的主题发布数据,同时订阅任意数量的主题,如 :numref:`ros2\_topics`所示。主题是数据在节点之间以及因此在系统的不同部分之间移动的主要方式之一。
|
||||
Graph的重要元素,它充当节点交换消息的总线。一个节点可以向任意数量的主题发布数据,同时订阅任意数量的主题,如图:numref:`ros2\_topics`所示。主题是数据在节点之间以及因此在系统的不同部分之间移动的主要方式之一,例如,移动机器人的激光测距仪的相关节点可以将读数发布到lidar这个主题上,然后导航模块和安全模块的相关节点都可以订阅这个主题从而持续的获得想要的信息。
|
||||
|
||||
rqt是ROS的一个软件框架,以插件的形式实现了各种 GUI 工具。可以在 rqt
|
||||
中将所有现有的GUI工具作为可停靠窗口运行。这些工具仍然可以以传统的独立方法运行,但rqt可以更轻松地同时管理屏幕上的所有各种窗口。
|
||||
@@ -81,6 +93,8 @@ rqt是ROS的一个软件框架,以插件的形式实现了各种 GUI 工具。
|
||||
图中节点的另一种通信方式。服务基于调用和响应模型,而不是主题的发布者-订阅者模型。虽然主题允许节点订阅数据流并获得持续更新,但服务仅在客户端专门调用它们时才提供数据。节点可以使用ROS2中的服务进行通信。与主题那种单向通信模式,节点发布可由一个或多个订阅者使用的信息的方式不同
|
||||
服务是客户端向节点发出请求的请求/响应模式提供服务,服务处理请求并生成响应。
|
||||
|
||||
例如,当一个服务机器人想要尝试识别一个条形码时,控制模块的相关节点(Service客户端)可以发送照相机截图到条形码识别节点(Service服务端),后者尝试识别并返回结果给控制模块。
|
||||
|
||||

|
||||
|
||||
:width:`800px`
|
||||
|
||||
901
chapter_rl_sys/ros_code_ex.md
Normal file
@@ -0,0 +1,901 @@
|
||||
## 机器人操作系统(ROS)的入门案例
|
||||
|
||||
在这一章节中,我们将带领大家安装ROS2并配置好使用环境,然后再通过一些简单的代码示例来让大家更深入的了解如何使用ROS2和上一章节所介绍的概念。
|
||||
|
||||
在本章节以及本章后续的案例章节中,我们将使用ROS2 Foxy Fitzroy(笔者撰写时的最新的ROS2 LTS版本),Ubuntu Focal(20.04)和Ubuntu Focal系统所带的Python 3.8(笔者的Ubuntu Focal所带的是3.8.10)。
|
||||
其中ROS2 Foxy Fitzroy和Ubuntu Focal是官方的搭配,而如果你采用debian安装的方式(官方推荐方式)来安装ROS2的话,则Python必须使用Ubuntu所带的Python3版本。
|
||||
这是因为debian安装方式会将很多ROS2的Python依赖库以`apt install`(而非`pip install`)的方式安装到Ubuntu自带的Python3路径中去。
|
||||
这也就是说,当你选定ROS2版本后,你所需的Ubuntu版本和Python版本也就随之确定了。
|
||||
|
||||
如果想要使用Python虚拟环境(virtual env)的话,也必须指定使用Ubuntu系统所带的Python解释器(interpreter),并在创建时加上`site-packages`选项。添加这个选项是因为我们需要那些安装在系统Python3路径中的ROS2的依赖库。如果读者感兴趣的话,可以阅读这篇[英文教程](https://docs.ros.org/en/foxy/How-To-Guides/Using-Python-Packages.html)来了解更多细节。
|
||||
|
||||
举例来说,对于`pipenv`用户,可以通过下面这条命令来创建一个使用系统Python3并添加了`site-packages`的虚拟环境。
|
||||
|
||||
```shell
|
||||
pipenv --python $(/usr/bin/python3 -V | cut -d" " -f2) --site-packages
|
||||
```
|
||||
|
||||
因为要使用系统Python3的原因,用`conda`创建的虚拟环境可能会出现各种不兼容的问题。
|
||||
|
||||
对于其它版本的ROS2,安装过程和使用方式基本相同。
|
||||
|
||||
在本章节以及本章后续的案例章节中,我们在合适的场合将用ROS2,Ubuntu和Python来分别指代ROS2 Foxy Fitzroy,Ubuntu Focal和Ubuntu Focal所带的Python 3.8。
|
||||
|
||||
本章节中的案例有参考ROS2的[官方教程](https://docs.ros.org/en/foxy/Tutorials.html)。这个官方教程讲解的非常详细,非常适合初学者入门ROS2。如果读者对英文有自信的话,可以尝试阅读官方教程来了解更多ROS2的细节。
|
||||
|
||||
另外,本章节的案例所使用的代码可以在本书相关的[ROS2案例代码库](https://github.com/openmlsys/openmlsys-ros2)中的`src/my_hello_world`和`src/my_interfaces`文件夹内找到。
|
||||
|
||||
### 安装ROS2 Foxy Fitzroy
|
||||
|
||||
在Ubuntu上安装ROS2相对简单,绝大多数情况跟随官方教程安装即可。
|
||||
例如对于ROS2 Foxy Fitzroy和Ubuntu Focal,对自己英文水平较为自信的读者也可以跟随[这篇官方教程](https://docs.ros.org/en/foxy/Installation/Ubuntu-Install-Debians.html)。
|
||||
本章节关于ROS2的安装的部分主要也是这篇教程相关部分的转述。
|
||||
|
||||
#### 系统区域(locale)需要支持UTF-8
|
||||
|
||||
在开始安装之前,我们需要先确保我们Ubuntu系统的区域(locale)已经设置成了支持UTF-8的值。
|
||||
我们可以通过`locale`命令来查看目前的区域(locale)设置。
|
||||
如果`LANG`的值是以`.UTF-8`结尾的话,则代表系统已经是支持UTF-8的区域(locale)设置了。
|
||||
否则,可以使用下面的命令来将系统的区域(locale)设置为支持UTF-8的美式英语。
|
||||
想设置成其它语言只需更改相应的语言代码即可。
|
||||
|
||||
```shell
|
||||
sudo apt update && sudo apt install locales
|
||||
sudo locale-gen en_US en_US.UTF-8
|
||||
sudo update-locale LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8
|
||||
export LANG=en_US.UTF-8
|
||||
```
|
||||
|
||||
#### 设置软件源
|
||||
|
||||
我们还需要将ROS2的软件源加入到系统中。我们可以通过下面这些命令完成这点。
|
||||
|
||||
```shell
|
||||
sudo apt update && sudo apt install curl gnupg2 lsb-release
|
||||
sudo curl -sSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key -o /usr/share/keyrings/ros-archive-keyring.gpg
|
||||
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] http://packages.ros.org/ros2/ubuntu $(source /etc/os-release && echo $UBUNTU_CODENAME) main" | sudo tee /etc/apt/sources.list.d/ros2.list > /dev/null
|
||||
```
|
||||
|
||||
#### 安装ROS2
|
||||
|
||||
现在我们可以开始安装ROS2了。我们可以先更新软件源缓存,然后再安装ROS2 Desktop版。这个版本包含了ROS2框架和大部分ROS2开发常用的软件库,如RViz等,因此是首选的版本。
|
||||
|
||||
```shell
|
||||
sudo apt update
|
||||
sudo apt install ros-foxy-desktop
|
||||
```
|
||||
|
||||
另外,让我们再来安装两个额外的软件,`colcon`和`rosdep`。前者是ROS2的编译工具,后者可以帮助我们迅速安装一个ROS2工程所需的依赖库。
|
||||
|
||||
```shell
|
||||
sudo apt-get install python3-colcon-common-extensions python3-rosdep
|
||||
```
|
||||
|
||||
到此,我们已经安装好了ROS2。但是,如果想要使用它,我们还需要一个额外的环境设置步骤。
|
||||
|
||||
#### 环境设置
|
||||
|
||||
对于任意安装好的ROS2(和ROS)版本,我们需要source对应的setup脚本来为对应的版本设置好所需环境,然后才能开始使用其版本。
|
||||
|
||||
例如,对于刚安装好的ROS2 Foxy Fitzroy,我们可以在终端中执行下面的命令来设置好ROS2所需的环境。
|
||||
|
||||
```shell
|
||||
source /opt/ros/foxy/setup.bash
|
||||
```
|
||||
|
||||
如果你用的是bash以外的shell,你可以尝试将setup的文件扩展名改为对应shell的名字。例如zsh的用户可以尝试使用`source /opt/ros/foxy/setup.zsh`命令。
|
||||
|
||||
如果你不想每次使用ROS2之前都要输入上述命令,可以尝试将这条命令加入到你的`.bashrc`文件中去(或者是`.zshrc`或其它对应的shell文件)。这样,你以后的每个新命令行终端都会自动设置到ROS2所需的环境。
|
||||
|
||||
这种环境设置方式的好处在于你可以放心的安装多个不同版本的ROS2(和ROS),然后只需在需要时`source`对应版本的`setup.bash`文件,从而使用这个版本的ROS2并不受其它版本的干扰。
|
||||
|
||||
如果你是一个Python的重度用户,上面这种将`setup.bash`加入到`.bashrc`的方式可能会对你造成一些困扰。因为你的所有virtual env从此都会自动引入ROS2的环境设置,并且ROS2所包含的python libraries也会加入到你的virtual env的路径里面去。
|
||||
我相信,你可能对于virtual env会检测到ROS2的库这种情况不会感到特别开心,即使这些库并不会被用到或破坏你virtual env中程序的运行。
|
||||
|
||||
解决这个问题的方法也很简单。当你准备主要用Python来开发一个ROS2项目时,你可以为这个项目新建一个virtual env,然后将`source /opt/ros/foxy/setup.bash`这条命令加入到这个virtual env的`activate`脚本中去。
|
||||
|
||||
注意!你可能需要将这条`source`命令添加到脚本结尾前一些的位置或脚本最开头,要不然当你进入(activate)virtual env时你有可能会遇到下面这个错误(例如,对于`pipenv`的用户就需要添加到脚本结尾处的`hash -r 2>/dev/null`这条命令之前而不是最末尾)。
|
||||
|
||||
```shell
|
||||
Shell for UNKNOWN_VIRTUAL_ENVIRONMENT already activated.
|
||||
No action taken to avoid nested environments.
|
||||
```
|
||||
|
||||
#### 测试安装成功
|
||||
|
||||
当我们执行了上述的`source`命令之后,我们可以测试ROS2的安装以及环境设置时成功的。
|
||||
|
||||
我们只需在执行了`source`命令的命令行中执行`printenv | grep -i ^ROS`。输出的结果应该包含以下三个环境变量。
|
||||
|
||||
```shell
|
||||
ROS_VERSION=2
|
||||
ROS_PYTHON_VERSION=3
|
||||
ROS_DISTRO=foxy
|
||||
```
|
||||
|
||||
此外,我们可以新开两个执行了`source`命令的终端窗口,然后分别执行以下两条命令。
|
||||
|
||||
终端1:
|
||||
```shell
|
||||
ros2 run demo_nodes_cpp talker
|
||||
```
|
||||
|
||||
终端2:
|
||||
```shell
|
||||
ros2 run demo_nodes_py listener
|
||||
```
|
||||
|
||||
如果成功安装并执行了`source`命令的话,我们将会看到`talker`显示它正在发布消息,同时`listener`显示它听到了这些消息。
|
||||
|
||||
恭喜!您已经成功安装好了ROS2并配置到了环境。下面我们将会通过几个简单的案例来展示上章节中介绍过的ROS2的核心概念。
|
||||
|
||||
### ROS2节点和Hello World
|
||||
|
||||
在这一小节中,我们将会创建一个ROS2项目,并使用Python来编写一个Hello World案例,以便展示ROS2 Node的基本结构。
|
||||
|
||||
#### 新建一个ROS2项目
|
||||
|
||||
首先,在一个合适的位置新建一个文件夹。这个文件夹将是我们ROS2项目的根目录,同时也是上一章节中介绍过的“工作区”(Workspace)。这个工作区是我们自己创建的,所以它是一个Overlay Workspace。相对的,我们之前执行的`source`命令会帮我们准备好这个Overlay所基于的核心工作区(Underlay Workspace)。
|
||||
|
||||
假设我们创建了名为`openmlsys-ros2`的工作区。
|
||||
|
||||
```shell
|
||||
mkdir openmlsys-ros2
|
||||
cd openmlsys-ros2
|
||||
```
|
||||
|
||||
然后让我们为这个工作区创建一个Python的虚拟环境(virtual env)并依照上面*环境设置*小节中所介绍的那样将`source`命令添加到虚拟环境对应的`activate`脚本中去。
|
||||
|
||||
**我们默认之后所有案例章节的命令都是在这个新建的虚拟环境中执行的。**
|
||||
|
||||
不同的虚拟环境管理工具会有不同的指令,因此这一步笔者没有提供可执行命令的示例,而是留给读者自行处理。
|
||||
|
||||
接下来,我们要在这个工作区文件夹内新建一个名为`src`的子文件夹。在这个子文件夹内,我们将会创建不同的ROS2的程序库(package)。这些程序库相互独立,但又会互相调用其他库的功能来达成整个ROS2项目想要达成的各种目的。
|
||||
|
||||
在创建好`src`文件夹后,我们可以尝试调用`colcon build`命令。`colcon`是ROS2项目常用的一个编译工具(build tool)。这个命令会尝试编译整个ROS2项目(即目前工作区内的所有的程序库)。在成功运行完命令后,我们可以发现工作区内多出了三个新文件夹:`build`,`install`和`log`。其中`build`内是编译过程的中间产物,`install`内是编译的最终产物(即编译好的库),而`log`内是编译过程的日志。
|
||||
|
||||
到此,我们已经新建好了一个ROS2项目的框架,可以开始编写具体的代码了。
|
||||
|
||||
#### 新建一个ROS2框架下的Python库
|
||||
|
||||
下面,让我们在`src`文件夹内新建一个ROS2的程序库。我们将在这个程序库内编写我们的Hello World案例。
|
||||
|
||||
```shell
|
||||
cd src
|
||||
ros2 pkg create --build-type ament_python --dependencies rclpy std_msgs --node-name hello_world_node my_hello_world
|
||||
```
|
||||
|
||||
`ros2`命令的`pkg create`子项可以帮助我们快速的创建一个ROS2程序库的框架。`build-type`参数指明了这是一个纯Python库,`dependencies`参数指明了这个库将会使用`rclpy`和`std_msgs`这两个依赖库,`node-name`参数指明了我们创建的程序库中会有一个名为hello_world_node的ROS2节点,而最后的my_hello_world则是新建程序库的名字。
|
||||
|
||||
进入新建好的程序库文件夹`my_hello_world`,我们可以看到刚运行的命令已经帮我们建好一个Python库文件夹`my_hello_world`。其与程序库同名,且内含`__init__.py`文件和`hello_world_node.py`文件。后者的存在是由于我们使用了`node_name`参数的原因。我们将在这个Python库文件夹内编写我们的Python代码。
|
||||
|
||||
除此之外,还有`resource`和`test`这两个文件夹。前者帮助ROS2来定位Python程序库,因此我们不需要管它。后者用来包含所有的测试代码,并且我们可以看到里面已经有了三个测试文件。
|
||||
|
||||
除了这三个文件夹外,还有三个文件,`package.xml`,`setup.cfg`和`setup.py`。
|
||||
|
||||
`package.xml`是ROS2程序库的标准配置文件。打开后我们可以发现很多内容已经预生成好了,但是我们还需填写或更新`version`,`description`,`maintainer`和`license`这几项的内容。在此笔者推荐大家每次新建一个ROS2库的时候都第一时间将这些信息补全。除了这些项,我们还能看到`rclpy`和`std_msgs`已经被列为依赖库了,这是因为我们使用了`dependencies`参数的原因。如果我们要添加或修改依赖库,可以直接在`package.xml`内的`depend`列表处修改。除了最常用的`depend`(同时针对build,export和execution),我们还有`build_depend`,`build_export_depend`,`exec_depend`,`test_depend`,`buildtool_depend`和`dec_depend`。关于`package.xml`的具体介绍可以参考此英文[Wiki Page](http://wiki.ros.org/catkin/package.xml)。
|
||||
|
||||
`setup.cfg`和`setup.py`都是Python库的相关文件,但是ROS2也会通过这两个文件来了解怎么安装这个Python库至`install`文件夹以及有哪些需要注册的entry points,即可以直接用ROS2命令行命令来直接调用的程序。我们可以看到在`setup.py`中的`entry_points`项的`console_scripts`子项中已经将`hello_world_node`这个名字设置为`my_hello_world/hello_world_node.py`这个Python文件中`main()`函数的别名。我们后续就可以使用ROS2命令行命令和这个名字来直接调用这个函数。具体方式如下:
|
||||
|
||||
```shell
|
||||
# ros2 run <package_name> <entry_point>
|
||||
ros2 run my_hello_world hello_world_node
|
||||
```
|
||||
|
||||
后续如果需要添加新的entry point的话可以直接在此位置添加。
|
||||
|
||||
除了entry point需要关注之外,我们也需要及时将`setup.py`中的`version`,`maintainer`,`maintainer_email`,`description`和`license`项都更新好。
|
||||
|
||||
#### 第一个ROS2节点
|
||||
|
||||
让我们打开`my_hello_world/hello_world_node.py`这个Python文件,清空里面全部内容,以便于编写我们需要的代码。
|
||||
|
||||
首先,让我们引入必要的库:
|
||||
|
||||
```python
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
|
||||
from std_msgs.msg import String
|
||||
```
|
||||
|
||||
`rclpy`(ROS Client Library for Python)让我们能够通过Python来使用ROS2框架内的各种功能。而`Node`类则是所有ROS2节点的基类(Base Class),我们的节点类也需要继承这个基类。`std_msgs`则包含了ROS2预定义的一些用于框架内通信的标准信息格式,我们需要使用`String`这种消息格式来传递字符串信息。
|
||||
|
||||
接下来让我们定义我们自己的ROS2节点:
|
||||
|
||||
```python
|
||||
class HelloWorldNode(Node):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__('my_hello_world_node')
|
||||
self.msg_publisher = self.create_publisher(String, 'hello_world_topic', 10)
|
||||
timer_period = 1.
|
||||
self.timer = self.create_timer(timer_period, self.timer_callback)
|
||||
self.count = 0
|
||||
|
||||
def timer_callback(self):
|
||||
msg = String()
|
||||
msg.data = f'Hello World: {self.count}'
|
||||
self.msg_publisher.publish(msg)
|
||||
self.get_logger().info(f'Publishing: "{msg.data}"')
|
||||
self.count += 1
|
||||
```
|
||||
|
||||
如上所述,我们的节点类`HelloWorldNode`继承于`Node`基类。
|
||||
|
||||
在`__init__()`方法中,我们先调用基类的初始化方法,并通过这个调用将我们的节点命名为`my_hello_world_node`。接着我们创建一个信息发布者,它可以将字符串类型的信息发布到`hello_world_topic`这个主题上,并且会维持一个大小为10的缓冲区。再接着我们创建一个计时器,它会每秒钟调用一次`timer_callback()`方法。最后,我们初始化一个计数器,来统计总共有多少条信息被发布了。
|
||||
|
||||
在`timer_callback()`方法中,我们简单的创建一条带计数器的Hello World信息,并通过信息发布者发送出去。然后我们在日志中记录这次操作并将计数器加一。
|
||||
|
||||
定义好我们的Hello World节点类后,我们可以开始定义`main()`函数。这个函数就是我们之前在`setup.py`中看到的那个entry point。
|
||||
|
||||
```python
|
||||
def main(args=None):
|
||||
rclpy.init(args=args)
|
||||
hello_world_node = HelloWorldNode()
|
||||
rclpy.spin(hello_world_node)
|
||||
hello_world_node.destroy_node()
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
```
|
||||
|
||||
这个`main()`也比较简单。我们先通过`rclpy.init()`方法来启动ROS2框架。然后我们创建一个`HelloWorldNode`的实例。接着我们通过`rclpy.spin()`方法将这个实例加入到运行的ROS2框架中去,让其参与ROS2的事件循环并正确运行。`rclpy.spin()`是一个阻碍方法,它会一直运行一直到被阻止(例如ROS2框架停止运行)。这时候我们就会摧毁我们的节点,并且确保关闭ROS2框架。如果我们忘记了摧毁不再使用的节点,不用慌,garbage collector也会帮忙摧毁这个节点。
|
||||
|
||||
到此,我们创建了第一个ROS2节点!
|
||||
|
||||
#### 第一次编译和运行
|
||||
|
||||
让我们尝试编译新编写的这个库。这里,我们并不是真的要编译一个Python项目,而是将我们写的Python库安装到一个ROS2能找到的地方。
|
||||
|
||||
```shell
|
||||
# cd <workspace>
|
||||
cd openmlsys-ros2
|
||||
colcon build --symlink-install
|
||||
```
|
||||
|
||||
通过在运行这个编译命令,我们会编译工作区内`src`文件夹下所有的Python和C++库,并将编译好的C++库和Python库安装到`install`文件夹下。
|
||||
通过指定`--symlink-install`这个选项,我们要求`colcon`对于Python库用生成symlink的方式来代替复制安装。这样一来,我们在`src`中做的后续改动都会直接反应到`install`中去,而不用一直反复执行编译命令。
|
||||
|
||||
在编译成功之后,编译好的库还不能直接使用。例如你现在执行`ros2 run my_hello_world hello_world_node`的话很有可能会得到`Package 'my_hello_world' not found`这样一个结果。
|
||||
|
||||
为了使用编译好的库,我们需要让ROS2知道`install`文件夹。具体来说,我们需要`source`在`install`文件夹下的`local_setup.bash`文件。即:
|
||||
|
||||
```shell
|
||||
source install/local_setup.bash
|
||||
```
|
||||
|
||||
有些机敏的读者可能会想到我们可以像之前添加那个`setup.bash`一样将这个`install/local_setup.bash`也加入到虚拟环境的`activate`脚本中去,这样我们就不用每次都单独`source`这个文件了。很可惜,这样会带来一些问题。
|
||||
|
||||
具体来说,一方面我们需要将这两个文件都`source`了(不管是通过`activate`脚本还是手动输入)才能顺利运行编译好的ROS2程序,但另一方面我们必须只`source`第一个`setup.bash`而不`source`第二个`local_setup.bash`才能顺利编译带有C++依赖项的纯Python的ROS2库。
|
||||
在稍后面一点的案例中我们会看到,对于一个使用了自定义消息接口库(自己编写的C++库)的纯Python的ROS2程序库来说,必须只`source`第一个`setup.bash`而不`source`第二个`local_setup.bash`才能顺利编译。
|
||||
|
||||
在成功`source`了`install/local_setup.bash`之后,我们就可以尝试调用写好的节点了。
|
||||
|
||||
从现在开始,除非特殊说明,**新开一个终端窗口**都是指*新开一个确保`setup.bash`和`install/local_setup.bash`都已经被`source`了的终端窗口*,而在**工作区执行`colcon build`命令**则都是*在一个只`source`了`setup.bash`而忽略了`install/local_setup.bash`的终端窗口中执行此编译命令*。
|
||||
|
||||
```shell
|
||||
ros2 run my_hello_world hello_world_node
|
||||
```
|
||||
|
||||
我们应该会看到类似下面这样的信息:
|
||||
|
||||
```shell
|
||||
[INFO] [1653270247.805815900] [my_hello_world_node]: Publishing: "Hello World: 0"
|
||||
[INFO] [1653270248.798165800] [my_hello_world_node]: Publishing: "Hello World: 1"
|
||||
```
|
||||
|
||||
我们还可以再新开一个终端窗口,然后执行`ros2 topic echo /hello_world_topic`。我们应该能看到类似下面的信息:
|
||||
|
||||
```shell
|
||||
data: 'Hello World: 23'
|
||||
---
|
||||
data: 'Hello World: 24'
|
||||
---
|
||||
```
|
||||
|
||||
这代表着我们的信息确实被发布到了目标主题上。因为`ros2 topic echo <topic_name>`这条命令输出的就是给定名字的主题所接收到的信息。
|
||||
|
||||
恭喜!您已成功运行了您的第一个ROS2节点!
|
||||
|
||||
#### 一个消息订阅者节点
|
||||
|
||||
只是发布消息并不能组成一个完整的流程,我们还需要一个消息订阅者来消费我们发布的信息。
|
||||
|
||||
让我们在`hello_world_node.py`所在的文件夹内新建一个名为`message_subscriber.py`的文件,并添加以下内容:
|
||||
|
||||
```python
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
|
||||
from std_msgs.msg import String
|
||||
|
||||
|
||||
class MessageSubscriber(Node):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__('my_hello_world_subscriber')
|
||||
self.msg_subscriber = self.create_subscription(
|
||||
String, 'hello_world_topic', self.subscriber_callback, 10
|
||||
)
|
||||
|
||||
def subscriber_callback(self, msg):
|
||||
self.get_logger().info(f'Received "{msg.data}"')
|
||||
|
||||
|
||||
def main(args=None):
|
||||
rclpy.init(args=args)
|
||||
message_subscriber = MessageSubscriber()
|
||||
rclpy.spin(message_subscriber)
|
||||
message_subscriber.destroy_node()
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
这个新添加的文件以及其中的消息订阅者节点类和上面的HelloWorld节点类十分相似,甚至更为简单些。我们只需要在初始化时通过基类初始化方法赋予节点`my_hello_world_subscriber`这个名字,然后创建一个消息订阅者来订阅`hello_world_topic`主题下的消息,并指定`subscriber_callback()`方法来处理接收到的消息。而在`subscriber_callback()`中,我们将接收到的消息记录进日志。`main()`方法则和HelloWorld节点类的基本一样。
|
||||
|
||||
在能正式使用这个新节点之前,我们需要将其添加成为一个entry point。为此,我们只需在`setup.py`的对应位置添加下面这行:
|
||||
|
||||
```python
|
||||
'message_subscriber = my_hello_world.message_subscriber:main'
|
||||
```
|
||||
|
||||
但是,添加完成之后在终端窗口运行`ros2 run my_hello_world message_subscriber`还是会得到`No executable found`这样的错误反馈。这是因为我们新增了一个entry point,必须重新编译整个ROS2项目才能让ROS2知道这个新增点。
|
||||
|
||||
让我们再次在工作区目录执行`colcon build --symlink-install`。在成功编译后,让我们新建两个终端窗口,都分别确保`source`好了两个`setup`文件。然后分别用`ros2`命令调用它们:
|
||||
|
||||
```shell
|
||||
# in terminal 1
|
||||
ros2 run my_hello_world hello_world_node
|
||||
```
|
||||
|
||||
```shell
|
||||
# in terminal 2
|
||||
ros2 run my_hello_world message_subscriber
|
||||
```
|
||||
|
||||
我们应该可以看到终端窗口1中会不断显示发布了第N号Hello World消息,而终端窗口2中则不断显示收到了第N号Hello World消息。
|
||||
|
||||
恭喜!你完成了一对ROS2节点,一个负责发送信息,一个负责订阅接受信息。
|
||||
|
||||
### ROS2参数
|
||||
|
||||
顺利完成上面的消息发布者和消息订阅者是个很好的开始,但是实际项目的节点不会这么简单。
|
||||
至少,实际项目的节点会是参数化的。下面,就让我们一起看看怎样让一个节点读取一个参数。
|
||||
|
||||
让我们在`hello_world_node.py`所在的文件夹内新建一个名为`parametrised_hello_world_node.py`的文件,并添加以下内容:
|
||||
|
||||
```python
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
|
||||
from std_msgs.msg import String
|
||||
|
||||
|
||||
class ParametrisedHelloWorldNode(Node):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__('parametrised_hello_world_node')
|
||||
self.msg_publisher = self.create_publisher(String, 'hello_world_topic', 10)
|
||||
timer_period = 1.
|
||||
self.timer = self.create_timer(timer_period, self.timer_callback)
|
||||
self.count = 0
|
||||
self.declare_parameter('name', 'world')
|
||||
|
||||
def timer_callback(self):
|
||||
name = self.get_parameter('name').get_parameter_value().string_value
|
||||
msg = String()
|
||||
msg.data = f'Hello {name}: {self.count}'
|
||||
self.msg_publisher.publish(msg)
|
||||
self.get_logger().info(f'Publishing: "{msg.data}"')
|
||||
self.count += 1
|
||||
|
||||
|
||||
def main(args=None):
|
||||
rclpy.init(args=args)
|
||||
hello_world_node = ParametrisedHelloWorldNode()
|
||||
rclpy.spin(hello_world_node)
|
||||
hello_world_node.destroy_node()
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
```
|
||||
|
||||
我们可以看到,这个新的参数化HelloWorld节点类和之前的HelloWorld节点类基本相同。
|
||||
唯二的区别在于:1)这个新类在初始化方法中额外通过`self.declare_parameter()`方法来向ROS2框架声明新的节点实例会有一个名为`name`的参数,并且这个参数的初始值为`world`;2)这个新类在`timer_callback()`回调函数中尝试获取这个`name`参数的实际值,并以这个实际值来组成要发送的信息的内容。
|
||||
|
||||
让我们先将这个新文件的`main()`方法注册为一个新的entry point。
|
||||
同样的,在`setup.py`中的相应位置加入下面这行即可。
|
||||
然后别忘了在工作区根目录下执行`colcon build --symlink-install`来重新编译项目。
|
||||
|
||||
```python
|
||||
'parametrised_hello_world_node = my_hello_world.parametrised_hello_world_node:main'
|
||||
```
|
||||
|
||||
在编译完成之后,如果我们在终端中执行`ros2 run my_hello_world parametrised_hello_world_node`,我们将看到这个参数化HelloWorld节点将正常运行,并持续发布"*Hello World: N*"这样的信息。此时节点使用的是`world`这个初始值。
|
||||
|
||||
让我们在一个新的终端中执行`ros2 param list`,我们将看到下面的信息:
|
||||
|
||||
```shell
|
||||
/parametrised_hello_world_node:
|
||||
name
|
||||
use_sim_time
|
||||
```
|
||||
|
||||
这个信息表示`parametrised_hello_world_node`这个节点的确申明并使用一个`name`参数。
|
||||
另外一个名为`use_sim_time`的参数是ROS2默认给与的一个参数,用来表示这个节点是否使用ROS2框架内部的模拟时间,而不是电脑的系统时间。
|
||||
|
||||
我们可以继续在这个终端中输入下面这个命令来将值`ROS2`赋予给`name`这个参数。
|
||||
|
||||
```shell
|
||||
ros2 param set /parametrised_hello_world_node name "ROS2"
|
||||
```
|
||||
|
||||
如果赋值成功的话,这个命令会返回`Set parameter successful`,并且我们可以在持续运行参数化HelloWorld节点的那个终端窗口内看到其发布的信息变为了"*Hello ROS2: N*"。
|
||||
|
||||
恭喜!你现在掌握了如何让ROS2节点(和其它类型的ROS2程序)使用参数的方法。
|
||||
|
||||
### ROS2服务
|
||||
|
||||
在上一章节中我们知道了ROS2框架除了发布者-订阅者这种通信模式,还有服务端-客户端这种模式。
|
||||
在这一小节中,我们将通过一个简单的串联两个字符串的服务来演示如何使用这种模式。
|
||||
|
||||
#### 自定义的服务接口
|
||||
|
||||
在正式开始编写服务端和客户端的代码之前,我们需要先定义好它们之间进行沟通的信息接口。
|
||||
|
||||
ROS2框架内有三种类型的信息接口:
|
||||
|
||||
- 发布者-订阅者模式下的节点所用的**消息**类型接口(message/msg):这种接口只负责单向的消息传递,也只用定义单向传递的信息的格式。
|
||||
- 服务端-客户端模式下的服务节点所用的**服务**类型接口(service/srv):这种接口需要负责双向的消息传递,即需要定义客户端发给服务端的请求的格式和服务端发给客户端的响应的格式。
|
||||
- 动作模式下的动作节点所用的**动作**类型接口(action):这种接口需要负责双向的消息传递以及中间的进展反馈,即需要定义动作发起节点发给动作节点的请求的格式,动作节点发给发起节点的结果的格式,以及动作节点发给发起节点的中间进展反馈的格式。
|
||||
|
||||
对于前面定义的那些HelloWorld节点,我们使用的是已经预定义好的`std_msgs`库内的`std_msgs.msg.String`类型的消息类型接口。
|
||||
实际上,因为消息类型接口只负责定义单向的信息格式,我们很容易找到现成的符合我们需求的类型。
|
||||
但是对于服务(service)和动作(action)来说,因为涉及到定义双向沟通的格式,很多时候我们需要自己定义一个接口类型。接下来,就让我们自行定义我们的字符串串联服务将要使用的服务类型接口。
|
||||
|
||||
首先,让我们在工作区的`src`文件夹内新建一个库来专门维护自定义的消息,服务和动作类型接口。
|
||||
|
||||
```shell
|
||||
cd openmlsys-ros2/src
|
||||
ros2 pkg create --build-type ament_cmake my_interfaces
|
||||
```
|
||||
|
||||
这个新建的库是一个C++库,而不是Python库。这是因为ROS2的自定义接口类型只能以C++库的方式存在。新建好库之后,记得更新`package.xml`中的相关项。
|
||||
|
||||
下面,让我们在新建的`src/my_interfaces`文件夹内新建三个子文件夹:`msg`,`srv`和`action`。这是因为一般会将自定义的接口放到相对应的子文件夹中去,以方便维护。
|
||||
|
||||
```shell
|
||||
cd my_interfaces
|
||||
mkdir msg srv action
|
||||
```
|
||||
|
||||
接着,让我们在`srv`子目录下创建我们想要定义的服务类型接口。
|
||||
|
||||
```shell
|
||||
cd srv
|
||||
touch ConcatTwoStr.srv
|
||||
```
|
||||
|
||||
然后,让我们将以下内容添加到`ConcatTwoStr.srv`中去:
|
||||
|
||||
```
|
||||
string str1
|
||||
string str2
|
||||
---
|
||||
string ret
|
||||
```
|
||||
|
||||
其中,`---`之上的是客户端发给服务端的请求的格式,而之下的是服务端发给客户端的响应的格式。
|
||||
|
||||
定义好了接口后,我们还需要更改`CMakeLists.txt`以便让编译器知道有自定义接口需要编译并能找到它们。让我们打开`my_interfaces/CMakeLists.txt`并在`if(BUILD_TESTING)`这行之前添加下面的内容。
|
||||
|
||||
```make
|
||||
find_package(rosidl_default_generators REQUIRED)
|
||||
|
||||
rosidl_generate_interfaces(${PROJECT_NAME}
|
||||
"srv/ConcatTwoStr.srv"
|
||||
)
|
||||
```
|
||||
|
||||
上面这两段代码的主要作用是告诉编译器需要`rosidl_default_generators`这个库并生成我们指明的自定义接口。
|
||||
|
||||
在更新好`CMakeLists.txt`之后,我们还需要把`rosidl_default_generators`添加到`package.xml`中作为自定义接口库的依赖项。打开`package.xml`,在`<test_depend>ament_lint_auto</test_depend>`这行前添加下面内容。
|
||||
|
||||
```xml
|
||||
<build_depend>rosidl_default_generators</build_depend>
|
||||
<exec_depend>rosidl_default_runtime</exec_depend>
|
||||
<member_of_group>rosidl_interface_packages</member_of_group>
|
||||
```
|
||||
|
||||
更新好`package.xml`后,我们就可以编译这个自定义接口库了。
|
||||
|
||||
```shell
|
||||
cd openmlsys-ros2
|
||||
colcon build --packages-select my_interfaces
|
||||
```
|
||||
|
||||
上述命令中我们通过`--packages-select`选项指定了只编译`my_interfaces`这一个库从而节省时间,因为`my_hello_world`这个库目前并没有任何更改。另外,我们没有使用`--symlink-install`选项是因为这个自定义接口库是一个C++库,每次更改后必须重新编译。
|
||||
|
||||
在运行这次的编译命令时,读者有可能会遇到`ModuleNotFoundError: No module named 'XXX'`这类的错误(`XXX`可以是`em`,`catkin_pkg`,`lark`,`numpy`或其它Python库)。
|
||||
遇到这类错误多半是因为所使用的Python虚拟环境并不是指向Ubuntu系统Python3或`site-packages`并没有被包含在虚拟环境中。
|
||||
读者可能需要删除当前的虚拟环境并按照本章节开头所讲解的那样重新创建一个符合要求的虚拟环境。
|
||||
|
||||
我们可以通过在新的终端窗口运行`ros2 interface show my_interfaces/srv/ConcatTwoStr`来验证是否已经编译成功了。成功的话终端会显示自定义服务接口`ConcatTwoStr`的具体定义。
|
||||
|
||||
现在,我们定义好了需要使用的服务接口,下面可以开始编写我们的服务段和客户端了。
|
||||
|
||||
#### ROS2服务端
|
||||
|
||||
让我们在`hello_world_node.py`所在的文件夹内新建一个名为`concat_two_str_service.py`的文件,并添加以下内容:
|
||||
|
||||
```python
|
||||
from my_interfaces.srv import ConcatTwoStr
|
||||
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
|
||||
|
||||
class ConcatTwoStrService(Node):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__('concat_two_str_service')
|
||||
self.srv = self.create_service(ConcatTwoStr, 'concat_two_str', self.concat_two_str_callback)
|
||||
|
||||
def concat_two_str_callback(self, request, response):
|
||||
response.ret = request.str1 + request.str2
|
||||
self.get_logger().info(f'Incoming request\nstr1: {request.str1}\nstr2: {request.str2}')
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def main(args=None):
|
||||
rclpy.init(args=args)
|
||||
concat_two_str_service = ConcatTwoStrService()
|
||||
rclpy.spin(concat_two_str_service)
|
||||
concat_two_str_service.destroy_node()
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
```
|
||||
|
||||
我们可以发现,编写一个服务(Service)和编写一个一般的节点(Node)很相似,甚至它们都是继承自同一个基类`rclpy.node.Node`。在这个文件中,我们先从编译好的`my_interfaces`库中引入自定义的服务接口`ConcatTwoStr`。然后在服务端节点的初始化方法中通过`self.create_service()`创建一个服务器对象,并指明服务接口类型是`ConcatTwoStr`,服务名字是`concat_two_str`,处理服务请求的回调函数是`self.concat_two_str_callback`。而在回调函数`self.concat_two_str_callback()`中,我们通过`request`对象取得请求的`str1`和`str2`,计算出结果并赋值到`response`对象的`ret`上,并进行日志记录。我们可以看到,`request`和`response`对象的结构符合我们在`ConcatTwoStr.srv`中的定义。
|
||||
|
||||
另外别忘记了将此文件的`main()`方法作为一个entry point添加到`setup.py`中去。
|
||||
|
||||
```python
|
||||
'concat_two_str_service = my_hello_world.concat_two_str_service:main'
|
||||
```
|
||||
|
||||
#### ROS2客户端
|
||||
|
||||
让我们在`hello_world_node.py`所在的文件夹内新建一个名为`concat_two_str_client_async.py`的文件,并添加以下内容:
|
||||
|
||||
```python
|
||||
import sys
|
||||
|
||||
from my_interfaces.srv import ConcatTwoStr
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
|
||||
|
||||
class ConcatTwoStrClientAsync(Node):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__('concat_two_str_client_async')
|
||||
self.cli = self.create_client(ConcatTwoStr, 'concat_two_str')
|
||||
while not self.cli.wait_for_service(timeout_sec=1.0):
|
||||
self.get_logger().info('service not available, waiting again...')
|
||||
self.req = ConcatTwoStr.Request()
|
||||
|
||||
def send_request(self):
|
||||
self.req.str1 = sys.argv[1]
|
||||
self.req.str2 = sys.argv[2]
|
||||
self.future = self.cli.call_async(self.req)
|
||||
|
||||
|
||||
def main(args=None):
|
||||
rclpy.init(args=args)
|
||||
|
||||
concat_two_str_client_async = ConcatTwoStrClientAsync()
|
||||
concat_two_str_client_async.send_request()
|
||||
|
||||
while rclpy.ok():
|
||||
rclpy.spin_once(concat_two_str_client_async)
|
||||
if concat_two_str_client_async.future.done():
|
||||
try:
|
||||
response = concat_two_str_client_async.future.result()
|
||||
except Exception as e:
|
||||
concat_two_str_client_async.get_logger().info(
|
||||
'Service call failed %r' % (e,))
|
||||
else:
|
||||
concat_two_str_client_async.get_logger().info(
|
||||
'Result of concat_two_str: (%s, %s) -> %s' %
|
||||
(concat_two_str_client_async.req.str1, concat_two_str_client_async.req.str2, response.ret))
|
||||
break
|
||||
|
||||
concat_two_str_client_async.destroy_node()
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
```
|
||||
|
||||
相比于服务端,这个客户端较为复杂一点。在客户端节点的初始化方法中,我们先创建一个客户端对象,并指明服务接口类型是`ConcatTwoStr`,服务名字为`concat_two_str`。然后通过一个`while`循环,这个客户端将一直等待知道对应服务上线才会进行下一步。这个循环等待的技巧是很多客户端都会使用的。当服务端上线以后,初始化方法将创建一个服务请求对象的模板并暂存于客户端节点的`req`属性上。除了初始化方法,客户端节点还定义了另一个方法`send_request()`来读取程序启动时命令行的前两个参数,然后存入服务请求对象并异步发送给服务端。
|
||||
|
||||
而在`main()`方法中,我们先创建一个客户端并发送服务请求,然后通过一个`while`循环来等待服务返回结果并记录进日志。其中,`rclpy.ok()`是用来检测ROS2是否还在正常运行,以保证当ROS2在服务结束前就停止运行了的话,客户端这边不会陷入死循环。而`rclpy.spin_once()`和`rclpy.spin()`略有不同,后者会不断执行事件循环直到ROS2停止,而前者则只会执行一次事件循环。这也是为什么前者更适合用在这里,因为我们已经有了一个while循环了。另外我们可以看到,`concat_two_str_client.future`对象提供了很多方法来帮助我们确定目前服务请求的状态。
|
||||
|
||||
同样的,别忘记了将此文件的`main()`方法作为一个entry point添加到`setup.py`中去。
|
||||
|
||||
```python
|
||||
'concat_two_str_client_async = my_hello_world.concat_two_str_client_async:main'
|
||||
```
|
||||
|
||||
#### 确认正常运行
|
||||
|
||||
我们现在编写好了我们的服务端和客户端,让我们在工作区根目录下重新编译一边`my_hello_world`库。
|
||||
|
||||
```shell
|
||||
cd openmlsys-ros2
|
||||
colcon build --packages-select my_hello_world --symlink-install
|
||||
```
|
||||
|
||||
然后让我们在两个新的终端窗口中分别运行以下命令。
|
||||
|
||||
```shell
|
||||
# in terminal 1
|
||||
ros2 run my_hello_world concat_two_str_client_async Hello World
|
||||
```
|
||||
|
||||
```shell
|
||||
# in terminal 2
|
||||
ros2 run my_hello_world concat_two_str_service
|
||||
```
|
||||
|
||||
如果一切正常的话,我们应该看到类似以下的信息。
|
||||
|
||||
```shell
|
||||
# in terminal 1
|
||||
[INFO] [1653525569.843701600] [concat_two_str_client_async]: Result of concat_two_str: (Hello, World) -> HelloWorld
|
||||
```
|
||||
|
||||
```shell
|
||||
# in terminal 2
|
||||
[INFO] [1653516701.306543500] [concat_two_str_service]: Incoming request
|
||||
str1: Hello
|
||||
str2: World
|
||||
```
|
||||
|
||||
恭喜!您现在已经了解如何在ROS2框架中新建自定义的接口类型和创建服务端节点和客户端节点了!
|
||||
|
||||
### ROS2动作
|
||||
|
||||
在上一章节中我们了解了ROS2框架内的服务端-客户端模式。这样一来,我们只剩下动作(action)这一种模式了。
|
||||
在这一小节中,我们将通过一个简单的逐个累加一个数列的每项元素来求和的动作来演示如何使用这种模式。
|
||||
|
||||
#### 自定义的动作接口
|
||||
|
||||
在正式开始编写动作相关的节点代码之前,我们需要先定义好动作的信息接口。
|
||||
|
||||
我们可以继续使用之前建好的`my_interfaces`库。
|
||||
让我们在`my_interfaces/action`中新建一个`MySum.action`文件,并添加以下内容。
|
||||
|
||||
```
|
||||
# Request
|
||||
int32[] list
|
||||
---
|
||||
# Result
|
||||
int32 sum
|
||||
---
|
||||
# Feedback
|
||||
int32 sum_so_far
|
||||
```
|
||||
|
||||
可以看到,整个信息接口十分简单。动作的请求信息只有一项类型为整数数列的项`list`,动作的最终结果信息只有一项类型为整数的项`sum``,而中间反馈信息则只有一项类型同为整数的项`sum_so_far`,用以计算到目前位置累加的和。
|
||||
|
||||
接下来,让我们在`CMakeLists.txt`中添加这个新的信息接口。具体来说只用将`"action/MySum.action"`添加到`rosidl_generate_interfaces()`方法内的`"srv/ConcatTwoStr.srv"`之后即可。
|
||||
|
||||
最后别忘了编译所做的更改:在工作区根目录中运行`colcon build --packages-select my_interface`。
|
||||
|
||||
#### ROS2动作服务器
|
||||
|
||||
让我们在`hello_world_node.py`所在的文件夹内新建一个名为`my_sum_action_server.py`的文件,并添加以下内容:
|
||||
|
||||
```python
|
||||
import rclpy
|
||||
from rclpy.action import ActionServer
|
||||
from rclpy.node import Node
|
||||
|
||||
from my_interfaces.action import MySum
|
||||
|
||||
|
||||
class MySumActionServer(Node):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__('my_sum_action_server')
|
||||
self._action_server = ActionServer(
|
||||
self, MySum, 'my_sum', self.execute_callback
|
||||
)
|
||||
|
||||
def execute_callback(self, goal_handle):
|
||||
self.get_logger().info('Executing goal...')
|
||||
feedback_msg = MySum.Feedback()
|
||||
feedback_msg.sum_so_far = 0
|
||||
for elm in goal_handle.request.list:
|
||||
feedback_msg.sum_so_far += elm
|
||||
self.get_logger().info(f'Feedback: {feedback_msg.sum_so_far}')
|
||||
goal_handle.publish_feedback(feedback_msg)
|
||||
goal_handle.succeed()
|
||||
result = MySum.Result()
|
||||
result.sum = feedback_msg.sum_so_far
|
||||
return result
|
||||
|
||||
|
||||
def main(args=None):
|
||||
rclpy.init(args=args)
|
||||
|
||||
my_sum_action_server = MySumActionServer()
|
||||
|
||||
rclpy.spin(my_sum_action_server)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
```
|
||||
|
||||
对于这个动作服务器节点类,类似的,我们还是在其初始化方法中新建一个动作服务器对象,并指定了之前定义的`MySum`作为信息接口类型,`my_sum`是动作名字,`self.execute_callback`方法则作为动作执行的回调函数。
|
||||
|
||||
紧接着,我们在`self.execute_callback()`方法中定义了当接收到了一个新目标是应做什么处理。在这里,我们可以把一个目标当作之前定义的`MySum`信息接口里的`request`部分来处理,因为这里的目标就是包含了动作请求的目的的相关信息的结构体,即`request`部分所定义的部分。
|
||||
|
||||
当我们接收到一个目标后,我们先从`MySum`创建一个反馈消息对象`feedback_msg`,并将其`sum_so_far`项用作一个累加器。然后我们遍历目标请求中的`list`项里面的数据,并这些数据逐项进行累加。每当我们累加一项后,我们都会通过`goal_handle.publish_feedback()`方法发送一次反馈消息。最后,当全部计算完成后,我们通过`goal_handle.succeed()`来标记此次动作已经成功完成,并且通过`MySum`新建一个结果对象,填充结果值并返回。
|
||||
|
||||
在`main()`函数中,我们只需要新建一个动作服务器节点类的新实例,并调用`rclpy.spin()`将其加入事件循环即可。
|
||||
|
||||
最后别忘了将`main()`也添加成为一个entry point。我们只需在`setup.py`中适当位置添加下面行即可。
|
||||
|
||||
```python
|
||||
'my_sum_action_server = my_hello_world.my_sum_action_server:main'
|
||||
```
|
||||
|
||||
#### ROS2动作客户端
|
||||
|
||||
让我们在`hello_world_node.py`所在的文件夹内新建一个名为`my_sum_action_client.py`的文件,并添加以下内容:
|
||||
|
||||
```python
|
||||
import sys
|
||||
import rclpy
|
||||
from rclpy.action import ActionClient
|
||||
from rclpy.node import Node
|
||||
|
||||
from my_interfaces.action import MySum
|
||||
|
||||
|
||||
class MySumActionClient(Node):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__('my_sum_action_client')
|
||||
self._action_client = ActionClient(self, MySum, 'my_sum')
|
||||
|
||||
def send_goal(self, list):
|
||||
goal_msg = MySum.Goal()
|
||||
goal_msg.list = list
|
||||
|
||||
self._action_client.wait_for_server()
|
||||
|
||||
self._send_goal_future = self._action_client.send_goal_async(
|
||||
goal_msg, feedback_callback=self.feedback_callback
|
||||
)
|
||||
self._send_goal_future.add_done_callback(self.goal_response_callback)
|
||||
|
||||
def goal_response_callback(self, future):
|
||||
goal_handle = future.result()
|
||||
if not goal_handle.accepted:
|
||||
self.get_logger().info('Goal rejected...')
|
||||
return
|
||||
|
||||
self.get_logger().info('Goal accepted.')
|
||||
|
||||
self._get_result_future = goal_handle.get_result_async()
|
||||
self._get_result_future.add_done_callback(self.get_result_callback)
|
||||
|
||||
def get_result_callback(self, future):
|
||||
result = future.result().result
|
||||
self.get_logger().info(f'Result: {result.sum}')
|
||||
rclpy.shutdown()
|
||||
|
||||
def feedback_callback(self, feedback_msg):
|
||||
feedback = feedback_msg.feedback
|
||||
self.get_logger().info(f'Received feedback: {feedback.sum_so_far}')
|
||||
|
||||
|
||||
def main(args=None):
|
||||
rclpy.init(args=args)
|
||||
|
||||
action_client = MySumActionClient()
|
||||
|
||||
action_client.send_goal([int(elm) for elm in sys.argv[1:]])
|
||||
|
||||
rclpy.spin(action_client)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
```
|
||||
|
||||
我们可以看到,这个动作客户端节点类比上面的服务器节点类要稍许复杂些,这是因为我们要适当的处理发送请求,接受反馈和处理结果这三件事。
|
||||
|
||||
首先,还是类似的,我们在这个动作客户端节点类的初始化方法中新建一个动作客户端对象,并指定`MySum`作为消息接口类型和`my_sum`作为动作名称。
|
||||
|
||||
然后,我们申明`self.send_goal()`方法来负责生成并发送一个目标/请求。具体来说,我们先从`MySum`新建一个目标对象并将接收到的`list`参数赋值到目标对象的`list`属性上去。紧接着,让我们等待动作服务器准备就绪。当动作服务器准备就绪后,让我们异步发送目标并指定`self.feedback_callback`作为反馈信息回调函数。最后,我们设定`self.goal_response_callback`作为发送目标信息这个异步操作的回调函数。
|
||||
|
||||
在`self.goal_response_callback()`这个异步发送目标信息的回调函数中,我们先检查目标请求是否被接受了,并日志记录相关结果。如果目标请求被接受了的话,我们就通过`goal_handle.get_result_async()`来得到处理结果这个异步操作的`future`对象,并通过这个`future`对象将`self.get_result_callback`设定为最终结果的回调函数。
|
||||
|
||||
在`self.get_result_callback()`这个最终结果的回调函数中,我们就简单的获取累加结果并记录进日志。最后我们调用`rclpy.shutdown()`来结束当前节点。
|
||||
|
||||
相对的,在`self.feedback_callback()`这个反馈消息的回调函数中。我们仅仅简单的获取反馈信息的内容并记录进日志。值得注意的是,反馈消息的回调函数可能被执行多次,所以最好不要在其中写入太多的处理逻辑,而是尽量让其轻量化。
|
||||
|
||||
最后,在`main()`方法中,我们创建一个动作客户端节点类的实例,将命令行的参数转化为需要被求和的目标数列,最后调用动作客户端节点类实例的`send_goal()`方法并传入目标求和数列来发起求和请求。
|
||||
|
||||
同样的,别忘了将`main()`也添加成为一个entry point。我们只需在`setup.py`中适当位置添加下面行即可。
|
||||
|
||||
```python
|
||||
'my_sum_action_client = my_hello_world.my_sum_action_client:main'
|
||||
```
|
||||
|
||||
#### 确认正常运行
|
||||
|
||||
我们现在编写好了我们的动作服务器和动作客户端,让我们在工作区根目录下重新编译一边`my_hello_world`库。
|
||||
|
||||
```shell
|
||||
cd openmlsys-ros2
|
||||
colcon build --packages-select my_hello_world --symlink-install
|
||||
```
|
||||
|
||||
然后让我们在两个新的终端窗口中分别运行以下命令。
|
||||
|
||||
```shell
|
||||
# in terminal 1
|
||||
ros2 run my_hello_world my_sum_action_client 1 2 3
|
||||
```
|
||||
|
||||
```shell
|
||||
# in terminal 2
|
||||
ros2 run my_hello_world my_sum_action_server
|
||||
```
|
||||
|
||||
如果一切正常的话,我们应该看到类似以下的信息。
|
||||
|
||||
```shell
|
||||
# in terminal 1
|
||||
[INFO] [1653561740.000499500] [my_sum_action_client]: Goal accepted.
|
||||
[INFO] [1653561740.001171900] [my_sum_action_client]: Received feedback: 1
|
||||
[INFO] [1653561740.001644000] [my_sum_action_client]: Received feedback: 3
|
||||
[INFO] [1653561740.002327500] [my_sum_action_client]: Received feedback: 6
|
||||
[INFO] [1653561740.002761600] [my_sum_action_client]: Result: 6
|
||||
```
|
||||
|
||||
```shell
|
||||
# in terminal 2
|
||||
[INFO] [1653561739.988907200] [my_sum_action_server]: Executing goal...
|
||||
[INFO] [1653561739.989213900] [my_sum_action_server]: Feedback: 1
|
||||
[INFO] [1653561739.989549000] [my_sum_action_server]: Feedback: 3
|
||||
[INFO] [1653561739.989855400] [my_sum_action_server]: Feedback: 6
|
||||
```
|
||||
|
||||
恭喜!您现在已经了解如何在ROS2框架中新建自定义的接口类型和创建动作服务端节点和动作客户端节点了!
|
||||
|
||||
### 小结
|
||||
|
||||
在本章节中,我们了解了怎样安装ROS2和在Python虚拟环境中进行ROS2项目的开发。然后我们通过一些案例来更加深入的了解了ROS2的一些核心概念,即节点,主题,参数,服务,和动作。
|
||||
@@ -1,7 +1,7 @@
|
||||
## 小结
|
||||
|
||||
在这一章,我们简单介绍了机器人学习系统的基本概念,包括通用机器人操作系统、感知系统、规划系统和控制系统等,给读者对机器人学习问题的基本认识。当前,机器人学习是一个快速发展的人工智能分支,许多实际问题都有可能通过机器人学习算法的进一步发展得到解决。另一方面,由于机器人学习问题设置的特殊性,也使得相应系统与相关硬件的耦合程度更高、更复杂:如何更好地平衡各种传感器负载?如何在计算资源有限的情况下最大化计算效率(实时性)?等等,都需要对计算机系统的设计和使用有更好的理解。
|
||||
在这一章,我们简单介绍了机器人系统的基本概念,包括通用机器人操作系统、感知系统、规划系统和控制系统等,给读者对机器人问题的基本认识。当前,机器人许多实际问题都有可能通过算法的进一步发展得到解决。另一方面,由于机器人问题设置的特殊性,也使得相应系统与相关硬件的耦合程度更高、更复杂:如何更好地平衡各种传感器负载?如何在计算资源有限的情况下最大化计算效率(实时性)?等等,都需要对计算机系统的设计和使用有更好的理解。
|
||||
|
||||
## 参考文献
|
||||
|
||||
:bibliography:`../references/rlsys.bib`
|
||||
:bibliography:`../references/rlsys.bib`
|
||||
|
||||
BIN
img/ch01/framework-architecture.png
Normal file
|
After Width: | Height: | Size: 257 KiB |
|
Before Width: | Height: | Size: 418 KiB |
|
Before Width: | Height: | Size: 174 KiB |
BIN
img/ch01/system-ecosystem.png
Normal file
|
After Width: | Height: | Size: 531 KiB |
BIN
img/ch03/chain.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
img/ch03/dynamic_gen.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
img/ch03/eager-gen.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
img/ch03/eager.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
img/ch03/graph.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
img/ch03/if.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
img/ch03/order.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
img/ch03/recurrent.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
img/ch03/simpledag.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
img/ch03/static.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
img/ch03/static_gen.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
img/ch03/tensor.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
img/ch03/unroll.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
img/ch03/while.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
img/ch04/LLVM基础结构.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
img/ch04/TensorFlow-IR.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
img/ch04/编译器整体流程.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
img/ch05/SIMD.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
img/ch05/SIMT.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
img/ch05/memory_architecture.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
img/ch05/parallel_computing.png
Normal file
|
After Width: | Height: | Size: 253 KiB |
BIN
img/ch05/poly.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
img/ch05/poly_test.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
img/ch06/6.4/duplicated_data.png
Executable file
|
After Width: | Height: | Size: 299 KiB |
BIN
img/ch06/6.4/hide_global_latency.png
Executable file
|
After Width: | Height: | Size: 199 KiB |
BIN
img/ch06/6.4/hide_smem_latency.png
Executable file
|
After Width: | Height: | Size: 174 KiB |
BIN
img/ch06/6.4/naive.png
Executable file
|
After Width: | Height: | Size: 160 KiB |