2.1 GPU并行运算库
先看一个最简单的向量运算问题:
A 和B是两个N维向量,请计算A、B两个向量之和。
有任一高级编程语言基础的读者,都可以在5分钟内编写完成解决这个问题的程序代码。以C语言为例,实现该向量运算的代码如下(简称“代码C”):
在代码C中,VecAdd()是我们自定义的一个向量加法函数,可以在一个for循环中,将A和B这两个N维向量对应位置的元素两两求和,得到向量C。由于这段代码在由CPU执行时,只可能在单一的CPU线程上执行,因此,VecAdd()执行的是串行运算。
而用Nvidia GPU实现向量加法并行运算的程序代码如下(简称“代码G”):
我们将代码C与代码G进行对比,很容易发现二者的以下差异。
(1)VecAdd()的传参定义。在代码C中,第4个形参是向量的维数,也就是for循环需要执行的次数。但在代码G中,VecAdd()只有3个形参:两个相加的向量和最后得到的结果向量。那么,向量的维数是在哪里传递的呢?对于这个问题,在后面会给出答案。
(2)定义VecAdd()时的前缀。在C语言中,每个函数都有自己的返回值类型,比如在代码C中,VecAdd()的返回值类型就是void,也就是空类型。在代码G中,我们注意到,在void前面还有一个前缀,即“__global__”,此时若在程序中包含CUDA运行时库相关的头文件,编译器就会认为这个函数属于调用CUDA的核函数,并且将函数内部的语句编译为GPU指令。
(3)VecAdd()的实现不同。在代码G的VecAdd()中有这样一条语句:
执行该语句,实际上是获取当前GPU的线程ID。因为在GPU中有成千上万个计算线程,每个线程都可以并发执行同样的程序,所以程序需要通过这种方式获取自身所在的线程ID,VecAdd()通过线程ID来确定自己应当操作向量的哪个元素,以保证每个线程都操作向量的不同元素。而代码C是在单一的CPU Core上串行执行的,因此也无须获取自己的线程ID。
(4)代码G在main()中调用VecAdd()时,通过核函数调用的附加参数(在尖括号<<<>>>中包裹的参数)传入了参数N,这个参数决定了核函数在多少个线程中执行。由于N实际上为三个入参,依次为向量A、向量B和向量C这三个向量的元素数量,因此,核函数在执行时,会为参与计算的向量的每个元素都分配一个线程,进行加法操作。
我们发现,CUDA是一个原生的为并行计算设计的编程框架,通过该框架可以很容易地调用GPU中的海量计算单元进行并行计算。
我们要让GPU进行计算,就需要解决以下问题。
(1)将数据从CPU连接的内存(以下简称“系统主内存”)发送到GPU。
(2)将代码从系统主内存发送到GPU。
(3)让GPU执行代码。
(4)在GPU执行完代码后,将GPU的计算结果搬运回主内存。
CUDA对开发者屏蔽了这些问题的解决方式,在libcudart.so等动态链接库中调用了GPU的KMD(Kernel Mode Driver,内核模式驱动)来实现以上操作。CUDA的工作流程分为以下4步,如图2-1所示。
(1)GPU发起DMA(Direct Memory Access,直接内存访问),将系统主内存中的数据复制到GPU内存中。
(2)CPU向GPU注入指令。
(3)GPU中的多个计算线程并行执行GPU指令。
(4)GPU发起DMA,将GPU内存中的数据复制到系统主内存中。
图2-1
CUDA的工作流程对程序员而言是透明的,程序员只需在开发代码时,按照CUDA的规范进行书写,并使用支持CUDA运行时库的编译器,就可以生成执行这些操作的代码。支持CUDA运行时库的编程语言除C/C++外,还包括Python、Java、Fortran等,甚至包括Mathematica等专用的数值计算工具。
基于CUDA开发的应用的架构图如图2-2所示。
图2-2
可以看出,图2-2中的应用除了调用了一般运行时库(如Linux操作系统中的glibc),还调用了CUDA运行时库。CUDA运行时库通过调用操作系统中的GPU驱动来让GPU执行运算。