第一个CUDA程序:向量加法
说实话,我教了这么多年CUDA,向量加法这个例子,就像编程界的"Hello World"。但它比Hello World有意思多了——它让你真正感受到GPU并行计算的威力。
我记得第一次写这个程序时,心里还在嘀咕:这玩意儿真能比CPU快?结果一跑,好家伙,100万个元素加起来,CPU要跑好几毫秒,GPU眨眼就完事了。今天我们就来亲手写一个,把CUDA的骨架搭起来。
先看代码,再讲道理
别急着看理论,咱们先把代码跑起来。下面是一个完整的向量加法核函数:
// 核函数声明
__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 = 1000000;
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);
// 拷贝数据到GPU
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);
// 清理...
}
代码不长,但信息量很大。咱们拆开揉碎了讲。
核函数声明:__global__ 到底啥意思?
你看那个 __global__ 关键字,它告诉编译器:这个函数要在GPU上跑,而且能被CPU调用。CUDA有三种函数修饰符:
| 修饰符 | 调用方 | 执行位置 | 典型用途 |
|---|---|---|---|
__global__ |
CPU调用 | GPU执行 | 核函数入口 |
__device__ |
GPU调用 | GPU执行 | 辅助函数 |
__host__ |
CPU调用 | CPU执行 | 普通函数(可省略) |
我个人习惯,__global__ 函数返回类型必须是 void。为什么?因为GPU上成千上万个线程同时跑,你让谁去return?所以核函数只管干活,结果通过指针参数传回来。
语法糖:<<<grid, block>>> 的真相
这个尖括号语法,我第一次看到时觉得挺别扭。但用多了就发现,它其实很直观。
<<<blocksPerGrid, threadsPerBlock>>> 定义了线程的组织方式:
- grid:一个网格,包含多个block
- block:一个线程块,包含多个thread
- thread:最小的执行单元
说白了,就是三层结构。GPU把任务拆成网格,网格里放线程块,线程块里放线程。每个线程执行相同的代码,但处理不同的数据。
关键理解:blockIdx.x 是当前线程所在块的编号,threadIdx.x 是线程在块内的编号,blockDim.x 是每个块的线程数。三者组合起来,就能算出全局唯一的线程ID。
画个图,一目了然
光说可能不够直观,我画了个图,帮你理解线程是怎么组织的:
你看,每个Block里有多个Thread,每个Grid里有多个Block。实际运行时,GPU会把Block分配到不同的SM(流多处理器)上执行。这就是CUDA的并行精髓。
线程索引计算:那个公式是怎么来的?
代码里有一行:int i = blockIdx.x * blockDim.x + threadIdx.x;
这个公式其实很简单。你想想看:
- 每个Block有
blockDim.x个线程 - 当前是第
blockIdx.x个Block - 所以前面已经有
blockIdx.x * blockDim.x个线程处理过了 - 再加上当前线程在Block内的偏移
threadIdx.x - 就得到了全局唯一的线程ID
举个例子:假设 blockDim.x = 256,blockIdx.x = 2,threadIdx.x = 10。那么 i = 2 * 256 + 10 = 522。这个线程就负责处理数组的第522个元素。
小技巧:我习惯把线程ID打印出来调试。用 printf 在核函数里输出 blockIdx.x、threadIdx.x 和计算出的 i,能帮你快速理解线程的映射关系。
边界检查:为什么一定要加?
你看代码里有个 if (i < n) 的判断。这个很重要。
为什么?因为 blocksPerGrid = (n + threadsPerBlock - 1) / threadsPerBlock 这个公式,算出来的Block数可能会多出一些线程。比如 n = 1000,threadsPerBlock = 256,那么 blocksPerGrid = 4。总线程数 = 4 * 256 = 1024,比1000多了24个。
这24个线程如果不加判断,就会访问到数组边界之外,轻则读到垃圾数据,重则段错误。
我曾经在项目里吃过这个亏。有一次写矩阵乘法,忘了加边界检查,结果跑出来的结果时对时错。查了两天才发现,原来是边界外的线程在捣乱。从那以后,我写核函数的第一件事就是加边界判断。
内存管理:主机和设备之间的搬运工
CUDA编程里,内存管理是绕不开的坎。你得记住:CPU和GPU有各自的内存空间,数据不能直接共享。
基本流程就三步:
- cudaMalloc:在GPU上分配显存
- cudaMemcpy:在主机和设备之间拷贝数据
- cudaFree:释放显存
这里有个坑:cudaMemcpy 的最后一个参数,指定拷贝方向。常见的有:
cudaMemcpyHostToDevice:从CPU到GPUcudaMemcpyDeviceToHost:从GPU到CPUcudaMemcpyDeviceToDevice:GPU内部拷贝
方向搞反了,数据就传不过去。我刚开始学的时候,经常忘了把结果拷回来,然后盯着CPU上的数组发呆——怎么全是0?
性能小贴士:Block大小怎么选?
代码里我用了 threadsPerBlock = 256。这个数字不是随便选的。
一般来说,Block大小建议是32的倍数(因为warp大小是32)。常用的值有128、256、512。太小了(比如32)会导致SM利用率低,太大了(比如1024)又会受寄存器数量限制。
我个人经验:对于大多数计算密集型任务,256是个不错的起点。然后你可以用profiler去调优,找到最适合你GPU型号的值。
完整代码示例
最后,给你一个可以直接编译运行的完整代码:
#include <stdio.h>
#include <cuda_runtime.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 = 1000000;
size_t size = n * sizeof(float);
// 主机内存
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;
}
// 设备内存
float *d_A, *d_B, *d_C;
cudaMalloc(&d_A, size);
cudaMalloc(&d_B, size);
cudaMalloc(&d_C, size);
// 拷贝到GPU
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);
// 验证结果
for (int i = 0; i < 10; i++) {
printf("C[%d] = %f\n", i, h_C[i]);
}
// 清理
cudaFree(d_A);
cudaFree(d_B);
cudaFree(d_C);
free(h_A);
free(h_B);
free(h_C);
return 0;
}
编译命令:nvcc -o vecAdd vecAdd.cu
跑一下试试。如果看到输出 C[0] = 0.0, C[1] = 3.0, C[2] = 6.0...,恭喜你,第一个CUDA程序跑通了!
嗯,向量加法虽然简单,但它包含了CUDA编程的所有核心要素:核函数声明、线程组织、内存管理。把这个吃透了,后面的矩阵乘法、归约、卷积,都是在这个基础上加花样。