Prhub

#43468 [feature][kv_offload] Self-describing KV events for OffloadingConnector

原始 PR 作者 Change72 合并时间 2026-06-22 15:27 文件变更 9 提交数 8 评论 42 代码增减 +720 / -62

执行摘要

为 OffloadingConnector 添加自描述 KV 事件

原有的 OffloadingConnector 事件携带占位符负载(token_ids=[], block_size=0, parent_block_hash=None),导致外部消费者(如 Dynamo KVBM)无法利用 CPU 层事件进行 KV 感知路由。PR body 明确指出:“The legacy connector events were useful for observability but not sufficient for external routers or lower-tier indexers… consumers could not reconstruct their own block keys or parent chain and had to drop the CPU-tier events。” 作者也在评论中强调:“This PR is driven by a concrete integration requirement from the Dynamo KVBM team, which consumes vLLM's KVEvents to maintain a router-side prefix index across both GPU and CPU tiers。”

值得精读。本 PR 展示了如何在遗留系统上扩展事件模型以满足外部路由需求,设计上强调兼容性(opt-in、向后兼容)、模块化(独立 events.py)和边界防御(tiering fail-fast)。特别关注 OffloadingEventsTracker 的数据流设计:在 store job 构建阶段捕获元数据,在 take_events 阶段消耗,避免了在 worker 进程中保留请求上下文。

讨论亮点

核心审核讨论集中在模块解耦与设计权衡:

  • orozery 要求将事件逻辑隔离到独立文件:建议创建 events.py 避免 scheduler.py 膨胀,并采用 opt-in 方式。作者采纳,将 OffloadingEventsTracker 及其辅助类型移至新文件。
  • 分离 enable_kv_cache_events 与 self_describing_kv_events:orozery 指出应保留两个独立配置,避免隐式依赖。作者引入 OffloadingKVEventsConfig 分离两个开关,derived flag 仅在两者皆 true 时启用自描述捕获。
  • None 返回处理:orozery 质疑 _build_event_metadata 中多个 None 返回的实际可行性。作者分析与验证后确认所有路径均为不变条件,改为 assert,并删除对应的 bad-path 测试。
  • 多 tier 拒绝:orozery 指出当前设计不适用于多 tier 场景(如存储回读)。作者添加 TieringOffloadingSpec 中的 fail-fast 拒绝逻辑,并明确此 PR 仅作用于单层 CPU offload。
  • 测试覆盖要求:orozery 要求补充 store→evict→re-store 循环测试、chunk 模式父链测试等。作者全部添加,并移除了旧 scheduler 测试中的占位符断言。

实现拆解

  1. 新增事件跟踪核心:在 vllm/distributed/kv_transfer/kv_connector/v1/offloading/events.py 中创建 OffloadingEventsTracker 类,负责在 store 发生时快照 token_ids、block_hashes、parent_hash 等元数据,并在 take_events 时将其组装为自描述的 BlockStored/BlockRemoved 事件。同时定义了 OffloadingEventGroupSpecget_offloading_event_group_spec 用于从 KVCacheGroupSpec 提取事件所需规格。
  2. 配置层解耦:在 vllm/v1/kv_offload/base.py 中添加 OffloadingKVEventsConfig 数据类,将全局 KV 事件启用标志与自描述 opt-in 分离。OffloadingSpec.__init__vllm_config.kv_events_configkv_connector_extra_config 构建此配置,并挂载到 spec 上,供 scheduler 使用。
  3. 调度器集成:在 scheduler.py 中,GroupOffloadConfig 新增 kv_event_group_spec 字段携带事件元数据。_build_store_jobs 中调用 tracker.record_store() 捕获负载;take_events 改为通过 tracker.take_events() 转换事件流,并移除对 BlockRemoved/BlockStored 的直接 import。同时将 OffloadingEventsTracker 实例化为 self._events_tracker
  4. 多 tier 互斥与测试:在 tiering/spec.py__init__ 中,若检测到 self_describing_kv_events=True 直接抛出 ValueError,因为多 tier 环境下自描述元数据不可用。cpu/spec.py 移除重复的 enable_events 推导,统一使用 spec 上的 kv_events_config
  5. 测试与文档:新增 tests/v1/kv_connector/unit/offloading_connector/test_events.py 覆盖 9+ 个测试用例,包括 by-block 发射、chunk 模式父链、多组打包、store→evict→re-store 循环等。从 test_scheduler.py 中移除旧的占位符测试代码。更新 docs/features/kv_offloading_usage.md 记录 self_describing_kv_events 配置项及注意事项。
文件 模块 状态 重要度
vllm/distributed/kv_transfer/kv_connector/v1/offloading/events.py 事件跟踪 added 9.08
tests/v1/kv_connector/unit/offloading_connector/test_events.py 测试 added 8.05
vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py 调度器 modified 7.12
vllm/v1/kv_offload/base.py 基础层 modified 6.35
vllm/v1/kv_offload/tiering/spec.py 分层存储 modified 6.25
tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py 测试 modified 5.85
tests/v1/kv_connector/unit/offloading_connector/utils.py 测试工具 modified 4.61
docs/features/kv_offloading_usage.md 文档 modified 1.58

关键符号

OffloadingEventsTracker.__init__ OffloadingEventsTracker.record_store OffloadingEventsTracker.take_events OffloadingEventsTracker.reset OffloadingEventsTracker._build_event_metadata OffloadingEventsTracker._stored_to_events OffloadingEventsTracker._removed_to_events get_offloading_event_group_spec OffloadingKVEventsConfig OffloadingConnectorScheduler.take_events OffloadingConnectorScheduler._build_store_jobs

关键源码片段

vllm/distributed/kv_transfer/kv_connector/v1/offloading/events.py core-logic

核心新增文件,实现 OffloadingEventsTracker 完成自描述事件负载的快照、存储和发射,是整个功能的数据平面。

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Self-describing KV cache events for the offloading connector."""from collections.abc import Iterable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, NamedTuplefrom vllm.distributed.kv_events import BlockRemoved, BlockStored, KVCacheEvent
from vllm.v1.core.kv_cache_utils import BlockHash, maybe_convert_block_hash
from vllm.v1.kv_cache_interface import KVCacheGroupSpec, get_kv_cache_spec_kind, get_kv_cache_spec_sliding_window
from vllm.v1.kv_offload.base import OffloadingEvent, OffloadingKVEventsConfig, OffloadKey, get_offload_block_hash, get_offload_group_idx
from vllm.v1.request import Requestif TYPE_CHECKING:
    from vllm.distributed.kv_transfer.kv_connector.v1.offloading.scheduler import GroupOffloadConfiglogger = init_logger(__name__)
​
​
class OffloadingEventGroupSpec(NamedTuple):
    """KV cache spec metadata carried on BlockStored for event consumers"""
    kv_cache_spec_kind: str | None
    kv_cache_spec_sliding_window: int | None
​
​
def get_offloading_event_group_spec(kv_cache_group: KVCacheGroupSpec) -> OffloadingEventGroupSpec:
    kv_cache_spec = kv_cache_group.kv_cache_spec
    return OffloadingEventGroupSpec(
        kv_cache_spec_kind=get_kv_cache_spec_kind(kv_cache_spec).value,
        kv_cache_spec_sliding_window=get_kv_cache_spec_sliding_window(kv_cache_spec),
    )
​
​
@dataclass(slots=True)
class _OffloadEventMetadata:
    """Snapshot of BlockStored payload for one OffloadKey, kept until eviction"""
    block_hashes: tuple[BlockHash, ...]
    parent_block_hash: BlockHash | None
    token_ids: tuple[int, ...]
    block_size: int
    lora_id: int | None
    lora_name: str | None
    extra_keys: tuple[tuple[Any, ...] | None, ...] | None # deferred for multi-modal
    group_idx: int
    kv_cache_spec: OffloadingEventGroupSpec
​
​
class OffloadingEventsTracker:
    """Tracks offloaded chunks' KV event payloads from store to eviction"""
​
    def __init__(self, config: OffloadingKVEventsConfig):
        self.config = config
        # Self-describing only when both KV events and opt-in are enabled
        self.self_describing_enabled = (
            config.enable_kv_cache_events and config.self_describing_kv_events
        )
        # Bounded dict: OffloadKey -> metadata, cleared on eviction
        self._pending_event_metadata: dict[OffloadKey, _OffloadEventMetadata] = {}
​
    def record_store(self, req: Request, group_config: "GroupOffloadConfig",
                     offload_block_idx: int, offload_key: OffloadKey) -> None:
        """Capture metadata snapshot for an offloaded chunk.
        No-op if self-describing is disabled or group is sliding-window/SSM."""
        if not self.self_describing_enabled:
            return
        if group_config.sliding_window_size_in_blocks is not None:
            return # sliding-window/SSM groups keep legacy placeholder
        meta = self._build_event_metadata(req, group_config, offload_block_idx)
        self._pending_event_metadata[offload_key] = meta
​
    def take_events(self, events: Iterable[OffloadingEvent]) -> Iterable[KVCacheEvent]:
        """Translate raw OffloadingEvents into self-describing KV events."""
        for event in events:
            if event.removed:
                yield from self._removed_to_events(event)
            else:
                yield from self._stored_to_events(event)
​
    def reset(self) -> None:
        """Clear all pending metadata. Called on engine reset."""
        self._pending_event_metadata.clear()
​
    # ... (internal helpers _build_event_metadata, _stored_to_events, _removed_to_events 省略 )
tests/v1/kv_connector/unit/offloading_connector/test_events.py test-coverage

新增 355 行单元测试,全面覆盖 OffloadingEventsTracker 的各类场景,是保障正确性的关键。

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from unittest.mock import MagicMock
import pytest
import torch
from vllm.distributed.kv_events import BlockRemoved, BlockStored
from vllm.distributed.kv_transfer.kv_connector.v1.offloading.events import OffloadingEventGroupSpec, OffloadingEventsTracker
from vllm.distributed.kv_transfer.kv_connector.v1.offloading.scheduler import GroupOffloadConfig
from vllm.v1.kv_offload.base import OffloadingEvent, OffloadingKVEventsConfig, OffloadKey, make_offload_key
from vllm.v1.kv_cache_interface import KVCacheSpecKind_CPU_MEDIUM = "cpu" # simplified medium string for testing
_FULL_ATTENTION_EVENT_SPEC = OffloadingEventGroupSpec(
    kv_cache_spec_kind=KVCacheSpecKind.FULL_ATTENTION.value,
    kv_cache_spec_sliding_window=None,
)
​
​
def _tracker(*, enable_kv_cache_events=True, self_describing_kv_events=True):
    return OffloadingEventsTracker(OffloadingKVEventsConfig(
        enable_kv_cache_events=enable_kv_cache_events,
        self_describing_kv_events=self_describing_kv_events,
    ))
​
​
def test_take_events_publishes_routable_block_stored():
    """Basic by-block emission with parent chaining and metadata"""
    block_size = 4
    tracker = _tracker()
    group_config = _group_config(block_size=block_size)
    req = _request(block_hashes=[_hash(i) for i in range(6)], token_count=24)
    keys = _record_chunks(tracker, req, group_config, num_chunks=6)
​
    batch1 = list(tracker.take_events([_stored_event(keys[:3])]))
    assert len(batch1) == 3
    for i, event in enumerate(batch1):
        assert isinstance(event, BlockStored)
        assert event.block_hashes == [_wire_hash(_hash(i))]
        assert event.block_size == block_size
        assert event.token_ids == list(range(i * block_size + 1, (i + 1) * block_size + 1))
        if i == 0:
            assert event.parent_block_hash is None
        else:
            assert event.parent_block_hash == _wire_hash(_hash(i - 1))
        assert event.group_idx == 0
        assert event.kv_cache_spec_kind == KVCacheSpecKind.FULL_ATTENTION.value
​
    # Cross-batch parent chaining
    batch2 = list(tracker.take_events([_stored_event(keys[3:])]))
    assert batch2[0].parent_block_hash == batch1[-1].block_hashes[0]
​
​
def test_take_events_falls_back_to_placeholder_when_opt_out():
    """When self-describing is disabled, legacy placeholder is used"""
    tracker = _tracker(self_describing_kv_events=False)
    group_config = _group_config(block_size=4)
    req = _request(block_hashes=[_hash(0)], token_count=4)
    keys = _record_chunks(tracker, req, group_config, num_chunks=1)
    events = list(tracker.take_events([_stored_event(keys)]))
    assert len(events) == 1
    assert events[0].token_ids == []
    assert events[0].block_size == 0
    assert events[0].parent_block_hash is None
vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py dependency-wiring

集成点,引入 OffloadingEventsTracker、扩展 GroupOffloadConfig、改动 take_events 和 _build_store_jobs,是功能生效的桥梁。

# 关键变更片段:GroupOffloadConfig 新增 kv_event_group_spec
class GroupOffloadConfig(NamedTuple):
    group_idx: int
    gpu_block_size: int
    offloaded_block_size: int
    hash_block_size_factor: int
    kv_event_group_spec: OffloadingEventGroupSpec # 新增:事件元数据
    sliding_window_size_in_blocks: int | None
    alignment_block_count: int | None = None
    is_eagle_group: bool = False# 在 OffloadingConnectorScheduler.__init__ 中初始化 tracker
self._events_tracker = OffloadingEventsTracker(spec.kv_events_config)# 在 _build_store_jobs 中记录存储事件
self._events_tracker.record_store(req, group_config, offloaded_block_idx, offload_key)# take_events 改为委托给 tracker
def take_events(self) -> Iterable[KVCacheEvent]:
    return self._events_tracker.take_events(self.manager.take_events())

评论区精华

提取事件逻辑到独立文件 events.py 设计

orozery 要求将 OffloadingEventsTracker 及元数据逻辑从 scheduler.py 移出,保持调度器代码简洁。

结论:作者创建了 events.py,所有事件跟踪逻辑移至新类,scheduler.py 仅做导入和实例化。 · 已解决

分离 KV 事件启用与自描述 opt-in 配置 设计

orozery 指出应将 enable_kv_cache_events 和 self_describing_kv_events 作为两个独立参数,而不是隐式合并。

结论:引入 OffloadingKVEventsConfig 包含两个字段,OffloadingEventsTracker 内部通过两者同时为 true 决定是否捕获自描述负载。 · 已解决

_build_event_metadata 的 None 返回处理 正确性

orozery 质疑多个 None 返回的实际可达性,建议改为 assert 或补充说明。作者分析后确认所有路径均为 invariants,改为 assert 并删除 bad-path 测试。

结论:将 _build_event_metadata 中的 None 返回替换为 assert,仅保留外部不可达的防御。 · 已解决

多 tier 对自描述事件的支持 设计

orozery 指出自描述事件不适用于多 tier 场景(如存储→CPU 回读),需要 fail-fast。作者添加 TieringOffloadingSpec 中的拒绝逻辑。

结论:在 TieringOffloadingSpec.__init__ 中检查 self_describing_kv_events 并抛出 ValueError,明确不支持。 · 已解决

补充 store→evict→re-store 循环测试 测试

orozery 指出测试未覆盖相同 offload key 重新记录(eviction 后重新 store)的场景。

结论:作者添加 test_take_events_supports_restore_after_eviction 测试用例,验证完整循环。 · 已解决

风险与影响

  • Tiering 互斥:若用户同时启用 TieringOffloadingSpecself_describing_kv_events,将直接抛出 ValueError。虽然避免静默错误,但可能中断已有配置,需在文档中提前告知。
  • 内存开销:自描述启用后,_pending_event_metadata 存储每个 offloaded chunk 的元数据(包括 token_ids 列表等),虽受 CPU pool 容量限制上限,但在高并发下可能增加内存压力。
  • 重复 Announcement:chunk 模式下,若共享 prefix 不对齐到 chunk 边界,同一 hash 可能被多次 announce,下游消费者必须去重。PR body 明确描述了此语义,但用户需自行实现去重逻辑。
  • extra_keys 路径未验证:多模态、cache-salt、prompt-embedding 等使用 extra_keys 的重型路径尚未在自描述事件中充分测试,可能引入未预期行为。
  • 向后兼容性:默认关闭,不会影响现有用户。但若用户显式启用,且依赖旧的占位符行为(虽然 unlikely),则可能产生变化。
  • 对用户:默认行为无变化。启用了 self_describing_kv_events 的用户(如 Dynamo 集成)可获得完整 CPU offload 事件,便于构建拓扑感知路由。需注意 tiering spec 不兼容。
  • 对系统:增加约 720 行代码,但核心逻辑集中在 events.py,不影响主路径性能(opt-out)。take_events 内部增加一次 side-table 查找,开销可忽略。
  • 对团队:明确了 KV offload 事件的数据流向,为后续多 tier 增强铺平道路(需要设计持久化元数据方案)。测试覆盖完善,降低了回归风险。
核心路径变更 多 tier 限制 上游消费者需去重 默认未启用 extra_keys 路径未验证

关联 Issue

#44865 [KV Offload] Reshape the transfer data model: per group specs and offloaded side alignment offset
#45693 [kv_offload] Use Hugepages for kv_offload tiering setup

完整报告

参与讨论