A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 4, 00:00 UTC
[Bugfix][KV Transfer][NIXL] Notify P node on pre-admission rejection to free stranded KV blocks (#41269)
Dao007forever committed
May 9, 2026 at 22:52 UTC
3f5bd482f5c1a5dbdffbbf68d624e20bb7032013
11 files changed
+156
-2
tests/v1/kv_connector/unit/test_nixl_connector.py
+28
@@ -321,6 +321,34 @@ def test_prompt_less_than_block_size():
321
assert len(scheduler_output.scheduled_new_reqs) == 0
322
323
324
+def test_abort_immediately_remote_prefill_enqueues_empty_recv():
325
+ """A remote-prefill request added with abort_immediately=True should
326
+ be added to the scheduler's waiting queue then immediately aborted, so the
327
+ NIXL connector's request_finished hook enqueues an empty recv to notify
328
+ the prefill instance to free its blocks."""
329
+ from vllm.v1.request import RequestStatus
330
+
331
+ scheduler = create_scheduler(create_vllm_config())
332
+
333
+ request = create_request(request_id=42, num_tokens=10, do_remote_prefill=True)
334
+ assert request.kv_transfer_params is not None
335
+ assert request.kv_transfer_params["do_remote_prefill"] is True
336
+
337
+ # Mimic the EngineCore.add_request path for an abort-immediately req.
338
+ scheduler.add_request(request)
339
+ scheduler.finish_requests([request.request_id], RequestStatus.FINISHED_ABORTED)
340
+
341
+ scheduler_output = scheduler.schedule()
342
+ meta = scheduler_output.kv_connector_metadata
343
+ assert isinstance(meta, NixlConnectorMetadata)
344
+ assert set(meta.reqs_to_recv) == {request.request_id}
345
+ req_meta = meta.reqs_to_recv[request.request_id]
346
+ assert req_meta.local_block_ids == []
347
+ assert req_meta.remote.request_id == f"prefill-{42}"
348
+ # do_remote_prefill is consumed by request_finished to prevent re-issuing.
349
+ assert request.kv_transfer_params["do_remote_prefill"] is False
350
+
351
+
352
@patch(
353
"vllm.distributed.kv_transfer.kv_connector.v1.nixl.worker.NixlWrapper",
354
FakeNixlWrapper,
vllm/distributed/kv_transfer/kv_connector/v1/nixl/scheduler.py
+3
-1
@@ -525,7 +525,9 @@ class NixlConnectorScheduler:
525
if params.get("do_remote_prefill"):
526
# If do_remote_prefill is still True when the request is finished,
527
# update_state_after_alloc must not have been called (the request
528
- # must have been aborted before it was scheduled).
528
+ # must have been aborted before it was scheduled, e.g. via the
529
+ # abort_immediately path used to clean up KV-transfer requests
530
+ # rejected at the D-side serving layer).
531
# To avoid stranding the prefill blocks in the prefill instance,
532
# we must add empty block_ids to _reqs_need_recv so that our
533
# worker side will notify and free blocks in the prefill instance.
vllm/engine/protocol.py
+14
@@ -108,6 +108,20 @@ class EngineClient(ABC):
108
"""
109
...
110
111
+ @abstractmethod
112
+ async def notify_kv_transfer_request_rejected(
113
+ self,
114
+ request_id: str,
115
+ kv_transfer_params: dict[str, Any],
116
+ *,
117
+ data_parallel_rank: int | None = None,
118
+ ) -> None:
119
+ """Notify the engine that a KV-transfer request was rejected before
120
+ engine admission, so connector-side cleanup can run (e.g. free
121
+ prefill blocks pinned on the P node).
122
+ """
123
+ ...
124
+
125
@abstractmethod
126
async def is_tracing_enabled(self) -> bool: ...
127
vllm/entrypoints/openai/chat_completion/serving.py
+9
@@ -234,6 +234,15 @@ class OpenAIServingChat(OpenAIServing):
234
for the API specification. This API mimics the OpenAI
235
Chat Completion API.
236
"""
237
+ return await self._with_kv_transfer_rejection_cleanup(
238
+ self._create_chat_completion(request, raw_request), request, raw_request
239
+ )
240
+
241
+ async def _create_chat_completion(
242
+ self,
243
+ request: ChatCompletionRequest,
244
+ raw_request: Request | None = None,
245
+ ) -> AsyncGenerator[str, None] | ChatCompletionResponse | ErrorResponse:
246
# Streaming response
247
tokenizer = self.renderer.tokenizer
248
assert tokenizer is not None
vllm/entrypoints/openai/completion/serving.py
+9
@@ -118,6 +118,15 @@ class OpenAIServingCompletion(OpenAIServing):
118
- suffix (the language models we currently support do not support
119
suffix)
120
"""
121
+ return await self._with_kv_transfer_rejection_cleanup(
122
+ self._create_completion(request, raw_request), request, raw_request
123
+ )
124
+
125
+ async def _create_completion(
126
+ self,
127
+ request: CompletionRequest,
128
+ raw_request: Request | None = None,
129
+ ) -> AsyncGenerator[str, None] | CompletionResponse | ErrorResponse:
130
if request.stream and request.use_beam_search:
131
return self.create_error_response(
132
"Streaming is not currently supported with beam search"
vllm/entrypoints/openai/engine/serving.py
+39
-1
@@ -4,7 +4,7 @@ import asyncio
4
import contextlib
5
import json
6
import time
7
-from collections.abc import AsyncGenerator, Mapping
7
+from collections.abc import AsyncGenerator, Awaitable, Mapping
8
from dataclasses import dataclass, field
9
from http import HTTPStatus
10
from typing import Any, ClassVar, Generic, Protocol, TypeAlias, TypeVar
@@ -118,6 +118,7 @@ AnyResponse: TypeAlias = (
118
)
119
120
RequestT = TypeVar("RequestT", bound=AnyRequest)
121
+_T = TypeVar("_T")
122
123
124
@dataclass(kw_only=True)
@@ -156,6 +157,9 @@ class OpenAIServing:
157
self.model_config = engine_client.model_config
158
self.renderer = engine_client.renderer
159
self.input_processor = engine_client.input_processor
160
+ vllm_config = getattr(engine_client, "vllm_config", None)
161
+ kv_transfer_config = getattr(vllm_config, "kv_transfer_config", None)
162
+ self.has_kv_connector = kv_transfer_config is not None
163
164
# Computed once at startup (cached by ``vllm_config`` identity) and
165
# stamped on non-streaming responses. Streaming chunks deliberately
@@ -616,6 +620,40 @@ class OpenAIServing:
620
except ValueError:
621
return None
622
623
+ async def _with_kv_transfer_rejection_cleanup(
624
+ self,
625
+ awaitable: Awaitable[_T],
626
+ request: ChatCompletionRequest | CompletionRequest | ResponsesRequest,
627
+ raw_request: Request | None,
628
+ ) -> _T:
629
+ """Wrap a `create_*` coroutine so that, if it raises or returns an
630
+ ErrorResponse (i.e. the request never reached the engine), the KV
631
+ connector is notified to free any pinned remote-prefill blocks."""
632
+ kv_transfer_params = self.has_kv_connector and request.kv_transfer_params
633
+ if not kv_transfer_params or not kv_transfer_params.get("do_remote_prefill"):
634
+ return await awaitable
635
+
636
+ notify = True
637
+ try:
638
+ result = await awaitable
639
+ if not isinstance(result, ErrorResponse):
640
+ notify = False
641
+ return result
642
+ finally:
643
+ if notify:
644
+ try:
645
+ await self.engine_client.notify_kv_transfer_request_rejected(
646
+ request.request_id,
647
+ kv_transfer_params,
648
+ data_parallel_rank=self._get_data_parallel_rank(raw_request),
649
+ )
650
+ except Exception:
651
+ logger.warning(
652
+ "Failed to notify KV connector about rejected request %s",
653
+ request.request_id,
654
+ exc_info=True,
655
+ )
656
+
657
@staticmethod
658
def _parse_tool_calls_from_content(
659
request: ResponsesRequest | ChatCompletionRequest,
vllm/entrypoints/openai/responses/serving.py
+11
@@ -323,6 +323,17 @@ class OpenAIServingResponses(OpenAIServing):
323
AsyncGenerator[StreamingResponsesResponse, None]
324
| ResponsesResponse
325
| ErrorResponse
326
+ ):
327
+ return await self._with_kv_transfer_rejection_cleanup(
328
+ self._create_responses(request, raw_request), request, raw_request
329
+ )
330
+
331
+ async def _create_responses(
332
+ self, request: ResponsesRequest, raw_request: Request | None = None
333
+ ) -> (
334
+ AsyncGenerator[StreamingResponsesResponse, None]
335
+ | ResponsesResponse
336
+ | ErrorResponse
337
):
338
error_check_ret = await self._check_model(request)
339
if error_check_ret is not None:
vllm/v1/engine/__init__.py
+6
@@ -122,6 +122,12 @@ class EngineCoreRequest(
122
reasoning_ended: bool | None = None
123
reasoning_parser_kwargs: dict[str, Any] | None = None
124
125
+ # If True, the request should be added to the scheduler's waiting queue
126
+ # and immediately aborted, so connector-side cleanup runs via the standard
127
+ # request_finished hook. Used to free P-side prefill blocks when a
128
+ # KV-transfer request is rejected on the D node before engine admission.
129
+ abort_immediately: bool = False
130
+
131
@property
132
def params(self) -> SamplingParams | PoolingParams:
133
"""Return the processed params (sampling or pooling)."""
vllm/v1/engine/async_llm.py
+27
@@ -720,6 +720,33 @@ class AsyncLLM(EngineClient):
720
if self.log_requests:
721
logger.info("Aborted request(s) %s.", ",".join(request_ids))
722
723
+ async def notify_kv_transfer_request_rejected(
724
+ self,
725
+ request_id: str,
726
+ kv_transfer_params: dict[str, Any],
727
+ *,
728
+ data_parallel_rank: int | None = None,
729
+ ) -> None:
730
+ """Submit a pre-aborted request so the connector's request_finished
731
+ hook runs to free any pre-admission KV-transfer resources (e.g. NIXL
732
+ prefill blocks pinned on the P node)."""
733
+ request = EngineCoreRequest(
734
+ request_id=request_id,
735
+ prompt_token_ids=[0],
736
+ mm_features=None,
737
+ sampling_params=SamplingParams(
738
+ max_tokens=1,
739
+ extra_args={"kv_transfer_params": dict(kv_transfer_params)},
740
+ ),
741
+ pooling_params=None,
742
+ arrival_time=time.time(),
743
+ lora_request=None,
744
+ cache_salt=None,
745
+ data_parallel_rank=data_parallel_rank,
746
+ abort_immediately=True,
747
+ )
748
+ await self.engine_core.add_request_async(request)
749
+
750
async def pause_generation(
751
self,
752
*,
vllm/v1/engine/core.py
+4
@@ -344,6 +344,10 @@ class EngineCore:
344
)
345
346
self.scheduler.add_request(request)
347
+ if request.abort_immediately:
348
+ # Immediately abort so the connector's request_finished hook runs
349
+ # to free any pre-admission KV-transfer resources.
350
+ self.abort_requests([request.request_id])
351
352
def abort_requests(self, request_ids: list[str]):
353
"""Abort requests from the scheduler."""
vllm/v1/request.py
+6
@@ -76,6 +76,7 @@ class Request:
76
resumable: bool = False,
77
reasoning_ended: bool | None = None,
78
reasoning_parser_kwargs: dict[str, Any] | None = None,
79
+ abort_immediately: bool = False,
80
) -> None:
81
self.request_id = request_id
82
self.client_index = client_index
@@ -182,6 +183,10 @@ class Request:
183
# None entry in the queue means finished.
184
self.streaming_queue: deque[StreamingUpdate | None] | None = None
185
186
+ # If True, request should be aborted immediately after being added to
187
+ # the scheduler so the connector's request_finished hook runs.
188
+ self.abort_immediately = abort_immediately
189
+
190
@classmethod
191
def from_engine_core_request(
192
cls,
@@ -206,6 +211,7 @@ class Request:
211
resumable=request.resumable,
212
reasoning_ended=request.reasoning_ended,
213
reasoning_parser_kwargs=request.reasoning_parser_kwargs,
214
+ abort_immediately=request.abort_immediately,
215
)
216
217
def append_output_token_ids(