Repository navigation
perf: pre-warm upstream realtime connection pool to cut session-establishment latency - #31163
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
70baf2e to
5ee042c
Compare
…v stand-in) Minimal multi-stage image for load-testing the realtime pool: builds the gateway with default features (no python-config, no libpython), runs on a debian-slim base (~157MB), and reads model_list from the OPENAI_REALTIME_MODEL env stand-in. No config.yaml or pip install needed. Build context is the repo root; only litellm-rust/ is included via the sidecar .dockerignore.
|
|
Teaches an agent how to benchmark any ai-gateway endpoint: deploy the gateway, run one load generator against both provider-direct and the gateway with the same protocol, phase-decompose latency (dial/session/first-token/total), compare at scale, and report success% + p50/p95. Documents the benchmarks/<endpoint>/ layout and the hard no-committed-keys rule.
The measured results table lives in the PR description (numbers go stale in a committed README). The benchmarks/realtime/ dir now holds only the sanitized load-gen harness; the generic method is in benchmarks/SKILL.md.
Copies the ws-bench Go load generator (main.go, go.mod, go.sum, Dockerfile, run.sh) into benchmarks/realtime/. Measures dial/session/first-audio/total per WebSocket connection against both OpenAI-direct and the gateway. No keys are hardcoded — the bearer token comes from -key / $OPENAI_API_KEY; default host is api.openai.com.
Render one-off jobs don't surface stdout via the Logs API, so on a hosted runner the long-lived web service runs the leg and PUBLISHES the result: serve.sh decodes the base64 flag list, runs wsbench teeing output to /tmp/web/result.txt, then serves it over HTTP so the result is fetchable at /result.txt. No secrets are written to the served file (the -key is only in wsbench's argv). The Dockerfile now copies both run.sh and serve.sh.
Remove any prior result.txt at startup and write the new run to a .partial file that's atomically moved into place only once complete. Prevents a fetcher from reading a previous run's numbers while the current run is still in flight.
…t scale The replenisher dialed missing warm sockets sequentially, so a full refill cost needed x handshake (~needed x 350ms). Under high connect rates the pool drained faster than it refilled and ~85% of connects missed (measured: only ~15% pool hits at 5000/500). Firing the dials together with join_all refills in ~one handshake window, keeping warm supply close to peak concurrent connects so the sub-millisecond warm handoff becomes the median rather than the lucky-hit tail. Each warm_one dial is independent (no shared state until the final push under the lock), so concurrent refill is safe. Pool unit tests unchanged and passing.
The lean load-test image isn't worth carrying in the repo; deploy the gateway however you normally do and set the pool env vars.
…repo) Drop the Go load-gen, Dockerfile, run.sh, serve.sh, and the generic SKILL.md from the repo. The harness now lives at github.com/ishaan-berri/litellm-realtime-bench; benchmarks/realtime/README.md carries the results table and links there for repro.
Pooling is now documented as a section in src/routes/realtime/README.md next to the code (handoff diagram, sizing rule, config, notes) instead of the standalone REALTIME_POOL_DESIGN.md RFC. Update the realtime_pool.rs doc pointer to it.
Use .into_iter().flatten() instead of an if-let-Ok in the for loop over the join_all results, and let rustfmt wrap it. Clears the CI clippy -D warnings failure; fmt + clippy + cargo test all green locally.
Greptile SummaryThis PR adds a pre-warmed upstream WebSocket connection pool to the Rust ai-gateway's realtime route, eliminating the per-connect
Confidence Score: 4/5The fallback to fresh-dial on any pool miss means the pool cannot break realtime sessions for correctness, but one gap in The overall design is sound and the cold-path fallback ensures the pool is never a correctness dependency. However, litellm-rust/crates/providers/src/realtime_pool.rs — specifically the
|
| Filename | Overview |
|---|---|
| litellm-rust/crates/providers/src/realtime_pool.rs | New file implementing the pre-warmed connection pool. Exponential backoff, liveness checks, and concurrent refill are all present, but warm_one accepts any JSON first-frame as the session.created event without validating the event type — meaning application-level error events from OpenAI would be silently cached and handed to clients. |
| litellm-rust/crates/providers/src/realtime.rs | Refactored to expose dial_upstream, splice, and read_event so the pool can reuse them; the public realtime function is preserved with identical semantics, and the new realtime_warm correctly relays the buffered prelude before splicing. |
| litellm-rust/crates/ai-gateway/src/main.rs | Pool built and registered at startup; MissedTickBehavior::Skip is set, the disabled path is handled, and the weak-ref shutdown guard is correct. |
| litellm-rust/crates/ai-gateway/src/routes/realtime/service.rs | Warm path added cleanly before the cold-dial fallback; pool is never on the correctness path — a miss or dead socket falls through to fresh-dial exactly as before. |
| litellm-rust/crates/ai-gateway/src/routes/realtime/mod.rs | Propagates the pool through the bridge closure; axum-layer changes are minimal and correct. |
| litellm-rust/crates/ai-gateway/src/state.rs | Adds realtime_pool: Arc<RealtimePool> to shared state; trivial and correct. |
| litellm-rust/crates/core/src/router/mod.rs | Adds a read-only deployments() accessor so main.rs can enumerate upstreams without coupling to internals; no behavior change. |
| litellm-rust/crates/providers/src/lib.rs | Adds pub mod realtime_pool; one-line change, correct. |
Comments Outside Diff (1)
-
litellm-rust/crates/providers/src/realtime_pool.rs, line 1051-1062 (link)First event not validated as
session.createdwarm_onereads the first text frame from OpenAI and stores it unconditionally insession_created, butread_eventaccepts any parseable JSON — it doesn't checkevent_type. If OpenAI sends an application-level error as the first frame (e.g. a rate-limit or model-not-found event accepted at the WS level)warm_onestill returnsOk,any_okbecomestrue, backoff is cleared, and every subsequent client on this key receives that error event relayed in place ofsession.created. A one-line guard afterread_eventwould fix this: reject any event whoseevent_type != "session.created"by returningErr(CoreError::InvalidResponse(...)), so the socket is discarded and the failure is correctly counted toward backoff.
Reviews (3): Last reviewed commit: "docs: document the replenisher backoff i..." | Re-trigger Greptile
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
A key whose warm-up dials all fail (invalid credentials, an unreachable upstream) was re-dialed on every 250 ms tick — firing 'needed' concurrent TLS connection attempts against the upstream indefinitely, risking rate-limit exhaustion that would degrade valid cold-path traffic. Add per-key exponential backoff (500 ms base, doubling, 30 s cap): after a pass whose dials all fail, the key is skipped until its retry window elapses; any successful dial resets it. The backoff map has its own lock so the request hot path (take) never contends on it. Two tests added: a broken key enters backoff and is not re-dialed during the window; a healthy key never backs off.
|
@greptileai please re-review — addressed the replenisher backoff finding. The background replenisher now puts a key into exponential backoff (500 ms base, doubling, 30 s cap) after a pass whose warm-up dials all fail (invalid credentials / unreachable upstream), instead of firing |
|
@greptileai review |
tin-berri
left a comment
There was a problem hiding this comment.
- Fix is_dead to not treat Ping/Pong as death (+ a regression test) — otherwise the optimization silently degrades at steady state.
- Drop the clippy::too_many_arguments allow by bundling the upstream tx/rx into a struct — the repo doesn't suppress lints, it fixes the type.
- Batch the refill push under one lock (folds together with replacing the any_ok mutable flag), and cap REALTIME_POOL_SIZE / add the idle-cost + ping caveats to the README.
6072019
into
litellm_internal_staging
Relevant issues
Latency optimization for the Rust ai-gateway realtime path. Re-benchmarked live on Render at 5000 calls / 500 concurrency, 10 gateway instances.
Pre-Submission checklist
Type
🚄 Infrastructure
🆕 New Feature
Changes
A latency optimization on the Rust ai-gateway realtime path.
The problem
The gateway's only added cost over direct-to-OpenAI is session establishment: on each client connect it dials a fresh upstream WS to OpenAI and blocks until
session.created. Dial, first-audio and streaming add ~0.What this does
A pre-warmed upstream connection pool keyed by
(model, api_key, api_base). A background task keeps OpenAI realtime sockets already connected and already pastsession.created(buffered). On a client connect we take a warm socket and relay its bufferedsession.createdinstantly via a local handoff (aVec::pop+ liveness check — sub-millisecond, no network), then splice exactly as today. On a pool miss or dead warm socket we fall back to fresh-dial — the pool is never on the critical path for correctness, only latency.Config:
REALTIME_POOL_SIZE(default4;0disables → fresh-dial) andREALTIME_POOL_MAX_IDLE_SECS(default30).Concurrent refill (makes it land at scale)
The replenisher originally dialed missing warm sockets sequentially, so a full refill cost
needed × handshake(~needed × 350 ms). Under high connect rates the pool drained faster than it refilled — at 5000/500 only ~15% of connects hit the pool, so the median connect still paid the full handshake. This PR fires the refill dials concurrently (join_all), refilling in ~one handshake window. Result: pool hit rate went 15% → ~76% and median session 357 ms → 0 ms.Replenisher safety (review follow-ups)
neededconcurrent TLS dials every 250 ms tick — bounds connection attempts so a single misconfigured deployment can't exhaust upstream rate limits and degrade valid cold-path traffic. Resets on the first successful dial; its own lock keeps the request hot path (take) contention-free.MissedTickBehavior::Skipon the replenish interval, so a slow tick doesn't trigger catch-up bursts.UpstreamKeyDebug redactsapi_key([REDACTED]) so the credential can't leak via logs.Benchmark results (5000 calls / 500 concurrency, 10 instances)
All times in milliseconds. Each leg run twice; values pooled across both runs. Harness: ishaan-berri/litellm-realtime-bench (ws-bench), run from an 8-vCPU Render runner against OpenAI-direct and the deployed gateway. Phases per WebSocket connection:
session.created(the phase the pool removes)response.createsent → first audio delta (OpenAI inference)Direct vs Gateway (pool ON), with overhead and where the gateway is faster:
The gateway is faster than direct OpenAI on 4 of 6 metrics (negative overhead): dial and the total tail (p95/p99), because the runner + gateway are co-located in Render Oregon while OpenAI's edge is further, and the warm handoff makes session sub-millisecond. ¹ The two "slower" rows track a
1st-audiodifference (440 → 664 ms) between the legs.1st-audiois OpenAIs own inference time, which the gateway only relays — but the legs ran at different times, so this gap cannot yet be attributed to OpenAI-side variance vs a possible warm-socket penalty without an interleaved A/B (alternating direct/gateway connections within one run so OpenAI variance cancels). Defensible claim: session-establishment overhead is eliminated (−7 ms p50, sub-1ms warm handoff); end-to-endtotalis inconclusive pending that controlled run.Gateway session overhead vs direct: pool OFF +360 ms p50 → pool ON −7 ms p50. The warm handoff is sub-millisecond, so the median connect's session phase collapses to ~0. ~76% of connects hit the pool; ~70% had session < 1 ms. The remaining ~24% miss and fresh-dial (~350 ms), which is the p95/p99 session tail — raise
REALTIME_POOL_SIZEto shrink it.Pool sizing: size to peak concurrent connects per instance,
REALTIME_POOL_SIZE ≈ peak_concurrency / instances. This run used 64/instance for ~500 concurrency over 10 instances. The concurrent warm-up (≈64 dials/instance at boot) was accepted by OpenAI with no rate-limit errors.Tests
Pool mechanics unit-tested against an in-process fake OpenAI realtime WS server: warm handoff relays the buffered
session.created; pool miss falls back to fresh-dial; dead/stale warm socket is discarded; background replenisher tops up a registered key; a broken key enters backoff and is not re-dialed during the window; a healthy key never backs off.cargo test -p litellm-providers→ all pool tests pass (concurrent refill + backoff included).Docs in this PR
src/routes/realtime/README.md— pooling design next to the code: handoff diagram, sizing rule, config knobs, caveats.benchmarks/realtime/README.md— the results table above + how to reproduce.The load-gen harness lives in its own repo so it doesn't bloat litellm: https://github.com/ishaan-berri/litellm-realtime-bench (no keys hardcoded; bearer token via
-key/$OPENAI_API_KEY).