mirror of
https://github.com/openmlsys/openmlsys-zh.git
synced 2026-04-08 13:19:05 +08:00
update rl chapter according to chinese modification
This commit is contained in:
79
website/README.md
Normal file
79
website/README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# 机器学习系统:设计和实现
|
||||
|
||||
本开源项目试图给读者讲解现代机器学习系统的设计原理和实现经验。
|
||||
|
||||
🔥 **书籍网页版:** [机器学习系统:设计和实现](https://openmlsys.github.io/)
|
||||
|
||||
🔥 **书籍PDF:** 将在勘误后,2022年中发布。
|
||||
|
||||
## 发布
|
||||
|
||||
- 27/06/2022: OpenMLSys社区发布通俗易懂的高性能AI算子开发教程,助力学生和工程师60分钟理解算子性能优化的关键知识点。相应的[技术博客](https://zhuanlan.zhihu.com/p/531498210)和[复现代码](https://github.com/openmlsys/openmlsys-cuda)都已免费公开。感谢@[Jie Ren](https://github.com/JieRen98) 和 @[Wenteng Liang](https://github.com/Went-Liang) 的贡献!🔥
|
||||
- 17/03/2022: 本书处于勘误阶段。如发现文字和图片错误,可创建Issue并@[章节编辑](info/editors.md)。我们非常欢迎社区提交PR直接勘误。
|
||||
|
||||
## 适用读者
|
||||
|
||||
本书的常见读者包括:
|
||||
|
||||
- **学生:**
|
||||
随着大量机器学习课程在大学中的普及,学生已经开始掌握大量机器学习的基础理论和神经网络的实现。然而,需要训练出可以实际应用的机器学习模型,需要对现代机器学习系统有充分的认识。
|
||||
|
||||
- **科研人员:**
|
||||
研发新型的机器学习模型不仅仅需要会使用基础的机器学习系统接口。同时,新型的模型需要给系统提供新的自定义算子(Custom
|
||||
Operators),又或者是会利用高级的分布式执行算子来实现大模型的开发。这一系列需求都需要对底层系统具有充分认识。
|
||||
|
||||
- **开发人员:**
|
||||
大量的数据和AI驱动的公司都部署了机器学习基础设施。这一设施的核心就是机器学习系统。因此了解机器学习系统有助于开发人员对于系统性能调优,以定位问题,并且根据业务需求对机器学习系统进行深度定制。
|
||||
|
||||
## 内容介绍
|
||||
|
||||
现代机器学习框架具有复杂的内部架构和繁多的外部相关组件。在本书中,我们将对其细致拆分,深入解读:
|
||||
|
||||
基础:
|
||||
|
||||
- **编程接口:** 为了支持海量应用,机器学习框架的编程接口设计具有大量的设计哲学,在易用性和性能之间取得平衡。本书将讲述编程接口的演进,机器学习工作流,定义深度学习模型,以及用C/C++进行框架开发。
|
||||
|
||||
- **计算图:** 机器学习框架需要支持自动微分,硬件加速器,多编程前端等。实现这些支持的核心技术是:计算图(Computational Graph)。本书将讲述计算图的基本构成,生成方法和调度策略。
|
||||
|
||||
性能进阶:
|
||||
|
||||
- **编译器前端:**
|
||||
机器学习框架需要利用编译器前端技术对计算图进行功能拓展和性能优化。本书将讲述常见的前端技术,包括类型推导,中间表示(Intermediate Representation),自动微分等。
|
||||
|
||||
- **编译器后端和运行时:**
|
||||
机器学习框架的一个核心目标是:如何充分利用异构硬件。这其中会涉及编译器后端技术,以及将计算图算子(Operator)调度到硬件上的运行时(Runtime)。本书将讲述计算图优化,算子选择,内存分配和计算调度与执行。
|
||||
|
||||
- **硬件加速器:**
|
||||
机器学习框架的基本运行单元是算子,而算子的实现必须充分利用硬件加速器(GPU和Ascend)的特性。本书将会讲述硬件加速器的基本构成原理和常见的高性能编程接口。
|
||||
|
||||
- **数据处理框架:**
|
||||
机器学习框架会集成高性能框架来进行数据预处理。本书将会讲述这一类数据处理框架在设计中需要达到的多个目标:易用性,高效性,保序性,分布式等。
|
||||
|
||||
- **模型部署:**
|
||||
在模型完成训练后,用户需要将模型部署到终端设备(如云服务器,移动终端和无人车)。这其中涉及到的模型转换,模型压缩,模型推理和安全保护等知识也会在本书中讨论。
|
||||
|
||||
- **分布式训练:**
|
||||
机器学习模型的训练需要消耗大量资源。越来越多的机器学习框架因此原生支持分布式训练。在本书中我们将会讨论常见的分布式训练方法(包括数据并行,模型并行和流水线并行),以及实现这些方法的系统架构(包括集合通讯和参数服务器)。
|
||||
|
||||
功能拓展:
|
||||
|
||||
- **深度学习推荐系统:** 推荐系统是目前机器学习应用最成功的领域之一。本书将会概括推荐系统的运作原理,详细描述大规模工业场景下的推荐系统架构设计。
|
||||
|
||||
- **联邦学习系统:** 随着数据保护法规和隐私保护的崛起,联邦学习正成为日益重要的研究领域。本书将会介绍联邦学习的常用方法以及相关系统实现。
|
||||
|
||||
- **强化学习系统:** 强化学习是走向通用人工智能的关键技术。本书将会介绍目前常见的强化学习系统(包括单智能体和多智能体等)。
|
||||
|
||||
- **可解释性AI系统:** 随着机器学习在安全攸关(Safety-critical)领域的应用,机器学习系统越来越需要对决策给出充分解释。本书将会讨论可解释AI系统的常用方法和落地实践经验。
|
||||
|
||||
- **机器人系统:** 机器人(无人车,无人机,家用机器人等)作为机器学习技术重要的应用领域,在最近数年得到了广泛应用。在实践中,机器人系统在实时性,安全性,鲁棒性等方面都有极高要求,这要求开发者具有算法和系统的双重思维,从而解决实际问题。本书中我们将结合最新研究成果和机器人系统实践经验讲解该类系统的设计原则和实现细节。
|
||||
|
||||
|
||||
我们在持续拓展拓展本书的内容,如元学习系统,自动并行,深度学习集群调度,绿色AI系统,图学习系统等。我们也非常欢迎社区对于新内容提出建议,贡献章节。
|
||||
|
||||
## 构建指南
|
||||
|
||||
请参考[构建指南](info/info.md)来了解如何构建本书的网页版本和PDF版本。
|
||||
|
||||
## 写作指南
|
||||
|
||||
我们欢迎大家来一起贡献和更新本书的内容。常见的贡献方式是提交PR来更新和添加Markdown文件。写作的风格和图片要求请参考[风格指南](info/style.md)。同时,机器学习领域涉及到大量的中英文翻译,相关的翻译要求请参考[术语指南](info/terminology.md)。
|
||||
@@ -0,0 +1,72 @@
|
||||
## 经典机器学习方法
|
||||
|
||||
大量经典机器学习算法,如 支持向量机(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$不断地单调减小,并且最终能够收敛。不过,算法可能收敛到局部最小值。
|
||||
|
||||
本章结束语:
|
||||
|
||||
在系统角度,机器学习的算法无论是什么算法,涉及到高维数据任务的现都是矩阵运算实现的。
|
||||
|
||||
## 参考文献
|
||||
|
||||
:bibliography:`../references/appendix.bib`
|
||||
@@ -0,0 +1,93 @@
|
||||
## 梯度下降与反向传播
|
||||
|
||||
上面大体上介绍了经典神经网络的内容,那么现在有一个问题,这些网络中的参数是如何确定的呢?如果要解决的问题是一个小感知器就能解决的话,参数可以人为地去确定。但是如果是一个深度网络的话,参数的确定需要自动化,也就是所谓的网络训练,而这个过程需要我们设定一个**损失函数**(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) :cite:`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 :cite:`KingmaAdam2014`、RMSProp :cite:`tieleman2012rmsprop`和
|
||||
Adagrad :cite:`duchi2011adagrad`等,在训练的过程中通过自动的方法来修改学习率,实现训练的快速收敛,到达最小值点。
|
||||
12
website/appendix_machine_learning_introduction/index.md
Normal file
12
website/appendix_machine_learning_introduction/index.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# 附录:机器学习介绍
|
||||
|
||||
本书假设读者有一定的机器学习算法基础,因此本章只会简略地介绍一下机器学习,其中的梯度下降方法对本书机器学习系统来说尤为重要,是必须掌握的内容。
|
||||
|
||||
```toc
|
||||
:maxdepth: 2
|
||||
:numbered:
|
||||
|
||||
neural_network
|
||||
gradient_descent
|
||||
classic_machine_learning
|
||||
```
|
||||
175
website/appendix_machine_learning_introduction/neural_network.md
Normal file
175
website/appendix_machine_learning_introduction/neural_network.md
Normal file
@@ -0,0 +1,175 @@
|
||||
## 神经网络
|
||||
|
||||
### 感知器
|
||||

|
||||
: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`
|
||||
|
||||

|
||||
:width:`600px`
|
||||
:label:`single_neuron_decision_boundary2`
|
||||
|
||||

|
||||
: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`
|
||||
|
||||
### 多个神经元
|
||||
|
||||

|
||||
:width:`600px`
|
||||
:label:`two_neurons2`
|
||||
|
||||
上述网络只有一个输出,若多个神经元在一起就可以有多个输出。 :numref:`two_neurons2`是有两个输出的网络,每个输出都和所有输入相连,所以也被称**全连接层**(Fully-Connected(FC) Layer),
|
||||
可由下述式子 :eqref:`fc_cal`表示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:`fc_cal`
|
||||
|
||||
如下式子表示了矩阵方法的实现:
|
||||
|
||||
$$
|
||||
{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`
|
||||
|
||||
### 多层感知器
|
||||
|
||||

|
||||
|
||||
**多层感知器**(Multi-Layer
|
||||
Perceptron,MLP) :cite:`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)。下面我们介绍一下其他常用的神经网络层。
|
||||
|
||||
### 卷积网络
|
||||
|
||||

|
||||
:width:`600px`
|
||||
:label:`conv_computation_v4`
|
||||
|
||||
**卷积神经网络** (Convolutional Neural
|
||||
Network,CNN) :cite:`lecun1989backpropagation`由多层**卷积层**(Convolutional
|
||||
Layer)组成,常用于计算机视觉任务 :cite:`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$。
|
||||
|
||||

|
||||
:width:`600px`
|
||||
:label:`pooling_v3`
|
||||
|
||||
卷积层和全连接层都是很常用的,但是卷积层在输入是高维度的图像时,需要的参数量远远小于全连接层。卷积层的运算和全连接层是类似的,前者基于高维度张量运算,后者基于二维矩阵运算。
|
||||
|
||||
### 时序模型
|
||||
|
||||
现实生活中除了图像还有大量时间序列数据,例如视频、股票价格等等。**循环神经网络**(Recurrent
|
||||
Neural Networks,RNN) :cite:`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`
|
||||
|
||||

|
||||
:width:`600px`
|
||||
:label:`rnn_simple_cell2`
|
||||
|
||||
然而这种简单的朴素循环神经网络有严重的信息遗忘问题。比如说我们的输入是"我是中国人,我的母语是___",隐状态记住了"中国人"的信息,使得网络最后可以预测出"中文"一词;但是如果句子很长的时候,隐状态可能记不住太久之前的信息了,比如说"我是中国人,我去英国读书,后来在法国工作,我的母语是___",这时候在最后的隐状态中关于"中国人"的信息可能会被因为多次的更新而遗忘了。
|
||||
为了解决这个问题,后面有人提出了各种各样的改进方法,其中最有名的是长短期记忆(Long
|
||||
Short-Term Memory,LSTM) :cite:`Hochreiter1997lstm`。关于时序的模型还有很多很多,比如近年来出现的Transformer :cite:`vaswani2017attention`等等。
|
||||
2
website/build_and_transform.sh
Normal file
2
website/build_and_transform.sh
Normal file
@@ -0,0 +1,2 @@
|
||||
d2lbook build html
|
||||
python3 tools/format_tables.py
|
||||
9
website/build_html.sh
Normal file
9
website/build_html.sh
Normal file
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
rm -rf _build/rst _build/html
|
||||
d2lbook build rst
|
||||
cp static/frontpage.html _build/rst/
|
||||
d2lbook build html
|
||||
cp -r static/image/* _build/html/_images/
|
||||
86
website/chapter_accelerator/accelerator_architecture.md
Normal file
86
website/chapter_accelerator/accelerator_architecture.md
Normal file
@@ -0,0 +1,86 @@
|
||||
## 加速器基本组成原理
|
||||
|
||||
上节主要介绍了加速器的意义以及设计思路,讲述了加速器与通用处理器在设计上的区别,可以看到加速器的硬件结构与CPU的硬件结构有着根本的不同,通常都是由多种片上缓存以及多种运算单元组成。本章节主要以GPU的Volta架构作为样例进行介绍。
|
||||
|
||||
### 硬件加速器的架构
|
||||
|
||||
现代GPU在十分有限的面积上实现了极强的计算能力和极高的储存器以及IO带宽。在一块高端的GPU中,晶体管数量已经达到主流CPU的两倍,而且显存已经达到了16GB以上,工作频率也达到了1GHz。GPU的体系架构由两部分组成,分别是流处理阵列和存储器系统,两部分通过一个片上互联网络连接。流处理器阵列和存储器系统都可以单独扩展,规格可以根据产品的市场定位单独裁剪。如GV100的组成 :cite:`2017NVIDIA`如 :numref:`gv100`所示:
|
||||
|
||||

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

|
||||
:width:`800px`
|
||||
:label:`gemm-algorith`
|
||||
|
||||
#### 编程接口使能加速器
|
||||
|
||||
- **算子库层级**
|
||||
|
||||
:label:`sec-accelerator-use-cublas`
|
||||
|
||||
在上述不同层级的编程方式中,直接调用算子加速库使能加速器无疑是最快捷高效的方式。NVIDIA提供了cuBLAS/cuDNN两类算子计算库,cuBLAS提供了使能张量计算核心的接口,用以加速矩阵乘法(GEMM)运算,cuDNN提供了对应接口加速卷积(CONV)运算等。
|
||||
以 :numref:`accelerator-programable-title`小节的GEMM运算为例,与常规CUDA调用cuBLAS算子库相似,通过cuBLAS加速库使能张量计算核心步骤包括:
|
||||
|
||||
1. 创建cuBLAS对象句柄且设置对应数学计算模式
|
||||
|
||||
```cpp
|
||||
cublasHandle_t handle;
|
||||
cublasStatus_t cublasStat = cublasCreate(&handle);
|
||||
cublasStat = cublasSetMathMode(handle, CUBLAS_TENSOR_OP_MATH);
|
||||
```
|
||||
|
||||
2. 分配和初始化矩阵内存空间及内容元素
|
||||
|
||||
```cpp
|
||||
size_t matrixSizeA = (size_t)M * K;
|
||||
cublasStat = cudaMalloc(&devPtrA[0], matrixSizeA * sizeof(devPtrA[0][0]));
|
||||
cublasStat = cublasSetMatrix(M, K, sizeof(A[0]), A, M, devPtrA[i], M);
|
||||
```
|
||||
|
||||
3. 调用对应计算函数接口
|
||||
|
||||
```cpp
|
||||
cublasStat = cublasGemmEx(handle, transa, transb, m, n, k, alpha,
|
||||
A, CUDA_R_16F, lda,
|
||||
B, CUDA_R_16F, ldb,
|
||||
beta, C, CUDA_R_16F, ldc, CUDA_R_32F, algo);
|
||||
```
|
||||
|
||||
4. 传回结果数据
|
||||
|
||||
```cpp
|
||||
cublasStat = cublasGetMatrix(M, N, sizeof(D[0]), devPtrD[i], M, D, M);
|
||||
```
|
||||
|
||||
5. 释放内存和对象句柄
|
||||
|
||||
```cpp
|
||||
cudaFree(devPtrA);
|
||||
cudaDestroy(handle);
|
||||
```
|
||||
|
||||
当然,由于加速器一般会受到矩阵形状、数据类型、排布方式等限制,因此在调用句柄和函数接口时要多加注意。如本例中,cuBLAS计算模式必须设置为$CUBLAS\_TENSOR\_OP\_MATH$,步长必须设置为8的倍数,输入数据类型必须为$CUDA\_R\_16F$等。按照如上方式即可通过cuBLAS算子库对 :numref:`accelerator-programable-title`实例使能张量计算核心,通过NVIDIA官方数据可知,该方式对于不同矩阵乘法计算规模,平均有4~10倍的提升,且矩阵规模越大,加速器提升效果越明显。
|
||||
|
||||
该方式由于能够隐藏体系结构细节,易用性较好,且一般官方提供的算子库吞吐量较高。但与此同时,这种算子颗粒度的库也存在一些问题,如不足以应对复杂多变的网络模型导致的算子长尾问题(虽然常规形式算子占据绝大多数样本,但仍有源源不断的新增算子,因其出现机会较少,算子库未对其进行有效优化。),以及错失了较多神经网络框架优化(如算子融合)的机会。
|
||||
|
||||
- **编程原语层级**
|
||||
|
||||
第二种加速器编程方式为编程原语使能加速器,如通过在Device端调用CUDA WMMA (Warp Matrix Multiply Accumulate) API接口。以线程束(即{Warp},是调度的基本单位)为操纵对象,使能多个张量计算核心。该方式在CUDA 9.0中被公开,程序员可通过添加API头文件的引用和命名空间定义来使用上述API接口。基于软硬件协同设计的基本思想,该层级编程API的设计多与架构绑定,如在Volta架构中WMMA操纵的总是$16\times16$大小的矩阵块,并且操作一次跨两张量计算核心进行处理,本质是与张量计算核心如何集成进SM中强相关的。在Volta架构下,针对FP16输入数据类型,NVIDIA官方提供了三种不同矩阵规模的WMMA乘累加计算接口,分别为$16\times16\times16$,$32\times8\times16$,$8\times32\times16$。
|
||||
该API接口操纵的基本单位为Fragment,是一种指明了矩阵含义(乘法器/累加器)、矩阵形状($WMMA\_M, WMMA\_N, WMMA\_K$)、数据类型(FP16/FP32)、排布方式($row\_major/ col\_major$)等信息的模板类型,包括如下:
|
||||
|
||||
```cpp
|
||||
wmma::fragment<wmma::matrix_a, WMMA_M, WMMA_N, WMMA_K, half, wmma::row_major> a_frag;
|
||||
wmma::fragment<wmma::matrix_b, WMMA_M, WMMA_N, WMMA_K, half, wmma::col_major> b_frag;
|
||||
wmma::fragment<wmma::accumulator, WMMA_M, WMMA_N, WMMA_K, float> acc_frag;
|
||||
wmma::fragment<wmma::accumulator, WMMA_M, WMMA_N, WMMA_K, float> c_frag;
|
||||
```
|
||||
使用时,需要将待执行乘法操作矩阵块的数据加载到寄存器,作为Fragment,在将累加Fragment初始化/清零操作后,通过张量计算核心执行乘累加运算,最后将运算结果的Fragment存回到内存。与上述操作对应的,NVIDIA提供了$wmma.load\_matrix\_sync(), wmma.store\_matrix\_sync()$接口用于将参与计算的子矩阵块写入/载出Fragment片段;$wmma.fill\_fragment()$接口用于初始化对应Fragment的数据;$wmma.mma\_sync()$接口用于对Fragment进行乘累加运算。
|
||||
|
||||
- **指令层级**
|
||||
|
||||
在NVIDIA PTX ISA (Instruction Set Architecture)中提供了另一个编程接口,如Volta架构中的$mma.sync.m8n8k4$指令,它使用$M=8, N=8, K=4$的形状配置执行乘累加操作。该API接口操纵的基本单位为数据元素,除了需要指明矩阵尺寸(即修饰符$.m8n8k4$),还需要指明数据的排布类型(用修饰符$.row$或$.col$)以及输入累加器D、矩阵A、矩阵B及输出累加器C的数据格式(使用修饰符$.f32$或$.f16$等)。如要使用PTX指令集,还需要参考官方文档按照相应的语法规则编写,如代码所示。
|
||||
|
||||
```cpp
|
||||
half_t *a, *b;
|
||||
float *C, *D;
|
||||
unsigned const* A = reinterpret_cast<unsigned const*>(a);
|
||||
unsigned const* B = reinterpret_cast<unsigned const*>(b);
|
||||
|
||||
asm volatile(
|
||||
"mma.sync.aligned.m8n8k4.row.row.f32.f16.f16.f32 "
|
||||
"{%0,%1,%2,%3,%4,%5,%6,%7}, {%8,%9}, {%10,%11}, "
|
||||
"{%12,%13,%14,%15,%16,%17,%18,%19};\n"
|
||||
: "=f"(D[0]), "=f"(D[1]), "=f"(D[2]), "=f"(D[3]), "=f"(D[4]),
|
||||
"=f"(D[5]), "=f"(D[6]), "=f"(D[7])
|
||||
: "r"(A[0]), "r"(A[1]), "r"(B[0]), "r"(B[1]), "f"(C[0]),
|
||||
"f"(C[1]), "f"(C[2]), "f"(C[3]), "f"(C[4]), "f"(C[5]),
|
||||
"f"(C[6]), "f"(C[7]));
|
||||
);
|
||||
```
|
||||
|
||||
使用时,直接将数据元素作为输入传入(对于FP16的数据元素作为$unsigned$类型传入),与上述操作对应的,NVIDIA提供了$ldmatrix$指令用于从共享内存中加载数据到Fragment。
|
||||
|
||||
作为一个更细粒度的指令,mma指令可以组成更加多样化形状的Warp范围的WMMA API接口,可以控制线程束内线程与数据的映射关系,并允许AI编译器自动/手动显式地管理内存层次结构之间的矩阵分解,因此相比于直接应用NVCUDA::WMMA API具有更好的灵活性。
|
||||
|
||||
#### 算子编译器编程使能加速器
|
||||
|
||||
基于算子编译器使能加速器实现矩阵乘的流程则对用户更加友好。以在Ascend中使用TBE为例,用户只需基于python定义矩阵乘的tensor信息(数据类型及形状等),调用对应TBE接口即可。如代码所示:
|
||||
|
||||
```python
|
||||
a_shape = (1024, 256)
|
||||
b_shape = (256, 512)
|
||||
bias_shape = (512, )
|
||||
in_dtype = "float16"
|
||||
dst_dtype = "float32"
|
||||
tensor_a = tvm.placeholder(a_shape, name='tensor_a', dtype=in_dtype)
|
||||
tensor_b = tvm.placeholder(b_shape, name='tensor_b', dtype=in_dtype)
|
||||
tensor_bias = tvm.placeholder(bias_shape, name='tensor_bias', dtype=dst_dtype)
|
||||
res = te.lang.cce.matmul(tensor_a, tensor_b, False, False, False, dst_dtype=dst_dtype, tensor_bias=tensor_bias)
|
||||
```
|
||||
24
website/chapter_accelerator/index.md
Normal file
24
website/chapter_accelerator/index.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# 硬件加速器
|
||||
|
||||
上一章节详细讨论了后端的计算图优化、算子选择以及内存分配。当前主流深度学习模型大多基于神经网络实现,无论是训练还是推理,都会产生海量的计算任务,尤其是涉及矩阵乘法这种高计算任务的算子。然而,通用处理器芯片如CPU在执行这类算子时通常耗时较大,难以满足训练和推理任务的需求。因此工业界和学术界都将目光投向特定领域的加速器芯片设计,希望以此来解决算力资源不足的问题。
|
||||
|
||||
|
||||
本章将会着重介绍加速器的基本组成原理,并且以矩阵乘法为例,介绍在加速器上的编程方式及优化方法。
|
||||
|
||||
本章的学习目标包括:
|
||||
|
||||
- 掌握加速器的基本组成
|
||||
|
||||
- 掌握矩阵乘法的常见优化手段
|
||||
|
||||
- 理解编程API的设计理念
|
||||
|
||||
```toc
|
||||
:maxdepth: 2
|
||||
|
||||
accelerator_introduction
|
||||
accelerator_architecture
|
||||
accelerator_programming
|
||||
accelerator_practise
|
||||
summary
|
||||
```
|
||||
21
website/chapter_accelerator/summary.md
Normal file
21
website/chapter_accelerator/summary.md
Normal file
@@ -0,0 +1,21 @@
|
||||
## 总结
|
||||
|
||||
- 面向深度学习计算任务,加速器通常都是由多种片上缓存以及多种运算单元组成来提升性能。
|
||||
|
||||
- 未来性能增长需要依赖架构上的改变,即需要利用可编程的硬件加速器来实现性能突破。
|
||||
|
||||
- 出于计算效率和易用性等原因,加速器一般会具有多个等级的编程方式,包括:算子库层级,编程原语层级和指令层级。
|
||||
|
||||
- 越底层的编程方式越能够灵活地控制加速器,但同时对程序员的能力要求也越高。
|
||||
|
||||
|
||||
## 扩展阅读
|
||||
|
||||
- CUDA编程指导 [CUDA](https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html)
|
||||
- 昇腾社区 [Ascend](https://gitee.com/ascend)
|
||||
- MLIR应用进展 [MLIR](https://mlir.llvm.org/talks)
|
||||
|
||||
|
||||
## 参考文献
|
||||
|
||||
:bibliography:`../references/accelerator.bib`
|
||||
@@ -0,0 +1,235 @@
|
||||
## 计算调度与执行
|
||||
|
||||
经过算子选择与内存分配之后,计算任务可以通过运行时完成计算的调度与在硬件上的执行。根据是否将算子编译为计算图,计算的调度可以分为单算子调度与计算图调度两种方式,例如在MindSpore中分别提供了PyNative模式和Graph模式。而根据硬件提供的能力差异,计算图的执行方式又可以分为逐算子下发执行的交互式执行以及将整个计算图或者部分子图一次性下发到硬件的下沉式执行两种模式。
|
||||
|
||||
### 单算子调度
|
||||
|
||||
单算子调度是相对于计算图而言,算法或者模型中包含的算子通过Python语言的运行时被逐个调度执行。例如PyTorch的默认执行方式,TensorFlow的eager模式,以及MindSpore的PyNative模式。以MindSpore为例,如代码所示。
|
||||
|
||||
```python
|
||||
import mindspore.nn as nn
|
||||
from mindspore import context
|
||||
from mindspore import ms_function
|
||||
|
||||
# 以单算子方式执行后续计算中的算子。
|
||||
context.set_context(mode=context.PYNATIVE_MODE)
|
||||
|
||||
class Computation(nn.Cell):
|
||||
def construct(self, x, y):
|
||||
m = x * y
|
||||
n = x - y
|
||||
print(m)
|
||||
z = m + n
|
||||
return z
|
||||
|
||||
compute = Computation()
|
||||
c = compute(1, 2)
|
||||
print(c)
|
||||
```
|
||||
|
||||
上述脚本将所有的计算逻辑定义在Computation类的construct方法中,由于在脚本开头的context中预先设置了单算子执行模式,construct中的计算将被Python的运行时逐行调用执行,同时可以在代码中的任意位置添加print命令以便打印中间的计算结果。
|
||||
|
||||
单算子执行的调用链路如 :numref:`single_op_exec`所示,算子在Python侧被触发执行后,会经过机器学习框架初始化,其中需要确定包括算子的精度,输入与输出的类型和大小以及对应的硬件设备等信息,接着框架会为该算子分配计算所需的内存,最后交给具体的硬件计算设备完成计算的执行。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`single_op_exec`
|
||||
|
||||
单算子调度方式的好处在于其灵活性,由于算子直接通过Python运行时调度,一方面可以表达任意复杂的计算逻辑,尤其是在需要复杂控制流以及需要Python原生数据结构支持来实现复杂算法的场景;另一方面单算子调度对于程序正确性的调试非常便利,开发人员可以在代码执行过程中打印任意需要调试的变量;最后一点是通过Python运行时驱动算子的方式,可以在计算中与Python庞大而丰富的生态库协同完成计算任务。
|
||||
|
||||
### 计算图调度
|
||||
|
||||
虽然单算子调度具有如上所述的优点,其缺点也很明显。一方面是难于进行计算性能的优化,原因是由于缺乏计算图的全局信息,单算子执行时无法根据上下文完成算子融合,代数化简等优化;另一方面由于缺乏计算的拓扑关系,整个计算只能串行调度执行,即无法通过运行时完成并行计算。例如上述示例代码的计算逻辑可以表达为 :numref:`graph_exec`所示。由该计算图可以看出,其中乘法和减法之间并没有依赖关系,因此这两个计算可以并行执行,而这样的并行执行信息只有将计算表达为计算图后才能完成分析,这也是计算图调度相对于单算子调度的优势之一。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`graph_exec`
|
||||
|
||||
下面我们开始介绍计算图的调度方式,在一个典型的异构计算环境中,主要存在CPU、GPU以及NPU等多种计算设备,因此一张计算图可以由运行在不同设备上的算子组成为异构计算图。 :numref:`computation_graph`展示了一个典型的由异构硬件共同参与的计算图。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`computation_graph`
|
||||
|
||||
所述计算图由如下几类异构硬件对应的算子组成:
|
||||
|
||||
- **CPU算子**:由C++语言编写实现并在主机上通过CPU执行的算子,CPU计算的性能取决于是否能够充分利用CPU多核心的计算能力。
|
||||
|
||||
- **GPU算子**:以英伟达GPU芯片为例,通过在主机侧将GPU
|
||||
Kernel逐个下发到GPU设备上,由GPU芯片执行算子的计算逻辑,由于芯片上具备大量的并行执行单元,可以为高度并行的算法提供强大的加速能力。
|
||||
|
||||
- **NPU算子**:以华为Ascend芯片为例,
|
||||
Ascend是一个高度集成的SoC芯片,NPU的优势是支持将部分或整个计算图下沉到芯片中完成计算,计算过程中不与Host发生交互,因此具备较高的计算性能。
|
||||
|
||||
- **Python算子**:在执行模式上与CPU算子类似,都是由主机上的CPU执行计算,区别在于计算逻辑是由Python语言的运行时通过Python解释器解释执行。
|
||||
|
||||
异构计算图能够被正确表达的首要条件是准确标识算子执行所在的设备,例如异构计算图 :numref:`computation_graph`中所标识的CPU、GPU和Ascend
|
||||
Kernel,以及被标记为被Python语言运行时执行的Python
|
||||
Kernel。主流框架均提供了指定算子所在运行设备的能力,以MindSpore为例,一段简单的异构计算代码如下所示。
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
from mindspore import Tensor
|
||||
import mindspore.ops.operations as ops
|
||||
from mindspore.common.api import ms_function
|
||||
|
||||
# 创建算子并指定执行算子的硬件设备
|
||||
add = ops.Add().add_prim_attr('primitive_target', 'CPU')
|
||||
sub = ops.Sub().add_prim_attr('primitive_target', 'GPU')
|
||||
|
||||
# 指定按照静态计算图模式执行函数
|
||||
@ms_function
|
||||
def compute(x, y, z):
|
||||
r = add(x, y)
|
||||
return sub(r, z)
|
||||
|
||||
# 创建实参
|
||||
x = Tensor(np.ones([2, 2]).astype(np.float32))
|
||||
y = Tensor(np.ones([2, 2]).astype(np.float32))
|
||||
z = Tensor(np.ones([2, 2]).astype(np.float32))
|
||||
|
||||
# 执行计算
|
||||
output = compute(x, y, z)
|
||||
```
|
||||
|
||||
上述代码片段完成了x + y - z的计算逻辑,其中Add算子被设置为在CPU上执行,Sub算子被设置为在GPU上执行,从而形成了CPU与GPU协同的异构计算,通过类似的标签机制,可以实现任意复杂的多硬件协同的异构计算表达。
|
||||
另外一类较为特殊的异构是Python算子,Python语言的优势在于表达的灵活性和开发效率,以及丰富的周边生态,因此将Python算子引入到计算图中和其他异构硬件的算子协同计算,对计算的灵活性会产生非常大的帮助。与CPU、GPU分别执行在不同设备上的异构不同,Python算子和C++实现的CPU算子都是通过主机侧的CPU核执行,差异在于Python算子是通过统一的计算图进行描述,因此也需要在后端运行时中触发执行。为了在计算图中能够表达Python算子,框架需要提供相应的支持。
|
||||
|
||||
完成计算图中算子对应设备的标记以后,计算图已经准备好被调度与执行,根据硬件能力的差异,可以将异构计算图的执行分为三种模式,分别是逐算子交互式执行,整图下沉执行与子图下沉执行。交互式执行主要针对CPU和GPU的场景,计算图中的算子按照输入和输出的依赖关系被逐个调度与执行;而整图下沉执行模式主要是针对NPU芯片而言,这类芯片主要的优势是能够将整个神经网络的计算图一次性下发到设备上,无需借助主机的CPU能力而独立完成计算图中所有算子的调度与执行,减少了主机和芯片的交互次数,借助NPU的张量加速能力,提高了计算效率和性能;子图下沉执行模式是前面两种执行模式的结合,由于计算图自身表达的灵活性,对于复杂场景的计算图在NPU芯片上进行整图下沉执行的效率不一定能达到最优,因此可以将对于NPU芯片执行效率低下的部分分离出来,交给CPU或者GPU等执行效率更高的设备处理,而将部分更适合NPU计算的子图下沉到NPU进行计算,这样可以兼顾性能和灵活性两方面。
|
||||
|
||||
上述异构计算图可以实现两个目的,一个是异构硬件加速,将特定的计算放置到合适的硬件上执行;第二个是实现算子间的并发执行,从计算图上可以看出,kernel_1和kernel_2之间没有依赖关系,kernel_3和kernel_4之间也没有依赖关系,因此这两组CPU和GPU算子在逻辑上可以被框架并发调用,而kernel_5依赖kernel_3和kernel_4的输出作为输入,因此kernel_5需要等待kernel_3和kernel_4执行完成后再被触发执行。
|
||||
|
||||
虽然在计算图上可以充分表达算子间的并发关系,在实际代码中会产生由于并发而引起的一些不预期的副作用场景,例如如下代码所示:
|
||||
|
||||
```python
|
||||
import mindspore as ms
|
||||
from mindspore import Parameter, Tensor
|
||||
import mindspore.ops.operations as ops
|
||||
from mindspore.common.api import ms_function
|
||||
|
||||
# 定义全局变量
|
||||
x = Parameter(Tensor([1.0], ms.float32), name="x")
|
||||
y = Tensor([0.2], ms.float32)
|
||||
z = Tensor([0.3], ms.float32)
|
||||
|
||||
# 指定按照静态计算图模式执行函数
|
||||
@ms_function
|
||||
def compute(y, z):
|
||||
ops.Assign()(x, y)
|
||||
ops.Assign()(x, z)
|
||||
r = ops.Sub()(x, y)
|
||||
return r
|
||||
|
||||
compute(y, z)
|
||||
```
|
||||
|
||||
上述代码表达了如下计算逻辑:
|
||||
|
||||
```text
|
||||
x = y
|
||||
x = z
|
||||
x = x - y
|
||||
```
|
||||
|
||||
这段简单的计算逻辑翻译到计算图上可以表示为 :numref:`side_effect_1`所示。
|
||||
|
||||

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

|
||||
:width:`800px`
|
||||
:label:`side_effect_2`
|
||||
|
||||
|
||||
图中虚线箭头表达了算子之间的依赖关系,添加依赖关系后,算子会按照Assign_1、Assign_2、Sub_1的顺序串行执行,与代码原本的语义保持一致。
|
||||
|
||||
### 交互式执行
|
||||
|
||||
如上所述,交互式执行模式下,框架的运行时根据计算图中算子的依赖关系,按照某种执行序(例如广度优先序)逐个将算子下发到硬件上执行。为了助于理解和对比,先引入非异构计算图(计算图中的算子都是在同一类设备上)的执行方式,异构计算图的执行是基于非异构计算图基础之上的。
|
||||
|
||||
1、非异构计算图的执行方式
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`graph_exec_1`
|
||||
|
||||
如 :numref:`graph_exec_1`是一张非异构计算图,计算图上全部Kernel均为GPU算子,执行方式一般分为串行执行和并行执行:
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`graph_exec_2`
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`graph_exec_3`
|
||||
|
||||
- **串行执行**:将计算图展开为执行序列,按照执行序逐个串行执行,如 :numref:`graph_exec_2`所示。其特点为执行顺序固定,单线程执行,对系统资源要求相对较低。
|
||||
|
||||
- **并行执行**:将计算图按照算子之间的依赖关系展开,有依赖关系的算子通过输入依赖保证执行顺序,没有依赖关系的算子则可以并行执行,如 :numref:`graph_exec_3`所示,Kernel_1和Kernel_2没有依赖可以并行执行,Kernel_3和Kernel_4没有依赖可以并行执行。其特点为执行顺序不固定,每轮执行的算子顺序大概率不一样,多线程执行,对系统资源要求相对较高。
|
||||
|
||||
串行执行和并行执行各有优点和缺点,总结对比见 :numref:`serial_vs_parallel`。
|
||||
|
||||
:串行执行和并行执行之对比
|
||||
|
||||
| 执行方式 | 串行执行 | 并行执行 |
|
||||
|--------------|----------|------|
|
||||
|算子执行顺序 | 固定 | 不固定 |
|
||||
|算子执行线程 |单线程 | 多线程 |
|
||||
|所需执行资源 | 较低 | 较高 |
|
||||
:label:`serial_vs_parallel`
|
||||
|
||||
2、异构计算图的执行方式
|
||||
|
||||

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

|
||||
:width:`800px`
|
||||
:label:`graph_exec_5`
|
||||
|
||||
将一张异构计算图切分为多个子计算图后,执行方式一般分为子图拆分执行和子图合并执行:
|
||||
|
||||
- **子图拆分执行**:将切分后的多个子图分开执行,即一个子图执行完再执行另一个子图,如 :numref:`graph_exec_6`所示,上一个子图的输出数据会传输给下一个子图的输入数据,并且下一个子图需要将输入数据拷贝为本图的device数据,如Graph_2\_GPU需要将Graph_1\_CPU的输出数据从CPU拷贝到GPU,反过来Graph_3\_CPU需要将Graph2GPU的输出数据从GPU拷贝到CPU,子图之间互相切换执行有一定的开销。
|
||||
|
||||
- **子图合并执行**:将切分后的多个子图进行合并,合并为一个整体的DAG执行,如 :numref:`graph_exec_7`所示,通过算子的设备属性来插入拷贝算子以实现不同设备上的算子数据传输,并且拷贝算子也是进入整图中的,从而形成一个大的整图执行,减少子图之间的切换执行开销。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`graph_exec_6`
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`graph_exec_7`
|
||||
|
||||
由于子图合并执行能够减少子图之间的切换执行开销,因此一般来说子图合并执行性能较高,总结对比见 :numref:`partitioning_vs_merging`。
|
||||
|
||||
:子图拆分和子图合并之对比
|
||||
|
||||
| 执行方式 | 子图拆分 | 子图合并|
|
||||
| --------------|------------------|--------------|
|
||||
| 异构数据传输 | 子图之间拷贝 | 算子之间拷贝|
|
||||
| 执行额外开销 | 子图切换执行开销 | 无|
|
||||
| 执行并发粒度 | 子图并发 | 算子原生并发|
|
||||
:label:`partitioning_vs_merging`
|
||||
|
||||
|
||||
3、异构计算图的执行加速
|
||||
|
||||
前面讲述了非异构计算图的两种执行方式和异构计算图的两种执行方式,其中异构计算图又是在非异构计算图的基础之上,因此异构计算图按照两两组合共有四种执行方式,以MindSpore为例,采用的是子图合并并行执行,示例图如 :numref:`graph_exec_5`所示,首先是作为一张整图来执行可以避免子图切换的执行开销,然后在整图内并行执行,可以最大粒度的发挥并发执行优势,达到最优的执行性能。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`graph_exec_8`
|
||||
|
||||
### 下沉式执行
|
||||
|
||||
下沉式执行是通过专用芯片的SoC架构,将整个或部分计算图一次性调度到芯片上以完成全量数据的计算。例如对于Ascend芯片,多个Ascend算子组成的计算图可以在执行前被编译成为一个Task,通过Ascend驱动程序提供的接口,将包含多个算子的Task一次性下发到硬件上调度执行。因此上例中可以将Ascend的算子Kernel_7和Kernel_8优化为一个子图Graph_4\_Ascend,再将该子图编译成为一个Task,并下沉到Ascend上执行,如 :numref:`graph_exec_8`所示。
|
||||
|
||||
下沉式执行由于避免了在计算过程中主机侧和设备侧的交互,因此可以获得更好的整体计算性能。然而下沉式执行也存在一些局限,例如在动态shape算子,复杂控制流等场景下会面临较大的技术挑战。
|
||||
56
website/chapter_backend_and_runtime/graph_optimizer.md
Normal file
56
website/chapter_backend_and_runtime/graph_optimizer.md
Normal file
@@ -0,0 +1,56 @@
|
||||
## 计算图优化
|
||||
|
||||
后端的计算图优化主要是针对硬件的优化,根据优化适用于所有硬件还是只适合特定硬件,可以分为通用硬件优化和特定硬件优化,例如为了适配硬件指令限制而做的子图变换和与特定硬件无关的算子内存IO优化。
|
||||
|
||||
### 通用硬件优化
|
||||
|
||||
通用硬件优化主要指与特定硬件类型无关的计算图优化,优化的核心是子图的等价变换:在计算图中尝试匹配特定的子图结构,找到目标子图结构后,通过等价替换方式,将其替换成对硬件更友好的子图结构。
|
||||
|
||||
以优化内存IO为例。深度学习算子按其对资源的需求可以分为两类:
|
||||
计算密集型算子,这些算子的时间绝大部分花在计算上,如卷积、全连接等;
|
||||
访存密集型算子,这些算子的时间绝大部分花在访存上,他们大部分是Element-Wise算子,例如 ReLU、Element-Wise Sum等。
|
||||
在典型的深度学习模型中,一般计算密集型和访存密集型算子是相伴出现的,最简单的例子是“Conv + ReLU”。Conv卷积算子是计算密集型,ReLU算子是访存密集型算子,ReLU算子可以直接取Conv算子的计算结果进行计算,因此可以将二者融合成一个算子来进行计算,从而减少内存访问延时和带宽压力,提高执行效率。
|
||||
|
||||
例如:“Conv + Conv + Sum + ReLU”的融合,从图\ref{fig:ch07/ch07-compiler-backend-03}中可以看到融合后的算子减少了两个内存的读和写的操作,优化了Conv的输出和Sum的输出的读和写的操作。
|
||||
|
||||

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

|
||||
:width:`800px`
|
||||
:label:`graph_kernel`
|
||||
|
||||
:numref:`graph_kernel`中,算子拆解阶段(Expander)将计算图中一些复杂算子(composite
|
||||
op,图中Op1、Op3、Op4)展开为计算等价的基本算子组合(
|
||||
图中虚线正方形框包围着的部分);在算子聚合阶段(Aggregation),将计算图中将基本算子(basic
|
||||
op,如图中Op2)、拆解后的算子(expanded
|
||||
op)组合融合,形成一个更大范围的算子组合;在算子重建阶段(Reconstruction)中,按照输入tensor到输出tensor的仿射关系将基本算子进行分类:elemwise、
|
||||
broadcast、reduce、transform等,并在这基础上归纳出不同的通用计算规则(如
|
||||
elemwise + reduce 规则:elemwise +
|
||||
reduce在满足一定条件后可以高效执行),根据这些计算规则不断地从这个大的算子组合上进行分析、筛选,最终重新构建成新的算子(如图中虚线正方形包围的两个算子
|
||||
New Op1 和 New
|
||||
Op2)。图算融合通过对计算图结构的拆解和聚合,可以实现跨算子边界的联合优化;并在算子重建中,通过通用的计算规则,以必要的访存作为代价,生成对硬件更友好、执行更高效的新算子。
|
||||
|
||||
### 特定硬件优化
|
||||
|
||||
特定硬件优化是指该计算图的优化是在特定硬件上才能做的优化,常见的基于硬件的优化包括由于硬件指令的限制而做的优化,特定硬件存储格式导致的优化等。
|
||||
|
||||
1、硬件指令限制
|
||||
|
||||
在一些特定的硬件上,IR中计算节点没有直接对应的硬件算子,只能通过子图的变换来达到子图中所有算子在对应的硬件上的存在。例如在MindSpore中,昇腾芯片上的Concat算子,只支持有限的输入个数(63个),因此当前端IR上的输入个数大于限制输入的时候,需要将该计算节点拆分成等价的多个Concat节点,如 :numref:`concat`所示:
|
||||
当Concat有100个输入时,单个算子只支持最多63个输入,此时会将该计算节点拆分成两个Concat节点,分别为63个输入和37个输入的两个算子。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`concat`
|
||||
|
||||
2、数据排布格式的限制
|
||||
|
||||
针对不同特点的计算平台和不同的算子,为了追求最好的性能,一般都需要选择不同的数据排布格式(Format),而这些排布格式可能跟框架缺省的排布格式是不一样的。在这种情况下,一般的做法是算子在执行完成后对输出插入一个格式转换操作,把排布格式转换回框架的缺省排布格式,这就引入了额外的内存操作。以 :numref:`transdata`为例,在昇腾平台上Conv算子在输入和输出的内存排布为5HD时是性能最优的,所以可以看到Conv算子输出结果的格式是5HD,然后通过一个转换操作转回了框架缺省的NCHW,紧接着,后面又是一个Conv算子,它需要5HD的输入,所以又做了一个NCHW到5HD的转换。我们很容易看出,虚线框内的两个转换操作互为逆操作,可以相互抵消。通过对计算图的模式匹配,可以将该类型的操作消除。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`transdata`
|
||||
31
website/chapter_backend_and_runtime/index.md
Normal file
31
website/chapter_backend_and_runtime/index.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# 编译器后端和运行时
|
||||
|
||||
在上一章节,详细讲述了一个AI编译器前端的主要功能,重点介绍了中间表示以及自动微分。在得到中间表示后,如何充分利用硬件资源高效地执行,是编译器后端和运行时要解决的问题。
|
||||
|
||||
在本章节中, 将会介绍AI编译器后端的一些基本概念,详细描述后端的计算图优化、算子选择等流程。通过对编译器前端提供的中间表示进行优化,充分发挥硬件能力,从而提高程序的执行效率。在此基础上,介绍运行时是如何对计算任务进行内存分配以及高效地调度执行。
|
||||
|
||||
本章的学习目标包括:
|
||||
|
||||
- 了解编译器后端和运行时的作用
|
||||
|
||||
- 掌握计算图优化的常用方法
|
||||
|
||||
- 掌握算子选择的常用方法
|
||||
|
||||
- 掌握内存分配的常用方法
|
||||
|
||||
- 掌握计算图调度和执行的常用方法
|
||||
|
||||
- 了解目前算子编译器的基本特点以及其尚未收敛的几个问题
|
||||
|
||||
```toc
|
||||
:maxdepth: 2
|
||||
|
||||
overview
|
||||
graph_optimizer
|
||||
kernel_selecter
|
||||
memory_allocator
|
||||
compute_schedule_and_execute
|
||||
op_compiler
|
||||
summary
|
||||
```
|
||||
84
website/chapter_backend_and_runtime/kernel_selecter.md
Normal file
84
website/chapter_backend_and_runtime/kernel_selecter.md
Normal file
@@ -0,0 +1,84 @@
|
||||
## 算子选择
|
||||
|
||||
过计算图优化后,需要对IR图上的每个节点进行算子选择,才能生成真正在设备上执行的算子序列。由于IR图上的节点可能有后端的很多算子与其对应,不同规格的算子在不同的情况下执行效率各不相同,在算子选择阶段的主要任务就是如何根据IR图中的信息在众多算子中选择出最合适的一个算子去目标设备上执行。
|
||||
|
||||
### 算子选择的基础概念
|
||||
|
||||
经历了后端的图优化后,IR图中的每一个节点都有一组算子与之对应。此时的IR图中的每一个节点可以认为是用户可见的最小硬件执行单元,代表了用户代码的一个操作,对于这个操作还没有具体生成有关设备信息的细节描述。这些信息是算子选择所选择的内容信息,称之为算子信息。算子信息主要包括以下内容:
|
||||
|
||||
1. 针对不同特点的计算平台和不同的算子,为了追求最好的性能,一般都需要选择不同的数据排布格式。机器学习系统常见的数据排布格式有NCHW和NHWC等。
|
||||
|
||||
2. 对于不同的硬件支持不同的计算精度,例如float32、float16和int32等。算子选择需要在所支持各种数据类型的算子中选择出用户所设定的数据类型最为相符的算子。
|
||||
|
||||
**数据排布格式**
|
||||
|
||||
机器学习系统中很多运算都会转换成为矩阵的乘法,例如卷积运算。我们知道矩阵乘法$A\times B = C$
|
||||
是以A的一行乘以B的一列求和后得到C的一个元素。以 :numref:`matmuldatalayout`为例,在 :numref:`matmuldatalayout`的上方,矩阵数据的存储是按照行优先来进行存储,虽然B在存储时是按照行存储,但是读取数据时却按照列进行读取,假如我们能把B的格式进行转换转换为列存储,例如 :numref:`matmuldatalayout`下方所示,这样就可以通过访问连续内存的方式加快数据访问速度进而提升运算速度。由此可见不同的数据排布方式对性能有很大影响。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`matmuldatalayout`
|
||||
|
||||
在机器学习系统中常见的数据格式一般有两种,分别为NCHW类型和NHWC类型。其中N代表了数据输入的批大小,C代表了图像的通道,H和W分别代表图像输入的高和宽。:numref:`data_format`展示了BatchSize为2,通道数16和大小为5\*4的数据逻辑示意图。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`data_format`
|
||||
|
||||
但是计算机的存储并不能够直接将这样的矩阵放到内存中,需要将其展平成1维后存储,这样就涉及逻辑上的索引如何映射成为内存中的索引,即如何根据逻辑数据索引来映射到内存中的1维数据索引。
|
||||
|
||||
对于NCHW的数据是先取W轴方向数据,再取H轴方向数据,再取C轴方向,最后取N轴方向。其中物理存储与逻辑存储的之间的映射关系为
|
||||
$$offsetnchw(n,c,h,w) = n*CHW + c*HW + h*W +w$$
|
||||
如 :numref:`nchw`所示,这种格式中,是按照最低维度W轴方向进行展开,W轴相邻的元素在内存排布中同样是相邻的。如果需要取下一个图片上的相同位置的元素,就必须跳过整个图像的尺寸($C*H*W$)。比如有8张32\*32的RGB图像,此时$N=8,C=3,H=32,W=32$。在内存中存储它们需要先按照W轴方向进行展开,然后按照H轴排列,这样之后便完成了一个通道的处理,之后按照同样的方式处理下一个通道。处理完全部通道后,处理下一张图片。PyTorch和MindSpore框架默认使用NCHW格式。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`nchw`
|
||||
|
||||
类似的NHWC数据格式是先取C方向数据,再取W方向,然后是H方向,最后取N方向。NHWC是Tensorflow默认的数据格式。这种格式在PyTorch中称为Channel-Last。
|
||||
$$offsetnhwc(n,h,w,c) = n*HWC + h*WC + w*C +c$$
|
||||
:numref:`nchwandnhwc`展示了不同数据格式下逻辑排布到内存物理侧数据排布的映射。\[x:1\]代表从最内侧维度到最下一维度的索引变换。比如\[a:1\]表示当前行W轴结束后,下一个H轴排布。\[b:1\]表示最内侧C轴排布完成后进行按照W轴进行排列。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`nchwandnhwc`
|
||||
|
||||
上述的数据存储格式具有很大的灵活性,很多框架都采用上述的两种格式作为默认的数据排布格式。但是在硬件上对数据操作时,此时的数据排布可能还不是最优的。在机器学习系统中,用户输入的数据往往会远远大于计算部件一次性计算所能容纳的最大范围,所以此时必须将输入的数据进行切片分批送到运算部件中进行运算。为了加速运算很多框架又引入了一些块布局格式来进行进一步的优化,这种优化可以使用一些硬件的加速指令,对数据进行搬移和运算。比如oneDNN上的nChw16c
|
||||
和nChw8c
|
||||
格式,以及Ascend芯片的5HD等格式。这种特殊的数据格式与硬件更为贴合,可以快速的将矩阵向量化,并且极大的利用片内缓存。
|
||||
|
||||
**数据精度**
|
||||
|
||||
通常深度学习的系统,使用的是单精度(float32)表示。这种数据类型占用32位内存。还有一种精度较低的数据类型为半精度(float16),其内部占用了16位的内存。由于很多硬件会对半精度数据类型进行优化,半精度的计算吞吐量可以是单精度的$2\sim 8$倍,且半精度占用的内存更小,这样可以输入更大的批大小(BatchSize),进而减少总体训练时间。接下来详细看一下半精度浮点数与精度浮点数的区别。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`floatdtype`
|
||||
|
||||
如 :numref:`floatdtype`中Sig代表符号位,占1位,表示了机器数的正负,Exponent表示指数位,Mantissa为尾数位。其中float16类型的数据采用二进制的科学计数法转换为十进制的计算方式如式$$(-1)^{sign}\times 2^{exponent-15}\times (\frac{mantissa}{1024}+1)$$所示。
|
||||
其中如果指数位全为0时,且尾数位全为0时表示数字0。
|
||||
如果指数位全为0,尾数位不全为0则表示一个非常小的数值。
|
||||
当指数全为1,尾数位全为0表示根据符号位正无穷大,或者负无穷大。
|
||||
若指数全为1,但是尾数位不为0,则表示NAN。
|
||||
其中bfloat16并不属于一个通用的数据类型,是Google提出的一种特殊的类型,现在一般只在一些TPU上训练使用,其指数位数与float32位数保持一致,可以较快的与float32进行数据转换。由于bfloat16并不是一种通用类型,IEEE中也并没有提出该类型的标准。
|
||||
|
||||
**算子信息库**
|
||||
|
||||
前面讲述了数据格式和数据精度的概念,基于这两个概念,在不同硬件下会有不同的算子支持,一个硬件上支持的所有算子的集合定义为该硬件的算子信息库。算子选择过程就是从算子信息库中选择最合适算子的过程。
|
||||
|
||||
### 算子选择的过程
|
||||
|
||||
前文介绍了算子选择主要是针对IR图中的每一个操作节点选择出最为合适的算子。其中算子信息主要包括了支持设备类型、数据类型和数据排布格式三个方面。经过编译器前端类型推导与静态分析的阶段后,IR图中已经推导出了用户代码侧的数据类型。下面介绍算子选择的基本过程。
|
||||
|
||||
如图 :numref:`select_kernel`所示,展示了算子选择过程。首先,选择算子执行的硬件设备。不同的硬件设备上,算子的实现、支持数据类型、执行效率通常会有所差别。这一步往往是用户自己指定的,若用户未指定,则编译器后端会为用户匹配一个默认的设备。
|
||||
然后,后端会根据IR图中推导出的数据类型和内存排布格式选择对应的算子。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`select_kernel`
|
||||
|
||||
理想情况下算子选择所选择出的算子类型,应该与用户预期的类型保持一致。但是由于软硬件的限制,很可能算子的数据类型不能满足用户所期待的数据类型,此时需要对该节点进行升精度或者降精度处理才能匹配到合适的算子。比如在MindSpore 的Ascend后端由于硬件限制导致Conv2D算子只存在float16一种数据类型。如果用户设置的整网使用的数据类型为float32数据,那么只能对Conv2D算子的输入数据进行降精度处理,即将输入数据类型从float32转换成float16。
|
||||
|
||||
算子的数据排布格式转换是一个比较耗时的操作,为了避免频繁的格式转换所带来的内存搬运开销,数据应该尽可能地以同样的格式在算子之间传递,算子和算子的衔接要尽可能少的出现数据排布格式不一致的现象。另外,数据类型不同导致的降精度可能会使得误差变大,收敛速度变慢甚至不收敛,所以数据类型的选择也要结合具体算子分析。
|
||||
|
||||
总的来说,一个好的算子选择算法应该尽可能的保持数据类型与用户设置的数据类型一致,且尽可能少的出现数据格式转换。
|
||||
68
website/chapter_backend_and_runtime/memory_allocator.md
Normal file
68
website/chapter_backend_and_runtime/memory_allocator.md
Normal file
@@ -0,0 +1,68 @@
|
||||
## 内存分配
|
||||
:label:`ch05-sec-memory_pool`
|
||||
|
||||
内存在传统计算机存储器层次结构中有着重要的地位,它是连接高速缓存和磁盘之间的桥梁,有着比高速缓存更大的空间,比磁盘更快的访问速度。随着深度学习的发展,深度神经网络的模型越来越复杂,AI芯片\footnote{与前文中的硬件加速器指意相同,业内习惯称为AI芯片}上的内存很可能无法容纳一个大型网络模型。因此,对内存进行复用是一个重要的优化手段。此外,通过连续内存分配和 In-Place内存分配还可以提高某些算子的执行效率。
|
||||
|
||||
### Device内存概念
|
||||
|
||||
在深度学习体系结构中,通常将与硬件加速器(如GPU、AI芯片等)相邻的内存称之为设备(Device)内存,而与CPU相邻的内存称之为主机(Host)内存。如:numref:`host-device-memory`所示,CPU可以合法地访问主机上的内存,而无法直接访问设备上的内存;同理,AI芯片可以访问设备上的内存,却无法访问主机上的内存。因此,在网络训练过程中,往往需要从磁盘加载数据到主机内存中,然后在主机内存中做数据处理,再从主机内存拷贝到设备内存中,最后设备才能合法地访问数据。算子全部计算完成后,用户要获取训练结果,又需要把数据从设备内存拷贝到主机内存中。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`host-device-memory`
|
||||
|
||||
### 内存分配 {#内存分配-1}
|
||||
|
||||
内存分配模块主要负责给图中算子的输入、输出分配Device内存。用户的前端脚本经过编译器前端处理后得到中间表达,后端根据中间表达进行算子选择和相关优化,可以得到算子最终的输入输出张量的形状、数据类型(Data Type)、格式(Format)等信息,根据这些信息可以计算出算子输入、输出张量的尺寸大小。基本的计算方法如式$$size=\left (\prod_{i=0}^{dimension}shape_i\right ) * sizeof\left ( data type \right )$$所示。得到张量的尺寸大小后,往往还需要对内存大小进行对齐操作。内存通常以4字节、8字节或16字节为一组进行访问,如果被搬运的内存大小不是这些值的倍数,内存后面会填充相应数量的空数据以使得内存长度达到这些值的倍数。因此,访问非对齐的内存可能会更加耗时。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`memory_allocate`
|
||||
|
||||
下面以 :numref:`memory_allocate`为例介绍内存分配的大致流程。首先给输入张量、Conv2D的权重和Conv2D的输出分配内存地址。然后为BatchNorm的输入分配地址时,发现BatchNorm的输入就是Conv2D算子的输出,而该张量的地址已经在之前分配过了,因此只需要将Conv2D算子的输出地址共享给BatchNorm的输入,就可以避免内存的重复申请以及内存的冗余拷贝。以此类推,可以发现整个过程中可以将待分配的内存分成三种类型:一是整张图的输入张量,二是算子的权重或者属性,三是算子的输出张量,三种类型在训练过程中的生命周期有所不同。
|
||||
|
||||
在CPU上常常使用malloc函数直接申请内存,这种方式申请内存好处是随时申请随时释放,简单易用。然而在许多对性能要求严苛的计算场景中,由于所申请内存块的大小不定,频繁申请释放会降低性能。通常会使用内存池的方式去管理内存,先申请一定数量的内存块留作备用,当程序有内存申请需求时,直接从内存池中的内存块中申请。当程序释放该内存块时,内存池会进行回收并用作后续程序内存申请时使用。
|
||||
在深度学习框架中,设备内存的申请也是非常频繁的,往往也是通过内存池的方式去管理设备内存,并让设备内存的生命周期与张量的生命周期保持一致。不同的深度学习框架在内存池的设计上大同小异,以:numref:`device_malloc`的MindSpore框架内存申请为例,进程会从设备上申请足够大的内存,然后通过双游标从两端偏移为张量分配内存。首先从申请的首地址开始进行偏移,为算子权重的张量分配内存,这部分张量生命周期较长,往往持续整个训练过程。然后从申请设备地址的末尾开始偏移,为算子的输出张量分配内存,这部分内存的生命周期较短,往往在该算子计算结束并且后续计算过程中无需再次使用该算子的输出的情况下,其生命周期就可以结束。通过这种方式,只需要从设备上申请一次足够大的内存,后续算子的内存分配都是通过指针偏移进行分配,减少了直接从设备申请内存的耗时。
|
||||
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`device_malloc`
|
||||
|
||||
### 内存复用
|
||||
|
||||
在机器学习系统中,内存复用是指分析张量的生命周期,将生命周期结束的张量的设备内存释放回内存池并用于后续张量的内存分配。内存复用的目的是提高内存的利用率,让有限的设备内存容纳更大的模型。
|
||||
以 :numref:`memory_allocate`为例,当BatchNorm算子计算结束后,输出1不再被任何算子使用,则该张量的设备内存可以被回收,并且如果输出1的内存尺寸大于等于输出3的内存尺寸,则从输出1回收的地址可以用于输出3的内存分配,从而达到复用输出1地址的目的。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`combine_memory_reuse_and_no_reuse`
|
||||
|
||||
为了更好地描述内存复用问题,通过内存生命周期图来辅助理解。如 :numref:`combine_memory_reuse_and_no_reuse`所示,图中横坐标表示张量的生命周期,图中纵坐标表示内存大小。在生命周期内,某一个张量将一直占用某块设备内存,直至生命周期结束才会释放相应内存块。通过张量生命周期和内存大小可以构造出矩形块,而内存分配要求解的目标是在内存生命周期图中容纳更多的矩形块,问题的约束是矩形块之间无碰撞。 :numref:`combine_memory_reuse_and_no_reuse`左边是在未使用任何内存复用策略的情况下的内存生命周期图,此时内存同时只能容纳T0、T1、T2、T3四个张量。
|
||||
|
||||
内存复用策略的求解是一个NP完全的问题。许多深度学习框架通常采用贪心的策略去分配内存,例如采用BestFit算法,每次直接从内存池中选取可以满足条件的最小内存块,然而这种贪心的策略往往会陷入局部最优解,而无法求得全局最优解。为了更好地逼近内存分配策略全局最优解,MindSpore框架提出了一种新的内存分配算法
|
||||
SOMAS(Safe Optimized Memory Allocation Solver,安全优化的内存分配求解器)。SOMAS将计算图并行流与数据依赖进行聚合分析,得到算子间祖先关系,构建张量全局生命周期互斥约束,使用多种启发式算法求解最优的内存静态规划,实现逼近理论极限的内存复用,从而提升支持的内存大小。
|
||||
|
||||
由 :numref:`combine_memory_reuse_and_no_reuse`右边所示,经过SOMAS求解之后,同样的内存大小,可支持的Tensor数量达到了7个。
|
||||
|
||||
### 常见的内存分配优化手段
|
||||
|
||||
#### 内存融合
|
||||
|
||||
上述内存分配的方式,都是以单个张量的维度去分配的,每个张量分配到的设备地址往往是离散的。但是对于某些特殊的算子,如AllReduce通信算子,需要为它们分配连续的内存。通信算子的执行包含通信等待、数据搬移、计算等步骤,而在大规模分布式集群的场景下,通信的耗时往往是性能瓶颈。针对这种场景,如 :numref:`memory_fusion`所示,可以将多个通信算子融合成一个,为通信算子的输入分配连续的内存,从而减少通信的次数。
|
||||
又比如分布式训练中的神经网络权重初始化,通常将一个训练进程中的权重初始化,然后将该权重广播到其他进程中。当一个网络有较多权重的时候,需要多次进行广播。通常可以为所有权重分配连续的内存地址,然后广播一次,节省大量通信的耗时。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`memory_fusion`
|
||||
|
||||
#### In-Place算子
|
||||
|
||||
在内存分配流程中,会为每个算子的输入和输出都分配不同的内存。然而对很多算子而言,为其分配不同的输入和输出地址,会浪费内存并且影响计算性能。例如优化器算子,其计算的目的就是更新神经网络的权重;例如Python语法中的 += 和 *= 操作符,将计算结果更新到符号左边的变量中;例如 a[0]=b 语法,将 a[0] 的值更新为 b。诸如此类计算有一个特点,都是为了更新输入的值。下面以张量的 a[0]=b 操作为例介绍In-Place的优点。
|
||||
:numref:`inplace-op`左边是非In-Place操作的实现,step1将张量a拷贝到张量a',step2将张量b赋值给张量a',step3将张量a'拷贝到张量a。 :numref:`inplace-op`右边是算子In-Place操作的实现,仅用一个步骤将张量b拷贝到张量a对应的位置上。对比两种实现,可以发现In-Place操作节省了两次拷贝的耗时,并且省去了张量a'内存的申请。
|
||||
|
||||

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

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

|
||||
:width:`800px`
|
||||
:label:`fig:ch05/ch05-parallel_computing`
|
||||
|
||||
以上种种在程序实际运行的时候针对数据做出的特殊操作,统称为**调度(Schedule)**。调度定义了:
|
||||
|
||||
(1)应该在何时何处计算函数中的每个值?
|
||||
|
||||
(2)数据应该储存在哪里?
|
||||
|
||||
(3)每个值在多个消费者(Consumer)之间访存需要花费多长时间?另外在何时由每个消费者独立重新计算?这里的消费者指使用前序结构进行计算的值。
|
||||
|
||||
通俗理解,调度策略指的是:在编译阶段根据目标硬件体系结构的特点而设计出的一整套通过提升局部性和并行性而使得编译出的可执行文件在运行时性能最优的算法。这些算法并不会影响计算结果,只是干预计算过程,以达到提升运算速度的效果。
|
||||
|
||||
### 子策略组合优化
|
||||
|
||||
算子编译器的一种优化思路是:将抽象出来的调度策略进行组合,拼接排布出一个复杂而高效的调度集合。子策略组合优化,本质上还是基于人工手动模板匹配的优化方式,依赖于开发人员对于硬件架构有较深的理解。这种方式较为直接,但组合出的优化策略无法调优,同时对各类算子精细化的优化也带来较多的人力耗费。本文以TVM为例,通过在CPU上加速优化一段实际代码,简要介绍其中几种基本调度策略组成的优化算法。
|
||||
|
||||
我们以形式为乘累加计算的代码[\[lst:before_tvm\]](#lst:before_tvm){reference-type="ref"
|
||||
reference="lst:before_tvm"}为例简要分析描述这一算法。该代码的核心计算逻辑为:首先对张量C进行初始化,之后将张量A与张量B相乘后,结果累加到张量C中。
|
||||
|
||||
``` {#lst:before_tvm caption="乘累加计算代码" label="lst:before_tvm"}
|
||||
for (m: int32, 0, 1024) {
|
||||
for (n: int32, 0, 1024) {
|
||||
C[((m*1024) + n)] = 0f32
|
||||
for (k: int32, 0, 1024) {
|
||||
let cse_var_2: int32 = (m*1024)
|
||||
let cse_var_1: int32 = (cse_var_2 + n)
|
||||
C[cse_var_1] = (C[cse_var_1] + (A[(cse_var_2 + k)]*B[((k*1024) + n)]))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
假定数据类型为浮点型(Float),此时张量A、B、C的大小均为1024 $\times$
|
||||
1024,三者占用的空间共为1024 $\times$ 1024 $\times$ 3 $\times$
|
||||
sizeof(float) = 12MB。这远远超出了常见缓存的大小(如L1
|
||||
Cache为32KB)。因此按照此代码形式,要将整块张量A、B、C一起计算,只能放入离计算核更远的内存进行计算。其访存效率远低于缓存。
|
||||
|
||||
为了提升性能,提出使用平铺(Tile),循环移序(Reorder)和切分(Split)的调度策略。由于L1缓存大小为32KB,为了保证每次计算都能够放入缓存中,我们选取因子(Factor)为32进行平铺,使得平铺后的每次计算时只需要关注m.inner
|
||||
$\times$
|
||||
n.inner构成的小块(Block)即可,而其他的外层循环不会影响最内层小块的访存。其占用内存大小为32
|
||||
$\times$ 32 $\times$ 3 $\times$ sizeof(float) =
|
||||
12KB,足够放入缓存中。代码[\[lst:after_tvm\]](#lst:after_tvm){reference-type="ref"
|
||||
reference="lst:after_tvm"}展示了经过该策略优化优化后的变化。
|
||||
|
||||
``` {#lst:after_tvm caption="子策略组合优化后的代码" label="lst:after_tvm"}
|
||||
// 由for (m: int32, 0, 1024)以32为因子平铺得到外层循环
|
||||
for (m.outer: int32, 0, 32) {
|
||||
// 由for (n: int32, 0, 1024)以32为因子平铺得到外层循环
|
||||
for (n.outer: int32, 0, 32) {
|
||||
// 由for (m: int32, 0, 1024)以32为因子平铺得到内层循环
|
||||
for (m.inner.init: int32, 0, 32) {
|
||||
// 由for (n: int32, 0, 1024)以32为因子平铺得到内层循环
|
||||
for (n.inner.init: int32, 0, 32) {
|
||||
// 对应地得到相应系数
|
||||
C[((((m.outer*32768) + (m.inner.init*1024)) + (n.outer*32)) + n.inner.init)] = 0f32
|
||||
}
|
||||
}
|
||||
// 由for (k: int32, 0, 1024)以4为因子切分得到外层循环,并进行了循环移序
|
||||
for (k.outer: int32, 0, 256) {
|
||||
// 由for (k: int32, 0, 1024)以4为因子切分得到外层循环,并进行了循环移序
|
||||
for (k.inner: int32, 0, 4) {
|
||||
// 由for (m: int32, 0, 1024)以32为因子平铺得到内层循环
|
||||
for (m.inner: int32, 0, 32) {
|
||||
// 由for (n: int32, 0, 1024)以32为因子平铺得到内层循环
|
||||
for (n.inner: int32, 0, 32) {
|
||||
// 由n轴平铺得到的外轴系数
|
||||
let cse_var_3: int32 = (n.outer*32)
|
||||
// 由m轴平铺得到的外轴和内轴系数
|
||||
let cse_var_2: int32 = ((m.outer*32768) + (m.inner*1024))
|
||||
// 由m轴和n轴得到的外轴和内轴系数
|
||||
let cse_var_1: int32 = ((cse_var_2 + cse_var_3) + n.inner)
|
||||
// 这里是核心计算逻辑,划分成不同层次使得每次循环计算的数据能够放入cache中
|
||||
C[cse_var_1] = (C[cse_var_1] + (A[((cse_var_2 + (k.outer*4)) + n.inner)] * B[((((k.outer*4096) + (k.inner*1024)) + cse_var_3) + n.inner)]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
本示例参照TVM提供的"在CPU上优化矩阵乘运算的实例教程"[^1]中的第一项优化,读者可深入阅读后续优化内容。
|
||||
|
||||
### 调度空间算法优化
|
||||
|
||||
算子编译器的另外一种优化思路是:通过对调度空间搜索/求解,自动生成对应算子调度。此类方案包括多面体模型编译(Polyhedral
|
||||
Compilation)(基于约束对调度空间求解)和Ansor(调度空间搜索)等。这类方法的好处是提升了算子编译的泛化能力,缺点是搜索空间过程会导致编译时间过长。
|
||||
以多面体模型编译技术将代码的多层循环抽象为多维空间,将每个计算实例抽象为空间中的点,实例间的依赖关系抽象为空间中的线,主要对循环进行优化。该算法的主要思想是针对输入代码的访存特点进行建模,调整循环语句中的每一个实例的执行顺序,使得新调度下的循环代码有更好的局部性和并行性。
|
||||
|
||||
我们以代码[\[lst:before_poly\]](#lst:before_poly){reference-type="ref"
|
||||
reference="lst:before_poly"}为例介绍该算法。
|
||||
|
||||
``` {#lst:before_poly caption="待优化代码" label="lst:before_poly"}
|
||||
for (int i = 0; i < N; i++)
|
||||
for (int j = 1; j < N; j++)
|
||||
a[i+1][j] = a[i][j+1] - a[i][j] + a[i][j-1];
|
||||
```
|
||||
|
||||
如 :numref:`fig:ch05/ch05-poly_test`所示,通过多面体模型算法先对此代码的访存结构进行建模,然后分析实例(即 :numref:`fig:ch05/ch05-poly_test`中节点)间的依赖关系(即 :numref:`fig:ch05/ch05-poly_test`中箭头)。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`fig:ch05/ch05-poly_test`
|
||||
|
||||
|
||||
再进行复杂的依赖分析和调度变换之后得到一个符合内存模型的最优解。代码[\[lst:after_poly\]](#lst:after_poly){reference-type="ref"
|
||||
reference="lst:after_poly"}显示了经过多面体模型优化后得到的结果。
|
||||
|
||||
``` {#lst:after_poly caption="多面体模型算法优化后的代码" label="lst:after_poly"}
|
||||
for (int i_new = 0; i_new < N; i_new++)
|
||||
for (int j_new = i+1; j_new < i+N; j_new++)
|
||||
a[i_new+1][j_new-i_new] = a[i_new][j_new-i_new+1] - a[i_new][j_new-i_new] + a[i_new][j_new-i_new-1];
|
||||
```
|
||||
|
||||
观察得到的代码,发现优化后的代码较为复杂。但是仅凭肉眼很难发现其性能优势之处。仍需对此优化后的代码进行如算法描述那样建模,并分析依赖关系后得出结论,如 :numref:`fig:ch05/ch05-poly`所示:经过算法优化后解除了原代码中的循环间的依赖关系,从而提高了并行计算的机会。即沿着 :numref:`fig:ch05/ch05-poly`中虚线方向分割并以绿色块划分后,可以实现并行计算。
|
||||
该算法较为复杂,限于篇幅,在这里不再详细展开。读者可移步到笔者专门为此例写的文章-《深度学习编译之多面体模型编译------以优化简单的两层循环代码为例》详读。
|
||||
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`fig:ch05/ch05-poly`
|
||||
|
||||
|
||||
### 芯片指令集适配
|
||||
|
||||
前文讲述了算子编译器的优化方法,本小节将阐述算子编译器适配不同芯片上指令集的情况。一般意义上来说,通用编译器的设计会尽量适配多种后端。如此一来,在面临不同体系结构特点和不同编程模型的多种后端时,算子编译器承受了相当大的压力。
|
||||
|
||||
当下的AI芯片中,常见的编程模型分为:单指令多数据(Single Instruction,
|
||||
Multiple Data,
|
||||
SIMD),即单条指令一次性处理大量数据,如 :numref:`fig:ch05/ch05-SIMD`所示;单指令多线程(Single Instruction,
|
||||
Multiple Threads,
|
||||
SIMT),即单条指令一次性处理多个线程的数据,如 :numref:`fig:ch05/ch05-SIMT`所示。前者对应的是带有向量计算指令的芯片;后者对应的是带有明显的线程分级的芯片。另外,也有一些芯片开始结合这两种编程模型的特点,既有类似线程并行计算的概念,又有向量指令的支持。针对不同的编程模型,算子编译器在进行优化(如向量化等)时的策略也会有所不同。
|
||||
|
||||
|
||||

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

|
||||
:width:`800px`
|
||||
:label:`fig:ch05/ch05-SIMT`
|
||||
|
||||
一般来说,算子编译器在具体的代码中会按照:前端、中端、后端,逐渐差异化的思路进行实现。即在前端设计中兼容多种不同芯片后端的指令集,以帮助编译器用户(即AI程序员)不需要在乎芯片差异,而只需要专注在AI算法逻辑上即可;在中间表示(IR)设计中对不同芯片的体系结构进行区分,从而可以实现对不同芯片进行不同的优化方法;在后端的目标代码生成部分对各个芯片的不同指令集详细区分,以保证生成出的目标代码能够顺利运行在目标芯片上。
|
||||
|
||||
### 算子表达能力
|
||||
|
||||
算子表达能力指的是:算子编译器的前端识别输入代码,并在不损失语义信息的情况下转换为中间表示的能力。算子编译器承接的前端输入往往是PyTorch等的Python形式的代码,而Python中各种灵活的表达方式(包括而不限于索引、View语义等)对算子编译器的前端表达能力提出了较高要求。另外在检测网络中,输入算子往往还有大量的控制流语句。此外,还经常可以看到神经网络中存在许多的动态形状问题,即网络中的算子形状会受网络迭代次数和控制流等条件的影响。这些都对算子编译器前端的表达能力提出了很高的要求。
|
||||
|
||||
在实际工程实践中,发现大量的长尾分布般不常见但性能很差的算子(后文简称为长尾算子)往往是整体网络训练或推理的瓶颈点。而这些长尾算子大都是由于其出现频次低而不至于实现在计算库中。同时其语法过于灵活或存在大量的控制流语句以及动态形状问题而难以被目前的算子编译器前端充分表达出来,因此也难以通过算子编译器进行优化加速。于是,这些长尾算子只好以运行速度较慢的Python解释器或者虚拟机的方式执行,从而成为整个网络中的性能瓶颈。此时,提高算子编译器前端的表达能力就成为了重中之重。
|
||||
|
||||
### 相关编译优化技术
|
||||
|
||||
算子编译器与传统编译器在优化技术方面根出同源,但由于面对的问题不同,所以在优化思路上也有差别。两者都以前中后端的思路进行设计,都是以增强局部性和并行性为优化的理论依据。
|
||||
但是前者面向的问题是AI领域中的计算问题,往往在优化过程中会大量参考和借鉴高性能计算(High-Performance
|
||||
Computing,
|
||||
HPC)的优化思路,这种情况称为借助专家经验进行优化。另外算子编译器面对的后端AI芯片的体系结构的不同,如重点的单指令多数据和单指令多线程为代表的两种后端体系结构,决定了优化过程中更多偏向于生成对单指令多数据友好的加速指令,或者生成对单指令多线程友好的多线程并行计算模型。
|
||||
而后者面向的问题是更加通用的标量计算行为和计算机控制命令,往往在优化中围绕寄存器的使用和分支预测准确性等进行优化。
|
||||
总之,由于需要解决的问题不同,算子编译器和传统编译器在优化算法的具体实现上有着一定的区别,但是在算法设计时也有互相借鉴的机会。
|
||||
|
||||
[^1]: 在CPU上优化矩阵乘运算的实例教程:<https://tvm.apache.org/docs/how_to/optimize_operators/opt_gemm.html>
|
||||
29
website/chapter_backend_and_runtime/overview.md
Normal file
29
website/chapter_backend_and_runtime/overview.md
Normal file
@@ -0,0 +1,29 @@
|
||||
## 概述
|
||||
|
||||
编译器前端主要将用户代码进行解析翻译得到计算图IR,并对其进行设备信息无关的优化,此时的优化并不考虑程序执行的底层硬件信息。编译器后端的主要职责是对前端下发的IR做进一步的计算图优化,让其更加贴合硬件,并为IR中的计算节点选择在硬件上执行的算子,然后为每个算子的输入输出分配硬件内存,最终生成一个可以在硬件上执行的任务序列。
|
||||
|
||||
如 :numref:`compiler-backend-architecture`所示,编译器后端处于前端和硬件驱动层中间,主要负责计算图优化、算子选择和内存分配的任务。首先,需要根据硬件设备的特性将IR图进行等价图变换,以便在硬件上能够找到对应的执行算子,该过程是计算图优化的重要步骤之一。前端IR是通过解析用户代码生成的,属于一个较高的抽象层次,隐藏一些底层运行的细节信息,此时无法直接对应硬件上的算子(算子是设备上的基本计算序列,例如MatMul、Convolution、ReLU等),需要将细节信息进行展开后,才能映射到目标硬件上的算子。对于某些前端IR的子集来说,一个算子便能够执行对应的功能,此时可以将这些IR节点合并成为一个计算节点,该过程称之为算子融合;对于一些复杂计算,后端并没有直接与之对应的算子,但是可以通过几个基本运算的算子组合达到同样的计算效果,此时可以将前端IR节点拆分成多个小算子。在完成计算图优化之后,就要进行算子选择过程,为每个计算节点选择执行算子。算子选择是在得到优化的IR图后选取最合适的目标设备算子的过程。针对用户代码所产生的IR往往可以映射成多种不同的硬件算子,但是这些不同硬件算子的执行效率往往有很大差别,如何根据前端IR选择出最高效的算子,是算子选择的核心问题。算子选择本质上是一个模式匹配问题。其最简单的方法就是每一个IR节点对应一个目标硬件的算子,但是这种方法往往对目标硬件的资源利用比较差。现有的编译器一般都对每一个IR节点提供了多个候选的算子,算子选择目标就是从中选择最优的一个算子作为最终执行在设备上的算子。总的来说,在机器学习系统中,对前端生成的IR图上的各个节点进行拆分和融合,让前端所表示的高层次IR逐步转换为可以在硬件设备上执行的低层次IR。得到了这种更加贴合硬件的IR后,对于每个单节点的IR可能仍然有很多种不同的选择,例如可以选择不同的输入输出格式和数据类型,需要对IR图上每个节点选择出最为合适的算子,算子选择过程可以认为是针对IR图的细粒度优化过程,最终生成完整的算子序列。最后,遍历算子序列,为每个算子分配相应的输入输出内存,然后将算子加载到设备上执行计算。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`compiler-backend-architecture`
|
||||
|
||||
### 计算图优化
|
||||
|
||||
计算图优化是在不影响模型的数值特性的基础上,通过图变换达到简化计算、减少资源开销、适配硬件的执行能力、提升执行性能的目的。
|
||||
|
||||
### 算子选择
|
||||
|
||||
算子选择是将IR图上的每个计算节点映射到设备上可执行算子的过程,一个IR图上的计算节点往往可以对应多个设备上的算子,这个过程中需要考虑算子的规格,算子的执行效率等问题,算子选择目标就是从中选择最优的一个算子。
|
||||
|
||||
### 内存分配
|
||||
|
||||
经过计算图优化和算子选择之后,我们可以得到IR图中每个算子的输入输出的形状(Shape)、数据类型、存储格式。根据这些信息,计算输入输出数据的大小,并为输入输出分配设备上的内存,然后将算子加载到设备上才能真正执行计算。此外,为了更充分地例用设备内存资源,可以对内存进行复用,提高内存利用率。
|
||||
|
||||
### 计算调度与执行
|
||||
|
||||
经过算子选择与内存分配之后,计算任务可以通过运行时完成计算的调度与在硬件上的执行。根据是否将算子编译为计算图,计算的调度可以分为单算子调度与计算图调度两种方式。而根据硬件提供的能力差异,计算图的执行方式又可以分为逐算子下发执行的交互式执行以及将整个计算图或者部分子图一次性下发到硬件的下沉式执行两种模式。
|
||||
|
||||
### 算子编译器
|
||||
|
||||
作为AI编译器中一个重要组成部分,算子编译器把单个简单或复杂的算子经过表达和优化后编译为一个单独的可执行文件。目前业界面对算子编译器仍有许多有趣的问题尚未得出明确结论,相关的处理逻辑与方法也尚未收敛。本小节希望将这些问题简单抛出,并给出业界比较典型的几种处理方式。若能对业界朋友们和同学们有所启发甚至若能对这些问题起到促进收敛的作用,那真是再好不过!目前尚待收敛的问题包括而不限于:如何通过算子编译器进行性能优化?算子编译器如何兼容不同体系结构特点的芯片?面对输入Python代码的灵活性以及神经网络训练时动态性的情况,该如何充分将这些完美表达出来?
|
||||
27
website/chapter_backend_and_runtime/summary.md
Normal file
27
website/chapter_backend_and_runtime/summary.md
Normal file
@@ -0,0 +1,27 @@
|
||||
## 总结
|
||||
|
||||
- 编译器后端主要负责计算图优化、算子选择、内存分配这三个任务。
|
||||
|
||||
- 计算图优化是在不影响模型的数值特性的基础上,通过图变换达到减少资源开销、适配硬件的执行能力、提升执行性能的目的。
|
||||
|
||||
- 计算图优化主要分为硬件通用优化和特定硬件优化,例如与硬件无关的算子内存IO优化和为了适配特定硬件指令限制而做的子图变换。
|
||||
|
||||
- 算子选择是为IR图中的每个计算节点选择一个最适合在设备上执行的算子。
|
||||
|
||||
- 数据存在多种存储格式和计算精度,不同的存储格式和计算精度在不同场景下对算子计算性能有较大的影响,所以算子选择需要综合考虑各方面影响选择最优的算子。
|
||||
|
||||
- 经过计算图优化和算子选择之后,得到了最终的IR。基于最终的IR,需要为算子的输入输出Tensor分配内存,然后加载算子到硬件上执行。
|
||||
|
||||
- 内存复用是一个重要的内存分配优化手段,可以让设备上容纳更大的网络模型。
|
||||
|
||||
- 将通信算子的内存进行融合,可以提高通信的效率;合理分配In-Place算子的内存,可以节省内存使用并且提高计算效率。
|
||||
|
||||
- 运行时对于算子的执行可以分为单算子调度和计算图调度两种模式,而在计算图调度模式中,根据具体硬件的能力又可以分为交互式执行和下沉式执行两种方式,交互式执行具备更多的灵活性,下沉执行可以获得更好的计算性能。
|
||||
|
||||
- 算子编译器是优化硬件性能的关键组件。其中,调度策略的优化和基于多面体模型算法的优化是两个关键技术。
|
||||
|
||||
## 扩展阅读
|
||||
|
||||
- 内存分配作为机器学习后端的重要部分,建议阅读 [Sublinear Memory Cost](https://arxiv.org/abs/1604.06174)、 [Dynamic Tensor Rematerialization](https://arxiv.org/abs/2006.09616)。
|
||||
- 对于运行时的调度以及执行,建议阅读 [A Lightweight Parallel and Heterogeneous Task Graph Computing System](https://arxiv.org/abs/2004.10908)、 [Dynamic Control Flow in Large-Scale Machine Learning](https://arxiv.org/abs/1805.01772)、[DEEP LEARNING WITH DYNAMIC COMPUTATION GRAPHS](https://arxiv.org/abs/1702.02181)。
|
||||
- 算子编译器是本书的扩展部分,建议阅读提出计算与调度分离的论文: [Halide: A Language and Compiler for Optimizing Parallelism, Locality, and Recomputation in Image Processing Pipelines](https://dl.acm.org/doi/abs/10.1145/2499370.2462176),以及介绍调度空间优化的论文 [Ansor: Generating High-Performance Tensor Programs for Deep Learning](https://arxiv.org/abs/2006.06762)和 [olly - Polyhedral optimization in LLVM](https://arxiv.org/abs/2105.04555)
|
||||
@@ -0,0 +1,21 @@
|
||||
## 计算图的设计背景和作用
|
||||
|
||||

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

|
||||
:width:`300px`
|
||||
:label:`simpledag`
|
||||
### 张量和算子
|
||||
|
||||
在数学中定义张量是基于标量与向量的推广。在机器学习领域内将多维数据称为张量,使用秩来表示张量的轴数或维度。如:numref:`tensor`所示,标量为零秩张量,包含单个数值,没有轴;向量为一秩张量,拥有一个轴;拥有RGB三个通道的彩色图像即为三秩张量,包含三个轴。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`tensor`
|
||||
|
||||
在机器学习框架中张量不仅存储数据,还需要存储张量的数据类型、数据形状、秩以及梯度传递状态等多个属性,如:numref:`tensor_attr`所示,列举了主要的属性和功能。
|
||||
|
||||
:张量属性
|
||||
|
||||
| 张量属性 | 功能 |
|
||||
| -------------- | -------|
|
||||
| 形状(shape) | 存储张量的每个维度的长度,如[3,3,3] |
|
||||
| 秩或维数(dim) | 表示张量的轴数或者维数,标量为0,向量为1。 |
|
||||
| 数据类型(dtype) | 表示存储的数据类型,如bool、uint8、int16、float32、float64等 |
|
||||
| 存储位置(device) | 创建张量时可以指定存储的设备位置,如CPU、GPU等 |
|
||||
| 名字(name) | 张量的标识符 |
|
||||
:label:`tensor_attr`
|
||||
|
||||
以图像数据为例来具体说明张量属性的作用。当机器学习框架读取一张高为96像素、宽为96像素的RGB三通道图像,并将图像数据转换为张量存储时。该张量的形状属性则为[96,96,3]分别代表高、宽、通道的数量,秩即为3。原始RGB图像每个像素上的数据以0-255的无符号整数来表示色彩,因此图像张量存储时会将数据类型属性设置为uint8格式。将图像数据传输给卷积网络模型进行网络训练前,会对图像数据进行归一化处理,此时数据类型属性会重新设置为float32格式,因为通常机器学习框架在训练模型时默认采用float32格式。
|
||||
|
||||
机器学习框架在训练时需要确定在CPU、GPU或其他硬件上执行计算,数据和权重参数也应当存放在对应的硬件内存中才能正确被调用,张量存储位置属性则用来指明存储的设备位置。存储位置属性通常由机器学习框架根据硬件环境自动赋予张量。在模型训练过程中,张量数据的存储状态可以分为可变和不可变两种,可变张量存储神经网络模型权重参数,根据梯度信息更新自身数据,如参与卷积运算的卷积核张量;不可变张量用于用户初始化的数据或者输入模型的数据,如上文提到的图像数据张量。
|
||||
|
||||
那么在机器学习场景下的张量一般长什么样子呢?上文提到的图像数据张量以及卷积核张量,形状一般是“整齐”的。即每个轴上的具有相同的元素个数,就像一个“矩形”或者“立方体”。在特定的环境中,也会使用特殊类型的张量,比如不规则张量和稀疏张量。如 :numref:`tensorclass`中所示,不规则张量在某个轴上可能具有不同的元素个数,它们支持存储和处理包含非均匀形状的数据,如在自然语言处理领域中不同长度文本的信息;稀疏张量则通常应用于图数据与图神经网络中,采用特殊的存储格式如坐标表格式(Coordinate List,COO),可以高效存储稀疏数据节省存储空间。
|
||||
|
||||

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

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

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

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

|
||||
:width:`600px`
|
||||
:label:`if`
|
||||
|
||||
|
||||
当模型中有循环控制时,循环中的操作可以执行零次或者多次。此时采用展开机制,对每一次操作都赋予独特的运算标识符,以此来区分相同运算操作的多次调用。每一次循环都直接依赖于前一次循环的计算结果,所以在循环控制中需要维护一个张量列表,将循环迭代的中间结果缓存起来,这些中间结果将参与前向计算和梯度计算。下面这段代码描述了简单的循环控制,将其展开得到等价代码后,可以清楚的理解需要维护张量$\boldsymbol{X_i}$和$\boldsymbol{W_i}$的列表。
|
||||
```python
|
||||
def recurrent_control(X : Tensor, W : Sequence[Tensor], cur_num = 3):
|
||||
for i in range(cur_num):
|
||||
X = matmul(X, W[i])
|
||||
return X
|
||||
#利用展开机制将上述代码展开,可得到等价表示
|
||||
def recurrent_control(X : Tensor, W : Sequence[Tensor]):
|
||||
X1 = matmul(X, W) #为便于表示与后续说明,此处W = W[0], W1 = W[1], W2 = W[2]
|
||||
X2 = matmul(X1, W1)
|
||||
Y = matmul(X2, W2)
|
||||
return Y
|
||||
```
|
||||
如 :numref:`while`描述了上述代码的前向计算图和反向计算图,循环控制的梯度同样也是一个循环,它与前向循环的迭代次数相同。执行循环体的梯度计算中,循环体当前迭代计算输出的梯度值作为下一次迭代中梯度计算的输入值,直至循环结束。
|
||||
|
||||

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

|
||||
:width:`600px`
|
||||
:label:`chain`
|
||||
|
||||
假设选取均方误差为损失函数,那么损失值是怎样通过链式法则将梯度信息传递给图中的$\boldsymbol{W}$和$\boldsymbol{W_1}$呢?又为什么要计算非参数数据$\boldsymbol{X}$和$\boldsymbol{X_1}$的梯度呢?为了解决上述两个疑问,要详细思考前向传播和反向传播的计算过程。首先通过前向传播来计算损失值三个步骤:(1)$\boldsymbol{X_1}=\boldsymbol{XW}$;(2)$\boldsymbol{Y}=\boldsymbol{X_1W_1}$;(3)Loss=$\frac{1}{2}$($\boldsymbol{Y}$-Label)$^2$, 此处Label即为标签值。
|
||||
|
||||
得到损失函数之后,目的是最小化预测值和标签值间的差异。为此根据链式法则利用公式3.4和公式3.5来进行反向传播,来求解损失函数关于参数$\boldsymbol{W}$和$\boldsymbol{W_1}$的梯度值:
|
||||
|
||||
$$
|
||||
\frac{\partial {\rm Loss}}{\partial \boldsymbol{W_1}}=\frac{\partial \boldsymbol{Y}}{\partial \boldsymbol{W_1}}\frac{\partial {\rm Loss}}{\partial \boldsymbol{Y}} ~~~~~~~~~~~(3.4)
|
||||
$$
|
||||
|
||||
$$
|
||||
\frac{\partial {\rm Loss}}{\partial \boldsymbol{W}}=\frac{\partial \boldsymbol{X_1}}{\partial \boldsymbol{W}}\frac{\partial {\rm Loss}}{\partial \boldsymbol{Y}}\frac{\partial \boldsymbol{Y}}{\partial \boldsymbol{X_1}} ~~~~~~~~~~~(3.5)
|
||||
$$
|
||||
|
||||
可以看出公式3.4和公式3.5都计算了$\frac{\partial {\rm Loss}}{\partial \boldsymbol{Y}}$对应图3.10中的grad $\boldsymbol{Y}$。公式3.5中的$\frac{\partial {\rm Loss}}{\partial \boldsymbol{Y}}\frac{\partial \boldsymbol{Y}}{\partial \boldsymbol{X_1}}$对应:numref:`chain`中的grad $\boldsymbol{X_1}$,为了便于计算模型参数$\boldsymbol{W}$的梯度信息,需要计算中间结果$\boldsymbol{X_1}$的梯度信息。这也就解决了前面提出的第二个疑问,计算非参数的中间结果梯度是为了便于计算前序参数的梯度值。
|
||||
|
||||
接着将$\boldsymbol{X_1}=\boldsymbol{XW}$、$\boldsymbol{Y}=\boldsymbol{X_1W_1}$和Loss=$\frac{1}{2}$($\boldsymbol{Y}$-Label)$^2$代入公式3.4和公式3.5展开为公
|
||||
式3.6和公式3.7,可以分析机器学习框架在利用链式法则构建反向计算图时,变量是如何具体参与到梯度计算中的。
|
||||
|
||||
$$
|
||||
\frac{\partial {\rm Loss}}{\partial \boldsymbol{W_1}}=\frac{\partial \boldsymbol{Y}}{\partial \boldsymbol{W_1}}\frac{\partial {\rm Loss}}{\partial \boldsymbol{Y}}=\boldsymbol{X_1}^\top(\boldsymbol{Y}-{\rm Label}) ~~~~~~~~~~~(3.6)
|
||||
$$
|
||||
|
||||
$$
|
||||
\frac{\partial {\rm Loss}}{\partial \boldsymbol{W}}=\frac{\partial \boldsymbol{X_1}}{\partial \boldsymbol{W}}\frac{\partial {\rm Loss}}{\partial \boldsymbol{Y}}\frac{\partial \boldsymbol{Y}}{\partial \boldsymbol{X_1}}=\boldsymbol{X}^\top(\boldsymbol{Y}-{\rm Label})\boldsymbol{W_1}^\top ~~~~~~~~~~~(3.7)
|
||||
$$
|
||||
|
||||
公式3.6在计算$\boldsymbol{W_1}$的梯度值时使用到了前向图中的中间结果$\boldsymbol{X_1}$。公式3.7中不仅使用输入数据$\boldsymbol{X}$来进行梯度计算,参数$\boldsymbol{W_1}$也参与了参数$\boldsymbol{W}$的梯度值计算。因此可以回答第一个疑问,参与计算图中参数的梯度信息计算过程的不仅有后序网络层传递而来的梯度信息,还包含有前向计算中的中间结果和参数数值。
|
||||
|
||||
通过分析:numref:`chain`和公式3.4、3.5、3.6、3.7解决了两个疑问后,可以发现计算图在利用链式法则构建反向计算图时,会对计算过程进行分析保存模型中的中间结果和梯度传递状态,通过占用部分内存复用计算结果达到提高反向传播计算效率的目的。
|
||||
|
||||
将上述的链式法则推导推广到更加一般的情况,结合控制流的灵活构造,机器学习框架均可以利用计算图快速分析出前向数据流和反向梯度流的计算过程,正确的管理中间结果内存周期,更加高效的完成计算任务。
|
||||
|
||||
|
||||
<!-- 上一小节中简单的循环控制模型前向传播可以表示为$\boldsymbol{Y}=\boldsymbol{W_2}(\boldsymbol{W_1}(\boldsymbol{W}(\boldsymbol{X})))$。在反向传播的过程中可以将前向计算等价为$\boldsymbol{Y}=\boldsymbol{W_2}\boldsymbol{X_2}$,首先得到参数$\boldsymbol{W_2}$的梯度表示。再接着根据$\boldsymbol{X_2}=\boldsymbol{W_1}\boldsymbol{X_1}$得到$\boldsymbol{W_1}$的梯度表示,按照层级即可推导得出$\boldsymbol{W}$的梯度表示:
|
||||
|
||||
$$
|
||||
\begin{aligned}
|
||||
\nabla\boldsymbol{X_2} &= \nabla\boldsymbol{Y}\boldsymbol{W_2}^\top \\
|
||||
\nabla\boldsymbol{W_2} &= \boldsymbol{X_2}^\top\nabla\boldsymbol{Y} \\
|
||||
\nabla\boldsymbol{X_1} &= \nabla\boldsymbol{X_2}\boldsymbol{W_1}^\top = (\nabla\boldsymbol{Y}\boldsymbol{W_2}^\top)\boldsymbol{W_1}^\top \\
|
||||
\nabla\boldsymbol{W_1} &= \boldsymbol{X_1}^\top\nabla\boldsymbol{X_2} = \boldsymbol{X_1}^\top(\nabla\boldsymbol{Y}\boldsymbol{W_2}^\top) \\
|
||||
\nabla\boldsymbol{X} &= \nabla\boldsymbol{X_1}\boldsymbol{W}^\top = ((\nabla\boldsymbol{Y}\boldsymbol{W_2}^\top)\boldsymbol{W_1}^\top)\boldsymbol{W}^\top \\
|
||||
\nabla\boldsymbol{W} &= \boldsymbol{X}^\top\nabla\boldsymbol{X_1} = \boldsymbol{X}^\top((\nabla\boldsymbol{Y}\boldsymbol{W_2}^\top)\boldsymbol{W_1}^\top)
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
根据链式法则,相应位置的导数乘积即可将网络得到的损失函数梯度信息传播到每一个权重参数,应用优化器的参数权重更新规则,即可达到神经网络模型参数训练迭代的目的。
|
||||
|
||||
根据上述公式我们可以得出循环控制的反向梯度计算过程如下,在下面代码中变量的前缀*grad*代表变量梯度变量,*transpose*代表矩阵转置算子。
|
||||
```python
|
||||
grad_X2 = matmul(grad_Y, transpose(W2))
|
||||
grad_W2 = matmul(transpose(X2), grad_Y)
|
||||
grad_X1 = matmul(grad_X2, transpose(W1))
|
||||
grad_W1 = matmul(transpose(X1), grad_X2)
|
||||
grad_X = matmul(grad_X1, transpose(W))
|
||||
grad_W = matmul(transpose(X), grad_X1)
|
||||
```
|
||||
结合公式、代码以及 :numref:`chain`我们可以看出,在反向传播过程中使用到前向传播的中间变量。因此保存网络中间层输出状态和中间变量,尽管占用了部分内存但能够复用计算结果,达到了提高反向传播计算效率的目的。
|
||||
|
||||

|
||||
:width:`600px`
|
||||
:label:`chain`
|
||||
|
||||
在深度学习计算框架中,控制流可以进行嵌套,比如多重循环和循环条件控制,计算图会对复杂控制流进行准确的描述,以便于执行正确的计算调度与执行任务。可以通过[代码示例](https://github.com/openmlsys/openmlsys-pytorch/blob/master/chapter_computational_graph/control_flow.py)查看在条件控制和循环控制下,前向和反向计算的数据流。 -->
|
||||
@@ -0,0 +1,180 @@
|
||||
## 计算图的生成
|
||||
在了解计算图的基本构成后,那么下一个问题就是:计算图要如何自动化生成呢?在机器学习框架中可以生成静态图和动态图两种计算图。静态生成可以根据前端语言描述的神经网络拓扑结构以及参数变量等信息构建一份固定的计算图。因此静态图在执行期间可以不依赖前端语言描述,常用于神经网络模型的部署,比如移动端人脸识别场景中的应用等。
|
||||
|
||||
动态图则需要在每一次执行神经网络模型依据前端语言描述动态生成一份临时的计算图,这意味着计算图的动态生成过程灵活可变,该特性有助于在神经网络结构调整阶段提高效率。主流机器学习框架TensorFlow、MindSpore均支持动态图和静态图模式;PyTorch则可以通过工具将构建的动态图神经网络模型转化为静态结构,以获得高效的计算执行效率。了解两种计算图生成方式的优缺点及构建执行特点,可以针对待解决的任务需求,选择合适的生成方式调用执行神经网络模型。
|
||||
|
||||
### 静态生成
|
||||
|
||||
静态图的生成与执行原理如 :numref:`static`所示,采用先编译后执行的方式,该模式将计算图的定义和执行进行分离。
|
||||
|
||||

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

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

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

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

|
||||
:width:`800px`
|
||||
:label:`ast`
|
||||
|
||||
在使用上述功能的过程中,可以将整体模型动态图代码全部转换为静态图代码,提高计算效率并用于硬件部署。同时也可以将整体模型中的部分函数转化为局部静态子图,静态子图会被机器学习框架视为一个完整的算子并嵌入动态图中。执行整体动态图时,当计算到对应的函数会自动调用静态子图。使用该方式既提高了计算效率,又在一定程度上保留代码调试改进的灵活性。
|
||||
|
||||
下面代码中模型整体可以采用动态生成,而@ms\_function可以使用基于源码转换的技术将模块add\_and\_relu的转化为静态图结构。与动态生成中代码执行相同,模型接收输入按照模型定义的计算顺序进行调度执行,并生成临时动态图结构,当执行语句Y=add\_and\_relu(Y,b)时,机器学习框架会自动调用该模块静态生成的图结构执行计算,通过动态图和静态图的混合执行提高计算能力。此外,动静态转换的技术常用于模型部署阶段。部署动态图模型时除了需要训练完成的参数文件,还须根据前端语言编写的模型代码构建拓扑关系。这使得动态图部署受到局限性,部署硬件中往往难以提供支持前端语言运行的执行环境。因此当使用动态图模式训练完模型参数后,可以将整体网络结构转换为静态图格式,将神经网络模型和参数文件进行序列化保存,与前端代码完全解耦,扩大模型部署的硬件支持范围。
|
||||
|
||||
|
||||
```python
|
||||
@ms_function #mindspore中基于源码转换的函数装饰器,可以将该函数转换为静态图
|
||||
def add_and_relu(Y, b):
|
||||
Y = Y + b
|
||||
Y = relu(Y)
|
||||
return Y
|
||||
|
||||
def model(X, flag):
|
||||
if flag>0:
|
||||
Y = matmul(W1, X)
|
||||
else:
|
||||
Y = matmul(W2, X)
|
||||
Y = add_and_relu(Y, b)
|
||||
return Y
|
||||
```
|
||||
|
||||
|
||||
18
website/chapter_computational_graph/index.md
Normal file
18
website/chapter_computational_graph/index.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# 计算图
|
||||
|
||||
上一章节展示了如何高效编写机器学习程序,那么下一个问题就是:机器学习系统如何高效地在硬件上执行这些程序呢?这一核心问题又能被进一步拆解为:如何对机器学习程序描述的模型调度执行?如何使得模型调度执行更加高效?如何自动计算更新模型所需的梯度?解决这些问题的关键是计算图(Computational Graph)技术。为了讲解这一技术,本章将详细讨论计算图的基本组成、自动生成和高效执行中所涉及的方法。
|
||||
|
||||
本章的学习目标包括:
|
||||
- 掌握计算图的基本构成。
|
||||
- 掌握计算图静态生成和动态生成方法。
|
||||
- 掌握计算图的常用执行方法。
|
||||
|
||||
```toc
|
||||
:maxdepth: 2
|
||||
|
||||
background_and_functionality
|
||||
components_of_computational_graph
|
||||
generation_of_computational_graph
|
||||
schedule_of_computational_graph
|
||||
summary
|
||||
```
|
||||
@@ -0,0 +1,67 @@
|
||||
## 计算图的调度
|
||||
|
||||
模型训练就是计算图调度图中算子的执行过程。宏观来看训练任务是由设定好的训练迭代次数来循环执行计算图,此时需要优化迭代训练计算图过程中数据流载入和训练(推理)执行等多个任务之间的调度策略。微观上单次迭代需要考虑计算图内部的调度执行问题,根据计算图结构、计算依赖关系、计算控制分析算子的执行调度。优化计算图的调度和执行性能,目的是尽可能充分利用计算资源,提高计算效率,缩短模型训练和推理时间。接下来会详细介绍计算图的调度和执行。
|
||||
|
||||
### 算子调度执行
|
||||
|
||||
算子的执行调度包含两个步骤,第一步,根据拓扑排序算法,将计算图进行拓扑排序得到线性的算子调度序列;第二步,将序列中的算子分配到指令流进行运算,尽可能将序列中的算子并行执行,提高计算资源的利用率。
|
||||
|
||||
计算图是一种由依赖边和算子构成的有向无环图,机器学习框架后端需要将包含这种依赖关系的算子准确地发送到计算资源,比如GPU、NPU上执行。针对有向无环图,通常使用拓扑排序来得到一串线性的序列。
|
||||
|
||||
如 :numref:`schedule`所示,左边是一张有向无环图。图中包含了a、b、c、d、e五个节点和a->d、b->c、c->d、d->e四条边(a->d表示d依赖于a,称为依赖边)。将图的依赖边表达成节点的入度(图论中通常指有向图中某点作为图中边的终点的次数之和),可以得到各个节点的入度信息(a:0、 b:0、 c:1、 d:2、 e:1)。拓扑排序就是不断循环将入度为0的节点取出放入队列中,直至有向无环图中的全部节点都加入到队列中,循环结束。例如,第一步将入度为0的a、b节点放入到队列中,此时有向无环图中c、d的入度需要减1,得到新的入度信息(c:0、d:1、e:1)。以此类推,将所有的节点都放入到队列中并结束排序。
|
||||
|
||||

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

|
||||
:width:`800px`
|
||||
:label:`order`
|
||||
|
||||
宏观上来看迭代训练之间,每一轮迭代中计算图必须读取训练数据,执行完整的前向计算和反向梯度计算,将图中所有参数值更新完毕后,才能开始下一轮的计算图迭代计算更新。所以“数据载入-数据预处理-模型训练”的计算图整体任务调度是以串行方式进行的。
|
||||
|
||||
在分析计算图内部算子依赖关系时,除了直接依赖和间接依赖之外,存在算子间相互独立的情况。如 :numref:`para`中op1和op2之间相互独立,此时可以将两个算子分配到两个硬件上进行并行计算。对比串行执行,并行计算可以同时利用更多的计算资源来缩短执行时间。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`para`
|
||||
|
||||
并行包括算子并行、模型并行以及数据并行。算子并行不仅可以在相互独立的算子间实现,同时也可以将单个算子合理的切分为相互独立的多个子操作,进一步提高并行性。模型并行就是将整体计算图进行合理的切分,分配到不同设备上进行并行计算,缩短单次计算图迭代训练时间。数据并行则同时以不同的数据训练多个相同结构的计算图,减少训练迭代次数,加快训练效率。这三种并行方式将在后续章节中进行详细讲解。
|
||||
|
||||
### 数据载入同步与异步机制
|
||||
|
||||
一次完整计算图的训练执行过程包含:数据载入、数据预处理、网络训练三个环节。三个环节之间的任务调度是以串行方式进行,每一个环节都有赖于前一个环节的输出。但计算图的训练是多轮迭代的过程,多轮训练之间的三个环节可以用同步与异步两种机制来进行调度执行。
|
||||
|
||||
- **同步**:顺序执行任务,当前任务执行完后会等待后续任务执行情况,任务之间需要等待、协调运行;
|
||||
|
||||
- **异步**:当前任务完成后,不需要等待后续任务的执行情况,可继续执行当前任务下一轮迭代。
|
||||
|
||||
以同步机制来执行计算图训练时,如 :numref:`synchronization`所示,每一轮迭代中,数据载入后进行数据预处理操作,然后传输给计算图进行训练。每一个环节执行完当前迭代中的任务后,会一直等待后续环节的处理,直至计算图完成一次迭代训练更新参数值后,才会进行下一轮迭代的数据载入、数据预处理以及网络训练。当进行数据载入时,数据预处理、模型训练处于等待的状态;同样的,模型处于训练时,数据载入的I/O通道处于空闲,同步机制造成计算资源和通信资源的浪费。
|
||||
|
||||

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

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

|
||||
:width:`800px`
|
||||
:label:`asyn_para`
|
||||
29
website/chapter_computational_graph/summary.md
Normal file
29
website/chapter_computational_graph/summary.md
Normal file
@@ -0,0 +1,29 @@
|
||||
## 总结
|
||||
|
||||
- 为了兼顾编程的灵活性和计算的高效性,设计了基于计算图的机器学习框架。
|
||||
|
||||
- 计算图的基本数据结构是张量,基本运算单元是算子。
|
||||
|
||||
- 计算图可以表示机器学习模型的计算逻辑和状态,利用计算图分析图结构并进行优化。
|
||||
|
||||
- 计算图是一个有向无环图,图中算子间可以存在直接依赖和间接依赖关系,或者相互关系独立,但不可以出现循环依赖关系。
|
||||
|
||||
- 可以利用控制流来改变数据在计算图中的流向,常用的控制流包括条件控制和循环控制。
|
||||
|
||||
- 计算图的生成可以分为静态生成和动态生成两种方式。
|
||||
|
||||
- 静态图计算效率高,内存使用效率高,但调试性能较差,可以直接用于模型部署。
|
||||
|
||||
- 动态图提供灵活的可编程性和可调试性,可实时得到计算结果,在模型调优与算法改进迭代方面具有优势。
|
||||
|
||||
- 利用计算图和算子间依赖关系可以解决模型中的算子执行调度问题。
|
||||
|
||||
- 根据计算图可以找到相互独立的算子进行并发调度,提高计算的并行性。而存在依赖关系的算子则必须依次调度执行。
|
||||
|
||||
- 计算图的训练任务可以使用同步或者异步机制,异步能够有效提高硬件使用率,缩短训练时间。
|
||||
|
||||
## 扩展阅读
|
||||
|
||||
- 计算图是机器学习框架的核心理念之一,了解主流机器学习框架的设计思想,有助于深入掌握这一概念,建议阅读 [TensorFlow 设计白皮书](https://arxiv.org/abs/1603.04467)、 [PyTorch计算框架设计论文](https://arxiv.org/abs/1912.01703)。
|
||||
- 图外控制流直接使用前端语言控制流,熟悉编程语言即可掌握这一方法,而图内控制流则相对较为复杂,建议阅读[TensorFlow控制流](http://download.tensorflow.org/paper/white_paper_tf_control_flow_implementation_2017_11_1.pdf)论文。
|
||||
- 动态图和静态图设计理念与实践,建议阅读[TensorFlow Eager 论文](https://arxiv.org/pdf/1903.01855.pdf)、[TensorFlow Eager Execution](https://tensorflow.google.cn/guide/eager?hl=zh-cn)示例、[TensorFlow Graph](https://tensorflow.google.cn/guide/intro_to_graphs?hl=zh-cn)理念与实践、[MindSpore动静态图](https://www.mindspore.cn/docs/programming_guide/zh-CN/r1.6/design/dynamic_graph_and_static_graph.html)概念。
|
||||
21
website/chapter_data_processing/data_order.md
Normal file
21
website/chapter_data_processing/data_order.md
Normal file
@@ -0,0 +1,21 @@
|
||||
## 保序性设计
|
||||
|
||||
和常规数据并行计算任务不同的是,机器学习场景下的数据并行处理为了确保实验的可复现性需要维护保序的性质。在具体实现中,我们需要保证并行数据预处理后的数据输出顺序与输入顺序保持相同(即下图中的SeqB和SeqA相同)。这确保了每一次的数据模块的结果输出顺序由数据混洗模块输出顺序唯一确定,有助于用户在不同的实验之间进行比较和调试。不同的机器学习系统采用了不同的方案来确保保序性,我们以MindSpore的实现为例子进行介绍以加深读者对这部分内容的理解。
|
||||
|
||||

|
||||
: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实现了保序功能。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`mindspore_data_order_implementation`
|
||||
97
website/chapter_data_processing/extension.md
Normal file
97
website/chapter_data_processing/extension.md
Normal file
@@ -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<ds::TensorTransform> decode(new ds::vision::Decode());
|
||||
// 2. 缩放
|
||||
std::shared_ptr<ds::TensorTransform> resize(new ds::vision::Resize({256}));
|
||||
// 3. 归一化
|
||||
std::shared_ptr<ds::TensorTransform> 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<ds::TensorTransform> 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 :cite:`nvidia_dali`是一个更加通用的基于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模式执行。
|
||||
|
||||

|
||||
|
||||
: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等现有大数据计算框架进行数据预处理并将结果写入分布式文件系统,而训练的机器只需要读取预处理的结果数据并进行训练即可。
|
||||
|
||||

|
||||
|
||||
:width:`800px`
|
||||
:label:`distributed_data_preprocess_based_on_3rd_party_software`
|
||||
|
||||
该方案虽然在业内被广泛使用,却面临着三个问题:
|
||||
|
||||
- 由于数据处理和数据训练采用不同的框架,使得用户为此常常需要在两个不同的框架中编写不同语言的程序,增加了用户的使用负担。
|
||||
|
||||
- 由于数据处理系统和机器学习两个系统间无法做零拷贝的数据共享,使得数据的序列化和反序列化常常成为不可忽视的额外开销。
|
||||
|
||||
- 由于大数据计算框架并不是完全针对机器学习场景,使得某些分布式预处理操作如全局的数据混洗无法被高效的实现。
|
||||
|
||||
为了更适配机器学习场景的数据预处理,分布式机器学习框架Ray借助其自身的任务调度能力实现了简单的分布式的数据预处理------
|
||||
Ray Dataset :cite:`moritz2018ray`,由于数据预处理和训练处在同一个框架内,在降低了用户的编程负担的同时也通过数据的零拷贝共享消除了序列化/反序列化带来的额外开销。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")
|
||||
```
|
||||
30
website/chapter_data_processing/index.md
Normal file
30
website/chapter_data_processing/index.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# 数据处理框架
|
||||
|
||||
在前两个章节中,我们介绍了编译器前后端的相关内容,详细地阐述了源程序到目标程序的转换优化过程。除了让芯片在训练/推理过程中高性能地运行,我们还需要将数据高效地发送给芯片,以实现全流程的性能最优。机器学习模型训练和推理需要从存储设备(如本地磁盘和内存、远端的存储系统等)中加载数据集,对数据集进行一系列处理变换,将处理结果发送到GPU或者华为昇腾Ascend等加速器中完成模型计算,该流程的任何一个步骤出现性能问题都会对训练和推理的吞吐率造成负面影响。本章我们将核心介绍如何设计、并实现一个面向机器学习场景的数据系统,以帮助用户轻松构建各种复杂的数据处理流水线(Data
|
||||
Pipeline),同时我们的数据系统要有足够高的执行性能,以确保数据预处理步骤不会成为模型训练和推理的性能瓶颈。
|
||||
|
||||
本章主要从易用性、高效性和保序性三个维度展开介绍机器学习系统中的数据模块。在前两个小节中,我们首先讨论如何构建一个易用的数据模块。包括如何设计编程抽象,使得用户通过短短几行代码便可以描述一个复杂的预处理过程;以及如何做到既内置丰富算子提升易用性,又可以灵活支持用户使用自定义算子覆盖长尾需求。用户构建好数据处理流程后,数据模块需要负责高效的调度执行数据流水线,以达到最优的数据处理吞吐率。高效的执行数据流水线是一个具有挑战性的任务,我们既要面临数据读取部分的I/O性能问题,又要解决数据处理部分的计算性能问题。针对上述挑战,我们将分别介绍面向高吞吐率读取性能的数据文件格式设计,以及能够充分发挥多核CPU算力的并行架构设计。不仅如此,和常规数据并行计算任务不同的是,大部分机器学习场景对于数据的输入输出顺序有着特殊的`保序性`的要求,我们将会使用一节的内容来介绍什么是保序性,以及如何在数据模块的并行架构中设计相应组件计来满足该特性需求。学习了上述的内容后,读者将会对如何构建一个面向机器学习场景高效易用的数据模块有深刻的理解。最后,作为拓展内容,我们将以目前学术界和业界的一些实践经验来介绍当单机处理性能达不到要求时,该如何去扩展我们的数据处理模块以满足训练性能需求。本章学习目标包括:
|
||||
|
||||
- 了解机器学习数据模块架构中的关键组件及其功能
|
||||
|
||||
- 了解不同数据模块用户编程接口的设计
|
||||
|
||||
- 掌握面向高性能数据读取的数据文件格式设计
|
||||
|
||||
- 掌握机器学习系统数据模块并行架构
|
||||
|
||||
- 掌握机器学习系统数据模块数据保序性含义及其解决方案
|
||||
|
||||
- 了解两种单机数据处理性能扩展方案
|
||||
|
||||
|
||||
```toc
|
||||
:maxdepth: 2
|
||||
|
||||
requirements
|
||||
program_model
|
||||
performance
|
||||
data_order
|
||||
extension
|
||||
summary
|
||||
```
|
||||
183
website/chapter_data_processing/performance.md
Normal file
183
website/chapter_data_processing/performance.md
Normal file
@@ -0,0 +1,183 @@
|
||||
## 高效性设计
|
||||
|
||||
在上一节中我们重点介绍了数据模块的编程抽象以及编程接口设计,确保用户可以方便的基于我们提供的API描述数据处理流程而不需要过多关注实现和执行细节。那么本节我们将进一步探究数据加载以及流水线调度执行等数据模块关键部分设计细节以确保用户能够拥有最优的数据处理性能。同时在本节内容中,我们也会贯穿现有主要机器学习系统的实践经验以帮助读者加深对这些关键设计方案的理解。
|
||||
|
||||
如 :numref:`async_data_process` 所示,深度学习模型训练需要借助数据模块首先从存储设备中加载数据集,在内存中进行一系列的预处理变换,最终将处理好的数据集发送到加速器芯片上执行模型的计算,目前有大量的工作都着重于研究如何通过设计新的硬件或者应用算子编译等技术加速芯片上的模型计算,而在数据处理流水的性能问题上鲜有涉及。但事实上很多情况下,数据预处理的执行时间往往在整个训练任务中占据着相当大的比例,导致GPU/华为昇腾Ascend等加速器无法被充分利用。研究数据表明,企业内数据中心的计算任务大约有30%的计算时间花费在数据预处理步骤 :cite:`murray2021tf`,也有研究发现在一些公开数据集上的模型训练任务有65%的时间都花费在了数据预处理上 :cite:`mohan2020analyzing`,由此可以看出数据模块的性能对于整体训练吞吐率有着决定性的影响。
|
||||
|
||||

|
||||
: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差异,并基于这种数据格式进行数据加载方案的设计与优化,而实际使用中用户只需要将其原始数据集转换存储为我们的统一数据格式便可以享受到高效的读取效率。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`unified_record_format`
|
||||
|
||||
那么我们的Unirecord除了统一用户存储格式之外还需要具备哪些特性呢?机器学习模型训练中对数据的访问具有如下特点:
|
||||
|
||||
- 每一个Epoch内以一种随机顺序遍历所有的数据且每个数据只被遍历一次
|
||||
|
||||
- 所有Epoch需要以不同的随机顺序遍历访问所有数据
|
||||
|
||||
上述的访问特性要求我们的Unirecord存储格式能够支持高效的随机读取。当我们的数据集能够全部存储在RAM中时,对Unirecord的随机读取并不会成为大的问题。但是当数据集大到必须存储在本地磁盘或者分布式文件系统中时,我们就需要设计特定的方案。一个直观的想法是将一个Unirecord文件分为索引块和数据块,索引块中记录每个数据在文件中的大小、偏移以及一些校验值等元信息,数据块存储每个数据的主体数据。当我们需要对一个Unirecord格式的文件进行随机读取时,我们首先在内存中加载该文件的索引块(通常远远小于整个文件大小)并在内存中建立文件内数据的索引表,接着当我们需要随机读取数据时,我们首先在索引表中查询该数据在文件中的偏移、大小等信息并基于该信息从磁盘上进行读取。这样的读取方式可以满足我们在磁盘上的随机读取需求。接下来我们以MindSpore提出的MindRecord的实践经验为例子介绍统一文件格式的设计,以帮助大家加深对这部分内容的理解
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`file_random_access`
|
||||
|
||||
#### MindRecord介绍
|
||||
|
||||
MindRecord是MindSpore推出的统一数据格式,目标是归一化用户的数据集,优化训练数据的读取过程。该文件格式具备如下特征:
|
||||
|
||||
- 实现多变的用户数据统一存储、访问,训练数据读取更加简便。
|
||||
|
||||
- 数据聚合存储,高效读取,且方便管理、移动。
|
||||
|
||||
- 高效的数据编解码操作,对用户透明、无感知。
|
||||
|
||||
- 可以灵活控制分区的大小,实现分布式训练。
|
||||
|
||||
和我们前文设计的Unirecord思路相似,一个MindRecord文件也由数据文件和索引文件组成,数据文件包含文件头、标量数据页、块数据页,用于存储用户归一化后的训练数据,索引文件包含基于标量数据(如图像Label、图像文件名等)生成的索引信息,用于方便的检索、统计数据集信息。为确保对一个MindRecord文件的随机读取性能,MindSpore建议单个MindRecord文件小于20G,若数据集超过20G,用户可在MindRecord数据集生成时指定相应参数将原始数据集分片存储为多个MindRecord文件。
|
||||
|
||||

|
||||
|
||||
: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的结果就是正确的。
|
||||
|
||||

|
||||
|
||||
:width:`800px`
|
||||
:label:`mindrecord_partition`
|
||||
|
||||
### 数据计算的高效性
|
||||
|
||||
解决了数据读取性能问题后,我们继续来研究数据计算的性能提升(即最大化上文中的数据处理速率P)。我们以上文提及的数据预处理流水为例子、来研究如何设计数据模块对用户计算图的调度执行以达到最优的性能。
|
||||
|
||||

|
||||
|
||||
:width:`800px`
|
||||
:label:`serialized_data_process`
|
||||
|
||||
由于深度学习芯片如GPU/华为昇腾Ascend等并不具备通用数据处理的能力,
|
||||
我们目前还是主要依赖CPU来完成预处理计算。主流的AI服务器大多具备多个多核CPU,数据模块需要设计合理的并行架构充分发挥多核算力,以提升数据预处理性能达到尽可能减少加速器由于等待数据而阻塞的目的。本节中我们将介绍流水线粒度并行以及算子粒度并行两种常见的并行架构。流水线并行的方式结构清晰,易于理解和实现,主要被Pytorch这样基于Python实现数据模块的机器学习系统所采用。受到经典数据并行系统调度执行架构设计的影响,其他如Google的TensorFlow以及华为的MindSpore等系统主要采用算子粒度并行做精细CPU算力分配以达到充分利用多核算力的目的。然而精细的分配意味着我们需要对所有数据处理流程中涉及的算子设置合理的并行参数,这对用户而言是一个较大的挑战。于是MindSpore等框架又提供数据流图中关键参数自动调优的功能,通过运行时的动态分析自动搜索得到最优的算子并行度等参数,极大的减少了用户的编程负担。接下来我们一一展开讨论。
|
||||
|
||||
#### 流水线并行
|
||||
|
||||
第一种常见的并行方案为流水线粒度的并行,即我们把用户构建的计算流水在一个线程/进程内顺序串行执行,同时启动多个线程/进程并行执行多个流水线。假设用户总共需要处理N个数据样本,那么当流水线并行度为M时,每个进程/线程只需要执行处理(N/M)个样本。流水线并行架构结构简单,易于实现。整个并行架构中各个执行进程/线程只需要在数据执行的开始和结束进行跨进程/线程的通信即可,数据模块将待处理的数据任务分配给各个流水线进程/线程,并在最终进行结果汇总发送到芯片上进行模型计算。从用户的角度而言使用也相对方便,只需要指定关键的并行度参数即可。接下来我们以Pytorch为例子进行详细展开。
|
||||
|
||||

|
||||
: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返回给主进程。
|
||||
|
||||

|
||||
: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来达到基于多线程实现流水线并行以提升通信效率的目的 :cite:`rmpygil`,感兴趣的读者可以选择继续深入了解。
|
||||
|
||||
#### 算子并行
|
||||
|
||||
流水线并行中算力(CPU核心)的分配以流水线为粒度,相对而言,以算子为计算资源分配粒度的算子并行是一种追求更精细算力分配的并行方案。我们期望对计算耗时高的算子分配更高的并行度,计算耗时低的算子分配更低的并行度,以达到更加高效合理的CPU算力使用。算子并行想法和经典的数据并行计算系统的并行方式一脉相承,我们以经典的MapReduce计算执行为例子,我们发现这也可以认为是一种算子并行(map算子和reduce算子),其中map算子的并行度和reduce算子的并行度根据各个算子阶段的计算耗时而决定。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`mapreduce`
|
||||
|
||||
下图中我们给出本节开头数据预处理流程的算子并行架构示意图,我们根据各个算子的计算耗时设置图片解码算子并行度为3,图片缩放并行度为2,图片随机旋转算子并行度为4,图片归一化算子并行度为3,以及图像通道转置算子并行度为1。我们期望通过给不同耗时的算子精准的分配算力,以达到算力高效充分的利用。具体实现中算子并行一般采用线程级并行,所有的算子使用线程间队列等方法进行共享内存通信。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`operator_parallisim`
|
||||
|
||||
现有机器学习系统的数据模块中,tf.data以及MindData均采用了算子并行的方案。由于对算力的利用更加充分、以及基于C++的高效数据流调度实现,算子并行的方案往往展示出更好的性能,tf.data的性能评测表明其相比较Pytorch的Dataloader有近两倍的性能优势 :cite:`murray2021tf`。
|
||||
接下来我们以一段基于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都增加了流水线关键参数动态调优功能,基于对流水线执行时的性能监控计算得到合理的参数以尽可能达到最优的数据预处理吞吐率 :cite:`murray2021tf`。
|
||||
|
||||
#### 数据处理计算图优化
|
||||
|
||||
在前文中,我们专注于通过并行架构来高效执行用户构建的数据预处理计算图。但我们可以思考如下问题:用户给定的计算图是否是一个高效的计算图?
|
||||
如果不高效,我们是否能够在保证等价变换的前提下将用户的数据计算图进行优化重写得到执行性能预期更好的计算图?没错,这和我们在前几章中学习的模型计算图编译优化有着相同的思想,即通过分析变换计算图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指令集来加速数据预处理。
|
||||
|
||||
122
website/chapter_data_processing/program_model.md
Normal file
122
website/chapter_data_processing/program_model.md
Normal file
@@ -0,0 +1,122 @@
|
||||
## 易用性设计
|
||||
|
||||
本节我们主要介绍如何设计一个易用的机器学习系统数据模块。正如前文所言,易用性既要求数据模块提供好的编程抽象和接口使得用户可以方便的构建一个数据处理流水,同时还要支持用户灵活地在数据流水中注册使用自定义算子以满足丰富多变的特殊需求,接下来我们将从编程接口抽象和自定义算子注册机制两个方面来展开探讨。
|
||||
|
||||
### 编程抽象与接口
|
||||
|
||||
:numref:`image_process_pipeline` 我们展示的是一个训练图片分类模型的经典数据预处理流水线。我们从存储设备中加载数据集后,对数据集中的图片数据进行解码、缩放、旋转、正规化、通道变换等一系列操作,对数据集的标签也进行特定的预处理操作,最终将处理好的数据发送到芯片上进行模型的计算。我们希望数据模块提供的编程抽象具备足够高的层次,以使得用户可以通过短短几行代码就能描述清楚数据处理的逻辑,不需要陷入过度的、重复的数据处理实现细节当中。同时又要确保这一套高层次的抽象具备足够通用性,以满足多样的数据预处理需求。在我们得到一个好的编程抽象后,我们将会以基于MindSpore的数据模块提供的编程接口实现下图所描述的数据预处理流水线的代码片段为例子来展示一个优秀的编程抽象对用户编程负担的减轻是有多么大的作用。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`image_process_pipeline`
|
||||
|
||||
|
||||
事实上,面向数据计算的编程抽象早已在通用数据并行计算系统领域中被广泛的研究并取得了相对统一的共识------那就是提供类LINQ式 :cite:`meijer2006linq` 的编程抽象,其最大的特点是让用户专注于描述基于数据集的生成与变换,而将这些操作的高效实现与调度执行交由数据系统的运行时负责。一些优秀的系统如Naiad :cite:`murray2013naiad`,
|
||||
Spark :cite:`zaharia2010spark`, DryadLINQ :cite:`fetterly2009dryadlinq`等都采用了这种编程模型。我们以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`的计算流程可视化中我们也可以清晰的看到用户在创建数据集后,只需要描述在数据集上的作用算子即可,至于算子的执行和实现则由系统的运行时负责。
|
||||
|
||||

|
||||
|
||||
:width:`800px`
|
||||
:label:`rdd_transformation_example`
|
||||
主流机器学习系统中的数据模块同样也采用了类似的编程抽象,如TensorFlow的数据模块tf.data :cite:`murray2021tf`,
|
||||
以及MindSpore的数据模块MindData等。接下来我们以MindData的接口设计为例子来介绍如何面向机器学习这个场景设计好的编程抽象来帮助用户方便的构建模型训练中多种多样的数据处理流水线。
|
||||
|
||||
MindData是机器学习系统MindSpore的数据模块,主要负责完成机器学习模型训练中的数据预处理任务,MindData向用户提供的核心编程抽象为基于Dataset(数据集)的变换处理。这里的Dataset是一个数据帧的概念(Data
|
||||
Frame),即一个Dataset为一个多行多列,且每一列都有列名的关系数据表。
|
||||
|
||||

|
||||
: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的创建,这给用户提供了极大的自由度。
|
||||
|
||||

|
||||
|
||||
最后我们以一个基于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中的自定义算子注册使用实现为例子展开讨论这部分内容。
|
||||
|
||||

|
||||
|
||||
: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仓(插件仓) :cite:`minddata` 为MindData的算子插件仓,囊括了为特定领域(遥感,医疗,气象等)量身制作的算子,该仓承载MindData的插件能力扩展,为用户编写MindData的新算子提供了便捷易用的入口,用户通过编写算子、编译、安装插件步骤,然后就可以在MindData
|
||||
Pipeline的map操作中使用新开发的算子。
|
||||
|
||||
|
||||
|
||||

|
||||
|
||||
:width:`800px`
|
||||
:label:`mindspore_user_defined_operator`
|
||||
33
website/chapter_data_processing/requirements.md
Normal file
33
website/chapter_data_processing/requirements.md
Normal file
@@ -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等加速器中以进行后续的模型计算和更新。高性能的数据模块往往选择将数据向设备的搬运与加速器中的计算异步执行,以提升整个训练的吞吐率。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`pipeline`
|
||||
|
||||
实现上述的组件只是数据模块的基础,我们还要对如下方面进行重点设计:
|
||||
|
||||
#### 易用性
|
||||
|
||||
AI模型训练/推理过程中涉及到的数据处理非常灵活:一方面,不同的应用场景中数据集类型千差万别,特点各异,在加载数据集时,数据模块要支持图像、文本、音频、视频等多种类型的特定存储格式,还要支持内存、本地磁盘、分布式文件系统以及对象存储系统等多种存储设备类型,模块需要对上述复杂情况下数据加载中的IO差异进行抽象统一,减少用户的学习成本。另一方面,不同的数据类型往往也有着不同的数据处理需求。现有常见机器学习任务中,图像任务常常对图像进行缩放、翻转、模糊化等处理,文本任务需要对文本进行切分、向量化等操作,而语音任务需要对语音进行快速傅立叶变换、混响增强、变频等预处理。为帮助用户解决绝大部分场景下的数据处理需求,数据模块需要支持足够丰富的面向各种类型的数据预处理算子。然而新的算法和数据处理需求在不断快速涌现,我们需要支持用户在数据模块中方便的使用自定义处理算子,以应对数据模块未覆盖到的场景,达到灵活性和高效性的最佳平衡。
|
||||
|
||||
#### 高效性
|
||||
|
||||
由于GPU/华为昇腾Ascend等常见AI加速器主要面向Tensor数据类型计算,并不具备通用的数据处理能力,现有主流机器学习系统数据模块通常选择使用CPU进行数据流水线的执行。理想情况下,在每个训练迭代步开始之前,数据模块都需要将数据准备好、以减少加速器因为等待数据而阻塞的时间消耗。然而数据流水线中的数据加载和数据预处理常常面临着具有挑战性的I/O性能和CPU计算性能问题,数据模块需要设计具备支持随机读取且具备高读取吞吐率的文件格式来解决数据读取瓶颈问题,同时还需要设计合理的并行架构来高效的执行数据流水线,以解决计算性能问题。为达到高性能的训练吞吐率,主流机器学习系统均采用数据处理与模型计算进行异步执行,以掩盖数据预处理的延迟。
|
||||
|
||||
#### 保序性
|
||||
|
||||
和常规的数据并行计算任务所不同的是,机器学习模型训练对数据输入顺序敏感。使用随机梯度下降算法训练模型时,通常在每一轮需要按照一种伪随机顺序向模型输入数据,并且在多轮训练(Epoch)中每一轮按照不同的随机顺序向模型输入数据。由于模型最终的参数对输入数据的顺序敏感,为了帮助用户更好的调试和确保不同次实验的可复现性,我们需要在系统中设计相应机制使得数据最终送入模型的顺序由数据混洗组件的数据输出顺序唯一确定,不会由于并行数据变换而带来最终数据模块的数据输出顺序不确定。我们将在后文中对于保序性的要求和具体实现细节展开探讨。
|
||||
8
website/chapter_data_processing/summary.md
Normal file
8
website/chapter_data_processing/summary.md
Normal file
@@ -0,0 +1,8 @@
|
||||
## 总结
|
||||
|
||||
本章我们围绕着易用性、高效性和保序性三个维度展开研究如何设计实现机器学习系统中的数据预处理模块。在易用性维度我们重点探讨了数据模块的编程模型,通过借鉴历史上优秀的并行数据处理系统的设计经验,我们认为基于描述数据集变换的编程抽象较为适合作为数据模块的编程模型,在具体的系统实现中,我们不仅要在上述的编程模型的基础上提供足够多内置算子方便用户的数据预处理编程,同时还要考虑如何支持用户方便的使用自定义算子。在高效性方面,我们从数据读取和计算两个方面分别介绍了特殊文件格式设计和计算并行架构设计。我们也使用我们在前几章中学习到的模型计算图编译优化技术来优化用户的数据预处理计算图,以进一步的达到更高的数据处理吞吐率。机器学习场景中模型对数据输入顺序敏感,于是衍生出来保序性这一特殊性质,我们在本章中对此进行了分析并通过MindSpore中的Connector的特殊约束实现来展示真实系统实现中如何确保保序性。最后,我们也针对部分情况下单机CPU数据预处理性能的问题,介绍了当前基于异构处理加速的纵向扩展方案,和基于分布式数据预处理的横向扩展方案,我们相信读者学习了本章后能够对机器学习系统中的数据模块有深刻的认知,也对数据模块未来面临的挑战有所了解。
|
||||
|
||||
## 扩展阅读
|
||||
|
||||
- 流水线粒度并行实现示例建议阅读 [Pytorch DataLoader](https://github.com/pytorch/pytorch/tree/master/torch/utils/data)。
|
||||
- 算子粒度并行实现示例建议阅读 [MindData](https://gitee.com/mindspore/mindspore/tree/master/mindspore/ccsrc/minddata)。
|
||||
223
website/chapter_distributed_training/collective.md
Normal file
223
website/chapter_distributed_training/collective.md
Normal file
@@ -0,0 +1,223 @@
|
||||
## 集合通信
|
||||
|
||||
作为并行计算中的一个重要概念,集合通信算子经常会被用来构建单程序流/多数据流编程环境(single program-multiple data, SPMD)中的许多交互模式。近年来,该领域无论是在对不同硬件架构的支持还是算法性能的发展上都成果颇丰,而因SPMD在大型深度学习系统中与数据并行的深厚联系,这些框架也在其中受益匪浅。因此,相比点对点 (Point-to-Point, p2p) 通信,我们有更大的兴趣去探讨如何高效地在数据中心(Data Centers)中实现这些集合通信范式。首先,我们会介绍一些集合通信中常见的算子,一个经典的利用All算法解决分布式训练系统中网络瓶颈的示例,探讨该算法在不同网络拓扑结构下的差异性以及一些重要指标(算法带宽,总线带宽)的计算方法,最后简略介绍现有机器学习系统对不同集合通信算法的支持。
|
||||
|
||||
### 常见算子
|
||||
|
||||
在分布式内存模型(Distributed Memory Model)中,一些常见的进程间数据交互模式由硬件支持和并行算法的内在性质而涌现。因此,主流的并行计算架构标准(例如MPI)和机器学习系统的底层集合通信库(例如gloo,NCCL)通常会支持数个经典的算子并针对其做优化,一般包括Broadcast,Reduce,AllGather,ReduceScatter 和 AllReduce。在一个基于 :cite:`Sanders2019-cq` 的简化理论模型下,可以对这些算子的特性进行简单的介绍并探讨具体的实现方法和计算开销。
|
||||
|
||||
#### 基本定义
|
||||
|
||||
首先,假定一个简化后的分布式内存模型:存在p个随机存取存储器(Random Access Machines, RAM)作为基础的处理单元(Processing Element, PE),并由一个网络来连接所有的机器。每个处理单元有自己的独立内存,并且所有的处理单元间的通信都通过网络传输。同时,每个处理单元都知道自己的编号$i$,通常在$1$到$p$之间。
|
||||
网络之间的通信在最底层的情况下均为点对点的全双工通信(full-duplex point-to-point communication):
|
||||
|
||||
* 每次通信有且仅有一个发送者(sender)和一个接收者(receiver)。
|
||||
* 在某个特定时刻,每个处理单元仅能至多发送或接收一个信息。但是,在网络中可以同时传输多个信息。每个处理单元也可以在发送一个信息的同时接收一个信息。
|
||||
* 传输一个长度为l的信息会花费$a+bl$的时间,其中$a$代表延迟(latency),即单位信息通过网络从一个处理单元出发到达另一个处理单元所需的时间;$b$代表传输延迟(transmission delay),即把单位信息从处理单元中放到网络通信单元所需的时间。前者的大小一般取决于两个处理单元间的物理距离(同一个机架,同一个数据中心,横跨全球等),而后者的大小一般取决于通信网络的带宽。在这个模型下,假定所有处理单元之间的a和b均为恒定值。
|
||||
* 通信可以指定一个发送者或者一个接收者:由于每个存储单元都有相对应的编号,我们可以定义两个函数send(i,l) 和receive(i,l)。其中send函数会把信息l从当前的处理单元发送至编号为i的处理单元,而receive函数会从编号为i的处理单元接收信息l。在调用send函数时,处理单元必须同时调用receive来保证编号为i的处理单元收到了该信息。因此,也可以说send和receive 同步(synchronize)了发送者和接收者。
|
||||
* 作为拓展,我们也可以定义上述函数的一个变种:i = send(m) 和 i = receive(m),即在传输信息时不规定发送者或接收者。这种情况下,网络中的任意一个处理单元都可以发送或接收该信息,而最终完成传输的处理单元的编号会作为函数的返回值。
|
||||
* 虽然在现实生活中错误(fault)时常发生,但是在这个模型里,暂不考虑通信丢失(dropped message)和通信毁坏(corrupted message)的情况。
|
||||
|
||||
分布式内存模型中对于通信同步和传输的结合使得在这个理论模型下开发的代码更好维护。额外的,由于这个框架下提出的算法往往会产生一些很有规律的,包含了网络中所有处理单元的交互模式,通常会在最基础的点对点通信上维护一个算子库,用来归纳总结这些高效且更易于理解的算法,我们将其称为集合通信算子。
|
||||
|
||||
#### Broadcast
|
||||
在SPMD中,最常见的一个交互模式经常是把一个位于处理单元i的信息发送到全部其他的节点,用于同步某种全局的变量或者参数。为此Broadcast算子可以定义为从编号为$i$的处理单元发送长度为$l$的信息给全部剩余的$p-1$个处理单元。在这里,一种简单的方法是在一个循环中使用$p-1$次send/receive来实现Broadcast,但这并不能很好地利用通信可并行化的特质(该算法只有$(a+bl)(p-1)$的线性时间复杂度)。为此,我们可以利用分治思想(divide-and-conquer)来对上述算法进行优化。假设所有的处理单元可以重新对编号进行排列,使得Broadcast的发送者为编号为$1$的处理单元。同时,为了简化计算过程,假设对于某个自然数$n$,$p = 2^n$。 现在,我们可以通过从1 向 $p/2$ 发送一次信息来把问题转化为两个大小为$p/2$的子问题:编号为1的处理单元对1到$p/2-1$ 的Broadcast,以及编号为$p/2$的处理单元对$p/2$到$p$的Broadcast。我们便可以通过在这两个子问题上进行递归来完成这个算法,并把临界条件定义为编号为i的处理单元在$[i,i]$这个区间里的Broadcast。此时,由于i本身已经拥有该信息,我们不需要做任何操作便可直接完成Broadcast。这个优化后的算法有$(a+bl)\log p$ 时间复杂度,因为在算法的每一阶段$t$,我们有$2^t$个计算单元在并行运行Broadcast算子。同时,算法一定会在$\log p$ 步之内结束。
|
||||
|
||||
#### Reduce
|
||||
除了Broadcast,另一个常见的交互模式为程序试图概述在部分处理单元上得到的中间值。这时候,对于一个符合结合律(associative property)的算子$f$,我们可以定义Reduce算子,即将所有处理单元上的某个值两两配对重复应用该算子,并把最终结果储存在编号为$i$的计算单元上。常见的应用于Reduce中的算子有加和,乘积,最大值,最小值和平均值等。一个简易的Reduce的优化实现同样可以用分治思想来实现,即把$1$到$p/2-1$的Reduce结果存到编号为$1$的处理单元中,然后把$p/2$到$p$的Reduce结果存到$p/2$上。最后,我们可以把$p/2$的结果发送至$1$,执行$f$,并把最后的结果存至$i$。假设$f$的运行时间复杂度为常数并不改变其输出信息的长度$l$,Reduce的时间复杂度仍然为$(a+bl)\log p$。
|
||||
|
||||
#### AllReduce
|
||||
AllReduce算子为Reduce的一个变种,即将f的结果存至所有处理单元上。在这里,我们给出一个简化版的AllReduce 实现方式,即首先把最终值通过Reduce存到编号为$1$的处理单元,再将该值通过Broadcast广播到所有的处理单元上。在两个子算子都使用上述的算法情况下,AllReduce的时间复杂度仍为$(a+bl)\log p。$
|
||||
|
||||
#### Gather
|
||||
Gather算子尝试将每个处理单元上的信息全部聚合到编号为$i$的处理单元上,通常用于组装散落在每个处理单元上的独立信息。在聚合函数符合结合律的情况下,可以通过将其设为Reduce算子中的$f$来实现Gather算子。但是,在这种情况下,无论是基于链表还是数组的实现,在每一步的Reduce子问题中$f$的时间复杂度或输出长度$l$都发生了改变。因此,Gather并不具有先前Reduce或者Broadcast的时间复杂度,而是$a \log p + (p-1) bl$。这是因为在算法的每一阶段t,我们传输的信息长度为$l 2^t$。
|
||||
|
||||
#### AllGather
|
||||
相比起Gather,AllGather 算子会把聚合的结果存到所有的处理单元上。在这里,一个简单的做法是使用Gather和Broadcast把聚合结果先存到编号为1的处理单元中,再将其广播到剩余的处理单元上。这会产生一个$a \log p + (p-1) bl + (a+plb) \log p$的时间复杂度,因为在Broadcast时如果忽略链表/数组实现所带来的额外空间开销,每次通信的长度为$pl$而不是$l$。简化后,我们得到了一个$a \log p + plb \log p$ 的时间复杂度。在一个基于超立方体的算法下,我们可以将其进一步优化到和Gather一样的$a \log p + (p-1) bl$ (:cite:`Sanders2019-cq`),然而由于篇幅问题便不再赘述。
|
||||
|
||||
#### Scatter
|
||||
Scatter算子可以被视作Gather的逆运算:把一个存在于编号为$i$的处理单元上,长度为$p$(信息长度为$pl$)的链式数据结构L中的值分散到每个处理单元上,使得编号为i的处理单元会得到$L[i]$。我们可以通过模仿Gather算法来设计一个简易的Scatter实现:每一步的运算中,与其是聚集一半处理单元的结果,我们把现在的子链继续对半切分,并把前半段和后半段作为子问题进行递归。这时候,在算法的每一阶段$t$,我们传输的信息长度为$l 2^(m-t)$,其中m是算法总共运行的步骤,不会超过$\log p$ (见Broadcast)。最终,Scatter算子的检疫实现和Gather一样都有$a \log p + (p-1) bl$ 时间复杂度。在机器学习系统中,相比于链式数据结构,Scatter经常同时被用于可切分的数据结构,例如张量(tensor)在一个维度上的p等分等。
|
||||
|
||||
#### ReduceScatter
|
||||
ReduceScatter算子可以视为Reduce 和 Scatter算子的组合体,即对于每个处理单元上分别拥有的一个链式/可切分数据结构,在通过f 概述后再重新分散到各个单元中。虽然我们已经知道了Reduce 和Scatter 各自的时间复杂度,但是在对ReduceScatter做时间复杂度分析时需要注意两部之间信息长度的变化:假设每个处理单元上的数据结构所需通信长度为$pl$,第一阶段的Reduce算法需要$(a+plb)\log p$ 时间复杂度。参照Scatter的分析,第二阶段的算子则需要 $a \log p + (p-1) bl$ 时间复杂度。综合下来,ReduceScatter 需要 $a \log p + plb \log p$ 的时间复杂度,和AllGather相同。同时,运行ReduceScatter 和 AllGather的效果等同于运行一次AllReduce。
|
||||
|
||||
在SPMD中,通常还有一些额外的集合通信算子,如Prefix Sum,Barrier,All-to-All等,但由于篇幅限制以及与机器学习系统的有限联系,便不再赘述。最后,由于该模型下通信网络的拓扑结构较为简单,上文中呈现二叉树形的递归树也可以达到很好的实际运行速度。所有关于时间复杂度的分析也是基于这些相对简化的假设情况。后文中,我们将会用AllReduce举例介绍如何在更复杂的拓扑结构下设计不同的集合通信算子变种,并在时间复杂度之外去关注实际的通信量和运算时间。
|
||||
|
||||
### 在数据中心的梯度计算
|
||||
|
||||
接下来,我们将用一个示例来阐释集合通信在机器学习系统中发挥的重要作用。
|
||||
|
||||

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

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

|
||||
:width:`800px`
|
||||
:label:`ch10-AllReduce-process`
|
||||
|
||||
AllReduce算子会把梯度的计算拆分成$M-1$个Reduce算子和$M-1$个Broadcast算子(其中$M$是节点的数量)。其中,Reduce算子用于计算出梯度的和(Summation),Broadcast算子用于把梯度之和广播给全部的节点。为了说明这些算子的执行过程,可以参照 :numref:`ch10-AllReduce-process` 。AllReduce算子由Reduce算子开始,在第一个Reduce算子中,AllReduce算子会对全部节点进行配对(Pairing),让他们共同完成梯度相加的操作。在 :numref:`ch10-AllReduce-process` 的第一个Reduce算子中,设备1和设备2进行了配对共同对分区1的数据相加。其中,设备2把本地的梯度数据1发送给设备1,设备将接收到1和本地的分区1内的梯度数据:2进行相加,计算出中间(intermediate)梯度相加的结果:3。与此同时,设备1和设备3进行配对,共同完成对分区3的数据相加。而设备3和设备2进行配对,共同完成对于分区2的数据相加。
|
||||
|
||||
在上述Reduce的算子中,梯度的计算实现了以下几个特性:
|
||||
|
||||
- **网络优化:**
|
||||
全部设备都同时在接收和发送数据,利用起了每个设备的入口(Ingress)和出口(Egress)带宽。因此AllReduce过程中可利用的带宽是$M \times B$,其中$M$是节点数量,$B$是节点带宽,从而让系统实现网络带宽上的可扩展性。
|
||||
|
||||
- **算力优化:**
|
||||
全部设备的处理器都参与了梯度相加的计算。因此AllReduce过程中可利用的处理器是$M \times P$,其中$M$是节点数量,$P$是处理器数量,从而让系统实现计算上的可扩展性。
|
||||
|
||||
- **负载均衡:**
|
||||
由于数据分区是平均划分的,因此每次设备分摊到的通讯和计算开销是相等的。
|
||||
|
||||
在接下来的Reduce算子中,AllReduce算法会对不同数据分区选择另外的配对方法。例如说,在 :numref:`ch10-AllReduce-process` 的第二个Reduce算子中,AllReduce算法会将:设备1和设备3进行配对,负责分区1的数据相加。将设备1和设备2进行配对,负责分区2。将设备2和设备3进行配对,负责分区3。在一个3个节点的AllReduce集群里,在2个Reduce算子完成后,我们就计算出了每个分区的数据相加结果(分区1的结果7此时在设备3上,分区2的结果14此时在设备1上,分区3的结果21此时在设备2上)。
|
||||
|
||||
接下来,AllReduce算法将进入Broadcast阶段。这一阶段的过程和Reduce算子类似,核心区别是节点进行配对后,他们不再进行数据相加,而是将Reduce的计算结果进行广播。在 :numref:`ch10-AllReduce-process` 中的第一个Broadcast算子中,设备1会将分区2的结果14直接写入设备3的分区2中。设备2会讲分区3的结果21直接写入设备1中。设备3会将分区1的结果直接写入设备2中。在一个3个节点的AllReduce集群中,我们会重复2次Broadcast算子来将每个分区的Reduce结果告知全部的节点。
|
||||
|
||||
### 带宽计算
|
||||
|
||||
在讨论集合通信算子的性能时,人们经常会使用一些数值化指标去量化不同的算法实现,其中一个重要概念为带宽(Bandwidth)。在文献(:cite:`nvidia-nccl`)中,通常有两种主流的对带宽的计算方法,分别为算法带宽(Algorithm Bandwidth)与总线带宽(Bus Bandwidth)。
|
||||
|
||||
#### 算法带宽
|
||||
前文提到,在计算点对点通信所需的时间是,会在信息长度之上乘以一个系数b。这个系数就是算法带宽,泛指单位时间内执行操作(通信,计算等)的数量。一般计算公式为$b = s/t$,其中$s$代指操作的大小,$t$指操作指定的两个端点之间所经过的时间。以点到点通信举例,我们可以通过衡量一个大小已知的信息$m$在执行send函数时所花的时间来确定两个处理单元之间网络的带宽。
|
||||
|
||||
#### 总线带宽
|
||||
虽然算法带宽的计算方法既简单又高效,但很难将其拓展至对于集合通信算子的带宽计算。这是因为,取决于具体算子和算法实现的不同,一个集合通信算子在执行过程中测得的算法带宽往往会远小于硬件本身的最高带宽。在实际运行相应的测试中,经常能观测到随着处理单元增加,算法带宽呈下降趋势。为了解决这一问题,NCCL提出了总线带宽这一概念,通过对于每个集合通信算子的分析来对测得的算法带宽乘以一个校正系数(correction factor),来减轻处理单元数量对于测量带宽的影响并给出一个更贴近实际硬件表现的带宽值。下面列出了一些常见算子的校正系数,以及背后的简略推导。
|
||||
|
||||
* AllReduce:$2(p-1)/p$ 对于在处理单元$n_1, n_2 \cdots n_p$ 上的值 $v_1, v_2 \cdots v_p$ 计算 $v_1 (op) v_2 \cdots (op) v_p$(其中$op$为符合结合律的算子),再存回每个处理单元中。在不考虑实际实现算法和网络拓扑的情况下,这个操作理论上只需要 $2(p-1)$ 次数据传输,其中包含在每个处理单元上分开进行的 $n-1$ 次 op的运算,以及最后 $n$ 次最终数据值的广播,再减去第一个处理单元的运算和最后一个处理单元的广播的影响。假设每个处理单元对于外界所有信息处理的带宽为$B$,我们可以得出对于S个在不同处理单元上的数据运行AllReduce是能得到的最优情况下的运行时间:$t = (2S(p-1)) / (pB)$,进行简化后可得 $B = (S/t)(2(p-1)/p) = b (2(p-1)/p)$。这里的 $2(p-1)/p$便是我们的校正系数。
|
||||
* ReduceScatter:$(p-1)/p$ 对于每个处理单元来说,可以把ReduceScatter理解为只执行AllReduce中的聚合部分。对此,我们只需要考虑上文分析中的$n-1$次$op$的运算,整理后可得$B = (S/t)((p-1)/p) = b ((p-1)/p)$。
|
||||
* AllGather:$(p-1)/p$ 同理,对于每个处理单元来说,可以把AllGather理解为只执行AllReduce中的广播部分。我们同理可得$B = (S/t)((p-1)/p) = b ((p-1)/p)$。
|
||||
* Broadcast:$1$ 与AllReduce不同的是,Broadcast中所有数据需要从算子本身的发送者发出。即使在上文的分治情况下,我们也需要等待所有子问题运行结束才能确保Broadcast算子本身的正确性。因此,在计算带宽时瓶颈仍为发送者对于外界所有信息处理的带宽,所以 $B = S/t$,即校正系数为$1$。
|
||||
* Reduce:$1$ 同Broadcast,Reduce需要将所有数据送往算子的接收者,因此校正系数同样为$1$。
|
||||
|
||||
由于Gather和Scatter的带宽计算与实际聚合/分散时的数据结构相关性更高,故不给出特定的校正系数。
|
||||
|
||||
### 样例分析
|
||||
|
||||
针对不同的集群性质,现代机器学习系统往往会灵活应用不同集合通信算子的组合来最大化通信效率。这里,我们提供了两个具体的案例分析,分别为微软的ZeRO 以及 OpenAI 的 DALL—E。
|
||||
|
||||
#### ZeRO
|
||||
ZeRO (:cite:`rajbhandari2020zero`)是微软提出的神经网络优化器,可用于训练千亿级参数的神经网络,也在实践中成功训练了当时世界上最大的语言模型(为高达170亿参数的transformer)。在训练这个级别的神经网络时主要遇到的问题是巨量参数对于加速器内存的占用,其中包括优化器本身的参数,反向传播时的梯度,以及模型参数本身。通过简易的计算不难得出,170亿参数的模型在32位浮点表示情况下会占用至少680GB的内存,远超于现在内存最高的深度学习加速器A100 (最高内存80GB)。于是,我们需要考虑如何高效的把模型切成数份存储在不同的加速器上,以及如何高效的通过使用集合通信算子来进行模型训练和推理。ZeRO对此提出了多个优化方法,这里例举了三个典型的例子:
|
||||
1. 首先,可以发现在现代集群中,节点内部加速器的带宽往往比节点之间的带宽要大很多。这在某种程度上偏离了上文中的理论框架。为此,我们需要尽量减少节点间的通信,尽量保证大部分通信仅存在于节点内部的加速器之间。在观察模型切分时,不难看出模型本身前馈和反向传播时需要大量的在不同切片之间通信,相比下来不同模型拷贝之间的梯度聚合反而具有相对较少的通信量。针对这一特性,ZeRO选择了将单一模型的全部切片存储到同一节点内部,从而大大提高了训练效率。
|
||||
2. 进一步地,假设模型中的参数在层的细粒度上呈线性,便可将其从前到后分别存储到不同加速其中。在前馈时,可以注意到某一层的计算仅依赖于其相邻层的参数。对此,与其是手动设计点到点通信,我们可以对所有包含模型参数的加速器进行一次AllGather计算,用来提取每一层之后一层的参数,以及计算该层本身的激活值。为了节约内存,我们在AllGather结束后立即丢弃除了该层以外其他层的参数。
|
||||
3. 同理,在反向传播时我们只需要前一层的参数来计算本层的激活值和梯度,因此我们只需要再次使用AllGather来完成每个加速器上的梯度计算。同时,我们注意到在聚集梯度后,对于每个加速器我们仅需要在内存中的层数的梯度。对此,我们可以使用ReduceScatter算子来在平均后直接把相应的梯度存到编号为i的加速器上,而不是通常情况下的AllReduce。
|
||||
|
||||
#### DALL-E
|
||||
DALL-E (:cite:`ramesh2021zero`)是OpenAI提出的一个基于文字的图片生成模型,模型同样拥有高达120亿参数。在训练时,除了运用到ZeRO所使用的AllGather + ReduceScatter 技巧,OpenAI团队在细节上做了进一步的优化,以达到更快的训练速度。这里,我们简略介绍以下和集合通信相关的两点:
|
||||
1. 我们注意到,集合通信算子的运行速度和通信本身的长度正相关。在模型训练中,这代表了模型参数本身的大小。对此,DALL-E 选择用矩阵分解(matrix factorization)的方法先把高维张量调整为一个二维矩阵,通过分解后分开用集合通信算子进行传输,从而大大减少了通信量。
|
||||
2. 另一个减少通信量的方法在于数据类型本身。一个显然的做法是使用16位的半精度浮点数,相比正常的32位参数表示可以节省近一倍的通信量。但是,在实践中发现低精度的数据类型会使得模型收敛不稳定,往往导致最终训练效果大打折扣。为此,OpenAI分析了DALL—E 的模型结构,并把其中的参数根据对数据类型精度的敏感性分为了多个类。其中对精度最敏感的一类照常使用32位浮点表示并只通过AllReduce来同步,而最不敏感的参数则照常通过矩阵分解进行压缩和传输。对于比较敏感的一类,例如Adam 优化其中的动能(moments)和方差(variance)参数,OpenAI 基于 IEEE 754 标准实现了两个全新的数据类型:1-6-9和0-6-10(其中第一表示正负所需的位数,第二表示指数所需的位数,第三表示有效数字所需的位数),在节省空间和保持收敛性能之间找到了一个平衡。
|
||||
|
||||
### 集合通信与机器学习系统
|
||||
|
||||
最后,集合通信已经被深度集成到了整个机器学习系统之中,以至于一些在库级别以上的开发者很难意识到系统在训练和推理时的一些步骤是由底层逻辑实现的。
|
||||
一般来说,不同的机器学习系统对于集合通信一般提供了两个级别的抽象,分别是更与硬件耦合的,可以直接调用集合通信算子的库,和更偏向神经网络实现的,通过内部调用集合通信算子来实现分布式训练和推理的深度学习框架。作为算法工程师,通常会接触到后者的抽象(包括Horovod, KungFu, TensorFlow distributed等),而作为集群的维护者,往往需要深入了解前者的运行原理和具体的调试方法。以深度学习框架 PyTorch 举例,在torch.distributed 命名空间(namespace)下实现了一系列方便开发者使用的分布式模型训练和推理函数。在其内部,会根据实际运行的集群调用更底层的集合通信算子库,例如MPI,NCCL(前文中已有介绍,适用于GPU分布式训练),gloo(适用于CPU分布式训练)等。我们来具体对比PyTorch distributed 中对于AllReduce 的应用和 NCCL 的差异性:下面两段代码中,前者(:cite:`li2022ddp`)通过PyTorch自带的分布式数据并行(Distributed Data Parallel)方法完成了一次简易的深度学习模型计算,后者则通过gloo的Python 接口pygloo和Ray(:cite:`moritz2018ray`)完成了一个二维张量的AllReduce计算。
|
||||
|
||||
```python
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
import torch.nn as nn
|
||||
import torch.optim as optim
|
||||
import torch.multiprocessing as mp
|
||||
|
||||
from torch.nn.parallel import DistributedDataParallel as DDP
|
||||
|
||||
def setup(rank, world_size):
|
||||
os.environ['MASTER_ADDR'] = 'localhost'
|
||||
os.environ['MASTER_PORT'] = '12355'
|
||||
dist.init_process_group("gloo", rank=rank, world_size=world_size)
|
||||
|
||||
class ToyModel(nn.Module):
|
||||
def __init__(self):
|
||||
super(ToyModel, self).__init__()
|
||||
self.net1 = nn.Linear(10, 10)
|
||||
self.relu = nn.ReLU()
|
||||
self.net2 = nn.Linear(10, 5)
|
||||
|
||||
def forward(self, x):
|
||||
return self.net2(self.relu(self.net1(x)))
|
||||
|
||||
def demo_basic(rank, world_size):
|
||||
setup(rank, world_size)
|
||||
|
||||
model = ToyModel().to(rank)
|
||||
# 通过调用DDP将模型在每个处理器上完成初始化
|
||||
ddp_model = DDP(model, device_ids=[rank])
|
||||
|
||||
loss_fn = nn.MSELoss()
|
||||
optimizer = optim.SGD(ddp_model.parameters(), lr=0.001)
|
||||
|
||||
optimizer.zero_grad()
|
||||
outputs = ddp_model(torch.randn(20, 10))
|
||||
labels = torch.randn(20, 5).to(rank)
|
||||
|
||||
# 在反向传播时,框架内部会执行AllReduce算法
|
||||
loss_fn(outputs, labels).backward()
|
||||
optimizer.step()
|
||||
|
||||
def run_demo(demo_fn, world_size):
|
||||
mp.spawn(demo_fn,
|
||||
args=(world_size,),
|
||||
nprocs=world_size,
|
||||
join=True)
|
||||
|
||||
if __name__ == "__main__":
|
||||
n_gpus = torch.cuda.device_count()
|
||||
assert n_gpus >= 2, f"Requires at least 2 GPUs to run, but got {n_gpus}"
|
||||
run_demo(demo_basic, n_gpus)
|
||||
```
|
||||
|
||||
```python
|
||||
import os
|
||||
import ray
|
||||
import pygloo
|
||||
import numpy as np
|
||||
import multiprocessing
|
||||
|
||||
@ray.remote(num_cpus=1)
|
||||
def test_allreduce(rank, world_size, fileStore_path):
|
||||
context = pygloo.rendezvous.Context(rank, world_size)
|
||||
attr = pygloo.transport.tcp.attr("localhost")
|
||||
dev = pygloo.transport.tcp.CreateDevice(attr)
|
||||
fileStore = pygloo.rendezvous.FileStore(fileStore_path)
|
||||
store = pygloo.rendezvous.PrefixStore(str(world_size), fileStore)
|
||||
|
||||
context.connectFullMesh(store, dev)
|
||||
|
||||
sendbuf = np.array([[1,2,3],[1,2,3]], dtype=np.float32)
|
||||
recvbuf = np.zeros_like(sendbuf, dtype=np.float32)
|
||||
sendptr = sendbuf.ctypes.data
|
||||
recvptr = recvbuf.ctypes.data
|
||||
|
||||
# 标明发送者和者并直接调用AllReduce
|
||||
pygloo.allreduce(context, sendptr, recvptr,
|
||||
sendbuf.size, pygloo.glooDataType_t.glooFloat32,
|
||||
pygloo.ReduceOp.SUM, pygloo.allreduceAlgorithm.RING)
|
||||
|
||||
if __name__ == "__main__":
|
||||
ray.init()
|
||||
world_size = multiprocessing.cpu_count()
|
||||
fileStore_path = f"{ray.worker._global_node.get_session_dir_path()}" + "/collective/gloo/rendezvous"
|
||||
os.makedirs(fileStore_path)
|
||||
ray.get([test_allreduce.remote(rank, world_size, fileStore_path) for rank in range(world_size)])
|
||||
```
|
||||
|
||||
可以注意到,前者并没有显式的调用集合通信算子,而是通过DistributedDataParallel将分布式训练和正常训练之间的不同隐藏了起来。如果我们需要在不同集群上运行这段代码,只需要在setup 函数内相对的更改PyTorch使用的底层集合通信库即可。在backward函数被调用时,才会真正的使用AllReduce算法。相比下来,如果想要直接使用gloo,不仅需要使用一步一步的创建通信所需要的数据结构,同时也很难和现有的模型训练框架无缝连接。
|
||||
27
website/chapter_distributed_training/index.md
Normal file
27
website/chapter_distributed_training/index.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# 分布式训练
|
||||
|
||||
随着机器学习的进一步发展,科学家们设计出更大型,更多功能的机器学习模型(例如说,GPT-3)。这种模型含有大量参数,需要复杂的计算以及处理海量的数据。单个机器上有限的资源无法满足训练大型机器学习模型的需求。因此,我们需要设计分布式训练系统,从而将一个机器学习模型任务拆分成多个子任务,并将子任务分发给多个计算节点,解决资源瓶颈。
|
||||
|
||||
在本章节中,我们会引入分布式机器学习系统的相关概念,设计挑战,系统实现和实例研究。我们会首先讨论分布式训练系统的定义,设计动机和好处。进一步,我们会讨论常见的分布式训练方法:数据并行,模型并行和流水线并行。在实际中,这些分布式训练方法会被参数服务器(Parameter Servers),或者是集合通信库(Collective Communication Libraries)实现。不同的系统实现具有各自的优势和劣势。我们会用大型预训练模型和大型深度学习推荐系统作为实例来探讨不同系统实现的利与弊。
|
||||
|
||||
本章的学习目标包括:
|
||||
|
||||
- 掌握分布式训练相关系统组件的定义,设计动机和好处
|
||||
|
||||
- 掌握常见的分布式训练方法:数据并行,模型并行和流水线并行
|
||||
|
||||
- 掌握常见的分布式训练框架实现:参数服务器和集合通信
|
||||
|
||||
- 理解常见分布式训练的实例,和采用不同实现方法的利弊。
|
||||
|
||||
|
||||
```toc
|
||||
:maxdepth: 2
|
||||
|
||||
overview
|
||||
methods
|
||||
pipeline
|
||||
collective
|
||||
parameter_servers
|
||||
summary
|
||||
```
|
||||
61
website/chapter_distributed_training/methods.md
Normal file
61
website/chapter_distributed_training/methods.md
Normal file
@@ -0,0 +1,61 @@
|
||||
## 分布式方法
|
||||
|
||||
我们会讨论分布式训练系统实现的常用并行方法。我们首先给出并行方法的设计目标以及分类。然后,我们会详细描述各个并行方法。
|
||||
|
||||
### 概述
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`ch10-single-node`
|
||||
|
||||
分布式训练系统的设计目标是:将单节点训练系统转化成**等价的**并行训练系统,从而在不影响模型精度的条件下完成训练过程的加速。一个单节点训练系统往往如 :numref:`ch10-single-node`所示。一个训练过程会由多个数据小批次(mini-batch)完成。在图中,一个数据小批次被标示为**数据**。训练系统会利用数据小批次来生成梯度,提升模型精度。这个过程由一个训练**程序**实现。在实际中,这个程序往往实现了一个多层神经网络的执行过程。该神经网络的执行由一个计算图(Computational Graph)表达。这个图有多个相互连接的算子(Operator),每个算子会拥有计算参数。每个算子往往会实现一个神经网络层(Neural Network Layer),而参数则代表了这个层在训练中所更新的权重(Weights)。
|
||||
|
||||
为了更新参数,计算图的执行会分为**前向**传播和**反向**传播两个阶段。前向传播的第一步会将数据读入第一个算子,该算子会根据当前的参数,计算出传播给下一个算子的数据。算子依次重复这个前向传播的过程(算子1 -\> 算子2 -\> 算子3),直到最后一个算子结束。最后的算子随之马上开始反向传播。反向传播中,每个算子依次计算出梯度(梯度3 -\> 梯度2 -\> 梯度1),并利用梯度更新本地的参数。反向传播最终在第一个算子结束。反向传播的结束也标志本次数据小批次的结束,系统随之读取下一个小批次,继续更新模型。
|
||||
|
||||
:分布式训练方法分类
|
||||
|
||||
| | 单数据 | 多数据 |
|
||||
|:---:|:---:|:---:|
|
||||
| 单程序 | 单程序单数据:单点执行 | 单程序多数据:数据并行 |
|
||||
| 多程序 | 多程序单数据:模型并行 | 多程序多数据:混合并行 |
|
||||
:label:`ch10-parallel-methods`
|
||||
|
||||
给定一个单节点训练系统,人们会对**数据**和**程序**分区(Partition),从而完成并行加速。 :numref:`ch10-parallel-methods`总结了不同的切分方法。单节点训练系统可以被归类于单程序单数据模式。而假如用户希望使用更多的设备来实现并行计算,他们首先可以选择对数据进行分区,并将同一个程序复制到多个设备上并行执行。这种方式是单程序多数据模式,常被称为**数据并行**(Data Parallelism)。另一种并行方式是对程序进行分区:程序的算子会被分发给多个设备按照依次完成。这种模式是多程序单数据模式,常被称为**模型并行**(Model Parallelism)。当训练超大型智能模型时,开发人们往往要同时对数据和程序进行切分,从而实现最高程度的并行。这种模式是多程序多数据模式,常被称为**混合并行**(Hybrid Parallelism)。
|
||||
|
||||
接下来,我们详细讲解各种并行方法的执行过程。
|
||||
|
||||
### 数据并行
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`ch10-data-parallel`
|
||||
|
||||
数据并行往往可以解决单节点的算力不足。这种并行方式在人工智能框架中最为常见,具体实现包括:TensorFlow DistributedStrategy,PyTorch Distributed,Horovod DistributedOptimizer等。在一个数据并行系统中,假设用户给定一个训练批大小$N$,并且希望使用$M$个并行设备来加速训练。那么,该训练批大小会被分为$M$个分区,每个设备会分配到$N/M$个训练样本。这些设备共享一个训练程序的副本,在不同数据分区上独立执行,计算梯度。不同的设备(假设设备编号为$i$)会根据本地的训练样本估计出梯度$G_i$。为了确保训练程序参数的一致性,本地梯度$G_i$需要聚合,计算出平均梯度$(\sum_{i=1}^{M} G_i) / M$。最终,训练程序利用平均梯度修正模型参数,完成小批量的训练。
|
||||
|
||||
:numref:`ch10-data-parallel`展示了2个设备构成的数据并行例子。假设用户给定的批大小(Batch Size)是64,那么每个设备会分配到32个训练样本,并且具有相同的神经网络参数(程序副本)。本地的训练样本会依次通过这个程序副本中的算子,完成前向传播和反向传播。在反向传播的过程中,程序副本会生成局部梯度。不同设备上对应的局部梯度(如设备1和设备2上各自的梯度1)会进行聚合,从而计算平均梯度。这个聚合的过程往往由集合通信库(Collective Communication)的Allreduce操作来完成。
|
||||
|
||||
### 模型并行
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`ch10-model-parallel-intra-op`
|
||||
|
||||
模型并行往往用于解决单节点的内存不足问题。一个常见的内存不足场景是模型中含有大型算子,例如说深度神经网络中需要计算大量分类的全连接层(Fully Connected Layer)。完成这种大型算子计算所需的内存可能超过单设备的内存容量。那么我们需要对这个大型算子进行切分。假设这个算子具有$P$个参数,而我们拥有$N$个设备,那么我们可以将$P$个参数平均分配给$N$个设备(每个设备分配$P/N$个参数),从而让每个设备负责更少的计算量,能够在内存容量的限制下完成前向传播和反向传播中所需的计算。这种切分方式是模型并行的应用,被称为**算子内并行**(Intra-operator Parallelism)。
|
||||
|
||||
:numref:`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)。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`ch10-model-parallel-inter-op`
|
||||
|
||||
:numref:`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的反向计算,完成本次训练。
|
||||
|
||||
### 混合并行
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`ch10-hybrid-parallel`
|
||||
|
||||
在训练大型人工智能模型中,我们往往会同时面对算力不足和内存不足。因此,我们需要混合使用数据并行和模型并行,这种方法被称为混合并行。 :numref:`ch10-hybrid-parallel`提供了一个由4个设备实现的混合并行的例子。在这个例子中,我们首先实现算子间并行来解决训练程序内存开销过大的问题:该训练程序的算子1和算子2被分摊到了设备1和设备2上。进一步,我们通过数据并行来添加3和设备4,提升系统算力。为了达到这一点,我们对训练数据进行分区(数据分区1和数据分区2),并将模型(算子1和算子2)分配复制到设备3和设备4上生成可以并行执行的程序副本。在前向计算的过程中,设备1和设备3上的算子1副本同时开始,计算结果分别发送(Send)给设备2和设备4完成算子2副本的计算。在反向计算中,设备2和设备4同时开始计算梯度,本地梯度通过Allreduce进行平均。反向计算传递到设备1和设备3上的算子1副本结束。
|
||||
37
website/chapter_distributed_training/overview.md
Normal file
37
website/chapter_distributed_training/overview.md
Normal file
@@ -0,0 +1,37 @@
|
||||
## 系统概述
|
||||
|
||||
### 设计动机
|
||||
|
||||
接下来,我们详细讨论分布式训练系统的设计动机。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`ch10-computation-increase`
|
||||
|
||||
##### 算力不足
|
||||
|
||||
单处理器的算力不足是促使人们设计分布式训练系统的一个主要原因。一个处理器的算力可以用**每秒钟浮点数操作**(Floating Point Operations Per Second,FLOPS)来衡量。如 :numref:`ch10-computation-increase`所示,根据摩尔定律(Moore's Law),中央处理器的算力每18个月增长2倍。虽然计算加速卡,如GPU和Tensor Processing Unit(TPU),针对机器学习计算(如矩阵相乘)提供了大量的算力。这些加速卡的发展最终也受限于摩尔定律,增长速度也停留在每18个月2倍。而与此同时,机器学习模型正在快速发展。短短数年,我们从仅能识别有限物体的AlexNet模型,一路发展到在复杂任务中打败人类的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级别的内存。
|
||||
|
||||
### 分布式训练架构
|
||||
|
||||
受限于单节点的有限算力,内存和存储资源,人们把关注投向了日益成熟的云计算数据中心。一个数据中心管理着数十万个计算服务器。随着数据中心的全球部署,人们可以很方便地获得数百个服务器。这些服务器可以通过分布式训练系统来协调和管理,解决训练大型机器学习模型过程遇到的算力,内存和存储不足,从而完成训练过程的加速。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`ch10-single-vs-multi`
|
||||
|
||||
在设计分布式训练系统的过程中,我们需要找出有资源瓶颈的计算任务,根据计算任务的特点,将其拆分成多个子任务,然后将子任务分发给多个节点(可以是服务器,机器,或者是加速卡)并行完成。 :numref:`ch10-single-vs-multi`描述了如何将单节点执行转换为分布式执行的一般过程。在机器学习系统中,一个计算任务往往会有一组数据(例如训练样本)或者任务(例如算子)作为输入,利用一个计算节点(例如GPU)生成一组输出(例如梯度)。假如单节点成为瓶颈,我们可以利用分布式计算进行加速。分布式执行一般具有三个步骤:第一步,我们需要将输入进行**切分**。第二步,每个输入部分会分发给不同的计算节点,实现**并行**计算。第三步,每个计算节点的输出,进一步**合并**,最终得到和单节点等价的计算结果。这种切分-并行-合并的模式,本质上实现了分而治之算法(Divide-and-Conquer Algorithm)的设计思想:由于每个计算节点只需要负责更小的子任务,因此其可以更快速的完成计算,最终形成对整个计算过程的加速。
|
||||
|
||||
### 用户益处
|
||||
|
||||
通过使用分布式训练系统,我们往往可以获得以下几个关键好处:
|
||||
|
||||
- **提升系统性能**:使用分布式训练,往往可以带来训练性能的巨大提升。一个分布式训练系统往往用以下这个指标来衡量性能:到达目标精度所需的时间(time-to-accuracy)。这个指标由两个参数决定:一个数据周期所需的完成时间,以及一个数据周期模型所提升的精度。通过持续增加并行处理节点,我们可以将数据周期的完成时间不断变短,最终显著减少到达目标精度所需的时间。
|
||||
|
||||
- **经济性(Economy)**:使用分布式训练,我们也可以进一步减少训练及其模型所需的成本。受限于单节点散热的上限,单节点的算力越高,其所需的散热硬件成本也更高。因此,在提供同等的算力的条件下,组合多个计算节点是一个更加经济高效的方式。这促使云服务商(如亚马逊和微软等)需要更加注重给用户提供成本高效的分布式机器学习系统。
|
||||
|
||||
- **抵御硬件故障**:分布式训练系统同时能有效提升抵御硬件故障的能力。机器学习训练集群往往由商用硬件(Commodity Hardware)组成,这类硬件(例如说,磁盘和网卡)运行一定周期就会产生故障。而仅使用单个硬件进行训练的话,那么一个硬件的故障就会造成整个训练的任务的失败。通过将这个训练任务由多个硬件共同完成,即使一个硬件故障了,我们也可以通过将这个硬件上相应的计算子任务转移给其余硬件,继续完成训练,从而避免训练任务的失败。
|
||||
35
website/chapter_distributed_training/parameter_servers.md
Normal file
35
website/chapter_distributed_training/parameter_servers.md
Normal file
@@ -0,0 +1,35 @@
|
||||
## 参数服务器
|
||||
|
||||
接下来,我们介绍另一种常见的分布式训练系统实现:参数服务器。常见的深度学习框架以不同方式提供了参数服务器。TensorFlow和MindSpore原生提供了参数服务器的实现;PyTorch需要用户使用框架提供的Rpc接口自行实现;还有一些框架则需要用户使用第三方的参数服务器实现,例如PS-Lite。
|
||||
|
||||
### 计算和存储分离
|
||||
|
||||
利用参数服务器的其中一个核心需求是实现:计算和存储的分离。在训练模型中,计算可以被理解为计算更新模型参数所需要的计算(例如说,计算本地梯度和计算平均梯度),而存储可以被理解为将模型参数存储在内存设备中(例如说,主机内存,加速卡内存和SSD设备)。传统的神经网络训练中,计算往往是核心瓶颈,因此我们只需要配置有合适数量的带有加速卡的服务器,常被称为训练服务器(Training servers)。
|
||||
|
||||
随着机器学习的发展,新型的稀疏模型被开发出来。相比于传统的神经网络训练,稀疏模型的训练往往不需要大量昂贵的计算加速卡(GPU),而需要海量的内存来存储嵌入表(Embedding table)。例如说,一个大型深度学习推荐系统中,它们往往使用小型的深度神经网络(如Multi-layer Perception),训练这种神经网络只需要几个GPU即可。而另一方面,推荐系统中往往需要存储PB级别的嵌入表。嵌入表往往由推荐系统的用户特征(User feature)和产品特征(Item feature)构成。这些特征往往是大型向量(Vector)。现代推荐系统需要服务数亿的用户,推荐数以千万的商品。假设用户的特征是1MB,而系统需要服务10亿的用户,那么用户的嵌入表就会有1PB的大小。而这个大小远远超过了一个深度学习服务器所具有的内存。假如我们部署大量的昂贵的深度学习服务器来存储海量嵌入表,那么这些服务器上的加速卡的使用率将会极低,无法实现对于硬件的高效利用。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`ch10-parameter-servers`
|
||||
|
||||
为了解决上述问题,人们往往会在稀疏模型集群中混合部署:训练服务器和参数服务器,从而实现对于计算需求和内存需求分别满足。 :numref:`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和Raft。此外,主副本上的更新如何复制到从副本上也同样是分布式系统领域的经典共识问题。通常系统设计者需要在可用性(Availability)和一致性(Consistency)之间做出取舍。如果参数服务器副本间采用强一致性的复制协议(例如,链式副本(Chain replication))则可能导致训练服务器的推送请求失败,即参数服务器不可用。反之,如果参数服务器采用弱一致性的复制协议,则可能导致副本间存储的参数不一致。
|
||||
|
||||
### 掉队者问题
|
||||
|
||||
参数服务器的另一大核心作用是可以让用户方便解决掉队者问题。在之前的讨论中,在每一步训练结束后,训练服务器都需要计算平均梯度来对每一个模型副本进行更新,从而保证下一步训练开始前,全部模型副本的参数的一致性,这种对于参数一致性的确保一般被称为同步训练(Synchronous training)。同步训练一般会有助于训练系统达到更好的模型精度,但是当系统规模变大,我们往往会在系统中引入掉队者(Straggler)。掉队者出现的原因很多。常见的原因包括:掉队者设备可能和其他设备不在同一个机柜中,因此掉队者的通讯带宽显著小于其他设备。另外,掉队者设备也可能和其他进程共享本地的服务器计算和通讯资源,形成资源竞争,从而降低了性能。
|
||||
|
||||
掉队者对于基于Allreduce的同步训练系统的性能有显著影响,这是因为Allreduce让全部节点参与到平均梯度的计算和通讯中,而每个节点负责等量的数据。因此任何一个掉队者的出现,都会让整个Allreduce操作延迟完成。为了解决这个问题,人们也会使用参数服务器来计算平均梯度。一种常见的设计是:训练服务器训练出本地梯度后,会把本地梯度全部推送到参数服务器。参数服务器在等到一定数据训练服务器(例如说90%的训练服务器)的本地梯度后,就开始计算平均梯度。这样可以确保平均梯度的计算不会被落后者的出现延误。计算好的平均梯度马上推送给全部训练服务器,开始下一轮训练。
|
||||
|
||||
解决掉队者的另外一种常见做法是利用参数服务器实现**异步训练**(Asynchronous training)。在一个异步训练系统中,每个训练服务器在训练开始时,有相同的模型参数副本。在训练中,他们计算出本地梯度后会马上将本地梯度推送到参数服务器,参数服务器将推送的梯度立刻用于更新参数,并把更新好的参数马上推送回对应的训练服务器。在这个过程中,不同的训练服务器很可能会使用不同版本的模型参数进行本地梯度的计算,这种做法有可能会伤害模型的精度,但它同时让不同训练服务器可以按照各自的运算速度来推送和拉取参数,而无需等待同伴,因此避免了掉队者对于整个集群性能的影响。
|
||||
14
website/chapter_distributed_training/pipeline.md
Normal file
14
website/chapter_distributed_training/pipeline.md
Normal file
@@ -0,0 +1,14 @@
|
||||
## 流水线并行
|
||||
|
||||
在数据并行和模型并行以外,流水线并行是另一种常用的并行加速方法。流水线并行往往被应用在大型模型并行系统中。这种系统通过算子内并行和算子间并行解决单设备内存不足的问题。然而,当这类系统的运行中,计算图中的下游设备需要长期持续处于空闲状态,等待上游设备的计算完成,才可以开始计算,这极大降低了设备的平均使用率。这种现象被称为模型并行空洞(Model Parallelism Bubble)。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`ch10-pipeline-parallel`
|
||||
|
||||
为了减少空洞,提升设备使用率,我们可以在模型并行系统中构建流水线。这种做法的核心想法是将一个数据小批量(Data Mini-batch)划分为多个微批量(Micro-batch)。假设一个数据小批量有$D$个训练数据,这个小批量可以被划分为$M$个微批量,那么微批量的大小就是$D/M$。每个微批量相应进入训练系统,完成前向传播(Forwards propagation)和反向传播(Backwards propagation),计算出梯度。每个微批量对应的梯度将会缓存,等到全部微批量完成,缓存的梯度会被加和,算出平均梯度,更新模型参数。
|
||||
|
||||
:numref:`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)。当设备完成前向传播后,必须等到全部反向传播开始,在此期间设备会处于空闲状态。在 :numref:`ch10-pipeline-parallel` 中,我们可以看到设备1在完成2个前向传播任务后,要等很多时间才能开始2个反向传播任务。这其中的等待时间即被称为泡沫。为了减少设备的等待时间,一种常见的做法是尽可能的增加微批量的数量,从而让反向传播尽可能早的开始。然而,使用非常小的微批量大小,可能会造成加速器无法被充分利用。因此最优的微批量大小是多种因素的折中。其中最核心的因素是流水线泡沫的大小和加速器的计算能力。
|
||||
|
||||
52
website/chapter_distributed_training/summary.md
Normal file
52
website/chapter_distributed_training/summary.md
Normal file
@@ -0,0 +1,52 @@
|
||||
## 总结
|
||||
|
||||
- 大型机器学习模型的出现带来了对于算力和内存需求的快速增长,催生了分布式训练系统的出现。
|
||||
|
||||
- 分布式训练系统的设计往往遵循"分而治之"的设计思路。
|
||||
|
||||
- 利用分布式训练系统,人们可以显著提升性能,经济性,并且帮助抵御硬件故障。
|
||||
|
||||
- 分布式训练系统可以通过数据并行增加设备来提升算力。
|
||||
|
||||
- 当单节点内存不足时,我们可以通过模型并行来解决单设备内存不足。模型并行有两种实现方式:算子内并行和算子间并行。
|
||||
|
||||
- 大型模型并行系统容易出现设备使用空洞,而这种空洞可以通过流水线并行解决。
|
||||
|
||||
- 分布式训练系统往往运行在商用数据中心之中,数据中心网络无法提供充足的网络带宽来传输大量训练中生成的梯度。
|
||||
|
||||
- 为了提供海量的带宽,机器学习集群拥有异构的网络:以太网,机内网络(NVLink)和InfiniBand。
|
||||
|
||||
- 为了解决单节点瓶颈,我们可以使用Allreduce来分摊梯度聚合过程中的计算和通讯开销。
|
||||
|
||||
- 参数服务器可以帮助机器学习集群实现计算-存储的分离,从而更好的支持大型稀疏模型。
|
||||
|
||||
- 参数服务器常用数据副本技术解决数据热点问题,同时它们也可以被用来解决同步训练系统中常见的掉队者问题。
|
||||
|
||||
|
||||
## 扩展阅读
|
||||
|
||||
- 分布式机器学习系统:[综述](https://dl.acm.org/doi/abs/10.1145/3377454)
|
||||
|
||||
- 利用集合通信支持并行训练的实践:[Horovod](https://arxiv.org/abs/1802.05799)
|
||||
|
||||
- AllReduce的工程实现细节:[树形结构](https://developer.nvidia.com/blog/massively-scale-deep-learning-training-nccl-2-4/),[环形结构](https://github.com/baidu-research/baidu-allreduce),[二维环面结构](https://arxiv.org/abs/1811.05233),以及[CollNet算法](https://github.com/NVIDIA/nccl/issues/320)
|
||||
|
||||
- 流水线并行的实践:[gPipe](https://arxiv.org/abs/1811.06965)
|
||||
|
||||
- 在大规模数据并行下的实践:[Accurate, Large Minibatch SGD: Training ImageNet in 1 Hour](https://arxiv.org/abs/1706.02677)
|
||||
|
||||
- 模型并行在超大模型上的实践:[ZeRO](https://arxiv.org/abs/1910.02054)
|
||||
|
||||
- 最后,在讨论集合通信时,经常可以看到一些关于底层通信接口的专业术语,例如以太网,Infiniband 等。这里给出一些常见术语的具体定义:
|
||||
|
||||
* [以太网(Ethernet)](https://web.archive.org/web/20181222184046/http://www.mef.net/Assets/White_Papers/Metro-Ethernet-Services.pdf)
|
||||
* [NVLink](https://devblogs.nvidia.com/parallelforall/how-nvlink-will-enable-faster-easier-multi-gpu-computing/)
|
||||
* [AWS Elastic Fabric Adapter (EFA)](https://aws.amazon.com/cn/hpc/efa/)
|
||||
* [Infiniband](https://www.infinibandta.org/about-infiniband/)
|
||||
* [RDMA](http://reports.ias.ac.in/report/12829/understanding-the-concepts-and-mechanisms-of-rdma)
|
||||
* [RoCE](https://www.roceinitiative.org/about-overview/)
|
||||
* [IPoIB](https://www.ibm.com/docs/en/aix/7.2?topic=protocol-internet-over-infiniband-ipoib)
|
||||
|
||||
## 参考文献
|
||||
|
||||
:bibliography:`../references/distributed.bib`
|
||||
246
website/chapter_explainable_AI/explainable_ai.md
Normal file
246
website/chapter_explainable_AI/explainable_ai.md
Normal file
@@ -0,0 +1,246 @@
|
||||
|
||||
## 背景
|
||||
|
||||
在人类历史上,技术进步、生产关系逻辑和伦理法规的发展是动态演进的。当一种新的技术在实验室获得突破后,其引发的价值产生方式的变化会依次对商品形态、生产关系等带来冲击。而同时当新技术带来的价值提升得到认可后,商业逻辑的组织形态在自发的调整过程中,也会对技术发展的路径、内容甚至速度提出诉求,并当诉求得到满足时适配以新型的伦理法规。在这样的相互作用中,技术系统与社会体系会共振完成演进,是谓技术革命。
|
||||
|
||||
近10年来,籍由算力与数据规模的性价比突破临界点,以深度神经网络为代表的联结主义模型架构及统计学习范式(以后简称深度学习)在特征表征能力上取得了跨越级别的突破,大大推动了人工智能的发展,在很多场景中达到令人难以置信的效果。比如:人脸识别准确率达到97%以上;谷歌智能语音助手回答正确率,在2019年的测试中达到92.9%。在这些典型场景下,深度学习在智能表现上的性能已经超过了普通人类(甚至专家),从而到了撬动技术更替的临界点。在过去几年间,在某些商业逻辑对技术友好,或者伦理法规暂时稀缺的领域,如安防、实时调度、流程优化、竞技博弈、信息流分发等,人工智能和深度学习取得了技术和商业上快速突破。
|
||||
|
||||
食髓知味,技术发展的甜头自然每个领域都不愿放过。而当对深度学习商业化运用来到某些对技术敏感、与人的生存或安全关系紧密的领域,如自动驾驶、金融、医疗和司法等高风险应用场景时,原有的商业逻辑在进行技术更替的过程中就会遇到阻力,从而导致商业化变现速度的减缓甚至失败。究其原因,以上场景的商业逻辑及背后伦理法规的中枢之一是稳定的、可追踪的责任明晰与责任分发;而深度学习得到的模型是个黑盒,我们无法从模型的结构或权重中获取模型行为的任何信息,从而使这些场景下责任追踪和分发的中枢无法复用,导致人工智能在业务应用中遇到技术上和结构上的困难。
|
||||
|
||||
举2个具体的例子:例1,在金融风控场景,通过深度学习模型识别出来小部分用户有欺诈嫌疑,但是业务部门不敢直接使用这个结果进行处理。因为人们难以理解结果是如何得到的,从而无法判断结果是否准确。而且该结果缺乏明确的依据,如果处理了,也无法向监管机构交代;
|
||||
例2,在医疗领域,深度学习模型根据患者的检测数据,判断患者有肺结核,但是医生不知道诊断结果是怎么来的,不敢直接采用,而是根据自己的经验,仔细查看相关检测数据,然后给出自己的判断。从这2个例子可以看出,黑盒模型严重影响模型在实际场景的应用和推广。
|
||||
|
||||
此外,模型的可解释性问题也引起了国家层面的关注,相关机构对此推出了相关的政策和法规。
|
||||
|
||||
- 2017年7月,国务院印发《新一代人工智能发展规划》,首次涵盖可解释AI。
|
||||
|
||||
- 2021年3月,中国人民银行发布金融行业标准《人工智能算法金融应用评价规范》,对金融行业AI模型可解释性提出了明确要求。
|
||||
|
||||
- 2021年8月,网信办《互联网信息服务算法推荐管理规定》,
|
||||
提出对互联网行业算法推荐可解释性的要求。
|
||||
|
||||
- 2021年9月,科技部发布《新一代人工智能伦理规范》。
|
||||
|
||||
因此,从商业推广层面以及从法规层面,我们都需要打开黑盒模型,对模型进行解释,可解释AI正是解决该类问题的技术。
|
||||
|
||||
## 可解释AI定义
|
||||
|
||||
按DARPA(美国国防部先进研究项目局)的描述,如 :numref:`xai_concept`所示,
|
||||
可解释AI的概念在于:区别于现有的AI系统,可解释AI系统可以解决用户面对黑盒模型时遇到的问题,使得用户知其然并知其所以然。
|
||||
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`xai_concept`
|
||||
|
||||
然而,不论是学术界还是工业界,对于可解释AI (eXplainable AI(XAI))都没有一个统一的定义。这里列举3种典型定义,供大家参考讨论:
|
||||
|
||||
- 可解释性就是希望寻求对模型工作机理的直接理解,打破人工智能的黑盒子。
|
||||
|
||||
- 可解释AI是为AI算法所做出的决策提供人类可读的以及可理解的解释。
|
||||
|
||||
- 可解释AI是确保人类可以轻松理解和信任人工智能代理做出的决策的一组方法。
|
||||
|
||||
|
||||
我们根据自身的实践经验和理解,将可解释AI定义为:一套面向机器学习(主要是深度神经网络)的技术合集,包括可视化、数据挖掘、逻辑推理、知识图谱等,目的是通过此技术合集,使深度神经网络呈现一定的可理解性,以满足相关使用者对模型及应用服务产生的信息诉求(如因果或背景信息),从而为使用者对人工智能服务建立认知层面的信任。
|
||||
|
||||
## 可解释AI算法现状介绍
|
||||
|
||||
随着可解释AI概念的提出,可解释AI越来越受到学术界及工业界的关注,下图展示了人工智能领域顶级学术会议中可解释AI关键字的趋势。为了让读者更好的对现有可解释AI算法有一个整体认知,我们这里参考 :cite:`2020tkde_li`总结归纳了可解释AI的算法类型,如 :numref:`XAI_methods`所示。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`XAI_methods`
|
||||
|
||||
对模型进行解释有多种多样的方法,这里依据解释过程是否引入数据集以外的外部知识,将其分为数据驱动的解释方法和知识感知的解释方法。
|
||||
|
||||
**数据驱动的解释**
|
||||
|
||||
数据驱动的解释是指纯粹从数据本身生成解释的方法,而不需要先验知识等外部信息。为了提供解释,数据驱动的方法通常从选择数据集(具有全局或局部分布)开始。然后,将选定的数据集或其变体输入到黑盒模型(在某些情况下,选取数据集不是所必需的。例如, :cite:`erhan2009visualizing`提出的最大激活值方法),通过对黑盒模型的相应预测进行一定的分析(例如,对预测w.r.t.输入特征进行求导)来生成解释。根据可解释性的范围,这些方法可以进一步分为全局方法或局部方法,即它们是解释所有数据点的全局模型行为还是预测子集行为。特别地,基于实例的方法提供了一种特殊类型的解释–它们直接返回数据实例作为解释。虽然从解释范围的分类来看,基于实例的方法也可以适合全局方法(代表性样本)或局部方法(反事实),但我们单独列出它们,以强调它们提供解释的特殊方式。
|
||||
|
||||
全局方法旨在提供对模型逻辑的理解以及所有预测的完整推理,基于对其特征、学习到的组件和结构的整体视图等等。有几个方向可以探索全局可解释性。为了便于理解,我们将它们分为以下三个子类:
|
||||
(i)
|
||||
模型提取——从原始黑盒模型中提取出一个可解释的模型,比如通过模型蒸馏的方式将原有黑盒模型蒸馏到可解释的决策树 :cite:`frosst2017distilling` :cite:`zhang2019interpreting`,从而使用决策树中的规则解释该原始模型;
|
||||
(ii)
|
||||
基于特征的方法——估计特征的重要性或相关性,如 :numref:`xai_global_feature_importance`所示,
|
||||
该类型解释可提供如“信用逾期记录是模型依赖的最重要特征”的解释,从而协助判定模型是否存在偏见. 一种典型的全局特征解释方法是SHAP(其仅能针对树模型输出全局解释):cite:`lundberg2017unified`。
|
||||
(iii) 透明模型设计——修改或重新设计黑盒模型以提高其可解释性。这类方法目前也逐渐成为探索热点,近期的相关工作包括ProtoPNet :cite:`chen2019looks`, Interpretable CNN :cite:`zhang2018interpretable`, ProtoTree :cite:`nauta2021neural`等。
|
||||
|
||||

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

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

|
||||
:width:`800px`
|
||||
:label:`xai_gradient_based`
|
||||
|
||||
**知识感知的解释**
|
||||
|
||||
数据驱动的解释方法能够从数据集或输入和输出之间的关系提供全面的解释。在此基础上,还可以利用外部知识来丰富解释并使其更加人性化。没有机器学习背景知识的门外汉可能很难直接理解特征的重要性,以及特征和目标之间的联系。借助外部领域知识,我们不仅可以生成表明特征重要性的解释,还可以描述某些特征比其他特征更重要的原因。因此,在过去几年中,基于知识感知的可解释AI方法引起了越来越多的关注。与从多种情景中收集的原始数据集相比,知识通常被视为人类根据生活经验或严格的理论推理得出的实体或关系。一般来说,知识可以有多种形式。它可以保留在人的头脑中,也可以用自然语言、音频或规则记录,具有严格的逻辑。为了对这些方法进行系统回顾,我们在此根据知识来源将它们分为两类:通用知识方法和知识库(KB)方法。前者以非结构化数据为知识源来构建解释,后者以结构化知识库为基础来构建解释。
|
||||
|
||||
提供知识的一个相对直接的方法是通过人类的参与。事实上,随着人工智能研究和应用的爆炸式增长,人类在人工智能系统中的关键作用已经慢慢显现。这样的系统被称为以人为中心的人工智能系统。 :cite:`riedl2019human`认为,以人为中心的人工智能不仅能让人工智能系统从社会文化的角度更好地了解人类,还能让人工智能系统帮助人类了解自己。为了实现这些目标,人工智能需要满足可解释性和透明度等几个属性。
|
||||
|
||||
|
||||
具体来说,人类能够通过提供相当多的人类定义的概念来在人工智能系统中发挥作用。 :cite:`kim2018interpretability`利用概念激活向量(CAV)来测试概念在分类任务中的重要性(TCAV)。CAV是与感兴趣目标概念的激活与否决策边界垂直的矢量,该矢量可以这样获取: 输入目标概念的正负样本, 进行线性回归, 得到决策边界, 从而得到CAV。以“斑马”的“条纹”概念为例,用户首先收集包含有“条纹”的数据样本及不含“条纹”的数据样本,输入到网络中,获取中间层的激活值,基于正负样本的标签($1$代表含有概念,$0$代表不含概念)对中间层激活值进行拟合,获取决策边界,CAV即为该决策边界的垂直向量。
|
||||
|
||||
|
||||
如 :numref:`xai_tcav`所示,为了计算TCAV评分,代表第$l$层概念对类$k$预测的重要性的“概念敏感度”可以首先计算为方向导数$S_{C,k,l}(\mathbf{x})$:
|
||||
$$\begin{split}
|
||||
S_{C,k,l}(\mathbf{x}) = &\lim_{\epsilon\rightarrow 0}\frac{h_{l,k}(f_{l}(\mathbf{x})+\epsilon \mathbf{v}^{l}_{C})-h_{l,k}(f_{l}(\mathbf{x}))}{\epsilon} \\ = &\nabla h_{l,k}(f_{l}(\mathbf{x})) \cdot \mathbf{v}^{l}_{C}
|
||||
\end{split}
|
||||
\label{eq:TCAV_score}$$
|
||||
其中$f_{l}(\mathbf{x})$是在第$l$、$h_{l,k}(\cdot)$是类$k$的logit,$\nabla h_{l,k}(\cdot)$是$h_{l,k}$
|
||||
w.r.t层$l$的激活的梯度。$\mathbf{v}^{l}_{C}$是用户旨在探索的概念$C$的CAV。正(或负)敏感性表明概念$C$对输入的激活有正(或负)影响。
|
||||
|
||||
基于$S_{C,k,l}$,
|
||||
TCAV就可以通过计算类$k$的具有正$S_{C,k,l}$’s的样本的比率来获得:
|
||||
|
||||
$$\textbf{TCAV}_{Q_{C,k,l}}=\frac{\vert \{\mathbf{x}\in X_{k}:S_{C,k,l}(\mathbf{x})>0\}\vert}{\vert X_{k}\vert}
|
||||
\label{eq:TCAV}$$
|
||||
结合$t$-分布假设方法,如果$\textbf{TCAV}_{Q_{C,k,l}}$大于0.5,则表明概念$C$对类$k$有重大影响。
|
||||
|
||||

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

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

|
||||
:width:`800px`
|
||||
:label:`xai_kg_recommendation`
|
||||
|
||||
在 :numref:`xai_kg_recommendation`中,有两个突出显示的相关概率(38.6%, 21.1%),它们是在推荐过程中模型计算的关键路径被激活的概率。红色箭头突出显示从“Team Fortress 2”到历史项目“Half-Life”之间的关键路径。它表明TB-Net能够通过各种关系连接向用户推荐物品,并找出关键路径作为解释。因此,将“Team Fortress 2”推荐给用户的解释可以翻译成固定话术:“Team Fortress 2”是游戏公司“Valve”开发的一款动作类、多人在线、射击类电子游戏。这与用户历史玩过的游戏“Half-Life”有高度关联。
|
||||
|
||||
## 可解释AI系统及实践
|
||||
|
||||
随着各领域对可解释的诉求快速增长,越来越多企业集成可解释AI工具包,为广大用户提供快速便捷的可解释实践,业界现有的主流工具包有:
|
||||
- TensorFlow团队的What-if Tool,用户不需编写任何程序代码就能探索学习模型,让非开发人员也能参与模型调校工作。
|
||||
- IBM的AIX360,提供了多种的解释及度量方法去评估模型在各个不同维度上的可解释及可信性能。
|
||||
- Facebook Torch团队的captum,针对图像及文本场景,提供了多种主流解释方法。
|
||||
- 微软的InterpretML,用户可以训练不同的白盒模型及解释黑盒模型。
|
||||
- SeldonIO的Alibi,专注于查勘模型内部状况及决策解释,提供各种白盒、黑盒模型、单样本及全局解释方法的实现。
|
||||
- 华为MindSpore的XAI工具,提供数据工具、解释方法、白盒模型以及度量方法,为用户提供不同级别的解释(局部,全局,语义级别等)。
|
||||
|
||||
本节将以MindSpore XAI工具为例,讲解在实践中如何使用可解释AI工具为图片分类模型和表格数据分类模型提供解释,从而协助用户理解模型进行进一步的调试调优。
|
||||
MindSpore XAI工具的架构如下,其为基于MindSpore深度学习框架的一个可解释工具,可在Ascend及GPU设备上部署。
|
||||

|
||||
:width:`800px`
|
||||
:label:`mindspore_xai`
|
||||
|
||||
要使用MindSpore可解释AI,读者首先要通过pip安装MindSpore XAI包(支持MindSpore1.7 或以上,GPU及Ascend 处理器,推荐配合JupyterLab使用):
|
||||
|
||||
```bash
|
||||
pip install mindspore-xai
|
||||
```
|
||||
|
||||
在MindSpore XAI的[官网教程](https://www.mindspore.cn/xai/docs/zh-CN/r1.8/index.html)中,详细介绍了如何安装和使用提供的解释方法, 读者可自行查阅。
|
||||
|
||||
### MindSpore XAI工具为图片分类场景提供解释
|
||||
|
||||
下面结合MindSpore XAI1.8版本中已支持的显着图可视方法 GradCAM 作为一个代码演示例子。读者可参阅[官方教程](https://www.mindspore.cn/xai/docs/zh-CN/1.8/using_cv_explainers.html)以取得演示用的数据集, 模型和完整脚本代码。
|
||||
|
||||
```python
|
||||
|
||||
from mindspore_xai.explainer import GradCAM
|
||||
|
||||
# 通常指定最后一层的卷积层
|
||||
grad_cam = GradCAM(net, layer="layer4")
|
||||
|
||||
# 3 是'boat'类的ID
|
||||
saliency = grad_cam(boat_image, targets=3)
|
||||
```
|
||||
|
||||
如果输入的是一个维度为 $1*3*224*224$ 的图片Tensor,那返回的saliency就是一个 $1*1*224*224$ 的显著图Tensor。下面我们将几个例子展示如何使用可解释AI能力来更好理解图片分类模型的预测结果,获取作为分类预测依据的关键特征区域,从而判断得到分类结果的合理性和正确性,加速模型调优。
|
||||
|
||||
|
||||

|
||||
:width:`400px`
|
||||
:label:`correct_correct`
|
||||
|
||||
上图预测标签是“bicycle”,解释结果给出依据的关键特征 在车轮上,说明这个分类判断依据是合理的, 可以初步判定模型为可信的。
|
||||
|
||||

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

|
||||
:width:`400px`
|
||||
:label:`wrong_wrong`
|
||||
|
||||
在上图中,预测标签为“boat”,但是原始图像中并没有船只存在,通过图中右侧解释结果可以看到模型将水面作为分类的关键依据,得到预测结果“boat”,这个依据是错误的。通过对训练数据集中标签为“boat”的数据子集进行分析,发现绝大部分标签为“boat”的图片中,都有水面,这很可能导致模型训练的时候,误将水面作为“boat”类型的关键依据。基于此,按比例补充有船没有水面的图片集,从而大幅消减模型学习的时候误判关键特征的概率。
|
||||
|
||||
### MindSpore XAI工具为表格分类场景提供解释
|
||||
MindSpore XAI 1.8版本支持了三个业界比较常见的表格数据模型解释方法:LIMETabular、SHAPKernel和SHAPGradient。
|
||||
|
||||
以LIMETabular为例针对一个复杂难解释的模型,提供一个局部可解释的模型来对单个样本进行解释:
|
||||
```python
|
||||
from mindspore_xai.explainer import LIMETabular
|
||||
|
||||
# 将特征转换为特征统计数据
|
||||
feature_stats = LIMETabular.to_feat_stats(data, feature_names=feature_names)
|
||||
|
||||
# 初始化解释器
|
||||
lime = LIMETabular(net, feature_stats, feature_names=feature_names, class_names=class_names)
|
||||
|
||||
# 解释
|
||||
lime_outputs = lime(inputs, targets, show=True)
|
||||
```
|
||||
|
||||
解释器会显示出把该样本分类为setosa这一决定的决策边界,返回的 lime_outputs 是代表决策边界的一个结构数据。
|
||||
可视化解释,可得到
|
||||

|
||||
:width:`400px`
|
||||
:label:`tabular_lime`
|
||||
上述解释说明针对setosa这一决策,最为重要的特征为petal length。
|
||||
|
||||
### MindSpore XAI工具提供白盒模型
|
||||
|
||||
除了针对黑盒模型的事后解释方法,XAI工具同样提供业界领先的白盒模型,使得用户可基于这些白盒模型进行训练,在推理过程中模型可同时输出推理结果及解释结果。以TB-Net为例(可参考:numref:`tb_net`及其[官网教程](https://e.gitee.com/mind_spore/repos/mindspore/xai/tree/master/models/whitebox/tbnet)进行使用),该方法已上线商用,为百万级客户提供带有语义级解释的理财产品推荐服务。TB-Net利用知识图谱对理财产品的属性和客户的历史数据进行建模。在图谱中,具有共同属性值的理财产品会被连接起来,待推荐产品与客户的历史购买或浏览的产品会通过共同的属性值连接成路径,构成该客户的子图谱。然后,TB-Net对图谱中的路径进行双向传导计算,从而识别关键产品和关键路径,作为推荐和解释的依据。
|
||||
|
||||
|
||||
一个可解释推荐的例子如下:在历史数据中,该客户近期曾购买或浏览了理财产品A、B和N等等。通过TB-Net的路径双向传导计算可知,路径(产品P,年化利率_中等偏高,产品A)和路径(产品P,风险等级_中等风险,产品N)的权重较高,即为关键路径。此时,TB-Net输出的解释为:“推荐理财产品P给该客户,是因为它的年化利率_中等偏高,风险等级_中等风险,分别与该客户近期购买或浏览的理财产品A和B一致。”
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`tbnet_finance`
|
||||
|
||||
除了上面介绍的解释方法外,MindSpore XAI还会提供一系列的度量方法用以评估不同解释方法的优劣,另外也会陆续增加自带解释的白盒模型,用户可直接取用成熟的模型架构以快速构建自己的可解释AI系统。
|
||||
|
||||
|
||||
## 未来可解释AI
|
||||
|
||||
为了进一步推动可解释AI的研究,我们在此总结了一些值得注意的研究方向。
|
||||
|
||||
首先,知识感知型XAI仍有很大的研究扩展空间。然而,要有效地利用外部知识,仍有许多悬而未决的问题。其中一个问题是如何在如此广阔的知识空间中获取或检索有用的知识。例如, 维基百科上记载了各式各样各领域相关的知识, 但如果要解决医学图像分类问题, 维基百科上大部分词条都是无关或存在噪音的, 这样便很难准确地寻找到合适的知识引入到XAI系统中。
|
||||
|
||||
此外,XAI系统的部署也非常需要一个更加标准和更加统一的评估框架。为了构建标准统一的评估框架,我们可能需要同时利用不同的指标,相互补充。不同的指标可能适用于不同的任务和用户。统一的评价框架应具有相应的灵活性。
|
||||
|
||||
最后,我们相信跨学科合作将是有益的。XAI的发展不仅需要计算机科学家来开发先进的算法,还需要物理学家、生物学家和认知科学家来揭开人类认知的奥秘,以及特定领域的专家来贡献他们的领域知识。
|
||||
|
||||
## 参考文献
|
||||
|
||||
:bibliography:`../references/explainable.bib`
|
||||
21
website/chapter_explainable_AI/index.md
Normal file
21
website/chapter_explainable_AI/index.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# 可解释性AI系统
|
||||
|
||||
近10年来,籍由算力与数据规模的性价比突破临界点,以深度神经网络为代表的联结主义模型架构及统计学习范式(以后简称深度学习)在特征表征能力上取得了跨越级别的突破,大大推动了人工智能的发展,在很多场景中达到令人难以置信的效果。比如:人脸识别准确率达到97%以上;谷歌智能语音助手回答正确率,在2019年的测试中达到92.9%。在这些典型场景下,深度学习在智能表现上的性能已经超过了普通人类(甚至专家),从而到了撬动技术更替的临界点。在过去几年间,在某些商业逻辑对技术友好,或者伦理法规暂时稀缺的领域,如安防、实时调度、流程优化、竞技博弈、信息流分发等,人工智能和深度学习取得了技术和商业上快速突破。
|
||||
|
||||
食髓知味,技术发展的甜头自然每个领域都不愿放过。而当对深度学习的商业化运用来到某些对技术敏感、与人的生存或安全关系紧密的领域,如自动驾驶、金融、医疗和司法等高风险应用场景时,原有的商业逻辑在进行技术更替的过程中就会遇到阻力,从而导致商业化变现速度的减缓甚至失败。究其原因,以上场景的商业逻辑及背后伦理法规的中枢之一是稳定的、可追踪的责任明晰与责任分发;而深度学习得到的模型是个黑盒,我们无法从模型的结构或权重中获取模型行为的任何信息,从而使这些场景下责任追踪和分发的中枢无法复用,导致人工智能在业务应用中遇到技术上和结构上的困难。此外,模型的可解释性问题也引起了国家层面的关注,相关机构对此推出了相关的政策和法规。
|
||||
|
||||
因此,从商业推广层面以及从法规层面,我们都需要打开黑盒模型,对模型进行解释,可解释AI正是解决该类问题的技术。
|
||||
|
||||
本章的学习目标包括:
|
||||
|
||||
- 掌握可解释AI的目标和应用场景
|
||||
|
||||
- 掌握常见的可解释AI方法类型及其对应的典型方法
|
||||
|
||||
- 思考可解释AI方法的未来发展
|
||||
|
||||
```toc
|
||||
:maxdepth: 2
|
||||
|
||||
explainable_ai
|
||||
```
|
||||
57
website/chapter_federated_learning/horizontal_fl.md
Normal file
57
website/chapter_federated_learning/horizontal_fl.md
Normal file
@@ -0,0 +1,57 @@
|
||||
## 横向联邦学习
|
||||
|
||||
### 云云场景中的横向联邦
|
||||
|
||||
在横向联邦学习系统中,具有相同数据结构的多个参与者通过参数或云服务器协同建立机器学习模型。一个典型的假设是参与者是诚实的,而服务器是诚实但好奇的,因此不允许任何参与者向服务器泄漏信息。这种系统的训练过程通常包括以下四个步骤:
|
||||
|
||||
①:参与者在本地计算训练梯度,使用加密、差异隐私或秘密共享技术掩饰所选梯度,并将掩码后的结果发送到服务器;
|
||||
|
||||
②:服务器执行安全聚合,不了解任何参与者的信息;
|
||||
|
||||
③:服务器将汇总后的结果发送给参与者;
|
||||
|
||||
④:参与者用解密的梯度更新他们各自的模型。
|
||||
|
||||
和传统分布式学习相比,联邦学习存在训练结点不稳定和通信代价大的难点。这些难点导致了联邦学习无法和传统分布式学习一样:在每次单步训练之后,同步不同训练结点上的权重。为了提高计算通信比并降低频繁通信带来的高能耗,谷歌公司在2017年 :cite:`fedavg`提出了联邦平均算法(Federated Averaging,FedAvg)。 :numfef:`ch10-federated-learning-fedavg`展示了FedAvg的整体流程。在每轮联邦训练过程中,端侧进行多次单步训练。然后云侧聚合多个端侧权重,并取加权平均。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`ch10-federated-learning-fedavg`
|
||||
|
||||
随着研究和应用的深入,研究者们意识到了FedAvg不适用于某些场景。比如在数据异质性(端上数据不是I.I.D分布)、系统异质性(端设备时断时连)的情况下,对模型参数只进行简单的加权平均会在训练过程中引入大量偏差,从而影响模型的收敛速度、预测性能。针对此,研究人员提出了基于Momentum :cite:`FedAvg_Momentum`、Variation Control :cite:`scaffold`、Bayesian :cite:`FedBE`、Distillation :cite:`PATE`和Proximal Estimation :cite:`FedProx`等原理开发的算法和系统框架。
|
||||
|
||||
### 端云场景中的横向联邦
|
||||
|
||||
端云联邦的总体流程和云云联邦一样,但端云联邦学习面临的难点还包括以下三个方面:
|
||||
|
||||
1.高昂的通信代价。在联邦学习问题中,原始数据保存在远程客户端设备本地,必须与中央服务器不断交互才能完成全局模型的构建。通常的通信网络可能是WLAN或移动数据,网络通信速度可能比本地计算慢许多个数量级,这就造成高昂的通信代价成为了联邦学习的关键瓶颈。
|
||||
|
||||
2.系统异质性。由于客户端设备硬件条件(CPU、内存)、网络连接(3G、4G、5G、WIFI)和电源(电池电量)的变化,联邦学习网络中每个设备的存储、计算和通信能力都有可能不同。网络和设备本身的限制可能导致某一时间仅有一部分设备处于活动状态。此外,设备还会出现没电、网络无法接入等突发状况,导致瞬时无法连通。这种异质性的系统架构影响了联邦学习整体策略的制定。
|
||||
|
||||
3.隐私问题。联邦学习共享客户端设备中的模型参数更新(例如梯度信息)而不是原始数据,因此在数据隐私保护方面优于其他的分布式学习方法。然而,在训练过程中传递模型的更新信息仍然存在向第三方或中央服务器暴露敏感信息的风险。隐私保护成为联邦学习需要重点考虑的问题。
|
||||
|
||||
为了解决端云联邦学习带来的挑战,MindSpore Federated Learning设计了分布式FL-Server架构。系统由调度器模块、服务器模块和客户端模块三个部分组成,其系统架构如 :numref:`ch10-federated-learning-architecture`所示。其中:
|
||||
|
||||
- 联邦学习调度器:
|
||||
|
||||
联邦学习调度器(FL-Scheduler)协助集群组网,并负责管理面任务的下发。
|
||||
|
||||
- 联邦学习服务器:
|
||||
|
||||
联邦学习服务器(FL-Server)提供客户端选择、限时通信、分布式联邦聚合功能。FL-Server需要具备支持端云千万台设备的能力以及边缘服务器的接入和安全处理的逻辑。
|
||||
|
||||
- 联邦学习客户端:
|
||||
|
||||
联邦学习客户端(FL-Client)负责本地数据训练,并在和FL-Server进行通信时,对上传权重进行安全加密。
|
||||
|
||||

|
||||
|
||||
:label:`ch10-federated-learning-architecture`
|
||||
|
||||
此外,MindSpore Federated针对端云联邦学习设计了出三大特性:
|
||||
|
||||
1.限时通信:在FL-Server和FL-Client建立连接后,启动全局的计时器和计数器。当预先设定的时间窗口内的FL-Server接收到FL-Client训练后的模型参数满足初始接入的所有FL-Client的一定比例后,就可以进行聚合。若时间窗内没有达到比例阈值,则进入下一轮迭代。保证即使有海量FL-Client接入的情况下,也不会由于个别FL-Client训练时间过长或掉线导致的整个联邦学习过程卡死。
|
||||
|
||||
2.松耦合组网:使用FL-Server集群。每个FL-Server接收和下发权重给部分FL-Client,减少单个FL-Server的带宽压力。此外,支持FL-Client以松散的方式接入。任意FL-Client的中途退出都不会影响全局任务,并且FL-Client在任意时刻访问任意FL-Server都能获得训练所需的全量数据。
|
||||
|
||||
3.加密模块:MindSpore Federated为了防止模型梯度的泄露,部署了多种加密算法:本地差分隐私(LDP)、基于多方安全计算(MPC)的安全聚合算法和华为自研的基于符号的维度选择差分隐私算法(SignDS)。
|
||||
20
website/chapter_federated_learning/index.md
Normal file
20
website/chapter_federated_learning/index.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# 联邦学习系统
|
||||
|
||||
在本章中,我们介绍深度学习的一个重要分支——联邦学习及其在系统方面的知识。本章的学习目标包括:
|
||||
|
||||
- 掌握联邦学习基本定义,并了解现有主流联邦开源框架。
|
||||
- 了解横向联邦学习算法。
|
||||
- 了解纵向联邦学习算法。
|
||||
- 了解联邦学习加密算法。
|
||||
- 了解联邦学习前沿算法和未来研究方向。
|
||||
|
||||
```toc
|
||||
:maxdepth: 2
|
||||
|
||||
overview
|
||||
horizontal_fl
|
||||
vertical_fl
|
||||
privacy_encryption_algorithm
|
||||
outlook
|
||||
summary
|
||||
```
|
||||
36
website/chapter_federated_learning/outlook.md
Normal file
36
website/chapter_federated_learning/outlook.md
Normal file
@@ -0,0 +1,36 @@
|
||||
## 展望
|
||||
|
||||
为了实现联邦学习的大规模商用,我们仍然需要做许多的研究工作。比如我们无法查看联邦学习的分布式化的数据,那就很难选择模型的超参数以及设定优化器,只能采用一些基于模拟的方案来调测模型;比如用于移动设备时,单用户的标签数据很少,甚至无法获取数据的标签信息,联邦学习如何用于无监督学习;比如由于参与方的数据分布不一致,训练同一个全局模型,很难评价模型对于每个参与方的好坏;比如数据一直是公司的核心资产,不同的公司一直在致力于收集数据和创造数据孤岛,如何有效地激励公司或者机构参与联邦学习的系统中来。下面将介绍一些MindSpore Federated在进行的一些尝试和业界的相关工作。
|
||||
|
||||
**异构场景下的联邦学习**
|
||||
|
||||
之前探讨的横向联邦和纵向联邦学习都是让不同的参与方共同建立一个共享的机器学习模型。然而,企业级联邦学习框架往往需要适应多种异构场景,如数据异构(不同客户端数据规模以及分布不一致),设备异构(不同客户端设备计算能力,通信效率不一致),以及模型异构(不同本地客户端模型学到的特征不一致)。
|
||||
|
||||
比较主流的两种联邦异构场景下的工作:
|
||||
|
||||
1)对异构数据具有高度鲁棒性的本地模型个性化联邦学习策略:
|
||||
|
||||
联邦学习训练的是一个全局模型,基于所有数据得到一个全局最优解,但是不同参与方的数据量和分布都是不同的,很多场景下全局模型无法在把握整体的同时又照顾到这种差异。当某一方的数据和整体偏离比较大时,联邦学习的效果确实有可能不如本地训练的效果。那么如何在所有参与方总体的收益最大化的同时,让个体的收益也能够最大化,这就是个性化联邦学习。
|
||||
|
||||
个性化联邦学习并不要求所有参与方最终使用的模型必须是一样的,比如允许每个参与方在参与联邦之后,根据自己的数据对模型进行微调,从而生成本方独特的个性化模型。在进行个性化微调之后,往往模型在本地测试集上的效果会更好。在这种方式下,不同参与方的模型结构是一样的,但是模型参数会有所不同。还有一些方案,是让所有的参与方拥有同样的特征提取层,但是任务分类层不同。还有的思路是将知识蒸馏引入联邦学习中,将联邦学习的全局模型作为teacher,将个性化模型作为student,可以缓解个性化过程中的过拟合问题。
|
||||
|
||||
2)对于异构模型进行模型聚合的策略研究:
|
||||
|
||||
一般在FedAvg的联邦聚合范式下,本地迭代训练次数越少、聚合地越频繁,模型收敛精度会越好,尤其是在不同参与客户端的数据是非iid的情况下。但是聚合会带来通信成本开销,联邦学习存在通信成本与模型精度的Trade-off。因此很多研究者聚焦于如何设计自适应聚合方案,要求在给定训练时间开销的前提下,找到本地更新和全局通信之间的最佳平衡,令全局模型的泛化误差最小。
|
||||
|
||||
**通信效率提升**
|
||||
|
||||
端云联邦学习流程中,每一个全局训练轮次里,每个参与方都需要给服务器发送完整的模型参数更新,然后服务器将聚合后的模型参数下发。现代的深度学习网络动辄有数百万甚至更大量级的参数,如此多的参数量传输将会带来巨大的通信开销带来性能瓶颈(用户流量、通信时延、客户端参与数量)。为了降低通信开销,MindSpore Federated采取了一些改善通信效率的方法:
|
||||
|
||||
1)智能调频策略:通过改变全局模型聚合的轮次来提高联邦学习效率,减少训练任务达到收敛的通信开销。一种直觉是在模型训练初期,参数变化剧烈,因此设置较大的聚合频率,使得模型快速收敛;在模型训练中后期,参数趋于稳定,因此设置较小的聚合频率,减少通信成本。
|
||||
|
||||
2)通信压缩方案:对权重差进行量化以及稀疏化操作,即每次通信仅上传一小部分最重要参数的梯度,从而代替上传所有参数。之所以选择权重差做量化和稀疏,是因为它比权重值的分布更易拟合,而且稀疏性更高。量化就是将FP32的数据类型映射到INT8甚至更低比特表示的数值上,一方面降低存储和通信开销,另一方面可以更好地采用一些压缩编码方式进行传输(如哈夫曼编码、有限状态熵编码等)。比较常用的稀疏化方法有Top-k稀疏,即按梯度的绝对值从小到大排序,每轮只上传前k个参数,如何选取合适的\(k\)是一个有挑战性的问题。这个方案对于模型的联邦学习训练来说是有损的,在精度损失和通信压缩率之间也是trade-off。
|
||||
|
||||
**联邦生态**
|
||||
|
||||
在前面的章节中,我们介绍了面向隐私保护的联邦学习领域的一些技术与实践,然而随着探索地更加深入,联邦学习领域也变得更具包容性,它涵盖了分不是机器学习、模型压缩部署、信息安全、加密算法、博弈论等等。随着越来越多的公司、高校和机构参与进来,现在的联邦学习已经不仅仅是一种技术解决方案,还是一个隐私保护的生态系统,比如不同的参与方希望以可持续的方式加入联邦,如何设计激励机制,以确保利润可以相对公平地被联邦参与方共享,同时对于恶意的实施攻击或者破坏行为的参与方进行有效遏制。
|
||||
|
||||
另外,随着用户数据隐私保护和合理使用的法律法规越来越多的被推出,制定联邦学习的技术标准显得愈加重要,这一标准能够在法律监管部门和技术开发人员之间建立一座桥梁,让企业知道采用何种技术,能够在合乎法规的同时更好地进行信息的共享。
|
||||
|
||||
2020年底正式出版推行了由IEEE 标准委员会(SASB)通过的联邦学习国际标准(IEEE P3652.1),该标准旨在提供一个搭建联邦学习的体系架构和应用的指导方针,主要内容包括:联邦学习的描述和定义、场景需求分类和安全测评、联邦学习个性指标的评估如何量化、联合管控的需求。这也是国际上首个针对人工智能协同技术框架订立的标准,标志着联邦学习开启大规模工业化应用的新篇章。
|
||||
|
||||
47
website/chapter_federated_learning/overview.md
Normal file
47
website/chapter_federated_learning/overview.md
Normal file
@@ -0,0 +1,47 @@
|
||||
## 概述
|
||||
|
||||
随着人工智能的飞速发展,大规模和高质量的数据越来越重要,但也制约了其进一步的发展。隐私、监管和工程等问题造成了设备与设备之间的数据不能共享,进而导致了数据孤岛问题的出现。为了解决这一难题,联邦学习(Federated Learning,FL)应运而生。联邦学习的概念最早在2016年被提了出来,能有效帮助多个机构在满足用户隐私保护、数据安全和政府法规的要求下,进行数据使用和机器学习建模。
|
||||
|
||||
### 定义
|
||||
|
||||
联邦学习的核心即为数据不动,模型动。显然,若是将数据从各方集中在一起,无法保证对用户隐私的保护,不符合相关法律法规。联邦学习让模型在各个数据方“移动”,这样就可以达到数据不出端即可建模的效果。在联邦学习中,各方数据都保留在本地,通过(在中心服务器上)交换加密的参数建立机器学习模型。
|
||||
|
||||
### 应用场景
|
||||
|
||||
在实际的应用场景中,根据样本和特征的重叠情况,联邦学习可以被分为横向联邦学习(样本不同,特征重叠),纵向联邦学习(特征不同,样本重叠)和联邦迁移学习(样本和特征都不重叠)。
|
||||
|
||||
**横向联邦学习**适用于不同参与方拥有的特征相同、但参与的个体不同的场景。比如,在广告推荐场景中,算法开发人员使用不同手机用户的相同特征(点击次数、停留时间或使用频次等)的数据来建立模型;抑或者是在输入法中用来预测由于这些特征数据不能出端,因此,横向联邦学习被用来联合多用户的数据来构建模型。
|
||||
|
||||
**纵向联邦学习**适用于样本重叠多、特征重叠少的场景。比如,有两个不同机构,一家是保险公司,另一家是医院。它们的用户群体很有可能包含该地的大部分居民,用户的交集可能较大。由于保险公司记录的都是用户的收支行为与信用评级,而医院则保有用户的疾病与购药记录,因此它们的用户特征交集较小。纵向联邦学习就是将这些不同特征在加密的状态下加以聚合,以增强模型能力的联邦学习。
|
||||
|
||||
**联邦迁移学习**的核心是找到源领域和目标领域之间的相似性。比如有两个不同机构,一家是位于中国的银行,另一家是位于美国的电商。由于受到地域限制,这两家机构的用户群体交集很小。同时,由于机构类型的不同,二者的数据特征也只有小部分重合。在这种情况下,要想进行有效的联邦学习,就必须引入迁移学习,来解决单边数据规模小和标签样本少的问题,从而提升模型的效果。
|
||||
|
||||
### 部署场景
|
||||
|
||||
联邦学习和参数服务器(数据中心分布式学习)架构非常相似,都是采用中心化的服务器和分散的客户端去构建同一个机器学习模型。此外,根据客户端来源和规模的不同,联邦学习还可以细分为跨组织(cross-silo)与跨设备(cross-device)联邦学习。一般而言,跨组织联邦学习的用户一般是企业、机构单位级别的,而跨设备联邦学习针对的则是便携式电子设备、移动端设备等。 :numref:`ch10-federated-learning-different-connection`展示了三者的区别和联系:
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`ch10-federated-learning-different-connection`
|
||||
|
||||
### 常用框架
|
||||
|
||||
随着用户和开发人员对联邦学习技术的需求不断增长,联邦学习工具和框架的数量也越来越多。下面将介绍一些主流的联邦学习框架。
|
||||
|
||||
[TFF](https://www.tensorflow.org/federated) (TensorFlow Federated)是谷歌牵头开发的联邦学习开源框架,用于在分散数据上进行机器学习和其他计算。TFF的开发是为了促进联邦学习的开放研究和实验,这是一种机器学习的方法,在许多参与的客户中训练共享的全局模型,这些客户将其训练数据保存在本地。例如,联邦学习已被用于训练移动键盘的预测模型,而无需将敏感的键入数据上载到服务器。
|
||||
|
||||
[PaddleFL](https://paddlefl.readthedocs.io/en/latest/index.html)是百度提出的一个基于PaddlePaddle的开源联邦学习框架。研究人员可以很轻松地用PaddleFL复制和比较不同的联邦学习算法,开发人员也比较容易在大规模分布式集群中部署PaddleFL联邦学习系统。PaddleFL提供很多种联邦学习策略(横向联邦学习、纵向联邦学习)及其在计算机视觉、自然语言处理、推荐算法等领域的应用。此外,PaddleFL还将提供传统机器学习训练策略的应用,例如多任务学习、联邦学习环境下的迁移学习。依靠着PaddlePaddle的大规模分布式训练和Kubernetes对训练任务的弹性调度能力,PaddleFL可以基于全栈开源软件轻松地部署。
|
||||
|
||||
[FATE](https://fate.fedai.org) (Federated AI Technology Enabler)由微众银行提出,是全球首个联邦学习工业级开源框架,可以让企业和机构在保护数据安全和数据隐私的前提下进行数据协作。 FATE项目使用多方安全计算 (MPC) 以及同态加密 (HE) 技术构建底层安全计算协议,以此支持不同种类的机器学习的安全计算,包括逻辑回归、基于树的算法、深度学习和迁移学习等。 FATE于2019年2月首次对外开源,并成立 [FATE TSC](https://github.com/FederatedAI/FATE-Community/blob/master/FATE_Project_Technical_Charter.pdf) 对FATE社区进行开源治理,成员包含国内主要云计算和金融服务企业。
|
||||
|
||||
[FedML](https://FedML.ai)是一个USC牵头提出的联邦学习开源研究和基准库,它有助于开发新的联合学习算法和公平的性能比较。FedML支持三种计算范式(分布式训练、移动设备上训练和独立模拟),供用户在不同的系统环境中进行实验。FedML还通过灵活和通用的API设计和参考基线实现促进多样化的算法研究。为非I.I.D设置精心策划的全面基准数据集旨在进行公平比较。
|
||||
|
||||
[PySyft](https://openmined.github.io/PySyft/index.html)是UCL、DeepMind和OpenMined发布的安全和隐私深度学习Python库,包括联邦学习、差分隐私和多方学习。PySyft使用差分隐私和加密计算(如多方计算(MPC)和同态加密(HE))将私有数据与模型训练解耦流。
|
||||
|
||||
[Fedlearner](https://github.com/bytedance/fedlearner)是字节跳动提出的协作机器学习框架,它允许对分布在机构之间的数据进行联合建模。Fedlearner附带了用于群集管理、作业管理、作业监控和网络代理的周围基础架构。Fedlearner采用云原生部署方案。数据存放在HDFS。通过Kubernetes管理和拉起任务。每个Fedlearner的训练任务需要参与双方同时拉起K8S任务,通过Master节点统一管理,Worker建实现通信。
|
||||
|
||||
[OpenFL](https://openfl.readthedocs.io/en/latest/index.html)是英特尔提出的用于联邦学习的Python框架。OpenFL旨在成为数据科学家的灵活、可扩展和易于学习的工具。
|
||||
|
||||
[Flower](https://flower.dev)是剑桥大学、Adap等发布的联邦学习开源系统,主要针对在大规模、异质化设备上部署联邦学习算法的应用场景进行优化。
|
||||
|
||||
[MindSpore Fedrated](https://www.mindspore.cn/en) (昇思)是华为提出的一款开源联邦学习框架,支持千万级无状态终端设备商用化部署,在用户数据留存在本地的情况下,使能全场景智能应用。 MindSpore Federated优先专注于大规模参与方的横向联邦的应用场景,使参与联邦学习的各用户在不共享本地数据的前提下共建AI模型。MindSpore Fedrated主要解决隐私安全、大规模联邦聚合、易用性和跨平台部署等联邦学习在工业场景部署的难点。
|
||||
@@ -0,0 +1,135 @@
|
||||
## 隐私加密算法
|
||||
|
||||
联邦学习过程中,用户数据仅用于本地设备训练,不需要上传至中央FL-Server。这样可以避免用户个人数据的直接泄露。然而联邦学习框架中,模型的权重以明文形式上云仍然存在间接泄露用户隐私的风险。敌手获取到用户上传的明文权重后,可以通过重构、模型逆向等攻击恢复用户的个人训练数据,导致用户隐私泄露。
|
||||
|
||||
MindSpore Federated框架,提供了基于本地差分隐私(LDP)和基于多方安全计算(MPC)的安全聚合算法,在本地模型的权重上云前对其进行加噪或加扰。在保证模型可用性的前提下,解决联邦学习中的隐私泄露问题。
|
||||
|
||||
### 基于LDP的安全聚合
|
||||
|
||||
差分隐私(differential privacy)是一种保护用户数据隐私的机制。差分隐私定义为:
|
||||
|
||||
$$
|
||||
Pr[\mathcal{K}(D)\in S] \le e^{\epsilon} Pr[\mathcal{K}(D’) \in S]+\delta
|
||||
$$
|
||||
|
||||
对于两个差别只有一条记录的数据集$D, D’$,通过随机算法$\mathcal{K}$,输出结果为集合$S$子集的概率满足上面公式。$\epsilon$为差分隐私预算,$\delta$扰动,$\epsilon$和$\delta$越小,说明$\mathcal{K}$在$D$和$D’$上输出的数据分布越接近。
|
||||
|
||||
在联邦学习中,假设FL-Client本地训练之后的模型权重矩阵是$W$,由于模型在训练过程中会“记住”训练集的特征,所以敌手可以借助$W$还原出用户的训练数据集。
|
||||
|
||||
MindSpore Federated提供基于本地差分隐私的安全聚合算法,防止本地模型的权重上云时泄露隐私数据。
|
||||
|
||||
FL-Client会生成一个与本地模型权重$W$相同维度的差分噪声矩阵$G$,然后将二者相加,得到一个满足差分隐私定义的权重$W_p$:
|
||||
|
||||
$$
|
||||
W_p=W+G
|
||||
$$
|
||||
|
||||
FL-Client将加噪后的模型权重$W_p$上传至云侧FL-Server进行联邦聚合。噪声矩阵$G$相当于给原模型加上了一层掩码,在降低模型泄露敏感数据风险的同时,也会影响模型训练的收敛性。如何在模型隐私性和可用性之间取得更好的平衡,仍然是一个值得研究的问题。实验表明,当参与方的数量$n$足够大时(一般指1000以上),大部分噪声能够相互抵消,本地差分机制对聚合模型的精度和收敛性没有明显影响。
|
||||
|
||||
### 基于MPC的安全聚合
|
||||
|
||||
尽管差分隐私技术可以适当保护用户数据隐私,但是当参与FL-Client数量比较少或者高斯噪声幅值较大时,模型精度会受较大影响。为了同时满足模型保护和模型收敛这两个要求,MindSpore Federated提供了基于MPC的安全聚合方案。
|
||||
|
||||
在这种训练模式下,假设参与的FL-Client集合为$U$,对于任意FL-Client $u$和$v$,
|
||||
它们会两两协商出一对随机扰动$p_{uv}$、$p_{vu}$,满足
|
||||
|
||||
$$
|
||||
\label{puv}
|
||||
p_{uv}=
|
||||
\begin{cases}
|
||||
-p_{vu}, &u{\neq}v\\
|
||||
0, &u=v
|
||||
\end{cases}
|
||||
$$
|
||||
于是每个FL-Client $u$ 在上传模型权重至FL-Server前,会在原模型权重$x_u$加上它与其它用户协商的扰动:
|
||||
|
||||
$$
|
||||
x_{encrypt}=x_u+\sum\limits_{v{\in}U}p_{uv}
|
||||
$$
|
||||
|
||||
从而FL-Server聚合结果$\overline{x}$为:
|
||||
|
||||
上面的过程只是介绍了聚合算法的主要思想,基于MPC的聚合方案是精度无损的,代价是通讯轮次的增加。
|
||||
|
||||
### 基于LDP-SignDS算法的安全聚合
|
||||
|
||||
对于先前的基于维度加噪的LDP算法,在隐私预算一定时,添加到每个维度的噪声规模基本上与模型参数的数量成正比。因此,对于高维模型,可能需要非常多的参与方来减轻噪音对模型收敛的影响。为了解决上述“维度依赖”问题,MindSpore Federated 进一步提供了基于维度选择的**Sign-based Dimension Selection (SignDS)** :cite:`jiang2022signds`算法。SignDS算法的主要思想是,对于每一条真实的本地更新$\Delta\in\mathbb{R}^{d}$,用户端首先选择一小部分更新最明显的维度构建topk集合$S_k$,并以此选择一个维度集合$J$返回给FL-Server。FL-Server根据维度集合$J$构建一条对应的稀疏更新$\Delta^\prime$,并聚合所有稀疏更新进用于更新全局模型。由于本地模型更新与本地数据信息相关联,直接选取真实的最大更新维度可能导致隐私泄露。对此,SignDS算法在两方面实现了隐私保证。一方面,算法使用了一种基于指数机制(Exponential Mechanism, EM :cite:`mcsherry2007mechanism`)的维度选择算法**EM-MDS**,使得所选维度集满足严格的$\epsilon$-LDP保证;另一方面,在构建稀疏更新时,对所选维度分配一个常量值而不直接使用实际更新值,以保证稀疏更新和本地数据不再直接关联。由于维度选择满足$\epsilon$-LDP,且分配给所选维度的更新值与本地数据无关,根据差分隐私的传递性 :cite:`dwork2014algorithmic`,所构建的稀疏更新同样满足$\epsilon$-LDP保证。**相较于之前基于维度加噪的LDP算法,SignDS算法可以显著提升高维模型的训练精度。同时,由于FL-Client只需上传一小部分的维度值而不是所有的模型权重,因此联邦学习的上行通信量也被大大降低。**
|
||||
|
||||
下面,我们分别对topk集合$S_k$的构建和EM-MDS维度选择算法进行详细介绍。
|
||||
|
||||
首先,由于实际更新值有正负,直接给所有选定的维度分配相同的常量值可能会明显改变模型更新方向,影响模型收敛。为了解决这个问题,SignDS提出了一种基于符号的topk集合构建策略。具体来讲,算法引入了一个额外的符号变量$s\in\\{-1,1\\}$。该变量由FL-Client以等概率随机采样,用于确定本地更新$\Delta$的topk集合$S_k$。如果$s=1$,我们将$\Delta$按**真实更新值**排序,并将**最大**的$k$个更新维度记为$S_k$。我们进一步从$S_k$中随机选择一部分维度,并将$s=1$作为这些维度的更新值用以构建稀疏更新。直觉上,$S_k$中维度的更新值很可能大于零。因此,将$s=1$分配给选定的维度不会导致模型更新方向的太大差异,从而减轻了对模型精度的影响。类似的,当$s=-1$时,我们选取**最小**的$k$个更新维度记为$S_k$,并将$s=-1$分配给所选维度。
|
||||
|
||||
下面,我们进一步介绍用于维度选择的EM-MDS算法。简单来说,EM-MDS算法的目的是从输出维度域$\mathcal{J}$中以一定概率$\mathcal{P}$随机选择一个维度集合$J\in\mathcal{J}$,不同维度集合对应的概率不同。我们假设$J$总共包含$h$个维度,其中有$\nu$个维度属于topk集合(即$|S_k \cap J|=\nu$,且$\nu\in[0,h]$),另外$h-\nu$个维度属于非topk集合。直观上,$\nu$越大,$J$中包含的topk维度越多,模型收敛越好。因此,我们希望给$\nu$较大的维度集合分配更高的概率。基于这个想法,我们将评分函数定义为:
|
||||
|
||||
$$
|
||||
u(S_{k}, J) = 𝟙(|S_k\cap J| \geq \nu_{th}) = 𝟙(\nu \geq \nu_{th})
|
||||
$$
|
||||
:eqlabel:`score_function`
|
||||
|
||||
$u(S_{k}, J)$用来衡量输出维度集合$J$中包含的topk维度的数量是否超过某一阈值$\nu_{th}$($\nu_{th}\in[1,h]$),超过则为1,否则为0。进一步,$u(S_{k}, J)$的敏感度可计算为:
|
||||
|
||||
$$
|
||||
\phi = \max_{J\in\mathcal{J}} ||u(S_{k}, J) - u(S^\prime_{k}, J)||= 1 - 0 = 1
|
||||
$$
|
||||
:eqlabel:`sensitivity`
|
||||
|
||||
注意 :eqref:`sensitivity`对于任意一对不同的topk集合$S_k$和$S_k^\prime$均成立。
|
||||
|
||||
根据以上定义,EM-MDS算法描述如下:
|
||||
|
||||
*给定真实本地更新$\Delta\in\mathbb{R}^{d}$的topk集合$S_k$和隐私预算$\epsilon$,输出维度集合$J\in\mathcal{J}$的采样概率为:*
|
||||
|
||||
$$
|
||||
\mathcal{P}=\frac{\mathrm{exp}(\frac{\epsilon}{\phi}\cdot u(S_{k}, J))}{\sum_{J^\prime\in\mathcal{J}}\mathrm{exp}(\frac{\epsilon}{\phi}\cdot u(S_{k}, J^\prime))}
|
||||
=
|
||||
\frac{\mathrm{exp}(\epsilon\cdot 𝟙(\nu \geq \nu_{th}))}{\sum_{\tau=0}^{\tau=h}\omega_{\tau}\cdot \mathrm{exp}(\epsilon\cdot 𝟙(\tau\geq\nu_{th}))}
|
||||
=
|
||||
\frac{\mathrm{exp}(\epsilon\cdot 𝟙(\nu \geq \nu_{th}))}{\sum_{\tau=0}^{\tau=\nu_{th}-1}\omega_{\tau} + \sum_{\tau=\nu_{th}}^{\tau=h}\omega_{\tau}\cdot \mathrm{exp}(\epsilon)}
|
||||
$$
|
||||
:eqlabel:`emmds`
|
||||
|
||||
*其中,$\nu$是$J$中包含的topk维度数量,$\nu_{th}$是评分函数的阈值,$J^\prime$是任意一输出维度集合,$\omega_{\tau}=\binom{k}{\tau}\binom{d-k}{h-\tau}$是所有包含$\tau$个topk维度的集合数。*
|
||||
|
||||
我们进一步提供了EM-MDS算法的隐私证明:
|
||||
|
||||
对于每个FL-Client,给定随机采样的符号值$x$,任意两个本地更新$\Delta$,$\Delta^\prime$的topk集合记为$S_k$和$S_k^\prime$,对于任意输出维度集合$J\in\mathcal{J}$,令$\nu=|S_k \cap J|$, $\nu^\prime=|S_k^\prime \cap J|$为$J$与两组topk维度集的交集数量。根据 :eqref:`emmds`,以下不等式成立:
|
||||
|
||||
$$
|
||||
\frac{\mathrm{Pr}\[J|\Delta\]}{\mathrm{Pr}\[J|\Delta^\prime\]} = \frac{\mathrm{Pr}\[J|S_{k}\]}{\mathrm{Pr}\[J|S^\prime_{k}\]} = \frac{\frac{\mathrm{exp}(\frac{\epsilon}{\phi}\cdot u(S_{k}, J))}{\sum_{J^\prime\in\mathcal{J}}\mathrm{exp}(\frac{\epsilon}{\phi}\cdot u(S_{k}, J^\prime))}}{\frac{\mathrm{exp}(\frac{\epsilon}{\phi}\cdot u(S^\prime_{k}, J))}{\sum_{J^\prime\in\mathcal{J}}\mathrm{exp}(\frac{\epsilon}{\phi}\cdot u(S^\prime_{k}, J^\prime))}}
|
||||
= \frac{\frac{\mathrm{exp}(\epsilon\cdot 𝟙(\nu \geq \nu_{th}))}{\sum_{\tau=0}^{\tau=h}\omega_{\tau}\cdot \mathrm{exp}(\epsilon\cdot 𝟙(\tau\geq\nu_{th}))}}{\frac{
|
||||
\mathrm{exp}(\epsilon\cdot 𝟙(\nu^\prime \geq \nu_{th}))}{\sum_{\tau=0}^{\tau=h}\omega_{\tau}\cdot \mathrm{exp}(\epsilon\cdot 𝟙(\tau\geq\nu_{th}))}} \\
|
||||
= \frac{\mathrm{exp}(\epsilon\cdot 𝟙(\nu \geq \nu_{th}))}{
|
||||
\mathrm{exp}(\epsilon\cdot 𝟙(\nu^\prime \geq \nu_{th}))}
|
||||
\leq \frac{\mathrm{exp}(\epsilon\cdot 1)}{\mathrm{exp}(\epsilon\cdot 0)} = \mathrm{exp}(\epsilon)
|
||||
$$
|
||||
|
||||
*证明EM-MDS算法满足$\epsilon$-LDP保证。*
|
||||
|
||||
值得注意的是,计算 :eqref:`emmds`需要先确定topk维度数的阈值$\nu_{th}$。为此,我们首先推导在给定阈值$\nu_{th}$时,任意一组输出维度集合$J$包含的topk维度的概率分布和期望:
|
||||
|
||||
$$
|
||||
\mathrm{Pr}(\nu=\tau|\nu_{th})=
|
||||
\begin{cases}
|
||||
\omega_{\tau} / \Omega \quad \quad \quad \quad \quad \mathrm{ } &if \quad \tau\in\[0,\nu_{th}\) \\
|
||||
\omega_{\tau}\cdot\mathrm{exp}(\epsilon) / \Omega \quad \quad &if \quad \tau\in\[\nu_{th},h\]
|
||||
\end{cases}
|
||||
$$
|
||||
:eqlabel:`discrete-prob`
|
||||
|
||||
$$
|
||||
\mathbb{E}\[\nu|\nu_{th}\] = \sum_{\tau=0}^{\tau=h}\tau\cdot \mathrm{Pr}(\nu=\tau|\nu_{th})
|
||||
$$
|
||||
:eqlabel:`expectation`
|
||||
|
||||
这里,$\Omega$为 :eqref:`emmds`中$\mathcal{P}$的分母部分。直觉上,$\mathbb{E}\[\nu|\nu_{th}\]$越高,随机采样的$J$集合中包含的topk维度的概率就越大,从而模型效用就越好。因此,我们将$\mathbb{E}\[\nu|\nu_{th}\]$最高时的阈值确定为目标阈值$\nu_{th}^{\*}$,即:
|
||||
|
||||
$$
|
||||
\nu_{th}^{\*} = \underset{\nu_{th}\in\[1, h\]}{\operatorname{argmax}} \mathbb{E}\[\nu|\nu_{th}\]
|
||||
$$
|
||||
:eqlabel:`threshold`
|
||||
|
||||
最后,我们在 :numref:`signds_workflow`中描述了SignDS算法的详细流程。给定本地模型更新$\Delta$,我们首先随机采样一个符号值$s$并构建topk集合$S_k$。接下来,我们根据 :eqref:`threshold`确定阈值$\nu_{th}^{\*}$并遵循 :eqref:`emmds`定义的概率选择输出集合$J$。考虑到输出域$\mathcal{J}$包含$\binom{d}{k}$个可能的维度集合,以一定概率直接从$\mathcal{J}$中随机采样一个组合需要很大的计算成本和空间成本。因此,我们采用了逆采样算法以提升计算效率。具体来说,我们首先从标准均匀分布中采样一个随机值$\beta\sim U(0,1)$,并根据 :eqref:`discrete-prob`中$p(\nu=\tau|\nu_{th})$的累计概率分布$CDF_{\tau}$确定输出维度集合中包含的topk维度数$\nu$。最后,我们从topk集合$S_k$中随机选取$\nu$个维度,从非topk集合中随机采样$h-\nu$个维度,以构建最终的输出维度集合$J$。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`signds_workflow`
|
||||
7
website/chapter_federated_learning/summary.md
Normal file
7
website/chapter_federated_learning/summary.md
Normal file
@@ -0,0 +1,7 @@
|
||||
## 小结
|
||||
|
||||
在这一章,我们简单介绍了联邦学习的背景、系统架构、联邦平均算法、隐私加密算法以及实际部署时的挑战。联邦学习是一个新起步的人工智能算法,可以在“数据保护”与“数据孤岛”这两大约束条件下,建立有效的机器学习模型。此外,由于联邦学习场景的特殊性(端侧数据不上传、安全隐私要求高和数据非独立同分布等特点),使得系统和算法的开发难度更高:如何平衡计算和通讯的开销?如何保证模型不会泄露隐私?算法如何在非独立同分布场景下收敛?这些难点都需要开发人员对实际的联邦学习场景有更深刻的认识。
|
||||
|
||||
在这一章,我们简单介绍了联邦学习的背景、系统架构、联邦平均算法、隐私加密算法以及实际部署时的挑战。联邦学习是一个新起步的人工智能算法,可以在“数据保护”与“数据孤岛”这两大约束条件下,建立有效的机器学习模型。
|
||||
|
||||
此外,由于联邦学习场景的特殊性(端侧数据不上传、安全隐私要求高和数据非独立同分布等特点),使得系统和算法的开发难度更高。如何平衡计算和通讯的开销,如何保证模型不会泄露隐私,以及算法如何在非独立同分布场景下收敛等等,都需要开发人员对实际的联邦学习场景有更深刻的认识。
|
||||
67
website/chapter_federated_learning/vertical_fl.md
Normal file
67
website/chapter_federated_learning/vertical_fl.md
Normal file
@@ -0,0 +1,67 @@
|
||||
## 纵向联邦学习
|
||||
|
||||
现在我们介绍另一种联邦学习算法:纵向联邦学习(Vertical Federated Learning)。纵向联邦学习的参与方拥有相同样本空间、不同特征空间的数据,通过共有样本数据进行安全联合建模,在金融、广告等领域拥有广泛的应用场景。和横向联邦学习相比,纵向联邦学习的参与方之间需要协同完成数据求交集、模型联合训练和推理,实现技术方法会相对更加复杂,并且随着参与方增多复杂度越高。
|
||||
|
||||
下面以企业A和企业B两方为例来介绍纵向联邦的基本架构和流程。假设企业A有特征数据$X_a$和标签数据$Y$,可以独立建模;企业B有特征数据$X_b$,缺乏标签数据,因此无法独立建模。由于隐私法规和行业规范等原因,两个企业之间的数据无法直接互通。企业A和企业B可采用纵向联邦学习解决方案进行合作,数据不出本地,使用双方共同样本数据$X_a$、$Y$和$X_b$进行联合建模和训练。最终双方都能获得一个更强大的模型。
|
||||
|
||||
### 纵向联邦架构
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`federated-learning-vfl-arch`
|
||||
|
||||
纵向联邦学习系统中的模型训练一般分为如下阶段:
|
||||
- 样本对齐:首先对齐企业A和企业B中具有相同ID(Identification)的样本数据。在数据对齐阶段,系统会采用加密算法对数据进行保护,确保任何一方的用户数据不会暴露。
|
||||
- 联合训练:在确定企业A和企业B共有用户数据后,可以使用这些共有的数据来协同训练一个业务模型。模型训练过程中,模型参数信息以加密方式进行传递。已训练好的联邦学习模型可以部署在联邦学习系统的各参与方。
|
||||
|
||||
### 样本对齐
|
||||
|
||||
隐私集合求交(Private Set Intersection,PSI)技术是纵向联邦学习中数据样本对齐的常用解决方案。业界PSI实现方案有多种:基于电路、基于公钥加密、基于不经意传输协议和基于全同态加密等。不同PSI方案各有优劣势。例如,基于公钥加密方案不需要辅助服务器运行,但公钥加密的计算开销大;而基于不经意传输方案计算性能高,但通信开销较大。因此在具体应用时,要根据实际场景来选择功能、性能和安全之间的最佳平衡方案。
|
||||
|
||||
基于RSA盲签名是一种基于公钥加密的经典PSI方法,也是当前业界纵向联邦学习系统中广泛应用的技术之一。下面以企业A和企业B为例描述RSA盲签名算法的基本流程。
|
||||
|
||||

|
||||
:width:`600px`
|
||||
:label:`federated-learning-vfl-data`
|
||||
|
||||
|
||||
企业A作为服务端,拥有标签数据和样本ID集合$\{a_1, a_2, …,a_w\}$。企业B则作为客户端,拥有样本ID集合$\{b_1, b_2, …,b_v\}$。
|
||||
首先,在企业A的服务端利用RSA算法生成私钥和公钥。其中,私钥$(n,d)$保留在服务端,公钥$(n,e)$则发送给企业B的客户端。
|
||||
|
||||
在服务端侧生成RSA计算样本对齐ID的签名:
|
||||
$$t_j=H^{'}(K_{a:j})$$
|
||||
其中,
|
||||
$K_{a:j}=(H(a_j))^d \ mod \ n$,是采用私钥$d$加密的对$H(a_j)$的RSA加密的结果。$H()$和$H^{'}()$是哈希函数。
|
||||
同样,在客户端侧对样本ID进行公钥加密,并乘以一个随机数$R_{b,i}$用于加盲扰动:
|
||||
$$y_i=H(b_i)\cdot(R_{b,i})^e \ mod \ n$$
|
||||
客户端侧将上述计算出来的$\{y_1,...,y_v\}$值传输给服务端侧。服务端侧收到$y_i$值后,使用私钥$d$进行签名并计算:
|
||||
$$y_i^{'}=y_i^d \ mod \ n$$
|
||||
然后将计算出的$\{y_1^{'},...,y_v^{'}\}$和$\{t_1,...,t_w\}$发送给客户端侧。
|
||||
而客户端侧收到$y_i^{'}$和$t_j$后,首先完成去盲操作:
|
||||
$$K_{b:i}={y_i}^{'}/R_{b,i}$$
|
||||
并将自己的ID签名与服务端发过来的ID签名进行样本对齐,得到加密和哈希组合状态下的ID交集$I$,
|
||||
$${t_i}^{'}=H^{'}(K_{b:i}) \\I=\{t_1,...,t_w\}\cap \{{t_1}^{'},...,{t_v}^{'}\}$$
|
||||
|
||||
最后,将对齐后的样本ID交集$I$发送给服务端,服务端利用自身的映射表单独求取明文结果。这样企业A和企业B在加密状态下完成了求取相交的用户集合,并且在整个过程中双方非重叠样本ID都不会对外暴露。
|
||||
|
||||
### 联合训练
|
||||
|
||||
在确样本ID对齐后,就可以使用这些公共的数据来训练机器学习模型。目前,线性回归、决策树和神经网络等模型已经被广泛应用到纵向联邦系统中。
|
||||
在纵向联邦的模型训练过程中,一般会引入第三方协作者C来实现中心服务器功能,并且假设这个第三方协作者C是可信的,不会与其他参与方合谋。中心服务器在训练过程中作为中立方,产生和分发密钥,并对加密数据进行解密和计算。但中心服务器角色是非必须的,例如在两方联邦学习的场景下,不需要第三方协作者C来协调双方的训练任务,可以由具有标签数据的企业A来充当中心服务服务器的角色。不失一般性,下面继续以包含第三方协作者C的方案来描述纵向联邦模型联合训练过程。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`federated-learning-vfl-train`
|
||||
|
||||
- 第一步:由第三方协作者C创建密钥对,将公钥发送给企业A和B。
|
||||
- 第二步:在企业A和B侧分别计算梯度和损失计算需要的中间结果,并进行加密和交换。
|
||||
- 第三步:企业A和B分别计算加密梯度和添加掩码。同时企业A还将计算加密损失值。计算完成后,企业A和B向第三方协作者C发送加密后的值。
|
||||
- 第四步:第三方协作者C对梯度和损失值解密,然后将结果发送回企业A和B。
|
||||
- 第五步:企业A和B将收到的值首先去除梯度上的掩码,然后更新本地模型参数。
|
||||
|
||||
在整个训练过程中,企业A和B之间的任何敏感数据都是经过加密算法加密之后再发出己信任域。同态加密(Homomorphic Encryption,HE)是业界联邦学习框架常用的算法之一。同态加密是指加密过后的两份数据进行某些运算之后直接解密,可以得到真实数据经过相同运算的结果。当这种运算是加法时,就称为加法同态加密。将加密函数记为$[[\cdot]]$,加法同态加密具有如下特征:
|
||||
$$[[a+b]]=[[a]]+[[b]]$$
|
||||
Paillier算法是一种满足加法的同态加密算法,已经广泛应用在第三方数据处理领域和信号处理领域。在纵向联邦学习中,通常采用Paillier加密算法对损失函数和梯度进行加密,从而实现跨机构的模型安全联合训练。
|
||||
|
||||
模型联合训练完成后就可以投入生产环境部署应用。由于纵向联邦中每个参与方具有部分模型结构,因此推理也需要双方协作完成计算。联合推理过程和联合训练类似,首先第三方协作者C将推理数据ID发送给企业A和B,双方在本地完成推理计算后将结果加密后传输到第三方协作者C,由C计算模型最终的联合推理结果。
|
||||
|
||||
252
website/chapter_frontend_and_ir/ad.md
Normal file
252
website/chapter_frontend_and_ir/ad.md
Normal file
@@ -0,0 +1,252 @@
|
||||
自动微分
|
||||
--------
|
||||
|
||||
上一节,我们介绍了机器学习框架的中间表示,设计这些中间表示的最核心的目的之一便是服务于自动微分变换。那么什么是自动微分?我们在这一节来详细介绍。
|
||||
|
||||
### 自动微分的基本概念
|
||||
|
||||
自动微分(Automatic
|
||||
Differentiation,AD)是一种对计算机程序进行高效且准确求导的技术,在上个世纪六七十年代就已经被广泛应用于流体力学、天文学、数学金融等领域 :cite:`10.5555/1455489`。时至今日,自动微分的实现及其理论仍然是一个活跃的研究领域。随着近些年深度学习在越来越多的机器学习任务上取得领先成果,自动微分被广泛的应用于机器学习领域。许多机器学习模型使用的优化算法都需要获取模型的导数,因此自动微分技术成为了一些热门的机器学习框架(例如TensorFlow和PyTorch)的核心特性。
|
||||
|
||||
常见的计算机程序求导的方法可以归纳为以下四种 :cite:`2015Automatic`:手工微分(Manual
|
||||
Differentiation)、数值微分(Numerical
|
||||
Differentiation)、符号微分(Symbolic
|
||||
Differentiation)和自动微分(Automatic Differentiation)。
|
||||
|
||||
(1)手工微分:需手工求解函数导数的表达式,并在程序运行时根据输入的数值直接计算结果。手工微分需根据函数的变化重新推导表达式,工作量大且容易出错。
|
||||
|
||||
(2)数值微分 :cite:`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)符号微分 :cite:`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 :cite:`10.5555/60181.60188` )问题。如 :numref:`symbolic_differentiation` 所示,用符号微分计算递归表达式$l_{n+1}=4l_n(1-l_n)$,$l_1=x$的导数表达式,其结果随着迭代次数增加快速膨胀。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`symbolic_differentiation`
|
||||
|
||||
并且符号微分需要表达式被定义成闭合式的(closed-form),不能带有或者严格限制控制流的语句表达,使用符号微分会很大程度上地限制了机器学习框架网络的设计与表达。
|
||||
|
||||
(4)自动微分 :cite:`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`:
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`example_compute_graph`
|
||||
|
||||
(1)前向模式
|
||||
|
||||

|
||||
: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)反向模式
|
||||
|
||||

|
||||
: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 x_n})$都计算出来,而前向模式则需要执行n次。这种计算一个标量值的输出关于大量参数输入的梯度的场景恰好是机器学习实践中最常见的一种计算场景,这使得反向模式的自动微分成为反向传播算法使用的核心技术之一。
|
||||
|
||||
但是反向模式也存在一定的缺陷。在源程序分解为一系列基本操作后,前向模式由于求导顺序与基本操作的执行顺序一致,输入值可以在执行基本操作的过程中同步获得。而在反向模式中,由于求导顺序与源程序的执行顺序是相反的,计算过程需要分为两个阶段,第一个阶段先执行源程序,且将源程序的中间结果保存起来,在第二阶段才把中间结果取出来去计算导数。因此反向模式会有额外的内存消耗。业界也一直在研究反向模式的内存占用优化方法,例如检查点策略(checkpointing
|
||||
strategies)和数据流分析(data-flow
|
||||
analysis) :cite:`2006The,2017Divide` 。
|
||||
|
||||
### 自动微分的实现
|
||||
|
||||
上一节我们介绍了自动微分的基本概念,可以总结为将程序分解为一系列微分规则已知的基本操作,然后运用链式法则将它们的微分结果组合起来得到程序的微分结果。而在机器学习的应用中,因为输入的数量远远大于输出的数量,所以反向模式的自动微分更受青睐。虽然自动微分的基本思想是明确的,但是具体的实现方法也分为几类 :cite:`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 Overloading,
|
||||
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不需要在运行时做自动微分的转换,但是对于反向模式,在反向部分执行时,仍然需要确保前向执行的一部分中间变量可以被获取到,有两种方式可以解决该问题 :cite:`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\<Primitive\>类型的$bprop$,通过解析Python层预先注册的$get\_bprop$函数的得到,如下所示。对于ValueNode\<FuncGraph\>类型的节点,则递归求出它的梯度函数对象。
|
||||
```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的映射,并建立起节点间的反向传播连接,实现梯度累加,最后返回原函数的梯度函数计算图。
|
||||
@@ -0,0 +1,29 @@
|
||||
AI编译器设计原理
|
||||
----
|
||||
|
||||
无论是传统编译器还是AI编译器,它们的输入均为用户的编程代码,输出也机器执行的高效代码。进阶篇将用两个章节详细介绍AI编译器,里面的很多概念借用了通用编译器中的概念,如AOT(Ahead of Time提前编译)、JIT(Just in time 即时)、IR(Intermediate Representation中间表示)、PASS优化、AST(Abstract Trees)、副作用、闭包等概念,和编译器教材中对应概念的定义相同,对编译器相关概念感兴趣的读者可以翻阅相关的编译原理教材,本书会将讨论重点放在机器学习编译器相较于传统编译器的独特设计与功能上。
|
||||
|
||||
AI编译器的设计受到了主流编译器(如LLVM)的影响。为了方便理解AI编译器,首先通过 :numref:`LLVM_basic_struc`展示LLVM编译器的架构。
|
||||
|
||||

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

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

|
||||
:width:`800px`
|
||||
:label:`MS_Compiler`
|
||||
@@ -0,0 +1,44 @@
|
||||
常见前端编译优化方法
|
||||
--------------------
|
||||
|
||||
和传统编译器相同,机器学习编译器也会进行编译优化。编译优化意在解决编译生成的中间表示的低效性,使得代码的长度变短,编译与运行的时间减少,执行期间处理器的能耗变低。编译优化可以分为与硬件无关的优化和与硬件相关的编译优化。因为前端是不感知具体后端硬件的,因此前端执行的全部都是与硬件无关的编译优化。
|
||||
|
||||
### 前端编译优化简介
|
||||
|
||||
大多数编译优化器会由一系列的"趟"(Pass)来组成。每个"趟"以中间表示为输入,又以新生成的中间表示为输出。一个"趟"还可以由几个小的"趟"所组成。一个"趟"可以运行一次,也可以运行多次。
|
||||
|
||||
在编译优化中,优化操作的选择以及顺序对于编译的整体具有非常关键的作用。优化操作的选择决定了优化器能够感知中间表示中的哪些低效性,也决定了编译器将要如何去重写中间表示以消除这种低效性。优化操作的顺序决定了各趟操作的执行顺序。编译器可以根据具体需要运行不同的编译优化操作。也可以根据编译优化级别来调整优化的次数,种类以及顺序。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`pass_structure`
|
||||
|
||||
### 常见编译优化方法介绍及实现
|
||||
|
||||
前端编译优化的方法有很多,机器学习框架也有很多不同于传统编译器的优化方式。在本小节当中,我们会介绍三种常见且通用的前端编译优化方法。
|
||||
|
||||
1\. 无用与不可达代码消除
|
||||
|
||||
如 :numref:`pass_useless_code_elimination`所示。无用代码是指输出结果没有被任何其他代码所使用的代码。不可达代码是指没有有效的控制流路径包含该代码。删除无用或不可达的代码可以使得中间表示更小,提高程序的编译与执行速度。无用与不可达代码一方面有可能来自于程序编写者的编写失误,也有可能是其他编译优化所产生的结果。
|
||||
|
||||

|
||||
:width:`600px`
|
||||
:label:`pass_useless_code_elimination`
|
||||
|
||||
2\. 常量传播、常量折叠
|
||||
|
||||
常量传播:如 :numref:`pass_constant_broadcast`所示,如果某些量为已知值的常量,那么可以在编译时刻将使用这些量的地方进行替换。
|
||||
|
||||
常量折叠:如 :numref:`pass_constant_broadcast`所示,多个量进行计算时,如果能够在编译时刻直接计算出其结果,那么变量将由常量替换。
|
||||
|
||||

|
||||
:width:`600px`
|
||||
:label:`pass_constant_broadcast`
|
||||
|
||||
3\. 公共子表达式消除
|
||||
|
||||
如 :numref:`pass_CSE`所示,如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E就成为了公共子表达式。对于这种表达式,没有必要花时间再对它进行计算,只需要直接用前面计算过的表达式结果代替E就可以了。
|
||||
|
||||

|
||||
:width:`600px`
|
||||
:label:`pass_CSE`
|
||||
29
website/chapter_frontend_and_ir/index.md
Normal file
29
website/chapter_frontend_and_ir/index.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# AI编译器和前端技术
|
||||
编译器作为计算机系统的核心组件,在机器学习框架设计中也扮演着重要的角色,并衍生出了一个专门的编译器种类:AI编译器。AI编译器既要对上承接模型算法的变化,满足算法开发者不断探索的研究诉求,又要对下在最终的二进制输出上满足多样性硬件的诉求,满足不同部署环境的资源要求。既要满足框架的通用普适性,又要满足易用性的灵活性要求,还要满足性能的不断优化诉求。AI编译器保证了机器学习算法的便捷表达和高效执行,日渐成为了机器学习框架设计的重要一环。
|
||||
|
||||
本章将先从AI编译器的整体框架入手, 介绍AI编译器的基础结构。接下来,本章会详细讨论编译器前端的设计,并将重点放在中间表示以及自动微分两个部分。有关AI编译器后端的详细知识, 将会在后续的第五章进行讨论。
|
||||
|
||||
本章的学习目标包括:
|
||||
|
||||
- 理解AI编译器的基本设计原理
|
||||
|
||||
- 理解中间表示的基础概念,特点和实现方法
|
||||
|
||||
- 理解自动微分的基础概念,特点和实现方法
|
||||
|
||||
- 了解类型系统和静态推导的基本原理
|
||||
|
||||
- 了解编译器优化的主要手段和常见优化方法
|
||||
|
||||
|
||||
```toc
|
||||
:maxdepth: 2
|
||||
|
||||
ai_compiler_design_principle
|
||||
overview_of_frontend
|
||||
intermediate_representation
|
||||
ad
|
||||
type_system_and_static_analysis
|
||||
common_frontend_optimization_pass
|
||||
summary
|
||||
```
|
||||
270
website/chapter_frontend_and_ir/intermediate_representation.md
Normal file
270
website/chapter_frontend_and_ir/intermediate_representation.md
Normal file
@@ -0,0 +1,270 @@
|
||||
中间表示
|
||||
--------
|
||||
|
||||
中间表示作为编译器的核心数据结构之一,无论是在传统编译器中,还是在机器学习框架中,
|
||||
都有着极其重要的地位。本章节我们会先介绍中间表示的基本概念以及传统编译器的中间表示类型。在此基础上,我们会探讨针对机器学习框架,中间表示的设计所面临的新的需求和挑战。最后,我们会介绍现有机器学习框架的中间表示的种类及其实现。
|
||||
|
||||
### 中间表示的基本概念
|
||||
|
||||
中间表示(IR),是编译器用于表示源代码的数据结构或代码,是程序编译过程中介于源语言和目标语言之间的程序表示。几乎所有的编译器都需要某种形式的中间表示,来对被分析、转换和优化的代码进行建模。在编译过程中,中间表示必须具备足够的表达力,在不丢失信息的情况下准确表达源代码,并且充分考虑从源代码到目标代码编译的完备性、编译优化的易用性和性能。
|
||||
|
||||
引入中间表示后,中间表示既能面向多个前端,表达多种源程序语言,又能对接多个后端,连接不同目标机器,如 :numref:`intermediate_representation`所示。在此基础上,编译流程就可以在前后端直接增加更多的优化流程,这些优化流程以现有IR为输入,又以新生成的IR为输出,被称为优化器。优化器负责分析并改进中间表示,极大程度的提高了编译流程的可拓展性,也降低了优化流程对前端和后端的破坏。
|
||||
|
||||

|
||||
:width:`850px`
|
||||
:label:`intermediate_representation`
|
||||
|
||||
随着编译器技术的不断演进,中间表示主要经历了三个发展阶段。在早期阶段,中间表示是封闭在编译器内部的,供编译器编写者使用。在中期阶段,随着编译器的开源,中间表示逐步开源公开,主要供编译器设计者、分析工具设计者使用。现阶段,中间表示朝着软件生态构建的方向发展,旨在构建统一的中间表示。
|
||||
|
||||
### 中间表示的种类
|
||||
|
||||
上一节介绍了中间表示的基本概念,初步阐述了中间表示的重要作用和发展历程。接下来从组织结构的角度出发,介绍通用编译器的中间表示的类型以及各自特点 :cite:`2020MLIR`,如下表所示。中间表示组织结构的设计,对编译阶段的分析优化、代码生成等有着重要影响。编译器的设计需求不同,采用的中间表示组织结构也有所不同。
|
||||
|
||||
::: {#tab:ch04/ch04-categorize}
|
||||
组织结构 特点 举例
|
||||
-------------- ---------------------- ----------------------------------
|
||||
Linear IR 基于线性代码 堆栈机代码、三地址代码
|
||||
Graphical IR 基于图 抽象语法树、有向无环图、控制流图
|
||||
Hybrid IR 基于图与线性代码混合 LLVM IR
|
||||
|
||||
: 中间表示的分类
|
||||
:::
|
||||
|
||||
1\) 线性中间表示
|
||||
|
||||
线性中间表示类似抽象机的汇编代码,将被编译代码表示为操作的有序序列,对操作序列规定了一种清晰且实用的顺序。由于大多数处理器采用线性的汇编语言,线性中间表示广泛应用于编译器设计。
|
||||
|
||||
常用线性中间表示有堆栈机代码(Stack-Machine Code)和三地址代码(Three
|
||||
Address Code) :cite:`2007Compilers` 。堆栈机代码是一种单地址代码,提供了简单紧凑的表示。堆栈机代码的指令通常只有一个操作码,其操作数存在一个栈中。大多数操作指令从栈获得操作数,并将其结果推入栈中。三地址代码,简称为3AC,模拟了现代RISC机器的指令格式。它通过一组四元组实现,每个四元组包括一个运算符和三个地址(两个操作数、一个目标)。对于表达式a-b\*5,堆栈机代码和三地址代码如 :numref:`linear_ir`所示。
|
||||
|
||||

|
||||
: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可以重用子树,降低求值过程的代价。
|
||||
|
||||

|
||||
:width:`400px`
|
||||
:label:`AST_DAG`
|
||||
|
||||
3、混合中间表示
|
||||
|
||||
混合中间表示是线性中间表示和图中间表示的结合,这里以LLVM IR :cite:`2004LLVM` 为例进行说明。LLVM(Low Level Virtual
|
||||
Machine)是2000年提出的开源编译器框架项目,旨在为不同的前端后端提供统一的中间表示。LLVM
|
||||
IR使用线性中间表示表示基本块,使用图中间表示表示这些块之间的控制流,如 :numref:`LLVM_IR`所示。基本块中,每条指令以静态单赋值(Static
|
||||
Single Assignment, SSA) :cite:`Richard1995A`
|
||||
形式呈现,这些指令构成一个指令线性列表。SSA形式要求每个变量只赋值一次,并且每个变量在使用之前定义。控制流图中,每个节点为一个基本块,基本块之间通过边实现控制转移。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`LLVM_IR`
|
||||
|
||||
### 机器学习框架的中间表示
|
||||
|
||||
上一节介绍了中间表示的类型,并举例说明了常见的中间表示形式。传统中间表示如LLVM
|
||||
IR,能够很好地满足通用编译器的基本功能需求,包括类型系统、控制流和数据流分析等。然而,它们偏向机器语言,难以满足机器学习框架编译器的中间表示的需求。
|
||||
|
||||
在设计机器学习框架的中间表示时,需要充分考虑以下因素:
|
||||
|
||||
1\)
|
||||
张量表达。机器学习框架主要处理张量数据,因此正确处理张量数据类型是机器学习框架中间表示的基本要求。
|
||||
|
||||
2\)
|
||||
自动微分。自动微分是指对网络模型的自动求导,通过梯度指导对网络权重的优化。主流机器学习框架都提供了自动微分的功能,在设计中间表示时需要考虑自动微分实现的简洁性、性能以及高阶微分的扩展能力。
|
||||
|
||||
3\)
|
||||
计算图模式。主流机器学习框架如TensorFlow、PyTorch、MindSpore等都提供了静态图和动态图两种计算图模式,静态计算图模式先创建定义计算图,再显式执行,有利于对计算图进行优化,高效但不灵活。动态计算图模式则是每使用一个算子后,该算子会在计算图中立即执行得到结果,使用灵活、便于调试,但运行速度较低。机器学习框架的中间表示设计同时支持静态图和动态图,可以针对待解决的任务需求,选择合适的模式构建算法模型。
|
||||
|
||||
4\)
|
||||
支持高阶函数和闭包 :cite:`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。如下Python代码使用了Scripting方法并打印其对应的中间表示图:
|
||||
```python
|
||||
import torch
|
||||
|
||||
@torch.jit.script
|
||||
def test_func(input):
|
||||
rv = 10.0
|
||||
for i in range(5):
|
||||
rv = rv + input
|
||||
rv = rv/2
|
||||
return rv
|
||||
|
||||
print(test_func.graph)
|
||||
```
|
||||
该中间表示图的结构为:
|
||||
```
|
||||
graph(%input.1 : Tensor):
|
||||
%9 : int = prim::Constant[value=1]()
|
||||
%5 : bool = prim::Constant[value=1]() # test.py:6:1
|
||||
%rv.1 : float = prim::Constant[value=10.]() # test.py:5:6
|
||||
%2 : int = prim::Constant[value=5]() # test.py:6:16
|
||||
%14 : int = prim::Constant[value=2]() # test.py:8:10
|
||||
%rv : float = prim::Loop(%2, %5, %rv.1) # test.py:6:1
|
||||
block0(%i : int, %rv.9 : float):
|
||||
%rv.3 : Tensor = aten::add(%input.1, %rv.9, %9) # <string>:5:9
|
||||
%12 : float = aten::FloatImplicit(%rv.3) # test.py:7:2
|
||||
%rv.6 : float = aten::div(%12, %14) # test.py:8:7
|
||||
-> (%5, %rv.6)
|
||||
return (%rv)
|
||||
```
|
||||
|
||||
|
||||
TorchScript是PyTorch的JIT实现,支持使用Python训练模型,然后通过JIT转换为语言无关的模块,从而提升模型部署能力,提高编译性能。同时,TorchScript
|
||||
IR显著改善了Pytorch框架的模型可视化效果。
|
||||
|
||||
2、Jax
|
||||
|
||||
Jax机器学习框架同时支持静态图和动态图,其中间表示采用Jaxpr(JAX Program
|
||||
Representation) IR。Jaxpr
|
||||
IR是一种强类型、纯函数的中间表示,其输入、输出都带有类型信息,函数输出只依赖输入,不依赖全局变量。
|
||||
|
||||
|
||||
Jaxpr IR的表达采用ANF(A-norm
|
||||
Form)函数式表达形式,ANF文法如下所示:
|
||||
|
||||
```
|
||||
<aexp> ::= NUMBER | STRING | VAR | BOOLEAN | PRIMOP
|
||||
| (lambda (VAR ...) <exp>)
|
||||
<cexp> ::= (<aexp> <aexp> ...)
|
||||
| (if <aexp> <exp> <exp>)
|
||||
<exp> ::= (let ([VAR <cexp>]) <exp>) | <cexp> | <aexp>
|
||||
```
|
||||
|
||||
ANF形式将表达式划分为两类:原子表达式(aexp)和复合表达式(cexp)。原子表达式用于表示常数、变量、原语、匿名函数,复合表达式由多个原子表达式组成,可看作一个匿名函数或原语函数调用,组合的第一个输入是调用的函数,其余输入是调用的参数。如下代码打印了一个函数对应的JaxPr:
|
||||
```python
|
||||
from jax import make_jaxpr
|
||||
import jax.numpy as jnp
|
||||
|
||||
def test_func(x, y):
|
||||
ret = x + jnp.sin(y) * 3
|
||||
return jnp.sum(ret)
|
||||
|
||||
print(make_jaxpr(test_func)(jnp.zeros(8), jnp.ones(8)))
|
||||
```
|
||||
其对应的JaxPr为:
|
||||
```
|
||||
{ lambda ; a:f32[8] b:f32[8]. let
|
||||
c:f32[8] = sin b
|
||||
d:f32[8] = mul c 3.0
|
||||
e:f32[8] = add a d
|
||||
f:f32[] = reduce_sum[axes=(0,)] e
|
||||
in (f,) }
|
||||
```
|
||||
|
||||
Jax框架结合了Autograd 和 JIT,基于Jaxpr
|
||||
IR,支持循环、分支、递归、闭包函数求导以及三阶求导,并且支持自动微分的反向传播和前向传播。
|
||||
|
||||
3、TensorFlow
|
||||
|
||||
TensorFlow框架同时支持静态图和动态图,是一个基于数据流编程的机器学习框架,使用数据流图作为数据结构进行各种数值计算。TensorFlow机器学习框架的静态图机制更为人所熟知。在静态图机制中,运行TensorFlow的程序会经历一系列的抽象以及分析,程序会逐步从高层的中间表示向底层的中间表示进行转换,我们把这种变换成为lowering。
|
||||
|
||||
为了适配不同的硬件平台,基于静态计算图,TensorFlow采用了多种IR设计,其编译生态系统如 :numref:`TFIR`所示。蓝色部分是基于图的中间表示,绿色部分是基于SSA的中间表示。在中间表示的转换过程中,各个层级的中间表示各自为政,无法互相有效地沟通信息,也不清楚其他层级的中间表示做了哪些优化,因此每个中间表示只能尽力将当前的优化做到最好,造成了很多优化在每个层级的中间表示中重复进行, 从而导致优化效率的低下。尤其是从图中间表示到SSA中间表示的变化过大,转换开销极大。此外,各个层级的相同优化的代码无法复用,也降低了开发效率。
|
||||
|
||||

|
||||
:width:`1000px`
|
||||
:label:`TFIR`
|
||||
|
||||
4、MLIR
|
||||
|
||||
针对这个问题,TensorFlow团队提出了MLIR(Multi-Level Intermediate
|
||||
Represent,多级中间表示) :cite:`2020MLIR`。MLIR不是一种具体的中间表示定义,而是为中间表示提供一个统一的抽象表达和概念。 开发者可以使用MLIR开发的一系列基础设施,来定义符合自己需求的中间表示, 因此我们可以把MLIR理解为“编译器的编译器”。MLIR不局限于TensorFlow框架, 还可以用于构建连接其他语言与后端(如LLVM)的中间表示。
|
||||
MLIR深受LLVM设计理念的影响,但与LLVM不同的是, MLIR是一个更开放的生态系统。 在MLIR中, 没有预设的操作与抽象类型, 这使得开发者可以更自由地定义中间表示,并更有针对性地解决其领域的问题。MLIR通过Dialect的概念来支持这种可拓展性, Dialect在特定的命名空间下为抽象提供了分组机制,分别为每种中间表示定义对应的产生式并绑定相应的Operation, 从而生成一个MLIR类型的中间表示。Operation是MLIR中抽象和计算的核心单元,其具有特定的语意,可以用于表示LLVM中所有核心的IR结构, 例如指令, 函数以及模块等。 如下就是一个MLIR定义下的Operation:
|
||||
|
||||
```
|
||||
%tensor = "toy.transpose"(%tensor) {inplace = true} : (tensor<2x3xf64>) -> tensor<3x2xf64> loc("example/file/path":12:1)
|
||||
```
|
||||
- \% tensor: Operation定义的结果的名字, $\%$是为了避免冲突统一加入的。一个Operation可以定义0或者多个结果,它们是SSA值。
|
||||
- "toy.transpose": Operation的名字。它是一个唯一的字符串,其中Dialect为Toy。因此它可以理解为Toy Dialect 中的transpose Operation。
|
||||
- (\%tensor):输入操作数(或参数)的列表,它们是由其它操作定义或引用块参数的 SSA 值。
|
||||
- {inplace = true}:零个或多个属性的字典,这些属性是始终为常量的特殊操作数。在这里,我们定义了一个名为“inplace”的布尔属性,它的常量值为 true。
|
||||
- (tensor<2x3xf64>)->tensor<3x2xf64>:函数形式表示的操作类型,前者是输入,后者是输出。尖括号内代表输入与输出的数据类型以及形状, 例如$<2x3xf64>$代表一个形状位2X3, 数据类型为float64的张量。
|
||||
- loc("example/file/path":12:1):此操作的源代码中的位置。
|
||||
|
||||
由于各层中间表示都遵循如上的样式进行定义,所以各个层级的中间表示之间可以更加方便的进行转换, 提高了中间表示转换的效率。各个不同层级的中间表示还可以协同进行优化。 此外,由于中间表示之间不再相互独立, 各层级的优化不必做到极致,而是可以将优化放到最适合的层级。 其他的中间表示只需要先转换为该层级的中间表示,就可以进行相关的优化,提高了优化的效率与开发效率。TensorFlow从图中间表示到SSA中间表示的转换也可以通过使用MLIR来进行多层转换, 使转换更加平滑, 降低了转化的难度。 针对MLIR的更多内容将会在第六章进行介绍。
|
||||
|
||||
5、MindSpore
|
||||
|
||||
与PyTorch、Jax、TensorFlow框架相同,MindSpore机器学习框架同时支持静态图和动态图。MindSpore框架采用的是一种基于图表示的函数式中间表示,即MindIR,全称MindSpore
|
||||
IR。MindIR没有采用多层中间表示的结构,而是通过统一的中间表示,定义了网络的逻辑结构和算子的属性,能够消除不同后端的模型差异,连接不同的目标机器。
|
||||
|
||||
MindIR最核心的目的是服务于自动微分变换,而自动微分采用的是基于函数式编程框架的变换方法,因此MindIR采用了接近于ANF函数式的语义。MindIR具有以下特点:
|
||||
|
||||
(1)基于图的(Graph
|
||||
based)。与TensorFlow类似,程序使用图来表示,使其容易去做优化。但跟TensorFlow不一样的是,在MindSpore中,函数是"一等公民"。函数可以被递归调用,也可以被当做参数传到其他的函数中,或者从其他函数中返回,使得MindSpore可以表达一系列的控制流结构。
|
||||
|
||||
(2)纯函数的(Purely functional)。
|
||||
|
||||
纯函数是指函数的结果只依赖函数的参数。若函数依赖或影响外部的状态,比如,函数会修改外部全局变量,或者函数的结果依赖全局变量的值,则称函数具有副作用 :cite:`spuler1994compiler`。若使用了带有副作用的函数,代码的执行顺序必须得到严格的保证,否则可能会得到错误的结果,比如对全局变量的先写后读变成了先读后写。同时,副作用的存在也会影响自动微分,因为反向部分需要从前向部分获取中间变量,需要确保该中间变量的正确。因此需要保证自动微分的函数是纯函数。
|
||||
|
||||
由于Python语言具有高度动态性的特点,纯函数式编程对用户使用上有一些编程限制。有些机器学习框架的自动微分功能只支持对纯函数求导,且要求用户自行保证这一点。如果用户代码中写了带有副作用的函数,那么求导的结果可能会不符合预期。MindIR支持副作用的表达,能够将副作用的表达转换为纯函数的表达,从而在保持ANF函数式语义不变的同时,确保执行顺序的正确性,从而实现自由度更高的自动微分。
|
||||
|
||||
(3)支持闭包表示的(Closure
|
||||
representation)。反向模式的自动微分,需要存储基本操作的中间结果到闭包中,然后再去进行组合连接。所以有一个自然的闭包表示尤为重要。闭包是指代码块和作用域环境的结合,在MindIR中,代码块是以函数图呈现的,而作用域环境可以理解为该函数被调用时的上下文环境。
|
||||
|
||||
(4)强类型的(Strongly
|
||||
typed)。每个节点需要有一个具体的类型,这个对于性能最大化很重要。在机器学习应用中,因为算子可能很耗费时间,所以越早捕获错误越好。因为需要支持函数调用和高阶函数,相比于TensorFlow的数据流图,MindIR的类型和形状推导更加复杂且强大。
|
||||
|
||||
在结合MindSpore框架的自身特点后,MindIR的定义如 :numref:`MindIR`所示。
|
||||
|
||||

|
||||
:width:`1100px`
|
||||
:label:`MindIR`
|
||||
|
||||
接下来我们通过如下的一段程序作为示例,来进一步分析MindIR。
|
||||
|
||||
```python
|
||||
def func(x, y):
|
||||
return x / y
|
||||
|
||||
@ms_function
|
||||
def test_f(x, y):
|
||||
a = x - 1
|
||||
b = a + y
|
||||
c = b * func(a, b)
|
||||
return c
|
||||
```
|
||||
|
||||
该函数对应的ANF表达式为:
|
||||
```
|
||||
lambda (x, y)
|
||||
let a = x - 1 in
|
||||
let b = a + y in
|
||||
let func = lambda (x, y)
|
||||
let ret = x / y in
|
||||
ret end in
|
||||
let %1 = func(a, b) in
|
||||
let c = b * %1 in
|
||||
c end
|
||||
```
|
||||
|
||||
在ANF中,每个表达式都用let表达式绑定为一个变量,通过对变量的引用来表示对表达式输出的依赖,而在MindIR中,每个表达式都绑定为一个节点,通过节点与节点之间的有向边表示依赖关系。该函数对应的MindIR的可视化表示如 :numref:`MindIR_graph`所示。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`MindIR_graph`
|
||||
|
||||
MindIR同时支持静态计算图和动态计算图的构建方式,更好地兼顾了灵活性与高性能。相比传统计算图,MindIR不仅可以表达算子之间的数据依赖,还可以表达丰富的函数式语义,具备更自然的自动微分实现方式。MindIR原生支持闭包,并且支持高阶函数的表达。在处理控制流时,MindIR将控制流转换为高阶函数的数据流,不仅支持数据流的自动微分,还支持条件跳转、循环和递归等控制流的自动微分,从而提升MindSpore的自动微分能力。
|
||||
|
||||
在JIT即时编译方面,MindIR采用了基于图表示的形式,将控制流和数据流合一,支持更高效的JIT优化。在编译优化方面,MindIR引入优化器对计算图进行优化,采用前端-优化器-后端的三段式表达形式,支持硬件无关的优化(如类型推导、表达式化简等)、硬件相关的优化(如自动并行、内存优化、图算融合、流水线执行等)以及部署推理相关的优化(如量化、剪枝等),显著提升了MindSpore的编译执行能力。
|
||||
27
website/chapter_frontend_and_ir/overview_of_frontend.md
Normal file
27
website/chapter_frontend_and_ir/overview_of_frontend.md
Normal file
@@ -0,0 +1,27 @@
|
||||
AI编译器前端技术概述
|
||||
----
|
||||
|
||||
:numref:`compiler_frontend_struc`展示了机器学习编译器前端的基础结构。其中,对源程序的解析过程与传统编译器是大致相同的,本章节不对这部分进行更细致的讨论。机器学习框架的编译器前端的独特之处主要在于对自动微分功能的支持。为了满足自动微分功能带来的新需求,机器学习框架需要在传统中间表示的基础上设计新的中间表示结构。因此,本章节的介绍重点会放在中间表示和自动微分这两个部分,随后会简要探讨类型系统、静态分析和前端优化等编译器的基础概念。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`compiler_frontend_struc`
|
||||
|
||||
### 中间表示
|
||||
|
||||
中间表示是编译器用于表示源代码的数据结构或代码,是程序编译过程中介于源语言和目标语言之间的程序表示。传统机器学习框架的中间表示分为三大类,分别是线性中间表示,图中间表示以及混合中间表示。然而,传统编译器的中间表示难以完全满足机器学习框架对于中间表示的一系列需求。因此,机器学习框架的开发者在传统中间表示的设计基础上不断扩展,提出了很多适用于机器学习框架的中间表示。
|
||||
|
||||
### 自动微分
|
||||
|
||||
自动微分(Automatic Differentiation,
|
||||
AD)是一种介于符号微分和数值微分之间的针对计算图进行符号解析的求导方法,用于计算函数梯度值。深度学习等现代AI算法通过使用大量数据来学习拟合出一个优化后带参模型,其中使用的学习算法多是基于现实数据在模型中的经验误差,通过梯度下降的方法来更新模型的参数。因此,自动微分在深度学习中处于非常重要的地位,是整个训练算法的核心组件之一。自动微分通常在编译器前端优化中实现,通过对中间表示的符号解析来生成带有梯度函数的中间表示。
|
||||
|
||||
### 类型系统与静态分析
|
||||
|
||||
为了有效减少程序在运行时可能出现的错误,编译器的前端引入了类型系统(Type
|
||||
System)和静态分析(Static
|
||||
Analysis)系统。类型系统可以防止程序在运行时发生类型错误,而静态分析能够为编译优化提供线索和信息,有效减少代码中存在的结构性错误、安全漏洞等问题。
|
||||
|
||||
### 前端编译优化
|
||||
|
||||
编译优化意在解决代码的低效性,无论是在传统编译器还是在机器学习框架中都起着很重要的作用。前端的编译优化与硬件无关。
|
||||
30
website/chapter_frontend_and_ir/summary.md
Normal file
30
website/chapter_frontend_and_ir/summary.md
Normal file
@@ -0,0 +1,30 @@
|
||||
## 总结
|
||||
|
||||
- 中间表示是编译器的核心数据结构之一,是程序编译过程中介于源语言和目标语言之间的程序表示。
|
||||
|
||||
- 传统编译器的中间表示从组织结构出发,可以分为线性中间表示,图中间表示以及混合中间表示。
|
||||
|
||||
- 机器学习框架对中间表示有一系列新的需求,这些新的需求是传统中间表示所不能完美支持的。因此需要在传统中间表示的基础上扩展新的,更适用于机器学习框架的中间表示。
|
||||
|
||||
- 自动微分的基本思想是将计算机程序中的运算操作分解为一个有限的基本操作集合,且集合中基本操作的求导规则均为已知,在完成每一个基本操作的求导后,使用链式法则将结果组合得到整体程序的求导结果。
|
||||
|
||||
- 自动微分根据链式法则的组合顺序,可以分为前向自动微分与反向自动微分。
|
||||
|
||||
- 前向自动微分更适用于对输入维度小于输出维度的网络求导,反向自动微分则更适用于对输出维度小于输入维度的网络求导。
|
||||
|
||||
- 自动微分的实现方法大体上可以划分为基本表达式法、操作符重载法以及代码变化法。
|
||||
|
||||
- 类型系统是指类型的集合以及使用类型来规定程序行为的规则,用于定义不同的类型,指定类型的操作和类型之间的相互作用,广泛应用于编译器、解释器和静态检查工具中。
|
||||
|
||||
- 静态分析,是指在不实际运行程序的情况下,通过词法分析、语法分析、控制流、数据流分析等技术对代码进行分析验证的技术
|
||||
|
||||
- 编译优化意在解决编译生成的中间表示的低效性,前端执行的均为与硬件无关的编译优化。
|
||||
|
||||
|
||||
## 扩展阅读
|
||||
|
||||
- 一种基于图的中间表示类型: [综述](https://dl.acm.org/doi/10.1145/202530.202534)
|
||||
|
||||
- 机器学习框架中的自动微分: [综述](https://arxiv.org/abs/1502.05767)
|
||||
|
||||
- 函数式框架中的反向自动微分: [综述](https://dl.acm.org/doi/10.1145/1330017.1330018)
|
||||
@@ -0,0 +1,39 @@
|
||||
类型系统和静态分析
|
||||
------------------
|
||||
|
||||
上一章节介绍了自动微分的基本概念和实现方法,自动微分是机器学习框架中不可或缺的核心功能。在编译器前端的设计中,为了提高编译器的抽象能力和程序运行的正确性,有效减少程序在运行时可能出现的错误,编译器引入了类型系统和静态分析系统,接下来将对它们的基本概念、主要功能、常见系统进行介绍。
|
||||
|
||||
### 类型系统概述
|
||||
|
||||
程序设计语言中,类型是指数值、表达式、函数等属性内容。类型系统是指类型的集合以及使用类型来规定程序行为的规则。类型系统用于定义不同的类型,指定类型的操作和类型之间的相互作用,广泛应用于编译器、解释器和静态检查工具中。类型系统提供的主要功能有:
|
||||
|
||||
1)正确性。编译器的类型系统引入了类型检查技术,用于检测和避免运行时错误,确保程序运行时的安全性。通过类型推导与检查,编译器能够捕获大多数类型相关的异常报错,避免执行病态程序导致运行时错误,保证内存安全,避免类型间的无效计算和语义上的逻辑错误。
|
||||
|
||||
2)优化。静态类型检查可以提供有用的信息给编译器,从而使得编译器可以应用更有效的指令,节省运行时的时间。
|
||||
|
||||
3)抽象。在安全的前提下,一个强大的类型系统的标准是抽象能力。通过合理设计抽象,开发者可以更关注更高层次的设计。
|
||||
|
||||
4)可读性。阅读代码时,明确的类型声明有助于理解程序代码。
|
||||
|
||||
机器学习框架一般使用Python语言作为描述网络模型结构的前端语言。Python语言是一门动态强类型的语言,入门简单易学习,开发代码简洁高效,但由于其解释执行的方式,运行速度往往较慢。Python前端语言给用户带来了动态灵活的语义和高效的开发效率,但是若想要生成运行高效的后端代码,后端框架需要优化友好的静态强类型中间表示。因此,需要一种高效可靠的静态分析方法作为桥梁,将Python前端表示转换成等价的静态强类型中间表示,以此给用户同时带来高效的开发效率和运行效率,例如Hindley--Milner(HM)类型系统。这是一种具有参数多态性的简单类型lambda演算的类型系统。它最初由J.
|
||||
Roger Hindley 提出 :cite:`1969The`,并由Robin Milner
|
||||
进行扩展和验证 :cite:`1978A` 。后来,路易斯·达马斯(Luis
|
||||
Damas)对HM类型推导方法进行了详尽的分析和证明 :cite:`1982Principal`,并将其扩展到支持具有多态引用的系统。Hindley--Milner类型系统的目标是在没有给定类型注解的情况下,自动推导出任意表达式的类型。其算法具有抽象性和通用性,采用简洁的符号表示,能够根据表达式形式推导出明确直观的定义,常用于类型推导和类型检查。因此,Hindley--Milner类型系统广泛应用于编程语言设计中,比如Haskell和Ocaml。
|
||||
|
||||
### 静态分析概述
|
||||
|
||||
在设计好类型系统后,编译器需要使用静态分析系统来对中间表示进行静态检查与分析。语法解析模块(parser)将程序代码解析为抽象语法树(AST)并生成中间表示。此时的中间表示缺少类型系统中定义的抽象信息,因此引入静态分析模块,对中间表示进行处理分析,并且生成一个静态强类型的中间表示,用于后续的编译优化、自动并行以及自动微分等。在编译器前端的编译过程中,静态分析可能会被执行多次,有些框架还会通过静态分析的结果判断是否终止编译优化。
|
||||
|
||||
静态分析模块基于抽象释义对中间表示进行类型推导、常量传播、泛型特化等操作,这些专业术语的含义分别为:
|
||||
|
||||
抽象释义:通过抽象解释器将语言的实际语义近似为抽象语义,只获取后续优化需要的属性,进行不确定性的解释执行。抽象值一般包括变量的类型和维度。
|
||||
|
||||
类型推导:在抽象释义的基础上,编译器推断出程序中变量或表达式的抽象类型,方便后续利用类型信息进行编译优化。
|
||||
|
||||
泛型特化:泛型特化的前提是编译器在编译期间可以进行类型推导,提供类型的上下文。在编译期间,编译器通过类型推导确定调用函数时的类型,然后,编译器会通过泛型特化,进行类型取代,为每个类型生成一个对应的函数方法。
|
||||
|
||||
接下来以MindSpore框架为例,简要介绍一下静态分析模块的具体实现。MindSpore采用抽象释义的方法,对抽象值做不确定的抽象语义的解释执行,函数图中每个节点的抽象值是所期望得到的程序静态信息。基本的抽象释义方法流程可以理解为,从MindIR的顶层函数图入口开始解释执行,将函数图中所有节点进行拓扑排序,根据节点的语义递归推导各节点的抽象值。当遇到函数子图时,递归进入函数子图进行解释执行,最后返回顶层函数输出节点的抽象值。根据抽象释义方法流程,MindSpore的静态分析模块主要分为抽象域模块、缓存模块、语义推导模块和控制流处理模块。
|
||||
|
||||

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

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

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

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

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

|
||||
: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$;将量化参数代入量化公式中,转换网络中的权重为量化整数值;删除伪量化算子,在量化网络层前后分别插入量化和反量化算子。
|
||||
|
||||
#### 训练后量化
|
||||
|
||||
训练后量化也可以分成两种,权重量化和全量化。权重量化仅量化模型的权重以压缩模型的大小,在推理时将权重反量化为原始的float32数据,后续推理流程与普通的float32模型一致。权重量化的好处是不需要校准数据集,不需要实现量化算子,且模型的精度误差较小,由于实际推理使用的仍然是float32算子,所以推理性能不会提高。全量化不仅会量化模型的权重,还会量化模型的激活值,在模型推理时执行量化算子来加快模型的推理速度。为了量化激活值,需要用户提供一定数量的校准数据集用于统计每一层激活值的分布,并对量化后的算子做校准。校准数据集可以来自训练数据集或者真实场景的输入数据,需要数量通常非常小。在量化激活值时会以校准数据集为输入,执行推理流程然后统计每层激活值的数据分布并得到相应的量化参数,具体的操作流程如下:
|
||||
|
||||
- 使用直方图统计的方式得到原始float32数据的统计分布$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表示偏置。首先需要对量化的均值做校正,对float32算子和量化算子输出的每个通道求平均,假设某个通道i的float32算子输出均值为$a_i$,量化算子反量化输出均值为$a_{qi}$,将这个通道两个均值的差$a_i-a_q$加到对应的通道上即可使得最终的输出均值和float32一致。另外还需要保证量化后的分布和量化前是一致的,设某个通道权重数据的均值、方差为$E(w_c)$、$||w_c-E(w_c)||$,量化后的均值和方差为$E(\hat{w_c})$、$||\hat{w_c}-E(\hat{w_c})||$,对权重如下校正:
|
||||
$$\hat{w_c}\leftarrow\zeta_c(\hat{w_c}+u_c)$$
|
||||
$$u_c=E(w_c)-E(\hat{w_c})$$
|
||||
$$\zeta_c=\frac{||w_c-E(w_c)||}{||\hat{w_c}-E(\hat{w_c})||}$$
|
||||
|
||||
量化方法作为一种通用的模型压缩方法,可以大幅提升神经网络存储和压缩的效率,已经取得了广泛的应用。
|
||||
|
||||
### 模型稀疏
|
||||
|
||||
模型稀疏是通过去除神经网络中部分组件(如权重、特征图、卷积核)降低网络的存储和计算代价,它和模型权重量化、权重共享、池化等方法一样,属于一种为达到降低模型计算复杂度的目标而引入的一种强归纳偏置。
|
||||
|
||||
#### 模型稀疏的动机
|
||||
|
||||
因为卷积神经网络中的卷积计算可以被看作输入数据和卷积核权重的加权线性组合,所以通常绝对值小的权重对输出数据具有相对较小的影响。对模型进行稀疏操作的合理性主要来源于两方面的假设:
|
||||
|
||||
- 其一,针对权重参数来说,当前许多神经网络模型存在过参数化(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:`800px`
|
||||
:label:`ch08-fig-deepcomp`
|
||||
|
||||
除了直接去除冗余的神经元之外,基于字典学习的方法也可以用来去掉深度卷积神经网络中无用的权值。通过学习一系列卷积核的基,可以把原始卷积核变换到系数域上并且它们稀疏。比如,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))$$
|
||||
:eqlabel:`ch08-equ-c2Fcn_distill`
|
||||
|
||||
其中,$\mathcal{H}(\cdot,\cdot)$是交叉熵函数,$o_S$和$o_T$分别是学生网络和教师网络的输出,$\mathbf{y}$是标签。公式 :eqref:`ch08-equ-c2Fcn_distill`中的第一项使得学生神经网络的分类结果接近预期的真实标签,而第二项的目的是提取教师神经网络中的有用信息并传递给学生神经网络,$\lambda$是一个权值参数用来平衡两个目标函数。$\tau(\cdot)$是一个软化(soften)函数,将网络输出变得更加平滑。
|
||||
|
||||
公式 :eqref:`ch08-equ-c2Fcn_distill`仅仅从教师神经网络分类器输出的数据中提取有价值的信息,并没有从其他中间层去将教师神经网络的信息进行挖掘。因此,Romero等人[@FitNet])进一步地开发了一种学习轻型学生神经网络的方法,该算法可以从教师神经网络中任意的一层来传递有用的信息给学生神经网络。此外,事实上,并不是所有的输入数据对卷积神经网络的计算和完成后续的任务都是有用的。例如,在一张包含一个动物的图像中,对分类和识别结果比较重要的是动物所在的区域,而不是那些无用的背景信息。所以,有选择性地从教师神经网络的特征图中提取信息是一个更高效的方式。于是,Zagoruyko和Komodakis([@attentionTS])提出了一种基于感知(Attention)损失函数的学习方法来提升学生神经网络的性能,该方法在学习学生神经网络的过程中,引入了感知模块(Attention),选择性地将教师神经网络中的信息传递给学生神经网络,并帮助其进行训练。感知图用来表达输入图像不同位置对最终分类结果的重要性。感知模块从教师网络生成感知图,并迁移到学生网络,如图
|
||||
|
||||

|
||||
:width:`500px`
|
||||
:label:`ch08-fig-distillation`
|
||||
|
||||
知识蒸馏是一种有效的帮助小网络优化的方法,能够进一步和剪枝、量化等其他压缩方法结合,训练得到精度高、计算量小的高效模型。
|
||||
@@ -0,0 +1,93 @@
|
||||
## 训练模型到推理模型的转换及优化
|
||||
|
||||
### 模型转换
|
||||
|
||||
前面章节提到过,不同的训练框架,如Tensorflow、PyTorch、MindSpore、MXNet、CNTK等,都定义了自己的模型的数据结构,推理系统需要将它们转换到统一的一种数据结构上。开发神经网络交换协议(Open Neural Network Exchange,ONNX)正是为此目的而设计的。ONNX支持广泛的机器学习运算符集合,并提供了不同训练框架的转换器,例如TensorFlow模型到ONNX模型的转换器、PyTorch模型到ONNX模型的转换器等。
|
||||
模型转换本质上是将模型这种结构化的数据,从一种数据结构转换为另一种数据结构的过程。进行模型转换首先要分析两种数据结构的异同点,然后针对结构相同的数据做搬运;对于结构相似的数据做一一映射;对于结构差异较大的数据则需要根据其语义做合理的数据转换;更进一步如果两种数据结构上存在不兼容,则模型转换无法进行。ONNX的一个优势就在于其强大的表达能力,从而大多数业界框架的模型都能够转换到ONNX的模型上来而不存在不兼容的情况。
|
||||
|
||||
模型可以抽象为一种图,从而模型的数据结构可以解构为以下两个要点:
|
||||
|
||||
- 模型拓扑连接:从图的角度来说,就是图的边;从AI模型的角度来说,就是AI模型中的数据流和控制流等。模型数据流和控制流的定义又可以引申出子图的表达形式、模型输入输出的表达形式、控制流结构的表达形式等。比如Tensorflow1.x中的控制流表达为一种有环图,通过Enter、Exit、Switch、LoopCond、NextIteration等算子来解决成环,而ONNX通过Loop,If等算子来表达控制流,从而避免引入了有环,所以在将Tensorflow1.x的控制流模型转化为ONNX模型时,需要将Tensorflow模型中的控制流图结构融合成ONNX的While或者If算子。
|
||||
|
||||
- 算子原型定义:从图的角度来说,就是图的顶点;从AI模型角度来说,就是AI模型中的数据处理节点或者控制流节点。算子原型包括但不限于算子类型、算子输入输出的定义、算子属性的定义等。比如Caffe的slice算子和ONNX的slice算子的语义其实是不一致的,Caffe的slice算子应该映射到ONNX的Split算子,所以在将Caffe模型转换成ONNX模型时,需要将Caffe的Slice算子映射到ONNX的Split算子。比如Tensorflow中的中的FusedBatchNorm算子在Caffe中找不到相同语义的算子,需要将Caffe的BatchNorm算子和Scale算子组合起来才能表达相同的语义。
|
||||
|
||||
在完成模型转换之后,通常地,框架会将一些不依赖于输入的工作提前去完成。这些工作包括了如常量折叠、算子融合、算子替换、算子重排等一些优化手段。这些优化手段的概念在前面的章节其实已经提及到,比如在编译器前端阶段,通常也会做常量折叠;在编译器后端阶段,通常会根据后端的硬件支持程度,对算子进行融合和拆分。但是有些优化工作只有在部署阶段才能进行或者彻底进行。
|
||||
|
||||
### 算子融合
|
||||
:label:`ch08-sec-fusion`
|
||||
|
||||
算子融合,就是将深度神经网络模型中的多个算子,按照一定的规则,合并成一个新的算子。通过算子融合,可以减少模型在线推理时的计算量、访存开销,从而降低推理时的时延和功耗。
|
||||
|
||||

|
||||
:width:`150px`
|
||||
:label:`ch08-fig-storage`
|
||||
|
||||
算子融合带来的性能上的收益主要来自两个方面,一是通过融合,充分利用寄存器和缓存,避免多个算子运算时,数据在CPU和内存之间的存储和读取的耗时。如 :numref:`ch08-fig-storage`,可以看到计算机的储存系统,从最靠近cpu的寄存器L1(Level1)、L2(Level2)等多级缓存,到内存、硬盘,其存储的容量越来越大,但读取数据的耗时也越来越大。融合后,前一次计算的结果可以先暂存在CPU的寄存器或者缓存中,下一次计算直接从寄存器或者缓存中读取,减少了内存读写的IO次数。二是通过融合,可以将一些计算量提前完成,避免了前向推理时的冗余计算或者循环冗余计算。
|
||||
|
||||

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

|
||||
:width:`500px`
|
||||
:label:`ch08-fig-bn_replace`
|
||||
|
||||
如 :numref:`ch08-fig-bn_replace`,我们以Batchnorm算子替换成Scale算子为例,阐述算子替换的原理。我们直接将Batchnorm的计算公式 :eqref:`ch08-equ-bn_equation`进行分解,并将常量合并简化,Batchnorm的计算公式可以写成:
|
||||
|
||||
$$\pmb{Y_{bn}}=scale*\pmb{X_{bn}}+offset$$
|
||||
:eqlabel:`ch08-equ-replace_scale`
|
||||
|
||||
其中scale和offset为两个标量。可以看到,计算公式简化后,可以将其映射到一个Scale算子。
|
||||
|
||||
在Batchnorm算子被替换为Scale算子的前后,网络中的参数量、计算量都减少了,该算子替换策略可以优化模型部署时的功耗和性能。同理,该算子替换优化策略只能在部署阶段才能进行,因为一方面在部署阶段Batchnorm计算公式中被认为是常量的符号,在训练时是参数并非常量。另一方面该优化策略会降低模型的参数量,改变模型的结构,降低模型的表达能力,影响训练收敛时模型的准确率。
|
||||
|
||||
### 算子重排
|
||||
|
||||
算子重排是指将模型中算子的拓扑序按照某些规则进行重新排布,在不降低模型的推理精度的前提下,降低模型推理的计算量。常用的算子重排技术有针对于Slice算子、StrideSlice算子、Crop算子等裁切类算子的前移、Reshape算子和Transpose算子的重排、BinaryOp算子的重排等。
|
||||
|
||||

|
||||
:width:`500px`
|
||||
:label:`ch08-fig-crop_reorder`
|
||||
|
||||
如 :numref:`ch08-fig-crop_reorder`,Crop算子是从输入的特征图中裁取一部分作为输出,经过Crop算子后,特征图的大小就降低了。如果将这个裁切的过程前移,提前对特征图进行裁切,那么后续算子的计算量也会相应地减少,从而提高模型部署时的推理性能。Crop算子前移带来的性能提升跟Crop算子的参数有关。但是Crop算子一般只能沿着的element wise类算子前移。
|
||||
|
||||
通过前面的实验数据可以看到,通过推理前的模型优化,可以为推理的时延、功耗、内存占用带来极大的收益。
|
||||
@@ -0,0 +1,31 @@
|
||||
## 概述
|
||||
|
||||
模型完成训练后,需要将模型及参数持久化成文件,不同的训练框架导出的模型文件中存储的数据结构不同,这给模型的推理系统带来了不便。推理系统为了支持不同的训练框架的模型,需要将模型文件中的数据转换成统一的数据结构。此外,在训练模型转换成推理模型的过程中,需要进行一些如算子融合、常量折叠等模型的优化以提升推理的性能。
|
||||
|
||||
推理模型部署到不同的场景,需要满足不同的硬件设备的限制,例如,在具有强大算力的计算中心或数据中心的服务器上可以部署大规模的模型,而在边缘侧服务器、个人电脑以及智能手机上算力和内存则相对有限,部署的模型的规模就相应地要降低。在超低功耗的微控制器上,则只能部署非常简单的机器学习模型。此外,不同硬件对于不同数据类型(如float32、float16、bfloat16、int8等)的支持程度也不相同。为了满足这些硬件的限制,在有些场景下需要对训练好的模型进行压缩,降低模型的复杂度或者数据的精度,减少模型的参数,以适应硬件的限制。
|
||||
|
||||
模型部署到运行环境中执行推理,推理的时延、内存占用、功耗等是影响用户使用的关键因素,优化模型推理的方式有两种,一是设计专有的机器学习的芯片,相对于通用的计算芯片,这些专有芯片一般在能效比上具有很大的优势。二是通过软硬协同最大程度地发挥硬件的能力。对于第二种方式,以CPU为例,如何切分数据块以满足cache大小,如何对数据进行重排以便计算时可以连续访问,如何减少计算时的数据依赖以提升硬件流水线的并行,如何使用扩展指令集以提升计算性能,这些都需要针对不同的CPU架构进行设计和优化。
|
||||
|
||||
对于一个企业来讲,模型是属于重要的资产,因此,在模型部署到运行环境以后,保护模型的安全至关重要。本章节会介绍如模型混淆等一些常见的机器学习模型的安全保护手段。
|
||||
|
||||
针对上述模型部署时的挑战,业界有一些常见的方法:
|
||||
|
||||
- **算子融合**
|
||||
|
||||
通过表达式简化、属性融合等方式将多个算子合并为一个算子的技术,融合可以降低模型的计算复杂度及模型的体积。
|
||||
|
||||
- **常量折叠**
|
||||
|
||||
将符合条件的算子在离线阶段提前完成前向计算,从而降低模型的计算复杂度和模型的体积。常量折叠的条件是算子的所有输入在离线阶段均为常量。
|
||||
|
||||
- **模型压缩**
|
||||
|
||||
通过量化、剪枝等手段减小模型体积以及计算复杂度的技术,可以分为需要重训的压缩技术和不需要重训的压缩技术两类。
|
||||
|
||||
- **数据排布**
|
||||
|
||||
根据后端算子库支持程度和硬件限制,搜索网络中每层的最优数据排布格式,并进行数据重排或者插入数据重排算子,从而降低部署时的推理时延
|
||||
|
||||
- **模型混淆**
|
||||
|
||||
对训练好的模型进行混淆操作,主要包括新增网络节点和分支、替换算子名的操作,攻击者即使窃取到混淆后的模型也不能理解原模型的结构。此外,混淆后的模型可以直接在部署环境中以混淆态执行,保证了模型在运行过程中的安全性。
|
||||
172
website/chapter_model_deployment/model_inference.md
Normal file
172
website/chapter_model_deployment/model_inference.md
Normal file
@@ -0,0 +1,172 @@
|
||||
## 模型推理
|
||||
|
||||
训练模型经过前面的转换、压缩等流程后,需要部署在计算硬件上进行推理。执行推理主要包含以下步骤:
|
||||
|
||||
- 前处理:将原始数据处理成适合网络输入的数据。
|
||||
- 执行推理:将离线转换得到的模型部署到设备上执行推理流程,根据输入数据计算得到输出数据。
|
||||
- 后处理:模型的输出结果做进一步的加工处理,如筛选阈值。
|
||||
|
||||
### 前处理与后处理
|
||||
|
||||
#### 前处理
|
||||
|
||||
前处理主要完成数据预处理,在现实问题中,原始数据往往非常混乱,机器学习模型无法识别并从中提取信息。数据预处理的目的是将原始数据例如图片、语音、文本等,处理成适合网络输入的tensor数据,并消除其中无关的信息,恢复有用的真实信息,增强有关信息的可检测性,最大限度地简化数据,从而改进模型的特征抽取、图像分割、匹配和识别等可靠性。
|
||||
|
||||
常见的数据预处理手段有:
|
||||
|
||||
- 特征编码:将描述特征的原始数据编码成数字,输入给机器学习模型,因为它们只能处理数字数据。常见的编码方法有:离散化、序号编码、One-hot编码,二进制编码等;
|
||||
|
||||
- 数据归一化:修改数据的值使其达到共同的标度但不改变它们之间的相关性,消除数据指标之间的量纲影响。常用的技术有:Min-Max归一化将数据缩放到给定范围,Z-score归一化使数据符合正态分布;
|
||||
|
||||
- 处理离群值: 离群值是与数据中的其他值保持一定距离的数据点,适当地排除离群值可以提升模型的准确性。
|
||||
|
||||
#### 后处理
|
||||
|
||||
通常,模型推理结束后,需要把推理的输出数据传递给用户完成后处理,常见的数据后处理手段有:
|
||||
|
||||
- 连续数据离散化:模型实际用于预测离散数据,例如商品数量时,用回归模型预测得到的是连续值,需要四取五入、取上下限阈值等得到实际结果;
|
||||
|
||||
- 数据可视化:将数据图形化、表格化,便于找到数据之间的关系,来决定下一步的分析策略;
|
||||
|
||||
- 手动拉宽预测范围:回归模型往往预测不出很大或很小的值,结果都集中在中部区域。例如医院的化验数据,通常是要根据异常值诊断疾病。手动拉宽预测范围,将偏离正常范围的值乘一个系数,可以放大两侧的数据,得到更准确的预测结果。
|
||||
|
||||
### 并行计算
|
||||
:label:`ch08-sec-parallel_inference`
|
||||
|
||||
为提升推理的性能,需要重复利用多核的能力,所以一般推理框架会引入多线程机制。主要的思路是将算子的输入数据进行切分,通过多线程去执行不同数据切片,实现算子并行计算,从而成倍提升算子计算性能。
|
||||
|
||||

|
||||
: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等。
|
||||
|
||||

|
||||
: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的值上。
|
||||
|
||||

|
||||
: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模型的推理时间主要耗费在卷积、矩阵乘算子的计算上,占到了整网百分之九十甚至更多的时间。本小节主要介绍卷积算子算法方面的优化手段,可以应用到各种硬件设备上。
|
||||
|
||||
卷积的计算可以转换为两个矩阵相乘,在前述 :numref:`ch08-sec-parallel_inference`小节中,已经详细介绍了矩阵乘运算的优化。对于不同的硬件,确定合适的矩阵分块,优化数据访存与指令并行,可以最大限度的发挥硬件的算力,提升推理性能。
|
||||
|
||||
1.Img2col
|
||||
|
||||
将卷积的计算转换为矩阵乘,一般采用Img2col的方法实现。在常见的神经网络中,卷积的输入通常都是4维的,默认采用的数据排布方式为NHWC,如图 :numref:`ch08-fig-conv_nhwc`所示,是一个卷积示意图。输入维度为(1,IH,IW,IC),卷积核维度为(OC,KH,KW,IC),输出维度为(1,OH,OW,OC)。
|
||||
|
||||

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

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

|
||||
:width:`600px`
|
||||
:label:`ch08-fig-img2col_weight`
|
||||
|
||||
2.Winograd算法
|
||||
|
||||
卷积计算归根到底是矩阵乘法,两个二维矩阵相乘的时间复杂度是$O(n^3)$。Winograd算法可以降低矩阵乘法的复杂度。
|
||||
|
||||
以一维卷积运算为例,记为\textit{\textbf{F}}($m$,$r$),其中,$m$代表输出的个数,$r$为卷积核的个数。输入为$\textit{\textbf{d}}=[d_0 \ d_1 \ d_2 \ d_3]$,卷积核为$g=[g_0 \ g_1 \ g_2]^{\rm T}$,该卷积计算可以写成矩阵形式如式 :eqref:`ch08-equ-conv_matmul_one_dimension`所示,需要6次乘法和4次加法。
|
||||
|
||||
$$F(2, 3)=\left[ \begin{matrix} d_0 & d_1 & d_2 \\ d_1 & d_2 & d_3 \end{matrix} \right] \left[ \begin{matrix} g_0 \\ g_1 \\ g_2 \end{matrix} \right]=\left[ \begin{matrix} y_0 \\ y_1 \end{matrix} \right]$$
|
||||
:eqlabel:`ch08-equ-conv_matmul_one_dimension`
|
||||
|
||||
可以观察到,卷积运算转换为矩阵乘法时输入矩阵中存在着重复元素$d_1$和$d_2$,因此,卷积转换的矩阵乘法相对一般的矩阵乘有了优化空间。可以通过计算中间变量$m_0-m_3$得到矩阵乘的结果,见式 :eqref:`ch08-equ-conv-2-winograd`:
|
||||
|
||||
$$F(2, 3)=\left[ \begin{matrix} d_0 & d_1 & d_2 \\ d_1 & d_2 & d_3 \end{matrix} \right] \left[ \begin{matrix} g_0 \\ g_1 \\ g_2 \end{matrix} \right]=\left[ \begin{matrix} m_0+m_1+m_2 \\ m_1-m_2+m_3 \end{matrix} \right]$$
|
||||
:eqlabel:`ch08-equ-conv-2-winograd`
|
||||
|
||||
其中,$m_0-m_3$的分别见公式 :eqref:`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}$$
|
||||
:eqlabel:`ch08-equ-winograd-param`
|
||||
|
||||
通过$m_0-m_3$间接计算r1,r2,需要的运算次数包括:输入$d$的4次加法;输出$m$的4次乘法和4次加法。在推理阶段,权重的数值是常量,因此卷积核上的运算可以在图编译阶段计算,不计入在线的run时间。所以总的运算次数为4次乘法和8次加法,与直接运算的6次乘法和4次加法相比,乘法次数减少,加法次数增加。在计算机中,乘法一般比加法慢,通过减少乘法次数,增加少量加法,可以实现加速。
|
||||
|
||||
计算过程写成矩阵形式如式 :eqref:`ch08-equ-winograd-matrix`所示,其中,⊙为对应位置相乘,A、B、G都是常量矩阵。这里写成矩阵计算是为了表达清晰,实际使用时,按照公式 :eqref:`ch08-equ-winograd-param`手写展开的计算速度更快。
|
||||
|
||||
|
||||
$$\mathbf{Y}=\mathbf{A^T}(\mathbf{G}g)*(\mathbf{B^T}d)$$
|
||||
:eqlabel:`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]$$
|
||||
:eqlabel:`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]$$
|
||||
:eqlabel:`ch08-equ-winograd-matrix-g`
|
||||
|
||||
$$\mathbf{A^T}=\left[ \begin{matrix} 1 & 1 & -1 & 0 \\ 0 & 1 & -1 & -1 \end{matrix} \right] \\$$
|
||||
:eqlabel:`ch08-equ-winograd-matrix-at`
|
||||
|
||||
|
||||
通常深度学习领域通常使用的都是2D卷积,将F(2,3)扩展到F(2x2,3x3),可以写成矩阵形式,如公式 :eqref:`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}$$
|
||||
:eqlabel:`ch08-equ-winograd-two-dimension-matrix`
|
||||
|
||||
Winograd算法的整个计算过程在逻辑上可以分为4步,如 :numref:`ch08-fig-winograd`所示:
|
||||
|
||||

|
||||
:width:`500px`
|
||||
:label:`ch08-fig-winograd`
|
||||
|
||||
针对任意的输出大小,要使用\textit{\textbf{F}}(2$\times$2,3$\times$3)的Winograd算法,需要将输出切分成2$\times$2的块,找到对应的输入,按照上述的四个步骤,就可以求出对应的输出值。当然,Winograd算法并不局限于求解\textit{\textbf{F}}(2$\times$2,3$\times$3),针对任意的\textit{\textbf{F}}($m$$\times$$m$,$r$$\times$$r$),都可以找到适当的常量矩阵\textit{\textbf{A}}、\textit{\textbf{B}}、\textit{\textbf{G}},通过间接计算的方式减少乘法次数。但是随着$m$、$r$的增大,输入、输出涉及的加法以及常量权重的乘法次数都在增加,那么乘法次数带来的计算量下降会被加法和常量乘法所抵消。因此,在实际使用场景中,还需要根据Winograd的实际收益来选择。
|
||||
|
||||
本小节主要介绍了模型推理时的数据处理和性能优化手段。选择合适的数据处理方法,可以更好地提取输入特征,处理输出结果。并行计算以及算子级别的硬件指令与算法优化可以最大限度的发挥硬件的算力。除此之后,内存的占用及访问速率也是影响推理性能的重要因素,因此推理时需要设计合理的内存复用策略,内存复用的策略已经在编译器后端章节已经做了阐述。
|
||||
57
website/chapter_model_deployment/model_security.md
Normal file
57
website/chapter_model_deployment/model_security.md
Normal file
@@ -0,0 +1,57 @@
|
||||
## 模型的安全保护
|
||||
AI服务提供商在本地完成模型训练和调优后,将模型部署到第三方外包平台上(如终端设备、边缘设备和云服务器)来提供推理服务。由于AI模型的设计和训练需要投入大量时间、数据和算力,如何保护模型的知识产权(包括模型结构和参数等信息),防止模型在部署过程中的传输、存储以及运行环节被窃取,已经成为服务/模型提供商最为关心的问题之一。
|
||||
|
||||
### 概述
|
||||
模型的安全保护可以分为静态保护和动态保护两个方面。静态保护指的是模型在传输和存储时的保护,目前业界普遍采用的是基于文件加密的模型保护方案,AI模型文件以密文形态传输和存储,执行推理前在内存中解密。在整个推理过程中,模型在内存中始终是明文的,存在被敌手从内存中转储的风险。动态保护指的是模型在运行时的保护,目前业界已有的模型运行时保护方案主要有以下三个技术路线:一是基于TEE(Trusted Execution Environment)的模型保护方案,TEE通常指的是通过可信硬件隔离出来的一个“安全区”,AI模型文件在非安全区加密存储和传输,在安全区中解密运行。该方案在CPU上的推理时延较小,但依赖特定可信硬件,有一定的部署难度。此外,受硬件资源约束,难以保护大规模深度模型,且目前仍无法有效支持异构硬件加速。二是基于密态计算的保护方案,该方案基于密码学方法(如同态加密、多方安全计算等),保证模型在传输、存储和运行过程中始终保持密文状态。该方案不依赖特定硬件,但面临非常大的计算或通信开销问题,且无法保护模型结构信息。三是基于混淆的模型保护方案,该方案主要通过对模型的计算逻辑进行加扰,使得敌手即使能获取到模型也无法理解。与前两种技术路线相比,该方案仅带来较小的性能开销,且精度损失很低,同时,不依赖特定硬件,可支持大模型的保护。下面将重点介绍基于混淆的模型保护技术。
|
||||
|
||||
### 模型混淆
|
||||
模型混淆技术可以自动混淆明文AI模型的计算逻辑,使得攻击者即使在传输和存储时获取到模型也无法理解;且支持模型混淆态执行,保证模型运行时的机密性。同时不影响模型原本的推理结果、仅带来较小的推理性能开销。模型混淆技术主要包含以下几个步骤:
|
||||
|
||||

|
||||
:width:`400px`
|
||||
:label:`ch08-fig-model_obfuscate`
|
||||
|
||||
结合 :numref:`ch08-fig-model_obfuscate`,详细阐述模型混淆的执行步骤:
|
||||
|
||||
(1) 解析模型并获取计算图
|
||||
|
||||
对于一个训练好的模型,首先根据模型结构解析模型文件并获取模型计算逻辑的图表达(计算图)用于后续操作。获取的计算图包括节点标识、节点算子类型、节点参数权重以及网络结构等信息。
|
||||
|
||||
(2) 对计算图的网络结构加扰
|
||||
|
||||
通过图压缩和图增广等技术,对计算图中节点与节点之间的依赖关系进行加扰,达到隐藏模型真实计算逻辑的效果。其中,图压缩通过整图检查来匹配原网络中的关键子图结构,这些子图会压缩并替换为单个新的计算节点。对于压缩后的计算图,图增广通过在网络结构中加入新的输入/输出边,进一步隐藏节点间的真实依赖关系。新增的输入/输出边可以来自/指向图中现已有的节点,也可以来自/指向本步骤新增的混淆节点。
|
||||
|
||||
> 加扰是指在计算图中添加扰动,来达到模型混淆的目的,常用的加扰手段有添加冗余的节点和边、融合部分子图等等。
|
||||
|
||||
(3) 对计算图的节点匿名化
|
||||
|
||||
遍历步骤(2)处理后的计算图,筛选出需要保护的节点。对于图中的每个需要保护的节点,将节点标识、节点算子类型以及其他能够描述节点计算逻辑的属性替换为无语义信息的符号。对于节点标识匿名化,本步骤保证匿名化后的节点标识仍然是唯一的,以区分不同的节点。对于算子类型匿名化,为了避免大规模计算图匿名化导致的算子类型爆炸问题,可以将计算图中算子类型相同的节点划分为若干不相交的集合,同一个集合中节点的算子类型替换为相同的匿名符号。步骤(5)将保证节点匿名化后,模型仍然是可被识别和执行的。
|
||||
|
||||
(4) 对计算图的参数权重加扰
|
||||
|
||||
对于每个需要保护的权重,通过一个随机噪声和映射函数对权重进行加扰。每个权重加扰时可以使用不同的随机噪声和映射函数,步骤(6)将保证权重加扰不会影响模型执行结果的正确性。将经过步骤(2)(3)(4)处理后的计算图保存为模型文件供后续使用。
|
||||
|
||||
(5) 算子接口变换
|
||||
|
||||
步骤(5)(6)将对每个需要保护的算子类型进行算子形态变换,生成若干候选混淆算子。原算子与混淆算子之间是一对多的对应关系,候选混淆算子的数量等于步骤(3)划分的节点集合的数量。
|
||||
本步骤根据步骤(2)(3)(4)的得到的匿名化算子类型、算子输入/输出关系等信息,对相应算子的接口进行变换。算子接口的变换方式包括但不局限于输入输出变换、接口名称变换。其中,输入输出变换通过修改原算子的输入输出数据,使得生成的混淆算子与原算子的接口形态不同。新增的输入输出数据包括步骤(2)图增广新增的节点间数据依赖和步骤(4)权重混淆引入的随机噪声。接口名称变换将原算子名称替换为步骤(3)生成的匿名化算子名称,保证节点匿名化后的模型仍然是可被识别和执行的,且算子的名称不会泄露其计算逻辑。
|
||||
|
||||
(6) 算子实现变换
|
||||
|
||||
对算子的代码实现进行变换。代码实现的变换方式包括但不局限于字符串加密、冗余代码等软件代码混淆技术,保证混淆算子与原算子实现语义相同的计算逻辑,但是难以阅读和理解。不同的算子可以采用不同代码混淆技术的组合进行代码变换。除代码等价变形之外,混淆算子还实现了一些额外的计算逻辑,如对于步骤(4)中参数被加扰的算子,混淆算子也实现了权重加扰的逆映射函数,用于在算子执行过程中动态消除噪声扰动,保证混淆后模型的计算结果与原模型一致。将生成的混淆算子保存为库文件供后续使用。
|
||||
|
||||
(7) 部署模型和算子库
|
||||
|
||||
将混淆态模型文件以及相应的混淆算子库文件部署到目标设备上。
|
||||
|
||||
(8) 混淆模型加载
|
||||
|
||||
根据模型结构解析混淆态模型文件并获取模型计算逻辑的图表达,即经过步骤(2)(3)(4)处理后得到的混淆计算图。
|
||||
|
||||
(9) 计算图初始化
|
||||
|
||||
对计算图进行初始化,生成执行任务序列。根据安全配置选项,若需要保护模型运行时安全,则直接对混淆计算图进行初始化,生成执行任务序列,序列中的每个计算单元对应一个混淆算子或原算子的执行。若仅需保护模型传输和存储时安全,则可先将内存中的混淆计算图恢复为原计算图,然后对原计算图进行初始化,生成执行任务序列,序列中的每个单元对应一个原算子的执行,这样可以进一步降低推理时的性能开销。
|
||||
|
||||
(10) 推理任务执行
|
||||
|
||||
根据AI应用程序输入的推理数据,遍历执行任务序列中的每个计算单元,得到推理结果。若当前计算单元对应的算子是混淆算子时,调用混淆算子库;否则,调用原算子库。
|
||||
27
website/chapter_model_deployment/summary.md
Normal file
27
website/chapter_model_deployment/summary.md
Normal file
@@ -0,0 +1,27 @@
|
||||
## 总结
|
||||
|
||||
- 不同的模型部署场景下,通常对于模型大小、运行时内存占用、推理时延和推理功耗等指标有限制。
|
||||
|
||||
- 针对模型大小指标,通常在离线阶段通过模型压缩技术来优化,比如量化技术、剪枝技术、知识蒸馏技术等,除此之外,一部分模型优化技术,比如融合技术等,也有助于模型轻量化,不过其效果比较微弱。
|
||||
|
||||
- 针对运行时内存指标,主要有三方面的优化:优化模型大小、优化部署框架包大小以及优化运行时临时内存。模型大小的优化手段在上一点中已经说明;部署框架包大小主要通过精简框架代码、框架代码模块化等方式来优化。运行时临时内存主要通过内存池实现内存之间的复用来优化。
|
||||
|
||||
- 针对模型的推理时延指标,主要有两方面的优化,一方面是离线时通过模型优化技术和模型压缩技术尽可能降低模型推理所需的计算量;另一方面是通过加大推理的并行力度和优化算子实现来充分挖掘硬件的计算潜力。值得注意的是,除了考虑计算量和算力,推理时的访存开销也是一个重要的影响因素。
|
||||
|
||||
- 针对模型的推理功耗,主要的优化思路是降低模型的计算量,这与针对模型推理时延的优化手段有重合之处,可以参考离线的模型优化技术和模型压缩技术。
|
||||
|
||||
- 本章除了介绍优化模型部署的各方面指标的优化技术以外,还介绍了安全部署相关的技术,如模型混淆、模型加密等。部署安全一方面可以保护企业的重要资产,另一方面可以防止黑客通过篡改模型从而入侵攻击部署环境。
|
||||
|
||||
## 扩展阅读
|
||||
|
||||
- Google量化白皮书 [量化](https://arxiv.org/abs/1806.08342)
|
||||
|
||||
- 诺亚高精度剪枝算法 [剪枝](https://arxiv.org/abs/2010.10732)
|
||||
|
||||
- 针对多核处理器的自动图并行调度框架 [性能优化](https://proceedings.mlsys.org/paper/2021/file/a5e00132373a7031000fd987a3c9f87b-Paper.pdf)
|
||||
|
||||
- 诺亚量子启发的低比特量化算法 [量化](https://arxiv.org/abs/2009.08695)
|
||||
|
||||
- 诺亚GhostNet极简骨干网络 [网络结构替换](https://arxiv.org/abs/1911.11907)
|
||||
|
||||
- 诺亚加法神经网络 [网络结构替换](https://arxiv.org/abs/1912.13200)
|
||||
51
website/chapter_preface/index.md
Normal file
51
website/chapter_preface/index.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# 前言
|
||||
|
||||
## 缘起
|
||||
|
||||
我在2020年来到了爱丁堡大学信息学院,爱丁堡大学是AI(Artificial Intelligence, 人工智能)研究的发源地之一,很多学生慕名而来学习机器学习技术。爱丁堡大学拥有许多出色的机器学习课程(如自然语言处理、计算机视觉、计算神经学等),同时也拥有一系列关于计算机系统的基础课程(如操作系统、编程语言、编译器、计算机体系架构等)。但是当我在教学的过程中问起学生:机器学习是如何利用计算机系统实现计算加速和部署?许多学生会投来疑惑的眼神。这促使我思考在爱丁堡大学乃至于其他世界顶尖大学的教学大纲中,是不是缺一门衔接机器学习和计算机系统的课程。
|
||||
|
||||
我的第一反应是基于一门已有的课程来进行拓展。那时,加州大学伯克利分校的AI Systems(人工智能系统)课程较为知名。这门课描述了机器学习系统的不同研究方向,内容以研读论文为主。可惜的是,许多论文已经无法经受住时间的检验。更重要的是,这门课缺乏对于知识的整体梳理,未能形成完整的知识体系架构。学习完这门课程,学生未能对于从头搭建机器学习系统有明确的思路。我将目光投向其他学校,华盛顿大学曾短期开过Deep Learning Systems(深度学习系统)课程,这门课程讲述了机器学习程序的编译过程。而由于这门课程以讲述Apache TVM深度学习编译器为主要目的,对于机器学习系统缺乏完整的教学。另外,斯坦福大学的课程Machine Learning Systems Design(机器学习系统设计)因为课程设计人的研究领域以数据库为主,因此该课程专注于数据清洗、数据管理、数据标注等主题。
|
||||
|
||||
当时觉得比较合适的是微软亚洲研究院的AI Systems课程。这门课程讲述了机器学习系统背后的设计理念。但是当我准备将其教授给本科生的时候,我发现这门课对于机器学习系统核心设计理念讲解得很浅,同时要求学生有大量计算机系统的背景知识,实际上它更适合教授给博士生。上述的课程共同问题是:其课程结构都以研读相关论文为主,因此教授的内容都是高深和零散的,而不是通俗易懂,知识脉络清晰的教科书,这给学习机器学习系统造成了极大的困难。
|
||||
|
||||
回首2020年,我们已经拥有了优秀的操作系统、数据库、分布式系统等基础性教材。同时,在机器学习相关算法方面也有了一系列教材。然而,无论是国内外,我很难找到一本系统性讲述机器学习系统的教材。因此,许多公司和高校实验室不得不花费大量的人力和物力从头培养学生和工程师,使他们加强对于机器学习底层基础设施的认识。这类教材的缺乏已经制约了高校的人才培养,不利于高校培养出符合业界学界和时代发展的人才了。因此,我开始思考:我们是不是应该推出一本机器学习系统的教科书了呢?
|
||||
|
||||
## 开端
|
||||
|
||||
带着写书的构想,我开始和朋友沟通。大家都非常认可编写这类书的巨大价值,但是现实的情况是:很少有人愿意做这么一件费力的事情。我当时的博士后导师也劝我:你现在处在教职生涯的初期,追求高影响力的学术论文是当务之急,写一本书要耗费大量的时间和精力,最后可能也无法出版面世。而我和同行交流时也发现:他们更愿意改进市面上已经有的教科书,即做有迹可循的事情,而不是摸着石头过河,做从无到有的事情。特别是对于机器学习系统这个快速发展,频繁试错的领域,能不能写出经受时间检验的书也是一个未知数。
|
||||
|
||||
考虑到写作的巨大挑战,我将写书的想法藏于心中,直到一次回国和MindSpore的架构师金雪锋聊天。和雪锋的相识大约是在2019年的圣诞节,雪锋来伦敦访问,他正在领导MindSpore的开发(当时MindSpore 1.0还没有发布)。而对于机器学习系统的开发,我也有很深的兴趣。我在2018年也和好友一起从头搭建一个机器学习框架(类似于PyTorch),虽然最终资源不足无疾而终,不过许多的思考成就了我之后发表的多篇机器学习系统论文。和雪锋聊起来,我们都对AI系统开发之难深有同感。我们共同的感慨就是:找到懂机器学习系统开发的人太难了。现今的学生都一心学习机器学习算法,很多学生对于底层的运作原理理解得很浅。而当他们在实际中应用机器学习技术时才意识到系统的重要性,那时想去学习,却没有了充沛的学习时间。我对雪锋苦笑道:“我是准备写一本机器学习系统教材的,但是可能还要等3,4年才能完成。” 雪锋说:“我也有这个想法啊。你要是写的话,我能帮助到你吗?”
|
||||
|
||||
这句话点醒了我。传统的图书写作,往往依赖于一,两个教授将学科十余年的发展慢慢总结整理成书。这种模式类似于传统软件开发的瀑布流方式。可是,在科技的世界,这已经变了!软件的发展从传统的瀑布流进化到如今的开源敏捷开发。而图书的写作为什么还要停留在传统方式呢?MXNet开源社区编写的专注于深度学习算法的图书*Deep Dive into Deep Learning*就是一个很好的例子啊。我因此马上找到当年一起创立TensorLayer开源社区的小伙伴:北京大学的董豪,我们一拍即合,说干就干。雪锋也很高兴我和董豪愿意开始做这件事,也邀请了他的同事干志良来帮忙。我们终于开始图书的写作了!
|
||||
|
||||
经过几轮的讨论,我们将书名定为《机器学习系统:设计和实现》。我们希望通过教给学生机器学习系统设计原理,同时也为学生提供大量的系统实现经验分享,让他们在将来工作和科研中遇到实际问题知道该如何分析和解决。
|
||||
|
||||
## 社区的构建
|
||||
|
||||
考虑到机器学习系统本身就是一个不断发展并且孕育细分领域的学科。我从一开始就在思考:如何设计一个可扩展(Scalable)的社区架构保证这本书的可持续发展呢?因为我专注于大规模软件系统,故决定借鉴几个分布式系统的设计要点构建社区:
|
||||
|
||||
* **预防单点故障和瓶颈**:现代分布式系统往往采用控制层和数据层分离的设计避免单点故障和瓶颈。那么我们在设计高度可扩展的写作社区的时候也要如此。因此,我们设计了如下分布式机制:编辑决定花最多的时间来寻找优秀的、主动的、负责任的书稿章节负责人。章节负责人可以进一步寻找其他作者共同协作。章节负责人和章节作者进行密切的沟通,按照给定时间节点,全速异步推进。编辑和章节负责人设定了每周讨论同步写作的进展,确保并行完成的章节内容质量能够持续符合编辑和社区的整体预期。
|
||||
|
||||
* **迭代式改进**:深度学习的优化算法随机梯度下降本质上是在复杂问题中利用局部梯度进行海量迭代,最终找到局部最优解。因此我利用了同样的思路设计图书质量的迭代提高。我们首先在Overleaf上写作好书籍的初版(类似于初始参数)。接下来,将图书的内容做成标准的Git代码仓库。建立机制鼓励开源社区和广大读者开启GitHub问题(Issue)和拉取请求(Pull Request,PR),持续改进图书质量,而我们设置好完善的书籍构建工具、持续集成工具、贡献者讨论会等,就可以让图书的质量持续提高实现随机梯度下降(Stochastic Gradient Descent)一样的结果最优性。
|
||||
|
||||
* **高可用性**:构建7 $\times$ 24小时在线的写作平台,让图书参与者可以在全球任何时区、任何语言平台下都能参与开发图书,倾听社区的反馈。因此将Git仓库放置在GitHub上,并准备之后在Gitee做好镜像。这样,就搭建了一套高可用的写作平台了。
|
||||
|
||||
* **内容中立**:一个分布式系统要能长久运行,其中的每一个节点都要同等对待,遇到故障才能用统一的办法进行故障恢复。考虑到图书写作中的故障(设计无法经受时间检验,写作人中途不得不退出等)可能来源于方方面面,我们让不同背景的参与者共同完成每一个章节,确保写出中立、客观、包容的内容,并且写作不会因为故障而中断。
|
||||
|
||||
## 现状和未来
|
||||
|
||||
机制一旦建立好,写作就自动化地跑起来了,参与者也越来越多,我带过的学生袁秀龙、丁子涵、符尧、任杰、梁文腾也很用心参与编写,董豪邀请了鹏城实验室的韩佳容和赖铖,志良邀请了许多MindSpore的小伙伴进来做贡献,许多资深的机器学习系统设计者也和我们在各个渠道展开讨论,提供了非常多宝贵的写作建议。另外,学界和产业界的反响也很热烈。海外很多优秀的学生(斯坦福大学的孙建凯、卡耐基梅隆大学的廖培元、剑桥大学的王瀚宸、爱丁堡大学的穆沛),产业界的朋友(英国葛兰素史克公司机器学习团队的肖凯严)都加入了我们的写作。同时,学界的教授(英国伦敦帝国理工学院的Peter Pietzuch教授、香港科技大学的陈雷教授等)也持续给我们提供了写作意见,改进了图书质量。
|
||||
|
||||
充分发动了“分布式系统”的力量后,图书的内容得以持续高质量地添加。当我们开源了图书以后,图书的受众快速增长,GitHub上关注度的增长让我们受宠若惊。在社区的推动下,图书的中文版、英文版、阿拉伯语版都已经开始推进。这么多年来,我第一次意识到我在分布式系统和机器学习中学习到的知识,在解决现实复杂问题的时候是如此的有用!
|
||||
|
||||
很多时候,当我们面对未知而巨大的困难时,个人的力量真的很渺小。而和朋友、社区一起就变成了强大的力量,让我们鼓起勇气,走出了最关键的第一步!希望我的一些思考,能给其他复杂问题的求解带来一些小小的启发。
|
||||
|
||||
截止2022年5月,本书已经拥有了以下贡献者参与了各章节的编写:**导论**(麦络、董豪、干志良)、**编程模型**(赖铖、麦络、董豪)、**计算图**(韩佳容、麦络、董豪)、**AI编译器和前端技术**(梁志博、张清华、黄炳坚、余坚峰、干志良)、**AI编译器后端和运行时**(褚金锦、穆沛、蔡福璧)、**硬件加速器**(张任伟、任杰、梁文腾、刘超、陈钢、黎明奇)、**数据处理**(袁秀龙)、**模型部署**(韩刚强、唐业辉、翟智强、李姗妮)、**分布式训练**(麦络、廖培元)、**联邦学习系统**(吴天诚、王瀚宸)、**推荐系统**(符尧、裴贝、麦络)、**强化学习系统**(丁子涵)、**可解释AI系统**(李昊阳、李小慧)、**机器人系统**(孙建凯、肖凯严)。
|
||||
|
||||
最后,我们非常欢迎新成员的加入以提升书籍质量,扩展内容。感兴趣的读者可以通过书籍的[OpenMLSys社区](https://github.com/openmlsys/) 联系我们。我们非常期待和大家一起努力,编写出一本推动业界发展的机器学习系统图书!
|
||||
|
||||
麦络
|
||||
|
||||
英国爱丁堡
|
||||
|
||||
2022年5月4日
|
||||
3
website/chapter_preface_advanced/index.md
Normal file
3
website/chapter_preface_advanced/index.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# 第二部分:进阶篇
|
||||
|
||||
下面本书将重点讲解 AI 编译器的基本构成,以及 AI 编译器前端、后端和运行时中的关键技术。本书也将对于硬件加速器、数据处理、模型部署和分布式训练分别进行深入解读,从而为开发者提供从 0 到 1 构建机器学习框架所需的核心知识和实践经验。
|
||||
7
website/chapter_preface_extension/index.md
Normal file
7
website/chapter_preface_extension/index.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# 第三部分:拓展篇
|
||||
|
||||
在本书的第三部分,我们将会介绍机器学习框架生态下的众多拓展应用。我们将会首先介绍深度学习推荐系统,这一类系统是深度学习应用最成功的领域之一,在各大互联网公司中得到了大量部署。
|
||||
其后,我们将会介绍联邦学习系统,这一类希望在数据隐私保护意识快速崛起的今天具有举足轻重的作用。我们同时也会介绍可解释性AI系统,这一类系统能够对机器学习推理的过程给出解释,因此在金融,医疗等安全攸关系统(Safety-critical System)中得到大量应用。
|
||||
最后,我们会介绍强化学习系统,这一类系统可以帮助计算机和人类进行自动化决策,是实现通用人工智能的关键基础设施。
|
||||
|
||||
机器学习系统作为一个蓬勃发展的领域,我们也会在不远的将来看到更多相关系统的崛起。我们也欢迎社区贡献更多的章节进入拓展篇,如有想法可以和本书的编辑们联系。
|
||||
181
website/chapter_programming_interface/c_python_interaction.md
Normal file
181
website/chapter_programming_interface/c_python_interaction.md
Normal file
@@ -0,0 +1,181 @@
|
||||
## C/C++编程接口
|
||||
|
||||
在2.2和2.3节中,分别讨论了开发者如何利用Python来定义机器学习的整个工作流,以及如何定义复杂的深度神经网络。然而,在很多时候,开发者也需要添加自定义的算子来帮助实现新的模型,优化器,数据处理函数等。这些自定义算子需要通过C和C++实现,从而获得最优性能。但是为了帮助这些算子被开发者使用,他们也需要暴露为Python函数,从而方便开发者整合入已有的Python为核心编写的工作流和模型。在这一小节中,我们讨论这一过程是如何实现的。
|
||||
|
||||
### 在Python中调用C/C++函数的原理
|
||||
|
||||
由于Python的解释器是由C实现的,因此在Python中可以实现对于C和C++函数的调用。现代机器学习框架(包括TensorFlow,PyTorch和MindSpore)主要依赖Pybind11来将底层的大量C和C++函数自动生成对应的Python函数,这一过程一般被称为Python绑定(
|
||||
Binding)。在Pybind11出现以前,将C和C++函数进行Python绑定的手段主要包括:
|
||||
|
||||
- Python的C-API。这种方式要求在一个C++程序中包含Python.h,并使用Python的C-API对Python语言进行操作。使用这套API需要对Python的底层实现有一定了解,比如如何管理引用计数等,具有较高的使用门槛。
|
||||
|
||||
- 简单包装界面产生器(Simplified Wrapper and Interface Generator,SWIG)。SWIG可以将C和C++代码暴露给Python。SWIG是TensorFlow早期使用的方式。这种方式需要用户编写一个复杂的SWIG接口声明文件,并使用SWIG自动生成使用Python
|
||||
C-API的C代码。自动生成的代码可读性很低,因此具有很大代码维护开销。
|
||||
|
||||
- Python的ctypes模块,提供了C语言中的类型,以及直接调用动态链接库的能力。缺点是依赖于C的原生的类型,对自定义类型支持不好。
|
||||
|
||||
- Cython是结合了Python和C语言的一种语言,可以简单的认为就是给Python加上了静态类型后的语法,使用者可以维持大部分的Python语法。Cython编写的函数会被自动转译为C和C++代码,因此在Cython中可以插入对于C/C++函数的调用。
|
||||
|
||||
- Boost::Python是一个C++库。它可以将C++函数暴露为Python函数。其原理和Python C-API类似,但是使用方法更简单。然而,由于引入了Boost库,因此有沉重的第三方依赖。
|
||||
|
||||
相对于上述的提供Python绑定的手段,Pybind11提供了类似于Boost::Python的简洁性和易用性,但是其通过专注支持C++
|
||||
11,并且去除Boost依赖,因此成为了轻量级的Python库,从而特别适合在一个复杂的C++项目(例如本书讨论的机器学习系统)中暴露大量的Python函数。
|
||||
|
||||
### 添加C++编写的自定义算子
|
||||
|
||||
算子是构建神经网络的基础,在前面也称为低级API;通过算子的封装可以实现各类神经网络层,当开发神经网络层遇到内置算子无法满足时,可以通过自定义算子来实现。以MindSpore为例,实现一个GPU算子需要如下步骤:
|
||||
|
||||
1. Primitive注册:算子原语是构建网络模型的基础单元,用户可以直接或者间接调用算子原语搭建一个神经网络模型。
|
||||
|
||||
2. GPU Kernel实现:GPU Kernel用于调用GPU实现加速计算。
|
||||
|
||||
3. GPU Kernel注册:算子注册用于将GPU
|
||||
Kernel及必要信息注册给框架,由框架完成对GPU Kernel的调用。
|
||||
|
||||
**1.注册算子原语**
|
||||
算子原语通常包括算子名、算子输入、算子属性(初始化时需要填的参数,如卷积的stride、padding)、输入数据合法性校验、输出数据类型推导和维度推导。假设需要编写加法算子,主要内容如下:
|
||||
|
||||
- 算子名:TensorAdd
|
||||
|
||||
- 算子属性:构造函数\_\_init\_\_中初始化属性,因加法没有属性,因此\_\_init\_\_不需要额外输入。
|
||||
|
||||
- 算子输入输出及合法性校验:infer_shape方法中约束两个输入维度必须相同,输出的维度和输入维度相同。infer_dtype方法中约束两个输入数据必须是float32类型,输出的数据类型和输入数据类型相同。
|
||||
|
||||
- 算子输出
|
||||
|
||||
MindSpore中实现注册TensorAdd代码如下:
|
||||
```python
|
||||
# mindspore/ops/operations/math_ops.py
|
||||
class TensorAdd(PrimitiveWithInfer):
|
||||
"""
|
||||
Adds two input tensors element-wise.
|
||||
"""
|
||||
@prim_attr_register
|
||||
def __init__(self):
|
||||
self.init_prim_io_names(inputs=['x1', 'x2'], outputs=['y'])
|
||||
|
||||
def infer_shape(self, x1_shape, x2_shape):
|
||||
validator.check_integer('input dims', len(x1_shape), len(x2_shape), Rel.EQ, self.name)
|
||||
for i in range(len(x1_shape)):
|
||||
validator.check_integer('input_shape', x1_shape[i], x2_shape[i], Rel.EQ, self.name)
|
||||
return x1_shape
|
||||
|
||||
def infer_dtype(self, x1_dtype, x2_type):
|
||||
validator.check_tensor_type_same({'x1_dtype': x1_dtype}, [mstype.float32], self.name)
|
||||
validator.check_tensor_type_same({'x2_dtype': x2_dtype}, [mstype.float32], self.name)
|
||||
return x1_dtype
|
||||
```
|
||||
|
||||
在mindspore/ops/operations/math_ops.py文件内注册加法算子原语后,需要在mindspore/ops/operations/\_\_init\_\_中导出,方便python导入模块时候调用。
|
||||
```python
|
||||
# mindspore/ops/operations/__init__.py
|
||||
from .math_ops import (Abs, ACos, ..., TensorAdd)
|
||||
__all__ = [
|
||||
'ReverseSequence',
|
||||
'CropAndResize',
|
||||
...,
|
||||
'TensorAdd'
|
||||
]
|
||||
```
|
||||
|
||||
**2.GPU算子开发**继承GPUKernel,实现加法使用类模板定义TensorAddGpuKernel,需要实现以下方法:
|
||||
|
||||
- Init(): 用于完成GPU Kernel的初始化,通常包括记录算子输入/输出维度,完成Launch前的准备工作;因此在此记录Tensor元素个数。
|
||||
|
||||
- GetInputSizeList():向框架反馈输入Tensor需要占用的显存字节数;返回了输入Tensor需要占用的字节数,TensorAdd有两个Input,每个Input占用字节数为element_num$\ast$sizeof(T)。
|
||||
|
||||
- GetOutputSizeList():向框架反馈输出Tensor需要占用的显存字节数;返回了输出Tensor需要占用的字节数,TensorAdd有一个output,占用element_num$\ast$sizeof(T)字节。
|
||||
|
||||
- GetWorkspaceSizeList():向框架反馈Workspace字节数,Workspace是用于计算过程中存放临时数据的空间;由于TensorAdd不需要Workspace,因此GetWorkspaceSizeList()返回空的std::vector\<size_t\>。
|
||||
|
||||
- Launch(): 通常调用CUDA kernel(CUDA kernel是基于Nvidia GPU的并行计算架构开发的核函数),或者cuDNN接口等方式,完成算子在GPU上加速;Launch()接收input、output在显存的地址,接着调用TensorAdd完成加速。
|
||||
```python
|
||||
// mindspore/ccsrc/backend/kernel_compiler/gpu/math/tensor_add_v2_gpu_kernel.h
|
||||
|
||||
template <typename T>
|
||||
class TensorAddGpuKernel : public GpuKernel {
|
||||
public:
|
||||
TensorAddGpuKernel() : element_num_(1) {}
|
||||
~TensorAddGpuKernel() override = default;
|
||||
|
||||
bool Init(const CNodePtr &kernel_node) override {
|
||||
auto shape = AnfAlgo::GetPrevNodeOutputInferShape(kernel_node, 0);
|
||||
for (size_t i = 0; i < shape.size(); i++) {
|
||||
element_num_ *= shape[i];
|
||||
}
|
||||
InitSizeLists();
|
||||
return true;
|
||||
}
|
||||
|
||||
const std::vector<size_t> &GetInputSizeList() const override { return input_size_list_; }
|
||||
const std::vector<size_t> &GetOutputSizeList() const override { return output_size_list_; }
|
||||
const std::vector<size_t> &GetWorkspaceSizeList() const override { return workspace_size_list_; }
|
||||
|
||||
bool Launch(const std::vector<AddressPtr> &inputs, const std::vector<AddressPtr> &,
|
||||
const std::vector<AddressPtr> &outputs, void *stream_ptr) override {
|
||||
T *x1 = GetDeviceAddress<T>(inputs, 0);
|
||||
T *x2 = GetDeviceAddress<T>(inputs, 1);
|
||||
T *y = GetDeviceAddress<T>(outputs, 0);
|
||||
|
||||
TensorAdd(element_num_, x1, x2, y, reinterpret_cast<cudaStream_t>(stream_ptr));
|
||||
return true;
|
||||
}
|
||||
|
||||
protected:
|
||||
void InitSizeLists() override {
|
||||
input_size_list_.push_back(element_num_ * sizeof(T));
|
||||
input_size_list_.push_back(element_num_ * sizeof(T));
|
||||
output_size_list_.push_back(element_num_ * sizeof(T));
|
||||
}
|
||||
|
||||
private:
|
||||
size_t element_num_;
|
||||
std::vector<size_t> input_size_list_;
|
||||
std::vector<size_t> output_size_list_;
|
||||
std::vector<size_t> workspace_size_list_;
|
||||
};
|
||||
```
|
||||
|
||||
TensorAdd中调用了CUDA
|
||||
kernelTensorAddKernel来实现element_num个元素的并行相加:
|
||||
```python
|
||||
// mindspore/ccsrc/backend/kernel_compiler/gpu/math/tensor_add_v2_gpu_kernel.h
|
||||
|
||||
template <typename T>
|
||||
__global__ void TensorAddKernel(const size_t element_num, const T* x1, const T* x2, T* y) {
|
||||
for (size_t i = blockIdx.x * blockDim.x + threadIdx.x; i < element_num; i += blockDim.x * gridDim.x) {
|
||||
y[i] = x1[i] + x2[i];
|
||||
}
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
void TensorAdd(const size_t &element_num, const T* x1, const T* x2, T* y, cudaStream_t stream){
|
||||
size_t thread_per_block = 256;
|
||||
size_t block_per_grid = (element_num + thread_per_block - 1 ) / thread_per_block;
|
||||
TensorAddKernel<<<block_per_grid, thread_per_block, 0, stream>>>(element_num, x1, x2, y);
|
||||
return;
|
||||
}
|
||||
|
||||
template void TensorAdd(const size_t &element_num, const float* x1, const float* x2, float* y, cudaStream_t stream);
|
||||
```
|
||||
|
||||
**3.GPU算子注册**算子信息包含1.Primive;2.Input dtype, output dtype;3.GPU Kernel class;
|
||||
4.CUDA内置数据类型。框架会根据Primive和Input dtype, output dtype,调用以CUDA内置数据类型实例化GPU Kernel class模板类。如下代码中分别注册了支持float和int的TensorAdd算子。
|
||||
```python
|
||||
// mindspore/ccsrc/backend/kernel_compiler/gpu/math/tensor_add_v2_gpu_kernel.cc
|
||||
|
||||
MS_REG_GPU_KERNEL_ONE(TensorAddV2, KernelAttr()
|
||||
.AddInputAttr(kNumberTypeFloat32)
|
||||
.AddInputAttr(kNumberTypeFloat32)
|
||||
.AddOutputAttr(kNumberTypeFloat32),
|
||||
TensorAddV2GpuKernel, float)
|
||||
|
||||
MS_REG_GPU_KERNEL_ONE(TensorAddV2, KernelAttr()
|
||||
.AddInputAttr(kNumberTypeInt32)
|
||||
.AddInputAttr(kNumberTypeInt32)
|
||||
.AddOutputAttr(kNumberTypeInt32),
|
||||
TensorAddV2GpuKernel, int)
|
||||
```
|
||||
|
||||
完成上述三步工作后,需要把MindSpore重新编译,在源码的根目录执行bash
|
||||
build.sh -e gpu,最后使用算子进行验证。
|
||||
21
website/chapter_programming_interface/development_history.md
Normal file
21
website/chapter_programming_interface/development_history.md
Normal file
@@ -0,0 +1,21 @@
|
||||
## 机器学习系统编程模型的演进
|
||||
|
||||

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

|
||||
:width:`800px`
|
||||
:label:`img_workflow`
|
||||
|
||||
### 环境配置
|
||||
|
||||
下面以MindSpore框架实现多层感知机为例,了解完整的机器学习工作流。代码运行环境为MindSpore1.5.2,Ubuntu16.04,CUDA10.1。
|
||||
在构建机器学习工作流程前,MindSpore需要通过context.set_context来配置运行需要的信息,如运行模式、后端信息、硬件等信息。
|
||||
以下代码导入context模块,配置运行需要的信息。
|
||||
|
||||
```python
|
||||
import os
|
||||
import argparse
|
||||
from mindspore import context
|
||||
parser = argparse.ArgumentParser(description='MindSpore MLPNet Example')
|
||||
parser.add_argument('--device_target', type=str, default="CPU", choices=['Ascend', 'GPU', 'CPU'])
|
||||
args = parser.parse_known_args()[0]
|
||||
context.set_context(mode=context.GRAPH_MODE, device_target=args.device_target)
|
||||
```
|
||||
上述配置样例运行使用图模式。根据实际情况配置硬件信息,譬如代码运行在Ascend
|
||||
AI处理器上,则--device_target选择Ascend,代码运行在CPU、GPU同理。
|
||||
|
||||
### 数据处理
|
||||
配置好运行信息后,首先讨论数据处理API的设计。这些API提供了大量Python函数支持用户用一行命令即可读入常见的训练数据集(如MNIST,CIFAR,COCO等)。
|
||||
在加载之前需要将下载的数据集存放在./datasets/MNIST_Data路径中;MindSpore提供了用于数据处理的API模块
|
||||
mindspore.dataset,用于存储样本和标签。在加载数据集前,通常会对数据集进行一些处理,mindspore.dataset也集成了常见的数据处理方法。
|
||||
以下代码读取了MNIST的数据是大小为$28 \times 28$的图片,返回DataSet对象。
|
||||
|
||||
```python
|
||||
import mindspore.dataset as ds
|
||||
DATA_DIR = './datasets/MNIST_Data/train'
|
||||
mnist_dataset = ds.MnistDataset(DATA_DIR)
|
||||
```
|
||||
|
||||
有了DataSet对象后,通常需要对数据进行增强,常用的数据增强包括翻转、旋转、剪裁、缩放等;在MindSpore中是使用map将数据增强的操作映射到数据集中的,之后进行打乱(Shuffle)和批处理(Batch)。
|
||||
```python
|
||||
# 导入需要用到的模块
|
||||
import mindspore.dataset as ds
|
||||
import mindspore.dataset.transforms.c_transforms as C
|
||||
import mindspore.dataset.vision.c_transforms as CV
|
||||
from mindspore.dataset.vision import Inter
|
||||
from mindspore import dtype as mstype
|
||||
# 数据处理过程
|
||||
def create_dataset(data_path, batch_size=32, repeat_size=1,
|
||||
num_parallel_workers=1):
|
||||
# 定义数据集
|
||||
mnist_ds = ds.MnistDataset(data_path)
|
||||
resize_height, resize_width = 32, 32
|
||||
rescale = 1.0 / 255.0
|
||||
rescale_nml = 1 / 0.3081
|
||||
shift_nml = -1 * 0.1307 / 0.3081
|
||||
|
||||
# 定义所需要操作的map映射
|
||||
resize_op = CV.Resize((resize_height, resize_width), interpolation=Inter.LINEAR)
|
||||
rescale_nml_op = CV.Rescale(rescale_nml * rescale, shift_nml)
|
||||
hwc2chw_op = CV.HWC2CHW()
|
||||
type_cast_op = C.TypeCast(mstype.int32)
|
||||
# 使用map映射函数,将数据操作应用到数据集
|
||||
mnist_ds = mnist_ds.map(operations=type_cast_op, input_columns="label", num_parallel_workers=num_parallel_workers)
|
||||
mnist_ds = mnist_ds.map(operations=[resize_op, rescale_nml_op,hwc2chw_op], input_columns="image",num_parallel_workers=num_parallel_workers)
|
||||
|
||||
# 进行shuffle、batch操作
|
||||
buffer_size = 10000
|
||||
mnist_ds = mnist_ds.shuffle(buffer_size=buffer_size)
|
||||
mnist_ds = mnist_ds.batch(batch_size, drop_remainder=True)
|
||||
return mnist_ds
|
||||
```
|
||||
|
||||
### 模型定义
|
||||
|
||||
使用MindSpore定义神经网络需要继承mindspore.nn.Cell,神经网络的各层需要预先在\_\_init\_\_方法中定义,然后重载\_\_construct\_\_方法实现神经网络的前向传播过程。
|
||||
因为输入大小被处理成$32 \times 32$的图片,所以需要用Flatten将数据压平为一维向量后给全连接层。
|
||||
全连接层的输入大小为$32 \times 32$,输出是预测属于$0 \sim 9$中的哪个数字,因此输出大小为10,下面定义了一个三层的全连接层。
|
||||
```python
|
||||
# 导入需要用到的模块
|
||||
import mindspore.nn as nn
|
||||
# 定义线性模型
|
||||
class MLPNet(nn.Cell):
|
||||
def __init__(self):
|
||||
super(MLPNet, self).__init__()
|
||||
self.flatten = nn.Flatten()
|
||||
self.dense1 = nn.Dense(32*32, 128)
|
||||
self.dense2 = nn.Dense(128, 64)
|
||||
self.dense3 = nn.Dense(64, 10)
|
||||
|
||||
def construct(self, inputs):
|
||||
x = self.flatten(inputs)
|
||||
x = self.dense1(x)
|
||||
x = self.dense2(x)
|
||||
logits = self.dense3(x)
|
||||
return logits
|
||||
# 实例化网络
|
||||
net = MLPNet()
|
||||
```
|
||||
|
||||
### 损失函数和优化器
|
||||
|
||||
有了神经网络组件构建的模型我们还需要定义**损失函数**来计算训练过程中输出和真实值的误差。**均方误差**(Mean Squared Error,MSE)是线性回归中常用的,是计算估算值与真实值差值的平方和的平均数。
|
||||
**平均绝对误差**(Mean Absolute Error,MAE)是计算估算值与真实值差值的绝对值求和再求平均。
|
||||
**交叉熵**(Cross Entropy,CE)是分类问题中常用的,衡量已知数据分布情况下,计算输出分布和已知分布的差值。
|
||||
|
||||
有了损失函数,我们就可以通过损失值利用**优化器**对参数进行训练更新。对于优化的目标函数$f(x)$;先求解其梯度$\nabla$$f(x)$,然后将训练参数$W$沿着梯度的负方向更新,更新公式为:$W_t = W_{t-1} - \alpha\nabla(W_{t-1})$,其中$\alpha$是学习率,$W$是训练参数,$\nabla(W_{t-1})$是方向。
|
||||
神经网络的优化器种类很多,一类是学习率不受梯度影响的随机梯度下降(Stochastic Gradient Descent)及SGD的一些改进方法,如带有Momentum的SGD;另一类是自适应学习率如AdaGrad、RMSProp、Adam等。
|
||||
|
||||
**SGD**的更新是对每个样本进行梯度下降,因此计算速度很快,但是单样本更新频繁,会造成震荡;为了解决震荡问题,提出了带有Momentum的SGD,该方法的参数更新不仅仅由梯度决定,也和累计的梯度下降方向有关,使得增加更新梯度下降方向不变的维度,减少更新梯度下降方向改变的维度,从而速度更快也减少震荡。
|
||||
|
||||
自适应学习率**AdaGrad**是通过以往的梯度自适应更新学习率,不同的参数$W_i$具有不同的学习率。AdaGrad对频繁变化的参数以更小的步长更新,而稀疏的参数以更大的步长更新。因此对稀疏的数据表现比较好。**Adadelta**是对AdaGrad的改进,解决了AdaGrad优化过程中学习率$\alpha$单调减少问题;Adadelta不对过去的梯度平方进行累加,用指数平均的方法计算二阶动量,避免了二阶动量持续累积,导致训练提前结束。**Adam**可以理解为Adadelta和Momentum的结合,对一阶二阶动量均采用指数平均的方法计算。
|
||||
|
||||
MindSpore提供了丰富的API来让用户导入损失函数和优化器。在下面的例子中,计算了输入和真实值之间的softmax交叉熵损失,导入Momentum优化器。
|
||||
```python
|
||||
# 定义损失函数
|
||||
net_loss = nn.SoftmaxCrossEntropyWithLogits(sparse=True, reduction='mean')
|
||||
# 定义优化器
|
||||
net_opt = nn.Momentum(net.trainable_params(), learning_rate=0.01, momentum=0.9)
|
||||
```
|
||||
|
||||
### 训练及保存模型
|
||||
|
||||
MindSpore提供了回调Callback机制,可以在训练过程中执行自定义逻辑,使用框架提供的ModelCheckpoint为例。ModelCheckpoint可以保存网络模型和参数,以便进行后续的Fine-tuning(微调)操作。
|
||||
```python
|
||||
# 导入模型保存模块
|
||||
from mindspore.train.callback import ModelCheckpoint, CheckpointConfig
|
||||
# 设置模型保存参数
|
||||
config_ck = CheckpointConfig(save_checkpoint_steps=1875, keep_checkpoint_max=10)
|
||||
# 应用模型保存参数
|
||||
ckpoint = ModelCheckpoint(prefix="checkpoint_lenet", config=config_ck)
|
||||
```
|
||||
|
||||
通过MindSpore提供的model.train接口可以方便地进行网络的训练,LossMonitor可以监控训练过程中loss值的变化。
|
||||
```python
|
||||
# 导入模型训练需要的库
|
||||
from mindspore.nn import Accuracy
|
||||
from mindspore.train.callback import LossMonitor
|
||||
from mindspore import Model
|
||||
|
||||
def train_net(args, model, epoch_size, data_path, repeat_size, ckpoint_cb, sink_mode):
|
||||
"""定义训练的方法"""
|
||||
# 加载训练数据集
|
||||
ds_train = create_dataset(os.path.join(data_path, "train"), 32, repeat_size)
|
||||
model.train(epoch_size, ds_train, callbacks=[ckpoint_cb, LossMonitor(125)], dataset_sink_mode=sink_mode)
|
||||
```
|
||||
|
||||
其中,dataset_sink_mode用于控制数据是否下沉,数据下沉是指数据通过通道直接传送到Device上,可以加快训练速度,dataset_sink_mode为True表示数据下沉,否则为非下沉。
|
||||
|
||||
有了数据集、模型、损失函数、优化器后就可以进行训练了,这里把train_epoch设置为1,对数据集进行1个迭代的训练。在train_net和
|
||||
test_net方法中,我们加载了之前下载的训练数据集,mnist_path是MNIST数据集路径。
|
||||
```python
|
||||
train_epoch = 1
|
||||
mnist_path = "./datasets/MNIST_Data"
|
||||
dataset_size = 1
|
||||
model = Model(net, net_loss, net_opt, metrics={"Accuracy": Accuracy()})
|
||||
train_net(args, model, train_epoch, mnist_path, dataset_size, ckpoint, False)
|
||||
```
|
||||
|
||||
### 测试和验证
|
||||
|
||||
测试是将测试数据集输入到模型,运行得到输出的过程。通常在训练过程中,每训练一定的数据量后就会测试一次,以验证模型的泛化能力。MindSpore使用model.eval接口读入测试数据集。
|
||||
```python
|
||||
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]}"')
|
||||
```
|
||||
183
website/chapter_programming_interface/neural_network_layer.md
Normal file
183
website/chapter_programming_interface/neural_network_layer.md
Normal file
@@ -0,0 +1,183 @@
|
||||
## 定义深度神经网络
|
||||
|
||||
在上一节我们使用MindSpore构建了一个多层感知机的网络结构,随着深度神经网络的飞速发展,各种深度神经网络结构层出不穷,但是不管结构如何复杂,神经网络层数量如何增加,构建深度神经网络结构始终遵循最基本的元素:1.承载计算的节点;2.可变化的节点权重(节点权重可训练);3.允许数据流动的节点连接。因此在机器学习编程库中深度神经网络是以层为核心,它提供了各类深度神经网络层基本组件;将神经网络层组件按照网络结构进行堆叠、连接就能构造出神经网络模型。
|
||||
|
||||
### 以层为核心定义神经网络
|
||||
|
||||
神经网络层包含构建机器学习网络结构的基本组件,如计算机视觉领域常用到卷积(Convolution)、池化(Pooling)、全连接(Fully Connected);自然语言处理常用到循环神经网络(Recurrent Neural Network,RNN);为了加速训练,防止过拟合通常用到批标准化(BatchNorm)、Dropout等。
|
||||
|
||||
**全连接**是将当前层每个节点都和上一层节点一一连接,本质上是特征空间的线性变换;可以将数据从高维映射到低维,也能从低维映射到高维度。
|
||||
:numref:`fc_layer`展示了全连接的过程,对输入的n个数据变换到大小为m的特征空间,再从大小为m的特征空间变换到大小为p的特征空间;可见全连接层的参数量巨大,两次变换所需的参数大小为$n \times m$和$m \times p$。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`fc_layer`
|
||||
|
||||
**卷积**操作是卷积神经网络中常用的操作之一,卷积相当于对输入进行滑动滤波。根据卷积核(Kernel)、卷积步长(Stride)、填充(Padding)对输入数据从左到右,从上到下进行滑动,每一次滑动操作是矩阵的乘加运算得到的加权值。
|
||||
如 :numref:`conv_comp`卷积操作主要由输入、卷积核、输出组成输出又被称为特征图(Feature Map)。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`conv_comp`
|
||||
|
||||
卷积的具体运算过程我们通过 :numref:`single_conv`进行演示。该图输入为$4 \times 4$的矩阵,卷积核大小为$3 \times 3$,卷积步长为1,不填充,最终得到的$2 \times 2$的输出矩阵。
|
||||
计算过程为将$3 \times 3$的卷积核作用到左上角$3 \times 3$大小的输入图上;输出为$1 \times 1 + 2 \times 0 + 2 \times 1 + 3 \times 0 + 2 \times 1 + 3 \times 0 + 4 \times 1 + 1 \times 0 + 3 \times 1 = 12$,
|
||||
同理对卷积核移动1个步长再次执行相同的计算步骤得到第二个输出为11;当再次移动将出界时结束从左往右,执行从上往下移动1步,再进行从左往右移动;依次操作直到从上往下再移动也出界时,结束整个卷积过程,得到输出结果。我们不难发现相比于全连接,卷积的优势是参数共享(同一个卷积核遍历整个输入图)和参数量小(卷积核大小即是参数量)。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`single_conv`
|
||||
|
||||
在卷积过程中,如果我们需要对输出矩阵大小进行控制,那么就需要对步长和填充进行设置。还是上面的输入图,如需要得到和输入矩阵大小一样的输出矩阵,步长为1时就需要对上下左右均填充一圈全为0的数。
|
||||
|
||||
在上述例子中我们介绍了一个输入一个卷积核的卷积操作。通常情况下我们输入的是彩色图片,有三个输入,这三个输入称为通道(Channel),分别代表红、绿、蓝(RGB)。此时我们执行卷积则为多通道卷积,需要三个卷积核分别对RGB三个通道进行上述卷积过程,之后将结果加起来。
|
||||
具体如 :numref:`channels_conv`描述了一个输入通道为3,输出通道为1,卷积核大小为$3 \times 3$,卷积步长为1的多通道卷积过程;需要注意的是,每个通道都有各自的卷积核,同一个通道的卷积核参数共享。如果输出通道为$out_c$,输入通道为$in_c$,那么需要$out_c$$\times$$in_c$个卷积核。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`channels_conv`
|
||||
|
||||
**池化**是常见的降维操作,有最大池化和平均池化。池化操作和卷积的执行类似,通过池化核、步长、填充决定输出;最大池化是在池化核区域范围内取最大值,平均池化则是在池化核范围内做平均。与卷积不同的是池化核没有训练参数;池化层的填充方式也有所不同,平均池化填充的是0,最大池化填充的是$-inf$。
|
||||
:numref:`pooling`是对$4 \times 4$的输入进行$2 \times 2$区域池化,步长为2,不填充;图左边是最大池化的结果,右边是平均池化的结果。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`pooling`
|
||||
|
||||
有了卷积、池化、全连接组件就可以构建一个非常简单的卷积神经网络了, :numref:`nn_network`展示了一个卷积神经网络的模型结构。
|
||||
给定输入$3 \times 64 \times 64$的彩色图片,使用16个$3 \times 3 \times 3$大小的卷积核做卷积,得到大小为$16 \times 64 \times 64$的特征图;
|
||||
再进行池化操作降维,得到大小为$16 \times 32 \times 32$的特征图;
|
||||
对特征图再卷积得到大小为$32 \times 32 \times 32$特征图,再进行池化操作得到$32 \times 16 \times 16$大小的特征图;
|
||||
我们需要对特征图做全连接,此时需要把特征图平铺成一维向量这步操作称为Flatten,压平后输入特征大小为$32\times 16 \times 16 = 8192$;
|
||||
之后做一次全连接对大小为8192特征变换到大小为128的特征,再依次做两次全连接分别得到64,10。
|
||||
这里最后的输出结果是依据自己的实际问题而定,假设我们的输入是包含$0 \sim 9$的数字图片,做分类那输出对应是10个概率值,分别对应$0 \sim 9$的概率大小。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`nn_network`
|
||||
|
||||
有了上述基础知识,对卷积神经网络模型构建过程使用伪代码描述如下:
|
||||
```python
|
||||
# 构建卷积神经网络的组件接口定义:
|
||||
全连接层接口:fully_connected(input, weights)
|
||||
卷积层的接口:convolution(input, filters, stride, padding)
|
||||
最大池化接口:pooling(input, pool_size, stride, padding, mode='max')
|
||||
平均池化接口:pooling(input, pool_size, stride, padding, mode='mean')
|
||||
|
||||
# 构建卷积神经网络描述:
|
||||
input:(3,64,64)大小的图片
|
||||
# 创建卷积模型的训练变量,使用随机数初始化变量值
|
||||
conv1_filters = variable(random(size=(3, 3, 3, 16)))
|
||||
conv2_filters = variable(random(size=(3, 3, 16, 32)))
|
||||
fc1_weights = variable(random(size=(8192, 128)))
|
||||
fc2_weights = variable(random(size=(128, 64)))
|
||||
fc3_weights = variable(random(size=(64, 10)))
|
||||
# 将所有需要训练的参数收集起来
|
||||
all_weights = [conv1_filters, conv2_filters, fc1_weights, fc2_weights, fc3_weights]
|
||||
|
||||
# 构建卷积模型的连接过程
|
||||
output = convolution(input, conv1_filters, stride=1, padding='same')
|
||||
output = pooling(output, kernel_size=3, stride=2, padding='same', mode='max')
|
||||
output = convolution(output, conv2_filters, stride=1, padding='same')
|
||||
output = pooling(output, kernel_size=3, stride=2, padding='same', mode='max')
|
||||
output = flatten(output)
|
||||
output = fully_connected(output, fc1_weights)
|
||||
output = fully_connected(output, fc2_weights)
|
||||
output = fully_connected(output, fc3_weights)
|
||||
```
|
||||
|
||||
随着深度神经网络应用领域的扩大,诞生出了丰富的模型构建组件。在卷积神经网络的计算过程中,前后的输入是没有联系的,然而在很多任务中往往需要处理序列信息,如语句、语音、视频等,为了解决此类问题诞生出循环神经网络(Recurrent Neural Network,RNN);
|
||||
循环神经网络很好的解决了序列数据的问题,但是随着序列的增加,长序列又导致了训练过程中梯度消失和梯度爆炸的问题,因此有了长短期记忆(Long Short-term Memory,LSTM);
|
||||
在语言任务中还有Seq2Seq它将RNN当成编解码(Encoder-Decoder)结构的编码器(Encoder)和解码器(Decode);
|
||||
在解码器中又常常使用注意力机制(Attention);基于编解码器和注意力机制又有Transformer;
|
||||
Transformer又是BERT模型架构的重要组成。随着深度神经网络的发展,未来也会诞生各类模型架构,架构的创新可以通过各类神经网络基本组件的组合来实现。
|
||||
|
||||
### 神经网络层的实现原理
|
||||
|
||||
2.3.1中使用伪代码定义了一些卷积神经网络接口和模型构建过程,整个构建过程需要创建训练变量和构建连接过程。随着网络层数的增加,手动管理训练变量是一个繁琐的过程,因此2.3.1中描述的接口在机器学习库中属于低级API。机器学习编程库大都提供了更高级用户友好的API,它将神经网络层抽象出一个基类,所有的神经网络层都继承基类来实现,如MindSpore提供的mindspore.nn.Cell;PyTorch提供的torch.nn.Module。基于基类他们都提供了高阶API,如MindSpore 提供的mindspore.nn.Conv2d、mindspore.nn.MaxPool2d、mindspore.dataset;PyTorch提供的torch.nn.Conv2d、torch.nn.MaxPool2d、torch.utils.data.Dataset。
|
||||
|
||||
:numref:`model_build`描述了神经网络构建过程中的基本细节。基类需要初始化训练参数、管理参数状态以及定义计算过程;神经网络模型需要实现对神经网络层和神经网络层参数管理的功能。在机器学习编程库中,承担此功能有MindSpore的Cell、PyTorch的Module。Cell和Module是模型抽象方法也是所有网络的基类。现有模型抽象方案有两种,一种是抽象出两个方法分别为Layer(负责单个神经网络层的参数构建和前向计算),Model(负责对神经网络层进行连接组合和神经网络层参数管理);另一种是将Layer和Model抽象成一个方法,该方法既能表示单层神经网络层也能表示包含多个神经网络层堆叠的模型,Cell和Module就是这样实现的。
|
||||
|
||||

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

|
||||
:width:`800px`
|
||||
:label:`cell_abs`
|
||||
|
||||
神经网络接口层基类实现,仅做了简化的描述,在实际实现时,执行计算的\_\_call\_\_方法并不会让用户直接重载,它往往在\_\_call\_\_之外定义一个执行操作的方法(对于神经网络模型该方法是实现网络结构的连接,对于神经网络层则是实现计算过程)后再\_\_call\_\_调用;如MindSpore的Cell因为动态图和静态图的执行是不一样的,因此在\_\_call\_\_里定义动态图和计算图的计算执行,在construct方法里定义层或者模型的操作过程。
|
||||
|
||||
### 自定义神经网络层
|
||||
|
||||
2.3.1中使用伪代码定义机器学习库中低级API,有了实现的神经网络基类抽象方法,那么就可以设计更高层次的接口解决手动管理参数的繁琐。假设已经有了神经网络模型抽象方法Cell,构建Conv2D将继承Cell,并重构\_\_init\_\_和\_\_call\_\_方法,在\_\_init\_\_里初始化训练参数和输入参数,在\_\_call\_\_里调用低级API实现计算逻辑。同样使用伪代码描述自定义卷积层的过程。
|
||||
|
||||
```python
|
||||
# 接口定义:
|
||||
卷积层的接口:convolution(input, filters, stride, padding)
|
||||
变量:Variable(value, trainable=True)
|
||||
高斯分布初始化方法:random_normal(shape)
|
||||
神经网络模型抽象方法:Cell
|
||||
|
||||
# 定义卷积层
|
||||
class Conv2D(Cell):
|
||||
def __init__(self, in_channels, out_channels, ksize, stride, padding):
|
||||
# 卷积核大小为 ksize x ksize x inchannels x out_channels
|
||||
filters_shape = (out_channels, in_channels, ksize, ksize)
|
||||
self.stride = stride
|
||||
self.padding = padding
|
||||
self.filters = Variable(random_normal(filters_shape))
|
||||
|
||||
def __call__(self, inputs):
|
||||
outputs = convolution(inputs, self.filters, self.stride, self.padding)
|
||||
```
|
||||
|
||||
有了上述定义在使用卷积层时,就不需要创建训练变量了。
|
||||
如我们需要对$30 \times 30$大小10个通道的输入使用$3 \times 3$的卷积核做卷积,卷积后输出通道为20。
|
||||
调用方式如下:
|
||||
```python
|
||||
conv = Conv2D(in_channel=10, out_channel=20, filter_size=3, stride=2, padding=0)
|
||||
output = conv(input)
|
||||
```
|
||||
|
||||
在执行过程中,初始化Conv2D时,\_\_setattr\_\_会判断属性,属于Cell把神经网络层Conv2D记录到self.\_cells,属于parameter的filters记录到self.\_params。查看神经网络层参数使用conv.parameters_and_names;查看神经网络层列表使用conv.cells_and_names;执行操作使用conv(input)。
|
||||
|
||||
### 自定义神经网络模型
|
||||
|
||||
神经网络层是Cell的子类(SubClass)实现,同样的神经网络模型也可以采用SubClass的方法自定义神经网络模型;构建时需要在\_\_init\_\_里将要使用的神经网络组件实例化,在\_\_call\_\_里定义神经网络的计算逻辑。同样的以2.3.1的卷积神经网络模型为例,定义接口和伪代码描述如下:
|
||||
```python
|
||||
# 使用Cell子类构建的神经网络层接口定义:
|
||||
# 构建卷积神经网络的组件接口定义:
|
||||
全连接层接口:Dense(in_channel, out_channel)
|
||||
卷积层的接口:Conv2D(in_channel, out_channel, filter_size, stride, padding)
|
||||
最大池化接口:MaxPool2D(pool_size, stride, padding)
|
||||
张量平铺:Flatten()
|
||||
|
||||
# 使用SubClass方式构建卷积模型
|
||||
class CNN(Cell):
|
||||
def __init__(self):
|
||||
self.conv1 = Conv2D(in_channel=3, out_channel=16, filter_size=3, stride=1, padding=0)
|
||||
self.maxpool1 = MaxPool2D(pool_size=3, stride=1, padding=0)
|
||||
self.conv2 = Conv2D(in_channel=16, out_channel=32, filter_size=3, stride=1, padding=0)
|
||||
self.maxpool2 = MaxPool2D(pool_size=3, stride=1, padding=0)
|
||||
self.flatten = Flatten()
|
||||
self.dense1 = Dense(in_channels=768, out_channel=128)
|
||||
self.dense2 = Dense(in_channels=128, out_channel=64)
|
||||
self.dense3 = Dense(in_channels=64, out_channel=10)
|
||||
|
||||
def __call__(self, inputs):
|
||||
z = self.conv1(inputs)
|
||||
z = self.maxpool1(z)
|
||||
z = self.conv2(z)
|
||||
z = self.maxpool2(z)
|
||||
z = self.flatten(z)
|
||||
z = self.dense1(z)
|
||||
z = self.dense2(z)
|
||||
z = self.dense3(z)
|
||||
return z
|
||||
net = CNN()
|
||||
```
|
||||
|
||||
上述卷积模型进行实例化,其执行将从\_\_init\_\_开始,第一个是Conv2D,Conv2D也是Cell的子类,会进入到Conv2D的\_\_init\_\_,此时会将第一个Conv2D的卷积参数收集到self.\_params,之后回到Conv2D,将第一个Conv2D收集到self.\_cells;第二个的组件是MaxPool2D,因为其没有训练参数,因此将MaxPool2D收集到self.\_cells;依次类推,分别收集第二个卷积层的参数和层信息以及三个全连接层的参数和层信息。实例化之后可以调用net.parameters_and_names来返回训练参数;调用net.cells_and_names查看神经网络层列表。
|
||||
26
website/chapter_programming_interface/summary.md
Normal file
26
website/chapter_programming_interface/summary.md
Normal file
@@ -0,0 +1,26 @@
|
||||
## 总结
|
||||
|
||||
- 现代机器学习系统需要兼有易用性和高性能,因此其一般选择Python作为前端编程语言,而使用C和C++作为后端编程语言。
|
||||
|
||||
- 一个机器学习框架需要对一个完整的机器学习应用工作流进行编程支持。这些编程支持一般通过提供高层次Python API来实现。
|
||||
|
||||
- 数据处理编程接口允许用户下载,导入和预处理数据集。
|
||||
|
||||
- 模型定义编程接口允许用户定义和导入机器学习模型。
|
||||
|
||||
- 损失函数接口允许用户定义损失函数来评估当前模型性能。同时,优化器接口允许用户定义和导入优化算法来基于损失函数计算梯度。
|
||||
|
||||
- 机器学习框架同时兼有高层次Python API来对训练过程,模型测试和调试进行支持。
|
||||
|
||||
- 复杂的深度神经网络可以通过叠加神经网络层来完成。
|
||||
|
||||
- 用户可以通过Python API定义神经网络层,并指定神经网络层之间的拓扑来定义深度神经网络。
|
||||
|
||||
- Python和C之间的互操作性一般通过CType等技术实现。
|
||||
|
||||
- 机器学习框架一般具有多种C和C++接口允许用户定义和注册C++实现的算子。这些算子使得用户可以开发高性能模型,数据处理函数,优化器等一系列框架拓展。
|
||||
|
||||
## 扩展阅读
|
||||
|
||||
- MindSpore编程指南:[MindSpore](https://www.mindspore.cn/docs/programming_guide/zh-CN/r1.6/index.html)
|
||||
- Python和C/C++混合编程:[Pybind11](https://pybind11.readthedocs.io/en/latest/basics.html#creating-bindings-for-a-simple-function)
|
||||
10
website/chapter_recommender_system/future.md
Normal file
10
website/chapter_recommender_system/future.md
Normal file
@@ -0,0 +1,10 @@
|
||||
## 未来可以探索的方向
|
||||
|
||||
为了解决在线深度学习推荐系统的以上几点问题,研究人员也探索了几个潜在的方向。
|
||||
|
||||
- 云--边--端协同推荐系统。随着边缘设备的增加以及用户端设备性能逐渐增强,服务提供者可以通过将部分计算服务从云服务器下放至边缘服务器乃至用户的设备上来提高模型的反应速度。例如,有研究 :cite:`gong2020edgerec`探索了将模型的前几层下放至客户端上,并且利用用户的本地数据进行个性化训练以给出更加准确的推荐结果。当用户的兴趣发生改变时,客户端上的小模型可以实时地更新以响应用户的请求。除此之外,还可以借鉴联邦学习中的概念,例如有研究 :cite:`NEURIPS2020_a1d4c20b`探索了利用知识迁移的方法在云-端之间传递信息。在在线推荐系统中使用这种方法可以彻底解耦云上的大模型与客户端的小模型。
|
||||
|
||||
- 异构硬件多级存储。前文提到GPU显存无法装下完整的模型参数,一些现有的系统 :cite:`MLSYS2020_f7e6c855`为了充分利用GPU的计算优势,采用多级缓存的思想,将部分参数分级存储于显存、主存和固态硬盘上。在他们提出的这个分级系统中,主要解决了缓存策略和异构硬件的适配问题。然而在设计类似的存储系统时,还应该考虑到机器学习模型内在的一些访存特征以进一步优化。Kraken :cite:`9355295`这篇工作讨论了利用机器学习模型的特征对嵌入项的哈希表的存储进行优化的方法。此外,新型硬件的发展为解决大规模推荐模型的高效存储提供了新的可能。比如非易失存储可以作为主存的扩展,进一步提升系统可以支持的模型尺寸。然而目前还没有见到专门为在线机器学习优化的非易失存储系统。另外也有工作 :cite:`MLSYS2021_ec895663`讨论了利用FPGA加速嵌入表的访存并且相比于CPU服务器取得了非常显著的效果。
|
||||
|
||||
|
||||
- 内存高效的嵌入项存储与计算。除了系统上的设计,研究人员也在探索其他算法优化手段来压缩嵌入表的内存需求。直接使用低精度浮点数可以有效降低内存开销,但是还是会对模型的精度产生一定的影响。因此在在线推荐服务这种精度敏感的场景中并不适用。除此之外, :cite:`MLSYS2021_979d472a`利用低秩分解可以将一个大矩阵分解为两个小矩阵(向量)。这种方法可以在保留原矩阵大量信息的前提下显著减小内存开销。除了低秩分解外,还有其他 :cite:`10.1145/3394486.3403059`分解嵌入表的手段。还有研究 :cite:`ginart2021mixed`表明,没有必要为所有的项目都使用一样长的嵌入项,可以根据嵌入项的重要性动态决定其长度以节省内存开销。作为系统设计者,如何将层出不穷的算法优化手段高效地实现是需要考虑的问题。
|
||||
13
website/chapter_recommender_system/index.md
Normal file
13
website/chapter_recommender_system/index.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# 深度学习推荐系统
|
||||
|
||||
推荐系统通过对用户特征、历史行为等数据的分析,为用户推荐可能感兴趣的内容、商品或者广告。在信息爆炸的时代,高效且准确的推荐结果极大地提升了在线服务的质量。近年来,基于深度学习的推荐模型由于可以高效地利用在线服务中产生的海量数据,被谷歌、脸书、阿里巴巴等各大公司广泛应用于生产环境中。本节主要介绍深度学习推荐系统在工业界的主流系统架构、问题以及可能的解决方案。
|
||||
|
||||
```toc
|
||||
:maxdepth: 2
|
||||
|
||||
overview
|
||||
system_architecture
|
||||
system_problem
|
||||
future
|
||||
summary
|
||||
```
|
||||
38
website/chapter_recommender_system/overview.md
Normal file
38
website/chapter_recommender_system/overview.md
Normal file
@@ -0,0 +1,38 @@
|
||||
## 背景
|
||||
|
||||
为了方便本节的讨论,我们首先简单介绍一些推荐系统的基本概念,包括三部分:推荐模型的结构,推荐系统的架构,和评估推荐系统的关键指标。
|
||||
|
||||
### 推荐模型
|
||||
基于深度学习的推荐模型在过去几年受到了学术界和工业界的高度关注,得到了快速发展。目前主流的推荐模型 :cite:`10.1145/2988450.2988454,10.1145/3124749.3124754,ijcai2017-239,naumov2019deep`的基本结构可以总结如图 :numref:`ch10-recommendation-models`。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`ch10-recommendation-models`
|
||||
|
||||
|
||||
推荐模型以用户和内容的交互历史、用户属性、内容属性等特征作为输入,令输入特征进行充分相互作用,再将交互结果交由稠密深度神经网络来预测用户点击候选内容的可能性。为了加深读者的对推荐模型的理解,此处我们以Wide & Deep模型 :cite:`10.1145/2988450.2988454`作为例子,深入分析推荐模型的输入特征以及输入特征之间如何交互。由于推荐模型的设计不在本章的讨论范围内,所以下面的介绍中重点介绍模型的基本结构以方便理解推荐系统的设计。对Wide & Deep模型的设计理念、数据生成、数据预处理等细节感兴趣的读者可以自行阅读论文以进一步了解。
|
||||
|
||||
|
||||
Wide & Deep模型是一个设计简洁然而性能优异的模型,由谷歌(Google)在开发并应用于谷歌应用商店中,该模型在谷歌的实际生产环境中可以大幅提升应用的下载率。
|
||||
|
||||
|
||||
Wide & Deep模型的输入数据可以分为两类:连续特征(例如:用户年龄、用户已安装的应用数量、用户参与的会话数量等)和类别特征(例如:用户ID,用户性别属性、用户设备类型、应用ID等)。连续特征的值本身是具有实际意义的数字,可以直接参与后续模型的运算。而类别特征的值并不具有实际意义,所以需要使用嵌入表(Embedding table)将类别特征转化为数字向量,即嵌入项(Embedding item)。
|
||||
|
||||
|
||||
Wide & Deep模型对输入数据进行两部分处理:Wide部分和Deep部分。Wide部分计算用户已安装应用和候选应用的外积(Cross-product)。Deep部分将所有连续特征和类别特征的嵌入项拼接起来输入一个多层感知机(Multilayer perceptron)。Wide部分和Deep部分按照一定规则拼接然后得到最终的输出。
|
||||
|
||||
### 推荐系统
|
||||
|
||||
在实际的生产环境中,除了推荐模型本身,推荐系统通常包括:数据收集、数据处理、数据存储、模型训练、模型存储、模型评估、推理服务等多个子系统。如图 :numref:`ch10-abstract-recommendation-systems`所示,这些子系统之间分工协作、紧密配合,构成一个从用户反馈、到模型更新、再到新推荐结果生成的闭环。下一小节中将以英伟达(NVIDIA)公司的Merlin开源框架 :cite:`Merlin`为例,概括介绍推荐系统流水线上的组件,并重点介绍模型训练、推理子系统的结构。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`ch10-abstract-recommendation-systems`
|
||||
|
||||
### 关键指标
|
||||
|
||||
- **准确性:**
|
||||
深度学习模型给出的推荐结果的准确性是推荐系统需要关注的一个基本指标。然而不同于一般学术论文中使用的基准数据集,生产环境中推荐模型面对的是动态变化的数据分布。例如,每天的热点内容都不尽相同,而且每个用户的兴趣也会不断变化。为了保持模型的性能,推荐系统需要不断根据用户的反馈对已有模型进行更新。
|
||||
|
||||
- **可用性:**
|
||||
除了推荐准确性,对于在线服务的提供者而言,可用性是一个非常关键的指标。当用户需要一个推荐结果时,相比于给用户一个不完全准确的推荐,"无响应"的结果对于用户的体验伤害更大。因此,在某种程度上可以说系统可用性是比推荐结果的准确性更加关键的一个指标。然而这并不意味着准确性不重要,在一定的资源限制下,在线推荐系统的设计者必须谨慎地在准确性和可用性之间进行平衡。例如,使用更宽、更深、更复杂的神经网络模型可能会给出更加准确的推荐结果,但如果其推断延迟高于给定的阈值,那么这样的模型不能直接运用于生产环境中。
|
||||
19
website/chapter_recommender_system/summary.md
Normal file
19
website/chapter_recommender_system/summary.md
Normal file
@@ -0,0 +1,19 @@
|
||||
## 小结
|
||||
|
||||
推荐系统作为深度学习在工业界最成功的落地成果之一,极大地提升了用户的在线服务体验,并且为各大公司创造了可观的利润,然而也带来了许多系统层面的挑战亟待解决。本节简单介绍了典型的工业界推荐系统架构及其面临的挑战,并给出了潜在的解决方案的方向。在实际生产环境中,具体的系统设计方案需要根据不同推荐场景的需求而变化,不存在一种万能的解决方案。
|
||||
|
||||
## 扩展阅读
|
||||
|
||||
- 推荐模型:[Wide & Deep](https://arxiv.org/abs/1606.07792)
|
||||
|
||||
- 开源推荐系统框架:[Merlin](https://irsworkshop.github.io/2020/publications/paper_21_Oldridge_Merlin.pdf)
|
||||
|
||||
- 软硬件协同设计加速超大规模深度学习推荐系统训练:[ZionEX](https://arxiv.org/abs/2104.05158v5)
|
||||
|
||||
- 利用多级缓存支持超大规模深度学习推荐系统训练:[Distributed Hierarchical GPU Parameter Server for Massive Scale Deep Learning Ads Systems](https://arxiv.org/abs/2003.05622)
|
||||
|
||||
- 工业界机器学习系统的实践:[Hidden Technical Debt in Machine Learning Systems](https://papers.nips.cc/paper/2015/hash/86df7dcfd896fcaf2674f757a2463eba-Abstract.html)
|
||||
|
||||
## 参考文献
|
||||
|
||||
:bibliography:`../references/recommender.bib`
|
||||
20
website/chapter_recommender_system/system_architecture.md
Normal file
20
website/chapter_recommender_system/system_architecture.md
Normal file
@@ -0,0 +1,20 @@
|
||||
## 主流系统架构
|
||||
|
||||
在实际的生产环境中,除了推荐模型本身,一个企业级推荐系统通常包括从用户反馈数据收集,到模型训练,再到服务用户请求的完整流水线(pipeline)。甚至从系统角度来看,推荐模型固然是系统的核心,然而其本身的代码仅占推荐系统的很小一部分,而围绕推荐模型所构建的其他基础设施占据了系统的绝大部分 :cite:`NIPS2015_86df7dcf`。
|
||||
|
||||
|
||||
本节以英伟达公司的Merlin开源框架 :cite:`Merlin`为例,简单介绍推荐系统的完整流水线以及各个组件在其中的作用,关于Merlin特性的完整介绍请读者参考官方文档。
|
||||
Merlin是英伟达公司开发的一个开源推荐系统框架,帮助使用者构建高效的、基于GPU的推荐系统。Merlin提供三个主要开源组件:NVTabular :cite:`NVTabular`,HugeCTR :cite:`HugeCTR`和Triton :cite:`Triton`,分别对数据处理、模型训练核推理服务进行端到端加速。下面分别展开介绍流水线中的三个主要环节:
|
||||
|
||||
1. 数据处理:推荐系统首先从用户的推荐请求中记录用户的反馈数据(例如,用户是否对推荐结果做出了正向反应)到数据存储系统中。随后数据预处理系统对原始反馈数据进行格式化、清洗、重采样等操作生成训练数据。关于数据处理组件,本书在前面章节进行了详细介绍。在Merlin中,这一步骤由NVTabular负责。同时NVTabular还为多种模型训练框架提供了数据加载器(dataloader)。
|
||||
2. 模型训练:推荐模型每次迭代选择一批训练数据,拉取对应的嵌入项、并送入稠密神经网络,计算损失,然后反向传播计算梯度,最终更新嵌入表和稠密神经网络。正如上文提到的,嵌入表占据了推荐模型绝大部分存储而其更新具有显著的稀疏性,因此推荐系统通常采用上一章介绍的参数服务器架构来存储模型。具体来讲,所有参数被分布存储在一组参数服务器上,而训练服务器根据训练数据从参数服务器上拉取对应的嵌入项和所有稠密神经网络参数。训练服务器本地更新之后将本地梯度或新的参数发送回参数服务器以更新全局参数。全局参数更新可以选择全同步,半同步,或异步更新。为了提升训练的吞吐,可以在训练服务器上缓存一部分参数。HugeCTR为此提供了Embedding Training Cache和GPU embedding cache。为了避免训练服务器和参数服务器之间的通信限制训练吞吐率,一些公司也在探索单机多GPU训练超大规模推荐系统。然而正如前文提到的,即使是单个推荐模型的参数量(~100GB)也超出了目前最新的GPU显存。有鉴于此,脸书(Facebook)公司的定制训练平台 -- ZionEX :cite:`zionex`利用计算设备之间的高速链接将多台设备的存储共享起来可以单机训练TB级推荐模型。然而对于更大规模的模型或中小型企业、实验室,参数服务器架构依然是性价比最高的解决方案。
|
||||
3. 推理服务:类似地,推理服务器在接到一批用户的推荐请求后,从参数服务器拉去相应的嵌入项和稠密神经网络参数来响应用户的请求。推荐系统的推理服务对延迟十分敏感,例如脸书公司的DLRM :cite:`naumov2019deep`基准在MLPerf评测中的服务器延迟限定在30ms[^1]。因此如何在限定延迟(latency-bounded)的情况下尽可能提升吞吐(throughput)是推理服务面临的关键问题。在GPU推理场景下,常见的优化手段有:请求动态合批处理、核融合、低精度部署等 :cite:`10.1145/3437801.3441578,wang-etal-2021-lightseq`. Triton提供了请求调度的功能并且支持多种不同的机器学习框架作为后端。
|
||||
|
||||
|
||||
在工业界,为了提升系统在发生故障的情况下的可用性,以上介绍的各个组件在实际中部署中都应该具备基本的容灾和故障恢复能力。以推理服务为例,在线服务中的深度学习推荐模型通常都采用多副本分布式部署。同一个模型的多个副本通常会被部署在至少两个不同的地理区域内的多个数据中心中,如图 :numref:`ch10-recommendation-systems`所示,以应对大面积停电或者网络中断而导致整个地区的所有副本都不可用。除了容错方面的考虑,部署多个副本还有其他几点优势。首先,将模型部署在靠近用户的云服务器上可以提升响应速度。其次,部署多份副本也可以拓展模型推理服务的吞吐率。
|
||||
|
||||

|
||||
:width:`800px`
|
||||
:label:`ch10-recommendation-systems`
|
||||
|
||||
[^1]: https://mlcommons.org/en/inference-datacenter-11/
|
||||
11
website/chapter_recommender_system/system_problem.md
Normal file
11
website/chapter_recommender_system/system_problem.md
Normal file
@@ -0,0 +1,11 @@
|
||||
## 现有解决方案及其存在的问题
|
||||
|
||||
在线服务系统的两个主要诉求:
|
||||
|
||||
- 大模型的高效存储。
|
||||
为了提升训练和推理的性能,通常推荐模型全部存储在内存中,然而纯内存存储对于内存的需求极高。推荐模型的输入中包含大量无法直接进行矩阵运算的类别数据,而由于每种类别数据包含的每种情况都需要一个单独的嵌入项来表示,而稠密深度神经网络的参数可以共享,在大规模推荐模型中,嵌入表占据了绝大部分内存 :cite:`MLSYS2021_979d472a,MLSYS2020_f7e6c855`。举例说明,假设一个推荐模型需要处理1亿条短视频内容,而每条短视频对应的嵌入项为一个64维的32位浮点数向量,那么仅该内容嵌入表就需要占据大约24GB内存。如果考虑到用户标识符等其他嵌入表,那么单个模型可以轻易占据近100GB内存。而在工业界生产环境中,TB级的推荐模型 :cite:`MLSYS2020_f7e6c855`也是非常常见的。此外,在线推荐系统中需要同时运行多个模型负责不同的服务,甚至同一个服务也会上线多个模型以供算法开发人员验证不同的模型结构或者训练策略,因此系统中通常会同时存在上百个超大模型。综上所述,在线推荐系统亟需既能拓展存储容量,又不会影响训练和推理性能的存储解决方案。
|
||||
|
||||
- 大模型的快速更新。
|
||||
在线服务系统所面对的环境是复杂多变的,因此其中的机器学习模型必须不断更新以应对新的数据分布。以一个短视频推荐系统为例,其面对的变化主要来自三点。首先,每时每刻都有大量的新视频上传,这些新视频的特征分布和模型训练时所见到的数据不同;其次,对于不断加入的新用户,模型难以直接给出最优的推荐结果;最后,全部用户和内容之间的交互在不断改变,表现为热点视频在持续变化。因此,为了应对以上变化,在线服务中不可能奢望仅仅训练一次模型就能够一劳永逸地解决问题。目前业界主流的做法是利用新产生的数据不断地增量式更新所部属的模型。在学术界和工业界大量的研究和实践 :cite:`10.1145/2020408.2020444,10.1145/2648584.2648589,10.1145/3267809.3267817,9355295`中都发现模型更新可以有效缓解概念漂移带来的危害,而且更新的频率越高,模型的性能越好。
|
||||
|
||||
在线推荐系统对跨地域地部署的大模型进行快速更新的需求在现有的系统中很难得到满足。一种最直观的解决方案是周期性地将训练服务器上的模型参数发给所有副本。然而这种方式面临着非常大的资源瓶颈。我们以网络开销为例进行分析。假设负责训练的参数服务器存储有100GB的参数,每10分钟将所有参数(在训练集群内部,模型更新的速度极快,10分钟足够将所有参数更新多次)发给其余2个副本。这就需要至少2.6Gbps的网络带宽。然而我们的分析只是最基本的情况,没有考虑网络传输的额外开销以及可能出现的失败重传,也没有考虑需要水平扩展至更多副本、更大模型、更高的更新频率的情况。为了缓解网络瓶颈,人们不得不选择以更慢的速度更新更大的模型,或者限制模型大小以追求更快的更新速度。简单的广播模型参数除了会有很大的资源瓶颈,还无法保证多副本之间的一致性。然而如果采用先前的数据库系统来保证一致性,只能使得资源开销更加严重,进一步限制系统的规模和效率。
|
||||
10
website/chapter_references/index.md
Normal file
10
website/chapter_references/index.md
Normal file
@@ -0,0 +1,10 @@
|
||||
```eval_rst
|
||||
|
||||
.. only:: html
|
||||
|
||||
参考文献
|
||||
==========
|
||||
|
||||
```
|
||||
|
||||
:bibliography:`../mlsys.bib`
|
||||
20
website/chapter_reinforcement_learning/index.md
Normal file
20
website/chapter_reinforcement_learning/index.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# 强化学习系统
|
||||
|
||||
在本章中,我们介绍深度学习的一个重要分支——强化学习及其在系统方面的知识。本章的学习目标包括:
|
||||
|
||||
- 掌握强化学习基本知识。
|
||||
|
||||
- 掌握单节点和多节点强化学习系统设计思路。
|
||||
|
||||
- 掌握多智能体强化学习基础知识及系统设计简介。
|
||||
|
||||
```toc
|
||||
:maxdepth: 2
|
||||
|
||||
rl_introduction
|
||||
single_node_rl
|
||||
distributed_node_rl
|
||||
marl
|
||||
marl_sys
|
||||
summary
|
||||
```
|
||||
41
website/chapter_reinforcement_learning/marl.md
Normal file
41
website/chapter_reinforcement_learning/marl.md
Normal file
@@ -0,0 +1,41 @@
|
||||
## 多智能体强化学习
|
||||
|
||||
以上所讲述的强化学习内容都为单智能体强化学习,而在近来的强化学习研究中,多智能体强化学习越来越受到研究人员关注。回想在本小节初介绍的单智能体强化学习框架 :numref:`ch12/ch12-rl-framework`,其中我们只有单个智能体产生的单个动作对环境产生影响,环境也返回单个奖励值给智能体。这里我们把单智能体强化学习扩展到多智能体强化学习,可以得到至少两种可能的多智能体强化学习框架,如 :numref:`ch12/ch12-marl`所示。 :numref:`ch12/ch12-marl`(a)为多智能体同时执行动作的情况,他们相互之间观察不到彼此的动作,他们的动作一同对环境产生影响,并各自接受自己动作所产生的奖励。 :numref:`ch12/ch12-marl`(b)为多智能体顺序执行动作的情况,后续智能体可能观察到前序智能体的动作,他们的动作一同对环境产生影响,并接受到各自的奖励值或共同的奖励值。除此之外,还有许多其他可能的多智能体框架,如更复杂的智能体间观察机制、智能体间通讯机制、多智能体合作与竞争等等。同时,这里假设多个智能体对环境的观察量都为环境的状态,这是最简单的一种,也是现实中最不可能出现的一种,实际情况下的多智能体往往对环境有各自不同的观察量。
|
||||
|
||||
:width:`800px`
|
||||
|
||||
:label:`ch12/ch12-marl`
|
||||
|
||||
这里我们可以根据前面对单智能体强化学习过程的马尔可夫决策过程描述,给出多智能体强化学习的马尔可夫决策过程,它可以用一个数组$(\mathcal{S}, N, \boldsymbol{\mathcal{A}}, \mathbf{R}, \mathcal{T}, \gamma)$来表示。$N$是智能体个数,$\mathcal{S}$和$\boldsymbol{\mathcal{A}}=(\mathcal{A}_1, \mathcal{A}_2, ..., \mathcal{A}_N)$分别是环境状态空间和多智能体动作空间,其中$A_i$是第$i$个智能体的动作空间,$\mathbf{R}=(R_1, R_2, ..., R_N)$是多智能体奖励函数,$\mathbf{R}(s,\mathbf{a})$: $\mathcal{S}\times \boldsymbol{\mathcal{A}}\rightarrow \mathbb{R}^N$为对于当前状态$s\in\mathcal{S}$和当前多智能体动作$\mathbf{a}\in\boldsymbol{\mathcal{A}}$的奖励向量值,其中$R_i$是对第$i$个智能体的奖励值。从当前状态和动作到下一个状态的状态转移概率定义为$\mathcal{T}(s^\prime|s,\mathbf{a})$: $\mathcal{S}\times\boldsymbol{\mathcal{A}}\times\mathcal{S}\rightarrow \mathbb{R}_+$。$\gamma\in(0,1)$是奖励折扣因子(假设多个智能体采用相同的奖励折扣因子)。不同于单智能体强化学习,多智能体强化学习的目标除了常见的最大化每个智能体各自的期望累计奖励值$\mathbb{E}[\sum_t \gamma^t r^i_t], i\in[N]$之外,还有许多其他可能的学习目标,如达到纳什均衡、最大化团队奖励等等。
|
||||
|
||||
由上述介绍和定义可以发现,多智能体强化学习是一个比单智能体强化学习更加复杂的问题。而实际上,多个智能体的存在,对于每个智能体的决策而言,绝对不是简单的把每个单智能体决策累加的难度,实际情况要比单智能体决策问题复杂很多。多智能体系统的研究实际上是门古老的学科,它与博弈论(Game Theory)密切相关,在深度强化学习盛行以前早已有大量研究和许多理论上未解的难题。其中一个典型的问题是纳什均衡在双人非零和博弈下没有多项式时间内可解的方法(实际上,这是一个PPAD(Polynomial Parity Argument, Directed version)类的问题。(见论文Settling the Complexity of Computing Two-Player Nash Equilibria. Xi Chen, et al.)由于篇幅限制,我们这里无法对多智能体问题做深入探讨,我们可以用一个简单例子来介绍为什么多智能体强化学习问题无法简单地用单智能体强化学习算法来解。
|
||||
|
||||
:剪刀-石头-布的奖励值表
|
||||
|
||||
| 奖励值 | 剪刀 | 石头 | 布 |
|
||||
| --- | ------- | ------- | ------- |
|
||||
| **剪刀** | (0,0) | (-1,+1) | (+1,-1) |
|
||||
| **石头** | (+1,-1) | (0,0) | (-1,+1) |
|
||||
| **布** | (-1,+1) | (+1,-1) | (0,0) |
|
||||
|:label:`tab_ch12_ch12_marl`||||
|
||||
|
||||
我们考虑一个大家都熟悉的游戏, 剪刀-石头-布,考虑两个玩家玩这个游戏的输赢情况,我们知道有这样的输赢关系:剪刀<石头<布<剪刀...这里的“<”即前一个纯策略被后一个纯策略完全压制,我们给予奖励值-1、+1到这两个玩家,当他们选择相同的纯策略时,奖励值均为0。于是我们得到一个奖励值表如 :numref:`tab_ch12_ch12_marl`所示,横轴为玩家1,纵轴为玩家2,表内的数组为玩家1和玩家2各自在相应动作下得到的奖励值。
|
||||
|
||||
由于这个矩阵的反对称性,这个问题的纳什均衡策略对两个玩家相同,均为$(\frac{1}{3}, \frac{1}{3}, \frac{1}{3})$的策略分布,即有各$\frac{1}{3}$的概率出剪刀、石头或布。如果我们把得到这个纳什均衡策略作为多智能体学习的目标,那么我们可以简单分析得到这个均衡策略无法通过简单的单智能体算法得到。考虑我们随机初始化两个玩家为任意两个纯策略,比如玩家1出剪刀,玩家2出石头。这时假设玩家2策略固定,可以把玩家2看做固定环境的一部分,于是可以使用任意单智能体强化学习算法对玩家1进行训练,使其最大化自己的奖励值。于是,玩家1会收敛到布的纯策略。这时再把玩家1固定,训练玩家2,玩家2又收敛到剪刀的纯策略。于是循环往复,整个训练过程始终无法收敛,玩家1和2各自在3个策略中循环却无法得到正确的纳什均衡策略。
|
||||
|
||||
|
||||

|
||||
|
||||
:width:`600px`
|
||||
|
||||
:label:`ch12/ch12-marl-sp`
|
||||
|
||||
我们在上面这个例子中采用的学习方法其实是多智能体强化学习中最基础的一种,叫自学习(Selfplay),如 :numref:`ch12/ch12-marl-sp`所示。自学习的方法即固定当前玩家 1 的策略,按照单智能体优化的方法最大化一侧智能体的表现,所得策略称为最佳反应策略(Best Response Strategy)。之后再将这一最佳反应策略作为玩家 2 的固定策略,再来优化另一边的智能体策略,如此循环。我们可以看到自学习在特定的任务设置下可能无法收敛到我们想要的最终目标。正是由于多智能体学习过程中有类似循环结构的出现,我们需要更复杂的训练方法,和专门针对多智能体的学习方式来达到我们想要的目标。
|
||||
|
||||
一般来讲,多智能体强化学习是比单智能体强化学习更复杂的一类,对于自学习的方法而言,单智能体强化学习的过程可以看做一个多智能体强化学习的子任务。从前面这一小游戏的角度来理解,当玩家 1 策略固定时,玩家 1 加游戏环境构成玩家 2 的实际学习环境,由于这个环境是固定的,玩家 2 可以通过单智能体强化学习来达到自身奖励值最大化;这时再固定玩家 2 的策略,玩家 1 又可以进行单智能体强化学习...... 这样,单智能体强化学习是多智能体任务的子任务。如 :numref:`ch12/ch12-marl-fsp`,其他算法如虚构自学习(Fictitious Self-play),需要在每个单智能体强化学习的步骤中,对对手历史策略的平均策略求得最优应对策略,而对手的训练也是如此,进行循环,能够在上面剪刀-石头-布一类的游戏中保证收敛到纳什均衡策略。
|
||||
|
||||

|
||||
|
||||
:width:`600px`
|
||||
|
||||
:label:`ch12/ch12-marl-fsp`
|
||||
41
website/chapter_reinforcement_learning/marl_sys.md
Normal file
41
website/chapter_reinforcement_learning/marl_sys.md
Normal file
@@ -0,0 +1,41 @@
|
||||
## 多智能体强化学习系统
|
||||
|
||||
上述的简单例子只是为了帮助读者理解强化学习在多智能体问题里的角色,而如今前沿的多智能体强化学习算法已经能够解决相当大规模的复杂多智能体问题,如星际争霸(StarCraft II)、Dota 2等游戏,已相继被DeepMind、OpenAI等公司所研究的智能体AlphaStar :cite:`vinyals2019grandmaster`和OpenAI Five :cite:`berner2019dota`攻克,达到超越人类顶级玩家的水平。国内公司如腾讯、启元世界等也提出了星际争霸游戏的多智能体强化学习解决方案TStarBot-X :cite:`han2020tstarbot`和SCC :cite:`wang2021scc`。对于这类高度复杂的游戏环境,整个训练过程对分布式计算系统的要求更高,而整个训练过程可能需要分为多个阶段。以 AlphaStar 为例,它训练的智能体采用了监督学习与强化学习结合的方式。在训练早期,往往先采用大量的人类专业玩家标定数据进行有监督的学习,从而使智能体快速获得较好的能力,随后,训练会切换到强化学习过程,使用前面介绍的虚构自学习的算法进行训练,即自我博弈。为了得到一个表现最好的智能体,算法需要充分探索整个策略空间,从而在训练中不止对一个策略进行训练,而是对一个策略集群(League)进行训练,并通过类似演化算法的方式对策略集群进行筛选,得到大量策略中表现最好的策略。如 :numref:`ch12/ch12-marl_train`所示,在训练过程中每个智能体往往需要
|
||||
和其他智能体以及剥削者(Exploiter)进行博弈,剥削者是专门针对某一个智能体策略的最佳对手策略,与之对抗可以提高策略自身的防剥削能力。通过对大量智能体策略进行训练并筛选的这类方法称为集群式训练(Population-based Training/League Training),是一种通过分布式训练提高策略种群多样性进而提升模型表现的方式。可见,在实践中这类方法自然需要分布式系统支持,来实现多个智能体的训练和相互博弈,这很好地体现了多智能体强化学习对分布式计算的依赖性。
|
||||
|
||||

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

|
||||
|
||||
:width:`800px`
|
||||
|
||||
:label:`ch12/ch12_marl_sys`
|
||||
27
website/chapter_reinforcement_learning/rl_introduction.md
Normal file
27
website/chapter_reinforcement_learning/rl_introduction.md
Normal file
@@ -0,0 +1,27 @@
|
||||
## 强化学习介绍
|
||||
|
||||
近年来,强化学习作为机器学习的一个分支受到越来越多的关注。2013 年 DeepMind 公司的研究人员提出了深度 Q 学习 :cite:`mnih2013playing`(Deep Q-learning),成功让 AI 从图像中学习玩电子游戏。自此以后,以 DeepMind 为首的科研机构推出了像 AlphaGo 围棋 AI 这类的引人瞩目的强化学习成果,并在 2016 年与世界顶级围棋高手李世石的对战中取得了胜利。自那以后,强化学习领域连续取得了一系列成就,如星际争霸游戏智能体 AlphaStar、Dota 2 游戏智能体 OpenAI Five、多人零和博弈德州扑克的 Pluribus、机器狗运动控制算法等。在这一系列科研成就的背后,是整个强化学习领域算法在这些年内快速迭代进步的结果,基于模拟器产生的大量数据使得对数据“饥饿”(Data Hungry)的深度神经网络能够表现出很好的拟合效果,从而将强化学习算法的能力充分发挥出来,在以上领域中达到或者超过人类专家的学习表现。目前,强化学习已经从电子游戏逐步走向更广阔的应用场景,如机器人控制、机械手灵巧操作、能源系统调度、网络负载分配、股票期货交易等一系列更加现实和富有意义的领域,对传统控制方法和启发式决策理论发起冲击。
|
||||
|
||||

|
||||
|
||||
:width:`400px`
|
||||
|
||||
:label:`ch12/ch12-rl-framework`
|
||||
强化学习的核心是不断地与环境交互来优化策略从而提升奖励的过程,主要表现为基于某个**状态**(State)下的**动作**(Action)的选择。进行这一决策的对象我们常称为**智能体**(Agent),而这一决策的影响将在**环境**(Environment)中体现。更具体地,不同的决策会影响环境的**状态转移**(State Transition)和**奖励**(Reward)。以上状态转移是环境从当前状态转移到下一状态的函数,它可以是确定性也可以是随机性的。奖励是环境对智能体动作的反馈,通常是一个标量。以上过程可以抽象为 :numref:`ch12/ch12-rl-framework`所示,这是文献中最常见的强化学习的模型描述。
|
||||
|
||||
举例来说,当人在玩某个电子游戏的时候,需要逐渐熟悉游戏的操作以取得更好的游戏结果,那么人从刚接触到这个游戏到逐步掌握游戏技巧的这个过程为一个类似于强化学习的过程。该游戏从开始后的任一时刻,会处于一个特定的状态,而人通过观察这个状态会获得一个**观察量**(Observation)(如观察游戏机显示屏的图像),并基于这个观察量做出一个操作动作(如发射子弹),这一动作将改变这个游戏下一时刻的状态,使其转移到下一个状态(如把怪物打败了),并且玩家可以知道当前动作的效果(如产生了一个正或负的分数,怪物打败了则获得正分数)。这时玩家再基于下一个状态的观察量做出新的动作选择,周而复始,直到游戏结束。通过反复的操作和观察,人能够逐步掌握这个游戏的技巧,一个强化学习智能体也是如此。
|
||||
|
||||
这里注意,有几个比较关键的问题:一是观察量未必等于状态,而通常观察量是状态的函数,从状态到观察量的映射可能有一定的信息损失。对于观察量等于状态或者根据观察量能够完全恢复环境状态的情况,我们称为**完全可观测**(Fully Observable),否则我们称为**部分可观测**(Partially Observable)环境;二是玩家的每个动作未必会产生立即反馈,某个动作可能在许多步之后才产生效果,强化学习模型允许这种延迟反馈的存在;三是这种反馈对人的学习过程而言未必是个数字,但是我们对强化学习智能体所得到的反馈进行数学抽象,将其转变为一个数字,称为奖励值。奖励值可以是状态的函数,也可以是状态和动作的函数,依具体问题而定。奖励值的存在是强化学习问题的一个基本假设,也是现有强化学习与监督式学习的一个主要区别
|
||||
|
||||
强化学习的决策过程通常由一个马尔可夫决策过程(Markov Decision Process,MDP)(马尔可夫决策过程即一个后续状态只依赖当前状态和动作而不依赖于历史状态的函数)描述,可以用一个数组$(\mathcal{S}, \mathcal{A}, R, \mathcal{T}, \gamma)$来表示。$\mathcal{S}$和$\mathcal{A}$分别是状态空间和动作空间,$R$是奖励函数,$R(s,a)$: $\mathcal{S}\times \mathcal{A}\rightarrow \mathbb{R}$为对于当前状态$s\in\mathcal{S}$和当前动作$a\in\mathcal{A}$的奖励值。从当前状态和动作到下一个状态的状态转移概率定义为$\mathcal{T}(s^\prime|s,a)$: $\mathcal{S}\times\mathcal{A}\times\mathcal{S}\rightarrow \mathbb{R}_+$。$\gamma\in(0,1)$是奖励折扣因子(折扣因子可以乘到每个后续奖励值上,从而使无穷长序列有有限的奖励值之和)。强化学习的目标是最大化智能体的期望累计奖励值$\mathbb{E}[\sum_t \gamma^t r_t]$。
|
||||
|
||||
马尔可夫决策过程中的马尔可夫性质由以下定义
|
||||
|
||||
$$
|
||||
\mathcal{T}(s_{t+1}|s_t) = \mathcal{T}(s_{t+1}|s_0, s_1, s_2, \dots, s_t)
|
||||
$$
|
||||
|
||||
即当前状态转移只依赖于上一时刻状态,而不依赖于整个历史。这里的状态转移函数$\mathcal{T}$中省略了动作$a$,马尔可夫性质是环境转移过程的属性,其独立于产生动作的决策过程。
|
||||
|
||||
基于马尔可夫性质,可以进一步推导出在某一时刻最优策略不依赖于整个决策历史,而只依赖于当前最新状态的结论。这一结论在强化学习算法设计中有着重要意义,它简化了最优策略的求解过程。
|
||||
|
||||
21
website/chapter_reinforcement_learning/single_node_rl.md
Normal file
21
website/chapter_reinforcement_learning/single_node_rl.md
Normal file
@@ -0,0 +1,21 @@
|
||||
## 单节点强化学习系统
|
||||
|
||||
前面介绍了强化学习的基本知识,这里我们介绍常见的单智能体强化学习系统中较为简单的一类,即单节点强化学习系统,这里的节点是指一个用于模型更新的计算单元。我们按照是否对模型更新的过程做并行化处理,将强化学习系统分为单节点和分布式强化学习系统。其中,单节点强化学习系统可以理解为只实例化一个类对象作为智能体,与环境交互进行采样和利用所采得的样本进行更新的过程分别视为这个类内的不同函数。除此之外的更为复杂的强化学习框架都可视为分布式强化学习系统。
|
||||
|
||||
分布式强化学习系统的具体形式有很多,系统的形式也往往依赖于所实现的算法。从最简单的情况考虑,假设我们仍在同一个计算单元上实现算法,但是将强化学习的采样过程和更新过程实现为两个并行的进程,甚至各自实现为多个进程,以满足不同计算资源间的平衡。这时就需要进程间通信来协调采样和更新过程,这是一个最基础的分布式强化学习框架。更为复杂的情况是,整个算法的运行在多个计算设备上进行(如一个多机的计算集群),智能体的函数可能需要跨机跨进程间的通信来实现。对于多智能体系统,还需要同时对多个智能体的模型进行更新,则需要更为复杂的计算系统设计。我们将逐步介绍这些不同的系统内的实现机制。
|
||||
|
||||
我们先对单节点强化学习系统进行介绍。在这里,我们以RLzoo :cite:`ding2020efficient`为例,讲解一个单节点强化学习系统构建所需要的基本模块。如 :numref:`ch12/ch12-rlzoo`所示,是RLzoo算法库中采用的一个典型的单节点强化学习系统,它包括几个基本的组成部分:神经网络、适配器、策略网络和价值网络、环境实例、模型学习器、经验回放缓存(Experience Replay Buffer)等。
|
||||
|
||||
我们先对前三个,神经网络、适配器、策略网络和价值网络进行介绍。神经网络即一般深度学习中的神经网络,用于实现基于数据的函数拟合,我们在图中简单列出常见的三类神经网络:全连接网络,卷积网络和循环网络。策略网络和价值网络是一般深度强化学习的常见组成部分,策略网络即一个由深度神经网络参数化的策略表示,而价值网络为神经网络表示的状态价值(State-Value)或状态-动作价值(State-Action Value)函数。这里我们不妨称前三类神经网络为一般神经网络,策略网络和价值网络为强化学习特定网络,前者往往是后者的重要组成部分。在RLzoo中,适配器则是为实现强化学习特定网络而选配一般神经网络的功能模块。首先,根据不同的观察量类型,强化学习智能体所用的神经网络头部会有不同的结构,这一选择可以由一个基于观察量的适配器来实现;其次,根据所采用的强化学习算法类型,相应的策略网络尾部需要有不同的输出类型,包括确定性策略和随机性策略,RLzoo 中使用一个策略适配器来进行选择;最后,根据不同的动作输出,如离散型、连续型、类别型等,需要使用一个动作适配器来选择。:numref:`ch12/ch12-rlzoo`中我们统称这三个不类型的适配器为适配器。
|
||||
|
||||
介绍完这些,我们已经有了可用的策略网络和价值网络,这构成了强化学习智能体核心学习模块。除此之外,还需要一个学习器(Learner)来更新这些学习模块,更新的规则就是强化学习算法给出的损失函数。而要想实现学习模块的更新,最重要的是输入的学习数据,即智能体跟环境交互过程中所采集的样本。对于**离线**(Off-Policy)强化学习,这些样本通常被存储于一个称为经验回放缓存的地方,学习器在需要更新模型时从该缓存中采得一些样本来进行更新。这里说到的离线强化学习是强化学习算法中的一类,强化学习算法可以分为在线(On-Policy)强化学习和离线(Off-Policy)强化学习两类,按照某个特定判据。这个判据是,用于更新的模型和用于采样的模型是否为同一个,如果是,则称在线强化学习算法,否则为离线强化学习算法。因而,离线强化学习通常允许与环境交互所采集的样本被存储于一个较大的缓存内,从而允许在许久之后再从这个缓存中抽取样本对模型进行更新。而对于在线强化学习,这个“缓存”有时其实也是存在的,只不过它所存储的是非常近期内采集的数据,从而被更新模型和用于采样的模型可以近似认为是同一个。从而,这里我们简单表示 RLzoo 的强化学习系统统一包括这个经验回放缓存模块。有了以上策略和价值网络、经验回放缓存、适配器、学习器,我们就得到了 RLzoo 中一个单节点的强化学习智能体,将这个智能体与环境实例交互,并采集数据进行模型更新,我们就得到了一个完整的单节点强化学习系统。这里的环境实例化我们允许多个环境并行采样。
|
||||
|
||||

|
||||
|
||||
:width:`800px`
|
||||
|
||||
:label:`ch12/ch12-rlzoo`
|
||||
近来研究人员发现,强化学习算法领域的发展瓶颈,可能不仅在于算法本身,而在于让智能体在其中采集数据的模拟器的模拟速度。Isaac Gym :cite:`makoviychuk2021isaac`是Nvidia公司于2021年推出的基于GPU(Graphics Processing Unit)的模拟引擎,在单GPU上实现2-3倍于之前基于CPU(Central Processing Unit)的模拟器的运行速度。关于 GPU上运行加速我们已经在章节 5 中有所介绍。之所以 GPU 模拟能够对强化学习任务实现显著的加
|
||||
速效果,除了 GPU 本身多核心的并行运算能力之外,还在于这省却了 CPU 与 GPU 之间的数据传输和通信时间。传统的强化学习环境,如 OpenAI Gym(这是一个常用的强化学习基准测试环境)等,都是基于 CPU 进行的模拟计算,而深度学习方法的神经网络训练通常是在 GPU 或TPU(Tensor Processing Unit) 上进行的。
|
||||
|
||||
从智能体与 CPU 上实例化的模拟环境交互过程所收集的数据样本,通常先暂时以 CPU 的数据格式存储,在使用的时候被转移到 GPU 上成为具有 GPU 数据类型的数据(如使用 PyTorch 时可通过tensor.to(device)的函数实现,只需将device设为“cuda”即可将一个类型为torch.Tensor的tensor转移到GPU上),然后来进行模型训练。同时,由于模型参数是以 GPU 上数据的类型存储的,调用模型进行前向传递的过程中也需要先将输入数据从 CPU 转移到 GPU 上,并且可能需要将模型输出的 GPU 数据再转移回 CPU 类型。这一系列冗余的数据转换操作都会显著增长模型学习的时间,并且也增加了算法实际使用过程中的工程量。Isaac Gym 模拟器的设计从模拟器下层运行硬件上解决了这一困难,由于模拟器和模型双双实现在 GPU 上,他们之间的数据通信不再需要通过 CPU 来实现,从而绕过了 CPU 与 GPU 数据双向传输这一问题,实现了对强化学习任务中模拟过程的特定加速。
|
||||
7
website/chapter_reinforcement_learning/summary.md
Normal file
7
website/chapter_reinforcement_learning/summary.md
Normal file
@@ -0,0 +1,7 @@
|
||||
## 小结
|
||||
|
||||
在这一章,我们简单介绍了强化学习的基本概念,包括单智能体和多智能体强化学习算法、单节点和分布式强化学习系统等,给读者对强化学习问题的基本认识。当前,强化学习是一个快速发展的深度学习分支,许多实际问题都有可能通过强化学习算法的进一步发展得到解决。另一方面,由于强化学习问题设置的特殊性(如需要与环境交互进行采样等),也使得相应算法对计算系统的要求更高:如何更好地平衡样本采集和策略训练过程?如何均衡 CPU 和 GPU 等不同计算硬件的能力?如何在大规模分布式系统上有效部署强化学习智能体?都需要对计算机系统的设计和使用有更好的理解。
|
||||
|
||||
## 参考文献
|
||||
|
||||
:bibliography:`../references/reinforcement.bib`
|
||||
22
website/chapter_rl_sys/control.md
Normal file
22
website/chapter_rl_sys/control.md
Normal file
@@ -0,0 +1,22 @@
|
||||
## 控制系统
|
||||
|
||||
虽然控制理论已牢牢植根于基于模型(Model-based)的设计传统,但丰富的数据和机器学习给控制理论带来了新的机遇。控制理论和机器学习的交叉点涵盖了广泛的研究方向,包括但不限于动态系统的学习、在线学习和控制、深度学习的控制理论观点、强化学习以及在各种现实世界系统中的应用。
|
||||
从机器学习的角度来看,未来的主要挑战之一是超越模式识别并解决数据驱动控制和动态过程优化方面的问题。
|
||||
|
||||
理论方面,线性二次控制(Linear-Quadratic
|
||||
Control)是经典的控制方法,最近有关于图神经网络在分布式线性二次控制的研究。作者称将线性二次问题转换为自监督学习问题,能够找到基于图神经网络(Graph
|
||||
Neural
|
||||
Networks,GNN)的最佳分布式控制器,他们还推导出了所得闭环系统稳定的充分条件。随着基于数据和学习的机器人控制方法不断得到重视,研究人员必须了解何时以及如何在现实世界中最好地利用这些方法,因为安全是至关重要的,有的研究通过学习不确定的动力学来安全地提高性能,鼓励安全或稳健的强化学习方法,以及可以正式认证所学控制策略的安全性的方法。 :numref:`safe\_learning\_control`展示了安全学习控制(Safe Learning
|
||||
Control)系统的框架图,用数据驱动的方法来学习控制策略,兼顾安全性。Lyapunov
|
||||
函数是评估非线性动力系统稳定性的有效工具,最近有人提出Neural
|
||||
Lyapunov来将安全性纳入考虑。
|
||||
|
||||
应用方面,有基于神经网络的自动驾驶汽车模型预测控制,也有研究将最优控制和学习相结合并应用在陌生环境中的视觉导航,该研究将基于模型的控制与基于学习的感知相结合来解决。基于学习的感知模块产生一系列航路点通过无碰撞路径引导机器人到达目标。基于模型的规划器使用这些航路点来生成平滑且动态可行的轨迹,该轨迹使用反馈控制在物理系统上执行。在模拟的现实世界杂乱环境和实际地面车辆上的实验表明,与纯粹基于几何映射或基于端到端学习的替代方案相比,这种新的系统可以在新环境中更可靠、更有效地到达目标位置。强化学习和模仿学习与控制论有密切联系:LEOC整合了强化学习和经典控制理论的原则方法。有人将基于模型的离线强化学习算法扩展到高维视觉观察空间并在真实机器人上执行基于图像的抽屉关闭任务方面表现出色。控制部分通过神经网络优化可以更加平滑、节能、安全,如何将
|
||||
神经网络和传统控制理论结合,特别是和运动学算法相结合,将会是一个有趣的方向。
|
||||
|
||||

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

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

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

|
||||
|
||||
这样一来,我们的机器人就准备好在虚拟事件中移动了。
|
||||
|
||||
请在RViz的工具栏中选择`Navigation2 Goal`按钮,并在地图上选择你想要Turtlebot3机器人最终所到达的位置和姿态。
|
||||
一旦选好了,你将会看到机器人开始向目标位置移动并最终到达目标。
|
||||
|
||||
RViz还提供了很多其它的Nav2功能的按钮,你可以通过Nav2和ROS2的官方英文文档来了解更多使用方法。
|
||||
|
||||
恭喜,你现在初步了解了怎样使用ROS2框架内的大型功能模组!
|
||||
|
||||
#### 章节附录:在WSL中使用Nav2
|
||||
|
||||
有些读者可能是通过Windows下的WSL(Windows Subsystem for Linux)来运行ROS2的。
|
||||
如果是这种情况,这一章节中的图形界面程序,如RViz和Gazebo,可能会造成问题。
|
||||
这是因为WSL默认并不能打开图形界面程序。
|
||||
|
||||
幸运的是,我们可以更改设置来达到在WSL中运行图形界面程序这一点。
|
||||
[这篇笔记](https://github.com/rhaschke/lecture/wiki/WSL-install)介绍了其作者是如何在WSL中运行ROS2和图形界面的。其中第二点尤为值得注意。
|
||||
而[这篇笔记](https://github.com/cascadium/wsl-windows-toolbar-launcher#firewall-rules)则更为细致的介绍了在一般情况下怎样在WSL中运行图形界面程序。
|
||||
|
||||
这两篇笔记应该可以给读者足够的信息来解决上述所说的和RViz还有Gazebo相关的问题。唯一的缺点就是这两篇笔记都是英文的,对读者的英语水平有一定要求。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user