Model-Aware Load Balancing & Circuit Breakers

Computation Graphs & ML Infrastructure advanced Building with AI ~20 min read

Overview

In a distributed ML inference system with a multi-backend serving layer, naive round-robin load balancing fails catastrophically. Model inference has fundamentally different performance characteristics than typical web requests — latency varies by orders of magnitude depending on model size, batch accumulation, GPU memory pressure, and whether the model weights are cached in device memory. A request routed to a node that just evicted the target model from GPU memory can take 10× longer than one routed to a node with the model already resident.

Model-aware load balancing solves this by incorporating model-specific metadata into routing decisions. For read-heavy inference workloads, consistent hashing pins a model’s requests to a stable subset of nodes, maximizing GPU cache hit rates. For write-heavy workloads like online learning or embedding index updates, least-loaded routing distributes work evenly to prevent hotspots. A well-designed inference gateway combines both strategies with circuit breaker patterns that prevent cascading failures when individual serving nodes degrade.

The circuit breaker pattern — borrowed from electrical engineering — adds a state machine (closed → open → half-open) around each backend connection. When a node’s error rate or latency exceeds a threshold, the breaker trips open, immediately failing fast rather than queuing requests behind a degraded node. This is critical in ML serving where a single slow node can exhaust connection pools and propagate backpressure across the entire inference fleet.

Core Concepts

Consistent Hashing for Read-Heavy Inference

Consistent hashing maps model IDs onto a hash ring, assigning each model to a stable set of k replica nodes. When a node joins or leaves, only 1/n of keys remap rather than reshuffling the entire assignment. For ML inference, this means model weights stay resident in GPU memory on the same nodes across requests, avoiding the 2–30 second penalty of loading weights from disk or network storage. For instance, using virtual nodes (typically 150–200 per physical node) ensures even distribution across heterogeneous GPU hardware.

Least-Loaded Routing for Write-Heavy Workloads

Write-heavy operations — embedding index updates, model fine-tuning, gradient aggregation — benefit from least-loaded (also called “power of two choices”) routing. The balancer samples two or more candidate nodes and routes to the one with the lowest in-flight request count or queue depth. This prevents convoy effects where a temporarily slow node accumulates an unbounded backlog. A production system tracks per-node GPU utilization, pending batch sizes, and memory pressure to compute a composite load score.

Circuit Breaker State Machine

The circuit breaker operates in three states: Closed (normal operation, requests flow through), Open (node is considered failed, requests immediately return an error or are rerouted), and Half-Open (a probe request is sent to test recovery). Transitions are governed by configurable thresholds: consecutive failures, error rate over a sliding window, or p99 latency exceeding an SLO. The half-open state uses exponential backoff with jitter to avoid thundering herd recovery storms.

Health Scoring & Weighted Routing

Rather than a binary healthy/unhealthy signal, a production load balancer maintains a continuous health score per backend node ranging from 0.0 (dead) to 1.0 (fully healthy). The score is a weighted combination of recent error rate (40%), p99 latency relative to baseline (30%), GPU memory availability (20%), and active connection count (10%). Routing weight is proportional to this score, allowing graceful degradation — a node at 0.6 health still receives 60% of its normal traffic share rather than being fully removed.

Request Hedging

For latency-critical inference paths, hedged requests work as follows: after a configurable delay (typically the p95 latency), a duplicate request is sent to an alternate node. The first response wins and the other is cancelled. This trades a small amount of extra compute (typically 2–5% overhead) for dramatically tighter tail latency. Hedging is only enabled when the circuit breaker is closed and the system-wide load is below a configurable threshold to prevent amplification during overload.

How It Works

Load Balancer Interface

The core abstraction defines a LoadBalancer interface that all routing strategies implement. Each strategy receives a request context containing the model ID, request type (inference vs. update), and urgency level, and returns the best backend node.

package mlbalancer

import (
    "context"
    "time"
)

// Backend represents a single ML serving node.
type Backend struct {
    ID           string
    Address      string
    GPUMemoryMB  int64
    LoadedModels map[string]bool
    HealthScore  float64
    InFlight     int64
}

// RequestContext carries model-aware routing metadata.
type RequestContext struct {
    ModelID     string
    RequestType RequestType // Inference or Update
    Urgency     Urgency     // Normal, High, Critical
    PayloadSize int64
}

type RequestType int

const (
    Inference RequestType = iota
    Update
)

type Urgency int

const (
    Normal Urgency = iota
    High
    Critical
)

// LoadBalancer selects a backend for a given request.
type LoadBalancer interface {
    // Pick returns the best backend for the request context.
    Pick(ctx context.Context, req *RequestContext) (*Backend, error)
    // UpdateBackends refreshes the set of available backends.
    UpdateBackends(backends []*Backend)
    // ReportResult feeds request outcome back for adaptive routing.
    ReportResult(backendID string, latency time.Duration, err error)
}

Consistent Hash Ring Implementation

The consistent hash ring uses xxhash for fast, non-cryptographic hashing and virtual nodes for even distribution. When a model ID is hashed, the ring returns the k nearest distinct physical nodes, enabling replica-aware routing.

package mlbalancer

import (
    "fmt"
    "sort"
    "sync"

    "github.com/cespare/xxhash/v2"
)

const defaultVirtualNodes = 150

// HashRing implements consistent hashing with virtual nodes.
type HashRing struct {
    mu           sync.RWMutex
    vnodes       int
    ring         []uint64        // sorted hash values
    hashToNode   map[uint64]string // hash → physical node ID
    nodes        map[string]*Backend
}

func NewHashRing(vnodes int) *HashRing {
    if vnodes <= 0 {
        vnodes = defaultVirtualNodes
    }
    return &HashRing{
        vnodes:     vnodes,
        hashToNode: make(map[uint64]string),
        nodes:      make(map[string]*Backend),
    }
}

// AddNode inserts a backend with its virtual nodes onto the ring.
func (h *HashRing) AddNode(b *Backend) {
    h.mu.Lock()
    defer h.mu.Unlock()

    h.nodes[b.ID] = b
    for i := 0; i < h.vnodes; i++ {
        key := fmt.Sprintf("%s#%d", b.ID, i)
        hash := xxhash.Sum64String(key)
        h.ring = append(h.ring, hash)
        h.hashToNode[hash] = b.ID
    }
    sort.Slice(h.ring, func(i, j int) bool {
        return h.ring[i] < h.ring[j]
    })
}

// GetNodes returns the k closest distinct physical nodes for a key.
func (h *HashRing) GetNodes(key string, k int) []*Backend {
    h.mu.RLock()
    defer h.mu.RUnlock()

    if len(h.ring) == 0 {
        return nil
    }

    hash := xxhash.Sum64String(key)
    idx := sort.Search(len(h.ring), func(i int) bool {
        return h.ring[i] >= hash
    })
    if idx == len(h.ring) {
        idx = 0 // wrap around
    }

    seen := make(map[string]bool)
    var result []*Backend
    for i := 0; i < len(h.ring) && len(result) < k; i++ {
        pos := (idx + i) % len(h.ring)
        nodeID := h.hashToNode[h.ring[pos]]
        if !seen[nodeID] {
            seen[nodeID] = true
            if b, ok := h.nodes[nodeID]; ok {
                result = append(result, b)
            }
        }
    }
    return result
}

Circuit Breaker State Machine

The circuit breaker tracks failures within a sliding window and transitions between states atomically. The half-open state permits a single probe request to test whether the backend has recovered before fully closing the breaker.

package mlbalancer

import (
    "sync"
    "sync/atomic"
    "time"
)

type BreakerState int32

const (
    StateClosed   BreakerState = iota // normal operation
    StateOpen                         // failing fast
    StateHalfOpen                     // probing recovery
)

// CircuitBreaker wraps a single backend connection.
type CircuitBreaker struct {
    mu              sync.Mutex
    state           atomic.Int32
    failureCount    int64
    successCount    int64
    failureThreshold int64
    successThreshold int64        // successes needed in half-open to close
    openTimeout      time.Duration // how long to stay open before half-open
    lastFailure      time.Time
    slidingWindow    []time.Time   // timestamps of recent failures
    windowSize       time.Duration
    halfOpenPermit   atomic.Bool   // only one probe at a time
}

// NewCircuitBreaker creates a breaker with sensible ML serving defaults.
func NewCircuitBreaker(failThreshold, successThreshold int64, openTimeout time.Duration) *CircuitBreaker {
    cb := &CircuitBreaker{
        failureThreshold: failThreshold,
        successThreshold: successThreshold,
        openTimeout:      openTimeout,
        windowSize:       30 * time.Second,
    }
    cb.state.Store(int32(StateClosed))
    return cb
}

// Allow checks whether a request should be permitted through.
func (cb *CircuitBreaker) Allow() bool {
    state := BreakerState(cb.state.Load())
    switch state {
    case StateClosed:
        return true
    case StateOpen:
        cb.mu.Lock()
        defer cb.mu.Unlock()
        if time.Since(cb.lastFailure) > cb.openTimeout {
            cb.state.Store(int32(StateHalfOpen))
            cb.halfOpenPermit.Store(true)
            return true
        }
        return false
    case StateHalfOpen:
        // Only allow one probe request at a time.
        return cb.halfOpenPermit.CompareAndSwap(true, false)
    }
    return false
}

// RecordSuccess records a successful request.
func (cb *CircuitBreaker) RecordSuccess() {
    state := BreakerState(cb.state.Load())
    if state == StateHalfOpen {
        cb.mu.Lock()
        defer cb.mu.Unlock()
        cb.successCount++
        if cb.successCount >= cb.successThreshold {
            cb.state.Store(int32(StateClosed))
            cb.failureCount = 0
            cb.successCount = 0
            cb.slidingWindow = nil
        }
        cb.halfOpenPermit.Store(true) // allow next probe
        return
    }
    // In closed state, reset consecutive failure tracking.
    cb.mu.Lock()
    cb.failureCount = 0
    cb.mu.Unlock()
}

// RecordFailure records a failed request and may trip the breaker.
func (cb *CircuitBreaker) RecordFailure() {
    cb.mu.Lock()
    defer cb.mu.Unlock()

    now := time.Now()
    cb.lastFailure = now

    state := BreakerState(cb.state.Load())
    if state == StateHalfOpen {
        // Probe failed — reopen immediately.
        cb.state.Store(int32(StateOpen))
        cb.successCount = 0
        return
    }

    // Sliding window: prune old failures, add new one.
    cutoff := now.Add(-cb.windowSize)
    pruned := cb.slidingWindow[:0]
    for _, t := range cb.slidingWindow {
        if t.After(cutoff) {
            pruned = append(pruned, t)
        }
    }
    pruned = append(pruned, now)
    cb.slidingWindow = pruned

    if int64(len(cb.slidingWindow)) >= cb.failureThreshold {
        cb.state.Store(int32(StateOpen))
    }
}

Health-Check Integration

The health checker runs as a background goroutine, periodically probing each backend and updating its composite health score. Scores feed directly into routing weight calculations.

package mlbalancer

import (
    "context"
    "math"
    "sync"
    "time"
)

// HealthChecker periodically evaluates backend health.
type HealthChecker struct {
    mu        sync.RWMutex
    backends  map[string]*BackendHealth
    interval  time.Duration
    prober    Prober
}

type BackendHealth struct {
    Backend       *Backend
    ErrorRate     float64   // 0.0–1.0 over sliding window
    P99Latency    time.Duration
    BaseLatency   time.Duration // established baseline p99
    GPUMemFree    float64   // fraction of GPU memory available
    ActiveConns   int64
    Score         float64   // composite 0.0–1.0
    LastProbe     time.Time
}

// Prober sends a lightweight health probe to a backend.
type Prober interface {
    Probe(ctx context.Context, addr string) (latency time.Duration, gpuMemFree float64, err error)
}

// ComputeScore calculates the composite health score.
func (bh *BackendHealth) ComputeScore() float64 {
    // Error rate component (40% weight): 1.0 when no errors, 0.0 at 100%.
    errScore := 1.0 - bh.ErrorRate

    // Latency component (30% weight): ratio of baseline to current p99.
    latScore := 1.0
    if bh.P99Latency > 0 && bh.BaseLatency > 0 {
        ratio := float64(bh.BaseLatency) / float64(bh.P99Latency)
        latScore = math.Min(ratio, 1.0) // cap at 1.0
    }

    // GPU memory component (20% weight).
    gpuScore := bh.GPUMemFree

    // Connection pressure component (10% weight).
    connScore := 1.0
    if bh.ActiveConns > 100 {
        connScore = math.Max(0.0, 1.0-float64(bh.ActiveConns-100)/500.0)
    }

    bh.Score = 0.4*errScore + 0.3*latScore + 0.2*gpuScore + 0.1*connScore
    return bh.Score
}

// Run starts the periodic health check loop.
func (hc *HealthChecker) Run(ctx context.Context) {
    ticker := time.NewTicker(hc.interval)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            hc.mu.RLock()
            backends := make([]*BackendHealth, 0, len(hc.backends))
            for _, bh := range hc.backends {
                backends = append(backends, bh)
            }
            hc.mu.RUnlock()

            for _, bh := range backends {
                probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
                lat, gpuMem, err := hc.prober.Probe(probeCtx, bh.Backend.Address)
                cancel()

                hc.mu.Lock()
                if err != nil {
                    bh.ErrorRate = bh.ErrorRate*0.8 + 0.2*1.0 // EWMA
                } else {
                    bh.ErrorRate = bh.ErrorRate*0.8 + 0.2*0.0
                    bh.P99Latency = lat
                    bh.GPUMemFree = gpuMem
                }
                bh.LastProbe = time.Now()
                bh.ComputeScore()
                bh.Backend.HealthScore = bh.Score
                hc.mu.Unlock()
            }
        }
    }
}

Implementation Patterns

Composite Load Balancer with Strategy Selection

A production load balancer selects a routing strategy based on request type. Inference requests use consistent hashing for cache affinity, while update requests use least-loaded routing. Both strategies are wrapped in circuit breakers and respect health scores.

package mlbalancer

import (
    "context"
    "errors"
    "math/rand"
    "sync"
    "time"
)

var ErrNoHealthyBackend = errors.New("no healthy backend available")

// CompositeBalancer selects strategy based on request type.
type CompositeBalancer struct {
    mu          sync.RWMutex
    ring        *HashRing
    backends    []*Backend
    breakers    map[string]*CircuitBreaker
    replicas    int // number of hash ring replicas to consider
}

func NewCompositeBalancer(replicas int) *CompositeBalancer {
    return &CompositeBalancer{
        ring:     NewHashRing(defaultVirtualNodes),
        breakers: make(map[string]*CircuitBreaker),
        replicas: replicas,
    }
}

func (cb *CompositeBalancer) Pick(ctx context.Context, req *RequestContext) (*Backend, error) {
    switch req.RequestType {
    case Inference:
        return cb.pickConsistentHash(req)
    case Update:
        return cb.pickLeastLoaded(req)
    default:
        return cb.pickLeastLoaded(req)
    }
}

// pickConsistentHash routes inference to consistent hash targets,
// falling back through replicas if circuit breakers are open.
func (cb *CompositeBalancer) pickConsistentHash(req *RequestContext) (*Backend, error) {
    cb.mu.RLock()
    defer cb.mu.RUnlock()

    candidates := cb.ring.GetNodes(req.ModelID, cb.replicas)
    for _, backend := range candidates {
        breaker, ok := cb.breakers[backend.ID]
        if !ok {
            continue
        }
        if breaker.Allow() && backend.HealthScore > 0.2 {
            return backend, nil
        }
    }
    return nil, ErrNoHealthyBackend
}

// pickLeastLoaded implements power-of-two-choices for write workloads.
func (cb *CompositeBalancer) pickLeastLoaded(req *RequestContext) (*Backend, error) {
    cb.mu.RLock()
    defer cb.mu.RUnlock()

    healthy := make([]*Backend, 0)
    for _, b := range cb.backends {
        breaker, ok := cb.breakers[b.ID]
        if ok && breaker.Allow() && b.HealthScore > 0.2 {
            healthy = append(healthy, b)
        }
    }

    if len(healthy) == 0 {
        return nil, ErrNoHealthyBackend
    }
    if len(healthy) == 1 {
        return healthy[0], nil
    }

    // Power of two choices: pick two random candidates, return least loaded.
    i := rand.Intn(len(healthy))
    j := rand.Intn(len(healthy) - 1)
    if j >= i {
        j++
    }

    a, b := healthy[i], healthy[j]
    // Composite load: in-flight requests weighted by inverse health score.
    loadA := float64(a.InFlight) / a.HealthScore
    loadB := float64(b.InFlight) / b.HealthScore

    if loadA <= loadB {
        return a, nil
    }
    return b, nil
}

func (cb *CompositeBalancer) UpdateBackends(backends []*Backend) {
    cb.mu.Lock()
    defer cb.mu.Unlock()

    cb.backends = backends
    cb.ring = NewHashRing(defaultVirtualNodes)
    for _, b := range backends {
        cb.ring.AddNode(b)
        if _, ok := cb.breakers[b.ID]; !ok {
            cb.breakers[b.ID] = NewCircuitBreaker(5, 3, 30*time.Second)
        }
    }
}

func (cb *CompositeBalancer) ReportResult(backendID string, latency time.Duration, err error) {
    cb.mu.RLock()
    breaker, ok := cb.breakers[backendID]
    cb.mu.RUnlock()
    if !ok {
        return
    }
    if err != nil {
        breaker.RecordFailure()
    } else {
        breaker.RecordSuccess()
    }
}

Hedged Request Pattern

For latency-sensitive inference, a hedged request fires a backup after the p95 latency threshold. The first response wins and the loser’s context is cancelled.

package mlbalancer

import (
    "context"
    "time"
)

// HedgedRequest sends a primary request and, if it hasn’t completed
// after hedgeDelay, fires a backup to an alternate backend.
func HedgedRequest(
    ctx context.Context,
    balancer *CompositeBalancer,
    req *RequestContext,
    hedgeDelay time.Duration,
    doInference func(ctx context.Context, backend *Backend) ([]byte, error),
) ([]byte, error) {
    primary, err := balancer.Pick(ctx, req)
    if err != nil {
        return nil, err
    }

    type result struct {
        data []byte
        err  error
    }

    ch := make(chan result, 2)
    primaryCtx, primaryCancel := context.WithCancel(ctx)
    defer primaryCancel()

    // Launch primary request.
    go func() {
        data, err := doInference(primaryCtx, primary)
        ch <- result{data, err}
    }()

    // Wait for hedgeDelay, then launch backup if primary hasn’t returned.
    timer := time.NewTimer(hedgeDelay)
    defer timer.Stop()

    select {
    case r := <-ch:
        return r.data, r.err
    case <-timer.C:
        // Primary is slow — hedge with a backup.
        backup, err := balancer.Pick(ctx, req)
        if err != nil || backup.ID == primary.ID {
            // No alternate available; wait for primary.
            r := <-ch
            return r.data, r.err
        }

        hedgeCtx, hedgeCancel := context.WithCancel(ctx)
        defer hedgeCancel()

        go func() {
            data, err := doInference(hedgeCtx, backup)
            ch <- result{data, err}
        }()

        // First response wins.
        r := <-ch
        return r.data, r.err
    case <-ctx.Done():
        return nil, ctx.Err()
    }
}

Common Pitfalls

Further Reading