[Bugfix][KV Connector] Populate SimpleCPUOffload BlockStored metadata (#54325)

mevince committed Sep 3, 2026 at 08:58 UTC 4ae6228284c4e9776f62d0867f543e621864176c
4 files changed +1133 -29
tests/v1/kv_connector/unit/test_simple_cpu_offload_connector.py new
+92
@@ -0,0 +1,92 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Unit tests for SimpleCPUOffloadConnector-level behavior."""
4 +
5 +from __future__ import annotations
6 +
7 +import pytest
8 +
9 +from tests.v1.kv_connector.unit.utils import create_vllm_config
10 +from tests.v1.simple_kv_offload.test_scheduler import (
11 + _BYTES_PER_BLOCK,
12 + BLOCK_SIZE,
13 + DTYPE,
14 + HEAD_SIZE,
15 + NUM_KV_HEADS,
16 +)
17 +from vllm.distributed.kv_transfer.kv_connector.v1.base import KVConnectorRole
18 +from vllm.distributed.kv_transfer.kv_connector.v1.simple_cpu_offload_connector import (
19 + SimpleCPUOffloadConnector,
20 +)
21 +from vllm.v1.kv_cache_interface import (
22 + FullAttentionSpec,
23 + KVCacheConfig,
24 + KVCacheGroupSpec,
25 + KVCacheTensor,
26 +)
27 +
28 +
29 +def _make_kv_cache_config(num_blocks: int = 16) -> KVCacheConfig:
30 + fa_layers = ["layer0"]
31 + spec = FullAttentionSpec(
32 + block_size=BLOCK_SIZE,
33 + num_kv_heads=NUM_KV_HEADS,
34 + head_size=HEAD_SIZE,
35 + dtype=DTYPE,
36 + )
37 + groups = [KVCacheGroupSpec(fa_layers, spec)]
38 + size = _BYTES_PER_BLOCK * num_blocks
39 + tensors = [
40 + KVCacheTensor(
41 + size=size,
42 + layers=fa_layers,
43 + layer_stride=size,
44 + block_stride=_BYTES_PER_BLOCK,
45 + )
46 + ]
47 + return KVCacheConfig(
48 + num_blocks=num_blocks,
49 + kv_cache_tensors=tensors,
50 + kv_cache_groups=groups,
51 + )
52 +
53 +
54 +def _make_connector(
55 + extra_config: dict | None = None,
56 + num_gpu_blocks: int = 16,
57 + cpu_bytes: int = _BYTES_PER_BLOCK * 8,
58 +) -> SimpleCPUOffloadConnector:
59 + kv_cache_config = _make_kv_cache_config(num_gpu_blocks)
60 + config_dict = {
61 + "cpu_bytes_to_use": cpu_bytes,
62 + }
63 + if extra_config:
64 + config_dict.update(extra_config)
65 + vllm_config = create_vllm_config(
66 + kv_connector="SimpleCPUOffloadConnector",
67 + kv_role="kv_both",
68 + kv_connector_extra_config=config_dict,
69 + )
70 + connector = SimpleCPUOffloadConnector(
71 + vllm_config=vllm_config,
72 + role=KVConnectorRole.SCHEDULER,
73 + kv_cache_config=kv_cache_config,
74 + )
75 + return connector
76 +
77 +
78 +def test_disk_mode_rejects_non_positive_capacity() -> None:
79 + """Disk mode with disk_capacity_bytes <= 0 raises ValueError.
80 +
81 + Without this guard, the worker operates in disk mode but the scheduler
82 + classifies events as MEDIUM_CPU instead of MEDIUM_STORAGE, silently
83 + mislabeling events.
84 + """
85 + with pytest.raises(ValueError, match="disk_capacity_bytes > 0"):
86 + _make_connector(
87 + extra_config={
88 + "kv_offload_backend": "disk",
89 + "disk_path": "/tmp/fake",
90 + "disk_capacity_bytes": 0,
91 + }
92 + )
tests/v1/simple_kv_offload/test_kv_events.py new
+790
@@ -0,0 +1,790 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""KV cache event emission tests for SimpleCPUOffloadScheduler.
4 +
5 +Verifies that BlockStored/BlockRemoved events carry the correct storage
6 +medium (MEDIUM_CPU / MEDIUM_STORAGE) and per-group metadata when
7 +``enable_kv_cache_events`` is set, and that no events are emitted when it
8 +is unset.
9 +"""
10 +
11 +from __future__ import annotations
12 +
13 +from tests.v1.simple_kv_offload.test_scheduler import (
14 + _BYTES_PER_BLOCK,
15 + BLOCK_SIZE,
16 + DTYPE,
17 + HEAD_SIZE,
18 + NUM_KV_HEADS,
19 + SchedulerFixture,
20 + _alloc_and_register,
21 + _allocate_cp_gpu_blocks,
22 + _make_cp_request,
23 + _make_kv_cache_config,
24 + _make_vllm_config,
25 + make_request,
26 + make_scheduler_output,
27 + simulate_store_completion,
28 +)
29 +from vllm import SamplingParams
30 +from vllm.config.kv_events import KVEventsConfig
31 +from vllm.distributed.kv_events import (
32 + MEDIUM_CPU,
33 + MEDIUM_STORAGE,
34 + BlockRemoved,
35 + BlockStored,
36 +)
37 +from vllm.lora.request import LoRARequest
38 +from vllm.utils.hashing import sha256
39 +from vllm.v1.core.block_pool import BlockPool
40 +from vllm.v1.core.kv_cache_manager import KVCacheBlocks
41 +from vllm.v1.core.kv_cache_utils import (
42 + get_block_hash,
43 + get_request_block_hasher,
44 + make_block_hash_with_group_id,
45 + maybe_convert_block_hash,
46 +)
47 +from vllm.v1.core.single_type_kv_cache_manager import register_all_kvcache_specs
48 +from vllm.v1.kv_cache_interface import (
49 + FullAttentionSpec,
50 + KVCacheConfig,
51 + KVCacheGroupSpec,
52 + KVCacheTensor,
53 + MambaSpec,
54 + SlidingWindowSpec,
55 +)
56 +from vllm.v1.request import Request
57 +from vllm.v1.simple_kv_offload.manager import (
58 + SimpleCPUOffloadScheduler,
59 + StoreRequestState,
60 +)
61 +
62 +
63 +def _make_mixed_kv_cache_config(
64 + num_blocks: int,
65 + sliding_window: int | None = None,
66 + with_mamba: bool = False,
67 + mamba_block_size: int = BLOCK_SIZE,
68 +) -> KVCacheConfig:
69 + """Build a KVCacheConfig with a FullAttention group plus optional
70 + SlidingWindow and Mamba groups for per-group metadata tests."""
71 + register_all_kvcache_specs(vllm_config=None)
72 + groups = []
73 + tensors = []
74 +
75 + # Group 0 is always FullAttention (the scheduler requires one FA group).
76 + fa_layers = ["layer_fa"]
77 + fa_spec = FullAttentionSpec(
78 + block_size=BLOCK_SIZE,
79 + num_kv_heads=NUM_KV_HEADS,
80 + head_size=HEAD_SIZE,
81 + dtype=DTYPE,
82 + )
83 + groups.append(KVCacheGroupSpec(fa_layers, fa_spec))
84 + fa_bytes = _BYTES_PER_BLOCK * num_blocks
85 + tensors.append(
86 + KVCacheTensor(
87 + size=fa_bytes,
88 + layers=fa_layers,
89 + layer_stride=fa_bytes,
90 + block_stride=_BYTES_PER_BLOCK,
91 + )
92 + )
93 +
94 + if sliding_window is not None:
95 + sw_layers = ["layer_sw"]
96 + sw_spec = SlidingWindowSpec(
97 + block_size=BLOCK_SIZE,
98 + num_kv_heads=NUM_KV_HEADS,
99 + head_size=HEAD_SIZE,
100 + dtype=DTYPE,
101 + sliding_window=sliding_window,
102 + )
103 + groups.append(KVCacheGroupSpec(sw_layers, sw_spec))
104 + sw_bytes = _BYTES_PER_BLOCK * num_blocks
105 + tensors.append(
106 + KVCacheTensor(
107 + size=sw_bytes,
108 + layers=sw_layers,
109 + layer_stride=sw_bytes,
110 + block_stride=_BYTES_PER_BLOCK,
111 + )
112 + )
113 +
114 + if with_mamba:
115 + m_layers = ["layer_m"]
116 + m_spec = MambaSpec(
117 + block_size=mamba_block_size,
118 + shapes=((NUM_KV_HEADS, HEAD_SIZE, HEAD_SIZE),),
119 + dtypes=(DTYPE,),
120 + mamba_cache_mode="align",
121 + )
122 + groups.append(KVCacheGroupSpec(m_layers, m_spec))
123 + m_bytes = _BYTES_PER_BLOCK * num_blocks
124 + tensors.append(
125 + KVCacheTensor(
126 + size=m_bytes,
127 + layers=m_layers,
128 + layer_stride=m_bytes,
129 + block_stride=_BYTES_PER_BLOCK,
130 + )
131 + )
132 +
133 + return KVCacheConfig(
134 + num_blocks=num_blocks,
135 + kv_cache_tensors=tensors,
136 + kv_cache_groups=groups,
137 + )
138 +
139 +
140 +def make_events_scheduler(
141 + num_cpu_blocks: int = 8,
142 + num_gpu_blocks: int = 16,
143 + num_groups: int = 1,
144 + lazy: bool = False,
145 + disk_capacity_bytes: int = 0,
146 + dcp_world_size: int = 1,
147 + kv_cache_config: KVCacheConfig | None = None,
148 + enable_events: bool = True,
149 +) -> SchedulerFixture:
150 + """Build a SimpleCPUOffloadScheduler with kv cache events enabled."""
151 + if kv_cache_config is None:
152 + kv_cache_config = _make_kv_cache_config(num_gpu_blocks, num_groups)
153 + vllm_config = _make_vllm_config()
154 + if enable_events:
155 + vllm_config.kv_events_config = KVEventsConfig(enable_kv_cache_events=True)
156 + vllm_config.parallel_config.decode_context_parallel_size = dcp_world_size
157 +
158 + virtual_block_size = BLOCK_SIZE * dcp_world_size
159 + cpu_capacity_bytes = _BYTES_PER_BLOCK * num_cpu_blocks * num_groups
160 +
161 + sched = SimpleCPUOffloadScheduler(
162 + vllm_config=vllm_config,
163 + kv_cache_config=kv_cache_config,
164 + cpu_capacity_bytes=cpu_capacity_bytes,
165 + scheduler_block_size=virtual_block_size,
166 + hash_block_size=virtual_block_size,
167 + lazy_offload=lazy,
168 + disk_capacity_bytes=disk_capacity_bytes,
169 + )
170 +
171 + gpu_block_pool = BlockPool(
172 + num_gpu_blocks=num_gpu_blocks,
173 + enable_caching=True,
174 + hash_block_size=virtual_block_size,
175 + )
176 + sched.bind_gpu_block_pool(gpu_block_pool)
177 + return SchedulerFixture(
178 + scheduler=sched,
179 + gpu_block_pool=gpu_block_pool,
180 + vllm_config=vllm_config,
181 + kv_cache_config=kv_cache_config,
182 + num_groups=num_groups,
183 + )
184 +
185 +
186 +def _do_eager_store(
187 + fix: SchedulerFixture,
188 + num_blocks: int = 2,
189 + lora_request: LoRARequest | None = None,
190 +) -> tuple[object, Request]:
191 + """Run an eager store of ``num_blocks`` and complete it.
192 +
193 + Returns (meta, req) for further assertions.
194 + """
195 + sched = fix.scheduler
196 + if lora_request is not None:
197 + # lora_request must be set in the constructor so block_hashes
198 + # (computed in __init__) include the lora extra_keys.
199 + req = Request(
200 + request_id="req-lora",
201 + prompt_token_ids=list(range(num_blocks * BLOCK_SIZE + 1)),
202 + sampling_params=SamplingParams(max_tokens=1),
203 + pooling_params=None,
204 + mm_features=None,
205 + lora_request=lora_request,
206 + block_hasher=get_request_block_hasher(BLOCK_SIZE, sha256),
207 + )
208 + else:
209 + req = make_request(num_blocks=num_blocks)
210 + kv_blocks = _alloc_and_register(fix, req, num_blocks)
211 + sched.update_state_after_alloc(req, kv_blocks, num_external_tokens=0)
212 + block_ids = kv_blocks.get_block_ids()
213 + sched_out = make_scheduler_output(
214 + {req.request_id: num_blocks * BLOCK_SIZE},
215 + new_reqs={req.request_id: block_ids},
216 + )
217 + meta = sched.build_connector_meta(sched_out)
218 + assert meta.store_event >= 0
219 + simulate_store_completion(sched, meta.store_event)
220 + return meta, req
221 +
222 +
223 +# ---------------------------------------------------------------------------
224 +# events disabled -> no events
225 +# ---------------------------------------------------------------------------
226 +def test_events_disabled_no_events() -> None:
227 + """When enable_kv_cache_events is False, take_events returns nothing."""
228 + fix = make_events_scheduler(enable_events=False)
229 + sched = fix.scheduler
230 + assert sched.enable_kv_cache_events is False
231 + _do_eager_store(fix, num_blocks=2)
232 + events = list(sched.take_events())
233 + assert events == [], f"expected no events, got {events}"
234 +
235 +
236 +# ---------------------------------------------------------------------------
237 +# eager store emits BlockStored with per-group metadata
238 +# ---------------------------------------------------------------------------
239 +def test_block_stored_per_group_metadata_full_attention() -> None:
240 + """BlockStored carries medium, group_idx, kind, sliding_window, block_size,
241 + and the correct block hash."""
242 + fix = make_events_scheduler()
243 + sched = fix.scheduler
244 + assert sched.kv_event_medium == MEDIUM_CPU
245 + _, req = _do_eager_store(fix, num_blocks=2)
246 + events = list(sched.take_events())
247 + stored = [e for e in events if isinstance(e, BlockStored)]
248 + assert len(stored) == 2
249 + ev = stored[0]
250 + assert ev.medium == MEDIUM_CPU
251 + assert ev.group_idx == 0
252 + assert ev.kv_cache_spec_kind == "full_attention"
253 + assert ev.kv_cache_spec_sliding_window is None
254 + assert ev.block_size == BLOCK_SIZE
255 + assert ev.locality == "LOCAL"
256 + expected_hash_0 = maybe_convert_block_hash(
257 + get_block_hash(make_block_hash_with_group_id(req.block_hashes[0], 0))
258 + )
259 + assert ev.block_hashes == [expected_hash_0]
260 + assert ev.parent_block_hash is None
261 + assert ev.token_ids == req.prompt_token_ids[0:BLOCK_SIZE]
262 +
263 + # Second block's parent_block_hash equals the first block's hash.
264 + ev1 = stored[1]
265 + expected_hash_1 = maybe_convert_block_hash(
266 + get_block_hash(make_block_hash_with_group_id(req.block_hashes[1], 0))
267 + )
268 + assert ev1.block_hashes == [expected_hash_1]
269 + assert ev1.parent_block_hash == expected_hash_0
270 + assert ev1.token_ids == req.prompt_token_ids[BLOCK_SIZE : 2 * BLOCK_SIZE]
271 +
272 +
273 +def test_eager_store_lora_metadata() -> None:
274 + """BlockStored carries lora_id and lora_name from req.lora_request."""
275 + fix = make_events_scheduler()
276 + sched = fix.scheduler
277 +
278 + _do_eager_store(
279 + fix,
280 + num_blocks=1,
281 + lora_request=LoRARequest(
282 + lora_name="test-lora",
283 + lora_int_id=42,
284 + lora_path="/fake/lora/path",
285 + ),
286 + )
287 +
288 + events = list(sched.take_events())
289 + stored = [e for e in events if isinstance(e, BlockStored)]
290 + assert len(stored) == 1
291 + ev = stored[0]
292 + assert ev.lora_id == 42, f"expected lora_id=42, got {ev.lora_id}"
293 + assert ev.lora_name == "test-lora", (
294 + f"expected lora_name='test-lora', got {ev.lora_name!r}"
295 + )
296 + assert ev.extra_keys is not None, "expected extra_keys for lora request"
297 + assert any("test-lora" in keys for keys in ev.extra_keys), (
298 + f"expected 'test-lora' in extra_keys, got {ev.extra_keys}"
299 + )
300 + assert len(ev.token_ids) == BLOCK_SIZE
301 +
302 +
303 +# ---------------------------------------------------------------------------
304 +# disk mode -> MEDIUM_STORAGE
305 +# ---------------------------------------------------------------------------
306 +def test_disk_mode_medium_storage_on_block_stored() -> None:
307 + """disk_capacity_bytes>0 sets kv_event_medium=STORAGE on BlockStored."""
308 + fix = make_events_scheduler(
309 + disk_capacity_bytes=_BYTES_PER_BLOCK * 8,
310 + enable_events=True,
311 + )
312 + sched = fix.scheduler
313 + assert sched.kv_event_medium == MEDIUM_STORAGE
314 + _do_eager_store(fix, num_blocks=1)
315 + events = list(sched.take_events())
316 + stored = [e for e in events if isinstance(e, BlockStored)]
317 + assert len(stored) == 1
318 + assert stored[0].medium == MEDIUM_STORAGE
319 + assert stored[0].locality == "LOCAL"
320 +
321 +
322 +def test_finished_eager_store_emits_all_storage_events() -> None:
323 + """Finished stores preserve and announce every registered hash."""
324 + fix = make_events_scheduler(
325 + disk_capacity_bytes=_BYTES_PER_BLOCK * 8,
326 + enable_events=True,
327 + )
328 + sched = fix.scheduler
329 + req = make_request(num_blocks=2)
330 + kv_blocks = _alloc_and_register(fix, req, num_blocks=2)
331 + gpu_blocks = kv_blocks.blocks[0]
332 + primary_hashes = []
333 + for gpu_block in gpu_blocks:
334 + assert gpu_block.block_hash is not None
335 + primary_hashes.append(gpu_block.block_hash)
336 +
337 + fine_grained_req = Request(
338 + request_id="req-finished-fine-grained-hash",
339 + prompt_token_ids=req.prompt_token_ids,
340 + sampling_params=req.sampling_params,
341 + pooling_params=None,
342 + mm_features=None,
343 + block_hasher=get_request_block_hasher(BLOCK_SIZE // 2, sha256),
344 + )
345 + secondary_hash = make_block_hash_with_group_id(fine_grained_req.block_hashes[0], 0)
346 + fix.gpu_block_pool._insert_block_hash(
347 + secondary_hash, gpu_blocks[0], num_tokens=BLOCK_SIZE // 2
348 + )
349 +
350 + sched.update_state_after_alloc(req, kv_blocks, num_external_tokens=0)
351 + sched.request_finished_all_groups(req, kv_blocks.get_block_ids())
352 + meta = sched.build_connector_meta(make_scheduler_output({}))
353 + assert meta.store_event >= 0
354 + simulate_store_completion(sched, meta.store_event)
355 +
356 + primary_cpu_block = sched.cpu_block_pool.cached_block_hash_to_block.get_one_block(
357 + primary_hashes[0]
358 + )
359 + secondary_cpu_block = sched.cpu_block_pool.cached_block_hash_to_block.get_one_block(
360 + secondary_hash
361 + )
362 + assert primary_cpu_block is not None
363 + assert secondary_cpu_block is primary_cpu_block
364 +
365 + stored = [e for e in sched.take_events() if isinstance(e, BlockStored)]
366 + primary_event_hashes = [
367 + maybe_convert_block_hash(get_block_hash(block_hash))
368 + for block_hash in primary_hashes
369 + ]
370 + secondary_event_hash = maybe_convert_block_hash(get_block_hash(secondary_hash))
371 + by_hash = {event.block_hashes[0]: event for event in stored}
372 + assert set(by_hash) == {*primary_event_hashes, secondary_event_hash}
373 + assert (
374 + by_hash[primary_event_hashes[0]].token_ids == req.prompt_token_ids[:BLOCK_SIZE]
375 + )
376 + assert (
377 + by_hash[primary_event_hashes[1]].token_ids
378 + == req.prompt_token_ids[BLOCK_SIZE : 2 * BLOCK_SIZE]
379 + )
380 + assert by_hash[primary_event_hashes[1]].parent_block_hash == primary_event_hashes[0]
381 + secondary_event = by_hash[secondary_event_hash]
382 + assert secondary_event.medium == MEDIUM_STORAGE
383 + assert secondary_event.block_size == 0
384 + assert secondary_event.token_ids == []
385 + assert secondary_event.parent_block_hash is None
386 +
387 + sched.cpu_block_pool.evict_blocks({primary_cpu_block.block_id})
388 + removed = [
389 + event for event in sched.take_events() if isinstance(event, BlockRemoved)
390 + ]
391 + assert {event.block_hashes[0] for event in removed} == {
392 + primary_event_hashes[0],
393 + secondary_event_hash,
394 + }
395 + assert all(event.medium == MEDIUM_STORAGE for event in removed)
396 +
397 +
398 +def test_secondary_hash_block_stored_metadata() -> None:
399 + """Secondary hashes carry their own token range and block size."""
400 + fix = make_events_scheduler(
401 + disk_capacity_bytes=_BYTES_PER_BLOCK * 8,
402 + enable_events=True,
403 + )
404 + sched = fix.scheduler
405 + sched.hash_block_size = BLOCK_SIZE // 2
406 + req = Request(
407 + request_id="req-secondary-hash",
408 + prompt_token_ids=list(range(BLOCK_SIZE + 1)),
409 + sampling_params=SamplingParams(max_tokens=1),
410 + pooling_params=None,
411 + mm_features=None,
412 + block_hasher=get_request_block_hasher(BLOCK_SIZE // 2, sha256),
413 + )
414 + req.num_computed_tokens = BLOCK_SIZE
415 + gpu_block = fix.gpu_block_pool.get_new_blocks(1)[0]
416 + primary_hash = make_block_hash_with_group_id(req.block_hashes[1], 0)
417 + secondary_hash = make_block_hash_with_group_id(req.block_hashes[0], 0)
418 + fix.gpu_block_pool._insert_block_hash(
419 + primary_hash, gpu_block, num_tokens=BLOCK_SIZE
420 + )
421 + fix.gpu_block_pool._insert_block_hash(
422 + secondary_hash, gpu_block, num_tokens=BLOCK_SIZE // 2
423 + )
424 + state = StoreRequestState(
425 + request=req,
426 + block_ids=([gpu_block.block_id],),
427 + num_stored_blocks=[0],
428 + )
429 +
430 + gpu_block_ids, advanced_per_group, block_meta = sched._select_eager_blocks_to_store(
431 + state,
432 + ([gpu_block.block_id],),
433 + )
434 + assert gpu_block_ids == [gpu_block.block_id]
435 + assert advanced_per_group == [1]
436 + assert block_meta is not None
437 + assert set(block_meta[0]) == {primary_hash, secondary_hash}
438 +
439 + cpu_block = sched.cpu_block_pool.get_new_blocks(1)[0]
440 + sched._process_store_completion(
441 + gpu_block_ids,
442 + [cpu_block.block_id],
443 + block_meta,
444 + )
445 +
446 + primary_event_hash = maybe_convert_block_hash(get_block_hash(primary_hash))
447 + secondary_event_hash = maybe_convert_block_hash(get_block_hash(secondary_hash))
448 + stored = [event for event in sched.take_events() if isinstance(event, BlockStored)]
449 + by_hash = {event.block_hashes[0]: event for event in stored}
450 + assert set(by_hash) == {primary_event_hash, secondary_event_hash}
451 + assert by_hash[primary_event_hash].block_size == BLOCK_SIZE
452 + assert by_hash[primary_event_hash].token_ids == req.prompt_token_ids[:BLOCK_SIZE]
453 + assert by_hash[secondary_event_hash].block_size == BLOCK_SIZE // 2
454 + assert (
455 + by_hash[secondary_event_hash].token_ids
456 + == req.prompt_token_ids[: BLOCK_SIZE // 2]
457 + )
458 +
459 + primary_cpu_block = sched.cpu_block_pool.cached_block_hash_to_block.get_one_block(
460 + primary_hash
461 + )
462 + secondary_cpu_block = sched.cpu_block_pool.cached_block_hash_to_block.get_one_block(
463 + secondary_hash
464 + )
465 + assert primary_cpu_block is not None
466 + assert secondary_cpu_block is primary_cpu_block
467 +
468 +
469 +# ---------------------------------------------------------------------------
470 +# BlockRemoved relabeled to correct medium
471 +# ---------------------------------------------------------------------------
472 +def test_block_removed_relabeled_medium_cpu() -> None:
473 + """Evicted BlockRemoved (emitted as GPU by BlockPool) is relabeled CPU."""
474 + # 5 total = 4 usable (null_block takes 1). Fill 4, then store 2 more to
475 + # evict 2 LRU blocks, producing BlockRemoved events.
476 + fix = make_events_scheduler(num_cpu_blocks=5, num_gpu_blocks=16)
477 + sched = fix.scheduler
478 +
479 + # Fill CPU with 4 blocks (2 + 2).
480 + _do_eager_store(fix, num_blocks=2)
481 + # Drain store events so they don't clutter the eviction check.
482 + list(sched.take_events())
483 + _do_eager_store(fix, num_blocks=2)
484 + list(sched.take_events())
485 +
486 + # Now store 2 more -> evicts 2 LRU blocks -> BlockRemoved emitted by pool
487 + # with MEDIUM_GPU, then relabeled by scheduler.take_events().
488 + _do_eager_store(fix, num_blocks=2)
489 + events = list(sched.take_events())
490 +
491 + removed = [e for e in events if isinstance(e, BlockRemoved)]
492 + assert len(removed) >= 2, (
493 + f"expected >=2 BlockRemoved from eviction, got {len(removed)}"
494 + )
495 + for ev in removed:
496 + assert ev.medium == MEDIUM_CPU, (
497 + f"BlockRemoved should be relabeled CPU, got {ev.medium}"
498 + )
499 + assert ev.locality == "LOCAL", (
500 + f"BlockRemoved should be LOCAL, got {ev.locality}"
501 + )
502 + stored = [e for e in events if isinstance(e, BlockStored)]
503 + assert len(stored) == 2
504 +
505 +
506 +# ---------------------------------------------------------------------------
507 +# lazy store emits BlockStored
508 +# ---------------------------------------------------------------------------
509 +def test_lazy_store_emits_block_stored() -> None:
510 + """Lazy-mode store completion also emits BlockStored with MEDIUM_CPU."""
511 + fix = make_events_scheduler(num_cpu_blocks=8, num_gpu_blocks=8, lazy=True)
512 + sched = fix.scheduler
513 + gpu_pool = fix.gpu_block_pool
514 +
515 + # Allocate, hash, free -> hashed blocks in free queue.
516 + req = make_request(num_blocks=2)
517 + gpu_blocks = []
518 + for blk in gpu_pool.get_new_blocks(2):
519 + gpu_blocks.append(blk)
520 + gpu_pool.cache_full_blocks(
521 + request=req,
522 + blocks=gpu_blocks,
523 + num_cached_blocks=0,
524 + num_full_blocks=2,
525 + block_size=BLOCK_SIZE,
526 + kv_cache_group_id=0,
527 + )
528 + gpu_pool.free_blocks(gpu_blocks)
529 +
530 + # Push hashed blocks to LRU head (8 total - 1 null = 7 usable; 2 freed
531 + # hashed + 5 other free -> consume 5 fillers).
532 + fillers = gpu_pool.get_new_blocks(5)
533 +
534 + meta = sched.build_connector_meta(make_scheduler_output({}))
535 + assert meta.store_event >= 0
536 + simulate_store_completion(sched, meta.store_event)
537 + gpu_pool.free_blocks(fillers)
538 +
539 + events = list(sched.take_events())
540 + stored = [e for e in events if isinstance(e, BlockStored)]
541 + assert len(stored) == 2, f"expected 2 BlockStored, got {len(stored)}"
542 + for ev in stored:
543 + assert ev.medium == MEDIUM_CPU
544 + assert ev.group_idx == 0
545 + assert ev.kv_cache_spec_kind == "full_attention"
546 + assert ev.locality == "LOCAL"
547 + assert ev.token_ids == []
548 + assert ev.parent_block_hash is None
549 +
550 +
551 +# ---------------------------------------------------------------------------
552 +# cp_world_size scales block_size for non-Mamba
553 +# ---------------------------------------------------------------------------
554 +def test_cp_world_size_scales_block_size() -> None:
555 + """block_size on BlockStored = spec.block_size * cp_world_size (FA)."""
556 + fix = make_events_scheduler(dcp_world_size=2)
557 + sched = fix.scheduler
558 + gpu_pool = fix.gpu_block_pool
559 + vbs = BLOCK_SIZE * 2
560 + assert sched.cp_world_size == 2
561 +
562 + req = _make_cp_request(num_blocks=1, virtual_block_size=vbs)
563 + gpu_blocks = _allocate_cp_gpu_blocks(gpu_pool, req, 1, vbs)
564 + kv_blocks = KVCacheBlocks(blocks=(gpu_blocks,))
565 + req.num_computed_tokens = vbs
566 + sched.update_state_after_alloc(req, kv_blocks, num_external_tokens=0)
567 + sched_out = make_scheduler_output(
568 + {req.request_id: vbs},
569 + new_reqs={req.request_id: kv_blocks.get_block_ids()},
570 + )
571 + meta = sched.build_connector_meta(sched_out)
572 + assert meta.store_event >= 0
573 + simulate_store_completion(sched, meta.store_event)
574 +
575 + events = list(sched.take_events())
576 + stored = [e for e in events if isinstance(e, BlockStored)]
577 + assert len(stored) == 1
578 + # spec.block_size=BLOCK_SIZE, cp_world_size=2 -> BLOCK_SIZE * 2
579 + assert stored[0].block_size == BLOCK_SIZE * 2
580 + assert stored[0].locality == "LOCAL"
581 +
582 +
583 +# ---------------------------------------------------------------------------
584 +# Mamba block_size NOT scaled by cp_world_size
585 +# ---------------------------------------------------------------------------
586 +def test_mamba_block_size_not_scaled() -> None:
587 + """Mamba group: block_size = spec.block_size (no cp scaling).
588 +
589 + With cp_world_size=2, scheduler_block_size=hash_block_size=32. The
590 + coordinator constraints force Mamba spec.block_size=32 (must divide
591 + scheduler_block_size and be a multiple of hash_block_size). The event
592 + block_size for Mamba must be 32 (spec.block_size * 1), NOT 64
593 + (spec.block_size * cp_world_size) -- that is the bug this guards.
594 + """
595 + kv_cfg = _make_mixed_kv_cache_config(
596 + num_blocks=16, with_mamba=True, mamba_block_size=32
597 + )
598 + fix = make_events_scheduler(
599 + num_cpu_blocks=8,
600 + num_gpu_blocks=16,
601 + kv_cache_config=kv_cfg,
602 + dcp_world_size=2,
603 + num_groups=2,
604 + )
605 + sched = fix.scheduler
606 +
607 + cpu_pool = sched.cpu_block_pool
608 + gpu_pool = fix.gpu_block_pool
609 +
610 + cpu_blk = cpu_pool.get_new_blocks(1)[0]
611 + gpu_blk = gpu_pool.get_new_blocks(1)[0]
612 +
613 + raw_hash = make_request(num_blocks=1).block_hashes[0]
614 + mamba_hash = make_block_hash_with_group_id(raw_hash, group_id=1)
615 + gpu_blk._block_hash = mamba_hash # type: ignore[attr-defined]
616 +
617 + sched._process_store_completion(
618 + gpu_block_ids=[gpu_blk.block_id],
619 + cpu_block_ids=[cpu_blk.block_id],
620 + )
621 +
622 + events = list(sched.take_events())
623 + stored = [e for e in events if isinstance(e, BlockStored)]
624 + assert len(stored) == 1
625 + ev = stored[0]
626 + assert ev.group_idx == 1
627 + assert ev.kv_cache_spec_kind == "mamba"
628 + assert ev.kv_cache_spec_sliding_window is None
629 + assert ev.locality == "LOCAL"
630 + assert ev.block_size == 32, (
631 + f"Mamba block_size should be unscaled (32), got {ev.block_size}"
632 + )
633 +
634 +
635 +# ---------------------------------------------------------------------------
636 +# multi-group FA+SW stores carry correct group_idx and kind
637 +# ---------------------------------------------------------------------------
638 +def test_multi_group_fa_block_stored_group_idx() -> None:
639 + """Two distinct groups (FA + SW): each BlockStored carries its group_idx
640 + and kind. HybridKVCacheCoordinator requires >=2 distinct spec groups, so
641 + we use FA (group 0) + SlidingWindow (group 1)."""
642 + kv_cfg = _make_mixed_kv_cache_config(num_blocks=16, sliding_window=32)
643 + fix = make_events_scheduler(
644 + num_cpu_blocks=8,
645 + num_gpu_blocks=16,
646 + kv_cache_config=kv_cfg,
647 + num_groups=2,
648 + )
649 + sched = fix.scheduler
650 +
651 + cpu_pool = sched.cpu_block_pool
652 + gpu_pool = fix.gpu_block_pool
653 + raw_hash = make_request(num_blocks=1).block_hashes[0]
654 +
655 + # Store one block for each group via direct _process_store_completion.
656 + for gidx in (0, 1):
657 + cpu_blk = cpu_pool.get_new_blocks(1)[0]
658 + gpu_blk = gpu_pool.get_new_blocks(1)[0]
659 + gpu_blk._block_hash = make_block_hash_with_group_id( # type: ignore
660 + raw_hash, group_id=gidx
661 + )
662 + sched._process_store_completion(
663 + gpu_block_ids=[gpu_blk.block_id],
664 + cpu_block_ids=[cpu_blk.block_id],
665 + )
666 +
667 + events = list(sched.take_events())
668 + stored = [e for e in events if isinstance(e, BlockStored)]
669 + assert len(stored) == 2
670 + by_group = {ev.group_idx: ev for ev in stored}
671 + assert set(by_group) == {0, 1}
672 + assert by_group[0].kv_cache_spec_kind == "full_attention"
673 + assert by_group[0].kv_cache_spec_sliding_window is None
674 + assert by_group[1].kv_cache_spec_kind == "sliding_window"
675 + assert by_group[1].kv_cache_spec_sliding_window == 32
676 + for ev in stored:
677 + assert ev.locality == "LOCAL"
678 +
679 +
680 +def test_disk_mode_block_removed_relabeled_storage() -> None:
681 + """Disk mode: evicted BlockRemoved relabeled to MEDIUM_STORAGE."""
682 + fix = make_events_scheduler(
683 + num_cpu_blocks=5,
684 + num_gpu_blocks=16,
685 + disk_capacity_bytes=_BYTES_PER_BLOCK * 5,
686 + )
687 + sched = fix.scheduler
688 + assert sched.kv_event_medium == MEDIUM_STORAGE
689 +
690 + # Fill disk with 4 blocks (2 + 2).
691 + _do_eager_store(fix, num_blocks=2)
692 + list(sched.take_events())
693 + _do_eager_store(fix, num_blocks=2)
694 + list(sched.take_events())
695 +
696 + # Store 2 more -> evicts 2 LRU blocks -> BlockRemoved relabeled STORAGE.
697 + _do_eager_store(fix, num_blocks=2)
698 + events = list(sched.take_events())
699 +
700 + removed = [e for e in events if isinstance(e, BlockRemoved)]
701 + assert len(removed) >= 2, (
702 + f"expected >=2 BlockRemoved from eviction, got {len(removed)}"
703 + )
704 + for ev in removed:
705 + assert ev.medium == MEDIUM_STORAGE, (
706 + f"BlockRemoved should be relabeled STORAGE, got {ev.medium}"
707 + )
708 + assert ev.locality == "LOCAL", (
709 + f"BlockRemoved should be LOCAL, got {ev.locality}"
710 + )
711 + stored = [e for e in events if isinstance(e, BlockStored)]
712 + assert len(stored) == 2
713 + for ev in stored:
714 + assert ev.medium == MEDIUM_STORAGE
715 +
716 +
717 +def test_mamba_dcp_metadata_guard() -> None:
718 + """Mamba+DCP: token_ids guard emits [] when capture and emission sizes differ.
719 +
720 + The metadata capture at _prepare_eager_store_specs uses
721 + g_block_size = spec.block_size * cp_world_size = 32 * 2 = 64 to slice
722 + tokens. The event emission uses block_size = spec.block_size * 1 = 32
723 + for Mamba (SSM state is not CP-sharded). The guard in
724 + _process_store_completion detects len(token_ids) != block_size and emits
725 + token_ids=[] instead of misleading data. When #49962 fixes the capture
726 + path, the guard stops triggering and correct token_ids flow through.
727 + """
728 + vbs = BLOCK_SIZE * 2
729 + kv_cfg = _make_mixed_kv_cache_config(
730 + num_blocks=16, with_mamba=True, mamba_block_size=32
731 + )
732 + fix = make_events_scheduler(
733 + num_cpu_blocks=8,
734 + num_gpu_blocks=16,
735 + kv_cache_config=kv_cfg,
736 + dcp_world_size=2,
737 + num_groups=2,
738 + )
739 + sched = fix.scheduler
740 + gpu_pool = fix.gpu_block_pool
741 +
742 + # Mamba g_block_size = 32 * 2 = 64, so 2 Mamba blocks need 128 computed
743 + # tokens to be "ready"; FA g_block_size = 16 * 2 = 32 needs only 64.
744 + req = _make_cp_request(num_blocks=4, virtual_block_size=vbs)
745 + fa_blocks = _allocate_cp_gpu_blocks(gpu_pool, req, 2, vbs, group_id=0)
746 + mamba_blocks = _allocate_cp_gpu_blocks(gpu_pool, req, 2, vbs, group_id=1)
747 + kv_blocks = KVCacheBlocks(blocks=(fa_blocks, mamba_blocks))
748 + req.num_computed_tokens = 4 * vbs
749 + sched.update_state_after_alloc(req, kv_blocks, num_external_tokens=0)
750 +
751 + block_ids = kv_blocks.get_block_ids()
752 + sched_out = make_scheduler_output(
753 + {req.request_id: 4 * vbs},
754 + new_reqs={req.request_id: block_ids},
755 + )
756 + meta = sched.build_connector_meta(sched_out)
757 + assert meta.store_event >= 0
758 + simulate_store_completion(sched, meta.store_event)
759 +
760 + events = list(sched.take_events())
761 + stored = [e for e in events if isinstance(e, BlockStored)]
762 + assert len(stored) == 4, (
763 + f"expected 4 BlockStored (2 FA + 2 Mamba), got {len(stored)}"
764 + )
765 +
766 + by_group: dict[int, list[BlockStored]] = {}
767 + for ev in stored:
768 + by_group.setdefault(ev.group_idx, []).append(ev)
769 +
770 + # FA group: capture size (64) == emission size (64) → token_ids present.
771 + fa_events = by_group[0]
772 + assert len(fa_events) == 2
773 + for ev in fa_events:
774 + assert ev.block_size == vbs
775 + assert len(ev.token_ids) == vbs, (
776 + f"FA token_ids should be {vbs}, got {len(ev.token_ids)}"
777 + )
778 +
779 + # Mamba group: capture size (64) != emission size (32) → guard emits [].
780 + mamba_events = by_group[1]
781 + assert len(mamba_events) == 2
782 + for ev in mamba_events:
783 + assert ev.block_size == 32
784 + assert ev.token_ids == [], (
785 + f"Mamba token_ids should be [] (guard), got {ev.token_ids}"
786 + )
787 + assert ev.parent_block_hash is None, (
788 + "Mamba parent_block_hash should be None (guard)"
789 + )
790 + assert ev.extra_keys is None, "Mamba extra_keys should be None (guard)"
vllm/distributed/kv_transfer/kv_connector/v1/simple_cpu_offload_connector.py
+4
@@ -111,6 +111,10 @@ class SimpleCPUOffloadConnector(KVConnectorBase_V1, SupportsHMA):
111 raise ValueError(
112 'kv_offload_backend="disk" requires disk_path to be set.'
113 )
114 + if disk_capacity_bytes <= 0:
115 + raise ValueError(
116 + 'kv_offload_backend="disk" requires disk_capacity_bytes > 0.'
117 + )
118 else:
119 ignored = [k for k in _DISK_ONLY_KEYS if k in extra_config]
120 if ignored:
vllm/v1/simple_kv_offload/manager.py
+247 -29
@@ -8,7 +8,13 @@ from dataclasses import dataclass, field, replace
8 from typing import TYPE_CHECKING, Any
9
10 from vllm.config import VllmConfig
11 -from vllm.distributed.kv_events import KVCacheEvent
11 +from vllm.distributed.kv_events import (
12 + MEDIUM_CPU,
13 + MEDIUM_STORAGE,
14 + BlockRemoved,
15 + BlockStored,
16 + KVCacheEvent,
17 +)
18 from vllm.distributed.kv_transfer.kv_connector.utils import yield_req_data
19 from vllm.logger import init_logger
20 from vllm.utils.math_utils import cdiv
@@ -17,11 +23,23 @@ from vllm.v1.core.kv_cache_coordinator import (
23 KVCacheCoordinator,
24 get_kv_cache_coordinator,
25 )
26 +from vllm.v1.core.kv_cache_utils import (
27 + BlockHashWithGroupId,
28 + ExternalBlockHash,
29 + generate_block_hash_extra_keys,
30 + get_block_hash,
31 + get_group_id,
32 + make_block_hash_with_group_id,
33 + maybe_convert_block_hash,
34 + resolve_block_hashes,
35 +)
36 from vllm.v1.core.sched.output import SchedulerOutput
37 from vllm.v1.kv_cache_interface import (
38 FullAttentionSpec,
39 MambaSpec,
40 SlidingWindowSpec,
41 + get_kv_cache_spec_kind,
42 + get_kv_cache_spec_sliding_window,
43 )
44 from vllm.v1.outputs import KVConnectorOutput
45 from vllm.v1.simple_kv_offload.metadata import (
@@ -38,10 +56,27 @@ if TYPE_CHECKING:
56 logger = init_logger(__name__)
57
58
59 +@dataclass
60 +class BlockStoreMeta:
61 + """Per-block metadata snapshot for BlockStored event emission.
62 +
63 + Captured at store-prep time (when the Request is available) and carried
64 + through TransferMeta to async completion, where the Request is gone.
65 + """
66 +
67 + token_ids: list[int]
68 + parent_block_hash: ExternalBlockHash | None
69 + lora_id: int | None
70 + lora_name: str | None
71 + extra_keys: tuple[Any, ...] | None
72 + block_size: int | None = None
73 +
74 +
75 @dataclass
76 class TransferMeta:
77 gpu_block_ids: list[int]
78 cpu_block_ids: list[int]
79 + block_meta: list[dict[BlockHashWithGroupId, BlockStoreMeta]] | None = None
80
81
82 @dataclass
@@ -98,6 +133,7 @@ class SimpleCPUOffloadScheduler:
133 kv_cache_config, offload_capacity
134 )
135 self.num_cpu_blocks = self.cpu_kv_cache_config.num_blocks
136 + self.kv_event_medium = MEDIUM_STORAGE if disk_capacity_bytes > 0 else MEDIUM_CPU
137 # Find the full attention kv group for prefix cache matching.
138 self.fa_gidx = -1
139 for g_idx, g in enumerate(self.cpu_kv_cache_config.kv_cache_groups):
@@ -414,17 +450,22 @@ class SimpleCPUOffloadScheduler:
450 ) -> SimpleCPUOffloadMetadata:
451 # --- Stores ---
452 store_event = -1
417 - store_gpu, store_cpu, store_req_ids = self.prepare_store_specs(scheduler_output)
453 + store_gpu, store_cpu, store_req_ids, store_meta = self.prepare_store_specs(
454 + scheduler_output
455 + )
456 for transfer in self._pending_finished_stores:
457 store_gpu.extend(transfer.gpu_block_ids)
458 store_cpu.extend(transfer.cpu_block_ids)
459 + if store_meta is not None:
460 + assert transfer.block_meta is not None
461 + store_meta.extend(transfer.block_meta)
462 self._pending_finished_stores.clear()
463
464 if store_gpu:
465 store_event = self._store_event_counter
466 self._store_event_counter += 1
467 self._store_event_to_blocks[store_event] = TransferMeta(
427 - store_gpu, store_cpu
468 + store_gpu, store_cpu, store_meta
469 )
470 if store_req_ids: # For eager mode only, track req->blocks mapping
471 self._store_event_to_reqs[store_event] = store_req_ids
@@ -469,7 +510,12 @@ class SimpleCPUOffloadScheduler:
510
511 def prepare_store_specs(
512 self, scheduler_output: SchedulerOutput
472 - ) -> tuple[list[int], list[int], list[str]]:
513 + ) -> tuple[
514 + list[int],
515 + list[int],
516 + list[str],
517 + list[dict[BlockHashWithGroupId, BlockStoreMeta]] | None,
518 + ]:
519 """Prepare store specs for the store event."""
520 if self._lazy_mode:
521 return self._prepare_lazy_store_specs()
@@ -478,7 +524,12 @@ class SimpleCPUOffloadScheduler:
524
525 def _prepare_lazy_store_specs(
526 self,
481 - ) -> tuple[list[int], list[int], list[str]]:
527 + ) -> tuple[
528 + list[int],
529 + list[int],
530 + list[str],
531 + list[dict[BlockHashWithGroupId, BlockStoreMeta]] | None,
532 + ]:
533 """Single-pass cursor walk: offload cached GPU blocks near eviction.
534
535 Walks the GPU free queue from the cursor, counting blocks that are
@@ -487,7 +538,7 @@ class SimpleCPUOffloadScheduler:
538 """
539 gpu_pool = self._gpu_block_pool
540 if gpu_pool is None or self._target_free <= 0:
490 - return [], [], []
541 + return [], [], [], None
542
543 free_queue = gpu_pool.free_block_queue
544 cpu_pool = self.cpu_block_pool
@@ -525,11 +576,16 @@ class SimpleCPUOffloadScheduler:
576 else:
577 cpu_ids = []
578
528 - return gpu_ids, cpu_ids, []
579 + return gpu_ids, cpu_ids, [], None
580
581 def _prepare_eager_store_specs(
582 self, scheduler_output: SchedulerOutput
532 - ) -> tuple[list[int], list[int], list[str]]:
583 + ) -> tuple[
584 + list[int],
585 + list[int],
586 + list[str],
587 + list[dict[BlockHashWithGroupId, BlockStoreMeta]] | None,
588 + ]:
589 """Identify newly computed blocks to offload from scheduler requests.
590
591 Only considers blocks whose KV data has been **confirmed computed** by
@@ -537,19 +593,22 @@ class SimpleCPUOffloadScheduler:
593 the finish-time flush if the request completes first.
594
595 Returns:
540 - (gpu_block_ids, cpu_block_ids, req_ids) for the store event.
596 + (gpu_block_ids, cpu_block_ids, req_ids, block_meta) for the store
597 + event. ``block_meta`` is None when kv cache events are disabled.
598 """
599
600 merged_gpu_block_ids: list[int] = []
601 merged_cpu_block_ids: list[int] = []
602 req_ids: list[str] = []
603 + merged_block_meta: list[dict[BlockHashWithGroupId, BlockStoreMeta]] | None = (
604 + [] if self.enable_kv_cache_events else None
605 + )
606
607 gpu_block_pool = self._gpu_block_pool
608 if gpu_block_pool is None:
549 - return [], [], []
609 + return [], [], [], merged_block_meta
610 cpu_block_pool = self.cpu_block_pool
551 - kv_cache_groups = self.cpu_kv_cache_config.kv_cache_groups
552 - num_groups = len(kv_cache_groups)
611 + num_groups = len(self.cpu_kv_cache_config.kv_cache_groups)
612 # Dedup against blocks already scheduled.
613 in_flight = self._in_flight_store_gpu_blocks
614
@@ -558,13 +617,13 @@ class SimpleCPUOffloadScheduler:
617 if state is None or state.finished:
618 continue
619
561 - # Accumulate new block IDs.
620 if preempted:
621 state.block_ids = tuple([] for _ in range(num_groups))
622 state.num_stored_blocks = [0] * num_groups
623 if new_block_id_groups:
624 for g in range(min(num_groups, len(new_block_id_groups))):
625 if new_block_id_groups[g] is not None:
626 + # Accumulate new block IDs.
627 state.block_ids[g].extend(new_block_id_groups[g])
628
629 num_new_tokens = scheduler_output.num_scheduled_tokens.get(req_id, 0)
@@ -575,8 +634,8 @@ class SimpleCPUOffloadScheduler:
634 if not block_ids_by_group:
635 continue
636
578 - gpu_block_ids, advanced_per_group = self._select_eager_blocks_to_store(
579 - state, block_ids_by_group
637 + gpu_block_ids, advanced_per_group, block_meta = (
638 + self._select_eager_blocks_to_store(state, block_ids_by_group)
639 )
640
641 # --- Phase 2: Batch allocate CPU blocks ---
@@ -592,6 +651,9 @@ class SimpleCPUOffloadScheduler:
651 merged_gpu_block_ids.extend(gpu_block_ids)
652 merged_cpu_block_ids.extend(cpu_block_ids)
653 in_flight.update(gpu_block_ids)
654 + if merged_block_meta is not None:
655 + assert block_meta is not None
656 + merged_block_meta.extend(block_meta)
657
658 # Touch GPU blocks to prevent freeing during async copy
659 gpu_block_pool.touch(
@@ -609,20 +671,26 @@ class SimpleCPUOffloadScheduler:
671 for g in range(num_groups):
672 state.num_stored_blocks[g] += advanced_per_group[g]
673
612 - return merged_gpu_block_ids, merged_cpu_block_ids, req_ids
674 + return merged_gpu_block_ids, merged_cpu_block_ids, req_ids, merged_block_meta
675
676 def _select_eager_blocks_to_store(
677 self,
678 state: StoreRequestState,
679 block_ids_by_group: tuple[list[int], ...],
618 - ) -> tuple[list[int], list[int]]:
619 - """Return confirmed, uncached eager blocks and per-group cursor advances."""
680 + ) -> tuple[
681 + list[int],
682 + list[int],
683 + list[dict[BlockHashWithGroupId, BlockStoreMeta]] | None,
684 + ]:
685 + """Return confirmed eager blocks, cursor advances, and event metadata."""
686 assert self._gpu_block_pool is not None
687 gpu_block_ids: list[int] = []
688 advanced_per_group = [0] * len(self.cpu_kv_cache_config.kv_cache_groups)
623 - confirmed_tokens = (
624 - state.request.num_computed_tokens - state.request.num_output_placeholders
689 + block_meta: list[dict[BlockHashWithGroupId, BlockStoreMeta]] | None = (
690 + [] if self.enable_kv_cache_events else None
691 )
692 + request = state.request
693 + confirmed_tokens = request.num_computed_tokens - request.num_output_placeholders
694 aligned_tokens = confirmed_tokens // self.block_size * self.block_size
695 num_free = self.cpu_block_pool.get_num_free_blocks()
696
@@ -637,7 +705,17 @@ class SimpleCPUOffloadScheduler:
705 * self.cp_world_size
706 )
707 ready = min(len(group_gpu_ids), aligned_tokens // group_size)
640 - for gpu_block_id in group_gpu_ids[state.num_stored_blocks[g] : ready]:
708 + resolved_hashes = (
709 + resolve_block_hashes(
710 + request.block_hashes, self.hash_block_size, group_size
711 + )
712 + if block_meta is not None
713 + else None
714 + )
715 + curr_mm_idx = 0
716 + secondary_mm_idx = 0
717 + start = state.num_stored_blocks[g]
718 + for i, gpu_block_id in enumerate(group_gpu_ids[start:ready], start=start):
719 gpu_block = self._gpu_block_pool.blocks[gpu_block_id]
720 if gpu_block.is_null or gpu_block.block_hash is None:
721 advanced_per_group[g] += 1
@@ -655,8 +733,65 @@ class SimpleCPUOffloadScheduler:
733 break
734 gpu_block_ids.append(gpu_block_id)
735 advanced_per_group[g] += 1
658 -
659 - return gpu_block_ids, advanced_per_group
736 + if block_meta is not None:
737 + token_start = i * group_size
738 + token_end = token_start + group_size
739 + assert resolved_hashes is not None
740 + parent_hash = (
741 + None
742 + if i == 0
743 + else maybe_convert_block_hash(resolved_hashes[i - 1])
744 + )
745 + lora_req = request.lora_request
746 + extra_keys, curr_mm_idx = generate_block_hash_extra_keys(
747 + request, token_start, token_end, curr_mm_idx
748 + )
749 + meta_by_hash = {
750 + gpu_block.block_hash: BlockStoreMeta(
751 + token_ids=list(
752 + request.all_token_ids[token_start:token_end]
753 + ),
754 + parent_block_hash=parent_hash,
755 + lora_id=lora_req.adapter_id if lora_req else None,
756 + lora_name=lora_req.name if lora_req else None,
757 + extra_keys=extra_keys,
758 + )
759 + }
760 + first_hash_idx = token_start // self.hash_block_size
761 + last_hash_idx = token_end // self.hash_block_size
762 + for hash_idx in range(first_hash_idx, last_hash_idx):
763 + block_hash = make_block_hash_with_group_id(
764 + request.block_hashes[hash_idx], g
765 + )
766 + if block_hash == gpu_block.block_hash:
767 + continue
768 + hash_start = hash_idx * self.hash_block_size
769 + hash_end = hash_start + self.hash_block_size
770 + secondary_extra_keys, secondary_mm_idx = (
771 + generate_block_hash_extra_keys(
772 + request,
773 + hash_start,
774 + hash_end,
775 + secondary_mm_idx,
776 + )
777 + )
778 + meta_by_hash[block_hash] = BlockStoreMeta(
779 + token_ids=list(request.all_token_ids[hash_start:hash_end]),
780 + parent_block_hash=(
781 + None
782 + if hash_idx == 0
783 + else maybe_convert_block_hash(
784 + request.block_hashes[hash_idx - 1]
785 + )
786 + ),
787 + lora_id=lora_req.adapter_id if lora_req else None,
788 + lora_name=lora_req.name if lora_req else None,
789 + extra_keys=secondary_extra_keys,
790 + block_size=self.hash_block_size,
791 + )
792 + block_meta.append(meta_by_hash)
793 +
794 + return gpu_block_ids, advanced_per_group, block_meta
795
796 def update_connector_output(self, connector_output: KVConnectorOutput) -> None:
797 """Handle async transfer completions from worker.
@@ -695,7 +830,11 @@ class SimpleCPUOffloadScheduler:
830 if not self._lazy_mode:
831 self._in_flight_store_gpu_blocks.difference_update(transfer.gpu_block_ids)
832
698 - self._process_store_completion(transfer.gpu_block_ids, transfer.cpu_block_ids)
833 + self._process_store_completion(
834 + transfer.gpu_block_ids,
835 + transfer.cpu_block_ids,
836 + transfer.block_meta,
837 + )
838 logger.debug(
839 "Store event %d completed: cached %d blocks to CPU",
840 event_idx,
@@ -713,15 +852,19 @@ class SimpleCPUOffloadScheduler:
852 self._cleanup_store_request(req_id)
853
854 def _process_store_completion(
716 - self, gpu_block_ids: list[int], cpu_block_ids: list[int]
855 + self,
856 + gpu_block_ids: list[int],
857 + cpu_block_ids: list[int],
858 + block_meta: list[dict[BlockHashWithGroupId, BlockStoreMeta]] | None = None,
859 ) -> None:
860 """Register copied blocks in the CPU prefix cache and release refs."""
861 assert len(cpu_block_ids) == len(gpu_block_ids)
862 + assert block_meta is None or len(block_meta) == len(gpu_block_ids)
863
864 cpu_blocks = [self.cpu_block_pool.blocks[bid] for bid in cpu_block_ids]
865
866 assert self._gpu_block_pool is not None
724 - for gpu_block_id, cpu_block in zip(gpu_block_ids, cpu_blocks):
867 + for i, (gpu_block_id, cpu_block) in enumerate(zip(gpu_block_ids, cpu_blocks)):
868 gpu_block = self._gpu_block_pool.blocks[gpu_block_id]
869 primary_hash = gpu_block.block_hash
870 assert primary_hash is not None
@@ -737,6 +880,76 @@ class SimpleCPUOffloadScheduler:
880 self.cpu_block_pool._insert_block_hash(
881 block_hash, cpu_block, num_tokens=None
882 )
883 + if self.enable_kv_cache_events:
884 + meta_by_hash = block_meta[i] if block_meta is not None else {}
885 + primary_group_idx = get_group_id(primary_hash)
886 + primary_spec = self.cpu_kv_cache_config.kv_cache_groups[
887 + primary_group_idx
888 + ].kv_cache_spec
889 + primary_block_size = primary_spec.block_size * (
890 + self.cp_world_size if not isinstance(primary_spec, MambaSpec) else 1
891 + )
892 + events_to_emit: list[
893 + tuple[BlockHashWithGroupId, BlockStoreMeta | None, int]
894 + ] = [
895 + (
896 + primary_hash,
897 + meta_by_hash.get(primary_hash),
898 + primary_block_size,
899 + )
900 + ]
901 + for secondary_hash in secondary_hashes:
902 + secondary_meta = meta_by_hash.get(secondary_hash)
903 + events_to_emit.append(
904 + (
905 + secondary_hash,
906 + secondary_meta,
907 + secondary_meta.block_size
908 + if secondary_meta is not None
909 + and secondary_meta.block_size is not None
910 + else 0,
911 + )
912 + )
913 +
914 + for block_hash, meta, event_block_size in events_to_emit:
915 + group_idx = get_group_id(block_hash)
916 + spec = self.cpu_kv_cache_config.kv_cache_groups[
917 + group_idx
918 + ].kv_cache_spec
919 + if meta is not None and len(meta.token_ids) != event_block_size:
920 + # token_ids were sliced with a different g_block_size
921 + # (e.g. Mamba+DCP where capture uses spec.block_size *
922 + # cp_world_size but the event emits spec.block_size * 1).
923 + # Emit empty metadata until #49962 fixes the sizing.
924 + token_ids = []
925 + parent_block_hash = None
926 + extra_keys = None
927 + else:
928 + token_ids = meta.token_ids if meta else []
929 + parent_block_hash = meta.parent_block_hash if meta else None
930 + extra_keys = meta.extra_keys if meta else None
931 + self.cpu_block_pool.kv_event_queue.append(
932 + BlockStored(
933 + block_hashes=[
934 + maybe_convert_block_hash(get_block_hash(block_hash))
935 + ],
936 + parent_block_hash=parent_block_hash,
937 + token_ids=token_ids,
938 + block_size=event_block_size,
939 + lora_id=meta.lora_id if meta else None,
940 + medium=self.kv_event_medium,
941 + lora_name=meta.lora_name if meta else None,
942 + extra_keys=(
943 + [extra_keys] if extra_keys is not None else None
944 + ),
945 + group_idx=group_idx,
946 + kv_cache_spec_kind=get_kv_cache_spec_kind(spec).value,
947 + kv_cache_spec_sliding_window=(
948 + get_kv_cache_spec_sliding_window(spec)
949 + ),
950 + locality="LOCAL",
951 + )
952 + )
953
954 # Free CPU and GPU blocks' ref counts to turn them into prefix cache
955 self.cpu_block_pool.free_blocks(cpu_blocks)
@@ -811,12 +1024,12 @@ class SimpleCPUOffloadScheduler:
1024 gpu_pool = self._gpu_block_pool
1025 if state is None or gpu_pool is None:
1026 return
814 - gpu_ids, _ = self._select_eager_blocks_to_store(state, block_ids)
1027 + gpu_ids, _, block_meta = self._select_eager_blocks_to_store(state, block_ids)
1028 if not gpu_ids:
1029 return
1030 cpu_blocks = self.cpu_block_pool.get_new_blocks(len(gpu_ids))
1031 self._pending_finished_stores.append(
819 - TransferMeta(gpu_ids, [b.block_id for b in cpu_blocks])
1032 + TransferMeta(gpu_ids, [b.block_id for b in cpu_blocks], block_meta)
1033 )
1034 self._in_flight_store_gpu_blocks.update(gpu_ids)
1035 gpu_pool.touch([gpu_pool.blocks[bid] for bid in gpu_ids])
@@ -882,7 +1095,12 @@ class SimpleCPUOffloadScheduler:
1095 state.store_events.clear()
1096
1097 def take_events(self) -> Iterable[KVCacheEvent]:
885 - return self.cpu_block_pool.take_events()
1098 + events = self.cpu_block_pool.take_events()
1099 + for event in events:
1100 + if isinstance(event, BlockRemoved):
1101 + event.medium = self.kv_event_medium
1102 + event.locality = "LOCAL"
1103 + return events
1104
1105 def reset(self) -> bool:
1106 """Abandon pending transfers and reset the CPU cache when safe.