diff --git a/.gitignore b/.gitignore index 5293251..2fddff0 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,5 @@ dist* _build/ test*.md run.sh -.idea \ No newline at end of file +.idea +env diff --git a/INFO.md b/INFO.md index 1930f3d..6eeb879 100644 --- a/INFO.md +++ b/INFO.md @@ -1,21 +1,17 @@ ## 环境安装 机器学习系统书籍部署在Github是依赖于d2lbook工具实现的。因此我们首先要安装d2lbook。 -``` -pip install git+https://github.com/d2l-ai/d2l-book -``` -使用pip如果不能安装成功,可以通过git clone下载代码安装 -``` +```bash git clone git@github.com:d2l-ai/d2l-book.git cd d2l-book python setup.py install ``` -使用d2lbook构建HTML需要安装pandoc,可以使用pip install pandoc。 -构建PDF时如果有SVG图片需要安装LibRsvg来转换SVG图片,安装librsvg可以通过pip install librsvg。 +使用d2lbook构建HTML需要安装`pandoc`, 可以使用`apt-get install pandoc`(如果是MacOS可以用Homebrew)和。 +构建PDF时如果有SVG图片需要安装LibRsvg来转换SVG图片,安装`librsvg`可以通过`apt-get install librsvg`(如果是MacOS可以用Homebrew)。 当然构建PDF必须要有LaTeX,如安装[Tex Live](https://www.tug.org/texlive/). ## 编译HTML版本 在编译前先下载[openmlsys-zh](https://github.com/openmlsys/openmlsys-zh) 所有的编译命令都在改文件目录内执行。 -``` +```bash git clone git@github.com:openmlsys/openmlsys-zh.git cd openmlsys-zh ``` diff --git a/README.md b/README.md index 91b29c9..e7a86ec 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,60 @@ # 机器学习系统:设计和实现 +本开源项目试图给读者讲解现代机器学习系统的设计原理和实现经验。 + +## 适用读者 + +本书的常见读者包括: + +- **学生:** + 随着大量机器学习课程在大学中的普及,学生已经开始掌握大量机器学习的基础理论和神经网络的实现。然而,需要训练出可以实际应用的机器学习模型,需要对现代机器学习系统有充分的认识。 + +- **科研人员:** + 研发新型的机器学习模型不仅仅需要会使用基础的机器学习系统接口。同时,新型的模型往往需要给系统提供新的自定义算子(Custom + Operators),又或者是会利用高级的分布式执行算子来实现大模型的开发。这一系列需求都需要对底层系统具有充分认识。 + +- **开发人员:** + 大量的数据和AI驱动的公司都部署了机器学习基础设施。这一设施的核心就是机器学习系统。因此了解机器学习系统有助于开发人员对于系统性能调优,以定位问题,并且根据业务需求对机器学习系统进行深度定制。 + +## 构建指南 + +请参考[构建指南](INFO.md)来了解如何构建本书的网页版本和PDF版本。 + +## 写作指南 + +我们欢迎大家来一起贡献和更新本书的内容。常见的贡献方式是提交PR来更新和添加Markdown文件。写作的风格和图片要求请参考[风格指南](STYLE_GUIDE.md)。同时,机器学习领域涉及到大量的中英文翻译,相关的翻译要求请参考[术语指南](TERMINOLOGY.md)。 + +## 内容介绍 + +一个现代的机器学习框架往往具有如下图所示的基本架构。 + + + + +- **编程接口:** 一个机器学习框架面向用户的编程接口(Programming + interface)需要特殊设计。编程接口提供简单易用的编程函数(往往是PyThon)从而让用户定义出各式各样的神经网络和相关的训练过程。同时,编程接口要兼顾性能:神经网络的执行可以调用硬件相关C和C++函数(许多加速器和操作系统的编程接口)。该部分的内容将在第2章展开。 + +- **计算图:** + 用户定义的机器学习程序往往会表达成一个计算图(Computational + graph)。这个计算图使得用户并行计算和异步执行得以实现。该部分内容将在第3章展开。 + +- **计算加速器:** + 现代计算加速器提供了丰富的编程接口让应用来优化其相关性能。而如何高效使用计算加速器是许多机器学习框架的核心。我们将在第4章中讨论加速器的加速原理和相关使用技巧。 + +- **编译器前端:** + 在将计算图发送到加速器执行之前,机器学习框架往往会对计算图做一系列硬件无关的一系列优化,这一过程被称之为:编译器前端。其中核心步骤是对用户定义的神经网络训练实现自动微分。在此期间,计算图会被表示为中间表达(Intermediate + Representation),并同时应用类型系统和静态分析等一系列技术。我们将在第5章中讨论相关内容。 + +- **编译器后端:** + 编译器前端生成的中间表达可以进一步针对硬件的特性(例如说,L2/L3大小,指令流水线长度)进行性能优化,硬件算子选择,内存分配。这一以硬件为核心的编译过程被称为:编译器后端,该部分内容将在第6章中讨论。 + +- **数据处理:** + 机器学习框架会集成多种数据管理模块。其中包括数据预处理模块,模型参数checkpoint,模型可视化和训练结果可视化等。该部分内容将在第7章中讨论。 + +- **模型部署:** + 在模型完成训练后,用乎需要对模型进行部署。该过程中,我们会根据部署硬件的特点进行模型格式的转换,针对硬件特性进行推理性能优化。同时,移动硬件往往具有小内存的特点。因此大量的模型压缩技术也在部署中得到应用。这些相关内容将在第8章中讨论。 + +- **分布式训练:** + 当模型的训练需要大量内存和算力的时候,机器学习框架会提供原生的分布式执行编程接口。分布式机器学习系统已经在工业界得到大量的部署。相关内容会在第9章讨论。 + +除了上述核心组件以外,机器学习系统作为一个依然高速发展的前沿学科,还有大量的问题正在被密集研究,相关的前沿问题将在本书的第10章中展开讨论。另外,机器学习算法相关的理论知识是本书的预备知识,本书不做深入讨论,基础的机器学习理论知识可以在附录中找到。 \ No newline at end of file diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index a9169e9..7ca99ca 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -17,12 +17,19 @@ * 图片需要手动改 * 公式部分可能会有不正确,需要注意 * 代码部分需要手动改,样式如下: - ```python - ```python - import os - import argparse + ```markdown + ``` python + import os + import argparse + ``` ``` -``` + * 转换得到的md中,如果使用了"-"语法,则不能出现以下形式的内容: + ```markdown + - title + + content content content content content con... + ``` + 即"-"之后空了一行,且内容行首空了4格,否则d2lbook会编译失败: ## 图片 @@ -68,6 +75,10 @@ 机器学习系统工作流如 :numref:`img_workflow` 。必须注意的是在引用时冒号前要空有一个字符距离。 ``` * 表格或者章节引用和图片引用类似,流程依旧是打上标签,然后用 :numref:‘引用的标签’ +* 其他转换方式 + * 如果图中有很多公式,使用工具导入可能会有大量公式乱码,此时可以将图保存为.png格式。 + * 使用[在线图片去底工具](https://www.aigei.com/bgremover/) 将图片中的白底去除。 + diff --git a/appendix_Introduction_machine_learning/classic_machine_learning.md b/appendix_Introduction_machine_learning/classic_machine_learning.md new file mode 100644 index 0000000..c2b8737 --- /dev/null +++ b/appendix_Introduction_machine_learning/classic_machine_learning.md @@ -0,0 +1,68 @@ +## 经典机器学习方法 + +大量经典机器学习算法,如 支持向量机(Support Vector Machine,SVM), +K最近邻(K-Nearest Neighbor, KNN)分类算法 和K均值聚类算法(K-Means +Clustering Algorithm)等, +虽然它们有的有网络参数,有的没有网络参数,有的是监督学习算法,有的是无监督学习算法, +训练过程也不一样,但是从系统的角度,它们都是以矩阵运算为基础的。下面,我们来简要介绍一下这些算法。 + +### 支持向量机 + +**支持向量机**(Support Vector +Machine,SVM),是一种经典的机器学习分类算法,其核心思想在于最大化决策边界到数据点的距离。在这里,我们以线性可分数据为例;对于非线性可分的数据,运用**核方法**(Kernel +Method)即可类似处理。 + +如果训练数据是线性可分的,SVM的目标则是最大化**间隔**(Margin)。首先,我们先来定义最大化间隔的分类器,如下: +$$\min_{{w},b} ~~~\frac{1}{2} ||{w}||^2$$ +$$s.t. ~~~y_i ({w}^T {x_i} + b) \geq 1, ~~~\forall 1 \leq i \leq n$$ +其拉格朗日乘子为 +$$L({w},b,{\lambda}) = \frac{1}{2} ||{w}||^2 + \sum_{i=1}^n \lambda_i (1-y_i({w}^T {x_i} + b))$$ +由于$\frac{1}{2} ||{w}||^2$是凸的,并且$\lambda_i (1-y_i({w}^T {x_i} + b))$是线性的(也是凸的),所以优化问题的解为 +$$\max_{\lambda>0} \min_{{w},b} L({w},b, {\lambda})$$ +求$L$关于${w},b$的导数有 +$$\nabla_{{w}} L= {w} - \sum_{i=1}^n \lambda_i y_i {x_i}$$ +$$\nabla_b L = - \sum_{i=1}^n \lambda_i y_i$$ +令$L$关于${w},b$的导数均为0得到,${w}^* = \sum_{i=1}^n \lambda_i y_i {x_i}$以及$\sum_{i=1}^n \lambda_i y_i = 0$。 +由于当$\lambda$固定的时候,$b$的值对目标函数无贡献,所以可以令$b^* = 0$。 +这时,由对偶性理论和KTT条件,我们得到: +$$y_i ({w}^{*T} {x_i} + b^*) > 1 \Rightarrow \lambda_i^* = 0$$ +$$\lambda_i^* > 0 \Rightarrow y_i ({w}^{*T} {x_i} + b^*) = 1$$ +$${w}^* = \sum_{i=1}^n \lambda_i^* y_i {x_i}$$ +如果$y_i ({w}^{*T} {x_i} + b^*) = 1$,那么${x_i}$就是离超平面$({w}^*,b^*)$最近的点之一,否则就不是。因此,${w}^*$就是离超平面$({w}^*,b^*)$最近的点${x_i}$的线性组合。 + +如此,通过SVM算法,我们实现了数据的分类,并且能够最大化了决策边界到最近点的距离。 +我们定义满足$y_i ({w}^{*T} {x_i} + b^*) = 1$的${x_i}$为**支持向量**(Support +Vectors),同时把分类器$\hat{y}=sgn({w}^{*T} {x_i} + b^*)$称为支持向量机。 + +### K最近邻算法 + +**K最近邻算法**(K-Nearest +Neighbor,KNN)也是一种传统的机器学习算法,可用于分类、回归等基本的机器学习任务。和上面介绍的SVM算法不同,K最近邻算法的核心思想并不是用一个决策边界把属于不同类的数据分开,而是依靠每个数据点周围几个距离最近的数据的性质,来预测数据点本身的性质。 + +KNN用于分类时,为了预测某个样本点的类别,会进行一次投票。投票的对象为离这个观测样本点最近的K个样本点,每个要投票的样本点可能会被赋予不同的权重,而投票的"内容"则是样本点的类别。处理投票结果的时候,采用的是少数服从多数的决策方法(Majority +Vote)。也就是说,若一个样本点最近的K个样本点中大多数属于某个类别,那么该样本点也属于这个类别。 + +KNN算法的具体描述如下:(1)计算待分类点到各已知类别点的距离;(2)将这些点按照距离排序,并按照距离挑选出最近的K个点;(3)按照每个点的权重进行"统票",票面内容为点所处的类别;(4)返回得票最高的类别,并作为待分类点的预测类别。 + +KNN算法有几个需要注意的关键问题,包括超参数K的选择,距离的度量方式,还有分类决策规则。对于超参数K,不宜过大,否则会导致很大的近似误差,反之亦不宜过小,否则会导致很大的估计误差。距离的度量,则可以选择曼哈顿距离、欧式距离和闵可夫斯基距离等等。为了降低K值对于预测结果产生的误差和影响,我们通常可以对分类决策规则做一定的规定,比如在投票决策时让距离小的点有更大的权重,距离较大的点权重较小。在编程实现KNN算法的时候,权重等参数都会以矩阵的形式进行运算,以提高运算效率。 + +### K均值聚类算法 + +**K均值聚类算法**(K-Means Clustering +Algorithm)是机器学习中一种常见的无监督聚类算法。在这里,我们首先定义聚类问题:给定数据点${x_1},\cdots, {x_n} \in \mathbb{R}^d$和$K\in \mathbb{N}$,需要划分为$K$个簇${C_1}, \cdots, {C_K} \in \mathbb{R}^d$以及每个数据点所对应的分类中心点${ C_{(1)}}, \cdots, {C_{(n)}}$,以最小化距离和$\sum_i ||{x_i} - {C_{(i)}}||^2$。 + +K均值聚类算法是一种解决聚类问题的算法,算法过程如下: + +- 随机选择${C_1}, \cdots, {C_K}$ + +- 把${x_i}$所对应的分类置为距离其最近的聚类中心点的分类 + +- 计算并赋值${C_K} = \frac{\sum_{{C_{(i)}}={C_K}} {x_i}}{\sum_{{C_{(i)}}={C_K}} 1}$ + +- 重复以上步骤直到算法收敛 + +可以证明,K均值聚类算法会使得距离和$\sum_i ||{x_i} - {C_{(i)}}||^2$不断地单调减小,并且最终能够收敛。不过,算法可能收敛到局部最小值。 + +本章结束语: + +在系统角度,机器学习的算法无论是什么算法,涉及到高维数据任务的现都是矩阵运算实现的。 diff --git a/appendix_Introduction_machine_learning/gradient_descent.md b/appendix_Introduction_machine_learning/gradient_descent.md new file mode 100644 index 0000000..64e27d4 --- /dev/null +++ b/appendix_Introduction_machine_learning/gradient_descent.md @@ -0,0 +1,94 @@ +## 梯度下降与反向传播 + +上面大体上介绍了经典神经网络的内容,那么现在有一个问题,这些网络中的参数是如何确定的呢?如果要解决的问题是一个小感知器就能解决的话,参数可以人为地去确定。但是如果是一个深度网络的话,参数的确定需要自动化,也就是所谓的网络训练,而这个过程需要我们设定一个**损失函数**(Loss +Function)来作为训练优化的一个方向。 +常见的损失函数有:1)用来衡量向量之间距离的均方误差(Mean Squared +Error,MSE) +$\mathcal{L} = \frac{1}{N}\|{y}-\hat{{y}}\|^{2}_{2} = \frac{1}{N}\sum_{i=1}^N(y_{i}-\hat{y}_{i})^{2}$ +和 平均绝对误差(Mean Absolute Error,MAE) +$\mathcal{L} = \frac{1}{N}\sum_{i=1}^{N}|y_{i}-\hat{y}_{i}|$ +,其中$N$代表数据样本的数量,用以求平均用,而$y$代表真实标签(Ground +Truth)、$\hat{y}$代表网络输出的预测标签。 +2)分类任务可以用的交叉熵损失(Cross Entropy) +$\mathcal{L} = - \frac{1}{N} \sum_{i=1}^N \bigg(y_{i}\log\hat{y}_{i} + (1 - y_{i})\log(1 - \hat{y}_{i})\bigg)$来作为损失数,当且仅当输出标签和预测标签一样的时候损失值才为零。 + +有了损失值之后,我们就可以利用大量真实标签的数据和优化方法来更新模型参数了,其中最常用的方法是**梯度下降**(Gradient +Descent)。如 :numref:`gradient_descent2`所示, +开始的时候,模型的参数${w}$是随机选取的,然后求出损失值对参数的偏导数$\frac{\partial \mathcal{L}}{\partial {w}}$,通过反复迭代 +${w}:={w}-\alpha\frac{\partial \mathcal{L}}{\partial {w}}$完成优化。这个优化的过程其实就可以降低损失值以达到任务目标,其中$\alpha$是控制优化幅度的**学习率**(Learning +Rate)。 +在实践中,梯度下降最终得到的最小值很大可能是一个局部最小值,而不是全局最小值。不过由于深度神经网络能提供一个很强的数据表达能力,所以局部最小值可以很接近全局最小值,损失值可以足够小。 + +![梯度下降介绍。(左图)只有一个可以训练的参数$w$;(右图)有两个可以训练的参数${w}=[w_1,w_2]$。在不断更新迭代参数后,损失值$\mathcal{L}$会逐渐地减小。但是由于存在很多局部最优解,我们往往不能更新到全局最优解。](../img/ch_basic/gradient_descent2.png) +:width:`600px` +:label:`gradient_descent2` + +那么接下来,在深度神经网络中如何实现梯度下降呢,这需要计算出网络中每层参数的偏导数$\frac{\partial \mathcal{L}}{\partial {w}}$,我们可以用**反向传播**(Back-Propagation)[@rumelhart1986learning; @lecun2015deep]来实现。 +接下来, +我们引入一个中间量${\delta}=\frac{\partial \mathcal{L}}{\partial {z}}$来表示损失函数$\mathcal{L}$ +对于神经网络输出${z}$(未经过激活函数,不是$a$)的偏导数, +并最终得到$\frac{\partial \mathcal{L}}{\partial {w}}$。 + +我们下面用一个例子来介绍反向传播算法, +我们设层序号为$l=1, 2, \ldots L$(输出层(最后一层)序号为$L$)。 +对于每个网络层,我们有输出${z}^l$,中间值${\delta}^l=\frac{\partial \mathcal{L}}{\partial {z}^l}$和一个激活值输出${a}^l=f({z}^l)$ +(其中$f$为激活函数)。 +我们假设模型是使用Sigmoid激活函数的多层感知器,损失函数是均方误差(MSE)。也就是说,我们设定: + +- 网络结构${z}^{l}={W}^{l}{a}^{l-1}+{b}^{l}$ + +- 激活函数${a}^l=f({z}^l)=\frac{1}{1+{\rm e}^{-{z}^l}}$ + +- 损失函数$\mathcal{L}=\frac{1}{2}\|{y}-{a}^{L}\|^2_2$ + +我们可以直接算出激活输出对于原输出的偏导数: + +- $\frac{\partial {a}^l}{\partial {z}^l}=f'({z}^l)=f({z}^l)(1-f({z}^l))={a}^l(1-{a}^l)$ + +和损失函数对于激活输出的偏导数: + +- $\frac{\partial \mathcal{L}}{\partial {a}^{L}}=({a}^{L}-{y})$ + +有了这些后,为了进一步得到损失函数对于每一个参数的偏导数,可以使用**链式法则**(Chain +Rule),细节如下: + +首先,从输出层($l=L$,最后一层)开始向后方传播误差,根据链式法则,我们先计算输出层的中间量: + +- ${\delta}^{L} + =\frac{\partial \mathcal{L}}{\partial {z}^{L}} + =\frac{\partial \mathcal{L}}{\partial {a}^{L}}\frac{\partial {a}^L}{\partial {z}^{L}}=({a}^L-{y})\odot({a}^L(1-{a}^L))$ + +除了输出层($l=L$)的中间值${\delta}^{L}$,其他层($l=1, 2, \ldots , L-1$)的中间值${\delta}^{l}$如何计算呢? + +- 已知模型结构${z}^{l+1}={W}^{l+1}{a}^{l}+{b}^{l+1}$,我们可以直接得到$\frac{\partial {z}^{l+1}}{\partial {a}^{l}}={W}^{l+1}$;而且我们已知$\frac{\partial {a}^l}{\partial {z}^l}={a}^l(1-{a}^l)$ + +- 那么根据链式法则,我们可以得到 ${\delta}^{l} + =\frac{\partial \mathcal{L}}{\partial {z}^{l}} + =\frac{\partial \mathcal{L}}{\partial {z}^{l+1}}\frac{\partial {z}^{l+1}}{\partial {a}^{l}}\frac{\partial {a}^{l}}{\partial {z}^{l}} + =({W}^{l+1})^\top{\delta}^{l+1}\odot({a}^l(1-{a}^l))$ + +根据上面的计算有所有层的中间值${\delta}^l, l=1, 2, \ldots , L$后,我们就可以在此基础上求出损失函数对于每层参数的偏导数:$\frac{\partial \mathcal{L}}{\partial {W}^l}$和$\frac{\partial \mathcal{L}}{\partial {b}^l}$,以此来根据梯度下降的方法来更新每一层的参数。 + +- 已知模型结构${z}^l={W}^l{a}^{l-1}+{b}^l$,我们可以求出 + $\frac{\partial {z}^{l}}{\partial {W}^l}={a}^{l-1}$ 和 + $\frac{\partial {z}^{l}}{\partial {b}^l}=1$ + +- 那么根据链式法则,我们可以得到$\frac{\partial \mathcal{L}}{\partial {W}^l}=\frac{\partial \mathcal{L}}{\partial {z}^l}\frac{\partial {z}^l}{\partial {W}^l}={\delta}^l({a}^{l-1})^\top$ + , + $\frac{\partial \mathcal{L}}{\partial {b}^l}=\frac{\partial \mathcal{L}}{\partial {z}^l}\frac{\partial {z}^l}{\partial {b}^l}={\delta}^l$ + +求得所有偏导数$\frac{\partial \mathcal{L}}{\partial {W}^l}$ 和 +$\frac{\partial \mathcal{L}}{\partial {b}^l}$后,我们就可以用梯度下降更新所有参数${W}^l$ +和 ${b}^l$: + +- ${W}^l:={W}^l-\alpha\frac{\partial \mathcal{L}}{\partial {W}^l}$, + ${b}^l:={b}^l-\alpha\frac{\partial \mathcal{L}}{\partial {b}^l}$ + +但是还有一个问题需要解决,那就是梯度下降的时候每更新一次参数,都需要计算一次当前参数下的损失值。然而,当训练数据集很大时($N$很大),若每次更新都用整个训练集来计算损失值的话,计算量会非常巨大。 +为了减少计算量,我们使用**随机梯度下降**(Stochastic Gradient +Descent,SGD)来计算损失值。具体来说,我们计算损失值不用全部训练数据,而是从训练集中随机选取一些数据样本来计算损失值,比如选取16、32、64或者128个数据样本,样本的数量被称为**批大小**(Batch +Size)。 +此外,学习率的设定也非常重要。如果学习率太大,可能无法接近最小值的山谷,如果太小,训练又太慢。 +自适应学习率,例如Adam [@KingmaAdam2014]、RMSProp [@tieleman2012rmsprop] +和 +Adagrad [@duchi2011adagrad] 等,在训练的过程中通过自动的方法来修改学习率,实现训练的快速收敛,到达最小值点。 diff --git a/appendix_Introduction_machine_learning/index.md b/appendix_Introduction_machine_learning/index.md index 15172fc..680737b 100644 --- a/appendix_Introduction_machine_learning/index.md +++ b/appendix_Introduction_machine_learning/index.md @@ -1,10 +1,12 @@ # 附录:机器学习介绍 - +本书假设读者有一定的机器学习算法基础,因此本章只会简略地介绍一下机器学习,其中的梯度下降方法对本书机器学习系统来说尤为重要,是必须掌握的内容。 ```toc :maxdepth: 2 :numbered: - +neural_network +gradient_descent +classic_machine_learning ``` \ No newline at end of file diff --git a/appendix_Introduction_machine_learning/neural_network.md b/appendix_Introduction_machine_learning/neural_network.md new file mode 100644 index 0000000..b2febf7 --- /dev/null +++ b/appendix_Introduction_machine_learning/neural_network.md @@ -0,0 +1,179 @@ +## 神经网络 + +### 感知器 +![有三个输入和单一输出的神经元](../img/ch_basic/single_neuron2.png) +:width:`600px` +:label:`single_neuron` + + :numref:`single_neuron`是一个神经元的例子,输入数据$x$根据连线上的权重$w$做加权求和得到输出$z$,我们把这样的模型叫作**感知器**(Perceptron)。 + 因为输入和输出之间只有一层神经连接,这个模型也叫做单层感知器。 :numref:`single_neuron`的模型计算可以写为:$z = w_{1}x_{1}+ w_{2}x_{2} + w_{3}x_{3}$。 + +当输入数据用列向量${x}=[x_1,x_2,x_3]^T$表示,模型权重用行向量${w}=[w_1,w_2,w_3]$表示,那么输出的标量$z$可以写为: + +$$z = +\begin{bmatrix} +w_1,w_2,w_3\\ +\end{bmatrix} +\begin{bmatrix} +x_1\\ +x_2\\ +x_3 +\end{bmatrix} +={w}{x}$$ + +我们可以利用输出标量$z$为输入的加权组合来实现特定任务。 +比如,可以对"好苹果"和"坏苹果"进行分类,输入的$x_1,x_2,x_3$分别代表三种不同的特征:1)红色的程度,2)有没有洞,3)大小。如果苹果的大小对这个判断没有影响,那么对应的权重就为零。 +这个神经网络的训练,其实就是选择合适的权重,来实现我们的任务。比如我们可以选择合适的权重,使得当$z$小于等于$0$时代表"坏苹果",而当$z$大于$0$时则是"好苹果"。 +则最终的分类输出标签$y$如下,为$1$时代表好,$0$代表坏。这个神经元的输入和输出之间只有一层,所以可以成为单层神经网络。 + +$$ +y = +\begin{cases} +1 & z>0 \\ +0 & z \leq 0 \\ +\end{cases}$$ + +### 决策边界vs.偏置 + +通过选择合适的权重以$z$大于或小于$0$来对输入数据做分类的话,可以在数据空间上获得一个**决策边界** +(Decision +Boundary)。如 :numref:`single_neuron_decision_boundary2`所示,以神经元输出$z=0$作为输出标签$y$的决策边界,没有偏置时决策边界必然经过坐标原点,如果数据样本点不以原点来分开,会导致分类错误。 +为了解决这个问题,可以在神经元上加入一个**偏置**(Bias)。 :numref:`single_neuron_bias2` +是一个有偏置$b$的神经元模型,可以用 :eqref:`singleneuron_bias`表达: +$$z = w_{1}x_{1}+ w_{2}x_{2}+ w_{3}x_{3} + b$$ +:eqlabel:`singleneuron_bias` + +![两个输入(左)和三个输入(右)时的决策边界。不同形状的点代表不同类别的数据,需要找到$z=0$作为决策边界来把不同数据点分开。两个输入时决策边界是一直线,三个输入时决策边界是一个平面,高维度输入时决策边界称为**超平面**(Hyperplane)。 +左: $z=w_{1}x_{1}+w_{2}x_{2}+b$。右:$z=w_{1}x_{1}+w_{2}x_{2}+w_{3}x_{3}+b$。没有偏置时,决策边界必然经过原点,所以不能分开不同类别的数据样本。](../img/ch_basic/single_neuron_decision_boundary2.png) +:width:`600px` +:label:`single_neuron_decision_boundary2` + +![一个有偏置的单层神经网络](../img/ch_basic/single_neuron_bias2.png) +:width:`600px` +:label:`single_neuron_bias2` + +有了偏置以后,决策边界(直线、平面或超平面)可以不经过坐标原点,因此能更好地分类样本。 +准确来说,决策边界把这些样本数据分成两个不同的类别,这个边界是 +$\{x_1, x_2, x_3 | w_{1}x_{1}+ w_{2}x_{2}+ w_{3}x_{3} + b = 0\}$。 + +### 逻辑回归 + +上述神经元的输入和输出是线性关系,为了提供非线性的数据表达能力,可以在神经元输出上加上**激活函数**(Activation +Function),最常见的激活函数有Sigmoid、Tanh、ReLU和Softmax等。 +比如,上述神经元以$z=0$为分界来做分类任务,那么我们可不可以让神经元输出一个概率呢?比如输出$0~1$,$1$代表输入数据$100\%$为某一类。 +为了让神经元输出$0~1$,可以在$z$上加一个逻辑函数**Sigmoid**, +如 :eqref:`sigmoid`所示,Sigmoid把数值限制在0和1之中,通过一个简单的临界值(如:0.5)来决定最终输出的标签是否属于某个类别。这个方法叫做**逻辑回归**(Logistic +Regression)。 + +$$a = f({z}) = \frac{1}{1+{\rm e}^{-{z}}}$$ +:eqlabel:`sigmoid` + +### 多个神经元 + +![多个神经元](../img/ch_basic/two_neurons2.png) +:width:`600px` +:label:`two_neurons2` + +上述网络只有一个输出,若多个神经元在一起就可以有多个输出。 :numref:`two_neurons2`是有两个输出的网络,每个输出都和所有输入相连,所以也被称**全连接层**(Fully-Connected(FC) Layer), +可由下述式子 :eqref:`fullyconnected`表示X。 + +$$ +z_{1} &= w_{11}x_{1} + w_{12}x_{2} + w_{13}x_{3} + b_1 \notag \\ +z_{2} &= w_{21}x_{1} + w_{22}x_{2} + w_{23}x_{3} + b_2$$ +:eqlabel:`fullyconnected` + +如下式子表示了矩阵方法的实现: + +$$ +{z} = +\begin{bmatrix} +z_1 \\ +z_2 +\end{bmatrix} += +\begin{bmatrix} +w_{11} & w_{12} & w_{13}\\ +w_{21} & w_{22} & w_{23}\\ +\end{bmatrix} +\begin{bmatrix} +x_1\\ +x_2\\ +x_3 +\end{bmatrix} ++ +\begin{bmatrix} +b_1 \\ b_2 +\end{bmatrix} += {W}{x} + {b}$$ + + +多输出的网络可以实现多分类问题,比如有10个数值输出,每个数值分别代表一类物品的概率,每个输出在$0$到$1$之间,10个输出之和为$1$。 +可用 :eqref:`e_softmax`的**Softmax** 函数来实现,$K$为输出的个数: + +$$f({z})_{i} = \frac{{\rm e}^{z_{i}}}{\sum_{k=1}^{K}{\rm e}^{z_{k}}}$$ +:eqlabel:`e_softmax` + +### 多层感知器 + +![多层感知器例子。$a^l_i$表示神经元输出$z$经过激活函数后的值,其中$l$代表层的序号($L$代表输出层),$i$代表输出的序号](../img/ch_basic/mlp2.png) + +**多层感知器**(Multi-Layer +Perceptron,MLP)[@rosenblatt1958perceptron]通过叠加多层全连接层来提升网络的表达能力。相比单层网络,多层感知器有很多中间层的输出并不暴露给最终输出,这些层被称为**隐含层**(Hidden +Layers)。这个例子中的网络可以通过下方的串联式矩阵运算实现,其中$W^l$和$b^l$代表不同层的权重矩阵和偏置,$l$代表层号,$L$代表输出层。 + +$${z} = f({W^L}f({W^3}f({W^2}f({W^1}{x} + {b^1}) + {b^2}) + {b^3}) + {b^L})$$ + +在深度学习时代,网络模型基本都是多层的神经网络层连接起来的,输入数据经过多层的特征提取,可以学到不同抽象层级的**特征向量**(Feature +Vector)。下面我们介绍一下其他常用的神经网络层。 + +### 卷积网络 + +![卷积运算例子。 输入一个三通道的数据,其大小为$4 \times 4 \times 3$(高 +$\times$ 宽 $\times$ +通道数),为了对各个通道做卷积,卷积核也必须有三个通道,一个卷积核的大小为$3 \times 3 \times 3 \times 1$(高 +$\times$ +宽$\times$输入通道数$\times$输出通道数(卷积核的个数))。有多少个卷积核就有多少个输出的**特征图**(Feature +Map),在这个例子中因为只有一个卷积核,所以输出的通道数为1,高宽为2。与此同时,我们把这种高维度的输入数据称为**张量**(Tensor),比如RGB图像、视频、前一层卷积层的输出等等。](../img/ch_basic/conv_computation_v4.png) +:width:`600px` +:label:`conv_computation_v4` + +**卷积神经网络** (Convolutional Neural +Network,CNN)[@lecun1989backpropagation]由多层**卷积层**(Convolutional +Layer)组成,常用于计算机视觉任务 [@krizhevsky2012imagenet; @he2016deep]。 + :numref:`conv_computation_v4`描述了一个卷积运算的例子。 +根据卷积的特点,我们可以知道两个事实:1)一个卷积核的通道数,等于输入的通道数;2)输出的通道数,等于卷积核的数量。 + + :numref:`conv_computation_v4`例子中,卷积核每次滑动一个数值的范围来进行卷积操作,我们称它的**步长**(Stride)为1。此外,如果希望输入的边缘数值也能被考虑在内的话,则需要对边缘做**填零**(Zero +Padding)操作。 :numref:`conv_computation_v4`例子中,如果输入的每个通道上下左右都填充一圈零,那么输出的大小则为$4\times 4\times 1$。填零的圈数取决于卷积核的大小,卷积核越大则填零圈数越大。 + +为了对输入的图像数据做特征提取,卷积核数量往往比输入数据的通道数据要多,这样的话输出数据的数值会很多,计算量变大。然而图像数据中相邻像素的特征往往相似,所以我们可以对相邻的输出特征进行聚合操作。**池化层**就是为了实现这个目的,我们通常有两种池化方法最大值池化(Max +Pooling)和平均值池化(Mean +Pooling)。如 :numref:`pooling_v3`所示,假设池化的卷积核高宽为$2\times2$,输入$4\times4$的数据,步长为2(步长为1时,则输出等于输入),则输出为$2\times2$。 + +![$2 \times 2$ +最大值池化和平均值池化的例子,它们的步长为2,输入大小是$4 \times 4$](../img/ch_basic/pooling_v3.png) +:width:`600px` +:label:`pooling_v3` + +卷积层和全连接层都是很常用的,但是卷积层在输入是高维度的图像时,需要的参数量远远小于全连接层。卷积层的运算和全连接层是类似的,前者基于高维度张量运算,后者基于二维矩阵运算。 + +### 时序模型 + +现实生活中除了图像还有大量时间序列数据,例如视频、股票价格等等。**循环神经网络**(Recurrent +Neural Networks,RNN)[@rumelhart1986learning] +是一种处理序列数据的深度学习模型结构。序列数据是一串连续的数据$\{x_1, x_2, \dots, x_n\}$,比如每个$x$代表一个句子中的单词。 + +为了可以接收一连串的输入序列,如 :numref:`rnn_simple_cell2`所示,朴素循环神经网络使用了循环单元(Cell)作为计算单元,用隐状态(Hidden +State)来存储过去输入的信息。具体来说,对输入模型的每个数据$x$,根据公式 :eqref:`aligned`,循环单元会反复计算新的隐状态,用于记录当前和过去输入的信息。而新的隐状态会被用到下一单元的计算中。 + +$${h}_t = {W}[{x}_t; {h}_{t-1}] + {b}$$ +:eqlabel:`aligned` + +![朴素循环神经网络。 在每一步的计算中,循环单元通过过去时刻的隐状态${h}_{t-1}$和当前的输入${x}_t$,求得当前的隐状态${h}_t$。](../img/ch_basic/rnn_simple_cell2.png) +:width:`600px` +:label:`rnn_simple_cell2` + +然而这种简单的朴素循环神经网络有严重的信息遗忘问题。比如说我们的输入是"我是中国人,我的母语是\_\_\_",隐状态记住了"中国人"的信息,使得网络最后可以预测出"中文"一词;但是如果句子很长的时候,隐状态可能记不住太久之前的信息了,比如说"我是中国人,我去英国读书,后来在法国工作,我的母语是\_\_\_",这时候在最后的隐状态中关于"中国人"的信息可能会被因为多次的更新而遗忘了。 +为了解决这个问题,后面有人提出了各种各样的改进方法,其中最有名的是长短期记忆(Long +Short-Term +Memory,LSTM)[@Hochreiter1997lstm]。关于时序的模型还有很多很多,比如近年来出现的Transformer [@vaswani2017attention]等等。 diff --git a/build_and_transform.sh b/build_and_transform.sh new file mode 100644 index 0000000..70243ca --- /dev/null +++ b/build_and_transform.sh @@ -0,0 +1,2 @@ +d2lbook build html +python3 tools/format_tables.py \ No newline at end of file diff --git a/chapter_compiler_backend_and_runtime/compute_schedule_and_execute.md b/chapter_compiler_backend_and_runtime/compute_schedule_and_execute.md index 1ac9406..82675e4 100644 --- a/chapter_compiler_backend_and_runtime/compute_schedule_and_execute.md +++ b/chapter_compiler_backend_and_runtime/compute_schedule_and_execute.md @@ -29,7 +29,7 @@ 上述脚本将所有的计算逻辑定义在Computation类的construct方法中,由于在脚本开头的context中预先设置了单算子执行模式,construct中的计算将被Python的运行时逐行调用执行,同时可以在代码中的任意位置添加print命令以便打印中间的计算结果。 -单算子执行的调用链路如图:numref:`single_op_exec`所示,算子在Python侧被触发执行后,会经过AI框架初始化,其中需要确定包括算子的精度,输入与输出的类型和大小以及对应的硬件设备等信息,接着框架会为该算子分配计算所需的内存,最后交给具体的硬件计算设备完成计算的执行。 +单算子执行的调用链路如图 :numref:`single_op_exec`所示,算子在Python侧被触发执行后,会经过AI框架初始化,其中需要确定包括算子的精度,输入与输出的类型和大小以及对应的硬件设备等信息,接着框架会为该算子分配计算所需的内存,最后交给具体的硬件计算设备完成计算的执行。 ![单算子执行](../img/ch05/single_op_exec.PNG) :width:`800px` @@ -39,13 +39,13 @@ ### 计算图调度 -虽然单算子调度具有如上所述的优点,其缺点也很明显。一方面是难于进行计算性能的优化,原因是由于缺乏计算图的全局信息,单算子执行时无法根据上下文完成算子融合,代数化简等优化;另一方面由于缺乏计算的拓扑关系,整个计算只能串行调度执行,即无法通过运行时完成并行计算。例如上述示例代码的计算逻辑可以表达为图:numref:`graph_exec`所示。由该计算图可以看出,其中乘法和减法之间并没有依赖关系,因此这两个计算可以并行执行,而这样的并行执行信息只有将计算表达为计算图后才能完成分析,这也是计算图调度相对于单算子调度的优势之一。 +虽然单算子调度具有如上所述的优点,其缺点也很明显。一方面是难于进行计算性能的优化,原因是由于缺乏计算图的全局信息,单算子执行时无法根据上下文完成算子融合,代数化简等优化;另一方面由于缺乏计算的拓扑关系,整个计算只能串行调度执行,即无法通过运行时完成并行计算。例如上述示例代码的计算逻辑可以表达为图 :numref:`graph_exec`所示。由该计算图可以看出,其中乘法和减法之间并没有依赖关系,因此这两个计算可以并行执行,而这样的并行执行信息只有将计算表达为计算图后才能完成分析,这也是计算图调度相对于单算子调度的优势之一。 ![计算图](../img/ch05/graph_exec.png) :width:`800px` :label:`graph_exec` -下面我们开始介绍计算图的调度方式,在一个典型的异构计算环境中,主要存在CPU、GPU以及NPU等多种计算设备,因此一张计算图可以由运行在不同设备上的算子组成为异构计算图。图:numref:`computation_graph`展示了一个典型的由异构硬件共同参与的计算图。 +下面我们开始介绍计算图的调度方式,在一个典型的异构计算环境中,主要存在CPU、GPU以及NPU等多种计算设备,因此一张计算图可以由运行在不同设备上的算子组成为异构计算图。图 :numref:`computation_graph`展示了一个典型的由异构硬件共同参与的计算图。 ![异构硬件计算图](../img/ch05/computation_graph.png) :width:`800px` @@ -63,7 +63,7 @@ - **Python算子**:在执行模式上与CPU算子类似,都是由主机上的CPU执行计算,区别在于计算逻辑是由Python语言的运行时通过Python解释器解释执行。 -异构计算图能够被正确表达的首要条件是准确标识算子执行所在的设备,例如异构计算图:numref:`computation_graph`中所标识的CPU、GPU和Ascend +异构计算图能够被正确表达的首要条件是准确标识算子执行所在的设备,例如异构计算图 :numref:`computation_graph`中所标识的CPU、GPU和Ascend Kernel,以及被标记为被Python语言运行时执行的Python Kernel。主流框架均提供了指定算子所在运行设备的能力,以MindSpore为例,一段简单的异构计算代码如下所示: @@ -138,7 +138,7 @@ z的计算逻辑,其中Add算子被设置为在CPU上执行,Sub算子被设 :width:`800px` :label:`side_effect_1` -代码中所示三行计算之间并没有依赖关系,因此这三个算子在计算图的逻辑上可以被并发执行,并发关系如图:numref:`side_effect_1`所示,然而根据代码的语义,显而易见是需要确保程序能够被顺序执行,这里引入的问题被称为副作用,副作用是指函数修改了在函数外部定义的状态变量的行为。由于副作用的引入而导致了错误并发关系的发生,一种解决方案是在计算图编译阶段通过添加算子间的依赖,将并发执行逻辑转换为顺序执行逻辑,转换后的计算图如图:numref:`side_effect_2`所示: +代码中所示三行计算之间并没有依赖关系,因此这三个算子在计算图的逻辑上可以被并发执行,并发关系如图 :numref:`side_effect_1`所示,然而根据代码的语义,显而易见是需要确保程序能够被顺序执行,这里引入的问题被称为副作用,副作用是指函数修改了在函数外部定义的状态变量的行为。由于副作用的引入而导致了错误并发关系的发生,一种解决方案是在计算图编译阶段通过添加算子间的依赖,将并发执行逻辑转换为顺序执行逻辑,转换后的计算图如图 :numref:`side_effect_2`所示: ![消除副作用](../img/ch05/side_effect_2.png) :width:`800px` @@ -157,7 +157,7 @@ z的计算逻辑,其中Add算子被设置为在CPU上执行,Sub算子被设 :width:`800px` :label:`graph_exec_1` -如图:numref:`graph_exec_1`是一张非异构计算图,计算图上全部Kernel均为GPU算子,执行方式一般分为串行执行和并行执行: +如图 :numref:`graph_exec_1`是一张非异构计算图,计算图上全部Kernel均为GPU算子,执行方式一般分为串行执行和并行执行: ![串行执行](../img/ch05/graph_exec_2.png) :width:`800px` @@ -167,9 +167,9 @@ z的计算逻辑,其中Add算子被设置为在CPU上执行,Sub算子被设 :width:`800px` :label:`graph_exec_3` -- **串行执行**:将计算图展开为执行序列,按照执行序逐个串行执行,如图:numref:`graph_exec_2`所示。其特点为执行顺序固定,单线程执行,对系统资源要求相对较低。 +- **串行执行**:将计算图展开为执行序列,按照执行序逐个串行执行,如图 :numref:`graph_exec_2`所示。其特点为执行顺序固定,单线程执行,对系统资源要求相对较低。 -- **并行执行**:将计算图按照算子之间的依赖关系展开,有依赖关系的算子通过输入依赖保证执行顺序,没有依赖关系的算子则可以并行执行,如图:numref:`graph_exec_3`所示,Kernel_1和Kernel_2没有依赖可以并行执行,Kernel_3和Kernel_4没有依赖可以并行执行。其特点为执行顺序不固定,每轮执行的算子顺序大概率不一样,多线程执行,对系统资源要求相关较高。 +- **并行执行**:将计算图按照算子之间的依赖关系展开,有依赖关系的算子通过输入依赖保证执行顺序,没有依赖关系的算子则可以并行执行,如图 :numref:`graph_exec_3`所示,Kernel_1和Kernel_2没有依赖可以并行执行,Kernel_3和Kernel_4没有依赖可以并行执行。其特点为执行顺序不固定,每轮执行的算子顺序大概率不一样,多线程执行,对系统资源要求相关较高。 串行执行和并行执行各有优点和缺点,总结对比见表5.1。 @@ -186,8 +186,8 @@ z的计算逻辑,其中Add算子被设置为在CPU上执行,Sub算子被设 :width:`800px` :label:`graph_exec_4` -如图:numref:`graph_exec_4`是一张异构计算图,其中Kernel_1、Kernel_2、Kernel_5、Kernel_9为CPU算子,Kernel_6为python算子(执行也是在CPU上),Kernel_3和Kernel_4为GPU算子,Kernel_7和Kernel_8为GPU算子。 -一般来说计算图的优化都是基于非异构计算图来实现的,要求计算图中的算子为同一设备上的,方便算子间的融合替换等优化操作,因此需要将一张异构计算图切分为多个非异构计算图,这里切分就比较灵活了,可以定义各种切分规则,一般按照产生尽量少的子图的切分规则来切分,尽量将多的同一设备上的算子放在一张子图中,如图:numref:`graph_exec_5`所示,最后产生5张子图:Graph_1\_CPU、Graph_2\_GPU、Graph_3\_CPU、Graph_4\_Ascend、Graph_5\_CPU。 +如图 :numref:`graph_exec_4`是一张异构计算图,其中Kernel_1、Kernel_2、Kernel_5、Kernel_9为CPU算子,Kernel_6为python算子(执行也是在CPU上),Kernel_3和Kernel_4为GPU算子,Kernel_7和Kernel_8为GPU算子。 +一般来说计算图的优化都是基于非异构计算图来实现的,要求计算图中的算子为同一设备上的,方便算子间的融合替换等优化操作,因此需要将一张异构计算图切分为多个非异构计算图,这里切分就比较灵活了,可以定义各种切分规则,一般按照产生尽量少的子图的切分规则来切分,尽量将多的同一设备上的算子放在一张子图中,如图 :numref:`graph_exec_5`所示,最后产生5张子图:Graph_1\_CPU、Graph_2\_GPU、Graph_3\_CPU、Graph_4\_Ascend、Graph_5\_CPU。 ![异构计算图切分](../img/ch05/graph_exec_5.png) :width:`800px` @@ -195,9 +195,9 @@ z的计算逻辑,其中Add算子被设置为在CPU上执行,Sub算子被设 将一张异构计算图切分为多个子计算图后,执行方式一般分为子图拆分执行和子图合并执行: -- **子图拆分执行**:将切分后的多个子图分开执行,即一个子图执行完再执行另一个子图,如图:numref:`graph_exec_6`所示,上一个子图的输出数据会传输给下一个子图的输入数据,并且下一个子图需要对输入数据拷贝为本图的device数据,如Graph_2\_GPU需要将Graph_1\_CPU的输出数据从CPU拷贝到GPU,反过来Graph_3\_CPU需要将Graph2GPU的输出数据从GPU拷贝到CPU,子图之间互相切换执行有一定的开销。 +- **子图拆分执行**:将切分后的多个子图分开执行,即一个子图执行完再执行另一个子图,如图 :numref:`graph_exec_6`所示,上一个子图的输出数据会传输给下一个子图的输入数据,并且下一个子图需要对输入数据拷贝为本图的device数据,如Graph_2\_GPU需要将Graph_1\_CPU的输出数据从CPU拷贝到GPU,反过来Graph_3\_CPU需要将Graph2GPU的输出数据从GPU拷贝到CPU,子图之间互相切换执行有一定的开销。 -- **子图合并执行**:将切分后的多个子图进行合并,合并为一个整体大的DAG执行,如图:numref:`graph_exec_7`所示,通过算子的设备属性来插入拷贝算子以实现不同设备上的算子数据传输,并且拷贝算子也是进入整图中的,从而形成一个大的整图执行,减少子图之间的切换执行开销。 +- **子图合并执行**:将切分后的多个子图进行合并,合并为一个整体大的DAG执行,如图 :numref:`graph_exec_7`所示,通过算子的设备属性来插入拷贝算子以实现不同设备上的算子数据传输,并且拷贝算子也是进入整图中的,从而形成一个大的整图执行,减少子图之间的切换执行开销。 ![子图拆分](../img/ch05/graph_exec_6.png) :width:`800px` @@ -218,7 +218,7 @@ z的计算逻辑,其中Add算子被设置为在CPU上执行,Sub算子被设 3、异构计算图的执行加速 -前面讲述了非异构计算图的两种执行方式和异构计算图的两种执行方式,其中异构计算图又是在非异构计算图的基础之上,因此异构计算图按照两两组合共有四种执行方式,以MindSpore为例,采用的是子图合并并行执行,示例图如图:numref:`graph_exec_5`所示,首先是作为一张整图来执行可以避免子图切换的执行开销,然后在整图内并行执行,可以最大粒度的发挥并发执行优势,达到最优的执行性能。 +前面讲述了非异构计算图的两种执行方式和异构计算图的两种执行方式,其中异构计算图又是在非异构计算图的基础之上,因此异构计算图按照两两组合共有四种执行方式,以MindSpore为例,采用的是子图合并并行执行,示例图如图 :numref:`graph_exec_5`所示,首先是作为一张整图来执行可以避免子图切换的执行开销,然后在整图内并行执行,可以最大粒度的发挥并发执行优势,达到最优的执行性能。 ![异构硬件加速](../img/ch05/graph_exec_8.png) :width:`800px` @@ -226,6 +226,6 @@ z的计算逻辑,其中Add算子被设置为在CPU上执行,Sub算子被设 ### 下沉式执行 -下沉式执行是通过专用芯片的SoC架构,将整个或部分计算图一次性调度到芯片上以完成全量数据的计算。例如对于Ascend芯片,多个Ascend算子组成的计算图可以在执行前被编译成为一个Task,通过Ascend驱动程序提供的接口,将包含多个算子的Task一次性下发到硬件上调度执行。因此上例中可以将Ascend的算子Kernel_7和Kernel_8优化为一个子图Graph_4\_Ascend,再将该子图编译成为一个Task,并下沉到Ascend上执行,如图:numref:`graph_exec_8`所示。 +下沉式执行是通过专用芯片的SoC架构,将整个或部分计算图一次性调度到芯片上以完成全量数据的计算。例如对于Ascend芯片,多个Ascend算子组成的计算图可以在执行前被编译成为一个Task,通过Ascend驱动程序提供的接口,将包含多个算子的Task一次性下发到硬件上调度执行。因此上例中可以将Ascend的算子Kernel_7和Kernel_8优化为一个子图Graph_4\_Ascend,再将该子图编译成为一个Task,并下沉到Ascend上执行,如图 :numref:`graph_exec_8`所示。 下沉式执行由于避免了在计算过程中主机侧和设备侧的交互,因此可以获得更好的整体计算性能。然而下沉式执行也存在一些局限,例如在动态shape算子,复杂控制流等场景下会面临较大的技术挑战。 \ No newline at end of file diff --git a/chapter_compiler_backend_and_runtime/graph_optimizer.md b/chapter_compiler_backend_and_runtime/graph_optimizer.md index 34f2ae1..b6428af 100644 --- a/chapter_compiler_backend_and_runtime/graph_optimizer.md +++ b/chapter_compiler_backend_and_runtime/graph_optimizer.md @@ -14,7 +14,7 @@ ReLU,Element-Wise Sum等。 ReLU"。Conv卷积算子是计算密集型,ReLU算子是访存密集型算子,ReLU算子可以直接取Conv算子的计算结果进行计算,因此我们可以将二者融合成一个算子来进行计算,从而减少内存访问延时和带宽压力,提高执行效率。 例如:"Conv + Conv + Sum + -ReLU"的融合,从图:numref:`conv_sum_relu`中我们可以看到融合后的算子减少了两个内存的读和写的操作,优化了Conv的输出和Sum的输出的读和写的操作。 +ReLU"的融合,从图 :numref:`conv_sum_relu`中我们可以看到融合后的算子减少了两个内存的读和写的操作,优化了Conv的输出和Sum的输出的读和写的操作。 ![Elementwise算子融合](../img/ch05/conv_sum_relu.png) :width:`800px` @@ -28,7 +28,7 @@ MindSpore :width:`800px` :label:`graph_kernel` -图:numref:`graph_kernel`中,算子拆解阶段(Expander)将计算图中一些复杂算子(composite +图 :numref:`graph_kernel`中,算子拆解阶段(Expander)将计算图中一些复杂算子(composite op, 图中Op1、Op3、Op4)展开为计算等价的基本算子组合( 图中虚线正方形框包围着的部分);在算子聚合阶段(Aggregation),将计算图中将基本算子(basic op, 如图中Op2)、拆解后的算子(expanded @@ -45,7 +45,7 @@ Op2)。图算融合通过对计算图结构的拆解和聚合,可以实现 1、硬件指令限制 -在一些特定的硬件上,IR中计算节点没有直接对应的硬件算子,只能通过子图的变换来达到子图中所有算子在对应的硬件上的存在。例如在MindSpore中,昇腾芯片上的Concat算子,只支持有限的输入个数(63个),因此当前端IR上的输入个数大于限制输入的时候,需要将该计算节点拆分成等价的多个Concat节点,如图:numref:`concat`所示: +在一些特定的硬件上,IR中计算节点没有直接对应的硬件算子,只能通过子图的变换来达到子图中所有算子在对应的硬件上的存在。例如在MindSpore中,昇腾芯片上的Concat算子,只支持有限的输入个数(63个),因此当前端IR上的输入个数大于限制输入的时候,需要将该计算节点拆分成等价的多个Concat节点,如图 :numref:`concat`所示: 当Concat有100个输入时,单个算子只支持最多63个输入,此时会将该计算节点拆分成两个Concat节点,分别为63个输入和37个输入的两个算子。 ![Concat算子拆分](../img/ch05/concat.png) @@ -54,7 +54,7 @@ Op2)。图算融合通过对计算图结构的拆解和聚合,可以实现 2、数据排布格式的限制 -针对不同特点的计算平台和不同的算子,为了追求最好的性能,一般都需要选择不同的数据排布格式(Format),而这些排布格式可能跟框架缺省的排布格式是不一样的。在这种情况下,一般的做法是算子在执行完成后对输出插入一个格式转换操作,把排布格式转换回框架的缺省排布格式,这就引入了额外的内存操作。以下图:numref:`transdata`为例,在昇腾平台上Conv算子在输入和输出的内存排布为5HD时是性能最优的,所以可以看到Conv算子输出结果的格式是5HD,然后通过一个转换操作转回了框架缺省的NCHW,紧接着,后面又是一个Conv算子,它需要5HD的输入,所以又做了一个NCHW到5HD的转换。我们很容易看出,虚线框内的两个转换操作互为逆操作,可以相互抵消。通过对计算图的模式匹配,可以将该类型的操作消除。 +针对不同特点的计算平台和不同的算子,为了追求最好的性能,一般都需要选择不同的数据排布格式(Format),而这些排布格式可能跟框架缺省的排布格式是不一样的。在这种情况下,一般的做法是算子在执行完成后对输出插入一个格式转换操作,把排布格式转换回框架的缺省排布格式,这就引入了额外的内存操作。以下图 :numref:`transdata`为例,在昇腾平台上Conv算子在输入和输出的内存排布为5HD时是性能最优的,所以可以看到Conv算子输出结果的格式是5HD,然后通过一个转换操作转回了框架缺省的NCHW,紧接着,后面又是一个Conv算子,它需要5HD的输入,所以又做了一个NCHW到5HD的转换。我们很容易看出,虚线框内的两个转换操作互为逆操作,可以相互抵消。通过对计算图的模式匹配,可以将该类型的操作消除。 ![数据排布格式转换消除](../img/ch05/transdata.png) :width:`800px` diff --git a/chapter_compiler_backend_and_runtime/kernel_selecter.md b/chapter_compiler_backend_and_runtime/kernel_selecter.md index 1bb5f73..6289f46 100644 --- a/chapter_compiler_backend_and_runtime/kernel_selecter.md +++ b/chapter_compiler_backend_and_runtime/kernel_selecter.md @@ -13,13 +13,13 @@ **数据排布格式** 机器学习系统中很多运算都会转换成为矩阵的乘法,例如卷积运算。我们知道矩阵乘法$A\times B = C$ -,是以A的一行乘以B的一列求和后得到C的一个元素。以图:numref:`matmuldatalayout`为例,在图:numref:`matmuldatalayout`的上方,矩阵数据的存储是按照行优先来进行存储,虽然B在存储时是按照行存储,但是读取数据时却按照列进行读取,假如我们能把B的格式进行转换转换为列存储,例如图:numref:`matmuldatalayout`下方所示,这样就可以通过访问连续内存的方式加快数据访问速度进而提升运算速度。由此可见不同的数据排布方式对性能有很大影响。 +,是以A的一行乘以B的一列求和后得到C的一个元素。以图 :numref:`matmuldatalayout`为例,在图 :numref:`matmuldatalayout`的上方,矩阵数据的存储是按照行优先来进行存储,虽然B在存储时是按照行存储,但是读取数据时却按照列进行读取,假如我们能把B的格式进行转换转换为列存储,例如图 :numref:`matmuldatalayout`下方所示,这样就可以通过访问连续内存的方式加快数据访问速度进而提升运算速度。由此可见不同的数据排布方式对性能有很大影响。 ![矩阵乘法数据排布示意图](../img/ch05/matmuldatalayout.png) :width:`800px` :label:`matmuldatalayout` -在机器学习系统中我们常见的数据格式一般有两种,分别为NCHW类型和NHWC类型。其中N代表了数据输入的BatchSize大小,C代表了图像的通道,H和W分别代表图像输入的长和宽。图:numref:`data_format`展示了BatchSize为2,通道数16和长度为5\*4大小的数据逻辑示意图。 +在机器学习系统中我们常见的数据格式一般有两种,分别为NCHW类型和NHWC类型。其中N代表了数据输入的BatchSize大小,C代表了图像的通道,H和W分别代表图像输入的长和宽。图 :numref:`data_format`展示了BatchSize为2,通道数16和长度为5\*4大小的数据逻辑示意图。 ![常见数据格式](../img/ch05/data_format.png) :width:`800px` @@ -29,7 +29,7 @@ 对于NCHW的数据是先取W轴方向数据,再取H轴方向数据,再取C轴方向,最后取N轴方向。其中物理存储与逻辑存储的之间的映射关系为 $$offsetnchw(n,c,h,w) = n*CHW + c*HW + h*W +w$$ -如图:numref:`nchw`所示,这种格式中,是按照最低维度W轴方向进行展开,W轴相邻的元素在内存排布中同样是相邻的。如果需要取下一个图片上的相同位置的元素,就必须跳过整个图像的尺寸($C*H*W$)。比如我有8张32\*32的RGB图像,此时$N=8,C=3,H=32,W=32$。在内存中存储们需要先按照w轴方向进行展开,然后按照h轴排列,这样之后便完成了一个通道的处理,之后按照同样的方式处理下一个通道。处理完全部通道后,处理下一张图片。PyTorch和MindSpore框架默认使用NCHW格式。 +如图 :numref:`nchw`所示,这种格式中,是按照最低维度W轴方向进行展开,W轴相邻的元素在内存排布中同样是相邻的。如果需要取下一个图片上的相同位置的元素,就必须跳过整个图像的尺寸($C*H*W$)。比如我有8张32\*32的RGB图像,此时$N=8,C=3,H=32,W=32$。在内存中存储们需要先按照w轴方向进行展开,然后按照h轴排列,这样之后便完成了一个通道的处理,之后按照同样的方式处理下一个通道。处理完全部通道后,处理下一张图片。PyTorch和MindSpore框架默认使用NCHW格式。 ![RGB图片下的NHWC数据格式](../img/ch05/nchw.png) :width:`800px` @@ -37,7 +37,7 @@ $$offsetnchw(n,c,h,w) = n*CHW + c*HW + h*W +w$$ 类似的NHWC数据格式是先取C方向数据,再取W方向,然后是H方向,最后取N方向。NHWC是Tensorflow默认的数据格式。这种格式在PyTorch中称为Chanel-Last。 $$offsetnchw(n,c,h,w) = n*HWC + h*HW + w*C +c$$ -图:numref:`nchwandnhwc`展示了不同数据格式下逻辑排布到内存物理侧数据排布的映射。\[x:1\]代表从最内侧维度到最下一维度的索引变换。比如\[a:1\]表示当前行W轴结束后,下一个H轴排布。\[b:1\]表示最内侧C轴排布完成后进行按照W轴进行排列。 +图 :numref:`nchwandnhwc`展示了不同数据格式下逻辑排布到内存物理侧数据排布的映射。\[x:1\]代表从最内侧维度到最下一维度的索引变换。比如\[a:1\]表示当前行W轴结束后,下一个H轴排布。\[b:1\]表示最内侧C轴排布完成后进行按照W轴进行排列。 ![NHWC与NHWC数据存储格式](../img/ch05/nchwandnhwc.png) :width:`800px` @@ -56,7 +56,7 @@ Precision)浮点表示。这种数据类型占用32位内存。还有一种精 :width:`800px` :label:`floatdtype` -如图:numref:`floatdtype`其中sign代表符号位,占1位,表示了机器数的正负,exponent表示指数位,Mantissa为尾数位。其数据计算采用二进制的科学计数法转换为十进制的计算方式如下: +如图 :numref:`floatdtype`其中sign代表符号位,占1位,表示了机器数的正负,exponent表示指数位,Mantissa为尾数位。其数据计算采用二进制的科学计数法转换为十进制的计算方式如下: $$(-1)^{sign}\times 2^{exponent-15}\times (\frac{mantissa}{1024}+1)$$ 其中如果指数位全为0时,且尾数位全为0时表示数字0。 如果指数位全为0,尾数位不全为0则表示一个非常小的数值。 diff --git a/chapter_compiler_backend_and_runtime/memory_allocator.md b/chapter_compiler_backend_and_runtime/memory_allocator.md index 6ca0c78..fd1d890 100644 --- a/chapter_compiler_backend_and_runtime/memory_allocator.md +++ b/chapter_compiler_backend_and_runtime/memory_allocator.md @@ -1,4 +1,5 @@ -## 内存分配 {#sec:ch06/ch06-memory-pool} +## 内存分配 +:label:`ch05-sec-memory_pool` 内存在传统计算机存储器层次结构中有着重要的地位,它是连接高速缓存和磁盘之间的桥 梁,有着比高速缓存更大的空间,比磁盘更快的访问速度。随着深度学习的发展,深度神经网络的模型越来越复杂,AI芯片上的内存很可能无法容纳一个大型网络模型。因此,对内存进行复用是一个重要的优化手段。此外,通过连续内存分配和 @@ -6,7 +7,7 @@ In-Place内存分配还可以提高某些算子的执行效率。 ### Device内存概念 -在深度学习体系结构中,我们通常将与硬件加速器(如GPU,AI芯片等)相邻的内存称之为设备(Device)内存,而与CPU相邻的内存称之为主机(Host)内存。如图:numref:`host-device-memory`所示,CPU可以合法地访问主机上的内存,而无法直接访问设备上的内存;同理,AI芯片可以访问设备上的内存,却无法访问主机上的内存。因此,在网络训练过程中,我们往往需要从磁盘加载数据到主机内存中,然后在主机内存中做数据处理,再从主机内存拷贝到设备内存中,最后设备才能合法地访问数据。算子全部计算完成后,用户要获取训练结果,又需要把数据从设备内存拷贝到主机内存中。 +在深度学习体系结构中,我们通常将与硬件加速器(如GPU,AI芯片等)相邻的内存称之为设备(Device)内存,而与CPU相邻的内存称之为主机(Host)内存。如图 :numref:`host-device-memory`所示,CPU可以合法地访问主机上的内存,而无法直接访问设备上的内存;同理,AI芯片可以访问设备上的内存,却无法访问主机上的内存。因此,在网络训练过程中,我们往往需要从磁盘加载数据到主机内存中,然后在主机内存中做数据处理,再从主机内存拷贝到设备内存中,最后设备才能合法地访问数据。算子全部计算完成后,用户要获取训练结果,又需要把数据从设备内存拷贝到主机内存中。 ![主机内存和设备内存](../img/ch05/host-device-memory.png) :width:`800px` @@ -23,11 +24,11 @@ $$size=\prod_{i=0}^{dimention}shape_i * sizeof\left ( data type \right )$$ :width:`800px` :label:`memory_allocate` -下面以图:numref:`memory_allocate`为例介绍内存分配的大致流程。首先我们会给Input +下面以图 :numref:`memory_allocate`为例介绍内存分配的大致流程。首先我们会给Input Tensor、Conv2D的权重和Conv2D的输出分配内存地址。然后为BatchNorm的输入分配地址时,我们发现BatchNorm的输入就是Conv2D算子的输出,而该Tensor的地址已经在之前分配过了,因此只需要将Conv2D算子的输出地址共享给BatchNorm的输入,就可以避免内存的重复申请以及内存的冗余拷贝。以此类推,可以发现整个过程中可以将待分配的内存分成三种类型:一是整张图的输入Tensor,二是算子的权重或者属性,三是算子的输出Tensor,三种Tensor在训练过程中的生命周期有所不同。 在CPU上我们常常使用malloc函数直接申请内存,这种方式申请内存好处是随时申请随时释放,简单易用。然而在许多对性能要求严苛的计算场景中,由于所申请内存块的大小不定,频繁申请释放会降低性能。通常我们会使用内存池的方式去管理内存,先申请一定数量和大小的内存块留作备用,当程序有内存申请需求时,直接从内存池中的内存块中申请。当程序释放该内存块时,内存池会进行回收并用作后续程序内存申请时使用。 -在深度学习框架中,Device内存的申请也是非常频繁的,往往也是通过内存池的方式去管理Device内存,并让Device内存的生命周期与Tensor的生命周期保持一致。不同的深度学习框架在内存池的设计上大同小异,我们以图:numref:`device_malloc`的MindSpore框架内存申请为例,进程会从Device上申请足够大的内存,然后通过双游标从两端偏移为Tensor分配内存。首先从申请的首地址开始进行偏移,为算子权重的Tensor分配内存,这部分Tensor生命周期较长,往往持续整个训练过程。然后从申请Device地址的末尾开始偏移,为算子的输出Tensor分配内存,这部分内存的生命周期较短,往往在该算子计算结束并且后续计算过程中无需使用再次使用该算子的输出的情况下,其生命周期就可以结束。通过这种方式,我们只需要从Device上申请一次足够大的内存,后续算子的内存分配都是通过指针偏移进行分配,减少了直接从设备申请内存的耗时。 +在深度学习框架中,Device内存的申请也是非常频繁的,往往也是通过内存池的方式去管理Device内存,并让Device内存的生命周期与Tensor的生命周期保持一致。不同的深度学习框架在内存池的设计上大同小异,我们以图 :numref:`device_malloc`的MindSpore框架内存申请为例,进程会从Device上申请足够大的内存,然后通过双游标从两端偏移为Tensor分配内存。首先从申请的首地址开始进行偏移,为算子权重的Tensor分配内存,这部分Tensor生命周期较长,往往持续整个训练过程。然后从申请Device地址的末尾开始偏移,为算子的输出Tensor分配内存,这部分内存的生命周期较短,往往在该算子计算结束并且后续计算过程中无需使用再次使用该算子的输出的情况下,其生命周期就可以结束。通过这种方式,我们只需要从Device上申请一次足够大的内存,后续算子的内存分配都是通过指针偏移进行分配,减少了直接从设备申请内存的耗时。 ![双游标法分配内存](../img/ch05/device_malloc.png) :width:`800px` @@ -36,19 +37,19 @@ Tensor、Conv2D的权重和Conv2D的输出分配内存地址。然后为BatchNor ### 内存复用 在机器学习系统中,内存复用是指分析Tensor的生命周期,将生命周期结束的Tensor的Device内存释放回内存池并用于后续Tensor的内存分配。内存复用的目的是提高内存的利用率,让有限的设备内存容纳更大的模型。 -以图:numref:`memory_allocate`为例,当BatchNorm算子计算结束后,output1不再被任何算子使用,则该Tensor的Device内存可以被回收,并且如果output1的内存尺寸大于等于output3的内存尺寸,则从output1回收的地址可以用于output3的内存分配,从而达到复用output1地址的目的。 +以图 :numref:`memory_allocate`为例,当BatchNorm算子计算结束后,output1不再被任何算子使用,则该Tensor的Device内存可以被回收,并且如果output1的内存尺寸大于等于output3的内存尺寸,则从output1回收的地址可以用于output3的内存分配,从而达到复用output1地址的目的。 ![内存生命周期图](../img/ch05/combine_memory_reuse_and_no_reuse.png) :width:`800px` :label:`combine_memory_reuse_and_no_reuse` -为了更好地描述内存复用问题,我们通过内存生命周期图来辅助理解。如图:numref:`combine_memory_reuse_and_no_reuse`所示,图中横坐标表示Tensor的生命周期,图中纵坐标表示内存大小。在生命周期内,某一个Tensor将一直占用某块Device内存,直至生命周期结束才会释放相应内存块。通过Tensor生命周期和内存大小可以构造出矩形块,而内存分配要求解的目标是在内存生命周期图中容纳更多的矩形块,问题的约束是矩形块之间无碰撞。图:numref:`combine_memory_reuse_and_no_reuse`左边是在未使用任何内存复用策略的情况下的内存生命周期图,此时内存同时只能容纳T0、T1、T2、T3四个Tensor。 +为了更好地描述内存复用问题,我们通过内存生命周期图来辅助理解。如图 :numref:`combine_memory_reuse_and_no_reuse`所示,图中横坐标表示Tensor的生命周期,图中纵坐标表示内存大小。在生命周期内,某一个Tensor将一直占用某块Device内存,直至生命周期结束才会释放相应内存块。通过Tensor生命周期和内存大小可以构造出矩形块,而内存分配要求解的目标是在内存生命周期图中容纳更多的矩形块,问题的约束是矩形块之间无碰撞。图 :numref:`combine_memory_reuse_and_no_reuse`左边是在未使用任何内存复用策略的情况下的内存生命周期图,此时内存同时只能容纳T0、T1、T2、T3四个Tensor。 内存复用策略的求解是一个NP完全的问题。许多深度学习框架通常采用贪心的策略去分配内存,例如采用BestFit算法,每次直接从内存池中选取可以满足条件的最小内存块,然而这种贪心的策略往往会陷入局部最优解,而无法求得全局最优解。为了更好地逼近内存分配策略全局最优解,MindSpore框架提出了一种新的内存分配算法 SOMAS(Safe Optimized Memory Allocation Solver)。SOMAS将计算图并行流与数据依赖进行聚合分析,得到算子间祖先关系,构建张量全局生命周期互斥约束,使用多种启发式算法求解最优的内存静态规划,实现逼近理论极限的内存复用,从而提升支持的内存大小。 -由图:numref:`combine_memory_reuse_and_no_reuse`右边可知,经过SOMAS求解之后,同样的内存大小,可支持的Tensor数量达到了7个。 +由图 :numref:`combine_memory_reuse_and_no_reuse`右边可知,经过SOMAS求解之后,同样的内存大小,可支持的Tensor数量达到了7个。 ### 常见的内存分配优化手段 @@ -64,10 +65,10 @@ Solver)。SOMAS将计算图并行流与数据依赖进行聚合分析,得到 #### In-Place算子 在前面的内存分配流程中,我们会为每个算子的输入和输出都分配不同的内存。然而对很多算子而言,为其分配不同的输入和输出地址,会浪费内存并且影响计算性能。例如优化器算子,其计算的目的就是更新神经网络的权重;例如Python语法中的'+='和'\*='操作符,将计算结果更新到符号左边的变量中;例如'a\[0\]=b'语法,将'a\[0\]'的值更新为'b'。诸如此类计算有一个特点,都是为了更新输入的值。下面以Tensor的'a\[0\]=b'操作为例介绍In-Place的优点。 -图:numref:`inplace-op`左边是非In-Place操作的实现,step1将Tensor +图 :numref:`inplace-op`左边是非In-Place操作的实现,step1将Tensor a拷贝到Tensor a',step2将Tensor b赋值给Tensor a',step3将Tensor a'拷贝到Tensor -a。图:numref:`inplace-op`右边是算子In-Place操作的实现,仅用一个步骤将Tensor +a。图 :numref:`inplace-op`右边是算子In-Place操作的实现,仅用一个步骤将Tensor b拷贝到Tensor a对于的位置上。对比两种实现,可以发现In-Place操作节省了两次拷贝的耗时,并且省去了Tensor a'内存的申请。 diff --git a/chapter_compiler_frontend_and_ir/ad.md b/chapter_compiler_frontend_and_ir/ad.md new file mode 100644 index 0000000..51271f0 --- /dev/null +++ b/chapter_compiler_frontend_and_ir/ad.md @@ -0,0 +1,252 @@ +自动微分 +-------- + +上一节,我们介绍了机器学习框架的中间表示,设计这些中间表示的最核心的目的之一便是服务于自动微分变换。那么什么是自动微分?我们在这一节来详细介绍。 + +### 自动微分的基本概念 + +自动微分(Automatic +Differentiation,AD)是一种对计算机程序进行高效且准确求导的技术,在上个世纪六七十年代就已经被广泛应用于流体力学、天文学、数学金融等领域([@10.5555/1455489])。时至今日,自动微分的实现及其理论仍然是一个活跃的研究领域。随着近些年深度学习在越来越多的机器学习任务上取得领先成果([@lecun2015deep]),自动微分被广泛的应用于机器学习领域。许多机器学习模型使用的优化算法都需要获取模型的导数,因此自动微分技术成为了一些热门的机器学习框架(例如TensorFlow和PyTorch)的核心特性。 + +常见的计算机程序求导的方法可以归纳为以下四种([@2015Automatic]):手工微分(Manual +Differentiation)、数值微分(Numerical +Differentiation)、符号微分(Symbolic +Differentiation)和自动微分(Automatic Differentiation)。 + +(1)手工微分:需手工求解函数导数的表达式,并在程序运行时根据输入的数值直接计算结果。手工微分需根据函数的变化重新推导表达式,工作量大且容易出错。 + +(2)数值微分([@2015Numerical]):数值微分通过差分近似方法完成,其本质是根据导数的定义推导而来。 + +$$f^{'}(x)=\lim_{h \to 0}\frac{f(x+h)-f(x)}{h}$$ + +当$h$充分小时,可以用差分$\frac{f(x+h)-f(x)}{h}$来近似导数结果。而近似的一部分误差,称为截断误差(Truncation +error)。理论上,数值微分中的截断误差与步长$h$有关,$h$越小则截断误差越小,近似程度越高。但实际情况下数值微分的精确度并不会随着$h$的减小而一直减小。这是因为计算机系统对于浮点数运算的精度有限导致另外一种误差的存在,这种误差称为舍入误差(Round-off +Error)。舍入误差会随着$h$变小而逐渐增大。当h较大时,截断误差占主导。而当h较小时,舍入误差占主导。 +在截断误差和舍入误差的共同作用下,数值微分的精度将会在某一个$h$值处达到最小值,并不会无限的减小。因此,虽然数值微分容易实现,但是存在精度误差问题。 + +(3)符号微分([@2003Computer]):利用计算机程序自动地通过如下的数学规则对函数表达式进行递归变换来完成求导。 +$$\frac{d}{dx}(f(x)+g(x))\rightsquigarrow\frac{d}{dx}f(x)+\frac{d}{dx}g(x)$$ + +$$\frac{d}{dx}(f(x)g(x))\rightsquigarrow(\frac{d}{dx}f(x))g(x)+f(x)(\frac{d}{dx}g(x))$$ +符号微分常被应用于现代代数系统工具中,例如Mathematica、Maxima和Maple,以及机器学习框架,如Theano。符号微分虽然消除了手工微分硬编码的缺陷。但因为对表达式进行严格的递归变换和展开,不复用产生的变换结果,很容易产生表达式膨胀(expression +swell([@10.5555/60181.60188]))问题。如图:numref:`symbolic_differentiation`所示,用符号微分计算递归表达式$l_{n+1}=4l_n(1-l_n)$,$l_1=x$的导数表达式,其结果随着迭代次数增加快速膨胀。 + +![符号微分的表达式膨胀问题](../img/ch04/符号微分的表达式膨胀问题.png) +:width:`800px` +:label:`symbolic_differentiation` + +并且符号微分需要表达式被定义成闭合式的(closed-form),不能带有或者严格限制控制流的语句表达,使用符号微分会很大程度上地限制了机器学习框架网络的设计与表达。 + +(4)自动微分([@2000An]):自动微分的思想是将计算机程序中的运算操作分解为一个有限的基本操作集合,且集合中基本操作的求导规则均为已知,在完成每一个基本操作的求导后,使用链式法则将结果组合得到整体程序的求导结果。自动微分是一种介于数值微分和符号微分之间的求导方法,结合了数值微分和符号微分的思想。相比于数值微分,自动微分可以精确地计算函数的导数;相比符号微分,自动微分将程序分解为基本表达式的组合,仅对基本表达式应用符号微分规则,并复用每一个基本表达式的求导结果,从而避免了符号微分中的表达式膨胀问题。而且自动微分可以处理分支、循环和递归等控制流语句。目前的深度学习框架基本都采用自动微分机制进行求导运算,下面我们将重点介绍自动微分机制以及自动微分的实现。 + +### 前向与反向自动微分 + +自动微分根据链式法则的不同组合顺序,可以分为前向模式(Forward +Mode)和反向模式(Reverse +Mode)。对于一个复合函数$y=a(b(c(x)))$,其梯度值$\frac{dy}{dx}$的计算公式为: +$$\frac{dy}{dx}=\frac{dy}{da}\frac{da}{db}\frac{db}{dc}\frac{dc}{dx}$$ +前向模式的自动微分是从输入方向开始计算梯度值的,其计算公式为: +$$\frac{dy}{dx}=(\frac{dy}{da}(\frac{da}{db}(\frac{db}{dc}\frac{dc}{dx})))$$ +反向模式的自动微分是从输出方向开始计算梯度值的,其计算公式为: +$$\frac{dy}{dx}=(((\frac{dy}{da}\frac{da}{db})\frac{db}{dc})\frac{dc}{dx})$$ +我们以下面的函数为例介绍两种模式的计算方式,我们希望计算函数在$(x_1, x_2)=(2,5)$处的导数$\frac{\partial y}{\partial x_1}$: +$$y=f(x_1,x_2)=ln(x_1)+{x_1}{x_2}-sin(x_2)$$ + +该函数对应的计算图如:numref:`example_compute_graph`: + +![示例计算图](../img/ch04/自动微分-示例计算图.svg) +:width:`800px` +:label:`example_compute_graph` + +(1)前向模式 + +![前向模式自动微分示例](../img/ch04/自动微分-前向模式自动微分示例.png) +:width:`800px` +:label:`forward_AD` + +前向模式的计算过程如图:numref:`forward_AD`所示,左侧是源程序分解后得到的基本操作集合,右侧展示了运用链式法则和已知的求导规则,从上至下计算每一个中间变量${\dot{v}_i}=\frac{\partial v_i}{\partial x_1}$,从而计算出最后的变量${\dot{v}_5}=\frac{\partial y}{\partial x_1}$。 + +当我们想要对一个函数求导时,我们想要得到的是该函数的任意一个输出对任意一个输入的偏微分的集合。对于一个带有$n$个独立输入$x_i$和$m$个独立输出$y_i$的函数$f:{\mathbf{R}^n}\to \mathbf{R}^m$,该函数的求导结果可以构成如下的雅克比矩阵(Jacobian +Matrix): $$\mathbf{J}_{f}= +\begin{bmatrix} + \frac{\partial y_1}{\partial x_1} & \cdots & \frac{\partial y_1}{\partial x_n} \\ + \vdots & \ddots & \vdots \\ + \frac{\partial y_m}{\partial x_1} & \cdots & \frac{\partial y_m}{\partial x_n} +\end{bmatrix}$$ + +前向模式中每次计算函数$f$的所有输出对某一个输入的偏微分,也就是雅克比矩阵的某一列,如下面的向量所示。因此,通过n次前向模式的自动微分就可以得到整个雅克比矩阵。 +$$\begin{bmatrix} + \frac{\partial y_1}{\partial x_i} \\ + \vdots \\ + \frac{\partial y_m}{\partial x_i} +\end{bmatrix}$$ + +前向模式通过计算雅克比向量积(Jacobian-vector +products)的方式来计算这一列的结果。我们初始化$\dot{\mathbf{x}}=\mathbf{r}$。基本操作的求导规则是已经定义好的,代表着基本操作的雅可比矩阵是已知量。在此基础上,我们应用链式法则从$f$的输入到输出传播求导结果,从而得到输入网络的雅克比矩阵中的一列。 +$$\mathbf{J}_{f}\mathbf{r}= +\begin{bmatrix} + \frac{\partial y_1}{\partial x_1} & \cdots & \frac{\partial y_1}{\partial x_n} \\ + \vdots & \ddots & \vdots \\ + \frac{\partial y_m}{\partial x_1} & \cdots & \frac{\partial y_m}{\partial x_n} +\end{bmatrix} +\begin{bmatrix} + r_1 \\ + \vdots \\ + r_n +\end{bmatrix}$$ + +(2)反向模式 + +![反向模式自动微分示例](../img/ch04/自动微分-反向模式自动微分示例.png) +:width:`800px` +:label:`backward_AD` + +反向模式的计算过程如上图:numref:`backward_AD`所示,左侧是源程序分解后得到的基本操作集合,右侧展示了运用链式法则和已知的求导规则,从$\bar{v}_5=\bar{y}=\frac{\partial y}{\partial y}=1$开始, +由下至上地计算每一个中间变量${\bar{v}_i}=\frac{\partial y_j}{\partial v_i}$,从而计算出最后的变量${\bar{x}_1}=\frac{\partial y}{\partial x_1}$和${\bar{x}_2}=\frac{\partial y}{\partial x_2}$。 + +反向模式每次计算的是函数$f$的某一个输出对任一输入的偏微分,也就是雅克比矩阵的某一行,如下面的向量所示。因此通过运行m次反向模式自动微分,我们就可以得到整个雅克比矩阵。 +$$\begin{bmatrix} + \frac{\partial y_j}{\partial x_1} & \cdots & \frac{\partial y_j}{\partial x_n} +\end{bmatrix}$$ + +类似地,我们可以通过计算向量雅克比积(Vector-jacobian +products)的方式来计算雅克比矩阵的一行。我们初始化$\bar{\mathbf{y}}=\mathbf{r}$,在已知基本操作的求导规则的前提下,应用链式法则从$f$的输出到输入传播求导结果,从而最后得到雅克比矩阵中的一行。 +$$\mathbf{r}^{T}\mathbf{J}_{f}= +\begin{bmatrix} + r_1 & \cdots & r_m +\end{bmatrix} +\begin{bmatrix} + \frac{\partial y_1}{\partial x_1} & \cdots & \frac{\partial y_1}{\partial x_n} \\ + \vdots & \ddots & \vdots \\ + \frac{\partial y_m}{\partial x_1} & \cdots & \frac{\partial y_m}{\partial x_n} +\end{bmatrix}$$ + +在求解函数$f$的雅克比矩阵时,前向模式的迭代次数与雅克比矩阵的列数相关,而反向模式的迭代次数则与雅克比矩阵的行数相关。因此,在函数输出个数远远大于输入个数时$(f:{\mathbf{R}^n}\to \mathbf{R}^m, n << m)$,前向模式效率更高;反之,在函数输入个数远远大于输出个数时$(f:{\mathbf{R}^n}\to \mathbf{R}^m, n >> m)$,反向模式效率更高。在极端情况下的函数$f:{\mathbf{R}^n}\to \mathbf{R}$,只需要应用一次反向模式就已经能够把所有输出对输入的导数$(\frac{\partial y}{\partial x_1},\cdots,\frac{\partial y}{\partial n})$都计算出来,而前向模式则需要执行n次。这种计算一个标量值的输出关于大量参数输入的梯度的场景恰好是机器学习实践中最常见的一种计算场景,这使得反向模式的自动微分成为反向传播算法使用的核心技术之一。 + +但是反向模式也存在一定的缺陷。在源程序分解为一系列基本操作后,前向模式由于求导顺序与基本操作的执行顺序一致,输入值可以在执行基本操作的过程中同步获得。而在反向模式中,由于求导顺序与源程序的执行顺序是相反的,计算过程需要分为两个阶段,第一个阶段先执行源程序,且将源程序的中间结果保存起来,在第二阶段才把中间结果取出来去计算导数。因此反向模式会有额外的内存消耗。业界也一直在研究反向模式的内存占用优化方法,例如检查点策略(checkpointing +strategies)和数据流分析(data-flow +analysis)([@2006The];[@2017Divide])。 + +### 自动微分的实现 + +上一节我们介绍了自动微分的基本概念,可以总结为将程序分解为一系列微分规则已知的基本操作,然后运用链式法则将它们的微分结果组合起来得到程序的微分结果。而在机器学习的应用中,因为输入的数量远远大于输出的数量,所以反向模式的自动微分更受青睐。虽然自动微分的基本思想是明确的,但是具体的实现方法也分为几类([@2015Automatic]),大体可以划分为基本表达式法(Elemental +Libraries)、操作符重载法(Operator +Overloading,OO)和代码变换法(Source Code Transformation,ST)。 + +(1)基本表达式法:封装大多数的基本表达式及对应的微分表达式,通过库函数的方式提供给用户,用户在写代码时,需要手工分解程序为一系列的基本表达式,然后使用这些库函数去替换这些基本表达式。以程序$a=(x+y)/z$为例,用户需要手工地把这个程序分解为: +```python +t = x + y +a = t / z +``` +然后使用自动微分的库函数去替换分解出来的基本表达式: +```python +// 参数为变量x, y, t和对应的导数变量dx, dy, dt +call ADAdd(x, dx, y, dy, t, dt) +// 参数为变量t, z, a和对应的导数变量dt, dz, da +call ADDiv(t, dt, z, dz, a, da) +``` +库函数ADAdd和ADDiv运用链式法则,分别定义了Add和Div的微分表达式。 +```python + def ADAdd(x, dx, y, dy, z, dz): + z = x + y + dz = dy + dx + + def ADDiv(x, dx, y, dy, z, dz): + z = x / y + dz = dx / y + (x / (y * y)) * dy +``` +基本表达式法的优缺点显而易见,优点是实现简单直接,可为任意语言快速实现微分的库函数;而缺点是增加了用户的工作量,用户必须先手工分解程序为一些基本表达式,才能使用这些库函数进行编程,无法方便地使用语言原生的表达式。 + +(2)操作符重载法(Operator Overlading, +OO):依赖于现代编程语言的多态特性,使用操作符重载对编程语言中的基本操作语义进行重定义,封装其微分规则。每个基本操作类型及其输入关系,在程序运行时会被记录在一个所谓的"tape"的数据结构里面,最后,这些"tape"会形成一个跟踪轨迹(trace),我们就可以使用链式法则沿着轨迹正向或者反向地将基本操作组成起来进行微分。以自动微分库AutoDiff为例,对编程语言的基本运算操作符进行了重载: +```C++ + namespace AutoDiff + { + public abstract class Term + { + // 重载操作符 `+`,`*` 和 `/`,调用这些操作符时,会通过其中的 + // TermBuilder 将操作的类型、输入输出信息等记录至 tape 中 + public static Term operator+(Term left, Term right) + { + return TermBuilder.Sum(left, right); + } + public static Term operator*(Term left, Term right) + { + return TermBuilder.Product(left, right); + } + public static Term operator/(Term numerator, Term denominator) + { + return TermBuilder.Product(numerator, TermBuilder.Power(denominator, -1)); + } + } + + // Tape 数据结构中的基本元素,主要包含: + // 1) 操作的运算结果 + // 2) 操作的运算结果对应的导数结果 + // 3) 操作的输入 + // 除此外还通过函数 Eval 和 Diff 定义了该运算操作的计算规则和微分规则 + internal abstract class TapeElement + { + public double Value; + public double Adjoint; + public InputEdges Inputs; + + public abstract void Eval(); + public abstract void Diff(); + } + } +``` +OO对程序的运行跟踪经过了函数调用和控制流,因此实现起来也是简单直接。而缺点是需要在程序运行时进行跟踪,特别在反向模式上还需要沿着轨迹反向地执行微分,所以会造成性能上的损耗,尤其对于本来运行就很快的基本操作。并且因为其运行时跟踪程序的特性,该方法不允许在运行前做编译时刻的图优化,控制流也需要根据运行时的信息来展开。Pytorch的自动微分框架使用了该方法。 + +(3)代码变换法(Source +Transformation,ST):提供对编程语言的扩展,分析程序的源码或抽象语法树(AST),将程序自动地分解为一系列可微分的基本操作,而这些基本操作的微分规则已预定义好,最后使用链式法则对基本操作的微分表达式进行组合生成新的程序表达来完成微分。TensorFlow,MindSpore等机器学习框架都采用了该方式。 + +不同于OO在编程语言内部操作,ST需要语法分析器(parser)和操作中间表示的工具。除此以外,ST需要定义对函数调用和控制流语句(如循环和条件等)的转换规则。其优势在于对每一个程序,自动微分的转换只做一次,因此不会造成运行时的额外性能损耗。而且,因为整个微分程序在编译时就能获得,编译器可以对微分程序进行进一步的编译优化。但ST实现起来更加复杂,需要扩展语言的预处理器、编译器或解释器,且需要支持更多的数据类型和操作,需要更强的类型检查系统。另外,虽然ST不需要在运行时做自动微分的转换,但是对于反向模式,在反向部分执行时,仍然需要确保前向执行的一部分中间变量可以被获取到,有两种方式可以解决该问题([@van2018Automatic]): + +(1)基于Tape的方式。该方式使用一个全局的"tape"去确保中间变量可以被获取到。原始函数被扩展为在前向部分执行时把中间变量写入到tape中的函数,在程序执行反向部分时会从tape中读取这些中间变量。除了存储中间变量外,OO中的tape还会存储执行的操作类型。然而因为tape是一个在运行时构造的数据结构,所以需要添加一些定制化的编译器优化方法。且为了支持高阶微分,对于tape的读写都需要是可微分的。而大多数基于tape的工具都没有实现对tape的读写操作的微分,因此它们都不支持多次嵌套执行反向模式的自动微分(reverse-over-reverse)。机器学习框架Tangent采用了该方式。 + +(2)基于闭包(closure)的方式。基于闭包的方式可以解决基于tape方式的缺陷。在函数式编程里,闭包可以捕获到语句的执行环境并识别到中间变量的非局部使用。因为这些它们是闭包里的自由变量,所以不需要再去定制化编译器优化方法。 + +MindSpore是使用基于闭包的代码变换法来实现的自动微分的。这需要一个定制的中间表示。MindIR的具体设计,在上一节中已经介绍过,这里不再赘述。 + +MindSpore的自动微分,使用基于闭包的代码变换法实现,转换程序根据正向部分的计算,构造了一个闭包的调用链。这些闭包包含了计算导数的代码以及从正向部分拿到的中间变量。程序中的每个函数调用,都会得到转换并且额外返回一个叫做"bprop"的函数,$bprop$根据给定的关于输出的导数,计算出关于输入的导数。由于每个基本操作的$bprop$是已知的,我们可以容易地反向构造出用户定义的整个函数的$bprop$。为了支持reverse-over-reverse调用去计算高阶导数,我们需要确保可以在已转换好的程序中再进行转换,这需要有处理函数自由变量(函数外定义的变量)的能力。为了达到这个目的,每个$bprop$除了关于原始函数输入的偏导数以外,还会返回一系列关于自由变量的偏导数,闭包里面的$bprop$负责把每个偏导数解开,将其分别累加贡献到各自的自由变量上。且闭包也是一种函数,可以作为其他闭包的输入。因此,MindSpore自动微分的算法设计可以总结为: + +(1)应用链式求导法则,对每个函数(算子或子图)定义一个反向传播函数$bprop: dout->(df, dinputs)$,这里$df$表示函数对自由变量的导数,$dinputs$表示函数对输入的导数。 + +(2)应用全微分法则,将($df$, $dinputs$)累加到对应的变量上。 + +涉及控制流语句时,因为MindIR实现了分支、循环和闭包等操作的函数式表达,我们对这些操作应用上述法则进行组合,即可完成微分。定义运算符K求解导数,MindSpore的自动微分算法可以简单表达如下: +```C++ + // func和inputs分别表示函数及其输入,dout为关于输出的梯度 + v = (func, inputs) + F(v): { + (result, bprop) = K(func)(inputs) + df, dinputs = bprop(dout) + v.df += df + v.dinputs += dinputs + } +``` +MindSpore解析器模块首先根据Python的AST生成MindIR,再经过特化模块使得中间表示中的算子可识别,然后调用自动微分模块。自动微分模块的入口函数如下所示: +```C++ + function Grad { + Init(); + MapObject(); // 实现Parameter/Primitive/FuncGraph/FreeVariable对象的映射 + MapMorphism(); // 实现CNode的映射 + Finish(); + Return GetKGraph(); // 获取梯度函数计算图 + } +``` +Grad函数先通过MapObject实现图上自由变量、Parameter和ValueNode(Primitive或FuncGraph)等节点到$fprop$的映射。$fprop$是$(forward\_result, bprop)$形式的梯度函数对象。$forward\_result$是前向计算图的输出节点,$bprop$是以$fprop$的闭包对象形式生成的梯度函数,它只有$dout$一个入参,其余的输入则是引用的$fprop$的输入和输出。其中对于ValueNode\类型的$bprop$,通过解析Python层预先注册的$get\_bprop$函数的得到,如下所示。对于ValueNode\类型的节点,则递归求出它的梯度函数对象。 +```python + @bprop_getters.register(P.ReLU) + def get_bprop_relu(self): + """Grad definition for `ReLU` operation.""" + input_grad = G.ReluGrad() + + def bprop(x, out, dout): + dx = input_grad(dout, out) + return (dx,) + + return bprop +``` +随后,MapMorphism函数从原函数的输出节点开始实现对CNode的映射,并建立起节点间的反向传播连接,实现梯度累加,最后返回原函数的梯度函数计算图。 diff --git a/chapter_compiler_frontend_and_ir/common_frontend_optimization_pass.md b/chapter_compiler_frontend_and_ir/common_frontend_optimization_pass.md new file mode 100644 index 0000000..344cb95 --- /dev/null +++ b/chapter_compiler_frontend_and_ir/common_frontend_optimization_pass.md @@ -0,0 +1,44 @@ +常见前端编译优化方法 +-------------------- + +和传统编译器相同,机器学习编译器也会进行编译优化。编译优化意在解决编译生成的中间表示的低效性,使得代码的长度变短,编译与运行的时间减少,执行期间处理器的能耗变低。编译优化可以分为与硬件无关的优化和与硬件相关的编译优化。因为前端是不感知具体后端硬件的,因此前端执行的全部都是与硬件无关的编译优化。 + +### 前端编译优化简介 + +大多数编译优化器会由一系列的"趟"(Pass)来组成。每个"趟"以中间表示为输入,又以新生成的中间表示为输出。一个"趟"还可以由几个小的"趟"所组成。一个"趟"可以运行一次,也可以运行多次。 + +在编译优化中,优化操作的选择以及顺序对于编译的整体具有非常关键的作用。优化操作的选择决定了优化器能够感知中间表示中的哪些低效性,也决定了编译器将要如何去重写中间表示以消除这种低效性。优化操作的顺序决定了各趟操作的执行顺序。编译器可以根据具体需要运行不同的编译优化操作。也可以根据编译优化级别来调整优化的次数,种类以及顺序。 + +![编译优化的"趟"结构](../img/ch04/编译优化-pass结构.svg) +:width:`800px` +:label:`pass_structure` + +### 常见编译优化方法介绍及实现 + +前端编译优化的方法有很多,机器学习框架也有很多不同于传统编译器的优化方式。在本小节当中,我们会介绍三种常见且通用的前端编译优化方法。 + +1\. 无用与不可达代码消除 + +如图 :numref:`pass_useless_code_elimination`所示。无用代码是指输出结果没有被任何其他代码所使用的代码。不可达代码是指没有有效的控制流路径包含该代码。删除无用或不可达的代码可以使得中间表示更小,提高程序的编译与执行速度。无用与不可达代码一方面有可能来自于程序编写者的编写失误,也有可能是其他编译优化所产生的结果。 + +![无用代码消除](../img/ch04/编译优化-无用代码消除.svg) +:width:`600px` +:label:`pass_useless_code_elimination` + +2\. 常量传播、常量折叠 + +常量传播:如图 :numref:`pass_constant_broadcast`所示,如果某些量为已知值的常量,那么可以在编译时刻将使用这些量的地方进行替换。 + +常量折叠:如图 :numref:`pass_constant_broadcast`所示,多个量进行计算时,如果能够在编译时刻直接计算出其结果,那么变量将由常量替换。 + +![常量传播与常量折叠](../img/ch04/编译优化-常量传播与常量折叠.svg) +:width:`600px` +:label:`pass_constant_broadcast` + +3\. 公共子表达式消除 + +如图 :numref:`pass_CSE`所示,如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E就成为了公共子表达式。对于这种表达式,没有必要花时间再对它进行计算,只需要直接用前面计算过的表达式结果代替E就可以了。 + +![公共子表达式消除](../img/ch04/编译优化-公共子表达式消除.svg) +:width:`600px` +:label:`pass_CSE` diff --git a/chapter_compiler_frontend_and_ir/index.md b/chapter_compiler_frontend_and_ir/index.md index 4f1fb29..2f67845 100644 --- a/chapter_compiler_frontend_and_ir/index.md +++ b/chapter_compiler_frontend_and_ir/index.md @@ -1,10 +1,31 @@ -# 编译器前端和IR +# 编译器前端 +在上一章节中,我们详细讨论了计算图的生成和调度,在进阶部分的介绍中简单介绍了深度学习编译器的作用。定义深度学习模型、计算图使用系统为用户提供的高级编程API,我们将用户使用高级编程API编写的程序称为源程序,将与硬件相关的程序称为目标程序,深度学习编译器需要理解输入的源程序并将其映射到目标机。为了实现这两项任务,编译器的设计被分解为两个主要部分:前端和后端。传统编译器的前端专注于理解源程序,后端则专注于将功能映射到目标机。为了将前后端相连接,我们需要一种结构来表示转换后的源代码,这就是中间表示(Intermediate +Representation, IR)。 +图 :numref:`compiler_frontend_structure`展示了机器学习编译器的前端的流程。其中,对源程序的解析过程与传统编译器是大致相同的,本章节不对这部分进行更细致的讨论。机器学习框架的编译器前端的独特之处主要在于自动微分功能的支持。为了满足自动微分功能带来的新需求,机器学习框架需要在传统中间表示的基础上设计新的中间表示结构。因此,本章节的介绍重点会放在中间表示以及自动微分这两个部分。最后,我们会简要探讨类型系统,静态分析和前端优化等编译器基础概念。 + +![编译器前端基础结构](../img/ch04/编译器前端基础架构.svg) +:width:`1000px` +:label:`compiler_frontend_structure` + +本章的学习目标包括: + +- 理解中间表示的基础概念,特点和实现方法 + +- 理解自动微分的基础概念,特点和实现方法 + +- 了解类型系统和静态推导的基本原理 + +- 了解编译器优化的主要手段和常见优化方法 ```toc :maxdepth: 2 -:numbered: - -``` \ No newline at end of file +overview_of_frontend +intermediate_representation +ad +type_system_and_static_analysis +common_frontend_optimization_pass +summary +``` diff --git a/chapter_compiler_frontend_and_ir/intermediate_representation.md b/chapter_compiler_frontend_and_ir/intermediate_representation.md new file mode 100644 index 0000000..7b0ff2b --- /dev/null +++ b/chapter_compiler_frontend_and_ir/intermediate_representation.md @@ -0,0 +1,187 @@ +中间表示 +-------- + +中间表示作为编译器的核心数据结构之一,无论是在传统编译器中,还是在机器学习框架中, +都有着极其重要的地位。本章节我们会先介绍中间表示的基本概念以及传统编译器的中间表示类型。在此基础上,我们会探讨针对机器学习框架,中间表示的设计所面临的新的需求和挑战。最后,我们会介绍现有机器学习框架的中间表示的种类及其实现。 + +### 中间表示的基本概念 + +中间表示(IR),是编译器用于表示源代码的数据结构或代码,是程序编译过程中介于源语言和目标语言之间的程序表示。几乎所有的编译器都需要某种形式的中间表示,来对被分析、转换和优化的代码进行建模。在编译过程中,中间表示必须具备足够的表达力,在不丢失信息的情况下准确表达源代码,并且充分考虑从源代码到目标代码编译的完备性、编译优化的易用性和性能。 + +引入中间表示后,中间表示既能面向多个前端,表达多种源程序语言,又能对接多个后端,连接不同目标机器,如图 :numref:`intermediate_representation`所示。在此基础上,编译流程就可以在前后端直接增加更多的优化流程,这些优化流程以现有IR为输入,又以新生成的IR为输出,被称为优化器。优化器负责分析并改进中间表示,极大程度的提高了编译流程的可拓展性,也降低了优化流程对前端和后端的破坏。 + +![中间表示](../img/ch04/中间表示-中间表示结构.svg) +:width:`800px` +:label:`intermediate_representation` + +随着编译器技术的不断演进,中间表示主要经历了三个发展阶段。在早期阶段,中间表示是封闭在编译器内部的,供编译器编写者使用。在中期阶段,随着编译器的开源,中间表示逐步开源公开,主要供编译器设计者、分析工具设计者使用。现阶段,中间表示朝着软件生态构建的方向发展,旨在构建统一的中间表示。 + +### 中间表示的种类 + +上一节介绍了中间表示的基本概念,初步阐述了中间表示的重要作用和发展历程。接下来从组织结构的角度出发,介绍通用编译器的中间表示的类型以及各自特点([@2007Engineering]),如下表所示。中间表示组织结构的设计,对编译阶段的分析优化、代码生成等有着重要影响。编译器的设计需求不同,采用的中间表示组织结构也有所不同。 + +::: {#tab:ch04/ch04-categorize} + 组织结构 特点 举例 + -------------- ---------------------- ---------------------------------- + Linear IR 基于线性代码 堆栈机代码、三地址代码 + Graphical IR 基于图 抽象语法树、有向无环图、控制流图 + Hybrid IR 基于图与线性代码混合 LLVM IR + + : 中间表示的分类 +::: + +1\) 线性中间表示 + +线性中间表示类似抽象机的汇编代码,将被编译代码表示为操作的有序序列,对操作序列规定了一种清晰且实用的顺序。由于大多数处理器采用线性的汇编语言,线性中间表示广泛应用于编译器设计。 + +常用线性中间表示有堆栈机代码(Stack-Machine Code)和三地址代码(Three +Address Code) ([@2007Compilers]) +。堆栈机代码是一种单地址代码,提供了简单紧凑的表示。堆栈机代码的指令通常只有一个操作码,其操作数存在一个栈中。大多数操作指令从栈获得操作数,并将其结果推入栈中。三地址代码,简称为3AC,模拟了现代RISC机器的指令格式。它通过一组四元组实现,每个四元组包括一个运算符和三个地址(两个操作数、一个目标)。对于表达式a-b\*5,堆栈机代码和三地址代码如图 :numref:`linear_ir`所示。 + +![堆栈机代码和三地址代码](../img/ch04/中间表示-线性中间表示.svg) +:width:`800px` +:label:`linear_ir` + +2、图中间表示 + +图中间表示将编译过程的信息保存在图中,算法通过图中的对象如节点、边、列表、树等来表述。虽然所有的图中间表示都包含节点和边,但在抽象层次、图结构等方面各有不同。常见的图中间表示包括抽象语法树(Abstract +Syntax Tree,AST)、有向无环图(Directed Acyclic +Graph,DAG)、控制流图(Control-Flow Graph,CFG)等。 + +AST抽象语法树采用树型中间表示的形式,是一种接近源代码层次的表示。对于表达式$a*5+a*5*b$,其AST表示如图 :numref:`AST_DAG`所示。可以看到,AST形式包含$a*5$的两个不同副本,存在冗余。在AST的基础上,DAG提供了简化的表达形式,一个节点可以有多个父节点,相同子树可以重用。如果编译器能够证明$a$的值没有改变,则DAG可以重用子树,降低求值过程的代价。 + +![AST图和DAG图](../img/ch04/中间表示-ASTDAG.svg) +:width:`600px` +:label:`AST_DAG` + +3、混合中间表示 + +混合中间表示是线性中间表示和图中间表示的结合,这里以LLVM IR +([@2004LLVM]) 为例进行说明。LLVM(Low Level Virtual +Machine)是2000年提出的开源编译器框架项目,旨在为不同的前端后端提供统一的中间表示。LLVM +IR使用线性中间表示表示基本块,使用图中间表示表示这些块之间的控制流,如图 :numref:`LLVM_IR`所示。基本块中,每条指令以静态单赋值(Static +Single Assignment, SSA) ([@Richard1995A]) +形式呈现,这些指令构成一个指令线性列表。SSA形式要求每个变量只赋值一次,并且每个变量在使用之前定义。控制流图中,每个节点为一个基本块,基本块之间通过边实现控制转移。 + +![LLVM IR](../img/ch04/中间表示-LLVMIR.svg) +:width:`600px` +:label:`LLVM_IR` + +### 机器学习框架的中间表示 + +上一节介绍了中间表示的类型,并举例说明了常见的中间表示形式。传统中间表示如LLVM +IR,能够很好地满足通用编译器的基本功能需求,包括类型系统、控制流和数据流分析等。然而,它们偏向机器语言,难以满足机器学习框架编译器的中间表示的需求。 + +在设计机器学习框架的中间表示时,需要充分考虑以下因素: + +1\) +张量表达。机器学习框架主要处理张量数据,因此正确处理张量数据类型是机器学习框架中间表示的基本要求。 + +2\) +自动微分。自动微分是指对网络模型的自动求导,通过梯度指导对网络权重的优化。主流机器学习框架都提供了自动微分的功能,在设计中间表示时需要考虑自动微分实现的简洁性、性能以及高阶微分的扩展能力。 + +3\) +计算图模式。主流机器学习框架如TensorFlow、PyTorch、MindSpore等都提供了静态图和动态图两种计算图模式,静态计算图模式先创建定义计算图,再显式执行,有利于对计算图进行优化,高效但不灵活。动态计算图模式则是每使用一个算子后,该算子会在计算图中立即执行得到结果,使用灵活、便于调试,但运行速度较低。机器学习框架的中间表示设计同时支持静态图和动态图,可以针对待解决的任务需求,选择合适的模式构建算法模型。 + +4\) +支持高阶函数和闭包([@2010C])。高阶函数和闭包是函数式编程的重要特性,高阶函数是指使用其它函数作为参数、或者返回一个函数作为结果的函数,闭包是指代码块和作用域环境的结合,可以在另一个作用域中调用一个函数的内部函数,并访问到该函数作用域中的成员。支持高阶函数和闭包,可以抽象通用问题、减少重复代码、提升框架表达的灵活性和简洁性。 + +5\) +编译优化。机器学习框架的编译优化主要包括硬件无关的优化、硬件相关的优化、部署推理相关的优化等,这些优化都依赖于中间表示的实现。 + +6\) JIT(Just In +Time)能力。机器学习框架进行编译执行加速时,经常用到JIT即时编译。JIT编译优化将会对中间表示中的数据流图的可优化部分实施优化,包括循环展开、融合、内联等。中间表示设计是否合理,将会影响机器学习框架的JIT编译性能和程序的运行能力。 + +针对上述需求,机器学习框架的开发者在传统中间表示的设计基础上不断扩展,提出了很多适用于机器学习框架的中间表示。接下来介绍一些主流机器学习框架的中间表示。 + +1、PyTorch + +PyTorch框架是一个基于动态计算图机制的机器学习框架,以Python优先,具有很强的易用性和灵活性,方便用户编写和调试网络代码。为了保存和加载网络模型,PyTorch框架提供了TorchScript方法,用于创建可序列化和可优化模型。TorchScript +IR作为PyTorch模型的中间表示,通过JIT即时编译的形式,将Python代码转换成目标模型文件。任何TorchScript程序都可以在Python进程中保存,并加载到没有Python依赖的进程中。 + +PyTorch框架采用命令式编程方式,其TorchScript +IR以基于SSA的线性IR为基本组成形式,并通过JIT即时编译的Tracing和Scripting两种方法将Python代码转换成TorchScript +IR。图 :numref:`TorchScript_IR`给出了Python示例代码及其TorchScript +IR。 + +![Python代码及输出的TorchScript IR](../img/ch04/中间表示-torchscript.png) +:width:`800px` +:label:`TorchScript_IR` + + +TorchScript是PyTorch的JIT实现,支持使用Python训练模型,然后通过JIT转换为语言无关的模块,从而提升模型部署能力,提高编译性能。同时,TorchScript +IR显著改善了Pytorch框架的模型可视化效果。 + +2、TensorFlow + +与PyTorch框架的动态图机制不同,TensorFlow机器学习框架因为其静态图机制而被人熟知。关于静态图和动态图的介绍,请参考第3.3章节。 + +TensorFlow框架同时支持静态图和动态图,是一个基于数据流编程的机器学习框架,使用数据流图作为数据结构进行各种数值计算。为了适配不同的硬件平台,基于静态计算图,TensorFlow采用了多种IR设计,其编译生态系统如图 :numref:`MLIR`所示。蓝色部分是基于图的中间表示,绿色部分是基于SSA的中间表示,各层级在结构和抽象层级上存在较大的差距,转换开销大,而且同一层级的中间表示优化是相互独立的,不利于协同优化。 + +![TensorFlow MLIR](../img/ch04/中间表示-MLIR.svg) +:width:`600px` +:label:`MLIR` + +针对这个问题,TensorFlow团队提出了MLIR(Multi-Level Intermediate +Represent,多级中间表示) +([@2020MLIR]),允许使用TensorFlow和其它机器学习库的项目编译更有效的代码,从而最大程度地利用基础硬件。MLIR是用于现代优化编译器的灵活基础架构,旨在定义一个通用的中间表示,在统一的基础架构中支持多种不同的需求。MLIR采用混合中间表示,允许在同一编译单元中结合多个层级的抽象来表示、分析和转换计算图,利用其模块化、可扩展的特点,解决了各种中间表示之间转换效率和可迁移性不高的问题,从而适配多种硬件平台。 + +3、Jax + +Jax机器学习框架同时支持静态图和动态图,其中间表示采用Jaxpr(JAX Program +Representation) IR。Jaxpr +IR是一种强类型、纯函数的中间表示,其输入、输出都带有类型信息,函数输出只依赖输入,不依赖全局变量。 + +![ANF文法与Jaxpr IR](../img/ch04/中间表示-Jaxpr.png) +:width:`800px` +:label:`Jaxpr` + +Jaxpr IR的表达采用ANF(A-norm +Form)函数式表达形式,如图 :numref:`Jaxpr`所示。ANF形式将表达式划分为两类:原子表达式(aexp)和复合表达式(cexp)。原子表达式用于表示常数、变量、原语、匿名函数,复合表达式由多个原子表达式组成,可看作一个匿名函数或原语函数调用,组合的第一个输入是调用的函数,其余输入是调用的参数。 + +Jax框架结合了Autograd 和 JIT,基于Jaxpr +IR,支持循环、分支、递归、闭包函数求导以及三阶求导,并且支持自动微分的反向传播和前向传播。 + +4、MindSpore + +与PyTorch、TensorFlow、Jax框架相同,MindSpore机器学习框架同时支持静态图和动态图。MindSpore框架采用的是一种基于图表示的函数式中间表示,即MindIR,全称MindSpore +IR。MindIR通过统一的中间表示,定义了网络的逻辑结构和算子的属性,能够消除不同后端的模型差异,连接不同的目标机器。 + +MindIR最核心的目的是服务于自动微分变换,而自动微分采用的是基于函数式编程框架的变换方法,因此MindIR采用了接近于ANF函数式的语义。MindIR具有以下特点: + +(1)基于图的(Graph +based)。与TensorFlow类似,程序使用图来表示,使其容易去做优化。但跟TensorFlow不一样的是,在MindSpore中,函数是"一等公民"。函数可以被递归调用,也可以被当做参数传到其他的函数中,或者从其他函数中返回,使得MindSpore可以表达一系列的控制流结构。 + +(2)纯函数的(Purely functional)。 + +纯函数是指函数的结果只依赖函数的参数。若函数依赖或影响外部的状态,比如,函数会修改外部全局变量,或者函数的结果依赖全局变量的值,则称函数具有副作用([@spuler1994compiler])。若使用了带有副作用的函数,代码的执行顺序必须得到严格的保证,否则可能会得到错误的结果,比如对全局变量的先写后读变成了先读后写。同时,副作用的存在也会影响自动微分,因为反向部分需要从前向部分获取中间变量,需要确保该中间变量的正确。因此需要保证自动微分的函数是纯函数。 + +由于Python语言具有高度动态性的特点,纯函数式编程对用户使用上有一些编程限制。有些机器学习框架的自动微分功能只支持对纯函数求导,且要求用户自行保证这一点。如果用户代码中写了带有副作用的函数,那么求导的结果可能会不符合预期。MindIR支持副作用的表达,能够将副作用的表达转换为纯函数的表达,从而在保持ANF函数式语义不变的同时,确保执行顺序的正确性,从而实现自由度更高的自动微分。 + +(3)支持闭包表示的(Closure +representation)。反向模式的自动微分,需要存储基本操作的中间结果到闭包中,然后再去进行组合连接。所以有一个自然的闭包表示尤为重要。闭包是指代码块和作用域环境的结合,在MindIR中,代码块是以函数图呈现的,而作用域环境可以理解为该函数被调用时的上下文环境。 + +(4)强类型的(Strongly +typed)。每个节点需要有一个具体的类型,这个对于性能最大化很重要。在机器学习应用中,因为算子可能很耗费时间,所以越早捕获错误越好。因为需要支持函数调用和高阶函数,相比于TensorFlow的数据流图,MindIR的类型和形状推导更加复杂且强大。 + +在结合MindSpore框架的自身特点后,MindIR的定义如图 :numref:`MindIR`所示。 + +![MindIR文法。MindIR中的ANode对应于ANF的原子表达式,ValueNode用于表示常数值,ParameterNode用于表示函数的形参,CNode则对应于ANF的复合表达式,表示函数调用](../img/ch04/中间表示-MindIR.svg) +:width:`800px` +:label:`MindIR` + +接下来我们通过图 :numref:`MindIR_example`中的一段程序作为示例,来进一步分析MindIR。 + +![MindIR的ANF表达](../img/ch04/中间表示-MindIR示例.PNG) +:width:`600px` +:label:`MindIR_example` + +在ANF中,每个表达式都用let表达式绑定为一个变量,通过对变量的引用来表示对表达式输出的依赖,而在MindIR中,每个表达式都绑定为一个节点,通过节点与节点之间的有向边表示依赖关系。其函数图表示如图 :numref:`MindIR_graph`所示。 + +![MindIR的函数图表示](../img/ch04/中间表示-MindIR图.png) +:width:`800px` +:label:`MindIR_graph` + +MindIR同时支持静态计算图和动态计算图的构建方式,更好地兼顾了灵活性与高性能。相比传统计算图,MindIR不仅可以表达算子之间的数据依赖,还可以表达丰富的函数式语义,具备更自然的自动微分实现方式。MindIR原生支持闭包,并且支持高阶函数的表达。在处理控制流时,MindIR将控制流转换为高阶函数的数据流,不仅支持数据流的自动微分,还支持条件跳转、循环和递归等控制流的自动微分,从而提升MindSpore的自动微分能力。 + +在JIT即时编译方面,MindIR采用了基于图表示的形式,将控制流和数据流合一,支持更高效的JIT优化。在编译优化方面,MindIR引入优化器对计算图进行优化,采用前端-优化器-后端的三段式表达形式,支持硬件无关的优化(如类型推导、表达式化简等)、硬件相关的优化(如自动并行、内存优化、图算融合、流水线执行等)以及部署推理相关的优化(如量化、剪枝等),显著提升了MindSpore的编译执行能力。 diff --git a/chapter_compiler_frontend_and_ir/overview_of_frontend.md b/chapter_compiler_frontend_and_ir/overview_of_frontend.md new file mode 100644 index 0000000..e0a02be --- /dev/null +++ b/chapter_compiler_frontend_and_ir/overview_of_frontend.md @@ -0,0 +1,23 @@ +概述 +---- + +本章节将讨论重点放在中间表示与自动微分章节。自动微分作为机器学习框架的编译器的独有功能,其实现需要满足其需求的中间表示的支持。在讨论完这两部分后,我们会简要介绍类型系统系统,静态分析和前端编译优化等编译器基础概念。 + +### 中间表示 + +中间表示是编译器用于表示源代码的数据结构或代码,是程序编译过程中介于源语言和目标语言之间的程序表示。传统机器学习框架的中间表示分为三大类,分别是线性中间表示,图中间表示以及混合中间表示。然而,传统编译器的中间表示难以完全满足机器学习框架对于中间表示的一系列需求。因此,机器学习框架的开发者在传统中间表示的设计基础上不断扩展,提出了很多适用于机器学习框架的中间表示。 + +### 自动微分 + +自动微分(Automatic Differentiation, +AD)是一种介于符号微分和数值微分之间的针对计算图进行符号解析的求导方法,用于计算函数梯度值。深度学习等现代AI算法通过使用大量数据来学习拟合出一个优化后带参模型,其中使用的学习算法多是基于现实数据在模型中的经验误差,通过梯度下降的方法来更新模型的参数。因此,自动微分在深度学习中处于非常重要的地位,是整个训练算法的核心组件之一。自动微分通常在编译器前端优化中实现,通过对中间表示的符号解析来生成带有梯度函数的中间表示。 + +### 类型系统与静态分析 + +为了有效减少程序在运行时可能出现的错误,编译器的前端引入了类型系统(Type +System)和静态分析(Static +Analysis)系统。类型系统可以防止程序在运行时发生类型错误,而静态分析能够为编译优化提供线索和信息,有效减少代码中存在的结构性错误、安全漏洞等问题。 + +### 前端编译优化 + +编译优化意在解决代码的低效性,无论是在传统编译器还是在机器学习框架中都起着很重要的作用。前端的编译优化与硬件无关。 diff --git a/chapter_compiler_frontend_and_ir/summary.md b/chapter_compiler_frontend_and_ir/summary.md new file mode 100644 index 0000000..9f42f31 --- /dev/null +++ b/chapter_compiler_frontend_and_ir/summary.md @@ -0,0 +1,22 @@ +总结 +---- + +- 中间表示是编译器的核心数据结构之一,是程序编译过程中介于源语言和目标语言之间的程序表示。 + +- 传统编译器的中间表示从组织结构出发,可以分为线性中间表示,图中间表示以及混合中间表示。 + +- 机器学习框架的中间对中间表示有一系列新的需求,这些新的需求是传统中间表示所不能完美支持的。因此需要在传统中间表示的基础上扩展新的,更适用于机器学习框架的中间表示。 + +- 自动微分的基本思想是将将计算机程序中的运算操作分解为一个有限的基本操作集合,且集合中基本操作的求导规则均为已知,在完成每一个基本操作的求导后,使用链式法则将结果组合得到整体程序的求导结果。 + +- 自动微分根据链式法则的组合顺序,可以分为前向自动微分与反向自动微分。 + +- 前向自动微分更适用于对输入维度小于输出维度的网络求导,反向自动微分则更适用于对输出维度小于输入维度的网络求导。 + +- 自动微分的实现方法大体上可以划分为基本表达式法,操作符重载法以及代码变化法。 + +- 类型系统是指类型的集合以及使用类型来规定程序行为的规则,用于定义不同的类型,指定类型的操作和类型之间的相互作用,广泛应用于编译器、解释器和静态检查工具中。 + +- 静态分析,是指在不实际运行程序的情况下,通过词法分析、语法分析、控制流、数据流分析等技术对代码进行分析验证的技术 + +- 编译优化意在解决编译生成的中间表示的低效性,前端执行的均为与硬件无关的编译优化。 diff --git a/chapter_compiler_frontend_and_ir/type_system_and_static_analysis.md b/chapter_compiler_frontend_and_ir/type_system_and_static_analysis.md new file mode 100644 index 0000000..481d2f5 --- /dev/null +++ b/chapter_compiler_frontend_and_ir/type_system_and_static_analysis.md @@ -0,0 +1,41 @@ +类型系统和静态分析 +------------------ + +上一章节介绍了自动微分的基本概念和实现方法,自动微分是机器学习框架中不可或缺的核心功能。在编译器前端的设计中,为了提高编译器的抽象能力和程序运行的正确性,有效减少程序在运行时可能出现的错误,编译器引入了类型系统和静态分析系统,接下来将对它们的基本概念、主要功能、常见系统进行介绍。 + +### 类型系统概述 + +程序设计语言中,类型是指数值、表达式、函数等属性内容。类型系统是指类型的集合以及使用类型来规定程序行为的规则。类型系统用于定义不同的类型,指定类型的操作和类型之间的相互作用,广泛应用于编译器、解释器和静态检查工具中。类型系统提供的主要功能有: + +1)正确性。编译器的类型系统引入了类型检查技术,用于检测和避免运行时错误,确保程序运行时的安全性。通过类型推导与检查,编译器能够捕获大多数类型相关的异常报错,避免执行病态程序导致运行时错误,保证内存安全,避免类型间的无效计算和语义上的逻辑错误。 + +2)优化。静态类型检查可以提供有用的信息给编译器,从而使得编译器可以应用更有效的指令,节省运行时的时间。 + +3)抽象。在安全的前提下,一个强大的类型系统的标准是抽象能力。通过合理设计抽象,开发者可以更关注更高层次的设计。 + +4)可读性。阅读代码时,明确的类型声明有助于理解程序代码。 + +机器学习框架一般使用Python语言作为描述网络模型结构的前端语言。Python语言是一门动态弱类型的语言,入门简单易学习,开发代码简洁高效,但由于其解释执行的方式,运行速度往往较慢。Python前端语言给用户带来了动态灵活的语义和高效的开发效率,但是若想要生成运行高效的后端代码,后端框架需要优化友好的静态强类型中间表示。因此,需要一种高效可靠的静态分析方法作为桥梁,将Python前端表示转换成等价的静态强类型中间表示,以此给用户同时带来高效的开发效率和运行效率,例如Hindley--Milner(HM)类型系统。这是一种具有参数多态性的简单类型lambda演算的类型系统。它最初由J. +Roger Hindley 提出([@1969The]),并由Robin Milner +进行扩展和验证([@1978A])。后来,路易斯·达马斯(Luis +Damas)对HM类型推导方法进行了详尽的分析和证明([@1982Principal]),并将其扩展到支持具有多态引用的系统。Hindley--Milner类型系统的目标是在没有给定类型注解的情况下,自动推导出任意表达式的类型。其算法具有抽象性和通用性,采用简洁的符号表示,能够根据表达式形式推导出明确直观的定义,常用于类型推导和类型检查。因此,Hindley--Milner类型系统广泛应用于编程语言设计中,比如Haskell和Ocaml。 + +### 静态分析概述 + +在编译器前端中,除了类型系统的设计,还需要对程序代码进行检查分析,经常用到静态分析技术。静态分析,是指在不实际运行程序的情况下,通过词法分析、语法分析、控制流、数据流分析等技术对代码进行分析验证的技术。 + +词法分析:将字符流变换为输入语言的单流,每个单词都必须归类到某个语法范畴中。常用的词法分析器包括表驱动词法分析器、直接编码词法分析器和手工编码的词法分析器。 + +语法分析:判断单词流表示的输入程序在程序设计语言中是否是一个有效的句子。语法分析分为自顶向下语法分析和自底向上语法分析。自顶向下语法分析器从语法分析树的根开始,系统化地向下扩展树,直至树的叶节点与语法分析器返回的已归类单词相匹配。而自底向上语法分析器从叶结点开始构建语法分析树,自叶结点向根节点的方向前景。 + +控制流分析:控制流分析是分析程序控制流图、获得程序静态属性的静态分析方法。常见的控制流分析方法包括抽象释义、约束补偿和类型系统等。 + +数据流分析:数据流分析用于确定可进行优化的机会,并证明变换的安全性。 + +通过以上几种分析,静态分析对程序代码进行扫描,验证代码是否满足规范性、安全性、可靠性、可维护性等指标。 + +接下来以MindSpore框架为例,简要介绍一下静态分析模块的具体实现。MindSpore采用抽象释义的方法,对抽象值做不确定的抽象语义的解释执行,函数图中每个节点的抽象值是所期望得到的程序静态信息。基本的抽象释义方法流程可以理解为,从MindIR的顶层函数图入口开始解释执行,将函数图中所有节点进行拓扑排序,根据节点的语义递归推导各节点的抽象值。当遇到函数子图时,递归进入函数子图进行解释执行,最后返回顶层函数输出节点的抽象值。根据抽象释义方法流程,MindSpore的静态分析模块主要分为抽象域模块、缓存模块、语义推导模块和控制流处理模块。 + +![静态分析模块](../img/ch04/静态分析-静态分析模块.png) +:width:`850px` +:label:`static_analysis_module` diff --git a/chapter_computational_graph/index.md b/chapter_computational_graph/index.md index 83981d1..46a7c9d 100644 --- a/chapter_computational_graph/index.md +++ b/chapter_computational_graph/index.md @@ -1,4 +1,4 @@ -# 计算图的设计背景和作用 +# 计算图 在上一章节中,我们展示了用户的利用机器学习框架所编写的程序。这些用户程序包含了对于训练数据,模型和训练过程的定义。然而为了运行这些程序,机器学习系统依然需要解决诸多问题,包括:如何高效执行一个复杂的机器学习模型?如何识别出机器学习模型中需要训练的参数?如何自动计算更新模型所需的梯度?为了解决这些问题,现代机器学习框架实现了*计算图*(Computational graph)这一技术。在本章中,我们详细讨论计算图的基本组成,生成和执行等关键设计。本章的学习目标包括: diff --git a/chapter_data_processing_framework/data_order.md b/chapter_data_processing_framework/data_order.md new file mode 100644 index 0000000..d483856 --- /dev/null +++ b/chapter_data_processing_framework/data_order.md @@ -0,0 +1,21 @@ +## 保序性设计 + +和常规数据并行计算任务不同的是,机器学习场景下的数据并行处理为了确保实验的可复现性需要维护保序的性质。在具体实现中,我们需要保证并行数据预处理后的数据输出顺序与输入顺序保持相同(即下图中的SeqB和SeqA相同)。这确保了每一次的数据模块的结果输出顺序由数据混洗模块输出顺序唯一确定,有助于用户在不同的实验之间进行比较和调试。不同的机器学习系统采用了不同的方案来确保保序性,我们以MindSpore的实现为例子进行介绍以加深读者对这部分内容的理解。 + +![数据的保序性——确保SeqB与SeqA相同](../img/ch07/7.4/data_ordering.png) +:width:`800px` +:label:`data_order_definition` + +MindSpore通过约束算子线程组间的通信行为来确保对当前算子的下游算子的输入顺序与自己的输入顺序相同,基于这种递归的约束,确保了整个并行数据处理最后一个算子的输出顺序与第一个算子的输入顺序相同。具体实现中,MindSpore以Connector为算子线程组间的通信组件,对Connector的核心操作为上游算子的Push操作以及下游算子的Pop操作,我们重点关注MindSpore对这两个行为的约束。 + +Connector的使用有如下两个要求: + +- Connector两端的数据生产线程组和数据消费线程组中的线程分别从0开始编号。 + +- 确保数据生产者的输入数据顺序是在各个生产者线程间为按顺序轮询分布(Round-Robin distribution), 即当生产者线程组大小为M时,生产者线程0拥有第(0 + M \* k)个数据,生产者线程1拥有第(1 + M \* k),生产者线程2拥有第(2 + M \* k)个数据等(其中k=0,1,2,3\...)。 + +Connector中维护与生产者线程数目相同的队列并确保向Connector中放入数据时,每个生产者线程生产的数据只放到对应编号的队列中,这样可以确保Connector中的数据在不同的队列间的分布与在不同生产者线程组之间的分布相同(代码片段中的Push函数)。接着当Connector的消费者线程组从Connector中获取数据时,我们需要确保最终数据在不同的消费者线程间依然为按顺序轮询分布,即当消费者线程组大小为N时,消费者线程0拥有第(0 + N \* k)个数据,消费者线程1拥有第(1 + N \* k),消费者线程2拥有第(2 + N \* k)个数据等(其中k=0,1,2,3\...)。为此当有消费者线程从Connector中请求数据时,Connector在确保当前请求消费者线程编号i与待消费数据标号j符合$i=j\%N$的关系下(其中N为消费者线程数目)按照轮循的方式从各个队列中获取数据,如果二者标号不符合上述关系,则该请求阻塞等待。通过这种通信的约束方式,MindSpore实现了保序功能。 + +![MindSpore保序性实现](../img/ch07/7.4/mindspore_data_order.jpeg) +:width:`800px` +:label:`mindspore_data_order_implementation` \ No newline at end of file diff --git a/chapter_data_processing_framework/extension.md b/chapter_data_processing_framework/extension.md new file mode 100644 index 0000000..016bb98 --- /dev/null +++ b/chapter_data_processing_framework/extension.md @@ -0,0 +1,97 @@ +## 单机数据处理性能的扩展 + +上文我们介绍了通过并行架构发挥多核CPU算力来加速数据预处理,以满足芯片上模型计算对于数据消费的吞吐率需求,这在大部分情况下都能解决用户的问题。然而数据消费性能随着AI芯片的发展在逐年快速增长(即模型计算速率在变快),而主要借助CPU算力的数据模块却由于摩尔定律的逐渐终结无法享受到芯片性能提升带来的硬件红利,使得数据生产的性能很难像模型计算性能一样逐年突破。不仅如此,近几年AI服务器上AI芯片数量的增长速度远超CPU数量的增长速度,进一步加剧了芯片的数据消费需求与数据模块的数据生产性能之间的矛盾。我们以英伟达(NVIDIA)公司生产的NVIDIA DGX系列服务器为例子,DGX-1服务器中配置有40个CPU核和8个GPU芯片,而到了下一代的NVIDIA DGX-2服务器时,GPU芯片的数目增长了到了16个,而CPU核的数目仅从40个增加到了48个。由于所有的GPU芯片在训练时共享CPU的算力,故平均而言每个GPU芯片(数据消费者)能够使用的算力从NVIDIA DGX-1时的5CPU核/GPU下降到了 NVIDIA DGX-2的3CPU核/GPU,CPU的算力瓶颈会导致用户使用多卡训练时无法达到预期的扩展性能。针对单机上的CPU算力不足的问题,我们给出两种目前常见的两种解决方案,即基于CPU+AI芯片的异构数据处理的加速方案和基于分布式数据预处理的扩展方案。 + +### 基于异构计算的数据预处理 + +由于AI芯片相比于CPU拥有更丰富的算力资源,故在CPU算力成为数据预处理瓶颈时通过借助AI加速芯片来做数据预处理是一个行之有效的方案。虽然AI芯片不具备通用的数据预处理能力,但是由于大部分高耗时的数据预处理都是Tensor相关的计算,如语音中的快速傅立叶变换(Fast Fourier Transform, FFT),图像中的去噪等,使得部分操作可以被卸载到AI芯片上来加速。如华为昇腾Ascend310芯片上的Dvpp模块为芯片内置的硬件解码器,相较于CPU拥有对图形处理更强劲的性能,Dvpp支持JPEG图片的解码缩放等图像处理基础操作,用户实际数据预处理中可以指定部分图像处理在昇腾Ascend310芯片上完成以提升数据模块性能。 + +```python +namespace ms = mindspore; +namespace ds = mindspore::dataset; + +// 初始化操作 +//... + +// 构建数据处理算子 + +// 1. 解码 +std::shared_ptr decode(new ds::vision::Decode()); +// 2. 缩放 +std::shared_ptr resize(new ds::vision::Resize({256})); +// 3. 归一化 +std::shared_ptr normalize(new ds::vision::Normalize( + {0.485 * 255, 0.456 * 255, 0.406 * 255}, {0.229 * 255, 0.224 * 255, 0.225 * 255})); +// 4. 剪裁 +std::shared_ptr center_crop(new ds::vision::CenterCrop({224, 224})); + +// 构建流水并指定使用昇腾Ascend进行计算 +ds::Execute preprocessor({decode, resize, center_crop, normalize}, MapTargetDevice::kAscend310, 0); + +// 执行数据处理流水 +ret = preprocessor(image, &image); +``` + +相比较Dvpp只支持图像的部分预处理操作,英伟达公司研发的DALI\[8\]是一个更加通用的基于GPU的数据预处理加速框架。DALI中包含如下三个核心概念: + +- DataNode:表示一组Tensor的集合 + +- Operator:对DataNode进行变换处理的算子,一个Operator的输入和输出均为DataNode。比较特殊的是,DALI中的算子可以被设置为包括cpu,gpu,mixed三种不同执行模式,其中cpu模式下算子的输入输出均为cpu上的DataNode,gpu模式下算子的输入输出均为gpu上的DataNode,而mixed模式下的算子的输入为cpu的DataNode而输出为gpu的DataNode。 + +- Pipeline:用户通过Operator描述DataNode的处理变换过程而构建的数据处理流水 + +实际使用中用户通过设置算子的运行模式(mode)来配置算子的计算是用CPU还是GPU完成计算,同时DALI中有如下限制:当一个算子为mixed模式或者gpu模式时,其所有的下游算子强制要求必须为gpu模式执行。 + +![NVIDIA DALI概览](../img/ch07/7.5/dali_overview.png) + +:width:`800px` +:label:`dali_overview` + +下面展示一段使用DALI构建数据处理流水线的示例代码,我们从文件中读取图片数据经过混合模式的解码再经过运算在GPU上的旋转和缩放算子处理后返回给用户处理 +结果。由于其展示出的优异性能, +DALI被广泛的用于高性能推理服务和多卡训练性能的优化上。 + + +```python +import nvidia.dali as dali + +pipe = dali.pipeline.Pipeline(batch_size = 3, num_threads = 2, device_id = 0) +with pipe: + files, labels = dali.fn.readers.file(file_root = "./my_file_root") + images = dali.fn.decoders.image(files, device = "mixed") + images = dali.fn.rotate(images, angle = dali.fn.random.uniform(range=(-45,45))) + images = dali.fn.resize(images, resize_x = 300, resize_y = 300) + pipe.set_outputs(images, labels) + +pipe.build() +outputs = pipe.run() +``` + +### 基于分布式的数据预处理 + +分布式数据预处理是另一种解决CPU算力性能不足的可选方案。一种常见的做法是借助Spark、Dask等现有大数据计算框架进行数据预处理并将结果写入分布式文件系统,而训练的机器只需要读取预处理的结果数据并进行训练即可。 + +![基于第三方分布式计算框架的分布式数据预处理](../img/ch07/7.5/distribute.png) + +:width:`800px` +:label:`distributed_data_preprocess_based_on_3rd_party_software` + +该方案虽然再业内被广泛使用,却面临着三个问题: + +- 由于数据处理和数据训练采用不同的框架,使得用户为此常常需要在两个不同的框架中编写不同语言的程序,增加了用户的使用负担。 + +- 由于数据处理系统和机器学习两个系统间无法做零拷贝的数据共享,使得数据的序列化和反序列化常常成为不可忽视的额外开销。 + +- 由于大数据计算框架并不是完全针对机器学习场景,使得某些分布式预处理操作如全局的数据混洗无法被高效的实现。 + +为了更适配机器学习场景的数据预处理,分布式机器学习框架Ray借助其自身的任务调度能力实现了简单的分布式的数据预处理------ +Ray Dataset\[10\],由于数据预处理和训练处在同一个框架内,在降低了用户的编程负担的同时也通过数据的零拷贝共享消除了序列化/反序列化带来的额外开销。Ray Dataset支持如map、batch、map、filter等简单并行数据集变换算子、以及如mean等一些基础的聚合操作算子。同时Ray +Dataset也支持排序、随机打乱、GroupBy等全局混洗操作,该方案目前处在研究开发中,还未被广泛的采用,感兴趣的读者可以翻阅相关资料进一步的了解。 + +```python + ray.data.read_parquet("foo.parquet") \ + .filter(lambda x: x < 0) \ + .map(lambda x: x**2) \ + .random_shuffle() \ + .write_parquet("bar.parquet") +``` diff --git a/chapter_data_processing_framework/index.md b/chapter_data_processing_framework/index.md index a1ee49c..c7f1fb6 100644 --- a/chapter_data_processing_framework/index.md +++ b/chapter_data_processing_framework/index.md @@ -1,10 +1,31 @@ # 数据处理框架 +在前两个章节中,我们介绍了编译器前后端的相关内容,详细地阐述了源程序到目标程序的转换优化过程。除了让芯片在训练/推理过程中高性能地运行,我们还需要将数据高效地发送给芯片,以实现全流程的性能最优。机器学习模型训练和推理需要从存储设备(如本地磁盘和内存、远端的存储系统等)中加载数据集,对数据集进行一系列处理变换,将处理结果发送到到GPU或者华为昇腾Ascend等加速器中完成模型计算,该流程的任何一个步骤出现性能问题都会对训练和推理的吞吐率造成负面影响。本章我们将核心介绍如何设计、并实现一个面向机器学习场景的数据系统,以帮助用户轻松构建各种复杂的数据处理流水线(Data +Pipeline),同时我们的数据系统要有足够高的执行性能,以确保数据预处理步骤不会成为模型训练和推理的性能瓶颈。 + +本章主要从易用性、高效性和保序性三个维度展开介绍机器学习系统中的数据模块。在前两个小节中,我们首先讨论如何构建一个易用的数据模块。包括如何设计编程抽象,使得用户通过短短几行代码便可以描述一个复杂的预处理过程;以及如何做到既内置丰富算子提升易用性,又可以灵活支持用户使用自定义算子覆盖长尾需求。用户构建好数据处理流程后,数据模块需要负责高效的调度执行数据流水线,以达到最优的数据处理吞吐率。高效的执行数据流水线是一个具有挑战性的任务,我们既要面临数据读取部分的I/O性能问题,又要解决数据处理部分的计算性能问题。针对上述挑战,我们将分别介绍面向高吞吐率读取性能的数据文件格式设计,以及能够充分发挥多核CPU算力的并行架构设计。不仅如此,和常规数据并行计算任务不同的是,大部分机器学习场景对于数据的输入输出顺序有着特殊的`保序性`的要求,我们将会使用一节的内容来介绍什么是保序性,以及如何在数据模块的并行架构中设计相应组件计来满足该特性需求。学习了上述的内容后,读者将会对如何构建一个面向机器学习场景高效易用的数据模块有深刻的理解。最后,作为拓展内容,我们将以目前学术界和业界的一些实践经验来介绍当单机处理性能达不到要求时,该如何去扩展我们的数据处理模块以满足训练性能需求。本章学习目标包括: + +- 了解机器学习数据模块架构中的关键组件及其功能 + +- 了解不同数据模块用户编程接口的设计 + +- 掌握面向高性能数据读取的数据文件格式设计 + +- 掌握机器学习系统数据模块并行架构 + +- 掌握机器学习系统数据模块数据保序性含义及其解决方案 + +- 了解两种单机数据处理性能扩展方案 ```toc :maxdepth: 2 -:numbered: - +requirements +program_model +performance +data_order +extension +summary +reference ``` \ No newline at end of file diff --git a/chapter_data_processing_framework/performance.md b/chapter_data_processing_framework/performance.md new file mode 100644 index 0000000..831a721 --- /dev/null +++ b/chapter_data_processing_framework/performance.md @@ -0,0 +1,183 @@ +## 高效性设计 + +在上一节中我们重点介绍了数据模块的编程抽象以及编程接口设计,确保用户可以方便的基于我们提供的API描述数据处理流程而不需要过多关注实现和执行细节。那么本节我们将进一步探究数据加载以及流水线调度执行等数据模块关键部分设计细节以确保用户能够拥有最优的数据处理性能。同时在本节内容中,我们也会贯穿现有主要机器学习系统的实践经验以帮助读者加深对这些关键设计方案的理解。 + +如 :numref:`async_data_process` 所示,深度学习模型训练需要借助数据模块首先从存储设备中加载数据集,在内存中进行一系列的预处理变换,最终将处理好的数据集发送到加速器芯片上执行模型的计算,目前有大量的工作都着重于研究如何通过设计新的硬件或者应用算子编译等技术加速芯片上的模型计算,而在数据梳理流水的性能问题上鲜有涉及。但事实上很多情况下,数据预处理的执行时间往往在整个训练任务中占据着相当大的比例,导致GPU/华为昇腾Ascend等加速器无法被充分利用。研究数据表明,企业内数据中心的计算任务大约有30%的计算时间花费在数据预处理步骤\[5\],也有研究发现在一些公开数据集上的模型训练任务有65%的时间都花费在了数据预处理上\[6\],由此可以看出数据模块的性能对于整体训练吞吐率有着决定性的影响。 + +![数据加载、预处理、模型计算异步并行执行](../img/ch07/7.3/async_data_process.png) +:width:`800px` +:label:`async_data_process` + +为了追求最高的训练吞吐率,现有系统一般选择将数据读取、数据预处理计算、以及芯片上的模型计算三个步骤异步并行执行。这三步构成了典型的数据生产者和数据消费者的上下游关系,我们将数据从存储设备中的读取速率用F表示,数据预处理速率用P表示,芯片上的数据消费速率用G表示。理想情况下我们希望G < min(F, P),此时加速芯片不会因为等待数据而阻塞。然而现实情况下,我们常常要么因为数据加载速率F过低(称为I/O Bound),要么因为数据预处理速率P过低(称为CPU Bound)导致G>min(F, P)而使得芯片无法被充分利用。针对上述关键性能问题,我们将在本节重点探究两个内容: + +- 如何针对机器学习场景的特定I/O需求来设计相应文件格式及加载方式,以优化数据读取速率F。 + +- 如何设计并行架构来充分发挥现代多核CPU的计算能力,以提升数据处理速率P。 + +在本节的最后我们还会研究一个具有挑战性的问题,即如何利用我们在前几章学到的计算图的编译技术来优化用户的数据处理计算流图,以进一步达到最优的数据处理吞吐率性能。那么接下来,请读者和我们一起开启本节的头脑风暴旅程。 + +### 数据读取的高效性 + +首先我们来研究如何解决数据读取的性能挑战。我们面临的第一个问题是数据类型繁多,存储格式不统一带来的I/O差异,如文本数据可能存储成txt数据格式,图像数据可能存储成原始格式或者如JPEG等压缩格式。我们显然无法去针对每一种存储情况都设计其最优的数据读取方案。但是我们可以通过提出一种统一的存储格式(我们称之为Unirecord格式)以屏蔽不同数据类型的I/O差异,并基于这种数据格式进行数据加载方案的设计与优化,而实际使用中用户只需要将其原始数据集转换存储为我们的统一数据格式便可以享受到高效的读取效率。 + +![统一数据格式](../img/ch07/7.3/uni_record.png) +:width:`800px` +:label:`unified_record_format` + +那么我们的Unirecord除了统一用户存储格式之外还需要具备哪些特性呢?机器学习模型训练中对数据的访问具有如下特点: + +- 每一个Epoch内以一种随机顺序遍历所有的数据且每个数据只被遍历一次 + +- 所有Epoch需要以不同的随机顺序遍历访问所有数据 + +上述的访问特性要求我们的Unirecord存储格式能够支持高效的随机读取。当我们的数据集能够全部存储在RAM中时,对Unirecord的随机读取并不会成为大的问题。但是当数据集大到必须存储在本地磁盘或者分布式文件系统中时,我们就需要设计特定的方案。一个直观的想法是将一个Unirecord文件分为索引块和数据块,索引块中记录每个数据在文件中的大小、偏移以及一些校验值等元信息,数据块存储每个数据的主体数据。当我们需要对一个Unirecord格式的文件进行随机读取时,我们首先在内存中加载该文件的索引块(通常远远小于整个文件大小)并在内存中建立文件内数据的索引表,接着当我们需要随机读取数据时,我们首先在索引表中查询该数据在文件中的偏移、大小等信息并基于该信息从磁盘上进行读取。这样的读取方式可以满足我们在磁盘上的随机读取需求。接下来我们以MindSpore提出的MindRecord的实践经验为例子介绍统一文件格式的设计,以帮助大家加深对这部分内容的理解 + +![支持随机读取的文件格式设计](../img/ch07/7.3/file_indexing.png) +:width:`800px` +:label:`file_random_access` + +#### MindRecord介绍 + +MindRecord是MindSpore推出的统一数据格式,目标是归一化用户的数据集,优化训练数据的读取过程。该文件格式具备如下特征: + +- 实现多变的用户数据统一存储、访问,训练数据读取更加简便。 + +- 数据聚合存储,高效读取,且方便管理、移动。 + +- 高效的数据编解码操作,对用户透明、无感知。 + +- 可以灵活控制分区的大小,实现分布式训练。 + +和我们前文设计的Unirecord思路相似,一个MindRecord文件也由数据文件和索引文件组成,数据文件包含文件头、标量数据页、块数据页,用于存储用户归一化后的训练数据,索引文件包含基于标量数据(如图像Label、图像文件名等)生成的索引信息,用于方便的检索、统计数据集信息。为确保对一个MindRecord文件的随机读取性能,MindSpore建议单个MindRecord文件小于20G,若数据集超过20G,用户可在MindRecord数据集生成时指定相应参数将原始数据集分片存储为多个MindRecord文件。 + +![MindRecord文件格式组成](../img/ch07/7.3/MindRecord_format.png) + +:width:`800px` +:label:`mindrecord_format` + +一个MindRecord文件中的数据文件部分具体的关键部分的详细信息如下: + +- **文件头** + 文件头主要用来存储文件头大小、标量数据页大小、块数据页大小、Schema信息、索引字段、统计信息、文件分区信息、标量数据与块数据对应关系等,是MindRecord文件的元信息。 + +- **标量数据页** + 标量数据页主要用来存储整型、字符串、浮点型数据,如图像的Label、图像的文件名、图像的长宽等信息,即适合用标量来存储的信息会保存在这里。 + +- **块数据页** + 块数据页主要用来存储二进制串、Numpy数组等数据,如二进制图像文件本身、文本转换成的字典等。 + +用户训练时,MindRecord的读取器能基于索引文件快速的定位找到数据所在的位置,并将其读取解码出来。另外MindRecord具备一定的检索能力,用户可以通过指定查询条件筛选获取符合期望的数据样本。 + +对于分布式训练场景,MindRecord会基于数据文件中Header及索引文件进行元数据的加载,得到所有样本的ID及样本在数据文件中的偏移信息,然后根据用户输入的num_shards(训练节点数)和shard_id(当前节点号)进行数据的partition,得到当前节点的num_shards分之一的数据,即:分布式训练时,多个节点只读取数据集的num_shards分之一,借由计算侧的AllReduce实现整个数据集训练的效果。进一步,如果用户开启shuffle操作,那么每epoch保证所有节点shuffle seed保持一致,那么对所有样本的ID shuffle结果是一致的,那么数据partition的结果就是正确的。 + +![MindRecord Partition策略](../img/ch07/7.3/partition.png) + +:width:`800px` +:label:`mindrecord_partition` + +### 数据计算的高效性 + +解决了数据读取性能问题后,我们继续来研究数据计算的性能提升(即最大化上文中的数据处理速率P)。我们以上文提及的数据预处理流水为例子、来研究如何设计数据模块对用户计算图的调度执行以达到最优的性能。 + +![数据预处理流程串行顺序执行示意图](../img/ch07/7.3/single_pipeline.png) + +:width:`800px` +:label:`serialized_data_process` + +由于深度学习芯片如GPU/华为昇腾Ascend等并不具备通用数据处理的能力, +我们目前还是主要依赖CPU来完成预处理计算。主流的AI服务器大多具备多个多核CPU,数据模块需要设计合理的并行架构充分发挥多核算力,以提升数据预处理性能达到尽可能减少加速器由于等待数据而阻塞的目的。本节中我们将介绍流水线粒度并行以及算子粒度并行两种常见的并行架构。流水线并行的方式结构清晰,易于理解和实现,主要被Pytorch这样基于Python实现数据模块的机器学习系统所采用。受到经典数据并行系统调度执行架构设计的影响,其他如Google的TensorFlow以及华为的MindSpore等系统主要采用算子粒度并行做精细CPU算力分配以达到充分利用多核算力的目的。然而精细的分配意味着我们需要对所有数据处理流程中涉及的算子设置合理的并行参数,这对用户而言是一个较大的挑战。于是MindSpore等框架又提供数据流图中关键参数自动调优的功能,通过运行时的动态分析自动搜索得到最优的算子并行度等参数,极大的减少了用户的编程负担。接下来我们一一展开讨论。 + +#### 流水线并行 + +第一种常见的并行方案为流水线粒度的并行,即我们把用户构建的计算流水在一个线程/进程内顺序串行执行,同时启动多个线程/进程并行执行多个流水线。假设用户总共需要处理N个数据样本,那么当流水线并行度为M时,每个进程/线程只需要执行处理(N/M)个样本。流水线并行架构结构简单,易于实现。整个并行架构中各个执行进程/线程只需要在数据执行的开始和结束进行跨进程/线程的通信即可,数据模块将待处理的数据任务分配给各个流水线进程/线程,并在最终进行结果汇总发送到芯片上进行模型计算。从用户的角度而言使用也相对方便,只需要指定关键的并行度参数即可。接下来我们以Pytorch为例子进行详细展开。 + +![流水线级别并行执行示意图](../img/ch07/7.3/pipeline_parallisim.png) +:width:`800px` +:label:`pipeline_parallisim` + +在Pytorch中,用户只需要实现一个Dataset的Python类编写数据处理过程,Dataloader通过用户指定的并行度参数num_workers来启动相应个数的Python进程调用用户自定义的Dataset类进行数据预处理。Dataloader中的进程有两类角色:worker进程以及主进程,以及两类进程间通信队列:index_queue以及worker_result_queue。训练过程中,主进程负责将待处理数据任务列表通过index_queue发送给各个worker进程,每个woker进程执行用户编写的Dataset类的数据预处理逻辑并将处理后的结果通过worker_result_queue返回给主进程。 + +![Pytorch Dataloader并行执行架构](../img/ch07/7.3/pytorch_dataloader.png) +:width:`800px` +:label:`pytorch_dataloader` + +接下来我们展示一段用户使用Pytorch的Dataloader进行并行数据预处理的代码片段,可以发现我们只需要实现Dataset类描述数据预处理逻辑,并指定num_workers即可实现流水线粒度的并行数据预处理。 + + +```python +# 描述数据预处理流程 +class TensorDataset: + def __init__(self, inps): + sef.inps = inps + + def __getitem__(self, idx): + data = self.inps[idx] + data = data + 1 + return data + + def __len__(self): + return self.inps.shape[0] + +inps = torch.arange(10 * 5, dtype=torch.float32).view(10, 5) +dataset = TensorDataset(inps) + +# 指定并行度为3 +loader = DataLoader(dataset, batch_size=2, num_workers=3) + +for batch_idx, sample in enumerate(loader): + print(sample) +``` + +最后需要指出的是, Pytorch Dataloader的执行过程中涉及大量进程间通信,虽然为了加速这一步骤,Pytorch对Tensor类数据实现了基于共享内存的进程间通信机制。然而当通信数据量较大时,跨进程通信仍然会较大地影响端到端的数据预处理吞吐率性能。当然,这不是流水线并行自身的架构问题,而是由于CPython的全局解释器锁(Global Interpreter Lock, GIL)导致在Python层面实现流水线并行时只能采用进程并行。为了解决这个问题,目前Pytorch团队也在尝试通过移除CPython中的GIL来达到基于多线程实现流水线并行以提升通信效率的目的\[7\],感兴趣的读者可以选择继续深入了解。 + +#### 算子并行 + +流水线并行中算力(CPU核心)的分配以流水线为粒度,相对而言,以算子为计算资源分配粒度的算子并行是一种追求更精细算力分配的并行方案。我们期望对计算耗时高的算子分配更高的并行度,计算耗时低的算子分配更低的并行度,以达到更加高效合理的CPU算力使用。算子并行想法和经典的数据并行计算系统的并行方式一脉相承,我们以经典的MapReduce计算执行为例子,我们发现这也可以认为是一种算子并行(map算子和reduce算子),其中map算子的并行度和reduce算子的并行度根据各个算子阶段的计算耗时而决定。 + +![MapReduce经典并行执行架构](../img/ch07/7.3/map_reduce.png) +:width:`800px` +:label:`mapreduce` + +下图中我们给出本节开头数据预处理流程的算子并行架构示意图,我们根据各个算子的计算耗时设置图片解码算子并行度为3,图片缩放并行度为2,图片随机旋转算子并行度为4,图片归一化算子并行度为3,以及图像通道转置算子并行度为1。我们期望通过给不同耗时的算子精准的分配算力,以达到算力高效充分的利用。具体实现中算子并行一般采用线程级并行,所有的算子使用线程间队列等方法进行共享内存通信。 + +![算子并行执行架构](../img/ch07/7.3/operator_parallisim.png) +:width:`800px` +:label:`operator_parallisim` + +现有机器学习系统的数据模块中,tf.data以及MindData均采用了算子并行的方案。由于对算力的利用更加充分、以及基于C++的高效数据流调度实现,算子并行的方案往往展示出更好的性能,tf.data的性能评测表明其相比较Pytorch的Dataloader有近两倍的性能优势\[5\]。 +接下来我们以一段基于MindSpore实现本节开篇的数据预处理流程来展示如何在一个算子并行的数据流水线中设置各个算子的并行度。 + +```python +import mindspore.dataset as ds +import mindspore.dataset.transforms.c_transforms as c_transforms +import mindspore.dataset.transforms.vision.c_transforms as vision + +# 读取数据 +dataset_dir = "path/to/imagefolder_directory" +dataset = ds.ImageFolderDatasetV2(dataset_dir, num_parallel_workers=8) +transforms_list = [vision.Decode(), + vision.Resize((256, 256)), + vision.RandomRotation((0, 15)), + vision.Normalize((100, 115.0, 121.0), (71.0, 68.0, 70.0)), + vision.HWC2CHW()] +onehot_op = c_transforms.OneHot(num_classes) +# 解码算子并行度为3 +dataset = dataset.map(input_columns="image", operations=vision.Decode(), num_parallel_workers=3) +# 缩放算子并行度为2 +dataset = dataset.map(input_columns="image", operations=vision.Resize((256, 256)), num_parallel_workers=2) +# 随机旋转算子并行度为4 +dataset = dataset.map(input_columns="image", operations=vision.RandomRotation((0, 15)), num_parallel_workers=4) +# 正规化算子并行度为3 +dataset = dataset.map(input_columns="image", operations=vision.Normalize((100, 115.0, 121.0), (71.0, 68.0, 70.0)), num_parallel_workers=3) +# 通道转置算子并行度为1 +dataset = dataset.map(input_columns="image", operations=vision.HWC2CHW(), num_parallel_workers=1) +dataset = dataset.map(input_columns="label", operations=onehot_op) +``` + +我们发现,虽然算子并行具备更高的性能潜力,但却需要我们对每一个算子设置合理并行参数。这不仅对用户提出了较高的要求,同时也增加了由于不合理的并行参数设置导致性能反而下降的风险。为了让用户更加轻松的使用算子并行,tf.data和MindData都增加了流水线关键参数动态调优功能,基于对流水线执行时的性能监控计算得到合理的参数以尽可能达到最优的数据预处理吞吐率\[5\]。 + +#### 数据处理计算图优化 + +在前文中,我们专注于通过并行架构来高效执行用户构建的数据预处理计算图。但我们可以思考如下问题:用户给定的计算图是否是一个高效的计算图? +如果不高效,我们是否能够在保证等价变换的前提下将用户的数据计算图进行优化重写得到执行性能预期更好的计算图?没错,这和我们在前几章中学习的模型计算图编译优化有着相同的思想,即通过分析变换计算图IR得到更优的IR表示来达到更好的执行性能。常用的数据图优化策略有算子融合以及map操作向量化两种。算子融合将map+map、map+batch、map+filter、filter+filter等算子组合融合成一个等价复合算子,将原先需要在两个线程组中执行的计算融合为在一个线程组中执行的复合计算,减少线程间的同步通信开销,达到了更优的性能。而map操作向量化则将常见的dataset.map(f).batch(b)操作组合变换调整为dataset.batch(b).map(parallel_for(f)),借助现代CPU的对并行操作更友好的SIMD指令集来加速数据预处理。 + diff --git a/chapter_data_processing_framework/program_model.md b/chapter_data_processing_framework/program_model.md new file mode 100644 index 0000000..5e740b3 --- /dev/null +++ b/chapter_data_processing_framework/program_model.md @@ -0,0 +1,122 @@ +## 易用性设计 + +本节我们主要介绍如何设计一个易用的机器学习系统数据模块。正如前文所言,易用性既要求数据模块提供好的编程抽象和接口使得用户可以方便的构建一个数据处理流水,同时还要支持用户灵活地在数据流水中注册使用自定义算子以满足丰富多变的特殊需求,接下来我们将从编程接口抽象和自定义算子注册机制两个方面来展开探讨。 + +### 编程抽象与接口 + + :numref:`image_process_pipeline` 我们展示的是一个训练图片分类模型的经典数据预处理流水线。我们从存储设备中加载数据集后,对数据集中的图片数据进行解码、缩放、旋转、正规化、通道变换等一系列操作,对数据集的标签也进行特定的预处理操作,最终将处理好的数据发送到芯片上进行模型的计算。我们希望数据模块提供的编程抽象具备足够高的层次,以使得用户可以通过短短几行代码就能描述清楚数据处理的逻辑,不需要陷入过度的、重复的数据处理实现细节当中。同时又要确保这一套高层次的抽象具备足够通用性,以满足多样的数据预处理需求。在我们得到一个好的编程抽象后,我们将会以基于MindSpore的数据模块提供的编程接口实现下图所描述的数据预处理流水线的代码片段为例子来展示一个优秀的编程抽象对用户编程负担的减轻是有多么大的作用。 + +![数据预处理示例](../img/ch07/7.2/image_process_pipeline.png) +:width:`800px` +:label:`image_process_pipeline` + + +事实上,面向数据计算的编程抽象早已在通用数据并行计算系统领域中被广泛的研究并取得了相对统一的共识------那就是提供类LINQ式\[1\]的编程抽象,其最大的特点是让用户专注于描述基于数据集的生成与变换,而将这些操作的高效实现与调度执行交由数据系统的运行时负责。一些优秀的系统如Naiad\[2\], +Spark\[3\], DryadLINQ\[4\]等都采用了这种编程模型。我们以Spark为例子进行简要介绍。 + +Spark向用户提供了基于弹性分布式数据集(Resilient Distributed Dataset, RDD)概念的编程模型。一个RDD是一个只读的分布式数据集合,用户通过Spark的编程接口来主要描述RDD的创建及变换过程,我们以一个Spark示例进行展开讨论。下面展示了一段在一个日志文件中统计包含ERROR字段的行数的Spark代码,我们首先通过文件读取创建一个分布式的数据集file(前文提到RDD表示数据的集合,这里的file实际上是日志行的数据集合)。 +我们对这个file数据集进行filter(过滤)运算得到新的只保留包含ERROR字段的日志行的数据集errs,接着我们对errs中的每一个数据进行map(映射)操作得到数据集ones,最后我们对ones数据集进行reduce操作得到了我们最终想要的统计结果,即file数据集中包含ERROR字段的日志行数。 + +```java +val file = spark.textFile("hdfs://...") +val errs = file.filter(_.contains("ERROR")) +val ones = errs.map(_ => 1) +val count = ones.reduce(_+_) +``` + + + +我们发现用户只需要四行代码就完成了在这样一个分布式的数据集中统计特定字段行数的复杂任务,这得益于Spark核心的RDD编程抽象,从 :numref:`rdd_transformation_example`的计算流程可视化中我们也可以清晰的看到用户在创建数据集后,只需要描述在数据集上的作用算子即可,至于算子的执行和实现则由系统的运行时负责。 + +![Spark编程核心------RDD变换](../img//ch07/7.2/RDD.png) + +:width:`800px` +:label:`rdd_transformation_example` +主流机器学习系统中的数据模块同样也采用了类似的编程抽象,如TensorFlow的数据模块tf.data\[5\], +以及MindSpore的数据模块MindData等。接下来我们以MindData的接口设计为例子来介绍如何面向机器学习这个场景设计好的编程抽象来帮助用户方便的构建模型训练中多种多样的数据处理流水线。 + +MindData是机器学习系统MindSpore的数据模块,主要负责完成机器学习模型训练中的数据预处理任务,MindData的向用户提供的核心编程抽象为基于Dataset(数据集)的变换处理。这里的Dataset是一个数据帧的概念(Data +Frame),即一个Dataset为一个多行多列,且每一列都有列名的关系数据表。 + +![MindSpore +Dataset示例](../img/ch07/7.2/dataset_table.png) +:width:`800px` +:label:`mindspore dataset example` + +基于这样一个编程模型,结合我们在第一节中介绍的机器学习数据流程中的关键处理流程,MindData为用户提供了对数据集进行shuffle、map、batch等变换操作的数据集操作算子,这些算子接收一个Dataset作为输入,并以一个新处理生成的Dataset作为结果输出,我们列举典型的数据集变换接口如下: + +:MindSpore支持的数据集操作接口 + +| 数据集操作 | 含义解释 | +| -------------------- | -------------------------------------------------------- | +| batch | 将数据集中的多行数据项组成一个mini-batch | +| map | 对数据集中的每行数据进行变换操作 | +| shuffle | 随机打乱数据集中的数据行的顺序 | +| filter | 对数据集的数据行进行过滤操作,只保留通过过滤条件的数据行 | +| prefetch | 从存储介质中预取数据集 | +| project | 从Dataset数据表中选择一些列用于接下来的处理 | +| zip | 将多个数据集合并为一个数据集 | +| repeat | 多轮次训练中,重复整个数据流水多次。 | +| create_dict_iterator | 对数据集创建一个返回字典类型数据的迭代器。 | +| ... | ... | + +上述描述了数据集的接口抽象,而对数据集的具体操作实际上是由具体的数据算子函数定义。为了方便用户使用,MindData对机器学习领域常见的数据类型及其常见数据处理需求都内置实现了丰富的数据算子库。针对视觉领域,MindData提供了常见的如Decode(解码)、Resize(缩放)、RandomRotation(随机旋转)、Normalize(正规化)以及HWC2CHW(通道转置)等算子;针对文本领域,MindData提供了Ngram、NormalizeUTF8、BertTokenizer等算子;针对语音领域,MindData提供了TimeMasking(时域掩盖)、LowpassBiquad(双二阶滤波器)、ComplexNorm(归一化)等算子;这些常用算子能覆盖用户的绝大部分需求。 + +除了支持灵活的Dataset变换,针对数据集种类繁多、格式与组织各异的难题,MindData还提供了灵活的Dataset创建,主要分为如下三类: + +- 通过内置数据集直接创建:MindData内置丰富的经典数据集,如CelebADataset、Cifar10Dataset、CocoDataset、ImageFolderDataset、MnistDataset、VOCDataset等。如果用户需要使用这些常用数据集,可通过一行代码即可实现数据集的开箱使用。同时MindData对这些数据集的加载进行了高效的实现,以确保用户能够享受到最好的读取性能。 + +- 从MindRecord中加载创建:MindRecord为MindData设计的一种高性能通用数据存储文件格式,用户可将数据集转换为MindRecord后借助MindSpore的相关API进行高效的读取。 + +- 从Python类创建:如果用户已经有自己数据集的Python读取类,那么可以通过MindData的GeneratorDataset接口调用该Python类实现Dataset的创建,这给用户提供了极大的自由度。 + +![MindSpore +Dataset多种生成方式](../img/ch07/7.2/dataset.png) + +最后我们以一个基于MindData实现我们本节开篇所描述的数据处理流水线为例子来展示以Dataset为核心概念的数据编程抽象是多么的用户友好。我们只需要短短10余行代码即可完成我们所期望的复杂数据处理,同时在整个过程中,我们只专注于逻辑的描述,而将算子的实现和算子执行流程交由数据模块负责,这极大的减轻了用户的编程负担。 + +```python +import mindspore.dataset as ds +import mindspore.dataset.transforms.c_transforms as c_transforms +import mindspore.dataset.transforms.vision.c_transforms as vision +dataset_dir = "path/to/imagefolder_directory" + +# create a dataset that reads all files in dataset_dir with 8 threads +dataset = ds.ImageFolderDatasetV2(dataset_dir, num_parallel_workers=8) + +#create a list of transformations to be applied to the image data +transforms_list = [vision.Decode(), + vision.Resize((256, 256)), + vision.RandomRotation((0, 15)), + vision.Normalize((100, 115.0, 121.0), (71.0, 68.0, 70.0)), + vision.HWC2CHW()] +onehot_op = c_transforms.OneHot(num_classes) + +# apply the transform to the dataset through dataset.map() +dataset = dataset.map(input_columns="image", operations=transforms_list) +dataset = dataset.map(input_columns="label", operations=onehot_op) + +``` + +### 自定义算子支持 + +有了基于数据集变换的编程抽象、以及针对机器学习各种数据类型的丰富变换算子支持,我们可以覆盖用户绝大部分的数据处理需求。然而由于机器学习领域本身进展快速,新的数据处理需求不断涌现,可能会有用户想要使用的数据变换算子没有被数据模块覆盖支持到的情况发生。为此我们需要设计良好的用户自定义算子注册机制,使得用户可以方便在构建数据处理流水线时使用自定义的算子。 + +机器学习场景中,用户的开发编程语言以Python为主,所以我们可以认为用户的自定义算子更多情况下实际上是一个Python函数或者Python类。数据模块支持自定义算子的难度主要由数据模块对计算的调度实现方式有关系,比如Pytorch的dataloader的计算调度主要在Python层面实现,得益于Python语言的灵活性,在dataloader的数据流水中插入自定义的算子相对来说比较容易;而像TensorFlow的tf.data以及MindSpore的MindData的计算调度主要在C++层面实现,这使得数据模块想要灵活的在数据流中插入用户定义的Python算子变得较为有挑战性。接下来我们以MindData中的算子自定义算子注册使用实现为例子展开讨论这部分内容。 + +![MindData的C层算子和Python层算子](../img/ch07/7.2/operation.png) + +:width:`800px` +:label:`mindspore operator example` + +MindData中的数据预处理算子可以分为C层算子以及Python层算子,C层算子能提供较高的执行性能而Python层算子可以很方便借助丰富的第三方Python包进行开发。为了灵活地覆盖更多场景,MindData支持用户使用Python开发自定义算子,如果用户追求更高的性能,MindData也支持用户将开发的C层算子编译后以插件的形式注册到MindSpore的数据处理中进行调用。 + +对于用户传入map、filter等数据集变换算子中的自定义数据处理算子,MindData的Pipeline启动后会通过创建的Python运行时来执行。需要指出的是自定义的Python算子需要保证需要保一个或多个输入、输出均是numpy.ndarray类型。具体执行过程中,当MindData的Pipeline的数据集变换中执行用户自定义的PyFunc算子时,会将输入数据以numpy.ndarray的类型传递给用户的PyFunc,自定义算子执行完毕后再以numpy.ndarray返回给MindData,在此期间,正在执行的数据集变换算子(如map、filter等)负责该PyFunc的运行时生命周期及异常判断。如果用户追求更高的性能,MindData也支持用户自定义C算子。dataset-plugin仓(插件仓)\[10\]为MindData的算子插件仓,囊括了为特定领域(遥感,医疗,气象等)量身制作的算子,该仓承载MindData的插件能力扩展,为用户编写MindData的新算子提供了便捷易用的入口,用户通过编写算子、编译、安装插件步骤,然后就可以在MindData +Pipeline的map操作中使用新开发的算子。 + + + +![MindSpore自定义算子注册](../img/ch07/7.2/dataset-plugin.png) + +:width:`800px` +:label:`mindspore_user_defined_operator` \ No newline at end of file diff --git a/chapter_data_processing_framework/reference.md b/chapter_data_processing_framework/reference.md new file mode 100644 index 0000000..ea6464e --- /dev/null +++ b/chapter_data_processing_framework/reference.md @@ -0,0 +1,34 @@ +## 引用 +\[1\] Meijer, E., Beckman, B., & Bierman, G. (2006, June). Linq: +reconciling object, relations and xml in the. net framework. In +Proceedings of the 2006 ACM SIGMOD international conference on +Management of data (pp. 706-706). + +\[2\] Murray, D. G., McSherry, F., Isaacs, R., Isard, M., Barham, P., & +Abadi, M. (2013, November). Naiad: a timely dataflow system. In +Proceedings of the Twenty-Fourth ACM Symposium on Operating Systems +Principles (pp. 439-455). + +\[3\] Zaharia, M., Chowdhury, M., Franklin, M. J., Shenker, S., & Stoica, +I. (2010). Spark: Cluster computing with working sets. HotCloud, +10(10-10), 95. + +\[4\] Fetterly, Y. Y. M. I. D., Budiu, M., Erlingsson, Ú., & Currey, P. +K. G. J. (2009). DryadLINQ: A system for general-purpose distributed +data-parallel computing using a high-level language. Proc. LSDS-IR, 8. + +\[5\] Murray, D. G., Simsa, J., Klimovic, A., & Indyk, I. (2021). tf. +data: A Machine Learning Data Processing Framework. arXiv preprint +arXiv:2101.12127. + +\[6\] Mohan, J., Phanishayee, A., Raniwala, A., & Chidambaram, V. (2020). +Analyzing and mitigating data stalls in DNN training. arXiv preprint +arXiv:2007.06775. + +\[7\] https://docs.google.com/document/d/18CXhDb1ygxg-YXNBJNzfzZsDFosB5e6BfnXLlejd9l0/edit#. + +\[8\] https://github.com/NVIDIA/DALI. + +\[9\] https://docs.ray.io/en/latest/data/dataset.html. + +\[10\] https://gitee.com/mindspore/dataset-plugin. \ No newline at end of file diff --git a/chapter_data_processing_framework/requirements.md b/chapter_data_processing_framework/requirements.md new file mode 100644 index 0000000..9cd7e26 --- /dev/null +++ b/chapter_data_processing_framework/requirements.md @@ -0,0 +1,33 @@ +## 概述 + +机器学习场景中的数据处理是一个典型的ETL(Extract, Transform, +Load)过程,第一个阶段(Extract)需要从存储设备中加载数据集,第二个阶段(Transform)完成对数据集的变换处理。虽然不同的机器学习系统在构建数据模块时采用了不同的技术方案,但其核心都会包含数据加载、数据混洗、数据变换、数据mini-batch组装以及数据发送等关键组件。其中每个组件的功能介绍如下所示: + +- **数据加载组件(Load)**:负责从存储设备中加载读取数据集,需要同时考虑存储设备的多样性(如本地磁盘/内存,远端磁盘和内存等)和数据集格式的多样性(如csv格式,txt格式等)。根据机器学习任务的特点,AI框架也提出了统一的数据存储格式(如谷歌TFRecord, + 华为MindRecord等)以提供更高性能的数据读取。 + +- **数据混洗组件(Shuffle)**:负责将输入数据的顺序按照用户指定方式随机打乱,以提升模型的鲁棒性。 + +- **数据变换组件(Map)**:负责完成数据的变换处理,内置面向各种数据类型的常见预处理算子,如图像中的尺寸缩放和翻转,音频中的随机加噪和变调、文本处理中的停词去除和随机遮盖(Mask)等。 + +- **数据组装组件(Batch)**:负责组装构造一个批次(mini-batch)的数据发送给训练/推理。 + +- **数据发送组件(Send)**:负责将处理后的数据发送到GPU/华为昇腾Ascend等加速器中以进行后续的模型计算和更新。高性能的数据模块往往选择将数据向设备的搬运与加速器中的计算异步执行,以提升整个训练的吞吐率。 + +![数据模块的核心组件](../img/ch07/7.1/pipeline.png) +:width:`800px` +:label:`pipeline` + +实现上述的组件只是数据模块的基础,我们还要对如下方面进行重点设计: + +#### 易用性 + +AI模型训练/推理过程中涉及到的数据处理非常灵活:一方面,不同的应用场景中数据集类型千差万别,特点各异,在加载数据集时,数据模块要支持图像、文本、音频、视频等多种类型的特定存储格式,还要支持内存、本地磁盘、分布式文件系统以及对象存储系统等多种存储设备类型,模块需要对上述复杂情况下数据加载中的IO差异进行抽象统一,减少用户的学习成本。另一方面,不同的数据类型往往也有着不同的数据处理需求。现有常见机器学习任务中,图像任务常常对图像进行缩放、翻转、模糊化等处理,文本任务需要对文本进行切分、向量化等操作,而语音任务需要对语音进行快速傅立叶变换、混响增强、变频等预处理。为帮助用户解决绝大部分场景下的数据处理需求,数据模块需要支持足够丰富的面向各种类型的数据预处理算子。然而新的算法和数据处理需求在不断快速涌现,我们需要支持用户在数据模块中方便的使用自定义处理算子,以应对数据模块未覆盖到的场景,达到灵活性和高效性的最佳平衡。 + +#### 高效性 + +由于GPU/华为昇腾Ascend等常见AI加速器主要面向Tensor数据类型计算,并不具备通用的数据处理能力,现有主流机器学习系统数据模块通常选择使用CPU进行数据流水线的执行。理想情况下,在每个训练迭代步开始之前,数据模块都需要将数据准备好、以减少加速器因为等待数据而阻塞的时间消耗。然而数据流水线中的数据加载和数据预处理常常面临着具有挑战性的I/O性能性能和CPU计算性能问题,数据模块需要设计具备支持随机读取且具备高读取吞吐率的文件格式来解决数据读取瓶颈问题,同时还需要设计合理的并行架构来高效的执行数据流水线,以解决计算性能问题。为达到高性能的训练吞吐率,主流机器学习系统均采用数据处理与模型计算进行异步执行,以掩盖数据预处理的延迟。 + +#### 保序性 + +和常规的数据并行计算任务所不同的是,机器学习模型训练对数据输入顺序敏感。使用随机梯度下降算法训练模型时,通常在每一轮需要按照一种伪随机顺序向模型输入数据,并且在多轮训练(Epoch)中每一轮按照不同的随机顺序向模型输入数据。由于模型最终的参数对输入数据的顺序敏感,为了帮助用户更好的调试和确保不同次实验的可复现性,我们需要在系统中设计相应机制使得数据最终送入模型的顺序由数据混洗组件的数据输出顺序唯一确定,不会由于并行数据变换而带来最终数据模块的数据输出顺序不确定。我们将在后文中对于保序性的要求和具体实现细节展开探讨。 diff --git a/chapter_data_processing_framework/summary.md b/chapter_data_processing_framework/summary.md new file mode 100644 index 0000000..8d2b95a --- /dev/null +++ b/chapter_data_processing_framework/summary.md @@ -0,0 +1,3 @@ +## 章节总结 + +本章我们围绕着易用性、高效性和保序性三个维度展开研究如何设计实现机器学习系统中的数据预处理模块。在易用性维度我们重点探讨了数据模块的编程模型,通过借鉴历史上优秀的并行数据处理系统的设计经验,我们认为基于描述数据集变换的编程抽象较为适合作为数据模块的编程模型,在具体的系统实现中,我们不仅要在上述的编程模型的基础上提供足够多内置算子方便的用户的数据预处理编程,同时还要考虑如何支持用户方便的使用自定义算子。在高效性方面,我们从数据读取和计算、两个分别介绍了特殊文件格式设计和计算并行架构设计。我们也使用我们在前几章中学习到的模型计算图编译优化技术来优化用户的数据预处理计算图,以进一步的达到更高的数据处理吞吐率。机器学习场景中模型对数据输入顺序敏感,于是衍生出来保序性这一特殊性质,我们在本章中对此进行了分析并通过MindSpore中的Connector的特殊约束实现来展示真实系统实现中如何确保保序性。最后,我们也针对部分情况下单机CPU数据预处理性能的问题,介绍了当前基于异构处理加速的纵向扩展方案,和基于分布式数据预处理的横向扩展方案,我们相信读者学习了本章后能够对机器学习系统中的数据模块有深刻的认知,也对数据模块未来面临的挑战有所了解。 \ No newline at end of file diff --git a/chapter_distributed_training_system/index.md b/chapter_distributed_training_system/index.md index d12b578..909f969 100644 --- a/chapter_distributed_training_system/index.md +++ b/chapter_distributed_training_system/index.md @@ -1,10 +1,331 @@ -# 分布式训练系统 +--- +bibliography: +- references.bib +--- +# 分布式训练系统 {#ch:distributed} +随着机器学习的进一步发展,科学家们设计出更大型,更多功能的机器学习模型(例如说,GPT-3)。这种模型含有大量参数,需要复杂的计算以及处理海量的数据。单个机器上有限的资源无法满足训练大型机器学习模型的需求。因此,我们需要设计分布式训练系统,从而将一个机器学习模型任务拆分成多个子任务,并将子任务分发给多个计算节点,解决资源瓶颈。 -```toc -:maxdepth: 2 -:numbered: +在本章节中,我们会引入分布式机器学习系统的相关概念,设计挑战,系统实现和实例研究。我们会首先讨论分布式训练系统的定义,设计动机和好处。进一步,我们会讨论常见的分布式训练方法:数据并行,模型并行和流水线并行。在实际中,这些分布式训练方法会被参数服务器(Parameter +Servers),或者是集合通讯库(Collective Communication +Libraries)实现。不同的系统实现具有各自的优势和劣势。我们会用大型预训练模型和大型深度学习推荐系统作为实例来探讨不同系统实现的利与弊。 +本章的学习目标包括: -``` \ No newline at end of file +- 掌握分布式训练相关系统组件的定义,设计动机和好处 + +- 掌握常见的分布式训练方法:数据并行,模型并行和流水线并行 + +- 掌握常见的分布式训练框架实现:参数服务器和集合通讯 + +- 理解常见分布式训练的实例,和采用不同实现方法的利弊。 + +## 系统概述 + +### 设计动机 + +接下来,我们详细讨论分布式训练系统的设计动机 + +![对比机器学习模型参数量增长和计算硬件的算力增长](figs/ch10/ch10-computation-increase.pdf){#fig:ch010/ch10-computation-increase} + +##### 算力不足 + +单处理器的算力不足是促使人们设计分布式训练系统的一个主要原因。一个处理器的算力可以用**每秒钟浮点数操作**(Floating +Point Operations Per Second,FLOPS)来衡量。 +如图[1.1](#fig:ch010/ch10-computation-increase){reference-type="ref" +reference="fig:ch010/ch10-computation-increase"}所示,根据摩尔定律(Moore's +Law),中央处理器的算力每18个月增长2倍。虽然计算加速卡,如GPU和Tensor +Processing +Unit(TPU),针对机器学习计算(如矩阵相乘)提供了大量的算力。这些加速卡的发展最终也受限于摩尔定律,增长速度也停留在每18个月2倍。而与此同时,机器学习模型正在快速发展。短短数年,我们从仅能识别有限物体的AlexNet模型([@krizhevsky2012alexnet]),一路发展到在复杂任务中打败人类的AlphaStar([@alphastar])。这期间,模型对于算力需求每18个月增长了35倍。解决处理器性能和算力需求之间的鸿沟 +的关键就在于利用分布式计算。通过大型数据中心和云计算设施,我们可以快速获取大量的处理器。通过分布式训练系统有效管理这些处理器,我们可以实现算力的快速增长,从而持续满足模型的需求。 + +##### 内存不足 + +在训练机器学习模型的过程中,训练系统需要在内存中存储大量数据。这些数据包括:模型参数(Parameters)以及训练和更新这些参数所产生的中间数据,如特征图(Feature +Map)和梯度(Gradients)。假设一个深度神经网络模型具有10亿的参数,所有特征图共有20亿参数,每个参数都由一个32位浮点数表达,而更新这些参数至少还需要产生与特征图和参数等量的梯度。由于一个32位浮点数需要4个字节(Byte)的内存来存储,那么训练这个10亿规模的模型就需要至少24GB($24 \times 10^9$ +Byte)的内存。现在,随着大型预训练模型的崛起,一个深度神经网络(如GPT-3)会拥有超过千亿的参数。假设我们依然使用32位浮点数来存储参数,激活值和梯度,那么训练这个模型就至少需要1.2TB的内存。而如今的训练加速卡(如NVIDIA +A100)仅能提供最高80GB的内存。单卡内存空间的增长受到硬件规格,散热和成本等诸多因素,难以进一步快速增长。因此,我们需要分布式训练系统来同时使用数百个训练加速卡,从而为千亿级别的模型提供所需的TB级别的内存。 + +### 分布式训练架构 + +\[概述本章核心系统组件,定义本章的技术用语\] +受限于单节点的有限算力,内存和存储资源,人们把关注投向了日益成熟的云计算数据中心。一个数据中心管理着数十万个计算服务器。随着数据中心的全球部署,人们可以很方便地获得数百个服务器。这些服务器可以通过分布式训练系统来协调和管理,解决训练大型机器学习模型过程遇到的算力,内存和存储不足,从而完成训练过程的加速。 + +![单节点计算和多节点分布式计算](figs/ch10/ch10-single-vs-multi.pdf){#fig:ch010/ch10-single-vs-multi} + +在设计分布式训练系统的过程中,我们需要找出有资源瓶颈的计算任务,根据计算任务的特点,将其拆分成多个子任务,然后将子任务分发给多个节点(可以是服务器,机器,或者是加速卡)并行完成。 +图 [1.2](#fig:ch010/ch10-single-vs-multi){reference-type="ref" +reference="fig:ch010/ch10-single-vs-multi"}描述了如何将单节点执行转换为分布式执行的一般过程。在机器学习系统中(如图 [1.2](#fig:ch010/ch10-single-vs-multi){reference-type="ref" +reference="fig:ch010/ch10-single-vs-multi"}所示),一个计算任务往往会有一组数据(例如训练样本)或者任务(例如算子)作为输入,利用一个计算节点(例如GPU)生成一组输出(例如梯度)。假如单节点成为瓶颈,我们可以利用分布式计算进行加速。如图 [1.2](#fig:ch010/ch10-single-vs-multi){reference-type="ref" +reference="fig:ch010/ch10-single-vs-multi"}所示,分布式执行一般具有三个步骤:第一步,我们需要将输入进行**切分**。第二步,每个输入部分会分发给不同的计算节点,实现**并行**计算。第三步,每个计算节点的输出,进一步**合并**,最终得到和单节点等价的计算结果。这种切分-并行-合并的模式,本质上实现了分而治之算法(Divide-and-Conquer +Algorithm)的设计思想:由于每个计算节点只需要负责更小的子任务,因此其可以更快速的完成计算,最终形成对整个计算过程的加速。 + +### 用户益处 + +\[总结系统对于用户的好处\] +通过使用分布式训练系统,我们往往可以获得以下几个关键好处: + +- **提升系统性能**:使用分布式训练,往往可以带来训练性能的巨大提升。一个分布式训练系统往往用以下这个指标来衡量性能:到达目标精度所需的时间(time-to-accuracy)。这个指标由两个参数决定: + 一个数据周期所需的完成时间,以及一个数据周期模型所提升的精度。通过持续增加并行处理节点,我们可以将数据周期的完成时间不断变短,最终显著减少到达目标精度所需的时间。 + +- **经济性(Economy)**:使用分布式训练,我们也可以进一步减少训练及其模型所需的成本。受限于单节点散热和半导体制程的限制,在一个节点上不断增加算力和内存的成本一般会指数增加\[引用\]。因此,高性能的深度学习计算节点往往具有极高的硬件成本。而提供同等的算力下,通过组合多个计算节点往往比使用单个节点显著的更低。举例,在亚马逊\[\...\...\]。因此通过构建分布式训练系统,我们可以提升训练集群的经济性,降低模型的训练成本。 + +- **抵御硬件故障**:分布式训练系统同时能有效提升抵御硬件故障的能力。机器学习训练集群往往由商用硬件(Commodity + Hardware)组成,这类硬件(例如说,磁盘和网卡)运行一定周期就会产生故障。而仅使用单个硬件进行训练的话,那么一个硬件的故障就会造成整个训练的任务的失败。通过将这个训练任务又多个硬件共同完成,即使一个硬件故障了,我们也可以通过将这个硬件上相应的计算子任务转移给其余硬件,继续完成训练,从而避免训练任务的失败。 + +## 分布式训练方法 + +我们会讨论分布式训练系统实现的常用并行方法。我们首先给出并行方法的设计目标以及分类。然后,我们会详细描述各个并行方法。 + +### 概述 + +![单节点训练系统](figs/ch10/ch10-single-node.pdf){#fig:ch010/ch10-single-node} + +分布式训练系统的设计目标是:将单节点训练系统转化成**等价的**并行训练系统,从而在不影响模型精度的条件下完成训练过程的加速。一个单节点训练系统往往如图[1.3](#fig:ch010/ch10-single-node){reference-type="ref" +reference="fig:ch010/ch10-single-node"}所示。一个训练过程会由多个数据小批次(mini-batch)完成。在图中,一个数据小批次被标示为**数据**。训练系统会利用数据小批次来生成梯度,提升模型精度。这个过程由一个训练**程序**实现。在实际中,这个程序往往实现了一个多层神经网络的执行过程。 +该神经网络的执行由一个计算图(Computational +Graph)表达。这个图有多个相互连接的算子(Operator),每个算子会拥有计算参数。每个算子往往会实现一个神经网络层(Neural +Network Layer),而参数则代表了这个层在训练中所更新的的权重(Weights)。 + +为了更新参数,计算图的执行会分为**前向**传播和**反向**传播两个阶段。前向传播的第一步会将数据读入第一个算子,该算子会根据当前的参数,计算出传播给下一个算子的数据。算子依次重复这个前向传播的过程(算子1 +-\> 算子2 -\> +算子3),直到最后一个算子结束。最后的算子随之马上开始反向传播。反向传播中,每个算子依次计算出梯度(梯度3 +-\> 梯度2 -\> +梯度1),并利用梯度更新本地的参数。反向传播最终在第一个算子结束。反向传播的结束也标志本次数据小批次的结束,系统随之读取下一个小批次,继续更新模型。 + +::: {#tab:ch010/ch10-parallel-methods} + 单数据 多数据 + -------- ------------------------ ------------------------ + 单程序 单程序单数据:单点执行 单程序多数据:数据并行 + 多程序 多程序单数据:模型并行 多程序多数据:混合并行 + + : 分布式训练方法分类 +::: + +给定一个单节点训练系统,人们会对**数据**和**程序**分区(Partition),从而完成并行加速。表[1.1](#tab:ch010/ch10-parallel-methods){reference-type="ref" +reference="tab:ch010/ch10-parallel-methods"}总结了不同的切分方法。单节点训练系统可以被归类于 +单程序单数据模式。而假如用户希望使用更多的设备来实现并行计算,他们首先可以选择对数据进行分区,并将同一个程序复制到多个设备上并行执行。这种方式是单程序多数据模式,常被称为**数据并行**(Data +Parallelism)。另一种并行方式是对程序进行分区:程序的算子会被分发给多个设备按照依次完成。这种模式是 +多程序单数据模式,常被称为**模型并行**(Model +Parallelism)。当训练超大型智能模型时,开发人们往往要同时对数据和程序进行切分,从而实现最高程度的并行。这种模式是多程序多数据模式,常被称为**混合并行**(Hybrid +Parallelism)。 + +接下来,我们详细讲解各种并行方法的执行过程。 + +### 数据并行 + +![数据并行训练系统](figs/ch10/ch10-data-parallel.pdf){#fig:ch010/ch10-data-parallel} + +数据并行往往可以解决单节点的算力不足。这种并行方式在人工智能框架中最为常见,具体实现包括:TensorFlow +DistributedStrategy [@tensorflow_distributed]),PyTorch +Distributed [@pytorch_distributed],Horovod +DistributedOptimizer [@horovod_distributed]等。在一个数据并行系统中,假设用户给定一个训练批大小$N$,并且希望使用$M$个并行设备来加速训练。那么,该训练批大小会被分为$M$个分区,每个设备会分配到$N/M$个训练样本。这些设备共享一个训练程序的副本,在不同数据分区上独立执行,计算梯度。不同的设备(假设设备编号为$i$)会根据本地的训练样本估计出梯度$G_i$。为了确保训练程序参数的一致性,本地梯度$G_i$需要聚合,计算出平均梯度$(\sum_{i=1}^{N} G_i) / N$。最终,训练程序利用平均梯度修正模型参数,完成小批量的训练。 + +图[1.4](#fig:ch010/ch10-data-parallel){reference-type="ref" +reference="fig:ch010/ch10-data-parallel"}展示了2个设备构成的数据并行例子。假设用户给定的批大小(Batch +Size)是64,那么每个设备会分配到32个训练样本,并且具有相同的神经网络参数(程序副本)。本地的训练样本会依次通过这个程序副本中的算子,完成前向传播和反向传播。在反向传播的过程中,程序副本会生成局部梯度。不同设备上对应的局部梯度(如设备1和设备2上各自的梯度1)会进行聚合,从而计算平均梯度。这个聚合的过程往往由集合通讯库(Collective +Communication)的Allreduce操作来完成。 + +### 模型并行 + +![模型并行系统:算子内并行](figs/ch10/ch10-model-parallel-intra-op.pdf){#fig:ch010/ch10-model-parallel-intra-op} + +模型并行往往用于解决单节点的内存不足问题。一个常见的内存不足场景是模型中含有大型算子,例如说深度神经网络中需要计算大量分类的全连接层(Fully +Connected +Layer)。完成这种大型算子计算所需的内存可能超过单设备的内存容量。那么我们需要对这个大型算子进行切分。假设这个算子具有$P$个参数,而我们拥有$N$个设备,那么我们可以将$P$个参数平均分配给$N$个设备(每个设备分配$P/N$个参数),从而让每个设备负责更少的计算量,能够在内存容量的限制下完成前向传播和反向传播中所需的计算。这种切分方式是模型并行的应用,被称为**算子内并行**(Intra-operator +Parallelism)。 + +图[1.5](#fig:ch010/ch10-model-parallel-intra-op){reference-type="ref" +reference="fig:ch010/ch10-model-parallel-intra-op"}给出了一个由2个设备实现的算子内并行的例子。在这个例子中,假设一个神经网络具有2个算子,算子1的计算(包含正向和反向传播)需要预留16G的内存,算子2的计算需要预留1G的内存。而本例中的设备最多可以提供10G的内存。为了完成这个神经网络的训练,我们需要对算子1实现并行。具体做法是,将算子1的参数平均分区,设备1和设备2各负责其中部分算子1的参数。由于设备1和设备2的参数不同,因此它们各自负责程序分区1和程序分区2。在训练这个神经网络的过程中,数据(小批量)会首先传给算子1。由于算子1的参数分别由2个设备负责,因此数据会被广播给这2个设备。不同设备根据本地的参数分区完成前向计算,生成的本地计算结果需要进一步合并(Combine),发送给下游的算子2。在反向传播中,算子2的数据会被广播给设备1和设备2,这些设备根据本地的算子1分区各自完成局部的反向计算。计算结果进一步合并传播回数据,最终完成反向传播。 + +另一种内存不足的场景是:模型的总内存需求超过了单设备的内存容量。在这种场景下,假如我们总共有$N$个算子和$M$个设备,我们可以将算子平摊给这$M$个设备,让每个设备仅需负责$N/M$个算子的前向和反向计算,降低设备的内存开销。这种并行方式是模型并行的另一种应用,被称为**算子间并行**(Inter-operator +Parallelism)。 + +![模型并行系统:算子间并行](figs/ch10/ch10-model-parallel-inter-op.pdf){#fig:ch010/ch10-model-parallel-inter-op} + +图[1.6](#fig:ch010/ch10-model-parallel-inter-op){reference-type="ref" +reference="fig:ch010/ch10-model-parallel-inter-op"}给出了一个由2个设备实现的算子间并行的例子。在这个例子中,假设一个神经网络具有2个算子,算子1和算子2各自需要10G的内存完成计算,则模型总共需要20G的内存。而每个设备仅能提供10G内存。在这个例子中,用户可以把算子1放置在设备1上,算子2放置在设备2上。在前向传播中,算子1的输出会被发送(Send)给下游的设备2。设备2接收(Receive)来自上游的数据,完成算子2的前向计算。在反向传播中,设备2将算子2的反向计算结果发送给设备1。设备1完成算子1的反向计算,完成本次训练。 + +### 混合并行 + +![混合并行系统](figs/ch10/ch10-hybrid-parallel.pdf){#fig:ch010/ch10-hybrid-parallel} + +在训练大型人工智能模型中,我们往往会同时面对算力不足和内存不足。因此,我们需要混合使用数据并行和模型并行,这种方法被称为混合并行。图[1.7](#fig:ch010/ch10-hybrid-parallel){reference-type="ref" +reference="fig:ch010/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副本结束。 + +## 流水线并行 + +在数据并行和模型并行以外,流水线并行是另一种常用的并行加速方法。 +流水线并行往往被应用在大型模型并行系统中。这种系统通过算子内并行和算子间并行解决单设备内存不足的问题。 +然而,当这类系统的运行中(如图[1.5](#fig:ch010/ch10-model-parallel-intra-op){reference-type="ref" +reference="fig:ch010/ch10-model-parallel-intra-op"}和图[1.6](#fig:ch010/ch10-model-parallel-inter-op){reference-type="ref" +reference="fig:ch010/ch10-model-parallel-inter-op"}所示),计算图中的下游设备需要长期持续处于空闲状态,等待上游设备的计算完成,才可以开始计算,这极大降低了设备的平均使用率。这种现象被称为模型并行空洞(Model +Parallelism Bubble)。 + +![流水线并行系统。**注意!图的F和B任务的编号需要更新!**](figs/ch10/ch10-pipeline-parallel.pdf){#fig:ch010/ch10-pipeline-parallel +width="\\linewidth"} + +为了减少空洞,提升设备使用率,我们可以在模型并行系统中构建流水线。这种做法的核心想法是将一个数据小批量(Data +Mini-batch)划分为多个微批量(Micro-batch)。假设一个数据小批量有$D$个训练数据,这个小批量可以被划分为$M$个微批量,那么微批量的大小就是$D/M$。每个微批量相应进入训练系统,完成前向传播(Forwards +propagation)和反向传播(Backwards +propagation),计算出梯度。每个微批量对应的梯度将会缓存,等到全部微批量完成,缓存的梯度会被加和,算出平均梯度,更新模型参数。 + +图[1.8](#fig:ch010/ch10-pipeline-parallel){reference-type="ref" +reference="fig:ch010/ch10-pipeline-parallel"}进一步给出了一个流水线并行的执行例子。在本例中,模型参数需要切分给4个设备存储。为了充分利用起来这4个设备,我们将小批量切分为2个微批量。当设备1完成第一个微批量的前向传播后(表示为$F_{0,0}$)后,他会将中间结果发送给设备2,触发响应的前向传播任务(表示为$F_{1,0}$)。与此同时,设备1也可以开始第二个微批量的前向传播任务(表示为$F_{0,1}$)。前向传播会在流水线的最后一个设备--设备3--完成。系统于是开始反向传播。设备4开始第1个微批量的反向传播任务(表示为$B_{3,0}$)。该任务完成后的中间结果会被发送给设备3,触发响应的反向传播任务(表示为$B_{2,0}$)。与此同时,设备4会缓存好对应第1个微批量的梯度,接下来开始第2个微批量计算(表示为$B_{3,1}$)。当设备4完成了全部的反向传播计算后,他会将本地缓存的梯度进行相加,并且除以微批量数量,计算出平均梯度,该梯度用于更新模型参数。 + +流水线并行的关键因素是流水线泡沫(Bubble)。当设备完成前向传播后,必须等到全部反向传播开发,在此期间设备会处于空闲状态。在图[1.8](#fig:ch010/ch10-pipeline-parallel){reference-type="ref" +reference="fig:ch010/ch10-pipeline-parallel"}中,我们可以看到设备1在完成2个前向传播任务后,要等很多时间才能开始2个传向传播任务。这其中的等待时间即被称为泡沫。为了减少设备的等待时间,一种常见的做法是尽可能的增加微批量的数量,从而让反向传播尽可能早的开始。然而,使用非常小的微批量大小,可能会造成加速器无法被充分利用。因此最优的微批量大小是多种因素的折中。其中最核心的因素是流水线泡沫的大小和加速器的计算能力。 + +## 集合通讯的高效实现 + +接下来,我们会讲解常见的大型深度模型训练的系统实现。 +这一类系统往往部署在商用的数据中心(Data +Centers),以及如何在数据中心中高效实现集合通讯,从而让分布式训练系统免于网络瓶颈。 + +### 梯度计算和数据中心网络 + +图[1.9](#fig:ch010/ch10-datacentre){reference-type="ref" +reference="fig:ch010/ch10-datacentre"}描述了一个典型的用于深度学习模型训练的数据中心。数据中心中的训练服务器一般会有多个设备。如需增加服务器,我们会将多个训练服务器放置在一个机柜(Rack)上,同时接入一个架顶交换机(Top +of Rack +Switch)来连接多个服务器。当一个机柜满的时候,我们可以通过在架顶交换机之间增加骨干交换机(Spine +Switch),接入新的机柜。通过这种方式,我们可以在数据中心内不断增加服务器,从而为神经网络的训练提供海量的算力和内存。目前的商用数据中心可能拥有超过一百万台服务器。 + +![数据中心](figs/ch10/ch10-datacentre.pdf){#fig:ch010/ch10-datacentre} + +在数据中心中训练大型神经网络的首要挑战是:如何高效计算大量的平均梯度。假设给定一个千亿级别参数的神经网络(GPT-3模型含有1750亿参数),如果用32位浮点数来表达每一个参数,那么每一步训练中,一个数据并行模式下的模型副本(Model +replica)就需要生成700GB的本地梯度数据(即 175G $\times$ 4 bytes = +700GB)。假如我们有3个模型副本,那么至少需要传输1.4TB(即,700GB +$\times$ +$(3-1)$)的本地梯度(这是因为$N$个副本中,我们只需要传送$N-1$梯度来完成平均梯度计算)。当平均梯度计算完成后,我们需要进一步将平均梯度广播到全部的模型副本(即1.4TB的数据),更新本地参数,从而确保模型副本不会偏离(Diverge)。 + +当前的数据中心往往使用以太网(Ethernet)构建网络。主流的商用以太网链路带宽一般是10Gbps和25Gbps。利用以太网传输海量梯度会产生严重的传输延迟,从而降低模型训练的速度。新型深度学习训练集群(如英伟达的DGX系列机器)往往配置有更快的Inifiband。单个InfiniBand链路可以提供100Gbps和200Gbps的带宽。即使拥有这种高速网络,传输TB级别的本地梯度依然需要大量延迟(1TB的数据需要在200Gbps的链路上传输25秒)。 + +为了避免通过网络传输数据,现代深度学习服务器一般都会配备多个计算设备(例如说,DGX-3机器会被配备8个A100 +GPU),而在一个服务器内的多个设备可以通过高速机内网络互联(如NVLink)。这种高速机内网络可以提供高达400GB/s的带宽,从而让传输TB级别数成为可能。然而,受限于单个服务器的散热,成本和硬件故障等需求,在一个服务器内我们无法无限制的持续增加设备,大型深度学习模型的训练最终还是需要多个服务器共同完成。因此,计算平均梯度需要同时借助以太网或者是InfiniBand,以及服务器内部的NVLink等机内网络。 + +### 高效梯度计算:Allreduce算法 + +为了在数据中心中高效完成梯度平均的操作,我们往往会实现 +Allreduce算法。这个算法诞生的背景是:传统计算平均梯度的方法往往是在集群中找出一个设备来收集本地梯度,计算平均梯度,然后再将平均梯度广播到全部的设备。这种做法易于实现,但是其引入了两个问题。首先,多设备共同给这个聚合设备发送数据的时候,在聚合设备上往往会产生严重的带宽不足和网络拥塞。其次,单设备需要负担大量的梯度平均的计算,而受限于单设备上的有限算力,这种平均计算会受限于算力瓶颈。 + +![Allreduce初始状态和终止状态](figs/ch10/ch10-allreduce-state.pdf){#fig:ch010/ch10-allreduce-state +width="\\linewidth"} + +为了解决上述问题,人们设计了Allreduce算法。该算法的核心设计思路是:让全部的节点参与进来平均梯度的网络通信和平均计算中,从而将巨大的网络和算力开销均摊给全部节点,从而解决使用单个梯度聚合节点的问题。假设我们有$M$个设备,每个设备有一个模型副本,该模型由$N$个参数构成。那么按照Allreduce算法要求,我们需要首先将全部的参数按照设备数量切分成$M$个分区(Partition),每个分区具有$N/M$个参数。 +为了讲解Allreduce的过程,我们首先给出这个算法的初始和终止状态。如图[1.10](#fig:ch010/ch10-allreduce-state){reference-type="ref" +reference="fig:ch010/ch10-allreduce-state"} +所示,该例子含有3个设备,每个设备有一个模型副本,这个副本有3个参数。那么按照Allreduce的分区方法,参数会被划分成3个分区(3个设备),而每一个分区有1个参数($N/M$,N代表3个参数,M代表3个设备)。在这个例子中,假定设备1拥有参数2,4,6,设备2拥有参数1,2,3,设备3拥有参数4,8,12,那么Allreduce结束后,全部的设备都拥有梯度相加后的结果7,14,21,其中分区1的结果7是由3个设备中分区1的初始结果相加而成(7 += 1 + 2 + +4)。为了计算平均梯度,每个设备只需要在最后将梯度之和除以设备数量即可(分区1的最终结果为7除以3)。 + +Allreduce算法会把梯度的加和计算拆分成$M-1$个Reduce步骤和$M-1$个Broadcast步骤(其中$M$是节点的数量)。Reduce步骤是为了计算出梯度的和(Summation),Broadcast步骤是为了把梯度之和广播给全部的节点。为了说明这些步骤的执行过程,我们利用 +图[1.11](#fig:ch010/ch10-allreduce-process){reference-type="ref" +reference="fig:ch010/ch10-allreduce-process"}。Allreduce算法由Reduce步骤开始,在第一个Reduce步骤中,Allreduce算法会对全部节点进行配对(Pairing),让他们共同完成梯度相加的操作。在图[1.11](#fig:ch010/ch10-allreduce-process){reference-type="ref" +reference="fig:ch010/ch10-allreduce-process"}的第一个Reduce步骤中,设备1和设备2进行了配对共同对分区1的数据相加。其中,设备2把本地的梯度数据1发送给设备1,设备将接收到1和本地的分区1内的梯度数据:2进行相加,计算出中间(intermediate)梯度相加的结果:3。于此同时,设备1和设备3进行配对,共同完成对分区3的数据相加。而设备3和设备2进行配对,共同完成对于分区2的数据相加。 + +在上述Reduce的步骤中,梯度的计算实现了以下几个特性: + +- **网络优化:** + 全部设备都同时在接收和发送数据,利用起了每个设备的入口(Ingress)和出口(Egress)带宽。因此Allreduce过程中可利用的带宽是$M \times B$,其中$M$是节点数量, + $B$是节点带宽,从而让系统实现网络带宽上的可扩展性。 + +- **算力优化:** + 全部设备的处理器都参与了梯度相加的计算。。因此Allreduce过程中可利用的处理器是$M \times P$,其中$M$是节点数量, + $P$是处理器数量,从而让系统实现计算上的可扩展性。 + +- **负载均衡:** + 由于数据分区是平均划分的,因此每次设备分摊到的通讯和计算开销是相等的。 + +在接下来的Reduce步骤中,Allreduce算法会对不同数据分区选择另外的配对方法。例如说,在图[1.11](#fig:ch010/ch10-allreduce-process){reference-type="ref" +reference="fig:ch010/ch10-allreduce-process"}的第二个Reduce步骤中,Allreduce算法会将:设备1和设备3进行配对,负责分区1的数据相加。将设备1和设备2进行配对,负责分区2。将设备2和设备3进行配对,负责分区3。在一个3个节点的Allreduce集群里,在2个Reduce步骤完成后,我们就计算出了每个分区的数据相加结果(分区1的结果7此时在设备3上,分区2的结果14此时在设备1上,分区3的结果21此时在设备2上)。 + +![Allreduce算法的过程](figs/ch10/ch10-allreduce-process.pdf){#fig:ch010/ch10-allreduce-process +width="\\linewidth"} + +接下来,Allreduce算法将进入Broadcast阶段。这一阶段的过程和Reduce步骤类似,核心区别是节点进行配对后,他们不再进行数据相加,而是将Reduce的计算结果进行广播。在图[1.11](#fig:ch010/ch10-allreduce-process){reference-type="ref" +reference="fig:ch010/ch10-allreduce-process"}中的第一个Broadcast步骤中,设备1会将分区2的结果14直接写入设备3的分区2中。设备2会讲分区3的结果21直接写入设备1中。设备3会将分区1的结果直接写入设备2中。在一个3个节点的Allreduce集群中,我们会重复2次Broadcast步骤来将每个分区的Reduce结果告知全部的节点。 + +Allreduce算法已经被常见的分布式训练框架(包括Horovod, KungFu, TensorFlow +distributed, PyTorch +distributed)等支持。当用户选择使用数据并行模式的过程,其底层会默认触发。 + +## 参数服务器 + +接下来,我们介绍另一种常见的分布式训练系统实现:参数服务器。TensorFlow原生提供了参数服务器的实现。而其他框架,例如PyTorch和MindSpore,则需要用户使用第三方的参数服务器实现,例如PS-Lite。 + +### 计算和存储分离 + +利用参数服务器的其中一个核心需求是实现:计算和存储的分离。在训练模型中,计算可以被理解为计算更新模型参数所需要的计算(例如说,计算本地梯度和计算平均梯度),而存储可以被理解为将模型参数存储在内存设备中(例如说,主机内存,加速卡内存和SSD设备)。传统的神经网络训练中,计算往往是核心瓶颈,因此我们只需要配置有合适数量的带有加速卡的服务器,常被称为训练服务器(Training +servers)。 + +随着机器学习的发展,新型的稀疏模型被开发出来。相比于传统的神经网络训练,稀疏模型的训练往往不需要大量昂贵的计算加速卡(GPU),而需要海量的内存来存储嵌入表(Embedding +table)。例如说,一个大型深度学习推荐系统中,它们往往使用小型的深度神经网络(如Multi-layer +Perception),训练这种神经网络只需要几个GPU即可。而另一方面,推荐系统中往往需要存储PB级别的嵌入表。嵌入表往往由推荐系统的用户特征(User +feature)和产品特征(Item +feature)构成。这些特征往往是大型向量(Vector)。现代推荐系统需要服务数亿的用户,推荐数以千万的商品。假设用户的特征是1MB,而系统需要服务10亿的用户,那么用户的嵌入表就会有1PB的大小。而这个大小远远超过了一个深度学习服务器所具有的内存。假如我们部署大量的昂贵的深度学习服务器来存储海量嵌入表,那么这些服务器上的加速卡的使用率将会极低,无法实现对于硬件的高效利用。 + +![参数服务器](figs/ch10/ch10-parameter-servers.pdf){#fig:ch010/ch10-parameter-servers +width="0.63\\linewidth"} + +为了解决上述问题,人们往往会在稀疏模型集群中混合部署:训练服务器和参数服务器,从而实现对于计算需求和内存需求分别满足。图[1.12](#fig:ch010/ch10-parameter-servers){reference-type="ref" +reference="fig:ch010/ch10-parameter-servers"}描述了带有参数服务器的机器学习集群。这个集群中含有2个训练服务器和2个参数服务器,训练服务器一般是拥有加速卡的计算优化服务器(Compute-optimised +server)。而参数服务器一般是内存优化服务器(Memory-optimised +server),其的内存大小一般远远大于计算优化服务器。在一个稀疏模型中往往拥有神经网络参数和嵌入表参数。神经网络较小,其可以存储在训练服务器内存中。而嵌入表很大,因此需要存储在额外的参数服务器中。参数服务器一般会按照键-值对(Key-value +pairs)的方式来存储参数。常用的键包括用户名(User ID),产品名(Item +ID)或者是参数名(Parameter +Key)。常用的值是以多维度向量(Multi-dimensional +tensors)表达的模型参数。假如存在多个参数服务器,参数服务器会用数据分区函数(例如,哈希函数和区域划分)将健-值映射到不同参数服务器上。 + +为了完成对于模型的训练,在每一步训练中,训练服务器会根据当前的小批量训练数据,找到本批量中需要用到的参数。例如说,本小批量数据只会训练部分用户的特征,那么这些用户的特征才会需要。根据参数服务器的数据分区函数,训练服务器可以知道参数当前在哪个参数服务器上,它们因此会用参数的键(Key)向对应的参数服务器发起拉取请求(Pull +request)。参数服务器响应,并返回对应的值(Value)。训练服务器将拉取的参数(往往是嵌入表)和本地内存中的模型参数(往往是神经网络)进行合并,从而对合并的模型进行训练,计算梯度。假如训练服务器实现了数据并行,那么训练服务器计算出的本地梯度需要利用Allreduce计算出平均梯度。对于训练服务器本地内存中的参数,训练服务器可以马上利用平均梯度进行修改。对于在参数服务器中存储的参数,训练服务器发起推送请求(Push +request)将平均梯度发送到参数服务器,参数服务器更新本地存储的参数。 + +在以上的参数服务器架构中,机器学习集群拥有者可以灵活的根据梯度计算所需要算力配置合理数量的训练服务器。他们也可以根据参数的数量配置大部分的稀疏参数(Sparse +parameters)在参数服务器中,仅留下小部分的密集参数(Dense +parameters)在训练服务器中。密集参数和稀疏参数的核心区别是:稀疏参数在每一步训练不一定都会被用到,他们需要根据当前训练小批量来决定。而密集参数每一步训练都需要用到。因此为了频繁从参数服务器中拉取,密集参数往往会存储在训练服务器中。 + +### 数据副本 + +在参数服务器的实际部署中,人们往往需要解决数据热点问题。互联网数据往往符合幂律概率(Power-law +distribution),这会导致部分稀疏参数在训练过程中被访问的次数会显著高于其他参数。例如说,热门商品的特征向量被训练服务器拉取的次数就会远远高于非热门商品。因此,存储了热门数据的参数服务器所承受的数据拉取和推送请求会远远高于其他参数服务器,因此形成数据热点,伤害了系统的可扩展性。 + +解决数据热点问题的关键是利用在没有副本的情况下,通用的做法是每隔一段时间将所有参数在外存中保存一份检查点(checkpoint)。当出现机器故障时,首先所有的训练必须停止,等待故障的机器恢复上线,然后从外存中重新加载检查点。这就会导致从上一次保存检查点到故障发生时的数据全部丢失。保存一次检查点的开销随模型大小而增加,训练大模型时通常每隔1-2小时保存一次。因此无副本的参数服务器如果发生故障,会丢失最多1-2小时的数据。 + +解决参数服务器故障和数据热点问题的常用技术是构建模型主从副本。(Master-slave +replication)。一份参数在多个机器上拥有副本,并指定其中一个副本作为主副本。训练服务器的所有更新操作都向主副本写入并同步至从副本上。如何取得共识确定哪一个副本是主副本是分布式系统领域一个经典问题,已经有了相当多的成熟的算法,例如Paxos[@lamport2001paxos],RAFT[@184040],ZooKeeper[@hunt2010zookeeper]等。此外,主副本上的更新如何复制到从副本上也同样是分布式系统领域的经典共识问题。通常系统设计者需要在可用性(Availability)和一致性(Consistency)之间做出取舍。如果参数服务器副本间采用强一致性的复制协议(例如,链式复制[@li2014scaling])则可能导致训练服务器的推送请求失败,即参数服务器不可用。反之,如果参数服务器采用弱一致性的复制协议([@yu2020weips]),则可能导致副本间存储的参数不一致。 + +### 掉队者问题 + +参数服务器的另一大核心作用是可以让用户方便解决掉队者问题。在之前的讨论中,在每一步训练结束后,训练服务器都需要计算平均梯度来对每一个模型副本进行更新,从而保证下一步训练开始前,全部模型副本的参数的一致性,这种对于参数一致性的确保一般被称为同步训练(Synchronous +training)。同步训练一般会有助于训练系统达到更好的模型精度,但是当系统规模变大,我们往往会在系统中引入掉队者(Straggler)。掉队者出现的原因很多。常见的原因包括:掉队者设备可能和其他设备不在同一个机柜中,因此掉队者的通讯带宽显著小于其他设备。另外,掉队者设备也可能和其他进程共享本地的服务器计算和通讯资源,形成资源竞争,从而降低了性能。 + +掉队者对于基于Allreduce的同步训练系统的性能有显著影响,这是因为Allreduce让全部节点参与到平均梯度的计算和通讯中,而每个节点负责等量的数据。因此任何一个掉队者的出现,都会让整个Allreduce操作延迟完成。为了解决这个问题,人们也会使用参数服务器来计算平均梯度。一种常见的设计是:训练服务器训练出本地梯度后,会把本地梯度全部推送到参数服务器。参数服务器在等到一定数据训练服务器(例如说90%的训练服务器)的本地梯度后,就开始计算平均梯度。这样可以确保平均梯度的计算不会被落后者的出现延误。计算好的平均梯度马上推送给全部训练服务器,开始下一轮训练。 +0 +解决掉队者的另外一种常见做法是利用参数服务器实现**异步训练**(Asynchronous +training)。在一个异步训练系统中,每个训练服务器在训练开始时,有相同的模型参数副本。在训练中,他们计算出本地梯度后会马上将本地梯度推送到参数服务器,参数服务器将推送的梯度立刻用于更新参数,并把更新好的参数马上推送回对应的训练服务器。在这个过程中,不同的训练服务器很可能会使用不同版本的模型参数进行本地梯度的计算,这种做法有可能会伤害模型的精度,但它同时让不同训练服务器可以按照各自的运算速度来推送和拉取参数,而无需等待同伴,因此避免了掉队者对于整个集群性能的影响。 + +## 总结 + +- 大型机器学习模型的出现带来了对于算力和内存需求的快速增长,催生了分布式训练系统的出现。 + +- 分布式训练系统的设计往往遵循"分而治之"的设计思路。 + +- 利用分布式训练系统,人们可以显著提升性能性能,经济性,并且帮助抵御硬件故障。 + +- 分布式训练系统可以通过数据并行增加设备来提升算力。 + +- 当单节点内存不足时,我们可以通过模型并行来解决单设备内存不足。模型并行有两种实现方式:算子内并行和算子间并行。 + +- 大型模型并行系统容易出现设备使用空洞,而这种空洞可以通过流水线并行解决。 + +- 分布式训练系统往往运行在商用数据中心之中,数据中心网络无法提供充足的网络带宽来传输大量训练中生成的梯度。 + +- 为了提供海量的带宽,机器学习集群拥有异构的网络:以太网,机内网络(NVLink)和InfiniBand。 + +- 为了解决单节点瓶颈,我们可以使用 + Allreduce来分摊梯度聚合过程中的计算和通讯开销。 + +- 参数服务器可以帮助机器学习集群实现计算-存储的分离,从而更好的支持大型稀疏模型。 + +- 参数服务器常用数据副本技术解决数据热点问题,同时它们也可以被用来解决同步训练系统中常见的掉队者问题。 diff --git a/chapter_model_deployment/index.md b/chapter_model_deployment/index.md index 89e53f0..efc9e1e 100644 --- a/chapter_model_deployment/index.md +++ b/chapter_model_deployment/index.md @@ -1,10 +1,27 @@ # 模型部署 +在前面的章节中,我们讲述了机器学习模型训练系统的基本组成,这一章节我们将讲述模型部署的相关知识。模型部署是将训练好的模型部署到运行环境中进行推理的过程,模型部署的过程中需要解决训练模型到推理模型的转换,硬件资源对模型的限制,模型推理的时延、功耗、内存占用等指标对整个系统的影响以及模型的安全等一系列的问题。 + +本章将主要介绍机器学习模型部署的主要流程,包括训练模型到推理模型的转换、适应硬件限制的模型压缩技术、模型推理及性能优化以及模型的安全保护,最后我们会给出一个模型部署端到端的实践用例。 + +本章的学习目标包括: + +- 了解训练模型到推理模型转换及优化 + +- 掌握模型压缩的常用方法:量化、稀疏和知识蒸馏 + +- 掌握模型推理的流程及常用的性能优化的技术 + +- 了解模型安全保护的常用方法 ```toc :maxdepth: 2 -:numbered: - +model_deployment_introduction +model_converter_and_optimizer +model_compression +model_inference +model_security +summary ``` \ No newline at end of file diff --git a/chapter_model_deployment/model_compression.md b/chapter_model_deployment/model_compression.md new file mode 100644 index 0000000..69908ff --- /dev/null +++ b/chapter_model_deployment/model_compression.md @@ -0,0 +1,122 @@ +## 模型压缩 +:label:`ch08-sec-model_compression` + +在上一小节中,我们简要介绍了模型转换的目的,并重点讲述了模型部署时的一些常用的模型优化手段。考虑到不同场景的硬件对模型的要求不同,比如部署在手机上,对于模型的大小比较敏感,一般在兆级别。因此,对于一些较大的模型,我们往往需要通过一些模型压缩的技术,使其能满足不同计算硬件的要求。 + +### 量化 + +模型量化是指以较低的推理精度损失将连续取值(通常为FP32或者大量可能的离散值)的浮点型权重或者通过各个算子的数据定点近似(通常为INT8)为有限多个离散值的过程,如 :numref:`ch08-fig-quant_minmax`,T是量化前的数据范围。通过以更少的位数表示浮点数据,模型量化可以减少模型尺寸,进而减少在推理时的内存消耗,并且在一些低精度运算较快的处理器上可以增加推理速度。 + +![量化原理](../img/ch08/quant-minmax.png) +:width:`300px` +:label:`ch08-fig-quant_minmax` + +计算机中不同数据类型的占用比特数及其表示的数据范围各不相同。可以根据实际业务需求将原模型量化成不同比特数的模型,一般深度神经网络的模型用单精度浮点数表示,如果能用有符号整数来近似原模型的参数,那么被量化的权重参数存储大小就可以降到原先的四分之一,用来量化的比特数越少,量化后的模型压缩率越高。工业界目前最常用的量化位数是8比特,低于8比特的量化被称为低比特量化。1比特是模型压缩的极限,可以将模型压缩为1/32,在推理时也可以使用高效的XNOR和BitCount位运算来提升推理速度。 + +另外,根据量化数据表示的原始数据范围是否均匀,还可以将量化方法分为线性量化和非线性量化。实际的深度神经网络的权重和激活值通常是不均匀的,因此理论上使用非线性量化能够达到更高的精度,但在实际推理中非线性量化的计算复杂度较高,通常使用线性量化。下面着重介绍线性量化的原理。 + +假设r表示量化前的浮点数,量化后的整数q可以表示为: + +$$q=clip(round(\frac{r}{s}+z),q_{min},q_{max})$$ + +$round(\cdot)$和$clip(\cdot)$分别表示取整和截断操作,$q_{min}$和$q_{max}$是量化后的最小值和最大值。$s$是数据量化的间隔,$z$是表示数据偏移的偏置,$z$为0的量化被称为对称(Symmetric)量化,不为0的量化称为非对称(Asymmetric)量化。对称量化可以避免量化算子在推理中计算z相关的部分,降低推理时的计算复杂度;非对称量化可以根据实际数据的分布确定最小值和最小值,可以更加充分的利用量化数据信息,使得计算精度更高。 + +根据量化参数$s$和$z$的共享范围,量化方法可以分为逐层量化和逐通道量化。逐层量化以一层网络为量化单位,每层网络的一组量化参数;逐通道量化以一层网络的每个量化通道为单位,每个通道单独使用一组量化参数。逐通道量化由于量化粒度更细,能获得更高的量化精度,但计算也更复杂。 + +根据量化过程中是否需要训练,可以将模型量化分为量化感知训练(Quantization Aware Training, QAT)和训练后量化(Post Training Quantization, PTQ)两种,其中感知量化训练是指在模型训练过程中加入伪量化算子,通过训练时统计输入输出的数据范围可以提升量化后模型的精度,适用于对模型精度要求较高的场景;训练后量化指对训练后的模型直接量化,只需要少量校准数据,适用于追求高易用性和缺乏训练资源的场景。 + +#### 量化感知训练 + +量化感知训练是在训练过程中模拟量化,利用伪量化节点将量化带来的精度变化计入训练误差,使得优化器能在训练过程中尽量减少量化误差,得到更高的模型精度。量化感知训练的具体流程如下: + +- 初始化:设置权重和激活值的范围$q_{min}$和$q_{max}$的初始值; + +- 构建模拟量化网络:在需要量化的权重和激活值后插入伪量化节点; + +- 量化训练:重复执行以下步骤直到网络收敛,计算量化网络层的权重和激活值的范围$q_{min}$和$q_{max}$,前向计算反向传播更新网络权重参数; + +- 导出量化网络:获取$q_{min}$和$q_{max}$,并计算量化参数$s$和$z$; + 根据公式计算权重的量化整数值,并替换对应网络层的参数和数据类型; + 删除伪量化节点,在量化网络层前后分别插入量化和反量化算子。 + +#### 训练后量化 + +训练后量化也可以分成两种,权重量化和全量化。权重量化仅量化模型的权重以压缩模型的大小,在推理时将权重反量化为原始的FP32数据,后续推理流程与普通的FP32模型一致。权重量化的好处是不需要校准数据集,不需要实现量化算子,且模型的精度误差较小,由于实际推理使用的仍然是FP32算子,所以推理性能不会提高。全量化不仅会量化模型的权重,还会量化模型的激活值,在模型推理时执行量化算子来加快模型的推理速度、为了量化激活值,需要用户提供一定数量的校准数据集用于统计每一层激活值的分布,并对量化后的算子做校准。校准数据集可以来自训练数据集或者真实场景的输入数据,需要数量通常非常小。在做训练后量化时会以校准数据集为输入,执行推理流程然后统计每层激活值的数据分布并得到相应的量化参数,具体的操作流程如下: + +- 使用直方图统计的方式得到原始FP32数据的统计分布$P_f$; + +- 在给定的搜索空间中选取若干个$q_{min}$和$q_{max}$分别对激活值量化,得到量化后的数据$Q_q$; + +- 使用直方图统计得到$Q_q$的统计分布; + +- 计算每个$Q_q$与$P_f$的统计分布差异,并找到差异性最低的一个对应的$q_{min}$和$q_{max}$来计算相应的量化参数,常见的用于度量分布差异的指标包括KL散度(Kullback-Leibler Divergence)、对称KL散度(Symmetric Kullback-Leibler Divergence)和JS散度(Jenson-Shannon Divergence)。 + +除此之外,由于量化存在固有误差,还需要校正量化误差。以矩阵乘为例,$a=\sum_{i=1}^Nw_ix_i+b$,w表示权重,x表示激活值,b表示偏置。首先需要对量化的均值做校正,对fp32算子和量化算子输出的每个通道求平均,假设某个通道i的fp32算子输出均值为$a_i$,量化算子反量化输出均值为$a_{qi}$,将这个通道两个均值的差$a_i-a_q$加到对应的通道上即可使得最终的输出均值和fp32一致。另外还需要保证量化后的分布和量化前是一致的,设某个通道权重数据的均值、方差为$E(w_c)$、$||w_c-E(w_c)||$,量化后的均值和方差为$E(\hat{w_c})$、$||\hat{w_c}-E(\hat{w_c})||$,对权重做如下校正: +$$\hat{w_c}\leftarrow\zeta_c(\hat{w_c}+u_c)$$ +$$u_c=E(w_c)-E(\hat{w_c})$$ +$$\zeta_c=\frac{||w_c-E(w_c)||}{||\hat{w_c}-E(\hat{w_c})||}$$ + +量化方法作为一种通用的模型压缩方法,可以大幅提升神经网络存储和压缩的效率,已经取得了广泛的应用。 + +### 模型稀疏 + +模型稀疏是通过去除神经网络中部分组件(如权重、特征图、卷积核)降低网络的存储和计算代价,它和模型权重量化、权重共享、池化等方法一样,属于一种为达到降低模型计算复杂度的目标而引入的一种强归纳偏置。 + +#### 模型稀疏的动机 + +因为卷积神经网络中的卷积计算可以被看作输入数据和卷积核中权重的加权线性组合,所以细小的权重对输出数据就具有相对较小的影响。对模型进行稀疏操作的合理性主要来源于两方面的假设: + +- 其一,针对权重参数来说,当前许多神经网络模型存在过参数化(Over-parameterized)的现象,动辄具有几千万甚至数亿规模的参数量。 + +- 其二,针对模型推理过程中生成的激活值特征图,对于许多检测、分类、分割等视觉任务来说激活值特征图中能利用的有效信息相对于整张图仅占较小的比例。 + +根据以上描述按照模型稀疏性来源的不同,主要分为权重稀疏和激活值稀疏,它们的目的都是为了减少模型当中的冗余成分来达到降低计算量和模型存储的需求。具体来说,对模型进行稀疏就是根据模型的连接强弱程度(一般根据权重或激活的绝对值大小),对一些强度较弱的连接进行剪枝(将权重参数或激活值置为0)来达到模型稀疏并提高模型推理性能的目的。特别地,我们将模型权重或激活值张量中0值所占的比例称为模型稀疏度。一般而言,模型稀疏度越高带来的模型准确率下降越大,因此我们的目标是尽可能在提高模型稀疏度的同时保证模型准确率下降较小。 + +实际上,如同神经网络本身的发明受到了神经生物学启发一样,神经网络模型稀疏方法同样受到了神经生物学的启发。在一些神经生物学的发现中,人类以及大多数哺乳动物的大脑都会出现一种叫做突触修剪的活动。突触修剪即神经元的轴突和树突发生衰退和完全死亡,这一活动发生在哺乳动物的婴幼儿时期,然后一直持续到成年以后。这种突触修剪机制不断简化和重构哺乳动物大脑的神经元连接,使得哺乳动物的大脑能以更低的能量获得更高效的工作方式。 + +#### 结构与非结构化稀疏 + +首先我们考虑权重稀疏,对于权重稀疏来说,按照稀疏模式的不同,主要分为结构化和非结构化稀疏。简单来讲,结构化稀疏就是在通道或者卷积核层面对模型进行剪枝。这种稀疏方式能够得到规则且规模更小的权重矩阵,因此比较适合CPU和GPU进行加速计算。但与此同时,结构化稀疏是一种粗粒度的稀疏方式,将会对模型的推理准确率造成较大的下降。 + +而非结构化稀疏,可以对权重张量中任意位置的权重进行裁剪,因此这种稀疏方式属于细粒度的稀疏。这种稀疏方式相对于结构化稀疏,造成的模型准确率下降较小。但是也正是因为这种不规则的稀疏方式,导致稀疏后的模型难以利用硬件获得较高的加速比。其背后原因主要有以下几点: + +- 不规则排布的模型权重矩阵会带来大量的控制流指令,比如由于大量0值的存在,我们会不可避免地引入大量if-else分支判断指令,因此会降低指令层面的并行度。 + +- 权重矩阵的不规则内存排布会造成线程发散和负载不均衡,而不同卷积核往往是利用多线程进行计算的,因此这也影响了线程层面的并行度。 + +- 权重矩阵的不规则内存排布造成了较低的访存效率,因为它降低了数据的局部性以及缓存命中率。 + +为了解决以上非结构化稀疏带来的种种问题,近期出现的研究当中通过引入特定稀疏模式将结构化稀疏和非结构化稀疏结合了起来,从而一定程度上兼具结构化和非结构化稀疏的优点并克服了两者的缺点。 + +#### 稀疏策略 + +明确了模型稀疏的对象之后,我们需要确定模型稀疏的具体策略,具体来说我们需要决定何时对模型进行稀疏以及如何对模型进行稀疏。目前最常见模型稀疏的一般流程为:预训练、剪枝、微调。具体而言,我们首先需要训练得到一个收敛的稠密模型,然后在此基础上进行稀疏和微调。选择在预训练之后进行稀疏动作的原因基于这样一个共识,即预训练模型的参数蕴含了学习到的知识,继承这些知识然后进行稀疏得到的模型效果要比从头开始训练好。除了基于预训练模型进行进行一步修剪之外,训练和剪枝交替进行也是一种常用的策略。相比于一步修剪的方法,这种逐步的修剪方式,使得训练和剪枝紧密结合,可以更有效地发现冗余的卷积核,被广泛采用于现代神经网络剪枝方法中。 + +以下通过一个具体实例(Deep Compression([@han2015deep])) +来说明如何进行网络修剪:如 :numref:`ch08-fig-deepcomp`所示,在去掉大部分的权值之后,深度卷积神经网络的精度将会低于其原始的精度。对剪枝后稀疏的神经网络进行微调,可以进一步提升压缩后网络的精度。剪枝后的模型可以进一步进行量化,使用更低比特的数据来表示权值;此外,结合霍夫曼(Huffman)编码可以进一步地降低深度神经网络的存储。 + +![Deep Compression([@han2015deep])](../img/ch08/deepcomp.png) +:width:`700px` +:label:`ch08-fig-deepcomp` + +除了直接去除冗余的神经元之外,基于字典学习的方法也可以用来去掉深度卷积神经网络中无用的权值([@bagherinezhad2017lcnn])。通过学习一系列卷积核的基,可以把原始卷积核变换到系数域上并且它们稀疏。比如,Bagherinezhad等人([@bagherinezhad2017lcnn])将原始卷积核分解成卷积核的基和稀疏系数的加权线性组合。 + +### 知识蒸馏 + +知识蒸馏,也被称为教师-学生神经网络学习算法,已经受到业界越来越多的关注。大型深度模型在实践中往往会获得良好的性能,因为当考虑新数据时,过度参数化会提高泛化性能。在知识蒸馏中,小模型(学生模型)通常是由一个大模型(教师模型)监督,算法的关键问题是如何从老师模型转换的知识传授给学生模型。通过把一个全新的更深的更窄结构的深度神经网络当作学生神经网络,然后把一个预先训练好的神经网络模型当作教师神经网络。利用这个教师神经网络模型来帮助学生神经网络模型的算法是当下的一个研究热点。 + +Hinton等人([@Distill])首先提出了教师神经网络-学生神经网络学习框架,通过最小化两个神经网络之间的差异来学习一个更窄更深的神经网络。记教师神经网络为$\mathcal{N}_{T}$,它的参数为$\theta_T$,同时记学生神经网络为$\mathcal{N}_{S}$,相应的参数为$\theta_S$。一般而言,学生神经网络相较于教师神经网络具有更少的参数。 + +文献([@Distill])提出的知识蒸馏(knowledge distillation,KD)方法,同时令学生神经网络的分类结果接近真实标签并且令学生神经网络的分类结果接近于教师神经网络的分类结果,即, +$$\mathcal{L}_{KD}(\theta_S) = \mathcal{H}(o_S,\mathbf{y}) +\lambda\mathcal{H}(\tau(o_S),\tau(o_T))$$ +:label:`ch08-equ-c2Fcn_distill` + +其中,$\mathcal{H}(\cdot,\cdot)$是交叉熵函数,$o_S$和$o_T$分别是学生网络和教师网络的输出,$\mathbf{y}$是标签。公式 :numref:`ch08-equ-c2Fcn_distill`中的第一项使得学生神经网络的分类结果接近预期的真实标签,而第二项的目的是提取教师神经网络中的有用信息并传递给学生神经网络,$\lambda$是一个权值参数用来平衡两个目标函数。$\tau(\cdot)$是一个软化(soften)函数,将网络输出变得更加平滑。 + +公式 :numref:`ch08-equ-c2Fcn_distill`仅仅从教师神经网络分类器输出的数据中提取有价值的信息,并没有从其它中间层去将教师神经网络的信息进行挖掘。因此,Romero等人[@FitNet])进一步地开发了一种学习轻型学生神经网络的方法,该算法可以从教师神经网络中任意的一层来传递有用的信息给学生神经网络。此外,事实上,并不是所有的输入数据对卷积神经网络的计算和完成后续的任务都是有用的。例如,在一张包含一个动物的图像中,对分类和识别结果比较重要的是动物所在的区域,而不是那些无用的背景信息。所以,有选择性地从教师神经网络的特征图中提取信息是一个更高效的方式。于是,Zagoruyko和Komodakis([@attentionTS])提出了一种基于感知(attention)损失函数的学习方法来提升学生神经网络的性能,如 :numref:`ch08-fig-AttentionTS`所示。该算法在学习学生神经网络的过程中,引入了Attention模块,选择性地将教师神经网络中的信息传递给学生神经网络,并帮助其进行训练。 + +![文献([@attentionTS])所提出的教师神经网络-学生神经网络学习算法,该算法在学习学生神经网络的过程中,引入了感知模块(Attention),选择性地将教师神经网络中的信息传递给学生神经网络,并帮助其进行训练。感知图可以识别输入图像不同位置对最终分类结果的重要性,并从教师网络传递到学生网络。](../img/ch08/AttentionTS.png) +:width:`800px` +:label:`ch08-fig-AttentionTS` + +知识蒸馏是一种有效的帮助小网络优化的方法,能够进一步和剪枝、量化等其他压缩方法结合,训练得到精度高、计算量小的高效模型。 diff --git a/chapter_model_deployment/model_converter_and_optimizer.md b/chapter_model_deployment/model_converter_and_optimizer.md new file mode 100644 index 0000000..8d337c9 --- /dev/null +++ b/chapter_model_deployment/model_converter_and_optimizer.md @@ -0,0 +1,93 @@ +## 训练模型到推理模型的转换及优化 + +### 模型转换 + +前面我们提到过,不同的训练框架(Tensorflow、PyTorch、MindSpore、MXNet、CNTK等)都定义了自己的模型的数据结构,推理系统需要将它们转换到统一的一种数据结构上。Open Neural Network Exchange(ONNX)正是为此目的而设计的。ONNX支持广泛的机器学习运算符集合,并提供了不同训练框架的转换器,例如TensorFlow模型到ONNX模型的转换器、PyTorch模型到ONNX模型的转换器等。 +模型转换本质上是将模型这种结构化的数据,从一种数据结构转换为另一种数据结构的过程。进行模型转换首先要分析两种数据结构的异同点,然后针对结构相同的数据做搬运;对于结构相似的数据做一一映射;对于结构差异较大的数据则需要根据其语义做合理的数据转换;更进一步如果两种数据结构上存在不兼容,则模型转换无法进行。ONNX的一个优势就在于其强大的表达能力,从而大多数业界框架的模型都能够转换到ONNX的模型上来而不存在不兼容的情况. +模型可以抽象为是一种图,从而模型的数据结构可以解构为以下两个要点: + +- 模型拓扑表达:从图的角度来说,就是图的边;从模型的角度来说,就是模型中的数据流和控制流等,模型数据流和控制流的定义又可以引申出子图的表达形式、模型输入输出的表达形式、控制流结构的表达形式等。比如Tensorflow1.x中的控制流表达为一种有环图,通过Enter、Exit、Switch、LoopCond、NextIteration等算子来解决成环,而ONNX通过Loop,If等算子来表达控制流,从而避免引入了有环,所以在将Tensorflow1.x的控制流模型转化为ONNX模型时,需要将Tensorflow模型中的控制流图结构融合成ONNX的While或者If算子。 + +- 算子原型定义:从图的角度来说,就是图的顶点;从模型角度来说,就是模型中的数据处理节点或者控制流节点。算子原型包括但不限于算子类型、算子输入输出的定义、算子属性的定义等。比如Caffe的slice算子和ONNX的slice算子的语义其实是不一致的,Caffe的slice算子应该映射到ONNX的Split算子,所以在将Caffe模型转换成ONNX模型时,需要将Caffe的Slice算子映射到ONNX的Split算子。比如Tensorflow中的中的FusedBatchNorm算子在Caffe中找不到相同语义的算子,需要将Caffe的BatchNorm算子和Scale算子组合起来才能表达相同的语义。 +通常模型转换的过程也就是转换模型中的拓扑关系和映射模型中的算子原型。 + +在完成模型转换之后,通常地,我们会将一些不依赖于输入的工作提前去完成。这些工作包括了如常量折叠、算子融合、算子替换、算子重排等一些优化手段。这些优化手段的概念在前面的章节其实已经提及到,比如在编译器前端阶段,通常也会做常量折叠;在编译器后端阶段,通常会根据后端的硬件支持程度,对算子进行融合和拆分。但是有些优化工作只有在部署阶段才能进行或者彻底进行。 + +### 算子融合 +:label:`ch08-sec-fusion` + +算子融合,就是将深度神经网络模型中的多个算子,按照一定的规则,合并成一个新的算子。通过算子融合,可以减少模型在线推理时的计算量、访存开销,从而降低推理时的时延和功耗。 + +![计算机分层存储架构](../img/ch08/storage.png) +:width:`150px` +:label:`ch08-fig-storage` + +算子融合带来的性能上的收益主要来自两个方面,一是通过融合,充分利用寄存器和缓存,避免多个算子运算时,数据在CPU和内存之间的存储和读取的耗时。如 :numref:`ch08-fig-storage`,可以看到计算机的储存系统,从最靠近cpu的寄存器L1、L2等多级缓存,到内存、硬盘,其存储的容量越来越大,但读取数据的耗时也越来越大。融合后,前一次计算的结果可以先暂存在CPU的寄存器(Register)或者缓存(Cache)中,下一次计算直接从寄存器或者缓存中读取,减少了内存读写的IO次数。二是通过融合,可以将一些计算量提前完成,避免了前向推理时的冗余计算或者循环冗余计算。 + +![Convolution + Batchnorm算子融合](../img/ch08/conv-bn-fusion.png) +:width:`500px` +:label:`ch08-fig-conv_bn_fusion` + +如 :numref:`ch08-fig-conv_bn_fusion`,我们以Convolution算子和Batchnorm算子的融合为例,阐述算子融合的基本原理,图中蓝色框表示算子,黄色框表示融合后新增或者改变的算子,白色框表示算子中的权重或者常数张量。其融合的过程是一个计算表达式简化的过程,Convolution算子的计算过程可以等效为一个矩阵乘,其公式可以表达为 :numref:`equ:conv-equation`。 + +$$\pmb{Y_{conv}}=\pmb{W_{conv}}*\pmb{X_{conv}}+\pmb{B_{conv}}$$ +:label:`ch08-equ-conv_equation` + +这里我们不需要理解公式 :numref:`ch08-equ-conv_equation`中每个变量的含义,只需要注意到一点,该公式是$\pmb{Y_{conv}}$关于$\pmb{X_{conv}}$的,其他符号均表示常量。 + +Batchnorm算子的计算过程如公式 :numref:`equ:bn-equation`所示。 + +$$\pmb{Y_{bn}}=\gamma\frac{\pmb{X_{bn}}-\mu_{\mathcal{B}}}{\sqrt{{\sigma_{\mathcal{B}}}^{2}+\epsilon}}+\beta$$ +:label:`ch08-equ-bn_equation` + +同样,这里我们不需要理解batchnorm中的所有参数的含义,只需要了解公式 :numref:`ch08-equ-bn_equation`是$\pmb{Y_{bn}}$关于$\pmb{X_{bn}}$的,其他符号均表示常量。 + +如 :numref:`ch08-fig-conv_bn_fusion`,当Convlution算子的输出作为Batchnorm输入时,最终Batchnorm算子的计算公式也就是要求$\pmb{Y_{bn}}$关于$\pmb{X_{conv}}$的计算公式,我们将$\pmb{Y_{conv}}$代入到$\pmb{X_{bn}}$,然后将常数项合并提取后,可以得到公式 :numref:`equ:conv-bn-equation-3`。 + +$$\pmb{Y_{bn}}=\pmb{A}*\pmb{X_{conv}}+\pmb{B}$$ +:label:`ch08-equ-conv_bn_equation_3` + +其中$\pmb{A}$和$\pmb{B}$为两个矩阵。可以看到,公式 :numref:`ch08-equ-conv_bn_equation_3`其实就是一个Convolution的计算公式。这个结果表明,在模型部署时,我们可以将Convolution和Batchnorm两个算子的计算等价为一个Convolution算子。我们将上述以计算公式的合并和简化为基础的算子融合称为计算公式融合。 + +在Convolution算子和Batchnorm算子融合的前后,网络结构相当于减少了一个Batchnorm算子,相应的网络中的参数量和网络所需的计算量都减少了;同时由于算子数量的减少,访存次数也相应地减少了。综合来看,该融合Pattern优化了模型部署时的功耗、性能,同时对于模型的体积大小也有少许收益。 + +在融合过程中,Convolution计算公式和Batchnorm计算公式中被认为是常量的符号在训练时均为参数,并不是常量。训练阶段如果进行该融合会导致模型参数的缺失。从该融合Pattern的结果来看,融合后网络中减少了一个Batchnorm算子,减少了一个Batchnorm算子的参数量,其实就是改变了深度神经网络的算法,会影响到网络的准确率,这是不可接受的。所以Convolution算子与Batchnorm算子的融合一般是在部署阶段特有的一种优化手段,其优化效果我们以MinsSpore Lite为例,构造了包含一个Convolution和一个Batchnorm的sample网络,分别以样例网络和mobilenet-v2网络为例,在华为Mate30手机上,以两线程运行模型推理,取3000轮推理的平均时耗作为模型推理性能的指标,对比融合前后该指标的变化。从表 :numref:`ch08-tab-conv_bn_fusion`可以看到,对于sample网络和mobilenet-v2网络,融合后分别获得了8.5%和11.7%的推理性能提升,这个性能提升非常可观。并且这个性能提升没有带来任何的副作用,也没有对于硬件或算子库的提出额外要求。 + +::: {#tab:ch08-tab-conv_bn_fusion} + 网络 sample mobilenet-v2 + ------- ------------- ----------------- + 融合前 0.035 15.415 + 融合后 0.031 13.606 + +: Convolution + Batchnorm融合前后推理性能(单位:ms) +::: +:label:`ch08-tab-conv_bn_fusion` + +### 算子替换 + +算子替换,即将模型中某些算子替换计算逻辑一致但对于在线部署更友好的算子。算子替换的原理是通过合并同类项、提取公因式等数学方法,将算子的计算公式加以简化,并将简化后的计算公式映射到某类算子上。算子替换可以达到降低计算量、降低模型大小的效果。 + +![Batchnorm算子替换](../img/ch08/bn-replace.png) +:width:`500px` +:label:`ch08-fig-bn_replace` + +如 :numref:`ch08-fig-bn_replace`,我们以Batchnorm算子替换成Scale算子为例,阐述算子替换的原理。我们直接将Batchnorm的计算公式 :numref:`ch08-equ-replace_scale`进行分解,并将常量合并简化,Batchnorm的计算公式可以写成: + +$$\pmb{Y_{bn}}=scale*\pmb{X_{bn}}+offset$$ +:label:`ch08-equ-replace_scale` + +其中scale和offset为两个标量。可以看到,计算公式简化后,我们可以将其映射到一个Scale算子。 + +在Batchnorm算子被替换为Scale算子的前后,网络中的参数量、计算量都减少了,该算子替换策略可以优化模型部署时的功耗和性能。同理,该算子替换优化策略只能在部署阶段才能进行,因为一方面在部署阶段Batchnorm计算公式中被认为是常量的符号,在训练时是参数并非常量。另一方面该优化策略会降低模型的参数量,改变模型的结构,降低模型的表达能力,影响训练收敛时模型的准确率。 + +### 算子重排 + +算子重排是指将模型中算子的拓扑序按照某些规则进行重新排布,在不降低模型的推理精度的前提下,降低模型推理的计算量。常用的算子重排技术有针对于Slice算子、StrideSlice算子、Crop算子等裁切类算子的前移、Reshape算子和Transpose算子的重排、BinaryOp算子的重排等。 + +![Crop算子重排](../img/ch08/crop-reorder.png) +:width:`500px` +:label:`ch08-fig-crop_reorder` + +如 :numref:`ch08-fig-crop_reorder`,Crop算子是从输入的feature map中裁取一部分作为输出,经过Crop算子后,feature map的size就降低了。如果我们将这个裁切的过程前移,提前对feature map进行裁切,那么后续算子的计算量也会相应地减少,从而提高模型部署时的推理性能。Crop算子前移带来的性能提升跟Crop算子的参数有关。但是Crop算子一般只能沿着的element wise类算子前移。 + +通过前面的实验数据我们可以看到,通过推理前的模型优化,可以为推理的时延、功耗、内存占用带来极大的收益。 diff --git a/chapter_model_deployment/model_deployment_introduction.md b/chapter_model_deployment/model_deployment_introduction.md new file mode 100644 index 0000000..65fe42f --- /dev/null +++ b/chapter_model_deployment/model_deployment_introduction.md @@ -0,0 +1,29 @@ +## 概述 + +模型完成训练后,需要将模型及参数持久化成文件,不同的训练框架导出的模型文件中存储的数据结构不同,这给模型的推理系统带来了不便。推理系统为了支持不同的训练框架的模型,需要将模型文件中的数据转换成统一的数据结构。此外,在训练模型转换成推理模型的过程中,需要进行一些如算子融合、常量折叠等模型的优化以提升推理的性能。 + +推理模型部署到不同的场景,需要满足不同的硬件设备的限制,例如,在具有强大算力的计算中心或数据中心的服务器上可以部署大规模的模型,而在边缘侧服务器、个人电脑以及智能手机上算力和内存则相对有限,部署的模型的规模就相应地要降低。在超低功耗的微控制器上,则只能部署非常简单的机器学习模型。此外,不同硬件对于不同数据类型(如FP32、FP16、BF16、INT8等)的支持程度也不相同。为了满足这些硬件的限制,在有些场景下需要对训练好的模型进行压缩,降低模型的复杂度或者数据的精度,减少模型的参数,以适应硬件的限制。 + +模型部署到运行环境中执行推理,推理的时延、内存占用、功耗等是影响用户使用的关键因素,优化模型推理的方式有两种,一是设计专有的机器学习的芯片,相对于通用的计算芯片,这些专有芯片一般在能效比上具有很大的优势。二是通过软硬协同最大程度地发挥硬件的能力。对于第二种方式,以CPU为例,如何切分数据块以满足cache大小,如何对数据进行重排以便计算时可以连续访问,如何减少计算时的数据依赖以提升硬件流水线的并行,如何使用扩展指令集以提升计算性能,这些都需要针对不同的CPU架构进行设计和优化。 + +对于一个企业来讲,模型是属于重要的资产,因此,在模型部署到运行环境以后,保护模型的安全至关重要。本章节会介绍如模型混淆等一些常见的机器学习模型的安全保护手段。 + +- **模型压缩** + +通过量化、剪枝等手段减小模型体积以及计算复杂度的技术,可以分为需要重训的压缩技术和不需要重训的压缩技术两类。 + +- **算子融合** + +通过表达式简化、属性融合等方式将多个算子合并为一个算子的技术,融合可以降低模型的计算复杂度及模型的体积。 + +- **常量折叠** + +将符合条件的算子在离线阶段提前完成前向计算,从而降低模型的计算复杂度和模型的体积。常量折叠的条件是算子的所有输入在离线阶段均为常量。 + +- **数据排布** + +根据后端算子库支持程度和硬件限制,搜索网络中每层的最优数据排布格式,并进行数据重排或者插入数据重排算子,从而降低部署时的推理时延。 + +- **模型混淆** + +xxx。 diff --git a/chapter_model_deployment/model_inference.md b/chapter_model_deployment/model_inference.md new file mode 100644 index 0000000..43e45cf --- /dev/null +++ b/chapter_model_deployment/model_inference.md @@ -0,0 +1,186 @@ +## 模型推理 + +训练模型经过前面的转换、压缩等流程后,需要部署在计算硬件上进行推理。执行推理主要包含以下步骤: + +- 前处理:将原始数据处理成适合网络输入的数据。 +- 执行推理:将离线转换得到的模型部署到设备上执行推理流程,根据输入数据计算得到输出数据。 +- 后处理:模型的输出结果做进一步的加工处理,如筛选阈值。 + +### 前处理与后处理 + +#### 前处理 + +前处理主要完成数据预处理,在现实问题中,我们得到的原始数据往往非常混乱,机器学习模型无法识别并从中提取信息。数据预处理的目的是将原始数据例如图片、语音、文本等,处理成适合网络输入的tensor数据,并消除其中无关的信息,恢复有用的真实信息,增强有关信息的可检测性,最大限度地简化数据,从而改进模型的特征抽取、图像分割、匹配和识别等可靠性。 + +常见的数据预处理手段有: + +- 特征编码:将描述特征的原始数据编码成数字,输入给机器学习模型,因为它们只能处理数字数据。常见的编码方法有:离散化、序号编码、One-hot编码,二进制编码等; + +- 数据归一化:修改数据的值使其达到共同的标度但不改变它们之间的相关性,消除数据指标之间的量纲影响。常用的技术有:Min-Max归一化将数据缩放到给定范围,Z-score归一化使数据符合正态分布; + +- 处理离群值: 离群值是与数据中的其他值保持一定距离的数据点,适当地排除离群值可以提升模型的准确性。 + +针对特定的原始数据,往往存在特定的数据处理手段。在前述8.2"机器学习数据基本类型及常见数据变换方式"章节中,分别详细介绍了图像、音频、文本等数据的预处理方法。 + +#### 后处理 + +通常,模型推理结束后,需要把推理的输出数据传递给用户完成后处理,常见的数据后处理手段有: + +- 连续数据离散化:模型实际用于预测离散数据,例如商品数量时,用回归模型预测得到的是连续值,需要四取五入、取上下限阈值等得到实际结果; + +- 数据可视化:将数据图形化、表格化,便于找到数据之间的关系,来决定下一步的分析策略; + +- 手动拉宽预测范围:回归模型往往预测不出很大或很小的值,结果都集中在中部区域。例如医院的化验数据,通常是要根据异常值诊断疾病。手动拉宽预测范围,将偏离正常范围的值乘一个系数,可以放大两侧的数据,得到更准确的预测结果。 + +### 并行计算 +:label:`ch08-sec-parallel_inference` + +为提升推理的性能,需要重复利用多核的能力,所以一般推理框架会引入多线程机制。主要的思路是将算子的输入数据进行切分,通过多线程去执行不同数据切片,实现算子并行计算,从而成倍提升算子计算性能。 + +![矩阵乘数据切分](../img/ch08/parallel.png) +:width:`800px` +:label:`ch08-fig-parallel` + +如图所示,对于矩阵乘可以按左矩阵的行进行切分,可以利用三个线程分别计算A1\*B,A2\*B,A3\*B,实现矩阵乘多线程并行计算。 + +为方便算子并行计算,同时避免频繁创建销毁线程的开销,推理框架一般会使用线程池机制。业界有两种较为通用的做法: + +- 使用OpenMp编程接口:OpenMP(Open Multi-Processing)是一套支持跨平台共享内存方式的多线程并发的编程API,如算子并行最常用的接口\"parallel for\",实现for循环体的代码被多线程并行执行。 +- 推理框架实现针对算子并行计算的线程池,相对OpenMp提供的接口会更有针对性,性能会更高,且更轻量。 + +### 算子优化 +:label:`ch08-sec-kernel_optimization` + +在部署AI模型时,我们期望模型执行训练或推理的时间尽可能地短,以获得更优越的性能。对于一个固定的深度学习网络,框架调度的时间占比往往很小,性能的瓶颈就在算子的执行。下面从硬件指令和算法角度介绍一些算子优化的方法。 + +#### 硬件指令优化 + +绝大多数的设备上都有CPU,因此算子在CPU上的时间尤为重要,下面介绍一下在ARM +CPU硬件指令优化的方法。 + +1\. 汇编语言 + +开发者使用的C++、Java等高级编程语言会通过编译器输出为机器指令码序列,而高级编程语言能做的事通常受编译器所限,汇编语言是靠近机器的语言,可以一对一实现任何指令码序列,编写的程序存储空间占用少、执行速度快、效率优于高级编程语言。 + +在实际应用中,最好是程序的大部分用高级语言编写,运行性能要求很高的部分用汇编语言来编写,通过混合编程实现优势互补。深度学习的卷积、矩阵乘等算子涉及大量的计算,使用汇编语言能够给模型训练和推理性能带来数十到数百倍量级的提升。 + +下面以ARMv8系列处理器为例,介绍和硬件指令相关的优化。 + +2\. 寄存器与NEON指令 + +ARMv8系列的CPU上有32个NEON寄存器v0-v31,如 :numref:`ch08-fig-register`所示,NEON寄存器v0可存放128bit的数据,即4个float32,8个float16,16个int8等。 + +![ARMv8处理器NEON寄存器v0的结构](../img/ch08/register.png) +:width:`500px` +:label:`ch08-fig-register` + +针对该处理器,可以采用SIMD(Single Instruction,Multiple Data,单指令、多数据)提升数据存取计算的速度。相比于单数据操作指令,NEON指令可以一次性操作NEON寄存器的多个数据。例如:对于浮点数的fmla指令,用法为fmla v0.4s, v1.4s, v2.4s,如 :numref:`ch08-fig-fmla`所示,用于将v1和v2两个寄存器中相对应的float值相乘累加到v0的值上。 + +![fmla指令计算功能](../img/ch08/fmla.png) +:width:`600px` +:label:`ch08-fig-fmla` + +3\. 汇编语言优化 + +对于已知功能的汇编语言程序来说,计算类指令通常是固定的,性能的瓶颈就在非计算指令上。如 :numref:`ch08-fig-storage`所示,计算机各存储设备类似于一个金字塔结构,最顶层空间最小,但是速度最快,最底层速度最慢,但是空间最大。L1-L3统称为cache(高速缓冲存储器),CPU访问数据时,会首先访问位于CPU内部的cache,没找到再访问CPU之外的主存,此时引入了缓存命中率的概念来描述在cache中完成数据存取的占比。要想提升程序的性能,缓存命中率要尽可能的高。 + +下面简单列举一些提升缓存命中率、优化汇编性能的手段: + +(1)循环展开:尽可能使用更多的寄存器,以代码体积换性能; + +(2)指令重排:打乱不同执行单元的指令以提高流水线的利用率,提前有延迟的指令以减轻延迟,减少指令前后的数据依赖等; + +(3)寄存器分块:合理分块NEON寄存器,减少寄存器空闲,增加寄存器复用; + +(4)计算数据重排:尽量保证读写指令内存连续,提高缓存命中率; + +(5)使用预取指令:将要使用到的数据从主存提前载入缓存,减少访问延迟。 + +#### 算法优化 + +多数AI模型的推理时间主要耗费在卷积、矩阵乘算子的计算上,占到了整网百分之九十甚至更多的时间。本小节主要介绍卷积算子算法方面的优化手段,可以应用到各种硬件设备上。 +卷积的计算可以转换为两个矩阵相乘,在前述5.3.3小节中,已经详细介绍了矩阵乘GEMM运算的优化。对于不同的硬件,确定合适的矩阵分块,优化数据访存与指令并行,可以最大限度的发挥硬件的算力,提升推理性能。 + +1.Img2col + +将卷积的计算转换为矩阵乘,一般采用Img2col的方法实现。在常见的神经网络中,卷积的输入通常都是4维的,默认采用的数据排布方式为NHWC,如 :numref:`ch08-fig-conv_nhwc`所示,是一个卷积示意图。输入维度为(1,IH,IW,IC),卷积核维度为(OC,KH,KW,IC),输出维度为(1,OH,OW,OC)。 + +![通用卷积示意图](../img/ch08/conv_nhwc.png) +:width:`800px` +:label:`ch08-fig-conv_nhwc` + +对卷积的Img2col规则如下。如 :numref:`ch08-fig-img2col_input`所示,对该输入做重排,得到的矩阵见右侧,行数对应输出的OH\*OW的个数;每个行向量里,先排列计算一个输出点所需要输入上第一个通道的KH\*KW个数据,再按次序排列之后的通道,直到通道IC。 + +![输入Img2col的矩阵](../img/ch08/img2col_input.png) +:width:`800px` +:label:`ch08-fig-img2col_input` + +如 :numref:`ch08-fig-img2col_weight`所示,对权重数据做重排。将1个卷积核展开为权重矩阵的一列,因此共有OC列,每个列向量上先排列第一个输入通道上KH\*KW的数据,再依次排列后面的通道直到IC。通过重排,卷积的计算就可以转换为两个矩阵相乘的求解。在实际实现时,Img2col和GEMM的数据重排会同时进行,以节省运行时间。 + +![卷积核Img2col的矩阵](../img/ch08/img2col_weight.png) +:width:`600px` +:label:`ch08-fig-img2col_weight` + +2.Winograd算法 + +卷积计算归根到底是矩阵乘法,两个二维矩阵相乘的时间复杂度是$O(n^3)$。我们可以使用Winograd来降低矩阵乘法的复杂度。 + +以一维卷积运算为例,记为F(m,r),其中,m代表输出的个数,r为卷积核的个数。输入为$d=[d_0 \ d_0 \ d_2 \ d_3]$,卷积核为$g=[g_0 \ g_0 \ g_2]^T$,该卷积计算可以写成矩阵形式如公式 :numref:`ch08-equ-conv_matmul_one_dimension`所示,需要6次乘法和4次加法。 + +$$F(2, 3)= +\left[ \begin{matrix} d_0 & d_0 & d_2 \\ d_1 & d_2 & d_3 \end{matrix} \right] \left[ \begin{matrix} g_0 \\ g_1 \\ g_2 \end{matrix} \right]= +\left[ \begin{matrix} y_0 \\ y_1 \end{matrix} \right]$$ +:label:`ch08-equ-conv_matmul_one_dimension` + +可以观察到,卷积运算转换为矩阵乘法时输入矩阵中存在着重复元素$d_1$和$d_2$,因此,卷积转换的矩阵乘法相对一般的矩阵乘有了优化空间。可以通过计算中间变量$m_0-m_3$得到矩阵乘的结果,见公式 :numref:`ch08-equ-conv-2-winograd`: + +$$F(2, 3)= +\left[ \begin{matrix} d_0 & d_0 & d_2 \\ d_1 & d_2 & d_3 \end{matrix} \right] \left[ \begin{matrix} g_0 \\ g_1 \\ g_2 \end{matrix} \right]= +\left[ \begin{matrix} m_0+m_1+m_2 \\ m_1-m_2+m_3 \end{matrix} \right]$$ +:label:`ch08-equ-conv-2-winograd` + +其中,$m_0-m_3$的分别见公式 :numref:`ch08-equ-winograd-param`: + +$$\begin{aligned} +m_0=(d_0-d_2)*g_0 \\ +m_1=(d_1+d_2)*(\frac{g_0+g_1+g_2}{2}) \\ +m_2=(d_0-d_2)*(\frac{g_0-g_1+g_2}{2}) \\ +m_2=(d_1-d_3)*g_2 +\end{aligned}$$ +:label:`ch08-equ-winograd-param` + + +通过$m_0-m_3$间接计算r1,r2,需要的运算次数包括:输入d的4次加法;输出m的4次乘法和4次加法。在推理阶段,权重的数值是常量,因此卷积核上的运算可以在图编译阶段计算,不计入在线的run时间。所以总的运算次数为4次乘法和8次加法,与直接运算的6次乘法和4次加法相比,乘法次数减少,加法次数增加。在计算机中,乘法一般比加法慢,通过减少乘法次数,增加少量加法,可以实现加速。 + +计算过程写成矩阵形式如公式 :numref:`ch08-equ-winograd-matrix`所示,其中,⊙为对应位置相乘,A、B、G都是常量矩阵。这里写成矩阵计算是为了表达清晰,实际使用时,按照公式 :numref:`ch08-equ-winograd-param`手写展开的计算速度更快。 + +$$\mathbf{Y}=\mathbf{A^T}(\mathbf{G}g)*(\mathbf{B^T}d)$$ +:label:`ch08-equ-winograd-matrix` + +$$\mathbf{B^T}= +\left[ \begin{matrix} 1 & 0 & -1 & 0 \\ 0 & 1 & 1 & 0 \\ 0 & -1 & 1 & 0 \\ 0 & 1 & 0 & -1 \end{matrix} \right]$$ +:label:`ch08-equ-winograd-matrix-bt` + +$$\mathbf{G}= +\left[ \begin{matrix} 1 & 0 & 0 \\ 0.5 & 0.5 & 0.5 \\ 0.5 & -0.5 & 0.5 \\ 0 & 0 & 1 \end{matrix} \right]$$ +:label:`ch08-equ-winograd-matrix-g` + +$$\mathbf{A^T}= +\left[ \begin{matrix} 1 & 1 & -1 & 0 \\ 0 & 1 & -1 & -1 \end{matrix} \right] \\$$ +:label:`ch08-equ-winograd-matrix-at` + + +通常深度学习领域通常使用的都是2D卷积,将F(2,3)扩展到F(2x2,3x3),可以写成矩阵形式,如公式 :numref:`ch08-equ-winograd-two-dimension-matrix`所示。此时,Winograd算法的乘法次数为16,而直接卷积的乘法次数为36,降低了2.25倍的乘法计算复杂度。 + +$$\mathbf{Y}=\mathbf{A^T}(\mathbf{G}g\mathbf{G^T})*(\mathbf{B^T}d\mathbf{B})\mathbf{A}$$ +:label:`ch08-equ-winograd-two-dimension-matrix` + +Winograd算法的整个计算过程在逻辑上可以分为4步,如 :numref:`ch08-fig-winograd`所示: + +![winograd步骤示意图](../img/ch08/winograd.png) +:width:`500px` +:label:`ch08-fig-winograd` + +针对任意的输出大小,要使用F(2x2,3x3)的Winograd算法,需要将输出切分成2x2的块,找到对应的输入,按照上述的四个步骤,就可以求出对应的输出值。当然,Winograd算法并不局限于求解F(2x2,3x3),针对任意的F(m\*m,r\*r),都可以找到适当的常量矩阵A、B、G,通过间接计算的方式减少乘法次数。但是随着m、r的增大,输入、输出涉及的加法以及常量权重的乘法次数都在增加,那么乘法次数带来的计算量下降会被加法和常量乘法所抵消。因此,在实际使用场景中,还需要根据Winograd的实际收益来选择。 + +本小节主要介绍了模型推理时的数据处理和性能优化手段。选择合适的数据处理方法,可以更好地提取输入特征,处理输出结果。并行计算以及算子级别的硬件指令与算法优化可以最大限度的发挥硬件的算力。除此之后,内存的占用及访问速率也是影响推理性能的重要因素,因此推理时需要设计合理的内存复用策略,关于内存复用的策略我们在编译器后端章节已经做了阐述。 diff --git a/chapter_model_deployment/model_security.md b/chapter_model_deployment/model_security.md new file mode 100644 index 0000000..2fe20e6 --- /dev/null +++ b/chapter_model_deployment/model_security.md @@ -0,0 +1,7 @@ +## 模型的安全保护 + +### 概述 + +### 模型混淆 + +### 模型加密 diff --git a/chapter_model_deployment/summary.md b/chapter_model_deployment/summary.md new file mode 100644 index 0000000..f28210a --- /dev/null +++ b/chapter_model_deployment/summary.md @@ -0,0 +1,13 @@ +## 总结 + +- 不同的模型部署场景下,通常对于模型大小、运行时内存占用、推理时延和推理功耗等指标有限制。 + +- 针对模型大小指标,通常在离线阶段通过模型压缩技术来优化,比如量化技术、剪枝技术、知识蒸馏技术等,除此之外,一部分模型优化技术,比如融合技术 :numref:`ch08-sec-fusion`等,也有助于模型轻量化,不过其效果比较微弱。 + +- 针对运行时内存指标,主要有三方面的优化:优化模型大小、优化部署框架包大小以及优化运行时临时内存。模型大小的优化手段在上一点中已经说明;部署框架包大小主要通过精简框架代码、框架代码模块化等方式来优化。运行时临时内存主要通过内存池实现内存之间的复用来优化,这部分可以参见 :numref:`ch05-sec-memory_pool`。 + +- 针对模型的推理时延指标,主要有两方面的优化,一方面是离线时通过模型优化技术 :numref:`ch08-sec-fusion`和模型压缩技术 :numref:`ch08-sec-model_compression`尽可能降低模型推理所需的计算量;另一方面是通过加大推理的并行力度 :numref:`ch08-sec-parallel_inference`和优化算子实现 :numref:`ch08-sec-kernel_optimization`来充分挖掘硬件的计算潜力。值得注意的是,除了考虑计算量和算力,推理时的访存开销也是一个重要的影响因素,这一点在 :numref:`ch08-sec-fusion`小节和 :numref:`ch08-sec-kernel_optimization`小节中进行了相关优化。 + +- 针对模型的推理功耗,主要的优化思路是降低模型的计算量,这与针对模型推理时延的优化手段有重合之处,可以参考离线的模型优化技术 :numref:`ch08-sec-fusion`和模型压缩技术 :numref:`ch08-sec-model_compression`。 + +- 本章除了介绍优化模型部署的各方面指标的优化技术以外,还介绍了安全部署相关的技术,如模型混淆、模型加密等。部署安全一方面可以保护企业的重要资产,另一方面可以防止黑客通过篡改模型从而入侵攻击部署环境。 diff --git a/chapter_programming_interface/ml_workflow.md b/chapter_programming_interface/ml_workflow.md index b1d4091..3968143 100644 --- a/chapter_programming_interface/ml_workflow.md +++ b/chapter_programming_interface/ml_workflow.md @@ -26,7 +26,7 @@ ### 环境配置 在构建机器学习工作流程前,MindSpore需要通过context.set_context来配置运行需要的信息,如运行模式、后端信息、硬件等信息。 -导入context模块,配置运行需要的信息。 +导入context模块,配置运行需要的信息。以下代码运行环境为Ubuntu16.04,CUDA10.1,MindSpore1.5.2。 ```python import os @@ -94,7 +94,7 @@ def create_dataset(data_path, batch_size=32, repeat_size=1, # 导入需要用到的模块 import mindspore.nn as nn # 定义线性模型 -class MLPNet(nn.Module): +class MLPNet(nn.Cell): def __init__(self): super(MLPNet, self).__init__() self.flatten = nn.Flatten() @@ -175,20 +175,32 @@ train_net(args, model, train_epoch, mnist_path, dataset_size, ckpoint, False) 测试是模型运行测试数据集得到的结果,通常在训练过程中,每训练一定的数据量后就会测试一次,以验证模型的泛化能力。MindSpore使用model.eval接口读入测试数据集。 ```python -def test_net(network, model, data_path): +def test_net(model, data_path): """定义验证的方法""" ds_eval = create_dataset(os.path.join(data_path, "test")) acc = model.eval(ds_eval, dataset_sink_mode=False) print("{}".format(acc)) +# 验证模型精度 +test_net(model, mnist_path) ``` 在训练完毕后,参数保存在checkpoint中,可以将训练好的参数加载到模型中进行验证。 ```python +import numpy as np +from mindspore import Tensor from mindspore import load_checkpoint, load_param_into_net +# 定义测试数据集,batch_size设置为1,则取出一张图片 +ds_test = create_dataset(os.path.join(mnist_path, "test"), batch_size=1).create_dict_iterator() +data = next(ds_test) +# images为测试图片,labels为测试图片的实际分类 +images = data["image"].asnumpy() +labels = data["label"].asnumpy() # 加载已经保存的用于测试的模型 param_dict = load_checkpoint("checkpoint_lenet-1_1875.ckpt") # 加载参数到网络中 load_param_into_net(net, param_dict) # 使用函数model.predict预测image对应分类 output = model.predict(Tensor(data['image'])) +# 输出预测分类与实际分类 +print(f'Predicted: "{predicted[0]}", Actual: "{labels[0]}"') ``` \ No newline at end of file diff --git a/chapter_transition_advanced/index.md b/chapter_transition_advanced/index.md new file mode 100644 index 0000000..35828e5 --- /dev/null +++ b/chapter_transition_advanced/index.md @@ -0,0 +1,47 @@ +# 第二部分:进阶篇 + +第一部分基础篇介绍了机器学习系统的基础,首先把机器学习系统作为一个黑盒系统,介绍机器学习系统的使用场景和主要需求,机器学习系统提供给用户的编程接口。以及一个典型的机器学习系统中的计算图构建、生成和调度的过程和主要技术。第二部分进阶篇,将站在系统设计的角度,思考在设计现代机器学习系统中需要考虑的问题和解决方案,详细的技术方案介绍在后面的章节会具体展开,本节重点介绍机器学习系统的总体架构以及进阶篇各章节之间的关系。 +机器学习系统面临的挑战可以从如下四个方面的变化趋势来看,如图所示,横向表示的是模型算法研究,生产和部署的过程,纵向表示的是AI系统支撑的模型算法应用以及支持AI系统的芯片硬件: + +1. 面向算法开发者:怎样提高算法开发的效率并兼顾运行性能?怎样让算法开发者减少学习成本随心所欲的表达算法思想?等一系列问题是机器学习系统需要考虑的问题。按照构建计算图的方式,AI框架分为了静态图和动态图两种类型:静态图在执行前先进行构图和编译优化,需要使用系统提供的构图API进行构图,其表达受限于API的灵活和丰富程度,但由于静态图先编译后执行,执行期性能较高;动态图则是边执行边构图,符合算法开发人员的使用习惯,由于直接使用Python生态,可以做到会Python就会写算法;动态图逐语句执行,调试方便,算法开发效率很高,但动态图的执行效率一般情况下不如静态图。 +2. 面向部署:怎样将AI模型算法部署到每个设备、每个应用、每个行业?由于各个设备的算力和资源属性差别巨大,尤其是端侧和边缘侧的某些设备硬件资源极其受限,机器学习系统本身和模型大小和能效比有严苛的要求,以智能手表为例,其要求框架严格限制在100KB以内,能效/功耗比要能支持较长的待机时间要求。而另一方面,AI模型越来越大,即使部署在端侧的模型也动辄几十MB,占用大量的内存、计算和通信资源。因此机器学习系统需要解决跨系统、跨设备、极致轻量化部署的问题。 +3. 面向算法和数据:从计算规模看,AI模型规模呈现指数级增长,2021年模型规模已经达到十万亿参数,预计很快会增长到百万亿参数,怎么应对模型越来越大的挑战;从计算范式看,如何处理不断涌现的新的计算范式。机器学习系统过去是由机器学习、深度学习为主的,未来会不会支持其他的负载,比如:科学计算、数据分析、图形计算等,这样可以最大化的利用资源和提高开发易用性。 +4. 面向硬件:如果把AI加速器按照GPU、多核/众核、DataFlow架构来进行分类的话,DataFlow的架构占比一半左右,Dataflow架构虽然在能效比上有很大的优势,但是加大了可编程性和性能优化的难度,如何充分发挥芯片的性能进行极致的性能优化? + +![影响机器学习系统架构的因素](../img/Advanced/preface3_1.png) + +​ 上述四个方面的不同需求,在设计AI框架系统的时候,需要基于场景充分的予以考虑,另外还需要考虑: + +通用性:也可以成为泛化性,是不是所有的模型算法同一套代码,没有针对某个网络的特殊定制代码? 是不是所有硬件同一套机制,在机器学习系统中针对特定硬件版本的定制只存在于硬件相关层? + +易用性:对新用户而言,易用性关注更多的是入门的门槛,能不能一键式的安装、升级和运行常见的模型;对深度用户,如:算法研究人员而言,是不是能够轻松的表达算法、调试算法和部署算法模型是易用性的重点。另外,生态兼容性是易用性的一个重要考量,方便的使用常用的工具、第三方库兼容和对接,支持更多的硬件进行训练和部署是重要的因素。 + +性能:追求性价比、性能功耗比、大规模集群的性能加速比是机器学习系统性能的主要衡量指标,MLPerf、AIPerf榜单主要比拼的是典型网络在不同系统上的综合性能指标,机器学习系统中提供的系统优化机制对性能起到了重要作用。 + +​ 在机器学习系统的架构设计中,采用三层架构来应对这些挑战和需要考虑的问题。 下图是MindSpore的总体架构图,就是以AI编译器为核心的三层架构设计。第一层MindExpression表示层,提供灵活的模型/算法表示,考到到生态兼容性,采用Python作为主要的编程语言,后续也可以提供其他编程语言的扩展如:Julia、仓颉等编程语言。支持常见的第三库如:Numpy、Scipy等,数据处理支持常用的数据处理库,如:opencv的API等;第二层MindCompiler,AI编译器负责机器学习系统的编译优化,包括自动微分、硬件无关的优化,硬件相关的优化,分布式系统相关的优化等都在AI编译器层完成,经过这一层后,用户表达的算法和模型转换成特定硬件上的高效执行的机器代码;前端负责静态分析、类型推导以及自动微分、分布式并行子图拆分等PASS优化;后端负责硬件相关的优化,如:内存优化、图算融合等。在第二层可以看到有MindData数据处理框架,主要是因为在系统中所处的层次和MindCompiler相同,关系也比较密切。数据处理框架主要是提供数据处理格式、数据处理加速和数据处理相关的保序、分布式缓存等操作。由于数据处理和AI训练是在同一套硬件集群系统中,需要根据训练和数据处理的负载消耗情况来进行资源分配,有时候也会把数据处理相关操作在AI计算中构成一张DAG图进行调度。第三层MindRT运行时,提供不同部署环境下通用的分布式异构并行调度机制、内存分配等,对于不同的硬件使用不同的backend的设计方式。详细的介绍再后面的章节中会展开讨论,在本部分重点介绍设计的思想和章节之间的关系。 + +![preface3_arc](../img/Advanced/preface3_arc.png "MindSpore系统架构") + +​ 既要对上承接模型算法的变化,满足算法开发者研究不断探索的诉求, 又要在最终的二进制输出上满足多样性硬件的诉求,满足不同部署环境的资源要求。既要满足系统的通用,也要满足易用性的灵活性要求,还要满足性能的不断优化诉求,这里引入编译器的概念再合适不过了。 编译器概念可以很好抽象上面提到的挑战和问题,编译器的架构和机器学习系统非常吻合。编译器输入的是用户编程代码,输出的是机器执行的高效代码,编译器的作用主要是转换和优化,这和机器学习系统的输入输出,机器学习的目标是完全一致的。所以在进阶篇我们将用两个章节详细介绍AI编译器,里面的很多概念是和通用编译器中的概念是相同的,比如AOT(Ahead of Time提前编译)、JIT(Just in time)、IR(中间表示)、PASS优化、AST(Abstract Struct Trees)、副作用、闭包等概念,对编译器相关概念需要了解的读者可以翻阅相关的编译原理教材了解。 + +​ AI编译器是一种相对较新的概念和工具,一个强大的AI编译器将让算法科学家和开发人员享受其带来的益处,包括表达的便捷和执行的高性能,是AI框架设计的核心。为了更好的理解AI编译器架构,先从如下图传统编译器LLVM架构说起,随着越来越多的编程语言和多种不同架构的算力硬件的支持, LLVM IR为不同的前端编程语言和硬件架构提供了一个桥梁,先把前端不同的编程语言映射成统一的LLVM IR,再把LLVM IR映射到不同的后端,这样可以大大简化编译器的开发,不同的前端,不同的硬件都可以尽可能的共享LLVM IR和其上的基础设施和编译优化。在这里可以把LLVM编译器分成三个部分:前端、IR和后端。前端将高级语言转换成IR,后端将IR转换成目标硬件上的机器指令,IR作为桥梁在前后端之间进行基于IR的各种优化。这样无论是新增硬件的支持,还是新的前端支持都可以尽可能的复用IR相关的部分。IR可以是单层的IR,也可以是多层的IR, LLVM IR是典型的单层的IR,前后端优化都基于相同的LLVM IR进行。 + +![影响机器学习系统架构的因素](../img/Advanced/preface3_2.png "LLVM IR的架构") + +​ 在AI编译器领域往往采用多层级逐步优化的方式,对应的IR也是多层级IR的。TensorFlow等AI框架就是采用多层级IR来进行设计的,MindSpore早期版本也是使用多层次IR的,下图就是MLIR官方材料中给出的TensorFlow框架当前的IR现状,中间至少有三个层次的IR,即:TensorFlow Graph IR, XLA (Accelerated Linear Algebra)HLO、 以及特定硬件的LLVM IR 或者TPU IR,下面就不同的层级IR和其上的编译优化做一个简要介绍。在前一章节计算图部分提到的图优化,也称为:图编译优化,主要实现整图级别的优化和操作,如: 图优化、图切分,往往是基于Graph IR这个层次进行的,比较适合静态图的高效执行模式;由于整图级别的IR缺少相应的硬件信息,难以进行硬件相关的优化,所以在中间层次就出现了相关的硬件通用编译优化,如:XLA、TensorRT、MindSpore的图算融合等都属于这一类编译器,来针对不同的硬件进行算子融合,提升网络在特定硬件上的执行性能。在本书的第五章节编译器后端中硬件通用优化中有一个章节介绍图算融合编译器的相关设计。最后一个层次的IR是特定硬件加速器专有的IR,一般由硬件厂商自带的编译器提供,如:Ascend硬件自带的TBE编译器就是基于TVM IR生成高效的执行算子的,在本书的第6章加速器章节也会有相应章节介绍算子编译器。 + +![影响机器学习系统架构的因素](../img/Advanced/preface3_3.png "图. MLIR架构") + +​ 多层级IR的优势是IR表达上更加地灵活,可以在不同层级的IR上进行合适的PASS优化,更加方便,优化算法也更加地高效;但是多层级IR也有一些劣势。首先多层级IR必然需要进行IR之间的转换,IR转换要做到兼容非常困难,工程工作量很大;IR的转换除了工程的工作量比较大以外,可能会带来信息的损失, 由于上一层IR的某些信息缺失,给下一层优化带来困难,由于会造成信息丢失对优化的影响,因此对优化执行的顺序也有更强的约束。其次多层级IR有些优化既可以在上一层IR进行,也可以在下一层IR进行,让系统开发者很难选择。最后不同层级IR定义的算子粒度大小不同,可能给给精度带来一定的影响。所以在MindSpore的架构设计中采用了统一的MindIR设计,如下图所示详细给出了AI编译器内部的运行流程和分工,所以本书中编译器前端主要是指图编译和硬件无关的优化,编译器后端主要是指硬件相关优化、算子选择等。 + +![影响机器学习系统架构的因素](../img/Advanced/preface3_4.png "图. MindSpore AI编译器分层架构") + +​ 性能除了在AI领域有一个很重要的需求就是要尽可能和AI硬件结合起来,发挥AI硬件的算力性能。当前AI硬件除了常见的CPU、GPU外,各种DSA架构的加速器硬件如:华为推出的Ascend昇腾AI加速器是不可忽视的AI硬件的主打硬件。在第6章加速器部分,首先对加速器硬件的架构进行介绍,理解加速器的编程手段和编程接口,在机器学习系统中就可以更好的使用加速器。 + +数据是机器学习的生命线,在训练计算中有30%左右的计算时间用于数据处理,如果算上一个完整的机器学习模型训练,包括从公开的数据集中预处理数据等占比时间比例更高。ML模型数据处理需要提供灵活的数据处理能力,包括接口和自定义算子的能力,也需要高性能的处理,包括数据处理加速和分布式数据处理等,在本书第7章节数据处理框架部分进行了介绍。 + +训练后的模型需要部署到各种形态的终端设备中,将智慧赋能到各行各业。当前AI部署到企业的比例还不到20%,模型部署存在着两大方面的挑战:一方面资源受限设备的CPU处理能力不强,内存和硬盘空间都很小,同时对模型执行的功耗、精度和时延开销又有严苛的指标要求,在此场景下,模型部署遇到了性能、功耗和大小的挑战;另一方面超大模型部署对算力消耗较大,需要进行分布式部署。而很多大企业往往采用统一的模型部署的云服务,需要一套服务接口提供对外服务,机器学习系统需要能适配不同的模型,不同的数据处理方法,对接不同的硬件后端,提升云端部署的效率、成本和吞吐率。本书第8章节模型部署将从模型部署的一般流程出发,详细的介绍在模型部署到手机等端侧设备上需要进行的模型转换、模型优化和模型压缩的相关技术。对于分布式场景下模型部署相关的技术进行介绍。模型部署也可以转换到和模型训练相同的IR上进行算子融合、代数化简以及硬件相关的优化,共享AI编译器相关的技术设施,采用和训练相同的IR,可以保持训练和推理的精度一致性。 + +机器学习系统中单机系统包括编译优化、加速器加速、模型部署和数据处理执行流程,如下图所示。随着模型的越来越大,AI训练单机系统已经不能满足需求,大型模型的训练需要使用到AI集群来进行训练,2021年盘古大模型的发布,就提到使用了2048个训练节点的超大规模的昇腾计算集群。在超大模型训练中,如果Scale out模型训练到集群上,需要机器学习系统支持数据并行、模型并行、流水线并行、优化器并行的能力,同时允许多种策略灵活的组合使用。由于大规模集群的系统配置的复杂度很高,无论对于算法人员还是系统人员,配置这些策略的门槛都比较高,需要机器学习系统提供半自动和自动的策略支持,适配不同模型、不同规模的集群的灵活的分布式并行策略,这些技术将在第9章节分布式训练进行介绍。 + +![影响机器学习系统架构的因素](../img/Advanced/preface3_5.png "图. MindSpore AI编译器分层架构") diff --git a/img/Advanced/preface3_1.png b/img/Advanced/preface3_1.png new file mode 100644 index 0000000..6c2e418 Binary files /dev/null and b/img/Advanced/preface3_1.png differ diff --git a/img/Advanced/preface3_2.png b/img/Advanced/preface3_2.png new file mode 100644 index 0000000..37015fd Binary files /dev/null and b/img/Advanced/preface3_2.png differ diff --git a/img/Advanced/preface3_3.png b/img/Advanced/preface3_3.png new file mode 100644 index 0000000..0d11487 Binary files /dev/null and b/img/Advanced/preface3_3.png differ diff --git a/img/Advanced/preface3_4.png b/img/Advanced/preface3_4.png new file mode 100644 index 0000000..f041ab4 Binary files /dev/null and b/img/Advanced/preface3_4.png differ diff --git a/img/Advanced/preface3_5.png b/img/Advanced/preface3_5.png new file mode 100644 index 0000000..9ad98c4 Binary files /dev/null and b/img/Advanced/preface3_5.png differ diff --git a/img/Advanced/preface3_arc.png b/img/Advanced/preface3_arc.png new file mode 100644 index 0000000..e05c50d Binary files /dev/null and b/img/Advanced/preface3_arc.png differ diff --git a/img/ch04/中间表示-ASTDAG.svg b/img/ch04/中间表示-ASTDAG.svg new file mode 100644 index 0000000..ff4464a --- /dev/null +++ b/img/ch04/中间表示-ASTDAG.svg @@ -0,0 +1,342 @@ + + + +××a5×ba5××ba5AST抽象语法树DAG向无环图 diff --git a/img/ch04/中间表示-Jaxpr.png b/img/ch04/中间表示-Jaxpr.png new file mode 100644 index 0000000..5c0214b Binary files /dev/null and b/img/ch04/中间表示-Jaxpr.png differ diff --git a/img/ch04/中间表示-LLVMIR.svg b/img/ch04/中间表示-LLVMIR.svg new file mode 100644 index 0000000..a3aa537 --- /dev/null +++ b/img/ch04/中间表示-LLVMIR.svg @@ -0,0 +1,197 @@ + + + +intmain() {intx = 1;if (x > 0) {x += 1}return 0;} diff --git a/img/ch04/中间表示-MLIR.svg b/img/ch04/中间表示-MLIR.svg new file mode 100644 index 0000000..fddf16b --- /dev/null +++ b/img/ch04/中间表示-MLIR.svg @@ -0,0 +1,321 @@ + + + +TensorFlowGraphXLA HLOTensor RTnGraphCore MLTensorFlowLiteLLVM IRTPU IRSeveral othersNNAPIMany othersGrappler diff --git a/img/ch04/中间表示-MindIR.svg b/img/ch04/中间表示-MindIR.svg new file mode 100644 index 0000000..8dfce81 --- /dev/null +++ b/img/ch04/中间表示-MindIR.svg @@ -0,0 +1,380 @@ + + + +AnfNodeANodeCNodeParameterNodeValueNodeParameterScalarNamedTensorTypeShapePrimitiveMetaFuncGraphFuncGraph diff --git a/img/ch04/中间表示-MindIR图.png b/img/ch04/中间表示-MindIR图.png new file mode 100644 index 0000000..d0bf7b5 Binary files /dev/null and b/img/ch04/中间表示-MindIR图.png differ diff --git a/img/ch04/中间表示-MindIR示例.png b/img/ch04/中间表示-MindIR示例.png new file mode 100644 index 0000000..ba3f0fa Binary files /dev/null and b/img/ch04/中间表示-MindIR示例.png differ diff --git a/img/ch04/中间表示-torchscript.png b/img/ch04/中间表示-torchscript.png new file mode 100644 index 0000000..6b673b4 Binary files /dev/null and b/img/ch04/中间表示-torchscript.png differ diff --git a/img/ch04/中间表示-中间表示结构.svg b/img/ch04/中间表示-中间表示结构.svg new file mode 100644 index 0000000..7f8a454 --- /dev/null +++ b/img/ch04/中间表示-中间表示结构.svg @@ -0,0 +1,364 @@ + + + +源程序语言1源程序语言2源程序语言M前端优化器后端目标机器1目标机器2目标机器NIRIR diff --git a/img/ch04/中间表示-线性中间表示.svg b/img/ch04/中间表示-线性中间表示.svg new file mode 100644 index 0000000..e1a6444 --- /dev/null +++ b/img/ch04/中间表示-线性中间表示.svg @@ -0,0 +1,744 @@ + + + +push 5push bmultiplypush asubstract目标运算符操作数1操作数2t15t2bt3×t1t2t4at5-t4t3t1 5t2 bt3 t1×t2t4 at5 t4 t3堆栈机代码三地址代码 diff --git a/img/ch04/符号微分的表达式膨胀问题.png b/img/ch04/符号微分的表达式膨胀问题.png new file mode 100644 index 0000000..a7cb9a0 Binary files /dev/null and b/img/ch04/符号微分的表达式膨胀问题.png differ diff --git a/img/ch04/编译优化-pass结构.svg b/img/ch04/编译优化-pass结构.svg new file mode 100644 index 0000000..1f946d0 --- /dev/null +++ b/img/ch04/编译优化-pass结构.svg @@ -0,0 +1,581 @@ + + + +Pass APass BPass CIRIRIRIRC1C2C3B1B2B3A1A2A3 diff --git a/img/ch04/编译优化-公共子表达式消除.svg b/img/ch04/编译优化-公共子表达式消除.svg new file mode 100644 index 0000000..2c5e00d --- /dev/null +++ b/img/ch04/编译优化-公共子表达式消除.svg @@ -0,0 +1,66 @@ + + + +d = (a + b*c)*2 + (b*c)/5 + (c*c*b)d = (a + E)*2 + E/5 + c*E diff --git a/img/ch04/编译优化-常量传播与常量折叠.svg b/img/ch04/编译优化-常量传播与常量折叠.svg new file mode 100644 index 0000000..213ff54 --- /dev/null +++ b/img/ch04/编译优化-常量传播与常量折叠.svg @@ -0,0 +1,162 @@ + + + +a = 2b = a + 2a = 2b = 2 + 2a = 2b = 4常量传播常量折叠 diff --git a/img/ch04/编译优化-无用代码消除.svg b/img/ch04/编译优化-无用代码消除.svg new file mode 100644 index 0000000..1f7a10d --- /dev/null +++ b/img/ch04/编译优化-无用代码消除.svg @@ -0,0 +1,234 @@ + + + +x = Net1()y = Net2()z = Net3() # z is not usedd = ops1(x, y)x=Net1()y=Net2()d=ops1(x,y) diff --git a/img/ch04/编译器前端基础架构.svg b/img/ch04/编译器前端基础架构.svg new file mode 100644 index 0000000..df0243c --- /dev/null +++ b/img/ch04/编译器前端基础架构.svg @@ -0,0 +1,326 @@ + + + +解析静态分析前端优化自动微分前端优化是否继续优化中间表示源程序中间表示中间表示中间表示中间表示中间表示中间表示 diff --git a/img/ch04/自动微分-前向模式自动微分示例.png b/img/ch04/自动微分-前向模式自动微分示例.png new file mode 100644 index 0000000..ac7f559 Binary files /dev/null and b/img/ch04/自动微分-前向模式自动微分示例.png differ diff --git a/img/ch04/自动微分-反向模式自动微分示例.png b/img/ch04/自动微分-反向模式自动微分示例.png new file mode 100644 index 0000000..f6f3615 Binary files /dev/null and b/img/ch04/自动微分-反向模式自动微分示例.png differ diff --git a/img/ch04/自动微分-示例计算图.svg b/img/ch04/自动微分-示例计算图.svg new file mode 100644 index 0000000..e28394c --- /dev/null +++ b/img/ch04/自动微分-示例计算图.svg @@ -0,0 +1,810 @@ + + + +V-1V0V1V2V4V3V5X1X2V1=ln(V-1)V3= sin(V0)V2= V-1V0V4= V1+ V2V5= V4-V3 diff --git a/img/ch04/静态分析-静态分析模块.png b/img/ch04/静态分析-静态分析模块.png new file mode 100644 index 0000000..7b56225 Binary files /dev/null and b/img/ch04/静态分析-静态分析模块.png differ diff --git a/img/ch07/7.1/pipeline.png b/img/ch07/7.1/pipeline.png new file mode 100644 index 0000000..045ee03 Binary files /dev/null and b/img/ch07/7.1/pipeline.png differ diff --git a/img/ch07/7.2/RDD.png b/img/ch07/7.2/RDD.png new file mode 100644 index 0000000..ca9f68e Binary files /dev/null and b/img/ch07/7.2/RDD.png differ diff --git a/img/ch07/7.2/dataset-plugin.png b/img/ch07/7.2/dataset-plugin.png new file mode 100644 index 0000000..06cfe23 Binary files /dev/null and b/img/ch07/7.2/dataset-plugin.png differ diff --git a/img/ch07/7.2/dataset.png b/img/ch07/7.2/dataset.png new file mode 100644 index 0000000..2ebb0cd Binary files /dev/null and b/img/ch07/7.2/dataset.png differ diff --git a/img/ch07/7.2/dataset_table.png b/img/ch07/7.2/dataset_table.png new file mode 100644 index 0000000..47fc0ba Binary files /dev/null and b/img/ch07/7.2/dataset_table.png differ diff --git a/img/ch07/7.2/image_process_pipeline.png b/img/ch07/7.2/image_process_pipeline.png new file mode 100644 index 0000000..ab08dbf Binary files /dev/null and b/img/ch07/7.2/image_process_pipeline.png differ diff --git a/img/ch07/7.2/operation.png b/img/ch07/7.2/operation.png new file mode 100644 index 0000000..55b4d0b Binary files /dev/null and b/img/ch07/7.2/operation.png differ diff --git a/img/ch07/7.3/MindRecord_format.png b/img/ch07/7.3/MindRecord_format.png new file mode 100644 index 0000000..247733c Binary files /dev/null and b/img/ch07/7.3/MindRecord_format.png differ diff --git a/img/ch07/7.3/async_data_process.png b/img/ch07/7.3/async_data_process.png new file mode 100644 index 0000000..3f6cdf3 Binary files /dev/null and b/img/ch07/7.3/async_data_process.png differ diff --git a/img/ch07/7.3/file_indexing.png b/img/ch07/7.3/file_indexing.png new file mode 100644 index 0000000..3afc61c Binary files /dev/null and b/img/ch07/7.3/file_indexing.png differ diff --git a/img/ch07/7.3/map_reduce.png b/img/ch07/7.3/map_reduce.png new file mode 100644 index 0000000..49b776e Binary files /dev/null and b/img/ch07/7.3/map_reduce.png differ diff --git a/img/ch07/7.3/operator_parallisim.png b/img/ch07/7.3/operator_parallisim.png new file mode 100644 index 0000000..5ccb124 Binary files /dev/null and b/img/ch07/7.3/operator_parallisim.png differ diff --git a/img/ch07/7.3/partition.png b/img/ch07/7.3/partition.png new file mode 100644 index 0000000..23fb91d Binary files /dev/null and b/img/ch07/7.3/partition.png differ diff --git a/img/ch07/7.3/pipeline_parallisim.png b/img/ch07/7.3/pipeline_parallisim.png new file mode 100644 index 0000000..4dc768c Binary files /dev/null and b/img/ch07/7.3/pipeline_parallisim.png differ diff --git a/img/ch07/7.3/pytorch_dataloader.png b/img/ch07/7.3/pytorch_dataloader.png new file mode 100644 index 0000000..bf2233d Binary files /dev/null and b/img/ch07/7.3/pytorch_dataloader.png differ diff --git a/img/ch07/7.3/single_pipeline.png b/img/ch07/7.3/single_pipeline.png new file mode 100644 index 0000000..cffc9ce Binary files /dev/null and b/img/ch07/7.3/single_pipeline.png differ diff --git a/img/ch07/7.3/uni_record.png b/img/ch07/7.3/uni_record.png new file mode 100644 index 0000000..80c5f0c Binary files /dev/null and b/img/ch07/7.3/uni_record.png differ diff --git a/img/ch07/7.4/data_ordering.png b/img/ch07/7.4/data_ordering.png new file mode 100644 index 0000000..f1e7d22 Binary files /dev/null and b/img/ch07/7.4/data_ordering.png differ diff --git a/img/ch07/7.4/mindspore_data_order.jpeg b/img/ch07/7.4/mindspore_data_order.jpeg new file mode 100644 index 0000000..3c0cb6c Binary files /dev/null and b/img/ch07/7.4/mindspore_data_order.jpeg differ diff --git a/img/ch07/7.5/dali_overview.png b/img/ch07/7.5/dali_overview.png new file mode 100644 index 0000000..57339a0 Binary files /dev/null and b/img/ch07/7.5/dali_overview.png differ diff --git a/img/ch08/AttentionTS.png b/img/ch08/AttentionTS.png new file mode 100644 index 0000000..a0d0f96 Binary files /dev/null and b/img/ch08/AttentionTS.png differ diff --git a/img/ch08/bn-replace.png b/img/ch08/bn-replace.png new file mode 100644 index 0000000..98a1b19 Binary files /dev/null and b/img/ch08/bn-replace.png differ diff --git a/img/ch08/conv-bn-fusion.png b/img/ch08/conv-bn-fusion.png new file mode 100644 index 0000000..c7f5caa Binary files /dev/null and b/img/ch08/conv-bn-fusion.png differ diff --git a/img/ch08/conv_2d.png b/img/ch08/conv_2d.png new file mode 100644 index 0000000..fee8e40 Binary files /dev/null and b/img/ch08/conv_2d.png differ diff --git a/img/ch08/conv_nhwc.png b/img/ch08/conv_nhwc.png new file mode 100644 index 0000000..2e7fdda Binary files /dev/null and b/img/ch08/conv_nhwc.png differ diff --git a/img/ch08/crop-reorder.png b/img/ch08/crop-reorder.png new file mode 100644 index 0000000..41a6fa9 Binary files /dev/null and b/img/ch08/crop-reorder.png differ diff --git a/img/ch08/deepcomp.png b/img/ch08/deepcomp.png new file mode 100644 index 0000000..a1af159 Binary files /dev/null and b/img/ch08/deepcomp.png differ diff --git a/img/ch08/flow.png b/img/ch08/flow.png new file mode 100644 index 0000000..b3bfb69 Binary files /dev/null and b/img/ch08/flow.png differ diff --git a/img/ch08/fmla.png b/img/ch08/fmla.png new file mode 100644 index 0000000..45ce6be Binary files /dev/null and b/img/ch08/fmla.png differ diff --git a/img/ch08/gemm.png b/img/ch08/gemm.png new file mode 100644 index 0000000..eb2133c Binary files /dev/null and b/img/ch08/gemm.png differ diff --git a/img/ch08/img2col_input.png b/img/ch08/img2col_input.png new file mode 100644 index 0000000..1be55b6 Binary files /dev/null and b/img/ch08/img2col_input.png differ diff --git a/img/ch08/img2col_weight.png b/img/ch08/img2col_weight.png new file mode 100644 index 0000000..c2eff25 Binary files /dev/null and b/img/ch08/img2col_weight.png differ diff --git a/img/ch08/parallel.png b/img/ch08/parallel.png new file mode 100644 index 0000000..3b045b9 Binary files /dev/null and b/img/ch08/parallel.png differ diff --git a/img/ch08/quant-minmax-outpoints.png b/img/ch08/quant-minmax-outpoints.png new file mode 100644 index 0000000..2f88454 Binary files /dev/null and b/img/ch08/quant-minmax-outpoints.png differ diff --git a/img/ch08/quant-minmax.png b/img/ch08/quant-minmax.png new file mode 100644 index 0000000..67f8dc7 Binary files /dev/null and b/img/ch08/quant-minmax.png differ diff --git a/img/ch08/register.png b/img/ch08/register.png new file mode 100644 index 0000000..c920f8d Binary files /dev/null and b/img/ch08/register.png differ diff --git a/img/ch08/storage.png b/img/ch08/storage.png new file mode 100644 index 0000000..964c77a Binary files /dev/null and b/img/ch08/storage.png differ diff --git a/img/ch08/winograd.png b/img/ch08/winograd.png new file mode 100644 index 0000000..bbe3098 Binary files /dev/null and b/img/ch08/winograd.png differ diff --git a/img/ch_basic/conv_computation_v4.png b/img/ch_basic/conv_computation_v4.png new file mode 100644 index 0000000..e3b866b Binary files /dev/null and b/img/ch_basic/conv_computation_v4.png differ diff --git a/img/ch_basic/gradient_descent2.png b/img/ch_basic/gradient_descent2.png new file mode 100644 index 0000000..ceef5a4 Binary files /dev/null and b/img/ch_basic/gradient_descent2.png differ diff --git a/img/ch_basic/mlp2.png b/img/ch_basic/mlp2.png new file mode 100644 index 0000000..c28a1d8 Binary files /dev/null and b/img/ch_basic/mlp2.png differ diff --git a/img/ch_basic/pooling_v3.png b/img/ch_basic/pooling_v3.png new file mode 100644 index 0000000..475fb1b Binary files /dev/null and b/img/ch_basic/pooling_v3.png differ diff --git a/img/ch_basic/rnn_simple_cell2.png b/img/ch_basic/rnn_simple_cell2.png new file mode 100644 index 0000000..faa8690 Binary files /dev/null and b/img/ch_basic/rnn_simple_cell2.png differ diff --git a/img/ch_basic/single_neuron2.png b/img/ch_basic/single_neuron2.png new file mode 100644 index 0000000..4ef0439 Binary files /dev/null and b/img/ch_basic/single_neuron2.png differ diff --git a/img/ch_basic/single_neuron_bias2.png b/img/ch_basic/single_neuron_bias2.png new file mode 100644 index 0000000..60b9143 Binary files /dev/null and b/img/ch_basic/single_neuron_bias2.png differ diff --git a/img/ch_basic/single_neuron_decision_boundary2.png b/img/ch_basic/single_neuron_decision_boundary2.png new file mode 100644 index 0000000..2bf85d1 Binary files /dev/null and b/img/ch_basic/single_neuron_decision_boundary2.png differ diff --git a/img/ch_basic/two_neurons2.png b/img/ch_basic/two_neurons2.png new file mode 100644 index 0000000..939d2ce Binary files /dev/null and b/img/ch_basic/two_neurons2.png differ diff --git a/index.md b/index.md index 7c8f93f..b4172bf 100644 --- a/index.md +++ b/index.md @@ -7,13 +7,14 @@ chapter_introduction/index chapter_programming_interface/index chapter_computational_graph/index -chapter_compiler_front-end_and_ir/index +chapter_compiler_frontend_and_ir/index chapter_compiler_backend_and_runtime/index chapter_hardware_accelerator/index chapter_data_processing_framework/index chapter_model_deployment/index chapter_distributed_training_system/index +chapter_transition_advanced/index chapter_framework_expansion/index appendix_Introduction_machine_learning/index -``` \ No newline at end of file +``` diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..588cb5d --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +bs4 \ No newline at end of file diff --git a/tools/format_tables.py b/tools/format_tables.py new file mode 100644 index 0000000..8b1d132 --- /dev/null +++ b/tools/format_tables.py @@ -0,0 +1,35 @@ +from bs4 import BeautifulSoup +import os + +""" +1. 本脚本文件会设置所有html文件,使得表格居中显示。 +2. 确保你已经安装了bs4, 具体可以通过pip install bs4 进行安装 +""" + +root_path = "./" +html_root_path = "_build/html/" + +def get_html_list(): + index_html_path = os.path.join(root_path,html_root_path ,"index.html") + index_soup = BeautifulSoup(open(index_html_path)) + + content_list = index_soup.find(name="div", attrs={"class":"globaltoc"}).\ + find_all(name="a", attrs={"class": "reference internal"}) + html_list = [os.path.join(html_root_path,content_name["href"]) for content_name in content_list] + return html_list + +def format_table(): + html_list = get_html_list() + for html_file in html_list: + soup = BeautifulSoup(open(html_file)) + all_tables = soup.find_all(name="table", attrs={"class":"docutils align-default"}) + for table in all_tables: + table["style"] = "margin-left:auto;margin-right:auto;margin-top:10px;margin-bottom:20px;" + + if len(all_tables): + write_out_file = open(html_file, mode="w") + write_out_file.write(soup.prettify()) + write_out_file.close() + +if __name__ == "__main__": + format_table() \ No newline at end of file