一、CUDA编程模型入门:从CPU到GPU的思维转变

说实话,我第一次接触CUDA的时候,脑子里全是CPU那套串行思维。写个for循环,一个线程跑完所有数据——这在GPU世界里,简直就是暴殄天物。今天我就带你从零开始,把CUDA这套编程模型掰开揉碎了讲清楚。

1.1 CUDA架构简介:GPU不是单纯的“多核CPU”

很多人以为GPU就是CPU的“加强版”——核心多嘛。但你要这么想,后面写代码会吃大亏。

我个人的理解是这样的:CPU是“特种兵”,单兵作战能力极强;GPU是“步兵方阵”,单个士兵很弱,但人海战术无敌。CUDA架构的精髓,就是让成千上万个“弱鸡”线程协同作战,完成海量数据的并行处理。

核心差异:

  • CPU:延迟优化(把单个任务做快)
  • GPU:吞吐量优化(同时做很多任务)

CUDA架构里,GPU被组织成一个个流多处理器(SM)。每个SM里有多个CUDA核心,共享一套控制单元和缓存。嗯,这里要注意:线程不是直接跑在CUDA核心上的,而是由SM调度执行。这个调度机制,我后面会细说。

1.2 主机与设备:CPU和GPU的分工协作

在CUDA编程模型里,CPU叫主机(Host),GPU叫设备(Device)。它们各干各的活,通过PCIe总线交换数据。

我刚开始写CUDA程序时,犯过一个低级错误:直接在GPU上访问CPU的内存。结果程序崩溃,查了半天才发现——主机和设备的内存是物理隔离的

角色 职责 内存
主机(CPU) 控制逻辑、串行任务、I/O 系统内存(DDR)
设备(GPU) 大规模并行计算 显存(VRAM)

典型的CUDA程序流程是这样的:

  1. 主机分配内存并初始化数据
  2. 把数据从主机拷贝到设备
  3. 在设备上启动核函数(并行计算)
  4. 把结果从设备拷贝回主机
  5. 释放内存

说白了,CPU是“项目经理”,GPU是“施工队”。项目经理把图纸(数据)交给施工队,施工队干完活再把成果交回来。

1.3 核函数(Kernel):GPU上的“并行任务”

核函数,就是跑在GPU上的函数。用__global__修饰符声明,调用时用三括号语法<<<grid, block>>>指定线程配置。

来看个最简单的例子:

// 核函数定义
__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() {
    // ... 数据准备和拷贝 ...
    vec_add<<<256, 256>>>(d_a, d_b, d_c, N);
    // ... 结果拷贝和清理 ...
}

这里有个坑,我当年踩过:核函数里不能调用printf(调试用除外),不能使用C++标准库,不能有递归。为什么?因为GPU的线程是轻量级的,没有完整的运行时环境支持这些操作。

我的调试技巧:核函数出错了怎么办?用cudaGetLastError()检查异步错误,或者先用小数据量在CPU上验证逻辑。

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

这是CUDA编程模型里最核心的概念,也是初学者最容易懵的地方。

CUDA把线程组织成三层结构:

  • Thread(线程):最基础的执行单元
  • Block(线程块):一组线程,共享共享内存,可以同步
  • Grid(线程网格):一组线程块,构成整个核函数

我画了张图,帮你理解这个层次关系:

Grid(线程网格) Block (0,0) Block (1,0) Block (2,0) T0 T1 T2 T3 ... ... Tn Tn 共享内存 Thread Block Grid

每个线程都有一个唯一的ID,通过内置变量获取:

  • threadIdx.x:线程在块内的索引
  • blockIdx.x:块在网格内的索引
  • blockDim.x:每个块的线程数

你想想看,如果我们要处理一个长度为N的数组,每个线程处理一个元素,那么线程的全局ID就是:

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

这个公式,我建议你刻在脑子里。写CUDA代码,90%的时间都在和这个公式打交道。

我曾经踩过的坑:线程块的大小不是随便设的。每个SM的资源有限,块太大可能无法同时调度多个块,块太小又浪费硬件。一般建议块大小取32的倍数(warp大小),比如128、256、512。

1.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 = 1000000;
    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] = i * 1.0f;
        h_b[i] = 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 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. 结果拷贝回主机
    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;
}

这个例子虽然简单,但包含了CUDA编程的完整流程。我个人建议你亲手敲一遍,感受一下从CPU到GPU的数据流转。

小技巧:cudaMallocManaged代替cudaMalloc可以自动管理内存迁移,新手期用这个能少写很多拷贝代码。但生产环境还是建议手动管理,性能更好。

好了,这一章的内容就到这里。CUDA编程模型的核心就是理解主机与设备的分工、核函数的定义与调用、以及线程的三层层次结构。把这些基础打牢,后面学各种并行模式就会轻松很多。


公众号:蓝海资料掘金营,微信deep3321