第二章:GPU架构基础
各位同学,今天我们来聊聊GPU最核心的架构概念。说实话,我刚入行时也被这些术语搞得晕头转向——SIMT、Warp、SM、各种内存……但搞懂了这些,你就掌握了GPU设计的钥匙。
2.1 SIMT执行模型:GPU的灵魂
SIMT,全称是Single Instruction Multiple Threads。说白了,就是一条指令控制多个线程同时干活。
你可能会问:这和CPU的SIMD有什么区别?嗯,区别大了。CPU的SIMD是程序员手动把数据打包,比如用AVX指令一次处理8个float。但GPU的SIMT是硬件自动管理的——你写一个线程的行为,硬件帮你复制成几十上百个线程一起跑。
关键点:SIMT模型下,每个线程有自己的程序计数器(PC)和寄存器状态。虽然它们执行同一条指令,但可以走不同的分支路径。当然,代价就是分支发散时性能会下降。
我在项目中遇到过这样一个坑:写了一个if-else分支,结果一半线程走if,一半走else。硬件不得不串行执行两个分支,性能直接腰斩。所以,写GPU代码时尽量让同一个Warp里的线程走相同路径。
2.2 Warp / Wavefront:硬件调度的基本单位
Warp是NVIDIA的叫法,AMD叫Wavefront。本质上是一回事——一组同时执行的线程。
NVIDIA的Warp大小是32个线程,AMD的Wavefront是64个线程。为什么是32或64?这跟硬件设计有关。我个人习惯把Warp看作一个“原子执行单元”——调度器每次调度一个Warp,而不是单个线程。
来看一个简单的例子:
// 假设我们有128个线程,Warp大小=32
// 硬件会把这128个线程分成4个Warp
// Warp 0: 线程0-31
// Warp 1: 线程32-63
// Warp 2: 线程64-95
// Warp 3: 线程96-127
__global__ void vec_add(float *a, float *b, float *c, int n) {
int idx = threadIdx.x + blockIdx.x * blockDim.x;
if (idx < n) {
c[idx] = a[idx] + b[idx]; // 同一个Warp内的线程执行相同指令
}
}
避坑指南:我曾经在设计调度器时,忽略了Warp的上下文切换开销。每个Warp需要保存32份寄存器状态,如果寄存器压力大,切换成本会很高。后来我学会了在编译器层面做寄存器分配优化。
2.3 流多处理器(SM / CU):GPU的计算核心
SM(Streaming Multiprocessor)是NVIDIA的叫法,AMD叫CU(Compute Unit)。它是GPU里真正干活的计算单元。
一个SM包含:
- 多个CUDA核心(也叫SP,Streaming Processor)——执行算术运算
- 共享内存(Shared Memory)——低延迟、高带宽的片上存储
- 寄存器文件(Register File)——每个线程私有的最快存储
- Warp调度器——负责选择和发射Warp
- 加载/存储单元(LD/ST Unit)——处理内存访问
- 特殊函数单元(SFU)——处理sin、cos、sqrt等
我记得有一次做性能分析,发现某个kernel的occupancy(占用率)很低。查了半天,原来是每个线程用了太多寄存器,导致SM能同时容纳的Warp数量减少。说白了,寄存器是有限资源,你用多了别人就进不来。
| 组件 | 功能 | 设计注意事项 |
|---|---|---|
| CUDA核心 | 执行整数/浮点运算 | 注意流水线深度和发射宽度 |
| Warp调度器 | 每个周期选择就绪的Warp发射 | 需要足够的就绪Warp来隐藏延迟 |
| 共享内存 | 同一Block内线程共享 | Bank冲突会影响带宽 |
| 寄存器文件 | 每个线程私有 | 寄存器溢出会掉到Local Memory |
2.4 内存层次结构:速度与容量的博弈
GPU的内存层次,说白了就是一套“速度越快、容量越小、价格越贵”的体系。从上到下依次是:
2.4.1 寄存器(Register)
最快,没有之一。每个线程独享,延迟1个周期。但数量有限——NVIDIA的SM通常有65536个32位寄存器,分给所有线程用。你想想看,如果每个线程用32个寄存器,那一个SM最多只能同时容纳2048个线程。
注意:我曾经见过一个kernel,因为寄存器压力太大,编译器把部分变量“溢出”到了Local Memory。性能直接从100%掉到30%。所以,写代码时尽量少用局部变量,或者用__launch_bounds__限制每个线程的寄存器数。
2.4.2 共享内存(Shared Memory)
位于SM内部,延迟大约20-30个周期。同一Block内的线程可以互相访问。典型大小是48KB或96KB(取决于架构)。
共享内存最大的坑是Bank冲突。共享内存被分成32个Bank(对应Warp大小),如果多个线程同时访问同一个Bank的不同地址,就会发生冲突,导致串行化。
// 好的访问模式:无Bank冲突
__shared__ float data[32][32];
float val = data[threadIdx.x][threadIdx.y]; // 每个线程访问不同行
// 坏的访问模式:16路Bank冲突
float val = data[threadIdx.y][threadIdx.x]; // 同一列,不同行
2.4.3 全局内存(Global Memory)
最大的存储空间,但也是最慢的。延迟400-800个周期。所有线程都能访问,但必须通过缓存。
访问全局内存时,要尽量满足“合并访问”(Coalesced Access)——同一个Warp的线程访问连续地址。否则,一次内存事务只能传输少量有用数据,浪费带宽。
2.4.4 本地内存(Local Memory)
这是个容易混淆的概念。名字叫“本地”,实际上存在全局内存里。当寄存器不够用时,编译器会把变量 spill 到这里。延迟和全局内存一样高。
如何判断变量被 spill 了?看PTX代码或者用profiler。我建议你在设计RTL时,给寄存器文件留足够的端口,减少spill的概率。
2.5 总结与个人体会
讲到这里,我想分享一点个人经验。做GPU架构设计,最难的不是理解这些概念本身,而是理解它们之间的相互影响。比如:
- Warp大小影响调度器设计
- 寄存器数量影响occupancy
- 共享内存大小影响Block的并行度
- 全局内存带宽影响整体吞吐
你想想看,这些参数环环相扣。改一个,其他都得跟着调。我当年设计第一代SM时,就是因为没算清楚寄存器文件和Warp调度器的关系,导致芯片面积超标30%。后来重新做floorplan才搞定。
下一章我们会深入Warp调度器的RTL实现,到时候你会看到这些概念是如何落地成具体电路的。