27届大模型面试准备(五十九):大模型推理引擎内核深度剖析------PagedAttention、调度器与显存管理
引言:与上一篇、系列的关系
前面(二十五)讲推理服务化与投机解码、(五十三)讲多模态服务化、(五十四)讲成本与吞吐调优,都偏「架构与成本」。本篇往内核里再钻一层:一个推理引擎(vLLM、TensorRT-LLM、SGLang 之类)到底怎么把一次请求变成显存里的 KV Cache、怎么调度 token、怎么不浪费显存。这是面试「部署优化」这条线的硬核题,也是华为多模态 LLM 岗现场写代码/画架构高频考点。
推理引擎内核四大件
+--------------------------------------------------+
| 调度器 Scheduler |
| waiting / running / finished 状态机 |
| continuous batching (token级, 非request级) |
+--------------------------------------------------+
| 下发
+--------------------------------------------------+
| 显存管理 KV Cache Manager |
| PagedAttention: block 分页, 类似虚拟内存 |
| 预占/抢占(swap or recompute) |
+--------------------------------------------------+
| 计算
+--------------------------------------------------+
| 算子层 Operators |
| FlashAttention / fused kernel / CUDA Graph |
| 量化算子(AWQ/GPTQ dequant) |
+--------------------------------------------------+
| 加速
+--------------------------------------------------+
| 投机解码 / 前缀缓存 / 并行 verify |
+--------------------------------------------------+
第一节 推理的两阶段:prefill 与 decode 必须分开想
一次生成请求在引擎里分两段:
-
Prefill(预填充):把整段 prompt 并行喂进模型,一次性算出所有 prompt token 的 KV Cache,并产出第一个新 token。它是计算密集(compute-bound),能充分用满 GPU 矩阵算力。
-
Decode(解码):自回归逐个 token 生成,每步只算一个新 token 的 KV,但要读全部历史 KV Cache。它是访存密集(memory-bound),瓶颈在显存带宽而非算力。
prompt: [t1 t2 t3 t4] -> Prefill 并行 -> KV(t1..t4) + 首token
然后循环:
Decode step: 读 KV(t1..t4) + 新KV(t5) -> 预测 t6
Decode step: 读 KV(t1..t5) + 新KV(t6) -> 预测 t7
... 每步仅 1 个新 token, 但读全部历史 KV
工程含义:prefill 要大 batch、高并行;decode 要降访存、用投机解码/量化。把两者混在一起调度会互相拖累,所以很多引擎做 prefill/decode 分离(PD 分离,见五十四),把两段放到不同实例。
第二节 PagedAttention:给 KV Cache 做「虚拟内存」
朴素实现里,每个请求的 KV Cache 是预分配的一整块连续显存,长度按 max_length 算。问题:请求实际长度参差,短请求浪费大量预留显存;不同请求长度不一,连续分配产生外部碎片。PagedAttention 借鉴操作系统分页:
- 把 KV Cache 切成固定大小的 block(如每 block 存 16 个 token 的 KV)。
- 每个请求维护一张 block table,逻辑上连续的 token 映射到物理上可能离散的 block。
- 生成时按需分配新 block,不再预留整段;请求间可共享相同前缀的 block(前缀缓存)。
python
# PagedAttention 的 block table(概念骨架)
class Sequence:
def __init__(self, block_size=16):
self.block_size = block_size
self.block_table = [] # 逻辑块 -> 物理块号
self.tokens = []
def append(self, tok, free_blocks):
if len(self.tokens) % self.block_size == 0:
self.block_table.append(free_blocks.pop()) # 按需分配物理块
self.tokens.append(tok)
# 注意力计算时分块取 KV:attention(query, KV[block_table[b]])
收益:显存利用率从 ~20% 提到 ~90%+,同等显存能并发更多请求,吞吐翻数倍。这也是 vLLM 之所以快的核心。面试能画出「逻辑块→block table→物理块」三层映射,已基本拿分。
第三节 连续批处理:token 级调度,不等整批
传统 static batching:攒够 N 个请求一起跑,哪个先结束也得等最慢的,GPU 常空转。连续批处理(continuous batching) 在 token 级调度:每生成一个 token 步,就把刚结束的请求换出、把等待队列的新请求换入。
时间步 -> Batch 组成(动态变化)
step1: [A,B,C] 都在 decode
step2: [A,B,C] C 结束, 换入 D
step3: [A,B,D] B 结束, 换入 E
step4: [A,D,E] ...
=> GPU 几乎不空转, 吞吐远高于 static
调度器状态机:
python
class Scheduler:
def step(self):
self._admit() # waiting 按预算换入 running
out = self._run_batch(self.running) # token级batch
for seq in self.running:
if seq.finished:
self._free(seq); self.finished.append(seq)
要点:连续批处理让「短请求不被长请求拖累」,P99 延迟更稳。它和 PagedAttention 是绝配------换入新请求只需分配 block,无需连续大块。
第四节 显存预算与抢占
引擎要维护一个显存预算 :KV Cache 池大小 = 总显存 − 模型权重 − 激活/临时量。当 waiting 队列请求需要的新 block 超出剩余预算,就要**抢占(preemption)**正在跑的某些序列:
- Swap(换出):把被抢占序列的 KV block 搬到 CPU 内存,恢复时再搬回。不丢计算,但搬移有开销。
- Recompute(重算):直接丢弃其 KV,恢复时重新 prefill。无搬移开销,但浪费已算的算力。
python
def preempt(seq, mode, kv_pool, cpu_pool):
if mode == "swap":
cpu_pool[seq.id] = kv_pool.evict(seq.block_table)
else:
kv_pool.free(seq.block_table); seq.block_table = []
seq.reset_to_prefill()
工程取舍:被抢占序列短 → recompute 更划算;长 → swap 更划算。引擎通常按序列长度自动选。
第五节 算子与 kernel:把每一拍都榨干
-
FlashAttention:把 QK^T、softmax、PV 融合进一个 CUDA kernel,不把巨大的中间注意力矩阵(N×N)写回显存,改在片上(SRAM)算。访存从 O(N²) 降到接近 O(N),既快又省显存。
-
Fused kernel:layernorm、激活、残差合并,减少 kernel 启动与中间读写。
-
CUDA Graph:把多步 kernel 录成一张图一次性回放,消除 Python/驱动调度开销,对 decode 这种小 batch 高频步收益大。
-
量化算子:AWQ/GPTQ 权重以 INT4 存储,计算时按需 dequant 到 FP16/BF16 再乘,显存和带宽都省。
无融合: Q,K,V Proj -> 写显存 -> Softmax -> 写显存 -> AttnOut -> 写显存
Flash: Q,K,V Proj -> [片上: attn 全流程] -> 写一次显存
面试追问「FlashAttention 为什么省显存」------答:传统注意力要把 N×N 的 softmax 分数矩阵 materialize 到 HBM,Flash 用 tiling 在 SRAM 内分块算、只写最终 O(N) 的输出,显存从平方级降到线性。
第六节 投机解码与前缀缓存的内核实现
(二十五)讲过投机解码思想:小 draft 模型先猜 k 个 token,大 target 模型一次并行验证。内核上关键是 tree attention / 并行 verify:把 draft 猜出的多条候选(树状而非链状)拼成一个 batch,target 模型一次前向同时验证全部,accepted 前缀保留、第一个 reject 之后重采样。
draft 猜: t5 ->[t6a, t6b] -> t7a ...
target 一次验证整棵候选树, 接受最长正确前缀, 其余丢弃
=> 每步可能推进 >1 个 token, decode 步数下降
前缀缓存(prefix caching) 则是:相同系统 prompt(如多模态的系统指令 + 同一张图)的 KV 前缀跨请求复用(PagedAttention 的 block 共享),显著降低首 token 延迟(TTFT)。这在(五十三)多模态服务化里特别关键------同一张图被多个请求共用时,ViT 编码与 KV 前缀都无需重算。
第七节 多模态引擎特例
多模态推理引擎比纯文本多三件事(接五十三):
- ViT 解耦:视觉编码器可离线把图像编码成视觉 token 缓存,多个请求共用同一张图时直接命中缓存,避免重复编码。
- 图像 token 压缩:一张图可能产出几百个视觉 token,占满 KV。常用 token merging / 下采样 / 可学习压缩,把视觉 token 从 N 压到 M(M<<N)再进 LLM 的 KV 池。
- 跨模态前缀缓存:系统 prompt + 同一张图的 KV 前缀可跨会话复用,进一步降 TTFT。
第八节 与岗位结合:怎么调一个引擎
面试现场若被要求「压测并调优一个部署」,可给这套动作:
- 先定指标:TTFT(首 token 延迟)、TBT(token 间延迟)、吞吐(req/s)、显存占用。
- 用(五十四)的三本账找瓶颈:是算力墙、带宽墙还是显存墙。
- 调优杠杆:开 PagedAttention + 连续批、上量化(KV Cache 量化优先,几乎无损)、多模态开 ViT 缓存与 token 压缩、长 prompt 开前缀缓存、必要时 PD 分离。
- 压测用真实分布(不是均匀长度),盯 P95/P99 而非均值。
面试速答(本篇可直接背的 3 句)
- 推理分 prefill(计算密集、并行)和 decode(访存密集、逐 token),两者瓶颈不同,故常做 PD 分离。
- PagedAttention 把 KV Cache 分页成 block + block table 映射,像虚拟内存,消除碎片、提显存利用率到 90%+,支撑连续批处理。
- 加速三板斧:FlashAttention 融合算子省显存、量化降带宽、投机解码在 decode 段推进多 token;多模态额外要 ViT 缓存 + 视觉 token 压缩 + 前缀缓存。
高频追问清单
- 为什么 KV Cache 是 decode 的瓶颈?答:每步要读全部历史 KV,计算量小但访存量随序列线性涨,受显存带宽限制。
- PagedAttention 和操作系统分页像在哪?答:都用语义连续、物理离散的块表;支持按需分配、共享前缀、换出,避免预留与碎片。
- 连续批处理比 static 好在哪?答:token 级换入换出,短请求不拖累长请求,GPU 空转少,吞吐与 P99 都更优。
- 抢占选 swap 还是 recompute?答:短序列 recompute(重算便宜),长序列 swap(搬移比重算省)。
- FlashAttention 为什么省显存?答:不 materialize N×N 注意力矩阵,分块在 SRAM 内算,显存从 O(N²) 降到 O(N)。
- 投机解码何时不划算?答:在 compute-bound 或 prefill 阶段不划算,只在带宽受限的 decode 上收益明显。
- 前缀缓存为什么对多模态特别有用?答:同一系统指令+同一张图的 KV 前缀可跨请求复用,省 ViT 重编码与 KV 重算,降 TTFT。