V2 设计动机
Block Manager V1 将所有逻辑集中在一个大类中(BlockSpaceManagerV1),随着 prefix caching、sliding window、CoW 等高级特性的加入,代码变得越来越复杂。V2 通过清晰的抽象分层来解决这个问题:
- Block:单个内存块的抽象,封装 token 存储和 content hash
- BlockAllocator:块分配器接口,管理块的分配和释放
- BlockTable:序列级别的块管理,封装 token 到块的映射
- DeviceAwareBlockAllocator:跨设备(GPU/CPU)的块分配
can_swap_in 始终返回 AllocStatus.LATER,swap_out 直接 raise NotImplementedError)。但其 prefix caching 和 BlockTable 抽象已经完整可用。
架构层级总览
scheduler 调用的顶层接口"] BT["BlockTable
每个序列一个,管理 token→block 映射"] CGA["CpuGpuBlockAllocator
跨设备块分配器"] NBA["NaiveBlockAllocator
基础分配策略"] PCA["PrefixCachingBlockAllocator
前缀缓存分配策略"] NB["NaiveBlock
简单块实现"] PCB["PrefixCachingBlock
带 content hash 的块"] BSM -->|"持有 Dict[SeqId, BlockTable]"| BT BSM -->|"持有"| CGA BT -->|"通过 allocator 分配块"| CGA CGA -->|"GPU/CPU 各一个"| NBA CGA -->|"或"| PCA NBA -->|"创建"| NB PCA -->|"创建"| PCB
核心设计理念:BlockTable 负责序列粒度的块管理,BlockAllocator 负责设备粒度的内存管理。BlockSpaceManagerV2 只是将两者组合在一起,提供 Scheduler 需要的接口。
Block 接口
Block(block/interfaces.py L9-89)是所有块的抽象基类:
class Block(ABC):
@abstractmethod
def append_token_ids(self, token_ids: List[int]) -> None:
pass
@property
@abstractmethod
def block_id(self) -> Optional[int]:
pass
@property
@abstractmethod
def token_ids(self) -> List[int]:
pass
@property
@abstractmethod
def num_empty_slots(self) -> int:
pass
@property
@abstractmethod
def is_full(self) -> bool:
pass
@property
@abstractmethod
def prev_block(self) -> Optional["Block"]:
pass
@property
@abstractmethod
def computed(self) -> bool:
raise NotImplementedError
@property
@abstractmethod
def last_accessed(self) -> float:
raise NotImplementedError
@property
@abstractmethod
def content_hash(self) -> Optional[int]:
"""Return the content-based hash of the current block, or None if it is
not yet defined or not supported. For the content-based hash to be
defined, the current block must be full."""
return None
关键属性:
| 属性 | 作用 |
|---|---|
block_id | 物理块 ID,对应实际的 GPU/CPU 内存位置 |
token_ids | 存储在此块中的 token ID 列表 |
prev_block | 前一个块的引用,形成链表结构(用于 prefix hash) |
content_hash | 基于内容的哈希值,只有满块才有定义 |
computed | 是否已计算过 KV Cache |
last_accessed | 最近访问时间(用于 LRU 淘汰) |
BlockTable 抽象
BlockTable(block/block_table.py)为每个序列维护一组有序的 Block,是 V2 的核心设计:
class BlockTable:
"""A class to manage blocks for a specific sequence.
The BlockTable maps a sequence of tokens to a list of blocks, where each
block represents a contiguous memory allocation for a portion of the
sequence.
"""
def __init__(self, block_size, block_allocator, _blocks=None):
self._block_size = block_size
self._allocator = block_allocator
self._blocks: List[Block] = _blocks or []
self._num_full_slots = len(self._get_all_token_ids())
@staticmethod
def get_num_required_blocks(token_ids, block_size) -> int:
return cdiv(len(token_ids), block_size)
def allocate(self, token_ids, device=Device.GPU):
"""首次分配:为整个 token 序列分配所有需要的 block"""
self._blocks = self._allocate_blocks_for_token_ids(
prev_block=None, token_ids=token_ids, device=device)
self._num_full_slots = len(token_ids)
def append_token_ids(self, token_ids, num_lookahead_slots=0):
"""追加 token:用于 decode 阶段逐步生成"""
self.ensure_num_empty_slots(len(token_ids) + num_lookahead_slots)
blocks = self._blocks[self._num_full_slots // self._block_size:]
token_blocks = self._chunk_token_blocks_for_append(token_ids)
for block, token_block in zip(blocks, token_blocks):
block.append_token_ids(token_block)
self._num_full_slots += len(token_ids)
BlockTable 管理三个核心操作:
- allocate:初始化时为完整 prompt 分配块。通过
_allocate_blocks_for_token_ids将 token 切分为block_size大小的 chunk,每个 chunk 分配一个 block - append_token_ids:decode 阶段追加新 token。先通过
ensure_num_empty_slots确保有足够空间,然后写入 - fork:beam search 时复制 block table,新表共享底层 block(CoW 语义)
def ensure_num_empty_slots(self, num_empty_slots: int) -> None:
if self._num_empty_slots >= num_empty_slots:
return
slots_to_allocate = num_empty_slots - self._num_empty_slots
blocks_to_allocate = cdiv(slots_to_allocate, self._block_size)
for _ in range(blocks_to_allocate):
self._blocks.append(
self._allocator.allocate_mutable(
prev_block=self._blocks[-1],
device=Device.GPU))
- immutable block:已满的块,token 内容固定,可以计算 content hash。在 prefix caching 中用于匹配已缓存的 KV Cache
- mutable block:未满的块,还在被追加 token。当它被填满时,自动变为 immutable
CpuGpuBlockAllocator
CpuGpuBlockAllocator(block/cpu_gpu_block_allocator.py)是设备感知的分配器,封装了 GPU 和 CPU 两个独立的 BlockAllocator:
@staticmethod
def create(allocator_type, num_gpu_blocks, num_cpu_blocks, block_size):
block_ids = list(range(num_gpu_blocks + num_cpu_blocks))
gpu_block_ids = block_ids[:num_gpu_blocks]
cpu_block_ids = block_ids[num_gpu_blocks:]
if allocator_type == "naive":
gpu_allocator = NaiveBlockAllocator(
create_block=NaiveBlock, num_blocks=num_gpu_blocks,
block_size=block_size, block_ids=gpu_block_ids)
cpu_allocator = NaiveBlockAllocator(
create_block=NaiveBlock, num_blocks=num_cpu_blocks,
block_size=block_size, block_ids=cpu_block_ids)
elif allocator_type == "prefix_caching":
gpu_allocator = PrefixCachingBlockAllocator(
num_blocks=num_gpu_blocks, block_size=block_size,
block_ids=gpu_block_ids)
cpu_allocator = PrefixCachingBlockAllocator(
num_blocks=num_cpu_blocks, block_size=block_size,
block_ids=cpu_block_ids)
return CpuGpuBlockAllocator(
cpu_block_allocator=cpu_allocator,
gpu_block_allocator=gpu_allocator)
关键设计:
- Block ID 全局唯一:GPU 和 CPU 的 block ID 是连续分配的(GPU: 0~N-1, CPU: N~N+M-1),通过
_block_ids_to_allocator映射表可以根据 block ID 找到对应的设备分配器 - 策略可配置:通过
allocator_type选择"naive"(基础分配)或"prefix_caching"(前缀缓存),GPU 和 CPU 使用相同的策略 - CoW 在 GPU 侧:
clear_copy_on_writes()只在 GPU 分配器上调用
NaiveBlock 实现
NaiveBlock(block/naive_block.py)是最基础的 Block 实现,使用简单的 free list 管理块:
NaiveBlockAllocator 维护一个空闲块 ID 池。分配时从池中弹出一个 ID,释放时将 ID 推回池中。CoW 通过引用计数实现:
allocate_mutable:从 free list 取一个块,创建新的 NaiveBlockallocate_immutable:与 mutable 相同,但块一开始就是满的fork:通过_cow_tracker.increase_refcount()增加引用计数,多个序列共享同一个物理块free:减少引用计数,归零时才真正释放
NaiveBlock.content_hash 始终返回 None,因此无法用于 prefix caching。如果需要 prefix caching,必须使用 PrefixCachingBlock。
PrefixCachingBlock
PrefixCachingBlockAllocator(block/prefix_caching_block.py)在 NaiveBlock 基础上增加了基于内容的块去重能力。
Content Hash 计算
每个满块(is_full=True)会计算一个 content hash:
content_hash = hash((prev_block_hash, tuple(token_ids)))
hash 的输入包括前一个块的 hash和当前块的 token ID。这意味着两个块即使 token 内容相同,如果前缀不同,hash 也不同。这正是"前缀缓存"语义所需要的。
Prefix Caching 工作原理
token_ids = [1,2,3,4,5,6,7,8]"] --> B["切分为 block_size=4 的块"] B --> C["Block 0: [1,2,3,4]
hash = H0"] B --> D["Block 1: [5,6,7,8]
hash = H1"] C --> E{"H0 在缓存中?"} E -->|"是"| F["复用已有物理块
跳过 prefill"] E -->|"否"| G["分配新物理块
正常 prefill"] D --> H{"H1 在缓存中?"} H -->|"是"| I["复用已有物理块"] H -->|"否"| J["分配新物理块"]
allocate_immutable 的核心逻辑:
- 计算 content hash
- 查找
_cached_blocks映射表 - 如果找到匹配的已缓存块 → 增加引用计数,直接返回(零分配)
- 如果没找到 → 分配新块,加入缓存映射表
已计算块的跳过
get_common_computed_block_ids(block_manager_v2.py L234-250)找出所有序列共享的、已计算的前缀块。这些块的 KV Cache 已经在 GPU 上,prefill 时可以直接跳过,大幅减少计算量。
BlockSpaceManagerV2
BlockSpaceManagerV2(block_manager_v2.py L17-278)是 V2 的顶层管理器,提供 Scheduler 所需的标准接口:
def __init__(self, block_size, num_gpu_blocks, num_cpu_blocks,
watermark=0.01, sliding_window=None, enable_caching=False):
self.block_size = block_size
self.num_total_gpu_blocks = num_gpu_blocks
self.num_total_cpu_blocks = num_cpu_blocks
self.watermark_blocks = int(watermark * num_gpu_blocks)
self.enable_caching = enable_caching
self.block_allocator = CpuGpuBlockAllocator.create(
allocator_type="prefix_caching" if enable_caching else "naive",
num_gpu_blocks=num_gpu_blocks,
num_cpu_blocks=num_cpu_blocks,
block_size=block_size,
)
self.block_tables: Dict[SeqId, BlockTable] = {}
关键接口实现:
can_allocate / allocate
can_allocate 使用 watermark 机制(保留 1% 的块防止频繁淘汰),通过 BlockTable.get_num_required_blocks 计算最坏情况下需要的块数。allocate 为第一个序列创建 BlockTable 并分配块,后续序列通过 block_table.fork() 共享。
can_append_slots / append_slots
can_append_slots 使用最坏情况估计:假设每个被触及的块都需要新分配(考虑 CoW)。实际分配在 append_slots 中通过 block_table.append_token_ids 执行。
def can_append_slots(self, seq_group, num_lookahead_slots) -> bool:
num_touched_blocks = 0
for seq in seq_group.get_seqs(status=SequenceStatus.RUNNING):
block_table = self.block_tables[seq.seq_id]
num_touched_blocks += (
block_table.get_num_blocks_touched_by_append_slots(
token_ids=block_table.get_unseen_token_ids(
seq.get_token_ids()),
num_lookahead_slots=num_lookahead_slots,
))
num_free_gpu_blocks = self.block_allocator.get_num_free_blocks(
Device.GPU)
return num_touched_blocks <= num_free_gpu_blocks
Lookahead Slots
V2 引入了 lookahead slots 概念——为每个序列预留额外的 KV Cache 空间,供 speculative decoding 使用。这些 slot 在 append_token_ids 时一并分配,但内容未定义。
V1 vs V2 对比
| 维度 | Block Manager V1 | Block Manager V2 |
|---|---|---|
| 架构 | 单体类,所有逻辑集中 | 分层架构:Block → BlockAllocator → BlockTable → Manager |
| 块抽象 | PhysicalTokenBlock + LogicalTokenBlock | 统一 Block ABC,immutable/mutable 语义 |
| Prefix Caching | 通过 evictor 实现 LRU 淘汰 | content hash 驱动,allocate_immutable 自动去重 |
| Swap | 完整实现 | 未实现(NotImplementedError) |
| Sliding Window | 完整实现 | 未实现(assert) |
| CoW | 引用计数 + copy-on-write 映射 | 引用计数 + CowTracker |
| Lookahead Slots | 不支持 | 原生支持(speculative decoding) |
| 配置 | block_manager_version="v1" |
block_manager_version="v2" + enable_prefix_caching=True |
- 如果需要 swap 功能(GPU 显存紧张场景),目前只能用 V1
- 如果需要 prefix caching(大量共享 system prompt 场景),V2 的实现更优雅
- 如果需要 speculative decoding(lookahead slots),需要 V2
- V1 是经过生产验证的稳定版本,V2 是面向未来的架构重构