2. CUDA编程基础:主机与设备、核函数编写、线程层次结构
好,咱们正式开始动手写CUDA代码了。这一章是地基中的地基,你把它啃透了,后面那些花里胡哨的优化技巧才能玩得转。我当年刚接触CUDA时,就是没搞懂主机和设备到底怎么分工,结果调试一个简单的向量加法调了整整两天——后来发现是数据根本没拷到显卡上。嗯,这种坑咱们今天必须绕过去。
2.1 主机与设备:CPU和GPU的分工
说白了,CUDA程序里有两个角色:主机(Host)和设备(Device)。主机就是CPU及其内存,设备就是GPU及其显存。CPU擅长逻辑控制和串行计算,GPU擅长大规模并行计算。所以我们的策略是:CPU负责调度、控制流程、管理数据;GPU负责闷头算数。
核心原则:数据在哪里,计算就在哪里。你要在GPU上算,就得先把数据搬到GPU的显存里。
我习惯把主机和设备想象成两个独立的仓库。CPU仓库(主机内存)里的货,GPU仓库(显存)拿不到。你得叫一辆卡车(cudaMemcpy)把货拉过去。算完了,再拉回来。就这么简单。
来看一下典型的数据流:
- 在主机内存中分配空间并初始化数据
- 在设备显存中分配空间
- 将数据从主机拷贝到设备
- 在GPU上执行核函数
- 将结果从设备拷贝回主机
- 释放主机和设备上的内存
注意:忘记释放显存是新手最常见的错误。显存泄漏比内存泄漏更隐蔽,因为你的电脑可能不会立刻崩溃,但跑几次大模型训练后,显卡就罢工了。我曾经在生产环境里排查过这种问题,最后发现是某个中间结果没释放,导致OOM。所以,养成好习惯:cudaMalloc 和 cudaFree 成对出现。
2.2 核函数(Kernel)编写
核函数就是在GPU上执行的函数。它的写法跟普通C函数很像,但有几个关键区别。你想想看,如果每个线程都执行同一个函数,那它们怎么知道自己该干哪一部分活?答案是通过内置变量。
先看一个最简单的核函数——向量加法:
// 核函数定义:前面加 __global__ 修饰符
__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];
}
}
这里有几个要点:
__global__告诉编译器,这个函数是在设备上执行,但从主机端调用- 核函数返回类型必须是
void - 核函数不能使用C++的STL容器、不能有静态变量、不能有可变参数
- 核函数内部可以调用设备函数(用
__device__修饰)
我的习惯:写核函数时,我总会在开头先算好线程索引,然后立刻做边界检查。为什么?因为线程总数不一定能被块大小整除,最后一批线程可能会越界。不做检查,轻则读到垃圾数据,重则程序崩溃。这个习惯帮我避免过无数次bug。
调用核函数的方式也很特别:
// 主机端调用核函数
vecAdd<<<gridSize, blockSize>>>(d_A, d_B, d_C, N);
尖括号里的两个参数,第一个是网格大小(多少个线程块),第二个是块大小(每个块多少个线程)。这两个参数决定了你总共启动多少个线程。
2.3 线程层次结构:Grid、Block、Thread
这是CUDA最核心的概念之一。GPU的线程不是一维的,而是三维的层次结构。我刚开始学的时候也觉得三维有点多余,但后来做图像处理时才发现,用二维线程索引访问像素简直不要太爽。
来看这张图,它展示了线程的组织方式:
每个线程都有三个内置变量来确定自己的位置:
| 内置变量 | 类型 | 说明 |
|---|---|---|
threadIdx |
dim3 | 线程在块内的索引(x, y, z) |
blockIdx |
dim3 | 块在网格内的索引(x, y, z) |
blockDim |
dim3 | 每个块的维度大小(有多少个线程) |
gridDim |
dim3 | 网格的维度大小(有多少个块) |
计算全局线程索引的公式是:
// 一维情况
int tid = blockIdx.x * blockDim.x + threadIdx.x;
// 二维情况(常用于图像处理)
int x = blockIdx.x * blockDim.x + threadIdx.x;
int y = blockIdx.y * blockDim.y + threadIdx.y;
int idx = y * width + x; // 展平为一维
关于块大小的选择:我个人建议块大小取32的倍数,最好是128或256。为什么是32?因为GPU的warp大小就是32,一个warp是GPU调度的最小单位。块大小取32的倍数,能保证warp里没有空闲线程。我踩过的坑是:有一次为了省事设了100,结果每个warp里只有4个线程干活,28个在摸鱼,性能直接腰斩。
2.4 完整的向量加法示例
把上面所有知识点串起来,写一个完整的程序:
#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 = 1 << 20; // 1048576 个元素
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] = 1.0f;
h_B[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 blockSize = 256;
int gridSize = (N + blockSize - 1) / blockSize; // 向上取整
vecAdd<<<gridSize, blockSize>>>(d_A, d_B, d_C, N);
// 5. 拷贝结果回主机
cudaMemcpy(h_C, d_C, size, cudaMemcpyDeviceToHost);
// 验证结果
for (int i = 0; i < 10; i++) {
printf("C[%d] = %f\n", i, h_C[i]);
}
// 6. 释放内存
cudaFree(d_A);
cudaFree(d_B);
cudaFree(d_C);
free(h_A);
free(h_B);
free(h_C);
return 0;
}
避坑指南:我曾经在项目里忘记加 cudaDeviceSynchronize(),结果核函数还没执行完,主机就开始读结果,读到的全是0。核函数是异步的,主机不会等它执行完。如果你需要在核函数之后立即读取结果,记得加同步。不过在这个例子里,cudaMemcpy 本身会隐式同步,所以没问题。
2.5 小结
这一章我们搞清楚了三个核心概念:主机和设备的分工、核函数的写法、线程的三层组织结构。说白了,CUDA编程就是:CPU负责指挥和搬运,GPU负责干活;每个线程通过内置变量找到自己该处理的数据;线程按Grid-Block-Thread三层组织,方便我们处理各种维度的数据。
嗯,这些概念刚接触时可能有点绕,但写两三个小例子就熟了。我建议你现在就动手把上面的向量加法跑一遍,然后试着改成矩阵加法(二维线程)。代码跑通了,这些概念就真正是你的了。
想要更多资料,联系可可