GPU 与 CUDA
随着近年来深度学习的爆发,原来被用于图形渲染的GPU被大量用于并行加速深度学习的模型训练中,在这个过程中 CUDA 作为 NVIDIA 推出的基于GPU的一个通用并行计算平台和编程模型也得到了广泛的使用。或许你已经十分了解 现代CPU的体系架构,但是对于GPU还不甚清晰,GPU的体系架构到底和CPU有何区别,CUDA模型是什么,我们该如何使用 CUDA 实现并行计算,本文将为你扫盲祛魅,本文中使用到的所有代码可以在我的 Github 中找到。
GPU 体系架构
为什么我们需要 GPU
如前所述,GPU (Graphics Processing Unit)最开始只是用于游戏、视频中的图形渲染,而现在最热门的一个应用领域是在深度学习的加速计算上。为什么需要 GPU 来加速计算呢?我们知道,随着摩尔定律的发展,在过去五十年间CPU的性能获得了巨大的提升,不论是从芯片上晶体管数目,还是时钟频率,到后来的从单核处理器发展到后来的多核多处理器。
下图是过去五十年间各款CPU处理器上晶体管数目的变化,基本上满足每18个月提升一倍的规律,虽然现在看起来50十年后摩尔定律对CPU来说有停滞的迹象(这是另一个话题,此处不表)
在 CPU 算力快速提升的这五十年,人们需要的计算量也同时在迅猛发展着,从最开始的桌面互联网,到后来的移动互联网,以及5年前爆发的深度学习,无一不需要庞大的计算力。在这个过程中,仅仅依靠CPU的算力开始力有不逮,这个过程中像GPU、FPGA、DSP等异构计算单元开始得到广泛的应用。下面,我回归计算的本质,以GPU为例来分析为什么我们需要这些异构计算单元。
无论是 CPU 还是 GPU,我们可以把计算模型抽象为下面这张图,这也是典型的冯诺伊曼体系架构。
影响计算能力的4个主要因素如下:
- Parallel Processing:Amount of data processed at one time
- Clock Frequency:Processing speed on each data element
- Memory Bandwidth:Amount of data transferred at one time
- Memory Lantency:Time for each data element to be transferred
对于CPU,依次分析这几个因素:
- 为了提供并行处理能力,我们从单核单处理器发展到多核多处理器,每个时钟周期CPU也能够处理多条指令
- 因为CPU时钟频率和功率的关系 $ Power = k * ClockFrequency * Voltage^2 $ ,在CPU过去的发展历史中,通过提高CPU时钟频率可以变得更快,与此同时为了保持CPU功耗的正常,也需要不断降低电压。但是当主频逐渐逼近到 4GHz 时,电压已经不能再降低了,因为这已经到达了晶体管高低电平反转的极限,关于这部分的更多内容可以参考 摩尔定律 。
- 现在CPU用的是常规的DDR内存,明显存在着内存带宽限制
- 从CPU到DDR内存的延时很高,2020年的时候大概有100ns,具体可以参考 Key Numbers Every Programmer Should Know。CPU通过其他的方式隐藏了这个问题:
- Large On-Chip Low-Latency Cache,大概1ns
- MultiThreading
- Out-of-order execution
尽管现在CPU的能力还在发展,但是以上的问题极大的限制了其算力的提高,当前仅靠CPU已经不能够满足人们对庞大算力的需求了。因此我们需要其他的专用芯片来帮助CPU一起计算,这就是异构计算的来源。GPU等专用计算单元虽然工作频率较低,但具有更多的内核数和并行计算能力,总体性能/芯片面积比和性能/功耗比都很高。随着人工智能时代的降临,GPU从游戏走进了人们的视野。
无论是CPU还是GPU,在进行计算时都需要用核心(Core)来做算术逻辑运算。核心中有ALU(逻辑运算单元)和寄存器等电路。在进行计算时,一个核心只能顺序执行某项任务。CPU作为通用计算芯片,不仅仅做算术逻辑计算,其很重要的一部分功能是做复杂的逻辑控制,一般而言CPU上的Core数目相对较少,数据中心的服务器一般也就40左右个CPU核心。但是GPU动辄有上千个核心,这些核心可以独立的进行算术逻辑计算,大大提高了并行计算处理能力。
GPU时代的最大获益者是NVIDIA,当然AMD他们家也有GPU产品,但是因为AMD并没有形成CUDA这样的软件生态导致深度学习中主要用的都是NVIDIA的GPU,后面的分析都将基于NVIDIA的GPU产品。NVIDIA 不同时代产品的芯片设计不同,每代产品背后有一个架构代号,架构均以著名的物理学家为名,以向先贤致敬,对于消费者而言,英伟达主要有两条产品线:
- 消费级产品 GeForce系列:GeForce 2080 Ti…
- 高性能计算产品 Telsa系列:Telsa V100、Telsa P100、Telsa P40…
GPU 硬件模型
Host and Device
GPU并不是一个独立运行的计算平台,而是需要与CPU的协同工作,可以看作是CPU的协处理器,因此当我们说GPU并行计算的时候,实质上是指的 CPU+GPU 的异构计算架构。由于CPU和GPU是分开的,在NVIDIA的设计理念里,CPU和主存被称为 Host,GPU和显存被称为 Device。Host 和 Device 概念会贯穿整个NVIDIA GPU编程。
基于 CPU + GPU 的异构计算平台可以优势互补,CPU负责处理逻辑复杂的串行程序,GPU重点处理数据密集型的并行计算程序,从而发挥最大功效。CUDA 程序中既包含 Host 程序,又包含 Device 程序,它们分别在CPU和GPU上运行。
同时, Host 与 Device 之间通过PCIe总线交互进行数据拷贝,典型的 CUDA 程序的执行流程如下:
- 初始化后,将数据从 Main Memory 拷贝到 GPU Memory
- CPU 调用 CUDA 的核函数
- GPU 的 CUDA Core 并行执行核函数
- 将 Device 上的运算结果拷贝到 Host 上
GPU核心在做计算时,只能直接从显存中读写数据,程序员需要在代码中指明哪些数据需要从内存和显存之间相互拷贝。这些数据传输都是在总线上,因此总线的传输速度和带宽成了部分计算任务的瓶颈。当前最新的总线技术是NVLink,IBM的 Power CPU 和 NVIDIA 的高端显卡可以通过NVLink直接通信,Intel 的 CPU目前不支持NVLink,只能使用PCIe技术。同时,单台机器上的多张英伟达显卡也可以使用NVLink相互通信,适合多GPU卡并行计算的场景。
Streaming Multiprocessor
在 NVIDIA 的设计里,一张GPU卡有多个Streaming Multiprocessor(SM),每个 SM 中有多个计算核心,SM 是运算和调度的基本单元。下图为当前计算力最强的显卡Tesla V100,密密麻麻的绿色小格子就是GPU小核心,多个小核心一起组成了一个SM。
将 SM 放大,单个SM的结构如图所示:
可以看到一个SM中包含了计算核心和存储部分,SM的核心组件包括CUDA核心,共享内存,寄存器等,SM可以并发地执行数百个线程,并发能力就取决于SM所拥有的资源数。
- 针对不同计算的小核心(绿色小格子),包括优化深度学习的TENSOR CORE,32个64位浮点核心(FP64),64个整型核心(INT),64个32位浮点核心(FP32)
- 计算核心直接从寄存器(Register)中读写数据
- 调度和分发器(Scheduler和Dispatch Unit)
- L0和L1级缓存
具体而言,SM中的FP32进行32位浮点加乘运算,INT进行整型加乘运算,SFU(Special Functional Unit)执行一些倒数和三角函数等运算。Tensor Core是 NVIDIA 新的微架构中提出的一种混合精度的计算核心。我们知道,当前深度神经网络中使用到最频繁的矩阵运算是: $ D = A \times B + C $。Tensor Core可以对 $ 4 \times 4 $ 的矩阵做上述运算。其中:
- 涉及乘法的 A 和 B 使用FP16的16位浮点运算,精度较低
- 涉及加法的 C 和 D 使用FP16或FP32精度
Tensor Core是在 Volta 架构开始提出的,使用Volta架构的V100在深度学习上的性能远超Pascal架构的P100。
CUDA 编程模型
前面提到,NVIDIA 相对于 AMD 的一个巨大优势是它的 CUDA 软件生态,下图是 NVIDIA GPU 编程的软件栈,从底层的GPU驱动和CUDA 工具包,上面还提供了科学计算所必需的cuBLAS线性代数库,cuFFT快速傅里叶变换库以及cuDNN深度神经网络加速库,当前常见的 TensorFlow 和 PyTorch 深度学习框架底层大多都基于 cuDNN 库。
Hello World
在进一步学习 CUDA 编程模型之前,我们首先配置好 CUDA 的运行环境,跑通 Hello World 从而对 CUDA 编程有一个直观的认识,这里使用的是腾讯云的 GPU 服务器,机器安装的是 CentOS 7 系统,CUDA 环境配置可以参考 CUDA Installation Guide Linux 。
根据上图的 NVIDIA GPU 软件栈,有了一个插上了 GPU 的服务器之后,我们首先查看机器上的 GPU,可以看到当前机器上装GPU是 Tesla P40:
|
|
接下来在 这里下载 CUDA Toolkit,这里选择的是 rpm local 的安装方式:
|
|
执行上面的安装操作之后,我们可以看到在 /usr/lib64/ 看到 libcuda.so :
|
|
下面是一些我们会经常用到的 CUDA 工具,你需要通过配置环境变量来使用他们:
编译器:nvcc (C/C++)
调试器:nvcc-gdb
性能分析:nsight, nvprof
函数库:cublas, nvblas, cusolver, cufftw, cusparse, nvgraph
设置环境变量如下:
|
|
除此之外,对于 64 位系统,需要设置 LD_LIBRARY_PATH:
|
|
这个时候可以确认驱动的版本:
|
|
可以使用nvidia-smi命令查看显卡情况,比如这台机器上几张显卡,CUDA版本,显卡上运行的进程等。
|
|
CUDA 自己提供了一系列的代码示例,可以通过下面的方法安装:
|
|
在对应目录下,我们可以看到 CUDA 提供的源代码:
|
|
直接在这个目录下执行 make,可以在 bin目录下得到所有代码的二进制程序,选择其中的 deviceQuery 执行:
|
|
到现在,CUDA Toolkit 安装完毕,接下来通过编写一个简单的 hello world 来直观感受 CUDA 编程:
|
|
可以看到,CUDA 程序基本上和标准 C 语言程序一样,主要的区别在于 __global__ 限定词 和 <<<... >>> 符号。其中 __global__ 标记用来告诉编译器这段代码会运行在 Device (GPU)上,它会被运行在 Host 上的代码调用,也被称作是在 Device 上线程中并行执行的核函数(Kernel),是在 Device 上线程中并行执行的函数。
当一个核函数被调用时,需要通过 <<<grid, block>>> 符号 来设置核函数执行时的配置,在 CUDA 的术语中,这称作 kernel lauch,在后面我们将深入介绍这部分。
hello world 程序写完,我们以 hello.cu 这样的后缀名来保存,接下来使用 nvcc 来编译,整体上用法与 gcc 几乎一样:
|
|
可以看到,来自 CPU 的 Hello World 执行了一次,来自 GPU 的 Hello World 执行了8次。
核函数与线程模型
上文提到,为了实现 GPU 并行加速计算,我们需要在 Host 上执行 kernel launch,让 核函数 在 Device 上的多个线程并发执行。具体的方式就是在调用核函数的时候通过 <<<grid, block>>> 来指定核函数要执行的线程数量N,之后GPU上的N个Core会并行执行核函数,并且每个线程会分配一个唯一的线程号threadID,这个ID值可以通过核函数的内置变量threadIdx来获得。
CUDA将核函数所定义的运算称为线程(Thread),多个线程组成一个块(Block),多个块组成网格(Grid)。这样一个Grid可以定义成千上万个线程,也就解决了并行执行上万次操作的问题。 <<<grid, block>>> 中括号中第一个数字表示整个Grid有多少个Block,括号中第二个数字表示一个Block有多少个Thread。前面 Hello World 用 2 个Block,每个Block中有4个Thread,所以总共执行了8次。
实际上,线程(Thread)是一个编程上的软件概念。从硬件来看,Thread运行在一个CUDA核心上,多个Thread组成的Block运行在Streaming Multiprocessor(SM),多个Block组成的Grid运行在一个GPU显卡上。当一个 kernel 被执行时,它的gird中的线程块被分配到SM上,一个线程块只能在一个SM上被调度。SM一般可以调度多个线程块,这要看SM本身的能力。那么有可能一个 kernel 的各个线程块被分配多个SM,所以grid只是逻辑层,而SM才是执行的物理层。
grid 和 block都是定义为dim3类型的变量,dim3可以看成是包含三个无符号整数(x,y,z)成员的结构体变量,在定义时,缺省值初始化为1。因此 grid 和 block 可以灵活地定义为 1-dim,2-dim 以及3-dim 结构,对于上图中结构(主要水平方向为x轴),定义的 grid和 block 如下所示, kernel 在调用时也必须通过执行配置<<<grid, block>>>来指定 kernel 所使用的线程数及结构。
|
|
所以,一个线程需要两个内置的坐标变量(blockIdx,threadIdx)来唯一标识,它们都是dim3类型变量,其中blockIdx指明线程所在grid中的位置,而threaIdx指明线程所在block中的位置,如图中的 Thread (1,1) 满足:
|
|
不同的执行配置会影响GPU程序的速度,一般需要多次调试才能找到较好的执行配置,在实际编程中,执行配置<<<grid, block>>>应参考下面的方法:
- Block运行在SM上,不同硬件架构(Turing、Volta、Pascal…)的CUDA核心数不同,一般需要根据当前硬件来设置Block的大小
block(执行配置中第二个参数)。一个Block中的Thread数最好是32、128、256的倍数。注意,限于当前硬件的设计,Block大小不能超过1024。 - Grid的大小
grid(执行配置中第一个参数),即一个Grid中Block的个数可以由总次数N除以block,并向上取整。
例如,我们想并行启动1000个Thread,可以将blockDim设置为128,1000 ÷ 128 = 7.8,向上取整为8。使用时,执行配置可以写成gpuWork<<<8, 128>>>(),CUDA共启动8 * 128 = 1024个Thread,实际计算时只使用前1000个Thread,多余的24个Thread不进行计算。
{% note info%}
这几个变量比较容易混淆,再次明确一下:block是Block中Thread的个数,一个Block中的threadIdx最大不超过block;grid是Grid中Block的个数,一个Grid中的blockIdx最大不超过grid。
{% endnote %}
这几个变量比较容易混淆,再次明确一下:block是Block中Thread的个数,一个Block中的threadIdx最大不超过block;grid是Grid中Block的个数,一个Grid中的blockIdx最大不超过grid。
kernel 的这种线程组织结构天然适合vector,matrix等运算,我们将在后面实现向量加法和矩阵乘法。如我们将利用上图2-dim结构实现两个矩阵的加法,每个线程负责处理每个位置的两个元素相加,代码如下所示。线程块大小为(16, 16),然后将 $ N*N $ 大小的矩阵均分为不同的线程块来执行加法运算。
SM采用的是SIMT (Single-Instruction, Multiple-Thread,单指令多线程)架构,基本的执行单元是 线程束(wraps),线程束包含32个线程,这些线程同时执行相同的指令,但是每个线程都包含自己的指令地址计数器和寄存器状态,也有自己独立的执行路径。
当线程块被划分到某个SM上时,它将进一步划分为多个线程束,因为这才是SM的基本执行单元,但是一个SM同时并发的线程束数是有限的。这是因为资源限制,SM要为每个线程块分配共享内存,而也要为每个线程束中的线程分配独立的寄存器。所以SM的配置会影响其所支持的线程块和线程束并发数量。由于SM的基本执行单元是包含32个线程的线程束,所以block大小一般要设置为32的倍数。(16, 16)的二维Block是一个常用的配置,共256个线程。之前也曾提到过,每个Block的Thread个数最好是128、256或512,这与GPU的硬件架构高度相关。
|
|
线程块中的线程数是有限制的,现代GPUs的线程块可支持的线程数可达1024个。有时候,我们要知道一个线程在 blcok 中的全局ID,此时就必须还要知道 block 的组织结构,这是通过线程的内置变量 blockDim来获得。它获取线程块各个维度的大小。
- 对于一个
2-dim的block $ (D_x, D_y) $ ,线程 $ (x, y) $ 的ID值为 $ (x + y * D_x) $ - 对于一个
3-dim的block $ (D_x, D_y, D_z) $,线程 $(x, y, z)$ 的ID值为 $ (x + y * D_z + z * D_z * D_y) $
另外线程还有内置变量 gridDim,用于获得网格块各个维度的大小。
内存模型与管理
此外这里简单介绍一下CUDA的内存模型,如下图所示。可以看到,
- 每个 Thread 有自己的私有本地内存(Local Memory)
- 每个 Block 有包含共享内存(Shared Memory),可以被线程块中所有线程共享,其生命周期与线程块一致
- 所有的 Thread 都可以访问全局内存(Global Memory)
- 访问一些只读内存块:常量内存(Constant Memory)和纹理内存(Texture Memory)
- L1 Cache,L2 Cache
{% gp group-layout %}
{% endgp %}
下面简单介绍一下CUDA编程中内存管理常用的API。首先是在 Device 上分配内存的 cudaMalloc 、cudaFree 和 cudaMemcpy函数,分别对应C语言中的 malloc、free和 memcpy函数:
|
|
CUDA 编程实战
知道了CUDA编程基础,接下来我们以两个向量的加法为例,介绍如何利用CUDA编程来实现GPU加速计算。
CPU 向量加法:传统计算方法
我们首先来看利用 CPU 来计算向量加法该如何编程:
|
|
GPU 向量加法:一个Block一个Thread
我们将 CPU 的向量加法转换成 CUDA 程序,使用 GPU 来计算,下面这段代码演示了如何使用 CUDA 编程规范来编写程序。实际上仍然只是使用一个 core 来进行计算,不仅没有提高并行度,反而还增加了数据拷贝的成本,显然相比原来的计算是会更慢的,这里主要作为演示。
|
|
GPU 向量加法:一个Block多个Thread
为了提高并行度,我们设置一个 Block 多个 Thread 同时进行计算,如下图所示总共有256个Thread,每个 Thread 负责处理 Vector 中的一部分。每一次迭代中,256个Thread分别计算 Vector 的这256个数,然后在下一次迭代中每个Thread往后推进256个数,继续计算。
|
|
相比 CPU 程序,这里的并行度显著提高,GPU 计算的时间也大大减小。
GPU 向量加法:多个Block多个Thread
在上一个方案中,我们的256个Thread仍然需要计算多个数字,如果我们将并行度继续扩大,让每个Thread只需要计算Vector中的一个数,那么计算消耗时间将会更短。如下图所示,我们使用多个Block多个Thread,其中每个Block还是256个Thread,但是我们现在的Grid有多个Block,Block数字由Vector的长度除以BlockSize得到。
|
|
GPU 向量加法:Unified Memory
在上面的实现中,我们需要单独在 Host 和 Device 上进行内存分配,并且要进行数据拷贝,这是很容易出错的。好在CUDA 6.0引入统一内存(Unified Memory)来避免这种麻烦,简单来说就是统一内存使用一个托管内存来共同管理 Host 和 Device 中的内存,并且自动在 Host 和 Device 中进行数据传输。CUDA中使用cudaMallocManaged函数分配托管内存:
|
|
利用统一内存,可以将上面的程序简化如下:
|
|
相比之前的代码,使用统一内存更简洁了,值得注意的是 kernel 执行是与 Host 异步的,由于托管内存自动进行数据传输,这里要用cudaDeviceSynchronize() 函数保证 Device 和 Host 同步,这样后面才可以正确访问 kernel 计算的结果。
参考资料
-
No backlinks found.