对应 Maps to:Stanford CS349D AI Inference Infrastructure(Spring 2026):#4 Wed Apr 8、#13 Mon May 11 两次 Programming Assignment Meeting(课程项目讨论会),#19 Wed Jun 3 Project Presentations(项目展示)。 依据说明:这三次课没有阅读材料,也没有公开的 slides。本讲依据:课程网站的项目说明(“迷你推理引擎”,四个里程碑,“所有特性必须保持兼容,所以越往后越难”);前 16 讲的论文与配套代码;评测方法部分另外参考了 A. Agrawal et al., Vidur: A Large-Scale Simulation Framework for LLM Inference(MLSys 2024),A. Agrawal et al., Etalon: Holistic Performance Evaluation Framework for LLM Inference Systems(arXiv:2407.07000,早期版本名为 Metron),以及 MLPerf Inference 和 SemiAnalysis InferenceMAX(现名 InferenceX)的公开说明。课程主线的梳理、特性相互作用的整理、开放问题与项目建议是我的综合。讲义里的纸上数字全部来自配套代码的实际运行。 配套代码 Code:
code/lecture17/(numpy;1 个模块mini_engine.py:把整门课的特性装进同一个按迭代推进的引擎,每个特性一个开关,外加多副本路由、goodput 搜索和成本;3 个脚本,分别约 50 秒、1.5 分钟、2 分钟以内) 预计阅读时间:2.5–3 小时(含运行代码)
0. 本讲导航 · How to read this lecture
0.1 学习目标 Learning goals
学完这一讲,你应该能够:
- 用”五个基本事实 + 两条主线 + 五个层次”把整门课串起来,说出每一讲在解决哪个瓶颈。
- 画出一个完整推理引擎的结构(调度、KV 管理、模型执行、路由),说清楚课程里的每个特性在一轮迭代的哪一步起作用。
- 在同一个负载上评测一组特性:用 goodput 和每百万 token 的成本,而不是单个吞吐数字;同时给出”逐个打开”和”从全开里逐个关掉”两种消融,并解释它们为什么不一致。
- 列举特性之间的典型冲突与互补(投机解码 × 批大小 / 显存 / SLO,前缀缓存 × 分块 prefill / 路由 / 解耦,量化 × 缓存容量……)。
- 设计一个不容易自欺欺人的评测:开环负载、足够长的稳态窗口、合适的指标(包括卡顿)、真实的共享结构、多个随机种子、认真调过的基线。
- 说出这个领域还没解决的问题,并为课程项目选一个合适的题目。
0.2 先修与本讲范围 Prerequisites & scope
| 默认你已经会 Assumed | 本讲只给直觉,不再展开 Previewed only |
|---|---|
| 第 1 讲:TTFT / TPOT / goodput、roofline | 各讲论文的细节(回到对应的讲次) |
| 第 2、3、5、7 讲:连续批处理、分页、前缀缓存、投机解码、分块 prefill | 真实 GPU 上的基准测试工具(vLLM / SGLang 的 bench 脚本) |
| 第 11、16 讲:缓存感知路由、重叠调度 | 量化的数值细节(附录讲 A) |
| 前置讲 P4 的排队论(Little 定律、开环 / 闭环) |
0.3 本讲地图 Map
§1 课程主线回顾:五个事实、两条主线、五个层次
§2 迷你推理引擎全景:课程项目要拼出来的东西
§3 端到端评测:同一个负载上逐个打开、逐个关掉 ← 代码 01
§4 特性之间的相互作用:为什么收益不能简单相乘 ← 代码 02
§5 怎样评测一个推理系统:六个最常见的坑 ← 代码 03
§6 开放问题
§7 课程项目:选题与做法
§8–12 小结、常见误区、练习、延伸阅读、结语
0.4 记号 Notation
| 符号 | 含义 |
|---|---|
| 根请求 root request | 负载里独立到达的一个”任务”:一次聊天、一次长文档问答,或者一整个多轮会话(会话的后续轮次由上一轮的完成触发) |
| goodput | 在 SLO 达成率 ≥ 90% 的前提下能承受的最大到达率(本讲以根请求 / 秒计) |
| SLO | 本讲默认 TTFT ≤ 2 秒、TPOT ≤ 50 毫秒(每个请求两条都满足才算达标) |
| 阶梯 ladder | 从朴素引擎出发、按顺序一项一项加特性的消融 |
| 留一 leave-one-out | 从”全部打开”出发、每次只关掉一项的消融 |
| 稳态窗口 | 只统计到达时刻落在 [预热结束, 最后一个根请求到达] 之间的请求 |
| 卡顿 stall | 一个请求相邻两次拿到 token 之间最长的间隔(Etalon 关心的指标) |
0.5 关于数字 · About the numbers
本讲所有表格的数字都来自 mini_engine.py 在纸上 GPU(第 1 讲的 roofline:H100、Llama-3-8B)上的模拟:GPU 时间按 roofline 算,CPU 开销用第 16 讲的假设值,前缀缓存用第 3 讲真实的基数树。它能说明机制和相对大小,不能替代真实 GPU 上的基准测试。论文和公开资料里的数字写成”论文报告”或”公开资料”。§5 会专门讨论”模拟器的数字什么时候能信”。
1. 课程主线回顾 · The big picture
1.1 五个基本事实
第 1 讲列出了整门课的出发点,回头看,每一讲都是在和其中一两条”讨价还价”:
- 自回归生成必须一步一步做,每个请求带着一份随生成增长的 KV cache。→ 第 5 讲(投机解码:一步验证多个 token)、第 10 讲(换掉会增长的状态)、第 14 讲(上下文就是记忆)。
- prefill 受限于算力,decode 受限于带宽;batch = 1 的 decode 只用到不到 1% 的算力。→ 第 2 讲(批处理)、第 4 讲(注意力内核)、第 7 讲(把两个阶段分开)、附录讲 A(少读字节)。
- 批处理是第一杠杆,显存(KV cache)是批大小的天花板。 → 第 3 讲(分页、前缀共享)、第 8 讲(MoE 的批被专家摊薄)、第 9 讲(分层存放 KV)。
- 输出长度事先未知,请求级的执行时间不可预测,只能每一步重新调度。→ 第 2 讲(迭代级调度)、第 13 讲(程序级调度)、第 15 讲(RL 里的长尾)。
- 衡量推理系统要看延迟、吞吐、成本和 goodput,结论高度依赖负载和 SLO。 → 本讲 §3–§5。
1.2 两条主线、五个层次
第 1 讲的地图把优化分成两条主线——喂饱 GPU(让每一步做更多有用的工作)和管好 KV cache(让显存放下更多、更有用的状态)——以及五个层次。下面这张表把十六讲放进这张地图,并各摘一个配套代码里跑出来的数字,提醒你”这一讲的东西值多少、在什么条件下”:
| 层次 | 讲次 | 解决的瓶颈 | 配套代码里的一个数字(条件) |
|---|---|---|---|
| 内核 | 第 4 讲 FlashAttention / FlashInfer | 注意力读写 HBM、decode 并行度不够 | split-KV:batch = 1、32K 上下文快 16 倍 |
| 单卡引擎 | 第 2 讲 批处理与调度 | 读权重不能分摊、请求进不来出不去 | 连续批处理在 TTFT ≤ 1 秒的 SLO 下承受的流量是静态批的几百倍 |
| 第 3 讲 PagedAttention / RadixAttention | KV 预留与碎片、前缀重复计算 | 显存紧张时分页是按上限预留的 5.2 倍;GQA + H100 时收益小得多 | |
| 第 5 讲 投机解码 | decode 每步只出 1 个 token | 批小时目标模型验证 γ + 1 个 token 和 decode 1 个差不多贵;批大时不划算 | |
| 第 16 讲 SGLang | CPU 开销、各种特性的组合 | 重叠调度在完整负载上 1.10 倍;“prefill 完成就插入基数树”值 1.4 倍 | |
| 多 GPU | 第 6 讲 并行 | 放不下、降延迟、提吞吐 | 8 卡跑 8B:延迟最低是 TP=4,吞吐最高是 TP=2 × DP=4 |
| 第 7 讲 PD 解耦 | prefill 打断 decode | 分块 prefill 把 P99 TBT 从 130 ms 降到 12 ms;解耦相对最好的共置只高 1.07–1.22 倍 | |
| 第 8 讲 MoE | 每个专家分到的 token 太少 | 负载偏斜时最忙的卡决定一切(128 卡掉到 717),冗余专家拉回 3,438 | |
| 集群 | 第 9 讲 上下文管理 | KV 太大、要分层存放 | 从 CPU 内存或 RDMA 加载比重算快 7–9 倍,普通网络上反而更慢 |
| 第 11 讲 路由 | 负载均衡 vs 缓存亲和 | 纯亲和会被一个共享系统提示吸到一个副本上 | |
| 第 12 讲 多租户 | GPU 用不满、冷启动 | 保温 10 秒就消除了 99.8% 的冷启动(我们的回放) | |
| 应用 | 第 13 讲 agent | 请求之间的依赖与程序级排队 | Parrot 式依赖执行在链式摘要上 1.13–1.45 倍 |
| 第 14 讲 记忆 | 每人一份长上下文的代价 | 每人 12 万 token 让峰值吞吐降 107 倍;只追加的手册让缓存复用 92.4% | |
| 第 15 讲 RL | rollout 的长尾 | 最后 10% 的样本占一半以上的生成时间 | |
| 协同设计 | 第 8、10 讲,附录讲 A | 模型结构与系统一起改 | DSA 在长上下文把注意力代价降 7–8 倍、短上下文反而更慢 |
1.3 三种贯穿全课的思维方式
- 先找瓶颈,再谈优化。 每一讲开头都在问同一个问题:这一步的时间花在哪——算力、带宽、显存容量、CPU、通信,还是排队?roofline(第 1 讲)、α–β 模型(第 6 讲)、Little 定律(P4)都是”找瓶颈”的工具。一个优化只在它针对的瓶颈正好是当前瓶颈时才有收益——这句话会在 §3 里被反复验证。
- 分清”无损”和”近似”。 连续批处理、分页、前缀缓存、FlashAttention、分块 prefill、推测采样都不改变输出的分布(第 1 讲的不变量:与参考实现在浮点误差内一致);量化、稀疏注意力、压缩 KV、草稿模型的”贪心接受”则用精度换速度,必须单独评估质量(附录讲 A §6)。
- 负载决定一切。 前缀缓存在少样本负载上值两三倍、在无共享负载上一文不值(第 16 讲);投机解码在批小时快、批大时慢(第 5 讲);解耦在摘要负载上反而更差(第 7 讲)。任何”X 比 Y 快几倍”都要追问:什么负载、什么 SLO、什么硬件。
2. 迷你推理引擎全景 · The mini serving engine
2.1 课程项目在拼什么
原课的项目是一个”迷你推理引擎”,分四个里程碑:(1)数据并行与张量并行;(2)连续批处理与 PagedAttention;(3)上下文缓存与分块 prefill;(4)一个进阶功能——投机解码、分层上下文缓存、prefill / decode 解耦,或者自己提出的功能。课程网站特别强调:每个里程碑都建立在前一个之上,所有特性必须保持兼容,所以越往后越难。
“保持兼容”为什么难?因为每个特性都对引擎的其他部分做了假设:
- 分页 KV 假设”每一步每个请求多 1 个 token”——投机解码一次可能多出 γ + 1 个,被拒绝的还要回收;
- 前缀缓存假设”KV 只依赖前缀”——分块 prefill 的中间状态、被抢占后重算的请求、量化过的 KV 都要能正确地进出基数树;
- 重叠调度假设”下一轮的批可以在这一轮算完之前排好”——投机解码要等这一轮接受了几个 token 才知道下一轮从哪开始;结构化输出要等上一个 token 才知道下一步的语法掩码;
- 张量并行假设”所有 GPU 走同样的控制流”——每个特性的调度决定都要在各 GPU 上一致。
2.2 一个引擎由哪几块组成
请求 ──▶ 前端(分词、API、结构化输出的语法)
│
▼
┌──────────────── 调度器(每一轮迭代都重新决定)─────────────────┐
│ ① 接纳:排队策略(FCFS / 最长前缀优先)、在基数树里匹配前缀、 │
│ 申请 KV(按上限预留 / 分页按需)、放不下就淘汰缓存 │
│ ② 增长:decode 的请求各多 1 个(或 γ + 1 个)token;不够就抢占 │
│ ③ 组批:decode 各 1 个 token + 用剩下的预算做 prefill(分块) │
│ ④ 收尾:产出 token、prefill 完成的提示插进基数树、结束的请求释放 │
└──────────────────────────────┬────────────────────────────────┘
│ CPU 准备下一轮(重叠调度:和 GPU 同时做)
▼
模型执行:注意力内核(分页 / 前缀共享)、矩阵乘法(BF16 / FP8 / 4 位)、
并行(TP / DP 注意力 / EP)、草稿模型(投机解码)
│
▼
多副本:路由器(轮询 / 最少请求 / 缓存感知);prefill / decode 解耦;KV 缓存池
本讲的 mini_engine.py 就是按这张图写的。Engine.step() 做一轮迭代的四步,每个特性是 EngineConfig 里的一个开关:
| 开关 | 课程里的出处 | 在 step() 的哪一步起作用 |
|---|---|---|
batching = "static" / "continuous" | 第 1、2 讲 | ① 静态批只在批空了以后接纳 |
memory = "reserve" / "paged" | 第 2、3 讲 | ① 预留多少;② 不够时抢占最晚进来的请求、之后重算 |
prefix_cache、policy = "lpm" | 第 3、16 讲 | ① 匹配前缀、按命中长度排序、同一轮推迟重复的未缓存前缀;④ prefill 完成就插入 |
chunk | 第 7 讲 | ③ 每轮的 prefill token 预算 |
overlap | 第 16 讲 | 迭代时间 = max(CPU, GPU) 而不是 CPU + GPU |
spec、spec_max_batch | 第 5 讲 | ② 多留 γ + 1 个;③ 验证 γ + 1 个 token、草稿模型跑 γ 次;④ 按 α 抽样接受几个 |
quant = "fp8" | 附录讲 A | 字节数、算力、KV 容量 |
tp、多副本 + Router | 第 6、11 讲 | 每步的算力 / 带宽 / all-reduce;请求分给哪个副本 |
2.3 写一个能同时打开所有特性的引擎,我踩到的坑
写这个模块时,我自己就撞上了”特性之间不兼容”的问题,都是在把两个特性放在一起时才出现的:
- 前缀缓存 × 显存回收:接纳一个请求时,先在基数树里匹配前缀,再为它腾显存(淘汰 LRU 叶子),最后才锁住匹配到的节点——显存极紧时,腾显存这一步会把刚刚匹配到的前缀自己淘汰掉,于是这个请求以为自己命中了、其实那段 KV 已经没了。正确的顺序是匹配后立刻加锁、放不下再解锁。第 16 讲的运行时也有同样的写法;那一讲的实验里刚匹配的节点在 LRU 顺序里排在最后、从来没被淘汰到,输出不受影响,但我已经一并改正。
- 分页 × 基数树的计量单位:分页按块(16 个 token)分配,基数树按 token 存放。一个请求 prefill 完成时,它的提示从”私有的块”变成”树里的 token”,两边取整的方式不同,于是每一轮的显存增长要逐个请求按”取整之后差多少”计算,否则占用会偶尔超出容量。
- 评测 × 有限长的实验:第一版评测固定用 500 个根请求,得到的 goodput 比稳态高出将近一倍(§5.2)。
这三个坑都不是哪一篇论文的内容,而是”把东西拼起来”才会遇到的——这正是课程项目想让你经历的。
2.4 模拟的边界
mini_engine 能做到的:每个请求的 token 级生命周期、真实的基数树、显存的逐轮记账、抢占与重算、多副本的联合推进。做不到的:真实内核的效率随批大小和形状变化(我们只有 roofline 的两个经验系数)、CUDA graph、注意力内核对分页的开销、通信与计算的真实重叠、量化带来的质量损失、投机解码接受率与上下文的关系(我们按请求类型给定一个 α)。所以本讲的结论都以”机制”和”量级”的形式陈述;具体的倍数,要在真实系统上测。
3. 端到端评测:同一个负载上逐个打开、逐个关掉 · End-to-end evaluation
3.1 负载
代码 01 用一个”像生产环境”的混合负载(mixed_workload):
| 请求类型 | 请求数 | 平均提示 | 平均输出 | 可共享的前缀 |
|---|---|---|---|---|
| 共享系统提示的聊天(8 个应用,Zipf 分布) | 2,011 | 1,722 | 263 | 1,500 token 的系统提示 |
| 多轮会话(2–4 轮,下一轮在上一轮结束后 1–4 秒到达) | 3,681 | 1,138 | 241 | 之前的对话 |
| 长文档问答(2,000–8,000 token 的独有文档) | 771 | 5,104 | 187 | 无 |
4,000 个根请求展开成 6,463 个请求,平均每个根请求要生成 390 个输出 token。多轮会话的思考时间取得很短(更像 agent 的连续调用),是为了让会话在一分钟的实验里能走完。
3.2 指标:goodput 和成本
我们不报”最大吞吐”,而报 goodput:在 TTFT ≤ 2 秒、TPOT ≤ 50 毫秒、90% 的请求达标的前提下,能承受的最大到达率(第 1、7 讲)。为什么?因为吞吐总可以靠”让请求多排一会儿队、把批做大”来提高,而用户感受到的是延迟;goodput 把”多快”和”多好”放进同一个数字。
成本由 goodput 换算:每块 H100 每小时 3 美元(假设,与第 1、2 讲相同),每百万输出 token 的成本 = 3 ÷(goodput × 390 × 3,600)× 10^6。goodput 翻倍,成本减半。
3.3 阶梯:按课程项目的顺序逐个打开
| 配置 | goodput(根请求/秒) | 比上一步 | 输出 tok/s | 美元 / 百万 token | TTFT P90 | TPOT P90 | 命中率 | 平均批 |
|---|---|---|---|---|---|---|---|---|
| 朴素引擎(静态批、按上限预留) | 0.20 | — | 78 | 10.662 | 1.79 秒 | 7 毫秒 | 0% | 0 |
| + 连续批处理 | 9.28 | 46.36× | 3,624 | 0.230 | 1.10 | 39 | 0% | 69 |
| + 分页 KV | 9.40 | 1.01× | 3,670 | 0.227 | 0.23 | 46 | 0% | 77 |
| + 前缀缓存 | 12.41 | 1.32× | 4,844 | 0.172 | 0.19 | 40 | 54% | 87 |
| + 最长前缀优先 | 12.41 | 1.00× | 4,844 | 0.172 | 0.19 | 40 | 54% | 87 |
| + 分块 prefill | 18.58 | 1.50× | 7,254 | 0.115 | 0.48 | 41 | 56% | 149 |
| + 重叠调度 | 19.54 | 1.05× | 7,630 | 0.109 | 0.42 | 38 | 56% | 152 |
| + FP8 权重与 KV | 36.73 | 1.88× | 14,340 | 0.058 | 0.29 | 19 | 57% | 173 |
(TTFT、TPOT、命中率、批都是在 goodput 处测的。)
几件值得停下来想的事:
- 朴素引擎几乎不能用。 静态批要等批里最长的请求写完才接新请求——输出最长 2,000 个 token,一批可能跑十几秒——后到的请求排在后面,TTFT 动辄超过 2 秒。它只能在每秒 0.2 个根请求的流量下达标。连续批处理让 goodput 提高 46 倍:这一步是”从不能用到能用”,后面每一步都是在它的基础上乘一个系数。
- 有两步的增益是 1.00–1.01 倍。 这不是说分页和最长前缀优先没用,而是说在那个阶段,卡住 goodput 的是别的东西。看 TPOT P90:加上分页以后已经到了 46 毫秒,离 50 毫秒的 SLO 只差一点——瓶颈是”不分块的长文档 prefill 一整步挡住所有 decode”,显存还不是瓶颈;分页让批稍大了一点(69 → 77),也让 TTFT 好了很多(1.10 → 0.23 秒),但 goodput 被 TPOT 卡住了。
- 分块 prefill 一步就是 1.5 倍:它把长 prefill 切成 2,048 token 一块,decode 不再被整段挡住,TPOT 的余量让批从 87 涨到 149。代价写在 TTFT 上(0.19 → 0.48 秒)——长文档要分好几轮才 prefill 完。
- FP8 是单项最大的(1.88 倍):权重和 KV 的字节数减半、KV 容量多一倍多、算力翻倍。但它是唯一改变输出的一项,要单独评估质量(附录讲 A)。
- 从”连续批处理”到”全部打开”(不含 FP8)是 2.11 倍,加上 FP8 是 3.96 倍;每百万 token 的成本从 0.230 美元降到 0.109 美元、再到 0.058 美元。和第 16 讲的结论一致:单看每一项都不算大,合起来是好几倍。
3.4 留一:从全部打开出发,每次只关掉一项
| 配置 | goodput | 相对全开 | 阶梯里这一步的增益 |
|---|---|---|---|
| 全部打开 | 19.54 | 1.00× | — |
| 关掉连续批处理(静态批) | 0.22 | 0.01× | 46.36× |
| 关掉分页 KV(按上限预留) | 15.18 | 0.78× | 1.01× |
| 关掉前缀缓存 | 14.80 | 0.76× | 1.32× |
| 关掉最长前缀优先 | 20.04 | 1.03× | 1.00× |
| 关掉分块 prefill | 14.44 | 0.74× | 1.50× |
| 关掉重叠调度 | 18.58 | 0.95× | 1.05× |
| 加上投机解码(批 ≤ 128 时开) | 19.54 | 1.00× | — |
| 加上 FP8 | 36.73 | 1.88× | 1.88× |
最醒目的是分页 KV:阶梯里它几乎没有贡献(1.01 倍),留一时关掉它 goodput 掉 22%。原因正是上一节说的:分块 prefill 和重叠调度把批做到 150 多个请求以后,“每个请求按 API 上限 2,048 个输出 token 预留 KV”就成了瓶颈——预留模式下平均批只有 88。同一个特性,在不同的”其他特性都开着什么”的背景下,价值完全不同。
这引出一个方法论的问题:“每个特性贡献了多少”没有唯一的答案。 阶梯的结果取决于顺序:先开的特性拿走了它和后开的特性”共享”的那部分收益。留一的结果回答的是另一个问题:“在其他一切都开着的情况下,这一项还值多少”——对决定”要不要保留这个特性”更有用,但所有留一的损失加起来,并不等于”全开 / 全关”的总收益。如果一定要一个”公平”的分摊,可以用合作博弈里的 Shapley 值:对所有可能的打开顺序取平均(练习 3 用 2 × 2 的例子算一遍)。实践中的建议是:两种消融都报告,并写清楚负载和 SLO。
另外两项:关掉最长前缀优先反而略高(1.03 倍),投机解码不改变 goodput——这两个差别都在测量噪声之内(§5.6)。投机解码为什么没用,§4.2 解释。
4. 特性之间的相互作用 · Why gains don’t simply multiply
4.1 一张相互作用表
把整门课的特性两两放在一起,常见的关系可以归成三类:抢同一份资源(显存、算力预算、每轮的 token 预算)、互相依赖的时序(下一轮要等这一轮的结果)、作用在同一个瓶颈上(收益重叠)。下表是我整理的常见组合(“本讲”表示代码 02 有对应的实验):
| 组合 | 关系 | 为什么 | 出处 |
|---|---|---|---|
| 投机解码 × 批大小 | 抢算力 | 验证 B × (γ + 1) 个 token,批大了这一步就从受限于带宽变成受限于算力 | 第 5 讲;本讲 §4.2 |
| 投机解码 × 显存 | 抢显存 | 草稿模型的权重和它自己的 KV;被拒绝的 token 也要先占位 | 本讲 §4.3 |
| 投机解码 × 重叠调度 | 时序依赖 | 下一轮从哪个位置开始,要等这一轮接受了几个 token;SGLang 早期要求二选一,后来的 SpecV2 才把两者叠起来(实验性) | 第 16 讲 |
| 投机解码 × 分块 prefill | 抢 token 预算 | 每个 decode 请求占 γ + 1 个位置,留给 prefill 的预算变少 | — |
| 前缀缓存 × 分块 prefill | 作用在 prefill 的两个侧面 | 一个让要算的 prefill 变少,一个让剩下的 prefill 不挡 decode;收益部分重叠 | 本讲 §4.4 |
| 前缀缓存 × 数据并行 | 缓存被切碎 | 每个副本各有一棵树;路由不看缓存,同一会话的各轮散到各处 | 第 11 讲;本讲 §4.5 |
| 前缀缓存 × PD 解耦 | 缓存在哪一边 | 前缀命中省的是 prefill,缓存要放在 prefill 节点(或共享的 KV 池);decode 节点上的 KV 还得传过去 | 第 7、9 讲 |
| 前缀缓存 × 分页 | 粒度 | 块大小为 16 时,不足一块的前缀不能共享(SGLang 默认一页 1 个 token) | 第 3 讲 |
| 前缀缓存 × 混合 / 线性注意力 | 状态不能截断 | SSM 状态只能精确命中,不能像 KV 一样取一段前缀 | 第 10 讲 |
| 结构化输出 × 重叠调度 | 时序依赖 | 下一步的语法掩码依赖上一个 token;要把掩码的计算也重叠到 GPU 时间里 | 第 3、16 讲 |
| 量化 × 前缀缓存 | 互补 | KV 字节减半,能缓存的前缀多一倍,命中率随之提高 | 本讲 §4.3 |
| 重叠调度 × 输出很短的负载 | 白算 | 结束要晚一轮才知道,每个请求多算一步 | 第 16 讲 |
| DP 注意力 × 前缀缓存 | 缓存被切碎 | 每个 DP rank 只看到自己那部分请求的前缀,需要 rank 之间的路由 | 第 16 讲 |
| 抢占 × 前缀缓存 | 互补 | 被抢占的请求的提示还在树里(只是不再被锁),重算时多半能命中 | 第 3 讲 |
4.2 投机解码:负载和 SLO 决定要不要开
代码 02 演示 1,TPOT 中位数(毫秒)/ 平均批大小:
| 到达率 | 不开 | 自适应(批 ≤ 32 时开) | 自适应(批 ≤ 128) | 一直开 |
|---|---|---|---|---|
| 1 个/秒 | 6.0 / 2 | 4.0 / 1 | 4.0 / 1 | 4.0 / 1 |
| 8 个/秒 | 9.8 / 22 | 5.7 / 14 | 5.7 / 14 | 5.7 / 14 |
| 12 个/秒 | 15.1 / 48 | 14.9 / 47 | 8.8 / 30 | 8.8 / 30 |
| 20 个/秒 | 34.0 / 159 | 34.1 / 159 | 34.1 / 156 | 31.5 / 151 |
不同 TPOT SLO 下的 goodput:
| TPOT SLO | 不开 | 自适应(批 ≤ 128) | 一直开 |
|---|---|---|---|
| 10 毫秒 | 6.60 | 10.14 | 10.14 |
| 20 毫秒 | 11.95 | 15.57 | 15.57 |
| 50 毫秒 | 19.54 | 19.54 | 19.30 |
负载轻时,投机解码把 TPOT 从 6 毫秒降到 4 毫秒;负载重时,它几乎不再带来好处。更重要的是第二张表:同一个特性,SLO 严格时让 goodput 提高一半以上,SLO 宽松时开不开都一样。 所以”要不要开投机解码”不是引擎的属性,而是”负载 + SLO”的属性;生产系统按当前的批大小动态调节 γ(甚至关掉它),门槛本身也要按硬件和模型调——门槛设成 32 时,中等负载下的收益就被白白放弃了(12 个/秒时 14.9 毫秒对 8.8 毫秒)。
4.3 显存是共享的
代码 02 演示 2 用一个”缓存放不下”的负载:64 个应用,各有 6,000 token 的系统提示(共 38.4 万 token),80% 是聊天。
| 配置 | KV 容量(token) | goodput | 命中率 | 相对 BF16 |
|---|---|---|---|---|
| BF16 | 426,784 | 11.95 | 73% | 1.00× |
| BF16 + 投机解码 | 323,135 | 12.25 | 69% | 1.03× |
| FP8 | 976,100 | 25.80 | 82% | 2.16× |
| FP8 + 投机解码 | 762,588 | 26.46 | 81% | 2.21× |
草稿模型(Llama-3.2-1B,约 2.5 GB 权重,每个 token 再多 25% 的 KV)让 KV 容量少了约四分之一,命中率随之下降——讨论投机解码时,这笔显存账常被忽略。FP8 在这里的收益(2.16 倍)比混合负载上(1.88 倍)更大:多出来的容量缓存了更多应用的系统提示,一部分收益是通过前缀缓存间接得到的。
4.4 前缀缓存 × 分块 prefill:部分重叠
代码 02 演示 3,其余特性全开:
| 不分块 | 分块 2,048 | |
|---|---|---|
| 前缀缓存关 | 10.14 | 14.80 |
| 前缀缓存开 | 14.44 | 19.54 |
单开前缀缓存 1.42 倍,单开分块 1.46 倍,两个都开 1.93 倍——略小于乘积 2.08 倍:有一部分收益是重叠的,但重叠得不多(前缀缓存帮不了没有共享前缀的长文档,分块恰好处理它们)。
这里有一个值得记住的插曲。我第一次跑这个实验时,goodput 的判据还没有”窗口末尾也要达标”这一条(§5.2),得到的是 2.30 倍、大于乘积 2.05 倍——结论正好相反。原因是那时”全部打开”的 goodput 被高估得最多。两个特性之间相互作用的方向,是一个很脆弱的结论:四个配置的 goodput 各有几个百分点的误差,比值相乘再相比,误差会叠加。
4.5 多副本:路由决定前缀缓存还剩多少
代码 02 演示 4,4 个副本(4 × H100,各跑一个 Llama-3-8B):
| 路由 | 到达率 | 命中率 | TTFT P90 | TPOT P90 | 达标 |
|---|---|---|---|---|---|
| 轮询 | 60 个/秒 | 46% | 0.20 | 29 | 100% |
| 最少请求 | 60 | 46% | 0.19 | 27 | 100% |
| 缓存感知(负载差 ≤ 4) | 60 | 55% | 0.19 | 25 | 100% |
| 缓存感知(负载差 ≤ 32) | 60 | 56% | 0.24 | 34 | 100% |
| 轮询 | 90 | 46% | 0.99 | 41 | 91% |
| 缓存感知(负载差 ≤ 4) | 90 | 54% | 0.77 | 40 | 91% |
缓存感知路由把多轮会话的下一轮送回它的历史所在的副本,命中率从 46% 提高到 55% 左右;负载保护放得太松时,热门前缀把请求集中到少数副本,TPOT 变差。但即使命中率提高了,90 个/秒时的达标率也和轮询一样——这个负载的瓶颈是 decode 读 KV 和权重,多出来的命中省下的 prefill 不是限制 goodput 的那一项。第 11 讲的教训在这里重现:先保证负载均衡,再在差不多闲的副本之间挑缓存最多的;路由的收益同样取决于瓶颈在哪。
5. 怎样评测一个推理系统 · How to evaluate an inference system
5.1 一次评测的五个要素
| 要素 | 要回答的问题 | 常见的坑(本讲的演示) |
|---|---|---|
| 工作负载 | 输入 / 输出长度的分布?到达过程?共享结构(系统提示、多轮)? | 反复使用小数据集让前缀缓存”作弊”(§5.5) |
| 指标与 SLO | TTFT、TPOT、卡顿、E2E 的哪个分位数?SLO 是多少? | 平均 TPOT 掩盖卡顿(§5.4) |
| 实验设计 | 开环还是闭环?多长?怎样去掉预热和收尾? | 固定请求个数(§5.2);闭环压测(§5.3) |
| 基线 | 和谁比?基线调过参吗? | 没调好的基线让结论反过来(§5.7) |
| 统计 | 几个随机种子?差别比噪声大吗? | 一个种子上的几个百分点(§5.6) |
公开的基准测试在这几个要素上做了明确的选择,可以作为参照。MLPerf Inference 的 LLM 测试用泊松到达的”server”场景,要求 P99 的 TTFT 和 TPOT 都不超过上限,报告满足约束时的最大吞吐——这就是 goodput 的思路;据公开资料,Llama-2-70B 的常规约束是 TTFT 2 秒、TPOT 200 毫秒,“interactive”一档收紧到 450 毫秒和 40 毫秒。SemiAnalysis InferenceMAX(现名 InferenceX)每当软件版本变化就重跑,报告的不是一个点,而是一条曲线:横轴是每个用户每秒拿到的 token(交互性),纵轴是每块 GPU 每秒的 token(吞吐),以及对应的每百万 token 成本——吞吐和交互性此消彼长,只报一个点没有意义。
5.2 实验长度与稳态
代码 03 演示 1,同一个引擎(全部打开)的 goodput,五种实验设计:
| 实验设计 | goodput | 相对 180 秒 |
|---|---|---|
| 固定 800 个根请求,统计全部请求 | 33.56 | 1.88× |
| 固定 800 个根请求,去掉前 25% | 28.16 | 1.58× |
| 固定 60 秒,去掉前 15 秒 | 22.54 | 1.26× |
| 同上 + 窗口末尾也要达标(本讲默认) | 19.36 | 1.09× |
| 固定 180 秒 + 窗口末尾也要达标 | 17.84 | 1.00× |
固定请求个数是最常见的错误:到达率越高,这些请求越快到齐——800 个根请求在 30 个/秒时不到半分钟就发完了,队列还没积累起来,实验就结束了;多轮会话的后几轮又在负载已经变轻时才到达,拉高了达标率。这正是我写第一版评测脚本时犯的错(固定 500 个根请求、负载参数略有不同,得到 37.6 个/秒)。
固定时长 + 稳态窗口好得多,但 60 秒仍然高估:到达率只比处理能力高一点时,队列在 45 秒的窗口里只长到一两秒,大部分请求还能达标。我的修正是再加一条判据——窗口最后 20% 的时间里到达的请求也要达标:如果系统在慢慢过载,队列在窗口末尾最长,这一段最先超标。加上以后,60 秒的结果和 180 秒相差不到一成(我另外试过 360 秒,与 180 秒相近)。第 7 讲用的”中位 TTFT 不超过上限”(Sarathi-Serve 的可持续条件)防的是同一个问题。最简单的检查方法是:把实验加长一倍,看结论变不变。
5.3 开环与闭环
很多压测工具有一个”并发数 = N”的模式:N 个虚拟用户,每个发一个请求、等它完成、立刻发下一个。这是闭环负载。代码 03 演示 2(只用聊天和长文档请求):
| 压测方式 | 请求/秒 | 输出 tok/s | TTFT P50 | TTFT P99 | N 与 吞吐 × 平均延迟 |
|---|---|---|---|---|---|
| 闭环 N = 32 | 10.6 | 2,715 | 0.010 | 0.19 | 32 vs 32 |
| 闭环 N = 128 | 17.9 | 4,587 | 0.022 | 0.30 | 128 vs 127 |
| 闭环 N = 256 | 22.0 | 5,657 | 0.049 | 0.46 | 256 vs 242 |
| 闭环 N = 384 | 21.5 | 5,485 | 0.095 | 29.86 | 384 vs 354 |
| 开环 14 个/秒 | 14.0 | 3,171 | 0.022 | 0.25 | |
| 开环 22 个/秒 | 22.0 | 5,379 | 0.097 | 0.69 | |
| 开环 26 个/秒 | 26.0 | 6,607 | 0.194 | 10.90 |
闭环里,“每秒多少请求”不是你设定的,而是系统自己的速度决定的:N 个用户、每个请求平均要 E2E 秒,吞吐就是 N / E2E——这就是 Little 定律(P4),表里最后一列两边基本相等。N 小于批的上限(256)时,加 N 只是让批变大;超过上限,多出来的用户只能排队,TTFT 的 P99 跳到半分钟。这个跳变是 N 造成的,不是流量造成的。一个”并发 128 时吞吐 X、延迟 Y”的闭环结果,没法告诉你系统在真实流量下的 goodput。
闭环还有一个更隐蔽的问题,叫 coordinated omission:服务器卡住时,闭环的用户都在等结果,不会有新请求在卡顿期间到达、排进队列,于是测到的尾延迟偏小。同一个引擎关掉分块 prefill(长文档的 prefill 会挡住 decode):闭环 N = 128 在每秒 14.4 个请求下测到的卡顿 P99 是 0.35 秒,开环在每秒 14 个请求下测到的是 0.63 秒——吞吐更低,卡顿反而更严重。
5.4 指标:平均值会掩盖卡顿
代码 03 演示 3,每秒 14 个根请求:
| 配置 | TPOT P50 | TPOT P90 | 卡顿 P99 | 卡顿 > 200 毫秒的请求 |
|---|---|---|---|---|
| 分块 prefill | 19.1 毫秒 | 24.1 毫秒 | 55 毫秒 | 0% |
| 不分块 | 31.9 毫秒 | 45.3 毫秒 | 1,552 毫秒 | 93% |
不分块时 TPOT 也变差了,但仍在 50 毫秒的 SLO 之内——按 TPOT 算,这些请求全部达标;而 93% 的请求经历过超过 200 毫秒的停顿,最坏的 1% 停了一秒半以上。TPOT 是整个回答的平均值,停顿被摊到几百个 token 上就看不出来了(第 7 讲已经看到:TPOT 的平均会掩盖停顿,P99 TBT 不会)。
论文卡片 Paper card · Etalon(原名 Metron)
- 问题:TTFT、TBT、TPOT、归一化延迟这些常用指标,要么只看单个 token(看不出整体速度),要么按 token 数平均(看不出卡顿和排队)。论文举例:一个系统 60% 的请求调度延迟超过 25 秒,另一个最长 15 秒,两者的归一化延迟却只差几百毫秒;一次 10 秒的停顿对 TPOT 的影响也很小。
- 关键想法:借用实时系统里”周期性任务的截止时间”:给每个 token 设一个截止时间(第一个 token 的截止时间由提示长度决定,之后按固定的期望速率),统计错过截止时间的比例——fluidity-index;提前生成的 token 可以”缓冲”,抵消后面的小停顿。
- 用途:同时反映排队、prefill 的时间和生成中的卡顿,适合比较流式输出的用户体验。
- 局限:要选定期望的生成速率和 TTFT 的截止时间,这本身是一种 SLO 的选择;对离线批处理负载没有意义。
5.5 数据:别让前缀缓存”作弊”
代码 03 演示 4,长文档为主的负载(60% 是文档问答):
| 文档来源 | goodput | 命中率 | 8 个/秒时 TTFT P50 |
|---|---|---|---|
| 每个请求一篇新文档 | 12.10 | 17% | 0.133 秒 |
| 从 20 篇文档里反复抽取 | 21.89 | 93% | 0.013 秒 |
很多压测工具从一个固定的数据集里抽提示;数据集小、请求多时,同一篇文档被反复问到,打开前缀缓存的引擎第二次见到它几乎不用 prefill,goodput 看起来高了八成、TTFT 好了十倍。真实流量里能共享的前缀(系统提示、对话历史、热门文档)是负载本身的性质:评测时要按真实的共享结构构造负载(或者用真实的 trace,例如第 9 讲 Mooncake 公开的 trace、Azure 的 LLM 推理 trace),并且报告命中率——命中率异常地高,往往说明负载有问题。
5.6 噪声:公共随机数与”显著”
代码 03 演示 5,只换负载的随机种子:
| 种子 | 全部打开 | 关掉最长前缀优先 | 相对 |
|---|---|---|---|
| 0 | 19.54 | 20.04 | 1.03× |
| 1 | 17.89 | 18.12 | 1.01× |
| 2 | 17.67 | 18.35 | 1.04× |
只换种子,“全部打开”的 goodput 就在 17.7 到 19.5 之间变化(约 ±5%)。比较两个配置时要让它们跑同一份负载(同一个种子,统计上叫”公共随机数”),比较同一种子下的相对值,再看这个相对值在多个种子上是否一致。
这一行还有一个更值得警惕的故事:我最初用不检查”窗口末尾也要达标”的 goodput 做同样的比较时,三个种子上都是关掉以后更低(低 4%–14%),现在三个种子上都是略高(1%–4%)。几个百分点的效应,连方向都会随评测方法改变——这样的结论只能写成”差别不显著”。
5.7 基线:调好它
代码 03 演示 6,2 块 H100 跑 Llama-3-8B:
| 配置 | TPOT ≤ 10 毫秒 | TPOT ≤ 50 毫秒 |
|---|---|---|
| DP2(两个引擎,各最多 256 个请求,缓存感知路由) | 11.80 | 39.12 |
| TP2(一个引擎,最多 256 个请求) | 19.79 | 33.62 |
| TP2(一个引擎,最多 512 个请求) | 19.79 | 44.38 |
如果两边都用默认的”每个引擎最多同时运行 256 个请求”,结论很漂亮:TPOT 严格时 TP2 好,宽松时 DP2 好——“SLO 决定并行方式”。但 TP2 只有一个引擎,它的批上限只有 DP2 两个引擎合起来的一半;把它调到 512 以后,TP2 在两种 SLO 下都更好(在我们的模型里,TP 只多了很小的 all-reduce 开销,却省下一份权重)。**前一个结论来自没调好的基线。**这是系统论文最常见的毛病之一:新系统精心调过,基线用默认参数。评测时要给每个被比较的系统同样认真地调参,并写明调了哪些参数。(真实 GPU 上 TP 的效率损失比我们的模型大,第 6 讲在真实规格下得到的是”TP 降延迟、DP 提吞吐”;这里的数字不能直接搬到真实部署上。)
5.8 模拟器的位置
整门课的性能数字都来自”纸上的 GPU”,这是有意的选择:它让我们在笔记本上几秒钟就能比较几十种配置。业界也这样做——
论文卡片 Paper card · Vidur
- 问题:为一个模型和一种负载找到成本最低、又满足 SLO 的部署配置(并行方式、调度器、批大小、chunk 大小、GPU 型号……),在真实 GPU 上逐个试太贵;而且最优配置随”模型 + 负载”变化,一个负载上的最优配置用到另一个负载上,成本可能差 2 倍(论文报告)。
- 关键想法:LLM 的结构高度相似,只需对少数几种算子(矩阵乘法、注意力的 prefill / decode、通信……)做性能剖析,再用随机森林回归插值到没测过的形状;在此之上模拟调度器的每一轮迭代(与本讲的
mini_engine同一种思路,只是算子时间来自实测而不是 roofline)。- 结果(论文报告):端到端延迟的预测误差小于 9%;为 LLaMA2-70B 找最优配置,在一台 CPU 机器上一小时完成,而逐个在 GPU 上试估计要 4.2 万 GPU 小时、约 21.8 万美元。
- 局限:需要先在目标硬件上剖析算子;模拟器只和它模型化了的东西一样准(新的内核、新的调度策略要加进去)。
模拟器的数字什么时候能信?经验是:用它比较”相对的好坏”和”机制”,用真实测量确认”绝对的数字”;模拟器里成立的结论,最好能在真实系统上用一两个点验证;凡是取决于假设值的结论(本课程里如 CPU 开销、两批重叠的阈值、反量化的代价),都要明说。
6. 开放问题 · Open problems
- 负载在快速变化。 推理模型的输出动辄几万 token,agent 一个任务调用几十次模型、上下文越积越长,RL 的 rollout 本身就是推理负载。输出长度未知(第 1 讲的事实 4)变得更尖锐:调度、KV 预留、批大小都要在不确定下做决定。程序级的调度(第 13 讲)和长尾的处理(第 15 讲)都还远没有定论。
- KV 是新的”数据”。 分层存放(第 9 讲)、压缩与量化(第 9 讲、附录讲 A)、跨请求 / 跨用户共享(以及随之而来的隐私和隔离问题)、把上下文”训练”成 KV(第 14 讲的 Cartridges)——KV 越来越像一个需要数据库式管理的对象。
- 模型与系统一起设计。 MoE(第 8 讲)、MLA、稀疏注意力与混合架构(第 10 讲)、多 token 预测、低精度(FP8 → FP4)都在改变推理的账本;每一种新结构都要重新设计缓存、调度和内核。哪些结构”对推理友好”,正在成为训练时的考虑。
- 解耦的粒度。 从 prefill / decode 解耦(第 7 讲)到注意力 / 专家解耦(第 8 讲 MegaScale-Infer),再到异构硬件、弹性伸缩、多模型共享 GPU(第 12 讲)——最优的切分方式随模型、负载和硬件变化,自动化的配置搜索(Vidur、DistServe 的搜索)还很初步。
- 确定性与正确性。 本课程反复使用的不变量是”无损优化不改变输出”,但在真实 GPU 上,同一个请求在不同的批大小下,数值计算的归约顺序不同,输出就可能不同——负载变了,答案就变了。Thinking Machines Lab 2025 年的一篇博客(Horace He,Defeating Nondeterminism in LLM Inference)指出主要原因不是常说的”并行浮点的不确定性”,而是内核的结果依赖于批大小,并给出了”与批无关”(batch-invariant)的内核。确定性有性能代价,在 RL 训练、评测和调试里却很重要。
- 评测本身。 标准化的负载、诚实的基线、能复现的结果、能耗(每兆瓦的 token 数正在成为指标)——§5 的每一个坑都还有人在踩。
7. 课程项目:选题与做法 · The course project
7.1 四个里程碑,对应本课程的代码
| 里程碑 | 要做的 | 本课程里可以先在纸上试的模块 | 正确性怎样验证 |
|---|---|---|---|
| 1. 数据并行与张量并行 | 多副本 + 路由;Megatron 式切分 | 第 6 讲 parallel_sim(TensorParallelLLM)、第 11 讲路由器 | 张量并行的输出与单卡在浮点误差内一致 |
| 2. 连续批处理与 PagedAttention | 迭代级调度、块表、抢占 | 第 2 讲 cb_engine、第 3 讲 paged_kv / paged_memory | 批处理的输出与逐个生成相同;块被正确回收 |
| 3. 上下文缓存与分块 prefill | 基数树、插入时机、token 预算 | 第 3 讲 radix_cache、第 7 讲 pd_engine、第 16 讲 sgl_runtime | 命中前缀时输出与完整 prefill 相同;分块与不分块输出相同 |
| 4. 进阶功能 | 投机解码 / 分层缓存 / PD 解耦 / 自选 | 第 5 讲 spec_decode、第 9 讲 context_cache、第 7 讲、附录讲 A | 投机采样的输出分布与目标模型相同;量化单独评估质量 |
7.2 做法上的建议
- 先写测试,再写优化。 每个无损优化都要有”和参考实现比输出”的测试(第 1 讲的不变量);把它们放进每次修改都会跑的测试集。后面的里程碑最容易悄悄破坏前面的正确性。
- 先算账,再动手。 用 roofline 和本课程的纸上模型估计这个特性在你的负载上最多值多少;如果估出来只有几个百分点,先想清楚瓶颈在哪。
- 把评测脚本当作项目的一部分。 开环负载、稳态窗口、goodput、成本、卡顿;阶梯和留一两种消融;多个种子。§5 的清单就是评分时会被问的问题。
- 选一个”有冲突”的进阶功能更有收获。 例如:投机解码 + 连续批处理(自适应 γ、显存记账)、分层缓存 + 前缀缓存(什么时候从 CPU 加载、什么时候重算)、PD 解耦 + 前缀缓存(缓存放哪一边)。§4.1 的表里每一行都可以是一个题目。
- 诚实地报告。 和预想不一样的结果、只在某种负载下成立的收益、依赖假设参数的结论,都写出来——本课程每一讲代码 README 里的”关于实验结果”就是这样做的。
7.3 一些更开放的题目
- 输出长度预测 + 调度:用一个便宜的预测器(甚至只用请求类型)做短作业优先,和第 2 讲的 SJF 比较,看预测误差的容忍度。
- 自适应投机解码:按当前批大小和每个请求的接受率选 γ(§4.2 显示门槛很重要)。
- 量化感知的调度:FP8 KV 让容量翻倍,最优的 max_running、chunk 大小要不要跟着变?
- 评测工具:实现一个开环负载生成器,支持真实 trace 的回放、稳态检测、fluidity-index。
- 确定性:在你的引擎里测量”同一个请求在不同批大小下输出是否相同”,找出是哪一步的归约造成的。
8. 小结 · Summary
- 整门课围绕五个基本事实:自回归 + KV cache、prefill 受限于算力而 decode 受限于带宽、批处理是第一杠杆而显存是天花板、输出长度不可预知、评价看负载和 SLO。十六讲分布在内核、单卡引擎、多 GPU、集群、应用五个层次和”模型—系统协同”上;贯穿始终的是三种思维方式:先找瓶颈、分清无损与近似、负载决定一切。
- 一个推理引擎的每一轮迭代分四步:接纳、增长、组批与计时、收尾。课程里的每个特性都在其中某一步起作用;把它们放进同一个引擎时,会撞上”兼容性”的问题——本讲的模块里就出现了前缀匹配与显存回收的顺序错误。
- 在一个混合负载上(代码 01):朴素引擎几乎不能用,连续批处理让 goodput 提高 46 倍;之后各项合起来再 2.11 倍,加上 FP8 共 3.96 倍,每百万 token 的成本从 0.230 美元降到 0.058 美元。阶梯和留一给出的”贡献”不同:分页 KV 在阶梯里只有 1.01 倍,留一时却值 22%。
- 特性之间会抢资源、有时序依赖、作用在同一个瓶颈上(代码 02):投机解码在 TPOT 严格时让 goodput 提高一半以上、宽松时没用;草稿模型吃掉约四分之一的 KV 容量;FP8 在缓存受限的负载上收益更大;前缀缓存和分块 prefill 的收益部分重叠;缓存感知路由提高命中率,但不一定提高 goodput。
- 评测的坑(代码 03):固定请求个数会把 goodput 高估六到九成,60 秒的窗口也要加”窗口末尾也达标”的判据;闭环压测的吞吐由并发数决定、还会掩盖卡顿;平均 TPOT 看不出卡顿;重复的提示让前缀缓存作弊;几个百分点的差别连方向都可能随方法改变;没调好的基线能把结论反过来。
- 开放问题集中在:变化中的负载(推理模型、agent、RL)、KV 的数据化管理、模型与系统的协同设计、解耦的粒度与自动配置、确定性,以及评测本身。
9. 常见误区 · Common pitfalls
- “把每篇论文的加速比乘起来,就是全部打开的加速比。” 收益取决于当时的瓶颈;分页在阶梯里 1.01 倍、留一时 1.29 倍(关掉它只剩 0.78)。
- “阶梯消融里增益为 1 的特性没用。” 它可能只是当时不是瓶颈;看留一的结果,或者换一个顺序。
- “投机解码总能降延迟、提吞吐。” 批大时验证变成受限于算力,草稿模型还要占显存;宽松的 SLO 下它不提高 goodput。
- “命中率越高越好。” 命中率高可能是负载在”作弊”(重复的提示),也可能是路由把请求挤在少数副本上;要看 goodput 和尾延迟。
- “闭环压测的并发数 = 系统能承受的流量。” 闭环的吞吐由并发数和系统速度共同决定,看不出排队爆炸,还会掩盖卡顿;要用开环负载找 goodput。
- “TPOT 达标,用户体验就好。” 一次一秒多的停顿摊到几百个 token 上几乎看不出来;看卡顿的分位数或 fluidity-index。
- “实验跑了几百个请求,够了。” 固定请求个数在高到达率下实验太短;用固定时长 + 稳态窗口,并把实验加长一倍检验。
- “我的系统比基线快 X 倍。” 先确认基线调过参(批上限、chunk 大小、并行方式),而且在同一份负载、多个种子上比较。
10. 练习 · Exercises
参考答案里的数字来自代码 01–03 的输出;练习 4 的数字来自我在临时副本上跑的
goodput(改了 SLO 和 chunk 大小,其余与代码 01 相同)。
练习 1(成本)。全部打开(不含 FP8)时 goodput 是每秒 19.54 个根请求,每个根请求平均输出 390 个 token,H100 每小时 3 美元。(a) 验证每百万 token 的成本约 0.109 美元。(b) 要把成本降到 0.05 美元,goodput 要到多少?FP8 够不够?
参考答案
(a) 19.54 × 390 ≈ 7,621 token/s;3 ÷ (7,621 × 3,600) × 10^6 ≈ 0.109 美元。(b) 需要 3 × 10^6 ÷ (0.05 × 3,600) ≈ 16,667 token/s,即每秒约 42.7 个根请求。FP8 的 goodput 是 36.73(0.058 美元),还差约 16%——可以再看看 W4A8KV4 一类更激进的量化(附录讲 A 代码 04),或者放宽 SLO。注意:成本是在”满足 SLO 的最大负载”下算的,真实服务的平均负载往往低得多,平均成本要更高。
练习 2(Little 定律)。闭环压测 N = 128 时,系统每秒完成 17.9 个请求。(a) 每个请求平均在系统里待多久?(b) 有人想用闭环压测证明”系统能承受每秒 30 个请求”,于是把 N 加到 1,000。会看到什么?为什么这个实验证明不了他想证明的东西?
参考答案
(a) 128 ÷ 17.9 ≈ 7.2 秒(表里”吞吐 × 平均延迟”一栏算出 127,与 N 基本相等)。(b) 引擎最多同时运行 256 个请求,处理能力大约每秒 22 个;N = 1,000 时,多出来的用户只能排队,吞吐停在每秒 22 个左右,平均延迟变成 1,000 ÷ 22 ≈ 45 秒,TTFT 的 P99 远超 SLO(N = 384 时 P99 已经是 30 秒)。闭环实验里吞吐永远不会超过处理能力,所以它既”证明不了能承受 30 个”,也不会出现开环时那种”队列无限增长”的崩溃——它只告诉你在 N 个用户下的稳态。要回答”能不能承受每秒 30 个”,要用到达率为 30 的开环负载。
练习 3(Shapley 值)。用 §4.4 的 2 × 2 表(关 / 开前缀缓存 × 不分块 / 分块):(a) 阶梯里先开前缀缓存、再开分块时,两者各”贡献”多少 goodput?反过来呢?(b) 用 Shapley 值(两种顺序的平均)分摊总收益。
参考答案
总收益 = 19.54 − 10.14 = 9.40。(a) 先前缀后分块:前缀 14.44 − 10.14 = 4.30,分块 19.54 − 14.44 = 5.10;先分块后前缀:分块 14.80 − 10.14 = 4.66,前缀 19.54 − 14.80 = 4.74。谁后开,谁拿到的多(两者的收益部分重叠,后开的那个拿到的是”在另一个已经开着时的边际收益”)。(b) 前缀:(4.30 + 4.74) / 2 = 4.52;分块:(5.10 + 4.66) / 2 = 4.88;相加正好是 9.40。特性多了以后,Shapley 值要对所有 n! 种顺序平均(或者抽样估计),代价是要跑很多组配置。
练习 4(chunk 的大小与 TTFT 的 SLO)。分块 prefill 的预算越小,decode 被打断得越少,但长文档要分越多轮才 prefill 完,TTFT 变长。在代码 01 的负载上,我把 chunk 设成 512 / 2,048 / 8,192 / 不分块,分别在 TTFT ≤ 2 秒和 TTFT ≤ 0.5 秒下求 goodput(TPOT ≤ 50 毫秒)。你预期会看到什么?
参考答案
实际结果(根请求 / 秒):
| chunk | TTFT ≤ 2 秒 | TTFT ≤ 0.5 秒 |
|---|---|---|
| 512 | 20.55 | 19.30 |
| 2,048 | 19.54 | 19.06 |
| 8,192 | 17.67 | 17.23 |
| 不分块 | 14.44 | 12.56 |
TTFT 宽松时,512 最好:TPOT 的余量最大(goodput 处 TPOT P90 只有 20 毫秒),批可以更大,代价是 TTFT P90 到了 1.2 秒。TTFT 收紧到 0.5 秒(还要求中位数 ≤ 0.25 秒)以后,512 的优势几乎消失,和 2,048 差不多。不分块在两种 SLO 下都明显更差——长文档的整段 prefill 让 TPOT 超标。chunk 大小是一个要按 SLO 调的旋钮;第 7 讲在 Sarathi-Serve 上看到的”预算太小也会变差”,在这里还没出现,因为同时 decode 的请求数(一两百)还小于 512。
练习 5(要不要开投机解码)。你要部署一个代码补全服务,SLO 是 TTFT ≤ 2 秒、TPOT ≤ 20 毫秒,高峰负载预计每秒 13 个根请求(与代码 02 的负载相同)。(a) 根据 §4.2 的表,开不开投机解码?门槛设多少?(b) 如果显存很紧(缓存放不下所有系统提示),还要考虑什么?
参考答案
(a) TPOT ≤ 20 毫秒时,不开的 goodput 是 11.95,低于高峰的 13;自适应(批 ≤ 128)是 15.57,够用。门槛不能设成 32——12 个/秒时平均批已经 47,门槛 32 会让投机解码在高峰时关掉。(b) 草稿模型的权重和 KV 吃掉约四分之一的 KV 容量(§4.3),命中率会下降;代码补全的提示常常很长、而且同一个文件反复补全,命中率掉下来的代价可能比投机解码省下的还多。应该在”缓存放不下”的真实负载上重新测,并考虑 FP8 KV(容量翻倍)或更小的草稿(例如 EAGLE 式的单层草稿头)。
练习 6(思考:给里程碑 3 设计评测)。你实现了前缀缓存和分块 prefill。写出评测计划:负载、指标、实验设计、基线、统计各怎么定?要防哪些 §5 里的坑?
参考答案(要点)
负载:至少三种共享结构——共享系统提示、多轮会话、无共享的长文档(作对照),命中率要报告出来;不要从小数据集里反复抽提示。指标:goodput(TTFT、TPOT 的 SLO 写清楚)、卡顿的 P99(分块 prefill 的主要收益在这里)、命中率、成本。实验设计:开环泊松到达、固定时长、稳态窗口,并把实验加长一倍检验;扫描一串到达率画出延迟—负载曲线,而不是只报一个点。基线:同一个引擎关掉前缀缓存、关掉分块(留一),再加上从朴素引擎开始的阶梯;chunk 大小、max_running 要为每个配置调过。统计:至少三个种子,同一种子下比较相对值。正确性:命中前缀时、分块时,输出与完整 prefill 逐 token 相同。
练习 7(思考:批大小会改变答案吗)。本课程的不变量是”无损优化不改变输出”。(a) 在真实 GPU 上,为什么同一个请求在批大小为 1 和批大小为 64 时,贪心解码的输出可能不同?(b) 你会怎样在自己的引擎里检测这个问题?什么场景下必须解决它?
参考答案(要点)
(a) 浮点加法不满足结合律;矩阵乘法、注意力、归一化的内核会按批大小选择不同的切分和归约顺序(例如 split-K、split-KV 的段数),于是同一个请求的 logits 在最后几位上不同;贪心解码在前两名 logit 很接近时就会选出不同的 token,之后一路分叉(附录讲 A 演示 4 看到的”翻转”是同一种现象)。(b) 同一个请求,分别单独跑和夹在不同大小的批里跑,逐 token 比较输出(或比较 logits 的最大差);找出哪一个内核的结果随批变化。RL 训练(推理和训练要对得上)、可复现的评测、调试和审计都需要确定性;办法是用与批无关的内核(固定归约顺序),代价是一些性能。
11. 延伸阅读 · Further reading
本讲依据的材料
- CS349D 课程网站的项目说明(Mini Serving Engine 的四个里程碑):https://web.stanford.edu/class/cs349d/。
- A. Agrawal, N. Kedia, J. Mohan, A. Panwar, N. Kwatra, B. S. Gulavani, R. Ramjee, A. Tumanov. Vidur: A Large-Scale Simulation Framework for LLM Inference. MLSys 2024(arXiv:2405.05465)。
- A. Agrawal, A. Agarwal, N. Kedia, J. Mohan, S. Kundu, N. Kwatra, R. Ramjee, A. Tumanov. Etalon: Holistic Performance Evaluation Framework for LLM Inference Systems. arXiv:2407.07000(早期版本名为 Metron)。
- MLPerf Inference 的 LLM 基准规则(MLCommons);SemiAnalysis InferenceMAX / InferenceX 的说明页(inferencex.semianalysis.com)。
评测与方法
- G. Tene. How NOT to Measure Latency(关于 coordinated omission 的经典演讲)。
- 前置讲 P4:排队论、开环与闭环、Little 定律。
- H. He / Thinking Machines Lab. Defeating Nondeterminism in LLM Inference. 2025-09(与批无关的内核)。
回顾各讲
- 单卡引擎:第 2 讲(Orca)、第 3 讲(vLLM、SGLang)、第 5 讲(投机解码)、第 7 讲(Sarathi-Serve、DistServe)、第 16 讲(SGLang 的工程)。
- 规模化:第 6 讲(并行)、第 8 讲(MoE)、第 9 讲(Mooncake)、第 11 讲(Preble)、第 12 讲(多租户)。
- 应用与新负载:第 13 讲(agent)、第 14 讲(记忆)、第 15 讲(RL)。
12. 结语 · Closing
十七讲走下来,一个推理系统的问题几乎都可以还原成同一个句式:在某个负载和 SLO 下,这一步的瓶颈是什么,怎样在不改变(或可控地改变)输出的前提下绕过它。 新的模型、新的硬件、新的负载会不断改变瓶颈的位置,但”先算账、再动手、诚实地评测”的方法不会过时。
本课程还有一篇可选的附录讲 A:量化与低精度推理——原课的阅读清单没有单独讲它,但它是实践中最常用的推理优化之一:本讲里单项收益最大的 FP8,它的代价(精度)和它的变体(4 位权重、8 位激活、4 位 KV、Blackwell 的 FP4)都在那里展开。