[Core] Add max_num_queued_reqs and max_num_queued_tokens for queue size management (#49445)
Signed-off-by: NickLucche <nicolo.lucchesi@mistral.ai>
This commit is contained in:
@@ -42,7 +42,7 @@ from vllm.entrypoints.openai.models.serving import (
|
||||
)
|
||||
from vllm.entrypoints.openai.parser.harmony_utils import get_encoding
|
||||
from vllm.entrypoints.serve.engine.protocol import ErrorResponse
|
||||
from vllm.exceptions import VLLMValidationError
|
||||
from vllm.exceptions import QueueOverflowError, VLLMValidationError
|
||||
from vllm.inputs import TokensPrompt
|
||||
from vllm.multimodal.inputs import PlaceholderRange
|
||||
from vllm.outputs import CompletionOutput, RequestOutput
|
||||
@@ -848,6 +848,9 @@ class MockEngine:
|
||||
renderer: MagicMock = field(default_factory=MagicMock)
|
||||
errored: bool = False
|
||||
|
||||
def check_admission(self, n: int = 1, request_id: str | None = None) -> None:
|
||||
pass
|
||||
|
||||
|
||||
async def _async_serving_chat_init():
|
||||
engine = MockEngine()
|
||||
@@ -944,6 +947,35 @@ async def test_serving_chat_returns_correct_model_name():
|
||||
assert await serving_chat.create_chat_completion(req) == MODEL_NAME
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("stream", [True, False])
|
||||
async def test_admission_rejection_escapes_before_response_starts(stream):
|
||||
"""Overload rejections must propagate out of create_chat_completion.
|
||||
|
||||
For streaming this is the only chance to return a real HTTP status: once
|
||||
StreamingResponse is constructed the 200 has already been sent and the
|
||||
rejection would degrade into an in-band SSE error chunk.
|
||||
"""
|
||||
mock_engine = MagicMock(spec=AsyncLLM)
|
||||
mock_engine.errored = False
|
||||
mock_engine.model_config = MockModelConfig()
|
||||
mock_engine.input_processor = MagicMock()
|
||||
mock_engine.renderer = _build_renderer(mock_engine.model_config)
|
||||
mock_engine.check_admission.side_effect = QueueOverflowError()
|
||||
|
||||
serving_chat = _build_serving_chat(mock_engine)
|
||||
req = ChatCompletionRequest(
|
||||
model=MODEL_NAME,
|
||||
messages=[{"role": "user", "content": "what is 1+1?"}],
|
||||
stream=stream,
|
||||
)
|
||||
|
||||
with pytest.raises(QueueOverflowError):
|
||||
await serving_chat.create_chat_completion(req)
|
||||
|
||||
mock_engine.generate.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_serving_chat_should_set_correct_max_tokens():
|
||||
mock_engine = MagicMock(spec=AsyncLLM)
|
||||
|
||||
@@ -38,6 +38,7 @@ async def test_non_streaming_cancel_aborts_engine_requests(
|
||||
):
|
||||
engine_client = SimpleNamespace(
|
||||
errored=False,
|
||||
check_admission=Mock(),
|
||||
generate=Mock(side_effect=lambda *_args, **_kwargs: _never_finishes()),
|
||||
abort=AsyncMock(),
|
||||
is_tracing_enabled=AsyncMock(return_value=False),
|
||||
@@ -104,6 +105,7 @@ async def test_non_streaming_cancel_advances_all_chunk_generators():
|
||||
started_request_ids: list[str] = []
|
||||
engine_client = SimpleNamespace(
|
||||
errored=False,
|
||||
check_admission=Mock(),
|
||||
generate=Mock(
|
||||
side_effect=lambda *_args, **_kwargs: _records_start_then_never_finishes(
|
||||
started_request_ids, _args[2]
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""Unit tests for admission control (max_num_queued_reqs / max_num_queued_tokens).
|
||||
|
||||
These tests cover:
|
||||
- OutputProcessor.get_num_queued_tokens() token counting
|
||||
- AsyncLLM.check_admission() admission control logic
|
||||
- Exception classes (GracefulHTTPError, QueueOverflowError, MaxQueuedTokensError)
|
||||
- create_error_response() mapping GracefulHTTPError to HTTP 503
|
||||
- SchedulerConfig field defaults and validation
|
||||
- human_readable_int CLI notation for max_num_queued_tokens
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from http import HTTPStatus
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from vllm.config.scheduler import SchedulerConfig
|
||||
from vllm.entrypoints.serve.exception_handling.error_response import (
|
||||
create_error_response,
|
||||
)
|
||||
from vllm.exceptions import (
|
||||
GracefulHTTPError,
|
||||
MaxQueuedTokensError,
|
||||
QueueOverflowError,
|
||||
VLLMError,
|
||||
)
|
||||
from vllm.pooling_params import PoolingParams
|
||||
from vllm.sampling_params import SamplingParams
|
||||
from vllm.utils.argparse_utils import human_readable_int
|
||||
from vllm.v1.engine.async_llm import AsyncLLM
|
||||
from vllm.v1.engine.output_processor import OutputProcessor
|
||||
|
||||
pytestmark = pytest.mark.cpu_test
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_req_state(prompt_len: int, is_prefilling: bool = True):
|
||||
return SimpleNamespace(
|
||||
prompt_len=prompt_len,
|
||||
is_prefilling=is_prefilling,
|
||||
)
|
||||
|
||||
|
||||
def _make_async_llm(
|
||||
max_num_queued_reqs: int | None = None,
|
||||
max_num_queued_tokens: int | None = None,
|
||||
num_unfinished: int = 0,
|
||||
num_queued_tokens: int = 0,
|
||||
) -> AsyncLLM:
|
||||
"""Create a bare AsyncLLM with just the attributes needed for scheduling."""
|
||||
llm = AsyncLLM.__new__(AsyncLLM)
|
||||
llm.scheduler_config = SimpleNamespace(
|
||||
max_num_queued_reqs=max_num_queued_reqs,
|
||||
max_num_queued_tokens=max_num_queued_tokens,
|
||||
)
|
||||
llm.output_processor = MagicMock()
|
||||
llm.output_processor.get_num_unfinished_requests.return_value = num_unfinished
|
||||
llm.output_processor.get_num_queued_tokens.return_value = num_queued_tokens
|
||||
return llm
|
||||
|
||||
|
||||
def _make_output_processor(**request_states) -> OutputProcessor:
|
||||
op = OutputProcessor.__new__(OutputProcessor)
|
||||
op.request_states = request_states
|
||||
return op
|
||||
|
||||
|
||||
def _make_scheduler_config(**kwargs) -> SchedulerConfig:
|
||||
return SchedulerConfig(
|
||||
runner_type="generate",
|
||||
max_model_len=4096,
|
||||
is_encoder_decoder=False,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exception classes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_graceful_http_error_carries_status_and_message():
|
||||
err = GracefulHTTPError("custom message", HTTPStatus.SERVICE_UNAVAILABLE)
|
||||
assert err.message == "custom message"
|
||||
assert err.http_status == HTTPStatus.SERVICE_UNAVAILABLE
|
||||
assert str(err) == "custom message"
|
||||
|
||||
|
||||
def test_graceful_http_error_is_vllm_error():
|
||||
err = GracefulHTTPError("msg", HTTPStatus.TOO_MANY_REQUESTS)
|
||||
assert isinstance(err, VLLMError)
|
||||
|
||||
|
||||
def test_queue_overflow_error():
|
||||
err = QueueOverflowError()
|
||||
assert err.http_status == HTTPStatus.SERVICE_UNAVAILABLE
|
||||
assert isinstance(err, GracefulHTTPError)
|
||||
assert "busy" in err.message.lower() or "try again" in err.message.lower()
|
||||
|
||||
|
||||
def test_max_queued_tokens_error():
|
||||
err = MaxQueuedTokensError()
|
||||
assert err.http_status == HTTPStatus.SERVICE_UNAVAILABLE
|
||||
assert isinstance(err, GracefulHTTPError)
|
||||
assert "backlog" in err.message.lower() or "try again" in err.message.lower()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exc_cls", [QueueOverflowError, MaxQueuedTokensError])
|
||||
def test_admission_exceptions_are_vllm_errors(exc_cls):
|
||||
"""Admission rejections must reach the VLLMError HTTP handler."""
|
||||
assert issubclass(exc_cls, VLLMError)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OutputProcessor.get_num_queued_tokens
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_queued_tokens_empty():
|
||||
assert _make_output_processor().get_num_queued_tokens() == 0
|
||||
|
||||
|
||||
def test_queued_tokens_sums_prefilling_requests():
|
||||
op = _make_output_processor(r1=_make_req_state(100), r2=_make_req_state(200))
|
||||
assert op.get_num_queued_tokens() == 300
|
||||
|
||||
|
||||
def test_queued_tokens_excludes_non_prefilling():
|
||||
op = _make_output_processor(
|
||||
r1=_make_req_state(100, is_prefilling=True),
|
||||
r2=_make_req_state(200, is_prefilling=False),
|
||||
r3=_make_req_state(50, is_prefilling=True),
|
||||
)
|
||||
assert op.get_num_queued_tokens() == 150
|
||||
|
||||
|
||||
def test_queued_tokens_all_non_prefilling():
|
||||
op = _make_output_processor(
|
||||
r1=_make_req_state(100, is_prefilling=False),
|
||||
r2=_make_req_state(200, is_prefilling=False),
|
||||
)
|
||||
assert op.get_num_queued_tokens() == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AsyncLLM.check_admission
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_admission_no_limits_allows_everything():
|
||||
llm = _make_async_llm(num_unfinished=999, num_queued_tokens=999)
|
||||
llm.check_admission()
|
||||
|
||||
|
||||
# -- max_num_queued_reqs ----------------------------------------------------
|
||||
|
||||
|
||||
def test_admission_reqs_allows_when_under_limit():
|
||||
llm = _make_async_llm(max_num_queued_reqs=10, num_unfinished=5)
|
||||
llm.check_admission()
|
||||
|
||||
|
||||
def test_admission_reqs_rejects_at_limit():
|
||||
llm = _make_async_llm(max_num_queued_reqs=10, num_unfinished=10)
|
||||
with pytest.raises(QueueOverflowError):
|
||||
llm.check_admission()
|
||||
|
||||
|
||||
def test_admission_reqs_rejects_with_n():
|
||||
llm = _make_async_llm(max_num_queued_reqs=10, num_unfinished=8)
|
||||
with pytest.raises(QueueOverflowError):
|
||||
llm.check_admission(3)
|
||||
|
||||
|
||||
def test_admission_reqs_allows_n_at_boundary():
|
||||
llm = _make_async_llm(max_num_queued_reqs=10, num_unfinished=7)
|
||||
llm.check_admission(3)
|
||||
|
||||
|
||||
def test_admission_reqs_rejects_when_zero_limit():
|
||||
llm = _make_async_llm(max_num_queued_reqs=0, num_unfinished=0)
|
||||
with pytest.raises(QueueOverflowError):
|
||||
llm.check_admission()
|
||||
|
||||
|
||||
# -- max_num_queued_tokens --------------------------------------------------
|
||||
|
||||
|
||||
def test_admission_tokens_allows_when_under_limit():
|
||||
llm = _make_async_llm(max_num_queued_tokens=1000, num_queued_tokens=500)
|
||||
llm.check_admission()
|
||||
|
||||
|
||||
def test_admission_tokens_rejects_at_limit():
|
||||
llm = _make_async_llm(max_num_queued_tokens=1000, num_queued_tokens=1000)
|
||||
with pytest.raises(MaxQueuedTokensError):
|
||||
llm.check_admission()
|
||||
|
||||
|
||||
def test_admission_tokens_rejects_over_limit():
|
||||
llm = _make_async_llm(max_num_queued_tokens=1000, num_queued_tokens=1500)
|
||||
with pytest.raises(MaxQueuedTokensError):
|
||||
llm.check_admission()
|
||||
|
||||
|
||||
def test_admission_tokens_rejects_when_zero_limit():
|
||||
llm = _make_async_llm(max_num_queued_tokens=0, num_queued_tokens=0)
|
||||
with pytest.raises(MaxQueuedTokensError):
|
||||
llm.check_admission()
|
||||
|
||||
|
||||
# -- interaction between both limits ----------------------------------------
|
||||
|
||||
|
||||
def test_admission_both_limits_checked_independently():
|
||||
llm = _make_async_llm(
|
||||
max_num_queued_reqs=100,
|
||||
max_num_queued_tokens=1000,
|
||||
num_unfinished=5,
|
||||
num_queued_tokens=1000,
|
||||
)
|
||||
with pytest.raises(MaxQueuedTokensError):
|
||||
llm.check_admission()
|
||||
|
||||
|
||||
def test_admission_req_limit_checked_before_token_limit():
|
||||
llm = _make_async_llm(
|
||||
max_num_queued_reqs=10,
|
||||
max_num_queued_tokens=1000,
|
||||
num_unfinished=10,
|
||||
num_queued_tokens=1000,
|
||||
)
|
||||
with pytest.raises(QueueOverflowError):
|
||||
llm.check_admission()
|
||||
|
||||
|
||||
# -- n derived from params at the add_request call site ---------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("params", [SamplingParams(), PoolingParams()])
|
||||
def test_admission_params_without_explicit_n_count_as_one_slot(params):
|
||||
"""PoolingParams has no ``.n``; add_request must fall back to 1."""
|
||||
llm = _make_async_llm(max_num_queued_reqs=10, num_unfinished=9)
|
||||
llm.check_admission(getattr(params, "n", 1) or 1)
|
||||
|
||||
llm = _make_async_llm(max_num_queued_reqs=10, num_unfinished=10)
|
||||
with pytest.raises(QueueOverflowError):
|
||||
llm.check_admission(getattr(params, "n", 1) or 1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_single_request_admission_respects_limit():
|
||||
"""Concurrent n=1 admissions cannot consume the same slot."""
|
||||
llm = _make_async_llm(max_num_queued_reqs=1)
|
||||
request_states: dict[str, SimpleNamespace] = {}
|
||||
llm.output_processor.get_num_unfinished_requests.side_effect = lambda: len(
|
||||
request_states
|
||||
)
|
||||
llm.output_processor.has_request.side_effect = request_states.__contains__
|
||||
|
||||
def add_request(request, *_args):
|
||||
request_states[request.request_id] = _make_req_state(
|
||||
len(request.prompt_token_ids)
|
||||
)
|
||||
|
||||
llm.output_processor.add_request.side_effect = add_request
|
||||
|
||||
async def add_request_async(_request):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
llm.engine_core = SimpleNamespace(
|
||||
add_request_async=AsyncMock(side_effect=add_request_async),
|
||||
shutdown=MagicMock(),
|
||||
)
|
||||
llm.log_requests = False
|
||||
|
||||
requests = [
|
||||
SimpleNamespace(request_id=f"request-{idx}", prompt_token_ids=[idx])
|
||||
for idx in range(2)
|
||||
]
|
||||
results = await asyncio.gather(
|
||||
*(
|
||||
llm._add_request(request, None, None, 0, MagicMock())
|
||||
for request in requests
|
||||
),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
assert sum(result is None for result in results) == 1
|
||||
assert sum(isinstance(result, QueueOverflowError) for result in results) == 1
|
||||
assert len(request_states) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# create_error_response integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_queue_overflow_maps_to_503():
|
||||
resp = create_error_response(QueueOverflowError())
|
||||
assert resp.error.code == HTTPStatus.SERVICE_UNAVAILABLE.value
|
||||
assert resp.error.type == HTTPStatus.SERVICE_UNAVAILABLE.phrase
|
||||
assert resp.error.param is None
|
||||
msg = resp.error.message.lower()
|
||||
assert "busy" in msg or "try again" in msg
|
||||
|
||||
|
||||
def test_max_queued_tokens_maps_to_503():
|
||||
resp = create_error_response(MaxQueuedTokensError())
|
||||
assert resp.error.code == HTTPStatus.SERVICE_UNAVAILABLE.value
|
||||
assert resp.error.type == HTTPStatus.SERVICE_UNAVAILABLE.phrase
|
||||
msg = resp.error.message.lower()
|
||||
assert "backlog" in msg or "try again" in msg
|
||||
|
||||
|
||||
def test_custom_graceful_error_maps_to_its_status():
|
||||
err = GracefulHTTPError("custom", HTTPStatus.SERVICE_UNAVAILABLE)
|
||||
resp = create_error_response(err)
|
||||
assert resp.error.code == HTTPStatus.SERVICE_UNAVAILABLE.value
|
||||
assert resp.error.type == HTTPStatus.SERVICE_UNAVAILABLE.phrase
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SchedulerConfig field defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_scheduler_config_defaults_are_none():
|
||||
config = _make_scheduler_config()
|
||||
assert config.max_num_queued_reqs is None
|
||||
assert config.max_num_queued_tokens is None
|
||||
|
||||
|
||||
def test_scheduler_config_accepts_explicit_values():
|
||||
config = _make_scheduler_config(
|
||||
max_num_queued_reqs=100,
|
||||
max_num_queued_tokens=32000,
|
||||
)
|
||||
assert config.max_num_queued_reqs == 100
|
||||
assert config.max_num_queued_tokens == 32000
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["max_num_queued_reqs", "max_num_queued_tokens"])
|
||||
def test_scheduler_config_rejects_negative(field):
|
||||
with pytest.raises(ValidationError):
|
||||
_make_scheduler_config(**{field: -1})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# human_readable_int for CLI notation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"input_str, expected",
|
||||
[
|
||||
("32k", 32_000),
|
||||
("1k", 1_000),
|
||||
("1K", 1_024),
|
||||
("1m", 1_000_000),
|
||||
("1M", 1_048_576),
|
||||
("100", 100),
|
||||
("2.5k", 2_500),
|
||||
("0", 0),
|
||||
],
|
||||
)
|
||||
def test_human_readable_int_parses_notation(input_str: str, expected: int):
|
||||
assert human_readable_int(input_str) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid", ["abc", "1x", "", "k", "1.5K"])
|
||||
def test_human_readable_int_rejects_invalid(invalid: str):
|
||||
with pytest.raises((argparse.ArgumentTypeError, ValueError)):
|
||||
human_readable_int(invalid)
|
||||
@@ -71,6 +71,37 @@ class SchedulerConfig:
|
||||
"""For chunked prefill, a request is considered long if the prompt is
|
||||
longer than this number of tokens. 0 disables the cap (default)."""
|
||||
|
||||
max_num_queued_reqs: int | None = Field(default=None, ge=0)
|
||||
"""Maximum number of requests that can be in-flight (waiting or running)
|
||||
at the same time, or None for no limit. When the limit is reached, new
|
||||
requests are rejected with HTTP 503 so the client can retry on another
|
||||
instance. This bounds vLLM's otherwise unbounded request queue and is
|
||||
primarily a coarse capacity valve."""
|
||||
|
||||
max_num_queued_tokens: int | None = Field(default=None, ge=0)
|
||||
"""Maximum total prompt tokens of requests currently in the prefill
|
||||
phase, or None for no limit. When the limit is reached, new requests
|
||||
are rejected with HTTP 503.
|
||||
|
||||
This is a TTFT QoS mechanism: by setting it to
|
||||
``target_TTFT * prefill_throughput`` you reject requests when the
|
||||
prefill backlog would exceed the latency target. In a disaggregated
|
||||
prefill-decode setup this maps directly to the prefill pool's
|
||||
capacity.
|
||||
|
||||
Note: the count is conservative. A partially prefilled request
|
||||
still contributes its full ``prompt_len`` until it transitions out
|
||||
of the prefill phase, because the scheduler's per-iteration
|
||||
``num_computed_tokens`` progress is not propagated to the API
|
||||
server process during prefill (``EngineCoreOutput`` is only
|
||||
emitted once the request starts producing tokens). Similarly,
|
||||
prefix-cache hits (``num_cached_tokens``) are only known to the
|
||||
OutputProcessor after prefill completes. This overestimates the
|
||||
real backlog, causing earlier rejection than strictly necessary
|
||||
— the safe direction for QoS. The impact is limited to long
|
||||
prompts under chunked prefill; short prompts that prefill in a
|
||||
single iteration are unaffected."""
|
||||
|
||||
enable_chunked_prefill: bool = True
|
||||
"""If True, prefill requests can be chunked based
|
||||
on the remaining `max_num_batched_tokens`.
|
||||
|
||||
@@ -367,6 +367,7 @@ def _compute_kwargs(cls: ConfigType) -> dict[str, dict[str, Any]]:
|
||||
"max_num_scheduled_tokens",
|
||||
"kv_cache_memory_bytes",
|
||||
"safetensors_prefetch_block_size",
|
||||
"max_num_queued_tokens",
|
||||
}
|
||||
if name == "max_model_len":
|
||||
kwargs[name]["type"] = human_readable_int_or_auto
|
||||
@@ -544,6 +545,8 @@ class EngineArgs:
|
||||
max_num_scheduled_tokens: int | None = None
|
||||
long_prefill_token_threshold: int = SchedulerConfig.long_prefill_token_threshold
|
||||
max_num_seqs: int | None = None
|
||||
max_num_queued_reqs: int | None = None
|
||||
max_num_queued_tokens: int | None = None
|
||||
max_logprobs: int = ModelConfig.max_logprobs
|
||||
logprobs_mode: LogprobsMode = ModelConfig.logprobs_mode
|
||||
use_fp64_gumbel: bool = ModelConfig.use_fp64_gumbel
|
||||
@@ -1554,6 +1557,13 @@ class EngineArgs:
|
||||
"default": None,
|
||||
},
|
||||
)
|
||||
scheduler_group.add_argument(
|
||||
"--max-num-queued-reqs", **scheduler_kwargs["max_num_queued_reqs"]
|
||||
)
|
||||
scheduler_group.add_argument(
|
||||
"--max-num-queued-tokens",
|
||||
**scheduler_kwargs["max_num_queued_tokens"],
|
||||
)
|
||||
scheduler_group.add_argument(
|
||||
"--long-prefill-token-threshold",
|
||||
**scheduler_kwargs["long_prefill_token_threshold"],
|
||||
@@ -2348,6 +2358,8 @@ class EngineArgs:
|
||||
max_num_batched_tokens=self.max_num_batched_tokens,
|
||||
max_num_scheduled_tokens=self.max_num_scheduled_tokens,
|
||||
max_num_seqs=self.max_num_seqs,
|
||||
max_num_queued_reqs=self.max_num_queued_reqs,
|
||||
max_num_queued_tokens=self.max_num_queued_tokens,
|
||||
max_model_len=model_config.max_model_len,
|
||||
enable_chunked_prefill=self.enable_chunked_prefill,
|
||||
disable_chunked_mm_input=self.disable_chunked_mm_input,
|
||||
|
||||
@@ -62,6 +62,23 @@ class EngineClient(ABC):
|
||||
@abstractmethod
|
||||
def dead_error(self) -> BaseException: ...
|
||||
|
||||
def check_admission( # noqa: B027
|
||||
self, n: int = 1, request_id: str | None = None
|
||||
) -> None:
|
||||
"""Reject the request up front if it would exceed queue limits.
|
||||
|
||||
Called before a response is started so that overload rejections can
|
||||
carry an HTTP status, which is not possible once a streaming response
|
||||
has begun. Engines without admission control accept everything.
|
||||
|
||||
Args:
|
||||
n: Number of sequences the request will occupy.
|
||||
request_id: Request id, used for logging only.
|
||||
|
||||
Raises:
|
||||
GracefulHTTPError: If the request cannot be admitted.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def generate(
|
||||
self,
|
||||
|
||||
@@ -171,6 +171,19 @@ class GenerateBaseServing(BaseServing, BeamSearchOnlineMixin):
|
||||
# Never fail server startup over the fingerprint.
|
||||
self.system_fingerprint = None
|
||||
|
||||
def _preflight(self, n: int = 1) -> None:
|
||||
"""Engine checks that must run before a response is started.
|
||||
|
||||
This is required for the streaming case, where we return a success
|
||||
status before we actually start generating text :).
|
||||
|
||||
Args:
|
||||
n: Number of sequences the request will occupy.
|
||||
"""
|
||||
if self.engine_client.errored:
|
||||
raise self.engine_client.dead_error
|
||||
self.engine_client.check_admission(n)
|
||||
|
||||
def create_streaming_error_response(
|
||||
self,
|
||||
message: str | Exception,
|
||||
|
||||
@@ -195,6 +195,7 @@ class ServingGenerativeScoring(BaseServing):
|
||||
# Check if engine is alive
|
||||
if self.engine_client.errored:
|
||||
raise self.engine_client.dead_error
|
||||
self.engine_client.check_admission(len(request.items))
|
||||
|
||||
# Get tokenizer
|
||||
tokenizer = self.renderer.tokenizer
|
||||
|
||||
@@ -56,8 +56,7 @@ class OpenAIServingChatBatch(OpenAIServingChat):
|
||||
logger.error("Error with model %s", error_check_ret)
|
||||
return error_check_ret
|
||||
|
||||
if self.engine_client.errored:
|
||||
raise self.engine_client.dead_error
|
||||
self._preflight()
|
||||
|
||||
renderer = self.online_renderer
|
||||
|
||||
|
||||
@@ -235,11 +235,7 @@ class OpenAIServingChat(GenerateBaseServing):
|
||||
logger.error("Error with model %s", error_check_ret)
|
||||
return error_check_ret
|
||||
|
||||
# If the engine is dead, raise the engine's DEAD_ERROR.
|
||||
# This is required for the streaming case, where we return a
|
||||
# success status before we actually start generating text :).
|
||||
if self.engine_client.errored:
|
||||
raise self.engine_client.dead_error
|
||||
self._preflight(request.n or 1)
|
||||
|
||||
return await self.online_renderer.render_chat(request)
|
||||
|
||||
|
||||
@@ -102,11 +102,7 @@ class OpenAIServingCompletion(GenerateBaseServing):
|
||||
if error_check_ret is not None:
|
||||
return error_check_ret
|
||||
|
||||
# If the engine is dead, raise the engine's DEAD_ERROR.
|
||||
# This is required for the streaming case, where we return a
|
||||
# success status before we actually start generating text :).
|
||||
if self.engine_client.errored:
|
||||
raise self.engine_client.dead_error
|
||||
self._preflight(request.n or 1)
|
||||
|
||||
return await self.online_renderer.render_completion(request)
|
||||
|
||||
|
||||
@@ -350,11 +350,7 @@ class OpenAIServingResponses(GenerateBaseServing):
|
||||
if maybe_validation_error is not None:
|
||||
return maybe_validation_error
|
||||
|
||||
# If the engine is dead, raise the engine's DEAD_ERROR.
|
||||
# This is required for the streaming case, where we return a
|
||||
# success status before we actually start generating text :).
|
||||
if self.engine_client.errored:
|
||||
raise self.engine_client.dead_error
|
||||
self._preflight()
|
||||
|
||||
if request.store and not self.enable_store:
|
||||
# Disable the store option.
|
||||
|
||||
@@ -110,11 +110,7 @@ class ServingTokens(GenerateBaseServing):
|
||||
logger.error("Error with model %s", error_check_ret)
|
||||
return error_check_ret
|
||||
|
||||
# If the engine is dead, raise the engine's DEAD_ERROR.
|
||||
# This is required for the streaming case, where we return a
|
||||
# success status before we actually start generating text :).
|
||||
if self.engine_client.errored:
|
||||
raise self.engine_client.dead_error
|
||||
self._preflight()
|
||||
|
||||
lora_request = None
|
||||
lora_request = self._maybe_get_adapters(request, supports_default_mm_loras=True)
|
||||
|
||||
@@ -26,6 +26,7 @@ def create_error_response(
|
||||
)
|
||||
|
||||
from vllm.exceptions import (
|
||||
GracefulHTTPError,
|
||||
VLLMClientError,
|
||||
VLLMNotFoundError,
|
||||
VLLMServerError,
|
||||
@@ -33,7 +34,11 @@ def create_error_response(
|
||||
VLLMValidationError,
|
||||
)
|
||||
|
||||
if isinstance(exc, VLLMValidationError):
|
||||
if isinstance(exc, GracefulHTTPError):
|
||||
err_type = HTTPStatus(exc.http_status).phrase
|
||||
status_code = exc.http_status
|
||||
param = None
|
||||
elif isinstance(exc, VLLMValidationError):
|
||||
err_type = "BadRequestError"
|
||||
status_code = HTTPStatus.BAD_REQUEST
|
||||
param = exc.parameter
|
||||
|
||||
@@ -440,11 +440,7 @@ class SpeechToTextBaseServing(GenerateBaseServing):
|
||||
if not request.model:
|
||||
request.model = self.models.model_name()
|
||||
|
||||
# If the engine is dead, raise the engine's DEAD_ERROR.
|
||||
# This is required for the streaming case, where we return a
|
||||
# success status before we actually start generating text :).
|
||||
if self.engine_client.errored:
|
||||
raise self.engine_client.dead_error
|
||||
self._preflight()
|
||||
|
||||
if request.response_format not in [
|
||||
"text",
|
||||
|
||||
@@ -125,3 +125,48 @@ class GenerationError(VLLMServerError):
|
||||
def __init__(self, message: str = "Internal server error"):
|
||||
super().__init__(message)
|
||||
self.status_code = HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
|
||||
|
||||
class GracefulHTTPError(VLLMError):
|
||||
"""Exception that should be translated into an HTTP error response.
|
||||
|
||||
These are expected to occur during normal operation (e.g. admission
|
||||
control rejections) and should be surfaced to the client with the
|
||||
explicit HTTP status code they carry, rather than being mapped to a
|
||||
generic 4xx/5xx by the client/server split.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, http_status: HTTPStatus):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.http_status = http_status
|
||||
|
||||
|
||||
class QueueOverflowError(GracefulHTTPError):
|
||||
"""Raised when admitting a request would exceed the request queue limit.
|
||||
|
||||
Returns HTTP 503 (Service Unavailable) so that load balancers and
|
||||
client SDKs retry the request on a different instance.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
"The engine is currently busy and cannot accept new requests. "
|
||||
"Please try again later or on a different instance.",
|
||||
HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
|
||||
class MaxQueuedTokensError(GracefulHTTPError):
|
||||
"""Raised when the pending prefill tokens exceed the configured limit.
|
||||
|
||||
Returns HTTP 503 (Service Unavailable) so that load balancers and
|
||||
client SDKs retry the request on a different instance.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
"The engine has reached its prefill token backlog limit. "
|
||||
"Please try again later or on a different instance.",
|
||||
HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
@@ -21,7 +21,13 @@ from vllm.distributed.weight_transfer.base import (
|
||||
from vllm.engine.arg_utils import AsyncEngineArgs
|
||||
from vllm.engine.protocol import EngineClient, StreamingInput
|
||||
from vllm.entrypoints.serve.elastic_ep.middleware import set_scaling_elastic_ep
|
||||
from vllm.exceptions import VLLMClientError, VLLMValidationError
|
||||
from vllm.exceptions import (
|
||||
GracefulHTTPError,
|
||||
MaxQueuedTokensError,
|
||||
QueueOverflowError,
|
||||
VLLMClientError,
|
||||
VLLMValidationError,
|
||||
)
|
||||
from vllm.inputs import EngineInput, PromptType
|
||||
from vllm.logger import init_logger
|
||||
from vllm.lora.request import LoRARequest
|
||||
@@ -112,6 +118,7 @@ class AsyncLLM(EngineClient):
|
||||
self.vllm_config = vllm_config
|
||||
self._elastic_ep_lock = asyncio.Lock()
|
||||
self.model_config = vllm_config.model_config
|
||||
self.scheduler_config = vllm_config.scheduler_config
|
||||
self.observability_config = vllm_config.observability_config
|
||||
|
||||
tracing_endpoint = self.observability_config.otlp_traces_endpoint
|
||||
@@ -273,6 +280,64 @@ class AsyncLLM(EngineClient):
|
||||
if handler is not None:
|
||||
cancel_task_threadsafe(handler)
|
||||
|
||||
def get_num_unfinished_requests(self) -> int:
|
||||
return self.output_processor.get_num_unfinished_requests()
|
||||
|
||||
def get_num_queued_tokens(self) -> int:
|
||||
return self.output_processor.get_num_queued_tokens()
|
||||
|
||||
def check_admission(self, n: int = 1, request_id: str | None = None) -> None:
|
||||
"""Reject the request if it would exceed queue limits.
|
||||
|
||||
Both limits return HTTP 503 (Service Unavailable) so that load
|
||||
balancers and client SDKs retry on a different instance.
|
||||
|
||||
- ``max_num_queued_reqs``: hard cap on the number of unfinished requests
|
||||
(waiting + running). A request with ``n > 1`` counts as ``n`` slots.
|
||||
- ``max_num_queued_tokens``: TTFT QoS — cap on the total prompt
|
||||
tokens of requests still in prefill.
|
||||
|
||||
Note: ``get_num_queued_tokens`` uses ``prompt_len`` for all prefilling requests.
|
||||
Chunked prefill progress and prefix-cache hits are not subtracted because the
|
||||
scheduler's ``num_computed_tokens`` and ``num_cached_tokens`` are only
|
||||
propagated to the API server after prefill completes. The overestimation is
|
||||
conservative — earlier rejection, preserving TTFT targets.
|
||||
|
||||
Args:
|
||||
n: Number of sequences the request will occupy.
|
||||
request_id: Request id, used for logging only.
|
||||
|
||||
Raises:
|
||||
QueueOverflowError: If ``max_num_queued_reqs`` would be exceeded.
|
||||
MaxQueuedTokensError: If ``max_num_queued_tokens`` would be exceeded.
|
||||
"""
|
||||
max_num_reqs = self.scheduler_config.max_num_queued_reqs
|
||||
if max_num_reqs is not None:
|
||||
current = self.get_num_unfinished_requests()
|
||||
if current + n > max_num_reqs:
|
||||
logger.info(
|
||||
"Request queue full - rejecting request %s "
|
||||
"(current=%d, n=%d, max=%d).",
|
||||
request_id,
|
||||
current,
|
||||
n,
|
||||
max_num_reqs,
|
||||
)
|
||||
raise QueueOverflowError()
|
||||
|
||||
max_queued_tokens = self.scheduler_config.max_num_queued_tokens
|
||||
if max_queued_tokens is not None:
|
||||
current_tokens = self.get_num_queued_tokens()
|
||||
if current_tokens >= max_queued_tokens:
|
||||
logger.info(
|
||||
"Max queued tokens reached - rejecting request %s "
|
||||
"(current_tokens=%d, max=%d).",
|
||||
request_id,
|
||||
current_tokens,
|
||||
max_queued_tokens,
|
||||
)
|
||||
raise MaxQueuedTokensError()
|
||||
|
||||
async def get_supported_tasks(self) -> tuple[SupportedTask, ...]:
|
||||
if not hasattr(self, "_supported_tasks"):
|
||||
# Cache the result
|
||||
@@ -317,6 +382,10 @@ class AsyncLLM(EngineClient):
|
||||
"prompt logprobs"
|
||||
)
|
||||
|
||||
if isinstance(params, SamplingParams) and params.n > 1:
|
||||
# TODO (NickLucche) Batch check admission check for all n requests
|
||||
self.check_admission(params.n, request_id)
|
||||
|
||||
if isinstance(prompt, AsyncGenerator):
|
||||
if reasoning_ended is not None or reasoning_parser_kwargs is not None:
|
||||
raise NotImplementedError
|
||||
@@ -430,7 +499,12 @@ class AsyncLLM(EngineClient):
|
||||
index: int,
|
||||
queue: RequestOutputCollector,
|
||||
):
|
||||
# Add the request to OutputProcessor (this process).
|
||||
if parent_req is None and not self.output_processor.has_request(
|
||||
request.request_id
|
||||
):
|
||||
self.check_admission(request_id=request.request_id)
|
||||
|
||||
# Register locally before the first await so concurrent tasks see this request.
|
||||
self.output_processor.add_request(request, prompt, parent_req, index, queue)
|
||||
|
||||
# Add the EngineCoreRequest to EngineCore (separate process).
|
||||
@@ -630,8 +704,8 @@ class AsyncLLM(EngineClient):
|
||||
logger.info("Request %s failed (engine dead).", request_id)
|
||||
raise
|
||||
|
||||
# Request validation error.
|
||||
except VLLMClientError as e:
|
||||
# Request validation error or admission control rejection.
|
||||
except (VLLMClientError, GracefulHTTPError) as e:
|
||||
if self.log_requests:
|
||||
logger.info("Request %s failed (bad request): %s.", request_id, e)
|
||||
raise
|
||||
@@ -907,8 +981,8 @@ class AsyncLLM(EngineClient):
|
||||
logger.info("Request %s failed (engine dead).", request_id)
|
||||
raise
|
||||
|
||||
# Request validation error.
|
||||
except VLLMClientError:
|
||||
# Request validation error or admission control rejection.
|
||||
except (VLLMClientError, GracefulHTTPError):
|
||||
if self.log_requests:
|
||||
logger.info("Request %s failed (bad request).", request_id)
|
||||
raise
|
||||
|
||||
@@ -467,6 +467,20 @@ class OutputProcessor:
|
||||
def get_num_unfinished_requests(self):
|
||||
return len(self.request_states)
|
||||
|
||||
def has_request(self, request_id: str) -> bool:
|
||||
return request_id in self.request_states
|
||||
|
||||
def get_num_queued_tokens(self) -> int:
|
||||
"""Total prompt tokens of requests currently in the prefill phase.
|
||||
|
||||
Uses ``prompt_len`` rather than remaining prefill work because the
|
||||
scheduler's ``num_computed_tokens`` is not propagated to the API
|
||||
server until prefill completes. See ``SchedulerConfig`` docs.
|
||||
"""
|
||||
return sum(
|
||||
req.prompt_len for req in self.request_states.values() if req.is_prefilling
|
||||
)
|
||||
|
||||
def has_unfinished_requests(self) -> bool:
|
||||
return len(self.request_states) > 0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user