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

Signed-off-by: Change72 <changg@nvidia.com> Co-authored-by: Claude <noreply@anthropic.com>

Change72 committed Jun 22, 2026 at 00:27 UTC a9f7b2d41c5e92f0d60ebdd3dbe04e627019c179
9 files changed +720 -62
docs/features/kv_offloading_usage.md
+1
@@ -74,6 +74,7 @@ vllm serve <model> \
74 | `max_tracker_size` | no | `64000` | single-tier | Max entries in the lookup tracker. |
75 | `secondary_tiers` | no | `[]` | multi-tier | List of secondary tier configs (see below). |
76 | `offload_prompt_only` | no | `true` | both | If `true`, only prompt (prefill) blocks are offloaded; decode blocks are skipped. |
77 +| `self_describing_kv_events` | no | `false` | single-tier | Opt-in. When `true` *and* KV cache events are enabled (`--kv-events-config` with `enable_kv_cache_events`), the connector emits self-describing block-granular `BlockStored`/`BlockRemoved` payloads (constituent block hashes, whole-chunk `token_ids`, per-block `block_size`, parent hash, LoRA + group/cache-spec metadata) instead of the placeholder fallback, so external KV-event consumers can index offloaded blocks. Inert unless events are enabled. Currently rejected by `TieringOffloadingSpec`. Full-attention groups only; sliding-window/SSM groups keep the placeholder fallback. In chunk mode (`block_size` > GPU block size), overlapping chunks re-announce shared per-block hashes, so consumers must reference-count (deduplicate) repeated store/remove announcements. |
78 | `spec_module_path` | no | — | both | Python import path for a custom `OffloadingSpec` not in the built-in registry. Required only when `spec_name` is not built-in (advanced). |
79
80 ## Secondary Tiers
tests/v1/kv_connector/unit/offloading_connector/test_events.py new
+355
@@ -0,0 +1,355 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +from unittest.mock import MagicMock
4 +
5 +import pytest
6 +import torch
7 +
8 +from tests.v1.kv_connector.unit.utils import create_vllm_config
9 +from vllm.config import KVEventsConfig, KVTransferConfig
10 +from vllm.distributed.kv_events import BlockRemoved, BlockStored
11 +from vllm.distributed.kv_transfer.kv_connector.v1.offloading.events import (
12 + OffloadingEventGroupSpec,
13 + OffloadingEventsTracker,
14 +)
15 +from vllm.distributed.kv_transfer.kv_connector.v1.offloading.scheduler import (
16 + GroupOffloadConfig,
17 +)
18 +from vllm.v1.core.kv_cache_utils import BlockHash, maybe_convert_block_hash
19 +from vllm.v1.kv_cache_interface import (
20 + FullAttentionSpec,
21 + KVCacheConfig,
22 + KVCacheGroupSpec,
23 + KVCacheSpecKind,
24 +)
25 +from vllm.v1.kv_offload.base import (
26 + OffloadingEvent,
27 + OffloadingKVEventsConfig,
28 + OffloadKey,
29 + make_offload_key,
30 +)
31 +from vllm.v1.kv_offload.cpu.common import CPULoadStoreSpec
32 +from vllm.v1.kv_offload.tiering.spec import TieringOffloadingSpec
33 +
34 +_CPU_MEDIUM = CPULoadStoreSpec.medium()
35 +_FULL_ATTENTION_EVENT_SPEC = OffloadingEventGroupSpec(
36 + kv_cache_spec_kind=KVCacheSpecKind.FULL_ATTENTION.value,
37 + kv_cache_spec_sliding_window=None,
38 +)
39 +
40 +
41 +def _tracker(
42 + *,
43 + enable_kv_cache_events: bool = True,
44 + self_describing_kv_events: bool = True,
45 +) -> OffloadingEventsTracker:
46 + return OffloadingEventsTracker(
47 + OffloadingKVEventsConfig(
48 + enable_kv_cache_events=enable_kv_cache_events,
49 + self_describing_kv_events=self_describing_kv_events,
50 + )
51 + )
52 +
53 +
54 +def _hash(i: int) -> BlockHash:
55 + return BlockHash(str(i).encode())
56 +
57 +
58 +def _wire_hash(block_hash: BlockHash):
59 + return maybe_convert_block_hash(block_hash)
60 +
61 +
62 +def _request(*, block_hashes: list[BlockHash], token_count: int):
63 + req = MagicMock()
64 + req.block_hashes = block_hashes
65 + req.all_token_ids = list(range(1, token_count + 1))
66 + req.lora_request = None
67 + return req
68 +
69 +
70 +def _group_config(
71 + *,
72 + group_idx: int = 0,
73 + block_size: int = 4,
74 + block_size_factor: int = 1,
75 + sliding_window_size_in_blocks: int | None = None,
76 +) -> GroupOffloadConfig:
77 + return GroupOffloadConfig(
78 + group_idx=group_idx,
79 + gpu_block_size=block_size,
80 + offloaded_block_size=block_size * block_size_factor,
81 + hash_block_size_factor=block_size_factor,
82 + sliding_window_size_in_blocks=sliding_window_size_in_blocks,
83 + kv_event_group_spec=_FULL_ATTENTION_EVENT_SPEC,
84 + )
85 +
86 +
87 +def _record_chunks(
88 + tracker: OffloadingEventsTracker,
89 + req,
90 + group_config: GroupOffloadConfig,
91 + num_chunks: int,
92 +) -> list[OffloadKey]:
93 + keys: list[OffloadKey] = []
94 + hbf = group_config.hash_block_size_factor
95 + for chunk_idx in range(num_chunks):
96 + tail_hash = req.block_hashes[(chunk_idx + 1) * hbf - 1]
97 + assert tail_hash is not None
98 + key = make_offload_key(tail_hash, group_config.group_idx)
99 + tracker.record_store(req, group_config, chunk_idx, key)
100 + keys.append(key)
101 + return keys
102 +
103 +
104 +def _stored_event(keys: list[OffloadKey]) -> OffloadingEvent:
105 + return OffloadingEvent(keys=keys, medium=_CPU_MEDIUM, removed=False)
106 +
107 +
108 +def _removed_event(keys: list[OffloadKey]) -> OffloadingEvent:
109 + return OffloadingEvent(keys=keys, medium=_CPU_MEDIUM, removed=True)
110 +
111 +
112 +def test_take_events_publishes_routable_block_stored():
113 + block_size = 4
114 + tracker = _tracker()
115 + group_config = _group_config(block_size=block_size)
116 + req = _request(
117 + block_hashes=[_hash(i) for i in range(6)],
118 + token_count=block_size * 6,
119 + )
120 + keys = _record_chunks(tracker, req, group_config, num_chunks=6)
121 +
122 + batch1 = list(tracker.take_events([_stored_event(keys[:3])]))
123 + assert len(batch1) == 3
124 +
125 + for i, event in enumerate(batch1):
126 + assert isinstance(event, BlockStored)
127 + assert event.medium == _CPU_MEDIUM
128 + assert event.block_hashes == [_wire_hash(_hash(i))]
129 + assert event.block_size == block_size
130 + assert event.token_ids == list(
131 + range(i * block_size + 1, (i + 1) * block_size + 1)
132 + )
133 + if i == 0:
134 + assert event.parent_block_hash is None
135 + else:
136 + assert event.parent_block_hash == _wire_hash(_hash(i - 1))
137 + assert event.lora_id is None
138 + assert event.lora_name is None
139 + assert event.extra_keys is None
140 + assert event.group_idx == 0
141 + assert event.kv_cache_spec_kind == KVCacheSpecKind.FULL_ATTENTION.value
142 + assert event.kv_cache_spec_sliding_window is None
143 +
144 + batch2 = list(tracker.take_events([_stored_event(keys[3:])]))
145 + assert len(batch2) == 3
146 + assert batch2[0].parent_block_hash == batch1[-1].block_hashes[-1]
147 +
148 + assert len(tracker._pending_event_metadata) == 6
149 +
150 +
151 +def test_take_events_factor_gt_1_chunk_store_and_remove():
152 + block_size = 4
153 + block_size_factor = 3
154 + tracker = _tracker()
155 + group_config = _group_config(
156 + block_size=block_size, block_size_factor=block_size_factor
157 + )
158 + req = _request(
159 + block_hashes=[_hash(i) for i in range(6)],
160 + token_count=block_size * block_size_factor * 2,
161 + )
162 + keys = _record_chunks(tracker, req, group_config, num_chunks=2)
163 +
164 + stored = list(tracker.take_events([_stored_event(keys)]))
165 + assert len(stored) == 2
166 +
167 + expected_hashes = []
168 + for chunk_idx, event in enumerate(stored):
169 + assert isinstance(event, BlockStored)
170 + expected_chunk_hashes = [
171 + _wire_hash(_hash(i))
172 + for i in range(
173 + chunk_idx * block_size_factor,
174 + (chunk_idx + 1) * block_size_factor,
175 + )
176 + ]
177 + assert event.block_hashes == expected_chunk_hashes
178 + assert event.block_size == block_size
179 + assert len(event.token_ids) == block_size * block_size_factor
180 + if chunk_idx == 0:
181 + assert event.parent_block_hash is None
182 + else:
183 + assert event.parent_block_hash == _wire_hash(_hash(block_size_factor - 1))
184 + expected_hashes.extend(expected_chunk_hashes)
185 +
186 + assert len(tracker._pending_event_metadata) == 2
187 +
188 + removed = list(tracker.take_events([_removed_event(keys)]))
189 + assert len(removed) == 1
190 + assert isinstance(removed[0], BlockRemoved)
191 + assert removed[0].block_hashes == expected_hashes
192 + assert removed[0].medium == _CPU_MEDIUM
193 + assert removed[0].group_idx == 0
194 + assert not tracker._pending_event_metadata
195 +
196 +
197 +def test_take_events_factor_gt_1_store_is_order_independent():
198 + block_size_factor = 3
199 + tracker = _tracker()
200 + group_config = _group_config(block_size_factor=block_size_factor)
201 + req = _request(
202 + block_hashes=[_hash(i) for i in range(6)],
203 + token_count=4 * block_size_factor * 2,
204 + )
205 + keys = _record_chunks(tracker, req, group_config, num_chunks=2)
206 + unknown_key = make_offload_key(_hash(12345), 0)
207 +
208 + events = list(tracker.take_events([_stored_event([keys[1], unknown_key, keys[0]])]))
209 +
210 + assert len(events) == 3
211 + chunk1, placeholder, chunk0 = events
212 + assert [len(event.block_hashes) for event in events] == [3, 1, 3]
213 + assert placeholder.block_size == 0
214 + assert placeholder.token_ids == []
215 + assert chunk0.parent_block_hash is None
216 + assert chunk1.parent_block_hash == chunk0.block_hashes[-1]
217 +
218 +
219 +def test_take_events_opt_out_keeps_placeholders():
220 + tracker = _tracker(self_describing_kv_events=False)
221 + group_config = _group_config()
222 + req = _request(block_hashes=[_hash(i) for i in range(3)], token_count=12)
223 + keys = _record_chunks(tracker, req, group_config, num_chunks=3)
224 +
225 + assert not tracker.self_describing_enabled
226 + assert not tracker._pending_event_metadata
227 +
228 + events = list(
229 + tracker.take_events(
230 + [
231 + _stored_event(keys),
232 + _removed_event(keys),
233 + ]
234 + )
235 + )
236 + assert len(events) == 4
237 + for event in events[:3]:
238 + assert isinstance(event, BlockStored)
239 + assert event.block_size == 0
240 + assert event.token_ids == []
241 + assert event.parent_block_hash is None
242 + assert isinstance(events[3], BlockRemoved)
243 + assert len(events[3].block_hashes) == 3
244 +
245 +
246 +def test_record_store_skips_sliding_window_group():
247 + tracker = _tracker()
248 + group_config = _group_config(sliding_window_size_in_blocks=2)
249 + req = _request(block_hashes=[_hash(i) for i in range(3)], token_count=12)
250 + keys = _record_chunks(tracker, req, group_config, num_chunks=3)
251 +
252 + assert not tracker._pending_event_metadata
253 +
254 + events = list(tracker.take_events([_stored_event(keys[:1])]))
255 + assert len(events) == 1
256 + assert isinstance(events[0], BlockStored)
257 + assert events[0].block_size == 0
258 +
259 +
260 +def test_take_events_groups_removed_hashes_by_kv_group():
261 + tracker = _tracker()
262 + group0_config = _group_config(group_idx=0, block_size_factor=2)
263 + group1_config = _group_config(group_idx=1, block_size_factor=2)
264 + req0 = _request(block_hashes=[_hash(0), _hash(1)], token_count=8)
265 + req1 = _request(block_hashes=[_hash(10), _hash(11)], token_count=8)
266 + key0 = _record_chunks(tracker, req0, group0_config, num_chunks=1)[0]
267 + key1 = _record_chunks(tracker, req1, group1_config, num_chunks=1)[0]
268 +
269 + removed = list(tracker.take_events([_removed_event([key0, key1])]))
270 +
271 + assert len(removed) == 2
272 + by_group = {event.group_idx: event.block_hashes for event in removed}
273 + assert by_group == {
274 + 0: [_wire_hash(_hash(0)), _wire_hash(_hash(1))],
275 + 1: [_wire_hash(_hash(10)), _wire_hash(_hash(11))],
276 + }
277 +
278 +
279 +def test_take_events_supports_restore_after_eviction():
280 + block_size = 4
281 + tracker = _tracker()
282 + group_config = _group_config(block_size=block_size)
283 + req = _request(block_hashes=[_hash(0)], token_count=block_size)
284 + key = _record_chunks(tracker, req, group_config, num_chunks=1)[0]
285 +
286 + first_store = list(tracker.take_events([_stored_event([key])]))
287 + assert len(first_store) == 1
288 + assert isinstance(first_store[0], BlockStored)
289 + assert first_store[0].token_ids == [1, 2, 3, 4]
290 +
291 + removed = list(tracker.take_events([_removed_event([key])]))
292 + assert len(removed) == 1
293 + assert isinstance(removed[0], BlockRemoved)
294 + assert not tracker._pending_event_metadata
295 +
296 + req.all_token_ids = [5, 6, 7, 8]
297 + tracker.record_store(req, group_config, offload_block_idx=0, offload_key=key)
298 +
299 + second_store = list(tracker.take_events([_stored_event([key])]))
300 + assert len(second_store) == 1
301 + assert isinstance(second_store[0], BlockStored)
302 + assert second_store[0].token_ids == [5, 6, 7, 8]
303 +
304 +
305 +def test_reset_cache_clears_side_table():
306 + tracker = _tracker()
307 + group_config = _group_config()
308 + req = _request(block_hashes=[_hash(i) for i in range(3)], token_count=12)
309 + _record_chunks(tracker, req, group_config, num_chunks=3)
310 +
311 + assert tracker._pending_event_metadata
312 +
313 + tracker.reset()
314 +
315 + assert not tracker._pending_event_metadata
316 +
317 +
318 +def test_tiering_rejects_self_describing_kv_events():
319 + vllm_config = create_vllm_config(
320 + block_size=4,
321 + max_num_batched_tokens=16,
322 + disable_hybrid_kv_cache_manager=False,
323 + )
324 + vllm_config.kv_transfer_config = KVTransferConfig(
325 + kv_connector="OffloadingConnector",
326 + kv_role="kv_both",
327 + kv_connector_extra_config={
328 + "spec_name": "TieringOffloadingSpec",
329 + "cpu_bytes_to_use": 1 << 20,
330 + "self_describing_kv_events": True,
331 + "secondary_tiers": [{"type": "example"}],
332 + },
333 + )
334 + vllm_config.kv_events_config = KVEventsConfig(
335 + enable_kv_cache_events=True,
336 + publisher="null",
337 + )
338 + kv_cache_config = KVCacheConfig(
339 + num_blocks=0,
340 + kv_cache_tensors=[],
341 + kv_cache_groups=[
342 + KVCacheGroupSpec(
343 + ["layer"],
344 + FullAttentionSpec(
345 + block_size=4,
346 + num_kv_heads=1,
347 + head_size=1,
348 + dtype=torch.float32,
349 + ),
350 + )
351 + ],
352 + )
353 +
354 + with pytest.raises(ValueError, match="TieringOffloadingSpec"):
355 + TieringOffloadingSpec(vllm_config, kv_cache_config)
tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py
-29
@@ -1,6 +1,5 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -from collections.abc import Iterable
3 from types import SimpleNamespace
4 from unittest.mock import MagicMock
5
@@ -12,19 +11,16 @@ from tests.v1.kv_connector.unit.offloading_connector.utils import (
11 to_keys,
12 )
13 from tests.v1.kv_connector.unit.utils import EOS_TOKEN_ID
15 -from vllm.distributed.kv_events import BlockRemoved, BlockStored
14 from vllm.distributed.kv_transfer.kv_connector.v1.offloading.scheduler import (
15 OffloadingConnectorScheduler,
16 RequestOffloadState,
17 )
20 -from vllm.v1.core.kv_cache_utils import BlockHash
18 from vllm.v1.kv_cache_interface import (
19 FullAttentionSpec,
20 KVCacheGroupSpec,
21 SlidingWindowSpec,
22 )
23 from vllm.v1.kv_offload.base import (
27 - OffloadingEvent,
24 OffloadingManager,
25 OffloadPolicy,
26 ReqContext,
@@ -146,31 +142,6 @@ def test_offloading_connector(request_runner, async_scheduling: bool):
142 runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 1
143 runner.run(decoded_tokens=[EOS_TOKEN_ID], expected_loaded=(3, 4, 5))
144
149 - # test take_events
150 - def to_hashes(int_hashes: list[int]) -> list[BlockHash]:
151 - return [BlockHash(str(i).encode()) for i in int_hashes]
152 -
153 - def take_events() -> Iterable[OffloadingEvent]:
154 - yield OffloadingEvent(keys=to_keys([1, 2, 3]), medium="A", removed=False)
155 - yield OffloadingEvent(keys=to_keys([4, 5, 6]), medium="B", removed=True)
156 -
157 - runner.manager.take_events.side_effect = take_events
158 - events = list(runner.scheduler_connector.take_events())
159 - assert len(events) == 2
160 - event = events[0]
161 - assert isinstance(event, BlockStored)
162 - assert event.block_hashes == to_hashes([1, 2, 3])
163 - assert event.block_size == 0
164 - assert event.medium == "A"
165 - assert event.token_ids == []
166 - assert event.parent_block_hash is None
167 - assert event.lora_id is None
168 - assert event.lora_name is None
169 - event = events[1]
170 - assert isinstance(event, BlockRemoved)
171 - assert event.block_hashes == to_hashes([4, 5, 6])
172 - assert event.medium == "B"
173 -
145
146 @pytest.mark.parametrize("async_scheduling", [True, False])
147 def test_request_preemption(request_runner, async_scheduling: bool):
tests/v1/kv_connector/unit/offloading_connector/utils.py
+16 -1
@@ -14,7 +14,12 @@ from tests.v1.kv_connector.unit.utils import (
14 create_vllm_config,
15 )
16 from vllm import SamplingParams
17 -from vllm.config import KVTransferConfig, VllmConfig, set_current_vllm_config
17 +from vllm.config import (
18 + KVEventsConfig,
19 + KVTransferConfig,
20 + VllmConfig,
21 + set_current_vllm_config,
22 +)
23 from vllm.distributed.kv_transfer.kv_connector.v1 import KVConnectorRole
24 from vllm.distributed.kv_transfer.kv_connector.v1.offloading.common import (
25 OffloadingConnectorMetadata,
@@ -198,6 +203,9 @@ class RequestRunner:
203 "spec_module_path": "tests.v1.kv_connector.unit.offloading_connector.utils", # noqa: E501
204 # Preserve legacy behavior for tests; new opt-in tests override.
205 "offload_prompt_only": False,
206 + # Exercise the self-describing KV events path by default;
207 + # opt-out tests override this to cover the legacy placeholders.
208 + "self_describing_kv_events": True,
209 }
210 if block_size_factor > 1:
211 extra_config["block_size"] = block_size * block_size_factor
@@ -209,6 +217,13 @@ class RequestRunner:
217 kv_role="kv_both",
218 kv_connector_extra_config=extra_config,
219 )
220 + vllm_config.kv_events_config = KVEventsConfig(
221 + # Enable so the offloading events tracker is active, but use the
222 + # null publisher: these tests drain take_events directly and a
223 + # real ZMQ publisher would bind a port per test.
224 + enable_kv_cache_events=True,
225 + publisher="null",
226 + )
227
228 if kv_cache_groups is None:
229 kv_cache_groups = [
vllm/distributed/kv_transfer/kv_connector/v1/offloading/events.py new
+286
@@ -0,0 +1,286 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Self-describing KV cache events for the offloading connector.
4 +
5 +The OffloadingManager identifies an offloaded chunk only by its OffloadKey,
6 +so its raw events carry no token ids, parent hash, or block size.
7 +:class:`OffloadingEventsTracker` snapshots each chunk's full ``BlockStored``
8 +payload while the ``Request`` is alive and publishes stores as block-granular
9 +payloads: a chunk event may carry multiple constituent per-block hashes, and
10 +evictions fan out to the same hashes. Chunks overlapping a non-chunk-aligned
11 +shared prefix re-announce the shared hashes once per chunk; consumers are
12 +expected to deduplicate (reference-count) repeated store/remove announcements
13 +of the same hash. Opt-in via
14 +``kv_connector_extra_config["self_describing_kv_events"]``; inert unless
15 +KV cache events are enabled. See the PR description for the full design.
16 +"""
17 +
18 +from collections.abc import Iterable
19 +from dataclasses import dataclass
20 +from typing import TYPE_CHECKING, Any, NamedTuple
21 +
22 +from vllm.distributed.kv_events import BlockRemoved, BlockStored, KVCacheEvent
23 +from vllm.logger import init_logger
24 +from vllm.v1.core.kv_cache_utils import BlockHash, maybe_convert_block_hash
25 +from vllm.v1.kv_cache_interface import (
26 + KVCacheGroupSpec,
27 + get_kv_cache_spec_kind,
28 + get_kv_cache_spec_sliding_window,
29 +)
30 +from vllm.v1.kv_offload.base import (
31 + OffloadingEvent,
32 + OffloadingKVEventsConfig,
33 + OffloadKey,
34 + get_offload_block_hash,
35 + get_offload_group_idx,
36 +)
37 +from vllm.v1.request import Request
38 +
39 +if TYPE_CHECKING:
40 + from vllm.distributed.kv_transfer.kv_connector.v1.offloading.scheduler import (
41 + GroupOffloadConfig,
42 + )
43 +
44 +logger = init_logger(__name__)
45 +
46 +
47 +class OffloadingEventGroupSpec(NamedTuple):
48 + kv_cache_spec_kind: str | None
49 + kv_cache_spec_sliding_window: int | None
50 +
51 +
52 +def get_offloading_event_group_spec(
53 + kv_cache_group: KVCacheGroupSpec,
54 +) -> OffloadingEventGroupSpec:
55 + kv_cache_spec = kv_cache_group.kv_cache_spec
56 + return OffloadingEventGroupSpec(
57 + kv_cache_spec_kind=get_kv_cache_spec_kind(kv_cache_spec).value,
58 + kv_cache_spec_sliding_window=get_kv_cache_spec_sliding_window(kv_cache_spec),
59 + )
60 +
61 +
62 +@dataclass(slots=True)
63 +class _OffloadEventMetadata:
64 + """BlockStored payload snapshot for one OffloadKey, captured at store
65 + time and kept until the matching eviction event. ``medium`` is forwarded
66 + from the OffloadingEvent."""
67 +
68 + # The chunk's constituent block hashes; the last one is the OffloadKey.
69 + block_hashes: tuple[BlockHash, ...]
70 + parent_block_hash: BlockHash | None
71 + token_ids: tuple[int, ...]
72 + block_size: int
73 + lora_id: int | None
74 + lora_name: str | None
75 + # Deferred: needs the same incremental curr_mm_idx handling as GPU events.
76 + extra_keys: tuple[tuple[Any, ...] | None, ...] | None
77 + group_idx: int
78 + kv_cache_spec: OffloadingEventGroupSpec
79 +
80 +
81 +class OffloadingEventsTracker:
82 + """Tracks offloaded chunks' KV event payloads from store to eviction.
83 +
84 + The scheduler calls :meth:`record_store` from ``_build_store_jobs``
85 + while the ``Request`` is available, and routes the manager's raw
86 + :class:`OffloadingEvent` stream through :meth:`take_events`. All state
87 + is bounded by the CPU pool capacity and cleared by :meth:`reset`.
88 + """
89 +
90 + def __init__(self, config: OffloadingKVEventsConfig):
91 + self.config = config
92 + self.self_describing_enabled = (
93 + config.enable_kv_cache_events and config.self_describing_kv_events
94 + )
95 +
96 + # OffloadKey -> payload snapshot, kept until the eviction event so
97 + # BlockRemoved can fan out. Bounded: one entry per offloaded chunk.
98 + self._pending_event_metadata: dict[OffloadKey, _OffloadEventMetadata] = {}
99 +
100 + def record_store(
101 + self,
102 + req: Request,
103 + group_config: "GroupOffloadConfig",
104 + offload_block_idx: int,
105 + offload_key: OffloadKey,
106 + ) -> None:
107 + """Snapshot the KV cache event payload for one offloaded chunk.
108 +
109 + No-op when self-describing event capture is disabled or for
110 + sliding-window / SSM groups, which keep the legacy placeholder payload.
111 + """
112 + if not self.self_describing_enabled:
113 + return
114 + if group_config.sliding_window_size_in_blocks is not None:
115 + return
116 + meta = self._build_event_metadata(req, group_config, offload_block_idx)
117 + self._pending_event_metadata[offload_key] = meta
118 +
119 + def take_events(self, events: Iterable[OffloadingEvent]) -> Iterable[KVCacheEvent]:
120 + """Translate raw OffloadingEvents into self-describing KV events.
121 +
122 + Complete metadata is available only for full-attention groups when
123 + the tracker is enabled. Other shapes retain the legacy placeholder
124 + payload so consumers can ignore them.
125 +
126 + Yields:
127 + ``BlockStored`` or ``BlockRemoved`` events corresponding to
128 + the underlying :class:`OffloadingEvent` stream.
129 + """
130 + for event in events:
131 + if event.removed:
132 + yield from self._take_removed_event(event)
133 + else:
134 + yield from self._take_stored_event(event)
135 +
136 + def reset(self) -> None:
137 + """Drop all tracked state; pending payloads are stale after a
138 + manager cache reset."""
139 + self._pending_event_metadata.clear()
140 +
141 + def _build_event_metadata(
142 + self,
143 + req: Request,
144 + group_config: "GroupOffloadConfig",
145 + offload_block_idx: int,
146 + ) -> _OffloadEventMetadata:
147 + """Build the payload snapshot for one offloaded chunk: its
148 + constituent per-block hashes, the whole chunk's tokens, and the
149 + per-block ``block_size``."""
150 + hbf = group_config.hash_block_size_factor
151 + assert hbf > 0
152 + assert offload_block_idx >= 0
153 + # per-block token count (= the GPU/hash block size)
154 + sub_block_size = group_config.offloaded_block_size // hbf
155 + # chunk c covers hash-blocks [c*hbf, (c+1)*hbf); its tail block's hash
156 + # is the chunk's OffloadKey.
157 + first_hash_idx = offload_block_idx * hbf
158 + last_hash_idx = first_hash_idx + hbf
159 + assert first_hash_idx >= 0
160 + assert last_hash_idx <= len(req.block_hashes)
161 + chunk_hashes: list[BlockHash] = []
162 + for block_hash in req.block_hashes[first_hash_idx:last_hash_idx]:
163 + assert block_hash is not None
164 + chunk_hashes.append(block_hash)
165 + assert len(chunk_hashes) == hbf
166 +
167 + if group_config.sliding_window_size_in_blocks is not None:
168 + # record_store filters these out before calling this helper.
169 + raise AssertionError("self-describing events only support full attention")
170 +
171 + parent_block_hash: BlockHash | None
172 + if first_hash_idx == 0:
173 + parent_block_hash = None
174 + else:
175 + parent_block_hash = req.block_hashes[first_hash_idx - 1]
176 + assert parent_block_hash is not None
177 +
178 + tok_start = offload_block_idx * group_config.offloaded_block_size
179 + tok_end = tok_start + group_config.offloaded_block_size
180 + assert tok_end <= len(req.all_token_ids)
181 + token_ids = tuple(req.all_token_ids[tok_start:tok_end])
182 +
183 + lora_id: int | None = None
184 + lora_name: str | None = None
185 + if req.lora_request is not None:
186 + lora_id = req.lora_request.adapter_id
187 + lora_name = req.lora_request.name
188 +
189 + return _OffloadEventMetadata(
190 + block_hashes=tuple(chunk_hashes),
191 + parent_block_hash=parent_block_hash,
192 + token_ids=token_ids,
193 + block_size=sub_block_size,
194 + lora_id=lora_id,
195 + lora_name=lora_name,
196 + extra_keys=None,
197 + group_idx=group_config.group_idx,
198 + kv_cache_spec=group_config.kv_event_group_spec,
199 + )
200 +
201 + def _placeholder_stored(self, key: OffloadKey, medium: str) -> BlockStored:
202 + return BlockStored(
203 + block_hashes=[
204 + maybe_convert_block_hash(BlockHash(get_offload_block_hash(key)))
205 + ],
206 + parent_block_hash=None,
207 + token_ids=[],
208 + lora_id=None,
209 + block_size=0,
210 + medium=medium,
211 + lora_name=None,
212 + group_idx=get_offload_group_idx(key),
213 + )
214 +
215 + def _take_stored_event(self, event: OffloadingEvent) -> Iterable[KVCacheEvent]:
216 + # Metadata is read, NOT popped: the entry must survive until the
217 + # eviction event so BlockRemoved can fan out to the same hashes.
218 + # Events are self-contained (own parent), so key order is free.
219 + for key in event.keys:
220 + meta = self._pending_event_metadata.get(key)
221 + if meta is None:
222 + if self.self_describing_enabled:
223 + # Expected for unsupported shapes; warn once only.
224 + logger.warning_once(
225 + "OffloadingEventsTracker: no event metadata for "
226 + "offload key during BlockStored emission; emitting a "
227 + "placeholder payload. Expected for non-full-attention "
228 + "groups; otherwise indicates a missing populate path."
229 + )
230 + yield self._placeholder_stored(key, event.medium)
231 + continue
232 +
233 + yield BlockStored(
234 + block_hashes=list(
235 + maybe_convert_block_hash(h) for h in meta.block_hashes
236 + ),
237 + parent_block_hash=(
238 + maybe_convert_block_hash(meta.parent_block_hash)
239 + if meta.parent_block_hash is not None
240 + else None
241 + ),
242 + token_ids=list(meta.token_ids),
243 + block_size=meta.block_size,
244 + lora_id=meta.lora_id,
245 + medium=event.medium,
246 + lora_name=meta.lora_name,
247 + extra_keys=(
248 + list(meta.extra_keys) if meta.extra_keys is not None else None
249 + ),
250 + group_idx=meta.group_idx,
251 + kv_cache_spec_kind=meta.kv_cache_spec.kv_cache_spec_kind,
252 + kv_cache_spec_sliding_window=(
253 + meta.kv_cache_spec.kv_cache_spec_sliding_window
254 + ),
255 + )
256 +
257 + def _take_removed_event(self, event: OffloadingEvent) -> Iterable[KVCacheEvent]:
258 + # Keep group_idx unambiguous if a manager batch spans groups.
259 + by_group: dict[int, list] = {}
260 + for key in event.keys:
261 + meta = self._pending_event_metadata.pop(key, None)
262 + if meta is not None:
263 + group_idx = meta.group_idx
264 + by_group.setdefault(group_idx, []).extend(
265 + maybe_convert_block_hash(h) for h in meta.block_hashes
266 + )
267 + else:
268 + if self.self_describing_enabled:
269 + logger.warning_once(
270 + "OffloadingEventsTracker: no event metadata for "
271 + "offload key during BlockRemoved emission; emitting a "
272 + "placeholder removal. Expected if the matching store "
273 + "used the legacy placeholder payload; otherwise "
274 + "indicates missing store metadata."
275 + )
276 + group_idx = get_offload_group_idx(key)
277 + by_group.setdefault(group_idx, []).append(
278 + maybe_convert_block_hash(BlockHash(get_offload_block_hash(key)))
279 + )
280 +
281 + for group_idx, hashes in by_group.items():
282 + yield BlockRemoved(
283 + block_hashes=hashes,
284 + medium=event.medium,
285 + group_idx=group_idx,
286 + )
vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py
+32 -19
@@ -5,7 +5,7 @@ from dataclasses import dataclass, field
5 from itertools import islice
6 from typing import Any, NamedTuple
7
8 -from vllm.distributed.kv_events import BlockRemoved, BlockStored, KVCacheEvent
8 +from vllm.distributed.kv_events import KVCacheEvent
9 from vllm.distributed.kv_transfer.kv_connector.utils import yield_req_data
10 from vllm.distributed.kv_transfer.kv_connector.v1.base import KVConnectorMetadata
11 from vllm.distributed.kv_transfer.kv_connector.v1.offloading.common import (
@@ -14,6 +14,11 @@ from vllm.distributed.kv_transfer.kv_connector.v1.offloading.common import (
14 ReqId,
15 TransferJob,
16 )
17 +from vllm.distributed.kv_transfer.kv_connector.v1.offloading.events import (
18 + OffloadingEventGroupSpec,
19 + OffloadingEventsTracker,
20 + get_offloading_event_group_spec,
21 +)
22 from vllm.distributed.kv_transfer.kv_connector.v1.offloading.metrics import (
23 OffloadingConnectorStats,
24 _TransferMetricName,
@@ -36,7 +41,6 @@ from vllm.v1.kv_offload.base import (
41 OffloadPolicy,
42 ReqContext,
43 RequestOffloadingContext,
39 - get_offload_block_hash,
44 make_offload_key,
45 )
46 from vllm.v1.outputs import KVConnectorOutput
@@ -69,6 +73,9 @@ class GroupOffloadConfig(NamedTuple):
73 gpu_block_size: int
74 offloaded_block_size: int
75 hash_block_size_factor: int
76 + # KV cache spec metadata propagated onto emitted BlockStored events so
77 + # KV-aware consumers can classify and filter the group.
78 + kv_event_group_spec: OffloadingEventGroupSpec
79 # None below means full attention
80 sliding_window_size_in_blocks: int | None
81 # Number of this group's offloaded blocks per full-attention alignment
@@ -200,6 +207,9 @@ class SchedulerOffloadConfig(NamedTuple):
207 alignment_block_count=_alignment_block_count(
208 gpu_block_size * spec.block_size_factor, sw
209 ),
210 + kv_event_group_spec=get_offloading_event_group_spec(
211 + spec.kv_cache_config.kv_cache_groups[idx]
212 + ),
213 is_eagle_group=idx in eagle_groups,
214 )
215 for idx, gpu_block_size in enumerate(spec.gpu_block_size)
@@ -361,6 +371,8 @@ class OffloadingConnectorScheduler:
371 # be freed before a request finishes).
372 self._block_id_to_pending_jobs: dict[int, set[int]] = {}
373
374 + self._events_tracker = OffloadingEventsTracker(spec.kv_events_config)
375 +
376 def _generate_job_id(self) -> int:
377 job_id = self._job_counter
378 self._job_counter += 1
@@ -934,6 +946,11 @@ class OffloadingConnectorScheduler:
946 continue
947
948 offloaded_block_idx = start_block_idx + idx
949 +
950 + self._events_tracker.record_store(
951 + req, group_config, offloaded_block_idx, offload_key
952 + )
953 +
954 gpu_block_idx = offloaded_block_idx * block_size_factor
955 for i in range(block_size_factor):
956 block_id = block_ids[gpu_block_idx + i]
@@ -1184,25 +1201,17 @@ class OffloadingConnectorScheduler:
1201 return False, None
1202
1203 def take_events(self) -> Iterable[KVCacheEvent]:
1187 - """Take the KV cache events from the connector.
1204 + """Drain pending KV cache events.
1205
1189 - Returns:
1190 - A list of KV cache events.
1206 + Complete metadata is available only when self-describing KV events
1207 + are enabled, and only for full-attention groups. Other shapes retain
1208 + the previous placeholder payload so consumers can ignore them.
1209 +
1210 + Yields:
1211 + ``BlockStored`` or ``BlockRemoved`` events corresponding to
1212 + the underlying :class:`OffloadingEvent` stream.
1213 """
1192 - for event in self.manager.take_events():
1193 - block_hashes = [get_offload_block_hash(key) for key in event.keys]
1194 - if event.removed:
1195 - yield BlockRemoved(block_hashes=block_hashes, medium=event.medium)
1196 - else:
1197 - yield BlockStored(
1198 - block_hashes=block_hashes,
1199 - parent_block_hash=None,
1200 - token_ids=[],
1201 - lora_id=None,
1202 - block_size=0,
1203 - medium=event.medium,
1204 - lora_name=None,
1205 - )
1214 + yield from self._events_tracker.take_events(self.manager.take_events())
1215
1216 def reset_cache(self) -> None:
1217 """Reset the offloading manager cache, evicting all stored blocks."""
@@ -1238,6 +1247,10 @@ class OffloadingConnectorScheduler:
1247 self._jobs.clear()
1248 self._block_id_to_pending_jobs.clear()
1249
1250 + # The manager pool is empty; pending event payloads and announced
1251 + # reference counts are stale.
1252 + self._events_tracker.reset()
1253 +
1254 # Note: _current_batch_jobs_to_flush is intentionally NOT cleared.
1255 # The load flush IDs collected above must be delivered to workers.
1256 if self._blocks_being_loaded is not None:
vllm/v1/kv_offload/base.py
+19
@@ -147,6 +147,16 @@ class OffloadingHistogramMetadata(OffloadingMetricMetadata):
147 buckets: tuple[float, ...] | None = None
148
149
150 +@dataclass(frozen=True)
151 +class OffloadingKVEventsConfig:
152 + # Global vLLM KV event publishing flag. When false, connector-specific
153 + # event capture must stay inert because take_events() is not drained.
154 + enable_kv_cache_events: bool
155 + # OffloadingConnector opt-in for self-describing BlockStored payloads.
156 + # Effective only when enable_kv_cache_events is true.
157 + self_describing_kv_events: bool
158 +
159 +
160 class OffloadingManager(ABC):
161 @abstractmethod
162 def lookup(self, key: OffloadKey, req_context: ReqContext) -> bool | None:
@@ -443,6 +453,15 @@ class OffloadingSpec(ABC):
453 kv_transfer_config = vllm_config.kv_transfer_config
454 assert kv_transfer_config is not None
455 self.extra_config = kv_transfer_config.kv_connector_extra_config
456 + kv_events_config = vllm_config.kv_events_config
457 + self.kv_events_config = OffloadingKVEventsConfig(
458 + enable_kv_cache_events=(
459 + kv_events_config is not None and kv_events_config.enable_kv_cache_events
460 + ),
461 + self_describing_kv_events=bool(
462 + self.extra_config.get("self_describing_kv_events", False)
463 + ),
464 + )
465
466 # When True, only prompt (prefill) blocks are offloaded; decode-phase
467 # blocks (KV generated after the prompt) are skipped. Useful when prior
vllm/v1/kv_offload/cpu/spec.py
+1 -6
@@ -114,11 +114,6 @@ class CPUOffloadingSpec(OffloadingSpec):
114 @override
115 def get_manager(self) -> OffloadingManager:
116 if not self._manager:
117 - kv_events_config = self.vllm_config.kv_events_config
118 - enable_events = (
119 - kv_events_config is not None and kv_events_config.enable_kv_cache_events
120 - )
121 -
117 # store_threshold: how many times a block must appear in lookup()
118 # before it is eligible for CPU offloading. Values < 2 disable
119 # filtering (a threshold of 1 equals no filter; 0 is the default).
@@ -130,7 +125,7 @@ class CPUOffloadingSpec(OffloadingSpec):
125 self._manager = CPUOffloadingManager(
126 num_blocks=self.num_blocks,
127 cache_policy=self.eviction_policy, # type: ignore[arg-type]
133 - enable_events=enable_events,
128 + enable_events=self.kv_events_config.enable_kv_cache_events,
129 store_threshold=store_threshold,
130 max_tracker_size=max_tracker_size,
131 )
vllm/v1/kv_offload/tiering/spec.py
+10 -7
@@ -69,6 +69,14 @@ class TieringOffloadingSpec(CPUOffloadingSpec):
69 super().__init__(vllm_config, kv_cache_config)
70 # Redeclare for mypy: parent sets this but `--follow-imports skip` hides it
71 self._manager: OffloadingManager | None = None
72 + if self.kv_events_config.self_describing_kv_events:
73 + raise ValueError(
74 + "self_describing_kv_events is not supported by "
75 + "TieringOffloadingSpec. Tier promotions can emit primary-tier "
76 + "store events that do not correspond to GPU store jobs, so the "
77 + "current self-describing side table cannot describe them "
78 + "correctly."
79 + )
80
81 # Parse secondary tier configurations
82 self.secondary_tier_configs = self.extra_config.get("secondary_tiers", [])
@@ -91,11 +99,6 @@ class TieringOffloadingSpec(CPUOffloadingSpec):
99 TieringOffloadingManager instance
100 """
101 if not self._manager:
94 - kv_events_config = self.vllm_config.kv_events_config
95 - enable_events = (
96 - kv_events_config is not None and kv_events_config.enable_kv_cache_events
97 - )
98 -
102 # Create scheduler-side SharedOffloadRegion (rank=None) so the
103 # primary tier can eagerly create a memoryview over _base.
104 scheduler_mmap = SharedOffloadRegion(
@@ -111,7 +114,7 @@ class TieringOffloadingSpec(CPUOffloadingSpec):
114 primary_tier = CPUPrimaryTierOffloadingManager(
115 num_blocks=self.num_blocks,
116 cache_policy=self.eviction_policy, # type: ignore[arg-type]
114 - enable_events=enable_events,
117 + enable_events=self.kv_events_config.enable_kv_cache_events,
118 mmap_region=scheduler_mmap,
119 )
120
@@ -143,7 +146,7 @@ class TieringOffloadingSpec(CPUOffloadingSpec):
146 tiering_manager = TieringOffloadingManager(
147 primary_tier=primary_tier,
148 secondary_tiers=secondary_tiers,
146 - enable_events=enable_events,
149 + enable_events=self.kv_events_config.enable_kv_cache_events,
150 )
151 if int(self.extra_config.get("store_threshold", 0)) >= 2:
152 raise ValueError(