[KV Offload] Support self-describing KV events with TieringOffloadingSpec (#48679)
Signed-off-by: Change72 <changg@nvidia.com> Co-authored-by: OpenAI Codex <codex@openai.com>
Chang Guo committed
Jul 20, 2026 at 06:41 UTC
f007cceb422599330495b202632d78c7b3b89e61
6 files changed
+452
-65
docs/features/kv_offloading_usage.md
+1
-1
@@ -75,7 +75,7 @@ vllm serve <model> \
75
| `max_tracker_size` | no | `64000` | single-tier | Max entries in the lookup tracker. |
76
| `secondary_tiers` | no | `[]` | multi-tier | List of secondary tier configs (see below). |
77
| `offload_prompt_only` | no | `true` | both | If `true`, only prompt (prefill) blocks are offloaded; decode blocks are skipped. |
78
-| `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, or `blocks_per_chunk` > 1), overlapping chunks re-announce shared per-block hashes, so consumers must reference-count (deduplicate) repeated store/remove announcements. |
78
+| `self_describing_kv_events` | no | `false` | both | 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. With `TieringOffloadingSpec`, a CPU promotion is self-describing when a local request observes its primary-tier `HIT` before event translation; otherwise its stored event may retain the placeholder, while a later `HIT` can backfill metadata for removal. Pending-removal/re-promotion races and externally initiated promotions may also produce placeholders, and consumers must ignore removals for unknown hashes. Full-attention groups only; sliding-window/SSM groups keep the placeholder fallback. In chunk mode (`block_size` > GPU block size, or `blocks_per_chunk` > 1), overlapping chunks re-announce shared per-block hashes, so consumers must reference-count (deduplicate) repeated store/remove announcements. |
79
| `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). |
80
81
## Secondary Tiers
tests/v1/kv_connector/unit/offloading_connector/test_events.py
+143
-14
@@ -7,7 +7,13 @@ 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 MEDIUM_CPU, MEDIUM_FS, BlockRemoved, BlockStored
10
+from vllm.distributed.kv_events import (
11
+ MEDIUM_CPU,
12
+ MEDIUM_FS,
13
+ MEDIUM_OBJ,
14
+ BlockRemoved,
15
+ BlockStored,
16
+)
17
from vllm.distributed.kv_transfer.kv_connector.v1.offloading.config import (
18
build_offloading_config,
19
)
@@ -62,8 +68,9 @@ def _wire_hash(block_hash: BlockHash):
68
return maybe_convert_block_hash(block_hash)
69
70
65
-def _request(*, block_hashes: list[BlockHash], token_count: int):
71
+def _request(*, block_hashes: list[BlockHash], token_count: int, req_id: str = "req"):
72
req = MagicMock()
73
+ req.request_id = req_id
74
req.block_hashes = block_hashes
75
req.all_token_ids = list(range(1, token_count + 1))
76
req.lora_request = None
@@ -104,10 +111,32 @@ def _record_chunks(
111
return keys
112
113
114
+def _record_lookup_chunks(
115
+ tracker: OffloadingEventsTracker,
116
+ req,
117
+ group_config: GroupOffloadConfig,
118
+ num_chunks: int,
119
+) -> list[OffloadKey]:
120
+ keys: list[OffloadKey] = []
121
+ hbf = group_config.hashes_per_chunk
122
+ for chunk_idx in range(num_chunks):
123
+ tail_hash = req.block_hashes[(chunk_idx + 1) * hbf - 1]
124
+ assert tail_hash is not None
125
+ key = make_offload_key(tail_hash, group_config.group_idx)
126
+ tracker.record_lookup(
127
+ req,
128
+ group_config,
129
+ chunk_idx,
130
+ key,
131
+ )
132
+ keys.append(key)
133
+ return keys
134
+
135
+
136
def _stored_event(
137
keys: list[OffloadKey],
109
- locality: Locality | None = None,
138
medium: str = _CPU_MEDIUM,
139
+ locality: Locality | None = None,
140
) -> OffloadingEvent:
141
return OffloadingEvent(
142
keys=keys,
@@ -119,8 +148,8 @@ def _stored_event(
148
149
def _removed_event(
150
keys: list[OffloadKey],
122
- locality: Locality | None = None,
151
medium: str = _CPU_MEDIUM,
152
+ locality: Locality | None = None,
153
) -> OffloadingEvent:
154
return OffloadingEvent(
155
keys=keys,
@@ -130,6 +159,21 @@ def _removed_event(
159
)
160
161
162
+def _lookup_chunk() -> tuple[
163
+ OffloadingEventsTracker, MagicMock, GroupOffloadConfig, OffloadKey
164
+]:
165
+ tracker = _tracker()
166
+ req = _request(block_hashes=[_hash(0)], token_count=4)
167
+ group_config = _group_config()
168
+ key = _record_lookup_chunks(
169
+ tracker,
170
+ req,
171
+ group_config,
172
+ num_chunks=1,
173
+ )[0]
174
+ return tracker, req, group_config, key
175
+
176
+
177
def test_take_events_forwards_locality_to_rich_store():
178
tracker = _tracker()
179
req = _request(block_hashes=[_hash(0)], token_count=4)
@@ -220,18 +264,37 @@ def test_take_events_publishes_routable_block_stored():
264
assert len(tracker._pending_event_metadata) == 6
265
266
223
-def test_take_events_factor_gt_1_chunk_store_and_remove():
267
+def test_promotion_emits_full_cpu_stored_event():
268
+ tracker, _, _, key = _lookup_chunk()
269
+
270
+ [event] = tracker.take_events([_stored_event([key])])
271
+
272
+ assert isinstance(event, BlockStored)
273
+ assert event.medium == MEDIUM_CPU
274
+ assert event.block_hashes == [_wire_hash(_hash(0))]
275
+ assert event.parent_block_hash is None
276
+ assert event.token_ids == [1, 2, 3, 4]
277
+ assert event.block_size == 4
278
+ assert event.lora_id is None
279
+ assert event.lora_name is None
280
+ assert event.extra_keys is None
281
+ assert event.group_idx == 0
282
+ assert event.kv_cache_spec_kind == KVCacheSpecKind.FULL_ATTENTION.value
283
+ assert event.kv_cache_spec_sliding_window is None
284
+
285
+
286
+def test_lookup_promotion_factor_gt_1_store_and_remove():
287
block_size = 4
225
- blocks_per_chunk = 3
288
+ blocks_per_chunk = 2
289
tracker = _tracker()
290
group_config = _group_config(
291
block_size=block_size, blocks_per_chunk=blocks_per_chunk
292
)
293
req = _request(
231
- block_hashes=[_hash(i) for i in range(6)],
294
+ block_hashes=[_hash(i) for i in range(4)],
295
token_count=block_size * blocks_per_chunk * 2,
296
)
234
- keys = _record_chunks(tracker, req, group_config, num_chunks=2)
297
+ keys = _record_lookup_chunks(tracker, req, group_config, num_chunks=2)
298
299
stored = list(tracker.take_events([_stored_event(keys)]))
300
assert len(stored) == 2
@@ -293,6 +356,7 @@ def test_take_events_opt_out_keeps_placeholders():
356
group_config = _group_config()
357
req = _request(block_hashes=[_hash(i) for i in range(3)], token_count=12)
358
keys = _record_chunks(tracker, req, group_config, num_chunks=3)
359
+ _record_lookup_chunks(tracker, req, group_config, num_chunks=3)
360
361
assert not tracker.self_describing_enabled
362
assert not tracker._pending_event_metadata
@@ -315,11 +379,21 @@ def test_take_events_opt_out_keeps_placeholders():
379
assert len(events[3].block_hashes) == 3
380
381
318
-def test_record_store_skips_sliding_window_group():
382
+@pytest.mark.parametrize(
383
+ "sliding_window_size_in_chunks",
384
+ [1, 2],
385
+ ids=["ssm", "sliding-window"],
386
+)
387
+def test_event_metadata_skips_non_full_attention_group(
388
+ sliding_window_size_in_chunks: int,
389
+):
390
tracker = _tracker()
320
- group_config = _group_config(sliding_window_size_in_chunks=2)
391
+ group_config = _group_config(
392
+ sliding_window_size_in_chunks=sliding_window_size_in_chunks
393
+ )
394
req = _request(block_hashes=[_hash(i) for i in range(3)], token_count=12)
395
keys = _record_chunks(tracker, req, group_config, num_chunks=3)
396
+ _record_lookup_chunks(tracker, req, group_config, num_chunks=3)
397
398
assert not tracker._pending_event_metadata
399
@@ -329,6 +403,57 @@ def test_record_store_skips_sliding_window_group():
403
assert events[0].block_size == 0
404
405
406
+def test_pending_cpu_removal_consumes_hit_backfill_until_next_hit():
407
+ tracker = _tracker()
408
+ block_hashes = [_hash(0), _hash(1)]
409
+ req = _request(block_hashes=block_hashes, token_count=8)
410
+ group_config = _group_config(blocks_per_chunk=2)
411
+ key = _record_chunks(tracker, req, group_config, num_chunks=1)[0]
412
+ confirmed_meta = tracker._pending_event_metadata[key]
413
+ lookup_req = _request(
414
+ block_hashes=block_hashes,
415
+ token_count=8,
416
+ req_id="new-request",
417
+ )
418
+
419
+ tracker.record_lookup(
420
+ lookup_req,
421
+ group_config,
422
+ 0,
423
+ key,
424
+ )
425
+ assert tracker._pending_event_metadata[key] is confirmed_meta
426
+
427
+ removed = list(tracker.take_events([_removed_event([key])]))
428
+ assert len(removed) == 1
429
+ assert removed[0].block_hashes == [
430
+ _wire_hash(_hash(0)),
431
+ _wire_hash(_hash(1)),
432
+ ]
433
+
434
+ stored = list(tracker.take_events([_stored_event([key])]))
435
+ assert len(stored) == 1
436
+ assert stored[0].block_size == 0
437
+ assert stored[0].token_ids == []
438
+
439
+ tracker.record_lookup(lookup_req, group_config, 0, key)
440
+ removed = list(tracker.take_events([_removed_event([key])]))
441
+ assert removed[0].block_hashes == [
442
+ _wire_hash(_hash(0)),
443
+ _wire_hash(_hash(1)),
444
+ ]
445
+
446
+
447
+@pytest.mark.parametrize("medium", [MEDIUM_FS, MEDIUM_OBJ])
448
+def test_secondary_stored_event_does_not_mutate_cpu_metadata(medium: str):
449
+ tracker, _, _, key = _lookup_chunk()
450
+ expected_metadata = dict(tracker._pending_event_metadata)
451
+
452
+ stored = list(tracker.take_events([_stored_event([key], medium)]))
453
+ assert stored[0].token_ids == [1, 2, 3, 4]
454
+ assert tracker._pending_event_metadata == expected_metadata
455
+
456
+
457
def test_take_events_groups_removed_hashes_by_kv_group():
458
tracker = _tracker()
459
group0_config = _group_config(group_idx=0, blocks_per_chunk=2)
@@ -378,7 +503,7 @@ def test_reset_cache_clears_side_table():
503
tracker = _tracker()
504
group_config = _group_config()
505
req = _request(block_hashes=[_hash(i) for i in range(3)], token_count=12)
381
- _record_chunks(tracker, req, group_config, num_chunks=3)
506
+ _record_lookup_chunks(tracker, req, group_config, num_chunks=3)
507
508
assert tracker._pending_event_metadata
509
@@ -387,7 +512,7 @@ def test_reset_cache_clears_side_table():
512
assert not tracker._pending_event_metadata
513
514
390
-def test_tiering_rejects_self_describing_kv_events():
515
+def test_tiering_accepts_self_describing_kv_events():
516
vllm_config = create_vllm_config(
517
block_size=4,
518
max_num_batched_tokens=16,
@@ -423,5 +548,9 @@ def test_tiering_rejects_self_describing_kv_events():
548
],
549
)
550
426
- with pytest.raises(ValueError, match="TieringOffloadingSpec"):
427
- TieringOffloadingSpec(build_offloading_config(vllm_config, kv_cache_config))
551
+ spec = TieringOffloadingSpec(build_offloading_config(vllm_config, kv_cache_config))
552
+ tracker = OffloadingEventsTracker(spec.kv_events_config)
553
+
554
+ assert spec.kv_events_config.enable_kv_cache_events
555
+ assert spec.kv_events_config.self_describing_kv_events
556
+ assert tracker.self_describing_enabled
tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py
+259
-27
@@ -1,7 +1,7 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
from types import SimpleNamespace
4
-from unittest.mock import MagicMock
4
+from unittest.mock import MagicMock, call
5
6
import pytest
7
import torch
@@ -11,6 +11,7 @@ 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
14
+from vllm.distributed.kv_events import MEDIUM_CPU, BlockRemoved, BlockStored
15
from vllm.distributed.kv_transfer.kv_connector.v1.offloading.metrics import (
16
OffloadingConnectorStats,
17
_ConnectorMetricName,
@@ -19,6 +20,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.offloading.scheduler import (
20
OffloadingConnectorScheduler,
21
RequestOffloadState,
22
)
23
+from vllm.v1.core.kv_cache_utils import BlockHash
24
from vllm.v1.kv_cache_interface import (
25
FullAttentionSpec,
26
KVCacheGroupSpec,
@@ -26,6 +28,7 @@ from vllm.v1.kv_cache_interface import (
28
)
29
from vllm.v1.kv_offload.base import (
30
LookupResult,
31
+ OffloadingEvent,
32
OffloadingManager,
33
OffloadPolicy,
34
ReqContext,
@@ -143,6 +146,159 @@ def test_scheduler_reports_lookup_async_delay_on_resolve(request_runner):
146
assert reduced[f"{_ConnectorMetricName.LOOKUP_ASYNC_DELAY}_sum"] > 0
147
148
149
+def test_max_offload_tokens_zero_does_not_record_pending_lookups(request_runner):
150
+ runner = request_runner(
151
+ block_size=4,
152
+ num_gpu_blocks=10,
153
+ async_scheduling=False,
154
+ )
155
+ runner.manager.lookup.return_value = LookupResult.RETRY
156
+ runner.manager.take_events.return_value = []
157
+ runner.manager.prepare_store.side_effect = lambda keys, req_context: (
158
+ generate_store_output(keys)
159
+ )
160
+
161
+ runner.new_request(
162
+ token_ids=[1] * 12,
163
+ kv_transfer_params={"max_offload_tokens": 0},
164
+ )
165
+ runner.run(decoded_tokens=[])
166
+
167
+ tracker = runner.connector_scheduler._events_tracker
168
+ assert runner.manager.lookup.call_count == 3
169
+ assert not tracker._pending_event_metadata
170
+ assert list(runner.connector_scheduler.take_events()) == []
171
+
172
+ runner.manager.lookup.return_value = LookupResult.MISS
173
+ runner.run(decoded_tokens=[EOS_TOKEN_ID])
174
+
175
+ assert not tracker._pending_event_metadata
176
+ assert list(runner.connector_scheduler.take_events()) == []
177
+
178
+
179
+def test_abort_before_hit_uses_placeholder_then_later_hit_heals_removal(
180
+ request_runner,
181
+):
182
+ runner = request_runner(
183
+ block_size=4,
184
+ num_gpu_blocks=10,
185
+ async_scheduling=False,
186
+ blocks_per_chunk=2,
187
+ )
188
+ raw_events: list[OffloadingEvent] = []
189
+
190
+ def take_raw_events():
191
+ yield from raw_events
192
+ raw_events.clear()
193
+
194
+ runner.manager.lookup.return_value = LookupResult.RETRY
195
+ runner.manager.take_events.side_effect = take_raw_events
196
+ runner.manager.prepare_store.side_effect = lambda keys, req_context: (
197
+ generate_store_output([])
198
+ )
199
+
200
+ runner.new_request(token_ids=[1] * 8)
201
+ runner.run(decoded_tokens=[])
202
+
203
+ tracker = runner.connector_scheduler._events_tracker
204
+ assert not tracker._pending_event_metadata
205
+ key = runner.manager.lookup.call_args.args[0]
206
+ req_id = str(runner.req_id)
207
+ req_status = runner.connector_scheduler._req_status[req_id]
208
+
209
+ runner.scheduler.finish_requests((req_id,), RequestStatus.FINISHED_ABORTED)
210
+
211
+ assert not tracker._pending_event_metadata
212
+
213
+ raw_events.append(OffloadingEvent(keys=[key], medium=MEDIUM_CPU, removed=False))
214
+ events = list(runner.connector_scheduler.take_events())
215
+ assert len(events) == 1
216
+ assert isinstance(events[0], BlockStored)
217
+ assert events[0].block_size == 0
218
+ assert events[0].token_ids == []
219
+
220
+ runner.manager.lookup.return_value = LookupResult.HIT
221
+ group_config = runner.connector_scheduler.config.kv_group_configs[0]
222
+ assert (
223
+ runner.connector_scheduler._maximal_prefix_lookup(
224
+ [key],
225
+ req_status.req_context,
226
+ req_status.req,
227
+ group_config,
228
+ 0,
229
+ )
230
+ == 1
231
+ )
232
+ assert key in tracker._pending_event_metadata
233
+
234
+ raw_events.append(OffloadingEvent(keys=[key], medium=MEDIUM_CPU, removed=True))
235
+ [event] = runner.connector_scheduler.take_events()
236
+ assert isinstance(event, BlockRemoved)
237
+ assert event.medium == MEDIUM_CPU
238
+ assert len(event.block_hashes) == 2
239
+ assert key not in tracker._pending_event_metadata
240
+
241
+
242
+@pytest.mark.parametrize("blocks_per_chunk", [1, 2])
243
+def test_promotion_hit_precedes_stored_event_translation(
244
+ request_runner,
245
+ blocks_per_chunk: int,
246
+):
247
+ runner = request_runner(
248
+ block_size=4,
249
+ num_gpu_blocks=10,
250
+ async_scheduling=False,
251
+ blocks_per_chunk=blocks_per_chunk,
252
+ )
253
+ token_ids = [1] * 4 * blocks_per_chunk
254
+
255
+ runner.manager.prepare_store.side_effect = lambda keys, req_context: (
256
+ generate_store_output(keys)
257
+ )
258
+ runner.new_request(token_ids=token_ids)
259
+ runner.run(
260
+ decoded_tokens=[EOS_TOKEN_ID],
261
+ expected_stored=tuple(range(blocks_per_chunk)),
262
+ )
263
+ runner.scheduler.reset_prefix_cache()
264
+ runner.connector_scheduler._events_tracker.reset()
265
+
266
+ raw_events: list[OffloadingEvent] = []
267
+
268
+ def lookup(key, req_context):
269
+ raw_events.append(OffloadingEvent(keys=[key], medium=MEDIUM_CPU, removed=False))
270
+ return LookupResult.HIT
271
+
272
+ def take_raw_events():
273
+ yield from raw_events
274
+ raw_events.clear()
275
+
276
+ runner.manager.lookup.side_effect = lookup
277
+ runner.manager.take_events.side_effect = take_raw_events
278
+ runner.manager.prepare_store.side_effect = lambda keys, req_context: (
279
+ generate_store_output([])
280
+ )
281
+ publisher = MagicMock()
282
+ runner.scheduler.kv_event_publisher = publisher
283
+
284
+ runner.new_request(token_ids=token_ids)
285
+ runner.run(
286
+ decoded_tokens=[],
287
+ expected_loaded=tuple(range(blocks_per_chunk)),
288
+ )
289
+
290
+ events = [
291
+ event
292
+ for publish_call in publisher.publish.call_args_list
293
+ for event in publish_call.args[0].events
294
+ if isinstance(event, BlockStored) and event.medium == MEDIUM_CPU
295
+ ]
296
+ assert len(events) == 1
297
+ assert len(events[0].block_hashes) == blocks_per_chunk
298
+ assert events[0].block_size == 4
299
+ assert events[0].token_ids == token_ids
300
+
301
+
302
@pytest.mark.parametrize("async_scheduling", [True, False])
303
def test_offloading_connector(request_runner, async_scheduling: bool):
304
block_size = 4
@@ -241,7 +397,7 @@ def test_offloading_connector(request_runner, async_scheduling: bool):
397
runner.manager.prepare_store.side_effect = lambda keys, req_context: (
398
generate_store_output([])
399
)
244
- runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 1
400
+ runner.connector_scheduler._maximal_prefix_lookup = lambda keys, ctx, *_: 1
401
runner.run(decoded_tokens=[EOS_TOKEN_ID], expected_loaded=(0, 1, 2))
402
403
# single block lookup with a hit in a middle block
@@ -249,7 +405,7 @@ def test_offloading_connector(request_runner, async_scheduling: bool):
405
runner.manager.prepare_store.side_effect = lambda keys, req_context: (
406
generate_store_output([])
407
)
252
- runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 1
408
+ runner.connector_scheduler._maximal_prefix_lookup = lambda keys, ctx, *_: 1
409
runner.run(decoded_tokens=[EOS_TOKEN_ID], expected_loaded=(3, 4, 5))
410
411
@@ -307,7 +463,7 @@ def test_request_preemption(request_runner, async_scheduling: bool):
463
464
# request should now return from preemption
465
# re-load [0, ..., 8] from the CPU and store [9, 10, 11]
310
- runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 3
466
+ runner.connector_scheduler._maximal_prefix_lookup = lambda keys, ctx, *_: 3
467
runner.manager.prepare_store.side_effect = lambda keys, req_context: (
468
generate_store_output(keys)
469
)
@@ -427,7 +583,7 @@ def test_concurrent_lookups_of_the_same_prefix(request_runner, async_scheduling:
583
# start a request to load the first block, but don't complete
584
runner.scheduler.reset_prefix_cache()
585
runner.new_request(token_ids=[0] * tokens_per_chunk)
430
- runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 1
586
+ runner.connector_scheduler._maximal_prefix_lookup = lambda keys, ctx, *_: 1
587
runner.run(
588
decoded_tokens=[],
589
complete_transfers=False,
@@ -439,7 +595,7 @@ def test_concurrent_lookups_of_the_same_prefix(request_runner, async_scheduling:
595
596
# start a new request to load the same first block
597
runner.new_request(token_ids=[0] * tokens_per_chunk)
442
- runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 1
598
+ runner.connector_scheduler._maximal_prefix_lookup = lambda keys, ctx, *_: 1
599
runner.run(
600
decoded_tokens=[],
601
complete_transfers=False,
@@ -491,7 +647,7 @@ def test_abort_loading_requests(request_runner, async_scheduling: bool):
647
# start a request to load the first block, but don't complete
648
runner.scheduler.reset_prefix_cache()
649
runner.new_request(token_ids=[0] * tokens_per_chunk)
494
- runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 1
650
+ runner.connector_scheduler._maximal_prefix_lookup = lambda keys, ctx, *_: 1
651
runner.run(
652
decoded_tokens=[],
653
complete_transfers=False,
@@ -793,73 +949,144 @@ def _make_scheduler_with_lookup(
949
950
scheduler = object.__new__(OffloadingConnectorScheduler)
951
scheduler.manager = manager
952
+ scheduler._events_tracker = MagicMock()
953
return scheduler
954
955
956
_EMPTY_REQ_CTX = ReqContext(req_id="")
957
+_LOOKUP_REQ = MagicMock()
958
+_LOOKUP_REQ.request_id = "req"
959
+_LOOKUP_GROUP_CONFIG = MagicMock()
960
+
961
+
962
+def _maximal_lookup(sched, keys, start_chunk_idx: int = 0):
963
+ return sched._maximal_prefix_lookup(
964
+ keys,
965
+ _EMPTY_REQ_CTX,
966
+ _LOOKUP_REQ,
967
+ _LOOKUP_GROUP_CONFIG,
968
+ start_chunk_idx,
969
+ )
970
971
972
class TestMaximalPrefixLookup:
973
def test_all_hit(self):
974
sched = _make_scheduler_with_lookup({1: LookupResult.HIT, 2: LookupResult.HIT})
805
- assert sched._maximal_prefix_lookup(to_keys([1, 2]), _EMPTY_REQ_CTX) == 2
975
+ assert _maximal_lookup(sched, to_keys([1, 2])) == 2
976
+
977
+ def test_records_absolute_chunk_indices(self):
978
+ keys = to_keys([1, 2])
979
+ sched = _make_scheduler_with_lookup({1: LookupResult.HIT, 2: LookupResult.HIT})
980
+
981
+ assert _maximal_lookup(sched, keys, start_chunk_idx=3) == 2
982
+ assert sched._events_tracker.record_lookup.call_args_list == [
983
+ call(
984
+ _LOOKUP_REQ,
985
+ _LOOKUP_GROUP_CONFIG,
986
+ 3,
987
+ keys[0],
988
+ ),
989
+ call(
990
+ _LOOKUP_REQ,
991
+ _LOOKUP_GROUP_CONFIG,
992
+ 4,
993
+ keys[1],
994
+ ),
995
+ ]
996
997
def test_all_miss(self):
998
sched = _make_scheduler_with_lookup({})
809
- assert sched._maximal_prefix_lookup(to_keys([1, 2]), _EMPTY_REQ_CTX) == 0
999
+ assert _maximal_lookup(sched, to_keys([1, 2])) == 0
1000
+ sched._events_tracker.record_lookup.assert_not_called()
1001
1002
def test_partial_prefix(self):
1003
sched = _make_scheduler_with_lookup({1: LookupResult.HIT, 2: LookupResult.HIT})
813
- assert sched._maximal_prefix_lookup(to_keys([1, 2, 3]), _EMPTY_REQ_CTX) == 2
1004
+ assert _maximal_lookup(sched, to_keys([1, 2, 3])) == 2
1005
1006
def test_miss_then_hit(self):
1007
sched = _make_scheduler_with_lookup({2: LookupResult.HIT})
817
- assert sched._maximal_prefix_lookup(to_keys([1, 2]), _EMPTY_REQ_CTX) == 0
1008
+ assert _maximal_lookup(sched, to_keys([1, 2])) == 0
1009
1010
def test_single_hit(self):
1011
sched = _make_scheduler_with_lookup({1: LookupResult.HIT})
821
- assert sched._maximal_prefix_lookup(to_keys([1]), _EMPTY_REQ_CTX) == 1
1012
+ assert _maximal_lookup(sched, to_keys([1])) == 1
1013
1014
def test_empty(self):
1015
sched = _make_scheduler_with_lookup({})
825
- assert sched._maximal_prefix_lookup([], _EMPTY_REQ_CTX) == 0
1016
+ assert _maximal_lookup(sched, []) == 0
1017
+
1018
+ @pytest.mark.parametrize(
1019
+ "pending_result",
1020
+ [LookupResult.RETRY, LookupResult.HIT_PENDING],
1021
+ )
1022
+ def test_pending_result_is_not_recorded(
1023
+ self,
1024
+ pending_result: LookupResult,
1025
+ ):
1026
+ sched = _make_scheduler_with_lookup({1: pending_result})
1027
+
1028
+ assert _maximal_lookup(sched, to_keys([1])) is None
1029
+ sched._events_tracker.record_lookup.assert_not_called()
1030
1031
def test_retry_defers(self):
1032
+ keys = to_keys([1, 2])
1033
sched = _make_scheduler_with_lookup(
1034
{1: LookupResult.RETRY, 2: LookupResult.HIT}
1035
)
831
- assert sched._maximal_prefix_lookup(to_keys([1, 2]), _EMPTY_REQ_CTX) is None
1036
+ assert _maximal_lookup(sched, keys) is None
1037
assert sched.manager.lookup.call_count == 2
1038
+ sched._events_tracker.record_lookup.assert_called_once_with(
1039
+ _LOOKUP_REQ,
1040
+ _LOOKUP_GROUP_CONFIG,
1041
+ 1,
1042
+ keys[1],
1043
+ )
1044
1045
def test_retry_after_hit_defers(self):
1046
+ keys = to_keys([1, 2])
1047
sched = _make_scheduler_with_lookup(
1048
{1: LookupResult.HIT, 2: LookupResult.RETRY}
1049
)
838
- assert sched._maximal_prefix_lookup(to_keys([1, 2]), _EMPTY_REQ_CTX) is None
1050
+ assert _maximal_lookup(sched, keys) is None
1051
+ sched._events_tracker.record_lookup.assert_called_once_with(
1052
+ _LOOKUP_REQ,
1053
+ _LOOKUP_GROUP_CONFIG,
1054
+ 0,
1055
+ keys[0],
1056
+ )
1057
1058
def test_hit_pending_defers(self):
1059
+ keys = to_keys([1, 2])
1060
sched = _make_scheduler_with_lookup(
1061
{1: LookupResult.HIT_PENDING, 2: LookupResult.HIT}
1062
)
844
- assert sched._maximal_prefix_lookup(to_keys([1, 2]), _EMPTY_REQ_CTX) is None
1063
+ assert _maximal_lookup(sched, keys) is None
1064
assert sched.manager.lookup.call_count == 2
1065
+ sched._events_tracker.record_lookup.assert_called_once_with(
1066
+ _LOOKUP_REQ,
1067
+ _LOOKUP_GROUP_CONFIG,
1068
+ 1,
1069
+ keys[1],
1070
+ )
1071
1072
def test_hit_pending_does_not_stop_scan(self):
1073
"""HIT_PENDING defers but does not break — scan continues until miss."""
1074
sched = _make_scheduler_with_lookup(
1075
{1: LookupResult.HIT_PENDING, 2: LookupResult.MISS, 3: LookupResult.HIT}
1076
)
852
- assert sched._maximal_prefix_lookup(to_keys([1, 2, 3]), _EMPTY_REQ_CTX) is None
1077
+ assert _maximal_lookup(sched, to_keys([1, 2, 3])) is None
1078
assert sched.manager.lookup.call_count == 2
1079
+ sched._events_tracker.record_lookup.assert_not_called()
1080
1081
def test_retry_stops_at_miss(self):
1082
"""RETRY is treated as hit for iteration, but miss stops the scan."""
1083
sched = _make_scheduler_with_lookup(
1084
{1: LookupResult.RETRY, 2: LookupResult.MISS, 3: LookupResult.HIT}
1085
)
860
- assert sched._maximal_prefix_lookup(to_keys([1, 2, 3]), _EMPTY_REQ_CTX) is None
1086
+ assert _maximal_lookup(sched, to_keys([1, 2, 3])) is None
1087
# lookup should have been called for blocks 1 and 2 (stops at miss)
1088
assert sched.manager.lookup.call_count == 2
1089
+ sched._events_tracker.record_lookup.assert_not_called()
1090
1091
1092
class TestSlidingWindowLookup:
@@ -1011,7 +1238,7 @@ def test_request_level_policy_stores_all_blocks(request_runner, async_scheduling
1238
1239
# New request with 2 offloaded chunks; first matches what's in CPU.
1240
runner.new_request(token_ids=[0] * tokens_per_chunk * 2)
1014
- runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 1
1241
+ runner.connector_scheduler._maximal_prefix_lookup = lambda keys, ctx, *_: 1
1242
runner.manager.prepare_store.side_effect = lambda keys, req_context: (
1243
generate_store_output(keys)
1244
)
@@ -1042,7 +1269,7 @@ def test_loads_do_not_populate_fence_index(request_runner):
1269
async_scheduling=False,
1270
)
1271
runner.new_request(token_ids=[0] * 12)
1045
- runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 1
1272
+ runner.connector_scheduler._maximal_prefix_lookup = lambda keys, ctx, *_: 1
1273
runner.run(decoded_tokens=[], complete_transfers=False)
1274
assert runner.connector_scheduler._block_id_to_pending_jobs == {}
1275
@@ -1088,7 +1315,7 @@ def test_fence_at_update_state_after_alloc(request_runner):
1315
1316
runner.scheduler.reset_prefix_cache()
1317
runner.new_request(token_ids=[0] * 4)
1091
- runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 1
1318
+ runner.connector_scheduler._maximal_prefix_lookup = lambda keys, ctx, *_: 1
1319
runner.manager.prepare_store.side_effect = lambda keys, req_context: (
1320
generate_store_output([])
1321
)
@@ -1139,7 +1366,7 @@ def test_fence_at_build_store_jobs(request_runner):
1366
1367
runner.scheduler.reset_prefix_cache()
1368
runner.new_request(token_ids=[1] * 4)
1142
- runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 0
1369
+ runner.connector_scheduler._maximal_prefix_lookup = lambda keys, ctx, *_: 0
1370
runner.manager.prepare_store.side_effect = lambda keys, req_context: (
1371
generate_store_output([])
1372
)
@@ -1365,7 +1592,7 @@ def test_reset_cache(request_runner, async_scheduling: bool):
1592
# Leave the load in-flight so that reset_cache must flush it.
1593
runner.scheduler.reset_prefix_cache()
1594
runner.new_request(token_ids=[0] * tokens_per_chunk)
1368
- runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 1
1595
+ runner.connector_scheduler._maximal_prefix_lookup = lambda keys, ctx, *_: 1
1596
runner.manager.prepare_store.side_effect = lambda keys, req_context: (
1597
generate_store_output([])
1598
)
@@ -1553,9 +1780,7 @@ def test_async_preempt_readmit_before_transfer_output_is_deferred(request_runner
1780
# preemption batch's ModelRunnerOutput is consumed by update_from_output().
1781
free_block_queue.num_free_blocks = num_free_blocks_empty
1782
assert runner.scheduler.reset_prefix_cache()
1556
- runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: len(
1557
- key
1558
- )
1783
+ runner.connector_scheduler._maximal_prefix_lookup = lambda keys, ctx, *_: len(keys)
1784
1785
readmit_output = runner.scheduler.schedule()
1786
@@ -1669,7 +1894,7 @@ def test_swa_alignment_skip(request_runner, async_scheduling: bool):
1894
runner.scheduler.reset_prefix_cache()
1895
runner.new_request(token_ids=[0] * num_tokens + [1])
1896
runner.manager.lookup.return_value = LookupResult.HIT
1672
- runner.connector_scheduler._maximal_prefix_lookup = lambda key, req_context: 2
1897
+ runner.connector_scheduler._maximal_prefix_lookup = lambda keys, ctx, *_: 2
1898
runner.run(
1899
decoded_tokens=[EOS_TOKEN_ID],
1900
# Group 0: full prefix lookup hits 2 offloaded chunks
@@ -1840,6 +2065,13 @@ class TestEagle:
2065
req.request_id = "test-req"
2066
req.num_tokens = num_tokens
2067
req.kv_transfer_params = None
2068
+ num_hash_blocks = max(
2069
+ len(hashes) * scheduler.config.kv_group_configs[idx].hashes_per_chunk
2070
+ for idx, hashes in enumerate(offload_keys_per_group)
2071
+ )
2072
+ req.block_hashes = [BlockHash(str(i).encode()) for i in range(num_hash_blocks)]
2073
+ req.all_token_ids = list(range(num_tokens))
2074
+ req.lora_request = None
2075
2076
state = RequestOffloadState(
2077
config=scheduler.config,
vllm/distributed/kv_transfer/kv_connector/v1/offloading/events.py
+29
-11
@@ -61,9 +61,9 @@ def get_offloading_event_group_spec(
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."""
64
+ """BlockStored payload snapshot for one OffloadKey, captured while the
65
+ Request is available and kept until the matching eviction event. ``medium``
66
+ is forwarded from the OffloadingEvent."""
67
68
# The chunk's constituent block hashes; the last one is the OffloadKey.
69
block_hashes: tuple[BlockHash, ...]
@@ -81,10 +81,11 @@ class _OffloadEventMetadata:
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`.
84
+ The scheduler calls :meth:`record_store` from ``_build_store_jobs`` and
85
+ :meth:`record_lookup` for ready primary-tier hits while the ``Request`` is
86
+ available. Deferred and missing lookups add no state. Under the connector's
87
+ supported success-only transfer model, entries follow primary allocations
88
+ until CPU removal translation or :meth:`reset`.
89
"""
90
91
def __init__(self, config: OffloadingKVEventsConfig):
@@ -93,8 +94,7 @@ class OffloadingEventsTracker:
94
config.enable_kv_cache_events and config.self_describing_kv_events
95
)
96
96
- # OffloadKey -> payload snapshot, kept until the eviction event so
97
- # BlockRemoved can fan out. Bounded: one entry per offloaded chunk.
97
+ # OffloadKey -> payload snapshot, kept until CPU removal or reset.
98
self._pending_event_metadata: dict[OffloadKey, _OffloadEventMetadata] = {}
99
100
def record_store(
@@ -116,6 +116,23 @@ class OffloadingEventsTracker:
116
meta = self._build_event_metadata(req, group_config, chunk_idx)
117
self._pending_event_metadata[offload_key] = meta
118
119
+ def record_lookup(
120
+ self,
121
+ req: Request,
122
+ group_config: "GroupOffloadConfig",
123
+ chunk_idx: int,
124
+ offload_key: OffloadKey,
125
+ ) -> None:
126
+ """Snapshot metadata for a ready primary-tier lookup hit."""
127
+ if not self.self_describing_enabled:
128
+ return
129
+ if group_config.sliding_window_size_in_chunks is not None:
130
+ return
131
+ if offload_key not in self._pending_event_metadata:
132
+ self._pending_event_metadata[offload_key] = self._build_event_metadata(
133
+ req, group_config, chunk_idx
134
+ )
135
+
136
def take_events(self, events: Iterable[OffloadingEvent]) -> Iterable[KVCacheEvent]:
137
"""Translate raw OffloadingEvents into self-describing KV events.
138
@@ -165,7 +182,7 @@ class OffloadingEventsTracker:
182
assert len(chunk_hashes) == hbf
183
184
if group_config.sliding_window_size_in_chunks is not None:
168
- # record_store filters these out before calling this helper.
185
+ # The recording methods filter these out before calling this helper.
186
raise AssertionError("self-describing events only support full attention")
187
188
parent_block_hash: BlockHash | None
@@ -232,7 +249,8 @@ class OffloadingEventsTracker:
249
"OffloadingEventsTracker: no event metadata for "
250
"offload key during BlockStored emission; emitting a "
251
"placeholder payload. Expected for non-full-attention "
235
- "groups; otherwise indicates a missing populate path."
252
+ "groups and promotions not observed as a primary-tier "
253
+ "hit before translation."
254
)
255
yield self._placeholder_stored(key, event.medium, locality)
256
continue
vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py
+20
-4
@@ -463,15 +463,27 @@ class OffloadingConnectorScheduler:
463
del self._req_status[req_id]
464
465
def _maximal_prefix_lookup(
466
- self, keys: Iterable[OffloadKey], req_context: ReqContext
466
+ self,
467
+ keys: Iterable[OffloadKey],
468
+ req_context: ReqContext,
469
+ req: Request,
470
+ group_config: GroupOffloadConfig,
471
+ start_chunk_idx: int,
472
) -> int | None:
473
"""Return the number of consecutive offloaded chunks from the start,
474
or None if the backend deferred a lookup."""
475
hit_count = 0
476
defer_lookup = False
472
- for key in keys:
473
- match self.manager.lookup(key, req_context):
477
+ for local_idx, key in enumerate(keys):
478
+ result = self.manager.lookup(key, req_context)
479
+ match result:
480
case LookupResult.HIT:
481
+ self._events_tracker.record_lookup(
482
+ req,
483
+ group_config,
484
+ start_chunk_idx + local_idx,
485
+ key,
486
+ )
487
hit_count += 1
488
case LookupResult.HIT_PENDING:
489
defer_lookup = True
@@ -616,7 +628,11 @@ class OffloadingConnectorScheduler:
628
num_hit_chunks: int | None
629
if sliding_window_size_in_chunks is None:
630
num_hit_chunks = self._maximal_prefix_lookup(
619
- offload_keys, req_status.req_context
631
+ offload_keys,
632
+ req_status.req_context,
633
+ req_status.req,
634
+ group_config,
635
+ start_chunk_idx,
636
)
637
else:
638
required_window = sliding_window_size_in_chunks
vllm/v1/kv_offload/tiering/spec.py
-8
@@ -136,14 +136,6 @@ class TieringOffloadingSpec(CPUOffloadingSpec):
136
super().__init__(config)
137
# Redeclare for mypy: parent sets this but `--follow-imports skip` hides it
138
self._manager: OffloadingManager | None = None
139
- if self.kv_events_config.self_describing_kv_events:
140
- raise ValueError(
141
- "self_describing_kv_events is not supported by "
142
- "TieringOffloadingSpec. Tier promotions can emit primary-tier "
143
- "store events that do not correspond to GPU store jobs, so the "
144
- "current self-describing side table cannot describe them "
145
- "correctly."
146
- )
139
140
# Parse secondary tier configurations
141
self.secondary_tier_configs = self.extra_config.get("secondary_tiers", [])