vLLM v1 中 Scheduler(CPU) ↔ Worker(GPU) 的索引契约:为什么 HBM 索引发到 worker、为什么有两种 SlotMappingMode、DeepSeek V4 的三通道索引设计。
| 术语 | 实际含义 | 位置 |
|---|---|---|
| KV cache host | Scheduler 侧 KVCacheManager:逻辑块分配、引用计数、free-list、prefix caching | CPU |
| HBM 索引 | Worker 侧 BlockTable 产出的两个 GPU tensor:block_table([max_reqs, max_blocks_per_req])和 slot_mapping([max_num_tokens]) | GPU HBM |
| block_ids | Scheduler → Worker 之间传递的逻辑块 ID 列表(tuple[list[int],...]) | CPU → GPU 的消息 |
vllm/v1/worker/block_table.py:19-21, 24-112 · vllm/v1/core/kv_cache_manager.py:283 · vllm/v1/core/sched/output.py:39,126
一句话:Scheduler 只识逻辑块号,Attention kernel 只识物理 HBM 地址;两者之间的翻译必须在 worker 里完成。
Scheduler 通过 KVCacheManager.allocate_slots() 从 free-list 拿出逻辑块号,装进 SchedulerOutput.new_block_ids:
# vllm/v1/core/kv_cache_manager.py:283
def allocate_slots(self, request, num_new_tokens, ...) -> KVCacheBlocks | None:
# 从 free-list 拿出若干逻辑块号 [block_3, block_7, ...]
# 返回给 Scheduler → 装进 SchedulerOutput.new_block_ids
[num_blocks, block_size, head_dim] 还是别的)block_table 还是 slot_mapping
这是职责分离的核心:调度策略 和 设备内存布局 是两个独立变化维度。
Worker 收到 block_ids 后,在 GPU HBM 上构建 kernel 直接消费的索引:
# vllm/v1/worker/gpu_model_runner.py:1444-1445
# 1. 收到逻辑 block_ids,append 到 block_table 的 CPU 镜像
self.input_batch.block_table.append_row(new_block_ids, req_index)
# gpu_model_runner.py:1950
# 2. commit:CPU → GPU 异步拷贝(HBM 索引就位)
self.input_batch.block_table.commit_block_table(num_reqs)
# gpu_model_runner.py:2168
# 3. compute_slot_mapping:用 Triton kernel 把 block_table 翻译成
# 每个 token 的物理 slot 索引(HBM 地址的"偏移编号")
self.input_batch.block_table.compute_slot_mapping(
num_reqs, query_start_loc, positions)
源: vllm/v1/worker/gpu_model_runner.py:1444,1950,2168
_compute_slot_mapping_kernel(Triton)执行 OS 页表同构的翻译:
# vllm/v1/worker/block_table.py:348-411
@triton.jit
def _compute_slot_mapping_kernel(...):
# 对每个 token:
virtual_block_indices = pos // virtual_block_size
block_indices = virtual_block_indices * BLOCKS_PER_KV_BLOCK
+ local_block_offsets // block_size
block_numbers = block_table[req_idx, block_indices] # 查页表
slot_offsets = local_block_offsets % block_size
slot_ids = block_numbers * block_size + slot_offsets # 物理 slot
block_table = 页表(逻辑 block → 物理 block 号)slot_mapping = 虚实地址转换结果(逻辑位置 → 物理 HBM slot)slot_id = block_number × block_size + offset 完全等价 phys = page_frame × PAGE_SIZE + page_offset
| 原因 | 解释 |
|---|---|
| 本地性 | slot_mapping 要直接喂给 attention kernel(FA/FlashMLa),kernel 在 GPU 上执行 |
| 解耦 | 让 Scheduler 算 = 让 CPU 替 GPU 做内存访问规划,耦合设备布局 |
| 依赖 | slot_mapping 依赖 positions、query_start_loc 等 GPU tensor |
| 变化速率 | 调度策略(prefix cache/recompute/swap)经常变;GPU 内存布局(block_size/CP/DP)也经常变;两者独立 |
Scheduler (CPU) Worker (GPU)
───────────────── ─────────────
block_ids: [3, 7, 12] ──────append────→ block_table.cpu[row] = [3,7,12]
│
↓ commit_block_table (H2D)
block_table.gpu ← 这就是"HBM 索引"
│
↓ compute_slot_mapping (Triton)
slot_mapping.gpu ← kernel 直接消费
# vllm/v1/worker/block_table.py:19-21
class SlotMappingMode(Enum):
TOKEN_TO_KV_SLOT = "token_to_kv_slot" # 标准:每 token → 1 slot
NONE = "none" # Mamba/GDN:不用 slot_mapping
# vllm/v1/worker/gpu_model_runner.py:7122-7125
if kv_cache_spec_kind == KVCacheSpecKind.MAMBA:
slot_mapping_modes.append(SlotMappingMode.NONE) # 循环状态,不写 paged KV
else:
slot_mapping_modes.append(SlotMappingMode.TOKEN_TO_KV_SLOT)
源: vllm/v1/worker/gpu_model_runner.py:7113-7125
compute_slot_mapping 里直接 return(block_table.py:160-163),不浪费 kernel 计算update_block_table 路径而非 reshape_and_cache
# vllm/v1/worker/block_table.py:153-163
def compute_slot_mapping(self, num_reqs, query_start_loc, positions):
if self.slot_mapping_mode == SlotMappingMode.NONE:
# Mamba/GDN groups consume the block table as recurrent state
# indices and do not use per-token slot mappings.
return
这是两个层级的抽象,变化速率不同:
| 维度 | KV cache host (KVCacheManager) | HBM 索引 (BlockTable) |
|---|---|---|
| 粒度 | Block 级(管理 free-list、引用计数) | Token 级(每个 token 的物理 slot) |
| 内容 | 逻辑块 ID、ref_cnt、hash 用于 prefix caching | block_table.gpu + slot_mapping.gpu |
| 变化原因 | 调度策略(prefix cache、recompute、swap) | 设备内存布局(block_size、compress_ratio、CP/DP) |
| 使用者 | Scheduler | Attention kernel |
[3, 7, 12, 0, 0, ...] 供 kernel 按 row 索引slot_mapping;这是 worker 内部从 block_table + positions 实时算出来的(compute_slot_mapping)blocks_per_kv_block = 2,把每个 host block 拆成 2 个 kernel block。host 完全不知道这回事。# vllm/v1/worker/block_table.py:58-78
if kernel_block_size == block_size:
# Standard case: direct mapping
self.block_size = block_size
self.blocks_per_kv_block = 1
else:
# Hybrid case: memory blocks subdivided to match kernel
self.block_size = kernel_block_size
self.blocks_per_kv_block = block_size // kernel_block_size
源: vllm/v1/worker/block_table.py:58-78
因为 slot_mapping 依赖:
positions(每个 token 在序列中的绝对位置)—— Scheduler 传下来的query_start_loc(batch 内每个 req 的起止)—— worker 在 GPU 上算的block_size、compress_ratio、cp_kv_cache_interleave_size 等设备相关参数DeepSeek V4 是 vLLM 里索引路径最复杂的模型,因为它有三条独立的 KV 索引通道,各自有 block_table / slot_mapping:
DeepSeek V4 的 3 条 KV 索引通道
─────────────────────────────────
① Main MLA cache — 压缩 KV(compress_ratio=4 或 128),block_size=256
② Indexer cache — Lightning Indexer 的稀疏 top-k 选择器,独立 block_table
③ SWA cache — Sliding Window Attention,block_size=64
# vllm/models/deepseek_v4/attention.py:605-624
def get_kv_cache_spec(self, vllm_config) -> KVCacheSpec | None:
if self.compress_ratio <= 1: # SWA-only 层
return None # SWA 走③,不走这里
return MLAAttentionSpec(
block_size=..., # 256
num_kv_heads=1,
head_size=self.head_dim, # 512
compress_ratio=self.compress_ratio, # 4 或 128
alignment=576, # fp8_ds_mla 专用对齐
)
关键:storage_block_size = block_size // compress_ratio(kv_cache_interface.py:402-403):
compress_ratio=4 时:256-token 的 host block 只存 64 个 KV(每 4 token 压 1 个)compress_ratio=128 时:256-token 的 host block 只存 2 个 KV只有满足 (pos+1) % compress_ratio == 0 的 token 才有合法 slot:
# vllm/v1/attention/backends/mla/compressor_utils.py:9-50
@triton.jit
def _compressed_slot_mapping_kernel(...):
for i in range(0, query_len, TRITON_BLOCK_SIZE):
pos = start_pos + i + arange(0, TRITON_BLOCK_SIZE)
is_valid = (pos + 1) % COMPRESS_RATIO == 0
pos_after_compress = pos // COMPRESS_RATIO
block_ids = pos_after_compress // block_size
block_numbers = block_table[req_idx, block_ids] # 标准 block_table 查表
slot_ids = block_numbers * block_size + pos_after_compress % block_size
slot_ids = tl.where(is_valid, slot_ids, PAD_ID) # 非压缩位填 -1
源: vllm/v1/attention/backends/mla/compressor_utils.py:9-50
-1(不写 cache)。这和标准 FA 的"每 token 必有一个 slot"完全不同。
Indexer 是 DeepSeek V4 的稀疏注意力选择器:每个 query token 通过它挑出 top-k 个 KV token。
# vllm/models/deepseek_v4/attention.py:668-764
class DeepseekV4Indexer:
def __init__(self, ...):
# 独立的 KV cache(128 fp8 + 4 fp32 scale = 132B per token head)
self.k_cache = DeepseekV4IndexerCache(
head_dim=head_dim + head_dim//quant_block_size*4,
compress_ratio=compress_ratio,
)
# 独立的 compressor(把 hidden_states 投成 KV score)
self.compressor = DeepseekCompressor(...)
# indexer op 内部有自己的 block_table 查表逻辑
self.indexer_op = SparseAttnIndexer(
self.k_cache, ..., skip_k_cache_insert=True)
Indexer 的 slot_mapping 也是独立计算的:
# vllm/v1/attention/backends/mla/indexer.py:579-591
compressed_slot_mapping = slot_mapping
if self.compress_ratio > 1:
compressed_slot_mapping = get_compressed_slot_mapping(
num_tokens, query_start_loc, seq_lens, block_table,
self.kv_cache_spec.storage_block_size,
self.compress_ratio, out=self.compressed_slot_mapping_buffer)
源: vllm/v1/attention/backends/mla/indexer.py:579-591
block_table,但产出各自的 compressed_slot_mapping。
SWA 存最近 window_size(如 2048)个 token 的全量 KV,用于滑动窗口内精确注意力。
# vllm/v1/attention/backends/mla/sparse_swa.py:51-102
class DeepseekV4SWACache:
def __init__(self, ...):
# 强制 block_size = 64(因为 C4A 和 SWA 共享物理 tensor)
self.block_size = 64
def get_kv_cache_spec(self, vllm_config):
return SlidingWindowMLASpec(
block_size=64, # 与 main MLA 的 256 不同!
sliding_window=self.window_size,
)
SWA 的索引特殊之处:不只在 build 时算 slot_mapping,还要算 decode_swa_indices 和 prefill_swa_indices——每个 token 在窗口内的所有历史 slot 列表:
# vllm/v1/attention/backends/mla/sparse_swa.py:660-716
@triton.jit
def _compute_swa_indices_and_lens_kernel(...):
# 标准 block_table 查表,但"一对多":一个 query token → window_size 个 KV slot
for i in range(0, window_size, TRITON_BLOCK_SIZE):
pos_offset = start_pos + offset
block_indices = pos_offset // block_size
block_numbers = block_table[req_idx, block_indices]
block_offsets = pos_offset % block_size
slot_ids = block_numbers * block_size + block_offsets
源: vllm/v1/attention/backends/mla/sparse_swa.py:660-716
# vllm/v1/worker/gpu_model_runner.py:2294-2538
# 1. 通过 kv_cache_gid 索引到不同的 BlockTable 实例
blk_table = self.input_batch.block_table[kv_cache_gid]
# 2. 每个 gid 产出独立的 block_table_tensor + slot_mapping
for kv_cache_gid, kv_cache_group in enumerate(kv_cache_groups):
if kv_cache_gid > 0:
cm.block_table_tensor = _get_block_table(kv_cache_gid)
cm.slot_mapping = slot_mappings[kv_cache_gid]
# 3. 每个 attention backend 的 builder.build() 消费各自的 cm
最终一个 DeepSeek V4 forward 里:
DeepseekV4FlashMLAMetadataBuilder):用 compressed_slot_mappingDeepseekV32IndexerMetadataBuilder):用 indexer 自己的 compressed_slot_mappingDeepseekSparseSWAMetadataBuilder):用标准 slot_mapping + SWA indices需求:在 16K-128K 上下文下做全量注意力太贵
│
↓
观察:大部分 KV 对当前 query 无用(稀疏性)
│
├─→ 方案① 压缩(compress_ratio=4/128)—— 只存 1/4 或 1/128 的 KV
│ → 需要 compressed_slot_mapping(非压缩位不写)
│
├─→ 方案② Lightning Indexer —— 每个 query 先快速打分挑 top-k
│ → 需要独立 indexer cache + 独立 block_table
│
└─→ 方案③ Sliding Window —— 近端 token 全部保留(生成质量关键)
→ 需要独立 SWA cache + 独立 block_size=64
三条通道的 block_table 共享逻辑 block_ids(同一个 SchedulerOutput),
但各自产出独立的 slot_mapping(因为 block_size / compress_ratio 不同)
| 问题 | 答案一句话 |
|---|---|
| 为什么把 HBM 索引发到 worker | Scheduler 只产出逻辑 block_ids,worker 把它翻译成 GPU 物理地址(block_table.gpu + slot_mapping.gpu)供 kernel 直接消费;这是"策略(CPU) ↔ 执行(GPU)"的边界。 |
| 为什么 HBM 索引有两种模式且与 host 不同 | SlotMappingMode 区分标准 KV(TOKEN_TO_KV_SLOT)和递归状态(NONE);host 管 block 级分配,worker 管 token 级虚实映射,变化速率不同必须分层。 |
| DeepSeek V4 索引设计过程 | 三条独立通道(Main MLA / Indexer / SWA),共享逻辑 block_ids 但各自产出独立的 block_table 和 slot_mapping,分别处理压缩(sparse)、top-k选择(indexer)、滑动窗口(SWA)三种不同访问模式。 |
SchedulerOutput.block_ids 这条最小契约解耦。
| 文件 | 核心内容 |
|---|---|
vllm/v1/worker/block_table.py | BlockTable / MultiGroupBlockTable / SlotMappingMode / _compute_slot_mapping_kernel |
vllm/v1/worker/gpu_model_runner.py:1444,1950,2168 | Worker 侧 append_row / commit_block_table / compute_slot_mapping 调用点 |
vllm/v1/worker/gpu_model_runner.py:7113-7125 | SlotMappingMode 分发逻辑(Mamba→NONE, else→TOKEN_TO_KV_SLOT) |
vllm/v1/core/kv_cache_manager.py:283 | Scheduler 侧 allocate_slots 产出逻辑 block_ids |
vllm/v1/core/sched/output.py:39,126 | NewRequestData.block_ids / CachedRequestData.new_block_ids 契约 |
vllm/v1/attention/backend.py:402-428 | CommonAttentionMetadata(block_table_tensor + slot_mapping 跨层共享) |
vllm/v1/attention/backends/flash_attn.py:1071-1105 | FA 用 slot_mapping 做 reshape_and_cache_flash scatter write |
vllm/v1/attention/backends/mla/compressor_utils.py | DeepSeek V4 压缩 slot_mapping 计算(compress_ratio 过滤) |
vllm/v1/attention/backends/mla/indexer.py:579-591 | Indexer 独立 compressed_slot_mapping |
vllm/v1/attention/backends/mla/sparse_swa.py:660-716 | SWA 一对多 slot 映射(window_size 个历史 slot) |
vllm/models/deepseek_v4/attention.py:605-624 | DeepSeek V4 get_kv_cache_spec 产出 MLAAttentionSpec |
vllm/v1/kv_cache_interface.py:389-403 | MLAAttentionSpec / storage_block_size = block_size // compress_ratio |