以下
文
章来源于微信公众号:
周彬
作者:
周彬
链接:
https://zhuanlan.zhihu.com/p/570795544
本文仅用于学术分享,如有侵权,请联系
后
台作删文处理
CUDA编程在AI工程化过程中常常会用到,本文详细汇总了作者对于CUDA开发过程中的心得和经验,希望对大家有帮助。
基本编程模型
最基本的概念就是
显存、kernel函数、线程块、stream:
-
开发者可以通过CUDA Runtime API,申请、释放显存,并在内存和显存间进行数据拷贝。
-
开发者可以编写专用于在GPU上执行的kernel函数,在主机侧通过CUDA C扩展调用kernel函数,调用将创建数以万计的GPU线程,每个GPU线程均会完整执行一次kernel函数,kernel函数内可以对显存进行读、写等各种操作。数以万计的GPU线程之间靠只读的内置变量(线程ID等)互相区分。
-
一次kernel调用对应的GPU线程,需划分为一个个尺寸相同的线程块。线程块是向GPU进行调度的最小单位,GPU同时支持多个线程块的执行,达到上限后,只有旧的线程块内的线程全部执行完成后,新的线程块才会被调度入GPU。
-
stream相当于是GPU上的任务队列。每个kernel调用或大多数CUDA API都可以指定关联到某一个stream。同一个stream的任务是严格保证顺序的,上一个命令执行完成才会执行下一个命令。不同stream的命令不保证任何执行顺序。部分优化技巧需要用到多个stream才能实现。如在执行kernel的同时进行数据拷贝,需要一个stream执行kernel,另一个stream进行数据拷贝。
基本硬件架构及其在Kernel执行中的作用
显卡由内部的主板、显存颗粒、GPU芯片等组成。GPU内部的架构如下图:
GPU架构图
可以看到GPU由许许多多的SM(Stream Multiprocesser)组成。SM内部的架构如下图:
SM架构图
线程块就是被调度到SM上执行的,按照线程块占用的硬件资源不同,SM可以同时执行一个或多个线程块。
-
-
-
Shared Memory是一块可以由开发者编程控制的高速缓存。开发者可以在kernel函数内通过编程手段写入或读取数据。其访问latency远远小于全局显存。
-
SM分为4个子区域,称为SMSP。每个SMSP包括如下功能单元。
-
-
-
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个寄存器。
-
-
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])
-
-
有些情况下,即使数据不太适合使用kernel处理,但如果为了较低的算法latency,也可权衡传输代价后使用kernel处理数据
-
优化Kernel访存效率
-
对Global Memory的访存需要注意合并访存(coalesced )。[2]
-
warp的访存合并后,起始地址及访存大小对齐到32字节
-
-
8.0及以上的设备可以通过编程控制L2的访存策略提高L2命中率。
-
-
-
连续的4字节单元映射到连续的bank。如0-3字节在bank0,4-7字节在bank1……字节128-131字节在bank0
-
若warp中不同的线程访问相同的bank,则会发生bank冲突(bank conflict),bank冲突时,warp的一条访存指令会被拆分为n条不冲突的访存请求,降低shared memory的有效带宽。所以需要尽量避免bank冲突。
-
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可以选择,硬件必然空转等待。
-
-
如线程块为128,不考虑其它情况下,调度8个线程块到SM即可保持满Occupancy。
-
如线程块为768,则若调度一个线程块到SM,Occupancy只有0.75,而调度两个线程块则不可能——已超出2080Ti 一个SM最多1024个线程的限制。
-
-
2080Ti每个线程块最多使用48Kb Shared Memory;每个SM只有64Kb Shared Memory。
-
若线程块尺寸为128,而线程块使用的Shared Memory有30Kb,则由于Shared Memory限制,最多只有两个线程块(256线程)被调度到SM,Occupancy只有0.25。Shared Memory使用60Kb。
-
若线程块尺寸为128, 每个线程块使用7Kb,则共可调度8个线程块到SM,使用Shared Memory 56Kb。
-
-
2080Ti每个SM有65536个32位寄存器,平均到最多1024个线程,则每个线程只能使用64个寄存器。
-
若某些算法比较复杂需要使用更多寄存器(如矩阵乘法中,需要加载更多数据到寄存器以提高计算访存比),如每线程需要使用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,即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占用的时钟周期更少。
-
现代不论是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 File也存在bank冲突,但在CUDA C层面上没有直接办法进行物理寄存器控制。
-
可以通过SASS汇编器,人工进行指令寄存器分配,以尽量消除register bank conflict。
-
可以通过SASS汇编器,为寄存器访问添加reuse标记,以尽量消除register bank conflict。
使用TensorCore进一步加速矩阵运算[6]
TensorCore可以用来快速进行D=A*B+C矩阵运算,提供
load_matrix_sync
,
store_matrix_sync
,
mma_sync
等API。
使用CUDA生态的各库
NVIDIA已经提供了不少库,效率高性能好,合理使用可以大大提高开发效率,减少开发工作量。