第4章 CUDA程序结构:从grid到thread
4.1 Kernel函数——这才是GPU的“入口”
很多初学者会把CUDA程序理解成「写一个函数,放到GPU上跑」。这个说法没错,但少了最关键的一步——你得明确告诉GPU:这个函数怎么切分任务?
kernel函数就是用 __global__ 修饰的C函数。我最早写的时候,总忘加这个修饰符,编译半天报错「找不到入口」。后来养成了习惯:写任何一个要在GPU上并行的函数,第一行先写 __global__。
一个典型的kernel长这样:
__global__ void harmonyCorrectionKernel(float* input, float* output, int width, int height) {
// 每个线程独立处理一个像素
int idx = threadIdx.x + blockIdx.x * blockDim.x;
int idy = threadIdx.y + blockIdx.y * blockDim.y;
if (idx >= width || idy >= height) return;
// 执行校正逻辑
output[idy * width + idx] = applyCorrection(input[idy * width + idx]);
}
注意调用时的尖括号语法:
dim3 gridDim(16, 16);
dim3 blockDim(32, 32);
harmonyCorrectionKernel<<>>(d_input, d_output, width, height);
尖括号里的两个参数,就是本章的核心——grid和block。
4.2 Grid、Block、Thread——物理还是逻辑?
我见过新手把thread当成GPU硬件线程。其实不是。GPU硬件有SM、SP/Warp这些物理单位。而grid、block、thread是**编程模型**里的逻辑概念。说白了,是为了让你方便地组织并行任务。
为什么要有这三层?
想想看:一张图像可能有几百万像素。如果让所有线程直接平铺,你根本没法管理。分层之后:
- Thread:最小的执行单元,对应一个数据元素。比如一个像素。
- Block:一组线程,它们可以共享shared memory,也能同步。我常把block对应到图像的一个tile(图块)。
- Grid:所有block的集合,覆盖整个数据域。比如整张图像。
说个经验:block内的线程数不要超过1024(计算能力3.0+的限制)。但更实际的是,block内线程数最好是32的倍数——因为warp大小是32。我曾经在一个项目里设了31线程/block,结果SM利用率掉了将近一半。这坑,你们别踩。
4.3 内置变量——threadIdx, blockIdx, blockDim, gridDim
这些是CUDA自动提供的变量,类型都是 dim3。不需要你声明,直接拿来用。
| 变量 | 含义 | 取值范围 |
|---|---|---|
threadIdx | 线程在block内的索引 | [0, blockDim - 1] |
blockIdx | block在grid内的索引 | [0, gridDim - 1] |
blockDim | block内每维的线程数 | 由<<<>>>第二个参数设定 |
gridDim | grid内每维的block数 | 由<<<>>>第一个参数设定 |
4.4 线程索引计算——把逻辑坐标映射到数据
有了这些内置变量,怎么算出每个线程负责的数据位置?
我个人习惯用一维映射处理一维数组(比如音频数据),用二维映射处理图像。二维情况最典型:
int x = blockIdx.x * blockDim.x + threadIdx.x;
int y = blockIdx.y * blockDim.y + threadIdx.y;
int linearIdx = y * width + x; // 全局一维索引
为什么这样算?
把grid想象成一块大棋盘,每个block是棋盘上的格子,每个thread是格子里的点。你从grid左上角出发,先找到你在哪个格子(blockIdx),再找到格子里的位置(threadIdx),最后加上格子的偏移(blockIdx * blockDim.x)。
边界检查的样板代码:
int x = blockIdx.x * blockDim.x + threadIdx.x;
int y = blockIdx.y * blockDim.y + threadIdx.y;
if (x >= width || y >= height) return;
嗯,一个return就搞定了。
4.5 一张图看懂层级关系
下面这张图是我自己经常给团队新人讲的:线程、block、grid构成三层树形结构,每个节点都是逻辑概念,但决定了你的并行粒度。
4.6 多维配置——dim3的精妙
上面例子用的都是二维grid和二维block。实际上你可以用一维、二维、三维。什么时候用三维?
比如处理3D体数据(医学CT、地震波),或者卷积核需要跨通道时。我个人项目中很少用到三维block,但偶尔为了匹配数据维度会设定三维grid(z方向对应时间帧)。
创建dim3的方式:
dim3 block(8, 8, 1); // block = (8,8,1) 等效于 block = dim3(8,8)
dim3 grid(128, 128); // grid = (128,128,1)
没指定Z维时,默认是1。我记得初学那会儿,以为dim3会自动填充0,结果用了0维……程序直接崩溃。默认是1,不是0。
4.7 实践小结——一个完整的Harmony校正调用
把前面所有串起来,看一个更完整的例子。假设我要对一张1920×1080的图像做色差校正:
const int W = 1920;
const int H = 1080;
dim3 blockDim(32, 32);
dim3 gridDim((W + blockDim.x - 1) / blockDim.x,
(H + blockDim.y - 1) / blockDim.y);
harmonyCorrectionKernel<<>>(d_in, d_out, W, H);
这里用 (W + blockDim.x - 1) / blockDim.x 向上取整,保证覆盖所有像素。多余线程在kernel内部被边界检查过滤掉。