1. GPU 简介
GPU(Graphics Processing Unit)是一种图形渲染设备,是显卡(Video Card/Graphics Card)的计算核心。GPU 最初仅用作纹理映射和多边形着色等需要较多存储空间的图形处理任务,不过,现代 GPU 已经不再局限于 3D 图形处理。GPU 已经成为了通用的多核处理器,它在 浮点运算、并行计算 等方面提供数十倍乃至于上百倍于 CPU 的性能。
GPU 和 CPU 有设计理念的不同,GPU 中包含大量的 ALU(Arithmetic Logic Unit),如图 1 所示。
Figure 1: GPU 和 CPU 有设计理念的不同,GPU 中包含大量的计算单元
2. CUDA C 编程
Nvidia 提出了 CUDA(Compute Unified Device Architecture)编程模型,它在 C(注:也支持 Fortran)语言的基础上进行了很小的扩展,使得应用程序既可以包含在 CPU 中执行的代码,又可以包含在 GPU 中执行的代码,充分利用了 CPU 和 GPU 各自的优点。
CUDA 程序的执行过程如图 2 所示,这个图演示了先执行 CPU 代码,再执行 GPU 代码,然后又执行 CPU 代码,又再执行 GPU 代码的情况。
Figure 2: Execution of a CUDA program
CUDA 程序中, 一个函数用 __global__
修饰,表明这个函数在 GPU 中运行,且被称为“kernel”。
2.1. Hello World
下面是 CUDA 版本的 Hello World 程序:
1 | #include<stdio.h> |
使用 nvcc 进行编译:
1 | $ nvcc hello.cu –o hello |
测试运行得到的可执行程序:
1 | $ ./hello |
2.2. 实例:数组相加
下面通过“两个浮点数数组相加”的例子来介绍一下 CUDA 编程。
2.2.1. CPU 版本
先看一下“两个浮点数数组相加”的 CPU 版本:
1 | #include <iostream> |
编译并运行:
1 | $ g++ add.cpp -o add |
2.2.2. 改造为 GPU 版本
下面我们来看看如何把前面的程序改造为 GPU 版本。
GPU 只能访问 GPU 中的内存,称为 Device Memory;而 CPU 能访问的内存称为 Host Memory。
Figure 3: Host Memory and Device Memory
对于前面例子,我们要把输入数据(x, y 两个数组)所占的内存从 Host Memory 复制到 Device Memory 中,然后执行 GPU 计算,计算完成后,把计算后的结果从 Device Memory 复制加 Host Memory 中。 这是 GPU 编程的通用编程模式。
下面是改造后的 GPU 版本:
1 | // 这个例子仅启动了 1 个 GPU 线程,没有利用 GPU 优势 |
经过这个改造后,函数 add
在 GPU 上运行,但仅启动了一个 GPU 线程,这并没有利用 GPU 的优势。为了利用 GPU 优势,我们需要对函数 add
本身进行改造。后面将对此进行介绍。
2.2.3. 线程结构 <<<numBlocks, threadsPerBlock>>>
在启用 GPU 线程时,需要使用语法 <<<numBlocks, threadsPerBlock>>>
指定线程结构。
比如: kernel1<<<1, 4>>>();
表示 1 个 block,每个 block 中有 4 个线程。
在 kernel 函数中,可以通过 threadIdx.x
知道自己是第几个线程。这例子中 kernel1 中打印 threadIdx.x
时会分别得到 0,1,2,3,参考节 2.1 中的例子。
又如: kernel1<<<2, 4>>>();
表示 2 个 block,每个 block 中有 4 个线程。
在 kernel 函数中, 可以通过 blockIdx.x
知道自己是第几个 block,这个例子中会分别为 0,1;可以通过 threadIdx.x
知道自己是第几个线程。 这例子中 kernel1 中打印 threadIdx.x
时会分别得到 0,1,2,3。也就是说 8 个线程中打印 blockIdx.x
和 threadIdx.x
时会得到:
1 | blockIdx.x threadIdx.x |
在 kernel 函数,通过 blockDim.x
可以知道 threadIdx.x
的维度(最大 x
下标加 1)。也就是说 8 个线程中打印 blockIdx.x, threadIdx.x, blockDim.x 时会得到:
1 | blockIdx.x threadIdx.x blockDim.x |
这样,以方式 kernel1<<<2, 4>>>();
启动 kernel1 时,在 kernel1 函数中使用 blockIdx.x * blockDim.x + threadIdx.x
就可以得到 0,1,2,3,4,5,6,7。
这里介绍的线程结构比较简单,关于更多细节,可参考节:3.1
2.2.4. GPU 版本 2(monolithic kernel)
为了充分利用 GPU 优势,我们让每个 GPU 线程仅处理数组中的一个元素。kernel 函数改造为如下:
1 | __global__ |
这种类型的 kernel 被称为“monolithic kernel”。
每个线程仅处理 1 个元素,数组有 N=1<<20
(即 1M)元素,故我们需要启动 1M 线程,每个线程处理不同元素,下面这都是可行的:
1 | add<<<ceil(N/512.0), 512>>>(N, x, y); // ceil(N/512.0) = 2048 个 block,每个 block 中有 512 个线程;2048 * 512 = 1M |
我们可以直接配置 1 个 block,让 block 的线程数为 N
吗?像下面这样:
1 | add<<<1, N>>>(N, x, y); // 这是不行的,N 超过了每个 block 中的最大线程数的限制 |
这是不行的。因为每个 block 中的最大线程数是有限制的:
- 当 Compute capability < 2.0 时,每个 block 中的最大线程数为 512;
- 当 Compute capability >= 2.0 时,每个 block 中的最大线程数为 1024。
2.2.5. GPU 版本 3(grid-stride loop)
在上一节介绍的 monolithic kernel 是不灵活的。启动 GPU 线程时,必须指定恰当的 <<<numBlocks, threadsPerBlock>>>
参数,否则可能出现数组元素没有被处理的情况(这种情况在指定的 numBlocks 太小时可能出现);此外,当元素规模变得更大时,可能会超过 numBlocks 的最大限制。
这种介绍另外一种更加灵活的 Kernel 函数编写方式——grid-stride loop:
1 | __global__ |
grid-stride loop 形式的 kernel 很灵活,其正确性和调用时如何指定线程无关。比如,下面这些调用形式都可以得到正确的结果:
1 | add<<<1, 256>>>(N, x, y); |
下面分别介绍这几种情况。
当使用 add<<<1, 256>>>(N, x, y);
时,共 256 个线程,每个线程处理 4096 个元素,每个线程中各变量如下:
1 | blockIdx.x blockDim.x threadIdx.x gridDim.x index stride |
例如,index 为 0 的线程将对数组下标为 0, 256, 2256, …, 4095256 的元素(共 4096 个)进行处理。
当使用 add<<<2, 256>>>(N, x, y);
共 512 个线程,每个线程处理 2048 个元素,每个线程中各变量如下:
1 | blockIdx.x blockDim.x threadIdx.x gridDim.x index stride |
例如,index 为 0 的线程将对数组下标为 0, 512, 2512, …, 2047512 的元素(共 2048 个)进行处理。
当使用 add<<<4096, 256>>>(N, x, y);
时,共 1M 线程(index 为 515 的线程如图 4 所示,图片摘自 https://developer.nvidia.com/blog/even-easier-introduction-cuda/ ),每个线程处理 1 个元素,每个线程中各变量如下:
1 | blockIdx.x blockDim.x threadIdx.x gridDim.x index stride |
Figure 4: add<<<4096, 256>>>(N, x, y);
中 index 为 515 的线程
当使用 add<<<1, 1>>>(N, x, y);
时,共 1 个线程,每个线程处理 1M 元素,每个线程中各变量如下:
1 | blockIdx.x blockDim.x threadIdx.x gridDim.x index stride |
在 CUDA Pro Tip: Write Flexible Kernels with Grid-Stride Loops 一文中,总结了 grid-stride loop 的几个优点:
- Scalability and thread reuse. By using a loop, you can support any problem size even if it exceeds the largest grid size your CUDA device supports. Moreover, you can limit the number of blocks you use to tune performance.
- Debugging. By using a loop instead of a monolithic kernel, you can easily switch to serial processing by launching one block with one thread.
add<<<1, 1>>>(N, x, y);
This makes it easier to emulate a serial host implementation to validate results. - Portability and readability. The grid-stride loop code is more like the original sequential loop code than the monolithic kernel code, making it clearer for other users.
2.3. Function Execution Space Specifiers
函数除了用 __global__
修饰外,还可以指定 __device__, __host__
,它们的含义和区别如表 1 所示。
name | Executed on the: | Only callable from the: |
---|---|---|
__global__ |
device | host |
__device__ |
device | device |
__host__ |
host | host |
所谓 host 就是指 CPU,而 device 就是 GPU。
__global__
不能和 __device__
同时使用,也不能和 __host__
同时使用;而 __device__
和 __host__
可以同时使用。
参考:https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#function-declaration-specifiers
2.3.1. __forceinline__
and __noinline__
当一个函数用 __device__
修饰时,编译器自己会决定是否对该函数进行内联编译。
我们也可以指定 __forceinline__
或者 __noinline__
来强制使用(或者不使用)内联编译。
3. 可伸缩的并行执行
3.1. CUDA 线程组织
一个 Grid 内的所有线程会执行相同的 kernel 函数。
在语法 <<<m, n>>>
中,参数 m 和 n 除了可以是 int
类型外,还可以是 dim3
类型(三维数组):
1 | dim3 dimGrid(2, 1, 1); |
下面我们看一个复杂一些的例子,dimGrid 为 2 x 2 x 1,dimBlock 为 4 x 2 x 2:
1 | dim3 dimGrid(2, 2, 1); |
这个例子中,block 共 4 个,表现为二维形式;而 thread 共 8 个,表现为三维形式,如图 5 所示。
Figure 5: A multidimensional example of CUDA grid organization.
内置变量 gridDim.x, gridDim.y, gridDim.z
分别保存着 grid 的三个维度的信息,上面例子中,由于 dimGrid 为 2 x 2 x 1,所以有:
1 | gridDim.x = 2 |
内置变量 blockDim.x, blockDim.y, blockDim.z
分别保存着 block 的三个维度的信息,上面例子中,由于 dimBlock 为 4 x 2 x 2,所以有:
1 | blockDim.x = 4 |
blockIdx.x, blockIdx.y, blockIdx.z
保存着 grid 中当前 block 的下标, threadIdx.x, threadIdx.y, threadIdx.z
保存着 block 中当前 thread 的下标。如果以图 5 中面向读者的右下角那个 thread 为当前 thread,则有:
1 | blockIdx.x = 1 |
3.1.1. 各种维度情况下线程的编号
不管采用什么维度的 grid 和 block,我们都可以得到当前 thread 的扁平的全局一维下标:
1 | // 1D grid of 1D blocks |
参考:https://cs.calvin.edu/courses/cs/374/CUDA/CUDA-Thread-Indexing-Cheatsheet.pdf
3.1.2. 抽象概念(Grid/Block/Thread)和硬件的映射关系
下面是抽象概念(Grid/Block/Thread)和硬件的映射关系:
- Grids map to GPUs
- Blocks map to the MultiProcessors (MP)
- Threads map to Stream Processors (SP)
- Warps are groups of (32) threads that execute simultaneously
3.2. 映射线程到多维数据(RGB 转灰度图片实例)
grid 可以是 1D,2D,3D,block 也可以是 1D,2D,3D,那我们应该如何选择线程的组织形式呢?这往往由待处理数组的结构的决定。 比如,处理图片时,由于图片是像素点的二维数组,这时采用 2D grid 和 2D block 是个不错的选择。假设,现在要处理图片的像素规模为 x×y=76×62 。我们决定采用 16 x 16 的 2D block,这时 x 方向上至少需要 5 block,而 y 方向上至少需要 4 block,如图 6 所示。
Figure 6: Using a 2D thread grid to process a 76 × 62 picture P.
从图 6 中可以看到,在 x 方向上有 4 个多余的线程,在 y 方向上有 2 个多余的线程。在 kernel 函数中通过边界检查让多余线程不执行操作即可。
假设 GPU 任务为 RGB 彩色图片转灰色图片,则可以这样启动 kernel:
1 | int m = 76; |
关键的 kernel,即 colorToGreyscaleConversion 的实现如下:
1 | // we have 3 channels corresponding to RGB |
3.3. 图片模糊处理实例
下面看一个更复杂的图片处理例子——图片模糊处理。
图片模糊处理的一种方式就是“把当前像素相邻的几个像素的平均值”作为当前像素的值,如图 7 所示,它取的是 3 x 3 小窗口里的像素的平均值(当然这个小窗口也可以更大,如 5 x 5 或 7 x 7 等)。
Figure 7: Each output pixel is the average of a patch of pixels in the input image.
下面是图片模糊处理 blurKernel 的实现:
1 | __global__ |
上面代码中,如果计算 3 x 3 小窗口里的像素的平均值(9 个像素点的平均值),则 BLUE_SIZE = 1;如果计算 5 x 5 小窗口里的像素的平均值(25 个像素点的平均值),则 BLUE_SIZE = 2。
需要说明的是,对于角上和边上的像素,其平均值并没有计算 9 个像素点,如图 8 所示。
Figure 8: 角上仅考虑了 4 个像素点的平均,边上仅考虑了 6 个像素点的平均
3.4. Barrier Synchronization(限于 block 内)
CUDA 中,可以使用函数 __syncthreads()
,让同一个 block 中的线程进行同步。也就是说,当一个线程调用 __syncthreads()
后,它会等待同一个 block 中的所有其它线程都到达 __syncthreads()
所在位置后,才往下执行。
不过,需要注意的是。一个 __syncthreads()
必须被同一个 block 中所有线程都执行,或者都不执行。假设在 if-then-else
语句的 if 和 else 分支中各有一个 __syncthreads()
语句,而同一个 block 中的有些线程执行进入了 if 分支,而另外一些线程进入了 else 分支,那么这个程序会一直等待。
这种同步机制限定在同一个 block 内,也就是说 block 之间没有任何的依赖和约束,它们可以以任意顺序执行, 这提供了 Transparent Scalability,如图 9 所示。
Figure 9: Lack of synchronization constraints between blocks enables transparent scalability for CUDA programs.
3.5. Thread Scheduling
Thread 调度属于硬件的实现细节,了解这些实现细节有助于我们进行性能调优。
CUDA 程序一般会创建一些线程块(Block), Block 会被调度到空闲的 Streaming Multiprocessors(SM)上去。当 Block 执行完毕后,Block 会退出 SM,释放出 SM 的资源,以供其他待 Block 调度进去。
因此,无论是只有 2 个 SM 的 GPU,还是有 4 个 SM 的 GPU,这些线程块都会被调度执行,只不过 4 个 SM 的 GPU 一般会执行得更快。因此,同样的程序,可以在具有不同 SM 数量上的 GPU 运行,这称为 Automatic Scalability。如图 10 所示。
Figure 10: Automatic Scalability
更细节一点, 一个 block 分配给 SM 执行时,还会进一步拆分为 Warp,它是以 32 个 thread 组成的小分组(warpSize 是一个硬件的参数,它往往为 32)。Warp 是 SM 内的线程调度的最小单元。 假设,一个 block 中共有 256 个线程,则我们可以计算出这个 block 包含 256/32 = 8 个 Warp。
一个 Warp 在执行时,遵循 Single instruction, multiple threads(SIMT)模式。也就是 32 个 thread 会共享“instruction fetching”过程,并不是每个 thread 分别去“instruction fetching”,而是“instruction fetching”后,给 32 个线程都执行。这种方式可以大大减少频繁的“instruction fetching”过程。
4. Memory and Data Locality
4.1. Memory-Bound Programs
考虑节 3.3 中介绍的图片模糊 kernel 的最核心代码:
1 | // Get the average of the surrounding BLUR_SIZE x BLUE_SIZE box |
在内层 for 循环的每次迭代中,有 1 次 Global Memory 的访问(即对 in[]
数组的访问),有 1 次浮点数的加法运算(即 pixVal += in[curRow * w + curCol]
)。
我们把“浮点运算次数”和“取内存次数”的比值定义为 compute-to-globalmemory-access ratio (CGMA),对于上面例子有:
浮点运算次数访问次数CGMA=浮点运算次数Global Memory 访问次数=11=1.0
假设 Global memory 的访问速度是 1000 GB/s(即 1 TB/s),考虑单精度浮点数占用 4 个字节,那么每秒可以加载 1000/4=250 giga 浮点数,也就是说 kernel 每秒处理浮点数不会超过 250 GFLOPS。
设某 GPU 的浮点计算性能为 12 TFLOPS,那么运行上面 kernel 时,仅达到浮点计算能力峰值的 2%,没有充分地利用 GPU。像这种,执行速度的 “瓶颈位于内存访问过程”的程序被称为“memory-bound program”。
后文将介绍如何减少内存的访问次数,以提高程序执行速度。
4.2. 矩阵乘法
下面介绍矩阵 M 和 N 相乘得到结果矩阵 P。
假设每个线程仅计算结果矩阵 P 的一个元素,可以使用下面的 kernel 函数:
1 | __global__ |
这个 kernel 和节 3.2 介绍的彩色图片转灰度图片的 colorToGreyscaleConversion 基本类似。kernel 中 Row 和 Col 的如图 11 所示。
Figure 11: Row 和 Col 的计算
和彩色图片转灰度图片类似,我们也是采用 2D block。假设矩阵为 4 x 4 的,采用 2 x 2 的 block,那么 kernel 执行如 12 图所示。
Figure 12: MatrixMulKernel 执行示意图
如果仅考虑 block(0,0) 中线程的执行,则如图 13 所示。
Figure 13: block(0,0) 中线程的执行
在下面最关键代码中:
1 | for (int k = 0; k < Width; ++k) { |
有两次 Global memory 的访问,一次浮点乘法和一次浮点加法。所以上一节介绍的 CGMA 值会为 1,这是一个“memory-bound program”,我们需要想办法减少内存的访问次数。
4.3. CUDA 内存类型
CUDA 设备中有不同的内存类型,可以帮助我们提高 CGMA,以提高程序性能。
CUDA 的内存类型如图 14 所示。
Figure 14: Overview of the CUDA device memory model
通过表 2 所示语法可以声明程序变量位于哪种内存中。
Variable declaration | Memory | Scope | Lifetime |
---|---|---|---|
Automatic variables other than arrays | Register | Thread | Kernel |
Automatic array variables | Local | Thread | Kernel |
__device__ __shared__ int SharedVar; |
Shared | Block | Kernel |
__device__ int GlobalVar; |
Global | Grid | Application |
__device__ __constant__ int ConstVar; |
Constant | Grid | Application |
4.4. 矩阵相乘优化(Tile 优化)
如何减少矩阵相乘时对 Global memory 的访问呢?我们先看看图 13 的情况。
block(0,0) 中的 4 个线程读取 Global memory 的情况如图 15 所示。可以发现:Global memory 中的数据被读取了多次。
Figure 15: block(0,0) 线程读取内存的情况
如果同一个 block 中的线程仅从 Global memory 中读取输入矩阵一次,放入到 Shared memory 中,则可以减少对 Global memory 的访问,以提高程序性能。
这种优化被为 Tile 优化。下面是一个采用 Tile 优化的矩阵相乘的 kernel 函数:
1 | __global__ |
5. Unified Memory
CUDA 6 中引入了 Unified Memory,不用显式地使用 cudaMemcpy
在 Host 和 Device 之间复制内存了,简化了编程步骤,如图 16 所示。
Figure 16: CUDA 6 Unified Memory
6. 并行计算模式
在《Programming Massively Parallel Processors, 3rd, 2017》一书介绍了一些并行计算模式,如:Convolution、Prefix Sum、Histogram、Sparse Matrix Computation、Merge Sort、Graph Search。
这里不介绍它们,有兴趣的读者可以参考原著。
7. Compute Capability
CUDA 的计算能力 Compute Capability 可以认为是硬件的版本。表 3 列出了不同 Compute Capability 下的一些产品型号。
Compute Capability | Micro-architecture | GeForce(消费级) | Quadro(专业级) | Tesla(数据中心) | Jetson(嵌入式) |
---|---|---|---|---|---|
1.0 | Tesla | GeForce 8800 GTX | Quadro FX 5600 | ||
2.0 | Fermi | GeForce GTX 590 | Quadro Plex 7000 | ||
3.0 | Kepler | GeForce GTX 770 | Quadro K5000 | Tesla K10 | |
3.2 | Kepler | ||||
3.5 | Kepler | GeForce GTX TITAN Z | Quadro K6000 | Tesla K40, Tesla K20 | |
3.7 | Kepler | Tesla K80 | |||
5.0 | Maxwell | GeForce GTX 750 | Quadro K1200 | ||
5.2 | Maxwell | GeForce GTX TITAN X | Quadro M5000 | Tesla M60, Tesla M40 | |
5.3 | Maxwell | Jetson TX1, Tegra X1 | |||
6.0 | Pascal | Quadro GP100 | Tesla P100 | ||
6.1 | Pascal | GeForce GTX 1080 | Quadro P6000 | Tesla P40, Tesla P4 | |
6.2 | Pascal | Jetson TX2 | |||
7.0 | Volta | NVIDIA TITAN V | Quadro GV100 | Tesla V100 | |
7.2 | Volta | Jetson AGX Xavier | |||
7.5 | Turing | Geforce RTX 2080 | Quadro RTX 8000 | Tesla T4 | |
8.0 | Ampere |
不同 Compute Capability 的区别可以参考:https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#compute-capabilities
8. 开发工具
8.1. nvcc
nvcc 是 CUDA 编程器,在节 2.1 中介绍了它的基本用法。
8.2. nvprof
nvprof 是对 CUDA 程序进行性能瓶颈分析的工具。
下面是使用 nvprof
对矩阵相乘 CUDA 程序进行分析的例子:
1 | $ nvprof matrixMul |
8.3. nvidia-smi
nvidia-smi (NVIDIA System Management Interface) 是管理 NVIDIA GPU 设备的命令行工具。可以监控 GPU 使用情况以及更改 GPU 状态。
下面是 nvidia-smi 的运行例子,输出中 GPU-Util 为 100% 表示 GPU 正在满负载工作。
1 | $ nvidia-smi |
9. 参考
本文主要考虑