3、CUDA编程基础回顾:CUDA编程模型,Kernel Launch,Grid-Block-Thread层次,内存管理API

好,咱们正式开始聊CUDA。说实话,很多做AI推理优化的朋友,一开始都觉得CUDA是搞图形学的人学的。其实不然。你想想看,现在大模型这么火,哪个推理框架底层离得开CUDA?我当年刚接触GPU编程时,也踩过不少坑。今天这一节,咱们就把最核心的几个概念捋清楚。

3.1 CUDA编程模型:说白了就是“主机+设备”

CUDA的编程模型,本质上是一个异构计算模型。什么叫异构?就是CPU和GPU各干各的,协同工作。

  • Host(主机):指的是CPU及其内存(系统内存)。负责控制逻辑、串行任务、数据准备。
  • Device(设备):指的是GPU及其显存(全局内存)。负责大规模并行计算。

我个人习惯把Host比作“项目经理”,Device比作“施工队”。项目经理把图纸(数据)准备好,告诉施工队怎么干,然后施工队一拥而上,快速干完。项目经理再回来收尾。

核心要点:CUDA程序从CPU启动,在GPU上执行并行计算,结果再传回CPU。这个流程,你写任何CUDA程序都绕不开。

这里我画了一张图,帮你直观理解这个模型:

Host (CPU) 系统内存 (System Memory) 负责:控制逻辑、串行任务 Device (GPU) 显存 (Global Memory) 负责:大规模并行计算 cudaMemcpy (Host→Device) cudaMemcpy (Device→Host) CUDA 程序执行流程 1. CPU 准备数据 → 2. 数据拷贝到 GPU 3. GPU 并行执行 Kernel → 4. 结果拷回 CPU

3.2 Kernel Launch:启动GPU干活

在CUDA里,GPU上执行的函数叫Kernel。你写一个函数,前面加上 __global__ 修饰符,它就变成了一个Kernel。调用的时候,用三括号语法 <<<grid, block>>> 来启动。

举个例子:

// 定义一个Kernel函数
__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 = 1000000;
    int threadsPerBlock = 256;
    int blocksPerGrid = (N + threadsPerBlock - 1) / threadsPerBlock;

    vecAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, N);
    // ... 拷贝结果回主机 ...
}

这里要注意,<<<grid, block>>> 里的两个参数,分别指定了网格和线程块的维度。我刚开始写的时候,经常搞混这两个参数。其实你记住:grid 是线程块的集合,block 是线程的集合。

避坑指南:我曾经在项目里,因为忘记检查 blocksPerGrid 的计算是否正确,导致数组尾部元素没有被处理。那个bug查了我整整一个下午。所以,一定要确保总线程数 >= 数据量,并且在Kernel里做边界检查。

3.3 Grid-Block-Thread层次:GPU的“组织架构”

CUDA的线程层次结构,是理解GPU并行的关键。说白了,就是GPU怎么组织成千上万个线程来干活。

  • Thread(线程):最小的执行单元。每个线程执行Kernel函数中的同一段代码,但处理不同的数据。
  • Block(线程块):一组线程的集合。同一个Block内的线程可以共享显存(Shared Memory),并且可以同步。
  • Grid(网格):一组Block的集合。一个Kernel对应一个Grid。

你想想看,为什么要有这个层次?因为GPU的硬件架构就是这样的。一个Block内的线程,会被调度到同一个Streaming Multiprocessor(SM)上执行。这样它们才能高效地共享数据。

我画个图帮你理解:

Grid (网格) Block (0,0) T(0,0) T(1,0) T(2,0) T(0,1) T(1,1) T(2,1) T(0,2) T(1,2) T(2,2) ... 更多线程 Block (1,0) ... 线程组 ... Block (2,0) ... 线程组 ... 每个Block包含多个Thread,每个Grid包含多个Block

在Kernel内部,我们可以通过内置变量来获取当前线程的位置:

内置变量 含义 示例值
threadIdx.x 当前线程在Block内的索引 0, 1, 2, ..., blockDim.x-1
blockIdx.x 当前Block在Grid内的索引 0, 1, 2, ..., gridDim.x-1
blockDim.x 每个Block包含的线程数 256, 512, 1024
gridDim.x Grid包含的Block数 由用户指定

计算全局唯一线程ID的公式:int tid = blockIdx.x * blockDim.x + threadIdx.x;。这个公式你最好背下来,写Kernel时几乎每次都要用。

3.4 内存管理API:cudaMalloc 和 cudaFree

GPU不能直接访问CPU的内存。所以,我们需要在显存上分配空间,把数据拷过去,算完再拷回来。这就用到了CUDA的内存管理API。

最核心的两个函数:

  • cudaMalloc(void **devPtr, size_t size):在显存上分配 size 字节的空间,返回指针。
  • cudaFree(void *devPtr):释放显存空间。

配合 cudaMemcpy 使用,完成数据搬运:

float *h_A = (float*)malloc(N * sizeof(float)); // 主机内存
float *d_A = nullptr;

cudaMalloc(&d_A, N * sizeof(float));            // 显存分配
cudaMemcpy(d_A, h_A, N * sizeof(float), cudaMemcpyHostToDevice); // 拷入

// ... 执行Kernel ...

cudaMemcpy(h_A, d_A, N * sizeof(float), cudaMemcpyDeviceToHost); // 拷出
cudaFree(d_A);                                     // 释放显存
free(h_A);                                         // 释放主机内存

重要提醒:cudaMalloc 的第一个参数是 void**,也就是二级指针。我见过很多新手写成 cudaMalloc(d_A, ...),结果程序直接崩溃。记住,要传指针的地址。

另外,每次调用 cudaMalloc 和 cudaMemcpy 后,最好检查一下返回值:

cudaError_t err = cudaMalloc(&d_A, N * sizeof(float));
if (err != cudaSuccess) {
    printf("cudaMalloc failed: %s\n", cudaGetErrorString(err));
    // 处理错误
}

嗯,这里要注意。在压测场景下,频繁地 cudaMalloc 和 cudaFree 是非常耗时的。这就是为什么后面我们要讲显存池——提前分配好一大块显存,重复利用,避免反复调用这些API。

个人经验:我在做某个推理服务优化时,发现每次请求都调用 cudaMalloc 分配临时缓冲区,导致延迟抖动很大。后来改用显存池预分配,P99 延迟直接降了 40%。所以,能复用就复用,别偷懒。

好了,CUDA编程基础就回顾到这里。这些内容虽然基础,但它们是后面所有优化技巧的基石。你把这些概念吃透了,后面讲显存池、流水线、异步操作时,才能跟得上节奏。

可可精品资料库