1. 程式人生 > >TensorFlow 核心流程剖析 -- 2 神經網路模型的構建、分割和優化

TensorFlow 核心流程剖析 -- 2 神經網路模型的構建、分割和優化

與本章節相關的一些關鍵術語

  • graph

    我們知道, 在tensorflow裡,模型是以compuatation graph的形式存在,作為訓練和inference的載體。下面簡稱graph。
    graph的組成:

    • node:即定義一個具體的計算操作,比如Add, MatMul,Conv等。每個node可以定義多種屬性 ,包括它的計算操作(叫做Op),輸入、輸出資料型別,與計算相關的引數設定,比如Convolution時需要的padding\stride.
    • tensor:是一個node輸出的計算結果,比如MatMul計算完成後,輸出一個多維的張量,就是一個tensor,tensor可以作為下一個node的資料輸入。
    • edge:連線各個node, 目前有兩種edege:

      • data_flow: 負責在node之間傳遞tensor資料
      • control_flow: 負責確定node之間的執行依賴關係
        多個node和他們之間的edge連線,就構成了一個graph,完整描述我們定義的神經網路模型。
  • GraphDef

    用於定義graph的ProtoBuf協議格式,因其文字屬性,可以將graph以這種格式儲存至文字檔案,實現訓練模型的儲存。也可以很方面的在不同裝置、軟體模組之間傳輸和解析GraphDef。另外,tensorboard可以讀取GraphDef格式儲存的文字檔案,顯示graph。

  • session

    執行graph的主體。負責建立和管理graph及其所需的執行裝置(device)資源。

  • device

    執行graph的硬體資源,屬於session,如本地的gpu device/ cpu device。

  • Executor

    graph的具體執行者,屬於session,當我們把graph分割到多個device執行的時候,也會生成對應的多個Executor例項。

模型的生成總體流程

graph的生成,源自通過python API中對graph中nodes的定義。

一般來講,我們通過python API這樣開始訓練一個model:

  • 定義graph和其中的node
  • 建立session去Run這個graph

Graph的生成總體流程如下圖:

graphing 生成總體流程

graph的構建、分割、優化(c++部分)

graph的執行是在c++程式碼中完成的,在執行前,需要對graph進行構建、分割、優化。
而在開始構建一個graph之前,我們必須先建立一個session,作為執行這個graph的主體。

Session的建立

python API呼叫c++ API , 建立合適的session,用於執行graph。

執行graph的session有兩類:

  1. DirectSession:使用本地的devic作為執行資源,如本機中的gpu、cpu。可以將graph分割、分佈到多個devices上執行,實現devices之間的併發執行,同樣可以實現data parallelism/ model parallelism 這兩種分散式訓練。配置比較簡單,我們主要結合這種session進行舉例和講解。
  2. GrpcSession:使用遠端主機的device作為計算資源,grpc作為遠端呼叫的機制。使用cluster進行分散式訓練的場景就需要使用這種session。實現上,graph按照worker分割的原理,類似於DirectSession按照device分割的原理, 每個worker上sub-graph的執行者也是Executor,這裡不探討。

為方便感興趣的朋友進一步檢視程式碼,列出主要函式呼叫路徑:

Created with Raphaël 2.1.0c_api.ccc_api.ccsession.ccsession.ccTF_NewDeprecatedSession()NewSession()

full_graph例項的建立和優化

python部分定義好的graph,通過TF_ExtendGraph()介面, 將graph以protobuf定義的 GraphDef格式,傳遞到c++ session中。
同步graph有兩種場景:

  1. graph增量式更新後,主動同步到c++ session;
  2. 每次session run時,都會先同步graph到c++ session,以保證run的是最新的graph;

主要函式呼叫路徑:

Created with Raphaël 2.1.0c_api.ccc_api.ccdirect_session.ccdirect_session.ccsimple_graph_execution_state.ccsimple_graph_execution_state.ccTF_ExtendGraph()Extend()InitBaseGraph()SimpleGraphExecutionState::Extend()

full_graph生成和優化流程如下圖所示:
注:之所以叫做full_graph,是因為此時還沒有對graph根據feed/fetch裁剪、以及分散式的切割 。

graph init and optimize

c++ session 接收到GraphDef後, 主要步驟描述如下:

  1. 根據是否是第一次建立graph, 決定是否進行extend。目前extend的版本,只支援增加node,不能修改、刪除session中已繫結的graph。通過這個功能,我們可以很方便的對graph進行增量式的搭建。
  2. 直接對GraphDef進行優化,涉及到增加/刪除部分node、GraphDef的重構。優化的策略目前主要有:

    • pruning: 裁剪掉執行graph時無用的node,如StopGradient,Identity。
    • layout:針對執行在cuda GPU上的DNN node,如MaxPool,Conv2D等,將input格式為NHWC的這些node轉換為input為NCHW格式,以提高處理效率,比如cuDNN的大部分kernel在計算時,使用NCHW格式的tensor更為高效。
    • constfolding:針對所有輸入都為Constant OP的node,提前執行得到輸出:建立臨時kernel,輸入各Contant的值,執行得到結果作為新的Constant,再重構圖,以使session run時不會再執行上述node。
    • memory: 增加Identity nodes,將指定的tensor,swap to host memory,比如可以在CPU上debug 檢視某個位於GPU memory的tensor的值。
    • auto-parallel: 建立full_graph的replica,並將replicas分佈到各個GPU上, 實現data parallel。

    具體演算法可以參見函式 RunMetaOptimizer() in meta_optimizer.cc

  3. 建立full_graph: 基於優化過的GraphDef, 生成Graph例項,即full_graph,包含所有的node/edges。

  4. node的部署(placement):將所有node,根據使用者建立node時指定的device、或者SimplePlacer策略,將合適的device分配給node。可用的devices列表由direct session在生成時建立,device可以是本地GPU/CPU,也可以是cluster中的worker job、ps job等,後面章節會單獨講解device。 每個node,將會使用分配的device上的相應kernel例項來進行計算,所以在分配時,還需驗證在該device上否具備node使用的kernel例項。目前開源的SimplePlacer策略比較簡單,比如相鄰node共device等。後續應釋出根據node的計算資源、儲存資源消耗,進行負載均衡等多種部署策略。

根據feed/fetch對full_graph進行修改和裁剪

每次session run,需要指定feed和fetch 的tensors,我們知道,tensor是node的輸出,所以可以根據tensor定位到full_graph中的feed/fetch nodes:以feed nodes作為起點,fetch nodes作為終點,圈定的full_graph要執行的範圍,並不一定等同於整個full_graph,比如我們可以指定full_graph的中間節點作為本次run的起點、終點。
通過裁剪,可以提高執行的效率,從而確定本次run要執行的full_graph的哪一部分,將不需要執行的部分剪掉,提取需要執行的部分,整個動作就是full_graph的裁剪。
主要有兩個步驟:
1。 feed/fetch node的修改:為feed 增加Recv node、Source node, 為fetch 增加Send node、Sink node,同時修改增加的node、feed/fetch node 與上下游node的關係。Source node 即作為full_graph 本次run的起點,Sink node為終點。
2。 裁減:從fetch node(+ target node,如果有指定)出發,廣度優先的方式沿著data_flow edge/control_flow edge 逆向(記住,data_flow/control_flow是有向連線)遍歷full_graph,沒有覆蓋到的node即為對本次fetch tensor無關的node,刪除這些node,完成對full_graph的裁減。

為了便於講解,我們把這時候得到的graph叫做full_graph_subset.

主要函式呼叫路徑:

Created with Raphaël 2.1.0c_api.ccc_api.ccdirect_session.ccdirect_session.ccsimple_graph_execution_state.ccsimple_graph_execution_state.ccsubgraph.ccsubgraph.ccTF_Run()Run()GetOrCreateExecutors()CreateGraphs()BuildGraph()RewriteGraphForExecution()

根據device對full_graph_subset分割

這時候我們得到的本次需要run的full_graph_subset,其中每個node都已經包含了分配的device資訊,接下來我們要把full_graph_subset分割為多個Graph例項,每一個Graph例項與每一個具體device一一對應。 分割後得到的各graph,我們叫做partitioned_graph。

下面舉例,用一個簡單的模型:

layer1 + layer2 + softmax_loss, 其中layer1 和 layer2 架構一樣:W*x + b -> Relu
由於沒有訓練需求,不新增BP的node。
我們 layer1 定義為在cpu:0上執行,layer2 和 softmax_loss layer在 gpu:0 上執行。
feed tensor有[x, y_], fetch tensor是 softmax_loss/Mean node的輸出tensor。

我們可以把上述主要步驟中得到的各個graph例項, 都逐一轉換為GraphDef格式儲存至文字檔案,再用tensorboard來看一下分割的結果:

有必要先講一下分割graph時,為跨devices的edge,增加Send/Recv node 和Rendezvous(匯合點)的機制,以實現cpu<->gpu, gpu<->gpu等場景的資料傳輸,如下圖:

send recv rendez

分割之前的完整模型:

以layer1中的MatMul這個node為例,檢視其Device屬性,可以看到位於指定的cpu:0

full graph detail cpu 0 nodes

檢視 softmax_loss layer中的Mean node, 可以看到其位於指定的gpu:0

full graph detail gpu 0 nodes

分割之後的partitioned_graph:

先看一下分割到cpu:0 上的partitioned_graph, 可以看到為 cpu:0 <-> cpu:0 資料傳輸建立的send/recv nodes,以及建立的集合點 Rendezvous。send/recv nodes 通過Rendezvous, 與gpu:0 傳輸資料。
同時還可以看到為feed/fetch建立的 send/recv nodes: 這裡相當於定義了一個清晰的邊界,使用者程式指定的feed / fetch tensors, 必須通過Rendezvous 傳遞到graph內部。 注意: feed/fetch tensor 只能在cpu:0 上。

partition graph cpu:0

再看一下分割到gpu:0 上的 partitioned_graph, 可以看到同樣的機制。這裡的Rendezvous 和cpu:0 上使用的是同一個例項,實現了gpu:0 <-> cpu: 0 資料傳輸。

partition graph cpu:0

接下來會為每個partitioned_graph建立local executor,作為其本地的執行者。

上述例子對應的處理流程如下:

主要函式呼叫路徑:

Created with Raphaël 2.1.0c_api.ccc_api.ccdirect_session.ccdirect_session.ccgraph_partition.ccgraph_partition.ccTF_Run()Run()GetOrCreateExecutors()CreateGraphs()Partition()AddControlFlow()AddSend()AddRecv()

graph partition flow chart

對於原來full_graph_subset改動比較大的,有兩個地方:

  1. 為while_loop的跨devices執行增加control_loop. 這個邏輯比較繁瑣,有興趣的朋友可以直接看程式碼AddControlFlow()
  2. 為跨devices的edge,增加Send/Recv node 和Rendezvous(匯合點)機制。

至此, graph已經做好了執行的準備。後續文章再講解executor併發執行的機制。