第二章:CUDA编程模型——主机与设备、核函数、线程层次结构

好,我们正式开始CUDA编程的核心内容。这一章,我会带你理解CUDA编程模型中最基础、也最重要的几个概念。说白了,就是搞明白CPU和GPU怎么配合干活,以及GPU内部那成千上万个线程是怎么组织起来的。

2.1 主机与设备:CPU和GPU的分工

在CUDA的世界里,CPU被称为主机(Host),GPU被称为设备(Device)。它们各司其职,协同工作。

  • 主机(CPU):负责逻辑控制、串行计算、I/O操作。它不适合做大规模并行计算。
  • 设备(GPU):负责大规模并行计算。它拥有成百上千个核心,适合处理数据并行任务。

我刚开始接触CUDA时,犯过一个低级错误:试图在GPU上直接调用printf打印调试信息。嗯,后来才知道,GPU的核函数里确实可以printf,但输出是异步的,而且顺序完全不可控。调试起来那叫一个痛苦。

核心要点:主机和设备有各自独立的内存空间。数据必须显式地从主机拷贝到设备,计算完成后再拷贝回来。

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

核函数,就是你在GPU上执行的函数。它用__global__修饰符声明,并且必须返回void

看一个最简单的例子:

// 核函数定义
__global__ void hello_from_gpu() {
    printf("Hello from GPU thread %d\n", threadIdx.x);
}

int main() {
    // 启动核函数,使用1个block,每个block里10个线程
    hello_from_gpu<<<1, 10>>>();
    
    // 等待GPU执行完成
    cudaDeviceSynchronize();
    
    return 0;
}

这里有个细节,我建议你注意:核函数的调用是异步的。也就是说,CPU发出调用指令后,不会等待GPU执行完,而是立即继续执行后面的代码。所以,如果你需要等待GPU完成,必须显式调用cudaDeviceSynchronize()

避坑指南:我曾经在项目中忘记加cudaDeviceSynchronize(),结果CPU端直接读取了GPU还没算完的数据。嗯,那天的bug排查了整整一个下午。记住:核函数调用后,如果需要使用结果,一定要同步!

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

这是CUDA编程模型中最精妙的设计之一。GPU的线程不是乱糟糟的一团,而是有清晰的层次结构。

层次 说明 类比
Grid(网格) 一个核函数启动的所有线程 整个工厂
Block(线程块) 一组可以协作的线程 车间
Thread(线程) 最小的执行单元 工人

你想想看,为什么要有这种层次结构?

原因很简单:GPU的硬件资源是有限的。一个Block内的线程可以共享内存(Shared Memory),也可以同步(__syncthreads())。但不同Block之间的线程,是完全独立的,无法直接通信。

我个人习惯把Block看作一个「工作组」。组内的线程可以互相帮忙,组与组之间各干各的。这种设计,说白了就是为了让GPU能高效地调度成千上万个线程。

2.4 <<< >>> 语法:启动核函数的钥匙

这个三括号语法,是CUDA特有的。它用来指定核函数启动时的配置参数。

kernel_name<<<grid_dim, block_dim>>>(args...);
  • grid_dim:网格的维度,即有多少个Block。可以是整数或dim3类型。
  • block_dim:每个Block的维度,即有多少个Thread。同样可以是整数或dim3类型。

举个例子:

// 一维网格,一维Block
my_kernel<<<32, 256>>>(data);

// 二维网格,二维Block
dim3 grid_dim(4, 4);
dim3 block_dim(16, 16);
my_kernel<<<grid_dim, block_dim>>>(data);

这里有个重要的限制:每个Block的线程数不能超过1024(对于现代GPU)。这是硬件决定的。我记得有一次,一个同事写了个<<<1, 2048>>>,结果程序直接崩溃。嗯,这个坑我帮你踩过了。

我的建议:Block的线程数最好选32的倍数(比如128、256、512)。因为GPU的warp大小是32,这样能充分利用硬件资源。我一般用256,既不会太少导致资源浪费,也不会太多导致寄存器压力过大。

2.5 线程索引:如何定位每个线程

每个线程都有自己唯一的ID。CUDA提供了几个内置变量来获取这个ID:

  • threadIdx.x:线程在Block内的索引
  • blockIdx.x:Block在Grid内的索引
  • blockDim.x:每个Block的线程数
  • gridDim.x:每个Grid的Block数

要计算全局唯一的线程ID,公式是:

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

这个公式,说白了就是:先找到你所在的Block的起始位置,再加上你在Block内的偏移量。

看一个完整的例子:

__global__ void vector_add(float *a, float *b, float *c, int n) {
    int tid = blockIdx.x * blockDim.x + threadIdx.x;
    
    if (tid < n) {
        c[tid] = a[tid] + b[tid];
    }
}

int main() {
    int n = 1000000;
    int block_size = 256;
    int grid_size = (n + block_size - 1) / block_size;
    
    // ... 分配内存、拷贝数据 ...
    
    vector_add<<<grid_size, block_size>>>(d_a, d_b, d_c, n);
    
    // ... 拷贝结果、释放内存 ...
    
    return 0;
}

注意那个if (tid < n)的判断。为什么需要它?因为数据量不一定正好是Block大小的整数倍。多出来的线程,必须被「关掉」,否则会访问越界内存。

关键点:永远记得做边界检查!这是CUDA编程中最常见的bug来源之一。我见过太多人因为忘记这个判断,导致程序在数据量不是Block大小整数倍时崩溃。

2.6 小结

这一章,我们聊了CUDA编程模型的几个核心概念:

  • 主机与设备的分工
  • 核函数的定义与调用
  • Grid-Block-Thread的三层线程层次结构
  • <<< >>>语法的使用
  • 线程索引的计算方法

这些概念,是后续所有CUDA优化的基础。你想想看,不理解线程怎么组织,怎么谈优化?下一章,我们会深入内存层次结构,看看怎么用好全局内存、共享内存和寄存器。

嗯,先消化这些吧。有问题随时问我。