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,因为用户真正记住的正是那条尾巴。盯住两个时钟,也盯住平均值如何撒谎:
两个时钟: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,让同一次读取被所有人分摊——同一个模型,相反的经济学。