流式输出下的 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 分位的时间轴对齐看。

触发条件与现象

定位步骤

  1. 取分位而非均值:对 ITL 取 P50/P95/P99,与 TPOT 的同组分位并排。
  2. 逐请求看间隔序列:确认尖峰出现在序列的哪个位置,首段、中段还是尾段。
  3. 对齐运行序列数与队列:把 ITL 尖峰与 vllm:num_requests_running、vllm:num_requests_waiting 的时间轴对齐。
  4. 分段归因:把尖峰与 prefill 混入、抢占事件、反分词开销区分开。
  5. 固定取数口径:到达速率、输入输出长度分布、warmup 规则一并记录。
  6. 单卡多卡对照:判断抖动是否被通信放大。

处置

动作 作用 代价 / 前提
开 --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 抬升时,问题在步间抖动,不在整体。