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

Malloc Lab

时间:2020-12-02 12:40:09      阅读:5      评论:0      收藏:0      [点我收藏+]

标签:左右   位置   poi   时间   参数   32位   loading   init   success   

Basic Info

这是CMU 15-213的Malloc Lab,本来没打算做,被同学安利了一波~
需要用C实现A Dynamic Storage Allocator,类似于libc中的malloc/free/realloc,整体来看难度较大。

开始没什么思路,看了下CSAPP动态内存分配那一节。
内存的划分是这样子的:
技术图片
运行时分配的虚拟内存主要是Heap,Allocator将堆视作不同size的块,Allocator有2种:

  • Explicit Allocators:需要应用程序手动释放申请的内存块,malloc/free
  • Implicit Allocators:就是garbage collectors

malloc返回的对齐地址取决于编译环境,32位是8的倍数,64位是16的倍数;malloc不会初始化申请的内存,calloc会初始化内存为0。
堆的增长是通过增加内核的brk指针来增加/减小堆:

void *sbrk(intptr_t incr);  // success: old brk pointer; error: -1

如果free的是一个非法指针,那么结果未定义。

主要实现在mm.c中,有4个主要函数:

int mm_init(void);
void *mm_malloc(size_t size);
void mm_free(void *ptr);
void *mm_realloc(void *ptr, size_t size);

mm_init负责初始化,比如分配初始的堆空间,成功返回0,失败返回-1;
mm_malloc负责分配指定大小的空间,但是这个空间必须在已经初始化的堆里,不能与其他空间冲突;返回的指针按照8B对齐,与libc中实现的malloc类似,最后会有一个PK;
mm_realloc负责:

  • ptrNULL,等价于void *mm_malloc(size)
  • size=0,等价于mm_free(ptr)
  • 如果ptr不为NULL,把ptr所指的内存块增加/减小到size个字节,返回新地址(可能与原来的块相同,也可能不同,取决于实现方式、原块里内碎片的数量以及请求size的大小),保持原块的内容不变(如果申请变大,剩下的空间是未初始化的;如果申请变小,那么只保留size大小的原块内容)

memlib.c为我们的分配器模拟了内存系统,可以调用里面的一些函数:
void *mem_sbrk(int incr): 将堆扩展incrB,参数是正整数,返回新分配的内存首地址;
void *mem_heap_lo(void): 返回堆的首地址;
void *mem_heap_hi(void): 返回堆的最后一个字节的地址;
size_t mem_heapsize(void): 返回当前堆的大小;
size_t mem_pagesize(void): 返回系统页面大小。

Implementation

首先明确设计约束条件:

  • 可以处理任意的请求释放序列
  • 请求需要被立即响应
  • 内存对齐要求
  • 不能修改已经分配的内存块

再明确设计目标:

  • 最大化吞吐量
  • 最大化内存利用率

吞吐量最大化:单位时间处理的请求,分配复杂度\(O(n)\),n是空闲块的数目,释放复杂度\(O(1)\)
内存利用率最大化:用peak utilization衡量,假设有\(n\)个请求:\(R_0, R_1, ... R_k, ..., R_{n-1}\),在\(R_k\)完成后,将aggregate payload(申请的字节总数)记作\(P_k\),当前的堆的大小记作\(H_k\)(单调不减),单调不减的条件可以通过使\(H_k\)为high-water mark来松弛,这样堆就可以上下都增长。那么peak utilization为:

\[U_k=\frac{max_{i\leq k}P_i}{H_k} \]

我们的目标是最大化\(U_{n-1}\)

这两目标是需要trade-off的,吞吐量越大,意味着需要减小操作的时间,往往就不能花费时间去处理碎片;内存利用率越大,意味着要精心处理分配和回收的块,自然需要更多的时间。

具体来说,有以下几点:
技术图片
这些关键细节的设计非常重要,再BB一次:程序架构、数据结构和接口设计是一门艺术!

  • free只给一个指针,怎么知道要释放多少空间:记录每一块的大小;
  • 空闲块的组织管理:隐式链表、显式链表(双向链表)(存储指针域开销太大)、Segregated Free Lists、将空闲块始终按大小排序;
  • Placement: 有多个满足要求的空闲块,如何选择以放置新的申请:First Fit, Next Fit, Best Fit
  • Splitting: 在一个空闲块放置申请后如何处理剩余的空闲空间。可以直接将整个空闲块分配出去,也可以将剩余的空闲空间重新利用;
  • 无法找到合适的满足请求的块:做空闲块合并后再次检查是否可以满足;用sbrk向内核申请更多的内存,插入空闲链表;
  • 空闲块合并:需要考虑何时合并:立即合并(可能引发抖动)、延迟合并(申请失败时合并整个堆里所有的空闲块);合并后面的块很容易,但是要高效合并前面的空闲块,需要用双向的Boundary Tag(可以优化以减少空间开销);

不仅需要记录每块的大小,还需要区分已分配块和空闲块,所以block的格式可以设计如下:
技术图片
如果要求double-word(8B)对齐,那么Block size总是8的倍数,所以低3位都是0,可以利用其存储分配状态。

这样整个堆就可以组织为连续的分配和空闲块,由于已经存储了每块的大小,所以隐式空闲链表就应运而生:
技术图片
隐式链表可以在\(O(1)\)的时间里合并之后的空闲块,但是要合并之前的空闲块,需要\(O(n)\)的时间扫描整个堆,这显然无法接受。Knuth大佬提出了boundary tags,这样实际上相当于隐式双向链表:
技术图片
这样就可以通过Footer在\(O(1)\)检查之前的块的状态及其开始位置,缺点在于如果小的内存块比较多的话,内存开销太大。
双向tag带来的内存开销可以优化:只有前面的块是空闲,才需要它footer里的大小,所以可以在每个块用后3位里的某一位来存储前面块的状态,那么已分配块就不需要footer了,可以把footer的空间用来当作payload,但是空闲块仍然需要2个tag。

那么现在整个堆变成了这样:
技术图片
这里的heap_listp指向Prologue block的中间是做了一些小优化,方便直接定位到下一块的数据位置。

这里的实现非常tricky和subtle,一开始只申请了4words,unused(1)+Prologue(2)+Epilogue(1),后续的extend_heap申请一个空闲块后,将原来的Epilogue作为空闲块的header,空闲块的最后一个word变为新的Epilogue。

由于对齐要求(Headers在非对齐位置,Payloads对齐),分配器应该有一个minimum block size,即使只请求了1B,也要分配minimum block size,这里是16B。

写代码时先实现并测试mallocfree,如果能正确并且高效执行,再去实现realloc

Evaluation

性能主要考虑2方面因素:

  • 空间利用率\(U\):peak ratio即评测程序申请的总内存(mm_malloc/mm_realloc但是还没有mm_free)与分配器使用的堆容量的比值,需要用好的策略减小碎片,使得该值接近1;
  • 吞吐量\(T\):每秒完成的操作数量

评测程序会综合考虑2个方面,计算一个performance index

\[P=wU+(1-w)min(1,\frac{T}{T_{libc}}) \]

\(T_{libc}\)libc中的malloc的吞吐量,大概在600Kops/s左右,\(w=0.6\)
这个\(P\)既考虑了内存资源,又考虑了CPU资源,两个矛盾的指标需要适当权衡。

第一个版本mm1.c基本就是抄书,Implicit Free Lists+First Fit+Bi Boundary Tag,抄书也就将将及格。。
技术图片
将First-Fit改成Next-Fit,还不错:
技术图片
可以看到:Next-Fit在速度上有很大提升,主要是因为它是从上次终止的块开始搜索,避免了前面很多块的无效搜索。

最后对于Implicit Lists的性能做个总结:
分配:\(O(n)\)
释放:\(O(1)\)
Memory Overhead:取决于First Fit等策略

感觉这个性能已经不错了,但是一些无聊的计算机科学家还是不满意分配时的效率。接着我们看下更加????的方法Segregated Free Lists:
技术图片
每个size级别的块都有自己的free list,分配大小为n的块时:

  • 搜索合适的free list使得size>n
  • 找到:split并将remainder放入应该去的list
  • 未找到:向操作系统申请更大的内存,分出去n,将剩下的放入相应的list

释放时合并空闲块并且放入相应的free list即可。

这实际上近似模拟了Best Fit,而且不用搜索整个free list,Best Fit一般有着最优的内存利用率,但是运行时间\(O(n)\),又是吞吐量和内存利用率的trade-off,终于明白了为什么官方的malloc要用这个方式了:吞吐量更大\(O(lgn)\)、更优的内存利用率Best Fit。

Segregated Free Lists中的空闲块包含Header+prev+next+padding+Footer,已分配块没有前后指针。
写代码要注意:整个堆中的块位置是不变的,只是状态(分配、释放)在改变,整个堆中的空闲块是用seg list的方式串起来的。

Debug可以自己写一下mm_check,还是很有用的。
realloc快de疯了,整整一个晚上。。。其实就4种情况:

  1. 如果当前已分配块后面是结尾块,直接申请新的堆空间,与原块组合返回;
  2. 如果当前已分配块后面是一个空闲块,且两者之和>=size,组合返回;
  3. 如果当前已分配块后是一个空闲块,但两者之和<size,但是空闲块后是结束块,申请新的堆空间,三者组合返回;
  4. malloc新块,将原块复制,释放原块。

第一次写完,只有85,内存利用率太差了:
技术图片
place的时候,如果申请块比较大,我们将其分配到后半部分,将前半部分切割为空闲:
技术图片
之前class的划分是1,2-3,4-7,8-15...
但是最小块是16B,所以16B以下的用不到,所以改为16-31,32-63,64-127,128-255...

这样优化后直接96:
技术图片
后面还可以继续优化榨干性能,比如去掉已分配块的Footer,realloc组合块以后对remainder进行split...
以后有时间再说......

Malloc Lab

标签:左右   位置   poi   时间   参数   32位   loading   init   success   

原文地址:https://www.cnblogs.com/EIMadrigal/p/14051740.html

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