第 12 章 · KV 缓存与混合注意力Latency, throughput & batching

latency、throughput 与 batching

深入阅读 · 第 12 章 KV cache——「快」到底怎么衡量,以及一台服务器如何把一个 memory-bound 的 decode 变成面向众人的 throughput。

KV cache 正是 decode 快的原因:每个新 token 复用它早已算好的 key 和 value,而不是把整段提示词重算一遍。但「快」其实是两个不同的数字,取决于你指的是哪一个;而一台真正的服务器要同时伺候的不是一个用户,而是数百个。这个子章节讲的就是服务层:先看速度怎么衡量,再看 batching 如何把你在演示里看到的那个 memory-bound decode 变成 throughput。

两个时钟——以及为什么平均值会骗人

当输出逐 token 流式吐出时,有两个时钟在走,它们量的是不同的东西。TTFT(time to first token)是第一个词出现前的停顿——它由受算力限制的 prefill 决定,prefill 要一口气消化你整条提示词。TPOT(time per output token,也叫 ITL,inter-token latency)是此后稳定的流式速度——由受显存带宽限制的 decode 决定。两者此消彼长,你分开调它们:10 ms 的 ITL,对那一个用户来说就是 100 tokens/sec。(对于非流式调用——一个 agent 发起一次工具调用、等整段 JSON ——你感受到的不是这两个时钟中的任何一个,而是总响应时间。)

陷阱在于:把其中任何一个当成平均值来报告。推理 latency 是右偏的:大多数请求聚集在典型值附近,拖着一条由倒霉请求组成的长长慢尾。mean 被那条尾巴拉向右边、把它掩盖了——于是工程师改用百分位来报告。P50 是中位数(一半更慢),P90 是十分之一,P99 是一百个里最糟的那个。可靠性工作盯的是 P90/P99,因为用户真正记住的正是那条尾巴。盯住两个时钟,也盯住平均值如何撒谎:

推理速度的两个时钟
流式输出——两个指标
TTFTTTFT ≈ 220 mstime to first token · 受算力限制的 prefill首个 token随后流式吐出 · ITL / TPOT10 ms
ITL 10 ms = 100 tokens/sec / 用户

两个时钟:TTFT 是第一个词之前的停顿(受算力限制的 prefill);此后这些词以稳定的 ITL / TPOT 流式吐出(受显存带宽限制的 decode)。TPS 是有歧义的—— “perceived TPS” 是单个用户的体感,“total TPS” 是整个服务的 throughput。 对于非流式调用(比如一次 agent 工具调用),你衡量的则是总响应时间。

latency 右偏——平均值掩盖了长尾
P50meanP90P95P99长长的慢尾 →
P50 = 1/2 更慢 · P90 = 1/10 · P95 = 1/20 · P99 = 1/100

latency 是右偏的:大多数请求聚集在众数附近,拖着一条长长的慢尾。于是 mean 落在 median右侧——平均值被长尾拉高,反而把它掩盖了。 工程师改用百分位来报告:P50(1/2 更慢)、P90(1/10)、P95(1/20)、P99(1/100)。 可靠性工作盯的是 P90 / P99

inference-time vs end-to-end

这个浏览器内的 demo 衡量的是最原始的 TTFT 与 TPOT——没有网络、没有排队、batch 为 1、单个用户。 生产环境报告的是 p95 / p99 end-to-end(GPU 上的时间加上网络加上排队), 因为在高负载下,用户记住的正是那条慢尾。当 inference 很快但 end-to-end 很慢时,要修的是基础设施,而不是模型。 你的 demo 是下限;生产环境活在长尾里。

示意性的整数——讲的是形状,并非本模型实测的 latency。

batching——一次权重读取,许多 token

现在是那根杠杆。回想 roofline:一个 decode step 把模型的 全部权重从内存里恰好读一遍,而在 batch 1 时,这整趟扫描只产出一个 token——最左侧的 memory 悬崖。解法直接得近乎令人难堪。让 N 个请求共用同一次权重读取,GPU 就用一次内存扫描吐出 N 个 token。total throughput 随 batch 上升,而内存流量纹丝不动——batching 就是 memory wall 的解药,也是 decode 负载沿 roofline 爬向 ridge 的方式。

各家服务器的区别在于何时启动一个 batch。static batching 等到 batch 装满,于是第一个到的为最后一个埋单。dynamic batching 在装满超时触发时启动,谁先到算谁。continuous(即 in-flight)batching——vLLMSGLang 的做法——在 token级别把新请求换进空位,于是每个几乎立刻开始、queue 始终很短。切换这三种,看请求 lane 重新排布时序:

batching——三种 schedule,一个 trade-off
in-flight · vLLM / SGLang

也叫 in-flight batching(TensorRT-LLM 的叫法)。新请求在 token 级别 插进空座位,于是每个几乎立刻开始、queue 始终很短。这正是 vLLMSGLang 的做法。

020406080req 1运行中req 2运行中req 3运行中req 4运行中time(ms)→
queue运行中
平均等待 + 运行: 30 ms
为什么 batching 能治好 memory wall

一个 decode step 把模型的 全部 权重从内存里读 一遍。让 N 个请求共用这一次权重读取,GPU 就用一次内存扫描吐出 N 个 token——total throughput 上升,而内存流量纹丝不动。这正是本课程讲过的 memory-bound decode 的解药。

那个旋钮:latency ↔ throughput
1
total throughput
56tok/s先升,后饱和
per-user latency
18ms / token随 batch 上升
🔒 你在这里: batch = 1 · 单用户 · 零 batching
你这一标签页的副本 vs 共享 API

这个浏览器内的 demo 以 单用户、batch = 1 运行——这是 memory-bound decode 的 最坏 情况,因为那一次昂贵的权重读取只驮着一个 token。共享的生产 API 把几十个陌生人编织进一个 continuous batch,让同一次权重读取被所有人分摊。这就是为什么托管模型在高负载下反而感觉快, 而你的私有副本始终卡在带宽上。同一个模型,相反的经济学。

三种 server batching schedule(static、dynamic、continuous / in-flight)的示意图:把请求画成毫秒轴上的 lane,外加一个 batch size 滑块——throughput 读数先升后饱和,per-user latency 随之上升——以及一个锁定的「你在这里:batch = 1」标记,对应单用户的浏览器内 demo。

合成毫秒轴上的示意时序,加上一个玩具版的 throughput/latency 模型——它讲的是每种 schedule 的形状,不是真实 benchmark。

关键在于 batching 是一个旋钮,不是白赚。把 batch 往上滑,两个读数朝相反方向移动:total throughput 先升后饱和(你离开 memory-bound 区间、撞上 compute 上限),而每个用户各自的 latency 上升(每一步要分摊的同行更多了)。挑一个 batch size,就是在那条 latency-对-throughput 曲线上选一个点——要么对单个用户快,要么对许多人来说每 token 更便宜。

这也正是为什么这趟浏览器之旅和托管 API 的体感不同:你是 batch 1 的单一用户,是 memory-bound decode 的最坏情况,因为那一次昂贵的权重读取只驮着一个 token。共享的生产服务器把几十个陌生人编织进一个 continuous batch,让同一次读取被所有人分摊——同一个模型,相反的经济学。