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前端:
- Program由一系列的Block組成,每個Block包含各自的 Variable 和Operator。
- (可選操作)Transpiler將用戶定義的Program轉換為Transpiled Program(如:分布式訓練時,將原來的Program拆分為Parameter Server Program 和Trainer Program)。
- C++后端:
- (可選操作)C++后端將Python端的Program轉換為統一的中間表達(Intermediate Representation,IR Graph),并進行相應的編譯優化,最終得到優化后可執行的計算圖。其中,編譯優化包括但不限于:
o Operator Fusion:將網絡中的兩個或多個細粒度的算子融合為一個粗粒度算子。例如,表達式z = relu(x + y)對應著2個算子,即執行x + y運算的elementwise_add算子和激活函數relu算子。若將這2個算子融合為一個粗粒度的算子,一次性完成elementwise_add和relu這2個運算,可節省中間計算結果的存儲、讀取等過程,以及框架底層算子調度的開銷,從而提升執行性能和效率。
o 存儲優化:神經網絡訓練/預測過程會產生很多中間臨時變量,占用大量的內存/顯存空間。為節省網絡的存儲占用,飛槳paddle底層采用變量存儲空間復用、內存/顯存垃圾及時回收等策略,保證網絡以極低的內存/顯存資源運行。 - 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 的類型,如relu、conv2d、elementwise_add等。
? inputs : std::map<std::string, std::vectorstd::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::vectorstd::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 支持的類型包括:
o bool
o int32
o int64
o float32
o std::string
o std::vector
o std::vector
o std::vector
o std::vectorstd::string
o std::vector
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
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 1output = true_out; // block 1
}
else
{int64_t false_out = 0; // block 2output = 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 所示。
圖3Program與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的結點:
o Variable 的輸入結點為產生該 Variable 的 Operator , 輸出結點為以該 Variable 為輸入的 Operator 。
o 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> 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) 時,框架底層會執行如下兩個操作:
o 調用relu算子,根據輸入x計算輸出y。
o 記錄relu反向算子需要的信息。relu算子的反向計算公式為 x_grad = y_grad * (y > 0) ,因此反向計算需要前向輸出變量y,在構建反向計算圖時會將y的信息記錄下來。
? 當用戶調用 z = paddle.sum(y) 時,框架底層會執行如下兩個操作:
o 因為這里是將y的所有元素求和,是reduce_sum,調用reduce_sum算子,根據輸入y計算出z。
o 記錄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時)
說明:
- 在使用GPU計算時,為了保證更高的執行效率,框架本身不會等待前向 Operator 的CUDA Kernel 執行完畢后才返回。即在Python端用戶構建網絡的同時,C++后端可能仍在異步地執行CUDA Kernel。只有在用戶需要獲得 Tensor 的值時(例如調用 y.numpy() ),框架才會等待CUDA Kernel執行完畢。這樣既保證了運算的高效性,又保證了用戶能獲取到正確的 Tensor 值。
- 在模型預測過程中,用戶調用了 layer.eval() 切換到預測模式時,框架在運行前向 Operator 后將不再記錄反向信息。此時會更加節省存儲資源,這是因為反向 Operator 往往需要前向 Tensor 參與反向計算,若用戶切換到預測模式,則不會記錄反向 Operator ,同時反向 Operator 所需的前向Tensor 亦能得到及時釋放。
動態圖變量和算子的底層表示
由于動態圖模式下算子是即時執行,可即時獲得變量的計算結果,因此動態圖的變量和算子必須存儲有運行時的信息。動態圖的變量和算子在C++端分別以 VarBase 和 OpBase 的數據結構表示。
動態圖的變量表示
VarBase 的主要成員為:
class OpBase;
class VarBase {
Variable var_;
std::shared_ptr grad_var_;
std::vector<std::shared_ptr> 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> grad_pending_ops_;
};
? grad_ins_: 反向算子所有輸入構成的映射表,其key為反向算子的輸入slot,value為輸入的 VarBase 。
? grad_outs_: 反向算子所有輸出構成的映射表,其key為反向算子的輸出slot,value為輸出的 VarBase 。
? grad_pending_ops_: 反向計算圖中該反向算子的后繼算子列表。
動態圖底層執行邏輯的實現
當用戶在Python端調用飛槳paddle的前向算子API時,動態圖框架底層將執行以下操作:
- 根據輸入inputs,運行前向算子,得到輸出outputs。
- 若前向算子不需要計算梯度,則直接返回。
- 若前向算子需要計算梯度,則創建對應的反向算子列表grad_ops( std::vector<std::shared_ptr> 類型)。
- 對于grad_ops中每個反向算子grad_op,執行下述操作:
o 設置grad_op的輸入變量 grad_ins_ 和輸出變量 grad_outs_ 。其中,grad_ins_ 可能包含:前向輸入變量forward_inputs、前向輸出變量forward_outputs以及前向輸出變量的梯度forward_outputs_grads; grad_outs_ 包含前向輸入變量的梯度forward_inputs_grads。
o 將grad_op添加到每個前向輸出變量的梯度forward_outputs_grads的 grad_ops_ 域中,表示此變量為grad_op的輸入。
o 設置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) # 語句1emb2 = self._embedding2(x) # 語句2mul_out = emb1 * emb2 # 語句3relu_out = paddle.nn.functional.relu(mul_out) # 語句4return 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的具體流程為:
- 計算每個反向算子的依賴數dependency_num,即其前繼算子的數量。
對于 圖7(反向計算圖) ,所有算子均只有1個前繼算子,因此每個算子的依賴數均為1。 - 聲明一個空的算子隊列queue,并將調用 backward() 接口的變量的 grad_ops_ 進入算子隊列queue。
對于 圖7(反向計算圖) ,將relu_out@GRAD的 grad_ops_ 即relu_grad進入算子隊列queue。 - 若算子隊列queue未空,則取出隊列頭部的算子op,執行下述操作:
o 執行反向算子op。
o 遍歷反向算子op的 grad_pending_ops_ 域,將其每個后繼算子的依賴數dependency_num減1。若某個后繼算子的依賴數減至0,說明此算子的所有前繼算子均以執行完畢,可以開始執行此算子,將此算子加入算子隊列queue。
對于 圖7(反向計算圖) ,具體的執行流程為:
o relu_grad算子出隊列queue并執行,然后將elementwise_mul_grad算子加入隊列queue,此時隊列queue剩余1個算子。
o elementwise_mul_grad算子出隊列queue并執行,然后將2個lookup_table_grad算子加入隊列queue,此時隊列queue剩余2個算子。
o 第一個lookup_table_grad算子出隊列queue并執行,無算子需要加入隊列queue,此時隊列queue剩余1個算子。
o 第二個lookup_table_grad算子出隊列queue并執行,無算子需要加入隊列queue,此時隊列queue剩余0個算子,為空。 - 若算子隊列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在后續的網絡中是否還會被使用,因此難以執行算子融合操作。但因為算子即時執行,隨時均可輸出網絡的計算結果,更易于調試。
總結
以上是生活随笔為你收集整理的AI框架精要:设计思想的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Paddle Lite端侧部署
- 下一篇: AI框架外部用户贡献代码