内存管理
CUDA是C语言的扩展,内存方面基本集成了C语言的方式,由程序员控制CUDA内存,当然,这些内存的物理设备是在GPU上的,而且与CPU内存分配不同,CPU内存分配完就完事了,GPU还涉及到数据传输,主机和设备之间的传输。
为达到最优性能,CUDA提供了在主机端准备设备内存的函数,并且显式地向设备传递数据,显式的从设备取回数据。
统一内存Unified Memory
统一内存是可从系统中的任何处理器访问的单个内存地址空间(参见上图)。 这种硬件/软件技术允许应用程序分配可以从 CPU 或 GPU 上运行的代码读取或写入的数据。 分配统一内存就像用调用 cudaMallocManaged() 替换对 malloc() 或 new 的调用一样简单,这是一个分配函数,它返回一个可从任何处理器访问的指针(下文中的 ptr)。
cudaError_t cudaMallocManaged(void** ptr, size_t size);
// 第一个参数,是指针的指针,一般的用法是先我们申明一个指针变量,然后调用这个函数
float * devMem=NULL;
cudaError_t cudaMalloc((float**) devMem, count)
devMem是一个指针,定义时初始化指向NULL,这样做是安全的,避免出现野指针,cudaMalloc函数要修改devMem的值,所以必须把他的指针传递给函数,如果把devMem当做参数传递,经过函数后,指针的内容还是NULL。
函数执行失败返回:cudaErrorMemoryAllocation.
当分配完地址后,可以使用下面函数进行初始化:
cudaError_t cudaMemset(void * devPtr,int value,size_t count)
用法和Memset类似,但是注意,这些被我们操作的内存对应的物理内存都在GPU上。
当分配的内存不被使用时,使用下面语句释放程序。
cudaError_t cudaFree(void * devPtr)
注意这个参数一定是前面cudaMalloc类的函数(还有其他分配函数)分配到空间,如果输入非法指针参数,会返回 cudaErrorInvalidDevicePointer 错误,如果重复释放一个空间,也会报错。
内存传输
C语言的内存分配完成后就可以直接读写了,但是对于异构计算,这样是不行的,因为主机线程不能访问设备内存,设备线程也不能访问主机内存,这时候我们要传送数据了
cudaError_t cudaMemcpy(void *dst,const void * src,size_t count,enum cudaMemcpyKind kind)
- 第一个参数dst:目标地址
- 第二个参数src:原始地址,
- count:拷贝的内存大小
- kind:传输类型,传输类型包括以下几种:
- cudaMemcpyHostToHost
- cudaMemcpyHostToDevice
- cudaMemcpyDeviceToHost
- cudaMemcpyDeviceToDevice
固定内存
主机内存采用分页式管理,通俗的说法就是操作系统把物理内存分成一些“页”,然后给一个应用程序一大块内存,但是这一大块内存可能在一些不连续的页上,应用只能看到虚拟的内存地址,而操作系统可能随时更换物理地址的页(从原始地址复制到另一个地址)但是应用是不会差觉得,但是从主机传输到设备上的时候,如果此时发生了页面移动,对于传输操作来说是致命的,所以在数据传输之前,CUDA驱动会锁定页面,或者直接分配固定的主机内存,将主机源数据复制到固定内存上,然后从固定内存传输数据到设备上:
上图左边是正常分配内存,传输过程是:锁页-复制到固定内存-复制到设备,右边时分配时就是固定内存,直接传输到设备上。
cudaError_t cudaMallocHost(void ** devPtr,size_t count)
分配count字节的固定内存,这些内存是页面锁定的,可以直接传输到设备的
固定的主机内存释放使用:
cudaError_t cudaFreeHost(void *ptr)
可以测试一下固定内存和分页内存的传输效率,代码如下:
#include <cuda_runtime.h>
#include <stdio.h>
#include "freshman.h"
void sumArrays(float * a,float * b,float * res,const int size)
{
for(int i=0;i<size;i+=4)
{
res[i]=a[i]+b[i];
res[i+1]=a[i+1]+b[i+1];
res[i+2]=a[i+2]+b[i+2];
res[i+3]=a[i+3]+b[i+3];
}
}
__global__ void sumArraysGPU(float*a,float*b,float*res)
{
int i=blockIdx.x*blockDim.x+threadIdx.x;
res[i]=a[i]+b[i];
}
int main(int argc,char **argv)
{
int dev = 0;
cudaSetDevice(dev);
int nElem=1<<14;
printf("Vector size:%d\n",nElem);
int nByte=sizeof(float)*nElem;
float *a_h=(float*)malloc(nByte);
float *b_h=(float*)malloc(nByte);
float *res_h=(float*)malloc(nByte);
float *res_from_gpu_h=(float*)malloc(nByte);
memset(res_h,0,nByte);
memset(res_from_gpu_h,0,nByte);
float *a_d,*b_d,*res_d;
// pine memory malloc
CHECK(cudaMallocHost((float**)&a_d,nByte));
CHECK(cudaMallocHost((float**)&b_d,nByte));
CHECK(cudaMallocHost((float**)&res_d,nByte));
initialData(a_h,nElem);
initialData(b_h,nElem);
CHECK(cudaMemcpy(a_d,a_h,nByte,cudaMemcpyHostToDevice));
CHECK(cudaMemcpy(b_d,b_h,nByte,cudaMemcpyHostToDevice));
dim3 block(1024);
dim3 grid(nElem/block.x);
sumArraysGPU<<<grid,block>>>(a_d,b_d,res_d);
printf("Execution configuration<<<%d,%d>>>\n",grid.x,block.x);
CHECK(cudaMemcpy(res_from_gpu_h,res_d,nByte,cudaMemcpyDeviceToHost));
sumArrays(a_h,b_h,res_h,nElem);
checkResult(res_h,res_from_gpu_h,nElem);
cudaFreeHost(a_d);
cudaFreeHost(b_d);
cudaFreeHost(res_d);
free(a_h);
free(b_h);
free(res_h);
free(res_from_gpu_h);
return 0;
}
零拷贝内存
截止到目前,我们所接触到的内存知识的基础都是:主机直接不能访问设备内存,设备不能直接访问主机内存。对于早期设备,这是肯定的,但是后来,一个例外出现了——零拷贝内存。
GPU线程可以直接访问零拷贝内存,这部分内存在主机内存里面,CUDA核函数使用零拷贝内存有以下几种情况:
当设备内存不足的时候可以利用主机内存
避免主机和设备之间的显式内存传输
提高PCIe传输率
设备和主机可以同时访问同一个设备地址了,注意主机和设备的内存竞争——当使用零拷贝内存的时候。
零拷贝内存是固定内存,不可分页。可以通过以下函数创建零拷贝内存:
cudaError_t cudaHostAlloc(void ** pHost,size_t count,unsigned int flags)
最后一个标志参数,可以选择以下值:
- cudaHostAllocDefalt:cudaHostAllocDefalt和cudaMallocHost函数一致
- cudaHostAllocPortable:返回能被所有CUDA上下文使用的固定内存
- cudaHostAllocWriteCombined:返回写结合内存,在某些设备上这种内存传输效率更高
- cudaHostAllocMapped:产生零拷贝内存
注意,零拷贝内存虽然不需要显式的传递到设备上,但是设备还不能通过pHost直接访问对应的内存地址,设备需要访问主机上的零拷贝内存,需要先获得另一个地址,这个地址帮助设备访问到主机对应的内存,方法是:
cudaError_t cudaHostGetDevicePointer(void ** pDevice,void * pHost,unsigned flags);
// pDevice就是设备上访问主机零拷贝内存的指针了
零拷贝内存可以当做比设备主存储器更慢的一个设备。频繁的读写,零拷贝内存效率极低,这个非常容易理解,因为每次都要经过PCIe
但是零拷贝内存也有例外的时候,比如当CPU和GPU继承在一起的时候,物理内存公用的,这时候零拷贝内存,效果相当不错。但是如果离散架构,主机和设备之间通过PCIe连接,那么零拷贝内存将会非常耗时。
统一虚拟寻址
设备架构2.0以后,Nvida又有新创意,他们搞了一套称为统一寻址虚拟方式(UVA)的内存机制,这样,设备内存和主机内存被映射到同一虚拟内存地址中。如图
UVA之前,我们要管理所有的设备和主机内存,尤其是他们的指针,零拷贝内存尤其麻烦,很容易乱的
通过UVA,cudaHostAlloc函数分配的固定主机内存具有相同的主机和设备地址,可以直接将返回的地址传递给核函数。
前面的零拷贝内存:
- 分配映射的固定主机内存
- 使用CUDA运行时函数获取映射到固定内存的设备指针
- 将设备指针传递给核函数
有了UVA,可以不用上面的那个获得设备上访问零拷贝内存的的指针 函数了:
cudaError_t cudaMallocManaged(void ** devPtr,size_t size,unsigned int flags=0
float *a_host,*b_host,*res_d;
CHECK(cudaHostAlloc((float**)&a_host,nByte,cudaHostAllocMapped));
CHECK(cudaHostAlloc((float**)&b_host,nByte,cudaHostAllocMapped));
CHECK(cudaMalloc((float**)&res_d,nByte));
res_from_gpu_h=(float*)malloc(nByte);
initialData(a_host,nElem);
initialData(b_host,nElem);
dim3 block(1024);
dim3 grid(nElem/block.x);
sumArraysGPU<<<grid,block>>>(a_host,b_host,res_d);
}
统一内存寻址
Nvidia的同志们还是不停的搞出新花样,CUDA6.0的时候又来了个统一内存寻址,注意不是同一虚拟寻址,统一内存中创建一个托管内存池(CPU上有,GPU上也有),内存池中已分配的空间可以通过相同的指针直接被CPU和GPU访问,底层系统在统一的内存空间中自动的进行设备和主机间的传输。数据传输对应用是透明的,大大简化了代码。
统一内存寻址提供了一个“指针到数据”的编程模型,概念上类似于零拷贝,但是零拷贝内存的分配是在主机上完成的,而且需要互相传输,但是统一寻址不同。
托管内存是指底层系统自动分配的统一内存,未托管内存就是我们自己分配的内存,这时候对于核函数,可以传递给他两种类型的内存,已托管和未托管内存,可以同时传递。
托管内存可以是静态的,也可以是动态的,添加 managed 关键字修饰托管内存变量。静态声明的托管内存作用域是文件,这一点可以注意一下。
托管内存分配方式:
cudaError_t cudaMallocManaged(void ** devPtr,size_t size,unsigned int flags=0)
CUDA6.0中设备代码不能调用cudaMallocManaged,只能主机调用,所有托管内存必须在主机代码上动态声明,或者全局静态声明