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 10, 10:15 UTC
[Feat][Core] Support multiple KV cache groups in Hybrid KV Coordinator (#31707)
Signed-off-by: Yifan Qiao <yifanqiao@berkeley.edu>
Yifan Qiao committed
Jan 9, 2026 at 10:53 UTC
cd4a95e3aa0478d441964fa9afc062febffd65bf
2 files changed
+324
-127
tests/v1/core/test_prefix_caching.py
+235
-4
@@ -35,6 +35,7 @@ from vllm.v1.kv_cache_interface import (
35
FullAttentionSpec,
36
KVCacheConfig,
37
KVCacheGroupSpec,
38
+ MambaSpec,
39
SlidingWindowSpec,
40
)
41
@@ -106,8 +107,23 @@ def make_kv_cache_config(block_size: int, num_blocks: int) -> KVCacheConfig:
107
108
109
def make_kv_cache_config_hybrid_model(
109
- block_size: int, num_blocks: int
110
+ block_size: int, num_blocks: int, second_spec_type: str = "sliding_window"
111
) -> KVCacheConfig:
112
+ if second_spec_type == "sliding_window":
113
+ second_spec = SlidingWindowSpec(
114
+ block_size=block_size,
115
+ num_kv_heads=1,
116
+ head_size=1,
117
+ dtype=torch.float32,
118
+ sliding_window=2 * block_size,
119
+ )
120
+ elif second_spec_type == "mamba":
121
+ second_spec = MambaSpec(
122
+ block_size=block_size,
123
+ shapes=(1, 1),
124
+ dtypes=(torch.float32,),
125
+ )
126
+
127
return KVCacheConfig(
128
num_blocks=num_blocks,
129
kv_cache_tensors=[],
@@ -123,16 +139,49 @@ def make_kv_cache_config_hybrid_model(
139
),
140
KVCacheGroupSpec(
141
["layer2"],
126
- SlidingWindowSpec(
142
+ second_spec,
143
+ ),
144
+ KVCacheGroupSpec(
145
+ ["layer3"],
146
+ second_spec,
147
+ ),
148
+ ],
149
+ )
150
+
151
+
152
+def make_kv_cache_config_three_types(
153
+ block_size: int, num_blocks: int, third_spec_type: str = "mamba"
154
+) -> KVCacheConfig:
155
+ if third_spec_type == "mamba":
156
+ third_spec = MambaSpec(
157
+ block_size=block_size,
158
+ shapes=(1, 1),
159
+ dtypes=(torch.float32,),
160
+ )
161
+ elif third_spec_type == "sliding_window":
162
+ third_spec = SlidingWindowSpec(
163
+ block_size=block_size,
164
+ num_kv_heads=1,
165
+ head_size=1,
166
+ dtype=torch.float32,
167
+ sliding_window=4 * block_size,
168
+ )
169
+
170
+ return KVCacheConfig(
171
+ num_blocks=num_blocks,
172
+ kv_cache_tensors=[],
173
+ kv_cache_groups=[
174
+ KVCacheGroupSpec(
175
+ ["layer1"],
176
+ FullAttentionSpec(
177
block_size=block_size,
178
num_kv_heads=1,
179
head_size=1,
180
dtype=torch.float32,
131
- sliding_window=2 * block_size,
181
),
182
),
183
KVCacheGroupSpec(
135
- ["layer3"],
184
+ ["layer2"],
185
SlidingWindowSpec(
186
block_size=block_size,
187
num_kv_heads=1,
@@ -141,6 +190,10 @@ def make_kv_cache_config_hybrid_model(
190
sliding_window=2 * block_size,
191
),
192
),
193
+ KVCacheGroupSpec(
194
+ ["layer3"],
195
+ third_spec,
196
+ ),
197
],
198
)
199
@@ -424,6 +477,184 @@ def test_prefill_hybrid_model():
477
)
478
479
480
+def _make_hybrid_kv_cache_config(
481
+ block_size: int, num_blocks: int, spec_types: list[str]
482
+) -> KVCacheConfig:
483
+ """
484
+ Create a KVCacheConfig with the specified spec types.
485
+
486
+ Args:
487
+ block_size: The block size for KV cache.
488
+ num_blocks: The number of blocks in the KV cache.
489
+ spec_types: List of spec type strings. Supported types:
490
+ - "full": FullAttentionSpec
491
+ - "sliding_window": SlidingWindowSpec with window=2*block_size
492
+ - "sliding_window_large": SlidingWindowSpec with window=4*block_size
493
+ - "mamba": MambaSpec
494
+ """
495
+ spec_map = {
496
+ "full": lambda: FullAttentionSpec(
497
+ block_size=block_size,
498
+ num_kv_heads=1,
499
+ head_size=1,
500
+ dtype=torch.float32,
501
+ ),
502
+ "sliding_window": lambda: SlidingWindowSpec(
503
+ block_size=block_size,
504
+ num_kv_heads=1,
505
+ head_size=1,
506
+ dtype=torch.float32,
507
+ sliding_window=2 * block_size,
508
+ ),
509
+ "sliding_window_large": lambda: SlidingWindowSpec(
510
+ block_size=block_size,
511
+ num_kv_heads=1,
512
+ head_size=1,
513
+ dtype=torch.float32,
514
+ sliding_window=4 * block_size,
515
+ ),
516
+ "mamba": lambda: MambaSpec(
517
+ block_size=block_size,
518
+ shapes=(1, 1),
519
+ dtypes=(torch.float32,),
520
+ ),
521
+ }
522
+
523
+ kv_cache_groups = [
524
+ KVCacheGroupSpec([f"layer{i}"], spec_map[spec_type]())
525
+ for i, spec_type in enumerate(spec_types)
526
+ ]
527
+
528
+ return KVCacheConfig(
529
+ num_blocks=num_blocks,
530
+ kv_cache_tensors=[],
531
+ kv_cache_groups=kv_cache_groups,
532
+ )
533
+
534
+
535
+# Test cases covering various combinations of KV cache spec types:
536
+# - Varying number of groups (2, 3, or 4)
537
+# - 0, 1, or 2 full attention groups
538
+# - Sliding window with different window sizes
539
+# - Interleaved group IDs (full attn and other types mixed)
540
+# - Mamba spec combinations
541
+_HYBRID_MODEL_TEST_CASES = [
542
+ # 2 groups: 1 full + 1 other
543
+ pytest.param(["full", "sliding_window"], id="2g-full+sw"),
544
+ pytest.param(["full", "mamba"], id="2g-full+mamba"),
545
+ # 2 groups: 0 full (all other types)
546
+ pytest.param(["sliding_window", "mamba"], id="2g-sw+mamba"),
547
+ pytest.param(["sliding_window", "sliding_window_large"], id="2g-sw+sw_large"),
548
+ # 3 groups: 1 full + 2 others (same type)
549
+ pytest.param(["full", "sliding_window", "sliding_window"], id="3g-full+2sw"),
550
+ pytest.param(["full", "mamba", "mamba"], id="3g-full+2mamba"),
551
+ # 3 groups: 1 full + 2 others (different types)
552
+ pytest.param(["full", "sliding_window", "mamba"], id="3g-full+sw+mamba"),
553
+ pytest.param(
554
+ ["full", "sliding_window", "sliding_window_large"],
555
+ id="3g-full+sw+sw_large",
556
+ ),
557
+ # 3 groups: 2 full + 1 other
558
+ pytest.param(["full", "full", "sliding_window"], id="3g-2full+sw"),
559
+ pytest.param(["full", "full", "mamba"], id="3g-2full+mamba"),
560
+ # 4 groups: interleaved (full, other, full, other)
561
+ pytest.param(
562
+ ["full", "sliding_window", "full", "sliding_window_large"],
563
+ id="4g-interleaved-full+sw+sw_large",
564
+ ),
565
+ pytest.param(
566
+ ["full", "mamba", "full", "mamba"],
567
+ id="4g-interleaved-full+mamba",
568
+ ),
569
+ # 4 groups: interleaved with different sliding windows
570
+ pytest.param(
571
+ ["full", "sliding_window", "full", "sliding_window_large"],
572
+ id="4g-interleaved-full+sw_mixed",
573
+ ),
574
+ # 4 groups: 0 full (all other types)
575
+ pytest.param(
576
+ ["sliding_window", "mamba", "sliding_window_large", "mamba"],
577
+ id="4g-sw+mamba+sw_large+mamba",
578
+ ),
579
+ # 4 groups: 2 full + 2 others (grouped)
580
+ pytest.param(
581
+ ["full", "full", "sliding_window", "mamba"],
582
+ id="4g-2full+sw+mamba",
583
+ ),
584
+]
585
+
586
+
587
+@pytest.mark.parametrize("spec_types", _HYBRID_MODEL_TEST_CASES)
588
+def test_prefill_hybrid_model_combinations(spec_types: list[str]):
589
+ """
590
+ Test prefix caching with hybrid models containing various combinations of
591
+ KV cache spec types.
592
+
593
+ This unified test covers:
594
+ - Various combinations (full attn + other attn types)
595
+ - Varying number of groups (2, 3, or 4)
596
+ - 0, 1, or 2 full attention groups in the combination
597
+ - Two sliding_window attn groups with different window sizes
598
+ - Interleaved group IDs (full attn and other types alternating)
599
+ - Mamba spec with other attention types
600
+ """
601
+ block_size = 16
602
+ num_groups = len(spec_types)
603
+ # Allocate enough blocks for all groups
604
+ num_blocks = 10 * num_groups
605
+
606
+ kv_cache_config = _make_hybrid_kv_cache_config(block_size, num_blocks, spec_types)
607
+ manager = KVCacheManager(
608
+ kv_cache_config,
609
+ max_model_len=8192,
610
+ enable_caching=True,
611
+ hash_block_size=block_size,
612
+ )
613
+
614
+ hash_fn = sha256
615
+
616
+ # Complete 3 blocks (48 tokens)
617
+ common_token_ids = [i for i in range(3) for _ in range(block_size)]
618
+ unique_token_ids = [3] * 7
619
+ all_token_ids = common_token_ids + unique_token_ids
620
+
621
+ # First request: no cache hit initially
622
+ req0 = make_request("0", all_token_ids, block_size, hash_fn)
623
+ computed_blocks, num_computed_tokens = manager.get_computed_blocks(req0)
624
+
625
+ assert len(req0.block_hashes) == 3
626
+ assert not computed_blocks.blocks[0] # No cache hit initially
627
+ assert num_computed_tokens == 0
628
+
629
+ blocks = manager.allocate_slots(
630
+ req0, 55, len(computed_blocks.blocks[0]) * block_size, computed_blocks
631
+ )
632
+ assert blocks is not None
633
+ # Should have blocks for all groups
634
+ assert len(blocks.get_block_ids()) == num_groups
635
+
636
+ # Second request: should hit cached blocks for common prefix
637
+ req1 = make_request("1", common_token_ids + [4] * 5, block_size, hash_fn)
638
+ computed_blocks, num_computed_tokens = manager.get_computed_blocks(req1)
639
+
640
+ # Should hit cached blocks for all groups
641
+ assert num_computed_tokens == 3 * block_size
642
+ assert len(computed_blocks.blocks) == num_groups
643
+
644
+ # Allocate and verify blocks for second request
645
+ blocks = manager.allocate_slots(
646
+ req1,
647
+ len(common_token_ids) + 5 - num_computed_tokens,
648
+ num_computed_tokens,
649
+ computed_blocks,
650
+ )
651
+ assert blocks is not None
652
+ assert len(blocks.get_block_ids()) == num_groups
653
+
654
+ manager.free(req0)
655
+ manager.free(req1)
656
+
657
+
658
def test_prefill_plp():
659
"""Test prefill with APC and some prompt logprobs (plp) requests.
660
vllm/v1/core/kv_cache_coordinator.py
+89
-123
@@ -14,7 +14,7 @@ from vllm.v1.core.kv_cache_utils import (
14
)
15
from vllm.v1.core.single_type_kv_cache_manager import (
16
CrossAttentionManager,
17
- FullAttentionManager,
17
+ SingleTypeKVCacheManager,
18
get_manager_for_kv_cache_spec,
19
)
20
from vllm.v1.kv_cache_interface import (
@@ -354,9 +354,6 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
354
"""
355
KV cache coordinator for hybrid models with multiple KV cache types, and
356
thus multiple kv cache groups.
357
- To simplify `find_longest_cache_hit`, it only supports the combination of
358
- two types of KV cache groups, and one of them must be full attention.
359
- May extend to more general cases in the future.
357
"""
358
359
def __init__(
@@ -397,70 +394,46 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
394
395
def verify_and_split_kv_cache_groups(self) -> None:
396
"""
400
- Verifies that the model has exactly two types of KV cache groups, and
401
- one of them is full attention. Then, split the kv cache groups into full
402
- attention groups and other groups.
397
+ Groups KV cache groups by their spec type for efficient batch processing
398
+ during cache hit lookup.
399
"""
404
- full_attention_spec: FullAttentionSpec | None = None
405
- other_spec: KVCacheSpec | None = None
406
- self.full_attention_group_ids: list[int] = []
407
- self.other_group_ids: list[int] = []
400
+ attention_groups: list[
401
+ tuple[KVCacheSpec, list[int], type[SingleTypeKVCacheManager]]
402
+ ] = []
403
+
404
for i, g in enumerate(self.kv_cache_config.kv_cache_groups):
409
- if isinstance(g.kv_cache_spec, FullAttentionSpec):
410
- if full_attention_spec is None:
411
- full_attention_spec = g.kv_cache_spec
412
- else:
413
- assert full_attention_spec == g.kv_cache_spec, (
414
- "HybridKVCacheCoordinator assumes exactly one type of "
415
- "full attention groups now."
405
+ manager_cls = self.single_type_managers[i].__class__
406
+ spec = g.kv_cache_spec
407
+
408
+ # Try to find an existing group with the same spec
409
+ for existing_spec, group_ids, existing_cls in attention_groups:
410
+ if existing_spec == spec:
411
+ assert manager_cls is existing_cls, (
412
+ "Expected same manager class for identical KV cache specs."
413
)
417
- self.full_attention_group_ids.append(i)
414
+ group_ids.append(i)
415
+ break
416
else:
419
- if other_spec is None:
420
- other_spec = g.kv_cache_spec
421
- else:
422
- assert other_spec == g.kv_cache_spec, (
423
- "HybridKVCacheCoordinator assumes "
424
- "exactly one other type of groups now."
425
- )
426
- self.other_group_ids.append(i)
417
+ attention_groups.append((spec, [i], manager_cls))
418
428
- assert full_attention_spec is not None, (
429
- "HybridKVCacheCoordinator assumes exactly one type of full "
430
- "attention groups now."
419
+ assert len(attention_groups) > 1, (
420
+ "HybridKVCacheCoordinator requires at least two attention groups."
421
)
432
- assert other_spec is not None, (
433
- "HybridKVCacheCoordinator assumes exactly one type of other groups now."
422
+
423
+ # Put full attention first: its efficient left-to-right scan provides
424
+ # a tighter initial bound, reducing work for subsequent groups.
425
+ self.attention_groups = sorted(
426
+ attention_groups,
427
+ key=lambda x: not isinstance(x[0], FullAttentionSpec),
428
)
429
436
- self.full_attention_manager_cls = FullAttentionManager
437
- self.other_attention_cls = self.single_type_managers[
438
- self.other_group_ids[0]
439
- ].__class__
440
- self.full_attention_spec = full_attention_spec
441
- self.other_spec = other_spec
442
- self.full_attention_block_size = self.full_attention_spec.block_size
443
- self.other_block_size = self.other_spec.block_size
444
- # The LCM of the block sizes of full attention and other attention.
430
+ # The LCM of the block sizes of all attention types.
431
# The cache hit length must be a multiple of the LCM of the block sizes
432
# to make sure the cache hit length is a multiple of the block size of
433
# each attention type. Requiring this because we don't support partial
434
# block cache hit yet.
449
- self.lcm_block_size = lcm(self.full_attention_block_size, self.other_block_size)
450
-
451
- if max(self.full_attention_group_ids) < min(self.other_group_ids):
452
- self.full_attn_first = True
453
- elif max(self.other_group_ids) < min(self.full_attention_group_ids):
454
- self.full_attn_first = False
455
- else:
456
- raise ValueError(
457
- "HybridKVCacheCoordinator assumes the full "
458
- "attention group ids and other attention group ids "
459
- "do not interleave, either full attention group ids "
460
- "are before other attention group ids or vice versa."
461
- "This is for simplifying merging hit_blocks_full_attn and "
462
- "hit_blocks_other_attn to hit_blocks."
463
- )
435
+ block_sizes = [spec.block_size for spec, _, _ in attention_groups]
436
+ self.lcm_block_size = lcm(*block_sizes)
437
438
def find_longest_cache_hit(
439
self,
@@ -468,7 +441,12 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
441
max_cache_hit_length: int,
442
) -> tuple[tuple[list[KVCacheBlock], ...], int]:
443
"""
471
- Find the longest cache hit for the request.
444
+ Find the longest cache hit using an iterative fixed-point algorithm.
445
+
446
+ Each attention type either accepts the current candidate length or
447
+ reduces it. If any type reduces the length, restart checks over all
448
+ types. This converges because length monotonically decreases and is
449
+ bounded below by 0.
450
451
Args:
452
block_hashes: The block hashes of the request.
@@ -476,75 +454,63 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
454
455
Returns:
456
A tuple containing:
479
- - A list of the cache hit blocks for each single type manager.
457
+ - A tuple of the cache hit blocks for each single type manager.
458
- The number of tokens of the longest cache hit.
459
"""
482
- # First, find the longest cache hit for full attention.
483
- if self.full_attention_spec.block_size == self.hash_block_size:
484
- # Common case.
485
- full_attention_block_hashes: BlockHashList = block_hashes
486
- else:
487
- # block_size is a multiple of hash_block_size. This happens when different
488
- # KV cache groups have different block sizes. In this case, we need to
489
- # recalculate block_hashes at the granularity of block_size, using the
490
- # original block_hashes (at the granularity of hash_block_size).
491
- full_attention_block_hashes = BlockHashListWithBlockSize(
492
- block_hashes, self.hash_block_size, self.full_attention_spec.block_size
493
- )
494
- hit_blocks_full_attn = self.full_attention_manager_cls.find_longest_cache_hit(
495
- block_hashes=full_attention_block_hashes,
496
- max_length=max_cache_hit_length,
497
- kv_cache_group_ids=self.full_attention_group_ids,
498
- block_pool=self.block_pool,
499
- kv_cache_spec=self.full_attention_spec,
500
- use_eagle=self.use_eagle,
501
- alignment_tokens=self.lcm_block_size,
502
- )
503
- hit_length = len(hit_blocks_full_attn[0]) * self.full_attention_block_size
504
-
505
- # Next, find the cache hit for the other attention WITHIN
506
- # the cache hit of full attention.
507
- if self.other_spec.block_size == self.hash_block_size:
508
- # Common case.
509
- other_block_hashes: BlockHashList = block_hashes
510
- else:
511
- # Similar to the full attention case, here we need to recalculate
512
- # block_hashes at the granularity of block_size, using the original
513
- # block_hashes (at the granularity of hash_block_size).
514
- other_block_hashes = BlockHashListWithBlockSize(
515
- block_hashes, self.hash_block_size, self.other_spec.block_size
460
+
461
+ def _get_block_hashes(kv_cache_spec: KVCacheSpec) -> BlockHashList:
462
+ if kv_cache_spec.block_size == self.hash_block_size:
463
+ return block_hashes
464
+ return BlockHashListWithBlockSize(
465
+ block_hashes, self.hash_block_size, kv_cache_spec.block_size
466
)
517
- hit_blocks_other_attn = self.other_attention_cls.find_longest_cache_hit(
518
- block_hashes=other_block_hashes,
519
- max_length=hit_length,
520
- kv_cache_group_ids=self.other_group_ids,
521
- block_pool=self.block_pool,
522
- kv_cache_spec=self.other_spec,
523
- use_eagle=self.use_eagle,
524
- alignment_tokens=self.lcm_block_size,
525
- )
526
- hit_length = len(hit_blocks_other_attn[0]) * self.other_block_size
527
-
528
- # NOTE: the prefix cache hit length must be a multiple of block_size as
529
- # we don't support partial block cache hit yet. The cache hit length
530
- # of other attention is ensured to be a multiple of the block size of
531
- # full attention layers in current implementation, because hit_length is
532
- # a multiple of other attention's block size, and other attention's
533
- # block size is a multiple of full attention's block size (verified in
534
- # `verify_and_split_kv_cache_groups`).
535
- assert hit_length % self.full_attention_block_size == 0
536
-
537
- # Truncate the full attention cache hit to the length of the
538
- # cache hit of the other attention.
539
- for group_hit_blocks in hit_blocks_full_attn:
540
- del group_hit_blocks[hit_length // self.full_attention_block_size :]
541
-
542
- # Merge the hit blocks of full attention and other attention.
543
- if self.full_attn_first:
544
- hit_blocks = hit_blocks_full_attn + hit_blocks_other_attn
545
- else:
546
- hit_blocks = hit_blocks_other_attn + hit_blocks_full_attn
547
- return hit_blocks, hit_length
467
+
468
+ num_groups = len(self.kv_cache_config.kv_cache_groups)
469
+ hit_length = max_cache_hit_length
470
+ hit_blocks_by_group: list[list[KVCacheBlock] | None] = [None] * num_groups
471
+
472
+ while True:
473
+ curr_hit_length = hit_length
474
+
475
+ for spec, group_ids, manager_cls in self.attention_groups:
476
+ is_full_attn = isinstance(spec, FullAttentionSpec)
477
+
478
+ # Full attention: reuse cached blocks (downward-closed property)
479
+ cached_blocks = hit_blocks_by_group[group_ids[0]]
480
+ if is_full_attn and cached_blocks is not None:
481
+ # For full attention, we only need to compute the cache hit
482
+ # length once. Starting from the second iteration, if the
483
+ # curr_hit_length is reduced by other groups, we can simply
484
+ # keep the first (curr_hit_length // block_size) blocks from
485
+ # the last iteration.
486
+ num_blocks = curr_hit_length // spec.block_size
487
+ curr_hit_length = num_blocks * spec.block_size
488
+ for group_id in group_ids:
489
+ blocks = hit_blocks_by_group[group_id]
490
+ assert blocks is not None
491
+ del blocks[num_blocks:]
492
+ else:
493
+ hit_blocks = manager_cls.find_longest_cache_hit(
494
+ block_hashes=_get_block_hashes(spec),
495
+ max_length=curr_hit_length,
496
+ kv_cache_group_ids=group_ids,
497
+ block_pool=self.block_pool,
498
+ kv_cache_spec=spec,
499
+ use_eagle=self.use_eagle,
500
+ alignment_tokens=self.lcm_block_size,
501
+ )
502
+ curr_hit_length = len(hit_blocks[0]) * spec.block_size
503
+ for group_id, blocks in zip(group_ids, hit_blocks):
504
+ hit_blocks_by_group[group_id] = blocks
505
+
506
+ if curr_hit_length < hit_length:
507
+ hit_length = curr_hit_length
508
+ else:
509
+ break
510
+
511
+ return tuple(
512
+ blocks if blocks is not None else [] for blocks in hit_blocks_by_group
513
+ ), hit_length
514
515
516
def get_kv_cache_coordinator(