前置讲 P1 · GPU 与性能的第一性原理:从 roofline 到 Tensor Core 与数值格式 · GPU Performance from First Principles

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

课程源码 ZIP ↓

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

本篇目录

对应 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

学完这一篇,你应该能够:

  1. 说清 CPU 和 GPU 的设计哲学有什么不同(延迟优先 vs 吞吐优先),并用 Little 定律解释 GPU 为什么需要成千上万个线程。
  2. 描述 GPU 的执行层次(线程 → warp → 线程块 → 网格;SM)和存储层级(寄存器、共享内存、L2、HBM),并解释分支发散、合并访存、占用率、tile / wave 量化这几个常见的性能陷阱。
  3. 用”分块与数据复用”解释:为什么矩阵乘法能跑满 Tensor Core,而逐元素运算无论怎么写都受限于带宽;为什么投影层的算术强度约等于批里的 token 数。
  4. 用 roofline 逐个算子地分析 Transformer 的一次 forward,说出 prefill 和 decode 的时间各花在哪里。
  5. 区分三种瓶颈(compute-bound、memory-bound、overhead-bound),说出各自的对策:批处理、算子融合、低精度、CUDA Graph;会算 MFU 和 MBU。
  6. 说清浮点数是怎样构造的,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 讲的记号(π\pi 峰值算力、β\beta 显存带宽、II 算术强度、BB 批大小)。本篇新增:

符号 / 术语含义
SMstreaming multiprocessor,GPU 上的一个”小处理器”;H100 SXM 有 132 个
warp32 个线程组成的执行单位,共用一个指令流
线程块(thread block,也叫 CTA)一组线程,被整体放到一个 SM 上运行,块内线程可以共享”共享内存”
内核(kernel)在 GPU 上执行的一个函数;一次启动会创建一个由很多线程块组成的网格(grid)
bm×bnb_m \times b_n矩阵乘法里一个线程块负责的输出块(tile)大小
M,N,KM, N, K矩阵乘法 CM×N=AM×KBK×NC_{M\times N} = A_{M\times K} B_{K\times N} 的三个维度;在 LLM 里 MM 通常是这一步的 token 数
MFU / MBU模型算力利用率 / 模型带宽利用率(§6.4)

单位同第 1 讲:字节用十进制(1 GB = 10910^9 字节),需要 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 抽象成两个数字:峰值算力 π\pi(计算器每秒做多少次运算)和显存带宽 β\beta(水管每秒搬多少字节)。一次计算要做 FF 次运算、搬 QQ 个字节,它至少要

T  ≥  max⁡ ⁣(Fπ,  Qβ).T \;\ge\; \max\!\left(\frac{F}{\pi},\; \frac{Q}{\beta}\right).

把 I=F/QI = F / Q(算术强度)和屋脊点 π/β\pi / \beta 比较,就知道是计算器先忙满(compute-bound)还是水管先忙满(memory-bound)。H100 的屋脊点是 989×1012/3.35×1012≈295989 \times 10^{12} / 3.35 \times 10^{12} \approx 295 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 会正式讲)算:一个系统里平均”在里面”的东西的数量 = 到达率 × 每个东西平均停留的时间。放到显存上:

在途的字节=带宽×访存延迟.\text{在途的字节} = \text{带宽} \times \text{访存延迟}.

要让 H100 的水管一直满着(3.35 TB/s),如果一次访存要约 500 个时钟周期(约 270 纳秒,这是我为说明量级取的假设),任意时刻就要有 3.35×1012×2.7×10−7≈0.93.35 \times 10^{12} \times 2.7 \times 10^{-7} \approx 0.9 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 FP81,979 TFLOP/s
Tensor Core BF16 / FP16989 TFLOP/s
Tensor Core TF32495 TFLOP/s
CUDA core FP3267 TFLOP/s
CUDA core FP6433.5 TFLOP/s

最重要的一行对比:Tensor Core(BF16)是 CUDA core(FP32)的 14.8 倍。Tensor Core 只会做一件事——小矩阵块的乘加(§3.5);其他一切运算(加法、指数、比较、开方)都只能用 CUDA core。所以一个算子能用到多少算力,首先取决于它能不能写成矩阵乘法。第 1 讲用的 π=989\pi = 989 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 GB3.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% 的线程走 if51%
随机 1% 的线程走 if81%

随机 10% 时几乎每个 warp 里都至少有一个线程走 if(1−0.932≈97%1 - 0.9^{32} \approx 97\%),效率和奇偶交替差不多;随机 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)4100%
步长 2850%
步长 8 及以上(例如按列读行优先的矩阵)3212.5%
连续,但起点偏移 16 字节580%
每个线程读 16 字节(向量化)16100%

相邻线程读相邻地址时,请求被合并(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 寄存器/线程864100%warp 数
256 线程/块,64 寄存器/线程43250%寄存器
128 线程/块,255 寄存器/线程2812.5%寄存器
128 线程/块,48 KB 共享内存41625%共享内存
GEMM 式:384 线程,168 寄存器,200 KB 共享内存11218.8%寄存器

限制占用率的往往不是线程数,而是寄存器和共享内存:每个线程用的寄存器越多,能同时驻留的线程越少。

占用率要多高?取决于内核在等什么。演示 4 用一个玩具模型说明:每个 warp 计算 20 个周期、再等 500 个周期的访存(假设值),那么一个 warp 单独跑时 SM 只有 4% 的时间在干活,要约 26 个 warp 轮流才能让 SM 不闲着。访存密集的内核需要高占用率。但矩阵乘法内核恰恰相反:它故意让每个线程用大量寄存器、每个块用大量共享内存,好把分块做大、让每个读进来的数被复用更多次(§4);占用率只有一个块、十几个 warp,照样能跑满 Tensor Core。高性能内核的设计,就是在”块大(复用多)“和”块多(并行多、能隐藏延迟)“之间找平衡。

3.5 Tensor Core

Tensor Core 是专门做小矩阵乘加 D=A×B+CD = A \times B + C 的硬件单元:一条指令由一组线程(Hopper 上是 4 个 warp 组成的 warpgroup)合作发出,一次完成一个小矩阵块的乘法和累加。每个 SM 有 4 个 Tensor Core。和 CUDA core 相比,它的优势来自两点:一次处理一整块,省掉了大量的取指令和寄存器读写;输入用低精度(BF16、FP8),累加用 FP32(§7.4 解释为什么必须这样)。H100 的 Tensor Core 每个 SM、每个时钟的矩阵乘法吞吐是 A100 的 2 倍,用 FP8 时再翻一倍。

Tensor Core 带来了一个深远的后果:所有想跑快的计算,都要尽量变成矩阵乘法。注意力里的 QK⊤QK^\top 和 PVPV 是矩阵乘法,可以用 Tensor Core;softmax 不是,只能用 CUDA core。在 H100 上前者的算力是后者的 15 倍,到了 Blackwell,Tensor Core 的吞吐又翻倍,计算指数函数的单元却几乎没有增长,于是 softmax 的指数运算成了 FlashAttention-4 要专门对付的瓶颈(第 4 讲 §3.5)。

3.6 tile 量化与 wave 量化

矩阵乘法内核让每个线程块负责输出矩阵 CC 的一个 bm×bnb_m \times b_n 的块(tile)。由此产生两种”量化”浪费(这里的”量化”指”只能取整数个”,和 §7 的数值量化无关):

  • tile 量化:MM、NN 不是块大小的整数倍时,边上的块只有一部分有用,但仍要做整块的计算。
  • wave 量化:所有 SM 同时各算一个块叫”一波”(wave)。块数比 SM 数多一点点,就要多跑一整波,而这一波只有少数 SM 在干活。

代码 01 演示 5 先复现 NVIDIA GEMM 指南里的例子:A100(108 个 SM),M=2304M = 2304,块 256 × 128。N=1536N = 1536 时块数是 9 × 12 = 108,正好一波;N=1544N = 1544 时块数变成 117,多出来的 9 个块要单独再跑一整波——时间翻倍、FLOP/s 减半,与 NVIDIA 的实测一致。

再看推理里的形状:H100 上 Llama-3-8B 的 gate_up 投影 CM×28672=XM×4096WC_{M \times 28672} = X_{M \times 4096} W,块 128 × 128:

MM(批里的 token 数)块数波数tile 效率总效率
122420.8%0.7%
64224250.0%42.4%
1282242100%84.8%
129448450.4%42.8%
2,0483,58428100%97.0%

decode 时 MM 只有几到几十,一个 128 行的块里只有 MM 行有用,Tensor Core 做的大部分是无用功。好在 decode 本来就受限于带宽(读权重),算力浪费了也不影响时间;但它说明小批的矩阵乘法需要专门的内核(更小的块、沿 KK 方向切分的 split-K),附录讲 A 的 Marlin 就是为这种形状设计的。MM 从 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 同样要读显存,为什么矩阵乘法快?

矩阵乘法 C=ABC = AB(AA 是 M×KM \times K,BB 是 K×NK \times N)一共做 2MNK2MNK 次运算,而三个矩阵一共只有 MK+KN+MNMK + KN + MN 个数。如果每个数只从显存读写一次,算术强度是

Iideal=2MNK(MK+KN+MN)⋅每个数的字节.I_{\text{ideal}} = \frac{2MNK}{(MK + KN + MN) \cdot \text{每个数的字节}}.

M=N=K=4096M = N = K = 4096、BF16 时,I≈1365I \approx 1365 FLOP/byte,远超 H100 的屋脊点 295。原因是复用:AA 的每个元素要和 BB 的 NN 个元素相乘,BB 的每个元素要和 AA 的 MM 个元素相乘。

但”每个数只读一次”是理想情况。最直接的写法——每个线程算一个输出元素 CijC_{ij},自己去显存读一整行 Ai,:A_{i,:} 和一整列 B:,jB_{:,j}——完全没有利用复用:AA 的每一行被读了 NN 次。代码 02 演示 1 在 128 × 128 × 128 的矩阵上真的这样算了一遍:强度只有 0.5 FLOP/byte,每读进 2 个数(4 字节)只做 1 次乘加,和逐元素运算一样差。

4.2 分块

分块(tiling)的做法是:每次把 AA 的一个 bm×bkb_m \times b_k 块和 BB 的一个 bk×bnb_k \times b_n 块读进快存储(共享内存),在快存储里把它们乘起来,累加到一个 bm×bnb_m \times b_n 的累加器(寄存器)上;沿 KK 方向走完,再把这个输出块写回显存。

         B 的一列块(b_k × b_n),沿 K 方向一块一块读进来
              ┌──┐
              │  │
              │  │
   ┌──────────┼──┼─┐
 A │ ▓▓▓▓▓▓▓▓ │▓▓│ │   C 的一个输出块(b_m × b_n),累加器放在寄存器里
   └──────────┴──┴─┘
   A 的一行块(b_m × b_k),沿 K 方向一块一块读进来

读进来的 AA 块会和 bnb_n 列 BB 相乘,BB 块会和 bmb_m 行 AA 相乘。每个输出块沿 KK 方向一共读 K(bm+bn)K(b_m + b_n) 个数、做 2bmbnK2 b_m b_n K 次运算,所以

Itile≈2 bmbn(bm+bn)⋅每个数的字节→  bm=bn=b, BF16  b2.I_{\text{tile}} \approx \frac{2\, b_m b_n}{(b_m + b_n) \cdot \text{每个数的字节}} \quad\xrightarrow{\;b_m = b_n = b,\ \text{BF16}\;}\quad \frac{b}{2}.

块越大,强度越高。代码 02 演示 1 的结果(128³,按 BF16 记账):

写法HBM 读强度(FLOP/byte)需要的快存储
朴素8.39 MB0.50512 B
分块 16 × 16524 KB7.532.05 KB
分块 64 × 64131 KB25.6020.48 KB
分块 128 × 12865.5 KB42.6773.73 KB

所有写法算出来的 CC 都一样(差别只是浮点舍入,约 10−1410^{-14})。分块只改变”数据从哪儿来”,不改变数学——这正是 FlashAttention 的精神。

4.3 片上容量决定强度的上限

块不能无限大:输入块要放进共享内存,累加器要放进寄存器。代码 02 演示 2 在 4096³ 的 BF16 矩阵上算了一遍(bk=64b_k = 64,输入块双缓冲,累加器 FP32):

块大小强度共享内存累加器一个 SM 放得下?
64 × 6431.832 KiB16 KiB能
128 × 12863.064 KiB64 KiB能
128 × 25683.696 KiB128 KiB能
256 × 256124.1128 KiB256 KiB不能

一个 SM 的寄存器一共 256 KiB,还要留给地址计算等,所以能用的块大约到 128 × 256 为止(Hopper 上 CUTLASS 常用的就是这个量级),强度约 84 FLOP/byte。要达到屋脊点 295,方块边长要约 590,光累加器就要 1.33 MiB。

这是一个普遍的结论,早在 1981 年就被 Hong 和 Kung 证明过:在一台快存储容量为 SS 的机器上,矩阵乘法和慢存储之间的数据搬运量至少是 Ω(n3/S)\Omega(n^3 / \sqrt{S})。片上存储越大,能达到的算术强度越高;FlashAttention 论文也用同样的思路证明了它的 IO 复杂度在某个范围内是最优的(第 4 讲 §3.4)。

于是有一个看似矛盾的结论:只靠一个 SM 自己的片上存储,矩阵乘法相对 HBM 仍然受限于带宽(84 < 295)。可真实的 GEMM 在 H100 上确实能跑到峰值的 70% 以上。缺的那一截从哪里来?

4.4 L2:多个线程块共享数据

答案在 L2。132 个 SM 同时各算一个输出块;如果线程块的执行顺序让”同时在跑的块”在输出矩阵里挨在一起,它们需要的 AA 行块、BB 列块就有大量重叠:同一块数据从 HBM 读进 L2 一次,之后被好几个 SM 从 L2 取走。代码 02 演示 3 用理想化的模型算这笔账(132 个 128 × 256 的块排成 12 × 11 的网格,覆盖 1536 × 2816 的区域):

哪一级强度达到 70% 峰值需要的带宽
单个块,数据全从 HBM 来858.1 TB/s
一波 132 个块经 L2 共享,相对 HBM9940.7 TB/s
相对 L2:每个块各自从 L2 取858.1 TB/s
相对 L2:2 个 SM 一簇、BB 块多播1285.4 TB/s

经过 L2 共享,相对 HBM 的强度超过了屋脊点,HBM 不再是瓶颈(这要求块的调度顺序合适——CUTLASS 里叫 swizzle——并且 L2 放得下它们的工作集)。瓶颈于是挪到了 L2 → SM 这一级。Hopper 新增的线程块簇和 TMA(张量内存加速器,专门搬数据的硬件单元)可以把同一个 BB 块多播给簇里的几个 SM,进一步减少从 L2 读的量。

这个演示想说明的是 roofline 的分层用法:存储层级的每一级都可以画一条”屋顶”,每一级都要算一遍”强度 vs 带宽”的账。Williams 等人的原论文就有这种”多层天花板”的画法。

4.5 逐元素运算:没有复用,只能融合

现在看另一类算子:加法、缩放、激活函数、归一化、softmax。它们的共同点是每个输入元素只被用一次,没有任何复用。代码 02 演示 4(BF16):

算子每元素运算强度(FLOP/byte)离屋脊点差多少倍
加法 x+yx + y(读 2 写 1)10.171,771×
SiLU x⋅σ(x)x \cdot \sigma(x)41.00295×
softmax(约 5 次运算)51.25236×
一个元素做 100 次运算10025.012×

要让一个逐元素算子达到屋脊点,每个元素要做上千次运算,现实中的激活函数、归一化远远做不到。逐元素运算无论怎么写都受限于带宽,再好的内核也只能逼近带宽的上限。

唯一的办法是少读写几次:把相邻的算子合并成一个内核,中间结果留在片上,这就是算子融合(kernel fusion)。Horace He 的例子:x.cos().cos() 不融合时是两个内核,读写 4 次;融合后读写 2 次,时间减半,而且融合后的”两次 cos”和”一次 cos”几乎一样快——时间全花在读写上,多算一次 cos 是免费的。

4.6 LLM 里的矩阵乘法:强度 ≈ 批里的 token 数

把 §4.1 的公式用到 LLM 的投影层:XM×KWK×NX_{M \times K} W_{K \times N},MM 是这一步的 token 数,KK、NN 是几千到几万的模型维度。M≪K,NM \ll K, N 时,

Iideal=2MNK(MK+KN+MN)⋅2≈2MNK2KN=M.I_{\text{ideal}} = \frac{2MNK}{(MK + KN + MN) \cdot 2} \approx \frac{2MNK}{2KN} = M.

批里有多少个 token,强度就约是多少。代码 02 演示 5 对 Llama-3-8B 的五个矩阵乘法算了一遍:

投影MM=1MM=64MM=256MM=2,048越过屋脊点的 MM
qkv_proj1622321,117336
gate_up_proj1632391,303322
down_proj1632371,247326
lm_head1632401,351319

这一行公式解释了推理系统的大部分设计:

  • batch = 1 的 decode:M=1M = 1,强度约 1,每读一个 2 字节的权重只做 2 次运算——“只用到不到 1% 的算力”(第 1 讲)。
  • 批处理:多一个 token 一起算,权重就多被复用一次。批处理是第一杠杆,原因就在这里(第 2 讲)。
  • prefill 一次就有几百上千个 token,所以是 compute-bound;decode 每个请求每步只贡献 1 个 token,要三百多个请求一起 decode,投影层才越过屋脊点——而且注意力读 KV 的部分不会因为批大而复用(第 1 讲、§5)。
  • 第 7 讲的分块 prefill 把一个长提示切成若干块和 decode 拼在一起,本质上是在控制每一步的 MM。

5. roofline 的完整用法:逐个算子分析 Transformer · Per-operator roofline

5.1 怎样用 roofline

第 1 讲把整次 forward 当成一个整体:总 FLOPs、总字节,取 max。这对估计时间够用,但看不出时间花在哪里、哪些地方能优化。完整的用法是逐个算子(也就是逐个内核)地做:

  1. 把一次 forward 拆成算子,数出每个算子的 FLOPs 和字节(权重、KV、激活的读写都要算);
  2. 给每个算子选对”屋顶”:矩阵乘法用 Tensor Core 的峰值,其他算子用 CUDA core 的峰值;带宽一般用 HBM 的;
  3. 每个算子的时间 ≈ 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矩阵乘法S=QK⊤S = QK^\top,把分数矩阵写回 HBM
softmax逐元素 / 归约读 SS、写 PP
attn_pv矩阵乘法O=PVO = PV
o_proj矩阵乘法输出投影
residual_add逐元素残差连接
mlp_norm逐元素 / 归约RMSNorm
gate_up_proj矩阵乘法SwiGLU 的两个上投影
silu_mul逐元素SiLU(g)⊙u\text{SiLU}(g) \odot u
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_proj11.1%4.76 ms8.0%compute
attn_scores1.9%3.46 ms5.8%memory
softmax0.1%6.41 ms10.7%memory
attn_pv1.9%3.46 ms5.8%memory
gate_up_proj51.8%22.23 ms37.2%compute
silu_mul0.0%2.10 ms3.5%memory
down_proj25.9%11.12 ms18.6%compute
合计29.7 TFLOP59.83 ms100%
  • 矩阵乘法(四个投影 + 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 合成一个内核(读 xx 和残差、写新的 xx 和归一化后的 hh)。
  • 融合 RoPE 和写 KV:算完 Q、K 的旋转后直接写进 KV cache 的分页块。
  • FlashAttention:QK⊤QK^\top、softmax、PVPV 合成一个内核,分数矩阵从不写回 HBM(第 4 讲)。
  • SiLU × mul:本身就是一个内核,读 gate 和 up、写一份结果。
  • 第 17 讲提到的融合采样、附录讲 A 的”反量化 + 矩阵乘法”融合内核,也是同一个思路。

代码 03 演示 3 比较融合前后(内核数从每层 14 个降到 9 个,整次 forward 从 451 个降到 291 个):

场景不融合融合加速
prefill 51212.85 ms11.92 ms1.08×
prefill 2,04859.83 ms47.59 ms1.26×
prefill 8,192391.98 ms208.26 ms1.88×
decode batch 1,上下文 1,0245.66 ms5.65 ms1.00×
decode batch 256,上下文 1,02420.34 ms19.48 ms1.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 µseager,每内核 20 µsCUDA Graph + 融合临界值
Llama-3.2-1B,batch 10.94 ms1.16 ms4.54 ms1.09 ms5.1 µs
Llama-3.2-1B,batch 641.80 ms2.13 ms4.54 ms1.96 ms9.4 µs
Llama-3-8B,batch 15.65 ms6.11 ms9.02 ms5.96 ms13.5 µs
Llama-3-8B,batch 649.07 ms9.74 ms9.74 ms9.37 ms21.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=模型需要的 FLOPs/时间峰值算力,MBU=(权重字节+KV 字节)/时间峰值带宽.\text{MFU} = \frac{\text{模型需要的 FLOPs} / \text{时间}}{\text{峰值算力}}, \qquad \text{MBU} = \frac{(\text{权重字节} + \text{KV 字节}) / \text{时间}}{\text{峰值带宽}}.

两者都只算模型本身必须的运算和搬运(不算激活、不算实现上多出来的读写)。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):

一步时间瓶颈MFUMBU
prefill 2,04843.38 mscompute69.2%11%
prefill 8,192191.09 mscompute69.8%3%
decode batch 1,上下文 1,0246.15 msmemory0.26%73%
decode batch 649.31 msmemory10.8%76%
decode batch 25618.93 msmemory21.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 个符号位 ss、ee 个指数位 EE、mm 个尾数位 FF。正规数(normal number)的值是

x=(−1)s×(1+F2m)×2E−bias,bias=2e−1−1.x = (-1)^s \times \Big(1 + \frac{F}{2^m}\Big) \times 2^{E - \text{bias}}, \qquad \text{bias} = 2^{e-1} - 1.

就像十进制的科学计数法 1.23×1051.23 \times 10^5:尾数给出有效数字,指数给出数量级。开头的”1.”不用存(正规数的最高位总是 1),所以 mm 位尾数实际有 m+1m + 1 位有效数字。指数字段全 0 时是次正规数(subnormal),值 = (F/2m)×21−bias(F / 2^m) \times 2^{1 - \text{bias}},让数可以平滑地逼近 0;指数字段全 1 在 IEEE 格式里留给无穷大和 NaN。

代码 04 演示 2 把几个数存进不同格式。例如 BF16(e=8e = 8,m=7m = 7,bias = 127):

  • 1.0 → 0 01111111 0000000:指数 127 − 127 = 0,尾数 0,值 1×201 \times 2^0。
  • 0.1 → 0 01111011 1001101,存下来是 0.100098:0.1 在二进制里是无限循环小数(就像 1/3 在十进制里),任何二进制浮点格式都存不精确。
  • 70000 → 70144(误差 0.21%)。

指数位管范围,尾数位管精度——这是理解所有格式的钥匙。

7.2 常见格式

代码 04 演示 1:

格式位数指数尾数最大值相对精度十进制有效位数推理里的用途
FP32328233.4×10383.4 \times 10^{38}6×10−86 \times 10^{-8}7.2累加器、归一化、softmax 的内部计算
TF32198103.4×10383.4 \times 10^{38}4.9×10−44.9 \times 10^{-4}3.3Tensor Core 上”FP32 输入”的矩阵乘法(训练里常用)
BF1616873.4×10383.4 \times 10^{38}3.9×10−33.9 \times 10^{-3}2.4权重、激活、KV 的默认格式
FP161651065,5044.9×10−44.9 \times 10^{-4}3.3较早的模型;范围太小(§7.5)
FP8 E4M38434486.2×10−26.2 \times 10^{-2}1.2推理的权重、激活、KV
FP8 E5M285257,3440.1250.9训练里的梯度
FP4 E2M142160.250.6Blackwell 的 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 看相邻两个可表示数的间距(括号里是相对间距):

格式x=0.01x = 0.01x=1x = 1x=100x = 100x=10000x = 10000
BF166.1e-05(0.61%)0.0078(0.78%)0.5(0.50%)64(0.64%)
FP167.6e-06(0.08%)0.00098(0.10%)0.0625(0.06%)8(0.08%)
FP8 E4M30.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 累加

矩阵乘法里最关键的一步是累加:一个输出元素是 KK 个乘积之和,KK 动辄几千。代码 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 累加器2.1×10−62.1 \times 10^{-6}
顺序累加,BF16 累加器2.0×10−22.0 \times 10^{-2}
树形(两两)求和,每一步都舍入到 BF161.8×10−31.8 \times 10^{-3}

输入可以是 BF16,累加必须用 FP32:差了四个数量级。这正是 Tensor Core 的设计——“低精度乘、FP32 累加”。树形求和让每个数只经过 log⁡24096=12\log_2 4096 = 12 次舍入,即使全程 BF16 也好得多;GPU 上的归约(reduction)天然是树形的。

(c) 浮点加法不满足结合律。同样 4,096 个 FP32 数,切成 1、2、4、8、16 段分别求和再相加,得到 5 个不同的结果(差在第 7 位有效数字左右)。数学上 (a+b)+c=a+(b+c)(a + b) + c = a + (b + c),浮点数上不一定,因为每一步都有舍入。这有一个推理系统里很现实的后果:GPU 内核会根据批大小选不同的切分方式(split-K、不同的块数),所以同一个请求和不同的请求拼批时,输出的 logits 可能差一点点,偶尔让贪心解码选出不同的 token。第 17 讲提到的”批不变性”(batch invariance)问题——同样的输入、温度为 0,两次运行结果不一样——根源就在这里。

7.5 溢出:FP16 为什么被 BF16 取代

代码 04 演示 5:RMSNorm 要算 mean(x2)\text{mean}(x^2)。激活里偶尔会有几百的离群值(附录讲 A §2.4 讲它们从哪里来),而 3002=90,000>65,504300^2 = 90{,}000 > 65{,}504:

计算 mean(x2)\text{mean}(x^2) 的格式结果归一化后与精确值的最大差
FP16inf39.6(整行变成 0)
BF16(平方和也用 BF16 累加)56.5(正确值 57.37)0.30
FP3257.372.6×10−52.6 \times 10^{-5}

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 1decode 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。

低精度在推理里有三种收益,要分清:

  1. 字节少:decode 受限于带宽,时间几乎和字节数成正比。FP8 快 1.85 倍(不到 2 倍是因为每步还有 0.5 ms 的固定开销)。
  2. 算力高:prefill 受限于算力,只有 Tensor Core 直接做低精度乘法(FP8 峰值是 BF16 的 2 倍,Blackwell 的 FP4 再翻倍)才有用。
  3. 容量大:同样的显存放下更多的 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 次 x←x⋅cx \leftarrow x \cdot c。不融合:16 遍,每遍从内存读写整个数组。分块融合:把数组切成放得进缓存的小块,在缓存里连做 16 次再处理下一块。

不融合分块融合(每块 4 MB)加速
单线程约 72 ms约 60 ms1.2×
12 个线程约 36 ms约 15 ms2.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 Graphdecode 用 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

  1. GPU 是吞吐优先的:大量简单的运算单元,靠成千上万个线程隐藏几百纳秒的访存延迟(在途的字节 = 带宽 × 延迟)。
  2. 执行层次:线程 → warp(32 个,共用指令流)→ 线程块(共享”共享内存”)→ 网格;硬件上是 132 个 SM。分支发散、循环次数不齐、不合并的访存、tile / wave 量化都会浪费硬件。
  3. 存储层级:寄存器、共享内存、L2、HBM 越来越大、越来越慢。矩阵乘法靠分块复用数据,强度随块大小增长,受片上容量限制,再靠 L2 在 SM 之间共享补足;逐元素运算没有复用,无论怎么写都受限于带宽,只能靠融合减少读写。
  4. 投影层的强度 ≈ 批里的 token 数:prefill compute-bound,decode memory-bound,要三百多个 token 一起算才越过 H100 的屋脊点。
  5. 逐算子的 roofline 显示:prefill 时矩阵乘法占 96% 的 FLOPs 却只占 70% 的时间,不融合的注意力和逐元素算子占了其余;decode 时一切受限于带宽,批大了以后读 KV 成为主要开销。
  6. 三种瓶颈:compute(用 Tensor Core、低精度计算)、memory(批处理、融合、低精度存储)、overhead(CUDA Graph、减少内核数、CPU / GPU 重叠)。prefill 看 MFU,decode 看 MBU。
  7. 数值格式:指数位管范围,尾数位管精度。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 投影(K=4096K = 4096,N=28672N = 28672)在 A100 上大约需要多少个 token 一起算才越过屋脊点?和 H100 比,为什么更少?

参考答案

(a) 312/2.039≈153312 / 2.039 \approx 153 FLOP/byte。(b) 强度 ≈ MM(§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 比,说明了什么?

参考答案

4.8×1012×6×10−7=2.884.8 \times 10^{12} \times 6 \times 10^{-7} = 2.88 MB;÷ 128 = 22,500 条。比驻留的 warp 数还多,说明每个 warp 必须同时发出多条访存(不等前一条回来就发下一条),或者用 TMA 这类异步搬运单元一次搬一大块。带宽越高,隐藏延迟需要的”在途工作”越多——这也是 Hopper 加入 TMA、异步拷贝的原因之一。

练习 3(分块的强度)。BF16 的矩阵乘法用 64 × 64 的方块分块。(a) 强度约是多少?(b) 要在 A100 上(屋脊点 153)只靠一个块的复用达到屋脊点,方块边长要多大?累加器(FP32)要多少字节?A100 每个 SM 的寄存器是 256 KB,放得下吗?

参考答案

(a) 2⋅64⋅64/(128⋅2)=322 \cdot 64 \cdot 64 / (128 \cdot 2) = 32 FLOP/byte。(b) 强度 ≈ b/2b/2,要 b≈306b \approx 306;累加器 3062×4≈375306^2 \times 4 \approx 375 KB,放不下。和 §4.3 的结论一样:单个 SM 的片上存储不够,要靠 L2 在 SM 之间共享数据。

练习 4(融合省多少读写)。不融合时,“残差相加 + RMSNorm”是两个内核:加法读 2 个长度 dd 的向量、写 1 个;RMSNorm 读 1 个、写 1 个。融合成 fused add + RMSNorm 后,读 2 个、写 2 个(新的残差流和归一化后的结果)。每个 token 每层省了百分之几的激活读写?为什么这对 decode 的总时间影响很小?

参考答案

不融合 3+2=53 + 2 = 5 个向量的读写,融合后 4 个,省 20%。但 decode 时激活只有每个请求一个 token 那么大(d=4096d = 4096 个数,8 KB),而每一层要读几百 MB 的权重,激活的读写不到千分之一;所以融合在 decode 里的主要价值是减少内核数,而不是字节(§6.1)。

练习 5(小模型的启动开销)。一个结构类似 Qwen2.5-0.5B 的模型(24 层,d=896d = 896,14 个 query 头、2 个 KV 头,dff=4864d_{ff} = 4864,词表 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:28=2562^8 = 256;257 就不能精确表示了。FP16:211=20482^{11} = 2048。(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 要降到多少?

参考答案

每块卡每步读 70.6/2=35.370.6 / 2 = 35.3 GB,35.3 GB/20 ms=1.7635.3\ \text{GB} / 20\ \text{ms} = 1.76 TB/s,MBU ≈ 1.76 / 3.35 ≈ 53%。MBU 80% 对应 35.3 GB/(0.8×3.35 TB/s)≈13.235.3\ \text{GB} / (0.8 \times 3.35\ \text{TB/s}) \approx 13.2 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 以外的部分——采样方法和分词。

← 返回课程目录