malloc-large
測試項目來看,為何 rp 的執行時間比 sys (glibc) 來得長?目前經過幾個測試,發現到 rpmalloc 的執行時間在分配 2MB ~ 35 MB 的時候,配置的時間會比 sys 還要長,我認為可能的原因有以下幾個
- rpmalloc 在面對超大空間 (2097120 bytes) 時,是從作業系統分配,而不是從 freelist 中尋找。因為 rpmalloc 專注在小空間的分配,所以它將 三種尺寸的 size 都訂的比較小 (the small blocks are [16, 1024] bytes, medium blocks (1024, 32256] bytes, and large blocks (32256, 2097120] bytes. ),因此這個可能導致在配置時,所花費的時間變長。此外如果將分配的空間設定在 1MB ~ 2MB 時, rpmalloc 的執行時間就會比 sys 所花的時間要短; 而超過 35 MB 後,兩者所配置的時間就會相當接近
第 5 週測驗題、第 6 週測驗題、第 11 週測驗題提及逐步建構記憶體配置器 (memory allocator),本任務針對 Linux 核心的機制,設計現代的高效記憶體配置器。
第 5 週測驗題、第 6 週測驗題、第 11 週測驗題提及逐步建構記憶體配置器 (memory allocator),搭配閱讀 CS:APP 第 9 章,解讀原本程式碼原理、指出其不足處,並著手改進。此部分還不考慮 lock-free 設計,但應量化和分析 lock contention 的影響。
目前已完成
待完成
目前遇到問題
目前打算先消除對 srbk / brk 系統呼叫的依賴,改用 mmap 系統呼叫。
目前小尺寸的配置採用鏈結串列,並引入 a1091150/2023q1_Homeworl6_quiz5 的 memory pool ,經過修改後進行使用
可以看到這個 commit 版本的程式碼在距離 allociator 還差得很遠
TODO: 解釋 perf 結果Image Not Showing Possible ReasonsLearn More →
- The image file may be corrupted
- The server hosting the image is unavailable
- The image path is incorrect
- The image format is not supported
以下列出 test_small 與 test_large 的 perf performance
與 thestinger/allocator 的 perf 結果
經過上面的測試結果發現,我的 page fault 大約都是比 allocator 的多了 20% 左右,並且在 test_small 的 instructions 數量比起 allocator 多了非常多,也因為這樣,導致出現了 context-switches 的數量非常高。 我的推測是我在小尺寸的配置器實作上,因為使用
linked-list 導致在搜尋的時候會因為 FIFO 或是 LIFO 的堆疊機制導致出現 worst case ,進而導致 instructions 的數量大增。
可以看到目前的 test_large 的表現是還不錯的,但是還有一些需要改進的地方,像是記憶體的位置可能重新配置要盡量固定在原地等等的問題。
再來應該著手改善的是要進行小尺寸記憶體配置器的效能改善以及設計一個可以選擇要用小尺寸還是大尺寸的選擇器。
首先先用 perf graph 來看看究竟是哪個環節佔用了最多的時間
可以得到以下結果
可以看到大部份的時間都是在 pool_free
與 get_loc_to_free
這兩個函式之間,所以可能要再回去重新設計這兩個函式。
從 test_small.c
這個測試來看到它所測試的是 FIFO ,而我所進行的實作卻是 LIFO ,因此在面對到這樣的測試的時候,就會顯的特別的耗時
若是將 test_small.c
修改成從這個 array 的末端開始釋放的話,就可以看到他的性能明顯的提昇,符合上面的推測。
但是這樣做並不是一個完美的解決方法,因此,可以將改為 tree 來進行,這樣就可以至少保證搜尋的時間會是 ,詳見 Red–black tree 。
或是使用
這幾種方式來改善效能。
至少涵蓋以下:
著重於以下考量因素:
rb.h
首先先看到 rb.h ,可以發現它與 quiz4 的 rb.h 是差不多的,因此這裡就不過多進行介紹
詳細的筆記可以參考 wanghanchi / linux2023-quiz4 與 wanghanchi / linux2023-tree
test_small.c
這段程式碼測試了程式在多執行緒情況下,能否正確地配置及收回記憶體。特別是在這種配置的記憶體數量是比較小 (16 Bytes) 且大量 (10000000個) 的情況下。
使用了 alloc_so
來進行測試,發現可以正常執行並且檢查回傳值也是 0
test_large.c
在這段程式碼中,使用 malloc
來配置了一個大小為 4096 * 4 (16KB) 的記憶體區域,然後使用多次 realloc 來改變其大小。在每次 realloc 呼叫之後,我們都檢查返回的指標是否與原始指標相同,以確認是否發生了記憶體區域的移動。
可以從註解看到 // in-place shrink
與 // in-place expand
。如果需要重新配置的記憶體大小比先前的還要小的話,是會將已配置的記憶體空間縮小至指定的大小; 相對地,若是要重新配置的空間比原本的還要大的話,就會指向新配置的更大記憶體。
直接從 realloc 這個程式碼開始看!
首先先了解對於 small 與 large 的 size 定義
可以得知 MAX_SMALL 為 512 ,而 MAX_LARGE 為 ((4096 * 1024) - (32 + 32)) = 4194240
接著看到從 realloc 的其中一段程式碼看到原地縮小以及原地擴張的條件為 <= MAX_LARGE
還有 >= MAX_SMALL
,也就是說要 realloc 的大小在 512 ~ 4194240 之間就不會去改變記憶體的初始位置而只有改變大小
接著一樣測試看看程式
回傳值為 0
,代表沒有問題 !
test_huge.c
這段程式碼主要也是在測試 malloc 與 realloc 的一些行為,主要可以分成以下幾點
首先,上面已知道 CHUNK_SIZE
為 4096 * 1024 = 4194304 ,因此只要配置了一個 CHUNK_SZIE
就會直接超出 MAX_LARGE
的大小
接著看到 realloc 的程式碼,可以看到如果超過了 MAX_LARGE
的大小之後,就會使用 huge_realloc
再來看到 huge_realloc 的程式碼
可以看到如果 new_real_size < old_size
, 就會不改變原本這塊記憶體的初始位置,並且進行大小裁剪; 如果 new_real_size > old_size
的話就會先嘗試不改動初始位置進行配置,如果失敗的話才去重新配置一塊新的記憶體位置; 而至於位什麼不考慮 new_real_size == old_size
的情況是因為在 realloc 函式的一開始就有檢查兩個大小是否一樣,因此不會有這種情況發生。
接著繼續往下查看 huge_no_move_expand
其中 memory_commit
的用處是將之前使用 mmap 配置的虛擬記憶體 page 實際配置到實體記憶體中。在 Linux 中,mmap 配置的虛擬記憶體 page 不會直接配置到實體記憶體中,而是配置到虛擬記憶體區域(Virtual Memory Area,VMA)中,當應用程式實際使用該 page 時,VMA 才會將其配置到實體記憶體中,這個過程也被稱為 page fault。
memory_commit
函式會呼叫 mprotect 函式,將指定的位址和大小的記憶體區域的保護權限設定為可讀可寫。由於在 Linux 中只有具有寫權限的頁才會被配置到實體記憶體中,因此透過將記憶體區域的保護權限設定為可讀可寫,就可以將之前配置的虛擬記憶體頁配置到實體記憶體中。
接著回去看到 huge_realloc
這個函式,可以看到如果有進到 huge_move_expand
這個函式的話,就會如同註解所說的用到 MREMAP
這個系統呼叫,從 man page 可以看到它會重新映射一個已經存在的虛擬記憶體區域,並且可以改變這個記憶體的大小。
接著看回 test_huge.c ,可以看到註解一段寫著 // madvise purge
,從 man page 可以知道他是要告知作業系統這塊記憶體不會被用到,可以進行釋放
從 huge_move_expand
這個函式中的 memory_decommit
函式中可以發現這個系統呼叫並且註明了使用 MADV_DONTNEED
接著就進行程式測試
發現 main 的回傳值竟然不是 0
,於是開始尋找哪個部份回傳 1
的,最後發現是在第 54 行的時候 return 的
可以用個簡單的實驗,將 54 行的回傳值修改成 10 ,並重新測試
可以看到確實是這個部份進行回傳的,看來 p 跟 dest 並不會使用同一個記憶體地址
從 memory_remap_fixed
這個函式看到使用了 MREMAP_MAYMOVE|MREMAP_FIXED
這樣的 flags 給作業系統,代表可能在配置記憶體的過程中作業系統會幫我們尋找可配置的位置,並且不會與其他的重疊如果有找到的話就會進行移動,但是如果失敗就會留在原本的位置。
所以我認為這邊會進行 return 應該是正確的。
alloc.c
主要想要了解這個專案對於記憶體是如何進行管理的
可以看到從測試檔案 test_small, test_large, test_huge 看到這個配置器應該是有針對三種尺寸的記憶體來進行配置的。
接著在用一個大的結構包住這三個記憶體管理器
接著這些的初始化定義會是在 malloc_init
與 malloc_init_slow
這幾個函式
malloc_init_slow
malloc_init
透過這四個初始化函式來進行初始化
再來看到 allocate_small
這個函式,其中會用到 slab_first_alloc
與 slab_allocate
以及 struct slab
首先先看到這兩個 struct
struct slot 的 data 使用 uint8_t
的陣列型態 ([]),這是因為 uint8_t 是一個佔用一個 byte 的無號整數型態,所以使用 uint8_t data[] 的方式可以讓 data 陣列的大小動態指定為所需的大小,同時也讓資料塊的對齊方式更加靈活
struct slab 中的 data 也使用了類似的方式,用 uint8_t data[] 來表示一塊大小可變的記憶體。這樣的設計可以減少記憶體碎片,因為在記憶體池中,所有的資料塊大小都是固定的,所以如果每個 slab 中的 data 都使用固定大小的陣列,就可能會產生很多無法被利用的空間
更多關於
uint8_t *data
與uint8_t data[]
的探討可以從 課堂問答筆記 week 11 中找到
memory.c
與 memory.h
跟記憶體有關操作都會定義在這邊
首先先看到
memory_init
這個函式主要是用來確認系統有沒有開啟 overcommit ,如果有開啟的話( digit == 2 ),就要將 reduce_commit_charge
這個旗標保持設定為 true。
這將影響到後面在配置記憶體的行為。
memory_decommit
可以看到根據 reduce_commit_charge
會有兩種不同的行為
true
prot
屬性也可以之後透過 mprotect
進行動態修改false
madvise
函式對指定的記憶體區域進行建議,告訴系統該區域的內容在未來可能不會被使用。使用 MADV_DONTNEED
參數,表示該區域的內容在未來可能不再需要,系統可以釋放或清除該內容。這種方式並不直接將記憶體設為無法存取,而是提供一個建議給系統,系統可以根據這個建議自行處理記憶體的釋放。這種方式適用於有可能再次使用該記憶體區域,但在目前情況下可以釋放其內容的情況,例如快取資料或臨時使用的記憶體。memory_commit
可以看到這個函式跟上面的 memory_decommit
可以互相搭配,如果 reduce_commit_charge
為 true
的話,就會在需要 commit 的時候,使用 mprotect
命令將其的權限修改為 PROT_READ|PROT_WRITE
,即為可以進行操作。
memory_map
這邊是要用 mmap 來向作業系統取得配置的空間
memory_remap_fixed
這邊使用 mremap 系統呼叫來重新映射記憶體區域的位置以及可以改變他的大小。
可以注意到它添加了 MREMAP_MAYMOVE
和 MREMAP_FIXED
這兩個參數
new_addr
參數作為新的位址。要特別注意的是如果重新映射成功,則返回 false
,否則返回 true
,表示重新映射失敗。
在 allocator 中有 mutex.h
與 mutex.c
,看似是使用 mutex ,但是如果系統有支援 atomic 操作的話,就會切換成使用 atomic ,非常有意思!
先看到 mutex.h
假如偵測到了 linux 的環境的話,就會使用 C11 標準的 stdatomic 來進行資料的保護。
可以看到它將 atomic_int
重新 typedef
成 mutex 。
接下來看到 mutex.c ,也是透過一樣的手段來實作 lock 的,由於學生本身使用的是基於 linux 的 ubuntu 22.04 環境,所以主要以 atomic 進行筆記。
sys_futex
首先 Futex 是 Fast userspace muTexes 的縮寫
所以這個函數主要就是要去呼叫 futex 這個 system call 的。
mutex_init
先將 lock 初始化為 0
, 並且回傳 false 。
mutex_try_lock
mutex_trylock
函式嘗試以非阻塞的方式執行 atomic_compare_exchange_strong_explicit
操作,將 mutex 的值從 0
更改為 1
。
根據 atomic_compare_exchange_strong_explicit
的行為,如果期望值與 mutex 目前的值相同,則該操作會成功並回傳 true;反之,操作會失敗並回傳 false。
當 atomic_compare_exchange_strong_explicit
操作成功時,mutex 的值從預期的 0
被替換為 1
,並回傳 false。然而,在 mutex_trylock
函式中,我們希望回傳 true 表示嘗試取得鎖失敗。
相反地,當 atomic_compare_exchange_strong_explicit
操作失敗時,表示 mutex 的值不是預期的 0
,該操作會回傳 true。然而,我們希望回傳 false 表示嘗試取得鎖成功。
為此,在 return 後面加上 !
操作的目的是將回傳值進行反轉。當 atomic_compare_exchange_strong_explicit
回傳 true 時,我們進行取反後得到 false,表示嘗試取得鎖成功;而當 atomic_compare_exchange_strong_explicit
回傳 false 時,我們進行取反後得到 true,表示嘗試取得鎖失敗。
因此,在 mutex_trylock
函式中,回傳值為 true 表示嘗試取得鎖失敗,回傳值為 false 表示嘗試取得鎖成功。透過使用 !
,使得函式的回傳值與常用的習慣一致。
mutex_lock
這個函式一樣用到上面的概念,先設定預期的值為 0
,接下來執行 atomic_compare_exchange_strong_explicit
,如果回傳的是 ture 的話,就代表有成功進行交換,並且回傳 true ,再經過 !
的操作之後,就會變成 false 從而避免進入 if 判斷句; 如果回傳的是 false ,表沒有進行交換,也就是說它目前是被鎖定的,因此就會進入到 if 判斷句進行等待。
再來進到 if 敘述句中,會先檢查 exceoted
的值是否等於 2
,如果不是 2
的話,就會用 atomic_exchange_explicit
來將 mutex 的值會變成 2
,再將函式回傳值配置給 excepted
。
接下來檢查 excepted
的值是不是非零,如果的話,就會進到 while-loop 中,然後使用 sys_futex 來將 mutex 的值變成 2
,然後在將函式回傳值配置給 excepted
。重複這個行為直到, excepted
變成 0
。
mutex_unlock
首先使用 atomic_fetch_sub_explicit
來對 mutex 進行減 1
的操作,接著檢查函是的回傳值是否為 1
,若是相等於 1
的話,就是代表 mutex 本來就是鎖定的狀態,所以就可以不用進到 if 敘述句中; 若是原本不是鎖定的狀態,也就是回傳值不為 1
的時候,就會通過 atomic_store_explicit
來將 mutex 設定為 0
,表示 mutex 目前為空閒的狀態。
接下來使用系統呼叫 sys_futex(m, FUTEX_WAKE_PRIVATE, 1, NULL, NULL, 0);
來將一個在等待 mutex 的執行緒喚醒,其中的參數 1
代表喚醒一個執行緒。
通過喚醒正在等待的執行緒,其他的執行緒就有機會獲取到 mutex 並進行操作。
這些雖用到 atomic 操作,但為 blocking 的同步機制 (mutex),若要降低 lock contention,需要導入 lock-free 演算法
首先可以看到 free 的定義
便可以知道 free 的所有行為都在 deallocate
中完成,因此再去看到該函式
可以知道在釋放記憶體的時候分成了三種
可以看到只有在面對小尺寸的時候,是命名為 deallocate_small ,其他的卻都有 free ,是因為在設計的時候,將小尺寸的記憶體配置器設定為 memory pool 了,所以不會進行 munmap 的動作,只會去標記為已釋放。
首先先看到 huge_free
由於 huge size 的記憶體也是用紅黑樹進行管理的,所以移除的操作就會是以下
ptr
找到 node
ptr
找到對應的 arena
arena
所以採用 maybe_lock_arena
arena
去找到 extent_tree
node
指定為搜索到的節點node
指定為要移除的節點node
加進要 free
的 list
中arnea
的 lock
chunk
再來看到 large_free
可以看到 large 與 huge 的管理策略就不太一樣了
size
的加減來獲得下一個 node
的地址node
進行合併node
進行合併self
的大小是否等於 CHUNK_SIZE - LARGE_CHUNK_HEADER
,若是的話表示整個區域都是屬於它一整塊的,就進行釋放。node
插回到 large tree
裡再來看到最為複雜的 small_size
ptr
來取得 slot
, slab
, size
, 與 bin
cache
是否為 dead
的狀態,若是的話
dead
,若是的話,就找到該 chuck
並且釋放它cache->bin
中找到要釋放的 bin
bin
所累積的大小是否已經超過的 CACHE_SIZE
,若是
bin
的大小設置成 slab->size
的大小bin
的大小 > CACHE_SIZE/2
後為止slot
指標為 flush
,並且將其設置為 NULLbin
所屬的 arena
,並且 lock
住slot
來去得到它所屬的 chunk
chunk
所屬的 arena
是否與當前處理的 arena
相同
slab_deallocate
釋放 slot
所在的空間slot
的 next
設為 flush
flush
為 NULL 之後才結束完成建置之後,就可以使用以下指令進行所有的配置器跑所有的測試
或是也可以指定的配置器跑指定的測試,並且選定要使用的執行緒
會得到以下這樣的結果輸出
再來也可以用以下命令來將結果繪製成圖表
會得到像這樣的圖表(要注意的是原本是 svg 檔,需要轉成 png 才能上傳上來)
為了讓解讀便利,只要列出 sys, rpmalloc, mimalloc, tcmalloc (依據執行時間排序,由大到小) 以及自行增添的實作。
mimalloc-bench 目前僅支援類 Unix 的系統來作使用,像是 Ubuntu 就很適合。
其中支援了很多的記憶體配置器,像是
在 README.md 中還有更多的記憶體配置器介紹
可以分成兩種
其中壓力測試包括了
這邊主要學習他的 atomic 操作
可以看到他的 atomic 參數如下
接著是把以上的變數不論是 32-bit 或是 64-bit 或是 指標 的型態帶入進函式中。
以上這三個函式都是透過指定的 32-bit, 64-bit 或是指標變數來進行 atomic_load_explicit
這個函式的。
並且使用了 memory_order_relaxed
這個來標記 memory_order 的 flag,使用這個 flag 可以保證最佳的性能與當前操作的不可分割性,但是不考慮執行緒之間的同步,其他執行緒可能讀到新值也有可能讀到舊值。
以上這三個函式都是透過指定的 32-bit, 64-bit 或是指標變數來進行 atomic_load_explicit
這個函式的。
但是不同的是有使用 memory_order_relaxed
也有 memory_order_release
這幾種不同的 flag 的。
memory_order_relaxed
memory_order_release
加減
以上四個函式都是通過了 atomic_fetch_add_explicit
這個 atomic 函式所進行的
可以看到它也是使用了 memory_order_relaxed
的這個 flag ,並且也在回傳值得時候,加上/減掉 add 的值。
這邊用到的 atomic 操作共有兩種
atomic_compare_exchange_weak_explicit
dst
與 ref
的值進行相比,如果發現 dst
與 ref
相等的話,就把 val
的值指派到 dst
,並且回傳 true
; 如果不相等的話,就把 dst
的值指派到 ref
,並且回傳 false
。atomic_exchange_explicit
dst
的值用 val
來替換,並且回傳 dst
先前的值。接著定義都是 atomic 變數的結構
在 struct span_t 這個結構中也有定義了一個 atomic 指標,用來指向 free_list_deferred
在 struct heap_t 這個結構中也有定義了一個 atomic 指標,用來管理共享的變數
在 struct global_cache_t 也有定義 atomic 變數,並且把它命名為 lock
也有兩個全域變數使用了 atomic 定義
首先先從配置記憶體的開始看
從 rpmalloc
開始看起
可以看到它會先檢查配置的 size ,再來透過 get_thread_heap()
來獲得目前的 thread heap。
接著可以看到在 rpmalloc
這個函式中的 get_thread_heap
這個函式的深層有用到一個比較特別的定義
這邊可以看到使用了一個名為 TLS 的 model,__attribute__((tls_model("initial-exec")))
代表者 _memory_thread_heap
這個變數會允許每個執行緒擁有自己的變數,而這些變數在不同執行緒之間是互不干擾的,因此,這種作法通常應用再多執行緒的環境中。
再看到 rpmalloc/CACHE.md 的這個說明
使用了 thread cache 的機制,是一種在多執行緒環境下用於記憶體配置的概念。當多個執行緒同時進記憶體配置的時候,他們可能會競爭存取共享的記憶體資源,從而導致性能下降。
因此為了避免這個問題, rpmalloc 為每個 thread 創造一個讀去的執行緒快取,可以用於儲存該執行緒所配置的記憶體 block 。這樣一來,就可以讓每個執行緒都可以在自己的執行緒快取中進行快速的配置以及釋放的操作,並且不會跟它執行緒競爭共享的資源,從而導致性能降低。
回到 rpmalloc/CACHE.md 可以看到性能的圖表顯示在不設置快取的情況, rpmalloc-nocache 使用了極低的記憶體開銷,甚至低於了 C Run time 的標準庫。
並且也可以從圖上得知,若是使用一般的 rpmalloc 或是 rpmalloc-unlimit 會可以讓 CPU 在一秒之內做出最多的操作。
再來看到 _rpmalloc_allocate
可以發現他也是將要配置的空間分成了三個尺寸
從定義可以發現
並且只要超過了 large 的上限的話,就會直接計算需要幾個 pages ,之後呼叫 mmap 系統呼叫直接配置。
然後設計預設了最常用到的會是 small size 的部份,所以將 small size 的 if 敘述句前面加上了 EXCEPTED
,來告訴編譯器這個是最常發生的,可以透過 Built-in Functions 來減少 branch 所帶來的 branch penalty。
可以看到設計者用了以下 bitwise 來確保配置的空間是 align 16 bit
接著使用了 atomic 操作來對更新統計資料,包括了目前配置的數量與總共配置的數量
再來若 heap_size_class->free_list
不為 0
的話,就從 free_list
中 pop 第一個 block 出來並且回傳給使用者用。
若是 free_list
是空的話,就會去 heap 來配置空間。
可以發現處理 medium 的方法與處理 small 的方法是大同小異的,不同的是處理 index 的方法
而 base_idx 轉換公式為
可以看到它一樣是先計算所需要的大小
接下來去取得空間的地址之後
確認是否有打開 FIRST_CLASS_HEAPS
,如果有的話,就會把它加入到 span 的 double linked list 裡面。
最後回傳這個地址,特別注意要回傳的地址會是要從 span 偏移 header_size
的位址。
這個函式也是先求出要用的 page size 之後,就用 mmap 來配置 huge 尺寸的空間
再來更新 span 的資訊,使用 atomic 操作 _rpmalloc_stat_add_peak
來確保資訊同步。
然後再把它加進管理 heap->large_huge_span
的 double linked list 中。
再來看到 rpfree
,就只有簡單的一行
接著在看到 _rpmalloc_deallocate
,
首先先用了一個 atomic 操作將 atomic 全域變數 _deallocation_counter
加 1
。
接著根據傳入的 p
,來尋找他是超大, 大, 中, 小 其中的那一個。
再來因為 span
的預設大小是 64*1024
,所以利用了 align span 的技巧搭配 _memory_span_mask
來找到這個指標是屬於哪個 span 的
接著就可以依據 span
的資訊來找到他是哪個尺寸的
首先先用一個 atomic 操作,將 span->heap
中 alloc_current 減 1 ,再把 free_total
加 1
,主要就是更新統計的資料。
如果有 align 的話,就會先將指標的地址還原到 block start 的地方。
接著如果滿足以上三個條件的話,就要用 _rpmalloc_deallocate_defer_small_or_medium
來去釋放空間,不然就是用 _rpmalloc_deallocate_direct_small_or_medium
首先檢查 span 內的記憶體空間是否是完全被用盡的,如果是的話,就會從 heap->full_span
中把該 span 移出,接著把它插入到 heap->size_class[span->size_class].partial_span
中,然後更新統計資料 --heap->full_span_count;
。
將 block
的指標轉換為 void**
型別,並將 span
的 free_list
賦值給該指標。這將把 block
加入到 span
的 free list
中。
再來減少 span->used_count
的值。
最後將 block
設置為 span
的新 free list 的開頭
在函式的最後檢查了是否有未使用的區塊,並且因為如果有未使用的區塊的話,就代表沒有其他的外部執行緒進行存取這個 span
。 將 span
的 free_list_deferred
設定為 INVALID_POINTER
,且把它從 span
的 雙向 linked list 中移除,並且加進 cache 中。
在註解中有提到,這裡的 memory order 有點棘手,為了避免出現 ABA 問題,於是需要確保 deferred free list
不會出現 list 與 list size 不同步的問題,因此需要使用到 CAS 的 atomic 操作來完成。
ABA 問題是在多執行緒計算中,ABA 問題發生在同步過程中,當一個位置被讀取兩次,兩次讀取的值相同,兩次讀取的值相同被用來斷定中間沒有發生任何事情;然而,另一個執行緒可以在兩次讀取之間執行並更改值,做其他工作,然後將值改回原樣,從而使第一個執行緒認為沒有任何變化,即使第二個執行緒確實違反了該假設。
在這段程式碼中使用了 spinlock 的概念的完成這個部份,等待 free_list
變成一個有效的指標值,而不是 INVALID_POINTER
,在等待的期間會一直在自旋。
這邊感覺是一個可以進行改進的空間,例如使用 RCU 等等的
接下來看到 rpcalloc
可以看到先做了乘法是否溢位的檢查
再來就是做了記憶體配置 rpaligned_alloc
後使用 memset(block, 0, total);
初始化為 0
。
最後看到 _rprealloc
在 _rpmalloc_reallocate
的函式中可以看到也是分成了三個部分,分別是 small & medium , large 與 更大的 size 的。
接下來可以看到這邊
確定 small / medium 記憶體 block 的位置和大小,然後檢查該記憶體 block 是否能夠容納新的大小 size。如果可以容納,則直接返回該記憶體 block 的指標,並且如果需要,將舊的數據移動到新的位置。
TODO
這邊的 flags 是什麼時候轉變成 0 / 1 的還沒有看懂
接下來的 large 與 huge size 記憶體 block 是相同的概念。
可以看到在這邊針對了很多不同的平台做了很多的區隔,但是我們只針對 x86-64 與 linux 的系統。
可以看到先使用 mmap 配置空間給 ptr ,接著檢查是否回傳的值為 MAP_FAILED,如果是的話,就再使用 mmap 重新配置空間,並且使用 madvise 來給出這段記憶體空間的建議。
並且使用到了 prctl 這個系統呼叫來為 vma 命名
最後做了一些 address offset 後就可以回傳 ptr 。
這邊是先將 address 先 offset 回來之後,先做 munmap 之後,再做 madvise 的建議,而沒有直接的去 unmap 這段記憶體空間,讓作業系統決定記憶體的處理。
可以看到它最了很多的測試,包括了 thread 或是 test_named_pages 等等的功能。
從 hankluo6 / 2021 年報告 中可以看到在 isoalloc 使用了 MADV_POPULATE
這樣的機制來降低 page fault!
先 clone 下來後進行編譯測試
可以得到這樣的輸出
一連測試了幾個方法都沒有出現 bug ,代表目前的版本應該沒什麼大問題!
可以看到這個專案的檔案結構很清楚,主要常用的應該是這幾個目錄
make library
後產生的 share object 執行檔的目錄在這邊可以看到 isoalloc 只支援 64-bit 的系統,並且也是引入了跟其他配置器類似的 arena
的實作。
在每個 block 都會使用 2 個 bits 來作為狀態的顯示
00
代表空閒的 chunk10
代表該 chunk 正在被使用01
代表曾被使用過但是現在是空閒的11
金絲雀 chunk (canary chunk)除此之外, isoalloc 還有其他的特色
THREAD_SUPPORT
的時候,由 spinlock 或是 mutex 來達成 thread-safe
的目標在 os
目錄中,有分別對應著各個平台的標頭檔。
可以看到在 android.h 中有使用 prctl
來為 VMA 設定他的 magic number,但是在 linux.h 中並沒有做這樣的 define; 相對應在 rpmalloc 有對這兩個平台做設定,不確定 iosalloc 為什麼沒有做設定
TODO: 提交 pull requestImage Not Showing Possible ReasonsLearn More →
- The image file may be corrupted
- The server hosting the image is unavailable
- The image path is incorrect
- The image format is not supported
已提交 Commit
接下來可以看到其他的 header,從檔名就可以大致知道每個檔案的用途。
特別看到 iso_alloc_ds.h
從這個 header 可以得到我們所使用的結構定義
iso_alloc_zone_t
, iso_alloc_root
或是 zone_quarantine_t
等等的結構體。
首先先特別看到 iso_alloc_util.c
這個檔案中的 mmap_pages
這個函式
可以看到這個函式針對 linux 的環境下使用到 MAP_POPULATE
這個 mmap 的參數。
先看到 madvise 的部分
從 man page 看起
posix_madvise(3)
關於 advice 的參數有以下幾種
MADV_NORMAL
MADV_RANDOM
MADV_SEQUENTIAL
MADV_WILLNEED
MADV_DONTNEED
MADV_HUGEPAGE
MADV_NOHUGEPAGE
再來是 mmap & populate。
map_populate
是 mmap()
系統呼叫的一個 flag,用於指示核心應在映射期間立即將檔案中的所有內容讀入到新建的映射區域中。這樣可以提高後續對這些內容的存取速度,因為所有資料已經存在於頁面快取中,而不必等到實際存取這些頁面時才進行檔案存取。
使用 map_populate
標誌可以顯著提高檔案映射的性能,但需要額外的記憶體空間和 I/O 運算。因此,只有在確定需要快速且頻繁地存取整個映射區域的情況下才應使用 map_populate
標誌。
map_populate
可以讓程式在第一次存取新映射的記憶體時,避免因 page fault 產生的延遲,進而提高程式的運行效率。因為當 mmap 設置 MAP_POPULATE 標誌時,核心會在進行映射時將檔案的內容直接讀取到實體頁面中,這樣就可以避免第一次存取時因 page fault 而造成的影響。map_populate
可能會導致一定程度上的性能下降。這是因為核心執行 mmap 系統呼叫時,需要在檔案系統和檔案快取之間進行多次的頁面交換,這會導致額外的 I/O 操作和 CPU 資源的消耗。此外,對於非常大的檔案或共享記憶體區域,核心可能無法在 mmap 呼叫期間完成整個映射區域的填充,這樣可能會導致一些頁面在第一次存取時仍然需要進行 page fault。接下來可以做個小實驗,把 map_populate
註解掉,然後藉由 Makefile 中的幾個測試,來觀察該 advise 對於系統的影響。
perf 結果 | system malloc | isoalloc (有 map_populate) | isoalloc (無 map_populate) |
---|---|---|---|
context-switches | 81 | 91 | 143 |
page-faults | 433,117 | 200,698 | 202,752 |
cycles | 2,736,496,520 | 3,895,062,512 | 3,940,056,221 |
instructions | 5,163,961,202 | 5,166,597,836 | 5,188,067,947 |
branches | 1,057,012,596 | 1,077,344,361 | 1,085,839,342 |
branch-misses | 2,028,648 | 7,933,186 | 8,018,593 |
time elapsed | 0.61596 | 0.87356 | 0.88456 |
可以看到有使用 map_populate
的情況下,確實有減少一些些的 page-faults ,但是相對的沒有增加太多的時間。
TODO: 針對上述實驗,提出適合使用 map_populate
的時機並落實在 rpmalloc (或自行實作的程式碼中)。
避免濫用「通過」,可改為「藉由」,否則無法區分 "pass" 一詞。
根據 map_populate
的特性,我認為以下幾個是可以使用的時機
MAP_POPULATE
可以減少因為存取映射範圍而引起的 I/O 操作。這在需要連續存取大量資料的情況下,可以提高性能並減少 I/O 成本。檢討上述「記憶體配置器的雛形及改進」的實作,針對並行環境,設計高效率的記憶體配置器,可沿用自己的程式碼或改寫 rpmalloc (採用 public domain 形式發佈),應特別考慮以下:
MADV_POPULATE
一類的機制來降低 page fault,參見 2021 年報告MAP_POPULATE
首先先針對上面的情境找出適合將 map_populate
加在 rpmalloc 的地方。
在 _rpmalloc_mmap_os
這個函式中找到設定 mmap 的 flags,
接著可以在下面,設定條件決定是否要添加 MAP_POPULATE
這個 flag 。
目前先以小於 small size 的或是大於 8 MB 大小的記憶體的情況添加這個 flag
首先為了確定上面所提到的使用情境是正確的,會在配置不同尺寸的時候決定是否加上 flag ,並且可以用多個測試來進行比對。
MAP_POPULATE
的版本 (也就是使用上面的修改) (rpmalloc-test)MAP_POPULATE
(rpmalloc -test)從上面的結果可以看到,一般的情況都是 minor page fault ,並且使用了 MAP_POPULATE
這個 flag 確實可以降低 page fault 的數量,但是因為他要做額外的操作進而導致 instructions 的數量大幅的上升,讓執行的時間也到增加到了兩倍,這同樣的也代表著並不能夠任意的使用 MAP_POPULATE
。
而在配置記憶體的時候如果用上面的設置的話,可以減少大約 2% 左右的 page-fault 。
接著想要測試冷啟動時的 page fault
可以使用 sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'
釋放 pagecache/dentries/inodes
接著再做一次測試
MAP_POPULATE
的版本 (也就是使用上面的修改) (rpmalloc-test)MAP_POPULATE
(rpmalloc -test)接著稍微改變了一下想法,將程式碼改成下面這樣
稍微放寬一些對於大尺寸進行 populate 的範圍
就可以得到以下這樣的結果
可以看到的是他的 minor page fault 數值又下降了一些,並且也沒有將執行時間變得太長,影響性能。
接著用這樣的程式碼來進行 mimalloc-bench 的測試
詳細的測試結果可以看到 rpmalloc 測試結果 中的 mimalloc-bench 測試 v1
從這三者的測試結果來看,可以發現部分使用 MAP_POPULATE
的版本在執行時間與原版本的差不多,並且在 xmalloc-testN 這項測試裡面的 page-fault 表現比原本的版本好! 而在 glibc-thread 這項的測試中,表現與原版本的差不多。
並且可以看到 rpmalloc 測試結果 中的 mimalloc-bench 測試 v2
這個部份可以到使用了 MAP_POPULATE
這個 flag 確實減少了很多的 page fualt ,並且就算是部份使用也可以降低約 33% 的 page fault 。在這項測試中,就比較難發現出全部使用 MAP_POPULATE
所帶來的副作用,也就是執行時間只有小幅度的上升,並沒有像是上面的 rpmalloc-test 所造成的那麼嚴重。
假如將 rpmalloc-test 指定以一個 CPU 核心執行的話,可以明顯的觀察到 page-fault 的下降
這個就是 lock-contention 以及不同的 thread 在執行所造成的 page-fault 。
在 mimallic-bench 也會出現相同的問題
可以看到在不同的 CPU 數量執行下,結果也會不同。
而在 rpmalloc 的測試文件 test/main.c
中可以看到
在這個函式取得了真實 CPU 中的 thread 數量,以我的電腦為例 (5600X),有六個核心, 十二個執行緒,因此在這邊 hardware_threads
就是 12
。
而在下面可以看到在不同的測試函式中,就會有設定 thread 的上下限制,但是通常是最多 16 或是 32 個執行緒,最少 2 個執行緒。
per-cpu
與 per-thread
都是兩種常見的優化技術,兩者的目的都是為了減少對共享資源的競爭,以提高效能和可擴展性
每個配置器實例僅由對應的 CPU 核使用,因此減少不同處理器核之間的競爭。這種方式適用於多核系統,其中多個處理器核可能同時進行記憶體配置和釋放操作。每個核心都擁有自己的配置器實例,可以獨立地進行操作,從而避免不必要的同步和競爭。這提高了整體的配置性能,減少鎖的使用,並改善可擴展性
每個執行緒都擁有自己的配置器實例,用於該執行緒的記憶體配置和釋放操作。這種方式適用於多執行緒程式,其中多個執行緒可能同時進行記憶體配置和釋放操作。每個執行緒都有自己的配置器實例,可以獨立地進行操作,而不會干擾其他執行緒的配置操作。這樣做可以減少鎖的使用,減少競爭,並提高整體的配置性能和可擴展性
在 rpmalloc 中的實作目前是 per-thread。
在 Performance Tuning TCMalloc 中有提到說目前 tcmalloc 的實作, per-cpu
的性能是比 per-thread
還要好的,也因此它成為預設的選項。
mstress
cache-scratch
先使用 M1 macbook (記憶體 8 G + 儲存空間 256 G) 來進行測試,由於剩餘的空間不足以分割磁碟灌雙系統,因此以下實驗使用 docker 來進行。
docker 的 image 可以在 Ubuntu:22.04 - linux; arm64 variant v8 中得到
然後我所配置給 docker 的資源為
接著就可以開始使用 docker,基本的命令可以從這邊查找 macOS macbook M1 使用 docker 虛擬 ubuntu ,為 ROS 做準備
接著使用 uname -a
與 lscpu
來檢查規格
從 htop
的資訊也可以看到記憶體與分配給他的資源一致。
接著就可以開始測試
在使用 mimalloc-bench 的時候,會遇到一些問題
cp /usr/lib/linux-tools/5.15.0-49-generic/perf /usr/bin/perf
來解決 (其中 linux kernel 版本可能不同,自行更改成自己的版本即可)#include <x86intrin.h>
,所以將其稍做修改
這樣就可以正常的執行測試了
參見: https://github.com/DLTcollab/sse2neon/blob/master/sse2neon.h#L9168
經過老師的提醒,將上述修改成以下
可以在 mimalloc-bench ( arm64 ) 結果 這邊看到他與 system allocator 與 mimalloc, tcmalloc 的相比較。
mimalloc-bench 的測試工具是使用 time ,在 linux 的環境下面的可以用 man time
來查看使用的方法,而使用 macos 的環境下可以使用 gtime --help
這個指令來查看 man page 。
而 mimalloc-bench 幫我們列出了幾個資訊
%E
: 該行程所執行的時間%M
: 該行程所持有的最大實體記憶體使用量 ( 單位 KB)%U
: 該行程在 User mode 使用 CPU 的總秒數%S
: 該行程在 Kernel mode 使用 CPU 的總秒數%F
: 該行程在執行時所產生的 major page fault%R
: 該行程在執行時所產生的 minor page fault因此,透過以上的資訊以及 mimalloc-bench ( arm64 ),我們可以觀察出各個 memory allocator 的特性。
以下是一些常見的記憶體配置器性能指標:
可以使用 valgrind 這個工具來對記憶體的操作進行分析
分析的命令以及 massif 工具這些使用方法可以從這邊尋找 valgrind + 自動測試程式
>>
操作wfe
來取代 yield
wfe
指令會比起使用 yield
好。在 perf 測試 mimalloc-bench 的結果中可以看到 malloc-large
在進行了對大尺寸的記憶體配置改進後,雙平台 (x86-64 與 arm64) 都有減少了大量的 page-fault 數量,也有提昇了約 6~ 33 % 的執行時間; 這個改進也改善了 rpmalloc 在配置超大空間時弱勢的部份。
同時也可以看到,在不增加最大 RSS (Resident Set Size) 的情況下,執行的時間縮短了不少,同時也拉近了跟其他配置器的差距。
綜合上述 使用 MAP_populate 這個章節,可以發現到適合使用 map_populate
的情境就是頻繁配置記憶體區塊的時候; 但是為了兼顧性能以及避免濫用,我設定在中小型或是極大尺寸的配置時才啟用 MAP_populate
目前打算設置三個等級的 flag ,
MAP_POPULATE
MAP_POPULATE
MAP_POPULATE
而預設會以 部份使用 MAP_POPULATE
為主
而使用者可以在前面的 define 自行決定該開啟哪種等級的
詳細程式碼 👉 commit
TODO:
實作根據配置各尺寸的頻率來自動調整是否開啟 MMAP_POPULATE
接下來可以看到 security 這個測試中, rpmalloc 有幾個測試沒有通過
從 Security benchmarks / README.md 可以發現開發者有說明到
double-free detection isn't necessarily worth it when you have types isolation.
以及 impossibly_large_malloc_xxxxx
系列沒有通過不一定是錯誤的,因為從測試原始碼看到測試的內容為
並且在 rpmalloc/README.md 中提到
All entry points assume the passed values are valid, for example passing an invalid pointer to free would most likely result in a segmentation fault. The library does not try to guard against errors!.
所以代表把無效的指標傳給 free()
是會造成出錯的,因為它假設了所有的值都是有效的!
而 rpmalloc 所限制的最大分配空間為
如果將 ENABLE_VALIDATE_ARGS
設為 1
( 預設為 0
) 即可通過這個 impossibly_large_malloc
測試
在 rpmalloc 原本的實作中並沒有針對 double free 做檢查,雖然這個是作者原本所設定的,但是想到可以引入 isoalloc 中的 bitmap 來做檢查,也就是使用記憶體對齊的特性來將標記的 bit 塞入其中,如以下這樣設定
00
free chunk10
currently in use01
was used but is now free11
canary chunk以下這個簡單的程式碼做個測試
可以看到不同記憶體配置器都有不同的反應
我認為透過這樣的方法可以用低成本的方式來檢查 double free 的問題。
參考自 2021 年報告 在存取陣列的時候,針對下一個預期會取到的元素進行 prefetch ,如下所示
在經過 perf 的結果 (Ryzen 5600x)
接著看到 ARM64 主機 (emag) 的測試
可以看到也許有提昇了一些些的性能,但是基本上算是微乎其微
因為在老師的 ARM64 主機建置 mimalloc-bench 需要 sudo 的密碼,因此,我寫了一個類似的 benchmark ,測試的內容也與 mimalloc-bench 相同 (扣除一些出現相容性問題的測試集),並且可以將結果繪製成長條圖,詳情可以參見 allocator-bench
所以以下的結果都會是使用這個 benchmark 所跑出來的結果
下圖中的圖例
最後的 my-rpmalloc 是使用 commit 64d525c 這個版本的,整合了上面所有的改進。
原本的程式碼 clone 下來進行編譯的時候會出現這個問題
後來詳細了解原因後發現是 purge_ratio
在 purge.h 定義,而 purge.h 又被 alloc.c 與 huge.c 所 include ,才導致出現變數重複的問題,因此稍微修改程式碼就可以正常編譯
修改的程式碼
這樣就可以正常編譯了
我們可以知道使用 mmap 系統呼叫的時候,所請求的尺寸會對齊 page 的大小,像是如果請求 4095 bytes 或是 30 bytes 都會返回一個 4096 bytes 的大小回來 (在我的設備上),但是我做了一個小小的實驗, 程式碼如下
主要就是配置一個 8192 bytes 的空間,接著用 int 型別填滿它,原本預期是只能填入 2048
個 int ,但是如程式碼一樣填入了 2100 個卻是可以正常執行的,不知道為何會這樣。
這個問題是源自於作業系統的 overcommit 所導致的
可以使用來取得當前的設定值,總共有三個可以的值
- 0: heuristic overcommit (this is the default)
- 1: always overcommit, never check
- 2: always check, never overcommit
而之後測試的時候都應該關閉 overcommit
所以可以輸入雖然這樣可能會導致電腦在使用的時候性能微微降低,但是可以在設計或是測試的時候更準確
在 alloc.h 中有一個結構的命名為 thread_cache
他的定義如下
雖然知道它與小尺寸的記憶體的配置有關,通過使用這個結構體可以在配置的時候加速,但是不確定為何前面要加上 thread
,也有可能是我還沒 get 到開發者的用意。
照著 README.md 的指示,使用以下命令
會在 PartitionAlloc 的部份出現這樣的問題
注意看 mimalloc-bench/blob/master/.github/workflows/all.yml 的使用方式:
也就是跳過某些記憶體配置器的實作,避免編譯錯誤。
jservImage Not Showing Possible ReasonsLearn More →
- The image file may be corrupted
- The server hosting the image is unavailable
- The image path is incorrect
- The image format is not supported
經過老師的提醒之後,在這台 x86-64 架構與 ubuntu 22.04 的電腦已經可以正常的建置環境,所使用的命令為 mimalloc-bench/blob/master/.github/workflows/all.yml 中的 ubuntu 那段
同時也測試了 M1 macbook 的環境下能否順利建置。
MacOS 版本為 Ventura 13.2.1 的環境下,使用以下命令來完成環境的建置
接下來就進入漫長的測試過程,若有錯誤會在進行更新!
可以在 build-bench-env.sh
中看到 version_rocksdb 的版本設定是 8.1.1
build-bench-env.sh
但是在執行測試的時候,腳本上面的是 7.3.1 版本
bench.sh
這樣就會造成在測試的時候找不到該測試目錄夾,而沒有測試到
這時候只要將 bench.sh
的版本也更新到 8.1.1 就可以正常運作了。
已提交 pull request / commit
在這台 arm64 的架構下進行編譯
會出現這樣的問題,而導致編譯失敗
我的解決方法是在 rpmalloc/malloc.c
的開頭添加了 reallocarray
的宣告
這樣就可以正常編譯了
在進行 rpmalloc-test 時也是一切正常。
這邊不確定是 rpmalloc 的問題還是我電腦的 xcode 的問題。
這台主機的套件較舊,以 eMag 主機為主。
在 ThunderX2 - Cavium 這個有兩個 numa 節點的電腦上,在編譯以及執行 rpmalloc-test 上有遇到兩個問題
在 test/main-override.cc 這個檔案中有以下這個巨集
在 ThunderX2 目前版本的 g++ (9.4.0)是無法被識別到的,於是為了能夠通過編譯,先將其註解。
在 How can I turn on (literally) ALL of GCC's warnings? 中有人提到
Some will not work on GCC 10.
…
-Wmismatched-new-delete (gcc 11, not in gcc 10)
因此判斷在目前 ThunderX2 的環境是無法處理這個問題的。
在執行 rpmalloc-test 的時候,會發生非預期的問題,
目前在思考是否是因為 numa 節點所導致的問題,或是 gcc/g++ 版本的問題,因為在另外一台的 ARM 主機並沒有遇到這樣的問題