我们想要使用GPU的主要原因是GPU可以提供强大的计算能力。GPU相较CPU有两个主要的优势:巨大的计算吞吐量和极高的内存带宽。
I 延迟和吞吐量
CPU 是低延迟低吞吐量处理器;GPU 是高延迟高吞吐量处理器。
II GPU内存带宽
例如,NVIDIA RTX A4000
建立在 NVIDIA Ampere
架构之上,其芯片规格如下:
NVIDIA RTX A4000 芯片规格
NVIDIA RTX A4000
芯片配备 16 GB 的 GDDR6 显存、256 位显存接口(GPU 和 VRAM 之间总线上的独立链路数量),因为这些与显存相关的特性,所以A4000
内存带宽可以达到 448 GB/s。
III 计算量 FLOPs
floating point operations
指的是浮点运算次数,理解为计算量,可以用来衡量算法/模型时间的复杂度。Floating-point Operations Per Second
,每秒所执行的浮点运算次数,理解为计算速度, 是一个衡量硬件性能/模型速度的指标,即一个芯片的算力。multiply-accumulate operations
,乘-加操作次数,MACCs 大约是 FLOPs 的一半。将 w[0]∗x[0]+... 视为一个乘法累加或 1 个 MACC。下图阐述了01年到14年间英伟达GPU和因特尔CPU的算力增涨趋势,纵坐标以每秒10亿次浮点运算为单位。
NVIDIA GPU和Intel CPU的算力增涨趋势
绿线表示NVIDIA GPU,蓝线表示Intel CPU。可以看出GPU的算力比CPU几乎高出一个数量级。GPU和CPU在计算浮点类型数据的性能差异主要原因在于,GPU专门用于密集型高度并行计算,这也正是图形渲染的主要内容。因此GPU的设计中将更多的晶体管用于数据处理而非数据缓存和复杂的控制逻辑。
GPU与CPU特点
为了合理的利用GPU的计算资源,程序中可以并行计算的部分需被分解为大量可以并发运行的线程(threads)。在CUDA中,这些线程在一些被称为核函数(kernels)的特殊函数中被定义。
核函数和线程
在CUDA编程模型中,两个主要的硬件设备分别为CPU和GPU,它们都有自己专用的内存区域。
I 主机、设备和异构并行编程
在应用CUDA编程时,像大部分在CPU上运行的软件一样,CUDA程序的主要部分还是由Host端控制。但每当遇到一段可以大规模并行的代码时,程序会将这部分代码的执行从Host端传递到Device端。Host端和Device端通过PCI Express总线进行通信。PCI Express总线的数据传输相对于Host和Device非常慢,因此在Host端和Device端之间数据交换的成本非常高。这就是只让大规模并行的部分在Device端执行的原因。
主机、设备和异构并行编程
II 线程
线程的特点
为了组织线程与GPU上CUDA核如何匹配,CUDA有一个层次结构。层次结构分为三个级别:线程(threads)、块(blocks)和网格(grids)。
综上所述,可以将线程看作块的元素,将块看作网格的元素。
线程、块和网格
每个网格由一组块组成,这些块以一维,二维或是三维结构组织在网格中。类似地,块由一组线程组成,这些线程也是以一维,二维或是三维结构组织在块中。下面的例子中,网格中有二维结构组成的6个块,每个块中有二维结构组成的12个线程。这个网格中共有72个线程。
网格和块示例
当核函数执行时,在上图这个网格对应的情况下,会有72个线程在GPU中同时运行。
接下来我们了解一下CUDA程序是如何在代码中组织的。最重要的是要牢记CPU起到控制作用,即主机控制着程序的主要流程。就像C语言的程序一样,CUDA程序的流程也是由main()函数开始的。程序按正常的顺序运行,直到运行至我们想要加载到GPU上的代码部分。
将大规模并行计算的代码从CPU加载到GPU上运行是通过启动一个核函数来实现的,这个核函数在设备端作为一个网格来运行。然后对于程序的控制立即返回到主机端上。main()函数直接在核函数启动后的点继续运行 。main()函数继续执行任何串行代码,直到另一个核函数被启动。
需要注意的是,main()函数不会等待核函数运行完成。因此如果程序中需要收集一些特定的核函数运行结果,我们需要在主机端代码中创建一个明确的屏障来告诉main()函数去等待核函数运行结束后再继续运行。
CUDA程序模型
核函数的启动就像调用任何普通C函数一样,以执行的核函数的名称开始,而传递给核函数的参数写在括号中。核函数的启动和普通函数的调用之间唯一的区别是必须指定网格和块的维度,这些信息要写在<<< >>>
内。实现如下:
// Launch Kernel
kernelName<<< grid_size, block_size >>>( ... );
因此在启动核函数之前,需要配置其启动参数。这些配置参数定义了网格和块的维度信息。具体实现如下:
// Block and Grid dimensions
dim3 grid_size(x, y, z);
dim3 block_size(x, y, z);
表示形状的x, y, z
为整数值。dim3
是一个CUDA数据结构,其只是一个对应x, y, z
的整数集合。grid_size
和block_size
是dim3
数据结构的变量名称。其默认值为(1, 1, 1)
。
用一个例子来演示核函数的启动语法。首先配置网格和块的尺寸,在这个例子中网格尺寸为3x2
,而块的尺寸为4x3
。然后使用指定的配置参数来启动核函数。
核函数启动示例
I 流程细节
让我们更细致地了解CUDA程序的流程。需要注意的是,主机端和设备端有不同的内存区域。
CUDA程序流程图
II 分配设备内存
CUDA中分配设备内存类似于C语言中的分配内存。在C语言中,分配内存需要调用malloc()
函数;释放内存的函数是free()
函数。
cudaMalloc()
函数。cudaMalloc()
函数有两个参数,第一个参数是我们想要将数据复制到的内存位置;第二个参数是分配的内存区域大小。cudaFree()
函数,无需传入参数。C语言和CUDA中分配和释放内存函数
在主机和设备端的数据传递通过调用cudaMemcpy()
函数来进行。函数语法如下:
cudaMemcpy(dst, src, numBytes, direction);
cudaMemcpy()
函数需要传入4个参数。第一个参数dst
为要将数据拷贝到的内存地址指针。第二个参数src
为指向被拷贝数据来源的内存地址指针。第三个参数numBytes
为以字节为单位传输数据的大小。第四个参数direction
为传输数据的方向,若将数据由主机端拷贝至设备端,设置为cudaMemcpyHostToDevice
; 将数据从设备端拷贝回主机端,设置为cudaMemcpyDeviceToHost
。
数据传递函数
将之前讨论的所有概念联系在一起,写一个CUDA程序作为示例。通过进入main()函数开始程序的执行,接下来定义两个指针变量h_c
和d_c
。由于主机和设备有单独的内存区域,在主机上端对设备端指针解引用会导致程序崩溃。为了区分主机端和设备端的变量,最好遵循特定的命名规则,如用字母h和d表示主机和设备。
定义内存指针
接下来,用cudaMalloc()
函数分配设备端内存空间,同时传递两个参数:设备端内存位置的指针和分配内存大小。
分配GPU内存空间
此时已经有数据储存在主机端变量中,因此假设h_c
已经被初始化赋值数据。然后调用cudaMemcpy()
函数去将此数据从主机内存复制到设备内存上。
复制数据
接下来,需要配置启动参数,以在特定的网格和块的维度下启动核函数。在例子中,设置网格和块的维度均为1x1x1。然后启动核函数,在<<< >>>
内填入配置的维度参数,并传入核函数的相应参数。那么此例中的核函数作为一个包含单个线程的单个块进行执行。
配置参数并启动核函数
最后还是调用cudaMemcpy()
函数将核函数的运行结果从设备端复制回主机端,并将设备端分配的内存空间释放。
复制结果并释放内存空间
完整示例程序如下:
int main(void) {
// Declare variables
int *h_c, *d_c;
// Allocate memory on the device
cudaMalloc((void**)&d_c, sizeof(int)); // 传入的是d_c的地址,d_c也是地址类型的变量,存放的是设备端数据位置
// Copy data to device
cudaMemcpy(d_c, h_c, sizeof(int), cudaMemcpyHostToDevice);
// Configuration Parameters
dim3 grid_size(1);
dim3 block_size(1);
// Launch the Kernel
kernel<<<grid_size, block_size>>>(...);
// Copy data back to host
cudaMemcpy(h_c, d_c, sizeof(int), cudaMemcpyDeviceToHost);
// De-allocate memory
cudaFree(d_c);
free(h_c); // h_c在cpu上
return 0;
}
原网址: 访问
创建于: 2024-03-12 15:09:13
目录: default
标签: 无
未标明原创文章均为采集,版权归作者所有,转载无需和我联系,请注明原出处,南摩阿彌陀佛,知识,不只知道,要得到
最新评论