diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ac1b365..e395dda 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,6 +12,10 @@ 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: conda install -y pandoc==2.17 diff --git a/chapter_accelerator/accelerator_architecture.md b/chapter_accelerator/accelerator_architecture.md index f527260..b932f9b 100644 --- a/chapter_accelerator/accelerator_architecture.md +++ b/chapter_accelerator/accelerator_architecture.md @@ -1,10 +1,10 @@ ## 加速器基本组成原理 -上节主要介绍了加速器的意义以及设计思路,了解到加速器与通用处理器在设计上的区别,因此加速器的硬件结构与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`所示: ![Volta GV100](../img/ch06/V100.svg) :width:`800px` @@ -17,32 +17,31 @@ - 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中能够调度的线程块数量就会受到限制,可执行的线程总数量会因此受到限制,可执行的线程数量过少会造成硬件无法充分的利用,性能急剧下降。所以要根据算法的需求合理使用寄存器。 - -- **共享内存(Shared Memory)**:共享内存实际上是用户可控的一级缓存,每个SM(流多处理器)中有128KB的一级缓存, 开发者可根据应用程序需要配置最大96KB的一级缓存作为共享内存。共享内存的访存延迟极低,只有几十个时钟周期。共享内存具有高达1.5TB/s的带宽,远远高于全局内存的峰值带宽900GB/s。所以说,共享内存的使用对于一个高性能计算工程师来说是一个必须要掌握的一个概念。 +- **寄存器文件(Register File)**:片上最快的存储器,但与CPU不同,GPU的每个SM(流多处理器)有上万个寄存器。尽管如此当每个线程使用过多的寄存器时,SM中能够调度的线程块数量就会受到限制,可执行的线程总数量会因此受到限制,可执行的线程数量过少会造成硬件无法充分的利用,性能急剧下降。所以要根据算法的需求合理使用寄存器。 + +- **共享内存(Shared Memory)**:共享内存实际上是用户可控的一级缓存,每个SM(流多处理器)中有128KB的一级缓存, 开发者可根据应用程序需要配置最大96KB的一级缓存作为共享内存。共享内存的访存延迟极低,只有几十个时钟周期。共享内存具有高达1.5TB/s的带宽,远远高于全局内存的峰值带宽900GB/s。共享内存的使用对于高性能计算工程师来说是一个必须要掌握的概念。 - **全局内存(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)相似,一次计算一个标量元素。 @@ -56,30 +55,31 @@ :width:`800px` :label:`compute-unit` -GPU计算单元主要由标量计算单元组成,而在Volta及以后的架构中还加入了三维向量计算单元。如 :numref:`SM`所示,对于每个SM,其中64个32位浮点运算单元、64个32位整数运算单元、32个64位浮点运算单元均为标量计算单元。而8个张量核则是专为神经网络应用设计的三维向量计算单元。 +GPU计算单元主要由标量计算单元和三维向量计算单元组成。如 :numref:`SM`所示,对于每个SM,其中64个32位浮点运算单元、64个32位整数运算单元、32个64位浮点运算单元均为标量计算单元。而8个张量计算核心则是专为神经网络应用设计的三维向量计算单元。 ![Volta GV100 流多处理器(SM)](../img/ch06/SM.svg) :width:`800px` :label:`SM` -张量核(Tensor Core)每个时钟周期完成一次$4\times4$的矩阵乘累加计算,如 :numref:`tensorcore`: +张量计算核心每个时钟周期完成一次$4\times4$的矩阵乘累加计算,如 :numref:`tensorcore`所示: ```cpp D = A * B + C ``` -![Tensor Core $4\times4$矩阵乘累加计算](../img/ch06/tensor_core.svg) +![张量计算核心$4\times4$矩阵乘累加计算](../img/ch06/tensor_core.svg) :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)等。 ![达芬奇架构设计](../img/ch06/davinci_architecture.svg) :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的运算精度。 +昇腾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的运算精度。 diff --git a/chapter_accelerator/accelerator_introduction.md b/chapter_accelerator/accelerator_introduction.md index 266c93d..c242642 100644 --- a/chapter_accelerator/accelerator_introduction.md +++ b/chapter_accelerator/accelerator_introduction.md @@ -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运算单元,就用于加速矩阵乘法的计算。 diff --git a/chapter_accelerator/accelerator_practise.md b/chapter_accelerator/accelerator_practise.md index 767283c..2e4ec99 100644 --- a/chapter_accelerator/accelerator_practise.md +++ b/chapter_accelerator/accelerator_practise.md @@ -1,16 +1,16 @@ ## 加速器实践 -在本节中,我们会通过具体的CUDA代码向读者介绍如何编写一个并行计算的广义矩阵乘法程序,通过提高计算强度、使用共享内存、优化内存读取流水线等方法最终取得接近硬件加速器性能峰值的实现。虽然在以上章节介绍了TensorCore相关的内容,但由于篇幅限制,我们在本节中不使用此硬件结构。通过使用更为基本的CUDA代码实现FP32的广义矩阵乘法,与此同时并讲解若干实用优化策略。 +在本节中会通过具体的CUDA代码向读者介绍如何编写一个并行计算的广义矩阵乘法程序,通过提高计算强度、使用共享内存、优化内存读取流水线等方法最终取得接近硬件加速器性能峰值的实现。虽然在以上章节介绍了张量计算核心相关的内容,但由于篇幅限制,在本节中不使用此硬件结构。而是通过使用更为基本的CUDA代码实现FP32的广义矩阵乘法,来讲解若干实用优化策略。 ### 环境 本节的实践有以下的软件环境依赖: * Eigen:Eigen是一个线性代数C++模板库,用户可以只使用几条语句完成多线程线性代数运算。 -* OpenMP(可选):OpenMP是用于共享内存并行系统的多处理器程序设计的一套指导性编译处理方案,我们可以使用OpenMP对Eigen的计算进行加速。 +* 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`)。 @@ -20,10 +20,7 @@ :label:`sec-accelerator-naive` -广义矩阵乘法指GEMM(General Matrix Multiplication),即$C = \alpha A\times B + \beta C$,其中$A\in\mathbb{R}^{M\times K}, B\in\mathbb{R}^{K\times N}, C\in\mathbb{R}^{M\times N}$。 - -矩阵$C$ 的第$m$行第$n$列元素$C_{m, n}$ 是由矩阵$A$中第$m$行$K$维向量和矩阵$B$中第$n$列$K$维向量的内积与$C_{m, n}$原始值加权求和得到的,因此在这种视角下的CPU代码为: - +依照算法:label:`algo-accelerator-gemm`,编写CPU代码如代码如下。 ```c++ float A[M][K]; float B[K][N]; @@ -41,7 +38,7 @@ for (unsigned m = 0; m < M; ++m) { } ``` -可以看到,矩阵$C$ 中各个元素的计算是独立的。我们可以利用GPU的大量线程去分别计算矩阵$C$ 中相应的元素,以达到并行计算的目的,GPU核函数将如下所示: +可以看到,矩阵$C$ 中各个元素的计算是独立的。可以利用GPU的大量线程去分别计算矩阵$C$ 中相应的元素,以达到并行计算的目的,GPU核函数将如下所示: ```c++ __global__ void gemmKernel(const float * A, @@ -67,7 +64,7 @@ __global__ void gemmKernel(const float * A, 其可视化结构如 :numref:`cuda_naive_gemm`所示,矩阵$C$中每一个元素由一个线程计算,在GPU Kernel的第5和6行计算该线程对应矩阵$C$中的元素行号$m$及列号$n$,然后在第9到11行该线程利用行号与列号读取矩阵$A$和矩阵$B$中相应的行列向量元素并计算向量内积,最后在第17行将结果写回$C$矩阵。 -![矩阵乘法的朴素实现](../img/ch06/6.4/naive.svg) +![矩阵乘法的朴素实现](../img/ch06/6.4/naive.png) :width:` 800px` :label:`cuda_naive_gemm` @@ -84,129 +81,18 @@ void gemmNaive(const float *A, const float *B, float *C, } ``` -在这里我们令每个线程块处理矩阵$C$中$16\times16$个元素,因此我们开启$(M - 1) / 16 + 1 \times (N - 1) / 16 + 1$个线程块用于计算整个矩阵$C$。 +在这里令每个线程块处理矩阵$C$中$16\times16$个元素,因此开启$(M - 1) / 16 + 1 \times (N - 1) / 16 + 1$个线程块用于计算整个矩阵$C$。 -接下来我们生成数据并执行: -```c++ -#include +使用Eigen生成数据并计算得到CPU端的广义矩阵乘法结果,同时实现了GPU端计算结果的误差计算、时间测试的代码,详情见[first_attempt.cu](https://github.com/openmlsys/openmlsys-cuda/blob/main/first_attempt.cu),编译及执行得到输出结果为: -using namespace Eigen; -int main() { - unsigned M = 2048, N = 2048, K = 1024; - float alpha = 1., beta = 1.; - float *deviceAPrt, *deviceBPtr, *deviceCPtr; - Matrix A{M, K}, B{K, N}, C{M, N}; - A.setRandom(); - B.setRandom(); - C.setRandom(); - cudaMalloc(&deviceAPrt, M * K * sizeof(float)); - cudaMemcpy(deviceAPrt, A.data(), M * K * sizeof(float), - cudaMemcpyHostToDevice); - cudaMalloc(&deviceBPtr, K * N * sizeof(float)); - cudaMemcpy(deviceBPtr, B.data(), K * N * sizeof(float), - cudaMemcpyHostToDevice); - cudaMalloc(&deviceCPtr, M * N * sizeof(float)); - cudaMemcpy(deviceCPtr, C.data(), M * N * sizeof(float), - cudaMemcpyHostToDevice); - gemmNaive(deviceAPrt, deviceBPtr, deviceCPtr, alpha, beta, M, N, K); - cudaDeviceSynchronize(); -} -``` -我们在代码的第8到11行利用Eigen构建并初始化矩阵$A, B, C$。在代码的第12到20行分配GPU内存并将CPU数据拷贝到GPU端。最后我们在第21行执行函数并在22行等待该函数结束。 - -接下来我们需要对我们实现的GPU代码测速并验证其数值正确性。对GPU代码的测速我们使用`cudaEvent`,`cudaEvent`可以记录GPU端程序事务并用来计算两个事务之间的耗时,使用方法如下段代码: - -```c++ -cudaEvent_t startEvent, stopEvent; -cudaEventCreate(&startEvent); -cudaEventCreate(&stopEvent); - -cudaEventRecord(startEvent); -gemmNaive(deviceAPrt, deviceBPtr, deviceCPtr, alpha, beta, M, N, K); -cudaEventRecord(stopEvent); - -cudaEventSynchronize(stopEvent); -float milliseconds = 0; -cudaEventElapsedTime(&milliseconds, startEvent, stopEvent); -printf("Average Time: %.3f ms\n", milliseconds); - -cudaEventDestroy(stopEvent); -cudaEventDestroy(startEvent); -``` - -具体地,我们首先声明类型为`cudaEvent_t`的变量,然后使用第2及第3行所示代码创建GPU事务,在待测的GPU代码起始时使用第5行的代码记录起始事务并在GPU代码结束后使用第7行的代码记录结束事务。通过使用第9到第12行代码计算两次事务间的时间差并打印,最后使用第12及第13行代码销毁GPU事务。 - -执行这段代码,得到输出结果: - -``` -Average Time: 46.354 ms -``` - -接下来我们实现数值验证的相关代码并计算CPU耗时: - -```c++ -#include - -// ... - -int main() { - // ... - Matrix hostResult{M, N}, - deviceResult{M, N}; - omp_set_num_threads(omp_get_num_procs()); - clock_t begin, end; - begin = clock(); - hostResult = alpha * (A * B) + beta * C; - end = clock(); - printf("Average Time: %.3f ms\n", double(end - begin) / CLOCKS_PER_SEC * 1e3); - cudaMemcpy(deviceResult.data(), deviceCPtr, M * N * sizeof(float), - cudaMemcpyDeviceToHost); - cudaDeviceSynchronize(); - - Eigen::Array diffArray = - (hostResult - deviceResult).array().abs(); - printf("Max Error: %f\n", diffArray.maxCoeff()); -} -``` - -我们在第9到14行使用CPU计算结果并计时,在第15行将GPU计算的结果拷贝到CPU内存中,在第19到21行计算误差并打印。值得注意的是我们在第9行使用了OpenMP以启用CPU多线程计算一般矩阵乘法。 - -完整代码在[first_attempt.cu](https://github.com/openmlsys/openmlsys-cuda/blob/main/first_attempt.cu)中,编译及执行指令如下: - -```bash -mkdir build && cd build -cmake .. -make first_attempt -./first_attempt -``` - -输出结果为: ``` Average Time: 48.961 ms Max Error: 0.000092 ``` -我们可以使用以下公式粗略的计算GPU的峰值吞吐量:2$\times$频率$\times$单精度计算单元数量 ,其中单精度计算单元数量等于GPU中流多处理器(SM)数量乘每个流多处理器中单精度计算单元数量;并用以下公式粗略的计算我们代码的吞吐量:2$\times$数据量$\div$时间: - -```c++ -int main() { - // ... - int gpu_rank = 0; - cudaDeviceProp deviceProp{}; - cudaGetDeviceProperties(&deviceProp, gpu_rank); - cudaSetDevice(gpu_rank); - double boostFrequency = deviceProp.clockRate / 1e6; - int1 fp32CoresNum = 128; - double peakPerformance = boostFrequency * fp32CoresNum * 2; - printf("FP32 peak throughput %.3f GFLOPS\n", peakPerformance); - double GFLOPS = 2 * 1e-9 * M * N * K / (milliseconds * 1e-3); - printf("Average Throughput: %.3f GFLOPS\n", GFLOPS); -} -``` - -执行可以输出: +可以使用以下公式粗略的计算GPU的峰值吞吐量:2$\times$频率$\times$单精度计算单元数量 ,其中单精度计算单元数量等于GPU中流多处理器(SM)数量乘每个流多处理器中单精度计算单元数量,计算可以得到以下结果: ``` FP32 peak throughput 29767.680 GFLOPS @@ -214,195 +100,28 @@ Average Throughput: 185.313 GFLOPS ``` 可以发现目前的代码距离设备峰值性能仍有较大的差距。在整个计算过程中计算密集最大的过程为矩阵乘法$A\times B$,其时间复杂度为$O(M*N*K)$,而整个计算过程时间复杂度为$O(M*N*K+2*M*N)$,因此对矩阵乘法的优化是提升性能的关键。 -#### 使用封装结构代替指针 - -在上面的实现中,由于二维矩阵的数据是使用一维数组进行存储,所以在访问数据时需要使用行坐标与二维矩阵宽度的乘积和列坐标的和来索引到具体位置的元素,这样的访问方式并不直观且在后续逐渐复杂的实现中容易出错。因此,我们可以自定义一个结构体,通过重载 `()` 运算符,实现对矩阵元素的二维索引,同时我们提供 `addOffset` 方法用于加入一个固定的偏移,具体实现如下: - -```c++ -template -struct __device_builtin__ Tensor2D { - T *const __restrict__ ptr; - const unsigned rows, cols; - int _rowOffset{0}, _colOffset{0}; - - template - __host__ __device__ Tensor2D(t &&ptr, unsigned rows, unsigned cols) - : ptr{reinterpret_cast(ptr)}, rows{rows}, cols{cols} {}; - - __host__ __device__ T &operator()(unsigned row, unsigned col) const { - return ptr[_colOffset + col + (row + _rowOffset) * cols]; - } - - template - __host__ __device__ void addOffset(int rowOffset, int colOffset) { - _rowOffset += rowOffset; - _colOffset += colOffset * sizeof(t) / sizeof(T); - } -}; -``` -另外,我们在读取数据数据之前需要对偏移判断是否越界,因此我们增加以下方法: -```c++ -template -struct __device_builtin__ Tensor2D { - // ... - __host__ __device__ bool validRowOffset(int rowOffset) const { - return (_rowOffset + rowOffset) < rows; - } - - __host__ __device__ bool validColOffset(int colOffset) const { - return (_colOffset + colOffset) < cols; - } - - __host__ __device__ bool validOffset(int rowOffset, - int colOffset) const { - return validRowOffset(rowOffset) && validColOffset(colOffset); - } -}; -``` -完整代码在[util.cuh](https://github.com/openmlsys/openmlsys-cuda/blob/main/util.cuh)。 -最终,我们可以将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; - Tensor2D tensorA{A, M, K}; - Tensor2D tensorB{B, K, N}; - Tensor2D tensorC{C, M, N}; - if (!tensorC.validOffset(m, n)) return; - float c = 0; - for (unsigned k = 0; k < K; ++k) { - c += tensorA(m, k) * tensorB(k, n); - } - c = c * alpha; - float result = c; - if (beta != 0) { - result = result + tensorC(m, n) * beta; - } - tensorC(m, n) = result; -} -``` ### 提高计算强度 -计算强度(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$会带来计算强度的提升。 +计算强度(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` 指针完成的,具体到硬件指令集上实际是使用指令 `LDG.E` 与 `STG.E` 完成的。可以使用128位宽指令`LDG.E.128` 与 `STG.E.128` 一次读取多个 `float` 数。使用宽指令的好处是一方面简化了指令序列,使用一个宽指令代替四个标准指令可以节省十几个指令的发射周期,这可以为计算指令的发射争取到额外的时间;另一方面128比特正好等于一个cache line的长度,使用宽指令也有助于提高cache line的命中率。但并不提倡在一切代码中过度追求宽指令的使用,开发者应当将更多的时间关注并行性设计和局部数据复用等更直接的优化手段。 -具体的实现如下,由于每个 `float` 类型大小为32个比特,我们可以将4个 `float` 堆叠在一起构成一个128比特的 `float4` 类,对 `float4` 的访存将会是使用宽指令完成。虽然CUDA Toolkit已经有实现的 `float4` 类,但是为了代码抽象我们将自行实现我们自己的 `float4` 类。 +具体的实现如下,由于每个 `float` 类型大小为32个比特,可以将4个 `float` 堆叠在一起构成一个128比特的 `float4` 类,对 `float4` 的访存将会是使用宽指令完成。其具体代码实现见[util.cuh](https://github.com/openmlsys/openmlsys-cuda/blob/main/util.cuh)中。 -```c++ -struct __device_builtin__ __builtin_align__(16) float4 { - float data[4]; +在实现GPU核函数过程中要注意,每个线程需要从原本各读取矩阵$A$和矩阵$B$中一个 `float` 数据变为各读取4个 `float` 数据,这就要求现在每个线程负责处理矩阵$C$中$4\times 4$的矩阵块,称之为 `thread tile` 。如图:numref:`use_float4`所示,每个线程从左到右、从上到下分别读取矩阵$A$和矩阵$B$的数据并运算,最后写入到矩阵$C$中。 - __host__ __device__ float operator[](unsigned idx) const { return data[idx]; } - __host__ __device__ float &operator[](unsigned idx) { return data[idx]; } - - __host__ __device__ float4 operator*(float other) const { - return float4{data[0] * other, data[1] * other, data[2] * other, - data[3] * other}; - } - - __host__ __device__ float4 operator+(const float4 &other) const { - return float4{data[0] + other.data[0], data[1] + other.data[1], - data[2] + other.data[2], data[3] + other.data[3]}; - } -}; -``` - -我们重载了`[]`运算符,从而可以通过索引访问`float4` 内部元素。此外我们定义了 `float4` 与 `float` 乘法及 `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` 。相应的GPU核函数应为以下形式: - -```c++ -__global__ void gemmKernel(const float *__restrict__ A, - const float *__restrict__ B, float *__restrict__ C, - float alpha, float beta, unsigned M, unsigned N, - unsigned K) { - constexpr unsigned kCount = sizeof(float4) / sizeof(float); - unsigned int m = (threadIdx.x + blockDim.x * blockIdx.x) * kCount; - unsigned int n = (threadIdx.y + blockDim.y * blockIdx.y) * kCount; - Tensor2D tensorA{A, M, K}; - tensorA.addOffset(m, 0); - Tensor2D tensorB{B, K, N / kCount}; - tensorB.addOffset(0, n / kCount); - Tensor2D tensorC{C, M, N / kCount}; - tensorC.addOffset(m, n / kCount); - if (!tensorC.validOffset(0, 0)) return; - - float4 c[4]; - memset(c, 0, sizeof(c)); - for (unsigned k = 0; k < K; ++k) { - float4 fragmentA{}; - for (unsigned i = 0; i < kCount; ++i) { - fragmentA[i] = tensorA(i, k); - } - float4 fragmentB = tensorB(k, 0); - - for (unsigned i = 0; i < kCount; ++i) { - c[i] = c[i] + fragmentB * fragmentA[i]; - } - } - - for (auto &term : c) { - term = term * alpha; - } - - for (unsigned i = 0; i < kCount; ++i) { - float4 result = c[i]; - if (beta != 0) { - result = c[i] + tensorC(i, 0) * beta; - } - tensorC(i, 0) = result; - } -} -``` - -我们首先在第6到14行计算每个线程需要处理的数据块在矩阵中的起始行列坐标`m,n`,即 :numref:`use_float4` 中矩阵$C$中浅绿色数据块的左上角坐标,然后使用`Tensor2D`中的`addOffset`方法,为每个线程定位到它要处理的数据块的起始位置上,并且利用`validOffset`方法判断线程是否越界。然后就可以沿着K方向循环,在第18到23行每个线程分别读取矩阵$A$中连续的4行和矩阵$B$中连续的四列组成两个 `float4` ,即 :numref:`use_float4` 中粉色与黄色的四个元素。之后在第25到27行计算线程负责处理矩阵$C$中的$4 \times 4$个元素。最后在第30到40行对结果使用参数 `alpha` 和 `beta` 进行放缩并写回矩阵$C$的内存。 - -![提高计算强度](../img/ch06/6.4/use_float4.svg) +![提高计算强度](../img/ch06/6.4/use_float4.png) :width:` 800px` :label:`use_float4` -为了将尺寸数据在编译期确定,减少执行期的额外数据读取开销,我们引入一个新的模板类 `Layout` ,这个类保存各种尺寸数据。其实现代码如下: -```c++ -template -struct Layout { - static constexpr int m = _m; - static constexpr int n = _n; - static constexpr int k = _k; -}; -``` -对上述代码稍加修改便可使用这个新的特性: -```c++ -template -__global__ void gemmKernel(const float *__restrict__ A, - const float *__restrict__ B, float *__restrict__ C, - float alpha, float beta, unsigned M, unsigned N, - unsigned K) { - constexpr unsigned kCount = sizeof(float4) / sizeof(float); - unsigned int m = (threadIdx.x + LayoutTile::m * blockIdx.x) * kCount; - unsigned int n = (threadIdx.y + LayoutTile::n * blockIdx.y) * kCount; - // ... -} -``` +完整代码见[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)。 -同时在启动时使用新修改的模板函数来启动GPU核函数: - -```c++ -using LayoutTile = Layout<16 * 4, 16 * 4>; - -gemmKernel<<>>(deviceAPtr, deviceBPtr, deviceCPtr, alpha, beta, - M, N, K); -``` -完整代码见[gemm_use_128.cu](https://github.com/openmlsys/openmlsys-cuda/blob/main/gemm_use_128.cu)。 - -#### 测试及分析 +![通过提高线程所处理矩阵块的数量来进一步提高计算强度](../img/ch06/6.4/use_tile.png) +:width:` 800px` +:label:`use_tile` 测试得到以下结果: @@ -411,307 +130,55 @@ Max Error: 0.000092 Average Time: 6.232 ms, Average Throughput: 1378.317 GFLOPS ``` -接下来我们使用分析工具Nsight Compute分析取得性能提升的具体原因。Nsight Compute是英伟达发布的主要针对GPU核函数的性能分析工具,它通过劫持驱动的方式对GPU底层数据采样和输出。可以使用以下指令进行性能分析: +使用分析工具Nsight Compute分析取得性能提升的具体原因。Nsight Compute是英伟达发布的主要针对GPU核函数的性能分析工具,它通过劫持驱动的方式对GPU底层数据采样和输出。可以使用以下指令进行性能分析: ```bash +bash ncu --set full -o ``` `--set full` 代表采样所有数据, `-o` 代表以文件的形式输出结果; `` 填输出文件名但注意不要加后缀名, `` 填待分析的可执行文件及其参数。 -比如我们需要分析 `first_attempt` ,将输出结果命名为 `first_attepmt_prof_result` 我们可以使用以下指令: +比如需要分析 `first_attempt` ,将输出结果命名为 `first_attepmt_prof_result` 可以使用以下指令: ```c++ ncu --set full -o first_attepmt_prof_result ./first_attempt ``` 如果提示权限不足可以使在指令前加`sudo` 。 -在得到输出文件之后,我们可以使用 `nv-nsight-cu` 查看文件。我们对我们改动的GPU核函数与上一版本的GPU核函数进行对比分析,发现: +在得到输出文件之后,可以使用 `nv-nsight-cu` 查看文件。对改动的GPU核函数与上一版本的GPU核函数进行对比分析,发现: -首先 `LDG` 指令数量下降了84%,且指标 `Stall LG Throttle` 下降33%,说明使用宽指令增加计算密度确实可以通过减少全局内存访问的指令数目而减少发射等待时间。最后指标 `Arithmetic Intensity` 的提升也和我们之前的关于计算强度的分析相吻合。 +首先 `LDG` 指令数量下降了84%,且指标 `Stall LG Throttle` 下降33%,说明使用宽指令增加计算密度确实可以通过减少全局内存访问的指令数目而减少发射等待时间。最后指标 `Arithmetic Intensity` 的提升也和之前的关于计算强度的分析相吻合。 -### 进一步提升计算强度 - -我们可以通过使每个线程负责处理更多的矩阵$C$中的数据块从而实现更高的计算强度,即如 :numref:`use_tile` 右侧所示,使 `thread tile` 扩大为4个$4 \times 4$矩阵的规模。我们对核函数进行以下修改,首先我们用`LayoutTile` 来描述每个线程块处理数据 `tile`的布局 ,其中 `LayoutTile::m` 和 `LayoutTile::n` 等于 :numref:`use_tile` 左图中浅绿色矩阵块的高度和宽度, `LayoutTile::k` 等于1;其次我们用`LayoutBlock` 来描述一个线程块中线程的布局;同时我们用`LayoutThread` 来描述 `thread tile` 中子矩阵的布局 ,其中`LayoutThread::m` 和 `LayoutThread::n` 等于 :numref:`use_tile` 右图中深绿色矩阵块的高度和宽度 。 - -![通过提高线程所处理矩阵块的数量来进一步提高计算强度](../img/ch06/6.4/use_tile.svg) -:width:` 800px` -:label:`use_tile` - -首先修改核函数签名: - -```c++ -template -__global__ void gemmKernel(const float *__restrict__ A, - const float *__restrict__ B, float *__restrict__ C, - float alpha, float beta, unsigned M, unsigned N, - unsigned K) { - // ... -} -``` - -然后改写线程负责处理数据的偏移量,即图3左图中的行列偏移值 `m` 和 `n` ,其代码实现如下 : - -```c++ -unsigned m = threadIdx.x * LayoutThread::m + LayoutTile::m * blockIdx.x; -unsigned n = threadIdx.y * LayoutThread::n + LayoutTile::n * blockIdx.y; -``` - -由于每个线程从原来的处理一个数据块变为多个数据块,我们需要以下几个变量: - -```c++ -const unsigned itekCountnA = LayoutTile::m / LayoutBlock::m / LayoutThread::m; -const unsigned itekCountnB = LayoutTile::n / LayoutBlock::n / LayoutThread::n; -const unsigned intervalA = LayoutTile::m / itekCountnA; -const unsigned intervalB = LayoutTile::n / itekCountnB; -``` - `itekCountnA` 是每个线程处理 `thread tile` 在行方向上迭代的次数。`intervalA` 是 `thread tile` 子矩阵在行方向的间隔。同理 `itekCountnB` 与 `intervalB` 是在列方向上数据块的数量与数据块的间隔。 -因为 `thread tile` 扩大为若干个矩阵块,我们使用以下代码用来记录每个矩阵块是否越界: - -```c++ -bool validLoadTileA[itekCountnA]; -bool validLoadTileB[itekCountnB]; -#pragma unroll -for (unsigned i = 0; i < itekCountnA; ++i) { - validLoadTileA[i] = pA.validRowOffset(i * intervalA); -} -#pragma unroll -for (unsigned i = 0; i < itekCountnB; ++i) { - validLoadTileB[i] = pB.validColOffset(i * intervalB / kCount); -} -``` - -对于数据的读取和累加计算相应的需要增加循环: - -```c++ -constexpr float4 float4Zero{0.f, 0.f, 0.f, 0.f}; -for (unsigned k = 0; k < K; ++k) { -#pragma unroll - for (unsigned iterA = 0; iterA < itekCountnA; ++iterA) { - float4 fragmentA{}; - validLoadTileA[iterA] &= pA.validColOffset(k); -#pragma unroll - for (unsigned i = 0; i < kCount; ++i) { - fragmentA[i] = validLoadTileA[i] ? pA(i + iterA * intervalA, k) : 0; - } -#pragma unroll - for (unsigned iterB = 0; iterB < itekCountnB; ++iterB) { - validLoadTileB[iterB] &= pB.validRowOffset(k); - float4 fragmentB = validLoadTileB[iterB] - ? pB(k, iterB * intervalB / kCount) - : float4Zero; -#pragma unroll - for (unsigned i = 0; i < kCount; ++i) { - c[iterA][iterB][i] = c[iterA][iterB][i] + fragmentB * fragmentA[i]; - } - } - } -} -``` -注意到我们此时使用了编译器指令 `#pragma unroll` 用于将循环展开,即如果循环次数是可以在编译时确定的话,编译器将会把带有判断和跳转的循环代码展开成串行代码。这样做的好处主要是减少了判断语句,此外还有利于编译器发现数据依赖从而更好地分配寄存器。缺点是可能会增加寄存器的使用,有潜在的降低GPU占用率的风险。 -最后对于结果使用 `alpha` 和 `beta` 的放缩以及写回也相应的加上数据块的循环: - -```c++ -#pragma unroll -for (auto &termA : c) { -#pragma unroll - for (auto &termB : termA) { -#pragma unroll - for (auto &term : termB) { - term = term * alpha; - } - } -} - -#pragma unroll -for (unsigned iterA = 0; iterA < itekCountnA; ++iterA) { -#pragma unroll - for (unsigned iterB = 0; iterB < itekCountnB; ++iterB) { -#pragma unroll - for (unsigned i = 0; i < kCount; ++i) { - float4 result{c[iterA][iterB][i]}; - if (beta != 0) { - result = result + - pC(i + iterA * intervalA, iterB * intervalB / kCount) * beta; - } - pC(i + iterA * intervalA, iterB * intervalB / kCount) = result; - } - } -} -``` -完整代码见[gemm_use_tile.cu](https://github.com/openmlsys/openmlsys-cuda/blob/main/gemm_use_tile.cu)。 - -#### 测试及分析 - -测试得到以下结果: +我们对`gemm_use_tile.cu`测试得到以下结果: ``` Max Error: 0.000092 Average Time: 3.188 ms, Average Throughput: 2694.440 GFLOPS ``` -使用Nsight Compute分析发现:类似地,本次优化在`Stall LG Throttle` 等指标上取得了进一步的提升。 +使用Nsight Compute分析发现:类似地,本次优化在 `Stall LG Throttle` 等指标上取得了进一步的提升。 ### 使用共享内存缓存复用数据 :label:`sec-accelerator-use-smem` -虽然令一个线程一次读取更多的数据能取得计算强度的提升进而带来性能的提升,但是这种令单个线程处理数据增多的设计会导致开启总的线程数量减少,进而导致并行度下降,因此我们需要使用其他硬件特性在尽可能不影响并行度的前提下取得性能提升。在之前的代码中,我们开启若干个线程块,每个线程块处理矩阵$C$中的一个或多个矩阵块。在 :numref:`duplicated_data` 中,我们可以观察到,处理矩阵$C$同一行的线程$x, y$会读取矩阵$A$中相同的数据,我们可以借助共享内存让同一个线程块中不同的线程读取不重复的数据而提升程序吞吐量。 +虽然令一个线程一次读取更多的数据能取得计算强度的提升进而带来性能的提升,但是这种令单个线程处理数据增多的设计会导致开启总的线程数量减少,进而导致并行度下降,因此需要使用其他硬件特性在尽可能不影响并行度的前提下取得性能提升。在之前的代码中,开启若干个线程块,每个线程块处理矩阵$C$中的一个或多个矩阵块。在 :numref:`duplicated_data` 中,可以观察到,处理矩阵$C$同一行的线程$x, y$会读取矩阵$A$中相同的数据,可以借助共享内存让同一个线程块中不同的线程读取不重复的数据而提升程序吞吐量。 -![线程间重复读取数据](../img/ch06/6.4/duplicated_data.svg) +![线程间重复读取数据](../img/ch06/6.4/duplicated_data.png) :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` 所示,每次循环从共享内存读取数据并计算。这种设计带来的好处是,我们可以让每个线程不必独自从全局内存读取所有需要的数据,整个线程块将共同需要的数据从全局内存中读取并写入到共享内存中,此后每个线程在计算过程中只需要从共享内存中读取所需要的数据即可。 +具体地,需要对代码进行如下改造:首先此前代码在计算内积过程是进行$K$次循环读取数据并累加计算,在此设定下每次循环中处理矩阵$C$中相同行的线程会读取相同的矩阵$A$的数据,处理矩阵$C$中相同列的线程会读取相同的矩阵$B$的数据。可以通过将此$K$次循环拆解成两层循环,外层循环$\frac{K}{tileK}$次,每次外层循环的迭代读取一整块数据,内层循环$tileK$次进行累加数据。数据从全局内存向共享内存的搬运过程如图 :numref:`use_smem_store` 所示,每次内层循环开始前将矩阵$A$和矩阵$B$中一整个 `tile` 读取到共享内存中;数据从共享内存到寄存器的搬运如图 :numref:`use_smem_load` 所示,每次内层循环循环从共享内存读取数据并计算。这种设计带来的好处是,可以让每个线程不必独自从全局内存读取所有需要的数据,整个线程块将共同需要的数据从全局内存中读取并写入到共享内存中,此后每个线程在计算过程中只需要从共享内存中读取所需要的数据即可。 -![向共享内存中写入数据](../img/ch06/6.4/use_smem_store.svg) +![向共享内存中写入数据](../img/ch06/6.4/use_smem_store.png) :width:` 800px` :label:`use_smem_store` -![从共享内存中读取数据](../img/ch06/6.4/use_smem_load.svg) +![从共享内存中读取数据](../img/ch06/6.4/use_smem_load.png) :width:` 800px` :label:`use_smem_load` -下面我们将实现使用共享内存的GPU核函数。首先,我们定义每个线程块在外层循环的每次迭代中从矩阵$A$中读取大小为$tileM \times tileK$的数据块,在矩阵$B$中读取大小为$tileK \times tileN$的数据块。假设每个线程块中一共含有$blockSize$个线程,那么就可以使用这$blockSize$个线程,每个线程循环$\frac{tileM * tileK}{blockSize * 4}$次将矩阵$A$中的矩阵块 `tileA` 读取进共享内存中,同理每个线程循环$\frac{tileM * tileK}{blockSize * 4}$次将矩阵$B$中的矩阵块 `tileB` 读取进共享内存中。 - -首先需要定义若干变量: - -```c++ -using LayoutTileT = - Layout; - using LayoutThreadT = - Layout; - -constexpr unsigned blockSize = LayoutBlock::m * LayoutBlock::n; - -const unsigned nInTileC = threadIdx.x % LayoutBlock::m; -const unsigned mInTileC = threadIdx.x / LayoutBlock::m; - -constexpr unsigned tileSizeA = LayoutTile::m * LayoutTile::k; -constexpr unsigned tileIterationsA = tileSizeA / blockSize / kCount; -constexpr unsigned tileGlobalIntervalA = blockSize / LayoutTileT::k; -constexpr unsigned tileComputeIterationsA = LayoutTileT::m / LayoutBlock::m; -constexpr unsigned tileSharedIntervalA = LayoutTile::m / tileComputeIterationsA; -const unsigned kInTileA = threadIdx.x % LayoutTileT::k; -const unsigned mInTileA = threadIdx.x / LayoutTileT::k; - -constexpr unsigned tileSizeB = LayoutTile::n * LayoutTile::k; -constexpr unsigned tileIterationsB = tileSizeB / blockSize / kCount; -constexpr unsigned tileGlobalIntervalB = blockSize / LayoutTileT::n; -constexpr unsigned tileComputeIterationsB = LayoutTileT::n / LayoutBlock::n; -constexpr unsigned tileSharedIntervalBT = LayoutTileT::n / tileComputeIterationsB; -const unsigned nInTileB = threadIdx.x % LayoutTileT::n; -const unsigned kinTileB = threadIdx.x / LayoutTileT::n; -``` -因为 `LayoutTile` 与 `LayoutThread` 是表示的 `float` 数据的布局,我们有时将其看为 `float4` 的数据储存,因此我们需要加入变量 `LayoutTileT` 与 `LayoutThreadT` 。 `blockSize` 指一个线程块内的线程数量。 我们在此版本使用一维线程块的布局模拟二维布局,所以我们需要计算在二维布局下的坐标:用 `mInTileC` 与 `nInTileC` 表示在给定 `LayoutBlock` 布局下的二维线程坐标。由于 `tileA` 是$tileM \times timeK$的尺寸,因此我们可以确定其中数据数量`tileSizeA` ,由于一个线程块内有 `blockSize` 个线程且每个线程一次读取 `kCount` 个 `float` 数,因此整个 `tileA` 需要用 `tileIterationsA = tileSizeA / blockSize / kCount` 次读取。每个线程在最开始时负责读取的 `tileA` 的位置使用变量 `kInTileA` 和 `mInTileA` 表示。因为需要用`tileIterationsA` 次读取 `tileA` ,每次向下滑动的距离我们使用变量`tileGlobalIntervalA`表示。同时因为需要用每个线程需要处理 `thread tile` 中多个子矩阵块,其中每个线程处理 `thread tile` 时在行方向上迭代的次数 定义为`tileComputeIterationsA` 。这些子矩阵块在 `m` 方向的间隔我们用`tileSharedIntervalA` 表示。类似地,我们定义与 `tileB` 的若干变量。 - -此外我们需要声明共享内存 `tile` 和从全局内存读取的数据 `buffer` : - -```c++ -__shared__ float4 tileA[LayoutTile::m][LayoutTileT::k]; -__shared__ float4 tileB[LayoutTile::k][LayoutTileT::n]; -float4 bufferA[tileIterationsA]; -float4 bufferB[tileIterationsB]; -``` - -我们使用以下代码将数据从全局内存中读出: - -```c++ -#pragma unroll -for (unsigned j = 0; j < tileIterationsA; ++j) { - validLoadTileA[j] = validLoadTileA[j] && pA.validColOffset(0); - bufferA[j] = - validLoadTileA[j] ? pA(j * tileGlobalIntervalA, 0) : float4Zero; -} - -#pragma unroll -for (unsigned j = 0; j < tileIterationsB; ++j) { - validLoadTileB[j] = - validLoadTileB[j] && pB.validRowOffset(j * tileGlobalIntervalB); - bufferB[j] = - validLoadTileB[j] ? pB(j * tileGlobalIntervalB, 0) : float4Zero; -} -``` - -从全局内存将数据读入 `buffer` 之后我们使用以下代码将数据写入共享内存: - -```c++ -__syncthreads(); -#pragma unroll -for (unsigned a = 0; a < tileIterationsA; ++a) { - tileA[mInTileA + a * tileGlobalIntervalA][kInTileA] = bufferA[a]; -} - -#pragma unroll -for (unsigned a = 0; a < tileIterationsB; ++a) { - tileB[kinTileB + a * tileGlobalIntervalB][nInTileB] = bufferB[a]; -} -__syncthreads(); -``` -不要忘记写入前和写入后进行一次同步避免数据竞争。 -此后我们使用以下代码执行内层循环: - -```c++ -#pragma unroll -for (unsigned j = 0; j < LayoutTile::k; j++) { -#pragma unroll - for (unsigned a = 0; a < tileComputeIterationsA; ++a) { -#pragma unroll - for (unsigned b = 0; b < LayoutThread::m; ++b) { - fragmentA[a][b] = - tileA[a * tileSharedIntervalA + mInTileC * LayoutThread::m + b] - [j / kCount][j % kCount]; - } - } -#pragma unroll - for (unsigned a = 0; a < tileComputeIterationsB; ++a) { - fragmentB[a] = tileB[j][a * tileSharedIntervalBT + nInTileC]; - } -#pragma unroll - for (unsigned d = 0; d < tileComputeIterationsA * LayoutThread::m; ++d) { -#pragma unroll - for (unsigned e = 0; e < tileComputeIterationsB * LayoutThreadT::n; ++e) { - c[d][e] = - c[d][e] + fragmentB[e] * - fragmentA[d / LayoutThread::m][d % LayoutThread::m]; - } - } -} -``` -内层循环的流程包括从共享内存中读取数据到 `fragment` ,使用 `fragment` 的数据进行计算。 -在内层循环结束后对全局内存增加偏移量后执行下一次外层循环: - -```c++ -pA.addOffset(0, LayoutTileT::k); -pB.addOffset(LayoutTile::k, 0); -``` - -其他计算放缩等代码与上一个版本基本一致,写回代码如下: - -```c++ -#pragma unroll -for (unsigned i = 0; i < tileComputeIterationsA; ++i) { -#pragma unroll - for (unsigned a = 0; a < LayoutThread::m; a++) { - const bool mValid = pC.validRowOffset(a); -#pragma unroll - for (unsigned b = 0; b < tileComputeIterationsB; b++) { - const bool nValid = pC.validColOffset(b * tileSharedIntervalBT); - if (mValid && nValid) { - openmlsys::float4 result{c[a + i * LayoutThread::m][b]}; - if (beta != 0) { - result = result + pC(a, b * tileSharedIntervalBT) * beta; - } - pC(a, b * tileSharedIntervalBT) = result; - } - } - } - pC.addOffset(tileSharedIntervalA, 0); -} -``` 完整代码见[gemm_use_smem.cu](https://github.com/openmlsys/openmlsys-cuda/blob/main/gemm_use_smem.cu)。 -#### 测试及分析 - 测试得到以下结果: ``` @@ -719,235 +186,69 @@ 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` 等都有显著提升,这都能充分解释了我们使用共享内存的改进是合理且有效的。 +通过使用Nsight Compute对核函数分析并与上一个核函数进行对比,可以观察到一些主要的变化:首先 `LDG` 指令数量下降了97%,与此前设计相吻合。同时观察到 `SM Utilization` 提升了218%也可以侧面证实使用共享内存减少了内存访问延迟从而提升了利用率,此外还可以观察到各项指标如 `Pipe Fma Cycles Active` 等都有显著提升,这都能充分解释了使用共享内存的改进是合理且有效的。 ### 减少寄存器使用 +可以注意到在向共享内存中存储矩阵$A$的数据块是按照行优先的数据排布进行的,而对此共享内存的读取是逐行读取的。可以将矩阵$A$的数据块在共享内存中数据按照列优先的形式排布,这样可以减少循环及循环变量从而带来寄存器使用数量减少进而带来性能提升。 -我们注意到在我们向共享内存中存储矩阵$A$的数据块是按照行优先的数据排布进行的,而我们对此共享内存的读取是按列逐行读取的。我们可以将矩阵$A$的数据块在共享内存中数据按照列优先的形式排布,这样我们可以减少循环及循环变量从而带来寄存器使用数量减少进而带来性能提升。 - -需要对代码做如下修改,首先将 `tileA` 修改为列优先矩阵: - -```c++ -__shared__ float4 tileA[LayoutTile::k][LayoutTileT::m]; -``` - -其次需要将写入 `tileA` 的过程按照列优先调整: - -```c++ -#pragma unroll - for (unsigned a = 0; a < tileIterationsA; ++a) { -#pragma unroll - for (unsigned j = 0; j < LayoutThread::m; ++j) { - tileA[kInTileA * kCount + j] - [(a * tileGlobalIntervalA + mInTileA) / kCount] - [(a * tileGlobalIntervalA + mInTileA) % kCount] = bufferA[a][j]; - } - } -``` - -最后修改从 `tileA` 读取的过程: - -```c++ -#pragma unroll - for (unsigned a = 0; a < tileComputeIterationsA; ++a) { - fragmentA[a] = tileA[j][a * tileSharedIntervalAT + mInTileC]; - } -``` 完整代码见[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架构上此改动可能不会带来正面影响。 +使用Nsight Compute分析有以下观察发现主要的变化:`Occupancy` 提升1.3%,而带来此提升的原因是寄存器使用111个,相比上一个GPU核函数使用128个寄存器减少了17个,从而带来了性能提升。但这个变化会因为GPU架构不同导致有不同的变化,同时可以观察到 `STS` 指令数量提升且带来一些 bank confilct ,因此在其他GPU架构上此改动可能不会带来正面影响。 ### 隐藏共享内存读取延迟 -在GPU中使用指令 `LDS` 读取共享内存中的数据,在这条指令发出后并不会等待数据读取到寄存器后再执行下一条语句,只有执行到依赖 `LDS` 指令读取的数据的指令时才会等待读取的完成。而在上一小节中,我们在内层$tileK$次循环中,每次发射完读取共享内存的指令之后就会立即执行依赖于读取数据的数学运算,这样就会导致计算单元等待数据从共享内存的读取,如 :numref:`use_smem_pipeline` 所示。事实上,对共享内存的访问周期能多达几十个时钟周期,而计算指令的执行往往只有几个时钟周期,因此通过一定方式隐藏对共享内存的访问会取得不小的收益。我们可以重新优化流水线隐藏一定的数据读取延迟。具体地,我们可以在内层的$tileK$次循环中每次循环开始时读取发射下一次内层循环数据的读取指令。由于在执行本次运算时计算指令并不依赖于下一次循环的数据,因此计算过程不会等待之前发出的读取下一次内层循环数据的指令,具体见 :numref:`hide_smem_latency` 。 +在GPU中使用指令 `LDS` 读取共享内存中的数据,在这条指令发出后并不会等待数据读取到寄存器后再执行下一条语句,只有执行到依赖 `LDS` 指令读取的数据的指令时才会等待读取的完成。而在上一小节中,在内层$tileK$次循环中,每次发射完读取共享内存的指令之后就会立即执行依赖于读取数据的数学运算,这样就会导致计算单元等待数据从共享内存的读取,如 :numref:`use_smem_pipeline` 所示。事实上,对共享内存的访问周期能多达几十个时钟周期,而计算指令的执行往往只有几个时钟周期,因此通过一定方式隐藏对共享内存的访问会取得不小的收益。可以通过重新优化流水线隐藏一定的数据读取延迟。如图 :numref:`hide_smem_latency` 所示,可以在内层的$tileK$次循环中每次循环开始时读取发射下一次内层循环数据的读取指令。由于在执行本次运算时计算指令并不依赖于下一次循环的数据,因此计算过程不会等待之前发出的读取下一次内层循环数据的指令。 -![上一个GPU核函数的流水线](../img/ch06/6.4/use_smem_pipeline.svg) +![上一个GPU核函数的流水线](../img/ch06/6.4/use_smem_pipeline.png) :width:` 800px` :label:`use_smem_pipeline` -![隐藏共享内存读取延迟的流水线](../img/ch06/6.4/hide_smem_latency.svg) +![隐藏共享内存读取延迟的流水线](../img/ch06/6.4/hide_smem_latency.png) :width:` 800px` :label:`hide_smem_latency` -我们对代码需要做如下修改,首先需要将`fragment` 的数量加倍用于存储下一次内循环读取的数据: - -```c++ -float4 fragmentA[2][tileComputeIterationsA * LayoutThreadT::m]; -float4 fragmentB[2][tileComputeIterationsB * LayoutThreadT::n]; -``` - -其后要在内层循环开始前从 `tile` 中向 `fragment` 传输数据: - -```c++ -#pragma unroll -for (unsigned a = 0; a < tileComputeIterationsA; ++a) { - fragmentA[0][a] = tileA[0][a * tileSharedIntervalAT + mInTileC]; -} -#pragma unroll -for (unsigned a = 0; a < tileComputeIterationsB; ++a) { - fragmentB[0][a] = tileB[0][a * tileSharedIntervalBT + nInTileC]; -} -``` - -同时在内层循环每次迭代的开始时读取下一次内层循环需要的 `tile` 中的数据: - -```c++ -#pragma unroll -for (unsigned a = 0; a < tileComputeIterationsA; ++a) { - fragmentA[(j + 1) % 2][a] = - tileA[j + 1][a * tileSharedIntervalAT + mInTileC]; -} -#pragma unroll -for (unsigned a = 0; a < tileComputeIterationsB; ++a) { - fragmentB[(j + 1) % 2][a] = - tileB[j + 1][a * tileSharedIntervalBT + nInTileC]; -} -``` -其中 `j` 为内存循环的次数。 -最后我们修改计算过程的代码 : - -```c++ -#pragma unroll -for (unsigned d = 0; d < tileComputeIterationsA * LayoutThread::m; ++d) { -#pragma unroll - for (unsigned e = 0; e < tileComputeIterationsA * LayoutThreadT::n; ++e) { - c[d][e] = - c[d][e] + - fragmentB[j % 2][e] * - fragmentA[j % 2][d / LayoutThread::m][d % LayoutThread::m]; - } -} -``` -其中 `j` 为内层循环的次数。 完整代码见[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` 的减少充分说明了内存延迟是有效的。 + +使用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$中的数据块的读取指令完成,从而实现隐藏全局内存读取延迟的目的。具体流水线可视化见 :numref:`hide_global_latency` 。 -![隐藏全局内存读取延迟的流水线](../img/ch06/6.4/hide_global_latency.svg) +上一小节中介绍了对共享内存读取流水线优化的方法,事实上,GPU在读取全局内存中使用的指令 `LDG` 也有与共享内存读取指令 `LDS` 类似的行为特性。因此类似的在$\frac{K}{tileK}$次外层循环中每次循环开始时发出下一次外层循环需要的矩阵$A$中的数据块的读取指令,而本次外循环的整个内层循环过程中不依赖下一次外循环的数据,因此本次外循环的内循环过程中不会等待对下一次外层循环需要的矩阵$A$中的数据块的读取指令完成,从而实现隐藏全局内存读取延迟的目的。此外,可以让内层循环先执行$tileK - 1$次,在最后一次执行前将 `buffer` 中的数据写入 `tile` ,其后再执行内层循环的最后一次迭代,这样能更进一步隐藏向 `tile` 写入的内存延迟。具体流水线可视化见图 :numref:`hide_global_latency` 。 + +![隐藏全局内存读取延迟的流水线](../img/ch06/6.4/hide_global_latency.png) :width:` 800px` :label:`hide_global_latency` -我们将对代码进行以下修改,首先需要将 `tile` 加倍并加入一个决定向哪个 `tile` 写入的符号 `writeStageIdx` : - -```c++ -__shared__ float4 tileA[2][LayoutTile::k][LayoutTileT::m]; -__shared__ float4 tileB[2][LayoutTile::k][LayoutTileT::n]; -bool writeStageIdx = false; -``` - -紧接着我们将从 `buffer` 向 `tile` 写入的过程相应的依照加倍后的 `tile` 修改 : - -```c++ -for (unsigned i = 0; i < tileIterationsA; ++i) { -#pragma unroll - for (unsigned j = 0; j < LayoutThread::m; ++j) { - tileA[writeStageIdx][kInTileA * kCount + j] - [(i * tileGlobalIntervalA + mInTileA) / kCount] - [(i * tileGlobalIntervalA + mInTileA) % kCount] = bufferA[i][j]; - } -} - -#pragma unroll -for (unsigned i = 0; i < tileIterationsB; ++i) { - tileB[writeStageIdx][kinTileB + i * tileGlobalIntervalB][nInTileB] = - bufferB[i]; -} -``` - -其后相应修改从 `tile` 向 `fragment` 读取数据的相关代码,并将符号 `writeStageIdx` 翻转: - -```c++ -#pragma unroll -for (unsigned i = 0; i < tileComputeIterationsA; ++i) { - fragmentA[0][i] = - tileA[writeStageIdx][0][i * tileSharedIntervalAT + mInTileC]; -} -#pragma unroll -for (unsigned i = 0; i < tileComputeIterationsB; ++i) { - fragmentB[0][i] = - tileB[writeStageIdx][0][i * tileSharedIntervalBT + nInTileC]; -} -writeStageIdx = !writeStageIdx; -``` - -接下来我们在每次外层循环开始时从全局内存读取下一次计算需要的 `buffer` : - -```c++ -tensorA.addOffset(0, LayoutTileT::k); -tensorB.addOffset(LayoutTile::k, 0); -#pragma unroll -for (unsigned j = 0; j < tileIterationsA; ++j) { - validLoadTileA[j] = validLoadTileA[j] && tensorA.validColOffset(0); - bufferA[j] = - validLoadTileA[j] ? tensorA(j * tileGlobalIntervalA, 0) : float4Zero; -} - -#pragma unroll -for (unsigned j = 0; j < tileIterationsB; ++j) { - validLoadTileB[j] = - validLoadTileB[j] && tensorB.validRowOffset(j * tileGlobalIntervalB); - bufferB[j] = - validLoadTileB[j] ? tensorB(j * tileGlobalIntervalB, 0) : float4Zero; -} -``` - -最后我们在内层循环结束后将预先读取的 `buffer` 写入到 `tile` 中并翻转符号位 `writeStageIdx` : - -```c++ -#pragma unroll -for (unsigned d = 0; d < tileIterationsA; ++d) { -#pragma unroll - for (unsigned e = 0; e < LayoutThread::m; ++e) { - tileA[writeStageIdx][kInTileA * kCount + e] - [(d * tileGlobalIntervalA + mInTileA) / kCount] - [(d * tileGlobalIntervalA + mInTileA) % kCount] = bufferA[d][e]; - } -} -#pragma unroll -for (unsigned a = 0; a < tileIterationsB; ++a) { - tileB[writeStageIdx][kinTileB + a * tileGlobalIntervalB][nInTileB] = - bufferB[a]; -} -writeStageIdx = !writeStageIdx; -``` - -事实上,我们可以让内层循环先执行$tileK - 1$次,在最后一次执行前将 `buffer` 中的数据写入 `tile` ,其后再执行内层循环的最后一次迭代,这样能更进一步隐藏向 `tile` 写入的内存延迟。 - 完整代码见[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` 主要是针对全局内存的指标。该指标的显著减少充分说明我们可以在一定程度上隐藏全局内存的读取。 + +使用Nsight Compute分析可以观察到指标 `Stall Long Scoreboard` 减少了67%,与上一小结的 `Stall Short Scoreboard` 概念相对应,`Stall Long Scoreboard` 主要是针对全局内存的指标。该指标的显著减少充分说明预取数据可以在一定程度上隐藏全局内存的读取。 ### 与cuBLAS对比 -前一节中介绍了cuBLAS的接口,我们可以很容易地写出以下代码使用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) { @@ -960,16 +261,29 @@ void cublasGemm(const float *A, const float *B, float *C, float alf, float bet, 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` 等优于我们,但总体上我们略胜一筹。 + +使用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` 类型的数据进行读取。 + + - 优化访存流水线——隐藏访存延迟:在进行内存结构变化(矩阵数据搬移)时,可以优化访存流水线,在数据搬移的间隔执行计算操作以隐藏数据搬移的延迟。 diff --git a/chapter_accelerator/accelerator_programming.md b/chapter_accelerator/accelerator_programming.md index 579ee25..535d12f 100644 --- a/chapter_accelerator/accelerator_programming.md +++ b/chapter_accelerator/accelerator_programming.md @@ -1,7 +1,7 @@ ## 加速器基本编程原理 :label:`accelerator-program-title` -本章前两节主要介绍了硬件加速器设计的意义、思路以及基本组成原理。软硬件协同优化作为构建高效AI系统的一个重要指导思想,需要软件算法/软件栈和硬件架构在神经网络应用中互相影响、紧密耦合。为了最大限度地发挥加速器的优势,要求能够基于硬件系统架构提供易用、高效的编程方法。因此,在本节中将着重介绍加速器的可编程性,包括编程接口直接调用方式及算子编译器优化方式。 +本章前两节主要介绍了这些硬件加速器设计的意义、思路以及基本组成原理。软硬件协同优化作为构建高效AI系统的一个重要指导思想,需要软件算法/软件栈和硬件架构在神经网络应用中互相影响、紧密耦合。为了最大限度地发挥加速器的优势,要求能够基于硬件系统架构设计出一套较为匹配的指令或编程方法。因此,本节将着重介绍加速器的可编程性,以及如何通过编程使能加速器,提升神经网络算子的计算效率。 ### 硬件加速器的可编程性 :label:`accelerator-programable-title` @@ -10,25 +10,25 @@ #### 编程接口使能加速器 -硬件加速器出于计算效率和易用性等方面考虑,将编程接口使能方式分为不同等级,一般包括:算子库层级,编程原语层级,以及指令层级。为了更具象的解释上述层级的区别,我们以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,通过搜索的方式,为目标算子生成调度及切分参数。 ![TVM](../img/ch06/TVM.svg) :width:`800px` @@ -37,7 +37,7 @@ - **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完成指令映射。 ![MLIR_Lowing](../img/ch06/MLIR-Lowing.svg) :width:`800px` @@ -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优化等。不仅提供了一个优化过的神经网络标准算子库,同时还提供了算子开发能力及融合能力。通过TBE提供的API和自定义算子编程开发界面可以完成相应神经网络算子的开发,帮助用户较容易的去使能硬件加速器上的AI Core指令,以实现高性能的神经网络计算。为了简化算子开发流程,TBE还实现了一个Auto +Schedule工具,开放了自定义算子编程DSL,用于自动完成复杂算子的调度生成。此外,TBE还实现了端到端的动态形状算子编译能力。 ![TBE](../img/ch06/TBE.svg) :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识别为硬件无关的通用优化与硬件相关的特定优化,编译时按照硬件特征拼接组合,实现异构硬件后端的快速适配。 ![AKG](../img/ch06/akg.png) :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}$。 ![矩阵乘法GEMM运算](../img/ch06/gemm.svg) :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 a_frag; @@ -130,21 +133,37 @@ wmma::fragment b_ wmma::fragment acc_frag; wmma::fragment 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指令集,还需要参考官方文档按照相应的语法规则编写,如代码所示。 -![mma指令之线程与矩阵元素映射关系](../img/ch06/ptx.svg) -:width:`800px` -:label:`PTX` +```cpp +half_t *a, *b; +float *C, *D; +unsigned const* A = reinterpret_cast(a); +unsigned const* B = reinterpret_cast(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) diff --git a/chapter_accelerator/index.md b/chapter_accelerator/index.md index e9cb2bc..78dc409 100644 --- a/chapter_accelerator/index.md +++ b/chapter_accelerator/index.md @@ -1,8 +1,9 @@ # 硬件加速器 -上一章节,我们详细讨论了计算图的基本组成,生成和执行等关键设计。当前主流深度学习模型大多基于神经网络实现,无论是训练还是推理,都会产生海量的计算任务,尤其是涉及矩阵乘法这种高计算任务的算子。另一方面,通用处理器芯片如CPU,在执行这类算子时通常耗时较大,难以满足训练/推理任务的需求。因此工业界和学术界都将目光投向特定领域的加速器芯片设计,希望以此来解决算力资源不足的问题。 +上一章节详细讨论了后端的计算图优化、算子选择以及内存分配。当前主流深度学习模型大多基于神经网络实现,无论是训练还是推理,都会产生海量的计算任务,尤其是涉及矩阵乘法这种高计算任务的算子。然而,通用处理器芯片如CPU在执行这类算子时通常耗时较大,难以满足训练和推理任务的需求。因此工业界和学术界都将目光投向特定领域的加速器芯片设计,希望以此来解决算力资源不足的问题。 -本章将会着重介绍加速器的基本组成原理,并且以矩阵乘法为例,介绍在加速器上的编程方式及优化方法。最后,介绍由异构算子组成的异构计算图表达与执行方式。 + +本章将会着重介绍加速器的基本组成原理,并且以矩阵乘法为例,介绍在加速器上的编程方式及优化方法。 本章的学习目标包括: @@ -12,8 +13,6 @@ - 理解编程API的设计理念 -- 理解异构硬件加速的表达与执行 - ```toc :maxdepth: 2 diff --git a/chapter_computational_graph/background_and_functionality.md b/chapter_computational_graph/background_and_functionality.md index c073834..e7472f6 100644 --- a/chapter_computational_graph/background_and_functionality.md +++ b/chapter_computational_graph/background_and_functionality.md @@ -1,20 +1,21 @@ ## 计算图的设计背景和作用 -![基于计算图的架构](../img/ch03/dag.svg) +![基于计算图的架构](../img/ch03/graph.png) :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++系统模块执行。这个数据结构(即计算图)可以表述用户的输入数据、模型中的计算逻辑(通常称为算子)以及算子之间的执行顺序。 - **自动化计算梯度。** - 用户给定的训练程序仅仅包含了一个机器学习模型如何将用户输入(一般为训练数据)转化为输出(一般为损失函数)的过程。而为了训练这个模型,机器学习框架需要分析任意机器学习模型和其中的算子,找出自动化计算梯度的方法。计算图的出现让自动化分析模型定义和自动化计算梯度成为可能。 + 用户的模型训练程序接收训练数据集的数据样本,通过神经网络前向计算,最终计算出损失值。根据损失值,机器学习系统为每个模型参数计算出梯度来更新模型参数。考虑到用户可以写出任意的模型拓扑和损失值计算方法,计算梯度的方法必须通用并且能实现自动运行。计算图可以辅助机器学习系统快速分析参数之间的梯度传递关系,实现自动化计算梯度的目标。 + +- **分析模型变量生命周期。** + 在用户训练模型的过程中,系统会通过计算产生临时的中间变量,如前向计算中的激活值和反向计算中的梯度。前向计算的中间变量可能与梯度共同参与到模型的参数更新过程中。通过计算图,系统可以准确分析出中间变量的生命周期(一个中间变量生成以及销毁时机),从而帮助框架优化内存管理。 - **优化程序执行。** - 用户给定的模型程序往往是"串行化"地连接起来多个神经网络层。通过利用计算图来分析模型中算子的执行关系,机器学习框架可以更好地发现将算子进行异步执行的机会,从而以更快的速度完成模型程序的执行。 \ No newline at end of file + 用户给定的模型程序具备不同的网络拓扑结构。机器学习框架利用计算图来分析模型结构和算子执行依赖关系,并自动寻找算子并行计算的策略,从而提高模型的执行效率。 \ No newline at end of file diff --git a/chapter_computational_graph/components_of_computational_graph.md b/chapter_computational_graph/components_of_computational_graph.md index 8262a8e..eed6e9c 100644 --- a/chapter_computational_graph/components_of_computational_graph.md +++ b/chapter_computational_graph/components_of_computational_graph.md @@ -1,95 +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})$转化为计算图表示。 -![简单计算图](../img/ch03/simpledag.svg) +![简单计算图](../img/ch03/simpledag.png) :width:`300px` :label:`simpledag` ### 张量和算子 -在计算框架中,基础组件包含张量和算子,张量是基础数据结构,算子是基本运算单元。在数学中定义张量是基于向量与矩阵的推广,涵盖标量、向量与矩阵的概念。可以将标量理解为零阶张量,向量为一阶张量,我们熟悉的RGB彩色图像即为三阶张量。在计算框架中张量不仅存储数据,还存储数据类型、数据形状、维度或秩以及梯度传递状态等多个属性,如:numref:`tensor_attr`所示,列举了主要的属性和功能。可以通过[代码示例](https://github.com/openmlsys/openmlsys-pytorch/blob/master/chapter_computational_graph/tensor.py)查看张量的属性和部分操作展示 +在数学中定义张量是基于标量与向量的推广。在机器学习领域内将多维数据称为张量,使用秩来表示张量的轴数或维度。如:numref:`tensor`所示,标量为零秩张量,包含单个数值,没有轴;向量为一秩张量,拥有一个轴;拥有RGB三个通道的彩色图像即为三秩张量,包含三个轴。 +![张量](../img/ch03/tensor.png) +:width:`800px` +:label:`tensor` + +在机器学习框架中张量不仅存储数据,还需要存储张量的数据类型、数据形状、秩以及梯度传递状态等多个属性,如:numref:`tensor_attr`所示,列举了主要的属性和功能。 :张量属性 | 张量属性 | 功能 | | -------------- | -------| | 形状(shape) | 存储张量的每个维度的长度,如[3,3,3] | -| 维度或秩(dim) | 表示张量维度的数量,标量为0,向量为1、矩阵为2 | -| 数据类型(dtype) | 表示存储的数据类型,如bool、int8、int16、float32、float64等 | +| 秩或维数(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或其他硬件上执行计算,数据和权重参数也应当存放在对应的硬件内存中才能正确被调用,张量存储位置属性则用来指明存储的设备位置。存储位置属性通常由机器学习框架根据硬件环境自动赋予张量。在模型训练过程中,张量数据的存储状态可以分为可变和不可变两种,可变张量存储神经网络模型权重参数,根据梯度信息更新自身数据,如参与卷积运算的卷积核张量;不可变张量用于用户初始化的数据或者输入模型的数据,如上文提到的图像数据张量。 -![张量](../img/ch03/tensor.svg) -:width:`800px` -:label:`tensor` -通常我们使用的张量是"整齐"的,每个轴上的具有相同的元素个数,就像一个"矩形"或者"立方体"。在特定的环境中,也会使用特殊类型的张量,比如不规则张量和稀疏张量,如 :numref:`tensorclass`中所示。不规则张量在某个轴上可能具有不同的元素个数,它们支持存储和处理包含非均匀形状的数据,在自然语言处理领域,不规则张量可以存储不同长度文本的信息。稀疏张量则通常应用于图数据与图神经网络中,采用特殊的存储格式如坐标表格式(Coordinate -List, COO),可以高效存储稀疏数据,节省存储空间。 +那么在机器学习场景下的张量一般长什么样子呢?上文提到的图像数据张量以及卷积核张量,形状一般是“整齐”的。即每个轴上的具有相同的元素个数,就像一个“矩形”或者“立方体”。在特定的环境中,也会使用特殊类型的张量,比如不规则张量和稀疏张量。如 :numref:`tensorclass`中所示,不规则张量在某个轴上可能具有不同的元素个数,它们支持存储和处理包含非均匀形状的数据,如在自然语言处理领域中不同长度文本的信息;稀疏张量则通常应用于图数据与图神经网络中,采用特殊的存储格式如坐标表格式(Coordinate List,COO),可以高效存储稀疏数据节省存储空间。 ![张量分类](../img/ch03/tensorclass.svg) :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算子移除则该节点无输出,导致后续的激活函数无法得到输入,从而计算图中的数据流动中断,这表明计算图中的算子间具有依赖关系并且存在传递性。 ![计算依赖](../img/ch03/dependence.svg) :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,这些情况都会致使模型训练无法得到预期结果。在构建深度学习模型时,应避免算子间产生循环依赖。 ![循环依赖](../img/ch03/recurrent.svg) :width:`300px` :label:`recurrent` -在深度学习计算框架中,表示循环关系通常是以**展开**机制(Unrolling)来实现。当需要实现循环关系时,循环体的计算子图按照迭代次数进行复制,将代表相邻迭代轮次的子图进行串联,相邻迭代轮次的计算子图之间就是直接依赖关系。循环三次的计算图进行展开如 :numref:`unroll`。在计算图中,每一个张量和运算符都具有独特的标识符,即使是相同的操作运算,在参与不同计算任务时都具有不同的标识符。区分循环关系和循环依赖的关键在于,是否两个独特标识符之间的运算互相具有直接依赖和相互依赖。循环关系在展开复制计算子图的时候会给复制的所有张量和运算符赋予新的标识符,区分被复制的原始子图,以避免形成循环依赖。 +在机器学习框架中,表示循环关系(Loop Iteration)通常是以**展开**机制(Unrolling)来实现。循环三次的计算图进行展开如 :numref:`unroll`,循环体的计算子图按照迭代次数进行复制3次,将代表相邻迭代轮次的子图进行串联,相邻迭代轮次的计算子图之间是直接依赖关系。在计算图中,每一个张量和运算符都具有独特的标识符,即使是相同的操作运算,在参与循环不同迭代中的计算任务时具有不同的标识符。区分循环关系和循环依赖的关键在于,具有两个独特标识符的计算节点之间是否存在相互依赖关系。循环关系在展开复制计算子图的时候会给复制的所有张量和运算符赋予新的标识符,区分被复制的原始子图,以避免形成循环依赖。 -![循环展开](../img/ch03/unroll.svg) + +![循环展开](../img/ch03/unroll.png) :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: @@ -98,11 +108,13 @@ def control(A, B, C, conditional = True): y = matmul(A, C) return y ``` -![条件控制计算图](../img/ch03/if.svg) + + :numref:`if`描述上述代码的前向计算图和反向计算图。对于具有if条件的模型,梯度计算需要知道采用了条件的哪个分支,然后将梯度计算逻辑应用于该分支。在前向计算图中张量$\boldsymbol{C}$经过条件控制不参与计算,在反向计算时同样遵守控制流决策,不会计算关于张量$\boldsymbol{C}$的梯度。 + +![条件控制计算图](../img/ch03/if.png) :width:`600px` :label:`if` - :numref:`if`描述上述代码的前向计算图和反向计算图。对于具有if-条件的模型,梯度计算需要知道采用了条件的哪个分支,然后将梯度逻辑应用于该分支。在前向计算图中张量${C}$经过条件控制不参与计算,在反向计算时同样遵守控制流决策,不会计算关于张量$C$的梯度。 当模型中有循环控制时,循环中的操作可以执行零次或者多次。此时采用展开机制,对每一次操作都赋予独特的运算标识符,以此来区分相同运算操作的多次调用。每一次循环都直接依赖于前一次循环的计算结果,所以在循环控制中需要维护一个张量列表,将循环迭代的中间结果缓存起来,这些中间结果将参与前向计算和梯度计算。下面这段代码描述了简单的循环控制,将其展开得到等价代码后,可以清楚的理解需要维护张量$\boldsymbol{X_i}$和$\boldsymbol{W_i}$的列表。 ```python @@ -117,36 +129,74 @@ def recurrent_control(X : Tensor, W : Sequence[Tensor]): Y = matmul(X2, W2) return Y ``` -如 :numref:`while`描述了上述代码的前向计算图和反向计算图,循环控制的梯度同样也是一个循环,它与前向循环相迭代次数相同,执行循环体的梯度计算。循环体输出的梯度值作为下一次梯度计算的初始值,直至循环结束。 +如 :numref:`while`描述了上述代码的前向计算图和反向计算图,循环控制的梯度同样也是一个循环,它与前向循环的迭代次数相同。执行循环体的梯度计算中,循环体当前迭代计算输出的梯度值作为下一次迭代中梯度计算的输入值,直至循环结束。 -![循环控制计算图](../img/ch03/while.svg) +![循环控制计算图](../img/ch03/while.png) :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}$。 + +![反向传播局部计算图](../img/ch03/chain.png) +: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解决了两个疑问后,可以发现计算图在利用链式法则构建反向计算图时,会对计算过程进行分析保存模型中的中间结果和梯度传递状态,通过占用部分内存复用计算结果达到提高反向传播计算效率的目的。 + +将上述的链式法则推导推广到更加一般的情况,结合控制流的灵活构造,机器学习框架均可以利用计算图快速分析出前向数据流和反向梯度流的计算过程,正确的管理中间结果内存周期,更加高效的完成计算任务。 + + + diff --git a/chapter_computational_graph/generation_of_computational_graph.md b/chapter_computational_graph/generation_of_computational_graph.md index 22729c2..bb75ba9 100644 --- a/chapter_computational_graph/generation_of_computational_graph.md +++ b/chapter_computational_graph/generation_of_computational_graph.md @@ -1,15 +1,21 @@ ## 计算图的生成 -计算框架执行深度学习模型训练时,会根据模型结构生成计算图,通过调度计算图完成模型计算。在计算框架中可以生成静态图和动态图两种计算图。静态图对应声明式编程范式,动态图对应命令式编程范式。静态生成可以根据前端语言描述的神经网络拓扑结构以及参数变量等信息构建一份固定的计算图,因此静态图在执行期间可以不依赖前端语言描述,常用于神经网络模型的部署,比如移动端人脸识别场景中的应用等。动态图则需要在每一次执行神经网络模型依据前端语言描述动态生成一份临时的计算图,这意味着计算图的动态生成过程灵活可变,该特性有助于我们在神经网络结构调整阶段提高效率。主流计算框架TensorFlow、MindSpore均支持动态图和静态图模式;PyTorch则可以通过工具将构建的动态图神经网络模型转化为静态结构,以获得高效的计算执行效率。了解两种计算图生成方式的优缺点及构建执行特点,可以针对待解决的任务需求,选择合适的生成方式调用执行神经网络模型。 +在了解计算图的基本构成后,那么下一个问题就是:计算图要如何自动化生成呢?在机器学习框架中可以生成静态图和动态图两种计算图。静态生成可以根据前端语言描述的神经网络拓扑结构以及参数变量等信息构建一份固定的计算图。因此静态图在执行期间可以不依赖前端语言描述,常用于神经网络模型的部署,比如移动端人脸识别场景中的应用等。 + +动态图则需要在每一次执行神经网络模型依据前端语言描述动态生成一份临时的计算图,这意味着计算图的动态生成过程灵活可变,该特性有助于在神经网络结构调整阶段提高效率。主流机器学习框架TensorFlow、MindSpore均支持动态图和静态图模式;PyTorch则可以通过工具将构建的动态图神经网络模型转化为静态结构,以获得高效的计算执行效率。了解两种计算图生成方式的优缺点及构建执行特点,可以针对待解决的任务需求,选择合适的生成方式调用执行神经网络模型。 ### 静态生成 -静态图的生成与执行原理如 :numref:`static`所示,采用先编译后执行的方式,该模式将计算图的定义和执行进行分离。在静态图模式下使用前端语言定义模型形成完整的程序表达后,并不使用前端语言解释器进行执行,而是将前端描述的完整模型交给计算框架。框架在执行模型计算之前会首先对神经网络模型进行分析,获取网络层之间的连接拓扑关系以及参数变量设置、损失函数等信息,接着用一种特殊的静态数据结构来描述拓扑结构及其他神经网络模型组件,这种特殊的静态数据结构通常被称为静态计算图。静态计算图可以通过优化策略转换成等价的更加高效的结构。当进行模型训练或者推理过程时,静态计算图接收数据并通过相应硬件调度执行图中的算子来完成任务。 +静态图的生成与执行原理如 :numref:`static`所示,采用先编译后执行的方式,该模式将计算图的定义和执行进行分离。 -![静态图生成与执行](../img/ch03/static.svg) +![静态图生成与执行](../img/ch03/static.png) :width:`800px` :label:`static` -以构建并执行下列伪代码,来详细讲解静态图的生成与执行,*matmul*表示矩阵乘法算子,*relu*表示线性矫正单元算子。在部分计算框架中如TensorFlow进行前端定义时,需要声明并编写包含数据占位符、损失函数、优化函数、网络编译、执行环境以及网络执行器等在内的预定义配置项,此外还需要使用图内控制流算子编写控制语句,代码较为繁琐并缺乏可读性。随着计算框架设计的改进与发展,框架提供的编程接口和模型构建模式呈现出更加统一和友好的趋势,比如MindSpore提供动静态统一的前端编程表达。因此为了便于理解静态生成的过程与原理,此处使用更加简洁的语言逻辑描述模型。 + +使用前端语言定义模型形成完整的程序表达后,机器学习框架首先对神经网络模型进行分析,获取网络层之间的连接拓扑关系以及参数变量设置、损失函数等信息。然后机器学习框架会将完整的模型描述编译为可被后端计算硬件调用执行的固定代码文本,这种固定代码文本通常被称为静态计算图。当使用静态计算图进行模型训练或者推理过程时,无需编译前端语言模型。静态计算图直接接收数据并通过相应硬件调度执行图中的算子来完成任务。静态计算图可以通过优化策略转换成等价的更加高效的结构,提高后端硬件的计算效率。 + +以构建并执行下列伪代码,来详细讲解静态图的生成与执行。在部分机器学习框架中进行前端定义时,需要声明并编写包含数据占位符、损失函数、优化函数、网络编译和执行环境以及网络执行器等在内的预定义配置项,此外还需要使用图内控制流算子编写控制语句。随着机器学习框架设计的改进与发展,框架趋向于提供的友好的编程接口和统一的模型构建模式,比如MindSpore提供动静态统一的前端编程表达。因此为了便于理解静态生成的过程与原理,此处使用更加简洁的语言逻辑描述模型。 + ```python def model(X, flag): if flag>0: @@ -20,62 +26,74 @@ def model(X, flag): Y = relu(Y) return Y ``` -完成前端语言的模型完整构建表达后,执行模型运算时不会直接接收输入数据进行计算,而是使用计算框架的编译器对模型进行编译。由于在进行静态生成编译时并不读取输入数据,此时需要一种特殊的张量来表示输入数据辅助构建完整的计算图,这种特殊张量就被称之为"数据占位符"。在上述的伪代码中输入数据**X**需要使用占位符在静态图中表示。构造伪代码中的条件控制时,由于在静态图模式下构建网络并没有执行任何计算,对于条件控制在编译阶段并不会进行逻辑运算完成判断,因此需要将条件控制算子以及所有的分支计算子图加入计算图中。在执行阶段网络接受数据流入,调度条件控制算子时进行逻辑判断,控制数据流入不同的分支计算子图中进行后续计算。由于控制流和静态生成的特殊性,在部分计算框架中前端语言Python的控制流不能够被正确编译为等价的静态图结构,因此需要使用复杂的图内方法实现控制流。 +机器学习框架在进行静态生成编译时并不读取输入数据,此时需要一种特殊的张量来表示输入数据辅助构建完整的计算图,这种特殊张量就被称为:数据占位符(Placeholder )。在代码第1行中输入数据$\boldsymbol{X}$需要使用占位符在静态图中表示。由于静态生成时模型无数据输入,因此代码第2行中的条件控制,也无法进行逻辑计算,条件控制在编译阶段并不会完成判断,因此需要将条件控制算子以及所有的分支计算子图加入计算图中。在静态计算图执行计算阶段网络接收数据流入,调度条件控制算子根据输入数据进行逻辑判断,控制数据流入不同的分支计算子图中进行后续计算。在部分机器学习框架中前端语言Python的控制流不能够被正确编译为等价的静态图结构,因此需要机器学习框架的控制原语来实现控制流。 -静态生成的过程是采用计算框架编译器将代码编译为中间表示。计算框架编译器受传统编译器方案启发,设计体系结构包含两部分:编译器前端和编译器后端。中间表示承上启下贯穿前端和后端,是前端源代码和目标硬件代码之间的中间数据格式。在计算框架编译器中中间表示以计算图形式存在,编译器会根据前端神经网络模型自动构建完整的前向计算图和反向计算图。 - -![静态生成](../img/ch03/static-gen.svg) +![静态生成](../img/ch03/static_gen.png) :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`所示,采用解析式的执行方式,其核心特点是编译与执行同时发生。动态图采用前端语言自身的解释器对代码进行解析,利用机器学习框架本身的算子分发功能,算子会即刻执行并输出结果。动态图模式采用用户友好的命令式编程范式,使用前端语言构建神经网络模型更加简洁,深受广大深度学习研究者青睐。 -![动态图原理](../img/ch03/dynamic.svg) +![动态图原理](../img/ch03/eager.png) :width:`600px` :label:`dynamic` -由于动态图模式的编程友好性,动态图深受广大深度学习研究者青睐。接下来使用上一小节的伪代码来讲解动态生成和静态生成的区别。 +接下来使用上一小节的伪代码来讲解动态生成和静态生成的区别。 -尽管静态图和动态图在前端语言表达上略有差异,但本质的区别在于静态生成和动态生成的编译执行过程不同。使用前端语言构建完成模型表达后,动态生成并不采用计算框架编译器生成完整的静态计算图,而是采用前端语言的解释器Python API调用计算框架,框架利用自身的算子分发功能,将Python调用的算子在相应的硬件如CPU、GPU、NPU等上进行加速计算,然后再将计算结果返回给前端。该过程并不产生静态的计算图,而是按照前端语言描述模型结构,按照计算依赖关系进行调度执行,动态生成临时的图拓扑结构。 +静态图和动态图除了在前端语言表达上略有差异,本质的区别在于编译执行过程。使用前端语言构建完成模型表达后,动态生成并不采用机器学习框架编译器生成完整的静态计算图,而是采用前端语言的解释器Python API调用机器学习框架,框架利用自身的算子分发功能,将Python调用的算子在相应的硬件如CPU、GPU、NPU等上进行加速计算,然后再将计算结果返回给前端。该过程并不产生静态的计算图,而是按照前端语言描述模型结构,按照计算依赖关系进行调度执行,动态生成临时的图拓扑结构。 -![动态生成](../img/ch03/dynamic_gen.png) + +如:numref:`dynamicgen`中所示动态生成流程。 + +![动态生成](../img/ch03/eager-gen.png) :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}$,机器学习框架依据收集的信息完成前向计算和反向图构建。 尽管动态生成中完整的网络结构在执行前是未知的,不能使用静态图中的图优化技术来提高计算执行性能。但其即刻算子调用与计算的能力,使得模型代码在运行的时候,每执行一句就会立即进行运算并会返回具体的值,方便开发者在模型构建优化过程中进行错误分析、结果查看等调试工作,为研究和实验提供了高效的助力。 -此外得益于动态图模式灵活的执行计算特性,动态生成可以使用前端语言的原生控制流,充分发挥前端语言的编程友好性特性。解决了静态图中代码难调试、代码编写繁琐以及控制流复杂等问题,对于初学者更加友好,提高了算法开发迭代效率和神经网络模型改进速率。 +此外得益于动态图模式灵活的计算执行特性,动态生成可以使用前端语言的原生控制流,充分发挥前端语言的编程友好性特性。解决了静态图中代码难调试、代码编写烦琐以及控制流复杂等问题,对于初学者更加友好,提高了算法开发迭代效率和神经网络模型改进速率。 ### 动态和静态生成的比较 -静态生成和动态生成的过程各有利弊。从使用者的角度可以直观的感受到静态图不能实时获取中间结果、代码调试困难以及控制流编写复杂,而动态图可以实时获取结果、调试简单、控制流符合编程习惯。虽然静态图的编写、生成过程复杂,但是相应的执行性能却超过动态图,我们用一个简单的例子来说明在性能和内存占用方面静态图的优势。 -```python -def model(X1, X2): - Y1 = matmul(X1, W1) - Y2 = matmul(X2, W2) - Y = Y1 + Y2 - output = relu(Y) - return output -``` -在静态生成过程中,计算框架获取完整的计算图可以分析出计算$\boldsymbol{Y_1}$和$\boldsymbol{Y_2}$的过程相对独立,可以将其进行自动并行计算,加快计算效率。而动态生成的过程中,若无手动配置并行策略,计算框架无法获取图结构不能分析出算子之间的独立性,则只能按照代码顺序执行。模型在输出结果之前执行了*add*和*relu*算子操作,在静态生成过程中利用计算图优化策略中的算子融合方法,可以将这两个算子融合为一个算子执行,这样减少了中间变量$\boldsymbol{Y}$的存储与读取过程,加快了计算效率,减少了内存占用。而动态生成过程则需要按照顺序执行*add*和*relu*两步操作,需要存储变量$\boldsymbol{Y}$。除此之外,由于静态生成能够同时分析重构出前向计算图和反向计算图,可以提前确定反向计算中需要保存的前向中间变量信息。而动态生成则在完成前向计算后才能构建出反向计算图,为了保证反向计算效率需要保存更多的前向计算中间变量信息,相比之下静态生成的过程更加节省内存占用。 - -为了方便读者对比,将静态图和动态图特性总结见 :numref:`cmp_dynamic_static`。 +静态生成和动态生成的过程各有利弊。为了方便读者对比,将静态图和动态图特性总结见:numref:`cmp_dynamic_static`。 :静态图和动态图对比 @@ -89,11 +107,38 @@ def model(X1, X2): | 内存占用 | 可直接部署 | 不可直接部署 | :label:`cmp_dynamic_static` + +从使用者的角度可以直观的感受到静态图不能实时获取中间结果、代码调试困难以及控制流编写复杂,而动态图可以实时获取结果、调试简单、控制流符合编程习惯。虽然静态图的编写、生成过程复杂,但是相应的执行性能却超过动态图,下面用一个简单的代码来说明在性能和内存占用方面静态图的优势。 +```python +def model(X1, X2): + Y1 = matmul(X1, W1) + Y2 = matmul(X2, W2) + Y = Y1 + Y2 + output = relu(Y) + return output +``` +若对代码进行静态生成,机器学习框架可以构建完整的计算图。分析可知,计算$\boldsymbol{Y_1}$和$\boldsymbol{Y_2}$的过程相对独立,可以将其进行自动并行计算,加快计算效率。在静态生成过程中还可以利用计算图优化策略中的算子融合方法,将Add和ReLU两个算子融合为一个算子执行,这样减少了中间变量$\boldsymbol{Y}$的存储与读取过程,加快了计算效率,减少了内存占用。而动态生成的过程中,若无手动配置并行策略,机器学习框架无法获取图结构不能分析出算子之间的独立性,则只能按照代码顺序执行Add和ReLU两步操作,且需要存储变量$\boldsymbol{Y}$。除此之外,由于静态生成能够同时分析重构出前向计算图和反向计算图,可以提前确定反向计算中需要保存的前向中间变量信息。而动态生成则在完成前向计算后才能构建出反向计算图,为了保证反向计算效率需要保存更多的前向计算中间变量信息,相比之下静态生成的过程更加节省内存占用。 + + 针对两种模式的特性,结合任务需求选择合适的模式可以事半功倍,学术科研以及模型开发调试阶段,为了快速验证思想和迭代更新模型结构可以选择动态图模式进行构建算法;网络模型确定,为了加速训练过程或者为硬件部署模型,可以选择静态图模式。 ### 动态图与静态图的转换和融合 -动态图模式下拥有简洁的接口和编程体验,具备友好的调试交互机制。代码按照编写顺序即时执行,符合我们在编写模型的直观感受和习惯。可以快速将算法思想转化为实际代码。静态图模式下可以分离前后端语言,编译解析前端语言构建的整体网络结构,并进行优化后以高效后端语言执行,可以直接用于部署。为了兼顾动态图易用性和静态图部署性能两方面优势,目前TensorFlow、MindSpore、PyTorch、PaddlePaddle等主流计算框架均具备动态图转静态图的功能,支持使用动态图编写代码,框架自动转换为静态图网络结构。 +动态图便于调试,适用于模型构建实验阶段;静态图执行高效,节省模型训练时间,那么有没有办法可以让机器学习框架结合两种模式的优势呢?事实上,目前TensorFlow、MindSpore、PyTorch、PaddlePaddle等主流机器学习框架为了兼顾动态图易用性和静态图执行性能高效两方面优势,均具备动态图转静态图的功能,支持使用动态图编写代码,框架自动转换为静态图网络结构执行计算。 + +将各框架中支持源码转换和追踪转换技术的接口梳理如:numref:`dynamic_static_switch`所示。 + +:主流框架动态图转换静态图支持 + +| 框架 | 动态图转静态图 | +| :-----------------:| :--------------------------------------------------: | +| TensorFlow |@tf_function追踪算子调度构建静态图,
其中AutoGraph机制可以自动转换控制流为静态表达 | +| MindSpore | context.set_context(mode=context.PYNATIVE_MODE)动态图模式,
context.set_context(mode=context.GRAPH_MODE) 静态图模式,
@ms_function支持基于源码转换 | +| PyTorch | torch.jit.script()支持基于源码转换,
torch.jit.trace()支持基于追踪转换 | +| PaddlePaddle | paddle.jit.to_static()支持基于源码转换,
paddle.jit.TracedLayer.trace()支持基于追踪转换 | + +:label:`dynamic_static_switch` + 动态图转换为静态图的实现方式有两种: @@ -101,17 +146,21 @@ def model(X1, X2): - **基于源码转换**:分析前端代码来将动态图代码自动转写为静态图代码,并在底层自动帮用户使用静态图执行器运行。 -**基于追踪转换**的原理相对简单,当使用动态图模式构建好网络后,使用追踪(Tracing)进行转换将分为两个阶段。第一个阶段计算框架会创建一个新的计算图,此时以动态图模式执行代码,计算框架会自动追踪数据流的流动以及算子的调度,将所有的操作捕获并根据调度顺序构建静态图模型。第二个阶段,当执行完一次动态图后,计算框架已生成静态图,当再次调用相同的模型时,计算框架会自动指向静态图模型,以高效的性能执行计算。追踪技术只是记录第一次执行动态图时调度的算子,但若是模型中存在依赖于中间结果的条件分支控制流,只能追踪到根据第一次执行时触发的分支。此时构建的静态图模型并不是完整的,缺失了数据未流向的其他分支。在后续的调用中,因为静态模型已无法再改变,若计算过程中数据流向缺失分支会导致模型运行错误。同样的,依赖于中间数据结果的循环控制也无法追踪到全部的迭代状态。 +**基于追踪转换**的原理相对简单,当使用动态图模式构建好网络后,使用追踪进行转换将分为两个阶段。第一个阶段与动态生成原理相同,机器学习框架创建并运行动态图代码,自动追踪数据流的流动以及算子的调度,将所有的算子捕获并根据调度顺序构建静态图模型。与动态生成不同的地方在于机器学习框架并不会销毁构建好的图,而是将其保存为静态图留待后续执行计算。第二个阶段,当执行完一次动态图后,机器学习框架已生成静态图,当再次调用相同的模型时,机器学习框架会自动指向静态图模型执行计算。追踪技术只是记录第一次执行动态图时调度的算子,但若是模型中存在依赖于中间结果的条件分支控制流,只能追踪到根据第一次执行时触发的分支。此时构建的静态图模型并不是完整的,缺失了数据未流向的其他分支。在后续的调用中,因为静态模型已无法再改变,若计算过程中数据流向缺失分支会导致模型运行错误。同样的,依赖于中间数据结果的循环控制也无法追踪到全部的迭代状态。 -动态图基于前端语言自身的解释器进行模型代码的解析执行。比如当Python作为前端语言,采取原生Python边运行边解释的特性,配合框架提供的数据处理/算子分发的功能计算,即可实现动态图的即时执行特性。而且静态图则采用计算框架自带的图编译器,对神经网络模型进行建图后,再调用图结构进行计算。动态图代码与静态图代码之间存在差异,不能直接使用静态图编译器,因此基于源码转换的方法需要将动态图代码转换为静态图代码描述。 +动态图基于前端语言的解释器进行模型代码的解析执行,而静态图模式下需要经过机器学习框架自带的图编译器对模型进行建图后,再执行静态计算图。由于图编译器所支持编译的静态图代码与动态图代码之间存在差异,因此需要基于源码转换的方法将动态图代码转换为静态图代码描述,然后经过图编译器生成静态计算图。 -**基于源码转换**的方式则能够改善基于追踪转换的缺陷。如 :numref:`ast`中所示,基于源码转换的流程经历两个阶段。第一个阶段,对动态图模式下的代码扫描进行词法分析,通过词法分析器分析源代码中的所有字符,对代码进行分割并移除空白符、注释等,将所有的单词或字符都转化成符合规范的语法单元列表。接着进行语法分析即解析器,将得到的语法单元列表转换成树形式,并对语法进行检查避免错误。第二阶段,动态图转静态图的核心部分就是对抽象语法树进行转写,计算框架中对每一个需要转换的语法都预设有转换器,每一个转换器对语法树进行扫描改写,将动态图代码语法映射为静态图代码语法。其中最为重要的前端语言控制流,会在这一阶段分析转换为静态图接口进行实现。转写完毕之后,将新的语法树再还原回静态图代码,就可以使用静态生成执行。使用该方式可以避免基于追踪转换中控制流表达缺失的情况。 +**基于源码转换**的方式则能够改善基于追踪转换的缺陷。如 :numref:`ast`中所示,基于源码转换的流程经历两个阶段。第一个阶段,对动态图模式下的代码扫描进行词法分析,通过词法分析器分析源代码中的所有字符,对代码进行分割并移除空白符、注释等,将所有的单词或字符都转化成符合规范的词法单元列表。接着进行语法分析即解析器,将得到的词法单元列表转换成树形式,并对语法进行检查避免错误。第二阶段,动态图转静态图的核心部分就是对抽象语法树进行转写,机器学习框架中对每一个需要转换的语法都预设有转换器,每一个转换器对语法树进行扫描改写,将动态图代码语法映射为静态图代码语法。其中最为重要的前端语言控制流,会在这一阶段分析转换为静态图接口进行实现,也就避免了基于追踪转换中控制流缺失的情况。转写完毕之后,即可从新的语法树还原出可执行的静态图代码。 ![基于源码转换流程](../img/ch03/ast.svg) :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,18 +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追踪算子调度构建静态图,
其中AutoGraph机制可以自动转换控制流为静态表达 | -| MindSpore | context.set_context(mode=context.PYNATIVE_MODE)动态图模式,
context.set_context(mode=context.GRAPH_MODE) 静态图模式,
@ms_function支持基于源码转换 | -| PyTorch | torch.jit.script()支持基于源码转换,
torch.jit.trace()支持基于追踪转换 | -| PaddlePaddle | paddle.jit.to_static()支持基于源码转换,
paddle.jit.TracedLayer.trace()支持基于追踪转换 | - -:label:`dynamic_static_switch` - diff --git a/chapter_computational_graph/index.md b/chapter_computational_graph/index.md index aff40dd..b1e53a0 100644 --- a/chapter_computational_graph/index.md +++ b/chapter_computational_graph/index.md @@ -1,12 +1,10 @@ # 计算图 -在上一章节中,我们展示了用户利用机器学习框架所编写的程序。这些用户程序包含了对于训练数据,模型和训练过程的定义。然而为了运行这些程序,机器学习系统依然需要解决诸多问题,包括:如何高效执行一个复杂的机器学习模型?如何识别出机器学习模型中需要训练的参数?如何自动计算更新模型所需的梯度?为了解决这些问题,现代机器学习框架实现了*计算图*(Computational -graph)这一技术。在本章中,我们详细讨论计算图的基本组成,生成和执行等关键设计。本章的学习目标包括: +上一章节展示了如何高效编写机器学习程序,那么下一个问题就是:机器学习系统如何高效地在硬件上执行这些程序呢?这一核心问题又能被进一步拆解为:如何对机器学习程序描述的模型调度执行?如何使得模型调度执行更加高效?如何自动计算更新模型所需的梯度?解决这些问题的关键是计算图(Computational Graph)技术。为了讲解这一技术,本章将详细讨论计算图的基本组成、自动生成和高效执行中所涉及的方法。 +本章的学习目标包括: - 掌握计算图的基本构成。 - -- 掌握计算图静态生成和动态生成两种方法。 - +- 掌握计算图静态生成和动态生成方法。 - 掌握计算图的常用执行方法。 ```toc diff --git a/chapter_computational_graph/schedule_of_computational_graph.md b/chapter_computational_graph/schedule_of_computational_graph.md index 8ad66c7..1d55ee0 100644 --- a/chapter_computational_graph/schedule_of_computational_graph.md +++ b/chapter_computational_graph/schedule_of_computational_graph.md @@ -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)。以此类推,将所有的节点都放入到队列中并结束排序。 ![算子调度执行](../img/ch03/schedule.svg) :width:`700px` :label:`schedule` -生成调度序列之后,需要将序列中的算子与数据分发到指定的GPU/NPU上执行运算。根据算子依赖关系和计算设备数量,可以将无相互依赖关系的算子分发到不同的计算设备,同时执行运算,这一过程称之为并行计算,与之相对应的按照序贯顺序在同一设备执行运算被称之为串行计算。在深度学习中,当数据集和参数量的规模越来越大,我们在分发数据与算子时通信消耗会随之而增加,计算设备会在数据传输的过程中处于闲置状态,此时采用同步与异步的任务调度机制可以更好的协调通信与训练任务,提高通信模块与计算设备的使用率,在后续的小节中将详细介绍串行与并行、同步与异步的概念。 +生成调度序列之后,需要将序列中的算子与数据分发到指定的GPU/NPU上执行运算。根据算子依赖关系和计算设备数量,可以将无相互依赖关系的算子分发到不同的计算设备,同时执行运算,这一过程称之为并行计算,与之相对应的按照序贯顺序在同一设备执行运算被称为串行计算。在深度学习中,当数据集和参数量的规模越来越大在分发数据与算子时通信消耗会随之而增加,计算设备会在数据传输的过程中处于闲置状态。此时采用同步与异步的任务调度机制可以更好的协调通信与训练任务,提高通信模块与计算设备的使用率,在后续的小节中将详细介绍串行与并行、同步与异步的概念。 ### 串行与并行 @@ -24,13 +24,13 @@ - **并行**:队列中的任务可以同时进行调度执行,加快执行效率。 -首先我们从微观上来分析计算图内部的串行调度。计算图中大多数算子之间存在直接依赖或者间接依赖关系,具有依赖关系的算子间任务调度则必定存在执行前后的时间顺序。如 :numref:`order`,计算图接受输入数据进行前向计算得到预测值,计算损失函数进行反向梯度计算,整体代码流程后序算子的计算有赖于前序算子的输出。此时算子的执行队列只能以串行的方式进行调度,保证算子都能正确接受到输入数据,才能完成计算图的一次完整执行。 +首先从微观上来分析计算图内部的串行调度。计算图中大多数算子之间存在直接依赖或者间接依赖关系,具有依赖关系的算子间任务调度则必定存在执行前后的时间顺序。如 :numref:`order`,计算图接受输入数据进行前向计算得到预测值,计算损失函数进行反向梯度计算,整体代码流程后序算子的计算有赖于前序算子的输出。此时算子的执行队列只能以串行的方式进行调度,保证算子都能正确接受到输入数据,才能完成计算图的一次完整执行。 -![算子的串行](../img/ch03/order.svg) +![算子的串行](../img/ch03/order.png) :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通道处于空闲,同步机制造成计算资源和通信资源的浪费。 ![同步机制](../img/ch03/synchronization.svg) :width:`800px` :label:`synchronization` -以异步机制来执行计算图训练时,如 :numref:`asynchronous`所示,在迭代训练中,当数据通道将数据读取后交给后续的数据与处理环节后,不需要等待计算图训练迭代完成,直接读取下一批次的数据。对比同步机制,异步机制的引入减少了数据载入、数据预处理、网络训练三个环节的空闲等待时间,能够大幅度缩短循环训练的整体时间,提高任务执行效率。 +以异步机制来执行计算图训练时,如 :numref:`asynchronous`所示,在迭代训练中,当数据通道载入数据后交给后续的数据预处理环节后,不需要等待计算图训练迭代完成,直接读取下一批次的数据。对比同步机制,异步机制的引入减少了数据载入、数据预处理、网络训练三个环节的空闲等待时间,能够大幅度缩短迭代训练的整体时间,提高任务执行效率。 ![异步机制](../img/ch03/asynchronous.svg) :width:`800px` :label:`asynchronous` -当我们将异步机制与并行计算结合在一起,如 :numref:`asyn_para`所示,利用丰富的计算资源可以进一步提高计算图训练效率,缩短训练时间。 + +将异步机制与并行计算结合在一起,如 :numref:`asyn_para`所示,一方面异步机制减少模型等待数据载入和预处理的时间,另一方面并行计算增加了单轮模型训练接受的数据量。相比于不采用异步机制和同步计算,机器学习框架可以利用丰富的计算资源更快速的遍历训练完数据集,缩短训练时间提高计算效率。 ![异步并行](../img/ch03/asyn_para.svg) :width:`800px` diff --git a/chapter_computational_graph/summary.md b/chapter_computational_graph/summary.md index e278791..7e70fce 100644 --- a/chapter_computational_graph/summary.md +++ b/chapter_computational_graph/summary.md @@ -1,6 +1,6 @@ ## 总结 -- 为了兼顾编程的灵活性和计算的高效性,设计了基于计算图的深度学习框架。 +- 为了兼顾编程的灵活性和计算的高效性,设计了基于计算图的机器学习框架。 - 计算图的基本数据结构是张量,基本运算单元是算子。 @@ -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)概念。 \ No newline at end of file diff --git a/chapter_distributed_training/methods.md b/chapter_distributed_training/methods.md index a6c50b3..97ac87c 100644 --- a/chapter_distributed_training/methods.md +++ b/chapter_distributed_training/methods.md @@ -30,7 +30,7 @@ :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操作来完成。 @@ -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副本结束。 \ No newline at end of file +在训练大型人工智能模型中,我们往往会同时面对算力不足和内存不足。因此,我们需要混合使用数据并行和模型并行,这种方法被称为混合并行。 :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副本结束。 diff --git a/chapter_introduction/applicable_readers.md b/chapter_introduction/applicable_readers.md deleted file mode 100644 index f4eed3e..0000000 --- a/chapter_introduction/applicable_readers.md +++ /dev/null @@ -1,12 +0,0 @@ -## 适用读者 - -本书由浅入深地讨论机器学习系统的设计原理和实现经验。其读者包括: - -- **学生:** - 本书将帮助学生获得大量机器学习系统的设计原则和一手实践经验。从而帮助其更全面理解机器学习算法的实践挑战和理论优劣。 - -- **科研人员:** - 本书将帮助科研人员学习到机器学习落地实践中遇到的种种挑战,引导设计出能解决大规模实际问题的下一代机器学习算法。 - -- **开发人员:** - 本书将帮助开发人员深刻理解机器学习系统的内部架构,从而帮助其优化系统性能,调试问题,并且根据业务需求对机器学习系统进行定制。 \ No newline at end of file diff --git a/chapter_introduction/applications.md b/chapter_introduction/applications.md new file mode 100644 index 0000000..5233681 --- /dev/null +++ b/chapter_introduction/applications.md @@ -0,0 +1,18 @@ +## 机器学习应用 + +通俗来讲,机器学习是指从数据中学习出有用知识的技术。以学习模式分类,机器学习可以分为监督学习(Supervised Learning)、无监督学习(Unsupervised Learning)和强化学习(Reinforcement Learning)等。 + +* 监督学习是已知输入和输出的对应关系下的机器学习场景。比如给定输入图像和它对应的离散标签。 +* 无监督学习是只有输入数据但不知道输出标签下的机器学习场景。比如给定一堆猫和狗的图像,自主学会猫和狗的分类,这种无监督分类也称为聚类(Clustering)。 +* 强化学习则是给定一个学习环境和任务目标,算法自主地去不断改进自己以实现任务目标。比如 AlphaGo围棋就是用强化学习实现的,给定的环境是围棋的规则,而目标则是胜利得分。 + +从应用领域上划分,机器学习应用包括计算机视觉、自然语言处理和智能决策等。 +狭义上来讲,基于图像的应用都可归为计算机视觉方面的应用,典型的应用有人脸识别、物体识别、目标跟踪、人体姿态估计、图像理解等。 +计算机视觉方法广泛应用于自动驾驶、智慧城市、智慧安防等领域。 + +自然语言处理涉及文本或者语音方面的应用,典型的应用包括语言翻译、文本转语音、语音转文本、文本理解、图片风格变换等。 +计算机视觉和自然语言处理有很多交集,如图像的文本描述生成、基于文本的图像生成、基于文本的图像处理等应用都同时涉及语言和图像两种数据类型。 + +智能决策的应用往往通过结合计算机视觉、自然语言处理、强化学习、控制论等技术手段,实现决策类任务。智能决策方法广泛用于机器人、自动驾驶、游戏、推荐系统、智能工厂、智能电网等领域。 + +不同的机器学习应用底层会应用不同的机器学习算法,如支持向量机(Support Vector Machine,SVM)、逻辑回归(Logistic Regression)、朴素贝叶斯(Naive Bayes)算法等。近年来,得益于海量数据的普及,神经网络(Neural Networks)算法的进步和硬件加速器的成熟,深度学习(Deep Learning)开始蓬勃发展。虽然机器学习算法很多,但无论是经典算法还是深度学习算法的计算往往以向量和矩阵运算为主体,因此本书主要通过神经网络为例子展开机器学习系统的介绍。 \ No newline at end of file diff --git a/chapter_introduction/architecture.md b/chapter_introduction/architecture.md new file mode 100644 index 0000000..44404b6 --- /dev/null +++ b/chapter_introduction/architecture.md @@ -0,0 +1,31 @@ +## 机器学习框架的基本组成原理 + +一个完整的机器学习框架一般具有如图 :numref:`framework-architecture` 所示的基本架构。 + +![机器学习框架基本构成](../img/ch01/framework-architecture.png) +: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)实现。 \ No newline at end of file diff --git a/chapter_introduction/components_of_machine_learning_systems.md b/chapter_introduction/components_of_machine_learning_systems.md deleted file mode 100644 index 5189f42..0000000 --- a/chapter_introduction/components_of_machine_learning_systems.md +++ /dev/null @@ -1,35 +0,0 @@ -## 基本组成 - -一个完整的机器学习系统往往具有如 :numref:`framework_architecture`所示的基本架构。 - -![机器学习框架基本构成](../img/ch01/framework_architecture.png) -:width:`600px` -:label:`framework_architecture` - -- **编程接口:** 为了支持广泛的开发者,机器学习框架的编程接口不仅需要高层次简易编程(例如,Python,Julia和Java),同时也需要支持低层次高性能编程(利用C和C++函数调用操作系统和硬件加速器)。 - -- **计算图:** - 利用不同编程接口实现的机器学习程序需要共享一个运行后端。实现这一后端的关键技术是:应用无关的计算图。计算图包含计算节点,节点之间的边表达计算依赖。计算图可以被同步和异步执行。 - -- **编译器前端:** - 给定一个计算图,机器学习框架会对计算图做一系列优化。和硬件无关的优化由编译器前端实现。编译器前端实现包括:中间表达,自动微分,类型推导和静态分析等等。 - -- **编译器后端和运行时:** - 机器学习框架利用编译器后端对计算图可以进一步针对硬件的特性(例如说,L2/L3大小,指令流水线长度)进行性能优化。最终优化后的计算图通过运行时执行在通用处理器(CPU)或者是硬件加速器之上。运行时需要实现算子选择和内存分配等技术。 - -- **硬件加速器:** - 现代硬件加速器提供了丰富的编程接口。在本书中,我们将会介绍硬件加速器的基本组成原理和编程接口。我们同时会给出一个硬件加速器使用案例来从0到1讲述如何高效使用加速器。 - -- **数据处理:** - 机器学习系统拥有专门的数据处理框架来实现数据读取,存储和预处理的功能由数据处理模块(例如,TensorFlow的tf.data和PyTorch的DataLoader)。这一框架需要针对机器学习应用实现易用性,保序性和高效性等设计目标。 - -- **模型部署:** - 在模型完成训练后,下一个常用的系统功能是:模型部署。为了确保模型可以在内存有限的硬件上执行,我们会使用模型转换,量化,蒸馏等模型压缩技术。同时,我们也需要实现针对推理硬件平台(例如,英伟达Jetson)的模型算子优化。最后,为了保证模型的安全(不被黑客窃取),实践者还会对模型进行混淆设计。 - -- **分布式训练:** - 分布式训练日渐成为一个机器学习框架的核心组件。本书将介绍常见的分布式训练方法(数据并行,模型并行,混合并行和流水线并行)。同时我们会深入介绍这些方法的高效系统实现(包括集合通讯库和参数服务器)。 - -- **拓展模块:** - 机器学习系统的广泛部署使得许多的扩展模块陆续出现。本书将会介绍得到大量实践部署的拓展模块:深度学习推荐系统,联邦学习系统,强化学习系统,可解释性AI系统和机器人系统。 - -机器学习算法相关的理论知识是本书的预备知识,本书不做深入讨论。基础的机器学习理论知识可以在附录中找到。 \ No newline at end of file diff --git a/chapter_introduction/design.md b/chapter_introduction/design.md new file mode 100644 index 0000000..b48a9e1 --- /dev/null +++ b/chapter_introduction/design.md @@ -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` diff --git a/chapter_introduction/ecosystem.md b/chapter_introduction/ecosystem.md new file mode 100644 index 0000000..41c87fa --- /dev/null +++ b/chapter_introduction/ecosystem.md @@ -0,0 +1,36 @@ +## 机器学习系统生态 + +以机器学习框架为核心,人工智能社区创造出了庞大的**机器学习系统**生态。广义来说,机器学习系统是指实现和支持机器学习应用的各类型软硬件系统的泛称。图 :numref:`system-ecosystem` 总结了各类型的机器学习系统。 + +![机器学习系统和相关生态](../img/ch01/system-ecosystem.png) +:width:`600px` +:label:`system-ecosystem` + +- **联邦学习:** + 随着用户隐私保护和数据保护法的出现,许多机器学习应用无法直接接触用户数据完成模型训练。因此这一类应用需要通过机器学习框架实现联邦学习(Federated Learning)。 + +- **推荐系统:** + 将机器学习(特别是深度学习)引入推荐系统在过去数年取得了巨大的成功。相比于传统基于规则的推荐系统,深度学习推荐系统能够有效分析用户的海量特征数据,从而实现在推荐准确度和推荐时效性上的巨大提升。 + +- **强化学习:** + 强化学习具有数据收集和模型训练方法的特殊性。因此,需要基于机器学习框架进一步开发专用的强化学习系统。 + +- **可解释AI:** + 随着机器学习在金融、医疗和政府治理等关键领域的推广,基于机器学习框架进一步开发的可解释性AI系统正得到日益增长的重视。 + +- **机器人:** + 机器人是另一个开始广泛使用机器学习框架的领域。相比于传统的机器人视觉方法,机器学习方法在特征自动提取、目标识别、路径规划等多个机器人任务中获得了巨大成功。 + +- **图学习:** + 图(Graph)是最广泛使用的数据结构之一。许多互联网数据(如社交网络、产品关系图)都由图来表达。机器学习算法已经被证明是行之有效的分析大型图数据的方法。这种针对图数据的机器学习系统被称之为图学习系统(Graph Learning System)。 + +- **科学计算:** + 科学计算覆盖许多传统领域(如电磁仿真、图形学、天气预报等),这些领域中的许多大规模问题都可以有效利用机器学习方法求解。因此,针对科学计算开发机器学习系统变得日益普遍。 + +- **机器学习集群调度:** + 机器学习集群一般由异构处理器、异构网络甚至异构存储设备构成。同时,机器学习集群中的计算任务往往具有共同的执行特点(如基于集合通信算子AllReduce迭代进行)。因此,针对异构设备和任务特点,机器学习集群往往具有特定的调度方法设计。 + +- **量子计算:** + 量子计算机一般通过混合架构实现。其中,量子计算由量子计算机完成,而量子仿真由传统计算机完成。由于量子仿真往往涉及到大量矩阵计算,许多量子仿真系统(如TensorFlow Quantum和MindQuantum)都基于机器学习框架实现。 + +本书受限于篇幅,将不会对所有机器学习系统进行深入讲解。目前,本书会从系统设计者的角度出发,对应用在联邦学习、推荐系统、强化学习、可解释AI和机器人中的相关核心系统进行讲解。 diff --git a/chapter_introduction/index.md b/chapter_introduction/index.md index f406715..0c5fff3 100644 --- a/chapter_introduction/index.md +++ b/chapter_introduction/index.md @@ -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 ``` \ No newline at end of file diff --git a/chapter_introduction/machine_learning_applications.md b/chapter_introduction/machine_learning_applications.md deleted file mode 100644 index 255e29a..0000000 --- a/chapter_introduction/machine_learning_applications.md +++ /dev/null @@ -1,23 +0,0 @@ -## 机器学习应用 - -通俗来讲,机器学习是指从数据中学习出有用知识的技术。从学习模式来说,机器学习可以分为监督学习(Supervised -Learning)、无监督学习(Unsupervised Learning)、强化学习(Reinforcement -Learning)等: - -* 监督学习是已知输入输出对应关系情况下的学习,比如: 给定输入图像和它对应的内容标签,学习图像分类(Classification)。 -* 无监督学习是只有输入数据但不知道输出标签情况下的学习,比如:给定一堆猫和狗的图像,自主学会猫和狗的分类,这种无监督分类也称为聚类(Clustering)。 -* 强化学习则是给定一个学习环境和任务目标,算法自主地去不断尝试、改进自己、以实现任务目标 ,比如: AlphaGo围棋就是用强化学习实现的,给定的环境是围棋的规则、而目标则是胜利得分。 - -从应用领域上划分,主要包括计算机视觉、自然语言处理和智能决策这三大部分,而且这三大部分之间也有交集。 -狭义上来讲基于图像的应用都可归为计算机视觉方面的应用,典型的应用有人脸识别、物体识别、目标跟踪、人体姿态估计、以及图像的理解、修复、分割与检测等等。 -计算机视觉方法广泛应用于自动驾驶、智慧城市、智慧安防等领域。 -自然语言处理涉及文本或者语音方面的应用,典型的应用包括语言翻译、文本转语音、语音转文本、以及文本理解、分类、风格变换与纠错等等。 -计算机视觉和自然语言处理有很多交集,例如图像的文本描述生成、基于文本的图像生成、基于文本的图像处理等应用都同时涉及到了语言和图像两种数据类型。 -智能决策方面,往往通过结合计算机视觉、自然语言处理、强化学习、控制论等技术手段,实现决策类任务,广泛用于机器人、自动驾驶、游戏、推荐系统、智能工厂、智能电网等领域。 - -经典的机器学习算法有支持向量机(Support Vector -Machine,SVM)、逻辑回归(Logistic Regression)、朴素贝叶斯(Naive -Bayes) -等方法。然而得力于大数据互联网和计算机性能的提升,以深度学习(Deep -Learning)为代表的方法得到了广泛的研究和应用。 -虽然机器学习算法很多,但无论是经典算法还是深度学习算法的计算往往以向量、矩阵运算为主体的,因此本书主要通过深度神经网络为例子展开机器学习系统的介绍。下面我们来快速了解一下机器学习系统的设计需求、实现目标以及组成原理。 \ No newline at end of file diff --git a/chapter_introduction/readers.md b/chapter_introduction/readers.md new file mode 100644 index 0000000..e2a4475 --- /dev/null +++ b/chapter_introduction/readers.md @@ -0,0 +1,14 @@ +## 图书结构和读者 + +本书由浅入深地讨论机器学习系统的设计原理和实现经验。其中,**基础篇**覆盖编程接口设计和计算图等框架使用者需要了解的核心概念。**进阶篇**覆盖编译器前端、编译器后端、数据管理等框架设计者需要了解的核心概念。最后,**拓展篇**覆盖重要的机器学习系统类别(如联邦学习和推荐系统等),从而为各领域的机器学习爱好者提供统一的框架使用和设计入门教学。 + +本书的常见读者包括: + +- **学生:** + 本书将帮助学生获得大量机器学习系统的设计原则和一手实践经验,从而帮助其更全面理解机器学习算法的实践挑战和理论优劣。 + +- **科研人员:** + 本书将帮助科研人员解决机器学习落地实践中面临的种种挑战,引导设计出能解决大规模实际问题的下一代机器学习算法。 + +- **开发人员:** + 本书将帮助开发人员深刻理解机器学习系统的内部架构,从而帮助其开发应用新功能、调试系统性能,并且根据业务需求对机器学习系统进行定制。 \ No newline at end of file diff --git a/chapter_introduction/requirements_for_machine_learning_systems.md b/chapter_introduction/requirements_for_machine_learning_systems.md deleted file mode 100644 index 2f917f6..0000000 --- a/chapter_introduction/requirements_for_machine_learning_systems.md +++ /dev/null @@ -1,44 +0,0 @@ -## 设计目标 -![机器学习框架](../img/ch01/framework_position.png) -: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` diff --git a/chapter_preface/index.md b/chapter_preface/index.md index be80b98..7d6fa92 100644 --- a/chapter_preface/index.md +++ b/chapter_preface/index.md @@ -2,49 +2,50 @@ ## 缘起 -我在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上,并准备之后在Gitee做好镜像。这样,我们就搭建了一套高可用的写作平台了。 +* **高可用性**:构建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日 diff --git a/chapter_programming_interface/c_python_interaction.md b/chapter_programming_interface/c_python_interaction.md index 946cdf4..f295f6e 100644 --- a/chapter_programming_interface/c_python_interaction.md +++ b/chapter_programming_interface/c_python_interaction.md @@ -1,6 +1,6 @@ ## C/C++编程接口 -在上述小节中,我们讨论了开发者如何利用Python来定义机器学习的整个工作流,以及如何定义复杂的深度神经网络。然而,在很多时候,用户也需要添加自定义的算子来帮助实现新的模型,优化器,数据处理函数等。这些自定义算子需要通过C和C++实现,从而获得最优性能。但是为了帮助这些算子被用户使用,他们也需要暴露为Python函数,从而方便用户整合入已有的Python为核心编写的工作流和模型。在这一小节中,我们讨论这一过程是如何实现的。 +在2.2和2.3节中,分别讨论了开发者如何利用Python来定义机器学习的整个工作流,以及如何定义复杂的深度神经网络。然而,在很多时候,开发者也需要添加自定义的算子来帮助实现新的模型,优化器,数据处理函数等。这些自定义算子需要通过C和C++实现,从而获得最优性能。但是为了帮助这些算子被开发者使用,他们也需要暴露为Python函数,从而方便开发者整合入已有的Python为核心编写的工作流和模型。在这一小节中,我们讨论这一过程是如何实现的。 ### 在Python中调用C/C++函数的原理 diff --git a/chapter_programming_interface/development_history.md b/chapter_programming_interface/development_history.md index b7ebf28..78c9afb 100644 --- a/chapter_programming_interface/development_history.md +++ b/chapter_programming_interface/development_history.md @@ -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相似。 diff --git a/chapter_programming_interface/index.md b/chapter_programming_interface/index.md index f0d79d1..2daefd5 100644 --- a/chapter_programming_interface/index.md +++ b/chapter_programming_interface/index.md @@ -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 ``` \ No newline at end of file diff --git a/chapter_programming_interface/ml_programming_paradigm.md b/chapter_programming_interface/ml_programming_paradigm.md new file mode 100644 index 0000000..d59971d --- /dev/null +++ b/chapter_programming_interface/ml_programming_paradigm.md @@ -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) +``` \ No newline at end of file diff --git a/chapter_programming_interface/ml_workflow.md b/chapter_programming_interface/ml_workflow.md index 6ad47de..799d011 100644 --- a/chapter_programming_interface/ml_workflow.md +++ b/chapter_programming_interface/ml_workflow.md @@ -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)的方式读取出来,反复计算梯度来更新模型。这个反复的过程称为训练。 - **测试和调试:** @@ -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): """定义验证的方法""" diff --git a/chapter_programming_interface/neural_network_layer.md b/chapter_programming_interface/neural_network_layer.md index cc5fb69..c10aea7 100644 --- a/chapter_programming_interface/neural_network_layer.md +++ b/chapter_programming_interface/neural_network_layer.md @@ -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$。 ![全连接层](../img/ch02/fc_layer_1.svg) :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就是这样实现的。 ![神经网络模型构建细节](../img/ch02/model_build.svg) :width:`800px` :label:`model_build` - :numref:`cell_abs`展示了设计神经网络层抽象方法的通用表示。通常在构造器会选择使用Python中collections模块的OrderedDict来初始化神经网络层和神经网络层参数的存储;它的输出是一个有序的,相比与Dict更适合深度学习这种模型堆叠的模式。参数和神经网络层的管理是在\_\_setattr\_\_中实现的,当检测到属性是属于神经网络层及神经网络层参数时就记录起来。神经网络模型比较重要的是计算连接过程,可以在\_\_call\_\_里重载,实现神经网络层时在这里定义计算过程。训练参数的返回接口是为了给优化器传所有训练参数。神经网络层返回为了遍历各层神经网络得到各个神经网络层的参数。这里只列出了一些重要的方法,在自定义方法中,通常需要实现参数插入删除方法、神经网络层插入删除、神经网络模型信息等。 + :numref:`cell_abs`展示了设计神经网络层抽象方法的通用表示。通常在构造器会选择使用Python中collections模块的OrderedDict来初始化神经网络层和神经网络层参数的存储;它的输出是一个有序的,相比与Dict更适合深度学习这种模型堆叠的模式。参数和神经网络层的管理是在\_\_setattr\_\_中实现的,当检测到属性是属于神经网络层及神经网络层参数时就记录起来。神经网络模型比较重要的是计算连接过程,可以在\_\_call\_\_里重载,实现神经网络层时在这里定义计算过程。训练参数的返回接口给优化器传所有训练参数,这些参数是基类遍历了所有网络层后得到的。这里只列出了一些重要的方法,在自定义方法中,通常需要实现参数插入删除、神经网络层插入删除、神经网络模型信息返回等方法。 ![神经网络基类抽象方法](../img/ch02/cell_abstract.svg) :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查看神经网络层列表。 diff --git a/img/ch01/framework-architecture.png b/img/ch01/framework-architecture.png new file mode 100644 index 0000000..1d8826c Binary files /dev/null and b/img/ch01/framework-architecture.png differ diff --git a/img/ch01/framework_architecture.png b/img/ch01/framework_architecture.png deleted file mode 100644 index 57b6136..0000000 Binary files a/img/ch01/framework_architecture.png and /dev/null differ diff --git a/img/ch01/framework_position.png b/img/ch01/framework_position.png deleted file mode 100644 index f178f1f..0000000 Binary files a/img/ch01/framework_position.png and /dev/null differ diff --git a/img/ch01/system-ecosystem.png b/img/ch01/system-ecosystem.png new file mode 100644 index 0000000..8244bf7 Binary files /dev/null and b/img/ch01/system-ecosystem.png differ diff --git a/img/ch03/chain.png b/img/ch03/chain.png new file mode 100644 index 0000000..be36f5d Binary files /dev/null and b/img/ch03/chain.png differ diff --git a/img/ch03/eager-gen.png b/img/ch03/eager-gen.png new file mode 100644 index 0000000..b76d8fe Binary files /dev/null and b/img/ch03/eager-gen.png differ diff --git a/img/ch03/eager.png b/img/ch03/eager.png new file mode 100644 index 0000000..18ff96d Binary files /dev/null and b/img/ch03/eager.png differ diff --git a/img/ch03/graph.png b/img/ch03/graph.png new file mode 100644 index 0000000..100a662 Binary files /dev/null and b/img/ch03/graph.png differ diff --git a/img/ch03/if.png b/img/ch03/if.png new file mode 100644 index 0000000..5785998 Binary files /dev/null and b/img/ch03/if.png differ diff --git a/img/ch03/order.png b/img/ch03/order.png new file mode 100644 index 0000000..452128a Binary files /dev/null and b/img/ch03/order.png differ diff --git a/img/ch03/recurrent.png b/img/ch03/recurrent.png new file mode 100644 index 0000000..fcb6ef0 Binary files /dev/null and b/img/ch03/recurrent.png differ diff --git a/img/ch03/simpledag.png b/img/ch03/simpledag.png new file mode 100644 index 0000000..c85050d Binary files /dev/null and b/img/ch03/simpledag.png differ diff --git a/img/ch03/static.png b/img/ch03/static.png new file mode 100644 index 0000000..b72524a Binary files /dev/null and b/img/ch03/static.png differ diff --git a/img/ch03/static_gen.png b/img/ch03/static_gen.png new file mode 100644 index 0000000..a69f44a Binary files /dev/null and b/img/ch03/static_gen.png differ diff --git a/img/ch03/tensor.png b/img/ch03/tensor.png new file mode 100644 index 0000000..aea6e03 Binary files /dev/null and b/img/ch03/tensor.png differ diff --git a/img/ch03/unroll.png b/img/ch03/unroll.png new file mode 100644 index 0000000..8019181 Binary files /dev/null and b/img/ch03/unroll.png differ diff --git a/img/ch03/while.png b/img/ch03/while.png new file mode 100644 index 0000000..d2a9848 Binary files /dev/null and b/img/ch03/while.png differ diff --git a/img/ch06/6.4/duplicated_data.png b/img/ch06/6.4/duplicated_data.png new file mode 100755 index 0000000..8df9a2c Binary files /dev/null and b/img/ch06/6.4/duplicated_data.png differ diff --git a/img/ch06/6.4/duplicated_data.svg b/img/ch06/6.4/duplicated_data.svg deleted file mode 100755 index 5e5dc4f..0000000 --- a/img/ch06/6.4/duplicated_data.svg +++ /dev/null @@ -1,3 +0,0 @@ - - -
C
C
A
A
B
B
K
K
M
M
N
N
K
K
x
x
y
y
Text is not SVG - cannot display
\ No newline at end of file diff --git a/img/ch06/6.4/hide_global_latency.png b/img/ch06/6.4/hide_global_latency.png new file mode 100755 index 0000000..70cf9ca Binary files /dev/null and b/img/ch06/6.4/hide_global_latency.png differ diff --git a/img/ch06/6.4/hide_global_latency.svg b/img/ch06/6.4/hide_global_latency.svg deleted file mode 100755 index 677265a..0000000 --- a/img/ch06/6.4/hide_global_latency.svg +++ /dev/null @@ -1,3 +0,0 @@ - - -
Load
shared
Load...
Load
shared
Load...
Load
shared
Load...
Math
Math
Load
shared
Load...
Load
shared
Load...
Math
Math
Math
Math
Loop
Loop
__syncthreads()
__syncthreads()
Store shared
Store shar...
__syncthreads()
__syncthreads()
Store shared
Store shar...
Load global
Load global
Load global
Load global

Math
Math
Text is not SVG - cannot display
\ No newline at end of file diff --git a/img/ch06/6.4/hide_smem_latency.png b/img/ch06/6.4/hide_smem_latency.png new file mode 100755 index 0000000..269b35e Binary files /dev/null and b/img/ch06/6.4/hide_smem_latency.png differ diff --git a/img/ch06/6.4/hide_smem_latency.svg b/img/ch06/6.4/hide_smem_latency.svg deleted file mode 100755 index 81d0670..0000000 --- a/img/ch06/6.4/hide_smem_latency.svg +++ /dev/null @@ -1,3 +0,0 @@ - - -
Loop
Loop
__syncthreads()
__syncthreads()
Store shared
Store shar...
Load global
Load global
Load
shared
Load...
Load
shared
Load...
Load
shared
Load...
Load
shared
Load...
Math
Math
Math
Math
Math
Math
Math
Math
__syncthreads()
__syncthreads()
Text is not SVG - cannot display
\ No newline at end of file diff --git a/img/ch06/6.4/naive.png b/img/ch06/6.4/naive.png new file mode 100755 index 0000000..fb898ee Binary files /dev/null and b/img/ch06/6.4/naive.png differ diff --git a/img/ch06/6.4/naive.svg b/img/ch06/6.4/naive.svg deleted file mode 100755 index 49091ef..0000000 --- a/img/ch06/6.4/naive.svg +++ /dev/null @@ -1,3 +0,0 @@ - - -
B
B
C
C
A
A
K
K
M
M
N
N
K
K
n
n
m
m
Text is not SVG - cannot display
\ No newline at end of file diff --git a/img/ch06/6.4/use_float4.png b/img/ch06/6.4/use_float4.png new file mode 100755 index 0000000..bd49506 Binary files /dev/null and b/img/ch06/6.4/use_float4.png differ diff --git a/img/ch06/6.4/use_float4.svg b/img/ch06/6.4/use_float4.svg deleted file mode 100755 index caa82b1..0000000 --- a/img/ch06/6.4/use_float4.svg +++ /dev/null @@ -1,3 +0,0 @@ - - -
B
B
C
C
A
A
K
K
M
M
N
N
K
K
n
n
m
m
B fragment
B fragment
A fragment
A fragment
Text is not SVG - cannot display
\ No newline at end of file diff --git a/img/ch06/6.4/use_smem_load.png b/img/ch06/6.4/use_smem_load.png new file mode 100755 index 0000000..62506d7 Binary files /dev/null and b/img/ch06/6.4/use_smem_load.png differ diff --git a/img/ch06/6.4/use_smem_load.svg b/img/ch06/6.4/use_smem_load.svg deleted file mode 100755 index 24a7bf1..0000000 --- a/img/ch06/6.4/use_smem_load.svg +++ /dev/null @@ -1,3 +0,0 @@ - - -
fragment A
fragment A
fragment B
fragment B
tileM
tileM
tileN
tileN
tileK
tileK
tileK
tileK
kInTileA
kInTileA
tileSharedIntervalA
tileSh...
tileSharedIntervalB
tileSh...
mInTileC
mInTil...
nInTileC
nInTil...
tile A
tile A
tile B
tile B
Text is not SVG - cannot display
\ No newline at end of file diff --git a/img/ch06/6.4/use_smem_pipeline.png b/img/ch06/6.4/use_smem_pipeline.png new file mode 100755 index 0000000..121d9d8 Binary files /dev/null and b/img/ch06/6.4/use_smem_pipeline.png differ diff --git a/img/ch06/6.4/use_smem_pipeline.svg b/img/ch06/6.4/use_smem_pipeline.svg deleted file mode 100755 index a857c1a..0000000 --- a/img/ch06/6.4/use_smem_pipeline.svg +++ /dev/null @@ -1,3 +0,0 @@ - - -
Loop
Loop
__syncthreads()
__syncthreads()
Store shared
Store shar...
Load global
Load global
Load
shared
Load...
Load
shared
Load...
Load
shared
Load...
Load
shared
Load...
Math
Math
Math
Math
Math
Math
Math
Math
__syncthreads()
__syncthreads()
Text is not SVG - cannot display
\ No newline at end of file diff --git a/img/ch06/6.4/use_smem_store.png b/img/ch06/6.4/use_smem_store.png new file mode 100755 index 0000000..ee65070 Binary files /dev/null and b/img/ch06/6.4/use_smem_store.png differ diff --git a/img/ch06/6.4/use_smem_store.svg b/img/ch06/6.4/use_smem_store.svg deleted file mode 100755 index faa547d..0000000 --- a/img/ch06/6.4/use_smem_store.svg +++ /dev/null @@ -1,3 +0,0 @@ - - -
C
C
A
A
B
B
K
K
M
M
N
N
K
K
tile A
tile A
tile B
tile B
tileM
tileM
tileN
tileN
tileK
tileK
tileK
tileK
mInTileA
mInTileA
tileGlobalIntervalA
tileGlobal...
nInTileB
nInTileB
kInTileA
kInTileA
tileGlobalIntervalB
tileGlobal...
kInTileB
kInTileB
Text is not SVG - cannot display
\ No newline at end of file diff --git a/img/ch06/6.4/use_tile.png b/img/ch06/6.4/use_tile.png new file mode 100755 index 0000000..2f99a4c Binary files /dev/null and b/img/ch06/6.4/use_tile.png differ diff --git a/img/ch06/6.4/use_tile.svg b/img/ch06/6.4/use_tile.svg deleted file mode 100755 index 4c18109..0000000 --- a/img/ch06/6.4/use_tile.svg +++ /dev/null @@ -1,3 +0,0 @@ - - -
C
C
A
A
B
B
K
K
M
M
N
N
K
K
n
n
m
m
fragment A
fragment A
fragment B
fragment B
Text is not SVG - cannot display
\ No newline at end of file diff --git a/info/info.md b/info/info.md index d739869..f563f01 100644 --- a/info/info.md +++ b/info/info.md @@ -10,7 +10,7 @@ python setup.py install 当然构建PDF必须要有LaTeX,如安装[Tex Live](https://www.tug.org/texlive/). ## 编译HTML版本 -在编译前先下载[openmlsys-zh](https://github.com/openmlsys/openmlsys-zh) 所有的编译命令都在改文件目录内执行。 +在编译前先下载[openmlsys-zh](https://github.com/openmlsys/openmlsys-zh) , 所有的编译命令都在这个文件目录内执行。 ```bash git clone https://github.com/openmlsys/openmlsys-zh.git cd openmlsys-zh diff --git a/static/frontpage.html b/static/frontpage.html index 6e64ad1..2db5ba9 100644 --- a/static/frontpage.html +++ b/static/frontpage.html @@ -220,7 +220,7 @@ a {

董豪

-

北京大学

+

北京大学、鹏城实验室

@@ -312,6 +312,13 @@ a {

剑桥大学

+
+
+ +

穆沛

+

爱丁堡大学

+
+