CUDA编程-04:CUDA内存模型

CUDA内存模型

CUDA中可编程内存的类型有:

  • 寄存器(Registers)
  • 本地内存(Local Memory)
  • 共享内存(Shared Memory)
  • 常量内存(Constant Memory)
  • 纹理内存(Texture Memory)
  • 全局内存(Global Memory)

这些内存空间的层次结构如下图所示,每种不同类型的内存空间都有不同的作用域、生命周期和缓存行为。在一个内核函数中,每个线程都有自己的本地内存,每个线程块有自己的共享内存并对块内的所有线程可见,一个线程网格中的所有线程都可以访问全局内存、常量内存和纹理内存,其中常量内存和纹理内存为只读内存空间。

CUDA编程-04:CUDA内存模型_第1张图片

如果对线程、线程块的概念不熟悉的,可以参考我的上一篇文章

寄存器

在内核函数中声明且没有其他修饰符修饰的变量通常是存放在GPU的寄存器中,比如下面代码中的线程索引变量i。寄存器通常用于存放内核函数中需要频繁访问的线程私有变量,这些变量与内核函数的生命周期相同,内核函数执行完毕后,就不能再对它们进行访问了。

__global__ void VectorAddGPU(const float *const a, const float *const b,
                             float *const c, const int n) {
  int i = blockDim.x * blockIdx.x + threadIdx.x; 
  if (i < n) {
    c[i] = a[i] + b[i]; 
  }
}

寄存器是GPU中访问速度最快的内存空间,但是一个SM中寄存器的数量比较有限,一旦内核函数使用了超过硬件限制的寄存器数量,则会使用本地内存来代替多占用的寄存器,这种寄存器溢出的情况会带来性能上的不利影响,实际编程过程中我们应该避免这种情况。使用nvcc的编译选项maxrregcount可以控制内核函数使用的寄存器的最大数量:

-maxrregcount=32

本地内存

在内核函数中符合存储在寄存器中但不能进入分配的寄存器空间中的变量将被溢出到本地内存中,可能存放到本地内存中的变量有:

  • 编译时使用未知索引引用的本地数组
  • 可能会占用大量寄存器空间的较大本地结构体或者数组
  • 任何不满足内核函数寄存器限定条件的变量

溢出到本地内存中的变量本质上与全局内存在同一块区域。

共享内存

在内核函数中被__shared__修饰符修饰的变量被存储到共享内存中。每个SM都有一定数量由线程块分配的共享内存,它们在内核函数内进行声明,生命周期伴随整个线程块,一个线程块执行结束后,为其分配的共享内存也被释放以便重新分配给其他线程块进行使用。线程块中的线程通过使用共享内存中的数据可以实现互相之间的协作,不过使用共享内存必须调用如下函数进行同步:

void __sybcthreads()

该函数为线程块中的所有线程设置了一个执行障碍点,使得同一线程块中的所有线程必须都执行到该障碍点才能往下执行,这样就可以避免一些潜在的数据冲突。

常量内存

常量变量用__constant__修饰符进行修饰,它们必须在全局空间内和所有内核函数之外进行声明,对同一编译单元中的内核函数都是可见的。常量变量存储在常量内存中,内核函数只能从常量内存中读取数据,常量内存必须在host端代码中使用下面的函数来进行初始化:

cudaError_t cudaMemcpyToSymbol(const void* symbol, const void* src,size_t count);

下面的例子展示了如何声明常量内存并与之进行数据交换:

__constant__ float const_data[256];
float data[256];
cudaMemcpyToSymbol(const_data, data, sizeof(data));
cudaMemcpyFromSymbol(data, const_data, sizeof(data));

常量内存适合用于线程束中的所有线程都需要从相同的内存地址中读取数据的情况,比如所有线程都需要的常量参数,每个GPU只可以声明不超过64KB的常量内存。

全局内存

全局内存是GPU中容量最大、延迟最高的内存空间,其作用域和生命空间都是全局的。一个全局内存变量可以在host代码中使用cudaMalloc函数进行动态声明,或者使用__device__修饰符在device代码中静态地进行声明。全局内存变量可以在任何SM设备中被访问到,其生命周期贯穿应用程序的整个生命周期。

下面的例子展示了如何静态声明并使用全局变量:

#include 
#include 

__device__ float dev_data;

__global__ void AddGlobalVariable(void) {
  printf("device, global variable before add: %.2f\n", dev_data);
  dev_data += 2.0f;
  printf("device, global variable after add: %.2f\n", dev_data);
}

int main(void) {
  float host_data = 4.0f;
  cudaMemcpyToSymbol(dev_data, &host_data, sizeof(float));
  printf("host, copy %.2f to global variable\n", host_data);
  AddGlobalVariable<<<1, 1>>>();
  cudaMemcpyFromSymbol(&host_data, dev_data, sizeof(float));
  printf("host, get %.2f from global variable\n", host_data);
  cudaDeviceReset();
  return 0;
}

上面的代码中需要注意的是,变量dev_data只是作为一个标识符存在,并不是device端的全局内存变量地址,所以不能直接使用cudaMemcpy函数把host上的数据拷贝到device端。不能直接在host端的代码中使用运算符&device端的变量进行取地址操作,因为它只是一个表示device端物理位置的符号,不过我们可以使用如下函数来获取它的地址:

cudaError_t cudaGetSymbolAddress(void** devPtr, const void* symbol);

这个函数用于获取device端的全局内存物理地址,获取地址后,就可以使用cudaMemcpy函数进行操作:

int main(void) {
  float host_data = 4.0f;
  float *dev_ptr = NULL;
  cudaGetSymbolAddress((void **)&dev_ptr, dev_data);
  cudaMemcpy(dev_ptr, &host_data, sizeof(float), cudaMemcpyHostToDevice);
  printf("host, copy %.2f to global variable\n", host_data);
  AddGlobalVariable<<<1, 1>>>();
  cudaMemcpy(&host_data, dev_ptr, sizeof(float), cudaMemcpyDeviceToHost);
  printf("host, get %.2f from global variable\n", host_data);
  cudaDeviceReset();
  return 0;
}

程序输出的结果如下:

host, copy 4.00 to global variable
device, global variable before add: 4.00
device, global variable after add: 6.00
host, get 6.00 from global variable

CUDA编程中,一般情况下device端的内核函数不能访问host端声明的变量,host端的函数也不能直接访问device端的变量,即使它们是在同一个文件内声明的

纹理内存

纹理内存驻留在设备内存中,并在每个SM的只读缓存中缓存。纹理内存是一种通过指定的只读缓存访问的全局内存,是对二维空间局部性的优化,所以使用纹理内存访问二维数据的线程可以达到最优性能。

缓存

GPU上有4种缓存:

  • 一级缓存
  • 二级缓存
  • 只读常量缓存
  • 只读纹理缓存

每个SM都有一个一级缓存,所有SM共享一个二级缓存,每个SM只有一个只读常量缓存和只读纹理缓存。一级和二级缓存用来存储本地内存和全局内存中的数据,包括寄存器溢出的部分。

固定内存

默认的host端的内存是可分页的,它按照操作系统的要求将主机虚拟内存上的数据移动到不同的物理位置。GPU不能在可分页的host端内存上安全地访问数据,因为当host端操作系统在物理位置上移动该数据时它无法控制。当从可分页的host端内存传输数据到device端内存时,CUDA驱动程序会先临时分配页面锁定的或固定的host端内存,再将host端的数据复制到该内存中,最后从该内存中把数据拷贝到device端的内存中。

CUDA编程-04:CUDA内存模型_第2张图片

CUDA提供下面的函数可以直接分配固定的主机内存:

cudaError_t cudaMallocHost(void **devPtr, size_t count);

这样分配的host端固定内存可以直接被device端访问,使得device端可以用很高的带宽进行读写操作。不过,过多地分配固定内存会降低host系统的性能,因为它能用于虚拟内存的可分页内存数量减少了。固定内存必须通过下面的函数进行释放:

cudaError_t cudaFreeHost(void *ptr);

零拷贝内存

前面说过,一般情况下host不能直接访问device端的变量,device也不能直接访问host端的变量。有一种例外的情况,那就是零拷贝内存,hostdevice都可以访问零拷贝内存。在内核函数中使用零拷贝内存有以下几个优势:

  • device内存不足时使用host内存
  • 避免devicehost之间显示的数据传输
  • 提高PCIe传输率

零拷贝内存是固定内存,CUDA提供下面的函数创建一个固定内存到device地址空间的映射:

cudaError_t cudaHostAlloc(void **pHost, size_t count, unsigned int flags);

flags参数可以选择以下几种

  • cudaHostAllocDefault:使cudaHostAlloc函数的行为与cudaMallocHost一致。
  • cudaHostAllocPortable:返回能被所有CUDA上下文使用的固定内存。
  • cudaHostAllocWriteCombined:返回写结合内存,该内存可以在某些系统配置上通过PCIe总线更快地传输。
  • cudaHostAllocMapped:返回被映射到device地址空间的host端内存。

使用下面的函数可以获取映射到固定内存的device端指针:

cudaError_t cudaHostGetDevicePointer(void **pDevice, void *pHost, unsigned int flags);

该函数得到的指针pDevice可以在device上进行引用以访问通过映射得到的host端的固定内存。

如果需要在hostdevice之间共享少量的数据,那么零拷贝内存会是一个不错的选择。不过对于需要频繁读写的操作,使用零拷贝内存会显著地降低程序的性能,因为每一次映射到内存的传输都需要通过PCIe总线进行。另外,使用零拷贝内存必须同步hostdevice的内存访问操作以避免潜在的数据冲突。

参考资料

  • CUDA C 编程权威指南
  • Professional CUDA C Programming
  • CUDA C Programming Guide
  • CUDA Programming:A Developer's Guide to Parallel Computing with GPUs

你可能感兴趣的:(CUDA编程,深度学习,c++,开发语言)