Lecture 17 · 总结:拼出完整的迷你推理引擎,怎样评测它,以及开放问题 · Wrap-up

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

课程源码 ZIP ↓

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

本篇目录

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

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

  1. 用”五个基本事实 + 两条主线 + 五个层次”把整门课串起来,说出每一讲在解决哪个瓶颈。
  2. 画出一个完整推理引擎的结构(调度、KV 管理、模型执行、路由),说清楚课程里的每个特性在一轮迭代的哪一步起作用。
  3. 在同一个负载上评测一组特性:用 goodput 和每百万 token 的成本,而不是单个吞吐数字;同时给出”逐个打开”和”从全开里逐个关掉”两种消融,并解释它们为什么不一致。
  4. 列举特性之间的典型冲突与互补(投机解码 × 批大小 / 显存 / SLO,前缀缓存 × 分块 prefill / 路由 / 解耦,量化 × 缓存容量……)。
  5. 设计一个不容易自欺欺人的评测:开环负载、足够长的稳态窗口、合适的指标(包括卡顿)、真实的共享结构、多个随机种子、认真调过的基线。
  6. 说出这个领域还没解决的问题,并为课程项目选一个合适的题目。

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 讲列出了整门课的出发点,回头看,每一讲都是在和其中一两条”讨价还价”:

  1. 自回归生成必须一步一步做,每个请求带着一份随生成增长的 KV cache。→ 第 5 讲(投机解码:一步验证多个 token)、第 10 讲(换掉会增长的状态)、第 14 讲(上下文就是记忆)。
  2. prefill 受限于算力,decode 受限于带宽;batch = 1 的 decode 只用到不到 1% 的算力。→ 第 2 讲(批处理)、第 4 讲(注意力内核)、第 7 讲(把两个阶段分开)、附录讲 A(少读字节)。
  3. 批处理是第一杠杆,显存(KV cache)是批大小的天花板。 → 第 3 讲(分页、前缀共享)、第 8 讲(MoE 的批被专家摊薄)、第 9 讲(分层存放 KV)。
  4. 输出长度事先未知,请求级的执行时间不可预测,只能每一步重新调度。→ 第 2 讲(迭代级调度)、第 13 讲(程序级调度)、第 15 讲(RL 里的长尾)。
  5. 衡量推理系统要看延迟、吞吐、成本和 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 / RadixAttentionKV 预留与碎片、前缀重复计算显存紧张时分页是按上限预留的 5.2 倍;GQA + H100 时收益小得多
第 5 讲 投机解码decode 每步只出 1 个 token批小时目标模型验证 γ + 1 个 token 和 decode 1 个差不多贵;批大时不划算
第 16 讲 SGLangCPU 开销、各种特性的组合重叠调度在完整负载上 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 讲 RLrollout 的长尾最后 10% 的样本占一半以上的生成时间
协同设计第 8、10 讲,附录讲 A模型结构与系统一起改DSA 在长上下文把注意力代价降 7–8 倍、短上下文反而更慢

1.3 三种贯穿全课的思维方式

  1. 先找瓶颈,再谈优化。 每一讲开头都在问同一个问题:这一步的时间花在哪——算力、带宽、显存容量、CPU、通信,还是排队?roofline(第 1 讲)、α–β 模型(第 6 讲)、Little 定律(P4)都是”找瓶颈”的工具。一个优化只在它针对的瓶颈正好是当前瓶颈时才有收益——这句话会在 §3 里被反复验证。
  2. 分清”无损”和”近似”。 连续批处理、分页、前缀缓存、FlashAttention、分块 prefill、推测采样都不改变输出的分布(第 1 讲的不变量:与参考实现在浮点误差内一致);量化、稀疏注意力、压缩 KV、草稿模型的”贪心接受”则用精度换速度,必须单独评估质量(附录讲 A §6)。
  3. 负载决定一切。 前缀缓存在少样本负载上值两三倍、在无共享负载上一文不值(第 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 写一个能同时打开所有特性的引擎,我踩到的坑

写这个模块时,我自己就撞上了”特性之间不兼容”的问题,都是在把两个特性放在一起时才出现的:

  1. 前缀缓存 × 显存回收:接纳一个请求时,先在基数树里匹配前缀,再为它腾显存(淘汰 LRU 叶子),最后才锁住匹配到的节点——显存极紧时,腾显存这一步会把刚刚匹配到的前缀自己淘汰掉,于是这个请求以为自己命中了、其实那段 KV 已经没了。正确的顺序是匹配后立刻加锁、放不下再解锁。第 16 讲的运行时也有同样的写法;那一讲的实验里刚匹配的节点在 LRU 顺序里排在最后、从来没被淘汰到,输出不受影响,但我已经一并改正。
  2. 分页 × 基数树的计量单位:分页按块(16 个 token)分配,基数树按 token 存放。一个请求 prefill 完成时,它的提示从”私有的块”变成”树里的 token”,两边取整的方式不同,于是每一轮的显存增长要逐个请求按”取整之后差多少”计算,否则占用会偶尔超出容量。
  3. 评测 × 有限长的实验:第一版评测固定用 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,0111,7222631,500 token 的系统提示
多轮会话(2–4 轮,下一轮在上一轮结束后 1–4 秒到达)3,6811,138241之前的对话
长文档问答(2,000–8,000 token 的独有文档)7715,104187无

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美元 / 百万 tokenTTFT P90TPOT P90命中率平均批
朴素引擎(静态批、按上限预留)0.20—7810.6621.79 秒7 毫秒0%0
+ 连续批处理9.2846.36×3,6240.2301.10390%69
+ 分页 KV9.401.01×3,6700.2270.23460%77
+ 前缀缓存12.411.32×4,8440.1720.194054%87
+ 最长前缀优先12.411.00×4,8440.1720.194054%87
+ 分块 prefill18.581.50×7,2540.1150.484156%149
+ 重叠调度19.541.05×7,6300.1090.423856%152
+ FP8 权重与 KV36.731.88×14,3400.0580.291957%173

(TTFT、TPOT、命中率、批都是在 goodput 处测的。)

几件值得停下来想的事:

  1. 朴素引擎几乎不能用。 静态批要等批里最长的请求写完才接新请求——输出最长 2,000 个 token,一批可能跑十几秒——后到的请求排在后面,TTFT 动辄超过 2 秒。它只能在每秒 0.2 个根请求的流量下达标。连续批处理让 goodput 提高 46 倍:这一步是”从不能用到能用”,后面每一步都是在它的基础上乘一个系数。
  2. 有两步的增益是 1.00–1.01 倍。 这不是说分页和最长前缀优先没用,而是说在那个阶段,卡住 goodput 的是别的东西。看 TPOT P90:加上分页以后已经到了 46 毫秒,离 50 毫秒的 SLO 只差一点——瓶颈是”不分块的长文档 prefill 一整步挡住所有 decode”,显存还不是瓶颈;分页让批稍大了一点(69 → 77),也让 TTFT 好了很多(1.10 → 0.23 秒),但 goodput 被 TPOT 卡住了。
  3. 分块 prefill 一步就是 1.5 倍:它把长 prefill 切成 2,048 token 一块,decode 不再被整段挡住,TPOT 的余量让批从 87 涨到 149。代价写在 TTFT 上(0.19 → 0.48 秒)——长文档要分好几轮才 prefill 完。
  4. FP8 是单项最大的(1.88 倍):权重和 KV 的字节数减半、KV 容量多一倍多、算力翻倍。但它是唯一改变输出的一项,要单独评估质量(附录讲 A)。
  5. 从”连续批处理”到”全部打开”(不含 FP8)是 2.11 倍,加上 FP8 是 3.96 倍;每百万 token 的成本从 0.230 美元降到 0.109 美元、再到 0.058 美元。和第 16 讲的结论一致:单看每一项都不算大,合起来是好几倍。

3.4 留一:从全部打开出发,每次只关掉一项

配置goodput相对全开阶梯里这一步的增益
全部打开19.541.00×—
关掉连续批处理(静态批)0.220.01×46.36×
关掉分页 KV(按上限预留)15.180.78×1.01×
关掉前缀缓存14.800.76×1.32×
关掉最长前缀优先20.041.03×1.00×
关掉分块 prefill14.440.74×1.50×
关掉重叠调度18.580.95×1.05×
加上投机解码(批 ≤ 128 时开)19.541.00×—
加上 FP836.731.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 / 24.0 / 14.0 / 14.0 / 1
8 个/秒9.8 / 225.7 / 145.7 / 145.7 / 14
12 个/秒15.1 / 4814.9 / 478.8 / 308.8 / 30
20 个/秒34.0 / 15934.1 / 15934.1 / 15631.5 / 151

不同 TPOT SLO 下的 goodput:

TPOT SLO不开自适应(批 ≤ 128)一直开
10 毫秒6.6010.1410.14
20 毫秒11.9515.5715.57
50 毫秒19.5419.5419.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
BF16426,78411.9573%1.00×
BF16 + 投机解码323,13512.2569%1.03×
FP8976,10025.8082%2.16×
FP8 + 投机解码762,58826.4681%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.1414.80
前缀缓存开14.4419.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 P90TPOT P90达标
轮询60 个/秒46%0.2029100%
最少请求6046%0.1927100%
缓存感知(负载差 ≤ 4)6055%0.1925100%
缓存感知(负载差 ≤ 32)6056%0.2434100%
轮询9046%0.994191%
缓存感知(负载差 ≤ 4)9054%0.774091%

缓存感知路由把多轮会话的下一轮送回它的历史所在的副本,命中率从 46% 提高到 55% 左右;负载保护放得太松时,热门前缀把请求集中到少数副本,TPOT 变差。但即使命中率提高了,90 个/秒时的达标率也和轮询一样——这个负载的瓶颈是 decode 读 KV 和权重,多出来的命中省下的 prefill 不是限制 goodput 的那一项。第 11 讲的教训在这里重现:先保证负载均衡,再在差不多闲的副本之间挑缓存最多的;路由的收益同样取决于瓶颈在哪。


5. 怎样评测一个推理系统 · How to evaluate an inference system

5.1 一次评测的五个要素

要素要回答的问题常见的坑(本讲的演示)
工作负载输入 / 输出长度的分布?到达过程?共享结构(系统提示、多轮)?反复使用小数据集让前缀缓存”作弊”(§5.5)
指标与 SLOTTFT、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.561.88×
固定 800 个根请求,去掉前 25%28.161.58×
固定 60 秒,去掉前 15 秒22.541.26×
同上 + 窗口末尾也要达标(本讲默认)19.361.09×
固定 180 秒 + 窗口末尾也要达标17.841.00×

固定请求个数是最常见的错误:到达率越高,这些请求越快到齐——800 个根请求在 30 个/秒时不到半分钟就发完了,队列还没积累起来,实验就结束了;多轮会话的后几轮又在负载已经变轻时才到达,拉高了达标率。这正是我写第一版评测脚本时犯的错(固定 500 个根请求、负载参数略有不同,得到 37.6 个/秒)。

固定时长 + 稳态窗口好得多,但 60 秒仍然高估:到达率只比处理能力高一点时,队列在 45 秒的窗口里只长到一两秒,大部分请求还能达标。我的修正是再加一条判据——窗口最后 20% 的时间里到达的请求也要达标:如果系统在慢慢过载,队列在窗口末尾最长,这一段最先超标。加上以后,60 秒的结果和 180 秒相差不到一成(我另外试过 360 秒,与 180 秒相近)。第 7 讲用的”中位 TTFT 不超过上限”(Sarathi-Serve 的可持续条件)防的是同一个问题。最简单的检查方法是:把实验加长一倍,看结论变不变。

5.3 开环与闭环

很多压测工具有一个”并发数 = N”的模式:N 个虚拟用户,每个发一个请求、等它完成、立刻发下一个。这是闭环负载。代码 03 演示 2(只用聊天和长文档请求):

压测方式请求/秒输出 tok/sTTFT P50TTFT P99N 与 吞吐 × 平均延迟
闭环 N = 3210.62,7150.0100.1932 vs 32
闭环 N = 12817.94,5870.0220.30128 vs 127
闭环 N = 25622.05,6570.0490.46256 vs 242
闭环 N = 38421.55,4850.09529.86384 vs 354
开环 14 个/秒14.03,1710.0220.25
开环 22 个/秒22.05,3790.0970.69
开环 26 个/秒26.06,6070.19410.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 P50TPOT P90卡顿 P99卡顿 > 200 毫秒的请求
分块 prefill19.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.1017%0.133 秒
从 20 篇文档里反复抽取21.8993%0.013 秒

很多压测工具从一个固定的数据集里抽提示;数据集小、请求多时,同一篇文档被反复问到,打开前缀缓存的引擎第二次见到它几乎不用 prefill,goodput 看起来高了八成、TTFT 好了十倍。真实流量里能共享的前缀(系统提示、对话历史、热门文档)是负载本身的性质:评测时要按真实的共享结构构造负载(或者用真实的 trace,例如第 9 讲 Mooncake 公开的 trace、Azure 的 LLM 推理 trace),并且报告命中率——命中率异常地高,往往说明负载有问题。

5.6 噪声:公共随机数与”显著”

代码 03 演示 5,只换负载的随机种子:

种子全部打开关掉最长前缀优先相对
019.5420.041.03×
117.8918.121.01×
217.6718.351.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.8039.12
TP2(一个引擎,最多 256 个请求)19.7933.62
TP2(一个引擎,最多 512 个请求)19.7944.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

  1. 负载在快速变化。 推理模型的输出动辄几万 token,agent 一个任务调用几十次模型、上下文越积越长,RL 的 rollout 本身就是推理负载。输出长度未知(第 1 讲的事实 4)变得更尖锐:调度、KV 预留、批大小都要在不确定下做决定。程序级的调度(第 13 讲)和长尾的处理(第 15 讲)都还远没有定论。
  2. KV 是新的”数据”。 分层存放(第 9 讲)、压缩与量化(第 9 讲、附录讲 A)、跨请求 / 跨用户共享(以及随之而来的隐私和隔离问题)、把上下文”训练”成 KV(第 14 讲的 Cartridges)——KV 越来越像一个需要数据库式管理的对象。
  3. 模型与系统一起设计。 MoE(第 8 讲)、MLA、稀疏注意力与混合架构(第 10 讲)、多 token 预测、低精度(FP8 → FP4)都在改变推理的账本;每一种新结构都要重新设计缓存、调度和内核。哪些结构”对推理友好”,正在成为训练时的考虑。
  4. 解耦的粒度。 从 prefill / decode 解耦(第 7 讲)到注意力 / 专家解耦(第 8 讲 MegaScale-Infer),再到异构硬件、弹性伸缩、多模型共享 GPU(第 12 讲)——最优的切分方式随模型、负载和硬件变化,自动化的配置搜索(Vidur、DistServe 的搜索)还很初步。
  5. 确定性与正确性。 本课程反复使用的不变量是”无损优化不改变输出”,但在真实 GPU 上,同一个请求在不同的批大小下,数值计算的归约顺序不同,输出就可能不同——负载变了,答案就变了。Thinking Machines Lab 2025 年的一篇博客(Horace He,Defeating Nondeterminism in LLM Inference)指出主要原因不是常说的”并行浮点的不确定性”,而是内核的结果依赖于批大小,并给出了”与批无关”(batch-invariant)的内核。确定性有性能代价,在 RL 训练、评测和调试里却很重要。
  6. 评测本身。 标准化的负载、诚实的基线、能复现的结果、能耗(每兆瓦的 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. 先写测试,再写优化。 每个无损优化都要有”和参考实现比输出”的测试(第 1 讲的不变量);把它们放进每次修改都会跑的测试集。后面的里程碑最容易悄悄破坏前面的正确性。
  2. 先算账,再动手。 用 roofline 和本课程的纸上模型估计这个特性在你的负载上最多值多少;如果估出来只有几个百分点,先想清楚瓶颈在哪。
  3. 把评测脚本当作项目的一部分。 开环负载、稳态窗口、goodput、成本、卡顿;阶梯和留一两种消融;多个种子。§5 的清单就是评分时会被问的问题。
  4. 选一个”有冲突”的进阶功能更有收获。 例如:投机解码 + 连续批处理(自适应 γ、显存记账)、分层缓存 + 前缀缓存(什么时候从 CPU 加载、什么时候重算)、PD 解耦 + 前缀缓存(缓存放哪一边)。§4.1 的表里每一行都可以是一个题目。
  5. 诚实地报告。 和预想不一样的结果、只在某种负载下成立的收益、依赖假设参数的结论,都写出来——本课程每一讲代码 README 里的”关于实验结果”就是这样做的。

7.3 一些更开放的题目

  • 输出长度预测 + 调度:用一个便宜的预测器(甚至只用请求类型)做短作业优先,和第 2 讲的 SJF 比较,看预测误差的容忍度。
  • 自适应投机解码:按当前批大小和每个请求的接受率选 γ(§4.2 显示门槛很重要)。
  • 量化感知的调度:FP8 KV 让容量翻倍,最优的 max_running、chunk 大小要不要跟着变?
  • 评测工具:实现一个开环负载生成器,支持真实 trace 的回放、稳态检测、fluidity-index。
  • 确定性:在你的引擎里测量”同一个请求在不同批大小下输出是否相同”,找出是哪一步的归约造成的。

8. 小结 · Summary

  1. 整门课围绕五个基本事实:自回归 + KV cache、prefill 受限于算力而 decode 受限于带宽、批处理是第一杠杆而显存是天花板、输出长度不可预知、评价看负载和 SLO。十六讲分布在内核、单卡引擎、多 GPU、集群、应用五个层次和”模型—系统协同”上;贯穿始终的是三种思维方式:先找瓶颈、分清无损与近似、负载决定一切。
  2. 一个推理引擎的每一轮迭代分四步:接纳、增长、组批与计时、收尾。课程里的每个特性都在其中某一步起作用;把它们放进同一个引擎时,会撞上”兼容性”的问题——本讲的模块里就出现了前缀匹配与显存回收的顺序错误。
  3. 在一个混合负载上(代码 01):朴素引擎几乎不能用,连续批处理让 goodput 提高 46 倍;之后各项合起来再 2.11 倍,加上 FP8 共 3.96 倍,每百万 token 的成本从 0.230 美元降到 0.058 美元。阶梯和留一给出的”贡献”不同:分页 KV 在阶梯里只有 1.01 倍,留一时却值 22%。
  4. 特性之间会抢资源、有时序依赖、作用在同一个瓶颈上(代码 02):投机解码在 TPOT 严格时让 goodput 提高一半以上、宽松时没用;草稿模型吃掉约四分之一的 KV 容量;FP8 在缓存受限的负载上收益更大;前缀缓存和分块 prefill 的收益部分重叠;缓存感知路由提高命中率,但不一定提高 goodput。
  5. 评测的坑(代码 03):固定请求个数会把 goodput 高估六到九成,60 秒的窗口也要加”窗口末尾也达标”的判据;闭环压测的吞吐由并发数决定、还会掩盖卡顿;平均 TPOT 看不出卡顿;重复的提示让前缀缓存作弊;几个百分点的差别连方向都可能随方法改变;没调好的基线能把结论反过来。
  6. 开放问题集中在:变化中的负载(推理模型、agent、RL)、KV 的数据化管理、模型与系统的协同设计、解耦的粒度与自动配置、确定性,以及评测本身。

9. 常见误区 · Common pitfalls

  1. “把每篇论文的加速比乘起来,就是全部打开的加速比。” 收益取决于当时的瓶颈;分页在阶梯里 1.01 倍、留一时 1.29 倍(关掉它只剩 0.78)。
  2. “阶梯消融里增益为 1 的特性没用。” 它可能只是当时不是瓶颈;看留一的结果,或者换一个顺序。
  3. “投机解码总能降延迟、提吞吐。” 批大时验证变成受限于算力,草稿模型还要占显存;宽松的 SLO 下它不提高 goodput。
  4. “命中率越高越好。” 命中率高可能是负载在”作弊”(重复的提示),也可能是路由把请求挤在少数副本上;要看 goodput 和尾延迟。
  5. “闭环压测的并发数 = 系统能承受的流量。” 闭环的吞吐由并发数和系统速度共同决定,看不出排队爆炸,还会掩盖卡顿;要用开环负载找 goodput。
  6. “TPOT 达标,用户体验就好。” 一次一秒多的停顿摊到几百个 token 上几乎看不出来;看卡顿的分位数或 fluidity-index。
  7. “实验跑了几百个请求,够了。” 固定请求个数在高到达率下实验太短;用固定时长 + 稳态窗口,并把实验加长一倍检验。
  8. “我的系统比基线快 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 毫秒)。你预期会看到什么?

参考答案

实际结果(根请求 / 秒):

chunkTTFT ≤ 2 秒TTFT ≤ 0.5 秒
51220.5519.30
2,04819.5419.06
8,19217.6717.23
不分块14.4412.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)都在那里展开。

← 返回课程目录