对应 Maps to:Stanford CS349D AI Inference Infrastructure(Spring 2026),#5 Mon Apr 13 Batching。必读:G.-I. Yu, J. S. Jeong, G.-W. Kim, S. Kim, B.-G. Chun, Orca: A Distributed Serving System for Transformer-Based Generative Models(OSDI 2022);D. Crankshaw, X. Wang, G. Zhou, M. J. Franklin, J. E. Gonzalez, I. Stoica, Clipper: A Low-Latency Online Prediction Serving System(NSDI 2017)。 依据说明:原课没有公开 slides。本讲依据两篇必读论文的全文来写。§6.1 引用的”真正存放 token 状态的显存只有 20.4%–38.2%“来自第 3 讲的必读论文 vLLM(它重新实现了三种版本的 Orca 来测量)。§7”之后的发展”是我补充的。 配套代码 Code:
code/lecture02/(numpy + 标准库;4 个脚本 + 2 个模块:ragged_batch.py在真实的迷你模型上做选择性批处理,cb_engine.py是迭代级调度的模拟器;最慢的03_continuous_batching.py约 30 秒) 预计阅读时间:3–4 小时(含运行代码)
0. 本讲导航 · How to read this lecture
0.1 学习目标 Learning goals
学完这一讲,你应该能够:
- 说清楚批处理分摊的是什么(读权重、固定开销),不分摊的是什么(计算量、KV cache),以及批处理在延迟上的代价。
- 解释 Clipper 的三个批处理设计:按模型的延迟画像定批大小、用 AIMD 在线自适应、延迟凑批。能判断什么时候值得凑批。
- 复述 Orca 指出的两个问题:先完成的请求走不了、新到的请求进不来;同一步里的请求形状各不相同。
- 解释 Orca 的两个技术:迭代级调度(iteration-level scheduling,今天叫连续批处理 continuous batching)和选择性批处理(selective batching)。说明为什么”注意力拆开算”代价很小。
- 读懂 Orca 的调度算法:迭代级 FCFS、max_bs、按 max_tokens 预留 KV 槽位。知道它为什么会浪费显存(第 3 讲的出发点)。
- 用迷你引擎验证:连续批处理的输出和逐个生成完全相同;在同一个 SLO 下,它能承受的流量是静态批处理的几百倍。
- 理解连续批处理的几个旋钮(KV 预留、max_bs、调度顺序、prefill 的插入方式)各自在权衡什么。
0.2 先修与本讲范围 Prerequisites & scope
| 默认你已经会 Assumed | 本讲只给直觉,后面再展开 Previewed only |
|---|---|
| 第 1 讲:prefill / decode、KV cache、roofline、TTFT / TPOT / goodput、静态批处理的排队实验 | KV cache 的分页管理与抢占(第 3 讲;前置讲 P3 先讲操作系统的分页) |
| 建议先读前置讲 P1(GPU)、P2(推理账本)、P4(排队论) | 注意力内核怎样同时处理一批长度不同的请求(第 4 讲) |
| 张量 / 流水线并行(第 6 讲;前置讲 P5) | |
| prefill 与 decode 的干扰:分块 prefill、解耦(第 7 讲) |
0.3 本讲地图 Map
§1 为什么批处理,以及它为什么在 LLM 上变难
§2 Clipper:CNN 时代的自适应批处理(延迟画像、AIMD、延迟凑批、论文卡片、放到 LLM 上看) ← 代码 01
§3 请求级批处理为什么在 LLM 上失灵(Orca 的两个挑战) ← 代码 02 演示 1
§4 Orca:迭代级调度、选择性批处理、调度算法、分布式执行、评测、论文卡片 ← 代码 02
§5 在迷你引擎上实现连续批处理:正确性、模拟器、和静态批处理比、goodput、成本 ← 代码 02 演示 4、代码 03
§6 连续批处理的旋钮:KV 预留、max_bs、调度顺序、prefill 怎样插进来 ← 代码 04
§7 之后的发展
§8–12 小结、常见误区、练习、延伸阅读、下一讲预告
0.4 记号 Notation
沿用第 1 讲的记号(、、、、TTFT、TPOT、SLO……),新增:
| 符号 / 术语 | 含义 |
|---|---|
| 迭代(iteration)/ 步(step) | 模型的一次 forward。prefill 是一次迭代,每个 decode token 也是一次迭代 |
| max_bs | 同时运行(同一步里)的最大请求数,Orca 的 “max batch size” |
| ITL | 相邻两个输出 token 的时间间隔(第 1 讲 §4.1)。TPOT 是它的平均值,ITL 的 P99 / 最大值反映”卡顿” |
| 延迟画像(latency profile) | 处理一批的时间随批大小变化的函数,Clipper 用 近似 |
| KV 槽位(slot) | 存一个 token 的 K 和 V 所需的显存(Orca 的说法) |
0.5 关于数字 · About the numbers
和第 1 讲一样,有三类数字:(1) 纸上 GPU 的模拟(代码 01 演示 4、代码 03、04):数量级与趋势可信,不是实测;(2) CPU 实测(代码 02 演示 3):来自编写本讲时的 Apple M4 Pro,你的机器会不同;(3) 玩具模型(代码 01 演示 1–3 的三个”模型”):延迟画像是我设定的参数,量级参考 Clipper 论文,只用来说明机制。论文里的数字写成”论文报告”。
1. 为什么批处理,以及它为什么在 LLM 上变难 · Why batching, and why it’s hard for LLMs
1.1 回顾:批处理分摊的是什么
第 1 讲的结论:batch = 1 的 decode 每生成一个 token,都要把全部权重从显存读一遍,算力利用率不到 1%。把 个请求放进同一次 forward,权重只读一遍,就能为 个请求各产出一个 token。
代码 02 演示 2 在真实的迷你模型上把这件事数得清清楚楚:5 个请求,一次批量 forward 和逐个 forward 相比——
| 选择性批处理(一次) | 逐个计算(5 次) | 倍数 | |
|---|---|---|---|
| FLOPs | 10.3 M | 10.3 M | 1.00× |
| 读权重字节 | 425 KB | 2.08 MB | 4.88× |
| 读写 KV 字节 | 254 KB | 254 KB | 1.00× |
批处理不会让计算变少,也不会让 KV 的读写变少,它只省下了重复读权重。 但 decode 的时间几乎全花在读权重上,所以这一项就是批处理提升吞吐的全部来源。在 CPU 上实测(代码 02 演示 3,一个 1300 万参数的模型,64 个 decode 请求):逐个算 37 ms,拼成一批 8.2 ms,快 4.5 倍。
1.2 批处理的代价
批处理不是免费的,代价都落在延迟上:
- 凑批要等。请求是陆续到达的,要凑成一批,先到的就得等后到的。
- 批越大,每一步越慢。第 1 讲代码 02 演示 4:Llama-3-8B 的 decode 批从 1 涨到 256,每步从 6 ms 涨到 19 ms。每一步就是每个请求的一个 token,所以 TPOT 跟着变大。
- 一批同进同出。如果一批要一起开始、一起结束,先完成的要陪着最慢的,后到的要等整批结束。第 1 讲代码 03 看到的正是这个:静态批处理的座位浪费 60%–80%,TTFT 很差。
所以批处理的核心问题是:一批放多少、什么时候发出、谁和谁放在一起。这一讲的两篇论文分别在两个时代回答了这个问题。
1.3 两个时代的批处理
| Clipper(2017,CNN 时代) | Orca(2022,LLM 时代) | |
|---|---|---|
| 一个请求 | 一次 forward(一张图 → 一个类别) | 一次 prefill + 很多次 decode |
| 一批 | 凑够一批输入,算一次就结束 | 跨越很多次迭代,而且每个请求需要的迭代数不同、事先未知 |
| 关键问题 | 每个模型的批该多大?要不要等一等再发? | 怎样让请求在批里随时进出?形状不同的请求怎样一起算? |
| 调度单位 | 一批请求 | 一次迭代 |
2. Clipper:CNN 时代的自适应批处理 · Clipper
2.1 背景与架构
2017 年前后,机器学习系统的研究大多集中在训练上,可模型训练出来以后要部署成在线预测服务:应用发来一个查询,几十毫秒内要拿到预测结果,而且查询量很大。当时的现实是,一家公司的模型来自不同的框架(scikit-learn、Spark MLlib、TensorFlow、Caffe……),每个框架的部署方式、性能特点都不一样。
Clipper 是一个通用的低延迟预测服务系统,夹在应用和各种机器学习框架之间。它分两层:
- 模型抽象层(model abstraction layer):把每个模型包进一个独立的 Docker 容器,通过统一的批量预测接口(
pred_batch(inputs))调用。上面有三样东西:预测缓存(同样的查询直接返回缓存结果,用 CLOCK 算法近似 LRU 淘汰)、自适应批处理(本节的重点)、容器之间的进程隔离。 - 模型选择层(model selection layer):同一个任务可以有多个模型(不同框架、不同版本)。用多臂老虎机算法在线选择(Exp3 选单个最好的模型,Exp4 做加权集成),根据用户反馈不断调整。集成多个模型时,如果某个模型太慢,就不等它,用已有的结果先返回(straggler mitigation,掉队者缓解)。
我们只关心批处理这一块,它也是 Clipper 对后来的推理系统影响最大的部分。
2.2 延迟画像:每个模型的”最佳批”都不一样
批处理为什么能提高吞吐?Clipper 总结了两个原因:一是分摊固定开销(RPC 调用、框架内部的开销、把输入拷到 GPU);二是让框架可以利用数据并行的计算(GPU、BLAS 库)。
但不同模型的情况差别巨大。Clipper 测量了每个模型的延迟画像(latency profile):处理一批的时间随批大小怎样变化。论文发现很多框架的延迟画像近似是一条直线:
是固定开销, 是每多一个输入多花的时间。给定延迟目标(SLO),能用的最大批是 。论文报告,在 20 ms 的目标下,线性 SVM 能用的最大批是核 SVM 的 241 倍(前者只做一次向量点积,后者要做一串昂贵的核计算),吞吐分别约 3 万 qps 和 200 qps。
代码 01 演示 1 用三个玩具模型重现这个现象:
| 模型 | 固定开销 | 每个输入 | 20 ms 内的最大批 | 批处理吞吐 | 不批处理 | 收益 |
|---|---|---|---|---|---|---|
| 线性模型 | 2 ms | 30 µs | 600 | 30,000 qps | 493 qps | 60.9× |
| 中等模型 | 3 ms | 0.5 ms | 34 | 1,700 qps | 286 qps | 6.0× |
| 核方法模型 | 1 ms | 4.75 ms | 4 | 200 qps | 174 qps | 1.1× |
两个结论:批处理的收益取决于固定开销占多大比例;最大批必须按模型设定。可人工为每个模型调批大小既麻烦又脆弱(硬件、负载、同机的其他任务都会改变延迟画像),于是有了下面的自适应方案。
2.3 AIMD:加性增、乘性减
Clipper 借用了网络拥塞控制里的经典思路 AIMD(additive increase, multiplicative decrease,TCP 就是这样调发送窗口的):
- 每处理完一批,如果延迟没超过目标,批大小加一个固定量(加性增);
- 一旦超过目标,批大小乘以 0.9(乘性减,回退 10%)。
TCP 的回退通常是减半,Clipper 只退 10%,因为最佳批大小不会剧烈波动,小幅回退能让批大小一直待在上限附近。论文也试过另一种办法:用分位数回归拟合”P99 延迟随批大小变化的直线”,直接算出上限。两者效果相近,但 AIMD 更简单、更容易调,而且能持续适应变化(比如 Spark 的垃圾回收暂停),所以成了默认方案。
代码 01 演示 2 模拟这个过程:中等模型,延迟有约 5% 的随机波动,第 300–500 批期间每个输入的开销翻倍(模拟同机的干扰)。三种策略:
| 阶段 | 策略 | 平均批 | 超过 20 ms 的批 | 吞吐 qps |
|---|---|---|---|---|
| 变慢之前 | 固定批(离线按 P99 调好,29) | 29.0 | 0% | 1,653 |
| AIMD(目标 20 ms) | 30.0 | 18% | 1,663 | |
| AIMD(目标 18 ms,留余量) | 27.1 | 0% | 1,635 | |
| 变慢期间 | 固定批 | 29.0 | 100% | 908 |
| AIMD | 16.5 | 35% | 849 | |
| AIMD(留余量) | 14.6 | 6% | 831 | |
| 恢复之后 | 固定批 | 29.0 | 1% | 1,657 |
| AIMD | 31.3 | 19% | 1,678 | |
| AIMD(留余量) | 28.0 | 0% | 1,648 |
离线调好的固定批在正常情况下很好,可环境一变,它的每一批都违约。AIMD 在几十批之内把批缩小到新的上限附近,恢复后又爬回去。
但这个实验也暴露了 AIMD 的一个本性:它靠”试探到超时”来找上限。稳态下批大小在上限附近锯齿状来回,所以总有一部分批超时(这里约两成,因为每批都加 1,而随机波动让边界变得模糊)。把目标定在 SLO 之下留一点余量,违约几乎消失,吞吐只损失约 2%。这是控制系统里很常见的权衡:反应越快、越激进,越容易越界。
2.4 延迟凑批:等一会儿,凑大一点
负载不高时,队列里的请求常常凑不满一批。Clipper 的延迟凑批(delayed batching)是:worker 空闲时不急着发出,最多再等一小段时间,让更多请求到达。论文把它类比为 TCP 的 Nagle 算法(攒够数据再发包),收益来自”发一批的固定开销”与”每多一个输入的开销”的比值。论文报告:scikit-learn SVM 的固定开销高,等 2 ms 让吞吐提高了 3.3 倍;Spark SVM 处理小批本来就高效,凑批没有收益。
代码 01 演示 3:两个模型,负载都约为各自”不批处理时服务能力”的 80%:
| 模型 | 等待上限 | 平均批 | worker 忙碌比例 | P50 延迟 |
|---|---|---|---|---|
| 高固定开销(a = 2 ms,b = 20 µs) | 0 | 1.2 | 64% | 2.49 ms |
| 1 ms | 1.6 | 49% | 3.06 ms | |
| 2 ms | 2.0 | 40% | 4.04 ms | |
| 低固定开销(a = 0.1 ms,b = 0.5 ms) | 0 | 1.5 | 75% | 1.28 ms |
| 2 ms | 8.4 | 68% | 8.15 ms |
高固定开销的模型多等 2 ms,worker 的忙碌比例从 64% 降到 40%:同样的负载用更少的计算完成,省下的能力可以应付突发流量或者给别的模型用。低固定开销的模型凑了大得多的批,忙碌比例却只降了几个百分点,延迟成倍增加。凑批的价值取决于固定开销有多大。
在我们这个开环(请求按泊松过程到达)的玩具模型里,延迟凑批没有提高”最大吞吐”:负载高的时候,worker 忙碌期间到达的请求自然会攒成批,不需要刻意等。它省下的是负载不满时的计算。论文里 3.3 倍的吞吐提升是在它自己的测试负载下测得的,机制相同,数字不能直接套用。
2.5 论文卡片:Clipper
| 项目 | 内容 |
|---|---|
| 论文 | Clipper: A Low-Latency Online Prediction Serving System,Crankshaw 等,NSDI 2017(UC Berkeley) |
| 一句话 | 一个夹在应用和各种机器学习框架之间的通用在线预测服务系统 |
| 问题 | 模型要部署成低延迟、高吞吐的在线服务,而模型来自五花八门的框架,各自的性能特点差别巨大 |
| 现有方案的缺陷 | 系统和框架绑定;批大小靠离线手工调,部署后固定不变 |
| 关键洞察 | 用统一的”批量预测”接口隔离框架差异;批大小按每个模型的延迟画像在线自适应 |
| 机制 | 模型容器 + 预测缓存;AIMD 自适应批大小(回退 10%);延迟凑批;基于老虎机算法的模型选择与集成;掉队者缓解 |
| 评测 | 论文报告:在 20 ms 目标下批处理最多带来 26 倍吞吐提升;2 ms 的凑批等待让 scikit-learn SVM 吞吐提高 3.3 倍;与 TensorFlow Serving 相比吞吐和延迟相当,同时支持模型组合和在线学习 |
| 局限与假设 | 一个请求 = 一次 forward;批算完就结束;延迟画像只和批大小有关 |
| 与本课的关系 | “按延迟目标限制批大小”演变成 Orca 的 max_bs;AIMD 式的反馈控制在后来的 SLO 感知调度里反复出现;模型选择层是第 11 讲(LLMSelector)的先声 |
2.6 把 Clipper 的视角放到 LLM 上
代码 01 演示 4 用纸上 GPU 画出 Llama-3-8B 一步 decode 的延迟画像:
| 上下文长度 | 一步 | 每多 1 个序列 | 一步 | 一步 ≤ 20 ms 的最大批 |
|---|---|---|---|---|
| 512 | 6.13 ms | 25 µs | 12.52 ms | 553 |
| 2,048 | 6.20 ms | 100 µs | 31.76 ms | 138 |
| 8,192 | 6.50 ms | 401 µs | 108.68 ms | 34 |
LLM 的 decode 同样近似是 。截距 是”读一遍权重”,斜率 是”每个序列多读的 KV”。不同的是,斜率随上下文长度变化:上下文 512 时一步 20 ms 能放五百多个序列,8K 时只能放 34 个。所以一个固定的最大批不能保证 TPOT。更根本的不同在于:CNN 的一批算完就结束,LLM 的”批”由正在生成的请求组成,每一步都在变。Clipper 的框架装不下这件事,这正是 Orca 要解决的问题。
3. 请求级批处理为什么在 LLM 上失灵 · Why request-level batching breaks
Orca 论文从当时的标准部署方式出发:Triton Inference Server(服务层,负责收请求、凑批)+ FasterTransformer(执行引擎,NVIDIA 为 Transformer 推理优化的 CUDA 实现)。服务层把一批请求交给引擎,引擎跑完这批的全部迭代,再一次性把结果交回。论文指出了两个挑战。
3.1 挑战一:先完成的走不了,后到的进不来
服务层和引擎只在两个时刻交互:服务层把一批交给空闲的引擎时,引擎处理完整批时。这就是以请求为粒度的调度(request-level scheduling)。对生成式模型来说,一批里的请求需要的迭代数各不相同:
- 先生成完的请求(Orca 论文图 3 里的 x2)不能先返回,引擎还在为它做无用的计算,直到整批结束;
- 批开始之后才到的请求,要等整批结束才能被处理,排队时间可能很长。
这就是第 1 讲代码 03 里静态批处理的”座位浪费”和”头部阻塞”。论文特别指出,训练时没有这个问题:训练用 teacher forcing,一批在一次迭代里就处理完了。
3.2 挑战二:同一步里的请求形状各不相同
假设我们想每一步都重新组批,马上会撞上第二个问题:两个请求的下一次迭代,未必能放进同一个批里算。要批量计算,每个请求的运算和输入张量的形状必须完全一致,可下面三种情况都不满足:
- 两个都在做 prefill,但提示长度不同;
- 两个都在 decode,但处在不同的位置(已有的 KV 长度不同,注意力的形状就不同);
- 一个在 prefill(一次处理很多 token),一个在 decode(一次处理 1 个)。
只有”同阶段、同长度 / 同位置”的请求才能批在一起,批越大,凑齐的可能性越小。代码 02 演示 1 给出了一个典型的一步:
| 请求 | 阶段 | 这一步的新 token | 已有上下文 |
|---|---|---|---|
| r0 | prefill | 37 | 0 |
| r1 | prefill | 9 | 0 |
| r2 | decode | 1 | 120 |
| r3 | decode | 1 | 15 |
| r4 | decode | 1 | 64 |
如果硬要拼成一个 张量( 个请求 × 每个 个 token × 隐藏维度 ),就得把每个请求补齐到 37 个 token:要算 185 个 token,有用的只有 49 个,浪费 74%。注意力更糟:各请求的 KV 长度也不同,练习 4 会算出,补齐后注意力的计算量是有用部分的 20 多倍。
4. Orca:迭代级调度与选择性批处理 · Orca
4.1 迭代级调度
一个比喻:静态批处理像旅游大巴——凑满一车才发车,开到终点所有人才能下车;中途到站的乘客只能等下一班。迭代级调度像地铁——每一站(每一次迭代)都可以有人下车、有人上车,车上的座位(max_bs)和车厢的空间(显存)是仅有的限制。
Orca 的第一个想法很直接:调度的粒度从”一个请求”缩小到”一次迭代”。调度器不断重复三件事:
- 从请求池里选出这一步要跑的请求;
- 让引擎只跑一次迭代;
- 收回这一步每个请求产出的 token。
每一步结束,调度器都能看到哪些请求完成了,立刻把结果返回给客户端;新到的请求最多等一次迭代,就有机会被选中。论文称之为 iteration-level scheduling。今天的推理引擎普遍把它叫作连续批处理(continuous batching),NVIDIA TensorRT-LLM 叫它 in-flight batching。
代码 02 演示 4 在真实的迷你模型上跑了一遍(6 个请求在不同迭代到达,max_bs = 3;P = prefill,D = decode,· = 已到达在等待,- = 空占的座位):
连续批处理(迭代级调度)(共 10 次迭代)
0123456789
r0 PDDDDD 到达@0,最后一个 token @5
r1 PDD 到达@0,最后一个 token @2
r2 PDDDDDDDD 到达@1,最后一个 token @9
r3 ·PD 到达@2,最后一个 token @4
r4 ··PDDDD 到达@3,最后一个 token @9
r5 PDD 到达@6,最后一个 token @8
静态批处理(整批同进同出)(共 18 次迭代)
012345678901234567
r0 PDDDDD 到达@0,最后一个 token @5
r1 PDD--- 到达@0,最后一个 token @2
r2 ·····PDDDDDDDD 到达@1,最后一个 token @14
r3 ····PD------- 到达@2,最后一个 token @7
r4 ···PDDDD---- 到达@3,最后一个 token @10
r5 ·········PDD 到达@6,最后一个 token @17
连续批处理里,r1 在第 2 步结束后离开,r3 第 3 步就补了进来;r3 结束后,r4 下一步就进来了。新请求要等,只是因为座位满了(max_bs = 3),而不是因为”要等整批结束”。同一步里同时有 P 和 D,这要靠下一节的选择性批处理。最重要的是最后一行输出:连续批处理生成的 token 与每个请求单独生成完全相同。调度方式改变的只是”什么时候算”,不改变”算出什么”。
4.2 选择性批处理
第二个挑战怎么办?Orca 的观察是:Transformer 层里的算子,只有注意力关心”token 属于哪个请求”。
- 线性层(QKV 投影、输出投影、MLP)、LayerNorm / RMSNorm、残差相加、激活函数:都是对每个 token 独立做的运算,根本不需要”批”这个维度。把这一步所有请求的所有新 token 首尾相接,拼成一个 的扁平矩阵(上例是 ),一次矩阵乘法就全算完了。权重只读一遍,批处理的好处一点不少。
- 注意力:一个请求的 query 只能和它自己的 KV 做点积。所以在注意力之前插入一个 Split,把扁平矩阵按请求切开,每个请求各自算注意力(用各自的 KV cache),算完再用 Merge 拼回 ,继续后面的算子。
[49, H] ──QKV 线性层(一次)──▶ Split ──┬─ r0 的注意力(37 个 query,看自己的 37 个 KV)
├─ r1 的注意力(9 个 query)
├─ r2 的注意力(1 个 query,看自己的 121 个 KV)
├─ r3 ……
└─ r4 …… ──▶ Merge ──▶ [49, H] ──输出投影、MLP(一次)──▶
为什么”注意力拆开算”代价很小?因为注意力没有权重参数。批处理省下的是”重复读权重”,而注意力本来就没有权重可分摊;它读的是每个请求自己的 KV,拆不拆开都要各读各的。Orca 的微基准实验也证实了这一点:在所有请求完全相同(可以照常全部批处理)的情况下,Orca 引擎和 FasterTransformer 的性能”相近或略差”(论文原话的意思),差距很小。
Orca 还维护一个 Attention K/V manager,按请求分别保存 KV,直到调度器通知某个请求结束才释放。实现上,Orca 把各请求的注意力内核融合进一个 CUDA 内核(直接把不同请求的线程块拼在一起),减少内核启动开销、提高 GPU 利用率。
我们的 ragged_batch.forward_batch 就是选择性批处理的 numpy 版本。代码 02 演示 2:5 个混合阶段的请求,批量计算与逐个计算的 logits 最大差 ,贪心选出的下一个 token 全部相同;运算量和 KV 读写相同,读权重的字节少了 4.88 倍。自检还验证了,它数出的 FLOPs 和字节数与第 1 讲 hw_model 对同一组 SeqWork 的公式完全相等。所以后面模拟器里”一步的代价”,有一个真实实现在背后对账。
4.3 调度算法
有了选择性批处理,引擎可以批量处理任意一组请求,剩下的问题是每一步选哪些。Orca 的调度器(论文 Algorithm 1)刻意保持简单:
- 迭代级 FCFS:对任意两个请求,先到的那个跑过的迭代数不少于后到的。按到达时间排序,从前往后选。(后到的请求可能先完成,因为它需要的迭代少。)
- max_bs:一步最多选 max_bs 个请求。批大小的收益递减、延迟递增,需要运维按”延迟预算 vs 吞吐”来调。
- KV 槽位预留:请求第一次被选中时(还在 prefill 阶段),调度器为它预留 max_tokens 个槽位(max_tokens 是请求的属性:输入长度 + 最多生成多少个 token),总预留量不能超过 n_slots(分配给 K/V manager 的显存)。预留不下就停止选择。
为什么要预留?因为 KV 显存要到请求结束才能释放。如果不预留,可能出现所有请求都在等新的 KV 槽位、谁也跑不下去的死锁。预留保证了:只要一个请求被接纳,它一定能跑完。
这个设计的代价也很明显:预留的是请求可能达到的最大长度,而不是它现在的长度。一个最终只生成 50 个 token 的请求,可能占着 2048 个 token 的显存直到结束。这正是第 3 讲 vLLM 的出发点(§6.1 会量化它)。
4.4 分布式执行
Orca 要服务上千亿参数的模型(GPT-3 175B 需要 16 块 A100),所以它组合了两种模型并行(第 6 讲详细讲):层内并行(intra-layer,把矩阵乘法切到多块 GPU 上,即张量并行)和层间并行(inter-layer,把不同的层放到不同的 GPU 上,即流水线并行)。有两个设计值得一提:
- 控制面与数据面分离。每一步,引擎都要把”这一步有哪些请求、各在什么位置”之类的控制信息传给所有 worker。FasterTransformer 和 Megatron-LM 通过 GPU 之间的 NCCL 通道传这些信息,每次都引入 CPU-GPU 同步。Orca 让控制信息走单独的 gRPC 通道(CPU 之间),NCCL 只传中间张量。论文的微基准实验里,175B 模型上 Orca 引擎因此比 FasterTransformer 快最多 47%。
- 不用微批的流水线。流水线并行要让每一段同时有活干。FasterTransformer 的做法是把一批切成微批(microbatch),可微批越小,批处理的效率越低;微批太大,流水线里又有气泡。Orca 的调度器不等一步返回,就继续注入下一批,让引擎里始终有 n_workers 个批在跑。这在迭代级调度下是自然的,每一批本来就只跑一次迭代。
4.5 评测
- 环境:Azure 上的 A100(40 GB)VM,每台 8 卡 NVLink、机间 1.6 Tb/s InfiniBand。模型是 GPT-3 规模的 13B、101B、175B、341B(101B 和 341B 是作者改层数和隐藏维度构造的),FP16。基线是 FasterTransformer,配上作者实现的”每次最多取 max_bs 个请求”的调度器(Triton、TensorFlow Serving 的常见做法)。
- 负载:当时没有公开的生成式模型请求 trace,作者合成了一个:输入长度 ~ U(32, 512),最多生成的 token 数 ~ U(1, 128);让模型从不输出结束符(他们没有真实的模型权重和输入文本,无法判断什么时候该结束),泊松到达。
- 结果(论文报告):
- 引擎微基准(所有请求相同、不用调度器):Orca 与 FasterTransformer 相近;13B 模型上,FasterTransformer 在批大小 ≥ 8 时就因为按最大长度(2048)为每个请求预分配 KV 而爆显存;175B 上 Orca 快最多 47%(控制面与数据面分离)。
- 端到端:在 175B 模型上,要达到每 token 190 ms 的中位归一化延迟,FasterTransformer 的吞吐是 0.185 请求/秒,Orca 是 6.81 请求/秒,36.9 倍。FasterTransformer 的最好配置是 max_bs = 1 或 8,批再大反而更差:长短不一的请求混在一批里,先完成的走不了。
- 所有请求长度相同(没有先完成的问题)时,FasterTransformer 的大批终于有用了,但 Orca 仍然更好。
4.6 论文卡片:Orca
| 项目 | 内容 |
|---|---|
| 论文 | Orca: A Distributed Serving System for Transformer-Based Generative Models,Yu 等,OSDI 2022(首尔大学、FriendliAI) |
| 一句话 | 以一次迭代为单位调度生成式模型的推理请求,用选择性批处理让任意一组请求都能批量计算 |
| 问题 | 生成式模型的请求要跑很多次迭代,次数各不相同 |
| 现有方案的缺陷 | 以请求为单位调度:先完成的走不了、后到的进不来;形状不同的请求无法批量计算 |
| 关键洞察 | 调度粒度应该是迭代;只有注意力需要区分请求,而注意力没有权重,拆开算几乎不损失批处理的好处 |
| 机制 | 迭代级调度(迭代级 FCFS + max_bs);选择性批处理(扁平化 + Split / Merge);按 max_tokens 预留 KV 槽位防止死锁;层内 + 层间并行;控制面与数据面分离;不用微批的流水线 |
| 评测 | GPT-3 13B–341B,A100,合成 trace;与 FasterTransformer 相比,同样延迟下吞吐提高 36.9 倍(论文报告) |
| 局限与假设 | KV 按最大长度预留,显存浪费大(vLLM 测得有效利用率只有 20.4%–38.2%);prefill 和 decode 混在同一步会互相干扰;FCFS 不考虑请求长度;合成负载里请求从不提前结束 |
| 与本课的关系 | 连续批处理是今天所有推理引擎的基础;它的显存问题引出第 3 讲(PagedAttention),prefill / decode 干扰引出第 7 讲(分块 prefill、解耦),调度粒度的思想在第 13 讲(以程序为单位调度)再上一层 |
5. 在迷你引擎上实现连续批处理 · Continuous batching in our mini engine
5.1 两半式的实现
第 1 讲 §8.3 说过,本讲义的迷你引擎分两半:正确性来自真实计算,性能来自模拟时钟。本讲两半各加了一个模块:
ragged_batch.py(真实计算):forward_batch实现选择性批处理,continuous_generate在迷你模型上真正执行迭代级调度。它们证明了:(1) 批量 forward = 逐个 forward;(2) 连续批处理的输出 = 每个请求单独生成的输出;(3) 一次批量 forward 的运算量和字节数 =hw_model对同一组SeqWork的公式。cb_engine.py(模拟时钟):run_continuous_batching在纸上 GPU 上模拟迭代级调度,记录每个请求每一个 token 的产出时间。
cb_engine 有一个刻意的设计:引擎只决定”每一步算谁”,“显存够不够”交给一个显存管理器对象回答。管理器只要实现 try_admit(能不能接纳)、grow(能不能再长一个 token)、free(释放)三个方法。本讲提供 Orca 式的 ReservationMemory(按 max / pow2 / oracle 三种策略预留),第 3 讲换上按块分配的分页管理器,引擎一行不改。引擎里也已经写好了显存不够时的抢占逻辑(把最晚到达的请求踢出去,下次重算或从 CPU 内存换回)。本讲的预留式管理器永远不会触发它,第 3 讲才会用到。
每一步,引擎做五件事:
- 把已到达的请求放进等待队列(按调度策略排序);
- 准入:按队列顺序接纳新请求,直到运行集合满 max_bs 个或显存放不下(放不下就停,不跳过队首,保持先来先服务);
- 组批:新请求做 prefill,正在运行的请求各 decode 1 个 token;
- 显存检查(必要时抢占);
- 用
step_cost算出这一步的时间,推进时钟,记录 token,结束的请求立刻离开。
写成伪代码就是:
while 还有请求没完成:
把已到达的请求按策略插入 waiting
new = []
while waiting 非空 且 len(running) + len(new) < max_bs:
r = waiting 的队首
if not memory.try_admit(r): break # 放不下就停(不跳过队首)
new.append(waiting.pop(0))
decoding = running(prefill_first 模式下,如果 new 非空则为空)
确保 decoding 里每个请求的 KV 能再长一个 token,否则抢占最晚到达的请求
works = [SeqWork(prefill 长度, 0) for r in new] + [SeqWork(1, 上下文) for r in decoding]
t += step_cost(model, gpu, works).time # 纸上 GPU 推进时钟
每个参与的请求产出 1 个 token;生成完的请求立刻离开、释放显存
自检验证了:max_bs = 1 时,它和第 1 讲的 run_sequential 给出完全相同的时间戳。
5.2 和静态批处理比
代码 03 演示 1 把第 1 讲代码 03 的实验原样搬过来(Llama-3-8B + H100,600 个聊天请求,SLO:TTFT ≤ 1 s 且 TPOT ≤ 50 ms),换上两种 Orca:一种按 Llama 3 的最大上下文 8192 为每个请求预留 KV(现实中 Orca 能做的),一种神谕预留(假设知道输出长度,作上限参考)。节选:
| 系统 | 到达率 | 吞吐 tok/s | TTFT P50 | TTFT P99 | TPOT P50 | ITL P99 | SLO 达成 |
|---|---|---|---|---|---|---|---|
| 静态批 =32 | 1 | 293 | 1.6 s | 8.8 s | 6.2 ms | — | 33% |
| Orca(按最大长度预留) | 1 | 293 | 11.8 ms | 47.6 ms | 6.2 ms | 6.5 ms | 100% |
| 静态批 =128 | 16 | 1,475 | 26.8 s | 68.2 s | 9.7 ms | — | 1% |
| Orca(按最大长度预留) | 16 | 3,719 | 14.9 ms | 105.7 ms | 8.0 ms | 25.6 ms | 100% |
| Orca(按最大长度预留) | 32 | 4,514 | 3.6 s | 9.7 s | 8.8 ms | 29.1 ms | 20% |
| Orca(神谕预留) | 32 | 5,793 | 20.5 ms | 107.2 ms | 10.9 ms | 40.3 ms | 100% |
| Orca(神谕预留) | 48 | 6,868 | 30.4 ms | 163.2 ms | 14.8 ms | 53.1 ms | 100% |
四个现象:
- 低负载时 TTFT 差了两个数量级。到达率只有 1 请求/秒,静态批处理的 TTFT 中位数已经 1.6 秒(要等前一批全部结束),连续批处理只要 12 毫秒(新请求下一步就能进来)。
- 负载上来以后,静态批处理很快过载,吞吐封顶在一两千 token/s;连续批处理的吞吐随负载继续增长,SLO 一直全部达成。
- 两种 Orca 在 32 请求/秒时分道扬镳。按最大长度预留时,每个请求占 8192 个 token 的显存,一块 H100 最多同时放约 52 个请求,系统过载;神谕预留的同样负载下 SLO 全部达成。同样的调度,显存怎样分配决定了能并发多少请求。
- ITL 的 P99 随负载上升(6.5 → 53 ms):负载越高,越多的 decode 步里混进了别人的 prefill,这些步更慢(§6.4)。
5.3 goodput:一块 H100 能承受多少流量
第 1 讲 §4.4 给了 goodput 的第二种定义:SLO 达成率不低于目标(这里 90%)时,系统能承受的最大请求速率。它直接回答”要满足这个服务质量,需要多少块 GPU”。代码 03 演示 2 用二分查找求出每个系统的这个数:
| 系统 | 最大到达率(请求/秒) | 相对 batch = 1 |
|---|---|---|
| batch = 1 | 0.10 | 1× |
| 静态批 =32 | 0.10 | 1× |
| 静态批 =128 | 0.10 | 1× |
| Orca(按最大长度预留) | 20.34 | 214× |
| Orca(神谕预留) | 63.24 | 664× |
在这个 SLO 下,静态批处理和 batch = 1 一样差:它们都被长尾输出造成的头部阻塞拖住,0.1 请求/秒的负载就有一成左右的请求违约。连续批处理把能承受的流量提高了两个多数量级。换算成机器数:要以这个 SLO 服务 100 请求/秒,静态批处理需要约 1000 块 H100,Orca(按最大长度预留)需要 5 块,神谕预留只要 2 块。这就是”批处理是第一杠杆”真正兑现的样子,而兑现它的前提是迭代级调度。
5.4 吞吐上限、成本,以及一个实验方法上的坑
代码 03 演示 3 在过载(到达率 128 请求/秒)下测各系统的吞吐上限。这里出现了一个第 1 讲没注意到的问题:一次有限的过载实验,在所有请求都被接纳之后还有一段收尾。最后只剩几个输出特别长的请求在跑,批越来越小,GPU 大部分时间在为一两个请求读权重。这段收尾的长短取决于最长的那个请求,和调度好坏无关,却会把”总 token ÷ 总时长”拉低。所以这里报告两种吞吐:
| 系统 | 整体吞吐(含收尾) | 稳态吞吐 | 每百万 token 成本(按稳态) |
|---|---|---|---|
| batch = 1 | 162 | 162 | 5.14 美元 |
| 静态批 =32 | 923 | 951 | 0.88 美元 |
| 静态批 =128 | 1,495 | 1,590 | 0.52 美元 |
| Orca(按最大长度预留) | 4,553 | 5,762 | 0.14 美元 |
| Orca(神谕预留) | 7,336 | 10,354 | 0.08 美元 |
| 第 1 讲的理想上限(128 个座位始终坐满,只算 decode) | 12,656 | 0.07 美元 |
(稳态吞吐 = 只统计到最后一个请求开始产出 token 为止,见 cb_engine.steady_throughput。第 1 讲代码 03 演示 4 报告的是整体吞吐。)
连续批处理的稳态吞吐比整体吞吐高三到四成,静态批处理只高几个百分点。原因是连续批处理在”满负荷”阶段效率太高,收尾阶段相对就显得浪费。比较系统时要看稳态吞吐;更好的做法是让实验足够长(练习 8:把请求数从 600 增加到 3000,两种口径都向约 1.2 万 token/s 靠拢)。这是前置讲 P4 会系统讲的实验方法问题:预热、稳态和收尾都要分开看。
神谕预留的 Orca 的稳态吞吐达到了第 1 讲理想上限的八成左右。剩下的差距主要来自 prefill:理想上限只算了 decode,而这 600 个请求的 prefill 单独做一遍要 6.8 秒 GPU 时间,约占总忙碌时间 23.5 秒的 29%。prefill 混进 decode 步以后,一部分计算被 decode 读权重的时间”掩盖”了(roofline 取两者的最大值),实际损失比三成小。
6. 连续批处理的旋钮 · Knobs of continuous batching
6.1 KV 预留:被占着 ≠ 被用着
Orca 在请求第一次被调度时就为它预留全部 KV 槽位。预留多少?现实中系统不知道输出会有多长,只能按最大长度预留;如果有一个不太准的长度预测,可以多预留一些(比如按 2 的幂向上取整);如果知道真实长度(做不到),就只预留需要的。第 3 讲的 vLLM 论文正是用这三种方式重新实现了 Orca:Orca (Max)、Orca (Pow2)、Orca (Oracle)。
代码 04 演示 1 用一个让显存成为瓶颈的”长上下文”负载(提示均值 2048、输出均值 512),SLO:TTFT ≤ 2 s 且 TPOT ≤ 50 ms。“占用”是被预留的显存比例,“实际存储”是真正写了 KV 的比例(都按时间加权):
| 到达率 | 显存管理 | 吞吐 tok/s | 平均批 | 占用 | 实际存储 | TTFT P99 | SLO 达成 |
|---|---|---|---|---|---|---|---|
| 8 | 按最大长度预留 | 3,137 | 46 | 88% | 25% | 19.3 s | 20% |
| 8 | 按 2 的幂预留 | 3,661 | 72 | 52% | 39% | 434 ms | 100% |
| 8 | 神谕预留 | 3,661 | 72 | 47% | 39% | 434 ms | 100% |
| 12 | 按 2 的幂预留 | 4,046 | 105 | 75% | 57% | 13.2 s | 53% |
| 12 | 神谕预留 | 4,089 | 111 | 73% | 60% | 11.8 s | 57% |
| 12 | 不限显存 | 4,353 | 139 | — | 75% | 639 ms | 100% |
三个结论:
- 按最大长度预留时,显存几乎被占满(约九成),可真正写了 KV 的只有四分之一左右。平均批只有四十多个,到达率 8 请求/秒就过载了。vLLM 论文在它重新实现的 Orca 上测到的数字与此相近:真正存放 token 状态的显存只有 20.4%–38.2%。
- 预留越接近真实需要,能同时放进来的请求越多。按 2 的幂预留已经和神谕预留差不多。
- 即使是神谕预留,到达率 12 以上也被显存卡住了。原因是预留的是请求最终的长度,可一个请求在大部分时间里还没长到那么长。“不限显存”那一行说明,如果能”用多少占多少”,同样的负载 SLO 能全部达成。
第 3 讲的 PagedAttention 就是在做这件事:按块、按需分配,不预留。代价是可能在半路上发现显存不够,于是要有抢占机制。
6.2 max_bs:吞吐与每个 token 的延迟
代码 04 演示 2:聊天负载,到达率 48 请求/秒(神谕预留,显存不是瓶颈),max_bs 从 8 扫到 512:
| max_bs | 吞吐 tok/s | 平均批 | TTFT P99 | TPOT P50 | ITL P99 | SLO 达成 |
|---|---|---|---|---|---|---|
| 8 | 1,188 | 8 | 126.0 s | 6.5 ms | 9.3 ms | 2% |
| 32 | 3,444 | 27 | 28.2 s | 7.8 ms | 23.3 ms | 7% |
| 64 | 5,029 | 47 | 11.6 s | 9.4 ms | 34.1 ms | 16% |
| 128 | 6,359 | 76 | 3.1 s | 12.6 ms | 43.7 ms | 57% |
| 256 | 6,868 | 97 | 163 ms | 14.8 ms | 53.1 ms | 100% |
| 512 | 6,868 | 97 | 163 ms | 14.8 ms | 53.1 ms | 100% |
max_bs 太小,能同时跑的请求太少,系统过载,TTFT 以几十秒计。max_bs 变大,吞吐上升但边际收益递减,每步的序列多了,TPOT 和 ITL 随之上升。超过这个负载实际需要的并发数以后,max_bs 就不再起作用(256 和 512 完全相同)。Orca 论文的建议是由运维按”延迟预算 vs 吞吐”来调它。可正如 §2.6 所见,同样的批大小在不同的上下文长度下延迟差很多。后来的系统转向更细的控制:按每步的 token 预算、按 TPOT 目标来组批(第 7 讲)。
6.3 调度顺序:先来先服务还是短作业优先
Orca 用迭代级 FCFS。如果把等待队列按”剩余输出长度”从短到长排(短作业优先,SJF)会怎样?代码 04 演示 3:聊天负载,到达率 40,max_bs = 64(故意设小,让请求排队,排序才有意义):
| 策略 | 平均 TTFT | TTFT P50 | TTFT P99 | TTFT 最大 | 平均 E2E | SLO 达成 |
|---|---|---|---|---|---|---|
| FCFS | 3.9 s | 3.4 s | 9.3 s | 9.4 s | 6.6 s | 23% |
| SJF(神谕:真实长度) | 1.9 s | 94 ms | 15.4 s | 18.0 s | 4.6 s | 74% |
| SJF(预测,σ = 0.5) | 2.2 s | 99 ms | 16.0 s | 18.9 s | 4.8 s | 73% |
| SJF(预测,σ = 1.0) | 2.5 s | 128 ms | 17.7 s | 19.9 s | 5.2 s | 71% |
(预测值 = 真实长度 × ,。σ = 0.5 时预测值常常差 1.6 倍,σ = 1 时常常差 2.7 倍。)
- 短作业优先让大多数请求几乎不用排队:TTFT 中位数从 3.4 秒降到 0.1 秒,SLO 达成率从 23% 升到 74%。这是排队论的经典结论:在单服务器上,先服务短作业能最小化平均等待时间(前置讲 P3、P4)。
- 代价是尾巴:长请求总被插队,TTFT 的 P99 和最大值反而变大了,这就是饥饿(starvation)。SGLang 论文在缓存感知调度时也提到了同样的风险。真实系统常用”老化”(等得越久优先级越高)或公平调度来补救。
- 预测不准也有用:排序只需要知道”谁更短”,不需要精确的长度。σ = 1 的粗糙预测仍保留了大部分收益。这是”LLM 输出长度预测”这条研究线的出发点(§7)。
还有一个容易忽略的前提:只有排队时,排序才有意义。练习 5 把 max_bs 改回 256(这个负载下不排队),SJF 和 FCFS 的结果完全相同。连续批处理本身消除了大部分排队,调度顺序的作用主要在过载或突发时体现。
6.4 prefill 怎样插进来
一个新请求的 prefill 有几百到几千个 token,正在运行的请求各 decode 1 个 token。两者怎样共处一步?
- mixed(Orca):新请求的 prefill 和正在进行的 decode 放进同一步。
- prefill_first(早期 vLLM 的默认做法):只要这一步有新请求能进来,就只做 prefill,正在 decode 的请求暂停一步。
代码 04 演示 4(聊天负载,max_bs = 256,神谕预留):
| 到达率 | 方式 | 吞吐 tok/s | TTFT P50 | TTFT P99 | TPOT P50 | ITL P99 | ITL 最大 |
|---|---|---|---|---|---|---|---|
| 16 | mixed | 3,719 | 14.7 ms | 82.6 ms | 8.0 ms | 25.6 ms | 89.7 ms |
| 16 | prefill_first | 3,701 | 14.2 ms | 79.7 ms | 9.2 ms | 38.8 ms | 183.4 ms |
| 32 | mixed | 5,793 | 20.5 ms | 107.2 ms | 10.9 ms | 40.3 ms | 89.8 ms |
| 32 | prefill_first | 5,675 | 17.9 ms | 111.8 ms | 14.9 ms | 74.3 ms | 213.2 ms |
| 48 | mixed | 6,868 | 30.4 ms | 163.2 ms | 14.8 ms | 53.1 ms | 131.3 ms |
| 48 | prefill_first | 6,506 | 43.4 ms | 1.1 s | 21.6 ms | 91.9 ms | 261.8 ms |
- mixed:ITL 的最大值约 90 ms(负载 48 时 131 ms)。某一步里混进了一个几千 token 的 prefill(负载高时一步里还会混进好几个),同一步里所有正在 decode 的请求都跟着等了这么久。用户看到的就是”字流到一半,卡了一下”。
- prefill_first:低负载时 TTFT 略好一点(prefill 不用和几百个 decode 挤在同一步),但正在 decode 的请求会被连续好几步的 prefill 卡住,ITL 的 P99 和最大值都明显更差。负载高时,prefill 单独成步还浪费了”顺便读权重”的机会,吞吐和 TTFT 也变差。
两种做法都没有消除 prefill 与 decode 的相互干扰,只是让干扰落在不同的地方:mixed 牺牲 decode 的平稳,prefill_first 更偏向新请求。第 7 讲有两种更彻底的解法:把长 prefill 切成小块、每步只混一点(Sarathi-Serve 的分块 prefill),或者干脆把 prefill 和 decode 放到不同的 GPU 上(DistServe 的解耦)。
7. 之后的发展 · Since Orca
(这一节是我的补充,不是原课内容。)
- 连续批处理成了默认。vLLM、SGLang、Hugging Face TGI、NVIDIA TensorRT-LLM(称为 in-flight batching)等主流引擎都以迭代级调度为基础。选择性批处理的思想也保留了下来:线性层在扁平化的 token 上一起算,注意力由能处理变长请求的内核完成(第 4 讲的 FlashInfer)。
- 显存管理:Orca 按最大长度预留的问题由 vLLM 的 PagedAttention 解决(第 3 讲)。按需分配带来了抢占:显存不够时把一些请求踢出去,之后重算或从 CPU 内存换回。
- prefill 与 decode 的干扰:分块 prefill(Sarathi-Serve,OSDI 2024)和每步的 token 预算成了主流引擎的标准功能;更进一步是把两个阶段放到不同的 GPU 上(DistServe、Splitwise、Mooncake,第 7、9 讲)。
- 调度策略:FCFS 之外,研究者探索了公平性(例如 Sheng 等的 Fairness in Serving Large Language Models,OSDI 2024,按”已获得的服务量”在用户之间公平分配)、基于输出长度预测的调度(预测长度来分配显存或做近似短作业优先,例如用学习排序的方法预测请求之间的相对长短)、面向 SLO 的调度与准入控制。
- 调度器自身的开销:迭代级调度意味着每一步都要在 CPU 上做一次调度决策。GPU 每一步只要几毫秒,CPU 端的 Python 调度、组批、采样就可能成为瓶颈(第 1 讲的 overhead-bound)。SGLang 等引擎后来把 CPU 调度和 GPU 计算重叠起来(第 16 讲)。
- 以程序为单位:agent 一次任务要调用很多次模型。以”程序”而不是”请求”为单位来调度,是第 13 讲(Parrot、Autellix)的主题,可以看作 Orca 的思路又上了一层。
8. 小结 · Summary
- 批处理只省下重复读权重,不减少计算和 KV 读写;但 decode 的时间几乎全花在读权重上,所以它是吞吐的第一杠杆(代码 02:5 个请求,读权重少 4.88 倍,FLOPs 和 KV 字节不变)。代价是延迟:凑批要等,批大了每步更慢,同进同出要陪跑。
- Clipper(CNN 时代):每个模型的延迟画像 不同,最大批必须按模型设定;AIMD 在线自适应,环境变化时比离线调好的固定批稳健得多,代价是靠”试探到超时”找上限;延迟凑批的价值取决于固定开销的比重。
- Orca 指出请求级调度在生成式模型上的两个问题(先完成的走不了、后到的进不来;形状不同的请求无法批量计算),提出迭代级调度和选择性批处理(线性层扁平化一起算,注意力拆开各算;注意力没有权重,拆开几乎不损失)。论文报告同样延迟下吞吐是 FasterTransformer 的 36.9 倍。
- 在迷你引擎上:连续批处理的输出与逐个生成完全相同;在 TTFT ≤ 1 s、TPOT ≤ 50 ms 的 SLO 下,能承受的流量是静态批处理的几百倍(代码 03:0.10 → 20.34 / 63.24 请求/秒)。
- Orca 按最大长度预留 KV:显存被占满,真正用到的只有约四分之一,并发数被压到几十个(代码 04 演示 1)。这是第 3 讲 PagedAttention 的出发点。
- 连续批处理的旋钮:max_bs 权衡吞吐和 TPOT;短作业优先大幅降低平均和中位延迟,代价是长请求的饥饿,粗糙的长度预测就够用;prefill 怎样插进 decode 决定了干扰落在哪里(mixed 让 decode 卡顿,prefill_first 卡得更厉害)。这是第 7 讲的出发点。
- 实验方法:有限的过载实验有”收尾”,比较吞吐要看稳态,或者让实验足够长。
9. 常见误区 · Common pitfalls
- “批处理让计算变少了。” 没有。FLOPs 一样,KV 读写一样,省下的只是重复读权重(和固定开销)。在 compute-bound 的长 prefill 上,批处理几乎没有收益。
- “批越大越好。” 批大受显存限制,每步更慢(TPOT 上升),超过实际并发需要之后不再有用(§6.2)。
- “连续批处理会改变模型的输出。” 不会。它只改变每个请求在哪一步被计算;只要注意力按请求分开、KV 不串,输出与逐个生成相同(代码 02 演示 4)。真实系统里的微小差异来自浮点累加顺序(第 1 讲 §6.5),不是调度本身。
- “选择性批处理要把注意力也批起来才高效。” 注意力没有权重,批处理它不能省下读权重;它的效率靠的是一个能同时处理变长请求的融合内核(第 4 讲),而不是把请求补齐成规整张量(补齐会浪费 20 多倍的计算,练习 4)。
- “Orca 解决了显存问题。” 没有。它按最大长度预留 KV 来防止死锁,显存实际利用率很低。这是 vLLM 要解决的问题。
- “短作业优先总是更好。” 它优化平均值和中位数,代价是长请求的尾延迟和饥饿;而且只在排队时才起作用(练习 5)。
- “AIMD 能保证不违约。” 它靠试探到超时来找上限,稳态下总有一部分批超过目标;要少违约,目标要留余量(代码 01 演示 2)。
- “吞吐 = 总 token ÷ 总时间。” 有限的过载实验有收尾,这样算会低估好的系统(§5.4)。
10. 练习 · Exercises
练习 3、4、5、8 的参考答案里的数字,是我把代码复制到一个临时目录、按题目修改后实际运行得到的(没有改动课程目录里的代码)。
练习 1(迭代级 FCFS)。Orca 定义的迭代级 FCFS 是:“对请求池里的任意两个请求,如果 比 先到, 跑过的迭代数不少于 。“(a) 为什么在这个定义下,后到的请求仍然可能先完成?(b) 当显存预留不下队首请求时,Orca 的 Select 函数直接 break,而不是跳过它去找后面更小的请求。这样做保护了什么,又损失了什么?
参考答案
(a) 定义只约束”跑过的迭代数”,不约束”需要的迭代数”。后到的请求如果只需要很少的迭代(输出很短),它和先到的请求一起跑,先跑够自己需要的次数,就先完成了。代码 02 演示 4 里 r3 比 r2 晚到,却早完成。 (b) 保护了 FCFS 的公平性,防止大请求被小请求无限期地插队(饥饿)。损失是头部阻塞:队首的大请求放不下时,后面本来放得下的小请求也只能等,显存和计算资源可能闲着。这是”公平”和”利用率”之间的经典权衡。
练习 2(延迟画像)。某模型的延迟画像是 毫秒。(a) 延迟目标 20 ms 时最大批和吞吐是多少?(b) 如果同机的干扰让每个输入的开销翻倍,原来的批会怎样?新的最大批是多少?(c) 用 AIMD(每批 +1,超时 ×0.9),从新上限开始,最少几批之后会再次超时?
参考答案
(a) ,吞吐 qps(代码 01 演示 1 的中等模型)。 (b) 34 的批要 ms,每一批都超时。新的最大批 。 (c) 从 17 开始:没超时就 +1,到 18 时 ms 超时。所以下一批就会超时,然后退到 ,再爬两批又超时。这就是演示 2 里 AIMD 在变慢期间违约 35% 的原因(再加上随机波动)。
练习 3(降低上下文上限)。代码 03 的”Orca(按最大长度预留)“按 8192 预留。假设运维把服务的最大上下文降到 4096(总长度超过的请求直接拒绝),重跑聊天负载在到达率 16 和 32 下的结果。会拒绝多少请求?换来了什么?
参考答案
600 个请求里只有 1 个总长度超过 4096,被拒绝。到达率 16 时两者都不过载,结果几乎一样(吞吐 3,715 / 3,714 tok/s,SLO 都是 100%,平均批 30),只是被占用的显存从 58% 降到 29%。到达率 32 时差别很大:按 8192 预留时吞吐 4,516 tok/s、SLO 达成 20%、平均批 39;按 4096 预留时吞吐 5,748 tok/s、SLO 达成 100%、平均批 61。拒绝 0.2% 的超长请求,换来了能承受的负载翻倍。这说明在预留式的显存管理下,“最大上下文”这个看似无关的配置会直接决定并发能力。这也是为什么当时的服务常常设比较保守的上下文上限。PagedAttention 之后,这个两难基本消失了。
练习 4(补齐的代价)。代码 02 演示 1 的那一步:两个 prefill(新 token 37、9,上下文 0),三个 decode(新 token 1,上下文 120、15、64)。如果注意力也补齐成规整张量(每个请求都补齐到 37 个 query、121 个 key),要算多少个 (query, key) 对?有用的(因果掩码允许的)有多少个?
参考答案
有用的:prefill 37 个 token 是 ,prefill 9 个 token 是 ,三个 decode 各是上下文 + 1:,合计 950 对。补齐后: 22,385 对,是有用部分的 23.6 倍。线性层补齐浪费 74%(§3.2),注意力补齐浪费得更多,因为它同时在两个维度上补齐。所以 Orca 让注意力”拆开算”,而不是”补齐算”。
练习 5(什么时候排序才有意义)。把代码 04 演示 3 的 max_bs 从 64 改成 256(到达率仍是 40),比较 FCFS 和 SJF(神谕)。
参考答案
两者结果完全相同:平均 TTFT 31.8 ms,P99 115.3 ms,SLO 100%,运行中的请求最多 169 个。max_bs = 256 时这个负载根本不排队,每个请求一到就能被接纳,等待队列的顺序不起作用。短作业优先之类的策略只在资源(座位、显存)不够、请求要排队时才有效。连续批处理本身已经把排队减少到很低,所以调度策略的价值主要在过载、突发,或显存紧张的长上下文场景里体现。
练习 6(流水线与微批)。Orca 有 3 个流水线 worker(层间并行)。(a) 为什么 FasterTransformer 要把一批切成微批才能用流水线并行?(b) 为什么 Orca 不需要?(c) Orca 的调度器在 Algorithm 1 里用哪个变量保证三个 worker 都有活干?
参考答案
(a) 在以请求为单位的调度下,一批交给引擎以后,要等整批(所有迭代)跑完才能交下一批。为了让流水线的每一段同时有活干,只能把这一批切成几个微批轮流送进流水线;可微批小了,每一段的批处理效率就降低,微批太大又会有气泡。 (b) Orca 每一批只跑一次迭代,调度器不必等它返回就可以组下一批(由别的请求组成),注入流水线。流水线里同时有几批在跑,每一批都是”完整大小”的批。 (c) n_scheduled:调度器持续注入新批,直到在引擎里运行的批数达到 n_workers,才等待最早的那一批返回(Algorithm 1 第 9–10 行)。
练习 7(机器数)。用代码 03 演示 2 的结果估算:要以”TTFT ≤ 1 s 且 TPOT ≤ 50 ms、达成率 ≥ 90%“的服务质量承载 100 请求/秒的聊天流量,静态批处理( = 128)、Orca(按最大长度预留)、Orca(神谕预留)各需要多少块 H100?假设流量能均匀分到各块卡上,并且每块卡独立服务。
参考答案
静态批: 块;Orca(按最大长度预留):,即 5 块;Orca(神谕预留):,即 2 块。迭代级调度把所需的机器数降了两个数量级,显存管理(第 3 讲)又能再省一半以上。现实中流量不会完美均匀,负载均衡和路由本身也是问题(第 11 讲)。
练习 8(实验长度)。把代码 03 演示 3 的请求数从 600 改成 3000,重新测 Orca(神谕预留)和静态批 = 128 的整体吞吐与稳态吞吐。
参考答案
Orca(神谕预留):600 个请求时整体 7,336、稳态 10,354 tok/s;3000 个请求时整体 10,363、稳态 11,850 tok/s。静态批 = 128:600 个请求时 1,495 / 1,590,3000 个请求时 1,412 / 1,418。实验变长以后,收尾的占比变小,两种口径越来越接近;而且 600 个请求时连”稳态吞吐”也被低估了(开头的爬坡、到达期太短),3000 个请求时 Orca 已经达到理想上限 12,656 的 94%。结论:比较过载吞吐时,实验要足够长,或者只统计稳态窗口。静态批处理对实验长度不敏感,因为它在任何阶段都同样低效。
练习 9(思考:怎样减少 prefill 对 decode 的干扰)。§6.4 的两种方式都没有消除干扰。在不增加 GPU 的前提下,你能想到什么办法让”新请求的 TTFT”和”正在 decode 的请求的 ITL”都不太差?它需要引擎支持什么?
参考答案(要点)
把长 prefill 切成小块(例如每块 512 个 token),每一步只混一块进来,同时限制每一步的总 token 数(token 预算)。这样每一步的时间有上限,正在 decode 的请求不会被一个巨大的 prefill 卡住;新请求的 prefill 分几步完成,TTFT 略有增加但可控。引擎需要支持”一个请求的 prefill 分几次做”,即 SeqWork(n_new=块长, n_past=已处理的部分)。我们的接口已经能表达它,ragged_batch.forward_batch 也天然支持(一个序列这一步送入的 token 可以是提示的一部分)。这就是第 7 讲 Sarathi-Serve 的分块 prefill。另一条路是把 prefill 和 decode 放到不同的 GPU 上(DistServe),代价是要在 GPU 之间传输 KV cache。
11. 延伸阅读 · Further reading
本讲的必读论文
- G.-I. Yu, J. S. Jeong, G.-W. Kim, S. Kim, B.-G. Chun. Orca: A Distributed Serving System for Transformer-Based Generative Models. OSDI 2022.
- D. Crankshaw, X. Wang, G. Zhou, M. J. Franklin, J. E. Gonzalez, I. Stoica. Clipper: A Low-Latency Online Prediction Serving System. NSDI 2017.
相关工作
- P. Gao, L. Yu, Y. Wu, J. Li. Low Latency RNN Inference with Cellular Batching(BatchMaker),EuroSys 2018:在 RNN 上按”单元”粒度调度和批处理,Orca 论文把它列为最相关的前人工作。
- NVIDIA. FasterTransformer 与 Triton Inference Server 的文档:理解 Orca 对比的基线。
- W. Kwon et al. Efficient Memory Management for Large Language Model Serving with PagedAttention. SOSP 2023(第 3 讲必读;§6 的 Orca (Max / Pow2 / Oracle) 就来自它)。
- A. Agrawal et al. Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve. OSDI 2024(第 7 讲;分块 prefill)。
- Y. Sheng et al. Fairness in Serving Large Language Models. OSDI 2024(多用户之间的公平调度)。
背景
- 网络拥塞控制中的 AIMD:任何一本计算机网络教材讲 TCP 拥塞控制的章节。
- J. Nagle. Congestion Control in IP/TCP Internetworks. RFC 896, 1984(Nagle 算法)。
12. 下一讲预告 · Next lecture
本讲看到,连续批处理把调度问题解决得差不多了,剩下的瓶颈落在显存上。Orca 按最大长度预留 KV,显存几乎被占满,真正用到的只有四分之一。第 3 讲:推理引擎的显存与前缀复用读两篇论文。vLLM 的 PagedAttention 借用操作系统的虚拟内存和分页,把每个请求的 KV cache 切成固定大小的块、按需分配,让显存浪费接近于零,并让并行采样、beam search 的多个序列共享 KV。SGLang 的 RadixAttention 把不同请求之间的共享前缀(系统提示、多轮对话、few-shot 示例)组织成一棵基数树,自动复用 KV cache。我们会把迷你引擎的显存管理器换成分页的,看看并发数和 goodput 还能提高多少,以及显存不够时的抢占(重算 vs 换出)要付出什么代价。
建议先读前置讲 P3(操作系统基础):虚拟内存、分页、写时复制、LRU 缓存、基数树,都是第 3 讲直接借用的概念。