Skip to content

[perf][tinker] Resolve awaited requests with one shared batched poller - #1978

Open
avigyabb wants to merge 8 commits into
NovaSky-AI:mainfrom
avigyabb:tinker-shared-future-waiter
Open

[perf][tinker] Resolve awaited requests with one shared batched poller#1978
avigyabb wants to merge 8 commits into
NovaSky-AI:mainfrom
avigyabb:tinker-shared-future-waiter

Conversation

@avigyabb

@avigyabb avigyabb commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

What

retrieve_future waited by polling per caller: every in-flight request opened its own AsyncSession and ran its own SELECT on 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_heartbeat is the canary — a trivial single-row UPDATE that should never be slow. Measured at the DB layer (no GPU, no HTTP server), 2048 concurrent waiters:

per-caller polling shared poller
polls demanded vs. achieved 2048/s → 1215/s (pool saturated) 1 query per 50 ms tick, flat in waiter count
session_heartbeat p50 778 ms 2 ms
session_heartbeat max 3820 ms 7 ms

This 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.00 alongside HTTP 500s on /asample. Replaying that shape at the DB layer — 8192 concurrent waiters, which is what groups_per_batch=512 × group_size=4 with several batches off-policy produces — gives:

same load, 120 s main this PR
QueuePool timeouts → HTTP 500 1 0
worst submit latency 21–29 s (against the 30 s ceiling) 0.37–1.58 s
submits completed 1,090 62,582

At 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.py over a dict[int, set[asyncio.Future]] held on app.state:

  • wait_for_future(waiters, request_id, timeout) registers a future and awaits it, returning (status, result_data) or None on timeout.
  • poll_futures(db_engine, waiters, interval) runs as a lifespan-managed task, and every 50 ms issues one WHERE 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:

  • Each caller gets its own future, not one shared per id. Concurrent waiters on one id are routine rather than exotic: the SDK gives up on a retrieve_future call after 45 s and retries the same request_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 in waiters forever and grow the poll query without bound.
  • The endpoint's first query does three jobs at once. It fetches 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 to wait_for_future. Query once → return if terminal → otherwise wait.
  • The poller survives errors. A failed iteration is logged and the loop continues, so a transient database problem can't permanently wedge every waiter; callers still have their own timeouts. CancelledError is re-raised ahead of the broad handler so shutdown isn't swallowed.
  • Shutdown cancels and awaits the task. The handle lives on app.state.future_poller, which also keeps a strong reference so the task can't be garbage-collected mid-execution.
  • No id chunking. SQLite has capped bound parameters at 32766 since 3.32 and Postgres at 65535, both far above any plausible in-flight count.
  • Timeout behaviour is unchanged — still 408 after 300 s, now via the named 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_future observes completion. The endpoint's external contract is unchanged — same 200/400/404/408/500 responses and the same bodies — and test_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

uv run --isolated --extra dev --extra jax --extra tinker pytest tests/tinker/ --ignore=tests/tinker/skyrl_train

11 tests in tests/tinker/test_futures.py, covering the waiting primitives and the endpoint:

  • resolution once a request completes; failed status surfaced distinctly; None on timeout
  • an abandoned request leaves no entry behind — the leak the per-caller design exists to prevent
  • one waiter giving up does not strand the others, and multiple waiters on one id all resolve
  • query count does not scale with waiter count — 50 concurrent waiters over several ticks must issue fewer than 50 statements, which is the whole point and would fail loudly if per-caller polling came back
  • the poller survives a failing iteration
  • endpoint: a terminal request is served without consulting the poller, a pending one waits, an unknown id 404s

Two 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

@avigyabb
avigyabb marked this pull request as ready for review August 4, 2026 18:29
@avigyabb
avigyabb requested a review from erictang000 August 4, 2026 18:30

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread skyrl/tinker/futures.py Outdated
Comment thread skyrl/tinker/futures.py Outdated
Comment thread skyrl/tinker/api.py Outdated
@avigyabb
avigyabb force-pushed the tinker-shared-future-waiter branch from 50837b4 to c003bc1 Compare August 7, 2026 18:59
@avigyabb
avigyabb requested a review from pcmoritz August 7, 2026 19:39
avigyabb and others added 7 commits August 7, 2026 19:44
`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
avigyabb force-pushed the tinker-shared-future-waiter branch from 728af20 to 8acaab7 Compare August 7, 2026 19:46
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant