对应 Maps to:原课没有对应的一次课。CS349D 默认学生学过计算机体系结构;这篇前置讲为没有这类背景的读者补上读后面各讲需要的 GPU 知识,起点是第 1 讲 §3 的”计算器 + 水管”。 依据说明:GPU 的结构和规格来自 NVIDIA 的公开资料:H100 架构博客 NVIDIA Hopper Architecture In-Depth、Hopper Tuning Guide、CUDA 编程指南的”计算能力”表、Matrix Multiplication Background User’s Guide。roofline 来自 S. Williams, A. Waterman, D. Patterson, Roofline: An Insightful Visual Performance Model for Multicore Architectures(CACM 2009)。“三种瓶颈”的说法来自 H. He, Making Deep Learning Go Brrrr From First Principles(博客,2022)。启动开销的实测参照来自 PyTorch 团队的 Accelerating Generative AI with PyTorch II: GPT, Fast(博客,2023);MBU 的定义来自 Databricks 的 LLM Inference Performance Engineering: Best Practices(博客,2023);L2 带宽是 Chips and Cheese 对 H100 PCIe 的第三方测量。§2–§7 的数字来自”纸上 GPU”的计算(代码 01–04),§8 是在一台 Apple M4 Pro 笔记本上的实测(代码 05)。 配套代码 Code:
code/prereq1/(numpy;5 个脚本 + 1 个共享模块;全部几秒内跑完) 预计阅读时间:3–4 小时(含运行代码)
0. 本讲导航 · How to read this lecture
0.1 学习目标 Learning goals
学完这一篇,你应该能够:
- 说清 CPU 和 GPU 的设计哲学有什么不同(延迟优先 vs 吞吐优先),并用 Little 定律解释 GPU 为什么需要成千上万个线程。
- 描述 GPU 的执行层次(线程 → warp → 线程块 → 网格;SM)和存储层级(寄存器、共享内存、L2、HBM),并解释分支发散、合并访存、占用率、tile / wave 量化这几个常见的性能陷阱。
- 用”分块与数据复用”解释:为什么矩阵乘法能跑满 Tensor Core,而逐元素运算无论怎么写都受限于带宽;为什么投影层的算术强度约等于批里的 token 数。
- 用 roofline 逐个算子地分析 Transformer 的一次 forward,说出 prefill 和 decode 的时间各花在哪里。
- 区分三种瓶颈(compute-bound、memory-bound、overhead-bound),说出各自的对策:批处理、算子融合、低精度、CUDA Graph;会算 MFU 和 MBU。
- 说清浮点数是怎样构造的,FP32 / BF16 / FP16 / FP8 / FP4 的范围和精度,为什么要用 FP32 累加,为什么低精度能让 decode 变快。
0.2 先修与本讲范围 Prerequisites & scope
| 默认你已经会 Assumed | 本讲不展开 Out of scope |
|---|---|
| 第 1 讲 §2–§3:prefill / decode、KV cache、“计算器 + 水管”、算术强度 | 写 CUDA 程序(语法、编译、调试) |
| 矩阵乘法的定义;对数、指数 | GPU 的电路、功耗、制造 |
| Python 基础(代码里解释了 numpy 和线程池) | 量化的具体方法(附录讲 A)、注意力内核的细节(第 4 讲) |
| 不需要学过计算机体系结构 | 多卡之间的通信(前置讲 P5) |
0.3 本讲地图 Map
§1 两个数字什么时候不够用:回顾 roofline;三种瓶颈
§2 CPU 与 GPU:延迟优先 vs 吞吐优先;在途的字节;H100 的计算单元与存储层级 ← 代码 01
§3 GPU 的组织:线程、warp、线程块、SM;SIMT 与分支发散;合并访存;占用率;
Tensor Core;tile / wave 量化;内核与启动 ← 代码 01
§4 存储层级与数据复用:分块矩阵乘法;片上容量的上限;L2 在 SM 之间共享;
逐元素运算为什么没救;LLM 里的矩阵乘法形状 ← 代码 02
§5 roofline 的完整用法:逐个算子分析 Transformer 的一次 forward ← 代码 03
§6 融合、启动开销与三种瓶颈;CUDA Graph;MFU 与 MBU ← 代码 03
§7 数值格式:浮点数的构造;范围与精度;累加;溢出;低精度为什么快 ← 代码 04
§8 在你的电脑上:测出 CPU 的 roofline、CPU 上的"融合"、固定开销 ← 代码 05
§9 与推理系统的联系(只想快速浏览的读者读这一节)
§10–14 小结、常见误区、练习、延伸阅读、下一篇
0.4 记号 Notation
沿用第 1 讲的记号( 峰值算力、 显存带宽、 算术强度、 批大小)。本篇新增:
| 符号 / 术语 | 含义 |
|---|---|
| SM | streaming multiprocessor,GPU 上的一个”小处理器”;H100 SXM 有 132 个 |
| warp | 32 个线程组成的执行单位,共用一个指令流 |
| 线程块(thread block,也叫 CTA) | 一组线程,被整体放到一个 SM 上运行,块内线程可以共享”共享内存” |
| 内核(kernel) | 在 GPU 上执行的一个函数;一次启动会创建一个由很多线程块组成的网格(grid) |
| 矩阵乘法里一个线程块负责的输出块(tile)大小 | |
| 矩阵乘法 的三个维度;在 LLM 里 通常是这一步的 token 数 | |
| MFU / MBU | 模型算力利用率 / 模型带宽利用率(§6.4) |
单位同第 1 讲:字节用十进制(1 GB = 字节),需要 1024 进制时写 KiB / MiB。
0.5 关于数字 · About the numbers
本篇有三类数字,读的时候请分清:
- 公开规格:H100 的 SM 数、各计算单元的峰值、显存容量和带宽,来自 NVIDIA 的数据手册和架构博客。
- 纸上 GPU 的计算:代码 01–04 的时间都是 roofline 模型算出来的(效率系数沿用第 1 讲的 70% / 80%),说明量级和趋势,不是实测。少数几个量是我的估算或假设(共享内存带宽、各级延迟、启动开销),文中都标出来了。
- 你的电脑上的实测:代码 05。每台机器、每次运行都不同;文中引用的是在一台 Apple M4 Pro(12 核)上的结果。
1. 两个数字什么时候不够用 · When two numbers are not enough
第 1 讲把一块 GPU 抽象成两个数字:峰值算力 (计算器每秒做多少次运算)和显存带宽 (水管每秒搬多少字节)。一次计算要做 次运算、搬 个字节,它至少要
把 (算术强度)和屋脊点 比较,就知道是计算器先忙满(compute-bound)还是水管先忙满(memory-bound)。H100 的屋脊点是 FLOP/byte。这个模型叫 roofline(屋顶线),由 Williams、Waterman、Patterson 在 2009 年提出:在”性能 vs 算术强度”的双对数图上,带宽是一条斜线,峰值算力是一条水平线,两条线组成的”屋顶”就是一段代码能达到的性能上限。
这两个数字足够回答第 1 讲的问题:“batch = 1 的 decode 为什么只用到不到 1% 的算力?“可读后面的论文时,你会不断碰到它回答不了的问题:
- FlashAttention 说”减少 HBM 读写”:HBM 以外还有什么存储?为什么在那里算就快?(§2、§4)
- 为什么矩阵乘法能把计算器喂饱,softmax、加法却永远在等水管?既然都要从显存读数据,差别在哪里?(§4)
- 为什么同一个矩阵乘法,矩阵稍微大一点点,时间突然翻倍?(§3.6)
- 为什么推理引擎要花大力气做”算子融合”和”CUDA Graph”?小模型的 decode 为什么连带宽都用不满?(§6)
- BF16、FP8 到底是什么?为什么”字节少了”就快?(§7)
Horace He 在一篇很有影响的博客里把深度学习程序的时间分成三部分:计算(在 GPU 上做浮点运算的时间)、搬运(在存储之间移动张量的时间)、开销(其他一切:Python 解释器、框架分发、启动内核……)。哪一部分占主导,就叫哪一种瓶颈。roofline 的两个数字只覆盖了前两种。本篇的任务,就是把这三种瓶颈各自拆到能动手分析的程度。
2. CPU 与 GPU:延迟优先 vs 吞吐优先 · CPU vs GPU
2.1 两种设计哲学
CPU 和 GPU 都是”计算器 + 存储”,但它们为完全不同的工作而设计。
CPU 是延迟优先的。它要尽快跑完一条指令序列:操作系统、浏览器、数据库里的代码充满了分支和依赖,下一步做什么取决于上一步的结果。所以 CPU 只有十几个到几十个核心,但每个核心都很”聪明”:大容量的缓存,让数据尽量就在手边;分支预测,猜下一步走哪条路;乱序执行,在等数据时先做后面不相关的指令。芯片上的大部分面积花在这些”让一条指令流跑快”的机制上,而不是运算单元本身。
GPU 是吞吐优先的。它的工作是对海量数据做同样的运算:图形渲染里每个像素、深度学习里每个矩阵元素,彼此几乎独立。所以 GPU 把面积花在运算单元的数量上:H100 有 16,896 个 FP32 运算单元(CUDA core)和 528 个 Tensor Core,每个都很简单,没有分支预测、没有乱序执行。GPU 不试图让任何一个线程跑快,而是让同时有足够多的线程在跑。
这就引出一个问题:GPU 的核心那么”笨”,等一次显存访问要几百纳秒,它怎么不闲着?
2.2 在途的字节:为什么需要成千上万个线程
答案是隐藏延迟(latency hiding):一个线程在等数据时,切换到另一个数据已经到了的线程去执行。GPU 上的切换几乎不花时间,因为每个线程的寄存器一直放在芯片上,不需要保存和恢复。
需要多少线程?用 Little 定律(前置讲 P4 会正式讲)算:一个系统里平均”在里面”的东西的数量 = 到达率 × 每个东西平均停留的时间。放到显存上:
要让 H100 的水管一直满着(3.35 TB/s),如果一次访存要约 500 个时钟周期(约 270 纳秒,这是我为说明量级取的假设),任意时刻就要有 MB 的数据”在路上”。一个 warp(32 个线程)的一条访存指令一次带回 128 字节,所以需要约 7,000 条访存同时在途(代码 01 演示 1)。整块 H100 最多同时驻留 132 × 64 = 8,448 个 warp。这正好说明了 GPU 的设计:它不靠缓存去”避免”延迟,而是靠大量线程去”隐藏”延迟。
这也解释了一个推理里常见的现象:如果一个内核只给 GPU 很少的并行工作(例如 batch = 1、上下文很短的 decode 注意力,只有几个头),即使它是 memory-bound 的,也可能连带宽都用不满,因为在途的访存不够多。第 4 讲 decode 注意力的 split-KV(把一个请求的 KV 切给多个线程块)就是为了解决这个问题。
2.3 H100 的计算单元
代码 01 演示 1 列出 H100 SXM 各种计算单元的稠密峰值(数据手册):
| 计算单元 | 峰值 |
|---|---|
| Tensor Core FP8 | 1,979 TFLOP/s |
| Tensor Core BF16 / FP16 | 989 TFLOP/s |
| Tensor Core TF32 | 495 TFLOP/s |
| CUDA core FP32 | 67 TFLOP/s |
| CUDA core FP64 | 33.5 TFLOP/s |
最重要的一行对比:Tensor Core(BF16)是 CUDA core(FP32)的 14.8 倍。Tensor Core 只会做一件事——小矩阵块的乘加(§3.5);其他一切运算(加法、指数、比较、开方)都只能用 CUDA core。所以一个算子能用到多少算力,首先取决于它能不能写成矩阵乘法。第 1 讲用的 TFLOP/s 是 Tensor Core 的数字,它只对矩阵乘法成立。
2.4 H100 的存储层级
| 存储 | 谁能访问 | 总容量 | 总带宽 | 延迟(数量级) |
|---|---|---|---|---|
| 寄存器 | 每个线程私有 | 34.6 MB(每 SM 256 KB) | — | 约 1 个时钟周期 |
| 共享内存 / L1(SRAM) | 一个线程块共享 | 34.6 MB(每 SM 256 KB,最多 228 KB 作共享内存) | 约 31 TB/s(估算) | 几十个时钟周期 |
| L2 缓存 | 整块 GPU 共享 | 50 MB | 约 5.5 TB/s(第三方测量) | 两三百个时钟周期 |
| HBM3(显存) | 整块 GPU 共享 | 80 GB | 3.35 TB/s | 五百个时钟周期以上 |
(容量和 HBM 带宽是标称值。共享内存带宽是我按”每个 SM 每时钟 128 字节 × 132 个 SM × 约 1.83 GHz”估算的;L2 带宽是 Chips and Cheese 在 H100 PCIe 上测的近分区读带宽;延迟只给数量级,不同测量差别很大。)
记住这张表的形状就够了:越靠近计算单元越快、越小。片上(寄存器 + 共享内存)加起来只有几十 MB,比 HBM 小三个数量级;共享内存的带宽却是 HBM 的近 10 倍,寄存器还要快得多。第 4 讲 FlashAttention 的全部思想,就是”让数据尽量待在片上”。
这张表也解释了第 1 讲的一个说法:“H200 相对 H100 的升级几乎全给了水管和仓库”。H200 的计算单元和片上存储与 H100 相同,只把 HBM 换成了更大(141 GB)、更快(4.8 TB/s)的 HBM3e。
3. GPU 的组织:从线程到 SM · How a GPU is organized
3.1 执行的层次
写 GPU 程序时,你写的是一个线程要做什么;然后告诉 GPU”启动这么多线程”。这些线程按层次组织:
网格 grid(一次内核启动)
└── 线程块 thread block / CTA(例如 128 或 256 个线程;块内可以用共享内存交换数据、互相同步)
└── warp(32 个线程,硬件真正调度的单位,共用一个指令流)
└── 线程 thread(有自己的寄存器)
硬件这边,一块 H100 有 132 个 SM。启动一个内核时,GPU 把网格里的线程块分配到各个 SM 上;一个 SM 可以同时驻留多个线程块(上限由寄存器、共享内存等资源决定,§3.4);块一旦分到某个 SM,就在那里一直跑到结束。每个 SM 里有 4 个 warp 调度器,每个时钟周期从”数据已经准备好”的 warp 里挑一个发射指令。Hopper 还新增了线程块簇(thread block cluster):最多 16 个线程块保证同时运行在相邻的 SM 上,可以直接访问彼此的共享内存(§4.4 会用到)。
为什么要分这么多层?因为不同层次之间的协作代价不同:同一个 warp 的线程天然同步;同一个线程块的线程能通过共享内存快速交换数据;不同线程块之间只能通过 L2 / HBM 交换数据,几乎没法同步。写一个高效的内核,很大程度上就是决定”哪些工作放在同一个块里,好共享数据”。
3.2 SIMT:一个 warp 共用一个指令流
GPU 的执行模型叫 SIMT(single instruction, multiple threads):一个 warp 的 32 个线程每个时刻执行同一条指令,只是作用在各自的数据上。这样一个指令发射器就能驱动 32 个运算单元,省下大量面积。
代价是分支发散(branch divergence)。如果代码里有 if,同一个 warp 里有的线程走 if、有的走 else,这个 warp 只能先后把两条路都走一遍:走 if 时 else 的线程闲着,反之亦然。代码 01 演示 2(if、else 各 10 条指令,2,048 个线程):
| 条件分布 | 效率 |
|---|---|
| 所有线程走同一条路 | 100% |
| 按 warp 对齐地分成两半(前一半 warp 全走 if) | 100% |
| 奇偶交替 | 50% |
| 随机 10% 的线程走 if | 51% |
| 随机 1% 的线程走 if | 81% |
随机 10% 时几乎每个 warp 里都至少有一个线程走 if(),效率和奇偶交替差不多;随机 1% 时只有约 20 个线程,但它们散落在不同的 warp 里,效率仍降到 81%。罕见的慢路径在 GPU 上并不便宜。
同样的事情也发生在循环次数不齐的时候:一个 warp 要等它里面循环次数最多的那个线程做完。假设 decode 注意力里每个线程负责一个请求、循环”上下文长度”次,上下文长度服从长尾分布(中位数约 1,000),按到达顺序分组时效率只有 19%;先按长度排序再分组,提高到 76%。这和第 2 讲批处理里的 padding、第 4 讲 decode 注意力的负载均衡是同一个问题,只是发生在更细的粒度上。
3.3 合并访存
显存不是按”一个数”取的,而是按 32 字节的扇区(sector)成块地取。一个 warp 的 32 个线程同时发出读请求时,硬件把它们落在的扇区合并起来取。代码 01 演示 3:
| 32 个线程各读一个 float32 | 取的扇区 | 有用比例 |
|---|---|---|
| 连续地址(步长 1) | 4 | 100% |
| 步长 2 | 8 | 50% |
| 步长 8 及以上(例如按列读行优先的矩阵) | 32 | 12.5% |
| 连续,但起点偏移 16 字节 | 5 | 80% |
| 每个线程读 16 字节(向量化) | 16 | 100% |
相邻线程读相邻地址时,请求被合并(coalesced)成最少的扇区;步长变大,有用比例按 1/步长下降,直到每个线程独占一个扇区。也就是说,同样读 128 字节的有用数据,布局不好可以多搬 8 倍的字节。
这是 KV cache 布局要考虑的第一件事:一起被读的东西要在地址上连续。第 3 讲的 PagedAttention 把 KV 按块(例如 16 个 token)存放,块内每个 token、每个头的 K 是连续的 128 × 2 = 256 字节,所以分页几乎不损失带宽;如果按单个 token 随机散放、每次只读几个字节,带宽就会被浪费好几倍。
3.4 占用率:一个 SM 能同时驻留多少 warp
§2.2 说 GPU 靠”同时有很多 warp”隐藏延迟。一个 SM 能同时驻留多少 warp,受四个上限约束(H100:最多 64 个 warp、32 个线程块、65,536 个寄存器、228 KB 共享内存),取最小的那个。驻留的 warp 数 ÷ 64 叫占用率(occupancy)。代码 01 演示 4 的占用率计算器:
| 内核配置 | 每 SM 块数 | warp | 占用率 | 限制因素 |
|---|---|---|---|---|
| 256 线程/块,32 寄存器/线程 | 8 | 64 | 100% | warp 数 |
| 256 线程/块,64 寄存器/线程 | 4 | 32 | 50% | 寄存器 |
| 128 线程/块,255 寄存器/线程 | 2 | 8 | 12.5% | 寄存器 |
| 128 线程/块,48 KB 共享内存 | 4 | 16 | 25% | 共享内存 |
| GEMM 式:384 线程,168 寄存器,200 KB 共享内存 | 1 | 12 | 18.8% | 寄存器 |
限制占用率的往往不是线程数,而是寄存器和共享内存:每个线程用的寄存器越多,能同时驻留的线程越少。
占用率要多高?取决于内核在等什么。演示 4 用一个玩具模型说明:每个 warp 计算 20 个周期、再等 500 个周期的访存(假设值),那么一个 warp 单独跑时 SM 只有 4% 的时间在干活,要约 26 个 warp 轮流才能让 SM 不闲着。访存密集的内核需要高占用率。但矩阵乘法内核恰恰相反:它故意让每个线程用大量寄存器、每个块用大量共享内存,好把分块做大、让每个读进来的数被复用更多次(§4);占用率只有一个块、十几个 warp,照样能跑满 Tensor Core。高性能内核的设计,就是在”块大(复用多)“和”块多(并行多、能隐藏延迟)“之间找平衡。
3.5 Tensor Core
Tensor Core 是专门做小矩阵乘加 的硬件单元:一条指令由一组线程(Hopper 上是 4 个 warp 组成的 warpgroup)合作发出,一次完成一个小矩阵块的乘法和累加。每个 SM 有 4 个 Tensor Core。和 CUDA core 相比,它的优势来自两点:一次处理一整块,省掉了大量的取指令和寄存器读写;输入用低精度(BF16、FP8),累加用 FP32(§7.4 解释为什么必须这样)。H100 的 Tensor Core 每个 SM、每个时钟的矩阵乘法吞吐是 A100 的 2 倍,用 FP8 时再翻一倍。
Tensor Core 带来了一个深远的后果:所有想跑快的计算,都要尽量变成矩阵乘法。注意力里的 和 是矩阵乘法,可以用 Tensor Core;softmax 不是,只能用 CUDA core。在 H100 上前者的算力是后者的 15 倍,到了 Blackwell,Tensor Core 的吞吐又翻倍,计算指数函数的单元却几乎没有增长,于是 softmax 的指数运算成了 FlashAttention-4 要专门对付的瓶颈(第 4 讲 §3.5)。
3.6 tile 量化与 wave 量化
矩阵乘法内核让每个线程块负责输出矩阵 的一个 的块(tile)。由此产生两种”量化”浪费(这里的”量化”指”只能取整数个”,和 §7 的数值量化无关):
- tile 量化:、 不是块大小的整数倍时,边上的块只有一部分有用,但仍要做整块的计算。
- wave 量化:所有 SM 同时各算一个块叫”一波”(wave)。块数比 SM 数多一点点,就要多跑一整波,而这一波只有少数 SM 在干活。
代码 01 演示 5 先复现 NVIDIA GEMM 指南里的例子:A100(108 个 SM),,块 256 × 128。 时块数是 9 × 12 = 108,正好一波; 时块数变成 117,多出来的 9 个块要单独再跑一整波——时间翻倍、FLOP/s 减半,与 NVIDIA 的实测一致。
再看推理里的形状:H100 上 Llama-3-8B 的 gate_up 投影 ,块 128 × 128:
| (批里的 token 数) | 块数 | 波数 | tile 效率 | 总效率 |
|---|---|---|---|---|
| 1 | 224 | 2 | 0.8% | 0.7% |
| 64 | 224 | 2 | 50.0% | 42.4% |
| 128 | 224 | 2 | 100% | 84.8% |
| 129 | 448 | 4 | 50.4% | 42.8% |
| 2,048 | 3,584 | 28 | 100% | 97.0% |
decode 时 只有几到几十,一个 128 行的块里只有 行有用,Tensor Core 做的大部分是无用功。好在 decode 本来就受限于带宽(读权重),算力浪费了也不影响时间;但它说明小批的矩阵乘法需要专门的内核(更小的块、沿 方向切分的 split-K),附录讲 A 的 Marlin 就是为这种形状设计的。 从 128 变到 129 时块数翻倍:推理引擎常把批大小(以及 CUDA Graph 录制的批大小)取成 8、16 或 128 的倍数,原因之一就在这里。
3.7 内核与启动
从 CPU 的角度看,GPU 是一个异步的协处理器:CPU 调用一次”启动内核”(kernel launch),把要执行的函数、网格大小、参数放进一个队列(CUDA stream),然后立刻返回,接着准备下一个内核;GPU 按顺序从队列里取出内核执行。只要 CPU 准备内核的速度比 GPU 执行的速度快,CPU 就能”跑在前面”,GPU 一直有活干。
一次 Transformer forward 由几百个内核组成(每层十几个,§5)。每次启动在 CPU 端要花几微秒(Python 代码、框架的分发逻辑、驱动调用),GPU 上相邻两个内核之间也有约 1 微秒的空隙。如果每个内核在 GPU 上只跑几微秒,CPU 就跟不上了——这就是第三种瓶颈,§6.2 会算清楚它什么时候出现。
4. 存储层级与数据复用 · Memory hierarchy and data reuse
4.1 同样要读显存,为什么矩阵乘法快?
矩阵乘法 ( 是 , 是 )一共做 次运算,而三个矩阵一共只有 个数。如果每个数只从显存读写一次,算术强度是
、BF16 时, FLOP/byte,远超 H100 的屋脊点 295。原因是复用: 的每个元素要和 的 个元素相乘, 的每个元素要和 的 个元素相乘。
但”每个数只读一次”是理想情况。最直接的写法——每个线程算一个输出元素 ,自己去显存读一整行 和一整列 ——完全没有利用复用: 的每一行被读了 次。代码 02 演示 1 在 128 × 128 × 128 的矩阵上真的这样算了一遍:强度只有 0.5 FLOP/byte,每读进 2 个数(4 字节)只做 1 次乘加,和逐元素运算一样差。
4.2 分块
分块(tiling)的做法是:每次把 的一个 块和 的一个 块读进快存储(共享内存),在快存储里把它们乘起来,累加到一个 的累加器(寄存器)上;沿 方向走完,再把这个输出块写回显存。
B 的一列块(b_k × b_n),沿 K 方向一块一块读进来
┌──┐
│ │
│ │
┌──────────┼──┼─┐
A │ ▓▓▓▓▓▓▓▓ │▓▓│ │ C 的一个输出块(b_m × b_n),累加器放在寄存器里
└──────────┴──┴─┘
A 的一行块(b_m × b_k),沿 K 方向一块一块读进来
读进来的 块会和 列 相乘, 块会和 行 相乘。每个输出块沿 方向一共读 个数、做 次运算,所以
块越大,强度越高。代码 02 演示 1 的结果(128³,按 BF16 记账):
| 写法 | HBM 读 | 强度(FLOP/byte) | 需要的快存储 |
|---|---|---|---|
| 朴素 | 8.39 MB | 0.50 | 512 B |
| 分块 16 × 16 | 524 KB | 7.53 | 2.05 KB |
| 分块 64 × 64 | 131 KB | 25.60 | 20.48 KB |
| 分块 128 × 128 | 65.5 KB | 42.67 | 73.73 KB |
所有写法算出来的 都一样(差别只是浮点舍入,约 )。分块只改变”数据从哪儿来”,不改变数学——这正是 FlashAttention 的精神。
4.3 片上容量决定强度的上限
块不能无限大:输入块要放进共享内存,累加器要放进寄存器。代码 02 演示 2 在 4096³ 的 BF16 矩阵上算了一遍(,输入块双缓冲,累加器 FP32):
| 块大小 | 强度 | 共享内存 | 累加器 | 一个 SM 放得下? |
|---|---|---|---|---|
| 64 × 64 | 31.8 | 32 KiB | 16 KiB | 能 |
| 128 × 128 | 63.0 | 64 KiB | 64 KiB | 能 |
| 128 × 256 | 83.6 | 96 KiB | 128 KiB | 能 |
| 256 × 256 | 124.1 | 128 KiB | 256 KiB | 不能 |
一个 SM 的寄存器一共 256 KiB,还要留给地址计算等,所以能用的块大约到 128 × 256 为止(Hopper 上 CUTLASS 常用的就是这个量级),强度约 84 FLOP/byte。要达到屋脊点 295,方块边长要约 590,光累加器就要 1.33 MiB。
这是一个普遍的结论,早在 1981 年就被 Hong 和 Kung 证明过:在一台快存储容量为 的机器上,矩阵乘法和慢存储之间的数据搬运量至少是 。片上存储越大,能达到的算术强度越高;FlashAttention 论文也用同样的思路证明了它的 IO 复杂度在某个范围内是最优的(第 4 讲 §3.4)。
于是有一个看似矛盾的结论:只靠一个 SM 自己的片上存储,矩阵乘法相对 HBM 仍然受限于带宽(84 < 295)。可真实的 GEMM 在 H100 上确实能跑到峰值的 70% 以上。缺的那一截从哪里来?
4.4 L2:多个线程块共享数据
答案在 L2。132 个 SM 同时各算一个输出块;如果线程块的执行顺序让”同时在跑的块”在输出矩阵里挨在一起,它们需要的 行块、 列块就有大量重叠:同一块数据从 HBM 读进 L2 一次,之后被好几个 SM 从 L2 取走。代码 02 演示 3 用理想化的模型算这笔账(132 个 128 × 256 的块排成 12 × 11 的网格,覆盖 1536 × 2816 的区域):
| 哪一级 | 强度 | 达到 70% 峰值需要的带宽 |
|---|---|---|
| 单个块,数据全从 HBM 来 | 85 | 8.1 TB/s |
| 一波 132 个块经 L2 共享,相对 HBM | 994 | 0.7 TB/s |
| 相对 L2:每个块各自从 L2 取 | 85 | 8.1 TB/s |
| 相对 L2:2 个 SM 一簇、 块多播 | 128 | 5.4 TB/s |
经过 L2 共享,相对 HBM 的强度超过了屋脊点,HBM 不再是瓶颈(这要求块的调度顺序合适——CUTLASS 里叫 swizzle——并且 L2 放得下它们的工作集)。瓶颈于是挪到了 L2 → SM 这一级。Hopper 新增的线程块簇和 TMA(张量内存加速器,专门搬数据的硬件单元)可以把同一个 块多播给簇里的几个 SM,进一步减少从 L2 读的量。
这个演示想说明的是 roofline 的分层用法:存储层级的每一级都可以画一条”屋顶”,每一级都要算一遍”强度 vs 带宽”的账。Williams 等人的原论文就有这种”多层天花板”的画法。
4.5 逐元素运算:没有复用,只能融合
现在看另一类算子:加法、缩放、激活函数、归一化、softmax。它们的共同点是每个输入元素只被用一次,没有任何复用。代码 02 演示 4(BF16):
| 算子 | 每元素运算 | 强度(FLOP/byte) | 离屋脊点差多少倍 |
|---|---|---|---|
| 加法 (读 2 写 1) | 1 | 0.17 | 1,771× |
| SiLU | 4 | 1.00 | 295× |
| softmax(约 5 次运算) | 5 | 1.25 | 236× |
| 一个元素做 100 次运算 | 100 | 25.0 | 12× |
要让一个逐元素算子达到屋脊点,每个元素要做上千次运算,现实中的激活函数、归一化远远做不到。逐元素运算无论怎么写都受限于带宽,再好的内核也只能逼近带宽的上限。
唯一的办法是少读写几次:把相邻的算子合并成一个内核,中间结果留在片上,这就是算子融合(kernel fusion)。Horace He 的例子:x.cos().cos() 不融合时是两个内核,读写 4 次;融合后读写 2 次,时间减半,而且融合后的”两次 cos”和”一次 cos”几乎一样快——时间全花在读写上,多算一次 cos 是免费的。
4.6 LLM 里的矩阵乘法:强度 ≈ 批里的 token 数
把 §4.1 的公式用到 LLM 的投影层:, 是这一步的 token 数,、 是几千到几万的模型维度。 时,
批里有多少个 token,强度就约是多少。代码 02 演示 5 对 Llama-3-8B 的五个矩阵乘法算了一遍:
| 投影 | =1 | =64 | =256 | =2,048 | 越过屋脊点的 |
|---|---|---|---|---|---|
| qkv_proj | 1 | 62 | 232 | 1,117 | 336 |
| gate_up_proj | 1 | 63 | 239 | 1,303 | 322 |
| down_proj | 1 | 63 | 237 | 1,247 | 326 |
| lm_head | 1 | 63 | 240 | 1,351 | 319 |
这一行公式解释了推理系统的大部分设计:
- batch = 1 的 decode:,强度约 1,每读一个 2 字节的权重只做 2 次运算——“只用到不到 1% 的算力”(第 1 讲)。
- 批处理:多一个 token 一起算,权重就多被复用一次。批处理是第一杠杆,原因就在这里(第 2 讲)。
- prefill 一次就有几百上千个 token,所以是 compute-bound;decode 每个请求每步只贡献 1 个 token,要三百多个请求一起 decode,投影层才越过屋脊点——而且注意力读 KV 的部分不会因为批大而复用(第 1 讲、§5)。
- 第 7 讲的分块 prefill 把一个长提示切成若干块和 decode 拼在一起,本质上是在控制每一步的 。
5. roofline 的完整用法:逐个算子分析 Transformer · Per-operator roofline
5.1 怎样用 roofline
第 1 讲把整次 forward 当成一个整体:总 FLOPs、总字节,取 max。这对估计时间够用,但看不出时间花在哪里、哪些地方能优化。完整的用法是逐个算子(也就是逐个内核)地做:
- 把一次 forward 拆成算子,数出每个算子的 FLOPs 和字节(权重、KV、激活的读写都要算);
- 给每个算子选对”屋顶”:矩阵乘法用 Tensor Core 的峰值,其他算子用 CUDA core 的峰值;带宽一般用 HBM 的;
- 每个算子的时间 ≈ max(FLOPs ÷ 算力, 字节 ÷ 带宽),再加起来。
性能(FLOP/s,对数)
│ Tensor Core 屋顶 ───────────────────────── 989 T
│ /
│ / ● 大矩阵乘法(prefill 的投影层)
│ /
│ CUDA core 屋顶 /─────────────────────────── 67 T
│ /
│ / ← 斜率 = HBM 带宽 3.35 TB/s
│ ● /
│ 逐元素 / ● decode 的投影层(强度 ≈ 批大小)
│ /
└──────────────────────────────────────────── 算术强度(FLOP/byte,对数)
1 20 295
一个算子落在斜线下方,就是 memory-bound,优化方向是少搬字节(融合、低精度、更好的复用);落在水平线下方,就是 compute-bound,优化方向是用上 Tensor Core、用更低精度的计算、减少无用功(tile 量化)。注意不是矩阵乘法的算子有一条低得多的屋顶(67 T),所以它们比矩阵乘法更早碰到算力的上限。
5.2 一层 Transformer 的算子
gpu_lab.transformer_ops 把 Llama 风格的一层拆成 14 个算子(不融合时,每个都是一次单独的内核,各自从 HBM 读输入、写输出):
| 算子 | 类型 | 做什么 |
|---|---|---|
| attn_norm | 逐元素 / 归约 | RMSNorm |
| qkv_proj | 矩阵乘法 | 算 Q、K、V |
| rope | 逐元素 | 旋转位置编码 |
| kv_write | 搬运 | 把新 token 的 K、V 写进 KV cache |
| attn_scores | 矩阵乘法 | ,把分数矩阵写回 HBM |
| softmax | 逐元素 / 归约 | 读 、写 |
| attn_pv | 矩阵乘法 | |
| o_proj | 矩阵乘法 | 输出投影 |
| residual_add | 逐元素 | 残差连接 |
| mlp_norm | 逐元素 / 归约 | RMSNorm |
| gate_up_proj | 矩阵乘法 | SwiGLU 的两个上投影 |
| silu_mul | 逐元素 | |
| down_proj | 矩阵乘法 | 下投影 |
| residual_add | 逐元素 | 残差连接 |
外加开头的 embedding 查表、结尾的 final_norm 和 lm_head。这个模型的矩阵乘法、注意力的 FLOPs 和权重字节与第 1 讲的 hw_model 完全一致(自检 5),只是多数了 hw_model 忽略的激活读写。
5.3 prefill:矩阵乘法为主,但不是全部
代码 03 演示 1:Llama-3-8B 在 H100 上 prefill 2,048 个 token,不融合(节选):
| 算子 | FLOPs 占比 | 时间 | 时间占比 | 瓶颈 |
|---|---|---|---|---|
| qkv_proj | 11.1% | 4.76 ms | 8.0% | compute |
| attn_scores | 1.9% | 3.46 ms | 5.8% | memory |
| softmax | 0.1% | 6.41 ms | 10.7% | memory |
| attn_pv | 1.9% | 3.46 ms | 5.8% | memory |
| gate_up_proj | 51.8% | 22.23 ms | 37.2% | compute |
| silu_mul | 0.0% | 2.10 ms | 3.5% | memory |
| down_proj | 25.9% | 11.12 ms | 18.6% | compute |
| 合计 | 29.7 TFLOP | 59.83 ms | 100% |
- 矩阵乘法(四个投影 + lm_head)占 96.2% 的 FLOPs、69.7% 的时间,都是 compute-bound:prefill 的主体是大矩阵乘法。
- 逐元素和归一化类的算子(不含 softmax)只占 0.03% 的 FLOPs,却占了 8.1% 的时间:它们全是 memory-bound,而且只能用 CUDA core。
- 不融合的注意力三个算子占 22.3%:每一层都要把 32 个头 × 2,048 × 2,048 的分数矩阵写回 HBM、读出来做 softmax、再写回、再读。softmax 本身的运算微不足道,时间几乎全在读写分数矩阵上。
FLOPs 的占比和时间的占比可以差很多。只数 FLOPs,会以为注意力和逐元素运算无关紧要;逐个算子算时间,才看到它们占了三成。这就是 FlashAttention 和各种融合内核的出发点。
5.4 decode:一切都受限于带宽
代码 03 演示 2:decode,每个请求的上下文 1,024。
- batch = 1:16 个算子全部 memory-bound;矩阵乘法(读权重)占 99.0% 的时间,总共 5.66 ms。decode 的时间几乎就是”把 16 GB 权重读一遍”。
- batch = 64:权重仍然只读一遍,但注意力要读 64 份 KV。注意力相关的算子从 0.9% 涨到 36.9% 的时间;总时间从 5.66 ms 只涨到 9.29 ms,产出的 token 却多了 63 个。
批越大、上下文越长,读 KV 越成为主要开销,而且这部分不会因为批大而被复用(每个请求的 KV 是它自己的)。这就是第 3 讲(让显存放下更多 KV)、第 4 讲(读 KV 读得快)、第 9 讲(KV 放不下时去哪里)、第 10 讲(让 KV 变小或者少读)都在围绕 KV 打转的原因。
5.5 和第 1 讲的”纸上 GPU”比
逐算子模型融合后算出 prefill 2,048 要 47.6 ms,第 1 讲的 hw_model 是 43.4 ms,多出约 10%。多出来的是 hw_model 忽略的两件事:激活的读写,以及逐元素算子只能用 CUDA core。所以第 1 讲的”纸上 GPU”在 prefill 上略偏乐观;在 decode 上两者几乎一样(激活只有一个 token 那么大)。这类”简化模型的误差有多大、偏向哪一边”的检查,读论文时也值得做。
6. 融合、启动开销与三种瓶颈 · Fusion, launch overhead, and the three regimes
6.1 算子融合
现代推理引擎里常见的融合:
- fused add + RMSNorm:残差相加和下一个 RMSNorm 合成一个内核(读 和残差、写新的 和归一化后的 )。
- 融合 RoPE 和写 KV:算完 Q、K 的旋转后直接写进 KV cache 的分页块。
- FlashAttention:、softmax、 合成一个内核,分数矩阵从不写回 HBM(第 4 讲)。
- SiLU × mul:本身就是一个内核,读 gate 和 up、写一份结果。
- 第 17 讲提到的融合采样、附录讲 A 的”反量化 + 矩阵乘法”融合内核,也是同一个思路。
代码 03 演示 3 比较融合前后(内核数从每层 14 个降到 9 个,整次 forward 从 451 个降到 291 个):
| 场景 | 不融合 | 融合 | 加速 |
|---|---|---|---|
| prefill 512 | 12.85 ms | 11.92 ms | 1.08× |
| prefill 2,048 | 59.83 ms | 47.59 ms | 1.26× |
| prefill 8,192 | 391.98 ms | 208.26 ms | 1.88× |
| decode batch 1,上下文 1,024 | 5.66 ms | 5.65 ms | 1.00× |
| decode batch 256,上下文 1,024 | 20.34 ms | 19.48 ms | 1.04× |
prefill 越长,融合越重要,收益几乎全部来自 FlashAttention:不融合时分数矩阵的读写随长度平方增长。decode 几乎不受益:激活只有每个请求一个 token 那么大,batch 256 时激活的读写也只有约 5 GB,远小于读权重和 KV。decode 里融合更大的价值是减少内核数,这是下一节的主题。
6.2 启动开销与 CUDA Graph
§3.7 说过,CPU 发射内核、GPU 执行内核是异步的。eager 模式(框架逐个调用算子,例如普通的 PyTorch)下,墙钟时间约等于”GPU 执行全部内核的时间”和”CPU 发射全部内核的时间”中较大的那个。CPU 那一项更大时,GPU 在等 CPU——这就是 overhead-bound。
CPU 每发射一个内核要多久,差别很大:经验上从约 1–2 微秒(C++ 直接调用驱动)到 10 微秒以上(Python + 框架的逐个算子调用)。代码 03 演示 4 扫描这个数(GPU 上内核之间的空隙取 1 微秒,假设值),并给出”临界值” = 不融合时 GPU 忙的时间 ÷ 内核数:CPU 每个内核比它慢,GPU 就开始等 CPU。
| 场景(上下文 1,024) | GPU 纯计算 | eager,每内核 5 µs | eager,每内核 20 µs | CUDA Graph + 融合 | 临界值 |
|---|---|---|---|---|---|
| Llama-3.2-1B,batch 1 | 0.94 ms | 1.16 ms | 4.54 ms | 1.09 ms | 5.1 µs |
| Llama-3.2-1B,batch 64 | 1.80 ms | 2.13 ms | 4.54 ms | 1.96 ms | 9.4 µs |
| Llama-3-8B,batch 1 | 5.65 ms | 6.11 ms | 9.02 ms | 5.96 ms | 13.5 µs |
| Llama-3-8B,batch 64 | 9.07 ms | 9.74 ms | 9.74 ms | 9.37 ms | 21.6 µs |
临界值只有几微秒到十几微秒。模型越小、批越小,每个内核在 GPU 上越”薄”,越容易被 CPU 拖住:1B 模型 batch 1 时,CPU 每个内核 20 微秒,一步就要 4.54 ms,是 CUDA Graph 版的 4.2 倍。
CUDA Graph 的做法是:把一整次 forward 的所有内核启动录下来(capture),之后每一步只需要 CPU 发一个命令”重放这张图”(replay),GPU 自己按顺序执行几百个内核。CPU 的开销从”每个内核一次”变成”每步一次”。代价是:重放时每个内核的配置(网格大小、指针、形状)必须和录制时一样。可推理的每一步,批大小和每个请求的长度都在变。所以引擎通常为几十种批大小各录一张图,运行时选一张”刚好够大”的(多出来的位置填空);注意力内核则要设计成不依赖每一步具体长度的形式(第 4 讲 §5.6 FlashInfer 的持久内核与固定工作区)。第 8 讲的 MoE 也遇到同样的困难:每一步路由到每个专家的 token 数都不同。
真实世界的量级:PyTorch 团队的 gpt-fast 博客报告,在 A100 上跑 Llama-7B(batch 1),最简单的 eager 实现只有 25.5 token/s,trace 显示”严重受限于 CPU 开销”;用 torch.compile(CUDA Graph + 自动生成的融合内核)加静态 KV cache 后达到 107.0 token/s,MBU 72%。真实的 eager 框架每层发出的算子比我们模型里的 14 个多得多(reshape、类型转换、Python 里的小运算),每个又要 10 微秒以上,所以差距比我们的模型更大。(gpt-fast 的提速里也有更快的内核和静态 KV cache 的贡献,不全是启动开销。)第 16 讲 SGLang 的”零开销”调度器,是在 CUDA Graph 之外再把 CPU 的调度工作和 GPU 的计算重叠起来。
6.3 三种瓶颈
| 瓶颈 | 特征 | 怎样判断 | 推理里的例子 | 对策 |
|---|---|---|---|---|
| compute-bound | 算力用满 | 算术强度高于屋脊点;MFU 接近效率上限 | 长 prefill 的投影层;大批 decode 的投影层 | 用 Tensor Core、更低精度的计算(FP8)、减少无用功(tile / wave 量化) |
| memory-bound | 带宽用满 | 强度低于屋脊点;MBU 高 | decode;逐元素算子;不融合的注意力 | 批处理(复用权重)、融合(少走水管)、低精度存储(少搬字节)、更好的访存模式 |
| overhead-bound | 两者都没用满 | GPU 时间线上内核之间有大段空白;时间不随问题规模变化 | 小模型 / 小批的 decode;Python 调度循环 | CUDA Graph、融合(减少内核数)、CPU / GPU 重叠、用编译型语言写热点 |
Horace He 给过一个很实用的判断方法:把问题规模(批大小、序列长度)放大一倍,看时间怎么变。时间几乎不变,是 overhead-bound;时间翻倍、但 FLOP/s 远低于峰值,是 memory-bound;FLOP/s 接近峰值,是 compute-bound。
6.4 MFU 与 MBU
两个衡量”离硬件极限还有多远”的指标:
两者都只算模型本身必须的运算和搬运(不算激活、不算实现上多出来的读写)。MFU 是训练系统常用的指标;MBU 是 Databricks 在 LLM 推理的博客里提出的:decode 受限于带宽,评价它要看带宽用了多少。博客的例子:7B 模型 BF16 约 14 GB,TPOT 14 ms,峰值带宽 2 TB/s 的机器上 MBU = 14 GB ÷ 14 ms ÷ 2 TB/s = 50%。代码 03 演示 5(Llama-3-8B,H100,第 1 讲的纸上 GPU):
| 一步 | 时间 | 瓶颈 | MFU | MBU |
|---|---|---|---|---|
| prefill 2,048 | 43.38 ms | compute | 69.2% | 11% |
| prefill 8,192 | 191.09 ms | compute | 69.8% | 3% |
| decode batch 1,上下文 1,024 | 6.15 ms | memory | 0.26% | 73% |
| decode batch 64 | 9.31 ms | memory | 10.8% | 76% |
| decode batch 256 | 18.93 ms | memory | 21.3% | 78% |
prefill 看 MFU,decode 看 MBU。batch 1 的 decode MFU 只有 0.26%,但 MBU 有 73%:它在做它该做的事(读权重),只是这件事本身就慢。看到”MFU 不到 1%“就说”内核写得很差”是错的。批变大,MFU 随之上升、MBU 基本不变;提高 decode 效率的办法是把更多 token 塞进同一次读权重(批处理),或者让每次读的字节变少(低精度)。
7. 数值格式 · Number formats
7.1 浮点数是怎样构造的
一个二进制浮点数由三部分组成:1 个符号位 、 个指数位 、 个尾数位 。正规数(normal number)的值是
就像十进制的科学计数法 :尾数给出有效数字,指数给出数量级。开头的”1.”不用存(正规数的最高位总是 1),所以 位尾数实际有 位有效数字。指数字段全 0 时是次正规数(subnormal),值 = ,让数可以平滑地逼近 0;指数字段全 1 在 IEEE 格式里留给无穷大和 NaN。
代码 04 演示 2 把几个数存进不同格式。例如 BF16(,,bias = 127):
- 1.0 →
0 01111111 0000000:指数 127 − 127 = 0,尾数 0,值 。 - 0.1 →
0 01111011 1001101,存下来是 0.100098:0.1 在二进制里是无限循环小数(就像 1/3 在十进制里),任何二进制浮点格式都存不精确。 - 70000 → 70144(误差 0.21%)。
指数位管范围,尾数位管精度——这是理解所有格式的钥匙。
7.2 常见格式
代码 04 演示 1:
| 格式 | 位数 | 指数 | 尾数 | 最大值 | 相对精度 | 十进制有效位数 | 推理里的用途 |
|---|---|---|---|---|---|---|---|
| FP32 | 32 | 8 | 23 | 7.2 | 累加器、归一化、softmax 的内部计算 | ||
| TF32 | 19 | 8 | 10 | 3.3 | Tensor Core 上”FP32 输入”的矩阵乘法(训练里常用) | ||
| BF16 | 16 | 8 | 7 | 2.4 | 权重、激活、KV 的默认格式 | ||
| FP16 | 16 | 5 | 10 | 65,504 | 3.3 | 较早的模型;范围太小(§7.5) | |
| FP8 E4M3 | 8 | 4 | 3 | 448 | 1.2 | 推理的权重、激活、KV | |
| FP8 E5M2 | 8 | 5 | 2 | 57,344 | 0.125 | 0.9 | 训练里的梯度 |
| FP4 E2M1 | 4 | 2 | 1 | 6 | 0.25 | 0.6 | Blackwell 的 FP4 推理(必须配块缩放) |
(“相对精度”指 1 附近两个相邻数间距的一半。E4M3 遵循 OCP 规范的”FN”变体:没有无穷大,只有一个 NaN 编码,所以最大值是 448 而不是 240。)
几个要点:
- BF16 = FP32 的高 16 位:指数位一样多,所以范围和 FP32 一样;只是尾数从 23 位砍到 7 位,只剩 2–3 位十进制有效数字。从 FP32 转成 BF16 只需要舍掉低 16 位(代码里的
bf16_by_bits就是这么做的),非常便宜。 - FP16 反过来:尾数 10 位更精确,但指数只有 5 位,最大值只有 65,504。
- FP8 有两种分法:E4M3 精度高一点,E5M2 范围大一点。FP4 E2M1 的正值只有 0.5、1、1.5、2、3、4、6 这几个(加上 0),必须配合”每 16 或 32 个数一个缩放因子”才能用(附录讲 A §2.3)。
- 整数格式(INT8:−128 … 127;INT4:−8 … 7)没有指数,格点是均匀的,必须配一个缩放因子把真实数值映射过去。
7.3 间距随数值增大
代码 04 演示 3 看相邻两个可表示数的间距(括号里是相对间距):
| 格式 | ||||
|---|---|---|---|---|
| BF16 | 6.1e-05(0.61%) | 0.0078(0.78%) | 0.5(0.50%) | 64(0.64%) |
| FP16 | 7.6e-06(0.08%) | 0.00098(0.10%) | 0.0625(0.06%) | 8(0.08%) |
| FP8 E4M3 | 0.002(19.5%) | 0.125(12.5%) | 8(8.0%) | 超出范围 |
浮点数的绝对间距和数值大小成正比,相对间距大致不变:小数附近格点密,大数附近格点疏。这让一种格式能同时表示 0.01 和 10000,代价是每个数只有固定的几位有效数字。BF16 里 100 附近的间距是 0.5,10000 附近是 64:如果你把 token 的位置编号(position id)存成 BF16,位置 1001 会被舍入成 1000——练习 6 会算这个坑。
INT8 则相反:配好缩放因子后间距处处相同。例如让 ±100 映射到 ±127,间距是 0.79:在 100 附近相对误差 0.79%,在 1 附近却高达 79%。数据集中在 0 附近、偶尔有很大的离群值时,浮点格式更有利;数据分布均匀时,INT8 更精确。附录讲 A §2.2 用合成数据比较了这两种情况。
7.4 累加:为什么 Tensor Core 用 FP32 累加
矩阵乘法里最关键的一步是累加:一个输出元素是 个乘积之和, 动辄几千。代码 04 演示 4 揭示了低精度累加的问题:
(a) 被淹没(swamping)。10,000 个 0.01 顺序相加(正确答案 100):FP32 累加器得到 100.003;FP16 累加器停在 32,BF16 累加器停在 4。原因:和变大以后,它附近的间距超过了 0.02(BF16 在 4 附近的间距是 1/32),再加 0.01 就小于半个间距,被舍入掉了。
(b) 点积。长度 4,096 的点积,输入是 BF16(乘积本身精确):
| 累加方式 | 相对误差 |
|---|---|
| 顺序累加,FP32 累加器 | |
| 顺序累加,BF16 累加器 | |
| 树形(两两)求和,每一步都舍入到 BF16 |
输入可以是 BF16,累加必须用 FP32:差了四个数量级。这正是 Tensor Core 的设计——“低精度乘、FP32 累加”。树形求和让每个数只经过 次舍入,即使全程 BF16 也好得多;GPU 上的归约(reduction)天然是树形的。
(c) 浮点加法不满足结合律。同样 4,096 个 FP32 数,切成 1、2、4、8、16 段分别求和再相加,得到 5 个不同的结果(差在第 7 位有效数字左右)。数学上 ,浮点数上不一定,因为每一步都有舍入。这有一个推理系统里很现实的后果:GPU 内核会根据批大小选不同的切分方式(split-K、不同的块数),所以同一个请求和不同的请求拼批时,输出的 logits 可能差一点点,偶尔让贪心解码选出不同的 token。第 17 讲提到的”批不变性”(batch invariance)问题——同样的输入、温度为 0,两次运行结果不一样——根源就在这里。
7.5 溢出:FP16 为什么被 BF16 取代
代码 04 演示 5:RMSNorm 要算 。激活里偶尔会有几百的离群值(附录讲 A §2.4 讲它们从哪里来),而 :
| 计算 的格式 | 结果 | 归一化后与精确值的最大差 |
|---|---|---|
| FP16 | inf | 39.6(整行变成 0) |
| BF16(平方和也用 BF16 累加) | 56.5(正确值 57.37) | 0.30 |
| FP32 | 57.37 |
FP16 里平方直接溢出成无穷大,归一化后整行都是 0。BF16 范围足够,但如果连平方和也用 BF16 累加,误差就大了。这就是两个工程惯例的来历:
- BF16 取代 FP16 成为训练和推理的默认格式:范围和 FP32 一样,不用再操心溢出(FP16 训练要做 loss scaling 之类的防溢出处理)。
- 归一化、softmax 这类归约算子在 FP32 下计算,只把结果存回 BF16。
7.6 低精度为什么能加速推理
代码 04 演示 6(Llama-3-8B,H100,第 1 讲的纸上 GPU):
| 权重 / KV 每个数的字节 | decode batch 1 | decode batch 64 |
|---|---|---|
| FP32 / FP32(4 / 4) | 11.80 ms(0.52×) | 18.12 ms(0.51×) |
| BF16 / BF16(2 / 2) | 6.15 ms(1.00×) | 9.31 ms(1.00×) |
| FP8 / FP8(1 / 1) | 3.33 ms(1.85×) | 4.90 ms(1.90×) |
| INT4 权重 / FP8 KV(0.5 / 1) | 1.93 ms(3.19×) | 3.50 ms(2.66×) |
prefill 2,048 个 token:BF16 43.38 ms;只把权重存成 1 字节、计算仍用 BF16,还是 43.38 ms;Tensor Core 直接算 FP8(峰值 1,979 TFLOP/s)才降到 21.93 ms。
低精度在推理里有三种收益,要分清:
- 字节少:decode 受限于带宽,时间几乎和字节数成正比。FP8 快 1.85 倍(不到 2 倍是因为每步还有 0.5 ms 的固定开销)。
- 算力高:prefill 受限于算力,只有 Tensor Core 直接做低精度乘法(FP8 峰值是 BF16 的 2 倍,Blackwell 的 FP4 再翻倍)才有用。
- 容量大:同样的显存放下更多的 KV,批可以更大(第 3 讲、第 17 讲)。
INT4 权重只有第 1 种收益,而且要在内核里先把 4 位整数”反量化”成 BF16 再算,大批时这笔开销会吃掉收益。具体的量化方法(怎样选缩放因子、怎样处理离群值、怎样保证质量)是附录讲 A 的内容。
8. 在你的电脑上 · On your own machine
roofline 不是 GPU 专属的。代码 05 在你的电脑上实测三种瓶颈。下面引用的是在一台 Apple M4 Pro(12 核,numpy 链接 Apple 的 Accelerate 库)上的结果,你的机器会不同。
计算器和水管(演示 1):矩阵乘法约 3.0–3.2 TFLOP/s(Accelerate 用上了多个核心和矩阵加速单元);单线程复制约 128 GB/s;12 个线程一起读写约 225 GB/s(Apple 标称这款芯片的内存带宽是 273 GB/s)。用全核带宽算,屋脊点约 14 FLOP/byte;H100 是 295。两者都远大于 1:几乎所有计算设备都是”算得快、搬得慢”,这是 roofline 到处适用的原因。H100 的算力约是这台 CPU 的 300 多倍,带宽约 15 倍。
一个核心用不满水管。单线程只用到约 128 GB/s:在核心多、内存快的芯片上,一个核心自己的读写吞吐就跟不上内存。这引出一个容易忽略的要点:roofline 的”屋顶”要用这段代码实际能用到的带宽和算力。单线程的 numpy 和用满全部核心的 BLAS 面对的不是同一个屋顶;GPU 上一个只启动了几个线程块的内核,也用不满 HBM 带宽(§2.2)。
CPU 上的”算子融合”(演示 2):对一个 256 MB 的数组连续做 16 次 。不融合:16 遍,每遍从内存读写整个数组。分块融合:把数组切成放得进缓存的小块,在缓存里连做 16 次再处理下一块。
| 不融合 | 分块融合(每块 4 MB) | 加速 | |
|---|---|---|---|
| 单线程 | 约 72 ms | 约 60 ms | 1.2× |
| 12 个线程 | 约 36 ms | 约 15 ms | 2.4× |
12 个线程一起做时,内存是瓶颈,融合”少走水管”就有用;单线程时核心本身是瓶颈,在缓存里做也快不了多少。融合省的是走水管的次数,只有水管是瓶颈时才有用。块大小也有讲究:12 个线程、每块 256 KB 时,每块一次 Python 调用的开销占主导,比不融合还慢近 3 倍(0.36×);4 MB 最快(约 2.4–2.5×);16 MB 时所有线程的块加起来放不进缓存,又退回到 1.3×。GPU 内核选块大小时面对的是同一个权衡。
固定开销(演示 3):一次 8 个元素的 numpy 加法约 180 纳秒,1,000 个元素也只要约 250 纳秒——这时时间几乎全是”Python 调用 + numpy 分发 + 分配结果”的开销,和运算量无关。1,000 个 64 维向量各自乘一个 64 × 64 矩阵,Python 循环 1,000 次比拼成一次矩阵乘法慢约 100 倍。GPU 上的对应物就是 §6.2 的 CUDA Graph 和第 2 讲的批处理。
第 1 讲代码 02 演示 6 还在你的电脑上测过”向量乘矩阵 vs 矩阵乘矩阵”,可以和这里一起看。
9. 与推理系统的联系 · Connections to inference systems
有系统背景的读者可以只读这一节。
| 本篇的概念 | 在推理系统里的体现 | 哪一讲 |
|---|---|---|
| 屋脊点、强度 ≈ 批里的 token 数 | 批处理是第一杠杆;prefill compute-bound、decode memory-bound | 第 1、2 讲 |
| 显存容量 | KV cache 是批大小的天花板;分页、前缀缓存、分层存储 | 第 3、9 讲 |
| 片上 SRAM vs HBM、分块、IO 感知 | FlashAttention;decode 注意力的 split-KV | 第 4 讲 |
| 在途的访存、占用率 | decode 注意力要给 SM 足够多的并行工作 | 第 4 讲 |
| 循环次数不齐、负载均衡 | 长短不一的请求放在一起;FlashInfer 的负载均衡调度 | 第 2、4 讲 |
| 合并访存 | KV cache 按块连续存放;页大小的选择 | 第 3、4 讲 |
| tile / wave 量化 | 小批 GEMM 的专门内核;CUDA Graph 的批大小档位 | 第 16 讲、附录讲 A |
| Tensor Core 只做矩阵乘法 | 把注意力写成矩阵乘法;softmax 成为新瓶颈 | 第 4 讲 |
| 算子融合 | FlashAttention、fused add + RMSNorm、融合的反量化内核 | 第 4 讲、附录讲 A |
| 启动开销、CUDA Graph | decode 用 CUDA Graph;FlashInfer 兼容 CUDA Graph;MoE 的动态形状 | 第 4、8、16 讲 |
| CPU 与 GPU 异步 | 零开销调度器:CPU 调度和 GPU 计算重叠 | 第 16 讲 |
| MFU / MBU | 评价 prefill / decode 的效率 | 第 1、17 讲 |
| FP32 累加、非结合律 | 批不变性、确定性推理 | 第 17 讲 |
| 低精度:字节、算力、容量 | FP8 推理;INT4 权重;KV 量化 | 第 17 讲、附录讲 A |
| L2 在 SM 之间共享、线程块簇 | GPU 空间共享时”互相干扰”的来源之一 | 第 12 讲 |
10. 小结 · Summary
- GPU 是吞吐优先的:大量简单的运算单元,靠成千上万个线程隐藏几百纳秒的访存延迟(在途的字节 = 带宽 × 延迟)。
- 执行层次:线程 → warp(32 个,共用指令流)→ 线程块(共享”共享内存”)→ 网格;硬件上是 132 个 SM。分支发散、循环次数不齐、不合并的访存、tile / wave 量化都会浪费硬件。
- 存储层级:寄存器、共享内存、L2、HBM 越来越大、越来越慢。矩阵乘法靠分块复用数据,强度随块大小增长,受片上容量限制,再靠 L2 在 SM 之间共享补足;逐元素运算没有复用,无论怎么写都受限于带宽,只能靠融合减少读写。
- 投影层的强度 ≈ 批里的 token 数:prefill compute-bound,decode memory-bound,要三百多个 token 一起算才越过 H100 的屋脊点。
- 逐算子的 roofline 显示:prefill 时矩阵乘法占 96% 的 FLOPs 却只占 70% 的时间,不融合的注意力和逐元素算子占了其余;decode 时一切受限于带宽,批大了以后读 KV 成为主要开销。
- 三种瓶颈:compute(用 Tensor Core、低精度计算)、memory(批处理、融合、低精度存储)、overhead(CUDA Graph、减少内核数、CPU / GPU 重叠)。prefill 看 MFU,decode 看 MBU。
- 数值格式:指数位管范围,尾数位管精度。BF16 范围大、精度低;累加必须用 FP32;浮点加法不满足结合律。低精度的收益有三种:字节少(decode)、算力高(prefill)、容量大(KV)。
11. 常见误区 · Common pitfalls
- “GPU 快是因为时钟频率高”。GPU 的频率(约 2 GHz)比 CPU 还低;它快在同时有几万个运算单元在工作,以及专门做矩阵乘法的 Tensor Core。
- “MFU 低就说明内核写得差”。decode 的 MFU 天生不到 1%;该看的是 MBU。反过来,prefill 的 MBU 低也不说明有问题。
- “FLOPs 少的算子不重要”。逐元素算子和不融合的注意力只占 prefill 约 4% 的 FLOPs,却占了约 30% 的时间(§5.3)。
- “占用率越高越好”。访存密集的内核需要高占用率来隐藏延迟;矩阵乘法内核则故意用低占用率换更大的分块。
- “把数据放进共享内存就快了”。共享内存只有在数据被复用时才有用;逐元素运算读一次用一次,放不放进共享内存都一样慢(§4.5)。
- “量化成 INT4 一定快 4 倍”。只有 decode 受益于字节少,而且要扣掉固定开销、反量化开销;prefill 不会因为权重存成 INT4 而变快(§7.6)。
- “同样的输入、温度为 0,输出一定相同”。浮点加法不满足结合律,批的组成不同会让内核选不同的切分方式,logits 会有微小差异(§7.4)。
- “roofline 的屋顶就是厂商标的峰值”。屋顶要用这段代码实际能用到的资源:不是矩阵乘法的算子用 CUDA core 的屋顶;单线程程序用一个核心的带宽(§5.1、§8)。
12. 练习 · Exercises
练习 1(A100 的屋脊点)。A100-80GB 的 BF16 峰值 312 TFLOP/s,带宽 2.039 TB/s。(a) 屋脊点是多少?(b) Llama-3-8B 的 gate_up 投影(,)在 A100 上大约需要多少个 token 一起算才越过屋脊点?和 H100 比,为什么更少?
参考答案
(a) FLOP/byte。(b) 强度 ≈ (§4.6),所以约 150 多个 token;精确地用 gemm_intensity_ideal 算是 160(qkv_proj 是 164)。H100 的算力相对带宽涨得更多(屋脊点 295),所以要更大的批才能用满算力——每一代 GPU 的屋脊点都在上升,“批处理”变得越来越重要。
练习 2(Little 定律)。H200 的带宽是 4.8 TB/s。假设访存延迟约 600 纳秒,要用满带宽,任意时刻需要多少字节在途?如果每条 warp 访存指令带回 128 字节,需要多少条?和 H100 最多驻留的 8,448 个 warp 比,说明了什么?
参考答案
MB;÷ 128 = 22,500 条。比驻留的 warp 数还多,说明每个 warp 必须同时发出多条访存(不等前一条回来就发下一条),或者用 TMA 这类异步搬运单元一次搬一大块。带宽越高,隐藏延迟需要的”在途工作”越多——这也是 Hopper 加入 TMA、异步拷贝的原因之一。
练习 3(分块的强度)。BF16 的矩阵乘法用 64 × 64 的方块分块。(a) 强度约是多少?(b) 要在 A100 上(屋脊点 153)只靠一个块的复用达到屋脊点,方块边长要多大?累加器(FP32)要多少字节?A100 每个 SM 的寄存器是 256 KB,放得下吗?
参考答案
(a) FLOP/byte。(b) 强度 ≈ ,要 ;累加器 KB,放不下。和 §4.3 的结论一样:单个 SM 的片上存储不够,要靠 L2 在 SM 之间共享数据。
练习 4(融合省多少读写)。不融合时,“残差相加 + RMSNorm”是两个内核:加法读 2 个长度 的向量、写 1 个;RMSNorm 读 1 个、写 1 个。融合成 fused add + RMSNorm 后,读 2 个、写 2 个(新的残差流和归一化后的结果)。每个 token 每层省了百分之几的激活读写?为什么这对 decode 的总时间影响很小?
参考答案
不融合 个向量的读写,融合后 4 个,省 20%。但 decode 时激活只有每个请求一个 token 那么大( 个数,8 KB),而每一层要读几百 MB 的权重,激活的读写不到千分之一;所以融合在 decode 里的主要价值是减少内核数,而不是字节(§6.1)。
练习 5(小模型的启动开销)。一个结构类似 Qwen2.5-0.5B 的模型(24 层,,14 个 query 头、2 个 KV 头,,词表 151,936;按 lm_head 不共享计)在 H100 上 decode(batch 1,上下文 1,024)。用 gpu_lab 算:融合后有多少个内核?GPU 纯计算时间是多少?CPU 每发射一个内核最多能花多少微秒,GPU 才不会等 CPU?一个每内核约 2 微秒的 C++ 引擎够不够?
参考答案
transformer_ops(LLMSpec("toy0.5B", 24, 896, 14, 2, 4864, 151936), 1, 1, 1024, fused=True):219 个内核,GPU 纯计算约 0.37 ms;加上每个内核 1 微秒的空隙,临界值约 2.7 微秒。每内核 2 微秒的 C++ 引擎刚好在临界值附近,稍有抖动就会让 GPU 等 CPU;Python 框架(10 微秒以上)则一定是 overhead-bound。所以小模型的 decode 几乎必须用 CUDA Graph。
练习 6(BF16 能精确表示的整数)。(a) BF16 有 8 位有效数字(7 位尾数 + 隐含的 1),从 0 开始连续的整数最大能精确表示到多少?FP16 呢?(b) 如果把 token 的位置编号存成 BF16,位置 1001 会变成多少?257 呢?(c) 这对 RoPE 意味着什么?
参考答案
(a) BF16:;257 就不能精确表示了。FP16:。(b) 1001 → 1000(512 到 1024 之间间距是 4);257 → 256(平局取偶数)。quantize_float 可以验证。(c) 如果位置编号或者”位置 × 频率”的角度在 BF16 下计算,几百个 token 以后相邻位置就会算出相同的角度,模型分不清它们——长上下文会出错。所以 RoPE 的角度要在 FP32 下计算(很多实现都特意这样做)。
练习 7(MBU)。Llama-3-70B(约 706 亿参数)用 FP8 权重、在 2 块 H100 上做张量并行(每块卡存一半权重),batch 1 时 TPOT 是 20 ms。忽略 KV,每块卡的 MBU 是多少?要把 MBU 提到 80%,TPOT 要降到多少?
参考答案
每块卡每步读 GB, TB/s,MBU ≈ 1.76 / 3.35 ≈ 53%。MBU 80% 对应 ms。多卡时还要加上通信时间(前置讲 P5、第 6 讲),所以实际很难达到。
13. 延伸阅读 · Further reading
- S. Williams, A. Waterman, D. Patterson. Roofline: An Insightful Visual Performance Model for Multicore Architectures. Communications of the ACM, 2009.
- H. He. Making Deep Learning Go Brrrr From First Principles. 博客,2022。三种瓶颈、算子融合、怎样判断自己处在哪种瓶颈。
- NVIDIA. NVIDIA Hopper Architecture In-Depth(开发者博客,2022);Hopper Tuning Guide;CUDA C++ Programming Guide(尤其是 SIMT 架构、存储层级、计算能力表)。
- NVIDIA. Matrix Multiplication Background User’s Guide(Deep Learning Performance 文档)。GEMM 的算术强度、tile 量化与 wave 量化。
- PyTorch 团队. Accelerating Generative AI with PyTorch II: GPT, Fast. 博客,2023。从 25 token/s 到 200 多 token/s 的逐步优化。
- Databricks. LLM Inference Performance Engineering: Best Practices. 博客,2023。MBU 的定义。
- J.-W. Hong, H. T. Kung. I/O Complexity: The Red-Blue Pebble Game. STOC 1981。分块的理论下界。
- Micikevicius et al. FP8 Formats for Deep Learning. 2022。E4M3 与 E5M2。
- 进一步的 GPU 编程入门:NVIDIA 的 CUDA 编程指南前几章;Triton 的教程(用 Python 写分块的 GPU 内核)。
14. 下一篇 · Next
前置讲 P2:Transformer 推理的账本。本篇反复用到”一次 forward 要做多少运算、搬多少字节”,P2 把这本账逐项推导出来:参数量、每 token 的 FLOPs(包括注意力项)、MHA / GQA / MQA / MLA 对 KV cache 的影响、显存预算、延迟下界;再补上推理引擎里除 forward 以外的部分——采样方法和分词。