tensorflow源碼解析之common_runtime-executor-上


目錄

  1. 核心概念
  2. executor.h
    1. Executor
    2. NewLocalExecutor
    3. ExecutorBarrier
  3. executor.cc
    1. structs
    2. GraphView
    3. ExecutorImpl
    4. ExecutorState
    5. details

1. 核心概念

執行器是TF的核心中的核心了,前面做了這么多的准備工作,最后要在這里集大成了,想想還有點小激動。不過筆者在這里先打個預防針,執行器的概念多、結構復雜,想要透徹理解並不容易,為了保持文章的易讀性,我們也是盡量對細枝末節做了舍棄,以求反應執行器的核心本質,但無奈執行器涉及到的內容實在太多,因此本篇的篇幅可能會有點長,大家做好准備。
執行器的概念雖然復雜,但宏觀上的理解卻很簡答,給定一張待執行的圖,給定它的輸入,讓它按照計划執行,獲得輸出就好了。如果讀者對之前我們這個系列的內容有所了解,對於執行器的執行過程,應該能有一個大概的印像了。為了計算圖能夠執行,TF設計了op的概念,設計了實際執行op的kernel,構建了能夠表達計算內容的node和graph,對內存、設備給出了專門的管理類,這些結合在一起,為計算圖的執行提供了最全面的支持。但具體的執行中,還有非常多的細節需要處理,接下來我們將分兩節進行介紹,第一節介紹executor.h頭文件,它給出了執行器提供的對外接口,第二部分介紹executor.cc源文件,它給出了執行器的執行原理。

2. executor.h

這一節將給出執行器對外的API,在查看具體的結構之前,我們先看一下執行器是如何被應用的。

Graph* graph = ...;//構建圖
Executor* executor;
NewSimpleExecutor(my_device, graph, &executor);//生成執行器
Rendezvous* rendezvous = NewNaiveRendezvous();//構建通信通道
rendezvous->Send("input", some_input_tensor);//提供輸入
executor->Run({ExecutorOpts, rendezvous, nullptr});
rendezvous->Recv("output",&output_tensor);//獲得輸出

過程非常的簡單易懂,TF通過抽象給我們提供了易用的外部API,但這種易用性是以底層復雜的內部結構作為支持的,接下來我們就看一下,對外API方面TF都做了哪些工作

2.1 Executor

首先,當然是執行器本身。執行器本身提供的接口很簡單,如下:

class Executor {
  public:
    typedef std::function<void(const Status&)> DoneCallback;
    virtual void RunAsync(const Args& args, DoneCallback done) = 0;
    
    //RunAsync()函數的同步版本
    Status Run(const Args& args){
        Status ret;
        Notification n;
        RunAsync(args, [&ret, &n](const Status& s) {
            ret = s;
            n.Notify();
        });
        n.WaitForNotification();
        return ret;
    }
};

執行器本質上應當是異步執行的,這個我們可以理解,因為圖計算是一個非常復雜且漫長的過程,異步計算效率更高。但同時執行器也提供了異步計算的同步包裝,讓用戶可以用同步的方式來執行。
執行器接口的簡潔性,與執行器的復雜功能之間形成了巨大的反差,以至於我們不得不懷疑,執行器內部是不是隱藏了什么結構,果然,我們發現執行函數的第一個參數是Args,下面來看下它的結構:

struct Args {
    int64 step_id = 0;
    Rendezvous* rendezvous = nullptr;
    StepStatsCollector* stats_collector = nullptr;
    FunctionCallFrame* call_frame = nullptr;
    CancellationManager* cancellation_manager = nullptr;
    SessionState* session_state = nullptr;
    TensorStore* tensor_store = nullptr;
    ScopedStepContainer* step_container = nullptr;
    
    //如果為真,在設備上調用Sync函數
    bool sync_on_finish = false;
    
    typedef std::function<void()> Closure;
    typedef std::function<void(Closure)> Runner;
    Runner runner = nullptr;
    
    //每當一個節點完成執行的時候,都會調用這個回調函數
    typedef std::function<Status(const string& node_name, const int output_slot, const Tensor* tensor, const bool is_ref, OpKernelContext* ctx)> NodeOutputsCallback;
};

關於其中的參數,我們給出一些說明:

  • step_id是一個進程級別的唯一標識符,用來標識執行的步驟。當一個步驟運行了一個需要在多個設備上執行的op時,這些不同設備上的執行器將會收到相同的step_id。step_id是被用來追蹤一個步驟中用到的資源的。
  • RunAsync()函數使用rendezvous,作為與計算圖之間溝通輸入和輸出的機制;
  • RunAsync()調用stats_collector來收集統計信息。這允許我們能根據需求收集統計和traces信息。
  • 如果執行器被用來執行一個函數,那么RunAsync()可以使用call_frame,用來在調用者和被調用者之間傳遞參數和返回值。
  • RunAsync()可以使用cancellation_manager來注冊一些,在計算圖執行被取消后的回調函數。
  • RunAsync()將執行的閉包分配給runner,通常來說,runner背后都有一個線程池支持。

2.2 NewLocalExecutor

接下來,TF教我們怎樣生成一個本地的執行器,它需要用到下面這個函數:

::tensorflow::Status NewLocalExecutor(const LocalExecutorParams& params, const Graph* graph, Executor** executor);

這里面又出現了一個,我們未曾見過的結構,LocalExecutorParams,顧名思義,它是我們生成本地執行器需要的參數,這個類的結構如下:

struct LocalExecutorParams {
    Device* device;
    FunctionLibraryRuntime* function_library = nullptr;
    std::function<Status<const NodeDef&, OpKernel**)> create_kernel;
    std::function<void(OpKernel*)> delete_kernel;
    Executor::Args::NodeOutputsCallback node_outputs_cb;
};

它包含了設備、函數庫、kernel構造和刪除過程、節點執行完畢的回調函數,后面我們將會看到,在函數的實現里面,是怎樣利用這些信息構建執行器的。

2.3 ExecutorBarrier

在實際的應用中,我們可能需要用到不止一個執行器。為了使多個執行器能並行運行,我們需要對這些同時執行的執行器進行管理和統籌,於是就產生了ExecutorBarrier類。如下:

class ExecutorBarrier {
  public:
    typedef std::function<void(const Status&)> StatusCallback;
    
    //為num個不同的執行器進行統籌和管理,r是一個共享的數據傳輸通道,如果任意一個執行器失敗,rendezvous僅會崩潰一次。等最后一個執行器執行完畢時,會調用done,並且ExecutorBarrier對象會被刪除掉
    ExecutorBarrier(size_t num, Rendezvous* r, StatusCallback done);
    
    //返回一個執行器在執行完畢之后必須調用的函數閉包,執行器會使用它們結束時的狀態作為執行閉包的參數
    StatusCallback Get() {
        return std::bind(&ExecutorBarrier::WhenDone, this, std::placeholders::_1);
    }
  private:
    Rendezvous* rendez_ = nullptr;
    StatusCallback done_cb_ = nullptr;
    
    mutable mutex mu_;
    int pending_ GUARDED_BY(mu_) = 0;//還剩幾個執行器沒執行完
    Status status_ GUARDED_BY(mu_);
    
    void WhenDone(const Status& s){
        //...
    }
};

3. executor.cc

這一節我們將探討執行器的實現。本來我想像前面一樣,倒序介紹,這樣讀者更容易理解。但一則這個堆棧包含的信息量有點大,是否是一個更好的介紹方法還不好說,二則后面的核心實現比較復雜,前面的結構反而容易理解,因此我們就按照源文件的先后順序介紹了,等筆者找到更好的呈現方式,再來修改這里的順序。

3.1 structs

在圖構建的時候,為了方便操作,提供更多的功能呢,我們把很多結構設計的比較復雜,比如graph, node等,但在執行的時候,一則這些復雜的結構我們不一定用得上,二則它們的存在也會影響執行效率,因此TF就設計了很多對之前復雜結構的簡化,比如我們這一節將要介紹的EdgeInfo和NodeItem,以及下一節將要介紹的GraphView。
首先我們來看下EdgeInfo:

struct EdgeInfo {
    int dst_id;
    int output_slot:31;
    bool is_last:1;
    int input_slot;
};

顯然,它表示的是計算圖中的邊,包含了目的節點(dst_id),目的節點的端口號(output_slot),源節點的端口號(input_slot),之所以沒有包含源節點,我們猜測是因為這個結構體就是被包含在源節點內部的。
另外,is_last表示,這條邊對應的是不是目的節點的最后一個端口。
最后,int output_slot:31這個結構,表示接下來的這四個字節(int)共32個bit,output_slot僅占其中的31個,而接下來的這個bool is_last:1則占了最后一個bit位,這種定義方式是c++11之后才有的,可以更高效的利用存儲空間。
接下來我們看一下NodeItem這個結構,它表示計算圖中的一個節點:

struct NodeItem {
    const Node * node = nullptr;//表示一個計算圖中的節點
    
    OpKernel* kernel = nullptr;//這個節點對應的OpKernel
    
    bool kernel_is_expensize:1;
    bool kernel_is_async:1;
    bool is_merge:1;
    bool is_enter:1;
    bool is_exit:1;
    bool is_exit:1;
    bool is_control_trigger:1;
    bool is_sink:1;
    bool is_enter_exit_or_next_iter:1;
    
    int num_inputs;
    int num_outputs;
    
    int input_start = 0;//輸入的起始索引
    
    size_t num_output_edges;//輸出邊的數量
    
    PendingCounts::Handle pending_id;
    
    const EdgeInfo* output_edge_list() const { return output_edge_base(); }
    
    const EdgeInfo& output_edge(int i);
    
    DataType input_type(int i);
    DataType output_type(int i);
    
    const AllocatorAttributes* output_attrs();
  
  private:
    char* var();
    EdgeInfo output_edge_base();
    AllocatorAttributes* output_attr_base();
    uint8* input_type_base();
    uint8* output_type_base();
}

可見,NodeItem提供了對於計算圖節點的靜態信息的非常詳細的描述。

3.2 GraphView

剛才也提到了,為了執行的效率,執行器對一些基礎結構進行了簡化,剔除了不必要的信息,例如,對於計算圖來說,由於在執行過程中,不需要對圖結構進行更改,因此原來的Graph類中很多修改圖的接口都沒用了,所以TF提供了一個不可改變的視圖,用來使圖的執行更加高效。
下面我們來看下這個類的接口和數據:

class GraphView {
  public:
    GraphView(): space_(nullptr) {}
    void Initialize(const Graph* g);//GraphView初始化
    Status SetAllocAttrs(const Graph* g, const Device* device);
    NodeItem* node(size_t id) const;//返回指定的節點信息
  private:
    char* InitializeNode(char* ptr, const Node* n);//初始化節點信息
    size_t NodeItemBytes(const Node* n);
    
    int32 num_nodes_ = 0;
    uint32* node_offsets_ = nullptr;//節點的偏置,node_offsets_[id]保存了節點id在space_中的偏移量
    char* space_;//保存了指向NodeItem對象的存儲地址的指針
};

所以,從數據上來說就很清楚了,GraphView之所以是Graph的一個不可改變的視圖,是因為它分配了一塊內存空間,然后把圖中所有節點的信息(NodeItem)都依次存入這個空間中,並提供了對空間中信息進行檢索的接口,但是,沒有提供對這些信息進行修改的接口,所以,我們仍然能夠訪問到Graph中的任何靜態信息,但是無法對其進行修改。

3.3 ExecutorImpl

剛才我們已經看到,Executor類只是一個基類,真正的執行器實現,需要看它的子類,TF提供了一個實現類ExecutorImpl,它的結構仍然比較簡單:

class ExecutorImpl : public Executor {
  public:
    ExecutorImpl(const LocalExecutorParams& p, const Graph* g) : params_(p), graph_(g), gview_(){
        CHECK(p.create_kernel != nullptr);
        CHECK(p.delete_kernel != nullptr);
    }
    ~ExecutorImpl() override {
        for(int i=0;i<graph_->num_node_ids();i++){
            NodeItem* item = gview_.node(i);
            if(item != nullptr){
                params_.delete_kernel(item->kernel);
            }
        }
        for(auto fiter : frame_info_){
            delete fiter.second;
        }
        delete graph_;
    }
    
    Status Initialize();
    
    //處理當前圖中的每一個節點,嘗試分析出它們在分配內存時的內存分配屬性
    Status SetAllocAttrs();
    
    void RunAsync(const Args& args, DoneCallback done) override;

  private:
    //構建控制流信息
    static Status BuildControlFlowInfo(const Graph* graph, ControlFlowInfo* cf_info);
    
    //初始化待執行計數信息
    void InitializePending(const Graph* graph, const ControlFlowInfo& cf_info);
    
    //確認每一個FrameInfo都已准備好
    FrameInfo* EnsureFrameInfo(const string& fname){
        auto slot = &frame_info_[fname];
        if(*slot == nullptr){
            *slot = new FrameInfo;
        }
        return *slot;
    }
    
    //被當前的對象擁有
    LocalExecutorParams params_;
    const Graph* graph_;
    GraphView gview_;
    
    //對於params_的緩存
    bool device_record_tensor_accesses_ = false;
    
    //沒有任何輸入邊的根節點,它們應當組成初始預備隊列
    std::vector<const Node*> root_nodes_;
    
    //從幀名稱到幀信息的映射
    gtl::FlatMap<string, FrameInfo*> frame_info_;
};

為了說明細節,我們特意給出了部分函數的實現方式,對於其中的重點進行如下說明:

  • 關於析構函數,它一共做了三件事情,第一,利用GraphView找到每個node包含的OpKernel,並且將它刪除,第二,將所有的幀信息刪除,第三,將GraphView對象刪除。
  • 當前執行器實際擁有的對象有三個,一是LocalExecutorParams執行器生成時的參數,二是Graph*,對應圖的指針,注意執行器僅擁有這個指針,並不擁有這張圖,第三,GraphView,這是執行器完全擁有的結構。
  • 看到root_nodes_這個變量,應該會給我們一些啟發,圖的執行過程,是從一些不需要輸入的根節點出發的,根據節點之間的依賴關系依次執行,這個過程會用到隊列的數據結構,一旦一個隊列中某個節點的前驅節點都准備好了,這個節點就可以被執行了。
  • frame_info_是一個幀映射,圖執行過程中的幀信息主要是為了控制結構准備的,控制結構的加入使得TF真正從一個高效的計算引擎升級為一個類編程語言,關於它的說明將在下文中給出。

另外,這個類中也包含了我們之前沒有見過的兩種結構,ControlFlowInfo和FrameInfo,下面依次介紹它們的結構:

struct ControlFlowInfo {
    gtl::FlatSet<string> unique_frame_names;
    std::vector<string> frame_names;
};
struct FrameInfo {
    //幀的輸入數量
    int input_count;
    
    //幀的各節點輸入張量數量的總和
    int total_inputs;
    
    //決定了在我們最終創建的pending_counts數據結構中,接下來將要被分配內存的位置
    PendingCounts::Layout pending_counts_layout;
    
    //每個幀都包含了它自己的PendingCounts信息,只為了當前幀中的節點
    PendingCounts* pending_counts;
    
    //幀中的節點,僅在調試時使用
    std::vector<const Node*>* nodes;
};

ControlFlowInfo只包含了幀的名稱,只不過提供了set和vector兩種方式,set是為了更方便的查找某個幀的名稱是否被包含在內。而FrameInfo則包含了幀的詳細信息,主要是輸入數量,以及未完成的節點計數等信息。
接下來是一些函數的具體實現,本來不應該糾結與細節,但這些內容對於理解執行器相關類的執行原理非常重要,因此這里給出直觀解釋,並不詳解代碼。感興趣的讀者可以去閱讀源碼。

//GraphView類相關

//對於其包含的每個NodeItem,調用其析構函數,並且刪除相關指針對應的內存
GraphView::~GraphView();

//計算某個Node對應的NodeItem所需要的內存大小
size_t GraphView::NodeItemBytes(cost Node *n);

//初始化節點
char* GraphView::InitializeNode(char* ptr, const Node* n);

//初始化GraphView,主要是初始化了node_offsets_和space_兩個指針
void GraphView::Initialize(const Graph* g);

//設置內存分配的屬性
Status GraphView::SetAllocAttrs(const Graph* g, const Device* device);

//ExecutorImpl類相關

//初始化執行器,首先初始化GraphView,然后構建幀的信息,預處理圖中每個節點以便為op創造OpKernel,最后初始化PendingCounts信息
Status ExecutorImpl::Initialize();


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM