计算图是一种有向图,通过运算、数值及其相互依赖关系来表示计算过程。它描述如何将较简单的步骤组合起来求取函数的值,而不只是给出最终的公式。计算图在机器学习中十分重要,可支持模型执行和自动微分;在数值程序编译中,它也能揭示优化执行过程和内存使用的机会。不同系统采用的具体表示方式有所不同:可以将运算作为节点,让数值沿边传递,也可以将数值本身表示为节点。(tensorflow.org)
结构与求值
在以运算为中心的表示方式中,每个节点对传入边提供的输入执行运算,并生成供后续节点使用的输出。数值可以是标量、数组或张量,其中也包括矩阵。一个节点既可以表示简单的加法,也可以表示矩阵乘法等更复杂的运算。例如,在 TensorFlow 中,图包含运算对象,以及描述运算之间数据流动的张量对象。(tensorflow.org)
不含显式循环的有限计算通常表示为有向无环图。求值过程遵循依赖关系:只有在所需输入均已就绪后,运算才会执行。不同分支可以共享中间结果,因此这种表示不一定是树。这样的结构也提供了反向微分所需的依赖信息。(docs.pytorch.org)
以下面的计算为例:
对应的图包含乘法、加法和平方运算。当 、、 时,求值得到 、、。这些中间变量明确呈现了依赖关系的先后顺序。
并非所有基于图的编程系统都要求图无环。循环可以展开为重复的运算,也可以通过结构化控制流运算表示,或采用循环数据流机制表达。因此,用于实际执行微分的图与表示整个程序的图,结构不一定相同。TensorFlow 最初的设计明确支持用循环数据流图实现控制流。(tensorflow.org)
自动微分
计算图将基本运算与局部导数规则关联起来,再通过链式法则组合这些规则,从而支持微分。微分系统不会先推导出整个程序展开后的完整表达式,而是沿着组成程序的各项运算传播导数信息。对于向量值计算,相应的局部导数由雅可比矩阵描述,不过实际实现通常直接计算与这些矩阵的乘积,而不显式构造矩阵。(docs.jax.dev)
前向模式微分在执行原始计算的同时,传播输入沿某个方向的变化。对于 ,它计算雅可比矩阵与向量的乘积 。反向模式微分从输出的加权开始,反向传播敏感度,计算向量与雅可比矩阵的乘积。反向模式尤其适合由大量输入决定的标量输出;而在只关注较少输入方向时,前向模式通常更适用。(docs.jax.dev)
对于上面的例子,反向微分从 开始。平方运算给出 ;加法运算将这一敏感度传递给 和 ;随后,乘法运算给出:
在给定输入下,这些导数分别为 、 和 。当一个中间值通过多条路径影响输出时,需要将各条路径贡献的导数相加。(docs.pytorch.org)
在人工神经网络中,反向传播采用反向模式微分,计算损失函数相对于模型参数的梯度。随后,梯度下降等优化方法利用这些梯度更新参数。图的求值、微分和参数更新是不同的阶段,即使框架将它们整合进同一个训练流程,也是如此。(docs.pytorch.org)
静态图、动态图与追踪
静态图在执行前构建,并且可以重复使用。动态图则在运算执行时记录依赖关系,使记录下来的计算能够反映实际走过的分支。在 PyTorch 的即时执行自动微分机制中,每次前向执行都会重新创建微分图,从而适应不同迭代之间 Python 控制流的变化。(docs.pytorch.org)
追踪提供了另一种构建机制:系统通过执行或分析函数,将运算捕获到可重复使用的图中。TensorFlow 的 tf.function 会捕获 TensorFlow 运算,而 AutoGraph 则将受支持的 Python 控制流结构转换成生成图的代码。追踪期间执行的 Python 操作不会自动成为图中的运算;例如,普通的打印语句可能只在追踪时执行,而不会在之后每次执行图时运行。不同的输入签名也可能触发重新追踪。(tensorflow.org)
这些方式可以并存。PyTorch 的编译机制可以从即时执行的程序中捕获图,并构建用于编译执行的前向图和反向图;因此,“动态”与“已编译”并不是互斥的描述。(docs.pytorch.org)
编译与内存管理
图揭示了不同运算之间的关系,编译器可以利用这些关系进行优化。相关变换包括常量传播、公共子表达式消除、运算融合和缓冲区规划。融合将多个运算组合成更大的执行单元,有望减少启动开销以及中间值向内存的传输。在图形处理器上,融合后的运算可以将中间值保留在寄存器或共享内存中,而不必写入设备内存。(openxla.org)
图的结构也为并行计算中的调度和分布式计算中的划分提供依据。依赖关系约束执行顺序,而划分会引入设备之间的通信。因此,执行效率既取决于计算,也取决于数据移动。(openxla.org)
反向微分通常需要使用前向执行时保存的中间值。激活检查点通过保留选定的输入,并在反向计算时重新计算未保存的中间值,来降低内存开销。这种方法以额外计算换取更低的内存占用;要保证结果正确,重新计算必须重现相关的前向计算行为。(docs.pytorch.org)
可微性与数值局限
计算图并不能使每一项运算都变得可微。离散运算、未定义的表达式以及不可微点,都需要显式处理,或采用框架特定的导数约定。此外,即使屏蔽了无效运算的输出,对该运算进行微分仍可能产生无效梯度。如果原地修改覆盖了反向计算所需的数值,也会干扰微分过程。(docs.pytorch.org)