异构内存管理 (HMM) 是一项 CUDA 内存管理功能,它扩展了 CUDA 统一内存编程模型的简单性和生产力,以包括具有 PCIe 连接的 NVIDIA GPU 的系统上的系统分配内存。 系统分配内存是指最终由操作系统分配的内存; 例如,通过 malloc、mmap、C++ new 运算符(当然使用前面的机制)或为应用程序设置 CPU 可访问内存的相关系统例程。
以前,在基于 PCIe 的计算机上,GPU 无法直接访问系统分配的内存。 GPU 只能访问来自特殊分配器(例如 cudaMalloc 或 cudaMallocManaged)的内存。
启用 HMM 后,所有应用程序线程(GPU 或 CPU)都可以直接访问应用程序的所有系统分配内存。 与统一内存(可以被视为 HMM 的子集或前身)一样,无需在处理器之间手动复制系统分配的内存。 这是因为它会根据处理器使用情况自动放置在 CPU 或 GPU 上。
在 CUDA 驱动程序堆栈中,CPU 和 GPU 页面错误通常用于发现内存应放置在何处。 同样,这种自动放置已经在统一内存中发生 - HMM 只是扩展了行为以覆盖系统分配的内存以及 cudaMalloc 托管内存。
这种直接读取或写入完整应用程序内存地址空间的新功能将显着提高基于 CUDA 构建的所有编程模型的程序员生产力:CUDA C++、Fortran、Python 中的标准并行性、ISO C++、ISO Fortran、OpenACC、OpenMP、 以及许多其他人。
事实上,正如接下来的示例所示,HMM 简化了 GPU 编程,使得 GPU 编程几乎与 CPU 编程一样易于访问。 一些亮点:
顺便说一句,新的硬件平台(例如 NVIDIA Grace Hopper)通过所有 CPU 和 GPU 之间基于硬件的内存一致性来原生支持统一内存编程模型。 对于这样的系统,HMM 不是必需的,事实上,HMM 在那里被自动禁用。 思考这个问题的一种方法是观察 HMM 实际上是一种基于软件的方式,提供与 NVIDIA Grace Hopper Superchip 相同的编程模型。
2013 年推出的原始 CUDA 统一内存功能使您只需进行少量更改即可加速 CPU 程序,如下所示:
void sortfile(FILE* fp, int N) {
char* data;
data = (char*)malloc(N);
fread(data, 1, N, fp);
qsort(data, N, 1, cmp);
use_data(data);
free(data);
}
void sortfile(FILE* fp, int N) {
char* data;
cudaMallocManaged(&data, N);
fread(data, 1, N, fp);
qsort<<<...>>>(data, N, 1, cmp);
cudaDeviceSynchronize();
use_data(data);
cudaFree(data);
}
这种编程模型简单、清晰且功能强大。 在过去的 10 年里,这种方法使无数应用程序能够轻松地从 GPU 加速中受益。 然而,仍然有改进的空间:注意需要一个特殊的分配器:cudaMallocManaged 和相应的 cudaFree。
如果我们可以更进一步,摆脱这些呢? 这正是 HMM 所做的。
在具有 HMM 的系统上(详细信息如下),继续使用 malloc 和 free:
void sortfile(FILE* fp, int N) {
char* data;
data = (char*)malloc(N);
fread(data, 1, N, fp);
qsort(data, N, 1, cmp);
use_data(data);
free(data);
}
void sortfile(FILE* fp, int N) {
char* data;
data = (char*)malloc(N);
fread(data, 1, N, fp);
qsort<<<...>>>(data, N, 1, cmp);
cudaDeviceSynchronize();
use_data(data);
free(data)
}
使用 HMM,现在两者之间的内存管理是相同的。
使用 CUDA 内存分配器的 GPU 应用程序在具有 HMM 的系统上“按原样”工作。 这些系统的主要区别在于,像 malloc、C++ new 或 mmap 这样的系统分配 API 现在创建可以从 GPU 线程访问的分配,而无需调用任何 CUDA API 来告诉 CUDA 这些分配的存在。 下表列出了具有 HMM 的系统上最常见的 CUDA 内存分配器之间的差异:
Memory allocators on systems with HMM | Placement | Migratable | Accessible from: | ||
CPU | GPU | RDMA | |||
System allocatedmalloc , mmap , … |
First-touch GPU or CPU |
Y | Y | Y | Y |
CUDA managedcudaMallocManaged |
Y | Y | Y | N | |
CUDA device-onlycudaMalloc , … |
GPU | N | N | Y | Y |
CUDA host-pinnedcudaMallocHost , … |
CPU | N | Y | Y | Y |
一般来说,选择更好地表达应用程序意图的分配器可以使 CUDA 提供更好的性能。 借助 HMM,这些选择就变成了性能优化,无需在首次从 GPU 访问内存之前预先完成。 HMM 使开发人员能够首先专注于并行化算法,然后在开销提高性能时执行与内存分配器相关的优化。
C++、Fortran 和 Python 的无缝 GPU 加速
HMM 使使用标准化和可移植的编程语言(例如 Python)对 NVIDIA GPU 进行编程变得更加容易,Python 不区分 CPU 和 GPU 内存,并假设所有线程都可以访问所有内存,以及 ISO Fortran 和 ISO C++ 等国际标准描述的编程语言 。
这些语言提供并发和并行设施,使实现能够自动将计算分派到 GPU 和其他设备。 例如,自 C++ 2017 以来,
头文件中的标准库算法接受执行策略,使实现能够并行运行它们。
例如,在 HMM 之前,对大于 CPU 内存的文件进行就地排序非常复杂,需要先对文件的较小部分进行排序,然后将它们合并为完全排序的文件。 通过HMM,应用程序可以使用mmap将磁盘上的文件映射到内存中,并直接从GPU读取和写入它。 更多详情请参见GitHub上的HMM示例代码file_before.cpp和file_after.cpp。
void sortfile(FILE* fp, int N) {
std::vector<char> buffer;
buffer.resize(N);
fread(buffer.data(), 1, N, fp);
// std::sort runs on the GPU:
std::sort(std::execution::par,
buffer.begin(), buffer.end(),
std::greater{});
use_data(std::span{buffer});
}
void sortfile(int fd, int N) {
auto buffer = (char*)mmap(NULL, N,
PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
// std::sort runs on the GPU:
std::sort(std::execution::par,
buffer, buffer + N,
std::greater{});
use_data(std::span{buffer});
}
使用 -stdpar=gpu 选项时,并行 std::sort 算法的 NVIDIA C++ 编译器 (NVC++) 实现会对 GPU 上的文件进行排序。 该选项的使用有很多限制,HPC SDK 文档中有详细说明。
在 HMM 之前:GPU 只能访问 NVC++ 编译的代码中堆上动态分配的内存。 也就是说,CPU 线程堆栈上的自动变量、全局变量和内存映射文件无法从 GPU 访问(请参见下面的示例)。
HMM之后:GPU可以访问所有系统分配的内存,包括其他编译器和第三方库编译的CPU代码中动态分配在堆上的数据、CPU线程堆栈上的自动变量、CPU内存中的全局变量、内存映射文件等
HMM 支持所有内存操作,其中包括原子内存操作。 也就是说,程序员可以使用原子内存操作来将 GPU 和 CPU 线程与标志同步。 虽然 C++ std::atomic API 的某些部分使用 GPU 上尚不可用的系统调用(例如 std::atomic::wait 和 std::atomic::notify_all/_one API),但大多数 C++ 并发原语 API 可轻松用于在 GPU 和 CPU 线程之间执行消息传递。
有关更多信息,请参阅 GitHub 上的 HPC SDK C++ 并行算法:与 C++ 标准库的互操作性文档和atomic_flag.cpp HMM 示例代码。 您可以使用 CUDA C++ 扩展此集。 有关更多详细信息,请参阅 GitHub 上的 Ticket_lock.cpp HMM 示例代码。
void main() {
// Variables allocated with cudaMallocManaged
std::atomic<int>* flag;
int* msg;
cudaMallocManaged(&flag, sizeof(std::atomic<int>));
cudaMallocManaged(&msg, sizeof(int));
new (flag) std::atomic<int>(0);
*msg = 0;
// Start a different CPU thread…
auto t = std::jthread([&] {
// … that launches and waits
// on a GPU kernel completing
std::for_each_n(
std::execution::par,
&msg, 1, [&](int& msg) {
// GPU thread writes message…
*msg = 42; // all accesses via ptrs
// …and signals completion…
flag->store(1); // all accesses via ptrs
});
});
void main() {
// Variables on CPU thread stack:
std::atomic<int> flag = 0; // Atomic
int msg = 0; // Message
// Start a different CPU thread…
auto t = std::jthread([&] {
// … that launches and waits
// on a GPU kernel completing
std::for_each_n(
std::execution::par,
&msg, 1, [&](int& msg) {
// GPU thread writes message…
msg = 42;
// …and signals completion…
flag.store(1);
});
});
// CPU thread waits on GPU thread
while (flag.load() == 0);
// …and reads the message:
std::cout << msg << std::endl;
// …the GPU kernel and thread
// may still be running here…
}
void main() {
// Variables allocated with cudaMallocManaged
ticket_lock* lock; // Lock
int* msg; // Message
cudaMallocManaged(&lock, sizeof(ticket_lock));
cudaMallocManaged(&msg, sizeof(int));
new (lock) ticket_lock();
*msg = 0;
// Start a different CPU thread…
auto t = std::jthread([&] {
// … that launches and waits
// on a GPU kernel completing
std::for_each_n(
std::execution::par,
&msg, 1, [&](int& msg) {
// GPU thread takes lock…
auto g = lock->guard();
// … and sets message (no atomics)
msg += 1;
}); // GPU thread releases lock here
});
{ // Concurrently with GPU thread
// … CPU thread takes lock…
auto g = lock->guard();
// … and sets message (no atomics)
msg += 1;
} // CPU thread releases lock here
t.join(); // Wait on GPU kernel completion
std::cout << msg << std::endl;
}
void main() {
// Variables on CPU thread stack:
ticket_lock lock; // Lock
int msg = 0; // Message
// Start a different CPU thread…
auto t = std::jthread([&] {
// … that launches and waits
// on a GPU kernel completing
std::for_each_n(
std::execution::par,
&msg, 1, [&](int& msg) {
// GPU thread takes lock…
auto g = lock.guard();
// … and sets message (no atomics)
msg += 1;
}); // GPU thread releases lock here
});
{ // Concurrently with GPU thread
// … CPU thread takes lock…
auto g = lock.guard();
// … and sets message (no atomics)
msg += 1;
} // CPU thread releases lock here
t.join(); // Wait on GPU kernel completion
std::cout << msg << std::endl;
}
研究大型且寿命长的 HPC 应用程序的研究小组多年来一直渴望为异构平台提供更高效、更可移植的编程模型。 m-AIA 是一款多物理场求解器,由德国亚琛工业大学空气动力学研究所开发,包含近 300,000 行代码。 有关更多信息,请参阅使用 OpenACC 加速 C++ CFD 代码。 最初的原型不再使用 OpenACC,而是使用上述 ISO C++ 编程模型在 GPU 上进行部分加速,而原型工作完成时该模型尚不可用。
HMM 使我们的团队能够加速新的 m-AIA 工作负载,这些工作负载与 GPU 无关的第三方库(例如 FFTW 和 pnetcdf)接口,这些库用于初始条件和 I/O,并且不关心 GPU 直接访问相同的内存。
HMM 提供的一项有趣功能是直接来自 GPU 的内存映射文件 I/O。 它使开发人员能够直接从支持的存储或/磁盘读取文件,而无需将文件暂存在系统内存中,也无需将数据复制到高带宽 GPU 内存。 这还使应用程序开发人员能够轻松处理大于可用物理系统内存的输入数据,而无需构建迭代数据摄取和计算工作流程。
为了演示此功能,我们的团队编写了一个示例应用程序,该应用程序根据 ERA5 再分析数据集构建一年中每一天的每小时总降水量直方图。 有关更多详细信息,请参阅 ERA5 全局重新分析。
ERA5 数据集由几个大气变量的每小时估计值组成。 在数据集中,每个月的总降水量数据存储在单独的文件中。 我们使用了 1981 年至 2020 年 40 年的总降水量数据,总计 480 个输入文件,总输入数据大小约为 1.3 TB。 结果示例请参见下图。
使用 Unix mmap API,可以将输入文件映射到连续的虚拟地址空间。 使用 HMM,这个虚拟地址可以作为输入传递到 CUDA 内核,然后该内核可以直接访问这些值以构建一年中所有天每小时的总降水量直方图。
生成的直方图将驻留在 GPU 内存中,可用于轻松计算有趣的统计数据,例如北半球的平均月降水量。 例如,我们还计算了二月和八月的平均小时降水量。 要查看此应用程序的代码,请访问 GitHub 上的 HMM_sample_code。
size_t chunk_sz = 70_gb;
std::vector<char> buffer(chunk_sz);
for (fp : files)
for (size_t off = 0; off < N; off += chunk_sz) {
fread(buffer.data(), 1, chunk_sz, fp);
cudeMemcpy(dev, buffer.data(), chunk_sz, H2D);
histogram<<<...>>>(dev, N, out);
cudaDeviceSynchronize();
}
void* buffer = mmap(NULL, alloc_size,
PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS,
-1, 0);
for (fd : files)
mmap(buffer+file_offset, fileByteSize,
PROT_READ, MAP_PRIVATE|MAP_FIXED, fd, 0);
histogram<<<...>>>(buffer, total_N, out);
cudaDeviceSynchronize();
每当 CUDA 工具包和驱动程序检测到您的系统可以处理它时,它就会自动启用 HMM。 这些要求详细记录在 CUDA 12.2 发行说明:通用 CUDA 中。 你需要:
查询寻址模式属性以验证 HMM 是否已启用:
$ nvidia-smi -q | grep Addressing
Addressing Mode : HMM
要检测 GPU 可以访问系统分配的内存的系统,请查询 cudaDevAttrPageableMemoryAccess
。
此外,NVIDIA Grace Hopper Superchip 等系统支持 ATS,其行为与 HMM 类似。 事实上,HMM 和 ATS 系统的编程模型是相同的,因此对于大多数程序来说,仅检查 cudaDevAttrPageableMemoryAccess
就足够了。
然而,对于性能调整和其他高级编程,还可以通过查询 cudaDevAttrPageableMemoryAccessUsesHostPageTables
来区分 HMM 和 ATS。 下表显示了如何解释结果。
Attribute | HMM | ATS |
---|---|---|
cudaDevAttrPageableMemoryAccess | 1 | 1 |
cudaDevAttrPageableMemoryAccessUsesHostPageTables | 0 | 1 |
对于只对查询 HMM 或 ATS 公开的编程模型是否可用感兴趣的可移植应用程序,查询“可分页内存访问”属性通常就足够了。
预先存在的统一内存性能提示的语义没有变化。 对于已经在 NVIDIA Grace Hopper 等硬件一致性系统上使用 CUDA 统一内存的应用程序,主要的变化是 HMM 使它们能够在上述限制内“按原样”在更多系统上运行。
预先存在的统一内存提示也适用于 HMM 系统上的系统分配内存:
__host__ cudaError_t cudaMemPrefetchAsync(* ptr, size_t nbytes, int device):
__host__ cudaError_tcudaMemAdvise(*ptr, size_t nbytes, cudaMemoryAdvise,advice, int device)
: 提示系统:cudaMemAdviseSetPreferredLocation
,或cudaMemAdviseSetAccessedBy
,或cudaMemAdviseSetReadMostly
。更高级一点:有一个新的 CUDA 12.2 API cudaMemAdvise_v2,它使应用程序能够选择给定内存范围应该首选哪个 NUMA 节点。 当 HMM 将内存内容放在 CPU 端时,这一点就会发挥作用。
与往常一样,内存管理提示可能会提高或降低性能。 行为取决于应用程序和工作负载,但任何提示都不会影响应用程序的正确性。
CUDA 12.2 中的初始 HMM 实现提供了新功能,而不会降低任何现有应用程序的性能。 CUDA 12.2 中 HMM 的限制详细记录在 CUDA 12.2 发行说明:通用 CUDA 中。 主要限制是:
请继续关注未来的 CUDA 驱动程序更新,这些更新将解决 HMM 限制并提高性能。
HMM 通过消除对在常见的基于 PCIe(通常是 x86)计算机上运行的 GPU 程序的显式内存管理的需要,简化了编程模型。 程序员可以直接使用 malloc、C++ new 和 mmap 调用,就像他们在 CPU 编程中所做的那样。
HMM 通过在 CUDA 程序中安全地使用各种标准编程语言功能,进一步提高程序员的工作效率。 无需担心意外地将系统分配的内存暴露给 CUDA 内核。
HMM 可实现与新的 NVIDIA Grace Hopper Superchip 和类似机器之间的无缝过渡。 在基于 PCIe 的机器上,HMM 提供与 NVIDIA Grace Hopper Superchip 上使用的相同的简化编程模型。