異構記憶體管理 (HMM)¶
提供基礎設施和幫助程式,以將非傳統記憶體(如板載 GPU 的裝置記憶體)整合到常規核心路徑中,其基石是此類記憶體的專用結構頁(請參閱本文件的第 5 至 7 節)。
HMM 還為 SVM(共享虛擬記憶體)提供可選的幫助程式,即允許裝置以與 CPU 一致的方式透明地訪問程式地址,這意味著 CPU 上的任何有效指標對於裝置來說也是有效指標。這對於簡化高階異構計算的使用變得強制性,在高階異構計算中,GPU、DSP 或 FPGA 用於代表程序執行各種計算。
本文件分為以下幾個部分:在第一部分中,我闡述了使用特定於裝置的記憶體分配器相關的問題。在第二部分中,我闡述了許多平臺固有的硬體限制。第三部分概述了 HMM 設計。第四部分解釋了 CPU 頁表映象的工作方式以及 HMM 在此上下文中的用途。第五部分討論了裝置記憶體如何在核心內部表示。最後,最後一部分介紹了一種新的遷移幫助程式,允許利用裝置 DMA 引擎。
使用裝置特定記憶體分配器的問題¶
具有大量板載記憶體(數 GB)的裝置(如 GPU)歷來透過專用驅動程式特定 API 管理其記憶體。這會在裝置驅動程式分配和管理的記憶體與常規應用程式記憶體(私有匿名、共享記憶體或常規檔案支援的記憶體)之間建立斷開連線。從這裡開始,我將此方面稱為拆分地址空間。我使用共享地址空間來指代相反的情況:即,任何應用程式記憶體區域都可以被裝置透明地使用。
發生拆分地址空間是因為裝置只能訪問透過裝置特定 API 分配的記憶體。這意味著程式中的所有記憶體物件從裝置的角度來看並不相同,這使得依賴於廣泛庫的大型程式變得複雜。
具體來說,這意味著想要利用 GPU 等裝置的程式碼需要在通用分配的記憶體(malloc、mmap private、mmap share)和透過裝置驅動程式 API 分配的記憶體之間複製物件(這最終還是會得到 mmap,但裝置檔案的 mmap)。
對於平面資料集(陣列、網格、影像等),這不太難實現,但對於複雜資料集(列表、樹等),很難正確實現。複製複雜資料集需要重新對映其每個元素之間的所有指標關係。這是容易出錯的,並且由於重複的資料集和地址,程式變得更難除錯。
拆分地址空間還意味著庫無法透明地使用它們從核心程式或另一個庫獲得的資料,因此每個庫可能必須使用裝置特定記憶體分配器複製其輸入資料集。大型專案會受到此影響,並因各種記憶體複製而浪費資源。
複製每個庫 API 以接受由每個裝置特定分配器分配的輸入或輸出記憶體不是一個可行的選擇。這將導致庫入口點中的組合爆炸。
最後,隨著高階語言結構(在 C++ 中,但在其他語言中也是如此)的進步,編譯器現在可以在沒有程式設計師知識的情況下利用 GPU 和其他裝置。一些編譯器識別的模式只能使用共享地址空間來完成。對於所有其他模式,使用共享地址空間也更合理。
I/O 匯流排,裝置記憶體特性¶
由於一些限制,I/O 匯流排會損害共享地址空間。大多數 I/O 匯流排只允許從裝置到主記憶體的基本記憶體訪問;即使是快取一致性通常也是可選的。從 CPU 訪問裝置記憶體的限制更多。通常情況下,它不是快取一致的。
如果我們只考慮 PCIE 匯流排,那麼裝置可以訪問主記憶體(通常透過 IOMMU)並與 CPU 保持快取一致性。但是,它只允許裝置對主記憶體執行有限的一組原子操作。在另一個方向上更糟:CPU 只能訪問有限範圍的裝置記憶體,並且無法對其執行原子操作。因此,從核心的角度來看,裝置記憶體不能被認為是與常規記憶體相同的。
另一個不利因素是頻寬有限(使用 PCIE 4.0 和 16 通道約為 32GBytes/s)。這比最快的 GPU 記憶體(1 TBytes/s)少 33 倍。最終的限制是延遲。從裝置訪問主記憶體的延遲比裝置訪問其自身記憶體的延遲高一個數量級。
一些平臺正在開發新的 I/O 匯流排或 PCIE 的新增/修改,以解決其中一些限制(OpenCAPI、CCIX)。它們主要允許 CPU 和裝置之間的雙向快取一致性,並允許架構支援的所有原子操作。可悲的是,並非所有平臺都遵循這一趨勢,一些主要的架構沒有解決這些問題的硬體解決方案。
因此,為了使共享地址空間有意義,我們不僅必須允許裝置訪問任何記憶體,還必須允許在裝置使用任何記憶體時將其遷移到裝置記憶體(在此期間阻止 CPU 訪問)。
地址空間映象實現和 API¶
地址空間映象的主要目標是允許將 CPU 頁表範圍複製到裝置頁表中;HMM 幫助保持兩者同步。想要映象程序地址空間的裝置驅動程式必須從註冊 mmu_interval_notifier 開始
int mmu_interval_notifier_insert(struct mmu_interval_notifier *interval_sub,
struct mm_struct *mm, unsigned long start,
unsigned long length,
const struct mmu_interval_notifier_ops *ops);
在 ops->invalidate() 回撥期間,裝置驅動程式必須執行更新操作到範圍(將範圍標記為只讀,或完全取消對映等)。裝置必須在驅動程式回撥返回之前完成更新。
當裝置驅動程式想要填充虛擬地址範圍時,可以使用
int hmm_range_fault(struct hmm_range *range);
如果請求寫入訪問,它將觸發缺失或只讀條目的頁面錯誤(請參閱下文)。頁面錯誤使用通用 mm 頁面錯誤程式碼路徑,就像 CPU 頁面錯誤一樣。使用模式是
int driver_populate_range(...)
{
struct hmm_range range;
...
range.notifier = &interval_sub;
range.start = ...;
range.end = ...;
range.hmm_pfns = ...;
if (!mmget_not_zero(interval_sub->notifier.mm))
return -EFAULT;
again:
range.notifier_seq = mmu_interval_read_begin(&interval_sub);
mmap_read_lock(mm);
ret = hmm_range_fault(&range);
if (ret) {
mmap_read_unlock(mm);
if (ret == -EBUSY)
goto again;
return ret;
}
mmap_read_unlock(mm);
take_lock(driver->update);
if (mmu_interval_read_retry(&ni, range.notifier_seq) {
release_lock(driver->update);
goto again;
}
/* Use pfns array content to update device page table,
* under the update lock */
release_lock(driver->update);
return 0;
}
driver->update 鎖是驅動程式在其 invalidate() 回撥中使用的同一把鎖。在呼叫 mmu_interval_read_retry() 之前必須持有該鎖,以避免與併發 CPU 頁表更新發生任何競爭。
利用 default_flags 和 pfn_flags_mask¶
hmm_range 結構有兩個欄位,default_flags 和 pfn_flags_mask,它們指定整個範圍的錯誤或快照策略,而不是必須為 pfns 陣列中的每個條目設定它們。
例如,如果裝置驅動程式想要至少具有讀取許可權的範圍的頁面,則它設定
range->default_flags = HMM_PFN_REQ_FAULT;
range->pfn_flags_mask = 0;
並如上所述呼叫 hmm_range_fault()。這將填充範圍內所有至少具有讀取許可權的頁面的錯誤。
現在假設驅動程式想要做同樣的事情,但範圍內的一個頁面除外,它希望具有寫入許可權。現在驅動程式設定
range->default_flags = HMM_PFN_REQ_FAULT;
range->pfn_flags_mask = HMM_PFN_REQ_WRITE;
range->pfns[index_of_write] = HMM_PFN_REQ_WRITE;
這樣,HMM 將至少以讀取(即有效)許可權填充所有頁面,對於地址 == range->start + (index_of_write << PAGE_SHIFT),它將以寫入許可權填充錯誤,即,如果 CPU pte 沒有設定寫入許可權,則 HMM 將呼叫 handle_mm_fault()。
hmm_range_fault 完成後,標誌位設定為頁表的當前狀態,即如果頁面可寫,則設定 HMM_PFN_VALID | HMM_PFN_WRITE。
從核心核心的角度表示和管理裝置記憶體¶
已經嘗試了幾種不同的設計來支援裝置記憶體。第一個設計使用裝置特定的資料結構來儲存有關遷移記憶體的資訊,並且 HMM 將自身掛鉤在 mm 程式碼的各個位置,以處理對裝置記憶體支援的地址的任何訪問。事實證明,這最終複製了 struct page 的大多數字段,並且還需要更新許多核心程式碼路徑才能理解這種新型記憶體。
大多數核心程式碼路徑從不嘗試訪問頁面後面的記憶體,而只關心 struct page 內容。因此,HMM 切換到直接對裝置記憶體使用 struct page,這使得大多數核心程式碼路徑沒有意識到差異。我們只需要確保沒有人嘗試從 CPU 端對映這些頁面。
往返於裝置記憶體的遷移¶
由於 CPU 無法直接訪問裝置記憶體,因此裝置驅動程式必須使用硬體 DMA 或裝置特定載入/儲存指令來遷移資料。 migrate_vma_setup()、migrate_vma_pages() 和 migrate_vma_finalize() 函式旨在使驅動程式更易於編寫並集中驅動程式之間的通用程式碼。
在將頁面遷移到裝置私有記憶體之前,需要建立特殊的裝置私有 struct page。這些將用作特殊的“交換”頁表條目,以便 CPU 程序如果嘗試訪問已遷移到裝置私有記憶體的頁面,則會發生錯誤。
可以使用以下命令分配和釋放它們
struct resource *res;
struct dev_pagemap pagemap;
res = request_free_mem_region(&iomem_resource, /* number of bytes */,
"name of driver resource");
pagemap.type = MEMORY_DEVICE_PRIVATE;
pagemap.range.start = res->start;
pagemap.range.end = res->end;
pagemap.nr_range = 1;
pagemap.ops = &device_devmem_ops;
memremap_pages(&pagemap, numa_node_id());
memunmap_pages(&pagemap);
release_mem_region(pagemap.range.start, range_len(&pagemap.range));
還有 devm_request_free_mem_region()、devm_memremap_pages()、devm_memunmap_pages() 和 devm_release_mem_region(),當資源可以繫結到 struct device 時。
總體遷移步驟類似於遷移系統記憶體中的 NUMA 頁面(請參閱 頁面遷移),但這些步驟在裝置驅動程式特定程式碼和共享通用程式碼之間拆分
mmap_read_lock()裝置驅動程式必須將
struct vm_area_struct傳遞給migrate_vma_setup(),因此 mmap_read_lock() 或 mmap_write_lock() 需要在遷移期間持有。migrate_vma_setup(struct migrate_vma *args)裝置驅動程式初始化
struct migrate_vma欄位並將指標傳遞給migrate_vma_setup()。args->flags欄位用於過濾應遷移的源頁面。例如,設定MIGRATE_VMA_SELECT_SYSTEM將僅遷移系統記憶體,設定MIGRATE_VMA_SELECT_DEVICE_PRIVATE將僅遷移駐留在裝置私有記憶體中的頁面。如果設定了後一個標誌,則args->pgmap_owner欄位用於標識驅動程式擁有的裝置私有頁面。這避免了嘗試遷移駐留在其他裝置中的裝置私有頁面。目前,只有匿名私有 VMA 範圍可以遷移到或從系統記憶體和裝置私有記憶體遷移。migrate_vma_setup()執行的第一個步驟之一是使用mmu_notifier_invalidate_range_start(()和mmu_notifier_invalidate_range_end()呼叫圍繞頁表遍歷來填充args->src陣列遷移的 PFN,從而使其他裝置的 MMU 失效。invalidate_range_start()回撥傳遞一個struct mmu_notifier_range,其中event欄位設定為MMU_NOTIFY_MIGRATE,owner欄位設定為傳遞給migrate_vma_setup()的args->pgmap_owner欄位。這允許裝置驅動程式跳過失效回撥,並且僅失效實際遷移的裝置私有 MMU 對映。這在下一節中有更多解釋。在遍歷頁表時,
pte_none()或is_zero_pfn()條目會導致儲存在args->src陣列中的有效“零”PFN。這使驅動程式可以分配裝置私有記憶體並清除它,而不是複製零頁面。系統記憶體或裝置私有 struct 頁面的有效 PTE 條目將被lock_page()鎖定,從 LRU 中隔離(如果是系統記憶體,因為裝置私有頁面不在 LRU 上),從程序中取消對映,並且特殊的遷移 PTE 將插入到原始 PTE 的位置。migrate_vma_setup()還會清除args->dst陣列。裝置驅動程式分配目標頁面並將源頁面複製到目標頁面。
驅動程式檢查每個
src條目以檢視是否設定了MIGRATE_PFN_MIGRATE位,並跳過未遷移的條目。裝置驅動程式還可以選擇透過不填充該頁面的dst陣列來跳過遷移頁面。然後,驅動程式分配裝置私有 struct 頁面或系統記憶體頁面,使用
lock_page()鎖定頁面,並使用以下內容填充dst陣列條目dst[i] = migrate_pfn(page_to_pfn(dpage));
現在驅動程式知道此頁面正在遷移,它可以使裝置私有 MMU 對映失效並將裝置私有記憶體複製到系統記憶體或另一個裝置私有頁面。核心 Linux 核心處理 CPU 頁表失效,因此裝置驅動程式只需使其自己的 MMU 對映失效。
驅動程式可以使用
migrate_pfn_to_page(src[i])獲取源的struct page,並將源頁面複製到目標,或者如果指標為NULL,則清除目標裝置私有記憶體,這意味著源頁面未填充在系統記憶體中。migrate_vma_pages()此步驟是實際“提交”遷移的位置。
如果源頁面是
pte_none()或is_zero_pfn()頁面,則這是將新分配的頁面插入 CPU 頁表的位置。如果 CPU 執行緒在同一頁面上發生錯誤,則可能會失敗。但是,頁表已鎖定,並且只會插入一個新頁面。如果裝置驅動程式丟失了競爭,它將看到MIGRATE_PFN_MIGRATE位被清除。如果源頁面被鎖定、隔離等,則現在源
struct page資訊將複製到目標struct page,從而完成 CPU 端的遷移。裝置驅動程式更新仍在遷移的頁面的裝置 MMU 頁表,回滾未遷移的頁面。
如果
src條目仍具有MIGRATE_PFN_MIGRATE位集,則裝置驅動程式可以更新裝置 MMU,如果設定了MIGRATE_PFN_WRITE位,則可以設定寫入啟用位。migrate_vma_finalize()此步驟將特殊的遷移頁表條目替換為新頁面的頁表條目,並釋放對源和目標
struct page的引用。mmap_read_unlock()現在可以釋放鎖。
獨佔訪問記憶體¶
某些裝置具有諸如原子 PTE 位之類的功能,這些功能可用於實現對系統記憶體的原子訪問。為了支援對共享虛擬記憶體頁面的原子操作,此類裝置需要訪問該頁面,該訪問是 CPU 的任何使用者空間訪問所獨有的。make_device_exclusive() 函式可用於使使用者空間無法訪問記憶體範圍。
這會將給定範圍內的頁面的所有對映替換為特殊的交換條目。任何訪問交換條目的嘗試都會導致錯誤,透過將條目替換為原始對映來解決該錯誤。驅動程式會收到 MMU 通知程式已更改對映的通知,此後它將不再具有對該頁面的獨佔訪問許可權。保證獨佔訪問許可權持續到驅動程式釋放頁面鎖和頁面引用為止,此時 CPU 對該頁面的任何錯誤都可能按所述進行。
記憶體 cgroup (memcg) 和 rss 記賬¶
目前,裝置記憶體被視為 rss 計數器中的任何常規頁面(如果裝置頁面用於匿名頁面,則為匿名頁面;如果裝置頁面用於檔案支援的頁面,則為檔案頁面;如果裝置頁面用於共享記憶體,則為 shmem)。這是一個故意的選擇,目的是保持現有應用程式在不知道裝置記憶體的情況下開始使用裝置記憶體,使其執行不受影響。
一個缺點是 OOM killer 可能會殺死使用大量裝置記憶體而不是大量常規系統記憶體的應用程式,因此不會釋放太多系統記憶體。我們希望在決定以不同方式計算裝置記憶體之前,收集更多關於應用程式和系統在裝置記憶體存在下在記憶體壓力下做出反應的真實世界經驗。
對於記憶體 cgroup 也做出了相同的決定。裝置記憶體頁面根據常規頁面將被計算到的同一記憶體 cgroup 進行計算。這簡化了往返於裝置記憶體的遷移。這也意味著從裝置記憶體到常規記憶體的遷移不會失敗,因為它會超出記憶體 cgroup 限制。一旦我們獲得更多關於裝置記憶體如何使用及其對記憶體資源控制影響的經驗,我們可能會重新審視此選擇。
請注意,裝置記憶體永遠不會被裝置驅動程式或透過 GUP 鎖定,因此此類記憶體在程序退出時始終是空閒的。或者在共享記憶體或檔案支援的記憶體的情況下,當最後一個引用被刪除時。