Skip to content
On this page

GTC 2022 How CUDA Programming Works

GTC 2022 大会上关于 GPU 架构的演讲 摘录,来自英伟达 CUDA Architect,Stephen Jones。Stephen 在 PPT 中给他的演讲换了个主题名字:Why CUDA Programming Is The Way It Is,听完醍醐灌顶,感觉这个标题是很贴切了。

最宝贵的资源:内存带宽

首先 Stephen 以安培架构为例,如下图,两个重要的数据,FP64 峰值为 9.7TFLOPS,Memory Bandwith 峰值是 1555 GB/s。

image-20230601231943556

现在算一笔账:假设 108 个 SM 都以 1410 MHZ跑满了,SM 在每个时钟周期能加载 64 个字节,那么峰值应该是他们乘起来,9750 GB/s。但是内存的带宽仅有 1555 GB/s。

image-20230601232325647

再算一笔账:1555GB/s 的带宽跑满了,如果都是加载 64 比特的,也就是 8 个字节,那应该是 194 GFLOPS。所以说,FLOPS 不是关键限制,GPU 仍然可能面临“显存墙”。

从底层来看,内存单元依赖一个电容元件,要么带电为 1,要么不带电为 0。

image-20230601214841309

DRAM 芯片由数百万个这样的单元组成一个大的矩阵。数据使用行和列来索引。例如在读取时,一行中所有的单元都被激活,状态被复制到感应放大器上。问题是这样电容会放电,数据就会被破坏。然后根据列索引读取放大器中的数据。显而易见,一个优化就是一次性读取整行数据。但是在换一行读取的时候,需要写回数据,因为前一次的读取导致电容放电了,所以换行读取的开销也很大,切换行的成本是切换列的三倍。下图中的 page,也是指这样的一行。

image-20230601220316989

从 DRAM 加载和读取值的速率取决于电容器充电和放电所需的时间。读的时候放电,写回的时候充电。下图是一个概念性的公式。同时注意到,一行是 1KB。

image-20230601220816648

在实验中,也表现也基本符合理论,读取时候的间隔 stride 增大,读取 8 个字节的速度逐渐降低。

image-20230601221033404

因此在写 CUDA 程序的时候同样要注意内存的布局和数据访问的方式。比如在 CPU 上大家都知道,右侧写法会比左侧满很多

image-20230601221704839

CUDA 中的基本执行单元是 thread block,块中的线程一定是同时处于工作状态的。每个线程运行着相同的代码,但是有自己的寄存器。SIMT 建立在独立的 PC 寄存器上,同时硬件会在 block 启动时自动填充 blockIdx 和 threadIdx,用于区分不同线程,线程控制是隐式的,每个线程有自己的状态。

image-20230601222141531

block 被分成了一个个 32 个线程组成的 wrap。如下图,如果每个线程读取 8 字节的数据,那么一个 wrap 会读取连续的 256 字节(因为 threadIdx 是连续的)。

image-20230601222907593

GPU 上的单个 SM 可以管理 64 个 wrap,也就是 2048 个线程。但是如图所示,它实际上只有四个独立的部分,也就是说,同一时间最多同时运行四个 wrap。其他的是在一个 queue 里面等待调度。

image-20230601223108656

继续刚刚的讨论,那么一个 SM 上实际上跑了四个 wrap,每个 wrap 假设还是加载上面的 256 字节,那么就刚好是 1KB。还记得 1KB 吗?刚好是一个页面(内存单元中的一行)的大小。因此,尽管看上去是在进行随机内存读,但是由于是四个 wrap,共计 128 个线程在同时执行,恰好就读取了内存单元的连续的一行。对应下图中右边折线图左上角的情况。这一定程度上解释了为什么一个 wrap 是 32 个线程,为什么一个 SM 上可以同时运行 4 个 wrap。Stephen 也给出了建议:不要让你的 thread block 的大小超过 128。NV 开源的 CUTLASS 的就有相关优化。

image-20230601223652707

占用率

接下来讨论占用率 occupancy,以 A100 为例,108 个 SM,221184 个线程,资源是有限的,就像一个嵌入式系统,要清楚你能够利用到的硬件资源。一旦 grid 被加载,里面的 block 就会被放置到可用的 SM 上。这一步将会尽可能地让 block 广泛分布。

image-20230601220037549

图中每个 SM 上放置了两个 block,实际上取决于 block 的大小,单个 SM 中上限不超过 32 个,尽可能占满 SM。当一个 block 工作完成后,它会退出,硬件会安排另一个 block 到这个 SM 上,继续尽力占满 SM。但是填满 SM 到底是什么意思?

image-20230601224024378

这需要回到 SM 的硬件资源上,最重要的是顶部的四个:最大线程数,最大块数、每个 SM 的寄存器数量、每个 SM 的共享内存总量。另外,block 会尽可能均匀地分布到不同 SM 上,是因为这里的每个 SM 的数据加载带宽限制(由于要支持多个 SM 同时工作,所以每个 SM 的带宽是只占总带宽的一部分)。

image-20230601224551600

还是以一段程序为例,这里涉及到上面提到的 SM 中的几个资源,这里要注意寄存器是一个 per-thread 的资源需求,所以要乘以 block 内的 thread 数量。

image-20230601225236123

下面讨论一个 block 是依据什么被放到 SM 上的,注意一个 block 永远不会横跨两个 SM。如果没有足够的资源,SM 会放弃运行该 block。block 是保障并行性的最大元素,块内可以通过共享内存进行数据交换和线程同步,块内的线程总是同时处于工作状态。

如下图,右侧给出了一个虚拟的 block 的资源需求。我们希望尽可能多的 block 工作在 SM 上,但是由于共享内存不足,只能放三个 block。假设共享内存需求从 64KB 变成了32 KB,那么只能放 4 个,限制条件又变成了寄存器。但是,只从 block 的执行效率上来看,我们快了 33%(如果我们忽略共享内存调整,对执行速度的潜在的影响)。

image-20230601225845534

现在考虑另一个 grid,它不需要共享内存,每个线程所需的寄存器数量也比较少。事实上这是一个典型的排序、数据拷贝等用途的 kernel。那么 GPU 会尝试将它放到之前的空隙里面。

image-20230601230341366

这里也蕴含了另一个重要的点,独立的任务可以同时运行在 GPU 上,比如下图中的不同 Stream 间,可能有计算和数据拷贝的交错,也就是上图讨论的任务调度的技巧,也称为 oversubscription。

image-20230601230616947

image-20230601230736876