《投机解码与加速》把单条请求算快了,1000 个人同时来,GPU 又闲了—— 这一章的问题是:让 GPU 一直不闲着。瓶颈往往不在算力,而在显存和调度。
| 比什么 | 单次 | 服务化(Serving) |
|---|---|---|
| 关心什么 | 这一段算得快不快 | 单位时间能服务多少人 |
| 优化目标 | 延迟(你等多久) | 吞吐(GPU 每秒产出多少 token) |
| 只看一个会怎样 |
只看延迟 → 一个人独占 GPU,快得飞起,但成本高到无法上线 只看吞吐 → 攒一大批请求一起算,吞吐漂亮,但第一个用户等到怀疑人生 | |
服务化的全部工作,就是在延迟和吞吐之间找一个你能接受的平衡点——这两个指标互相冲突,下面那个互动会把它画出来。
像餐厅。延迟是"我这桌的菜多久上",
吞吐是"整个餐厅一小时能出多少道菜"。
只服务一桌,厨师全程盯着你这一道菜,上菜飞快——但餐厅亏死。
把 20 桌的菜一起下锅,出菜总量最大——但你这桌要等很久,而且可能凉了。
这个类比管到「几桌可以一起下锅」为止:真实推理里每个字是连续吐出来的,不是一个 batch 一道菜。
这套「来人速度 λ、服务速度 μ、负载 ρ = λ/μ」的算法叫排队论。后面每一节调的 batch,就是在动 μ——同一块卡每秒能服务多少人。
互动 · 一千个人同时来:队伍是这么爆掉的
| 指标 | 含义 | 用户感受 | 典型值(7B 模型) |
|---|---|---|---|
| TTFT Time To First Token |
从发请求到吐出第一个字 | 决定"它反应快不快" | 0.1 ~ 1 秒 |
| TPOT Time Per Output Token |
第一个字之后,每多一个字要多久 | 决定"它打字流不流畅" | 7 ~ 70 毫秒/字 随 batch、上下文变 |
| 吞吐 Throughput |
整个系统每秒产出多少 token | 用户看不到,但决定你的账单 | 1000 ~ 5000 tok/s |
为什么 TTFT 和 TPOT 要分开?因为它们的瓶颈完全不同。
TTFT 取决于"处理你输入的那几百个 token 要多久"——这一步叫预填充(prefill),是并行的,一次前向就搞定,
所以它对
TPOT 取决于"每生成一个字都要把整个 KV cache 读一遍"——这一步是串行的, 而且是访存密集的(瓶颈在读显存,不在算力)。所以它才是真正被 batch size 影响的那个。
看到"用户说很卡"时,先问清是首字慢还是打字慢——这是两个完全不同的问题。
互动 · 用户看到的那条时间轴:首字 + 逐字
这是这一章最重要的东西。先看传统做法为什么会浪费:
互动 · 静态批处理 vs 连续批处理(真实的逐 token 调度模拟)
静态批处理(等最长的跑完才换人)
连续批处理(每步都重新组 batch)
互动 · 空转白格:把「浪费」画成看得见的面积(利用率按格子数真实统计)
静态批处理(整批等到最长的那条跑完)
连续批处理(每步把空槽补满)
静态批处理:凑够一批就锁死,整批一起跑到最长的那个结束才释放。
短的请求早就生成完了,但它的槽位被占着,后面的请求只能等。
连续批处理(Continuous Batching / In-flight Batching):
每生成一个 token 就重新组一次 batch——谁生成完了立刻腾出槽位,
立刻从等待队列里补一个新请求进来。
只是把组批的粒度从「一条请求」换成「一个 token」,上面那张甘特图的 GPU 槽位利用率就从 55.8% 抬到 79.6%——请求长短越是参差不齐,差距越大。
这是 vLLM、TensorRT-LLM、TGI 这些推理引擎最核心的那个优化。
先算一笔真实的账。
把鼠标停在上面公式里的符号上——它下面的哪个控件会亮,就说明那个符号指的就是它。
0.5 MB 一个 token 听起来不多。但一个 4096 token 的对话就是 2 GB。 一张 40GB 的卡装下 7B 模型(14GB 权重)后只剩 26GB——只够 13 个并发长对话。 (本页的 GB / MB 都按 1024 进制算,也就是 GiB / MiB;显存厂商常用 10⁹ 口径,数字会小一点。)
互动 · 一个 token 的 KV cache 长什么样
互动 · 传统预分配 vs 分页管理(真实显存账本)
把鼠标停在上面那条公式里的符号上——它上面的哪个滑块会亮,就说明那个符号指的就是它。
传统做法是按最长可能长度预分配——你声明要 4096 个 token 的额度,
系统就一次性给你划 2GB,哪怕你实际只用了 300 个。
浪费率 60~80%,而且会产生大量无法利用的碎片。
PagedAttention 借用操作系统的思路:把 KV cache 切成固定大小的"页"(block)
(比如每页放 16 个 token),用到哪页才分配哪页,
页和页之间不需要在物理上连续。
结果:浪费从"按最长算"变成"平均浪费半页",显存利用率能从 20-40% 提到 90% 以上。
vLLM 论文报告,相同延迟下吞吐提升 2~4 倍。
这个类比管到「按页分配」为止:操作系统会把一页写回磁盘,KV cache 的页只在显存里挪位置,不会换出到硬盘。
① 间接寻址开销。读 KV cache 时不能直接顺序读,得先查一张页表。
这让本来就已经是访存瓶颈的 attention 更慢了一点——用吞吐换了一点延迟。
② 调度更复杂。要维护页的分配、回收、共享(比如同一个 prompt 的多个采样可以共享前缀的页)。
③ 块大小是个新旋钮。块太小 → 页表太长、间接寻址开销大;
块太大 → 浪费又回来了。通常 16 是个不错的起点。
| 方案 | 定位 | 强项 | 代价 |
|---|---|---|---|
| vLLM | 通用服务端的默认选择 | PagedAttention 的开创者;生态最活跃; 支持模型最全 | 极致低延迟场景略逊于专用引擎 |
| TensorRT-LLM | NVIDIA 官方,追求极致性能 | 编译后性能通常最好;对 NVIDIA 硬件压榨最彻底 | 编译流程重、调试难;换模型要重新编译;绑定 NVIDIA |
| SGLang | 复杂结构与高并发场景 | RadixAttention——自动复用多个请求的公共前缀。 在"同一个长系统提示 + 大量请求"的场景下优势巨大 | 社区规模比 vLLM 小 |
| TGI HuggingFace |
和 HF 生态集成 | 部署简单,模型转换省事 | 性能通常不如 vLLM |
| Triton NVIDIA |
模型服务框架(不只是 LLM) | 多模型、多框架统一调度;能做 A/B 测试、集成;也支持动态批处理(先把请求攒一小会儿凑一批——粒度是整条请求,和第 3 节的连续批处理不是同一件事) | 本身不含 LLM 优化,要配合后端 |
| Ollama / llama.cpp | 本地、单机、低并发 | 一行命令跑起来;量化支持好;CPU 也能跑 | 为单用户优化,高并发下吞吐远不如上面几个 |
互动 · 同一段系统提示,为什么要存很多份
要上线服务 → vLLM(除非你有专门的性能团队去啃 TensorRT-LLM)。 有超长公共前缀 → SGLang。 自己笔记本上跑着玩 → Ollama,但别拿它做生产服务——它是为"一个人用"设计的,并发一上来就原形毕露。
前面五节说的其实是同一句话:batch 开得越大,同一块卡服务的人越多,但每个人越慢。 这句话可以算出来——下面两张图都是真算的,拖滑块数字会跟着变。
互动 · 同一个旋钮的两端:吞吐上去,打字变慢
互动 · 每个符号管图上的哪一块
上面那行值是活的——它跟着这一节那两个滑块变;把鼠标停在公式里的符号上,图里对应的那块会亮。
把「batch」从 1 拖到 64,看两个数字同时变:每个字要等的时间 一直线性变慢,而吞吐在默认的 1024 上下文下只涨到 2783 token/秒(带宽天花板 4000 的七成)就快停了——
所以加大 batch 买到的吞吐是有限的,付出的延迟却是无限的。
这也是第 6 节那句话的来源:「同一个旋钮的两端」。
再回到第 3 节看那两张甘特图:连续批处理做的事,就是把组批的单位从「一条请求」降到「一个 token」,
于是这条曲线上你能站到更靠右的位置。
| 手段 | 换来什么 | 付出什么 | 什么时候真的会痛 → 换什么 |
|---|---|---|---|
| 加大 batch | 吞吐大幅上升(GPU 更饱和) | 每个请求的 TPOT 变长——你被别人的请求拖慢了 | 实时语音这种要 30 ms/字以内的场景 → 把 batch 调小,或用第 3 节的分块 prefill 把抖动压平 |
| KV cache 量化 FP16 → INT8 |
KV cache 显存砍半 → 并发数翻倍 | 输出质量会掉,而且掉多少必须实测。 有的任务几乎无损,有的长链推理会明显变差 | 任务对长链推理敏感时 → 先小流量实测质量,或只量化一部分层 |
| 权重 4bit 量化 | 权重显存砍到 1/4,能塞更大的模型 | 质量损失比 KV 量化更明显;不同量化算法的差距很大 | 输出质量要求高(代码、数学)→ 先试 INT8 / FP8,别一上来就 4bit |
| 前缀缓存 Prefix Caching |
相同系统提示的请求直接省掉重复计算,TTFT 大幅下降 | 要占显存存缓存;命中率低时反而浪费 | 请求前缀各不相同、命中率上不去 → 关掉它,别白占显存 |
| 投机解码 | 多花一个小 draft 模型的显存,就能把 TPOT 打下来 | 需要一个小模型当草稿;batch 很大时收益被稀释 | 低并发、延迟敏感时最划算;高并发稳态下 → 收益被稀释,可以不开 |
先量两个数,再决定动哪个旋钮
"每秒能服务多少人"和"每个人感觉多快"是同一个旋钮的两端。
你可以调 batch size 在两者之间滑动,但没有办法两边都拿到。
所以上线前必须先回答一个问题:你的用户能接受多慢?
聊天应用通常能忍 100ms/字,实时语音对话要压到 30ms 以下,
离线批处理则完全不在乎——这三种场景的最优配置完全不同。
不要一上来就换引擎、上量化。先测两个数:
① GPU 利用率——如果常年跑不到 70%,说明瓶颈不在nvidia-smi dmon 看 GPU 利用率。
② /metrics(vllm:kv_cache_usage_perc)看 KV cache 占用。
如果 KV cache 是大头,那提升并发数的关键就是 PagedAttention 和 KV 量化,而不是换更快的 GPU。
回到第 3 节第二张图,把「输出长度差异」拉到最右边:左边静态批处理的白块成倍膨胀,右边连续批处理只有收尾时才空出格子。静态以最长的请求为单位浪费,连续以单个格子为单位浪费。
这一章是系统章。下面三条联系,别章都没有。
动态 batch:一张每个 step 都被改写的表
| 暗线 | 这一章的回答 |
|---|---|
| A 信息流动 | 这一章的 batch 维度是动态的:请求进来是 [1, Lprompt],调度器拼成 [B, L],每个 step 各长一列 → [B, L+1],谁生成完就整行抽走、新请求整行换上。 别章的 batch 是你设的超参,这一章的 batch 是调度器每一步的决策。 |
| D 跑在什么上 | 两种受限分两个阶段:TTFT(prefill)是算力受限——整段输入一次矩阵乘,GPU 算力吃得饱;TPOT(decode)是带宽受限——每吐一个字都要把所有请求的 KV cache 读一遍,算术强度极低。完整对比见 《硬件与算力账本》。 |
| F 违背了哪个直觉 | 「想让每个人更快,就得给更多资源」——连续批处理反着来:让每个人稍慢,让所有人快得多。另两个反直觉:GPU 利用率低不是它算得慢,是在等最长那条请求;PagedAttention 借的是 1960 年代的操作系统虚拟内存。 |
它接住了 《投机解码与加速》解决的那一半:单条请求的延迟;这一章接另一半「同时服务多少人」,把空转的算力切成尽量多的并发请求。它给后面留的问题是:服务化只保证「快」,不保证「对」——那是 《评测、基准与幻觉》 的事。
服务化的核心是延迟 vs 吞吐这一对矛盾:加大 batch 提吞吐,就一定让单请求变慢;连续批处理把组批粒度从「一条请求」降到「一个 token」,PagedAttention 把 KV cache 按页(block)分配,两招分别把 GPU 利用率和显存里的并发数抬上去。 所以推理慢的时候先看显存和调度,不要先想着换卡。
上面讲的都是「够用」的版本。想往下挖,这里有三个入口—— 它们不是必修内容,是给想再往前走一步的读者准备的。