指令流水线基础:取指、译码、发射、执行、写回——五级经典流水线在GPU中的变体
各位同学,今天我们来聊聊指令流水线。
说实话,很多做GPU编程的朋友,写了很久的kernel,却不太清楚指令在硬件里到底是怎么跑的。这就像开车的人不懂发动机原理——能开,但开不快,出了问题也不知道怎么修。
我个人习惯,在讲任何优化之前,先把底层的流水线搞明白。因为所有的优化技巧,说白了都是在跟流水线打交道。
经典五级流水线:CPU的遗产
先回顾一下CPU里的经典五级流水线:
- 取指(IF):从指令缓存里把指令拿出来
- 译码(ID):解析指令,看看它想干什么
- 发射(IS):把指令送到执行单元门口排队
- 执行(EX):真正干活,比如做加法、访存
- 写回(WB):把结果写回寄存器
这五级,每一级花一个时钟周期。理想情况下,每个周期都有一条指令完成,吞吐率就是1条/周期。
但现实哪有这么理想?
我在项目中遇到过,CPU流水线最怕的就是分支预测失败和缓存缺失。一旦发生,流水线就要冲刷,十几条指令白干了。嗯,这个痛,做CPU的朋友都懂。
GPU流水线的变体:从单线程到大规模并行
GPU跟CPU最大的区别是什么?
CPU是「单线程性能至上」,GPU是「吞吐量至上」。所以GPU的流水线设计,完全是另一个思路。
你想想看,GPU里动辄几千个线程在跑,如果每个线程都搞一套五级流水线,芯片面积得爆炸。所以GPU做了几个关键变体:
变体一:取指阶段——Warp调度取代单线程取指
在GPU里,最小的执行单位不是线程,而是Warp(或者叫Wavefront)。一个Warp包含32个(NVIDIA)或64个(AMD)线程,它们执行同一条指令,只是处理不同的数据。
取指阶段,GPU不是给每个线程单独取指令,而是以一个Warp为单位,从指令缓存里取出指令,广播给Warp里的所有线程。
关键点:GPU的取指带宽是CPU的几十倍。一个时钟周期内,GPU可以同时为多个Warp取指。这就是为什么GPU能隐藏延迟——当一个Warp在等数据时,调度器立刻切换到另一个Warp,取指单元一刻不停。
变体二:译码阶段——指令格式的简化
GPU的指令集比CPU简单得多。CPU的x86指令,长度可变,译码极其复杂。GPU的指令,比如NVIDIA的PTX或者AMD的GCN指令,长度固定,译码逻辑简单。
我记得有一次做性能分析,发现某个kernel的译码阶段占了大量功耗。后来发现是因为用了太多特殊指令,导致译码器无法并行处理。换成普通指令后,功耗直接降了15%。
所以我的建议是:尽量用通用指令,少用那些花里胡哨的特殊指令。译码器喜欢简单的活儿。
变体三:发射阶段——多发射与乱序发射
现代GPU都支持多发射。什么意思?就是一个时钟周期内,可以发射多条指令到不同的执行单元。
比如,一条访存指令发射到LD/ST单元,一条算术指令发射到ALU,一条特殊函数指令发射到SFU。这三条指令可以同时发射,互不干扰。
| 执行单元类型 | 可同时发射的指令数 | 典型延迟(周期) |
|---|---|---|
| ALU(整数/浮点) | 2-4条 | 4-6 |
| LD/ST(访存) | 1-2条 | 100-400 |
| SFU(特殊函数) | 1条 | 16-32 |
看到没?访存延迟是几百个周期,而ALU延迟只有几个周期。这就是为什么GPU需要大量线程来隐藏访存延迟——你一个Warp在等数据,其他Warp的ALU指令可以插空执行。
避坑指南:我曾经写过一个kernel,里面全是访存指令,ALU指令很少。结果发射阶段经常空转,因为LD/ST单元忙不过来,ALU单元却闲着。后来我把一些计算挪到访存之前做,让ALU和LD/ST同时工作,性能提升了30%。
变体四:执行阶段——SIMT与向量化
GPU的执行阶段,用的是SIMT(单指令多线程)模型。跟CPU的SIMD(单指令多数据)不同,SIMT允许每个线程有自己的执行路径。
但这里有个坑:分支发散。
当一个Warp里的32个线程,有的走if分支,有的走else分支,GPU会怎么做?它会先执行if分支,把走else的线程掩蔽掉;然后再执行else分支,把走if的线程掩蔽掉。相当于两条路径串行执行了。
我见过最夸张的例子,一个kernel因为分支发散,性能直接掉了80%。
注意:尽量避免Warp内的分支发散。如果实在避免不了,让发散的分支尽量短。GPU执行发散分支时,相当于把Warp的并行度砍了一半甚至更多。
变体五:写回阶段——寄存器文件与共享内存
CPU的写回,通常只写回寄存器。GPU的写回,除了寄存器,还有共享内存和全局内存。
这里有个细节:GPU的寄存器文件非常大。一个SM(流多处理器)可能有数万个寄存器。但寄存器是分给每个线程的,线程越多,每个线程能用的寄存器就越少。
我建议你在写kernel时,控制每个线程的寄存器使用量。如果每个线程用太多寄存器,能同时运行的Warp数就会减少,延迟隐藏能力就会下降。
怎么控制?用__launch_bounds__或者编译器选项限制寄存器数。我曾经把一个kernel的寄存器数从64降到32,Warp数翻倍,性能反而提升了20%。
GPU流水线的整体视图
把上面这些变体串起来,GPU的流水线大概是这样的:
取指(Warp调度器选择下一个Warp)
→ 译码(固定长度指令,快速解析)
→ 发射(多发射到不同执行单元)
→ 执行(SIMT,32线程并行)
→ 写回(寄存器/共享内存/全局内存)
但注意,这不是一条流水线,而是很多条流水线并行工作。每个SM都有自己的流水线,每个流水线里又有多个Warp在轮转。
说白了,GPU的流水线就是一个「超级流水线工厂」——多条流水线同时跑,每条流水线里多个任务(Warp)交替执行,谁的数据准备好了谁就上。
小结
今天的内容,核心就一句话:GPU的流水线是经典五级流水线的并行化变体。
取指变成Warp调度,译码变得简单,发射支持多发射,执行变成SIMT,写回涉及多种存储层次。
下一章,我们会深入取指阶段,看看Warp调度器到底是怎么工作的,以及如何利用调度特性来优化你的kernel。
嗯,今天就到这里。有问题随时问我。