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会让它打印寄存器用量。