之前写过一篇《CUDA C Programming Guide》(《CUDA C 编程指南》)导读,整理了一下基础的知识,但没有真正实践过
最近由于工作需要使用到CUDA,因此需要再看一些书和博文,补一些知识。现将这些知识陈列在这里。
参考内容:
CUDA 基础知识博客整理
char* cudaGetErrorString(cudaError_t error) // 将错误码转换成字符串
// 可以使用如下宏
#define CHECK(call)\
{\
const cudaError_t error=call;\
if(error!=cudaSuccess)\
{\
printf("ERROR: %s:%d,",__FILE__,__LINE__);\
printf("code:%d,reason:%s\n",error,cudaGetErrorString(error));\
exit(1);\
}\
}
如果被定义为__global__,则会生成主机端和设备端两段代码
MyKernel<<<1,1>>>(参数)
需要sudo权限(当然注意sudo和普通用户路径差异的问题)
cudaMalloc占用的时间很长,显存分配好后尽量重复应用
nvidia-smi -q // 可以查询所有信息
当然也可以用API函数查
讲了各个架构的硬件模型,有很多好内容、好图,对理解NVIDIA硬件很有帮助
线程束
nvprof --metrics achieved_occupancy ./program
检测出来的是活跃线程束的比例,即每个周期活跃的线程束的平均值与一个sm支持的线程束最大值的比
nvprof --metrics gld_throughput ./simple_sum_matrix
nvprof --metrics gld_efficiency ./simple_sum_matrix
等到执行的时候再配置创建多少个网格,多少个块,这样就可以动态的利用GPU硬件调度器和加载平衡器了,通过动态调整,来适应负载。
嵌套执行是内核调内核,涉及到一些隐式同步的问题,比较麻烦
父网格(父内核)调用子网格(子内核)的一种比较好的方式是:
编译器可能将如下三种数据放在本地内存:
对于2.0以上的设备,本地内存存储在每个SM的一级缓存,或者设备的二级缓存上。而不再是全局内存上。
cudaError_t cudaFuncSetCacheConfig(const void * func,enum cudaFuncCache);
cudaFuncCachePreferNone//无参考值,默认设置
cudaFuncCachePreferShared//48k共享内存,16k一级缓存
cudaFuncCachePreferL1// 48k一级缓存,16k共享内存
cudaFuncCachePreferEqual// 32k一级缓存,32k共享内存
每个SM都有自己专属的常量内存cache,大小为64KB
常量内存有广播的特性,即可以将一块内存的数据一次性给一个线程束;因此类似于多项式系数等变量最好放到常量内存里
常量内存的最优访问方式是所有线程访问同一个数据
__constant__ // 全局可见,生命周期为整个程序运行期间,所有线程网格的所有线程均可见
cudaError_t cudaMemcpyToSymbol(const void* symbol,const void *src,size_t count); // 只有主机端可以修改,同步
动态声明:cudaMalloc,用cudaMemcpy拷贝
静态声明:__device__,可以放到kernel内部,也可以放到外部声明为全局的
可以在设备端直接赋值;但由于主机端不能直接访问全局内存,需要使用cudaMemcpyToSymbol/cudaMemcpyFromSymbol来操作,这点和动态声明不同
为了防止拷贝时系统换页,CUDA会保证所有数据都放在锁页内存上:
但是注意:cudaMallocHost会比cudaMalloc慢,只是传输会比较快(不是应该包含的吗)
零拷贝内存在频繁读写的情况下效率极低,但是对于一些CPU和GPU使用同一块物理内存的情况下,效率极高
cudaError_t cudaHostAlloc(void ** pHost,size_t count,unsigned int flags)
64位系统自动激活UVA,自动实现上述的零拷贝内存
使用cudaHostAlloc分配的内存,其指针可以被主机端核设备端共用
UVA可以简化开发,不过不如手动控制数据位置效率高
Kernel读取全局内存只有两种粒度:32Bytes和128Bytes
哪怕只要一个字节,也必须从全局内存读出来32Bytes
具体是多少Bytes取决与是否使用L1 Cache
对于CPU来说,一级缓存或者二级缓存是不能被编程的,但是CUDA是支持通过编译指令停用一级缓存的。如果启用一级缓存,那么每次从DRAM上加载数据的粒度是128字节,如果不使用一级缓存,只是用二级缓存,那么粒度是32字节。
我们把一次内存请求——也就是从内核函数发起请求,到硬件响应返回数据这个过程称为一个内存事务(加载和存储都行)。
当一个内存事务的首个访问地址是缓存粒度(32或128字节)的偶数倍的时候:比如二级缓存32字节的偶数倍64,128字节的偶数倍256的时候,这个时候被称为对齐内存访问,非对齐访问就是除上述的其他情况,非对齐的内存访问会造成带宽浪费。
当一个线程束内的线程访问的内存都在一个内存块里的时候,就会出现合并访问。
对齐合并访问的状态是理想化的,也是最高速的访问方式,当线程束内的所有线程访问的数据在一个内存块,并且数据是从内存块的首地址开始被需要的,那么对齐合并访问出现了。为了最大化全局内存访问的理想状态,尽量将线程束访问内存组织成对齐合并的方式,这样的效率是最高的。
下面是有关合并访问和对齐访问的一个例子:
(终于把这个问题搞明白了...)
计算能力在3.5以上的设备,可以将全局内存中的数据缓存到只读缓存中。
这两块内存粒度为32Bytes,速度和L1 Cache差不多,粒度更细,利用率更高
只读缓存有从全局内存的专用带宽
每个SM上只读缓存48KB
只读缓存独立存在,区别于常量缓存,常量缓存喜欢小数据,而只读内存加载的数据比较大,可以在非统一模式下访问
统一访问(读取同一地址)
有两种方法指导内存从只读缓存读取:
__global__ void copyKernel(float * in,float* out)
{
int idx=blockDim*blockIdx.x+threadIdx.x;
out[idx]=__ldg(&in[idx]);
}
void kernel(float* output, const float* __restrict__ input) {
...
output[idx] += input[idx];
}
SoA布局访存特性更好,性能更优
__shared__ float a[size_x][size_y]; // 静态分配,size_x、size_y必须编译时可确定
extern __shared__ int tile[]; // 必须添加extern关键字,表明运行时才知道
MyKernel<<>>(...); // 启动Kernel时传入动态共享内存大小
共分为32个大小相同的存储体
有三种经典的访问方式:
完全无冲突的话,一个内存事务即可访问完毕
有小部分冲突时,不冲突的部分一个内存事务可访问完毕,冲突的要多次内存事务才可访问完毕
2. 串行访问
完全冲突就是串行访问了,发生在读写同一个存储体的不同地址,或写同一个地址时
3. 广播访问
读同一个地址时会触发广播访问:一个内存事务执行完后,一个线程会获取到数据,他会以广播的方式告诉其他线程
虽然延迟低,但实际上内存带宽利用率差
存储体宽度为4(Bytes)的存储体数据排布情况
存储体宽度为8的存储体数据排布情况,此时访问0和访问32虽然在同一个存储体内,但是不会引发冲突
访问模式(带宽)是可查询和可配置的
cudaError_t cudaDeviceGetSharedMemConfig(cudaSharedMemConfig * pConfig);
cudaError_t cudaDeviceSetShareMemConfig(cudaSharedMemConfig config);
添加padding的两种方式:
__shared__ int tile[BDIMY][BDIMX+IPAD];
unsigned int row_idx=threadIdx.y*(blockDim.x+1)+threadIdx.x;
unsigned int col_idx=threadIdx.x*(blockDim.x+1)+threadIdx.y;
CUDA的内存访问并不严格按照程序里写的顺序执行,例如连续的两个访存指令,谁先谁后执行是不确定的
在另一本书里也有写过,CUDA是懒惰内存,只有需要数据但还没到的时候才会开始阻塞
void __syncthreads();
同一线程块内的线程都到达此后,才会继续执行;所有这之前的内存访问也会全部完成
同一线程块内此障碍点之前的所有全局内存,共享内存操作,对后面的线程都是可见的。
有可能会造成死锁:
if (threadID % 2 == 0) {
__syncthreads();
} else {
__syncthreads();
}
内存栅栏能保证栅栏前的内核内存写操作对栅栏后的其他线程都是可见的,有以下三种栅栏:块,网格,系统。
防止编译器优化,变量修改后立马写回显存,不暂存在寄存器里;Volatile修饰的变量始终在显存中
nvprof --metrics shared_load_transactions_per_request ./shared_memory_read
nvprof --metrics shared_store_transactions_per_request ./shared_memory_read
允许线程束内的线程直接互相访问各自的寄存器,不需要利用共享内存、全局内存交换数据
int __shfl(int var,int srcLane,int width=warpSize);
srcLane会对width进行取余
int __shfl_up(int var,unsigned int delta,int with=warpSize);
int __shfl_down(int var,unsigned int delta,int with=warpSize);
int __shfl_xor(int var,int laneMask,int with=warpSize); // 按位亦或
洗牌指令可以用于下面三种整数变量类型中:
基于流的异步内核启动和数据传输支持以下类型的粗粒度并发
cudaMemcpy是同步操作,异步操作版本:
cudaError_t cudaMemcpyAsync(void* dst, const void* src, size_t count,cudaMemcpyKind kind, cudaStream_t stream = 0);
stream=0代表默认的空流
cudaStream_t a;
cudaError_t cudaStreamCreate(cudaStream_t* pStream); // 给流分配资源
在非空流中启动内核需要加额外的启动参数:
MyKernel<<>>(...);
回收流的资源:
cudaError_t cudaStreamDestroy(cudaStream_t stream);
由于流和主机端是异步的,你在使用上面指令回收流的资源的时候,很有可能流还在执行,这时候,这条指令会正常执行,但是不会立刻停止流,而是等待流执行完成后,立刻回收该流中的资源。这样做是合理的也是安全的
查询流执行状态:
cudaError_t cudaStreamSynchronize(cudaStream_t stream); // 阻塞主机,直到流完成
cudaError_t cudaStreamQuery(cudaStream_t stream); // 立即返回,执行完了返回cudaSuccess,否则返回cudaErrorNotReady
使用流可以重叠数据移动和运算,利用并发加速:
内核并发最大数量也是有极限的,不同计算能力的设备不同,Fermi设备支持16路并发,Kepler支持32路并发。设备上的所有资源都是限制并发数量的原因,比如共享内存,寄存器,本地内存,这些资源都会限制最大并发数。
Fermi架构会有虚假依赖问题:
Hyper-Q技术解决了虚假依赖问题,使用多个工作队列,尽可能的并发:
3.5以上的设备可以给流优先级,也就是优先级高的(数字上更小的,类似于cpp运算符优先级)
优先级只影响核函数,不影响数据传输,高优先级的流可以占用低优先级的工作。
下面函数创建一个有指定优先级的流:
cudaError_t cudaStreamCreateWithPriority(cudaStream_t* pStream, unsigned int flags,int priority);
下面函数可以查询当前设备的优先级分布情况:
cudaError_t cudaDeviceGetStreamPriorityRange(int *leastPriority, int *greatestPriority);
CUDA事件可用来:
流中的任意点都可以通过API插入事件以及查询事件完成的函数,只有事件所在流中其之前的操作都完成后才能触发事件完成。默认流中设置事件,那么其前面的所有操作都完成时,事件才触发完成。
cudaEvent_t event;
cudaError_t cudaEventCreate(cudaEvent_t* event);
cudaError_t cudaEventDestroy(cudaEvent_t event); // 如果回收指令执行的时候事件还没有完成,那么回收指令立即完成,
当事件完成后,资源马上被回收。
事件通过如下函数添加到流中:
cudaError_t cudaEventRecord(cudaEvent_t event, cudaStream_t stream = 0);
查询事件状态:
cudaError_t cudaEventSynchronize(cudaEvent_t event); // 会阻塞主机线程知道事件被完成
cudaError_t cudaEventQuery(cudaEvent_t event); // 异步查询
记录两个事件的时间间隔:
cudaError_t cudaEventElapsedTime(float* ms, cudaEvent_t start, cudaEvent_t stop);
这个函数记录两个事件start和stop之间的时间间隔,单位毫秒,两个事件不一定是同一个流中。这个时间间隔可能会比实际大一些,因为cudaEventRecord这个函数是异步的,所以加入时间完全不可控,不能保证两个事件之间的间隔刚好是两个事件之间的。
cudaError_t cudaEventCreateWithFlags(cudaEvent_t* event, unsigned int flags);
cudaEventDefault
cudaEventBlockingSync
cudaEventDisableTiming
cudaEventInterprocess
其中cudaEventBlockingSync指定使用cudaEventSynchronize同步会造成阻塞调用线程。cudaEventSynchronize默认是使用cpu周期不断重复查询事件状态,而当指定了事件是cudaEventBlockingSync的时候,会将查询放在另一个线程中,而原始线程继续执行,直到事件满足条件,才会通知原始线程,这样可以减少CPU的浪费,但是由于通讯的时间,会造成一定的延迟。
cudaEventDisableTiming表示事件不用于计时,可以减少系统不必要的开支也能提升cudaStreamWaitEvent和cudaEventQuery的效率
cudaEventInterprocess表明可能被用于进程之间的事件
cudaStreamCreate创建的是阻塞流,里面有些操作会阻塞,直到空流中某些操作完成
空流时阻塞流,且与所有阻塞流同步
不同阻塞流的kernel不会并发执行,只会一个一个执行
创建非阻塞流的方法:
cudaError_t cudaStreamCreateWithFlags(cudaStream_t* pStream, unsigned int flags);
cudaStreamDefault;// 默认阻塞流
cudaStreamNonBlocking: //非阻塞流,对空流的阻塞行为失效。
非阻塞流可以同时并发执行
隐式操作常出现在内存操作上,比如:
常见的同步有:
cudaError_t cudaDeviceSynchronize(void); // 阻塞主机线程,直到设备完成所有操作
cudaError_t cudaStreamSynchronize(cudaStream_t stream); // 阻塞主机直到流完成,用来同步流的
cudaError_t cudaStreamQuery(cudaStream_t stream); // 非阻塞流测试,查询流是否完成
cudaError_t cudaEventSynchronize(cudaEvent_t event);
cudaError_t cudaEventQuery(cudaEvent_t event);
cudaError_t cudaStreamWaitEvent(cudaStream_t stream, cudaEvent_t event);
// 指定的流要等待指定的事件,事件完成后流才能继续,这个事件可以在这个流中,也可以不在
// 当在不同的流的时候,这个就是实现了跨流同步
使用如下函数将回调函数加入流中:
cudaError_t cudaStreamAddCallback(cudaStream_t stream,cudaStreamCallback_t callback,
void *userData, unsigned int flags);
回调函数应按如下方式定义:
void CUDART_CB my_callback(cudaStream_t stream, cudaError_t status, void *data) {
printf("callback from stream %d\n", *((int *)data));
}
回调函数需要遵循如下规则: