- Compare batch, real-time and streaming inference and choose the cheapest pattern that meets a product's freshness and latency needs.
- Explain the trade-offs between pickle checkpoints, safetensors, TorchScript, torch.export and ONNX, and implement an export with a numerical parity check.
- Implement a minimal model-serving API and describe how containers, Kubernetes probes, autoscaling and GPU device plugins run it in production.
- Calculate replica counts with Little's law and A/B test sample sizes from a minimum detectable effect.
- Design a release plan that uses shadow, canary and A/B stages with automated gates and a rehearsed rollback.
- Choose compression techniques (quantization, pruning, distillation) for an edge or on-device target.
Before this: The ML Lifecycle, Reproducibility and Experiment Tracking, Data Engineering for AI: Pipelines, Quality, Labeling, Features and Privacy
A trained model is only useful once something can call it. Deployment is the engineering that turns a weights file on a laptop into a service that answers fraud checks in 30 milliseconds, scores ten million customers overnight, or runs on a phone with no network. Most of the cost and risk in a production ML system sits here, not in the training notebook. Sculley et al. made the same point in their well-known "hidden technical debt" paper: the model code is a small box in the middle of a large system.
Intuition: a restaurant, not a recipe
Think of a trained model as a recipe. Deployment is opening the restaurant. You have to decide whether you cook to order (real-time), prep everything the night before (batch), or run a conveyor belt that cooks as ingredients arrive (streaming). You need a kitchen that can be copied exactly in another city (containers), a manager who adds cooks at the dinner rush (autoscaling), and a way to try a new dish on a few tables before it goes on every menu (canary and A/B tests). If the new dish makes people sick, you need to take it off the menu in minutes (rollback).
Three inference patterns
| Pattern | When predictions are made | Typical latency budget | Example | Main engineering concern |
|---|---|---|---|---|
| Batch (offline) | On a schedule, over a whole dataset | Minutes to hours for the job | Nightly churn scores written to a table | Throughput, cost, idempotent reruns |
| Real-time (online) | On request, one or a few inputs | Roughly 10–500 ms per request | Card fraud check at checkout | Tail latency (p95/p99), availability |
| Streaming | As events arrive on a stream (e.g. Kafka) | Seconds | Updating a user's risk score after each click | State, ordering, exactly-once semantics |
Choose the cheapest pattern the product allows. If a prediction is only read the next morning, batch scoring is simpler, cheaper and easier to debug than a live service. A common hybrid precomputes expensive parts in batch (embeddings, candidate lists) and does a cheap final step online (re-ranking with fresh context). The deciding question is how fresh the inputs must be: if the prediction depends on something that happened two seconds ago, you need online or streaming inference.
A frequent bug in real-time systems is training–serving skew: features computed one way in the offline training pipeline and another way in the online service. A feature store, or at least one shared feature-computation library used by both paths, is the standard defence (see the data-engineering lesson).
Packaging the model: serialization formats
Before you can serve a model you must save it in a form another process can load safely and run quickly.
- Pickle-based checkpoints (classic
torch.save,joblib): convenient but unsafe. Unpickling can execute arbitrary code, so never load untrusted pickles. Since PyTorch 2.6,torch.loaddefaults toweights_only=Truefor this reason. - safetensors: a simple format that stores only tensors (a JSON header plus raw bytes). It cannot execute code and supports zero-copy, memory-mapped loading. It has become the default for sharing weights on the Hugging Face Hub. It stores weights only; you still need the model code to rebuild the architecture.
- TorchScript (
torch.jit.script/torch.jit.trace): PyTorch's older way to capture a model as a graph that can run without Python. PyTorch now describes TorchScript as nearing end of life and steers new work towardstorch.export; treat it as legacy for new projects. - torch.export: captures a full, ahead-of-time graph (
ExportedProgram) of a PyTorch model, with explicit dynamic shapes. It is the entry point for PyTorch's newer deployment paths (for example AOTInductor compilation and ExecuTorch for mobile and edge devices). - ONNX (Open Neural Network Exchange): a framework-neutral graph format with a versioned operator set ("opset"). Export once, then run in ONNX Runtime, TensorRT, OpenVINO or other engines on many kinds of hardware.
- GGUF: the single-file format used by llama.cpp and related tools for quantized LLMs (covered in the local-runtimes lesson).
Rule of thumb: store training checkpoints as safetensors plus versioned code, and produce a separate inference artifact (ONNX, an exported program, or an engine-specific compiled file) in CI for each target runtime. Record the exact artifact hash in the model registry.
import torch, torch.nn as nn
from safetensors.torch import save_file, load_file
model = nn.Sequential(nn.Linear(20, 64), nn.ReLU(), nn.Linear(64, 1)).eval()
save_file(model.state_dict(), "fraud_v3.safetensors") # weights only, no code execution on load
model.load_state_dict(load_file("fraud_v3.safetensors"))
x = torch.randn(4, 20)
torch.onnx.export(model, (x,), "fraud_v3.onnx",
input_names=["x"], output_names=["logit"],
dynamic_axes={"x": {0: "batch"}, "logit": {0: "batch"}}) # batch dim stays dynamic
ep = torch.export.export(model, (x,)) # ahead-of-time graph capture
torch.export.save(ep, "fraud_v3.pt2")
import onnxruntime as ort, numpy as np
sess = ort.InferenceSession("fraud_v3.onnx", providers=["CPUExecutionProvider"])
onnx_out = sess.run(None, {"x": x.numpy()})[0]
assert np.allclose(onnx_out, model(x).detach().numpy(), atol=1e-5) # parity check
The last line is the most important: always check numerical parity between the training framework and the exported artifact on real inputs before shipping. Exporters change between PyTorch releases (since PyTorch 2.9, torch.onnx.export defaults to a torch.export-based path, which needs the onnxscript package and prefers a dynamic_shapes argument over the older dynamic_axes), so pin versions in your build and let the parity test catch surprises.
Serving over REST and gRPC
A model server wraps the artifact in a network API.
- REST/JSON over HTTP is universal, easy to debug with
curl, and fine for most workloads. - gRPC uses HTTP/2 and Protocol Buffers: binary payloads, a strict schema, streaming, and lower per-call overhead. It suits high-volume service-to-service calls and large tensor payloads.
You can write a small server yourself or use a dedicated inference server (NVIDIA Triton Inference Server, TorchServe, KServe on Kubernetes, Ray Serve, or vLLM and similar engines for LLMs; see Inference serving at scale). Dedicated servers add dynamic batching: they briefly hold incoming requests and run them through the GPU together, trading a few milliseconds of latency for much higher throughput.
A minimal FastAPI service:
from contextlib import asynccontextmanager
import numpy as np, onnxruntime as ort
from fastapi import FastAPI
from pydantic import BaseModel
MODEL_VERSION = "fraud_v3"
state = {}
@asynccontextmanager
async def lifespan(app: FastAPI):
state["sess"] = ort.InferenceSession(f"{MODEL_VERSION}.onnx",
providers=["CPUExecutionProvider"])
yield # load once at startup, not per request
state.clear()
app = FastAPI(lifespan=lifespan)
class PredictRequest(BaseModel):
features: list[list[float]] # batch of 20-dim feature vectors
@app.post("/v1/predict")
def predict(req: PredictRequest): # sync def: FastAPI runs it in a thread pool
x = np.asarray(req.features, dtype=np.float32)
logits = state["sess"].run(None, {"x": x})[0]
probs = 1 / (1 + np.exp(-logits))
return {"model_version": MODEL_VERSION, "scores": probs.ravel().tolist()}
@app.get("/healthz")
def healthz():
return {"ready": "sess" in state}
Run it with uvicorn app:app --workers 2. Points to notice: the model loads once at startup; the response includes the model version (essential for debugging and for joining predictions to labels later); a health endpoint lets the orchestrator know when the replica is ready. In production you would also validate input ranges, enforce a request size limit, log a sample of inputs and outputs, and export latency metrics.
Containers and Kubernetes basics
A container image packages the code, the Python environment, system libraries (and CUDA user-space libraries for GPU work) and often the model artifact, so the same thing runs on a laptop, in CI and in production. Good images are small (slim base image, multi-stage builds), pinned (exact dependency versions, base image digest), and run as a non-root user.
Kubernetes runs containers across a cluster. The objects you meet first:
- Pod: one or more containers scheduled together; the unit that runs.
- Deployment: declares "run N replicas of this pod template" and performs rolling updates.
- Service: a stable network name and load balancer in front of a set of pods.
- HorizontalPodAutoscaler (HPA): changes the replica count based on a metric (CPU, or custom metrics such as requests per second or queue depth).
- Readiness and liveness probes: readiness says "send me traffic" (only after the model has loaded); liveness says "restart me if I hang".
GPU scheduling. GPUs are exposed to Kubernetes through vendor device plugins, and pods request them as extended resources (for example nvidia.com/gpu: 1 under resources.limits). By default a GPU is allocated whole and is not shared between containers. Ways to share are vendor specific: NVIDIA offers Multi-Instance GPU (MIG) partitioning on supported data-center GPUs, plus time-slicing. Use node labels and taints so that only GPU workloads land on expensive GPU nodes. Model loading is slow (gigabytes from storage into GPU memory), so set generous readiness delays, and scale on queue depth or in-flight requests rather than CPU utilisation, which says little about a GPU-bound server.
Worked example: sizing a real-time service
A fraud service must handle a peak of requests per second with mean service time s per request. Little's law says the average number of requests in flight is
Load tests show one replica handles 8 concurrent requests before its p99 latency exceeds the 250 ms budget. Running replicas at 100% of that is fragile, so target 70% utilisation:
Add at least one more so that losing a node or doing a rolling update does not breach the SLO: plan for 7, and let the autoscaler go higher if traffic exceeds the forecast.
Releasing safely: shadow, canary, A/B, rollback
A new model that scored better offline can still fail online: the features differ slightly, the latency is worse, or users react differently. Release strategies limit the blast radius.
- Shadow (dark) deployment: the new model receives a copy of live traffic, but its outputs are only logged, never returned to users. It tests latency, errors and prediction distributions on real inputs at zero user risk. It cannot measure user-facing outcomes, because users never see its outputs.
- Canary release: send a small fraction of real traffic (say 1%, then 5%, 25%, 100%) to the new version, comparing error rate, latency and key business metrics against the old version at each step. Automate the gates: advance only if every metric is within its threshold.
- A/B test: randomly assign users (not requests, so each user has a consistent experience) to the old or new model for long enough to measure a business outcome with statistical power. A canary asks "is it safe?"; an A/B test asks "is it better?"
- Blue/green: run the old (blue) and new (green) stacks side by side and switch the router all at once; switching back is instant.
- Rollback: always keep the previous model version deployed or instantly deployable, and make "roll back" a one-command or automatic action. Roll back first, investigate second.
Worked example: how long must an A/B test run?
The current model's conversion rate is . You want to detect an absolute improvement of (to 0.11) with significance level (two-sided) and power . A standard approximation for users needed per arm is
where is the quantile of the standard normal distribution: and .
- sum: ; squared: .
- Variance term: .
- Numerator: .
- Divide by : users per arm.
With 4,000 eligible users a day split 50/50, that is about 7 days. Run full weeks anyway, because behaviour differs by weekday. Decide the duration before starting and do not stop early when the dashboard looks good: repeatedly peeking and stopping at the first significant result inflates the false-positive rate. (This formula is a simplification that uses for both arms. Use a proper power calculator, or sequential testing methods designed for peeking, in real work.)
Edge and on-device deployment
Running the model on the user's device (phone, browser, car, camera, laptop) removes network latency, works offline, keeps data local, and moves compute cost off your servers. The price is tight memory, power and thermal budgets, a wide spread of hardware, and slow update cycles. Typical runtimes include ONNX Runtime (including mobile and web builds), TensorFlow Lite (now branded LiteRT by Google), Core ML on Apple devices, ExecuTorch for PyTorch models, and llama.cpp for LLMs.
Compression makes models fit:
- Quantization: store weights (and often activations) in 8-bit or 4-bit integers instead of 32-bit floats. Post-training quantization needs only a small calibration set; quantization-aware training recovers more accuracy. See the quantization lesson.
- Pruning: remove weights or whole channels with little effect. Unstructured sparsity rarely speeds up ordinary hardware; structured pruning (whole channels or heads) and hardware-supported patterns such as 2:4 sparsity do.
- Knowledge distillation: train a small "student" model to match a large "teacher" model's output distribution (Hinton, Vinyals and Dean, 2015).
- Architecture choice: start from a model designed for mobile (depthwise-separable convolutions, small transformers).
Worked size example: a ResNet-50 has about 25.6 million parameters. At FP32 (4 bytes each) that is about MB; at INT8 about 26 MB, a 4× reduction before any pruning. Always re-measure accuracy on the target device and runtime, not just in Python on a workstation: operator support and numerical behaviour differ.
Common pitfalls
- Training–serving skew: different feature code, different preprocessing (image resize, tokenizer version) or different default values online versus offline. Test with recorded production inputs.
- Loading the model per request or lazily on the first request, which causes multi-second cold starts and timeouts. Load at startup and gate readiness on it.
- No model version in logs or responses, making it impossible to tell which model produced a bad prediction.
- Scaling GPU services on CPU utilisation. Use queue depth, in-flight requests or GPU utilisation.
- Skipping the parity test after export or quantization, then finding a silent accuracy drop in production.
- Unpinned dependencies and
latestimage tags, so a rebuild changes behaviour without anyone changing code. - Calling an A/B test early or randomising by request instead of by user.
- No tested rollback path. A rollback that has never been rehearsed often fails when needed.
- Loading untrusted pickles. Prefer safetensors and
weights_onlyloading.
In practice
- Pick the simplest inference pattern the product allows; batch is underrated.
- Treat models as versioned, immutable artifacts in a registry, with the training data snapshot, code commit, metrics and export hash attached.
- Build inference artifacts in CI with an automated parity test and a latency benchmark on the target hardware.
- Ship every model through shadow, then a staged canary with automated metric gates, then (if the change targets a business metric) an A/B test.
- Define SLOs (for example, p99 below 250 ms and 99.9% availability) and alert on them; the monitoring lesson covers model-quality signals.
- Keep N−1 deployed or instantly deployable, and practise rollbacks.
- For edge targets, budget memory, latency and energy up front, and test on the slowest device you support.
Key takeaways
- Use the simplest inference pattern the product allows: batch scoring is cheaper and easier to run than a live service when predictions need not be fresh.
- Store weights in a safe format such as safetensors, build runtime-specific artifacts (ONNX, exported programs) in CI, and always test numerical parity after export.
- A model server should load once at startup, expose readiness, return the model version and be scaled on queue depth or in-flight requests, not CPU.
- Shadow deployments test safety with no user risk, canaries limit the blast radius, and A/B tests measure whether the new model is actually better.
- Rollback must be a fast, rehearsed, preferably automatic action, with the previous version kept ready.
- Edge deployment trades server cost and latency for tight device budgets; quantization, pruning and distillation make models fit.
Terms in this lesson
- Batch inference
- Scoring a whole dataset on a schedule and storing the predictions for later use, rather than answering individual requests.
- Training–serving skew
- A difference between how inputs or features are computed during training and during serving that degrades a model's live performance.
- safetensors
- A tensor-only file format with a JSON header and raw data that cannot execute code on load and supports memory-mapped reading.
- ONNX
- An open, framework-neutral format that represents a model as a graph of versioned operators so it can run in many inference engines.
- torch.export
- A PyTorch API that captures a model ahead of time as a full graph (an ExportedProgram) for compilation and deployment without Python-level tracing at run time.
- Dynamic batching
- A server technique that groups requests arriving within a short window into one batch to raise accelerator throughput at a small latency cost.
- Readiness probe
- A Kubernetes check that tells the cluster a container is ready to receive traffic, typically only after its model has loaded.
- Shadow deployment
- Running a new model on a copy of live traffic while discarding its outputs, so its behaviour can be measured without affecting users.
- Canary release
- Gradually shifting a small, increasing fraction of real traffic to a new version while comparing its health metrics with the current version.
- Little's law
- The relation L = λW between the average number of items in a stable system, their arrival rate and the average time each spends in it.
Check your understanding
- 1.
A retailer recomputes product recommendations for every user once a night and shows them the next day. Which inference pattern fits best?
- 2.
Why is safetensors preferred over pickle-based checkpoints for sharing model weights?
- 3.
A service receives 400 requests per second at peak with a mean service time of 0.05 s. By Little's law, how many requests are in flight on average?
- 4.
You want to check a new model's latency and output distribution on real traffic without any user ever seeing its predictions. What should you use?
- 5.
Your GPU inference pods are overloaded and latency is climbing, but the HPA keeps them at 2 replicas because CPU utilisation is 20%. What is the best fix?
Exercises
Work each problem before opening the solution.
- Exercise 1Core
For each scenario choose batch, real-time or streaming inference and give one sentence of justification:
- Monthly credit-limit review for all cardholders.
- Blocking a fraudulent card payment before it is authorised.
- Updating a delivery driver's estimated arrival time every few seconds from GPS events.
- Tagging 3 million archived photos with labels.
Show solution
- Batch. The decision runs on a monthly schedule over every account, so a scheduled job is cheapest.
- Real-time. The decision must be returned within the payment's latency budget, before authorisation.
- Streaming. Predictions update continuously as events arrive and depend on recent state (the latest positions).
- Batch. It is a one-off bulk job with no user waiting; run it as a high-throughput offline job, ideally with large batches on GPUs.
- Exercise 2Core
A model service must handle 120 requests per second at peak. The mean latency per request is 0.2 s, and one replica serves 6 concurrent requests within the latency SLO. Using a 70% target utilisation and one extra replica for resilience, how many replicas should you plan for?
Show solution
Little's law: L = λW = 120 × 0.2 = 24 requests in flight.
Capacity per replica at 70% utilisation: 6 × 0.7 = 4.2.
Replicas = ⌈24 / 4.2⌉ = ⌈5.71⌉ = 6.
Add one for resilience: 7 replicas, with the autoscaler allowed to go higher if traffic exceeds forecast.
- Exercise 3Core
Write a Python function
parity_check(torch_model, onnx_path, n=256, atol=1e-4)that feeds the same random batch of shape(n, 20)to a PyTorch model and to an ONNX Runtime session whose input is namedx, and returns the maximum absolute difference, raising anAssertionErrorif it exceedsatol.Show solution
import numpy as np, torch, onnxruntime as ort def parity_check(torch_model, onnx_path, n=256, atol=1e-4): torch_model.eval() x = torch.randn(n, 20) with torch.no_grad(): ref = torch_model(x).numpy() sess = ort.InferenceSession(onnx_path, providers=["CPUExecutionProvider"]) out = sess.run(None, {"x": x.numpy()})[0] max_diff = float(np.max(np.abs(out - ref))) assert max_diff <= atol, f"parity failed: max diff {max_diff:.2e} > {atol}" return max_diffNotes:
eval()disables dropout and uses running batch-norm statistics, so both runs are deterministic.torch.no_grad()avoids building an autograd graph. In CI you should also run the check on a sample of recorded production inputs, because random Gaussian inputs may not exercise edge cases (very large values, zeros, padding). - Exercise 4Challenge
Your baseline click-through rate is 4%. Product wants to detect an absolute lift of 0.4 percentage points (to 4.4%) with α = 0.05 two-sided and 80% power. (a) Estimate the users needed per arm with . (b) With 10,000 eligible users per day split evenly, how many days must the test run? (c) Name two reasons not to stop the test on day 3 even if the result already looks significant.
Show solution
(a) ; squared = 7.85.
.
Numerator: .
.
users per arm.
(b) 5,000 users per arm per day → 37,700 / 5,000 ≈ 7.5 days; round up to 8 days, and in practice run two full weeks (14 days) to cover weekday effects evenly.
(c) (1) Repeatedly checking and stopping at the first significant result inflates the false-positive rate well above 5% unless you use a sequential testing method designed for it. (2) Early days are unrepresentative: novelty effects, weekday mix, and the users who arrive first differ from the overall population. The planned sample size also guarantees the power you designed for.
- Exercise 5Challenge
Design a release plan for replacing a production fraud model (real-time, 99.9% availability SLO, p99 < 250 ms). Specify the stages, the traffic share and duration of each, the metrics that gate each stage, and the rollback trigger and mechanism. Fraud labels (chargebacks) arrive 30–60 days after a transaction.
Show solution
A sound plan:
- Offline gate (CI): parity test of the exported artifact, latency benchmark on production hardware, evaluation on a recent hold-out window, and fairness or segment checks. Fail any gate and the model does not deploy.
- Shadow, 100% mirrored traffic, about 1 week: compare p50/p99 latency, error rate, score distribution (PSI against the current model's scores), and the decline rate the new model would have produced at the chosen threshold. Gate: p99 within SLO, errors ≤ baseline, would-be decline rate within an agreed band (e.g. ±10% relative) unless the change is intended.
- Canary 1% → 5% → 25%, each step at least 24 h: gate on availability, latency, actual decline rate, manual-review queue volume and customer complaints or contacts. The promotion between steps is automated when all gates pass.
- 100% rollout, with the old model kept deployed (blue/green) for at least the first weeks.
- Delayed-label evaluation: because chargebacks take 30–60 days, confirm the true fraud-capture gain later by comparing the arms' outcomes, and use proxy signals meanwhile (manual-review confirmations, early fraud reports).
Rollback trigger: any SLO breach for 5 minutes, decline rate outside the band, or review-queue growth beyond a threshold. Mechanism: a router or traffic-split switch back to the old version (automatic for SLO breaches), followed by an incident review.
Further reading
- paperSculley et al., Hidden Technical Debt in Machine Learning Systems (NeurIPS 2015)
- bookChip Huyen, Designing Machine Learning Systems (O'Reilly, 2022)
- docsPyTorch documentation: torch.export
- docsONNX: Open Neural Network Exchange
- docsKubernetes documentation: Schedule GPUs
- paperHan, Mao & Dally, Deep Compression (2015)
Go further
Sources
- Hinton, Vinyals & Dean, Distilling the Knowledge in a Neural Network (2015)
- Hugging Face safetensors documentation
- ONNX Runtime documentation
- PyTorch dev-discuss: torch.load flipped to weights_only=True by default (PyTorch 2.6)
- PyTorch release notes: torch.onnx.export default switches to dynamo=True in 2.9; TorchScript nearing end of life
- Google Developers Blog, TensorFlow Lite is now LiteRT (Sept 2024)
Fact-checked and last reviewed 2026-11-05