[轉帖]深度探究​Linux核心透明巨型頁

济南小老虎發表於2024-03-19

==目標 ==

處理大記憶體的效能關鍵計算應用程式工作集已經執行在libhugetlbfs之上,然後依次執行 hugetlbfs。透明的巨型頁面支援是另一種使用大頁為虛擬記憶體提供大頁支援的方法, 該支援自動提升和降低頁面大小和沒有hugetlbfs的缺點。

目前它只適用於匿名記憶體對映和tmpfs/shmem。但是將來它可以擴充套件到其他檔案系統。實際上,已經支援了只讀的檔案對映。

應用程式執行更快的原因有兩個的因素。第一個因素幾乎完全無關緊要,事實並非如此,這很重要,因為它也有缺點在頁錯誤中需要更大的清除頁複製有潛在的負面影響。第一個因素是採取每個2M的虛擬區域都有一個頁面錯誤(將核心的進入/退出頻率減少512倍)。這的生命週期中,一個記憶體對映只有第一次訪問記憶體。第二個更持久,也更重要因子將會影響應用程式的執行時整個記憶體的所有後續訪問 。第二個因素有兩個元件: 1)TLB miss將執行更快(特別是使用巢狀分頁的虛擬化,但幾乎總是在沒有虛擬化的裸系統上。2)單個TLB條目將是對映更大數量的虛擬記憶體,從而減少 TLB miss次數。使用虛擬化和巢狀分頁只有KVM和Linux客戶端同時支援對映更大的TLB正在使用大頁面,但顯著的速度已經發生了,如果其中一個使用大頁面只是因為TLB miss會跑得更快。

== 設計 ==

  • “優雅回退”:記憶體元件沒有透明的巨型頁面 知識可以回退到將巨型的PMD對映分解成ptes表, 如果有必要,分裂一個透明的大頁面。因此這些元件 可以繼續在常規頁面或常規pte對映上工作。
  • 如果由於記憶體碎片而導致大頁面分配失敗, 常規頁面應該優雅地分配和混合在 相同的vma中,沒有任何故障或重大延遲,沒有使用者感知。
  • 如果某個任務退出了,並且出現了更多可用的大頁面(要麼立即在buddy中或者透過VM), 由常規頁面支援的guest實體記憶體應該重新自動的安放在大頁面上(透過khugepaged執行緒)。
  • 它不需要記憶體預留,並且儘可能地使用大頁(這裡唯一可能的預留是kernelcore=, 以避免不可移動的頁面碎片化所有記憶體,但這樣的調整不是針對透明大頁支援的, 它是通用的適用於核心中所有動態高階分配的特性)

透明大頁支援最大限度地利用空閒記憶體,如果與hugetlbfs的保留方法相比,允許所有 未使用的記憶體用作快取或其他可移動(甚至不可移動的物件)。它不需要預留來防止從使用者空間發現大頁面分配失敗。它允許分頁 和所有其他高階vm功能在大頁上。應用程式不需要修改就可以利用它。

然而,應用程式可以進一步最佳化以利用這個功能,就像他們之前最佳化過避免每個malloc(4k)都需要大量的mmap系統呼叫。最佳化使用者空間到目前為止不是強制性的,khugepaged已經可以照顧長生命週期的頁面分配, 即使對於處理大量記憶體的不知道大頁的應用程式也是如此。

在某些情況下,當啟用大頁面時,系統範圍內,應用程式可能最終會分配更多的記憶體資源。一個應用程式可以對映一個 大的區域,但只觸及其中1位元組,在這種情況下,一個2M的頁面可能被分配而不是分配一個4k頁面是沒有好處的。這就是為什麼 可以在系統範圍內禁用大頁面,並且只在內部使用它們MADV_HUGEPAGE的madvise的區域。

嵌入式系統應該只在madvise區域內啟用大頁面為了消除浪費寶貴記憶體位元組的風險,並且只會跑得更快。

應用程式可以從大頁中獲得很多好處,而不可以冒著丟失記憶體的風險使用大頁,應該使用 madvise(MADV_HUGEPAGE)在他們關鍵對映區域。

== sysfs ==

透明大頁支援匿名記憶體能被完全的禁用(主要是為了除錯)或僅在MADV_HUGEPAGE區域內啟用 (避免佔用更多記憶體資源的風險)或者系統範圍內啟用。這可以透過以下方式實現:

echo never >/sys/kernel/mm/transparent_hugepage/enabled
echo always >/sys/kernel/mm/transparent_hugepage/enabled 
echo madvise >/sys/kernel/mm/transparent_hugepage/enabled 

還可以限制VM中的碎片整理工作,以生成匿名的巨型頁面,以防它們不能立即自由地使用madvise區域, 或者永遠不要嘗試對記憶體進行碎片整理,而只是回退到常規頁面,除非巨型頁面立即可用。顯然,如果我們花費CPU時間對記憶體進行碎片整理,那麼我們將期望獲得更多的好處, 因為我們稍後使用了大頁面而不是普通頁面。這不是總能保證的,更可能的情況是分配給一個 MADV_HUGEPAGE區域。

echo always >/sys/kernel/mm/transparent_hugepage/defrag 
echo defer >/sys/kernel/mm/transparent_hugepage/defrag 
echo defer+madvise >/sys/kernel/mm/transparent_hugepage/defrag 
echo madvise >/sys/kernel/mm/transparent_hugepage/defrag
echo never >/sys/kernel/mm/transparent_hugepage/defrag

“always”意味著請求THP的應用程式將在分配失敗時暫停,並直接回收頁面和規整記憶體, 以便立即分配THP。對於那些從THP使用中受益頗多並願意延遲虛擬機器開始使用它們的虛擬機器來說,這可能是可取的。

“defer”意味著應用程式將在後臺喚醒kswapd來回收頁面, 並喚醒kcompactd來規整記憶體,以便在不久的將來THP可用。khugepage負責隨後安裝THP頁面。

"defer+madvise"只對已經使用madvise(MADV_HUGEPAGE)的區域,後臺喚醒kswapd以回收頁面,並喚醒kcompactd以規整記憶體,以便THP在不久的將來可用。

"madvise"將進入直接回收,像"always",但只對madvise(MADV_HUGEPAGE)的區域。這是預設行為。

“never”應該是不言自明的,它不採取任何措施。

預設情況下,核心嘗試在讀取頁面錯誤時使用巨型零頁來進行匿名對映。可以透過寫入0來禁用巨型0頁,也可以透過寫入1來啟用巨型0頁:

echo 0 >/sys/kernel/mm/transparent_hugepage/use_zero_page 
echo 1 >/sys/kernel/mm/transparent_hugepage/use_zero_page

一些使用者空間(比如一個測試程式,或者一個最佳化的記憶體分配庫)可能想知道一個透明大頁的大小(以位元組為單位):

cat /sys/kernel/mm/transparent_hugepage/hpage_pmd_size

當transparent_hugepage/enabled設定為“always”或“madvise”時,khugepaged將自動啟動,如果設定為“never”,它將自動關閉。

khugepaged的執行頻率通常較低,因此,雖然人們可能不希望在缺頁異常期間同步呼叫碎片整理演算法, 但至少在khugepaged中呼叫碎片整理是值得的。但是,也可以透過寫0來禁用khugepaged中的碎片整理, 或者透過寫1來啟用khugepaged中的碎片整理:

echo 0 >/sys/kernel/mm/transparent_hugepage/khugepaged/defrag 
echo 1 >/sys/kernel/mm/transparent_hugepage/khugepaged/defrag

你也可以控制khugepaged每次透過時應該掃描多少頁面:

/sys/kernel/mm/transparent_hugepage/khugepaged/pages_to_scan

以及每次透過之間在khugepaged中等待毫秒數(你可以設定為0來執行khugepaged,在一個核的100%利用率):

/sys/kernel/mm/transparent_hugepage/khugepaged/scan_sleep_millisecs

以及在khugepage中等待多少毫秒,如果有一個巨大的頁面分配失敗,以阻止下一次分配嘗試。

/sys/kernel/mm/transparent_hugepage/khugepaged/alloc_sleep_millisecs

khugepaged的進度可以從坍縮的頁面數中看到:

/sys/kernel/mm/transparent_hugepage/khugepaged/pages_collapsed

每次透過:

/sys/kernel/mm/transparent_hugepage/khugepaged/full_scans

max_ptes_none指定有多少額外的小頁面(即尚未對映的)可以在踏縮一組小頁到大頁中被分配(查詢到相應的頁表項為空)。

/sys/kernel/mm/transparent_hugepage/khugepaged/max_ptes_none

較高的值會導致程式使用額外的記憶體。數值越低,獲得的thp效能越低。max_ptes_none值只會浪費很少的cpu時間,你可以忽略它。

max_ptes_swap指定當將一組頁面坍縮(collapse)成一個透明的大頁面時,可以從交換區換入多少頁面(查詢到相應的頁表項為換出頁識別符號)。。

/sys/kernel/mm/transparent_hugepage/khugepaged/max_ptes_swap

較高的值會導致過多的交換IO並浪費記憶體。較低的值可以防止thp被坍縮, 從而導致更少的頁面坍縮排thp,記憶體訪問效能較低。

== 啟動引數 ==

你可以更改透明大頁sysfs啟動時的預設值,透過傳遞引數"transparent_hugepage=always" 或 "transparent_hugepage=madvise" 或 "transparent_hugepage=never"到核心命令列。

== tmpfs/shmem 中的大頁面 ==

您可以使用掛載選項控制tmpfs中的大頁分配策略"huge="。它可以有以下值:

  • "always": 每次需要新頁面時,嘗試分配大頁面;
  • "never": 不要分配大頁面;
  • "within_size": 只有它將完全在i_size內時才分配大頁。也尊重fadvise()/madvise()提示;
  • "advise": 只有在fadvise()/madvise()請求時才分配大頁面;

預設策略為“never”。

“mount -o remount,huge= /mountpoint”在掛載後工作良好:重新掛載huge=never根本不會分解大頁面, 只是停止更多的分配。

還有一個sysfs介面可以控制內部shmem掛載的大頁分配策略:

/sys/kernel/mm/transparent_hugepage/shmem_enabled

掛載用於SysV SHM, memfds,共享匿名對映(/dev/zero或MAP_ANONYMOUS) GPU驅動的DRM物件,Ashmem。

除了上面列出的策略之外,shmem_enabled還允許另外兩個值:

  • "deny": 用於在緊急情況下使用,以強制關閉所有掛載的大頁選項;
  • "force": 為所有人強制提供大頁的選項——這對測試非常有用;
    ==需要重新啟動應用程式==

transparent_hugepage/enabled值和tmpfs掛載選項隻影響未來的行為。因此,為了使它們有效,您需要重新啟動任何可能使用大頁面的應用程式。這也適用於在khugepaged中註冊的區域。

== 監控使用情況==

當前使用的匿名透明大頁面的數量系統可以透過讀取/proc/meminfo中的AnonHugePages欄位來訪問。為了識別哪些應用程式正在使用匿名透明的大頁面,讀取/proc/PID/smaps並統計為每個對映的AnonHugePages欄位是必要的。

對映到使用者空間的檔案透明大頁面數量可用透過讀取/proc/meminfo中的ShmemPmdMapped和ShmemHugePages欄位。為了確定哪些應用程式正在對映檔案透明的巨大頁面,它讀取/proc/PID/smaps並統計為每個對映FileHugeMapped欄位是必要的。

注意,讀取smaps檔案時昂貴的,且經常會產生開銷。

在/proc/vmstat中有許多計數器可以用於監視系統提供大頁面的成功程度。

thp_fault_alloc : 每當處理缺頁異常時,一個大頁面被成功分配,thp_fault_alloc就會增加。這適用於第一次出現缺頁異常和COW錯誤。

thp_collapse_alloc:當它發現一個範圍的頁面坍縮成一個大頁,並有成功分配一個新的巨大頁來儲存資料,thp_collapse_alloc會被khugepaged增加。

thp_fault_fallback: 如果缺頁異常失敗的分配一個大頁,則thp_fault_fallback被增加,而回退使用小頁面。

thp_collapse_alloc_failed: 當它發現一個範圍的頁面應該被坍縮成一個大頁, 但是分配大頁失敗,thp_collapse_alloc_failed會被khugepaged增加。

thp_file_alloc: 在檔案大頁成功分配時遞增。

thp_file_mapped: 每對映到一個檔案大頁到使用者地址空間,thp_file_mapped就增加一次。

thp_split_page:在每次將一個巨大的頁面分裂為普通頁時遞增。發生這種情況的原因有很多,但都很常見原因是一個巨大的頁面是舊的,正在被回收。這個操作意味著分裂頁面對映的所有PMD。

thp_split_page_failed:如果核心無法分裂大頁,則增加thp_split_page_failed計數。如果頁面被人pin住了,就會發生這種情況。

thp_deferred_split_page:當大頁被放到分裂佇列時,thp_deferred_split_page計數被增加。當一個巨大的頁面部分被unmap且分裂它將釋放一些記憶體就會發生這種情況。分裂佇列上的頁將在記憶體壓力下分裂。

thp_split_pmd: 每當pmd分裂成pte表時,thp_split_pmd就會遞增。例如,當應用程式呼叫mprotect()或unmap()在大頁面的一部分。它不會分割大頁面,只是頁表條目。

thp_zero_page_alloc: thp_zero_page_alloc在每出現一個巨型零頁被成功地分配時遞增。它包括分配,放棄了與其他分配的競爭。注意,這不算每次巨型零頁的對映,只有它的分配。

thp_zero_page_alloc_failed: 如果核心分配巨型零頁失敗並回退到使用小頁,則thp_zero_page_alloc_failed會增加。

隨著系統老化,分配大頁的開銷可能會很大,因為系統會使用記憶體規整在記憶體周圍來複制資料, 以釋放大頁供使用。在/proc/vmstat中有一些計數器可以幫助監視這種開銷。

compact_stall: 每當程序停滯去允許記憶體規整時,compact_stall就會增加,以便一個巨大的頁面被釋放供使用。

compact_success: 如果系統規整記憶體和釋放一個大頁面供使用,則compact_success會增加(成功規整的次數)。

compact_fail: 如果系統試圖規整記憶體但是失敗了,則compact_fail會增加(失敗規整的次數)。

compact_pages_moved: 每次移動頁面時,compact_pages_moved會增加。如果 這個值是迅速增加的,說明該系統就是複製大量的資料來滿足大頁面分配。複製的成本可能超過任何減少TLB misse的節省。

compact_pagemigrate_failed: 在底層機制遞增移動頁面失敗,compact_pagemigrate_failed會增加(規整時,遷移頁面失敗次數) 。

compact_blocks_moved: 每次記憶體規整檢查時一個大頁面對齊的頁面範圍,compact_blocks_moved會增加。

可以使用函式跟蹤器來記錄在__alloc_pages_nodemask中花費了多長時間, 並使用mm_page_alloc跟蹤點來確定哪些分配用於巨大的頁面。

== get_user_pages and follow_page ==

get_user_pages和follow_page如果在一個巨型的頁面上執行,將返回往常一樣的頭頁或尾頁(就像他們在hugetlbfs上做的一樣)。大多數gup使用者只關心實際的物理屬性頁的地址和它的臨時固定在I/O之後釋放是完整的,所以他們不會注意到頁面是巨型的。但 如果有任何驅動程式會在尾部的頁面結構上損壞 page(用於檢查page->mapping或其他相關的位對於頭頁而不是尾頁),應該更新為跳轉改為檢查頭頁。在任何頭/尾頁上引用都可以防止頁面被任何人分裂。

注意:這些不是GUP API的新約束,它們與hugetlbfs上的約束相同, 所以任何能夠在hugetlbfs上處理GUP的驅動程式也可以很好地處理透明的大頁面支援對映。

如果您不能處理由follow_page返回的複合頁面,那麼可以將FOLL_SPLIT位指定為follow_page的引數, 這樣它將在返回大頁面之前分裂它們。例如,遷移將FOLL_SPLIT作為引數傳遞給follow_page,因為它不知道巨型頁面, 事實上它根本不能在hugetlbfs上工作(但由於FOLL_SPLIT,它在透明的巨型頁面上工作得很好)。遷移根本無法處理返回的大頁面(因為它不僅檢查頁面的PFN並在複製期間pin住它,而且帶有常規的pte/pmd對映)。

==最佳化應用程式==

為了保證核心將立即在任何記憶體區域對映2M頁,mmap區域必須自然對齊。posix_memalign()可以提供這種保證。

== Hugetlbfs ==

您可以在核心中使用hugetlbfs,並且始終很好地啟用了透明的超大頁支援。hugetlbfs中除了整體碎片更少之外,沒有什麼不同。所有屬於hugetlbfs的常見特性都被保留且不受影響。libhugetlbfs也會像往常一樣正常工作。

==優雅回退==

程式碼遍歷頁表但不能感知巨型的pmds,可以簡單地呼叫split_huge_pmd(vma, pmd, addr),其中pmd是pmd_offset返回的那個。透過查詢“pmd_offset”並在pmd_offset返回pmd後丟失的地方新增split_huge_pmd,使程式碼透明地感知大頁是很簡單的。多虧了優雅的回退設計,只需一行程式碼的更改,就可以避免編寫數百行(如果不是數千行的話)的複雜程式碼,從而使程式碼具有超大頁面的感知能力。

如果您沒有遍歷頁表,但是遇到了一個物理的大頁,但是您不能在程式碼中原生地處理它, 您可以透過呼叫split_huge_page(page)來分裂它。這就是Linux VM在嘗試切換大頁面之前所做的。如果頁面被pin住, 那麼split_huge_page()可能會失敗,您必須正確處理這個問題。

讓mremap.c透明感知hugepage的例子,只需要一行程式碼的改變:

diff --git a/mm/mremap.c b/mm/mremap.c
--- a/mm/mremap.c
+++ b/mm/mremap.c
@@ -41,6 +41,7 @@ static pmd_t *get_old_pmd(struct mm_stru
    return NULL;

  pmd = pmd_offset(pud, addr);
+  split_huge_pmd(vma, pmd, addr);
  if (pmd_none_or_clear_bad(pmd))
    return NULL;

== 鎖定大頁面感知程式碼 ==

我們希望儘可能多的程式碼能夠感知大頁,因為呼叫 split_huge_page()或split_huge_pmd()是有代價的。

要使頁表遍歷感知巨型pmd,您所需要做的就是呼叫pmd_trans_huge()在由pmd_offset返回的PMD上。你必須持有mmap_sem處於讀(或寫)模式,以確保不能出現巨型PMD由khugepaged建立 (khugepaged坍縮巨型頁collapse_huge_page 除anon_vma鎖外,還以寫模式持有mmap_sem)。如果pmd_trans_huge返回false,您只需返回到舊程式碼路徑。如果pmd_trans_huge返回true,則必須持有頁表鎖(pmd_lock()),然後重新執行pmd_trans_huge。持有頁表鎖將防止巨型的PMD被轉換成一個常規的PMD(split_huge_pmd可以與頁表遍歷並行)。如果第二個pmd_trans_huge返回false,則應該釋放頁表鎖並回退到之前的舊程式碼中。否則,您可以繼續處理巨型的pmd和hugepage本身。一旦完成,您可以釋放頁表鎖。

== 引用計數和透明大頁 ==

THP上的引用計數和其他複合頁的引用計數基本一致:

  • get_page()/put_page() and GUP 在首頁的->_refcount中操作。
  • 尾頁的->_refcoun總是0:get_page_unless_zero()從來不會在尾頁上成功。
  • map/unmap具有帶有PTE條目的頁面,增加/減小複合頁相關子頁上的->_mapcount。
  • map/unmap 整個複合頁的被記賬在compound_mapcount(儲存在第一個尾頁)。對於檔案巨型頁,我們也增加所有子頁面的->_mapcount, 以便無競爭檢測子頁面的最後一次unmap。

PageDoubleMap()表示頁面可能對映了pte。

對於匿名頁面,PageDoubleMap()還表示->_mapcount在所有子頁面中被抵消了一個。此附加引用是必需的,當子頁面同時被對映到PMDs和 PTEs時,獲得對其子頁面unmap的無競爭檢測。

這是降低每個子頁面的mapcount跟蹤開銷所需的最佳化。另一種方法是在整個複合頁面的每個map/unmap上的所有子頁面中新增 ->_mapcount。

對於匿名頁面,當頁面的PMD被分裂時,但仍有PMD對映,我們設定PG_double_map 。額外的引用去掉最後一個compound_mapcount。

檔案頁面在帶有PTE和的頁面的第一個對映上設定PG_double_map ,當頁面從頁面快取中被驅逐時,該頁面就會消失。

split_huge_page內部必須在從頭頁到尾頁分配refcount,然後清除頁面結構中所有的PG_head/尾位。它可以很容易地實現頁表條目的引用計數。但我們沒有足夠的資訊來分發額外的pins(即get_user_pages)。split_huge_page()請求去分裂pin住的大頁面是失敗的: 它期望頁面計數等於所有子頁面的mapcount之和加上1 (split_huge_page呼叫者必須有頭頁引用)。

split_huge_page使用遷移條目來穩定匿名頁面的page->_refcount和page->_mapcount。檔案頁面被取消對映。

我們和實體記憶體掃描器(頁面回收的掃描器)競爭也是安全的:掃描器來獲取對頁面的引用唯一合法的方式是get_page_unless_zero()。

在atomic_add()之前,所有尾頁的->_refcount都為0。這可以防止掃描器獲取到尾頁的引用。在atomic_add()之後,我們不關心->_refcount值。我們已經從頭頁上知道有多少引用是取消記賬的。

對於頭頁,get_page_unless_zero()會成功,我們不介意。它是明確拆分後引用應該去哪裡:它將停留在首頁。

注意split_huge_pmd()對refcount沒有任何限制: PMD可以在任何點被拆分並且永不失敗。

== 部分 unmap and deferred_split_huge_page() ==

解除THP部分對映(使用munmap()或其他方式)不會立即釋放記憶體。相反,我們在page_remove_rmap()中檢測到THP的一個子頁面沒有被使用 ,並在記憶體壓力時,將THP排隊以進行拆分。分裂將釋放未使用的子頁面。

由於將上下文鎖住在我們可以檢測到部分unmap的地方,所以不能立即拆分頁面。這也可能會適得其反,因為在許多情況下,如果THP跨越VMA邊界,在exit(2)期間會發生部分unmap。

用於對頁面進行排隊以進行拆分。當我們透過shrinker收縮器介面獲得記憶體壓力時,分裂本身就會發生。

參考文獻:https://mp.weixin.qq.com/s/vrSHeFJL3yAbz5sVBlEyLA (版權歸原作者所有)

相關文章