[perf][tinker] Resolve awaited requests with one shared batched poller - #1978
Open
avigyabb wants to merge 8 commits into
Open
[perf][tinker] Resolve awaited requests with one shared batched poller#1978avigyabb wants to merge 8 commits into
avigyabb wants to merge 8 commits into
Conversation
avigyabb
marked this pull request as ready for review
August 4, 2026 18:29
Contributor
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.
avigyabb
force-pushed
the
tinker-shared-future-waiter
branch
from
August 7, 2026 18:59
50837b4 to
c003bc1
Compare
`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>
…l tick Two simplifications to the shared poller: - retrieve_future checks row existence with one indexed SELECT before registering a waiter, so 404 is immediate again (matching the pre-poller behavior) and the poller no longer distinguishes missing rows from pending ones. This removes the KeyError-as-future-exception plumbing and the missing-id bookkeeping in _poll_once, whose terminal-status filter now lives in the WHERE clause. - Drop the _wakeup event: the poll loop sleeps one tick unconditionally and skips the query when there are no waiters. An idle wakeup 20x/s is free, and the event only saved the first waiter after idle at most one tick, which is no better than any other waiter gets. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
Results stay in the PR description; the script does not need to live in the repo. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
The endpoint ran a dedicated `SELECT request_id` purely to decide 404, threw the row away, and then handed off to the waiter -- which in turn had to document that unknown ids "just time out", since existence had become someone else's job. Selecting status and result_data in that same query collapses the three concerns into one sequence: look up once, return if already terminal, otherwise wait. It is a net three lines shorter, drops a poll tick of latency for a request that finished before the client asked (a real case when a client reconnects and re-polls), and lets `FutureWaiter.wait` go back to meaning simply "wait for a pending request", so `test_wait_times_out_for_unknown_request` no longer needs to exist to pin a quirk. Behaviour is unchanged: same 200/400/404/408/500 responses. The lookup and the wait remain separate statements, so a request completing between them is still picked up by the waiter. Adds endpoint-level tests for the paths this touches, which previously had none: a terminal request is served without consulting the waiter, a pending one delegates to it exactly once, and an unknown id 404s without waiting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
The class held no state of its own beyond the registry and the poller task, both of which the app already owns, so it was indirection without a payoff. `wait_for_future` and `poll_futures` take the registry explicitly, the lifespan holds it in `app.state`, and skyrl/tinker/futures.py goes away. Folding the existence check into the wait path at the same time: retrieve_future already had to query for a 404, so selecting status and result_data instead of just the id makes that one query serve three purposes -- 404 handling, an immediate answer for an already-finished request, and the hand-off to the poller. That removes a poll tick of latency for any client reconnecting to a request that finished while it was away, and the waiter no longer has to document that unknown ids "just time out" because existence is someone else's job. Waiters stay a set per request_id rather than one shared future. Concurrent waiters on one id are routine rather than defensive: the SDK gives a retrieve_future call 45s (api_future_impl.py `timeout=45`) and retries the same request_id on timeout, while this endpoint holds the request for up to 300s, so anything slower than 45s accumulates overlapping waiters -- in the fully-async runs measured earlier, samples took 600-750s. A single shared future would also linger in the registry when every waiter gives up, and an EXTERNAL request whose completion write failed never reaches a terminal status, so that entry would be pinned forever and grow the poll query without bound. Per-waiter cleanup drops those entries. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
`main` is currently failing pre-commit's black hook: NovaSky-AI#1920 added a test immediately above the `forward_backward_payload` helper introduced by NovaSky-AI#1992 and left one blank line between them instead of two, so `check_code_quality` fails on every open PR rather than just this one. Whitespace only. Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
avigyabb
force-pushed
the
tinker-shared-future-waiter
branch
from
August 7, 2026 19:46
728af20 to
8acaab7
Compare
futures.py is gone -- the waiting logic lives in api.py -- so test_futures.py was named after a module that no longer exists. Renamed to test_future_waiting.py after what it actually covers. Dropped four tests: - test_poller_survives_a_failing_iteration was vacuous. It disposed the engine to force an error, but SQLAlchemy simply opens a new connection after dispose, so the `except Exception` branch was never reached -- verified by counting logger.exception calls, which stayed at zero. It passed for the wrong reason. Covering that branch needs real failure injection; worth adding separately rather than leaving a test that claims coverage it does not provide. - test_returns_none_on_timeout duplicated the surviving abandoned-request test, which asserts the same None plus that the registry entry is gone. - test_multiple_waiters_on_same_request_all_resolve is subsumed by test_one_waiter_giving_up_does_not_strand_the_others, which covers fan-out and cancellation isolation together. - test_retrieve_future_waits_while_pending is covered for real by every test_api.py integration test, all of which drive pending -> wait against a live server. The seven that remain each pin a distinct behaviour: resolution, FAILED as a terminal status, timeout plus registry cleanup, cancellation isolation between waiters, query count not scaling with waiters, the already-terminal fast path, and the 404. Signed-off-by: Avi Basnet <avigyabb@stanford.edu>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
retrieve_futurewaited by polling per caller: every in-flight request opened its ownAsyncSessionand ran its ownSELECTon a 100 ms → 1 s backoff. Database load therefore scaled with concurrency, and because a session checkout comes from a pool the whole API server shares, those pollers starved every other endpoint.This replaces it with one background task that resolves all waiters with a single batched query per tick.
Independent of the engine-side PRs in this series (#1992) — that one is the engine subprocess, this is the API server — so it can land in any order.
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 needing the database queues behind them.
session_heartbeatis the canary — a trivial single-rowUPDATEthat should never be slow. Measured at the DB layer (no GPU, no HTTP server), 2048 concurrent waiters:session_heartbeatp50session_heartbeatmaxThis also reproduces a failure reported from a real run. A user on FSDP fully-async RL hit
sqlalchemy.exc.TimeoutError: QueuePool limit of size 5 overflow 10 reached, connection timed out, timeout 30.00alongside HTTP 500s on/asample. Replaying that shape at the DB layer — 8192 concurrent waiters, which is whatgroups_per_batch=512 × group_size=4with several batches off-policy produces — gives:QueuePooltimeouts → HTTP 500At 2048 waiters main peaks around 8 s — bad but under the ceiling; at 8192 it sits at 21–29 s and starts crossing, which is why the report describes intermittent warnings rather than constant failure.
Worth being precise about the causal chain there, because the original report guessed at it: the weight-update pause doesn't reject generation. It stalls forwarded samples, so in-flight requests pile up, so more concurrent pollers exist, and that exhausts the pool — which is exactly why it clusters around weight updates.
How it works
Two module-level functions in
api.pyover adict[int, set[asyncio.Future]]held onapp.state:wait_for_future(waiters, request_id, timeout)registers a future and awaits it, returning(status, result_data)orNoneon timeout.poll_futures(db_engine, waiters, interval)runs as a lifespan-managed task, and every 50 ms issues oneWHERE request_id IN (...) AND status IN (COMPLETED, FAILED)covering every waiter.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 ~1 s of latency for a request that finished just after a poll.
Details worth reviewing:
retrieve_futurecall after 45 s and retries the samerequest_id, while this endpoint holds for up to 300 s, so anything slower than 45 s accumulates overlapping waiters. Per-caller futures mean a caller giving up removes only its own entry, and an abandoned request can't pin an entry inwaitersforever and grow the poll query without bound.status, result_data, so a missing row is the 404, an already-terminal row returns immediately without waiting a tick, and only a pending row falls through towait_for_future. Query once → return if terminal → otherwise wait.CancelledErroris re-raised ahead of the broad handler so shutdown isn't swallowed.app.state.future_poller, which also keeps a strong reference so the task can't be garbage-collected mid-execution.RETRIEVE_FUTURE_TIMEOUT_SECONDS.Risk
This is the riskiest 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 and the same bodies — andtest_api.py's integration tests exercise it end to end against a real server subprocess, which is the main evidence here.One behaviour note: a 404 for an unknown id is now decided by the endpoint's own lookup, so it is still immediate.
Testing
11 tests in
tests/tinker/test_futures.py, covering the waiting primitives and the endpoint:Noneon timeoutTwo caveats on coverage: there is no GPU E2E run behind this (
tests/tinker/skyrl_train/is excluded and needs 4 GPUs), and the QueuePool reproduction above is at the DB layer — real SQLAlchemy engine, real pool, real waiting code, but no HTTP stack, no vLLM, and no CPU contention from a trainer. So the mechanism and its fix are demonstrated; a full fully-async E2E confirmation is still wanted.🤖 Generated with Claude Code