GPU架构基础:理解SM、Warp、Thread层级关系,以及内存层次结构
各位同学,今天我们来聊聊GPU架构最核心的东西。说实话,很多人写了很久的CUDA代码,却连SM和Warp的关系都没搞明白。我当年刚入行时也是这样,直到有一次在项目里遇到了严重的性能瓶颈,才逼着自己把底层架构啃了一遍。嗯,今天我就把这些经验分享给你。
一、从宏观到微观:SM、Warp、Thread
先看一张图,这是我手绘的GPU架构层级关系。你仔细看,就能明白它们是怎么嵌套的。
1. Thread(线程)—— 最小的执行单元
每个Thread就是一条独立的执行流。你写的每个CUDA核函数,最终都会被拆成成千上万个Thread来跑。我个人习惯把Thread想象成工厂里的工人,每个工人干同样的活,但处理不同的数据。
关键点:Thread是程序员能直接控制的最小单位。但GPU实际调度时,从来不会单独调度一个Thread。
2. Warp(线程束)—— 真正的调度单元
Warp由32个连续的Thread组成。这是GPU硬件调度的基本单位。说白了,SM每次取指令、执行指令,都是以Warp为单位进行的。
为什么会这样?因为GPU的设计哲学是「以空间换时间」。32个Thread共享一套指令单元,同时执行相同的指令,只是处理的数据不同。这种模式叫SIMT(单指令多线程)。
实战经验:我在优化矩阵乘法时发现,如果Warp内出现分支分歧(比如if-else),性能会急剧下降。因为GPU会串行执行两个分支,相当于浪费了一半的线程。所以写代码时尽量让Warp内的32个线程走相同的路径。
3. SM(流多处理器)—— 真正的计算核心
SM是GPU内部最核心的计算单元。一个GPU通常有几十到上百个SM。每个SM包含:
- 多个Warp调度器
- 大量的CUDA核心(也叫流处理器)
- 共享内存(Shared Memory)
- 寄存器文件(Register File)
- 一级缓存(L1 Cache)
每个SM可以同时驻留多个Warp。比如一个SM有64个CUDA核心,它可以同时调度2个Warp(64个线程)执行。但实际上,SM会维护一个Warp池,里面可能有几十个Warp在排队。当一个Warp因为访存而等待时,SM会立刻切换到另一个Warp执行——这就是GPU的零开销上下文切换。
注意:SM能同时容纳的Warp数量是有限的,这个上限由寄存器数量和共享内存大小决定。如果你每个Thread用了太多寄存器,SM能容纳的Warp就会减少,导致Occupancy(占用率)下降。我曾经在一个项目里就是因为寄存器使用过多,导致Occupancy从100%掉到了25%,性能直接腰斩。
二、内存层次结构:从快到慢,从大到小
GPU的内存层次,说白了就是「越快的越贵,越贵的越小」。我们来看看这张内存层次图:
1. 寄存器(Register)—— 最快,但最稀缺
寄存器是每个Thread私有的,速度跟CPU的寄存器一样快——一个时钟周期就能访问。但数量非常有限。以NVIDIA A100为例,每个SM有65536个寄存器,如果每个Thread用32个寄存器,那一个SM最多能驻留65536/32=2048个线程。
核心原则:能用寄存器就别用Local Memory。编译器有时会把寄存器溢出的变量放到Local Memory里,这会导致性能暴跌。我建议你用--ptxas-options=-v编译选项查看寄存器使用情况,如果发现spill(溢出),就要优化代码了。
2. 局部内存(Local Memory)—— 寄存器的备胎
当寄存器不够用时,编译器会自动把一些变量放到Local Memory里。但Local Memory实际上是在Global Memory里划了一块区域,所以延迟很高(几百个时钟周期)。
你想想看,本来一个时钟周期能搞定的事,现在要等几百个周期,这性能损失有多大。所以写代码时要尽量避免寄存器溢出。
3. 共享内存(Shared Memory)—— 程序员手中的利器
共享内存是同一个Block内所有Thread共享的,速度比Global Memory快很多(大约20-30倍)。它是我们做性能优化时最常用的手段。
我在项目中遇到过这样一个场景:做矩阵转置时,如果直接读写Global Memory,因为访问模式不连续,带宽利用率只有10%左右。后来我把数据先读到Shared Memory里,再转置写回Global Memory,带宽利用率直接飙到了80%以上。
使用技巧:Shared Memory分为32个Bank(存储体)。如果多个Thread同时访问同一个Bank,就会发生Bank Conflict,导致串行化。我建议你尽量让连续的Thread访问连续的Bank,避免冲突。
4. 全局内存(Global Memory)—— 容量最大,延迟最高
Global Memory就是显存,容量最大(几GB到几十GB),但延迟也最高(大约400-800个时钟周期)。所有Thread都能访问,但访问速度取决于访问模式。
这里有个关键概念叫「合并访问」(Coalesced Access)。说白了,就是让连续的Thread访问连续的内存地址。比如Thread 0访问地址0,Thread 1访问地址4,Thread 2访问地址8...这样GPU可以把这些访问合并成一次大的内存事务,效率极高。
避坑指南:我曾经犯过一个低级错误——在Global Memory上做随机访问。每个Thread访问一个随机地址,结果带宽利用率不到5%。后来改成连续访问,性能提升了20倍。记住:Global Memory最怕随机访问,最爱连续访问。
三、内存层次总结
| 内存类型 | 作用域 | 延迟 | 容量 | 典型用途 |
|---|---|---|---|---|
| 寄存器 | 单个Thread | 1个时钟周期 | 约256个/线程 | 局部变量、中间计算结果 |
| 局部内存 | 单个Thread | 数百个时钟周期 | 取决于显存 | 寄存器溢出时的备胎 |
| 共享内存 | 单个Block | 约20-30个时钟周期 | 48KB~164KB/SM | Block内数据共享、数据重用 |
| 全局内存 | 所有Thread | 400-800个时钟周期 | GB级别 | 主要数据存储、主机与设备通信 |
嗯,到这里GPU架构的基础就讲完了。你可能会问:这些知识在实际优化中怎么用?别急,后面我们会一步步深入。记住一句话:理解硬件,才能写出高效的代码。