前置讲 P3 · GPU 与性能模型 · GPUs and Performance Models

Generative AI · 预备知识 3。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓

个人整理的学习笔记,非官方讲义;课程来源与整理说明见课程介绍。配套示例未逐一运行验证。

本篇目录

这是一篇前置讲,不对应原课的某一次课。原课的第五单元”扩大规模”(第 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

  1. 说清楚 CPU 和 GPU 的设计取舍:少量强核心、追求低延迟 vs 大量简单核心、追求高吞吐;知道张量核心和低精度在 GPU 算力里的地位。
  2. 理解 GPU 的执行模型:线程、线程束(warp)、线程块、SM;用”大量线程束轮流执行”隐藏访存延迟;知道线程束分化和波次量化为什么会损失性能。
  3. 记住存储层次(寄存器、SRAM、L2、HBM)的容量和速度量级;会算一个运算的算术强度,用 roofline 模型判断它是计算受限还是访存受限,并估出理想耗时。
  4. 理解分块为什么能把矩阵乘法从访存受限变成计算受限,以及 SRAM 容量怎样限制块的大小。
  5. 理解算子融合为什么能加速访存受限的运算(而对计算受限的运算几乎无效),以及小运算为什么会被固定开销主导。
  6. 能用 roofline 分析一个 Transformer 的 prefill、decode 和训练:为什么 prefill 计算受限、decode 访存受限,批大小和上下文长度怎样改变这一切;会用 6ND6ND 估计训练算力。

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 的公开规格:

GPUSM 数BF16 张量核心 TFLOP/sFP32(非张量核心)TFLOP/sHBM 带宽 TB/s显存每 SM 的 SRAML2BF16 平衡点 FLOP/字节
A100 SXM10831219.52.0480 GB164 KiB40 MiB153
H100 SXM132989673.3580 GB228 KiB50 MiB295

(对照:本机 CPU 的一个核心实测约 3 TFLOP/s 的 FP32 矩阵乘法、约 120 GB/s 的内存复制。)三个观察:

  1. 算力几乎全部来自张量核心,而且只在低精度下才有。 H100 的 BF16 张量核心算力是它 FP32 通用单元的 15 倍。张量核心是专门做小矩阵乘加(例如 16×1616 \times 16 的块)的硬件单元。这是前置讲 P1 说”训练要用 BF16”的硬件原因——用 FP32 训练,你只能用到 GPU 的一小部分。
  2. 算力增长快于带宽增长。 从 A100 到 H100,BF16 算力涨了 3.2 倍,带宽只涨了 1.6 倍。所以”平衡点”(§3.3)从 153 升到 295:每从显存读一个字节,要做更多的运算才能把算力用满。这种”内存墙”(memory wall)是过去十几年的普遍趋势,也是为什么越来越多的优化都在想办法少读写显存。
  3. 快的存储小,大的存储慢。 每个 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 上的一个”核心”,有自己的调度器、计算单元、寄存器文件和 SRAMH100 有 132 个

一次 GPU 运算(一个”核函数”)的执行过程是:把网格里的线程块分发到各个 SM 上;每个 SM 上同时驻留若干个线程块,它们的线程束由 SM 的调度器轮流发射指令。

2.2 用并行度隐藏延迟

从显存(HBM)读一个数,要等几百个时钟周期才能拿到。CPU 用大缓存和复杂的乱序执行来减少等待;GPU 的办法简单粗暴:同时驻留很多线程束,一个在等数据,就切换到另一个。切换几乎没有代价,因为每个线程束的寄存器一直留在 SM 上。

01 第 2 部分模拟了这个机制:每个线程束反复”做 cc 条计算指令,然后访存一次(等 400 个周期)“,调度器每个周期发射一条准备好的指令:

每次访存间的计算量 c1 个线程束8 个32 个64 个(上限)
40.0100.0800.3200.637
160.0390.3091.0001.000
640.1401.0001.0001.000

(表中是计算单元的利用率;理论值约为 min⁡(1,W⋅c/(c+L))\min(1, W \cdot c / (c + L)),WW 是线程束数、LL 是延迟。)

  • 只有 1 个线程束时,利用率只有 1%:它算 4 个周期、等 400 个周期。
  • 线程束越多,利用率越高。驻留线程束的数量占上限的比例叫占用率(occupancy)。但上限是 64 个:如果每次访存之间只有 4 条计算,64 个线程束也只能用到 64%。
  • 算术强度高的代码对并行度的要求低:c=64c = 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 部分:一个 n×nn \times n 的输出矩阵按 128×128128 \times 128 分块,每块交给一个线程块;H100 有 132 个 SM(简化假设每个 SM 一次跑一个块)。线程块要分若干”波”(wave)跑完:

n线程块数波数SM 利用率
4096102480.970
4224108990.917
4352115690.973

nn 从 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 GB3.35 TB/s,延迟数百个周期
CPU 内存主板上,经 PCIe 连接数百 GB 到数 TBPCIe Gen5 每个方向约 64 GB/s

规律和 CPU 一样:越快越小、越大越慢。模型的权重、激活值、KV 缓存都放在 HBM 里;每次计算,数据要从 HBM 搬到 SRAM / 寄存器,算完再写回 HBM。性能优化的大部分工作,就是减少 HBM 和 SRAM 之间的搬运。

3.2 算术强度

对一个运算,数两样东西:

  • FLOPs:浮点运算次数。矩阵乘法 Cm×n=Am×kBk×nC_{m \times n} = A_{m \times k} B_{k \times n} 是 2mnk2mnk(每个输出元素做 kk 次乘法和 kk 次加法)。
  • 字节数:至少要从 HBM 读写多少数据。矩阵乘法至少要读 AA、BB,写 CC:(mk+kn+mn)×(mk + kn + mn) \times 每个数的字节数。

两者之比就是算术强度(arithmetic intensity),单位 FLOP/字节:每从显存搬一个字节,能做多少次运算。

运算FLOPs字节数(BF16)算术强度
逐元素加法 c=a+bc = a + b(N 个元素)NN3N×23N \times 21/61/6
GELU(每元素约 8 次运算)8N8N2N×22N \times 22
方阵乘法 n3n^32n32n^33n2×23n^2 \times 2n/3n/3
矩阵-向量 [1,d]×[d,d][1, d] \times [d, d]2d22d^2≈d2×2\approx d^2 \times 2≈1\approx 1
批量矩阵乘 [b,d]×[d,d][b, d] \times [d, d]2bd22bd^2≈(d2+2bd)×2\approx (d^2 + 2bd) \times 2≈b\approx b(b≪db \ll d 时)

3.3 roofline 模型

GPU 可以同时计算和搬数据(流水线化),所以一个运算的耗时由较慢的那个决定:

t≈max⁡(FLOPs峰值算力, 字节数内存带宽)t \approx \max\Big(\frac{\text{FLOPs}}{\text{峰值算力}},\ \frac{\text{字节数}}{\text{内存带宽}}\Big)

等价地,能达到的算力是 min⁡(峰值算力, 算术强度×带宽)\min(\text{峰值算力},\ \text{算术强度} \times \text{带宽})。把它画在”横轴算术强度、纵轴 FLOP/s”的对数坐标上,是一条先斜后平的折线,像一个屋顶——所以叫 roofline。转折点的横坐标

平衡点(ridge point)=峰值算力内存带宽\text{平衡点(ridge point)} = \frac{\text{峰值算力}}{\text{内存带宽}}

在 H100 的 BF16 下是 989/3.35≈295989 / 3.35 \approx 295 FLOP/字节。算术强度低于它的运算是访存受限(memory-bound),耗时 ≈ 字节数 / 带宽,算得再快也没用;高于它的是计算受限(compute-bound),耗时 ≈ FLOPs / 峰值。

02 第 1 部分把常见运算放到 H100 的 roofline 上:

运算算术强度瓶颈理想耗时占峰值
逐元素加法 4096×40960.17访存30.0 µs0.1%
GELU 4096×40962.00访存20.0 µs0.7%
softmax 4096 × 40961.25访存20.0 µs0.4%
RMSNorm 4096 × 40961.25访存20.0 µs0.4%
矩阵-向量 [1,4096]@[4096,4096]1.00访存10.0 µs0.3%
矩阵乘法 512³171访存0.5 µs58%
矩阵乘法 4096³1365计算139 µs100%

逐元素运算、softmax、归一化的算术强度比平衡点低两个数量级,永远是访存受限的,耗时只取决于读写了多少字节,和做了多少运算几乎无关。大矩阵乘法则是计算受限,可以接近峰值。一个 Transformer 的 FLOPs 绝大部分在矩阵乘法里,而时间却有相当一部分花在那些”不怎么计算”的运算上(§6)。

3.4 批大小:把矩阵-向量乘法变成矩阵乘法

生成文字时(decode),每一步只为每条序列算一个新 token,线性层就变成了矩阵-向量乘法:读一整个 d×dd \times d 的权重矩阵,只做 2d22d^2 次运算,算术强度约 1。02 第 2 部分:如果同时为 bb 个用户各生成一个 token,同一个权重矩阵被 bb 个向量共用,算术强度约等于 bb:

GPU批大小 b算术强度瓶颈这一次乘法的耗时平均每个 token占峰值
H10011.0访存10.0 µs10.0 µs0.3%
H1001615.9访存10.1 µs0.6 µs5.4%
H100128120.5访存10.6 µs0.1 µs40.8%
H100256227.6访存11.3 µs0.04 µs77.1%
H100512409.6计算17.4 µs0.03 µs100%

批大小从 1 涨到 128,这一次乘法的耗时几乎不变(都在等着读同样的权重),平均到每个 token 的成本却降了约 100 倍。要变成计算受限,批大小要达到平衡点附近:A100 约 256,H100 约 512。这就是推理服务一定要把很多用户的请求拼成一批(连续批处理,第 18 讲)的原因;也说明了为什么新一代 GPU 算力的提升对小批量解码帮助有限——小批量解码只看带宽。

3.5 在 CPU 上实测一条 roofline

02 第 3 部分在本机一个 CPU 核心上,固定一个 128 MiB 的权重矩阵 W4096×8192W_{4096 \times 8192},改变输入的行数 mm(算术强度约 m/2m/2,FP32),测实际的吞吐(⏱,每次运行不同,下面是一次的结果):

m算术强度有效带宽 GB/s实测 GFLOP/s实测 / roofline 上限
10.502011001.00
21.0024240.12
83.99441760.22
3215.8477490.25
12861.13018260.60
1024372830421.00

(两条”天花板”取自实测:带宽取所有点里最高的有效带宽,约 200 GB/s;算力取最高的 GFLOP/s,约 3000。)三点值得注意:

  1. 整体是 roofline 的两段式:强度低时吞吐受带宽限制,强度高时趋于算力上限。这台机器的平衡点约 15 FLOP/字节,远低于 H100 的 295(CPU 的带宽相对算力更”宽裕”),所以数字不能迁移,形状和分析方法可以。
  2. 天花板要用你关心的运算去测。矩阵-向量乘法的有效带宽约 200 GB/s,高于用”复制内存”(约 112 GB/s)或”求和”(约 61 GB/s)测出来的带宽:不同的代码路径能用到的带宽并不相同。
  3. roofline 只给上限。m=2m = 2–16 时只达到上限的 12%–25%,耗时几乎不随 mm 变化,m=2m = 2 甚至比 m=4m = 4 还慢——这几个形状走了库里另一个较慢的实现。离上限越远的地方越值得专门优化;推理里批大小很小的矩阵乘法正是这样的地方(第 18 讲的很多工作就是为它们写专门的核函数)。

4. 分块:让快而小的存储发挥作用 · Tiling

4.1 问题:朴素的矩阵乘法没有复用

§3.2 说矩阵乘法”至少”要读写 (mk+kn+mn)(mk + kn + mn) 个数,前提是每个数只从 HBM 读一次。朴素的三重循环做不到这一点:计算 Cij=∑kAikBkjC_{ij} = \sum_k A_{ik} B_{kj} 时,每次乘加都从 HBM 读一个 AikA_{ik} 和一个 BkjB_{kj},同一个 AikA_{ik} 被读了 nn 次。算术强度只有 0.5,比平衡点低几百倍。

4.2 分块

分块(tiling):把 CC 切成 T×TT \times T 的块,每个线程块负责一个输出块。它沿 kk 方向循环:每次把 AA 的一个 T×TT \times T 块和 BB 的一个 T×TT \times T 块从 HBM 搬进 SRAM,在 SRAM 里做完这两个块的乘加、累加到寄存器里的输出块上,再搬下一对。读进 SRAM 的每个数被用了 TT 次。

数一下 n×nn \times n 矩阵乘法从 HBM 读写的数据量:输出块有 (n/T)2(n/T)^2 个,每个要沿 kk 方向搬 n/Tn/T 次、每次两个块(2T22T^2 个数),最后写回一次:

读写量=(nT)2⋅nT⋅2T2+n2=2n3T+n2 个数\text{读写量} = \Big(\frac{n}{T}\Big)^2 \cdot \frac{n}{T} \cdot 2T^2 + n^2 = \frac{2n^3}{T} + n^2 \text{ 个数}

访存量 ∝1/T\propto 1/T,算术强度 ≈2n3/(2n3/T×2 字节)=T/2\approx 2n^3 / (2n^3/T \times 2 \text{ 字节}) = T/2(BF16)。03 第 1 部分用逐块计数验证了这个公式(n=256n = 256):

块边长 T显存读写字节算术强度需要的 SRAM(3 个 BF16 块)
167,239,9360.5—
322,228,22415.16 KiB
128655,36051.296 KiB
256393,21685.3384 KiB

块不能无限大:AA、BB、CC 三个块要同时放进一个 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 ms1×
NumPy 朴素转置 a.T.copy()54.4 ms47×
分块转置(64×64 的块,纯 Python 双重循环)20.5 ms18×
PyTorch x.t().contiguous()11.4 ms10×

转置要读写的数据量和复制完全一样,朴素写法却慢了几十倍:按列读一个行优先的矩阵,每读一个数都要跳过 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 个核函数加速比
185 µs85 µs1.0×
4341 µs85 µs4.0×
8681 µs85 µs8.0×

算子融合(kernel fusion)把多个运算写进同一个核函数:数据读进寄存器后连续做完所有运算,中间结果不写回 HBM。对访存受限的运算,加速比约等于融合的运算个数。torch.compile、XLA 这类编译器的主要收益就来自自动融合;FlashAttention(第 18 讲)、融合的 LayerNorm、SwiGLU、交叉熵则是手写的融合核函数。

5.2 融合只对访存受限的运算有效

04 第 2 部分在本机 CPU 上做了两个对照实验(⏱):

  • 实验 A:1677 万个 FP32 元素,连乘 kk 次常数,分开做 kk 遍 vs 合成一遍(结果在舍入误差内相同)。k=8k = 8 时分开做慢了 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) 在不同大小下的耗时(⏱):

元素个数每次调用有效带宽
160.38 µs0.5 GB/s
4,0960.63 µs78 GB/s
65,5367.07 µs111 GB/s
16,777,2161732 µs116 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.5922.0 GiB28.9 ms67%
注意力2.2233.3 GiB10.7 ms25%
其他(归一化、残差、SwiGLU)0.0110.3 GiB3.3 ms8%
输出层(只算最后一个 token)0.001.0 GiB0.3 ms1%

合计 30.8 TFLOP、理想耗时约 43 ms,平均达到峰值的 72%。

  • 每个权重矩阵被 2048 个 token 共用,线性层的算术强度约 1024(以 Q 投影为例),远高于平衡点:prefill 是计算受限的。
  • FLOPs 基本等于 2×参数量×token 数2 \times \text{参数量} \times \text{token 数}:前向时每个参数对每个 token 做一次乘加。(嵌入表和输出层的约 10 亿个参数不参与每个 token 的矩阵乘法,注意力另有 2.2 TFLOP。)
  • 注意力只占 7% 的 FLOPs,却占了 25% 的时间:朴素实现要把 32 个头 × 2048 × 2048 的分数矩阵写回显存、再读出来做 softmax,这部分是访存受限的;序列越长,它增长得越快(∝s2\propto s^2)。FlashAttention 用分块和在线 softmax 让分数矩阵根本不离开 SRAM(第 18 讲)。

6.2 decode:访存受限,批大小是关键

每一步为每条序列生成 1 个 token,上下文 2048:

批大小每一步耗时每条序列 token/s总吞吐 token/s算力利用率KV 缓存放得进 80 GB?
14.57 ms2192190.4%0.2 GiB是
85.19 ms1931,5422.5%2.0 GiB是
327.31 ms1374,3787.1%8.0 GiB是
12815.79 ms638,10413.2%32 GiB是
51252.26 ms199,79715.9%128 GiB否
  • 批大小为 1 时,每一步几乎全花在读权重上:除嵌入表外的权重共 14.0 GiB,14.0 GiB/3.35 TB/s≈4.514.0 \text{ GiB} / 3.35 \text{ TB/s} \approx 4.5 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,0487.31 ms37%8 GiB是
8,19215.48 ms70%32 GiB是
32,76848.18 ms90%128 GiB否
131,072178.95 ms97%512 GiB否

上下文变长后,每一步要读出整段 KV 缓存,它取代权重成了最大的读写量;而且 KV 缓存很快就超出了显存容量。长上下文推理同时受带宽和容量两方面的限制,这是 GQA、KV 缓存量化与压缩、稀疏注意力、状态空间模型(第 19、21 讲)的共同动机。

6.4 训练:C ≈ 6ND

训练一个模型要多少算力?经验公式

C≈6NDC \approx 6ND

(NN 参数量、DD 训练 token 数):前向时每个参数对每个 token 做 1 次乘加(2 FLOP,§6.1 验证过);反向要算对激活的梯度和对权重的梯度,各约 2 FLOP,合计约 4;总共 6。这个估计忽略了注意力的 s2s^2 项,上下文为几千 token 时误差不大。

05 第 4 部分:Llama-3 8B(8.03B 参数)在 15T token 上训练,C=7.2×1023C = 7.2 \times 10^{23} FLOP。实际能达到的算力用 MFU(Model FLOPs Utilization,实际做的有用运算 / 峰值算力)表示,大规模训练通常在 30%–50%:

MFU每卡有效 TFLOP/sH100 小时1024 块 H100 要几天
30%29768 万28
40%39651 万21
50%49441 万17

即使是 8B 这样的”小”模型,也要几十万 GPU 小时:用一千块 H100 也要三周左右。MFU 达不到 100% 的原因包括:访存受限的运算(§3、§5)、多卡之间的通信、流水线并行的气泡(第 17 讲)、故障与重启。把训练分布到成千上万块 GPU 上之后,它们之间怎样交换数据就成了新的瓶颈——这是下一篇前置讲 P4 的主题。


7. 小结 · Summary

  1. CPU 追求低延迟,GPU 追求高吞吐:GPU 用成千上万个简单线程同时做同样的事。深度学习的算力几乎全部来自低精度的张量核心(H100 的 BF16 是 FP32 通用单元的 15 倍)。
  2. 执行模型:32 个线程组成一个线程束,同时执行同一条指令;SM 靠同时驻留很多线程束来隐藏访存延迟;线程束内的分支会让效率减半;线程块数不整齐时会有波次量化损失。
  3. 存储层次:寄存器 → SRAM(每 SM 一两百 KiB)→ L2(几十 MiB)→ HBM(80 GB,3.35 TB/s)。越快越小。
  4. roofline:t≈max⁡(FLOPs/峰值,字节/带宽)t \approx \max(\text{FLOPs}/\text{峰值}, \text{字节}/\text{带宽});算术强度低于平衡点(H100 BF16 约 295)是访存受限,高于是计算受限。逐元素运算、softmax、归一化、批大小为 1 的矩阵-向量乘法都是访存受限的,只能用到峰值的 1% 以下。
  5. 批大小让权重被多个 token 共用,算术强度约等于批大小;H100 上要到约 512 才变成计算受限。
  6. 分块让读进 SRAM 的每个数被复用 TT 次,算术强度约 T/2T/2(BF16);SRAM 容量限制了块的大小,也就限制了算术强度。CPU 上的转置也能看到同样的局部性效应(朴素写法慢几十倍)。
  7. 算子融合省掉中间结果的内存往返,对访存受限的运算加速比约等于融合的运算数,对计算受限的运算几乎无效;小运算会被固定开销主导,用融合、CUDA Graphs、加大批量来应对。
  8. Transformer:prefill 计算受限(2048 token 约 43 ms、峰值的 72%,但朴素注意力占了 25% 的时间);decode 访存受限(批大小 1 时每秒最多约 220 token,只看带宽);长上下文时 KV 缓存成为主要的读写量和显存占用;训练算力 C≈6NDC \approx 6ND,8B 模型 15T token 约 50 万 H100 小时。

8. 常见误区 · Common pitfalls

  1. “GPU 越快,所有运算都越快。” 访存受限的运算只看带宽;从 A100 到 H100,算力涨了 3.2 倍,带宽只涨了 1.6 倍。
  2. “FLOPs 少的运算一定快。” 注意力在 prefill 里只占 7% 的 FLOPs,却占了 25% 的时间;softmax、归一化的 FLOPs 几乎可以忽略,耗时却不能。
  3. “用 FP32 训练只是慢一点。” H100 上 FP32 通用单元的算力只有 BF16 张量核心的 1/15。
  4. “生成文字时 GPU 满负荷运转。” 批大小为 1 时算力利用率不到 1%,GPU 大部分时间在等着读权重。
  5. “批大小翻倍,解码的延迟就翻倍。” 访存受限时,批大小从 1 涨到 32,每一步只从 4.6 ms 涨到 7.3 ms。
  6. “矩阵维度多一点点,耗时也只多一点点。” 波次量化和块量化会让耗时阶梯式上升;维度选成 64 / 128 的倍数。
  7. “融合总能加速。” 只对访存受限的运算有效;本机 CPU 上 GELU 是计算受限的,融合只快了 1.1 倍。
  8. “roofline 的预测就是实际耗时。” 它是理想上限;真实的库可能远低于上限(本机 CPU 上 m=2m = 2–16 的矩阵乘法只到上限的 12%–25%)。
  9. “小模型在 GPU 上一定跑得飞快。” 小运算会被核函数启动和 Python 开销主导,GPU 反而在等 CPU。
  10. “训练算力只和参数量有关。” C≈6NDC \approx 6ND:训练数据量 DD 同样重要(第 15 讲的规模定律就是在权衡 NN 和 DD)。

9. 练习 · Exercises

练习 1(平衡点)。 (a) A100 的 FP32(非张量核心)峰值 19.5 TFLOP/s、带宽 2.039 TB/s,它的 FP32 平衡点是多少?(b) 一个 FP32 的 4096³ 矩阵乘法在 A100 上用 FP32 通用单元算,是计算受限还是访存受限?理想耗时多少?换成 BF16 张量核心呢?

参考答案

(a) 19.5/2.039≈9.619.5 / 2.039 \approx 9.6 FLOP/字节。

(b) FP32 时算术强度 2n3/(3n2×4)=n/6≈6832n^3 / (3n^2 \times 4) = n/6 \approx 683,远高于 9.6,计算受限;耗时 2×40963/19.5×1012≈7.02 \times 4096^3 / 19.5 \times 10^{12} \approx 7.0 ms。BF16 张量核心:强度 n/3≈1365>153n/3 \approx 1365 > 153,仍计算受限,耗时 1.37×1011/312×1012≈0.441.37 \times 10^{11} / 312 \times 10^{12} \approx 0.44 ms,快约 16 倍。可以用 perf_lab.roofline(*matmul_cost(4096, 4096, 4096, bytes_per=4), A100, dtype="fp32") 验证。

练习 2(访存受限运算的耗时)。 H100 上,对一个 [16, 4096, 8192] 的 BF16 张量做 RMSNorm(读一遍、写一遍),理想耗时是多少?如果把它和前面的残差相加融合成一个核函数(原本残差相加要读两个张量、写一个),能省多少时间?

参考答案

元素数 16×4096×8192=5.37×10816 \times 4096 \times 8192 = 5.37 \times 10^8。RMSNorm 读写 2×2=42 \times 2 = 4 字节/元素,共 2.15 GB,耗时 2.15×109/3.35×1012≈0.642.15 \times 10^9 / 3.35 \times 10^{12} \approx 0.64 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) 约 70.6×109×0.5=3570.6 \times 10^9 \times 0.5 = 35 GB,每步约 35×109/3.35×1012≈10.535 \times 10^9 / 3.35 \times 10^{12} \approx 10.5 ms,每秒最多约 95 个 token。

(c) 解码是访存受限的,耗时 ≈ 读权重的字节数 / 带宽,字节数减为 1/4,耗时就近似减为 1/4(解量化的额外计算在访存受限时几乎是”免费”的)。prefill 是计算受限的,读权重的时间本来就不是瓶颈;而且 INT4 权重通常要先解量化成 BF16 再用张量核心计算,算力并没有提升。

练习 4(隐藏延迟)。 用 §2.2 的模型:访存延迟 L=400L = 400 周期,每次访存之间有 c=8c = 8 条计算指令,要多少个线程束才能让利用率接近 100%?每个 SM 最多 64 个,够吗?怎样改写代码可以降低对线程束数的要求?

参考答案

需要 W≥(c+L)/c=408/8=51W \ge (c + L)/c = 408/8 = 51 个线程束,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:64×64=409664 \times 64 = 4096 个块,⌈4096/132⌉=32\lceil 4096/132 \rceil = 32 波,利用率 4096/(32×132)=0.9704096 / (32 \times 132) = 0.970。256×128:32×64=204832 \times 64 = 2048 个块,⌈2048/132⌉=16\lceil 2048/132 \rceil = 16 波,利用率 2048/(16×132)=0.9702048 / (16 \times 132) = 0.970——碰巧一样。块越大,块数越少,量化损失的相对影响可能越大;库会根据形状选择块的大小和调度方式(例如”持久化核函数”让每个 SM 循环处理多个块)。用 perf_lab.wave_efficiency(4096, 132) 验证。

练习 6(SRAM 与块大小)。 如果用 FP32 做分块矩阵乘法,H100 每个 SM 的 228 KiB SRAM 最多能放下边长多少的三个块?此时的算术强度大约是多少?和 BF16 比呢?

参考答案

3T2×4≤228×10243 T^2 \times 4 \le 228 \times 1024,T≤139T \le 139。算术强度约 T/4T / 4(FP32 每个数 4 字节)≈ 35。BF16 时 T≤197T \le 197、强度约 T/2≈98T/2 \approx 98。低精度不仅让每次运算更快,还让同样大小的 SRAM 放下更大的块、复用更多次——这是低精度”双重收益”的又一个例子。

练习 7(长上下文的转折点)。 Llama-3 8B,批大小为 1 的解码。上下文多长时,每一步读 KV 缓存的字节数等于读权重的字节数(除嵌入表外约 14.0 GiB)?批大小为 32 时呢?

参考答案

每个 token 的 KV 缓存 128 KiB(P1 §5.3)。14.0 GiB/128 KiB≈1.1×10514.0 \text{ GiB} / 128 \text{ KiB} \approx 1.1 \times 10^5,即约 11 万 token。批大小为 32 时,每一步要读 32 条序列的 KV 缓存,转折点降到约 1.1×105/32≈36001.1 \times 10^5 / 32 \approx 3600 token。这和 §6.3 的表一致:批大小 32、上下文 2048 时注意力已占 37%,8192 时占 70%。

练习 8(MFU)。 假设用 2048 块 H100 花 30 天在 15T token 上训练完一个 8B 模型。MFU 是多少?如果实际 MFU 是 40%,需要多少天?

参考答案

可用算力 2048×989×1012×30×86400≈5.25×10242048 \times 989 \times 10^{12} \times 30 \times 86400 \approx 5.25 \times 10^{24} FLOP,所需 7.23×10237.23 \times 10^{23},MFU ≈13.8%\approx 13.8\%。MFU 40% 时约 30×13.8/40≈10.330 \times 13.8 / 40 \approx 10.3 天。MFU 是评价训练系统效率的核心指标:同样的硬件,MFU 从 14% 提高到 40%,训练时间缩短到约三分之一。

练习 9(开销)。 一个小模型每次前向要启动 200 个核函数,每个核函数的计算本身只要 1 µs,启动开销 5 µs。(a) 每次前向至少要多久?GPU 的忙碌比例是多少?(b) 用 CUDA Graphs 把启动开销降到每个 0.5 µs,或者把 200 个核函数融合成 20 个,各能快多少?

参考答案

(a) 200×(5+1)=1200200 \times (5 + 1) = 1200 µs,GPU 只有 200/1200=17%200 / 1200 = 17\% 的时间在计算。

(b) CUDA Graphs:200×(0.5+1)=300200 \times (0.5 + 1) = 300 µs,快 4 倍。融合成 20 个(假设计算总量不变,仍是 200 µs):20×5+200=30020 \times 5 + 200 = 300 µs,同样快 4 倍。两者结合:20×0.5+200=21020 \times 0.5 + 200 = 210 µ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):C≈6NDC \approx 6ND 的来历与计算最优配比(第 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。

← 返回课程目录