这是一篇前置讲,不对应原课的某一次课。原课的第五单元”扩大规模”(第 16 讲 MoE、第 17 讲分布式训练、第 18 讲 FlashAttention 与高效解码)以及第 19 讲长上下文,讨论的几乎都是”怎样让同样的计算在 GPU 上跑得更快、占得更少”。原课第 18 讲的 slides 从”矩阵乘法的分块""在线 softmax""HBM 与 SRAM”讲起,默认学生知道 GPU 的存储层次和 roofline 模型。这篇为没有体系结构背景的读者把它们补齐。 依据说明:本讲讲的是通用的 GPU 体系结构知识和 roofline 模型(Williams, Waterman & Patterson, 2009),硬件参数取自 NVIDIA A100 / H100 公开的规格(标称峰值,dense),“计算 / 访存 / 开销三种瓶颈”的说法参考了 Horace He 的博客 Making Deep Learning Go Brrrr From First Principles。代码里的 GPU 部分全部是模型和推算(这台电脑没有 NVIDIA GPU),实测部分在本机 CPU 的一个核心上进行,讲义会明确区分两者。 配套代码 Code:
code/prereq3/(5 个脚本 + 1 个模块;不需要 GPU;每个脚本 1–2 秒) 预计阅读时间:3–4 小时(含运行代码) 建议阅读时机:第 16 讲之前;第 18 讲必需。前置讲 P1 的 §5(显存核算)和 §6(带宽 vs 算力)是本篇的起点。
0. 本讲导航 · How to read this lecture
0.1 为什么要懂这些
前置讲 P1 §6 在一个 CPU 核心上测到一个现象:同一个核心,矩阵乘法每秒能做约 3000 亿次浮点运算,逐元素加法只有约 100 亿次,差了约 300 倍。这篇要把这个现象讲透,因为它解释了深度学习系统里大量”为什么这样设计”的问题:
- 为什么 GPU 适合神经网络,而且是低精度的神经网络?(§1)
- 为什么生成文字时,H100 的算力有 99% 以上是闲着的?为什么推理服务要把很多用户的请求拼成一批?(§3、§6)
- 为什么 FlashAttention 在不减少计算量的情况下能快好几倍?(§4、§6)
- 为什么
torch.compile、算子融合、CUDA Graphs 能加速?(§5) - 为什么矩阵维度常常选成 128 的倍数,词表大小要补齐成 64 的倍数?(§2)
所有这些问题,都可以用一个很简单的工具来分析:roofline 模型。它只需要两个硬件参数(峰值算力、内存带宽)和一个运算的两个数(浮点运算次数、读写字节数)。
0.2 学习目标 Learning goals
- 说清楚 CPU 和 GPU 的设计取舍:少量强核心、追求低延迟 vs 大量简单核心、追求高吞吐;知道张量核心和低精度在 GPU 算力里的地位。
- 理解 GPU 的执行模型:线程、线程束(warp)、线程块、SM;用”大量线程束轮流执行”隐藏访存延迟;知道线程束分化和波次量化为什么会损失性能。
- 记住存储层次(寄存器、SRAM、L2、HBM)的容量和速度量级;会算一个运算的算术强度,用 roofline 模型判断它是计算受限还是访存受限,并估出理想耗时。
- 理解分块为什么能把矩阵乘法从访存受限变成计算受限,以及 SRAM 容量怎样限制块的大小。
- 理解算子融合为什么能加速访存受限的运算(而对计算受限的运算几乎无效),以及小运算为什么会被固定开销主导。
- 能用 roofline 分析一个 Transformer 的 prefill、decode 和训练:为什么 prefill 计算受限、decode 访存受限,批大小和上下文长度怎样改变这一切;会用 估计训练算力。
0.3 本讲地图 Map
§1 CPU 与 GPU:两种设计哲学、张量核心与低精度 ← 代码 01 第 1 部分
§2 GPU 的执行模型:线程束、SM、隐藏延迟、分化、波次 ← 代码 01
§3 存储层次与 roofline:算术强度、平衡点、批大小、CPU 上实测 ← 代码 02
§4 分块:让快而小的存储发挥作用 ← 代码 03
§5 算子融合与开销:三种瓶颈 ← 代码 04
§6 把 roofline 用到 Transformer:prefill、decode、长上下文、训练的 6ND ← 代码 05
§7 小结 §8 常见误区 §9 练习 §10 延伸阅读 §11 下一篇预告
0.4 先修与说明
需要前置讲 P1 的两个概念:数据类型的字节数(BF16 = 2 字节)和”带宽 vs 算力”的直觉。不需要会 CUDA 编程。
关于数字:GPU 的参数都是厂商公开规格里的标称峰值(张量核心、dense、不含稀疏加速),真实程序通常只能达到 50%–80%;roofline 估计因此是”理想下限”,真实耗时只会更长。CPU 的实测数字来自作者的 Mac(Apple 芯片),每次运行、每台机器都不同,讲义只引用它们的量级和相对大小。
1. CPU 与 GPU:两种设计哲学 · Latency vs throughput
1.1 一个比喻
CPU 像几位经验丰富的大厨:每个人都能独立做一整道复杂的菜,遇到等待(水还没开)就想办法先做别的步骤(乱序执行、分支预测、大缓存)。目标是让每一道菜尽快上桌——低延迟。
GPU 像一个有几千名帮厨的大厨房:每个帮厨只会简单的动作,而且一组 32 个人必须同时做同一个动作(都切洋葱)。单看一个人很慢,但当任务是”切一万个洋葱”时,几千人同时干,总产量远远超过几位大厨。目标是单位时间内完成的总量——高吞吐。
神经网络的计算恰好是”一万个洋葱”:矩阵乘法里成千上万个乘加彼此独立、动作完全相同、没有分支。这就是 GPU 适合深度学习的根本原因。
1.2 两块数据中心 GPU
01 第 1 部分列出 A100 和 H100 的公开规格:
| GPU | SM 数 | BF16 张量核心 TFLOP/s | FP32(非张量核心)TFLOP/s | HBM 带宽 TB/s | 显存 | 每 SM 的 SRAM | L2 | BF16 平衡点 FLOP/字节 |
|---|---|---|---|---|---|---|---|---|
| A100 SXM | 108 | 312 | 19.5 | 2.04 | 80 GB | 164 KiB | 40 MiB | 153 |
| H100 SXM | 132 | 989 | 67 | 3.35 | 80 GB | 228 KiB | 50 MiB | 295 |
(对照:本机 CPU 的一个核心实测约 3 TFLOP/s 的 FP32 矩阵乘法、约 120 GB/s 的内存复制。)三个观察:
- 算力几乎全部来自张量核心,而且只在低精度下才有。 H100 的 BF16 张量核心算力是它 FP32 通用单元的 15 倍。张量核心是专门做小矩阵乘加(例如 的块)的硬件单元。这是前置讲 P1 说”训练要用 BF16”的硬件原因——用 FP32 训练,你只能用到 GPU 的一小部分。
- 算力增长快于带宽增长。 从 A100 到 H100,BF16 算力涨了 3.2 倍,带宽只涨了 1.6 倍。所以”平衡点”(§3.3)从 153 升到 295:每从显存读一个字节,要做更多的运算才能把算力用满。这种”内存墙”(memory wall)是过去十几年的普遍趋势,也是为什么越来越多的优化都在想办法少读写显存。
- 快的存储小,大的存储慢。 每个 SM 的 SRAM 只有一两百 KiB,全卡几十 MiB;显存(HBM)有 80 GB,但慢得多。§3.1 详细介绍这个层次。
2. GPU 的执行模型 · How a GPU executes
2.1 线程、线程束、线程块、SM
写 GPU 程序(例如用 CUDA 或 Triton)时,你写的是一个线程要做的事,然后启动成千上万个线程同时执行。硬件按层次组织它们:
| 层次 | 是什么 | 数量级 |
|---|---|---|
| 线程(thread) | 执行一份代码的最小单位,有自己的寄存器 | 每个 SM 最多 2048 个驻留 |
| 线程束(warp) | 32 个线程一组,同时执行同一条指令 | 每个 SM 最多 64 个驻留 |
| 线程块(thread block) | 若干个线程束,被分配到同一个 SM 上,可以共享该 SM 的 SRAM(共享内存)、互相同步 | 每块最多 1024 个线程 |
| 网格(grid) | 一次核函数(kernel)启动的全部线程块 | 任意多 |
| SM(流式多处理器) | GPU 上的一个”核心”,有自己的调度器、计算单元、寄存器文件和 SRAM | H100 有 132 个 |
一次 GPU 运算(一个”核函数”)的执行过程是:把网格里的线程块分发到各个 SM 上;每个 SM 上同时驻留若干个线程块,它们的线程束由 SM 的调度器轮流发射指令。
2.2 用并行度隐藏延迟
从显存(HBM)读一个数,要等几百个时钟周期才能拿到。CPU 用大缓存和复杂的乱序执行来减少等待;GPU 的办法简单粗暴:同时驻留很多线程束,一个在等数据,就切换到另一个。切换几乎没有代价,因为每个线程束的寄存器一直留在 SM 上。
01 第 2 部分模拟了这个机制:每个线程束反复”做 条计算指令,然后访存一次(等 400 个周期)“,调度器每个周期发射一条准备好的指令:
| 每次访存间的计算量 c | 1 个线程束 | 8 个 | 32 个 | 64 个(上限) |
|---|---|---|---|---|
| 4 | 0.010 | 0.080 | 0.320 | 0.637 |
| 16 | 0.039 | 0.309 | 1.000 | 1.000 |
| 64 | 0.140 | 1.000 | 1.000 | 1.000 |
(表中是计算单元的利用率;理论值约为 , 是线程束数、 是延迟。)
- 只有 1 个线程束时,利用率只有 1%:它算 4 个周期、等 400 个周期。
- 线程束越多,利用率越高。驻留线程束的数量占上限的比例叫占用率(occupancy)。但上限是 64 个:如果每次访存之间只有 4 条计算,64 个线程束也只能用到 64%。
- 算术强度高的代码对并行度的要求低: 时 8 个线程束就够了。这和 P1 §6、本篇 §3 的结论是同一件事,只是换成了”指令”的视角——每读一次数据做的运算越多,越不怕访存慢。
2.3 线程束分化
一个线程束的 32 个线程同时执行同一条指令,这种模式叫 SIMT(单指令、多线程)。如果代码里有 if/else,而同一个线程束里有的线程走 if、有的走 else,硬件只能先让走 if 的执行(其余的空等),再让走 else 的执行。01 第 3 部分:两条分支各 10 个周期,只要有一个线程走了不同的分支,就要 20 个周期,效率减半。
所以 GPU 代码要让同一个线程束里的线程”做同样的事”。矩阵乘法和逐元素运算天然没有分支,非常适合;MoE 的路由(第 16 讲:每个 token 去不同的专家)、不规则的稀疏注意力(第 19 讲)则要专门整理成规整的形状再计算。
2.4 波次量化
01 第 4 部分:一个 的输出矩阵按 分块,每块交给一个线程块;H100 有 132 个 SM(简化假设每个 SM 一次跑一个块)。线程块要分若干”波”(wave)跑完:
| n | 线程块数 | 波数 | SM 利用率 |
|---|---|---|---|
| 4096 | 1024 | 8 | 0.970 |
| 4224 | 1089 | 9 | 0.917 |
| 4352 | 1156 | 9 | 0.973 |
从 4096 增加到 4224,计算量只多了 6.3%,却要多跑一整波(多 12.5%):最后一波只有少数 SM 在干活。这叫波次量化(wave quantization)。类似地,矩阵维度不是块大小的整数倍时,边缘的块也会浪费算力(“块量化”)。这就是为什么模型的隐藏维度常选 64 或 128 的倍数,Karpathy 还把 GPT-2 的词表从 50,257 补齐到 50,304(64 的倍数)来提速。真实的库会用更复杂的调度来缓解,但这个效应真实存在。
3. 存储层次与 roofline 模型 · Memory hierarchy and the roofline
3.1 存储层次
| 层次 | 在哪里 | 容量(H100) | 速度(量级) |
|---|---|---|---|
| 寄存器 | 每个线程私有 | 每个 SM 256 KiB | 最快,几乎无延迟 |
| SRAM(共享内存 / L1) | 每个 SM 一块,线程块内共享 | 每个 SM 最多 228 KiB | 带宽合计是 HBM 的十倍量级 |
| L2 缓存 | 全卡共享 | 50 MiB | 介于两者之间 |
| HBM(显存) | 封装在 GPU 旁边的高带宽内存 | 80 GB | 3.35 TB/s,延迟数百个周期 |
| CPU 内存 | 主板上,经 PCIe 连接 | 数百 GB 到数 TB | PCIe Gen5 每个方向约 64 GB/s |
规律和 CPU 一样:越快越小、越大越慢。模型的权重、激活值、KV 缓存都放在 HBM 里;每次计算,数据要从 HBM 搬到 SRAM / 寄存器,算完再写回 HBM。性能优化的大部分工作,就是减少 HBM 和 SRAM 之间的搬运。
3.2 算术强度
对一个运算,数两样东西:
- FLOPs:浮点运算次数。矩阵乘法 是 (每个输出元素做 次乘法和 次加法)。
- 字节数:至少要从 HBM 读写多少数据。矩阵乘法至少要读 、,写 : 每个数的字节数。
两者之比就是算术强度(arithmetic intensity),单位 FLOP/字节:每从显存搬一个字节,能做多少次运算。
| 运算 | FLOPs | 字节数(BF16) | 算术强度 |
|---|---|---|---|
| 逐元素加法 (N 个元素) | |||
| GELU(每元素约 8 次运算) | 2 | ||
| 方阵乘法 | |||
| 矩阵-向量 | |||
| 批量矩阵乘 | ( 时) |
3.3 roofline 模型
GPU 可以同时计算和搬数据(流水线化),所以一个运算的耗时由较慢的那个决定:
等价地,能达到的算力是 。把它画在”横轴算术强度、纵轴 FLOP/s”的对数坐标上,是一条先斜后平的折线,像一个屋顶——所以叫 roofline。转折点的横坐标
在 H100 的 BF16 下是 FLOP/字节。算术强度低于它的运算是访存受限(memory-bound),耗时 ≈ 字节数 / 带宽,算得再快也没用;高于它的是计算受限(compute-bound),耗时 ≈ FLOPs / 峰值。
02 第 1 部分把常见运算放到 H100 的 roofline 上:
| 运算 | 算术强度 | 瓶颈 | 理想耗时 | 占峰值 |
|---|---|---|---|---|
| 逐元素加法 4096×4096 | 0.17 | 访存 | 30.0 µs | 0.1% |
| GELU 4096×4096 | 2.00 | 访存 | 20.0 µs | 0.7% |
| softmax 4096 × 4096 | 1.25 | 访存 | 20.0 µs | 0.4% |
| RMSNorm 4096 × 4096 | 1.25 | 访存 | 20.0 µs | 0.4% |
| 矩阵-向量 [1,4096]@[4096,4096] | 1.00 | 访存 | 10.0 µs | 0.3% |
| 矩阵乘法 512³ | 171 | 访存 | 0.5 µs | 58% |
| 矩阵乘法 4096³ | 1365 | 计算 | 139 µs | 100% |
逐元素运算、softmax、归一化的算术强度比平衡点低两个数量级,永远是访存受限的,耗时只取决于读写了多少字节,和做了多少运算几乎无关。大矩阵乘法则是计算受限,可以接近峰值。一个 Transformer 的 FLOPs 绝大部分在矩阵乘法里,而时间却有相当一部分花在那些”不怎么计算”的运算上(§6)。
3.4 批大小:把矩阵-向量乘法变成矩阵乘法
生成文字时(decode),每一步只为每条序列算一个新 token,线性层就变成了矩阵-向量乘法:读一整个 的权重矩阵,只做 次运算,算术强度约 1。02 第 2 部分:如果同时为 个用户各生成一个 token,同一个权重矩阵被 个向量共用,算术强度约等于 :
| GPU | 批大小 b | 算术强度 | 瓶颈 | 这一次乘法的耗时 | 平均每个 token | 占峰值 |
|---|---|---|---|---|---|---|
| H100 | 1 | 1.0 | 访存 | 10.0 µs | 10.0 µs | 0.3% |
| H100 | 16 | 15.9 | 访存 | 10.1 µs | 0.6 µs | 5.4% |
| H100 | 128 | 120.5 | 访存 | 10.6 µs | 0.1 µs | 40.8% |
| H100 | 256 | 227.6 | 访存 | 11.3 µs | 0.04 µs | 77.1% |
| H100 | 512 | 409.6 | 计算 | 17.4 µs | 0.03 µs | 100% |
批大小从 1 涨到 128,这一次乘法的耗时几乎不变(都在等着读同样的权重),平均到每个 token 的成本却降了约 100 倍。要变成计算受限,批大小要达到平衡点附近:A100 约 256,H100 约 512。这就是推理服务一定要把很多用户的请求拼成一批(连续批处理,第 18 讲)的原因;也说明了为什么新一代 GPU 算力的提升对小批量解码帮助有限——小批量解码只看带宽。
3.5 在 CPU 上实测一条 roofline
02 第 3 部分在本机一个 CPU 核心上,固定一个 128 MiB 的权重矩阵 ,改变输入的行数 (算术强度约 ,FP32),测实际的吞吐(⏱,每次运行不同,下面是一次的结果):
| m | 算术强度 | 有效带宽 GB/s | 实测 GFLOP/s | 实测 / roofline 上限 |
|---|---|---|---|---|
| 1 | 0.50 | 201 | 100 | 1.00 |
| 2 | 1.00 | 24 | 24 | 0.12 |
| 8 | 3.99 | 44 | 176 | 0.22 |
| 32 | 15.8 | 47 | 749 | 0.25 |
| 128 | 61.1 | 30 | 1826 | 0.60 |
| 1024 | 372 | 8 | 3042 | 1.00 |
(两条”天花板”取自实测:带宽取所有点里最高的有效带宽,约 200 GB/s;算力取最高的 GFLOP/s,约 3000。)三点值得注意:
- 整体是 roofline 的两段式:强度低时吞吐受带宽限制,强度高时趋于算力上限。这台机器的平衡点约 15 FLOP/字节,远低于 H100 的 295(CPU 的带宽相对算力更”宽裕”),所以数字不能迁移,形状和分析方法可以。
- 天花板要用你关心的运算去测。矩阵-向量乘法的有效带宽约 200 GB/s,高于用”复制内存”(约 112 GB/s)或”求和”(约 61 GB/s)测出来的带宽:不同的代码路径能用到的带宽并不相同。
- roofline 只给上限。–16 时只达到上限的 12%–25%,耗时几乎不随 变化, 甚至比 还慢——这几个形状走了库里另一个较慢的实现。离上限越远的地方越值得专门优化;推理里批大小很小的矩阵乘法正是这样的地方(第 18 讲的很多工作就是为它们写专门的核函数)。
4. 分块:让快而小的存储发挥作用 · Tiling
4.1 问题:朴素的矩阵乘法没有复用
§3.2 说矩阵乘法”至少”要读写 个数,前提是每个数只从 HBM 读一次。朴素的三重循环做不到这一点:计算 时,每次乘加都从 HBM 读一个 和一个 ,同一个 被读了 次。算术强度只有 0.5,比平衡点低几百倍。
4.2 分块
分块(tiling):把 切成 的块,每个线程块负责一个输出块。它沿 方向循环:每次把 的一个 块和 的一个 块从 HBM 搬进 SRAM,在 SRAM 里做完这两个块的乘加、累加到寄存器里的输出块上,再搬下一对。读进 SRAM 的每个数被用了 次。
数一下 矩阵乘法从 HBM 读写的数据量:输出块有 个,每个要沿 方向搬 次、每次两个块( 个数),最后写回一次:
访存量 ,算术强度 (BF16)。03 第 1 部分用逐块计数验证了这个公式():
| 块边长 T | 显存读写字节 | 算术强度 | 需要的 SRAM(3 个 BF16 块) |
|---|---|---|---|
| 1 | 67,239,936 | 0.5 | — |
| 32 | 2,228,224 | 15.1 | 6 KiB |
| 128 | 655,360 | 51.2 | 96 KiB |
| 256 | 393,216 | 85.3 | 384 KiB |
块不能无限大:、、 三个块要同时放进一个 SM 的 SRAM。H100 每个 SM 228 KiB,最多放下边长约 197 的三个 BF16 块;边长 256 就放不下了。所以 GPU 矩阵乘法库通常用 128 左右的块,再在寄存器层面做更小的分块;真实的库还会用长方形的块(例如 128×256)、让相邻的线程块共享 L2 缓存里的数据,进一步减少访问 HBM 的次数。SRAM 的大小决定了算术强度的上限——这是 H100 把 SRAM 从 A100 的 164 KiB 加大到 228 KiB 的原因之一。
4.3 分块不改变结果(只改变舍入)
03 第 2 部分:分块和不分块在数学上完全相同,只是加法顺序不同。按 P1 §2.6(浮点加法不满足结合律),结果有舍入级别的差异:FP64 约 1e-13,FP32 约 4e-5。FP32 下分块结果离精确值的误差(约 1.1e-5–1.4e-5)和直接计算(3.8e-5)是同一量级——换一种加法顺序,舍入误差就换一种,谁也不比谁更”对”。这也是”同样的模型、不同的 GPU 或库版本,输出有微小差别”的原因之一。
4.4 CPU 上的同一个道理
03 第 3 部分在本机上转置一个 4096×4096 的 FP32 矩阵(⏱):
| 写法 | 耗时(一次运行) | 相对于普通复制 |
|---|---|---|
| 普通复制 | 1.2 ms | 1× |
NumPy 朴素转置 a.T.copy() | 54.4 ms | 47× |
| 分块转置(64×64 的块,纯 Python 双重循环) | 20.5 ms | 18× |
PyTorch x.t().contiguous() | 11.4 ms | 10× |
转置要读写的数据量和复制完全一样,朴素写法却慢了几十倍:按列读一个行优先的矩阵,每读一个数都要跳过 4096 个数,CPU 每次从内存搬进缓存的一整个缓存行(通常 64–128 字节)只用到 4 个字节。分块转置哪怕用的是慢吞吞的 Python 循环,也快了 2.7 倍:每个小块读进缓存之后,里面的每个数都被用到了。
这和 GPU 上的分块是同一个道理:数据搬进快存储之后,要在被挤出去之前尽量多用几次。区别是 CPU 的缓存由硬件自动管理,GPU 的 SRAM 要由程序员(或 Triton、编译器)显式地管理。
5. 算子融合与开销 · Fusion and overhead
5.1 算子融合
一个 Transformer 层里有很多逐元素运算:残差相加、归一化、激活函数、dropout……每一个单独做,都要把整个激活张量从 HBM 读出来、再写回去。04 第 1 部分推算:6400 万个元素(例如 [8, 2048, 4096] 的 BF16 激活),在 H100 上:
| 运算个数 k | 分开做(k 个核函数) | 融合成 1 个核函数 | 加速比 |
|---|---|---|---|
| 1 | 85 µs | 85 µs | 1.0× |
| 4 | 341 µs | 85 µs | 4.0× |
| 8 | 681 µs | 85 µs | 8.0× |
算子融合(kernel fusion)把多个运算写进同一个核函数:数据读进寄存器后连续做完所有运算,中间结果不写回 HBM。对访存受限的运算,加速比约等于融合的运算个数。torch.compile、XLA 这类编译器的主要收益就来自自动融合;FlashAttention(第 18 讲)、融合的 LayerNorm、SwiGLU、交叉熵则是手写的融合核函数。
5.2 融合只对访存受限的运算有效
04 第 2 部分在本机 CPU 上做了两个对照实验(⏱):
- 实验 A:1677 万个 FP32 元素,连乘 次常数,分开做 遍 vs 合成一遍(结果在舍入误差内相同)。 时分开做慢了 8.1 倍——每多一遍内存往返,就多一份时间,和 §5.1 的推算一致。
- 实验 B:tanh 近似的 GELU,手写成 9 个独立运算 vs PyTorch 的
F.gelu(一个融合运算)。融合版只快了 1.1 倍。原因是在这个 CPU 核心上,一次 tanh 就相当于约 32 次逐元素加法:GELU 在这里是计算受限的,省下几遍内存往返无关紧要。
教训:先判断瓶颈,再决定怎么优化。同一个运算在不同硬件上可以落在 roofline 的不同一侧:GPU 有专门计算 tanh、exp 的特殊函数单元,GELU 在 GPU 上通常仍是访存受限的,融合的收益就会显现出来。
5.3 固定开销
04 第 3 部分测了一次 torch.add(a, b, out=c) 在不同大小下的耗时(⏱):
| 元素个数 | 每次调用 | 有效带宽 |
|---|---|---|
| 16 | 0.38 µs | 0.5 GB/s |
| 4,096 | 0.63 µs | 78 GB/s |
| 65,536 | 7.07 µs | 111 GB/s |
| 16,777,216 | 1732 µs | 116 GB/s |
16 个元素和 4096 个元素的耗时差不多:这段时间既不是在计算,也不是在搬数据,而是 Python 调用、PyTorch 的分发(检查类型、形状、选择实现)等固定开销。只有数据足够多,耗时才随数据量增长、有效带宽才接近内存带宽。
GPU 上还要加上启动核函数的开销(每次几微秒,取决于软件栈),而 GPU 算得快得多,所以小运算更容易被开销主导。一个小模型、小批量的推理,可能大部分时间都花在 CPU 一个一个地启动核函数上,GPU 反而在等。对策:融合(减少核函数个数);CUDA Graphs(把一串核函数”录”下来,以后一次性重放,省掉逐个启动的开销);加大批大小。第 18 讲的解码优化里这三招都会出现。
5.4 三种瓶颈
| 瓶颈 | 特征 | 典型场景 | 对策 |
|---|---|---|---|
| 计算受限 | 算术强度 > 平衡点 | 大矩阵乘法、大批量训练、prefill | 低精度(BF16/FP8)、用好张量核心 |
| 访存受限 | 算术强度 < 平衡点 | 逐元素运算、归一化、softmax、小批量解码 | 算子融合、增大批大小、量化(读更少的字节)、FlashAttention |
| 开销受限 | 每个运算的数据很少 | 小模型、小批量、Python 循环里的大量小运算 | 融合、CUDA Graphs、torch.compile、减少 Python 层的操作 |
判断方法:算出 FLOPs 和读写字节数,和平衡点比较;再看实测耗时是否接近”FLOPs / 峰值”或”字节 / 带宽”。两者都差很远,通常就是开销或实现问题。对症下药:访存受限的运算,换算力更强的 GPU 毫无用处;开销受限的程序,优化核函数也没用。
6. 把 roofline 用到 Transformer · A Transformer on the roofline
05 用 perf_lab.decoder_layer_ops 把 Llama-3 8B 一层解码器的每个运算(归一化、Q/K/V/O 投影、注意力分数、softmax、加权求和、MLP 的三个矩阵、逐元素运算)的 FLOPs 和字节数列出来,乘以 32 层,再加上输出层,逐个用 H100 的 roofline 估计耗时。全部是理想估计(朴素注意力:分数矩阵写回显存),真实系统通常慢 1.5–3 倍。
6.1 prefill:计算受限
读入一段 2048 token 的提示(批大小 1):
| 类别 | TFLOP | 显存读写 | 理想耗时 | 占总耗时 |
|---|---|---|---|---|
| 线性层 | 28.59 | 22.0 GiB | 28.9 ms | 67% |
| 注意力 | 2.22 | 33.3 GiB | 10.7 ms | 25% |
| 其他(归一化、残差、SwiGLU) | 0.01 | 10.3 GiB | 3.3 ms | 8% |
| 输出层(只算最后一个 token) | 0.00 | 1.0 GiB | 0.3 ms | 1% |
合计 30.8 TFLOP、理想耗时约 43 ms,平均达到峰值的 72%。
- 每个权重矩阵被 2048 个 token 共用,线性层的算术强度约 1024(以 Q 投影为例),远高于平衡点:prefill 是计算受限的。
- FLOPs 基本等于 :前向时每个参数对每个 token 做一次乘加。(嵌入表和输出层的约 10 亿个参数不参与每个 token 的矩阵乘法,注意力另有 2.2 TFLOP。)
- 注意力只占 7% 的 FLOPs,却占了 25% 的时间:朴素实现要把 32 个头 × 2048 × 2048 的分数矩阵写回显存、再读出来做 softmax,这部分是访存受限的;序列越长,它增长得越快()。FlashAttention 用分块和在线 softmax 让分数矩阵根本不离开 SRAM(第 18 讲)。
6.2 decode:访存受限,批大小是关键
每一步为每条序列生成 1 个 token,上下文 2048:
| 批大小 | 每一步耗时 | 每条序列 token/s | 总吞吐 token/s | 算力利用率 | KV 缓存 | 放得进 80 GB? |
|---|---|---|---|---|---|---|
| 1 | 4.57 ms | 219 | 219 | 0.4% | 0.2 GiB | 是 |
| 8 | 5.19 ms | 193 | 1,542 | 2.5% | 2.0 GiB | 是 |
| 32 | 7.31 ms | 137 | 4,378 | 7.1% | 8.0 GiB | 是 |
| 128 | 15.79 ms | 63 | 8,104 | 13.2% | 32 GiB | 是 |
| 512 | 52.26 ms | 19 | 9,797 | 15.9% | 128 GiB | 否 |
- 批大小为 1 时,每一步几乎全花在读权重上:除嵌入表外的权重共 14.0 GiB, ms。所以单个用户每秒最多约 220 个 token,无论 GPU 算力多大——这个上限只由显存带宽决定,算力利用率只有 0.4%。
- 批大小增大时,每一步的耗时涨得很慢(权重被共用),总吞吐几乎线性增长。代价是 KV 缓存线性增长:批大小 256 时 64 GiB 的 KV 缓存加上 15 GiB 的权重就放不下了(P1 §5.3)。
- 于是推理优化有两条主线(第 18 讲):增大批大小(连续批处理、用 PagedAttention 精细管理 KV 缓存)和减少每一步读的字节数(量化权重、GQA 缩小 KV 缓存、推测解码让读一次权重产出多个 token)。
6.3 长上下文:KV 缓存成为主角
批大小 32,上下文从 2K 加到 128K:
| 上下文长度 | 每一步耗时 | 注意力占比 | KV 缓存 | 放得进 80 GB? |
|---|---|---|---|---|
| 2,048 | 7.31 ms | 37% | 8 GiB | 是 |
| 8,192 | 15.48 ms | 70% | 32 GiB | 是 |
| 32,768 | 48.18 ms | 90% | 128 GiB | 否 |
| 131,072 | 178.95 ms | 97% | 512 GiB | 否 |
上下文变长后,每一步要读出整段 KV 缓存,它取代权重成了最大的读写量;而且 KV 缓存很快就超出了显存容量。长上下文推理同时受带宽和容量两方面的限制,这是 GQA、KV 缓存量化与压缩、稀疏注意力、状态空间模型(第 19、21 讲)的共同动机。
6.4 训练:C ≈ 6ND
训练一个模型要多少算力?经验公式
( 参数量、 训练 token 数):前向时每个参数对每个 token 做 1 次乘加(2 FLOP,§6.1 验证过);反向要算对激活的梯度和对权重的梯度,各约 2 FLOP,合计约 4;总共 6。这个估计忽略了注意力的 项,上下文为几千 token 时误差不大。
05 第 4 部分:Llama-3 8B(8.03B 参数)在 15T token 上训练, FLOP。实际能达到的算力用 MFU(Model FLOPs Utilization,实际做的有用运算 / 峰值算力)表示,大规模训练通常在 30%–50%:
| MFU | 每卡有效 TFLOP/s | H100 小时 | 1024 块 H100 要几天 |
|---|---|---|---|
| 30% | 297 | 68 万 | 28 |
| 40% | 396 | 51 万 | 21 |
| 50% | 494 | 41 万 | 17 |
即使是 8B 这样的”小”模型,也要几十万 GPU 小时:用一千块 H100 也要三周左右。MFU 达不到 100% 的原因包括:访存受限的运算(§3、§5)、多卡之间的通信、流水线并行的气泡(第 17 讲)、故障与重启。把训练分布到成千上万块 GPU 上之后,它们之间怎样交换数据就成了新的瓶颈——这是下一篇前置讲 P4 的主题。
7. 小结 · Summary
- CPU 追求低延迟,GPU 追求高吞吐:GPU 用成千上万个简单线程同时做同样的事。深度学习的算力几乎全部来自低精度的张量核心(H100 的 BF16 是 FP32 通用单元的 15 倍)。
- 执行模型:32 个线程组成一个线程束,同时执行同一条指令;SM 靠同时驻留很多线程束来隐藏访存延迟;线程束内的分支会让效率减半;线程块数不整齐时会有波次量化损失。
- 存储层次:寄存器 → SRAM(每 SM 一两百 KiB)→ L2(几十 MiB)→ HBM(80 GB,3.35 TB/s)。越快越小。
- roofline:;算术强度低于平衡点(H100 BF16 约 295)是访存受限,高于是计算受限。逐元素运算、softmax、归一化、批大小为 1 的矩阵-向量乘法都是访存受限的,只能用到峰值的 1% 以下。
- 批大小让权重被多个 token 共用,算术强度约等于批大小;H100 上要到约 512 才变成计算受限。
- 分块让读进 SRAM 的每个数被复用 次,算术强度约 (BF16);SRAM 容量限制了块的大小,也就限制了算术强度。CPU 上的转置也能看到同样的局部性效应(朴素写法慢几十倍)。
- 算子融合省掉中间结果的内存往返,对访存受限的运算加速比约等于融合的运算数,对计算受限的运算几乎无效;小运算会被固定开销主导,用融合、CUDA Graphs、加大批量来应对。
- Transformer:prefill 计算受限(2048 token 约 43 ms、峰值的 72%,但朴素注意力占了 25% 的时间);decode 访存受限(批大小 1 时每秒最多约 220 token,只看带宽);长上下文时 KV 缓存成为主要的读写量和显存占用;训练算力 ,8B 模型 15T token 约 50 万 H100 小时。
8. 常见误区 · Common pitfalls
- “GPU 越快,所有运算都越快。” 访存受限的运算只看带宽;从 A100 到 H100,算力涨了 3.2 倍,带宽只涨了 1.6 倍。
- “FLOPs 少的运算一定快。” 注意力在 prefill 里只占 7% 的 FLOPs,却占了 25% 的时间;softmax、归一化的 FLOPs 几乎可以忽略,耗时却不能。
- “用 FP32 训练只是慢一点。” H100 上 FP32 通用单元的算力只有 BF16 张量核心的 1/15。
- “生成文字时 GPU 满负荷运转。” 批大小为 1 时算力利用率不到 1%,GPU 大部分时间在等着读权重。
- “批大小翻倍,解码的延迟就翻倍。” 访存受限时,批大小从 1 涨到 32,每一步只从 4.6 ms 涨到 7.3 ms。
- “矩阵维度多一点点,耗时也只多一点点。” 波次量化和块量化会让耗时阶梯式上升;维度选成 64 / 128 的倍数。
- “融合总能加速。” 只对访存受限的运算有效;本机 CPU 上 GELU 是计算受限的,融合只快了 1.1 倍。
- “roofline 的预测就是实际耗时。” 它是理想上限;真实的库可能远低于上限(本机 CPU 上 –16 的矩阵乘法只到上限的 12%–25%)。
- “小模型在 GPU 上一定跑得飞快。” 小运算会被核函数启动和 Python 开销主导,GPU 反而在等 CPU。
- “训练算力只和参数量有关。” :训练数据量 同样重要(第 15 讲的规模定律就是在权衡 和 )。
9. 练习 · Exercises
练习 1(平衡点)。 (a) A100 的 FP32(非张量核心)峰值 19.5 TFLOP/s、带宽 2.039 TB/s,它的 FP32 平衡点是多少?(b) 一个 FP32 的 4096³ 矩阵乘法在 A100 上用 FP32 通用单元算,是计算受限还是访存受限?理想耗时多少?换成 BF16 张量核心呢?
参考答案
(a) FLOP/字节。
(b) FP32 时算术强度 ,远高于 9.6,计算受限;耗时 ms。BF16 张量核心:强度 ,仍计算受限,耗时 ms,快约 16 倍。可以用 perf_lab.roofline(*matmul_cost(4096, 4096, 4096, bytes_per=4), A100, dtype="fp32") 验证。
练习 2(访存受限运算的耗时)。 H100 上,对一个 [16, 4096, 8192] 的 BF16 张量做 RMSNorm(读一遍、写一遍),理想耗时是多少?如果把它和前面的残差相加融合成一个核函数(原本残差相加要读两个张量、写一个),能省多少时间?
参考答案
元素数 。RMSNorm 读写 字节/元素,共 2.15 GB,耗时 ms。残差相加单独做:读两个、写一个,6 字节/元素,约 0.96 ms;合计 1.60 ms。融合后:读两个输入、写一个输出,6 字节/元素,约 0.96 ms,省掉了”写残差结果、再读回来”的 4 字节/元素(约 0.64 ms),快约 1.7 倍。
练习 3(量化让解码变快)。 Llama-3 70B 在一块 H100 上做批大小为 1 的解码。(a) BF16 权重(约 141 GB)放得下吗?(b) 量化成 INT4(每个参数 0.5 字节)后,每一步的理想耗时和每秒 token 数上限是多少?(c) 为什么量化对解码的加速几乎和压缩比成正比,对 prefill 却没那么明显?
参考答案
(a) 放不下(80 GB)。
(b) 约 GB,每步约 ms,每秒最多约 95 个 token。
(c) 解码是访存受限的,耗时 ≈ 读权重的字节数 / 带宽,字节数减为 1/4,耗时就近似减为 1/4(解量化的额外计算在访存受限时几乎是”免费”的)。prefill 是计算受限的,读权重的时间本来就不是瓶颈;而且 INT4 权重通常要先解量化成 BF16 再用张量核心计算,算力并没有提升。
练习 4(隐藏延迟)。 用 §2.2 的模型:访存延迟 周期,每次访存之间有 条计算指令,要多少个线程束才能让利用率接近 100%?每个 SM 最多 64 个,够吗?怎样改写代码可以降低对线程束数的要求?
参考答案
需要 个线程束,64 个够用(但前提是每个线程用的寄存器和共享内存不多,否则驻留不了这么多线程束)。降低要求的办法是提高每次访存之间的计算量:例如每个线程计算多个输出元素、把数据先搬进 SRAM 再反复使用(§4 的分块),也就是提高算术强度。可以用 perf_lab.simulate_latency_hiding(51, compute_per_load=8, mem_latency=400) 验证。
练习 5(波次量化)。 H100 有 132 个 SM,一个 8192×8192 的输出按 128×128 分块,有多少个线程块、要几波、利用率多少?如果改成 256×128 的块呢?
参考答案
128×128: 个块, 波,利用率 。256×128: 个块, 波,利用率 ——碰巧一样。块越大,块数越少,量化损失的相对影响可能越大;库会根据形状选择块的大小和调度方式(例如”持久化核函数”让每个 SM 循环处理多个块)。用 perf_lab.wave_efficiency(4096, 132) 验证。
练习 6(SRAM 与块大小)。 如果用 FP32 做分块矩阵乘法,H100 每个 SM 的 228 KiB SRAM 最多能放下边长多少的三个块?此时的算术强度大约是多少?和 BF16 比呢?
参考答案
,。算术强度约 (FP32 每个数 4 字节)≈ 35。BF16 时 、强度约 。低精度不仅让每次运算更快,还让同样大小的 SRAM 放下更大的块、复用更多次——这是低精度”双重收益”的又一个例子。
练习 7(长上下文的转折点)。 Llama-3 8B,批大小为 1 的解码。上下文多长时,每一步读 KV 缓存的字节数等于读权重的字节数(除嵌入表外约 14.0 GiB)?批大小为 32 时呢?
参考答案
每个 token 的 KV 缓存 128 KiB(P1 §5.3)。,即约 11 万 token。批大小为 32 时,每一步要读 32 条序列的 KV 缓存,转折点降到约 token。这和 §6.3 的表一致:批大小 32、上下文 2048 时注意力已占 37%,8192 时占 70%。
练习 8(MFU)。 假设用 2048 块 H100 花 30 天在 15T token 上训练完一个 8B 模型。MFU 是多少?如果实际 MFU 是 40%,需要多少天?
参考答案
可用算力 FLOP,所需 ,MFU 。MFU 40% 时约 天。MFU 是评价训练系统效率的核心指标:同样的硬件,MFU 从 14% 提高到 40%,训练时间缩短到约三分之一。
练习 9(开销)。 一个小模型每次前向要启动 200 个核函数,每个核函数的计算本身只要 1 µs,启动开销 5 µs。(a) 每次前向至少要多久?GPU 的忙碌比例是多少?(b) 用 CUDA Graphs 把启动开销降到每个 0.5 µs,或者把 200 个核函数融合成 20 个,各能快多少?
参考答案
(a) µs,GPU 只有 的时间在计算。
(b) CUDA Graphs: µs,快 4 倍。融合成 20 个(假设计算总量不变,仍是 200 µs): µs,同样快 4 倍。两者结合: µs。开销受限时,优化核函数本身的速度几乎没有意义。
练习 10(自己测一测)。 修改 02_roofline.py 第 3 部分:把 W 改小到 1024×1024(4 MiB),再测 m = 1 的有效带宽。和 128 MiB 的 W 相比有什么不同?为什么?
参考答案
以你的运行结果为准。预期:W 小到能放进 CPU 的缓存时,重复测量会从缓存而不是内存读取数据,有效带宽可能明显更高(本讲义写作时,W 为 16 MiB 的第一版实验就出现过有效带宽”超过内存带宽”的现象,见 code/prereq3/README.md)。教训:做性能测量时要确认数据实际住在存储层次的哪一层;roofline 的带宽要用对应那一层的带宽。
10. 延伸阅读 · Further reading
- Williams, Waterman & Patterson (2009), Roofline: An Insightful Visual Performance Model for Multicore Architectures, CACM:roofline 模型的原始论文。
- Horace He, Making Deep Learning Go Brrrr From First Principles(博客):计算 / 访存 / 开销三种瓶颈,本讲 §5 的框架。
- NVIDIA, CUDA C++ Programming Guide 与 H100 Tensor Core GPU Architecture 白皮书:线程层次、存储层次、张量核心。
- NVIDIA, Matrix Multiplication Background User’s Guide(Deep Learning Performance 文档):算术强度、块量化与波次量化。
- Dao et al. (2022), FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness:用分块 + 在线 softmax 把注意力从访存受限拉回来(第 18 讲的阅读材料)。
- Kaplan et al. (2020)、Hoffmann et al. (2022): 的来历与计算最优配比(第 15 讲)。
- 本仓库的 CS336 第 5 讲(GPU 与 TPU) 和 CS349D 前置讲 P1(GPU 与性能):更深入的 GPU 执行模型模拟与推理系统视角。
11. 下一篇预告 · Next
前置讲 P4:多卡与通信(建议第 17 讲之前读)。一块 GPU 放不下、也算不完一个大模型的训练。P4 讲多块 GPU 之间怎样交换数据:broadcast、reduce、all-reduce、all-gather、reduce-scatter、all-to-all 这些”集合通信”各做什么;环形 all-reduce 为什么让每块卡的通信量几乎和卡数无关;用 α-β 模型估计通信耗时;NVLink 和 InfiniBand 的速度差多少。我们会在 CPU 上用列表模拟多块 GPU,真实地执行这些算法,并用 PyTorch 的 gloo 后端在多个进程之间跑一次真正的 all-reduce。