Skip to content
Chapter 1 of 8

Serving models: latency and throughput

A model that works in a notebook is not a service. A service takes traffic, and the first thing traffic teaches you is the difference between latency and throughput. Latency is how long one request waits. Throughput is how many requests you clear per second. They are not the same lever, and the biggest one for throughput is batching. The expensive part of an inference step is the fixed overhead of firing up the compute and touching the weights, and you pay that overhead once per forward pass whether the pass carries one request or thirty-two. Serve requests one at a time and you pay the overhead on every single one. Gather the requests that arrive close together, run them in one pass, and the fixed cost is now shared across the whole batch. In this lab you serve the same 256 requests two ways, one per pass and batched, and watch throughput jump by a large factor for the exact same work.

The lab: read it, then run it

labs/production/pr1-serving-batching.py
#!/usr/bin/env python3
"""
LAB PR1: Serving models. Latency and throughput through batching.

A model served one request at a time wastes the hardware. The expensive part of
inference is loading the weights and firing up the compute; whether you push one
request or thirty-two through that machinery, the fixed overhead is paid once.
Batching is the single biggest throughput lever in serving: gather requests that
arrive close together, run them in ONE forward pass, and the per-request cost
falls because the fixed overhead is now shared across the whole batch.

This lab is a deterministic serving simulator. It models a request's service
time as a fixed per-batch overhead (kernel launch, weight access) plus a small
marginal cost per request in the batch. It serves the same workload two ways,
unbatched (one request per pass) and batched (up to B per pass), and proves the
batched server clears the same work at markedly higher throughput.

Run: python3 modules/academy-content/labs/production/pr1-serving-batching.py
"""
import sys

# ---- the cost model (deterministic, no wall-clock, so it is reproducible) ----
# Each forward pass costs a FIXED overhead plus a MARGINAL cost per request in it.
# This is the real shape of inference: overhead dominates at batch size 1.
OVERHEAD_MS = 20.0     # fixed cost paid once per forward pass, regardless of size
MARGINAL_MS = 2.0      # extra cost each additional request in the batch adds
N_REQUESTS = 256       # the workload: this many requests must be served
BATCH = 32             # the batched server groups up to this many per pass


def serve(n_requests, batch_size):
    """Return (total_ms, throughput_rps) for serving n_requests in passes of
    at most batch_size. One pass costs OVERHEAD_MS + batch * MARGINAL_MS."""
    total_ms = 0.0
    remaining = n_requests
    passes = 0
    while remaining > 0:
        b = min(batch_size, remaining)
        total_ms += OVERHEAD_MS + b * MARGINAL_MS
        remaining -= b
        passes += 1
    throughput = n_requests / (total_ms / 1000.0)  # requests per second
    return total_ms, throughput, passes


def main():
    un_ms, un_rps, un_passes = serve(N_REQUESTS, 1)
    b_ms, b_rps, b_passes = serve(N_REQUESTS, BATCH)

    print("STEP 1: serve %d requests UNBATCHED (one per forward pass)" % N_REQUESTS)
    print("  forward passes : %d" % un_passes)
    print("  total time     : %.0f ms" % un_ms)
    print("  throughput     : %.1f req/s" % un_rps)

    print("")
    print("STEP 2: serve the same %d requests BATCHED (up to %d per pass)"
          % (N_REQUESTS, BATCH))
    print("  forward passes : %d" % b_passes)
    print("  total time     : %.0f ms" % b_ms)
    print("  throughput     : %.1f req/s" % b_rps)

    speedup = b_rps / un_rps
    print("")
    print("STEP 3: batching shares the fixed per-pass overhead across the batch")
    print("  throughput gain: %.1fx" % speedup)

    # Proof: batching clears the SAME workload at meaningfully higher throughput.
    # The gain must be real (well above 1x) and the request count must be equal.
    same_work = (N_REQUESTS == N_REQUESTS)
    real_gain = speedup > 2.0
    ok = same_work and real_gain
    print("")
    print("BATCHING RAISED THROUGHPUT (same work, fewer passes): %s"
          % ("YES" if ok else "NO"))
    if not ok:
        sys.exit(1)
    print("Batching is the first serving lever. Next: quantization to cut the cost per pass.")


if __name__ == "__main__":
    main()
Runnable lab
pr1-serving-batching.py

Serve the same workload unbatched and batched, and prove batching raises throughput for identical work.

Proves: BATCHING RAISED THROUGHPUT (same work, fewer passes): YES

Open the notebook

Runs in your browser via Pyodide. First run loads the runtime once; no install, no server.

Same 256 requests, a fraction of the passes, several times the throughput. That is the whole reason production serving stacks like vLLM and TGI are built around continuous batching: they group in-flight requests so the hardware is never doing overhead-only work for a single caller. Two things to carry. Batching trades a little latency (a request may wait a beat for others to join the batch) for a lot of throughput, and you tune that tradeoff to your traffic. And throughput is what your bill and your capacity are measured in, so it is the number to optimize first. Next you cut the cost of each pass itself with quantization.
Check your understanding
  1. 1. What is the difference between latency and throughput?

  2. 2. Why does batching raise throughput?

  3. 3. What does batching trade away to gain throughput?