Lecture 1 · 导论:为什么 AI 推理是一个系统问题 · Introduction: Why AI Inference Is a Systems Problem

AI Inference Infrastructure · Lecture 01。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓

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

本篇目录

对应 Maps to:Stanford CS349D AI Inference Infrastructure(Spring 2026,Christos Kozyrakis),#1 Mon Mar 30 Introduction。必读:S. Keshav, How to Read a Paper(ACM SIGCOMM CCR, 2007);选读:X. Miao et al., Towards Efficient Generative Large Language Model Serving: A Survey from Algorithms to Systems(ACM Computing Surveys, 2025)、A. Gujarati et al., Serving DNNs like Clockwork: Performance Predictability from the Bottom Up(OSDI 2020)。 依据说明:CS349D 是一门研讨课(seminar),每次课由学生带领讨论指定的论文。课程网站只公开了日程、阅读清单和课程项目(mini serving engine)的说明,没有 slides 或录像。本讲依据这三篇阅读材料和课程网站的项目说明来写。§2–§4 的”推理账本”是我根据公开的模型配置(Meta Llama 3)和 GPU 规格(NVIDIA 数据手册)推算的,§9”现状”是我补充的。 配套代码 Code:code/lecture01/(numpy + 标准库;4 个脚本 + 4 个共享模块;最慢的 03_metrics_and_batching_tradeoff.py 约 10 秒) 预计阅读时间:3–4 小时(含运行代码)


0. 本讲导航 · How to read this lecture

0.1 学习目标 Learning goals

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

  1. 描述一次 LLM 请求从发出到结束经历了什么(tokenize → 排队 → prefill → decode 循环 → 流式返回),并说出推理和训练在系统层面的主要不同。
  2. 解释三件事:自回归生成为什么必须一步一步做;KV cache 省掉了什么、又花掉了什么;prefill 和 decode 为什么是两种”性格”完全不同的计算。
  3. 用 roofline(屋顶线)模型解释三个现象:batch = 1 的 decode 只用到 GPU 算力的不到 1%;批处理是提升吞吐的第一杠杆;显存里的 KV cache 是批大小的天花板。能手算一个模型的权重大小、每 token FLOPs 和每 token KV cache 大小。
  4. 定义并区分 TTFT、TPOT / ITL、E2E、吞吐、goodput、SLO、尾延迟和每 token 成本;解释延迟与吞吐的权衡,以及为什么”吞吐高 ≠ 服务好”。
  5. 复述 Clockwork 的核心论点”可预测性要自底向上地保持”;说明 LLM 为什么打破了它的前提,以及 LLM 推理系统因此走向了哪些设计。
  6. 画出推理优化的层次地图(内核 / 引擎 / 多 GPU / 集群 / 应用,外加模型与系统的协同设计),并把本讲义的每一讲放到图上的位置。
  7. 用 Keshav 的三遍阅读法和本讲义的”论文卡片”读一篇系统论文。

0.2 先修与本讲范围 Prerequisites & scope

原课默认你学过操作系统、计算机体系结构和分布式系统。本讲义为没有这些背景的读者另写了 5 篇前置讲(P1–P5,见 SYLLABUS)。本讲只用到一个硬件概念:GPU ≈ “一台计算器 + 一根水管”,§3 会从零讲起。

默认你已经会 Assumed本讲只给直觉,后面再展开 Previewed only
Python 基础(numpy 不熟也没关系,代码里有解释)GPU 体系结构:SM、Tensor Core、HBM 与片上 SRAM、数值格式(前置讲 P1)
Transformer 大概是什么:注意力、MLP、层、tokenTransformer 推理账本的逐项推导(前置讲 P2)
知道”训练”和”推理”的区别调度、虚拟内存与分页、缓存淘汰(P3);排队论与评测方法(P4);分布式通信(P5)
不需要任何计算机系统背景每一种优化技术本身(第 2–17 讲)

0.3 本讲地图 Map

§1  从一次请求说起:请求的生命周期;推理和训练的区别;为什么推理越来越重要
§2  自回归生成:一次只能生成一个 token;KV cache;prefill 与 decode;KV cache 有多大   ← 代码 01、02
§3  GPU 视角:计算器与水管;算术强度与 roofline;批处理;显存是天花板;在你的电脑上   ← 代码 02
§4  怎样衡量一个推理系统:TTFT/TPOT/E2E;吞吐与成本;尾延迟与 SLO;goodput;排队实验 ← 代码 03
§5  LLM 之前的模型服务:Clockwork 与可预测性;LLM 打破了什么                        ← 代码 04
§6  全景图:两条主线、五个层次、survey 的分类、本讲义路线图、"不改变输出"的不变量
§7  怎样读一篇系统论文:三遍阅读法、论文卡片、示范
§8  课程项目:迷你推理引擎
§9  现状(2024–2026)
§10–14  小结、常见误区、练习、延伸阅读、下一讲预告

0.4 记号 Notation(全课统一)

符号含义例子
NN模型参数量Llama-3-8B:N≈8.03×109N \approx 8.03 \times 10^9
LpL_p提示长度(prefill 要处理的 token 数)512
LoL_o输出长度(要生成的 token 数,含第一个)256
BB批大小:同一次 forward 里有几个序列64
dd、nlayersn_\text{layers}、nheadsn_\text{heads}、nkvn_\text{kv}、dheadd_\text{head}隐藏维度、层数、query 头数、KV 头数、每头维度Llama-3-8B:4096、32、32、8、128
π\piGPU 的峰值算力(FLOP/s)H100 BF16 稠密:989×1012989 \times 10^{12}
β\beta显存带宽(byte/s)H100:3.35×10123.35 \times 10^{12}
II算术强度 = FLOPs ÷ 搬运的字节数(FLOP/byte)batch = 1 的 decode:约 1
TTFT / TPOT / ITL / E2E首 token 延迟 / 每个输出 token 的平均时间 / 相邻两个 token 的间隔 / 端到端延迟§4.1
SLO服务等级目标(service-level objective)TTFT ≤ 1 s 且 TPOT ≤ 50 ms

单位:FLOP 是”一次浮点运算”(乘法和加法各算一次),FLOPs 是”多少次运算”,FLOP/s 是”每秒多少次”。字节用十进制(1 GB = 10910^9 字节,和 GPU 厂商的标法一致);需要 1024 进制时写 KiB / MiB / GiB。批大小 BB 和”字节”不会混用:字节一律写成汉字或 byte。

0.5 关于这门课 · About CS349D

CS349D 是 Stanford 2026 年春季学期开设的研讨课,授课教师是 Christos Kozyrakis(计算机体系结构与系统方向),助教是 Zhiqiang Xie 和 Swapnil Gandhi。课程的动机写得很直接:大语言模型和智能体(agentic AI)的快速普及,让”高效地推理”成了关键挑战。课程内容是大规模 AI 推理背后的基础设施:要把 LLM 高效、低成本地部署在生产环境里,需要哪些技术、架构和工程决策。

课程形式:

  • 每次课两篇必读论文。课前读完,在 Gradescope 上交一份简短总结;每次课由两位同学带领讨论,另两位记笔记。有一次客座讲座:SGLang 的作者之一、RadixArk 创始人 Ying Sheng。
  • 课程项目:迷你推理引擎(mini serving engine)。分四个里程碑:(1) 数据并行与张量并行;(2) 连续批处理与 PagedAttention;(3) 上下文缓存与分块 prefill;(4) 一个进阶功能,可以从投机解码、分层上下文缓存、prefill/decode 解耦中选,也可以自己提。(§8)
  • 评分(暂定):项目 50%,带领讨论 25%,课堂参与 25%。

原课一共 19 次课:16 个技术主题、2 次项目讨论会和 1 次项目展示。本讲义把它们重新排成 17 讲 + 5 篇前置讲。主要的调整是把”并行”从第 2 次课挪到单卡引擎之后:原课先讲并行,是因为项目的第一个里程碑就是数据 / 张量并行;但对没有系统背景的读者来说,先把一块 GPU 上的引擎讲透(批处理、显存管理、注意力内核、投机解码),再扩展到多块 GPU,会顺得多。逐场对应见 SYLLABUS 第 0.1 节。

0.6 关于数字 · About the numbers

本讲有两类数字,可信程度不同:

  1. “纸上 GPU”算出来的数字(代码 02、03、04 的模拟部分):由公开规格(H100 的算力、带宽、显存)、公开模型配置,加上三个经验系数(能达到峰值算力的 70%、标称带宽的 80%、每次 forward 0.5 ms 的固定开销)计算得到。它们数量级和趋势可信,但不是任何真实推理引擎的实测值。每次运行完全相同。
  2. 在 CPU 上实测的数字(代码 01 的墙钟时间、代码 02 演示 6、代码 04 演示 1):来自编写本讲时的 Apple M4 Pro 笔记本。你的机器会给出不同的数字,只看趋势。

论文里的数字一律写成”论文报告”,表示我没有复现。


1. 从一次请求说起 · Anatomy of an LLM request

1.1 你按下回车之后

你在聊天界面里输入一个问题,按下回车。接下来大约一秒之内,屏幕上开始一个字一个字地出现回答。这一秒里发生了什么?

 用户 ──HTTP──▶ API 服务器 ──▶ tokenizer ──▶ 请求队列 ──▶ 调度器(scheduler)
                                                           │ 每一步决定:这一步让哪些请求上 GPU
                                                           ▼
                                                  GPU 上的模型执行器(model executor)
                                                   ① prefill:一次读完整个提示,产出第 1 个 token
                                                   ② decode:每步产出 1 个 token,循环 L_o − 1 次
                                                           │   (KV cache 管理器:给每个请求的 KV cache 分配显存)
                                                           ▼
 用户 ◀──流式返回(逐 token)── detokenizer ◀── 采样器(sampler:贪心 / 温度 / top-p)

逐个看这些部件:

  • API 服务器:接收 HTTP 请求。现在大多数推理服务都提供”OpenAI 兼容”的接口,所以同一个客户端可以换着连不同的后端。
  • Tokenizer(分词器):把文字切成 token、映射成整数 id。英文平均每个 token 约 3–4 个字符;Llama 3 的词表有 128256 个 token。从这一步开始,系统里流动的就是整数序列了。
  • 请求队列与调度器:GPU 一次只能做一件事(一次 forward),但同时有很多请求在等。调度器决定每一步让哪些请求上 GPU、各处理多少 token。它是推理引擎的大脑,第 2 讲整讲都在讲它。
  • 模型执行器:在 GPU 上跑模型的 forward。对一个请求来说,第一次 forward 处理整个提示(prefill),之后每次 forward 只处理一个新 token(decode)。§2 详细讲。
  • KV cache 管理器:每个请求在生成过程中都要在显存里保存一份不断增长的”KV cache”(§2.2)。显存有限,谁能放进来、放在哪、放不下怎么办,由它决定(第 3 讲)。
  • 采样器:模型每一步输出的是词表上的一个概率分布(logits),采样器按贪心、温度、top-p 等规则从中选出一个 token。
  • Detokenizer 与流式返回:把 token 变回文字,生成一个就推送一个给用户。这就是为什么你看到的回答是”一个字一个字冒出来”的。

一个贯穿全课的比喻:把 GPU 想成一家餐厅后厨里唯一的一位厨师。他手艺极快(算力),但调料都放在仓库里,每做一步都要去仓库搬(显存带宽)。每位客人点的菜要做很多”步”(生成很多 token),而且每位客人还要在厨房里占一块备菜台(KV cache),台面总共就那么大(显存容量)。领班(调度器)要决定:厨师每一趟从仓库回来,同时做哪几位客人的下一步?——这门课的大部分内容,都是在回答”怎样让这位厨师少跑空趟、让台面装下更多客人”。

1.2 推理和训练有什么不同

你可能会想:推理不就是训练里的 forward 吗?训练都做得了,推理有什么难的?下面这张表是本讲最重要的表之一:

维度训练 Training推理 Inference
首要目标吞吐与收敛:每天处理多少 token、多快训好延迟(用户在等)与吞吐(决定成本),通常带 SLO
工作形态一个巨大的离线任务,数据事先准备好数百万个独立的小请求,在线、随机到达
形状固定:把样本打包成统一的 batch × 序列长度可变:每个请求的提示长度、输出长度都不同,输出长度事先不知道
计算forward + backward + 优化器更新,几乎全是矩阵乘矩阵只有 forward。prefill 是矩阵乘矩阵;decode 是向量乘矩阵,受限于带宽
顺序一个序列的所有位置可以并行计算(teacher forcing)输出 token 必须一个接一个地生成(自回归)
显存里的状态权重 + 梯度 + 优化器状态 + 激活,大小固定、可预先规划权重 + 每个请求的 KV cache,随请求到来、生成变长、请求结束而动态变化
出错的后果从 checkpoint 恢复,损失几小时用户直接感受到超时、卡顿
生命周期训练一次(几周到几个月)一直在线;只要有人用就一直在花钱

最后一行带出一个经济事实:训练是一次性成本,推理是持续成本。一个模型训练好以后可能要服务成千上万的用户、运行一年以上;用户越多,推理的总算力和总成本就越大。

1.3 为什么推理越来越重要

三股力量把推理推到了系统研究的中心:

  1. 使用量。每一次对话、每一次代码补全、每一次搜索摘要都是一次推理。推理成本与使用量成正比,而训练成本不随使用量增长。
  2. 每个任务消耗的 token 越来越多。推理模型(reasoning models,如 OpenAI o1、DeepSeek-R1)在回答之前先”思考”成百上千个 token;智能体(agent)完成一个任务要调用模型几十次,每次都带着越来越长的上下文;检索增强(RAG)和代码库问答把几万 token 的文档塞进提示。生成越长,decode 越多;上下文越长,KV cache 越大。这两点正好是 §2–§3 要讲的两个瓶颈。
  3. 效率直接等于价格和体验。Stanford 的 AI Index 2025 报告:达到 GPT-3.5 水平的模型,推理价格从 2022 年 11 月到 2024 年 10 月下降了 280 多倍。这里面有硬件的进步,有”更小的模型达到同样水平”的进步,也有本课要讲的系统优化的贡献。代码 03 演示 4 用一个最直接的例子说明了系统优化的分量:同一块 GPU、同一个模型,只改变”怎样安排请求”,每百万输出 token 的成本就从 5.14 美元降到 0.07 美元(理想上限),相差 70 多倍(§4.2)。

2. 自回归生成:一切问题的根源 · Autoregressive decoding

2.1 一次只能生成一个 token

语言模型把一段文字的概率分解成”下一个 token”的条件概率连乘:

p(x1,…,xT)=∏t=1Tp(xt∣x1,…,xt−1)p(x_1, \dots, x_T) = \prod_{t=1}^{T} p(x_t \mid x_1, \dots, x_{t-1})

生成时就按这个顺序来:给定提示 x1:Lpx_{1:L_p},算出 p(xLp+1∣x1:Lp)p(x_{L_p+1} \mid x_{1:L_p}),从中选出一个 token;把它接到末尾,再算下一个……直到生成结束符或达到长度上限。

关键在于:第 t+1t+1 个 token 依赖第 tt 个 token 的取值,而第 tt 个 token 要等这一步算完、采样完才知道。所以生成 LoL_o 个 token,就必须依次做 LoL_o 次完整的 forward(每次都要穿过所有层),不能并行。这和训练完全不同:训练时整句话都是已知的,所有位置可以在一次 forward 里同时算(teacher forcing)。

代码 01 演示 1 一步一步展示了这个过程(迷你模型,提示 11 个 token,生成 8 个):

步阶段这一步处理之后 cache 长度FLOPs
1prefill11 个 token112.2 M
2decode1 个 token12212.5 K
3decode1 个 token13213.0 K
……………
8decode1 个 token18215.6 K

第 1 步(prefill)一口气处理了整个提示,并且已经产出了第 1 个输出 token;之后每一步只处理 1 个 token。生成 8 个 token 只需要 1 次 prefill + 7 次 decode:第 8 个 token 生成出来就结束了,不需要再为它做一次 forward。

2.2 KV cache:用内存换计算

回忆注意力机制:位置 tt 的 token 要用它的 query 向量 qtq_t 和之前所有位置的 key 向量 k1,…,ktk_1, \dots, k_t 做点积,得到注意力权重,再对 value 向量 v1,…,vtv_1, \dots, v_t 加权求和:

attn(qt)=∑j≤tsoftmaxj ⁣(qt⋅kjdhead)vj\text{attn}(q_t) = \sum_{j \le t} \text{softmax}_j\!\left(\frac{q_t \cdot k_j}{\sqrt{d_\text{head}}}\right) v_j

生成第 t+1t+1 个 token 时,最朴素的做法是把 x1:tx_{1:t} 整个重新过一遍模型。但注意一个事实:

在因果(causal)注意力里,前面的 token 看不到后面的 token。所以位置 jj 在每一层算出的 kjk_j、vjv_j 只依赖 x1:jx_{1:j},一旦算出来就永远不会变。

既然不会变,就把它们存下来:每一层、每个已处理的 token 各存一份 key 和 value。这就是 KV cache。有了它,每个 decode 步只需要为新来的那 1 个 token 算 q、k、v,把新的 k、v 追加进 cache,再用新 token 的 q 和 cache 里全部的 k、v 做注意力。

这个性质依赖因果掩码。BERT 那样的双向模型里,前面的 token 能看到后面的 token,新来一个 token 会改变所有旧 token 的表示,KV cache 就不成立了。decoder-only 架构在推理上的这个便利,是它成为 LLM 主流架构的原因之一。

省了多少? 不用 cache 时,第 kk 步要处理 Lp+k−1L_p + k - 1 个 token,总量

∑k=1Lo(Lp+k−1)=LoLp+Lo(Lo−1)2\sum_{k=1}^{L_o} (L_p + k - 1) = L_o L_p + \frac{L_o (L_o - 1)}{2}

随输出长度平方增长;用 cache 时,每个 token 恰好被处理一次,总量是 Lp+Lo−1L_p + L_o - 1。代码 01 演示 2(提示 32 个 token,生成 200 个):

用 KV cache每步重算倍数
处理的 token 总数23126,300113.9×
总 FLOPs61.2 M6.3 G103.6×
读权重字节82.59 MB89.27 MB1.1×
墙钟时间(M4 Pro 实测)约 17 ms约 130 ms约 8×

两种方式生成的 200 个 token 完全相同:KV cache 不改变数学,只是把算过的东西存起来。表里还有两个值得停下来想的细节:

  • “读权重字节”几乎一样。两种方式都做了 200 次 forward,每次都要把全部权重读一遍,与这一次处理多少 token 无关(差的那一点是重算时多查的几行词嵌入表)。也就是说,decode 时”搬权重”的成本是固定的,算多算少都要搬。这个事实是 §3 的出发点。
  • 墙钟时间的倍数(约 8×)比 FLOPs 的倍数(约 100×)小得多。这个迷你模型太小了,每次 forward 的时间主要花在 Python 和 numpy 的固定开销上(函数调用、分配数组),而不是运算本身。这是性能分析里的第三种瓶颈:overhead-bound(受限于固定开销)。真实的推理引擎会花大力气消除这类开销,例如用 CUDA Graphs 把一整次 forward 录成一次启动、把 CPU 端的调度和 GPU 计算重叠起来(第 16 讲)。

KV cache 的代价是显存。它是推理系统里最重要、最紧缺的资源,§2.4 和 §3.5 会算一算它有多大。

2.3 两个阶段:prefill 与 decode

有了 KV cache,一个请求的生成就清楚地分成两个阶段:

  • Prefill(预填充):把整个提示(LpL_p 个 token)一次性送进 forward。提示的 token 都是已知的,所以可以并行处理,就像训练一样。这一步填满了这个请求的 KV cache,并产出第 1 个输出 token。用户看到第一个字之前要等的时间(TTFT)≈ 排队时间 + 一次 prefill。
  • Decode(解码):之后每一步只送入上一步刚生成的 1 个 token,产出下一个 token,重复 Lo−1L_o - 1 次。用户看到的”出字速度”(TPOT)≈ 一次 decode 步的时间。

在我们的实现(tiny_llm.py)里,prefill 和 decode 调用的是同一个函数 forward(token_ids, cache),区别只在于 token_ids 的长度和 cache 里已有多少 token。可它们的”性格”截然不同。原因在矩阵的形状上:

  • prefill 时,每个线性层是 (Lp×d)⋅(d×d′)(L_p \times d) \cdot (d \times d'):矩阵乘矩阵,同一个权重矩阵被 LpL_p 行输入复用。
  • decode 时,每个线性层是 (1×d)⋅(d×d′)(1 \times d) \cdot (d \times d'):向量乘矩阵,权重矩阵被完整读一遍,却只用了一次。

代码 01 演示 3 在迷你模型里数出了每次 forward 的运算量和搬运量,算出算术强度(每搬 1 个字节做几次运算):

这次 forward新 token 数算术强度(FLOP/byte)
decode(上下文 100)10.50
prefill(提示 4)41.95
prefill(提示 16)167.59
prefill(提示 64)6428.14
prefill(提示 224)22482.74

decode 每搬 1 个字节只做 0.5 次运算(迷你模型用 FP32,每个数 4 字节,每个权重参与 1 次乘加 = 2 次运算);prefill 的强度随提示长度近似线性增长。§3 会把这个数字和 GPU 的能力比较,你会看到这一个数字决定了 prefill 和 decode 的全部性格。

预告:prefill 和 decode 在同一块 GPU 上混着跑会互相干扰。一个长 prefill 会让正在 decode 的其他请求”卡”一下,正在 decode 的请求又拖慢 prefill。第 2 讲(Orca)把两者放进同一批;第 7 讲(Sarathi-Serve、DistServe)一边把 prefill 切成小块,一边干脆把两者放到不同的 GPU 上。这些都是从这里的”两种性格”出发的。

2.4 KV cache 有多大

每个 token 的 KV cache 大小只取决于模型结构:

KV 字节 / token=2×nlayers×nkv×dhead×每个数的字节数\text{KV 字节 / token} = 2 \times n_\text{layers} \times n_\text{kv} \times d_\text{head} \times \text{每个数的字节数}

(2 是因为 K 和 V 各存一份。)Llama-3-8B:2×32×8×128×2=1310722 \times 32 \times 8 \times 128 \times 2 = 131072 字节 = 128 KiB / token。Llama-3-70B:2×80×8×128×22 \times 80 \times 8 \times 128 \times 2 = 320 KiB / token。代码 02 演示 2:

上下文长度8B 的 KV70B 的 KV8B:一块 H100 能同时放几个这样的请求
1,024128 MiB320 MiB416
8,1921 GiB2.5 GiB52
32,7684 GiB10 GiB13
131,07216 GiB40 GiB3

一个 128K 上下文的请求,光 KV cache 就和 8B 模型的全部权重(16 GB)一样大。

这里有两个设计值得注意:

  • GQA(grouped-query attention):Llama-3-70B 有 64 个 query 头,却只有 8 个 KV 头,每 8 个 query 头共用一组 K、V。如果用普通的多头注意力(每个 query 头一组 KV),KV cache 会是现在的 8 倍。GQA、MQA、DeepSeek 的 MLA,以及第 10 讲的稀疏注意力和混合架构,很大程度上都是为了把这个数字压下去而设计的:模型结构在迁就推理系统。
  • “预留”的浪费:早期系统事先不知道一个请求会生成多长,就按最大长度给每个请求预留一整块连续显存。代码 01 演示 4:迷你模型最大长度 256,一个”提示 20 + 输出 10”的短请求只用到预留空间的 12%。真实系统里同样的浪费意味着能同时服务的请求少了好几倍。第 3 讲的 PagedAttention 借用操作系统的”分页”思想解决它。

3. GPU 视角:为什么 decode 这么”浪费” · The roofline view

3.1 一台计算器和一根水管

现在需要一点硬件知识,但只需要两个数字。一块 GPU 可以粗略地看成:

  • 一台巨大的计算器:H100 的 Tensor Core 每秒能做约 π=989×1012\pi = 989 \times 10^{12} 次 BF16 运算(989 TFLOP/s)。
  • 一个仓库和一根水管:模型权重和 KV cache 放在显存(HBM,High Bandwidth Memory,H100 有 80 GB)里。要计算,数据必须先从显存搬到芯片上的计算单元旁边。这根水管每秒能搬 β=3.35×1012\beta = 3.35 \times 10^{12} 字节(3.35 TB/s)。

两个数字一比就知道问题在哪:算力”大”了带宽 295 倍。也就是说,从显存搬来的每一个字节,平均要被用来做 295 次运算,计算器才不会闲着。代码 02 演示 1:

GPUBF16 算力带宽显存屋脊点 π/β\pi/\beta(FLOP/byte)
A100-80GB312 TFLOP/s2.04 TB/s80 GB153
H100-SXM989 TFLOP/s3.35 TB/s80 GB295
H200-SXM989 TFLOP/s4.80 TB/s141 GB206

注意 H200 相对 H100 的升级:算力完全没变,带宽高了 43%,显存大了 76%。这一代的升级几乎全给了”水管”和”仓库”,因为大模型推理最缺的正是它们。(GPU 内部的 SM、Tensor Core、片上 SRAM、各种数值格式,前置讲 P1 会细讲。)

3.2 算术强度与 roofline 模型

一次计算要做 FF 次运算、从显存搬 MM 个字节,它的算术强度是 I=F/MI = F / M。计算器和水管可以同时工作(一边搬一边算),所以这次计算至少需要

t≈max⁡ ⁣(Fπ, Mβ)t \approx \max\!\left(\frac{F}{\pi},\ \frac{M}{\beta}\right)

哪一项更大,就说它是 compute-bound(受限于算力)还是 memory-bound(受限于带宽)。换成”能达到的运算速度”来说,就是 min⁡(π, β⋅I)\min(\pi,\ \beta \cdot I)。把它画出来,形状像一个屋顶,所以叫 roofline 模型(Williams、Waterman、Patterson,2009):

 达到的 FLOP/s
     π ┤                    ┌──────────────────────── 平的屋顶:compute-bound(算力用满了)
       │                  ╱
       │                ╱
       │              ╱  ← 斜的屋顶:memory-bound,斜率 = 带宽 β
       │            ╱       (强度每翻一倍,能达到的 FLOP/s 也翻一倍)
       │          ╱
       │        ╱
       └──┬───────────┬─────────────────────────▶ 算术强度 I(FLOP/byte)
        decode        屋脊点 π/β ≈ 295            长 prefill
        I ≈ 1                                    I ≈ L_p

屋脊点左边,计算器在等数据;右边,水管在等计算器。我们的 hw_model.py 就是这个公式,外加三个打折系数:实际能达到峰值算力的 70%、标称带宽的 80%,每次 forward 再加 0.5 ms 固定开销(§0.6)。

3.3 prefill 受限于算力,decode 受限于带宽

用 §2.3 的形状分析,把一个 NN 参数的模型代进去(BF16,每个参数 2 字节):

  • batch = 1 的 decode:每个参数做 1 次乘加,F≈2NF \approx 2N;每个参数被读一次,M≈2NM \approx 2N 字节。所以 I≈1I \approx 1,比屋脊点小了约 300 倍,计算器 99% 以上的时间在等数据。时间 ≈ 读一遍权重的时间:8B 模型 16 GB ÷ 3.35 TB/s ≈ 4.8 ms,这是每生成一个 token 的物理下限。
  • LpL_p 个 token 的 prefill:F≈2NLpF \approx 2N L_p,权重仍然只读一次,M≈2NM \approx 2N,所以 I≈LpI \approx L_p。提示足够长(在 H100 上约 300 个 token 以上),prefill 就变成 compute-bound,和训练时的状态差不多。

代码 02 演示 3、4 用纸上的 H100 和 Llama-3-8B 算出来:

一次 forward时间强度瓶颈MFU
decode,batch = 16.15 ms1memory0.3%
prefill,提示 2566.11 ms239memory59%
prefill,提示 51210.92 ms479compute67%
prefill,提示 8,192191.09 ms8,170compute70%
prefill,提示 32,7681.07 s37,751compute70%

MFU(model FLOPs utilization)= 实际达到的 FLOP/s ÷ 峰值 FLOP/s。batch = 1 的 decode,MFU 只有 0.3%:一块几万美元的 GPU,99.7% 的算力在空转。

还有三个细节:

  • 提示从 1 个 token 到 256 个 token,prefill 的时间几乎不变(约 6.1 ms):都是”读一遍权重”的时间,多处理的 token 是”免费”的。考虑我们的打折系数,交叉点在约 260 个 token。
  • compute-bound 之后,时间随提示长度线性增长:8K 的提示约 0.2 秒,32K 的提示约 1 秒。长上下文请求的 TTFT 就是这么来的。
  • 32K 时,平均每 token 的时间从约 21 µs 涨到约 33 µs:注意力的运算量和上下文长度的平方成正比,开始占主导。这是第 4 讲(FlashAttention)和第 10 讲(稀疏注意力)的问题。

3.4 批处理:让一次搬运服务更多请求

既然 decode 的时间几乎全花在”把权重搬一遍”上,最自然的办法就是:搬一遍,同时为很多个请求各算一个 token。把 BB 个请求放进同一次 forward,线性层变成 (B×d)⋅(d×d′)(B \times d) \cdot (d \times d'),权重的读取被 BB 个请求分摊。代码 02 演示 4(Llama-3-8B @ H100,每个序列上下文 1024):

批大小 BB每步时间(≈ 每个请求的 TPOT)吞吐 tok/s强度MFUKV 读占搬运量
16.15 ms16310.3%1%
86.50 ms1,23081.9%7%
649.31 ms6,8754210.8%36%
12812.52 ms10,2266216.1%53%
25618.93 ms13,5208121.3%70%

批大小从 1 到 64,每步时间只从 6 ms 涨到 9 ms,吞吐却涨了 42 倍。这就是为什么批处理是推理系统的第一杠杆,为什么第 2 讲(批处理与调度)是整门课的地基。

但表的最后一列揭示了一个限制:权重可以被分摊,KV cache 不能。每个请求的 KV cache 只被它自己的 query 使用,批里有 BB 个请求,就要读 BB 份 KV。随着 BB 增大,读 KV 的时间开始占主导(B=256B = 256 时占搬运量的 70%),强度增长变慢,decode 始终是 memory-bound,MFU 在这张表里最高只到 21%。这就是为什么后面会有那么多技术专门针对”读 KV”这件事:更高效的注意力内核(第 4 讲)、更小的 KV(GQA / MLA、量化、第 10 讲的新架构)、甚至把 decode 和 prefill 分开放(第 7 讲)。

第 5 讲的伏笔:decode 时算力大量闲置,那能不能用这些闲置的算力”多猜几个 token、一次验证”?这就是投机解码(speculative decoding)。它用多余的运算换更少的 forward 步数,正好对 memory-bound 的 decode 有效。

3.5 显存才是批大小的天花板

既然批越大吞吐越高,为什么不把批开到 1000?因为每个请求的 KV cache 都要放在显存里。一块 80 GB 的 H100,按 90% 的利用率算(vLLM 的默认值),放完 16 GB 权重还剩约 56 GB,能放约 42.7 万个 token 的 KV(代码 02 演示 2)。上下文 1024 时最多约 416 个请求;在上面的表里再往前一步,B=512B = 512 就放不下了。

上下文变长,事情会更糟。代码 02 演示 5:

上下文长度B=32B = 32 每步时间KV 读占比显存允许的最大批最大批时的吞吐 tok/s
5126.90 ms13%83130,832
2,0489.31 ms36%2087,719
8,19218.92 ms70%521,930
32,76857.39 ms90%13483

长上下文同时降低了两件事:每一步的速度(要读的 KV 多了)和能并行的请求数(显存装不下几个)。上下文从 512 到 32K,单卡的最大吞吐降了约 64 倍。

把 §3.4 和 §3.5 合起来,得到本讲最重要的结论之一:

在 decode 阶段,能同时放进显存的请求越多,吞吐就越高。所以”省 KV cache 显存”直接等于”提高吞吐”。

第 3 讲(分页,消除碎片和预留浪费)、第 3 讲后半(前缀共享,多个请求共用同一段 KV)、第 9 讲(把 KV 放到 CPU 内存和 SSD 上)、第 10 讲(让 KV 变小的新架构)都是这条主线上的工作。

3.6 在你自己的电脑上看到同样的现象

roofline 不是 GPU 独有的,CPU 上是同一个故事。代码 02 演示 6 在笔记本上实测:一个 4096 × 4096 的 FP32 矩阵(67 MB,比 CPU 缓存大得多),乘以一个 TT 行的矩阵。编写本讲时在 Apple M4 Pro 上的一次运行:

TT(行数)耗时GFLOP/s平均每行时间
1385 µs87385 µs
81.46 ms184183 µs
321.47 ms73246 µs
2563.66 ms2,34814 µs
102412.56 ms2,73512 µs

再把 32 个”请求”各算各的(32 次向量乘矩阵,矩阵读 32 遍)和拼成一批(1 次矩阵乘矩阵,矩阵读 1 遍)比较:11.5 ms vs 1.5 ms,快 7.5 倍。这就是批处理在你电脑上的样子。

你可能会注意到 T=8T = 8 到 T=32T = 32 时耗时几乎不变(约 1.5 ms),运算量却翻了 4 倍:这正是 memory-bound 的典型特征,多算的那些运算是”免费”的。另一个现象:在这台机器上,T=2T = 2–44 反而比 T=1T = 1 慢(约 2.8 ms,表里没列)。原因是数学库对 T=1T = 1 有专门的”向量乘矩阵”(GEMV)代码路径,T=2T = 2–44 走的通用矩阵乘法(GEMM)路径没为这么窄的形状优化。这不是 roofline 错了,而是”内核没为这种形状优化”。GPU 推理引擎同样要为 decode 的小批量专门写内核(第 4 讲)。

至此我们见到了性能分析的三种瓶颈(这个说法来自 Horace He 的博客 Making Deep Learning Go Brrrr From First Principles):

瓶颈什么时候出现本讲的例子解法方向
compute-bound强度高于屋脊点长 prefill更快的硬件、更少的运算(稀疏、低精度)
memory-bound强度低于屋脊点decode、短 prefill批处理、更少的字节(量化、更小的 KV)、更高效的内核
overhead-bound每次计算太小,固定开销占主导迷你模型的 decodeCUDA Graphs、内核融合、CPU/GPU 重叠

4. 怎样衡量一个推理系统 · Metrics

前面讲的是”一次 forward 多快”。可用户关心的是”我的请求多快”,老板关心的是”每个 token 多少钱”。这一节把这些问题变成精确的指标。

4.1 延迟:TTFT、TPOT / ITL、E2E

一个请求的时间线:

 到达         开始 prefill     第1个token    第2个    第3个  ……  第 L_o 个(完成)
  │── 排队 ──│──── prefill ────│── decode ──│── … ──│── … ──│
  │◀──────────── TTFT ─────────▶│◀─ ITL ─▶│
  │◀──────────────────────────── E2E ─────────────────────────────▶│
  • TTFT(time to first token,首 token 延迟)= 第一个输出 token 的时间 − 到达时间 = 排队 + prefill。决定了”按下回车后多久开始出字”。
  • ITL(inter-token latency,token 间延迟):相邻两个输出 token 的时间间隔。有的论文叫 TBT(time between tokens)。
  • TPOT(time per output token)= (完成时间 − 第一个 token 的时间) ÷ (LoL_o − 1),即 ITL 的平均值。决定了”出字的速度”。
  • E2E(end-to-end latency,端到端延迟)= 完成时间 − 到达时间 = TTFT + (LoL_o − 1) × TPOT。

为什么要分开?因为用户对它们的感受不一样。代码 03 演示 1(空闲系统,没有排队):

请求(提示 → 输出)TTFTTPOTE2E
128 → 646.11 ms6.11 ms0.39 s
512 → 25610.92 ms6.13 ms1.57 s
4,096 → 25689.44 ms6.31 ms1.70 s
512 → 2,04810.92 ms6.18 ms12.65 s

TTFT 主要由提示长度决定,TPOT 几乎是常数,E2E 主要由输出长度决定。一个生成 2048 个 token 的回答要 12 秒以上,再快的 prefill 也帮不上忙;但只要 TTFT 短、TPOT 稳定,用户看着字一个个冒出来,并不会觉得在”等”。所以流式输出的服务通常分别对 TTFT 和 TPOT 设目标,而不是对 E2E。TPOT = 50 ms 相当于每秒 20 个 token,已经比大多数人阅读的速度快。

4.2 吞吐与成本

  • 吞吐(throughput):每秒生成多少个输出 token(tokens/s),或每秒完成多少个请求(requests/s)。有时也把输入 token 算进去,读论文时要看清楚。
  • 每 token 成本:
每百万输出 token 成本=GPU 每小时价格×GPU 数吞吐 (tokens/s)×3600×106\text{每百万输出 token 成本} = \frac{\text{GPU 每小时价格} \times \text{GPU 数}}{\text{吞吐 (tokens/s)} \times 3600} \times 10^6

代码 03 演示 4(假设 H100 每小时 3 美元,只是示意;各配置都在过载时测吞吐上限):

配置吞吐 tok/s每百万输出 token 成本
batch = 11625.14 美元
静态批 BB = 84721.77 美元
静态批 BB = 329560.87 美元
静态批 BB = 1281,4750.57 美元
理想上限:128 个座位始终坐满(只算 decode)12,6560.07 美元

同一块 GPU、同一个模型,只改变”怎样安排请求”,成本就能差 70 多倍。表里的”静态批处理”是什么、为什么离理想上限那么远,§4.5 马上讲。

顺便解释一个现象:大多数 API 对输出 token 的定价是输入 token 的好几倍。看代码 02 的数字就明白了:输入 token 走 prefill,compute-bound、高度并行,2048 个 token 的 prefill 平均每 token 约 21 µs;输出 token 走 decode,即使批大小 256,平均每 token 也要约 74 µs,批小的时候更贵。输出 token 本来就更费 GPU。

4.3 尾延迟与 SLO

平均值会掩盖”少数人等了很久”。系统评测通常报告分位数:P50(中位数,典型体验)、P90、P99(最倒霉的 1%)。P99 这类指标叫尾延迟(tail latency)。

为什么要盯着尾巴?Dean 和 Barroso 在 The Tail at Scale(2013)里讲过一个”扇出”效应:如果一个任务要依次或并行地调用很多次服务,它被至少一次”慢调用”拖累的概率会迅速变大。一个 agent 完成任务要调用 LLM 20 次,每次有 1% 的概率超过 P99,那么至少碰上一次的概率是 1−0.9920≈18%1 - 0.99^{20} \approx 18\%。对单次调用来说”百里挑一”的慢,落到 agent 的整个任务上,就变成了”五六次里就有一次”。

SLO(service-level objective,服务等级目标)是对指标的承诺,例如”P99 TTFT ≤ 1 秒且 P99 TPOT ≤ 50 毫秒”,或者逐个请求地判断”这个请求的 TTFT ≤ 1 秒且 TPOT ≤ 50 毫秒吗”。满足 SLO 的请求比例叫 SLO 达成率(SLO attainment)。本讲义的代码用逐请求的判断。

4.4 Goodput:满足 SLO 的吞吐

吞吐有一个盲点:一个超时 30 秒才返回的回答,在吞吐里和准时的回答算得一样多。goodput 只数”好”的部分。常见的定义有两种:

  1. 每秒满足 SLO 的请求数(本讲义代码里的 goodput):好请求的个数 ÷ 总时长。
  2. 在 SLO 达成率不低于某个目标(如 90%)的前提下,系统能承受的最大请求速率(DistServe 等论文用的定义,第 7 讲)。它回答的问题是:要满足这个服务质量,一块 GPU 能接多少流量?

两种定义都在说同一件事:系统”忙”不等于”有用”。代码 03 的结果会把这一点展示得很清楚。

4.5 延迟与吞吐的权衡:一个排队实验

代码 03 模拟了一个真实一点的场景:Llama-3-8B 在一块 H100 上服务类似聊天的请求。提示长度均值 512、输出长度均值 256,都是长尾的对数正态分布;请求按泊松过程随机到达。SLO 是 TTFT ≤ 1 秒且 TPOT ≤ 50 毫秒。模拟器用 hw_model 计算每一步的时间,是一个离散事件模拟:时钟直接跳过”等待”,几百个请求的几十分钟模拟只要几秒钟。

策略 1:一次只服务一个请求(batch = 1,先来先服务)。代码 03 演示 2:

到达率(请求/秒)GPU 利用率吞吐 tok/sTTFT P50TTFT P99SLO 达成goodput(请求/秒)
0.236%5913.77 ms9.02 s75%0.15
0.472%1171.77 s19.50 s40%0.16
0.590%1475.77 s38.42 s18%0.09
0.6100%16243.03 s97.97 s1%0.01

三个现象:

  1. 吞吐的上限只有约 160 tok/s,平均每个请求要占 GPU 约 1.6 秒,所以服务能力约 0.6 请求/秒。
  2. 即使利用率只有 36%,TTFT 的 P99 也有 9 秒。原因是输出长度的长尾:少数请求要生成上千个 token,占用 GPU 十几秒,排在它们后面的请求全得等。这种”一个慢请求堵住后面所有人”的现象叫头部阻塞(head-of-line blocking)。利用率升高后,排队时间增长得越来越快:利用率接近 100% 时排队时间趋于无穷,这是排队论里著名的”曲棍球棒”曲线(前置讲 P4)。
  3. goodput 先略升、再迅速跌向 0。到达率 0.6 时 GPU 100% 忙碌,可几乎没有一个请求是”好”的。

策略 2:静态批处理(static batching)。攒够 BB 个请求(或者等满 0.2 秒)就一起处理,整批全部生成完才收下一批。这是 2020 年前后很多系统(例如 NVIDIA FasterTransformer 的用法)的做法。代码 03 演示 3(节选):

BB到达率吞吐 tok/sTTFT P50TTFT P99TPOT P50SLO 达成goodput座位浪费
80.51470.60 s7.95 s6.15 ms63%0.3230%
3212931.62 s8.84 s6.23 ms33%0.3451%
328(过载)1,01648.30 s94.60 s7.06 ms2%0.0573%
12816(过载)1,47526.79 s68.15 s9.74 ms1%0.0780%

(“座位浪费”= decode 阶段批里的座位中,被”已经生成完、却要陪着整批跑到最后”的请求白占的比例。)

吞吐确实上去了:过载时 BB = 8 / 32 / 128 分别约是 batch = 1 的 3、6、9 倍,TPOT 仍然只有几毫秒到十毫秒。可是:

  • 离潜力差得远。§3.4 说批 128 能让吞吐涨 60 多倍,这里只涨了 9 倍。原因是座位浪费高达 60%–80%:一批里大多数请求早早就结束了,它们的座位却要空等其中输出最长的那个。输出长度是长尾分布,批越大,“最长的那个”往往越长。
  • TTFT 很差。新请求要等当前这一批全部生成完才能开始。到达率只有 1 请求/秒(远低于吞吐上限)时,TTFT 中位数就超过了 1.5 秒,SLO 达成率只有三成左右。
  • goodput 只提高了约 2 倍(0.34 vs 0.16 请求/秒),远小于吞吐提高的倍数。

吞吐高 ≠ 服务好。 静态批处理用”整批同进同出”换来了吞吐,代价是座位浪费和头部阻塞。第 2 讲的 Orca 提出迭代级调度(iteration-level scheduling),也就是今天所说的连续批处理(continuous batching):每一步 forward 之后都重新决定批的组成,结束的请求立刻离开,新请求立刻加入。它同时解决了这两个问题,是现代推理引擎的基础。

这个结论依赖 SLO 的松紧。练习 4 会让你把 TTFT 的 SLO 放宽到 5 秒再看:那时静态批处理的优势会明显得多。哪个系统更好,永远要问”在什么负载、什么 SLO 下”。

4.6 不同应用要的东西不一样

应用典型的形状最在乎的指标
聊天助手中等提示、中等输出,流式TTFT、TPOT
代码补全(IDE 里)长提示(代码上下文)、很短的输出极低的 TTFT(几百毫秒以内)
长文档问答 / RAG很长的提示、短输出TTFT(prefill 很重),前缀复用
推理模型(reasoning)短提示、极长的输出(思考过程)TPOT 和总时长,decode 吞吐
智能体(agent)几十次调用、上下文越来越长、大量共享前缀整个任务的完成时间,前缀复用
离线批处理(数据标注、评测)大量请求一次性提交只要吞吐和成本,不在乎单个延迟
RL 训练中的 rollout一大批生成,必须全部完成才能更新模型整批的完成时间(最慢的那个决定一切,第 15 讲)
语音助手实时严格的 TTFT 和稳定的 TPOT(不能卡顿)

同一套引擎要在这些场景之间权衡。这也是为什么推理系统的论文总在换工作负载和 SLO 做实验,读论文时要特别留意(§7)。


5. LLM 之前的模型服务:Clockwork 与可预测性 · Serving before LLMs

为什么第一次课要读一篇 2020 年、讲 CNN 服务的论文?因为它把”推理服务”这个问题想得非常干净。看它的前提在 LLM 身上哪里不成立了,正好能说明 LLM 推理系统为什么长成今天的样子。

5.1 背景:2017–2020 年的模型服务

LLM 之前,“模型服务”(model serving)主要服务的是图像分类、推荐、语音这类模型:

  • 模型不大(几 MB 到几百 MB),一块 GPU 能放下很多个;一个平台要同时服务成百上千个不同的模型(不同客户、不同版本)。
  • 一个请求就是一次固定的 forward:输入一张图,输出一个类别,毫秒级完成。
  • 典型系统:TensorFlow Serving(2017);Clipper(NSDI 2017,第 2 讲会读)提出了自适应批处理(根据延迟目标动态调整批大小)、模型选择和结果缓存;之后还有 INFaaS、Nexus 等。

这些系统关心的问题是:在延迟目标(例如 100 毫秒)下,怎样让很多模型共享 GPU?什么时候把模型加载进显存?怎么凑批?它们大多是反应式(reactive)的:观察到延迟变高了,就调小批、加机器、换策略。反应式的方法能改善平均情况,却很难管住尾巴。

5.2 Clockwork 的核心洞察

Clockwork(Gujarati 等,OSDI 2020)的出发点是一个观察:

DNN 推理本身的执行时间是确定的。 一次 forward 是一串固定的运算,没有数据相关的分支、没有循环次数的变化,在一块独占的 GPU 上,同一个模型、同一个批大小,每次的耗时几乎完全一样。

既然如此,尾延迟是从哪来的?来自上面层层叠加的系统:多个模型在同一块 GPU 上并发执行时互相干扰、按需从内存加载模型时快时慢、线程调度的抖动……每一层都”为了平均效率”引入了一点不确定性,叠在一起,尾巴就长了。

所以 Clockwork 的论点是:可预测性要自底向上地保持。从”单次推理是可预测的”这块基石出发,每往上搭一层,都不能引入新的不确定性,最后整个分布式系统的端到端延迟也可以预测。

5.3 自底向上的可预测性:怎么做

论文把这个原则落实成几条设计:

  • Worker 一次只在一块 GPU 上执行一个推理,不让多个推理并发抢资源。牺牲一点并发换来确定的执行时间。
  • 所有决策集中到控制器(论文称为 consolidating choices):加载哪个模型、卸载哪个、哪些请求凑成一批、什么时候执行,全由中心控制器决定。Worker 不做任何自主决策,只照章执行。
  • 每个动作都带一个时间窗口:控制器下发”在 [最早, 最晚] 时间之间执行这个推理 / 加载这个模型”,worker 赶不上窗口就直接放弃并报告。Worker 报回实际耗时,控制器据此不断校准自己的预测。
  • 提前拒绝:因为能预测,控制器在请求到达时就能判断”它能不能按时完成”,赶不上的立刻拒绝,而不是排进队、算完、再违约。
  • 显存当作模型的缓存:把模型从主机内存加载进 GPU 也是一个由控制器规划的、时间可预测的动作。

论文用微软 Azure Functions 的生产 trace 评测,报告 Clockwork 能同时服务数千个模型,在 100 ms 的延迟目标下满足 99.997% 的请求。

5.4 实验:可预测性怎样换来 goodput

代码 04 演示 1 先在你的 CPU 上验证”forward 的耗时可预测”(编写本讲时的一次运行;d_model = 256、4 层的迷你模型):

测量对象中位数P99 / 中位数CV(标准差 / 平均值)
同一形状的 prefill(64 token)× 3001.26 ms1.227%
同一形状的 decode(1 token)× 300208 µs1.3810%
整个请求(输出长度随机)× 408.56 ms3.4068%

即使在一台没有为实时性做任何处理的笔记本上,同一形状的 forward 波动也只有百分之几到十几(多跑几次你会看到这个数在 5%–20% 之间变化)。但整个请求的耗时波动达到 68%,因为它几乎完全由输出长度决定。如果事先知道输出长度,用”耗时 ≈ a + b × 输出长度”拟合,相对误差的中位数只有约 5%。不可预测性来自”不知道要生成多少”,不是来自计算本身。

代码 04 演示 2 模拟 CNN 时代:一个服务器,每次推理恰好 10 ms(服务能力 100 请求/秒);平时每秒 60 个请求,第 10–12 秒突发到每秒 200 个;SLO 是完成延迟 ≤ 100 ms。

策略接受率SLO 达成接受后超时白算的比例
全部接受(FIFO)100%67%33%33%
Clockwork 式:到达时预测,赶不上就立刻拒绝91%91%0%0%

全部接受时,突发期间积压的队列要好几秒才能消化。突发之后很长一段时间里,连正常到达的请求也全部超时,GPU 三分之一的时间花在”注定超时”的请求上。提前拒绝时,执行时间被精确预测,系统只拒绝突发期间确实赶不上的那 9%,被接受的请求 100% 按时完成,总的 SLO 达成率从 67% 升到 91%。可预测性让系统可以有计划地失败:失败得快、失败得少,把有限的算力留给能按时完成的请求。

5.5 LLM 打破了什么

现在把同样的思路搬到 LLM 上。代码 04 演示 3:沿用代码 03 的聊天工作负载,batch = 1 顺序服务,到达率 0.4 请求/秒(利用率约 70%),SLO 是 TTFT ≤ 2 秒。调度器知道提示长度(所以知道 prefill 要多久),但不知道输出长度:

策略接受率SLO 达成接受后超时误拒绝白算的比例预测误差 P90
全部接受(FIFO)100%55%46%—48%3.85 s
预测 + 拒绝(按平均输出长度估计)84%70%17%26%20%1.91 s
预测 + 拒绝(神谕:知道真实长度)82%82%0%0%0%0.00 s

(“误拒绝”= 被拒绝的请求里,其实它自己能按时完成的比例。)

按平均长度估计,预测误差是秒级的:排在前面的请求可能只生成 30 个 token,也可能生成 2000 个。于是”接受”的决定不再可靠:被接受的请求有 17% 仍然超时(GPU 又开始白算),被拒绝的请求里有 26% 其实赶得上。它比全部接受好,但离神谕版本还差一截。神谕版本说明:只要能知道(或较准地预测)输出长度,Clockwork 式的规划仍然有效(练习 9 会看预测”有点准”时的情况)。

把 CNN 时代和 LLM 时代的服务放在一起:

CNN 时代LLM 时代
一个请求的计算一次固定的 forward1 次 prefill + 事先未知次数的 decode
可预测性整个请求可预测每一步可预测,整个请求不可预测
模型大小MB 级,一块 GPU 放很多个GB 到 TB 级,一个模型可能要多块 GPU(第 6 讲)
请求的状态无状态KV cache:随生成增长,可达 GB 级,必须管理(第 3 讲)
请求之间互相独立大量共享前缀:系统提示、多轮对话、agent 的历史(第 3、11 讲)
一个任务调用几次模型一次很多次:agent、RL rollout(第 13、15 讲)
批处理凑一批输入一起算一次每一步都可以改变批的组成(第 2 讲)

LLM 推理系统对”请求级不可预测”的回应,基本上可以归结为三招:

  1. 不再规划整个请求,而是每一步都重新调度:迭代级调度 / 连续批处理(第 2 讲)。
  2. 让资源分配可以反悔:KV cache 可以按块分配、可以抢占、可以换出到 CPU 内存、可以重算(第 3 讲)。
  3. 把 SLO 拆成每一步可控的指标:TTFT 和 TPOT,而不是 E2E。每一步的耗时仍然是可预测的,调度器可以用每步的代价模型来做决策,DistServe、Sarathi-Serve 这类系统正是这样做的(第 7 讲)。我们的 hw_model.step_cost 也是这个思路。

另一条路是去预测输出长度:用一个小模型或者模型自己的隐藏状态来猜”还要生成多少”,再据此排序(例如短作业优先)或做准入控制。这是一个活跃的研究方向,我们在第 2 讲讨论调度时会遇到。

所以 Clockwork 的精神并没有过时:它把”可预测的单位”从整个请求缩小到了一步 forward。


6. 全景图:这门课的地图 · The landscape

6.1 两条主线

回顾 §2–§4,推理系统面对的核心矛盾可以归结为两条主线:

  • 主线 A:把 GPU “喂饱”(计算利用率)。decode 天生 memory-bound,batch = 1 时 99% 的算力在空转。相关的技术有:更好的批处理与调度(第 2 讲)、用闲置算力多猜几个 token 的投机解码(第 5 讲)、让 prefill 和 decode 互不干扰(第 7 讲)、让 MoE 的专家都有活干(第 8 讲)。
  • 主线 B:管好 KV cache(显存)。批大小的天花板是显存,长上下文让它更紧。相关的技术有:分页消除浪费、前缀共享(第 3 讲)、更高效地读 KV 的注意力内核(第 4 讲)、把 KV 放到更大更便宜的存储层级上(第 9 讲)、让 KV 变小的新架构(第 10 讲)、在集群里把请求送到”已经有它的 KV”的机器上(第 11 讲)。

再往外是两个扩展维度:规模(一块 GPU 不够时,多卡、多机、集群、多租户;第 6–12 讲)和应用(agent、记忆、RL 让工作负载的形状发生了变化;第 13–15 讲)。

6.2 五个层次

 ┌─────────────────────────────────────────────────────────────────────────────┐
 │ 应用层     agent 工作流、记忆、RL rollout                          第 13–15 讲 │
 ├─────────────────────────────────────────────────────────────────────────────┤
 │ 集群层     路由与负载均衡、KV cache 的存储层级、多租户与 GPU 共享    第 9、11、12 讲 │
 ├─────────────────────────────────────────────────────────────────────────────┤
 │ 多 GPU 层  张量 / 流水线 / 专家并行、prefill 与 decode 解耦          第 6–8 讲 │
 ├─────────────────────────────────────────────────────────────────────────────┤
 │ 引擎层     批处理与调度、KV cache 管理、前缀复用、投机解码       第 2、3、5 讲 │
 ├─────────────────────────────────────────────────────────────────────────────┤
 │ 内核层     FlashAttention、FlashInfer、算子融合                       第 4 讲 │
 └─────────────────────────────────────────────────────────────────────────────┘
   贯穿各层:模型与系统的协同设计——MoE、混合架构、稀疏注意力(第 8、10 讲)
   收尾:一个生产级引擎怎样把这些拼在一起(第 16 讲 SGLang);总结与开放问题(第 17 讲)

读每一篇论文时都可以先问:它工作在哪一层?它假设下面那几层是什么样的?它给上面那几层提供了什么?

6.3 Survey 的分类

选读的 survey(Miao 等,ACM Computing Surveys 2025,作者来自 CMU)从”算法到系统”给出了另一种分类,可以和上面的层次图互相对照:

大类子类例子本讲义
算法创新解码算法投机解码、提前退出、级联推理、非自回归解码第 5 讲
架构设计注意力简化(GQA、稀疏、线性注意力)、条件计算(MoE)、循环单元(SSM)第 8、10 讲
模型压缩知识蒸馏、剪枝不单列(§9)
系统优化低比特量化INT8 / INT4 权重、FP8、KV cache 量化前置讲 P1;附录讲 A(可选)
并行计算张量 / 流水线 / 序列并行、去中心化推理第 6 讲
显存管理PagedAttention、KV 换出第 3、9 讲
请求调度迭代级调度、抢占、优先级第 2 讲
内核优化算子融合、定制注意力内核、变长序列、自动编译第 4 讲

survey 还整理了当时(2023 年底)主流的推理框架:FasterTransformer、FlexFlow-Serve、vLLM、FlexGen、TGI、DeepSpeed-Inference、TensorRT-LLM、MLC-LLM 等。从那以后格局变化很大(§9),但分类框架依然好用。CS349D 的阅读清单基本落在”系统优化”和”架构设计”两块,没有单独讲量化和压缩,这一点读者要心里有数。

6.4 本讲义的路线图

讲主题层次主要解决什么
1导论—问题的全貌与度量
2批处理与调度(Clipper、Orca)引擎把 GPU 喂饱;座位浪费与头部阻塞
3PagedAttention 与 SGLang引擎KV cache 的碎片与预留浪费;前缀复用
4FlashAttention 与 FlashInfer内核注意力的显存读写
5投机解码(Speculative Decoding、SpecInfer)引擎用闲置算力减少 decode 步数
6并行(FlexFlow、Transformer 推理的分区)多 GPU模型放不下一块卡;降低单步延迟
7Prefill / decode 的干扰与解耦(Sarathi-Serve、DistServe、Dynamo)多 GPU两种性格互相拖累;goodput
8MoE 推理(MegaScale-Infer、Helix)多 GPU + 协同设计专家的负载与通信
9上下文管理(Mooncake、CacheGen、Strata)集群KV 的存储层级、传输与压缩
10混合架构与稀疏注意力(Mamba、Marconi、DeepSeek-V3.2)协同设计让 KV 变小,或者不再随长度增长
11负载均衡与路由(Preble、LLMSelector)集群前缀亲和与负载均衡的矛盾;选模型
12多租户(LithOS、Prism、冷启动)集群很多模型、很多用户共享 GPU
13智能体工作流(Parrot、Autellix)应用以”程序”而不是”请求”为单位调度
14记忆(MemGPT、mem0、Cartridges、ACE)应用长期记忆与上下文的成本
15RL 的推理系统(HybridFlow、RLHFuse)应用rollout 的长尾与训练 / 推理的切换
16案例:SGLang全栈一个生产级引擎怎样把这些拼在一起
17总结与开放问题—迷你引擎全景;还没解决的问题

6.5 不变量:优化不能改变输出(以及什么时候允许改变)

这门课的大多数优化有一个共同的硬性要求:模型算出来的东西不能变。按这个标准,优化分两类:

  • 无损(exact)优化:KV cache、批处理、分页存储、前缀复用、FlashAttention、各种并行、prefill / decode 解耦。它们在数学上和最朴素的实现完全等价,输出必须和参考实现一致。投机解码也属于这一类,虽然它”猜”了 token,但第 5 讲会证明它生成的序列和原模型的分布完全相同。
  • 有损(lossy)优化:量化、KV cache 压缩(CacheGen)、稀疏注意力、用小模型替代大模型的路由。它们用一点质量换效率,必须单独评估质量损失。

“一致”在工程上有多严格?代码 01 演示 5:用和不用 KV cache,最后一步 logits 的最大差是 1.0×10−61.0 \times 10^{-6},不是 0。两种方式在数学上等价,但浮点运算的累加顺序不同(一个大矩阵乘法 vs 很多个小矩阵乘法),最后几位会不一样。在这个例子里,这么小的差别没有改变任何一个被选中的 token;可如果两个候选 token 的 logits 几乎相等,舍入误差就可能让贪心选到另一个。真实系统用 BF16(有效数字比 FP32 少得多),批大小一变,内核选的分块方式和累加顺序也会变,于是”同一个请求单独跑和混在一批里跑,结果不一样”在实际中经常发生。Thinking Machines 在 2025 年的博客 Defeating Nondeterminism in LLM Inference 专门分析了这个问题,并展示了怎样用批不变(batch-invariant)的内核做到逐位一致,代价是一些性能。

所以本讲义的代码采用这样的标准:无损优化必须和参考实现”在浮点误差范围内一致”,并且生成的 token 相同。每一讲的代码都会写这样的检查。这是”系统做对了”的第一道防线。


7. 怎样读一篇系统论文 · How to read a (systems) paper

这门课的主体是读论文,第一次课的必读材料就是”怎样读论文”。

7.1 Keshav 的三遍阅读法

S. Keshav(2007)建议把读一篇论文分成三遍,每一遍目标不同:

第一遍(5–10 分钟):鸟瞰。 仔细读标题、摘要和引言;只读各节的标题;扫一眼数学部分(只判断它是用来干什么的);读结论;扫一眼参考文献,看有没有你读过的。读完要能回答五个 C:

  • Category(类别):这是哪一类论文?测量研究?新系统?对已有系统的分析?
  • Context(背景):它和哪些已有工作相关?它基于什么理论或系统?
  • Correctness(正确性):它的假设看起来成立吗?
  • Contributions(贡献):它的主要贡献是什么?
  • Clarity(清晰度):写得清楚吗?

很多论文读完第一遍就可以放下了:与你无关,或者你只需要知道它存在。

第二遍(最多 1 小时):抓住内容,暂时跳过细节(比如证明)。 仔细看图和表:坐标轴标对了吗?有没有误差条?结论在统计上站得住吗?记下没读过的相关参考文献。读完要能向别人讲清楚这篇论文的要点和证据。如果读不懂,可能是缺背景知识,也可能是作者写得不清楚,也可能只是你累了。

第三遍(初学者 4–5 小时,熟练后约 1 小时):在脑子里重新实现一遍。 假设你是作者,用同样的假设,你会怎样做?把你的设想和论文比较,找出论文里没说的假设、实验里的漏洞、可以做得更好的地方。读完这一遍,你应该能凭记忆重建论文的结构,并说出它的强项和弱点。

Keshav 还给了做文献调研的建议:用搜索引擎找 3–5 篇最近的高引用论文,读它们的相关工作部分;找出被反复引用的关键论文和反复出现的作者、会议,再去读那些。

本讲义的每一讲,可以看作替你完成了”第二遍”,再加上一部分”第三遍”(配套代码就是一种”重新实现”)。但如果你想真正学会这门课,至少要对每讲的必读论文自己做一次第一遍和第二遍。

7.2 读系统论文要问的问题

系统论文有一个相当固定的骨架:问题 → 为什么现有方案不行 → 关键洞察 → 机制 → 评测。读的时候带着下面这些问题:

  1. 问题与动机:它要解决什么问题?为什么是现在?是什么变化(新模型、新硬件、新应用)让这个问题变得重要?
  2. 现有方案为什么不行:论文通常会用一个测量或一个例子说明现有方案的缺陷。这个缺陷是根本性的,还是工程上没做好?
  3. 关键洞察(key insight):通常是一句话。一篇好的系统论文,核心思想往往能用一两句话说清楚,剩下的都是”怎样把它做对、做快”。
  4. 机制:为了把洞察变成系统,它做了哪些设计?每一个设计在解决哪一个具体问题?有没有哪个设计其实可以去掉?
  5. 评测:和谁比(基线 baseline 是不是最强的、配置是否公平)?在什么负载上比(真实 trace 还是合成数据?长度分布是什么?)?比什么指标(吞吐?P99?goodput?在什么 SLO 下?)?有没有消融实验(ablation)说明每个设计各自贡献了多少?
  6. 局限与假设:在什么情况下它会失效?它依赖下层的什么性质(例如”执行时间可预测”)?
  7. 影响:它被后来的系统采纳了吗?它改变了大家看问题的方式吗?

第 4 节的教训在这里尤其重要:推理系统的结论高度依赖负载和 SLO。同一个方法,换一个输出长度分布、换一个 SLO,结论可能就反过来了。读评测时,先找到”它在什么条件下赢”。

7.3 本讲义的”论文卡片”

后面每一讲在介绍必读论文时,都会给出一张这样的卡片:

项目内容
论文标题、作者、会议、年份
一句话它做了什么
问题要解决什么;为什么重要
现有方案的缺陷论文用什么证据说明
关键洞察一两句话
机制2–5 个主要设计,每个解决什么
评测基线、负载、指标、主要结论(标明”论文报告”)
局限与假设什么时候不灵
与本课其他内容的关系前因后果

7.4 示范:用卡片读 Clockwork

项目内容
论文Serving DNNs like Clockwork: Performance Predictability from the Bottom Up,Gujarati 等,OSDI 2020
一句话利用 DNN 推理执行时间确定这一事实,构建一个端到端延迟可预测的模型服务系统
问题交互式应用需要模型服务稳定地满足低延迟目标;平台要同时服务大量模型
现有方案的缺陷反应式方法能改善平均延迟,却管不住由执行时间波动造成的尾延迟
关键洞察单次 DNN 推理的执行时间是确定的;只要系统的每一层都不引入新的不确定性,整个系统就能可预测
机制每块 GPU 一次只执行一个推理;所有决策集中到控制器;动作带时间窗口,赶不上就放弃;能预测,所以能在到达时就拒绝赶不上的请求;模型加载也由控制器规划
评测生产 trace(Azure Functions),数千个模型;论文报告 100 ms 目标下满足 99.997% 的请求,并有很好的请求级性能隔离
局限与假设依赖”执行时间可预测”。LLM 的输出长度未知,请求级的可预测性不再成立(§5.5);独占执行牺牲了并发
与本课的关系它的精神在 LLM 时代变成”每一步可预测”:迭代级调度(第 2 讲)、基于每步代价模型的调度(第 7 讲)、多租户的隔离问题(第 12 讲)

8. 课程项目:迷你推理引擎 · The mini serving engine

8.1 原课的四个里程碑

CS349D 的项目是分阶段地实现一个最小的 LLM 推理引擎(暂定):

  1. 数据并行与张量并行(data parallelism、tensor parallelism);
  2. 连续批处理与 PagedAttention;
  3. 上下文缓存与分块 prefill(context caching、chunked prefill);
  4. 一个进阶功能:投机解码、分层上下文缓存、prefill / decode 解耦,或者自己提一个。

8.2 本讲义的代码怎样一步步搭出它

本讲义的代码沿着同一条路线,但按”先单卡、后多卡”的顺序:

讲加到迷你引擎上的东西对应原课里程碑
1带 KV cache 的迷你模型 tiny_llm.py;纸上 GPU hw_model.py;请求、工作负载、指标与两个朴素引擎 serving_core.py地基
2迭代级调度 / 连续批处理;变长序列的批量 forward里程碑 2
3分块的 KV cache(block table)与 PagedAttention;前缀缓存(radix tree)里程碑 2、3
4分块(tiled)注意力与在线 softmax;面向 decode 的注意力内核—
5投机解码(草稿模型 + 验证 + 回滚 KV)里程碑 4
6在”虚拟设备”上的数据并行与张量并行里程碑 1
7分块 prefill;prefill / decode 解耦与 KV 传输里程碑 3、4
9分层的 KV 缓存(GPU / CPU / SSD)里程碑 4

每加一个功能,都要过 §6.5 的检查:生成的 token 和参考实现相同。

8.3 没有 GPU 怎么办:真实计算 + 模拟时钟

你可能会问:在 CPU 上用 numpy 跑一个 12 万参数的模型,测出来的速度有什么意义?没有意义,所以我们不靠它测速度。本讲义的代码把”推理引擎”拆成两半:

  • 正确性来自真实的计算:tiny_llm.py 真的做矩阵乘法、真的维护 KV cache。批处理、分页、前缀复用这些优化做得对不对,看输出和参考实现是否一致。这一半和规模无关,小模型上对,大模型上也对。
  • 性能来自模拟时钟:hw_model.py 用 roofline 公式和公开规格,估计”如果这一步跑在 H100 上、模型是 Llama-3-8B,要多少毫秒”。调度策略的好坏,看模拟出来的 TTFT、TPOT、goodput。

这种”功能用小模型验证、性能用代价模型模拟”的做法在系统研究里很常见,例如 Vidur(MLSys 2024)就是一个专门模拟 LLM 推理系统性能的模拟器。它的局限也要记住(hw_model.py 开头列了它忽略的东西):它能告诉你趋势和量级,告诉不了你某个引擎在某块卡上的真实毫秒数。


9. 现状(2024–2026)· State of the field

截至 2026 年,LLM 推理系统大致是这样一幅图景(这一节是我的补充,不是原课内容):

  • 开源推理引擎成了基础设施。vLLM(起源于 PagedAttention 论文,2023)和 SGLang(起源于 RadixAttention 论文,2024)是两个最主要的开源引擎,NVIDIA 的 TensorRT-LLM、Hugging Face 的 TGI 等也被广泛使用。连续批处理、分页 KV cache、前缀缓存、分块 prefill、投机解码,这些几年前还是论文的技术,现在是这些引擎的默认功能。本地推理则有 llama.cpp 这类面向 CPU 和消费级 GPU 的引擎。
  • 从单机走向”推理集群操作系统”。prefill / decode 解耦从论文(Splitwise、DistServe,2024)走进了生产:Moonshot AI(Kimi)的 Mooncake 架构以 KV cache 为中心组织整个集群;NVIDIA 在 2025 年发布了 Dynamo,一个面向解耦式服务、KV 感知路由和 KV 卸载的分布式推理框架;社区也出现了基于 Kubernetes 的 llm-d 等项目。DeepSeek 在 2025 年公开了它的推理系统概况:大规模专家并行 + prefill / decode 解耦。第 7–9 讲讲这些。
  • 模型结构在迁就推理。GQA 成了标配;DeepSeek 的 MLA 把 KV 压缩到一个低秩空间;MoE 让”参数量”和”每 token 计算量”脱钩;线性注意力 / 状态空间模型(Mamba)与注意力混合的架构,以及 DeepSeek-V3.2 的稀疏注意力,试图让长上下文的代价不再随长度线性(甚至平方)增长。第 8、10 讲讲这些。
  • 工作负载在变。推理模型让输出长度动辄几千上万个 token,decode 的比重大增;agent 让一次任务变成几十次调用,前缀复用和”以程序为单位的调度”变得重要;RL 后训练(如 DeepSeek-R1 的训练方式)里,绝大部分时间花在生成 rollout 上,推理系统成了训练系统的一部分。第 13–15 讲讲这些。
  • 硬件:显存带宽和容量的提升速度跟不上算力(§3.1 的 H100 → H200 是一个例子)。NVIDIA 的 GB200 NVL72 把 72 块 GPU 放进同一个 NVLink 域,让”一个模型跨很多块卡”的通信变便宜;AMD、Google TPU 以及各种推理专用芯片也在竞争。
  • 低精度:FP8 推理已经普及,更低比特(FP4、INT4 权重量化、KV cache 量化)越来越常见。CS349D 没有单独讲量化,本讲义在前置讲 P1 介绍数值格式,并在大纲里留了一个可选的附录讲。
  • 确定性与可复现:§6.5 提到的批不变内核,让”同样的输入得到逐位相同的输出”重新受到关注。这对 RL 训练(训练时和推理时的数值要对得上)和调试都很重要。

10. 小结 · Summary

  1. 推理不是”训练的 forward”:请求在线、随机地到达,长度可变且输出长度未知,每个请求带着不断增长的状态(KV cache),而且有延迟目标。这些让推理成了一个独立的系统问题。
  2. 自回归生成必须一步一步做。KV cache 用显存换计算,把总计算量从输出长度的平方降到线性(代码 01:处理的 token 数相差 114 倍),代价是每个 token 的 KV 都要占显存(Llama-3-8B:128 KiB / token)。
  3. 一个请求分成 prefill(一次处理整个提示,矩阵乘矩阵)和 decode(每步 1 个 token,向量乘矩阵)两个阶段。同一段代码,算术强度差几百倍。
  4. Roofline:H100 的算力比带宽”大” 295 倍。batch = 1 的 decode 算术强度约为 1,MFU 只有 0.3%;长 prefill 是 compute-bound。
  5. 批处理是第一杠杆:权重的读取被批内的请求分摊(代码 02:批 64 时吞吐涨 42 倍,每步时间只涨 50%)。但 KV 不能分摊,decode 始终 memory-bound。显存(KV cache)是批大小的天花板,长上下文同时降低速度和并发。所以省 KV 显存 = 提高吞吐。
  6. 指标:TTFT、TPOT / ITL、E2E、吞吐、每 token 成本、尾延迟、SLO、goodput。吞吐高 ≠ 服务好:静态批处理把吞吐提高了 3–9 倍,但在 TTFT ≤ 1 秒的 SLO 下 goodput 只提高约 2 倍(代码 03),原因是座位浪费和头部阻塞。这正是连续批处理要解决的问题。
  7. Clockwork:DNN 推理的执行时间是确定的,可预测性要自底向上地保持,靠提前拒绝”有计划地失败”(代码 04:SLO 达成率 67% → 91%)。LLM 打破了请求级的可预测性(输出长度未知),推理系统的回应是每一步都重新调度、可反悔的 KV 分配,以及按步定义的 SLO。
  8. 地图:两条主线(喂饱 GPU、管好 KV cache),五个层次(内核、引擎、多 GPU、集群、应用),外加模型与系统的协同设计。无损优化必须和参考实现输出一致(在浮点误差范围内)。
  9. 读系统论文用三遍阅读法,带着”问题、缺陷、洞察、机制、评测、局限”去读,特别留意评测用的负载和 SLO。

11. 常见误区 · Common pitfalls

  1. “推理就是训练的 forward,没什么好优化的。” 推理的瓶颈(带宽、显存、调度、尾延迟)和训练完全不同;同一块 GPU 上,只改调度方式,成本就能差几十倍(§4.2)。
  2. “GPU 算力越大,decode 越快。” batch 较小时 decode 受限于显存带宽,算力翻倍几乎没用。H100 → H200 算力没变、带宽涨了 43%,decode 却明显变快。
  3. “批越大越好。” 批大受显存限制;批大让每一步变慢(TPOT 上升);静态批处理下批越大,“等最长的那个”越久。批大小是延迟和吞吐之间的权衡。
  4. “吞吐高说明服务好。” 吞吐把超时的回答也算作成功。要看 goodput 和 SLO 达成率(§4.4–4.5)。
  5. “平均延迟低就够了。” 用户的体验和 agent 的任务时间常常由尾巴决定。调用 20 次,至少碰上一次 P99 事件的概率约 18%(§4.3)。
  6. “KV cache 是免费的优化。” 它省计算,但占显存,而显存直接决定能并发多少请求。KV cache 的管理是推理系统最核心的工作之一。
  7. “FLOPs 少就一定快。” 代码 01 里,KV cache 让 FLOPs 少了约 100 倍,墙钟时间却只快了约 8 倍(overhead-bound);decode 时 FLOPs 只占时间的很小一部分(memory-bound)。先搞清楚瓶颈在哪,再谈优化。
  8. “优化后的输出应该逐位相同,否则就是有 bug。” 浮点运算的累加顺序不同,结果的最后几位就不同(代码 01 演示 5:差 10−610^{-6})。合理的标准是”在浮点误差范围内一致且 token 相同”;要求逐位一致需要专门设计(批不变内核)。反过来,差别远大于浮点误差时,几乎肯定是 bug。
  9. “Clockwork 讲的是 CNN,和 LLM 无关。” 它的方法论(找出可预测的单位、自底向上地保持可预测性、按预测做准入和调度)在 LLM 推理系统里以”每一步”为单位延续了下来。
  10. “纸上 GPU 的毫秒数就是真实性能。” hw_model 只给数量级和趋势(§0.6、§8.3)。真实系统还要考虑内核效率、通信、CPU 开销、显存碎片……这些正是后面各讲的内容。

12. 练习 · Exercises

练习 2–5、9 的参考答案里的数字,是我把代码复制到一个临时目录、按题目修改后实际运行得到的(没有改动课程目录里的代码)。

练习 1(手算账本)。Llama-3-70B 的配置:80 层,d=8192d = 8192,64 个 query 头、8 个 KV 头(dhead=128d_\text{head} = 128),dff=28672d_\text{ff} = 28672,词表 128256,嵌入表与输出层不共享。按 BF16 计算:(a) 参数量;(b) 权重字节数;(c) 每个 token 的 KV cache;(d) 能否放进一块 80 GB 的 H100。

参考答案

(a) 每层注意力:WqW_q 和 WoW_o 各 8192×81928192 \times 8192,WkW_k、WvW_v 各 8192×10248192 \times 1024(8 个 KV 头 × 128),合计 2×67.1M+2×8.4M≈151.0M2 \times 67.1\text{M} + 2 \times 8.4\text{M} \approx 151.0\text{M}。每层 MLP:3×8192×28672≈704.6M3 \times 8192 \times 28672 \approx 704.6\text{M}。每层合计约 855.6M,80 层约 68.45B。嵌入表与输出层各 128256×8192≈1.05B128256 \times 8192 \approx 1.05\text{B}。总计约 70.55B(与 hw_model.py 的输出一致)。 (b) 70.55B×270.55\text{B} \times 2 字节 ≈ 141 GB。 (c) 2×80×8×128×2=3276802 \times 80 \times 8 \times 128 \times 2 = 327680 字节 = 320 KiB。 (d) 放不下:权重就超过了 80 GB。需要至少两块卡做张量并行(第 6 讲),或者把权重量化到 8 比特(约 71 GB,仍然几乎没有空间留给 KV cache)甚至 4 比特。

练习 2(换一块卡)。用 hw_model.py 计算 Llama-3-8B 在 A100-80GB 上的:(a) 屋脊点;(b) 考虑 70% / 80% 效率系数后,prefill 从多长开始变成 compute-bound;(c) batch = 1 的 decode 每步时间,以及”读一遍权重”的物理下限;(d) 和 H100 比,decode 慢了多少?为什么正好是这个比例?

参考答案

(a) 312/2.039≈312 / 2.039 \approx 153 FLOP/byte。 (b) 交叉点 ≈ 312×0.72.039×0.8≈134\frac{312 \times 0.7}{2.039 \times 0.8} \approx 134 个 token;实际运行时,由于 lm_head 和词嵌入的那一点额外字节,提示 142 个 token 时还是 memory-bound,145 个 token 时变成 compute-bound。比 H100(约 260)低,因为 A100 的屋脊点更低。 (c) 9.74 ms;物理下限 16.06 GB ÷ 2.039 TB/s ≈ 7.88 ms(9.74 ms 里包括 80% 带宽效率和 0.5 ms 固定开销)。 (d) H100 是 6.13 ms,A100 慢约 1.6 倍,正好约等于两者的带宽之比(3.35 / 2.039 ≈ 1.64),而不是算力之比(3.2 倍)。decode 受限于带宽。

练习 3(如果没有 GQA)。假设 Llama-3-8B 用普通多头注意力(32 个 KV 头),其他不变。用 dataclasses.replace(LLAMA3_8B, n_kv_heads=32) 构造它,计算:每 token 的 KV、一块 H100 能放的 KV token 数、上下文 1024 时的最大批,以及批 32、上下文 1024 时每步 decode 的时间。和 GQA 版本比较。

参考答案

每 token KV 从 128 KiB 变成 512 KiB;W_k、W_v 变大,参数量也从 8.03B 涨到约 8.84B。H100 能放的 KV 从 426,784 个 token 降到 103,624 个;上下文 1024 时最大批从 416 降到 101;批 32 时每步从 7.70 ms 涨到 13.12 ms(要读 4 倍的 KV)。GQA 只用一点点模型质量的代价(原论文的结论),就换来了 4 倍的并发能力和更快的 decode。这是”模型结构迁就推理系统”的典型例子。

练习 4(SLO 的松紧改变结论)。把代码 03 的 SLO_TTFT 改成 5 秒,重新运行演示 2 和演示 3。(a) batch = 1 和静态批处理(BB = 32、128)的最高 goodput 各是多少?(b) 用 §4.4 的第二种 goodput 定义(SLO 达成率 ≥ 90% 时能承受的最大到达率),分别在 TTFT ≤ 1 秒和 ≤ 5 秒下比较 batch = 1 与静态批 BB = 32。(c) 由此你对”静态批处理好不好”有什么新的看法?

参考答案

(a) TTFT ≤ 5 秒时,batch = 1 的最高 goodput 约 0.29 请求/秒(到达率 0.4);静态批 BB = 32 和 BB = 128 都能到约 1.8 请求/秒(到达率 3–4),约是 batch = 1 的 6 倍。(TTFT ≤ 1 秒时只有约 2 倍。) (b) 我在到达率 0.05–1.0 之间扫描:TTFT ≤ 1 秒时,batch = 1 在 0.05 请求/秒时达成率 94%、0.1 时 89%;BB = 32 在 0.1 时约 90%。两者都只能承受约 0.1 请求/秒,几乎没有差别。注意:即使负载只有 0.05 请求/秒(GPU 利用率不到 10%),batch = 1 也有 6% 的请求违约,这是长尾输出造成的头部阻塞。TTFT ≤ 5 秒时,batch = 1 约 0.25(91%),BB = 32 约 0.8(92%;1.0 时降到 88%),差 3 倍多。 (c) 静态批处理适合对首 token 延迟不敏感的场景(离线批处理、宽松的 SLO),在严格的交互式 SLO 下几乎没有帮助。“哪个方案好”永远取决于负载和 SLO,这也是读论文评测部分时首先要找的东西。

练习 5(只停止陪跑,不补位)。修改 serving_core.run_static_batching:decode 时只让还没结束的请求参与计算(把 works 改成只包含 r.output_len - 1 >= s 的请求),但座位仍然不让给新请求。在到达率 16(过载)下,比较 BB = 32 和 BB = 128 的吞吐、TTFT P50,以及 TTFT ≤ 1 秒时的 goodput。结果说明”座位浪费”的主要代价是什么?

参考答案

BB = 32:吞吐 956 → 1,160 tok/s(+21%),TTFT P50 67.8 → 53.2 秒,goodput 0.022 → 0.027。BB = 128:吞吐 1,475 → 2,468 tok/s(+67%),TTFT P50 26.8 → 11.9 秒,goodput 0.068 → 0.129。停止陪跑让每一步变便宜了(主要是少读了已结束请求的 KV,批越大省得越多),所以批越短;但 TTFT 仍然以秒计,goodput 几乎没有改善。座位浪费的主要代价不是”白算的那点运算”,而是空出来的座位不能马上让给排队的新请求。只有连续批处理(第 2 讲)能把它拿回来。

练习 6(尾延迟的扇出)。一个 agent 完成任务要依次调用 LLM nn 次,每次调用超过其 P99 延迟的概率是 1%,各次独立。(a) n=20n = 20 时,至少有一次超过 P99 的概率是多少?(b) 要让这个概率不超过 10%,每次调用应该按 P 几来设计?

参考答案

(a) 1−0.9920≈18.2%1 - 0.99^{20} \approx 18.2\%。 (b) 设每次超过的概率为 qq,要求 1−(1−q)20≤0.11 - (1-q)^{20} \le 0.1,即 (1−q)≥0.91/20≈0.99475(1-q) \ge 0.9^{1/20} \approx 0.99475,q≤0.525%q \le 0.525\%。也就是要按约 P99.5 来设计每次调用的延迟。调用次数越多,单次调用的尾巴要求越严格。这是”智能体工作负载让尾延迟更重要”的定量说法(第 13 讲)。

练习 7(为什么输出 token 更贵)。用 hw_model.py 计算 Llama-3-8B @ H100:(a) 2048 个 token 的 prefill 平均每个 token 的时间;(b) 上下文 1024 时,批大小 1、64、256 的 decode 平均每个 token 的时间。由此解释 API 定价中”输出 token 比输入 token 贵”的现象。

参考答案

(a) 约 21.2 µs。(b) 批 1:6.15 ms;批 64:约 145 µs;批 256:约 74 µs。即使在显存允许的大批下,decode 每个 token 的 GPU 时间也是 prefill 的好几倍;批小的时候差几百倍。prefill 是 compute-bound、高度并行,GPU 被用得很充分;decode 是 memory-bound,同样的 GPU 时间产出的 token 少得多。所以按 GPU 时间成本定价时,输出 token 自然更贵。

练习 8(读论文)。用 §7.1 的第一遍方法读第 2 讲的必读论文 Orca(OSDI 2022)的标题、摘要、引言和结论(10 分钟以内),回答五个 C,并尝试填写 §7.3 卡片的”问题""现有方案的缺陷""关键洞察”三行。读完第 2 讲后回来对照。

参考答案(要点)

Category:新系统。Context:基于 Transformer 的生成模型服务;对比的是 NVIDIA FasterTransformer + Triton 这类以请求为单位做批处理的系统。Contributions:两项技术,迭代级调度(iteration-level scheduling)和选择性批处理(selective batching)。问题:生成式模型要多次迭代,以请求为单位的批处理不适合。缺陷:先完成的请求要等整批结束才能返回,新到的请求要等整批结束才能开始,也就是本讲代码 03 看到的座位浪费和头部阻塞。关键洞察:调度的粒度应该是”一次迭代”(一步 forward),而不是”一个请求”。具体见第 2 讲。

练习 9(输出长度预测要多准才有用)。在代码 04 演示 3 的设定下,假设有一个长度预测器:预测值 = 真实输出长度 × eεe^{\varepsilon},ε∼N(0,σ2)\varepsilon \sim \mathcal{N}(0, \sigma^2)。分别取 σ\sigma = 0(神谕)、0.1、0.25、0.5、1.0,用它代替”按平均长度估计”,看 SLO 达成率、接受后超时比例和误拒绝比例。提示:代码 04 的文件名以数字开头,不能直接 import,可以用 importlib.util.spec_from_file_location 加载。

参考答案
σ\sigma接受率SLO 达成接受后超时误拒绝
0(神谕)81.5%81.5%0.0%0.0%
0.180.2%79.2%1.2%9.2%
0.2579.3%77.2%2.7%24.2%
0.576.7%74.2%3.3%34.3%
1.072.3%69.0%4.6%48.8%

(神谕这一行就是正文 §5.5 表格里的结果:489 / 600 = 81.5%,正文四舍五入成了 82%。预测噪声用独立的随机数生成器,种子 42。)

随着预测变差,SLO 达成率平滑下降;σ=1\sigma = 1(预测值常常差 2–3 倍)时,效果和”按平均长度估计”(70%)差不多。即使是粗糙的长度预测也有一点价值,但要接近神谕需要相当准(误差在 10%–25% 量级)。预测不准时,主要的损失来自误拒绝:预测器高估了排在前面的请求,导致本来赶得上的请求被拒绝。

练习 10(思考:为什么批大小会改变输出)。§6.5 说”同一个请求单独跑和混在一批里跑,结果可能不同”。数学上,批里的每一行是独立计算的,为什么会这样?什么样的内核设计可以避免?

参考答案(要点)

浮点加法不满足结合律:(a+b)+c(a + b) + c 和 a+(b+c)a + (b + c) 的舍入可能不同。矩阵乘法、softmax、归一化里都有长长的求和。为了在不同形状下都跑得快,内核会根据批大小(以及序列长度)选择不同的分块方式、不同的并行归约策略(例如把一个求和拆给多个线程块再合并,即 split-K),累加顺序随之改变,于是同一行的结果在不同批大小下最后几位不同;在 BF16 下,这些差别足以偶尔改变 argmax。避免的办法是使用批不变的内核:无论批大小是多少,每一行都用固定的归约顺序计算(例如固定分块大小、不按批大小切换 split-K 策略)。代价是在某些形状下放弃了更快的策略。参考 Thinking Machines 的博客 Defeating Nondeterminism in LLM Inference(2025)。


13. 延伸阅读 · Further reading

本讲的阅读材料

  • S. Keshav. How to Read a Paper. ACM SIGCOMM Computer Communication Review, 2007.(必读,3 页)
  • X. Miao, G. Oliaro, Z. Zhang, X. Cheng, H. Jin, T. Chen, Z. Jia. Towards Efficient Generative Large Language Model Serving: A Survey from Algorithms to Systems. ACM Computing Surveys, 2025(arXiv:2312.15234)。建议先读第 2 节(背景)和第 3 节(分类),当作全课的索引。
  • A. Gujarati, R. Karimi, S. Alzayat, W. Hao, A. Kaufmann, Y. Vigfusson, J. Mace. Serving DNNs like Clockwork: Performance Predictability from the Bottom Up. OSDI 2020.

推理的”算术”与性能直觉

  • Horace He. Making Deep Learning Go Brrrr From First Principles(博客,2022):compute / memory / overhead 三种瓶颈。
  • Kipply. Transformer Inference Arithmetic(博客,2022):手算 KV cache、FLOPs 与延迟。
  • Google DeepMind. How to Scale Your Model(在线书,2025)中关于 Transformer 推理的章节:用 roofline 分析 TPU / GPU 上的推理。
  • S. Williams, A. Waterman, D. Patterson. Roofline: An Insightful Visual Performance Model for Multicore Architectures. CACM 2009.

指标与尾延迟

  • J. Dean, L. A. Barroso. The Tail at Scale. CACM 2013.
  • Databricks(MosaicML). LLM Inference Performance Engineering: Best Practices(博客,2023):TTFT、TPOT 等指标的工程定义与测量。

本讲提到的其他内容

  • D. Crankshaw et al. Clipper: A Low-Latency Online Prediction Serving System. NSDI 2017(第 2 讲必读)。
  • Thinking Machines Lab(Horace He 等). Defeating Nondeterminism in LLM Inference(博客,2025)。
  • A. Agrawal et al. Vidur: A Large-Scale Simulation Framework for LLM Inference. MLSys 2024(推理系统模拟器)。
  • Stanford HAI. AI Index Report 2025(推理价格下降的数据)。

14. 下一讲预告 · Next lecture

本讲看到,批处理是推理系统的第一杠杆,但”整批同进同出”的静态批处理浪费了大部分潜力,还造成了严重的头部阻塞。第 2 讲:批处理与调度读两篇论文:Clipper(NSDI 2017)在 CNN 时代用自适应批处理在延迟和吞吐之间折中;Orca(OSDI 2022)指出生成式模型应该以一步 forward 为单位调度,也就是连续批处理,并用”选择性批处理”解决了不同长度的请求怎样放进同一次 forward 的问题。我们会在迷你引擎里实现连续批处理,验证它和逐个生成的输出完全相同,再在纸上 GPU 上看它把本讲代码 03 的 goodput 提高了多少。

建议的阅读顺序:没有系统背景的读者,先读**前置讲 P1(GPU 与性能的第一性原理)**和 P2(Transformer 推理的账本),它们把本讲 §2–§3 的直觉变成完整的推导;再读 P4(排队论与性能评估),它解释 §4.5 的”曲棍球棒”曲线和怎样做可信的性能实验;然后进入第 2 讲。

← 返回课程目录