02 · Model Serving at Scale¶
A trained model is useless until something can call it and get a prediction back, fast, under real load. This module covers wrapping a model in a REST API with FastAPI, exporting to ONNX for portable/faster inference, and batching — the core levers for serving at scale.
A minimal serving API¶
# serve.py
from fastapi import FastAPI
from pydantic import BaseModel
import joblib
import numpy as np
app = FastAPI()
model = joblib.load("model.pkl")
class PredictRequest(BaseModel):
features: list[float]
class PredictResponse(BaseModel):
prediction: int
probability: float
@app.post("/predict", response_model=PredictResponse)
def predict(req: PredictRequest):
x = np.array(req.features).reshape(1, -1)
proba = model.predict_proba(x)[0]
pred = int(np.argmax(proba))
return PredictResponse(prediction=pred, probability=float(proba[pred]))
uvicorn serve:app --host 0.0.0.0 --port 8000 --workers 4
curl -X POST localhost:8000/predict -H "Content-Type: application/json" \
-d '{"features": [14.2, 20.1, 91.3, ...]}'
# {"prediction": 1, "probability": 0.983}
pydantic models (PredictRequest/PredictResponse) validate the request
shape and types automatically — a malformed request gets a clear 422 error
before it ever reaches the model.
Exporting to ONNX for portable, optimized inference¶
import torch
from torch import nn
model_pt = nn.Sequential(nn.Linear(30, 64), nn.ReLU(), nn.Linear(64, 2))
model_pt.eval()
dummy_input = torch.randn(1, 30)
torch.onnx.export(
model_pt, dummy_input, "model.onnx",
input_names=["features"], output_names=["logits"],
dynamic_axes={"features": {0: "batch_size"}, "logits": {0: "batch_size"}},
)
import onnxruntime as ort
session = ort.InferenceSession("model.onnx")
outputs = session.run(["logits"], {"features": dummy_input.numpy()})
print(outputs[0].shape) # (1, 2)
ONNX Runtime applies graph-level optimizations (operator fusion, constant folding) that are framework-agnostic — the exported model can be served from Python, C++, or a mobile runtime without PyTorch installed at all.
Batching requests for throughput¶
import asyncio
import time
class BatchPredictor:
def __init__(self, model, max_batch_size=32, max_wait_ms=10):
self.model = model
self.max_batch_size = max_batch_size
self.max_wait_ms = max_wait_ms
self.queue = []
async def predict(self, features):
future = asyncio.get_event_loop().create_future()
self.queue.append((features, future))
if len(self.queue) >= self.max_batch_size:
await self._flush()
else:
asyncio.create_task(self._flush_after_timeout())
return await future
async def _flush_after_timeout(self):
await asyncio.sleep(self.max_wait_ms / 1000)
if self.queue:
await self._flush()
async def _flush(self):
batch, self.queue = self.queue, []
if not batch:
return
X = [f for f, _ in batch]
preds = self.model.predict(X) # one call, many rows -- the whole point
for (_, future), pred in zip(batch, preds):
if not future.done():
future.set_result(pred)
Instead of one model call per request, requests arriving within a
max_wait_ms window are grouped into a single batched call — trading a
small amount of added latency per request for a large increase in total
throughput.
Worked example: measuring the batching trade-off¶
import numpy as np
def time_predictions(model, n_requests, batch_size):
X = np.random.randn(n_requests, 30)
start = time.time()
n_batches = (n_requests + batch_size - 1) // batch_size
for i in range(n_batches):
batch = X[i*batch_size:(i+1)*batch_size]
model.predict(batch)
elapsed = time.time() - start
return elapsed, elapsed / n_requests * 1000
for bs in [1, 8, 32, 128]:
elapsed, per_req_ms = time_predictions(model, 1000, bs)
print(f"batch_size={bs:4d} total={elapsed:.3f}s per-request={per_req_ms:.3f}ms")
# batch_size=1 total=0.412s per-request=0.412ms
# batch_size=8 total=0.089s per-request=0.089ms
# batch_size=32 total=0.041s per-request=0.041ms
# batch_size=128 total=0.023s per-request=0.023ms
Cheat sheet¶
| Concern | Tool |
|---|---|
| Request/response validation | FastAPI + pydantic models |
| Framework-agnostic, optimized inference | ONNX export + onnxruntime |
| High throughput under load | Request batching (with a max-wait bound) |
| Concurrent request handling | uvicorn --workers N, async def handlers |
How It Actually Works¶
Batching amortizes fixed per-call overhead across many requests, which is
why throughput improves faster than the raw compute would suggest. Each
call into a model (model.predict(X)) carries fixed costs independent of
X's size — Python function call overhead, memory allocation, and for
matrix-multiply-heavy models, underutilized parallel hardware when X has
too few rows to fill all available compute lanes (the same GPU-parallelism
argument from Level 3 Module 07: a single-row matrix multiply leaves most
of a GPU's — or even a CPU's SIMD units' — capacity idle). Grouping 32
requests into one model.predict(batch_of_32) call pays that fixed
overhead once instead of 32 times, and lets the underlying linear-algebra
library parallelize across the full batch — which is mechanically why
per-request latency drops roughly 10-20x from batch_size=1 to
batch_size=32 in the worked example, far more than a linear "32 times
less overhead" would predict on its own, because the parallel hardware
utilization is also improving simultaneously.
max_wait_ms exists because batching trades individual-request latency
for aggregate throughput, and that trade needs an explicit bound. Without
a timeout, BatchPredictor would wait indefinitely for max_batch_size
requests to accumulate before running any of them — under low traffic, a
single request could wait forever. _flush_after_timeout's
asyncio.sleep(max_wait_ms / 1000) guarantees that even a lone request
gets processed within a bounded delay, at the cost of not achieving the
full batching benefit when traffic is sparse. This is a genuine trade-off,
not a free win: setting max_wait_ms too high improves throughput under
load but adds real latency to every request during quiet periods, while
setting it too low (or to 0) approaches the batch_size=1 case, forfeiting
most of the throughput gain — the right value depends on the application's
actual latency SLA.
ONNX's speedup comes from graph-level optimizations that are only
possible once the model is represented as a static computation graph
rather than a live Python object. Exporting to ONNX traces the model's
forward pass once (using dummy_input) and serializes the resulting
sequence of tensor operations into a static graph format, independent of
PyTorch's Python runtime. onnxruntime can then apply optimizations a
live PyTorch model can't easily benefit from at inference time: fusing
consecutive operations (e.g. a linear layer immediately followed by a ReLU
becomes one fused kernel instead of two separate memory-bound calls),
eliminating operations with statically-known outputs (constant folding),
and choosing hardware-specific optimized kernels for the target CPU or GPU
without any Python interpreter overhead per operation — which is why ONNX
inference is typically faster than the equivalent PyTorch model(x) call
even for the exact same mathematical function, purely from execution-plan
optimization rather than any change to what's being computed.
Exercise¶
Add a /health endpoint to the FastAPI app that returns {"status": "ok",
"model_version": ...}, and a /predict_batch endpoint accepting a list of
feature vectors in one request (rather than the async queue-based batching
above — a simpler, explicit batch endpoint). Compare, using the timing
function from the worked example, the throughput of sending 1000 requests
one at a time to /predict versus sending them as 8 requests of 125 items
each to /predict_batch, and report the speedup.