Benchmark:如何证明你的推理引擎真的更快

到目前为止,mini vLLM 已经实现了很多关键能力。

我们从最朴素的手写 generate 开始,逐步加入 KV cache,把请求抽象成 Sequence,实现 Scheduler,拆分 prefill 和 decode,引入流式输出和异步 Engine。随后,我们实现了 Block Manager,用 PagedAttention 解决 KV cache 的分页式访问,又继续加入 Prefix caching 和 Chunked prefill。

从工程直觉上看,这些优化应该能让系统更快、更稳、更能承载并发。

但直觉不够。

一个推理系统到底有没有变好,必须用 benchmark 证明。

Benchmark 的意义不是跑一个数字,然后说“提升了多少倍”。它更重要的作用是帮助我们拆解问题:到底是哪一部分变快了,哪一部分变慢了,哪个优化改善了吞吐,哪个优化改善了首 token 延迟,哪个优化只是把瓶颈转移到了别的地方。

如果没有 benchmark,我们很容易得出错误结论。

比如,某个优化让总吞吐上升了,但首 token 延迟变差了。对于离线批处理,这可能是好事;但对于在线聊天服务,用户体验可能反而变差。

再比如,某个调度策略让平均延迟下降了,但 P99 延迟显著变差。这意味着少部分用户会非常慢,线上体验可能不可接受。

所以这一篇我们要设计一套适合 mini vLLM 的 benchmark 方法。

目标不是一上来做特别复杂的压测平台,而是建立一个清晰的评估框架:测什么、怎么测、如何解释结果、如何用结果指导下一轮优化。

先区分两类场景:离线吞吐和在线服务

LLM 推理 benchmark 容易混乱的原因之一,是大家常常把不同场景的指标混在一起看。

离线场景和在线场景关注点并不一样。

离线场景更像批处理。

例如你有一批固定 prompt,要统一跑摘要、翻译、评测或数据生成。用户不关心每个请求什么时候开始返回,只关心整体多久跑完。

这种场景最重要的指标通常是吞吐。

每秒处理多少 tokens
每秒完成多少 requests
总耗时多少

在线场景不同。

在线服务面对的是持续到达的用户请求。用户关心模型什么时候开始响应,流式输出是否平滑,整个请求多久完成。

这种场景除了吞吐,还必须关注延迟。

首 token 延迟
每 token 延迟
请求总延迟
P90 / P99 尾部延迟

mini vLLM 是一个在线推理引擎,所以我们的 benchmark 不能只看 tokens per second。

吞吐当然重要,但延迟同样重要。

尤其是前面实现 Scheduler、Chunked prefill、Prefix caching 时,我们很多设计本来就不是单纯为了最高吞吐,而是为了让在线服务更平滑。

所以这篇文章的基准框架会围绕两个维度展开:

系统吞吐
用户延迟

一个好的推理系统必须在这两者之间取得平衡。

四个最核心的指标

我建议 mini vLLM 的第一版 benchmark 至少记录四类指标。

第一个是 Time To First Token,也就是 TTFT。

它表示用户请求进入系统后,到第一个输出 token 返回之间经过了多久。

TTFT = first_token_time - request_arrival_time

TTFT 主要受这些因素影响:

排队时间
prefill 时间
prefix cache 是否命中
chunked prefill 是否切分
调度策略是否让 waiting 请求及时执行

如果 TTFT 很高,用户会觉得模型迟迟不开始回答。

第二个是 Time Per Output Token,也就是 TPOT。

它表示生成阶段中,后续 token 的平均间隔。

TPOT ≈ decode_duration / output_token_count

TPOT 主要反映 decode 是否高效,流式输出是否顺滑。

它受这些因素影响:

decode batch 大小
PagedAttention 性能
KV cache 访问效率
running 请求是否被长 prefill 阻塞
Scheduler 是否稳定调度 decode

如果 TPOT 高,用户会看到输出很慢或一顿一顿。

第三个是 End To End Latency,也就是请求总延迟。

E2E latency = finish_time - request_arrival_time

它表示一个请求从进入系统到完全结束的时间。

这个指标受 prompt 长度、输出长度、排队、prefill、decode 全部影响。

第四个是吞吐。

吞吐可以分两种。

request throughput = completed_requests / total_time
token throughput = generated_tokens / total_time

对于 LLM serving,token throughput 通常更有代表性,因为不同请求输出长度可能差异很大。

如果一个系统每秒完成很多短请求,不一定等价于能高效生成长文本。

所以 benchmark 中最好同时记录 requests/s 和 output tokens/s。

这四类指标合起来,可以基本回答:

用户多久看到第一个 token
模型后续输出是否流畅
请求总体完成有多快
系统整体产能有多高

平均值不够,要看分位数

只看平均值很危险。

例如两个系统的平均 TTFT 都是 1 秒。

系统 A 的分布是:

大部分请求都在 0.8 到 1.2 秒之间

系统 B 的分布是:

90% 请求是 0.3 秒
10% 请求是 7 秒

平均值可能差不多,但用户体验完全不同。

系统 B 会让一部分用户非常痛苦。

所以 benchmark 必须记录分位数。

至少记录:

P50
P90
P99

P50 表示中位数,反映典型用户体验。

P90 表示 90% 请求能在这个时间内完成,反映较慢请求的情况。

P99 表示最慢 1% 的请求,反映尾部延迟。

在线服务里,尾部延迟非常重要。

长 prompt、KV cache 不足、调度不公平、chunked prefill 参数不合理,都可能让 P99 变差。

如果一个优化提高了平均吞吐,但让 P99 TTFT 暴涨,我们不能简单说它是好优化。

这也是系统工程和单点性能优化的区别。

设计 workload:不要只测一种请求

Benchmark 的结果高度依赖 workload。

如果只测一种固定 prompt,结果往往没有代表性。

LLM 在线请求至少有两个重要变量:

prompt length
output length

prompt length 决定 prefill 压力。

output length 决定 decode 压力。

所以第一版 benchmark 可以设计几类典型 workload。

第一类是短 prompt、短输出。

prompt: 32 到 128 tokens
output: 32 到 128 tokens

这类请求常见于聊天问答。

它能测试系统处理大量轻量请求的能力。

第二类是短 prompt、长输出。

prompt: 32 到 128 tokens
output: 512 到 2048 tokens

这类请求 decode 压力大,适合测试 continuous batching、PagedAttention 和 decode 吞吐。

第三类是长 prompt、短输出。

prompt: 4096 到 16000 tokens
output: 32 到 128 tokens

这类请求 prefill 压力大,适合测试 prefix caching、chunked prefill 和 TTFT。

第四类是长 prompt、长输出。

prompt: 4096 到 16000 tokens
output: 512 到 2048 tokens

这是最重的情况,同时考验 prefill、decode、KV cache 显存和调度策略。

第五类是混合 workload。

真实服务通常不是单一类型请求,而是短请求、长请求、长输出请求混在一起。

混合 workload 最能暴露调度问题。

例如,长 prompt 是否会阻塞短请求?长输出请求是否会让 waiting 请求饥饿?chunked prefill 是否改善了 running 请求的输出卡顿?

如果只测整齐请求,这些问题都不明显。

请求到达模式也很重要

除了请求本身的长度,请求到达方式也会影响结果。

最简单的 benchmark 是一次性提交所有请求。

一次性提交 1000 个请求
等待全部完成

这种方式更接近离线 batch。

它能测系统最大吞吐,但不太像在线服务。

在线服务里,请求是持续到达的。

所以还需要模拟不同到达率。

例如:

每秒 1 个请求
每秒 5 个请求
每秒 20 个请求
每秒 100 个请求

到达率越高,系统排队越严重。

当到达率超过系统处理能力时,延迟会迅速上升。

这个点非常重要。

一个服务不是在任意请求量下都稳定。它有容量上限。

Benchmark 应该帮助我们找到这个上限。

我们可以逐步增加请求到达率,观察:

吞吐什么时候不再增长
TTFT 什么时候开始快速上升
P99 延迟什么时候失控
KV cache blocks 什么时候接近耗尽

这能帮助我们判断系统在不同负载下的稳定区间。

要对比哪些版本

为了证明每个优化的作用,我们不能只测最终版本。

更好的方式是做逐步对比。

可以设计几个版本。

第一个版本是 naive generate。

每个请求单独生成,不做 KV cache 或不做服务级调度。这个版本通常很慢,但它是最基础的对照组。

第二个版本是 single request with KV cache。

它能证明 KV cache 对单请求 decode 的作用。

第三个版本是 static batching。

把一批请求固定组成 batch,观察 padding 浪费和 batch 逐渐变空的问题。

第四个版本是 continuous batching。

让 Scheduler 每轮动态选择 waiting 和 running 请求。这个版本应该明显改善在线吞吐和请求等待。

第五个版本是 paged KV cache。

引入 Block Manager 和 PagedAttention。这个版本主要看显存利用率、最大并发数和长上下文下的稳定性。

第六个版本是 prefix caching。

在有共享前缀的 workload 下,观察 TTFT 和 prefill token 数下降。

第七个版本是 chunked prefill。

在混合 workload 下,观察长 prompt 对 decode TPOT 和 P99 延迟的影响是否减小。

这种逐步对比比只测最终系统更有解释力。

因为每个优化解决的问题不同。

KV cache 主要减少重复计算。

Continuous batching 主要提高并发 decode 效率。

PagedAttention 主要改善 KV cache 显存管理。

Prefix caching 主要减少重复 prefill。

Chunked prefill 主要改善长 prefill 对在线输出的阻塞。

如果不分版本,很难知道最终性能来自哪里。

记录系统内部指标

除了用户可见指标,还应该记录系统内部指标。

因为用户指标只能告诉我们“慢了”,但不一定告诉我们“为什么慢”。

对于 mini vLLM,我建议至少记录这些内部指标:

waiting queue 长度
running queue 长度
prefilling queue 长度
每轮 prefill tokens 数
每轮 decode sequences 数
每轮实际 batched tokens 数
free KV blocks 数
prefix cache 命中率
prefix cache 占用 blocks 数
每轮 step 耗时
model forward 耗时
sampling 耗时
detokenization 耗时

这些指标非常有用。

例如,如果 TTFT 变高,同时 waiting queue 长度持续增长,说明请求进系统后排队严重。

如果 TPOT 变高,同时每轮 decode sequences 数下降,说明 decode batch 变小或被其他任务挤占。

如果 free KV blocks 持续下降,说明并发请求太多,或者 block 释放有问题,或者 prefix cache 占用过大。

如果 prefix cache 命中率很低,但它占用了很多 blocks,说明缓存策略可能不划算。

如果 sampling 耗时显著上升,说明瓶颈可能不在 attention,而在大 vocab 采样。

这些内部指标可以帮助我们解释 benchmark 结果。

没有内部指标,benchmark 很容易停留在表面。

用时间线理解调度行为

对于 Scheduler、chunked prefill 这类优化,单纯看平均值还不够。

最好能输出一条时间线。

例如每一轮 step 记录:

step id
调度了多少 decode sequences
调度了多少 prefill tokens
是否有 chunked prefill
step 耗时
产生了多少 output tokens

然后画出随时间变化的曲线。

通过时间线可以观察到很多现象。

如果没有 chunked prefill,可能会看到某些 step 里 prefill tokens 突然很高,同时 decode output tokens 降低,step 耗时变长。

这说明长 prefill 阻塞了 decode。

引入 chunked prefill 后,prefill tokens 会更平滑地分布在多个 step 中,decode output tokens 也更稳定。

这类可视化非常有说服力。

因为它不仅告诉读者“P99 下降了”,还展示了为什么下降。

技术博客里,benchmark 最好不要只贴表格。时间线图、队列长度图、free blocks 曲线都会让文章更有洞察。

设计 prefix caching 的专门实验

Prefix caching 需要专门 workload 才能体现价值。

如果所有 prompt 都完全不同,它不会有明显收益。

可以构造几组共享前缀实验。

第一组是不共享前缀。

所有请求 prompt 都不同,用来作为基线。

第二组是共享短前缀。

例如所有请求共享 128 tokens 的 system prompt。

第三组是共享长前缀。

例如所有请求共享 4096 tokens 的文档前缀,后面接不同问题。

第四组是部分共享。

比如 50% 请求共享前缀,50% 不共享。

第五组是缓存命中和淘汰混合。

构造多个不同前缀,让 prefix cache 容量不够保存全部,观察 LRU 淘汰和命中率。

对于这些实验,重点看:

TTFT 是否下降
prefill tokens 是否减少
prefix cache 命中率
prefix cache 占用 blocks
free blocks 是否被缓存挤占

Prefix caching 的效果不应该只看吞吐。

它最直接的收益是减少重复 prefill,从而降低共享前缀场景下的首 token 延迟和 prefill 计算量。

但它也有成本:缓存会占用 KV blocks。

如果缓存命中率低,反而可能降低系统可用并发。

所以 benchmark 要同时看收益和成本。

设计 chunked prefill 的专门实验

Chunked prefill 的价值主要体现在混合 workload。

可以设计这样的实验。

系统里持续有一批短 prompt、长输出请求正在 streaming。

它们对 decode 稳定性敏感。

然后周期性插入长 prompt、短输出请求。

这些长 prompt 会造成 prefill 压力。

对比两种配置:

不开启 chunked prefill
开启 chunked prefill

观察指标:

running 请求的 TPOT
TPOT P90 / P99
step 耗时波动
decode output tokens 曲线
长 prompt 请求的 TTFT
整体吞吐

预期现象是:

不开启 chunked prefill 时,长 prefill 到来会让某些 step 耗时突然变长,running 请求的 token 间隔变大。

开启后,长 prefill 被拆开,decode 输出更稳定,TPOT 尾部延迟下降。

但同时,长 prompt 请求自己的 TTFT 可能略有上升,因为它被切成多轮,并和 decode 交错执行。

这个实验很好地体现了系统取舍。

Chunked prefill 不是无条件让所有指标变好。

它是在牺牲部分长请求连续执行速度的情况下,改善整体在线服务平滑性。

这个结论比单纯说“chunked prefill 更快”更准确。

显存利用率怎么测

PagedAttention 和 Block Manager 的核心收益是显存利用率,所以 benchmark 必须测这件事。

可以记录这些指标:

总 KV blocks 数
已使用 blocks 数
free blocks 数
prefix cache blocks 数
active sequence blocks 数
每个 Sequence 最后一个 block 的未使用 slots
最大并发 Sequence 数
因为 KV blocks 不足被拒绝或延迟的请求数

对于连续预分配和 paged block 管理,可以做一个对比实验。

给定同样的请求长度分布,观察同一张 GPU 上最多能同时承载多少请求。

连续预分配方式可能因为每个请求都预留最大上下文长度,很快耗尽显存。

Paged block 方式则按实际 token 数分配,只在最后一个 block 产生少量内部碎片。

可以计算一个粗略的内部碎片率:

wasted_slots_in_last_blocks / total_allocated_slots

这个指标能直观展示 block size 的影响。

block size 越大,最后一个 block 的浪费可能越多。

block size 越小,碎片越少,但 block table 和 kernel 管理开销更高。

这也为后续调 block size 提供依据。

Benchmark 要避免的坑

做 LLM benchmark 有很多容易踩的坑。

第一个坑是没有 warmup。

模型第一次运行可能包含 CUDA kernel 初始化、显存分配、图编译或缓存构建。直接把第一次运行算进结果,会污染数据。

所以 benchmark 前要先 warmup 一段时间。

第二个坑是没有固定生成长度。

如果使用真实采样,模型可能提前 EOS,导致不同版本生成长度不同。吞吐和延迟就不好对比。

做系统性能对比时,可以先使用固定 max tokens,并在测试中禁用 EOS 或使用合成输出逻辑,确保输出长度可控。

第三个坑是没有区分 prompt tokens 和 output tokens。

prefill 和 decode 的成本完全不同。只说“总 tokens/s”可能误导。

最好分别记录:

processed prompt tokens
generated output tokens

第四个坑是只测单一 batch。

连续 batching 的价值在动态到达和不同长度请求中才明显。只测整齐 batch,无法体现调度优势。

第五个坑是忽略采样和 detokenization 开销。

当 attention 优化到一定程度后,sampling、logits processing、detokenization 也可能变成瓶颈。

第六个坑是把不同硬件结果混在一起比较。

GPU 型号、显存带宽、驱动、CUDA、PyTorch 版本都会影响结果。文章中要明确实验环境。

第七个坑是只看最好结果。

benchmark 应该多跑几次,报告稳定结果,最好给出均值和分位数。

这些细节会影响文章可信度。

如果 benchmark 做得不严谨,读者很难相信优化结论。

第一版 benchmark 脚手架应该长什么样

mini vLLM 的第一版 benchmark 可以设计成一个简单驱动器。

它负责生成请求,按照指定到达率提交给 Engine,收集每个请求的时间戳和输出 token 数。

每个请求记录:

request_id
arrival_time
scheduled_prefill_time
first_token_time
finish_time
prompt_len
output_len
stop_reason

每个 step 记录:

step_id
step_start_time
step_end_time
num_prefill_tokens
num_decode_sequences
num_outputs
num_free_blocks
waiting_queue_len
running_queue_len

结束后汇总统计:

TTFT P50 / P90 / P99
TPOT P50 / P90 / P99
E2E latency P50 / P90 / P99
requests/s
output tokens/s
平均 queue length
平均 free blocks
prefix cache hit rate

这些数据可以先输出成 JSON 或 CSV。

后面再用脚本画图。

文章中不需要把完整代码贴出来,但应该说明 benchmark 的数据结构和指标计算方式。

因为读者需要知道这些数字是怎么来的。

如何解释 benchmark 结果

Benchmark 最重要的不是贴数字,而是解释数字。

例如,如果 continuous batching 比 static batching 吞吐更高,可以解释为:static batching 中请求陆续结束,batch 逐渐变空,而 continuous batching 每轮都能让新请求加入,保持 decode batch 更饱满。

如果 PagedAttention 版本最大并发更高,可以解释为:KV cache 按实际 token block 分配,减少预分配浪费,让同样显存承载更多 active sequences。

如果 prefix caching 降低 TTFT,可以解释为:共享前缀不再重复 prefill,实际计算的 prompt tokens 下降。

如果 chunked prefill 降低 TPOT P99,可以解释为:长 prefill 被拆成多个 chunks,decode 请求不再长时间等待。

同样,如果某个指标变差,也要解释。

例如 chunked prefill 可能让长 prompt 请求自己的 TTFT 增加,因为它不再连续执行完整 prompt,而是和其他请求交错。

这不是 bug,而是调度取舍。

一个好的 benchmark 章节应该呈现这种思考:优化不是魔法,它总是在不同指标之间移动成本。

小结

这一篇我们为 mini vLLM 设计了一套 benchmark 框架。

一个在线 LLM 推理引擎不能只看 tokens per second。它至少要同时关注 TTFT、TPOT、端到端延迟、吞吐和尾部延迟。

不同优化对应不同指标。

KV cache 改善单请求 decode 的重复计算问题。

Continuous batching 提高动态并发下的 GPU 利用率。

PagedAttention 改善 KV cache 显存管理和最大并发。

Prefix caching 减少共享前缀的重复 prefill。

Chunked prefill 降低长 prompt 对流式 decode 的阻塞。

Benchmark 的 workload 也必须多样化。短 prompt、长 prompt、短输出、长输出、混合请求、不同到达率,都能暴露不同瓶颈。

除了用户可见指标,还要记录系统内部指标,例如 queue length、每轮 prefill tokens、decode sequences、free blocks、prefix cache 命中率和 step 耗时。只有这样,才能解释为什么某个指标变好或变差。

到这里,mini vLLM 已经不仅能实现功能,也能评估功能。

下一篇文章,我们会进入一个更高级的生成优化:speculative decoding。

前面的优化主要围绕调度、KV cache 和 attention 访问。Speculative decoding 则从另一个角度出发:能不能用一个更小更快的模型先草拟多个 token,再让大模型一次性验证,从而减少大模型 decode 的步数。