[Core][BugFix] Tag prefix-cache extra keys by source (#51899)

Signed-off-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: Lucas Bourtoule <35483370+dhalf@users.noreply.github.com> Co-authored-by: Tai An <antai12232931@outlook.com> Co-authored-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

Clinton Thomas committed Sep 29, 2026 at 21:24 UTC 765872e7ed5b6f372f0898a67e48d3365fa28139
7 files changed +131 -43
tests/v1/core/test_kv_cache_utils.py
+64 -16
@@ -54,6 +54,7 @@ from vllm.v1.core.kv_cache_utils import (
54 is_kv_cache_spec_uniform,
55 make_block_hash_with_group_id,
56 tensor_data,
57 + to_event_extra_keys,
58 )
59 from vllm.v1.hisparse.layout import (
60 create_hisparse_layout,
@@ -822,12 +823,12 @@ def test_generate_block_hash_extra_keys():
823
824 # Test with no extra keys
825 extra_keys, next_mm_idx = generate_block_hash_extra_keys(request, 0, 5, 0)
825 - assert extra_keys == (("hash1", 0),)
826 + assert extra_keys == (("mm", "hash1", 0),)
827 assert next_mm_idx == 1
828
829 # Test with partial overlap
830 extra_keys, next_mm_idx = generate_block_hash_extra_keys(request, 3, 8, 0)
830 - assert extra_keys == (("hash1", -3),)
831 + assert extra_keys == (("mm", "hash1", -3),)
832 assert next_mm_idx == 1
833
834 # Test with no overlap
@@ -837,7 +838,7 @@ def test_generate_block_hash_extra_keys():
838
839 # Test with multiple extra keys
840 extra_keys, next_mm_idx = generate_block_hash_extra_keys(request, 0, 15, 0)
840 - assert extra_keys == (("hash1", 0), ("hash2", 10))
841 + assert extra_keys == (("mm", "hash1", 0), ("mm", "hash2", 10))
842 assert next_mm_idx == 2
843
844
@@ -865,9 +866,9 @@ def test_generate_block_hash_extra_keys_cache_salt():
866
867 # salt is added for the first token
868 extra_keys, _ = generate_block_hash_extra_keys(request, 0, 1, 0)
868 - assert extra_keys == ("salt",)
869 + assert extra_keys == (("cache_salt", "salt"),)
870 extra_keys, _ = generate_block_hash_extra_keys(request, 0, 10, 0)
870 - assert extra_keys == ("salt",)
871 + assert extra_keys == (("cache_salt", "salt"),)
872
873 # no salt added for other tokens
874 extra_keys, _ = generate_block_hash_extra_keys(request, 1, 2, 0)
@@ -888,7 +889,7 @@ def test_generate_block_hash_extra_keys_cache_salt():
889
890 # Test with no extra keys
891 extra_keys, next_mm_idx = generate_block_hash_extra_keys(request_mm, 0, 5, 0)
891 - assert extra_keys == (("hash1", 0), "salt")
892 + assert extra_keys == (("mm", "hash1", 0), ("cache_salt", "salt"))
893 assert next_mm_idx == 1
894
895
@@ -906,13 +907,13 @@ def test_generate_block_hash_extra_keys_prompt_embeds():
907 extra_keys, _ = generate_block_hash_extra_keys(request, 0, 5, 0)
908 expected_embeds = prompt_embeds[0:5]
909 expected_hash = hashlib.sha256(kv_cache_utils.tensor_data(expected_embeds)).digest()
909 - assert extra_keys == (expected_hash,)
910 + assert extra_keys == (("prompt_embeds", expected_hash),)
911
912 # Test with prompt embeds for the second block
913 extra_keys, _ = generate_block_hash_extra_keys(request, 5, 10, 0)
914 expected_embeds = prompt_embeds[5:10]
915 expected_hash = hashlib.sha256(kv_cache_utils.tensor_data(expected_embeds)).digest()
915 - assert extra_keys == (expected_hash,)
916 + assert extra_keys == (("prompt_embeds", expected_hash),)
917
918
919 def test_generate_block_hash_extra_keys_prompt_embeds_cached(monkeypatch):
@@ -976,13 +977,58 @@ def test_generate_block_hash_extra_keys_lora():
977 )
978
979 extra_keys, _ = generate_block_hash_extra_keys(request, 0, 3, 0)
979 - assert extra_keys == ("test_lora_adapter",)
980 + assert extra_keys == (("lora", "test_lora_adapter"),)
981
982 request.lora_request = None
983 extra_keys, _ = generate_block_hash_extra_keys(request, 0, 3, 0)
984 assert extra_keys is None
985
986
987 +@pytest.mark.parametrize("hash_fn", [sha256, sha256_cbor])
988 +def test_lora_name_and_cache_salt_block_hashes_do_not_collide(hash_fn):
989 + """A cache_salt equal to a LoRA name must not share that LoRA's blocks."""
990 + lora_request = LoRARequest(
991 + lora_name="foo", lora_int_id=1, lora_path="/path/to/lora"
992 + )
993 + lora_req = Request(
994 + request_id="lora",
995 + prompt_token_ids=[0, 1, 2],
996 + sampling_params=SamplingParams(max_tokens=1),
997 + pooling_params=None,
998 + lora_request=lora_request,
999 + block_hasher=get_request_block_hasher(3, hash_fn),
1000 + )
1001 + salted_req = make_request("salted", [0, 1, 2], hash_fn=hash_fn, cache_salt="foo")
1002 +
1003 + assert lora_req.block_hashes[0] != salted_req.block_hashes[0]
1004 +
1005 +
1006 +def test_to_event_extra_keys_keeps_untagged_event_format():
1007 + """KV events keep publishing the extra-key shapes consumers already parse."""
1008 + request = make_request(
1009 + "0",
1010 + list(range(10)),
1011 + mm_positions=[PlaceholderRange(offset=2, length=3)],
1012 + mm_hashes=["hash1"],
1013 + cache_salt="salt",
1014 + )
1015 + request.lora_request = LoRARequest(
1016 + lora_name="adapter", lora_int_id=1, lora_path="/path/to/lora"
1017 + )
1018 +
1019 + extra_keys, _ = generate_block_hash_extra_keys(request, 0, 5, 0)
1020 +
1021 + assert extra_keys == (
1022 + ("lora", "adapter"),
1023 + ("mm", "hash1", 2),
1024 + ("cache_salt", "salt"),
1025 + )
1026 + event_keys = ("adapter", ("hash1", 2), "salt")
1027 + assert to_event_extra_keys([extra_keys, None]) == [event_keys, None]
1028 + assert to_event_extra_keys([]) is None
1029 + assert to_event_extra_keys(None) is None
1030 +
1031 +
1032 @pytest.mark.parametrize("hash_fn", [sha256, sha256_cbor])
1033 def test_hash_block_tokens(hash_fn):
1034 parent_block_hash = BlockHash(b"123")
@@ -1013,9 +1059,11 @@ def test_request_block_hasher(hash_fn):
1059 block_hashes = request.block_hashes
1060 assert len(block_hashes) == 2
1061 assert block_hashes[0] == hash_fn(
1016 - (kv_cache_utils.NONE_HASH, (0, 1, 2), (("hash1", 0),))
1062 + (kv_cache_utils.NONE_HASH, (0, 1, 2), (("mm", "hash1", 0),))
1063 + )
1064 + assert block_hashes[1] == hash_fn(
1065 + (block_hashes[0], (3, 4, 5), (("mm", "hash2", 0),))
1066 )
1018 - assert block_hashes[1] == hash_fn((block_hashes[0], (3, 4, 5), (("hash2", 0),)))
1067
1068
1069 @pytest.mark.parametrize("hash_fn", [sha256, sha256_cbor])
@@ -1043,7 +1091,7 @@ def test_request_block_hasher_incremental_append_with_multiple_mm_features(hash_
1091 )
1092
1093 expected_second_hash = hash_fn(
1046 - (incremental.block_hashes[0], (4, 5, 6, 7), (("A", 0), ("B", 2)))
1094 + (incremental.block_hashes[0], (4, 5, 6, 7), (("mm", "A", 0), ("mm", "B", 2)))
1095 )
1096 assert incremental.block_hashes[1] == expected_second_hash
1097 assert incremental.block_hashes == fresh.block_hashes
@@ -3657,7 +3705,7 @@ def test_request_block_hasher_with_prompt_embeds(hash_fn: Callable[[Any], bytes]
3705 (
3706 kv_cache_utils.NONE_HASH,
3707 tuple(prompt_token_ids[:block_size]),
3660 - (block1_embeds_hash,),
3708 + (("prompt_embeds", block1_embeds_hash),),
3709 )
3710 )
3711 assert block_hashes[0] == expected_hash1
@@ -3669,7 +3717,7 @@ def test_request_block_hasher_with_prompt_embeds(hash_fn: Callable[[Any], bytes]
3717 (
3718 block_hashes[0],
3719 tuple(prompt_token_ids[block_size:num_tokens]),
3672 - (block2_embeds_hash,),
3720 + (("prompt_embeds", block2_embeds_hash),),
3721 )
3722 )
3723 assert block_hashes[1] == expected_hash2
@@ -3706,7 +3754,7 @@ def test_request_with_prompt_embeds_and_mm_inputs(hash_fn: Callable[[Any], bytes
3754 (
3755 kv_cache_utils.NONE_HASH,
3756 tuple(prompt_token_ids[:block_size]),
3709 - (("hash1", 0), block1_embeds_hash),
3757 + (("mm", "hash1", 0), ("prompt_embeds", block1_embeds_hash)),
3758 )
3759 )
3760 assert block_hashes[0] == expected_hash1
@@ -3718,7 +3766,7 @@ def test_request_with_prompt_embeds_and_mm_inputs(hash_fn: Callable[[Any], bytes
3766 (
3767 block_hashes[0],
3768 tuple(prompt_token_ids[block_size:num_tokens]),
3721 - (("hash2", 0), block2_embeds_hash),
3769 + (("mm", "hash2", 0), ("prompt_embeds", block2_embeds_hash)),
3770 )
3771 )
3772 assert block_hashes[1] == expected_hash2
tests/v1/core/test_prefix_caching.py
+14 -6
@@ -2901,21 +2901,21 @@ def test_mm_prefix_caching():
2901 (
2902 kv_cache_utils.NONE_HASH,
2903 tuple(all_token_ids[:block_size]),
2904 - (("aaa", 11),),
2904 + (("mm", "aaa", 11),),
2905 )
2906 )
2907 assert block_hashes[1] == sha256(
2908 (
2909 block_hashes[0],
2910 tuple(all_token_ids[block_size : block_size * 2]),
2911 - (("aaa", -5), ("bbb", 14)),
2911 + (("mm", "aaa", -5), ("mm", "bbb", 14)),
2912 )
2913 )
2914 assert block_hashes[2] == sha256(
2915 (
2916 block_hashes[1],
2917 tuple(all_token_ids[block_size * 2 : block_size * 3]),
2918 - (("bbb", -2),),
2918 + (("mm", "bbb", -2),),
2919 )
2920 )
2921
@@ -2938,7 +2938,7 @@ def test_mm_prefix_caching():
2938 (
2939 block_hashes[2],
2940 tuple(all_token_ids[3 * block_size :] + [8] * 5),
2941 - (("ccc", 0),),
2941 + (("mm", "ccc", 0),),
2942 )
2943 )
2944
@@ -2984,7 +2984,11 @@ def test_cache_key_salting():
2984 block_hashes = req0.block_hashes
2985 assert len(block_hashes) == 3
2986 assert block_hashes[0] == sha256(
2987 - (kv_cache_utils.NONE_HASH, tuple(token_ids[:block_size]), ("salt1",))
2987 + (
2988 + kv_cache_utils.NONE_HASH,
2989 + tuple(token_ids[:block_size]),
2990 + (("cache_salt", "salt1"),),
2991 + )
2992 )
2993 assert block_hashes[1] == sha256(
2994 (block_hashes[0], tuple(token_ids[block_size : block_size * 2]), None)
@@ -3029,7 +3033,11 @@ def test_cache_key_salting():
3033 block_hashes = req2.block_hashes
3034 assert len(block_hashes) == 3
3035 assert block_hashes[0] == sha256(
3032 - (kv_cache_utils.NONE_HASH, tuple(token_ids[:block_size]), ("salt2",))
3036 + (
3037 + kv_cache_utils.NONE_HASH,
3038 + tuple(token_ids[:block_size]),
3039 + (("cache_salt", "salt2"),),
3040 + )
3041 )
3042 assert block_hashes[1] == sha256(
3043 (block_hashes[0], tuple(token_ids[block_size : block_size * 2]), None)
vllm/distributed/kv_events.py
+5 -3
@@ -68,9 +68,11 @@ class BlockStored(KVCacheEvent):
68 lora_name: str | None
69
70 """Extra keys used in block hash computation, one entry per block in
71 - block_hashes. Each entry contains MM identifiers, LoRA name, cache_salt,
72 - prompt embedding hashes, etc. for that specific block. Exposed for external
73 - KV cache consumers to reconstruct block hashes.
71 + block_hashes. Each entry contains, in order, the LoRA name (if
72 + `lora_name` is set), `(mm_identifier, offset)` pairs, the cache_salt
73 + string and prompt embedding digests for that specific block. These are
74 + published untagged; the block hash itself uses each value tagged with
75 + its source, e.g. `("lora", name)` or `("mm", mm_identifier, offset)`.
76 """
77 extra_keys: list[tuple[Any, ...] | None] | None = None
78
vllm/distributed/kv_transfer/kv_connector/v1/offloading/events.py
+2 -3
@@ -31,6 +31,7 @@ from vllm.v1.core.kv_cache_utils import (
31 BlockHash,
32 maybe_convert_block_hash,
33 resolve_block_hashes,
34 + to_event_extra_keys,
35 )
36 from vllm.v1.kv_cache_interface import (
37 KVCacheGroupSpec,
@@ -393,9 +394,7 @@ class OffloadingEventsTracker:
394 lora_id=meta.lora_id,
395 medium=_MEDIUM_TO_EVENT_STR[event.medium],
396 lora_name=meta.lora_name,
396 - extra_keys=(
397 - list(meta.extra_keys) if meta.extra_keys is not None else None
398 - ),
397 + extra_keys=to_event_extra_keys(meta.extra_keys),
398 group_idx=meta.group_idx,
399 kv_cache_spec_kind=meta.kv_cache_spec.kv_cache_spec_kind,
400 kv_cache_spec_sliding_window=(
vllm/v1/core/block_pool.py
+3 -2
@@ -25,6 +25,7 @@ from vllm.v1.core.kv_cache_utils import (
25 make_block_hash_with_group_id,
26 maybe_convert_block_hash,
27 resolve_block_hashes,
28 + to_event_extra_keys,
29 )
30 from vllm.v1.request import Request
31
@@ -367,7 +368,7 @@ class BlockPool:
368 lora_id=request.lora_request.adapter_id if request.lora_request else None,
369 medium=self.medium,
370 lora_name=request.lora_request.name if request.lora_request else None,
370 - extra_keys=extra_keys_list if extra_keys_list else None,
371 + extra_keys=to_event_extra_keys(extra_keys_list),
372 group_idx=kv_cache_group_id,
373 session_id=request.session_id,
374 )
@@ -557,7 +558,7 @@ class BlockPool:
558 lora_name=request.lora_request.name
559 if request.lora_request
560 else None,
560 - extra_keys=[extra_keys],
561 + extra_keys=to_event_extra_keys([extra_keys]),
562 group_idx=kv_cache_group_id,
563 session_id=request.session_id,
564 )
vllm/v1/core/kv_cache_utils.py
+41 -10
@@ -502,8 +502,8 @@ def _gen_mm_extra_hash_keys(
502 ) -> tuple[list[Any], int]:
503 """Generate extra keys related to MultiModal request for block hash
504 computation. For multi-modal inputs, the extra keys are
505 - (mm_hash, start_offset) that indicate a mm input contained in the
506 - block and its starting offset in the block tokens.
505 + ("mm", mm_hash, start_offset) tuples that indicate a mm input contained
506 + in the block and its starting offset in the block tokens.
507
508 Args:
509 request: The request object.
@@ -549,7 +549,7 @@ def _gen_mm_extra_hash_keys(
549 # relative to the start of the block so prefix-cache keys stay
550 # distinct when the same MM item appears at different positions
551 # within otherwise-identical placeholder blocks.
552 - extra_keys.append((mm_feature.identifier, offset - start_token_idx))
552 + extra_keys.append(("mm", mm_feature.identifier, offset - start_token_idx))
553
554 if end_token_idx >= offset + length:
555 # If this block contains the end of the current mm input,
@@ -565,7 +565,7 @@ def _gen_mm_extra_hash_keys(
565 return extra_keys, curr_mm_idx
566
567
568 -def _gen_lora_extra_hash_keys(request: Request) -> list[str]:
568 +def _gen_lora_extra_hash_keys(request: Request) -> list[tuple[str, str]]:
569 """Generate extra keys related to LoRA for block hash computation.
570
571 Args:
@@ -578,12 +578,12 @@ def _gen_lora_extra_hash_keys(request: Request) -> list[str]:
578 """
579 if not request.lora_request:
580 return []
581 - return [request.lora_request.lora_name]
581 + return [("lora", request.lora_request.lora_name)]
582
583
584 def _gen_prompt_embeds_extra_hash_keys(
585 request: Request, start_token_idx: int, end_token_idx: int
586 -) -> list[bytes]:
586 +) -> list[tuple[str, bytes]]:
587 """Generate extra keys related to prompt embeds for block hash computation.
588
589 Args:
@@ -605,7 +605,7 @@ def _gen_prompt_embeds_extra_hash_keys(
605 # Hash prompt embeds once per block and cache on request
606 embeds_hash = hashlib.sha256(tensor_data(block_prompt_embeds)).digest()
607 request._prompt_embeds_per_block_hashes[block_range] = embeds_hash
608 - return [embeds_hash]
608 + return [("prompt_embeds", embeds_hash)]
609
610
611 def generate_block_hash_extra_keys(
@@ -629,9 +629,11 @@ def generate_block_hash_extra_keys(
629 mm_extra_keys, new_start_mm_idx = _gen_mm_extra_hash_keys(
630 request, start_token_idx, end_token_idx, start_mm_idx
631 )
632 - lora_extra_keys: list[str] = _gen_lora_extra_hash_keys(request)
633 - cache_salt_keys: list[str] = (
634 - [request.cache_salt] if (start_token_idx == 0 and request.cache_salt) else []
632 + lora_extra_keys = _gen_lora_extra_hash_keys(request)
633 + cache_salt_keys: list[tuple[str, str]] = (
634 + [("cache_salt", request.cache_salt)]
635 + if (start_token_idx == 0 and request.cache_salt)
636 + else []
637 )
638 prompt_embeds_keys = _gen_prompt_embeds_extra_hash_keys(
639 request, start_token_idx, end_token_idx
@@ -647,6 +649,35 @@ def generate_block_hash_extra_keys(
649 return tuple(extra_keys), new_start_mm_idx
650
651
652 +def to_event_extra_keys(
653 + extra_keys: Iterable[tuple[Any, ...] | None] | None,
654 +) -> list[tuple[Any, ...] | None] | None:
655 + """Convert block-hash extra keys to the untagged per-block list published
656 + in KV events.
657 +
658 + External KV event consumers parse the pre-tagging shapes: bare LoRA names
659 + and cache salts, ``(mm_identifier, offset)`` pairs and bare prompt-embeds
660 + digests. Events keep that format until consumers handle the tagged keys.
661 +
662 + Args:
663 + extra_keys: One entry per block, each as returned by
664 + `generate_block_hash_extra_keys`, or None.
665 +
666 + Returns:
667 + One untagged entry per block, or None if there are no entries.
668 +
669 + """
670 + if not extra_keys:
671 + return None
672 + event_keys = [
673 + None
674 + if keys is None
675 + else tuple(key[1:] if key[0] == "mm" else key[1] for key in keys)
676 + for keys in extra_keys
677 + ]
678 + return event_keys or None
679 +
680 +
681 def hash_block_tokens(
682 hash_function: Callable[[Any], bytes],
683 parent_block_hash: BlockHash | None,
vllm/v1/simple_kv_offload/manager.py
+2 -3
@@ -34,6 +34,7 @@ from vllm.v1.core.kv_cache_utils import (
34 maybe_convert_block_hash,
35 resolve_block_hashes,
36 resolve_dcp_kv_block_size,
37 + to_event_extra_keys,
38 )
39 from vllm.v1.core.sched.output import SchedulerOutput
40 from vllm.v1.kv_cache_interface import (
@@ -1147,9 +1148,7 @@ class SimpleCPUOffloadScheduler:
1148 lora_id=meta.lora_id if meta else None,
1149 medium=self.kv_event_medium,
1150 lora_name=meta.lora_name if meta else None,
1150 - extra_keys=(
1151 - [extra_keys] if extra_keys is not None else None
1152 - ),
1151 + extra_keys=to_event_extra_keys(extra_keys and [extra_keys]),
1152 group_idx=group_idx,
1153 kv_cache_spec_kind=get_kv_cache_spec_kind(spec).value,
1154 kv_cache_spec_sliding_window=(