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.
How the templates are wired, and the decisions behind them. This is the document
to read before you adapt the code to your own model and infrastructure.
A POST /predict call travels through five stages:
1. ASGI transport. Uvicorn accepts the connection and hands the request to
the FastAPI app. With --workers N you get N independent OS processes, each
with its own copy of the model and its own batcher.
2. Metrics middleware. MetricsMiddleware (in middleware.py) starts a
timer and, on the way out, records http_request_duration_seconds and
http_requests_total keyed by method, *route template*, and status. Using
the route template (/predict) instead of the raw path keeps metric
cardinality bounded.
3. Validation. Pydantic parses the body into a PredictRequest. Ragged or
empty instance matrices are rejected here with a 422 before any model code
runs — fail fast, fail cheap.
4. Batching. The handler calls batcher.submit_many(instances). Each
instance becomes its own future on the batcher's queue, so instances from
*different* concurrent requests are merged into one predict call. The
handler simply awaits its results.
5. Response. Predictions are assembled into a PredictResponse with the
serving model's name/version and the measured server-side latency.
The model is loaded exactly once, during FastAPI's lifespan startup:
@asynccontextmanager
async def lifespan(app: FastAPI):
handle = load_model(settings.model_path, fmt=settings.model_format, ...)
batcher = DynamicBatcher(handle.predict_batch, ...)
await batcher.start()
app.state.handle = handle
app.state.batcher = batcher
yield
await batcher.stop()Loading in the lifespan (rather than at import or per request) means:
time per worker, not on the first request of every connection.
ready; an orchestrator that probes /healthz will not route traffic to a
worker whose model is still loading.
finally stops thebatcher, draining the in-flight batch before the process exits.
The module-level app = create_app() object is cheap to construct — it does no
I/O — so uvicorn model_serving.app:app is safe even though the model has not
loaded yet.
GET /healthz is deliberately a readiness probe: it returns 200 only when
the model handle is present *and* the batcher loop is running, and 503
otherwise. Wire it up accordingly:
| Probe | Kubernetes field | What it should check |
|---|---|---|
| Liveness | livenessProbe | process responds at all (a cheap route) |
| Readiness | readinessProbe | /healthz returns 200 (model loaded) |
| Startup | startupProbe | /healthz, with a generous failure budget for slow model loads |
A common mistake is pointing the *liveness* probe at a check that depends on the
model: if the model reload hiccups, Kubernetes will kill an otherwise healthy
pod. Keep liveness dumb and readiness smart.
Every supported format collapses to a single ModelHandle with one method that
matters: predict_batch(rows) -> list. loaders.py provides:
PickleLoader — any object with a .predict() method (stdlib pickle).JoblibLoader — scikit-learn / numpy estimators (lazy import joblib).OnnxLoader — ONNX graphs via onnxruntime (lazy import onnxruntime).Because the heavy imports are lazy, importing model_serving to use just
the batcher never forces joblib or onnxruntime to be installed. To serve a
framework that is not built in (TorchScript, a TensorFlow SavedModel, a remote
model server), implement ModelLoader.load() and call register_loader():
from model_serving.loaders import ModelLoader, ModelHandle, register_loader
class TorchScriptLoader(ModelLoader):
fmt = "torchscript"
def load(self, path, *, name, version, metadata=None):
import torch
module = torch.jit.load(path).eval()
def predict(rows):
with torch.no_grad():
return module(torch.tensor(rows)).tolist()
return ModelHandle(name, version, self.fmt, predict, str(path))
register_loader("torchscript", TorchScriptLoader())For tests and for models that are already in memory, skip files entirely with
ModelHandle.from_callable(fn, name=..., version=...) and pass it to
create_app(settings, model_handle=handle).
Two transports ship with identical request/response contracts:
app.py (FastAPI / ASGI) is the recommended path. Async I/O lets oneworker hold thousands of in-flight connections while the asyncio
DynamicBatcher merges their instances. CPU-bound predict calls run in a
thread executor so they never block the event loop.
flask_app.py (Flask / WSGI) exists for teams standardized on Gunicorn.WSGI is synchronous, so it uses the thread-based ThreadedBatcher — same
size/latency flush policy, different concurrency primitive.
Both reuse the *same* Pydantic models and the *same* metrics helpers, so you can
migrate between them without changing clients or dashboards.
Routing between model versions is intentionally not baked into a single
process — that belongs at the load-balancer layer where it can be changed
without a redeploy. The pattern the templates support:
1. Run two deployments of the same image with different MODEL_PATH /
MODEL_VERSION (see docker/docker-compose.yml: serving-stable and
serving-canary).
2. Split traffic at your proxy (nginx split_clients, Envoy weighted clusters,
a Kubernetes Service mesh): e.g. 90% stable, 10% canary.
3. Compare each version's inference_* and http_request_duration_seconds
metrics. Promote the canary by shifting weights — no code change required.
Clients that must hit a specific version can pin it with the request body's
model_version field; a replica serving a different version answers 409 so a
mis-routed request fails loudly instead of returning silently wrong predictions.
/predict; leave /healthz and/metrics open for probes and scrapers.
example uses a StandardScaler + LogisticRegression Pipeline) so serving
and training share the exact same transform.
ModelCache to keep several versions warm in one processand select per request — useful when traffic per version is too low to justify
a dedicated deployment each.