Serving ML Models

Reviewed & published by Brayan K

Turn a trained model into a reliable production service — REST and gRPC inference endpoints, request batching, autoscaling, model versioning, and safe rollouts with canary and A/B deploys.

Part of the free AI & Machine Learning course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

🍽️ Real-World Analogy: A Restaurant Kitchen

A trained model in a notebook is like a chef who only cooks at home. Serving it is opening a restaurant: the same cooking skill, but now you must take orders, cook many at once, and never keep a table waiting.

The whole lesson is about running that kitchen well: fast tickets, full grills, enough cooks, and a safe way to change the menu.

1 Inference Endpoints — REST and gRPC

Serving means putting your model behind a network address so other programs send an input and get a prediction back. The address is an inference endpoint.

A common setup is REST on the edge for callers, gRPC between internal services. Whichever you pick, two endpoints are non-negotiable: a /predict for inference and a /health the load balancer can poll.

Worked example — a minimal REST endpoint with FastAPI:

# A minimal REST inference endpoint with FastAPI.
# Run with:  uvicorn app:app --host 0.0.0.0 --port 8000

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

# Load the model ONCE at startup, not inside the handler.
# 'model' lives for the life of the process and is reused by every request.
model = None

@app.on_event("startup")
def load_model():
    global model
    # In real code: model = torch.load("model.pt"); model.eval()
    model = lambda text: {"label": "positive", "score": 0.97}
    # Warmup: run one dummy inference so the first real request is fast.
    model("warmup")

# Pydantic validates the request body and rejects bad input early.
class PredictRequest(BaseModel):
    text: str

class PredictResponse(BaseModel):
    label: str
    confidence: float

@app.post("/predict", response_model=PredictResponse)
def predict(req: PredictRequest):
    out = model(req.text)                 # reuse the loaded model
    return PredictResponse(label=out["label"], confidence=out["score"])

@app.get("/health")                       # used by the load balancer
def health():
    return {"status": "ok", "model_loaded": model is not None}

# Expected output (POST /predict with {"text": "great movie"}):
#   {"label": "positive", "confidence": 0.97}
# Expected output (GET /health):
#   {"status": "ok", "model_loaded": true}

Notice the model loads once at startup and is reused by every request. The /health route is how the kitchen tells the maitre d' (load balancer) it is ready for orders.

2 Serving Frameworks — Don't Build the Plumbing Yourself

FastAPI is great for one model, but a dedicated serving framework gives you batching, model versioning, multi-model hosting, and GPU scheduling for free. Pick by your stack:

FrameworkBuilt forReach for it when…
TensorFlow ServingTensorFlowYou have SavedModel files and want versioned TF serving
TorchServePyTorchYou have PyTorch models and want built-in dynamic batching
TritonAny frameworkMulti-model, multi-GPU, mixed TF/PyTorch/ONNX on one server
BentoMLAny frameworkYou want to package model + code + deps as one deployable unit

3 Request Batching, Latency vs Throughput

A GPU runs a batch of 8 inputs almost as fast as a single one. Request batching groups incoming requests into one forward pass, so throughput (requests per second) shoots up. The cost: a request waits a few milliseconds for the batch to fill, so its latency rises slightly. Frameworks expose two dials — max batch size and max wait — to balance the two.

You measure latency in percentiles, not averages. p50 is the median request; p99 is the slow tail — 1 in 100 requests is at least this slow. Users feel the tail, so p99 is the number that matters for an SLA.

Worked example — the batching loop a framework runs for you:

# Why serving frameworks batch requests: one GPU pass handles many inputs.
# This sketches the loop TorchServe / Triton / vLLM run for you.

import time

MAX_BATCH = 8        # never let a batch grow unbounded
MAX_WAIT_MS = 5      # don't make request #1 wait too long for a full batch

def run_batch(texts):
    # One forward pass over the whole batch — ~same cost as a single item.
    return ["positive"] * len(texts)

def serve(queue):
    results = []
    start = time.time()
    batch = []
    for item in queue:                         # incoming requests
        batch.append(item)
        waited_ms = (time.time() - start) * 1000
        # Flush when the batch is full OR we've waited long enough.
        if len(batch) >= MAX_BATCH or waited_ms >= MAX_WAIT_MS:
            results += run_batch(batch)         # 1 GPU pass for the whole batch
            batch = []
            start = time.time()
    if batch:
        results += run_batch(batch)             # flush the leftovers
    return results

print(serve(["a", "b", "c"]))

# Expected output:
#   ['positive', 'positive', 'positive']

The batch flushes when it is full or the wait timer fires — that timer is what keeps p99 from exploding under light traffic.

▶️ Worked Example: Latency Percentiles (run it)

Run this to see how one slow request pulls p99 far above p50, even when most requests are fast. Read the comments, then press run.

# Worked example: measure serving latency from a list of timings.
# Latency is usually reported as percentiles, not an average, because a
# few slow requests (the "tail") matter most to real users.

def percentile(times, p):
    # p is a fraction, e.g. 0.50 for p50 (median), 0.99 for p99.
    ordered = sorted(times)                  # sort smallest -> largest
    k = int(round((len(ordered) - 1) * p))   # index for that percentile
    return ordered[k]

# Latency of 10 requests, in milliseconds.
latencies_ms = [12, 14, 11, 13, 15, 12, 90, 13, 14, 12]

p50 = percentile(latencies_ms, 0.50)
p99 = percentile(latencies_ms, 0.99)

print("requests:", len(latencies_ms))
print("p50 (median):", p50, "ms")
print("p99 (tail):  ", p99, "ms")

# Expected output:
#   requests: 10
#   p50 (median): 13 ms
#   p99 (tail):   90 ms

🎯 Your Turn #1: Batch the Requests

Fill in the two blanks marked ___. Group 7 requests into batches of 4 and count how many GPU passes that takes. Check your output against the # ✅ Expected output comment.

# 🎯 YOUR TURN #1 — simulate request batching
# A GPU pass costs 8ms no matter how many items are in the batch (up to 8).
# Group the incoming requests into batches, then count the GPU passes.

PASS_MS = 8          # cost of one GPU forward pass
MAX_BATCH = ___      # 👉 set the max batch size to 4

requests = ["r1", "r2", "r3", "r4", "r5", "r6", "r7"]

batches = 0
i = 0
while i < len(requests):
    batch = requests[i:i + MAX_BATCH]   # take up to MAX_BATCH requests
    batches += ___                      # 👉 each loop runs exactly ONE pass, so add 1
    i += MAX_BATCH

total_ms = batches * PASS_MS

print("requests:", len(requests))
print("batches:", batches)
print("total GPU time:", total_ms, "ms")

# ✅ Expected output:
#   requests: 7
#   batches: 2
#   total GPU time: 16 ms

🎯 Your Turn #2: Measure the Tail

Fill in the two blanks so percentile() sorts the timings and returns the right value. A single 150ms request should send p99 sky-high while p50 stays calm.

# 🎯 YOUR TURN #2 — compute p50 and p99 latency
# Sort the timings and pick the value at each percentile index.

def percentile(times, p):
    ordered = ___                            # 👉 sort the list smallest -> largest
    k = int(round((len(ordered) - 1) * p))
    return ordered[k]

latencies_ms = [20, 22, 19, 21, 23, 20, 150, 21, 22, 20]

p50 = percentile(latencies_ms, ___)          # 👉 use 0.50 for the median
p99 = percentile(latencies_ms, 0.99)

print("p50:", p50, "ms")
print("p99:", p99, "ms")

# ✅ Expected output:
#   p50: 21 ms
#   p99: 150 ms

4 Autoscaling and Cold Starts

Autoscaling adds or removes server replicas as traffic changes — more cooks at the dinner rush, fewer at 3am. You scale on a signal like GPU utilisation, queue depth, or requests per second.

The catch is the cold start: a freshly added replica is slow on its first request because the model still has to load into memory and the GPU warm up. Two fixes: keep a minimum number of replicas always running, and run a warmup inference before the replica accepts traffic (you saw the warmup call in Section 1).

5 Model Versioning, Canary & A/B Deploys

Never overwrite a live model. Give every model a version (v1, v2, …) so you can serve a specific one, compare them, and roll back instantly if v2 misbehaves.

Trial the new recipe on a few tables (canary), or serve two recipes to see which sells better (A/B) — either way, the old recipe is one switch away.

Common Errors (And How to Fix Them)

These five mistakes sink most first serving deployments:

❌ No batching — wasting the GPU

One request per forward pass leaves the GPU 90% idle and caps your throughput.

✅ Fix: enable dynamic batching (set max batch size + max wait), or use a framework that does it for you.

❌ Cold starts on every request

Loading the model inside the handler reloads 500MB+ from disk per call — 10s latencies.

✅ Fix: load once at startup, run a warmup inference, and keep a minimum replica count.

❌ No versioning — can't roll back

Overwriting the live model means a bad deploy has no undo and no way to compare.

✅ Fix: tag every model with a version and deploy via canary so rollback is one switch.

❌ Blocking I/O on the request path

A synchronous DB or network call inside the handler stalls the whole worker under load.

✅ Fix: use async handlers, move slow work off the hot path, and set request timeouts.

❌ Unbounded queues — silent meltdown

An infinite request queue hides overload: latency climbs forever and memory blows up instead of failing fast.

✅ Fix: cap the queue length and reject extra requests with HTTP 429 so callers can back off.

📋 Quick Reference

ConceptWhat it isWhy it matters
REST vs gRPCJSON over HTTP vs binary RPCREST to debug, gRPC for speed
BatchingGroup requests into one pass5–10× throughput
Latency (p50/p99)Median vs slow-tail waitp99 is what users feel
ThroughputRequests served per secondCapacity of the service
AutoscalingAdd/remove replicas on loadMatch cost to demand
Cold startSlow first request after bootWarmup + min replicas fix it
VersioningTag every model v1, v2…Compare and roll back
Canary / A/BSlow rollout vs split testSafe change vs measured change

🎯 Mini-Challenge: SLA Checker

Now write it yourself with only a comment outline. Build a tiny SLA checker that flags when your p99 latency breaches a 200ms budget. The starter has just the steps — no filled-in logic.

# 🎯 MINI-CHALLENGE: a tiny SLA checker for your serving endpoint
# 1. Make a list of latencies (ms): [30, 28, 31, 29, 32, 30, 220, 29, 31, 30]
# 2. Write a percentile(times, p) helper (sort, then index at (n-1)*p rounded)
# 3. Compute p99
# 4. Set a budget: SLA_MS = 200
# 5. Print "BREACH" if p99 > SLA_MS, otherwise print "OK", plus the p99 value
#
# ✅ Expected output:
#   p99: 220 ms -> BREACH

# your code here

Lesson complete — you can serve a model in production!

You can expose a model over REST or gRPC, pick a serving framework, batch requests for throughput, read p50/p99 latency, autoscale without cold starts, and roll out new versions safely with canary and A/B deploys.

Practice quiz

What is model serving?

  • Training a model on more data
  • Compressing a model to fewer bits
  • Wrapping a trained model behind a network endpoint so other programs can get predictions
  • Cleaning the training data

Answer: Wrapping a trained model behind a network endpoint so other programs can get predictions. Serving exposes a trained model over a network (REST or gRPC) so clients can send inputs and receive predictions.

When is REST/JSON usually preferred over gRPC for inference?

  • When you want something easy to debug and call from any client, including browsers
  • When you need the lowest possible latency for tensors
  • When callers are only other internal services
  • When payloads are large binary blobs

Answer: When you want something easy to debug and call from any client, including browsers. REST/JSON is human-readable and easy to debug or call from a browser; gRPC suits fast internal service-to-service calls.

Why does request batching improve serving throughput?

  • It reduces model accuracy
  • It loads the model faster
  • It removes the need for a health check
  • A GPU runs a batch almost as fast as a single item, so grouping requests raises requests per second

Answer: A GPU runs a batch almost as fast as a single item, so grouping requests raises requests per second. Grouping many inputs into one forward pass uses the GPU efficiently, dramatically increasing throughput.

What is the main trade-off introduced by request batching?

  • Lower accuracy
  • A request may wait a few milliseconds for the batch to fill, raising its latency slightly
  • Higher memory usage forever
  • It disables versioning

Answer: A request may wait a few milliseconds for the batch to fill, raising its latency slightly. Batching raises throughput but a request waits for the batch to fill, slightly increasing per-request latency.

Why report latency as p50 and p99 percentiles instead of an average?

  • The slow tail (p99) is what users feel, and an average hides it
  • Averages are impossible to compute
  • Percentiles are always lower
  • p99 ignores slow requests

Answer: The slow tail (p99) is what users feel, and an average hides it. A few slow requests (the tail) matter most to users; p99 captures that tail while an average can hide it.

What is a cold start in model serving?

  • A request that returns an error
  • A request sent to the wrong version
  • The slow first request after a server boots, because the model must load and the GPU warm up
  • A batch that never fills

Answer: The slow first request after a server boots, because the model must load and the GPU warm up. A cold start is the slow first request after boot or scale-up while the model loads into memory.

Which practices help avoid cold starts?

  • Load the model inside every request handler
  • Load the model once at startup, run a warmup inference, and keep a minimum number of replicas
  • Scale to zero replicas always
  • Disable health checks

Answer: Load the model once at startup, run a warmup inference, and keep a minimum number of replicas. Loading once, warming up, and keeping minimum replicas running prevents the slow first-request penalty.

What does autoscaling do?

  • Versions the model automatically
  • Compresses the model
  • Batches requests
  • Adds or removes server replicas as traffic changes

Answer: Adds or removes server replicas as traffic changes. Autoscaling adjusts the number of replicas based on a signal like GPU utilisation or requests per second.

What is the difference between a canary deploy and an A/B deploy?

  • Canary measures which model is better; A/B is a safety check
  • Canary sends a small slice of traffic to a new version as a safety check; A/B splits traffic to measure which performs better
  • They are identical
  • Canary requires no rollback plan

Answer: Canary sends a small slice of traffic to a new version as a safety check; A/B splits traffic to measure which performs better. A canary is a safety mechanism (small slice, check health); an A/B deploy is a measurement mechanism comparing versions.

Why give every model a version when serving?

  • To make the model smaller
  • To increase latency
  • So you can serve a specific version, compare versions, and roll back instantly if a new one misbehaves
  • Because REST requires it

Answer: So you can serve a specific version, compare versions, and roll back instantly if a new one misbehaves. Versioning lets you compare models and instantly roll back a bad deploy instead of overwriting the live model.

Continue this course

Frequently asked questions

What is model serving?

Model serving is wrapping a trained model behind a network endpoint (usually REST or gRPC) so other programs can send inputs and get predictions back over the network, instead of calling the model from inside a notebook.

REST or gRPC for inference?

Use REST/JSON when you want something easy to debug and call from any client, including browsers. Use gRPC when you need lower latency and smaller payloads for binary data like tensors, and your callers are other services. Many teams expose REST publicly and gRPC internally.

Why does request batching make serving faster?

A GPU runs one batch almost as fast as one item, so grouping many small requests into a single forward pass dramatically raises throughput (requests per second). The trade-off is that a request may wait a few milliseconds for the batch to fill, so latency per request rises slightly.

What is a cold start and how do I avoid it?

A cold start is the slow first request after a server boots or scales up, because the model still has to load into memory and the GPU has to warm up. Avoid it by loading the model once at startup and running a dummy 'warmup' inference before accepting traffic, and by keeping a minimum number of replicas always running.

What is the difference between a canary and an A/B deploy?

A canary sends a small slice of traffic (say 5%) to a new model version to check it is healthy before rolling it out to everyone, so it is a safety mechanism. An A/B test deliberately splits traffic between versions to measure which performs better on a metric, so it is a measurement mechanism.

Should I build serving myself or use a framework?

Start with FastAPI when you have a single model and want full control. Move to a dedicated framework (TorchServe, TensorFlow Serving, Triton, or BentoML) once you need built-in batching, model versioning, multi-model hosting, or GPU scheduling without writing that plumbing yourself.