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 4, 15:00 UTC
[Bugfix] Cap SWA/chunked-local runtime admission to startup pool-sizing bound (#40946)
Signed-off-by: Dao Le <Dao007forever@gmail.com> Signed-off-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Nick Hill <nickhill123@gmail.com>
Dao007forever committed
Apr 26, 2026 at 21:33 UTC
7b1bc0a3eb01a6bc2650eda9970049f7825240d7
8 files changed
+213
-23
tests/v1/core/test_prefix_caching.py
+108
@@ -2512,3 +2512,111 @@ def test_block_lookup_cache_multi_blocks_per_key():
2512
assert cache.pop(key1, 11) is block11
2513
assert cache.get_one_block(key1) is None
2514
assert cache.pop(key1, 12) is None
2515
+
2516
+
2517
+def test_can_fit_full_sequence_swa_cap_admits_long_prompt():
2518
+ """Hybrid full+SWA model with a pool sized at the startup minimum should
2519
+ admit a prompt longer than the SWA cap, because SlidingWindowManager
2520
+ recycles blocks during chunked prefill (issue #39734)."""
2521
+ block_size = 16
2522
+ sliding_window = 4 * block_size # 64 tokens
2523
+ max_num_batched_tokens = 8 * block_size # 128 tokens
2524
+ max_model_len = 64 * block_size # 1024 tokens — much larger than the SWA cap
2525
+ # Startup pool sizing: full demands cdiv(max_model_len, bs) = 64 blocks,
2526
+ # SWA demands cdiv(SW-1+max_batched, bs) + 1 = cdiv(191, 16) + 1 = 13.
2527
+ # Pool minimum = 64 + 13 = 77; +1 for the null block.
2528
+ num_blocks = 64 + 13 + 1
2529
+
2530
+ config = KVCacheConfig(
2531
+ num_blocks=num_blocks,
2532
+ kv_cache_tensors=[],
2533
+ kv_cache_groups=[
2534
+ KVCacheGroupSpec(
2535
+ ["layer_full"],
2536
+ FullAttentionSpec(
2537
+ block_size=block_size,
2538
+ num_kv_heads=1,
2539
+ head_size=1,
2540
+ dtype=torch.float32,
2541
+ ),
2542
+ ),
2543
+ KVCacheGroupSpec(
2544
+ ["layer_swa"],
2545
+ SlidingWindowSpec(
2546
+ block_size=block_size,
2547
+ num_kv_heads=1,
2548
+ head_size=1,
2549
+ dtype=torch.float32,
2550
+ sliding_window=sliding_window,
2551
+ ),
2552
+ ),
2553
+ ],
2554
+ )
2555
+
2556
+ manager = KVCacheManager(
2557
+ config,
2558
+ max_model_len=max_model_len,
2559
+ max_num_batched_tokens=max_num_batched_tokens,
2560
+ enable_caching=True,
2561
+ hash_block_size=block_size,
2562
+ )
2563
+
2564
+ # A prompt that is shorter than max_model_len but longer than SW + chunk:
2565
+ # cdiv(prompt_len, bs) = 32 blocks. Without the cap, admission would
2566
+ # demand 32 (full) + 32 (SWA) = 64 blocks. With the cap, SWA contributes
2567
+ # only 13, so total = 32 + 13 = 45 ≤ pool size.
2568
+ prompt_len = 32 * block_size
2569
+ req = make_request("long", list(range(prompt_len)), block_size, sha256)
2570
+
2571
+ assert manager.can_fit_full_sequence(req)
2572
+
2573
+
2574
+def test_can_fit_full_sequence_full_attention_still_gates_oversized():
2575
+ """The cap only loosens the SWA group; a prompt that exceeds the
2576
+ full-attention pool capacity must still be rejected."""
2577
+ block_size = 16
2578
+ sliding_window = 4 * block_size
2579
+ max_num_batched_tokens = 8 * block_size
2580
+ max_model_len = 64 * block_size
2581
+ # Provide a tiny pool — even a small prompt should be rejected.
2582
+ num_blocks = 5
2583
+
2584
+ config = KVCacheConfig(
2585
+ num_blocks=num_blocks,
2586
+ kv_cache_tensors=[],
2587
+ kv_cache_groups=[
2588
+ KVCacheGroupSpec(
2589
+ ["layer_full"],
2590
+ FullAttentionSpec(
2591
+ block_size=block_size,
2592
+ num_kv_heads=1,
2593
+ head_size=1,
2594
+ dtype=torch.float32,
2595
+ ),
2596
+ ),
2597
+ KVCacheGroupSpec(
2598
+ ["layer_swa"],
2599
+ SlidingWindowSpec(
2600
+ block_size=block_size,
2601
+ num_kv_heads=1,
2602
+ head_size=1,
2603
+ dtype=torch.float32,
2604
+ sliding_window=sliding_window,
2605
+ ),
2606
+ ),
2607
+ ],
2608
+ )
2609
+
2610
+ manager = KVCacheManager(
2611
+ config,
2612
+ max_model_len=max_model_len,
2613
+ max_num_batched_tokens=max_num_batched_tokens,
2614
+ enable_caching=True,
2615
+ hash_block_size=block_size,
2616
+ )
2617
+
2618
+ # 16 blocks of full attention demand alone exceeds the 5-block pool.
2619
+ prompt_len = 16 * block_size
2620
+ req = make_request("oversized", list(range(prompt_len)), block_size, sha256)
2621
+
2622
+ assert not manager.can_fit_full_sequence(req)
tests/v1/core/test_single_type_kv_cache_manager.py
+3
@@ -22,11 +22,13 @@ pytestmark = pytest.mark.cpu_test
22
23
24
def get_sliding_window_manager(sliding_window_spec, block_pool, enable_caching=True):
25
+ # Tests don't exercise admission gating; pass a large cap that is a no-op.
26
return SlidingWindowManager(
27
sliding_window_spec,
28
block_pool=block_pool,
29
enable_caching=enable_caching,
30
kv_cache_group_id=0,
31
+ max_admission_blocks_per_request=10**9,
32
)
33
34
@@ -38,6 +40,7 @@ def get_chunked_local_attention_manager(
40
block_pool=block_pool,
41
enable_caching=enable_caching,
42
kv_cache_group_id=0,
43
+ max_admission_blocks_per_request=10**9,
44
)
45
46
vllm/v1/core/kv_cache_coordinator.py
+13
@@ -34,6 +34,7 @@ class KVCacheCoordinator(ABC):
34
self,
35
kv_cache_config: KVCacheConfig,
36
max_model_len: int,
37
+ max_num_batched_tokens: int,
38
use_eagle: bool,
39
enable_caching: bool,
40
enable_kv_cache_events: bool,
@@ -65,6 +66,8 @@ class KVCacheCoordinator(ABC):
66
self.single_type_managers = tuple(
67
get_manager_for_kv_cache_spec(
68
kv_cache_spec=kv_cache_group.kv_cache_spec,
69
+ max_num_batched_tokens=max_num_batched_tokens,
70
+ max_model_len=max_model_len,
71
block_pool=self.block_pool,
72
enable_caching=enable_caching,
73
kv_cache_group_id=i,
@@ -271,6 +274,7 @@ class KVCacheCoordinatorNoPrefixCache(KVCacheCoordinator):
274
self,
275
kv_cache_config: KVCacheConfig,
276
max_model_len: int,
277
+ max_num_batched_tokens: int,
278
use_eagle: bool,
279
enable_kv_cache_events: bool,
280
dcp_world_size: int,
@@ -281,6 +285,7 @@ class KVCacheCoordinatorNoPrefixCache(KVCacheCoordinator):
285
super().__init__(
286
kv_cache_config,
287
max_model_len,
288
+ max_num_batched_tokens,
289
use_eagle,
290
False,
291
enable_kv_cache_events,
@@ -316,6 +321,7 @@ class UnitaryKVCacheCoordinator(KVCacheCoordinator):
321
self,
322
kv_cache_config: KVCacheConfig,
323
max_model_len: int,
324
+ max_num_batched_tokens: int,
325
use_eagle: bool,
326
enable_caching: bool,
327
enable_kv_cache_events: bool,
@@ -327,6 +333,7 @@ class UnitaryKVCacheCoordinator(KVCacheCoordinator):
333
super().__init__(
334
kv_cache_config,
335
max_model_len,
336
+ max_num_batched_tokens,
337
use_eagle,
338
enable_caching,
339
enable_kv_cache_events,
@@ -381,6 +388,7 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
388
self,
389
kv_cache_config: KVCacheConfig,
390
max_model_len: int,
391
+ max_num_batched_tokens: int,
392
use_eagle: bool,
393
enable_caching: bool,
394
enable_kv_cache_events: bool,
@@ -392,6 +400,7 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
400
super().__init__(
401
kv_cache_config,
402
max_model_len,
403
+ max_num_batched_tokens,
404
use_eagle,
405
enable_caching,
406
enable_kv_cache_events,
@@ -574,6 +583,7 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
583
def get_kv_cache_coordinator(
584
kv_cache_config: KVCacheConfig,
585
max_model_len: int,
586
+ max_num_batched_tokens: int,
587
use_eagle: bool,
588
enable_caching: bool,
589
enable_kv_cache_events: bool,
@@ -586,6 +596,7 @@ def get_kv_cache_coordinator(
596
return KVCacheCoordinatorNoPrefixCache(
597
kv_cache_config,
598
max_model_len,
599
+ max_num_batched_tokens,
600
use_eagle,
601
enable_kv_cache_events,
602
dcp_world_size=dcp_world_size,
@@ -597,6 +608,7 @@ def get_kv_cache_coordinator(
608
return UnitaryKVCacheCoordinator(
609
kv_cache_config,
610
max_model_len,
611
+ max_num_batched_tokens,
612
use_eagle,
613
enable_caching,
614
enable_kv_cache_events,
@@ -608,6 +620,7 @@ def get_kv_cache_coordinator(
620
return HybridKVCacheCoordinator(
621
kv_cache_config,
622
max_model_len,
623
+ max_num_batched_tokens,
624
use_eagle,
625
enable_caching,
626
enable_kv_cache_events,
vllm/v1/core/kv_cache_manager.py
+7
@@ -109,6 +109,7 @@ class KVCacheManager:
109
kv_cache_config: KVCacheConfig,
110
max_model_len: int,
111
hash_block_size: int,
112
+ max_num_batched_tokens: int | None = None,
113
enable_caching: bool = True,
114
use_eagle: bool = False,
115
log_stats: bool = False,
@@ -118,6 +119,11 @@ class KVCacheManager:
119
metrics_collector: KVCacheMetricsCollector | None = None,
120
) -> None:
121
self.max_model_len = max_model_len
122
+ # When unset, fall back to `max_model_len` so the recycling-aware cap
123
+ # collapses to the prior (uncapped) admission behavior. The scheduler
124
+ # always supplies the real value at runtime.
125
+ if max_num_batched_tokens is None:
126
+ max_num_batched_tokens = max_model_len
127
128
self.enable_caching = enable_caching
129
self.use_eagle = use_eagle
@@ -131,6 +137,7 @@ class KVCacheManager:
137
self.coordinator = get_kv_cache_coordinator(
138
kv_cache_config=kv_cache_config,
139
max_model_len=self.max_model_len,
140
+ max_num_batched_tokens=max_num_batched_tokens,
141
use_eagle=self.use_eagle,
142
enable_caching=self.enable_caching,
143
enable_kv_cache_events=enable_kv_cache_events,
vllm/v1/core/sched/scheduler.py
+1
@@ -228,6 +228,7 @@ class Scheduler(SchedulerInterface):
228
self.kv_cache_manager = KVCacheManager(
229
kv_cache_config=kv_cache_config,
230
max_model_len=self.max_model_len,
231
+ max_num_batched_tokens=self.scheduler_config.max_num_batched_tokens,
232
enable_caching=self.cache_config.enable_prefix_caching,
233
use_eagle=self.use_eagle,
234
log_stats=self.log_stats,
vllm/v1/core/single_type_kv_cache_manager.py
+35
-1
@@ -41,6 +41,7 @@ class SingleTypeKVCacheManager(ABC):
41
kv_cache_group_id: int,
42
dcp_world_size: int = 1,
43
pcp_world_size: int = 1,
44
+ max_admission_blocks_per_request: int | None = None,
45
) -> None:
46
"""
47
Initializes the SingleTypeKVCacheManager.
@@ -48,6 +49,12 @@ class SingleTypeKVCacheManager(ABC):
49
kv_cache_spec: The kv_cache_spec for this manager.
50
block_pool: The block pool.
51
kv_cache_group_id: The id of the kv cache group of this manager.
52
+ max_admission_blocks_per_request: Recycling-aware per-request
53
+ block cap used by `get_num_blocks_to_allocate`. Only set for
54
+ spec types that recycle blocks across chunks (SWA,
55
+ chunked-local); `None` (the default) means no cap, which is
56
+ correct for full-attention-style specs that hold every
57
+ block until the request finishes.
58
"""
59
self.block_size = kv_cache_spec.block_size
60
self.dcp_world_size = dcp_world_size
@@ -57,6 +64,7 @@ class SingleTypeKVCacheManager(ABC):
64
self.kv_cache_spec = kv_cache_spec
65
self.block_pool = block_pool
66
self.enable_caching = enable_caching
67
+ self._max_admission_blocks_per_request = max_admission_blocks_per_request
68
self.new_block_ids: list[int] = []
69
70
# Mapping from request ID to blocks to track the blocks allocated
@@ -105,6 +113,19 @@ class SingleTypeKVCacheManager(ABC):
113
"""
114
115
num_required_blocks = cdiv(num_tokens, self.block_size)
116
+ if self._max_admission_blocks_per_request is not None:
117
+ # Recycling-aware specs (SWA, chunked-local) cap the per-request
118
+ # reservation here so admission matches the startup pool sizer
119
+ # (`SlidingWindowSpec.max_admission_blocks_per_request` / its
120
+ # chunked-local counterpart). `remove_skipped_blocks` runs from
121
+ # `allocate_slots` before each chunk's `get_num_blocks_to_allocate`,
122
+ # so per-request peak real-held blocks <= this cap, which keeps
123
+ # `sum(reservations) <= pool` <=> `sum(peak_real_held) <= pool`.
124
+ # Drift between the two would re-introduce the deadlock from
125
+ # issue #39734 or, worse, mid-prefill OOM.
126
+ num_required_blocks = min(
127
+ num_required_blocks, self._max_admission_blocks_per_request
128
+ )
129
num_req_blocks = len(self.req_to_blocks.get(request_id, ()))
130
131
if request_id in self.num_cached_block:
@@ -1126,8 +1147,21 @@ spec_manager_map: dict[type[KVCacheSpec], type[SingleTypeKVCacheManager]] = {
1147
1148
1149
def get_manager_for_kv_cache_spec(
1129
- kv_cache_spec: KVCacheSpec, **kwargs
1150
+ kv_cache_spec: KVCacheSpec,
1151
+ max_num_batched_tokens: int,
1152
+ max_model_len: int,
1153
+ **kwargs,
1154
) -> SingleTypeKVCacheManager:
1155
manager_class = spec_manager_map[type(kv_cache_spec)]
1156
+ # SlidingWindow / ChunkedLocalAttention managers recycle blocks across
1157
+ # chunks; the runtime admission cap must match the recycling-aware bound
1158
+ # the startup pool sizer uses (single source of truth: the spec method).
1159
+ if isinstance(kv_cache_spec, (SlidingWindowSpec, ChunkedLocalAttentionSpec)):
1160
+ kwargs["max_admission_blocks_per_request"] = (
1161
+ kv_cache_spec.max_admission_blocks_per_request(
1162
+ max_num_batched_tokens=max_num_batched_tokens,
1163
+ max_model_len=max_model_len,
1164
+ )
1165
+ )
1166
manager = manager_class(kv_cache_spec, **kwargs)
1167
return manager
vllm/v1/kv_cache_interface.py
+43
-22
@@ -376,19 +376,28 @@ class MLAAttentionSpec(FullAttentionSpec):
376
class ChunkedLocalAttentionSpec(AttentionSpec):
377
attention_chunk_size: int
378
379
- def max_memory_usage_bytes(self, vllm_config: VllmConfig) -> int:
380
- max_model_len = vllm_config.model_config.max_model_len
381
- max_num_batched_tokens = vllm_config.scheduler_config.max_num_batched_tokens
382
-
383
- # During chunked prefill, we allocate KV cache for at most
384
- # `self.attention_chunk_size` computed tokens plus the newly scheduled
385
- # tokens. And we won't allocate KV cache for more than `max_model_len`
386
- # tokens.
379
+ def max_admission_blocks_per_request(
380
+ self, max_num_batched_tokens: int, max_model_len: int
381
+ ) -> int:
382
+ """Per-request admission cap, in blocks.
383
+
384
+ Single source of truth for both startup pool sizing
385
+ (`max_memory_usage_bytes`) and the runtime admission gate, so requests
386
+ admitted by startup can also be admitted at runtime.
387
+ """
388
+ # During chunked prefill, we hold KV for at most one chunk window.
389
num_tokens = min(
390
self.attention_chunk_size + max_num_batched_tokens, max_model_len
391
)
392
+ return cdiv(num_tokens, self.block_size)
393
391
- return cdiv(num_tokens, self.block_size) * self.page_size_bytes
394
+ def max_memory_usage_bytes(self, vllm_config: VllmConfig) -> int:
395
+ max_model_len = vllm_config.model_config.max_model_len
396
+ max_num_batched_tokens = vllm_config.scheduler_config.max_num_batched_tokens
397
+ max_blocks = self.max_admission_blocks_per_request(
398
+ max_num_batched_tokens=max_num_batched_tokens, max_model_len=max_model_len
399
+ )
400
+ return max_blocks * self.page_size_bytes
401
402
403
@dataclass(frozen=True, kw_only=True)
@@ -409,26 +418,38 @@ class SlidingWindowSpec(AttentionSpec):
418
* get_dtype_size(self.dtype)
419
)
420
421
+ def max_admission_blocks_per_request(
422
+ self, max_num_batched_tokens: int, max_model_len: int
423
+ ) -> int:
424
+ """Per-request admission cap, in blocks.
425
+
426
+ Single source of truth for both startup pool sizing
427
+ (`max_memory_usage_bytes`) and the runtime admission gate. Per-request
428
+ real-held blocks plateau at this bound because
429
+ `SlidingWindowManager.remove_skipped_blocks` runs from `allocate_slots`
430
+ before each chunk's `get_num_blocks_to_allocate`.
431
+ """
432
+ # During chunked prefill, we hold KV for the last `sliding_window-1`
433
+ # computed tokens plus the newly scheduled tokens, and never more
434
+ # than `max_model_len`.
435
+ num_tokens = min(
436
+ self.sliding_window - 1 + max_num_batched_tokens, max_model_len
437
+ )
438
+ # +1 because the sliding window may not start from the beginning of
439
+ # the block. E.g. block size 4 and num_token 4 needs two blocks
440
+ # [XXCD][EF] to store the 6-token window [CDEF].
441
+ return cdiv(num_tokens, self.block_size) + 1
442
+
443
def max_memory_usage_bytes(self, vllm_config: VllmConfig) -> int:
444
assert vllm_config.parallel_config.decode_context_parallel_size == 1, (
445
"DCP not support sliding window."
446
)
447
max_model_len = vllm_config.model_config.max_model_len
448
max_num_batched_tokens = vllm_config.scheduler_config.max_num_batched_tokens
418
-
419
- # During chunked prefill, we allocate KV cache for the last
420
- # `self.sliding_window-1` computed tokens plus the newly scheduled
421
- # tokens. And we won't allocate KV cache for more than `max_model_len`
422
- # tokens.
423
- num_tokens = min(
424
- self.sliding_window - 1 + max_num_batched_tokens, max_model_len
449
+ max_blocks = self.max_admission_blocks_per_request(
450
+ max_num_batched_tokens=max_num_batched_tokens, max_model_len=max_model_len
451
)
426
-
427
- # +1 here because the sliding window may not start from the beginning
428
- # of the block. For example, if the block size is 4 and num_token
429
- # is 4, we need two blocks [XXCD] [EF] to store the sliding
430
- # window [CDEF] of 6 tokens.
431
- return (cdiv(num_tokens, self.block_size) + 1) * self.page_size_bytes
452
+ return max_blocks * self.page_size_bytes
453
454
455
@dataclass(frozen=True, kw_only=True)
vllm/v1/simple_kv_offload/manager.py
+3
@@ -110,6 +110,9 @@ class SimpleCPUOffloadScheduler:
110
self.cpu_coordinator: KVCacheCoordinator = get_kv_cache_coordinator(
111
kv_cache_config=self.cpu_kv_cache_config,
112
max_model_len=vllm_config.model_config.max_model_len,
113
+ max_num_batched_tokens=(
114
+ vllm_config.scheduler_config.max_num_batched_tokens
115
+ ),
116
use_eagle=False,
117
enable_caching=True,
118
enable_kv_cache_events=self.enable_kv_cache_events,