Production Systems

Round-robin and least-connections load balancers

LLM requests are not like web requests. They vary wildly in duration and resource consumption. I implemented both load balancing strategies and found that the naive choice can leave GPUs idle while others are overloaded.

Load balancing for LLM inference is a different problem than load balancing for web servers. A typical HTTP request takes 50 to 200 milliseconds. An LLM request can take anywhere from 100 milliseconds (short completion) to 30 seconds (long generation with a big prompt). This variance makes the choice of load balancing algorithm critically important.

Round-robin: the default choice

Round-robin is the simplest algorithm: requests are distributed to backends in a rotating sequence. Backend 1 gets request 1, backend 2 gets request 2, backend 3 gets request 3, then back to backend 1 for request 4.

import itertools
from dataclasses import dataclass

@dataclass
class Backend:
    id: str
    url: str
    active_requests: int = 0

class RoundRobinBalancer:
    def __init__(self, backends: list[Backend]):
        self.backends = backends
        self.cycle = itertools.cycle(backends)

    def select(self) -> Backend:
        return next(self.cycle)

# Usage
backends = [
    Backend("gpu-0", "http://gpu-0:8000"),
    Backend("gpu-1", "http://gpu-1:8000"),
    Backend("gpu-2", "http://gpu-2:8000"),
]
lb = RoundRobinBalancer(backends)

# Each call returns the next backend in sequence
for i in range(6):
    b = lb.select()
    print(f"Request {i} -> {b.id}")

Round-robin works well when requests have similar cost. For web servers, this is usually true. For LLM inference, it is catastrophically false.

Consider a scenario with 3 backends and 6 requests. Requests 1, 2, 3 have 100-token outputs (fast). Requests 4, 5, 6 have 2,000-token outputs (slow). Round-robin sends request 4 to backend 1, 5 to backend 2, 6 to backend 3. But requests 1, 2, 3 might have finished already, leaving their backends idle. Meanwhile, the backends processing the long requests are overloaded because they already have the short request's KV cache memory allocated.

Least-connections: a better default

Least-connections routes each new request to the backend with the fewest active (in-flight) requests. This naturally adapts to request duration: backends processing long requests accumulate connections and stop receiving new ones, while backends that finish quickly free up and attract new traffic.

class LeastConnectionsBalancer:
    def __init__(self, backends: list[Backend]):
        self.backends = backends

    def select(self) -> Backend:
        # Pick the backend with the fewest active requests
        # Break ties by selecting the first one (stable ordering)
        return min(self.backends, key=lambda b: b.active_requests)

    def on_request_start(self, backend: Backend):
        backend.active_requests += 1

    def on_request_end(self, backend: Backend):
        backend.active_requests -= 1

# Usage
lb = LeastConnectionsBalancer(backends)

backend = lb.select()
lb.on_request_start(backend)
# ... forward request ...
# When response completes:
lb.on_request_end(backend)

This is significantly better for LLM workloads because it implicitly accounts for request duration. A backend processing a 30-second generation naturally accumulates connections and gets fewer new requests, while a backend that just finished a short request drops to 0 connections and gets the next one.

Simulating the difference

I built a simple simulation to compare the two strategies under a realistic LLM workload with variable output lengths:

import random
import heapq

def simulate(balancer_class, n_requests=100, n_backends=4):
    """Simulate request processing with variable-length LLM outputs."""
    backends = [Backend(f"gpu-{i}", f"http://gpu-{i}:8000") for i in range(n_backends)]
    balancer = balancer_class(backends)

    # Event-driven simulation
    events = []  # (time, event_type, data)
    clock = 0.0
    completed = []

    # Generate arrivals with Poisson process
    for i in range(n_requests):
        arrival = clock
        clock += random.expovariate(20.0)  # 20 req/s
        # Output length follows a bimodal distribution (short + long requests)
        if random.random() < 0.7:
            output_tokens = random.randint(20, 100)   # short
        else:
            output_tokens = random.randint(500, 2000)  # long
        duration = output_tokens * 0.008  # ~8ms per token
        heapq.heappush(events, (arrival, "arrive", (i, duration)))

    while events:
        time, etype, data = heapq.heappop(events)

        if etype == "arrive":
            req_id, duration = data
            backend = balancer.select()
            if hasattr(balancer, "on_request_start"):
                balancer.on_request_start(backend)
            finish_time = time + duration
            heapq.heappush(events, (finish_time, "finish", (req_id, backend, time)))

        elif etype == "finish":
            req_id, backend, start_time = data
            if hasattr(balancer, "on_request_end"):
                balancer.on_request_end(backend)
            completed.append({
                "id": req_id,
                "backend": backend.id,
                "latency": time - start_time,
            })

    return completed

Results and analysis

Running this simulation shows a clear pattern:

The improvement is most dramatic when the workload is bimodal (mix of short and long requests), which is exactly what production LLM workloads look like. Chatbot queries produce 50 to 200 tokens. Summarization and code generation produce 500 to 4,000 tokens.

Why neither is optimal for LLMs

Both round-robin and least-connections treat all requests as equally costly, just at different granularities. For LLM inference, there are better signals available:

These are the kind of LLM-aware routing strategies that systems like NVIDIA Dynamo and production API gateways implement. But they all build on top of the same fundamental insight: you need to account for the heterogeneous cost of LLM requests.

Practical recommendation

If you are using a standard load balancer (NGINX, HAProxy, Envoy) in front of LLM servers, switch from round-robin to least-connections immediately. It is a one-line configuration change that meaningfully reduces tail latency. For NGINX: upstream backend { least_conn; server gpu-0:8000; server gpu-1:8000; }

Round-robin assumes all requests are equal. Least-connections assumes all requests are different but unknowable. LLM-aware routing knows the cost of each request before it starts. Each step up the ladder reduces tail latency.

Tomorrow: building a priority request queue with batch formation, which adds another layer of intelligence between the router and the inference engine.