低层级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_ptr
status = 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(N*lg(N)),其中N是在源设备上进行的需要映射到目标设备的分配数。通常,这是为每个设备对调用的,以启用完全双向对等访问,即总O(D*D*N*lg(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示例组合在一起:
Conclusion
CUDA 10.2引入了新的CUDA虚拟内存管理功能。这些新功能支持许多新的用例和性能优化,使用CUDA的应用程序可以利用这些新的用例和性能优化。我们在这篇文章中描述了其中的一些用例,但是我们有兴趣了解您可以如何使用这个新特性。
看看与CUDA 10.2工具包一起发布的一些CUDA示例,或者查看本文中引用的完整代码示例。