• TensorFlow中的显存管理器——BFC Allocator


    背景

    作者:DeepLearningStack,阿里巴巴算法工程师,开源TensorFlow Contributor]

    欢迎大家关注我的公众号,“互联网西门二少”,我将继续输出我的技术干货~

    使用GPU训练时,一次训练任务无论是模型参数还是中间结果都需要占用大量显存。为了避免每次训练重新开辟显存带来计算之外的开销,一般框架的做法是在真正的训练任务开始前,将每个节点的输入和输出,以及模型参数的shape计算出来并全局开辟一次,例如Caffe就是这种做法。随着深度学习模型的发展和迭代,不仅模型训练的数据shape可能发生变化,就连模型本身在训练过程中也可能发生变化,那么按照固定shape一次开辟显存的做法就不能满足需求了。为此,TensorFlow重新设计了较为灵活的显存管理机制,它使用了名为BFC的分配算法,并通过BFC Allocator为每个Tensor分配满足需求的显存。本节我们将一起窥探BFC Allocator的设计思想。

    从Tensor的创建谈起

    为Tensor分配存储区的时机

    在进入主题之前,让我们先思考一个问题:TensorFlow中的Tensor究竟是何时拿到所需存储区的呢?答案是在Tensor对象被创建时就立即进行分配。在TensorFlow的一轮训练结束后,所有的Tensor都已经被释放,下一轮计算开始后会按照需求重新创建Tensor,并为其分配新的存储空间。下面的代码片段中我们可以看到Tensor创建时,使用Allocator分配存储区的代码段。

    在创建Tensor对象时需要传入一个Allocator,这个Allocator可以是任何实现类,在GPU上使用的就是BFCAllocator。

     1 Tensor::Tensor(Allocator* a, DataType type, const TensorShape& shape)
     2     : shape_(shape), buf_(nullptr) {
     3   set_dtype(type);
     4   CHECK_NOTNULL(a);
     5   if (shape_.num_elements() > 0 || a->ShouldAllocateEmptyTensors()) {
     6     CASES(type, buf_ = new Buffer<T>(a, shape.num_elements()));
     7   }
     8   if (buf_ != nullptr && buf_->data() != nullptr && LogMemory::IsEnabled()) {
     9     LogMemory::RecordTensorAllocation("Unknown", LogMemory::UNKNOWN_STEP_ID,
    10                                       *this);
    11   }
    12 }

    上面代码的第6行创建了Buffer对象,它就是Tensor对象的实际存储区,让我们看看其构造函数的实现内容。

    1 emplate <typename T>
    2 Buffer<T>::Buffer(Allocator* a, int64 n,
    3                   const AllocationAttributes& allocation_attr)
    4     : BufferBase(a, a->Allocate<T>(n, allocation_attr)), elem_(n) {}

    上面的代码段重点在于第4行,因为在此处调用了Allocate函数,此时Buffer真正获得了一片实际的存储区。这已经能够说明存储区分配的时机是在一个Tensor对象被创建时立即发生的。

    遇到的问题——显存分配与回收的性能需求

    Tensor在每次创建时会得到存储区域,而每一轮训练都要重新创建新的Tensor,那么这里面临的一个问题:如此频繁的分配和回收存储区,如何才能做的高效?试想对于GPU来说,如果Allocate函数直接封装CUDA中昂贵的cudaMalloc函数,当Tensor被释放时直接调用cudaFree函数,那么训练速度将会因为这些overhead大打折扣。

    解决问题的基本思路——存储池

    如果你对操作系统这门课比较熟悉,那么应该很容易想到解决办法:将显存按照不同的大小一次性开辟出来,并组成存储池,每次调用Allocate函数时从存储池中获取,Tensor回收时将显存重新挂到存储池中。这样做确实可以满足性能需求,但是需要为此设计一个相对复杂的存储管理器。BFC Allocator就是TensorFlow中管理GPU显存的存储管理器。

    好了,需求和背景都已经了解了,接下来可以进入正题了,让我们先从原理开始说起。

    Best-Fit with Coalescing与dlmalloc

    BFC的全称是Best-Fit with Coalescing。从TensorFlow源码注释中得知,BFC算法并非TensorFlow完全原创,而是dlmalloc的一个简单实现版本。dlmalloc是一款优秀的存储分配器,它以Doug Lea的名字命名,这个站点包含了dlmalloc的详细说明,有兴趣的同学可以去看一看。之所以在TensorFlow中引入一个简单版本的dlmalloc算法,是因为该算法可以非常高效的按需分配和回收存储区,并尽可能减少存储碎片。

    BFC Allocator基本原理

    核心在于将存储区划分成块,并挂入存储池中进行管理。将存储区划分成存储块时要满足以下要求。

    1. 块内地址是连续地址

    2. 存储池中的块要以每个块基地址升序排列,并组织成双向链表

    3. 高地址块的size大于低地址块的size

    TensorFlow将存储块以及相应的块信息抽象为一种叫做Chunk的数据结构。

    核心数据结构

    Chunk

    Chunk是BFC最核心的数据结构之一,在TensorFlow源码中是以struct来描述的。具体来说,一个Chunk代表一段连续的存储空间,BFC要求各个Chunk要按照基地址升序排列并组织成双向链表,下图展示了Chunk的结构以及Chunk之间的连接关系。初始时,每个Chunk都有自己的size,并且这些size都是以256字节为模。应当注意,每个Chunk或者完全被标记为使用,或者完全标记为空闲,不存在该Chunk内只有部分空间被使用的情况

    prev,next:这两个变量起到指针作用,分别指向前驱和后继Chunk。因为在BFC Allocator模块中多个chunk都被放入了vector中,所以这两个指针实际上就是前驱和后继的index

    ptr:该Chunk的起始存储地址,或者叫基地址

    size:该Chunk描述存储区的实际总大小,每个Chunk的size是不同的,但都以256字节为模

    requested_size:该Chunk描述存储区的使用大小,代表了用户请求使用的大小它一定小于等于size因为Chunk不能被部分使用,所以即使用户实际只使用requested_size,那么也只能将整个大小为size的Chunk全部分配出去,显然这可能会造成一些碎片的浪费

    allocation_id:该值如果不为0,则代表已经被标记为使用,反之则是空闲

    bin_num:代表该Chunk所在Bin的Index。Bin是另一个核心数据结构,下面将会做详细介绍

    Bin

    如果我们想查询某一块符合条件的空闲Chunk并取出,那么只能对双向链表做遍历,显然这个效率不是很高。为了加速查询某块Chunk的速度,可以在创建Chunk链表时按一定顺序排列,并将整个有序链表在逻辑上切分成多个段,为每个段记录所包含的Chunk的范围,这种结构就是Bin,它相当于一种索引。因此,Bin结构是为了方便Chunk的查询而出现的。在BFC Allocator中,每个段中Chunk的顺序是按照size和基地址升序排序的,每个Bin都设有自己的bin_size,该bin_size表示该段包含的最小Chunk的size。这样一来,用户端就可以根据所需要申请的Memory大小直接找到对应的Bin,然后在该Bin中遍历寻找适合的Chunk。为了能够根据bin_size直接定位到Bin,规定bin_size与bin_num的大小关系为:bin_size=256 * 2bin_num。用户在申请Memory时,会将实际大小映射到最适合的bin_size上,然后再根据bin_size与bin_num的关系找到对应的Bin,进而在该段中遍历搜索。

    Bin中Chunk的是通过Set组织的,为了能在Set中体现双向链表的逻辑,只需要让Chunk在Set中按照规则升序排列,并修正前驱后继指针即可。指定Chunk顺序的Comparator代码段定义在Bin结构中,如下所示。

     1 // Sort first by size and then use pointer address as a tie breaker.
     2 bool operator()(const ChunkHandle ha,
     3                 const ChunkHandle hb) const NO_THREAD_SAFETY_ANALYSIS {
     4   const Chunk* a = allocator_->ChunkFromHandle(ha);
     5   const Chunk* b = allocator_->ChunkFromHandle(hb);
     6   if (a->size != b->size) {
     7     return a->size < b->size;
     8   }
     9   return a->ptr < b->ptr;
    10 }

    辅助工具类

    AllocationRegion与RegionManager

    这两个类是起到辅助作用。BFC Allocator每次分配存储区时都以Chunk为单位,指向Chunk的指针又是ChunkHandle类型(实际为数组下标),但分配存储的最终目的是把Chunk中指向存储区域的头指针ptr分配给请求方。另外,当系统回收存储区时,面对的也是存储区的头指针,那么如果不能根据头指针找到Chunk和Bin信息,回收就不能成功。因此这里显然应该设计一系列接口和函数:它能够记录每次分配的Chunk,并且能够保存分配存储区的地址ptr与Chunk之间的映射关系。AllocationRegion和RegionManager就是完成这些功能的接口。

    具体而言,AllocationRegion对应一次存储区分配的记录。一次存储区分配的信息包括起始地址ptr和存储区大小memory_size,这可能包括多个Chunk,所以该结构要记录此次分配中所包含所有Chunk的信息。RegionManager是AllocationRegion的管理器,它维护了AllocationRegion的数组。在RegionManager中,AllocationRegion数组是需要按照end_ptr地址排序的。

    利用RegionManager查询某个ptr所对应的ChunkHandle的时序图如下图所示。

    这部分功能较为简单,所以不再展开代码逻辑,感兴趣的同学可以阅读这两个类的定义立即就能理解。

    BFC分配与回收策略

    介绍完基本结构和BFC的设计思想之后,就可以试着去理解具体的存储区分配和回收过程了。

    Allocate流程

    AllocateRawInternal

    这是BFCAllocator的为用户分配Chunk的总体流程。因为物理设备上实际的空闲存储区已经被事先开辟好,并以Chunk的形式组织成了双向链表,那么BFC Allocator为用户分配存储区时直接从Chunk中获取即可。当双向链表中找不到合适的Chunk时,不得不向物理设备上申请更多存储空间,并创建新的Chunk放入到双向链表中,并挂入到B相应的Bin中。下面的流程图展示了这一过程,该过程涉及到了几个比较重要的子过程。它们分别是遍历搜索寻找最佳Chunk指针的FIndChunkPtr过程,当Chunk链表中不存在合适的Chunk以至于不得不向物理设备申请新存储空间的Extend过程,以及分配Chunk时为缓解碎片问题而出现的SplitChunk过程。

    整体流程的代码如下所示。

     1 void* BFCAllocator::AllocateRawInternal(size_t unused_alignment,
     2                                         size_t num_bytes,
     3                                         bool dump_log_on_failure,
     4                                         uint64 freed_before) {
     5   if (num_bytes == 0) {
     6     VLOG(2) << "tried to allocate 0 bytes";
     7     return nullptr;
     8   }
     9   // First, always allocate memory of at least kMinAllocationSize
    10   // bytes, and always allocate multiples of kMinAllocationSize bytes
    11   // so all memory addresses are nicely byte aligned.
    12   size_t rounded_bytes = RoundedBytes(num_bytes);
    13 
    14   // The BFC allocator tries to find the best fit first.
    15   BinNum bin_num = BinNumForSize(rounded_bytes);
    16 
    17   mutex_lock l(lock_);
    18   void* ptr = FindChunkPtr(bin_num, rounded_bytes, num_bytes, freed_before);
    19   if (ptr != nullptr) {
    20     return ptr;
    21   }
    22 
    23   // Try to extend
    24   if (Extend(unused_alignment, rounded_bytes)) {
    25     ptr = FindChunkPtr(bin_num, rounded_bytes, num_bytes, freed_before);
    26     if (ptr != nullptr) {
    27       return ptr;
    28     }
    29   }
    30 
    31   // We searched all bins for an existing free chunk to use and
    32   // couldn't find one.  This means we must have run out of memory,
    33   // Dump the memory log for analysis.
    34   if (dump_log_on_failure) {
    35     LOG(WARNING) << "Allocator (" << Name() << ") ran out of memory trying "
    36                  << "to allocate " << strings::HumanReadableNumBytes(num_bytes)
    37                  << ".  Current allocation summary follows.";
    38     DumpMemoryLog(rounded_bytes);
    39     LOG(WARNING) << RenderOccupancy();
    40   }
    41   return nullptr;
    42 }

    FindChunkPtr过程

    因为Chunk在每个Bin中都是按照size和基地址升序排列,所以搜索Chunk时只需顺序遍历free_chunks即可,首个找到的符合要求的Chunk即为所求。这个过程非常简单,不再以图的形式描述,只展示代码如下。

     1 void* BFCAllocator::FindChunkPtr(BinNum bin_num, size_t rounded_bytes,
     2                                  size_t num_bytes, uint64 freed_before) {
     3   // First identify the first bin that could satisfy rounded_bytes.
     4   for (; bin_num < kNumBins; bin_num++) {
     5     // Start searching from the first bin for the smallest chunk that fits
     6     // rounded_bytes.
     7     Bin* b = BinFromIndex(bin_num);
     8     for (auto citer = b->free_chunks.begin(); citer != b->free_chunks.end();
     9          ++citer) {
    10       const BFCAllocator::ChunkHandle h = (*citer);
    11       BFCAllocator::Chunk* chunk = ChunkFromHandle(h);
    12       DCHECK(!chunk->in_use());
    13       if (freed_before > 0 && freed_before < chunk->freed_count) {
    14         continue;
    15       }
    16       if (chunk->size >= rounded_bytes) {
    17         // We found an existing chunk that fits us that wasn't in use, so remove
    18         // it from the free bin structure prior to using.
    19         RemoveFreeChunkIterFromBin(&b->free_chunks, citer);
    20 
    21         // If we can break the size of the chunk into two reasonably large
    22         // pieces, do so.  In any case don't waste more than
    23         // kMaxInternalFragmentation bytes on padding this alloc.
    24         const int64 kMaxInternalFragmentation = 128 << 20;  // 128mb
    25         if (chunk->size >= rounded_bytes * 2 ||
    26             static_cast<int64>(chunk->size) - rounded_bytes >=
    27                 kMaxInternalFragmentation) {
    28           SplitChunk(h, rounded_bytes);
    29           chunk = ChunkFromHandle(h);  // Update chunk pointer in case it moved
    30         }
    31 
    32         // The requested size of the returned chunk is what the user
    33         // has allocated.
    34         chunk->requested_size = num_bytes;
    35         // Assign a unique id and increment the id counter, marking the
    36         // chunk as being in use.
    37         chunk->allocation_id = next_allocation_id_++;
    38 
    39         // Update stats.
    40         ++stats_.num_allocs;
    41         stats_.bytes_in_use += chunk->size;
    42         stats_.peak_bytes_in_use =
    43             std::max(stats_.peak_bytes_in_use, stats_.bytes_in_use);
    44         stats_.largest_alloc_size =
    45             std::max<std::size_t>(stats_.largest_alloc_size, chunk->size);
    46 
    47         VLOG(4) << "Returning: " << chunk->ptr;
    48         if (VLOG_IS_ON(4)) {
    49           LOG(INFO) << "A: " << RenderOccupancy();
    50         }
    51         return chunk->ptr;
    52       }
    53     }
    54   }
    55 
    56   return nullptr;
    57 }

    SplitChunk过程

    上图中没有展示出SplitChunk发生的位置,其实该过程是在FindChunkPtr中发生。在选取Chunk时,会有一定概率出现请求的size比所选的Chunk总size小很多的情况。因为每块Chunk只有in use或free两种状态,所以如果空闲的size比请求的size大很多,显然会造成该Chunk的实际使用率过低,这是一种浪费。BFC Allocator通过调用SplitChunk将Chunk分割成两部分来缓解这一问题。SplitChunk的功能顾名思义,就是将一块大的Chunk分割成两个部分。该过程发生在FindChunkPtr中,我们需要注意触发SplitChunk过程的条件,在代码中我们能看到这一函数的调用条件如下。

     1 // If we can break the size of the chunk into two reasonably large
     2 // pieces, do so.  In any case don't waste more than
     3 // kMaxInternalFragmentation bytes on padding this alloc.
     4 const int64 kMaxInternalFragmentation = 128 << 20;  // 128mb
     5 if (chunk->size >= rounded_bytes * 2 ||
     6     static_cast<int64>(chunk->size) - rounded_bytes >=
     7         kMaxInternalFragmentation) {
     8   SplitChunk(h, rounded_bytes);
     9   chunk = ChunkFromHandle(h);  // Update chunk pointer in case it moved
    10 }

    从代码中可以清晰的看到,当以下两个条件之一满足时,SplitChunk过程将被触发。

    1. 当chunk的size是用户请求的round size两倍及以上时(用户请求的size会根据最小分配单元做round近似)

    2. 当chunk的size减去用户请求的round size后依然大于等于最大碎片限定时(128MB)

    在执行SplitChunk时,需要调整Chunk的前驱后继指针,这就是链表的基本操作,非常简单。另外,SplitChunk会产生新的Free Chunk,需要根据它的大小将它插入到对应的Bin中。

    Extend过程

    上面的流程图已经展示,只有在双向链表中不能找到合适的Chunk时,Extend过程才会被调用。它的调用说明现有的存储池中已经没有可以满足需求的存储区了,需要向物理设备申请,并创建新的Chunk,然后放入Bin中。向物理设备申请存储空间时,如果因为一次申请的空间较大而失败,会将请求空间做0.9因子的衰退,下面的代码段展示了这个细节。申请结束后,需要向region_manager中记录该次申请。

     1 // Try allocating.
     2 size_t bytes = std::min(curr_region_allocation_bytes_, available_bytes);
     3 void* mem_addr = sub_allocator_->Alloc(alignment, bytes);
     4 if (mem_addr == nullptr && !started_backpedal_) {
     5   // Only backpedal once.
     6   started_backpedal_ = true;
     7 
     8   static constexpr float kBackpedalFactor = 0.9;
     9 
    10   // Try allocating less memory.
    11   while (mem_addr == nullptr) {
    12     bytes = RoundedBytes(bytes * kBackpedalFactor);
    13     if (bytes < rounded_bytes) break;
    14     mem_addr = sub_allocator_->Alloc(alignment, bytes);
    15   }
    16 }

    Deallocate流程

    因为在回收时只知道存储空间首地址指针,并不知道其对应的Chunk,所以需要先借助region_manager等辅助工具获取其所对应的Chunk指针,然后考虑其前驱后继节点是否可以合并。下面展示了整体流程。因为Merge的过程即使链表合并的过程,比较简单,所以在此不再赘述。

    这部分对应的代码逻辑如下图所示。

     1 void BFCAllocator::FreeAndMaybeCoalesce(BFCAllocator::ChunkHandle h) {
     2   Chunk* c = ChunkFromHandle(h);
     3   CHECK(c->in_use() && (c->bin_num == kInvalidBinNum));
     4 
     5   // Mark the chunk as no longer in use.
     6   c->allocation_id = -1;
     7 
     8   // Optionally record the free time.
     9   if (timing_counter_) {
    10     c->freed_count = timing_counter_->next();
    11   }
    12 
    13   // Updates the stats.
    14   stats_.bytes_in_use -= c->size;
    15 
    16   ChunkHandle coalesced_chunk = h;
    17 
    18   // If the next chunk is free, merge it into c and delete it.
    19   if (c->next != kInvalidChunkHandle && !ChunkFromHandle(c->next)->in_use()) {
    20     // VLOG(8) << "Merging c->next " << ChunkFromHandle(c->next)->ptr
    21     //         << " with c " << c->ptr;
    22     RemoveFreeChunkFromBin(c->next);
    23     Merge(h, c->next);
    24   }
    25 
    26   // If the previous chunk is free, merge c into it and delete c.
    27   if (c->prev != kInvalidChunkHandle && !ChunkFromHandle(c->prev)->in_use()) {
    28     // VLOG(8) << "Merging c " << c->ptr << " into c->prev "
    29     //         << ChunkFromHandle(c->prev)->ptr;
    30 
    31     coalesced_chunk = c->prev;
    32     RemoveFreeChunkFromBin(c->prev);
    33     Merge(c->prev, h);
    34   }
    35 
    36   InsertFreeChunkIntoBin(coalesced_chunk);
    37 }

    Allow Growth

    这是控制Allocator的一个选项,默认是False,此时会在设备上开辟最大限度的存储空间,并且全局只开辟一次。因为已经开辟了设备上的全部存储空间,所以若在双向链表中找不到合适的Chunk,那么将会直接报错OOM退出。当选项为True时,会经历多次存储空间的开辟,这完全取决于当前存储池中是否还有符合需求大小的Chunk。如果没有,则不断以2的n次方为基本大小进行开辟尝试,直到满足需求为止。那么这个值有什么用处呢?这取决于同一个Device是否允许被多个程序复用。比如在云基础设施上,如果能够开启Device复用,并打开Device的空分复用功能,那么将会大大提高集群资源的利用率。

    总结

    本文总结了TensorFlow中存储管理器——BFC Allocator。它的设计思路来自于经典来的dlmalloc分配算法,是Best fit coalecing的简单实现版本。BFC Allocator是为了应对TensorFlow中频繁分配释放存储空间需求的场景而出现的解决方案,通过事先将存储空间从物理设备上开辟好,并将这些空闲存储空间封装成Chunk,组织成有序双向链表,然后利用Bin这一种索引结构为Chunk的查询做加速,最终完成了高效的分配算法。在实际分配时,可能会遇到Chunk链表中不存在符合要求的空闲Chunk情况,这时候就可能需要向物理设备中再次开辟新的存储空间,这个过程被视为对Chunk链表的扩展,对应的过程是Extend。因为是按Chunk进行分配,势必可能造成存储碎片,为了解决碎片问题,BFC Allocator设计了SplitChunk和Merge函数。BFC Allocator是TensorFlow代码中比较精简的一个部分,该部分的代码难度较低,并且模块独立性较强,涉及到的代码量非常小,但是设计思想和功能却非常全面,非常适合初学者阅读和学习。

  • 相关阅读:
    php 数组分页
    Fchart
    thinkphp对数据库操作有哪些内置函数
    MySQL性能优化的最佳20+条经验
    apache 简单笔记
    PHPMyadmin 配置文件详解(配置)
    mysql 常用知识
    分布式微服务日志的配置
    分布式微服务的配置
    分布式接口的调用
  • 原文地址:https://www.cnblogs.com/deep-learning-stacks/p/10741859.html
Copyright © 2020-2023  润新知