08 > 线程束与延迟隐藏

GPU 并不是一个一个地运行线程。它把线程按 32 个一组来运行,这样的一组叫线程束。遇到慢速的显存访问时,它不是干等,而是切换到别的线程束去,从而把等待藏起来。这一课讲清楚这是怎么做到的,为什么 GPU 需要的线程数要远多于核心数,以及占用率是什么意思。

SIMT,一条指令带动多个线程

NVIDIA 把它的执行模型叫作 SIMT。你写核函数时,就像只为一个线程写代码,GPU 则用同一份代码运行很多线程。每个线程都有自己的寄存器和自己的数据,比如数组里属于它的那个元素。

硬件不会为每个线程单独取指令、译码。它为一组线程只做一次,然后组里的所有线程一起执行这条指令,各自处理自己的数据。这样省下了大量芯片面积和功耗,GPU 才能塞进这么多核心。

SIMT 看起来像 CPU 上的 SIMD,SIMD 是一条指令处理一个短向量。区别在于,用 SIMT 时你为每个线程写普通的标量代码,由硬件替你把线程分组。

线程束

一起执行同一条指令的那组线程就是线程束。到目前为止,所有 NVIDIA GPU 的线程束都是 32 个线程。一个 256 线程的线程块就是 256 / 32 = 8 个线程束,而一个 100 线程的线程块仍然要占 4 个线程束,因为最后一个线程束只装了一部分(100 = 3 × 32 + 4)。

线程束里的线程叫通道,编号从 0 到 31。在 CUDA 实践系列中,第 07 课 演示了线程如何算出自己的线程束编号和通道编号。

线程束调度器

第 00 课 介绍过 SM,也就是组成 GPU 的小处理器。L40S 是 Ada Lovelace 架构、CC 8.9 的 GPU,它的每个 SM 分成 4 个分区。每个分区有一个线程束调度器、64 KB 的一份寄存器文件,以及 32 条 FP32 通道。

每个时钟周期,每个线程束调度器都会挑出一个已就绪的线程束,发射它的下一条指令。所以一个 SM 每个周期最多能发射 4 条线程束指令,每个调度器一条。

在 L40S 上,一个 SM 最多能同时容纳 48 个线程束,也就是 48 × 32 = 1,536 个线程。这些就是它的驻留线程束。分给 4 个调度器,每个调度器有 48 / 4 = 12 个线程束可以挑选。

线程束等待显存时

从 VRAM 读取一次数据要花几百个时钟周期。第 07 课 讲了背后的存储层次。当一个线程束需要的值还没到时,它就没法执行下一条指令,这个线程束就停顿了。

CPU 核心会靠大缓存和提前猜测来尽量避免这种情况。GPU 的做法更简单。线程束调度器跳过停顿的线程束,从另一个已就绪的线程束发射指令。等数据到了,第一个线程束重新就绪,稍后再轮到它。

这种切换没有任何代价。每个驻留线程束的寄存器一直都留在寄存器文件里,所以没有什么需要保存或恢复的。而在 CPU 上,切换线程意味着把寄存器存到内存里,再把别的寄存器读回来,要花的时间长得多。

这就叫延迟隐藏。显存还是一样慢,但一个线程束等待的时候,其他线程束在做有用的事,所以调度器一直有活干。

下面的图是一个线程束调度器的简化模型。每个线程束先发射 2 个周期,然后等待显存 8 个周期。拖动滑块,改变驻留线程束的数量。

只有 1 个线程束时,调度器每 10 个周期只有 2 个周期在发射,忙碌率是 20%。每多一个线程束,就填上一个空档。有 (2 + 8) / 2 = 5 个线程束时,总有一个是就绪的,调度器 100% 的时间都在忙。第 6 个线程束什么也带不来,它只能排队等候。

真实的数字更大。从 VRAM 读取要几百个周期,所以调度器需要很多线程束,每个线程束里也需要很多相互独立的指令,才能把它盖住。

为什么 GPU 要的线程比核心多

只有存在别的线程束可以切换时,延迟隐藏才起作用。这就是为什么你启动的线程数要远远多于 GPU 的核心数。

用 L40S 算一个例子。

  • 142 个 SM × 128 = 18,176 个 FP32 核心
  • 142 个 SM × 1,536 = 218,112 个驻留线程
  • 218,112 / 18,176 = 每个核心可以驻留 12 个线程

在任何时刻,这些线程大多都在等待。这没关系。它们并没有被浪费,它们就是调度器的候选池,其他线程束等显存时,调度器就从这里挑。

占用率

占用率衡量一个 SM 装了多少线程束。

占用率 = 活跃线程束数 / 每个 SM 的最大线程束数

L40S 上的最大值是 48。如果一个核函数每个 SM 有 32 个活跃线程束,占用率就是 32 / 48 = 67%。如果 48 个全满,就是 100%。

占用率越高,每个调度器可挑选的线程束就越多,也就越有可能总能找到一个就绪的。

什么限制了占用率

SM 会给每个线程块分配寄存器、共享内存和一个位置。其中任何一样用完了,就再也放不下更多线程块,哪怕线程束上限还没到。在 L40S 上,最重要的限制有三个。

每个线程的寄存器

一个 SM 有 65,536 个 32 位寄存器,由它所有的驻留线程共用。要达到满占用,1,536 个线程都得放得下。

65,536 / 1,536 = 42.7,也就是每个线程大约 42 个寄存器

每个线程需要的寄存器越多,能放下的线程束就越少。

  • 64 个寄存器时,64 × 32 = 每个线程束 2,048 个寄存器,65,536 / 2,048 = 32 个线程束,所以是 32 / 48 = 67%
  • 128 个寄存器时,128 × 32 = 每个线程束 4,096 个,65,536 / 4,096 = 16 个线程束,所以是 16 / 48 = 33%

[!NOTE] 硬件按每个线程束 256 个寄存器为一块来分配。一个用 42 个寄存器的核函数,每个线程束需要 42 × 32 = 1,344 个,向上取整到 1,536,而 48 × 1,536 = 73,728,超过了 65,536。所以在 L40S 上要达到 100%,真正的上限是 40 个寄存器,因为 40 × 32 = 1,280,48 × 1,280 = 61,440,放得下。

每个线程块的共享内存

共享内存是 SM 内部的快速存储,由同一个线程块的线程共用。在 L40S 上,一个 SM 最多有 100 KB 共享内存,一个线程块最多能用 99 KB。

假设线程块有 256 个线程(8 个线程束),每个用 40 KB 共享内存。100 KB 里只放得下 2 个线程块,所以 SM 装 2 × 8 = 16 个线程束,也就是 16 / 48 = 33%。

线程块大小

L40S 上一个 SM 最多容纳 24 个线程块。太小的线程块会先撞上这个上限。每个线程块 32 个线程(1 个线程束)时,24 个线程块只有 24 个线程束,也就是 24 / 48 = 50%。

太大的线程块也会浪费空间。一个 1,024 线程的线程块是 32 个线程束。只放得下一个,因为两个就需要 64 个线程束,所以 SM 只装了 32 / 48 = 67%。128、256 或 512 线程的线程块能整除 1,536,只要寄存器和共享内存允许,就能达到 100%。

三个限制里最低的那个说了算。

分支发散

一个线程束的 32 个线程共用一条指令流。如果一个 if 让一些通道走这边、其余通道走那边,线程束就会先后执行两条路径,在每条路径上,没走这条路的通道都闲着。这叫分支发散。从 Volta 开始,每个线程都有自己的程序计数器,发散的线程可以交错执行、安全地互相等待,但两条路径仍然不是同时运行的。只有同一个线程束里的通道意见不一时,发散才会花时间。如果一个线程束的所有通道都走同一个分支,就没有代价。

占用率越高不一定越快

占用率是手段,不是目标。一旦调度器有足够多的就绪线程束来盖住等待,再多的线程束也不会改变什么,就像图里的第 6 个线程束一样。

一个线程束自己也能隐藏延迟。如果它接下来的指令并不依赖还在路上的那个值,调度器就可以继续发射它们。这叫 ILP。一个把更多数据放在寄存器里、给每个线程更多独立工作的核函数,在 33% 占用率下可能比一个 100% 占用率的简单核函数跑得更快。

[!WARNING] 为了提高占用率而强行让核函数少用寄存器,反而可能变慢。放不下的值会溢出到本地内存,而本地内存在 VRAM 里,每一次溢出都会增加你本想隐藏的那种显存流量。

这对 CUDA 意味着什么

你在核函数里做的每个选择都和这件事有关。启动时选的线程块大小、编译器给核函数分配的寄存器数量、你声明的共享内存,决定了每个 SM 能放下多少线程束。一个等待显存的核函数需要足够多的线程束来隐藏等待,第 09 课 会讲怎样判断一个核函数是受显存限制还是受计算限制。

线程块大小选 32 的倍数,这样就没有只装了一部分的线程束。在 L40S 上,128 或 256 是不错的默认值。尽量让同一个线程束里的通道走同一个分支。

[!TIP] 用 nvcc -arch=sm_89 -Xptxas -v -o NAME NAME.cu 编译,nvcc 会打印每个核函数用了多少寄存器和共享内存。有了这些数字,你就能像上面那样手算占用率。

小结

线程束是一起执行同一条指令的 32 个线程。每个线程束调度器每个周期发射一条线程束指令,并且零代价地跳过等待显存的线程束,因为每个线程束都保留着自己的寄存器。这就是为什么 GPU 需要的线程比核心多得多。占用率衡量驻留了多少线程束,寄存器、共享内存和线程块大小会限制它。要隐藏延迟,占用率必须足够,但更高并不自动意味着更快。

术语表

  • GPU(Graphics Processing Unit,图形处理器):这个系列的主角,由许多并行运行线程的 SM 组成。
  • CPU(Central Processing Unit,中央处理器):计算机的主处理器,有少数几个很快的核心。
  • SIMT(Single Instruction, Multiple Threads,单指令多线程):NVIDIA 的执行模型,你为一个线程写代码,硬件一次为一组线程运行它。
  • SIMD(Single Instruction, Multiple Data,单指令多数据):一条指令处理一个短向量的值,比如 CPU 的向量单元。
  • 核函数(kernel):在 GPU 上运行的函数,每个线程运行一次。
  • 线程(thread):核函数的一个实例,有自己的寄存器和数据。
  • 线程束(warp):一起执行同一条指令的 32 个线程。
  • 通道(lane):一个线程在线程束中的位置,从 0 到 31。
  • 线程块(block):一起启动、放在同一个 SM 上的一组线程,可以使用共享内存。
  • SM(Streaming Multiprocessor,流式多处理器):组成 GPU 的小处理器,L40S 有 142 个。
  • CC(compute capability,计算能力):表示 GPU 功能的版本号,L40S 是 8.9。
  • 线程束调度器(warp scheduler):每个时钟周期挑出一个就绪的线程束并发射其下一条指令的单元,Ada 的每个 SM 有 4 个。
  • 发射(issue):把一个线程束的下一条指令送去执行。每个线程束调度器每个时钟周期最多发射一条。
  • 驻留线程束(resident warp):一个 SM 同时容纳的线程束,L40S 上最多 48 个。
  • 寄存器(register):SM 里最快的存储,每个线程把自己的变量放在寄存器里。
  • 寄存器文件(register file):一个 SM 的全部寄存器,L40S 上是 65,536 个 32 位寄存器。
  • FP32(32-bit floating point,32 位浮点):GPU 计算的标准数字格式。
  • KB(kilobyte,千字节):1,024 字节。
  • VRAM(显存):GPU 卡上的大容量存储,从中读取一次要几百个时钟周期。
  • 停顿(stall):线程束因为在等待(比如等显存)而无法发射下一条指令。
  • 延迟隐藏(latency hiding):在慢速操作期间从其他线程束发射指令,让 GPU 保持忙碌。
  • 占用率(occupancy):活跃线程束数除以每个 SM 的最大线程束数。
  • 共享内存(shared memory):SM 内部的快速存储,由同一个线程块的线程共用,L40S 上每个 SM 最多 100 KB。
  • 分支发散(branch divergence):同一个线程束的通道走了不同路径,线程束只能先后执行这些路径。
  • 程序计数器(program counter):下一条指令的地址,从 Volta 开始每个线程都有自己的程序计数器。
  • ILP(instruction-level parallelism,指令级并行):同一个线程里互不依赖、无需互相等待就能发射的指令。
  • 溢出(spill):放不下寄存器的值被移到 VRAM 中的本地内存。
  • 本地内存(local memory):位于 VRAM 中、每个线程私有的内存,用来存放溢出的值。
  • CUDA(Compute Unified Device Architecture,统一计算设备架构):NVIDIA 用来编写在其 GPU 上运行的程序的平台。
  • nvcc(NVIDIA CUDA Compiler,NVIDIA CUDA 编译器):把 CUDA 代码变成 GPU 程序的编译器,-Xptxas -v 会让它打印寄存器用量。