4、CUDA编程模型入门:主机与设备、核函数(Kernel)概念、线程层次结构

好,咱们正式开始接触CUDA编程的核心了。

说实话,我当年刚接触CUDA时,最困惑的就是这个“主机”和“设备”到底怎么分工。你想想看,一个程序里有两套内存,两套执行流程,稍不留神就搞混了。这一章,咱们就把这些基础概念彻底捋清楚。

4.1 主机(Host)与设备(Device):CPU和GPU的分工

在CUDA的世界里,主机指的是CPU及其内存(系统内存),设备指的是GPU及其显存(设备内存)。

说白了,CPU是“总指挥”,负责逻辑控制、串行任务、数据预处理。GPU是“打工人”,专门干那些可以并行计算的脏活累活。

我习惯把CPU比作一个项目经理,GPU就是手底下的一千个工人。项目经理把任务拆好、分下去,工人们同时开干,最后项目经理再把结果收回来。

核心原则:

  • CPU负责:控制流、串行计算、数据搬运的发起
  • GPU负责:大规模并行计算、数据密集型运算
  • 数据必须先从主机拷贝到设备,计算完再拷回来

我在项目中遇到过一个问题:有个同事把数据指针直接传给了GPU,以为GPU能直接访问CPU内存。结果程序跑起来要么报错,要么出乱数据。嗯,这里要注意——GPU不能直接访问主机内存(除非你用统一内存,那是后话了)。

4.2 核函数(Kernel):在GPU上运行的函数

核函数,就是你在GPU上执行的函数。写法上有个明显的标志:__global__ 修饰符。

// 一个简单的核函数:给数组每个元素加1
__global__ void add_one(float *d_data, int n) {
    int idx = threadIdx.x + blockIdx.x * blockDim.x;
    if (idx < n) {
        d_data[idx] += 1.0f;
    }
}

你看,这个函数前面加了 __global__,表示它是在设备上执行、但从主机端调用的。调用方式也很特别:

// 主机端调用核函数
int n = 1024;
float *d_data;
cudaMalloc(&d_data, n * sizeof(float));
// ... 拷贝数据到设备 ...

// 启动核函数:<<>>
add_one<<<1, 256>>>(d_data, n);

cudaDeviceSynchronize(); // 等待GPU完成

这里 <<<1, 256>>> 就是启动配置,告诉GPU:用1个Block,每个Block里256个线程。总共256个线程并行执行这个函数。

个人经验:核函数里尽量不要写 printf,调试时偶尔用用可以,正式代码里会严重拖慢性能。我一般用 cuda-gdb 或者把结果拷回CPU再打印。

4.3 线程层次结构:Thread、Block、Grid

这是CUDA最核心的概念,也是初学者最容易晕的地方。我当年花了整整一周才彻底搞明白。

CUDA的线程组织分三层:

  • Thread(线程):最小的执行单元,每个线程执行相同的核函数,但处理不同的数据
  • Block(线程块):一组线程的集合,Block内的线程可以共享内存、同步协作
  • Grid(网格):一组Block的集合,一个Grid对应一个核函数的启动

你可以想象成:Grid是一个工厂Block是车间Thread是工人。每个工人干一样的活,但负责不同的零件。

关键内置变量:

变量 含义 示例
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 global_id = threadIdx.x + blockIdx.x * blockDim.x;

举个例子:假设你启动 <<<4, 256>>>,那么总共有 4×256 = 1024 个线程。第0个Block里的线程ID是0~255,第1个Block里的线程ID是256~511,以此类推。

4.4 一维、二维、三维:灵活的数据映射

CUDA支持一维、二维、三维的线程组织。为什么需要多维?因为你处理的数据可能是图像(二维)、体素(三维)。

二维Block的例子:

// 处理一张 32x32 的图像
dim3 blockDim(16, 16);  // 每个Block 16x16 = 256个线程
dim3 gridDim(2, 2);     // 2x2 = 4个Block

kernel<<<gridDim, blockDim>>>(d_image);

// 核函数内部
__global__ void kernel(float *d_image) {
    int x = threadIdx.x + blockIdx.x * blockDim.x;
    int y = threadIdx.y + blockIdx.y * blockDim.y;
    int idx = y * width + x;  // 二维转一维
    // 处理像素...
}

我个人习惯:处理图像时用二维Block,处理一维数组时用一维Block。别为了炫技硬上三维,除非你真的需要。

避坑指南:我曾经在嵌入式设备上把Block的线程数设成了1024,结果程序直接崩溃。后来查手册才发现,某些嵌入式GPU(比如Jetson Nano)每个Block最多支持512个线程。启动前一定要查一下设备的 cudaDeviceGetAttribute 参数。

4.5 一个完整的入门示例

咱们写一个完整的向量加法,把上面所有概念串起来:

#include <cuda_runtime.h>
#include <stdio.h>

// 核函数:向量加法
__global__ void vec_add(float *a, float *b, float *c, int n) {
    int idx = threadIdx.x + blockIdx.x * blockDim.x;
    if (idx < n) {
        c[idx] = a[idx] + b[idx];
    }
}

int main() {
    int n = 1024;
    size_t bytes = n * sizeof(float);

    // 1. 分配主机内存
    float *h_a = (float*)malloc(bytes);
    float *h_b = (float*)malloc(bytes);
    float *h_c = (float*)malloc(bytes);

    // 初始化数据...
    for (int i = 0; i < n; i++) {
        h_a[i] = i * 1.0f;
        h_b[i] = i * 2.0f;
    }

    // 2. 分配设备内存
    float *d_a, *d_b, *d_c;
    cudaMalloc(&d_a, bytes);
    cudaMalloc(&d_b, bytes);
    cudaMalloc(&d_c, bytes);

    // 3. 拷贝数据到设备
    cudaMemcpy(d_a, h_a, bytes, cudaMemcpyHostToDevice);
    cudaMemcpy(d_b, h_b, bytes, cudaMemcpyHostToDevice);

    // 4. 启动核函数
    int threads_per_block = 256;
    int blocks_per_grid = (n + threads_per_block - 1) / threads_per_block;
    vec_add<<<blocks_per_grid, threads_per_block>>>(d_a, d_b, d_c, n);

    // 5. 等待完成,拷贝结果回主机
    cudaDeviceSynchronize();
    cudaMemcpy(h_c, d_c, bytes, cudaMemcpyDeviceToHost);

    // 6. 验证结果
    for (int i = 0; i < 10; i++) {
        printf("h_c[%d] = %f\n", i, h_c[i]);
    }

    // 7. 清理
    cudaFree(d_a);
    cudaFree(d_b);
    cudaFree(d_c);
    free(h_a);
    free(h_b);
    free(h_c);

    return 0;
}

这个例子虽然简单,但包含了CUDA编程的完整流程:分配内存 → 拷贝数据 → 启动核函数 → 同步 → 拷回结果 → 清理。每一步都不能少。

我的习惯:写CUDA代码时,我会在每次 cudaMemcpycudaMalloc 后面加错误检查。用 cudaGetLastError() 捕获错误,不然出问题了都不知道是哪一步崩的。

4.6 本章小结

这一章咱们把CUDA编程模型的三块基石讲清楚了:

  • 主机与设备:CPU负责调度,GPU负责计算,数据要显式搬运
  • 核函数:用 __global__ 声明,用 <<<grid, block>>> 启动
  • 线程层次:Thread → Block → Grid,用 threadIdxblockIdxblockDim 定位

下一章,咱们会深入内存管理,聊聊全局内存、共享内存、常量内存这些“坑”和“宝”。到时候我会分享一个我在Jetson上优化图像处理时踩过的内存带宽的坑,保证让你少走弯路。