从 PagedAttention 到 Chunked Prefill:大模型推理调度的深度工程实战

从 PagedAttention 到 Chunked Prefill:大模型推理调度的深度工程实战

现代 LLM 推理服务面临的核心矛盾是:有限的 GPU 显存 vs 动态变化的序列长度。vLLM 通过 PagedAttention 解决了 KV Cache 的内存碎片问题,而 Chunked Prefill 则进一步打破了 Prefill 与 Decode 阶段的耦合,让调度器可以在微批次粒度上做优先级决策。本文从工程实现角度拆解这两个核心机制,并探讨它们如何共同构成现代 LLM 推理引擎的调度基石。

一、KV Cache 的显存困场

大模型推理的显存开销可以清晰地分为三部分:模型权重、激活值和 KV Cache。其中 KV Cache 是唯一随序列长度线性增长且逐 token 累积的部分。以 Llama-2-7B(d=4096, n=32 层, 32 个 KV head)为例,每个 token 的 KV Cache 大小约为:


KV_per_token = 2 × n_layers × n_kv_heads × head_dim × sizeof(bf16)
             = 2 × 32 × 32 × 128 × 2 bytes
             ≈ 524,288 bytes ≈ 0.5 MB/token

对于 4096 的上下文窗口,单个请求就要消耗 2GB 显存来存放 KV Cache。在并发批处理场景下,这成为吞吐量的第一瓶颈。

早期推理系统(如 HuggingFace TGI 的旧版本)采用预分配连续显存的方式为每个请求预留空间。这种策略带来两个致命缺陷:

内部碎片:假设最大上下文 8K,但多数请求只有几十到几百 token,实际利用率经常低于 25%。

外部碎片:请求完成释放后留下大小不一的"空洞",新请求的 block 大小往往难以匹配。

PagedAttention 的核心借鉴了操作系统虚拟内存的思路——将 KV Cache 拆成固定大小的 block(典型值 16 token/block),按需分配物理 block,通过 block table 做逻辑到物理的映射。

二、PagedAttention 的工程实现

2.1 Block Table 与分配器设计


class BlockSpaceManager:
    def __init__(self, block_size: int, num_gpu_blocks: int):
        self.block_size = block_size
        self.gpu_allocator = BlockAllocator(num_gpu_blocks)  # 空闲 block 池
        self.req_to_block_tables: Dict[str, List[int]] = {}
    
    def allocate(self, request_id: str, num_tokens: int) -> bool:
        blocks_needed = ceil(num_tokens / self.block_size)
        if self.gpu_allocator.available < blocks_needed:
            return False
        physical_blocks = [self.gpu_allocator.alloc() for _ in range(blocks_needed)]
        self.req_to_block_tables[request_id] = physical_blocks
        return True

关键点在于 block 大小的选择:16 token 是工程和性能上的折中。太小会增加 block table 的间接寻址开销和 GPU kernel 的索引计算成本;太大则加剧内部碎片。在实际部署中,FlashAttention 作者的研究表明 16 可以取得接近零碎片的利用率。

2.2 PagedAttention Kernel 的分块计算

标准 Attention 的 softmax 需要对完整序列求 max 和 sum,而 PagedAttention 通过 iterative softmax(或称 online softmax) 将计算拆分为固定大小 block 的增量更新。这本质上和 FlashAttention tiling 策略中的数学等价:

$$m_i = \max(m_{i-1}, \text{rowmax}(S_i))$$

$$\ell_i = e^{m_{i-1} - m_i} \cdot \ell_{i-1} + \text{rowsum}(e^{S_i - m_i})$$

$$O_i = \text{diag}(e^{m_{i-1} - m_i})^{-1} \cdot O_{i-1} + e^{S_i - m_i} \cdot V_i$$

vLLM 底层调用 FlashAttention v2,后者已经原生支持 paged KV layout。CUDA kernel 通过 block table 查询每个 logical position 对应的 physical address,避免了显存拷贝。

2.3 Copy-on-Write:Beam Search 的利器

在 Beam Search 场景下,多条 beam 共享前缀。PagedAttention 的 CoW 机制让多个逻辑 block 指向同一物理 block,只有当某个 fork 需要写入新 token 时才真正分配新 block。这使得 5-way Beam Search 的显存开销从 5× 降到接近 1×(假设共享 70% 前缀)。


def fork_beam(self, parent_id: str, child_id: str):
    # 共享物理 block 引用,未修改时不分配新空间
    self.req_to_block_tables[child_id] = self.req_to_block_tables[parent_id].copy()

三、Continuous Batching 与调度进阶

3.1 Iteration-Level Scheduling

传统静态批处理(Static Batching)必须等一个 batch 中所有请求完成才能释放 GPU 资源。而 Continuous Batching(也称 iteration-level scheduling)在每个 decode step 控制权的迭代边界上决定是否 swap in/out 请求。vLLM 的 Scheduler 在每个 schedule() 调用时执行以下流程:

  1. 检查等待队列(waiting queue),尝试为等待请求分配 KV block
  2. 如果 GPU block 不足,抢占(preempt)正在运行的请求——将其中低优先级请求的完整 KV Cache swap 到 CPU
  3. 合并新分配的 waiting 请求与 existing running 请求
  4. 如果 running 中有请求完成(生成 EOS 或达到 max_tokens),释放其 block

抢占的触发条件是 gpu_allocator.available < blocks_needed。抢占策略有两种:

  • Recomputation:丢弃被抢占请求的 KV Cache,等重新调度时重新 prefill。适合短序列或 block 极度稀缺时。
  • Swapping:将 KV Cache 拷贝到 CPU 内存。适合长 prefill 已经消耗大量计算的情况。

3.2 Chunked Prefill 的引入

在 Continuous Batching 中,Prefill(处理 prompt token 计算 KV Cache)曾是原子性的阻塞操作。一个 8K token 的 prompt 独占 GPU 几个毫秒期间,其他请求的 decode step 被饿死,严重拉高 TPOT(Time Per Output Token)。

Chunked Prefill 将长 prefill 拆成固定大小 chunk(如 512 token),每个 chunk 处理后让出 GPU 给 decode 请求。这等价于在一个 iteration 中混合了 prefill 计算和 decode step,用计算时间的一小部分换取延迟指标的显著改善。


def schedule_with_chunked_prefill(self, seq_group, remaining_prefill_tokens):
    remaining = min(remaining_prefill_tokens, self.chunked_prefill_size)
    chunk_blocks = ceil(remaining / self.block_size)
    if self.gpu_allocator.available >= chunk_blocks:
        self._allocate_chunk(seq_group, remaining)
        return ChunkPrefillPlan(seq_group, remaining)
    else:
        # block 不足:不抢占已有 decode,返回空 plan,下一轮再试
        return None

在 vLLM 的工程实现中,sampling_params 中 enable_chunked_prefill=True 时,prefill 的计算会被分成多个 step。每个 step 在 ModelRunner.execute_model 中通过 input_ids 的长度控制实际计算的 token 数。这要求模型的前向传播必须正确处理同一 batch 内混合 KV cache 状态的情况——部分请求是 decode(query_len=1),部分请求是 prefill chunk(query_len=chunk_size)。

FlashAttention 2.0 的 "varlen" 接口天然支持这一点:通过 cu_seqlens_q 和 cu_seqlens_k 数组告诉 kernel 每个 sequence 的实际起始偏移。

四、分离式推理与推测调度的工程权衡

4.1 Prefill-Decode Disaggregation

Chunked Prefill 缓解了 Prefill 对 Decode 的阻塞,但在高并发下吞吐的最优解是Prefill-Decode 分离部署架构(如 DistServe、Mooncake)。Prefill 实例纯做 prefill,Decode 实例纯做 decode step,两者通过 RDMA 或 NVLink 传输 KV Cache。

这种架构的优势在于:

  • Prefill 是计算密集(large GEMM),Decode 是带宽密集(repeated small GEMM + large KV read),混部会导致 GPU SM 利用率低下
  • 分离后 Prefill 实例可以配置更大 tensor parallelism,Decode 实例配置更大 pipeline parallelism
  • 调度器可以做更精细的 quota 控制,如 prefill 队列长度固定、decode 队列长度按 TPOT SLO 动态调节

DistServe 的论文指出,在 30K token prefill + 128 decode 的场景下,混部架构的 decode TPOT 恶化了 7.3×,而分离架构仅退化 1.2×。

4.2 Chunked Prefill 与 Disaggregation 的取舍

对于中小规模部署(单卡或 2-4 卡),Chunked Prefill 避免了跨节点网络 IO 的工程复杂度,是延迟与吞吐的优秀折中。但在大规模部署场景下,跨节点的 Disaggregation 带来的资源利用率提升远超 Chunked Prefill 的工程便利性收益。

五、实战调优:四个关键观测

5.1 Block 碎片率监控

在 vLLM 的 Prometheus 指标中,vllm:gpu_cache_usage_perc 和 vllm:swap_in/out 是核心观测点。实践中如果 swap 频率超过 5 次/秒,说明并发数过高或 block_size 不匹配业务序列长度分布。一个经验法则是让 P50 序列长度 ≤ 最大 context 的 30% 以获得健康利用率。

5.2 Chunk Size 对 TTFT vs. TBT 的取舍

Chunk Size TTFT(首 token 延迟) TBT(token 间延迟) 替代权衡
无 chunking(全 prefill) 最低(最快) 最高(被长 prompt 堵死) 低并发、不怕延迟尖刺
256 略高(额外数次调度) 较低 高并发、对 TTFT 有容限
1024 高(几乎无改善) 接近无 chunk 低并发、吞吐优先

生产经验表明,512 是多数场景下的"甜蜜点"——TTFT 增加不超过 15%,但 TBT P99 可从秒级改善到百毫秒级。

5.3 Prefix Caching 与 Multimodal Input

vLLM 0.4+ 引入 Automatic Prefix Caching:以 block 前 N 个 hash 值为 KV Cache 在全局 cache 中复用前缀。对于 system prompt 重用率高的 Agent 场景,首次 prefill 2K token 后,后续同前缀请求直接进入 decode 阶段,完全跳过 prefill 计算。实测在 LangChain 式对话链路中可降低 40-60% 的计算开销。

多模态模型的 Image Token 也受益于 Prefix Caching:相同不同角度的高分辨率图片前提只有 image embedding 略有不同,但经过 projection 后的 KV Cache 可能被完整命中——这是当前工程实践中的"高级技巧",需要对 KV Cache 做 content-based hash 而非简单前缀匹配。

5.4 KV Cache 量化:从 FP16 到 INT4

随着序列长度步入 128K+,KV Cache 成为比模型权重更重的显存负担。FP16→INT8 量化在 vLLM 中已是开箱即用,但 INT4 RoPE 感知量化才是真正解决长序列问题的杀手锏。KIVI 等研究证明,对Channel维度(head_dim 维度)做 INT2 量化、Token维度做 FP16 精度,可保持 logits 分布的误差在 2% 以内。工程中需要注意不同层对量化误差的敏感度差异:Attention 尾层对精度更敏感,应适当降量化程度。

六、展望:调度器的下一步

LLM 推理调度正在从"尽力而为"走向面向 SLO 的调度。DeepSpeed vLLM 和 NVIDIA NIM 已经展露了初步的 QoS 框架:

  • 分级队列:Free-tier 请求使用 swappable + preemptable 策略,Pro-tier 请求保证固定 block 配额
  • Decode Budget:为每个 decode 请求编码超过目标 TBT 的"预算余额",被抢占的请求扣除预算并在恢复时获得优先级补偿
  • Eco Mode:低负载时主动合并多个 decode batch 到更大 batch 以降低单 token 能耗

从更宏观的视角看,PagedAttention + Chunked Prefill + Prefix Caching 构成了现代推理引擎的"铁三角":PagedAttention 负责存储效率、Chunked Prefill 负责延迟可预测性、Prefix Cache 负责计算节省。理解这三者的工程细节,是构建生产级 LLM 服务的必要门槛。

实践建议:如果你的服务 TPOT P99 波动超过中位数 3×,优先排查 prefill chunk 配置而非盲目加卡——在多数场景下,更好地调度显存带来的收益远超硬件扩展。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部