Dynamic micro-batching is the single highest-leverage optimization for online
model serving. This guide explains how the batcher works, how to tune it, and
how to scale the service horizontally once a single replica is saturated.
Frameworks predict on a *matrix* far more efficiently than on one row at a time.
Two overheads get amortized across the batch:
predict() call crosses into numpy / C /BLAS once. A batch of 32 rows pays that crossing once; 32 single-row calls pay
it 32 times.
hostβdevice copy. Tiny batches leave the device idle waiting on Python. Larger
batches keep it busy and can be 10β50Γ more throughput-efficient.
The catch: a REST endpoint receives requests one at a time. The batcher's job is
to *reassemble* a matrix from many small requests without making any single
caller wait too long.
A batch is dispatched as soon as either trigger fires:
flush when: len(batch) >= max_batch_size # the "size" trigger
or elapsed >= max_batch_latency_ms # the "latency" trigger
of diminishing returns on your hardware).
It is the key to keeping tail latency predictable under light load: if only
one request arrives, it waits at most max_batch_latency_ms and then goes.
In batching.py the loop grabs the first queued item, then keeps pulling more
with a shrinking asyncio.wait_for timeout until the deadline passes or the
size cap is hit. The Flask ThreadedBatcher does the same with a
queue.Queue(timeout=...).
max_batch_size and max_latency_msThese two knobs are a throughput-vs-latency dial. Tune them empirically:
1. Measure single-row latency t1 and the per-row marginal cost in a
batch. Benchmark predict at sizes 1, 2, 4, 8, 16, 32, 64 and plot
rows/second. Throughput climbs, then plateaus β set max_batch_size near the
start of the plateau (often 16β64 for CPU sklearn, 64β512 for GPU deep nets).
2. Pick a latency budget. If your SLA is "p99 < 50 ms" and the model itself
takes ~20 ms on a full batch, you can afford max_batch_latency_ms of
roughly 10β15 ms of queueing. Smaller windows = lower latency, fewer co-batched
requests; larger windows = bigger batches, higher throughput, higher tail.
3. Validate under realistic load. Use examples/client_request.py's
concurrency burst, then read inference_batch_size from /metrics. If the
histogram still piles up at 1 under load, your latency window is too short (or
the load is genuinely too low to batch β which is fine).
Rules of thumb:
| Symptom | Likely fix |
|---|---|
| Batches stay size 1 under heavy load | increase max_batch_latency_ms |
| p99 latency too high, throughput fine | decrease max_batch_latency_ms |
| Throughput plateaus below target | increase max_batch_size (until memory/SLA) |
| OOM or huge tail spikes | decrease max_batch_size |
Little's Law (L = Ξ» Β· W) is enough to sanity-check capacity. With arrival rate
Ξ» (req/s) and mean time-in-system W (s), the average number of in-flight
requests is L. If your effective service rate (batches/s Γ batch_size) drops
below Ξ», the queue grows without bound and latency runs away. Two consequences:
β€ 70%). A service running at 95% utilization has no slack to absorb bursts and
will see latency explode at the first traffic spike.
GET /healthz reports the batcher'squeue_depth. A steadily rising queue depth is the earliest, clearest signal
that you are under-provisioned β earlier than CPU saturation.
Once one replica is saturated, scale horizontally:
batcher. Put a load balancer in front; round-robin is fine because the
batching happens *inside* each replica. This scales throughput linearly until
a shared bottleneck (database, feature store) appears.
uvicorn --workers N gives N processes percontainer, each with a full model copy β watch memory (N Γ model size). On GPU,
prefer one process per GPU and scale with more replicas/pods; multiple
workers contending for one device usually *hurts* throughput.
for batched inference. Prefer a custom metric: scale on queue_depth,
requests-per-second, or p95 of http_request_duration_seconds. In Kubernetes
this is an HPA on a Prometheus-adapter custom metric scraped from /metrics.
(BLAS threadpools, CUDA context, JIT). Send a synthetic batch during startup
so the first *real* request does not eat that cost. (The lifespan is the place
to add a warmup call.)
batch's intermediate tensors, plus framework overhead. Set container limits
with headroom; an OOM-kill mid-batch fails every co-batched caller at once.
OMP_NUM_THREADS,OPENBLAS_NUM_THREADS) across many workers cause thrashing. A common recipe is
one BLAS thread per process and concurrency from replicas instead.
Everything you need to run this in production is already exported on /metrics:
http_requests_total β request volume and error rate (by status).http_request_duration_seconds β latency histogram; compute p50/p95/p99 withhistogram_quantile() in PromQL.
inference_batch_size β confirms batching is actually happening; if it staysat 1 under load, revisit the latency window.
inference_errors_total β model/inference failures, distinct from HTTP 4xx.queue_depth (via /healthz) β the leading indicator for autoscaling.Alert on rising error rate, p99 latency breaching your SLA, and sustained queue
depth growth β in that order of urgency.
Get the full Model Serving Templates and unlock everything.
Get the complete guide with every chapter unlocked, including code samples, diagrams, and best practices.
Access all interactive tools with complete data, all workload profiles, and the full scenario library.
Downloadable source code, configuration files, and working examples from every chapter.
Free updates for life. Every new chapter, tool, and improvement included.