4. CUDA编程模型核心:Kernel函数定义与调用、线程层次结构、内置变量
好,咱们今天聊点硬核的。CUDA编程模型的核心,说白了就是三件事:怎么写Kernel、怎么组织线程、怎么用内置变量。这三件事搞明白了,你写CUDA程序就算入门了。
我记得刚接触CUDA那会儿,最让我困惑的就是——这玩意儿到底怎么并行起来的?CPU上我们写个for循环,一个个跑。GPU上呢?你得把任务拆成成千上万个线程,让它们同时干活。这个思维转变,是CUDA编程的第一道坎。
4.1 Kernel函数:GPU上跑的“轻量级任务”
Kernel函数,就是跑在GPU上的函数。定义它的时候,有个关键修饰符——__global__。你看到这个,就知道它是给GPU用的。
Kernel函数的核心特征:
- 用
__global__修饰,返回值必须是void - 调用时用尖括号语法
<<<grid, block>>>指定线程配置 - 所有线程执行同一份代码,但处理不同的数据
- 不能调用C++标准库函数(比如
printf在计算能力2.0以上才支持)
来看个最简单的例子:
// Kernel定义
__global__ void vecAdd(float* A, float* B, float* C, int N) {
int i = threadIdx.x + blockIdx.x * blockDim.x;
if (i < N) {
C[i] = A[i] + B[i];
}
}
// 调用方式
int main() {
// ... 数据准备和内存分配 ...
vecAdd<<<numBlocks, blockSize>>>(d_A, d_B, d_C, N);
// ... 结果回收 ...
}
这里有个坑,我踩过好几次——Kernel函数里别用全局变量。所有数据都得通过参数传进去,或者用常量内存、纹理内存这些特殊方式。为什么?因为GPU的线程模型决定了,每个线程看到的“全局”是不一样的。
个人习惯:我写Kernel函数时,总会在第一行先算好线程的全局索引。这样后面处理数据时,思路特别清晰。你想想看,每个线程知道自己该处理哪个元素,这不就是SIMT的精髓吗?
4.2 线程层次结构:Grid、Block、Thread
CUDA的线程组织方式,是个三层结构:Grid → Block → Thread。我画个图帮你理解:
这个结构图你看懂了吗?一个Grid包含多个Block,每个Block包含多个Thread。为什么搞这么复杂?说白了,是为了适配GPU的硬件架构。
Block里的线程可以共享显存(Shared Memory),还能同步(__syncthreads())。但不同Block之间的线程,老死不相往来——没法直接通信,也没法同步。这个设计是有道理的:Block映射到SM(流多处理器),SM内部通信快,SM之间通信慢。硬件决定了软件模型。
我曾经踩过的坑:刚开始写CUDA时,我试图让不同Block的线程协作完成一个任务。结果发现数据总对不上,调试了整整两天。后来才明白——Block之间没有同步机制,你没法保证执行顺序。所以,需要协作的线程,一定要放在同一个Block里。
4.3 内置变量:threadIdx、blockIdx、blockDim、gridDim
CUDA提供了几个内置变量,帮你定位当前线程在全局中的位置。这些变量在Kernel函数里可以直接用,不需要声明。
| 变量名 | 类型 | 含义 | 取值范围 |
|---|---|---|---|
threadIdx |
dim3 |
线程在Block内的索引 | 0 到 blockDim - 1 |
blockIdx |
dim3 |
Block在Grid内的索引 | 0 到 gridDim - 1 |
blockDim |
dim3 |
Block的维度(包含多少个线程) | 由调用时指定 |
gridDim |
dim3 |
Grid的维度(包含多少个Block) | 由调用时指定 |
这些变量都是 dim3 类型,有 .x、.y、.z 三个分量。你可以用一维、二维、三维来组织线程。我个人最常用的是一维,处理数组、向量这些线性数据时特别方便。
计算全局索引的公式,你得刻在脑子里:
// 一维情况
int tid = threadIdx.x + blockIdx.x * blockDim.x;
// 二维情况
int ix = threadIdx.x + blockIdx.x * blockDim.x;
int iy = threadIdx.y + blockIdx.y * blockDim.y;
int idx = iy * width + ix; // 线性化
// 三维情况
int ix = threadIdx.x + blockIdx.x * blockDim.x;
int iy = threadIdx.y + blockIdx.y * blockDim.y;
int iz = threadIdx.z + blockIdx.z * blockDim.z;
int idx = iz * height * width + iy * width + ix; // 线性化
避坑指南:我见过很多新手直接拿 threadIdx.x 当全局索引用。这肯定不对!threadIdx.x 只是Block内部的索引,不同Block的线程可能有相同的 threadIdx.x。一定要加上 blockIdx.x * blockDim.x 这个偏移量。
4.4 线程配置:怎么选Block大小和Grid大小
这是个经典问题。Block大小选多少?Grid大小怎么定?我直接给经验值:
- Block大小:通常是32的倍数(因为warp大小是32)。128、256、512都是常见选择。
- Grid大小:根据数据量来算。比如你有N个元素,Block大小是256,那Grid大小就是
ceil(N / 256)。 - 最大限制:每个Block最多1024个线程(计算能力2.0以上)。Grid的每个维度最多2^31-1个Block。
为什么Block大小要选32的倍数?因为GPU执行指令时,是以32个线程为一组(叫warp)来调度的。Block大小是32的倍数,能保证warp被充分利用,不会出现“半空”的warp浪费计算资源。
// 一个典型的线程配置
int N = 1000000;
int blockSize = 256;
int numBlocks = (N + blockSize - 1) / blockSize; // 向上取整
vecAdd<<<numBlocks, blockSize>>>(d_A, d_B, d_C, N);
这里有个细节——边界检查。因为 numBlocks * blockSize 可能大于N,所以Kernel函数里一定要加 if (i < N) 的判断。不然线程会访问越界内存,轻则数据错误,重则程序崩溃。
总结一下本章核心:
- Kernel函数用
__global__定义,<<<grid, block>>>调用 - 线程层次:Grid → Block → Thread,Block内可共享内存和同步
- 内置变量
threadIdx、blockIdx、blockDim、gridDim帮你定位线程 - 全局索引 = threadIdx + blockIdx * blockDim,别忘了边界检查
嗯,这些就是CUDA编程模型的核心骨架。你把这些搞明白了,后面学共享内存、原子操作、流并行这些高级特性时,就会觉得顺理成章。代码写多了,你会发现——CUDA其实没那么神秘,它就是一套用线程换时间的编程范式。