3. CUDA编程模型入门:Host与Device的概念,Kernel函数的定义与调用,理解<<>>执行配置的物理意义
好,咱们正式开始接触CUDA编程模型的核心了。
说实话,我第一次接触CUDA的时候,最困惑的就是这个Host和Device的概念。明明都是代码,怎么还分两套?后来踩过几次坑才明白——这其实是理解GPU编程的钥匙。
3.1 Host与Device:CPU和GPU的分工
简单来说:
- Host:就是CPU及其内存。负责控制流程、逻辑判断、I/O操作。
- Device:就是GPU及其显存。负责大规模并行计算。
你想想看,CPU擅长处理复杂、串行的任务。GPU呢?它擅长做简单、重复、并行的计算。所以CUDA程序天然就是"双核"的——CPU当总指挥,GPU当苦力。
核心原则:Host管理数据流向,Device执行计算任务。两者通过PCIe总线交换数据。
我在项目中遇到过一个问题:有个同事把所有逻辑都写在GPU上,结果调试了三天。后来我帮他改成Host控制流程、Device只做矩阵运算,半天就搞定了。说白了,别让GPU做它不擅长的事。
3.2 Kernel函数:在GPU上运行的代码
Kernel函数,就是你在GPU上执行的函数。它有几个关键特征:
- 用
__global__修饰符声明 - 返回类型必须是
void - 由Host调用,在Device上执行
- 所有线程执行同一份代码(SIMT模型)
来看一个最简单的例子:
// 定义一个Kernel函数
__global__ void hello_from_gpu() {
printf("Hello from GPU thread %d\n", threadIdx.x);
}
int main() {
// 调用Kernel函数
hello_from_gpu<<<1, 5>>>();
// 等待GPU执行完成
cudaDeviceSynchronize();
return 0;
}
这里有个细节:printf 在Kernel里能用,但只在计算能力2.0以上的GPU才支持。嗯,这个坑我踩过——在旧卡上跑,啥输出都没有,还以为程序挂了。
个人习惯:我写Kernel函数时,喜欢在函数名后面加个 _kernel 后缀,比如 matmul_kernel。这样一眼就能看出哪些代码跑在GPU上。
3.3 <<>>:执行配置的物理意义
这个语法,说实话,是CUDA里最让人迷惑的地方之一。我第一次看到 <<<1, 5>>> 的时候,心想:这什么鬼?三个尖括号?
其实它的物理意义非常直观:
- grid:线程格,包含多个线程块
- block:线程块,包含多个线程
- thread:线程,最小的执行单元
你可以这样理解:
- 一个Kernel启动,相当于一个grid
- grid由多个block组成
- 每个block包含多个thread
来看一个更具体的例子:
// 启动一个包含2个block、每个block有4个线程的Kernel
my_kernel<<<2, 4>>>();
// 等价于启动了 2 × 4 = 8 个线程
// 线程索引如下:
// Block 0: thread 0, 1, 2, 3
// Block 1: thread 4, 5, 6, 7
关键点:所有线程执行同一份Kernel代码,但通过 threadIdx.x 和 blockIdx.x 来区分各自处理的数据。
3.4 内置变量:threadIdx, blockIdx, blockDim, gridDim
CUDA提供了几个内置变量,帮你定位当前线程:
| 变量 | 含义 | 示例值 |
|---|---|---|
threadIdx.x |
当前线程在block内的索引 | 0, 1, 2, ..., blockDim.x-1 |
blockIdx.x |
当前block在grid内的索引 | 0, 1, 2, ..., gridDim.x-1 |
blockDim.x |
每个block包含的线程数 | 由<<<grid, block>>>的第二个参数决定 |
gridDim.x |
grid包含的block数 | 由<<<grid, block>>>的第一个参数决定 |
有了这些变量,你就可以计算全局线程ID:
int tid = blockIdx.x * blockDim.x + threadIdx.x;
这个公式我建议你背下来。我在项目中至少写过几百次。它把二维的block-grid结构,映射成了一维的线性索引。
我曾经踩过的坑:忘记考虑 blockDim.x 的维度。如果你用的是二维block,记得用 blockDim.x * blockDim.y 来计算总线程数。否则数据会错位,而且很难调试。
3.5 一个完整的例子:向量加法
咱们把上面这些概念串起来,写一个完整的向量加法:
#include <stdio.h>
// Kernel函数:向量加法
__global__ void vec_add_kernel(float *a, float *b, float *c, int n) {
int tid = blockIdx.x * blockDim.x + threadIdx.x;
if (tid < n) {
c[tid] = a[tid] + b[tid];
}
}
int main() {
int n = 1000;
size_t size = n * sizeof(float);
// 1. 在Host上分配内存
float *h_a = (float*)malloc(size);
float *h_b = (float*)malloc(size);
float *h_c = (float*)malloc(size);
// 初始化数据...
// 2. 在Device上分配显存
float *d_a, *d_b, *d_c;
cudaMalloc(&d_a, size);
cudaMalloc(&d_b, size);
cudaMalloc(&d_c, size);
// 3. 把数据从Host拷贝到Device
cudaMemcpy(d_a, h_a, size, cudaMemcpyHostToDevice);
cudaMemcpy(d_b, h_b, size, cudaMemcpyHostToDevice);
// 4. 配置执行参数
int threads_per_block = 256;
int blocks_per_grid = (n + threads_per_block - 1) / threads_per_block;
// 5. 启动Kernel
vec_add_kernel<<<blocks_per_grid, threads_per_block>>>(d_a, d_b, d_c, n);
// 6. 把结果拷回Host
cudaMemcpy(h_c, d_c, size, cudaMemcpyDeviceToHost);
// 7. 清理
cudaFree(d_a);
cudaFree(d_b);
cudaFree(d_c);
free(h_a);
free(h_b);
free(h_c);
return 0;
}
这个模式你记住了:分配→拷贝→执行→拷回→清理。我刚开始写CUDA程序时,经常忘记 cudaMemcpy 的方向,结果GPU算了个寂寞。
我的建议:写Kernel时,一定要加边界检查(就是那个 if (tid < n))。因为 blocks_per_grid * threads_per_block 可能比 n 大,多余的线程如果不检查,会访问非法内存。
3.6 执行配置的物理意义再思考
你可能会问:为什么要搞grid和block两层结构?直接启动N个线程不行吗?
原因有两点:
- 硬件限制:一个block内的线程共享shared memory,且数量有限(通常最多1024个)
- 调度效率:GPU以block为单位调度到SM上,block越多,越容易隐藏延迟
说白了,grid是逻辑上的组织方式,block是硬件上的调度单位。你设计执行配置时,要同时考虑这两个层面。
我记得有一次优化一个图像处理程序,把block大小从128改成256,性能提升了30%。原因就是每个SM能同时容纳更多线程,更好地隐藏了内存访问延迟。
3.7 本章小结
- Host是CPU,Device是GPU,各司其职
- Kernel函数用
__global__声明,在Device上执行 <<<grid, block>>>定义了线程的组织结构- 用
threadIdx、blockIdx等内置变量定位线程 - 向量加法是CUDA的"Hello World",掌握它就掌握了基本流程
下一章,咱们会深入探讨线程同步和shared memory的使用。到时候你会发现,理解了grid和block,后面的内容就顺理成章了。