4、CUDA编程模型:线程层次结构(Grid、Block、Thread)、核函数定义、<<<>>>语法
好,咱们今天聊聊CUDA编程里最核心、也最绕不开的一个概念——线程层次结构。
说实话,我刚开始学CUDA的时候,最头疼的就是搞不清Grid、Block、Thread这三者到底怎么分工。你想想看,CPU那边就几个核,顶多十几个线程,到了GPU这边,一下子冒出成千上万个线程,怎么管理?怎么调度?
嗯,CUDA的设计者想了个很聪明的办法——分层管理。
4.1 线程层次结构:Grid、Block、Thread
说白了,CUDA把所有的并行线程组织成了三层结构:
- Thread(线程):最底层的工作单元,每个线程执行核函数中的一条指令。
- Block(线程块):一组线程的集合,同一个Block内的线程可以共享数据(通过共享内存),也可以同步。
- Grid(网格):一组Block的集合,一个Grid对应一个核函数的执行。
我打个比方你就明白了。假设你要给一张1000x1000的图片做滤镜处理:
- 每个像素点需要一个线程来处理——这就是Thread。
- 把图片切成若干个小块,比如16x16的小方块,每个小方块内的像素由同一个Block里的线程处理——这就是Block。
- 整张图片就是Grid,它包含了所有的小方块——这就是Grid。
为什么会这样设计?
我在项目中遇到过一个问题:如果所有线程都直接暴露在同一个层级,那共享内存和同步的开销会变得极其复杂。分层之后,Block内部的线程可以高效协作,而Block之间则相对独立,这样GPU硬件就能轻松调度了。
关键点:
- 一个Grid可以有多个Block
- 一个Block可以有多个Thread
- Block内部的线程可以通过
__syncthreads()同步 - 不同Block之间的线程无法直接同步
4.2 内置变量:threadIdx、blockIdx、blockDim、gridDim
每个线程在运行时,都能通过一些内置变量知道自己是谁、在哪干活。这些变量是CUDA自动提供的,你直接拿来用就行。
| 变量名 | 类型 | 含义 |
|---|---|---|
threadIdx |
dim3 |
当前线程在Block内的索引(x, y, z) |
blockIdx |
dim3 |
当前Block在Grid内的索引(x, y, z) |
blockDim |
dim3 |
Block的维度,即每个Block有多少个线程 |
gridDim |
dim3 |
Grid的维度,即每个Grid有多少个Block |
举个例子,如果你启动了一个Grid,里面有2x3个Block,每个Block是4x4个线程,那么:
gridDim.x = 2,gridDim.y = 3blockDim.x = 4,blockDim.y = 4- 某个线程的
blockIdx.x可能是0或1,threadIdx.x可能是0到3
我个人习惯用一维索引来处理数组,这样代码更简洁。计算全局线程ID的公式是:
int tid = blockIdx.x * blockDim.x + threadIdx.x;
如果是二维的,那就稍微复杂一点:
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int idy = blockIdx.y * blockDim.y + threadIdx.y;
int tid = idy * width + idx; // 假设width是每行的元素数
4.3 核函数定义:__global__ 关键字
核函数,就是跑在GPU上的函数。定义的时候要在前面加上 __global__ 修饰符。
语法规则很简单:
- 返回类型必须是
void - 不能使用可变参数(比如
...) - 不能是类的成员函数(静态成员函数可以)
- 在主机端调用,在设备端执行
来看一个最经典的例子——向量加法:
// 核函数定义
__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) 的判断。为什么要加这个?
嗯,这里有个坑。我曾经在项目里忘了加边界检查,结果数组越界,读到了脏数据,调试了一下午才发现。你想想看,我们启动的线程数往往是N的整数倍,比如N=1000,我们启动了1024个线程,那多出来的24个线程就会访问到数组外面。所以边界检查是必须的。
注意:核函数内部不能调用 printf(调试时可以,但正式代码不要用),不能使用C++标准库(比如 std::vector),也不能使用递归。
4.4 <<<>>> 语法:启动核函数
核函数写好了,怎么让它跑起来?用 <<<grid, block>>> 语法。
这个语法看起来有点奇怪,我第一次看到的时候也愣了一下。它的作用是指定:
- 第一个参数:Grid的维度,即有多少个Block
- 第二个参数:Block的维度,即每个Block有多少个线程
两个参数都是 dim3 类型。如果你只传一个整数,CUDA会默认另外两个维度是1。
调用方式:
// 假设我们要处理N个元素
int N = 1000;
int threadsPerBlock = 256;
int blocksPerGrid = (N + threadsPerBlock - 1) / threadsPerBlock;
// 分配设备内存并拷贝数据(略)
// 启动核函数
vecAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, N);
// 等待GPU执行完成
cudaDeviceSynchronize();
这里有个小技巧:(N + threadsPerBlock - 1) / threadsPerBlock 这个公式是向上取整,保证即使N不能被threadsPerBlock整除,也能覆盖所有元素。我每次写CUDA代码都会用这个公式,已经形成肌肉记忆了。
另外,<<<>>> 还可以传第三个参数——共享内存大小(以字节为单位),以及第四个参数——流(stream)。不过初学者先掌握前两个就够了。
个人建议:Block的线程数最好是32的倍数。为什么?因为GPU的warp大小是32,一个warp里的线程是同时执行的。如果线程数不是32的倍数,就会有空闲的CUDA核心,造成浪费。我一般用128、256或512,这几个值在大多数GPU上表现都不错。
4.5 完整的例子:向量加法
把上面所有的知识点串起来,写一个完整的向量加法程序:
#include <cuda_runtime.h>
#include <stdio.h>
// 核函数定义
__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];
}
}
int main() {
int N = 1000;
size_t size = N * sizeof(float);
// 主机端分配内存
float *h_A = (float*)malloc(size);
float *h_B = (float*)malloc(size);
float *h_C = (float*)malloc(size);
// 初始化数据(略)
// 设备端分配内存
float *d_A, *d_B, *d_C;
cudaMalloc(&d_A, size);
cudaMalloc(&d_B, size);
cudaMalloc(&d_C, size);
// 拷贝数据到设备
cudaMemcpy(d_A, h_A, size, cudaMemcpyHostToDevice);
cudaMemcpy(d_B, h_B, size, cudaMemcpyHostToDevice);
// 启动核函数
int threadsPerBlock = 256;
int blocksPerGrid = (N + threadsPerBlock - 1) / threadsPerBlock;
vecAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, N);
// 拷贝结果回主机
cudaMemcpy(h_C, d_C, size, cudaMemcpyDeviceToHost);
// 验证结果(略)
// 释放内存
cudaFree(d_A);
cudaFree(d_B);
cudaFree(d_C);
free(h_A);
free(h_B);
free(h_C);
return 0;
}
这个例子虽然简单,但麻雀虽小五脏俱全。你把这个流程跑通了,CUDA编程的基本功就算打牢了。
4.6 避坑指南
最后,分享几个我踩过的坑:
- 忘记同步:我曾经在核函数后面忘了加
cudaDeviceSynchronize(),结果主机端代码先执行了,读到的数据全是错的。记住,核函数是异步的,主机不会等它执行完。 - Block太大:每个Block的线程数不能超过设备限制(一般是1024)。我刚开始写代码时设了2048,结果程序直接崩溃。
- Grid太大:Grid的维度也有上限,一般是65535(老设备)或2^31-1(新设备)。不过一般用不了那么多。
- 忘记释放内存:
cudaMalloc分配的内存一定要用cudaFree释放,不然会内存泄漏。这个和malloc/free是一个道理。
好了,这一章的内容就到这里。线程层次结构是CUDA的基石,搞懂了它,后面的共享内存、同步、性能优化才能顺利展开。下一章我们聊聊共享内存和线程同步,那才是真正体现CUDA威力的地方。