第四章:CUDA编程模型——线程层次结构与核函数实战

各位同学,今天我们来啃一块硬骨头——CUDA的线程层次结构。说实话,我刚接触CUDA时,也被Grid、Block、Thread这三层结构搞得晕头转向。但等你真正理解了,就会发现这设计其实非常巧妙。

4.1 线程层次结构:从宏观到微观

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

  • Grid(网格):最顶层,对应一个核函数启动的所有线程
  • Block(线程块):中间层,一组可以协作的线程
  • Thread(线程):最底层,实际执行计算的最小单元

我习惯把Grid想象成一个工厂,Block是车间,Thread就是工人。每个工人干自己的活,同一个车间的工人可以互相借工具(共享内存),不同车间之间就不能直接借了。

关键点:一个Block内的线程可以同步,可以通过共享内存通信。不同Block的线程,理论上完全独立。

4.2 线程索引与维度

每个线程都有唯一的ID。CUDA提供了几个内置变量:

变量 含义 示例
threadIdx 线程在Block内的索引 threadIdx.x, threadIdx.y
blockIdx Block在Grid内的索引 blockIdx.x, blockIdx.y
blockDim Block的维度大小 blockDim.x = 256
gridDim Grid的维度大小 gridDim.x = 1024

计算全局线程ID的公式,我建议你直接背下来:

int tid = blockIdx.x * blockDim.x + threadIdx.x;

为什么会这样?你想想看,每个Block有blockDim.x个线程,第blockIdx.x个Block的起始位置就是blockIdx.x * blockDim.x,再加上线程在Block内的偏移threadIdx.x,就是全局ID了。

我的习惯:处理一维数据时,我通常只用x维度。二维或三维数据时,再用y和z。别把问题搞复杂了。

4.3 内存模型:搞清楚数据在哪

CUDA的内存模型,嗯,这里要注意,它和CPU的内存模型差别很大。我刚开始写CUDA程序时,经常因为数据放错地方导致性能惨不忍睹。

主要的内存类型:

  • 全局内存(Global Memory):所有线程都能访问,但速度最慢。我把它比作「仓库」。
  • 共享内存(Shared Memory):同一个Block内的线程共享,速度很快。相当于「车间里的工具台」。
  • 寄存器(Register):每个线程私有,速度最快。就是「工人手里的螺丝刀」。
  • 常量内存(Constant Memory):只读,有缓存。适合存放查找表之类的数据。
  • 本地内存(Local Memory):线程私有,但实际存在全局内存里。寄存器不够用时编译器会自动用。

我曾经踩过的坑:以为共享内存是全局的,结果不同Block之间想共享数据,折腾了半天才发现根本行不通。记住:共享内存只在Block内可见!

4.4 核函数(Kernel)编写实战

核函数就是用__global__修饰的函数,在GPU上执行。写核函数有几个铁律:

  1. 返回类型必须是void
  2. 不能使用可变参数
  3. 不能是类的成员函数(静态成员函数可以)
  4. 调用的设备函数要用__device__修饰

来看一个最简单的向量加法:

__global__ void vecAdd(float *A, float *B, float *C, int N) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < N) {
        C[i] = A[i] + B[i];
    }
}

注意那个if (i < N)的边界检查。我见过太多新手忘记这个,结果数组越界,程序崩溃还找不到原因。

4.5 <<<>>>语法详解

这个语法是CUDA特有的,用来配置核函数的执行参数。格式是:

kernel_name<<<grid_dim, block_dim, shared_mem, stream>>>(args);

四个参数:

参数 含义 示例
grid_dim Grid的维度(dim3类型) dim3 grid(1024, 1, 1)
block_dim Block的维度(dim3类型) dim3 block(256, 1, 1)
shared_mem 动态共享内存大小(字节),默认0 sizeof(float) * 256
stream CUDA流,默认0(默认流) stream1

实际调用:

int N = 1 << 20;  // 约100万个元素
int threadsPerBlock = 256;
int blocksPerGrid = (N + threadsPerBlock - 1) / threadsPerBlock;

vecAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, N);

这里有个小技巧:(N + threadsPerBlock - 1) / threadsPerBlock这个公式,保证即使N不能被threadsPerBlock整除,也能启动足够的Block覆盖所有数据。

我个人建议:Block大小选128或256通常比较稳妥。太小了硬件利用率低,太大了可能受限于寄存器数量。我一般从256开始调优。

4.6 实战避坑指南

最后分享几个我这些年积累的经验:

  • 线程同步:同一个Block内用__syncthreads()同步。不同Block之间别想同步,那是设计上不允许的。
  • 内存对齐:全局内存访问尽量对齐,128字节对齐最好。不对齐的话,性能可能掉一个数量级。
  • Bank Conflict:共享内存有32个Bank,同时访问同一个Bank会导致冲突。我当年调优时,光解决这个问题就花了两天。
  • Occupancy:不是Block越大越好。要平衡每个SM上活跃的线程数和每个线程使用的资源。

好了,这一章的内容就到这里。记住,CUDA编程的核心就是理解线程怎么组织、数据怎么流动。把这些搞清楚了,后面写优化代码就会顺手很多。