CUDA 线程层级:grid → block → thread → warp

第 2 课 · 在 A100 上跑出你的第一个 kernel,让每个线程报出"我是谁"

这一课的唯一目标(tangible win)
学完后,你能在 A100 上编译并运行一个 CUDA kernel,让每个线程打印自己的 block/thread 坐标,并亲手用 blockIdx.x * blockDim.x + threadIdx.x 算出它在整个 grid 中的全局索引。 这是后面写 vector add、tiled matmul、直到读懂 FlashAttention kernel 的地基。第 1 课讲的三层抽象栈中,本课聚焦最底层 CUDA 的线程模型

你已经熟练 C/C++。好消息:CUDA C 的"语言"部分小得惊人 —— 真正要换的是心智模型。 普通 C 函数你从"一个执行流"的视角写;CUDA kernel 你从单个线程的视角写一段代码, 然后硬件把它复制成成千上万份同时跑。这一课就专攻这个视角切换。

1. 从 C 函数到 kernel:__global__<<< >>>

把一个普通函数变成在 GPU 上跑的 kernel,只需两件事:

普通调用 f(x) 跑一次;f<<<2,8>>>(x) 启动 2×8 = 16 个线程,每个都把同一段 kernel 代码跑一遍。

2. 三层层级:grid 装 block,block 装 thread

CUDA 把并行计算组织成三层。每次 kernel 启动创建恰好一个 grid;grid 由多个 block 组成(同一 grid 内所有 block 大小相同);每个 block 由若干 thread 组成。

grid(一次启动 · 2 个 block × 每 block 8 线程)
block 0
0
1
2
3
4
5
6
7
global 0–7
block 1
0
1
2
3
4
5
6
7
global 8–15

方块里的数字是 threadIdx.x(block 内编号)。高亮的是 block 1 的 thread 3 —— 它的全局索引是多少?(第 4 节揭晓)

硬件落点
一个 block 整体在单个 SM(流多处理器)上执行,不能迁移;一个 SM 可同时跑多个 block。block 之间必须能独立、任意顺序执行 —— 正是这种独立性,让同一份程序在 SM 更多的 GPU 上自动跑得更快。2
A100 上限
每个 block 最多 1024 个线程2 ⚠️ 别和"每个 SM 最多驻留线程数"混淆 —— A100 每个 SM 最多 2048 个,且可同时驻留多个 block。

3. 每个线程怎么知道"我是谁":四个内建变量

kernel 里有四个内建变量,每个线程都能读到,用来定位自己:

变量含义同一 block 内各线程
threadIdx线程在它所属 block 内的索引不同
blockIdxblock 在 grid 内的索引相同
blockDim一个 block 里的线程数相同
gridDimgrid 里的 block 数相同

四个变量都有 .x / .y / .z 三个分量(grid/block 可 1D/2D/3D,用 dim3 指定2)。本课只用 .x

4. 核心公式:全局索引

这是 CUDA 里最惯用的一行代码 —— 务必背下来:

globalIndex = blockIdx.x * blockDim.x + threadIdx.x;

直觉:blockIdx.x * blockDim.x 是"我这个 block 的起点偏移"(第几个 block × 每 block 多少线程),再加 threadIdx.x(我在 block 内的位置)。1 代入上图高亮线程:block 1、blockDim.x=8、threadIdx.x=3 → 1 × 8 + 3 = 11

为什么几乎总要配一个 if (globalIndex < n)
线程数通常不会刚好等于数据量(会向上取整),所以多出来的线程要用边界保护挡住,否则越界写内存。 另外:单 block 时 blockIdx.x==0,公式退化为 threadIdx.x —— 这也说明 <<<1,1>>> 单线程写法只对一个线程正确, 贸然加到很多线程会产生竞态(见下方误区①)。

5. warp:32 个线程一组的硬件执行单位

block 是逻辑分组;硬件上,block 里的线程每 32 个为一组叫 warp3 warp 是 SIMT(单指令多线程)的调度/执行单位:一个 warp 的线程同时执行同一条指令,lane 编号 0–31。 所以 block 线程数最好取 32 的倍数(如 256),否则最后一个 warp 有空 lane 被浪费。

⚠️ A100 细节
自 Volta/A100 起,warp 内线程有各自独立的程序计数器(Independent Thread Scheduling)—— 按 warp 调度,但不再保证严格锁步。别依赖隐式"warp 同步",需要时显式用 __syncwarp/__syncthreads3

这一点你优化 Open-Qwen2VL 时会反复用到:同一 warp 内连续、对齐的全局内存访问能被合并成一次事务(memory coalescing)—— 第 6 课展开。

动手:在 A100 上跑出来 🚀

下面这段程序我已经在你的 A100 上编译运行过(sm_80 / CUDA 11.4),真实输出贴在后面。轮到你复现:

  1. 把代码存成 hello_threads.cu,scp 到远程机:
    # 在本机 $ scp hello_threads.cu user@<workshop-ip>:/tmp/
  2. SSH 上去,用 nvcc 编译(-arch=sm_80 针对 A100 的 compute capability 8.0):
    $ ssh user@<workshop-ip> $ cd /tmp && /usr/local/cuda/bin/nvcc -arch=sm_80 hello_threads.cu -o hello_threads $ ./hello_threads

hello_threads.cu —— 每个线程报出身份并自算全局索引:

#include <cstdio>

// Kernel: 每个线程报出自己的 block/thread 坐标,
// 并用惯用公式 blockIdx.x * blockDim.x + threadIdx.x 算全局索引。
__global__ void report_identity(int n)
{
    int globalIndex = blockIdx.x * blockDim.x + threadIdx.x;
    if (globalIndex < n) {                       // 边界保护
        int lane   = threadIdx.x % warpSize;     // warpSize == 32 (A100)
        int warpId = globalIndex / warpSize;
        printf("block %d | thread-in-block %d | blockDim %d -> globalIndex %2d (warp %d, lane %2d)\n",
               blockIdx.x, threadIdx.x, blockDim.x, globalIndex, warpId, lane);
    }
}

int main()
{
    const int numBlocks       = 2;
    const int threadsPerBlock = 8;
    const int n = numBlocks * threadsPerBlock;   // 16 个工作项

    report_identity<<<numBlocks, threadsPerBlock>>>(n);   // 启动 grid

    // kernel 启动是异步的:必须同步,否则程序退出时
    // device 端 printf 可能还没刷回 host。
    cudaError_t err = cudaDeviceSynchronize();
    if (err != cudaSuccess) {
        printf("CUDA error: %s\n", cudaGetErrorString(err));
        return 1;
    }
    return 0;
}

在 A100 上的真实输出(我跑出来的):

block 1 | thread-in-block 0 | blockDim 8 -> globalIndex 8 (warp 0, lane 0) block 1 | thread-in-block 1 | blockDim 8 -> globalIndex 9 (warp 0, lane 1) block 1 | thread-in-block 2 | blockDim 8 -> globalIndex 10 (warp 0, lane 2) block 1 | thread-in-block 3 | blockDim 8 -> globalIndex 11 (warp 0, lane 3) ... (block 1 的 4–7) ... block 0 | thread-in-block 0 | blockDim 8 -> globalIndex 0 (warp 0, lane 0) ... (block 0 的 1–7) ...
注意一个反直觉的现象 —— 这本身就是一课
block 1 居然先打印,block 0 在后! 因为线程是并行跑的,printf 跨线程无序。 你每次跑顺序可能都不同。正确性不看顺序,而看:0–15 每个全局索引恰好出现一次,且 globalIndex == blockIdx×8 + threadIdx 永远成立。
试试:把 threadsPerBlock 改成 40,重新编译运行,观察 warp/lane 列怎么变(提示:40 > 32,会跨进 warp 1)。把你看到的贴给我,我们一起读。

检验:凭记忆作答(别翻上文)

回忆比重读更能形成长期记忆。先盖住上面,凭脑子答完再看解析。

常见误区(核验时专门标注)

误区 ① <<<1,1>>> 能跑通,换成很多线程也一定对
不对。单线程 kernel 往往写成"处理整个数组";若让很多线程都跑这段逻辑,它们会同时读写同一批位置,产生竞态(race condition)。并行 kernel 必须用全局索引让每个线程只处理自己那一份数据。
误区 ② 每 block 1024,所以每个 SM 也最多 1024 线程
两个不同的量。每 block 上限 1024 是语言/架构限制;每个 SM 的最大驻留线程数更高(A100 为 2048),且一个 SM 可同时驻留多个 block。
误区 ③ kernel 调用返回后,GPU 就算完了
kernel 启动异步,调用会在 GPU 完成前就返回 host。必须 cudaDeviceSynchronize() 等待,否则可能读到未算完的结果,device 端 printf 也可能没刷出来。
误区 ④ host 指针和 device 指针可以混用
host 与 device 是各自独立的内存空间。普通 malloc 的指针不能在 kernel 里解引用;要 cudaMalloc+cudaMemcpy 显式搬运,或用 cudaMallocManaged 分配 Unified 内存。(这是后续课程的主题。)

📖 首选精读(primary source)

NVIDIA: An Even Easier Introduction to CUDA

官方入门文,从单线程 kernel 一步步走到多 block 的 grid-stride loop,本课所有概念(__global__<<<>>>、全局索引、同步)都有可对照的可运行代码。读它,把本课的 hello_threads.cu 对照着看。

→ developer.nvidia.com/blog/even-easier-introduction-cuda

引用与延伸

  1. NVIDIA, An Even Easier Introduction to CUDA__global__、执行配置、全局索引公式。
  2. NVIDIA, CUDA Refresher: The CUDA Programming Model — 三层层级、1024 上限、3D 内建变量、block 独立执行。
  3. Simon Boehm, How to Optimize a CUDA Matmul Kernel — warp=32 与 SIMT、Volta+ 独立线程调度、coalescing。
💬 我是你的 CUDA 老师 —— 随时追问
哪里没讲透?想多看几个例子、把某段代码改了一起跑、或想知道这跟 FlashAttention/Open-Qwen2VL 怎么挂钩 —— 直接问我。卡住时问,比自己硬磕更高效。