4.1. 统一内存
本节解释了各种可用的统一内存范式的详细行为和用法。前面有关统一内存的章节展示了如何确定适用的统一内存范式,并简要介绍了每种范式。
如前所述,统一内存编程有四种范式:
前面三种提供完整统一内存支持的范式具有非常相似的行为和编程模型,完全支持 CUDA 统一内存的设备上的统一内存中介绍了这些范式,并重点指出了它们之间的差异。
最后一种范式的统一内存支持有限,Windows、WSL 和 Tegra 上的统一内存中对此进行了详细讨论。
4.1.1. 完全支持 CUDA 统一内存的设备上的统一内存
这些系统包括 NVIDIA Grace Hopper 等硬件相干内存系统,以及启用了异构内存管理(HMM)的现代 Linux 系统。HMM 是一种基于软件的内存管理系统,提供与硬件相干内存系统相同的编程模型。
Linux HMM 要求 Linux 内核版本为 6.1.24+、6.2.11+ 或 6.3+,设备的计算能力为 7.5 或更高,并且使用开源内核模块安装了版本为 535+ 的 CUDA 驱动。
注意
我们将 CPU 和 GPU 共用一张页表的系统称为硬件相干系统。CPU 和 GPU 使用独立页表的系统则称为软件相干系统。
NVIDIA Grace Hopper 等硬件相干系统为 CPU 和 GPU 提供一张在逻辑上合并的页表,请参见CPU 和 GPU 页表:硬件相干性与软件相干性。以下章节仅适用于硬件相干系统:
4.1.1.1. 统一内存:深入示例
完全支持 CUDA 统一内存的系统允许设备访问与该设备交互的主机进程所拥有的任何内存,请参见统一内存范式概览表。
本节展示几个高级用例,使用的核函数只是将输入字符数组的前 8 个字符打印到标准输出流:
__global__ void kernel(const char* type, const char* data) {
static const int n_char = 8;
printf("%s - first %d characters: '", type, n_char);
for (int i = 0; i < n_char; ++i) printf("%c", data[i]);
printf("'\n");
}
以下选项卡展示了使用系统分配的内存调用此核函数的各种方式:
void test_malloc() {
const char test_string[] = "Hello World";
char* heap_data = (char*)malloc(sizeof(test_string));
strncpy(heap_data, test_string, sizeof(test_string));
kernel<<<1, 1>>>("malloc", heap_data);
ASSERT(cudaDeviceSynchronize() == cudaSuccess,
"CUDA failed with '%s'", cudaGetErrorString(cudaGetLastError()));
free(heap_data);
}
void test_managed() {
const char test_string[] = "Hello World";
char* data;
cudaMallocManaged(&data, sizeof(test_string));
strncpy(data, test_string, sizeof(test_string));
kernel<<<1, 1>>>("managed", data);
ASSERT(cudaDeviceSynchronize() == cudaSuccess,
"CUDA failed with '%s'", cudaGetErrorString(cudaGetLastError()));
cudaFree(data);
}
void test_stack() {
const char test_string[] = "Hello World";
kernel<<<1, 1>>>("stack", test_string);
ASSERT(cudaDeviceSynchronize() == cudaSuccess,
"CUDA failed with '%s'", cudaGetErrorString(cudaGetLastError()));
}
void test_static() {
static const char test_string[] = "Hello World";
kernel<<<1, 1>>>("static", test_string);
ASSERT(cudaDeviceSynchronize() == cudaSuccess,
"CUDA failed with '%s'", cudaGetErrorString(cudaGetLastError()));
}
const char global_string[] = "Hello World";
void test_global() {
kernel<<<1, 1>>>("global", global_string);
ASSERT(cudaDeviceSynchronize() == cudaSuccess,
"CUDA failed with '%s'", cudaGetErrorString(cudaGetLastError()));
}
// declared in separate file, see below
extern char* ext_data;
void test_extern() {
kernel<<<1, 1>>>("extern", ext_data);
ASSERT(cudaDeviceSynchronize() == cudaSuccess,
"CUDA failed with '%s'", cudaGetErrorString(cudaGetLastError()));
}
/** This may be a non-CUDA file */
char* ext_data;
static const char global_string[] = "Hello World";
void __attribute__ ((constructor)) setup(void) {
ext_data = (char*)malloc(sizeof(global_string));
strncpy(ext_data, global_string, sizeof(global_string));
}
void __attribute__ ((destructor)) tear_down(void) {
free(ext_data);
}
请注意,extern 变量可以由完全不与 CUDA 交互的第三方库声明,其内存也可以由该库拥有和管理。
还要注意,GPU 只能通过指针访问栈变量以及文件作用域和全局作用域变量。在这个特定示例中,这很方便,因为字符数组已经声明为指针:const char*。不过,请考虑下面这个使用全局作用域整数的示例:
// this variable is declared at global scope
int global_variable;
__global__ void kernel_uncompilable() {
// this causes a compilation error: global (__host__) variables must not
// be accessed from __device__ / __global__ code
printf("%d\n", global_variable);
}
// On systems with pageableMemoryAccess set to 1, we can access the address
// of a global variable. The below kernel takes that address as an argument
__global__ void kernel(int* global_variable_addr) {
printf("%d\n", *global_variable_addr);
}
int main() {
kernel<<<1, 1>>>(&global_variable);
...
return 0;
}
在上面的示例中,我们需要确保将指向全局变量的指针传递给核函数,而不是在核函数中直接访问全局变量。这是因为不带__managed__说明符的全局变量默认声明为仅限__host__使用,因此目前大多数编译器不允许在设备代码中直接使用这些变量。
4.1.1.1.1. 以文件为后备的统一内存
由于完全支持 CUDA 统一内存的系统允许设备访问主机进程所拥有的任何内存,因此设备可以直接访问以文件为后备的内存。
这里展示上一节中最初示例的一个修改版本,使用以文件为后备的内存,从 GPU 打印直接从输入文件读取的字符串。在以下示例中,内存以物理文件为后备,但该示例也适用于以内存为后备的文件。
__global__ void kernel(const char* type, const char* data) {
static const int n_char = 8;
printf("%s - first %d characters: '", type, n_char);
for (int i = 0; i < n_char; ++i) printf("%c", data[i]);
printf("'\n");
}
void test_file_backed() {
int fd = open(INPUT_FILE_NAME, O_RDONLY);
ASSERT(fd >= 0, "Invalid file handle");
struct stat file_stat;
int status = fstat(fd, &file_stat);
ASSERT(status >= 0, "Invalid file stats");
char* mapped = (char*)mmap(0, file_stat.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
ASSERT(mapped != MAP_FAILED, "Cannot map file into memory");
kernel<<<1, 1>>>("file-backed", mapped);
ASSERT(cudaDeviceSynchronize() == cudaSuccess,
"CUDA failed with '%s'", cudaGetErrorString(cudaGetLastError()));
ASSERT(munmap(mapped, file_stat.st_size) == 0, "Cannot unmap file");
ASSERT(close(fd) == 0, "Cannot close file");
}
请注意,不具备hostNativeAtomicSupported属性的系统(请参见主机原生原子操作),包括启用了 Linux HMM 的系统,不支持对以文件为后备的内存进行原子访问。
4.1.1.1.2. 使用统一内存进行进程间通信(IPC)
注意
目前,将 IPC 与统一内存结合使用可能对性能产生显著影响。
许多应用程序倾向于每个进程管理一个 GPU,但仍然需要使用统一内存,例如用于超额订用,并从多个 GPU 访问它。
CUDA IPC(请参见进程间通信)不支持托管内存:不能通过本节讨论的任何机制共享此类内存的句柄。在完全支持 CUDA 统一内存的系统上,系统分配的内存能够用于 IPC。一旦与其他进程共享了对系统分配的内存的访问权限,同样的编程模型便适用,类似于以文件为后备的统一内存。
有关在 Linux 下创建可用于 IPC 的系统分配内存的各种方式的更多信息,请参见以下参考资料:
请注意,无法使用这种技术在不同主机及其设备之间共享内存。
4.1.1.2. 性能调优
为了使统一内存获得良好的性能,以下几点很重要:
了解分页在系统上的工作方式,以及如何避免不必要的缺页异常
了解使数据保持在访问它的处理器本地的各种机制
考虑根据系统的内存传输粒度对应用程序进行调优。
一般而言,性能提示(请参见性能提示)可能会提高性能,但使用不当可能使性能比默认行为更差。还要注意,任何提示都会在主机上带来相应的性能开销,因此有用的提示至少必须带来足以抵消这一开销的性能提升。
4.1.1.2.1. 内存分页与页大小
要更好地理解统一内存对性能的影响,了解虚拟寻址、内存页和页大小很重要。本小节尝试定义所有必要的术语,并解释为什么分页对性能很重要。
目前支持统一内存的所有系统都使用虚拟地址空间:这意味着应用程序使用的内存地址表示一个虚拟位置,该位置可能会被映射到内存实际驻留的物理位置。
目前支持的所有处理器,包括 CPU 和 GPU,还使用内存分页。由于所有系统都使用虚拟地址空间,因此存在两种类型的内存页:
虚拟页:这是操作系统按进程跟踪的一块固定大小的连续虚拟内存,可以将其映射到物理内存中。请注意,虚拟页与映射相关联:例如,可以使用不同的页大小将同一个虚拟地址映射到物理内存中。
物理页:这是处理器的主内存管理单元(MMU)支持的一块固定大小的连续内存,虚拟页可以映射到其中。
目前,所有 x86_64 CPU 使用的默认物理页大小为 4KiB。Arm CPU 支持多种物理页大小,包括 4KiB、16KiB、32KiB 和 64KiB,具体取决于 CPU 型号。最后,NVIDIA GPU 支持多种物理页大小,但更倾向于使用 2MiB 或更大的物理页。请注意,这些大小在未来的硬件中可能会改变。
虚拟页的默认页大小通常与物理页大小相对应,但只要操作系统和硬件支持,应用程序就可以使用不同的页大小。通常,受支持的虚拟页大小必须是 2 的幂,并且是物理页大小的倍数。
跟踪虚拟页到物理页映射的逻辑实体称为页表,而将具有给定虚拟大小的某个虚拟页映射到物理页的每个映射称为页表项(PTE)。所有受支持的处理器都为页表提供专用缓存,以加速虚拟地址到物理地址的转换。这些缓存称为转换后备缓冲区(TLB)。
应用程序性能调优有两个重要方面:
虚拟页大小的选择,
系统是提供一张由 CPU 和 GPU 共用的合并页表,还是为每个 CPU 和 GPU 分别提供独立页表。
4.1.1.2.1.1. 选择合适的页大小
一般而言,较小的页大小会带来较少的(虚拟)内存碎片,但会带来更多的 TLB 未命中;较大的页大小则会带来更多的内存碎片,但 TLB 未命中更少。此外,与较小的页大小相比,较大的页大小通常使内存迁移的开销更高,因为我们通常会迁移完整的内存页。这可能导致使用大页的应用程序出现更大的延迟峰值。有关缺页异常的更多详细信息,另请参见下一节。
性能调优的一个重要方面是,与 CPU 相比,GPU 上 TLB 未命中的开销通常要大得多。这意味着,如果 GPU 线程频繁访问使用足够小的页大小映射的统一内存中的随机位置,那么与对使用足够大的页大小映射的统一内存进行相同访问相比,其速度可能会慢得多。虽然当 CPU 线程随机访问使用小页大小映射的大块内存区域时,也可能出现类似的影响,但速度下降没有那么明显,因此应用程序可能希望在这种速度下降与减少内存碎片之间进行权衡。
请注意,一般而言,应用程序不应针对给定处理器的物理页大小进行性能调优,因为物理页大小可能因硬件而变化。上述建议仅适用于虚拟页大小。
4.1.1.2.1.2. CPU 和 GPU 页表:硬件相干性与软件相干性
NVIDIA Grace Hopper 等硬件相干系统为 CPU 和 GPU 提供了逻辑上合并的页表。这一点很重要,因为为了从 GPU 访问系统分配的内存,GPU 会使用 CPU 为所请求的内存创建的页表项。如果该页表项使用默认的 CPU 页大小 4KiB 或 64KiB,那么对大块虚拟内存区域的访问将导致大量 TLB 未命中,从而造成显著的速度下降。
另一方面,在 CPU 和 GPU 各自拥有独立逻辑页表的软件相干系统上,应考虑不同的性能调优方面:为了保证相干性,当处理器访问映射到另一处理器物理内存中的内存地址时,这些系统通常使用缺页异常。这种缺页异常意味着:
需要通过删除或更新页表项,确保当前拥有该页的处理器(物理页当前所在的处理器)无法再访问该页。
需要通过创建新的页表项或更新现有页表项,使其变为有效/活动状态,从而确保请求访问的处理器能够访问该页。
支撑该虚拟页的物理页必须移动/迁移到请求访问的处理器:这可能是一项开销很大的操作,其工作量与页大小成正比。
总体而言,在 CPU 和 GPU 线程频繁并发访问同一内存页的情况下,与软件相干系统相比,硬件相干系统具有显著的性能优势:
更少的缺页异常:这些系统无需使用缺页异常来模拟相干性或迁移内存,
更少的争用:这些系统以缓存行粒度而非页大小粒度保持相干性,也就是说,当多个处理器在同一缓存行内发生争用时,只交换缓存行,而缓存行比最小的页大小还要小得多;当不同处理器访问同一页内的不同缓存行时,则不会发生争用。
这会影响以下场景的性能:
CPU 和 GPU 同时对同一地址进行原子更新
CPU 线程向 GPU 线程发送信号,或反过来。
4.1.1.2.1.3. 混合使用硬件相干性和软件相干性
某些具有硬件相干性的系统(例如 NVIDIA DGX Station)也支持安装独立的非相干 GPU 硬件。来自硬件相干 GPU 的访问将继续使用CPU 和 GPU 页表:硬件相干性与软件相干性中所述的基于硬件的相干性,而来自独立 GPU 的访问将使用基于软件的相干性。
这两类 GPU 之间允许共享统一地址空间。访问所用的 GPU 不同,会导致不同的性能和迁移行为。具体而言,来自软件相干 GPU 的访问会产生更多缺页异常和内存迁移,而来自硬件相干 GPU 的访问产生的缺页异常较少,并会在可能的情况下进行远程映射。
为了获得最佳性能,应限制两个 GPU 之间的数据共享,或通过显式复制进行共享。或者,应采取调用cudaMemAdviseSetPreferredLocation等措施,以确保频繁共享的数据在物理上仍驻留于 CPU 内存或相干 GPU 内存中,因为默认情况下,访问软件相干内存需要发生缺页异常并迁移数据。
在混合相干性系统上,对于软件相干 GPU,cudaHostRegister及其他主机内存访问 API 的行为也会发生变化。混合相干性系统上的软件相干 GPU 不使用页锁定映射,而是使用 CPU 页表的软件镜像。这意味着某些 GPU 访问可能发生缺页异常,而在非混合相干性系统上则不会,尽管此类缺页异常很少见,并且应仅在内存压力下发生。
4.1.1.2.2. 从主机直接访问统一内存
某些设备在硬件上支持主机对驻留于 GPU 的统一内存进行相干的读取、存储和原子访问。这些设备的属性cudaDevAttrDirectManagedMemAccessFromHost设置为 1。请注意,所有硬件相干系统都会为通过 NVLink 连接的设备设置此属性。在这些系统上,主机可以直接访问驻留于 GPU 的内存,而不会发生缺页异常和数据迁移。请注意,对于 CUDA 托管内存,必须使用位置类型为cudaMemLocationTypeHost的cudaMemAdviseSetAccessedBy提示,才能启用这种不发生缺页异常的直接访问,请参见下面的示例。
__global__ void write(int *ret, int a, int b) {
ret[threadIdx.x] = a + b + threadIdx.x;
}
__global__ void append(int *ret, int a, int b) {
ret[threadIdx.x] += a + b + threadIdx.x;
}
void test_malloc() {
int *ret = (int*)malloc(1000 * sizeof(int));
// for shared page table systems, the following hint is not necesary
cudaMemLocation location = {.type = cudaMemLocationTypeHost};
cudaMemAdvise(ret, 1000 * sizeof(int), cudaMemAdviseSetAccessedBy, location);
write<<< 1, 1000 >>>(ret, 10, 100); // pages populated in GPU memory
cudaDeviceSynchronize();
for(int i = 0; i < 1000; i++)
printf("%d: A+B = %d\n", i, ret[i]); // directManagedMemAccessFromHost=1: CPU accesses GPU memory directly without migrations
// directManagedMemAccessFromHost=0: CPU faults and triggers device-to-host migrations
append<<< 1, 1000 >>>(ret, 10, 100); // directManagedMemAccessFromHost=1: GPU accesses GPU memory without migrations
cudaDeviceSynchronize(); // directManagedMemAccessFromHost=0: GPU faults and triggers host-to-device migrations
free(ret);
}
__global__ void write(int *ret, int a, int b) {
ret[threadIdx.x] = a + b + threadIdx.x;
}
__global__ void append(int *ret, int a, int b) {
ret[threadIdx.x] += a + b + threadIdx.x;
}
void test_managed() {
int *ret;
cudaMallocManaged(&ret, 1000 * sizeof(int));
cudaMemLocation location = {.type = cudaMemLocationTypeHost};
cudaMemAdvise(ret, 1000 * sizeof(int), cudaMemAdviseSetAccessedBy, location); // set direct access hint
write<<< 1, 1000 >>>(ret, 10, 100); // pages populated in GPU memory
cudaDeviceSynchronize();
for(int i = 0; i < 1000; i++)
printf("%d: A+B = %d\n", i, ret[i]); // directManagedMemAccessFromHost=1: CPU accesses GPU memory directly without migrations
// directManagedMemAccessFromHost=0: CPU faults and triggers device-to-host migrations
append<<< 1, 1000 >>>(ret, 10, 100); // directManagedMemAccessFromHost=1: GPU accesses GPU memory without migrations
cudaDeviceSynchronize(); // directManagedMemAccessFromHost=0: GPU faults and triggers host-to-device migrations
cudaFree(ret);
write核函数完成后,ret将在 GPU 内存中创建并初始化。接下来,CPU 将访问ret,随后append核函数将再次使用同一块ret内存。根据系统架构和对硬件相干性的支持情况,此代码将表现出不同的行为:
在具有
directManagedMemAccessFromHost=1的设备上:CPU 对托管缓冲区的访问不会触发任何迁移;数据将保持驻留于 GPU 内存中,后续的任何 GPU 核函数都可以继续直接访问它,而不会引发缺页异常或迁移在具有
directManagedMemAccessFromHost=0的设备上:CPU 对托管缓冲区的访问将发生缺页异常并启动数据迁移;任何尝试首次访问同一数据的 GPU 核函数都会发生缺页异常,并将页迁移回 GPU 内存。
4.1.1.2.3. 主机原生原子操作
某些设备(包括硬件相干系统中通过 NVLink 连接的设备)支持对驻留于 CPU 的内存进行硬件加速的原子访问。这意味着,对主机内存的原子访问不必通过缺页异常来模拟。对于这些设备,属性cudaDevAttrHostNativeAtomicSupported设置为 1。
4.1.1.2.4. 原子访问和同步原语
CUDA 统一内存支持主机线程和设备线程可用的所有原子操作,使所有线程都能通过并发访问同一共享内存位置来协作。libcu++库提供了许多针对主机线程与设备线程之间的并发使用而调优的异构同步原语,其中包括cuda::atomic、cuda::atomic_ref、cuda::barrier、cuda::semaphore等许多原语。
在软件相干系统上,不支持从设备对文件后备的主机内存进行原子访问。以下示例代码在硬件相干系统上有效,但在其他系统上会出现未定义行为:
#include <cuda/atomic>
#include <cstdio>
#include <fcntl.h>
#include <sys/mman.h>
#define ERR(msg, ...) { fprintf(stderr, msg, ##__VA_ARGS__); return EXIT_FAILURE; }
__global__ void kernel(int* ptr) {
cuda::atomic_ref{*ptr}.store(2);
}
int main() {
// this will be closed/deleted by default on exit
FILE* tmp_file = tmpfile64();
// need to allocate space in the file, we do this with posix_fallocate here
int status = posix_fallocate(fileno(tmp_file), 0, 4096);
if (status != 0) ERR("Failed to allocate space in temp file\n");
int* ptr = (int*)mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_PRIVATE, fileno(tmp_file), 0);
if (ptr == MAP_FAILED) ERR("Failed to map temp file\n");
// initialize the value in our file-backed memory
*ptr = 1;
printf("Atom value: %d\n", *ptr);
// device and host thread access ptr concurrently, using cuda::atomic_ref
kernel<<<1, 1>>>(ptr);
while (cuda::atomic_ref{*ptr}.load() != 2);
// this will always be 2
printf("Atom value: %d\n", *ptr);
return EXIT_SUCCESS;
}
在软件相干系统上,对统一内存的原子访问可能引发缺页异常,从而导致显著的延迟。请注意,在这些系统上,并非所有对 CPU 内存的 GPU 原子操作都会出现这种情况:nvidia-smi -q | grep "Atomic Caps Outbound"列出的操作可能避免缺页异常。
在硬件相干系统上,主机与设备之间的原子操作不需要缺页异常,但仍可能由于会导致任何内存访问发生缺页异常的其他原因而发生缺页异常。
4.1.1.2.5. 使用统一内存时 Memcpy()/Memset() 的行为
cudaMemcpy*()和cudaMemset*()接受任何统一内存指针作为参数。
对于cudaMemcpy*(),以cudaMemcpyKind指定的方向是一种性能提示;如果任一参数是统一内存指针,该提示可能对性能产生更大的影响。
因此,建议遵循以下性能建议:
当统一内存的物理位置已知时,使用准确的
cudaMemcpyKind提示。优先使用
cudaMemcpyDefault,而不是不准确的cudaMemcpyKind提示。始终使用已填充(已初始化)的缓冲区:避免使用这些 API 来初始化内存。
如果两个指针都指向系统分配的内存,应避免使用
cudaMemcpy*():改为启动核函数或使用std::memcpy等 CPU 内存复制算法。
4.1.1.2.6. 统一内存的内存分配器概述
对于完全支持 CUDA 统一内存的系统,可以使用各种不同的分配器来分配统一内存。下表概述了部分分配器及其各自的特性。请注意,本节中的所有信息都可能在未来的 CUDA 版本中发生变化。
API |
放置策略 |
可从何处访问 |
根据访问情况迁移[2] |
|
|---|---|---|---|---|
|
首次触碰/提示[1] |
CPU、GPU |
是[3] |
系统页大小或大页大小[6] |
|
首次触碰/提示 |
CPU、GPU |
是 |
驻留于 CPU:系统页大小;驻留于 GPU:2MB |
|
GPU |
GPU |
否 |
GPU 页大小:2MB |
|
CPU |
CPU、GPU |
否 |
由 CPU 映射:系统页大小 由 GPU 映射:2MB |
内存池,位置类型为主机: |
CPU |
CPU、GPU |
否 |
由 CPU 映射:系统页大小 由 GPU 映射:2MB |
内存池,位置类型为设备: |
GPU |
GPU |
否 |
2MB |
表不同分配器的统一内存支持概述展示了几种分配器在语义上的差异,可以考虑使用这些分配器来分配可由多个处理器(包括主机和设备)同时访问的数据。有关cudaMemPoolCreate的更多详细信息,请参见内存池一节;有关cuMemCreate的更多详细信息,请参见虚拟内存管理一节。
在设备内存作为 NUMA 域向系统公开的硬件相干系统上,可以使用numa_alloc_on_node等特殊分配器将内存固定到给定的 NUMA 节点,该节点可以是主机或设备。主机和设备都可以访问此内存,并且此内存不会迁移。同样,mbind可用于将内存固定到给定的一个或多个 NUMA 节点,并且可以使文件后备内存在首次被访问之前就放置到给定的一个或多个 NUMA 节点上。
以下内容适用于所分配内存用于共享的分配器:
mmap等系统分配器允许使用MAP_SHARED标志在进程之间共享内存。CUDA 支持此功能,可用于在连接到同一主机的不同设备之间共享内存。但是,目前不支持用它在多个主机以及多个设备之间共享内存。有关详细信息,请参见使用统一内存进行进程间通信(IPC)。若要在多个主机上通过网络访问统一内存或其他 CUDA 内存,请查阅所用通信库的文档,例如NCCL、NVSHMEM、OpenMPI、UCX等。
4.1.1.2.7. 局部性域和统一内存
不支持将托管内存分配局部化。局部性域不是统一内存的迁移目的地;cudaMemAllocationTypeManaged不能与局部化分配结合使用。需要在局部性域中放置内存的应用程序应改用局部化的页锁定内存池或局部化的 VMM 分配。
有关整体编程模型,请参见局部性域。
4.1.1.2.8. 访问计数器迁移
在硬件相干系统上,访问计数器功能会跟踪 GPU 对位于其他处理器上的内存的访问频率。这是确保将内存页移动到最频繁访问这些页的处理器的物理内存中所必需的。它可以指导 CPU 与 GPU 之间以及对等 GPU 之间的迁移,这一过程称为访问计数器迁移。
从 CUDA 12.4 开始,系统分配的内存支持访问计数器。请注意,文件后备内存不会根据访问情况迁移。对于系统分配的内存,可以通过使用cudaMemAdviseSetAccessedBy提示,以相应的设备 id 指定设备,来启用访问计数器迁移。如果访问计数器已启用,可以使用设置为主机的cudaMemAdviseSetPreferredLocation来防止迁移。默认情况下,cudaMallocManaged基于缺页异常后迁移的机制进行迁移。[7]
驱动程序也可能使用访问计数器,以便在抖动缓解或内存超额订用场景中提高效率。
当前系统允许在设置 accessed-by 设备提示时,对托管内存使用访问计数器迁移。这是一个实现细节,不应依赖它来确保未来的兼容性。
4.1.1.2.9. 避免从 CPU 频繁写入驻留于 GPU 的内存
如果主机访问统一内存,缓存未命中可能会在主机与设备之间产生比预期更多的流量。许多 CPU 架构要求所有内存操作(包括写入)都经过缓存层次结构。如果系统内存驻留于 GPU,这意味着 CPU 对该内存的频繁写入可能导致缓存未命中,从而在将实际值写入所请求的内存范围之前,先将数据从 GPU 传输到 CPU。在软件相干系统上,这可能引入额外的缺页异常,而在硬件相干系统上,这可能导致 CPU 操作之间的延迟更高。因此,为了与设备共享主机生成的数据,可以考虑写入驻留于 CPU 的内存,并由设备直接读取这些值。下面的代码展示了如何使用统一内存实现这一点。
size_t data_size = sizeof(int);
int* data = (int*)malloc(data_size);
// ensure that data stays local to the host and avoid faults
cudaMemLocation location = {.type = cudaMemLocationTypeHost};
cudaMemAdvise(data, data_size, cudaMemAdviseSetPreferredLocation, location);
cudaMemAdvise(data, data_size, cudaMemAdviseSetAccessedBy, location);
// frequent exchanges of small data: if the CPU writes to CPU-resident memory,
// and GPU directly accesses that data, we can avoid the CPU caches re-loading
// data if it was evicted in between writes
for (int i = 0; i < 10; ++i) {
*data = 42 + i;
kernel<<<1, 1>>>(data);
cudaDeviceSynchronize();
// CPU cache potentially evicted data here
}
free(data);
int* data;
size_t data_size = sizeof(int);
cudaMallocManaged(&data, data_size);
// ensure that data stays local to the host and avoid faults
cudaMemLocation location = {.type = cudaMemLocationTypeHost};
cudaMemAdvise(data, data_size, cudaMemAdviseSetPreferredLocation, location);
cudaMemAdvise(data, data_size, cudaMemAdviseSetAccessedBy, location);
// frequent exchanges of small data: if the CPU writes to CPU-resident memory,
// and GPU directly accesses that data, we can avoid the CPU caches re-loading
// data if it was evicted in between writes
for (int i = 0; i < 10; ++i) {
*data = 42 + i;
kernel<<<1, 1>>>(data);
cudaDeviceSynchronize();
// CPU cache potentially evicted data here
}
cudaFree(data);
4.1.1.2.10. 利用对系统内存的异步访问
如果应用程序需要与主机共享设备上工作的结果,有几种可行的选项:
设备将其结果写入驻留于 GPU 的内存,使用
cudaMemcpy*传输该结果,然后主机读取传输过来的数据。设备将其结果直接写入驻留于 CPU 的内存,然后主机读取该数据。
设备写入驻留于 GPU 的内存,然后主机直接访问该数据。
如果在向主机传输结果或主机访问结果的同时,可以在设备上调度独立的工作,则优先选择方案 1 或 3。如果在主机访问结果之前,设备一直没有工作可做,则方案 2 可能更合适。这是因为,除非使用许多主机线程来读取数据,否则设备写入的带宽通常高于主机读取的带宽。
void exchange_explicit_copy(cudaStream_t stream) {
int* data, *host_data;
size_t n_bytes = sizeof(int) * 16;
// allocate receiving buffer
host_data = (int*)malloc(n_bytes);
// allocate, since we touch on the device first, will be GPU-resident
cudaMallocManaged(&data, n_bytes);
kernel<<<1, 16, 0, stream>>>(data);
// launch independent work on the device
// other_kernel<<<1024, 256, 0, stream>>>(other_data, ...);
// transfer to host
cudaMemcpyAsync(host_data, data, n_bytes, cudaMemcpyDeviceToHost, stream);
// sync stream to ensure data has been transferred
cudaStreamSynchronize(stream);
// read transferred data
printf("Got values %d - %d from GPU\n", host_data[0], host_data[15]);
cudaFree(data);
free(host_data);
}
void exchange_device_direct_write(cudaStream_t stream) {
int* data;
size_t n_bytes = sizeof(int) * 16;
// allocate receiving buffer
cudaMallocManaged(&data, n_bytes);
// ensure that data is mapped and resident on the host
cudaMemLocation location = {.type = cudaMemLocationTypeHost};
cudaMemAdvise(data, n_bytes, cudaMemAdviseSetPreferredLocation, location);
cudaMemAdvise(data, n_bytes, cudaMemAdviseSetAccessedBy, location);
kernel<<<1, 16, 0, stream>>>(data);
// sync stream to ensure data has been transferred
cudaStreamSynchronize(stream);
// read transferred data
printf("Got values %d - %d from GPU\n", data[0], data[15]);
cudaFree(data);
}
void exchange_host_direct_read(cudaStream_t stream) {
int* data;
size_t n_bytes = sizeof(int) * 16;
// allocate receiving buffer
cudaMallocManaged(&data, n_bytes);
// ensure that data is mapped and resident on the device
cudaMemLocation device_loc = {};
cudaGetDevice(&device_loc.id);
device_loc.type = cudaMemLocationTypeDevice;
cudaMemAdvise(data, n_bytes, cudaMemAdviseSetPreferredLocation, device_loc);
cudaMemAdvise(data, n_bytes, cudaMemAdviseSetAccessedBy, device_loc);
kernel<<<1, 16, 0, stream>>>(data);
// launch independent work on the GPU
// other_kernel<<<1024, 256, 0, stream>>>(other_data, ...);
// sync stream to ensure data may be accessed (has been written by device)
cudaStreamSynchronize(stream);
// read data directly from host
printf("Got values %d - %d from GPU\n", data[0], data[15]);
cudaFree(data);
最后,在上面的显式复制示例中,可以使用主机或设备核函数来显式执行此传输,而不是使用cudaMemcpy*来传输数据。对于连续数据,优先使用 CUDA 复制引擎,因为复制引擎执行的操作可以与主机和设备上的工作重叠。cudaMemcpy*和cudaMemPrefetchAsyncAPI 可能会使用复制引擎,但不能保证cudaMemcpy*API 调用会使用复制引擎。出于同样的原因,对于足够大的数据,显式复制优于主机直接读取:如果主机和设备所执行的工作都没有使其各自的内存系统达到饱和,复制引擎就可以在主机和设备执行工作的同时并发执行传输。
复制引擎通常既用于主机与设备之间的传输,也用于通过 NVLink 连接的系统中对等设备之间的传输。由于复制引擎的总数有限,某些系统中cudaMemcpy*的带宽可能低于使用设备显式执行传输时的带宽。在这种情况下,如果传输位于应用程序的关键路径上,则可能更适合采用基于设备的显式传输。
4.1.2. 仅支持 CUDA 托管内存的设备上的统一内存
对于计算能力为 6.x 或更高但不支持可分页内存访问的设备,参见表统一内存范式概述,CUDA 托管内存得到完整支持并且具有相干性,但 GPU 无法访问由系统分配的内存。统一内存的编程模型和性能调优与完整支持 CUDA 统一内存的设备上的统一内存一节中描述的模型基本相似,一个显著的例外是不能使用系统分配器来分配内存。因此,以下各小节不适用:
4.1.3. Windows、WSL 和 Tegra 上的统一内存
注意
本节仅讨论计算能力低于 6.0 的设备或 Windows 平台,即concurrentManagedAccess属性设为 0 的设备。
计算能力低于 6.0 的设备或 Windows 平台,即concurrentManagedAccess属性设为 0 的设备,参见统一内存范式概述,支持 CUDA 托管内存,但存在以下限制:
数据迁移和相干性:不支持按需将托管数据以细粒度移动到 GPU。每当启动 GPU 核函数时,通常都必须将所有托管内存传输到 GPU 内存,以避免在访问内存时发生缺页故障。仅 CPU 端支持缺页故障。
GPU 内存超额订用:这些设备无法分配超过 GPU 内存物理大小的托管内存。
相干性和并发性:无法同时访问托管内存,因为缺少 GPU 缺页故障机制,如果 CPU 在 GPU 核函数处于活动状态时访问统一内存分配,就无法保证相干性。
4.1.3.1. 多 GPU
在具有计算能力低于 6.0 的设备的系统上,或在 Windows 平台上,托管分配通过 GPU 的对等访问功能自动对系统中的所有 GPU 可见。托管内存分配的行为类似于使用cudaMalloc()分配的非托管内存:当前活动设备是物理分配的所在地,但系统中的其他 GPU 将通过 PCIe 总线以较低的带宽访问该内存。
在 Linux 上,只要程序正在使用的所有 GPU 都支持对等访问,托管内存就会在 GPU 内存中分配。如果应用程序在任何时候开始使用某个 GPU,而该 GPU 与已在其上进行托管分配的其他 GPU 中的任意一个不支持对等访问,那么驱动程序就会将所有托管分配迁移到系统内存。在这种情况下,所有 GPU 都会受到 PCIe 带宽的限制。
在 Windows 上,如果对等映射不可用(例如,在架构不同的 GPU 之间),那么系统将自动回退到使用映射内存,而不论程序是否实际使用这两个 GPU。如果实际只会使用一个 GPU,就必须在启动程序之前设置CUDA_VISIBLE_DEVICES环境变量。这会限制可见的 GPU,并允许在 GPU 内存中分配托管内存。
或者,在 Windows 上,用户也可以将CUDA_MANAGED_FORCE_DEVICE_ALLOC设为非零值,以强制驱动程序始终使用设备内存进行物理存储。当此环境变量设为非零值时,该进程中使用的所有支持托管内存的设备都必须彼此具备对等兼容性。如果使用了一个支持托管内存的设备,而它与该进程先前使用过的、支持托管内存的其他设备中的任意一个不具备对等兼容性,则会返回错误::cudaErrorInvalidDevice,即使已经在那些设备上调用过::cudaDeviceReset也是如此。CUDA 环境变量中介绍了这些环境变量。
4.1.3.2. 相干性和并发性
为了确保相干性,统一内存编程模型对 CPU 和 GPU 并发执行时的数据访问施加了约束。实际上,在执行任何核函数操作期间,GPU 都独占访问所有托管数据,CPU 不允许访问这些数据,无论具体的核函数是否正在使用这些数据。CPU/GPU 的并发访问,即使访问的是不同的托管内存分配,也会导致段错误,因为该页被视为 CPU 不可访问。
例如,以下代码在计算能力为 6.x 的设备上可以成功运行,因为 GPU 的缺页故障功能消除了对同时访问的所有限制,但在 6.x 之前的架构和 Windows 平台上会失败,因为当 CPU 访问y时,GPU 程序核函数仍处于活动状态:
__device__ __managed__ int x, y=2;
__global__ void kernel() {
x = 10;
}
int main() {
kernel<<< 1, 1 >>>();
y = 20; // Error on GPUs not supporting concurrent access
cudaDeviceSynchronize();
return 0;
}
程序在访问y之前必须显式地与 GPU 同步(无论 GPU 核函数是否实际访问y(或任何托管数据):
__device__ __managed__ int x, y=2;
__global__ void kernel() {
x = 10;
}
int main() {
kernel<<< 1, 1 >>>();
cudaDeviceSynchronize();
y = 20; // Success on GPUs not supporting concurrent access
return 0;
}
请注意,任何在逻辑上保证 GPU 完成其工作的函数调用,都可以用来从逻辑上确保 GPU 工作已完成,参见显式同步。
请注意,如果在 GPU 处于活动状态时使用cudaMallocManaged()或cuMemAllocManaged()动态分配内存,那么在启动额外工作或同步 GPU 之前,该内存的行为是未指定的。在此期间尝试在 CPU 上访问该内存,可能会导致段错误,也可能不会。这不适用于使用标志cudaMemAttachHost或CU_MEM_ATTACH_HOST分配的内存。
4.1.3.3. 与流关联的统一内存
CUDA 编程模型提供流这一机制,供程序指示核函数启动之间的依赖性和独立性。保证启动到同一流中的核函数连续执行,而允许启动到不同流中的核函数并发执行。参见CUDA 流一节。
4.1.3.3.1. 流回调
CPU 可以在流回调内部合法地访问托管数据,前提是 GPU 上没有其他可能访问托管数据的流处于活动状态。此外,后面没有任何设备工作的回调可以用于同步:例如,在回调内部向条件变量发出信号;否则,CPU 访问仅在回调执行期间有效。有几个重要的注意事项:
当 GPU 处于活动状态时,始终允许 CPU 访问非托管映射内存中的数据。
当 GPU 正在运行任何核函数时,即使该核函数不使用托管数据,也会将 GPU 视为处于活动状态。如果核函数可能使用数据,则禁止访问
对于 GPU 之间对托管内存的并发访问,除了适用于多 GPU 访问非托管内存的约束之外,没有其他约束。
对于并发执行的 GPU 核函数访问托管数据,没有任何约束。
请注意,最后一点允许 GPU 核函数之间出现竞争,这与目前非托管 GPU 内存的情况相同。从 GPU 的角度看,托管内存的工作方式与非托管内存完全相同。以下代码示例说明了这些要点:
int main() {
cudaStream_t stream1, stream2;
cudaStreamCreate(&stream1);
cudaStreamCreate(&stream2);
int *non_managed, *managed, *also_managed;
cudaMallocHost(&non_managed, 4); // Non-managed, CPU-accessible memory
cudaMallocManaged(&managed, 4);
cudaMallocManaged(&also_managed, 4);
// Point 1: CPU can access non-managed data.
kernel<<< 1, 1, 0, stream1 >>>(managed);
*non_managed = 1;
// Point 2: CPU cannot access any managed data while GPU is busy,
// unless concurrentManagedAccess = 1
// Note we have not yet synchronized, so "kernel" is still active.
*also_managed = 2; // Will issue segmentation fault
// Point 3: Concurrent GPU kernels can access the same data.
kernel<<< 1, 1, 0, stream2 >>>(managed);
// Point 4: Multi-GPU concurrent access is also permitted.
cudaSetDevice(1);
kernel<<< 1, 1 >>>(managed);
return 0;
}
4.1.3.3.2. 与流关联的托管内存允许更细粒度的控制
统一内存建立在流独立性模型之上,允许 CUDA 程序显式地将托管分配与 CUDA 流关联。这样,程序员便可根据核函数是否启动到指定流中来指示核函数对数据的使用情况。这使得可以根据程序特定的数据访问模式来实现并发。用于控制此行为的函数是:
cudaError_t cudaStreamAttachMemAsync(cudaStream_t stream,
void *ptr,
size_t length=0,
unsigned int flags=0);
cudaStreamAttachMemAsync()函数将从 ptr 开始的 length 字节内存与指定流关联。这样,只要 stream 中的所有操作都已完成,就允许 CPU 访问该内存区域,而不论其他流是否处于活动状态。实际上,这将活动 GPU 对托管内存区域的独占所有权约束在每个流的活动范围内,而不是整个 GPU 的活动范围内。最重要的是,如果某个分配未与特定流关联,那么它对所有正在运行的核函数都可见,而不论这些核函数位于哪个流中。这是cudaMallocManaged()分配或__managed__变量的默认可见性;因此,在简单情况下,规则是只要有任何核函数正在运行,CPU 就不能访问这些数据。
注意
通过将分配与特定流关联,程序保证只有启动到该流中的核函数才会访问该数据。统一内存系统不会执行任何错误检查。
注意
除了允许更高的并发性之外,使用cudaStreamAttachMemAsync()还可以启用统一内存系统内的数据传输优化,这可能会影响延迟和其他开销。
以下示例展示了如何显式地将y与主机可访问性关联,从而使 CPU 在任何时候都能够访问。(请注意,核函数调用之后没有cudaDeviceSynchronize()。)现在,在 GPU 上运行的核函数对y的访问将产生未定义的结果。
__device__ __managed__ int x, y=2;
__global__ void kernel() {
x = 10;
}
int main() {
cudaStream_t stream1;
cudaStreamCreate(&stream1);
cudaStreamAttachMemAsync(stream1, &y, 0, cudaMemAttachHost);
cudaDeviceSynchronize(); // Wait for Host attachment to occur.
kernel<<< 1, 1, 0, stream1 >>>(); // Note: Launches into stream1.
y = 20; // Success – a kernel is running but “y”
// has been associated with no stream.
return 0;
}
4.1.3.3.3. 关于多线程主机程序的一个更详细的示例
cudaStreamAttachMemAsync()的主要用途是利用 CPU 线程实现独立任务的并行执行。在这样的程序中,CPU 线程通常会为其生成的所有工作创建自己的流,因为使用 CUDA 的 NULL 流会导致线程之间产生依赖关系。托管数据默认对任何 GPU 流都具有全局可见性,这可能使得多线程程序难以避免 CPU 线程之间的相互影响。因此,使用函数cudaStreamAttachMemAsync()将线程的托管分配与该线程自己的流关联,而且这种关联通常在线程的整个生命周期内都不会改变。这样的程序只需增加一次对cudaStreamAttachMemAsync()的调用,就可以使用统一内存进行数据访问:
// This function performs some task, in its own , in its own private stream and can be run in parallel
void run_task(int *in, int *out, int length) {
// Create a stream for us to use.
cudaStream_t stream;
cudaStreamCreate(&stream);
// Allocate some managed data and associate with our stream.
// Note the use of the host-attach flag to cudaMallocManaged();
// we then associate the allocation with our stream so that
// our GPU kernel launches can access it.
int *data;
cudaMallocManaged((void **)&data, length, cudaMemAttachHost);
cudaStreamAttachMemAsync(stream, data);
cudaStreamSynchronize(stream);
// Iterate on the data in some way, using both Host & Device.
for(int i=0; i<N; i++) {
transform<<< 100, 256, 0, stream >>>(in, data, length);
cudaStreamSynchronize(stream);
host_process(data, length); // CPU uses managed data.
convert<<< 100, 256, 0, stream >>>(out, data, length);
}
cudaStreamSynchronize(stream);
cudaStreamDestroy(stream);
cudaFree(data);
}
在此示例中,分配与流之间的关联只建立一次,然后主机和设备都会反复使用这些数据。与在主机和设备之间显式复制数据相比,得到的代码要简单得多,尽管结果相同。
函数cudaMallocManaged()指定了 cudaMemAttachHost 标志,这会创建一个最初对设备端执行不可见的分配。(默认分配对所有流上的所有 GPU 核函数都可见。)这可确保在分配数据之后、为特定流获取数据之前的这段时间内,不会与另一个线程的执行发生意外的相互影响。
如果没有此标志,而恰好有另一个线程启动的核函数正在运行,那么新分配将被视为正在由 GPU 使用。这可能影响该线程在能够将新分配的数据显式附加到私有流之前,从 CPU 访问这些数据的能力。因此,为了让线程之间能够安全地保持独立,应在分配时指定此标志。
另一种方法是在将分配附加到流之后,在所有线程之间设置一个进程范围的屏障。这将确保所有线程在启动任何核函数之前都完成其数据与流的关联,从而避免这一风险。在销毁流之前还需要第二个屏障,因为销毁流会使分配恢复其默认可见性。提供cudaMemAttachHost标志既是为了简化这一过程,也是因为并非总能在需要的位置插入全局屏障。
4.1.3.3.4. 与流关联的统一内存的数据移动
在未设置concurrentManagedAccess的设备上,Memcpy()/Memset() 对与流关联的统一内存的行为有所不同,适用以下规则:
如果指定了cudaMemcpyHostTo*且源数据是统一内存,那么,如果主机在复制流中能够以相干方式访问该数据,就会从主机访问该数据(1);否则,将从设备访问该数据。当指定了cudaMemcpy*ToHost且目标是统一内存时,类似的规则适用于目标。
如果指定了cudaMemcpyDeviceTo*且源数据是统一内存,那么将从设备访问该数据。设备必须能够在复制流中以相干方式访问源数据(2);否则,将返回错误。当指定了cudaMemcpy*ToDevice且目标是统一内存时,类似的规则适用于目标。
如果指定了cudaMemcpyDefault,那么,当无法在复制流中从设备以相干方式访问统一内存时(2),或者当数据的首选位置为cudaCpuDeviceId且能够在复制流中从主机以相干方式访问它时(1),将从主机访问统一内存;否则,将从设备访问它。
对统一内存使用cudaMemset*()时,必须能够在用于cudaMemset()操作的流中从设备以相干方式访问该数据(2);否则,将返回错误。
通过cudaMemcpy*或cudaMemset*从设备访问数据时,该操作所在的流被视为在 GPU 上处于活动状态。在此期间,如果 GPU 的设备属性concurrentManagedAccess的值为零,则 CPU 对与该流关联的数据或具有全局可见性的数据的任何访问都会导致段错误。程序必须进行适当的同步,以确保在从 CPU 访问任何关联数据之前,该操作已完成。
能够在给定流中从主机以相干方式访问,意味着该内存既不具有全局可见性,也不与给定流关联。
能够在给定流中从设备以相干方式访问,意味着该内存具有全局可见性或与给定流关联。
4.1.4. 性能提示
性能提示允许程序员向 CUDA 提供有关统一内存使用情况的更多信息。CUDA 使用性能提示来更高效地管理内存并提高应用程序性能。性能提示绝不会影响应用程序的正确性。性能提示只影响性能。
注意
只有在统一内存性能提示能够提高性能时,应用程序才应使用它们。
性能提示可用于任何统一内存分配,包括 CUDA 托管内存。在完全支持 CUDA 统一内存的系统上,性能提示可应用于所有由系统分配的内存。
4.1.4.1. 数据预取
cudaMemPrefetchAsyncAPI 是一个异步的流有序 API,它可能会迁移数据,使其驻留在更靠近指定处理器的位置。在预取数据期间仍可访问该数据。迁移只有在流中所有先前的操作都完成之后才会开始,并且会在流中任何后续操作开始之前完成。
cudaError_t cudaMemPrefetchAsync(const void *devPtr,
size_t count,
struct cudaMemLocation location,
unsigned int flags,
cudaStream_t stream=0);
在给定的stream中执行预取任务时,如果location.type为cudaMemLocationTypeDevice,则包含[devPtr, devPtr + count)的内存区域可能会被迁移到目标设备location.id;如果location.type为cudaMemLocationTypeHost,则可能会被迁移到 CPU。有关flags的详细信息,请参阅当前的CUDA 运行时 API 文档。
请考虑下面这个简单的代码示例:
void test_prefetch_sam(const cudaStream_t& s) {
// initialize data on CPU
char *data = (char*)malloc(dataSizeBytes);
init_data(data, dataSizeBytes);
cudaMemLocation location = {.type = cudaMemLocationTypeDevice, .id = myGpuId};
// encourage data to move to GPU before use
const unsigned int flags = 0;
cudaMemPrefetchAsync(data, dataSizeBytes, location, flags, s);
// use data on GPU
const unsigned num_blocks = (dataSizeBytes + threadsPerBlock - 1) / threadsPerBlock;
mykernel<<<num_blocks, threadsPerBlock, 0, s>>>(data, dataSizeBytes);
// encourage data to move back to CPU
location = {.type = cudaMemLocationTypeHost};
cudaMemPrefetchAsync(data, dataSizeBytes, location, flags, s);
cudaStreamSynchronize(s);
// use data on CPU
use_data(data, dataSizeBytes);
free(data);
}
void test_prefetch_managed(const cudaStream_t& s) {
// initialize data on CPU
char *data;
cudaMallocManaged(&data, dataSizeBytes);
init_data(data, dataSizeBytes);
cudaMemLocation location = {.type = cudaMemLocationTypeDevice, .id = myGpuId};
// encourage data to move to GPU before use
const unsigned int flags = 0;
cudaMemPrefetchAsync(data, dataSizeBytes, location, flags, s);
// use data on GPU
const uinsigned num_blocks = (dataSizeBytes + threadsPerBlock - 1) / threadsPerBlock;
mykernel<<<num_blocks, threadsPerBlock, 0, s>>>(data, dataSizeBytes);
// encourage data to move back to CPU
location = {.type = cudaMemLocationTypeHost};
cudaMemPrefetchAsync(data, dataSizeBytes, location, flags, s);
cudaStreamSynchronize(s);
// use data on CPU
use_data(data, dataSizeBytes);
cudaFree(data);
}
4.1.4.2. 数据使用提示
当多个处理器同时访问同一份数据时,可使用cudaMemAdvise来提示将如何访问[devPtr, devPtr + count)处的数据:
cudaError_t cudaMemAdvise(const void *devPtr,
size_t count,
enum cudaMemoryAdvise advice,
struct cudaMemLocation location);
该示例展示了如何使用cudaMemAdvise:
init_data(data, dataSizeBytes);
cudaMemLocation location = {.type = cudaMemLocationTypeDevice, .id = myGpuId};
// encourage data to move to GPU before use
const unsigned int flags = 0;
cudaMemPrefetchAsync(data, dataSizeBytes, location, flags, s);
// use data on GPU
const uinsigned num_blocks = (dataSizeBytes + threadsPerBlock - 1) / threadsPerBlock;
mykernel<<<num_blocks, threadsPerBlock, 0, s>>>(data, dataSizeBytes);
// encourage data to move back to CPU
location = {.type = cudaMemLocationTypeHost};
cudaMemPrefetchAsync(data, dataSizeBytes, location, flags, s);
cudaStreamSynchronize(s);
// use data on CPU
use_data(data, dataSizeBytes);
cudaFree(data);
}
// test-prefetch-managed-end
static const int maxDevices = 1;
static const int maxOuterLoopIter = 3;
static const int maxInnerLoopIter = 4;
// test-advise-managed-begin
void test_advise_managed(cudaStream_t stream) {
char *dataPtr;
size_t dataSize = 64 * threadsPerBlock; // 16 KiB
其中,advice可取以下值:
cudaMemAdviseSetReadMostly:这意味着该数据主要用于读取,仅偶尔进行写入。通常,它允许在该区域上以读取带宽为代价换取写入带宽。
cudaMemAdviseSetPreferredLocation:此提示将数据的首选位置设置为指定设备的物理内存。此提示鼓励系统将数据保留在首选位置,但并不保证这一点。为 location.type 传入
cudaMemLocationTypeHost值会将首选位置设置为 CPU 内存。其他提示(如cudaMemPrefetchAsync)可能会覆盖此提示,并允许内存迁移到其首选位置之外。
cudaMemAdviseSetAccessedBy:在某些系统中,在从给定处理器访问数据之前建立到内存的映射,可能有利于性能。此提示告知系统,当
location.type为cudaMemLocationTypeDevice时,location.id将频繁访问该数据,从而使系统能够假定创建这些映射是值得的。此提示并不指明数据应驻留在何处,但可以与cudaMemAdviseSetPreferredLocation结合使用来指定这一点。在硬件相干的系统上,此提示会开启访问计数器迁移,请参阅访问计数器迁移。
也可以通过使用下列值之一来取消设置各项建议:cudaMemAdviseUnsetReadMostly、cudaMemAdviseUnsetPreferredLocation和cudaMemAdviseUnsetAccessedBy。
该示例展示了如何使用cudaMemAdvise:
void test_advise_sam(cudaStream_t stream) {
char *dataPtr;
size_t dataSize = 64 * threadsPerBlock; // 16 KiB
// Allocate memory using malloc or cudaMallocManaged
dataPtr = (char*)malloc(dataSize);
// Set the advice on the memory region
cudaMemLocation loc = {.type = cudaMemLocationTypeDevice, .id = myGpuId};
cudaMemAdvise(dataPtr, dataSize, cudaMemAdviseSetReadMostly, loc);
int outerLoopIter = 0;
while (outerLoopIter < maxOuterLoopIter) {
// The data is written by the CPU each outer loop iteration
init_data(dataPtr, dataSize);
// The data is made available to all GPUs by prefetching.
// Prefetching here causes read duplication of data instead
// of data migration
cudaMemLocation location;
location.type = cudaMemLocationTypeDevice;
for (int device = 0; device < maxDevices; device++) {
location.id = device;
const unsigned int flags = 0;
cudaMemPrefetchAsync(dataPtr, dataSize, location, flags, stream);
}
// The kernel only reads this data in the inner loop
int innerLoopIter = 0;
while (innerLoopIter < maxInnerLoopIter) {
mykernel<<<32, threadsPerBlock, 0, stream>>>((const char *)dataPtr, dataSize);
innerLoopIter++;
}
outerLoopIter++;
}
free(dataPtr);
}
void test_advise_managed(cudaStream_t stream) {
char *dataPtr;
size_t dataSize = 64 * threadsPerBlock; // 16 KiB
// Allocate memory using cudaMallocManaged
// (malloc may be used on systems with full CUDA Unified memory support)
cudaMallocManaged(&dataPtr, dataSize);
// Set the advice on the memory region
cudaMemLocation loc = {.type = cudaMemLocationTypeDevice, .id = myGpuId};
cudaMemAdvise(dataPtr, dataSize, cudaMemAdviseSetReadMostly, loc);
int outerLoopIter = 0;
while (outerLoopIter < maxOuterLoopIter) {
// The data is written by the CPU each outer loop iteration
init_data(dataPtr, dataSize);
// The data is made available to all GPUs by prefetching.
// Prefetching here causes read duplication of data instead
// of data migration
cudaMemLocation location;
location.type = cudaMemLocationTypeDevice;
for (int device = 0; device < maxDevices; device++) {
location.id = device;
const unsigned int flags = 0;
cudaMemPrefetchAsync(dataPtr, dataSize, location, flags, stream);
}
// The kernel only reads this data in the inner loop
int innerLoopIter = 0;
while (innerLoopIter < maxInnerLoopIter) {
mykernel<<<32, threadsPerBlock, 0, stream>>>((const char *)dataPtr, dataSize);
innerLoopIter++;
}
outerLoopIter++;
}
cudaFree(dataPtr);
}
4.1.4.3. 内存丢弃
cudaMemDiscardBatchAsyncAPI 允许应用程序通知 CUDA 运行时,指定内存范围中的内容已不再有用。统一内存驱动程序会因基于缺页的迁移或为支持设备内存超额使用而进行的内存逐出,执行自动内存传输。这些自动内存传输有时可能是多余的,这会严重降低性能。将一个地址范围标记为“丢弃”,会告知统一内存驱动程序,应用程序已使用完该范围中的内容,在预取时或为了给其他分配腾出空间而逐出页时,无需迁移这些数据。如果丢弃后没有进行写入访问或预取,读取被丢弃的页将得到不确定的值。而丢弃操作之后的任何新写入都保证能够被后续读取访问看到。对正在被丢弃的地址范围进行并发访问或预取将导致未定义行为。
cudaError_t cudaMemDiscardBatchAsync(void **dptrs,
size_t *sizes,
size_t count,
unsigned long long flags,
cudaStream_t stream);
此函数对dptrs和sizes数组中指定的地址范围执行一批内存丢弃操作。这两个数组的长度必须相同,均为count所指定的长度。每个内存范围都必须指向通过cudaMallocManaged分配或通过__managed__变量声明的托管内存。
cudaMemDiscardAndPrefetchBatchAsyncAPI 将丢弃操作和预取操作结合在一起。调用cudaMemDiscardAndPrefetchBatchAsync在语义上等同于先调用cudaMemDiscardBatchAsync再调用cudaMemPrefetchBatchAsync,但更为高效。当应用程序需要内存位于目标位置,但不需要内存中的内容时,这很有用。
cudaError_t cudaMemDiscardAndPrefetchBatchAsync(void **dptrs,
size_t *sizes,
size_t count,
struct cudaMemLocation *prefetchLocs,
size_t *prefetchLocIdxs,
size_t numPrefetchLocs,
unsigned long long flags,
cudaStream_t stream);
prefetchLocs数组指定预取的目标位置,而prefetchLocIdxs指明每个预取位置适用于哪些操作。例如,如果一批操作共有 10 个,其中前 6 个应预取到一个位置,其余 4 个应预取到另一个位置,那么numPrefetchLocs应为 2,prefetchLocIdxs应为 {0, 6},而prefetchLocs应包含这两个目标位置。
重要注意事项:
如果丢弃后没有进行写入或预取,从被丢弃的范围读取将返回不确定的值
可以通过写入该范围或通过
cudaMemPrefetchAsync预取该范围来撤销丢弃操作与丢弃操作同时发生的任何读取、写入或预取都会导致未定义行为
所有设备的
cudaDevAttrConcurrentManagedAccess值都必须非零
4.1.4.4. 查询托管内存的数据使用属性
程序可以使用以下 API,查询通过cudaMemAdvise或cudaMemPrefetchAsync为 CUDA 托管内存分配的内存范围属性:
cudaMemRangeGetAttribute(void *data,
size_t dataSize,
enum cudaMemRangeAttribute attribute,
const void *devPtr,
size_t count);
此函数查询从devPtr开始、大小为count字节的内存范围的某个属性。该内存范围必须指向通过cudaMallocManaged分配或通过__managed__变量声明的托管内存。可以查询以下属性:
cudaMemRangeAttributeReadMostly:如果整个内存范围都设置了cudaMemAdviseSetReadMostly属性,则返回 1,否则返回 0。cudaMemRangeAttributePreferredLocation:如果整个内存范围都以相应的处理器作为首选位置,则返回的结果为 GPU 设备 ID 或cudaCpuDeviceId,否则返回cudaInvalidDeviceId。应用程序可以使用此查询 API,根据托管指针的首选位置属性,决定通过 CPU 还是 GPU 来暂存数据。请注意,查询时该内存范围的实际位置可能与首选位置不同。cudaMemRangeAttributeAccessedBy:将返回已为该内存范围设置了该建议的设备列表。cudaMemRangeAttributeLastPrefetchLocation:将返回最近一次使用cudaMemPrefetchAsync显式预取该内存范围时的目标位置。请注意,这仅返回应用程序最近一次请求将该内存范围预取到的位置。它并不表明到该位置的预取操作是否已完成,甚至不表明该操作是否已开始。cudaMemRangeAttributePreferredLocationType:它返回首选位置的位置类型,取值如下:cudaMemLocationTypeDevice:如果内存范围内的所有页都以同一个 GPU 作为首选位置,cudaMemLocationTypeHost:如果内存范围内的所有页都以 CPU 作为首选位置,cudaMemLocationTypeHostNuma:如果内存范围内的所有页都以同一个主机 NUMA 节点 ID 作为首选位置,cudaMemLocationTypeInvalid:如果并非所有页都具有相同的首选位置,或者某些页根本没有首选位置。
cudaMemRangeAttributePreferredLocationId:如果对同一地址范围的cudaMemRangeAttributePreferredLocationType查询返回cudaMemLocationTypeDevice,则返回设备序号。如果首选位置类型为主机 NUMA 节点,则返回主机 NUMA 节点 ID。否则,应忽略该 ID。cudaMemRangeAttributeLastPrefetchLocationType:返回最近一次通过cudaMemPrefetchAsync显式预取该内存范围内所有页时的目标位置类型。返回的值如下:cudaMemLocationTypeDevice:如果内存范围内的所有页都被预取到了同一个 GPU,cudaMemLocationTypeHost:如果内存范围内的所有页都被预取到了 CPU,cudaMemLocationTypeHostNuma:如果内存范围内的所有页都被预取到了同一个主机 NUMA 节点 ID,cudaMemLocationTypeInvalid:如果并非所有页都被预取到了同一个位置,或者某些页从未被预取过。
cudaMemRangeAttributeLastPrefetchLocationId:如果对同一地址范围的cudaMemRangeAttributeLastPrefetchLocationType查询返回cudaMemLocationTypeDevice,它将是有效的设备序号;如果查询返回cudaMemLocationTypeHostNuma,它将是有效的主机 NUMA 节点 ID。否则,应忽略该 ID。
此外,还可以使用相应的cudaMemRangeGetAttributes函数来查询多个属性。
4.1.4.5. GPU 内存超额使用
统一内存使应用程序能够超额使用任何单个处理器的内存:换言之,它们可以分配和共享大于系统中任何单个处理器内存容量的数组,从而能够对单个 GPU 无法容纳的数据集进行核外处理等操作,而不会显著增加编程模型的复杂性。
此外,还可以使用相应的cudaMemRangeGetAttributes函数来查询多个属性。