From 997c8756475b8c25d1639a55a2e0ab4ebf4b1b31 Mon Sep 17 00:00:00 2001 From: Tai An Date: Sat, 6 Jun 2026 17:55:01 -0700 Subject: [PATCH 1/3] fix(kv_cache): domain-tag LoRA and cache_salt prefix-cache keys LoRA names and cache_salt were both appended as bare strings into the same extra_keys tuple, so a LoRA request with lora_name == X and no cache_salt produced the same first-block hash as a base-model request with cache_salt == X and no LoRA. That breaks cache_salt isolation and can serve a base request a block computed under LoRA weights. Tag each source ("lora", name) / ("cache_salt", salt) so the keys live in separate namespaces and can no longer collide. Fixes #44701 Signed-off-by: Tai An --- tests/v1/core/test_kv_cache_utils.py | 34 ++++++++++++++++++++++++---- vllm/v1/core/kv_cache_utils.py | 18 +++++++++------ 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/tests/v1/core/test_kv_cache_utils.py b/tests/v1/core/test_kv_cache_utils.py index 94e9f6f4c100..728fc6576b11 100644 --- a/tests/v1/core/test_kv_cache_utils.py +++ b/tests/v1/core/test_kv_cache_utils.py @@ -496,9 +496,9 @@ def test_generate_block_hash_extra_keys_cache_salt(): # salt is added for the first token extra_keys, _ = generate_block_hash_extra_keys(request, 0, 1, 0) - assert extra_keys == ("salt",) + assert extra_keys == (("cache_salt", "salt"),) extra_keys, _ = generate_block_hash_extra_keys(request, 0, 10, 0) - assert extra_keys == ("salt",) + assert extra_keys == (("cache_salt", "salt"),) # no salt added for other tokens extra_keys, _ = generate_block_hash_extra_keys(request, 1, 2, 0) @@ -519,7 +519,7 @@ def test_generate_block_hash_extra_keys_cache_salt(): # Test with no extra keys extra_keys, next_mm_idx = generate_block_hash_extra_keys(request_mm, 0, 5, 0) - assert extra_keys == (("hash1", 0), "salt") + assert extra_keys == (("hash1", 0), ("cache_salt", "salt")) assert next_mm_idx == 1 @@ -607,13 +607,39 @@ def test_generate_block_hash_extra_keys_lora(): ) extra_keys, _ = generate_block_hash_extra_keys(request, 0, 3, 0) - assert extra_keys == ("test_lora_adapter",) + assert extra_keys == (("lora", "test_lora_adapter"),) request.lora_request = None extra_keys, _ = generate_block_hash_extra_keys(request, 0, 3, 0) assert extra_keys is None +def test_generate_block_hash_extra_keys_lora_cache_salt_no_collision(): + """A LoRA adapter name must not collide with an identically valued + ``cache_salt`` from a base-model request (see issue #44701).""" + shared = "COLLIDE" + + lora_request = make_request( + request_id="0", + prompt_token_ids=[_ for _ in range(6)], + ) + lora_request.lora_request = LoRARequest( + lora_name=shared, lora_int_id=1, lora_path="/path/to/lora" + ) + lora_keys, _ = generate_block_hash_extra_keys(lora_request, 0, 3, 0) + + salt_request = make_request( + request_id="1", + prompt_token_ids=[_ for _ in range(6)], + mm_positions=None, + mm_hashes=None, + cache_salt=shared, + ) + salt_keys, _ = generate_block_hash_extra_keys(salt_request, 0, 3, 0) + + assert lora_keys != salt_keys + + @pytest.mark.parametrize("hash_fn", [sha256, sha256_cbor]) def test_hash_block_tokens(hash_fn): parent_block_hash = BlockHash(b"123") diff --git a/vllm/v1/core/kv_cache_utils.py b/vllm/v1/core/kv_cache_utils.py index 7f3a5e4fdf3f..750589bfaf87 100644 --- a/vllm/v1/core/kv_cache_utils.py +++ b/vllm/v1/core/kv_cache_utils.py @@ -459,19 +459,21 @@ def _gen_mm_extra_hash_keys( return extra_keys, curr_mm_idx -def _gen_lora_extra_hash_keys(request: Request) -> list[str]: +def _gen_lora_extra_hash_keys(request: Request) -> list[tuple[str, str]]: """Generate extra keys related to LoRA for block hash computation. Args: request: The request object. Returns: - Return LoRA name of the request if it is a LoRA request. Return empty - list otherwise. + Return a domain-tagged LoRA name of the request if it is a LoRA + request. Return empty list otherwise. The ``"lora"`` tag keeps the + adapter name in its own key namespace so it cannot collide with an + identically valued key from another source (e.g. ``cache_salt``). """ if not request.lora_request: return [] - return [request.lora_request.lora_name] + return [("lora", request.lora_request.lora_name)] def _gen_prompt_embeds_extra_hash_keys( @@ -520,9 +522,11 @@ def generate_block_hash_extra_keys( mm_extra_keys, new_start_mm_idx = _gen_mm_extra_hash_keys( request, start_token_idx, end_token_idx, start_mm_idx ) - lora_extra_keys: list[str] = _gen_lora_extra_hash_keys(request) - cache_salt_keys: list[str] = ( - [request.cache_salt] if (start_token_idx == 0 and request.cache_salt) else [] + lora_extra_keys: list[tuple[str, str]] = _gen_lora_extra_hash_keys(request) + cache_salt_keys: list[tuple[str, str]] = ( + [("cache_salt", request.cache_salt)] + if (start_token_idx == 0 and request.cache_salt) + else [] ) prompt_embeds_keys = _gen_prompt_embeds_extra_hash_keys( request, start_token_idx, end_token_idx From 702a3ebad8a966605395b562845e640da440a0d7 Mon Sep 17 00:00:00 2001 From: Tai An Date: Fri, 31 Jul 2026 06:16:38 -0700 Subject: [PATCH 2/3] docs(kv_cache): note extra_keys concatenation order is load-bearing Per independent review on #44706: the order of lora/mm/cache_salt/ prompt_embeds keys is part of the block hash, so a later reordering (e.g. sorting for determinism) would silently invalidate the entire prefix cache. Document the invariant at the construction site. Signed-off-by: Tai An --- vllm/v1/core/kv_cache_utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/vllm/v1/core/kv_cache_utils.py b/vllm/v1/core/kv_cache_utils.py index 750589bfaf87..d5340e62874b 100644 --- a/vllm/v1/core/kv_cache_utils.py +++ b/vllm/v1/core/kv_cache_utils.py @@ -532,6 +532,10 @@ def generate_block_hash_extra_keys( request, start_token_idx, end_token_idx ) + # NOTE: the concatenation order below is load-bearing. It is part of the + # block hash, so reordering these lists changes every resulting hash and + # silently invalidates the entire prefix cache. Keep the order stable (do + # not, e.g., sort for determinism) unless a cache reset is intended. extra_keys: list[Any] = ( lora_extra_keys + mm_extra_keys + cache_salt_keys + prompt_embeds_keys ) From 20993e52d7f4f906e45ff99ec55ac5f588b12e7e Mon Sep 17 00:00:00 2001 From: Tai An Date: Sun, 30 Aug 2026 22:56:30 +0000 Subject: [PATCH 3/3] test(prefix_caching): update test_cache_key_salting for the tagged salt key test_cache_key_salting rebuilds the expected block hash by hand and hard-coded the untagged `("salt1",)` / `("salt2",)` shape, so it fails once cache_salt is domain-tagged. Both literals become `(("cache_salt", "salt1"),)` / `(("cache_salt", "salt2"),)`. Reported by @Hotragn on #44706. Signed-off-by: Tai An --- tests/v1/core/test_prefix_caching.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/v1/core/test_prefix_caching.py b/tests/v1/core/test_prefix_caching.py index 546412b1d2f8..19f7726bc187 100644 --- a/tests/v1/core/test_prefix_caching.py +++ b/tests/v1/core/test_prefix_caching.py @@ -1658,7 +1658,11 @@ def test_cache_key_salting(): block_hashes = req0.block_hashes assert len(block_hashes) == 3 assert block_hashes[0] == sha256( - (kv_cache_utils.NONE_HASH, tuple(token_ids[:block_size]), ("salt1",)) + ( + kv_cache_utils.NONE_HASH, + tuple(token_ids[:block_size]), + (("cache_salt", "salt1"),), + ) ) assert block_hashes[1] == sha256( (block_hashes[0], tuple(token_ids[block_size : block_size * 2]), None) @@ -1703,7 +1707,11 @@ def test_cache_key_salting(): block_hashes = req2.block_hashes assert len(block_hashes) == 3 assert block_hashes[0] == sha256( - (kv_cache_utils.NONE_HASH, tuple(token_ids[:block_size]), ("salt2",)) + ( + kv_cache_utils.NONE_HASH, + tuple(token_ids[:block_size]), + (("cache_salt", "salt2"),), + ) ) assert block_hashes[1] == sha256( (block_hashes[0], tuple(token_ids[block_size : block_size * 2]), None)