CUDA 算子工程:手写 FlashAttention v2 之路
第 1 章 GPU 范式:从 CPU 思维到 SIMT 思维
GPU 的一切设计都从同一个目标出发:用海量并行去掩盖单条指令的延迟。 CPU 花晶体管让一个线程跑得快,GPU 花晶体管让任何时刻都有线程可以算。
1.1 一个朴素的问题:GPU 比 CPU 快在哪
很多人第一次接触 GPU 编程时,脑子里都装着一个错误的类比:
"CPU 是 16 核的,GPU 是 10000 核的,所以 GPU 比 CPU 快 600 倍。"
这个类比听起来直观,但它几乎全错。
要看到错在哪,先把 2025 年的两款主流硬件摊开对比。一边是 Intel 第 5 代 Xeon Scalable(Emerald Rapids)8592+,一边是 NVIDIA H100 SXM5。
| 维度 | Xeon 8592+ (CPU) | H100 SXM5 (GPU) |
|---|---|---|
| 核心数 | 64 | 132 SM × 128 FP32 cores = 16896 |
| 主频 | 3.9 GHz boost | ~1.98 GHz boost |
| FP32 算力 | ~5 TFLOPs (AVX-512) | ~67 TFLOPs (SIMT) |
| 矩阵算力(BF16/FP16) | ~125 TFLOPs (AMX BF16,折算值见下注) | 989 TFLOPs (Tensor Core, FP16) |
| 内存带宽 | ~350 GB/s (DDR5-5600 × 8) | 3350 GB/s (HBM3) |
| 单线程指令延迟(典型) | 1–5 cycles | 4–40 cycles |
| 缓存层级 | L1 80KB/核 / L2 2MB/核 / L3 320MB | L1+SMEM 256KB/SM / L2 50MB |
| TDP | 350 W | 700 W |
| 价格(2025) | ~$13,000 | ~$30,000 |
CPU 数据来源:Intel® Xeon® Platinum 8592+ Product Specification (Q4 2023),价格为 Intel 建议客户价(规格页现列 $12,992)。 GPU 数据来源:NVIDIA H100 Tensor Core GPU Architecture Whitepaper (2022);H100 价格为公开市场行情,非官方定价。 「单线程指令延迟」一行两边的厂商文档都不给,是公开微基准测试的典型量级,仅作示意。
关于 Xeon 的 AMX 峰值:Intel 的产品规格页不公布 TFLOPs,上表的 ~125 是按 ISA 折算的——Emerald Rapids 每核一个 TMUL 单元,
TDPBF16PS一条指令做 16×32 与 32×16 两块 BF16 的乘加(8192 次乘加 = 16384 FLOPs),吞吐约 16 周期,即 1024 FLOPs·周期⁻¹·核⁻¹;64 核 × 1.9 GHz ≈ 125 TFLOPs。注意 Emerald Rapids 的 AMX 只有 BF16 与 INT8 两种数据类型,没有 FP16(AMX-FP16 要到 Granite Rapids 才有),所以这一行拿 AMX 的 BF16 去对 Tensor Core 的 FP16。关于 H100 的时钟:官方两个峰值是按不同时钟折算的——FP32 的 67 TFLOPs 隐含 1.98 GHz(132 × 128 × 2 × 1.98 GHz ≈ 67),而 FP16 Tensor Core 的 989 TFLOPs 隐含 1.83 GHz(989 ÷ 132 ÷ 4096 FLOPs·周期⁻¹)。本专栏一律直接引用官方峰值 67 / 989,只在反推 Tensor Core 每周期吞吐时用 1.83 GHz(见 §12.2);上表的主频取 FP32 峰值隐含的那个值。
几个有趣的对比:
- 纸面算力的倍数没有想象中夸张:纯 FP32 SIMT 算力 H100 比 Xeon 高 13×,而矩阵算力(H100 Tensor Core FP16 对 Xeon AMX BF16)也就 ~8×——Intel 把 AMX 堆得并不弱,"GPU 算力高两三个数量级"是个流传很广的错觉。真正决定 LLM 负载归谁的,是下一条。
- 内存带宽差距比算力差距更稳定:H100 是 Xeon 的 ~10×。这个比例从 Pascal 时代就大致维持。所以带宽优势是 GPU 真正的护城河——不是核心数。
- GPU 的单指令延迟反而更长:H100 的浮点加法(约 4 cycles)只是与 CPU 相当,内存读取即便命中 L1 也要约 40 cycles(第 4 章引的 H800 实测量级),比 CPU 的同类操作(约 4–5 cycles)长得多。
- GPU 的 cache 容量小得离谱:每个 H100 SM 只有 256KB 的 L1+SMEM 容量,这是 Xeon 单核 L2(2 MB)的 1/8。
第 3 条和第 4 条尤其反直觉——既然 GPU 单指令更慢、cache 更小,它凭什么"快"?
答案是:GPU 用大量并行的线程来掩盖单指令的慢。它不是让每个线程跑得快,而是让足够多的线程同时在跑,使得任何一个线程在等内存的时候,总有别的线程能算。
这就是 GPU 设计哲学的第一个分水岭:延迟隐藏(Latency Hiding)。
1.2 两条岔路:延迟优化 vs 吞吐优化
CPU 和 GPU 的差异不是"性能高低"的差异,而是"优化目标"的差异。
flowchart TB
subgraph CPU [CPU · 延迟优化]
C1[少量复杂核心]
C2[深度乱序执行]
C3[庞大分支预测]
C4[多级大容量 cache]
C5[强一致性内存模型]
C1 --> C2 --> C3 --> C4 --> C5
end
subgraph GPU [GPU · 吞吐优化]
G1[大量简单核心]
G2[顺序执行]
G3[无分支预测]
G4[小容量但快速 SMEM]
G5[弱一致性, 显式同步]
G1 --> G2 --> G3 --> G4 --> G5
end
Goal1[目标: 单线程任务尽快完成] -.-> CPU
Goal2[目标: 大量线程总吞吐最大化] -.-> GPU
把这两套设计哲学落到具体的硬件预算上,差异就更直观了。
1.2.1 CPU 是怎么花晶体管的
一颗现代 x86 CPU 核心的晶体管预算大致是这样分配的:
现代 CPU 单核晶体管分配(约 2-3 亿晶体管)
┌──────────────────────────────────────────────────┐
│ L1 / L2 cache: ~40% │
│ 乱序执行 + 寄存器重命名: ~20% │
│ 分支预测器 + 分支目标缓冲: ~10% │
│ 解码器 (μop cache): ~10% │
│ 实际算术单元 (ALU/FPU/AVX): ~10% │
│ 其他 (load/store unit, retire, etc.): ~10% │
└──────────────────────────────────────────────────┘
比例数据:依据 Intel/AMD 公开 die shot 做的量级估算,非官方数据,仅用于说明"晶体管花在哪里"。
注意最后一行:真正的算术单元只占了大约 10% 的晶体管。其余 90% 都在做"让单线程跑得更快"的辅助工作——预测下一条指令、并行执行多条无依赖指令、缓存可能要用的数据、记住每个寄存器最新的影子版本。
为什么花这么多晶体管在"辅助"上?因为 CPU 的设计目标是:给我一个串行任务,让它尽快跑完。当任务本身没有并行性(比如指针追踪、解析 JSON、跑一段单线程的业务逻辑),CPU 只能靠"在一条指令链上压榨更多并行"——也就是指令级并行(ILP, Instruction-Level Parallelism)。乱序执行、寄存器重命名、分支预测,全都是为 ILP 服务的。
1.2.2 GPU 是怎么花晶体管的
一个 Hopper SM 的晶体管预算分配截然不同:
Hopper SM 的晶体管去向(GH100 全片约 800 亿晶体管、144 个 SM,H100 SXM5 启用 132 个)
┌──────────────────────────────────────────────────┐
│ Tensor Core (4 个/SM): ~30% │
│ FP32/INT32 SIMT cores (128+64 个/SM): ~15% │
│ 寄存器文件 (256KB/SM): ~25% │
│ Shared Memory + L1 cache (256KB/SM): ~15% │
│ Warp scheduler (4 个/SM): ~5% │
│ Load/Store Unit, MMU, etc.: ~10% │
└──────────────────────────────────────────────────┘
比例数据:综合 NVIDIA Hopper Whitepaper 与公开 die shot 的量级估算。
一个鲜明对比:GPU 把约 45% 的晶体管花在了算术单元上(Tensor Core + SIMT cores),而 CPU 这个比例只有 ~10%。GPU 上几乎所有 CPU 引以为豪的"复杂逻辑"(乱序、分支预测、巨型 cache)全部砍掉,省下的晶体管全部投到算术单元和寄存器上。
但 GPU 砍掉这些"复杂逻辑",难道不会让性能掉得很惨吗?
会。GPU 的单指令性能确实比 CPU 差——一个 Hopper SIMT core 上一条 FP32 加法的延迟约 4 个周期,而 Xeon 的 AVX-512 加法虽然延迟也有几个周期,却能靠乱序执行把吞吐做到每周期一条以上。GPU 单核根本打不过 CPU 单核。
但 GPU 的玩法从一开始就不是"单线程跑得快"。它的玩法是:同时跑成千上万个线程,让任何时刻都有线程可以执行。这就引出了 GPU 的核心范式——SIMT。
1.3 SIMT:GPU 的核心范式
SIMT 全称 Single Instruction, Multiple Threads(单指令多线程),是 NVIDIA 在 2006 年的 Tesla 架构(G80)上引入的执行模型,术语在 2008 年的 IEEE Micro 论文《NVIDIA Tesla: A Unified Graphics and Computing Architecture》里被正式定义。
很多人把 SIMT 和 SIMD(Single Instruction, Multiple Data)混为一谈。它们确实相似,但差别足以把"会写 SIMD"的工程师在 GPU 上撞墙好几次。
flowchart LR
subgraph SIMD [SIMD · 一个核控多条数据线]
INS1[一条 SIMD 指令: VADD]
INS1 --> LANE1[Lane 0]
INS1 --> LANE2[Lane 1]
INS1 --> LANE3[Lane 2]
INS1 --> LANE4[Lane 3]
LANE1 -.同步执行.-> RESULT1[结果寄存器]
LANE2 -.同步执行.-> RESULT1
LANE3 -.同步执行.-> RESULT1
LANE4 -.同步执行.-> RESULT1
end
subgraph SIMT [SIMT · 一个 warp 调一组线程]
WARP[一个 warp 中的 32 个线程]
WARP --> T0[线程 0 有自己的寄存器]
WARP --> T1[线程 1 有自己的寄存器]
WARP --> T2[线程 2 有自己的寄存器]
WARP --> TN[... 共 32 个线程]
T0 -.独立的 PC, 独立的状态.-> SCHED[硬件调度器决定哪几个一起发指令]
T1 -.独立的 PC, 独立的状态.-> SCHED
T2 -.独立的 PC, 独立的状态.-> SCHED
TN -.独立的 PC, 独立的状态.-> SCHED
end
SIMD 和 SIMT 的关键差异有三点:
- 抽象层级不同:SIMD 是一条指令直接操作多个数据 lane,每个 lane 没有"自己的状态";SIMT 是一组(32 个)独立线程,每个线程有自己的程序计数器、自己的寄存器、自己的执行状态(这是编程模型的视角;硬件上真正给每个线程配独立 PC 要到 Volta,见 §1.3.2 末尾)——只是它们恰好被硬件捆在一起、用同一条指令推进。
- 编程模型不同:写 SIMD(比如 AVX-512、ARM SVE)需要显式地组织 lane、用 intrinsic 函数、对齐数据;写 SIMT 你就像在写普通的标量代码,由编译器和硬件去打包线程到 warp 里。
- divergence 处理不同:SIMD 遇到分支就需要程序员手写 mask;SIMT 硬件自动处理 divergence——当一个 warp 里 32 个线程走不同分支,硬件会自动让它们串行执行不同分支,但写代码的人不需要显式 mask。
第 3 点是 SIMT 模型最大的"魔法",也是它最大的"陷阱"。
1.3.1 Warp:SIMT 的最小调度单位
在 NVIDIA GPU 上,每 32 个线程组成一个 warp。warp 是硬件调度的最小单位——硬件每次给一个 warp 的 32 个线程发同一条指令。
为什么是 32?这个数字从 G80 一路沿用到 Hopper,没变过。CUDA 官方文档没有解释为什么选 32,常见的理解是工程权衡:太小(比如 8)的 warp 会让取指、译码、调度的开销摊到更少的线程上;太大(比如 128)的 warp 会让 divergence 代价太高。32 是 G80 时代定下的折中。
每个 SM 上有 4 个 warp scheduler(Hopper 上是 4 个,Volta/Ampere 也是 4 个)。每个 warp scheduler 每周期可以发射一条指令到一个 warp。所以一个 SM 每周期最多发射 4 条指令到 4 个不同的 warp,对应 4 × 32 = 128 个线程的一条指令。
flowchart TB SM[一个 Hopper SM] SM --> WS1[Warp Scheduler 0] SM --> WS2[Warp Scheduler 1] SM --> WS3[Warp Scheduler 2] SM --> WS4[Warp Scheduler 3] WS1 --> WP0[每周期发一条指令到 1 个 warp 的 32 个线程] WS2 --> WP1[每周期发一条指令到 1 个 warp 的 32 个线程] WS3 --> WP2[每周期发一条指令到 1 个 warp 的 32 个线程] WS4 --> WP3[每周期发一条指令到 1 个 warp 的 32 个线程]
但这只是每周期被选中发射的 4 个 warp。Hopper 一个 SM 最多可以同时驻留 64 个 warps(2048 active threads/SM;前提是每线程不超过 32 个寄存器,否则寄存器文件先装满)。其余 60 个 warp 怎么办?它们处于"待命"状态——硬件维护着它们的 PC、寄存器、谓词,每个 warp scheduler 每周期都从自己名下已就绪的 warp 里挑一个发射;某个 warp 卡住(比如等内存读完成)就暂不参与挑选,由别的就绪 warp 顶上。
这个切换过程是零开销的——和 CPU 的线程切换不同(CPU 切换上下文要保存到内存,开销几百到几千个周期),GPU 的 warp 切换发生在硬件层面,所有 warp 的寄存器都常驻在 SM 的寄存器文件里。切换只是改一下 warp scheduler 的指针,0 周期。
这就是 GPU 延迟隐藏的本质:
CPU 视角: GPU 视角:
┌──────────────────────┐ ┌──────────────────────┐
│ Thread A 等内存(200c)│ │ Warp 0 等内存(800c) │
│ 此时核心闲置... │ │ 切到 Warp 1 算1c │
│ 内存到了, A 继续算 │ │ 切到 Warp 2 算1c │
└──────────────────────┘ │ 切到 Warp 3 算1c │
│ ... 切 800 次 ... │
│ Warp 0 内存到了 │
│ 此时核心一直没闲着! │
└──────────────────────┘
CPU 看到一个线程等内存,会用复杂的乱序执行、prefetcher、缓存预测来填满这段空闲;GPU 看到一个 warp 等内存,直接换一个 warp 来跑。哪种思路更好?取决于你有多少独立的工作。
如果工作完全串行(一个长的依赖链),CPU 赢——它能把这条链跑得快。如果工作高度并行(几十万个独立的小任务),GPU 赢——它能让所有核心一刻不停。
LLM 推理与训练的核心算子(GEMM、Attention、LayerNorm)都属于后者。这是 GPU 在 LLM 时代成为绝对统治者的根本原因。
1.3.2 一个 warp 内的 divergence
SIMT 的"自动处理 divergence"听起来很美,但代价是真实的。看这段代码:
__device__ int work_a(); // 两个分支各自的工作,定义在别的编译单元(需 -rdc=true)
__device__ int work_b();
__global__ void diverge_kernel(int* arr) {
int tid = threadIdx.x;
if (tid % 2 == 0) {
arr[tid] = work_a(); // 偶数线程做 work_a
} else {
arr[tid] = work_b(); // 奇数线程做 work_b
}
}
一个 warp 里的 32 个线程,16 个走 if 分支,16 个走 else 分支。SIMT 硬件怎么办?(如果两个函数体很短、编译器能看到,它往往直接改成谓词执行(指令带条件谓词,按条件决定结果是否生效),SASS 里连跳转都没有;这里让函数体不可见,编出来才是真正的分支。)
它会先让 16 个偶数线程执行 work_a(),此时 16 个奇数线程被 mask 掉(不执行,但占着 ALU 周期);然后让 16 个奇数线程执行 work_b(),此时偶数线程被 mask 掉。
sequenceDiagram
participant Hardware as Warp Scheduler
participant Even as 16 偶数线程
participant Odd as 16 奇数线程
Note over Hardware: 进入 if 分支
Hardware->>Even: 执行 work_a()
Hardware->>Odd: mask 掉, 跟着走但不写结果
Note over Hardware: 切到 else 分支
Hardware->>Odd: 执行 work_b()
Hardware->>Even: mask 掉, 跟着走但不写结果
Note over Hardware: 两条路径都走完, warp 重新汇合
结果是:这个 kernel 的实际算力是非 divergence 情况的一半。因为同样的 32 个 ALU 周期,只有 16 个线程在干活。
这就是 SIMT 的代价。它不会编译报错,不会运行时崩溃,只会性能腰斩。
在 Volta 之前(Kepler/Maxwell/Pascal),一个 warp 的 32 个线程共享一个程序计数器加一个活跃掩码,divergence 由硬件的 SIMT 栈管理,两条路径走完后必然在重新汇合点(reconvergence point,即分支的直接后支配节点)自动汇合。Volta 引入了 Independent Thread Scheduling,每个线程有了独立的 PC,可以在子 warp 粒度上分叉与汇合;代价是硬件不再保证在汇合点自动重聚——依赖"warp 内隐式同步"的老代码从此必须显式插
__syncwarp()。这并不消除 divergence 的代价,只是让它的时机更难预测。汇合点如今由编译器在 SASS 里显式标出:上面的diverge_kernel用 nvcc 13.4 编到 sm_90a,分支两侧被一对BSSY/BSYNC包住(不同 CUDA 版本可能不同)。
1.3.3 Memory Coalescing:访存的 SIMT 化
SIMT 不仅影响指令执行,还深刻影响内存访问。
GPU 的全局内存访问以 transaction(事务)为单位,计算能力 6.0 起每个事务是 32 字节(一个 sector)。而一个 warp 里 32 个线程通常会同时发起内存读。如果这 32 个线程正好读连续、对齐的 32 个 4 字节元素(比如 float arr[i*32:i*32+32]),那么硬件可以把它们合并成 4 个 32 字节的 transaction(恰好覆盖一段 128 字节),效率拉满。
但如果这 32 个线程读的地址东一榔头西一棒槌(比如 arr[idx[tid]] 中 idx 是随机的),那就是 32 次独立的 32 字节 transaction:总线上搬了 1024 字节,只有 128 字节是要的,有效带宽掉到 1/8,事务数是 8 倍。
Coalesced(合并访存):
Thread: 0 1 2 3 ... 30 31
↓ ↓ ↓ ↓ ↓ ↓
Address: 0 4 8 12... 120 124
└────┬────────────┘
4 个 32B 事务(共 128B)
Uncoalesced(分散访存):
Thread: 0 1 2 ... 31
↓ ↓ ↓ ↓
Address: 0 1024 2048 ... 31744
└─────┬─────┴─────────┘
32 次独立事务(每次只用 4B/32B)
这个效应在 LLM 算子里非常常见。比如反向 attention 中 dQ 的累加,如果 thread layout 没设计好,每次 atomic add 都是 uncoalesced 的,性能会明显下降。第 4 章会专门讲访存模式,第 16 章(§16.3)会讲 dQ 累加的工程取舍。
1.4 案例:同样的 reduce,CPU 与 GPU 的两种思路
把上面所有的概念落到一个具体例子上:对一个长度为 N 的 float 数组求和。
1.4.1 CPU 的思路
最直观的 CPU 写法:
float sum = 0.0f;
for (int i = 0; i < N; ++i) {
sum += arr[i];
}
这段代码完全串行——sum += arr[i] 是一条依赖链,前一个加法结果是后一个的输入。但现代 CPU 不会真的让它串行:
- 乱序执行能把后面的 load 提前发出去,但加法本身仍被依赖链卡住,每条都要等上一条的结果;要让多条加法同时在飞,得用多个累加器把链拆开(浮点加法不满足结合律,编译器不开
-ffast-math之类选项时不会替你拆); - AVX-512 可以把循环 unroll,每次同时加 16 个 float(同一时刻的 16 lane);
- 多核可以把数组分成 N/cores 段,每段一个核算,最后汇总。
充分优化后的 CPU 代码:
#include <omp.h>
#include <immintrin.h>
float sum_cpu(const float* arr, int N) {
int nvec = N - N % 16; // 只让向量循环走整 16 的部分
float total = 0.0f;
#pragma omp parallel reduction(+:total)
{
__m512 acc = _mm512_setzero_ps();
#pragma omp for nowait
for (int i = 0; i < nvec; i += 16)
acc = _mm512_add_ps(acc, _mm512_loadu_ps(&arr[i]));
total += _mm512_reduce_add_ps(acc); // 每线程只做一次横向归约
}
for (int i = nvec; i < N; ++i) total += arr[i]; // 尾巴
return total;
}
两个细节值得说明:向量循环的上界取 nvec = N - N % 16、剩下不足 16 个元素的尾巴单独收,否则 _mm512_loadu_ps(&arr[i]) 会在 N 不是 16 的倍数时越界读;_mm512_reduce_add_ps 是一段 log₂16 级的横向归约序列而不是一条指令,放进循环体里会让它主导整个 kernel,所以每线程只在最后做一次,循环里用向量累加器。
这段代码已经把 OMP 多线程 + AVX-512 SIMD 都用上了。它的瓶颈不在算力而在内存带宽——这是一个 ~0.25 FLOPs/byte 的访存密集型 kernel。1 亿个 float 是 400 MB,按 ~350 GB/s 的 DDR5 带宽算,理论下限就是 ~1.1 ms,实测只会比它慢。
1.4.2 GPU 的思路
GPU 上同样的任务,思路完全不同。一个朴素的 GPU 写法:
__global__ void reduce_naive(const float* arr, float* result, int N) {
int tid = blockIdx.x * blockDim.x + threadIdx.x;
if (tid < N) {
atomicAdd(result, arr[tid]);
}
}
这段代码有 N 个线程,每个线程读一个元素,然后做 atomicAdd 把它累加到全局结果上。能跑,但性能极差——所有线程对同一个地址做原子加,这些原子操作只能一个接一个地执行,串行化严重。
正确的 GPU reduce 思路是分层归约:
template <int BLOCK_SIZE>
__global__ void reduce_v2(const float* arr, float* block_sums, 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. 每个 block 输出一个部分和
if (tid == 0) {
block_sums[blockIdx.x] = smem[0];
}
}
这段代码体现了 GPU 编程的几个核心思维:
- 大量线程协作:N 个线程同时读 N 个元素到 SMEM,用
__syncthreads()同步。 - 分层归约:每一步活跃线程数减半(BLOCK_SIZE → BLOCK_SIZE/2 → ... → 1),充分利用并行性。
- Shared Memory 是关键:所有归约都在 SMEM 里完成,避免反复读写 HBM。
- 多级 launch:block 内归约出 block_sums,再 launch 一次 kernel 把 block_sums 归约到最终结果。
同样 400 MB 的数据,H100 的 HBM3 带宽是 3.35 TB/s,理论下限约 0.12 ms——相对 CPU 的优势基本就是这两条带宽之比(~10×),而不是"核心数之比"。上面这个版本离理论下限还差得远:第 5 章我们会用 warp shuffle、cluster reduce、vectorized load 一步步把它推到逼近 HBM 峰值(量级上九成左右)。
1.4.3 思维差异的本质
把两种实现摆在一起,能看出根本的思维差异:
| 维度 | CPU 思路 | GPU 思路 |
|---|---|---|
| 并行粒度 | 几十个线程 × SIMD lane | 几十万个线程 |
| 同步模型 | 隐式(cache coherence) | 显式(__syncthreads) |
| 内存层级 | 自动缓存(L1/L2/L3) | 程序员管理(SMEM) |
| 优化重点 | ILP、SIMD、cache 命中 | 线程数、warp 协作、coalesced 访存 |
| 心智模型 | "几个工人努力干活" | "几万个工人协作分工" |
CPU 思路更接近"我作为程序员控制一个高效的工人去完成任务";GPU 思路更接近"我设计一套规则,让几万个工人按规则自组织地完成任务"。这是从 CPU 到 GPU 最重要的思维迁移。
1.5 心智模型:把 GPU 想成一个高度并行的厨房
为了让这一章的所有概念能在读者脑子里粘在一起,我建议建立一个比喻。
把 GPU 想成一个超大型厨房:
flowchart TB
subgraph Restaurant [GPU 餐厅]
direction TB
Kitchen1[厨房 1<br/>SM]
Kitchen2[厨房 2<br/>SM]
KitchenN[... 132 个厨房]
subgraph K1 [厨房 1 内部]
Chef[4 个领班<br/>4 个 Warp Scheduler]
Team1[团队 1<br/>32 个厨师 = 1 warp]
Team2[团队 2<br/>32 个厨师 = 1 warp]
TeamN[...最多 64 团队]
Chef -->|每周期分配一道菜| Team1
Chef -->|每周期分配一道菜| Team2
Chef -->|...| TeamN
Pantry[食品柜<br/>SMEM 最多 228KB]
end
Fridge[隔壁的冰箱<br/>全片共享的 L2 50MB]
end
Warehouse[城外的仓库<br/>HBM 80GB]
这个比喻里:
- 132 个厨房 = 132 个 SM:GPU 上同时有 132 个独立的"工作车间"。
- 每个厨房 4 个领班 = 4 个 warp scheduler:每个 SM 有 4 个独立的指令调度器。
- 每个厨房最多 64 个团队 = 64 个 active warps:一个 SM 最多可以同时驻留 64 个 warp 的状态。
- 每个团队 32 个厨师同步行动 = 32 thread/warp 同时执行同一条指令:SIMT。
- 食品柜(SMEM)就在厨房里,取用极快;冰箱(L2)在隔壁房间,慢一些;仓库(HBM)在城外,最慢。
用这个比喻能把很多 GPU 编程的"反直觉"现象解释通顺:
- 为什么 divergence 这么贵:一个团队 32 个厨师,本来要做同样的菜可以同步行动;如果其中 16 个要做菜 A,16 个要做菜 B,必须先一起做完 A 的那一半(另一半干瞪眼),再一起做完 B 的另一半(前一半干瞪眼)——总耗时翻倍。
- 为什么 coalesced 访存这么重要:派一个搬运工去仓库取一箱货比较高效;派 32 个搬运工去仓库各取一件货,让仓库门口堵得水泄不通。
- 为什么 occupancy 重要:一个厨房只有 4 个领班,但活跃团队数(一直在做菜的团队)越多,任何时刻领班都有团队可以分配工作;如果一个厨房只有 4 个团队,4 个领班一刻不停忙活,但任何一个团队卡住(等食材),领班就闲下来了。
- 为什么算力 vs 带宽的失衡这么关键:厨房算力(菜的产出速度)极强,但仓库的运输管道(HBM 带宽)有限;如果菜谱要求每做一道菜都从仓库取大量食材,再快的厨房也要等运输——这就是带宽 bound。
这个比喻不完美(任何比喻都不完美),但它能在你脑子里搭一个粗糙的骨架。后续章节遇到具体概念时,可以一直回到这个比喻上对照。
1.6 这一章的小结与下一章
这一章我们建立了 4 个核心认知:
- GPU 不是更多核的 CPU:它是为了高吞吐而完全不同设计的处理器。CPU 优化延迟,GPU 优化吞吐。
- SIMT 是 GPU 的核心范式:32 个线程为一个 warp,硬件给一个 warp 发同一条指令。warp 切换是零开销的,这是延迟隐藏的关键。
- Divergence 和 coalesced 访存是 SIMT 的两个常见陷阱:写代码时不会报错,但性能会无声地下降:两路分支就腰斩(32 路全分叉可到 1/32),完全散乱的访存有效带宽只剩 1/8。
- GPU 编程的心智迁移核心是从"控制一个工人"到"设计协作规则":你不再为单个线程优化,而是为大群线程的协作设计模式。
这些认知是后续所有章节的地基。第 2 章我们会带读者深入 Hopper 的微架构,把 SM 内部的 Tensor Core、TMA、L1+SMEM、寄存器文件这些组件逐一打开,建立一份精确到具体硬件单元的地图。读完第 2 章,读者就能拿着 NVIDIA 官方 Whitepaper 的任何一页,对应到自己脑子里的 SM 模型上。
本章动手练习:
- 在一台 H100 / A100 上写一个最朴素的 reduce kernel(用 atomicAdd),用 Nsight Compute 测一下实际带宽利用率(nvprof 不支持计算能力 8.0 及以上的 GPU,A100/H100 都在其列)。预测一下结果,再看实际数字差距。
- 把 §1.4.2 的
reduce_v2跑起来,对比朴素版本的性能差距。- 阅读 NVIDIA H100 Whitepaper 的 "H100 SM Architecture" 一节,识别每一段对应到本章的哪个概念。