转载一篇从知乎看到比较好的文章(cuda笔记)

CUDA一些小知识整理

之前写过一篇《CUDA C Programming Guide》(《CUDA C 编程指南》)导读,整理了一下基础的知识,但没有真正实践过

最近由于工作需要使用到CUDA,因此需要再看一些书和博文,补一些知识。现将这些知识陈列在这里。

参考内容:

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);\
  }\
}

cuda所有kernel启动都是异步的

cudaMemcpy从设备端拷贝回主机端时,会触发一个隐式同步

cuda函数修饰符:

转载一篇从知乎看到比较好的文章(cuda笔记)_第1张图片

如果被定义为__global__,则会生成主机端和设备端两段代码

Kernel核函数编写有以下限制

  • 只能访问设备内存
  • 必须有void返回类型
  • 不支持可变数量的参数
  • 不支持静态变量
  • 显示异步行为

调试时,可以先把kernel配置成单线程执行

MyKernel<<<1,1>>>(参数)

使用nvprof可进行计时

需要sudo权限(当然注意sudo和普通用户路径差异的问题)

cudaMalloc占用的时间很长,显存分配好后尽量重复应用

查询设备信息

nvidia-smi -q    // 可以查询所有信息

当然也可以用API函数查

一篇绝赞好文章:【CUDA 基础】3.1 CUDA执行模型概述

讲了各个架构的硬件模型,有很多好内容、好图,对理解NVIDIA硬件很有帮助

线程束的一种分类方式

线程束

  • 被调度到SM上
    • 选定的线程束
      • active的线程束(满足分支条件)
      • 不active的线程束
    • 阻塞的线程束
    • ready的线程束(所有资源均已就绪)
  • 未被调度到SM上(寄存器、共享内存、程序计数器不够了)

延迟有两种:算数延迟和内存延迟,后者远长于前者;两者均应想办法隐藏

nvprof检测活跃的线程束

nvprof --metrics achieved_occupancy ./program

检测出来的是活跃线程束的比例,即每个周期活跃的线程束的平均值与一个sm支持的线程束最大值的比

nvprof检测内存性能

nvprof --metrics gld_throughput ./simple_sum_matrix
nvprof --metrics gld_efficiency ./simple_sum_matrix

动态并行与嵌套执行

等到执行的时候再配置创建多少个网格,多少个块,这样就可以动态的利用GPU硬件调度器和加载平衡器了,通过动态调整,来适应负载。

嵌套执行是内核调内核,涉及到一些隐式同步的问题,比较麻烦

父网格(父内核)调用子网格(子内核)的一种比较好的方式是:

转载一篇从知乎看到比较好的文章(cuda笔记)_第2张图片

CUDA Kernel中定长数组也是放在寄存器里的

本地内存(Local Memory)

编译器可能将如下三种数据放在本地内存:

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

对于2.0以上的设备,本地内存存储在每个SM的一级缓存,或者设备的二级缓存上。而不再是全局内存上。

设置共享内存和L1 Cache的比例

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来操作,这点和动态声明不同

与CPU不同的是,CPU读写过程都有可能被缓存,但是GPU写的过程不被缓存,只有加载会被缓存!

CUDA变量总结

转载一篇从知乎看到比较好的文章(cuda笔记)_第3张图片

固定内存(锁页内存)

为了防止拷贝时系统换页,CUDA会保证所有数据都放在锁页内存上:

  • 将已分配内存锁定
  • 直接分配锁页内存:cudaMallocHost/cudaFreeHost
  • 自动分配一块锁页内存,将数据源拷贝至该锁页内存,再启动拷贝,如下图:

转载一篇从知乎看到比较好的文章(cuda笔记)_第4张图片

但是注意:cudaMallocHost会比cudaMalloc慢,只是传输会比较快(不是应该包含的吗)

零拷贝内存

零拷贝内存在频繁读写的情况下效率极低,但是对于一些CPU和GPU使用同一块物理内存的情况下,效率极高

同一虚拟寻址(UVA)

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的时候,这个时候被称为对齐内存访问,非对齐访问就是除上述的其他情况,非对齐的内存访问会造成带宽浪费。
当一个线程束内的线程访问的内存都在一个内存块里的时候,就会出现合并访问。
对齐合并访问的状态是理想化的,也是最高速的访问方式,当线程束内的所有线程访问的数据在一个内存块,并且数据是从内存块的首地址开始被需要的,那么对齐合并访问出现了。为了最大化全局内存访问的理想状态,尽量将线程束访问内存组织成对齐合并的方式,这样的效率是最高的。

下面是有关合并访问和对齐访问的一个例子:

转载一篇从知乎看到比较好的文章(cuda笔记)_第5张图片

(终于把这个问题搞明白了...)

一篇绝赞好文章【CUDA 基础】4.3 内存访问模式,把NVIDIA GPU的内存特性描述的很清楚,解决了我以前很多疑点

只读缓存

计算能力在3.5以上的设备,可以将全局内存中的数据缓存到只读缓存中。

这两块内存粒度为32Bytes,速度和L1 Cache差不多,粒度更细,利用率更高

只读缓存有从全局内存的专用带宽

每个SM上只读缓存48KB

只读缓存独立存在,区别于常量缓存,常量缓存喜欢小数据,而只读内存加载的数据比较大,可以在非统一模式下访问
统一访问(读取同一地址)

有两种方法指导内存从只读缓存读取:

  • 使用函数 _ldg
  • 在间接引用的指针上使用__restrict__限定修饰符
__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];
}

全局内存写入时,Fermi架构核Kepler架构不经过L1 Cache,只经过L2 Cache,粒度为32Bytes

AoS 和 SoA

转载一篇从知乎看到比较好的文章(cuda笔记)_第6张图片

SoA布局访存特性更好,性能更优

共享内存分配

  • 静态分配:可以分配1、2、3维
__shared__ float a[size_x][size_y];    // 静态分配,size_x、size_y必须编译时可确定
  • 动态分配:只能分配1维,不需要手动回收
extern __shared__ int tile[];          // 必须添加extern关键字,表明运行时才知道

MyKernel<<>>(...);    // 启动Kernel时传入动态共享内存大小

共享内存存储体的冲突问题

共分为32个大小相同的存储体

有三种经典的访问方式:

  1. 并行访问,多地址访问多存储体

完全无冲突的话,一个内存事务即可访问完毕

有小部分冲突时,不冲突的部分一个内存事务可访问完毕,冲突的要多次内存事务才可访问完毕

2. 串行访问

完全冲突就是串行访问了,发生在读写同一个存储体的不同地址,或写同一个地址时

3. 广播访问

读同一个地址时会触发广播访问:一个内存事务执行完后,一个线程会获取到数据,他会以广播的方式告诉其他线程

虽然延迟低,但实际上内存带宽利用率差

转载一篇从知乎看到比较好的文章(cuda笔记)_第7张图片

共享内存的访问模式(其实指的是带宽)

转载一篇从知乎看到比较好的文章(cuda笔记)_第8张图片

存储体宽度为4(Bytes)的存储体数据排布情况

转载一篇从知乎看到比较好的文章(cuda笔记)_第9张图片

存储体宽度为8的存储体数据排布情况,此时访问0和访问32虽然在同一个存储体内,但是不会引发冲突

转载一篇从知乎看到比较好的文章(cuda笔记)_第10张图片

转载一篇从知乎看到比较好的文章(cuda笔记)_第11张图片

访问模式(带宽)是可查询和可配置的

cudaError_t cudaDeviceGetSharedMemConfig(cudaSharedMemConfig * pConfig);
cudaError_t cudaDeviceSetShareMemConfig(cudaSharedMemConfig config);

使用Padding避免存储体冲突

转载一篇从知乎看到比较好的文章(cuda笔记)_第12张图片

转载一篇从知乎看到比较好的文章(cuda笔记)_第13张图片

添加padding的两种方式:

  • 静态padding:静态声明时列方向加一个常量
__shared__ int tile[BDIMY][BDIMX+IPAD];
  • 动态padding
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();
}

内存栅栏

内存栅栏能保证栅栏前的内核内存写操作对栅栏后的其他线程都是可见的,有以下三种栅栏:块,网格,系统。

转载一篇从知乎看到比较好的文章(cuda笔记)_第14张图片

Volatile

防止编译器优化,变量修改后立马写回显存,不暂存在寄存器里;Volatile修饰的变量始终在显存中

转载一篇从知乎看到比较好的文章(cuda笔记)_第15张图片

nvprof看共享内存的内存事务

nvprof  --metrics shared_load_transactions_per_request ./shared_memory_read
nvprof  --metrics shared_store_transactions_per_request ./shared_memory_read

#pragma unroll自动循环展开

warp shuffle指令

允许线程束内的线程直接互相访问各自的寄存器,不需要利用共享内存、全局内存交换数据

int __shfl(int var,int srcLane,int width=warpSize);

转载一篇从知乎看到比较好的文章(cuda笔记)_第16张图片

srcLane会对width进行取余

int __shfl_up(int var,unsigned int delta,int with=warpSize);
int __shfl_down(int var,unsigned int delta,int with=warpSize);

转载一篇从知乎看到比较好的文章(cuda笔记)_第17张图片

int __shfl_xor(int var,int laneMask,int with=warpSize);    // 按位亦或

转载一篇从知乎看到比较好的文章(cuda笔记)_第18张图片

洗牌指令可以用于下面三种整数变量类型中:

  • 标量变量
  • 数组
  • 向量型变量

CUDA流

  • 空流:隐式声明的流,无法管理
  • 非空流:显式声明的流

基于流的异步内核启动和数据传输支持以下类型的粗粒度并发

  • 重叠主机和设备计算
  • 重叠主机计算和主机设备数据传输
  • 重叠主机设备数据传输和设备计算
  • 并发设备计算(多个设备)

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);    // 给流分配资源

转载一篇从知乎看到比较好的文章(cuda笔记)_第19张图片

在非空流中启动内核需要加额外的启动参数:

MyKernel<<>>(...);

回收流的资源:

cudaError_t cudaStreamDestroy(cudaStream_t stream);
由于流和主机端是异步的,你在使用上面指令回收流的资源的时候,很有可能流还在执行,这时候,这条指令会正常执行,但是不会立刻停止流,而是等待流执行完成后,立刻回收该流中的资源。这样做是合理的也是安全的

查询流执行状态:

cudaError_t cudaStreamSynchronize(cudaStream_t stream);    // 阻塞主机,直到流完成
cudaError_t cudaStreamQuery(cudaStream_t stream);    // 立即返回,执行完了返回cudaSuccess,否则返回cudaErrorNotReady

使用流可以重叠数据移动和运算,利用并发加速:

转载一篇从知乎看到比较好的文章(cuda笔记)_第20张图片

内核并发最大数量也是有极限的,不同计算能力的设备不同,Fermi设备支持16路并发,Kepler支持32路并发。设备上的所有资源都是限制并发数量的原因,比如共享内存,寄存器,本地内存,这些资源都会限制最大并发数。

流调度

Fermi架构会有虚假依赖问题:

转载一篇从知乎看到比较好的文章(cuda笔记)_第21张图片

Hyper-Q技术解决了虚假依赖问题,使用多个工作队列,尽可能的并发:

转载一篇从知乎看到比较好的文章(cuda笔记)_第22张图片

流的优先级

3.5以上的设备可以给流优先级,也就是优先级高的(数字上更小的,类似于cpp运算符优先级)

优先级只影响核函数,不影响数据传输,高优先级的流可以占用低优先级的工作。

下面函数创建一个有指定优先级的流:

cudaError_t cudaStreamCreateWithPriority(cudaStream_t* pStream, unsigned int flags,int priority);

下面函数可以查询当前设备的优先级分布情况:

cudaError_t cudaDeviceGetStreamPriorityRange(int *leastPriority, int *greatestPriority);

CUDA事件

CUDA事件可用来:

  • 同步流执行
  • 监控设备的进展

流中的任意点都可以通过API插入事件以及查询事件完成的函数,只有事件所在流中其之前的操作都完成后才能触发事件完成。默认流中设置事件,那么其前面的所有操作都完成时,事件才触发完成。

cudaEvent_t event;
cudaError_t cudaEventCreate(cudaEvent_t* event);
cudaError_t cudaEventDestroy(cudaEvent_t event);    // 如果回收指令执行的时候事件还没有完成,那么回收指令立即完成,
                                                       当事件完成后,资源马上被回收。

通过CUDA事件计时

事件通过如下函数添加到流中:

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这个函数是异步的,所以加入时间完全不可控,不能保证两个事件之间的间隔刚好是两个事件之间的。

CUDA事件可配置参数

cudaError_t cudaEventCreateWithFlags(cudaEvent_t* event, unsigned int flags);

cudaEventDefault
cudaEventBlockingSync
cudaEventDisableTiming
cudaEventInterprocess
其中cudaEventBlockingSync指定使用cudaEventSynchronize同步会造成阻塞调用线程。cudaEventSynchronize默认是使用cpu周期不断重复查询事件状态,而当指定了事件是cudaEventBlockingSync的时候,会将查询放在另一个线程中,而原始线程继续执行,直到事件满足条件,才会通知原始线程,这样可以减少CPU的浪费,但是由于通讯的时间,会造成一定的延迟。
cudaEventDisableTiming表示事件不用于计时,可以减少系统不必要的开支也能提升cudaStreamWaitEvent和cudaEventQuery的效率
cudaEventInterprocess表明可能被用于进程之间的事件

流同步

转载一篇从知乎看到比较好的文章(cuda笔记)_第23张图片

阻塞流和非阻塞流

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));
}

回调函数需要遵循如下规则:

  • 回调函数里不能调用CUDA的API
  • 不可以执行同步操作

你可能感兴趣的:(CUDA,C)