diff --git a/chapter_frontend_and_ir/ad.md b/chapter_frontend_and_ir/ad.md index d2b1aa6..aa538b5 100644 --- a/chapter_frontend_and_ir/ad.md +++ b/chapter_frontend_and_ir/ad.md @@ -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和ValueNode(Primitive或FuncGraph)等节点到$fprop$的映射。$fprop$是$(forward\_result, bprop)$形式的梯度函数对象。$forward\_result$是前向计算图的输出节点,$bprop$是以$fprop$的闭包对象形式生成的梯度函数,它只有$dout$一个入参,其余的输入则是引用的$fprop$的输入和输出。其中对于ValueNode\类型的$bprop$,通过解析Python层预先注册的$get\_bprop$函数的得到,如下所示。对于ValueNode\类型的节点,则递归求出它的梯度函数对象。 ```python - @bprop_getters.register(P.ReLU) - def get_bprop_relu(self): - """Grad definition for `ReLU` operation.""" - input_grad = G.ReluGrad() +@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的映射,并建立起节点间的反向传播连接,实现梯度累加,最后返回原函数的梯度函数计算图。 diff --git a/chapter_frontend_and_ir/summary.md b/chapter_frontend_and_ir/summary.md index 9f42f31..fdd82c5 100644 --- a/chapter_frontend_and_ir/summary.md +++ b/chapter_frontend_and_ir/summary.md @@ -7,7 +7,7 @@ - 机器学习框架的中间对中间表示有一系列新的需求,这些新的需求是传统中间表示所不能完美支持的。因此需要在传统中间表示的基础上扩展新的,更适用于机器学习框架的中间表示。 -- 自动微分的基本思想是将将计算机程序中的运算操作分解为一个有限的基本操作集合,且集合中基本操作的求导规则均为已知,在完成每一个基本操作的求导后,使用链式法则将结果组合得到整体程序的求导结果。 +- 自动微分的基本思想是将计算机程序中的运算操作分解为一个有限的基本操作集合,且集合中基本操作的求导规则均为已知,在完成每一个基本操作的求导后,使用链式法则将结果组合得到整体程序的求导结果。 - 自动微分根据链式法则的组合顺序,可以分为前向自动微分与反向自动微分。