第 2 课 · 在 A100 上跑出你的第一个 kernel,让每个线程报出"我是谁"
blockIdx.x * blockDim.x + threadIdx.x 算出它在整个 grid 中的全局索引。
这是后面写 vector add、tiled matmul、直到读懂 FlashAttention kernel 的地基。第 1 课讲的三层抽象栈中,本课聚焦最底层 CUDA 的线程模型。
你已经熟练 C/C++。好消息:CUDA C 的"语言"部分小得惊人 —— 真正要换的是心智模型。 普通 C 函数你从"一个执行流"的视角写;CUDA kernel 你从单个线程的视角写一段代码, 然后硬件把它复制成成千上万份同时跑。这一课就专攻这个视角切换。
__global__ 与 <<< >>>把一个普通函数变成在 GPU 上跑的 kernel,只需两件事:
__global__ —— 它就成了 kernel:device(GPU)执行、host(CPU)可调用、返回类型必须 void。1kernel<<<numBlocks, threadsPerBlock>>>(args)。第一个参数是 grid 里的 block 数,第二个是每个 block 的线程数。1
普通调用 f(x) 跑一次;f<<<2,8>>>(x) 启动 2×8 = 16 个线程,每个都把同一段 kernel 代码跑一遍。
CUDA 把并行计算组织成三层。每次 kernel 启动创建恰好一个 grid;grid 由多个 block 组成(同一 grid 内所有 block 大小相同);每个 block 由若干 thread 组成。
方块里的数字是 threadIdx.x(block 内编号)。高亮的是 block 1 的 thread 3 —— 它的全局索引是多少?(第 4 节揭晓)
kernel 里有四个内建变量,每个线程都能读到,用来定位自己:
| 变量 | 含义 | 同一 block 内各线程 |
|---|---|---|
threadIdx | 线程在它所属 block 内的索引 | 不同 |
blockIdx | block 在 grid 内的索引 | 相同 |
blockDim | 一个 block 里的线程数 | 相同 |
gridDim | grid 里的 block 数 | 相同 |
四个变量都有 .x / .y / .z 三个分量(grid/block 可 1D/2D/3D,用 dim3 指定2)。本课只用 .x。
这是 CUDA 里最惯用的一行代码 —— 务必背下来:
直觉: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)blockIdx.x==0,公式退化为 threadIdx.x —— 这也说明 <<<1,1>>> 单线程写法只对一个线程正确,
贸然加到很多线程会产生竞态(见下方误区①)。
block 是逻辑分组;硬件上,block 里的线程每 32 个为一组叫 warp。3 warp 是 SIMT(单指令多线程)的调度/执行单位:一个 warp 的线程同时执行同一条指令,lane 编号 0–31。 所以 block 线程数最好取 32 的倍数(如 256),否则最后一个 warp 有空 lane 被浪费。
__syncwarp/__syncthreads。3这一点你优化 Open-Qwen2VL 时会反复用到:同一 warp 内连续、对齐的全局内存访问能被合并成一次事务(memory coalescing)—— 第 6 课展开。
下面这段程序我已经在你的 A100 上编译运行过(sm_80 / CUDA 11.4),真实输出贴在后面。轮到你复现:
hello_threads.cu,scp 到远程机:
nvcc 编译(-arch=sm_80 针对 A100 的 compute capability 8.0):
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 上的真实输出(我跑出来的):
printf 跨线程无序。
你每次跑顺序可能都不同。正确性不看顺序,而看:0–15 每个全局索引恰好出现一次,且 globalIndex == blockIdx×8 + threadIdx 永远成立。
threadsPerBlock 改成 40,重新编译运行,观察 warp/lane 列怎么变(提示:40 > 32,会跨进 warp 1)。把你看到的贴给我,我们一起读。
回忆比重读更能形成长期记忆。先盖住上面,凭脑子答完再看解析。
<<<1,1>>> 能跑通,换成很多线程也一定对cudaDeviceSynchronize() 等待,否则可能读到未算完的结果,device 端 printf 也可能没刷出来。
malloc 的指针不能在 kernel 里解引用;要 cudaMalloc+cudaMemcpy 显式搬运,或用 cudaMallocManaged 分配 Unified 内存。(这是后续课程的主题。)
官方入门文,从单线程 kernel 一步步走到多 block 的 grid-stride loop,本课所有概念(__global__、<<<>>>、全局索引、同步)都有可对照的可运行代码。读它,把本课的 hello_threads.cu 对照着看。
__global__、执行配置、全局索引公式。