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在后续的网络中是否还会被使用,因此难以执行算子融合操作。但因为算子即时执行,随时均可输出网络的计算结果,更易于调试。

 

posted @ 2021-02-12 09:28  吴建明wujianming  阅读(361)  评论(0编辑  收藏  举报