第3章:CUDA编程模型基础——线程、块、网格与核函数

CUDA编程模型说白了就是教你怎么把手里的并行任务切成小份,然后扔到GPU上几千个核心去跑。我第一次接触这个概念时也有点懵——线程、块、网格,三层结构到底怎么用?别急,我们一步步拆开看。

3.1 线程、块、网格——GPU的“三级组织架构”

我们先从硬件角度想:GPU里动辄几千个CUDA核心,你要怎么调度它们?线程是最小的执行单元,每个线程跑同一段代码(核函数),但处理的数据不同。就像工厂里每个工人各干各的活。光有线程还不够——几千个线程如果散乱无章,管理起来太麻烦。所以CUDA引入了线程块的概念:你把线程分成小组,比如256个线程一组,组内可以共享数据、同步执行。这叫块。最后,这些块再组成一个网格,整个网格就是一次核函数调用覆盖的全部线程。

我个人习惯把这三级结构想象成部队:网格是整个战役,块是连队,线程就是每个士兵。士兵只能在同一连队里互相支援(共享内存、同步),连队之间则独立作战。

核心关系总结:

  • 线程(thread):最小执行单元,每个线程有自己唯一的索引。
  • 块(block):一组线程,可共享内存,可同步(__syncthreads())。
  • 网格(grid):所有块的集合,一次核函数启动覆盖整个网格。

你可能会问:为什么要有块这个中间层?说白了,这是为了在硬件上高效映射。GPU的SM(流多处理器)可以同时执行多个块,但每个SM内的共享内存和寄存器是有限的。块太大可能塞不进一个SM,块太小又浪费资源。我经常在调优时去调整块大小,一个常用的起步值是256个线程每块。

CUDA 线程层次结构 网格(Grid) 由多个线程块组成,一次核函数调用对应一个网格 块0 blockIdx.x = 0 块1 blockIdx.x = 1 块2 blockIdx.x = 2 线程0 threadIdx.x=0 线程1 threadIdx.x=1 ... ... ... 每个块可包含 最多1024个线程

3.2 核函数——在GPU上运行的代码

核函数(kernel)就是你要让GPU执行的代码。它跟普通C函数长得很像,但有几个硬性要求:必须用 __global__ 修饰,返回值必须是 void,不能是类的成员函数。每个线程都执行同样的核函数代码——但通过内置变量 threadIdx、blockIdx 和 blockDim 来区分谁处理哪部分数据。

举个例子,我要把两个数组X和Y逐元素相加:

// 核函数定义
__global__ void vecAdd(float *X, float *Y, float *Z, int n) {
    // 计算全局线程索引
    int idx = threadIdx.x + blockIdx.x * blockDim.x;
    // 边界检查
    if (idx < n) {
        Z[idx] = X[idx] + Y[idx];
    }
}

注意这里每个线程只处理一个元素。这叫做“线程到数据的映射”。我在项目中见过有人忘了做边界检查,结果数组大小不是块大小的整数倍时,线程跑越界,读到了垃圾数据,整加结果偏掉——嗯,排查了好久才找到这个坑。

3.3 <<<>>> 语法——启动核函数的“开关”

在主机端(CPU)调用核函数时,要用一个特殊的语法:

vecAdd<<>>(d_X, d_Y, d_Z, N);

<<>> 就是告诉CUDA运行时:你开一个网格,网格里有 gridDim 个块,每个块里有 blockDim 个线程。这两个可以是整数(一维),也可以是 dim3 类型(二维、三维)。

比如:

// 一维:256个线程一维,共100个块
vecAdd<<<100, 256>>>(...);

// 二维:16x16的线程块,8x8个块
dim3 blockDim(16, 16);
dim3 gridDim(8, 8);
kernel<<>>(...);

小经验:我建议初期先用一维的dim3,把问题想清楚再上多维。多维常用于图像处理(每个像素用线程块处理一个子区域)。另外,块内线程数建议选32的倍数(warp大小),比如128、256、512。选256一般是个不错的起点,兼顾占用率和资源利用率。

3.4 内置变量——线程如何“认路”

每个线程在核函数内部可以访问一些只读变量,用来计算自己的唯一ID:

变量含义示例值(一维)
threadIdx.x线程在本块内的x索引0 ~ (blockDim.x - 1)
blockIdx.x本块在网格内的x索引0 ~ (gridDim.x - 1)
blockDim.x每个块在x维度的线程数由 <<<>>> 指定
gridDim.x网格在x维度的块数由 <<<>>> 指定

计算全局线程ID的经典公式:

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

如果是二维块和二维网格:

int globalIdx_x = threadIdx.x + blockIdx.x * blockDim.x;
int globalIdx_y = threadIdx.y + blockIdx.y * blockDim.y;
int globalIdx = globalIdx_y * (gridDim.x * blockDim.x) + globalIdx_x;

我最开始总弄混 blockIdx 和 threadIdx 的维度顺序。有个简单记忆法:先想块内的线程(threadIdx),再想网格里的块(blockIdx)。公式里就是 threadIdx + blockIdx * blockDim,跟矩阵索引很像。

曾经踩过的坑:我在做一个流体模拟的项目时,核函数里直接用 threadIdx.x 当全局ID,忘了加 blockIdx.x * blockDim.x。结果所有块内的线程都在处理同样的数据范围,数据没更新到位,仿真结果一直不对。排查了半个下午才发现——哎,每次写核函数我都反复检查这个公式。

3.5 完整示例——向量加法一步到位

下面是一个完整的向量加法代码框架,包括主机端内存分配、数据拷贝、核函数启动和结果收回:

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

// 核函数:Y = X + Y(就地加法)
__global__ void vecAdd(float *X, float *Y, int n) {
    int idx = threadIdx.x + blockIdx.x * blockDim.x;
    if (idx < n) {
        Y[idx] = X[idx] + Y[idx];
    }
}

int main() {
    int n = 1 << 20;        // 约100万个元素
    size_t bytes = n * sizeof(float);

    // 分配主机内存
    float *h_X = (float*)malloc(bytes);
    float *h_Y = (float*)malloc(bytes);
    // 初始化数据(略)

    // 分配设备内存
    float *d_X, *d_Y;
    cudaMalloc(&d_X, bytes);
    cudaMalloc(&d_Y, bytes);

    // 拷贝到设备
    cudaMemcpy(d_X, h_X, bytes, cudaMemcpyHostToDevice);
    cudaMemcpy(d_Y, h_Y, bytes, cudaMemcpyHostToDevice);

    // 定义网格和块
    int threads_per_block = 256;
    int blocks_per_grid = (n + threads_per_block - 1) / threads_per_block;

    // 启动核函数
    vecAdd<<<blocks_per_grid, threads_per_block>>>(d_X, d_Y, n);

    // 同步并检查错误
    cudaDeviceSynchronize();

    // 拷回结果
    cudaMemcpy(h_Y, d_Y, bytes, cudaMemcpyDeviceToHost);

    // 清理
    cudaFree(d_X); cudaFree(d_Y);
    free(h_X); free(h_Y);
    return 0;
}

这个 blocks_per_grid 的计算方式是经典写法:向上取整 + 边界检查。为什么不用 n / threads_per_block?万一 n 不是256的整数倍,少开的块就会漏掉尾部元素,所以一定要 (n + threads - 1) / threads。而且核函数里要有 if (idx < n) 兜底。

3.6 两个容易忽视的细节

  1. 网格和块的最大尺寸:每个维度的 blockDim 最大1024,网格 gridDim 最大 2^31-1(实际上受显卡限制)。块内线程总数不能超过1024。如果你设了 blockDim(32, 32),那就是1024个线程,刚刚好。
  2. 同步问题:不同块之间的线程无法直接同步,只能通过核函数结束来隐含同步。块内的线程可以用 __syncthreads() 同步,但必须保证所有线程都执行到这条语句,否则会死锁。我有一次在条件分支里用 __syncthreads() 没处理好,程序直接卡住,从此养成了“分支内慎用同步”的习惯。

一句话总结本章:

CUDA编程模型就是网格套块,块套线程,每个线程通过 threadId + blockId * blockDim 找到自己的数据,核函数用 <<>> 启动。边界检查不能省,块内线程数要选好。按照这个思路,你已经可以写出大多数基础CUDA程序了。