CUDA开发总结笔记

未分类4周前发布 tree
15 0 0
↑ 点击蓝字 关注极市平台
CUDA开发总结笔记
作者丨周彬@知乎(已授权)
来源丨https://zhuanlan.zhihu.com/p/570795544
编辑丨极市平台

极市导读

 

本文不是一篇教程,而是笔者的一篇总结笔记,概括性地整理CUDA开发相关的经验和知识。 >>加入极市CV技术交流群,走在计算机视觉的最前沿

基本编程模型

最基本的概念就是显存、kernel函数、线程块、stream:
  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内部的架构如下图:
CUDA开发总结笔记
GPU架构图
可以看到GPU由许许多多的SM(Stream Multiprocesser)组成。SM内部的架构如下图:
CUDA开发总结笔记
SM架构图
线程块就是被调度到SM上执行的,按照线程块占用的硬件资源不同,SM可以同时执行一个或多个线程块。
SM内部的主要部件有:
  1. L1 Cache及Shared Memory。
  2. L1 Cache是高速缓存。
  3. Shared Memory是一块可以由开发者编程控制的高速缓存。开发者可以在kernel函数内通过编程手段写入或读取数据。其访问latency远远小于全局显存。
  4. SM分为4个子区域,称为SMSP。每个SMSP包括如下功能单元。
  5. Warp Scheduler+Dispatch
  6. Register File寄存器文件
  7. 16个INT32、16个FP32、2个Tensor Core及多个LD/ST等单元
线程块在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有以下状态:
  • Active:被调度到某个SMSP的warp。
  • Eligible:就绪执行下一条指令的warp(即warp未stalled)。
在每个时钟周期,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任务完成。

优化内存与显存传输效率

  • 使用Pinned(page-locked) Memory提高传输速度
  • 通过在不同的Stream里同时分别执行kernel调用及数据传输,使数据传输与运算并行。(注意default stream的坑[1])
  • 尽量将小的数据在GPU端合成大块数据后传输
  • 有些情况下,即使数据不太适合使用kernel处理,但如果为了较低的算法latency,也可权衡传输代价后使用kernel处理数据
  • 注意PCI-e插口的通道个数

优化Kernel访存效率

  • 提高Global Memory访存效率
  1. 对Global Memory的访存需要注意合并访存(coalesced )。[2]
  2. warp的访存合并后,起始地址及访存大小对齐到32字节
  3. 尽量避免跨步访存
  4. 8.0及以上的设备可以通过编程控制L2的访存策略提高L2命中率。
  • 提高Shared Memory的访存效率
  1. shared memory由32个bank组成
  2. 每个bank每时钟周期的带宽为4字节
  3. 连续的4字节单元映射到连续的bank。如0-3字节在bank0,4-7字节在bank1……字节128-131字节在bank0
  4. 若warp中不同的线程访问相同的bank,则会发生bank冲突(bank conflict),bank冲突时,warp的一条访存指令会被拆分为n条不冲突的访存请求,降低shared memory的有效带宽。所以需要尽量避免bank冲突。
  5. CUDA 11.0以上可以使用_async-copy_ feature[3]

优化线程级并行

在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. Thread Block 线程块的大小。
  2. 如线程块为128,不考虑其它情况下,调度8个线程块到SM即可保持满Occupancy。
  3. 如线程块为768,则若调度一个线程块到SM,Occupancy只有0.75,而调度两个线程块则不可能——已超出2080Ti 一个SM最多1024个线程的限制。
  4. 每个线程块的Shared Memory使用量
  5. 2080Ti每个线程块最多使用48Kb Shared Memory;每个SM只有64Kb Shared Memory。
  6. 若线程块尺寸为128,而线程块使用的Shared Memory有30Kb,则由于Shared Memory限制,最多只有两个线程块(256线程)被调度到SM,Occupancy只有0.25。Shared Memory使用60Kb。
  7. 若线程块尺寸为128, 每个线程块使用7Kb,则共可调度8个线程块到SM,使用Shared Memory 56Kb。
  8. 每个线程使用的Register(寄存器数量)
  9. 2080Ti每个SM有65536个32位寄存器,平均到最多1024个线程,则每个线程只能使用64个寄存器。
  10. 若某些算法比较复杂需要使用更多寄存器(如矩阵乘法中,需要加载更多数据到寄存器以提高计算访存比),如每线程需要使用128个寄存器,此时由于寄存器限制,SM上最多可以有512线程,此时Occupancy最多为0.5.
高的Occupancy不一定代表较高的性能,如某些算法确实需要每线程128寄存器时,保持0.5的Occupancy反而是最优选择。但过低的Occupancy会对性能带来较大的负面影响。

指令级优化

  • 提高计算访存比
GPU执行计算时,需要LDS、LDG等指令先将数据读入寄存器,再进行计算,最后通过STS、STG等指令将数据保存下来。
以矩阵乘法为例,先进行矩阵分块,最终拆解为每个线程计算MxK,KxN的两个小矩阵的乘法:
若两小矩阵为M=2,N=2,K=1,即2×1;1×2,最后得到2×2的矩阵作为结果。则读入4个float需4条指令,计算指令也是4条,计算访存比4/4=1;
若两小矩阵为M=8,N=8,K=1,即8×1;1×8,最后得到8×8的矩阵作为结果。则读入16个float,需读取指令16条,计算指令8×8=64条,计算访存比64/16=4;若使用向量读(float4)每条指令读入4个float,则读取指令仅4条,计算访存比64/4=16
提高计算访存比,可以让GPU的更多时钟周期用于进行计算,相对的进行数据IO占用的时钟周期更少。
  • 提高指令级并行
指令级并行基本原理:
  • 现代不论是CPU还是GPU,指令的执行都是通过流水线进行的,流水线分为多个stage,即一条指令执行完成需要每个stage的工作都执行完成。而一个时钟周期并不是完成一条指令执行的所有时间,而是每一个stage完成当前工作的时间。流水线可以同时执行多条指令的不同阶段。
  • 当后续指令的执行需要依赖前面指令的结果写回寄存器,我们说出现了寄存器依赖。此时后续指令需要等待第前面指令结果写回寄存器才能执行,若后续指令执行时前面指令结果尚未写回寄存器,流水线会失速(stall),此时warp scheduler开始切换到其它eligible warp,若无eligible warp,则SMSP将会空转。
  • 若后续指令不依赖前面指令的结果,则即使前面指令未执行完毕,后续指令也可以开始执行。特别的,即使前序指令是一条耗时几百周期的LDG(全局内存读取)指令或耗时几十周期的LDS(共享内存读取)指令,只要后续一系列指令不依赖读取回来的数据,后续一系列指令可以正常执行而不必等待该LDG/LDS指令执写回寄存器。
通过以下方式,可以提高指令级并行,在线程级并行达不到较好效果的情况下,进一步提高程序性能:
  • 数据预取(Prefetch):数据1已读取到寄存器,使用该数据1计算前,先将后续数据2的读取指令发射,再执行一系列数据1的处理指令;这样数据1的处理和数据2的读取在流水线上同时执行着。当数据1处理完成,需要处理数据2时,可以确保数据2已经存在于寄存器中,此时类似的将数据3的读取和数据2的处理同步执行起来。
  • 指令重排:在存在寄存器依赖的指令间插入足够的其它指令,使得后续指令执行时,前面计算指令的结果已写回到寄存器。从CUDA C层面有意识地提供一些语句间的并行性,nvcc编译器可以一定程度上自动进行指令重排。若对nvcc重排结果不满意需要自己重排时,官方尚未开放SASS汇编器,目前只存在一些第三方SASS汇编器工具[5]。
  • 提高Register的效率
  1. Register File也存在bank冲突,但在CUDA C层面上没有直接办法进行物理寄存器控制。
  2. 可以通过SASS汇编器,人工进行指令寄存器分配,以尽量消除register bank conflict。
  3. 可以通过SASS汇编器,为寄存器访问添加reuse标记,以尽量消除register bank conflict。

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

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

使用CUDA生态的各库

NVIDIA已经提供了不少库,效率高性能好,合理使用可以大大提高开发效率,减少开发工作量。
  • cuBLAS[7]
  • TensorRT[8]
  • cudnn[9]
  • NVCodeC[10]
  • DeepStream[11]
  • nvJPEG[12]
  • NCCL[13]
  • CUTLASS [14]

参考

  1. ^Default Stream Implicit Synchronization https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#implicit-synchronization

  2. ^合并访存官方文档 https://docs.nvidia.com/cuda/cuda-c-best-practices-guide/index.html#coalesced-access-to-global-memory

  3. ^async-copy feature https://docs.nvidia.com/cuda/cuda-c-best-practices-guide/index.html#async-copy

  4. ^Occupancy https://docs.nvidia.com/cuda/cuda-c-best-practices-guide/index.html#occupancy

  5. ^CuAssembler https://github.com/cloudcores/CuAssembler

  6. ^Warp Matrix Functions https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#wmma

  7. ^cublas https://developer.nvidia.com/cublas

  8. ^tensorrt https://developer.nvidia.com/tensorrt

  9. ^cudnn https://developer.nvidia.com/cudnn

  10. ^nvidia-video-codec-sdk https://developer.nvidia.com/nvidia-video-codec-sdk

  11. ^deepstream-sdk https://developer.nvidia.com/deepstream-sdk

  12. ^nvjpeg https://developer.nvidia.com/nvjpeg

  13. ^nccl https://developer.nvidia.com/nccl

  14. ^cutlass https://github.com/NVIDIA/cutlass

CUDA开发总结笔记

公众号后台回复“数据集”获取100+深度学习各方向资源整理

极市干货

技术专栏:多模态大模型超详细解读专栏搞懂Tranformer系列大视觉模型 (LVM) 解读扩散模型系列极市直播
技术综述:小目标检测那点事大模型面试八股含答案万字长文!人体姿态估计(HPE)入门教程

CUDA开发总结笔记

点击阅读原文进入CV社区

收获更多技术干货

© 版权声明

相关文章

暂无评论

暂无评论...