GPU架构基础:SM/CU、内存层次与线程调度

各位同学,今天我们来聊聊GPU最核心的架构基础。说实话,这部分内容我每次讲都会想起自己刚入行时踩过的坑。那时候我天真地以为GPU就是一堆CPU拼在一起,结果第一次写CUDA程序,性能惨不忍睹。后来才明白——不理解硬件架构,写出来的代码就是碰运气。

SM与CU:GPU的“计算单元”

先说说SM(Streaming Multiprocessor)和CU(Compute Unit)。不同厂商叫法不同,NVIDIA叫SM,AMD叫CU,但本质是一回事——它们是GPU里真正干活的“小工厂”。

每个SM内部包含多个CUDA核心(也叫SP,Streaming Processor)。我习惯把SM比作一个车间,CUDA核心就是车间里的工人。车间里有自己的调度器、寄存器文件、共享内存,工人之间可以高效协作。

关键点:SM是独立的执行单元,每个SM有自己的指令调度器和执行资源。程序运行时,GPU会把线程块(Thread Block)分配给不同的SM。一个SM可以同时处理多个线程块,但线程块内的线程只能在同一个SM上执行。

我在项目中遇到过一个问题:某个kernel的线程块尺寸设得太大,导致一个SM只能容纳一个线程块,其他SM闲着没事干。结果GPU利用率不到30%。后来我把线程块尺寸调小,让每个SM能同时处理4-5个线程块,性能直接翻倍。

内存层次结构:Global/Shared/Local

GPU的内存层次,说白了就是“越近越快,越远越慢”。但很多人只记住了这句话,写代码时还是乱用。

内存类型 位置 延迟 容量 作用域
Global Memory 片外DRAM ~400-800 cycles GB级别 所有线程
Shared Memory 片上SRAM ~30 cycles 几十KB 同一个线程块
Local Memory 片外DRAM ~400-800 cycles 有限 单个线程
Register 片上寄存器 ~1 cycle 有限(每个线程) 单个线程

你看这个表,Shared Memory比Global Memory快了一个数量级。但很多人写代码时,数据明明可以复用,却反复去Global Memory里读。我曾经优化过一个矩阵乘法的例子,把频繁访问的子矩阵数据搬到Shared Memory里,性能提升了5倍。

我的习惯:写kernel之前,先画一张数据流图。标出哪些数据会被多次访问,哪些数据是线程间共享的。然后优先把这些数据放到Shared Memory里。如果Shared Memory不够用,再考虑用寄存器。

这里要特别提醒一下Local Memory。很多人以为Local Memory是“本地”的,应该很快。其实不然——Local Memory物理上还是在Global Memory里,只是每个线程私有的。如果你声明了一个大数组,寄存器放不下,编译器就会把它“溢出”到Local Memory。嗯,这往往是性能杀手。

避坑指南:我曾经在代码里声明了一个局部数组,大小是256个float。结果每个线程用了1KB的Local Memory,导致寄存器压力剧增,occupancy从100%掉到了25%。后来改成用Shared Memory + 循环分块,才把性能救回来。

Warp与线程束调度

Warp是GPU执行的基本单位。在NVIDIA的架构里,32个线程组成一个Warp。为什么是32?我记得最早是考虑到指令宽度和硬件成本的平衡。AMD的Wavefront是64个线程,各有各的道理。

Warp调度器的工作方式很有意思。每个SM里有多个Warp调度器,它们轮流发射指令。当一个Warp因为访存或同步而等待时,调度器会立刻切换到另一个就绪的Warp。这就是GPU能隐藏延迟的核心机制。

你想想看,如果只有一个Warp,那GPU大部分时间都在等数据。但如果有足够多的Warp,调度器就可以“无缝切换”,让计算单元一直忙。这就是为什么occupancy(活跃Warp数量与最大Warp数量的比值)这么重要。

核心公式:隐藏延迟所需的Warp数量 = 内存延迟 / 指令发射周期。举个例子,如果内存延迟是600 cycles,指令发射周期是4 cycles,那至少需要150个Warp才能完全隐藏延迟。如果每个SM最多支持64个Warp,那你就需要3个SM才能做到。

我在做图像处理项目时遇到过Warp发散的问题。当时写了一个条件分支,根据像素值走不同的处理路径。结果同一个Warp里的32个线程,一半走if,一半走else。Warp调度器只能先执行if路径,再执行else路径,性能直接腰斩。

我的建议:尽量让同一个Warp里的线程走相同的分支。如果无法避免,可以考虑用数据重排或者谓词执行(predicated execution)来减少发散。另外,分支粒度尽量控制在Warp级别,而不是线程级别。

GPU流水线:指令级并行

GPU的流水线和CPU不太一样。CPU的流水线追求单线程性能,有分支预测、乱序执行这些花哨的东西。GPU的流水线更“朴实”——它追求的是吞吐量。

一个典型的GPU指令流水线包括:取指、译码、发射、执行、写回。但GPU的发射阶段很特别——它一次发射一条指令给整个Warp的32个线程。这意味着32个线程同时执行同一条指令,只是操作的数据不同。

这就是SIMT(Single Instruction, Multiple Threads)的精髓。说白了,就是“一条指令,多份数据”。

流水线里有个关键概念叫“延迟隐藏”。GPU不像CPU那样用乱序执行来隐藏延迟,而是靠线程级并行。当一个Warp的指令因为数据依赖而停顿,调度器就换另一个Warp上。这种机制叫“细粒度多线程”(Fine-grained Multithreading)。

实战要点:要充分利用GPU流水线,你需要做到三点:

  • 提供足够的Warp(高occupancy)
  • 减少Warp内的分支发散
  • 合并内存访问(coalesced access)

我记得有一次调试一个深度学习推理的kernel,发现性能瓶颈在Global Memory的访问模式上。线程访问的地址是分散的,导致每次内存事务只用了很少的带宽。后来我把数据布局从AoS(Array of Structures)改成SoA(Structure of Arrays),内存访问变成了连续的,带宽利用率从20%提升到了90%。

注意:GPU流水线对内存访问模式非常敏感。如果同一个Warp的32个线程访问的地址是连续的,硬件会把它们合并成一次大的内存事务。如果地址是随机的,那就需要32次小事务,效率极低。我见过有人写代码时没注意这个,结果GPU跑得比CPU还慢。

最后说一句,理解GPU架构不是为了炫技,而是为了写出高效的代码。你不需要记住每个寄存器的名字,但一定要知道数据在哪里、怎么流动、哪里可能成为瓶颈。这样你写代码的时候,脑子里就会有一个清晰的“地图”。

下一章我们会讲GPU编程模型和CUDA编程基础,到时候我会带着大家写几个实际的例子。嗯,今天就到这里,有问题随时问我。