cuda有点难学不知道是不是自己脑仁儿不行~~

基本编程模型

最基本的概念就是显存、kernel函数、线程块、stream:

线程块在SM内部执行时,又进一步细分为线程束(Warp)。截止目前所有的NVIDIA GPU中,线程束均由32个线程组成。线程束(Warp)的执行采用SIMT(单指令多数据)模型。SMSP就是真正负责线程束执行的硬件单元。

按照NVIDIA的文档,2080 Ti每个SM上最多同时存在1024个线程,即32个warp。这些Warp平均分配到4个SMSP上,每个SMSP负责8个Warp的执行。

与CPU进行线程切换时需要将当前线程寄存器内容储存到内存,再从内存中读取出目标线程的寄存器内容不同。SMSP在不同的Warp间切换执行的代价是非常低的,因为SMSP有足够的寄存器分给每一个正在执行的线程独占使用。如负责8个Warp合计256线程执行的SMSP,有16384个32位通用寄存器,平均每个线程在生命周期内独占至少64个寄存器。

Warp有以下状态:

在每个时钟周期,SMSP内的Warp Scheduler+Dispatch从其负责的8个warp中选中一个Eligible Warp,发射(issue)该warp的一条指令。当没有任何一个warp处于Eligible状态时,该周期被跳过。  

当某warp的一条指令被发射后,SMSP的对应INT32、FP32或其它单元就会进行对应的计算。当前实例架构中有16个FP32单元,所以一个warp的浮点运算ALU需要两个周期才能算完。故每个2个周期发射一条浮点指令即可使FP32单元保持满载。空余的那一个周期可以穿插发射一些访存、INT32等其它指令。

SM的版本是由Compute Capacity表示的。绝大多数情况下,同一个系列的显卡有相同的Compute Capacity,代表其SM的架构是一样的,主要区别在SM数量、显存大小、工作主频等其他方面。

优化技巧
使用异步API

使用异步API如cudaMemcpyAsync可让GPU操作与CPU操作并行,CPU忙完后调用cudaStreamSynchronize,cudaEventWait等操作等待GPU任务完成。

优化内存与显存传输效率
优化Kernel访存效率

高的Occupancy不一定代表较高的性能,如某些算法确实需要每线程128寄存器时,保持0.5的Occupancy反而是最优选择。但过低的Occupancy会对性能带来较大的负面影响。

指令级优化

GPU执行计算时,需要LDS、LDG等指令先将数据读入寄存器,再进行计算,最后通过STS、STG等指令将数据保存下来。

以矩阵乘法为例,先进行矩阵分块,最终拆解为每个线程计算MxK,KxN的两个小矩阵的乘法:

若两小矩阵为M=2,N=2,K=1,即2x1;1x2,最后得到2x2的矩阵作为结果。则读入4个float需4条指令,计算指令也是4条,计算访存比4/4=1;

若两小矩阵为M=8,N=8,K=1,即8x1;1x8,最后得到8x8的矩阵作为结果。则读入16个float,需读取指令16条,计算指令8x8=64条,计算访存比64/16=4;若使用向量读(float4)每条指令读入4个float,则读取指令仅4条,计算访存比64/4=16

提高计算访存比,可以让GPU的更多时钟周期用于进行计算,相对的进行数据IO占用的时钟周期更少。

指令级并行基本原理:

通过以下方式,可以提高指令级并行,在线程级并行达不到较好效果的情况下,进一步提高程序性能:

使用TensorCore进一步加速矩阵运算

TensorCore可以用来快速进行D=A*B+C矩阵运算,提供load_matrix_sync, store_matrix_sync, mma_sync 等API。

使用CUDA生态的各库

NVIDIA已经提供了不少库,效率高性能好,合理使用可以大大提高开发效率,减少开发工作量。

优化线程级并行

在SMSP工作时,某些warp会由于访存依赖、寄存器依赖等原因stall。此时warp scheduler可以选中另一个eligible warp,执行其指令,以隐藏前一个warp的stall,使SMSP中的各个硬件资源尽量保持忙碌。但假如SMSP中所有的warp都不在eligible状态,则硬件只能空转等待某个warp从stall中恢复(如从global中请求的数据终于回来了)。

Occupancy[4]指标用来衡量SM当前activate warp数量与理论上最多支持的activate warp数量的比值。Occupancy数量越高,代表SMSP负责的activate warp越多,当某个warp stall时,有更多的备选warp,有更大的概率可以找到一个eligible warp。极端情况Occupancy为1/8时,SM仅4个warp,每个SMSP 1个warp,当该warp stall时,smsp没有其它warp可以选择,硬件必然空转等待。

影响Occupancy指标的包括以下因素:

  1. 开发者可以通过CUDA Runtime API,申请、释放显存,并在内存和显存间进行数据拷贝。
  2. 开发者可以编写专用于在GPU上执行的kernel函数,在主机侧通过CUDA C扩展调用kernel函数,调用将创建数以万计的GPU线程,每个GPU线程均会完整执行一次kernel函数,kernel函数内可以对显存进行读、写等各种操作。数以万计的GPU线程之间靠只读的内置变量(线程ID等)互相区分。
  3. 一次kernel调用对应的GPU线程,需划分为一个个尺寸相同的线程块。线程块是向GPU进行调度的最小单位,GPU同时支持多个线程块的执行,达到上限后,只有旧的线程块内的线程全部执行完成后,新的线程块才会被调度入GPU。
  4. stream相当于是GPU上的任务队列。每个kernel调用或大多数CUDA API都可以指定关联到某一个stream。同一个stream的任务是严格保证顺序的,上一个命令执行完成才会执行下一个命令。不同stream的命令不保证任何执行顺序。部分优化技巧需要用到多个stream才能实现。如在执行kernel的同时进行数据拷贝,需要一个stream执行kernel,另一个stream进行数据拷贝。
基本硬件架构及其在Kernel执行中的作用

显卡由内部的主板、显存颗粒、GPU芯片等组成。GPU内部的架构如下图:


可以看到GPU由许许多多的SM(Stream Multiprocesser)组成。SM内部的架构如下图:

  1. 线程块就是被调度到SM上执行的,按照线程块占用的硬件资源不同,SM可以同时执行一个或多个线程块。
    SM内部的主要部件有:
  2. L1 Cache及Shared Memory。
  3. L1 Cache是高速缓存。
  4. Shared Memory是一块可以由开发者编程控制的高速缓存。开发者可以在kernel函数内通过编程手段写入或读取数据。其访问latency远远小于全局显存。
  5. SM分为4个子区域,称为SMSP。每个SMSP包括如下功能单元。
  6. Warp Scheduler+Dispatch
  7. Register File寄存器文件
  8. 16个INT32、16个FP32、2个Tensor Core及多个LD/ST等单元
  9. Active:被调度到某个SMSP的warp。
  10. Eligible:就绪执行下一条指令的warp(即warp未stalled)。
  11. ...
  12. 使用Pinned(page-locked) Memory提高传输速度
  13. 通过在不同的Stream里同时分别执行kernel调用及数据传输,使数据传输与运算并行。(注意default stream的坑[1])
  14. 尽量将小的数据在GPU端合成大块数据后传输
  15. 有些情况下,即使数据不太适合使用kernel处理,但如果为了较低的算法latency,也可权衡传输代价后使用kernel处理数据
  16. 注意PCI-e插口的通道个数
  17. 提高Global Memory访存效率
  18. 对Global Memory的访存需要注意合并访存(coalesced )。[2]
  19. warp的访存合并后,起始地址及访存大小对齐到32字节
  20. 尽量避免跨步访存
  21. 8.0及以上的设备可以通过编程控制L2的访存策略提高L2命中率。
  22. 提高Shared Memory的访存效率
  23. shared memory由32个bank组成
  24. 每个bank每时钟周期的带宽为4字节
  25. 连续的4字节单元映射到连续的bank。如0-3字节在bank0,4-7字节在bank1……字节128-131字节在bank0
  26. 若warp中不同的线程访问相同的bank,则会发生bank冲突(bank conflict),bank冲突时,warp的一条访存指令会被拆分为n条不冲突的访存请求,降低shared memory的有效带宽。所以需要尽量避免bank冲突。
  27. CUDA 11.0以上可以使用_async-copy_ feature[3]
  28. Thread Block 线程块的大小。
  29. 如线程块为128,不考虑其它情况下,调度8个线程块到SM即可保持满Occupancy。
  30. 如线程块为768,则若调度一个线程块到SM,Occupancy只有0.75,而调度两个线程块则不可能——已超出2080Ti 一个SM最多1024个线程的限制。
  31. 每个线程块的Shared Memory使用量
  32. 2080Ti每个线程块最多使用48Kb Shared Memory;每个SM只有64Kb Shared Memory。
  33. 若线程块尺寸为128,而线程块使用的Shared Memory有30Kb,则由于Shared Memory限制,最多只有两个线程块(256线程)被调度到SM,Occupancy只有0.25。Shared Memory使用60Kb。
  34. 若线程块尺寸为128, 每个线程块使用7Kb,则共可调度8个线程块到SM,使用Shared Memory 56Kb。
  35. 每个线程使用的Register(寄存器数量)
  36. 2080Ti每个SM有65536个32位寄存器,平均到最多1024个线程,则每个线程只能使用64个寄存器。
  37. 若某些算法比较复杂需要使用更多寄存器(如矩阵乘法中,需要加载更多数据到寄存器以提高计算访存比),如每线程需要使用128个寄存器,此时由于寄存器限制,SM上最多可以有512线程,此时Occupancy最多为0.5.
  38. 提高计算访存比
  39. 提高指令级并行
  40. 现代不论是CPU还是GPU,指令的执行都是通过流水线进行的,流水线分为多个stage,即一条指令执行完成需要每个stage的工作都执行完成。而一个时钟周期并不是完成一条指令执行的所有时间,而是每一个stage完成当前工作的时间。流水线可以同时执行多条指令的不同阶段。
  41. 当后续指令的执行需要依赖前面指令的结果写回寄存器,我们说出现了寄存器依赖。此时后续指令需要等待第前面指令结果写回寄存器才能执行,若后续指令执行时前面指令结果尚未写回寄存器,流水线会失速(stall),此时warp scheduler开始切换到其它eligible warp,若无eligible warp,则SMSP将会空转。
  42. 若后续指令不依赖前面指令的结果,则即使前面指令未执行完毕,后续指令也可以开始执行。特别的,即使前序指令是一条耗时几百周期的LDG(全局内存读取)指令或耗时几十周期的LDS(共享内存读取)指令,只要后续一系列指令不依赖读取回来的数据,后续一系列指令可以正常执行而不必等待该LDG/LDS指令执写回寄存器。
  43. 数据预取(Prefetch):数据1已读取到寄存器,使用该数据1计算前,先将后续数据2的读取指令发射,再执行一系列数据1的处理指令;这样数据1的处理和数据2的读取在流水线上同时执行着。当数据1处理完成,需要处理数据2时,可以确保数据2已经存在于寄存器中,此时类似的将数据3的读取和数据2的处理同步执行起来。
  44. 指令重排:在存在寄存器依赖的指令间插入足够的其它指令,使得后续指令执行时,前面计算指令的结果已写回到寄存器。从CUDA C层面有意识地提供一些语句间的并行性,nvcc编译器可以一定程度上自动进行指令重排。若对nvcc重排结果不满意需要自己重排时,官方尚未开放SASS汇编器,目前只存在一些第三方SASS汇编器工具[5]。
  45. 提高Register的效率
  46. Register File也存在bank冲突,但在CUDA C层面上没有直接办法进行物理寄存器控制。
  47. 可以通过SASS汇编器,人工进行指令寄存器分配,以尽量消除register bank conflict。
  48. 可以通过SASS汇编器,为寄存器访问添加reuse标记,以尽量消除register bank conflict。
  49. cuBLAS
  50. TensorRT
  51. cudnn
  52. NVCodeC
  53. DeepStream
  54. nvJPEG
  55. NCCL
  56. CUTLASS 

最后是一个demo 

 https://github.com/godweiyang/NN-CUDA-Example

我给它命名为“Neural Network CUDA Example”,简称“NN CUDA Example”,意思就是神经网络调用CUDA的示例。