After building the async batch client, I started running benchmarks against inference endpoints in different regions. The results were striking: the same model, same hardware, same prompt, but TTFT varied by 10x depending on which region handled the request. Most of that variance was pure network latency. That is when I started thinking seriously about routing.
In traditional web services, geo-routing is a solved problem: put your app behind a CDN, use DNS-based routing, done. LLM inference is different because the backend is not stateless. GPU instances are expensive, capacity is limited, and you cannot just spin up a new replica in 200ms like you can with a container running a REST API. This post covers the routing strategies I have found useful when running inference across multiple clouds and regions.
Why multi-cloud in the first place?
Three reasons come up repeatedly:
- GPU availability: H100s and A100s are not always available in a single cloud or region. During 2023-2024, it was common to wait weeks for a reservation in a popular region. Spreading across clouds means you can actually get capacity.
- Cost arbitrage: spot pricing for GPU instances varies by cloud, region, and time of day. An H100 on GCP in us-central1 might be 30% cheaper than the same instance on AWS in us-east-1 at the same moment.
- Latency: if your users are in Europe, Asia, and North America, a single US-based deployment adds 150-300ms of round-trip network latency for overseas users. For streaming responses, this shows up as a noticeable delay before the first token appears.
The routing layer
The simplest architecture that works is a lightweight proxy at the edge that makes routing decisions based on three signals: client location, backend health, and backend load.
# Simplified routing logic
def select_backend(client_region: str, backends: list[Backend]) -> Backend:
"""Pick the best backend for this request."""
# Filter to healthy backends only
healthy = [b for b in backends if b.is_healthy]
if not healthy:
raise NoCapacityError("All backends down")
# Sort by composite score: latency + load penalty
def score(b: Backend) -> float:
latency_ms = REGION_LATENCY[client_region][b.region]
load_penalty = b.queue_depth * 10 # ms per queued request
return latency_ms + load_penalty
return min(healthy, key=score)
The REGION_LATENCY table is a static matrix of measured RTT between regions. I populate it by running periodic pings between the router locations and the backends. It does not change often. The queue_depth comes from the inference server's metrics endpoint, which vLLM and SGLang both expose.
Client location detection
There are two practical approaches:
- GeoIP lookup on the client IP: fast, no client cooperation needed, but inaccurate for VPN users and mobile networks. MaxMind's GeoLite2 database is free and good enough for region-level routing.
- Anycast + edge PoPs: deploy your router at edge locations (Cloudflare Workers, AWS CloudFront Functions, Fastly Compute). The request naturally arrives at the nearest PoP, and the PoP knows its own region. This is more reliable than GeoIP.
I prefer the edge PoP approach. A Cloudflare Worker that inspects cf.colo and cf.country headers knows exactly where the request entered the network, with no database lookup required.
Health checks for GPU backends
Standard HTTP health checks are necessary but not sufficient for inference backends. A vLLM server can return 200 on /health while its KV cache is full and every new request would be queued for minutes. You need deeper health signals:
- /metrics endpoint: vLLM exposes
vllm:num_requests_waiting,vllm:gpu_cache_usage_perc, andvllm:num_requests_running. These tell you whether the server can actually handle new work. - Synthetic probe requests: send a tiny completion request (5 tokens) every 30 seconds and measure TTFT. If TTFT spikes above a threshold, mark the backend as degraded.
- GPU health: NVIDIA's DCGM exporter exposes
DCGM_FI_DEV_XID_ERRORS. A non-zero XID error count means the GPU is having hardware issues, and you should drain the backend.
# Backend health check
async def check_backend_health(backend: Backend) -> HealthStatus:
try:
async with aiohttp.ClientSession() as session:
# Check basic health
async with session.get(
f"{backend.url}/health", timeout=5
) as resp:
if resp.status != 200:
return HealthStatus.DOWN
# Check metrics for load
async with session.get(
f"{backend.url}/metrics", timeout=5
) as resp:
metrics = parse_prometheus(await resp.text())
cache_usage = metrics["vllm:gpu_cache_usage_perc"]
queue_depth = metrics["vllm:num_requests_waiting"]
if cache_usage > 0.95:
return HealthStatus.DEGRADED
if queue_depth > 100:
return HealthStatus.DEGRADED
return HealthStatus.HEALTHY
except Exception:
return HealthStatus.DOWN
Latency vs. cost: the tradeoff knob
Pure latency-based routing sends everything to the nearest region. That is correct for user-facing chat, but wasteful for batch workloads. If you are running 5,000 evaluation prompts, you do not care about 150ms of network latency. You care about total throughput and cost.
I use a simple flag in the request to signal intent:
{
"model": "meta-llama/Llama-3.1-8B",
"prompt": "...",
"routing_hint": "lowest_cost" // or "lowest_latency"
}
The router interprets this: lowest_latency picks the nearest healthy backend. lowest_cost picks the backend on the cheapest spot instance, regardless of region. For batch jobs, routing to the cheapest available region can save 20-40% on GPU costs.
Spot pricing data is available via each cloud's API (AWS describe-spot-price-history, GCP compute.machineTypes.list with preemptible pricing). I poll this every 5 minutes and cache it in the router. Prices shift slowly enough that stale data by a few minutes is fine.
Failover and draining
When a backend goes down, the router needs to redirect in-flight requests without losing them. For non-streaming requests, this is straightforward: retry on a different backend. For streaming responses, it is harder because you cannot seamlessly resume a partially-generated response from a different server.
My approach:
- Non-streaming: automatic retry on the next-best backend. The client sees slightly higher latency but no error.
- Streaming: if the stream breaks mid-generation, close the connection and return a specific error code. The client can retry with the partial output as context, though the UX is not perfect.
- Graceful drain: before taking a backend offline (for maintenance or scale-down), mark it as draining. The router stops sending new requests but lets in-flight requests complete. This is essential for blue-green deployments.
DNS vs. application-layer routing
DNS-based routing (Route53 latency routing, Cloud DNS geolocation policies) is simple to set up but coarse. DNS TTLs mean failover takes 30-60 seconds minimum. You cannot route based on server load because DNS has no visibility into backend metrics.
Application-layer routing (a reverse proxy like Envoy, Nginx, or a custom service) gives you full control. You can route per-request, inspect headers, check backend metrics in real time, and fail over instantly. The cost is that you need to run and maintain the proxy.
For LLM inference, application-layer routing wins. The requests are long-lived (streaming can last seconds), the backends are expensive (you cannot afford to waste capacity due to stale DNS), and the routing decisions are complex (latency + cost + load + model availability).
The best routing decision is the one you do not have to make. If you can afford to run the same model in three regions with enough capacity in each, simple nearest-region routing handles 90% of cases. Complexity only pays off at scale, when GPU costs are high enough that a 20% savings justifies the engineering.
Tomorrow I will look inside the GPU itself and explore memory profiling to understand where the bytes actually go during inference.