单卡跑 7B 模型,nvidia-smi 显示利用率接近 100%,QPS 却上不去。多数人怪"卡不够",真实原因通常是调度:解码每步只算一个 token,并行单元大面积空转。
自回归生成把一次请求切成几千个近似串行的微步。批处理把这些微步拼成真正的并行,KV Cache 决定你能拼多宽。主流引擎之间的性能差距,大半落在这两件事的实现质量上。
1. prefill 与 decode:一张卡上的两种负载
一次请求进来,模型先对整段 prompt 做一次前向,得到首 token;之后每步只喂一个新 token,靠历史 KV 续算。这两个阶段的计算形态完全不同。
| 维度 | prefill | decode |
|---|---|---|
| 输入形状 | [B, S, H],整段 prompt |
[B, 1, H],每请求一个 token |
| 矩阵形态 | 大矩阵 × 大矩阵(GEMM) | 矩阵 × 向量(GEMV) |
| 计算特征 | 算力密集,吃 FLOPS | 访存密集,吃 HBM 带宽 |
| 主要限制 | Tensor Core 占用、注意力 O(S²) | 每步重读权重与 KV Cache |
| 可并行维度 | 序列内 token 并行 + batch 并行 | 只有 batch 维度 |
| 对 batch 的敏感度 | 低,一次摊薄 | 高,batch 越大越接近带宽饱和 |
| 对应指标 | TTFT(首 token 延迟) | TPOT / ITL(token 间隔) |
含义很直接:prefill 决定首字多快出现,decode 决定吐字多快,两者不能用一个"平均延迟"混着看。
# 分开采两个指标,别只看 e2e
vllm bench serve --model MODEL --num-prompts 200 \
--request-rate 8 --max-concurrency 32 \
--percentile-metrics ttft,tpot,itl,e2el
bench serve 参数名随 vLLM 版本变动过(早期是 benchmark_serving.py),跑前按当前版本 --help 核对。
2. 批处理为什么能提升吞吐
decode 每步的固定开销是"把全部权重从 HBM 读一遍"。batch=1 时,读 N GB 权重只换来 1 个 token,带宽几乎全浪费在搬运上;batch=B 时,同一份权重读一次服务 B 个 token,单位 token 的权重读取量降到 1/B。
| batch | 每步 HBM 读取量 | 每 token 摊到的权重 | 吞吐趋势 | 单请求延迟 |
|---|---|---|---|---|
| 1 | W + 1×KV | W | 基线,带宽浪费 | 最低 |
| 8 | W + 8×KV | W/8 | 近线性上升 | 略升 |
| 32 | W + 32×KV | W/32 | 接近饱和 | 上升 |
| 128 | W + 128×KV | W/128 | 转为 KV 带宽受限 | 明显上升,p99 恶化 |
W 是权重字节数,KV 是单请求单步的 KV 读取量。趋势是定性的,拐点由权重规模、KV 大小、卡的带宽决定,只能实测。注意吞吐与延迟在这里是相反的斜率。
3. 静态批:木桶效应与 padding
最朴素的批处理是"攒够 N 条一起跑,全跑完再收下一批",浪费有四种:
| 浪费 | 机制 | 后果 |
|---|---|---|
| 木桶效应 | 整批以最长输出为准,短请求算完只能占着位置 | 有效 batch 随时间单调衰减 |
| padding | 变长序列补齐到批内最长 | 无效 token 参与计算,也占 KV 显存 |
| 发车阻塞 | 批满才发车,或整批结束才收新请求 | 低峰期 GPU 空转,高峰期排队 |
| 长短混排 | 一个 8K 请求锁死整批 | 其余请求的 TPOT 被拖长数倍 |
一个 32 条的批,若平均输出 200 token、最长 2000 token,后半段实际只有一两条在算,其余 slot 白占显存。
4. 连续批处理:每步重新组批
in-flight batching(iteration-level scheduling)把调度粒度从"批"降到"步":
- 每一步开始时,调度器重算 running 队列与 waiting 队列;
- 已完成的请求立即退出,释放 slot 和 KV block;
- 新请求按剩余 token 预算立刻加入当前步,不等整批结束;
- 每步的 batch 组成可以不同,长度对齐只在本步内做。
vllm serve MODEL \
--max-num-seqs 64 \
--max-num-batched-tokens 8192 \
--gpu-memory-utilization 0.90 \
--enable-prefix-caching \
--swap-space 16 \
--max-model-len 8192
# TensorRT-LLM:in-flight batching 由 max_batch_size 与 max_num_tokens 两个上限共同约束
from tensorrt_llm import LLM
llm = LLM(model="MODEL",
max_batch_size=64,
max_num_tokens=8192,
enable_chunked_prefill=True)
chunked prefill 是配套件:把长 prompt 的 prefill 切块,与 decode 混在同一步跑,避免一个 32K prompt 卡住整批。代价是同步内混两种负载,算力与带宽互相挤占。
5. KV Cache 的显存公式
KV Cache 存的是每层每个 KV 头的 Key 与 Value。单请求占用:
KV_bytes = 2 × L × H_kv × D × S × dtype_bytes
2 是 K 和 V,L 层数,H_kv KV 头数(GQA/MQA 下远小于注意力头数),D 头维度,S 序列长度,dtype_bytes 精度字节(BF16 为 2)。乘上并发数 B 就是总占用。
def kv_bytes(layers, kv_heads, head_dim, seq_len, batch=1, dtype_bytes=2):
return 2 * layers * kv_heads * head_dim * seq_len * batch * dtype_bytes
# 假设:32 层、8 个 KV 头(GQA)、头维度 128、BF16
per_token = kv_bytes(32, 8, 128, 1) # 131072 B = 128 KiB / token
for s in (1024, 4096, 32768, 131072):
print(s, per_token * s / 2**30, "GiB/请求")
| 上下文长度 | 单请求 KV | 并发 1 | 并发 8 | 并发 32 |
|---|---|---|---|---|
| 1K | 128 MiB | 0.125 GiB | 1 GiB | 4 GiB |
| 4K | 512 MiB | 0.5 GiB | 4 GiB | 16 GiB |
| 32K | 4 GiB | 4 GiB | 32 GiB | 128 GiB |
| 128K | 16 GiB | 16 GiB | 128 GiB | 512 GiB |
表内数字均由公式推出,假设只有上面那一行;换模型就换 L、H_kv、D 重算。它解释了最常见的一类事故:上下文长度与并发是乘法关系。4K 调到 32K,同卡能承载的并发掉到八分之一。
6. PagedAttention:把 KV 当页管理
连续批处理要求 KV 能随时分配、随时回收,而传统做法是给每个请求按 max_model_len 预分配一块连续显存。
| 分配方式 | 内部碎片 | 外部碎片 | 支持动态长度 |
|---|---|---|---|
| 按最大长度预分配 | 高(实际长度远小于上限) | 高 | 差 |
| 连续重分配(realloc + copy) | 低 | 中 | 中,拷贝开销大 |
| 分块(paged,默认 16 token/块) | 低(尾部最多浪费一块) | 极低 | 好,块表映射 |
PagedAttention 借的是虚拟内存思路:逻辑连续、物理按块散落,用块表映射。收益是显存利用率上升、并发更多,并顺带支持前缀共享——多个请求指向同一批物理块,引用计数管理释放。
# 观察块利用率与碎片指标(Prometheus 端点)
curl -s http://HOST:8000/metrics | grep -E 'gpu_cache_usage|num_running|num_waiting|prefix_cache'
7. 前缀缓存与抢占换出
前缀缓存(prefix caching)留下相同前缀的 KV 块复用,典型场景是共享同一段 system prompt 或 few-shot 示例。
| 命中条件 | 说明 |
|---|---|
| token 序列逐字相同 | 空格、标点、换行不同即不命中 |
| 前缀必须从第 0 个 token 开始 | 中间插入时间戳、随机 ID 就断链 |
| 块边界对齐 | 不足一个块的尾部不进缓存 |
| 未被淘汰 | 缓存按 LRU 管理,冷门前缀会被挤掉 |
不命中的头号原因通常不是引擎,是提示词构造:把时间戳、请求 ID 放在 system prompt 最前面,每次都变,命中率恒为 0。静态前置、动态后移即可。
显存不够时引擎会抢占(preemption):把 KV 换到 CPU(swap),或丢弃后重算(recompute)。前者省算力费带宽,后者省显存费算力。
| 策略 | 代价 | 适用 |
|---|---|---|
| swap out/in | PCIe 往返,数十毫秒级 | 请求很快会恢复,prefill 很贵 |
| recompute | 重跑 prefill | 前缀短、算力有余 |
| 拒绝/排队 | 客户端等待 | 保护已在跑的请求 p99 |
抢占频繁发生时换出计数飙升、TPOT 抖动,这是"并发开过头"的信号。
8. 三个参数的互相牵制
| 参数 | 管什么 | 调大的后果 | 调小的后果 |
|---|---|---|---|
max_num_seqs |
同时在跑的请求数上限 | 并发高,但 KV 吃紧、抢占变多、p99 恶化 | GPU 喂不饱,吞吐上不去 |
max_num_batched_tokens |
单步 token 预算(prefill+decode 共用) | 单步更久,TTFT 可能变好、TPOT 变差 | 长 prompt 被切更多块,调度开销占比上升 |
gpu_memory_utilization |
引擎可占的显存比例 | 留给 KV 的块多,但易 OOM 或与同卡进程冲突 | KV 块少,并发被显存卡死 |
三者不是独立旋钮。max_num_seqs 开大而 KV 块不够,只能靠抢占维持,实际并发不升反降;gpu_memory_utilization 提到 0.95 而卡上还有别的进程,启动即 OOM。合理顺序:按模型算 KV 单请求占用 → 剩余显存除它得理论上限 → max_num_seqs 取上限六到七成 → 再压测调 max_num_batched_tokens。
9. 排错:六个高频现象
| 现象 | 根因 | 处理 |
|---|---|---|
| 并发开到显存上限,吞吐反而下降 | 频繁抢占,KV 换出/重算 | 降 max_num_seqs 至理论上限六成,看抢占计数 |
| 一个超长请求拖垮整批 | 无 chunked prefill 或预算过大 | 开 chunked prefill,限 max_num_batched_tokens |
| 压测吞吐漂亮,线上 p99 很差 | 只测了均值,没测分位与到达分布 | 用真实到达率压测,看 p99 TTFT 与 TPOT |
| 前缀缓存命中率接近 0 | 动态内容在提示词最前 | 静态前置、动态后移,逐字校验 |
| 启动就 OOM | gpu_memory_utilization 与卡上残留进程冲突 |
先清进程,留 10% 余量 |
| 首 token 很慢但吐字正常 | prefill 排队,调度预算被 decode 占满 | 提 max_num_batched_tokens 或做请求分级 |
10. 边界:什么时候这些不是重点
| 场景 | 更该关注 | 理由 |
|---|---|---|
| 单请求、低 QPS 的离线批跑 | 总吞吐与显存峰值 | 连续批处理的调度收益在低并发下不明显 |
| 极短输出(<16 token) | 调度开销与框架启动 | 每步固定开销占比过高 |
| 多卡张量并行 | 通信带宽与 TP 切分 | all-reduce 可能成新瓶颈 |
| 严格 SLA 的在线服务 | 分位延迟与降级 | 吞吐最优不等于体验达标 |
| 端侧 / CPU 推理 | 量化与内存带宽 | 没有大 batch 空间,思路完全不同 |
11. 自测
- 同一张卡上,为什么 prefill 阶段 GPU 利用率高、decode 阶段反而上不去?
max-model-len从 4K 调到 32K,在 KV 显存上放大了多少倍?依据哪个公式?- 静态批处理在长输出场景下,有效 batch 为什么会随时间衰减?
- PagedAttention 解决的是碎片问题还是拷贝问题?块表在其中起什么作用?
- 前缀缓存命中率为 0,最该先检查提示词的哪一部分?
- 压测吞吐达标但线上 p99 超时,可能漏了哪个维度的测量?