第一章:CUDA编程基础——主机与设备、核函数、线程层次结构
大家好,我是你们这门课的老师。在GPU计算领域摸爬滚打了十几年,踩过的坑比写过的代码还多。今天咱们开始第一讲,聊聊CUDA编程最基础的东西。
说实话,很多人一上来就急着写复杂的算子,结果连最基本的“主机”和“设备”都没搞清楚。我见过太多人把CPU和GPU的内存混为一谈,最后调试到崩溃。所以,咱们先把地基打牢。
1.1 主机(Host)与设备(Device)
在CUDA的世界里,主机指的是CPU及其内存系统,设备指的是GPU及其显存。这两个家伙分工明确:
- 主机(CPU):擅长逻辑控制、串行任务、少量数据的管理
- 设备(GPU):擅长大规模并行计算、大量数据的吞吐
你想想看,CPU就像个精明的项目经理,GPU则是一群任劳任怨的搬砖工。项目经理负责分配任务、检查结果,搬砖工只管埋头干活。
核心要点:主机和设备有各自独立的内存空间。主机内存用 malloc 分配,设备显存用 cudaMalloc 分配。数据不能直接互通,必须通过 cudaMemcpy 显式拷贝。
我在项目中遇到过一位同事,他直接在GPU核函数里访问主机指针,结果程序崩溃了三天都没找到原因。嗯,这就是没搞懂内存隔离的后果。
1.2 核函数(Kernel)的编写
核函数就是在GPU上执行的函数。它的写法有讲究:
// 核函数定义:前面加 __global__ 修饰符
__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];
}
}
注意几个关键点:
- 必须用
__global__修饰,告诉编译器这是给GPU跑的 - 返回类型必须是
void - 不能使用C++的STL容器,不能有变长参数
- 可以调用设备函数(用
__device__修饰)
个人习惯:我写核函数时,总会在参数列表最后加一个 int N 表示数据长度,然后在函数体内做边界检查。这能避免很多越界问题,尤其是当数据量不是线程块大小的整数倍时。
1.3 线程层次结构:Grid, Block, Thread
这是CUDA最核心的概念,也是新手最容易晕的地方。我画了一张图,帮你理清关系:
说白了,层级关系是这样的:
- Thread(线程):最小的执行单元,每个线程执行核函数的一份拷贝
- Block(线程块):一组线程的集合,它们可以协作(通过共享内存、同步操作)
- Grid(网格):一组线程块的集合,构成一次核函数启动
每个线程都有一个唯一的ID,通过 threadIdx、blockIdx、blockDim 来计算:
// 一维情况
int tid = threadIdx.x + blockIdx.x * blockDim.x;
// 二维情况(常用在图像处理)
int x = threadIdx.x + blockIdx.x * blockDim.x;
int y = threadIdx.y + blockIdx.y * blockDim.y;
int idx = y * width + x;
我曾经踩过的坑:刚开始写CUDA时,我总以为 threadIdx.x 就是全局唯一的线程ID。结果在多block的情况下,每个block的 threadIdx.x 都是从0开始的!必须结合 blockIdx 和 blockDim 才能算出全局索引。这个错误让我debug了整整一个下午。
1.4 <<< >>> 语法详解
这个语法看起来有点怪,但它是CUDA启动核函数的专用方式:
// 语法:kernel_name<<<grid_dim, block_dim>>>(args...);
// 一维示例
int threadsPerBlock = 256;
int blocksPerGrid = (N + threadsPerBlock - 1) / threadsPerBlock;
vecAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, N);
// 二维示例
dim3 blockDim(16, 16);
dim3 gridDim((width + 15) / 16, (height + 15) / 16);
kernel2D<<<gridDim, blockDim>>>(d_data, width, height);
参数说明:
| 参数位置 | 含义 | 类型 | 说明 |
|---|---|---|---|
| 第一个 | Grid维度 | dim3 或 int |
指定有多少个block |
| 第二个 | Block维度 | dim3 或 int |
指定每个block有多少个thread |
| 第三个(可选) | 共享内存大小 | size_t |
每个block动态分配的共享内存字节数 |
| 第四个(可选) | 流(Stream) | cudaStream_t |
指定在哪个流上执行 |
我的建议:新手先用一维的 <<<blocks, threads>>> 就好。等熟悉了再玩二维三维。另外,block内的线程数最好是32的倍数(warp大小),比如128、256、512。我一般用256,兼容性最好。
1.5 完整的向量加法示例
把上面所有知识点串起来,看一个完整的例子:
#include <cuda_runtime.h>
#include <stdio.h>
// 核函数
__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() {
int N = 1 << 20; // 约100万个元素
size_t size = N * sizeof(float);
// 1. 主机端分配内存
float *h_A = (float*)malloc(size);
float *h_B = (float*)malloc(size);
float *h_C = (float*)malloc(size);
// 初始化数据
for (int i = 0; i < N; i++) {
h_A[i] = i * 1.0f;
h_B[i] = i * 2.0f;
}
// 2. 设备端分配显存
float *d_A, *d_B, *d_C;
cudaMalloc(&d_A, size);
cudaMalloc(&d_B, size);
cudaMalloc(&d_C, size);
// 3. 数据从主机拷贝到设备
cudaMemcpy(d_A, h_A, size, cudaMemcpyHostToDevice);
cudaMemcpy(d_B, h_B, size, cudaMemcpyHostToDevice);
// 4. 启动核函数
int threadsPerBlock = 256;
int blocksPerGrid = (N + threadsPerBlock - 1) / threadsPerBlock;
vecAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, N);
// 5. 结果拷贝回主机
cudaMemcpy(h_C, d_C, size, cudaMemcpyDeviceToHost);
// 6. 验证结果
for (int i = 0; i < 10; i++) {
printf("C[%d] = %f\n", i, h_C[i]);
}
// 7. 清理
cudaFree(d_A);
cudaFree(d_B);
cudaFree(d_C);
free(h_A);
free(h_B);
free(h_C);
return 0;
}
这个流程你记牢了:分配 → 拷贝到设备 → 启动核函数 → 拷贝回主机 → 清理。我刚开始学的时候,每次写代码都按这个步骤来,慢慢就形成肌肉记忆了。
避坑指南:别忘了检查cuda API的返回值!用 cudaGetLastError() 或 cudaError_t 捕获错误。我曾经因为忘记检查 cudaMalloc 是否成功,结果在显存不足时程序静默崩溃,查了两天才发现是显存爆了。
好了,第一章的内容就到这里。记住,CUDA编程的核心就是理解主机与设备的协作关系,掌握线程层次结构,熟练使用 <<< >>> 语法。这些基础打牢了,后面学算子优化才会事半功倍。