第4章:CUDA编程基础回顾:线程层次结构、内存层次结构、SM与warp调度

好,咱们今天聊聊CUDA编程里最核心的几个概念。说实话,很多同学一开始学CUDA,就被各种线程、块、网格搞得晕头转向。我当年也一样,第一次写kernel的时候,连线程ID怎么算都搞错了,结果跑出来一堆乱码。嗯,今天咱们就把这些东西彻底理清楚。

4.1 线程层次结构:从Grid到Thread

CUDA的线程模型,说白了就是三层结构:Grid(网格)→ Block(线程块)→ Thread(线程)。你想想看,这就像是一个军队的编制:Grid是军团,Block是连队,Thread就是每个士兵。

关键点:每个线程都有唯一的ID,通过 threadIdx、blockIdx、blockDim 和 gridDim 来定位。

我个人习惯用一维索引来简化问题,但实际项目中经常用到二维甚至三维。比如处理图像时,用二维线程块就特别自然——每个线程对应一个像素。

// 一个典型的kernel启动方式
dim3 gridDim(16, 16);    // 16x16 个block
dim3 blockDim(32, 32);   // 每个block有32x32个线程
myKernel<<<gridDim, blockDim>>>(d_data, width, height);

// 在kernel内部计算全局线程ID
int tx = threadIdx.x + blockIdx.x * blockDim.x;
int ty = threadIdx.y + blockIdx.y * blockDim.y;
int globalIdx = ty * width + tx;

小技巧:blockDim的大小通常选32的倍数,因为warp大小就是32。选64或128比较常见,太大反而可能降低occupancy。

4.2 内存层次结构:速度与容量的博弈

CUDA的内存模型,其实就是一个金字塔结构。越往上速度越快,但容量越小。我刚开始做优化时,总想着把所有数据都塞进共享内存,结果发现根本放不下——这就是没理解这个层次结构。

内存类型 位置 访问延迟 作用域 生命周期
寄存器 SM内部 ~1 cycle 单个线程 kernel执行期间
共享内存 SM内部 ~5 cycles 单个block block生命周期
全局内存 显存 ~400 cycles 所有线程 程序运行期间
常量内存 显存(带缓存) ~10 cycles(缓存命中) 所有线程 程序运行期间
纹理内存 显存(带缓存) ~10 cycles(缓存命中) 所有线程 程序运行期间

这里我要特别强调一下共享内存。它虽然只有几十KB,但用好了能带来几十倍的性能提升。我曾经在优化一个矩阵转置的算子时,就是因为合理使用了共享内存做数据分块,把带宽利用率从30%提到了85%以上。

注意:共享内存有bank conflict的问题。如果多个线程访问同一个bank的不同地址,就会产生冲突,导致串行化。解决办法通常是加padding或者调整访问模式。

4.3 SM与warp调度:硬件怎么执行你的代码

SM(Streaming Multiprocessor)是GPU的核心计算单元。每个SM里有多个CUDA Core,还有共享内存、寄存器文件、warp调度器等。你写的kernel,最终就是在这些SM上执行的。

warp是SM调度的基本单位,大小固定为32个线程。同一个warp里的线程执行相同的指令(SIMT模型)。但要注意,如果线程有分支,就会产生warp divergence——说白了就是有些线程走if,有些走else,导致部分线程被屏蔽,性能下降。

我记得有一次调试一个复杂的计算图,发现某个算子的性能死活上不去。后来用nvprof一看,warp divergence率高达40%。原来是条件判断里有个变量是线程ID相关的,导致每个warp里都有分支。改成用位运算统一处理之后,性能直接翻倍。

Occupancy(占用率):每个SM能同时容纳的warp数量与最大warp数量的比值。occupancy越高,越能隐藏内存访问延迟。但也不是越高越好——有时候降低occupancy换取更多寄存器,反而能提升单线程性能。

下面这张图展示了SM内部的结构和warp调度流程:

SM (Streaming Multiprocessor) Warp Scheduler 选择就绪的warp发射指令 Dispatch Unit 分发指令到CUDA Core CUDA Cores (32/64/128个) 每个core执行一个线程的指令 同一warp的线程执行相同指令 Shared Memory (L1) block内线程共享,低延迟 Register File 每个线程私有,最快访问 图4-1:SM内部结构及warp调度流程

这张图展示了SM内部的核心组件。warp调度器会从多个就绪的warp中选择一个,然后通过dispatch unit把指令发射到CUDA Core上执行。共享内存和寄存器文件则提供快速的数据访问。

4.4 实战中的避坑指南

说了这么多理论,咱们来点实际的。我在优化算子时总结了几条经验:

  • 线程块大小选256或512:太小了SM利用率低,太大了寄存器压力大。256和512是大多数算子的甜点值。
  • 尽量减少全局内存访问:能放共享内存就放共享内存,能合并访问就合并访问。我曾经遇到一个算子,80%的时间都花在全局内存访问上,改成共享内存分块后快了5倍。
  • 注意warp divergence:如果条件判断依赖于线程ID,尽量用位运算或三元运算符替代if-else。
  • 用__syncthreads()同步:在共享内存读写之间一定要加同步,否则会出现数据竞争。我踩过这个坑,debug了整整两天才发现是少了个同步。

个人经验:刚开始做CUDA优化时,别急着调occupancy。先保证功能正确,然后用nvprof/ncu看瓶颈在哪里。很多时候,内存访问模式比occupancy更重要。

好了,这一章的内容就到这里。线程层次结构、内存层次结构、SM与warp调度,这三个概念是CUDA编程的基石。理解透了,后面的算子优化才能事半功倍。

一句话总结:线程层次结构决定了你怎么组织计算,内存层次结构决定了数据怎么流动,SM和warp调度决定了硬件怎么执行。三者协同,才能写出高效的CUDA程序。

想要更多资料,联系可可