LLM 推理引擎显存与调度深度实战:从 PagedAttention 到 Continuous Batching 与 Prefix Caching 的生产调优
本文不讨论解码算法与采样策略,也不涉及结构化输出的语法约束。我们聚焦一个更"土"但更烧钱的问题:同样一张 A100,为什么别人的服务能跑 3 倍吞吐,而你的显存还剩 40% 就 OOM 了。
一、推理成本的真实构成:不是 FLOPs,是显存带宽
很多团队优化推理服务时,第一反应是换更小的模型、做量化、上算子融合。这些都是对的,但它们优化的是计算。而在自回归解码(decode)阶段,真正的瓶颈几乎从来不是算力,而是显存带宽——每一步 decode 都要把整个模型的权重从 HBM 读进 SM,只为了算一个 token。
对 7B 模型 FP16 而言,权重约 14GB。A100 80GB 的 HBM 带宽是 2TB/s,理论极限约 2TB/s ÷ 14GB ≈ 143 次前向/秒——这是单请求串行时的天花板。要突破它,唯一办法是批处理:把 N 个请求拼成一个 batch,一次读权重服务 N 个 token,算术强度提升 N 倍。
于是问题变成:batch 能拼多大? 答案由显存决定,而显存的大头不是权重,是 KV Cache。
二、先算清楚账:KV Cache 的显存公式
每个 token 在每一层都要缓存一份 Key 和 Value 向量。单 token 单层 KV 显存为:
per_token_per_layer = 2 (K和V) × n_kv_heads × head_dim × dtype_size
整条序列:
kv_bytes = 2 × n_layers × n_kv_heads × head_dim × dtype_size × seq_len
写个脚本把它落地,避免拍脑袋估:
def kv_cache_bytes(
n_layers, n_kv_heads, head_dim, seq_len,
dtype_bytes=2, # fp16
batch=1
):
"""计算 KV Cache 显存占用(字节)"""
per_token = 2 * n_layers * n_kv_heads * head_dim * dtype_bytes
return per_token * seq_len * batch
def human(n):
for u in ["B", "KB", "MB", "GB"]:
if n < 1024:
return f"{n:.2f}{u}"
n /= 1024
return f"{n:.2f}TB"
# Llama-3-8B: 32层, 8个KV head(GQA), head_dim=128
cfg = dict(n_layers=32, n_kv_heads=8, head_dim=128)
for s in (2048, 8192, 32768, 131072):
one = kv_cache_bytes(seq_len=s, **cfg)
print(f"seq={s:>7} 单条={human(one):>10} 64并发={human(one*64):>10}")
输出大致是:
seq= 2048 单条= 128.00MB 64并发= 8.00GB
seq= 8192 单条= 512.00MB 64并发= 32.00GB
seq= 32768 单条= 2.00GB 64并发= 128.00GB
seq= 131072 单条= 8.00GB 64并发= 512.00GB
这张表是整个推理工程的坐标原点。 它说明三件事:
- 长上下文是显存杀手,不是算力杀手。128K 上下文单条就要 8GB KV。
- 权重是固定成本(14GB),KV 是可变成本,且随并发线性增长。
- 一张 80GB 卡,扣掉权重、激活值、CUDA context 的约 20GB 余量,留给 KV 的只有 50GB 左右——并发数完全由平均序列长度决定。
所以"最大并发数"不是一个能写死的配置,它是序列长度的函数。这就是为什么新一代引擎不再预分配连续显存。
三、PagedAttention:把操作系统虚拟内存搬进 KV Cache
在 vLLM 之前,主流实现(如早期 FasterTransformer)为每个请求预分配一块连续的最大长度显存。三个致命浪费:
- 内部碎片:申请 2048 实际只用 300,剩余 1748 锁死。
- 外部碎片:不同长度的请求反复分配释放,显存被切碎。
- 预留浪费:系统必须按"最大长度 × 最大 batch"预留,导致并发上不去。
实测中,传统方案的 KV Cache 有效利用率通常只有 20%~40%。
PagedAttention 的解法直接照抄操作系统:把 KV Cache 切成固定大小的 block(默认 16 个 token),每个请求的逻辑序列通过一张 block table 映射到物理 block,物理 block 不必连续。
# 概念模型:非连续的物理块 + 页表映射
BLOCK_SIZE = 16
class BlockAllocator:
def __init__(self, num_blocks):
self.free = list(range(num_blocks))
self.tables = {} # request_id -> [physical_block_id, ...]
def alloc(self, req_id, n_tokens):
need = (n_tokens + BLOCK_SIZE - 1) // BLOCK_SIZE
tbl = self.tables.setdefault(req_id, [])
for _ in range(need - len(tbl)):
if not self.free:
raise RuntimeError("KV Cache OOM —— 触发抢占/换出")
tbl.append(self.free.pop())
def free_req(self, req_id):
for b in self.tables.pop(req_id, []):
self.free.append(b)
def physical_spans(self, req_id, token_idx):
"""注意力 kernel 需要的 (block_id, offset)"""
b = token_idx // BLOCK_SIZE
return self.tables[req_id][b], token_idx % BLOCK_SIZE
真正的工程难点在 kernel:注意力计算必须能接受不连续的 K/V 地址。vLLM 的做法是写一个 gather 式的 attention kernel,按 block table 做二级索引。代价是 kernel 比连续版本略慢(早期约 5~10%,后续融合优化已基本抹平),收益是显存利用率从 30% 提到 90%+,近乎零浪费,意味着并发直接翻 2~3 倍。
这里有个容易被忽略的红利:写时复制(Copy-on-Write)。当多个请求共享同一段前缀(比如同一份 system prompt),它们的 block table 可以指向同一批物理 block,引用计数大于 1 时才复制。这是后面 Prefix Caching 和并行采样(beam search、n>1 采样)能近乎零成本的基础。
四、Continuous Batching:把调度粒度从"请求"降到"一步"
即使显存够了,传统静态批处理(static batching)依然低效:一个 batch 里,短请求先生成完 EOS,但它的显存要到整个 batch 全部结束才能释放;新来的请求只能干等。GPU 在 batch 尾部大量空转,尾延迟被最慢的请求绑架。
Continuous Batching(迭代级调度) 的核心只有一句话:每生成一个 token 就重新调度一次。
class ContinuousBatchScheduler:
def step(self):
# 1) 回收:上一步结束的请求立刻释放 block
for r in list(self.running):
if r.finished:
self.allocator.free_req(r.id)
self.running.remove(r)
# 2) 准入:在显存与批大小预算内,尽可能塞入等待队列
while self.waiting and self.can_admit(self.waiting[0]):
req = self.waiting.popleft()
self.allocator.alloc(req.id, len(req.prompt_tokens))
self.running.append(req)
# 3) 一次前向:整个 running 集合一起 decode 一个 token
self.model.forward(self.running)
def can_admit(self, req):
need = (len(req.prompt_tokens) + self.max_new_tokens + BLOCK_SIZE - 1) // BLOCK_SIZE
return (len(self.allocator.free) >= need
and len(self.running) < self.max_num_seqs)
三个细节决定它能不能真的跑起来:
- Prefill 与 Decode 的混合:新请求进来要做 prefill(算整个 prompt,算力密集),老请求在做 decode(带宽密集)。早期实现让老请求陪着等 prefill,产生"decode 卡顿"。vLLM 的解法是 chunked prefill:把长 prompt 切成 chunk,和 decode 拼在同一个 forward 里。
- 准入控制(admission control):上面
can_admit必须按最坏情况(prompt + max_new_tokens)预留,否则中途 OOM。但过于保守会压低并发——实践中建议按分位数预估输出长度,而不是写死 max_tokens。 - 抢占与换出:显存不足时,vLLM 会把低优先级请求的 KV block 换出到 CPU 内存(swap),或者重算(recompute)。长 prompt 短输出用重算更划算,短 prompt 长输出用换出更划算,因为换出的代价是传输,重算的代价是 prefill FLOPs。
五、Prefix Caching:被低估的性价比之王
生产环境里大量请求共享前缀:同一套 system prompt、同一份 RAG 召回文档、同一个多轮对话历史、同一份代码仓库上下文。这些前缀的 KV 是完全可以复用的。
vLLM 的 Automatic Prefix Cache 与 SGLang 的 RadixAttention,本质都是把 KV block 按内容哈希索引成一棵前缀树(radix tree),新请求进来时做最长前缀匹配,命中的 block 直接复用,只 prefill 增量部分。
# vLLM:开启前缀缓存 + chunked prefill 的典型生产配置
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Meta-Llama-3-8B-Instruct \
--gpu-memory-utilization 0.92 \
--max-model-len 32768 \
--enable-prefix-caching \
--enable-chunked-prefill \
--max-num-batched-tokens 8192 \
--max-num-seqs 256
调用侧要配合一个关键习惯:把稳定不变的内容放前面,把变化的内容放最后。
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY")
SYSTEM = open("system_prompt.txt").read() # 3000 token,长期不变
def ask(doc: str, question: str):
# 顺序至关重要:system -> 文档 -> 问题
# 若把 question 插在中间,后续任何请求都无法命中前缀
return client.chat.completions.create(
model="meta-llama/Meta-Llama-3-8B-Instruct",
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": f"文档:\n{doc}\n\n问题: {question}"},
],
temperature=0,
)
收益量级:在一个"系统提示 2000 token + 文档 6000 token"的 RAG 场景里,开启前缀缓存后 prefill 计算量下降约 95%,首 token 延迟(TTFT)从 800ms 降到 120ms。这是所有优化手段里 ROI 最高的一个,成本几乎为零。
但需要清醒认识它的两个边界:
- 命中率取决于前缀稳定性。 如果在 prompt 里塞时间戳、随机 request_id、动态排序的检索片段,命中率会直接归零。
- 缓存有淘汰成本。 Radix tree 按 LRU 淘汰,高并发短前缀场景下树维护本身的开销不可忽略。SGLang 的实践是给节点设置最小复用收益阈值,避免为缓存 1 个 block 付出管理开销。
六、生产调优清单与踩坑记录
按投入产出比排序,这是我在线上验证过的顺序:
1. 先测真实的序列长度分布。 别用平均值,看 P50/P90/P99。并发上限由 P90 决定,而成本由 P50 决定。
2. gpu-memory-utilization 不要设太高。 0.95 看起来很诱人,但激活值和 CUDA graph 捕获还需要额外空间,遇到长序列突发就会 OOM。0.90~0.92 是稳态值,留给引擎缓冲。
3. 打开 chunked prefill,但限制 max-num-batched-tokens。 它允许 prefill 和 decode 混跑,但值太大会让单个 prefill chunk 占满一步,把 decode 的延迟打爆。8K~16K 是比较安全的区间,需要根据 P99 TTTP(time to first token)回调。
4. 量化要分清楚对象。 权重量化(AWQ/GPTQ)省的是带宽,KV Cache 量化(FP8)省的是容量,两者不冲突。FP8 KV Cache 能直接把并发翻倍,代价是长文本上偶发的注意力精度损失——建议只在 32K 以上上下文且任务对数值不敏感(如摘要、分类)时启用。
5. 多卡部署优先张量并行,谨慎用流水线并行。 TP 同步的是激活值,PP 会引入气泡。只有当单卡放不下模型时才考虑 PP,且 micro-batch 要开够。
6. 监控必须看调度指标,而不只是 GPU 利用率。 真正有意义的三个指标:waiting queue length(排队请求数)、running seqs(实际并发)、cache hit rate(前缀命中率)。GPU 利用率 100% 但队列很长,说明你的瓶颈在显存而非算力,此时加卡不如开前缀缓存。
七、结论
LLM 推理优化的本质,是一道在固定显存预算下做资源复用的工程题,而不是算法题。三件事按序解决了三个层面的浪费:
- PagedAttention 消灭了空间浪费,让显存利用率从 30% 到 90%;
- Continuous Batching 消灭了时间浪费,让 GPU 不再等最慢的请求;
- Prefix Caching 消灭了重复计算,让相同前缀只付一次钱。
如果你的服务还没做过这三项,几乎可以确定存在 2~5 倍的吞吐空间——而且不需要动模型、不需要加卡,只需要改配置和调用姿势。这比任何花哨的优化都实在。

发表评论 取消回复