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 要走完你提示词里的每一个位置:如果整条提示词装得下这一步的 token budget,就一趟算完,否则会被拆成好几个 chunked pass(见下文);无论哪种,第一个 token 都要等到最后一个位置算完。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,让同一次读取被所有人分摊——同一个模型,相反的经济学。

Chunked prefill:让长 prompt 不再霸占 GPU

continuous batching 有一个我们一带而过的问题。如果新来的请求带着一个非常长的 prompt,它的 prefill 就是一次庞大的 compute-bound pass——而在这次 pass 跑完之前,其他所有在飞的请求什么都拿不到,它们的 decode 被卡住。一个人粘贴了一本小说,服务器上其他所有人的 token 就都被冻住。

解法是 chunked prefill:给每个 engine step 一个token 预算,把长 prompt 拆到足够多的 step 里去。每个 chunk 依然把自己的 keys 和 values 写进 cache,所以没有任何东西被重算——而且因为一个 chunk 在预算里留了余地,其他请求的 decode step 可以搭上同一次 forward pass。看一个 prompt 一步步走完:

Chunked prefill——一次一个 engine step
prompt · 18 tokens123456789101112131415161718其他请求R7R8LLMforward passpaged KV cacheB1B2B3B4B5block size = 4 tokens

一个很长的 prompt,不一定要挤进一次巨大的 forward pass。

这里的尺寸只是示意,为了让算术一眼看得清:8 tokens 的 budget 减去 2 个 decode,得到 6 tokens 的 chunk(6 + 6 + 6 = 18),而 4 tokens 的 block 正是 PagedAttention 论文自己举的例子。真实的服务里,每步的 budget 从 2048 左右(vLLM 的默认值,低延迟那一端)到 16384 甚至更高(为了吞吐),KV block 是 16 tokens(vLLM)或 1 token(SGLang)——一个 8192 tokens 的 prompt 在 2048 的 budget 下是 4 个 chunk。而且真实的 chunk 大小每步都在变,因为在跑的 decode 数量在变。

这段动画里有两个细节,比看上去更重要。第一,KV cache 是 paged 的——固定大小的 block 从左往右填,最后一个通常只填了一半。那点剩余就是唯一的浪费,而且每个序列最多浪费一个 block,而不是一上来就按最坏情况预留一整片连续空间。第二,first token 只在最后一个 chunk 之后才出现: 真正有用的 logits 在最后一个 prompt 位置上,所以之前每个 chunk 都是纯粹的 cache 填充——是实打实的工作,只是不出声。