Serve an ML Model on CPU: ONNX Export, FastAPI, Dynamic Batching and a p95 Latency Budget
Hands-on lab · IDE in your browser

Serve an ML Model on CPU: ONNX Export, FastAPI, Dynamic Batching and a p95 Latency Budget

Take a trained PyTorch image classifier to production on one CPU core.

Time
60 min
Checked steps
5
Level
Intermediate
Setup
None
Read step 1

Hands-on labs require Pro · $29.99/mo · cancel anytime

Lab cockpit60 min · 5 stepsSession running
0 / 5 steps passingExport to ONNX · step 1 of 5
serve.py▶ Run✓ Check
TILE_BYTES = 64 * 64 * 3MAX_TILES = 32  # ---------- Step 1: export ----------def export_onnx(model, path=ONNX_PATH):    """Export the model to ONNX with torch.onnx.export(..., dynamo=False): one input named "tiles" (N, 3, 64, 64)    float32, one output named "logits"; the batch dimension must be dynamic (dynamic_axes) so one file serves any    batch size."""     def max_difference(model, session, x):    """The largest absolute difference between the PyTorch model's logits and the ONNX session's on x    (a float32 tensor), as a float."""    
TerminalOutput

The job

Harrow County's land-use classifier works in a notebook. The planning office's web app now needs it as a service: many small requests, one CPU core, and a promise of answers within 100 ms. You make it fast, safe to call and honest about its latency.

5 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Export to ONNX

    Harrow County's land-use classifier works in a notebook.

    You writeexport_onnx()
  2. 2

    A fast runtime

    The server does the same work as the notebook, just leaner: numpy preprocessing, one ONNX Runtime session pinned to one thread.

    You writepreprocess()
  3. 3

    An HTTP API

    The app sends tiles as base64 text: each is the 12,288 raw bytes of a 64x64 RGB image.

    You writedecode_tile()
  4. 4

    Dynamic batching

    Each model call has a fixed overhead, so 16 tiles in one call cost far less than 16 calls.

    You writeMicroBatcher.submit()
  5. 5

    A latency budget

    The promise is a p95 under 100 ms: 95 out of 100 answers within 100 ms.

    You writepercentile()

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Export to ONNX

Harrow County's land-use classifier works in a notebook. Now it needs to answer the planning office's web app: many small requests, one CPU core, and a promise to reply in under 100 ms. A training framework is the wrong thing to serve from. ONNX is a file format that holds the network's graph and weights, and ONNX Runtime executes it without PyTorch or your model code.

Two things matter in the export. The input and output need stable names, since the server calls them by name. The batch dimension must be dynamic, or the file only accepts the batch size it was exported with.

Do this

1. Write export_onnx() and max_difference() in serve.py. model.py builds the trained model for you.

2. Run it. The export must agree with PyTorch to about 1e-6.

serve.py, the file you edit124 lines
"""Serving Harrow County's tile classifier on one CPU core: ONNX export, a fast runtime, an HTTP API, dynamic
batching and a latency budget."""
import asyncio
import base64
import binascii
import random
import time

import numpy as np
import onnxruntime as ort
import torch
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel

from model import MEAN, STD

ONNX_PATH = "tile_model.onnx"
TILE_BYTES = 64 * 64 * 3
MAX_TILES = 32


# ---------- Step 1: export ----------
def export_onnx(model, path=ONNX_PATH):
    """Export the model to ONNX with torch.onnx.export(..., dynamo=False): one input named "tiles" (N, 3, 64, 64)
    float32, one output named "logits"; the batch dimension must be dynamic (dynamic_axes) so one file serves any
    batch size."""
    # TODO (Step 1): torch.onnx.export(model, (example,), path, input_names=..., output_names=...,
    # dynamic_axes=..., dynamo=False) with a (1, 3, 64, 64) example; return path.
    raise NotImplementedError("Step 1: write export_onnx()")


def max_difference(model, session, x):
    """The largest absolute difference between the PyTorch model's logits and the ONNX session's on x
    (a float32 tensor), as a float."""
    # TODO (Step 1): run both on x and return the largest absolute difference as a float.
    raise NotImplementedError("Step 1: write max_difference()")


# ---------- Step 2: a fast runtime ----------
def preprocess(tiles):
    """uint8 (n, 64, 64, 3) -> float32 (n, 3, 64, 64): / 255, minus MEAN, divided by STD, channels first,
    contiguous. numpy only: the server does not need torch."""
    raise NotImplementedError("preprocess() arrives in Step 2")


class Classifier:
    """ONNX Runtime session on one thread, plus the class names."""

    def __init__(self, path, classes):
        self.classes = classes
        raise NotImplementedError("Classifier.__init__() arrives in Step 2")

    def predict(self, tiles):
        """[{"label": class name, "confidence": softmax probability of that class}] for every tile (uint8)."""
        raise NotImplementedError("Classifier.predict() arrives in Step 2")


def per_tile_ms(fn, tiles, repeats=20):
    """Median milliseconds per tile of fn(tiles) over `repeats` timed calls, after 2 untimed warm-up calls."""
    raise NotImplementedError("per_tile_ms() arrives in Step 2")


# ---------- Step 3: an HTTP API ----------
class PredictRequest(BaseModel):
    tiles: list[str]      # each a base64 string of the tile's 12,288 raw RGB bytes (64 x 64 x 3, row by row)


def decode_tile(b64):
    """The (64, 64, 3) uint8 array for one base64 tile. Raises ValueError if it is not valid base64 or not
    exactly TILE_BYTES bytes."""
    raise NotImplementedError("decode_tile() arrives in Step 3")


def create_app(classifier, batcher=None):
    """GET /health -> {"status": "ok", "classes": [...]}.
    POST /predict with PredictRequest -> {"predictions": [...]} (one per tile, from classifier.predict, or from
    batcher.submit for each tile when a batcher is given). Answer 422 with a helpful "detail" when there are no
    tiles, more than MAX_TILES, or a tile that decode_tile() rejects (say which one)."""
    raise NotImplementedError("create_app() arrives in Step 3")


# ---------- Step 4: dynamic batching ----------
class MicroBatcher:
    """Collects single tiles from many concurrent requests into batches: the worker takes the first waiting tile,
    then keeps taking more until it has max_batch or max_wait_ms has passed since the first, runs ONE
    classifier.predict() on the whole batch (in a thread, so new requests keep arriving), and hands every caller
    its own result. If predict() raises, every caller in that batch gets the exception."""

    def __init__(self, classifier, max_batch=16, max_wait_ms=2.0):
        self.classifier, self.max_batch, self.max_wait = classifier, max_batch, max_wait_ms / 1000
        self.queue, self.worker, self.batch_sizes = None, None, []

    async def submit(self, tile):
        """The prediction for one tile, once its batch has run. Starts the worker on first use."""
        raise NotImplementedError("MicroBatcher.submit() arrives in Step 4")

    async def _run(self):
        raise NotImplementedError("MicroBatcher._run() arrives in Step 4")

    def stop(self):
        if self.worker:
            self.worker.cancel()


# ---------- Step 5: a latency budget ----------
def percentile(values, q):
    """The q-th percentile (0-100) of values, linear interpolation (numpy's default), as a float."""
    raise NotImplementedError("percentile() arrives in Step 5")


async def load_test(batcher, tile, rate, n, seed=0):
    """Send n requests of one tile as a Poisson stream at `rate` per second: arrival times are running sums of
    random.Random(seed).expovariate(rate). Every 5 ms, start every request that is due, each as its own task that
    measures from its start to its answer. Returns {"p50", "p95" (ms), "throughput" (answers per second)}."""
    raise NotImplementedError("load_test() arrives in Step 5")


def pick_config(results, budget_ms):
    """results: {(max_batch, max_wait_ms): {"quiet": stats, "peak": stats}}. Among the configurations whose p95
    is within budget_ms under BOTH loads, the one with the lowest peak p95. None if no configuration fits."""
    raise NotImplementedError("pick_config() arrives in Step 5")


CONFIG = {"max_batch": 1, "max_wait_ms": 0}
Provided for you:model.pytry_it.py

Frequently asked questions

Why export a PyTorch model to ONNX for serving?

ONNX packages the graph and weights in one portable file, and ONNX Runtime executes it without PyTorch or your model code. On CPUs it is often considerably faster for inference, and the server image gets much smaller.

What is dynamic batching in model serving?

The server holds incoming requests for a few milliseconds and runs them through the model together. Each call has a fixed overhead, so batching raises throughput, at the cost of a small, bounded wait per request.

Why measure p95 latency instead of the average?

Users notice the slow requests. The 95th percentile says how long almost everyone waits, and it shows queueing delays under load that an average hides.

From a PyTorch model to a fast CPU service

Serving a model is its own discipline: portable formats, lean runtimes, input validation, batching and latency percentiles under realistic load. In this lab you export a ResNet-based classifier to ONNX and verify the export, serve it with ONNX Runtime and FastAPI, build a dynamic micro-batcher with asyncio, and load-test with Poisson arrivals to choose batch size and wait time against a p95 budget.