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分支里只有少数线程走不同路径,结果性能直接腰斩。后来改成数据预处理,把相同分支的线程聚在一起,吞吐量立马翻倍。

关键点:Warp是GPU调度的基本单位。SM内部有多个Warp Scheduler,每个周期可以发射多条指令。理想情况下,SM应该同时有足够多的Warp来隐藏延迟。

为什么会这样?因为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 全局
避坑指南:我曾经在项目里为了省事,把所有数据都放在Global Memory里,结果kernel跑得比CPU还慢。后来用Shared Memory做数据暂存,配合寄存器变量,才把性能拉回来。记住:能用寄存器的别用Shared,能用Shared的别用Global。

计算单元与访存单元比例:算力与带宽的“跷跷板”

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次数据,那就是访存密集型。对于后者,优化重点应该放在提高带宽利用率上,而不是堆计算单元。

经验之谈:我见过有人花大量时间优化计算密集型kernel的指令级并行,结果瓶颈在访存上,白费功夫。先搞清楚你的kernel属于哪一类,再对症下药。

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%以上。

注意:不要盲目追求Occupancy。我曾经在一个项目里把Occupancy从30%提到80%,结果因为寄存器溢出,性能反而下降了20%。后来通过减少每个线程的寄存器使用量(比如用short代替int),才把性能救回来。

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——瓶颈在访存。这时候你优化计算逻辑是没用的,得想办法减少数据搬运,或者提高数据复用。

实用技巧:我习惯用NVIDIA的Nsight Compute自动生成Roofline图。它会标出你的kernel在哪个区域,以及离天花板还有多远。如果离天花板很远,说明还有优化空间;如果已经贴在天花板上,那就该考虑换算法或硬件了。

本章小结

GPU架构和吞吐模型,说白了就是三件事:

  1. SM和Warp调度决定了指令如何被并行执行,以及延迟如何被隐藏。
  2. 内存层次结构决定了数据如何流动,以及你该把数据放在哪里。
  3. Roofline模型帮你一眼看出瓶颈在计算还是访存。

我建议你学完这章后,找个简单的kernel(比如向量加法),用Nsight Compute跑一下,看看它的Occupancy、计算访存比和Roofline位置。亲手做一遍,比看十遍书都管用。

GPU架构与吞吐模型知识体系 GPU架构与吞吐模型 SM与Warp调度 Warp Divergence 延迟隐藏 Warp Scheduler 内存层次结构 寄存器 Shared Memory Global Memory 计算与访存比例 计算密集型 访存密集型 计算访存比 Occupancy与Roofline Occupancy与资源限制 Roofline模型