一张 H100 每秒能做约 989 万亿次 bf16 乘加,一个 13B 模型生成一个 token 只需要约 260 亿次运算。按算力算,它应该每秒吐出几万个 token;实际单个请求只有一百多 token/s,GPU 的算力利用率不到 1%。时间去哪了?答案几乎全在”搬数据”上。这篇从这个落差出发,一路推出现代推理引擎(以 vLLM 为例)里的每个零件。
前置:Transformer 架构、资源核算与 Scaling Laws(算术强度)、GPU 与 Kernel(HBM 与带宽)
一个 token 一个 token 地生成
训练时整句话都已知,模型一次前向就能对所有位置同时算 loss,序列维度可以并行,矩阵又大又方,GPU 吃得很饱。生成不一样:第 t+1 个 token 依赖第 t 个 token 的采样结果,只能一步一步来,这叫自回归。
最朴素的写法是每生成一个 token,把”prompt + 已生成的全部 token”整段重新喂给模型,只取最后一个位置的输出。一次前向里 attention 的开销是 O(T²),生成 T 个 token 总共就是 O(T³)。而这里绝大部分计算是重复的:Transformer 是因果的,后面追加 token 不会改变前面任何位置在任何一层的激活(双向模型就不行,加一个词全部都变)。
既然前面位置的激活不变,attention 里它们贡献的 K、V 向量也不变。把每一层每个位置的 K、V 存下来,下一步只给新 token 算 Q、K、V,用新 Q 去和”历史 K + 新 K”做点积即可。这块缓存就是 KV cache。下面用 4 层单头 attention 对比两种写法,输出完全一致,速度差距随长度拉大:
import torch, time
torch.manual_seed(0)
D, L = 64, 4 # 4 层单头 attention,够看清 KV cache 的账
Wq, Wk, Wv = (torch.randn(L, D, D) / D**0.5 for _ in range(3))
def attn(x, l, cache=None):
q, k, v = x @ Wq[l], x @ Wk[l], x @ Wv[l]
if cache is not None: # 只算新 token 的 K/V,历史的直接从 cache 拿
cache[l][0].append(k); cache[l][1].append(v)
k, v = torch.cat(cache[l][0]), torch.cat(cache[l][1])
T, S = q.shape[0], k.shape[0]
# 新 token 排在最后,第 i 个新 token 能看到前 S-T+i 个位置
mask = torch.ones(T, S).tril(S - T).bool()
s = (q @ k.T / D**0.5).masked_fill(~mask, float("-inf"))
return x + s.softmax(-1) @ v
def forward(x, cache=None):
for l in range(L): x = attn(x, l, cache)
return torch.nn.functional.normalize(x, dim=-1) * D**0.5 # 输出直接当下一个 token 的 embedding
prompt = torch.randn(16, D)
def gen_naive(n):
seq = prompt.clone()
for _ in range(n): # 每步把整段历史重算一遍
seq = torch.cat([seq, forward(seq)[-1:]])
return seq[16:]
def gen_cached(n):
cache = [([], []) for _ in range(L)]
out = forward(prompt, cache)[-1:] # prefill:整个 prompt 一次过
res = [out]
for _ in range(n - 1): # decode:每步只喂 1 个 token
out = forward(out, cache); res.append(out)
return torch.cat(res)
for n in (64, 256, 512):
t = time.perf_counter(); a = gen_naive(n); t1 = time.perf_counter() - t
t = time.perf_counter(); b = gen_cached(n); t2 = time.perf_counter() - t
print(f"n={n:3d} naive {t1*1000:7.1f} ms cached {t2*1000:6.1f} ms max diff {(a-b).abs().max():.1e}")n= 64 naive 21.8 ms cached 10.1 ms max diff 1.9e-06
n=256 naive 177.0 ms cached 63.1 ms max diff 2.1e-06
n=512 naive 619.4 ms cached 211.6 ms max diff 2.5e-06差值在 1e-6 量级,只是浮点累加顺序不同。cached 版本还不够快,因为每步 torch.cat 都把整段 cache 复制一遍,真实引擎会预先分配显存、原地写入。这个”怎么放 KV cache”的问题后面会变成 PagedAttention。
代码里已经出现了推理的两个阶段。prefill:把整个 prompt 一次送进模型,填满 KV cache,产出第一个 token,和训练前向一样可以沿序列并行。decode:之后每步只输入 1 个新 token,读整个 KV cache,追加一条。用户感受到的两个延迟指标正好对应它们:TTFT(time to first token)主要是 prefill 的时间;TPOT(time per output token)是 decode 每步的时间。服务方还关心吞吐:所有请求合计每秒产出多少 token。
为什么 GPT 这类 decoder 能用 KV cache,而 BERT 这类双向 encoder 逐词追加输入时不能?
KV cache 成立的前提是"旧位置的 K、V 永远不变"。因果 attention 里位置 i 只看 1..i,追加 token 不影响它;双向 attention 里每个位置都看全句,追加一个词所有激活都要重算。
BERT 同样有 Q/K/V 投影,也是多头;序列长短和能不能缓存无关。
瓶颈不是算力,是带宽
有了 KV cache,计算量从 O(T³) 降下来了,可 decode 依然慢。要看清原因,得用 资源核算与 Scaling Laws 里的算术强度:一个操作做的 FLOPs 除以它读写的字节数。H100 的算力 989 TFLOP/s 除以 HBM 带宽 3.35 TB/s 约等于 295,算术强度低于 295 的操作,GPU 大部分时间在等数据,叫 memory-bound。
先看 MLP。输入是 B 个序列、每个 T 个 token,权重矩阵 D×F。读权重 2DF 字节(bf16),计算 2·B·T·D·F FLOPs,D、F 远大于 B·T 时强度约等于 B·T。prefill 时 T 是整个 prompt 长度,几百上千,轻松 compute-bound。decode 时 T = 1,强度只剩 B,也就是同时在跑的请求数。每一步都要把全部权重从 HBM 读一遍,却只给 B 个 token 用。
再看 attention。decode 时每个序列用自己的 1 个 query 去和自己的 S 个历史 K、V 做点积,FLOPs 约 4·S·D,读的字节约 4·S·D(K 和 V 各 2 字节/数)。强度是 S·T/(S+T),T = 1 时等于 S/(S+1),小于 1,而且和 B 无关:MLP 的权重是所有序列共用的,batch 越大分摊越多;KV cache 是每个序列私有的,batch 加大,字节和 FLOPs 同比例增长。
既然 decode 是 memory-bound,时间就约等于”这一步要读多少字节 ÷ 带宽”。每一步要读全部参数加上所有序列的 KV cache。每个 token 的 KV cache 大小是:
L 是层数,K_kv 是 KV 头数,H 是每头维度。Llama 2 13B(40 层、40 个 KV 头、H=128)每个 token 800 KiB,一条 1024 token 的序列约 0.84 GB。用这个公式算一张 H100 上的延迟和吞吐:
def kv_bytes_per_token(L, kv_heads, head_dim, bytes_per=2):
return L * kv_heads * head_dim * 2 * bytes_per # 2 = K 和 V
bw = 3.35e12 # H100 HBM 带宽 B/s
params = 2 * 13.0e9 # Llama 2 13B, bf16
for name, K in (("MHA K=40", 40), ("GQA K=8 ", 8)):
kv = kv_bytes_per_token(40, K, 128) * 1024 # 每序列 1024 token
for B in (1, 64):
total = B * kv + params
lat = total / bw
print(f"{name} B={B:3d} 显存 {total/1e9:6.2f} GB {lat*1000:5.1f} ms/step {B/lat:6.0f} tok/s")MHA K=40 B= 1 显存 26.84 GB 8.0 ms/step 125 tok/s
MHA K=40 B= 64 显存 79.69 GB 23.8 ms/step 2691 tok/s
GQA K=8 B= 1 显存 26.17 GB 7.8 ms/step 128 tok/s
GQA K=8 B= 64 显存 36.74 GB 11.0 ms/step 5836 tok/s开头那个”125 token/s”就是这么来的:B = 1 时每一步都在读 26 GB 参数。把 batch 加到 64,参数的读取被 64 个请求分摊,吞吐涨到 2691,但每一步要多读 64 份 KV cache,单请求延迟从 8 ms 涨到 24 ms,显存也快满了。调 batch 时延迟和吞吐是此消彼长的:吞吐 = B·带宽 / (B·KV + 参数),B 趋于无穷时上限是 带宽 / 每序列 KV ≈ 4000 token/s。
后两行把 KV 头数从 40 降到 8(这就是 GQA,见 注意力变体与 MoE;示意里没扣掉 W_K、W_V 变小省下的参数)。batch 不变时延迟和吞吐同时变好,因为要搬的字节少了。这给出了推理优化的总纲:decode 的时间 ≈ 字节数 ÷ 带宽,任何减少字节的手段都直接变成速度。减少 KV cache 的结构改动(GQA、MLA、滑动窗口、跨层共享)属于模型架构,放在 注意力变体与 MoE;推理侧能做的是下面三件事:把每个数存得更小(量化),让一次读取产出不止一个 token(投机采样),以及把显存管理好、塞进更大的 batch(PagedAttention 与 continuous batching)。
decode 阶段(每步每个序列 1 个新 token),下面哪些说法正确?
MLP 权重被 batch 内所有序列共用,读一次给 B 个 token 用,强度随 B 增长;attention 每个序列读自己的 KV cache,FLOPs 和字节随 B 同比例变化,强度固定在 S/(S+1)。
所以加 batch 只救得了 MLP;decode 的根本瓶颈是读参数和读 KV cache 的字节数,不是计算量。
量化:每个数少存几位
既然时间正比于字节,最直接的办法是让每个数占更少字节。量化把 bf16 的权重(或激活、KV cache)映射到 int8、fp8、int4。最基本的线性量化:选一个 scale 和 zero point,
例如 s = 0.1、z = 4 时 5.2342 量化成 56,反量化回 5.2。scale 通常按通道或按小块单独取,块越小,离群值影响越小。
| 格式 | 字节/数 | 典型用途 |
|---|---|---|
| bf16 | 2 | 推理默认 |
| fp8(E4M3,最大约 448) | 1 | H100 起有硬件支持,权重、激活、KV cache 都可用 |
| int8 | 1 | 权重量化,激活量化要处理离群值 |
| int4 | 0.5 | 只量化权重(weight-only),计算时反量化回 bf16 |
权重从 bf16 换成 int4,decode 时读参数的字节变成 1/4,memory-bound 下几乎直接变成速度。注意 weight-only 量化不减少 FLOPs,乘法前还要反量化,所以它主要帮 decode(读得少),对 compute-bound 的 prefill 帮助有限;想让 prefill 也变快,需要权重和激活都低精度、用 fp8/int8 的 tensor core 直接算。
QAT 在训练时模拟量化误差,效果好但要重训;PTQ 训练完再用少量校准数据定 scale,便宜,是开源模型的主流。两个常见 PTQ 方法:GPTQ 逐层量化,每量化一部分权重,就用二阶信息(Hessian)调整还没量化的权重来抵消误差;AWQ 观察到少数激活通道数值特别大,和它们相乘的权重更重要,于是量化前先按激活大小放大这些权重通道(等价地缩小对应激活),让它们在量化格点上更精确。KV cache 同样可以量化,fp8 KV cache 能让同一张卡放下两倍的 token。
投机采样:一次读权重,产出几个 token
量化是有损的。有没有一种加速,输出分布和原模型完全一样?
关键观察来自前面的算术强度:decode 时 GPU 大部分算力闲着。让目标模型一次处理 K 个 token(像一次小 prefill),和处理 1 个 token 的耗时差不多,因为两者都要把权重读一遍。所以”检查 K 个 token”几乎和”生成 1 个 token”一样贵。投机采样(speculative decoding)利用这一点:先让一个小的 draft 模型 p 便宜地连猜 K 个 token,再让目标模型 q 一次前向并行算出这 K 个位置上的分布,逐个决定接受还是拒绝。
规则是:对 draft 采出的 token x,以概率 min(1, q(x)/p(x)) 接受;一旦拒绝,从残差分布 max(q − p, 0)(归一化后)重新采一个 token,本轮结束。如果 K 个全被接受,目标模型那次前向还顺带给出了第 K+1 个位置的分布,再白送一个。所以每轮至少产出 1 个、最多 K+1 个 token。
为什么无损?对任意 token x,得到它有两条路:draft 采到 x 且被接受,概率 p(x)·min(1, q/p) = min(p(x), q(x));或者 draft 采到别的被拒,再从残差采到 x,残差恰好补上 q(x) − min(p(x), q(x))。两条相加正好是 q(x)。用模拟验证:
import random
random.seed(0)
q = {"A": 0.2, "B": 0.5, "C": 0.3} # 目标模型
p = {"A": 0.6, "B": 0.2, "C": 0.2} # draft 模型:猜得不太准
def sample(d): return random.choices(list(d), weights=d.values())[0]
def spec_step():
x = sample(p)
if random.random() < min(1, q[x] / p[x]): return x, True
res = {t: max(q[t] - p[t], 0) for t in q} # 拒绝后只从"q 比 p 多出来"的部分采样
return sample(res), False
N = 200_000; cnt = {t: 0 for t in q}; acc = 0
for _ in range(N):
x, ok = spec_step(); cnt[x] += 1; acc += ok
print({t: round(c / N, 3) for t, c in cnt.items()}, "accept", round(acc / N, 3))
print("理论接受率 sum min(p,q) =", sum(min(p[t], q[t]) for t in q)){'A': 0.2, 'B': 0.501, 'C': 0.299} accept 0.598
理论接受率 sum min(p,q) = 0.6000000000000001draft 明显偏爱 A,输出分布却和 q 一致。单步接受率 α = Σ min(p, q),两个分布越像越高。假设各位置独立,一轮期望产出 1 + α + α² + … + α^K = (1 − α^(K+1))/(1 − α) 个 token。α = 0.8、K = 4 时约 3.36 个;K 加到 8 也只有 4.33 个,因为后面的 draft token 要前面全部被接受才有意义,而 draft 的成本随 K 线性增长。Chen 等人在 Chinchilla 70B 上报告 2–2.5 倍加速。
两个限制要知道。第一,收益来自 decode 时闲置的算力;batch 已经很大、decode 接近 compute-bound 时,验证 K 个 token 不再免费,加速会缩水。第二,draft 要和目标模型共用 tokenizer、分布要像,通常从目标模型蒸馏,或像 EAGLE 那样在目标模型上加小预测头。
draft 分布 p = [0.5, 0.5],目标分布 q = [0.2, 0.8](两个 token A、B)。draft 采到 A,接受概率是多少?如果被拒绝,输出什么?
接受概率 min(1, q(A)/p(A)) = 0.2/0.5 = 0.4。残差 max(q − p, 0) = [0, 0.3],归一化后是 [0, 1],所以一定输出 B。验证:P[A] = 0.5 × 0.4 = 0.2 = q(A)。
0.2 是 q(A) 本身;概率为 1 是把比值写反成 p/q;拒绝后若按 q 采样,A 会被多算一次,最终分布就不再等于 q。
PagedAttention:像操作系统一样管 KV cache
前面的账显示,吞吐靠大 batch,而 batch 能开多大取决于显存里能放多少 KV cache。早期推理系统给每个请求按”最大长度”预留一整段连续显存:请求的输出长度事先不知道,只能按上限留。结果一个最终只生成 10 个 token 的请求占着 2048 个位置(内部碎片),各请求区段之间的空隙又凑不成一整段(外部碎片)。vLLM 论文测得这类系统里真正存了 token 的只占 KV 区域的 20%–40%。
这和操作系统早年的内存管理是同一个问题,解法也一样:分页(见 虚拟内存)。PagedAttention 把 KV cache 切成固定大小的块(block,vLLM 默认每块 16 个 token),物理块可以散落在显存任何位置。每个请求有一张 block table,把逻辑块号映射到物理块号,attention kernel 按表去取 K、V。新 token 填满当前块才分配下一块,浪费只发生在每个请求的最后一块里,论文报告浪费降到 4% 以下,同样的显存能放下几倍的请求。
分页还顺带带来共享。同一 system prompt 的多个请求、并行采样同一 prompt 的多个回答,前缀的块完全相同,可以让多张 block table 指向同一批物理块,用引用计数管理,要写入一个共享块时再复制(copy-on-write,和 fork 一样)。vLLM 的 prefix caching 进一步把块按”内容哈希”索引,新请求来时先查已有的前缀块,命中就跳过那部分 prefill,直接降低 TTFT。
关于 PagedAttention,下面哪些说法正确?
分页的意义在显存管理:消除按最大长度预留带来的碎片,并支持前缀共享,从而在同一张卡上放下更大的 batch,提高吞吐。
它不改变 attention 的计算量;单个请求的 decode 甚至因为间接寻址略有开销,快是快在 batch 变大、吞吐变高。
Continuous batching:每一步都重新组 batch
显存有了,还要把请求真正组成大 batch。线上请求到达时间不同、输出长度不同。传统的静态 batching 凑齐一批一起跑,必须等最长的那个生成完,整批才能退出,先结束的请求空占位置,新来的请求在门外等。
Orca(OSDI 2022)的办法是 iteration-level scheduling:调度粒度从”一个 batch 跑完”缩到”一步 decode”。每一步结束后,生成完的请求立刻移出、释放 KV 块,排队的新请求立刻加入。batch 每步都在变,所以叫 continuous batching。
长度各不相同的请求怎么在一步里一起算?MLP、投影这些对每个 token 独立的操作,不关心 token 属于哪个请求,直接把所有请求的 token 拼成一个 [总 token 数, D] 的矩阵做一次 matmul,权重只读一次;只有 attention 需要按请求分开,各读各的 KV 块。这样完全不需要 padding。
还剩一个冲突:一个 30K token 的长 prompt 进来,它的 prefill 会独占好几百毫秒,这一步里所有正在 decode 的请求都被卡住,用户看到输出突然停顿。chunked prefill 把长 prompt 切成几段,每一步只算一段,和 decode 请求共享这一步的 token 预算。prefill 是 compute-bound、decode 是 memory-bound,混在同一步里反而互补:decode 带来的权重读取顺便喂给 prefill 的计算。规模再大时还可以把两者放到不同 GPU 上(PD 分离),代价是要传输 KV cache。
开启 chunked prefill 后,一个 30K token 的新请求到来,而本步 token 预算是 8K。会发生什么?
chunked prefill 让长 prompt 分多步完成,每步和 decode 请求共享 token 预算,避免一个长 prefill 独占一步、拉高其他请求的 token 间延迟。
超过单步预算不会被拒,只是分步算;"一次算完、decode 暂停"正是不切块时的问题;等 decode 全部结束是静态 batching 的思路。
拼起来:vLLM 的架构与接入一个模型
把以上零件装进一个系统,就是 vLLM(V1 架构)的主干:
请求 -> API Server(OpenAI 兼容接口、分词)
-> EngineCore(独立进程,循环:schedule -> execute -> update)
|- Scheduler + KVCacheManager(决定本步每个请求算几个 token、分配 KV 块)
|- Executor -> Worker(每张 GPU 一个)-> ModelRunner(拼输入、跑模型、采样)
-> Detokenizer -> 流式返回调度器把 prefill 和 decode 统一成一个问题:本步给每个请求分配多少个新 token。每步有 token 上限(max_num_batched_tokens)和请求数上限(max_num_seqs)。先给正在 decode 的请求各分 1 个(开投机采样则多几个),剩余预算分给排队的新请求,prompt 太长就只算一段,这就是 chunked prefill。然后给每个请求申请本步需要的 KV 块;块不够时抢占优先级最低的请求,释放它的块,之后重新计算。
显存预算在启动时定好:先加载权重,再用一次最坏情况的 dummy 输入跑前向,测出激活峰值,剩下的 总显存 × gpu_memory_utilization − 权重 − 激活峰值 全部切成 KV 块。比如 80 GB 卡跑 Llama 3 8B(权重约 16 GB,每 token KV 128 KiB),假设激活峰值 4 GB:80 × 0.9 − 16 − 4 = 52 GB,约能放 40 万个 token 的 KV。
decode 步很小,kernel launch 的 CPU 开销会变得显眼,所以 ModelRunner 用 CUDA Graph 把整步录下来重放。attention kernel 本身用 FlashAttention 一类的实现,按 block table 读分页的 K、V。
接入一个新模型,本质是把 HuggingFace 的建模代码改写成符合上面这套运行时的样子,分四步:
- config:vLLM 读模型目录的
config.json,按architectures字段(如Qwen2ForCausalLM)在注册表里找实现类,超参从hf_config取。 - 模型类:构造函数签名统一为
__init__(self, vllm_config, prefix=""),prefix是模块在 state dict 里的完整名字,量化配置和 attention 层绑定 KV cache 都靠它。forward收到的input_ids、positions是压平的一维张量,没有 batch 维、没有 padding,每个 token 属于哪个请求由调度器给 attention 层的元数据决定。所以要删掉 HF 代码里的attention_mask、past_key_values和 dropout、loss 等训练逻辑。线性层要换成支持张量并行(见 并行训练)的版本:QKVParallelLinear按头切 Q/K/V、MergedColumnParallelLinear合并 gate 和 up、RowParallelLinear做输出投影并在末尾 all-reduce。直接搬nn.Linear的模型开 TP=4 时每张卡仍放完整权重,等于白开。 - load_weights:checkpoint 里是分开的
q_proj、k_proj、v_proj,vLLM 里是合并的qkv_proj。用一张映射表把每个张量路由到合并参数的对应分片,每个并行层自带的weight_loader负责按当前卡号切出自己那片。函数返回已加载参数名的集合,加载器据此检查有没有漏加载的权重,这是最常见的 bug 来源。 - 注册:加进内置模型注册表,或者通过插件机制在外部注册,不改 vLLM 源码。
在 vLLM 的模型类里,forward 收到的 input_ids 为什么是一维的、没有 batch 维?
每步参与计算的请求数和各自 token 数都在变,拼成一维、不做 padding 是 selective batching 的直接实现;attention 层根据元数据知道每个 token 属于哪个请求、去读哪些 KV 块。
vLLM 恰恰是为大 batch 设计的;HF 的输入是 [batch, seq] 二维带 padding,所以移植时要删掉 attention_mask 等逻辑。
记住这三件事
- decode 每步都要把参数和全部 KV cache 从显存读一遍,时间约等于字节数 ÷ 带宽;减少字节的手段(GQA、量化、fp8 KV cache)都直接变成速度。
- batch 越大吞吐越高、单请求延迟越差;投机采样用闲置的算力一次验证多个 token,且输出分布与目标模型完全一致。
- 推理引擎的核心是显存管理和调度:PagedAttention 消除碎片、共享前缀,continuous batching 与 chunked prefill 让 batch 每步都满且不卡顿。
延伸阅读
- Stanford CS336 Lecture 10 推理(课程代码 lecture_10.py)
- Kwon et al. 2023, Efficient Memory Management for LLM Serving with PagedAttention
- Yu et al. 2022, Orca: A Distributed Serving System for Transformer-Based Generative Models
- Leviathan et al. 2022, Fast Inference from Transformers via Speculative Decoding;Chen et al. 2023, Accelerating LLM Decoding with Speculative Sampling
- vLLM 文档:Adding a New Model
- How to Scale Your Model: Inference 章