2. CUDA编程模型:从Kernel到内存,一个都不能少

好,咱们进入正题。CUDA编程模型,说白了就是一套规则——告诉GPU怎么干活、数据放哪儿、怎么搬数据。我刚开始接触CUDA时,觉得这东西不就是写个函数嘛,结果一跑就崩。后来才明白,不理解这套模型,写出来的代码就是碰运气。

2.1 Kernel函数定义:GPU的入口函数

Kernel函数,就是跑在GPU上的函数。定义它有个固定套路:

__global__ void myKernel(float *d_data, int N) {
    int idx = threadIdx.x + blockIdx.x * blockDim.x;
    if (idx < N) {
        d_data[idx] = d_data[idx] * 2.0f;
    }
}

注意几个关键点:

  • __global__ 修饰符——告诉编译器这个函数是给GPU用的
  • 返回类型必须是 void——你不能从GPU直接返回一个值
  • 调用时用 <<<grid, block>>> 语法——指定线程组织方式
我的习惯:Kernel函数名我一般加个"kernel"后缀,比如 vectorAddKernel。这样看代码时一眼就能分清哪些是GPU代码。

你可能会问:为什么不能直接传一个普通数组进去?嗯,这里有个坑——Kernel函数只能访问设备内存(GPU显存)。你传个CPU指针进去,它根本找不到地址。

2.2 线程层次结构:Grid/Block/Thread

CUDA的线程组织方式,我管它叫"三层嵌套":

  • Thread(线程):最基础的计算单元,每个线程执行相同的Kernel代码
  • Block(线程块):一组线程的集合,共享一块共享内存(Shared Memory)
  • Grid(网格):所有Block的集合,对应一次Kernel调用

用一张图来理解:

Grid(网格) Block (0,0) Block (1,0) Block (2,0) Block (0,1) Block (1,1) Block (2,1) Thread

每个线程都有一个唯一的ID,通过 threadIdx、blockIdx、blockDim 计算出来。我常用的公式:

int globalIdx = threadIdx.x + blockIdx.x * blockDim.x;

这个公式,你写100个Kernel,99个都会用到它。

我曾经踩过的坑:Block内的线程数不要超过1024(对于现代GPU)。我刚开始写代码时设了2048个线程一个Block,结果Kernel直接罢工。后来查文档才知道,每个Block最多1024个线程。

2.3 内存层次结构:速度与容量的博弈

CUDA的内存模型,说白了就是"越靠近计算单元,越快但越小"。我整理了一张表:

内存类型 位置 访问速度 生命周期 典型用途
寄存器(Register) SM内部 最快(1周期) 线程级别 局部变量
共享内存(Shared Memory) SM内部 快(~5周期) Block级别 线程间数据共享
全局内存(Global Memory) 显存 慢(~400周期) 整个程序 主数据存储
常量内存(Constant Memory) 显存(带缓存) 较快(有缓存) 整个程序 只读常量数据
纹理内存(Texture Memory) 显存(带缓存) 较快(有缓存) 整个程序 图像/空间局部性数据

你想想看,全局内存访问一次要400个周期,而寄存器只要1个周期。这差距有多大?所以优化的核心思路就是:尽量把数据往快的存里放。

我的经验:做矩阵乘法时,我会把子矩阵先加载到共享内存里,然后让Block内的线程反复使用。这样全局内存的访问次数从O(N³)降到了O(N²),性能直接翻了好几倍。

2.4 主机与设备交互:数据搬运的艺术

主机(CPU)和设备(GPU)之间的数据交互,说白了就是"搬数据"和"下指令"。常用的API:

  • cudaMalloc:在设备上分配显存
  • cudaMemcpy:在主机和设备之间拷贝数据
  • cudaFree:释放显存

一个典型的流程:

// 1. 分配主机内存
float *h_data = (float*)malloc(N * sizeof(float));

// 2. 分配设备内存
float *d_data;
cudaMalloc(&d_data, N * sizeof(float));

// 3. 把数据从主机拷贝到设备
cudaMemcpy(d_data, h_data, N * sizeof(float), cudaMemcpyHostToDevice);

// 4. 启动Kernel
myKernel<<<gridSize, blockSize>>>(d_data, N);

// 5. 把结果从设备拷贝回主机
cudaMemcpy(h_data, d_data, N * sizeof(float), cudaMemcpyDeviceToHost);

// 6. 清理
cudaFree(d_data);
free(h_data);
注意:cudaMemcpy是同步操作,它会阻塞CPU直到拷贝完成。而Kernel启动是异步的,CPU会立即返回。我曾经犯过一个错误——在Kernel还没执行完时就读取结果,读到的全是垃圾数据。

还有一个容易被忽略的点:cudaDeviceSynchronize()。这个函数会等待所有Kernel执行完毕。我一般在调试时会在每个Kernel后面加一句,确保不会出现"数据还没算完就开始拷贝"的情况。

小技巧:用 cudaMemcpyAsync 配合流(Stream)可以实现数据传输和计算的重叠。说白了就是让GPU一边算一边搬下一批数据,效率能提升不少。

嗯,CUDA编程模型的核心就这些。理解了这个模型,你写出来的代码至少不会"跑不起来"。至于怎么跑得快,那是后面章节要聊的话题。

一句话总结:Kernel定义计算逻辑,线程层次决定并行方式,内存层次影响性能上限,主机设备交互是数据流动的桥梁。四者缺一不可。
可可精品资料库