-
Notifications
You must be signed in to change notification settings - Fork 420
[tinker] Forward samples with aiohttp instead of httpx #2161
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: avi/stack-1-access-log
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -7,7 +7,8 @@ | |||||||||||||||||||||||||||||||||||||||||||||
| import asyncio | ||||||||||||||||||||||||||||||||||||||||||||||
| from datetime import datetime, timezone | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| import httpx | ||||||||||||||||||||||||||||||||||||||||||||||
| import aiohttp | ||||||||||||||||||||||||||||||||||||||||||||||
| import orjson | ||||||||||||||||||||||||||||||||||||||||||||||
| from sqlmodel.ext.asyncio.session import AsyncSession | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| from skyrl.backends.renderer import render_model_input | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -19,6 +20,13 @@ | |||||||||||||||||||||||||||||||||||||||||||||
| from skyrl.utils.log import logger | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| class TransientInferenceError(RuntimeError): | ||||||||||||||||||||||||||||||||||||||||||||||
| """A 5xx from vllm-router/vLLM: the request was rejected, not executed, so it is safe to retry.""" | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| _ROUTER_CONNECT_TIMEOUT_SECONDS = 60.0 | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| class SkyRLTrainInferenceForwardingClient: | ||||||||||||||||||||||||||||||||||||||||||||||
| """Forwards EXTERNAL sample requests to the SkyRL-Train-managed vLLM.""" | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -36,27 +44,48 @@ def __init__( | |||||||||||||||||||||||||||||||||||||||||||||
| self.external_future_store = external_future_store | ||||||||||||||||||||||||||||||||||||||||||||||
| self._cached_proxy_url: str | None = None | ||||||||||||||||||||||||||||||||||||||||||||||
| self._cache_lock = asyncio.Lock() | ||||||||||||||||||||||||||||||||||||||||||||||
| # Backpressure layered: httpx pool -> vllm-router -> vLLM max_num_seqs. | ||||||||||||||||||||||||||||||||||||||||||||||
| # Default `forwarding_inference_max_connections=None` is unlimited; | ||||||||||||||||||||||||||||||||||||||||||||||
| # the only cost is file descriptors (raise `ulimit -n` accordingly). | ||||||||||||||||||||||||||||||||||||||||||||||
| max_conn = engine_config.forwarding_inference_max_connections | ||||||||||||||||||||||||||||||||||||||||||||||
| max_keepalive = max(max_conn // 4, 32) if max_conn is not None else None | ||||||||||||||||||||||||||||||||||||||||||||||
| self._http_client: httpx.AsyncClient = httpx.AsyncClient( | ||||||||||||||||||||||||||||||||||||||||||||||
| timeout=httpx.Timeout( | ||||||||||||||||||||||||||||||||||||||||||||||
| connect=10.0, | ||||||||||||||||||||||||||||||||||||||||||||||
| read=engine_config.forwarding_inference_timeout_sec, | ||||||||||||||||||||||||||||||||||||||||||||||
| write=300.0, | ||||||||||||||||||||||||||||||||||||||||||||||
| pool=300.0, | ||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||
| limits=httpx.Limits( | ||||||||||||||||||||||||||||||||||||||||||||||
| max_connections=max_conn, | ||||||||||||||||||||||||||||||||||||||||||||||
| max_keepalive_connections=max_keepalive, | ||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||
| # Created on first use so it binds to the serving event loop. | ||||||||||||||||||||||||||||||||||||||||||||||
| self._session: aiohttp.ClientSession | None = None | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| def _get_session(self) -> aiohttp.ClientSession: | ||||||||||||||||||||||||||||||||||||||||||||||
| """Return the shared aiohttp session, creating it on first use. | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| Backpressure is layered: connector limit -> vllm-router -> vLLM | ||||||||||||||||||||||||||||||||||||||||||||||
| max_num_seqs. Default `forwarding_inference_max_connections=None` is | ||||||||||||||||||||||||||||||||||||||||||||||
| unlimited; the only cost is file descriptors (raise `ulimit -n` | ||||||||||||||||||||||||||||||||||||||||||||||
| accordingly). Requests beyond the limit wait in the connector's FIFO | ||||||||||||||||||||||||||||||||||||||||||||||
| queue with no deadline, so a backlog of many thousands of samples | ||||||||||||||||||||||||||||||||||||||||||||||
| drains at the engine's pace instead of failing. | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| aiohttp rather than httpx: httpcore's pool rescans every connection | ||||||||||||||||||||||||||||||||||||||||||||||
| for every request, so its per-request CPU grows with the number of | ||||||||||||||||||||||||||||||||||||||||||||||
| in-flight samples (~28ms each at 512 in flight); aiohttp stays flat. | ||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||
| if self._session is None or self._session.closed: | ||||||||||||||||||||||||||||||||||||||||||||||
| max_conn = self.engine_config.forwarding_inference_max_connections | ||||||||||||||||||||||||||||||||||||||||||||||
| # keepalive_timeout must stay under the router's idle timeout so a | ||||||||||||||||||||||||||||||||||||||||||||||
| # pooled connection is never reused after the server closed it. | ||||||||||||||||||||||||||||||||||||||||||||||
| # Happy Eyeballs is off: a burst of connect timeouts cancels its | ||||||||||||||||||||||||||||||||||||||||||||||
| # sock_connect calls mid-flight, and under uvloop the closed sockets' | ||||||||||||||||||||||||||||||||||||||||||||||
| # descriptors get reused before the loop forgets them ("File | ||||||||||||||||||||||||||||||||||||||||||||||
| # descriptor N is used by transport"), failing unrelated forwards. | ||||||||||||||||||||||||||||||||||||||||||||||
| connector = aiohttp.TCPConnector(limit=max_conn or 0, keepalive_timeout=2, happy_eyeballs_delay=None) | ||||||||||||||||||||||||||||||||||||||||||||||
| self._session = aiohttp.ClientSession( | ||||||||||||||||||||||||||||||||||||||||||||||
| connector=connector, | ||||||||||||||||||||||||||||||||||||||||||||||
| timeout=aiohttp.ClientTimeout( | ||||||||||||||||||||||||||||||||||||||||||||||
| total=None, | ||||||||||||||||||||||||||||||||||||||||||||||
| # A saturated router can take tens of seconds to accept; | ||||||||||||||||||||||||||||||||||||||||||||||
| # that is queueing, not failure. | ||||||||||||||||||||||||||||||||||||||||||||||
| sock_connect=_ROUTER_CONNECT_TIMEOUT_SECONDS, | ||||||||||||||||||||||||||||||||||||||||||||||
| sock_read=self.engine_config.forwarding_inference_timeout_sec, | ||||||||||||||||||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||
| return self._session | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| async def aclose(self) -> None: | ||||||||||||||||||||||||||||||||||||||||||||||
| """Close the persistent httpx client. Called from api.py lifespan shutdown.""" | ||||||||||||||||||||||||||||||||||||||||||||||
| await self._http_client.aclose() | ||||||||||||||||||||||||||||||||||||||||||||||
| """Close the shared aiohttp session. Called from api.py lifespan shutdown.""" | ||||||||||||||||||||||||||||||||||||||||||||||
| if self._session is not None and not self._session.closed: | ||||||||||||||||||||||||||||||||||||||||||||||
| await self._session.close() | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| async def _read_proxy_url_from_db(self) -> str | None: | ||||||||||||||||||||||||||||||||||||||||||||||
| async with AsyncSession(self.db_engine) as session: | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -119,30 +148,31 @@ async def call_and_store_result( | |||||||||||||||||||||||||||||||||||||||||||||
| await session.commit() | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| async def _forward_with_retry(self, sample_req, model_id: str, *, base_model: str | None) -> types.SampleOutput: | ||||||||||||||||||||||||||||||||||||||||||||||
| # Retry only failures that occur before a request can reach vLLM. Read | ||||||||||||||||||||||||||||||||||||||||||||||
| # and write failures are ambiguous: vLLM may still be executing the | ||||||||||||||||||||||||||||||||||||||||||||||
| # Retry only failures where the request demonstrably did not execute: | ||||||||||||||||||||||||||||||||||||||||||||||
| # connect-phase errors and 5xx rejections from the router. Read and | ||||||||||||||||||||||||||||||||||||||||||||||
| # write failures are ambiguous: vLLM may still be executing the | ||||||||||||||||||||||||||||||||||||||||||||||
| # request, so retrying would duplicate generation load. | ||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||
| proxy_url = await self._resolve_proxy_url() | ||||||||||||||||||||||||||||||||||||||||||||||
| return await self._forward(proxy_url, sample_req, model_id, base_model=base_model) | ||||||||||||||||||||||||||||||||||||||||||||||
| except (httpx.ConnectError, httpx.ConnectTimeout) as e: | ||||||||||||||||||||||||||||||||||||||||||||||
| except (aiohttp.ClientConnectorError, aiohttp.ConnectionTimeoutError, TransientInferenceError) as e: | ||||||||||||||||||||||||||||||||||||||||||||||
| logger.warning( | ||||||||||||||||||||||||||||||||||||||||||||||
| "Connection error talking to %s (%s: %s) — refreshing proxy URL and retrying once", | ||||||||||||||||||||||||||||||||||||||||||||||
| "Transient error talking to %s (%s: %s) — refreshing proxy URL and retrying once", | ||||||||||||||||||||||||||||||||||||||||||||||
| self._cached_proxy_url, | ||||||||||||||||||||||||||||||||||||||||||||||
| type(e).__name__, | ||||||||||||||||||||||||||||||||||||||||||||||
| e, | ||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||
| proxy_url = await self._resolve_proxy_url(force_refresh=True) | ||||||||||||||||||||||||||||||||||||||||||||||
| return await self._forward(proxy_url, sample_req, model_id, base_model=base_model) | ||||||||||||||||||||||||||||||||||||||||||||||
| except httpx.ReadTimeout as e: | ||||||||||||||||||||||||||||||||||||||||||||||
| except aiohttp.SocketTimeoutError as e: | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+159
to
+168
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Catch the custom
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||
| # Not retried (see above). Long-context requests routinely exceed the | ||||||||||||||||||||||||||||||||||||||||||||||
| # default read deadline, so tell the caller how to raise it. The | ||||||||||||||||||||||||||||||||||||||||||||||
| # message is stored in the FutureDB ErrorResponse and shown to clients. | ||||||||||||||||||||||||||||||||||||||||||||||
| timeout_sec = self.engine_config.forwarding_inference_timeout_sec | ||||||||||||||||||||||||||||||||||||||||||||||
| raise RuntimeError( | ||||||||||||||||||||||||||||||||||||||||||||||
| f"Inference request to {self._cached_proxy_url} timed out after {timeout_sec:g}s waiting for " | ||||||||||||||||||||||||||||||||||||||||||||||
| "a response (httpx.ReadTimeout). The request was not retried because vLLM may still be " | ||||||||||||||||||||||||||||||||||||||||||||||
| "a response (read timeout). The request was not retried because vLLM may still be " | ||||||||||||||||||||||||||||||||||||||||||||||
| "executing it. If requests are expected to take this long (long prompts, large max_tokens, " | ||||||||||||||||||||||||||||||||||||||||||||||
| "or queueing behind other requests), increase the deadline with " | ||||||||||||||||||||||||||||||||||||||||||||||
| "`--forwarding-inference-timeout-sec` (EngineConfig.forwarding_inference_timeout_sec) or " | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -195,17 +225,22 @@ async def _forward( | |||||||||||||||||||||||||||||||||||||||||||||
| headers["X-Session-ID"] = session_id | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| url = f"{proxy_url}/v1/completions" | ||||||||||||||||||||||||||||||||||||||||||||||
| response = await self._http_client.post(url, json=payload, headers=headers) | ||||||||||||||||||||||||||||||||||||||||||||||
| if response.status_code >= 400: | ||||||||||||||||||||||||||||||||||||||||||||||
| raise RuntimeError(f"vLLM /v1/completions returned {response.status_code}: {response.text}") | ||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||
| result = response.json() | ||||||||||||||||||||||||||||||||||||||||||||||
| except ValueError as e: | ||||||||||||||||||||||||||||||||||||||||||||||
| # vllm-router can return HTML on transient errors even with 2xx status. | ||||||||||||||||||||||||||||||||||||||||||||||
| raise RuntimeError( | ||||||||||||||||||||||||||||||||||||||||||||||
| f"vLLM /v1/completions returned non-JSON ({response.status_code}, " | ||||||||||||||||||||||||||||||||||||||||||||||
| f"content-type={response.headers.get('content-type')!r}): {response.text[:512]}" | ||||||||||||||||||||||||||||||||||||||||||||||
| ) from e | ||||||||||||||||||||||||||||||||||||||||||||||
| async with self._get_session().post(url, json=payload, headers=headers) as response: | ||||||||||||||||||||||||||||||||||||||||||||||
| body = await response.read() | ||||||||||||||||||||||||||||||||||||||||||||||
| if response.status >= 500: | ||||||||||||||||||||||||||||||||||||||||||||||
| raise TransientInferenceError( | ||||||||||||||||||||||||||||||||||||||||||||||
| f"vLLM /v1/completions returned {response.status}: {body.decode(errors='replace')}" | ||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+230
to
+233
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||||||||||||||||||||||||||||||||||||||
| if response.status >= 400: | ||||||||||||||||||||||||||||||||||||||||||||||
| raise RuntimeError(f"vLLM /v1/completions returned {response.status}: {body.decode(errors='replace')}") | ||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||
| result = orjson.loads(body) | ||||||||||||||||||||||||||||||||||||||||||||||
| except orjson.JSONDecodeError as e: | ||||||||||||||||||||||||||||||||||||||||||||||
| # vllm-router can return HTML on transient errors even with 2xx status. | ||||||||||||||||||||||||||||||||||||||||||||||
| raise RuntimeError( | ||||||||||||||||||||||||||||||||||||||||||||||
| f"vLLM /v1/completions returned non-JSON ({response.status}, " | ||||||||||||||||||||||||||||||||||||||||||||||
| f"content-type={response.headers.get('content-type')!r}): {body[:512].decode(errors='replace')}" | ||||||||||||||||||||||||||||||||||||||||||||||
| ) from e | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+228
to
+243
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In try:
async with self._get_session().post(url, json=payload, headers=headers) as response:
try:
body = await response.read()
except asyncio.TimeoutError as e:
raise ReadTimeoutError("Read timeout") from e
if response.status >= 500:
raise TransientInferenceError(
f"vLLM /v1/completions returned {response.status}: {body.decode(errors='replace')}"
)
if response.status >= 400:
raise RuntimeError(f"vLLM /v1/completions returned {response.status}: {body.decode(errors='replace')}")
try:
result = orjson.loads(body)
except orjson.JSONDecodeError as e:
# vllm-router can return HTML on transient errors even with 2xx status.
raise RuntimeError(
f"vLLM /v1/completions returned non-JSON ({response.status}, "
f"content-type={response.headers.get('content-type')!r}): {body[:512].decode(errors='replace')}"
) from e
except asyncio.TimeoutError as e:
raise ConnectionTimeoutError("Connection timeout") from e |
||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| prompt_logprobs = None | ||||||||||||||||||||||||||||||||||||||||||||||
| topk = None | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
aiohttplibrary does not defineConnectionTimeoutErrororSocketTimeoutErrorexceptions. Referencing them will raise anAttributeErrorat runtime.To correctly handle and distinguish connection timeouts from read timeouts, we can define custom
ConnectionTimeoutErrorandReadTimeoutErrorexceptions.