把首 token 延迟砍掉 60% 的那一周
从 PagedAttention 的显存碎片,到投机解码里草稿模型的接受率调参。包含两个把 P99 搞得更糟的失败尝试。
线上反馈是”感觉很慢”。看监控,平均响应时间 2.1 秒,不算离谱。但首 token 延迟(TTFT)P99 是 4.8 秒——用户点了发送,要等将近五秒才看到第一个字。
体感上的”慢”几乎完全由 TTFT 决定,而不是总时长。这是我那一周最重要的收获。
先把时间花在哪里搞清楚
优化之前,我用最笨的办法:在推理链路上打了十几个时间戳。
import time
from contextlib import contextmanager
STAGES = {}
@contextmanager
def stage(name):
t0 = time.perf_counter()
try:
yield
finally:
STAGES.setdefault(name, []).append(
(time.perf_counter() - t0) * 1000
)
def report(pct=99):
import numpy as np
for k, v in sorted(STAGES.items(), key=lambda x: -np.percentile(x[1], pct)):
print(f"{k:28s} P{pct}={np.percentile(v, pct):7.1f}ms n={len(v)}")结果和我的猜测完全不同:
| 阶段 | P99 (ms) | 占比 |
|---|---|---|
| 排队等调度 | 2840 | 59% |
| prefill 计算 | 1150 | 24% |
| tokenize | 95 | 2% |
| 首 token decode | 62 | 1% |
| 其他(网络等) | 653 | 14% |
六成时间花在排队上。 我原本准备去优化 prefill 的 kernel,那会是在 24% 里抠几个百分点。
真正的瓶颈是调度
原来的实现用的是静态批处理:攒够一批请求或者等够超时时间,然后一起送进 GPU。问题在于批内有一条长 prompt,整批都要等它。
换成连续批处理(continuous batching)后,新请求可以插进正在运行的批次,不用等整批结束。这一项就让排队 P99 从 2840ms 降到 780ms。
两个失败的尝试
尝试一:把 batch size 开到最大。 直觉是吞吐会涨。实际结果是平均延迟降了 12%,但 P99 涨了 40%——大批次里总有一个倒霉请求要等很久。后来我把 batch 上限调回来了,改成按 token 总数限制而不是按请求数。
尝试二:投机解码,草稿模型选得太大。 我用了一个 1.5B 的草稿模型配 14B 主模型,接受率 78%,看着很漂亮。但草稿模型自己的推理开销吃掉了大部分收益,净加速只有 1.15 倍。换成 0.5B 的草稿模型后接受率掉到 61%,净加速反而到了 1.6 倍。
草稿模型的最优大小取决于接受率和自身开销的乘积,不是接受率越高越好。 这个我事后才想明白。
最终的收益拆解
| 优化项 | TTFT P99 改善 |
|---|---|
| 连续批处理 | −43% |
| 按 token 数限制批次 | −11% |
| PagedAttention 减少显存碎片 | −6% |
| 投机解码(0.5B 草稿) | −4% |
| 合计 | −60% |
排在第一位的那一项贡献了七成。这符合我对性能优化的一般印象:收益高度集中在一两个点上,找到它比优化它难得多。
所以那一周里,我真正在”优化”上花的时间不到两天,其余都在测量和验证。这个比例我认为是健康的。
如果你觉得我某个判断是错的,欢迎写信讨论。
