Fix chapter_frontend_and_ir (#205)

Co-authored-by: liangzhibo <liangzhibo@huawei.com>
Co-authored-by: Dalong <39682259+eedalong@users.noreply.github.com>
This commit is contained in:
Liang ZhiBo
2022-03-25 09:16:12 +08:00
committed by GitHub
parent cdec276546
commit 310383b763
2 changed files with 65 additions and 65 deletions

View File

@@ -121,7 +121,7 @@ $$\mathbf{r}^{T}\mathbf{J}_{f}=
\frac{\partial y_m}{\partial x_1} & \cdots & \frac{\partial y_m}{\partial x_n}
\end{bmatrix}$$
在求解函数$f$的雅克比矩阵时,前向模式的迭代次数与雅克比矩阵的列数相关,而反向模式的迭代次数则与雅克比矩阵的行数相关。因此,在函数输出个数远远大于输入个数时$(f:{\mathbf{R}^n}\to \mathbf{R}^m, n << m)$,前向模式效率更高;反之,在函数输入个数远远大于输出个数时$(f:{\mathbf{R}^n}\to \mathbf{R}^m, n >> m)$,反向模式效率更高。在极端情况下的函数$f:{\mathbf{R}^n}\to \mathbf{R}$,只需要应用一次反向模式就已经能够把所有输出对输入的导数$(\frac{\partial y}{\partial x_1},\cdots,\frac{\partial y}{\partial n})$都计算出来而前向模式则需要执行n次。这种计算一个标量值的输出关于大量参数输入的梯度的场景恰好是机器学习实践中最常见的一种计算场景这使得反向模式的自动微分成为反向传播算法使用的核心技术之一。
在求解函数$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
@@ -147,54 +147,54 @@ 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 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
def ADDiv(x, dx, y, dy, z, dz):
z = x / y
dz = dx / y + (x / (y * y)) * dy
```
基本表达式法的优缺点显而易见,优点是实现简单直接,可为任意语言快速实现微分的库函数;而缺点是增加了用户的工作量,用户必须先手工分解程序为一些基本表达式,才能使用这些库函数进行编程,无法方便地使用语言原生的表达式。
2操作符重载法Operator Overlading,
OO依赖于现代编程语言的多态特性使用操作符重载对编程语言中的基本操作语义进行重定义封装其微分规则。每个基本操作类型及其输入关系在程序运行时会被记录在一个所谓的"tape"的数据结构里面,最后,这些"tape"会形成一个跟踪轨迹(trace)我们就可以使用链式法则沿着轨迹正向或者反向地将基本操作组成起来进行微分。以自动微分库AutoDiff为例对编程语言的基本运算操作符进行了重载
```C++
namespace AutoDiff
namespace AutoDiff
{
public abstract class Term
{
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();
}
// 重载操作符 `+``*` 和 `/`,调用这些操作符时,会通过其中的
// 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的自动微分框架使用了该方法。
@@ -217,36 +217,36 @@ MindSpore的自动微分使用基于闭包的代码变换法实现转换
涉及控制流语句时因为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
}
// 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(); // 获取梯度函数计算图
}
function Grad {
Init();
MapObject(); // 实现Parameter/Primitive/FuncGraph/FreeVariable对象的映射
MapMorphism(); // 实现CNode的映射
Finish();
Return GetKGraph(); // 获取梯度函数计算图
}
```
Grad函数先通过MapObject实现图上自由变量、Parameter和ValueNodePrimitive或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()
@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,)
def bprop(x, out, dout):
dx = input_grad(dout, out)
return (dx,)
return bprop
return bprop
```
随后MapMorphism函数从原函数的输出节点开始实现对CNode的映射并建立起节点间的反向传播连接实现梯度累加最后返回原函数的梯度函数计算图。

View File

@@ -7,7 +7,7 @@
- 机器学习框架的中间对中间表示有一系列新的需求,这些新的需求是传统中间表示所不能完美支持的。因此需要在传统中间表示的基础上扩展新的,更适用于机器学习框架的中间表示。
- 自动微分的基本思想是将计算机程序中的运算操作分解为一个有限的基本操作集合,且集合中基本操作的求导规则均为已知,在完成每一个基本操作的求导后,使用链式法则将结果组合得到整体程序的求导结果。
- 自动微分的基本思想是将计算机程序中的运算操作分解为一个有限的基本操作集合,且集合中基本操作的求导规则均为已知,在完成每一个基本操作的求导后,使用链式法则将结果组合得到整体程序的求导结果。
- 自动微分根据链式法则的组合顺序,可以分为前向自动微分与反向自动微分。