第3章:CUDA编程模型入门:CUDA平台概述、主机与设备、核函数(Kernel)概念
好,我们开始进入CUDA编程模型。说实话,这是整个GPU编程最核心的一块。很多初学者一上来就盯着语法看,结果写出来的代码跑得比CPU还慢。我当年也犯过这个错,后来才明白——理解编程模型,比记住几个API重要得多。
3.1 CUDA平台:不只是个编译器
CUDA全称是Compute Unified Device Architecture,统一计算设备架构。嗯,名字挺长,但你只需要记住一点:它不是一个单纯的编程语言,而是一个完整的软硬件协同平台。
我个人习惯把CUDA平台拆成三层来看:
- 硬件层:GPU本身,包含SM(流多处理器)、显存、缓存等
- 驱动层:CUDA Driver API,负责硬件交互
- 运行时层:CUDA Runtime API,我们平时用的最多的那一层
为什么这么分?因为我在项目中遇到过一个问题:直接用Driver API写代码,代码量翻了三倍,但性能提升不到5%。后来我学乖了,除非做底层驱动开发,否则老老实实用Runtime API。
关键认知:CUDA不是C语言的扩展,而是一个异构计算平台。你的代码会被分成两部分——一部分跑在CPU上,一部分跑在GPU上。
3.2 主机与设备:两个世界
在CUDA的世界里,有两个角色:
- 主机(Host):CPU及其内存系统
- 设备(Device):GPU及其显存系统
说白了,主机负责控制逻辑、I/O操作、串行任务。设备负责大规模并行计算。两者通过PCIe总线通信,但要注意——这个通信带宽远低于显存带宽。
我曾经帮一个团队优化代码,发现他们把数据在主机和设备之间来回拷贝了十几次。你想想看,每次拷贝都要经过PCIe,这能不慢吗?后来我们改成一次拷贝、多次计算,性能直接提升了8倍。
| 特性 | 主机(CPU) | 设备(GPU) |
|---|---|---|
| 内存 | 系统内存(DDR) | 显存(GDDR/HBM) |
| 计算单元 | 几个到几十个核心 | 数千个CUDA核心 |
| 适合任务 | 串行、逻辑控制 | 数据并行、计算密集 |
| 内存延迟 | 较低 | 较高(但带宽极高) |
避坑指南:我曾经见过有人把malloc和cudaMalloc混用,结果程序崩溃了一整天。记住:主机内存用malloc,设备内存用cudaMalloc,别搞混了。
3.3 核函数(Kernel):GPU上的执行单元
核函数是什么?简单说,就是跑在GPU上的函数。用__global__修饰符声明,调用时用<<<grid, block>>>语法指定执行配置。
来看一个最基础的例子:
// 核函数定义
__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() {
// ... 分配内存、拷贝数据 ...
// 执行配置:1个grid,256个线程/block
vecAdd<<<1, 256>>>(d_A, d_B, d_C, N);
// ... 拷贝结果回主机 ...
}
这里有几个关键点:
__global__:告诉编译器这个函数跑在设备上,但从主机调用blockIdx.x:当前线程块在grid中的索引threadIdx.x:当前线程在线程块中的索引blockDim.x:每个线程块包含的线程数
为什么会这样设计?因为GPU需要把成千上万个线程组织起来。你想想看,如果每个线程都独立管理,调度器会疯掉的。所以CUDA采用了层次结构:Grid → Block → Thread。
个人经验:我刚开始写核函数时,总喜欢把if (i < N)这个边界检查去掉,觉得多余。结果有一次数据量不是256的整数倍,程序直接访问了非法内存。从那以后,我再也不敢省这个检查了。
3.4 执行配置:<<>>
这个语法看起来奇怪,但背后有深意。<<<grid, block>>>告诉运行时系统:
- 启动多少个线程块(gridDim)
- 每个线程块有多少个线程(blockDim)
举个例子:
// 处理1024个元素
int N = 1024;
int threadsPerBlock = 256;
int blocksPerGrid = (N + threadsPerBlock - 1) / threadsPerBlock;
vecAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, N);
这里用了一个经典技巧:(N + threadsPerBlock - 1) / threadsPerBlock。这是向上取整的整数除法,确保即使N不是256的倍数,也能覆盖所有元素。
重要原则:线程块的数量通常设置为SM数量的倍数。比如你的GPU有20个SM,那就设成40或80个block。这样能充分利用硬件资源。
3.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 = 1 << 20; // 约100万个元素
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);
// 拷贝数据到设备
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;
}
这个例子包含了CUDA编程的完整流程:分配内存 → 拷贝数据 → 执行核函数 → 拷贝结果 → 清理资源。嗯,每一步都不能少。
曾经踩过的坑:我刚开始写CUDA时,经常忘记调用cudaFree。结果程序跑着跑着显存就爆了。记住:显存泄漏比内存泄漏更难排查,因为GPU不会像CPU那样给你报段错误。
3.6 本章小结
这一章我们讲了三个核心概念:
- CUDA平台:一个完整的软硬件协同系统,不是单纯的编程语言
- 主机与设备:CPU和GPU各司其职,通过PCIe通信
- 核函数:用
__global__声明,用<<<grid, block>>>调用
下一章我们会深入线程层次结构,讲清楚Grid、Block、Thread到底是怎么映射到硬件上的。到时候你会发现,理解了这些,你就能写出真正高效的CUDA代码了。