[Bugfix][V1] Free out-of-window blocks on the processed-token basis under async scheduling (#47728)
Signed-off-by: Saddss <28726669061@qq.com> Signed-off-by: Nick Hill <nickhill123@gmail.com> Signed-off-by: Yifan Qiao <yifanqiao@inferact.ai> Co-authored-by: Yifan Qiao <yifanqiao@inferact.ai> Co-authored-by: Saddss <28726669061@qq.com> Co-authored-by: Nick Hill <nickhill123@gmail.com>
Saddss committed
Jul 8, 2026 at 20:34 UTC
cd0de48d0883ecb8e1ef350a99baa0c158f58e82
14 files changed
+549
-143
tests/models/multimodal/generation/test_voxtral_realtime.py
+1
-1
@@ -81,7 +81,7 @@ def assert_encoder_kv_cache_spec(engine: LLM) -> None:
81
assert spec.sliding_window == cdiv(750, 4) + 1 == 189
82
assert (
83
spec.max_admission_blocks_per_request(
84
- max_num_batched_tokens=1,
84
+ max_in_flight_tokens=1,
85
max_model_len=vllm_config.model_config.max_model_len,
86
)
87
== 13
tests/v1/core/test_kv_cache_utils.py
+3
@@ -1404,12 +1404,15 @@ def test_get_max_concurrency_for_kv_cache_config():
1404
enable_chunked_prefill=True,
1405
max_model_len=model_config.max_model_len,
1406
is_encoder_decoder=model_config.is_encoder_decoder,
1407
+ # Pin to sync: SWA per-request bounds grow with overlapping batches.
1408
+ async_scheduling=False,
1409
)
1410
1411
vllm_config = VllmConfig(
1412
model_config=model_config,
1413
scheduler_config=scheduler_config,
1414
)
1415
+ assert vllm_config.max_concurrent_batches == 1
1416
1417
full_attention_spec = FullAttentionSpec(
1418
block_size=16,
tests/v1/core/test_prefix_caching.py
+4
-2
@@ -3453,7 +3453,8 @@ def test_can_fit_full_sequence_swa_cap_admits_long_prompt():
3453
manager = make_kv_cache_manager(
3454
config,
3455
max_model_len=max_model_len,
3456
- max_num_batched_tokens=max_num_batched_tokens,
3456
+ # Single (sync) batch in flight, so in-flight tokens == batched tokens.
3457
+ max_in_flight_tokens=max_num_batched_tokens,
3458
enable_caching=True,
3459
hash_block_size=block_size,
3460
)
@@ -3509,7 +3510,8 @@ def test_can_fit_full_sequence_full_attention_still_gates_oversized():
3510
manager = make_kv_cache_manager(
3511
config,
3512
max_model_len=max_model_len,
3512
- max_num_batched_tokens=max_num_batched_tokens,
3513
+ # Single (sync) batch in flight, so in-flight tokens == batched tokens.
3514
+ max_in_flight_tokens=max_num_batched_tokens,
3515
enable_caching=True,
3516
hash_block_size=block_size,
3517
)
tests/v1/core/test_swa_inflight_window_free.py
new
+259
@@ -0,0 +1,259 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Out-of-window block frees vs in-flight GPU steps.
4
+
5
+With async scheduling / PP, `num_computed_tokens` optimistically includes
6
+tokens of unprocessed steps whose attention windows still read the blocks
7
+just below the optimistic boundary (and rejected spec tokens can roll it
8
+back), so `allocate_slots` frees on the processed-token basis:
9
+`num_computed_tokens - num_in_flight_tokens`.
10
+"""
11
+
12
+import torch
13
+
14
+from vllm.v1.core.sched.output import SchedulerOutput
15
+from vllm.v1.kv_cache_interface import ChunkedLocalAttentionSpec, SlidingWindowSpec
16
+from vllm.v1.outputs import ModelRunnerOutput
17
+
18
+from .utils import create_requests, create_scheduler, mock_kv
19
+
20
+NUM_PROMPT_TOKENS = 100
21
+BLOCK_SIZE = 16
22
+SLIDING_WINDOW = 16
23
+# Tokens 0..84 are outside the window of the next token to compute
24
+# (100 - 16 + 1 = 85), i.e. 5 full blocks.
25
+NUM_OUT_OF_WINDOW_BLOCKS = 85 // BLOCK_SIZE
26
+# Chunked-local skips whole chunks left of the current one:
27
+# (100 // 32) * 32 = 96 settled tokens -> 6 full blocks.
28
+CHUNK_SIZE = 32
29
+NUM_OUT_OF_CHUNK_BLOCKS = (NUM_PROMPT_TOKENS // CHUNK_SIZE) * CHUNK_SIZE // BLOCK_SIZE
30
+
31
+
32
+def _make_model_runner_output(
33
+ scheduler_output: SchedulerOutput,
34
+ token_id: int = 0,
35
+) -> ModelRunnerOutput:
36
+ req_ids = list(scheduler_output.num_scheduled_tokens.keys())
37
+ return ModelRunnerOutput(
38
+ req_ids=req_ids,
39
+ req_id_to_index={req_id: i for i, req_id in enumerate(req_ids)},
40
+ sampled_token_ids=[[token_id] for _ in req_ids],
41
+ logprobs=None,
42
+ prompt_logprobs_dict={},
43
+ pooler_output=[],
44
+ )
45
+
46
+
47
+def _create_swa_scheduler(async_scheduling: bool):
48
+ return create_scheduler(
49
+ block_size=BLOCK_SIZE,
50
+ async_scheduling=async_scheduling,
51
+ kv_cache_spec=SlidingWindowSpec(
52
+ block_size=BLOCK_SIZE,
53
+ num_kv_heads=1,
54
+ head_size=1,
55
+ dtype=torch.float32,
56
+ sliding_window=SLIDING_WINDOW,
57
+ ),
58
+ )
59
+
60
+
61
+def _create_chunked_scheduler(async_scheduling: bool):
62
+ return create_scheduler(
63
+ block_size=BLOCK_SIZE,
64
+ async_scheduling=async_scheduling,
65
+ kv_cache_spec=ChunkedLocalAttentionSpec(
66
+ block_size=BLOCK_SIZE,
67
+ num_kv_heads=1,
68
+ head_size=1,
69
+ dtype=torch.float32,
70
+ attention_chunk_size=CHUNK_SIZE,
71
+ ),
72
+ )
73
+
74
+
75
+def _num_null_blocks(scheduler, request_id: str) -> int:
76
+ manager = scheduler.kv_cache_manager.coordinator.single_type_managers[0]
77
+ null_block = manager._null_block
78
+ return sum(1 for b in manager.req_to_blocks[request_id] if b is null_block)
79
+
80
+
81
+def test_num_in_flight_tokens_accounting():
82
+ scheduler = create_scheduler(async_scheduling=True)
83
+ request = create_requests(num_requests=1, num_tokens=NUM_PROMPT_TOKENS)[0]
84
+ scheduler.add_request(request)
85
+
86
+ out0 = scheduler.schedule()
87
+ assert request.num_in_flight_tokens == NUM_PROMPT_TOKENS
88
+ # Async: decode scheduled before the prefill output is processed.
89
+ out1 = scheduler.schedule()
90
+ assert request.num_in_flight_tokens == NUM_PROMPT_TOKENS + 1
91
+
92
+ scheduler.update_from_output(out0, _make_model_runner_output(out0))
93
+ assert request.num_in_flight_tokens == 1
94
+ scheduler.update_from_output(out1, _make_model_runner_output(out1))
95
+ assert request.num_in_flight_tokens == 0
96
+
97
+
98
+def test_swa_free_waits_for_in_flight_step():
99
+ """Async: out-of-window blocks stay allocated until the step that still
100
+ reads them has been processed."""
101
+ scheduler = _create_swa_scheduler(async_scheduling=True)
102
+ request = create_requests(
103
+ num_requests=1, num_tokens=NUM_PROMPT_TOKENS, block_size=BLOCK_SIZE
104
+ )[0]
105
+ scheduler.add_request(request)
106
+ req_id = request.request_id
107
+ block_pool = scheduler.kv_cache_manager.block_pool
108
+
109
+ out0 = scheduler.schedule() # prefill, in flight from here on
110
+ free_after_prefill = block_pool.get_num_free_blocks()
111
+
112
+ # Decode scheduled while the prefill still reads the out-of-window blocks:
113
+ # they must not be freed yet.
114
+ out1 = scheduler.schedule()
115
+ assert _num_null_blocks(scheduler, req_id) == 0
116
+ assert block_pool.get_num_free_blocks() == free_after_prefill
117
+
118
+ # Prefill output processed; the next allocate frees the out-of-window
119
+ # blocks.
120
+ scheduler.update_from_output(out0, _make_model_runner_output(out0))
121
+ scheduler.schedule()
122
+ assert _num_null_blocks(scheduler, req_id) == NUM_OUT_OF_WINDOW_BLOCKS
123
+ assert (
124
+ block_pool.get_num_free_blocks()
125
+ == free_after_prefill + NUM_OUT_OF_WINDOW_BLOCKS
126
+ )
127
+ # Not double-freed on the following steps.
128
+ scheduler.update_from_output(out1, _make_model_runner_output(out1))
129
+ scheduler.schedule()
130
+ assert _num_null_blocks(scheduler, req_id) == NUM_OUT_OF_WINDOW_BLOCKS
131
+
132
+
133
+def test_swa_free_immediate_when_sync():
134
+ """Sync: no in-flight step at schedule time, frees happen at the first
135
+ decode allocation as before."""
136
+ scheduler = _create_swa_scheduler(async_scheduling=False)
137
+ request = create_requests(
138
+ num_requests=1, num_tokens=NUM_PROMPT_TOKENS, block_size=BLOCK_SIZE
139
+ )[0]
140
+ scheduler.add_request(request)
141
+ req_id = request.request_id
142
+
143
+ out0 = scheduler.schedule()
144
+ scheduler.update_from_output(out0, _make_model_runner_output(out0))
145
+ assert request.num_in_flight_tokens == 0
146
+
147
+ scheduler.schedule()
148
+ assert _num_null_blocks(scheduler, req_id) == NUM_OUT_OF_WINDOW_BLOCKS
149
+
150
+
151
+def test_swa_admission_cap_accounts_for_overlapping_batches():
152
+ spec = SlidingWindowSpec(
153
+ block_size=16,
154
+ num_kv_heads=1,
155
+ head_size=1,
156
+ dtype=torch.float32,
157
+ sliding_window=1024,
158
+ )
159
+ base = spec.max_admission_blocks_per_request(
160
+ max_in_flight_tokens=1024, max_model_len=16384
161
+ )
162
+ # (1024 - 1 + 1024) tokens -> 128 blocks, +1 for window misalignment.
163
+ assert base == 129
164
+ overlapped = spec.max_admission_blocks_per_request(
165
+ max_in_flight_tokens=2 * 1024, max_model_len=16384
166
+ )
167
+ # One extra in-flight chunk is held back: (1024 - 1 + 2 * 1024) tokens.
168
+ assert overlapped == 193
169
+
170
+
171
+def test_chunked_local_free_waits_for_in_flight_step():
172
+ """Chunked-local attention frees whole chunks left of the current one, and
173
+ is exposed to the same load-WAR: with async scheduling those chunks must
174
+ stay allocated until the in-flight step that still reads them settles."""
175
+ scheduler = _create_chunked_scheduler(async_scheduling=True)
176
+ request = create_requests(
177
+ num_requests=1, num_tokens=NUM_PROMPT_TOKENS, block_size=BLOCK_SIZE
178
+ )[0]
179
+ scheduler.add_request(request)
180
+ req_id = request.request_id
181
+ block_pool = scheduler.kv_cache_manager.block_pool
182
+
183
+ out0 = scheduler.schedule() # prefill, in flight from here on
184
+ free_after_prefill = block_pool.get_num_free_blocks()
185
+
186
+ # Decode scheduled while the prefill still reads the out-of-chunk blocks.
187
+ out1 = scheduler.schedule()
188
+ assert _num_null_blocks(scheduler, req_id) == 0
189
+ assert block_pool.get_num_free_blocks() == free_after_prefill
190
+
191
+ # Prefill output processed; the next allocate frees the out-of-chunk blocks.
192
+ scheduler.update_from_output(out0, _make_model_runner_output(out0))
193
+ scheduler.schedule()
194
+ assert _num_null_blocks(scheduler, req_id) == NUM_OUT_OF_CHUNK_BLOCKS
195
+ assert (
196
+ block_pool.get_num_free_blocks() == free_after_prefill + NUM_OUT_OF_CHUNK_BLOCKS
197
+ )
198
+ # Not double-freed on the following steps.
199
+ scheduler.update_from_output(out1, _make_model_runner_output(out1))
200
+ scheduler.schedule()
201
+ assert _num_null_blocks(scheduler, req_id) == NUM_OUT_OF_CHUNK_BLOCKS
202
+
203
+
204
+def test_chunked_local_admission_cap_accounts_for_overlapping_batches():
205
+ spec = ChunkedLocalAttentionSpec(
206
+ block_size=16,
207
+ num_kv_heads=1,
208
+ head_size=1,
209
+ dtype=torch.float32,
210
+ attention_chunk_size=1024,
211
+ )
212
+ base = spec.max_admission_blocks_per_request(
213
+ max_in_flight_tokens=1024, max_model_len=16384
214
+ )
215
+ # (1024 + 1024) tokens -> 128 blocks.
216
+ assert base == 128
217
+ overlapped = spec.max_admission_blocks_per_request(
218
+ max_in_flight_tokens=2 * 1024, max_model_len=16384
219
+ )
220
+ # One extra in-flight chunk is held back: (1024 + 2 * 1024) tokens.
221
+ assert overlapped == 192
222
+
223
+
224
+def test_connector_finish_frees_on_settled_basis():
225
+ """The out-of-window prune done at request finish, before the block table
226
+ is handed to a KV connector (simple CPU offload / NIXL store), must use the
227
+ same processed-token basis. Otherwise the connector reads/hands off a block
228
+ the still-in-flight step is optimistically counted as done with, which is
229
+ the load-path WAR this fix closes."""
230
+ scheduler = create_scheduler(
231
+ block_size=BLOCK_SIZE,
232
+ async_scheduling=True,
233
+ use_kv_connector=mock_kv(matched_tokens=0, is_async=False),
234
+ kv_cache_spec=SlidingWindowSpec(
235
+ block_size=BLOCK_SIZE,
236
+ num_kv_heads=1,
237
+ head_size=1,
238
+ dtype=torch.float32,
239
+ sliding_window=SLIDING_WINDOW,
240
+ ),
241
+ )
242
+ request = create_requests(
243
+ num_requests=1, num_tokens=NUM_PROMPT_TOKENS, block_size=BLOCK_SIZE
244
+ )[0]
245
+ scheduler.add_request(request)
246
+ req_id = request.request_id
247
+
248
+ out0 = scheduler.schedule() # prefill, in flight from here on
249
+ scheduler.schedule() # decode over-scheduled: num_computed_tokens optimistic
250
+
251
+ # Finishing now (connector store) must NOT prune out-of-window blocks the
252
+ # in-flight prefill still reads.
253
+ scheduler._connector_finished(request)
254
+ assert _num_null_blocks(scheduler, req_id) == 0
255
+
256
+ # Once the in-flight step settles, the same prune releases them.
257
+ scheduler.update_from_output(out0, _make_model_runner_output(out0))
258
+ scheduler._connector_finished(request)
259
+ assert _num_null_blocks(scheduler, req_id) == NUM_OUT_OF_WINDOW_BLOCKS
tests/v1/core/utils.py
+10
-11
@@ -30,6 +30,7 @@ from vllm.v1.kv_cache_interface import (
30
FullAttentionSpec,
31
KVCacheConfig,
32
KVCacheGroupSpec,
33
+ KVCacheSpec,
34
)
35
from vllm.v1.request import Request
36
from vllm.v1.structured_output import StructuredOutputManager
@@ -63,6 +64,7 @@ def create_scheduler(
64
use_ec_connector: bool = False,
65
ec_role: str | None = None,
66
use_v2_model_runner: bool | None = None,
67
+ kv_cache_spec: KVCacheSpec | None = None,
68
) -> Scheduler | AsyncScheduler:
69
"""Create scheduler under test.
70
@@ -164,20 +166,17 @@ def create_scheduler(
166
speculative_config=speculative_config,
167
ec_transfer_config=ec_transfer_config,
168
)
169
+ if kv_cache_spec is None:
170
+ kv_cache_spec = FullAttentionSpec(
171
+ block_size=block_size,
172
+ num_kv_heads=1,
173
+ head_size=1,
174
+ dtype=torch.float32,
175
+ )
176
kv_cache_config = KVCacheConfig(
177
num_blocks=num_blocks, # A large number of blocks to hold all requests
178
kv_cache_tensors=[],
170
- kv_cache_groups=[
171
- KVCacheGroupSpec(
172
- ["layer"],
173
- FullAttentionSpec(
174
- block_size=block_size,
175
- num_kv_heads=1,
176
- head_size=1,
177
- dtype=torch.float32,
178
- ),
179
- )
180
- ],
179
+ kv_cache_groups=[KVCacheGroupSpec(["layer"], kv_cache_spec)],
180
)
181
cache_config.num_gpu_blocks = num_blocks
182
register_all_kvcache_specs(vllm_config)
tests/v1/e2e/general/test_mamba_prefix_cache.py
+183
-61
@@ -39,13 +39,18 @@ from vllm.v1.worker.mamba_utils import get_mamba_groups
39
class StepAction:
40
num_computed_tokens_start: int
41
num_scheduled_tokens: int
42
- kv_cache_block_ids: list[int] # [] to follow last step
42
+ kv_cache_block_ids: list[int] # per-block mask: 1=held, 0=freed/nulled
43
preprocess_copy_idx: tuple[int, int] # -1, -1 for no copy
44
postprocess_copy_idx: tuple[int, int] # -1, -1 for no copy
45
46
47
num_speculative_tokens = 3
48
49
+# Whether the run under test uses async scheduling. Set by each test entrypoint
50
+# before generation; consulted where the scheduler's optimistic token count must
51
+# be corrected for in-flight (possibly-rejected) speculative tokens.
52
+async_scheduling_mode = False
53
+
54
num_accepted_tokens = 1
55
prompt_token_ids: list[int] = []
56
MODEL = "Qwen/Qwen3-Next-80B-A3B-Instruct-FP8"
@@ -249,7 +254,8 @@ def get_fake_execute_model_fn(original_execute_model_fn: Callable):
254
scheduler_output.scheduled_cached_reqs.num_computed_tokens[0]
255
)
256
if (
252
- self.num_spec_tokens
257
+ async_scheduling_mode
258
+ and self.num_spec_tokens
259
and num_prompt_tokens is not None
260
and num_computed_tokens > num_prompt_tokens
261
):
@@ -496,7 +502,10 @@ def apply_patch(monkeypatch: pytest.MonkeyPatch):
502
monkeypatch.setattr(mamba_utils, "do_mamba_copy_block", fake_copy_fn)
503
504
499
-def get_mamba_prefix_cache_step_configs() -> dict[str, TestConfig]:
505
+def get_mamba_prefix_cache_step_configs(
506
+ async_scheduling: bool = False,
507
+) -> dict[str, TestConfig]:
508
+ a = async_scheduling
509
tests = {
510
"accept_1": TestConfig(
511
num_prompt_tokens=554,
@@ -504,14 +513,24 @@ def get_mamba_prefix_cache_step_configs() -> dict[str, TestConfig]:
513
num_accepted_tokens=1,
514
step_actions=[
515
StepAction(0, 554, [1, 1, 1, 1], (-1, -1), (-1, -1)),
507
- StepAction(554, 4, [], (-1, -1), (-1, -1)),
508
- StepAction(555, 4, [1, 1, 1, 1, 1], (-1, -1), (-1, -1)),
509
- StepAction(556, 4, [], (-1, -1), (-1, -1)),
510
- StepAction(557, 4, [], (0, 1), (-1, -1)),
511
- StepAction(558, 4, [], (-1, -1), (-1, -1)),
512
- StepAction(559, 4, [], (-1, -1), (1, 0)),
513
- StepAction(560, 4, [], (-1, -1), (-1, -1)),
514
- StepAction(561, 4, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)),
516
+ StepAction(554, 4, [1, 1, 1, 1], (-1, -1), (-1, -1)),
517
+ StepAction(
518
+ 555, 4, [1, 1, 1, 1, 1] if a else [1, 1, 1, 1], (-1, -1), (-1, -1)
519
+ ),
520
+ StepAction(
521
+ 556, 4, [1, 1, 1, 1, 1] if a else [1, 1, 1, 1], (-1, -1), (-1, -1)
522
+ ),
523
+ StepAction(557, 4, [1, 1, 1, 1, 1], (0, 1), (-1, -1)),
524
+ StepAction(558, 4, [1, 1, 1, 1, 1], (-1, -1), (-1, -1)),
525
+ StepAction(559, 4, [1, 1, 1, 1, 1], (-1, -1), (1, 0)),
526
+ StepAction(560, 4, [1, 1, 1, 1, 1], (-1, -1), (-1, -1)),
527
+ StepAction(
528
+ 561,
529
+ 4,
530
+ [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1],
531
+ (-1, -1),
532
+ (-1, -1),
533
+ ),
534
],
535
),
536
# test case 2.1: no hit, accept 2 tokens
@@ -521,11 +540,19 @@ def get_mamba_prefix_cache_step_configs() -> dict[str, TestConfig]:
540
num_accepted_tokens=2,
541
step_actions=[
542
StepAction(0, 554, [1, 1, 1, 1], (-1, -1), (-1, -1)),
524
- StepAction(554, 4, [], (-1, -1), (-1, -1)),
525
- StepAction(556, 4, [1, 1, 1, 1, 1], (-1, -1), (-1, -1)),
526
- StepAction(558, 4, [], (1, 1), (2, 0)),
527
- StepAction(560, 4, [], (-1, -1), (-1, -1)),
528
- StepAction(562, 4, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)),
543
+ StepAction(554, 4, [1, 1, 1, 1], (-1, -1), (-1, -1)),
544
+ StepAction(
545
+ 556, 4, [1, 1, 1, 1, 1] if a else [1, 1, 1, 1], (-1, -1), (-1, -1)
546
+ ),
547
+ StepAction(558, 4, [1, 1, 1, 1, 1], (1, 1), (2, 0)),
548
+ StepAction(560, 4, [1, 1, 1, 1, 1], (-1, -1), (-1, -1)),
549
+ StepAction(
550
+ 562,
551
+ 4,
552
+ [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1],
553
+ (-1, -1),
554
+ (-1, -1),
555
+ ),
556
],
557
),
558
# test case 2.2: no hit, accept 2 tokens
@@ -535,10 +562,16 @@ def get_mamba_prefix_cache_step_configs() -> dict[str, TestConfig]:
562
num_accepted_tokens=2,
563
step_actions=[
564
StepAction(0, 555, [1, 1, 1, 1], (-1, -1), (-1, -1)),
538
- StepAction(555, 4, [], (-1, -1), (-1, -1)),
565
+ StepAction(555, 4, [1, 1, 1, 1], (-1, -1), (-1, -1)),
566
StepAction(557, 4, [1, 1, 1, 1, 1], (1, 1), (-1, -1)),
540
- StepAction(559, 4, [], (-1, -1), (1, 0)),
541
- StepAction(561, 4, [], (-1, -1), (-1, -1)),
567
+ StepAction(559, 4, [1, 1, 1, 1, 1], (-1, -1), (1, 0)),
568
+ StepAction(
569
+ 561,
570
+ 4,
571
+ [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1],
572
+ (-1, -1),
573
+ (-1, -1),
574
+ ),
575
StepAction(563, 4, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)),
576
],
577
),
@@ -548,10 +581,18 @@ def get_mamba_prefix_cache_step_configs() -> dict[str, TestConfig]:
581
num_accepted_tokens=3,
582
step_actions=[
583
StepAction(0, 553, [1, 1, 1, 1], (-1, -1), (-1, -1)),
551
- StepAction(553, 4, [], (-1, -1), (-1, -1)),
552
- StepAction(556, 4, [1, 1, 1, 1, 1], (-1, -1), (-1, -1)),
553
- StepAction(559, 4, [], (2, 1), (1, 0)),
554
- StepAction(562, 4, [], (-1, -1), (-1, -1)),
584
+ StepAction(553, 4, [1, 1, 1, 1], (-1, -1), (-1, -1)),
585
+ StepAction(
586
+ 556, 4, [1, 1, 1, 1, 1] if a else [1, 1, 1, 1], (-1, -1), (-1, -1)
587
+ ),
588
+ StepAction(559, 4, [1, 1, 1, 1, 1], (2, 1), (1, 0)),
589
+ StepAction(
590
+ 562,
591
+ 4,
592
+ [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1],
593
+ (-1, -1),
594
+ (-1, -1),
595
+ ),
596
StepAction(565, 4, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)),
597
],
598
),
@@ -561,10 +602,16 @@ def get_mamba_prefix_cache_step_configs() -> dict[str, TestConfig]:
602
num_accepted_tokens=3,
603
step_actions=[
604
StepAction(0, 554, [1, 1, 1, 1], (-1, -1), (-1, -1)),
564
- StepAction(554, 4, [], (-1, -1), (-1, -1)),
605
+ StepAction(554, 4, [1, 1, 1, 1], (-1, -1), (-1, -1)),
606
StepAction(557, 4, [1, 1, 1, 1, 1], (2, 1), (3, 0)),
566
- StepAction(560, 4, [], (-1, -1), (-1, -1)),
567
- StepAction(563, 4, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)),
607
+ StepAction(560, 4, [1, 1, 1, 1, 1], (-1, -1), (-1, -1)),
608
+ StepAction(
609
+ 563,
610
+ 4,
611
+ [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1],
612
+ (-1, -1),
613
+ (-1, -1),
614
+ ),
615
],
616
),
617
"accept_3_3": TestConfig(
@@ -573,9 +620,15 @@ def get_mamba_prefix_cache_step_configs() -> dict[str, TestConfig]:
620
num_accepted_tokens=3,
621
step_actions=[
622
StepAction(0, 555, [1, 1, 1, 1], (-1, -1), (-1, -1)),
576
- StepAction(555, 4, [], (-1, -1), (-1, -1)),
623
+ StepAction(555, 4, [1, 1, 1, 1], (-1, -1), (-1, -1)),
624
StepAction(558, 4, [1, 1, 1, 1, 1], (2, 1), (2, 0)),
578
- StepAction(561, 4, [], (-1, -1), (-1, -1)),
625
+ StepAction(
626
+ 561,
627
+ 4,
628
+ [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1],
629
+ (-1, -1),
630
+ (-1, -1),
631
+ ),
632
StepAction(564, 4, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)),
633
],
634
),
@@ -585,9 +638,15 @@ def get_mamba_prefix_cache_step_configs() -> dict[str, TestConfig]:
638
num_accepted_tokens=4,
639
step_actions=[
640
StepAction(0, 553, [1, 1, 1, 1], (-1, -1), (-1, -1)),
588
- StepAction(553, 4, [], (-1, -1), (-1, -1)),
641
+ StepAction(553, 4, [1, 1, 1, 1], (-1, -1), (-1, -1)),
642
StepAction(557, 4, [1, 1, 1, 1, 1], (3, 1), (3, 0)),
590
- StepAction(561, 4, [], (-1, -1), (-1, -1)),
643
+ StepAction(
644
+ 561,
645
+ 4,
646
+ [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1],
647
+ (-1, -1),
648
+ (-1, -1),
649
+ ),
650
StepAction(565, 4, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)),
651
],
652
),
@@ -597,9 +656,15 @@ def get_mamba_prefix_cache_step_configs() -> dict[str, TestConfig]:
656
num_accepted_tokens=4,
657
step_actions=[
658
StepAction(0, 554, [1, 1, 1, 1], (-1, -1), (-1, -1)),
600
- StepAction(554, 4, [], (-1, -1), (-1, -1)),
659
+ StepAction(554, 4, [1, 1, 1, 1], (-1, -1), (-1, -1)),
660
StepAction(558, 4, [1, 1, 1, 1, 1], (3, 1), (2, 0)),
602
- StepAction(562, 4, [], (-1, -1), (-1, -1)),
661
+ StepAction(
662
+ 562,
663
+ 4,
664
+ [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1],
665
+ (-1, -1),
666
+ (-1, -1),
667
+ ),
668
StepAction(566, 4, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)),
669
],
670
),
@@ -609,9 +674,15 @@ def get_mamba_prefix_cache_step_configs() -> dict[str, TestConfig]:
674
num_accepted_tokens=4,
675
step_actions=[
676
StepAction(0, 555, [1, 1, 1, 1], (-1, -1), (-1, -1)),
612
- StepAction(555, 4, [], (-1, -1), (-1, -1)),
677
+ StepAction(555, 4, [1, 1, 1, 1], (-1, -1), (-1, -1)),
678
StepAction(559, 4, [1, 1, 1, 1, 1], (3, 1), (1, 0)),
614
- StepAction(563, 4, [], (-1, -1), (-1, -1)),
679
+ StepAction(
680
+ 563,
681
+ 4,
682
+ [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1],
683
+ (-1, -1),
684
+ (-1, -1),
685
+ ),
686
StepAction(567, 4, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)),
687
],
688
),
@@ -621,9 +692,15 @@ def get_mamba_prefix_cache_step_configs() -> dict[str, TestConfig]:
692
num_accepted_tokens=4,
693
step_actions=[
694
StepAction(0, 556, [1, 1, 1, 1], (-1, -1), (-1, -1)),
624
- StepAction(556, 4, [], (-1, -1), (3, 0)),
695
+ StepAction(556, 4, [1, 1, 1, 1], (-1, -1), (3, 0)),
696
StepAction(560, 4, [1, 1, 1, 1, 1], (0, 1), (-1, -1)),
626
- StepAction(564, 4, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)),
697
+ StepAction(
698
+ 564,
699
+ 4,
700
+ [1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1],
701
+ (-1, -1),
702
+ (-1, -1),
703
+ ),
704
],
705
),
706
"prompt_block_size": TestConfig(
@@ -642,7 +719,13 @@ def get_mamba_prefix_cache_step_configs() -> dict[str, TestConfig]:
719
step_actions=[
720
StepAction(0, 560, [1, 1, 1, 1], (-1, -1), (-1, -1)),
721
StepAction(560, 560, [1, 1, 1, 1, 1], (0, 1), (-1, -1)),
645
- StepAction(560 * 2, 4, [0, 1, 1, 1, 1, 1], (1, 2), (-1, -1)),
722
+ StepAction(
723
+ 560 * 2,
724
+ 4,
725
+ [1, 1, 1, 1, 1, 1] if a else [0, 1, 1, 1, 1, 1],
726
+ (1, 2),
727
+ (-1, -1),
728
+ ),
729
],
730
),
731
"prompt_2_block_size_10": TestConfig(
@@ -652,7 +735,13 @@ def get_mamba_prefix_cache_step_configs() -> dict[str, TestConfig]:
735
step_actions=[
736
StepAction(0, 560, [1, 1, 1, 1], (-1, -1), (-1, -1)),
737
StepAction(560, 570, [1, 0, 1, 1, 1, 1], (0, 2), (-1, -1)),
655
- StepAction(560 * 2 + 10, 4, [0, 0, 1, 1, 1, 1], (-1, -1), (-1, -1)),
738
+ StepAction(
739
+ 560 * 2 + 10,
740
+ 4,
741
+ [1, 0, 1, 1, 1, 1] if a else [0, 0, 1, 1, 1, 1],
742
+ (-1, -1),
743
+ (-1, -1),
744
+ ),
745
],
746
),
747
"prompt_3_block_size": TestConfig(
@@ -662,7 +751,13 @@ def get_mamba_prefix_cache_step_configs() -> dict[str, TestConfig]:
751
step_actions=[
752
StepAction(0, 560 * 2, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)),
753
StepAction(560 * 2, 560, [0, 1, 1, 1, 1, 1], (1, 2), (-1, -1)),
665
- StepAction(560 * 3, 4, [0, 0, 1, 1, 1, 1, 1], (2, 3), (-1, -1)),
754
+ StepAction(
755
+ 560 * 3,
756
+ 4,
757
+ [0, 1, 1, 1, 1, 1, 1] if a else [0, 0, 1, 1, 1, 1, 1],
758
+ (2, 3),
759
+ (-1, -1),
760
+ ),
761
],
762
),
763
"prompt_3_block_size_10": TestConfig(
@@ -672,7 +767,13 @@ def get_mamba_prefix_cache_step_configs() -> dict[str, TestConfig]:
767
step_actions=[
768
StepAction(0, 560 * 2, [0, 1, 1, 1, 1], (-1, -1), (-1, -1)),
769
StepAction(560 * 2, 570, [0, 1, 0, 1, 1, 1, 1], (1, 3), (-1, -1)),
675
- StepAction(560 * 3 + 10, 4, [0, 0, 0, 1, 1, 1, 1], (-1, -1), (-1, -1)),
770
+ StepAction(
771
+ 560 * 3 + 10,
772
+ 4,
773
+ [0, 1, 0, 1, 1, 1, 1] if a else [0, 0, 0, 1, 1, 1, 1],
774
+ (-1, -1),
775
+ (-1, -1),
776
+ ),
777
],
778
),
779
"prompt_10_block_size": TestConfig(
@@ -691,14 +792,18 @@ def get_mamba_prefix_cache_step_configs() -> dict[str, TestConfig]:
792
StepAction(
793
560 * 9,
794
560,
694
- [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1],
795
+ [0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1, 1]
796
+ if a
797
+ else [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1],
798
(8, 9),
799
(-1, -1),
800
),
801
StepAction(
802
560 * 10,
803
4,
701
- [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1],
804
+ [0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1]
805
+ if a
806
+ else [0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1],
807
(9, 10),
808
(-1, -1),
809
),
@@ -720,34 +825,28 @@ def get_mamba_prefix_cache_step_configs() -> dict[str, TestConfig]:
825
StepAction(
826
560 * 9,
827
560 + 10,
723
- [0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1],
828
+ [0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 1, 1]
829
+ if a
830
+ else [0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1],
831
(8, 10),
832
(-1, -1),
833
),
834
],
835
),
836
}
730
-
837
return tests
838
839
734
-def fill_following_kv_cache_block_ids(test_config: TestConfig) -> None:
735
- for step_action_prev, step_action_next in zip(
736
- test_config.step_actions[:-1], test_config.step_actions[1:]
737
- ):
738
- if len(step_action_next.kv_cache_block_ids) == 0:
739
- step_action_next.kv_cache_block_ids = (
740
- step_action_prev.kv_cache_block_ids.copy()
741
- )
742
-
743
-
744
-@create_new_process_for_each_test()
745
-def test_mamba_prefix_cache_mrv1(monkeypatch: pytest.MonkeyPatch):
840
+def _run_mamba_prefix_cache_mrv1(
841
+ monkeypatch: pytest.MonkeyPatch, async_scheduling: bool
842
+):
843
+ global async_scheduling_mode
844
+ async_scheduling_mode = async_scheduling
845
run_ref_mamba_state_in_subprocess()
846
apply_patch(monkeypatch)
847
prompt_dataset = datasets.load_dataset("heheda/a_long_article")
848
full_prompt = prompt_dataset["train"][0]["text"]
750
- tests = get_mamba_prefix_cache_step_configs()
849
+ tests = get_mamba_prefix_cache_step_configs(async_scheduling)
850
851
engine = LLM(
852
model=MODEL,
@@ -761,6 +860,7 @@ def test_mamba_prefix_cache_mrv1(monkeypatch: pytest.MonkeyPatch):
860
},
861
max_num_batched_tokens=3072,
862
hf_overrides={"num_hidden_layers": NUM_HIDDEN_LAYERS},
863
+ async_scheduling=async_scheduling,
864
seed=42,
865
)
866
global prompt_token_ids
@@ -777,7 +877,6 @@ def test_mamba_prefix_cache_mrv1(monkeypatch: pytest.MonkeyPatch):
877
)
878
global cur_step_action_idx
879
cur_step_action_idx = 0
780
- fill_following_kv_cache_block_ids(test_config)
880
global step_actions
881
step_actions = test_config.step_actions
882
_ = engine.generate(
@@ -800,7 +899,20 @@ def test_mamba_prefix_cache_mrv1(monkeypatch: pytest.MonkeyPatch):
899
900
901
@create_new_process_for_each_test()
803
-def test_mamba_prefix_cache_mrv2(monkeypatch: pytest.MonkeyPatch):
902
+def test_mamba_prefix_cache_mrv1(monkeypatch: pytest.MonkeyPatch):
903
+ _run_mamba_prefix_cache_mrv1(monkeypatch, async_scheduling=False)
904
+
905
+
906
+@create_new_process_for_each_test()
907
+def test_mamba_prefix_cache_mrv1_async(monkeypatch: pytest.MonkeyPatch):
908
+ _run_mamba_prefix_cache_mrv1(monkeypatch, async_scheduling=True)
909
+
910
+
911
+def _run_mamba_prefix_cache_mrv2(
912
+ monkeypatch: pytest.MonkeyPatch, async_scheduling: bool
913
+):
914
+ global async_scheduling_mode
915
+ async_scheduling_mode = async_scheduling
916
monkeypatch.setenv("VLLM_ENABLE_V1_MULTIPROCESSING", "0")
917
monkeypatch.setenv("VLLM_USE_V2_MODEL_RUNNER", "1")
918
envs.disable_envs_cache()
@@ -998,11 +1110,12 @@ def test_mamba_prefix_cache_mrv2(monkeypatch: pytest.MonkeyPatch):
1110
max_num_batched_tokens=3072,
1111
max_model_len=BLOCK_SIZE * 12,
1112
hf_overrides={"num_hidden_layers": NUM_HIDDEN_LAYERS},
1113
+ async_scheduling=async_scheduling,
1114
seed=42,
1115
)
1116
1117
try:
1005
- tests = get_mamba_prefix_cache_step_configs()
1118
+ tests = get_mamba_prefix_cache_step_configs(async_scheduling)
1119
1120
global step_actions
1121
global cur_step_action_idx
@@ -1010,7 +1123,6 @@ def test_mamba_prefix_cache_mrv2(monkeypatch: pytest.MonkeyPatch):
1123
for test_name, test_config in tests.items():
1124
num_accepted_tokens = test_config.num_accepted_tokens
1125
cur_step_action_idx = 0
1013
- fill_following_kv_cache_block_ids(test_config)
1126
step_actions = test_config.step_actions
1127
sampling_params = SamplingParams(
1128
temperature=0.0,
@@ -1053,3 +1165,13 @@ def test_mamba_prefix_cache_mrv2(monkeypatch: pytest.MonkeyPatch):
1165
del engine
1166
torch.accelerator.empty_cache()
1167
cleanup_dist_env_and_memory()
1168
+
1169
+
1170
+@create_new_process_for_each_test()
1171
+def test_mamba_prefix_cache_mrv2(monkeypatch: pytest.MonkeyPatch):
1172
+ _run_mamba_prefix_cache_mrv2(monkeypatch, async_scheduling=False)
1173
+
1174
+
1175
+@create_new_process_for_each_test()
1176
+def test_mamba_prefix_cache_mrv2_async(monkeypatch: pytest.MonkeyPatch):
1177
+ _run_mamba_prefix_cache_mrv2(monkeypatch, async_scheduling=True)
vllm/config/vllm.py
+11
@@ -501,6 +501,17 @@ class VllmConfig:
501
return 2
502
return pp_size
503
504
+ @property
505
+ def max_in_flight_tokens(self) -> int:
506
+ # Upper bound on tokens that are scheduled but not yet settled (freed):
507
+ # every concurrent batch may hold up to a full `max_num_batched_tokens`.
508
+ # Recycling-aware KV cache specs (sliding-window, chunked-local) reserve
509
+ # for this because out-of-window blocks are freed on the processed-token
510
+ # basis, so in-flight steps transiently keep their blocks.
511
+ return (
512
+ self.max_concurrent_batches * self.scheduler_config.max_num_batched_tokens
513
+ )
514
+
515
@property
516
def num_speculative_tokens(self) -> int:
517
if (
vllm/v1/core/kv_cache_coordinator.py
+16
-16
@@ -67,7 +67,7 @@ class KVCacheCoordinator(ABC):
67
self,
68
kv_cache_config: KVCacheConfig,
69
max_model_len: int,
70
- max_num_batched_tokens: int,
70
+ max_in_flight_tokens: int,
71
use_eagle: bool,
72
enable_caching: bool,
73
enable_kv_cache_events: bool,
@@ -107,7 +107,7 @@ class KVCacheCoordinator(ABC):
107
self.single_type_managers = tuple(
108
get_manager_for_kv_cache_spec(
109
kv_cache_spec=kv_cache_group.kv_cache_spec,
110
- max_num_batched_tokens=max_num_batched_tokens,
110
+ max_in_flight_tokens=max_in_flight_tokens,
111
max_model_len=max_model_len,
112
block_pool=self.block_pool,
113
enable_caching=enable_caching,
@@ -332,7 +332,7 @@ class KVCacheCoordinator(ABC):
332
def remove_skipped_blocks(
333
self,
334
request_id: str,
335
- total_computed_tokens: int,
335
+ processed_computed_tokens: int,
336
num_prompt_tokens: int | None = None,
337
) -> None:
338
"""
@@ -341,15 +341,15 @@ class KVCacheCoordinator(ABC):
341
342
Args:
343
request_id: The request ID.
344
- total_computed_tokens: The total number of computed tokens, including
345
- local computed tokens and external computed tokens.
344
+ processed_computed_tokens: Computed-token prefix length covering
345
+ fully processed and committed tokens only (safe to free).
346
num_prompt_tokens: Optional prompt length. R-SWA managers use this to
347
free gap blocks between the prefill tail and decode window; other
348
manager types ignore it.
349
"""
350
for manager in self.single_type_managers:
351
manager.remove_skipped_blocks(
352
- request_id, total_computed_tokens, num_prompt_tokens
352
+ request_id, processed_computed_tokens, num_prompt_tokens
353
)
354
355
def get_blocks(self, request_id: str) -> tuple[list[KVCacheBlock], ...]:
@@ -387,7 +387,7 @@ class KVCacheCoordinatorNoPrefixCache(KVCacheCoordinator):
387
self,
388
kv_cache_config: KVCacheConfig,
389
max_model_len: int,
390
- max_num_batched_tokens: int,
390
+ max_in_flight_tokens: int,
391
use_eagle: bool,
392
enable_kv_cache_events: bool,
393
dcp_world_size: int,
@@ -399,7 +399,7 @@ class KVCacheCoordinatorNoPrefixCache(KVCacheCoordinator):
399
super().__init__(
400
kv_cache_config,
401
max_model_len,
402
- max_num_batched_tokens,
402
+ max_in_flight_tokens,
403
use_eagle,
404
False,
405
enable_kv_cache_events,
@@ -436,7 +436,7 @@ class UnitaryKVCacheCoordinator(KVCacheCoordinator):
436
self,
437
kv_cache_config: KVCacheConfig,
438
max_model_len: int,
439
- max_num_batched_tokens: int,
439
+ max_in_flight_tokens: int,
440
use_eagle: bool,
441
enable_caching: bool,
442
enable_kv_cache_events: bool,
@@ -449,7 +449,7 @@ class UnitaryKVCacheCoordinator(KVCacheCoordinator):
449
super().__init__(
450
kv_cache_config,
451
max_model_len,
452
- max_num_batched_tokens,
452
+ max_in_flight_tokens,
453
use_eagle,
454
enable_caching,
455
enable_kv_cache_events,
@@ -522,7 +522,7 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
522
self,
523
kv_cache_config: KVCacheConfig,
524
max_model_len: int,
525
- max_num_batched_tokens: int,
525
+ max_in_flight_tokens: int,
526
use_eagle: bool,
527
enable_caching: bool,
528
enable_kv_cache_events: bool,
@@ -535,7 +535,7 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
535
super().__init__(
536
kv_cache_config,
537
max_model_len,
538
- max_num_batched_tokens,
538
+ max_in_flight_tokens,
539
use_eagle,
540
enable_caching,
541
enable_kv_cache_events,
@@ -783,7 +783,7 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
783
def get_kv_cache_coordinator(
784
kv_cache_config: KVCacheConfig,
785
max_model_len: int,
786
- max_num_batched_tokens: int,
786
+ max_in_flight_tokens: int,
787
use_eagle: bool,
788
enable_caching: bool,
789
enable_kv_cache_events: bool,
@@ -797,7 +797,7 @@ def get_kv_cache_coordinator(
797
return KVCacheCoordinatorNoPrefixCache(
798
kv_cache_config,
799
max_model_len,
800
- max_num_batched_tokens,
800
+ max_in_flight_tokens,
801
use_eagle,
802
enable_kv_cache_events,
803
dcp_world_size=dcp_world_size,
@@ -810,7 +810,7 @@ def get_kv_cache_coordinator(
810
return UnitaryKVCacheCoordinator(
811
kv_cache_config,
812
max_model_len,
813
- max_num_batched_tokens,
813
+ max_in_flight_tokens,
814
use_eagle,
815
enable_caching,
816
enable_kv_cache_events,
@@ -823,7 +823,7 @@ def get_kv_cache_coordinator(
823
return HybridKVCacheCoordinator(
824
kv_cache_config,
825
max_model_len,
826
- max_num_batched_tokens,
826
+ max_in_flight_tokens,
827
use_eagle,
828
enable_caching,
829
enable_kv_cache_events,
vllm/v1/core/kv_cache_manager.py
+12
-9
@@ -114,7 +114,7 @@ class KVCacheManager:
114
max_model_len: int,
115
scheduler_block_size: int,
116
hash_block_size: int,
117
- max_num_batched_tokens: int | None = None,
117
+ max_in_flight_tokens: int | None = None,
118
enable_caching: bool = True,
119
use_eagle: bool = False,
120
log_stats: bool = False,
@@ -128,8 +128,8 @@ class KVCacheManager:
128
# When unset, fall back to `max_model_len` so the recycling-aware cap
129
# collapses to the prior (uncapped) admission behavior. The scheduler
130
# always supplies the real value at runtime.
131
- if max_num_batched_tokens is None:
132
- max_num_batched_tokens = max_model_len
131
+ if max_in_flight_tokens is None:
132
+ max_in_flight_tokens = max_model_len
133
134
self.enable_caching = enable_caching
135
self.use_eagle = use_eagle
@@ -143,7 +143,7 @@ class KVCacheManager:
143
self.coordinator = get_kv_cache_coordinator(
144
kv_cache_config=kv_cache_config,
145
max_model_len=self.max_model_len,
146
- max_num_batched_tokens=max_num_batched_tokens,
146
+ max_in_flight_tokens=max_in_flight_tokens,
147
use_eagle=self.use_eagle,
148
enable_caching=self.enable_caching,
149
enable_kv_cache_events=enable_kv_cache_events,
@@ -397,9 +397,12 @@ class KVCacheManager:
397
# insufficient free blocks.
398
# Should call this function before allocating new blocks to reduce
399
# the number of evicted blocks.
400
+ # Free on the processed-token basis: in-flight steps' attention windows
401
+ # still read blocks below the optimistic boundary, and rejected spec
402
+ # tokens can roll it back.
403
self.coordinator.remove_skipped_blocks(
404
request.request_id,
402
- total_computed_tokens,
405
+ max(0, total_computed_tokens - request.num_in_flight_tokens),
406
num_prompt_tokens=request.num_prompt_tokens,
407
)
408
@@ -472,7 +475,7 @@ class KVCacheManager:
475
def remove_skipped_blocks(
476
self,
477
request_id: str,
475
- total_computed_tokens: int,
478
+ processed_computed_tokens: int,
479
num_prompt_tokens: int | None = None,
480
) -> None:
481
"""Remove the blocks that are no longer needed from `blocks` and replace
@@ -480,12 +483,12 @@ class KVCacheManager:
483
484
Args:
485
request_id: The request ID.
483
- total_computed_tokens: The total number of computed tokens, including
484
- local computed tokens and external computed tokens.
486
+ processed_computed_tokens: Computed-token prefix length covering
487
+ fully processed and committed tokens only (safe to free).
488
num_prompt_tokens: Optional prompt length for R-SWA gap eviction.
489
"""
490
self.coordinator.remove_skipped_blocks(
488
- request_id, total_computed_tokens, num_prompt_tokens
491
+ request_id, processed_computed_tokens, num_prompt_tokens
492
)
493
494
def pop_blocks_for_free(self, request: Request) -> list[KVCacheBlock]:
vllm/v1/core/sched/scheduler.py
+9
-4
@@ -262,7 +262,7 @@ class Scheduler(SchedulerInterface):
262
self.kv_cache_manager = KVCacheManager(
263
kv_cache_config=kv_cache_config,
264
max_model_len=self.max_model_len,
265
- max_num_batched_tokens=self.scheduler_config.max_num_batched_tokens,
265
+ max_in_flight_tokens=vllm_config.max_in_flight_tokens,
266
enable_caching=self.cache_config.enable_prefix_caching,
267
use_eagle=self.use_eagle,
268
log_stats=self.log_stats,
@@ -1180,6 +1180,7 @@ class Scheduler(SchedulerInterface):
1180
for req_id, num_scheduled_token in num_scheduled_tokens.items():
1181
request = self.requests[req_id]
1182
request.num_computed_tokens += num_scheduled_token
1183
+ request.num_in_flight_tokens += num_scheduled_token
1184
if self.defer_block_free:
1185
# Record the in-flight step, to fence deferred block freeing.
1186
request.last_sched_seq = self.sched_step_seq
@@ -1566,10 +1567,12 @@ class Scheduler(SchedulerInterface):
1567
stopped_preempted_reqs: set[Request] = set()
1568
for req_id, num_tokens_scheduled in num_scheduled_tokens.items():
1569
assert num_tokens_scheduled > 0
1570
+ request = self.requests.get(req_id)
1571
+ if request is not None:
1572
+ request.num_in_flight_tokens -= num_tokens_scheduled
1573
if failed_kv_load_req_ids and req_id in failed_kv_load_req_ids:
1574
# skip failed or rescheduled requests from KV load failure
1575
continue
1572
- request = self.requests.get(req_id)
1576
if request is None or request.is_finished():
1577
# The request is already finished. This can happen if the
1578
# request is aborted while the model is executing it (e.g.,
@@ -2367,10 +2370,12 @@ class Scheduler(SchedulerInterface):
2370
return False, None
2371
2372
# Free any out-of-window prefix blocks before we hand the block table to
2370
- # the connector.
2373
+ # the connector, on the processed-token basis (see `allocate_slots`).
2374
self.kv_cache_manager.remove_skipped_blocks(
2375
request_id=request.request_id,
2373
- total_computed_tokens=request.num_computed_tokens,
2376
+ processed_computed_tokens=max(
2377
+ 0, request.num_computed_tokens - request.num_in_flight_tokens
2378
+ ),
2379
num_prompt_tokens=request.num_prompt_tokens,
2380
)
2381
vllm/v1/core/single_type_kv_cache_manager.py
+17
-21
@@ -508,7 +508,7 @@ class SingleTypeKVCacheManager(ABC):
508
def remove_skipped_blocks(
509
self,
510
request_id: str,
511
- total_computed_tokens: int,
511
+ processed_computed_tokens: int,
512
num_prompt_tokens: int | None = None,
513
) -> None:
514
"""
@@ -520,15 +520,15 @@ class SingleTypeKVCacheManager(ABC):
520
521
Args:
522
request_id: The request ID.
523
- total_computed_tokens: The total number of computed tokens, including
524
- local computed tokens and external computed tokens.
523
+ processed_computed_tokens: Computed-token prefix length covering
524
+ fully processed and committed tokens only (safe to free).
525
num_prompt_tokens: Optional prompt length for attention types (e.g.
526
R-SWA) that evict a middle gap rather than a head prefix. Ignored
527
by the default implementation.
528
"""
529
del num_prompt_tokens
530
# Remove the blocks that will be skipped during attention computation.
531
- num_skipped_tokens = self.get_num_skipped_tokens(total_computed_tokens)
531
+ num_skipped_tokens = self.get_num_skipped_tokens(processed_computed_tokens)
532
if num_skipped_tokens <= 0:
533
# This indicates that ALL tokens are inside attention window.
534
# Thus we do not need to free any blocks outside attention window.
@@ -639,14 +639,14 @@ class RSWAManager(FullAttentionManager):
639
def remove_skipped_blocks(
640
self,
641
request_id: str,
642
- total_computed_tokens: int,
642
+ processed_computed_tokens: int,
643
num_prompt_tokens: int | None = None,
644
) -> None:
645
"""Free gap blocks that are no longer needed for attention.
646
647
Gap = blocks entirely within
648
[ceil(prefix_len / block_size) * block_size,
649
- max(prefix_len, total_computed_tokens - rswa_window))
649
+ max(prefix_len, processed_computed_tokens - rswa_window))
650
651
Freed blocks are replaced with null_block in req_to_blocks so the
652
block_table passed to FA4 is valid (null_block KV is all-zero;
@@ -654,7 +654,7 @@ class RSWAManager(FullAttentionManager):
654
"""
655
if num_prompt_tokens is None:
656
super().remove_skipped_blocks(
657
- request_id, total_computed_tokens, num_prompt_tokens
657
+ request_id, processed_computed_tokens, num_prompt_tokens
658
)
659
return
660
@@ -662,7 +662,9 @@ class RSWAManager(FullAttentionManager):
662
# First block fully after the prefill boundary.
663
first_gap_block = cdiv(num_prompt_tokens, bs)
664
# Decode window start position; blocks before this are evictable.
665
- window_start = max(num_prompt_tokens, total_computed_tokens - self.rswa_window)
665
+ window_start = max(
666
+ num_prompt_tokens, processed_computed_tokens - self.rswa_window
667
+ )
668
last_gap_block = window_start // bs # exclusive upper bound
669
self._remove_blocks_in_range(request_id, first_gap_block, last_gap_block)
670
@@ -1144,20 +1146,13 @@ class MambaManager(SingleTypeKVCacheManager):
1146
def remove_skipped_blocks(
1147
self,
1148
request_id: str,
1147
- num_computed_tokens: int,
1149
+ processed_computed_tokens: int,
1150
num_prompt_tokens: int | None = None,
1151
) -> None:
1152
assert isinstance(self.kv_cache_spec, MambaSpec)
1153
1152
- # NOTE (tdoublep) with async scheduling, the num_computed_tokens can contain
1153
- # draft tokens from the previous step that may or may not be rejected later.
1154
- # This can make us think we are further ahead in the sequence than we actually
1155
- # are, so let's assume that all tokens are rejected so we don't free blocks
1156
- # that we might actually need.
1157
- num_computed_tokens = max(0, num_computed_tokens - self.num_speculative_blocks)
1158
-
1154
super().remove_skipped_blocks(
1160
- request_id, num_computed_tokens, num_prompt_tokens
1155
+ request_id, processed_computed_tokens, num_prompt_tokens
1156
)
1157
if self.mamba_cache_mode == "align":
1158
# `last_state_block_idx` refers to the block index allocated two steps ago.
@@ -1171,7 +1166,7 @@ class MambaManager(SingleTypeKVCacheManager):
1166
if (
1167
last_state_block_idx is not None
1168
and last_state_block_idx
1174
- < cdiv(num_computed_tokens, self.block_size) - 1
1169
+ < cdiv(processed_computed_tokens, self.block_size) - 1
1170
):
1171
blocks = self.req_to_blocks[request_id]
1172
if blocks[last_state_block_idx] != self._null_block:
@@ -1455,7 +1450,7 @@ class SinkFullAttentionManager(FullAttentionManager):
1450
1451
def get_manager_for_kv_cache_spec(
1452
kv_cache_spec: KVCacheSpec,
1458
- max_num_batched_tokens: int,
1453
+ max_in_flight_tokens: int,
1454
max_model_len: int,
1455
**kwargs,
1456
) -> SingleTypeKVCacheManager:
@@ -1468,7 +1463,8 @@ def get_manager_for_kv_cache_spec(
1463
1464
Args:
1465
kv_cache_spec: The KVCacheSpec instance
1471
- max_num_batched_tokens: The maximum number of tokens in a batch
1466
+ max_in_flight_tokens: The max tokens scheduled but not yet settled
1467
+ (one batch per concurrent step); see `VllmConfig.max_in_flight_tokens`
1468
max_model_len: The maximum context length the model could serve
1469
Returns:
1470
An instance of the appropriate SingleTypeKVCacheManager subclass
@@ -1489,7 +1485,7 @@ def get_manager_for_kv_cache_spec(
1485
):
1486
kwargs["max_admission_blocks_per_request"] = (
1487
kv_cache_spec.max_admission_blocks_per_request(
1492
- max_num_batched_tokens=max_num_batched_tokens,
1488
+ max_in_flight_tokens=max_in_flight_tokens,
1489
max_model_len=max_model_len,
1490
)
1491
)
vllm/v1/kv_cache_interface.py
+18
-15
@@ -482,25 +482,28 @@ class ChunkedLocalAttentionSpec(AttentionSpec):
482
attention_chunk_size: int
483
484
def max_admission_blocks_per_request(
485
- self, max_num_batched_tokens: int, max_model_len: int
485
+ self, max_in_flight_tokens: int, max_model_len: int
486
) -> int:
487
"""Per-request admission cap, in blocks.
488
489
Single source of truth for both startup pool sizing
490
(`max_memory_usage_bytes`) and the runtime admission gate, so requests
491
admitted by startup can also be admitted at runtime.
492
+
493
+ `max_in_flight_tokens` is the max tokens scheduled but not yet settled
494
+ (one batch per concurrent step); see `VllmConfig.max_in_flight_tokens`.
495
"""
493
- # During chunked prefill, we hold KV for at most one chunk window.
496
+ # During chunked prefill, we hold KV for at most one chunk window plus
497
+ # the in-flight tokens, since frees happen on the processed-token basis.
498
num_tokens = min(
495
- self.attention_chunk_size + max_num_batched_tokens, max_model_len
499
+ self.attention_chunk_size + max_in_flight_tokens, max_model_len
500
)
501
return cdiv(num_tokens, self.block_size)
502
503
def max_memory_usage_bytes(self, vllm_config: VllmConfig) -> int:
500
- max_model_len = vllm_config.model_config.max_model_len
501
- max_num_batched_tokens = vllm_config.scheduler_config.max_num_batched_tokens
504
max_blocks = self.max_admission_blocks_per_request(
503
- max_num_batched_tokens=max_num_batched_tokens, max_model_len=max_model_len
505
+ max_in_flight_tokens=vllm_config.max_in_flight_tokens,
506
+ max_model_len=vllm_config.model_config.max_model_len,
507
)
508
return max_blocks * self.page_size_bytes
509
@@ -544,7 +547,7 @@ class SlidingWindowSpec(AttentionSpec):
547
)
548
549
def max_admission_blocks_per_request(
547
- self, max_num_batched_tokens: int, max_model_len: int
550
+ self, max_in_flight_tokens: int, max_model_len: int
551
) -> int:
552
"""Per-request admission cap, in blocks.
553
@@ -553,13 +556,14 @@ class SlidingWindowSpec(AttentionSpec):
556
real-held blocks plateau at this bound because
557
`SlidingWindowManager.remove_skipped_blocks` runs from `allocate_slots`
558
before each chunk's `get_num_blocks_to_allocate`.
559
+
560
+ `max_in_flight_tokens` is the max tokens scheduled but not yet settled
561
+ (one batch per concurrent step); see `VllmConfig.max_in_flight_tokens`.
562
"""
563
# During chunked prefill, we hold KV for the last `sliding_window-1`
558
- # computed tokens plus the newly scheduled tokens, and never more
559
- # than `max_model_len`.
560
- num_tokens = min(
561
- self.sliding_window - 1 + max_num_batched_tokens, max_model_len
562
- )
564
+ # computed tokens plus the in-flight tokens (frees happen on the
565
+ # processed-token basis); never more than `max_model_len`.
566
+ num_tokens = min(self.sliding_window - 1 + max_in_flight_tokens, max_model_len)
567
# +1 because the sliding window may not start from the beginning of
568
# the block. E.g. block size 4 and num_token 4 needs two blocks
569
# [XXCD][EF] to store the 6-token window [CDEF].
@@ -569,10 +573,9 @@ class SlidingWindowSpec(AttentionSpec):
573
assert vllm_config.parallel_config.decode_context_parallel_size == 1, (
574
"DCP not support sliding window."
575
)
572
- max_model_len = vllm_config.model_config.max_model_len
573
- max_num_batched_tokens = vllm_config.scheduler_config.max_num_batched_tokens
576
max_blocks = self.max_admission_blocks_per_request(
575
- max_num_batched_tokens=max_num_batched_tokens, max_model_len=max_model_len
577
+ max_in_flight_tokens=vllm_config.max_in_flight_tokens,
578
+ max_model_len=vllm_config.model_config.max_model_len,
579
)
580
return max_blocks * self.page_size_bytes
581
vllm/v1/request.py
+5
@@ -141,6 +141,11 @@ class Request:
141
self.num_output_placeholders = 0
142
self.async_tokens_to_discard = 0
143
144
+ # Tokens of steps whose output is not yet processed (async scheduling
145
+ # and PP run ahead of the GPU); `num_computed_tokens` counts them
146
+ # optimistically.
147
+ self.num_in_flight_tokens = 0
148
+
149
# V2+PP+async: Enforces `pp_size` cadence between same-request decode steps
150
# so the worker's broadcast slot ring stays consistent.
151
self.next_decode_eligible_step = 0
vllm/v1/simple_kv_offload/manager.py
+1
-3
@@ -122,9 +122,7 @@ class SimpleCPUOffloadScheduler:
122
self.cpu_coordinator: KVCacheCoordinator = get_kv_cache_coordinator(
123
kv_cache_config=self.cpu_kv_cache_config,
124
max_model_len=vllm_config.model_config.max_model_len,
125
- max_num_batched_tokens=(
126
- vllm_config.scheduler_config.max_num_batched_tokens
127
- ),
125
+ max_in_flight_tokens=vllm_config.max_in_flight_tokens,
126
use_eagle=False,
127
enable_caching=True,
128
enable_kv_cache_events=self.enable_kv_cache_events,