码迷,mamicode.com
首页 > 其他好文 > 详细

tensorflow源码解析之common_runtime-executor-上

时间:2018-09-02 02:11:27      阅读:217      评论:0      收藏:0      [点我收藏+]

标签:inf   缓存   ++   string   access   行修改   运行   rop   return   

目录

  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();

tensorflow源码解析之common_runtime-executor-上

标签:inf   缓存   ++   string   access   行修改   运行   rop   return   

原文地址:https://www.cnblogs.com/jicanghai/p/9572213.html

(0)
(0)
   
举报
评论 一句话评论(0
登录后才能评论!
© 2014 mamicode.com 版权所有  联系我们:gaon5@hotmail.com
迷上了代码!