[KV Offload] Emit self-describing events for partial recurrent blocks (#51243)
Signed-off-by: chaunceyjiang <chaunceyjiang@gmail.com> Co-authored-by: Or Ozeri <oro@il.ibm.com>
Chauncey committed
Aug 9, 2026 at 16:28 UTC
c4239986427a40389fd79c1a4f6afd48aea6512e
5 files changed
+181
-6
docs/features/kv_offloading_usage.md
+1
-1
@@ -76,7 +76,7 @@ vllm serve <model> \
76
| `max_tracker_size` | no | `64000` | single-tier | Max entries in the lookup tracker. |
77
| `secondary_tiers` | no | `[]` | multi-tier | List of secondary tier configs (see below). |
78
| `offload_prompt_only` | no | `true` | both | If `true`, only prompt (prefill) blocks are offloaded; decode blocks are skipped. |
79
-| `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
+| `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. Partial recurrent tails emit the hash-aligned portion from the physical block start through the tail boundary. Other sliding-window/SSM chunks 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. |
80
| `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). |
81
82
## Custom Eviction Policies
tests/v1/kv_connector/unit/offloading_connector/test_events.py
+52
@@ -208,6 +208,58 @@ def test_take_events_forwards_locality_to_placeholder_store():
208
assert events[0].locality == "REMOTE"
209
210
211
+def test_partial_tail_event_describes_hash_aligned_physical_block_prefix():
212
+ tracker = _tracker()
213
+ group_config = _group_config(block_size=16, blocks_per_chunk=1)._replace(
214
+ hashes_per_chunk=4
215
+ )
216
+ req = _request(block_hashes=[_hash(i) for i in range(8)], token_count=32)
217
+ key = make_offload_key(req.block_hashes[6], group_config.group_idx)
218
+
219
+ tracker.record_partial_store(req, group_config, 28, key)
220
+ [event] = tracker.take_events([_stored_event([key])])
221
+
222
+ assert isinstance(event, BlockStored)
223
+ assert event.block_hashes == [_wire_hash(_hash(i)) for i in range(4, 7)]
224
+ assert event.parent_block_hash == _wire_hash(_hash(3))
225
+ assert event.token_ids == list(range(17, 29))
226
+ assert event.block_size == 4
227
+
228
+
229
+def test_partial_tail_lookup_does_not_overwrite_store_metadata():
230
+ tracker = _tracker()
231
+ group_config = _group_config()
232
+ stored_req = _request(block_hashes=[_hash(0)], token_count=4)
233
+ lookup_req = _request(block_hashes=[_hash(0)], token_count=4)
234
+ lookup_req.all_token_ids = [9, 9, 9, 9]
235
+ key = make_offload_key(stored_req.block_hashes[0], group_config.group_idx)
236
+
237
+ tracker.record_partial_store(stored_req, group_config, 4, key)
238
+ tracker.record_partial_lookup(lookup_req, group_config, 4, key)
239
+ [event] = tracker.take_events([_stored_event([key])])
240
+
241
+ assert isinstance(event, BlockStored)
242
+ assert event.token_ids == [1, 2, 3, 4]
243
+
244
+
245
+@pytest.mark.parametrize(
246
+ "record_method", ["record_partial_store", "record_partial_lookup"]
247
+)
248
+def test_partial_tail_sliding_window_event_uses_placeholder(record_method):
249
+ tracker = _tracker()
250
+ group_config = _group_config(sliding_window_size_in_chunks=1)
251
+ req = _request(block_hashes=[_hash(0)], token_count=4)
252
+ key = make_offload_key(req.block_hashes[0], group_config.group_idx)
253
+
254
+ getattr(tracker, record_method)(req, group_config, 4, key)
255
+ [event] = tracker.take_events([_stored_event([key])])
256
+
257
+ assert isinstance(event, BlockStored)
258
+ assert event.block_hashes == [_wire_hash(_hash(0))]
259
+ assert event.token_ids == []
260
+ assert event.block_size == 0
261
+
262
+
263
def test_take_events_forwards_locality_to_remove():
264
tracker = _tracker()
265
req = _request(block_hashes=[_hash(0)], token_count=4)
tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py
+35
-1
@@ -16,6 +16,7 @@ from tests.v1.kv_connector.unit.offloading_connector.utils import (
16
to_keys,
17
)
18
from tests.v1.kv_connector.unit.utils import EOS_TOKEN_ID
19
+from vllm.config import KVEventsConfig
20
from vllm.distributed.kv_events import MEDIUM_CPU, BlockRemoved, BlockStored
21
from vllm.distributed.kv_transfer.kv_connector.v1.offloading.common import (
22
OffloadingConnectorMetadata,
@@ -60,9 +61,12 @@ from vllm.v1.request import RequestStatus
61
62
63
def _make_partial_tail_scheduler() -> OffloadingConnectorScheduler:
63
- vllm_config = _make_vllm_config()
64
+ vllm_config = _make_vllm_config(extra_config={"self_describing_kv_events": True})
65
vllm_config.cache_config.prefix_match_unit = 4
66
vllm_config.speculative_config = None
67
+ vllm_config.kv_events_config = KVEventsConfig(
68
+ enable_kv_cache_events=True, publisher="null"
69
+ )
70
kv_cache_config = _make_mamba_hybrid_kv_cache_config()
71
spec = MockOffloadingSpec(build_offloading_config(vllm_config, kv_cache_config))
72
return OffloadingConnectorScheduler(spec, vllm_config, kv_cache_config)
@@ -77,6 +81,8 @@ def _make_partial_tail_request(
81
request.num_prompt_tokens = 30
82
request.num_tokens = 30
83
request.block_hashes = [BlockHash(f"h{i}".encode()) for i in range(7)]
84
+ request.all_token_ids = list(range(30))
85
+ request.lora_request = None
86
request.is_finished.return_value = False
87
scheduler.on_new_request(request)
88
return request
@@ -119,6 +125,34 @@ def test_partial_tail_store_uses_attention_and_recurrent_cow_sources():
125
12: {job_id},
126
99: {job_id},
127
}
128
+ assert scheduler.config.supports_partial_tail
129
+
130
+ events = list(
131
+ scheduler._events_tracker.take_events(
132
+ [
133
+ OffloadingEvent(
134
+ keys=list(scheduler._jobs[job_id].keys),
135
+ medium=Medium.CPU,
136
+ removed=False,
137
+ )
138
+ ]
139
+ )
140
+ )
141
+ assert len(events) == 2
142
+ assert all(isinstance(event, BlockStored) for event in events)
143
+ assert {event.group_idx for event in events} == {0, 1}
144
+ events_by_group = {event.group_idx: event for event in events}
145
+ full_attention_event = events_by_group[0]
146
+ assert full_attention_event.block_size == 4
147
+ assert full_attention_event.token_ids == list(range(16, 28))
148
+ assert len(full_attention_event.block_hashes) == 3
149
+ assert full_attention_event.parent_block_hash is not None
150
+
151
+ recurrent_event = events_by_group[1]
152
+ assert recurrent_event.block_size == 0
153
+ assert recurrent_event.token_ids == []
154
+ assert len(recurrent_event.block_hashes) == 1
155
+ assert recurrent_event.parent_block_hash is None
156
157
158
def test_partial_lookup_returns_exact_boundary_and_group_load_keys():
vllm/distributed/kv_transfer/kv_connector/v1/offloading/events.py
+79
@@ -145,6 +145,85 @@ class OffloadingEventsTracker:
145
req, group_config, chunk_idx
146
)
147
148
+ def record_partial_store(
149
+ self,
150
+ req: Request,
151
+ group_config: "GroupOffloadConfig",
152
+ boundary_tokens: int,
153
+ offload_key: OffloadKey,
154
+ ) -> None:
155
+ """Snapshot metadata for a newly stored partial recurrent tail."""
156
+ if group_config.sliding_window_size_in_chunks is not None:
157
+ return
158
+ self._record_partial_tail(req, group_config, boundary_tokens, offload_key)
159
+
160
+ def record_partial_lookup(
161
+ self,
162
+ req: Request,
163
+ group_config: "GroupOffloadConfig",
164
+ boundary_tokens: int,
165
+ offload_key: OffloadKey,
166
+ ) -> None:
167
+ """Backfill metadata for a partial recurrent tail lookup hit."""
168
+ if group_config.sliding_window_size_in_chunks is not None:
169
+ return
170
+ if offload_key not in self._pending_event_metadata:
171
+ self._record_partial_tail(req, group_config, boundary_tokens, offload_key)
172
+
173
+ def _record_partial_tail(
174
+ self,
175
+ req: Request,
176
+ group_config: "GroupOffloadConfig",
177
+ boundary_tokens: int,
178
+ offload_key: OffloadKey,
179
+ ) -> None:
180
+ """Build metadata for the valid prefix of one physical cache block.
181
+
182
+ A partial recurrent tail ends on a hash boundary but before its
183
+ physical cache block is full. The event describes only the valid
184
+ hashes and tokens, not the unused remainder of that physical block.
185
+ """
186
+ if not self.self_describing_enabled:
187
+ return
188
+
189
+ tokens_per_hash = group_config.tokens_per_chunk // group_config.hashes_per_chunk
190
+ # Subtract one so the boundary token itself cannot select the next
191
+ # physical block when the boundary lies exactly on a block edge.
192
+ chunk_start = (
193
+ (boundary_tokens - 1) // group_config.tokens_per_chunk
194
+ ) * group_config.tokens_per_chunk
195
+ first_hash_idx = chunk_start // tokens_per_hash
196
+ last_hash_idx = boundary_tokens // tokens_per_hash
197
+ assert chunk_start < boundary_tokens
198
+ assert boundary_tokens % tokens_per_hash == 0
199
+ assert last_hash_idx <= len(req.block_hashes)
200
+
201
+ # Unlike a complete chunk, a partial tail contains only the hashes
202
+ # between its physical block start and its valid token boundary.
203
+ maybe_block_hashes = req.block_hashes[first_hash_idx:last_hash_idx]
204
+ block_hashes = tuple(
205
+ block_hash for block_hash in maybe_block_hashes if block_hash is not None
206
+ )
207
+ assert block_hashes and len(block_hashes) == len(maybe_block_hashes)
208
+ parent_block_hash = (
209
+ req.block_hashes[first_hash_idx - 1] if first_hash_idx > 0 else None
210
+ )
211
+ assert first_hash_idx == 0 or parent_block_hash is not None
212
+
213
+ lora_id = req.lora_request.adapter_id if req.lora_request is not None else None
214
+ lora_name = req.lora_request.name if req.lora_request is not None else None
215
+ self._pending_event_metadata[offload_key] = _OffloadEventMetadata(
216
+ block_hashes=block_hashes,
217
+ parent_block_hash=parent_block_hash,
218
+ token_ids=tuple(req.all_token_ids[chunk_start:boundary_tokens]),
219
+ block_size=tokens_per_hash,
220
+ lora_id=lora_id,
221
+ lora_name=lora_name,
222
+ extra_keys=None,
223
+ group_idx=group_config.group_idx,
224
+ kv_cache_spec=group_config.kv_event_group_spec,
225
+ )
226
+
227
def take_events(self, events: Iterable[OffloadingEvent]) -> Iterable[KVCacheEvent]:
228
"""Translate raw OffloadingEvents into self-describing KV events.
229
vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py
+14
-4
@@ -280,10 +280,6 @@ class SchedulerOffloadConfig(NamedTuple):
280
spec.blocks_per_chunk == 1
281
and len(group_block_sizes) == 1
282
and has_partial_recurrent_group
283
- and not (
284
- spec.kv_events_config.enable_kv_cache_events
285
- and spec.kv_events_config.self_describing_kv_events
286
- )
283
and all(
284
config.sliding_window_size_in_chunks is None
285
or config.requires_cow_source
@@ -897,10 +893,12 @@ class OffloadingConnectorScheduler:
893
for boundary in range(max_boundary, complete_boundary, -tokens_per_hash):
894
boundary_pending = False
895
boundary_missed = False
896
+ boundary_keys = []
897
for group_config in self.config.kv_group_configs:
898
key = self._make_boundary_key(
899
req_status.req, group_config.group_idx, boundary
900
)
901
+ boundary_keys.append(key)
902
result = self.manager.lookup(key, req_status.req_context)
903
if result is LookupResult.MISS:
904
boundary_missed = True
@@ -910,6 +908,12 @@ class OffloadingConnectorScheduler:
908
909
pending |= boundary_pending
910
if not boundary_missed and not boundary_pending:
911
+ for group_config, key in zip(
912
+ self.config.kv_group_configs, boundary_keys
913
+ ):
914
+ self._events_tracker.record_partial_lookup(
915
+ req_status.req, group_config, boundary, key
916
+ )
917
req_status.partial_tail_boundary = boundary
918
return boundary - local_tokens
919
@@ -1203,6 +1207,12 @@ class OffloadingConnectorScheduler:
1207
if not store_output.keys_to_store:
1208
continue
1209
1210
+ for group_config, key in zip(self.config.kv_group_configs, keys):
1211
+ if key in store_output.keys_to_store:
1212
+ self._events_tracker.record_partial_store(
1213
+ req, group_config, boundary, key
1214
+ )
1215
+
1216
group_by_key = {key: idx for idx, key in enumerate(keys)}
1217
accepted_groups = [group_by_key[key] for key in store_output.keys_to_store]
1218
group_sizes = [0] * len(self.config.kv_group_configs)