4. Block Table机制:逻辑块到物理块的映射表,实现非连续物理显存的连续逻辑访问

好,咱们接着聊PagedAttention的核心机制。前面我讲了显存碎片化的问题,也提到了KV Cache怎么被切成块。那问题来了——块切好了,怎么让上层模型觉得这些块是连续的呢?

答案就是Block Table。说白了,它就是一个映射表。逻辑上连续的块,物理上可以东一块西一块。模型只管按逻辑地址访问,Block Table负责把逻辑地址翻译成真正的物理地址。

4.1 为什么需要Block Table?

我刚开始接触vLLM时,有个疑问一直绕不开:为什么非要多此一举搞个映射层?直接连续分配不香吗?

嗯,你想想看,大模型推理时,每个请求的序列长度是动态变化的。有的请求生成了10个token就停了,有的可能要生成1000个。如果提前分配连续显存,要么浪费,要么不够用。

我在项目中遇到过这样一个场景:一个8卡A100的推理集群,跑LLaMA-65B模型。如果不做Block Table,显存利用率大概只有40%左右。剩下的60%去哪了?全被碎片化和预分配浪费掉了。

Block Table的核心价值就三点:

  • 消除外部碎片:物理块可以散落在显存各处
  • 按需分配:用多少块就分配多少块,不提前占坑
  • 支持动态增长:序列变长了,追加新的物理块就行

核心思想:逻辑连续 ≠ 物理连续。Block Table就是连接这两者的桥梁。

4.2 Block Table的数据结构

Block Table本质上就是一个数组。数组的索引是逻辑块号,数组的值是物理块号。简单吧?

但实际实现中,vLLM用了更精细的结构。我直接贴核心代码,咱们一行行看:

class BlockTable:
    def __init__(self, block_size: int):
        self.block_size = block_size
        # 逻辑块号 -> 物理块号 的映射
        self._mapping: List[Optional[int]] = []
        # 物理块号 -> 引用计数
        self._ref_count: Dict[int, int] = {}
    
    def allocate(self, num_blocks: int) -> List[int]:
        """分配num_blocks个逻辑块,返回物理块号列表"""
        physical_blocks = self._allocator.allocate(num_blocks)
        for pb in physical_blocks:
            self._mapping.append(pb)
            self._ref_count[pb] = self._ref_count.get(pb, 0) + 1
        return physical_blocks
    
    def translate(self, logical_block_id: int) -> int:
        """逻辑块号 -> 物理块号"""
        if logical_block_id >= len(self._mapping):
            raise IndexError(f"逻辑块 {logical_block_id} 超出范围")
        return self._mapping[logical_block_id]
    
    def free(self, logical_block_id: int):
        """释放逻辑块"""
        pb = self._mapping[logical_block_id]
        self._ref_count[pb] -= 1
        if self._ref_count[pb] == 0:
            self._allocator.free(pb)
        self._mapping[logical_block_id] = None

注意看_ref_count这个字段。为什么要有引用计数?因为多个序列可能共享同一个物理块。比如beam search时,多个候选序列的前缀是一样的,它们可以共享物理块。引用计数归零了,才真正释放显存。

个人经验:我刚开始实现时没加引用计数,结果共享前缀的序列释放时把别人的数据也清掉了。排查了两天才发现这个坑。引用计数虽小,但必不可少。

4.3 逻辑到物理的映射过程

咱们用一个具体例子走一遍流程。假设block_size=16,即每个块存16个token的KV Cache。

一个请求来了,它需要生成32个token。逻辑上需要2个块(块0和块1)。

  1. 请求到达,Block Table分配2个物理块。假设物理块号是5和8。
  2. 映射关系:逻辑块0 → 物理块5,逻辑块1 → 物理块8。
  3. 模型访问逻辑块0时,Block Table返回物理块5的地址。
  4. 模型访问逻辑块1时,Block Table返回物理块8的地址。

你看,物理块5和8在显存里可能隔得很远,但模型完全感知不到。它只知道自己有连续的逻辑块0和1。

如果序列继续生成,需要追加第3个块。Block Table再分配一个物理块,比如物理块2,然后追加到映射表末尾。整个过程对模型透明。

4.4 多序列场景下的Block Table

实际推理时,显存里同时跑着几十上百个序列。每个序列都有自己的Block Table。vLLM是怎么管理这么多Block Table的?

我画了一张图,帮你理解这个结构:

多序列Block Table架构图 序列1 Block Table 逻辑块0 逻辑块1 逻辑块2 序列2 Block Table 逻辑块0 逻辑块1 序列3 Block Table 逻辑块0 逻辑块1 逻辑块2 物理块池(显存) 物理块0 物理块1 物理块2 物理块3 物理块4 物理块5 物理块6 物理块7 ref=1 ref=2 ref=1 ref=0 ref=1 ref=1 ref=0 ref=1 序列1占用 序列2占用 序列3占用 空闲块

从图上你能看到:

  • 序列1的逻辑块0→物理块0,逻辑块1→物理块1,逻辑块2→物理块5
  • 序列2的逻辑块0→物理块2,逻辑块1→物理块1(和序列1共享物理块1)
  • 序列3的逻辑块0→物理块4,逻辑块1→物理块5,逻辑块2→物理块7

注意物理块1被两个序列共享了,所以它的引用计数是2。物理块3和6的引用计数是0,表示空闲。

避坑指南:我曾经在实现共享块时犯过一个错误——释放序列时直接清空了物理块,没检查引用计数。结果另一个序列还在用这个块,数据突然变成全零,推理结果直接崩了。记住:共享块一定要等引用计数归零才能释放。

4.5 Block Table的性能开销

你可能会担心:每次访问都要查一次映射表,会不会有性能损失?

说实话,确实有。但vLLM做了两件事来降低这个开销:

  1. 批量翻译:一次推理需要访问多个块,vLLM会一次性把逻辑块号数组翻译成物理块号数组,而不是逐个翻译。
  2. 缓存热点映射:频繁访问的块映射关系会缓存在L1 cache里,命中率很高。

我在实际测试中,Block Table的翻译开销占整个推理时间的不到0.5%。相比它带来的显存利用率提升(从40%到95%+),这点开销完全可以忽略。

4.6 总结一下

Block Table是PagedAttention的基石。它用一张简单的映射表,解决了大模型推理中显存管理的核心矛盾——逻辑连续性和物理离散性。

记住三个关键点:

  • 每个序列有自己的Block Table
  • 物理块可以被多个序列共享(通过引用计数管理)
  • 翻译开销极小,收益巨大

嗯,Block Table就聊到这。下一节咱们看看vLLM怎么用这个机制做显存管理的全局调度,那才是真正体现工程智慧的地方。

专注资料整理