[1/N][Core] add partial prefix cache primitives (#45939)
Signed-off-by: zjy0516 <riverclouds.zhu@qq.com> Co-authored-by: Yifan Qiao <yifanqiao@inferact.ai>
Jiangyun Zhu committed
Jun 22, 2026 at 14:43 UTC
6bc6f2d86d7800f878a4a13495b51a6e3b728c37
5 files changed
+719
-41
tests/v1/core/prefix_cache/test_partial_prefix_cache_primitives.py
new
+460
@@ -0,0 +1,460 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from collections.abc import Callable
5
+
6
+import pytest
7
+
8
+import vllm.v1.core.kv_cache_utils as kv_cache_utils
9
+from vllm.distributed.kv_events import BlockRemoved, BlockStored
10
+from vllm.sampling_params import SamplingParams
11
+from vllm.utils.hashing import sha256
12
+from vllm.v1.core.block_pool import BlockPool
13
+from vllm.v1.core.kv_cache_utils import (
14
+ BlockHash,
15
+ BlockHashListWithBlockSize,
16
+ KVCacheBlock,
17
+ get_request_block_hasher,
18
+ hash_block_tokens,
19
+ init_none_hash,
20
+)
21
+from vllm.v1.request import Request
22
+
23
+pytestmark = pytest.mark.cpu_test
24
+
25
+
26
+@pytest.fixture(autouse=True)
27
+def _auto_init_hash_fn():
28
+ init_none_hash(sha256)
29
+
30
+
31
+def make_request(
32
+ request_id: str,
33
+ prompt_token_ids: list[int],
34
+ hash_block_size: int,
35
+ hash_fn: Callable,
36
+) -> Request:
37
+ sampling_params = SamplingParams(max_tokens=17)
38
+ sampling_params.update_from_generation_config({}, eos_token_id=100)
39
+ return Request(
40
+ request_id=request_id,
41
+ prompt_token_ids=prompt_token_ids,
42
+ sampling_params=sampling_params,
43
+ pooling_params=None,
44
+ block_hasher=get_request_block_hasher(hash_block_size, hash_fn),
45
+ )
46
+
47
+
48
+def boundary_hash(req: Request, hash_block_size: int, num_tokens: int) -> BlockHash:
49
+ # Every boundary at a hash_block_size multiple is just the fine-grained
50
+ # chain hash ending there.
51
+ return req.block_hashes[num_tokens // hash_block_size - 1]
52
+
53
+
54
+def cache_full_block_and_partial_tail(
55
+ token_ids: list[int],
56
+ *,
57
+ enable_kv_cache_events: bool = False,
58
+) -> tuple[BlockPool, Request, list[KVCacheBlock], BlockHash]:
59
+ hash_block_size = 2
60
+ block_size = 6
61
+ kv_cache_group_id = 0
62
+ req = make_request("0", token_ids, hash_block_size, sha256)
63
+ pool = BlockPool(
64
+ num_gpu_blocks=3,
65
+ enable_caching=True,
66
+ hash_block_size=hash_block_size,
67
+ enable_kv_cache_events=enable_kv_cache_events,
68
+ )
69
+ blocks = pool.get_new_blocks(2)
70
+
71
+ pool.cache_full_blocks(
72
+ request=req,
73
+ blocks=blocks,
74
+ num_cached_blocks=0,
75
+ num_full_blocks=1,
76
+ block_size=block_size,
77
+ kv_cache_group_id=kv_cache_group_id,
78
+ )
79
+ partial_hash = boundary_hash(req, hash_block_size, len(token_ids))
80
+ assert pool.cache_partial_block(
81
+ request=req,
82
+ block=blocks[1],
83
+ num_tokens=len(token_ids),
84
+ kv_cache_group_id=kv_cache_group_id,
85
+ block_size=block_size,
86
+ )
87
+ return pool, req, blocks, partial_hash
88
+
89
+
90
+def test_boundary_hashes_reuse_fine_grained_chain():
91
+ hash_block_size = 2
92
+ block_size = 6
93
+ token_ids = [0, 0, 1, 1, 2, 2, 3, 3, 4, 4]
94
+ req = make_request("0", token_ids, hash_block_size, sha256)
95
+
96
+ coarse = BlockHashListWithBlockSize(req.block_hashes, hash_block_size, block_size)
97
+ # The block_size=6 full-block hash is the fine hash at the 6-token boundary,
98
+ # not a concatenation of the three fine hashes inside the block.
99
+ assert coarse[0] == req.block_hashes[6 // hash_block_size - 1]
100
+ assert coarse[0] != BlockHash(
101
+ req.block_hashes[0] + req.block_hashes[1] + req.block_hashes[2]
102
+ )
103
+ # A partial tail at 10 tokens is the fine hash at the 10-token boundary,
104
+ # which chains over the entire prefix.
105
+ tail_hash = boundary_hash(req, hash_block_size, 10)
106
+ assert tail_hash == req.block_hashes[4]
107
+ assert tail_hash == hash_block_tokens(sha256, req.block_hashes[3], token_ids[8:10])
108
+
109
+
110
+def test_cache_partial_block_kv_cache_events():
111
+ hash_block_size = 4
112
+ block_size = 12
113
+ kv_cache_group_id = 2
114
+
115
+ pool = BlockPool(
116
+ num_gpu_blocks=2,
117
+ enable_caching=True,
118
+ hash_block_size=hash_block_size,
119
+ enable_kv_cache_events=True,
120
+ )
121
+ req = make_request(
122
+ "req_partial_events",
123
+ prompt_token_ids=list(range(hash_block_size * 2)),
124
+ hash_block_size=hash_block_size,
125
+ hash_fn=sha256,
126
+ )
127
+
128
+ block = pool.get_new_blocks(1)[0]
129
+ partial_entry_hash = pool.cache_partial_block(
130
+ request=req,
131
+ block=block,
132
+ num_tokens=hash_block_size * 2,
133
+ kv_cache_group_id=kv_cache_group_id,
134
+ block_size=block_size,
135
+ )
136
+
137
+ events = pool.take_events()
138
+ assert len(events) == 1
139
+ stored_event = events[0]
140
+ assert isinstance(stored_event, BlockStored)
141
+ assert partial_entry_hash is not None
142
+ assert stored_event.block_hashes == [
143
+ kv_cache_utils.maybe_convert_block_hash(req.block_hashes[1])
144
+ ]
145
+ assert stored_event.parent_block_hash == kv_cache_utils.maybe_convert_block_hash(
146
+ req.block_hashes[0]
147
+ )
148
+ assert stored_event.token_ids == req.all_token_ids[hash_block_size:]
149
+ assert stored_event.block_size == 4
150
+ assert stored_event.group_idx == kv_cache_group_id
151
+
152
+ duplicate_entry_hash = pool.cache_partial_block(
153
+ request=req,
154
+ block=block,
155
+ num_tokens=hash_block_size * 2,
156
+ kv_cache_group_id=kv_cache_group_id,
157
+ block_size=block_size,
158
+ )
159
+ assert duplicate_entry_hash == partial_entry_hash
160
+ assert pool.take_events() == []
161
+
162
+ pool.free_blocks([block])
163
+ pool.get_new_blocks(1)
164
+ events = pool.take_events()
165
+ assert len(events) == 1
166
+ removed_event = events[0]
167
+ assert isinstance(removed_event, BlockRemoved)
168
+ assert removed_event.block_hashes == stored_event.block_hashes
169
+ assert removed_event.group_idx == kv_cache_group_id
170
+
171
+
172
+def test_partial_block_replacement_emits_remove_then_store_events():
173
+ hash_block_size = 2
174
+ block_size = 6
175
+ kv_cache_group_id = 0
176
+ req = make_request("0", [0, 0, 1, 1, 2, 2, 3, 3], hash_block_size, sha256)
177
+ pool = BlockPool(
178
+ num_gpu_blocks=3,
179
+ enable_caching=True,
180
+ hash_block_size=hash_block_size,
181
+ enable_kv_cache_events=True,
182
+ )
183
+ blocks = pool.get_new_blocks(2)
184
+
185
+ pool.cache_full_blocks(
186
+ request=req,
187
+ blocks=blocks,
188
+ num_cached_blocks=0,
189
+ num_full_blocks=1,
190
+ block_size=block_size,
191
+ kv_cache_group_id=kv_cache_group_id,
192
+ )
193
+ partial_hash_8 = boundary_hash(req, hash_block_size, 8)
194
+ assert pool.cache_partial_block(
195
+ request=req,
196
+ block=blocks[1],
197
+ num_tokens=8,
198
+ kv_cache_group_id=kv_cache_group_id,
199
+ block_size=block_size,
200
+ )
201
+ assert pool.get_cached_block(partial_hash_8, [kv_cache_group_id]) == [blocks[1]]
202
+ pool.take_events()
203
+
204
+ req.append_output_token_ids([4, 4])
205
+ partial_hash_10 = boundary_hash(req, hash_block_size, 10)
206
+ assert pool.cache_partial_block(
207
+ request=req,
208
+ block=blocks[1],
209
+ num_tokens=10,
210
+ kv_cache_group_id=kv_cache_group_id,
211
+ block_size=block_size,
212
+ )
213
+ events = pool.take_events()
214
+
215
+ assert len(events) == 2
216
+ removed_event, stored_event = events
217
+ assert isinstance(removed_event, BlockRemoved)
218
+ assert removed_event.block_hashes == [
219
+ kv_cache_utils.maybe_convert_block_hash(partial_hash_8)
220
+ ]
221
+ assert removed_event.group_idx == kv_cache_group_id
222
+ assert isinstance(stored_event, BlockStored)
223
+ assert stored_event.block_hashes == [
224
+ kv_cache_utils.maybe_convert_block_hash(partial_hash_10)
225
+ ]
226
+ assert stored_event.parent_block_hash == kv_cache_utils.maybe_convert_block_hash(
227
+ boundary_hash(req, hash_block_size, 8)
228
+ )
229
+ assert stored_event.token_ids == req.all_token_ids[8:10]
230
+ assert stored_event.block_size == hash_block_size
231
+ assert stored_event.group_idx == kv_cache_group_id
232
+ assert pool.get_cached_block(partial_hash_8, [kv_cache_group_id]) is None
233
+ assert pool.get_cached_block(partial_hash_10, [kv_cache_group_id]) == [blocks[1]]
234
+
235
+
236
+def test_later_request_hits_cached_partial_tail():
237
+ hash_block_size = 2
238
+ block_size = 6
239
+ kv_cache_group_id = 0
240
+ cached_token_ids = [0, 0, 1, 1, 2, 2, 3, 3, 4, 4]
241
+ req = make_request("0", cached_token_ids, hash_block_size, sha256)
242
+ pool = BlockPool(
243
+ num_gpu_blocks=3,
244
+ enable_caching=True,
245
+ hash_block_size=hash_block_size,
246
+ )
247
+ blocks = pool.get_new_blocks(2)
248
+
249
+ pool.cache_full_blocks(
250
+ request=req,
251
+ blocks=blocks,
252
+ num_cached_blocks=0,
253
+ num_full_blocks=1,
254
+ block_size=block_size,
255
+ kv_cache_group_id=kv_cache_group_id,
256
+ )
257
+ partial_hash_10 = boundary_hash(req, hash_block_size, 10)
258
+ assert pool.cache_partial_block(
259
+ request=req,
260
+ block=blocks[1],
261
+ num_tokens=10,
262
+ kv_cache_group_id=kv_cache_group_id,
263
+ block_size=block_size,
264
+ )
265
+
266
+ replay = make_request("1", cached_token_ids, hash_block_size, sha256)
267
+ replay_hash_10 = boundary_hash(replay, hash_block_size, 10)
268
+ assert replay_hash_10 == partial_hash_10
269
+ assert pool.get_cached_block(replay_hash_10, [kv_cache_group_id]) == [blocks[1]]
270
+
271
+ extended = make_request("2", cached_token_ids + [10], hash_block_size, sha256)
272
+ extended_hash_10 = boundary_hash(extended, hash_block_size, 10)
273
+ assert extended_hash_10 == partial_hash_10
274
+ assert pool.get_cached_block(extended_hash_10, [kv_cache_group_id]) == [blocks[1]]
275
+
276
+
277
+def test_cache_partial_block_uses_fine_grained_boundary_hash():
278
+ hash_block_size = 2
279
+ block_size = 6
280
+ kv_cache_group_id = 0
281
+ token_ids = [0, 0, 1, 1, 2, 2, 3, 3, 4, 4]
282
+ req = make_request("0", token_ids, hash_block_size, sha256)
283
+ pool = BlockPool(
284
+ num_gpu_blocks=3,
285
+ enable_caching=True,
286
+ hash_block_size=hash_block_size,
287
+ )
288
+ blocks = pool.get_new_blocks(2)
289
+
290
+ pool.cache_full_blocks(
291
+ request=req,
292
+ blocks=blocks,
293
+ num_cached_blocks=0,
294
+ num_full_blocks=1,
295
+ block_size=block_size,
296
+ kv_cache_group_id=kv_cache_group_id,
297
+ )
298
+
299
+ partial_entry_hash = pool.cache_partial_block(
300
+ request=req,
301
+ block=blocks[1],
302
+ num_tokens=10,
303
+ kv_cache_group_id=kv_cache_group_id,
304
+ block_size=block_size,
305
+ )
306
+ # The partial entry is keyed by the fine-grained hash at the 10-token
307
+ # boundary, regardless of the owning group's block_size.
308
+ expected = boundary_hash(req, hash_block_size, 10)
309
+ assert partial_entry_hash == kv_cache_utils.make_block_hash_with_group_id(
310
+ expected, kv_cache_group_id
311
+ )
312
+ assert pool.get_cached_block(expected, [kv_cache_group_id]) == [blocks[1]]
313
+
314
+
315
+def test_cache_partial_block_requires_hash_boundary():
316
+ hash_block_size = 2
317
+ block_size = 4
318
+ req = make_request("0", [0, 0, 1, 1], hash_block_size, sha256)
319
+ pool = BlockPool(
320
+ num_gpu_blocks=2,
321
+ enable_caching=True,
322
+ hash_block_size=hash_block_size,
323
+ )
324
+ block = pool.get_new_blocks(1)[0]
325
+
326
+ with pytest.raises(AssertionError):
327
+ pool.cache_partial_block(
328
+ request=req,
329
+ block=block,
330
+ num_tokens=3,
331
+ kv_cache_group_id=0,
332
+ block_size=block_size,
333
+ )
334
+
335
+
336
+def test_cache_partial_block_duplicate_checks_all_blocks_for_hash():
337
+ hash_block_size = 2
338
+ block_size = 4
339
+ kv_cache_group_id = 0
340
+ req = make_request("0", [0, 0, 1, 1], hash_block_size, sha256)
341
+ pool = BlockPool(
342
+ num_gpu_blocks=4,
343
+ enable_caching=True,
344
+ hash_block_size=hash_block_size,
345
+ )
346
+ blocks = pool.get_new_blocks(2)
347
+
348
+ first_entry_hash = pool.cache_partial_block(
349
+ request=req,
350
+ block=blocks[0],
351
+ num_tokens=2,
352
+ kv_cache_group_id=kv_cache_group_id,
353
+ block_size=block_size,
354
+ )
355
+ second_entry_hash = pool.cache_partial_block(
356
+ request=req,
357
+ block=blocks[1],
358
+ num_tokens=2,
359
+ kv_cache_group_id=kv_cache_group_id,
360
+ block_size=block_size,
361
+ )
362
+ assert first_entry_hash == second_entry_hash
363
+
364
+ duplicate_entry_hash = pool.cache_partial_block(
365
+ request=req,
366
+ block=blocks[1],
367
+ num_tokens=2,
368
+ kv_cache_group_id=kv_cache_group_id,
369
+ block_size=block_size,
370
+ )
371
+ assert duplicate_entry_hash == second_entry_hash
372
+ assert pool.cached_block_hashes_by_block == {}
373
+
374
+
375
+def test_reset_prefix_cache_clears_partial_entry_metadata():
376
+ pool, req, blocks, partial_hash_10 = cache_full_block_and_partial_tail(
377
+ [0, 0, 1, 1, 2, 2, 3, 3, 4, 4]
378
+ )
379
+ full_hash = BlockHashListWithBlockSize(req.block_hashes, 2, 6)[0]
380
+
381
+ assert pool.get_cached_block(full_hash, [0]) == [blocks[0]]
382
+ assert pool.get_cached_block(partial_hash_10, [0]) == [blocks[1]]
383
+
384
+ pool.free_blocks(blocks)
385
+ assert pool.reset_prefix_cache()
386
+
387
+ assert pool.get_cached_block(full_hash, [0]) is None
388
+ assert pool.get_cached_block(partial_hash_10, [0]) is None
389
+ assert pool.cached_block_hashes_by_block == {}
390
+
391
+
392
+def test_evict_cached_block_removes_full_hash_and_partial_entry():
393
+ pool, req, blocks, partial_hash_10 = cache_full_block_and_partial_tail(
394
+ [0, 0, 1, 1, 2, 2, 3, 3, 4, 4]
395
+ )
396
+ full_hash = BlockHashListWithBlockSize(req.block_hashes, 2, 6)[0]
397
+
398
+ assert pool.get_cached_block(full_hash, [0]) == [blocks[0]]
399
+ assert pool.get_cached_block(partial_hash_10, [0]) == [blocks[1]]
400
+
401
+ pool.evict_blocks({blocks[0].block_id, blocks[1].block_id})
402
+
403
+ assert pool.get_cached_block(full_hash, [0]) is None
404
+ assert pool.get_cached_block(partial_hash_10, [0]) is None
405
+ assert pool.cached_block_hashes_by_block == {}
406
+
407
+
408
+def test_partial_block_promotes_to_direct_full_block_hash():
409
+ hash_block_size = 2
410
+ block_size = 6
411
+ kv_cache_group_id = 0
412
+ token_ids = [0, 0, 1, 1, 2, 2, 3, 3, 4, 4]
413
+ req = make_request("0", token_ids, hash_block_size, sha256)
414
+ pool = BlockPool(
415
+ num_gpu_blocks=3,
416
+ enable_caching=True,
417
+ hash_block_size=hash_block_size,
418
+ )
419
+ blocks = pool.get_new_blocks(2)
420
+
421
+ pool.cache_full_blocks(
422
+ request=req,
423
+ blocks=blocks,
424
+ num_cached_blocks=0,
425
+ num_full_blocks=1,
426
+ block_size=block_size,
427
+ kv_cache_group_id=kv_cache_group_id,
428
+ )
429
+ partial_hash_10 = boundary_hash(req, hash_block_size, 10)
430
+ assert pool.cache_partial_block(
431
+ request=req,
432
+ block=blocks[1],
433
+ num_tokens=10,
434
+ kv_cache_group_id=kv_cache_group_id,
435
+ block_size=block_size,
436
+ )
437
+ assert pool.get_cached_block(partial_hash_10, [kv_cache_group_id]) == [blocks[1]]
438
+
439
+ req.append_output_token_ids([5, 5])
440
+ full_hashes = BlockHashListWithBlockSize(
441
+ req.block_hashes, hash_block_size, block_size
442
+ )
443
+ promoted_full_hash = full_hashes[1]
444
+ # The promoted full-block hash is the fine hash at the 12-token boundary,
445
+ # not a concatenation of the fine hashes inside the block.
446
+ assert promoted_full_hash == req.block_hashes[12 // hash_block_size - 1]
447
+ assert promoted_full_hash != BlockHash(
448
+ req.block_hashes[3] + req.block_hashes[4] + req.block_hashes[5]
449
+ )
450
+
451
+ pool.cache_full_blocks(
452
+ request=req,
453
+ blocks=blocks,
454
+ num_cached_blocks=1,
455
+ num_full_blocks=2,
456
+ block_size=block_size,
457
+ kv_cache_group_id=kv_cache_group_id,
458
+ )
459
+ assert pool.get_cached_block(promoted_full_hash, [kv_cache_group_id]) == [blocks[1]]
460
+ assert pool.get_cached_block(partial_hash_10, [kv_cache_group_id]) is None
tests/v1/core/test_kv_cache_utils.py
+1
-1
@@ -225,7 +225,7 @@ def test_kv_cache_block():
225
226
# Test block hash setting and resetting
227
block_hash = make_block_hash_with_group_id(BlockHash(b"abc"), 0)
228
- block.block_hash = block_hash
228
+ block.set_block_hash(block_hash)
229
assert block.block_hash == block_hash
230
231
block.reset_hash()
tests/v1/core/test_prefix_caching.py
+1
-1
@@ -2003,7 +2003,7 @@ def test_maybe_evict_cached_block():
2003
assert len(pool.blocks) == len(block_hashes)
2004
# Manually add all blocks to cached_blocks
2005
for block, block_hash in zip(pool.blocks, block_hashes):
2006
- block.block_hash = block_hash
2006
+ block.set_block_hash(block_hash)
2007
pool.cached_block_hash_to_block.insert(block_hash, block)
2008
2009
block0, block1, block2, block3 = pool.blocks
vllm/v1/core/block_pool.py
+219
-23
@@ -72,6 +72,20 @@ class BlockHashToBlockMap:
72
self._unexpected_blocks_type(blocks)
73
return None
74
75
+ def contain(self, key: BlockHashWithGroupId, block_id: int) -> bool:
76
+ """
77
+ Checks whether the key maps to the given block ID.
78
+ """
79
+ blocks = self._cache.get(key)
80
+ if blocks is None:
81
+ return False
82
+ if isinstance(blocks, KVCacheBlock):
83
+ return blocks.block_id == block_id
84
+ if isinstance(blocks, dict):
85
+ return block_id in blocks
86
+ self._unexpected_blocks_type(blocks)
87
+ return False
88
+
89
def insert(self, key: BlockHashWithGroupId, block: KVCacheBlock) -> None:
90
"""
91
Inserts the KVCacheBlock to the cache
@@ -169,6 +183,7 @@ class BlockPool:
183
184
# Cache for block lookup
185
self.cached_block_hash_to_block: BlockHashToBlockMap = BlockHashToBlockMap()
186
+ self.cached_block_hashes_by_block: dict[int, set[BlockHashWithGroupId]] = {}
187
188
# To represent a placeholder block with block_id=0.
189
# The ref_cnt of null_block is not maintained, needs special care to
@@ -245,7 +260,6 @@ class BlockPool:
260
if num_cached_blocks >= num_full_blocks:
261
return
262
new_full_blocks = blocks[num_cached_blocks:num_full_blocks]
248
- assert len(request.block_hashes) >= num_full_blocks
263
assert block_mask is None or len(block_mask) == len(new_full_blocks)
264
if block_size == self.hash_block_size:
265
# Common case.
@@ -254,11 +268,10 @@ class BlockPool:
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
257
- # Recalculate block_hashes at the granularity of block_size, using
258
- # the original block_hashes (at the granularity of hash_block_size).
271
block_hashes = BlockHashListWithBlockSize(
272
request.block_hashes, self.hash_block_size, block_size
273
)
274
+ assert len(block_hashes) >= num_full_blocks
275
276
new_block_hashes = block_hashes[num_cached_blocks:]
277
new_hashes: list[ExternalBlockHash] | None = (
@@ -270,15 +283,27 @@ class BlockPool:
283
# in align mode. We skip null blocks here.
284
if blk.is_null or (block_mask is not None and not block_mask[i]):
285
continue
273
- assert blk.block_hash is None
286
block_hash = new_block_hashes[i]
287
+ num_hash_tokens = (num_cached_blocks + i + 1) * block_size
288
289
# Update and added the full block to the cache.
290
block_hash_with_group_id = make_block_hash_with_group_id(
291
block_hash, kv_cache_group_id
292
)
280
- blk.block_hash = block_hash_with_group_id
281
- self.cached_block_hash_to_block.insert(block_hash_with_group_id, blk)
293
+ if blk.block_hash is not None:
294
+ # The only valid case where a "new full block" already has a
295
+ # hash is partial->full promotion of the same cache block.
296
+ assert (
297
+ blk.block_hash_num_tokens is not None
298
+ and blk.block_hash_num_tokens < num_hash_tokens
299
+ )
300
+ removed_hashes = self._remove_cached_block_hashes(blk)
301
+ self._emit_block_removed_events(removed_hashes)
302
+ self._insert_block_hash(
303
+ block_hash_with_group_id,
304
+ blk,
305
+ num_tokens=num_hash_tokens,
306
+ )
307
if new_hashes is not None:
308
new_hashes.append(maybe_convert_block_hash(block_hash))
309
@@ -330,6 +355,190 @@ class BlockPool:
355
)
356
)
357
358
+ def cache_partial_block(
359
+ self,
360
+ request: Request,
361
+ block: KVCacheBlock,
362
+ num_tokens: int,
363
+ kv_cache_group_id: int,
364
+ block_size: int,
365
+ ) -> BlockHashWithGroupId | None:
366
+ """Register a partial prefix-cache entry for an existing block.
367
+
368
+ Prefix-cache keys normally identify full cache blocks. A partial entry
369
+ makes an existing cache block reachable from a fine-grained prefix
370
+ boundary inside that block without allocating or copying a new
371
+ ``KVCacheBlock``.
372
+
373
+ The partial entry is lookup metadata owned by ``block``. If ``block``
374
+ has no primary hash, the key becomes its primary hash. If the block
375
+ already has a primary hash, the partial entry is tracked in
376
+ ``cached_block_hashes_by_block`` so eviction, reset, and promotion can
377
+ remove every hash key that points to the block.
378
+
379
+ Args:
380
+ request: Request whose token IDs and block hashes define the
381
+ partial entry.
382
+ block: Existing cache block to make reachable from the partial
383
+ prefix boundary.
384
+ num_tokens: Prefix length represented by the partial entry. It
385
+ must be a positive multiple of ``self.hash_block_size`` and
386
+ cannot exceed the request's computed block hashes.
387
+ kv_cache_group_id: KV cache group that owns the partial entry.
388
+ block_size: Cache block size for the owning group. The partial
389
+ entry hash itself is always the prefix-chain hash at
390
+ ``num_tokens``; ``block_size`` is used to assert that the
391
+ entry is partial within the owning cache block.
392
+
393
+ Returns:
394
+ The hash key with group ID if a partial entry can be registered;
395
+ otherwise ``None`` for null blocks.
396
+ """
397
+ if block.is_null:
398
+ return None
399
+
400
+ assert block_size > self.hash_block_size
401
+ assert block_size % self.hash_block_size == 0
402
+ assert num_tokens % block_size != 0
403
+ block_hash = self._get_partial_block_hash(request, num_tokens)
404
+ num_hash_blocks = num_tokens // self.hash_block_size
405
+ block_hash_with_group_id = make_block_hash_with_group_id(
406
+ block_hash, kv_cache_group_id
407
+ )
408
+ already_cached = block.block_hash == block_hash_with_group_id or (
409
+ self.cached_block_hash_to_block.contain(
410
+ block_hash_with_group_id, block.block_id
411
+ )
412
+ )
413
+ if (
414
+ not already_cached
415
+ and block.block_hash is not None
416
+ and block.block_hash_num_tokens is not None
417
+ and block.block_hash_num_tokens < num_hash_blocks * self.hash_block_size
418
+ ):
419
+ removed_hashes = self._remove_cached_block_hashes(block)
420
+ self._emit_block_removed_events(removed_hashes)
421
+ self._insert_block_hash(
422
+ block_hash_with_group_id,
423
+ block,
424
+ num_tokens=num_hash_blocks * self.hash_block_size,
425
+ )
426
+ if self.enable_kv_cache_events and not already_cached:
427
+ parent_hash, block_start = self._get_partial_block_parent_hash_and_start(
428
+ request, num_tokens
429
+ )
430
+ parent_block_hash = (
431
+ maybe_convert_block_hash(parent_hash)
432
+ if parent_hash is not None
433
+ else None
434
+ )
435
+ block_end = num_tokens
436
+ curr_mm_idx = -1 if block_start > 0 else 0
437
+ extra_keys, _ = generate_block_hash_extra_keys(
438
+ request, block_start, block_end, curr_mm_idx
439
+ )
440
+ self.kv_event_queue.append(
441
+ BlockStored(
442
+ block_hashes=[maybe_convert_block_hash(block_hash)],
443
+ parent_block_hash=parent_block_hash,
444
+ token_ids=request.all_token_ids[block_start:block_end],
445
+ block_size=block_end - block_start,
446
+ lora_id=request.lora_request.adapter_id
447
+ if request.lora_request
448
+ else None,
449
+ medium=MEDIUM_GPU,
450
+ lora_name=request.lora_request.name
451
+ if request.lora_request
452
+ else None,
453
+ extra_keys=[extra_keys],
454
+ group_idx=kv_cache_group_id,
455
+ )
456
+ )
457
+ return block_hash_with_group_id
458
+
459
+ def _get_partial_block_hash(
460
+ self,
461
+ request: Request,
462
+ num_tokens: int,
463
+ ) -> BlockHash:
464
+ assert num_tokens % self.hash_block_size == 0
465
+ num_hash_blocks = num_tokens // self.hash_block_size
466
+ assert 0 < num_hash_blocks <= len(request.block_hashes)
467
+
468
+ # Each hash_block_size hash chains over its full prefix, so the partial
469
+ # entry for any group block size is the hash at that prefix boundary.
470
+ return request.block_hashes[num_hash_blocks - 1]
471
+
472
+ def _get_partial_block_parent_hash_and_start(
473
+ self,
474
+ request: Request,
475
+ num_tokens: int,
476
+ ) -> tuple[BlockHash | None, int]:
477
+ num_hash_blocks = num_tokens // self.hash_block_size
478
+ parent_hash = (
479
+ request.block_hashes[num_hash_blocks - 2] if num_hash_blocks > 1 else None
480
+ )
481
+ block_start = (num_hash_blocks - 1) * self.hash_block_size
482
+ return parent_hash, block_start
483
+
484
+ def _remove_cached_block_hashes(
485
+ self,
486
+ block: KVCacheBlock,
487
+ ) -> list[BlockHashWithGroupId]:
488
+ block_hashes: list[BlockHashWithGroupId] = []
489
+ if block.block_hash is not None:
490
+ block_hashes.append(block.block_hash)
491
+ block_hashes.extend(self.cached_block_hashes_by_block.pop(block.block_id, ()))
492
+ if not block_hashes:
493
+ return []
494
+
495
+ removed_hashes: list[BlockHashWithGroupId] = []
496
+ for block_hash in block_hashes:
497
+ if (
498
+ self.cached_block_hash_to_block.pop(block_hash, block.block_id)
499
+ is not None
500
+ ):
501
+ removed_hashes.append(block_hash)
502
+ block.reset_hash()
503
+ return removed_hashes
504
+
505
+ def _emit_block_removed_events(
506
+ self,
507
+ block_hashes: list[BlockHashWithGroupId],
508
+ ) -> None:
509
+ if not self.enable_kv_cache_events:
510
+ return
511
+ for block_hash in block_hashes:
512
+ self.kv_event_queue.append(
513
+ BlockRemoved(
514
+ block_hashes=[maybe_convert_block_hash(get_block_hash(block_hash))],
515
+ medium=MEDIUM_GPU,
516
+ group_idx=get_group_id(block_hash),
517
+ )
518
+ )
519
+
520
+ def _insert_block_hash(
521
+ self,
522
+ block_hash_with_group_id: BlockHashWithGroupId,
523
+ block: KVCacheBlock,
524
+ num_tokens: int | None,
525
+ ) -> None:
526
+ if block.block_hash == block_hash_with_group_id:
527
+ return
528
+
529
+ if self.cached_block_hash_to_block.contain(
530
+ block_hash_with_group_id, block.block_id
531
+ ):
532
+ return
533
+
534
+ if block.block_hash is None:
535
+ block.set_block_hash(block_hash_with_group_id, num_tokens=num_tokens)
536
+ else:
537
+ self.cached_block_hashes_by_block.setdefault(block.block_id, set()).add(
538
+ block_hash_with_group_id
539
+ )
540
+ self.cached_block_hash_to_block.insert(block_hash_with_group_id, block)
541
+
542
def get_new_blocks(self, num_blocks: int) -> list[KVCacheBlock]:
543
"""Get new blocks from the free block pool.
544
@@ -377,26 +586,12 @@ class BlockPool:
586
if self.metrics_collector:
587
self.metrics_collector.on_block_evicted(block)
588
380
- block_hash = block.block_hash
381
- if block_hash is None:
589
+ evicted_hashes = self._remove_cached_block_hashes(block)
590
+ if not evicted_hashes:
591
# The block doesn't have hash, eviction is not needed
592
return False
593
385
- if self.cached_block_hash_to_block.pop(block_hash, block.block_id) is None:
386
- # block not found in cached_block_hash_to_block,
387
- # eviction is not needed
388
- return False
389
-
390
- block.reset_hash()
391
-
392
- if self.enable_kv_cache_events:
393
- self.kv_event_queue.append(
394
- BlockRemoved(
395
- block_hashes=[maybe_convert_block_hash(get_block_hash(block_hash))],
396
- medium=MEDIUM_GPU,
397
- group_idx=get_group_id(block_hash),
398
- )
399
- )
594
+ self._emit_block_removed_events(evicted_hashes)
595
return True
596
597
def touch(self, blocks: Sequence[KVCacheBlock]) -> None:
@@ -478,6 +673,7 @@ class BlockPool:
673
674
# Remove all hashes so that no new blocks will hit.
675
self.cached_block_hash_to_block = BlockHashToBlockMap()
676
+ self.cached_block_hashes_by_block.clear()
677
678
# Remove all hashes from all blocks.
679
for block in self.blocks:
vllm/v1/core/kv_cache_utils.py
+38
-16
@@ -125,6 +125,9 @@ class KVCacheBlock:
125
# The hash key (block hash + group id) of the block, only available
126
# when the block is full and cached.
127
_block_hash: BlockHashWithGroupId | None = None
128
+ # Number of prefix tokens covered by _block_hash. For full blocks this is
129
+ # the full block boundary; partial aliases can end inside a cache block.
130
+ _block_hash_num_tokens: int | None = None
131
132
# Used to construct a doubly linked list for free blocks.
133
# These two attributes should only be manipulated by FreeKVCacheBlockQueue.
@@ -138,16 +141,25 @@ class KVCacheBlock:
141
def block_hash(self) -> BlockHashWithGroupId | None:
142
return self._block_hash
143
141
- @block_hash.setter
142
- def block_hash(self, block_hash: BlockHashWithGroupId):
143
- assert self.block_hash is None, (
144
+ @property
145
+ def block_hash_num_tokens(self) -> int | None:
146
+ return self._block_hash_num_tokens
147
+
148
+ def set_block_hash(
149
+ self,
150
+ block_hash: BlockHashWithGroupId,
151
+ num_tokens: int | None = None,
152
+ ) -> None:
153
+ assert self.block_hash is None and self._block_hash_num_tokens is None, (
154
"The block already has a hash. This should not happen."
155
)
156
self._block_hash = block_hash
157
+ self._block_hash_num_tokens = num_tokens
158
159
def reset_hash(self):
160
"""Reset the block hash when the block is evicted."""
161
self._block_hash = None
162
+ self._block_hash_num_tokens = None
163
164
def __repr__(self) -> str:
165
# Use block_id instead of KVCacheBlock object to avoid calling __repr__
@@ -158,6 +170,7 @@ class KVCacheBlock:
170
f"KVCacheBlock(block_id={self.block_id}, "
171
f"ref_cnt={self.ref_cnt}, "
172
f"_block_hash={self._block_hash!r}, "
173
+ f"_block_hash_num_tokens={self._block_hash_num_tokens}, "
174
f"prev_free_block={prev_block_id}, "
175
f"next_free_block={next_block_id})"
176
)
@@ -658,18 +671,24 @@ def resolve_kv_cache_block_sizes(
671
672
673
def get_request_block_hasher(
661
- block_size: int,
674
+ hash_block_size: int,
675
caching_hash_fn: Callable[[Any], bytes],
676
) -> Callable[[Request], list[BlockHash]]:
677
"""
678
Returns a function which computes the list of un-computed block hashes
666
- of a request."""
679
+ of a request.
680
+
681
+ Hashes are computed at ``hash_block_size`` granularity and chained over the
682
+ full prefix, so each hash uniquely fingerprints the prefix ending at its
683
+ boundary. Coarser group block sizes and partial-cache boundaries reuse
684
+ these hashes directly (see ``BlockHashListWithBlockSize``).
685
+ """
686
687
def request_block_hasher(request: Request) -> list[BlockHash]:
669
- start_token_idx = len(request.block_hashes) * block_size
688
+ start_token_idx = len(request.block_hashes) * hash_block_size
689
num_tokens = request.num_tokens
690
672
- if start_token_idx + block_size > num_tokens:
691
+ if start_token_idx + hash_block_size > num_tokens:
692
# Early stop when there no new full blocks created.
693
return []
694
@@ -686,7 +705,7 @@ def get_request_block_hasher(
705
)
706
new_block_hashes: list[BlockHash] = []
707
while True:
689
- end_token_idx = start_token_idx + block_size
708
+ end_token_idx = start_token_idx + hash_block_size
709
if end_token_idx > num_tokens:
710
# We only hash full blocks
711
break
@@ -703,7 +722,7 @@ def get_request_block_hasher(
722
)
723
724
new_block_hashes.append(block_hash)
706
- start_token_idx += block_size
725
+ start_token_idx += hash_block_size
726
prev_block_hash_value = block_hash
727
728
return new_block_hashes
@@ -2132,11 +2151,14 @@ class BlockHashListWithBlockSize:
2151
2152
Currently, only scaling up by an integer factor is supported (i.e.,
2153
`target_block_size` is a multiple of `hash_block_size`). Conversion is
2135
- performed lazily on access for efficiency, by concatenating consecutive
2136
- hashes at `hash_block_size` to form each hash at `target_block_size`.
2154
+ performed lazily on access for efficiency. Each `hash_block_size` hash is
2155
+ already chained over its entire prefix, so the hash at the last
2156
+ `hash_block_size` boundary of a `target_block_size` block uniquely
2157
+ fingerprints that block's prefix; we use it directly.
2158
2159
Example (`hash_block_size` = 16, `target_block_size` = 32):
2139
- concatenating two 16-size hashes yields one 32-size hash:
2160
+ the second 16-size hash already covers tokens 0-31, so it is the 32-size
2161
+ hash:
2162
2163
Block hashes with block_size 16:
2164
| Token Range | 0-15 | 16-31 | 32-47 | 48-63 |
@@ -2146,7 +2168,7 @@ class BlockHashListWithBlockSize:
2168
Block hashes with block_size 32:
2169
| Token Range | 0-31 | 32-63 |
2170
|-------------|------|-------|
2149
- | Hash | AB | CD |
2171
+ | Hash | B | D |
2172
2173
Args:
2174
block_hashes: Block hashes to convert, computed at `hash_block_size`.
@@ -2188,9 +2210,9 @@ class BlockHashListWithBlockSize:
2210
yield self._get_value_at(i)
2211
2212
def _get_value_at(self, idx: int) -> BlockHash:
2191
- base = idx * self.scale_factor
2192
- end = base + self.scale_factor
2193
- return BlockHash(b"".join(self.block_hashes[base:end]))
2213
+ # The last hash_block_size hash within the target block already chains
2214
+ # over the whole prefix, so it is the target block's hash.
2215
+ return self.block_hashes[(idx + 1) * self.scale_factor - 1]
2216
2217
2218
BlockHashList = list[BlockHash] | BlockHashListWithBlockSize