PidokuInfra

Serving Models from Go

Expert 1h 5m Difficulty 4/5 Topic 08 of 09

Prerequisites IV.02, IV.03, IV.06, V.05

The idea in one minute#

A model is expensive to call and cheap to call wider: running sixteen inputs together costs little more than running one. A serving layer exploits that with dynamic batching — hold each request for a few milliseconds, group whatever arrived, run the group as one call, and hand each caller its own result.

Around the batcher sit the things that make it a service: a bounded queue so overload turns into fast rejections instead of memory growth, deadlines so abandoned requests do not consume the model, backpressure from the model up to the client, and metrics on queue wait and batch size. Every piece is a pattern from module IV.

An analogy#

An airport shuttle. Leaving the moment one passenger boards wastes the trip; waiting until every seat is full strands the first passenger. So the rule is “leave when full, or after five minutes, whichever comes first”. The queue at the stop has a rope: when it is full, newcomers are told to take a taxi rather than wait for a bus that cannot reach them in time.

A picture#

flowchart TB
  H1["HTTP handler"] -->|"Submit(ctx, input)"| Q["bounded queue<br/>chan *request"]
  H2["HTTP handler"] --> Q
  H3["HTTP handler"] --> Q
  Q -->|"full"| REJ["ErrBusy: 429 or 503<br/>fail fast"]
  Q --> B["batcher goroutine<br/>collect until batch is full<br/>OR oldest waited maxWait"]
  B --> DROP["drop requests whose ctx is done"]
  DROP --> M["model.Run(batch)<br/>one call, native engine or GPU"]
  M -->|"result i on request i's reply chan"| H1
  M --> H2
  M --> H3
  B -.-> MET["metrics: queue depth, wait time,<br/>batch size, rejections"]
  class H1,H2,H3 neutral
  class Q,B queue
  class REJ warn
  class DROP,M compute
  class MET memory

How it really works#

Why batching pays#

A forward pass has costs that do not depend on how many inputs are in it — reading the weights, launching kernels, the cgo or network round trip — and costs that do. If a call costs fixed + n × per_item, then throughput at batch size n is n / (fixed + n × per_item): nearly n times higher while fixed dominates. On GPUs the effect is larger still, because a single input leaves almost all of the hardware idle (Inference Engineering I.06).

The two knobs#

KnobLarger meansSmaller means
maxBatchMore throughput, more memory per callBounded per-call latency
maxWaitFuller batches at low trafficLess added latency

At high load batches fill before the timer fires and maxWait is irrelevant. At low load the timer always fires and maxWait is pure added latency. Set maxWait to a small fraction of your latency budget — a few milliseconds — and let load do the rest.

The batcher, as a goroutine#

One goroutine owns the batch. Its loop:

take one request (block until there is one)
start a timer of maxWait
keep taking requests until the batch is full or the timer fires
drop any whose context is already done
run the model on the rest
send each result on that request's reply channel

Design points, each from an earlier lesson:

  • The request carries its own reply channel, buffered with capacity 1, so the batcher never blocks on a caller that has given up (IV.02).
  • Callers wait in a select on the reply and on ctx.Done() (IV.03).
  • The queue is a bounded channel; Submit uses select with default to reject when it is full (IV.06).
  • One owner goroutine means no locks around the batch.

Sizing the queue#

A queue longer than your deadline can drain is worse than useless: requests wait, expire, and were never going to be served. A reasonable bound:

queue capacity ≈ deadline × service rate

If the model serves 400 items per second and the deadline is 250 ms, a queue of about 100 is the most that can ever help. Beyond that, reject — the client can retry elsewhere, and your p99 stays honest.

More than one worker#

With several model replicas or GPUs, run one batcher per replica reading the same queue, or one batcher feeding a pool. Keep each replica’s batches full before spreading load thin.

LLM generation is different#

The batcher above suits single-shot models: embeddings, classifiers, rerankers. Text generation runs for hundreds of steps and requests finish at different times, so engines use continuous batching: the batch is re-formed at every decode step, admitting new requests as soon as others finish. Its admission limit is KV-cache memory rather than a count. The loop is the same idea — one owner, a queue, a step — and Inference Engineering V.09 and its Project 8 build it in Go.

Where the model runs#

OptionBoundaryNotes
A separate engine (vLLM, a native server)HTTP or gRPCThe Go service is a gateway and scheduler; the engine batches. The usual choice for LLMs
In-process through cgo (ONNX Runtime, llama.cpp)A cgo call per batchOne binary; batch here, because each call is expensive (V.04)
Pure GoNoneSmall models only

When the engine batches for you, your Go layer still needs the bounded queue, the deadlines and the rejection — admission control belongs at the front door.

What to measure#

  • Queue depth and time in queue, as a histogram.
  • Batch size distribution: persistently small at high load means a misconfiguration.
  • Rejections and expirations, separately.
  • Model time per call, and end-to-end latency per request.

These are the saturation signals from Observability V.03, and queue depth is the one to autoscale on.

Shutdown#

Stop accepting (close the listener), let handlers finish submitting, close the queue so the batcher drains and exits, wait for it. The program’s Close does the last two steps.

Code#

Go
// batcher.go — a dynamic batching server: bounded queue, max batch size, max wait, backpressure.
package main

import (
	"context"
	"errors"
	"fmt"
	"sort"
	"sync"
	"sync/atomic"
	"time"
)

var ErrBusy = errors.New("server busy")

type request struct {
	input    string
	enqueued time.Time
	reply    chan result // buffered: the worker never blocks on a caller that gave up
	ctx      context.Context
}

type result struct {
	output string
	waited time.Duration
	batch  int
}

// Model stands in for a GPU forward pass: a fixed cost per call plus a small cost per item.
// That shape — expensive to start, cheap to widen — is why batching pays.
type Model struct{ Fixed, PerItem time.Duration }

func (m Model) Run(inputs []string) []string {
	time.Sleep(m.Fixed + time.Duration(len(inputs))*m.PerItem)
	out := make([]string, len(inputs))
	for i, in := range inputs {
		out[i] = "embedding(" + in + ")"
	}
	return out
}

type Batcher struct {
	queue    chan *request
	model    Model
	maxBatch int
	maxWait  time.Duration
	wg       sync.WaitGroup
	batches  atomic.Int64
	items    atomic.Int64
}

func NewBatcher(m Model, queueSize, maxBatch int, maxWait time.Duration) *Batcher {
	b := &Batcher{queue: make(chan *request, queueSize), model: m, maxBatch: maxBatch, maxWait: maxWait}
	b.wg.Add(1)
	go b.loop()
	return b
}

// Submit is what an HTTP handler calls. It never queues without bound: a full queue is an
// immediate error the handler turns into 429 or 503.
func (b *Batcher) Submit(ctx context.Context, input string) (result, error) {
	r := &request{input: input, enqueued: time.Now(), reply: make(chan result, 1), ctx: ctx}
	select {
	case b.queue <- r:
	default:
		return result{}, ErrBusy
	}
	select {
	case res := <-r.reply:
		return res, nil
	case <-ctx.Done():
		return result{}, ctx.Err()
	}
}

// loop owns the batch. Collect until the batch is full OR the oldest request has waited maxWait.
func (b *Batcher) loop() {
	defer b.wg.Done()
	for first := range b.queue {
		batch := []*request{first}
		deadline := time.NewTimer(b.maxWait)
	collect:
		for len(batch) < b.maxBatch {
			select {
			case r, ok := <-b.queue:
				if !ok {
					break collect
				}
				batch = append(batch, r)
			case <-deadline.C:
				break collect
			}
		}
		deadline.Stop()

		// Skip requests whose callers already gave up: do not spend the model on them.
		live := batch[:0]
		inputs := make([]string, 0, len(batch))
		for _, r := range batch {
			if r.ctx.Err() == nil {
				live = append(live, r)
				inputs = append(inputs, r.input)
			}
		}
		if len(live) == 0 {
			continue
		}
		start := time.Now()
		outputs := b.model.Run(inputs)
		b.batches.Add(1)
		b.items.Add(int64(len(live)))
		for i, r := range live {
			r.reply <- result{outputs[i], start.Sub(r.enqueued), len(live)}
		}
	}
}

func (b *Batcher) Close() { close(b.queue); b.wg.Wait() }

// load sends requests at a fixed rate (open loop) and reports what callers experienced.
func load(name string, b *Batcher, ratePerSec int, d time.Duration) {
	var mu sync.Mutex
	var lat []time.Duration
	var rejected, timedOut atomic.Int64
	var wg sync.WaitGroup
	interval := time.Second / time.Duration(ratePerSec)
	start := time.Now()
	for i := 0; time.Since(start) < d; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
			defer cancel()
			t0 := time.Now()
			_, err := b.Submit(ctx, fmt.Sprint("text", i))
			switch {
			case errors.Is(err, ErrBusy):
				rejected.Add(1)
			case err != nil:
				timedOut.Add(1)
			default:
				mu.Lock()
				lat = append(lat, time.Since(t0))
				mu.Unlock()
			}
		}()
		time.Sleep(time.Until(start.Add(time.Duration(i+1) * interval)))
	}
	wg.Wait()
	elapsed := time.Since(start)
	b.Close()
	sort.Slice(lat, func(i, j int) bool { return lat[i] < lat[j] })
	p := func(q float64) float64 {
		if len(lat) == 0 {
			return 0
		}
		return float64(lat[int(q*float64(len(lat)-1))].Microseconds()) / 1000
	}
	avgBatch := 0.0
	if n := b.batches.Load(); n > 0 {
		avgBatch = float64(b.items.Load()) / float64(n)
	}
	fmt.Printf("%-26s %5d ok/s  batch %4.1f  p50 %5.1f ms  p99 %6.1f ms  rejected %4d  timed out %4d\n",
		name, int(float64(len(lat))/elapsed.Seconds()), avgBatch, p(0.5), p(0.99), rejected.Load(), timedOut.Load())
}

func main() {
	model := Model{Fixed: 8 * time.Millisecond, PerItem: 250 * time.Microsecond}
	const rate, dur = 600, 1500 * time.Millisecond
	fmt.Printf("model: %v per call + %v per item. Offered load: %d requests/s\n\n", model.Fixed, model.PerItem, rate)

	load("no batching (batch=1)", NewBatcher(model, 64, 1, 0), rate, dur)
	load("batch<=8,  wait 2 ms", NewBatcher(model, 64, 8, 2*time.Millisecond), rate, dur)
	load("batch<=32, wait 2 ms", NewBatcher(model, 64, 32, 2*time.Millisecond), rate, dur)
	load("batch<=32, wait 20 ms", NewBatcher(model, 64, 32, 20*time.Millisecond), rate, dur)
	fmt.Println("\nUnbatched, the model manages ~120 calls/s against 600 offered: the queue stays full, the")
	fmt.Println("few requests that succeed wait almost their whole deadline, and the rest are rejected or expire.")
	fmt.Println("Batched, the same model serves the whole load. A longer wait fills batches but adds latency.")
}

Remember this#

  • Batch because a model call has a large fixed cost: collect until full or until the oldest request has waited maxWait.
  • One goroutine owns the batch; each request carries a buffered reply channel and a context.
  • Bound the queue to what the deadline can drain; reject the rest immediately.
  • Skip requests whose callers have gone. Measure queue wait and batch size.
  • Generation uses continuous batching — the same loop, re-formed every token.

Try it#

  1. Run batcher.go. Shrink the unbatched server’s queue from 64 to 8. What happens to its p50 latency and to the number of timeouts? Why is a shorter queue kinder?
  2. Run two batcher goroutines on the same queue, as if there were two GPUs. How do throughput and average batch size change at the same load?
  3. Put the batcher behind a real net/http handler that returns 429 on ErrBusy, and load it with the open-loop generator from the Observability projects.

Check yourself#

  1. Why does batching raise throughput?
  2. What are the two conditions that end batch collection, and which matters at high load?
  3. Why should a queue not be longer than the deadline can drain?

↑↓ navigate↵ openesc close