vLLM 源码精读——高吞吐 LLM 推理引擎全景

本文基于 vLLM 源码 vllm/v1/(V1 引擎架构)。目标:让你在面试时能从"为什么传统 serving 显存利用率只有 20%“讲到"PagedAttention 的 block 怎么管”、从"prefill/decode 不再分阶段"讲到"continuous batching 的 token-budget 调度"、从"prefix caching + 拷贝写"讲到"V1 的 async scheduler + CUDA Graph + spec decode"。读完这篇,vLLM 这关基本通杀。


一、vLLM 要解决什么问题、为什么面试必问

传统 LLM serving(朴素 batch)有两个致命问题:

  1. KV cache 显存碎片化:为每个请求预分配一段连续 KV 显存,请求长短不一→产生大量内部碎片+外部碎片,实测显存利用率常低于 20%;且无法在请求间复用相同前缀。
  2. 批处理不连续:朴素做法是"攒满一个 batch 一齐 prefill,全部 decode 完再换下一批",prefill 和 decode 阶段割裂,新请求要等整批结束才能进→排队延迟高、GPU 空转。

vLLM 用两件套解决,也正是面试的核心:

  • PagedAttention:把 KV cache 像操作系统虚拟内存一样按固定 block(默认 16 token)分页管理,按需分配、消除碎片、显存利用率从 ~20% 拉到 ~95%。
  • Continuous Batching(连续批处理 / iteration-level batching):在每次 decode 迭代级别动态增删请求,prefill 和 decode 混在同一 batch,新请求即时加入、完成的即时退出,GPU 不空转。

源码鸟瞰(vllm/v1/):

1
2
3
4
5
6
7
engine/            # 引擎层: AsyncLLM(API 入口) / EngineCore(调度核心) / core_client
core/sched/ # 调度器: scheduler.py(每步分配 token 预算) + request_queue
core/ # KV cache: block_pool / kv_cache_manager / kv_cache_coordinator
worker/gpu/ # 执行器: model_runner(execute_model) / block_table / input_batch / cudagraph_utils
attention/ # 注意力后端: flash_attn / flashinfer / mla / mamba 等
spec_decode/ # 投机解码: eagle / medusa / ngram / draft_model / dflash
core/kv_offload/ # KV cache 卸载到 CPU/分层(PrefixCache 池化、disaggregated prefill)

主线:AsyncLLM 收请求 → EngineCore 驱动 Scheduler 每步算 token 预算分配给各请求 → KVCacheManagerBlockPool 按 block 管 KV → GPUModelRunner.execute_model 把 batch 喂给模型 + 跑 attention + 采样 → 输出回流。


二、PagedAttention:KV cache 的虚拟内存(地基)

2.1 类比 OS 虚拟内存

vLLM 把 KV cache 当作"显存里的内存系统"来管:

OS 虚拟内存 vLLM PagedAttention
进程的虚拟地址空间 一个请求的逻辑 KV 序列
page(4KB) block(默认 16 token 的 K/V)
页表(虚拟→物理) block table(逻辑 block → 物理 block id)
物理 page frame 显存里的 KV block
按 page 分配 按 block 按需分配

每个请求持有一张 block_table(逻辑 block 序号 → 物理 block id),逻辑上连续的 KV,物理上可以离散分布在不同物理 block。所以:

  • 消除外部碎片:任何空闲 block 都能用,不必连续。
  • 消除内部碎片:只有最后一个 block 可能浪费一点,浪费 ≤ 一个 block(16 token)。
  • 按需分配:用多少 token 才分多少 block,不预先占满。
  • 复用/共享:相同前缀可指向同一组物理 block(prefix caching)。

2.2 源码对应

  • core/block_pool.pyBlockPool:物理 block 池,负责分配/回收/驱逐,get_new_blocks(n)free_blockstouch(LRU)、evict_blocks
  • core/kv_cache_manager.pyKVCacheManager:每个请求的逻辑→物理映射,allocate_slots(为请求新增 token 分 block)、freeget_computed_blocks(prefix 命中查询)、cache_blocks(把 block 哈希存进 prefix 缓存)。
  • worker/gpu/block_table.py:GPU 上的 block_table 张量,attention kernel 据此查物理 block。
  • worker/gpu/model_runner.pyprepare_attn:把每请求的 slot_mapping(token → 物理 KV slot)算好,attention kernel 用它把新 K/V 写进正确位置。

2.3 block 的物理布局

一个 block 存 TT(如 16)个 token 的 K 和 V。attention kernel(v1/attention/backends/flash_attn.py)按 block_table 索引到这 TT 个 token 的 K/V 做 attention。对 query token 要 attention 到序列里所有已计算的 K/V,跨 block 拼起来——这正是 PagedAttention kernel 相对普通 attention 多做的事,但换来了零碎片 + 复用。

2.4 prefix caching + copy-on-write

相同前缀(system prompt、few-shot、共享文档)的 KV 可以被多请求共享。源码 BlockPool.get_cached_block / cache_full_blocks / cache_partial_block:每个 block 按其内容的 hash 作 key 存进 BlockHashToBlockMap。新请求来了 KVCacheManager.get_computed_blocks 先算它能命中哪些已缓存的 block,命中则直接引用同一组物理 block,不重算 KV——这就是 vLLM 的 prefix caching(v1 默认开启)。

Copy-on-write:当某请求要往一个"共享" block 写新 token(它在前缀后又续了内容),不能直接改共享 block(会污染别的请求),而是复制一份再写——BlockPool 维护引用计数(ref_cnt),共享 block 被写时复制。这就是源码里 cache_partial_block_maybe_evict_cached_block 处理的逻辑。

面试金句:“PagedAttention = 把 KV cache 当虚拟内存,逻辑 block 经 block_table 映射到离散物理 block,按需分配消除碎片、显存利用率从 20% 到 95%;相同前缀的 block 按内容 hash 共享,写时复制,这就是 prefix caching。”


三、调度器:没有"prefill 阶段 / decode 阶段"之分(V1 核心革新)

3.1 V0 的两阶段 vs V1 的统一

  • V0:有明确的 prefill 阶段和 decode 阶段,一个 iteration 要么全 prefill 要么全 decode,切换有开销。
  • V1:源码 scheduler.pyschedule() 开头注释明确写——“There’s no decoding phase nor prefill phase in the scheduler”。每个请求只有 num_computed_tokens(已算)和 num_tokens_with_spec(目标),调度器每步就是"把 token 预算分给各请求,让它俩差值变小"。

这个抽象统一了 chunked prefill、prefix caching、speculative decoding、jump decoding——它们都只是"让 num_computed_tokens 追上 num_tokens_with_spec" 的不同形态。

3.2 token-budget 调度(每步怎么分)

Scheduler.schedule() 的核心是 max_num_scheduled_tokens 这个全局 token 预算,按优先级分配:

  1. RUNNING 请求优先:先给正在跑的请求续命——每个 running 请求需要的 num_new_tokens = num_tokens_with_spec - num_computed_tokens,从预算扣。
  2. chunked prefill:长 prompt 一次算不完?切 chunk,每步算一段(long_prefill_token_threshold),剩下的下步再算——于是 prefill 和 decode 可在同一 batch 混跑。
  3. WAITING 请求:预算还有剩,从 waiting 队列拉新请求做 prefill(FCFS,但允许低优先级抢占以避免饿死——源码注释"by doing continue instead of break, allow lower-priority requests")。
  4. 预算耗尽或显存不够→preempt:当 KV 显存不够塞下新请求,_preempt_request 把 running 请求的 KV 换出/重算(preempt)给高优先级腾地方。

3.3 preemption(抢占)策略

KV 显存紧张时调度器会 preempt 请求。两种方式:

  • Recompute:丢弃某请求所有 KV block,把它退回 waiting 队列,下步从 prompt 重新 prefill(损失算力但简单)。
  • Swap:把 KV 临时换到 CPU(V0 有,V1 倾向 recompute + 新的 KV offload/tiering)。

源码 _preempt_request / _free_blocks / free 处理。抢占是 vLLM 在显存不足时的兜底,保证吞吐不崩。

面试金句:“V1 调度器取消了 prefill/decode 阶段,每步就是给每个请求分配 token 预算让 num_computed_tokens 追上目标——这就是 continuous batching 的本质:iteration 级动态混跑 prefill 和 decode。显存不够就 preempt(重算或换出)兜底。”


四、EngineCore 与 AsyncLLM:V1 的双进程异步架构

4.1 为什么拆异步

朴素架构里调度器(CPU)和 worker(GPU)串行:CPU 调度→GPU 算→CPU 收输出→CPU 再调度……CPU 调度时间和 GPU 计算时间互不重叠,互相等。GPU 在 CPU 调度时空闲。

V1 的解法:把"调度"和"执行"解耦成两条流水线,用队列 + 双进程(或双线程)异步重叠

4.2 源码结构

  • engine/async_llm.pyAsyncLLM:用户面向的异步 API(add_request/generate),把请求丢进 input queue,从 output queue 拉结果。output_handler 协程持续回流。
  • engine/core.pyEngineCore:调度核心。step() / step_with_batch_queue() 每步:调 scheduler.schedule() 算出 SchedulerOutput,丢给 worker 执行,收 ModelRunnerOutput,更新请求状态。
  • v1/worker/gpu/model_runner.pyGPUModelRunner.execute_model:真正跑一次 forward。

4.3 async scheduler 重叠

V1 用 asyncio 让 scheduler 的一步和 worker 的下一步重叠:worker 在 GPU 上跑第 ii 步 forward 时,scheduler 已经在 CPU 上算第 i+1i+1 步的 SchedulerOutput。源码 core/sched/async_scheduler.py 实现这种 overlap 调度。这就是 V1 比 V0 高吞吐的关键之一——CPU 调度开销藏进 GPU 计算里

4.4 sleep/wake 与 disaggregated prefill

EngineCore.sleep()/wake_up():让 GPU KV cache 卸载(sleep level 1/2)以腾显存给别的任务,再唤醒恢复——服务于 disaggregated prefill(prefill 节点和 decode 节点分离,KV 跨机迁移)。core/kv_offload/worker/gpu/kv_connector.pycoordinator.py 实现跨节点 KV 传输(Mooncake 风格的 KV 池化)。


五、GPUModelRunner.execute_model:一次 forward 怎么跑

worker/gpu/model_runner.pyexecute_model 是 worker 侧主入口,源码流程(精简):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def execute_model(self, scheduler_output, ...):
# 1. 更新请求状态: 完成的/新增的/变更的请求, 应用 block_table 写入
self.finish_requests(scheduler_output)
self.add_requests(scheduler_output)
self.update_requests(scheduler_output)
self.block_tables.apply_staged_writes()

# 2. 决定 CUDA Graph 批次描述: 按 num_tokens 选一个预先抓好的图
batch_desc, num_tokens_across_dp = dispatch_cg_and_sync_dp(...)

# 3. 准备输入: token ids/positions/block_table/slot_mapping 等
input_batch = self.prepare_inputs(scheduler_output, batch_desc)
block_tables, slot_mappings = self.prepare_attn(input_batch)

# 4. 准备 attention metadata(后端无关: flash_attn/flashinfer/mla/...)
attn_metadata = self.model_state.prepare_attn(input_batch, cg_mode, block_tables, ...)

# 5. 真正 forward + 采样
output = self.model(...) # 跑模型拿 logits/hidden
sampled = self.sample(output) # 采样(temperature/top_p/top_k/束搜索/约束)
return ModelRunnerOutput(...)

5.1 关键设计

  • prepare_attnslot_mapping:每个新 token 要写到哪里——把逻辑 token 位置映射到物理 KV block 的具体 slot,attention kernel 据此 scatter 写入新 K/V、gather 旧 K/V 算注意力。这是 PagedAttention 的运行时落地。
  • block_table apply_staged_writes:调度器本步分配的新 block id 先"暂存"(staged),execute_model 时统一 apply 到 GPU 上的 block_table 张量,减少 CPU↔GPU 同步次数。
  • 多后端 attentionv1/attention/backends/ 下 flash_attn、flashinfer、mla(DeepSeek MLA)、mamba(SSM)、linear_attn 等,registry.py 按 model_config 选后端。vLLM 能跑这么多模型全靠这层抽象。

5.2 CUDA Graph 抓图(decode 加速)

decode 阶段每步只产一个 token、算子小、kernel launch 开销占比高。vLLM 用 cudagraph_utils.py对若干个"固定 batch 大小/token 数"组合预先抓 CUDA Graph,运行时 dispatch_cg_and_sync_dp 按实际 num_tokens 选一个匹配的图直接 replay,跳过 kernel launch。capture_model 在启动时做这些抓图。这就是 decode 阶段 vLLM 低延迟的关键。

代价:图的种类有限、要为每种 num_tokens 抓一份图(占显存);EAGLE 等 spec decode 下还要为 draft token 数额外抓图。所以有 dispatch_cg_and_sync_dp 在 DP 各 rank 间同步选同样的图。

5.3 DP(数据并行)与统一 token 数

源码里反复出现 get_uniform_token_count / num_tokens_across_dp:多 DP rank 时,为能复用同一 CUDA Graph、做一致 attention,要把各 rank 的 token 数对齐(padding 到 uniform count)。这是 V1 对多卡 serving 的工程细节。


六、采样、约束输出、投机解码

6.1 采样 model_runner.sample

  • 把模型 logits 过 logits_processor(temperature、top_p、top_k、min_p、penalty)→ 采样 token id。
  • 支持束搜索(best_of、beam search)、多候选取样(n)。
  • v1/sample/ 下 logits_processor 的各种算子,很多是 fused kernel(v1/sample/ops)。
  • 约束输出 / 结构化输出v1/structured_output/):JSON schema、regex、grammar——通过 grammar 编译成 FSM bitmask,采样前 mask 掉不合法 token,保证输出符合 schema(xGrammar/AICI 思路)。

6.2 投机解码(v1/spec_decode/

V1 把 spec decode 做成"proposer + target 验证"的统一抽象,多种 proposer:

Proposer 文件 原理
EAGLE/EAGLE-3 eagle.py 自回归草稿头(与大模型共享 embedding/lm_head),一次 forward 出多个 draft token
Medusa medusa.py 多头并行猜下一 k 个 token,树形验证
draft_model draft_model.py 独立小模型起草稿(经典 spec decode)
n-gram ngram_proposer.py 纯启发式,用 prompt 里的 n-gram 猜 draft,零草稿模型开销
dflash dflash.py Flash 风格自回归草稿
suffix decoding suffix_decoding.py 后缀树匹配猜 draft

机制(呼应投机解码那篇):proposer 出 kk 个 draft token,target 一次 forward 并行验证,按接受率接受前缀、拒绝处从修正分布补采。源码里 Scheduler.update_draft_token_ids 把 draft token 并进 num_tokens_with_spec,统一进调度器——这就是 V1 "spec decode 只是让 num_computed_tokens 追上一个更大的目标"的具体体现take_draft_token_ids 在 worker 取 draft、sample_tokens 收验证。

面试金句:“V1 把 spec decode 做成 proposer 抽象(EAGLE/Medusa/draft/n-gram…),draft token 并进 num_tokens_with_spec 让调度器统一调度,target 一次 forward 验证、拒绝采样补采——无损且把 decode 的算力喂饱。”


七、KV cache offload / 分层 / disaggregated prefill

v1/core/kv_offload/(cpu/、tiering/)+ simple_kv_offload/

  • KV offload 到 CPU:显存紧张时把冷 KV block 换到 host 内存(pin 的),要时再换回。把"GPU 显存"扩展成"GPU + CPU 两级"。
  • tiering:多级 KV 池(GPU hot / CPU warm / SSD cold),按访问热度迁移。
  • disaggregated prefill:把 prefill(算力密集、可并行)和 decode(访存密集、串行)拆到不同节点池。prefill 节点算完 KV,通过 kv_connector 把 KV 迁到 decode 节点继续生成。engine/coordinator.pyworker/gpu/kv_connector.py_update_from_kv_xfer_finished 实现 KV 跨节点传输。Mooncake/NVIDIA Dynamo 这套都对接 vLLM 这层。

这层是当前推理系统最热的方向(PD 分离),面试讲到"你怎么看推理架构演进"必谈。


八、LoRA / 多模态 / 量化 / 分布式

  • LoRAvllm/lora/ + worker/gpu/lora_utils.py):多个 LoRA adapter 共享 base 权重,按请求激活。num_active_lorasexecute_model 里决定走哪条 fused LoRA kernel 路径;CUDA Graph 也要按 active lora 数抓图。
  • 多模态vllm/multimodal/ + worker/gpu/mm):图像/音频输入经 encoder 出 embedding,作为 prefix 注入。encoder_cache_manager 管 encoder 缓存,_try_schedule_encoder_inputs 给 encoder 算预算。
  • 量化vllm/model_executor/layers/quantization/ + csrc/quantization/):W8A8/W4A16/AWQ/GPTQ/FP8 等。把 nn.Linear 换成量化版本,attention/MLP 走量化 kernel。
  • 分布式vllm/distributed/ + worker/gpu/dp_utils.py):TP(切权重,跨 GPU all-reduce)、PP(切层)、DP(多副本)。v1/executor/ 编排多 worker。dispatch_cg_and_sync_dp 在 DP 各 rank 同步 token 数以复用 CUDA Graph。
  • 多种硬件vllm/platforms/csrc/cpu/csrc/rocm/):CPU/ROCm/TPU/XPU 后端,attention 后端按平台选。

九、怎么读源码(给你的路线)

  1. vllm/v1/request.py + vllm/v1/engine/async_llm.py:看一个请求从 API 进来怎么流转。
  2. vllm/v1/core/sched/scheduler.pyschedule():看 token 预算怎么分、continuous batching 的本质。这是 V1 最重要的一个函数。
  3. vllm/v1/core/block_pool.py + kv_cache_manager.py:看 PagedAttention 的 block 分配/回收/命中/驱逐、prefix caching、COW。
  4. vllm/v1/worker/gpu/model_runner.pyexecute_model + prepare_attn:看一次 forward 怎么把 batch + block_table + slot_mapping 拼好喂给 attention。
  5. vllm/v1/attention/backends/flash_attn.py:看 PagedAttention kernel 怎么按 block_table 算注意力。
  6. vllm/v1/engine/core.pystep + core/sched/async_scheduler.py:看 V1 怎么用 asyncio 让调度和执行重叠。
  7. vllm/v1/spec_decode/eagle.py:看 spec decode 怎么并进调度器统一处理。
  8. 跑一个最小例子(examples/basic/)+ VLLM_USE_V1=1 打开调试日志对照。

十、面试速答清单

Q1:vLLM 为什么比朴素 serving 快?两个核心是什么?

两个核心:PagedAttention 把 KV cache 按固定 block(16 token)分页管理,逻辑 block 经 block_table 映射到离散物理 block,按需分配消除碎片、显存利用率从 20% 到 95%;continuous batching 在每次 decode 迭代级别动态增删请求,prefill/decode 混跑、新请求即时加入、GPU 不空转。叠加 prefix caching 复用相同前缀、CUDA Graph 加速 decode、async scheduler 重叠 CPU 调度与 GPU 计算。

Q2:PagedAttention 怎么消除碎片?怎么复用前缀?

KV cache 当虚拟内存:请求持 block_table(逻辑→物理 block id),物理 block 可离散,外部碎片没了,内部碎片最多一个 block。block 按内容 hash 作 key 存 prefix 缓存,新请求 get_computed_blocks 命中已缓存 block 直接引用不重算 KV;要往共享 block 写新 token 时用引用计数 + copy-on-write 复制一份再写,避免污染别的请求。

Q3:continuous batching 和朴素 static batching 区别?V1 调度怎么做的?

static batching 攒满一批一起跑、整批结束才换;continuous batching 在 iteration 级别动态增删。V1 调度器取消 prefill/decode 阶段,每个请求有 num_computed_tokens 和 num_tokens_with_spec,每步把 max_num_scheduled_tokens 这个 token 预算按 running 优先、waiting 次之分配,让前者追上后者——这个抽象统一了 chunked prefill、prefix caching、spec decode。显存不够就 preempt(重算或换出)兜底。

Q4:V1 比 V0 强在哪?

三点:①async scheduler 用 asyncio 把 CPU 调度和 GPU 计算重叠,消除互相等待;②统一 prefill/decode 抽象,chunked prefill 让两者同 batch 混跑;③CUDA Graph 按 token 数抓图 replay,decode 阶段 kernel launch 开销大幅降低;④prefix caching 默认开、spec decode 多 proposer(EAGLE/n-gram/…)、KV offload/PD 分离原生支持。

Q5:CUDA Graph 在 vLLM 怎么用?为什么 decode 受益大?

decode 每步只产一两个 token、算子小,kernel launch 开销占比高。vLLM 在启动时 capture_model 为若干个固定 num_tokens/batch 组合抓 CUDA Graph,运行时 dispatch_cg_and_sync_dp 按实际 token 数选匹配图直接 replay,跳过 launch。代价是图要占显存、且种类有限,DP 各 rank 要同步选同一图以复用。prefill 算子大 launch 占比低、且 shape 多变,所以 Graph 对 prefill 收益小。

Q6:prefill 和 decode 为什么要拆开调度、disaggregated prefill 是什么?

prefill 算力密集可并行、长 prompt 一次算很多 token;decode 访存密集、串行、每步少 token。混跑时两者对算力/带宽需求不同会互相拖累。disaggregated prefill 把 prefill 和 decode 拆到不同节点池:prefill 节点算完 KV 通过 kv_connector 把 KV 迁到 decode 节点继续生成。vLLM V1 的 sleep/wake、kv_offload、coordinator 实现这层。Mooncake/Dynamo 都对接这层。

Q7:vLLM 的 spec decode 怎么和调度器结合?

V1 把 spec decode 做成 proposer 抽象(EAGLE/Medusa/draft_model/n-gram/suffix)。proposer 出 k 个 draft token 后,update_draft_token_ids 把 draft 并进请求的 num_tokens_with_spec,于是调度器把它当成"要算更多 token"统一处理,target 一次 forward 验证这 k+1 个位置、按接受率接受前缀、拒绝处修正分布补采——无损。CUDA Graph 还要为 draft token 数额外抓图。

Q8:preemption 怎么工作?什么时候触发?

当某步要调度但 KV 显存不够塞下新请求/继续 running 请求时,调度器从 running 尾部 preempt——_preempt_request 把请求的 KV block 释放、退回 waiting 队列,下步从头 recompute(或 swap 到 CPU)。这是显存不足的兜底,保证吞吐不崩但牺牲被抢占请求的算力。源码用 recompute 为主(简单、prefix caching 能缓解重算成本)。


十一、一张图收口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
请求流:  AsyncLLM.add_request ─► input queue

▼ (asyncio 双进程, CPU 调度 与 GPU 执行 重叠)
EngineCore.step:
Scheduler.schedule() ── token 预算分给 running(优先) + waiting + chunked prefill
│ (取消 prefill/decode 阶段, 统一 num_computed_tokens→num_tokens_with_spec)

KVCacheManager.allocate_slots ── BlockPool: 按需分物理 block / prefix 命中共享 / COW / preempt


GPUModelRunner.execute_model:
prepare_inputs + prepare_attn(算 slot_mapping) + block_tables.apply_staged_writes
dispatch_cg_and_sync_dp 选 CUDA Graph ── replay
model.forward + attention(flash_attn/mla/mamba...) ── PagedAttention 按 block_table 算注意力
sample(logits_processor / 约束输出 / spec decode 验证)


ModelRunnerOutput ─► output queue ─► AsyncLLM.generate 流式回流

主线一句话:vLLM = PagedAttention(KV 分页 + prefix 共享 + COW)把显存利用率拉满 + continuous batching(token 预算统一调度、prefill/decode 混跑)把 GPU 喂饱 + V1 三件套(async scheduler 重叠 CPU/GPU、CUDA Graph 跳 launch、spec decode 喂 decode 算力)把延迟和吞吐再推一格。 把这条主线、PagedAttention 的 block 机制、V1 调度器的 token 预算抽象讲顺,vLLM 这关就稳了。


参考资料

  • Kwon et al., Efficient Memory Management for Large Language Model Serving with PagedAttention (vLLM), SOSP 2023
  • vLLM V1 设计文档:docs/design/vllm/v1/ 源码
  • EAGLE: EAGLE: Speculative Sampling Requires Rethinking Feature Uncertainty, ICML 2024
  • 与本文显存计算法则、投机解码、RDMA(disaggregated prefill 跨机 KV)、CUDA Graph/x86 体系结构篇交叉对照