17 KiB
Zurich Performance Investigation
This document tracks two independent but compounding issues discovered during performance testing:
- Prometheus callback overhead — latency added only when Prometheus is enabled
- Baseline latency spikes — large spikes that occur even when callbacks are disabled
Problem 1: Prometheus Callback Overhead
Summary
Issue: When Prometheus is configured as a callback, request latency increases significantly. In reproducible scenarios, Prometheus increases:
- mean latency by ~27%
- p95 latency by ~42%
- requests >1s by ~2.6×
This issue was intermittent at first but became fully reproducible with random user IDs.
Reproduction Strategy
Baseline: Callbacks On vs Off
Multiple test batches comparing Prometheus enabled vs disabled:
Key finding
- Early batches showed severe impact
- Later batches showed smaller overhead
- Hypothesis formed: provider speed + cache behavior affects reproducibility
| Batch | Callbacks | avg | p95 | >1s |
|---|---|---|---|---|
| Worst cases | on | ~0.45–0.54s | ~1.2s | 5–8% |
| Control | off | ~0.30–0.38s | ~0.4s | ~1% |
Making the Issue Reproducible
Random vs Sequential User IDs
Random user IDs reliably triggered the issue:
| User IDs | Callbacks | p95 | >1s |
|---|---|---|---|
| random | on | ~1.05s | ~5% |
| random | off | ~0.50s | ~1.5% |
| sequential | on/off | ~0.39s | ~0.8% |
Conclusion: Prometheus overhead becomes reproducible when user lookups cause cache misses.
Root Cause Analysis
Suspected Areas
-
_increment_remaining_budget_metrics- Performs DB/cache lookups for key, team, and user
- Executed on every request
-
Callback execution model
- Callbacks run sequentially and block response completion
-
Prometheus label cardinality
- High-cardinality labels amplify contention
Isolation & Bisection
Early Return Tests
| Configuration | >1s |
|---|---|
| Full Prometheus | 5.6% |
| Early return | 2.1% |
➡️ Confirms Prometheus callback is on the critical path.
Async vs Sync Breakdown
- Skipping sync metric updates → no improvement
- Skipping budget metrics DB lookups → major improvement
➡️ Root cause isolated to _increment_remaining_budget_metrics
Fix Validation
Confirming the Cause
- Commenting out
_increment_remaining_budget_metricseliminated the latency spike - Moving it off the critical path with
create_task()showed mixed results due to variance
Baseline Comparison (6 runs, 30k requests)
Prometheus ON vs OFF
| Metric | On | Off | Δ |
|---|---|---|---|
| Mean avg | 0.408s | 0.322s | +27% |
| Mean p95 | 0.911s | 0.641s | +42% |
| >1s | 4.4% | 1.7% | 2.6× |
Target after fix: Prometheus-on within ~10–15% of Prometheus-off.
Final Resolution
-
Root cause: DB lookups on every request inside Prometheus budget metrics
-
Cache was silently failing due to suppressed DB errors
-
Fixes:
- Disable
check_db_only - Surface budget lookup failures in logs
- Parallelize budget lookups
- Disable
Fix commits
30534d7e82— cache behavior fixd37796662— error visibility
Problem 2: Baseline Latency Spikes (Independent of Prometheus)
Summary
Issue: Even with Prometheus fully disabled, Zurich sees extreme latency spikes (p95 >6s, 100% >1s).
This behavior:
- Affects callbacks on and off equally
- Is unrelated to the Prometheus fix
- Scales with end_user lookup patterns
Reference Baseline (Callbacks Off)
| avg | p95 | >1s | >5s |
|---|---|---|---|
| ~4.0s | ~6.3s | 100% | ~33% |
Hypothesis
Baseline spikes are driven by:
end_userDB lookups- cache misses (especially for non-existent users)
- provider-side queuing under concurrency
User Mode Experiments
1000 requests / 100 users
| User mode | avg | p95 | >1s |
|---|---|---|---|
| none | ~1.0s | ~7.9s | 10% |
| sequential | ~1.5s | ~8.6s | ~32% |
| random | ~1.6s | ~8.0s | ~34% |
Key insight:
Passing user dramatically increases latency.
One Request vs Many Requests per User
Callbacks Off, Random Users
| Req/user | avg | >1s |
|---|---|---|
| 1 | ~8.6s | 100% |
| 10 | ~1.5s | 33% |
➡️ Cache hits on subsequent requests reduce latency by ~6×
Created Users vs Random Users
| Mode | avg (10/user) | >1s |
|---|---|---|
| random | ~1.5s | 33% |
| created | ~1.2s | 10% |
Full Test Comparison (callbacks off)
1 request per user (100 requests, 100 users):
| User mode | File | avg | p95 | max | Above 1s |
|---|---|---|---|---|---|
| none | user_none_1_requests_per_user.txt |
11.836s | 20.736s | 22.443s | 100% |
| created | callbacks_off_1_per_user_created.txt |
8.923s | 14.962s | 15.774s | 100% |
| random | callbacks_off_1_per_user.txt |
8.647s | 14.909s | 15.668s | 100% |
10 requests per user (1000 requests, 100 users):
| User mode | File | avg | p95 | max | Above 1s |
|---|---|---|---|---|---|
| none (callbacks off) | callbacks_off_user_none.txt |
0.998s | 7.966s | 14.698s | 10.0% |
| none (callbacks on) | callbacks_on_user_none.txt |
1.099s | 7.767s | 14.873s | 10.0% |
| created | callbacks_off_10_per_user_created.txt |
1.198s | 8.839s | 15.483s | 10.0% |
| random | callbacks_off_10_per_user.txt |
1.538s | 8.275s | 15.499s | 33.2% |
Findings
The latency spike is high regardless of whether a user is passed to the payload or not.
Use a local LLM mock provider
- User none + caching true →
user_none_1_request_per_user_local_llm_cache_on.txt - User none + caching false →
user_none_1_request_per_user_local_llm_cache_off.txt
| Config | File | avg | p95 | max | Above 1s |
|---|---|---|---|---|---|
| User none, local LLM (localhost:8090), cache ON | user_none_1_request_per_user_local_llm_cache_on.txt |
15.204s | 26.543s | 27.706s | 100% |
| User none, local LLM (localhost:8090), cache OFF | user_none_1_request_per_user_local_llm_cache_off.txt |
15.449s | 26.317s | 27.430s | 100% |
What we know so far
- User parameter has no impact - Passing a user to the request payload doesn't affect first request latency
- Cache has no impact - Response caching ON vs OFF shows identical performance (~15s avg)
- Latency is proxy overhead - 15s average suggests infrastructure (DB, Redis, auth) is the bottleneck, not LLM calls since the LLM server is running locally.
Minimal Configuration Test Results
Test with test_config_minimal.yaml (no DB, no Redis, no callbacks, no alerting)
- Test completed:
validation_123.txt
| Config | File | avg | p95 | max | Above 1s |
|---|---|---|---|---|---|
| Minimal config | validation_123.txt |
8.654s | 15.065s | 15.807s | 100% |
| Full config (cache ON) | user_none_1_request_per_user_local_llm_cache_on.txt |
15.204s | 26.543s | 27.706s | 100% |
| Full config (cache OFF) | user_none_1_requests_per_user_local_llm_cache_off.txt |
15.449s | 26.317s | 27.430s | 100% |
Improvement: 43% faster (15.2s → 8.6s avg)
Critical Findings
-
Removing DB/Redis/callbacks cuts latency nearly in half
- Proves the 6.5s overhead comes from database operations, Redis, alerting infrastructure
-
BUT: 8.6s is still very slow for a local mock LLM
- Local LLM mock likely responds in milliseconds
- Proxy adds ~8.6s overhead even with everything disabled
- Same wave pattern persists (early: 15s, mid: 6-8s, late: 1.5-4s)
-
Root cause is in core proxy code, not features
- The bulk of latency comes from the proxy itself, not external network calls
- Likely issues:
- Request queueing/serialization (requests blocking each other)
- Poor concurrency handling
- Auth/router logic overhead
- Python async event loop bottlenecks
Conclusions
- User parameter: No impact
- Cache: No impact
- Database/Redis/Alerting: Adds ~6.5s overhead (15.2s → 8.6s)
- Core proxy code: Adds ~8.6s overhead even with bare minimum config
- Total overhead: ~15s for complex config processing 100 concurrent requests
Next action: Profile the proxy request path to identify the specific bottleneck in core code (likely concurrency/queueing)
cProfile: Per-Request Hotspots (minimal config, no DB/Redis)
Profile captured with test_config_minimal.yaml. Ordered by cumtime, normalized per request:
| Component | Calls | Cumtime | Per-call (est.) | Notes |
|---|---|---|---|---|
| user_api_key_auth | 200 | 12.58s | ~63ms | Auth entry point |
| _user_api_key_auth_builder | 100 | 12.55s | ~125ms | Main auth logic |
| log_db_metrics wrapper | 100 | 12.53s | ~125ms | Still in path despite no DB |
| FastAPI solve_dependencies | 1600/200 | 12.61s | ~63ms | Dependency injection for chat route |
| Starlette/FastAPI middleware/routing | 600 | ~12.7s | ~21ms | Middleware stack |
| annotationlib call_annotate_function | 23400 | 8.28s | ~0.4ms × many | Python 3.14 annotation processing |
| router acompletion | 586 | 0.67s | ~1ms | LLM call path (mostly I/O) |
Key finding: Auth + log_db_metrics ≈ 125ms per request even with no database. Expected for a simple master_key compare: microseconds.
Optimization targets (in order):
- Auth path + log_db_metrics – ensure early return when master-key-only (no DB)
- FastAPI dependency resolution – reduce number of injected dependencies on chat route
- Lazy/conditional Prisma import – avoid loading Prisma when DB not configured
Concurrency Test 1-10-30-50-100
Setup: Minimal config (test_config_minimal.yaml), no user in payload, master key auth, local LLM mock at localhost:8090. 100 total requests per run; concurrency = number of simulated users.
Results
| Concurrency | File | avg | p95 | max | >1s | >5s | >10s |
|---|---|---|---|---|---|---|---|
| 1 | concurrency_1_requests_100.txt |
0.012s | 0.007s | 0.716s | 0% | 0% | 0% |
| 10 | concurrency_10_requests_100.txt |
0.144s | 1.195s | 2.034s | 7% | 0% | 0% |
| 30 | concurrency_30_requests_100.txt |
0.833s | 3.861s | 4.836s | 27% | 0% | 0% |
| 50 | concurrency_50_requests_100.txt |
2.087s | 6.975s | 7.689s | 47% | 18% | 0% |
| 100 | concurrency_100_requests_100.txt |
7.721s | 14.019s | 14.657s | 97% | 69% | 33% |
Δ vs baseline (c=1):
| Concurrency | Δ avg | Δ p95 | Δ max |
|---|---|---|---|
| 1 | — | — | — |
| 10 | +1,100% (12×) | +16,971% (171×) | +184% (2.8×) |
| 30 | +6,842% (69×) | +55,057% (551×) | +575% (6.8×) |
| 50 | +17,292% (174×) | +99,529% (996×) | +974% (10.7×) |
| 100 | +64,242% (643×) | +200,129% (2,002×) | +1,947% (20.5×) |
Analysis
-
Steep degradation with concurrency — Average latency grows ~640× from 12 ms (c=1) to 7.7 s (c=100). p95 grows from 7 ms to 14 s.
-
First request vs later requests — At c=1, the first request is ~716 ms (cold/auth path), then ~5 ms per request. At c=10/30/50, the first request per user is 0.5–4 s, while subsequent requests are ~15–70 ms. At c=100, every request is effectively a “first” request (1 per user), so all pay the full contention cost.
-
Evidence of queueing/serialization — Higher concurrency yields much worse latency despite the same total load. This points to limited parallelism: requests appear to be processed in waves rather than truly in parallel.
-
Tail latency — At c=100, 33% of requests exceed 10 s. Worst-case (14.6 s) is far above the ~5 ms steady-state per-request cost, indicating substantial proxy overhead under load.
-
Config context — These runs use minimal config (no DB, Redis, callbacks) and the Prisma fast-path fix, so the bottleneck is in core proxy handling: auth, routing, and/or async event-loop behavior under concurrency.
cProfile Collection
cProfile was run at each concurrency level with:
$env:DATABASE_URL = $null; $env:REDIS_URL = $null; python -m cProfile -o concurrency_N_requests_100.prof -m litellm.proxy.proxy_cli --config test_config_minimal.yaml
(Proxy runs until latency test completes 100 requests, then stopped.)
| Concurrency | Profile file |
|---|---|
| 1 | concurrency_1_requests_100.prof |
| 10 | concurrency_10_requests_100.prof |
| 30 | concurrency_30_requests_100.prof |
| 50 | concurrency_50_requests_100.prof |
| 100 | concurrency_100_requests_100.prof |
concurrency_1_requests_100.profconcurrency_10_requests_100.profconcurrency_30_requests_100.profconcurrency_50_requests_100.profconcurrency_100_requests_100.prof
cProfile Analysis
Hypothesis (c=1 vs c=100):
- 2× more completion calls per request (all OS) –
router.acompletionchain: ~309 calls (~3/req) at c=1 vs ~599 (~6/req) at c=100; suggests retries, fallbacks, or middleware work scaling with concurrency. - Thread pool teardown (all OS) – c=100: ~14.7s in
threading.join/ ThreadPoolExecutor shutdown (29 threads); c=1: negligible. Suggests blocking work delegated to thread pool under load. - Context switching (all OS) –
Context.run~15s cumtime at c=100 (7,503 calls); not in top 100 at c=1.
Checklist
- Concurrency 1 →
concurrency_1_requests_100.txt - Concurrency 10 →
concurrency_10_requests_100.txt - Concurrency 30 →
concurrency_30_requests_100.txt - Concurrency 50 →
concurrency_50_requests_100.txt - Concurrency 100 →
concurrency_100_requests_100.txt
Real Auth vs Mock Auth Comparison
Setup: Minimal config (test_config_minimal.yaml), no DB/Redis. Command: poetry run python measure_latency.py --user-mode none --key-mode shared. Instrumented with [proxy] user_api_key_auth called at T and [proxy] chat/completions request received at T prints.
Log Files
| Auth mode | File | Description |
|---|---|---|
| Real | proxy_real_auth_100_requests_log.txt |
Full auth path (_read_request_body, _user_api_key_auth_builder) |
| Mock | proxy_mock_auth_100_requests_log.txt |
Early return with mock UserAPIKeyAuth; skips DB/cache and body parsing |
Findings
| Metric | Real auth | Mock auth |
|---|---|---|
| Auth calls before first chat | 93 | 1 (interleaved) |
| First auth → first chat handler | ~47 ms | ~0.3–0.6 ms |
| Auth↔chat pattern | Auth burst; chat handler delayed | Auth and chat back-to-back |
| 100 requests throughput | ~250 ms (auth dominates) | ~152 ms |
Interpretation
- Auth path is the bottleneck — Real auth does 93 calls before the first request reaches the chat handler; mock auth processes each request immediately. The ~47 ms gap is consistent with cProfile (~63 ms per auth call under load).
- Queueing in real auth — Auth invocations are serialized and block the chat route; mock auth removes that delay, so chat handlers run as soon as requests arrive.
- Confirms cProfile targets — Aligns with cProfile:
user_api_key_auth+_user_api_key_auth_builder+log_db_metrics≈ 125 ms/req. Mocking auth eliminates that cost.