mmap 性能分析與優(yōu)化

最近項目中需要實現(xiàn)一個進程間共享的動態(tài)增長隊列(單寫多讀),采用的是文件 mmap 的方案,有這么幾點考慮:

  • 進程間可以共享 mmap 文件映射的內(nèi)存頁,省去額外的內(nèi)核態(tài)到用戶態(tài)的內(nèi)存拷貝及可能的 IO 開銷,且修改能“實時”被看到(頁表已經(jīng)建好時)
  • mmap 使用方便,就跟訪問本地內(nèi)存一樣;而普通文件 read/write 處理動態(tài)增長的數(shù)據(jù)很麻煩
  • mmap 的數(shù)據(jù)能被 kernel 在后臺自動同步回文件,解決了持久化的問題
  • 底層文件可以放在 tmpfs 上,這樣性能就與正常的共享內(nèi)存無異了(事實上 Linux 的 POSIX shared memory 就是這么實現(xiàn)的)

看起來不錯,便捷、高效。魚掌可以兼得,就是這么任性——直到上線后發(fā)現(xiàn)系統(tǒng)指標異常,一路懷疑到這里可能有性能問題,才知道事實并不盡然。

初步調(diào)優(yōu)

我們測量了每個數(shù)據(jù)從入隊列到出隊列的時間差,數(shù)據(jù)分布見下表的第一行數(shù)據(jù)(Disk),其中大于 1ms 的毛刺點竟達到 3.8‰,均值和 90% 分位點也比預期的要高。初步猜測有兩個因素可能影響比較大:

  • 底層存儲在磁盤上,發(fā)生 major pagefault 時 IO 開銷比較大
  • 運行環(huán)境系統(tǒng)比較繁忙,CPU 資源緊張
+-----------+-------+-----------+---------+----------+---------+
| configure | store | bind_core | >1ms(‰) | mean(us) | 90%(us) |
+-----------+-------+-----------+---------+----------+---------+
| Disk      | disk  | N         |     3.8 |       18 |       9 |
| Bind-d    | disk  | Y         |     1.6 |       19 |      12 |
| Tmpfs     | mem   | N         |     2.2 |       16 |       9 |
| Bind-m    | mem   | Y         |     0.5 |        9 |       7 |
+-----------+-------+-----------+---------+----------+---------+

隨后我們分別嘗試了用 tmpfs 替代磁盤,綁定進程運行的 CPU 核等不同配置組合。從上表可以看到綁核對毛刺影響較大,再加上用 tmpfs 存儲,可以大幅度優(yōu)化延遲。

其實我們創(chuàng)建 mmap 時,已經(jīng)用了 MADV_SEQUENTIAL 來提示 kernel 我們是順序訪問,事實上也是如此。但如果這樣有效的話,換成 tmpfs 并不能帶來多大的加速,這和我們預期不符。所以有必要進一步搞清楚 mmap 的使用方式。

細化分析

我們先來梳理下 mmap 的機制。mmap 分兩種,匿名的和有文件映射的,我們只討論第二種。mmap 的語義是將指定文件區(qū)間映射到當前進程的虛擬地址空間,調(diào)用返回空間起始指針,后續(xù)對這段空間的內(nèi)存讀寫就相當于對底層文件內(nèi)容的讀寫。默認情況下,mmap 調(diào)用時并不會幫你把整個文件都映射進內(nèi)存,而是按需分配頁表:因為你的文件可能很大以至于超過可用內(nèi)存大?。灰部赡苣阒恍枰S機訪問其中一小部分,沒必要都映射進來。
那么當你訪問到一個尚未分配頁表的虛擬地址,CPU 就會觸發(fā)一次 page fault,當前進程進入相應的內(nèi)核 page fault handler。在這里,內(nèi)核需要

  1. 在文件系統(tǒng)中分配該文件區(qū)域?qū)?block(如果還未分配的話)
  2. 分配一個空閑物理內(nèi)存頁
  3. 讀取該段文件內(nèi)容到對應物理內(nèi)存頁
  4. 更新內(nèi)存頁表,以建立物理內(nèi)存頁到虛擬內(nèi)存頁的映射

當然如果只是 minor page fault(比如訪問的共享數(shù)據(jù)已經(jīng)被其他進程加載進內(nèi)存),就只需要第 4 步操作。除此外,大范圍的 mmap 還有個問題就是會給 TLB 帶來很大負擔,影響到整個系統(tǒng)的性能。

從使用者的角度,mmap 的這幾項開銷的優(yōu)化思路主要就是預處理了,比如預先分配文件內(nèi)容(Prealloc),預先觸發(fā) page fault(Prefault),讓操作系統(tǒng)協(xié)助預?。≒refetch)。實現(xiàn)預處理的手段也有好多種:

Prealloc

  • 調(diào)用 fallocate 預先分配文件空間

Prefetch

  • 調(diào)用 madvise 設置 MADV_SEQUENTIAL 策略(Seq)
  • 調(diào)用 madvise 設置 MADV_WILLNEED 策略(Need)

Prefault

  • mmap 調(diào)用中設置 MAP_POPULATE 標志位(Prefault)
  • 自行預先訪問 mmap 出來的內(nèi)存區(qū)間(ManualPrefault)

Prealloc 節(jié)省了步驟 1 的開銷,Prefault 節(jié)省了步驟 1-4 的開銷,Prefetch 最理想的情況下和 Prefault 效果一致。為此我們設計了一組對照實驗,測量每一組配置下用 128 Bytes 的數(shù)據(jù)塊去寫入 256MB mmap 區(qū)間所需的時間,以模擬我們真實的使用模式。底層的存儲都使用 tmpfs,我們還分別測量了 tmpfs 使用的內(nèi)存與測試進程在不同/相同 NUMA node 的情況。

+-------------------------------+-----------+-----------+
|             name              | same_node | diff_node |
+-------------------------------+-----------+-----------+
| BM_MMap11ManualPrefault       |    48.127 |    63.045 |
| BM_MMap10ManualPrefaultNeed   |    48.294 |    62.709 |
| BM_MMap06PreallocPrefault     |    48.316 |    62.539 |
| BM_MMap03Prefault             |    48.421 |    62.744 |
| BM_MMap07PreallocPrefaultNeed |    48.527 |    62.970 |
| BM_MMap04PrefaultNeed         |    48.543 |    63.133 |
| BM_MMap05PrefaultSeq          |    48.570 |    62.954 |
| BM_MMap02Prealloc             |   106.558 |   152.471 |
| BM_MMap08PrefetchNeed         |   127.054 |   174.662 |
| BM_MMap09PrefetchSeq          |   128.844 |   173.948 |
| BM_MMap01                     |   129.806 |   174.084 |
+-------------------------------+-----------+-----------+

我們可以看到,Prefech 的行為沒有明顯的效果,主要是因為操作系統(tǒng)需要一定時間去做預取,并且這個行為我們是不可控的,所以也沒有特別安排不同等待時間的實驗。Prealloc 能帶來 20% 不到的提升,這也是步驟 1 的開銷。其他各種 Prefault 的組合效果差不多,大約 60% 出頭,這基本就是 page fault 的所有開銷了。ManualPrefault 效果更好應該是因為它同時也做了 cache prefetch。

感興趣的同學可以跑下我的實驗代碼,比較不同環(huán)境的測試結(jié)果。

用戶態(tài)動態(tài)預處理

從實驗結(jié)果我們可以看到,Prefault 能極大地減少 mmap 的開銷,但它的代價也是很大的——需要把整個文件都事先加載進來。對于我們這個動態(tài)增長隊列的用法就更糟糕了,相當于我們必須加載進可能的最大隊列長度,而實際上大多數(shù)隊列只使用了一小部分空間,這就造成極大的浪費。

比較折衷的辦法是把不可控的 kernel pretch 搬到用戶態(tài)來:預先分配一小部分空間,在運行的過程中根據(jù)使用情況預先處理。每次預處理長度的算法可以根據(jù)實際應用具體調(diào)整,豐儉由人。由于 page fault 是在 kernel 態(tài)完成的,天然就是線程安全,所以多線程的預取實現(xiàn)起來很方便,也不會影響主線程的正常訪問。

常見錯誤

在設計實驗前,參考了些其他相關的測試代碼,發(fā)現(xiàn)不少 madvise 的錯誤用法,一般有這么兩種錯誤類型:

  1. 用 | 連接兩個不同的策略。事實上,madvise 的策略枚舉值是互斥的,不是比特標志位所以不能用 | 連用。這是一部分枚舉值的定義:
# define MADV_NORMAL 0 /* No further special treatment. */
# define MADV_RANDOM 1 /* Expect random page references. */
# define MADV_SEQUENTIAL 2 /* Expect sequential page references. */
# define MADV_WILLNEED 3 /* Will need these pages. */
# define MADV_DONTNEED 4 /* Don't need these pages. */
  1. 連續(xù)調(diào)用多次 madvise 以實現(xiàn)多種策略混搭的效果。其實只有最后一條 madvise 語句生效。Linux glibc madvise 實現(xiàn)中,madvise 直接調(diào)用 syscall,并透傳參數(shù)。 而在 Linux madvise syscall 代碼中也可以看出每次調(diào)用都會清空并覆蓋之前策略的設置。

另外一個是 man mmap 里對 MAP_POPULATE 的解釋有歧義:

MAP_POPULATE is supported for private mappings only since Linux 2.6.23.

它其實想說的是,從 Linux 2.6.23 版本以后才開始支持私有映射,共享映射一直都是支持的。但乍一看很容易理解成從 Linux 2.6.23 版本以后只支持私有映射。吐槽的人不只我一個哦。

大頁支持

傳統(tǒng)頁表大小只有 4KB,其實現(xiàn)代處理器架構(gòu)可以處理更大的頁表,從而減少訪問同樣內(nèi)存大小所需的 page fault 數(shù)量和 TLB 壓力。Linux 有兩種大頁支持,hugetlbpagetransparent hugepage。hugetlbpage 需要創(chuàng)建一個 hugetlb 文件系統(tǒng),但是只能讀不能寫,不符合我們的需求;transparent hugepage 靈活一些,可以在 mount tmpfs 時指定 huge 參數(shù),但我們現(xiàn)在生產(chǎn)環(huán)境版本還沒有這項功能。

參考

  1. mmap-vs-reading-blocks
  2. kernel mail list
最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容