zoukankan      html  css  js  c++  java
  • tensorflow源码剖析之framework-kernel

    目录

    1. 什么是kernel
    2. kernel_def
    3. op_kernel
    4. kernel的注册
    5. op_segment
    6. 关系图
    7. 涉及的文件
    8. 迭代记录

    1. 什么是kernel

    如果说op相当于操作的声明,那么kernel就是操作的实现。同一份声明在不同的设备上,最优的实现方式是不一样的,比如对于MatMul矩阵相乘这个操作,在CPU上可以用SSE指令优化加速,在GPU上可以用GPU实现高性能计算。因此就会对应CPU和GPU两种不同的实现。所以,在定义一个kernel时,除了要指明kernel对应的op之外,还需要指明kernel所在的设备类型。

    另外,kernel是一个运行期的概念。在定义图时,每个节点的op并不知道具体是由哪个kernel实现这个操作的,因为这时节点还没有被分配到具体的设备上,因此也就没法为其选择合适的kernel。

    2. kernel_def

    虽然kernel是一个运行期的概念,但我们仍然需要用一些静态的信息对kernel进行描述,这个静态的信息就是KernelDef这个proto,定义如下:

    message KernelDef {
        string op = 1;//对应操作的名称
        string device_type = 2;//对应设备的类型
        message AttrConstraint {
            string name = 1;
            AttrValue allowed_values = 2;
        }
        repeated AttrConstraint constraint = 3;//对应op中对参数的限制
        repeated string host_memory_arg = 4;//操作的输入或输出参数中,存在于host内存而不是device内存中的参数
        string label = 5;
    }
    

    其中的label字段我们解释一下。有时候用户会编写一些实验性的kernel,然后注册到某一个op上去。但这种kernel默认情况下是不会被使用的,除非用户用户在op中定义了一个_kernel字段,并且把这个字段赋值为某个kernel的label对应的值。举个例子,如果我们定义了一个MulKernel,且把它的label设置为'mulkernel',假设这个kernel对应的操作叫做MulOp,那么只有MulOp也包含一个字段_kernel,并且_kernel="mulkernel"时,MulKernel才可以被MulOp使用。

    按照惯例,TF也会为KernelDef设计一个构建类,这就是KernelDefBuilder。与之前的构建类类似,KernelDefBuilder也只是提供了一系列的属性设置API,私有数据成员也只有一个KernelDef指针:

    class KernelDefBuilder {
        //...
      private:
        KernelDef* kernel_def_;
    }
    

    3. op_kernel

    3.1 OpKernel

    如果说KernelDef只是对kernel的静态信息描述,那这里就要介绍kernel的本尊了,OpKernel是所有真正kernel的基类,它除了像KernelDef一样包含数值属性之外,还提供了kernel的核心API,compute函数。接下来我们仔细看一下OpKernel类的结构:

    class OpKernel {
      public:
        explicit OpKernel(OpKernelConstruction* context);
        virtual void Compute(OpKernelContext* context) = 0;//执行同步计算
        virtual AsyncOpKernel* AsAsync() { return nullptr; }
      private:
        const std::unique_ptr<const NodeDef> def_;
        const DataTypeVector input_types_;//输入的数据类型
        const MemoryTypeVector input_memory_types_;//输入的内存类型
        const DataTypeVector output_types_;//输出的数据类型
        const MemoryTypeVector output_memory_types_;//输出的内存类型
        const bool is_internal_;//是否是内部操作
        NameRangeMap input_name_map_;
        NameRangeMap output_name_map_;
        bool expensive_;//是否是复杂操作
    }
    

    以下对这个类进行一些说明:

    • 在构造函数中,我们发现它需要的是一个OpKernelConstruction指针,我们猜想,这个指针应该包含了构建一个OpKernel所必须的数据成员和功能函数,我们将在下文中详细介绍;
    • 另外,在核心API Compute中,接收的是一个OpKernelContext指针的参数,我们猜想,这个指针应该包含了OpKernel执行实际计算所需要的数据成员和功能函数。下文中也会专门介绍这个类;
    • 除此之外,我们还发现了一个指向NodeDef的指针。之前说过了,kernel是一个运行期的概念,虽然kernel是op的具体实现,但运行期中,op是跟具体的节点绑定在一起的,所以每个kernel都需要绑定一个具体的节点;
    • kernel可以被分为同步kernel和异步kernel两类,大部分的kernel都应该是同步的,Compute函数在计算结束后返回结果状态,但有些操作本身就是异步的,因此需要将某些kernel设计为异步的,比如网络数据接收操作,如果这个操作被设计成同步,那么如果有其它线程在使用同一个网络接收服务,当前线程就会被阻塞,从而造成资源的浪费。

    刚才讲到了异步kernel,现在我们就来看一下异步kernel对应的类,AsyncOpKernel:

    class AsyncOpKernel : public OpKernel {
      public:
        typedef std::function<void()> DoneCallback;
        virtual void ComputeAsync(OpKernelContext* context, DoneCallback done) = 0;
        //...
    };
    

    可见,异步计算的API除了context之外,还需要提供一个回调函数,在异步计算执行结束之后调用。

    3.2 OpKernelConstruction

    刚才我们提到,OpKernel的构造函数中,需要一个类型为OpKernelConstruction指针的参数,并且猜想这个参数包含了OpKernel构建所必须的数据成员和功能函数,实际上也确实如此,我们先来看下这个类的结构:

    class OpKernelConstruction {
      public:
        Status allocate_temp(DataType type, const TensorShape& shape, Tensor* out_temp);//分配一块临时内存
        Status allocate_persistent(DataType type, const TensorShape& shape, PersistentTensor* out_persistent, Tensor** out_tensor);//分配一块可复用内存
        //...
      private:
        const DeviceType device_type;
        DeviceBase* const device_;
        Allocator* allocator_;
        const NodeDef* def_;
        const OpDef* op_def_;
        FunctionLibraryRuntime* flib_;
        DataTypeSlice input_types_;
        MemoryTypeSlice input_memory_types_;
        DataTypeSlice output_types_;
        MemoryTypeSlice output_memory_types_;
        const int graph_def_version_;
        Status* status_;
    }
    

    其中有几点需要说明:

    • 关于临时内存和可复用内存。在OpKernel构建的过程中,我们可能需要分配一些内存,有些内存是临时性的,在OpKernel构建结束之后就没用了,我们会自主进行申请和释放。另外,我们也希望有些内存是可以在OpKernel的多次执行之间共享的,比如,有些kernel是有状态的,例如Variable,我们必须在kernel构建时就给这些内容申请内存;
    • 关于可复用内存,还有一点需要说明。对于在GPU上申请的可复用内存,由于GPU不像CPU那样内存方便管理,因此运行时需要对每一份内存的使用情况了如指掌,因此对于可复用的内存,我们必须对其每一次使用都了解。TF为此专门设计了一个PersistentTensor类,这个类是对Tensor的封装,但对于内部张量数据只能通过一个AccessData的接口来访问,只要我们在这个接口里设置一个Watcher,就能监控所有可复用内存的使用了;
    • 关于PersistentTensor还有一个疑点,我们知道OpKernelConstruction类能够在OpKernel初始化时申请永久内存,但在这两个类中,都没有发现对它的存储。既然永久内存时可以供kernel在不同启动之间共享的,那在第二次启动时怎样找到这个张量呢?还记得在resource章节中,我们介绍的resource_op_kernel吗?这个kernel实际上就是存储永久张量的地方。在申请了一个永久张量之后,我们为之申请一个新的resource_op_kernel来管理它,需要这个张量时,就向这个kernel索取。具体的做法是,可以为resource_op_kernel申请一个新的节点,并在新节点与原kernel所在节点之间连接一条边,使新节点作为老节点的输入;
    • 私有数据中还有一个FunctionLibraryRuntime结构的指针,顾名思义,这个结构表示一个运行时的函数库,我们将在function章节中详细描述;

    3.3 OpKernelContext

    还记得我们刚才提到,OpKernel的核心API Compute函数,需要一个类型为OpKernelContext指针的输入参数吗?刚才我们猜想,这个类包含了执行kernel计算所需要的数据成员和功能函数,实际上也确实如此。下面我们来看下这个类的构成:

    class OpKernelContext {
      public:
        //构造函数
        explicit OpKernelContext(Params* params);
        
        //输入获取
        const Tensor& input(int index);//获取不可变的输入张量
        Status input(StringPiece name, const Tensor** tensor);//获取不可变的输入张量
        Status input_list(StringPiece name, OpInputList* list);//获取不可变的输入数据列表
        Status input_ref_mutex(StringPiece name, mutex** out_mutex);//获取可变的引用输入
        Tensor mutable_input(int index, bool lock_held);//获取可变的引用输入
        Status mutable_input_list(StringPiece name, OpMultableInputList* list);//获取可变的引用输入列表
        void replace_ref_input(int index, const Tensor& tensor, bool lock_held);//替换某个引用输入
        Status replace_ref_input(StringPiece name, const Tensor& tensor, bool lock_held);
        
        //输入向输出传递
        //把引用输入转换为引用输出
        void forward_ref_input_to_ref_output(int input_index, int output_index);
        //将输入转换为指定形状的输出
        bool forward_input_to_output_with_shape(int input_index, int output_index, const TensorShape& output_shape, Tensor** output);
        Status forward_input_to_output_with_shape(StringPiece input_name, StringPiece output_name, const TensorShape& output_shape, Tensor** output);//同上
        //如果指定输入1.不是引用输入,2.与给出的属性描述一致,3.底层的buffer的引用计数为1,那么就返回一个指向该输入的底层数据的指针
        std::unique_ptr<Tensor> forward_input(int input_index, DataType dtype, const TensorShape& shape, MemoryType memory_type, const AllocatorAttributes& attr);
        //尝试把指定输入传递到指定输出,如果没有任何一个输入可以被传递,那么就申请一个新的内存作为输出
        Status forward_input_or_allocate_output(gtl::ArraySlice<int> candidate_input_indices, int output_index, const TensorShape& output_shape, Tensor** output);
        //尝试把指定输入用作临时变量,如果没有输入可用,则使用allocate_temp申请一个临时buffer
        Status forward_input_or_allocate_temp(gtl::ArraySlice<int> candidate_input_indices, DataType type, const TensorShape& shape, const AllocatorAttributes& allocator_attr, Tensor* out_temp);
        
        //输出获取
        Status output_list(StringPiece name, OpOutputList* list);
        
        //内存分配
        Status allocate_output(int index, const TensorShape& shape, Tensor** tensor);
        Status allocate_output(int index, const TensorShape& shape, Tensor** tensor, AllocatorAttributes attr);
        Status allocate_temp(DataType type, const TensorShape& shape, Tensor* out_temp, AllocatorAttributes allocator_attr, const AllocationAttributes& allocation_attr);
        Status allocate_persistent(DataType type, const TensorShape& shape, PersistentTensor* out_persistent, Tensor** out_tensor, AllocatorAttributes attr);
        
        //设置输出
        Status set_output(StringPiece name, const Tensor& tensor);
        Status set_output_ref(StringPiece name, mutex* mu, Tensor* tensor_for_ref);
        Status mutable_output(StringPiece name, Tensor** tensor);
        
        //...
        
      private:
        Status status_;
        Params* params_;
        mutable mutex mu_;
        gtl::InlinedVector<WrappedAllocator, 4> wrapped_allocators_ GUARDED_BY(mu_);
        gtl::InlinedVector<TensorValue,4> outputs_;
        ManualConstructor<UniqueTensorReferences> referenced_tensors_ GUARDED_BY(mu_);
        bool is_output_dead_ = false;
        int64 host_temp_memory_size_;
        int64 device_temp_memory_size_;
        gtl::InlinedVector<int64, 2> host_persistent_alloc_ids_;
        gtl::InlinedVector<int64, 2> device_persistent_alloc_ids_;
        int64 host_persistent_memory_allocated_;
        int64 device_persistent_memory_allocated_;
    }
    

    为了看清楚结构,代码我做了删减,即便如此,代码量仍然很大。对外的API包括了,获取输入,输入传递到输出,输出设置,输出获取,内存分配,下面我们重点讲几个核心概念:

    • 关于输入和输出的类型,相信看过代码已经有印像了,以输入为例(输出类似),被分为两类,正常输入和引用输入。正常输入是不可改变的,但引用输入是可以改变的。可以理解为,如果你想改变正常输入所在张量的内部数据,只能新建一个张量,将正常输入的数据拷贝过来,然后改变新张量里的数据,但对于引用输入,就可以直接对其修改,有时候还可以把修改后的引用输入直接当作输出。因此输入获取的API里,所有的API都有针对正常输入和引用输入两个版本。而在输入到输出传输的API里,也专门有一个引用输入到引用输出的传输;
    • 关于内存分配。在kernel执行期间,有三种分配内存的方式,第一种是分配永久内存,也就是上文中提到的可复用内存,因为某些操作是有状态的,在同一个kernel的多次调用之间,我们可以保留一些共享数据。第二种是分配输出内存,一个kernel可能会输出数据,这个数据必须先申请内存。第三种是分配临时内存,kernel计算中可能会用到一些临时的内存,计算结束之后就不用了;
    • 在某些情况下,一个张量即便不是被分配为输出,也可能会被当做输出使用。这个张量可能是一个输入,或者是存储在一个永久张量中,或者在究竟输出哪个张量还没被确定之前就被分配了。这种情况下,我们可以使用set_output或者set_output_ref函数来指定,这个张量被用作输出。我们可以使用任何之前分配的张量作为输出,即便这个张量是被当做临时张量分配的。使用那些不是被allocate_output函数分配的张量当做输出会有一定的性能损耗,因为allocate_output使用了存储在output_attr_array中的AllocatorAttributes属性来决定怎样分配内存,如果使用的张量与这个内存分配的要求不符,可能会引起额外的张量内存拷贝;

    在OpKernelContext的私有数据成员中,还有一个Params参数我们没有解释,下面就来看下它的结构:

    struct Params {
        int step_id = 0;//执行的步骤编号
        OpKernel* op_kernel = nullptr;
        DeviceBase* device = nullptr;//该kernel执行时所在的设备类型
        PerOpGpuDevice* eigen_gpu_device = nullptr;
        
        //追踪相关
        bool track_allocations = false;
        bool log_memory = false;
        bool record_tensor_accesses = false;
        
        const AllocatorAttributes* output_attr_array = nullptr;//输出的内存分配器属性
        ResourceMgr* resource_manager = nullptr;//当前的op_kernel可以访问的共享资源
        ScopedStepContainer* step_container = nullptr;//属于当前op_kernel的单步资源
        Rendezvous* rendezvous = nullptr;//通信机制
        TensorStore* tensor_store = nullptr;
        CancellationManager* cancellation_manager = nullptr;
        
        const gtl::InlindecVector<TensorValue,4>* inputs = nullptr;//当前op_kernel的输入
        bool is_input_dead = false;
        const gtl::InlinedVector<AllocatorAttributes, 4>* input_alloc_attrs = nullptr;
        
        //设备上下文
        const gtl::InlineVector<DeviceContext*,4>* input_device_contexts = nullptr;
        DeviceContext* op_device_context = nullptr;
        
        //对控制流相关操作的支持
        FrameAndIter frame_iter;
        
        //函数调用支持
        FunctionCallFrame* call_frame = nullptr;
        FunctionLibraryRuntime* function_library = nullptr;
        std::function<void(std::function<void()>)>* runner = nullptr;
        StepStatsCollector* stats_collector = nullptr;
    };
    

    虽然Params这个名字并不起眼,实际上内部别有乾坤。这里面包含了OpKernel计算所需要的资源,其中有很多属于运行时的资源,我们当前还没有介绍到,因此先略过不表,等这些内容都讲完之后,再回过头来看它吧。

    4. kernel的注册

    面对众多的OpKernel,我们也需要一个集中管理的地方,于是像OpRegistry一样,TF也设计了一个OpKernelRegistrar,本质上还是一个映射,定义如下:

    class OpKernelRegistrar {
      public:
        typedef OpKernel* (*Factory)(OpKernelConstruction*);
        OpKernelRegistrar(const KernelDef* kernel_def, StringPiece kernel_class_name, Factory factory){
            if(kernel_def != nullptr){
                InitInternal(kernel_def, kernel_class_name, factory);
            }
        }
      private:
        void InitInternal(const KernelDef* kernel_def, StringPiece kernel_class_name, Factory factory);
    }
    

    似乎没有找到存储数据的位置?实际上答案在这个InitInternal函数中,我们先讲结果,追根溯源,得到了这样的一个结构定义:

    typedef std::unordered_multimap<string, KernelRegistration> KernelRegistry;
    

    这个结构还需要两个函数才能发挥作用:

    void* GlobalKernelRegistry() {
        static KernelRegistry* global_kernel_registry = new KernelRegistry;
        return global_kernel_registry;
    }
    static KernelRegistry* GlobalKernelRegistryTyped() {
        return reinterpret_cast<KernelRegistry*>(GlobalKernelRegistry());
    }
    

    通过把GlobalKernelRegistryTyped函数定义为static,使得它返回的数据唯一,因此我们也就得到了一个全局的KernelRegistry作为OpKernel的注册中心。至于KernelRegistration的结构,比较简单,还是留给读者自己去探寻吧。

    5. op_segment

    有时候我们会为每个会话(Session)准备专用的kernel,因此就需要一个结构来管理每个会话的OpKernel,于是有了OpSegment类,我们看下它的结构:

    class OpSegment {
      public:
        void AddHold(const string& session_handle);
        void RemoveHold(const string& session_handle);
        typedef std::function<Status(OpKernel**)> CreateKernelFn;
        Status FindOrCreate(const string& session_handle, const string& node_name, OpKernel** kernel, CreateKernelFn create_fn);
      private:
        typedef std::unordered_map<string, OpKernel*> KernelMap;
        struct Item {
            int num_holds = 1;
            KernelMap name_kernel;
            ~Item();
        };
        typedef std::unordered_map<string, Item*> SessionMap;
        mutalbe mutex mu_;
        SessionMap session_ GUARDED_BY(mu_);
        //...
    };
    

    可见,这个OpSegment类,本质上是一个SessionMap,它其实是一个SessionHandle到Item结构体的映射,而后者又是op名称到OpKernel结构的映射。我们可以用下面的图来表示:

    graph LR SessionHandle-->|包含|Item Item-->|包含|OpKernel

    我们看到在Item结构中,有一个num_holds成员,它表示有多少hold指向了某个SessionHandle,hold的作用可以理解为引用计数,防止Session被删掉。向一个SessionHandle添加hold就是为了防止SessionHandle对应的OpKernel被删除。

    6. 关系图

    graph TB KernelDefBuilder-.创建.->KernelDef KernelDef-.描述.->OpKernel OpKernel-.包含.->OpKernel构造函数 OpKernel构造函数-.使用.->OpKernelConstruction OpKernel-.包含.->Compute函数 Compute函数-.使用.->OpKernelContext OpKernelContext-.包含.->Params OpKernel-.注册.->OpKernelRegistrar OpSegment-.包含.->OpKernel

    7. 涉及的文件

    • op_kernel
    • kernel_def_builder

    8. 迭代记录

    • v1.0 2018-08-28 文档创建
    • v2.0 2018-09-09 文档重构

    github地址

  • 相关阅读:
    hdu 2019 数列有序!
    hdu 2023 求平均成绩
    HDU 5805 NanoApe Loves Sequence (思维题) BestCoder Round #86 1002
    51nod 1264 线段相交
    Gym 100801A Alex Origami Squares (求正方形边长)
    HDU 5512 Pagodas (gcd)
    HDU 5510 Bazinga (字符串匹配)
    UVALive 7269 Snake Carpet (构造)
    UVALive 7270 Osu! Master (阅读理解题)
    UVALive 7267 Mysterious Antiques in Sackler Museum (判断长方形)
  • 原文地址:https://www.cnblogs.com/jicanghai/p/9545674.html
Copyright © 2011-2022 走看看