zoukankan      html  css  js  c++  java
  • AI框架精要:设计思想

    AI框架精要:设计思想

    本文主要介绍飞桨paddle平台的底层设计思想,可以帮助用户理解飞桨paddle框架的运作过程,以便于在实际业务需求中,更好的完成模型代码编写与调试及飞桨paddle框架的二次开发。

    从编程范式上说,飞桨paddle兼容支持声明式编程和命令式编程,通俗地讲就是,静态图和动态图。其实飞桨paddle本没有图的概念,在飞桨paddle设计上,把一个神经网络定义成一段类似程序的描述,就是在用户写程序的过程中,就定义了模型表达及计算。在静态图的控制流实现方面,飞桨paddle借助自己实现的控制流OP而不是python原生的if else和for循环,这使得在飞桨paddle中的定义的program即一个网络模型,可以有一个内部的表达,是可以全局优化编译执行的。考虑对开发者来讲,更愿意使用python原生控制流,飞桨paddle也做了支持,并通过解释方式执行,这就是动态图。但整体上,两种编程范式是相对兼容统一的。2020年,飞桨paddle将发布更加完善的动态图功能,同时会保持更强劲的性能。

    飞桨paddle平台中,将神经网络抽象为计算表示Operator(算子)和数据表示Variable(变量),如 图1 所示。神经网络的每层操作均由一个或若干Operator组成,每个Operator接受一系列的Variable作为输入,经计算后输出一系列的Variable

     图1 Operator和Variable关系示意图

    根据Operator解析执行方式不同,飞桨paddle支持如下两种编程范式:

    • 静态图模式(声明式编程范式):先编译后执行的方式。用户需预先定义完整的网络结构,再对网络结构进行编译优化后,才能执行获得计算结果。
    • 动态图模式(命令式编程范式):解析式的执行方式。用户无需预先定义完整的网络结构,每写一行网络代码,即可同时获得计算结果。

    举例来说,假设用户写了一行代码:y=x+1。在静态图模式下,运行此代码只会往计算图中插入一个Tensor加1的Operator,此时Operator并未真正执行,无法获得y的计算结果。但在动态图模式下,所有Operator均是即时执行的,运行完此代码后Operator已经执行完毕,用户可直接获得y的计算结果。

    静态图模式和动态图模式的能力对比如下表所示:


    说明:

    由于本章节涉及飞桨paddle深度学习平台的架构设计,需要用户具备一定深度学习背景和C/C++编程能力。


    静态图设计思想

    静态图执行流程

    在静态图模式下,飞桨paddle将神经网络描述为Program的数据结构,使用一种编程器式的执行流程,分为编译期和运行期两个阶段。

    • 编译期:直接调用飞桨paddleAPI编写Python程序,向Program中添加变量Variable和算子Operator。用户只需描述前向计算,无需关心反向计算、分布式场景及异构设备场景的计算。
    • 运行期:对Program进行编译优化,然后使用执行器Executor,创建Program中定义的变量,并执行Program中定义的算子。

    下面以一个简单的飞桨paddle训练代码为例,体会下在静态图模式下,编译期和运行期代码的变化。

    import paddle

    import numpy as np

     

    # 飞桨paddle2.0默认模式为动态图,需要开启静态图模式

    paddle.enable_static()

     

    # 编译期:调用飞桨paddle的API编写Python程序,如下述代码中定义了一个含conv2d的网络,并使用Adam优化器优化参数。

    image = paddle.static.data(name='image', shape=[None, 3, 224, 224], dtype='float32')

    conv_result = paddle.static.nn.conv2d(image, num_filters=64, filter_size=3)

    loss = paddle.mean(conv_result)

    adam = paddle.optimizer.Adam(learning_rate=1e-3)

    adam.minimize(loss)

     

    # 运行期:先运行一次startup program初始化网络参数,然后调用飞桨paddle的Executor和CompiledProgram API运行网络。

    place = paddle.CPUPlace() # 使用何种设备运行网络,CPUPlace表示使用CPU运行,CUDAPlace表示使用GPU运行

    executor = paddle.static.Executor(place) # 创建执行器

    executor.run(paddle.static.default_startup_program()) # 运行startup program进行参数初始化

     

    # 再使用CompiledProgram编译网络,准备执行。

    compiled_program = paddle.static.CompiledProgram(paddle.static.default_main_program())

     

    BATCH_NUM = 2

    BATCH_SIZE = 32

     

    for batch_id in range(BATCH_NUM):

        input_image = np.random.random([BATCH_SIZE, 3, 224, 224]).astype('float32')

        loss_numpy, = executor.run(compiled_program, feed={'image': input_image}, fetch_list=[loss])

        print("Batch {}, loss = {}".format(batch_id, loss_numpy))

     

    # 关闭静态图模式

    paddle.disable_static()

    Batch 0, loss = [-0.09575158]

    Batch 1, loss = [-0.11025753]

    静态图核心架构

    飞桨paddle静态图核心架构分为Python前端和C++后端两个部分,如 图2 所示:

     

     图2 飞桨paddle静态图核心架构示意图

    - Python前端:

    1. Program由一系列的Block组成,每个Block包含各自的 Variable 和Operator。
    2. (可选操作)Transpiler将用户定义的Program转换为Transpiled Program(如:分布式训练时,将原来的Program拆分为Parameter Server Program 和Trainer Program)。

    - C++后端:

    1. (可选操作)C++后端将Python端的Program转换为统一的中间表达(Intermediate Representation,IR Graph),并进行相应的编译优化,最终得到优化后可执行的计算图。其中,编译优化包括但不限于:
      • Operator Fusion:将网络中的两个或多个细粒度的算子融合为一个粗粒度算子。例如,表达式z = relu(x + y)对应着2个算子,即执行x + y运算的elementwise_add算子和激活函数relu算子。若将这2个算子融合为一个粗粒度的算子,一次性完成elementwise_add和relu这2个运算,可节省中间计算结果的存储、读取等过程,以及框架底层算子调度的开销,从而提升执行性能和效率。
      • 存储优化:神经网络训练/预测过程会产生很多中间临时变量,占用大量的内存/显存空间。为节省网络的存储占用,飞桨paddle底层采用变量存储空间复用、内存/显存垃圾及时回收等策略,保证网络以极低的内存/显存资源运行。
    2. Executor创建优化后计算图或Program中的 Variable ,调度图中的Operator,从而完成模型训练/预测过程。

    静态图的核心概念

    飞桨paddle静态图的核心概念如下:

    • Variable:表示网络中的数据。
    • Operator:表示网络中的操作。
    • Block:表示编程语言中的控制流结构,如条件结构(if-else)、循环结构(while)等。
    • Program:基于Protobuf的序列化能力提供模型保存、加载功能。Protobuf是Google推出的一个结构化数据的序列化框架,可将结构化数据序列化为二进制流,或从二进制流中反序列化出结构化数据。飞桨paddle模型的保存、加载功能依托于Protobuf的序列化和反序列化能力。
    • Transpiler:可选的编译步骤,作用是将一个Program转换为另一个Program。
    • Intermediate Representation:在执行前期,用户定义的Program会转换为一个统一的中间表达。
    • Executor:用于快速调度 Operator ,完成网络训练/预测。

    Variable

    飞桨paddle的Variable 表示网络中的数据。 Variable 的C++底层数据结构为Protobuf表示的 VarDesc,包含如下信息:

    message VarDesc {

      // Variable的名称

      required string name = 1;

     

      // Variable的类型,例如LOD_TENSOR、LOD_TENSOR_ARRAY等

      required VarType type = 2;

     

      // 是否为持久性变量,持久性变量在模型运行过程中不会销毁,持久性变量包括:模型参数、优化器参数等

      // 非持久性变量可能在模型运行过程中销毁

      optional bool persistable = 3;

    }

    Operator

    飞桨paddle的 Operator 表示网络中的操作。 Operator 的C++底层数据结构为Protobuf表示的 OpDesc ,包含如下信息:

    message OpDesc {

     

      // Operator的类型

      required string type = 3;

     

      // Operator的输入变量列表

      repeated Var inputs = 1;

     

      // Operator的输出变量列表

      repeated Var outputs = 2;

     

      // Operator的属性列表

      repeated Attr attrs = 4;

    }

    Operator 由如下4个域构成:

    • type : std::string 类型,表示 Operator 的类型,如reluconv2delementwise_add等。
    • inputs : std::map<std::string, std::vector<std::string>> 类型,记录输入slot名称至实际输入变量 Variable 名称的映射。

    例如,飞桨paddle sum 算子功能是将多个shape相同的输入Tensor(输入slot的名称为 X )累加为一个输出Tensor。若实际输入 Variable 的名称分别为 tmp_in_0 ,tmp_in_1 , tmp_in_2 ,则 sum 算子的 inputs 为 {"X": ["tmp_in_0", "tmp_in_1", "tmp_in_2"]} 。 type 相同的算子拥有相同的输入slot名称(类似于函数的形参),但实际输入变量的名称(类似于函数的实参)可以不同。

    • outputs : 与 inputs 类型相同,均为 std::map<std::string, std::vector<std::string>> 类型,记录输出slot名称至实际变量 Variable 名称的映射。

    例如,飞桨paddle的 split 算子功能是将输入Tensor沿某个维度拆分为若干个Tensor(输出slot的名称为 Out )。若实际输出 Variable 的名称分别为 tmp_out_0 , tmp_out_1 , tmp_out_2 ,则 split 算子的 outputs为 {"Out": ["tmp_out_0", "tmp_out_1", "tmp_out_2"]} 。

    • attrs : std::map<std::string, Attribute> 类型,表示属性名称至实际属性值的映射,其中 Attribute 支持的类型包括:
      • bool
      • int32
      • int64
      • float32
      • std::string
      • std::vector<bool>
      • std::vector<int32>
      • std::vector<float32>
      • std::vector<std::string>
      • std::vector<int64>

    Block

    飞桨paddle的 Block 用于表示编程语言中的控制流结构,如条件结构(if-else)、循环结构(while)等,还描述了一组以顺序、选择或是循环执行的 Operator 以及 Operator 操作的对象:Tensor。Block 的C++底层数据结构为Protobuf表示的 BlockDesc ,包含如下信息:

    message BlockDesc {

      // 该Block的ID

      required int32 idx = 1;

     

      // 父Block的ID,类似于编程语言的父子Block关系

      required int32 parent_idx = 2;

     

      // 该Block中包含的Variable列表

      repeated VarDesc vars = 3;

     

      // 该Block中包含的Operator列表

      repeated OpDesc ops = 4;

    }

    Block 的概念与编程语言中的类似,例如以下这段C++代码中包含三个Block:

    #include <cstdint>

     

    int64_t func(int64_t x, int64_t y)

    {

        bool condition = (x < y);  // block 0

        int64_t output;

       

        if (condition)             // block 0

        {

            int64_t true_out = 1;  // block 1

            output = true_out;     // block 1

        }

        else

        {

             int64_t false_out = 0; // block 2

            output = false_out;    // block 2

        }

       

        return output;

    }

    类似的,飞桨paddle代码的 Program 包含如下三段Block:

    import paddle

     

    paddle.enable_static()

     

    x = paddle.static.data(name='x', dtype='int64', shape=[1]) # block 0

    y = paddle.static.data(name='y', dtype='int64', shape=[1]) # block 0

     

    condition = paddle.less_than(x, y) # block 0

     

    def true_block():

        true_out = paddle.ones(shape=[1], dtype='int64') # block 1

        return true_out

       

    def false_block():

        false_out = paddle.zeros(shape=[1], dtype='int64') # block 2

        return false_out

     

    # 根据条件condition判断执行true_block还是false_block

    output = paddle.static.nn.cond(condition, true_block, false_block)

     

    paddle.disable_static()

    /opt/conda/envs/python35-paddle120-env/lib/python3.7/site-packages/paddle/fluid/layers/utils.py:77: DeprecationWarning: Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated, and in 3.8 it will stop working

      return (isinstance(seq, collections.Sequence) and

    每个Block 拥有自己的 Operator 和 Variable ,不同 Block 中的同名 Variable 是不同的变量。

    Program

    Program 的C++底层数据结构为Protobuf表示的 ProgramDesc,基于Protobuf的序列化能力提供模型保存、加载功能。ProgramDesc由若干 BlockDesc构成,其中最外层的Block称为 global block(对应Block ID为0),其余Block称为 sub block。

    Program、Block 的关系如 图3 所示。

     图3``Program``与``Block``关系示意图

    在模型训练/预测过程中,往往需要对参数进行一次初始化,随后多次执行训练/预测代码,以达到参数最优。因此,一段飞桨paddle程序通常包含两个 Program :

    • Startup Program:初始化 Operator 所在的 Program ,包括模型参数初始化、优化器参数初始化、reader初始化等 Operator 。框架定义了一个全局默认的Startup Program,即 paddle.static.default_startup_program() 。若用户没有显式指定Startup Program,则框架会使用默认的 paddle.static.default_startup_program() 。
    • Main Program:模型主体结构所在的 Program ,包括前向计算、反向计算、模型参数更新、优化器参数更新等 Operator 。框架定义了一个全局默认的Main Program,即 paddle.static.default_main_program() 。若用户没有显式指定Main Program,则框架会使用默认的 paddle.static.default_main_program() 。

    Startup Program用于模型初始化,Main Program负责描述网络主体结构。因此在模型训练过程中,往往只需要运行一次Startup Program(初始化一次),然后多次运行Main Program训练模型。

    下面以五个典型语句为例,体会一下 Program 在编译期的变化及其内部执行机制。

    import paddle

    import numpy as np

     

    # 飞桨paddle2.0默认模式为动态图,需要开启静态图模式

    paddle.enable_static()

     

    # 语句1 :在 ``paddle.static.default_main_program()`` 中定义变量 ``image`` 。

    image = paddle.static.data(name='image', shape=[None, 3, 224, 224], dtype='float32')

     

    # 语句2 :在 ``Program`` 中插入conv2d算子。由于conv2d算子包含参数

    # 因此语句中还隐含包括参数创建、参数初始化、算子插入等流程。

    # 本语句具体执行事物如下:

    #    在 paddle.static.default_startup_program()和paddle.static.default_main_program()

    #         中创建conv2d算子的权重参数weight和bias。

    #    在 paddle.static.default_startup_program()中插入权重参数weight和bias的初始化算子。

    #    在 paddle.static.default_main_program()中插入conv2d算子,以及conv2d的输出变量conv_result 。

    conv_result = paddle.static.nn.conv2d(image, num_filters=64, filter_size=3)

     

    # 语句3 :在Program中插入mean算子。由于mean算子不包含参数,因此语句不涉及

    # paddle.static.default_startup_program()修改,只会在paddle.static.default_main_program()

    # 中插入reduce_mean算子和对应的输出变量loss。

    loss = paddle.mean(conv_result)

     

    # 语句4 :定义Adam优化器,准备做参数优化。

    adam = paddle.optimizer.Adam(learning_rate=1e-3)

     

    # 语句5 :调用优化器的miminize。

    # 具体执行事物如下:

    #     在 paddle.static.default_startup_program() 中插入学习率、优化器参数

    #        (即Adam的Moment1、Moment2、Beta1Pow和Beta2Pow)变量及对应的初始化算子。

    #     在 paddle.static.default_main_program() 中插入反向算子,并创建对应的前向变量的梯度变量。

    #     在 paddle.static.default_main_program() 中插入优化器算子,用于根据参数梯度值更新参数。

    adam.minimize(loss)

     

    # 说明:

    # 由于以上代码中未指定Startup Program和Main Program,此处使用 paddle.static.default_startup_program()

    # 和 paddle.static.default_main_program()

     

    # 关闭静态图模式

    paddle.disable_static()

    Transpiler

    Transpiler 是一个 Program 层面的编译器,其作用是将一个 Program 转换为另一个 Program ,设计的目的是实现 Program 的自动转换,使得用户只需关系核心的模型训练/预测逻辑,无需关心底层实现细节。 Transpiler 不是必需的编译步骤。

    如 图4 所示,在Parameter Server + Trainer的分布式训练模式下,完成一个批次训练的流程如下:

    • Trainer:负责执行网络的前向和反向算子,计算参数的梯度后发送给Parameter Server。
    • Parameter Server:接收Trainer计算得到的参数梯度,执行网络优化器算子,更新网络的参数,并将更新后的参数发送给Trainer。

     

     图4 分布式训练转换Program示意图

    由此可见,Parameter Server和Trainer执行的算子是不同的,需要一个自动的转化机制将用户定义的原始 Program 转换为Parameter Server端和Trainer端的不同 Program ,并插入Parameter Server和Trainer间的通信算子,分布式训练的 DistributedTranspiler 用于完成上述转换。

    Intermediate Representation

    在执行前期,用户定义的 Program 会转换为一个统一的中间表达,即Intermediate Representation,简称IR。

    IR Graph代码示意如下:

    import paddle

     

    paddle.enable_static()

     

    image = paddle.static.data(shape=[None, 3, 224, 224], name='image', dtype='float32')

    label = paddle.static.data(shape=[None, 1], name='label', dtype='int64')

     

    y = paddle.static.nn.fc(image, size=1000)

     

    loss = paddle.nn.functional.softmax_with_cross_entropy(y, label)

     

    mean_loss = paddle.mean(loss)

     

    paddle.disable_static()

    飞桨paddle底层使用 SSA Graph有向无环图的形式表示IR,如 图5 所示。

     

     图5 IR Graph示意图

    • fc_w 和 fc_b 分别是网络中全连接层的权重参数和偏置参数,全连接层底层由 mul 和 elementwise_add 两个算子组成。
    • Variable 和 Operator 是Graph的结点:
      • Variable 的输入结点为产生该 Variable 的 Operator , 输出结点为以该 Variable 为输入的 Operator 。
      • Operator 的输入结点为该 Operator 的输入 Variable 结点,输出结点为该 Operator 的输出 Variable 结点。

    基于统一的IR Graph表达,飞桨paddle底层会进行Graph层面的优化,包括Operator Fusion,存储占用优化等,以提升执行效率。

    在接口层面,用户调用 paddle.static.CompiledProgram 后即可获得一张经过IR Graph优化后的计算图。

    import paddle

     

    train_program = paddle.static.default_main_program() # 训练网络

     

    # CompiledProgram内部会将Program转换为IR Graph,并进行一系列的图优化操作

    compiled_prog = paddle.static.CompiledProgram(train_program)

    说明

    IR的概念起源于编译器,是介于程序源代码与目标代码之间的中间表达形式。飞桨paddle的IR与编译器的IR类似,具有如下优势:

    • 便于编译优化算法的开发:所有的编译优化算法均以优化前的IR作为输入,并输出优化后的IR,因此不同的编译优化算法可以方便地串联起来使用,相互解耦,便于编译优化算法的开发。
    • 便于适配不同的后端硬件:不同后端硬件(Nvidia GPU、Intel CPU、ARM、FPGA等)的架构差异很大,若框架缺少统一的IR表达,则需要针对每一种不同的IR表达适配每一种不同的硬件平台,工作量巨大。若框架有统一的IR表达,则针对每一种不同的硬件平台做一次适配即可,且可把不同硬件平台的公共、通用的部分剥离出来抽象到IR层面,减少代码冗余度,提高可维护性。
    • 便于实现不同框架模型间的相互转换:每个深度学习框架往往均有自己的统一IR表达,实现不同框架模型间的转换时,只需要实现不同框架间IR的相互转换即可,开发成本低。

    Executor

    Executor 用于快速调度 Operator ,完成网络训练/预测。无论是 Program 还是 IR Graph,在执行网络前均只有网络的静态描述,此时网络还未运行,未有真正创建的占有存储空间的运行期变量。飞桨paddle的 Executor 内部使用 Scope 管理运行期的 Variable 。Scope 的主要数据成员为:

    class Scope {

      // 变量名称到变量的映射

      std::unordered_map<std::string, std::unique_ptr<Variable>> vars_;

     

      // 父Scope

      Scope *parent_;

     

      // 子Scope列表

      std::list<Scope *> kids_;

    };

    Scope 与编程语言中的变量作用域类似,在查找变量时,会先在当前 Scope 中查找,若有则返回; 若没有则递归地从父 Scope 中查到,直到父 Scope 为空,说明变量不存在。

    Executor 的创建方式如以下代码所示,其中 place 参数指明在何种设备上运行,目前飞桨paddle支持 CUDAPlace 和 CPUPlace 两种设备运行网络。

    import paddle

     

    USE_CUDA = False

     

    place = paddle.CUDAPlace(0) if USE_CUDA else paddle.CPUPlace()

     

    executor = paddle.static.Executor(place)


    执行器 Executor.run 方法用于运行网络,具体调用方式为:

    train_program = ... # 训练网络,可以是Program或CompiledProgram

     

    loss_numpy_value = executor.run(train_program, feed={'x': x_data, 'y': y_data}, fetch_list=[loss])

    Executor 的执行对象可以为 Program 或 CompiledProgram (即IR Graph),其运行的基本步骤为:

    • 在 Scope 中创建 Program 或 CompiledProgram 中的 Variable 。 持久性变量(模型参数、优化器参数等,即persistable属性为True的变量)创建于顶层的 Scope ,非持久性变量(临时变量)创建于顶层 Scope 的子 Scope 中。
    • 若执行对象为 Program ,则按照 Program 中 Operator 的排列次序顺序依次执行 Operator 。 若执行对象为 CompiledProgram ,则按照IR Graph中 Operator 的图依赖关系多线程地调度 Operator 。 每个 Operator 执行过程中,会首先从 Scope 中取出输入输出变量,然后根据输入变量进行一系列的运行后,将结果写入输出变量中。
    • 所有 Operator 执行完毕后,销毁顶层 Scope 的子 Scope ,即将网络中所有非持久性变量删除,保留持久性变量。

    动态图设计思想

    动态图模式是一种命令式的编程方式,无需构建完整的计算图,即可实时获得执行结果。

    动态图的执行流程

    在动态图模式下,Operator 是即时执行的,即用户每调用一个飞桨paddleAPI,API均会马上执行返回结果。在模型训练过程中,在运行前向 Operator 的同时,框架底层会自动记录对应的反向 Operator 所需的信息,即一边执行前向网络,另一边同时构建反向计算图。

    举例来说,在只有relu和sum两个算子的网络中,动态图执行流程如下代码注释。

    import numpy as np

    import paddle

     

    x_np = np.random.random([4, 5]).astype('float32')

    x = paddle.to_tensor(x_np)

     

    # 运行前向relu算子,记录反向relu信息

    y = paddle.nn.functional.relu(x)

    # 运行前向sum算子,记录反向sum信息

    z = paddle.sum(y)

    # 根据反向计算图执行反向

    z.backward()

    • 当用户调用 y = paddle.nn.functional.relu(x) 时,框架底层会执行如下两个操作:
      • 调用relu算子,根据输入x计算输出y。
      • 记录relu反向算子需要的信息。relu算子的反向计算公式为 x_grad = y_grad * (y > 0) ,因此反向计算需要前向输出变量y,在构建反向计算图时会将y的信息记录下来。
    • 当用户调用 z = paddle.sum(y) 时,框架底层会执行如下两个操作:
      • 因为这里是将y的所有元素求和,是reduce_sum,调用reduce_sum算子,根据输入y计算出z。
      • 记录reduce_sum反向算子需要的信息。reduce_sum算子的反向计算公式为 y_grad = z_grad.broadcast(y.shape) ,因此反向计算需要前向输入变量y,在构建反向计算图时会将y的信息记录下来。

    由于前向计算的同时,反向算子所需的信息已经记录下来,即反向计算图已构建完毕,因此后续用户调用 z.backward() 的时候即可根据反向计算图执行反向算子,完成网络反向计算,即依次执行:

    z_grad = [1] # 反向执行的起点z_grad为[1]

    y_grad = z_grad.broadcast(y.shape) # 执行reduce_sum的反向算子:y_grad为与y维度相同的Tensor,每个元素值均为1

    x_grad = y_grad * (y > 0) # 执行relu的反向算子:x_grad为与y维度相同的Tensor,每个元素值为1(当y > 0时)或0(当y <= 0时)


    说明:

    1. 在使用GPU计算时,为了保证更高的执行效率,框架本身不会等待前向 Operator 的CUDA Kernel 执行完毕后才返回。即在Python端用户构建网络的同时,C++后端可能仍在异步地执行CUDA Kernel。只有在用户需要获得 Tensor 的值时(例如调用 y.numpy() ),框架才会等待CUDA Kernel执行完毕。这样既保证了运算的高效性,又保证了用户能获取到正确的 Tensor 值。
    2. 在模型预测过程中,用户调用了 layer.eval() 切换到预测模式时,框架在运行前向 Operator 后将不再记录反向信息。此时会更加节省存储资源,这是因为反向 Operator 往往需要前向 Tensor 参与反向计算,若用户切换到预测模式,则不会记录反向 Operator ,同时反向 Operator 所需的前向Tensor 亦能得到及时释放。

    动态图变量和算子的底层表示

    由于动态图模式下算子是即时执行,可即时获得变量的计算结果,因此动态图的变量和算子必须存储有运行时的信息。动态图的变量和算子在C++端分别以 VarBase 和 OpBase 的数据结构表示。

    动态图的变量表示

    VarBase 的主要成员为:

    class OpBase;

     

    class VarBase {

      Variable var_;

      std::shared_ptr<VarBase> grad_var_;

      std::vector<std::shared_ptr<OpBase>> grad_ops_;

    };

    • var_: 用于存储运行时的Tensor信息。例如,当用户在Python端调用 tensor.numpy() 接口时会返回 var_ 中存储的Tensor数值。
    • grad_var_: 用于存储该变量对应的反向梯度变量。 VarBase 存储 grad_var_ 的目的是便于根据前向变量找到一次反向梯度变量,根据一次反向梯度变量找到二次反向梯度变量,依此类推。

    例如,当用户在Python端调用 tensor.gradient() 接口时会返回 grad_var_ ;若变量不需要计算梯度,则 grad_var_ 为空。若某个变量存在二次反向梯度,则用户可在Python端调用 tensor.gradient().gradient() 获得之(即返回C++端的grad_var_->grad_var_)。

    • grad_ops_: 用于存储以变量为输入的反向算子列表,仅对反向梯度变量有效,对于前向变量此域为空。grad_ops_ 的目的是在计算前向算子的同时,辅助构建反向计算图。

    动态图的算子表示

    OpBase 的主要成员为:

    class OpBase {

      GradVarMap grad_ins_;

      GradVarMap grad_outs_;

      std::vector<std::shared_ptr<OpBase>> grad_pending_ops_;

    };

    • grad_ins_: 反向算子所有输入构成的映射表,其key为反向算子的输入slot,value为输入的 VarBase 。
    • grad_outs_: 反向算子所有输出构成的映射表,其key为反向算子的输出slot,value为输出的 VarBase 。
    • grad_pending_ops_: 反向计算图中该反向算子的后继算子列表。

    动态图底层执行逻辑的实现

    当用户在Python端调用飞桨paddle的前向算子API时,动态图框架底层将执行以下操作:

    1. 根据输入inputs,运行前向算子,得到输出outputs。
    2. 若前向算子不需要计算梯度,则直接返回。
    3. 若前向算子需要计算梯度,则创建对应的反向算子列表grad_ops( std::vector<std::shared_ptr<OpBase>> 类型)。
    4. 对于grad_ops中每个反向算子grad_op,执行下述操作:
      • 设置grad_op的输入变量 grad_ins_ 和输出变量 grad_outs_ 。其中,grad_ins_ 可能包含:前向输入变量forward_inputs、前向输出变量forward_outputs以及前向输出变量的梯度forward_outputs_grads; grad_outs_ 包含前向输入变量的梯度forward_inputs_grads。
      • 将grad_op添加到每个前向输出变量的梯度forward_outputs_grads的 grad_ops_ 域中,表示此变量为grad_op的输入。
      • 设置grad_op的grad_pending_ops_ 域等于 grad_outs_ 的 grad_ops_ 域的总和,表示grad_op的后继反向算子为以 grad_outs_ 为输入的所有反向算子。

    下面以一段动态图代码示意动态图前向运行和反向图的构建过程:

    import paddle

     

    class ExampleLayer(paddle.nn.Layer):

        def __init__(self):

            super(ExampleLayer, self).__init__()

            self._embedding1 = paddle.nn.Embedding(size=[128, 10])

            self._embedding2 = paddle.nn.Embedding(size=[128, 10])

       

        def forward(self, x):

            emb1 = self._embedding1(x) # 语句1

            emb2 = self._embedding2(x) # 语句2

            mul_out = emb1 * emb2 # 语句3

            relu_out = paddle.nn.functional.relu(mul_out) # 语句4

            return relu_out

    代码对应的前向计算图和反向计算图如 图6 所示。

     

     图6 动态图代码示例的前向计算图和反向计算图

    图中W1和W2分别是代码中两个Embedding层的词表参数,@GRAD表示梯度变量,飞桨paddleEmbedding底层的算子为lookup_table。上述代码每个语句执行完毕后,反向计算图的变化如下所述:

    • 语句1:构建第一个反向算子lookup_table_grad,其输入为emb1@GRAD,输出为W1@GRAD,后继的反向算子为空。因为Embedding层的输入x不需要梯度,因此反向计算图中不含x@GRAD。
    • 语句2:构建第二个反向算子lookup_table_grad,其输入为emb2@GRAD,输出为W2@GRAD,后继的反向算子为空。因为Embedding层的输入x不需要梯度,因此反向计算图中不含x@GRAD。
    • 语句3:构建第三个反向算子elementwise_mul_grad,其输入为mul_out@GRAD,输出为emb1@GRAD和emb2@GRAD,后继的反向算子为前述构建的2个lookup_table_grad算子。
    • 语句4:构建第四个反向算子relu_grad,其输入为relu_out@GRAD,输出为mul_out@GRAD,后继的反向算子为elementwise_mul_grad。

    梯度自动计算Autograd

    由于前向组网过程中,框架已自动记录了反向计算图。当用户调用 tensor.backward() 的时候,框架会从调用该接口的 VarBase 节点开始,根据图依赖关系遍历执行反向计算图的每个 OpBase ,并进行相应的梯度累加,完成梯度自动计算Autograd的过程。

    以 图7(反向计算图) 为例,假设调用 backward() 接口的变量为relu_out@GRAD,则Autograd的具体流程为:

    1. 计算每个反向算子的依赖数dependency_num,即其前继算子的数量。

    对于 图7(反向计算图) ,所有算子均只有1个前继算子,因此每个算子的依赖数均为1。

    1. 声明一个空的算子队列queue,并将调用 backward() 接口的变量的 grad_ops_ 进入算子队列queue。

    对于 图7(反向计算图) ,将relu_out@GRAD的 grad_ops_ 即relu_grad进入算子队列queue。

    1. 若算子队列queue未空,则取出队列头部的算子op,执行下述操作:
      • 执行反向算子op。
      • 遍历反向算子op的 grad_pending_ops_ 域,将其每个后继算子的依赖数dependency_num减1。若某个后继算子的依赖数减至0,说明此算子的所有前继算子均以执行完毕,可以开始执行此算子,将此算子加入算子队列queue。

    对于 图7(反向计算图) ,具体的执行流程为:

      • relu_grad算子出队列queue并执行,然后将elementwise_mul_grad算子加入队列queue,此时队列queue剩余1个算子。
      • elementwise_mul_grad算子出队列queue并执行,然后将2个lookup_table_grad算子加入队列queue,此时队列queue剩余2个算子。
      • 第一个lookup_table_grad算子出队列queue并执行,无算子需要加入队列queue,此时队列queue剩余1个算子。
      • 第二个lookup_table_grad算子出队列queue并执行,无算子需要加入队列queue,此时队列queue剩余0个算子,为空。
    1. 若算子队列queue为空,则说明反向计算图中的所有算子均已执行完毕,Autograd计算完成。

    变量生命周期管理

    动态图的变量可能同时被飞桨paddlePython前端和C++后端持有,只有在Python前端和C++后端均不需要该变量时,变量才能被释放,否则可能出现内存泄漏或重复释放。 对此,飞桨paddle采用自动引用计数的方式,管理每个变量的生命周期,保证无论变量的最后一次引用出现在Python前端还是C++后端,均能被正确、自动地释放,实现了变量生命周期管理的自动管理。

    动态图和静态图的异同

    由上述动态图和静态图的底层实现可知,动态图模式和静态图模式底层算子实现的方法是相同的,最大的不同点在于:

    • 在静态图模式下,完整的网络结构在执行前是已知的,因此图优化分析的灵活性比较大,往往执行性能更佳,但调试难度大。

    以算子融合Operator Fusion为例,假设网络中有3个变量x,y,z和2个算子tanh和relu。在静态图模式下,可以分析出变量y在后续的网络中是否还会被使用,如果不再使用y,则可以将算子tanh和relu融合为一个粗粒度的算子,消除中间变量y,以提高执行效率。

    y = tanh(x)

    z = relu(y)

    • 在动态图模式下,完整的网络结构在执行前是未知的,因此图优化分析的灵活性比较低,执行性能往往不如静态图,但调试方便。

    仍以Operator Fusion为例,因为后续网络结构未知,无法得知变量y在后续的网络中是否还会被使用,因此难以执行算子融合操作。但因为算子即时执行,随时均可输出网络的计算结果,更易于调试。

     

    人工智能芯片与自动驾驶
  • 相关阅读:
    Maven关于web.xml中Servlet和Servlet映射的问题
    intellij idea的Maven项目运行报程序包找不到的错误
    修改Maven项目默认JDK版本
    刷题15. 3Sum
    刷题11. Container With Most Water
    刷题10. Regular Expression Matching
    刷题5. Longest Palindromic Substring
    刷题4. Median of Two Sorted Arrays
    刷题3. Longest Substring Without Repeating Characters
    刷题2. Add Two Numbers
  • 原文地址:https://www.cnblogs.com/wujianming-110117/p/14398506.html
Copyright © 2011-2022 走看看