Malloc Lab


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...
以后有時間再說......


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM