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 14, 05:30 UTC
fix: NIXL connector transfers partial block to pass full multi-modal context (#21074)
Signed-off-by: GuanLuo <gluo@nvidia.com>
GuanLuo committed
Aug 11, 2025 at 09:40 UTC
16fb668b61c8d21d1e86f0fa4aa876beb7647a8d
4 files changed
+130
-41
tests/v1/kv_connector/unit/test_nixl_connector.py
+8
-10
@@ -173,9 +173,9 @@ def test_prompt_less_than_block_size():
173
"""
174
Test that we can handle case where prompt is < block.
175
176
- In this case, the P worker will send empty remote_block_ids.
177
- The D worker should not schedule an async read in this case,
178
- since there is nothing to pull.
176
+ In this case, the P worker will still send remote_block_ids of the
177
+ partial block. The D worker should schedule an async read
178
+ in this case.
179
"""
180
vllm_config = create_vllm_config()
181
scheduler = create_scheduler(vllm_config)
@@ -184,22 +184,20 @@ def test_prompt_less_than_block_size():
184
BLOCK_SIZE = vllm_config.cache_config.block_size
185
NUM_TOKENS = int(BLOCK_SIZE * 0.5)
186
187
- # Request will have 0 remote blocks.
187
+ # Request will have 1 partial remote block.
188
request = create_request(request_id=1,
189
num_tokens=NUM_TOKENS,
190
do_remote_prefill=True,
191
- num_remote_blocks=0)
191
+ num_remote_blocks=1)
192
scheduler.add_request(request)
193
scheduler_output = scheduler.schedule()
194
195
- # This request should not have to read async.
195
+ # This request will read async.
196
kv_connector_metadata = scheduler_output.kv_connector_metadata
197
assert kv_connector_metadata is not None
198
assert isinstance(kv_connector_metadata, NixlConnectorMetadata)
199
- assert len(kv_connector_metadata.reqs_to_recv) == 0
200
-
201
- # This request should be scheduled regularly.
202
- assert len(scheduler_output.scheduled_new_reqs) == 1
199
+ assert len(kv_connector_metadata.reqs_to_recv) == 1
200
+ assert len(scheduler_output.scheduled_new_reqs) == 0
201
202
203
class FakeNixlConnectorWorker(NixlConnectorWorker):
tests/v1/kv_connector/unit/test_remote_decode_lifecycle.py
+14
-9
@@ -121,13 +121,18 @@ def test_short_prompt_lifecycle():
121
model_runner_output = create_model_runner_output(reqs=[request])
122
123
# (1c): update_from_output()
124
- # Since tokens < block_size, there will be no kv xfer.
125
- # So this should be cleaned up immediately.
126
- _ = scheduler.update_from_output(scheduler_output, model_runner_output)
124
+ # Even though tokens < block_size, there will be kv xfer for partial block.
125
+ eco = scheduler.update_from_output(scheduler_output, model_runner_output)
126
+ kv_transfer_params = eco[0].outputs[0].kv_transfer_params
127
+
128
+ assert (len(kv_transfer_params["remote_block_ids"]) == 1)
129
130
# Confirm we do not have any memory leaks after req lifecycle.
129
- # We need one more call to schedule() to clear data for persistent batch.
130
- _ = scheduler.schedule()
131
+ # We need to mark sending finish to clear data for persistent batch.
132
+ scheduler_output = scheduler.schedule()
133
+ model_runner_output = copy.deepcopy(EMPTY_MODEL_RUNNER_OUTPUT)
134
+ model_runner_output.finished_sending = [request.request_id]
135
+ scheduler.update_from_output(scheduler_output, model_runner_output)
136
assert_scheduler_empty(scheduler)
137
138
@@ -169,16 +174,16 @@ def test_prefix_cache_lifecycle():
174
eco = scheduler.update_from_output(scheduler_output, model_runner_output)
175
kv_transfer_params = eco[0].outputs[0].kv_transfer_params
176
172
- # Ensure we send all block ids, even if there is a cache hit.
177
+ # Ensure we send all block ids, including the partial blocks,
178
+ # even if there is a cache hit.
179
assert (len(
174
- kv_transfer_params["remote_block_ids"]) == NUM_EXTERNAL_FULL_BLOCKS)
180
+ kv_transfer_params["remote_block_ids"]) == (NUM_EXTERNAL_FULL_BLOCKS +
181
+ 1))
182
183
# STEP (2): Ensure it is freed.
184
scheduler_output = scheduler.schedule()
178
- scheduler.schedule()
185
model_runner_output = copy.deepcopy(EMPTY_MODEL_RUNNER_OUTPUT)
186
model_runner_output.kv_connector_output = KVConnectorOutput(
187
finished_sending=[request_remote.request_id])
188
scheduler.update_from_output(scheduler_output, model_runner_output)
183
- _ = scheduler.schedule()
189
assert_scheduler_empty(scheduler)
tests/v1/kv_connector/unit/test_remote_prefill_lifecycle.py
+99
-5
@@ -362,7 +362,7 @@ def test_cannot_schedule_after_recv():
362
BLOCK_SIZE = vllm_config.cache_config.block_size
363
# Prompt will use 2 blocks + 1 block after we schedule.
364
NUM_TOKENS_LOCAL = int(BLOCK_SIZE * NUM_PROMPT_BLOCKS)
365
- NUM_TOKENS_REMOTE = int(BLOCK_SIZE * (NUM_PROMPT_BLOCKS + 0.5))
365
+ NUM_TOKENS_REMOTE = int(BLOCK_SIZE * NUM_PROMPT_BLOCKS)
366
367
request_normal = create_request(request_id=1, num_tokens=NUM_TOKENS_LOCAL)
368
request_remote = create_request(request_id=2,
@@ -393,14 +393,24 @@ def test_cannot_schedule_after_recv():
393
assert len(scheduler.running) == 1
394
assert len(scheduler.waiting) == 1
395
396
- # Step 4: try to schedule, not enough blocks.
396
+ # Step 4: try to schedule, remote request is put to running list
397
+ # because the transfer is completed.
398
+ scheduler_output = scheduler.schedule()
399
+ model_runner_output = create_model_runner_output(
400
+ reqs=[request_normal, request_remote])
401
+ scheduler.update_from_output(scheduler_output, model_runner_output)
402
+ assert len(scheduler.running) == 2
403
+ assert len(scheduler.waiting) == 0
404
+
405
+ # Step 5: Remote request will be put back to waiting list
406
+ # because it needs new block to hold generated token.
407
scheduler_output = scheduler.schedule()
408
model_runner_output = create_model_runner_output(reqs=[request_normal])
409
scheduler.update_from_output(scheduler_output, model_runner_output)
410
assert len(scheduler.running) == 1
411
assert len(scheduler.waiting) == 1
412
403
- # Step 5: finish the request, free it.
413
+ # Step 6: finish the request, free it.
414
scheduler_output = scheduler.schedule()
415
model_runner_output = create_model_runner_output(reqs=[request_normal],
416
use_eos=True)
@@ -408,15 +418,99 @@ def test_cannot_schedule_after_recv():
418
assert len(scheduler.running) == 0
419
assert len(scheduler.waiting) == 1
420
411
- # Step 6: now we can schedule (with 2 blocks computed).
421
+ # Step 7: now we can schedule (with 2 blocks computed),
422
+ # request is retrieved from preempted list.
423
scheduler_output = scheduler.schedule()
424
model_runner_output = create_model_runner_output(reqs=[request_remote])
414
- assert (scheduler_output.scheduled_new_reqs[0].num_computed_tokens ==
425
+ assert (scheduler_output.scheduled_cached_reqs.num_computed_tokens[0] ==
426
NUM_PROMPT_BLOCKS * BLOCK_SIZE)
427
scheduler.update_from_output(scheduler_output, model_runner_output)
428
assert len(scheduler.running) == 1
429
assert len(scheduler.waiting) == 0
430
431
+ # Step 8: free everything.
432
+ scheduler_output = scheduler.schedule()
433
+ model_runner_output = create_model_runner_output(reqs=[request_remote],
434
+ use_eos=True)
435
+ scheduler.update_from_output(scheduler_output, model_runner_output)
436
+ _ = scheduler.schedule()
437
+ assert_scheduler_empty(scheduler)
438
+
439
+
440
+def test_cannot_recv():
441
+ """
442
+ Test that we can handle no schedule KV block transfer due to not
443
+ enough remaining KV blocks.
444
+ """
445
+
446
+ # NOTE: the KVCacheManager will use 1 null block.
447
+ # So there are 5 total working blocks.
448
+ TOTAL_NUM_BLOCKS = 6
449
+ vllm_config = create_vllm_config()
450
+ scheduler = create_scheduler(vllm_config, num_blocks=TOTAL_NUM_BLOCKS)
451
+
452
+ # Prime the KVCache.
453
+ NUM_PROMPT_BLOCKS = 2
454
+ BLOCK_SIZE = vllm_config.cache_config.block_size
455
+ # Prompt will use 2 blocks + 1 block after we schedule.
456
+ NUM_TOKENS_LOCAL = int(BLOCK_SIZE * NUM_PROMPT_BLOCKS)
457
+ NUM_TOKENS_REMOTE = int(BLOCK_SIZE * (NUM_PROMPT_BLOCKS + 0.5))
458
+
459
+ request_normal = create_request(request_id=1, num_tokens=NUM_TOKENS_LOCAL)
460
+ request_remote = create_request(request_id=2,
461
+ num_tokens=NUM_TOKENS_REMOTE,
462
+ do_remote_prefill=True)
463
+
464
+ # STEP 1: 3 blocks are in use (2 for prompt, 1 for decode).
465
+ scheduler.add_request(request_normal)
466
+ scheduler_output = scheduler.schedule()
467
+ model_runner_output = create_model_runner_output(reqs=[request_normal])
468
+ scheduler.update_from_output(scheduler_output, model_runner_output)
469
+ assert len(scheduler.running) == 1
470
+ assert len(scheduler.waiting) == 0
471
+
472
+ # Step 2: 3 blocks are in use,
473
+ # need 3 new for remote blocks but only 2 are available.
474
+ scheduler.add_request(request_remote)
475
+ scheduler_output = scheduler.schedule()
476
+ model_runner_output = create_model_runner_output(reqs=[request_normal])
477
+ scheduler.update_from_output(scheduler_output, model_runner_output)
478
+ assert len(scheduler.running) == 1
479
+ assert len(scheduler.waiting) == 1
480
+ # Should not have KV transfer in progress.
481
+ assert (request_remote.status != RequestStatus.WAITING_FOR_REMOTE_KVS)
482
+
483
+ # Step 3: finish the request, free it.
484
+ scheduler_output = scheduler.schedule()
485
+ model_runner_output = create_model_runner_output(reqs=[request_normal],
486
+ use_eos=True)
487
+ scheduler.update_from_output(scheduler_output, model_runner_output)
488
+ assert len(scheduler.running) == 0
489
+ assert len(scheduler.waiting) == 1
490
+
491
+ # Step 4: now we can initiate KV transfer (with 2 blocks computed).
492
+ scheduler_output = scheduler.schedule()
493
+ model_runner_output = create_model_runner_output(reqs=[])
494
+ scheduler.update_from_output(scheduler_output, model_runner_output)
495
+ assert len(scheduler.running) == 0
496
+ assert len(scheduler.waiting) == 1
497
+ assert (request_remote.status == RequestStatus.WAITING_FOR_REMOTE_KVS)
498
+
499
+ # Step 5: finish recving (5 blocks in use)
500
+ scheduler_output = scheduler.schedule()
501
+ model_runner_output = create_model_runner_output(
502
+ reqs=[], finished_recving=[request_remote.request_id])
503
+ scheduler.update_from_output(scheduler_output, model_runner_output)
504
+ assert len(scheduler.running) == 0
505
+ assert len(scheduler.waiting) == 1
506
+
507
+ # Step 6: schedule remote request
508
+ scheduler_output = scheduler.schedule()
509
+ model_runner_output = create_model_runner_output(reqs=[request_remote])
510
+ scheduler.update_from_output(scheduler_output, model_runner_output)
511
+ assert len(scheduler.running) == 1
512
+ assert len(scheduler.waiting) == 0
513
+
514
# Step 7: free everything.
515
scheduler_output = scheduler.schedule()
516
model_runner_output = create_model_runner_output(reqs=[request_remote],
vllm/distributed/kv_transfer/kv_connector/v1/nixl_connector.py
+9
-17
@@ -29,7 +29,7 @@ from vllm.distributed.utils import divide
29
from vllm.forward_context import ForwardContext
30
from vllm.logger import init_logger
31
from vllm.platforms import _Backend, current_platform
32
-from vllm.utils import make_zmq_path, make_zmq_socket, round_down
32
+from vllm.utils import make_zmq_path, make_zmq_socket
33
from vllm.v1.core.sched.output import SchedulerOutput
34
from vllm.v1.request import RequestStatus
35
@@ -275,10 +275,7 @@ class NixlConnectorScheduler:
275
276
if params is not None and params.get("do_remote_prefill"):
277
# Remote prefill: get all prompt blocks from remote.
278
- assert num_computed_tokens % self.block_size == 0
279
- rounded_num_prompt_tokens = round_down(
280
- len(request.prompt_token_ids), self.block_size)
281
- count = max(rounded_num_prompt_tokens - num_computed_tokens, 0)
278
+ count = len(request.prompt_token_ids) - num_computed_tokens
279
if count > 0:
280
return count, True
281
@@ -301,18 +298,16 @@ class NixlConnectorScheduler:
298
# NOTE: when accelerator is not directly supported by Nixl,
299
# prefilled blocks need to be saved to host memory before transfer.
300
304
- # figure out full computed blocks to save
301
+ # save all blocks
302
block_ids = blocks.get_block_ids()[0]
306
- all_full = request.num_tokens % self.block_size == 0
307
- full_block_ids = (block_ids if all_full else block_ids[:-1])
303
# TODO: skip the blocks that are already in the host xfer buffer.
304
# Currently, the host xfer buffer block is 1-to-1 mapped to device
305
# kv blocks, so host blocks won't be flushed as long as its device
306
# block is not overwritten; and it will be safe to skip saving them
307
# to host xfer buffer.
313
- if full_block_ids:
308
+ if block_ids:
309
self._reqs_need_save[request.request_id] = \
315
- (request, full_block_ids)
310
+ (request, block_ids)
311
elif params.get("do_remote_prefill"):
312
if params.get("remote_block_ids"):
313
if all(p in params for p in ("remote_engine_id", "remote_host",
@@ -401,12 +396,9 @@ class NixlConnectorScheduler:
396
or request.status != RequestStatus.FINISHED_LENGTH_CAPPED):
397
return False, None
398
404
- # Get computed blocks.
405
- all_full = request.num_computed_tokens % self.block_size == 0
406
- computed_block_ids = block_ids if all_full else block_ids[:-1]
407
-
408
- # If prompt < block_size, no xfer so free blocks immediately.
409
- delay_free_blocks = len(computed_block_ids) > 0
399
+ # TODO: check whether block_ids actually ever be 0. If not we could
400
+ # remove the conditional below
401
+ delay_free_blocks = len(block_ids) > 0
402
403
if delay_free_blocks:
404
# Prefill request on remote. It will be read from D upon completion
@@ -416,7 +408,7 @@ class NixlConnectorScheduler:
408
return delay_free_blocks, dict(
409
do_remote_prefill=True,
410
do_remote_decode=False,
419
- remote_block_ids=computed_block_ids,
411
+ remote_block_ids=block_ids,
412
remote_engine_id=self.engine_id,
413
remote_host=self.side_channel_host,
414
remote_port=self.side_channel_port,