CUDA 算子工程:手写 FlashAttention v2 之路
第 3 章 CUDA 编程模型七层
第 2 章讲的是硬件长什么样,这一章讲的是 CUDA 允许你怎么去指挥它。 两者不是一一对应的:每一层抽象都对应一种"协作的粒度",而不是一块具体的硅。
3.1 为什么需要七层抽象
第 2 章我们看到了 Hopper 硬件的精确结构——132 SM、每 SM 4 Sub-Core、每 Sub-Core 32 FP32 cores 等等。但程序员不会直接编程到这些硬件单元上。CUDA 在硬件之上铺了一层编程模型抽象,把硬件的复杂性藏起来,给程序员一个相对干净的并行 API。
这一层抽象一共有七级:
flowchart TB Host[Host · CPU 端] Device[Device · GPU 整卡] Grid[Grid · 一次 kernel launch 的所有线程] Cluster[Cluster · 一组协作的 Block · Hopper 新增] Block[Block · 一组共享 SMEM 的线程] Warp[Warp · 32 线程的硬件调度组] Thread[Thread · 单线程标量代码] Host -->|cudaMalloc, kernel<<<...>>>| Device Device -->|kernel 启动| Grid Grid -->|可选| Cluster Cluster --> Block Block --> Warp Warp --> Thread
为什么要这么多层?答案是:每一层都对应一种"协作的粒度"。
- Thread 层:你写普通的标量代码(局部变量、算术、判断、循环)。
- Warp 层:32 线程同步执行,可用 warp shuffle 做无 SMEM 的快速通信(
__shfl_sync/__shfl_xor_sync/__shfl_down_sync)。 - Block 层:上千线程共享 SMEM,可用
__syncthreads同步。 - Cluster 层(Hopper 新增):多 Block 之间通过分布式共享内存协作。
- Grid 层:一次 kernel launch 的所有线程。Grid 内不同 Block 默认无法直接通信,只能通过 atomic 或多次 launch。
- Device 层:整张 GPU 卡,host 通过 device API(cudaMalloc、cudaMemcpy)操作。
- Host 层:CPU 上的代码,负责数据准备、kernel launch、与其他系统组件交互。
不同算子在不同层次上做协作。比如 LayerNorm 主要在 Block 层协作(一个 token 的所有维度由一个 Block 处理);GEMM 在 Block 层分 tile、在 Warp 层做矩阵乘(Hopper 上一个 warp-group 协作发出 WGMMA);分布式 GEMM 跨 Device 协作(多卡 NCCL 通信)。
下面把每一层从下往上展开。
3.2 Thread 层:你写的代码看起来是单线程的
CUDA C++ 写 kernel 时,函数体内的代码看起来就是一段普通的 C++ 单线程代码:
__global__ void simple_kernel(float* arr, int N) {
int tid = threadIdx.x + blockIdx.x * blockDim.x;
if (tid < N) {
arr[tid] = arr[tid] * 2.0f + 1.0f;
}
}
这段代码每个线程独立执行一遍。每个线程通过 threadIdx、blockIdx、blockDim、gridDim 这几个内置变量知道"自己是谁"。每个线程有:
- 私有寄存器:局部变量(如上面的
tid)默认存在寄存器里。 - 私有的 PC(程序计数器):自 Volta 起每个线程有独立的 PC(Independent Thread Scheduling)。
- 共享的 SMEM 与 HBM:和其他线程协作的载体。
线程层的关键性能直觉:
- 不要假设线程间有任何顺序:线程 0 不一定比线程 1 先执行任何指令。如果你需要顺序,必须用同步原语。
- 标量局部变量通常就是寄存器:不必担心它们的"开销";但寄存器用量超出上限会溢出(spill)到 local memory,按运行时下标访问的局部数组也会被放进 local memory,那才有真实代价(第 4 章)。
- 避免线程间的"个性化分支":如第 1 章讲的,warp 内两条分支各占一半线程时,这段代码的吞吐直接减半。
3.3 Warp 层:32 线程的同步乐队
每 32 个连续线程组成一个 warp。warp 是 SIMT 模型的真正调度单位——硬件给一个 warp 发同一条指令。
CUDA 在 warp 层暴露的关键 API:
3.3.1 Warp Shuffle:寄存器间的零开销通信
__shfl_* 系列指令允许一个 warp 内 32 个线程直接交换寄存器值,不经过 SMEM:
// 把 lane 0 的值广播到 warp 内所有 32 个 lane
int val = my_value;
val = __shfl_sync(0xFFFFFFFF, val, 0);
// 把每个 lane 的值传给它的"对偶 lane"(XOR 模式)
val = __shfl_xor_sync(0xFFFFFFFF, val, 16);
// 树形归约
for (int offset = 16; offset > 0; offset >>= 1) {
val += __shfl_xor_sync(0xFFFFFFFF, val, offset);
}
// 此时所有 lane 都有 warp 内 32 个值的和
第一个参数 0xFFFFFFFF 是 mask,表示 32 个 lane 全部参与(如果某些 lane 因为 divergence 不活跃,需要相应调整 mask)。
Warp shuffle 的优势不在"单条指令延迟更低"——SHFL 走的是和 SMEM 相近的数据通路,延迟量级是可比的。它赢在别处:
经 SMEM 交换一次: 一条 store + 一条 load + 一次 __syncthreads, 还占掉 SMEM 容量
Warp shuffle: 一条 SHFL 指令, 不占 SMEM 容量, 不需要 block 级同步, 也没有 bank conflict
这是为什么 reduce 算法在 warp 内一定要用 shuffle 而不是 SMEM。第 5 章会有完整的 warp-level reduce 实现。
3.3.2 Warp Vote:32 票快速决策
__all_sync / __any_sync / __ballot_sync 让 32 个 lane 快速投票:
int my_val = ...;
// 1) all: 是否所有 lane 的值都非零
int all_nonzero = __all_sync(0xFFFFFFFF, my_val != 0);
// 2) any: 是否有任意 lane 的值非零
int any_nonzero = __any_sync(0xFFFFFFFF, my_val != 0);
// 3) ballot: 收集每个 lane 的布尔值到一个 32-bit 整数
unsigned mask = __ballot_sync(0xFFFFFFFF, my_val > 0);
// mask 的第 i 位 = (lane i 的 my_val > 0)
int count = __popc(mask); // 多少个 lane 满足条件
这些指令在以下场景非常有用:
- 稀疏 attention:只对满足条件的 token 做计算,先用 ballot 收集 mask。
- 动态 batch:判断当前 warp 是否有任何线程还有活要做。
- 变长输入:每个 lane 处理不同长度的序列,用 ballot 决定提前退出哪些 lane。
3.3.3 Cooperative Groups:现代化的 warp API
CUDA 9 引入的 Cooperative Groups 库提供了更现代的 warp 协作 API:
#include <cooperative_groups.h>
#include <cooperative_groups/reduce.h> // cg::reduce / cg::plus 在这个头里,漏了编译不过
namespace cg = cooperative_groups;
__global__ void kernel() {
auto block = cg::this_thread_block();
auto warp = cg::tiled_partition<32>(block);
int val = ...;
int sum = cg::reduce(warp, val, cg::plus<int>());
if (warp.thread_rank() == 0) {
// 此时 sum 是这个 warp 32 个值的和
}
}
Cooperative Groups 比裸的 __shfl_* 更安全(自动处理 mask)、更可读,但本质做的事是一样的。新代码推荐用 CG,老代码常见 __shfl_*。
3.4 Block 层:SMEM 协作的舞台
一个 Block 是一组 1-1024 个线程的集合,关键约束:
- 同一个 Block 的所有线程必须调度到同一个 SM 上。这是硬件约束,不是软件选择。
- Block 内的线程可以共享 SMEM:声明
__shared__变量即可。 - Block 内可以用
__syncthreads()同步:让所有线程等到同一点再继续。
Block 是 CUDA 编程中最常用的协作粒度。一个典型的 Block-level reduce:
template <int BLOCK_SIZE>
__global__ void block_reduce(const float* arr, float* out, int N) {
__shared__ float smem[BLOCK_SIZE];
int tid = threadIdx.x;
int gid = blockIdx.x * blockDim.x + tid;
// 1. 每线程读一个值到 SMEM
smem[tid] = (gid < N) ? arr[gid] : 0.0f;
__syncthreads();
// 2. block 内分层归约
for (int s = BLOCK_SIZE / 2; s > 0; s >>= 1) {
if (tid < s) smem[tid] += smem[tid + s];
__syncthreads();
}
// 3. 写出 partial sum
if (tid == 0) out[blockIdx.x] = smem[0];
}
Block 大小(block size,又叫 CTA size)的选择是个值得讨论的问题。
3.4.1 怎么选 Block Size
Hopper 的硬件约束:
Block 大小上限: 1024 线程 (32 warps)
SM 上同时活跃线程数上限: 2048 线程 (64 warps)
SM 上同时活跃 Block 数上限: 32 (CC 9.0 硬件上限)
SM 上 SMEM 总: 228 KB
SM 上寄存器总: 256 KB (65536 个 32-bit)
选 Block Size 时要平衡多个因素:
- Occupancy(占用率):希望 SM 上活跃 warp 数多。block 太大时资源按整块分配,零头容易浪费;block 太小又会先撞上每 SM 32 个 block 的上限(32 线程的 block 最多凑出 32 个 warp,只有 50% occupancy,至少 64 线程才可能满 64 warp)。
- 协作效率:block 太小(比如 32)时 block 级协作退化成 warp 内协作,SMEM 协作能力用不上。
- SMEM 容量:如果一个 block 用了 80 KB SMEM(加上 CUDA 每 block 预留的 1 KB 即 81 KB),228 KB 的 SM 只能放 2 个 block,occupancy 受限。
- 寄存器压力:每线程寄存器用量越多,能 active 的线程越少。
常用的 block size 经验值:
| 场景 | 推荐 Block Size | 理由 |
|---|---|---|
| Element-wise / 简单 reduction | 256 / 512 | 高 occupancy,不需要太多 SMEM |
| LayerNorm / Softmax | 256 / 512 / 1024 | 一个 block 处理一行/一个 token |
| GEMM (Tiled) | 128 / 256 | tile 内一组 warp 协作 |
| FA2 Forward | 128 (4 warps × 32);hdim 192/256 部分配置用 256 | 4 个 warp 沿行分摊一个 query block(csrc/flash_attn/src/flash_fwd_launch_template.h) |
Block size 没有"最优值"——必须在具体 kernel 上 profile。但最常见的是 128 / 256 / 512 这几档。
3.4.2 SMEM 的两种声明方式
// 1. 静态 SMEM (大小编译期已知)
__shared__ float smem_static[1024];
// 2. 动态 SMEM (大小运行时确定)
extern __shared__ float smem_dynamic[];
// host 端 launch:
int dynamic_smem_size = 1024 * 4; // 1024 floats = 4 KB
my_kernel<<<grid, block, dynamic_smem_size>>>(...);
动态 SMEM 适合 tile 大小可调的算子(比如 GEMM 不同输入需要不同 tile size)。Hopper 上动态 SMEM 上限默认 48 KB,超过需要用 cudaFuncSetAttribute opt-in——注意每 block 的上限是 227 KB(每 SM 的 228 KB 减去 CUDA 预留的 1 KB),写 228 KB 会直接失败:
cudaFuncSetAttribute(my_kernel,
cudaFuncAttributeMaxDynamicSharedMemorySize,
227 * 1024); // Hopper 每 block 上限;用 prop.sharedMemPerBlockOptin 查更稳
第 11 章手写 GEMM 时会用到这一点。
3.4.3 __syncthreads() 的代价
__syncthreads() 让 block 内所有线程等到同一个点。屏障指令本身不贵,真正的代价是等待:block 里最慢的那个 warp 到达之前,其余 warp 都停在屏障前不能发射后续指令。
一个常见的低效写法:单 buffer,每轮两次同步,加载和计算完全串行:
// 低效:两次 sync 都省不掉(第二次防止下一轮覆盖还在被读的 smem),但加载与计算串行
for (int i = 0; i < N; i += BLOCK_SIZE) {
smem[tid] = arr[i + tid];
__syncthreads();
/* compute on smem */
__syncthreads();
}
更好的写法是用软件流水线(software pipelining)让计算和加载重叠:
// 加载 buffer 0
smem[0][tid] = arr[0 * BLOCK_SIZE + tid];
__syncthreads();
for (int i = 1; i < N / BLOCK_SIZE; ++i) {
int cur = (i - 1) % 2;
int next = i % 2;
float v = arr[i * BLOCK_SIZE + tid]; // 先发出下一块的全局加载(落到寄存器)
/* compute on smem[cur] */ // 加载在途时算当前 buffer
smem[next][tid] = v; // 算完再写进 next buffer
__syncthreads();
}
/* compute on smem[(N / BLOCK_SIZE - 1) % 2] */
注意循环体里的顺序。如果直接写 smem[next][tid] = arr[...],写 SMEM 的指令要等全局加载返回才能发射,这个 warp 的计算排在它后面,谈不上重叠(nvcc 13.4 编到 sm_90a,SASS 里 STS 紧跟在 LDG 之后、先于计算用的 LDS/FFMA,不同 CUDA 版本可能不同)。先读进寄存器、算完再写 SMEM,正是 CUTLASS include/cutlass/gemm/threadblock/mma_pipelined.h 的寄存器中转做法。
这是 Tiled GEMM 的核心模式。第 11 章会反复用到,并换成 Ampere 起的 cp.async,由硬件把数据异步搬进 SMEM,连寄存器中转都省了。
3.5 Cluster 层:Hopper 引入的 Block 协作
Cluster 是 Hopper 引入的新一层抽象。Pre-Hopper 代码可以完全忽略它(兼容性默认 cluster_size=1),但 LLM 算子在 Hopper 上越来越多地利用它。
3.5.1 启用 Cluster
CUDA 提供两种方式声明 Cluster 大小:
// 1. 静态声明 (编译期固定)
__global__ void __cluster_dims__(2, 2, 1) my_kernel(...) {
// 这个 kernel 的 cluster 是 2×2×1 = 4 个 block
}
// 2. 动态配置 (host 启动时指定)
// (这种方式下 kernel 不能再带 __cluster_dims__)
cudaLaunchConfig_t config = {};
config.gridDim = grid; // 各维须是 cluster 对应维的整数倍
config.blockDim = block;
config.dynamicSmemBytes = smem_size;
config.stream = stream;
cudaLaunchAttribute attrs[1]; // 必须活到 cudaLaunchKernelEx 调用之后
attrs[0].id = cudaLaunchAttributeClusterDimension;
attrs[0].val.clusterDim.x = 2;
attrs[0].val.clusterDim.y = 2;
attrs[0].val.clusterDim.z = 1;
config.attrs = attrs;
config.numAttrs = 1;
cudaLaunchKernelEx(&config, my_kernel, args...);
CUDA 保证可移植的 cluster 上限是 8 个 Block;H100 上还可以 opt-in(cudaFuncAttributeNonPortableClusterSizeAllowed)到 16,但这会让 kernel 绑死在特定硬件上。
3.5.2 Cluster 内 API
Cluster 内的 Block 通过 Cooperative Groups 的 cluster_group 协作:
__global__ void cluster_kernel() {
cooperative_groups::cluster_group cluster =
cooperative_groups::this_cluster();
unsigned cluster_size = cluster.num_blocks(); // 总 block 数(dim_blocks() 返回的是 dim3)
unsigned block_rank = cluster.block_rank(); // 当前 block 在 cluster 内的 ID
// 关键: 跨 block 访问 SMEM
extern __shared__ float smem[];
float* peer_smem = cluster.map_shared_rank(smem, target_block_rank);
// 现在 peer_smem 指向另一个 block 的 SMEM, 可直接读写
}
map_shared_rank 是 Cluster 的核心:它把当前 block 的 SMEM 指针重映射到另一个 block 的 SMEM。对映射后地址的读写经 GPC 内专用的 SM-to-SM Network 完成,数据不必绕道 L2,延迟因此显著更低——这正是 DSMEM 的全部价值所在(本专栏没有条件实测具体周期数,§4.1 那张延迟表给的是各层的量级)。
3.5.3 Cluster 的同步
Cluster 内的 block 同步用 cluster.sync():
__global__ void cluster_reduce(float* arr, float* out, int N) {
auto cluster = cooperative_groups::this_cluster();
extern __shared__ float smem[];
int tid = threadIdx.x;
// 1. 每 block 算自己的 partial sum
// 注意 blockIdx 仍然是 grid 全局编号(cluster 不会把它重新编号),
// 所以全局下标照常写;cluster 内的位置才用 block_rank()。
int gid = blockIdx.x * blockDim.x + tid;
/* ... block-level reduce 到 smem[0] ... */
__syncthreads();
// 2. **先等所有 block 都把 partial sum 写进自己的 smem[0]**。
// 这一步不能省:cluster.sync() 之前去读 peer 的 SMEM,读到的是未定义值。
cluster.sync();
// 3. 跨 block 收集 (block 0 收集所有 partial sum)
if (cluster.block_rank() == 0 && tid == 0) {
float total = smem[0];
for (int i = 1; i < cluster.num_blocks(); ++i) {
float* peer = cluster.map_shared_rank(smem, i);
total += peer[0];
}
out[blockIdx.x / cluster.num_blocks()] = total; // 一个 cluster 出一个结果
}
// 4. 退出前再同步一次:只要还有 block 在读我的 SMEM,我就不能退出。
cluster.sync();
}
两次 cluster.sync() 一次都不能少:前一次保证被读的数据已经写好,后一次保证被读的 SMEM 在读完之前不会随着 block 退出而失效。漏掉任何一次都是竞态——而且在小 cluster 上大概率"跑起来是对的",等换个规模才炸。
这种"Cluster reduce"省掉的是第二次 kernel launch(单次 launch 开销在微秒量级)以及中间数组的一次 HBM 往返。数据量越小、kernel 本身越短,这部分固定开销占比越高,收益也越明显;数据量一大,它就淹没在 HBM 带宽里了。
3.6 Grid 层:一次 launch 的全部线程
一次 kernel<<<grid, block>>> 启动的所有 block 组成一个 grid。Grid 内的 block 是同伴关系——它们看到一样的全局内存,但默认无法直接通信。
3.6.1 Grid 间通信的几种手段
- 全局 atomic:原子操作在 L2 上执行,同一地址争用激烈时会被串行化,但简单。
- 多次 kernel launch:第一个 kernel 算 partial 结果,第二个 kernel 汇总。每次 launch 在 host 端有几微秒开销。
- CUDA Graph:把多个 kernel 串成一个 graph,整体提交,省去重复 launch 开销。
- Cooperative Launch + grid.sync():少数情况下可以用 grid-level 同步,但要求所有 block 同时驻留,grid 大小不能超过 SM 数 × 每 SM 可驻留 block 数。
LLM 算子里最常见的是 多次 kernel launch——比如 reduce 大数组:先 launch 一个 kernel 算每 block 的 partial,再 launch 一个 kernel 汇总。对 prefill 这类单个 kernel 就要跑几百微秒以上的场景,launch 开销可以忽略;但 decode 阶段每个 kernel 只跑几十微秒,几百次 launch 的开销与有效工作同一量级,这时就要靠 CUDA Graph(附录 A)。
3.6.2 Grid 大小的选择
理论上 grid_size 可以很大(gridDim.x ≤ 2^31 − 1, y/z ≤ 65535)。实践中两种典型策略:
flowchart LR
subgraph Strategy1 [策略 1: One Block per Output]
S1A[每个 Block 处理一个输出 tile]
S1B[grid_size = N_outputs]
S1C[Block 数过多时硬件自动队列]
end
subgraph Strategy2 [策略 2: Persistent Kernel]
S2A[grid_size = SM 数 × 每 SM 驻留 block 数]
S2B[每个 Block 用 grid-stride loop 循环处理多个 tile]
S2C[Block 数固定不变]
end
One Block per Output 是默认的写法,简单直观。Persistent Kernel 是 Hopper 上 CUTLASS、FA3 等高性能 kernel 普遍采用的写法(第 18 章详细讲),优势是:
- 摊薄 Block 派发开销:persistent kernel 用一个 Block 处理多个输出 tile,省下的是反复的 block 派发、资源分配与回收,以及尾部的调度抖动(第 18 章解释为什么不能按"block 数 × 单 block 开销"串行相加)。
- 更好的负载均衡:每个 Block 用 atomic counter 抢下一个 tile,自然均衡。
- 数据复用在 L2:SMEM 只是几个 stage 的环形缓冲,每个 tile 仍要重新载入;可调度的是 tile 的处理顺序,让相邻 tile 共用的数据尽量命中 L2(§18.2.2)。
3.7 Device 层:一张卡
Device 层的 API 是 host 端调用的,关键的有:
// 设备查询
int device_count;
cudaGetDeviceCount(&device_count);
cudaDeviceProp prop;
cudaGetDeviceProperties(&prop, 0);
// prop.multiProcessorCount -> SM 数 (132 for H100 SXM5)
// prop.sharedMemPerBlock -> SMEM 默认上限 (48 KB)
// prop.sharedMemPerBlockOptin -> 每 block 的 SMEM Optin 上限 (227 KB Hopper:
// 每 SM 228 KB,CUDA 每 block 预留 1 KB)
// prop.regsPerBlock -> 寄存器/block 上限
// prop.warpSize -> 32
// 设备选择
cudaSetDevice(0);
// 内存管理
float* d_ptr;
cudaMalloc(&d_ptr, N * sizeof(float));
cudaMemcpy(d_ptr, h_ptr, N * sizeof(float), cudaMemcpyHostToDevice);
cudaFree(d_ptr);
// Stream 与 Event (异步)
cudaStream_t stream;
cudaStreamCreate(&stream);
my_kernel<<<grid, block, 0, stream>>>(...);
cudaEvent_t event;
cudaEventCreate(&event);
cudaEventRecord(event, stream);
cudaEventSynchronize(event);
LLM 推理中最常用的 device API 是 stream(异步执行)和 graph(批量提交)。附录 A 会详细讲。
3.7.1 多 GPU 与 NVLink
Hopper 系统中常见的多 GPU 拓扑:
DGX H100 (8 张 H100):
GPU 0 GPU 1 ... GPU 7
│ ╲ │ ╲ │ ╲ 每张 GPU 的 18 条 NVLink 分别连到
▼ ▼ ▼ ▼ ▼ ▼ 4 颗 NVSwitch 芯片(每颗都连着全部 8 张卡)
[NVSwitch 0] [NVSwitch 1] [NVSwitch 2] [NVSwitch 3]
每张 GPU 到 NVSwitch 结构 900 GB/s 双向 (全互联, 不随对端是谁而变)
NVLink 4 在 H100 SXM5 上每条链路单向 25 GB/s,每张有 18 条链路 = 450 GB/s 单向 / 900 GB/s 双向。按双向口径比,NVLink 4 的 900 GB/s 是 PCIe Gen5 x16(128 GB/s 双向)的 ~7 倍;但仍然远低于 H100 SXM5 HBM3 的 3350 GB/s。所以张量并行(TP)的扩展常受 NVLink 带宽制约——TP 每层都要做跨卡归约,卡数一多,通信很容易成为瓶颈。
跨 GPU 的 CUDA API:
// 显式 P2P 拷贝
cudaMemcpyPeer(dst_ptr, dst_dev, src_ptr, src_dev, size);
// 或 NCCL (推荐)
ncclAllReduce(send_buf, recv_buf, count, ncclFloat, ncclSum, comm, stream);
本专栏是单 GPU 视角,多 GPU 通信的细节请见《vLLM 推理内核深度解析》第 14 章。
3.8 Host 层:把所有东西串起来
Host 端的代码负责:
int main() {
// 1. 准备数据
float* h_a = (float*)malloc(N * sizeof(float));
init_data(h_a, N);
// 2. 上 GPU
float* d_a;
cudaMalloc(&d_a, N * sizeof(float));
cudaMemcpy(d_a, h_a, N * sizeof(float), cudaMemcpyHostToDevice);
// 3. Launch kernel
int block_size = 256;
int grid_size = (N + block_size - 1) / block_size;
my_kernel<<<grid_size, block_size>>>(d_a, N);
// 4. 同步等待结果
cudaDeviceSynchronize();
// 5. 取回结果
cudaMemcpy(h_a, d_a, N * sizeof(float), cudaMemcpyDeviceToHost);
// 6. 清理
cudaFree(d_a);
free(h_a);
return 0;
}
绝大多数 host 代码长这个样。但 LLM 推理服务里,host 端比这复杂得多——CUDA Graph、多 stream pipeline、page-locked memory、cudaMallocAsync 等,其中 CUDA Graph 与 stream 附录 A 会专门讲。
3.9 七层抽象与硬件的映射
最后把七层抽象与第 2 章的硬件结构对应起来:
flowchart TB
subgraph Software [编程模型抽象]
Host3[Host CPU]
Device3[Device · 整 GPU]
Grid3[Grid]
Cluster3[Cluster]
Block3[Block · 1024 thread]
Warp3[Warp · 32 thread]
Thread3[Thread]
end
subgraph Hardware [硬件实体]
CPU3[CPU + System Memory]
GPU3[H100 SXM5 · 132 SM + L2 + HBM]
GPC3[GPC · 全片每 GPC 18 SM]
SM3[SM]
SubCore3[Sub-Core · 32 FP32 cores]
Reg3[Register file slot]
end
Host3 --- CPU3
Device3 --- GPU3
Grid3 -.-> GPU3
Cluster3 -.-> GPC3
Block3 -.-> SM3
Warp3 -.-> SubCore3
Thread3 -.-> Reg3
注意几个映射的特点:
- 不是一一对应:一个 SM 可以同时跑多个 block,所以 Block ↔ SM 是 N:1。
- Grid 没有对应的硬件单元:硬件的调度器把一个 grid 的 block 逐个派发给 SM,SM 只看得到 block。
- Cluster ↔ GPC 是新的对应:Cluster 内的 block 必须在同一 GPC 内,这是 Hopper 才有的硬件约束。
这个映射理解清楚后,性能调优就有方向了:
- 想要 warp-level 高效?关注 warp shuffle、warp vote、ballot。
- 想要 block-level 高效?关注 SMEM 利用、
__syncthreads频率、bank conflict。 - 想要 cluster-level 高效?关注 distributed SMEM 拓扑、
cluster.sync()频率。 - 想要 grid-level 高效?关注 occupancy、kernel launch 次数、persistent kernel 模式。
3.10 这一章的小结与下一章
这一章从软件角度梳理了 CUDA 的七层编程模型抽象:
- Thread:你写的标量代码。
- Warp:32 线程的硬件调度组,有 shuffle / vote / ballot 等专属 API。
- Block:SMEM 共享、
__syncthreads同步的舞台。 - Cluster(Hopper 新增):通过分布式 SMEM 跨 block 协作。
- Grid:一次 launch 的全部 block。
- Device:一张卡。
- Host:CPU 端代码。
每一层都有自己的协作 API 和性能直觉。后续章节会反复回到这七层——比如第 5 章 Reduce 用到 warp、block 与 cluster 层,第 11 章 Tiled GEMM 用到 block 层 SMEM 与 __syncthreads,第 17 章用 TMA 与 warp specialization 把 FA2 写到 SOTA。
第 4 章我们会换一个最关键的视角——内存层级。GPU 有 register / SMEM / L1 / L2 / HBM 五层内存,层与层之间的延迟和带宽差距从几倍到几十倍。LLM 算子优化的核心几乎全部围绕"怎么让数据多在 SMEM 和寄存器里待着,少往 HBM 跑"展开。读完第 4 章,读者会有一份按量级标注各层延迟与带宽的"内存代价表",可以放在桌面,写每一行 kernel 代码时心里都有数。
本章动手练习:
- 写一个 kernel,每个 block 输出自己的
(blockIdx, threadIdx, warp_rank, lane_rank),验证 32 线程组成 warp 的规律。- 用 Cooperative Groups 写一个 warp-level reduce,对比第 5 章会给的
__shfl_xor_sync版本,看哪种更易读。- 在 H100 上写一个最简单的 cluster kernel:cluster_size=2,让 block 0 写 SMEM,block 1 通过
map_shared_rank读 block 0 的 SMEM,验证 distributed SMEM 工作。