2. GPU架构与吞吐模型
各位同学,咱们今天聊点硬核的。GPU到底是怎么把计算跑得飞快的?说白了,你得先搞懂它的“骨架”和“血液”。我刚开始接触CUDA时,也以为写个kernel就能自动跑满性能,结果被现实狠狠教育了一顿。嗯,今天咱们就把这层窗户纸捅破。
SM与Warp调度机制:GPU的“车间”与“流水线”
GPU的核心计算单元叫SM(Streaming Multiprocessor)。你可以把SM想象成一个微型工厂,里面有很多计算核心、共享内存、寄存器等资源。每个SM同时处理多个线程块(Thread Block),但真正执行指令的最小单位是Warp——32个线程组成一个Warp。
我个人习惯把Warp比作“一列火车”。32个线程就是32节车厢,它们必须步调一致地前进。如果某个线程因为数据依赖或分支发散而掉队,整列火车都得等它。这就是所谓的“Warp Divergence”问题。我在项目中遇到过一段代码,if-else分支里只有少数线程走不同路径,结果性能直接腰斩。后来改成数据预处理,把相同分支的线程聚在一起,吞吐量立马翻倍。
为什么会这样?因为GPU的指令执行延迟很高(比如访存延迟几百个周期),但通过快速切换Warp,SM可以让计算单元一直忙碌。你想想看,如果只有一个Warp,那它等数据时SM就闲着;如果有足够多的Warp,SM就能在等待期间切换到另一个Warp执行。这就是“延迟隐藏”的核心思想。
内存层次结构:从快到慢的“数据高速公路”
GPU的内存系统,说白了就是一套金字塔结构。越靠近计算单元,速度越快,但容量越小。咱们从上往下捋:
- 寄存器(Register):每个线程私有,速度最快(1个周期)。但数量有限,每个SM通常只有64K~256K个寄存器。我见过新手一上来就声明几十个double变量,结果寄存器溢出到Local Memory,性能直接崩盘。
- 共享内存(Shared Memory):同一个线程块内所有线程共享,延迟约20~30个周期。它是程序员可以显式控制的缓存,适合做数据复用和线程间通信。我曾经在矩阵乘法里用Shared Memory做分块,性能提升了5倍。
- L1缓存:SM内部的缓存,通常和Shared Memory共用一块物理空间(比如128KB,可以配置为16KB L1+112KB Shared,或者各64KB)。L1对程序员是透明的,但你可以通过编译器提示来优化。
- Global Memory:显存,所有线程都能访问,但延迟高达400~800个周期。访问Global Memory时,一定要保证“合并访问”(Coalesced Access)——即同一个Warp的32个线程访问连续的地址。否则,带宽利用率会惨不忍睹。
| 存储层次 | 延迟(周期) | 容量(典型值) | 作用域 |
|---|---|---|---|
| 寄存器 | 1 | 256KB/SM | 线程私有 |
| 共享内存 | 20~30 | 48~96KB/SM | 线程块内 |
| L1缓存 | 30~40 | 16~128KB/SM | SM内 |
| Global Memory | 400~800 | 4~80GB | 全局 |
计算单元与访存单元比例:算力与带宽的“跷跷板”
GPU里除了计算核心(CUDA Core/Tensor Core),还有访存单元(Load/Store Unit, LSU)。这两者的比例决定了你的kernel是“计算密集型”还是“访存密集型”。
以NVIDIA A100为例,每个SM有64个FP32 CUDA Core和4个LSU。这意味着一个时钟周期内,SM可以执行64次浮点运算,但只能处理4次访存指令。如果每个浮点运算都需要从Global Memory读数据,那计算单元大部分时间都在空等。
我建议你在写kernel前先估算一下“计算访存比”(Compute-to-Memory Ratio)。比如一个kernel对每个元素做10次浮点运算,但只读1次数据,那它就是计算密集型的。反之,如果只做1次运算却读10次数据,那就是访存密集型。对于后者,优化重点应该放在提高带宽利用率上,而不是堆计算单元。
Occupancy与吞吐关系:不是越高越好
Occupancy指的是每个SM上活跃的Warp数量与最大Warp数量的比值。很多人以为Occupancy越高越好,其实不然。我刚开始也这么想,直到有一次把Occupancy从50%提到100%,性能反而下降了。
为什么会这样?因为每个SM的资源(寄存器、共享内存)是固定的。如果Occupancy太高,每个Warp分到的资源就少了。比如寄存器不够用,编译器会把变量溢出到Local Memory(实际在Global Memory里),延迟暴增。或者共享内存不够,导致线程块无法并行执行。
我个人习惯的做法是:先用NVIDIA Nsight Compute或Visual Profiler跑一遍,看看Occupancy和实际吞吐量的关系。通常,对于计算密集型kernel,Occupancy在50%~75%时性能最佳;对于访存密集型,高Occupancy有助于隐藏延迟,可以拉到80%以上。
Roofline模型入门:一眼看穿性能瓶颈
Roofline模型,说白了就是一张图,横轴是“计算访存比”(Operational Intensity,单位是FLOP/Byte),纵轴是“性能”(FLOP/s)。它把硬件的能力画成两条线:
- 天花板1:计算上限(Peak FLOP/s),由GPU的计算单元决定。
- 天花板2:访存上限(Peak Bandwidth × Operational Intensity),由显存带宽决定。
你的kernel性能,就是这两条线中较低的那条。如果kernel的计算访存比很低,那它就被“访存天花板”卡住;如果很高,就被“计算天花板”卡住。我每次优化kernel前,都会先画个Roofline图,看看瓶颈在哪。
举个例子:假设你的kernel计算访存比是10 FLOP/Byte,而GPU的峰值带宽是1 TB/s,那访存上限就是10 TFLOP/s。如果GPU的峰值算力是20 TFLOP/s,那你的kernel最多只能跑到10 TFLOP/s——瓶颈在访存。这时候你优化计算逻辑是没用的,得想办法减少数据搬运,或者提高数据复用。
本章小结
GPU架构和吞吐模型,说白了就是三件事:
- SM和Warp调度决定了指令如何被并行执行,以及延迟如何被隐藏。
- 内存层次结构决定了数据如何流动,以及你该把数据放在哪里。
- Roofline模型帮你一眼看出瓶颈在计算还是访存。
我建议你学完这章后,找个简单的kernel(比如向量加法),用Nsight Compute跑一下,看看它的Occupancy、计算访存比和Roofline位置。亲手做一遍,比看十遍书都管用。