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#
| Knob | Larger means | Smaller means |
|---|---|---|
maxBatch | More throughput, more memory per call | Bounded per-call latency |
maxWait | Fuller batches at low traffic | Less 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 channelDesign 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
selecton the reply and onctx.Done()(IV.03). - The queue is a bounded channel;
Submitusesselectwithdefaultto 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 rateIf 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#
| Option | Boundary | Notes |
|---|---|---|
| A separate engine (vLLM, a native server) | HTTP or gRPC | The 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 batch | One binary; batch here, because each call is expensive (V.04) |
| Pure Go | None | Small 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#
// 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#
- 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? - 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?
- Put the batcher behind a real
net/httphandler that returns 429 onErrBusy, and load it with the open-loop generator from the Observability projects.
Check yourself#
- Why does batching raise throughput?
- What are the two conditions that end batch collection, and which matters at high load?
- Why should a queue not be longer than the deadline can drain?