2906 字
约 9 分钟
14
S23-X03 · 推理引擎:连续批处理与 KV Cache

单卡跑 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)把调度粒度从"批"降到"步":

  1. 每一步开始时,调度器重算 running 队列与 waiting 队列;
  2. 已完成的请求立即退出,释放 slot 和 KV block;
  3. 新请求按剩余 token 预算立刻加入当前步,不等整批结束;
  4. 每步的 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. 自测

  1. 同一张卡上,为什么 prefill 阶段 GPU 利用率高、decode 阶段反而上不去?
  2. max-model-len 从 4K 调到 32K,在 KV 显存上放大了多少倍?依据哪个公式?
  3. 静态批处理在长输出场景下,有效 batch 为什么会随时间衰减?
  4. PagedAttention 解决的是碎片问题还是拷贝问题?块表在其中起什么作用?
  5. 前缀缓存命中率为 0,最该先检查提示词的哪一部分?
  6. 压测吞吐达标但线上 p99 超时,可能漏了哪个维度的测量?
S23-X03 · 推理引擎:连续批处理与 KV Cache
https://www.clxhxhhr.top/posts/5908/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。