2 GPU架构基础:SM、Warp、Grid-Block-Thread层次、内存层次结构

搞GPU编程,你不理解硬件长什么样,写出来的代码大概率跑不满性能。我最早接触CUDA时,觉得把任务扔给GPU就完事了,结果发现一个简单的向量加法,带宽利用率不到20%。后来老老实实啃了一遍架构手册,才明白问题出在哪。

这一节我们就拆开GPU看看。你不需要成为芯片设计师,但得知道那些玩意儿叫什么、怎么配合。

2.1 SM与Warp——GPU的微架构心脏

先从一个核心概念说起:SM(Streaming Multiprocessor)。你可以把它理解成GPU里的“计算单元小集群”。一块GPU有多少个SM,直接决定了它的并行能力。每个SM里有一堆CUDA Core、共享内存、寄存器文件、调度器等等。

那Warp呢?Warp是SM调度的基本单位,一个Warp包含32个线程。为什么是32?这是NVIDIA设计时的权衡——32个线程一起执行,可以最大化指令发射效率。我曾在项目里写过一些分支很重的kernel,结果一个Warp里32个线程走不同的分支,性能掉了差不多一半。说白了,同一个Warp里的线程,执行路径越一致,性能越好。

概念说明我踩过的坑
SM物理计算单元,包含CUDA Core、寄存器、共享内存等我曾经以为SM数越多越好,却没看每个SM的寄存器总量,导致线程数过多触发寄存器溢出
Warp32个线程组成的调度单元,同一个Warp执行相同的指令记住:一个分支让Warp串行化,32个线程变16+16… 慢得你想哭

个人技巧:调优时先看Occupancy(占用率)报告。NVCC的--ptxas-options=-v会告诉你每个SM用了多少寄存器和共享内存,据此调整block大小和线程数。

2.2 Grid-Block-Thread——你的任务怎么拆

写CUDA程序时,你需要把一个大任务拆成三层:Grid → Block → Thread。这对应硬件上的:GPU整体(Grid)→ SM(Block)→ Warp & Thread。

// 典型的调用方式
dim3 gridSize(16, 16);   // 16x16 = 256个Block
dim3 blockSize(32, 4);   // 每个Block有128个线程(32*4)
myKernel<<<gridSize, blockSize>>>();

这里要注意几点:

  • 一个Block只会被调度到一个SM上,但一个SM可以同时跑多个Block。
  • Block里的线程通过共享内存通信,Block之间不能直接共享数据(除非用全局内存再同步,但效率低)。
  • 每个Block里的线程数最好是32的倍数(因为Warp是32),我习惯用128或256,既保证Occupancy又不会让寄存器压力太大。
警告:Block里的线程数不要超过1024(Max threads per block)。有些老架构限制更小。你如果定义了一个blockSize=1024,却只用了一半线程,浪费了峰值能力。

2.3 内存层次结构——数据该放哪儿

GPU有多个内存层级,搬数据的速度天差地别。我画个图你就明白了(后面有SVG)。先列个表格让你一目了然:

内存类型位置容量延迟(大约)作用域
寄存器每个线程私有通常256KB/SM~1 cycle线程
共享内存SM内部48-96KB/SM~5-20 cyclesBlock内线程
L1/L2缓存SM/芯片几十KB~几MB~30-200 cycles自动管理
全局内存显存几GB~几十GB~400-800 cycles所有线程
常量内存显存+缓存64KB~400 cycles + 广播所有线程只读
纹理内存显存+特殊缓存取决于显存~400 cycles + 空间局部性优化全局只读

我个人经验:全局内存访问尽量合并(coalescing)。什么意思?相邻线程访问相邻地址,数据会被打包成128字节的cache line一次性拿到。如果你乱序访问——比如线程0读地址0,线程1读地址1000——那就得两次访存,带宽直接砍半。我曾经在图像处理代码里写了个out[y * width + x],看起来没问题,但线程ID和内存布局不匹配,导致带宽利用率只有40%。改成out[threadIdx.x + blockDim.x * blockIdx.x]后,带宽飙到95%。

核心原则:能用共享内存解决的数据局部性,就别去碰全局内存。共享内存延迟低一个数量级,而且可以手动tile(分块)。

2.4 一张图看懂GPU架构层级

下面我用SVG画了整个内存与执行单元的依赖关系。你看着它,再回看前面的文字,会更清晰。

GPU 架构层级与内存层次图 Grid(整个任务) 对应 GPU 全局,所有 Block 分布到多个 SM Block 0 调度到某个 SM Block 1 调度到另一个 SM Block N 调度到 SM SM(Streaming Multiprocessor) 每个 SM 包含多个 CUDA Core、共享内存、寄存器、Warp 调度器 可同时运行多个 Block,Block 内线程通过共享内存通信 共享内存 48~96KB/SM 寄存器文件 256KB/SM Warp(32线程/组) 同一个 Warp 执行相同指令(SIMT) 线程(Thread) 每个线程有私有寄存器、局部内存 全局内存(显存)→ 所有线程共享,延迟最高

从图上你能看到:整个任务(Grid)拆分成多个Block,每个Block被分配给一个SM。SM内部有共享内存和寄存器,Block内的线程通过共享内存交换数据。线程又组成Warp,32个一组,指令同步执行。最下面就是全局内存——数据的大本营,但访问它代价高昂。

实践经验:如果你在写kernel时发现性能卡在访存上,先检查是否可以利用共享内存。比如矩阵乘法,tile size设为16x16或32x32,把子块载入共享内存,计算时反复从共享内存读取,能快好几倍。

2.5 实战小贴士:如何查看硬件参数

想知道你的GPU有多少SM、每个SM多大共享内存?直接用CUDA API:

#include <cuda_runtime.h>

cudaDeviceProp prop;
cudaGetDeviceProperties(&prop, 0); // 查询设备0
printf("SM数量:%d\n", prop.multiProcessorCount);
printf("每个SM的共享内存大小:%zu bytes\n", prop.sharedMemPerMultiprocessor);
printf("每个Block最大线程数:%d\n", prop.maxThreadsPerBlock);
printf("显存总量:%.2f GB\n", prop.totalGlobalMem / 1024.0 / 1024.0 / 1024.0);

我一般在写新项目前都会跑一下这个代码,确认硬件的具体数值。毕竟架构不同(比如Volta和Ampere的SM设计就有差别),调参策略也得跟着变。

小结

GPU架构不复杂,但细节很多。记住几个关键数字:Warp = 32线程,Block内线程用共享内存,全局内存访问要合并。后面章节我们会把这些概念用到实际代码里,你会看到同样的算法,改一下内存布局,性能差距有多大。