当今科学计算越来越复杂,各种模型越来越大,使用SVM做分类的时代早已一去不复返,通过GPU加速应用的重要性不言而喻。我在MRI领域工作的这几年,经常碰高度复杂的MRI应用,Matlab计算时间在几小时到几天不等,即使高度优化的C++的程序也需要10至30分钟左右。这些应用的CPU利用率已然接近100%,因此通过GPU进一步优化程序成为最可行的手段。
GPU市场依然是NVIDIA一家独大,AMD想要与之抗衡还需要时间。尤其在生态方面,NVIDIA基于CUDA的护城河太深,以至于科学计算大部分都使用N家的GPU。如果你已经拥有N家的GPU,恐怕使用CUDA编写核函数加速应用仍然是最好的选择。
在开始之前,介绍下我的基本配置和驱动情况:
- CPU: AMD Ryzen5 1600
- RAM: 8G x 4
- GPU: NVIDIA GTX 1080 x 2 (Driver: 550.67 CUDA: 12.4)
- System: Manjaro Linux
基本编程模型
CUDA为GPU的硬件结构做了和CPU类似的抽象。以我的CPU配置为例,从硬件抽象来说,Ryzen5 1600有8个核心,每个核心有2个线程,因此可以同时运行16个线程。从软件抽象来说,我们则可以创建上百个逻辑线程,每一时刻最多有16个线程同时交给CPU运行,线程间的调度由操作系统负责,保证所有线程都有执行的可能性。
CUDA的GPU编程模型和CPU类似,thread是模型中的最小运行单位。从软件抽象来说,CUDA划分了grid、block和thread三个不同的层级,block包含一组固定数目的thread,而grid包含一组固定数目的block。比如以64个threads组成一个block,以10个blocks组成一个grid,那么这个grid实际上有10*64=640个thread。每个thread都会基于其所在的block有一个唯一的索引号(从0开始索引),而每个block都会基于所在的grid同样有一个唯一的索引号(从0开始索引)。因此每个thread在grid中的全局索引号是可以唯一确定下来的。block和grid总共可以有xyz三个维度(可以把它们想象成立方体)。
这种grid、block、thread的设计主要是为了耦合科学计算,因为科学计算的本质是去操作向量、矩阵等高维度的数组。比如我们希望相加两个的矩阵,那么可以设计为1个2维block,每个block在xy方向各含有32个threads,每个thread只负责一次简单标量加法。理想情况下,我们可以让GPU同时执行这个线程,在一次运算周期内完成矩阵加法的操作(相比让CPU每个线程执行标量加法的模型,我们总共需要循环次运算周期来覆盖所有的元素,所以说多就是快,多就是好)。
GPU在硬件上的抽象则没有CPU那么好懂,有几个NVIDIA的基本概念需要澄清。streaming multiprocessor(SM) 类比于CPU的核心,是互相独立的组件;cuda core是物理上运行1个thread的计算单元,类比于CPU的线程;一个SM可以包含很多的cuda cores(类比于CPU核心有两个线程)。与CPU不同的地方在于,GPU对SM里的cuda cores做了进一步的划分,每32个cuda cores视作一个warp,它是启动thread计算的最小单元。需要16个threads来做计算,GPU会启动一个warp(32个threads)。需要48个threads?GPU会启动两个warps(64个threads)。多出来的threads会执行同样的计算,只不过运行的结果会被丢弃掉。
实际运行时,一个或多个blocks由GPU负责调度交给一个SM去运行,硬件会将block中的threads按32划分成warps并执行。一个SM能同时执行多少个blocks取决于程序的设计方式和对资源的占用多少,这也是CUDA优化的重点之一,即尽量让SM的利用率变高。比如GTX 1080总共有20个SMs,每个SM包含128个cuda cores,因此每个SM可以最多同时运行4个warps。如果我的程序需要40个blocks,每个block包含48个threads,并且每个thread占用的资源都很少,理论上来说,GTX 1080可以同时在一个SM上启动2个block的计算,所有的SMs都能有效利用,只不过每个SM里有32个threads是在做无用的计算。相比之下,RTX 4080有76个SMs,因此可以同时处理更多的blocks。
CPU和GPU有独立的内存空间,因此数据必须先从CPU移动到GPU上,处理结束后再从GPU移动到CPU上。类比于熟知的DRAM、L3 cache、L2 cache和L1 cache,GPU分为global memory、local memory、L2 cache、shared memory、L1 cache和registers。最主要的区别当然是存取速度有着数量级上的提升。global memory是最慢的,但是所有thread都能访问,我们使用cudaMalloc
分配内存也都在global memory上,host(CPU)也能够访问global memory。L2 cache充当global memory和SMs的桥梁,所有的SMs都能访问。shared memory只有同一个block中的thread能够访问,它的速度大概是global memory的100倍以上。L1 cache和shared memory也类似。registers则是每个thread独有的,速度最快。local memory则有点特别,它是在register放不下时候的缓存区,本质上就是global memory,每个thread只能访问自己的部分。
NVCC编译
CUDA的核函数使用NVIDIA自定义的C++扩展语法编写,不属于C++标准的一部分,因此常见的编译器是不支持CUDA代码的编译,必须使用NVIDIA的nvcc编译器。nvcc编译器做的事情类似于对常规编译器的扩展:
- 先将CUDA核函数与常规C++代码分离开
- 将核函数编译成汇编(PTX)或者二进制(cubin)
- 用CUDA runtime函数替换原始代码中的核函数调用部分
- 调用常规C++编译器处理剩下的编译工作
最基本的nvcc命令如下所示,将vecadd.cu
编译成可执行二进制文件vecadd
:
nvcc vecadd.cu |
其中arch
用来控制基于何种虚拟架构生成汇编代码,而code
控制基于汇编如何生成和优化目标架构的二进制文件。所有的虚拟架构都以compute_xy
表示,而真实架构都以sm_xy
表示。我们主要重点关注code
的部分即可。上述命令的意思是生成目标架构为sm_50
的二进制,然后生成目标架构为sm_61
的二进制,最后生成目标架构为sm_70
的二进制和虚拟架构为compute_70
的PTX代码。这些二进制和代码都被封装在同一个文件中,在实际执行时由CUDA运行时负责选择最接近的架构。
搞这么多复杂的架构主要还是为了兼容性的问题。NVIDIA有以下规定:
code=sm_xy
生成的二进制只能在符合compute capability的x.y
设备上运行,跨版本的二进制文件是不允许的- 基于
arch=compute_ab
生成的PTX可以编译更高版本的二进制文件code=sm_xy
- 基于
arch=compute_xy
生成的PTX不可以编译低版本的二进制文件code=sm_ab
- 如果
code=compute_xy
,则可以依赖JIT编译在compute capability的x.z
设备上运行,其中z>=y - 如果
code=compute_ab
,则可以依赖JIT编译在compute capability的x.y
设备上运行,其中x.y的版本要大于a.b版本 - 基于
arch=compute_ab
生成的PTX不能编译高版本code=compute_xy
,因为此时已经没办法附加更高版本的PTX代码进入最终文件,同理高版本arch=compute_xy
也没有办法编译低版本code=compute_ab
GTX 1080的Compute Capability只到6.1,只支持最基本硬件特性。因此,上述编译的可执行文件可以在我的GPU上运行(因为存在和我的GPU架构匹配的二进制)。根据规则,下面命令编译的文件也可以通过JIT在我的GPU上运行,尽管二进制的目标架构是sm_70
:
nvcc vecadd.cu -gencode arch=compute_61,code=\"compute_61,sm_70\" -o vecadd |
在实施最简单的demo前,首先让我们看下GTX 1080的基本参数:
- SMs: 20
- Warps(CUDA Cores)/SM: 4(128)
- Global Memory(GB): 8
- L2 Cache(KB): 2048
- Shared Memory(KB)/SM: 96
- L1 Cache(KB)/SM: 48
矢量加法Demo
一个最简单的矢量加法demo如下:
|
带有__global__
的返回值为空的函数addvec
就是在GPU上每个thread都运行的核函数,这里的threadIdx
、blockIdx
、blockDim
和gridDim
都是CUDA自己定义的内部变量,是uint的三元组,分别提供前面编程模型中提到的thread、block的索引以及block中thread的数量、block的数量。核函数就是利用这些信息让每个thread做相同的标量加法运算,但作用于不同的元素位置。此外,因为我们的元素数目要大于实际可用的cuda cores,所以要利用循环分批次处理数据。
main
函数中分别在host(CPU)和device(GPU)上申请内存,并将CPU的内容复制到GPU内存中。addvec<<<iBlocks, iThreads>>>(da, db, dc)
调用核函数并立即返回(异步执行),通过cudaDeviceSynchronize
来等待所有thread计算完毕。最后将计算完成的内容copy回host的内存上。考虑到GTX 1080的架构,每个block我选择64个threads(2个warps),这样理想情况下每个SM可以塞进两个blocks的计算。
用Nsight分析程序性能
NVIDIA提供Nsight Systems工具来帮助分析程序性能,执行以下命令:
nsys profile --stats=true ./vecadd |
会对我们的vecadd可执行文件做详细的分析,输出各种运行时间、调用次数的报告。需要注意的是,nsys执行过程相当漫长,terminal上会显示“The target application terminated. One or more process it created re-parented. Waiting for termination of re-parented processes.”的提示,我一开始还以为是程序出错,其实是正常现象,只要耐心等待就好。执行完毕后,会在当前文件夹下生成.nsys-rep
和.sqlite
文件,以便后续追踪分析。
Time (%) Total Time (ns) Instances Avg (ns) Med (ns) Min (ns) Max (ns) StdDev (ns) Name |
在上例中,我们的addvec
核函数显示总运行时间是2.8us;如果稍作修改,比如iThreads=48
,运行时间则为:
Time (%) Total Time (ns) Instances Avg (ns) Med (ns) Min (ns) Max (ns) StdDev (ns) Name |
可以看出时间稍长了一些,符合我们的预期,因为有些warp里的thread计算被浪费掉了。