英語

Hugetlbfs 預留

概述

HugeTLB Pages 中描述的巨型頁面通常被預先分配供應用程式使用。如果 VMA 指示要使用巨型頁面,則這些巨型頁面在發生頁錯誤時在任務的地址空間中例項化。如果在發生頁錯誤時不存在巨型頁面,則任務會收到 SIGBUS 訊號,並且通常會悲慘地死亡。在新增巨型頁面支援後不久,人們認為最好在 mmap() 時檢測巨型頁面是否短缺。這個想法是,如果沒有足夠的巨型頁面來覆蓋對映,mmap() 將會失敗。這首先是透過在 mmap() 時對程式碼進行簡單檢查來確定是否有足夠的可用巨型頁面來覆蓋對映來實現的。與核心中的大多數事情一樣,程式碼隨著時間的推移而不斷發展。然而,基本思想是在 mmap() 時“預留”巨型頁面,以確保巨型頁面可用於該對映中的頁錯誤。以下描述嘗試描述巨型頁面預留處理在 v4.10 核心中是如何完成的。

讀者

此描述主要針對修改 hugetlbfs 程式碼的核心開發人員。

資料結構

resv_huge_pages

這是全域性的(每個 hstate)已預留的巨型頁面計數。預留的巨型頁面僅可用於預留它們的任務。因此,通常可用的巨型頁面數計算為 (free_huge_pages - resv_huge_pages)。

預留對映

預留對映由結構體描述

struct resv_map {
        struct kref refs;
        spinlock_t lock;
        struct list_head regions;
        long adds_in_progress;
        struct list_head region_cache;
        long region_cache_count;
};

系統中每個巨型頁面對映都有一個預留對映。resv_map 中的 regions 列表描述了對映中的區域。一個區域描述為

struct file_region {
        struct list_head link;
        long from;
        long to;
};

檔案區域結構的“from”和“to”欄位是對映中的巨型頁面索引。根據對映的型別,reserv_map 中的區域可能指示該範圍存在預留,或者不存在預留。

MAP_PRIVATE 預留的標誌

這些標誌儲存在預留對映指標的最低有效位中。

#define HPAGE_RESV_OWNER    (1UL << 0)

指示此任務是與對映關聯的預留的所有者。

#define HPAGE_RESV_UNMAPPED (1UL << 1)

指示最初對映此範圍(並建立預留)的任務由於 COW 失敗而取消了此任務(子任務)的頁面對映。

頁面標誌

PagePrivate 頁面標誌用於指示在釋放巨型頁面時必須恢復巨型頁面預留。更多詳細資訊將在“釋放巨型頁面”部分中討論。

預留對映位置(私有或共享)

巨型頁面對映或段可以是私有的或共享的。如果是私有的,則它通常僅對單個地址空間(任務)可用。如果是共享的,則它可以對映到多個地址空間(任務)中。這兩種型別的對映的預留對映的位置和語義差異很大。位置差異是

  • 對於私有對映,預留對映懸掛在 VMA 結構上。具體來說,vma->vm_private_data。此預留對映在建立對映(mmap(MAP_PRIVATE))時建立。

  • 對於共享對映,預留對映懸掛在 inode 上。具體來說,inode->i_mapping->private_data。由於共享對映始終由 hugetlbfs 檔案系統中的檔案支援,因此 hugetlbfs 程式碼確保每個 inode 都包含一個預留對映。因此,預留對映是在建立 inode 時分配的。

建立預留

當建立由巨型頁面支援的共享記憶體段 (shmget(SHM_HUGETLB)) 或透過 mmap(MAP_HUGETLB) 建立對映時,會建立預留。這些操作會導致呼叫例程 hugetlb_reserve_pages()

int hugetlb_reserve_pages(struct inode *inode,
                          long from, long to,
                          struct vm_area_struct *vma,
                          vm_flags_t vm_flags)

hugetlb_reserve_pages() 所做的第一件事是檢查在 shmget() 或 mmap() 呼叫中是否指定了 NORESERVE 標誌。如果指定了 NORESERVE,則此例程會立即返回,因為不需要任何預留。

引數“from”和“to”是對映或基礎檔案中的巨型頁面索引。對於 shmget(),“from”始終為 0,“to”對應於段/對映的長度。對於 mmap(),可以使用 offset 引數指定基礎檔案中的偏移量。在這種情況下,“from”和“to”引數已透過此偏移量進行調整。

PRIVATE 和 SHARED 對映之間的最大區別之一是預留對映中預留的表示方式。

  • 對於共享對映,預留對映中的條目指示相應的頁面存在或曾經存在預留。當預留被消耗時,不會修改預留對映。

  • 對於私有對映,預留對映中缺少條目指示相應的頁面存在預留。當預留被消耗時,會向預留對映新增條目。因此,預留對映也可以用於確定哪些預留已被消耗。

對於私有對映,hugetlb_reserve_pages() 建立預留對映並將其懸掛在 VMA 結構上。此外,設定 HPAGE_RESV_OWNER 標誌以指示此 VMA 擁有預留。

查詢預留對映以確定當前對映/段需要多少巨型頁面預留。對於私有對映,這始終是值 (to - from)。但是,對於共享對映,範圍 (to - from) 內可能已經存在一些預留。有關如何完成此操作的詳細資訊,請參閱 預留對映修改 部分。

對映可能與子池關聯。如果是這樣,則查詢子池以確保對映有足夠的空間。子池可能會預留一些預留,這些預留可用於對映。有關更多詳細資訊,請參閱 子池預留 部分。

在查詢預留對映和子池之後,已知需要的新預留的數量。呼叫例程 hugetlb_acct_memory() 來檢查並獲取請求數量的預留。hugetlb_acct_memory() 呼叫例程,這些例程可能會分配和調整剩餘頁面計數。但是,在這些例程中,程式碼只是檢查以確保有足夠的可用巨型頁面來容納預留。如果存在,則會調整全域性預留計數 resv_huge_pages,如下所示

if (resv_needed <= (resv_huge_pages - free_huge_pages))
        resv_huge_pages += resv_needed;

請注意,在檢查和調整這些計數器時,會持有全域性鎖 hugetlb_lock。

如果有足夠的可用巨型頁面,並且已調整全域性計數 resv_huge_pages,則會修改與對映關聯的預留對映以反映預留。在共享對映的情況下,將存在一個包含範圍“from” - “to”的 file_region。對於私有對映,不會對預留對映進行任何修改,因為缺少條目指示存在預留。

如果 hugetlb_reserve_pages() 成功,則將根據需要修改與對映關聯的全域性預留計數和預留對映,以確保範圍“from” - “to”存在預留。

消耗預留/分配巨型頁面

當分配與預留關聯的巨型頁面並在相應的對映中例項化時,會消耗預留。分配在例程 alloc_hugetlb_folio() 中執行

struct folio *alloc_hugetlb_folio(struct vm_area_struct *vma,
                             unsigned long addr, int avoid_reserve)

alloc_hugetlb_folio 傳遞一個 VMA 指標和一個虛擬地址,因此它可以查詢預留對映以確定是否存在預留。此外,alloc_hugetlb_folio 採用引數 avoid_reserve,該引數指示即使看起來已為指定的地址設定了預留,也不應使用預留。avoid_reserve 引數最常用於寫時複製和頁面遷移的情況,在這些情況下,將分配現有頁面的額外副本。

呼叫輔助例程 vma_needs_reservation() 來確定對映 (vma) 中的地址是否存在預留。有關此例程的功能的詳細資訊,請參閱 預留對映輔助例程 部分。vma_needs_reservation() 返回的值通常為 0 或 1。如果地址存在預留,則為 0;如果不存在預留,則為 1。如果不存在預留,並且對映有關聯的子池,則查詢子池以確定它是否包含預留。如果子池包含預留,則可以使用一個預留用於此分配。但是,在每種情況下,avoid_reserve 引數都會覆蓋預留的使用以進行分配。在確定是否存在預留並且可以用於分配之後,將呼叫例程 dequeue_huge_page_vma()。此例程採用與預留相關的兩個引數

  • avoid_reserve,這與傳遞給 alloc_hugetlb_folio() 的值/引數相同。

  • chg,即使此引數的型別為 long,也只有值 0 或 1 傳遞給 dequeue_huge_page_vma。如果值為 0,則表示存在預留(有關可能的問題,請參閱“記憶體策略和預留”部分)。如果值為 1,則表示不存在預留,並且必須儘可能從全域性可用池中獲取頁面。

搜尋與 VMA 記憶體策略關聯的可用列表以查詢可用頁面。如果找到一個頁面,則從可用列表中刪除該頁面時,free_huge_pages 的值會減少。如果頁面有關聯的預留,則會進行以下調整

SetPagePrivate(page);   /* Indicates allocating this page consumed
                         * a reservation, and if an error is
                         * encountered such that the page must be
                         * freed, the reservation will be restored. */
resv_huge_pages--;      /* Decrement the global reservation count */

請注意,如果找不到滿足 VMA 記憶體策略的巨型頁面,則將嘗試使用夥伴分配器分配一個頁面。這提出了剩餘巨型頁面和過度提交的問題,這些問題超出了預留的範圍。即使分配了剩餘頁面,也會進行與上面相同的基於預留的調整:SetPagePrivate(page) 和 resv_huge_pages--。

在獲得新的 hugetlb 對開本後,(對開本)->_hugetlb_subpool 設定為與頁面關聯的子池的值(如果存在)。這將用於釋放對開本時的子池記帳。

然後呼叫例程 vma_commit_reservation() 以根據預留的消耗調整預留對映。通常,這包括確保該頁面在區域對映的 file_region 結構中表示。對於存在預留的共享對映,預留對映中已經存在一個條目,因此不會進行任何更改。但是,如果共享對映中沒有預留,或者這是一個私有對映,則必須建立一個新條目。

預留對映可能在 alloc_hugetlb_folio() 開頭的 vma_needs_reservation() 呼叫與分配對開本後的 vma_commit_reservation() 呼叫之間進行了更改。如果在共享對映中為同一頁面呼叫了 hugetlb_reserve_pages,則可能會發生這種情況。在這種情況下,預留計數和子池可用頁面計數將相差一個。可以透過比較 vma_needs_reservation 和 vma_commit_reservation 的返回值來識別這種罕見的情況。如果檢測到此類爭用情況,則會調整子池和全域性預留計數以進行補償。有關這些例程的更多資訊,請參閱 預留對映輔助例程 部分。

例項化巨型頁面

在巨型頁面分配之後,該頁面通常會被新增到分配任務的頁表中。在此之前,共享對映中的頁面會被新增到頁面快取中,私有對映中的頁面會被新增到匿名反向對映中。在這兩種情況下,都會清除 PagePrivate 標誌。因此,當釋放已例項化的巨型頁面時,不會對全域性預留計數 (resv_huge_pages) 進行任何調整。

釋放巨型頁面

巨型頁面由 free_huge_folio() 釋放。它僅傳遞一個指向對開本的指標,因為它從通用 MM 程式碼中呼叫。釋放巨型頁面時,可能需要執行預留記帳。如果頁面與包含預留的子池關聯,或者頁面在錯誤路徑上釋放,則必須恢復全域性預留計數,在這種情況下,將需要執行預留記帳。

頁面->private 欄位指向與頁面關聯的任何子池。如果設定了 PagePrivate 標誌,則表示應調整全域性預留計數(有關如何設定這些標誌的資訊,請參閱 消耗預留/分配巨型頁面 部分)。

該例程首先呼叫頁面的 hugepage_subpool_put_pages()。如果此例程返回值 0(不等於傳遞的值 1),則表示預留與子池關聯,並且必須使用此新釋放的頁面來使子池預留數量保持在最小值之上。因此,在這種情況下,全域性 resv_huge_pages 計數器會遞增。

如果在頁面中設定了 PagePrivate 標誌,則全域性 resv_huge_pages 計數器將始終遞增。

子池預留

每個巨型頁面大小都有一個 struct hstate 與之關聯。hstate 跟蹤指定大小的所有巨型頁面。子池表示 hstate 中與已掛載的 hugetlbfs 檔案系統關聯的頁面子集。

掛載 hugetlbfs 檔案系統時,可以指定 min_size 選項,該選項指示檔案系統所需的最小巨型頁面數。如果指定了此選項,則對應於 min_size 的巨型頁面數將預留供檔案系統使用。此數字在 struct hugepage_subpool 的 min_hpages 欄位中跟蹤。在掛載時,呼叫 hugetlb_acct_memory(min_hpages) 以預留指定數量的巨型頁面。如果無法預留它們,則掛載將失敗。

當從子池獲取頁面或釋放回子池時,將呼叫例程 hugepage_subpool_get/put_pages()。它們執行所有子池記帳,並跟蹤與子池關聯的任何預留。hugepage_subpool_get/put_pages 傳遞巨型頁面數,該數量用於調整子池的“已用頁面”計數(get 向下調整,put 向上調整)。通常,它們返回傳遞的相同值,如果沒有足夠的頁面存在於子池中,則返回錯誤。

但是,如果預留與子池關聯,則可能返回小於傳遞的值的返回值。此返回值指示必須進行的其他全域性池調整的數量。例如,假設一個子池包含 3 個預留的巨型頁面,而有人請求 5 個。與子池關聯的 3 個預留頁面可用於滿足部分請求。但是,必須從全域性池中獲取 2 個頁面。為了將此資訊傳遞給呼叫方,將返回值為 2。然後,呼叫方負責嘗試從全域性池中獲取另外兩個頁面。

COW 和預留

由於共享對映都指向並使用相同的底層頁面,因此 COW 最關注私有對映的預留。在這種情況下,兩個任務可以指向同一個先前分配的頁面。一個任務嘗試寫入該頁面,因此必須分配一個新頁面,以便每個任務都指向自己的頁面。

最初分配頁面時,該頁面的預留已消耗。由於 COW 而嘗試分配新頁面時,可能沒有可用的可用巨型頁面,並且分配將失敗。

最初建立私有對映時,透過在所有者的預留對映指標中設定 HPAGE_RESV_OWNER 位來記錄對映的所有者。由於所有者建立了對映,因此所有者擁有與對映關聯的所有預留。因此,當發生寫入錯誤且沒有可用頁面時,將對預留的所有者和非所有者採取不同的操作。

如果出錯的任務不是所有者,則錯誤將失敗,並且任務通常會收到 SIGBUS。

如果所有者是出錯的任務,我們希望它成功,因為它擁有原始預留。為了實現這一點,該頁面會從非所有者任務中取消對映。這樣,唯一的引用來自所有者任務。此外,會在非所有者任務的預留對映指標中設定 HPAGE_RESV_UNMAPPED 位。如果非所有者任務稍後在不存在的頁面上出錯,則可能會收到 SIGBUS。但是,對映/預留的原始所有者將按預期執行。

預留對映修改

以下底層例程用於修改預留對映。通常,不會直接呼叫這些例程。而是呼叫預留對映輔助例程,該例程呼叫這些底層例程之一。這些底層例程在原始碼 (mm/hugetlb.c) 中得到了很好的記錄。這些例程是

long region_chg(struct resv_map *resv, long f, long t);
long region_add(struct resv_map *resv, long f, long t);
void region_abort(struct resv_map *resv, long f, long t);
long region_count(struct resv_map *resv, long f, long t);

預留對映上的操作通常涉及兩個操作

  1. 呼叫 region_chg() 以檢查預留對映並確定指定範圍 [f, t) 內有多少頁面當前未表示。

    呼叫程式碼執行全域性檢查和分配,以確定是否有足夠的巨型頁面使操作成功。

    1. 如果操作可以成功,則呼叫 region_add() 以實際修改先前傳遞給 region_chg() 的相同範圍 [f, t) 的預留對映。

    2. 如果操作無法成功,則呼叫 region_abort 以中止相同範圍 [f, t) 的操作。

請注意,這是一個兩步過程,在先前對相同範圍呼叫 region_chg() 之後,保證 region_add() 和 region_abort() 成功。region_chg() 負責預先分配任何必要的資料結構,以確保後續操作(特別是 region_add())將成功。

如上所述,region_chg() 確定範圍內當前未在對映中表示的頁面數。此數字將返回給呼叫方。region_add() 返回新增到對映中的範圍內頁面數。在大多數情況下,region_add() 的返回值與 region_chg() 的返回值相同。但是,在共享對映的情況下,可以在呼叫 region_chg() 和 region_add() 之間對預留對映進行更改。在這種情況下,region_add() 的返回值與 region_chg() 的返回值不匹配。在這種情況下,全域性計數和子池記帳可能不正確,需要進行調整。呼叫方有責任檢查此條件並進行適當的調整。

呼叫例程 region_del() 以從預留對映中刪除區域。它通常在以下情況下呼叫

  • 當刪除 hugetlbfs 檔案系統中的檔案時,inode 將被釋放,預留對映將被釋放。在釋放預留對映之前,必須釋放所有單個 file_region 結構。在這種情況下,region_del 傳遞範圍 [0, LONG_MAX)。

  • 當截斷 hugetlbfs 檔案時。在這種情況下,必須釋放新檔案大小之後的所有已分配頁面。此外,必須刪除預留對映中超出新檔案末尾的任何 file_region 條目。在這種情況下,region_del 傳遞範圍 [new_end_of_file, LONG_MAX)。

  • 當在 hugetlbfs 檔案中打孔時。在這種情況下,會一次一個地從檔案中間刪除巨型頁面。刪除頁面時,會呼叫 region_del() 以從預留對映中刪除相應的條目。在這種情況下,region_del 傳遞範圍 [page_idx, page_idx + 1)。

在每種情況下,region_del() 都將返回從預留對映中刪除的頁面數。在極少數情況下,region_del() 可能會失敗。這隻能在必須拆分現有 file_region 條目並且無法分配新結構的情況下發生。在這種錯誤情況下,region_del() 將返回 -ENOMEM。這裡的問題是預留對映將指示該頁面存在預留。但是,子池和全域性預留計數不會反映預留。為了處理這種情況,呼叫例程 hugetlb_fix_reserve_counts() 來調整計數器,使其與無法刪除的預留對映條目相對應。

在取消對映私有巨型頁面對映時呼叫 region_count()。在私有對映中,預留對映中缺少條目表示存在預留。因此,透過計算預留對映中的條目數,我們知道有多少預留被消耗,以及有多少預留未完成(未完成 = (end - start) - region_count(resv, start, end))。由於對映即將消失,因此子池和全域性預留計數會減少未完成的預留數。

預留對映輔助例程

存在幾個輔助例程可以查詢和修改預留對映。這些例程僅對特定巨型頁面的預留感興趣,因此它們只傳遞一個地址而不是一個範圍。此外,它們傳遞關聯的 VMA。從 VMA 中,可以確定對映型別(私有或共享)和預留對映的位置(inode 或 VMA)。這些例程只是呼叫“預留對映修改”部分中描述的底層例程。但是,它們確實考慮了私有和共享對映的預留對映條目的“相反”含義,並向呼叫方隱藏了此細節

long vma_needs_reservation(struct hstate *h,
                           struct vm_area_struct *vma,
                           unsigned long addr)

此例程為指定的頁面呼叫 region_chg()。如果不存在預留,則返回 1。如果存在預留,則返回 0

long vma_commit_reservation(struct hstate *h,
                            struct vm_area_struct *vma,
                            unsigned long addr)

這為指定的頁面呼叫 region_add()。與 region_chg 和 region_add 的情況一樣,此例程在先前呼叫 vma_needs_reservation 之後呼叫。它將為頁面新增一個預留條目。如果添加了預留,則返回 1;如果未新增預留,則返回 0。返回值應與先前呼叫 vma_needs_reservation 的返回值進行比較。意外的差異表示在呼叫之間修改了預留對映

void vma_end_reservation(struct hstate *h,
                         struct vm_area_struct *vma,
                         unsigned long addr)

這為指定的頁面呼叫 region_abort()。與 region_chg 和 region_abort 的情況一樣,此例程在先前呼叫 vma_needs_reservation 之後呼叫。它將中止/結束正在進行的預留新增操作

long vma_add_reservation(struct hstate *h,
                         struct vm_area_struct *vma,
                         unsigned long addr)

這是一個特殊的包裝例程,用於幫助在錯誤路徑上進行預留清理。它僅從例程 restore_reserve_on_error() 中呼叫。此例程與 vma_needs_reservation 結合使用,嘗試將預留新增到預留對映。它考慮了私有和共享對映的不同預留對映語義。因此,為共享對映呼叫 region_add(因為對映中存在的條目表示預留),併為私有對映呼叫 region_del(因為對映中缺少條目表示預留)。有關在錯誤路徑上需要執行的操作的更多資訊,請參閱“錯誤路徑上的預留清理”部分。

錯誤路徑上的預留清理

預留對映輔助例程 部分中所述,預留對映修改分兩步執行。首先,在分配頁面之前呼叫 vma_needs_reservation。如果分配成功,則呼叫 vma_commit_reservation。否則,呼叫 vma_end_reservation。全域性和子池預留計數會根據操作的成功或失敗進行調整,一切都很好。

此外,在例項化巨型頁面之後,會清除 PagePrivate 標誌,以便在最終釋放頁面時記帳正確。

但是,在分配巨型頁面之後但在例項化之前會遇到幾個錯誤例項。在這種情況下,頁面分配消耗了預留,並進行了適當的子池、預留對映和全域性計數調整。如果此時釋放頁面(在例項化和清除 PagePrivate 之前),則 free_huge_folio 將遞增全域性預留計數。但是,預留對映指示已消耗預留。由此產生的不一致狀態將導致“洩漏”預留的巨型頁面。全域性預留計數將高於應有的計數,並阻止分配預先分配的頁面。

例程 restore_reserve_on_error() 嘗試處理這種情況。它記錄得非常好。此例程的目的是將預留對映恢復到頁面分配之前的狀態。這樣,預留對映的狀態將與釋放頁面後的全域性預留計數相對應。

例程 restore_reserve_on_error 本身在嘗試恢復預留對映條目時可能會遇到錯誤。在這種情況下,它只會清除頁面的 PagePrivate 標誌。這樣,在釋放頁面時,全域性預留計數將不會遞增。但是,預留對映將繼續看起來像是已消耗預留。仍然可以為地址分配頁面,但它不會像最初預期的那樣使用預留的頁面。

有一些程式碼(最值得注意的是 userfaultfd)無法呼叫 restore_reserve_on_error。在這種情況下,它只是修改 PagePrivate,以便在釋放巨型頁面時不會洩漏預留。

預留和記憶體策略

當首次使用 git 來管理 Linux 程式碼時,struct hstate 中存在每個節點的巨型頁面列表。預留的概念是在一段時間後新增的。新增預留時,未嘗試考慮記憶體策略。雖然 cpusets 與記憶體策略不完全相同,但 hugetlb_acct_memory 中的此註釋總結了預留與 cpusets/記憶體策略之間的互動

/*
 * When cpuset is configured, it breaks the strict hugetlb page
 * reservation as the accounting is done on a global variable. Such
 * reservation is completely rubbish in the presence of cpuset because
 * the reservation is not checked against page availability for the
 * current cpuset. Application can still potentially OOM'ed by kernel
 * with lack of free htlb page in cpuset that the task is in.
 * Attempt to enforce strict accounting with cpuset is almost
 * impossible (or too ugly) because cpuset is too fluid that
 * task or memory node can be dynamically moved between cpusets.
 *
 * The change of semantics for shared hugetlb mapping with cpuset is
 * undesirable. However, in order to preserve some of the semantics,
 * we fall back to check against current free page availability as
 * a best attempt and hopefully to minimize the impact of changing
 * semantics that cpuset has.
 */

新增巨型頁面預留是為了防止在發生頁面錯誤時出現意外的頁面分配失敗 (OOM)。但是,如果應用程式使用 cpusets 或記憶體策略,則無法保證所需節點上提供巨型頁面。即使有足夠的全域性預留,情況也是如此。

Hugetlbfs 迴歸測試

最完整的 hugetlb 測試集位於 libhugetlbfs 儲存庫中。如果您修改任何與 hugetlb 相關的程式碼,請使用 libhugetlbfs 測試套件檢查迴歸。此外,如果您新增任何新的 hugetlb 功能,請向 libhugetlbfs 新增適當的測試。

-- Mike Kravetz,2017 年 4 月 7 日