内存动态分区分配_内存优化系列文章(5)动态内存分配器

eaefed15257ed6812f5b3ce26087bc5a.png

动态内存分配器的优势

应用程序可以通过 brk/mmap 系统调用从操作系统动态申请内存,但是通过系统调用管理内存,不仅管理效率低下,而且移植性也很差,比如为了减少内存碎片和缺页异常,一个应用程序可能需要对动态内存分配做很多优化,但是却很难轻松移植到另外一个程序。而动态内存分配器则相当于把一些常用的优化技术做了一层抽象和封装,这样应用程序则只需要简单的使用其提供的API便可以获得较好的内存管理。因此,使用管理方便,移植性好的动态内存分配器便成为必然的趋势。

c99508f02ab31b30e2d86016f80fda44.png

API

动态内存分配器管理的是堆区域,C/C++ 采用显式动态内存分配,应用程序需要对动态申请的内存负责释放。主要 API 如下:

#include void *malloc(size_t size)
  • malloc  返回一个 void* 指针,指向未初始化,并且符合对齐要求的一块连续的虚拟内存空间,其内存大小至少为 size。 系统一般要求申请的字节为双字对齐,32位模式中地址总是8的倍数,64位模式中总是16的倍数。如果需要初始化,可以采用 calloc。calloc 在malloc 的基础上做了一层封装,会把申请的内存空间初始化为零。如果想要改变以前已经分配的大小,则需要使用 relalloc 函数。

    void free(void* ptr)
  • ptr 参数必须指向 malloc/calloc/realloc 分配的虚拟内存空间的起始位置,否则将产生未定义的错误。一般情况下,free 相对于动态分配器而言,只是将其标志为了空闲块,并不一定会归还给操作系统。

  • 对比分析:结合我们之前的讨论我们也可看出,因为 calloc 已经初始化了内存空间,那么调用 calloc 分配的页在页表中一定有对应的页表项。而 malloc 申请的内存空间则不一定有。比如应用程序第一次调用malloc分配内存空间 ptr ,此时 ptr 指向的这些页面是没有页表项的,只有实际访问 ptr 指向的内存时才会触发缺页中断建立页表项。 访问 ptr 完成被 free 掉了,也即 ptr 指向的内存变成了空闲块。如果此块内存并没有还给操作系统,而此时程序又申请一块相同大小的虚拟内存,那么动态内存分配器很可能将 ptr 指向的空闲块再次分配给应用程序,这种情况下此块页面对应的页表项存在的,访问时并不会产生缺页中断,相当于动态内存分配器为 ptr 做了缓存处理,加快了程序的执行速度,这种内存可以重复使用的情况在深度学习领域非常常见,比如前后几层的算子通常具有相同尺寸大小的输入输出。我们在第一篇内存优化文章中的 BERT 模型就具有这种典型的特征。

实现要点

动态内存分配器主要包括申请和释放两种API。 申请的过程就是从空闲块列表中查找一个合适的空闲块,而释放则是把已经分配的内存块重新标记为空闲块。那么空闲块如何组织,如何查找空闲块,如果查找到的空闲块比实际请求的内存块要大如何分割?内存释放时可能会遇到相邻的空闲块,要不要合并?这些问题都是动态内存分配器的实现要点。

      空闲块组织

空闲块有隐式和显式两种组织方式,隐式的组织方式会把空闲块和分配块组织成一个链表,每次申请内存时都需要从头开始查找,效率低下,这里不展开描述。

对于每一个块,我们需要头部和脚部信息表示块的大小(包括头部大小)以及是否被分配等标记信息,脚部信息通常是头部的副本,这样有利于加快相邻块合并。32 位地址需要 8 字节对齐,因此内存块大小的后三位都是0, 可以被用来保存标记信息,假设有一个已分配的块,大小位24(0x18)字节,那么它的头部将是 

0x00000018|0x1 = 0x00000019

而对于一个块大小为40(0x28)字节的空闲块,头部如下:

0x00000028|0x0 = 0x00000028

25e1a7174b13ba63544bc1e13dfeaace.png

显示的组织方式通常把堆组织成一个双向空闲链表,每个空闲块节点都包含一个前驱和后继指针。

       放置分配块

当应用程序请求一个分配块时,分配器会搜索空闲块链表,找到一个足够大的块可以放置所请求块的空闲块。常见的放置策略有首次适配、下一次适配和最佳适配等。首次适配从头开始搜索空闲链表,选择一个合适的空闲块。下一次适配和首次适配很相似,只不过不是从链表的起始处开始,而是从上次查询结束的地方开始。最佳适配则是检查每个空闲块,选择适合所需请求的最小空闲块。

      分割空闲块

通常查找到的空闲块会比所请求的大小要大,如果不做分割,则会产生较多的内部碎片。通常会在满足对齐要求的情况下,将放置块分割成两部分,剩下的部分会成为一个新的空闲块。

     合并空闲块

当分配器释放一个已分配的空闲块时,可能有相邻的空闲块。这些相邻的空闲块可能会造成一种假碎片的现象。比如空闲块只有两个相邻的 4 字空闲块,这时应用程序请求一个 8 字的空闲块,虽然实际有 8 字相邻的空闲空间,但是却无法分配成功。为了解决假碎片问题,分配器需要采取一定策略决定何时合并相邻的空闲块,比如 TCMalloc 分配器中,只有线程缓存的相邻空闲块大于 2M时才会合并相邻空闲块。立即合并可能是最简单的,但是可能会产生反复分割合并的抖动。此外,在上述内存块的头部脚部信息中,第 i 个分配块脚部信息总是处于第 i+1 个分配块的上一个字中,因此对于第 i+1 块而言,可以通过查询第 i 个块的脚部信息快速判断这两个块是否可以合并。

       获取额外堆内存

如果分配器不能为请求块找到合适的内存空闲块,甚至在合并了空闲块之后还是不能找到,此时分配器就会通过调用 sbrk 函数向内核申请额外的堆内存。分配器将额外的内存转化成一个大的空闲块放入空闲链表。

实现目标

目标:

最大化吞吐率和内存利用率(减少内部碎片和外部碎片)

显示分配器的约束条件:

  • 处理任意的请求序列

    应用程序可以有任意的申请和释放请求序列,比如,释放的指针只要是一个已经成功分配的内存块,就应该响应释放请求,分配器不能假设申请和释放序列。

  • 立即响应请求

    不允许给请求重新添加优先级排列。

  • 只使用堆

  • 对齐要求

  • 不修改已分配的块

    分配器只能操作或者修改空闲块,对于已经分配的块不可修改或移动。

42bd7a2623de3b0a872e0706c0074269.png

6e1ae70d25b8393ef3d29a5258ed4489.png

        欢迎关注“程序员的大厂之路”微信公众

           更多技术干货持续更新中......       

                内存优化系列专题

                   深度学习系列

                         ...

a592259d003dd10159b7bad8f7aa7291.png

作者简介:小编是世界500强企业的一名深度学习软件工程师,主要从事高性能计算,深度学习性能优化方面的工作:包括但不限于深度学习框架优化、模型量化、分布式训练等。欢迎留言一起讨论计算机基础,深度学习性能优化等方面的知识

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值