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 3, 15:15 UTC
[Core][DSV4] Skip caching SWA blocks that can never serve a prefix-cache hit (#42258)
Signed-off-by: Yifan Qiao <yifanqiao@inferact.ai>
Yifan Qiao committed
May 15, 2026 at 00:59 UTC
4b364f810e12a689dd1862a81a163ccb6df07839
4 files changed
+232
-9
tests/v1/core/test_prefix_caching.py
+139
@@ -2528,6 +2528,145 @@ def test_different_block_size():
2528
assert num_computed_tokens == 4 * 16
2529
2530
2531
+def test_hybrid_cache_blocks_swa_tail_window_only():
2532
+ """Within each lcm-aligned segment, SWA's ``find_longest_cache_hit`` only
2533
+ returns the trailing ``ceil((sliding_window - 1) / block_size)`` blocks
2534
+ (its right-to-left scan stops once a contiguous match is found). Blocks
2535
+ earlier in the segment can never serve a hit, so
2536
+ ``HybridKVCacheCoordinator.cache_blocks`` should skip them rather than
2537
+ polluting the prefix-cache hash map."""
2538
+ block_size = 8
2539
+ # Full attn block_size=32, SWA block_size=8, sw=8 -> lcm=32.
2540
+ # tail = ceil(7/8) = 1; per_segment = 32/8 = 4.
2541
+ # Per-segment template = [F, F, F, T]; only the last SWA block in each
2542
+ # 32-token segment ends up in the prefix-cache hash map.
2543
+ kv_cache_config = KVCacheConfig(
2544
+ num_blocks=100,
2545
+ kv_cache_tensors=[],
2546
+ kv_cache_groups=[
2547
+ KVCacheGroupSpec(
2548
+ ["layer1"],
2549
+ FullAttentionSpec(
2550
+ block_size=4 * block_size,
2551
+ num_kv_heads=1,
2552
+ head_size=1,
2553
+ dtype=torch.float16,
2554
+ ),
2555
+ ),
2556
+ KVCacheGroupSpec(
2557
+ ["layer2"],
2558
+ SlidingWindowSpec(
2559
+ block_size=block_size,
2560
+ num_kv_heads=1,
2561
+ head_size=1,
2562
+ dtype=torch.float32,
2563
+ sliding_window=block_size,
2564
+ ),
2565
+ ),
2566
+ ],
2567
+ )
2568
+ manager = KVCacheManager(
2569
+ kv_cache_config=kv_cache_config,
2570
+ max_model_len=8192,
2571
+ enable_caching=True,
2572
+ hash_block_size=block_size,
2573
+ )
2574
+
2575
+ # 8 hash-blocks of 8 tokens (64 tokens, two lcm-aligned segments).
2576
+ token_ids = [i for i in range(8) for _ in range(block_size)]
2577
+ req = make_request("0", token_ids, block_size, sha256)
2578
+ computed_blocks, _ = manager.get_computed_blocks(req)
2579
+ blocks = manager.allocate_slots(
2580
+ req,
2581
+ 8 * block_size,
2582
+ len(computed_blocks.blocks[0]) * block_size,
2583
+ computed_blocks,
2584
+ )
2585
+ assert blocks is not None
2586
+ assert len(req.block_hashes) == 8
2587
+
2588
+ pool = manager.block_pool
2589
+ # SWA group_id=1: only hash 3 and hash 7 (the last block of each
2590
+ # 32-token segment) should be cached. Hashes 0,1,2,4,5,6 cannot serve
2591
+ # a hit at any lcm-aligned length, so they must NOT be cached.
2592
+ expected_cached = {3, 7}
2593
+ for i in range(8):
2594
+ cached = pool.get_cached_block(req.block_hashes[i], kv_cache_group_ids=[1])
2595
+ if i in expected_cached:
2596
+ assert cached is not None, f"SWA hash {i} should be cached"
2597
+ else:
2598
+ assert cached is None, (
2599
+ f"SWA hash {i} cannot serve any lcm-aligned hit; should not be cached"
2600
+ )
2601
+
2602
+
2603
+def test_hybrid_cache_blocks_clamped_to_lcm():
2604
+ """HybridKVCacheCoordinator.cache_blocks() clamps to lcm_block_size.
2605
+ Chunks past the last lcm-aligned boundary can never participate in a
2606
+ cache hit (find_longest_cache_hit always returns lcm-aligned hits), so
2607
+ caching them only pollutes the prefix-cache hash map and keeps blocks
2608
+ on the LRU list that could otherwise return to the free pool."""
2609
+ block_size = 16
2610
+ # Full attn block_size=32, SWA block_size=16 -> lcm=32.
2611
+ kv_cache_config = KVCacheConfig(
2612
+ num_blocks=100,
2613
+ kv_cache_tensors=[],
2614
+ kv_cache_groups=[
2615
+ KVCacheGroupSpec(
2616
+ ["layer1"],
2617
+ FullAttentionSpec(
2618
+ block_size=block_size * 2,
2619
+ num_kv_heads=1,
2620
+ head_size=1,
2621
+ dtype=torch.float16,
2622
+ ),
2623
+ ),
2624
+ KVCacheGroupSpec(
2625
+ ["layer2"],
2626
+ SlidingWindowSpec(
2627
+ block_size=block_size,
2628
+ num_kv_heads=1,
2629
+ head_size=1,
2630
+ dtype=torch.float32,
2631
+ sliding_window=2 * block_size,
2632
+ ),
2633
+ ),
2634
+ ],
2635
+ )
2636
+ manager = KVCacheManager(
2637
+ kv_cache_config=kv_cache_config,
2638
+ max_model_len=8192,
2639
+ enable_caching=True,
2640
+ hash_block_size=block_size,
2641
+ )
2642
+
2643
+ # 7 hash-blocks of 16 tokens (112 tokens). With lcm=32 the clamp truncates
2644
+ # to 96 tokens — SWA caches 6 hashes, full-attn caches 3.
2645
+ token_ids = [i for i in range(7) for _ in range(block_size)]
2646
+ req = make_request("0", token_ids, block_size, sha256)
2647
+ computed_blocks, _ = manager.get_computed_blocks(req)
2648
+ blocks = manager.allocate_slots(
2649
+ req,
2650
+ 7 * block_size,
2651
+ len(computed_blocks.blocks[0]) * block_size,
2652
+ computed_blocks,
2653
+ )
2654
+ assert blocks is not None
2655
+ assert len(req.block_hashes) == 7
2656
+
2657
+ pool = manager.block_pool
2658
+ # SWA group_id=1: hashes 0..5 cached (6 blocks * 16 tokens = 96), hash 6
2659
+ # spans tokens [96, 112) past the lcm boundary and must NOT be cached.
2660
+ for i in range(6):
2661
+ assert (
2662
+ pool.get_cached_block(req.block_hashes[i], kv_cache_group_ids=[1])
2663
+ is not None
2664
+ ), f"SWA hash {i} should be cached"
2665
+ assert pool.get_cached_block(req.block_hashes[6], kv_cache_group_ids=[1]) is None, (
2666
+ "SWA hash 6 spans tokens past the lcm boundary; should not be cached"
2667
+ )
2668
+
2669
+
2670
def test_block_lookup_cache_single_block_per_key():
2671
cache = BlockHashToBlockMap()
2672
key0 = BlockHashWithGroupId(b"hash0")
vllm/v1/core/block_pool.py
+16
-5
@@ -216,6 +216,7 @@ class BlockPool:
216
num_full_blocks: int,
217
block_size: int,
218
kv_cache_group_id: int,
219
+ block_mask: list[bool] | None = None,
220
) -> None:
221
"""Cache a list of full blocks for prefix caching.
222
This function takes a list of blocks that will have their block hash
@@ -233,11 +234,19 @@ class BlockPool:
234
be cached after this function.
235
block_size: Number of tokens in each block.
236
kv_cache_group_id: The id of the KV cache group.
237
+ block_mask: Optional mask aligned with
238
+ ``blocks[num_cached_blocks:num_full_blocks]``. When provided,
239
+ blocks where the mask is False are skipped (treated like null
240
+ blocks). Used by groups whose ``find_longest_cache_hit`` only
241
+ consults a subset of blocks (e.g. SWA tail-window), so blocks
242
+ that can never serve a hit stay out of the prefix-cache hash
243
+ map.
244
"""
245
if num_cached_blocks >= num_full_blocks:
246
return
247
new_full_blocks = blocks[num_cached_blocks:num_full_blocks]
248
assert len(request.block_hashes) >= num_full_blocks
249
+ assert block_mask is None or len(block_mask) == len(new_full_blocks)
250
if block_size == self.hash_block_size:
251
# Common case.
252
block_hashes: BlockHashList = request.block_hashes
@@ -256,10 +265,10 @@ class BlockPool:
265
[] if self.enable_kv_cache_events else None
266
)
267
for i, blk in enumerate(new_full_blocks):
259
- # Some blocks may be null blocks when enabling sparse attention like
260
- # sliding window attention, or Mamba models with prefix-caching in
261
- # align mode. We skip null blocks here.
262
- if blk.is_null:
268
+ # Some blocks may be null or masked out when enabling sparse attention
269
+ # like sliding window attention, or Mamba models with prefix-caching
270
+ # in align mode. We skip null blocks here.
271
+ if blk.is_null or (block_mask is not None and not block_mask[i]):
272
continue
273
assert blk.block_hash is None
274
block_hash = new_block_hashes[i]
@@ -288,12 +297,14 @@ class BlockPool:
297
# Generate extra keys for each block individually.
298
# Each block may have different extra_keys (e.g., different MM
299
# features, or cache_salt only for the first block).
291
- # Skip null blocks to match the length of new_hashes.
300
+ # Skip null/masked-out blocks to match the length of new_hashes.
301
extra_keys_list: list[tuple[Any, ...] | None] = []
302
curr_mm_idx = 0
303
for i in range(num_cached_blocks, num_full_blocks):
304
if blocks[i].is_null:
305
continue
306
+ if block_mask is not None and not block_mask[i - num_cached_blocks]:
307
+ continue
308
block_start = i * block_size
309
block_end = block_start + block_size
310
extra_keys, curr_mm_idx = generate_block_hash_extra_keys(
vllm/v1/core/kv_cache_coordinator.py
+16
@@ -484,6 +484,22 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
484
if any(gid in self.eagle_group_ids for gid in group_ids)
485
}
486
487
+ def cache_blocks(self, request: Request, num_computed_tokens: int) -> None:
488
+ # Cache hits in this coordinator are always a multiple of
489
+ # ``lcm_block_size`` tokens (see ``find_longest_cache_hit``). Within an
490
+ # aligned region, SWA groups only consult a subset of blocks per
491
+ # ``lcm_block_size``-segment so the unused blocks also stay out of the
492
+ # prefix-cache hash map.
493
+ num_computed_tokens = (
494
+ num_computed_tokens // self.lcm_block_size * self.lcm_block_size
495
+ )
496
+ for manager in self.single_type_managers:
497
+ manager.cache_blocks(
498
+ request,
499
+ num_computed_tokens,
500
+ alignment_tokens=self.lcm_block_size,
501
+ )
502
+
503
def find_longest_cache_hit(
504
self,
505
block_hashes: list[BlockHash],
vllm/v1/core/single_type_kv_cache_manager.py
+61
-4
@@ -275,7 +275,12 @@ class SingleTypeKVCacheManager(ABC):
275
self.new_block_ids = []
276
return ids
277
278
- def cache_blocks(self, request: Request, num_tokens: int) -> None:
278
+ def cache_blocks(
279
+ self,
280
+ request: Request,
281
+ num_tokens: int,
282
+ alignment_tokens: int | None = None,
283
+ ) -> None:
284
"""
285
Cache the blocks for the request.
286
@@ -283,6 +288,12 @@ class SingleTypeKVCacheManager(ABC):
288
request: The request.
289
num_tokens: The total number of tokens that need to be cached
290
(including tokens that are already cached).
291
+ alignment_tokens: The cache-hit alignment (in tokens) used by the
292
+ coordinator's ``find_longest_cache_hit``. When greater than
293
+ this group's ``block_size``, managers whose hit logic only
294
+ returns a subset of blocks per alignment-aligned segment
295
+ (SWA) skip the rest since they can never participate in a
296
+ future cache hit.
297
"""
298
num_cached_blocks = self.num_cached_block.get(request.request_id, 0)
299
num_full_blocks = num_tokens // self.block_size
@@ -290,6 +301,13 @@ class SingleTypeKVCacheManager(ABC):
301
if num_cached_blocks >= num_full_blocks:
302
return
303
304
+ # Fast path: when the coordinator imposes no alignment constraint
305
+ if alignment_tokens is None or alignment_tokens <= self.block_size:
306
+ block_mask = None
307
+ else:
308
+ block_mask = self._cache_block_mask(
309
+ num_cached_blocks, num_full_blocks, alignment_tokens
310
+ )
311
self.block_pool.cache_full_blocks(
312
request=request,
313
blocks=self.req_to_blocks[request.request_id],
@@ -297,10 +315,26 @@ class SingleTypeKVCacheManager(ABC):
315
num_full_blocks=num_full_blocks,
316
block_size=self.block_size,
317
kv_cache_group_id=self.kv_cache_group_id,
318
+ block_mask=block_mask,
319
)
320
321
self.num_cached_block[request.request_id] = num_full_blocks
322
323
+ def _cache_block_mask(
324
+ self,
325
+ num_cached_blocks: int,
326
+ num_full_blocks: int,
327
+ alignment_tokens: int,
328
+ ) -> list[bool] | None:
329
+ """Per-block mask for ``cache_full_blocks``. ``None`` means cache
330
+ every (non-null) block — the default for full attention.
331
+
332
+ Subclasses with sparse hit semantics (SWA) override this to skip
333
+ blocks that can never serve a hit at any alignment-aligned prefix
334
+ length.
335
+ """
336
+ return None
337
+
338
def free(self, request_id: str) -> None:
339
"""
340
Free the blocks for the request.
@@ -604,6 +638,19 @@ class SlidingWindowManager(SingleTypeKVCacheManager):
638
computed.pop()
639
return computed_blocks
640
641
+ def _cache_block_mask(
642
+ self, num_cached_blocks: int, num_full_blocks: int, alignment_tokens: int
643
+ ) -> list[bool] | None:
644
+ assert alignment_tokens > self.block_size
645
+ per_segment = alignment_tokens // self.block_size
646
+ tail = cdiv(self.sliding_window - 1, self.block_size)
647
+ if tail >= per_segment:
648
+ return None
649
+ skip = per_segment - tail
650
+ return [
651
+ i % per_segment >= skip for i in range(num_cached_blocks, num_full_blocks)
652
+ ]
653
+
654
def get_num_skipped_tokens(self, num_computed_tokens: int) -> int:
655
"""
656
Get the number of tokens that will be skipped for attention computation.
@@ -1050,9 +1097,14 @@ class MambaManager(SingleTypeKVCacheManager):
1097
"""
1098
return num_computed_tokens - 1
1099
1053
- def cache_blocks(self, request: Request, num_tokens: int) -> None:
1100
+ def cache_blocks(
1101
+ self,
1102
+ request: Request,
1103
+ num_tokens: int,
1104
+ alignment_tokens: int | None = None,
1105
+ ) -> None:
1106
num_cached_blocks_before = self.num_cached_block.get(request.request_id, 0)
1055
- super().cache_blocks(request, num_tokens)
1107
+ super().cache_blocks(request, num_tokens, alignment_tokens=alignment_tokens)
1108
num_cached_blocks_after = self.num_cached_block.get(request.request_id, 0)
1109
if num_cached_blocks_after > num_cached_blocks_before:
1110
for block in self.req_to_blocks[request.request_id][
@@ -1081,7 +1133,12 @@ class CrossAttentionManager(SingleTypeKVCacheManager):
1133
# requests, so `new_computed_blocks` should always be empty.
1134
assert len(new_computed_blocks) == 0
1135
1084
- def cache_blocks(self, request: Request, num_tokens: int) -> None:
1136
+ def cache_blocks(
1137
+ self,
1138
+ request: Request,
1139
+ num_tokens: int,
1140
+ alignment_tokens: int | None = None,
1141
+ ) -> None:
1142
# We do not cache blocks for cross-attention to be shared between
1143
# requests, so this method is not relevant.
1144
raise ValueError("Should not be called as prefix caching is disabled.")