[perf][tinker] Resolve awaited requests with one shared batched poller - #1978
[perf][tinker] Resolve awaited requests with one shared batched poller#1978avigyabb wants to merge 1 commit into
Conversation
`retrieve_future` waited by polling per caller: every in-flight request opened its own AsyncSession and ran its own SELECT on a 100ms->1s backoff. Database load therefore scaled with concurrency, and since a session checkout comes from a pool the whole API server shares, pollers starved every other endpoint. The API's async engine takes SQLAlchemy's defaults -- 15 connections (5 + 10 overflow) with a 30s checkout timeout, nothing configures them -- so a few thousand concurrent rollouts leaves thousands of pollers contending for 15 slots. `FutureWaiter` keeps a dict of request_id -> awaiting asyncio futures. One background task polls every 50ms with a single `WHERE request_id IN (...)` covering every waiter and resolves them. Because the query is shared, the interval can be tighter than the old backoff while doing far less work, so this also removes up to ~1s of latency for a request that finished just after a poll. Measured with the new skyrl/benchmarks/bench_future_waiting.py (no GPU, no HTTP server) at 2048 concurrent waiters, using session_heartbeat -- a trivial single-row UPDATE -- as the canary for pool starvation: polls demanded vs achieved 2048/s -> 1215/s | 1 query per tick session_heartbeat p50 778ms -> 2ms session_heartbeat max 3820ms -> 7ms The demanded-vs-achieved gap is the tell: the pool is the ceiling, so pollers back up and anything sharing the pool queues behind them. The stuck Tinker Fully Async E2E job shows the field version of this, logging `Session heartbeat failed for 120-220 seconds` from its first sampling wave on a 2048-concurrent-sample configuration. I could not reproduce the full 120s+ on an uncontended box (~4s was the worst I saw), so this is not claimed as that job's sole cause, but it is the same mechanism and it is removed here. Behaviour of the endpoint is unchanged: same 200/400/404/408/500 responses and the same body. 404 detection now costs up to one 50ms tick instead of being immediate, since a missing row is only observed on a poll. `FutureWaiter.notify()`, which would let the sample-forwarding path hand results straight to waiters, is deliberately left out because it needs the result-write changes from a separate PR. Forwarded samples are simply picked up on the next tick meanwhile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
There was a problem hiding this comment.
Code Review
This pull request introduces a shared future waiting mechanism (FutureWaiter) to optimize database connection pool usage by batching queries for in-flight requests, replacing the previous per-caller polling approach. It includes a benchmark script, integration into the FastAPI application, and unit tests. The review feedback highlights three key areas for improvement: explicitly cancelling pending waiters during shutdown to prevent hangs, implementing exponential backoff on background poller failures to avoid log flooding, and handling potential formatting errors when parsing request_id to prevent unhandled 500 errors.
| async def stop(self) -> None: | ||
| """Stop the background poller.""" | ||
| if self._task is not None: | ||
| self._task.cancel() | ||
| with suppress(asyncio.CancelledError): | ||
| await self._task | ||
| self._task = None |
There was a problem hiding this comment.
In FutureWaiter.stop(), the background poller task is cancelled and awaited. However, any active wait() calls currently awaiting results will be left hanging until their individual timeouts (up to 300 seconds) expire, because the background poller is no longer running to resolve them. This can cause the API server's graceful shutdown (e.g., during redeployments or scaling down) to hang or delay significantly.
To prevent this, we should explicitly cancel all pending waiters in stop() so they fail fast and allow the server to shut down immediately.
async def stop(self) -> None:
"""Stop the background poller."""
if self._task is not None:
self._task.cancel()
with suppress(asyncio.CancelledError):
await self._task
self._task = None
for waiters in list(self._waiters.values()):
for waiter in waiters:
if not waiter.done():
waiter.cancel()| async def _run(self) -> None: | ||
| while True: | ||
| if not self._waiters: | ||
| # Safe against lost wakeups: registration adds to _waiters and | ||
| # sets the event with no await in between, so an empty dict here | ||
| # means nothing has been registered yet. | ||
| self._wakeup.clear() | ||
| await self._wakeup.wait() | ||
| continue | ||
| try: | ||
| await self._poll_once() | ||
| except asyncio.CancelledError: | ||
| raise | ||
| except Exception: | ||
| # Keep the poller alive; waiters fall back on their own timeouts. | ||
| logger.exception("Future poller iteration failed") | ||
| await asyncio.sleep(self._poll_interval_sec) |
There was a problem hiding this comment.
If the database becomes permanently unavailable or experiences a transient outage, the background poller loop will fail on _poll_once() and immediately log an exception, then sleep for only self._poll_interval_sec (50ms) before retrying. This can rapidly flood the application logs with thousands of stack traces and put unnecessary connection pressure on a struggling database.
Implementing a simple exponential backoff on consecutive failures will mitigate log flooding and allow the database room to recover.
async def _run(self) -> None:
consecutive_failures = 0
while True:
if not self._waiters:
# Safe against lost wakeups: registration adds to _waiters and
# sets the event with no await in between, so an empty dict here
# means nothing has been registered yet.
self._wakeup.clear()
await self._wakeup.wait()
consecutive_failures = 0
continue
try:
await self._poll_once()
consecutive_failures = 0
except asyncio.CancelledError:
raise
except Exception:
consecutive_failures += 1
sleep_sec = min(self._poll_interval_sec * (2 ** consecutive_failures), 5.0)
logger.exception("Future poller iteration failed, retrying in %.2fs", sleep_sec)
await asyncio.sleep(sleep_sec)
continue
await asyncio.sleep(self._poll_interval_sec)| try: | ||
| result = await req.app.state.future_waiter.wait(int(request.request_id), RETRIEVE_FUTURE_TIMEOUT_SECONDS) | ||
| except KeyError: | ||
| raise HTTPException(status_code=404, detail="Future not found") |
There was a problem hiding this comment.
If a client sends a non-integer or malformed request_id in the RetrieveFutureRequest, calling int(request.request_id) will raise a ValueError. This unhandled exception will propagate up and result in a 500 Internal Server Error.
We should catch ValueError and raise a 400 Bad Request with a clear error message to improve API robustness.
| try: | |
| result = await req.app.state.future_waiter.wait(int(request.request_id), RETRIEVE_FUTURE_TIMEOUT_SECONDS) | |
| except KeyError: | |
| raise HTTPException(status_code=404, detail="Future not found") | |
| try: | |
| request_id = int(request.request_id) | |
| except ValueError: | |
| raise HTTPException(status_code=400, detail="Invalid request_id format") | |
| try: | |
| result = await req.app.state.future_waiter.wait(request_id, RETRIEVE_FUTURE_TIMEOUT_SECONDS) | |
| except KeyError: | |
| raise HTTPException(status_code=404, detail="Future not found") |
What
retrieve_futurewaited by polling per caller: every in-flight request opened its ownAsyncSessionand ran its ownSELECTon a 100 ms → 1 s backoff. That makes database load scale with concurrency, and — because a session checkout comes from a pool the whole API server shares — it starves every other endpoint (training loop, weight sync, session liveness, setup/bookkeeping).This replaces it with one background task that resolves all waiters using a single batched query per tick.
Independent of #1976 and #1977 (those are engine-side; this is API-side), and applies directly to
main.Why it matters more than it looks
The API's async engine takes SQLAlchemy's defaults: 15 connections (5 + 10 overflow) with a 30 s checkout timeout. Nothing configures them. So with a few thousand concurrent rollouts, thousands of pollers contend for 15 slots, and anything else that needs the database queues behind them.
session_heartbeatis the canary — a trivialUPDATEon one row ofsessions, which should never be slow. Measured withskyrl/benchmarks/bench_future_waiting.py(no GPU, no HTTP server) at 2048 concurrent waiters:session_heartbeatp50session_heartbeatmaxThe poll rate not keeping up with demand is the tell: the pool is the ceiling, so pollers back up and everything sharing the pool waits behind them.
This is also visible in the field. The stuck Tinker Fully Async E2E job logs
Session heartbeat failed for 120–220 secondsfrom its first sampling wave onward, on a run configured forgroups_per_batch=512 × group_size=4= 2048 concurrent samples. I could not reproduce the full 120 s+ on an uncontended box (I got to ~4 s), so I would not claim this as that job's sole cause — the box also has an FSDP trainer, two vLLM engines and a router competing for CPU, and generation was already slow before anything wedged. But the mechanism is the same one, and it is removed here.How it works
FutureWaiterkeeps adict[request_id, set[asyncio.Future]].wait()registers a future and awaits it; a single background task polls every 50 ms with oneWHERE request_id IN (...)covering every waiter, then resolves them.Because the query is shared, the interval can be tighter than the old backoff while doing far less work — so this also cuts latency, by up to the ~1 s the old backoff could add to a request that finished just after a poll.
Details worth reviewing:
asyncio.Eventrather than spinning. Registration adds to the dict and sets the event with noawaitin between, so a wakeup cannot be lost — that ordering is load-bearing and commented as such.KeyError, which the endpoint turns into a 404. Previously this was detected on the first poll; now it takes up to one tick (50 ms).RETRIEVE_FUTURE_TIMEOUT_SECONDS) rather than a literal.Risk
This is the highest-risk chunk of the series, which is why it is on its own: it adds a background task to the app lifespan and changes how
retrieve_futureobserves completion. The endpoint's external contract is unchanged — same 200/400/404/408/500 responses, same body — andtest_api.py's integration tests exercise it end to end against a real server subprocess, which is the main evidence here.Not included
FutureWaiter.notify()— letting the sample-forwarding path hand its result straight to waiters instead of waiting for the poller to rediscover its own write — is deliberately left out, because it needs the result-write changes that are in a separate PR. Without it, forwarded samples are simply discovered on the next 50 ms tick, which is still far better than before.Testing
New in
tests/tinker/test_futures.py:KeyErrorfor an unknown request (the 404 path)Noneon timeout (the 408 path)Unrelated flake, worth knowing: running the suite twice in a row fails
test_api.py::test_training_workflowand::test_delete_checkpointwith 404s, because the defaultdatabase_urlis a persistent file in the source tree (skyrl/tinker/tinker.db) and consecutive runs contaminate each other. Pre-existing onmain.rm -f skyrl/tinker/tinker.db*before rerunning.🤖 Generated with Claude Code