A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 14, 05:30 UTC
[Core] Use KVCacheBlock as much as possible instead of dict[block_id, KVCacheBlock] (#24830)
Signed-off-by: Jialin Ouyang <Jialin.Ouyang@gmail.com>
Jialin Ouyang committed
Sep 23, 2025 at 15:11 UTC
4f8c4b890ac1ca57b2f80278641fbe3f9a6abfba
3 files changed
+247
-87
tests/v1/core/test_prefix_caching.py
+127
-51
@@ -14,10 +14,11 @@ from vllm.multimodal.inputs import (MultiModalFeatureSpec,
14
MultiModalKwargsItem, PlaceholderRange)
15
from vllm.sampling_params import SamplingParams
16
from vllm.utils import sha256, sha256_cbor
17
-from vllm.v1.core.block_pool import BlockPool
17
+from vllm.v1.core.block_pool import BlockHashToBlockMap, BlockPool
18
from vllm.v1.core.kv_cache_manager import KVCacheManager, Request
19
-from vllm.v1.core.kv_cache_utils import (BlockHash, KVCacheBlock,
20
- get_block_hash, get_group_id,
19
+from vllm.v1.core.kv_cache_utils import (BlockHash, BlockHashWithGroupId,
20
+ KVCacheBlock, get_block_hash,
21
+ get_group_id,
22
get_request_block_hasher,
23
hash_block_tokens, init_none_hash,
24
make_block_hash_with_group_id)
@@ -138,7 +139,7 @@ def test_prefill(hash_fn):
139
blocks = manager.allocate_slots(req0, 55,
140
len(computed_blocks.blocks[0]) * 16,
141
computed_blocks)
141
- assert blocks.get_block_ids() == ([1, 2, 3, 4], )
142
+ assert blocks is not None and blocks.get_block_ids() == ([1, 2, 3, 4], )
143
144
# Check full block metadata
145
parent_block_hash = None
@@ -171,7 +172,7 @@ def test_prefill(hash_fn):
172
blocks = manager.allocate_slots(req1, num_new_tokens,
173
len(computed_blocks.blocks[0]) * 16,
174
computed_blocks)
174
- assert blocks.get_block_ids() == ([5], )
175
+ assert blocks is not None and blocks.get_block_ids() == ([5], )
176
for block in computed_blocks.blocks[0]:
177
assert block.ref_cnt == 2
178
@@ -207,7 +208,7 @@ def test_prefill(hash_fn):
208
blocks = manager.allocate_slots(req2, num_new_tokens,
209
len(computed_blocks.blocks[0]) * 16,
210
computed_blocks)
210
- assert blocks.get_block_ids() == ([6], )
211
+ assert blocks is not None and blocks.get_block_ids() == ([6], )
212
213
# Although we only have 6 free blocks, we have 8 blocks in
214
# the free block queue due to lazy removal.
@@ -227,7 +228,9 @@ def test_prefill(hash_fn):
228
len(computed_blocks.blocks[0]) * 16,
229
computed_blocks)
230
# This block ID order also checks the eviction order.
230
- assert blocks.get_block_ids() == ([7, 8, 9, 10, 4, 5, 6, 3, 2, 1], )
231
+ assert blocks is not None and blocks.get_block_ids() == ([
232
+ 7, 8, 9, 10, 4, 5, 6, 3, 2, 1
233
+ ], )
234
235
assert free_block_queue.num_free_blocks == 0
236
assert (free_block_queue.fake_free_list_head.next_free_block
@@ -261,8 +264,9 @@ def test_prefill_hybrid_model():
264
blocks = manager.allocate_slots(req0, 55,
265
len(computed_blocks.blocks[0]) * 16,
266
computed_blocks)
264
- assert blocks.get_block_ids() == ([1, 2, 3, 4], [5, 6, 7,
265
- 8], [9, 10, 11, 12])
267
+ assert blocks is not None and blocks.get_block_ids() == ([1, 2, 3, 4], [
268
+ 5, 6, 7, 8
269
+ ], [9, 10, 11, 12])
270
271
# Check full block metadata
272
parent_block_hash = None
@@ -298,7 +302,7 @@ def test_prefill_hybrid_model():
302
blocks = manager.allocate_slots(req1, num_new_tokens,
303
len(computed_blocks.blocks[0]) * 16,
304
computed_blocks)
301
- assert blocks.get_block_ids() == ([13], [14], [15])
305
+ assert blocks is not None and blocks.get_block_ids() == ([13], [14], [15])
306
for block_per_group in computed_blocks.blocks:
307
for block in block_per_group:
308
if block != manager.block_pool.null_block:
@@ -309,14 +313,15 @@ def test_prefill_hybrid_model():
313
manager.free(req1)
314
315
cached_block_hash_to_block_bak = copy.copy(
312
- manager.block_pool.cached_block_hash_to_block)
316
+ manager.block_pool.cached_block_hash_to_block._cache)
317
314
- def test_partial_request_hit(request_id: str, hash_to_evict: list[bytes],
318
+ def test_partial_request_hit(request_id: str,
319
+ hash_to_evict: list[BlockHashWithGroupId],
320
expect_hit_length: int):
321
req = make_request(request_id, common_token_ids + unique_token_ids,
322
block_size, sha256)
323
for hash_with_group_id in hash_to_evict:
319
- manager.block_pool.cached_block_hash_to_block.pop(
324
+ manager.block_pool.cached_block_hash_to_block._cache.pop(
325
hash_with_group_id)
326
computed_blocks, num_computed_tokens = manager.get_computed_blocks(req)
327
assert len(req.block_hashes) == 3
@@ -324,7 +329,7 @@ def test_prefill_hybrid_model():
329
for block_per_group in computed_blocks.blocks:
330
assert len(block_per_group) == num_computed_tokens // block_size
331
for hash_with_group_id in hash_to_evict:
327
- manager.block_pool.cached_block_hash_to_block[
332
+ manager.block_pool.cached_block_hash_to_block._cache[
333
hash_with_group_id] = cached_block_hash_to_block_bak[
334
hash_with_group_id]
335
manager.free(req)
@@ -362,7 +367,8 @@ def test_prefill_hybrid_model():
367
# total cache miss.
368
# The cache hit length of full attention is 1 * block_size.
369
# The cache hit length of sliding window is 2 * block_size.
365
- # Then it is cache miss as the two type of layers have different hit length.
370
+ # Then it is cache miss as the two type of layers
371
+ # have different hit length.
372
test_partial_request_hit("8", [
373
make_block_hash_with_group_id(block_hashes[2], 0),
374
make_block_hash_with_group_id(block_hashes[0], 1),
@@ -406,7 +412,7 @@ def test_prefill_plp():
412
blocks = manager.allocate_slots(req0, 55,
413
len(computed_blocks.blocks[0]) * 16,
414
computed_blocks)
409
- assert blocks.get_block_ids() == ([1, 2, 3, 4], )
415
+ assert blocks is not None and blocks.get_block_ids() == ([1, 2, 3, 4], )
416
req0_block_hashes = [b.block_hash for b in blocks.blocks[0]]
417
418
# Check full block metadata
@@ -441,7 +447,7 @@ def test_prefill_plp():
447
blocks = manager.allocate_slots(req1, num_new_tokens,
448
len(computed_blocks.blocks[0]) * 16,
449
computed_blocks)
444
- assert blocks.get_block_ids() == ([5], )
450
+ assert blocks is not None and blocks.get_block_ids() == ([5], )
451
for block in computed_blocks.blocks[0]:
452
assert block.ref_cnt == 2
453
@@ -478,6 +484,7 @@ def test_prefill_plp():
484
blocks = manager.allocate_slots(req2, 55,
485
len(computed_blocks.blocks[0]) * 16,
486
computed_blocks)
487
+ assert blocks is not None
488
block_ids = blocks.get_block_ids()
489
# Duplicate cached blocks have different ids but same hashes vs request #0
490
assert [b.block_hash for b in blocks.blocks[0]] == req0_block_hashes
@@ -513,7 +520,7 @@ def test_decode():
520
blocks = manager.allocate_slots(req0, 55,
521
len(computed_blocks.blocks[0]) * 16,
522
computed_blocks)
516
- assert blocks.get_block_ids() == ([1, 2, 3, 4], )
523
+ assert blocks is not None and blocks.get_block_ids() == ([1, 2, 3, 4], )
524
525
# Append slots without allocating a new block.
526
req0.num_computed_tokens = 55
@@ -558,7 +565,8 @@ def test_evict():
565
blocks = manager.allocate_slots(req0, 5 * 16 + 7,
566
len(computed_blocks.blocks[0]) * 16,
567
computed_blocks)
561
- assert len(blocks.blocks[0]) == 6 # 5 full + 1 partial
568
+ # 5 full + 1 partial
569
+ assert blocks is not None and len(blocks.blocks[0]) == 6
570
571
# 3 blocks.
572
req1 = make_request("1", list(range(last_token_id,
@@ -570,7 +578,7 @@ def test_evict():
578
blocks = manager.allocate_slots(req1, 3 * 16,
579
len(computed_blocks.blocks[0]) * 16,
580
computed_blocks)
573
- assert len(blocks.blocks[0]) == 3 # 3 full blocks
581
+ assert blocks is not None and len(blocks.blocks[0]) == 3 # 3 full blocks
582
last_token_id += 3 * 16
583
584
# 10 - (6 + 3) == 1
@@ -592,7 +600,7 @@ def test_evict():
600
blocks = manager.allocate_slots(req2, 3,
601
len(computed_blocks.blocks[0]) * 16,
602
computed_blocks)
595
- assert blocks.get_block_ids() == ([10], )
603
+ assert blocks is not None and blocks.get_block_ids() == ([10], )
604
assert manager.block_pool.free_block_queue.num_free_blocks == 7
605
606
@@ -617,7 +625,7 @@ def test_hash_block_correct_reuse():
625
blocks = manager.allocate_slots(req, num_tokens,
626
len(computed_blocks.blocks[0]) * 16,
627
computed_blocks)
620
- assert len(blocks.blocks[0]) == 1
628
+ assert blocks is not None and len(blocks.blocks[0]) == 1
629
630
# Deallocate the block.
631
manager.free(req)
@@ -631,7 +639,7 @@ def test_hash_block_correct_reuse():
639
blocks = manager.allocate_slots(req, num_tokens - 1,
640
len(computed_blocks.blocks[0]) * 16,
641
computed_blocks)
634
- assert len(blocks.blocks[0]) == 1
642
+ assert blocks is not None and len(blocks.blocks[0]) == 1
643
644
assert manager.block_pool.blocks[blocks.blocks[0]
645
[0].block_id].block_hash is None
@@ -658,7 +666,7 @@ def test_computed_blocks_not_evicted():
666
blocks = manager.allocate_slots(req0, num_tokens,
667
len(computed_blocks.blocks[0]) * 16,
668
computed_blocks)
661
- assert len(blocks.blocks[0]) == 1
669
+ assert blocks is not None and len(blocks.blocks[0]) == 1
670
assert blocks.blocks[0][0].block_id == 1
671
672
# Allocate another block.
@@ -670,7 +678,7 @@ def test_computed_blocks_not_evicted():
678
blocks = manager.allocate_slots(req1, num_tokens,
679
len(computed_blocks.blocks[0]) * 16,
680
computed_blocks)
673
- assert len(blocks.blocks[0]) == 1
681
+ assert blocks is not None and len(blocks.blocks[0]) == 1
682
assert blocks.blocks[0][0].block_id == 2
683
684
# Free the blocks.
@@ -688,7 +696,7 @@ def test_computed_blocks_not_evicted():
696
blocks = manager.allocate_slots(req2, num_tokens * 2 - num_tokens,
697
len(computed_blocks.blocks[0]) * 16,
698
computed_blocks)
691
- assert len(blocks.blocks[0]) == 1
699
+ assert blocks is not None and len(blocks.blocks[0]) == 1
700
assert blocks.blocks[0][0].block_id == 2
701
702
@@ -712,7 +720,7 @@ def test_basic_prefix_caching_disabled():
720
blocks = manager.allocate_slots(req1, 10,
721
len(computed_blocks.blocks[0]) * 16,
722
computed_blocks)
715
- assert len(blocks.blocks[0]) == 3
723
+ assert blocks is not None and len(blocks.blocks[0]) == 3
724
725
# Free the blocks.
726
manager.free(req1)
@@ -726,7 +734,7 @@ def test_basic_prefix_caching_disabled():
734
blocks = manager.allocate_slots(req2, 16,
735
len(computed_blocks.blocks[0]) * 16,
736
computed_blocks)
729
- assert len(blocks.blocks[0]) == 4
737
+ assert blocks is not None and len(blocks.blocks[0]) == 4
738
739
# New requests should not have any blocks.
740
req3 = make_request("3", list(range(4)), block_size, sha256)
@@ -773,7 +781,8 @@ def test_cache_blocks(hash_fn):
781
assert len(block_pool.cached_block_hash_to_block) == 2
782
assert all([block.block_hash is not None for block in blocks])
783
776
- # Test that blocks that don't start from the beginning are cached correctly.
784
+ # Test that blocks that don't start from the beginning are cached
785
+ # correctly.
786
blocks += [KVCacheBlock(block_id=2)]
787
block_pool.cache_full_blocks(
788
request=req,
@@ -1101,7 +1110,7 @@ def test_reset_prefix_cache():
1110
all_token_ids = full_block_token_ids + unique_token_ids
1111
req0 = make_request("0", all_token_ids, block_size, sha256)
1112
blocks = manager.allocate_slots(req0, 55)
1104
- assert blocks.get_block_ids() == ([1, 2, 3, 4], )
1113
+ assert blocks is not None and blocks.get_block_ids() == ([1, 2, 3, 4], )
1114
1115
unique_token_ids = [4] * 7
1116
all_token_ids = full_block_token_ids + unique_token_ids
@@ -1112,7 +1121,7 @@ def test_reset_prefix_cache():
1121
blocks = manager.allocate_slots(req1, 7,
1122
len(computed_blocks.blocks[0]) * 16,
1123
computed_blocks)
1115
- assert blocks.get_block_ids() == ([5], )
1124
+ assert blocks is not None and blocks.get_block_ids() == ([5], )
1125
1126
# Failed to reset prefix cache because some blocks are not freed yet.
1127
assert not manager.reset_prefix_cache()
@@ -1168,49 +1177,41 @@ def test_maybe_evict_cached_block():
1177
# Manually add all blocks to cached_blocks
1178
for block, block_hash in zip(pool.blocks, block_hashes):
1179
block.block_hash = block_hash
1171
- pool.cached_block_hash_to_block[block_hash][block.block_id] = block
1180
+ pool.cached_block_hash_to_block.insert(block_hash, block)
1181
1182
block0, block1, block2, block3 = pool.blocks
1174
- assert pool.cached_block_hash_to_block == {
1183
+ assert pool.cached_block_hash_to_block._cache == {
1184
block_hash0: {
1185
block0.block_id: block0,
1177
- block3.block_id: block3
1178
- },
1179
- block_hash1: {
1180
- block1.block_id: block1
1186
+ block3.block_id: block3,
1187
},
1182
- block_hash2: {
1183
- block2.block_id: block2
1184
- }
1188
+ block_hash1: block1,
1189
+ block_hash2: block2,
1190
}
1191
# Evict block1
1192
pool._maybe_evict_cached_block(block1)
1188
- assert pool.cached_block_hash_to_block == {
1193
+ assert pool.cached_block_hash_to_block._cache == {
1194
block_hash0: {
1195
block0.block_id: block0,
1196
block3.block_id: block3
1197
},
1193
- block_hash2: {
1194
- block2.block_id: block2
1195
- }
1198
+ block_hash2: block2,
1199
}
1200
# Evict block0: block_hash0 entry should NOT be removed, as block3
1201
# also use the same hash
1202
pool._maybe_evict_cached_block(block0)
1200
- assert pool.cached_block_hash_to_block == {
1203
+ assert pool.cached_block_hash_to_block._cache == {
1204
block_hash0: {
1205
block3.block_id: block3
1206
},
1204
- block_hash2: {
1205
- block2.block_id: block2
1206
- }
1207
+ block_hash2: block2,
1208
}
1209
# Evict block2
1210
pool._maybe_evict_cached_block(block2)
1210
- assert pool.cached_block_hash_to_block == {block_hash0: {3: block3}}
1211
+ assert pool.cached_block_hash_to_block._cache == {block_hash0: {3: block3}}
1212
# Evict block3
1213
pool._maybe_evict_cached_block(block3)
1213
- assert pool.cached_block_hash_to_block == {}
1214
+ assert pool.cached_block_hash_to_block._cache == {}
1215
1216
1217
@pytest.mark.parametrize("blocks_to_cache", [2, 3, 10])
@@ -1374,7 +1375,7 @@ def test_eagle_with_sliding_window():
1375
# Evict the first block in the request
1376
assert manager.block_pool.get_cached_block(
1377
block_hash_first_block, kv_cache_group_ids=[0]) is not None
1377
- manager.block_pool.cached_block_hash_to_block.pop(
1378
+ manager.block_pool.cached_block_hash_to_block._cache.pop(
1379
make_block_hash_with_group_id(block_hash_first_block, 0))
1380
1381
# New request
@@ -1386,3 +1387,78 @@ def test_eagle_with_sliding_window():
1387
# there will be no matched prefix.
1388
assert len(computed_blocks.blocks[0]) == 0
1389
assert num_tokens == 0
1390
+
1391
+
1392
+def test_block_lookup_cache_single_block_per_key():
1393
+ cache = BlockHashToBlockMap()
1394
+ key0 = BlockHashWithGroupId(b"hash0")
1395
+ key1 = BlockHashWithGroupId(b"hash1")
1396
+ key2 = BlockHashWithGroupId(b"hash2")
1397
+ block0 = KVCacheBlock(0)
1398
+ block1 = KVCacheBlock(1)
1399
+
1400
+ assert cache.get_one_block(key0) is None
1401
+ assert cache.get_one_block(key1) is None
1402
+ assert cache.get_one_block(key2) is None
1403
+ # key0 inserted
1404
+ cache.insert(key0, block0)
1405
+ assert cache.get_one_block(key0) is block0
1406
+ assert cache.get_one_block(key1) is None
1407
+ assert cache.get_one_block(key2) is None
1408
+ # key1 inserted
1409
+ cache.insert(key1, block1)
1410
+ assert cache.get_one_block(key0) is block0
1411
+ assert cache.get_one_block(key1) is block1
1412
+ assert cache.get_one_block(key2) is None
1413
+ # No block poped due to block_id mismatch
1414
+ assert cache.pop(key0, 100) is None
1415
+ assert cache.get_one_block(key0) is block0
1416
+ assert cache.get_one_block(key1) is block1
1417
+ assert cache.get_one_block(key2) is None
1418
+ # block poped with (key0, block ID 0)
1419
+ assert cache.pop(key0, 0) is block0
1420
+ assert cache.get_one_block(key0) is None
1421
+ assert cache.get_one_block(key1) is block1
1422
+ assert cache.get_one_block(key2) is None
1423
+ # No block poped due to block_id mismatch
1424
+ assert cache.pop(key0, 1) is None
1425
+ assert cache.get_one_block(key0) is None
1426
+ assert cache.get_one_block(key1) is block1
1427
+ assert cache.get_one_block(key2) is None
1428
+ # block poped with (key1, block ID 1)
1429
+ assert cache.pop(key1, 1) is block1
1430
+ assert cache.get_one_block(key0) is None
1431
+ assert cache.get_one_block(key1) is None
1432
+ assert cache.get_one_block(key2) is None
1433
+
1434
+
1435
+def test_block_lookup_cache_multi_blocks_per_key():
1436
+ cache = BlockHashToBlockMap()
1437
+ key0 = BlockHashWithGroupId(b"hash0")
1438
+ key1 = BlockHashWithGroupId(b"hash1")
1439
+ block00 = KVCacheBlock(0)
1440
+ block01 = KVCacheBlock(1)
1441
+ block10 = KVCacheBlock(10)
1442
+ block11 = KVCacheBlock(11)
1443
+
1444
+ assert cache.get_one_block(key0) is None
1445
+ assert cache.get_one_block(key1) is None
1446
+
1447
+ cache.insert(key0, block00)
1448
+ cache.insert(key0, block01)
1449
+ cache.insert(key1, block10)
1450
+ cache.insert(key1, block11)
1451
+
1452
+ assert cache.get_one_block(key0) is block00
1453
+ assert cache.pop(key0, 0) is block00
1454
+ assert cache.get_one_block(key0) is block01
1455
+ assert cache.pop(key0, 1) is block01
1456
+ assert cache.get_one_block(key0) is None
1457
+ assert cache.pop(key0, 2) is None
1458
+
1459
+ assert cache.get_one_block(key1) is block10
1460
+ assert cache.pop(key1, 10) is block10
1461
+ assert cache.get_one_block(key1) is block11
1462
+ assert cache.pop(key1, 11) is block11
1463
+ assert cache.get_one_block(key1) is None
1464
+ assert cache.pop(key1, 12) is None
tests/v1/core/test_single_type_kv_cache_manager.py
+8
-10
@@ -47,16 +47,15 @@ def test_chunked_local_attention_possible_cached_prefix():
47
BlockHash(str(i).encode()) for i in range(len(block_is_cached))
48
]
49
50
- block_pool.cached_block_hash_to_block.clear()
50
+ block_pool.cached_block_hash_to_block._cache.clear()
51
52
# Mock the block pool with the cached blocks
53
for i, (block_hash,
54
is_cached) in enumerate(zip(block_hash_list, block_is_cached)):
55
if is_cached:
56
- block_pool.cached_block_hash_to_block[
57
- make_block_hash_with_group_id(block_hash, 0)] = {
58
- i: block_pool.blocks[i + 10],
59
- }
56
+ block_pool.cached_block_hash_to_block.insert(
57
+ make_block_hash_with_group_id(block_hash, 0),
58
+ block_pool.blocks[i + 10])
59
60
computed_blocks = manager.find_longest_cache_hit(
61
block_hashes=block_hash_list,
@@ -112,16 +111,15 @@ def test_sliding_window_possible_cached_prefix():
111
BlockHash(str(i).encode()) for i in range(len(block_is_cached))
112
]
113
115
- block_pool.cached_block_hash_to_block.clear()
114
+ block_pool.cached_block_hash_to_block._cache.clear()
115
116
# Mock the block pool with the cached blocks
117
for i, (block_hash,
118
is_cached) in enumerate(zip(block_hash_list, block_is_cached)):
119
if is_cached:
121
- block_pool.cached_block_hash_to_block[
122
- make_block_hash_with_group_id(block_hash, 0)] = {
123
- i: block_pool.blocks[i + 10],
124
- }
120
+ block_pool.cached_block_hash_to_block.insert(
121
+ make_block_hash_with_group_id(block_hash, 0),
122
+ block_pool.blocks[i + 10])
123
124
computed_blocks = manager.find_longest_cache_hit(
125
block_hashes=block_hash_list,
vllm/v1/core/block_pool.py
+112
-26
@@ -1,8 +1,7 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-from collections import defaultdict
3
from collections.abc import Iterable
5
-from typing import Optional
4
+from typing import Any, Optional, Union
5
6
from vllm.distributed.kv_events import (MEDIUM_GPU, AllBlocksCleared,
7
BlockRemoved, BlockStored,
@@ -19,6 +18,103 @@ from vllm.v1.request import Request
18
logger = init_logger(__name__)
19
20
21
+class BlockHashToBlockMap:
22
+ """
23
+ Cache of blocks that are used for prefix caching. It caches blocks
24
+ from hash directly to a block or multiple blocks
25
+ (i.e. {block_hash: KVCacheBlocks})
26
+ - Mostly block_hash maps to a single KVCacheBlock, and KVCacheBlocks
27
+ would simply be a KVCacheBlock.
28
+ - Otherwise, KVCacheBlocks is a dict from {block_id: KVCacheBlock}
29
+
30
+ A cached block is a full block with a block hash that can be used
31
+ for prefix caching.
32
+ The cached block may be used by running requests or in the
33
+ free_block_queue that could potentially be evicted.
34
+
35
+ NOTE #1: We currently don't de-duplicate the blocks in the cache,
36
+ meaning that if a block becomes full and is cached, we don't check
37
+ if there is already an identical block in the cache. This is because
38
+ we want to make sure the allocated block IDs won't change so that
39
+ block tables are append-only.
40
+ NOTE #2: The union type is introduced in order to reduce GC costs
41
+ from the inner dict.
42
+ """
43
+
44
+ def __init__(self):
45
+ self._cache: dict[BlockHashWithGroupId,
46
+ Union[KVCacheBlock, dict[int, KVCacheBlock]]] = {}
47
+
48
+ def get_one_block(self,
49
+ key: BlockHashWithGroupId) -> Optional[KVCacheBlock]:
50
+ """
51
+ Gets any block with the given block hash key.
52
+ """
53
+ blocks = self._cache.get(key)
54
+ if blocks is not None:
55
+ if isinstance(blocks, KVCacheBlock):
56
+ return blocks
57
+ if isinstance(blocks, dict):
58
+ return next(iter(blocks.values()))
59
+ self._unexpected_blocks_type(blocks)
60
+ return None
61
+
62
+ def insert(self, key: BlockHashWithGroupId, block: KVCacheBlock) -> None:
63
+ """
64
+ Inserts the KVCacheBlock to the cache
65
+ """
66
+ blocks = self._cache.get(key)
67
+ if blocks is None:
68
+ # When key is not found, attach a single block to the key
69
+ self._cache[key] = block
70
+ elif isinstance(blocks, KVCacheBlock):
71
+ # If there's a block with the same key, merge the original block
72
+ # and the new block into a dict
73
+ self._cache[key] = {blocks.block_id: blocks, block.block_id: block}
74
+ elif isinstance(blocks, dict):
75
+ # If it's already a dict, simply insert the block
76
+ blocks[block.block_id] = block
77
+ else:
78
+ self._unexpected_blocks_type(blocks)
79
+
80
+ def pop(self, key: BlockHashWithGroupId,
81
+ block_id: int) -> Optional[KVCacheBlock]:
82
+ """
83
+ Checks if block_hash exists and pop block_id from the cache
84
+ """
85
+ blocks = self._cache.pop(key, None)
86
+ if blocks is None:
87
+ # block_hash not found in the cache
88
+ return None
89
+ # TODO(Jialin): If key is found, block_id should always present
90
+ # in blocks. We currently keep the original behaviour for safety.
91
+ #
92
+ # Will add block_id == blocks.block_id assertion and
93
+ # use del blocks[block_id] instead as followup.
94
+ if isinstance(blocks, KVCacheBlock):
95
+ if blocks.block_id == block_id:
96
+ return blocks
97
+ # If the single block ID doesn't match, we should put the
98
+ # block back (it should happen rarely)
99
+ self._cache[key] = blocks
100
+ return None
101
+ if isinstance(blocks, dict):
102
+ # Try to pop block_id from the block dict, and if dict still
103
+ # contain blocks, put back to the cache.
104
+ block = blocks.pop(block_id, None)
105
+ if len(blocks) > 0:
106
+ self._cache[key] = blocks
107
+ return block
108
+ self._unexpected_blocks_type(blocks)
109
+ return None
110
+
111
+ def __len__(self) -> int:
112
+ return len(self._cache)
113
+
114
+ def _unexpected_blocks_type(self, blocks: Any) -> None:
115
+ raise AssertionError(f"Invalid KV cache block type {type(blocks)}")
116
+
117
+
118
class BlockPool:
119
"""BlockPool that manages KVCacheBlocks.
120
It provides methods to allocate, free and cache the kv cache blocks. The
@@ -51,17 +147,9 @@ class BlockPool:
147
# enabled).
148
self.free_block_queue = FreeKVCacheBlockQueue(self.blocks)
149
54
- # {block_hash: {block ID: block}}. A cached block is
55
- # a full block with a block hash that can be used for prefix caching.
56
- # The cached block may be used by running requests or in the
57
- # free_block_queue that could potentially be evicted.
58
- # NOTE: We currently don't de-duplicate the blocks in the cache,
59
- # meaning that if a block becomes full and is cached, we don't check
60
- # if there is already an identical block in the cache. This is because
61
- # we want to make sure the allocated block IDs won't change so that
62
- # block tables are append-only.
63
- self.cached_block_hash_to_block: dict[BlockHashWithGroupId, dict[
64
- int, KVCacheBlock]] = defaultdict(dict)
150
+ # Cache for block lookup
151
+ self.cached_block_hash_to_block: BlockHashToBlockMap = \
152
+ BlockHashToBlockMap()
153
154
# To represent a placeholder block with block_id=0.
155
# The ref_cnt of null_block is not maintained, needs special care to
@@ -90,12 +178,11 @@ class BlockPool:
178
for group_id in kv_cache_group_ids:
179
block_hash_with_group_id = make_block_hash_with_group_id(
180
block_hash, group_id)
93
- cached_blocks_one_group = self.cached_block_hash_to_block.get(
181
+ block = self.cached_block_hash_to_block.get_one_block(
182
block_hash_with_group_id)
95
- if not cached_blocks_one_group:
183
+ if not block:
184
return None
97
- first_block = next(iter(cached_blocks_one_group.values()))
98
- cached_blocks.append(first_block)
185
+ cached_blocks.append(block)
186
return cached_blocks
187
188
def cache_full_blocks(
@@ -140,8 +227,8 @@ class BlockPool:
227
block_hash_with_group_id = make_block_hash_with_group_id(
228
block_hash, kv_cache_group_id)
229
blk.block_hash = block_hash_with_group_id
143
- self.cached_block_hash_to_block[block_hash_with_group_id][
144
- blk.block_id] = blk
230
+ self.cached_block_hash_to_block.insert(block_hash_with_group_id,
231
+ blk)
232
if new_hashes is not None:
233
new_hashes.append(maybe_convert_block_hash(block_hash))
234
@@ -211,15 +298,14 @@ class BlockPool:
298
if block_hash is None:
299
# The block doesn't have hash, eviction is not needed
300
return False
214
- blocks_by_id = self.cached_block_hash_to_block.get(block_hash)
215
- if blocks_by_id is None:
216
- # block_hash not found in cached_block_hash_to_block,
301
+
302
+ if self.cached_block_hash_to_block.pop(block_hash,
303
+ block.block_id) is None:
304
+ # block not found in cached_block_hash_to_block,
305
# eviction is not needed
306
return False
307
+
308
block.reset_hash()
220
- blocks_by_id.pop(block.block_id, None)
221
- if len(blocks_by_id) == 0:
222
- del self.cached_block_hash_to_block[block_hash]
309
310
if self.enable_kv_cache_events:
311
# FIXME (Chen): Not sure whether we should return `hash_value`
@@ -283,7 +369,7 @@ class BlockPool:
369
return False
370
371
# Remove all hashes so that no new blocks will hit.
286
- self.cached_block_hash_to_block = defaultdict(dict)
372
+ self.cached_block_hash_to_block = BlockHashToBlockMap()
373
374
# Remove all hashes from all blocks.
375
for block in self.blocks: