[Core][KV events] Report prefix-cache-reused blocks in full report mode (#45261)

Signed-off-by: Lei Gong <gonglei25@huawei.com> Co-authored-by: Lei Gong <gonglei25@huawei.com> Co-authored-by: Claude <noreply@anthropic.com>

GongLei-HW committed Jul 10, 2026 at 13:46 UTC e5588e49bc2642670116664a7fc4096e27adb179
5 files changed +270 -25
tests/v1/core/test_prefix_caching.py
+118 -1
@@ -12,7 +12,12 @@ import torch
12
13 import vllm.v1.core.kv_cache_manager as kv_cache_manager
14 import vllm.v1.core.kv_cache_utils as kv_cache_utils
15 -from vllm.distributed.kv_events import AllBlocksCleared, BlockRemoved, BlockStored
15 +from vllm.distributed.kv_events import (
16 + MEDIUM_GPU,
17 + AllBlocksCleared,
18 + BlockRemoved,
19 + BlockStored,
20 +)
21 from vllm.lora.request import LoRARequest
22 from vllm.multimodal.inputs import (
23 MultiModalFeatureSpec,
@@ -2454,6 +2459,118 @@ def test_block_removed_event_group_idx(group_id: int):
2459 assert event.group_idx == group_id
2460
2461
2462 +def test_emit_cached_block_events():
2463 + """emit_cached_block_events emits one BlockStored for already-cached
2464 + (reused) prefix blocks, carrying the correct group_idx /
2465 + parent_block_hash / token_ids, and without mutating block state."""
2466 + block_size = 4
2467 + num_cached_blocks = 3
2468 + kv_cache_group_id = 1
2469 + num_tokens = block_size * 4 # 4 full blocks; reuse the first 3
2470 +
2471 + pool = BlockPool(
2472 + num_gpu_blocks=8,
2473 + enable_caching=True,
2474 + hash_block_size=block_size,
2475 + enable_kv_cache_events=True,
2476 + )
2477 +
2478 + req = make_request(
2479 + "req_emit_cached",
2480 + prompt_token_ids=list(range(num_tokens)),
2481 + block_size=block_size,
2482 + hash_fn=sha256,
2483 + )
2484 + assert len(req.block_hashes) >= num_cached_blocks
2485 +
2486 + # Snapshot block state to prove emit_cached_block_events does not mutate it.
2487 + free_before = pool.get_num_free_blocks()
2488 + assert len(pool.cached_block_hash_to_block) == 0
2489 +
2490 + pool.emit_cached_block_events(
2491 + request=req,
2492 + num_cached_blocks=num_cached_blocks,
2493 + block_size=block_size,
2494 + kv_cache_group_id=kv_cache_group_id,
2495 + )
2496 +
2497 + # No block-state mutation: nothing allocated, nothing inserted into the
2498 + # prefix-cache map.
2499 + assert pool.get_num_free_blocks() == free_before
2500 + assert len(pool.cached_block_hash_to_block) == 0
2501 +
2502 + events = pool.take_events()
2503 + assert len(events) == 1
2504 + event = events[0]
2505 + assert isinstance(event, BlockStored)
2506 +
2507 + expected_hashes = [
2508 + kv_cache_utils.maybe_convert_block_hash(req.block_hashes[i])
2509 + for i in range(num_cached_blocks)
2510 + ]
2511 + assert event.block_hashes == expected_hashes
2512 + # Reused blocks start from block 0, so there is no parent block hash.
2513 + assert event.parent_block_hash is None
2514 + assert event.token_ids == list(req.all_token_ids[: num_cached_blocks * block_size])
2515 + assert event.group_idx == kv_cache_group_id
2516 + assert event.block_size == block_size
2517 + assert event.medium == MEDIUM_GPU
2518 + assert event.lora_id is None
2519 + assert event.lora_name is None
2520 +
2521 +
2522 +def test_emit_cached_block_events_disabled():
2523 + """No events are emitted when enable_kv_cache_events is False."""
2524 + block_size = 4
2525 + pool = BlockPool(
2526 + num_gpu_blocks=8,
2527 + enable_caching=True,
2528 + hash_block_size=block_size,
2529 + enable_kv_cache_events=False,
2530 + )
2531 + req = make_request(
2532 + "req_emit_disabled",
2533 + prompt_token_ids=list(range(block_size * 4)),
2534 + block_size=block_size,
2535 + hash_fn=sha256,
2536 + )
2537 +
2538 + pool.emit_cached_block_events(
2539 + request=req,
2540 + num_cached_blocks=3,
2541 + block_size=block_size,
2542 + kv_cache_group_id=0,
2543 + )
2544 +
2545 + assert pool.take_events() == []
2546 +
2547 +
2548 +def test_emit_cached_block_events_zero_cached():
2549 + """No events are emitted when num_cached_blocks == 0."""
2550 + block_size = 4
2551 + pool = BlockPool(
2552 + num_gpu_blocks=8,
2553 + enable_caching=True,
2554 + hash_block_size=block_size,
2555 + enable_kv_cache_events=True,
2556 + )
2557 + req = make_request(
2558 + "req_emit_zero",
2559 + prompt_token_ids=list(range(block_size * 4)),
2560 + block_size=block_size,
2561 + hash_fn=sha256,
2562 + )
2563 +
2564 + pool.emit_cached_block_events(
2565 + request=req,
2566 + num_cached_blocks=0,
2567 + block_size=block_size,
2568 + kv_cache_group_id=0,
2569 + )
2570 +
2571 + assert pool.take_events() == []
2572 +
2573 +
2574 def test_eagle_enabled_removes_last_block():
2575 """Verify Eagle does NOT remove blocks when request
2576 length is divisible by block size."""
vllm/v1/core/block_pool.py
+107 -24
@@ -14,8 +14,6 @@ from vllm.logger import init_logger
14 from vllm.v1.core.kv_cache_metrics import KVCacheMetricsCollector
15 from vllm.v1.core.kv_cache_utils import (
16 BlockHash,
17 - BlockHashList,
18 - BlockHashListWithBlockSize,
17 BlockHashWithGroupId,
18 ExternalBlockHash,
19 FreeKVCacheBlockQueue,
@@ -25,6 +23,7 @@ from vllm.v1.core.kv_cache_utils import (
23 get_group_id,
24 make_block_hash_with_group_id,
25 maybe_convert_block_hash,
26 + resolve_block_hashes,
27 )
28 from vllm.v1.request import Request
29
@@ -261,17 +260,7 @@ class BlockPool:
260 return
261 new_full_blocks = blocks[num_cached_blocks:num_full_blocks]
262 assert block_mask is None or len(block_mask) == len(new_full_blocks)
264 - if block_size == self.hash_block_size:
265 - # Common case.
266 - block_hashes: BlockHashList = request.block_hashes
267 - else:
268 - # block_size is a multiple of hash_block_size. This happens when
269 - # different KV cache groups have different block sizes.
270 - assert block_size % self.hash_block_size == 0
271 - block_hashes = BlockHashListWithBlockSize(
272 - request.block_hashes, self.hash_block_size, block_size
273 - )
274 - assert len(block_hashes) >= num_full_blocks
263 + block_hashes = resolve_block_hashes(request, self.hash_block_size, block_size)
264
265 new_block_hashes = block_hashes[num_cached_blocks:]
266 new_hashes: list[ExternalBlockHash] | None = (
@@ -338,23 +327,117 @@ class BlockPool:
327 extra_keys_list.append(extra_keys)
328
329 self.kv_event_queue.append(
341 - BlockStored(
330 + self._build_block_stored_event(
331 + request,
332 block_hashes=new_hashes,
333 parent_block_hash=parent_block_hash,
344 - token_ids=request.all_token_ids[start_token_idx:end_token_idx],
334 + start_token_idx=start_token_idx,
335 + end_token_idx=end_token_idx,
336 block_size=block_size,
346 - lora_id=request.lora_request.adapter_id
347 - if request.lora_request
348 - else None,
349 - medium=MEDIUM_GPU,
350 - lora_name=request.lora_request.name
351 - if request.lora_request
352 - else None,
353 - extra_keys=extra_keys_list if extra_keys_list else None,
354 - group_idx=kv_cache_group_id,
337 + kv_cache_group_id=kv_cache_group_id,
338 + extra_keys_list=extra_keys_list,
339 )
340 )
341
342 + def _build_block_stored_event(
343 + self,
344 + request: Request,
345 + block_hashes: list[ExternalBlockHash] | None,
346 + parent_block_hash: ExternalBlockHash | None,
347 + start_token_idx: int,
348 + end_token_idx: int,
349 + block_size: int,
350 + kv_cache_group_id: int,
351 + extra_keys_list: list[tuple[Any, ...] | None],
352 + ) -> BlockStored:
353 + """Build a ``BlockStored`` KV event for ``request``.
354 +
355 + Shared by ``cache_full_blocks`` (newly cached blocks) and
356 + ``emit_cached_block_events`` (prefix-cache-reused blocks) so both emit
357 + identical event shapes for downstream consumers.
358 + """
359 + return BlockStored(
360 + block_hashes=block_hashes,
361 + parent_block_hash=parent_block_hash,
362 + token_ids=request.all_token_ids[start_token_idx:end_token_idx],
363 + block_size=block_size,
364 + lora_id=request.lora_request.adapter_id if request.lora_request else None,
365 + medium=MEDIUM_GPU,
366 + lora_name=request.lora_request.name if request.lora_request else None,
367 + extra_keys=extra_keys_list if extra_keys_list else None,
368 + group_idx=kv_cache_group_id,
369 + )
370 +
371 + def emit_cached_block_events(
372 + self,
373 + request: Request,
374 + num_cached_blocks: int,
375 + block_size: int,
376 + kv_cache_group_id: int,
377 + ) -> None:
378 + """Generate BlockStored events for blocks reused from prefix cache.
379 +
380 + Unlike cache_full_blocks(), this does NOT modify block state —
381 + the blocks are already cached. It only generates events so that
382 + external consumers (e.g. gateway) can learn about reused blocks.
383 +
384 + Args:
385 + request: The request whose prefix cache blocks were reused.
386 + num_cached_blocks: Number of blocks that were cache hits.
387 + block_size: Number of tokens per block.
388 + kv_cache_group_id: The KV cache group ID.
389 + """
390 + if not self.enable_kv_cache_events or num_cached_blocks == 0:
391 + return
392 +
393 + block_hashes = resolve_block_hashes(request, self.hash_block_size, block_size)
394 +
395 + # Collect external hashes and extra_keys for cached blocks.
396 + cached_hashes: list[ExternalBlockHash] = []
397 + extra_keys_list: list[tuple[Any, ...] | None] = []
398 + curr_mm_idx = 0
399 + for i in range(num_cached_blocks):
400 + block_start = i * block_size
401 + block_end = block_start + block_size
402 + cached_hashes.append(maybe_convert_block_hash(block_hashes[i]))
403 + extra_keys, curr_mm_idx = generate_block_hash_extra_keys(
404 + request, block_start, block_end, curr_mm_idx
405 + )
406 + extra_keys_list.append(extra_keys)
407 +
408 + if not cached_hashes:
409 + return
410 +
411 + # Prefix-cache hits always form a contiguous prefix starting at block 0,
412 + # so the first (and thus the whole group's) parent block hash is None.
413 + parent_block_hash: ExternalBlockHash | None = None
414 + start_token_idx = 0
415 + end_token_idx = num_cached_blocks * block_size
416 +
417 + logger.debug(
418 + "EmitCachedBlock event: block_size=%d, "
419 + "num_cached_blocks=%d, parent_block_hash=%s, "
420 + "token_ids_len=%d, group_idx=%s",
421 + block_size,
422 + num_cached_blocks,
423 + parent_block_hash,
424 + len(request.all_token_ids[start_token_idx:end_token_idx]),
425 + kv_cache_group_id,
426 + )
427 +
428 + self.kv_event_queue.append(
429 + self._build_block_stored_event(
430 + request,
431 + block_hashes=cached_hashes,
432 + parent_block_hash=parent_block_hash,
433 + start_token_idx=start_token_idx,
434 + end_token_idx=end_token_idx,
435 + block_size=block_size,
436 + kv_cache_group_id=kv_cache_group_id,
437 + extra_keys_list=extra_keys_list,
438 + )
439 + )
440 +
441 def cache_partial_block(
442 self,
443 request: Request,
vllm/v1/core/kv_cache_manager.py
+21
@@ -136,6 +136,7 @@ class KVCacheManager:
136 max_in_flight_tokens = max_model_len
137
138 self.enable_caching = enable_caching
139 + self.enable_kv_cache_events = enable_kv_cache_events
140 self.use_eagle = use_eagle
141 self.log_stats = log_stats
142 self.metrics_collector = metrics_collector
@@ -235,6 +236,26 @@ class KVCacheManager:
236 )
237 )
238
239 + # When kv_cache_report_mode is "full", emit BlockStored events
240 + # for the reused prefix cache blocks so that external consumers
241 + # (e.g. gateway) can learn about them.
242 + if (
243 + num_new_computed_tokens > 0
244 + and self.enable_kv_cache_events
245 + and getattr(request, "kv_cache_report_mode", "incremental") == "full"
246 + ):
247 + for group_idx, group_blocks in enumerate(computed_blocks):
248 + num_blocks = len(group_blocks)
249 + if num_blocks > 0:
250 + group = self.kv_cache_config.kv_cache_groups[group_idx]
251 + block_size = group.kv_cache_spec.block_size
252 + self.block_pool.emit_cached_block_events(
253 + request,
254 + num_blocks,
255 + block_size,
256 + group_idx,
257 + )
258 +
259 if self.log_stats:
260 assert self.prefix_cache_stats is not None
261 self.prefix_cache_stats.record(
vllm/v1/core/kv_cache_utils.py
+19
@@ -2249,3 +2249,22 @@ class BlockHashListWithBlockSize:
2249
2250
2251 BlockHashList = list[BlockHash] | BlockHashListWithBlockSize
2252 +
2253 +
2254 +def resolve_block_hashes(
2255 + request: Request,
2256 + hash_block_size: int,
2257 + block_size: int,
2258 +) -> BlockHashList:
2259 + """Resolve the block-hash view for ``request`` at ``block_size``.
2260 +
2261 + When ``block_size`` equals ``hash_block_size``, reuse the request's
2262 + precomputed ``block_hashes`` directly; otherwise recalculate at
2263 + ``block_size`` granularity (``block_size`` must be a multiple of
2264 + ``hash_block_size``, which happens when KV cache groups differ in
2265 + block size).
2266 + """
2267 + if block_size == hash_block_size:
2268 + return request.block_hashes
2269 + assert block_size % hash_block_size == 0
2270 + return BlockHashListWithBlockSize(request.block_hashes, hash_block_size, block_size)
vllm/v1/request.py
+5
@@ -115,6 +115,11 @@ class Request:
115 self.kv_transfer_params = sampling_params.extra_args.get(
116 "kv_transfer_params"
117 )
118 + self.kv_cache_report_mode = sampling_params.extra_args.get(
119 + "kv_cache_report_mode", "incremental"
120 + )
121 + else:
122 + self.kv_cache_report_mode = "incremental"
123 else:
124 raise ValueError("sampling_params and pooling_params can't both be unset")
125