大模型推理加速:从 KV Cache 到 Continuous Batching 的实战复盘

cover

一、深夜告警:GPU 没跑满,请求却在排队

某天凌晨,监控面板突然报警——线上 LLM 推理服务的 P99 延迟从 800ms 飙到了 4.2s。排查下来发现,并发量从 50 QPS 涨到 200 QPS 时,GPU 利用率居然只有 35%。大部分时间不是花在计算上,而是耗在请求调度和内存拷贝上。问题不在模型本身,而是推理框架没把 GPU 喂饱。

大模型推理的瓶颈往往不在计算密度,而在于调度策略。请求调度、内存管理、批处理方式的粗放设计,让 GPU 大量时间在“等数据”。这篇文章结合生产环境代码和压测数据,聊聊 KV Cache 管理、Continuous Batching、Prefix Caching 这几个关键优化点。

二、推理加速的三个关键点

2.1 KV Cache:避免重复计算注意力

Transformer 自回归解码时,每生成一个 token 都要重新计算前面所有 token 的注意力。KV Cache 把已经算好的 Key/Value 向量存下来,下次直接用。不过 KV Cache 占用的显存会随着序列长度线性增长,7B 模型在 seq_len=4096 时,光 KV Cache 就要占 2GB 左右。

2.2 Continuous Batching:别让短序列等长序列

传统的 Static Batching 要求批内所有序列都跑完才能释放资源,短序列只能干等着长序列。Continuous Batching 在每个迭代步动态插入新请求、移除已完成请求,GPU 利用率能从 35% 提到 85% 以上。

2.3 Prefix Caching:复用公共前缀

多轮对话里,系统提示词和上下文前缀往往是一样的。Prefix Caching 把公共前缀的 KV Cache 跨请求复用,后续请求直接命中缓存,跳过 prefill 阶段。

sequenceDiagram
    participant Client
    participant Scheduler
    participant KVCacheMgr
    participant GPU

    Client->>Scheduler: 请求1 (prompt + query)
    Scheduler->>KVCacheMgr: 检查 prefix cache 命中
    KVCacheMgr-->>Scheduler: 未命中,分配新 slot
    Scheduler->>GPU: prefill(prompt) + decode(query)
    GPU-->>KVCacheMgr: 存储 KV Cache
    KVCacheMgr-->>Scheduler: 返回 token

    Client->>Scheduler: 请求2 (相同 prompt + 新 query)
    Scheduler->>KVCacheMgr: 检查 prefix cache 命中
    KVCacheMgr-->>Scheduler: 命中!复用 prefix KV
    Scheduler->>GPU: 仅 decode(query),跳过 prefill
    GPU-->>Scheduler: 返回 token(延迟降低 60%)

三、代码实现与压测结果

3.1 KV Cache 分页管理器

KV Cache 最头疼的问题是显存碎片化。借鉴操作系统的虚拟内存分页机制,把 KV Cache 切成固定大小的 Block,按需分配。

import torch
from typing import Dict, List, Optional
from dataclasses import dataclass, field

@dataclass
class KVBlock:
    """KV Cache 的一个物理块,固定大小"""
    block_id: int
    ref_count: int = 0           # 引用计数,支持 prefix cache 共享
    device_tensor: Optional[torch.Tensor] = None  # 实际显存数据

class PagedKVCacheManager:
    """
    分页式 KV Cache 管理器
    核心思路:将 KV Cache 按固定 block_size 分页,
    逻辑序列通过 page table 映射到物理 block,
    避免显存预分配导致的碎片化问题
    """
    def __init__(
        self,
        num_blocks: int,
        block_size: int,
        num_kv_heads: int,
        head_dim: int,
        num_layers: int,
        dtype: torch.dtype = torch.float16,
    ):
        self.block_size = block_size
        self.num_layers = num_layers

        # 预分配所有物理 block 的显存池
        # 形状: [num_blocks, 2, num_kv_heads, block_size, head_dim]
        # 2 对应 K 和 V
        element_size = torch.tensor([], dtype=dtype).element_size()
        per_block_bytes = 2 * num_kv_heads * block_size * head_dim * element_size
        total_bytes = num_blocks * per_block_bytes * num_layers
        print(f"[KVCache] 预分配显存池: {total_bytes / 1024**3:.2f} GB, "
              f"共 {num_blocks} 个 block")

        self.kv_pool = torch.empty(
            (num_layers, num_blocks, 2, num_kv_heads, block_size, head_dim),
            dtype=dtype, device="cuda"
        )

        # 空闲 block 链表
        self.free_blocks: List[KVBlock] = [
            KVBlock(block_id=i) for i in range(num_blocks)
        ]
        # 逻辑序列 -> 物理 block 映射表
        self.page_table: Dict[int, List[int]] = {}
        # block_id -> KVBlock 反向索引
        self.block_map: Dict[int, KVBlock] = {
            b.block_id: b for b in self.free_blocks
        }

    def allocate(self, seq_id: int, num_tokens: int) -> List[int]:
        """
        为序列分配 KV Cache block
        返回分配的物理 block_id 列表
        """
        num_needed = (num_tokens + self.block_size - 1) // self.block_size
        if len(self.free_blocks) < num_needed:
            raise RuntimeError(
                f"显存不足: 需要 {num_needed} 个 block, "
                f"仅剩 {len(self.free_blocks)} 个"
            )

        allocated = []
        for _ in range(num_needed):
            block = self.free_blocks.pop()
            block.ref_count = 1
            allocated.append(block.block_id)

        self.page_table[seq_id] = allocated
        return allocated

    def free(self, seq_id: int) -> None:
        """释放序列占用的所有 KV Cache block"""
        if seq_id not in self.page_table:
            return

        for block_id in self.page_table[seq_id]:
            block = self.block_map[block_id]
            block.ref_count -= 1
            # 引用计数归零才真正回收,支持 prefix cache 共享
            if block.ref_count <= 0:
                block.ref_count = 0
                self.free_blocks.append(block)

        del self.page_table[seq_id]

    def copy_prefix(
        self, src_seq_id: int, dst_seq_id: int, prefix_len: int
    ) -> List[int]:
        """
        复用 prefix 的 KV Cache(零拷贝,仅增加引用计数)
        用于多轮对话场景,避免重复计算系统提示词
        """
        src_blocks = self.page_table.get(src_seq_id, [])
        num_prefix_blocks = prefix_len // self.block_size

        dst_blocks = []
        # 共享 prefix block:增加引用计数,零拷贝
        for block_id in src_blocks[:num_prefix_blocks]:
            self.block_map[block_id].ref_count += 1
            dst_blocks.append(block_id)

        # 为新增 token 分配新 block
        remaining_tokens = prefix_len % self.block_size
        if remaining_tokens > 0:
            new_blocks = self.allocate(dst_seq_id, remaining_tokens)
            dst_blocks.extend(new_blocks)

        self.page_table[dst_seq_id] = dst_blocks
        return dst_blocks

    def get_physical_table(self, seq_id: int) -> torch.Tensor:
        """返回序列的 page table,用于 GPU kernel 中的地址映射"""
        block_ids = self.page_table.get(seq_id, [])
        return torch.tensor(block_ids, dtype=torch.int32, device="cuda")

3.2 Continuous Batching 调度器

import time
from collections import deque
from dataclasses import dataclass
from typing import Deque, List, Set

@dataclass
class Sequence:
    """推理序列状态机"""
    seq_id: int
    prompt_token_ids: List[int]
    generated_tokens: List[int] = field(default_factory=list)
    is_finished: bool = False
    max_tokens: int = 512

    @property
    def num_generated(self) -> int:
        return len(self.generated_tokens)

class ContinuousBatcher:
    """
    连续批处理调度器
    核心逻辑:每个 decode step 动态调整 batch 组成,
    已完成序列立即让出资源,新请求即时填入
    """
    def __init__(self, max_batch_size: int = 64):
        self.max_batch_size = max_batch_size
        self.waiting_queue: Deque[Sequence] = deque()
        self.running_batch: List[Sequence] = []
        self.finished_ids: Set[int] = set()

    def add_request(self, seq: Sequence) -> None:
        """新请求入队"""
        self.waiting_queue.append(seq)

    def schedule(self) -> List[Sequence]:
        """
        单步调度:移除已完成序列,填入新请求
        返回当前 step 的活跃 batch
        """
        # 移除已完成的序列
        self.running_batch = [
            s for s in self.running_batch
            if not s.is_finished
        ]

        # 从等待队列填入新请求,直到 batch 满
        available_slots = self.max_batch_size - len(self.running_batch)
        while available_slots > 0 and self.waiting_queue:
            seq = self.waiting_queue.popleft()
            self.running_batch.append(seq)
            available_slots -= 1

        return self.running_batch

    def step(self) -> List[Sequence]:
        """
        执行一次 decode step
        实际生产中此处调用 GPU kernel 执行推理
        """
        batch = self.schedule()
        if not batch:
            return []

        # 模拟 decode:每个序列生成一个 token
        for seq in batch:
            # 实际场景:调用模型 forward,取 argmax token
            seq.generated_tokens.append(0)  # placeholder
            if seq.num_generated >= seq.max_tokens:
                seq.is_finished = True
                self.finished_ids.add(seq.seq_id)

        return batch

    def is_idle(self) -> bool:
        return len(self.running_batch) == 0 and len(self.waiting_queue) == 0

3.3 压测数据:加速效果对比

在 A100 80GB 上部署 LLaMA-2-7B,对比三种策略的吞吐与延迟:

策略 QPS P50 延迟 P99 延迟 GPU 利用率
Static Batching (batch=32) 45 1.2s 4.1s 38%
Continuous Batching 120 0.6s 1.8s 82%
Continuous + Prefix Cache 165 0.35s 1.1s 88%

数据很直观:Continuous Batching 把吞吐提升了 2.7 倍,加上 Prefix Cache 后达到 3.7 倍,P99 延迟从 4.1s 降到了 1.1s。

四、加速策略的代价:显存、复杂度与一致性

4.1 KV Cache 分页管理的显存开销

分页管理解决了碎片化,但也引入了 page table 的额外显存和查表开销。block_size 越小,碎片越少,但 page table 越大。实测 block_size=16 是 7B 模型的甜点,13B 模型建议 block_size=32。

4.2 Continuous Batching 的调度延迟

每个 step 都要执行 schedule 逻辑,在 batch_size=64 时,纯 Python 调度耗时约 0.3ms。对于 decode step 仅需 10ms 的场景,调度占比 3%。如果 batch_size 超过 256,得把调度逻辑下沉到 C++/CUDA,否则调度本身会成为瓶颈。

4.3 Prefix Cache 的一致性风险

共享 prefix block 用引用计数实现零拷贝,但如果模型权重更新(比如在线学习),缓存的 KV 值和新权重不匹配,输出质量会出问题。生产环境中,模型权重更新时必须强制失效所有 prefix cache。

4.4 不适合的场景

  • 显存极度紧张(小于模型权重 1.2 倍)时,KV Cache 分页意义不大,建议优先用 PagedAttention 的 swap 机制
  • 请求序列长度差异极大(1 token vs 8192 token)时,Continuous Batching 的调度开销可能抵消收益
  • 单轮无前缀复用的场景,Prefix Cache 完全没用

五、总结

大模型推理加速的核心是最大化 GPU 计算密度。KV Cache 分页管理消除显存碎片,Continuous Batching 消除请求等待空洞,Prefix Cache 消除重复计算——这三者分别从内存、调度、计算三个维度压缩浪费。压测数据表明,三者叠加后 A100 上的推理吞吐提升了 3.7 倍,P99 延迟降低了 73%。

但每项优化都有代价:分页引入查表开销,连续批处理引入调度延迟,前缀缓存引入一致性风险。性能优化从来不是免费午餐,而是对具体场景的精确权衡。用代码说话,用数据服人——这才是推理加速工程的正确打开方式。

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐