在CUDA
中可编程内存的类型有:
这些内存空间的层次结构如下图所示,每种不同类型的内存空间都有不同的作用域、生命周期和缓存行为。在一个内核函数中,每个线程都有自己的本地内存,每个线程块有自己的共享内存并对块内的所有线程可见,一个线程网格中的所有线程都可以访问全局内存、常量内存和纹理内存,其中常量内存和纹理内存为只读内存空间。
如果对线程、线程块的概念不熟悉的,可以参考我的上一篇文章
在内核函数中声明且没有其他修饰符修饰的变量通常是存放在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
提供下面的函数可以直接分配固定的主机内存:
cudaError_t cudaMallocHost(void **devPtr, size_t count);
这样分配的host
端固定内存可以直接被device
端访问,使得device
端可以用很高的带宽进行读写操作。不过,过多地分配固定内存会降低host
系统的性能,因为它能用于虚拟内存的可分页内存数量减少了。固定内存必须通过下面的函数进行释放:
cudaError_t cudaFreeHost(void *ptr);
前面说过,一般情况下host
不能直接访问device
端的变量,device
也不能直接访问host
端的变量。有一种例外的情况,那就是零拷贝内存,host
和device
都可以访问零拷贝内存。在内核函数中使用零拷贝内存有以下几个优势:
device
内存不足时使用host
内存device
和host
之间显示的数据传输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
端的固定内存。
如果需要在host
和device
之间共享少量的数据,那么零拷贝内存会是一个不错的选择。不过对于需要频繁读写的操作,使用零拷贝内存会显著地降低程序的性能,因为每一次映射到内存的传输都需要通过PCIe
总线进行。另外,使用零拷贝内存必须同步host
和device
的内存访问操作以避免潜在的数据冲突。
CUDA C 编程权威指南
》Professional CUDA C Programming
》CUDA C Programming Guide
》CUDA Programming:A Developer's Guide to Parallel Computing with GPUs
》