线程模型与调度:Grid-Block-Thread层次结构、线程束(Warp)概念、线程发散与分支预测

聊到CUDA编程,绕不开的就是线程模型。很多初学者上来就写 kernel<<>>,但说实话,真正理解这三个层次怎么协同工作的,并不多。我刚开始接触CUDA时也踩过坑——以为线程越多越快,结果跑起来比CPU还慢。嗯,这里面的门道,咱们今天好好捋一捋。

Grid-Block-Thread:三层结构,各司其职

CUDA的线程组织方式,说白了就是三级管理架构:

  • Grid:一个kernel启动时对应的所有线程集合
  • Block:Grid里的一个工作组,共享一块共享内存
  • Thread:最小的执行单元,每个线程有自己的寄存器和局部内存

为什么要搞这么复杂?直接铺开几万个线程不行吗?

我个人习惯把GPU想象成一个大型工厂:Grid是整栋厂房,Block是车间,Thread是工人。车间里的工人可以互相递工具(共享内存),但不同车间之间不能直接交流。这种分层设计,是为了让硬件能高效调度。

关键点:Block内的线程可以通过 __syncthreads() 同步,但不同Block之间无法直接同步。这是设计上的硬约束,写代码时一定要记住。

我记得有一次做矩阵乘法优化,把Block大小设成了32x32,结果共享内存爆了。后来改成16x16,性能反而上去了。为什么?因为每个Block能用的共享内存是有限的,Block太大反而限制了同时活跃的Block数量。

线程束(Warp):真正的执行单元

这里有个概念特别重要——Warp。你写的是Thread,但GPU实际执行的是Warp。

一个Warp包含32个线程,它们是同时执行同一条指令的。也就是说,你写了1024个线程,硬件会把它分成32个Warp来调度。这32个线程共享一个程序计数器,步调完全一致。

概念 说明 我的经验
Warp大小 固定32个线程 所有NVIDIA GPU都一样,别记错
Warp调度 SM中的Warp Scheduler负责 每个SM有多个调度器,可以并发调度
Warp状态 活跃、就绪、阻塞 访存时Warp会阻塞,切换其他Warp执行

你想想看,如果Block大小不是32的倍数会怎样?比如Block大小是33,那第2个Warp里只有1个线程干活,剩下31个在空转。这就是资源浪费。所以我一般建议Block大小取32的倍数,比如128、256。

小技巧:查看你的GPU每个SM能同时容纳多少个Warp,用 cudaGetDeviceProperties 查 maxThreadsPerMultiProcessor 和 warpSize。我每次换新卡都会先跑这个。

线程发散与分支预测:性能杀手

好,现在聊一个坑——线程发散。

因为Warp里的32个线程执行同一条指令,如果遇到 if-else 分支,会发生什么?

__global__ void divergent_kernel(float* data, int N) {
    int idx = threadIdx.x + blockIdx.x * blockDim.x;
    if (idx < N/2) {
        data[idx] = data[idx] * 2.0f;  // 路径A
    } else {
        data[idx] = data[idx] * 0.5f;  // 路径B
    }
}

这段代码里,同一个Warp里的线程可能走不同的分支。硬件会怎么做?它会先执行路径A,那些走路径B的线程被屏蔽掉;然后再执行路径B,走路径A的线程被屏蔽。相当于串行化了,性能直接腰斩。

我曾经在一个图像处理项目里遇到过这个问题。一个简单的阈值判断,导致整个kernel慢了3倍。排查了半天,最后发现是线程发散造成的。

避坑指南:尽量避免Warp内的分支。如果分支不可避免,让同一个Warp里的线程走相同路径。比如按 warp_id = threadIdx.x / 32 来划分数据,保证一个Warp处理连续的数据块。

那分支预测呢?GPU也有类似CPU的分支预测机制,但效果有限。因为GPU的Warp是SIMT(单指令多线程)模型,不像CPU那样有复杂的预测逻辑。说白了,GPU更擅长无分支的规则计算,比如矩阵乘法、卷积。

用SVG画一张线程模型结构图

光说可能不够直观,我画了一张图,把Grid、Block、Thread、Warp的关系串起来:

Grid(网格) Block (0,0) T0 T1 T2 T3 ... 共享内存 Block (1,0) T0 T1 T2 T3 ... 共享内存 Block (2,0) T0 T1 T2 T3 ... 共享内存 Warp(线程束)—— 真正的执行单元 Warp 0 (T0~T31) Warp 1 (T32~T63) Warp 2 (T64~T95) Warp 3 (T96~T127) 每个Warp包含32个线程,共享程序计数器,执行同一条指令

从图里可以看得很清楚:Grid包含多个Block,Block包含多个Thread,而硬件调度时又把Thread打包成Warp来执行。这个层次关系,写kernel之前一定要在脑子里过一遍。

总结几个实战要点

  1. Block大小选256或512:太小了SM利用率低,太大了共享内存不够。我一般从256开始调。
  2. Grid大小要足够大:至少是SM数量的几倍,才能隐藏访存延迟。比如你的卡有80个SM,Grid至少设成160以上。
  3. 避免Warp内发散:分支判断尽量基于 warp_id 而不是 thread_id。
  4. 利用Warp调度:访存密集型任务,多开Warp让SM能切换执行,别让Warp闲着。

嗯,线程模型这块就聊到这儿。记住一句话:你写的是Thread,但GPU眼里只有Warp。理解了这一点,很多性能问题就迎刃而解了。

想要更多资料,联系可可