main
py 889 lines 32.1 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import copy
4 from unittest.mock import patch
5
6 import pytest
7
8 from vllm.v1.metrics.stats import KV_FETCH_STAGES
9 from vllm.v1.outputs import (
10 EMPTY_MODEL_RUNNER_OUTPUT,
11 KVConnectorOutput,
12 ModelRunnerOutput,
13 )
14 from vllm.v1.request import FinishReason, RequestStatus
15
16 from .utils import (
17 assert_scheduler_empty,
18 create_model_runner_output,
19 create_request,
20 create_scheduler,
21 create_vllm_config,
22 make_kv_cache_config,
23 )
24
25 pytestmark = pytest.mark.cpu_test
26
27
28 def _num_waiting_requests(scheduler) -> int:
29 return len(scheduler.waiting) + len(scheduler.kv_holding_waiting)
30
31
32 def _kv_fetch_stages(scheduler) -> tuple[int, ...]:
33 """(waiting_to_start, in_progress, completed_waiting) request counts."""
34 return tuple(scheduler._kv_fetch_counts[s] for s in KV_FETCH_STAGES)
35
36
37 def test_basic_lifecycle():
38 """Test lifecycle of a remote prefill."""
39 vllm_config = create_vllm_config()
40 scheduler = create_scheduler(vllm_config)
41
42 # 2 Full Blocks and 1 Half Block.
43 BLOCK_SIZE = vllm_config.cache_config.block_size
44 NUM_EXTERNAL_FULL_BLOCKS = 2
45 NUM_TOKENS = int(BLOCK_SIZE * (NUM_EXTERNAL_FULL_BLOCKS + 0.5))
46 START_FREE_BLOCK_QUEUE_SIZE = (
47 scheduler.kv_cache_manager.block_pool.free_block_queue.num_free_blocks
48 )
49
50 request = create_request(
51 request_id=1,
52 block_size=BLOCK_SIZE,
53 num_tokens=NUM_TOKENS,
54 do_remote_prefill=True,
55 )
56
57 scheduler.add_request(request)
58 request_id = request.request_id
59 assert _kv_fetch_stages(scheduler) == (1, 0, 0)
60
61 # STEP (1):
62 # (1a): schedule()
63 scheduler_output = scheduler.schedule()
64 assert _kv_fetch_stages(scheduler) == (0, 1, 0)
65
66 # Nothing running and empty scheduler output.
67 assert len(scheduler.running) == 0
68 assert len(scheduler_output.scheduled_new_reqs) == 0
69 assert scheduler_output.scheduled_cached_reqs.num_reqs == 0
70 assert len(scheduler_output.num_scheduled_tokens) == 0
71 assert scheduler_output.total_num_scheduled_tokens == 0
72
73 # Req waiting for KVs with no computed/scheduled toks ...
74 assert _num_waiting_requests(scheduler) == 1
75 assert request in scheduler.kv_holding_waiting
76 assert request.status == RequestStatus.WAITING_FOR_REMOTE_KVS
77 assert request.num_computed_tokens == NUM_TOKENS
78
79 # ... but should have (uncached) blocks allocated to it.
80 block_pool = scheduler.kv_cache_manager.block_pool
81 assert block_pool.free_block_queue.num_free_blocks < START_FREE_BLOCK_QUEUE_SIZE
82 assert len(block_pool.cached_block_hash_to_block) == 0
83 blocks = scheduler.kv_cache_manager.coordinator.single_type_managers[
84 0
85 ].req_to_blocks[request_id]
86 for block in blocks:
87 assert block._block_hash is None
88
89 # (1b): forward()
90 model_runner_output = EMPTY_MODEL_RUNNER_OUTPUT
91
92 # (1c): update_from_output()
93 engine_core_outputs = scheduler.update_from_output(
94 scheduler_output, model_runner_output
95 )
96 assert not engine_core_outputs or not engine_core_outputs[0].outputs
97
98 # STEP (2):
99 # (2a): schedule(): nothing happens!
100 scheduler_output = scheduler.schedule()
101 assert _num_waiting_requests(scheduler) == 1
102 assert len(scheduler.running) == 0
103
104 # (2b): forward(): request finishes recv.
105 model_runner_output = copy.deepcopy(EMPTY_MODEL_RUNNER_OUTPUT)
106 model_runner_output.kv_connector_output = KVConnectorOutput(
107 finished_recving={request_id}
108 )
109
110 # (2c): update_from_output():
111 engine_core_outputs = scheduler.update_from_output(
112 scheduler_output, model_runner_output
113 )
114 assert _num_waiting_requests(scheduler) == 1
115 assert request_id in scheduler.finished_recving_kv_req_ids
116 assert _kv_fetch_stages(scheduler) == (0, 0, 1)
117
118 # STEP (3):
119 # (3a): schedule(): this should actually schedule.
120 scheduler_output = scheduler.schedule()
121 assert len(scheduler.running) == 1
122 assert _kv_fetch_stages(scheduler) == (0, 0, 0)
123
124 # Confirm the block are actually allocated.
125 num_hashed_blocks = 0
126 blocks = scheduler.kv_cache_manager.coordinator.single_type_managers[
127 0
128 ].req_to_blocks[request_id]
129 for block in blocks:
130 assert block.ref_cnt == 1
131 num_hashed_blocks += 1 if block._block_hash is not None else 0
132 assert num_hashed_blocks == NUM_EXTERNAL_FULL_BLOCKS
133
134 # Confirm the rest of the prompt is scheduled in this step.
135 scheduled_req = scheduler_output.scheduled_new_reqs[0]
136 num_scheduled_tokens = scheduler_output.num_scheduled_tokens[request_id]
137 num_computed_tokens = scheduled_req.num_computed_tokens
138 total_prompt_tokens = len(scheduled_req.prompt_token_ids)
139 assert num_scheduled_tokens == total_prompt_tokens - num_computed_tokens
140
141 # (3b): execute_model()
142 model_runner_output = create_model_runner_output([request])
143 # (3c): update_from_output()
144 scheduler.update_from_output(scheduler_output, model_runner_output)
145
146 # Step (4): Hit EOS.
147 scheduler_output = scheduler.schedule()
148 model_runner_output = create_model_runner_output([request], use_eos=True)
149 engine_core_outputs = scheduler.update_from_output(
150 scheduler_output, model_runner_output
151 )
152 scheduler.schedule()
153
154 outputs = engine_core_outputs[0].outputs
155 assert len(outputs) == 1
156 output = outputs[0]
157 assert output.finish_reason == FinishReason.STOP
158 assert_scheduler_empty(scheduler)
159
160
161 def test_interleaved_lifecycle():
162 """Test Remote Prefills Work Well With Other Requests."""
163 vllm_config = create_vllm_config()
164 scheduler = create_scheduler(vllm_config)
165
166 # 2 Full Blocks and 1 Half Block.
167 BLOCK_SIZE = vllm_config.cache_config.block_size
168 NUM_EXTERNAL_FULL_BLOCKS = 2
169 NUM_TOKENS = int(BLOCK_SIZE * (NUM_EXTERNAL_FULL_BLOCKS + 0.5))
170
171 request_remote = create_request(
172 request_id=1,
173 block_size=BLOCK_SIZE,
174 num_tokens=NUM_TOKENS,
175 do_remote_prefill=True,
176 )
177 request_local_a = create_request(
178 request_id=2,
179 block_size=BLOCK_SIZE,
180 num_tokens=NUM_TOKENS,
181 )
182 request_local_b = create_request(
183 request_id=3,
184 block_size=BLOCK_SIZE,
185 num_tokens=NUM_TOKENS,
186 )
187
188 # STEP 1: Regular request is running.
189 scheduler.add_request(request_local_a)
190 scheduler_output = scheduler.schedule()
191 assert len(scheduler.running) == 1
192
193 model_runner_output = create_model_runner_output([request_local_a])
194 scheduler.update_from_output(scheduler_output, model_runner_output)
195
196 # STEP 2: Add a local and remote request.
197 scheduler.add_request(request_local_b)
198 scheduler.add_request(request_remote)
199 scheduler_output = scheduler.schedule()
200 assert len(scheduler.running) == 2
201 assert _num_waiting_requests(scheduler) == 1
202 assert len(scheduler_output.scheduled_new_reqs) == 1
203 assert scheduler_output.scheduled_cached_reqs.num_reqs == 1
204
205 model_runner_output = create_model_runner_output([request_local_a, request_local_b])
206 scheduler.update_from_output(scheduler_output, model_runner_output)
207
208 # STEP 3: continue running, KVs not arrived yet.
209 scheduler_output = scheduler.schedule()
210 assert len(scheduler.running) == 2
211 assert _num_waiting_requests(scheduler) == 1
212 assert len(scheduler_output.scheduled_new_reqs) == 0
213 assert scheduler_output.scheduled_cached_reqs.num_reqs == 2
214
215 model_runner_output = create_model_runner_output(
216 reqs=[request_local_a, request_local_b]
217 )
218 scheduler.update_from_output(scheduler_output, model_runner_output)
219 assert len(scheduler.running) == 2
220 assert _num_waiting_requests(scheduler) == 1
221 assert len(scheduler_output.scheduled_new_reqs) == 0
222 assert scheduler_output.scheduled_cached_reqs.num_reqs == 2
223
224 # STEP 4: KVs arrive.
225 scheduler_output = scheduler.schedule()
226 assert len(scheduler.running) == 2
227 assert _num_waiting_requests(scheduler) == 1
228 assert len(scheduler_output.scheduled_new_reqs) == 0
229 assert scheduler_output.scheduled_cached_reqs.num_reqs == 2
230
231 model_runner_output = create_model_runner_output(
232 [request_local_a, request_local_b], finished_recving={request_remote.request_id}
233 )
234 scheduler.update_from_output(scheduler_output, model_runner_output)
235
236 # STEP 5: RECVed KVs are sent to ModelRunner.
237 scheduler_output = scheduler.schedule()
238 assert len(scheduler.running) == 3
239 assert _num_waiting_requests(scheduler) == 0
240 assert len(scheduler_output.scheduled_new_reqs) == 1
241 assert scheduler_output.scheduled_cached_reqs.num_reqs == 2
242
243 model_runner_output = create_model_runner_output(
244 [request_local_a, request_local_b, request_remote]
245 )
246 scheduler.update_from_output(scheduler_output, model_runner_output)
247
248 # STEP 6: Hit EOS and free.
249 scheduler_output = scheduler.schedule()
250 model_runner_output = create_model_runner_output(
251 [request_local_a, request_local_b, request_remote],
252 use_eos=True,
253 )
254 scheduler.update_from_output(scheduler_output, model_runner_output)
255 scheduler.schedule()
256 assert_scheduler_empty(scheduler)
257
258
259 def test_no_spurious_prefix_caching():
260 """With P/D, blocks can be allocated but uncomputed for
261 multiple engine steps. This test confirms that we do
262 not accidentally have cache hits against uncomputed
263 blocks.
264 """
265 vllm_config = create_vllm_config()
266 scheduler = create_scheduler(vllm_config)
267
268 vllm_config = create_vllm_config()
269 scheduler = create_scheduler(vllm_config)
270
271 # 2 and a half full external blocks.
272 BLOCK_SIZE = vllm_config.cache_config.block_size
273 NUM_EXTERNAL_FULL_BLOCKS = 2
274 NUM_TOKENS = int(BLOCK_SIZE * (NUM_EXTERNAL_FULL_BLOCKS + 0.5))
275
276 # Both of these requests have prompts like [1,1,1,1,1, ...]
277 request_remote = create_request(
278 request_id=1,
279 block_size=BLOCK_SIZE,
280 num_tokens=NUM_TOKENS,
281 common_prefix_len=NUM_TOKENS,
282 do_remote_prefill=True,
283 )
284
285 request_local = create_request(
286 request_id=2,
287 block_size=BLOCK_SIZE,
288 num_tokens=NUM_TOKENS,
289 common_prefix_len=NUM_TOKENS,
290 do_remote_prefill=False,
291 )
292
293 # Schedule the remote prefill request. This should not
294 # cause any blocks to be cached.
295 scheduler.add_request(request_remote)
296 scheduler_output = scheduler.schedule()
297 scheduler.update_from_output(scheduler_output, EMPTY_MODEL_RUNNER_OUTPUT)
298 assert _num_waiting_requests(scheduler) == 1
299
300 # Schedule the local prefill request. This should
301 # cause blocks to be cached, but separately from
302 scheduler.add_request(request_local)
303 scheduler_output = scheduler.schedule()
304 assert len(scheduler.running) == 1
305 assert _num_waiting_requests(scheduler) == 1
306
307 local_blocks = scheduler.kv_cache_manager.coordinator.single_type_managers[
308 0
309 ].req_to_blocks[request_local.request_id]
310 remote_blocks = scheduler.kv_cache_manager.coordinator.single_type_managers[
311 0
312 ].req_to_blocks[request_remote.request_id]
313
314 # Local should have cached blocks (but not all due to preallocate).
315 num_hashed_blocks = 0
316 for block in local_blocks:
317 assert block.ref_cnt == 1
318 num_hashed_blocks += 1 if block._block_hash is not None else 0
319 assert num_hashed_blocks > 0
320
321 # Remote blocks should not be cached.
322 for block in remote_blocks:
323 assert block.ref_cnt == 1
324 assert block._block_hash is None
325
326
327 def test_full_block_prompt():
328 """Test that we handle a prompt that is the full block size."""
329 vllm_config = create_vllm_config()
330 scheduler = create_scheduler(vllm_config)
331
332 # 2 Full Blocks and 1 Half Block.
333 BLOCK_SIZE = vllm_config.cache_config.block_size
334 NUM_EXTERNAL_FULL_BLOCKS = 2
335 NUM_TOKENS = int(BLOCK_SIZE * NUM_EXTERNAL_FULL_BLOCKS)
336
337 request = create_request(
338 request_id=1,
339 block_size=BLOCK_SIZE,
340 num_tokens=NUM_TOKENS,
341 do_remote_prefill=True,
342 )
343
344 scheduler.add_request(request)
345 request_id = request.request_id
346
347 # STEP (1): Initialize a recv.
348 scheduler_output = scheduler.schedule()
349 # All blocks should be allocated.
350 num_blocks = len(
351 scheduler.kv_cache_manager.coordinator.single_type_managers[0].req_to_blocks[
352 request_id
353 ]
354 )
355 assert num_blocks == NUM_EXTERNAL_FULL_BLOCKS
356 model_runner_output = EMPTY_MODEL_RUNNER_OUTPUT
357 scheduler.update_from_output(scheduler_output, model_runner_output)
358
359 # # STEP (2): Recv.
360 scheduler_output = scheduler.schedule()
361 model_runner_output = copy.deepcopy(EMPTY_MODEL_RUNNER_OUTPUT)
362 model_runner_output.kv_connector_output = KVConnectorOutput(
363 finished_recving={request_id}
364 )
365 scheduler.update_from_output(scheduler_output, model_runner_output)
366 assert _num_waiting_requests(scheduler) == 1
367 assert request_id in scheduler.finished_recving_kv_req_ids
368
369 # # STEP (3): Run as usual.
370 scheduler_output = scheduler.schedule()
371
372 # We need to recompute the final token of the prompt to generate
373 # the first new token, so we should not have a new block.
374 num_blocks = len(
375 scheduler.kv_cache_manager.coordinator.single_type_managers[0].req_to_blocks[
376 request_id
377 ]
378 )
379 assert num_blocks == NUM_EXTERNAL_FULL_BLOCKS
380 assert scheduler_output.scheduled_new_reqs[0].num_computed_tokens == NUM_TOKENS - 1
381 assert scheduler_output.num_scheduled_tokens[request_id] == 1
382
383 model_runner_output = create_model_runner_output([request])
384 scheduler.update_from_output(scheduler_output, model_runner_output)
385
386 # # Step (4): Hit EOS.
387 scheduler_output = scheduler.schedule()
388 model_runner_output = create_model_runner_output([request], use_eos=True)
389 engine_core_outputs = scheduler.update_from_output(
390 scheduler_output, model_runner_output
391 )
392 scheduler.schedule()
393
394 outputs = engine_core_outputs[0].outputs
395 assert len(outputs) == 1
396 output = outputs[0]
397 assert output.finish_reason == FinishReason.STOP
398 assert_scheduler_empty(scheduler)
399
400
401 def test_cannot_schedule_after_recv():
402 """Test that we can handle no schedule after recv due to not
403 enough remaining KV blocks.
404 """
405 # NOTE: the KVCacheManager will use 1 null block.
406 # So there are 5 total working blocks.
407 TOTAL_NUM_BLOCKS = 6
408 vllm_config = create_vllm_config()
409 scheduler = create_scheduler(vllm_config, num_blocks=TOTAL_NUM_BLOCKS)
410
411 # Prime the KVCache.
412 NUM_PROMPT_BLOCKS = 2
413 BLOCK_SIZE = vllm_config.cache_config.block_size
414 # Prompt will use 2 blocks + 1 block after we schedule.
415 NUM_TOKENS_LOCAL = int(BLOCK_SIZE * NUM_PROMPT_BLOCKS)
416 NUM_TOKENS_REMOTE = int(BLOCK_SIZE * NUM_PROMPT_BLOCKS)
417
418 request_normal = create_request(
419 request_id=1, block_size=BLOCK_SIZE, num_tokens=NUM_TOKENS_LOCAL
420 )
421 request_remote = create_request(
422 request_id=2,
423 block_size=BLOCK_SIZE,
424 num_tokens=NUM_TOKENS_REMOTE,
425 do_remote_prefill=True,
426 )
427
428 # STEP 1: 3 blocks are in use (2 for prompt, 1 for decode).
429 scheduler.add_request(request_normal)
430 scheduler_output = scheduler.schedule()
431 model_runner_output = create_model_runner_output(reqs=[request_normal])
432 scheduler.update_from_output(scheduler_output, model_runner_output)
433 assert len(scheduler.running) == 1
434 assert _num_waiting_requests(scheduler) == 0
435
436 # Step 2: 5 blocks are in use (2 new for remote blocks).
437 scheduler.add_request(request_remote)
438 scheduler_output = scheduler.schedule()
439 model_runner_output = create_model_runner_output(reqs=[request_normal])
440 scheduler.update_from_output(scheduler_output, model_runner_output)
441 assert len(scheduler.running) == 1
442 assert _num_waiting_requests(scheduler) == 1
443
444 # Step 3: finish recving (5 blocks in use)
445 scheduler_output = scheduler.schedule()
446 model_runner_output = create_model_runner_output(
447 reqs=[request_normal], finished_recving={request_remote.request_id}
448 )
449 scheduler.update_from_output(scheduler_output, model_runner_output)
450 assert len(scheduler.running) == 1
451 assert _num_waiting_requests(scheduler) == 1
452
453 # Step 4: try to schedule, remote request is put to running list
454 # because the transfer is completed.
455 scheduler_output = scheduler.schedule()
456 model_runner_output = create_model_runner_output(
457 reqs=[request_normal, request_remote]
458 )
459 scheduler.update_from_output(scheduler_output, model_runner_output)
460 assert len(scheduler.running) == 2
461 assert _num_waiting_requests(scheduler) == 0
462
463 # Step 5: Remote request will be put back to waiting list
464 # because it needs new block to hold generated token.
465 scheduler_output = scheduler.schedule()
466 model_runner_output = create_model_runner_output(reqs=[request_normal])
467 scheduler.update_from_output(scheduler_output, model_runner_output)
468 assert len(scheduler.running) == 1
469 assert _num_waiting_requests(scheduler) == 1
470
471 # Step 6: finish the request, free it.
472 scheduler_output = scheduler.schedule()
473 model_runner_output = create_model_runner_output(
474 reqs=[request_normal], use_eos=True
475 )
476 scheduler.update_from_output(scheduler_output, model_runner_output)
477 assert len(scheduler.running) == 0
478 assert _num_waiting_requests(scheduler) == 1
479
480 # Step 7: now we can schedule (with 2 blocks computed),
481 # request is retrieved from preempted list.
482 scheduler_output = scheduler.schedule()
483 model_runner_output = create_model_runner_output(reqs=[request_remote])
484 # V2 emits a resumed (previously preempted) request as a NewRequestData
485 # rather than a cached request.
486 if scheduler.use_v2_model_runner:
487 num_computed = scheduler_output.scheduled_new_reqs[0].num_computed_tokens
488 else:
489 cached = scheduler_output.scheduled_cached_reqs
490 num_computed = cached.num_computed_tokens[0]
491 assert num_computed == NUM_PROMPT_BLOCKS * BLOCK_SIZE
492 scheduler.update_from_output(scheduler_output, model_runner_output)
493 assert len(scheduler.running) == 1
494 assert _num_waiting_requests(scheduler) == 0
495
496 # Step 8: free everything.
497 scheduler_output = scheduler.schedule()
498 model_runner_output = create_model_runner_output(
499 reqs=[request_remote], use_eos=True
500 )
501 scheduler.update_from_output(scheduler_output, model_runner_output)
502 _ = scheduler.schedule()
503 assert_scheduler_empty(scheduler)
504
505
506 def test_cannot_recv():
507 """Test that we can handle no schedule KV block transfer due to not
508 enough remaining KV blocks.
509 """
510 # NOTE: the KVCacheManager will use 1 null block.
511 # So there are 5 total working blocks.
512 TOTAL_NUM_BLOCKS = 6
513 vllm_config = create_vllm_config()
514 scheduler = create_scheduler(vllm_config, num_blocks=TOTAL_NUM_BLOCKS)
515
516 # Prime the KVCache.
517 NUM_PROMPT_BLOCKS = 2
518 BLOCK_SIZE = vllm_config.cache_config.block_size
519 # Prompt will use 2 blocks + 1 block after we schedule.
520 NUM_TOKENS_LOCAL = int(BLOCK_SIZE * NUM_PROMPT_BLOCKS)
521 NUM_TOKENS_REMOTE = int(BLOCK_SIZE * (NUM_PROMPT_BLOCKS + 0.5))
522
523 request_normal = create_request(
524 request_id=1, block_size=BLOCK_SIZE, num_tokens=NUM_TOKENS_LOCAL
525 )
526 request_remote = create_request(
527 request_id=2,
528 block_size=BLOCK_SIZE,
529 num_tokens=NUM_TOKENS_REMOTE,
530 do_remote_prefill=True,
531 )
532
533 # STEP 1: 3 blocks are in use (2 for prompt, 1 for decode).
534 scheduler.add_request(request_normal)
535 scheduler_output = scheduler.schedule()
536 model_runner_output = create_model_runner_output(reqs=[request_normal])
537 scheduler.update_from_output(scheduler_output, model_runner_output)
538 assert len(scheduler.running) == 1
539 assert _num_waiting_requests(scheduler) == 0
540
541 # Step 2: 3 blocks are in use,
542 # need 3 new for remote blocks but only 2 are available.
543 scheduler.add_request(request_remote)
544 scheduler_output = scheduler.schedule()
545 model_runner_output = create_model_runner_output(reqs=[request_normal])
546 scheduler.update_from_output(scheduler_output, model_runner_output)
547 assert len(scheduler.running) == 1
548 assert _num_waiting_requests(scheduler) == 1
549 # Should not have KV transfer in progress.
550 assert request_remote.status != RequestStatus.WAITING_FOR_REMOTE_KVS
551
552 # Step 3: finish the request, free it.
553 scheduler_output = scheduler.schedule()
554 model_runner_output = create_model_runner_output(
555 reqs=[request_normal], use_eos=True
556 )
557 scheduler.update_from_output(scheduler_output, model_runner_output)
558 assert len(scheduler.running) == 0
559 assert _num_waiting_requests(scheduler) == 1
560
561 # Step 4: now we can initiate KV transfer (with 2 blocks computed).
562 scheduler_output = scheduler.schedule()
563 model_runner_output = create_model_runner_output(reqs=[])
564 scheduler.update_from_output(scheduler_output, model_runner_output)
565 assert len(scheduler.running) == 0
566 assert _num_waiting_requests(scheduler) == 1
567 assert request_remote.status == RequestStatus.WAITING_FOR_REMOTE_KVS
568
569 # Step 5: finish recving (5 blocks in use)
570 scheduler_output = scheduler.schedule()
571 model_runner_output = create_model_runner_output(
572 reqs=[], finished_recving={request_remote.request_id}
573 )
574 scheduler.update_from_output(scheduler_output, model_runner_output)
575 assert len(scheduler.running) == 0
576 assert _num_waiting_requests(scheduler) == 1
577
578 # Step 6: schedule remote request
579 scheduler_output = scheduler.schedule()
580 model_runner_output = create_model_runner_output(reqs=[request_remote])
581 scheduler.update_from_output(scheduler_output, model_runner_output)
582 assert len(scheduler.running) == 1
583 assert _num_waiting_requests(scheduler) == 0
584
585 # Step 7: free everything.
586 scheduler_output = scheduler.schedule()
587 model_runner_output = create_model_runner_output(
588 reqs=[request_remote], use_eos=True
589 )
590 scheduler.update_from_output(scheduler_output, model_runner_output)
591 _ = scheduler.schedule()
592 assert_scheduler_empty(scheduler)
593
594
595 def test_kv_fetch_stages_cleared_on_abort():
596 vllm_config = create_vllm_config()
597 scheduler = create_scheduler(vllm_config)
598 BLOCK_SIZE = vllm_config.cache_config.block_size
599 request = create_request(
600 request_id=1,
601 block_size=BLOCK_SIZE,
602 num_tokens=int(BLOCK_SIZE * 2.5),
603 do_remote_prefill=True,
604 )
605 scheduler.add_request(request)
606 scheduler.schedule()
607 assert _kv_fetch_stages(scheduler) == (0, 1, 0)
608
609 scheduler.finish_requests(request.request_id, RequestStatus.FINISHED_ABORTED)
610 assert _kv_fetch_stages(scheduler) == (0, 0, 0)
611
612
613 @patch(
614 "vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_scheduler.current_platform"
615 )
616 def test_p_side_chunked_prefill_mamba(mock_platform):
617 """P-side integration: Mamba N-1 truncation + chunked prefill completes.
618
619 A 64-token P-side request is truncated to 63 by the N-1 fix, then
620 chunked into two prefill steps (32 + 31) and finishes with
621 LENGTH_CAPPED because max_tokens is set to 1.
622 """
623 mock_platform.device_type = "cpu"
624
625 BATCH_SIZE = 32
626 NUM_TOKENS = 64
627 BLOCK_SIZE = 16
628
629 vllm_config = create_vllm_config(
630 max_num_batched_tokens=BATCH_SIZE,
631 block_size=BLOCK_SIZE,
632 )
633 vllm_config.scheduler_config.disable_hybrid_kv_cache_manager = False
634
635 kv_cache_config = make_kv_cache_config(
636 block_size=BLOCK_SIZE,
637 mamba_enabled=True,
638 num_blocks=10000,
639 )
640
641 scheduler = create_scheduler(vllm_config, kv_cache_config=kv_cache_config)
642
643 request = create_request(
644 num_tokens=NUM_TOKENS,
645 do_remote_decode=True,
646 block_size=BLOCK_SIZE,
647 )
648 request.max_tokens = 128
649 scheduler.add_request(request)
650 request_id = request.request_id
651
652 # ── Step 1: first chunk ──
653 scheduler_output = scheduler.schedule()
654
655 assert len(request.prompt_token_ids) == NUM_TOKENS - 1
656 assert request.max_tokens == 1
657 assert scheduler_output.num_scheduled_tokens[request_id] == BATCH_SIZE
658 assert request.num_computed_tokens == BATCH_SIZE
659
660 # Model returns no tokens for intermediate prefill chunk
661 intermediate_output = ModelRunnerOutput(
662 req_ids=[request.request_id],
663 req_id_to_index={request.request_id: 0},
664 sampled_token_ids=[[]],
665 )
666 scheduler.update_from_output(scheduler_output, intermediate_output)
667
668 # ── Step 2: remaining chunk ──
669 scheduler_output = scheduler.schedule()
670
671 remaining = NUM_TOKENS - 1 - BATCH_SIZE # 31
672 assert scheduler_output.num_scheduled_tokens[request_id] == remaining
673 assert request.num_computed_tokens == NUM_TOKENS - 1
674
675 # Prefill complete: model generates 1 decode token
676 final_output = create_model_runner_output([request])
677 engine_core_outputs = scheduler.update_from_output(scheduler_output, final_output)
678
679 # max_tokens=1 → request finishes with LENGTH
680 outputs = engine_core_outputs[0].outputs
681 assert len(outputs) == 1
682 assert outputs[0].finish_reason == FinishReason.LENGTH
683
684
685 def test_async_load_reserves_blocks_for_inflight():
686 """A second async KV-connector load is not admitted if its initial
687 allocation would consume blocks reserved for an already in-flight sequence.
688
689 req_a gets a 1-block prefix (full sequence = 4 blocks), reserving 3 more.
690 req_b would need a 4-block initial allocation, but only
691 (free - req_a's 3-block reservation) = 3 blocks are available to it, so it is
692 held back in WAITING (holding no blocks) rather than wedging req_a.
693 """
694 vllm_config = create_vllm_config()
695 BLOCK_SIZE = vllm_config.cache_config.block_size
696 scheduler = create_scheduler(vllm_config, num_blocks=8) # usable = 7
697
698 req_a = create_request(
699 request_id=1,
700 block_size=BLOCK_SIZE,
701 num_tokens=BLOCK_SIZE * 4,
702 do_remote_prefill=True,
703 num_remote_blocks=1,
704 )
705 req_b = create_request(
706 request_id=2,
707 block_size=BLOCK_SIZE,
708 num_tokens=BLOCK_SIZE * 5,
709 do_remote_prefill=True,
710 num_remote_blocks=1,
711 )
712 scheduler.add_request(req_a)
713 scheduler.add_request(req_b)
714
715 # Partial external matches: req_a loads 1 block, req_b loads 4 blocks.
716 with patch.object(
717 scheduler.connector,
718 "get_num_new_matched_tokens",
719 side_effect=[(BLOCK_SIZE, True), (BLOCK_SIZE * 4, True)],
720 ):
721 scheduler.schedule()
722
723 assert req_a.status == RequestStatus.WAITING_FOR_REMOTE_KVS
724 assert req_b.status == RequestStatus.WAITING
725
726 req_to_blocks = scheduler.kv_cache_manager.coordinator.single_type_managers[
727 0
728 ].req_to_blocks
729 assert req_a.request_id in req_to_blocks
730 assert req_b.request_id not in req_to_blocks
731
732
733 def test_async_loads_both_admitted_when_pool_fits():
734 """Sanity: with a pool large enough, the reservation gate admits both async
735 loads (it is not over-conservative)."""
736 vllm_config = create_vllm_config()
737 BLOCK_SIZE = vllm_config.cache_config.block_size
738 scheduler = create_scheduler(vllm_config, num_blocks=64)
739
740 reqs = [
741 create_request(
742 request_id=i,
743 block_size=BLOCK_SIZE,
744 num_tokens=BLOCK_SIZE * 5,
745 do_remote_prefill=True,
746 num_remote_blocks=1,
747 )
748 for i in (1, 2)
749 ]
750 for req in reqs:
751 scheduler.add_request(req)
752
753 with patch.object(
754 scheduler.connector,
755 "get_num_new_matched_tokens",
756 side_effect=[(BLOCK_SIZE, True), (BLOCK_SIZE, True)],
757 ):
758 scheduler.schedule()
759
760 for req in reqs:
761 assert req.status == RequestStatus.WAITING_FOR_REMOTE_KVS
762
763
764 def test_async_load_reserves_blocks_for_promotion_margin():
765 """An async load is not admitted unless the blocks its own promotion will
766 need are still free.
767
768 A parked load is allocated without lookahead slots, but promotion pads it
769 to ``1 + num_spec_tokens`` and asks for the lookahead margin on top. Without
770 reserving that margin, two loads can be admitted that together consume the
771 whole pool; the head then fails ``allocate_slots`` forever while the load
772 behind it is never reached, and with nothing running no block is ever freed.
773
774 req_a (4 blocks) and req_b (3 blocks) exactly fill the 7 usable blocks, so
775 req_b must be held back in WAITING holding no blocks, leaving req_a room to
776 be promoted and run.
777 """
778 vllm_config = create_vllm_config(num_speculative_tokens=3)
779 BLOCK_SIZE = vllm_config.cache_config.block_size
780 scheduler = create_scheduler(vllm_config, num_blocks=8) # usable = 7
781
782 req_a = create_request(
783 request_id=1,
784 block_size=BLOCK_SIZE,
785 num_tokens=BLOCK_SIZE * 4,
786 do_remote_prefill=True,
787 max_tokens=1,
788 )
789 req_b = create_request(
790 request_id=2,
791 block_size=BLOCK_SIZE,
792 num_tokens=BLOCK_SIZE * 3,
793 do_remote_prefill=True,
794 max_tokens=1,
795 )
796 scheduler.add_request(req_a)
797 scheduler.add_request(req_b)
798
799 # Both get a full external hit, so each would hold its whole prompt.
800 with patch.object(
801 scheduler.connector,
802 "get_num_new_matched_tokens",
803 side_effect=[(BLOCK_SIZE * 4, True), (BLOCK_SIZE * 3, True)],
804 ):
805 scheduler.schedule()
806
807 assert req_a.status == RequestStatus.WAITING_FOR_REMOTE_KVS
808 assert req_b.status == RequestStatus.WAITING
809 req_to_blocks = scheduler.kv_cache_manager.coordinator.single_type_managers[
810 0
811 ].req_to_blocks
812 assert req_b.request_id not in req_to_blocks
813
814 # req_a's load lands: it must be promotable and actually get scheduled.
815 scheduler.update_from_output(
816 scheduler.schedule(),
817 create_model_runner_output([], finished_recving={req_a.request_id}),
818 )
819 scheduler_output = scheduler.schedule()
820 assert req_a.status == RequestStatus.RUNNING
821 assert scheduler_output.num_scheduled_tokens[req_a.request_id] > 0
822
823
824 def test_deferred_lookup_does_not_block_parked_load():
825 """A request with a deferred connector lookup must not strand an async
826 load queued behind it.
827
828 req_a's lookup is deferred (connector returns None), so it is parked in
829 waiting holding no blocks; req_b is then admitted as an async
830 load holding most of the pool. Once req_a's lookup resolves it cannot
831 allocate, and the waiting scan stops at it every step. The parked load
832 must still be promotable: it lives in the KV-holding queue
833 that is drained first, so it runs, finishes, and frees its blocks for
834 req_a.
835 """
836 vllm_config = create_vllm_config()
837 BLOCK_SIZE = vllm_config.cache_config.block_size
838 scheduler = create_scheduler(vllm_config, num_blocks=8) # usable = 7
839
840 req_a = create_request(
841 request_id=1, block_size=BLOCK_SIZE, num_tokens=BLOCK_SIZE * 4, max_tokens=1
842 )
843 req_b = create_request(
844 request_id=2,
845 block_size=BLOCK_SIZE,
846 num_tokens=BLOCK_SIZE * 5,
847 do_remote_prefill=True,
848 max_tokens=1,
849 )
850 scheduler.add_request(req_a)
851 scheduler.add_request(req_b)
852
853 deferrals = [(None, False)]
854
855 def lookup(request, num_computed_tokens):
856 if request is req_b:
857 return BLOCK_SIZE * 5, True
858 return deferrals.pop() if deferrals else (0, False)
859
860 with patch.object(
861 scheduler.connector, "get_num_new_matched_tokens", side_effect=lookup
862 ):
863 # Step 1: req_a's lookup is deferred; req_b is admitted as an async
864 # load holding 5 of 7 usable blocks.
865 scheduler.schedule()
866 assert req_a.status == RequestStatus.WAITING
867 assert req_b.status == RequestStatus.WAITING_FOR_REMOTE_KVS
868
869 # req_a's lookup resolves (no hit) while req_b's transfer is in
870 # flight; req_a needs 4 blocks but only 2 are free, so the waiting
871 # scan stops at it. req_b's transfer then finishes.
872 scheduler.update_from_output(
873 scheduler.schedule(),
874 create_model_runner_output([], finished_recving={req_b.request_id}),
875 )
876
877 # req_b must still be reached, promoted, and scheduled despite the
878 # scan stopping at req_a.
879 scheduler_output = scheduler.schedule()
880 assert req_b.status == RequestStatus.RUNNING
881 assert scheduler_output.num_scheduled_tokens[req_b.request_id] > 0
882
883 # req_b finishes and frees its blocks; now req_a fits and runs.
884 scheduler.update_from_output(
885 scheduler_output, create_model_runner_output([req_b])
886 )
887 scheduler_output = scheduler.schedule()
888 assert req_a.status == RequestStatus.RUNNING
889 assert scheduler_output.num_scheduled_tokens[req_a.request_id] > 0