GPU编程入门:CUDA基础、GPU架构、核函数、内存层次结构

说实话,我第一次接触GPU编程的时候,心里是有点发怵的。那时候我刚从嵌入式C转过来,满脑子都是CPU那套“单核串行”的思维。结果一上来就面对几千个线程同时跑,感觉就像从开手动挡小车,突然跳进了波音747的驾驶舱。但后来我发现,只要你理解了GPU的“脾气”,它其实比CPU更直来直去。

1. GPU架构:为什么它和CPU不一样?

CPU和GPU的设计哲学,说白了就是“精英小队” vs “人海战术”。CPU有强大的控制单元和巨大的缓存,擅长处理复杂的逻辑分支。而GPU呢?它把大部分晶体管都用来做计算单元(ALU),控制单元和缓存都精简到极致。

我习惯把GPU看作一个“包工头”带着几千个“民工”。包工头(控制单元)只负责分配任务,民工们(CUDA核心)只管埋头干活。每个民工虽然单兵作战能力一般,但架不住人多啊!

核心差异:

  • CPU: 延迟优化 —— 想尽办法让单个任务跑得快。
  • GPU: 吞吐量优化 —— 不在乎单个任务慢一点,但整体要完成海量任务。

你想想看,如果你要算100万个数的加法,CPU会一个一个算,但GPU会分成1000组,每组1000个数同时算。这就是并行计算的魅力。

CPU 架构 控制 缓存 ALU ALU 少量强大核心 大缓存,强控制 GPU 架构 控制 缓存 ALU ALU ALU ALU 控制 缓存 ALU ALU ALU ALU 大量简单核心(SM) 小缓存,简单控制 适合数据并行

2. CUDA基础:从Hello World开始

CUDA是NVIDIA推出的并行计算平台。你不需要把它想得太复杂,它就是在C语言基础上加了一些扩展关键字。比如 __global__ 表示这个函数在GPU上运行,__device__ 表示在GPU上调用。

来看一个最简单的核函数:

// 核函数定义:每个线程执行一次
__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;
    float *d_a, *d_b, *d_c;
    // 在GPU上分配内存
    cudaMalloc(&d_a, n * sizeof(float));
    cudaMalloc(&d_b, n * sizeof(float));
    cudaMalloc(&d_c, n * sizeof(float));

    // 启动核函数:1个block,1024个线程
    vec_add<<<1, 1024>>>(d_a, d_b, d_c, n);

    cudaDeviceSynchronize();
    // 记得释放内存
    cudaFree(d_a); cudaFree(d_b); cudaFree(d_c);
    return 0;
}

个人习惯: 我每次写核函数,都会在开头加一个边界检查 if (idx < n)。因为线程总数不一定能整除数据量,多出来的线程必须“空转”返回。曾经我漏掉这个检查,结果数组越界,查了整整一下午。

3. 核函数:线程是怎么组织的?

GPU的线程组织分三层:网格(Grid)→ 线程块(Block)→ 线程(Thread)。你启动核函数时写的 <<<grid, block>>> 就是在定义这两层。

  • Grid: 包含所有Block。可以是一维、二维、三维。
  • Block: 包含一组Thread。同一个Block内的线程可以协作(共享内存、同步)。
  • Thread: 最小编程单元,每个线程执行核函数的一份拷贝。

为什么这样设计?说白了是为了适应不同维度的数据。比如处理一张图片,你可以用二维Grid(对应图像宽高),每个Block处理一个16x16的像素块。

内置变量:

  • threadIdx.x:线程在Block内的索引
  • blockIdx.x:Block在Grid内的索引
  • blockDim.x:Block的维度大小
  • gridDim.x:Grid的维度大小

嗯,这里要注意:一个Block内的线程数上限通常是1024(取决于GPU架构)。我刚开始写的时候,总想一个Block塞2048个线程,结果程序直接崩了。后来才养成习惯,先查一下 cudaDeviceGetAttribute 获取设备参数。

4. 内存层次结构:谁快谁慢,心里要有数

GPU的内存体系,是性能调优的关键。我见过太多人把所有数据都放在全局内存里,结果性能还不如CPU。说白了,你得知道每种内存的“脾气”。

内存类型 位置 访问速度 作用域 生命周期
全局内存 板载显存 慢(~200周期) 所有线程 程序运行期
共享内存 芯片内(SM) 快(~5周期) 同一个Block Block生命周期
寄存器 芯片内(每个线程) 极快(1周期) 单个线程 线程生命周期
常量内存 板载显存(带缓存) 较快(有缓存) 所有线程(只读) 程序运行期
纹理内存 板载显存(带缓存) 较快(空间局部性) 所有线程(只读) 程序运行期

避坑指南: 我曾经在项目里把一个大数组放在全局内存,每个线程都频繁读写,结果带宽被占满,性能惨不忍睹。后来我把频繁访问的数据搬到了共享内存,速度提升了8倍。记住:尽量减少全局内存访问,能用共享内存就别用全局内存。

5. 共享内存与同步:Block内的“小团队协作”

共享内存是GPU编程的“杀手锏”。它允许同一个Block内的线程互相通信。但有个前提:你必须手动管理同步,不然会出现数据竞争。

来看一个经典的归约求和例子:

__global__ void reduce_sum(float *input, float *output, int n) {
    __shared__ float sdata[256];  // 共享内存,大小等于Block线程数
    int idx = threadIdx.x + blockIdx.x * blockDim.x;
    int tid = threadIdx.x;

    // 加载数据到共享内存
    sdata[tid] = (idx < n) ? input[idx] : 0.0f;
    __syncthreads();  // 等待所有线程加载完成

    // 树形归约
    for (int s = blockDim.x / 2; s > 0; s >>= 1) {
        if (tid < s) {
            sdata[tid] += sdata[tid + s];
        }
        __syncthreads();  // 每次归约后同步
    }

    // 将结果写回全局内存
    if (tid == 0) output[blockIdx.x] = sdata[0];
}

我建议: 每次使用 __syncthreads() 时,都要想清楚“所有线程是否都会到达这里?”如果有些线程提前return了,那同步就会死锁。我早期就踩过这个坑,调试时卡死,最后发现是条件分支导致部分线程没执行同步。

6. 内存合并访问:让带宽跑满的秘诀

全局内存虽然慢,但如果你能让访问模式“合并”,就能最大化利用带宽。所谓合并访问,就是相邻的线程访问相邻的地址。

举个例子:

  • 好: c[idx] = a[idx] + b[idx]; —— 线程0访问地址0,线程1访问地址1,连续。
  • 坏: c[threadIdx.x * 64] = ... —— 线程0访问地址0,线程1访问地址64,跨度大,无法合并。

我在项目中遇到过一位同事,他把数据结构设计成 struct of arrays 而不是 array of structs,结果性能直接翻倍。说白了,就是让数据在内存中按线程访问顺序紧密排列。

总结一下GPU编程的“心法”:

  1. 先想清楚数据怎么分块(Block划分)。
  2. 尽量用共享内存做中间计算。
  3. 保证全局内存访问是合并的。
  4. 减少线程分支分歧(warp divergence)。
  5. cuda-memcheck 检查越界。

好了,这一章的内容就到这里。GPU编程其实没那么神秘,你只要把“并行”两个字刻在脑子里,多写多练,很快就能上手。记住,遇到性能瓶颈时,先看看内存访问模式,再查查共享内存使用,往往能解决80%的问题。


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