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)。
- 请求到达,Block Table分配2个物理块。假设物理块号是5和8。
- 映射关系:逻辑块0 → 物理块5,逻辑块1 → 物理块8。
- 模型访问逻辑块0时,Block Table返回物理块5的地址。
- 模型访问逻辑块1时,Block Table返回物理块8的地址。
你看,物理块5和8在显存里可能隔得很远,但模型完全感知不到。它只知道自己有连续的逻辑块0和1。
如果序列继续生成,需要追加第3个块。Block Table再分配一个物理块,比如物理块2,然后追加到映射表末尾。整个过程对模型透明。
4.4 多序列场景下的Block Table
实际推理时,显存里同时跑着几十上百个序列。每个序列都有自己的Block Table。vLLM是怎么管理这么多Block Table的?
我画了一张图,帮你理解这个结构:
从图上你能看到:
- 序列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做了两件事来降低这个开销:
- 批量翻译:一次推理需要访问多个块,vLLM会一次性把逻辑块号数组翻译成物理块号数组,而不是逐个翻译。
- 缓存热点映射:频繁访问的块映射关系会缓存在L1 cache里,命中率很高。
我在实际测试中,Block Table的翻译开销占整个推理时间的不到0.5%。相比它带来的显存利用率提升(从40%到95%+),这点开销完全可以忽略。
4.6 总结一下
Block Table是PagedAttention的基石。它用一张简单的映射表,解决了大模型推理中显存管理的核心矛盾——逻辑连续性和物理离散性。
记住三个关键点:
- 每个序列有自己的Block Table
- 物理块可以被多个序列共享(通过引用计数管理)
- 翻译开销极小,收益巨大
嗯,Block Table就聊到这。下一节咱们看看vLLM怎么用这个机制做显存管理的全局调度,那才是真正体现工程智慧的地方。