tensorflow源码解析之common_runtime-executor-上
目录
- 核心概念
- executor.h
- Executor
- NewLocalExecutor
- ExecutorBarrier
- executor.cc
- structs
- GraphView
- ExecutorImpl
- ExecutorState
- 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-上的更多相关文章
- tensorflow源码解析之common_runtime拾遗
把common_runtime中剩余的内容,按照文件名排序进行了简单的解析,时间原因写的很仓促,算是占个坑,后续有了新的理解再来补充. allocator_retry 有时候内存分配不可能一次完成,为 ...
- tensorflow源码解析系列文章索引
文章索引 framework解析 resource allocator tensor op node kernel graph device function shape_inference 拾遗 c ...
- Tensorflow源码解析1 -- 内核架构和源码结构
1 主流深度学习框架对比 当今的软件开发基本都是分层化和模块化的,应用层开发会基于框架层.比如开发Linux Driver会基于Linux kernel,开发Android app会基于Android ...
- 渣渣菜鸡的 ElasticSearch 源码解析 —— 启动流程(上)
关注我 转载请务必注明原创地址为:http://www.54tianzhisheng.cn/2018/08/11/es-code02/ 前提 上篇文章写了 ElasticSearch 源码解析 -- ...
- tensorflow源码解析之framework拾遗
把framework中剩余的内容,按照文件名进行了简单解析.时间原因写的很仓促,算是占个坑,后面有了新的理解再来补充. allocation_description.proto 一个对单次内存分配结果 ...
- tensorflow源码解析之common_runtime-executor-下
目录 核心概念 executor.h Executor NewLocalExecutor ExecutorBarrier executor.cc structs GraphView ExecutorI ...
- tensorflow源码解析之framework-allocator
目录 什么是allocator 内存分配器的管理 内存分配追踪 其它结构 关系图 涉及的文件 迭代记录 1. 什么是allocator Allocator是所有内存分配器的基类,它定义了内存分配器需要 ...
- hibernate部分源码解析and解决工作上关于hibernate的一个问题例子(包含oracle中新建表为何列名全转为大写且通过hibernate取数时如何不用再次遍历将列名(key)值转为小写)
最近在研究系统启动时将数据加载到内存非常耗时,想着是否有办法优化!经过日志打印测试发现查询时间(查询时间:将数据库数据查询到系统中并转为List<Map>或List<*.Class& ...
- Tensorflow源码解析2 -- 前后端连接的桥梁 - Session
Session概述 1. Session是TensorFlow前后端连接的桥梁.用户利用session使得client能够与master的执行引擎建立连接,并通过session.run()来触发一次计 ...
随机推荐
- redis清缓存
先查询当前redis的服务是否已经启动 ps -ef|grep redis [root@guanbin-k8s-master ~]# ps -ef|grep redis redis 1557 1 0 ...
- 入门-k8s部署应用 (三)
Kubernetes 部署应用 在 k8s 上进行部署前,首先需要了解一个基本概念 Deployment Deployment 译名为 部署.在k8s中,通过发布 Deployment,可以创建应用程 ...
- 使用kubeadm快速部署一套K8S集群
一.Kubernetes概述 1.1 Kubernetes是什么 Kubernetes是Google在2014年开源的一个容器集群管理系统,Kubernetes简称K8S. K8S用于容器化应用程序的 ...
- Linux常用命令精华讲解 上部 (下部下回分解)不要催很忙的
Linux常用命令讲解 1.Linux命令基础 2.Linux命令帮助 3.目录与文件的基操 1.Shell是系统中运行的一种特殊程序在用户和内核之间充当"翻译官"的角色,登录li ...
- Oracle 撤回已经提交的事务
在PL/SQL操作了一条delete语句习惯性的commit 了,因少加了where条件 导致多删了数据 1.查询视图v$sqlarea,找到操作那条SQL的时间(FIRST_LOAD_TIME) s ...
- MyBatis动态SQL和缓存
1. 什么是动态SQL 静态SQL:静态SQL语句在程序运行前SQL语句必须是确定的,SQL语句中涉及的表的字段名必须是存在的,静态SQL的编译是在程序运行前的. 动态SQL:动态SQL语句是在程序运 ...
- 框架3.2--搭建V·P·N
目录 部署OpenVPN 一.服务端 1.安装openvpn和证书工具 2.生成服务器配置文件 3.准备证书签发相关文件 4.准备签发证书相关变量的配置文件 5.初始化PKI生成PKI相关目录和文件 ...
- 期中架构&防火墙¥四表五链
今日内容 架构图 包过滤防火墙 Iptables 新建虚拟机 内容详细 一.架构图 用户通过域名访问一个网站类比开车去饭店用餐 访问网站的流程 1.浏览器输入网站的域名(www.baidu.com), ...
- Solution -「UOJ #450」复读机
\(\mathcal{Description}\) Link. 求从 \(m\) 种颜色,每种颜色无限多的小球里选 \(n\) 个构成排列,使得每种颜色出现次数为 \(d\) 的倍数的排列方案 ...
- 前端提交数据到node的N种方式
写在前面 本篇介绍了前端提交数据给node的几种处理方式,从最基本的get和post请求,到图片上传,再到分块上传,由浅入深. GET请求 经典的get提交数据,参数通过URL传递给node,node ...