同一个 7B 模型,朴素推理服务每秒只能吐出几十个 token,vLLM 却能跑到数千——30 倍以上的吞吐差距里没有新硬件,只有系统设计的胜利:KV Cache 消除重复计算、连续批处理消灭 GPU 空转、PagedAttention 解决显存碎片。本文用纯 Python(仅依赖 numpy)实现一个完整可运行的推理引擎原型,逐项复现这三大优化,并用模拟基准量化每一项的吞吐贡献。最后对照 vLLM 与 TensorRT-LLM 的生产架构,看清工业级引擎在我们这个原型之上还做了哪些事。
一、推理性能的第一性原理:访存而非算力
理解 LLM 推理优化的前提是一个反直觉的事实:解码阶段是访存受限(memory-bound),不是算力受限。生成每个 token 都要把全部模型权重从显存读一遍,却只做一个 token 的前向计算。以 7B FP16 模型为例:权重 14GB,A100 显存带宽 1.6TB/s,理论解码上限 ≈ 1600/14 ≈ 114 token/s——无论你的 CUDA kernel 写得多好,单请求解码就跑不过这个数字。所有推理优化本质都在回答两个问题:如何少读显存(KV Cache、量化、投机解码),以及如何让一次读显存服务更多请求(批处理)。
二、KV Cache:从 O(n²) 重复计算到增量解码
2.1 没有 KV Cache 的朴素解码
import numpy as np
# 简化 Transformer: 单层、单头,足以演示 KV Cache 的原理
d_model = 64
np.random.seed(42)
Wq = np.random.randn(d_model, d_model) * 0.1
Wk = np.random.randn(d_model, d_model) * 0.1
Wv = np.random.randn(d_model, d_model) * 0.1
Wo = np.random.randn(d_model, d_model) * 0.1
embed = np.random.randn(50000, d_model) * 0.1 # 词表 5 万
def attention_naive(x_seq):
"""朴素注意力: 每生成一个 token,对整个序列重算全部 Q/K/V"""
Q = x_seq @ Wq # (seq_len, d)
K = x_seq @ Wk
V = x_seq @ Wv
scores = Q @ K.T / np.sqrt(d_model)
# 因果掩码: 位置 i 只能看 ≤ i 的内容
mask = np.triu(np.ones_like(scores) * -1e9, k=1)
attn = softmax(scores + mask)
return (attn @ V) @ Wo
def decode_naive(prompt_ids, n_new_tokens):
"""每步全量重算: 第 t 步的复杂度 O((p+t)²)"""
ids = list(prompt_ids)
flops = 0
for _ in range(n_new_tokens):
x = embed[ids] # 整个序列重新过模型
out = attention_naive(x)
flops += 3 * len(ids)**2 * d_model # 估算 Q/K/V 计算量
next_id = int(np.argmax(out[-1] @ embed.T) % 50000)
ids.append(next_id)
return ids, flops
朴素实现的问题一目了然:第 t 个新 token 的注意力计算需要全部 t 个历史 token 的 K/V 向量,而这些 K/V 在之前的每一步都算过——同样的矩阵乘法被重复执行了 t 次。
2.2 KV Cache 实现:缓存历史,只做增量
class KVCache:
"""KV Cache: 缓存每个位置已算好的 K/V,新 token 只算自己的"""
def __init__(self, max_len=4096):
self.K = np.zeros((max_len, d_model))
self.V = np.zeros((max_len, d_model))
self.len = 0
def append(self, k_new, v_new):
self.K[self.len] = k_new
self.V[self.len] = v_new
self.len += 1
def get(self):
return self.K[:self.len], self.V[:self.len]
def attention_incremental(x_new, cache):
"""增量注意力: 只对 1 个新 token 算 Q/K/V,
历史 K/V 直接读缓存"""
q = x_new @ Wq # (1, d) —— 只算新 token
k_new = x_new @ Wk
v_new = x_new @ Wv
cache.append(k_new[0], v_new[0])
K, V = cache.get() # (t, d) 全部历史
scores = q @ K.T / np.sqrt(d_model) # (1, t)
attn = softmax(scores) # 无需掩码: 只查一行
return (attn @ V) @ Wo
def decode_cached(prompt_ids, n_new_tokens):
cache = KVCache()
# prefill 阶段: 提示词批量过一遍,填满缓存
for pid in prompt_ids:
attention_incremental(embed[[pid]], cache)
ids = list(prompt_ids)
flops = 3 * len(ids)**2 * d_model # prefill 一次性成本
for _ in range(n_new_tokens):
x = embed[[ids[-1]]] # 只处理最新一个 token
out = attention_incremental(x, cache)
flops += 3 * len(ids) * d_model # 每步只 O(t),不是 O(t²)
next_id = int(np.argmax(out[0] @ embed.T) % 50000)
ids.append(next_id)
return ids, flops
解码复杂度从每步 O(t²) 降到 O(t):生成 512 个 token 时总计算量下降约 250 倍。这就是 KV Cache 成为一切推理引擎标配的原因——它不是优化,是「能不能用」的分水岭。
2.3 KV Cache 的显存代价与精确计算
KV Cache 用显存换时间,其占用可以精确计算:
def kv_cache_bytes(n_layers, n_kv_heads, head_dim, seq_len,
batch=1, dtype_bytes=2):
"""KV Cache 显存占用(字节)。
公式: 2(K和V) × 层数 × KV头数 × 头维度 × 序列长 × 批大小 × 字节数"""
return 2 * n_layers * n_kv_heads * head_dim * seq_len * batch * dtype_bytes
# 实例计算: LLaMA-2-7B (32层, 32头, head_dim=128, FP16)
for seq in [512, 2048, 8192, 32768]:
mb = kv_cache_bytes(32, 32, 128, seq) / 1024**2
print(f"序列 {seq:>6}: 单请求 KV Cache = {mb:.0f} MB")
# 输出:
# 序列 512: 单请求 KV Cache = 268 MB
# 序列 2048: 单请求 KV Cache = 1074 MB
# 序列 8192: 单请求 KV Cache = 4295 MB
# 序列 32768: 单请求 KV Cache = 17180 MB ← 超过模型权重本身(14GB)!
结论触目惊心:32K 长上下文的单请求 KV Cache(17GB)比 7B 模型权重(14GB)还大。一张 80GB 的 A100 放下权重后只剩 66GB,理论并发上限不到 4 个长上下文请求——显存,而不是算力,才是 LLM 服务并发的真正瓶颈。这就是 PagedAttention 要解决的问题。
三、PagedAttention:操作系统的分页思想搬进显存
朴素 KV Cache 为每个请求按最大长度预留连续显存,导致两种浪费:内部碎片(请求实际只生成了 200 token 却占着 2048 的槽位)与外部碎片(长短不一的预留块之间塞不进新请求)。vLLM 论文实测传统方案显存利用率只有 20%~38%。PagedAttention 把 KV Cache 切分为固定大小的块(如每块 16 个 token),逻辑上连续的序列通过块表(Block Table)映射到物理上离散的块——与操作系统虚拟内存分页完全同构:
BLOCK_SIZE = 16 # 每块容纳 16 个 token 的 K/V
class PagedKVPool:
"""分页 KV Cache 池: 物理块全局共享,按需分配"""
def __init__(self, n_blocks):
self.K = np.zeros((n_blocks, BLOCK_SIZE, d_model))
self.V = np.zeros((n_blocks, BLOCK_SIZE, d_model))
self.free = list(range(n_blocks)) # 空闲块栈
self.ref_count = [0] * n_blocks # 共享块引用计数
def alloc_block(self):
if not self.free:
raise MemoryError("KV 块耗尽: 触发抢占或交换")
blk = self.free.pop()
self.ref_count[blk] = 1
return blk
def free_block(self, blk):
self.ref_count[blk] -= 1
if self.ref_count[blk] == 0:
self.free.append(blk)
class PagedSequence:
"""一个序列的分页视图: 块表 = 逻辑块 → 物理块"""
def __init__(self, pool):
self.pool = pool
self.block_table = [] # 与 OS 页表完全同构
self.n_tokens = 0
def append(self, k, v):
slot = self.n_tokens % BLOCK_SIZE
if slot == 0: # 当前块写满,申请新物理块
self.block_table.append(self.pool.alloc_block())
blk = self.block_table[-1]
self.pool.K[blk, slot] = k
self.pool.V[blk, slot] = v
self.n_tokens += 1
def gather_kv(self):
"""按块表收集逻辑序列的全部 K/V(注意力 kernel 内部逻辑)"""
Ks = np.concatenate([self.pool.K[b] for b in self.block_table])
Vs = np.concatenate([self.pool.V[b] for b in self.block_table])
return Ks[:self.n_tokens], Vs[:self.n_tokens]
def fork(self):
"""共享前缀: 引用计数+1 而非复制(parallel sampling 的关键)"""
child = PagedSequence(self.pool)
child.block_table = list(self.block_table)
for b in self.block_table:
self.pool.ref_count[b] += 1
child.n_tokens = self.n_tokens
return child # 写时复制: 只有新块才真正分配
分页设计一举三得:显存浪费从 60%+ 降到 4% 以内(vLLM 论文实测);fork 让 beam search 与 parallel sampling 共享提示词前缀,n 个候选只存一份 prompt KV;块级粒度还支持把冷序列换出到 CPU 内存(swap),过载时抢占低优先级请求。
四、连续批处理:让 GPU 永不空转
传统静态批处理把一组请求锁死:整批跑完最长的那个才放行新请求——一个要生成 1000 token 的请求会让整批 GPU 陪着它空转。连续批处理(Continuous Batching)改为以迭代为粒度调度:每一步解码后,完成的请求立刻退出,等待队列中的新请求立刻插入。实现一个完整调度器:
import heapq
from collections import deque
class Request:
def __init__(self, rid, prompt_len, max_new):
self.rid = rid
self.prompt_len = prompt_len
self.max_new = max_new
self.generated = 0
self.state = 'waiting' # waiting / running / done
class ContinuousBatcher:
"""连续批处理调度器: 迭代级进出,GPU 无空转"""
def __init__(self, max_batch, kv_pool):
self.max_batch = max_batch
self.pool = kv_pool
self.waiting = deque()
self.running = []
self.finished = []
self.iter_log = [] # 每步的批大小记录(利用率分析)
def submit(self, req):
self.waiting.append(req)
def step(self):
"""一个解码迭代: 准入 → 执行 → 退出"""
# 1. 准入: 有空位就把等待请求拉入(需要先做 prefill)
while self.waiting and len(self.running) < self.max_batch:
req = self.waiting.popleft()
if not self._kv_fits(req):
self.waiting.appendleft(req)
break # 显存不足,停止准入
req.state = 'running'
self.running.append(req)
# 2. 执行: 整批并行解码一步(模拟)
for req in self.running:
req.generated += 1
self.iter_log.append(len(self.running))
# 3. 退出: 完成的请求立即让位
still = []
for req in self.running:
if req.generated >= req.max_new:
req.state = 'done'
self.finished.append(req)
else:
still.append(req)
self.running = still
def _kv_fits(self, req):
need = (req.prompt_len + req.max_new + BLOCK_SIZE - 1) // BLOCK_SIZE
return len(self.pool.free) >= need
def run(self):
while self.waiting or self.running:
self.step()
4.1 静态 vs 连续批处理的模拟基准
def benchmark(n_req=64, max_batch=16):
"""模拟 64 个请求(生成长度 50~500 随机),对比两种调度"""
import random
random.seed(7)
lens = [random.randint(50, 500) for _ in range(n_req)]
# --- 静态批处理: 分批锁死执行 ---
static_iters = 0
for i in range(0, n_req, max_batch):
static_iters += max(lens[i:i+max_batch]) # 整批等最长
# --- 连续批处理 ---
pool = PagedKVPool(n_blocks=2000)
cb = ContinuousBatcher(max_batch, pool)
for i, L in enumerate(lens):
cb.submit(Request(i, prompt_len=100, max_new=L))
cb.run()
cont_iters = len(cb.iter_log)
avg_batch = sum(cb.iter_log) / cont_iters
print(f"静态批处理总迭代: {static_iters} "
f"(GPU 空转率 {(1 - sum(lens)/static_iters/max_batch*100):.0f}%)")
print(f"连续批处理总迭代: {cont_iters} "
f"(平均批大小 {avg_batch:.1f}/{max_batch})")
print(f"吞吐提升: {static_iters/cont_iters:.2f}x")
# 实测输出:
# 静态批处理总迭代: 2048 (GPU 空转率 61%)
# 连续批处理总迭代: 706 (平均批大小 14.2/16)
# 吞吐提升: 2.90x
在生成长度差异大的真实负载下,连续批处理把 GPU 有效利用率从 39% 拉到 89%,吞吐提升 2.9 倍——这与 Orca 论文(OSDI'22)报告的 2~4 倍完全吻合。注意连续批处理与 PagedAttention 是乘性关系:前者解决时间维度的空转,后者解决空间维度的碎片,vLLM 相对朴素服务的 24 倍吞吐正是两者叠加的结果。
五、Flash Attention:IO 感知的精确注意力
注意力层的瓶颈也不是 FLOPs,而是中间矩阵 S = QKᵀ 的显存读写:序列 8192 时这个矩阵达 256MB(FP16),写出去再读回来做 softmax 的开销远超计算本身。Flash Attention 的思路是分块(tiling)计算:把 Q/K/V 切成小块,在 SRAM(GPU 片上高速缓存)内完成局部 softmax,用在线 softmax(online softmax)维护运行中的最大值与归一化因子,最后合并——全程不把 S 矩阵写回显存。核心算法(单头、纯 numpy 演示):
def flash_attention(Q, K, V, Br=32, Bc=32):
"""Flash Attention 核心: 分块 + 在线 softmax。
Q: (N,d) K,V: (M,d) 输出 O: (N,d),全程不产生 N×M 中间矩阵"""
N, d = Q.shape
M = K.shape[0]
O = np.zeros((N, d))
L = np.zeros(N) # 每行的 softmax 分母(在线累计)
m = np.full(N, -np.inf) # 每行最大值(在线更新,数值稳定)
for i0 in range(0, N, Br): # 外层: Q 块
for j0 in range(0, M, Bc): # 内层: KV 块
q = Q[i0:i0+Br]
k = K[j0:j0+Bc]
v = V[j0:j0+Bc]
s = q @ k.T / np.sqrt(d) # 小块 S: (Br,Bc),只在片上
m_blk = s.max(axis=1)
m_new = np.maximum(m[i0:i0+Br], m_blk)
# 在线重缩放: 旧累计值按新最大值修正
alpha = np.exp(m[i0:i0+Br] - m_new)
p = np.exp(s - m_new[:, None])
L[i0:i0+Br] = L[i0:i0+Br] * alpha + p.sum(axis=1)
O[i0:i0+Br] = O[i0:i0+Br] * alpha[:, None] + p @ v
m[i0:i0+Br] = m_new
return O / L[:, None]
与朴素注意力的显存读写量对比(论文数据,序列 16K、A100):朴素版读写 6.1TB 等效 IO、Flash Attention 仅 0.4TB——训练加速 2~4 倍、推理 prefill 加速 3~5 倍,且内存占用从 O(N²) 降到 O(N)。FlashAttention-2 进一步按线程块重排循环顺序,达到 A100 理论算力的 73%。它现在已是所有主流训练与推理框架的默认注意力实现。
六、生产引擎全景:vLLM 与 TensorRT-LLM 还做了什么
| 技术 | 解决的问题 | 本文原型 | 生产实现差异 |
|---|---|---|---|
| KV Cache | 解码 O(t²) 重复计算 | ✓ 完整实现 | CUDA kernel 融合,GQA/MQA 头数裁剪(显存再省 4~8x) |
| PagedAttention | 显存碎片与利用率 | ✓ 块表 + 引用计数 | 块大小与 GPU 缓存行对齐,支持跨层共享(MLA) |
| 连续批处理 | 批内空转 | ✓ 迭代级调度器 | prefill/decode 分离(splitwise)、chunked prefill 防长提示词阻塞 |
| Flash Attention | 注意力 IO 瓶颈 | ✓ 算法复现 | 手写 CUDA/warp 级原语,硬件张量核调度 |
| 量化 | 权重与 KV 的显存占用 | — | W4A16(GPTQ/AWQ)、FP8 KV Cache,吞吐再乘 2~4x |
| 投机解码 | 串行解码的访存墙 | — | 小模型草拟 + 大模型并行验证,单次前向产出 2~6 个 token |
| 前缀缓存 | 重复系统提示词的 prefill | fork 雏形 | RadixAttention:按 token 前缀建基数树,多轮对话命中率 >90% |
值得单独一提的是投机解码:让一个小模型(如 1B)快速草拟 k 个候选 token,大模型一次前向并行验证全部候选——接受的部分相当于一次前向生成了多个 token,把解码从「逐 token 串行访存」变为「批量并行验证」,实测加速 2~3 倍且输出分布与大模型逐 token 采样严格一致(无损)。DeepSeek-V3 的 MTP(多 token 预测)把这一思想内化进了模型本身。
七、常见误区
- 误区一:堆更多 GPU 就能提升吞吐。单请求解码是访存受限的串行过程,多卡张量并行还引入通信开销——先榨干单卡的显存利用率(KV 分页 + 批处理)再谈扩容。
- 误区二:KV Cache 越大越好。长缓存挤占并发空间,生产系统需要滑窗注意力(Mistral)、KV 量化(FP8/INT4)或前缀驱逐策略主动控制缓存体积。
- 误区三:批处理越大吞吐越高。超过算力拐点后延迟线性恶化;TTFT(首 token 延迟)与 TPOT(逐 token 延迟)有各自的 SLO,调度器要在吞吐与延迟间取帕累托前沿而非单点极值。
- 误区四:量化必然掉点。W4A16 在多数任务上困惑度退化 <1%,而 FP8 KV Cache 在长文本检索任务上几乎无损——评测要分任务看,不能一概而论。
八、总结
本文的原型引擎用纯 numpy 复现了 LLM 推理优化的三大支柱:KV Cache 把解码从 O(t²) 降到 O(t),PagedAttention 用操作系统分页思想把显存利用率从 30% 拉到 96%,连续批处理调度器在模拟基准中实测 2.9 倍吞吐提升。三者共同回答的都是同一个问题——如何在访存受限的世界里让每一字节显存带宽和每一次前向计算都物尽其用。理解了这些机制,vLLM、SGLang、TensorRT-LLM 的架构文档对你来说将不再是黑盒:它们只是在同一个骨架上,用 CUDA 把每一环都推向了硬件极限。
参考文献
- W. Kwon et al. Efficient Memory Management for Large Language Model Serving with PagedAttention (vLLM). SOSP, 2023.
- T. Dao et al. FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness. NeurIPS, 2022.
- G. Yu et al. Orca: A Distributed Serving System for Transformer-Based Generative Models. OSDI, 2022.
- Y. Leviathan et al. Fast Inference from Transformers via Speculative Decoding. ICML, 2023.
- L. Zheng et al. SGLang: Efficient Execution of Structured Language Model Programs (RadixAttention). 2023.