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,因为用户真正记住的正是那条尾巴。盯住两个时钟,也盯住平均值如何撒谎:
两个时钟:TTFT 是第一个词之前的停顿(受算力限制的 prefill);此后这些词以稳定的 ITL / TPOT 流式吐出(受显存带宽限制的 decode)。TPS 是有歧义的—— “perceived TPS” 是单个用户的体感,“total TPS” 是整个服务的 throughput。 对于非流式调用(比如一次 agent 工具调用),你衡量的则是总响应时间。
latency 是右偏的:大多数请求聚集在众数附近,拖着一条长长的慢尾。于是 mean 落在 median 的右侧——平均值被长尾拉高,反而把它掩盖了。 工程师改用百分位来报告:P50(1/2 更慢)、P90(1/10)、P95(1/20)、P99(1/100)。 可靠性工作盯的是 P90 / P99。
这个浏览器内的 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——vLLM 和 SGLang 的做法——在 token级别把新请求换进空位,于是每个几乎立刻开始、queue 始终很短。切换这三种,看请求 lane 重新排布时序:
也叫 in-flight batching(TensorRT-LLM 的叫法)。新请求在 token 级别 插进空座位,于是每个几乎立刻开始、queue 始终很短。这正是 vLLM 和 SGLang 的做法。
一个 decode step 把模型的 全部 权重从内存里读 一遍。让 N 个请求共用这一次权重读取,GPU 就用一次内存扫描吐出 N 个 token——total throughput 上升,而内存流量纹丝不动。这正是本课程讲过的 memory-bound decode 的解药。
这个浏览器内的 demo 以 单用户、batch = 1 运行——这是 memory-bound decode 的 最坏 情况,因为那一次昂贵的权重读取只驮着一个 token。共享的生产 API 把几十个陌生人编织进一个 continuous batch,让同一次权重读取被所有人分摊。这就是为什么托管模型在高负载下反而感觉快, 而你的私有副本始终卡在带宽上。同一个模型,相反的经济学。
合成毫秒轴上的示意时序,加上一个玩具版的 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 一步步走完:
一个很长的 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 填充——是实打实的工作,只是不出声。