AI Infra 推理部署技术总结:2. vLLM 内核——PagedAttention 与调度器原理

AI Infra 技术总结(二):vLLM 内核------PagedAttention 与调度器原理

系列第二篇。上一篇(第一部分)介绍了 vLLM 的定位、特点、用法与常见类;本篇深入引擎内部,拆解让 vLLM 快起来的两个核心机制:

PagedAttention(KV Cache 的分页内存管理)调度器(Scheduler,连续批处理的决策中枢)

读完你会理解:显存是怎么被"榨干"的,以及"GPU 每一步算谁"这件事是怎么被决定的。


0. 从一个请求的生命周期说起

一次 LLM 推理请求在引擎里经历两个阶段:

  1. Prefill(预填充) :一次性前向计算整个 prompt。prompt 有 2000 个 token,就并行算 2000 个位置,计算密集
  2. Decode(解码) :逐 token 生成输出。每步只算最新 1 个 token,但要把模型全部权重和此前的 KV Cache 重新读一遍,显存带宽密集

1. Prefill 对应图里:Prompt phase → LLM iteration 1

  • Prompt phase(提示词阶段)= Prefill(预填充阶段)
    输入:完整 prompt 的全部 token Is tomato a fruit?
  • iteration 1:执行 Prefill 前向计算
    1. 一次性把 prompt 所有 token 跑一遍 Transformer
    2. 生成 prompt 部分完整的 KV Cache,存下来,给后面复用
    3. 输出第一个候选 token Yes

✅Prefill 的本质:处理全部输入 prompt,构建 KV 缓存,只发生 1 次,在请求最开始。

2. Token generation phase = Decode 解码阶段(迭代 2、3、4)

Prefill 做完之后,就进入循环生成 token,每一个 iteration 就是一次 Decode step

  • iteration2:拿已经建好的 KV Cache + 上一步输出 token Yes,算出下一个 token it更新 KV Cache
  • iteration3:KV Cache + it →输出is,更新 KV Cache
  • iteration4:KV Cache + is →输出EOS结束符,请求结束

每一轮 Decode iteration,只输入 1 个新 token,复用历史全部 KV,不断追加新 KV。

Decode 阶段每算一个 token,每个 Transformer 层都会产生一对 K、V 向量并缓存 下来(这就是 KV Cache),供后续所有 token 的注意力计算使用。上下文越长、并发越高,KV Cache 占的显存越大------它通常比模型权重本身还大

于是 vLLM 要回答两个问题:

  • KV Cache 放哪、怎么放 → PagedAttention(第 1 节);
  • 每步把哪些请求放进 GPU 批次 → 调度器(第 2 节)。

1. PagedAttention:把 KV Cache 当成"虚拟内存"

1.1 传统做法的浪费在哪

传统推理引擎为每条请求的 KV Cache 预分配一整块连续显存 ,大小按请求的最大可能长度预留。这造成两类浪费:

  • 内部碎片:请求实际只生成了 100 个 token,却预留了 2048 个 token 的容量,剩余空间闲置且无法挪作他用;
  • 外部碎片:长请求结束后中间空出一大块,却往往放不下一个新请求,显存白白浪费。

业界与论文实验普遍观测到,传统方案下 KV Cache 显存利用率只有 20%~40%,是推理成本居高不下的主因。

1.2 分页设计:物理块池 + Block Table

PagedAttention(论文:Efficient Memory Management for Large Language Model Serving with PagedAttention,SOSP 2023)完整复刻了操作系统虚拟内存的分页思想:

操作系统概念 PagedAttention 对应物
物理页(Page,如 4KB) KV Block(默认存 16 个 token 的 K+V)
虚拟地址空间(进程视角连续) 请求视角的"逻辑 KV Cache"
页表(Page Table) Block Table(每个请求一张)
物理内存池 GPU 显存中预分配的物理块池
缺页中断 无空闲块可分配 → 触发抢占(preemption)
共享页 / 写时复制(CoW) 前缀缓存、beam search、并行采样共享块
页置换(LRU 换出) 前缀缓存块的 LRU 驱逐

具体实现上,物理 KV Cache 是一块预分配的大显存张量(以 FlashAttention 后端为例):

图中的 Block 是 KV Cache 的最小物理存储单元(页)

  • 每个 Block 可以存放固定数量 token 的 K、V 向量(图里每个 block 存 4 个 token,block size=4)。
  • Block 是 GPU 显存上一块连续内存;但是一个请求完整的 KV 缓存,不需要占用连续显存,可以分散在多个不连续的 Block 里。
  • Block 有物理编号:图中 Block0、Block1、Block2,就是物理块 ID。

⚠️注意:Block 是物理显存块,不是请求;多个不同请求不能共用同一个物理 Block(除了 prefix sharing 前缀共享场景)。

Query 向量如何访问分散的 KV Block(图的箭头含义)?

图左侧:forth 这个 token 生成对应的 Query 向量

正常标准 Attention:KV 是一块连续内存,Query 直接对完整连续 KV 矩阵做注意力计算。

PagedAttention:KV 被切散到 Block0、Block1、Block2 三块不连续显存块

箭头含义:Query 向量,间接寻址,分别读取 Block0、Block1、Block2 里面的 Key/Value 向量,再完成注意力分数计算。

不需要把分散的 Block 拷贝拼接成一块连续大内存;直接对分散块做 attention,这就是 PagedAttention 的核心。

逻辑视角 vs 物理视角(非常关键)

  1. 逻辑序列(请求视角) :对用户 Request 来说,它看到的是完整连续 token 序列:Four score and seven years ago our fathers brought forth,逻辑上是连续。
  2. 物理 Block(GPU 显存视角) :逻辑序列被切到 Block0、Block1、Block2,物理显存地址不连续
    vLLM 内部每个请求维护一张块表 (block table):记录该请求逻辑上的位置,对应到哪些物理 Block ID。

块表 = 类比 OS 的页表,逻辑页号 → 物理块号。

python 复制代码
key_cache:   [num_blocks, block_size, num_kv_heads, head_size]
value_cache: [num_blocks, block_size, num_kv_heads, head_size]
  • num_blocks 是显存能装下的最大物理块数;
  • 每个请求持有一张 Block Tableblock_id 列表),把逻辑位置映射到物理块;
  • 物理块无需相邻------请求 A 的第 0、1、2 个逻辑块可以落在物理块 42、17、99 上。

由此得到三个关键性质:

  1. 外部碎片 = 0:任意空闲块都可被任意请求使用;
  2. 增长是 O(1):多生成一个 token,只需在 Block Table 末尾追加一个 entry,无需搬移数据;
  3. 可跨请求共享:同一物理块可以同时挂在多个请求的 Block Table 下(靠引用计数 refcount 管理)。

1.3 Block 的生命周期(vLLM 运行时)

  1. 请求 Prefill 阶段:分配若干物理 Block,写入 prompt 全部 token 的 KV,填充 block table。
  2. Decode 循环:新 token 不断填入 Block,填满就申请新 Block。
  3. 请求结束:所有属于该 Request 的物理 Block,全部标记为空闲,可以分配给别的新请求复用。
  4. 还有 Swap:显存不足时,可以把冷 Block 换出到 CPU 内存;需要时 swap 回 GPU 显存。

1.4 为什么 block_size 默认是 16

  • 太小:每个块一次间接寻址,Block Table 变长,访存开销线性放大;
  • 太大 :内部碎片回归(每个请求最多浪费 block_size - 1 个槽位)。

16 是工程经验值:对 FlashAttention 的 tile 大小友好、与 RoPE/GQA 的 head 维度配合好,内部碎片影响约 2% 以内 ,可用 --block-size 调整。

1.5 共享与写时复制(Copy-on-Write)

共享是 PagedAttention 相对"单纯分页"更进一步的能力,三种典型场景:

场景 行为
前缀缓存(Prefix Caching) 两个请求共享相同 system prompt 的前缀块,ref_cnt 累加,最后一个引用消失才回收
Beam Search(beam=4) 4 条候选共享前缀,到分歧点 fork:共享时 ref_cnt++,分歧时写时复制
并行采样(n=4) prompt 段 4 个候选共享同一批块,generation 段各自独立

每个块用引用计数 跟踪使用者数量;只有 ref_cnt 归零才归还物理块池。共享块被某个请求"改写"时(分歧点),先复制再写,避免互相污染------这就是 COW。

1.6 内核实现:从自家 Kernel 到 FlashAttention

  • 2023 论文版 :vLLM 自研 paged_attention_v1/v2.cu CUDA 内核,论文的 24× 吞吐主要来自这套内核;
  • 2024 之后 :FlashAttention v2/v3 与 FlashInfer 原生支持 paged + varlen 模式(直接接受 block_table 参数),性能更优,成为默认执行后端;自家 paged kernel 退化为 fallback。

一句话:"PagedAttention"是内存管理思想,而实际的注意力计算已交给 FlashAttention/FlashInfer 的 paged 版本执行。

1.7 代价与收益

  • 代价:每个 KV 访问多一次 Block Table 间接寻址,attention 单步慢约 20%;
  • 收益 :论文实验(OPT-13B 等)中 KV 缓存利用率从约 20% 提升到 96%,加上连续批处理带来的 GPU 利用率提升,端到端吞吐达到约 24×(论文摘要口径:对比 FasterTransformer、Orca、TGI 等当时 SOTA)。

注:官方博客中"连续批处理带来 23×"是单独针对批处理机制与静态批处理对比的口径;论文的 24× 是整体系统对比口径,两者针对的基准不同,勿混淆。

2. 调度器(Scheduler):决定 GPU 每一步算谁

2.1 为什么要使用调度器

如果没有调度器,最简单推理模式就是:串行执行,一个请求完整跑完 prefill + 全部 decode,再处理下一个请求 。这种模式资源利用率极低,延迟高、并发差。调度器就是为了解决这个痛点,充分利用 GPU 算力 + 显存,实现连续批处理 (Continuous Batching)+PagedAttention

  1. 实现连续批处理 Continuous Batching(最核心目的)
    普通推理框架 (HuggingFace transformers):

提交请求 A → A 完整跑完 prefill+decode 全部 token 结束 → 再处理请求 B。

请求 A 在 decode 循环生成几百个 token 的漫长时间里,GPU 大部分时间闲置,算力浪费。

调度器带来的改变:

请求 A 还在 decode 生成 token 的时候,新请求 B 可以到来。调度器可以把 B 选进 batch,A 做 decode,B 做 prefill,两者同一个 GPU forward 并行执行

  • 不需要等旧请求全部结束,新请求随时插入 batch。
  • 每一步 GPU 迭代,调度器重新挑选 batch;batch 成员动态变化。
  1. 管理 PagedAttention 的 Block(KV Cache 显存管理)

PagedAttention 把 KV‑cache 拆成很多 Block(显存页),Block 的申请、分配、复用、回收、swap、引用计数,全部由调度器驱动。

  1. Prefill:调度器为请求分配若干物理 Block,构建 block_table。
  2. Decode:每生成新 token,Block 写满时,调度器申请新 Block 追加。
  3. 请求结束:调度器释放 Block 还给空闲池。
  4. 开启前缀缓存:调度器匹配前缀 Block,做 KV 复用,更新引用计数。
  5. 显存不够:调度器决策 swap‑out,把部分请求 KV 块挪到 CPU 内存,释放 GPU 显存。显存空闲后 swap‑in 回来。

如果没有调度器:你就要自己管理每个请求连续的 KV 显存;传统连续 KV 会造成严重显存碎片,并发上不去。PagedAttention 这套分页机制完全依赖调度器。

3. 平衡首 token 延迟 (TTFT) 与吞吐

GPU 算力特性:

  • Prefill:计算密集,算力利用率高;
  • Decode:访存密集,算力容易打不满。

调度器可以做权衡:

  • 当 waiting 队列堆积大量新请求,调度器可以把部分正在 decode 的长请求 swap‑out,腾出 GPU 显存,优先执行新请求的 prefill,降低新用户首 token 等待时间。
  • 支持不同调度策略:FCFS、优先级调度,控制哪些请求优先被服务。

如果没有调度器,长请求一旦跑起来,新请求只能排队等待,TTFT 会急剧恶化。

2.2 从静态批处理到连续批处理

  • 静态批处理 :请求打包成固定批次,必须等整批全部生成完才释放位置,长请求拖死整批,GPU 大量空转;
  • 连续批处理:每次前向计算(step)后动态调度------完成的请求移出,新请求随时补位。这是 vLLM 吞吐量量级提升的另一半来源。

2.2 V1 引擎:统一 Token 调度模型

vLLM V1 引擎对调度做了一次"革命性"简化:调度器不再区分 Prefill 阶段和 Decode 阶段。每个请求只有两个计数器:

  • num_computed_tokens:已经算过的 token 数;
  • num_tokens_with_spec:目标 token 数(prompt 长度 + 已生成 token 数,含投机解码的 draft token)。

调度器只做一件事:让前者追上后者。差距是多少,这一步就分配多少 token 的计算预算(受 token budget 限制):

场景 差距 本步分配
新请求 Prefill(2000 token prompt) 2000 最多 2000,受 budget 限制
Decode 中 1 1
Chunked Prefill(剩余 1500,budget 剩 512) 1500 512,剩余下一步继续
投机解码(1 正常 + 4 draft) 5 5

"Prefill"和"Decode"不再是调度器需要判断的阶段,而是差距大小不同时自然产生的结果 。V1 也因此能在同一个 forward pass 里混合执行 prefill 和 decode(V0 只能要么全 prefill、要么全 decode)。

2.3 队列结构与优先级

V1 调度器维护两个队列:

  • waiting 队列:等待被调度(尚未开始 / 被抢占待恢复)的请求;
  • running 队列:正在运行、处于 decode 的请求。

调度策略两种:FCFS (先到先得,默认)与 priority(按优先级,低值先处理,适合 SLA 场景)。

每步调度的优先级明确:

  1. 先服务 running 队列(decode):保证已开始生成的请求不"断火",解码连续性优先;
  2. 再用剩余 token budget 接纳 waiting 队列(prefill)
  3. 如果本步发生了抢占,则不再接纳新请求:避免新请求挤占被抢占请求的恢复资源。

2.4 一次 step() 的完整旅程

引擎反复调用 step(),每步三个阶段:

  1. Schedule(调度):选出本步要执行的请求(decode 和/或分块 prefill);
  2. Forward(前向):把选中请求拼成一个大 batch 跑模型,采样 token;
  3. Postprocess(后处理):把采样 token 追加进请求,检查停止条件;完成的请求清理 KV 块(归还物理块池)并返回输出。

调度时对每个请求调用 KV 管理器 allocate_slots

  1. 计算需要几块ceil(新增token数 / block_size)
  2. 检查空闲块池:空闲不足 → 触发抢占(见 2.5)或跳过本步调度;
  3. 分配块 :从空闲队列头部取出物理块,记入 request → blocks 映射。

停止条件包括:达到 max_tokens / 模型最大长度、采样到 EOS、命中 stop_token_ids、出现 stop 字符串。

2.5 抢占(Preemption):V1 只留 RECOMPUTE

触发条件:running 请求需要新的 KV 块,但物理块池空闲不足时,调度器必须"踢掉"一个请求腾空间。

踢谁

  • FCFS 策略:抢占最后加入的请求;
  • priority 策略:抢占优先级最低的请求。

踢掉之后怎么办------V0 与 V1 选择了不同的路线:

V0 V1
SWAP(换出) 支持:把 KV 块搬到 CPU 内存,恢复时搬回 不支持
RECOMPUTE(重算) 支持 唯一方式

V1 的 RECOMPUTE 流程:

  1. 释放被抢占请求的全部 KV 块;
  2. num_computed_tokens 归零(之前算的全部作废);
  3. 放回 waiting 队列头部(sticky-front,下一步优先恢复)。

V1 弃用 SWAP 的原因很工程化:前向计算比 CPU↔GPU 搬运便宜;被抢占的请求往往能命中前缀缓存,重算成本没那么高;SWAP 还需要额外 CPU 内存和带宽,复杂度高。

2.6 Watermark:显存"安全水位"

如果调度器把显存用到最后一块才停,新请求一进来就挤掉别人,会引发频繁抢占 。因此 vLLM 设计了 Watermark 机制:预留一定比例的空闲块作为缓冲,只有空闲块超过安全水位线时才接纳新的 waiting 请求------就像水库不会把水用到见底。

2.7 前缀缓存(Prefix Caching):让 Prefill 接近零成本

RAG、Agent 等场景里,大量请求共享相同的 system prompt 或知识库前缀。前缀缓存让这些前缀的 KV 只算一次

  1. 每个 block 计算一个链式哈希(包含前序所有 token,保证只有完全相同的前缀才命中);
  2. block 算满时把哈希注册进全局哈希表;
  3. 新请求逐个 block 查哈希,命中的块直接复用(refcount +1),完全跳过前向计算
  4. 缓存块用 LRU 管理,显存不足才驱逐。

与统一 Token 调度配合:命中后 num_computed_tokens 直接跳到命中位置------比如 1000 token 命中,差距从 1020 缩到 20,请求几乎瞬间从"等 prefill"变成"开始 decode"。

2.8 Chunked Prefill:长 Prompt 不阻塞

假设 GPU 每步 token budget 是 2048,来了一个 8000 token 的 prompt。若一次性算完,本步 budget 被占光,其他 decode 请求全部停摆。Chunked Prefill 把长 prompt 切成小块,每步只算 budget 允许的部分,与 decode 请求混在一个批次里:

复制代码
Step 1: 算 token 0~2047    ← 和别人的 decode 混在一起
Step 2: 算 token 2048~4095  ← 混在一起
Step 3: 算 token 4096~6143  ← 混在一起
Step 4: 算 token 6144~7999  ← prefill 完成,下一步开始生成

在统一 Token 调度模型下,Chunked Prefill 不是单独实现的特性,而是"差距被 budget 截断"的自然结果------不需要任何阶段判断代码。

3. 两套机制如何协同

复制代码
                调度器(每步)
        ┌──────────────────────────────┐
        │  running: decode 优先         │
        │  waiting: prefill / chunked  │
        │  token budget / max_num_seqs │
        └───────┬──────────────────────┘
                │ 选出本步请求 + 需要的 token 数
                ▼
        KV Cache Manager(PagedAttention)
        ┌──────────────────────────────┐
        │  按需分配物理块 / 前缀缓存命中   │
        │  空闲不足 → 抢占 RECOMPUTE     │
        │  refcount / COW / LRU        │
        └───────┬──────────────────────┘
                │ Block Table(逻辑→物理映射)
                ▼
        Attention Kernel(FlashAttention/FlashInfer paged 版)
        ┌──────────────────────────────┐
        │  按 block_table 间接访问 KV 计算 │
        └──────────────────────────────┘

一句话:调度器决定"算谁、算多少",KV 管理器决定"KV 放哪、够不够",注意力内核负责"按块表高效地算"。 三者通过 Block Table 与 token 预算解耦又协同。

4. 生产实践:参数 ↔ 机制对照

理解了机制,生产参数就不再有黑盒感:

参数(vllm serve / EngineArgs) 对应机制 调优提示
--gpu-memory-utilization 决定 KV 物理块池总大小(含 Watermark 空间) 0.85--0.95;过高易 OOM,过低导致频繁抢占
--max-num-batched-tokens 每步 token budget(Chunked Prefill 的"截断线") 过小吞吐降,过大长 prompt 挤占 decode
--max-num-seqs 每步最大序列数(running 上限) 默认 256;与显存、延迟平衡
--enable-prefix-caching 前缀缓存(链式哈希 + LRU) RAG/长 system prompt 场景强烈建议开启
--block-size KV 分块粒度(默认 16) 一般保持默认
--scheduling-policy fcfs / priority(抢占选谁) 有 SLA 分级需求时用 priority
--num-scheduler-steps 每次调度运行多个前向步(多步调度) V1 特性,提升吞吐,需关注延迟

值得监控的指标preemption_count(抢占次数,过高说明 KV 池偏小或 Watermark 过低)、前缀缓存命中率、KV Cache 利用率、每步 token 利用率。

5. 小结

  • PagedAttention = 把 KV Cache 当 OS 物理页管:定长块 + 每请求一张 Block Table + 物理块池 + refcount/COW/LRU。外部碎片归零、增长 O(1)、支持跨请求共享;代价只是 attention 单步约 20% 的间接寻址开销,换来 KV 利用率与吞吐的量级提升。
  • V1 调度器 = 统一 Token 调度模型:没有 prefill/decode 阶段之分,只有"已算 vs 目标"两个计数器;decode 优先、waiting 用剩余 budget 补位;KV 不足时用 RECOMPUTE 抢占(V1 弃用 SWAP);Watermark 防抖、前缀缓存省钱、Chunked Prefill 防阻塞。
  • 两者协同:调度器选请求 → KV 管理器分配块 → 内核按块表计算,构成 vLLM 高性能推理的完整闭环。

下期预告

从单机引擎到多卡与集群:张量并行、流水线并行、数据并行如何划分模型与数据,PD 分离(Prefill-Decode Disaggregation)为何成为大规模生产服务的"最后一公里",以及 vLLM 在 Kubernetes 上的部署与扩缩容实践。

参考来源:vLLM 官方博客 Inside vLLM: Anatomy of a High-Throughput LLM Inference System(2025-09)、PagedAttention 论文(arXiv:2309.06180)、vLLM 官方文档与社区源码解读文章。

相关推荐
Zzj_tju1 小时前
小模型指令微调:数据混合、模板与过拟合的最小复现
人工智能·深度学习·机器学习·语言模型
新知图书1 小时前
第 1 章 大模型时代 《DeepSeek原生应用与智能体开发实践》
人工智能·智能体
伯恩bourne1 小时前
Qdrant 快速入门 :数据模型介绍
服务器·数据库·人工智能
开开心心就好1 小时前
免费桌签打印工具,支持批量导入名字
前端·javascript·人工智能·docker·jupyter·智能手机·语音识别
信安IT租赁1 小时前
本地化部署的AI营销CRM:基于事件驱动的全流程自动化状态机实践
大数据·人工智能·软件工程
IT_陈寒1 小时前
明明设了默认值,为什么我的JavaScript函数参数还是undefined?
前端·人工智能·后端
cxr8281 小时前
如何彻底解决AI杜撰编造假文献的问题
人工智能·智能体
孤狼warrior1 小时前
SCTR 五次失败的安全 BN 路由器
人工智能·python·深度学习·算法·安全·yolo
AgentMaster1 小时前
元数据、血缘、质量、安全四大模块能力拆解,数据治理方案对比:4 种技术路线深度评测
大数据·数据库·数据仓库·人工智能·原型模式