低層級GPU虛擬內存管理引論

低層級GPU虛擬內存管理引論

Introducing Low-Level GPU Virtual Memory Management

CUDA應用程序越來越需要儘可能快速高效地管理內存。在CUDA 10.2之前,開發人員可用的選項數量僅限於CUDA提供的類似malloc的抽象。

CUDA10.2爲虛擬內存管理引入了一組新的API函數,使您能夠構建更高效的動態數據結構,並更好地控制應用程序中的GPU內存使用。在這篇文章中,我們將解釋如何使用新的API函數並瀏覽一些實際的應用程序用例。

在很多應用程序中,很難猜測初始分配應該有多大。您需要一個更大的分配,但是您不能承擔從GPU通過一個專門的動態數據結構來跟蹤指針的性能和開發成本。您真正想要的是在需要更多內存時增加分配,同時保持您一直擁有的連續地址範圍。如果你曾經使用過LIB的RealCoc函數,或者C++的STD::vector,你可能自己碰到這個問題。

Growing allocations

看看下面的簡單C++類,它描述了一個可以擴展的向量:

class Vector {

private:

void *d_p;

size_t alloc_sz, reserve_sz;

public:

Vector() : d_p(NULL), alloc_sz(0), reserve_sz(0) {}

// Reserves some extra space in
order to speed up grow()

CUresult reserve(size_t new_sz);

// Actually commits num bytes
of additional memory

CUresult grow(size_t new_sz);

// Frees up all the associated
resources.

~Vector();

};

在CUDA 10.2之前,在CUDA中實現這個概念的唯一方法是使用cudamaloc、cudaFree和cudaMemcpy,或者使用cudamalocmanaged和cudaPrefetchAsync來提交需要的內存。

CUresult Vector::reserve(size_t new_sz)
{
if (new_sz > reserve_sz)
{
void *new_ptr = nullptr;#ifndef USE_MANAGED_MEMORY
cudaMalloc(&new_ptr, new_sz);
#else
cudaMallocManaged(&new_ptr, new_sz);
#endif
cudaMemcpy(new_ptr, d_p, alloc_sz);
cudaFree(d_p);
d_p = new_ptr;
reserve_sz = new_sz;
}
}
CUresult Vector::grow(size_t new_sz)
{
Vector::reserve(alloc_sz + new_sz);
#ifdef
USE_MANAGED_MEMORY
cudaPrefetchAsync(d_p + alloc_sz, num, dev);
#endif
alloc_sz += new_sz;
}
Vector::~Vector()
{
if (d_p)
cudaFree(d_p);
}

雖然實現相當簡單,但有許多性能影響。

cudaMalloc函數分配的資源超過了增加分配所需的資源。要增長,您需要保留舊的分配,並分配一個新的分配,爲舊的分配留出足夠的空間和額外的空間,這將大大減少您的增長量。如果設備只有2 GiB的內存,並且您已經有1 GiB的向量,則不能將其增大,因爲您需要1 GiB加上您需要的增長量。有效地,你不能增長一個向量大於一半的GPU內存。

每個分配必須映射到所有對等上下文,即使它從未在這些對等上下文中使用過。

cudammcpy調用爲不斷增長的請求增加了延遲,並使用寶貴的內存帶寬來複制數據。這樣的帶寬可以更好地用在其他地方。

cudaFree調用在繼續之前等待當前上下文上的所有掛起工作(以及所有對等上下文)。

使用託管內存解決了其中一些問題,您將在本文後面看到。不幸的是,使用託管內存會增加一些兼容性問題,這些問題可能不適合所有應用程序。

按需頁面遷移並非在所有平臺上都可用(尤其是在Windows和Tegra移動平臺上)。在這些平臺上,使用cudamalocmanaged保留一個VA,然後根據需要提交它不是一個選項。
cudamalocmanaged內存不能與CUDA進程間通信(cudaIpc*)函數一起使用。要與其他進程通信,必須將數據複製到可共享的cudamaloc內存中,有效地複製數據以繞過此限制。
cudamalocmanaged內存不能與圖形互操作函數一起使用。在圖形API(如DirectX、OpenGL或Vulkan)中使用此數據之前,必須將數據複製到已註冊的圖形資源。

新的CUDA虛擬內存管理功能是低級的驅動程序功能,允許您實現不同的分配用例,而不會出現前面提到的許多缺點。

支持各種用例的需要使得低級虛擬內存分配與像cudamaloc這樣的高級函數有很大的不同。與單個函數不同,您將使用四個主要函數,我們將在後面的章節中更詳細地介紹這些函數:

cuMemCreate創建物理內存句柄。

cuMemAddressReserve保留一個虛擬地址範圍。

cumemap將物理內存句柄映射到虛擬地址範圍。

cuMemSetAccess將每個設備的內存訪問權限設置爲分配。

這些函數可以與cudaMalloc和cudamalocmanaged等運行時函數同時使用,但它們需要直接從驅動程序加載這些入口點。有關如何與此類驅動程序函數交互的更多信息,請參閱本文中包含的示例或隨CUDA工具包分發的各種示例。下面是這些新的虛擬內存管理功能的工作原理。

Allocating physical memory

首先,需要對物理內存進行操作,爲此需要使用新函數cuMemCreate。此函數採用句柄cumemgenericalallocationhandle,它描述要分配的內存的屬性,比如該內存物理位置在哪裏,或者應該提供什麼類型的可共享句柄。目前,唯一受支持的內存類型是當前設備上的固定設備內存,但在將來的CUDA版本中,還會有更多的屬性。

接下來,你需要尺寸。與cuMemAlloc不同,cuMemCreate只接受與句柄所描述的內存的粒度相匹配的大小。使用cuMemGetAllocationGranularity獲取此粒度並使用它填充請求的大小。現在,您擁有創建物理分配所需的所有信息,如下代碼示例所示:

size_t granularity = 0;
CUmemGenericAllocationHandle allocHandle;
CUmemAllocationProp prop = {};
prop.type = CU_MEM_ALLOCATION_TYPE_PINNED;
prop.location.type = CU_MEM_LOCATION_TYPE_DEVICE;
prop.location.id = currentDev;
cuMemGetAllocationGranularity(&granularity, &prop,CU_MEM_ALLOC_GRANULARITY_MINIMUM);
padded_size = ROUND_UP(size, granularity);cuMemCreate(&allocHandle, padded_size, &prop, 0);

您可以使用分配句柄映射分配的內存,以便CUDA的其餘部分可以訪問它,如下一節所述。您還可以將此分配句柄導出到可用於進程間通信甚至圖形互操作的對象。我們將在後面的章節中回到這些用例。

Mapping memory

要使用新的CUDA虛擬內存管理功能映射分配,必須首先從CUDA請求虛擬地址(VA)範圍。這類似於virtualloc或mmap的工作方式。使用CUDA,使用cuMemAddressReserve獲得合適的地址。接下來,將物理句柄映射到使用cumemap檢索的地址。

/* Reserve a virtual address range /
cuMemAddressReserve(&ptr, padded_size, 0, 0, 0);/
Map the virtual address range * to the physical allocation */
cuMemMap(ptr, padded_size, 0, allocHandle, 0);

繼續使用前面計算的填充大小。目前,CUDA不支持物理分配的映射部分,因此需要匹配大小。這在未來可能會改變。

雖然您現在可以嘗試從設備訪問地址,但它會生成設備故障,就像您訪問了無效內存一樣。這是因爲新映射的分配始終映射爲所有設備的CU_MEM_ACCESS_FLAGS_PROT_NONE,這意味着從任何設備對該VA範圍的訪問無效並觸發錯誤。其原因是使該內存的映射操作可伸縮。在本文後面的“用例:可伸縮對等映射”一節中,我們將回到這一點。

要啓用對此內存映射的訪問,請初始化訪問描述結構並調用cuMemSetAccess,如下代碼示例所示:

CUmemAccessDesc accessDesc = {};
accessDesc.location.type = CU_MEM_LOCATION_TYPE_DEVICE;accessDesc.location.id = currentDev;
accessDesc.flags = CU_MEM_ACCESS_FLAGS_PROT_READWRITE;
cuMemSetAccess(ptr, size, &accessDesc, 1);

現在,您可以從當前設備訪問[ptr,ptr+size]範圍內的任何地址,而不會出現問題。

Freeing memory

當然,到目前爲止描述的所有函數都有相應的自由函數。若要取消映射映射的VA範圍,請對整個VA範圍調用cummunmap,這會將VA範圍還原回cummaddressreserve之後的狀態。完成VA範圍後,cuMemAddressFree會將其返回給CUDA以用於其他用途。

最後,cuMemRelease使句柄無效,如果沒有映射引用,則將內存的備份存儲釋放回操作系統。下面的代碼示例顯示了這種情況:

cuMemUnmap(ptr, size);
cuMemRelease(allocHandle);
cuMemAddressFree(ptr, size);

雖然我們在這裏沒有詳細介紹這些函數,但是您可以查看CUDA示例以及本文中引用的示例,瞭解它們是如何協同工作的。

Putting it together

本文前面的部分使用CUDA虛擬內存管理功能介紹了cudamaloc的另一種實現。這些函數要詳細得多,並且需要更多關於應用程序如何使用分配的預先知識。我們將在本文後面向您展示這種額外冗長的好處。

回到向量的例子。使用CUDA虛擬內存管理功能,您可以將內存提交到虛擬地址空間的區域,就像使用cudaPrefetchAsync和cudaMallocManaged一樣。另外,如果您的保留空間不足,則不需要發出cudammcpy調用,也不需要分配比原始請求更多的內存。只需將您已經擁有的分配重新映射到它們的新地址。

首先,您需要一個VA範圍來映射,這在前面已經介紹過了。通常你已經有了一個VA,你只想把它附加到VA上來種植它。cuMemAddressReserve函數接受一個fixeddr參數,該參數允許您提示所需的VA起始地址。如果CUDA由於任何原因不能使用這個VA,它會忽略這個提示,並嘗試以其他方式完成請求。這對於向量類很有用:

CUresult Vector::reserve(size_t new_sz) {// …// Try to reserve at the end of
old_ptrstatus = cuMemAddressReserve(&new_ptr, (aligned_sz - reserve_sz), 0ULL, old_ptr + reserve_sz, 0ULL);
if ((status != CUDA_SUCCESS) || (new_ptr != (old_ptr + reserve_sz)))
{ // Nope, something went wrong. You couldn’t get the address you wanted, // so fall back to the slow path.
if (new_ptr != 0ULL)
{ // Don’t leak new_ptr if you got one.
(void)cuMemAddressFree(new_ptr, (aligned_sz - reserve_sz));
} // Now reserve the new, bigger VA range. status = cuMemAddressReserve(&new_ptr, aligned_sz,0ULL, 0ULL, 0ULL); // You have a new address range reserved, so remap. // …
}

既然您有了VA範圍,就需要時間來創建所需的塊,映射它,並提供對它的訪問權限。存儲信息以供以後使用,如句柄和分配大小。

CUresult Vector::grow(size_t new_sz)
{// …// Pad the size to the correct granularity
padded_sz = ROUND_UP(new_sz - alloc_sz, chunk_sz);// Create the chunk that you need
cuMemCreate(&handle, padded_sz, &prop, 0);// Map it at the end of ptr
cuMemMap(ptr + alloc_sz, padded_sz, 0ULL, handle, 0ULL);// Set the access
cuMemSetAccess(ptr + alloc_sz, padded_sz, &accessDesc, 1ULL);// Keep track of the metadata (for later)
handles.push_back(handle);
handle_sizes.push_back(padded_sz);
}

在某些情況下,您可能無法在當前VA範圍之後立即保留相鄰的VA。可能是另一個分配。您可以退回到釋放虛擬地址並將其重新映射到新的更大地址範圍的較慢路徑。返回Vector::reserve並實現此回退路徑。

因爲句柄和大小是按分配順序隱藏的,所以您只需取消映射舊的VA範圍,然後在正確的偏移量將每個句柄映射到更大的VA範圍。下面的代碼示例顯示了這種情況:

CUresult Vector::reserve(size_t new_sz) {// …// You have a new address range reserved, so remap.
CUdeviceptr ptr = new_ptr;
cuMemUnmap(d_p, alloc_sz); // And remap them to the new VA range, enabling their access
for (size_t i = 0ULL; i < handles.size(); i++) {
const size_t hdl_sz = handle_sizes[i];
cuMemMap(ptr, hdl_sz, 0ULL, handles[i], 0ULL); ptr += hdl_sz;}
cuMemSetAccess(new_ptr, new_sz, &accessDesc, 1ULL);// Free up our previous VA range
for (size_t i = 0ULL; i < va_ranges.size(); i++)
{
cuMemAddressFree(va_ranges[i].start, va_ranges[i].sz);
}

這裏有一個新的CUDA虛擬內存管理功能的矢量類的工作實現。

Performance results

現在您開始看到使用CUDA虛擬內存管理功能的好處。雖然帶有保留的標準cumemaloc(cudamaloc)路徑是最快的,但它也是最佔用內存的路徑:它提交它保留的所有內存,即使它不需要它。cuMemAlloc without reservation方法中的內存使用峯值是您需要增加的額外分配。尖峯會隨着你需要增長的數量呈指數增長。

另一方面,對於帶有預保留的cumemalocmanaged版本,應用程序分配它需要保留的1 GiB。然後它調用cummprefetchasync並在向量需要增長時進行同步。如果沒有保留,應用程序會像在cudaMalloc實現中那樣分配一個更大的緩衝區並執行一個拷貝,但是在接觸到該分配之前,不會對其進行分頁。

因爲只觸及了分配的一部分(要複製到的部分),所以只需要前一個分配的大小。然後釋放舊的緩衝區,並預取未觸及的部分,確保您永遠不需要超過以前的緩衝區大小。也就是說,這個方法確實會釋放一個髒的分配回操作系統,在預取數組的未觸及部分之後,最終會得到一個乾淨的分配。

CUDA虛擬內存管理功能與cumemalocmanaged保持着密切的同步,但是在是否可以附加到VA範圍並因此返回到前面描述的慢路徑上存在一些抖動。即便如此,這條緩慢的路徑仍然比其他實現快得多。

當您使用cuMemAddressReserve預先保留整個VA範圍,並在增長時分配新塊並將其映射到中時,您會看到您與cumemalocmanaged+reserve非常匹配,甚至在64 MiB大小調整後擴展得更好。

由於在任何時候都不會分配比所需更多的內存,即使是慢速重新映射,也總是低於分配的預算,就像cumemalocmanaged一樣。這兩種方法的區別在於不需要複製到新緩衝區,因此將提交內存的需要推遲到“預取”或塊創建時間。

查看通過自己運行vector_example代碼可以獲得什麼樣的性能優勢。

Application use case: Join operation in OLAP

在數據分析中可以找到不斷增長的分配器的一個重要用例。數據庫應用程序中計算最密集的操作是連接操作。

聯接的輸出大小依賴於數據,並且事先不知道。通常,輸出大小估計器被實現以向探測內核提供輸出緩衝區。然而,一個估計永遠不是100%準確的,所以你最終會分配比需要更多的內存。如何將未使用的物理內存傳遞迴驅動程序?對於cudaMalloc,這將需要分配一個新的緩衝區,從舊的緩衝區複製數據,並釋放舊的緩衝區,類似於前面討論的不斷增長的分配示例,如圖6所示。

在這裏插入圖片描述
Figure 6. Example pseudo-code for the probe phase of a join operation. This includes resizing the join output buffer to free up unused GPU memory.

下面是RAPIDS cuDF 0.13 join實現中的相應代碼:

rmm::device_vector<size_type> left_indices;
rmm::device_vector<size_type> right_indices;…
left_indices.resize(estimated_size);
right_indices.resize(estimated_size); …
probe_hash_table<<<…>>>(…); …
join_size = write_index.value(); …
left_indices.resize(join_size);
right_indices.resize(join_size);

GPU內存分配/釋放和內存複製開銷隱藏在rmm::device_vector類中。當前實現的問題是,必須爲輸出緩衝區提供兩倍的可用GPU內存,並且在調整大小操作期間,可以很容易地耗盡內存。這正是前一節中提出的向量類可以解決的問題。

可以使用前面討論過的CUDA虛擬內存管理功能改進rmm::device_vector類,這將允許您支持更大的連接輸出,並通過刪除副本來提高性能。NVIDIA正在考慮將其添加到RAPIDS內存管理器庫中。

Use case: Avoiding device synchronization on cudaFree

今天使用cudaFree會產生應用程序所依賴的意外副作用:同步。當調用cudaFree時,設備上的任何正在運行的工作都將完成,並且調用該函數的CPU線程將被阻塞,直到完成所有這些工作。這有一些編程模型的優點和缺點,但是直到現在應用程序才真正能夠靈活地選擇不使用這種行爲。

使用CUDA虛擬內存管理功能,您不能假設在調用cummunmap或cummsetaccess期間先前的工作會同步。但是,這些功能可能在某些平臺配置上同步,例如具有Maxwell或較舊GPU架構的系統。

Example

下面的示例顯示了使用cudamaloc和cudaFree進行同步的效果。在這裏,N個獨立的線程都在獨立的、非阻塞的流上啓動工作。在理想的情況下,您應該在GPU上觀察N個併發的spinKernel啓動,並且每個流中很少有間隙。直觀地說,引入同時分配和釋放自己內存的線程0不應該有任何效果:

global void spinKernel(); // thread 1…N
while (keep_going) { spinKernel<<<1,1, stream[i]>>>();} // thread 0
for (size_t i = 0; i < 100; i++)
{ cudaMalloc(&x, 1);
cudaFree(x);
}

Optimizing

在所有CUDA虛擬內存管理調用中,重疊量都在增加。與以前的版本相比,在修改設備的內存佈局時,GPU上沒有任何地方沒有運行任何東西。

當在多GPU平臺中使用cudaEnablePeerAccess啓用點對點訪問時,您還可以使用cudaFree看到這種同步效果。在這種情況下,您最終會同步每個cudaFree調用上的所有對等映射設備,即使分配僅由單個設備使用。有了新的CUDA虛擬內存管理功能,這不再是一個問題。

Use case: Scalable peer mappings

cudaenableeracess函數用於啓用對等設備對分配的訪問,但在調用時,它會強制所有先前的cudamaloc分配映射到啓用的目標對等設備。此外,cudaenableeracess還強制將所有未來的cudamaloc分配映射到目標對等設備以及源設備。

爲了便於開發,自動對等映射是非常理想的,因爲它消除了跟蹤每個設備的分配映射狀態的需要,並且避免了調試可能遇到的無效設備地址訪問問題。

不幸的是,cudaEnablePeerAccess提供的易用性可能會帶來性能上的損失,而直接讀取源代碼是不明顯的。典型的cudaMalloc調用的運行時複雜性爲O(lg(N)),其中N是先前分配的數量。這主要是由於內部記賬。

同時,cudaenableeracessapi的運行時複雜性大約爲O(Nlg(N)),其中N是在源設備上進行的需要映射到目標設備的分配數。通常,這是爲每個設備對調用的,以啓用完全雙向對等訪問,即總O(DDNlg(N)),其中D是設備數。此外,如前所述,cudamaloc現在必須將其分配映射到啓用對等訪問的所有設備。這意味着運行時複雜性現在可以擴展爲O(D*lg(N))。

許多應用程序通常只需要使用少量分配進行通信,這意味着並非所有分配都必須映射到所有設備。但是,當您只需要一些映射時,就需要支付這些額外映射的成本。

這裏是新的CUDA虛擬內存管理功能可以幫助的地方。cuMemSetAccess函數允許您將特定分配目標設置爲對等映射到特定設備集。雖然這仍然隨着訪問它的設備的數量而變化,但是隻有一個設備的常見情況仍然是O(lg(N))。此外,您不再需要cudaenableeracess,讓cudamaloc調用速度更快,只在需要時支付額外映射的費用。

要了解多GPU處理在實際中的工作方式,請參閱vectorAddDrvMMAP示例。

Other notable use cases

下面是一些需要考慮的其他用例:

操作系統本機進程間通信

導出到圖形

Operating system native interprocess communication

新的CUDA虛擬內存管理功能不支持其內存中的傳統cuIpc*功能。相反,它們公開了一種新的進程間通信機制,這種機制在每個受支持的平臺上都能更好地工作。這種新機制是基於操作特定於系統的句柄。在Windows上,它們是HANDLE或D3DKMT_HANDLE類型,而在基於Linux的平臺上,它們是文件描述符。

爲了獲得這些特定於操作系統的句柄之一,引入了新函數cummexporttoshareablehandle。必須將適當的請求句柄類型傳遞給cuMemCreate。默認情況下,內存不可導出,因此可共享句柄不能與默認屬性一起使用。

將分配導出到特定於操作系統的句柄後,可以按通常的方式將句柄傳輸到另一個進程:Linux可以使用Unix域套接字,Windows可以使用DuplicateHandle。然後,另一個進程可以使用cummimportfromshareablehandle並返回CUDA虛擬內存管理函數可以使用的cummgenericallocationhandle值。

CUDA示例memMapIpcDrv顯示了這在實踐中的工作方式。此示例適用於支持CUDA虛擬內存管理功能的所有Linux和Windows平臺。

Export to graphics

有些情況下,您希望CUDA應用程序在完全無頭模式下工作,而不涉及任何圖形。其他時候,就像大型基於物理的模擬一樣,您必須以某種方式可視化結果。

在CUDA 10.2之前,應用程序和庫必須提前知道他們想要爲圖形導出內存,以及他們需要使用或綁定到什麼圖形庫。然後,他們必須實現該圖形庫的代碼來分配內存並將其導入到CUDA中使用。

或者,他們可以要求應用程序向臨時緩衝區發出memcpy調用,該緩衝區已經註冊到應用程序所需的圖形庫中。然而,如前所述,memcpy增加了很多延遲,浪費了內存帶寬。

遵循用於進程間通信的相同代碼路徑,您還可以將操作系統特定的共享句柄用於其他用戶模式驅動程序,如Vulkan或OpenGL。這允許您使用CUDA虛擬內存管理功能分配內存,並將該內存導入所有支持操作系統特定句柄的圖形庫。

雖然我們還沒有公開此特定功能的示例,但您可以查看以下Vulkan和OpenGL擴展,並將其與前面的memMapIpcDrv示例組合在一起:

·
VkMemoryAllocateInfo

·
GL_EXT_memory_objects

Conclusion

CUDA 10.2引入了新的CUDA虛擬內存管理功能。這些新功能支持許多新的用例和性能優化,使用CUDA的應用程序可以利用這些新的用例和性能優化。我們在這篇文章中描述了其中的一些用例,但是我們有興趣瞭解您可以如何使用這個新特性。

看看與CUDA 10.2工具包一起發佈的一些CUDA示例,或者查看本文中引用的完整代碼示例。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章