静态图和动态图中的自动求导机制详解

news2024/11/18 11:37:15

01 静态图与动态图的区别

之前在 [1] 中提到过,自动求导(AutoDiff)机制是当前深度学习模型训练采用的主要方法,而在静态图和动态图中对于自动求导的处理是不一样的。作为前置知识,这里简单进行介绍。

我们都知道静态图建模(如 TensorFlow,paddle fluid)是声明式编程,其建图过程和计算过程是分开的,而对于动态图建模而言(如 pytorch,paddle)是命令式编程,其计算伴随着建图一起进行。注意,这两种编程范式有着根本上的区别,相信用过 tensorflow 和 pytorch 的小伙伴能感受得到。总的来说,动态图边建图边计算的方式容易理解,而静态图先建图,后计算的方式并不是很容易理解,我们完全可以把静态图语言(比如 TensorFlow,Paddle)看成是独立于 python 之外的建图的一种描述语言,其任务主要是建计算图,而其计算部分完全由其 C++ 后端进行计算。静态图的建图和计算独立的过程和示意代码,可以用 Fig 1.1 进行

注意到,动态图边建图边计算,也即是每一次的模型训练都会进行重新建图和计算,这意味着:

1、系统无法感知整个动态图模型的全局信息。有些变量可能后续不会再被引用了,可以释放内存,在动态图系统中由于无法感知到后续图的结构,因此就必须保留下来(除非工程师手动释放),导致显存占用一般会大于静态图(当然也并不一定)。

2、每次都需要重新建图,在计算效率上不如静态图,静态图是一次建图,后续永远都是在这个建图结果的基础上进行计算的。这个就类似于解释性语言(如 python)和编译性语言(如 C 和 C++)的区别。

3、由于动态图需要每次重新建图,导致其无法在嵌入式设备上进行部署(两种原因,1 是效率问题,2 是嵌入式设备通常不具有网站的建图运行时,只支持推理模式),通常需要其以某种形式(比如 ONNX)转化为静态图的参数后,通过静态图部署。常见的部署方式包括 TensorRT,Paddle Lite,TensorFlow Lite,TensorFlow Serving,NCNN(手机端居多)等等。

02 自动求导 AutoDiff

2.1 动态图

动态图是完全的边建图边计算,注意到是完全,完全,完全!重要的事情说三遍,这意味着在动态图里面的自动求导过程也是边建图边计算完成了。如 Fig 2.1 所示,在进行前向计算的过程中,除了对前向计算结果进行保存外(简称为前向计算缓存,forward cache),还会同时进行当前可计算的反向梯度的计算(简称为反向计算缓存,backward cache),并且将反向梯度的计算结果同样保存下来。在需要进行端到端的梯度计算的时候,比如调用了 pytorch 的 output.backward (),此时会分析输出节点 output 和每个叶子节点的拓扑关系,进行反向链式求导。此时其实每一步的梯度都已经求出来了,只需要拼在一起,形成一个链路即可。将早已计算得到的前向缓存和反向缓存结果代入拓扑中,得到最终每个叶子节点的梯度。如式子 (2-1) 和 (2-2) 所示。这就是动态图的前向和反向计算逻辑,在建图的同时完成前向计算和反向计算。这种机制使得模型的在线调试变得容易(对比静态图而言),我们待会将会看到静态图是多么的 “反人类”(对比动态图而言)。

不难发现,在进行反向传播的时候整个系统需要缓存,维护多种类型的变量,包括前向计算的结果缓存,反向梯度的缓存,参数矩阵等等。这些都是模型训练过程中占据显存使用的大头。

2.2 静态图

对于静态图而言,建图是一次性完成的,计算可以在这个建好的计算图上反复进行。如 Fig 3.2 所示,静态图在建图阶段同时将前向计算图和反向计算图都一并建好了(除非指定了在推理模型,此时没有反向建图的过程),当 placeholder 输入真实的 Tensor 数据时(也就是 feed_list),在指定了输出节点的情况下(也就是 fetch_list),执行器会解析整个计算图,得到每个节点的计算顺序,并对 Tensor 进行相对应的处理。如以下代码所示,通过 tf.gradients (Y, X) 可以显式拿到梯度节点,在执行器运行过程中 sess.run (),只需要指定需要的输出节点(比如是前向输出 output 或者是梯度输出 grad)和喂入数据 feed_list,即可在计算图上计算得到结果。

import tensorflow as tf

X1 = tf.placeholder(tf.float32, shape=(1,), name="X1")
X2 = tf.placeholder(tf.float32, shape=(1,), name="X2")

h1 = tf.multiply(X1, X2)
h2 = tf.add(h1, X1)
output = tf.div(h2, X2)

grad = tf.gradients(output, [X1, X2])

feed_dict = {
    "X1": 0.6, "X2": 0.2
}
sess = tf.Session()
output_v = sess.run(output, feed_dict)
grad_v = sess.run(grad, feed_dict)

由此我们发现了静态图和动态图自动求导机制的不同点,静态图在执行计算过程中,其实并不区分前向计算和反向计算。对于执行器而言,无论是前向过程建的图,亦或是反向过程建的图都是等价的,执行器不需要区分,因此只需要一套执行器即可,将自动求导机制的实现嵌入到了建图过程中。而由于动态图的建图和计算同时进行,导致其执行器也必须区分前向和反向的过程。从静态图的实现机制上看,我们也不难发现,由于静态图提前已经对整个计算图的拓扑结构有所感知,就能对其中不合理的内存使用进行优化,并且可以对节点进行融合优化,也可以静态分析得到更合理的节点执行顺序,从而实现更大的并行度。静态图的这些性质决定了其更适合于模型部署,计算效率和内存使用效率都比动态图更高。但是静态图也有一个最大麻烦,就是模型调试麻烦。首先由于对整个图都建好了后才能执行,因此并不能动态往里面添加原生 python 的 print 操作 —— 此时 Tensor 都还没计算出来呢,你打印出来的只是该计算节点本身而已,并没有输入任何数值信息。为了 print 其中的节点以进行模型调试,可以往里面插入 TensorFlow 的 tf.Print 操作节点,如 Fig 3.3 所示。当然,你也可以单纯在执行器运行时,通过指定 fetch_list=[h2] 进行中间变量的获取。但是不管是哪种方法,都显然比动态图的调试更为麻烦。

静态图对于数据流控制的操作,也远比动态图麻烦。以条件判断为例子,在动态图中只需要实时计算判断条件,实时建图计算即可,一切都是那么地顺滑。但是静态图是必须得提前建图的,这意味着无法实时进行分支判断,因此所有可能的分支都需要进行建图,如 Fig 3.4 所示,实现了以下的条件判断逻辑。

if (X > 2) {
    return X * X3
} else {
    return X4 - X
}

03 静态图自动求导的实现示例

3.1 前向建图和反向建图

以上讲了那么多动态图和静态图的差别,看似有些跑题了,我们说好的自动求导实现呢?嗯嗯,本章在读者对静态图和动态图有了充分的认知之后,将会讨论如何实现静态图的自动求导机制。笔者已经将代码开源_( https://github.com/FesianXu/ToyAutoDiff )_,有兴趣的读者可以自行尝试。在这个代码库中,主要有两种数据结构类,Node 和 Op。Node 是节点类,如下所示,其主要定义了输入列表 self.inputs,这个输入列表用于储存当前节点的所有输入信息,而其本身则是作为输出存在,通过这种方式可以建立一个前向图,如 Fig 3.5 所示,通过维护 Node 类中的 inputs 列表,就足以维护前向图的拓扑关系,其是一个有向无环图(Directed Acyclic Cycle, DAG)。同时,Node 类中还具有一个 const_attr 用于描述 Tensor 与常数的一些操作,如果想要引入类型推断系统,那么还需要加入 self.shape,但是本文中并没有引入这个机制。

class Node(object):
    def __init__(self):
        self.inputs = []
        self.op = None
        self.const_attr = None
        self.name = ""

    def __add__(self, other):
        if isinstance(other, Node):
            new_node = add_op(self, other)
        else:
            new_node = add_byconst_op(self, other)
        return new_node

    def __mul__(self, other):
        if isinstance(other, Node):
            new_node = mul_op(self, other)
        else:
            new_node = mul_byconst_op(self, other)
        return new_node

    def __truediv__(self, other):
        raise ValueError('No implement div')

    # Allow left-hand-side add and multiply.
    __radd__ = __add__
    __rmul__ = __mul__

    def __str__(self):
        return self.name

    __repr__ = __str__

通过实现一个抽象类 Op,我们把所有算子的基类需要的共有接口给定义了,第一个是计算方法(Compute),注意到该操作并不区分前向或者反向,在执行器调用这个 compute 的时候,只是对输入的实际 Tensor 进行指定计算而已,因此这个方法其实就是在图计算中实现惰性计算(Lazy Compute)的实际计算方法。第二个是反向建图方法(gradient),该方法对当前输入节点和输出节点(也即是自身)进行反向求导建图。同时注意到在__call__方法中,Op 将输出节点 new_node = Node () 进行定义,并且将其纳入自己类中 new_node.op = self。

class Op(object):
    def __call__(self):
        new_node = Node()
        new_node.op = self
        return new_node

    def compute(self, node, input_vals):
        raise NotImplementedError

    def gradient(self, node, output_grad):
        raise NotImplementedError

该 Op 类是一个抽象类,需要集成它实现其他具体的算子,比如矩阵乘法算子 MatMulOp。该矩阵乘法算子的输入是两个 Op,分别是 node_A 和 node_B。其在 compute 方法中,传入的 Tensor 是基于 numpy array 的,因此直接采用 np.dot () 进行计算即可,当然也可以加入类型断言,形状断言用以判断传入的 Tensor 符合计算图的要求。在 gradient 方法中,我们知道对于矩阵乘法而言,其微分如 (3-1) 所示,将每个输入节点的对应微分写到 gradient 中,此时的 ∂Y∂𝑌 就是前继节点的求导累积结果,在代码中记为 output_grad。

class MatMulOp(Op):
    """Op to matrix multiply two nodes."""
    def __call__(self, node_A, node_B, trans_A=False, trans_B=False):
        new_node = Op.__call__(self)
        new_node.matmul_attr_trans_A = trans_A
        new_node.matmul_attr_trans_B = trans_B
        new_node.inputs = [node_A, node_B]
        new_node.name = "MatMul(%s,%s,%s,%s)" % (node_A.name, node_B.name, str(trans_A), str(trans_B))
        return new_node

    def compute(self, node, input_vals):
        assert len(input_vals) == 2
        assert type(input_vals[0]) == np.ndarray and type(input_vals[1]) == np.ndarray
        return np.dot(input_vals[0], input_vals[1])

    def gradient(self, node, output_grad):
        """
    if Y=AB, then dA=dY B^T, dB=A^T dY
        """
        return [matmul_op(output_grad, transpose_op(node.inputs[1])), matmul_op(transpose_op(node.inputs[0]), output_grad)]

通过类似的方法还可以实现其他很多算子操作,比如加减乘除等等。前向建图很容易完成,我们讨论下如何进行反向建图。在该试验代码中,实现了一个 gradients 函数,如下所示,该函数对输出节点 output_node 和指定的节点列表(node_list)中的每个节点进行求导操作。在实现这个的过程中,我们调用了一个叫做 find_topo_sort 的函数,对以这个输出节点 output_node 为起始点进行深度优先搜寻(Depth First Search),然后进行逆序就得到了反拓扑结构。还是以 Fig 3.2 的拓扑结构为例子,对其输出 H3 进行 DFS,得到的拓扑序为 X2 -> X1 -> H1 -> H2 -> H3,进行翻转后得到 H3 -> H2 -> H1 -> X1 -> X2。我们发现翻转后的序,和 Fig 3.2 的反向建图的序是一致的。因此以此为序,遍历的过程中不断地调用当前遍历节点的 op.gradient 方法,实现层次反向建图。

for ind, each in enumerate(reverse_topo_order):
        if ind == 0:
            gg = each.op.gradient(each, oneslike_op(output_node))
        else:
            gg = each.op.gradient(each, node_to_output_grads_list[each])

        if gg is None:
            continue
        for indv, eachv in enumerate(gg):
            if each.inputs[indv] in node_to_output_grads_list.keys():
                node_to_output_grads_list[each.inputs[indv]] += gg[indv]
            else:
                node_to_output_grads_list[each.inputs[indv]] = gg[indv]

        node_to_output_grad[each] = each
    grad_node_list = [node_to_output_grads_list[node] for node in node_list]
    return grad_node_list

def find_topo_sort(node_list):
    """Given a list of nodes, return a topological sort list of nodes ending in them.

    A simple algorithm is to do a post-order DFS traversal on the given nodes, 
    going backwards based on input edges. Since a node is added to the ordering
    after all its predecessors are traversed due to post-order DFS, we get a topological
    sort.
    """
    visited = set()
    topo_order = []
    for node in node_list:
        topo_sort_dfs(node, visited, topo_order)
    return topo_order

def topo_sort_dfs(node, visited, topo_order):
    """Post-order DFS"""
    if node in visited:
        return
    visited.add(node)
    for n in node.inputs:
        topo_sort_dfs(n, visited, topo_order)
    topo_order.append(node)

建图完后我们就需要进行计算了,而计算是有执行器(Executor)进行的。执行器中最主要的方法是 run,这个相当于 TensorFlow 中的 sess.run (),不同的在于,这里的执行器是在构造器中指定 fetch_list,在 run () 中指定喂入的 Tensor 数据。在 run 方法中,我们同样需要采用 DFS 对计算图进行遍历(不区分前向还是反向,再强调一遍),得到了计算序后,依次喂入 tensor 数据,调用 op.compute () 进行 tensor 计算即可。

class Executor:
    """Executor computes values for a given subset of nodes in a computation graph."""
    def __init__(self, eval_node_list):
        self.eval_node_list = eval_node_list

    def run(self, feed_dict):
        node_to_val_map = dict(feed_dict)
        # Traverse graph in topological sort order and compute values for all nodes.
        topo_order = find_topo_sort(self.eval_node_list)
        for each in topo_order:
            if each.inputs:
                input_vals = []
                for each_input in each.inputs:
                    input_vals += [node_to_val_map[each_input]]
                node_to_val_map[each]  = each.op.compute(node=each, input_vals=input_vals)
        node_val_results = [node_to_val_map[node] for node in self.eval_node_list]
        return node_val_results

至此,我们就实现了一个简单的静态图 autodiff 机制得到试验,后续可以加入形状推断机制,抽象出 Layer 神经网络层,参数初始化器 Initiator,优化器 Optimizer,损失 Loss,模型层 Model,那么我们就可以构建出一个玩具版本的 TensorFlow

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1867663.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【深度学习】tensorboard的使用

目前正在写一个训练框架,需要有以下几个功能: 1.保存模型 2.断点继续训练 3.加载模型 4.tensorboard 查询训练记录的功能 命令: tensorboard --logdirruns --host192.168.112.5 效果: import torch import torch.nn as nn impor…

排序算法(2)之选择排序----直接选择排序和堆排序

个人主页:C忠实粉丝 欢迎 点赞👍 收藏✨ 留言✉ 加关注💓本文由 C忠实粉丝 原创 排序算法(2)之交换排序----冒泡排序和堆排序 收录于专栏【数据结构初阶】 本专栏旨在分享学习数据结构学习的一点学习笔记,欢迎大家在评论区交流讨论…

【系统架构设计师】七、信息安全技术基础知识(信息安全的概念|信息安全系统的组成框架|信息加解密技术)

目录 一、信息安全的概念 1.1 信息安全的基本要素和范围 1.2 信息存储安全 1.3 网络安全 二、信息安全系统的组成框架 2.1 技术体系 2.2 组织机构体系 2.3 管理体系 三、 信息加解密技术 3.1 数据加密 3.2 对称加密技术 3.3 非对称加密算法 3.4 数字信封 3.5 信…

信息系统项目管理师(项目管理师)

项目管理者再坚持“聚焦于价值”原则时,应该关注的关键点包括:1价值是项目成功的最终指标;2价值可以再整个项目进行期间、项目结束或完成后实现;3价值可以从定性和/或定量的角度进行定义和衡量;4以成果为导向&#xff…

鸿蒙开发系统基础能力:【@ohos.pasteboard (剪贴板)】

剪贴板 说明: 本模块首批接口从API version 6开始支持。后续版本的新增接口,采用上角标单独标记接口的起始版本。 导入模块 import pasteboard from ohos.pasteboard;属性 系统能力: 以下各项对应的系统能力均为SystemCapability.MiscServices.Pasteb…

ubuntu 18 虚拟机安装(1)

ubuntu 18 虚拟机安装 ubuntu 18.04.6 Ubuntu 18.04.6 LTS (Bionic Beaver) https://releases.ubuntu.com/bionic/ 参考: 设置固定IP地址 https://blog.csdn.net/wowocpp/article/details/126160428 https://www.jianshu.com/p/1d133c0dec9d ubuntu-18.04.6-l…

相亲交友微信小程序系统源码

开启浪漫邂逅新篇章 相亲交友——随着年龄的增长,越来越多的人开始关注自己的婚姻问题,为了提高相亲服务的质量,这款应用就可以拓宽在线社交网络范围。​ 💑 引言:邂逅爱情的新方式 在繁忙的都市生活中,寻…

《梦醒蝶飞:释放Excel函数与公式的力量》6.1 DATE函数

6.1 DATE函数 第一节:DATE函数 1)DATE函数概述 DATE函数是Excel中的一个内置函数,用于根据指定的年、月、日返回对应的日期序列号。这个函数非常有用,尤其是在处理日期数据时,它可以帮助你构建特定的日期&#xff0…

文章解读与仿真程序复现思路——电网技术EI\CSCD\北大核心《计及氢储能与需求响应的路域综合能源系统规划方法》

本专栏栏目提供文章与程序复现思路,具体已有的论文与论文源程序可翻阅本博主免费的专栏栏目《论文与完整程序》 论文与完整源程序_电网论文源程序的博客-CSDN博客https://blog.csdn.net/liang674027206/category_12531414.html 电网论文源程序-CSDN博客电网论文源…

DOOPRIME:美联储降息何时到来?经济数据是关键

摘要: 美联储理事库克在纽约经济俱乐部的演讲中表示,降息时机取决于未来的经济数据和通胀走势。她预期,明年通胀将显著放缓,其中核心商品通胀可能保持轻微负增长,剔除住房的核心服务通胀将逐步放缓。尽管市场对降息时…

易查分小程序丨查询下拉选择如何设置?

老师在发布查询时,如果查询条件过长或难以记忆,学生家长经常输入不正确,或难以记住,应该怎样解决? 可以使用【查询条件下拉选择功能】,让学生家长在下拉框中直接选择查询条件。 查询下拉演示效果 &#x1f…

宠物领养救助管理系带万字文档java项目基于springboot+vue的宠物管理系统java课程设计java毕业设计

文章目录 宠物领养救助管理系统一、项目演示二、项目介绍三、万字项目文档四、部分功能截图五、部分代码展示六、底部获取项目源码带万字文档(9.9¥带走) 宠物领养救助管理系统 一、项目演示 宠物领养救助系统 二、项目介绍 基于springbootv…

代码随想录——K 次取反后最大化的数组和(Leetcode1005)

题目链接 贪心 class Solution {public int largestSumAfterKNegations(int[] nums, int k) {int sum 0;Arrays.sort(nums);// 先把负数转正for(int i 0; i < nums.length; i){if(nums[i] < 0 && k > 0){nums[i] -nums[i];k--;}sum nums[i];}Arrays.so…

springcloud-sentinel 限流组件中文文档

快速开始 欢迎来到 Sentinel 的世界&#xff01;这篇新手指南将指引您快速入门 Sentinel。 Sentinel 的使用可以分为两个部分: 核心库&#xff08;Java 客户端&#xff09;&#xff1a;不依赖任何框架/库&#xff0c;能够运行于 Java 8 及以上的版本的运行时环境&#xff0c…

C++编程逻辑讲解step by step:把一个整数按位拆分成1位整数。

分析 步骤之间有何关联&#xff1f; 思考的逻辑&#xff1a; 第一步得到4&#xff0c;ba%10;即可 第二步得到3&#xff0c;但需要先得到123&#xff0c;所以要除10&#xff0c;即c(a/10) %10; 第三步得到2&#xff0c;但需要先得到12 &#xff0c;所以要除100&#xff0c…

[XYCTF新生赛2024]-PWN:ptmalloc2 it‘s myheap plus解析(glibc2.35,堆中的栈迁移,orw)

查看保护 查看ida 思路&#xff1a; 泄露libc和堆地址就不多说了&#xff0c;fastbin duf也不解释了。这里主要是利用fastbin duf在environ附近创建堆块&#xff0c;泄露environ中的栈地址&#xff0c;然后就利用fastbin duf修改rbp和返回地址进行栈迁移了&#xff0c;迁移目标…

日志打印中对容器(包括多级容器)的通用输出

在日志打印中&#xff0c;往往有打印一个数组、集合等容器中的每个元素的需求&#xff0c;这些容器甚至可能嵌套起来&#xff0c;如果每个地方都用for循环打印&#xff0c;将会特别麻烦。基于这种需求&#xff0c;作者尝试实现一个通用的打印函数SeqToStr()&#xff0c;将容器序…

47岁TVB儿童节目主持20多年美貌零走样

现年47岁的香港艺人张洁莲&#xff08;Jackeline姐姐&#xff09;自从2020年嫁给拍拖多年的TVB「不老型男」袁文杰后&#xff0c;不时都在社交平台分享合照&#xff0c;夫妻间甜蜜恩爱&#xff0c;相信正是她多年来的保养秘诀。 最近有网友在社交平台分享了与张洁莲的合照&…

docker配置redis主从复制

下载redis,复制redis.conf 主节点(6379) 修改redis.conf # bind 127.0.0.1 # 注释掉这里 protected-mode no # 改为no port 6379从节点(6380) 修改redis.conf bind 127.0.0.1 protected-mode no # 改为no port 6380 replicaof 172.17.0.2 6379 # 这里的ip为主节点容器的i…

CS与MSF的权限互相传递/mimikatz抓取windows 2012明文密码

目录 CS和MSF的简单介绍 Metasploit Cobalt Strike 1、CS权限传递到MSF 2、MSF权限传递到CS 3、使用mimikatz抓取明文密码 通过修改注册表用户重新登录后抓取明文密码 今天的任务是两个 一个是CS与MSF的权限互相传递一个是抓取windows2012的明文密码 那就分别来完成 …