流式输出下的 ITL 抖动:均值掩盖了什么
在 **** 的 ****(8 卡,单卡 64GB HBM,卡间互联 392 GB/s)上用 vLLM 0.6.x 起 OpenAI 兼容服务,客户端以流式返回逐 token 接收。分段指标里平均 ITL 与 SLO 相符,用户侧却反馈生成「一段快一段卡」。拉出分位后,ITL 的 P95 与 P99 明显高于均值,均值掩盖了少数被拉长的间隔。问题在于 ITL 是逐间隔统计的量,均值是分布的单一取样:同一个 decode 步内的 token 间隔相近,跨步的间隔随批构成变化,抖动落在步与步之间,被均值抹平。
ITL 与 TPOT 的口径
| 指标 | 定义 | 统计粒度 | 来源 |
|---|---|---|---|
| ITL | 相邻输出 token 的间隔 | 逐间隔 | 流式返回的相邻 chunk 时间差 |
| TPOT | 每输出 token 平均耗时 | 每请求 | (总时延 − TTFT) / (输出 token 数 − 1) |
| TTFT | 到达至首个输出 token | 每请求 | 首个 chunk |
| E2EL | 端到端时延 | 每请求 | 请求完成 |
TPOT 以每个请求的输出长度作分母,把长尾间隔平均进取;ITL 逐间隔保留分布。同一服务上两者常给出不同的分位,报告里必须写清用的是哪一个。
一步一 token:ITL 的物理来源
v0 引擎按步推进。每一步做一次前向,为每个运行中的序列追加恰好一个输出 token,因此同一序列相邻两个 token 的间隔等于相邻两个引擎步的时间差。单步耗时由三件事决定:本步批内的序列数、本步混入的 prefill token 数、本步固定开销(调度、主机侧下发、增量反分词)。任一项在步间变化,ITL 随之波动。
引擎侧的对应量是 vllm:time_per_output_token_seconds 直方图。它是逐步采样:每个 decode 序列组在每一步贡献一个样本,取值来自该序列相邻两次被调度之间的墙钟间隔,而不是请求结束时算出的平均。每个序列每步一个样本,因此样本数远多于请求数。
流式返回暴露了什么
流式把每个 token 单独下发,间隔被逐个观测;非流式一次性返回整段,只有 E2EL,看不出间隔分布。benchmarks/benchmark_serving.py 正是按流式取数:逐 chunk 记录时间,把每个请求的间隔列表累加到 itls,同时产出 ttfts、tpots、e2els。脚本注释指明 ttfts 在「后端不支持流式」时为空。
# 固定工况的在线压测入口(字段名示意)
python benchmarks/benchmark_serving.py \
--backend vllm \
--model <model-path> \
--num-prompts 80 \
--request-rate 8 \
--random-input-len 256 --random-output-len 64 \
--percentile-metrics ttft,tpot,itl \
--metric-percentiles 50,95,99
负载强度由 --request-rate(到达速率)与 --num-prompts 给定,该版本没有 --max-concurrency 这一项。到达间隔按指数分布采样,--request-rate 为 inf 时一次性发满。--percentile-metrics 的默认值是 ttft,tpot,itl,--metric-percentiles 的默认值只有 99——要得到 P50/P95/P99 必须显式传入,否则只能读到 ITL 的 P99。脚本对 ITL 输出的标签是 Inter-token Latency。
指标端读数
Prometheus 侧的 vllm:time_per_output_token_seconds 是累计直方图,桶边界为 0.01, 0.025, 0.05, 0.075, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.75, 1.0, 2.5(秒)。读分位要用 _bucket 差分出区间计数。同一端口上还有 vllm:num_requests_running、vllm:num_requests_waiting、vllm:num_requests_swapped、vllm:gpu_cache_usage_perc、vllm:num_preemptions_total,可与 ITL 分位的时间轴对齐看。
触发条件与现象
- 长短交错:长 prompt 的 prefill 在某一步占满 token 预算,同批 decode 的该步被拉长,出现单点尖峰。
- 高并发:批规模变大,单步耗时上升,ITL 基线与抖动同时抬高。
- 抢占与重算:KV 池紧张时序列被抢占,恢复后追加步,ITL 出现一个远离基线的间隔。
- 序列数在步间跳变(新请求入批、请求完成退出):批构成变化,ITL 呈阶梯。
- 增量反分词:逐 token 的主机侧开销按步累加,随
sampling_params.detokenize开关变化。 - 现象:P50 正常、P95/P99 抬升;多卡比单卡抖动更大;平均 ITL 达标而单条请求的间隔方差偏大。
定位步骤
- 取分位而非均值:对 ITL 取 P50/P95/P99,与 TPOT 的同组分位并排。
- 逐请求看间隔序列:确认尖峰出现在序列的哪个位置,首段、中段还是尾段。
- 对齐运行序列数与队列:把 ITL 尖峰与
vllm:num_requests_running、vllm:num_requests_waiting的时间轴对齐。 - 分段归因:把尖峰与 prefill 混入、抢占事件、反分词开销区分开。
- 固定取数口径:到达速率、输入输出长度分布、warmup 规则一并记录。
- 单卡多卡对照:判断抖动是否被通信放大。
处置
| 动作 | 作用 | 代价 / 前提 |
|---|---|---|
开 --enable-chunked-prefill 并固定 --max-num-batched-tokens |
长 prefill 切块,压平尖峰 | TTFT 上升 |
调小 --max-num-seqs |
缩小批规模,降低单步耗时 | 峰值吞吐下降 |
| 提高显存预算或降 KV dtype | 减少抢占与重算 | 量化带来精度代价 |
| 分流长短请求 | 避免长 prompt 与短请求同批 | 需调度或路由支持 |
| 固定分位口径并随工况记录 | 让两次测量可比 | 无 |
记录字段
# 一条流式 ITL 抖动记录(字段名示意)
metrics:
itl_p50_ms:
itl_p95_ms:
itl_p99_ms:
tpot_p50_ms:
tpot_p95_ms:
ttft_p95_ms:
e2el_p95_ms:
workload:
streaming: true # 是否流式取数
request_rate: # 到达速率,--request-rate
input_len: <分布>
output_len: <分布>
concurrency_observed: # 实际运行序列数
warmup_policy:
sampling_window:
resource:
max_num_seqs:
max_num_batched_tokens:
chunked_prefill:
gpu_cache_usage_perc_trace:
num_preemptions_total:
stability:
card_count:
小结
ITL 是逐间隔统计的量,均值是分布的单一取样。流式返回把每个 token 的间隔单独暴露,抖动落在 decode 步与步之间:批构成、混入的 prefill token、抢占与重算、增量反分词任一项在步间变化,间隔就出现尖峰。引擎侧的 vllm:time_per_output_token_seconds 逐步采样,天然保留分布。判定达标必须拉 P50/P95/P99,把 ITL 与 TPOT 的口径分开记,再把分位与到达速率、输入输出长度分布、实际运行序列数一起写清。均值达标而 P99 抬升时,问题在步间抖动,不在整体。